WebSurfer's Home

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

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

by WebSurfer 20. December 2018 11:27

MVC のアクションメソッドを使ってファイルをチャンク形式でエンコーディングしてブラウザにダウンロードする方法を書きます。

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

チャンク形式エンコーディングせず、普通に(Content-Length を設定して)ダウンロードする方法については先の記事「MVC でファイルのダウンロード」に書きましたのでそちらを見てください。

チャンク形式エンコーディングでダウンロードするには、その記事で紹介したようなヘルパーメソッド File は使えませんので別の手段を考えることになると思います。

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

MVC のアクションメソッドでは Controller.Response プロパティで HttpResponse オブジェクトを取得できますので、それを使って上記 (1) ~ (5) の操作を行うことができます。

そのコードを以下に書いておきます。結局は Web Forms アプリの記事「チャンク形式でダウンロード」のコードとほぼ同じですが・・・

public void ChunkedDownload()
{
  string folder = "~/Files/";
  string filename = "Sig552T8.jpg";
  string path = Server.MapPath(folder + filename);
  FileInfo fileInfo = new FileInfo(path);

  if (fileInfo.Exists)
  {
    int chunkSize = 10000;
    Byte[] buffer = new Byte[chunkSize];
    Response.Clear();

    using (FileStream stream = System.IO.File.OpenRead(path))
    {
      long length = stream.Length;
      Response.ContentType = "image/jpeg";
      Response.AddHeader("Content-Disposition",
              "attachment; filename=" + fileInfo.Name);

      while (length > 0 && Response.IsClientConnected)
      {
        int lengthRead = stream.Read(buffer, 0, chunkSize);
        Response.OutputStream.Write(buffer, 0, lengthRead);

        Response.Flush();
        length -= lengthRead;
      }
    }
  }            
}

アクションメソッドの戻り値は ActionResult である必要はなく void にできることに注意してください。

また、上のコードで最後を示す長さ 0 のチャンクも送信されます。(Fiddler で最後のバイト列が 30 0D 0A 0D 0A となっているのを確認しました)

もう一つ、MVC アプリでも HTTP ジェネリックハンドラ(.ashx ファイル)は使えますので、アクションメソッドを使わなくても、HTTP ジェネリックハンドラに同様な機能を実装することは可能です。

Tags:

Upload Download

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

by WebSurfer 14. December 2016 23:49

ASP.NET Web Forms アプリにおいて、ファイルをチャンク形式エンコーディングしてブラウザにダウンロードするサンプルを備忘録として書いておきます。

Fiddler で見た応答

チャンク形式エンコーディングとは HTTP/1.1 で定義されている方式で、送信したいデータを任意のサイズのチャンク(塊)に分割し、各々のチャンクにサイズ情報を付与するエンコード方式です。

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

サーバーにある既存のファイルをダウンロードするような場合はファイルのサイズは分かっていますので、チャンク形式エンコーディングせずに Content-Length を設定してダウンロードする方がよさそうです。(なので、自分的にはチャンク形式エンコーディングの使い道はあまりなさそうですが、せっかく調べたので書いておきます)

ASP.NET Web Forms アプリでは、HttpResponse.OutputStream にチャンクを書き込んだら Flush することでそのチャンクがクライアント(ブラウザ)に送信されます。

具体的には以下のコードのようにします。Sig552T8.jpg は 30,903 バイトの jpeg 画像ファイルで、以下の .aspx ページをブラウザから要求すると、その画像データを 10,000 バイトずつチャンクに分けて送信するようになっています。

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

<%@ Page Language="C#" %>
<%@ Import Namespace="System.IO" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<script runat="server">
  protected void Page_Load(object sender, EventArgs e)
  {
    string folder = "~/images/";
    string filename = "Sig552T8.jpg";
    string path = Server.MapPath(folder + filename);
    FileInfo fileInfo = new FileInfo(path);

    if (fileInfo.Exists)
    {
      int chunkSize = 10000;
      Byte[] buffer = new Byte[chunkSize];
      Response.Clear();   

      using (FileStream stream = File.OpenRead(path))
      {
        long length = stream.Length;
        Response.ContentType = "image/jpeg";
        Response.AddHeader("Content-Disposition", 
                "attachment; filename=" + fileInfo.Name);

        // ここで Flush しても通知バーは表示されない。以下のコ
        // ードのコメントアウトを外すとそれが確認できます。
        // Response.Flush();
        // System.Threading.Thread.Sleep(10000);

        while (length > 0 && Response.IsClientConnected)
        {
          int lengthRead = stream.Read(buffer, 0, chunkSize);
          Response.OutputStream.Write(buffer, 0, lengthRead);

          // ここでの最初の Flush で通知バーが表示される
          Response.Flush();
          length -= lengthRead;

          // chunked ダウンロードされていることを確認するため
          // 試験的に入れたコード。コメントアウトを外すとここ
          // で 5 秒待つ。
          // System.Threading.Thread.Sleep(5000);
        }
      }

      // <!DOCTYPE html ... 以下の html ソースをダウンロード
      // させないために設定
      Response.SuppressContent = true;
    }        
  }
</script>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
</head>
<body>
    <form id="form1" runat="server">
    </form>
</body>
</html>

ブラウザ上ではどういう動きになるかと言うと、IE9 を使った場合ですが、上記コードの中の while ループの最初の Flush で以下の画像のように通知バーが表示されます。

IE9 の通知バー

上の通知バーの[ファイルを開く(O)]または[保存(S)]をクリックすると通知バーは以下の画像のように変わります。(注:Thread.Sleep(5000) のコメントアウトを外して試してください)

ダウンロード中の通知バー

その後、最初に表示された通知バーで[ファイルを開く(O)]をクリックしていた場合はダウンロードが完了すると画像 Sig552T8.jpg がブラウザ上に表示されます。

最初に表示された通知バーで[保存(S)]をクリックしていた場合はダウンロードが完了すると通知バーは以下の画像のように変わります。

ダウンロード完了の通知バー

[ファイルを開く(O)]または[フォルダーを開く(P)]でダウンロードした画像ファイルを確認できます。

Tags:

Upload Download

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。ブログ2はそれ以外の日々の出来事などのトピックスになっています。

Calendar

<<  February 2025  >>
MoTuWeThFrSaSu
272829303112
3456789
10111213141516
17181920212223
242526272812
3456789

View posts in large calendar