動態

詳情 返回 返回

ASP.NET Core 製作一個低資源佔用的支持超大文件表單上傳的服務 - 動態 詳情

上傳文件到服務器的經典方法是採用表單上傳的方式

在 ASP.NET Core 的默認實現中,無論是直接在參數上寫 FromFormAttribute 配合 IFormFile 接收文件,還是通過 HttpRequest.ReadFormAsync 方法,對於客户端傳入的大文件,都會先緩存到磁盤裏面。這也就是為什麼會有一些開發者會誤認為使用 IFormFile 類型屬性時,可以立刻接收到客户端發送過來的文件而在有需要讀取時,才開始接收讀取的原因

事實上,對於超過緩存大小的表單請求文件,默認的 ASP.NET Core 實現將會先接收客户端的輸入數據,將其存放到本地臨時文件中。隨後再調用業務層的邏輯,構建的 IFormFile 類型讀取的內容實際上是從文件讀取的。這樣的設計的原因是客户端的表單上傳可能不是將文件放在末尾,這就意味着只有完全接收了表單,才能知道整個表單包含了哪些內容。比如類似如下的客户端表單上傳邏輯:

    // 以下是測試代碼
    using var httpClient = new HttpClient();

    using var multipartFormDataContent = new MultipartFormDataContent();
    using var fakeLongStream = new FakeLongStream();
    multipartFormDataContent.Add(new StreamContent(fakeLongStream), "TheFile", "FileName.zip");
    multipartFormDataContent.Add(new StringContent("Value1"), "Field1");
    var response = await httpClient.PostAsync($"{url}/PostMultipartForm", multipartFormDataContent);
    response.EnsureSuccessStatusCode();

以上的 FakeLongStream 是一個假裝是超大文件的 Stream 類型。通過以上代碼可見,先是在表單添加了超大文件,隨後再添加 Field1 表單內容。這就意味着服務端如果沒有完全接收整個表單,則無法列舉出整個表單包含的內容。服務端不能無限緩存大表單數據到內存,於是只好先存放到本地磁盤臨時文件

可見在此過程裏面,整個 ASP.NET Core 的默認實現的服務端,對於超大文件是不能快速響應的。而且也難以預先判斷請求合法性,最多隻能判斷 HEAD 請求頭,而不能根據表單讀取內容決定是否拒絕響應

對於本文我提及的需求,製作一個簽名服務器來説,我本身的服務器性能和磁盤空間都很小。客户端上傳的超大文件都會真的超級大,都是按 G 為單位。如果是真等 ASP.NET Core 完全讀取表單,緩存到本地文件,隨後我再從本地文件讀取緩存,計算簽名信息,那麼這個過程裏面不僅佔用資源多,且響應速度緩慢。畢竟讀寫磁盤的速度肯定沒有我直接計算簽名來得快

好在 ASP.NET Core 從設計上就是自由的,不僅提供了上層的簡單方便用法,也提供了底層的基礎實現方式。核心官方文檔是: https://learn.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-9.0

為了方便演示,我這裏創建了一個 Mini API 的 ASP.NET Core 項目。且為了簡化我的需求,可以認為我只期望對上傳的表單文件計算 SHA1 哈希值然後返回給到客户端

先通過 MapPost 映射請求信息,刪減後的代碼如下

WebApplication app = ...
app.Urls.Add(url);

app.MapPost("/PostMultipartForm", async (Microsoft.AspNetCore.Http.HttpContext context) =>
{
    ...
});

在此之前,為了讓 ASP.NET Core 能夠接收超大文件,需要設置無限制請求體大小,其代碼如下

var builder = WebApplication.CreateSlimBuilder(args);

builder.WebHost.UseKestrel(options =>
{
    // 無限制請求體大小
    // Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException:“Request body too large. The max request body size is 30000000 bytes.”
    options.Limits.MaxRequestBodySize = null;
});

提前瞭解到將執行表單傳輸,表單傳輸需要獲取 Boundary 分隔符,利用 MediaTypeHeaderValue 輔助類進行轉換,有刪減的代碼如下

app.MapPost("/PostMultipartForm", async (Microsoft.AspNetCore.Http.HttpContext context) =>
{
    var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();

    var request = context.Request;
    var response = context.Response;

    string? contentType = request.ContentType;
    if (contentType is null)
    {
        return;
    }

    MediaTypeHeaderValue mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(contentType);
    var contentTypeBoundary = mediaTypeHeaderValue.Boundary;
    var boundary = HeaderUtilities.RemoveQuotes(contentTypeBoundary).Value!;

    ...
});

