-
Notifications
You must be signed in to change notification settings - Fork 5.2k
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 inConsiderations
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 IDisposables 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 existsAlternative 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
DropKeyedinstead. Inspiration:SocketOptionName.DropMembership(paired withAddMembership)UdpClient.DropMulticastGroup(paired withJoinMulticastGroup)
- used
- other similar
AsKeyedalternatives:Keyed(ServiceLifetime)-- ➕ even more minimalisticAddAsKeyed(ServiceLifetime)-- ❔ pairs with the alternativeRemoveAsKeyed
- other similar
DropKeyedalternatives: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 withDisableKeyedLifetimeAddKeyedLifetime(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 withSetHandlerLifetimethat 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 likeAddHttpMessageHandlerwhich 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.