Inserción de dependencias en .NET

.NET admite el patrón de diseño de software de inserción de dependencias (DI), que es una técnica para conseguir la inversión de control (IoC) entre clases y sus dependencias. La inserción de dependencias en .NET es una parte integrada del marco, junto con la configuración, el registro y el patrón de opciones.

Una dependencia es un objeto del que depende otro objeto. Examine la clase MessageWriter siguiente con un método Write del que dependen otras clases:

public class MessageWriter
{
    public void Write(string message)
    {
        Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
    }
}

Una clase puede crear una instancia de la clase MessageWriter para usar su método Write. En el ejemplo siguiente, la clase MessageWriter es una dependencia de la clase Worker:

public class Worker : BackgroundService
{
    private readonly MessageWriter _messageWriter = new MessageWriter();

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
            await Task.Delay(1000, stoppingToken);
        }
    }
}

La clase crea y depende directamente de la clase MessageWriter. Las dependencias codificadas de forma rígida, como en el ejemplo anterior, son problemáticas y deben evitarse por las razones siguientes:

  • Para reemplazar MessageWriter por una implementación diferente, se debe modificar la clase Worker.
  • Si MessageWriter tiene dependencias, deben configurarse según la clase Worker. En un proyecto grande con varias clases que dependen de MessageWriter, el código de configuración se dispersa por la aplicación.
  • Esta implementación es difícil para realizar pruebas unitarias. La aplicación debe usar una clase MessageWriter como boceto o código auxiliar, que no es posible con este enfoque.

La inserción de dependencias aborda estos problemas mediante:

  • Uso de una interfaz o clase base para abstraer la implementación de dependencias.
  • Registro de la dependencia en un contenedor de servicios. .NET proporciona un contenedor de servicios integrado, IServiceProvider. Normalmente, los servicios se registran en el inicio de la aplicación y se anexan a .IServiceCollection Una vez que se agregan todos los servicios, se usa BuildServiceProvider para crear el contenedor de servicios.
  • Inserción del servicio en el constructor de la clase en la que se usa. El marco de trabajo asume la responsabilidad de crear una instancia de la dependencia y de desecharla cuando ya no es necesaria.

Por ejemplo, la interfaz IMessageWriter define el método Write:

namespace DependencyInjection.Example;

public interface IMessageWriter
{
    void Write(string message);
}

Esta interfaz se implementa mediante un tipo concreto, MessageWriter:

namespace DependencyInjection.Example;

public class MessageWriter : IMessageWriter
{
    public void Write(string message)
    {
        Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
    }
}

El código de ejemplo registra el servicio IMessageWriter con el tipo concreto MessageWriter. El método AddScoped registra el servicio mediante una duración con ámbito, definida como la duración de una única solicitud. Las duraciones del servicio se describen más adelante en este artículo.

namespace DependencyInjection.Example;

class Program
{
    static Task Main(string[] args) =>
        CreateHostBuilder(args).Build().RunAsync();

    static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureServices((_, services) =>
                services.AddHostedService<Worker>()
                        .AddScoped<IMessageWriter, MessageWriter>());
}

En la aplicación de ejemplo, el servicio IMessageWriter se solicita y usa para llamar al método Write:

namespace DependencyInjection.Example;

public class Worker : BackgroundService
{
    private readonly IMessageWriter _messageWriter;

    public Worker(IMessageWriter messageWriter) =>
        _messageWriter = messageWriter;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
            await Task.Delay(1000, stoppingToken);
        }
    }
}

Mediante el uso del patrón de DI, el servicio de trabajo:

  • No usa el tipo concreto MessageWriter, solo la interfaz IMessageWriter que lo implementa. Esto facilita el cambio de la implementación que el controlador utiliza sin modificar el controlador.
  • No crea una instancia de MessageWriter. El contenedor de DI crea la instancia.

La implementación de la interfaz de IMessageWriter se puede mejorar mediante el uso de la API de registro integrada:

namespace DependencyInjection.Example;

public class LoggingMessageWriter : IMessageWriter
{
    private readonly ILogger<LoggingMessageWriter> _logger;

    public LoggingMessageWriter(ILogger<LoggingMessageWriter> logger) =>
        _logger = logger;

    public void Write(string message) =>
        _logger.LogInformation(message);
}

El método ConfigureServices actualizado registra la nueva implementación de IMessageWriter:

static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureServices((_, services) =>
            services.AddHostedService<Worker>()
                    .AddScoped<IMessageWriter, LoggingMessageWriter>());

LoggingMessageWriter depende de ILogger<TCategoryName>, el que solicita en el constructor. ILogger<TCategoryName> es un ILogger<TCategoryName>.

