ASP.NET Core Dependency Injection Mejores prácticas, consejos y trucos

En este artículo, compartiré mis experiencias y sugerencias sobre el uso de Inyección de dependencias en aplicaciones ASP.NET Core. La motivación detrás de estos principios son;

  • Diseño efectivo de servicios y sus dependencias.
  • Prevención de problemas de subprocesos múltiples.
  • Prevención de fugas de memoria.
  • Prevención de posibles errores.

Este artículo asume que ya está familiarizado con Inyección de dependencias y ASP.NET Core en un nivel básico. De lo contrario, lea primero la documentación de Inyección de dependencia de núcleo de ASP.NET.

Lo esencial

Inyección de constructor

La inyección de constructor se utiliza para declarar y obtener dependencias de un servicio en la construcción del servicio. Ejemplo:

ProductService de clase pública
{
    Private readonly IProductRepository _productRepository;
    Public ProductService (IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }
    public void Delete (int id)
    {
        _productRepository.Delete (id);
    }
}

ProductService está inyectando IProductRepository como una dependencia en su constructor y luego lo está usando dentro del método Delete.

Buenas practicas:

  • Defina las dependencias requeridas explícitamente en el constructor del servicio. Por lo tanto, el servicio no se puede construir sin sus dependencias.
  • Asigne dependencia inyectada a un campo / propiedad de solo lectura (para evitar asignarle accidentalmente otro valor dentro de un método).

Inyección de propiedad

El contenedor de inyección de dependencia estándar de ASP.NET Core no admite la inyección de propiedades. Pero puede usar otro recipiente que respalde la inyección de la propiedad. Ejemplo:

usando Microsoft.Extensions.Logging;
usando Microsoft.Extensions.Logging.Abstraction;
espacio de nombres MyApp
{
    ProductService de clase pública
    {
        public ILogger  Logger {get; conjunto; }
        Private readonly IProductRepository _productRepository;
        Public ProductService (IProductRepository productRepository)
        {
            _productRepository = productRepository;
            Logger = NullLogger  .Instance;
        }
        public void Delete (int id)
        {
            _productRepository.Delete (id);
            Logger.LogInformation (
                $ "Eliminó un producto con id = {id}");
        }
    }
}

ProductService está declarando una propiedad Logger con setter público. El contenedor de inyección de dependencia puede configurar el registrador si está disponible (registrado en el contenedor DI antes).

Buenas practicas:

  • Use la inyección de propiedades solo para dependencias opcionales. Eso significa que su servicio puede funcionar correctamente sin estas dependencias proporcionadas.
  • Utilice un patrón de objeto nulo (como en este ejemplo) si es posible. De lo contrario, compruebe siempre que sea nulo mientras usa la dependencia.

Servicio de localización

El patrón del localizador de servicios es otra forma de obtener dependencias. Ejemplo:

ProductService de clase pública
{
    Private readonly IProductRepository _productRepository;
    Private readonly ILogger  _logger;
    Public ProductService (IServiceProvider serviceProvider)
    {
        _productRepository = serviceProvider
          .GetRequiredService  ();
        _logger = serviceProvider
          .GetService > () ??
            NullLogger  .Instance;
    }
    public void Delete (int id)
    {
        _productRepository.Delete (id);
        _logger.LogInformation ($ "Eliminó un producto con id = {id}");
    }
}

ProductService está inyectando IServiceProvider y resolviendo dependencias que lo utilizan. GetRequiredService genera una excepción si la dependencia solicitada no se había registrado antes. Por otro lado, GetService solo devuelve nulo en ese caso.

Cuando resuelve servicios dentro del constructor, se liberan cuando se lanza el servicio. Por lo tanto, no le importa liberar / eliminar servicios resueltos dentro del constructor (al igual que el constructor y la inyección de propiedades).

Buenas practicas:

  • No utilice el patrón del localizador de servicios siempre que sea posible (si el tipo de servicio se conoce en el tiempo de desarrollo). Porque hace que las dependencias sean implícitas. Eso significa que no es posible ver las dependencias fácilmente mientras se crea una instancia del servicio. Esto es especialmente importante para las pruebas unitarias en las que es posible que desee burlarse de algunas dependencias de un servicio.
  • Resolver dependencias en el constructor del servicio si es posible. Resolver en un método de servicio hace que su aplicación sea más complicada y propensa a errores. Cubriré los problemas y las soluciones en las siguientes secciones.

