vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

【ASP.NET Core】【TypeScript】PDF のダウンロード・アップロード 2

はじめに

平成 <--> 令和 をまたいでしまいましたが、続きです。
(令和になりましても引き続きよろしくお願いいたします (..)_ )

FormData を使ってファイル送信する場合の課題として、ファイルの容量制限が、少なくともデフォルトだとかなり厳しいことが挙げられます。

テキストファイル程度なら良いのですが、動画などになってくると数十 MB を超えたりします。

これをそのまま FormData として Body に入れて渡そうとすると BadHttpRequestException が発生します。

info: Microsoft.AspNetCore.Server.Kestrel[17]
      Connection id "XXXXXXXXXXXXX" bad request data: "Request body too large."
Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException: Request body too large.
   at Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException.Throw(RequestRejectionReason reason)
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.Http1MessageBody.ForContentLength.OnReadStarting()
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.MessageBody.TryStart()
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.MessageBody.ConsumeAsync()
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.ProcessRequests[TContext](IHttpApplication`1 application)
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.ProcessRequestsAsync[TContext](IHttpApplication`1 application)

Microsoft Docs によれば容量が大きいファイルのアップロードには stream を使う、という話があります。

ということで、これを試してみることにします。

Stream を使って PDF をアップロードする(失敗)

まぁ失敗というかなんというか。

サンプルでは Razor が使われていて、 View と Controller (コードビハインド?) で直接データのやり取りができると。

そのため Body に格納して渡すのではなく、 MemoryStream で直接メモリーに対象のファイルデータを読み込んでいるようでした。

まぁ素直に Razor 使えばよいのですが、今回は View 部分を完全に切り離せるようにしたいため、別の方法を探ります。

で、その方法って?

で、その方法なのですが、そもそも C# (Controller) 側が空の状態であっても、 Request の Body に格納して送信した時点で先ほどの Exception が発生するため、 View 側で何とかする必要がありそうです。

結局どうしたか

あれこれ調べてみたのですが、結局のところはファイルを分割して送信 -> C# (Controller) 側でひとまとめにする、という処理が必要になるようでした。

ということでこの方法を試してみます。

ファイルを分割してアップロード

ファイルの分割

ファイルを指定のサイズで分割する方法の一つに、 FileReader の readAsArrayBuffer を使うものがあります。

readAsArrayBuffer でファイルの内容を ArrayBuffer として読み込み、それを指定サイズ(バイト)ごとに分割して送信します。

mainPage.ts

exports.uploadFile = async function uploadFile(){
    const fileField = document.getElementById("upload_file_form") as HTMLInputElement;

    if(fileField === null ||
        fileField.files === null ||
        fileField.files.length <= 0){
        return;
    }
    // 本当は保存先のディレクトリ作成とか必要だと思います.

    const reader = new FileReader();
    let buffer: Uint8Array;

    // 約 8MB (実際は 7.62MB でした)ずつ分割.
    const size = 8000000;

    // 送信は前回と同じく FormData に.
    let formData: FormData;
    const file = fileField.files[0];

    // 内容が読み込まれたらファイル送信開始.
    reader.onload = async function(e: ProgressEvent) {
        
        // 参考リンクでは e.target.result になっているものがありますが,
        // 見つからなかったので FileReader から取得.
        const result = reader.result;
        if(result === null){
            console.error("result is null");
            return;
        }
        // 読み込んだ内容から Uint8Array を作り、指定サイズごとに分割 -> 送信.
        buffer = new Uint8Array(result as ArrayBuffer);
        let failed = false;   
        let fileIndex = 0;

        for(let i = 0; i < buffer.length; i += size){
            failed = false;
            formData = new FormData();
            formData.append('file', new Blob([buffer.subarray(i, i+size)]))
            await fetch('files/pdf', {
                method: 'POST',
                body: formData,
                headers: {
                    "FileName": file.name,
                    'FileIndex': fileIndex.toString(),
                }
            })
            .catch(error => {
                console.error('Error:', error);
                failed = true;
            });
            if(failed){
                console.log('failed');
                return;
            }
            fileIndex = fileIndex + 1;
            
            // このタイミングでプログレスバーをいじったりすると良さそうです.
        }

        // ファイルを送信し終わったらひとまとめにする処理を実行.
        var endFormData = new FormData();
        endFormData.append('fileName', file.name);
        await fetch('files/pdf/finished', {
            method: 'POST',
            body: endFormData,
        })
        .then(response => console.log('ok'))
        .catch(error => console.error('Error:', error));
    }
    // ファイルの内容を読みこむ.
    reader.readAsArrayBuffer(file);
}
  • 最初 PDF で試していたため URL が PDF になっていますが、手元にあるファイルサイズの関係で PDF 以外のファイルも使っていますがお気になさらず。。。

Uint8Array について

new Uint8Array(result as ArrayBuffer) のような形で生成している Uint8Array 。

