[杂谈]ASP.NET Core中的依赖注入的最佳实践,小贴士和技巧

发布于:2019-10-21

杂谈

原文: ASP.NET Core Dependency Injection Best Practices, Tips & Tricks

在本文当中,笔者将分享自己在ASP.NET Core应用中使用依赖注入的经验和建议。这些原则背后的动机是: - 有效设计服务及其依赖项。 - 防止多线程引发的问题。 - 防止内存泄漏。 - 防御性编程,防止潜在的Bug。

本文假设读者已经熟悉了依赖注入,同时对ASP.NET Core有了一定的了解。如果读者还未了解这两个技术,请先阅读 微软的官方文档 ASP.NET Core Dependency Injection documentation

基础

构造函数注入

构造函数注入主要用于在一个服务构造时,声明和获取对其它服务依赖关系。例如

public class ProductService
{
    private readonly IProductRepository _productRepository;    
    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }    
    public void Delete(int id)
    {
        _productRepository.Delete(id);
    }
}

为了使用IProductRepository中的删除方法,IProductRepository被作为依赖注入到ProductService当中。

最佳实践:

  • 在服务构造函数中显式定义所需的依赖项。 因此,如果服务所依赖的依赖项目不存在时,服务就无法被创建
  • 将被注入的对象设置为只读的域或者属性(从而防止,在其它方法中意外将此值重新赋值)。

属性注入

ASP.NET Core 自身所携带的standard dependency injection container 并不支持属性注入,但是读者可以使用其它支持输入注入的容器,读者可以参考此处Dependency injection in ASP.NET Core。 具体例子如下:

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;namespace MyApp
{
    public class ProductService
    {
        public ILogger<ProductService> Logger { get; set; }        
        private readonly IProductRepository _productRepository;       
        public ProductService(IProductRepository productRepository)
        {
            _productRepository = productRepository;            
           Logger = NullLogger<ProductService>.Instance;
        }      
        public void Delete(int id)
        {
            _productRepository.Delete(id); 
            Logger.LogInformation( $"Deleted a product with id = {id}");
        }
    }
}

ProductService 声明了带有public setter方法的Logger。如果Logger存在了, 依赖注入容器会自动将Logger设置到类中, 当然Logger必须要在注入之前就已经在依赖注入的管理器中注册。

最佳实践:

  • 仅将属性注入用于可选依赖项。 这意味着服务可以在没有提供这些依赖项的情况下正常运行。
  • 使用想本例中的Null Object模式,否则的话,每次在使用这个属性前都要检查它是否为Null。

服务定位器

服务定位器模式是另一种获得依赖的方案。请看此例:

public class ProductService
{
    private readonly IProductRepository _productRepository;
    private readonly ILogger<ProductService> _logger;   
    public ProductService(IServiceProvider serviceProvider)
    {
        _productRepository = serviceProvider
          .GetRequiredService<IProductRepository>();        
        _logger = serviceProvider
          .GetService<ILogger<ProductService>>() ??
            NullLogger<ProductService>.Instance;
    }    
    public void Delete(int id)
    {
        _productRepository.Delete(id);
        _logger.LogInformation($"Deleted a product with id = {id}");
    }
}

IServiceProvider被注入到ProductService用来完成ProductService的依赖获取。如果类所需要的依赖没有被注册,GetRequiredService将会抛出异常。 但是GetService在此场景下,会返回null。 当在构造函数中进行服务定位,这些被注入的服务将会类实例释放的时候自动释放。所以,读者无需关心在构造函数中被注入的服务的释放情况(就像构造函数注入和属性注入一样)。

最佳实践:

  • 请尽可能不使用服务定位器(如果在开发时已经知道了依赖的类型)。 因为它使依赖隐式转换, 这意味着在创建类实例时无法轻易看到依赖关系。在单元测试中,如果读者希望模拟类中某些依赖,这将会有非常大的影响。
  • 尽量使用构造函数依赖注入。 服务定位器会使应用程序更加复杂且容易出错。 笔者将在下一部分中介绍问题和解决方案。

