次の方法で共有


IHttpClientFactory でのキー付き DI のサポート

この記事では、キー付きサービスと IHttpClientFactory を統合する方法について説明します。

Keyed Services (Keyed DIとも呼ばれます) は、1 つのサービスの複数の実装で便利に操作できる依存関係挿入 (DI) 機能です。 登録時に、さまざまな サービス キー を特定の実装に関連付けることができます。 実行時に、このキーはサービスの種類と組み合わせて参照で使用されます。つまり、一致するキーを渡すことで特定の実装を取得できます。 キー付きサービスと DI 全般の詳細については、「.NET 依存関係の挿入 を参照してください。

.NET アプリケーションで IHttpClientFactory を使用する方法の概要については、「.NET での IHttpClientFactory の」を参照してください。

バックグラウンド

IHttpClientFactory と名前付き HttpClient インスタンスは、当然ながら、キー付きサービスのアイデアとよく一致します。 歴史的に、他のことと共に、特に IHttpClientFactory は、長い間欠けていたDI機能を克服する方法でした。 ただし、プレーンな名前付きクライアントでは、構成された HttpClientを挿入するのではなく、IHttpClientFactory インスタンスを取得、格納、クエリする必要があります。これは不便な場合があります。 Typed クライアントはその部分を簡素化しようとしますが、これには注意点があります。Typed クライアントは、誤設定誤用が生じやすく、サポート インフラストラクチャもまた、特定のシナリオ (モバイル プラットフォームなど) で明確な負荷になる可能性があります。

.NET 9 (Microsoft.Extensions.Http および Microsoft.Extensions.DependencyInjection パッケージ バージョン 9.0.0+) 以降では、IHttpClientFactory はキー付き DI を直接利用でき、("名前付き" および "型指定された" アプローチではなく) 新しい "キー付き DI アプローチ" を導入できます。 "キーによる DI アプローチは、非常に柔軟に構成可能な便利な HttpClient 登録と、具体的に構成された HttpClient インスタンスの直接的な注入を組み合わせています。"

基本的な使用方法

.NET 9 以降、この機能を使用するには拡張メソッドを呼び出してオプト インするAddAsKeyed必要があります。 オプトインすると、構成を適用する名前付きクライアントは、クライアントの名前をサービス キーとして使用して、キー付き HttpClient サービスとして DI コンテナーに追加されるため、標準のキー付きサービス API (FromKeyedServicesAttributeなど) を使用して、目的の名前付き HttpClient インスタンス (IHttpClientFactoryによって作成および構成) を取得できます。 既定では、クライアントは Scoped 有効期間で登録されます。

次のコードは、IHttpClientFactory、キー付き DI、ASP.NET Core 9.0 最小 API の統合を示しています。

var builder = WebApplication.CreateBuilder(args);

// --- (1) Registration ---
builder.Services.AddHttpClient("github", c =>
    {
        c.BaseAddress = new Uri("https://api.github.com/");
        c.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
        c.DefaultRequestHeaders.Add("User-Agent", "dotnet");
    })
    .AddAsKeyed(); // Add HttpClient as a Keyed Scoped service for key="github"

var app = builder.Build();

// --- (2) Obtaining HttpClient instance ---
// Directly inject the Keyed HttpClient by its name
app.MapGet("/", ([FromKeyedServices("github")] HttpClient httpClient) =>
    // --- (3) Using HttpClient instance ---
    httpClient.GetFromJsonAsync<Repo>("/repos/dotnet/runtime"));

app.Run();

record Repo(string Name, string Url);

エンドポイントの応答:

> ~  curl http://localhost:5000/
{"name":"runtime","url":"https://api.github.com/repos/dotnet/runtime"}

この例では、構成された HttpClient は、標準のキー付き DI インフラストラクチャを介して要求ハンドラーに挿入されます。これは、ASP.NET Core パラメーター バインドに統合されています。 ASP.NET Core のキー付きサービスの詳細については、「ASP.NET Core での依存関係の挿入」を参照してください。

キー付き、名前付き、および型指定されたアプローチの比較

の基本的な使用法 例の IHttpClientFactory関連コードのみを検討してください。

services.AddHttpClient("github", /* ... */).AddAsKeyed();                // (1)