Tiempos de vida de servicio

Hay tres tiempos de vida de servicio en la Inyección de dependencia de núcleo de ASP.NET:

  1. Los servicios transitorios se crean cada vez que se inyectan o solicitan.
  2. Los servicios con ámbito se crean por ámbito. En una aplicación web, cada solicitud web crea un nuevo ámbito de servicio separado. Eso significa que los servicios con alcance generalmente se crean por solicitud web.
  3. Los servicios Singleton se crean por contenedor DI. Eso generalmente significa que se crean solo una vez por aplicación y luego se usan durante toda la vida útil de la aplicación.

El contenedor DI realiza un seguimiento de todos los servicios resueltos. Los servicios se liberan y se eliminan cuando finaliza su vida útil:

  • Si el servicio tiene dependencias, también se liberan y eliminan automáticamente.
  • Si el servicio implementa la interfaz IDisposable, el método Dispose se llama automáticamente en el lanzamiento del servicio.

Buenas practicas:

  • Registre sus servicios como transitorios siempre que sea posible. Porque es sencillo diseñar servicios transitorios. Por lo general, no le importan los subprocesos múltiples y las pérdidas de memoria y sabe que el servicio tiene una vida corta.
  • Utilice el servicio de alcance de por vida con cuidado, ya que puede ser complicado si crea ámbitos de servicio secundario o utiliza estos servicios desde una aplicación que no es web.
  • Utilice la vida útil de Singleton con cuidado, ya que debe lidiar con problemas de múltiples subprocesos y posibles pérdidas de memoria.
  • No dependa de un servicio transitorio o de alcance de un servicio singleton. Debido a que el servicio transitorio se convierte en una instancia singleton cuando un servicio singleton lo inyecta y eso puede causar problemas si el servicio transitorio no está diseñado para soportar tal escenario. El contenedor DI predeterminado de ASP.NET Core ya arroja excepciones en tales casos.

Resolviendo servicios en un cuerpo de método

En algunos casos, es posible que deba resolver otro servicio en un método de su servicio. En tales casos, asegúrese de liberar el servicio después del uso. La mejor manera de garantizar eso es crear un alcance de servicio. Ejemplo:

