在 ASP.NET 核心 Web API 中上传文件和 JSON

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

我可以成功地收到一个文件列表,上传的 multipart/form-data内容类型如下:

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

And of course I can successfully receive HTTP request body formatted to my object using default JSON formatter like that:

public void Post([FromBody]SomeObject value)

但是如何在单个控制器操作中组合这两个操作呢?如何同时上传图像和 JSON 数据并将它们绑定到对象?

93914 次浏览

我不确定你是否能一步完成这两件事。

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

显然没有什么固定的方式可以让我做我想做的事。因此,我最终编写了自己的 ModelBinder来处理这种情况。我没有找到任何关于自定义模型绑定的官方文档,但是我使用了 这篇文章作为参考。

自定义 ModelBinder将搜索用 FromJson属性和反序列化字符串修饰的属性,这些字符串来自多部分请求到 JSON。我将模型封装在另一个具有 model 和 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());
});

简单,代码更少,没有包装器模型

There is simpler solution, heavily inspired by 安德鲁斯的回答. By using ModelBinderAttribute不需要指定模型或绑定提供程序。这样可以节省大量代码。您的控制器操作如下所示:

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

Implementation

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头中分配了一个名称。使用这些名称,默认 ASP.Net-Core知道哪个部分绑定到控制器操作中的哪个参数。

绑定到 IFormFile的文件还需要像请求的第二部分那样指定 filename。不需要 Content-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 可用于调用操作并测试服务器端代码。这非常简单,而且主要是用户界面驱动的。创建一个新请求并在 尸体-Tab 中选择 表格-资料。现在,您可以为请求的每个部分在 短信文件之间进行选择。

enter image description here

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

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 方法来调用您的控制器。

我有一个类似的问题,我解决了这个问题,使用 [FromForm]属性和 FileUploadModelView在函数如下:

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

我在前端使用 Angular 7,所以我使用 FormData类,它允许您将字符串或 blobs 附加到窗体。它们可以在控制器操作中使用 [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'
}

You now have a handle on the file and the object. Note that the name you provide in the params list of your controller action 必须的 match the name you provide when appending to the FormData object on the front-end.

我想做同样的使用 Vue 前端和。净核心 API。但由于某些奇怪的原因,IFormFile总是返回空。所以我不得不把它改成 IFormCollection 并把它整理出来。下面是面临同样问题的人的代码:)

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

我有一个类似的问题时,张贴从有角到 asp 核心 api。

Chrome: 表格资料

------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();
}
}

虽然这种方式不被赏识,但是它起作用了。

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

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;
}
}

你不需要“ JsonModelBinder”和其他定制的东西,我有模型

public class UpdateAdminProfileInfoRequest
{
    

public string FirstName { get; set; }
public string LastName { get; set; }
public string Mobile { get; set; }
public IFormFile Image { get; set; }
}

在 Controller 操作方法中只有一个参数

[FromForm]UpdateAdminProfileInfoRequest request

and everything works fine. If you need multiple file upload just change

IFormFile

with

List<IFormFile> files {get; set}

小心,我不知道为什么,但目前我使用。Net6和 JsonProperty 或 JsonPropertyName 都不能在 IFormFile 上工作,如果使用

[JsonProperty("imageFile")]

属性或类似 asp.net 的东西不要将客户端“ imageFile”字段映射到“ Image”属性。

我只是在加入我的 two cents

用 json 上传文件的 Angular app函数

 uploadFileWithJson = (files) => {
if (files.length === 0) {
return;
}


let fileToUpload = <File>files[0];
const formData = new FormData();
formData.append('file', fileToUpload, fileToUpload.name);


const user = {
"name":"Vikram",
"age":35
}
const userTxt = JSON.stringify(user);
formData.append('userData',userTxt);


this.http.post('https://localhost:5001/api/upload/UploadFileWithJson', formData, {reportProgress: true, observe: 'events'})
.subscribe({
next: (event) => {
if (event.type === HttpEventType.UploadProgress)
this.progress = Math.round(100 * event.loaded / event.total);
else if (event.type === HttpEventType.Response) {
this.message = 'Upload success.';
this.onUploadFinished.emit(event.body);
}
},
error: (err: HttpErrorResponse) => console.log(err)
});

}

.NET 核心 API

[HttpPost("UploadFileWithJson"), DisableRequestSizeLimit]
public IActionResult UploadFileWithJson([FromForm]UserWithFile model)
{
try
{
if (model == null)
throw new Exception($"{nameof(model)} is null");


if (model.File == null)
throw new Exception("File is null");


var folderName = Path.Combine("Resources", "Images");
var pathToSave = Path.Combine(Directory.GetCurrentDirectory(), folderName);
if (model.File.Length > 0)
{
var fileName = ContentDispositionHeaderValue.Parse(model.File.ContentDisposition).FileName.Trim('"');
var fullPath = Path.Combine(pathToSave, fileName);
var dbPath = Path.Combine(folderName, fileName);
using (var stream = new FileStream(fullPath, FileMode.Create))
{
model.File.CopyTo(stream);
}
return Ok(new { dbPath });
}
else
{
return BadRequest();
}
}
catch (Exception ex)
{
return StatusCode(500, $"Internal server error: {ex}");
}
}

还有模特班。

public class UserWithFile
{
public string UserData { get; set; }


public IFormFile File { get; set; }
}

现在 < strong > 注意 ,在角度应用程序中,FormData属性 文件名字的第一个字母 F小案子。然而,在。NET 核心相同的是 档案(或 大案子)。userData的情况也是如此。

我只是改变了 fileUserData在角度应用程序的 档案用户数据分别。

Voila! < img src = “ https://i.stack.imgur.com/swplF.png”alt = “ All work fine”/> < img src = “ https://i.stack.imgur.com/swplF.png”alt = “ All work fine”/> .