4

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

 3 years ago
source link: https://www.ttalk.im/pages/995338350741159939
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

原文: 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框架这以过程中积累的经验。 希望内给各位读者带来一些帮助。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK