asp.net core流式上傳大文件

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的內容進行判斷來獲取文件流的方式來處理的。html

下面來從客戶端和服務端兩個方面來解釋asp.net core中的文件上傳功能jquery

客戶端配置

文件是從客戶端上傳的到服務器的,因此在客戶端須要一些配置。 個人客戶端是HTML,使用form表單的方式來對文件進行上傳,因此這裏只介紹這種客戶端方式。首先上傳文件的話form的enctype屬性必須爲multipart/form-data的格式:web

 <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),"application/x-www-form-urlencoded" 顯然是低效的,由於它須要用 3 個字符來表示一個 non-ASCII 的字符。所以,這種狀況下,應該使用 "multipart/form-data" 格式。服務器

    若是採用這種格式來對錶單的內容進行請求,那麼Content-Type就是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參數,值就是文件名。mvc

    好比:
    Content-Disposition: form-data; name="user"; filename="hello.txt"

    上面的 "user" 就是表單中的控件的名字,後面的參數 filename 則是點選的文件名。
    對於可選的 Content-Type(若是沒有的話),默認就是 text/plainapp

    注意:

    若是文件內容是經過填充表單來得到,那麼上傳的時候,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的意思就是不限制了。
相關文章
相關標籤/搜索