Поделиться через


Поддержка зависимостей с ключами (keyed DI) в IHttpClientFactory

Из этой статьи вы узнаете, как интегрировать IHttpClientFactory с keyed Services.

Keyed Services (также называемый Keyed DI) — это функция внедрения зависимостей (DI), которая позволяет удобно работать с несколькими реализациями одной службы. При регистрации можно связать разные ключи службы с конкретными реализациями. Во время выполнения этот ключ используется в поиске в сочетании с типом службы, что означает, что вы можете получить определенную реализацию, передав соответствующий ключ. Дополнительные сведения о службах Keyed Services и DI в целом см. в внедрение зависимостей .NET.

Для получения сведений об использовании IHttpClientFactory в приложении .NET смотрите в разделе IHttpClientFactory с .NET.

Предыстория

IHttpClientFactory и именованные экземпляры HttpClient, неудивительно, хорошо соответствуют идее ключевых сервисов. Исторически, среди прочего, IHttpClientFactory был способом преодолеть отсутствие этой давней возможности DI. Но обычные именованные клиенты требуют получения, хранения и запроса экземпляра IHttpClientFactory вместо внедрения настроенной HttpClient, что может оказаться неудобным. Хотя типизированные клиенты пытаются упростить такую часть, она поставляется с перехватом: типизированные клиенты легко неправильно настроить и неправильно использовать, а поддерживающая инфраструктура также может быть существенной нагрузкой в определенных сценариях (например, на мобильных платформах).

Начиная с .NET 9 (пакетыMicrosoft.Extensions.Http и Microsoft.Extensions.DependencyInjection версии 9.0.0+), IHttpClientFactory могут напрямую использовать Keyed DI, вводя новый подход использования ключевого DI (в отличие от подходов "Именованный" и "Типизированный"). "Подход с использованием ключей DI связывает удобные, высоконастраиваемые регистрации HttpClient с простым внедрением конкретных настроенных экземпляров HttpClient."

Базовое использование

По состоянию на .NET 9 необходимо принять участие в функции, вызвав метод расширения AddAsKeyed. Если выбрано, именованный клиент, применяющий конфигурацию, добавляется в контейнер DI в качестве службы Keyed HttpClient, используя имя клиента в качестве ключа службы. Это позволяет использовать стандартные API Keyed Services (например, FromKeyedServicesAttribute) для получения необходимых экземпляров Именованных HttpClient (созданных и настроенных IHttpClientFactory). По умолчанию клиенты регистрируются с областью действия и временем жизни.

Следующий код иллюстрирует интеграцию между IHttpClientFactory, keyed DI и ASP.NET Core 9.0 Min 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 внедряется в обработчик запросов через стандартную инфраструктуру keyed DI, которая интегрирована в привязку параметров ASP.NET Core. Дополнительные сведения о службах с указанием ключей в ASP.NET Core см. в разделе внедрение зависимостей в ASP.NET Core.

Сравнение подходов Keyed, Named и Typed

Рассмотрим только код, связанный с IHttpClientFactory, из примера базового использования .

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

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

В этом фрагменте кода показано, как может выглядеть регистрация (1), получение настроенного экземпляра HttpClient(2)и использование полученного экземпляра клиента по мере необходимости (3) при использовании подхода Keyed DI.

Сравните, как эти же шаги выполняются с двумя "старыми" подходами.

Во-первых, с именованным подходом:

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

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

Во-вторых, при типизированном подходе:

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)
}

Из трех подход keyed DI предлагает наиболее краткий способ достичь того же поведения.

Встроенная проверка контейнера DI

Если вы включили регистрацию с использованием ключей для конкретного именованного клиента, вы можете получить к ней доступ с помощью любых существующих API, DI с использованием ключей. Но если вы ошибочно попытаетесь использовать имя, которое еще не включено, вы получите стандартное исключение Keyed 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() в регистрации типизированного клиента регистрируется только базовый именованный клиент как Keyed. Типизированный клиент по-прежнему регистрируется как обычный временный сервис.

Избегайте временной утечки памяти в HttpClient

Важный

HttpClient — это IDisposable, поэтому настоятельно рекомендуется избегать временного срока существования для экземпляров с ключами HttpClient.

Регистрация клиента в качестве временной службы keyed приводит к HttpClient и HttpMessageHandler экземплярам, которые фиксируются контейнером DI, как и реализация IDisposable. Это может привести к утечке памяти , если клиент инициализируется несколько раз в рамках служб Singleton.

Избегайте зависимостей в плену

Важный

Если HttpClient зарегистрировано либо:

  • как ключ Singleton, -OR-
  • как ключевой с областью видимости или временныйи внедряется в длительное (дольше HandlerLifetime) приложение области действия, -ИЛИ-
  • как временный keyedи внедренный в службу Singleton,

— экземпляр HttpClient становится пленникоми, скорее всего, переживет ожидаемый срок службы HandlerLifetime. IHttpClientFactory не имеет контроля над зависимыми клиентами, они не в состоянии участвовать в ротации обработчиков, и это может привести к потере из-за изменений DNS. Аналогичная проблема уже существует для типизированных клиентов, которые регистрируются как временные сервисы.

