Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HttpClientFactory support for keyed DI #89755

Closed
JamesNK opened this issue Aug 1, 2023 · 12 comments · Fixed by #104943
Closed

HttpClientFactory support for keyed DI #89755

JamesNK opened this issue Aug 1, 2023 · 12 comments · Fixed by #104943
Assignees
Labels
api-approved API was approved in API review, it can be implemented area-Extensions-HttpClientFactory blocking Marks issues that we want to fast track in order to unblock other important work enhancement Product code improvement that does NOT require public API changes/additions in-pr There is an active PR which will close this issue when it is merged
Milestone

Comments

@JamesNK
Copy link
Member

JamesNK commented Aug 1, 2023

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 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 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 with AddMembership)
      • UdpClient.DropMulticastGroup (paired with JoinMulticastGroup)
  • other similar AsKeyed alternatives:
    • Keyed(ServiceLifetime) -- ➕ even more minimalistic
    • AddAsKeyed(ServiceLifetime) -- ❔ pairs with the alternative RemoveAsKeyed
  • 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) or ClearKeyedLifetime() are not clear enough
  • other similar alternatives:
    • EnableKeyedLifetime(ServiceLifetime) -- ➕ aligns with DisableKeyedLifetime
    • 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 with SetHandlerLifetime 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 of AddHttpClient, and we'd like to be able to opt-in in all configuration approaches (including ConfigureHttpClientDefaults)
  • AddKeyedScoped() (without "As") -- conflicts with existing APIs like AddHttpMessageHandler which adds to the client, not to the service collection
  • Anything other than Add, e.g. Register... -- doesn't align with ServiceCollection APIs, only Add../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.

@ghost ghost added the untriaged New issue has not been triaged by the area owner label Aug 1, 2023
@ghost
Copy link

ghost commented Aug 1, 2023

Tagging subscribers to this area: @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

Issue Details

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;
    }
}
Author: JamesNK
Assignees: -
Labels:

area-Extensions-HttpClientFactory

Milestone: -

@karelz
Copy link
Member

karelz commented Aug 1, 2023

Triage: We should try to integrate with KeyedServices new feature if it is low cost. It will avoid introducing potential breaking changes in next release.

@karelz karelz added this to the 8.0.0 milestone Aug 1, 2023
@ghost ghost removed the untriaged New issue has not been triaged by the area owner label Aug 1, 2023
@karelz karelz added the enhancement Product code improvement that does NOT require public API changes/additions label Aug 1, 2023
@ghost ghost added the in-pr There is an active PR which will close this issue when it is merged label Aug 9, 2023
@karelz karelz modified the milestones: 8.0.0, 9.0.0 Aug 14, 2023
@karelz
Copy link
Member

karelz commented Aug 14, 2023

Moving it to 9.0 per discussion in #90272 (comment)

@ghost ghost added in-pr There is an active PR which will close this issue when it is merged and removed in-pr There is an active PR which will close this issue when it is merged labels Oct 12, 2023
@ghost ghost removed the in-pr There is an active PR which will close this issue when it is merged label Nov 11, 2023
@CarnaViire CarnaViire added api-ready-for-review API is ready for review, it is NOT ready for implementation blocking Marks issues that we want to fast track in order to unblock other important work labels Jul 9, 2024
@halter73
Copy link
Member

halter73 commented Jul 9, 2024

  • Anything other than Add, e.g. Register... -- doesn't align with ServiceCollection APIs, only Add../TryAdd.. is used there

I think we might want to consider AsKeyedScoped. I know other service collection APIs use "Add", but this is unusual because it's effectively modifying how a service that was already added gets registered. I cannot currently think of another example where we do this, so I don't think unusual naming is a bad thing if it communicates that the API has unusual behavior.

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.

This is sad. Ironically, our guidance for transient IDisposables is to "use the factory pattern to create an instance outside of the parent scope. In this situation, the app would generally have a Create method that calls the final type's constructor directly."

Given that keyed services are intended to be replacements for simple factories, it'd be nice if service registrations could opt-out of automatic disposal. #36461 is tracking this as a global option, but for something like this feature to make use of it, it would need to be configurable per ServiceDesriptor. @davidfowl @benjaminpetit

If we were designing something completely new, we'd just make sure the transient service and implementation types did not implement IDisposable, but that's not an option for HttpClient which brings me to my next thought...

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.
[...]
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.

