WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

ASP.NET Core MVC でチャンク形式でダウンロード

by WebSurfer 4. August 2024 19:20

ASP.NET Core MVC のアクションメソッドを使って、ファイルをチャンク形式でエンコーディングしてブラウザにダウンロードする方法を書きます。(Core 版の MVC アプリの話です。.NET Framework 版は先の記事「MVC でチャンク形式でダウンロード」を見てください)

チャンク形式でダウンロード

チャンク形式エンコーディングとは、 HTTP/1.1 で定義されている方式で、送信したいデータを任意のサイズのチャンク(塊)に分割し、各々のチャンクにサイズ情報を付与するエンコード方式です。(HTTP/2 はチャンク方式に対応しておらず、もっと効率的なデータストリーミングの仕組みを提供しているそうです)

メリットは、例えば、作成に時間がかかる大量のデータを動的に作成していて、作成中は全体のサイズが分からないが、部分的にでも作成でき次第送信を始められるというところにあるようです。(一旦全データをバッファして全体のサイズを調べ、Content-Length に設定するということをしなくても済みます)

概略方法を書きますと、(1) HttpResponse オブジェクトを取得、(2) それから Body プロパティを使って出力ストリームを取得、(3) コンテンツをチャンクに分割して WriteAsync メソッドでストリームに書き込む、(4) FlushAsync メソッドでクライアントに送信する、(5) 全チャンクを送信するまで (3) と (4) の操作を繰り返す・・・ということになります。

具体例はこの記事の下に載せたコードを見てください。test.pdf は 34,547 バイトの pdf ファイルで、下のコード例にあるアクションメソッドをブラウザから要求すると、その pdf ファイルのデータを 10,000 バイトずつチャンクに分けて送信するようになっています。

結果はこの記事の一番上の画像を見てください。Fiddler を使って要求・応答をキャプチャしたものです。応答ヘッダの赤線で示した部分を見るとチャンク形式エンコーディングになっていることが分かります。コンテンツの反転表示させた部分 32 37 31 30 に最初に送信されたチャンクのサイズが示されていることが分かります (文字コードは ASCII なので 32 37 31 30 は 2710 ⇒ 10 進数に直すと 10000)。

(注: Fiddler で応答コンテンツを見る際「Response body is encoded, Click to decode.」はクリックしないよう注意してください。クリックするとチャンクはまとめられ、さらに応答ヘッダには Content-Length が追加されて、チャンク形式ではなく普通にダウンロードされたように表示されます)

また、上のコードで送信データの最後を示す長さ 0 のチャンクも送信されています (Fiddler で最後のバイト列が 30 0D 0A 0D 0A となっているのを確認)。もちろん pdf ファイルも Content-Disposition に指定した名前で正しくダウンロードされます。

using Microsoft.AspNetCore.Mvc;

namespace MvcNet8App.Controllers
{
    public class DownloadController : Controller
    {
        // Core では Server.MapPath が使えないことの対応
        private readonly IWebHostEnvironment _hostingEnvironment;

        public DownloadController(IWebHostEnvironment hostingEnvironment)
        {
            _hostingEnvironment = hostingEnvironment;
        }

