dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.37k stars 4.75k forks source link

[API Proposal]: Dependency Injection - Add explicit way of binding multiple interfaces to the same singleton instance #70004

Open FooRider opened 2 years ago

FooRider commented 2 years ago

Background and motivation

When using Microsoft.Extensions.DependencyInjection, there is no way to explicitly bind multiple interfaces to a singleton instance of their implementation. Common workaround is to create ServiceDescriptor with implementationFactory that calls IServiceProvider.GetRequiredService() method.

This approach has two issues with current implementation:

Minimum example of the disposing issue:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

using (var host = Host.CreateDefaultBuilder(args)
  .UseDefaultServiceProvider(options =>
  { options.ValidateOnBuild = true; })
  .ConfigureServices(services =>
  {
    services.AddSingleton<NameAndGreetingGenerator>();
    services.AddSingleton<INameGenerator>(x => x.GetRequiredService<NameAndGreetingGenerator>());
    services.AddSingleton<IGreetingGenerator>(x => x.GetRequiredService<NameAndGreetingGenerator>());

    services.AddTransient<ExampleRunner>();
  })
  .Build())
{

  var gwn1 = host.Services.GetRequiredService<ExampleRunner>()
                          .GenerateGreetingWithName();
  Console.WriteLine(gwn1);

  var gwn2 = host.Services.GetRequiredService<ExampleRunner>()
                          .GenerateGreetingWithName();
  Console.WriteLine(gwn2);

}

class ExampleRunner
{
  private readonly INameGenerator nameGenerator;
  private readonly IGreetingGenerator greetingGenerator;

  public ExampleRunner(INameGenerator nameGenerator, IGreetingGenerator greetingGenerator)
  {
    Console.WriteLine($"Creating {nameof(ExampleRunner)}");
    this.nameGenerator = nameGenerator;
    this.greetingGenerator = greetingGenerator;
  }

  public string GenerateGreetingWithName()
    => greetingGenerator.GenerateGreeting(nameGenerator.GenerateName());
}

interface INameGenerator { string GenerateName(); }
interface IGreetingGenerator { string GenerateGreeting(string name); }

class NameAndGreetingGenerator : INameGenerator, IGreetingGenerator, IDisposable
{
  private readonly Random random = new Random();

  public NameAndGreetingGenerator() =>
    Console.WriteLine($"Creating {nameof(NameAndGreetingGenerator)}");

  public string GenerateName()
  {
    var chars = new List<char>();

    chars.Add((char)('A' + random.Next('Z' - 'A')));
    var length = random.Next(4, 10);
    while (chars.Count < length)
      chars.Add((char)('a' + random.Next('Z' - 'A')));

    return new string(chars.ToArray());
  }

  public string GenerateGreeting(string name) =>
    $"Welcome {name}!";

  public void Dispose() =>
    Console.WriteLine($"Disposing {nameof(NameAndGreetingGenerator)}");
}

Outputs:

Creating NameAndGreetingGenerator
Creating ExampleRunner
Welcome Xjcicxw!
Creating ExampleRunner
Welcome Jeudgnlkv!
Disposing NameAndGreetingGenerator
Disposing NameAndGreetingGenerator
Disposing NameAndGreetingGenerator

Minimum example of the validation issue (assume the same support classes and interfaces):

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

using (var host = Host.CreateDefaultBuilder(args)
  .UseDefaultServiceProvider(options =>
  { options.ValidateOnBuild = true; })
  .ConfigureServices(services =>
  {
    //services.AddSingleton<NameAndGreetingGenerator>();
    services.AddSingleton<INameGenerator>(x => x.GetRequiredService<NameAndGreetingGenerator>());
    services.AddSingleton<IGreetingGenerator>(x => x.GetRequiredService<NameAndGreetingGenerator>());

    services.AddTransient<ExampleRunner>();
  })
  .Build())
{

  var gwn1 = host.Services.GetRequiredService<ExampleRunner>() // throws exception after host is built, when ExampleRunner is requested
                          .GenerateGreetingWithName();
  Console.WriteLine(gwn1);

  var gwn2 = host.Services.GetRequiredService<ExampleRunner>()
                          .GenerateGreetingWithName();
  Console.WriteLine(gwn2);

}

