zoukankan      html  css  js  c++  java
  • 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的意思就是不限制了。
  • 相关阅读:
    关于图片去水印和转载
    多叉树(森林)转二叉树
    [置顶] 读入优化&输出优化
    【NOI OJ】1816 拨钟问题
    Splay树
    【AVL】宠物收养所
    【树状数组】Stars
    对拍
    使用Mybatis进行多表联查操作
    搜索框自动提示
  • 原文地址:https://www.cnblogs.com/pangjianxin/p/11136670.html
Copyright © 2011-2022 走看看