準備工作完成之後,就可以使用本文用到的核心類 MultipartReader 進行處理。傳入 boundary 和 request.Body 給到 MultipartReader 構造函數,即可開始執行讀取邏輯,示例代碼如下

    var boundary = HeaderUtilities.RemoveQuotes(contentTypeBoundary).Value!;
    var multipartReader = new MultipartReader(boundary, request.Body, bufferSize: 1024);

讀取的方式是寫一個無限循環,直到 MultipartReader 的 ReadNextSectionAsync 返回空才退出循環,代碼如下

    while (true)
    {
        MultipartSection? multipartSection = await multipartReader.ReadNextSectionAsync();
        if (multipartSection == null)
        {
            // 讀取完成了
            break;
        }

        ...
    }

當前讀取到的 MultipartSection 還不能確定表單的類型,不知道是否包含文件。可繼續通過 GetContentDispositionHeader 擴展方法服務獲取 ContentDispositionHeaderValue 類型,再判斷 IsFileDisposition 瞭解是否傳入為文件,代碼如下

        ContentDispositionHeaderValue? contentDispositionHeaderValue = multipartSection.GetContentDispositionHeader();

        if (contentDispositionHeaderValue is null)
        {
            continue;
        }

        // ContentType=application/octet-stream
        // form-data; name="file"; filename="Input.zip"

        if (contentDispositionHeaderValue.IsFileDisposition())
        {
            FileMultipartSection? fileMultipartSection = multipartSection.AsFileSection();
            if (fileMultipartSection?.FileStream is null)
            {
                continue;
            }
            ...
        }

拿到了 FileMultipartSection 即可繼續判斷 Name 和 FileName 內容,比如説拿到 Foo1 的就來保存文件

示例代碼如下,通過如下方式保存文件和使用上層的 IFormFile 的差別在於,如下方式可以直接從網絡讀取到本地文件,而 IFormFile 是先緩存到本地臨時文件再做類似文件讀取拷貝到目標本地文件的過程,相對來説如下方式耗費資源更低

            // 可在此判斷表單的各項內容。如判斷是 Foo1 的就保存文件,是 TheFile 的就計算哈希值
            if (fileMultipartSection.Name == "Foo1")
            {
                // 文件
                var fileName = fileMultipartSection.FileName;
                fileName = GetSafeFileName(fileName);
                // 處理文件上傳邏輯,例如保存文件
                // 這裏簡單地將文件保存到臨時目錄。小心,生產環境中請確保文件名安全,小心被攻擊
                var filePath = Path.Join(Path.GetTempPath(), $"Uploaded_{fileName}");
                await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read,
                    10240,
                    // 確保文件在關閉後被刪除,以防止臨時文件堆積。此僅僅為演示需求,避免臨時文件太多。請根據你的需求決定是否使用此選項
                    FileOptions.DeleteOnClose);
                await fileMultipartSection.FileStream.CopyToAsync(fileStream);

                // 完成文件寫入之後,可以通過以下代碼,直接讀取文件的內容
                fileStream.Position = 0;
                // 此時就可以立刻讀取 FileStream 的內容了
                logger.LogInformation($"Received file '{fileName}', saved to '{filePath}'");
            }

以上示例代碼裏面設置了 FileOptions.DeleteOnClose 選項,僅僅只是為了演示,作用是確保文件在關閉之後自動刪除,防止堆積測試文件

以上示例代碼用到的 GetSafeFileName 方法我將在後文給出,詳細請參閲 C# 不能用於文件名的字符

通過以上方式寫文件還有一個優勢是可以在 CopyToAsync 完成之後,立刻設置 Position 為 0 從而從零讀取文件,立刻就能讀取。整個過程發生在內存中的緩存佔用非常低

按照本文的需求,是給文件做簽名,連將文件寫入磁盤的消耗都可以不用。對比 IFormFile 先緩存到本地臨時文件的方式,如下直接讀取立刻處理的方式可以做到更低的資源佔用。如下方式基本上磁盤和內存都能做到非常平穩和非常低的水平。代碼如下

            if (fileMultipartSection.Name == "Foo1")
            {
                ...
            }
            else if (fileMultipartSection.Name == "TheFile")
            {
                using var sha1 = SHA1.Create();
                var hashByteList = await sha1.ComputeHashAsync(fileMultipartSection.FileStream);
                var hashString = Convert.ToHexString(hashByteList);
                logger.LogInformation($"Received file '{fileMultipartSection.FileName}', SHA1: {hashString}");
                await using var streamWriter = new StreamWriter(response.Body, leaveOpen: true);
                await streamWriter.WriteLineAsync($"Received file '{fileMultipartSection.FileName}', SHA1: {hashString}");
            }

如以上代碼所示,此時直接 SHA1 計算從網絡傳輸獲取的數據,無需碰觸磁盤讀寫。消耗的內存集中在緩存裏面,緩存內存固定大小,總體損耗很低