app.MapGet("/", ([FromKeyedServices("github")] HttpClient httpClient) => // (2)
    //httpClient.Get....                                                 // (3)

このコード スニペットは(1)、設定済みHttpClientインスタンス(2)を取得し、必要に応じてクライアント インスタンスを(3)使用する登録が、Keyed DI アプローチを使用した場合にどのように表示されるかを示しています。

同じ手順を 2 つの "古い" アプローチで実現する方法を比較します。

1 番目は、Named アプローチを使用した場合:

services.AddHttpClient("github", /* ... */);                          // (1)

app.MapGet("/github", (IHttpClientFactory httpClientFactory) =>
{
    HttpClient httpClient = httpClientFactory.CreateClient("github"); // (2)
    //return httpClient.Get....                                       // (3)
});

2 番目は、Typed アプローチを使用した場合:

services.AddHttpClient<GitHubClient>(/* ... */);          // (1)

app.MapGet("/github", (GitHubClient gitHubClient) =>
    gitHubClient.GetRepoAsync());

public class GitHubClient(HttpClient httpClient)          // (2)
{
    private readonly HttpClient _httpClient = httpClient;

    public Task<Repo> GetRepoAsync() =>
        //_httpClient.Get....                             // (3)
}

3 つのうち、キー付き DI アプローチは、同じ動作を実現するための最も簡潔な方法を提供します。

組み込みの DI コンテナーの検証

特定の名前付きクライアントに対してキー付き登録を有効にした場合は、既存のキー付き DI API を使用してアクセスできます。 ただし、まだ有効になっていない名前を誤って使用しようとすると、標準のキー付き DI 例外が発生します。

services.AddHttpClient("keyed").AddAsKeyed();
services.AddHttpClient("not-keyed");

provider.GetRequiredKeyedService<HttpClient>("keyed"); // OK

// Throws: No service for type 'System.Net.Http.HttpClient' has been registered.
provider.GetRequiredKeyedService<HttpClient>("not-keyed");

さらに、クライアントのスコープ付き有効期間は、キャプティブ依存関係のケースをキャッチするのに役立ちます。

services.AddHttpClient("scoped").AddAsKeyed();
services.AddSingleton<CapturingSingleton>();

// Throws: Cannot resolve scoped service 'System.Net.Http.HttpClient' from root provider.
rootProvider.GetRequiredKeyedService<HttpClient>("scoped");

using var scope = provider.CreateScope();
scope.ServiceProvider.GetRequiredKeyedService<HttpClient>("scoped"); // OK

// Throws: Cannot consume scoped service 'System.Net.Http.HttpClient' from singleton 'CapturingSingleton'.
public class CapturingSingleton([FromKeyedServices("scoped")] HttpClient httpClient)
//{ ...

サービスの有効期間の選択

既定では、AddAsKeyed() はキー付き スコープ付き サービスとして HttpClient を登録します。 ServiceLifetime パラメーターを AddAsKeyed() メソッドに渡すことで、有効期間を明示的に指定することもできます。

services.AddHttpClient("explicit-scoped")
    .AddAsKeyed(ServiceLifetime.Scoped);

services.AddHttpClient("singleton")
    .AddAsKeyed(ServiceLifetime.Singleton);

型指定されたクライアント登録内で AddAsKeyed() を呼び出すと、基になる名前付きクライアントのみがキー付きとして登録されます。 型付けされたクライアント自体は、シンプルな一時サービスとして引き続き登録されます。

一時的な HttpClient メモリ リークを回避する

重要

HttpClientIDisposable であるため、Keyed HttpClient インスタンスに対して Transient 有効期間を避けることを強く推奨します。

クライアントを Keyed Transient サービスとして登録すると、HttpClient インスタンスと HttpMessageHandler インスタンスの両方が IDisposable を実装するため、これらのインスタンスが DI コンテナーによってキャプチャされます。 これにより、クライアントがシングルトン サービス内で複数回解決された場合、 メモリ リークが発生する可能性があります。

キャプティブ依存関係を回避する

重要

HttpClient が登録されている場合:

  • Keyed Singleton として、または
  • Keyed Scoped または Transient として、長期間 (HandlerLifetime より長期) 実行されるアプリケーション Scope 内に挿入されるか、または
  • Keyed Transient として、Singleton サービスにされる

HttpClient は、キャプティブになり、想定される HandlerLifetime を超えて存続する可能性があります。 キャプティブ クライアントを制御できない IHttpClientFactory は、ハンドラーのローテーションに参加できず、DNS の変更が失われる可能性があります。 同様の問題が、Transient サービスとして登録された Typed クライアントにも既に存在します。

クライアントの長寿命が避けられない場合、または意図的に望まれる場合 (例えば、Keyed Singleton の場合)、PooledConnectionLifetime を適切な値に設定して、SocketsHttpHandlerを活用することが推奨されます。

services.AddHttpClient("shared")
    .AddAsKeyed(ServiceLifetime.Singleton) // explicit singleton
    .UseSocketsHttpHandler((h, _) => h.PooledConnectionLifetime = TimeSpan.FromMinutes(2))
    .SetHandlerLifetime(Timeout.InfiniteTimeSpan); // disable rotation
services.AddSingleton<MySingleton>();

public class MySingleton([FromKeyedServices("shared")] HttpClient shared) // { ...

スコープの不一致に注意してください

Scoped 有効期間は、Named HttpClient に対しては (Singleton や Transient の問題点と比較して) 問題が少ないものの、独自の注意点があります。

重要

特定の HttpClient インスタンスのキー付きスコープ有効期間は、想定どおりに、それが解決された "通常の" アプリケーション スコープ (受信要求スコープなど) にバインドされます。 ただし、基になるメッセージ ハンドラー チェーンには適用されません。これは、ファクトリから直接作成された名前付きクライアントの場合と同じ方法で、IHttpClientFactoryによって引き続き管理されます。 HttpClient が、同じ名前を持ち、異なるスコープ (例えば、同じエンドポイントへの 2 つの同時リクエスト) 内で (同じHandlerLifetime期間に) 解決される場合、同じHttpMessageHandlerインスタンスが再利用される可能性があります。 そのインスタンスには、メッセージ ハンドラーのスコープに示すように、独自の個別のスコープがあります。

手記

スコープの不一致 問題は厄介で、既存の問題であり、.NET 9 の時点ではまだ未解決の 残っています。 通常の DI インフラストラクチャを介して挿入されたサービスからは、すべての依存関係が同じスコープから満たされると予想されますが、キー付きスコープ HttpClient インスタンスの場合は、残念ながらそうではありません。

キー付きメッセージ処理チェーン

一部の高度なシナリオでは、HttpClient オブジェクトではなく、HttpMessageHandler チェーンに直接アクセスできます。 IHttpClientFactory は、ハンドラーを作成するための IHttpMessageHandlerFactory インターフェイスを提供します。キー付き DI を有効にすると、HttpClientだけでなく、それぞれの HttpMessageHandler チェーンもキー付きサービスとして登録されます。

services.AddHttpClient("keyed-handler").AddAsKeyed();

var handler = provider.GetRequiredKeyedService<HttpMessageHandler>("keyed-handler");
var invoker = new HttpMessageInvoker(handler, disposeHandler: false);

方法: 型指定されたアプローチからキー付き DI に切り替える

手記

現在、型指定されたクライアントではなく、キー付き DI アプローチを使用することをお勧めします。

既存の型指定されたクライアントからキー付き依存関係への最小限の変更の切り替えは、次のようになります。

- services.AddHttpClient<Service>(         // (1) Typed client
+ services.AddHttpClient(nameof(Service),  // (1) Named client
      c => { /* ... */ }                   // HttpClient configuration
  //).Configure....
- );
+ ).AddAsKeyed();                          // (1) + Keyed DI opt-in

+ services.AddTransient<Service>();        // (1) Plain Transient service

  public class Service(
-                                          // (2) "Hidden" Named dependency
+     [FromKeyedServices(nameof(Service))] // (2) Explicit Keyed dependency
      HttpClient httpClient) // { ...

この例では、次のようになります。

  1. Typed クライアント Service は以下に分割されています。
    • 同じ HttpClient 構成を持つ名前付きクライアント nameof(Service) の登録と、キー付き DI へのオプトイン。そして
    • 基本的な Transient サービス Service
  2. ServiceHttpClient 依存関係は、キー nameof(Service)を持つキー付きサービスに明示的にバインドされます。

名前を nameof(Service)する必要はありませんが、動作の変更を最小限に抑えることを目的とした例です。 内部的には、型指定されたクライアントは名前付きクライアントを使用します。既定では、このような「非表示」の名前付きクライアントは、リンクされた型指定されたクライアントの型名で呼ばれます。 この場合、「隠し名」とされていたものが nameof(Service)であったため、この例でもそれが保持されています。

技術的には、この例では、型指定されたクライアントを "ラップ解除" して、以前の "非表示" の名前付きクライアントが "公開" になり、型指定されたクライアント インフラストラクチャではなくキー付き DI インフラストラクチャを介して依存関係が満たされるようにします。

方法: 既定でキー付き DI にオプトインする

単一のクライアントごとに AddAsKeyed を呼び出す必要はありません。ConfigureHttpClientDefaultsを使用して、(任意のクライアント名に対して) "グローバル" を簡単にオプトインできます。 Keyed Services の視点からは、結果として KeyedService.AnyKey の登録が行われます。

services.ConfigureHttpClientDefaults(b => b.AddAsKeyed());

services.AddHttpClient("first", /* ... */);
services.AddHttpClient("second", /* ... */);
services.AddHttpClient("third", /* ... */);

public class MyController(
    [FromKeyedServices("first")] HttpClient first,
    [FromKeyedServices("second")] HttpClient second,
    [FromKeyedServices("third")] HttpClient third)
//{ ...

"不明" クライアントに注意する

手記

KeyedService.AnyKey 登録は、任意のキー値から特定のサービス インスタンスへのマッピングを定義します。 しかし、その結果として、Container 検証は適用されず、誤ったキー値によってエラーが発生することなく、誤ったインスタンスが挿入されることになります。

重要

キー付き HttpClientの場合、クライアント名の間違いにより、"不明な" クライアント (つまり、名前が登録されなかったクライアント) が誤って挿入される可能性があります。

プレーンな名前付きクライアントでも同じことが当てはまります。IHttpClientFactory は、クライアント名を明示的に登録する必要はありません (オプション パターン 動作する方法に合わせて調整します)。 不明な名前に対しては、未設定の (より正確には既定の) HttpClientがファクトリから提供されます。

手記

したがって、"既定で Keyed を使用" というアプローチは、すべての登録済みHttpClientクライアントだけでなく、IHttpClientFactory が作成可能なすべてのクライアントを対象としていることに注意が必要です。

services.ConfigureHttpClientDefaults(b => b.AddAsKeyed());
services.AddHttpClient("known", /* ... */);

provider.GetRequiredKeyedService<HttpClient>("known");   // OK
provider.GetRequiredKeyedService<HttpClient>("unknown"); // OK (unconfigured instance)

"オプトイン" 戦略に関する考慮事項

"グローバル" オプトインは 1 行で済むとはいえ、この機能が「設定なしに即座に」動作するわけではなく、オプトインが必要です。このような判断に関する詳細な背景と理由については、dotnet/runtime#89755 および dotnet/runtime#104943 を参照してください。 つまり、「デフォルトでオン」に対する主な障害は、ServiceLifetime "論争" です。現在の DI および IHttpClientFactory の実装の状態(9.0.0)では、あらゆる状況で全ての HttpClientに対して合理的に安全な単一の ServiceLifetime はありません。 ただし、今後のリリースの注意事項に対処し、戦略を "オプトイン" から "オプトアウト" に切り替える意図があります。

方法: キー付き登録からオプトアウトする

HttpClientのキー付き DI から明示的にオプトアウトするには、クライアント名ごとに RemoveAsKeyed 拡張メソッドを呼び出します。

services.ConfigureHttpClientDefaults(b => b.AddAsKeyed());      // opt IN by default
services.AddHttpClient("keyed", /* ... */);
services.AddHttpClient("not-keyed", /* ... */).RemoveAsKeyed(); // opt OUT per name

provider.GetRequiredKeyedService<HttpClient>("keyed");     // OK
provider.GetRequiredKeyedService<HttpClient>("not-keyed"); // Throws: No service for type 'System.Net.Http.HttpClient' has been registered.
provider.GetRequiredKeyedService<HttpClient>("unknown");   // OK (unconfigured instance)

または、ConfigureHttpClientDefaultsを使用して "グローバル" に設定します。

services.ConfigureHttpClientDefaults(b => b.RemoveAsKeyed()); // opt OUT by default
services.AddHttpClient("keyed", /* ... */).AddAsKeyed();      // opt IN per name
services.AddHttpClient("not-keyed", /* ... */);

provider.GetRequiredKeyedService<HttpClient>("keyed");     // OK
provider.GetRequiredKeyedService<HttpClient>("not-keyed"); // Throws: No service for type 'System.Net.Http.HttpClient' has been registered.
provider.GetRequiredKeyedService<HttpClient>("unknown");   // Throws: No service for type 'System.Net.Http.HttpClient' has been registered.

優先順位

一緒に呼び出された場合、またはそのいずれかが複数回呼び出された場合、AddAsKeyed()RemoveAsKeyed() は、通常、IHttpClientFactory 構成と DI 登録の規則に従います。

  1. 同じ名前で呼び出された場合、最後の設定が優先されます。最後の AddAsKeyed() の有効期間は、キー付き登録の作成に使用されます (RemoveAsKeyed() が最後に呼び出された場合を除き、その場合は名前が除外されます)。
  2. ConfigureHttpClientDefaults内でのみ使用すると、最後の設定が優先されます。
  3. ConfigureHttpClientDefaultsと特定のクライアント名の両方を使用した場合、すべての既定値は、すべての名前ごとの設定よりも先に実行されると見なされます。 したがって、既定値は無視でき、名前ごとの設定の最後が優先されます。

関連項目