【C#】Web上のファイルをメモリを圧迫せずにダウンロード・保存する方法

目次

概要

HttpClient を使用して、画像、PDF、ZIPなどのバイナリファイルをダウンロードし、ローカルディスクに保存する実装です。 GetByteArrayAsync で全データをメモリに読み込むのではなく、GetStreamAsyncStream.CopyToAsync を使用してストリームとして処理することで、数GB単位の巨大なファイルでもメモリ不足(OOM)を起こさずに効率よく保存できます。

仕様(入出力)

  • 入力: ダウンロード元のURL、保存先のファイルパス。
  • 出力: 指定パスへのファイル生成。
  • 前提: .NET標準ライブラリ(System.Net.Http, System.IO)を使用。インターネット接続が必要。

基本の使い方

レスポンスのストリーム(Webからのデータ流)と、ファイル書き込みのストリーム(ディスクへのパイプ)を繋ぎます。

// ファイルストリームを作成
using var fileStream = File.Create("output.zip");

// ネット上のストリームを取得
using var httpStream = await httpClient.GetStreamAsync(url);

// データを右から左へ流す(コピーする)
await httpStream.CopyToAsync(fileStream);

コード全文

ここでは「製品のマニュアルPDF(数MB〜数GB想定)をダウンロードフォルダに保存する」というシナリオのコンソールアプリケーションです。 C# 8.0以降の using 宣言を使用し、ネストを浅く保っています。

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

class Program
{
    // HttpClientは再利用する
    private static readonly HttpClient _httpClient = new HttpClient();

    static async Task Main()
    {
        // ダウンロード対象のURL(架空のPDF)
        string fileUrl = "https://example.com/downloads/products/manual_v2.pdf";
        
        // 保存先のパス(実行フォルダ直下の 'manual.pdf')
        string destinationPath = Path.Combine(Directory.GetCurrentDirectory(), "manual_v2.pdf");

        Console.WriteLine($"ダウンロード開始: {fileUrl}");

        try
        {
            await DownloadFileAsync(fileUrl, destinationPath);
            Console.WriteLine("ダウンロードが正常に完了しました。");
            Console.WriteLine($"保存先: {destinationPath}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"エラーが発生しました: {ex.Message}");
            // ダウンロードに失敗した作りかけのファイルがあれば削除する等の処理を入れると親切です
            if (File.Exists(destinationPath))
            {
                File.Delete(destinationPath);
            }
        }
    }

    /// <summary>
    /// ストリームを使用してファイルをダウンロード・保存します
    /// </summary>
    static async Task DownloadFileAsync(string url, string outputPath)
    {
        // 1. HTTPレスポンスのストリームを取得(ヘッダー取得後、ボディの受信を開始)
        // usingを使うことで完了後に確実に破棄(Dispose)する
        using var responseStream = await _httpClient.GetStreamAsync(url);

        // 2. 書き込み用のファイルストリームを作成
        // FileMode.Create: ファイルがあれば上書き、なければ作成
        using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None);

        // 3. ネットワークストリームからファイルストリームへデータを転送
        // 同期版の CopyTo ではなく、非同期版の CopyToAsync を使用してスレッドをブロックしないようにする
        await responseStream.CopyToAsync(fileStream);
    }
}

カスタムポイント

  • タイムアウト延長: デフォルトのタイムアウト(100秒)を超えるような巨大ファイルのダウンロードを行う場合は、_httpClient.Timeout を長めに設定するか、InfiniteTimeSpan を設定してください。
  • バッファサイズ調整: CopyToAsync のオーバーロードでバッファサイズを指定できますが、通常はデフォルト(81920バイト=80KB程度)のままで十分高速です。
  • キャンセル対応: ユーザー操作で中断できるようにするには、CopyToAsync(stream, cancellationToken) にトークンを渡します。

注意点

  1. 同期メソッドの回避: 入力コードにあった CopyTo(同期)は、ネットワーク待ちの間にメインスレッドをブロックするため、GUIアプリではフリーズの原因になります。必ず CopyToAsync を使用してください。
  2. 不完全なファイル: ダウンロード中に例外が発生した場合、ディスクには「途中まで書き込まれたファイル(壊れたファイル)」が残ります。catch ブロックでゴミファイルを削除する処理(クリーンアップ)を入れるのが安全です。
  3. ディスク容量: ストリーム処理でメモリは食いませんが、ディスク容量は必要です。事前に空き容量をチェックするか、IOException(ディスクフル)をハンドリングしてください。

応用

プログレスバー(進捗状況)の表示

標準の GetStreamAsync は進捗を通知してくれません。進捗を知るには GetAsyncHttpCompletionOption.ResponseHeadersRead を使い、Content-Length(総容量)を取得して自前で計算する必要があります。

// ヘッダーだけ先に読む
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
var totalBytes = response.Content.Headers.ContentLength ?? -1L;

using var stream = await response.Content.ReadAsStreamAsync();
using var fileStream = new FileStream(path, FileMode.Create);

// バッファを用意してループで読み書きし、進捗を表示する
byte[] buffer = new byte[8192];
int bytesRead;
long totalRead = 0;

while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
    await fileStream.WriteAsync(buffer, 0, bytesRead);
    totalRead += bytesRead;
    
    if (totalBytes > 0)
    {
        Console.Write($"\r進捗: {(double)totalRead / totalBytes * 100:F1}%");
    }
}

まとめ

失敗時のファイル削除(後始末)を実装することで、システムの信頼性が上がります。

ファイルダウンロードには GetStreamAsyncCopyToAsync の組み合わせが鉄板です。

全データをメモリ配列(byte[])に入れる方法は、小規模ファイル以外では避けてください。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

私が勉強したこと、実践したこと、してることを書いているブログです。
主に資産運用について書いていたのですが、
最近はプログラミングに興味があるので、今はそればっかりです。

目次