服务的生命周期

在ASP.NET Core的依赖注入中有 三种生命周期

  1. Transient,在每次服务被要求注入到类中,都会创建一个新的服务
  2. Scoped,服务在作用域内只会创建一次。在Web应用中,每个Web请求都会创建一个完全隔离的全新的服务作用域。 这代表着Scoped的生命周期的服务,会在每个Web请求时创建一个全新的服务。
  3. Singleton,代表服务只会为每个注入容器创建一次。这代表着,每个应用中该服务只会被创建一次,并在整个应用的生命周期中存活。

依赖注入容器会跟踪所有已经被解析过的服务。服务会在生命周期结束的时候释放。

  1. 如果一个服务也有依赖,这些依赖将会自动释放。
  2. 如果服务实现了IDisposable接口,Dispose方法会在服务被释放的时候自动调用。

最佳实践:

  • 尽可能将服务注册为Transient服务。 因为知道该服务的寿命很短,所以设计Transient服务很简单,通常不关心多线程和内存泄漏,它们很快就会被回收的。
  • 请谨慎使用Scoped服务的生命周期,因为如果创建了子服务作用域或从非Web应用程序使用这些服务,可能会很棘手。
  • 请谨慎使用Singleton生存期,因为这需要处理多线程和潜在的内存泄漏问题。
  • 不要在Singleton类型的服务中依赖Transient和Scoped这两种生命周期的服务。 因为Transient服务在注入Singleton服务时将成为一个Singleton实例。 如果该Transient服务并不是为这种场景设计的,那么将会产生很多问题。ASP.NET Core的注入容器对这种情况默认会抛出异常。

在方法中解析服务

有时,需要在一个服务的方法中解析并获取另一个服务。这种情况下,需要确保使用完该被注入的服务后,释放该服务。 最好的方法,就是创建一个子服务作用域,如下面的例子:

public class PriceCalculator
{
    private readonly IServiceProvider _serviceProvider;    
    public PriceCalculator(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }    
    public float Calculate(Product product, int count,
      Type taxStrategyServiceType)
    {
        using (var scope = _serviceProvider.CreateScope())
        {
            var taxStrategy = (ITaxStrategy)scope.ServiceProvider
              .GetRequiredService(taxStrategyServiceType);
            var price = product.Price * count;
            return price + taxStrategy.CalculateTax(price);
        }
    }
}

PriceCalculator构造的时候,IServiceProvider作为PriceCalculator的一个域被注入到了PriceCalculator当中。 PriceCalculator在它的Calculate方法中,使用IServiceProvider创建了一个子服务作用域。 在Calculate方法中使用了scope.ServiceProvider来代替_serviceProvider实例来完成服务的解析和注入。 因此所有在此通过scope注入的服务,都会随着using语句释放scope而自动释放。

最佳实践:

  • 如果要在方法中解析服务,请始终创建子服务作用域以确保正确释放已解析和注入的服务。
  • 如果方法以IServiceProvider作为参数,则可以直接从中解析服务,而无需考虑释放。 如果代码中创建服务作用域,那么代码需要自行管理服务作用域。 遵循此原则可使代码更整洁。
  • 不要保留对已解决服务的引用! 因为这样,可能会导致内存泄漏,并且稍后在使用对象引用时(除Singleton生命周期),可能会访问一个已被释放的对象。

单实例

单例服务通常被用来保持应用程序状态。 缓存就是保存应用程序状态的一个很好的例子。

public class FileService
{
    private readonly ConcurrentDictionary<string, byte[]> _cache;    
    public FileService()
    {
        _cache = new ConcurrentDictionary<string, byte[]>();
    }    
    public byte[] GetFileContent(string filePath)
    {
        return _cache.GetOrAdd(filePath, _ =>
        {
            return File.ReadAllBytes(filePath);
        });
    }
}