В случаях, когда долговечность клиента не может быть избегнута или если она сознательно требуется, например, для *keyed Singleton*, рекомендуется использовать SocketsHttpHandler, установив PooledConnectionLifetime разумное значение.

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) // { ...

Остерегайтесь несовпадения масштаба

Хотя срок существования объекта с областью видимости гораздо менее проблематичен для именованных HttpClient(по сравнению с проблемами Singleton и Transient), у него есть своя собственная особенность.

Важный

Время существования заданной области определенного экземпляра HttpClient привязано к обычной области приложения (например, области входящих запросов), из которой она была разрешена. Однако это не относится к базовой цепочке обработчиков сообщений, которая по-прежнему управляется IHttpClientFactory, так же, как это происходит для именованных клиентов, создаваемых непосредственно на фабрике. HttpClientс одним и тем же именем, но разрешённым (в рамках HandlerLifetime временного интервала) в двух разных областях (например, два параллельных запроса к одной и той же конечной точке), могут повторно использовать один и тот же экземплярHttpMessageHandler. Этот экземпляр, в свою очередь, имеет собственную отдельную область, как показано в области обработчика сообщений .

Заметка

Проблема несоответствия области является неприятным и давним, и по состоянию на .NET 9 по-прежнему остается нераскрытым. От службы, внедренной через обычную инфраструктуру di, вы ожидаете, что все зависимости будут удовлетворены из той же области, но для экземпляров с заданной областью ключа HttpClient, это, к сожалению, не так.

Цепочка обработчиков сообщений с ключами

Для некоторых сложных сценариев может потребоваться напрямую получить доступ к цепочке HttpMessageHandler вместо объекта HttpClient. IHttpClientFactory предоставляет интерфейс IHttpMessageHandlerFactory для создания обработчиков; и если включить Keyed DI, то не только HttpClient, но и соответствующая цепочка HttpMessageHandler регистрируется как служба Keyed:

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

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

Практическое руководство. Переход с типизированного подхода к keyed DI

Заметка

В настоящее время мы рекомендуем использовать подход keyed 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. Регистрация типизированного клиента Service разделена на:
    • Регистрация именованного клиента nameof(Service) с той же конфигурацией HttpClient и согласие на DI с использованием ключей.
    • Обычная временная услуга Service.
  2. HttpClient зависимость в Service явно привязана к ключевой службе с ключом nameof(Service).

Имя не обязательно должно быть nameof(Service), но это пример, цель которого — минимизация изменений в поведении. Во внутренней среде типизированные клиенты используют именованные клиенты, и по умолчанию такие "скрытые" именованные клиенты идут по имени типа связанного типизированного клиента. В этом случае "скрытое" имя было nameof(Service), поэтому пример сохранил его.

Технически, пример "распаковывает" типизированный клиент, чтобы ранее "скрытый" именованный клиент стал "открытым", и зависимость удовлетворяется через инфраструктуру Keyed DI вместо типизированной инфраструктуры клиента.

Практическое руководство. Как включить Keyed 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 регистрации определяют сопоставление от любого ключевого значения к некоторому экземпляру службы. Однако в результате проверка контейнера не применяется, и ошибочное значение ключанезаметно приводит к неправильному экземпляру, который внедряется.

Важный

Для ключевых элементов HttpClient, ошибка в указании имени клиента может привести к случайной инъекции "неизвестного" клиента, то есть клиента, имя которого никогда не было зарегистрировано.

То же самое верно для простых именованных клиентов: IHttpClientFactory не требует явной регистрации имени клиента, что соответствует способу работы шаблона параметров . Фабрика предоставляет ненастроенные (или, точнее, установленные по умолчанию)HttpClient для любого неизвестного имени.

Заметка

Поэтому важно помнить: подход "Ключ по умолчанию" охватывает не только все зарегистрированныеHttpClient, но и все клиенты, которые IHttpClientFactory, могут создавать.

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

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

Соображения по стратегии "Активного согласия"

Несмотря на то, что "глобальное" согласие является однострочным, жаль, что функция по-прежнему требует его, а не работает просто "из коробки". Полный контекст и обоснование этого решения см. в разделе dotnet/runtime#89755 и dotnet/runtime#104943. Короче говоря, основным препятствием для включения 'по умолчанию' является ServiceLifetime "противоречие": в текущем (9.0.0) состоянии9.0.0и IHttpClientFactory реализации, не существует такого ServiceLifetime, который был бы достаточно безопасным для всех HttpClientво всех возможных ситуациях. Однако есть намерение решить ограничения в предстоящих выпусках и изменить стратегию с "согласия" на "отказ".

Практическое руководство. Отказ от регистрации с ключами

Вы можете явно отказаться от Keyed DI для HttpClient-s, вызвав метод расширения 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 и конкретное имя клиента, все настройки по умолчанию считаются применяющимися до всех параметров каждого имени. Таким образом, значения по умолчанию можно игнорировать, а последний параметр для каждого имени становится приоритетным.

См. также