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

Integrate keyed DI with HttpClientFactory #90272

Closed
wants to merge 5 commits into from

Conversation

CarnaViire
Copy link
Member

  1. HttpClient (and HttpMessageHandler chain) are registered as keyed transients with client name as the key
  2. Generic parameters from AddHttpMessageHandler, ConfigurePrimaryHttpMessageHandler and AddLogger overloads are resolved as keyed by client name if possible, otherwise fall back to ordinary resolve
  3. Reduced unnecessary primary handler allocations

Transient lifetime was chosen to make this

var client1 = services.GetRequiredKeyedService<HttpClient>(name);
var client2 = services.GetRequiredKeyedService<HttpClient>(name); // new instance

functionally equivalent to this

var client1 = factory.CreateClient(name);
var client2 = factory.CreateClient(name); // new instance

Especially if needed to use in singletons.
I've considered scoped lifetime, but it would be too confusing with typed clients being transient and named clients created by factory being essentially transient.

Scenarios made possible by (1):

Constructor injection with the attribute (can substitute Typed clients):

class KeyedClientTestService
{
    public KeyedClientTestService([FromKeyedServices("test")] HttpClient httpClient)
    {
        HttpClient = httpClient;
    }

Resolving by name directly from service provider (needed when name is not a compile-time constant):

var client = services.GetRequiredKeyedService<HttpClient>(name);
// or
var handler = services.GetRequiredKeyedService<HttpMessageHandler>(name);

Scenarios made possible by (2):

Registering different implementations per name

serviceCollection.AddKeyedSingleton<BaseLogger, FirstClientLogger>("FirstClient");
serviceCollection.AddKeyedSingleton<BaseLogger, SecondClientLogger>("SecondClient");

serviceCollection.AddHttpClient("FirstClient")
    .AddLogger<BaseLogger>(); // will use FirstClientLogger

serviceCollection.AddHttpClient("SecondClient")
    .AddLogger<BaseLogger>(); // will use SecondClientLogger

Easy access to client name from implementation

serviceCollection.AddKeyedSingleton<KeyedHttpClientLogger>(KeyedService.AnyKey); // registered only once

serviceCollection.AddHttpClient("FirstClient")
    .AddLogger<KeyedHttpClientLogger>(); // "KeyedHttpClientLogger.FirstClient" category

serviceCollection.AddHttpClient("SecondClient")
    .AddLogger<KeyedHttpClientLogger>(); // "KeyedHttpClientLogger.SecondClient" category

class KeyedHttpClientLogger : IHttpClientLogger
{
    private readonly ILogger _logger;
    public KeyedHttpClientLogger(ILoggerFactory loggerFactory, [ServiceKey] string key)
    {
        _logger = loggerFactory.CreateLogger(nameof(KeyedHttpClientLogger) + "." + key);
    }

Fixes #89755

@ghost
Copy link

ghost commented Aug 9, 2023

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

Issue Details
  1. HttpClient (and HttpMessageHandler chain) are registered as keyed transients with client name as the key
  2. Generic parameters from AddHttpMessageHandler, ConfigurePrimaryHttpMessageHandler and AddLogger overloads are resolved as keyed by client name if possible, otherwise fall back to ordinary resolve
  3. Reduced unnecessary primary handler allocations

Transient lifetime was chosen to make this

var client1 = services.GetRequiredKeyedService<HttpClient>(name);
var client2 = services.GetRequiredKeyedService<HttpClient>(name); // new instance

functionally equivalent to this

var client1 = factory.CreateClient(name);
var client2 = factory.CreateClient(name); // new instance

Especially if needed to use in singletons.
I've considered scoped lifetime, but it would be too confusing with typed clients being transient and named clients created by factory being essentially transient.

Scenarios made possible by (1):

Constructor injection with the attribute (can substitute Typed clients):

class KeyedClientTestService
{
    public KeyedClientTestService([FromKeyedServices("test")] HttpClient httpClient)
    {
        HttpClient = httpClient;
    }

Resolving by name directly from service provider (needed when name is not a compile-time constant):

var client = services.GetRequiredKeyedService<HttpClient>(name);
// or
var handler = services.GetRequiredKeyedService<HttpMessageHandler>(name);

Scenarios made possible by (2):

Registering different implementations per name

serviceCollection.AddKeyedSingleton<BaseLogger, FirstClientLogger>("FirstClient");
serviceCollection.AddKeyedSingleton<BaseLogger, SecondClientLogger>("SecondClient");

serviceCollection.AddHttpClient("FirstClient")
    .AddLogger<BaseLogger>(); // will use FirstClientLogger

serviceCollection.AddHttpClient("SecondClient")
    .AddLogger<BaseLogger>(); // will use SecondClientLogger

Easy access to client name from implementation

serviceCollection.AddKeyedSingleton<KeyedHttpClientLogger>(KeyedService.AnyKey); // registered only once

serviceCollection.AddHttpClient("FirstClient")
    .AddLogger<KeyedHttpClientLogger>(); // "KeyedHttpClientLogger.FirstClient" category

serviceCollection.AddHttpClient("SecondClient")
    .AddLogger<KeyedHttpClientLogger>(); // "KeyedHttpClientLogger.SecondClient" category

class KeyedHttpClientLogger : IHttpClientLogger
{
    private readonly ILogger _logger;
    public KeyedHttpClientLogger(ILoggerFactory loggerFactory, [ServiceKey] string key)
    {
        _logger = loggerFactory.CreateLogger(nameof(KeyedHttpClientLogger) + "." + key);
    }

Fixes #89755

Author: CarnaViire
Assignees: -
Labels:

area-Extensions-HttpClientFactory

Milestone: -

@CarnaViire

This comment was marked as off-topic.

@azure-pipelines

This comment was marked as off-topic.

@CarnaViire
Copy link
Member Author

@halter73 @JamesNK @stephentoub could you please take a look?

Comment on lines +117 to +118
internal IReadOnlyList<Action<IPrimaryHandlerBuilder>> PrimaryHandlerActions => _primaryHandlerActions!;
internal IReadOnlyList<Action<IAdditionalHandlersBuilder>> AdditionalHandlersActions => _additionalHandlersActions!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why IReadOnlyList rather than List? The latter will be more efficient, especially if code ever enumerates these. And they're internal, so you don't need to worry about breaks if you ever decided to change the type.

private List<Action<IPrimaryHandlerBuilder>>? _primaryHandlerActions = new List<Action<IPrimaryHandlerBuilder>>();
private List<Action<IAdditionalHandlersBuilder>>? _additionalHandlersActions = new List<Action<IAdditionalHandlersBuilder>>();

internal IReadOnlyList<Action<IPrimaryHandlerBuilder>> PrimaryHandlerActions => _primaryHandlerActions!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the nullable suppression? I'd have expected instead these would be nullable but then you'd annotate members like MergedToHandlerBuilderActions with [MemberNotNull(...)] to indicate that they can be used as a guard.

Comment on lines +176 to +184
for (int i = 0; i < options.PrimaryHandlerActions.Count; i++)
{
options.PrimaryHandlerActions[i](b);
}

for (int i = 0; i < options.AdditionalHandlersActions.Count; i++)
{
options.AdditionalHandlersActions[i](b);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you change PrimaryHandlerActions / AdditionalHandlersActions to be List<T> instead of IReadOnlyList<T>, then you can foreach these, instead.

@@ -28,7 +28,26 @@ public DefaultHttpMessageHandlerBuilder(IServiceProvider services)
}
}

public override HttpMessageHandler PrimaryHandler { get; set; } = new HttpClientHandler();
private HttpMessageHandler? _primaryHandler;
internal bool PrimaryHandlerIsSet { get; private set;}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed? Why can't _primaryHandler just be checked directly to see whether it's null?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because there are existing cases when primary handler is set explicitly to null. E.g. in gRPC. While I kinda consider this pattern erroneous, I still don't want to break the users...

Comment on lines +29 to +31
/// Accessing this property will have a performance impact, as it will disable some optimizations.
/// Consider using configuration APIs like ConfigurePrimaryHttpMessageHandler or
/// ConfigureAdditionalHttpMessageHandlers instead of accessing this property directly.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this regress existing code, or are the optimizations this disables only relevant for code using new features?

Debug.Assert(_primaryHandlerActions is not null);
Debug.Assert(_additionalHandlersActions is not null);

List<Action<HttpMessageHandlerBuilder>> handlerBuilderActions = new List<Action<HttpMessageHandlerBuilder>>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
List<Action<HttpMessageHandlerBuilder>> handlerBuilderActions = new List<Action<HttpMessageHandlerBuilder>>();
List<Action<HttpMessageHandlerBuilder>> handlerBuilderActions = new List<Action<HttpMessageHandlerBuilder>>(_primaryHandlerActions.Count + _additionalHandlersActions.Count);

Comment on lines +129 to +133
handlerBuilderActions.AddRange(_primaryHandlerActions);
handlerBuilderActions.AddRange(_additionalHandlersActions);

_primaryHandlerActions = null;
_additionalHandlersActions = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have these lists been handed out to anyone externally? If not, rather than allocating a new list and throwing away both existing ones, you could do:

List<Action<HttpMessageHandlerBuilder>> handlerBuilderActions = _primaryHandlerActions;
handlerBuilderActions .AddRange(_additionalHandlersActions);
_primaryHandlerActions = null;
_additionalHandlersActions = null;
return handlerBuilderActions;


internal interface IAdditionalHandlersBuilder : IHandlerBuilder
{
IList<DelegatingHandler> AdditionalHandlers { get; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For these internal types, can you use the concrete types instead of interfaces?

@davidfowl
Copy link
Member

Is this opt in? What happens if I use a container that doesn't support keyed services? Will updating break me?

@CarnaViire
Copy link
Member Author

CarnaViire commented Aug 14, 2023

Is this opt in? What happens if I use a container that doesn't support keyed services? Will updating break me?

Good catch @davidfowl, thanks. I naively thought that given that AddKeyed... exists in DependecyInjection.Abstractions package, it will just succeed and/or be a no-op, and the only thing I had to actually check was whether IServiceProvider instance implemented IKeyedServiceProvider as well.

Turns out, it's not so easy. AddKeyed... would succeed, but then when e.g. Autofac would get to import IServiceCollection's descriptors into itself, it will access the ImplementationType property without checking -- unknowingly, since how would you know? you didn't touch this code -- for IsKeyedService first, and boom! 😢😭 Because you should have accessed KeyedImplementationType instead here. I am not sure I'm happy about this API design, but it has its pros, and either way, what can you do now...

The saddest thing is that I see no way in checking whether it's a third-party container being used, let alone whether it supports our new keyed feature (I expect, for quite some time, none of them would). Because we are registering to our own ServiceCollection which just accepts keyed descriptors without any problems, and the problems happen within 3rd party container's code.

Given the point in time, I guess the only way forward is to add an app-context switch for the registration, which will be off by default. 😢

@CarnaViire
Copy link
Member Author

Thinking more about this, and how we would have to have it off by default, I'm even considering not taking it into 8.0 at all...

The integration request came a bit too late in the game, but we decided to go with it, because it is a new shiny feature that brings a lot of value, and also to avoid the following: if this would not be part of 8.0 - then someone could make their own keyed HttpClient registrations, and we would clash when we adopt it later in e.g. 9.0... However, now in light that we still have to have some kind of opt-in API and can't have it on by default, the clash doesn't worry me anymore since it will be controlled by the same API.

Thoughts on punting this? @karelz @davidfowl @halter73 @JamesNK

@karelz
Copy link
Member

karelz commented Aug 14, 2023

Agreed, I don't think it makes sense to rush adding new API post RC1. We can't catch the RC1 train now.

@CarnaViire CarnaViire marked this pull request as draft August 14, 2023 19:54
@davidfowl
Copy link
Member

I think it makes sense to do this early in 9 to better understand how it might break users.

@karelz karelz added this to the 9.0.0 milestone Sep 12, 2023
@ghost ghost closed this Oct 12, 2023
@ghost
Copy link

ghost commented Oct 12, 2023

Draft Pull Request was automatically closed for 30 days of inactivity. Please let us know if you'd like to reopen it.

@CarnaViire CarnaViire reopened this Oct 12, 2023
@ghost ghost closed this Nov 11, 2023
@ghost
Copy link

ghost commented Nov 11, 2023

Draft Pull Request was automatically closed for 30 days of inactivity. Please let us know if you'd like to reopen it.

@davidfowl
Copy link
Member

@CarnaViire Lets do this early in .NET 9 so flesh out any issues that might be lurking 😄

@CarnaViire
Copy link
Member Author

@davidfowl yes, that's the plan 👍 I have my plate full at the moment, but I will proceed as soon as I'm able to

@davidfowl
Copy link
Member

Is there an issue outside of this PR?

@CarnaViire
Copy link
Member Author

#89755 but I didn't update it to API proposal yet.

I may not be able to proceed in November -- I have a couple of important time-sensitive project, so it's possible we might have to move it to early January (as we might not have a quorum on API reviews in December). Just setting expectations. It would still be an early phase of 9.0, so I think it's fine.

@github-actions github-actions bot locked and limited conversation to collaborators Dec 14, 2023
This pull request was closed.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

HttpClientFactory support for keyed DI
4 participants