FileService只是缓存文件内容以减少磁盘读取的服务。 该服务应注册为单例,否则它将不会按我们所想的那样工作。

最佳实践:

  • 如果服务保持状态,则应在设计上做到数据读写是线程安全的。 因为所有请求同时使用服务的相同实例。 笔者一般会使用ConcurrentDictionary而不是Dictionary来确保线程安全。
  • 不要使用单例服务中的Scoped或Transient服务。 因为,Transient服务可能并不是线程安全的。 如果必须使用它们,则在使用这些服务时要注意多线程(例如使用锁)。
  • 内存泄漏通常是由单例服务引起的。 在应用程序结束之前,它们是不会释放的。 因此,如果它们被实例化(或注入)后,直到应用程序结束时,才会被释放。 确保在适当的时候释放它们。 请参阅前面的在方法中解析服务。
  • 如果是为了缓存数据(在此示例中为文件内容),则应创建一种机制当原始数据源发生更改时(在此示例中,当磁盘上的缓存文件发生更改时)更新或使缓存的数据无效。

Scoped类型服务

Scoped生命周期似乎是存储每个Web请求数据的理想选择。 因为ASP.NET Core会为每个Web请求创建一个服务作用域。 因此,如果您将服务注册为Scoped类型,则可以在Web请求期间共享该服务。

public class RequestItemsService
{
    private readonly Dictionary<string, object> _items;    
    public RequestItemsService()
    {
        _items = new Dictionary<string, object>();
    }    
    public void Set(string name, object value)
    {
        _items[name] = value;
    }    
    public object Get(string name)
    {
        return _items[name];
    }
}

如果将RequestItemsService注册为Scoped类型并将其注入到两个不同的服务中, 则可以从一个服务中获得另一个服务添加的项目,因为它们将共享相同的RequestItemsService实例。 这就是对Scoped类型服务所具备的特性。

但是事实可能并不总是那样。 如果创建子服务作用域并从子服务作用域解析RequestItemsService,则将获得RequestItemsService的全新实例, 它也就无法按照设计所想的那样工作了。因此,Scoped类型服务并不意味着每个Web请求都共享同一个实例。

读者可能会认为这种显而易见的错误并不容易发生(在子作用域中解析Scoped类型服务)。但是这并不是一个错误(仅仅是一种常规的用法)。 通常这种场景发生的时候,所面向的业务并非如同例子中的业务这样简单。如果服务之间有着复杂的依赖关系,没有办法确保有人创建子作用域, 并在子作用域中进行服务注入,注入那些Scoped类型服务。

最佳实践:

  • Scoped类型服务可以被认为是一种优化,它是在一个Web请求中需要多次注入的服务。 因此,所有依赖Scoped类型服务的服务将在同一Web请求期间使用该Scoped类型服务的单个实例。
  • Scoped类型服务不必设计为线程安全的。 因为,它们通常应由单个Web请求/线程使用。因此,如果不使用线程安全,就不应该在不同线程中使用同一个Scoped类型服务的实例。
  • 如果使用Scoped类型服务是为了在Web请求中让所有服务之间共享数据,请小心(如上所述的原因)。 因为我们完全可以将每个Web请求的数据存储在HttpContext内(注入IHttpContextAccessor进行访问),这是更安全的方法。 HttpContext的生存期不受限制。 实际上,它根本没有注册到依赖注入中(这就是为什么不注入它,而是注入IHttpContextAccessor的原因)。 HttpContextAccessor通过使用AsyncLocal在Web请求期间共享相同的HttpContext

总结

依赖注入一开始似乎很容易使用,但是如果读者不严格的遵循某些原则,则可能存在多线程和内存泄漏的问题。 在这里分享的最佳实践,都是笔者在开发ASP.NET Boilerplate框架这以过程中积累的经验。 希望内给各位读者带来一些帮助。