在此方式裏面,依然可以讀取到普通的表單內容,如以下代碼所示

        if (contentDispositionHeaderValue.IsFileDisposition())
        {
            ...
        }
        else
        {
            // 普通表單字段
            var formMultipartSection = multipartSection.AsFormDataSection();
            if (formMultipartSection is null)
            {
                continue;
            }

            var name = formMultipartSection.Name;
            var value = await formMultipartSection.GetValueAsync();

            logger.LogInformation($"Received form field '{name}': {value}");
        }

看到這裏,相信大家也就理解為什麼那麼多 CDN 廠商或 OSS 廠商都要求將文件放在表單末尾,這樣可以方便他們的服務讀取表單的開始就可以進行足夠的校驗,而不是接收了一個超大文件之後才能讀取到可以校驗的表單信息

完全的示例代碼如下

using Microsoft.AspNetCore.WebUtilities;

using System.IO;
using System.Net;
using System.Net.Mime;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Net.Http.Headers;

var port = GetAvailablePort(IPAddress.Loopback);
var url = $"http://127.0.0.1:{port}";

_ = Task.Run(async () =>
{
    // 以下是測試代碼
    using var httpClient = new HttpClient();

    using var multipartFormDataContent = new MultipartFormDataContent();
    using var fakeLongStream = new FakeLongStream();
    multipartFormDataContent.Add(new StreamContent(fakeLongStream), "TheFile", "FileName.zip");
    multipartFormDataContent.Add(new StringContent("Value1"), "Field1");
    var response = await httpClient.PostAsync($"{url}/PostMultipartForm", multipartFormDataContent);
    response.EnsureSuccessStatusCode();
    var responseContent = await response.Content.ReadAsStringAsync();
    Console.WriteLine($"{responseContent}");
});

var builder = WebApplication.CreateSlimBuilder(args);

builder.WebHost.UseKestrel(options =>
{
    // 無限制請求體大小
    // Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException:“Request body too large. The max request body size is 30000000 bytes.”
    options.Limits.MaxRequestBodySize = null;
});

WebApplication app = builder.Build();
app.Urls.Add(url);

app.MapPost("/PostMultipartForm", async (Microsoft.AspNetCore.Http.HttpContext context) =>
{
    var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();

    var request = context.Request;
    var response = context.Response;

    string? contentType = request.ContentType;
    if (contentType is null)
    {
        return;
    }

    MediaTypeHeaderValue mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(contentType);
    var contentTypeBoundary = mediaTypeHeaderValue.Boundary;
    var boundary = HeaderUtilities.RemoveQuotes(contentTypeBoundary).Value!;
    var multipartReader = new MultipartReader(boundary, request.Body, 1024);

    await response.StartAsync();

    while (true)
    {
        MultipartSection? multipartSection = await multipartReader.ReadNextSectionAsync();
        if (multipartSection == null)
        {
            // 讀取完成了
            break;
        }

        ContentDispositionHeaderValue? contentDispositionHeaderValue = multipartSection.GetContentDispositionHeader();

        if (contentDispositionHeaderValue is null)
        {
            continue;
        }

        // ContentType=application/octet-stream
        // form-data; name="file"; filename="Input.zip"

        if (contentDispositionHeaderValue.IsFileDisposition())
        {
            var fileMultipartSection = multipartSection.AsFileSection();
            if (fileMultipartSection?.FileStream is null)
            {
                continue;
            }

            // 可在此判斷表單的各項內容。如判斷是 Foo1 的就保存文件,是 TheFile 的就計算哈希值
            if (fileMultipartSection.Name == "Foo1")
            {
                // 文件
                var fileName = fileMultipartSection.FileName;
                fileName = GetSafeFileName(fileName);
                // 處理文件上傳邏輯,例如保存文件
                // 這裏簡單地將文件保存到臨時目錄。小心,生產環境中請確保文件名安全,小心被攻擊
                var filePath = Path.Join(Path.GetTempPath(), $"Uploaded_{fileName}");
                await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read,
                    10240,
                    // 確保文件在關閉後被刪除,以防止臨時文件堆積。此僅僅為演示需求,避免臨時文件太多。請根據你的需求決定是否使用此選項
                    FileOptions.DeleteOnClose);
                await fileMultipartSection.FileStream.CopyToAsync(fileStream);

                // 完成文件寫入之後,可以通過以下代碼,直接讀取文件的內容
                fileStream.Position = 0;
                // 此時就可以立刻讀取 FileStream 的內容了
                logger.LogInformation($"Received file '{fileName}', saved to '{filePath}'");
            }
            else if (fileMultipartSection.Name == "TheFile")
            {
                using var sha1 = SHA1.Create();
                var hashByteList = await sha1.ComputeHashAsync(fileMultipartSection.FileStream);
                var hashString = Convert.ToHexString(hashByteList);
                logger.LogInformation($"Received file '{fileMultipartSection.FileName}', SHA1: {hashString}");
                await using var streamWriter = new StreamWriter(response.Body, leaveOpen: true);
                await streamWriter.WriteLineAsync($"Received file '{fileMultipartSection.FileName}', SHA1: {hashString}");
            }
        }
        else
        {
            // 普通表單字段
            var formMultipartSection = multipartSection.AsFormDataSection();
            if (formMultipartSection is null)
            {
                continue;
            }

            var name = formMultipartSection.Name;
            var value = await formMultipartSection.GetValueAsync();

            logger.LogInformation($"Received form field '{name}': {value}");
        }
    }


    await response.CompleteAsync();
});

