ASP.NET Core 制作一個低資源占用的支持超大文件表單上傳的服務
故事的背景是我準備制作一個簽名服務,為打包構建之后的產(chǎn)物文件進行簽名和對其內(nèi)容信息進行掃描。在這個過程里面,我需要搭建一個 ASP.NET Core 服務,這個服務要能承載客戶端上傳的超大文件表單,且預算有限,此服務占用資源要足夠低
上傳文件到服務器的經(jīng)典方法是采用表單上傳的方式
在 ASP.NET Core 的默認實現(xiàn)中,無論是直接在參數(shù)上寫 FromFormAttribute 配合 IFormFile 接收文件,還是通過 HttpRequest.ReadFormAsync 方法,對于客戶端傳入的大文件,都會先緩存到磁盤里面。這也就是為什么會有一些開發(fā)者會誤認為使用 IFormFile 類型屬性時,可以立刻接收到客戶端發(fā)送過來的文件而在有需要讀取時,才開始接收讀取的原因
事實上,對于超過緩存大小的表單請求文件,默認的 ASP.NET Core 實現(xiàn)將會先接收客戶端的輸入數(shù)據(jù),將其存放到本地臨時文件中。隨后再調用業(yè)務層的邏輯,構建的 IFormFile 類型讀取的內(nèi)容實際上是從文件讀取的。這樣的設計的原因是客戶端的表單上傳可能不是將文件放在末尾,這就意味著只有完全接收了表單,才能知道整個表單包含了哪些內(nèi)容。比如類似如下的客戶端表單上傳邏輯:
// 以下是測試代碼
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 表單內(nèi)容。這就意味著服務端如果沒有完全接收整個表單,則無法列舉出整個表單包含的內(nèi)容。服務端不能無限緩存大表單數(shù)據(jù)到內(nèi)存,于是只好先存放到本地磁盤臨時文件
可見在此過程里面,整個 ASP.NET Core 的默認實現(xiàn)的服務端,對于超大文件是不能快速響應的。而且也難以預先判斷請求合法性,最多只能判斷 HEAD 請求頭,而不能根據(jù)表單讀取內(nèi)容決定是否拒絕響應
對于本文我提及的需求,制作一個簽名服務器來說,我本身的服務器性能和磁盤空間都很小。客戶端上傳的超大文件都會真的超級大,都是按 G 為單位。如果是真等 ASP.NET Core 完全讀取表單,緩存到本地文件,隨后我再從本地文件讀取緩存,計算簽名信息,那么這個過程里面不僅占用資源多,且響應速度緩慢。畢竟讀寫磁盤的速度肯定沒有我直接計算簽名來得快
好在 ASP.NET Core 從設計上就是自由的,不僅提供了上層的簡單方便用法,也提供了底層的基礎實現(xiàn)方式。核心官方文檔是: https://learn.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-9.0
為了方便演示,我這里創(chuàng)建了一個 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;
});
提前了解到將執(zhí)行表單傳輸,表單傳輸需要獲取 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 構造函數(shù),即可開始執(zhí)行讀取邏輯,示例代碼如下
var boundary = HeaderUtilities.RemoveQuotes(contentTypeBoundary).Value!;
var multipartReader = new MultipartReader(boundary, request.Body, bufferSize: 1024);
讀取的方式是寫一個無限循環(huán),直到 MultipartReader 的 ReadNextSectionAsync 返回空才退出循環(huán),代碼如下
while (true)
{
MultipartSection? multipartSection = await multipartReader.ReadNextSectionAsync();
if (multipartSection == null)
{
// 讀取完成了
break;
}
...
}
當前讀取到的 MultipartSection 還不能確定表單的類型,不知道是否包含文件。可繼續(xù)通過 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 即可繼續(xù)判斷 Name 和 FileName 內(nèi)容,比如說拿到 Foo1 的就來保存文件
示例代碼如下,通過如下方式保存文件和使用上層的 IFormFile 的差別在于,如下方式可以直接從網(wǎng)絡讀取到本地文件,而 IFormFile 是先緩存到本地臨時文件再做類似文件讀取拷貝到目標本地文件的過程,相對來說如下方式耗費資源更低
// 可在此判斷表單的各項內(nèi)容。如判斷是 Foo1 的就保存文件,是 TheFile 的就計算哈希值
if (fileMultipartSection.Name == "Foo1")
{
// 文件
var fileName = fileMultipartSection.FileName;
fileName = GetSafeFileName(fileName);
// 處理文件上傳邏輯,例如保存文件
// 這里簡單地將文件保存到臨時目錄。小心,生產(chǎn)環(huán)境中請確保文件名安全,小心被攻擊
var filePath = Path.Join(Path.GetTempPath(), $"Uploaded_{fileName}");
await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read,
10240,
// 確保文件在關閉后被刪除,以防止臨時文件堆積。此僅僅為演示需求,避免臨時文件太多。請根據(jù)你的需求決定是否使用此選項
FileOptions.DeleteOnClose);
await fileMultipartSection.FileStream.CopyToAsync(fileStream);
// 完成文件寫入之后,可以通過以下代碼,直接讀取文件的內(nèi)容
fileStream.Position = 0;
// 此時就可以立刻讀取 FileStream 的內(nèi)容了
logger.LogInformation($"Received file '{fileName}', saved to '{filePath}'");
}
以上示例代碼里面設置了 FileOptions.DeleteOnClose 選項,僅僅只是為了演示,作用是確保文件在關閉之后自動刪除,防止堆積測試文件
以上示例代碼用到的 GetSafeFileName 方法我將在后文給出,詳細請參閱 C# 不能用于文件名的字符
通過以上方式寫文件還有一個優(yōu)勢是可以在 CopyToAsync 完成之后,立刻設置 Position 為 0 從而從零讀取文件,立刻就能讀取。整個過程發(fā)生在內(nèi)存中的緩存占用非常低
按照本文的需求,是給文件做簽名,連將文件寫入磁盤的消耗都可以不用。對比 IFormFile 先緩存到本地臨時文件的方式,如下直接讀取立刻處理的方式可以做到更低的資源占用。如下方式基本上磁盤和內(nèi)存都能做到非常平穩(wěn)和非常低的水平。代碼如下
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 計算從網(wǎng)絡傳輸獲取的數(shù)據(jù),無需碰觸磁盤讀寫。消耗的內(nèi)存集中在緩存里面,緩存內(nèi)存固定大小,總體損耗很低
在此方式里面,依然可以讀取到普通的表單內(nèi)容,如以下代碼所示
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;
}
// 可在此判斷表單的各項內(nèi)容。如判斷是 Foo1 的就保存文件,是 TheFile 的就計算哈希值
if (fileMultipartSection.Name == "Foo1")
{
// 文件
var fileName = fileMultipartSection.FileName;
fileName = GetSafeFileName(fileName);
// 處理文件上傳邏輯,例如保存文件
// 這里簡單地將文件保存到臨時目錄。小心,生產(chǎn)環(huán)境中請確保文件名安全,小心被攻擊
var filePath = Path.Join(Path.GetTempPath(), $"Uploaded_{fileName}");
await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read,
10240,
// 確保文件在關閉后被刪除,以防止臨時文件堆積。此僅僅為演示需求,避免臨時文件太多。請根據(jù)你的需求決定是否使用此選項
FileOptions.DeleteOnClose);
await fileMultipartSection.FileStream.CopyToAsync(fileStream);
// 完成文件寫入之后,可以通過以下代碼,直接讀取文件的內(nèi)容
fileStream.Position = 0;
// 此時就可以立刻讀取 FileStream 的內(nèi)容了
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 上,可以使用如下命令行拉取代碼。我整個代碼倉庫比較龐大,使用以下命令行可以進行部分拉取,拉取速度比較快
先創(chuàng)建一個空文件夾,接著使用命令行 cd 命令進入此空文件夾,在命令行里面輸入以下代碼,即可獲取到本文的代碼
git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 6b9f5ce59f0159e8e87c073db57ab62e12adecc5
以上使用的是國內(nèi)的 gitee 的源,如果 gitee 不能訪問,請?zhí)鎿Q為 github 的源。請在命令行繼續(xù)輸入以下代碼,將 gitee 源換成 github 源進行拉取代碼。如果依然拉取不到代碼,可以發(fā)郵件向我要代碼
git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 6b9f5ce59f0159e8e87c073db57ab62e12adecc5
獲取代碼之后,進入 Workbench/NujawfeafuKeekenercekiji 文件夾,即可獲取到源代碼
更多技術博客,請參閱 博客導航
參考文檔:
博客園博客只做備份,博客發(fā)布就不再更新,如果想看最新博客,請訪問 https://blog.lindexi.com/
如圖片看不見,請在瀏覽器開啟不安全http內(nèi)容兼容

本作品采用知識共享署名-非商業(yè)性使用-相同方式共享 4.0 國際許可協(xié)議進行許可。歡迎轉載、使用、重新發(fā)布,但務必保留文章署名[林德熙](http://www.rzrgm.cn/lindexi)(包含鏈接:http://www.rzrgm.cn/lindexi ),不得用于商業(yè)目的,基于本文修改后的作品務必以相同的許可發(fā)布。如有任何疑問,請與我[聯(lián)系](mailto:lindexi_gd@163.com)。

浙公網(wǎng)安備 33010602011771號