用文件模型綁定接口: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?
