应用笔记 · 2023年10月24日

ASP.NET Core 依赖注入最佳实践与技巧

在这篇文章中,我将分享一下在ASP.NET Core应用程序中使用依赖注入的经验与建议。
主要分享的目的,基于以下几点原则:

有效的设计服务及它们的依赖关系
预防多线程问题
预防内存移除
预防潜在bugs
这篇文章的前提假设你已经对依赖注入和ASP.NET Core由基本的认识,如果还没有,首先请阅读 ASP.NET Core Dependency Injection documentation。

基础
构造函数注入
构造函数注入(Constructor injection)用于声明和获取服务对服务构造的依赖关系。

 

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

良好实践

在服务构造函数中显式定义所需的依赖项。这样,服务缺失依赖关系就不能构造。
将注入的依赖项赋值给一个只读(read only)字段/属性(防止在方法调用过程中无意的赋值了其他值)。
属性注入
ASP.NET Core的 标配的依赖注入容器并不支持属性注入(property injection)。但是你可以 使用其他的依赖注入容器支持属性注入。。

 

ProductService声明了一个开放了Setter的日志(Logger)属性。依赖注入容器能赋值一个可用的值给这个日志属性(前提是已经在依赖注入容器内注册过)。

良好实践

仅对可选依赖项使用属性注入。这意味着你的服务可以在不提供这些依赖项的情况下正常工作。
尽量使用 空对象模式(如实例所示)。否则,在使用依赖项时始终做NULL检查。
服务定位(Service Locator)
服务定位(Service Locator)模式是另一种获取依赖项的方式。

 

ProductService注入了IServiceProvider,并使用它解析了ProdProductServiService的依赖关系。如果在使用之前注入容器的话,使用GetRequiredService方法会抛异常。另一边,使用GetService则返回NULL。

当你在构造函数中解析(resolve)依赖服务时,他们随着服务本身的释放而释放,所以你大可不必关系构造函数注入的依赖项的释放(就像构造函数和属性注入一样)。

良好实践

尽可能不要使用服务定位(Service Locator)模式。因为这样使得服务的依赖关系隐式化(译注,++服务的依赖关系不是显示的注入,导致代码层面的服务依赖关系不明确,从构造函数看,只有一个IServiceProvider的依赖++)。这意味着在创建服务实例时不能显示的看到服务的依赖项。而这对于单元测试尤其重要,因为你可能想要模拟服务的一些依赖项。
尽可能使用构造函数解析服务依赖项。在服务方法中解析依赖项会让应用程序变得更复杂,更容易出错。接下来,我将介绍这些问题和解决方案。
服务生命周期
在ASP.NET Core依赖注入概念里面,有 三种服务的生命周期:

Transient服务,在请求或注入服务的时候,每次都创建新实例。
Scoped服务,在作用域内创建服务。在Web应用程序,每一个web请求都会创建一个新的独立的服务作用域范围。这意味着每个web请求通常都创建有作用域的服务
Singleton服务,每个依赖注入容器会创建一次单例服务。在每个应用程序只会创建一次单例服务,在应用的整个生命周期都可用。
依赖注入容器会跟踪所有解析出来的服务,在它们的生命周期结束后会释放掉这些服务。

如果服务有依赖项,这些依赖项也会自动释放。
如果服务已经实现了IDisposable接口,在服务被释放的时候也会自动调用Dispose方法。
良好实践

尽可能的将你的服务注册成Transient服务。设计一个Transient服务是相对简单的,因为你通常不需要关心多线程和内存泄漏的问题,而且这些服务生命周期相对短。
小心使用Scoped服务,因为当你创建子作用域或者在非web应用程序使用Scoped服务,会出现一些棘手的问题。
小心使用Singleton服务,因为你需要正确处理多线程问题和潜在的内存泄露问题。
不要在Singleton服务中依赖一个Transient服务或Scoped服务。因为这时Transient服务会变成Singleton服务,如果Transient服务不支持单例场景,当Singleton服务注入Transient服务时会产生异常问题。ASP.NET Core默认依赖注入容器在这种场景下会抛异常。
在方法内解析服务
在某些场景下,你可能需要在服务的方法中解析另外一个服务。这种情况下请确保在使用服务后及时释放服务。这才是创建范围作用域服务的最佳方式。

 

PriceCalculator在构造函数里注入了IServiceProvider并赋值给以只读字段。然后PriceCalculator在Calculate的方法内创建了一个子范围作用域。使用scope.ServiceProvider来解析服务依赖,而不是用_serviceProvider实例。这样,在子范围作用域内被解析的所有服务会在using的声明结束后自动释放。

良好实践

