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-;q=0.7,*;q=0.7
Connection: keep-alive
Content-Length:
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-
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 = ;
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的意思就是不限制了。