        // アクションメソッドの戻り値は void または Task にできる
        [HttpGet("/ChunkedDownload")]
        [ResponseCache(Duration = 0, 
                       Location = ResponseCacheLocation.None, 
                       NoStore = true)]
        public async Task ChunkedDownload(CancellationToken token)
        {
            // この例では、アプリケーションルート直下の Files という名前の
            // フォルダの中の test.pdf というファイルをダウンロードする
            string contentRootPath = _hostingEnvironment.ContentRootPath;
            string physicalPath = contentRootPath + "\\" + 
                                  "Files\\" + "test.pdf";

            // チャンクサイズは 10000 とした
            int chunkSize = 10000;
            Byte[] buffer = new Byte[chunkSize];

            using (var stream = new FileStream(physicalPath, FileMode.Open))
            {
                long length = stream.Length;

                // 応答ヘッダに Content-Type と Content-Disposition を含める
                Response.ContentType = "application/pdf";
                Response.Headers.Append("Content-Disposition", 
                                        "attachment;filename=test.pdf");

                // MVC5 の Response.IsClientConnected は使えないので代わりに
                // !token.IsCancellationRequested を使う
                // アクションメソッドの引数に CancellationToken を追加してお
                // けば、フレームワークが HttpContext.RequestAborted から取
                // 得した CancellationToken を引数にバインドしてくれる
                while (length > 0 && !token.IsCancellationRequested)
                {
                    // チャンク形式でダウンロードされていることを確認するため
                    // 入れたコード。コメントアウトを外すとここで 3 秒待つ
                    //await Task.Delay(3000, CancellationToken.None);

                    int lengthRead = await stream.ReadAsync(
                                            buffer.AsMemory(0, chunkSize),
                                            token);

                    // MVC5 の Response.OutputStream は使えない
                    // 同期版のWrite メソッドは AllowSynchronousIO がデフォル
                    // トで false なので使えない
                    await Response.Body.WriteAsync(
                                            buffer.AsMemory(0, lengthRead),
                                            token);

                    // MVC5 の Response.Flush() は使えない
                    await Response.Body.FlushAsync(token);

                    length -= lengthRead;

                }
            }
        }
    }
}

注意点があるので以下に書いておきます。

注 1: IIS を使ってのインプロセスホスティングモデルでホストされる ASP.NET Core Web アプリは、クライアントによる要求の中断を検出してサーバー側の処理をキャンセルすることができます (詳しくは先の記事「要求の中断による処理のキャンセル (CORE)」を見てください)。

しかしながら、上のコードの最初の FlushAsync で応答ヘッダと最初のチャンクがブラウザに送信された時点で、ブラウザの X ボタンは表示されなくなり Esc キーは効かなくなって、それらの操作で処理は中断できなくなります。Windows 10 の Chrome 127.0.6533.89, Edge 127.0.2651.86, Firefox 128.0.3, Opera 112.0.5197.39 ですべて同じになることを確認しました。

ブラウザを閉じた場合、Edge 以外では処理は中断されますが、Edge では処理が続行されてダウンロードが完了してしまいます。理由はクライアントによる要求の中断情報を IIS に送れなくて、サーバー側で CancellationToken がキャンセル状態にならないためと思われますが、詳細は調べ切れておらず不明です。

注 2: 上の注 1 のクライアントによる要求の中断は、プロキシが入ると CancellationToken が IIS に届かなくなり、上のコードでは検出できなくなるので注意してください。(何故で検出できないのか悩んでいたら Fiddler を使っていたというのは内緒です(笑))

注 3: チャンクのバイト列を応答ストリームに書き込むのに同期メソッドの Write は使えません。使うと InvalidOperationException がスローされ、"Synchronous operations are disallowed. Call WriteAsync or set AllowSynchronousIO to true instead." というエラーになります。理由は AllowSynchronousIO が Keatrel を使う場合でも IIS を使う場合でもデフォルトで false に設定されているからだそうです。AllowSynchronousIO を true に設定するのではなく、上のコードのように WriteAsync メソッドを使うのが正解と思います。

注 4: ReadAsync および WriteAsync メソッドで、引数に Byte[], Int32, Int32, CancellationToken を取るオーバーロードを使うと、"より効率的なメモリベースのオーバーロードを呼び出すことをお勧めします" とのことでパフォーマンスルール CA1835 が出るので、それに従って Memory<Byte> / ReadOnlyMemory<Byte>, CancellationToken を引数に取るオーバーロードを使いました。

Tags: , , ,

Upload Download

Web API でファイルアップ/ダウンロード (CORE)

by WebSurfer 17. January 2021 17:41

ファイルをアップロード/ダウンロードする相手が ASP.NET Core 3.1 Web API の場合はどのようにすれば良いかについて書きます。

Web API へファイルアップロード