如果在方法内解析服务,请始终创建子范围作用域,以确保已解析的服务被正确释放。
如果一个方法使用IServiceProvider作为参数,那么可以直接使用它解析服务依赖,而不需要关心依赖服务是否释放。创建/管理服务范围作用域是调用方法代码的职责。遵循这一原则可以使代码更简洁。
不要保存对已解析服务的引用!否则,在使用对象引用时访问已释放的服务可能会导致内存泄漏(除非已解析的服务是单例的)。
单例服务 Singleton Services
单例服务通常为了保持应用程序状态而设计。缓存是一个应用程序状态的最好示例。

 

FileService只是简单的缓存了文件内容来减少磁盘读取。像这样的服务应该设计成单例服务。否则缓存将不能正常工作。

良好实践

如果一个服务持有某种状态,应该以线程安全的方式访问这个状态。因为所有的请求将并发的访问同一个实例,使用ConcurrentDictionary而不是Dictionary来确保线程安全。
不要在单例服务内使用Scoped/Transient服务,因为Transient服务可能不是线程安全的设计。如果确实需要使用,请注意多线程(例如使用Lock)。
引起内存泄漏的通常是由单例服务引起的。在应用程序结束之前,单例服务不会被释放。它们实例化类(或注入实例)也不会提前被释放,它们也会一直留在内存中,直到应用程序结束。确保在适当的时候释放服务,请参阅在方法内解析服务。
如果使用缓存数据(例如上述代码示例中文件内容的缓存),应该创建一种机制当原始数据发生变更的时候去更新或淘汰已缓存的数据(示例中当磁盘的文件变更时应该更新缓存)。
范围作用域服务Scoped Services
范围作用域服务似乎是一个为每个web请求存储数据的候选方式。因为ASP.NET Core为每一个Web请求都会创建一个服务范围作用域。因此一个服务注册成Scoped服务,在Web请求过程可以共享这个服务。

 

如果RequestItemsService注册成范围作用域的服务,并将RequestItemsService注入到两个不同的服务中,这两个服务可以访问到另外一个服务添加的数据,因为这两个服务在一个Web请求中是共享RequestItemsService实例的。

但是,现实情况可能不完全是这样的。如果你创建了子范围作用域并在子作用域范围内解析RequestItemsService,你会得到一个全新的RequestItemsService,而这并非我们所期望的那样。所有Scoped服务并非一个Web请求时共享一个服务实例。

你可能会认为你不会犯这样明显的错误(在子作用域内解析服务依赖)。但是这不是错误(一个常规用法而已)并且情况并没有那么简单。假设在你的服务中有庞大的服务依赖关系,你可能不知道是否有人会这么做(在子作用域内解析服务依赖)。

良好实践

Scoped服务可以视作一种优化手段(在一个web请求中不想注入太多服务)。这样在同一个Web请求中所有的服务使用同一个实例。
Scoped服务不需要设计线程安全。因为Scoped服务通常在一个线程或Web请求中使用,但是,这种场景下,不应该在不同线程之间共享Scoped服务。
如果要设计一个作用域服务来在web请求中的其他服务之间共享数据,小心上述问题。你可以使用HttpContext(通过IHttpContextAccessor来访问它)来存储每一个Web请求需要存储的数据,这是安全的处理方式。HttpContext生命周期并不是Scoped。实际上并没有注入到依赖注入的容器内(这是为什么使用IHttpContextAccessor访问它而不是注入到容器内的原因)。++在一个Web请求中, HttpContextAccessor使用AsyncLocal来共享相同的HttpContext++。
结论
依赖注入在最初使用的时候好像是挺简单的。如果不遵循严格的使用原则,依然会有潜在的多线程和内存泄漏问题。我在开发 ASP.NET Boilerplate框架过程中,基于我的实践体会分享了这些实践原则。

总结
在使用ASP.NET Core 依赖注入时需要注意几项:

在构造函数中显示的注入依赖关系。
在依赖关系众多时,职责单一原则,考虑拆分职责
更有利于单元测试。
属性注入,适用于可选依赖项,不影响服务正常运行,考虑空实现模式。
通常我们在设计框架/基类时,可以适当引入属性注入,这样可以使得继承类代码更简洁。
必要时,属性提供懒加载方式,提高服务启动速度。
选择合适的服务生命周期。顺序依次Transient > Singleton > Scoped,不确定时使用Transient ,明确使用场景的时候考虑Singleton和Scoped。同需要需要考虑服务的构建成本。
Transient服务的生命周期短,可以有效的规避多线程和内存泄漏问题,同时也引起应用程序的内存使用量上升,带了部分性能问题。
在Singleton服务中,禁止依赖Transient/Scoped服务,一方面,Transient/Scoped服务也会变成单例服务。另一方面,Transient/Scoped服务没有考虑多线程问题。
在使用Singleton服务时,多注意潜在的线程安全和内存泄漏问题。
在非Web应用场景和子作用服务场景,Scoped服务,并不能正确处理一个线程内共享实例。