No es raro usar la inserción de dependencias de forma encadenada. Cada dependencia solicitada a su vez solicita sus propias dependencias. El contenedor resuelve las dependencias del gráfico y devuelve el servicio totalmente resuelto. El conjunto colectivo de dependencias que deben resolverse suele denominarse árbol de dependencias, gráfico de dependencias o gráfico de objetos.

El contenedor resuelve ILogger<TCategoryName> aprovechando las ventajas de los ILogger<TCategoryName>, lo que elimina la necesidad de registrar todos los tipos construidos (genéricos).

Con la terminología de inserción de dependencias, un servicio:

  • Por lo general, es un objeto que proporciona un servicio a otros objetos, como el servicio IMessageWriter.
  • No está relacionado con un servicio web, aunque el servicio puede utilizar un servicio web.

El marco de trabajo proporciona un sistema de registro sólido. Las implementaciones de IMessageWriter que se muestran en los ejemplos anteriores se escribieron para mostrar la inserción de DI básica, no para implementar el registro. La mayoría de las aplicaciones no deberían tener que escribir registradores. En el código siguiente se muestra cómo usar el registro predeterminado, que solo requiere que Worker se registre en ConfigureServices como un servicio hospedado AddHostedService:

public class Worker : BackgroundService
{
    private readonly ILogger<Worker> _logger;

    public Worker(ILogger<Worker> logger) =>
        _logger = logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
            await Task.Delay(1000, stoppingToken);
        }
    }
}

Con el código anterior, no es necesario actualizar ConfigureServices porque el registro se proporciona a través del marco de trabajo.

Reglas de detección de varios constructores

Cuando un tipo define más de un constructor, el proveedor de servicios tiene lógica para determinar qué constructor usar. Se selecciona el constructor con el mayor número de parámetros donde los tipos pueden resolver DI. Considere el siguiente servicio de ejemplo de C#:

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(ILogger<ExampleService> logger)
    {
        // omitted for brevity
    }

    public ExampleService(FooService fooService, BarService barService)
    {
        // omitted for brevity
    }
}

En el código anterior, suponga que el registro se ha agregado y se puede resolver desde el proveedor de servicios, pero no los tipos FooService y BarService. El constructor con el parámetro ILogger<ExampleService> se usa para resolver la instancia de ExampleService. Aunque hay un constructor que define más parámetros, los tipos FooService y BarService no se pueden resolver en DI.

Si hay ambigüedad al detectar constructores, se produce una excepción. Considere el siguiente servicio de ejemplo de C#:

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(ILogger<ExampleService> logger)
    {
        // omitted for brevity
    }

    public ExampleService(IOptions<ExampleOptions> options)
    {
        // omitted for brevity
    }
}

Advertencia

El código ExampleService con parámetros de tipo ambiguos que se pueden resolver en DI produciría una excepción. No lo haga: está pensado para mostrar lo que significan los "tipos ambiguos que pueden resolver di".

En el ejemplo anterior, hay tres constructores. El primer constructor no tiene parámetros y no requiere ningún servicio del proveedor de servicios. Suponga que tanto el registro como las opciones se han agregado al contenedor de DI y son servicios que se pueden resolver en DI. Cuando el contenedor de DI intente resolver el tipo ExampleService, producirá una excepción, ya que los dos constructores son ambiguos.

Puede evitar la ambigüedad mediante la definición de un constructor que acepte ambos tipos que puedan resolver di en su lugar:

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(
        ILogger<ExampleService> logger,
        IOptions<ExampleOptions> options)
    {
        // omitted for brevity
    }
}

Registro de grupos de servicios con métodos de extensión

Las extensiones de Microsoft usan una convención para registrar un grupo de servicios relacionados. La convención es usar un único método de extensión de Add{GROUP_NAME} para registrar todos los servicios requeridos por una característica de marco. Por ejemplo, el método de extensión AddOptions registra todos los servicios necesarios para usar las opciones.

Servicios proporcionados por el marco de trabajo

El método ConfigureServices registra los servicios que la aplicación usa, incluidas las características de plataforma. Inicialmente, el valor IServiceCollection proporcionado a ConfigureServices tiene los servicios definidos por el marco en función de IServiceCollection. En el caso de las aplicaciones basadas en las plantillas de .NET, el marco de trabajo registra cientos de servicios.

En la tabla siguiente se ilustra una pequeña muestra de estos servicios registrados por el marco:

