iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 23
1

在 ASP.NET Core 實作上傳檔案及下載檔案功能算蠻簡易的,但對於上傳大型檔案就稍微麻煩一些,若沒有額外處理,則容易造成 ASP.NET Core 網站崩潰掛點。
本篇將介紹如何在 ASP.NET Core 實作上傳/下載檔案的 API。

同步發佈至個人部落格:
John Wu's Blog - [鐵人賽 Day23] ASP.NET Core 2 系列 - 上傳/下載檔案

簡易上傳/下載

建立一個接收檔案的 Controller,在 Action 的參數中,使用 IFormFile 型別,就可以接收到 HTML Form 傳來的檔案。
如果要允許多檔上傳,就在 Action 的參數中使用 List<IFormFile> 集合來接收參數。範例如下:

Controllers\FileController.cs

using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace MyWebsite.Controllers
{
    [Route("api/[controller]s")]
    public class FileController : Controller
    {
        private readonly static Dictionary<string, string> _contentTypes = new Dictionary<string, string>
        {
            {".png", "image/png"},
            {".jpg", "image/jpeg"},
            {".jpeg", "image/jpeg"},
            {".gif", "image/gif"}
        };
        private readonly string _folder;

        public FileController(IHostingEnvironment env)
        {
            // 把上傳目錄設為:wwwroot\UploadFolder
            _folder = $@"{env.WebRootPath}\UploadFolder";
        }

        [HttpPost]
        public async Task<IActionResult> Upload(List<IFormFile> files)
        {
            var size = files.Sum(f => f.Length);

            foreach (var file in files)
            {
                if (file.Length > 0)
                {
                    var path = $@"{_folder}\{file.FileName}";
                    using (var stream = new FileStream(path, FileMode.Create))
                    {
                        await file.CopyToAsync(stream);
                    }
                }
            }

            return Ok(new { count = files.Count, size });
        }

        [HttpGet("{fileName}")]
        public async Task<IActionResult> Download(string fileName)
        {
            if (string.IsNullOrEmpty(fileName))
            {
                return NotFound();
            }

            var path = $@"{_folder}\{fileName}";
            var memoryStream = new MemoryStream();
            using (var stream = new FileStream(path, FileMode.Open))
            {
                await stream.CopyToAsync(memoryStream);
            }
            memoryStream.Seek(0, SeekOrigin.Begin);

            // 回傳檔案到 Client 需要附上 Content Type,否則瀏覽器會解析失敗。
            return new FileStreamResult(memoryStream, _contentTypes[Path.GetExtension(path).ToLowerInvariant()]);
        }

    }
}

此範例有個小缺陷,就是上傳檔名不能重複,如果檔名重複會被複寫。

  • HTTP POST
    前端用 HTML Form 上傳檔案。enctype 使用 multipart/form-data,把 action 指向接收上傳資料的 API,可以用 accept 限制上傳檔案類型。如下:
<form method="post" enctype="multipart/form-data" action="/api/files">
    <input type="file" name="files" multiple accept="image/*"/>
    <br />
    <input type="submit" value="Upload" />
</form>
  • HTTP GET
    下載檔案就用 HTTP Get 請求 http://localhost:5000/api/files/{檔名} 即可。

表單資料

如果上傳檔案要伴隨著表單資料的話,可以透過 Model 包裝 IFormFile。如下:

Models\AlbumModel.cs

using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Http;

namespace MyWebsite.Models
{
    public class AlbumModel
    {
        public string Title { get; set; }

        public DateTime Date { get; set; }

        public List<IFormFile> Photos { get; set; }
    }
}

Action 在接收的參數就改為包裝後的 Model。例如:

Controllers\FileController.cs

// ...
[Route("album")]
[HttpPost]
public async Task<IActionResult> Album(AlbumModel model)
{
    // ...
    return Ok(new
    {
        title = model.Title,
        date = model.Date.ToString("yyyy/MM/dd"),
        photoCount = model.Photos.Count
    });
}

上傳檔案邏輯同上例 FileController.Upload

HTML Form 如下:

<form method="post" enctype="multipart/form-data" action="/api/users/album">
    名稱:<input type="text" name="title" /><br />
    日期:<input type="date" name="date" /><br />
    相片:<input type="file" name="photos" multiple accept="image/*" /><br />
    <input type="submit" value="送出" />
</form>

[鐵人賽 Day23] ASP.NET Core 2 系列 - 上傳/下載檔案 - 表單資料執行結果

大型檔案上傳

透過 IFormFile 上傳檔案,是由 ASP.NET Core 控制緩衝記憶體,如果檔案太大或很頻繁耗用緩衝記憶體,容易使 ASP.NET Core 的緩衝記憶體到達上限,屆時就是它死給你看的時候了。
所以,如果系統會有上傳大檔的需求,又或者是會很頻繁的上傳檔案,強烈建議改用串流的方式,自己實作寫入硬碟位置,避免 ASP.NET Core 控制緩衝記憶體控制到溢位。

DisableFormValueModelBindingFilter

由於要自行處理 Request 來的資料,所以要把 原本的 Model Binding 移除
建立一個 Attribute 註冊在大型檔案上傳的 API,透過 Resource Filter 在 Model Binding 之前把它移除。

Filters\DisableFormValueModelBindingFilter.cs

using System;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace MyWebsite.Filters
{
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
    public class DisableFormValueModelBindingFilter : 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)
        {
        }
    }
}

MultipartRequestHelper

從微軟官方範例直接複製 MultipartRequestHelper.cs 使用,這個類別是用來判斷 HTML Form 送來的 multipart/form-data 內容使用。

Helpers\MultipartRequestHelper.cs

using System;
using System.IO;
using Microsoft.Net.Http.Headers;

namespace MyWebsite.Helpers
{
    public static class MultipartRequestHelper
    {
        // Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq"
        // The spec says 70 characters is a reasonable limit.
        public static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit)
        {
            var boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary);
            if (string.IsNullOrWhiteSpace(boundary.Value))
            {
                throw new InvalidDataException("Missing content-type boundary.");
            }

            if (boundary.Length > lengthLimit)
            {
                throw new InvalidDataException($"Multipart boundary length limit {lengthLimit} exceeded.");
            }
            return boundary.Value;
        }

        public static bool IsMultipartContentType(string contentType)
        {
            return !string.IsNullOrEmpty(contentType)
                && contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0;
        }

        public static bool HasFormDataContentDisposition(ContentDispositionHeaderValue contentDisposition)
        {
            // Content-Disposition: form-data; name="key";
            return contentDisposition != null
                && contentDisposition.DispositionType.Equals("form-data")
                && string.IsNullOrEmpty(contentDisposition.FileName.Value)
                && string.IsNullOrEmpty(contentDisposition.FileNameStar.Value);
        }

        public static bool HasFileContentDisposition(ContentDispositionHeaderValue contentDisposition)
        {
            // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg"
            return contentDisposition != null
                && contentDisposition.DispositionType.Equals("form-data")
                && (!string.IsNullOrEmpty(contentDisposition.FileName.Value)
                 || !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value));
        }
    }
}

FileStreamingHelper

FileStreamingHelper 是從官方範例 StreamingController.cs 抽出的邏輯,可以讓 Controller 程式碼更簡潔,Stream 實體透過委派傳入,使用上較為彈性。

Helpers\FileStreamingHelper.cs

using System;
using System.Globalization;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Net.Http.Headers;

namespace MyWebsite.Helpers
{
    public static class FileStreamingHelper
    {
        private static readonly FormOptions _defaultFormOptions = new FormOptions();

        public static async Task<FormValueProvider> StreamFile(this HttpRequest request, Func<FileMultipartSection, Stream> createStream)
        {
            if (!MultipartRequestHelper.IsMultipartContentType(request.ContentType))
            {
                throw new Exception($"Expected a multipart request, but got {request.ContentType}");
            }

            // 把 request 中的 Form 依照 Key 及 Value 存到此物件
            var formAccumulator = new KeyValueAccumulator();

            var boundary = MultipartRequestHelper.GetBoundary(
                MediaTypeHeaderValue.Parse(request.ContentType),
                _defaultFormOptions.MultipartBoundaryLengthLimit);
            var reader = new MultipartReader(boundary, request.Body);

            var section = await reader.ReadNextSectionAsync();
            while (section != null)
            {
                // 把 Form 的欄位內容逐一取出
                ContentDispositionHeaderValue contentDisposition;
                var hasContentDispositionHeader = ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out contentDisposition);

                if (hasContentDispositionHeader)
                {
                    if (MultipartRequestHelper.HasFileContentDisposition(contentDisposition))
                    {
                        // 若此欄位是檔案,就寫入至 Stream;
                        using (var targetStream = createStream(section.AsFileSection()))
                        {
                            await section.Body.CopyToAsync(targetStream);
                        }
                    }
                    else if (MultipartRequestHelper.HasFormDataContentDisposition(contentDisposition))
                    {
                        // 若此欄位不是檔案,就把 Key 及 Value 取出,存入 formAccumulator
                        var key = HeaderUtilities.RemoveQuotes(contentDisposition.Name).Value;
                        var encoding = GetEncoding(section);
                        using (var streamReader = new StreamReader(
                            section.Body,
                            encoding,
                            detectEncodingFromByteOrderMarks: true,
                            bufferSize: 1024,
                            leaveOpen: true))
                        {
                            var value = await streamReader.ReadToEndAsync();
                            if (String.Equals(value, "undefined", StringComparison.OrdinalIgnoreCase))
                            {
                                value = String.Empty;
                            }
                            formAccumulator.Append(key, value);

                            if (formAccumulator.ValueCount > _defaultFormOptions.ValueCountLimit)
                            {
                                throw new InvalidDataException($"Form key count limit {_defaultFormOptions.ValueCountLimit} exceeded.");
                            }
                        }
                    }
                }

                // 取得 Form 的下一個欄位
                section = await reader.ReadNextSectionAsync();
            }