コードを書いてみましたが、先の記事「ASP.NET Core 3.1 Web API」に書いた、(1) Controller は ControllerBase クラスを継承、(2) ApiControllerAttribute 属性を付与、(3) ルーティングは RouteAttibute 属性を付与して設定、アクションメソッドに [HttpGet], [HttpPost] 属性を付与する以外は MVC の場合とほとんど変わりませんでした。

(MVC の場合は、先の記事「ASP.NET Core MVC でファイルアップロード」と「ASP.NET Core MVC でファイルダウンロード」を見てください)

それでこの記事の話は終わってしまうのですが、それではちょっとブログの記事としては寂しいし、今後の参考になるかもしれないので検証に使ったコードを下にアップしておきます。

Web API コントローラー/アクションメソッド

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

namespace WebAPI.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class FileUpDownloadController : ControllerBase
    {
        // 物理パスの取得用
        private readonly IWebHostEnvironment _hostingEnvironment;

        public FileUpDownloadController(IWebHostEnvironment hostingEnvironment)
        {
            this._hostingEnvironment = hostingEnvironment;
        }

        [HttpPost]
        public async Task<IActionResult> PostFile(IFormFile postedFile)
        {
            string result = "";
            if (postedFile != null && postedFile.Length > 0)
            {
                // アップロードされたファイル名を取得。ブラウザが IE 
                // の場合 postedFile.FileName はクライアント側でのフ
                // ルパスになることがあるので Path.GetFileName を使う
                string filename = Path.GetFileName(postedFile.FileName);

                // アプリケーションルートの物理パスを取得
                // wwwroot の物理パスは WebRootPath プロパティを使う
                string contentRootPath = _hostingEnvironment.ContentRootPath;
                string filePath = contentRootPath + "\\" + 
                                  "UploadedFiles\\" + filename;

                using (var stream = new FileStream(filePath, FileMode.Create))
                {
                    await postedFile.CopyToAsync(stream);
                }

                result = filename + " (" + postedFile.ContentType +
                         ") - " + postedFile.Length +
                         " bytes アップロード完了";
            }
            else
            {
                result = "ファイルアップロードに失敗しました";
            }

            return Content(result);
        }

        [HttpGet]
        [ResponseCache(Duration = 0, 
            Location = ResponseCacheLocation.None, NoStore = true)]
        public IActionResult GetFile(string filename = "sample1.jpg")
        {
            if (string.IsNullOrEmpty(filename))
            {
                return NotFound("引数が null または空");
            }

            // アプリケーションルートの物理パスを取得
            string contentRootPath = _hostingEnvironment.ContentRootPath;

            // ダウンロードするファイルの物理パス
            string physicalPath = contentRootPath + "\\" + 
                                  "Files\\" + filename;

            if (!System.IO.File.Exists(physicalPath))
            {
                return NotFound("指定されたパスにファイルが無い");
            }

            // Content-Disposition ヘッダを設定(RFC 6266 対応してない)
            Response.Headers.Append("Content-Disposition",
                "attachment;filename="+filename);
            
            return new PhysicalFileResult(physicalPath, "image/jpeg");
        }
    }
}

アップロードの検証に使った View

@{
    ViewData["Title"] = "Upload";
}

<h1>Upload</h1>

<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post" enctype="multipart/form-data">
            <div>
                <div>
                    <p>Upload file using this form:</p>
                    @* name 属性はモデルのクラスのプロパティ名と同じ
                       にしないとサーバー側でモデルバインディングさ
                       れないので注意。大文字小文字は区別しない。*@
                    <input type="file" name="postedfile" />
                </div>
            </div>

        </form>
        <div>
            <div>
                <input type="button" id="ajaxUpload" value="Upload" />
                <div id="result"></div>
            </div>
        </div>
    </div>
</div>

<script src="~/Scripts/jquery.js"></script>
<script type="text/javascript">
    //<![CDATA[
    $(function () {
        $('#ajaxUpload').on('click', function (e) {
            // FormData オブジェクトの利用
            var fd = new FormData(document.querySelector("form"));

            $.ajax({
                url: '/FileUpDownload',
                method: 'post',
                data: fd,
                processData: false, // jQuery にデータを処理させない
                contentType: false  // contentType を設定させない
            }).done(function(response) {
                $("#result").empty;
                $("#result").text(response);
            }).fail(function( jqXHR, textStatus, errorThrown ) {
                $("#result").empty;
                $("#result").text('textStatus: ' + textStatus +
                    ', errorThrown: ' + errorThrown);
            });
        });
    });
    //]]>