Tipo de servicio Período de duración
Microsoft.Extensions.DependencyInjection.IServiceScopeFactory Singleton
IHostApplicationLifetime Singleton
Microsoft.Extensions.Logging.ILogger<TCategoryName> Singleton
Microsoft.Extensions.Logging.ILoggerFactory Singleton
Microsoft.Extensions.ObjectPool.ObjectPoolProvider Singleton
Microsoft.Extensions.Options.IConfigureOptions<TOptions> Transitorio
Microsoft.Extensions.Options.IOptions<TOptions> Singleton
System.Diagnostics.DiagnosticListener Singleton
System.Diagnostics.DiagnosticSource Singleton

Duraciones de servicios

Los servicios se pueden registrar con una de las duraciones siguientes:

  • Transitorio
  • Con ámbito
  • Singleton

En las secciones siguientes se describen cada una de las duraciones anteriores. Elija una duración adecuada para cada servicio registrado.

Transitorio

Los servicios de duración transitoria se crean cada vez que el contenedor del servicio los solicita. Esta duración funciona mejor para servicios sin estado ligeros. Registre los servicios transitorios con AddTransient.

En las aplicaciones que procesan solicitudes, los servicios transitorios se eliminan al final de la solicitud.

Con ámbito

En el caso de las aplicaciones web, una duración con ámbito indica que los servicios se crean una vez por solicitud de cliente (conexión). Registre los servicios con ámbito con AddScoped.

En las aplicaciones que procesan solicitudes, los servicios con ámbito se eliminan al final de la solicitud.

Cuando se usa Entity Framework Core, el método de extensión AddDbContext registra tipos de DbContext con una duración de ámbito de forma predeterminada.

Nota

No resuelva un servicio con ámbito desde un singleton y tenga cuidado de no hacerlo indirectamente, por ejemplo, a través de un servicio transitorio. Puede dar lugar a que el servicio adopte un estado incorrecto al procesar solicitudes posteriores. Basta con:

  • Resolver un servicio singleton desde un servicio con ámbito o transitorio.
  • Resolver un servicio con ámbito desde otro servicio con ámbito o transitorio.

De manera predeterminada, en el entorno de desarrollo, resolver un servicio desde otro servicio con una duración más larga genera una excepción. Para más información, vea Validación del ámbito.

Singleton

Los servicios de duración de singleton se crean de alguna de las formas siguientes:

  • La primera vez que se solicitan.
  • Mediante el desarrollador, al proporcionar una instancia de implementación directamente al contenedor. Este enfoque rara vez es necesario.

Cada solicitud siguiente de la implementación del servicio desde el contenedor de inserción de dependencias utiliza la misma instancia. Si la aplicación requiere un comportamiento de singleton, permita que el contenedor de servicios administre la duración del servicio. No implemente el modelo de diseño singleton y proporcione el código para desechar el singleton. Los servicios nunca deben desecharse mediante el código que haya resuelto el servicio del contenedor. Si un tipo o fábrica se registra como singleton, el contenedor elimina el singleton de manera automática.

Registre los servicios singleton con AddSingleton. Los servicios singleton deben ser seguros para los subprocesos y se suelen usar en servicios sin estado.

En las aplicaciones que procesan solicitudes, los servicios singleton se eliminan cuando ServiceProvider se elimina al cerrarse la aplicación. Como no se libera memoria hasta que se apaga la aplicación, se debe tener en cuenta el uso de memoria con un servicio singleton.

Métodos de registro del servicio

El marco proporciona métodos de extensión de registro del servicio que resultan útiles en escenarios específicos:

Método Automático
objeto
eliminación
Múltiple
implementaciones
Transferencia de argumentos
Add{LIFETIME}<{SERVICE}, {IMPLEMENTATION}>()

Ejemplo:

services.AddSingleton<IMyDep, MyDep>();
No
Add{LIFETIME}<{SERVICE}>(sp => new {IMPLEMENTATION})

Ejemplos:

services.AddSingleton<IMyDep>(sp => new MyDep());
services.AddSingleton<IMyDep>(sp => new MyDep(99));
Add{LIFETIME}<{IMPLEMENTATION}>()

Ejemplo:

services.AddSingleton<MyDep>();
No No
AddSingleton<{SERVICE}>(new {IMPLEMENTATION})

Ejemplos:

services.AddSingleton<IMyDep>(new MyDep());
services.AddSingleton<IMyDep>(new MyDep(99));
No
AddSingleton(new {IMPLEMENTATION})

Ejemplos:

services.AddSingleton(new MyDep());
services.AddSingleton(new MyDep(99));
No No

Para obtener más información sobre el tipo de eliminación, consulte la sección Eliminación de servicios.

