HttpClientを上手に扱えていますか?
.NET アプリケーションを開発していると、外部のAPIと通信するためにHTTPリクエストを送る必要が生じます。
.NETでHTTPリクエストを送信する簡単な方法は、HttpClientを使うことです。
しかし、HttpClientは誤って使われやすいクラスでもあります。
よくある問題としては、ポートの枯渇やDNSの挙動に関するものがあります。
以下がHttpClientを使ううえで知っておくべきことです。
-
HttpClient利用のNG例 -
IHttpClientFactoryを使って設定を簡略化する方法 - 型指定クライアント(typed clients)の設定方法
- シングルトンサービスでは型指定クライアントを使用すべきでない理由
- それぞれの方法の使い分け
それでは、詳しく見ていきましょう!
HttpClientのシンプルな使い方
HttpClientを使う最もシンプルな方法は、新たにインスタンスを作成し、必要なプロパティを設定してリクエストを送信することです。
何が問題になりうるのでしょうか?
HttpClientのインスタンスはアプリケーションのライフサイクル全体で使い回すことを前提に設計されています。
各インスタンスはそれぞれ独自のコネクションプールを持っており、これはそれぞれを分離させるためだけでなく、ポートの枯渇を回避するためでもあります。
サーバーの負荷が大きい状況で、アプリケーションが常に新たな接続を作り続けると、利用可能なポートが枯渇してしまうかもしれません。そうすると、リクエストを送信する際に、実行時エラー(例:SocketException)が発生します。
public class GitHubService
{
private readonly GitHubSettings _settings;
public GitHubService(IOptions<GitHubSettings> settings)
{
_settings = settings.Value;
}
public async Task<GitHubUser?> GetUserAsync(string username)
{
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", _settings.GitHubToken);
client.DefaultRequestHeaders.Add("User-Agent", _settings.UserAgent);
client.BaseAddress = new Uri("https://api.github.com");
GitHubUser? user = await client
.GetFromJsonAsync<GitHubUser>($"users/{username}");
return user;
}
}
どうすれば良いでしょうか?
IHttpClientFactoryを使ってHttpClientを作成する
HttpClientのライフサイクルを自分で管理する代わりに、IHttpClientFactoryを使ってHttpClientのインスタンスを作成することができます。
CreateClientメソッドを呼び出して返ってきたHttpClientのインスタンスでHTTPリクエストを送信するだけです。
この方法の何が良いのでしょうか?
HttpClientにおいてコストが高い部分は、HttpMessageHandlerにあります。それぞれのHttpMessageHandlerは内部的にHTTPのコネクションプールを保持しており、これは再利用可能です。
IHttpClientFactoryはこのHttpMessageHandlerをキャッシュして再利用するため、新しいHttpClientインスタンスを作成するときも効率的に動作します。
ここで重要なのは、IHttpClientFactoryによって作成されたHttpClientインスタンスは短命(short-lived)であることを前提としているという点です。
つまり、使い終わったらすぐに破棄しても問題なく、内部のハンドラは使い回される設計になっています。
public class GitHubService
{
private readonly GitHubSettings _settings;
private readonly IHttpClientFactory _factory;
public GitHubService(
IOptions<GitHubSettings> settings,
IHttpClientFactory factory)
{
_settings = settings.Value;
_factory = factory;
}
public async Task<GitHubUser?> GetUserAsync(string username)
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", _settings.GitHubToken);
client.DefaultRequestHeaders.Add("User-Agent", _settings.UserAgent);
client.BaseAddress = new Uri("https://api.github.com");
GitHubUser? user = await client
.GetFromJsonAsync<GitHubUser>($"users/{username}");
return user;
}
}
名前付きクライアント(named clients)でコードの重複を減らす
IHttpClientFactoryを使うことで、HttpClientを直接作成する際に起こる問題の多くは解決できます。
しかし、作成のたびにデフォルトのリクエストパラメーターを設定する必要がある点は変わりません。
この問題を解決するために、AddHttpClientメソッドを使って「名前付きクライアント(named client)」を定義することができます。
AddHttpClientメソッドはデリゲートを受け取ることができ、その中でHttpClientインスタンスに対してデフォルトのパラメーターを設定できます。
services.AddHttpClient("github", (serviceProvider, client) =>
{
var settings = serviceProvider
.GetRequiredService<IOptions<GitHubSettings>>().Value;
client.DefaultRequestHeaders.Add("Authorization", settings.GitHubToken);
client.DefaultRequestHeaders.Add("User-Agent", settings.UserAgent);
client.BaseAddress = new Uri("https://api.github.com");
});
主な違いは、HttpClientを取得する際に、CreateClientにクライアントの名前を渡す必要がある点です。
しかし、HttpClient の使用方法はずっとシンプルになります。
名前を指定するだけで、異なるサービス間で同じ設定のHttpClientを流用できるのもメリットの一つですね。
public class GitHubService
{
private readonly IHttpClientFactory _factory;
public GitHubService(IHttpClientFactory factory)
{
_factory = factory;
}
public async Task<GitHubUser?> GetUserAsync(string username)
{
using var client = _factory.CreateClient("github");
GitHubUser? user = await client
.GetFromJsonAsync<GitHubUser>($"users/{username}");
return user;
}
}
名前の指定が誤っている場合、例外は発生しませんが、デフォルト設定のHttpClientが生成されます。
名前を定数化するなどの対応が有効です
型指定されたクライアント(typed clients)を利用する
名前付きクライアントを使う際の欠点は、HttpClientを取得するたびに名前を渡して解決しなければならないことです。
同じことを、より良い方法で実現するには「型指定されたクライアント(typed clients)」を使うのが効果的です。
これはAddHttpClient<TClient>メソッドを呼び出して、HttpClientを利用するサービスを設定することで実現できます。
内部的には、型名を名前として使った名前付きクライアントと同じ仕組みが使われています。
services.AddHttpClient<GitHubService>((serviceProvider, client) =>
{
var settings = serviceProvider
.GetRequiredService<IOptions<GitHubSettings>>().Value;
client.DefaultRequestHeaders.Add("Authorization", settings.GitHubToken);
client.DefaultRequestHeaders.Add("User-Agent", settings.UserAgent);
client.BaseAddress = new Uri("https://api.github.com");
});
GitHubServiceの中では、型指定されたHttpClientインスタンスを注入して使用し、
そのインスタンスには、あらかじめすべての設定が適用されています。
もうIHttpClientFactoryを使ったり、HttpClientインスタンスをわざわざ作成したりする必要はありません。
この方法ではTClientであるGitHubServiceが内部的にTransientライフタイムで登録されます。TClientはDIコンテナを使用して個別に登録しないでください。登録すると、前登録が後の登録にとり上書きされるためです。
public class GitHubService
{
private readonly HttpClient client;
public GitHubService(HttpClient client)
{
_client = client;
}
public async Task<GitHubUser?> GetUserAsync(string username)
{
GitHubUser? user = await client
.GetFromJsonAsync<GitHubUser>($"users/{username}");
return user;
}
}
しかし、シングルトンサービスで型指定されたクライアントを使うと、危険な場合があります。
シングルトンサービスで指定クライアントを使用すべきでない理由
指定クライアントをシングルトンサービスに注入すると、問題が発生する可能性があります。
なぜなら、型指定クライアントはTransientライフタイムで登録されているため、
シングルトンに注入されると、そのインスタンスはシングルトンのライフタイム全体にわたってキャッシュされ続けてしまうからです。
型指定クライアントも内部的には、IHttpClientFactoryによって生成されています。つまり、そのインスタンスは短命(short-lived)であることを前提としているのに、それをシングルトンに注入すると1回しか生成されず、再利用されることになります。
これにより、DNSの変更に型指定クライアントが対応できなくなるという問題が起こります。
どうすればいい?
シングルトンサービスの中で型指定クライアントを使いたい場合は、
推奨されるアプローチとして、SocketsHttpHandlerをプライマリハンドラーとして使い、PooledConnectionLifetimeを設定してください。
SocketsHttpHandlerが接続プールを管理してくれるので、
IHttpClientFactory側でハンドラーを定期的に作り直す必要はありません。
HandlerLifetimeにTimeout.InfiniteTimeSpanを指定して、
自動的なハンドラー再生成を無効にしておきましょう。
services.AddHttpClient<GitHubService>((serviceProvider, client) =>
{
var settings = serviceProvider
.GetRequiredService<IOptions<GitHubSettings>>().Value;
client.DefaultRequestHeaders.Add("Authorization", settings.GitHubToken);
client.DefaultRequestHeaders.Add("User-Agent", settings.UserAgent);
client.BaseAddress = new Uri("https://api.github.com");
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
// SocketsHttpHandlerを明示的に指定
return new SocketsHttpHandler()
{
// 同一接続を最大15分まで再利用する
PooledConnectionLifetime = TimeSpan.FromMinutes(15)
};
})
// HttpMessageHandlerの再生成を無効化(デフォルトは2分ごとに再生成される)
.SetHandlerLifetime(Timeout.InfiniteTimeSpan);
もしくは
services.AddHttpClient<GitHubService>((serviceProvider, client) =>
{
var settings = serviceProvider
.GetRequiredService<IOptions<GitHubSettings>>().Value;
client.BaseAddress = new Uri("https://api.github.com");
client.DefaultRequestHeaders.Add("Authorization", settings.GitHubToken);
client.DefaultRequestHeaders.Add("User-Agent", settings.UserAgent);
})
// ConfigurePrimaryHttpMessageHandlerよりも記述がスッキリする
.UseSocketsHttpHandler(handler =>
{
// 同一接続を最大15分まで再利用する
handler.PooledConnectionLifetime = TimeSpan.FromMinutes(15);
})
// HttpMessageHandlerの再生成を無効化(デフォルトは2分ごとに再生成される)
.SetHandlerLifetime(Timeout.InfiniteTimeSpan);
なぜこの設定が必要?
厳密に言うと、上記の設定をしなくても型指定クライアントはDNSの変更に対応することができます。HandlerLifetimeが切れると、IHttpClientFactoryがHttpMessageHandler(= SocketsHttpHandler)を再生成するようになっているからです。
しかし、これに伴って接続プール全体が毎回リセットされます。毎回TCP接続を張り直す必要が生じ、結果としてパフォーマンス低下、ソケット枯渇のリスクにつながります。
SocketsHttpHandlerを明示的に指定し、その中のPooledConnectionLifetimeを設定することで、各TCP接続ごとに寿命を持たせることができます。上記の例では、15分経過した接続だけが破棄され、他の接続は再利用されるようになります。接続プール自体(SocketsHttpHandler)はずっと再利用されるため、以下のようなメリットがあります。
- TCP接続の再利用が効き、パフォーマンスが安定する
- 不要な接続の張り直しやリソース消費を避けられる
- 開発者が「接続単位の寿命」を明示的に制御できるため、運用ポリシーに応じた調整も柔軟に行える
【HandlerLifetime = 2分】
┌──────────────┐ ⏱ 2分経過 ┌──────────────┐
│Handler A │ ───────────────→ │Handler B │
│- Connection Pool A │ │- Connection Pool B │
└──────────────┘ └──────────────┘
❌ プールごと破棄 ❌ 接続も作り直し
--------------------------------------------------
【PooledConnectionLifetime = 15分】
┌──────────────┐
│Handler A(再利用)│
│- Connection Pool │
│ ├─ conn1(10分)│ → 15分で捨てられる
│ ├─ conn2(14分)│ → もうすぐ寿命
│ └─ conn3(1分) │ → まだ再利用可能
└──────────────┘
✅ プールは維持
✅ DNS更新にも対応
それぞれの方法の使い分け
ここまでHttpClientを扱ういくつかの方法をご紹介しました。
しかし、結局のところどのように使い分ければ良いのでしょうか?
Microsoftの推奨
ありがたいことに、MicrosoftはHttpClientのベストプラクティスと推奨される使用方法を提示してくれています。
-
staticまたは "シングルトン"のインスタンスと共に、予想されるDNSの変更に応じた必要な間隔(2分など)が設定されたHttpClientを使用します。PooledConnectionLifetimeこれにより、IHttpClientFactoryのオーバーヘッドを増やすことなく、ポートの枯渇とDNSの変更の両方の問題が解決されます -
IHttpClientFactoryを使うと、異なるユースケース用に、複数の異なる構成のクライアントを使用できます。ただし、ファクトリによって作成されたクライアントは有効期間が短く、クライアントが作成されると、ファクトリで制御できなくなることに注意してください -
IHttpClientFactoryの柔軟な構成機能を活かしたい場合は、型指定クライアント(typed client)を使うのが適しています
などなど
chatGPTに聞いてみた
- 毎回
new HttpClient()- 単純に都度生成するだけなので、最も簡単
- しかし、再利用性が低く、本番や長期利用では問題が出やすい(ソケット枯渇)
-
SingletonHttpClient+SocketsHttpHandler+PooledConnectionLifetime- インスタンスを1つに絞り、接続プールを最大限に利用可能
- 個々の接続の寿命を指定してDNSの変更に柔軟に対応
- 最も高効率で安定的。パフォーマンス重視の本番利用で推奨されるベストプラクティス
-
IHttpClientFactory- ASP.NET Core標準で推奨される方法
- 自動でHandlerの再生成(デフォルト2分)がされるのでDNS更新に対応
- ただし、接続プールがその都度破棄されるため、高負荷環境で若干の性能低下が起こる可能性あり
- 型指定クライアント(typed client)
-
IHttpClientFactoryの仕組みをベースに、型ごとに設定やロジックをカプセル化 - 設定を一元管理しつつ、テスト性・可読性が向上
- 接続プールの制御を組み合わせれば、
Singletonに近い性能を引き出すことも可能
-
お読みいただきありがとうございました。
こちらの記事は主にMilan Jovanovic氏の記事を翻訳したものに、情報を追加したものです。