ASP.NET Core 制作一个低资源占用的支持超大文件表单上传的服务

[复制链接]
发表于 2025-11-5 09:29:26 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
故事的配景是我准备制作一个署名服务,为打包构建之后的产物文件举行署名和对其内容信息举行扫描。在这个过程内里,我必要搭建一个 ASP.NET Core 服务,这个服务要能承载客户端上传的超大文件表单,且预算有限,此服务占用资源要富足低
上传文件到服务器的经典方法是接纳表单上传的方式
在 ASP.NET Core 的默认实现中,无论是直接在参数上写 FromFormAttribute 共同 IFormFile 吸收文件,照旧通过 HttpRequest.ReadFormAsync 方法,对于客户端传入的大文件,都会先缓存到磁盘内里。这也就是为什么会有一些开发者会误以为利用 IFormFile 范例属性时,可以立即吸收到客户端发送过来的文件而在有必要读取时,才开始吸收读取的缘故原由
毕竟上,对于凌驾缓存巨细的表单哀求文件,默认的 ASP.NET Core 实现将会先吸收客户端的输入数据,将其存放到当地暂时文件中。随后再调用业务层的逻辑,构建的 IFormFile 范例读取的内容现实上是从文件读取的。如许的计划的缘故原由是客户端的表单上传大概不是将文件放在末端,这就意味着只有完全吸收了表单,才气知道整个表单包罗了哪些内容。好比雷同如下的客户端表单上传逻辑:
  1.     // 以下是测试代码
  2.     using var httpClient = new HttpClient();
  3.     using var multipartFormDataContent = new MultipartFormDataContent();
  4.     using var fakeLongStream = new FakeLongStream();
  5.     multipartFormDataContent.Add(new StreamContent(fakeLongStream), "TheFile", "FileName.zip");
  6.     multipartFormDataContent.Add(new StringContent("Value1"), "Field1");
  7.     var response = await httpClient.PostAsync($"{url}/PostMultipartForm", multipartFormDataContent);
  8.     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 映射哀求信息,删减后的代码如下
  1. WebApplication app = ...
  2. app.Urls.Add(url);
  3. app.MapPost("/PostMultipartForm", async (Microsoft.AspNetCore.Http.HttpContext context) =>
  4. {
  5.     ...
  6. });
复制代码
在此之前,为了让 ASP.NET Core 可以或许吸收超大文件,必要设置无穷定哀求体巨细,其代码如下
  1. var builder = WebApplication.CreateSlimBuilder(args);
  2. builder.WebHost.UseKestrel(options =>
  3. {
  4.     // 无限制请求体大小
  5.     // Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException:“Request body too large. The max request body size is 30000000 bytes.”
  6.     options.Limits.MaxRequestBodySize = null;
  7. });
复制代码
提前相识到将实验表单传输,表单传输必要获取 Boundary 分隔符,利用 MediaTypeHeaderValue 辅助类举行转换,有删减的代码如下
  1. app.MapPost("/PostMultipartForm", async (Microsoft.AspNetCore.Http.HttpContext context) =>
  2. {
  3.     var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
  4.     var request = context.Request;
  5.     var response = context.Response;
  6.     string? contentType = request.ContentType;
  7.     if (contentType is null)
  8.     {
  9.         return;
  10.     }
  11.     MediaTypeHeaderValue mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(contentType);
  12.     var contentTypeBoundary = mediaTypeHeaderValue.Boundary;
  13.     var boundary = HeaderUtilities.RemoveQuotes(contentTypeBoundary).Value!;
  14.     ...
  15. });
复制代码
准备工作完成之后,就可以利用本文用到的焦点类 MultipartReader 举行处置惩罚。传入 boundary 和 request.Body 给到 MultipartReader 构造函数,即可开始实验读取逻辑,示例代码如下
  1.     var boundary = HeaderUtilities.RemoveQuotes(contentTypeBoundary).Value!;
  2.     var multipartReader = new MultipartReader(boundary, request.Body, bufferSize: 1024);
复制代码
读取的方式是写一个无穷循环,直到 MultipartReader 的 ReadNextSectionAsync 返回空才退出循环,代码如下
  1.     while (true)
  2.     {
  3.         MultipartSection? multipartSection = await multipartReader.ReadNextSectionAsync();
  4.         if (multipartSection == null)
  5.         {
  6.             // 读取完成了
  7.             break;
  8.         }
  9.         ...
  10.     }