見た目的には C# の List のように、中の値をコピーして新しい配列を作っているのかな? とも思ったのですが、↓ Uint8Array.prototype.buffer を見ると元の ArrayBuffer への参照を保持しているようにも思えます。

この引数について、ファイルサイズが正しい場合中身が間違っていてもエラーが発生せずに 分割 -> 送信 と処理が進んでしまうため、マージ完了後に初めてファイルが正しくないことに気づく、という落とし穴があるため注意が必要です。
(参考にしたサイトで使われていた ProgressEvent.target.result が見つからなかったのですが、代替となるデータを渡し間違えてえらい苦労をしましたorz )

Header について

fileField.files[0] をそのまま渡していた場合と異なり、ArrayBuffer として分割したデータからはファイル名が取得できません。

そのため何らかの方法で送信する必要があります。

また分割データを順番通り復元するには、送信したデータの番号も送ってやる必要があります。

Body の FormData に追加することもできますが、今回は Header に入れることにしました。

理由は試したときに Body に複数データをセットした場合のサーバー側での受け取り方がわからなかったのと、エンティティヘッダーでは「メッセージボディの内容を記述する」とあったためです。

分割されたデータの受け取り

サーバー側です。

前回と同じく引数を IFormFile にして受け取ることも可能です。

が、 Microsoft Docs も参考にカスタムクラスで受け取ってみることにしました。

UploadFile.cs

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

public class UploadFile{
    [FromForm(Name="file")]
    public IFormFile File{get; set;}
}

HomeController.cs

using System.Collections.Generic;
using System.Text;
using System;
using System.IO;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.FileProviders;
using Microsoft.Net.Http.Headers;
using System.Net.Http;
using System.Linq;

namespace Controllers {

    public class HomeController : Controller {
        private static readonly string TmpLargeFileDirectory = @"C:/Users/XXX/workspace/DownloadUploadSample/files/tmp/";

        [HttpPost]
        [Route ("/files/pdf")]
        public async Task< IActionResult> UploadPdfFile (UploadFile uploadFile) {
            
            string fileName = Request.Headers["FileIndex"] + "_" + Request.Headers["FileName"];
            if(uploadFile?.File == null){
                Console.WriteLine("failed");
                return BadRequest();
            }
            
            using(var stream = new FileStream(TmpLargeFileDirectory + fileName, FileMode.Create)){
                await uploadFile.File.CopyToAsync(stream);
            }
            return Ok();
        }
    }
}

ほぼ前回と同じです。

カスタムのクラスを使うことで、複数データを渡す場合も名前指定で受け取ることができます。

今回は端折ってますが、ファイルアップロードの開始時点でファイルの保存先を作る、同時に別のユーザーがアップロードしたときに混ざらないようにするなどの処理が実際には必要になります。

Request.Body

データの受け取りについて、 fetch では Body にセットしているのだから、 Request.Body から受け取れないかな~と思ってみたのですが、データをうまく変換できず、断念しました。

ファイルをひとまとめにする

最後に出力されたファイルを一つにまとめます。

ローカルのファイルを読み込む・書き出す辺りは以前ローカルのファイルを読み込んだときの流用です。

HomeController.cs

~省略~
[HttpPost]
[Route ("/files/pdf/finished")]
public async Task< bool> FinishUploading(string fileName){

    List< byte> readBytes = new List< byte>();
    using(PhysicalFileProvider provider = new PhysicalFileProvider(TmpLargeFileDirectory)){
        foreach(IFileInfo fileInfo in provider.GetDirectoryContents(string.Empty)
            .Where(f => f.IsDirectory == false)
            .OrderBy(f => {
                string[] fileNames = f.Name.Split('_');
                int.TryParse(fileNames[0], out int index);
                return index;
            })){
            using(Stream reader = fileInfo.CreateReadStream()){
                int fileLength = (int)fileInfo.Length;
                byte[] newReadBytes = new byte[fileLength];
                reader.Read(newReadBytes, 0, fileLength);
                readBytes.AddRange(newReadBytes);
            }
        }
    }
    using (FileStream stream = new FileStream(TmpLargeFileDirectory + fileName, FileMode.Create)) {
        await stream.WriteAsync(readBytes.ToArray(), 0, readBytes.Count);
    }
    // 本当はこの後分割保存したファイルを削除した方が良いと思います.
    return true;
}
~省略~

おわりに

一応これでサイズが大きなファイルでもアップロードできるようにはなりました。

ただ、ファイルを分割 -> 保存 してもう一度読み込み -> マージして保存 とするのは無駄が大きいように思います。

下記は Rails + nginx ではありますが、最終 Rails ではなく nginx の機能を使って速度を上げています。

これを考えると、もう少し別の方法も考慮した方が良いように思いました。

冒頭でスキップしてしまいましたが、せっかくの ASP.NET Core なので、 Razor を使ってみるのも良いかもしれませんね。
(本当は今回試すつもりでしたが、分割アップロードで躓いて心が折れました。という言い訳)