PriceCalculator de clase pública
{
    IServiceProvider de solo lectura privado _serviceProvider;
    Calculadora de precios pública (IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    flotación pública Calcular (Producto producto, int cuenta,
      Escriba taxStrategyServiceType)
    {
        utilizando (var scope = _serviceProvider.CreateScope ())
        {
            var taxStrategy = (ITaxStrategy) scope.ServiceProvider
              .GetRequiredService (taxStrategyServiceType);
            precio var = producto. Precio * recuento;
            precio de devolución + taxStrategy.CalculateTax (precio);
        }
    }
}

PriceCalculator inyecta el IServiceProvider en su constructor y lo asigna a un campo. PriceCalculator luego lo utiliza dentro del método Calcular para crear un ámbito de servicio secundario. Utiliza scope.ServiceProvider para resolver servicios, en lugar de la instancia inyectada _serviceProvider. Por lo tanto, todos los servicios resueltos desde el alcance se liberan / eliminan automáticamente al final de la declaración de uso.

Buenas practicas:

  • Si está resolviendo un servicio en un cuerpo de método, cree siempre un ámbito de servicio secundario para asegurarse de que los servicios resueltos se liberen correctamente.
  • Si un método obtiene IServiceProvider como argumento, puede resolver directamente los servicios de él sin preocuparse por la liberación / eliminación. La creación / gestión del alcance del servicio es responsabilidad del código que llama a su método. Seguir este principio hace que su código sea más limpio.
  • ¡No mantenga una referencia a un servicio resuelto! De lo contrario, puede causar pérdidas de memoria y accederá a un servicio eliminado cuando utilice la referencia de objeto más adelante (a menos que el servicio resuelto sea singleton).

Servicios Singleton

Los servicios de Singleton generalmente están diseñados para mantener un estado de aplicación. Un caché es un buen ejemplo de estados de aplicación. Ejemplo:

FileService de clase pública
{
    private readonly ConcurrentDictionary  _cache;
    public FileService ()
    {
        _cache = new ConcurrentDictionary  ();
    }
    byte público [] GetFileContent (string filePath)
    {
        return _cache.GetOrAdd (filePath, _ =>
        {
            return File.ReadAllBytes (filePath);
        });
    }
}

FileService simplemente almacena en caché el contenido del archivo para reducir las lecturas del disco. Este servicio debe estar registrado como singleton. De lo contrario, el almacenamiento en caché no funcionará como se esperaba.

Buenas practicas:

  • Si el servicio tiene un estado, debe acceder a ese estado de manera segura para subprocesos. Porque todas las solicitudes utilizan simultáneamente la misma instancia del servicio. Utilicé ConcurrentDictionary en lugar de Dictionary para garantizar la seguridad del subproceso.
  • No use servicios de alcance o transitorios de servicios singleton. Porque los servicios transitorios pueden no estar diseñados para ser seguros para subprocesos. Si tiene que usarlos, tenga cuidado con los subprocesos múltiples mientras usa estos servicios (use el bloqueo, por ejemplo).
  • Las pérdidas de memoria generalmente son causadas por servicios únicos. No se liberan / eliminan hasta el final de la aplicación. Entonces, si crean instancias de clases (o inyectan) pero no las liberan / eliminan, también permanecerán en la memoria hasta el final de la aplicación. Asegúrese de liberarlos / desecharlos en el momento adecuado. Consulte la sección Servicios de resolución en un cuerpo del método anterior.
  • Si almacena datos en caché (contenido del archivo en este ejemplo), debe crear un mecanismo para actualizar / invalidar los datos almacenados en caché cuando cambie la fuente de datos original (cuando un archivo almacenado en caché cambie en el disco para este ejemplo).

Servicios con alcance

El alcance de por vida primero parece un buen candidato para almacenar datos de solicitud web. Porque ASP.NET Core crea un alcance de servicio por solicitud web. Por lo tanto, si registra un servicio como de ámbito, se puede compartir durante una solicitud web. Ejemplo:

clase pública RequestItemsService
{
    Diccionario privado de solo lectura  _items;
    RequestItemsService público ()
    {
        _items = nuevo Diccionario  ();
    }
    Conjunto público vacío (nombre de cadena, valor de objeto)
    {
        _items [nombre] = valor;
    }
    Objeto público Get (nombre de cadena)
    {
        return _items [nombre];
    }
}

Si registra RequestItemsService como ámbito y lo inyecta en dos servicios diferentes, puede obtener un elemento que se agrega desde otro servicio porque compartirán la misma instancia RequestItemsService. Eso es lo que esperamos de los servicios de alcance.

Pero ... el hecho puede no ser siempre así. Si crea un ámbito de servicio secundario y resuelve RequestItemsService desde el ámbito secundario, obtendrá una nueva instancia de RequestItemsService y no funcionará como espera. Por lo tanto, el servicio de ámbito no siempre significa instancia por solicitud web.

Puede pensar que no comete un error tan obvio (resolver un alcance dentro de un ámbito secundario). Pero, esto no es un error (un uso muy regular) y el caso puede no ser tan simple. Si hay un gran gráfico de dependencia entre sus servicios, no puede saber si alguien creó un ámbito secundario y resolvió un servicio que inyecta otro servicio ... que finalmente inyecta un servicio de alcance.

Buena práctica:

  • Un servicio con alcance se puede considerar como una optimización donde es inyectado por demasiados servicios en una solicitud web. Por lo tanto, todos estos servicios utilizarán una única instancia del servicio durante la misma solicitud web.
  • Los servicios con ámbito no necesitan ser diseñados como seguros para subprocesos. Debido a que normalmente deberían ser utilizados por una sola solicitud / hilo web. Pero ... en ese caso, ¡no debe compartir ámbitos de servicio entre diferentes hilos!
  • Tenga cuidado si diseña un servicio con alcance para compartir datos entre otros servicios en una solicitud web (explicada anteriormente). Puede almacenar datos por solicitud web dentro del HttpContext (inyecte IHttpContextAccessor para acceder a él), que es la forma más segura de hacerlo. La vida útil de HttpContext no tiene alcance. En realidad, no está registrado para DI en absoluto (es por eso que no lo inyecta, sino que inyecta IHttpContextAccessor en su lugar). La implementación de HttpContextAccessor usa AsyncLocal para compartir el mismo HttpContext durante una solicitud web.

Conclusión

La inyección de dependencia parece simple de usar al principio, pero existen posibles problemas de subprocesos múltiples y pérdida de memoria si no sigue algunos principios estrictos. Compartí algunos buenos principios basados ​​en mis propias experiencias durante el desarrollo del marco ASP.NET Boilerplate.