复制代码
当前读取到的 MultipartSection 还不能确定表单的范例,不知道是否包罗文件。可继续通过 GetContentDispositionHeader 扩展方法服务获取 ContentDispositionHeaderValue 范例,再判断 IsFileDisposition 相识是否传入为文件,代码如下
  1.         ContentDispositionHeaderValue? contentDispositionHeaderValue = multipartSection.GetContentDispositionHeader();
  2.         if (contentDispositionHeaderValue is null)
  3.         {
  4.             continue;
  5.         }
  6.         // ContentType=application/octet-stream
  7.         // form-data; name="file"; filename="Input.zip"
  8.         if (contentDispositionHeaderValue.IsFileDisposition())
  9.         {
  10.             FileMultipartSection? fileMultipartSection = multipartSection.AsFileSection();
  11.             if (fileMultipartSection?.FileStream is null)
  12.             {
  13.                 continue;
  14.             }
  15.             ...
  16.         }
复制代码
拿到了 FileMultipartSection 即可继续判断 Name 和 FileName 内容,好比说拿到 Foo1 的就来生存文件
示例代码如下,通过如下方式生存文件和利用上层的 IFormFile 的差别在于,如下方式可以直接从网络读取到当地文件,而 IFormFile 是先缓存到当地暂时文件再做雷同文件读取拷贝到目标当地文件的过程,相对来说如下方式淹灭资源更低
  1.             // 可在此判断表单的各项内容。如判断是 Foo1 的就保存文件,是 TheFile 的就计算哈希值
  2.             if (fileMultipartSection.Name == "Foo1")
  3.             {
  4.                 // 文件
  5.                 var fileName = fileMultipartSection.FileName;
  6.                 fileName = GetSafeFileName(fileName);
  7.                 // 处理文件上传逻辑,例如保存文件
  8.                 // 这里简单地将文件保存到临时目录。小心,生产环境中请确保文件名安全,小心被攻击
  9.                 var filePath = Path.Join(Path.GetTempPath(), $"Uploaded_{fileName}");
  10.                 await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read,
  11.                     10240,
  12.                     // 确保文件在关闭后被删除,以防止临时文件堆积。此仅仅为演示需求,避免临时文件太多。请根据你的需求决定是否使用此选项
  13.                     FileOptions.DeleteOnClose);
  14.                 await fileMultipartSection.FileStream.CopyToAsync(fileStream);
  15.                 // 完成文件写入之后,可以通过以下代码,直接读取文件的内容
  16.                 fileStream.Position = 0;
  17.                 // 此时就可以立刻读取 FileStream 的内容了
  18.                 logger.LogInformation($"Received file '{fileName}', saved to '{filePath}'");
  19.             }
复制代码
以上示例代码内里设置了 FileOptions.DeleteOnClose 选项,仅仅只是为了演示,作用是确保文件在关闭之后自动删除,防止堆积测试文件
以上示例代码用到的 GetSafeFileName 方法我将在后文给出,具体请参阅 C# 不能用于文件名的字符
通过以上方式写文件尚有一个上风是可以在 CopyToAsync 完成之后,立即设置 Position 为 0 从而从零读取文件,立即就能读取。整个过程发生在内存中的缓存占用非常低
按照本文的需求,是给文件做署名,连将文件写入磁盘的斲丧都可以不消。对比 IFormFile 先缓存到当地暂时文件的方式,如下直接读取立即处置惩罚的方式可以做到更低的资源占用。如下方式根本上磁盘和内存都能做到非常安稳和非常低的水平。代码如下
  1.             if (fileMultipartSection.Name == "Foo1")
  2.             {
  3.                 ...
  4.             }
  5.             else if (fileMultipartSection.Name == "TheFile")
  6.             {
  7.                 using var sha1 = SHA1.Create();
  8.                 var hashByteList = await sha1.ComputeHashAsync(fileMultipartSection.FileStream);
  9.                 var hashString = Convert.ToHexString(hashByteList);
  10.                 logger.LogInformation($"Received file '{fileMultipartSection.FileName}', SHA1: {hashString}");
  11.                 await using var streamWriter = new StreamWriter(response.Body, leaveOpen: true);
  12.                 await streamWriter.WriteLineAsync($"Received file '{fileMultipartSection.FileName}', SHA1: {hashString}");
  13.             }