app.Run();

static string GetSafeFileName(string arbitraryString)
{
    var invalidChars = System.IO.Path.GetInvalidFileNameChars();
    var replaceIndex = arbitraryString.IndexOfAny(invalidChars, 0);
    if (replaceIndex == -1) return arbitraryString;

    var r = new StringBuilder();
    var i = 0;

    do
    {
        r.Append(arbitraryString, i, replaceIndex - i);

        switch (arbitraryString[replaceIndex])
        {
            case '"':
                r.Append("''");
                break;
            case '<':
                r.Append('\u02c2'); // '˂' (modifier letter left arrowhead)
                break;
            case '>':
                r.Append('\u02c3'); // '˃' (modifier letter right arrowhead)
                break;
            case '|':
                r.Append('\u2223'); // '∣' (divides)
                break;
            case ':':
                r.Append('-');
                break;
            case '*':
                r.Append('\u2217'); // '∗' (asterisk operator)
                break;
            case '\\':
            case '/':
                r.Append('\u2044'); // '⁄' (fraction slash)
                break;
            case '\0':
            case '\f':
            case '?':
                break;
            case '\t':
            case '\n':
            case '\r':
            case '\v':
                r.Append(' ');
                break;
            default:
                r.Append('_');
                break;
        }

        i = replaceIndex + 1;
        replaceIndex = arbitraryString.IndexOfAny(invalidChars, i);
    } while (replaceIndex != -1);

    r.Append(arbitraryString, i, arbitraryString.Length - i);

    return r.ToString();
}

static int GetAvailablePort(IPAddress ip)
{
    using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
    socket.Bind(new IPEndPoint(ip, 0));
    socket.Listen(1);
    var ipEndPoint = (IPEndPoint) socket.LocalEndPoint!;
    var port = ipEndPoint.Port;
    return port;
}

class FakeLongStream : Stream
{
    public override void Flush()
    {
        throw new NotImplementedException();
    }

    public override int Read(byte[] buffer, int offset, int count)
    {
        if (Position == Length)
        {
            return 0;
        }

        Position += count;

        Random.Shared.NextBytes(buffer.AsSpan(offset, count));

        if (Position < Length)
        {
            return count;
        }

        var result = (int) (Length - (Position - count));
        Position = Length;
        return result;
    }

    public override long Seek(long offset, SeekOrigin origin)
    {
        throw new NotImplementedException();
    }

    public override void SetLength(long value)
    {
        throw new NotImplementedException();
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        throw new NotImplementedException();
    }

    public override bool CanRead => true;
    public override bool CanSeek => false;
    public override bool CanWrite => false;
    public override long Length => int.MaxValue / 2;
    public override long Position { get; set; }
}

本文代碼放在 github 和 gitee 上,可以使用如下命令行拉取代碼。我整個代碼倉庫比較龐大,使用以下命令行可以進行部分拉取,拉取速度比較快

先創建一個空文件夾,接着使用命令行 cd 命令進入此空文件夾,在命令行裏面輸入以下代碼,即可獲取到本文的代碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 6b9f5ce59f0159e8e87c073db57ab62e12adecc5

以上使用的是國內的 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令行繼續輸入以下代碼,將 gitee 源換成 github 源進行拉取代碼。如果依然拉取不到代碼,可以發郵件向我要代碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 6b9f5ce59f0159e8e87c073db57ab62e12adecc5

獲取代碼之後,進入 Workbench/NujawfeafuKeekenercekiji 文件夾,即可獲取到源代碼

更多技術博客,請參閲 博客導航

參考文檔:

  • https://github.com/dotnet/aspnetcore/issues/58233
  • https://learn.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-9.0
user avatar ligaai 頭像 kohler21 頭像 daqianduan 頭像 eolink 頭像
點贊 4 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.