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),"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-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的意思就是不限制了。
posted @ 2019-07-05 10:16  wall-ee  阅读(8691)  评论(0编辑  收藏  举报