What if we made the keyed HttpClient registration a singleton? I understand that it would not be able to participate in handler rotation as currently implemented. But as you point out, this is already broken when typed clients are used in singletons or when CreateClient() is only called once in a singleton, so it's not a new problem.

And we could try fixing this problem in the majority of cases by registering a keyed singleton HttpClient decorator that forwards SendAsync to the "real" HttpClient. If we updated IHttpClientFactory.CreateClient() to return this keyed singleton decorator, this would also fix existing bugs that people might not realize they have.

I know that there's more than just SendAsync to consider such as the settable, non-virtual BaseAddress property, but I doubt it's common to modify these after the HttpCilent is resolved. We could throw if someone tries to change BaseAddress, Timeout, MaxResponseContentBufferSize, etc... after it's been resolved from DI to mitigate this. As much as I would love to make both keyed service and singleton rotation support opt-out rather than opt-in, I understand this would be pretty breaking. But if we're going to make it opt-in either way, we might as well make all the breaking changes we want to at once.

@julealgon
Copy link

I'm sad to see @JamesNK original proposal not be the one used in the end here and instead you keep piling on special casing and custom stuff into this nasty AddHttpClient hack to a missing "dependent service" registration feature in the container.

With each extra custom API that gets added like this, you make the whole DI system more and more complicated to use.

At least James' original idea intended to remove some of the "custom" nature of the method and rely on the new keyed service support. Now it will be even more custom with extra API additions.

I've made this point in a few other issues with a few different folks before, but I think you should really reconsider this for the long term here. What the DI framework needs is a way to indicate a dependent service registration/configuration that works not only for HttpClient but generically.

HttpClient could leverage it, named IOptions could leverage it, ILogger could leverage it.

You keep creating special cases around each and that will just not scale in the long term IMHO.

@CarnaViire
Copy link
Member

CarnaViire commented Jul 11, 2024

@halter73

I think we might want to consider AsKeyedScoped.

It was actually the first name I considered, and then I forgot to include it in the list 🙈 The problem with AsKeyedScoped is that while it looks really nice if used right after AddHttpClient:

services.AddHttpClient("foo").AsKeyedScoped(); // nice!

but as soon as it's placed after any other configuration methods, it becomes super confusing, like it is no longer about the client, but about the previous item:

services.AddHttpClient("foo")
    .AddHttpMessageHandler(...).AsKeyedScoped(); // is it about the additional handler?

services.AddHttpClient("foo")
    .UseSocketsHttpHandler(...).AsKeyedScoped(); // is it about SocketsHttpHandler?

services.AddHttpClient("foo")
    .SetHandlerLifetime(...).AsKeyedScoped(); // ???

it'd be nice if service registrations could opt-out of automatic disposal. ... for something like this feature to make use of it, it would need to be configurable per ServiceDesriptor.

That would be PERFECT actually. I wonder if that's something that could be squeezed into 9.0 (sweet dreams 😅)

This would make transient clients "safe" from the memory leak, so that would mean that we can (at least) bring back AddAsKeyedTransient from the first version of my proposal 😄

And then we even technically can consider AddAsKeyedTransient being called by default (but for that I need to dig up the investigations from last year as to why exactly we then decided that default wouldn't work so we must have an API -- I vaguely remember that there was a problem caused by DI containers that don't support Keyed services)


What if we made the keyed HttpClient registration a singleton?

Some time ago, we actually did briefly entertain the idea of pushing the rotation logic down -- into some kind of a "magic handler". This sounds very close to what you were proposing, but with a "wrapper" handler instead of a "wrapper" client. I still would like to investigate this approach in the future. But this would most possibly require an untrivial amount of time 😢

So while the idea is tempting, it definitely will not fit in 9.0 😢

@CarnaViire
Copy link
Member

CarnaViire commented Jul 11, 2024

@julealgon

Oh I would love to have a keyed registration a default. (I even tried to do so last year)

But with the current state of DI -- we cannot.

As I mentioned in the Considerations section -- we can't make a Scoped HttpClient registration a default, and we can't have a Transient HttpClient registration at all at the moment.

Also, even if we were able to implement a default Transient registration -- I still see a value in having an option to change the lifetime, and use Scoped instead. But then you need an API for that 😄

