ASP.NET Core MVC如何上传文件及处理大文件上传
用文件模型绑定接口:IFormFile (小文件上传)
当你使用IFormFile接口来上传文件的时候,一定要注意,IFormFile会将一个Http请求中的所有文件都读取到服务器内存后,才会触发ASP.NET Core MVC的Controller中的Action方法。这种情况下,如果上传一些小文件是没问题的,但是如果上传大文件,势必会造成服务器内存大量被占用甚至溢出,所以IFormFile接口只适合小文件上传。
一个文件上传页面的Html代码一般如下所示:
<form method="post" enctype="multipart/form-data" action="/Upload"> <div> <p>Upload one or more files using this form:</p> <input type="file" name="files" /> </div> <div> <input type="submit" value="Upload" /> </div> </form>
为了支持文件上传,form标签上一定要记得声明属性enctype="multipart/form-data",否则你会发现ASP.NET Core MVC的Controller中死活都读不到任何文件。Input type="file"标签在html 5中支持上传多个文件,加上属性multiple即可。
使用IFormFile接口上传文件非常简单,将其声明为Controller中Action的集合参数即可:
[HttpPost] public async Task<IActionResult> Post(List<IFormFile> files) { long size = files.Sum(f => f.Length); foreach (var formFile in files) { var filePath = @"F:\UploadingFiles\" + formFile.FileName.Substring(formFile.FileName.LastIndexOf("\\") + 1);//注意formFile.FileName包含上传文件的文件路径,所以要进行Substring只取出最后的文件名 if (formFile.Length > 0) { using (var stream = new FileStream(filePath, FileMode.Create)) { await formFile.CopyToAsync(stream); } } } return Ok(new { count = files.Count, size }); }
注意上面Action方法Post的参数名files,必须要和上传页面中的Input type="file"标签的name属性值一样。
不要直接用Request.Form.Files
上面例子是我们知道Input type="file"标签的name属性值时的情况,如果你不知道Input type="file"标签的name属性值(例如前端用javascript动态生成的Input type="file"标签),有什么办法可以获取所有的上传文件吗?
也许有同学会想到可以用Request.Form.Files来获取当前Http请求中,所有的上传文件,如下所示:
[HttpPost] public async Task<IActionResult> Post() { IFormFileCollection files = Request.Form.Files; long size = files.Sum(f => f.Length); foreach (var formFile in files) { var filePath = @"F:\UploadingFiles\" + formFile.FileName.Substring(formFile.FileName.LastIndexOf("\\") + 1); if (formFile.Length > 0) { using (var stream = new FileStream(filePath, FileMode.Create)) { await formFile.CopyToAsync(stream); } } } return Ok(new { count = files.Count, size }); }
然后执行上面的代码你会发现,代码执行到Request.Form.Files的时候,就一直卡住了,如下所示:
然后现在我们给Post方法随便加一个参数string parameter,如下所示:
[HttpPost] public async Task<IActionResult> Post(string parameter) { IFormFileCollection files = Request.Form.Files; long size = files.Sum(f => f.Length); foreach (var formFile in files) { var filePath = @"F:\UploadingFiles\" + formFile.FileName.Substring(formFile.FileName.LastIndexOf("\\") + 1); if (formFile.Length > 0) { using (var stream = new FileStream(filePath, FileMode.Create)) { await formFile.CopyToAsync(stream); } } } return Ok(new { count = files.Count, size }); }
运行代码,你会发现虽然Post方法的参数string parameter没得到任何值为null,但是这次Post方法却没有卡在Request.Form.Files,文件上传成功,Post方法成功执行完毕:
这是因为当ASP.NET Core MVC中Controller的Action方法没有定义参数的时候,Request.Form不会做数据绑定,也就是说当我们在上面Post方法没有定义参数的时候,Request.Form根本就没有被ASP.NET Core初始化,所以只要一访问Request.Form代码就会被卡住,所以当我们随便给Post方法定义一个string parameter参数后,Request.Form就被初始化了,这时就可以访问Request.Form中的数据了。
既然必须要给Post方法定义参数,那我们就定义有意义的参数,而不是胡乱定义一个没有用的。我们将Post方法的代码改为如下:
[HttpPost] public async Task<IActionResult> Post([FromForm]IFormCollection formData) { IFormFileCollection files = formData.Files;//等价于Request.Form.Files long size = files.Sum(f => f.Length); foreach (var formFile in files) { var inputName = formFile.Name;//可以通过IFormFile.Name属性获得每个上传文件,在页面上所属Input type="file"标签的name属性值 var filePath = @"F:\UploadingFiles\" + formFile.FileName.Substring(formFile.FileName.LastIndexOf("\\") + 1); if (formFile.Length > 0) { using (var stream = new FileStream(filePath, FileMode.Create)) { await formFile.CopyToAsync(stream); } } } return Ok(new { count = files.Count, size }); }
我们给Post方法定义了一个IFormCollection类型的参数formData,并且标记了[FromForm]特性标签,表示IFormCollection formData参数使用Http请求中的表单(Form)数据进行初始化,所以这下formData其实就等价于Request.Form了。
我们可以从formData中访问表单(Form)提交的任何数据,获得所有的上传文件。其实Post方法的参数名字叫什么并不重要(本例中我们取名为formData),但是其参数必须是IFormCollection类型才会绑定Http请求中的表单(Form)数据,这才是关键。
执行上面的代码,文件成功上传,代码成功执行完毕:
用文件流 (大文件上传)
在介绍这个方法之前我们先来看看一个包含上传文件的Http请求是什么样子的:
Content-Type=multipart/form-data; boundary=---------------------------99614912995 -----------------------------99614912995 Content-Disposition: form-data; name="SOMENAME" Formulaire de Quota -----------------------------99614912995 Content-Disposition: form-data; name="OTHERNAME" SOMEDATA -----------------------------99614912995 Content-Disposition: form-data; name="files"; filename="Misc 001.jpg" SDFESDSDSDJXCK+DSDSDSSDSFDFDF423232DASDSDSDFDSFJHSIHFSDUIASUI+/== -----------------------------99614912995 Content-Disposition: form-data; name="files"; filename="Misc 002.jpg" ASAADSDSDJXCKDSDSDSHAUSAUASAASSDSDFDSFJHSIHFSDUIASUI+/== -----------------------------99614912995 Content-Disposition: form-data; name="files"; filename="Misc 003.jpg" TGUHGSDSDJXCK+DSDSDSSDSFDFDSAOJDIOASSAADDASDASDASSADASDSDSDSDFDSFJHSIHFSDUIASUI+/== -----------------------------99614912995--
这就是一个multipart/form-data格式的Http请求,我们可以看到第一行信息是Http header,这里我们只列出了Content-Type这一行Http header信息,这和我们在html页面中form标签上的enctype属性值一致,第一行中接着有一个boundary=---------------------------99614912995,boundary=后面的值是随机生成的,这个其实是在声明Http请求中表单数据的分隔符是什么,其代表的是在Http请求中每读到一行 ---------------------------99614912995,表示一个section数据,一个section有可能是一个表单的键值数据,也有可能是一个上传文件的文件数据。每个section的第一行是section header,其中Content-Disposition属性都为form-data,表示这个section来自form标签提交的表单数据,如果section header拥有filename或filenamestar属性,那么表示这个section是一个上传文件的文件数据,否则这个section是一个表单的键值数据,section header之后的行就是这个section真正的数据行。例如我们上面的例子中,前两个section就是表单键值对,后面三个section是三个上传的图片文件。
那么接下来,我们来看看怎么用文件流来上传大文件,避免一次性将所有上传的文件都加载到服务器内存中。用文件流来上传比较麻烦的地方在于你无法使用ASP.NET Core MVC的模型绑定器来将上传文件反序列化为C#对象(如同前面介绍的IFormFile接口那样)。首先我们需要定义类MultipartRequestHelper,用于识别Http请求中的各个section类型(是表单键值对section,还是上传文件section)
using System; using System.IO; using Microsoft.Net.Http.Headers; namespace AspNetCore.MultipartRequest { public static class MultipartRequestHelper { // Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq" // The spec says 70 characters is a reasonable limit. public static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit) { //var boundary = Microsoft.Net.Http.Headers.HeaderUtilities.RemoveQuotes(contentType.Boundary);// .NET Core <2.0 var boundary = Microsoft.Net.Http.Headers.HeaderUtilities.RemoveQuotes(contentType.Boundary).Value; //.NET Core 2.0 if (string.IsNullOrWhiteSpace(boundary)) { throw new InvalidDataException("Missing content-type boundary."); } //注意这里的boundary.Length指的是boundary=---------------------------99614912995中等号后面---------------------------99614912995字符串的长度,也就是section分隔符的长度,上面也说了这个长度一般不会超过70个字符是比较合理的 if (boundary.Length > lengthLimit) { throw new InvalidDataException( $"Multipart boundary length limit {lengthLimit} exceeded."); } return boundary; } public static bool IsMultipartContentType(string contentType) { return !string.IsNullOrEmpty(contentType) && contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0; } //如果section是表单键值对section,那么本方法返回true public static bool HasFormDataContentDisposition(ContentDispositionHeaderValue contentDisposition) { // Content-Disposition: form-data; name="key"; return contentDisposition != null && contentDisposition.DispositionType.Equals("form-data") && string.IsNullOrEmpty(contentDisposition.FileName.Value) // For .NET Core <2.0 remove ".Value" && string.IsNullOrEmpty(contentDisposition.FileNameStar.Value); // For .NET Core <2.0 remove ".Value" } //如果section是上传文件section,那么本方法返回true public static bool HasFileContentDisposition(ContentDispositionHeaderValue contentDisposition) { // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg" return contentDisposition != null && contentDisposition.DispositionType.Equals("form-data") && (!string.IsNullOrEmpty(contentDisposition.FileName.Value) // For .NET Core <2.0 remove ".Value" || !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value)); // For .NET Core <2.0 remove ".Value" } // 如果一个section的Header是: Content-Disposition: form-data; name="files"; filename="F:\Misc 002.jpg" // 那么本方法返回: files public static string GetFileContentInputName(ContentDispositionHeaderValue contentDisposition) { return contentDisposition.Name.Value; } // 如果一个section的Header是: Content-Disposition: form-data; name="myfile1"; filename="F:\Misc 002.jpg" // 那么本方法返回: Misc 002.jpg public static string GetFileName(ContentDispositionHeaderValue contentDisposition) { return Path.GetFileName(contentDisposition.FileName.Value); } } }
然后我们需要定义一个扩展类叫FileStreamingHelper,其中的StreamFiles扩展方法用于读取上传文件的文件流数据,并且将数据写入到服务器的硬盘上,其接受一个参数targetDirectory,用于声明将上传文件存储到服务器的哪个文件夹下。
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Net.Http.Headers; using System; using System.Globalization; using System.IO; using System.Text; using System.Threading.Tasks; namespace AspNetCore.MultipartRequest { public static class FileStreamingHelper { private static readonly FormOptions _defaultFormOptions = new FormOptions(); public static async Task<FormValueProvider> StreamFiles(this HttpRequest request, string targetDirectory) { if (!MultipartRequestHelper.IsMultipartContentType(request.ContentType)) { throw new Exception($"Expected a multipart request, but got {request.ContentType}"); } // Used to accumulate all the form url encoded key value pairs in the // request. var formAccumulator = new KeyValueAccumulator(); var boundary = MultipartRequestHelper.GetBoundary( MediaTypeHeaderValue.Parse(request.ContentType), _defaultFormOptions.MultipartBoundaryLengthLimit); var reader = new MultipartReader(boundary, request.Body); var section = await reader.ReadNextSectionAsync();//用于读取Http请求中的第一个section数据 while (section != null) { ContentDispositionHeaderValue contentDisposition; var hasContentDispositionHeader = ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out contentDisposition); if (hasContentDispositionHeader) { /* 用于处理上传文件类型的的section -----------------------------99614912995 Content - Disposition: form - data; name = "files"; filename = "Misc 002.jpg" ASAADSDSDJXCKDSDSDSHAUSAUASAASSDSDFDSFJHSIHFSDUIASUI+/== -----------------------------99614912995 */ if (MultipartRequestHelper.HasFileContentDisposition(contentDisposition)) { if (!Directory.Exists(targetDirectory)) { Directory.CreateDirectory(targetDirectory); } var fileName = MultipartRequestHelper.GetFileName(contentDisposition); var loadBufferBytes = 1024;//这个是每一次从Http请求的section中读出文件数据的大小,单位是Byte即字节,这里设置为1024的意思是,每次从Http请求的section数据流中读取出1024字节的数据到服务器内存中,然后写入下面targetFileStream的文件流中,可以根据服务器的内存大小调整这个值。这样就避免了一次加载所有上传文件的数据到服务器内存中,导致服务器崩溃。 using (var targetFileStream = System.IO.File.Create(targetDirectory + "\\" + fileName)) { using (section.Body) { //section.Body是System.IO.Stream类型,表示的是Http请求中一个section的数据流,从该数据流中可以读出每一个section的全部数据,所以我们下面也可以不用section.Body.CopyToAsync方法,而是在一个循环中用section.Body.Read方法自己读出数据(如果section.Body.Read方法返回0,表示数据流已经到末尾,数据已经全部都读取完了),再将数据写入到targetFileStream await section.Body.CopyToAsync(targetFileStream, loadBufferBytes); } } } /* 用于处理表单键值数据的section -----------------------------99614912995 Content - Disposition: form - data; name = "SOMENAME" Formulaire de Quota -----------------------------99614912995 */ else if (MultipartRequestHelper.HasFormDataContentDisposition(contentDisposition)) { // Content-Disposition: form-data; name="key" // // value // Do not limit the key name length here because the // multipart headers length limit is already in effect. var key = HeaderUtilities.RemoveQuotes(contentDisposition.Name); var encoding = GetEncoding(section); using (var streamReader = new StreamReader( section.Body, encoding, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true)) { // The value length limit is enforced by MultipartBodyLengthLimit var value = await streamReader.ReadToEndAsync(); if (String.Equals(value, "undefined", StringComparison.OrdinalIgnoreCase)) { value = String.Empty; } formAccumulator.Append(key.Value, value); // For .NET Core <2.0 remove ".Value" from key if (formAccumulator.ValueCount > _defaultFormOptions.ValueCountLimit) { throw new InvalidDataException($"Form key count limit {_defaultFormOptions.ValueCountLimit} exceeded."); } } } } // Drains any remaining section body that has not been consumed and // reads the headers for the next section. section = await reader.ReadNextSectionAsync();//用于读取Http请求中的下一个section数据 } // Bind form data to a model var formValueProvider = new FormValueProvider( BindingSource.Form, new FormCollection(formAccumulator.GetResults()), CultureInfo.CurrentCulture); return formValueProvider; } private static Encoding GetEncoding(MultipartSection section) { MediaTypeHeaderValue mediaType; var hasMediaTypeHeader = MediaTypeHeaderValue.TryParse(section.ContentType, out mediaType); // UTF-7 is insecure and should not be honored. UTF-8 will succeed in // most cases. if (!hasMediaTypeHeader || Encoding.UTF7.Equals(mediaType.Encoding)) { return Encoding.UTF8; } return mediaType.Encoding; } } }
现在我们还需要创建一个ASP.NET Core MVC的自定义拦截器DisableFormValueModelBindingAttribute,该拦截器实现接口IResourceFilter,用来禁用ASP.NET Core MVC的模型绑定器,这样当一个Http请求到达服务器后,ASP.NET Core MVC就不会在将请求的所有上传文件数据都加载到服务器内存后,才执行Controller的Action方法,而是当Http请求到达服务器时,就立刻执行Controller的Action方法。
ASP.NET Core 2.X使用下面的代码:
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; using System; using System.Linq; namespace AspNetCore.MultipartRequest { [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter { public void OnResourceExecuting(ResourceExecutingContext context) { var formValueProviderFactory = context.ValueProviderFactories .OfType<FormValueProviderFactory>() .FirstOrDefault(); if (formValueProviderFactory != null) { context.ValueProviderFactories.Remove(formValueProviderFactory); } var jqueryFormValueProviderFactory = context.ValueProviderFactories .OfType<JQueryFormValueProviderFactory>() .FirstOrDefault(); if (jqueryFormValueProviderFactory != null) { context.ValueProviderFactories.Remove(jqueryFormValueProviderFactory); } } public void OnResourceExecuted(ResourceExecutedContext context) { } } }
ASP.NET Core 3.X使用下面的代码:
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; using System; using System.Linq; namespace AspNetCore.MultipartRequest { [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter { public void OnResourceExecuting(ResourceExecutingContext context) { var factories = context.ValueProviderFactories; factories.RemoveType<FormValueProviderFactory>(); factories.RemoveType<FormFileValueProviderFactory>(); factories.RemoveType<JQueryFormValueProviderFactory>(); } public void OnResourceExecuted(ResourceExecutedContext context) { } } }
最后我们在Controller中定义一个叫Index的Action方法,并注册我们定义的DisableFormValueModelBindingAttribute拦截器,来禁用Action的模型绑定。Index方法会调用我们前面定义的FileStreamingHelper类中的StreamFiles方法,其参数为用来存储上传文件的文件夹路径。StreamFiles方法会返回一个FormValueProvider,用来存储Http请求中的表单键值数据,之后我们会将其绑定到MVC的视图模型viewModel上,然后将viewModel传回给客户端浏览器,来告述客户端浏览器文件上传成功。
[HttpPost] [DisableFormValueModelBinding] public async Task<IActionResult> Index() { FormValueProvider formModel; formModel = await Request.StreamFiles(@"F:\UploadingFiles"); var viewModel = new MyViewModel(); var bindingSuccessful = await TryUpdateModelAsync(viewModel, prefix: "", valueProvider: formModel); if (!bindingSuccessful) { if (!ModelState.IsValid) { return BadRequest(ModelState); } } return Ok(viewModel); }
视图模型viewModel的定义如下:
public class MyViewModel { public string Username { get; set; } }
最后我们用于上传文件的html页面和前面几乎一样:
<form method="post" enctype="multipart/form-data" action="/Home/Index"> <div> <p>Upload one or more files using this form:</p> <input type="file" name="files" multiple /> </div> <div> <p>Your Username</p> <input type="text" name="username" /> </div> <div> <input type="submit" value="Upload" /> </div> </form>
在文件上传前,获取HTTP Header和阻止文件上传:
我们还可以在调用FileStreamingHelper.StreamFiles方法之前和之后,在Controller的Action方法中和Filter拦截器中,获取HTTP Header的值(包含Cookie值)。甚至我们可以在Filter拦截器中,阻止文件上传。
首先我们定义一个拦截器叫ReadHeadersFilterAttribute,它实现了IAuthorizationFilter拦截器接口。
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using System; using System.IO; using System.Text; namespace AspNetCore.MultipartRequest { public class ReadHeadersFilterAttribute : Attribute, IAuthorizationFilter { public void OnAuthorization(AuthorizationFilterContext context) { //在Filter拦截器中,获取HTTP Header的值 string cookieValue = context.HttpContext.Request.Cookies["DemoKey"]; string host = context.HttpContext.Request.Headers["Host"].ToString(); string connection = context.HttpContext.Request.Headers["Connection"].ToString(); /* //如果有必要(例如发现用户的权限不对),可以在Filter拦截器中,通过给context.Result属性赋值来阻止文件上传 context.HttpContext.Response.ContentType = "text/html; charset=utf-8";//设置Http响应类型为text/html,编码为utf-8 context.HttpContext.Response.StatusCode = 401;//设置Http响应状态码为401 using (StreamWriter sw = new StreamWriter(context.HttpContext.Response.Body, Encoding.UTF8)) { sw.Write("<html><head></head><body><h1>Unauthorized!</h1></body></html>"); } context.Result = new EmptyResult();//加入EmptyResult就告诉ASP.NET Core MVC在本拦截器执行结束后,不必再为当前请求执行Controller中Action的代码,同时取消执行在本拦截器之后注册的其它Filter拦截器 */ } } }
可以看到,我们可以在拦截器中获取HTTP Header的值(包含Cookie值),甚至阻止文件上传。
然后我们将定义的ReadHeadersFilterAttribute拦截器,应用到文件上传的Action方法Upload上,如下所示:
[HttpPost] [DisableFormValueModelBinding] [DisableRequestSizeLimit] [ReadHeadersFilter] public async Task<IActionResult> Upload() { //在Controller的Action中,调用FileStreamingHelper.StreamFiles方法前获取HTTP Header的值 string cookieValue = Request.Cookies["DemoKey"]; string host = Request.Headers["Host"].ToString(); string connection = Request.Headers["Connection"].ToString(); FormValueProvider formModel; formModel = await Request.StreamFiles(@"F:\UploadingFiles"); return View("Index"); } public IActionResult SetCookie() { Response.Cookies.Append("DemoKey", "DemoValue"); return View("Index"); } public IActionResult Index() { return View(); }
可以看到在Controller的Action方法Upload中,我们在调用FileStreamingHelper.StreamFiles方法之前(当然之后也可以),可以获取HTTP Header的值(包含Cookie值)。
这就是所有的代码,希望对大家有所帮助!
参考文献:
Uploading Files In ASP.net Core
What is the boundary parameter in an HTTP multi-part (POST) Request?