</script>

ダウンロードの検証はブラウザのアドレスバーにコントローラーの URL を入力して FileUpDownload を GET 要求すれば可能です。ファイル名はデフォルトで "sample1.jpg" となっていますが、クエリ文字列で別のファイルを指定できます。

Tags: , , ,

Upload Download

ASP.NET Core MVC でファイルダウンロード

by WebSurfer 20. January 2020 12:09

ASP.NET Core 3.1 MVC アプリでファイルをダウンロードする方法を書きます。下の画像のようにファイル名とファイルの種類をブラウザにきちんと認識させるのが条件です。(画像は IE11 の例です。通知バーが正しく出ていることに注目してください)

IE11 の通知バー

.NET Framework MVC5 アプリでファイルをダウンロードする方法は、先の記事「MVC でファイルのダウンロード」に書きましたのでそちらを見てください。

ASP.NET Core 3.1 MVC の場合も基本的なことは .NET Framework MVC5 とほぼ同じで、FileResult Class を継承する VirtualFileResult, FileContentResult, FileStreamResult, PhysicalFileResult オブジェクトのいずれかを Controller のアクションメソッドで生成して返してやることになります。

VirtualFileResult, FileContentResult, FileStreamResult オブジェクトを生成するには File メソッドが使えます。File メソッドの第 1 引数の型に応じて生成されるオブジェクトが異なり、以下のようになります。

  1. string 型: VirtualFileResult - コンテンツが既存のファイルとして提供される場合
  2. byte[] 型: FileContentResult - コンテンツがバイト配列として提供される場合
  3. stream 型: FileStreamResult - コンテンツがストリームとして提供される場合

上の 1 番目の「コンテンツが既存のファイルとして提供される場合」は File メソッドの第 1 引数にファイルの仮想バスを設定します(注:物理パスを指定する .NET Framework MVC5 の File メソッドと異なります)。ファイルはアプリケーションルート直下の wwwroot フォルダ下に置きます(そこ以外では見つからないというサーバーエラーになります)。例えば wwwroot/Files/test.pdf をダウンロードする場合は File メソッドの第 1 引数は "/Files/test.pdf" とします。

物理パスを指定したい場合は PhysicalFileResult クラスを使います。File メソッドを使わず明示的に PhysicalFileResult クラスを初期化して返してやる必要があるようです。

File メソッドの第 2 引数にはファイルの MIME タイプ、第 3 引数には拡張子を含むファイル名を設定します。そうすることにより応答ヘッダに Content-Type と Content-Disposition が適切に設定されます。ブラウザによって Content-Type, Content-Disposition のどちらでファイル名とファイルの種類を判断するかが異なりますので両方をきちんと設定するのは必須です。

.NET Framework MVC5 の File メソッドの場合、第 3 引数に設定するファイル名には US-ASCII 文字を使用しないと IE では文字化けしましたが、Core の File メソッドは RFC 6266 (RFC 2231/RFC 5987) に準拠した Content-Disposition ヘッダを生成するので文字化けの問題は回避できるようになりました。例えば第 3 引数に "日本語.pdf" という文字列を設定すると以下の画像にあるような Content-Disposition ヘッダが生成されます。

応答ヘッダ

キャッシュコントロールは ResponseCacheAttribute で設定します。例えば [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] とすると、上の応答ヘッダの画像のとおり Cache-Control: no-store, no-cache / Pragma: no-cache となります。(注: .NET Framework MVC5 の Response.Cache は使えません)

以下に、順に PhysicalFileResult, VirtualFileResult, FileContentResult, FileStreamResult クラスを使った場合のサンプルコードを書いておきます。

PhysicalFileResult

任意のフォルダにある既存のファイルを物理パスを指定してダウンロードする場合です。File メソッドは使えないようなので PhysicalFileResult クラスを直接初期化して return します。Content-Disposition ヘッダは自力でコードを書いて設定します(RFC 6266 に準拠したい場合はそれも自力でコーディング要)。

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Hosting;
using MvcCoreApp.Models;
using Microsoft.AspNetCore.Http;
using System.IO;

namespace MvcCoreApp.Controllers
{
  public class DownloadController : Controller
  {
    // Core では Server.MapPath が使え���いことの対応
    private readonly IWebHostEnvironment _hostingEnvironment;

    public DownloadController(
                      IWebHostEnvironment hostingEnvironment)
    {
      _hostingEnvironment = hostingEnvironment;
    }

    [HttpGet("/downloadfile")]
    [ResponseCache(Duration = 0, 
                   Location = ResponseCacheLocation.None, 
                   NoStore = true)]
    public IActionResult DownloadFile()
    {            
      // Content-Disposition ヘッダを設定
      // (RFC 6266 対応してないので注意)
      Response.Headers.Append("Content-Disposition", 
                            "attachment;filename=test.pdf");

      // アプリケーションルートの物理パスを取得
      // wwwroot の物理パスは WebRootPath プロパティを使う
      string contentRootPath = 
                       _hostingEnvironment.ContentRootPath;

      // ダウンロードするファイルの物理パス。アプリケーション
      // ルート直下の Files フォルダの test.pdf とする
      string physicalPath = contentRootPath + "\\" + 
                            "Files\\" + "test.pdf";
      return new PhysicalFileResult(physicalPath, 
                                    "application/pdf");
    }
  }
}

VirtualFileResult

wwwroot フォルダ下にある既存のファイルを仮想パスを指定してダウンロードする場合です。File メソッドが利用できます。

[HttpGet("/downloadfile")]
[ResponseCache(Duration = 0, 
               Location = ResponseCacheLocation.None, 
               NoStore = true)]
public IActionResult DownloadFile()
{
  // wwwroot/Files フォルダ内の test.pdf を指定
  string virtualPath = "/Files/test.pdf";

  // RFC 6266 に準拠した Content-Disposition ヘッダを生成する
  // ので第 3 引数のファイル名には日本語が使えます
  return File(virtualPath, "application/pdf", "日本語.pdf");
}

FileContentResult

ダウンロードするコンテンツがバイト配列として提供される場合です。コンテンツを SQL Server からバイト配列として取得するような時に利用できそうです。

[HttpGet("/downloadfile")]
[ResponseCache(Duration = 0, 
               Location = ResponseCacheLocation.None, 
               NoStore = true)]
public IActionResult DownloadFile()
{
  // 以下はバイト配列を取得するための単なるサンプル
  string contentRootPath = 
                    _hostingEnvironment.ContentRootPath;
  string physicalPath = contentRootPath + "\\" + 
                        "Files\\" + "test.pdf";
  byte[] data = System.IO.File.ReadAllBytes(physicalPath);

  return File(data, "application/pdf", "日本語.pdf");
}

FileStreamResult

ダウンロードするコンテンツがストリームとして提供される場合です。コンテンツを Web API から HttpClient を利用して取得するような時に利用できるでしょうか。

[HttpGet("/downloadfile")]
[ResponseCache(Duration = 0, 
               Location = ResponseCacheLocation.None, 
               NoStore = true)]
public IActionResult DownloadFile()
{
  // 以下は stream を取得するための単なるサンプル
  string contentRootPath = 
                    _hostingEnvironment.ContentRootPath;
  string physicalPath = contentRootPath + "\\" + 
                        "Files\\" + "test.pdf";
  FileStream stream = 
            new FileStream(physicalPath, FileMode.Open);

  return File(stream, "application/pdf", "日本語.pdf");
}

Tags: , ,

Upload Download

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  October 2024  >>
MoTuWeThFrSaSu
30123456
78910111213
14151617181920
21222324252627
28293031123
45678910

View posts in large calendar