复制代码
如以上代码所示,此时直接 SHA1 盘算从网络传输获取的数据,无需碰触磁盘读写。斲丧的内存会合在缓存内里,缓存内存固定巨细,总体斲丧很低
在此方式内里,依然可以读取到平凡的表单内容,如以下代码所示
  1.         if (contentDispositionHeaderValue.IsFileDisposition())
  2.         {
  3.             ...
  4.         }
  5.         else
  6.         {
  7.             // 普通表单字段
  8.             var formMultipartSection = multipartSection.AsFormDataSection();
  9.             if (formMultipartSection is null)
  10.             {
  11.                 continue;
  12.             }
  13.             var name = formMultipartSection.Name;
  14.             var value = await formMultipartSection.GetValueAsync();
  15.             logger.LogInformation($"Received form field '{name}': {value}");
  16.         }
复制代码
看到这里,信赖各人也就明白为什么那么多 CDN 厂商或 OSS 厂商都要求将文件放在表单末端,如允许以方便他们的服务读取表单的开始就可以举行富足的校验,而不是吸收了一个超大文件之后才气读取到可以校验的表单信息
完全的示例代码如下
  1. 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 () =>{    // 以下是测试代码
  2.     using var httpClient = new HttpClient();
  3.     using var multipartFormDataContent = new MultipartFormDataContent();
  4.     using var fakeLongStream = new FakeLongStream();
  5.     multipartFormDataContent.Add(new StreamContent(fakeLongStream), "TheFile", "FileName.zip");
  6.     multipartFormDataContent.Add(new StringContent("Value1"), "Field1");
  7.     var response = await httpClient.PostAsync($"{url}/PostMultipartForm", multipartFormDataContent);
  8.     response.EnsureSuccessStatusCode();    var responseContent = await response.Content.ReadAsStringAsync();    Console.WriteLine($"{responseContent}");});var builder = WebApplication.CreateSlimBuilder(args);
  9. builder.WebHost.UseKestrel(options =>
  10. {
  11.     // 无限制请求体大小
  12.     // Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException:“Request body too large. The max request body size is 30000000 bytes.”
  13.     options.Limits.MaxRequestBodySize = null;
  14. });WebApplication app = builder.Build();app.Urls.Add(url);app.MapPost("/PostMultipartForm", async (Microsoft.AspNetCore.Http.HttpContext context) =>{    var logger = context.RequestServices.GetRequiredService();    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 的就计算哈希值
  15.             if (fileMultipartSection.Name == "Foo1")
  16.             {
  17.                 // 文件
  18.                 var fileName = fileMultipartSection.FileName;
  19.                 fileName = GetSafeFileName(fileName);
  20.                 // 处理文件上传逻辑,例如保存文件
  21.                 // 这里简单地将文件保存到临时目录。小心,生产环境中请确保文件名安全,小心被攻击
  22.                 var filePath = Path.Join(Path.GetTempPath(), $"Uploaded_{fileName}");
  23.                 await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read,
  24.                     10240,
  25.                     // 确保文件在关闭后被删除,以防止临时文件堆积。此仅仅为演示需求,避免临时文件太多。请根据你的需求决定是否使用此选项
  26.                     FileOptions.DeleteOnClose);
  27.                 await fileMultipartSection.FileStream.CopyToAsync(fileStream);
  28.                 // 完成文件写入之后,可以通过以下代码,直接读取文件的内容
  29.                 fileStream.Position = 0;
  30.                 // 此时就可以立刻读取 FileStream 的内容了
  31.                 logger.LogInformation($"Received file '{fileName}', saved to '{filePath}'");
  32.             }            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('\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 下令进入此空文件夹,在下令行内里输入以下代码,即可获取到本文的代码
  1. git init
  2. git remote add origin https://gitee.com/lindexi/lindexi_gd.git
  3. git pull origin 6b9f5ce59f0159e8e87c073db57ab62e12adecc5
复制代码
以上利用的是国内的 gitee 的源,如果 gitee 不能访问,请更换为 github 的源。请在下令行继续输入以下代码,将 gitee 源换成 github 源举行拉取代码。如果依然拉取不到代码,可以发邮件向我要代码
  1. git remote remove origin
  2. git remote add origin https://github.com/lindexi/lindexi_gd.git
  3. git pull origin 6b9f5ce59f0159e8e87c073db57ab62e12adecc5
复制代码
获取代码之后,进入 Workbench/NujawfeafuKeekenercekiji 文件夹,即可获取到源代码
更多技能博客,请参阅 博客导航
参考文档:
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表