At least James' original idea intended to remove some of the "custom" nature of the method and rely on the new keyed service support. Now it will be even more custom with extra API additions.

I'm not sure I understand the "contrast" between "ideas" you're trying to make. These are different things/"points in time". The initial ask was to be able to inject the clients via the keyed services infra. So the "custom nature" that is being "removed" in this case is the necessity to inject an IHttpClientFactory instance. The ask is still satisfied, and the "custom nature" is still being "removed", even with the presence of the opt-in API at the registration time.

@julealgon
Copy link

@CarnaViire

I still see a value in having an option to change the lifetime, and use Scoped instead. But then you need an API for that 😄

Of course there is value in being able to change the lifetime, I think everyone would agree with that. The problem is in the second part: the API to do so.

The container's original API was badly designed from the beginning IMHO, with the hardcoded nature of the lifetime in the same call as the types to register. These APIs will never scale properly:

  • Add{Lifetime}

Because other APIs were introduced later to register specific things but because the lifetime was built into the original methods (instead of being chained, or just be a parameter, or whatever) you end up with things like:

  • AddHttpClient // ...where is the lifetime now?
  • AddHostedService // same problem, even though hosted services will only be resolved once so it doesn't matter much here
  • AddKeyed... // now you have to replicate the overload 3 times one for each lifetime.... because again, the original API is not scalable/composable

If suddenly we want a AddLazy... extension, now it also needs to replicate the method 3 times, one for each lifetime, because the lifetime is coupled to the registration call...

Then if you ever need/want to introduce some sort of fourth lifetime, or a custom lifetime, you are screwed because you now have to go over a bunch of existing methods creating overloads for each of them to support the added lifetime.

Many container libraries have solved this issue by decoupling the types to register from the lifetime, either by having the lifetime be a parameter to the registration, or be a fluent call with a separate method, but yet Microsoft still made this obvious mistake. Even the original Unity container didn't have this issue.

I honestly believe a new more usable/composable API surface should be designed for MEDI while the old methods should be deprecated. It is the only way to move past these silly limitations.

Creating a .As{Lifetime} method while using all the old Add{Lifetime} ones in the same scope is extremely confusing. It only makes the whole API not orthogonal and more complicated to use. Now suddenly you need to understand that there are multiple ways to specify a lifetime depending on how you make the call.

At that point, why not just have Add().As{Lifetime} be the standard API then? (or something to that effect)?

At least James' original idea intended to remove some of the "custom" nature of the method and rely on the new keyed service support. Now it will be even more custom with extra API additions.

I'm not sure I understand the "contrast" between "ideas" you're trying to make. These are different things/"points in time". The initial ask was to be able to inject the clients via the keyed services infra. So the "custom nature" that is being "removed" in this case is the necessity to inject an IHttpClientFactory instance. The ask is still satisfied, and the "custom nature" is still being "removed", even with the presence of the opt-in API at the registration time.

Fair enough (based on the bolded). My concern was specifically with the addition of the completely custom (and outlandish based on the current API) AddAsKeyedScoped. The fact that that fluent call is now needed is an abomination and shows the obvious design flaw with the original API that I mentioned above.

My take has been that the team should stop and think about this more deeply. Stop introducing special cases for things and making the DI API more convoluted to use, and think about something that works more seamlessly, in a more orthogonal manner.

The API as it is is doomed to fail. It will get worse and worse over time as more of these "special" requirements come in. It has already been tarnished with the original introduction of AddHttpClient, named options and named httpclients. All of that should be migrated either to keyed registrations (now that the concept exists in MEDI) and proper "dependent registrations" (which do not exist yet).

If the container supported dependent registrations, even most cases of named/keyed services would completely go away:

services.Add<IHttpClientFactory, HttpClientFactory>(options => 
{
    options
        .WithSingletonLifetime()
        
        // General purpose "factory" mechanism in the container. 
        // No need for special casing factories all over the place
        // Allows fetching `HttpClient` from the container and use the factory to create them
        .FactoryFor<HttpClient>()
        .Configure(/* general purpose delegating handlers can be configured here */);
});

services.Add<IMyRepository, GenericRepository>(options => options.WithScopedLifetime());

