Description
Updated by @CarnaViire
UPD: Added Alternative Designs section, added opt-out API to the proposal
UPD2: Punted Scope Mismatch fix
HttpClientFactory allows for named clients. The new keyed DI feature can be used to resolve the clients by their name.
API
namespace Microsoft.Extensions.DependencyInjection;
public static partial class HttpClientBuilderExtensions // existing
{
public static IHttpClientBuilder AddAsKeyedScoped(this IHttpClientBuilder builder) {} // new
// alternatives:
// AsKeyed(this IHttpClientBuilder builder, ServiceLifetime lifetime)
// SetKeyedLifetime(this IHttpClientBuilder builder, ServiceLifetime lifetime)
// UPD: optional -- opt-out API
public static IHttpClientBuilder RemoveAsKeyed(this IHttpClientBuilder builder) {} // new
// alternatives:
// DropKeyed(this IHttpClientBuilder builder)
// DisableKeyedLifetime(this IHttpClientBuilder builder)
}
Usage
services.AddHttpClient("foo", c => c.BaseAddress = new Uri("https://foo.example.com"))
.UseSocketsHttpHandler(h => h.UseCookies = false)
.AddHttpMessageHandler<MyAuthHandler>()
.AddAsKeyedScoped();
services.AddHttpClient("bar")
.AddAsKeyedScoped()
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://bar.example.com"));
services.AddHttpClient<BazClient>(c => c.BaseAddress = new Uri("https://baz.example.com"))
.RemoveAsKeyed(); // explicitly opt-out
// ...
public class MyController
{
public MyController(
[FromKeyedServices("foo")] HttpClient fooClient,
[FromKeyedServices("bar")] HttpClient barClient)
{
_fooClient = fooClient;
_barClient = barClient;
}
}
Also, should be able to do the following
services.ConfigureHttpClientDefaults(
b => b.AddAsKeyedScoped()); // "AnyKey"-like registration -- all clients opted in
Considerations
1. Why opt-in and not on by default
Opting in rather than out, because it changes the lifetime from "essentially Transient" to Scoped.
([UPD2: punted] Plus we expect to fix the scope mismatch problem described by #47091, in case Keyed Services infra ()[FromKeyedServices...]
) is used to inject a client -- but this fix must be opt-in in one way or the other, at least in this release
Also I believe there's no straightforward API to un-register a service from service collection once added -- I believe it's done by manually removing the added descriptor from the service collection.
(We can consider making keyed registration a default in the next release, but we'd need to think about good way to opt-out then)
UPD: Added opt-out API to the proposal
2. Why only Keyed Scoped (and not Keyed Transient)
HttpClient
is IDisposable
, and we want to avoid multiple instances being captured by the service provider. Asking for a "new" client each time is a part of HttpClientFactory
guidelines, but DI will capture all IDisposable
s and hold them until the end of the application (for a root provider; or the end of the scope for a scope provider). Captured Transients will break and/or delay rotation clean up, resulting in a memory leak. There's no way to avoid the capturing that I'm aware of.
3. How it should be used in Singletons
By using the "old way" = using IHttpClientFactory.CreateClient()
-- this will continue working as before = creating a scope per handler lifetime.
4. What about Typed Clients
If opting into KeyedScoped, Typed clients will [UPD2: can] be re-registered as scoped services, rather than transients. (This was actually a long-existing ask that we couldn't implement without some kind of opt-in #36067)
This will mean that the Typed clients will stop working in Singletons -- but them "working" there is actually a pitfall, since they're captured for the whole app lifetime and thus not able to participate in handler rotation.
Given that a Typed client is tied with a single named client, it doesn't make much sense to register it as keyed. I see a Typed client as functionally analogous to a service with a [FromKeyedServices...]
dependance on a named client with name = service type name. See the example below.
services.AddHttpClient<BazClient>(c => c.BaseAddress = new Uri("https://baz.example.com"))
.AddAsKeyedScoped();
public class BazClient
{
public BazClient(
HttpClient httpClient,
ISomeOtherService someOtherDependency)
{
_httpClient = httpClient;
_someOtherDependency = someOtherDependency;
}
}
// -- EQUAL TO --
services.AddScoped<BazClient>();
services.AddHttpClient(nameof(BazClient), c => c.BaseAddress = new Uri("https://baz.example.com"))
.AddAsKeyedScoped();
public class BazClient
{
public BazClient(
[FromKeyedServices(nameof(BazClient))] HttpClient httpClient,
ISomeOtherService someOtherDependency)
{
_httpClient = httpClient;
_someOtherDependency = someOtherDependency;
}
}
FWIW I'd even suggest moving away from the Typed Client approach and substitute it with the keyed approach instead. It will also give more freedom if e.g. multiple instances of a "typed client" with different named clients are needed:
services.AddKeyedScoped<IClient, MyClient>(KeyedService.AnyKey);
services.AddHttpClient("foo", c => c.BaseAddress = new Uri("https://foo.example.com")).AddAsKeyedScoped();
services.AddHttpClient("bar", c => c.BaseAddress = new Uri("https://bar.example.com")).AddAsKeyedScoped();
public class MyClient : IClient
{
public MyClient([ServiceKey] string name, IServiceProvider sp)
{
_httpClient = sp.GetRequiredKeyedService<HttpClient>(name);
}
}
// ...
provider.GetRequiredKeyedService<IClient>("foo"); // depends on "foo" client
provider.GetRequiredKeyedService<IClient>("bar"); // depends on "bar" client
provider.GetRequiredKeyedService<IClient>("bad-name"); // throws, as no keyed HttpClient with such name exists
Alternative Designs
These are based around passing the lifetime as a parameter to avoid multiple methods. This is different from ordinary DI registrations, but then again, HttpClientFactory is already a different API set.
1. AsKeyed
AsKeyed(ServiceLifetime)
+ DropKeyed()
- ➕ minimalistic
- ➕ allows for other lifetimes
- ❔ no "paired" opt-out verb (like Add-Remove) to use
- used
DropKeyed
instead. Inspiration:SocketOptionName.DropMembership
(paired withAddMembership
)UdpClient.DropMulticastGroup
(paired withJoinMulticastGroup
)
- used
- other similar
AsKeyed
alternatives:Keyed(ServiceLifetime)
-- ➕ even more minimalisticAddAsKeyed(ServiceLifetime)
-- ❔ pairs with the alternativeRemoveAsKeyed
- other similar
DropKeyed
alternatives:RemoveAsKeyed
-- ❔ from main proposal, with "As" inside
// namespace Microsoft.Extensions.DependencyInjection
// classHttpClientBuilderExtensions
public static IHttpClientBuilder AsKeyed(this IHttpClientBuilder builder, ServiceLifetime lifetime) {}
// --- usage ---
services.AddHttpClient("foo")
.AddHttpMessageHandler<MyAuthHandler>()
.AsKeyed(ServiceLifetime.Scoped);
services.AddHttpClient("bar")
.AsKeyed(ServiceLifetime.Scoped);
services.AddHttpClient("baz")
.DropKeyed();
2. SetKeyedLifetime
SetKeyedLifetime(ServiceLifetime)
+ DisableKeyedLifetime()
- ➕ allows for other lifetimes
- ➕ makes sense even if/when the keyed registration is done by default (when used to change an already set lifetime)
- ❔ aligns with with
SetHandlerLifetime(TimeSpan)
-- though also might be a bit confusing - ❔ "Disable" is not an actual pair for "Set" either, but
SetKeyedLifetime(null)
orClearKeyedLifetime()
are not clear enough - other similar alternatives:
EnableKeyedLifetime(ServiceLifetime)
-- ➕ aligns withDisableKeyedLifetime
AddKeyedLifetime(ServiceLifetime)
-- ❔ hints that a service descriptor will be added to the service collection (e.g. if called multiple times, it will be added multiple times)SetKeyedServiceLifetime(ServiceLifetime)
-- ❔ this one doesn't clash withSetHandlerLifetime
that much, but is more bulky
// namespace Microsoft.Extensions.DependencyInjection
// classHttpClientBuilderExtensions
public static IHttpClientBuilder SetKeyedLifetime(this IHttpClientBuilder builder, ServiceLifetime lifetime) {}
public static IHttpClientBuilder DisableKeyedLifetime(this IHttpClientBuilder builder) {}
// --- usage ---
services.AddHttpClient("foo")
.AddHttpMessageHandler<MyAuthHandler>()
.SetKeyedLifetime(ServiceLifetime.Scoped);
services.AddHttpClient("bar")
.SetKeyedLifetime(ServiceLifetime.Scoped);
services.AddHttpClient("baz")
.DisableKeyedLifetime();
Some dismissed alternative namings/designs
AddHttpClient(...., ServiceLifetime)
overload with a new parameter -- there are already 20 (!!) overloads ofAddHttpClient
, and we'd like to be able to opt-in in all configuration approaches (includingConfigureHttpClientDefaults
)AddKeyedScoped()
(without "As") -- conflicts with existing APIs likeAddHttpMessageHandler
which adds to the client, not to the service collection- Anything other than
Add
, e.g.Register...
-- doesn't align with ServiceCollection APIs, onlyAdd..
/TryAdd..
is used there- UPD: AsKeyed and SetKeyedLifetime made way into Alternative Design section
AddKeyedClient()
,AddKeyedScopedHttpClient()
,AddScopedClient()
,AddHttpClientAsKeyedScoped()
,... -- not clear that not only HttpClient will be registered, but also the related HttpMessageHandler chain and, if present, a related Typed client.AddKeyedServices()
,AddToKeyedServices()
-- not clear that it will be scoped
Old proposal by @CarnaViire
namespace Microsoft.Extensions.DependencyInjection;
public static partial class HttpClientBuilderExtensions
{
public static IHttpClientBuilder AddAsKeyedTransient(this IHttpClientBuilder builder) {}
public static IHttpClientBuilder AddAsKeyedScoped(this IHttpClientBuilder builder) {}
}
Usage:
services.AddHttpClient("foo", c => c.BaseAddress = new Uri("https://foo.example.com"))
.UseSocketsHttpHandler(h => h.UseCookies = false)
.AddHttpMessageHandler<MyAuthHandler>()
.AddAsKeyedTransient();
services.AddHttpClient("bar")
.AddAsKeyedScoped()
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://bar.example.com"));
services.AddHttpClient<BazClient>(c => c.BaseAddress = new Uri("https://baz.example.com"))
.AddAsKeyedScoped();
Alternatives:
services.AddHttpClient("foo")
.AddKeyedServices(); // forces transient-only
// -OR-
services.AddHttpClient("foo")
.AddClientAsKeyedTransient();
.AddMessageHandlerAsKeyedScoped();
// -OR-
enum ClientServiceType
{
NamedClient,
TypedClient,
MessageHandler
}
services.AddHttpClient("foo")
.AddKeyedService(ClientServiceType.NamedClient, ServiceLifetime.Transient)
.AddKeyedService(ClientServiceType.MessageHandler, ServiceLifetime.Scoped);
Original issue by @JamesNK
HttpClientFactory allows for named clients. The new keyed DI feature should be used to resolve clients by their name.
Required today:
services.AddHttpClient("adventureworks", c => c.BaseAddress = new Uri("https://www.adventureworks.com"));
services.AddHttpClient("contoso", c => c.BaseAddress = new Uri("https://www.contoso.com"));
public class MyController
{
public MyController(IHttpClientFactory httpClientFactory)
{
_adventureWorksClient = httpClientFactory.CreateClient("adventureworks");
_contosoClient = httpClientFactory.CreateClient("contoso");
}
}
With keyed DI:
services.AddHttpClient("adventureworks", c => c.BaseAddress = new Uri("https://www.adventureworks.com"));
services.AddHttpClient("contoso", c => c.BaseAddress = new Uri("https://www.contoso.com"));
public class MyController
{
public MyController(
[FromKeyedServices("adventureworks")] HttpClient adventureWorksClient,
[FromKeyedServices("contoso")] HttpClient contosoClient)
{
_adventureWorksClient = adventureWorksClient;
_contosoClient = contosoClient;
}
}
Also would support multiple typed clients with different names. I think there is validation against doing that today, so need to consider how to change validation to allow it.