            // Bind form data to a model
            var formValueProvider = new FormValueProvider(
                BindingSource.Form,
                new FormCollection(formAccumulator.GetResults()),
                CultureInfo.CurrentCulture);

            return formValueProvider;
        }

        private static Encoding GetEncoding(MultipartSection section)
        {
            MediaTypeHeaderValue mediaType;
            var hasMediaTypeHeader = MediaTypeHeaderValue.TryParse(section.ContentType, out mediaType);
            // UTF-7 is insecure and should not be honored. UTF-8 will succeed in
            // most cases.
            if (!hasMediaTypeHeader || Encoding.UTF7.Equals(mediaType.Encoding))
            {
                return Encoding.UTF8;
            }
            return mediaType.Encoding;
        }
    }
}

上傳 API

HTML Form 使用同上述表單資料的範例,上傳檔案的 API 改成如下:

Controllers\FileController.cs

// ...
[Route("album")]
[HttpPost]
[DisableFormValueModelBindingFilter]
public async Task<IActionResult> Album()
{
    var photoCount = 0;
    var formValueProvider = await Request.StreamFile((file) =>
    {
        photoCount++;
        return System.IO.File.Create($"{_folder}\\{file.FileName}");
    });

    var model = new AlbumModel{
        Title = formValueProvider.GetValue("title").ToString(),
        Date = Convert.ToDateTime(formValueProvider.GetValue("date").ToString())
    };

    // ...

    return Ok(new
    {
        title = model.Title,
        date = model.Date.ToString("yyyy/MM/dd"),
        photoCount = photoCount
    });
}
  • DisableFormValueModelBindingFilter
    Action 套用此 Filter 後,HTML Form 就不會被轉換成物件傳入 Action,因此也就可以移除 Action 的參數了。
  • StreamFile
    StreamFile 會將 HTML Form 的內容以 FormValueProvider 包裝後回傳,並以委派方法讓你實做上傳的事件,以此例來說就是直接以串流的方式直接寫檔。
    這樣就能避免 ASP.NET Core 依賴緩衝記憶體上傳檔案。

Request Limits

上傳大檔可能還會遇到單一 Request 封包過大的錯誤。

  • Kestrel
    若是將 ASP.NET Core 單獨運行在 Kestrel 上,預設單一上傳封包是 30,000,000 bytes 大約是 28.6MB,單次 Request 上傳的大小限制可以在 KestrelServerLimits 修改 MaxRequestBodySize。如下:
    Program.cs
// ...
public class Program
{
    public static void Main(string[] args)
    {
        BuildWebHost(args).Run();
    }

    public static IWebHost BuildWebHost(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>()
            .UseKestrel(options =>
            {
                // 100MB
                options.Limits.MaxRequestBodySize = 100 * 1024 * 1024;
            })
            .Build();
}
  • IIS
    若是將 ASP.NET Core 運行在 IIS 上,預設單一上傳封包是 30,000,000 bytes 大約是 28.6MB,單次 Request 上傳的大小限制可以在 Web.config 修改 maxAllowedContentLength。如下:
    Web.config
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <system.webServer>
    <security>
      <requestFiltering>
        <!-- 100MB -->
        <requestLimits maxAllowedContentLength="104857600" />
      </requestFiltering>
    </security>
  </system.webServer>
</configuration>

參考

File uploads in ASP.NET Core
Uploading Files In ASP.net Core


上一篇
[Day22] ASP.NET Core 2 系列 - SignalR
下一篇
[Day24] ASP.NET Core 2 系列 - Entity Framework Core
系列文
ASP.NET Core 從入門到實用31

尚未有邦友留言

立即登入留言