.NETのHttpClientでメモリに優しく、でかいファイルをダウンロードする

HttpClient使って、なーんも考えずにコード書いて、
でかいファイル(1GBとか、2GBとか、それ以上とか)をダウンロードしようとすると、
メモリ消費ひどかったり、例外出たりするので、その対策メモです。

※ 例示コードは、C#になってます。
※ 接続先とかファイルパスとか、読みやすさ重視で、あえてハードコードで例示しています。

# 1. なーんも考えなかったコード

``` csharp
private static readonly HttpClient httpClient = new HttpClient();

private async Task DownloadAsync()
{
    using (var request  = new HttpRequestMessage(HttpMethod.Get, new Uri("https://127.0.0.1:8443/test.dat")))
    using (var response = await httpClient.SendAsync(request))
    {
        if (response.StatusCode == HttpStatusCode.OK)
        {
            using (var content    = response.Content)
            using (var stream     = await content.ReadAsStreamAsync())
            using (var fileStream = new FileStream(".\\test.dat", FileMode.Create, FileAccess.Write, FileShare.None))
            {
                stream.CopyTo(fileStream);
            }
        }
    }
}
```

単純に、`HttpRequestMessage`作って、`HttpClient.SendAsync(request)`してるだけです。
`HttpRequestMessage`を使わずに、`HttpClient.GetAsync(Uri)`とか使っても同様です。

成功したときには、
レスポンスのストリームと、ファイルのストリームを作って、
`Stream.CopyTo(Stream)`で、ファイルに書き込んでいます。
(`Stream.CopyToAsync(Stream)`も存在しますが、
この場合は、どっちみちファイル読み込みまで待っているので、使うメリットは、ほぼないと思います。)

# 2. どうなるか考えたり、実測してみる

**特になにも指定しない場合**、
`await HttpClient.SendAsync(request)`とか`await HttpClient.GetAsync(Uri)`とかは、
HTTPのヘッダーとBodyを全部読み取ってから結果を返します。
(実際には、`Task`絡みなので、上だと微妙な表現ですが、その辺は適当に汲み取ってもらえればと。)

例えば、2GB近いファイルをダウンロードしようとすると、
HttpClientの内部処理で、2GB近いメモリが利用されます。
実際には、(HTTPのヘッダー) + (HTTPのBody) + α(その他いろいろ処理に必要分)の
メモリが消費されることになります。

プロファイラで見るとこんな感じです。
ダウンロード開始直後に、2GB近いメモリが消費されています。
![MemProf_01.png](https://qiita-image-store.s3.amazonaws.com/0/211666/b0088ea6-34ef-0fab-b437-db6630661327.png)



なお、`HttpClient.MaxResponseContent`プロパティ(デフォルトで2GB)を超えるサイズのファイルを読み込もうとした場合、
HttpClientの内部処理にて、HTTPレスポンスの`Content-Length`ヘッダーを処理した時点で、
`System.Net.Http.HttpRequestException`が発生します。
また、**上記のプロパティのサイズを超えない場合でも**、内部処理で扱うバッファの制限上、
`System.OutOfMemoryException`が発生する場合があります。


# 3. 解決策と実測

対策は単純で、
『**HttpClientがHTTPのヘッダーとBodyを全部読み取ってから結果を返す**』
という部分の動作を変えるだけです。
オプション指定で行けます。


`HttpClient.SendAsync(request)`とか`HttpClient.GetAsync(Uri)`などには、
`HttpCompletionOption`を指定できるオーバーロードがあります。

`HttpCompletionOption.ResponseContentRead`だと、
『**HttpClientがHTTPのヘッダーとBodyを全部読み取ってから結果を返す**』
になります。

`HttpCompletionOption.ResponseHeadersRead`だと、
『**HttpClientがHTTPのヘッダーを読み取った時点で結果を返す**』
になります。

でかいファイルをダウンロードする目的では、
`HttpCompletionOption.ResponseHeadersRead`が都合がよさそうです。


こんな感じのコードになります。
最初のコードとの違いは、1か所のみで、
`var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead))`
のように、`HttpCompletionOption.ResponseHeadersRead`を指定するように変更しただけです。

``` csharp
private static readonly HttpClient httpClient = new HttpClient();

private async Task DownloadAsync()
{
    using (var request  = new HttpRequestMessage(HttpMethod.Get, new Uri("https://127.0.0.1:8443/test.dat")))
    using (var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)))
    {
        if (response.StatusCode == HttpStatusCode.OK)
        {
            using (var content    = response.Content)
            using (var stream     = await content.ReadAsStreamAsync())
            using (var fileStream = new FileStream(".\\test.dat", FileMode.Create, FileAccess.Write, FileShare.None))
            {
                stream.CopyTo(fileStream);
            }
        }
    }
}
```

プロファイラで見るとこんな感じです。
最初のコードを実行した場合は、2GB近いメモリが消費されていましたが、
**対策版では、15MB程度になっています**。
![MemProf_02.png](https://qiita-image-store.s3.amazonaws.com/0/211666/4e2e1d43-9aa4-7c48-d3a1-53b2856872e2.png)

> 実際には、このうち、`HttpClient`とかファイルのダウンロード関連の処理で必要なメモリは、数MB程度だと思います。
> `Stream.CopyTo(Stream)`内部で使うバッファもデフォルトでは、`80KB`程度です。
> バッファのサイズを大きく設定すると、その分、消費メモリも大きくなります。

また、対策版の場合は、
`HttpClient.MaxResponseContent`プロパティを超えるサイズのファイルもダウンロード可能です。