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),
如果采用这种格式来对表单的内容进行请求,那么Content-Type就是"application/x-www-form-urlencoded"
显然是低效的,因为它需要用 3 个字符来表示一个 non-ASCII 的字符。因此,这种情况下,应该使用"multipart/form-data"
格式。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的意思就是不限制了。