El registro de un servicio con un solo tipo de implementación es equivalente al registro de ese servicio con la misma implementación y el mismo tipo de servicio. Por eso no se pueden registrar varias implementaciones de un servicio mediante los métodos que no toman un tipo de servicio explícito. Estos métodos pueden registrar varias instancias de un servicio, pero todos tienen el mismo tipo de implementación.

Cualquiera de los métodos de registro de servicio anteriores se puede usar para registrar varias instancias de servicio del mismo tipo de servicio. En el ejemplo siguiente se llama a AddSingleton dos veces con IMessageWriter como tipo de servicio. La segunda llamada a AddSingleton invalida la anterior cuando se resuelve como IMessageWriter, y se agrega a la anterior cuando varios servicios se resuelven mediante IEnumerable<IMessageWriter>. Los servicios aparecen en el orden en que se han registrado al resolverse mediante IEnumerable<{SERVICE}>.

using ConsoleDI.IEnumerableExample;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace ConsoleDI.Example;

class Program
{
    static Task Main(string[] args)
    {
        using IHost host = CreateHostBuilder(args).Build();

        _ = host.Services.GetService<ExampleService>();

        return host.RunAsync();
    }

    static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureServices((_, services) =>
                services.AddSingleton<IMessageWriter, ConsoleMessageWriter>()
                        .AddSingleton<IMessageWriter, LoggingMessageWriter>()
                        .AddSingleton<ExampleService>());
}

El código fuente de ejemplo anterior registra dos implementaciones de IMessageWriter.

using System.Diagnostics;

namespace ConsoleDI.IEnumerableExample;

public class ExampleService
{
    public ExampleService(
        IMessageWriter messageWriter,
        IEnumerable<IMessageWriter> messageWriters)
    {
        Trace.Assert(messageWriter is LoggingMessageWriter);

        var dependencyArray = messageWriters.ToArray();
        Trace.Assert(dependencyArray[0] is ConsoleMessageWriter);
        Trace.Assert(dependencyArray[1] is LoggingMessageWriter);
    }
}

ExampleService define dos parámetros de constructor, un único IMessageWriter y un IEnumerable<IMessageWriter>. El IMessageWriter único es la última implementación registrada, mientras que IEnumerable<IMessageWriter> representa todas las implementaciones registradas.

El marco también proporciona métodos de extensión TryAdd{LIFETIME}, que registran el servicio solo si todavía no hay registrada una implementación.

En el ejemplo siguiente, la llamada a AddSingleton registra ConsoleMessageWriter como una implementación para IMessageWriter. La llamada a TryAddSingleton no tiene ningún efecto porque IMessageWriter ya tiene una implementación registrada:

services.AddSingleton<IMessageWriter, ConsoleMessageWriter>();
services.TryAddSingleton<IMessageWriter, LoggingMessageWriter>();

TryAddSingleton no tiene ningún efecto, puesto que ya se agregó y "try" generará un error. ExampleService impondría lo siguiente:

public class ExampleService
{
    public ExampleService(
        IMessageWriter messageWriter,
        IEnumerable<IMessageWriter> messageWriters)
    {
        Trace.Assert(messageWriter is ConsoleMessageWriter);
        Trace.Assert(messageWriters.Single() is ConsoleMessageWriter);
    }
}

Para más información, consulte:

Los métodos TryAddEnumerable(ServiceDescriptor) registran el servicio solo si todavía no hay una implementación del mismo tipo. A través de IEnumerable<{SERVICE}> se resuelven varios servicios. Al registrar los servicios, agregue una instancia si no se ha agregado ya una del mismo tipo. Los autores de bibliotecas usan TryAddEnumerable para evitar el registro de varias copias de una implementación en el contenedor.

En el ejemplo siguiente, la primera llamada a TryAddEnumerable registra MessageWriter como una implementación para IMessageWriter1. La segunda llamada registra MessageWriter para IMessageWriter2. La tercera llamada no tiene ningún efecto porque IMessageWriter1 ya tiene una implementación registrada de MessageWriter:

public interface IMessageWriter1 { }
public interface IMessageWriter2 { }

public class MessageWriter : IMessageWriter1, IMessageWriter2
{
}

services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter1, MessageWriter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter2, MessageWriter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter1, MessageWriter>());

Normalmente, el registro del servicio es independiente del orden, excepto cuando se registran varias implementaciones del mismo tipo.

IServiceCollection es una colección de objetos ServiceDescriptor. En el ejemplo siguiente se muestra cómo registrar un servicio mediante la creación e incorporación de un elemento ServiceDescriptor:

