【问题标题】:Upload files and JSON in ASP.NET Core Web API在 ASP.NET Core Web API 中上传文件和 JSON
【发布时间】:2017-05-13 01:32:58
【问题描述】:

如何使用分段上传将文件(图像)列表和 json 数据上传到 ASP.NET Core Web API 控制器?

我可以成功接收到文件列表,上传的内容类型为multipart/form-data

public async Task<IActionResult> Upload(IList<IFormFile> files)

当然,我可以使用默认的 JSON 格式化程序成功接收格式化为我的对象的 HTTP 请求正文:

public void Post([FromBody]SomeObject value)

但是我怎样才能将这两者结合在一个控制器动作中呢?如何上传图像和 JSON 数据并将它们绑定到我的对象?

【问题讨论】:

    标签: c# file-upload asp.net-core .net-core


    【解决方案1】:

    简单、代码少、无包装模型

    有一个更简单的解决方案,深受Andrius' answer 的启发。通过使用 ModelBinderAttribute 您不必指定模型或活页夹提供者。这样可以节省很多代码。您的控制器操作如下所示:

    public IActionResult Upload(
        [ModelBinder(BinderType = typeof(JsonModelBinder))] SomeObject value,
        IList<IFormFile> files)
    {
        // Use serialized json object 'value'
        // Use uploaded 'files'
    }
    

    实施

    JsonModelBinder 后面的代码(参见GitHub 或使用NuGet package):

    using System;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Mvc.ModelBinding;
    
    public class JsonModelBinder : IModelBinder {
        public Task BindModelAsync(ModelBindingContext bindingContext) {
            if (bindingContext == null) {
                throw new ArgumentNullException(nameof(bindingContext));
            }
    
            // Check the value sent in
            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
            if (valueProviderResult != ValueProviderResult.None) {
                bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);
    
                // Attempt to convert the input value
                var valueAsString = valueProviderResult.FirstValue;
                var result = Newtonsoft.Json.JsonConvert.DeserializeObject(valueAsString, bindingContext.ModelType);
                if (result != null) {
                    bindingContext.Result = ModelBindingResult.Success(result);
                    return Task.CompletedTask;
                }
            }
    
            return Task.CompletedTask;
        }
    }
    

    示例请求

    以下是上面的控制器操作Upload 接受的原始 http 请求示例。

    multipart/form-data 请求被拆分为多个部分,每个部分由指定的 boundary=12345 分隔。每个部分都在其Content-Disposition-header 中分配了一个名称。使用这些名称默认ASP.Net-Core 知道哪个部分绑定到控制器操作中的哪个参数。

    绑定到IFormFile 的文件还需要在请求的第二部分中指定filenameContent-Type 不是必需的。

    另外需要注意的是,json 部分需要反序列化为控制器操作中定义的参数类型。所以在这种情况下,SomeObject 类型应该具有string 类型的属性key

    POST http://localhost:5000/home/upload HTTP/1.1
    Host: localhost:5000
    Content-Type: multipart/form-data; boundary=12345
    Content-Length: 218
    
    --12345
    Content-Disposition: form-data; name="value"
    
    {"key": "value"}
    --12345
    Content-Disposition: form-data; name="files"; filename="file.txt"
    Content-Type: text/plain
    
    This is a simple text file
    --12345--
    

    使用 Postman 进行测试

    Postman 可用于调用操作并测试您的服务器端代码。这非常简单,主要是 UI 驱动的。创建一个新请求并在 Body-Tab 中选择 form-data。现在您可以为请求的每个部分在 textfile 之间进行选择。

    【讨论】:

    • 很好的解决方案,谢谢!我现在唯一的问题是如何从 Postman 调用 Upload 路由进行集成测试?如何在 JSON 中表示 IFormFile?
    • @PatriceCote 我已经更新了答案。请看一下:)
    • 非常感谢,这正是我想要的。但是,也许一个简单的 FromForm 而不是 FromBody 会完成我猜想的伎俩。
    • 这正是我正在寻找的,但我不知道如何使用 HttpClient 发布请求:/。任何帮助请:)
    • 这不会生成正确的 swagger 模型。并且 json 不会作为正文发送,而是查询参数 ...
    【解决方案2】:

    我在前端使用 Angular 7,所以我使用 FormData 类,它允许您将字符串或 blob 附加到表单。可以使用[FromForm] 属性在控制器操作中将它们从表单中拉出。我将文件添加到FormData 对象,然后将我希望与文件一起发送的数据字符串化,将其附加到FormData 对象,并在我的控制器操作中反序列化字符串。

    像这样:

    //front-end:
    let formData: FormData = new FormData();
    formData.append('File', fileToUpload);
    formData.append('jsonString', JSON.stringify(myObject));
    
    //request using a var of type HttpClient
    http.post(url, formData);
    
    //controller action
    public Upload([FromForm] IFormFile File, [FromForm] string jsonString)
    {
        SomeType myObj = JsonConvert.DeserializeObject<SomeType>(jsonString);
    
        //do stuff with 'File'
        //do stuff with 'myObj'
    }
    

    您现在有了文件和对象的句柄。请注意,您在控制器操作的参数列表中提供的名称​​必须与您在附加到前端的 FormData 对象时提供的名称匹配。

    【讨论】:

    • 如何处理多个文件?
    • @Tzof 看看MDN页面上的last example
    • 比这里的其他例子简单多了。效果很好。每佐夫的?只需对每个具有相同名称的附加文件进行另一个附加。
    【解决方案3】:

    显然没有内置的方法可以做我想做的事。所以我最终写了自己的ModelBinder 来处理这种情况。我没有找到任何关于自定义模型绑定的官方文档,但我使用了this post 作为参考。

    自定义ModelBinder 将搜索用FromJson 属性修饰的属性并将来自多部分请求的字符串反序列化为JSON。我将我的模型包装在另一个具有模型和IFormFile 属性的类(包装器)中。

    IJsonAttribute.cs:

    public interface IJsonAttribute
    {
        object TryConvert(string modelValue, Type targertType, out bool success);
    }
    

    FromJsonAttribute.cs:

    using Newtonsoft.Json;
    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
    public class FromJsonAttribute : Attribute, IJsonAttribute
    {
        public object TryConvert(string modelValue, Type targetType, out bool success)
        {
            var value = JsonConvert.DeserializeObject(modelValue, targetType);
            success = value != null;
            return value;
        }
    }
    

    JsonModelBinderProvider.cs:

    public class JsonModelBinderProvider : IModelBinderProvider
    {
        public IModelBinder GetBinder(ModelBinderProviderContext context)
        {
            if (context == null) throw new ArgumentNullException(nameof(context));
    
            if (context.Metadata.IsComplexType)
            {
                var propName = context.Metadata.PropertyName;
                var propInfo = context.Metadata.ContainerType?.GetProperty(propName);
                if(propName == null || propInfo == null)
                    return null;
                // Look for FromJson attributes
                var attribute = propInfo.GetCustomAttributes(typeof(FromJsonAttribute), false).FirstOrDefault();
                if (attribute != null) 
                    return new JsonModelBinder(context.Metadata.ModelType, attribute as IJsonAttribute);
            }
            return null;
        }
    }
    

    JsonModelBinder.cs:

    public class JsonModelBinder : IModelBinder
    {
        private IJsonAttribute _attribute;
        private Type _targetType;
    
        public JsonModelBinder(Type type, IJsonAttribute attribute)
        {
            if (type == null) throw new ArgumentNullException(nameof(type));
            _attribute = attribute as IJsonAttribute;
            _targetType = type;
        }
    
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            if (bindingContext == null) throw new ArgumentNullException(nameof(bindingContext));
            // Check the value sent in
            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
            if (valueProviderResult != ValueProviderResult.None)
            {
                bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);
                // Attempt to convert the input value
                var valueAsString = valueProviderResult.FirstValue;
                bool success;
                var result = _attribute.TryConvert(valueAsString, _targetType, out success);
                if (success)
                {
                    bindingContext.Result = ModelBindingResult.Success(result);
                    return Task.CompletedTask;
                }
            }
            return Task.CompletedTask;
        }
    }
    

    用法:

    public class MyModelWrapper
    {
        public IList<IFormFile> Files { get; set; }
        [FromJson]
        public MyModel Model { get; set; } // <-- JSON will be deserialized to this object
    }
    
    // Controller action:
    public async Task<IActionResult> Upload(MyModelWrapper modelWrapper)
    {
    }
    
    // Add custom binder provider in Startup.cs ConfigureServices
    services.AddMvc(properties => 
    {
        properties.ModelBinderProviders.Insert(0, new JsonModelBinderProvider());
    });
    

    【讨论】:

    • 我应该使用什么 InputFormatter 来接收作为 multipart/form-data 的数据?如果内容类型是多部分/表单数据,则会出现错误 500。
    • 你救了我的命,Andrius,当然。我花了一整天的时间思考这个问题。我在我的 API 中使用 swagger,当模型中的嵌套对象只有公共类型属性时,这很好。 Swagger 会像这样发送它们:“NestedObject.Id”等等,但是当涉及到数组时 -> 你的 JSON 绑定器是唯一可行的解​​决方案!
    • 感谢您的解决方案。如何使用邮递员向 api 发送请求?我正在使用表单数据发送..但它得到错误 415
    • 谁在使用web api 使用services.AddControllers(o =&gt; o.ModelBinderProviders.Insert(0, new JsonModelBinderProvider())); inside startup.cs ConfigureServices
    【解决方案4】:

    按照@bruno-zell 的出色回答,如果您只有一个文件(我没有使用IList&lt;IFormFile&gt; 进行测试),您也可以将您的控制器声明为:

    public async Task<IActionResult> Create([FromForm] CreateParameters parameters, IFormFile file)
    {
        const string filePath = "./Files/";
        if (file.Length > 0)
        {
            using (var stream = new FileStream($"{filePath}{file.FileName}", FileMode.Create))
            {
                await file.CopyToAsync(stream);
            }
        }
    
        // Save CreateParameters properties to database
        var myThing = _mapper.Map<Models.Thing>(parameters);
    
        myThing.FileName = file.FileName;
    
        _efContext.Things.Add(myThing);
        _efContext.SaveChanges();
    
    
        return Ok(_mapper.Map<SomeObjectReturnDto>(myThing));
    }
    

    然后您可以使用 Bruno 的答案中显示的 Postman 方法来调用您的控制器。

    【讨论】:

    • 这很好,如果尝试执行 HttpClient.PostAsync 调用来上传文件,C# 客户端代码是什么样的?
    • 我想你所要做的就是像同步一样调用它,然后在调用之前添加“await”或在末尾添加“.Result”
    • 我用 [FromForm] 做了一些测试,我得到了一个正确类型的对象,但参数没有设置。
    • 同意@Mukus。文件始终为空
    • 我输入了第一个参数(MyModel 参数,它不起作用。我将其更改为(字符串参数,并且我能够将对象作为 json 获取:)
    【解决方案5】:

    .net 5 的更新版本基于 @bruno-zell 的回答,增加了对多个文件的支持

    using System;
    using System.Collections;
    using System.Text.Json;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Mvc.ModelBinding;
    using Microsoft.Extensions.Options;
    
    public class JsonModelBinder : IModelBinder
    {
        private readonly JsonOptions _jsonOptions;
        public JsonModelBinder(IOptions<JsonOptions> jsonOptions)
        {
            _jsonOptions = jsonOptions.Value;
        }
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            if (bindingContext == null)
            {
                throw new ArgumentNullException(nameof(bindingContext));
            }
    
            // Check the value sent in
            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
            if (valueProviderResult != ValueProviderResult.None)
            {
                bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);
    
                string toSerialize;
                // Attempt to convert the input value
                if (typeof(IEnumerable).IsAssignableFrom(bindingContext.ModelType))
                {
                    toSerialize = "[" + string.Join<string>(',', valueProviderResult.Values) + "]";
                }
                else
                {
                    toSerialize = valueProviderResult.FirstValue;
                }
                var result = JsonSerializer.Deserialize(toSerialize, bindingContext.ModelType, _jsonOptions.JsonSerializerOptions);
                if (result != null)
                {
                    bindingContext.Result = ModelBindingResult.Success(result);
                    return Task.CompletedTask;
                }
            }
    
            return Task.CompletedTask;
        }
    }
    

    【讨论】:

      【解决方案6】:

      我不确定您是否可以一步完成这两件事。

      我过去如何实现这一点是通过 ajax 上传文件并在响应中返回文件 url,然后将其与 post 请求一起传递以保存实际记录。

      【讨论】:

      • 是的,这当然是可能的,但我试图避免为一项任务与服务器建立两个不同的连接,只是为了让客户端和服务器之间的所有内容保持同步。我想我已经找到了解决我的问题的方法。有时间我会在这里发布。
      【解决方案7】:

      我遇到了类似的问题,我通过在函数中使用[FromForm]属性和FileUploadModelView解决了这个问题,如下所示:

      [HttpPost("Save")]
      public async Task<IActionResult> Save([FromForm] ProfileEditViewModel model)
      {          
        return null;
      }
      

      【讨论】:

        【解决方案8】:

        我想使用 Vue 前端和 .net core api 来做同样的事情。但出于某种奇怪的原因,IFormFile 总是返回 null。因此,我不得不将其更改为 IFormCollection 并对其进行整理。这是任何面临相同问题的人的代码:)

        public async Task<IActionResult> Post([FromForm]IFormCollection files)
        

        【讨论】:

          【解决方案9】:

          我在从 Angular 发布到 asp core api 时遇到了类似的问题。

          铬: 表单数据

          ------WebKitFormBoundarydowgB6BX0wiwKeOk
          Content-Disposition: form-data; name="file1"
          
          undefined
          ------WebKitFormBoundarydowgB6BX0wiwKeOk
          Content-Disposition: form-data; name="file2"
          
          undefined
          ------WebKitFormBoundarydowgB6BX0wiwKeOk
          Content-Disposition: form-data; name="reportData"; filename="blob"
          Content-Type: application/json
          
          {"id":2,"report":3,"code":"XX0013","business":"01","name":"Test","description":"Description"}
          ------WebKitFormBoundarydowgB6BX0wiwKeOk--
          

          这是我的做法:

          我使用reportData作为上传的文件数据,然后读取文件的内容。

          [HttpPost]
          public async Task<IActionResult> Set([FromForm] IFormFile file1, [FromForm] IFormFile file2, [FromForm] IFormFile reportData)
          {
              try
              {
                  ReportFormModel.Result result = default;
          
                  if (reportData != null)
                  {
                      string reportJson = await reportData.ReadFormFileAsync();
                      ReportFormModel.Params reportParams = reportJson.JsonToObject<ReportFormModel.Params>();
          
                      if (reportParams != null)
                      {
                          //OK
                      }
                  }
                  return Ok(result);
              }
              catch (Exception ex)
              {
                  return BadRequest();
              }
          }
          
          
          public static class Utilities
          {
              public static async Task<string> ReadFormFileAsync(this IFormFile file)
              {
                  if (file == null || file.Length == 0)
                  {
                      return await Task.FromResult((string)null);
                  }
          
                  using var reader = new StreamReader(file.OpenReadStream());
                  return await reader.ReadToEndAsync();
              }
          }
          

          这种方式虽然不受欢迎,但确实有效。

          【讨论】:

            猜你喜欢
            • 2020-08-25
            • 2016-11-14
            • 1970-01-01
            • 2021-07-07
            • 1970-01-01
            • 2021-08-09
            • 1970-01-01
            • 1970-01-01
            • 2021-08-02
            相关资源
            最近更新 更多