如何将IFileFormCollection中的多个上载文件链接到相应的复杂模型字段?

问题描述 投票:0回答:1

我需要实现用于服务器到服务器通信的API,该API将以与某些JSON数据相同的请求发送文件,以确保原子性并避免保存没有相关数据的文件,反之亦然。

我找到了与JSON一起上传单个文件的解决方案:https://thomaslevesque.com/2018/09/04/handling-multipart-requests-with-json-and-file-uploads-in-asp-net-core/

但是问题是我的JSON模型更复杂。一个简化的示例,尝试涵盖我希望看到的所有情况:

class RootModel
{
    public string SomeField { get; set; }
    public IList<ChildModel> FilesWithDescriptions { get; set; }
    public IFormFile MainFile { get; set; }
    public IFormFile SomeOtherFile { get; set; }
}

class ChildModel
{
    public string FileDescription { get; set; }
    public IFormFileCollection SomeNestedFiles { get; set; }
}

MainFileSomeOtherFile已正确绑定,但问题出在FilesWithDescriptions -> SomeNestedFiles集合中-SomeNestedFiles始终为null。

我在邮递员中尝试了以下内容

FilesWithDescriptions [0] SomeNestedFiles

FilesWithDescriptions [0] .SomeNestedFiles

但FormFileModelBinder仍未设置SomeNestedFiles。不知道这是因为我以错误的格式传递了字段名称,还是因为FormFileModelBinder没有在模型内部递归,而我将不得不自己实现递归。将不得不查看FormFileModelBinder源代码。

如何实现它以保持每个上载文件与嵌套模型集合字段之间的正确关联?

c# json asp.net-core file-upload multipartform-data
1个回答
0
投票