services.Add<IMyService, MyService>(options => 
{
    options
        .WithTransientLifetime()
        .DependsOn(services =>
        {
            // Allow for specific dependent registrations that only apply to this service.
            // Other services requesting `IMyRepository` will still return `GenericRepository` from above
            services.Add<IMyRepository, SomeSpecialRepositoryImplementation>();

            // General-purpose `Configure` API that can be used for any object, not only `IOptions`
            // this configuration only applies to this particular service since it is inside `DependsOn`.
            // Keep in mind the `HttpClient` will still be constructed by the factory as thats defined outside
            services.Configure<HttpClient, MyApiOptions>((httpClient, apiOptions) => 
            {
                httpClient.BaseAddress = apiOptions.BaseAddress;
                httpClient.Timeout = apiOptions.GlobalTimeout;
            ));
            
            services.Configure<IHttpClientFactory>(/* specific delegating handlers can be set here */);
        },
});

services.Add<ILoggerFactory, LoggerFactory>(options => 
{
    options
        .WithTransientLifetime()
        .FactoryFor<ILogger>()
        .DependOn((services, injectionContext) => 
        {
            // Overload allows access to `injectionContext` object, which provides the target type
            // Factory receives the type and uses it to populate the `Category` for the `ILogger`,
            // eliminating the need for the `ILogger<T>` hack.
            services.Add<Type>(injectionContext.TargetType);
        });
}

public sealed class MyService(
    HttpClient httpClient, 
    IMyRepository myRepository, 
    ILogger logger)
    : IMyService
{
    // specific `HttpClient` injected without named/keyed registrations, still created by factory
    // custom `IMyRepository` injected without keyed registrations for single use case
    // `ILogger` has proper `Category` set based on `MyService` type. No need for `ILogger<T>` to exist
    ...
}
  • No need for coupling lifetimes with types to register
  • No need for special "named" httpclients since the dependency is 1 to 1 (can use general purpose keyed registrations for 1:N cases)
  • No need for special "typed" httpclients same as above
  • No need for special "configuration" mechanism for IOptions: becomes generic and applies to things like HttpClient without the need for special ConfigureClient etc
  • No need for IOptions mechanism/abstraction whatsoever (another hack, IMHO)
  • No need for special AddHttpClient with configurations by just making that a standard method that can work on any instance
  • No need for special ILogger<T> to workaround container limitation of not knowing the injection target type

@CarnaViire
Copy link
Member

Thanks for the detailed write up @julealgon!

I can definitely see your points. I can relate to the frustration.
I cannot speak from behalf of M.E.DI owners though -- I'm a part of the Networking Libraries team (System.Net.*). I can only suggest you to create a separate issue with the points and suggestions you have (I think you can just take the comment and create an issue from it), so it will get categorized into area-Extensions-DependencyInjection. This can both reach the code owners, and give an opportunity for other people to express the interest in the same thing.

But I must set the expectations -- what you say here

I honestly believe a new more usable/composable API surface should be designed for MEDI while the old methods should be deprecated. It is the only way to move past these silly limitations.

is not an option. We can't break the users like this.


I've added a couple of alternative designs that take in a ServiceLifetime though.

And it is a different API shape already (IHttpClientBuilder, not IServiceCollection). So even if there were some nice IServiceCollection APIs, they won't be applicable to IHttpClientBuilder as is anyway. But the users of IHttpClientBuilder APIs should still be able to use the feature (meaning Keyed DI support), so that's what we're trying to do here.

@CarnaViire
Copy link
Member

CarnaViire commented Jul 12, 2024

Updated the top post:

  • added opt-out API (optional)
    • RemoveAsKeyed()
  • added alternative designs section
    • AsKeyed(ServiceLifetime) + DropKeyed()
    • SetKeyedLifetime(ServiceLifetime) + DisableKeyedLifetime()

cc @halter73 @JamesNK @MihaZupan @ManickaP

@CarnaViire
Copy link
Member

CarnaViire commented Jul 13, 2024

Just wanted to record some thoughts regarding pros and cons of the automatic opt-in to the context Scope propagation (#47091), in case the client is registered with the Keyed DI:


On one hand...

✅ Align with expectations from resolving

Scope propagation will align with expectations from resolving a client, if it has any scoped dependencies. This will "automatically" fix the scope mismatch problem.

If I resolve a service from a specific (scoped) service provider, I definitely would expect the service's scoped dependencies to be satisfied from this exact service provider.

The existing scope mismatch problem is big enough already. While we technically could try arguing that "bla bla when you get a client from a singleton HttpClientFactory, the factory doesn't have any access to a scope, and that's why a client is created in a different one", this is moot when the factory is hidden "under the hood" of the keyed DI infra. The scope mismatch would become even more prominent and even more confusing.


On the other hand...

⚠️ Potentially unnecessary allocations

The first point is, if I don't have any scoped dependencies, then I don't care, and I don't really want to recreate a handler chain in each and every little scope (esp., if I imagine turning the rotation off by setting the handler lifetime to Infinity)

This would be rather hard to catch and troubleshoot, if there's no separate opt-in/opt-out from the scope propagation feature.

⚠️ PrimaryHandler premature disposal danger

The second point is, we cannot cache a user-provided primary handler, in case it was registered in DI in any way (e.g. if using ConfigurePrimaryHttpMessageHandler<THandler>(IHttpClientBuilder) overload) -- if resolving it within an existing scope, it will get disposed by DI after that scope is finished -- which can be earlier than the handler lifetime expires -- as opposed to the detached scope HttpClientFactory creates and makes sure it's only disposed after the handler is expired and garbage-collected)

This one can be partially mitigated by only allowing the user-provided handlers from the dedicated extension methods, meaning ConfigurePrimaryHttpMessageHandler, and NOT via HandlerBuilderFilters or ConfigureOptions -- in case the user opted in for the context propagation. I feel like suddenly rejecting a primary handler when the only thing I did was to opt in to the keyed DI is way too confusing.

(How this limitation will help: we can track PrimaryHandler creation actions separately from all other HandlerBuilder actions, so we can execute them (1) separately and not per-context-scope, (2) with a root or detached-scope provider)


So far it unfortunately looks like the automatic opt-in might not be worth it...

Potential solutions:

  1. Punt the scope propagation feature from 9.0 😢 Leave just the keyed registration feature; and the scope mismatch problem will continue to exist -- but then again, it's not like it could be entirely fixed in 9.0 anyway, only in conjunction with the keyed registration...
  2. Expand the keyed opt-in API with some additional parameter to opt-in/out of the scope propagation. I'm not sure I like this one, because
    1. it's not obvious that these two are related,
    2. it's not obvious whether RemoveAsKeyed should clear the value of this parameter,
    3. it's not obvious how it should interact with a separate scope propagation API if it will be added in the future.

But just in case, it will look smth like this:

namespace Microsoft.Extensions.DependencyInjection;

public static partial class HttpClientBuilderExtensions
{
+   public static IHttpClientBuilder AddAsKeyedScoped(this IHttpClientBuilder builder, bool propagateContextScope) {}
    public static IHttpClientBuilder AddAsKeyedScoped(this IHttpClientBuilder builder)
+//     => AddAsKeyedScoped(propagateContextScope: false)
    public static IHttpClientBuilder RemoveAsKeyed(this IHttpClientBuilder builder) {}
}

Why not a default param value: I think we cannot change the default param value after the API has shipped (but I'm not 100% sure)

services.AddHttpClient("foo")
    .AddAsKeyedScoped();

services.AddHttpClient("bar")
    .AddAsKeyedScoped(propagateContextScope: true);

@dotnet-policy-service dotnet-policy-service bot added the in-pr There is an active PR which will close this issue when it is merged label Jul 16, 2024
@terrajobst
Copy link
Member

terrajobst commented Jul 16, 2024

Video

  • We would like to align the names and take in the lifetime
  • The default behavior won't change for .NET 9, the expectation is that .NET 10 will change the default add it keyed lifetime by default.
namespace Microsoft.Extensions.DependencyInjection;

public static partial class HttpClientBuilderExtensions
{
    public static IHttpClientBuilder AddAsKeyed(this IHttpClientBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Scoped);
    public static IHttpClientBuilder RemoveAsKeyed(this IHttpClientBuilder builder);
}

@terrajobst terrajobst added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for review, it is NOT ready for implementation labels Jul 16, 2024
@github-actions github-actions bot locked and limited conversation to collaborators Aug 18, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
api-approved API was approved in API review, it can be implemented area-Extensions-HttpClientFactory blocking Marks issues that we want to fast track in order to unblock other important work enhancement Product code improvement that does NOT require public API changes/additions in-pr There is an active PR which will close this issue when it is merged
Projects
None yet
6 participants