HttpClient Based on Keyed Dependency Injection
Intro
In .NET 8, dependency injection introduced support for keyed services, which can be referenced in .NET 8’s KeyedService. In .NET 9, improvements were made to HttpClient’s name-based dependency injection, allowing the use of keyed services for resolution when using name-based HttpClient.
Sample
We can register a keyed service using the <span>AddHttpClient</span>
method followed by the <span>AddAsKeyed()</span>
method.
Here is a usage example:
var services = new ServiceCollection();
services.AddHttpClient("test1", client =>
{
client.BaseAddress = new Uri("http://localhost:6000");
})
.AddAsKeyed()
;
await using var provider = services.BuildServiceProvider();
var client1 = provider.GetRequiredKeyedService<HttpClient>("test1");
Console.WriteLine(client1.BaseAddress);
After registration, we can retrieve the HttpClient service by name from the dependency injection container, such as <span>provider.GetRequiredKeyedService<HttpClient>("test1")</span>
. Previously, we needed to use
scope.ServiceProvider.GetRequiredService<IHttpClientFactory>()
.CreateClient("test1")
Using keyed services simplifies this process. In ASP.NET Core, we can also use the <span>[FromKeyedService("test1")HttpClient client]</span>
attribute in API action methods.
var builder = WebApplication.CreateBuilder(args);
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();
// Directly inject the Keyed HttpClient by its name
app.MapGet("/", ([FromKeyedServices("github")] HttpClient httpClient) =>
httpClient.GetFromJsonAsync<Repo>("/repos/dotnet/runtime"));
app.Run();
record Repo(string Name, string Url);
The default lifecycle of the registered <span>HttpClient</span>
service is <span>Scoped</span>
. If adjustments are needed, we can do so as follows:
public static IHttpClientBuilder AddAsKeyed(this IHttpClientBuilder builder,
ServiceLifetime lifetime = ServiceLifetime.Scoped)
Example code is as follows:
var services = new ServiceCollection();
services.AddHttpClient("test1", client =>
{
client.BaseAddress = new Uri("http://localhost:5000");
})
.AddAsKeyed()
;
services.AddHttpClient("test2", client =>
{
client.BaseAddress = new Uri("http://localhost:6000");
})
.AddAsKeyed(ServiceLifetime.Singleton)
;
await using var provider = services.BuildServiceProvider();
{
await using var scope = provider.CreateAsyncScope();
var client1 = scope.ServiceProvider.GetRequiredKeyedService<HttpClient>("test1");
Console.WriteLine(client1.GetHashCode());
Console.WriteLine(client1.BaseAddress);
var client2 = scope.ServiceProvider.GetRequiredKeyedService<HttpClient>("test2");
Console.WriteLine(client2.GetHashCode());
Console.WriteLine(client2.BaseAddress);
}
{
await using var scope = provider.CreateAsyncScope();
var client1 = scope.ServiceProvider.GetRequiredKeyedService<HttpClient>("test1");
Console.WriteLine(client1.GetHashCode());
Console.WriteLine(client1.BaseAddress);
var client2 = scope.ServiceProvider.GetRequiredKeyedService<HttpClient>("test2");
Console.WriteLine(client2.GetHashCode());
Console.WriteLine(client2.BaseAddress);
}
Here, one HttpClient has the default lifecycle while the other is specified as <span>Singleton</span>
. When creating two scopes, the HttpClient instances should differ, while the Singleton instance should remain the same.
The output results are as follows:
If there are many HttpClients registered, writing <span>AddAsKey()</span>
for each can be cumbersome. We can use the <span>ConfigureHttpClientDefaults</span>
introduced in .NET 8 to register all HttpClients as named <span>HttpClient</span>
, eliminating the need to write each one individually.
services.ConfigureHttpClientDefaults(c =>
{
c.AddAsKeyed();
});
Using the default HttpClient configuration and individual HttpClient configurations can coexist. If the lifecycle of the individual HttpClient configuration is inconsistent with the default, the individual configuration will take precedence.
When using an unregistered name, the default <span>HttpClient</span>
will be returned.
await using var scope = provider.CreateAsyncScope();
var httpClient = scope.ServiceProvider.GetRequiredKeyedService<HttpClient>("default");
Console.WriteLine(httpClient.GetHashCode());
Console.WriteLine(httpClient.BaseAddress);
var httpClient2 = scope.ServiceProvider.GetRequiredKeyedService<HttpClient>("default");
Console.WriteLine(httpClient2.GetHashCode());
Additionally, we can remove the keyed service registration of HttpClient using the <span>RemoveAsKeyed()</span>
method.
public static IHttpClientBuilder RemoveAsKeyed(this IHttpClientBuilder builder)
Implement
How is it implemented internally? In fact, a keyed service is registered within the httpClient. We can decompile or look at the source code.
First, let’s take a look at the <span>AddAsKeyed()</span>
method.
public static IHttpClientBuilder AddAsKeyed(
this IHttpClientBuilder builder,
ServiceLifetime lifetime = ServiceLifetime.Scoped)
{
ThrowHelper.ThrowIfNull((object) builder, nameof (builder));
string name = builder.Name;
IServiceCollection services = builder.Services;
HttpClientMappingRegistry mappingRegistry = services.GetMappingRegistry();
if (name == null)
{
mappingRegistry.DefaultKeyedLifetime?.RemoveRegistration(services);
mappingRegistry.DefaultKeyedLifetime = new HttpClientKeyedLifetime(lifetime);
mappingRegistry.DefaultKeyedLifetime.AddRegistration(services);
}
else
{
HttpClientKeyedLifetime clientKeyedLifetime1;
if (mappingRegistry.KeyedLifetimeMap.TryGetValue(name, out clientKeyedLifetime1))
clientKeyedLifetime1.RemoveRegistration(services);
HttpClientKeyedLifetime clientKeyedLifetime2 = new HttpClientKeyedLifetime(name, lifetime);
mappingRegistry.KeyedLifetimeMap[name] = clientKeyedLifetime2;
clientKeyedLifetime2.AddRegistration(services);
}
return builder;
}
<span>HttpClientMappingRegistry</span>
is a mapping relationship and the default lifecycle of HttpClient.
internal sealed class HttpClientMappingRegistry
{
public Dictionary<string, Type> NamedClientRegistrations { get; } = new();
public Dictionary<string, HttpClientKeyedLifetime> KeyedLifetimeMap { get; } = new();
public HttpClientKeyedLifetime? DefaultKeyedLifetime { get; set; }
}
<span>HttpClientKeyedLifetime</span>
is implemented as follows:
internal class HttpClientKeyedLifetime
{
public static readonly HttpClientKeyedLifetime Disabled = new(null!, null!, null!);
public object ServiceKey { get; }
public ServiceDescriptor Client { get; }
public ServiceDescriptor Handler { get; }
public bool IsDisabled => ReferenceEquals(this, Disabled);
private HttpClientKeyedLifetime(object serviceKey, ServiceDescriptor client, ServiceDescriptor handler)
{
ServiceKey = serviceKey;
Client = client;
Handler = handler;
}
private HttpClientKeyedLifetime(object serviceKey, ServiceLifetime lifetime)
{
ThrowHelper.ThrowIfNull(serviceKey);
ServiceKey = serviceKey;
Client = ServiceDescriptor.DescribeKeyed(typeof(HttpClient), ServiceKey, CreateKeyedClient, lifetime);
Handler = ServiceDescriptor.DescribeKeyed(typeof(HttpMessageHandler), ServiceKey, CreateKeyedHandler, lifetime);
}
public HttpClientKeyedLifetime(ServiceLifetime lifetime) : this(KeyedService.AnyKey, lifetime) { }
public HttpClientKeyedLifetime(string name, ServiceLifetime lifetime) : this((object)name, lifetime) { }
public void AddRegistration(IServiceCollection services)
{
if (IsDisabled)
{
return;
}
services.Add(Client);
services.Add(Handler);
}
public void RemoveRegistration(IServiceCollection services)
{
if (IsDisabled)
{
return;
}
services.Remove(Client);
services.Remove(Handler);
}
private static HttpClient CreateKeyedClient(IServiceProvider serviceProvider, object? key)
{
if (key is not string name || IsKeyedLifetimeDisabled(serviceProvider, name))
{
return null!;
}
return serviceProvider.GetRequiredService<IHttpClientFactory>().CreateClient(name);
}
private static HttpMessageHandler CreateKeyedHandler(IServiceProvider serviceProvider, object? key)
{
if (key is not string name || IsKeyedLifetimeDisabled(serviceProvider, name))
{
return null!;
}
HttpMessageHandler handler = serviceProvider.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler(name);
// factory will return a cached instance, wrap it to be able to respect DI lifetimes
return new LifetimeTrackingHttpMessageHandler(handler);
}
private static bool IsKeyedLifetimeDisabled(IServiceProvider serviceProvider, string name)
{
HttpClientMappingRegistry registry = serviceProvider.GetRequiredService<HttpClientMappingRegistry>();
if (!registry.KeyedLifetimeMap.TryGetValue(name, out HttpClientKeyedLifetime? registration))
{
registration = registry.DefaultKeyedLifetime;
}
return registration?.IsDisabled ?? false;
}
}
As we can see, the <span>HttpClientKeyedLifetime</span>
is used to register or remove keyed services in the service.
When registering with <span>ConfigureHttpClientDefaults</span>
, it uses <span>KeyedService.AnyKey</span>
to register the <span>HttpClient</span>
.
If not using <span>ConfigureHttpClientDefaults</span>
for registration, an error will occur when trying to retrieve by name if it is not found, resulting in an error similar to the one below.
So what happens if we register the default <span>AddAsKeyed</span>
and then remove the service for an individual HttpClient? Interested readers can try it out!
References
- https://github.com/dotnet/runtime/issues/89755
- https://github.com/dotnet/runtime/pull/104943
- https://devblogs.microsoft.com/dotnet/dotnet-9-networking-improvements/#keyed-di-support
- https://learn.microsoft.com/en-us/dotnet/core/extensions/httpclient-factory-keyed-di
- https://github.com/WeihanLi/SamplesInPractice/blob/main/net9sample/Net9Samples/HttpClientSample.cs
Recommended Reading:EquinoxProject: An open-source project suitable for learning DDD, CQRS, Event Sourcing, and other technologies for building .Net web frameworks..NET Image Processing New Tool! PhotoSauce: A high-quality, high-performance open-source tool for image resizing.Explore QuestPDF: A cross-platform, multifunctional, professional-grade .NET PDF library.Comprehensive Analysis of WinForm Encryption Techniques.Multithreading in .NET.Thread Locks in .NET.
Click the card below to follow DotNet NB
Let’s communicate and learn together
▲Click the card above to follow DotNet NB and learn together
Please respond in the public account backend
Reply 【Roadmap】 to get the .NET 2024 developer roadmapReply 【Original Content】 to get original content from the public accountReply 【Summit Videos】 to get .NET Conf summit videosReply 【Personal Profile】 to get the author’s personal profileReply 【Year-End Summary】 to get the author’s year-end reviewReply【Join Group】 to join the DotNet NB communication and learning group
Long press to recognize the QR code below, or click to read the original text. Join me to communicate, learn, and share insights.