【翻译】ASP.NET Core 依赖注入最佳实践,技巧和窍门

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

译者注:

  • 本文作者为Abp框架作者,文章质量很高

  • 一般情况下,是打不开原文链接的,原因你懂的

  • 本文大部分内容是采用直译,少部分采用意译。旨在保持原汁原味又能流畅阅读

  • 原文中引用的文章为英文内容,译文中换成了中文内容


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

  • 有效设计服务及其依赖项

  • 防止多线程问题

  • 防止内存泄漏

  • 防止潜在的错误

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

基本

构造函数注入

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

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的标准依赖注入容器不支持属性注入。 但是您可以使用另一个支持属性注入的容器(译者注:如Autofac)。 例:

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正在使用公共设置器声明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

当您在构造函数中解析服务时,将在服务释放时将其释放。 因此,您不必担心在构造函数内部解析的服务的资源释放问题(就像构造函数和属性注入一样)

良好做法

  • 请勿使用服务定位器模式(如果在开发时间内已经知道服务类型)。 因为它使依赖关系不明确(原文:implicit)。 这意味着在创建服务实例时不可能轻易看到依赖关系。 这对于单元测试尤其重要,在单元测试中,您可能希望模拟服务的某些依赖项
  • 如果可能,在服务构造函数中解析依赖关系。 在服务的方法中解析依赖关系会使您的应用程序更加复杂且容易出错。 我将在下一部分中介绍问题和解决方案

服务生命周期

ASP.NET Core依赖注入中存在三个服务生命周期

  1. 暂时(Transient) 在每次注入或每次请求都会创建
  2. 范围(Scoped)是按范围创建的。在Web应用程序中,每个Web请求都会创建一个新的单独的服务范围。这意味着通常根据Web请求创建范围服务
  3. 单例(Singleton) 每个DI容器都会创建单例服务。通常,这意味着每个应用程序只能创建一次,然后在整个应用程序生命周期中使用它们

DI容器跟踪所有已解析的服务。服务在生命周期结束时被释放和处置:

  • 如果服务具有依赖项,则它们也将自动释放和处置
  • 如果该服务实现IDisposable接口,则在服务发布时会自动调用Dispose方法

良好做法

  • 尽可能将您的服务注册为暂时(Transient)服务。因为设计临时服务很简单。您通常不关心多线程和内存泄漏,并且知道该服务的生命周期很短
  • 谨慎使用**范围(Scoped) **服务,因为如果您创建子服务范围或从非Web应用程序使用这些服务,可能会很棘手
  • 请谨慎使用单例(Singleton) 服务,因为这样您就需要处理多线程和潜在的内存泄漏问题
  • 不要在暂时、范围的服务里依赖单例服务。因为在单例服务注入时,该暂时、范围服务将成为一个单例实例,如果该暂时、范围服务不旨在支持这种情况,则可能会导致问题。在这种情况下,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);
        }
    }
}

PriceCalculatorIServiceProvider注入其构造函数中,并将其分配给字段_serviceProvider。 然后,PriceCalculatorCalculate方法中使用它来创建子服务范围。 它使用scope.ServiceProvider解析服务,而不是注入的_serviceProvider实例。 因此,从范围解析的所有服务都会在using语句的末尾自动释放

良好做法

  • 如果要在方法体中解析服务,请始终创建子服务范围,以确保正确释放已解析的服务
  • 如果方法以IServiceProvider 作为参数,那么您可以直接从中解析服务,而无需考虑释放/处置。 创建/管理服务范围是代码调用您的方法的责任。 遵循此原则可使您的代码更整洁
  • 不要保持已释放的服务的引用! 否则,可能会导致内存泄漏,并且以后在使用对象引用时(除非已解析的服务为单例),您将访问到一个已释放的服务

单例服务

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

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来确保线程安全
  • 请勿在单例服务中使用暂时、范围的服务。 因为,暂时、范围的服务可能没有被设计为线程安全的。 如果必须使用它们,则在使用这些服务时要注意多线程(例如使用锁)
  • 内存泄漏通常是由单例服务引起的。 在应用程序结束之前,它们不会被释放/处置。 因此,如果它们实例化类(或注入)但不释放它们,它们将保留在内存中,直到应用程序结束。 确保您在适当的时候释放它们。 请参阅上面的在方法体中解析服务部分。
  • 如果缓存了数据(在此示例中为文件内容),则应创建一种机制,以便在原始数据源发生更改时(在此示例中,当磁盘上的缓存文件发生更改时)更新、使缓存的数据无效

范围服务

范围服务似乎是为每个Web请求存储数据的不错选择。 因为ASP.NET Core会为每个Web请求创建一个服务范围。 因此,如果您将服务的生命周期注册为范围,则可以在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注册为范围服务并将其注入到两个不同的服务中,则可以获得从另一个服务添加的数据,因为它们将共享相同的RequestItemsService实例。 这也是我们对范围服务的期望

但是..事实可能并不总是那样。 如果创建子服务作用域并从子作用域解析RequestItemsService,则将获得RequestItemsService的新实例,它将无法按预期工作。 因此,范围服务并不总是意味着每个Web请求都有相同的实例

您可能会认为自己没有犯这样明显的错误(创建子服务作用域并从中解析服务)。 但是,这不是一个错误(非常常规的用法),情况可能并非如此简单。 如果您的服务之间存在较大的依赖关系图,则您将无法确定是否有人创建了子服务作用域并解析了服务

良好做法

  • 范围服务可以被认为是优化过的,它被由Web请求中的很多服务注入使用。 因此,所有这些服务将在同一Web请求期间使用该服务的相同实例
  • 范围服务不必设计为线程安全。 因为它们通常应由单个Web请求/线程使用。 但是……在那种情况下,您不应该在不同线程之间共享范围服务!
  • 如果您设计的范围服务在Web请求中的其他服务之间共享数据,请务必小心(如上所述)。 您可以将每个Web请求数据存储在HttpContext (注入IHttpContextAccessor来访问它)内部,这是更安全的方法。 HttpContext的生存期不受限制。 实际上,它根本没有注册到DI(这就是为什么您不注入它,而是注入IHttpContextAccessor的原因)。 HttpContextAccessor实现使用AsyncLocal在Web请求期间共享相同的HttpContext

结论

依赖注入一开始似乎很容易使用,但是如果您不遵循某些严格的原则,则可能存在潜在的多线程和内存泄漏问题。 在ASP.NET Boilerplate框架的开发过程中,我根据自己的经验分享了一些好的原则

原文首发在 Volosoft Blog

posted @ 2020-11-18 21:25  Vincent-Huang  阅读(386)  评论(0编辑  收藏  举报