c# - 如何将 IFileFormCollection 中的多个上传文件链接到相应的复杂模型字段?
问题描述
我需要实现用于服务器到服务器通信的 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; }
}
MainFile
并SomeOtherFile
正确绑定,但问题在于FilesWithDescriptions -> SomeNestedFiles
集合 -SomeNestedFiles
始终为空。
我在邮递员中尝试了以下内容
FilesWithDescriptions[0]SomeNestedFiles
和
FilesWithDescriptions[0].SomeNestedFiles
但仍然 FormFileModelBinder 没有设置 SomeNestedFiles。不确定,如果那是因为我以错误的格式传递了字段名称,或者 FormFileModelBinder 没有在模型内递归,我必须自己实现递归。将不得不查看 FormFileModelBinder 源代码。
如何实现它以保持每个上传文件和嵌套模型集合字段之间的正确关联?
解决方案
我有点“放弃”并实现了我自己的集成到自定义模型绑定器中的 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 中的 JSON 字段:
{
"someField":"hello",
"filesWithDescriptions":[
{
"fileDescription":"a file"
},
{
"fileDescription":"b file"
}
]
}
一般来说,它可以工作,尽管它牺牲了 .NET ModelBinder 机制(自定义字段名称、验证器等)。如果有人知道更好的方法,非常欢迎您提出一些不那么 hacky 的建议。
推荐阅读
- python - pandas 将多个 groupby 结果放入同一个表中
- ios - 应用程序窗口应该在应用程序启动结束时有一个根视图控制器'
- r - 如何删除字符串中的空字符串(“”)
- node.js - Koa 在发送 postgresql 查询数据时发送“Not Found”
- ajax - 设置数据端点以接收来自 API 的推送数据
- javascript - 如何在 javascript 中将十六进制(缓冲区)转换为 IPv6
- html - 想在 div 元素中居中一个 href 元素
- xcode11 - iOS 13 中的 ATS 策略是否已更改并且允许任意加载现在不起作用?
- java - JDBC 事务未回滚 - setAutoCommit(false)
- delphi - Delphi 数组构造函数替代