我有点“放弃”,并实现了将我自己的hacky机制集成到自定义模型绑定程序中。它遍历反序列化的JSON对象以找到各种IFormFile,然后尝试通过匹配anyCase名称并使用递归路径访问属性和集合来从Request中提取它们。


    // partially borrowed from
    // https://thomaslevesque.com/2018/09/04/handling-multipart-requests-with-json-and-file-uploads-in-asp-net-core/
    public class JsonWithFilesFormDataModelBinder : IModelBinder
    {
        // code from FormFileModelBuilder
        private class FileCollection : ReadOnlyCollection<IFormFile>, IFormFileCollection
        {
            public FileCollection(List<IFormFile> list)
                : base(list)
            {
            }

            public IFormFile this[string name] => GetFile(name);

            public IFormFile GetFile(string name)
            {
                for (var i = 0; i < Items.Count; i++)
                {
                    var file = Items[i];
                    if (string.Equals(name, file.Name, StringComparison.OrdinalIgnoreCase))
                    {
                        return file;
                    }
                }

                return null;
            }

            public IReadOnlyList<IFormFile> GetFiles(string name)
            {
                var files = new List<IFormFile>();
                for (var i = 0; i < Items.Count; i++)
                {
                    var file = Items[i];
                    if (string.Equals(name, file.Name, StringComparison.OrdinalIgnoreCase))
                    {
                        files.Add(file);
                    }
                }

                return files;
            }
        }

        private readonly IOptions<MvcJsonOptions> _jsonOptions;

        const string JSON_PART_FIELD_NAME = "json";

        public JsonWithFilesFormDataModelBinder(IOptions<MvcJsonOptions> jsonOptions)
        {
            _jsonOptions = jsonOptions;
        }

        public async Task BindModelAsync(ModelBindingContext bindingContext)
        {
            if (bindingContext == null)
                throw new ArgumentNullException(nameof(bindingContext));

            var request = bindingContext.HttpContext.Request;
            if (!request.HasFormContentType)
                return;

            // Retrieve the form part containing the JSON
            var valueResult = bindingContext.ValueProvider.GetValue(JSON_PART_FIELD_NAME);
            if (valueResult == ValueProviderResult.None)
            {
                // The JSON was not found
                var message = bindingContext.ModelMetadata.ModelBindingMessageProvider.MissingBindRequiredValueAccessor(bindingContext.FieldName);
                bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, message);
                return;
            }

            var rawValue = valueResult.FirstValue;

            // Deserialize the JSON
            var model = JsonConvert.DeserializeObject(rawValue, bindingContext.ModelType, _jsonOptions.Value.SerializerSettings);

            if (model == null)
            {
                bindingContext.Result = ModelBindingResult.Success(model);
                return; // nothing to do
            }

            // could not use FormFileModelBinder because don't know how to recurse into collections
            // doing it manually from request instead

            // collecting all file fields

            // code from FormFileModelBinder
            var form = await request.ReadFormAsync();
            ICollection<IFormFile> postedFiles = new List<IFormFile>();

            foreach (var file in form.Files)
            {
                // If there is an <input type="file" ... /> in the form and is left blank.
                if (file.Length == 0 || string.IsNullOrEmpty(file.FileName))
                {
                    continue;
                }

                postedFiles.Add(file);
            }

            // now recursively step through the deserialized model
            // and fill all the recognized IFormFile and IFormFileCollection fields
            TryAssignFormFiles(model, postedFiles);

            // Set the successfully constructed model as the result of the model binding
            bindingContext.Result = ModelBindingResult.Success(model);
        }

        private void TryAssignFormFiles(object model, ICollection<IFormFile> postedFiles, string path = "")
        {
            // fill all the recognized IFormFile and IFormFileCollection fields

            var props = model.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance);
            foreach (var property in props)
            {
                var pt = property.PropertyType;

                var formFieldPath = path + property.Name;

                var matchingFiles = postedFiles.Where(p => p.Name.Equals(formFieldPath,
                    StringComparison.OrdinalIgnoreCase));

                if (typeof(IFormFile).IsAssignableFrom(pt))
                {
                    if (matchingFiles.Count() != 1)
                    {
                        // ambiguous, cannot process more or zero files for single item
                        continue;
                    }

                    property.SetValue(model, matchingFiles.First());
                    continue;
                }
                else if (typeof(IFormFile[]).IsAssignableFrom(pt))
                {
                    if (matchingFiles.Count() > 0)
                        property.SetValue(model, matchingFiles.ToArray());
                    continue;
                }
                else if (typeof(IList<IFormFile>).IsAssignableFrom(pt))
                {
                    if (matchingFiles.Count() > 0)
                        property.SetValue(model, matchingFiles.ToList());
                    continue;
                }
                else if (typeof(IFormFileCollection).IsAssignableFrom(pt))
                {
                    if (matchingFiles.Count() > 0)
                        property.SetValue(model, new FileCollection(matchingFiles.ToList()));
                    continue;
                }

                // if got here, then field was not a file or a collection of files
                // attempt to recurse deeper

                // is this enumerable? ignore strings that are enumerable chars
                if (!typeof(string).IsAssignableFrom(pt) &&
                    typeof(IEnumerable).IsAssignableFrom(pt))
                {
                    if (!(property.GetValue(model) is IEnumerable ienum))
                        continue;

                    int seq = 0;
                    foreach (var ev in ienum)
                    {
                        TryAssignFormFiles(ev, postedFiles, path + $"{property.Name}[{seq}].");
                        seq++;
                    }
                }
                else // not a collection
                     // ignore primitives and nullable primitives
                if (Nullable.GetUnderlyingType(pt) == null &&
                    !pt.IsValueType && !pt.IsEnum)
                {
                    // some class-like thing, recurse into it
                    // TODO: what about dictionaries that are struct KeyValuePair<TKey, TValue>?
                    // for now, assuming we won't be receiving those in our JSON
                    // because usually dictionary-like objects should be mapped to .NET class properties instead
                    var val = property.GetValue(model);
                    if (val == null)
                        continue;

                    TryAssignFormFiles(val, postedFiles, path + $"{property.Name}.");
                }
            }
        }
    }

测试数据结构:

    public class RootModel
    {
        public string SomeField { get; set; }
        public IList<ChildModel> FilesWithDescriptions { get; set; }
        public IFormFile MainFile { get; set; }
        public IFormFile SomeOtherFile { get; set; }
    }

    public class ChildModel
    {
        public string FileDescription { get; set; }
        public IFormFileCollection SomeNestedFiles { get; set; }
        public IFormFile SomeNestedFile { get; set; }
        public IFormFile[] SomeNestedFilesArray { get; set; }
        public IList<IFormFile> SomeNestedFilesList { get; set; }
    }


测试控制器方法:

        public async Task<ActionResult> AcceptJsonMultipart([ModelBinder(typeof(JsonWithFilesFormDataModelBinder))]RootModel model)

邮递员设置:

Postman setup for multipart JSON with nested files

邮递员中的JSON字段:

{       
    "someField":"hello",
    "filesWithDescriptions":[
        {
            "fileDescription":"a file"
        },
        {
            "fileDescription":"b file"
        }
    ]
}

通常,它可以工作,尽管它牺牲了.NET ModelBinder机制(自定义字段名称,验证器等)。如果有人知道更好的方法,我们非常欢迎您提出一些不太客气的建议。

© www.soinside.com 2019 - 2024. All rights reserved.