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

下面來從客戶端和服務端兩個方面來解釋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),"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參數,值就是文件名。

    比如:
    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的意思就是不限制了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM