【ASP.NET】大容量ファイルのダウンロード&C#での保存方法

C# の ASP.NET Web API で大容量ファイルをダウンロードする方法です。

ファイルの内容全てbyte配列としてメモリ上に展開しクライアントに応答するとファイルの内容分のメモリがリクエストごとに必要になります。ある程度のサイズなら問題にならない事もありますが、これが 100MB~GB などの大きいサイズになるとあっというまにサーバーのリソースを消費しつくしてしまいます。

なので、今回は大容量のデータをメモリ上に展開することなくクライアントに返す方法の紹介と、そのデータを C# のクライアントで受け取って省メモリなファイル保存方法を紹介したいと思います*1

確認環境

確認環境は以下の通り。

  • ASP.NET Core 3.1 + Web API
  • Visual Studio 2022
  • Windows11

既に .NET6 が出ていますが、.NET Core 3.1 で確認しています。

実装例

以下の種類を実装していきます。

  • ASP.NET Web API で1GB単位の大容量ファイルをダウンロードする
  • クライアント側を C# で実装して HttpClient で受け取りデータをファイルに保存する

サーバー側

まずはサーバー側です。

[ApiController]
[Route("v1/sample")]
public class MyController : ControllerBase
{
    private readonly ILogger<MyController> _logger;

    public MyController(ILogger<MyController> logger)
    {
        _logger = logger;
    }

    [HttpGet("test")]
    public IActionResult DownloadBigFile()
    {
        // あらかじめ設定しておく
        Response.StatusCode = StatusCodes.Status200OK;

        // 1GBのファイルの準備
        string filePath = @"C:\Temp\sample.bin";
        string fileName = Path.GetFileName(filePath);

        // ファイルパスを返すだけ扱いで即座に終了する
        // await とか必要ない、ファイルの非同期ストリーム処理はサーバーに任せる
        return PhysicalFile(filePath, "application/octet-stream", fileName);
    }
}

まずは上記の動作確認のために、以下のアドレスにブラウザでアクセスします。

localhost:8080/v1/sample/test

ブラウザ側で sample.bin という名前のファイルのダウロードが始まります。

サーバー側はこれだけです。

クライアント側

次にクライアントです。ブラウザならそもそも実装すら必要ないのですが C# で実装すると比べて面倒です。

上記の応答を C# のクライアントで受け取ってファイルに保存します。こちらも一度メモリに全部展開しないためリソース消費を抑える実装です。

using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

// 単純にストリームをダウンロードする
// サーバー側が指定したファイル名とかは取れない、とにかくストリームを保存する
private static async Task DownloadFileSimple()
{
    using HttpClient client = new HttpClient();
    using Stream responseStream = 
        await client.GetStreamAsync("https://localhost:44381/v1/sample/test");

    string destPath = @"C:\Temp\Result\sample.bin";
    if (File.Exists(destPath)) File.Delete(destPath);

    using var localFileStream = 
        new FileStream(@"C:\Temp\Result\sample.bin", FileMode.CreateNew);

    await responseStream.CopyToAsync(localFileStream, 1024); // ただし進捗は取れない
}

上記コードだとステータスコードとかヘッダーの取り回しができないのでもっと細かい制御をしたい場合以下のように GetStreamAsync の代わりに HttpResponseMessage を返す普通のメソッドを使用することができます。

byte 配列に一度展開したり、オプションの HttpCompletionOption.ResponseHeadersRead を指定し忘れるとメモリに全部乗ってしまうので Stream で扱うのがポイントです。

// HTTP Get の場合
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

// ちゃんとヘッダーとかを参照する
// こっちはサーバー側が指定したファイル名、ヘッダー情報、ステータスコード等色々情報が取れる
private static async Task DownloadFile()
{
    using HttpClient client = new HttpClient();
    using HttpResponseMessage res =
        await client.GetAsync("https://localhost:44381/v1/sample/test",
        HttpCompletionOption.ResponseHeadersRead); // ★このオプションを指定するのが重要

    Console.WriteLine($"[1] {Environment.WorkingSet}byte");

    if (res.StatusCode == HttpStatusCode.OK)
    {
        // サーバー側で指定されたファイル名はヘッダーに入ってる
        string fileName = res.Content.Headers.ContentDisposition.FileName;

        string destPath = @"C:\Temp\Result";
        string savePath = Path.Combine(destPath, fileName);
        if (File.Exists(savePath)) File.Delete(savePath);

        // 書き込み用のストリームにContentから直接書き込める
        using var localFileStream = new FileStream(savePath, FileMode.CreateNew);
        await res.Content.CopyToAsync(localFileStream);
    }
    else
    {
        string responseMsg = await res.Content.ReadAsStringAsync(); // 200以外の時は処理しない
        Console.WriteLine($"Failed. StatusCode = {(int)res.StatusCode}");
    }
}

Get はメソッドのにヘッダーだけ先読みの引数がありましたが、Post、Delete などの Get 以外は汎用的な SendAsync というメソッドを使用します。

もうこうなってくると実装するのがかなり面倒です。

// Get以外のPostなどは処理方法が違う
//  → .NET6とかの最新版では引数追加版が実装されている?
public async Task DownloadFile()
{
    // テキストボックス内に送信するJSONがそのまま入ってる想定で送信データを作成する
    string json = "{サーバーに送るコンテンツ}";
    string address = "https://localhost:44381/v1/sample/test";

    // サーバーに送るコンテンツの作成
    using HttpClient client = new HttpClient();
    HttpContent content = new StringContent(json, Encoding.UTF8, @"application/json");
    var msg = new HttpRequestMessage(HttpMethod.Post, address) // POSTを指定
    {
        Content = content,
    };

    // 汎用メソッドで送信する
    using HttpResponseMessage res =
        await client.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead);
    if (res.StatusCode == HttpStatusCode.OK)
    {
        // サーバー側で指定されたファイル名
        string fileName = res.Content.Headers.ContentDisposition.FileName;
        string path = @"d:\work\" + fileName;
        if (File.Exists(path)) File.Delete(path);

        using FileStream fs = new FileStream(path, FileMode.CreateNew);
        Stream stream = await res.Content.ReadAsStreamAsync();
        await stream.CopyToAsync(fs);
    }
    else
    {
        // エラー処理、とりあえずコンソールに出す
        string responseMsg = await res.Content.ReadAsStringAsync();
        Console.WriteLine($"Failed. StatusCode = {(int)res.StatusCode}");
    }
}

Get や SendAsync メソッドのオプションに HttpCompletionOption.ResponseHeadersRead を渡すと Content 全体を読み取らずにヘッダーをまず取得して残りは後で処理可能できるようになります。やや非常ですがこちらを使用したほうが細かい制御ができるので使用する機会が多いと思います。

最後に .NET のアプリが現在使用しているメモリ量を確認するには以下のようなコードを書くとバイト単位でメモリ使用量を確認することができます。

// バイト単位でメモリ使用量が取れる
Console.WriteLine($"{Environment.WorkingSet}byte");

1GBのファイルをダウンロードした後にこのメソッドで、「1094241824byte(=1GB以上)」とか出てると省メモリダウンロードに失敗しているので実装したら一回は確認しておいたほうが良いです。

*1:とはいえ、大したことを実装するわけではないです。あんまり日本語で情報が無かったのでまとめました