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()
- 単純に都度生成するだけなので、最も簡単
- しかし、再利用性が低く、本番や長期利用では問題が出やすい(ソケット枯渇)
-
Singleton
HttpClient
+SocketsHttpHandler
+PooledConnectionLifetime
- インスタンスを1つに絞り、接続プールを最大限に利用可能
- 個々の接続の寿命を指定してDNSの変更に柔軟に対応
- 最も高効率で安定的。パフォーマンス重視の本番利用で推奨されるベストプラクティス
-
IHttpClientFactory
- ASP.NET Core標準で推奨される方法
- 自動でHandlerの再生成(デフォルト2分)がされるのでDNS更新に対応
- ただし、接続プールがその都度破棄されるため、高負荷環境で若干の性能低下が起こる可能性あり
- 型指定クライアント(typed client)
-
IHttpClientFactory
の仕組みをベースに、型ごとに設定やロジックをカプセル化 - 設定を一元管理しつつ、テスト性・可読性が向上
- 接続プールの制御を組み合わせれば、
Singleton
に近い性能を引き出すことも可能
-
お読みいただきありがとうございました。
こちらの記事は主にMilan Jovanovic氏の記事を翻訳したものに、情報を追加したものです。