asp.net core流式上傳大文件
首先需要明確一點就是使用流式上傳和使用IFormFile在效率上沒有太大的差異,IFormFile的缺點主要是客戶端上傳過來的文件首先會緩存在服務器內存中,任何超過 64KB 的單個緩沖文件會從 RAM 移動到服務器磁盤上的臨時文件中。 文件上傳所用的資源(磁盤、RAM)取決於並發文件上傳的數量和大小。 流式處理與性能沒有太大的關系,而是與規模有關。 如果嘗試緩沖過多上傳,站點就會在內存或磁盤空間不足時崩潰(以上解釋來自官網https://docs.microsoft.com/zh-cn/aspnet/core/mvc/models/file-uploads?view=aspnetcore-2.2)。也就是說如果同時有很多客戶端上傳文件時,如果采用IFormFile的方式來上傳的話,上傳的文件首先會在你的服務器內存中進行緩存,還有可能從內存中導入到你的磁盤臨時文件中,那么必然會有兩個問題,一個是內存占用過高,另一個問題就是磁盤空間不足,所以,采用流式上傳的原因就在於解決這兩個問題。但是流式上傳需要比IFormFile復雜的多的配置,IFormFile上傳是在服務器進行模型綁定的操作,而流式上傳是要讀取Request的流並對boundary的內容進行判斷來獲取文件流的方式來處理的。
下面來從客戶端和服務端兩個方面來解釋asp.net core中的文件上傳功能
客戶端配置
文件是從客戶端上傳的到服務器的,所以在客戶端需要一些配置。 我的客戶端是HTML,使用form表單的方式來對文件進行上傳,所以這里只介紹這種客戶端方式。首先上傳文件的話form的enctype屬性必須為multipart/form-data的格式:
<form enctype="multipart/form-data"> .... </form>
注:關於multipart/form-data這部分內容可以參考https://www.jianshu.com/p/29e38bcc8a1d。
enctype有三種可選類型:
- application/x-www-urlencoded 默認情況下是
application/x-www-urlencoded
,當表單使用 POST 請求時,數據會被以 x-www-urlencoded 方式編碼到 Body 中來傳送,而如果 GET 請求,則是附在 url 鏈接后面來發送。GET 請求只支持 ASCII 字符集,因此,如果我們要發送更大字符集的內容,我們應使用 POST 請求。
如果要發送大量的二進制數據(non-ASCII),
如果采用這種格式來對表單的內容進行請求,那么Content-Type就是"application/x-www-form-urlencoded"
顯然是低效的,因為它需要用 3 個字符來表示一個 non-ASCII 的字符。因此,這種情況下,應該使用"multipart/form-data"
格式。application/x-www-form-urlencoded。
- multipart/form-data 采用這種方式提交的表單其content-type的格式就是multipart/form-data了。例如:發送一個這樣的表單:
<FORM method="POST" action="http://w.sohu.com/t2/upload.do" enctype="multipart/form-data"> <INPUT type="text" name="city" value="Santa colo"> <INPUT type="text" name="desc"> <INPUT type="file" name="pic"> </FORM>
POST /t2/upload.do HTTP/1.1 User-Agent: SOHUWapRebot Accept-Language: zh-cn,zh;q=0.5 Accept-Charset: GBK,utf-8;q=0.7,*;q=0.7 Connection: keep-alive Content-Length: 60408 Content-Type:multipart/form-data; boundary=ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC Host: w.sohu.com --ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC Content-Disposition: form-data; name="city" Santa colo --ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC Content-Disposition: form-data;name="desc" Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ... --ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC Content-Disposition: form-data;name="pic"; filename="photo.jpg" Content-Type: application/octet-stream Content-Transfer-Encoding: binary ... binary data of the jpg ... --ZnGpDtePMx0KrHh_G0X99Yef9r8JZsRJSXC--
從上面的
multipart/form-data
格式發送的請求的樣式來看,它包含了多個 Parts,每個 Part 都包含頭信息部分,
Part 頭信息中必須包含一個Content-Disposition
頭,其他的頭信息則為可選項, 比如Content-Type
等。Content-Disposition
包含了 type 和 一個名字為 name 的 parameter,type 是 form-data,name 參數的值則為表單控件(也即 field)的名字,如果是文件,那么還有一個 filename 參數,或者fileNameStar參數,值就是文件名。Content-Disposition: form-data; name="user"; filename="hello.txt"
上面的 "user" 就是表單中的控件的名字,后面的參數 filename 則是點選的文件名。
對於可選的 Content-Type(如果沒有的話),默認就是text/plain
。注意:
如果文件內容是通過填充表單來獲得,那么上傳的時候,Content-Type 會被自動設置(識別)成相應的格式,如果沒法識別,那么就會被設置成
"application/octet-stream"
如果多個文件被填充成單個表單項,那么它們的請求格式則會是 multipart/mixed。如果 Part 的內容跟默認的 encoding 方式不同,那么會有一個
"content-transfer-encoding"
頭信息來指定。下面,我們填充兩個文件到一個表單項中,行程的請求信息如下:
Content-Type: multipart/form-data; boundary=AaB03x --AaB03x Content-Disposition: form-data; name="submit-name" Larry --AaB03x Content-Disposition: form-data; name="files" Content-Type: multipart/mixed; boundary=BbC04y --BbC04y Content-Disposition: file; filename="file1.txt" Content-Type: text/plain ... contents of file1.txt ... --BbC04y Content-Disposition: file; filename="file2.gif" Content-Type: image/gif Content-Transfer-Encoding: binary ...contents of file2.gif... --BbC04y-- --AaB03x--
可以看到一個input type="file"同時上傳兩個文件時會有一個子boundary產生。
- text-plain 這個不做解釋了。
服務器配置
服務器采用asp.net core。
參考https://www.cnblogs.com/liuxiaoji/p/10266609.html
參考的這篇文章中已經比較舊了,在asp.net core2.2中,已經有了一些便捷的擴展方法方法來更清晰的表示這些邏輯,但是遺憾的是asp.net core的官方文檔還沒有更新這些。
此外,有關與文件斷點續傳/上傳的一個協議/規范,在這里:https://www.cnblogs.com/850391642c/p/tus-Protocol.html;我也在考慮后續要不要使用這個協議和實現來應用到我的項目中。
下面進入正題:
使用流式上傳的方式的缺點就是配置比較復雜,你無法使用IFormFile那種能夠采用模型綁定的方式來將上傳的文件反序列化成對象,需要我們進行配置,配置的步驟為:
①首先要判斷content-type是否是multipart
②從HttpRequest中拿到boundary
③將拿到的boundary和HttpRequest的body組合成一個MultipartReader對象
④從組合成的MultipartReader對象中讀取有boundary分隔的每個section,這個section有可能是一個form表單的鍵值對,也有可能是一個文件。
⑤逐項取出每一個section,然后對每個section進行判斷是form表單鍵值對還是一個文件,並進行相應的處理。其中,如果是表單項的鍵值對,那么將這個鍵值對存入一個對象中,如果是文件,則建立一個文件流並將文件寫入磁盤。
代碼基於asp.net core 2.2,代碼如下:
public static class FileStreamingHelper { /// <summary> /// 如果文件上傳成功,那么message會返回一個上傳文件的路徑,如果失敗,message代表失敗的消息 /// </summary> /// <param name="request"></param> /// <param name="targetDirectory"></param> /// <param name="cancellationToken"></param> /// <returns></returns> public static async Task<(bool success, string filePath, FormValueProvider valueProvider)> StreamFile(this HttpRequest request, string targetDirectory, CancellationToken cancellationToken) { //讀取boundary var boundary = request.GetMultipartBoundary(); if (string.IsNullOrEmpty(boundary)) { return (false, "解析失敗", null); } //檢查相應目錄 if (!Directory.Exists(targetDirectory)) { Directory.CreateDirectory(targetDirectory); } //准備文件保存路徑 var filePath = string.Empty; //准備viewmodel緩沖 var accumulator = new KeyValueAccumulator(); //創建section reader var reader = new MultipartReader(boundary, request.Body); try { var section = await reader.ReadNextSectionAsync(cancellationToken); while (section != null) { ContentDispositionHeaderValue header = section.GetContentDispositionHeader(); if (header.FileName.HasValue || header.FileNameStar.HasValue) { var fileSection = section.AsFileSection(); var fileName = fileSection.FileName; filePath = Path.Combine(targetDirectory, fileName); if (File.Exists(filePath)) { return (false, "你以上傳過同名文件", null); } accumulator.Append("mimeType", fileSection.Section.ContentType); accumulator.Append("fileName", fileName); accumulator.Append("filePath", filePath); using (var writeStream = File.Create(filePath)) { const int bufferSize = 1024; await fileSection.FileStream.CopyToAsync(writeStream, bufferSize, cancellationToken); } } else { var formDataSection = section.AsFormDataSection(); var name = formDataSection.Name; var value = await formDataSection.GetValueAsync(); accumulator.Append(name, value); } section = await reader.ReadNextSectionAsync(cancellationToken); } } catch (OperationCanceledException) { if (File.Exists(filePath)) { File.Delete(filePath); } return (false, "用戶取消操作", null); } // Bind form data to a model var formValueProvider = new FormValueProvider( BindingSource.Form, new FormCollection(accumulator.GetResults()), CultureInfo.CurrentCulture); return (true, filePath, formValueProvider); } }
這個方法會返回一個元組,來表示一些狀態和結果,首先,方法中檢查boundary是否為空,為空則直接返回錯誤碼;然后,根據boundary來創建一個關鍵的MultipartReader來讀取request.body中的每個section;然后,根據section的類型來決定將這個section當作一個filesection還是一個formdatasection來處理。這個方法順便將CancellationToken傳入,當客戶端中斷連接或其他原因造成中斷,引發OperationCanceledException時,方法會將已接受的字節組成的文件(無用的文件)刪除。最終,方法返回一個元組,里面有代表是否成功的布爾值,由代表消息的字符串,還有一個FormValueProvider,這個對象用於解析成最終的ViewModel。當布爾值為true時,代表消息的字符串是一個文件路徑。用於解析ViewModel后續步驟的處理,這是因為我需要將ViewModel轉化成一條文件上傳記錄存入數據庫。
然后還需要定義一個攔截器,用於告訴mvc不要進行模型綁定,這個攔截器實現了IResourceFilter接口:
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; using System; using System.Linq; namespace MyFtp.Api.Extensions { [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對請求body的大小以及上傳的文件的大小都有一些限制,為了免除這些限制,我們需要進行一些配置,如果你要是用IIS進行部署你的應用,則應該建立一個web.config文件進行相應的配置,這方面的內容在https://docs.microsoft.com/zh-cn/aspnet/core/mvc/models/file-uploads?view=aspnetcore-2.2,我使用的是kestrel,對kestrel進行配置也非常簡單,就是配置一個FormOption,在startup類中寫入:
//設置接收文件長度的最大值。 services.Configure<FormOptions>(x => { x.ValueLengthLimit = int.MaxValue; x.MultipartBodyLengthLimit = int.MaxValue; x.MultipartHeadersLengthLimit = int.MaxValue; });
上面的這個配置的單位是字節,配置了三個,這三個都是與表單相關的:一個是表單的鍵值對中的值的長度限制,一個是當表單enctype為multipart/form-data時文件的長度限制,還有一個是multipart頭長度的限制,也就是boundary=-------------------------------Gefsgeq!34這種玩意兒的限制。
上面的配置完成后還不行,因為asp.net core還對HttpRequest的長度也做了限制,還需要對HttpRequest請求體的長度進行配置,這個配置可以在action上面完成,有兩個attribute:
//[RequestSizeLimit()] [DisableRequestSizeLimit] public async Task<IActionResult> Post() {
.......
}
RequestSizeLimit是傳入一個表示字節的數字來對請求的大小進行限制,另一個DisableRequestSizeLimit的意思就是不限制了。