依赖注入.md 12 KB

ASP.NET 核心依赖性注入的最佳实践、技巧和诀窍

在本文中,我将分享我在 ASP.NET Core 应用程序中使用依赖注入的经验和建议。这些原则背后的动机是:

  • 有效地设计服务及其依赖性。
  • 防止多线程问题。
  • 防止内存泄漏。
  • 防止潜在的 bug。

本文假定您已经基本熟悉了依赖注入和 ASP.NET Core。如果不熟悉,请先阅读 ASP.NET Core Dependency Injection 文档。

基础知识

构造函数注入

构造函数注入用于声明和获取服务构造上的依赖关系。例如:

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

ProductService 在其构造函数中注入 IProductRepository 作为依赖,然后在 Delete 方法中使用它。

👍 良好的做法

  • 在服务构造函数中明确定义所需的依赖关系。因此,如果没有依赖关系,就不能构造服务。

  • 将注入的依赖关系分配给一个只读的字段/属性(以防止在方法中意外地将另一个值分配给它)。

属性注入

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,如果它是可用的(之前注册到 DI 容器)。

良好的做法

  • 只对可选的依赖关系使用属性注入。这意味着你的服务可以在没有提供这些依赖关系的情况下正常工作。
  • 如果可能的话,使用 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}");
    }
}

ProductService 正在注入 IServiceProvider 并使用它解析依赖关系。如果请求的依赖关系之前没有注册,GetRequiredService 会抛出异常。另一方面,GetService 在这种情况下只是返回 null。

当你在构造函数内部解析服务时,当服务被释放时,它们就会被释放。所以,你并不关心释放/处置在构造函数内部解析的服务(就像构造函数和属性注入一样)。

👍 良好的做法

  • 尽可能不要使用服务定位器模式(如果在开发的时候就知道服务类型的话)。因为它使依赖关系变得隐含。这意味着在创建服务实例时,不可能轻易看到依赖关系。这对于单元测试来说尤其重要,因为在单元测试中,你可能想模拟服务的一些依赖关系。

  • 如果可能的话,在服务构造函数中解决依赖关系。在服务方法中解决会使你的应用程序变得更加复杂和容易出错。我将在接下来的章节中介绍问题和解决方案。

服务生命周期

在 ASP.NET 核心依赖注入中,有三种服务寿命:

  1. Transient - 每次注入或请求时,都会创建瞬时服务。
  2. Scoped - 按范围创建的。在 Web 应用程序中,每个 Web 请求都会创建一个新的分离的服务作用域。这意味着范围化服务通常是在每个 Web 请求中创建的。
  3. Singleton - 是按 DI 容器创建的。这通常意味着它们在每个应用程序中只被创建一次,然后在整个应用程序的生命周期中使用。

DI 容器会跟踪所有已解决的服务。当服务的寿命结束时,服务会被释放和处理:

  • 如果服务有依赖关系,它们也会被自动释放和处置。
  • 如果服务实现了 IDisposable 接口,则在服务释放时自动调用 Dispose 方法。

👍 良好的做法

  • 尽可能将你的服务注册为瞬时服务。因为设计瞬态服务很简单。你一般不关心多线程和内存泄漏,你知道服务的寿命很短。
  • 谨慎使用 scoped ,因为如果你创建了子服务范围或者从非 Web 应用中使用这些服务,那就会很棘手。
  • 谨慎使用 Singleton,因为这样你需要处理多线程和潜在的内存泄漏问题。
  • 不要依赖来自 Singleton 服务的瞬态服务或作用域服务。因为当 Singleton 服务注入 Transient 服务时,Transient 服务就会变成 Singleton 服务,如果 Transient 服务的设计不支持这样的场景,那可能会造成问题。ASP.NET Core 的默认 DI 容器已经在这种情况下抛出了异常。

解决方法体中的服务

在某些情况下,您可能需要在您的服务的方法中解决另一个服务。在这种情况下,确保你在使用后释放服务。确保这一点的最好方法是创建一个服务范围。例如:

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 在 Calculate 方法中使用它来创建一个子服务范围,它使用 scope.ServiceProvider 来解析服务,而不是注入的_serviceProvider 实例。它使用 scope.ServiceProvider 来解析服务,而不是使用注入的 _serviceProvider 实例。因此,所有从作用域解析的服务都会在使用语句结束时自动 released/released。

👍 良好的做法

  • 如果你在方法体中解析一个服务,总是要创建一个子服务作用域,以确保解析的服务被正确释放。
  • 如果一个方法得到 IServiceProvider 作为参数,那么你就可以直接从它那里解析服务,而不用关心释放/处置的问题。创建/管理服务范围是调用你的方法的代码的责任。遵循这个原则可以让你的代码更干净。
  • 不要持有对已解析服务的引用!否则,可能会造成内存泄漏。否则,它可能会导致内存泄漏,并且当你以后使用对象引用时,你会访问一个已处置的服务(除非解析的是 Transient 服务)。

Transient 服务

Transient 服务一般是为了保持应用状态而设计的。缓存是应用状态的一个很好的例子。例如:

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 只是简单地缓存文件内容以减少磁盘读取。这个服务应该被注册为 Transient。否则,缓存将无法按照预期工作。

👍 良好的做法

  • 如果服务持有一个状态,它应该以线程安全的方式访问该状态。因为所有的请求都会并发使用同一个服务的实例。我使用了 ConcurrentDictionary 而不是 Dictionary 来保证线程安全。
  • 不要使用 singleton 服务中的 scoped 或 transient 服务。因为,transient 服务可能没有被设计成线程安全的。如果你必须使用它们,那么在使用这些服务时要注意多线程(比如使用锁)。
  • 内存泄漏一般是由 singleton 服务引起的。它们直到应用结束才会被释放/处理。因此,如果它们实例化类(或注入)但不释放/处置它们,它们也会留在内存中直到应用程序结束。确保在正确的时间释放/处置它们。请参阅上面的方法体中的解析服务部分。
  • 如果你缓存了数据(本例中的文件内容),你应该创建一个机制来更新/验证缓存数据,当原始数据源发生变化时(本例中缓存文件在磁盘上发生变化时)。

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 service 并不总是意味着每个 web 请求的实例。

你可能会认为你不会犯这样一个明显的错误(在子作用域中解析一个作用域)。但是,这并不是一个错误(一个非常常规的用法),情况可能并不那么简单。如果你的服务之间有一个大的依赖图,你就不能知道是否有人创建了一个子作用域,并解析了一个服务,而这个服务注入了另一个服务......最终注入了一个作用域服务。

👍 良好的做法

  • scoped 服务可以被认为是一种优化,它在一个 Web 请求中被太多的服务注入。因此,在同一个 Web 请求中,所有这些服务都会使用一个服务的实例。
  • scoped 服务不需要设计成线程安全的。因为它们通常应该由一个 web 请求/线程使用。但是......在这种情况下,你不应该在不同的线程之间共享服务作用域。
  • 如果你设计了一个 scoped 服务,以便在一个 web 请求中的其他服务之间共享数据,那就要小心了(上面已经解释过了)。你可以将每个 web 请求的数据存储在 HttpContext 里面(注入 IHttpContextAccessor 来访问它),这是比较安全的方式。HttpContext 的寿命是没有范围的。实际上,它根本没有注册到 DI(这就是为什么你不注入它,而是注入 IHttpContextAccessor 的原因)。HttpContextAccessor 实现使用 AsyncLocal 在 web 请求中共享同一个 HttpContext。

结论

依赖注入的使用初看起来很简单,但如果不严格遵循一些原则,就会出现潜在的多线程和内存泄漏问题。