string secretKey = Configuration["SecretKey"];
var descriptor = new ServiceDescriptor(
    typeof(IMessageWriter),
    _ => new DefaultMessageWriter(secretKey),
    ServiceLifetime.Transient);

services.Add(descriptor);

Los métodos Add{LIFETIME} integrados usan el mismo enfoque. Por ejemplo, consulte el código fuente de AddScoped.

Comportamiento de inserción de constructor

Los servicios se pueden resolver mediante el uso de:

Los constructores pueden aceptar argumentos que no se proporcionan mediante la inserción de dependencias, pero los argumentos deben asignar valores predeterminados.

Cuando los servicios se resuelven mediante IServiceProvider o ActivatorUtilities, la inserción de constructores IServiceProvider constructor público.

Cuando se resuelven los servicios mediante ActivatorUtilities, la inserción del constructor requiere que exista solo un constructor aplicable. Se admiten las sobrecargas de constructor, pero solo puede existir una sobrecarga cuyos argumentos pueda cumplir la inserción de dependencias.

Validación del ámbito

Cuando la aplicación se ejecuta en el Development entorno y llama a Development compilar el host, el proveedor de servicios predeterminado realiza comprobaciones para comprobar que:

  • Los servicios con ámbito no se resuelven desde el proveedor de servicios raíz.
  • Los servicios con ámbito no se insertan en singletons.

El proveedor de servicios raíz se crea cuando se llama a BuildServiceProvider. La vigencia del proveedor de servicios raíz es la misma que la de la aplicación cuando el proveedor se inicia con la aplicación, y se elimina cuando la aplicación se cierra.

De la eliminación de los servicios con ámbito se encarga el contenedor que los creó. Si un servicio con ámbito se crea en el contenedor raíz, su duración sube a la del singleton, ya que solo lo puede eliminar el contenedor raíz cuando la aplicación se cierra. Al validar los ámbitos de servicio, este tipo de situaciones se detectan cuando se llama a BuildServiceProvider.

Escenarios de ámbito

IServiceScopeFactory siempre se registra como singleton, pero IServiceProvider puede variar en función de la duración de la clase que lo contiene. Por ejemplo, si resuelve los servicios desde un ámbito y cualquiera de esos servicios toma un valor IServiceProvider, será una instancia con ámbito.

Para lograr servicios de ámbito dentro de implementaciones de IHostedService, BackgroundServicecomo IHostedService inyecte las dependencias del servicio a través de la inserción de constructores. En su lugar, inyecte una instancia de IServiceScopeFactory, cree un ámbito y, a continuación, resuelva las dependencias del ámbito para usar la duración de servicio adecuada.

namespace WorkerScope.Example;

public class Worker : BackgroundService
{
    private readonly ILogger<Worker> _logger;
    private readonly IServiceScopeFactory _serviceScopeFactory;

    public Worker(ILogger<Worker> logger, IServiceScopeFactory serviceScopeFactory) =>
        (_logger, _serviceScopeFactory) = (logger, serviceScopeFactory);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using (IServiceScope scope = _serviceScopeFactory.CreateScope())
            {
                try
                {
                    _logger.LogInformation(
                        "Starting scoped work, provider hash: {hash}.",
                        scope.ServiceProvider.GetHashCode());

                    var store = scope.ServiceProvider.GetRequiredService<IObjectStore>();
                    var next = await store.GetNextAsync();
                    _logger.LogInformation("{next}", next);

                    var processor = scope.ServiceProvider.GetRequiredService<IObjectProcessor>();
                    await processor.ProcessAsync(next);
                    _logger.LogInformation("Processing {name}.", next.Name);

                    var relay = scope.ServiceProvider.GetRequiredService<IObjectRelay>();
                    await relay.RelayAsync(next);
                    _logger.LogInformation("Processed results have been relayed.");

                    var marked = await store.MarkAsync(next);
                    _logger.LogInformation("Marked as processed: {next}", marked);
                }
                finally
                {
                    _logger.LogInformation(
                        "Finished scoped work, provider hash: {hash}.{nl}",
                        scope.ServiceProvider.GetHashCode(), Environment.NewLine);
                }
            }
        }
    }
}

En el código anterior, mientras se ejecuta la aplicación, el servicio en segundo plano:

  • Depende de IServiceScopeFactory.
  • Crea una instancia de IServiceScope para resolver servicios adicionales.
  • Resuelve los servicios con ámbito para su consumo.
  • Trabaja en el procesamiento de objetos, los retransmite y, por último, los marca como procesados.

En el código fuente de ejemplo, puede ver cómo las implementaciones de IHostedService pueden beneficiarse de la duración del servicio con ámbito.

Vea también