译者前言
本文译自ABP框架的开发博客《ASP.NET Core Dependency Injection Best Practices, Tips & Tricks》一文(原作者是Halil İbrahim Kalkan),仅在知乎平台发布。转载请注明原文链接、本文链接和译者(知乎用户 @叶影 )。
原文前言
在本文中,笔者将对http://ASP.NET Core应用中使用依赖注入的话题分享经验和建议。这些原则背后的动机是:
- 有效地设计服务及其依赖性
- 防止多线程问题
- 防止内存泄漏
- 防止潜在的错误
本文假定读者已经基本熟悉了依赖注入和http://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方法中使用它。
优秀实践:
- 在服务的构造函数中显式定义所需的依赖项。这样,没有依赖项就无法构建服务。
- 将注入的依赖项分配给只读字段/属性(以防止在方法内部意外为其分配另一个值)。
2.属性注入
http://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属性。只要ILogger的实例可用(之前已注册到DI容器),依赖注入容器就可以设置Logger对象。
优秀实践:
- 仅将属性注入用于可选的依赖项。这意味着您的服务可以在不提供这些依赖项的情况下正常运行。
- 如果可能,请使用空对象模式(Null Object Pattern,如本例所示)。否则,请在使用依赖项时始终进行Null检查。
3. 服务定位
服务定位模式(Service Locator Pattern)是获取依赖项的另一种方式。例如:
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。
当您在构造函数内部解析服务时,这些依赖服务将随着服务释放而一起释放。因此,您不必关心释放(release)/处理(dispose)在构造函数内部解析的依赖服务(就像构造函数和属性注入一样)。
优秀实践:
- 不管在什么地方都尽量别使用服务定位器模式(如果服务的类型在开发阶段已知)。因为它使依赖关系变成了隐式的(implicit)。那意味着在创建一个服务实例时不能轻易地看出它的依赖项。这对于单元测试尤其重要,在单元测试中,您可能希望模拟(mock)服务的某些依赖关系。
- 尽量在服务的构造方法中解析依赖关系。在服务的普通方法中解析依赖会使您的应用程序更加复杂和容易出错。笔者将在下一部分中介绍问题和解决方案。
4. 服务生命周期
在http://ASP.NET Core的依赖注入中,有三种服务生命周期。
- Transient类型的服务。每次注入或请求都会创建一个实例。
- Scoped类型的服务。按域(scope)的概念创建实例。在一个web应用中,每个web请求都会创建一个新的单独的服务域。那意味着scope类型的服务一般在每个web请求中都会创建一次。
- Singleton类型的服务。在每个DI容器里创建。通常,这意味着每个应用程序只能创建一次,然后在整个应用程序生命周期中使用它们。
DI容器会持续跟踪所有解析过的服务。所有服务都会在生命周期结束时被释放和处理:
- 如果服务具有依赖项,则它们也将自动释放和处置。
- 如果该服务实现IDisposable接口,则在服务释放时Dispose方法会被自动调用。
优秀实践:
- 尽可能将您的服务注册为Transient服务。因为设计Transient服务很简单。您通常不用关心多线程和内存泄漏,并且知道该服务的生命周期很短。
- 慎用Scoped服务,因为如果您创建了子服务域或者从一个非Web应用程序使用这些服务,情况可能会很棘手。
- 慎用Singleton服务,因为使用后你需要处理多线程和潜在的内存泄漏问题。
- 不要依赖Singleton服务中的Transient或Scope服务。因为,随着Singleton服务的注入,其中的Transient服务也变成了一个单例,而如果Transient服务在设计上并不支持这样的一个场景,则可能会导致问题。在这种情况下,http://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实例。因此,从域解析的所有服务都会在using语句的末尾自动释放/处理。
优秀实践:
- 如果要在方法体中解析服务,请始终创建子服务域以确保正确释放解析出的服务。
- 如果方法以IServiceProvider作为参数,则方法内可以直接从中解析服务,而无需考虑释放(release)/处理(dispose)。创建/管理服务域由调用您方法的代码负责。遵循此原则可使您的代码更整洁。
- 不要保留对解析出的服务的引用!否则,这可能会导致内存泄漏,以及当之后使用该对象引用时,您将访问到一个资源已释放的服务(disposed service)(除非解析的服务是单例)。
单例服务(Singleton Services)
单例服务通常被设计用来保持一个应用程序状态。缓存是应用程序状态中一个很好的例子。例如:
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 Services)
作用域类型服务的生命周期首先似乎是存储每个Web请求数据的理想候选。因为http://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框架的开发过程中总结的经验,分享了一些好的原则。