In this example, ServiceProvider validation doesn't reveal the fact that INameGenerator, nor IGreetingGenerator instances could be created using provided bindings, and doesn't throw an exception from .Build() method, allowing the program to continue and fail after initialization, when requesting ExampleRunner service.

API Proposal

Both of the current issues stem from the fact that ServiceDescriptor class currently doesn't know that multiple different service types are provided by the same instance of implementation type. There are multiple possible ways to encode this information into ServiceDescriptor class.

Possible solution # 1 - modify ServiceDescriptor class to contain multiple types as its ServiceType, in new property ServiceTypes.

public class ServiceDescriptor
{
  // ...
  public Type ServiceType { get; }

  public Type[] ServiceTypes { get; } // Add new property possibly containing multiple service types
  // ...
  // Also, add constructors accepting multiple serviceTypes
  public ServiceDescriptor(
    IEnumerable<Type> serviceTypes,
    Type implementationType,
    ServiceLifetime lifetime)
    : this(serviceTypes, lifetime
  {
    // ...
    ImplementationType = implementationType;
  }
  private ServiceDescriptor(
    IEnumerable<Type> serviceTypes,
    ServiceLifetime lifetime)
  {
    Lifetime = lifetime;
    ServiceTypes = serviceTypes.ToArray();
  }
}

API Usage

Usage of possible solution # 1:

hostBuilder.ConfigureServices(services => 
{
  var descriptor = new ServiceDescriptor(
    new[] { typeof(INameGenerator), typeof(IGreetingGenerator) },
    typeof(NameAndGreetingGenerator),
    ServiceLifetime.Singleton);
  services.Add(descriptor);
}

Using extension methods:

hostBuilder.ConfigureServices(services =>
{
  services.Add(new[] { typeof(INameGenerator), typeof(IGreetingGenerator) }, typeof(NameAndGreetingGenerator), ServiceLifetime.Singleton);
}

Another extension method using generics:

The name of this method should be different from "AddSingleton", because it could be confusing. Name of the extension method in the example is just a first proposal, a better name could be surely found.

hostBuilder.ConfigureServices(services =>
{
  services.AddMultiServiceSingleton<INameGenerator, IGreetingGenerator, NameAndGreetingGenerator>();
}

Alternative Designs

Another solution would require IoC container to check whether implementation type is already bound and use that binding to satisfy request for requested service type.

This could be problematic, since it would change current behavior.

Risks

Proposed solution will have consequences for current implementation of IoC containers. Each implementation would need to be able to work with ServiceDescriptors that have (ServiceType != null && ServiceTypes == null) and (ServiceType == null && ServiceTypes != null) and somehow implement the new scenario.

Proposed alternative solution will mean no changes in API interface, but would specify different behavior for container implementation. The risk for existing projects could be mitigated by new option in ServiceProviderOptions class that would enable this new behavior on request. Problem with this could be perhaps non-intuitive behavior when bindings do not have the same ServiceLifetime set.

ghost commented 2 years ago

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

Issue Details
### Background and motivation When using Microsoft.Extensions.DependencyInjection, there is no way to explicitly bind multiple interfaces to a singleton instance of their implementation. Common workaround is to create ServiceDescriptor with implementationFactory that calls IServiceProvider.GetRequiredService() method. This approach has two issues with current implementation: - implementationFactory function hides dependency and therefore ServiceProvider can not find missing bindings during validation. - The fact that the same instance is injected under different ServiceDescriptors is lost, potentially causing the ServiceProvider to dispose the same object multiple times. Minimum example of the disposing issue: ```csharp using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using (var host = Host.CreateDefaultBuilder(args) .UseDefaultServiceProvider(options => { options.ValidateOnBuild = true; }) .ConfigureServices(services => { services.AddSingleton(); services.AddSingleton(x => x.GetRequiredService()); services.AddSingleton(x => x.GetRequiredService()); services.AddTransient(); }) .Build()) { var gwn1 = host.Services.GetRequiredService() .GenerateGreetingWithName(); Console.WriteLine(gwn1); var gwn2 = host.Services.GetRequiredService() .GenerateGreetingWithName(); Console.WriteLine(gwn2); } class ExampleRunner { private readonly INameGenerator nameGenerator; private readonly IGreetingGenerator greetingGenerator; public ExampleRunner(INameGenerator nameGenerator, IGreetingGenerator greetingGenerator) { Console.WriteLine($"Creating {nameof(ExampleRunner)}"); this.nameGenerator = nameGenerator; this.greetingGenerator = greetingGenerator; } public string GenerateGreetingWithName() => greetingGenerator.GenerateGreeting(nameGenerator.GenerateName()); } interface INameGenerator { string GenerateName(); } interface IGreetingGenerator { string GenerateGreeting(string name); } class NameAndGreetingGenerator : INameGenerator, IGreetingGenerator, IDisposable { private readonly Random random = new Random(); public NameAndGreetingGenerator() => Console.WriteLine($"Creating {nameof(NameAndGreetingGenerator)}"); public string GenerateName() { var chars = new List(); chars.Add((char)('A' + random.Next('Z' - 'A'))); var length = random.Next(4, 10); while (chars.Count < length) chars.Add((char)('a' + random.Next('Z' - 'A'))); return new string(chars.ToArray()); } public string GenerateGreeting(string name) => $"Welcome {name}!"; public void Dispose() => Console.WriteLine($"Disposing {nameof(NameAndGreetingGenerator)}"); } ``` Outputs: ``` Creating NameAndGreetingGenerator Creating ExampleRunner Welcome Xjcicxw! Creating ExampleRunner Welcome Jeudgnlkv! Disposing NameAndGreetingGenerator Disposing NameAndGreetingGenerator Disposing NameAndGreetingGenerator ``` Minimum example of the validation issue (assume the same support classes and interfaces): ```csharp using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using (var host = Host.CreateDefaultBuilder(args) .UseDefaultServiceProvider(options => { options.ValidateOnBuild = true; }) .ConfigureServices(services => { //services.AddSingleton(); services.AddSingleton(x => x.GetRequiredService()); services.AddSingleton(x => x.GetRequiredService()); services.AddTransient(); }) .Build()) { var gwn1 = host.Services.GetRequiredService() // throws exception after host is built, when ExampleRunner is requested .GenerateGreetingWithName(); Console.WriteLine(gwn1); var gwn2 = host.Services.GetRequiredService() .GenerateGreetingWithName(); Console.WriteLine(gwn2); } ``` In this example, ServiceProvider validation doesn't reveal the fact that INameGenerator, nor IGreetingGenerator instances could be created using provided bindings, and doesn't throw an exception from .Build() method, allowing the program to continue and fail after initialization, when requesting ExampleRunner service. ### API Proposal Both of the current issues stem from the fact that ServiceDescriptor class currently doesn't know that multiple different service types are provided by the same instance of implementation type. There are multiple possible ways to encode this information into ServiceDescriptor class. Possible solution # 1 - modify ServiceDescriptor class to contain multiple types as its ServiceType, in new property ServiceTypes. ```csharp public class ServiceDescriptor { // ... public Type ServiceType { get; } public Type[] ServiceTypes { get; } // Add new property possibly containing multiple service types // ... // Also, add constructors accepting multiple serviceTypes public ServiceDescriptor( IEnumerable serviceTypes, Type implementationType, ServiceLifetime lifetime) : this(serviceTypes, lifetime { // ... ImplementationType = implementationType; } private ServiceDescriptor( IEnumerable serviceTypes, ServiceLifetime lifetime) { Lifetime = lifetime; ServiceTypes = serviceTypes.ToArray(); } } ``` ### API Usage Usage of possible solution # 1: ```csharp hostBuilder.ConfigureServices(services => { var descriptor = new ServiceDescriptor( new[] { typeof(INameGenerator), typeof(IGreetingGenerator) }, typeof(NameAndGreetingGenerator), ServiceLifetime.Singleton); services.Add(descriptor); } ``` Using extension methods: ```csharp hostBuilder.ConfigureServices(services => { services.Add(new[] { typeof(INameGenerator), typeof(IGreetingGenerator) }, typeof(NameAndGreetingGenerator), ServiceLifetime.Singleton); } ``` Another extension method using generics: The name of this method should be different from "AddSingleton", because it could be confusing. Name of the extension method in the example is just a first proposal, a better name could be surely found. ```csharp hostBuilder.ConfigureServices(services => { services.AddMultiServiceSingleton(); } ``` ### Alternative Designs Another solution would require IoC container to check whether implementation type is already bound and use that binding to satisfy request for requested service type. This could be problematic, since it would change current behavior. ### Risks Proposed solution will have consequences for current implementation of IoC containers. Each implementation would need to be able to work with ServiceDescriptors that have (ServiceType != null && ServiceTypes == null) and (ServiceType == null && ServiceTypes != null) and somehow implement the new scenario. Proposed alternative solution will mean no changes in API interface, but would specify different behavior for container implementation. The risk for existing projects could be mitigated by new option in ServiceProviderOptions class that would enable this new behavior on request. Problem with this could be perhaps non-intuitive behavior when bindings do not have the same ServiceLifetime set.
Author: FooRider
Assignees: -
Labels: `api-suggestion`, `untriaged`, `area-Extensions-DependencyInjection`
Milestone: -
Carsillas commented 1 year ago

I agree with this proposal but I just want to mention the multiple IDisposable calls should be a nonissue as I think it seems to be the standard pattern that multiple calls to Dispose should do nothing.

https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose (3rd paragraph)

XavierCL commented 6 months ago

I agree with this proposal but I just want to mention the multiple IDisposable calls should be a nonissue as I think it seems to be the standard pattern that multiple calls to Dispose should do nothing.

Note that the dispose order is also affected, not only the number of calls.

E.g.

class ExampleNameRunner
{
  private readonly INameGenerator nameGenerator;

  public ExampleNameRunner(INameGenerator nameGenerator)
  {
    this.nameGenerator = nameGenerator;
  }

  public string GenerateName()
    => nameGenerator.GenerateName();
}

class ExampleGreetingRunner
{
  private readonly IGreetingGenerator greetingGenerator;

  public ExampleGreetingRunner(IGreetingGenerator greetingGenerator)
  {
    this.greetingGenerator = greetingGenerator;
  }

  public string GenerateGreeting()
    => greetingGenerator.GenerateGreeting();
}

If you instanciate ExampleNameRunner then ExampleGreetingRunner, the creation order will be the following:

  1. INameGenerator (Singleton)
  2. ExampleNameRunner
  3. IGreetingGenerator (Singleton)
  4. ExampleGreetingRunner

Since the services are disposed in the reverse order (as they should), IGreetingGenerator will be disposed before ExampleNameRunner, which means ExampleNameRunner will hold a reference to a disposed singleton without itself having been disposed.

wvpm commented 3 months ago

This is a very relatable issue. I sometimes run into it with configuration classes (1 config implementation implementing several config interfaces).

I think the array of service types is not the right way to go. I'd rather have some helper method create multiple servicedescriptors as you now manually do.

As for validation, I recommend adding a unit test just for DI that verifies you can resolve the services you want to expose.

julealgon commented 3 months ago

Proposal makes sense to me and I've needed this many times in the past. However, I see zero reason why this is being limited to singletons.

The new API should be implemented to work with all lifetimes.

@wvpm

I think the array of service types is not the right way to go. I'd rather have some helper method create multiple servicedescriptors as you now manually do.

Why would you rather have N service descriptors registered? IMHO, the most "native" approach to this would be to have one descriptor and expand the current ServiceDescriptor.Type property into a Types array.

wvpm commented 3 months ago

Why would you rather have N service descriptors registered? IMHO, the most "native" approach to this would be to have one descriptor and expand the current ServiceDescriptor.Type property into a Types array.

The Microsoft DI package is meant to be compatible with all implementations. Expanding the ServiceDescriptor requires all implementations to also expand. Creating multiple ServiceDescriptors is something that already works. It just looks and feels weird. I previously ran into a similar issue, see https://github.com/dotnet/runtime/issues/41050#issuecomment-677785224 .