ASP.NET Core DI最佳实践
本文主要分享在ASP.NETCore中使用依赖注入的经验和建议,主要有以下作用:
- 有效设计服务及其依赖项
- 防止多线程问题
- 防止内存泄漏
- 防止潜在的错误
基本
构造函数注入(Constructor injection)
构造函数注入用于声明和获取服务对服务构造的依赖关系。例:
1 | public class ProductService |
ProductService
将 IProductRepository
作为其构造函数中的依赖项注入,然后在Delete
方法中使用它。
最佳实践:
- 在服务构造函数中显示定义所需的依赖项,如果没有其依赖项,就无法构造服务
- 将注入的依赖项分配给只读字段/属性(防止在使用过程中意外赋值)
属性注入(Property Injection)
ASP.NETCore自带的容器(Microsoft.Extensions.DependencyInjection)不支持属性注入,可以使用其他支持属性注入的容器。
1 | using Microsoft.Extensions.Logging; |
ProductService
使用公共 setter 声明一个 Logger
属性。依赖注入容器可以设置Logger,如果它是可用的(之前注册给DI容器)。
最佳实践:
- 仅对可选依赖项使用属性注入,服务可以在不提供这些依赖项的情况下正常工作。
- 使用Null对象模式,或者在使用时检查
null
服务定位器(Service Locator)
服务定位器模式是获取依赖项的另一种方法。
例:
1 | public class ProductService |
ProductService
注入 IServiceProvider 并使用它来解析依赖关系。
如果之前未注册所请求的依赖项,GetRequiredService 将引发异常。另一方面,在这种情况下,GetService 只返回 null
。
在构造函数中解析服务时,它们会在服务被释放时被释放。因此,不需要关心释放/处置在构造函数中解析的服务(就像构造函数和属性注入一样)
最佳实践:
- 尽可能不要使用服务定位器模式,因为它使依赖关系隐含起来。在创建服务实例时不能看到依赖关系,影响单元测试。
- 在服务构造函数中解决依赖关系,在服务方法中解决会使你的应用程序更加复杂和容易出错
服务生命周期
ASP.NET Core依赖注入中有三种服务生命周期:
Transient
瞬时服务在每次注入或请求服务时都会创建服务
Scoped
作用域服务是按作用域创建的。在 Web 应用程序中,每个 Web 请求都会创建一个新的分隔服务作用域,根据 Web 请求创建作用域服务。
Singleton
单例服务是按 DI 容器创建的。这通常意味着每个应用程序只创建一次,然后在整个应用程序生命周期内使用.
DI容器保持对所有已解决的服务的跟踪。服务在其生命周期结束时被释放和处置。
- 如果服务具有依赖项,则还会自动释放和释放这些依赖项。
- 如果服务实现了IDisposable接口,
Dispose
方法会在服务释放时被自动调用。
最佳实践:
- 尽可能地将服务注册为瞬时服务。因为设计瞬时服务很简单。一般不关心多线程和内存泄漏,服务的生命周期很短。
- 谨慎使用作用域服务生命周期,因为如果创建子服务作用域或从非 Web 应用程序使用这些服务可能存在问题。
- 谨慎使用单例生命周期,需要处理多线程和潜在的内存泄漏问题。
- 不要依赖单例服务中的瞬时或范围服务。 因为当单例服务注入瞬时服务时,瞬态服务会变成单例实例,如果瞬时服务不是为支持这种情况而设计的,则可能会导致问题。 在这种情况下,ASP.NET Core 的默认 DI 容器已经抛出异常。
在方法中解析服务
在某些情况下,可能需要在服务的方法中解析另一个服务。
在这种情况下,请确保在使用后释放服务。
最佳方法是创建服务范围(Scope)。
1 | public class PriceCalculator |
PriceCalculator
在其构造函数中注入IServiceProvider
并将其分配给一个字段。 PriceCalculator
然后在Calculate
方法中使用它来创建子服务范围。 它使用 scope.ServiceProvider
来解析服务,而不是注入的 _serviceProvider
实例。 因此,从范围解析的所有服务都会在 using
语句的末尾自动释放。
最佳实践:
- 如果要解析方法体中的服务,请始终创建子服务作用域,以确保正确释放已解析的服务
- 如果一个方法获取 IServiceProvider 作为参数,那么可以直接从中解析服务,而无需关心释放。 创建/管理服务范围是调用方法的代码的责任。 遵循这个原则可以让代码更干净。
- 不要保留对已解析服务的引用!否则,它可能会导致内存泄漏,并且稍后使用对象引用时将访问已释放的服务(除非已解析的服务是单例)。
单例服务(Singleton Services)
单例服务通常旨在保持应用程序状态。 缓存是应用程序状态的一个很好的例子。
示例:
1 | public class FileService |
文件服务只是缓存文件内容以减少磁盘读取。此服务应注册为单例,否则,缓存将无法按预期工作。
最佳实践:
如果服务保持状态,则应以线程安全的方式访问该状态。因为所有请求同时使用同一服务实例。使用 ConcurrentDictionary 而不是 Dictionary 来确保线程安全。
不要使用来自单例服务的作用域或瞬态服务。因为,瞬时服务可能未设计为线程安全。如果必须使用它们,在使用这些服务时注意多线程(例如使用锁)。
内存泄漏通常是由单例服务引起的。在应用程序结束之前,它们不会释放。因此,如果它们实例化(或注入)类但不释放放它们,它们将保留在内存中,直到应用程序结束。确保在正确的时间释放它们。
如果缓存数据(示例中为文件内容),则应创建一种机制,以便在原始数据源更改时(当磁盘上的缓存文件发生更改时)更新/使缓存数据失效。
作用域服务(Scoped Services)
作用域生存期首先似乎是存储每个 Web 请求数据的良好候选者。
因为 ASP.NET Core为每个 Web 请求创建一个服务范围。因此,如果将服务注册为作用域,则可以在 Web 请求期间共享该服务。
示例:
1 | public class RequestItemsService |
如果将 RequestItemsService
注册为作用域,并将其注入到两个不同的服务中,则可以获取从另一个服务添加的项,因为它们将共享同一个RequestItemsService
实例。这是我们对作用域服务的猜想。
但并不是这样,如果创建子服务作用域并从子作用域解析 RequestItemsService
,将获得 RequestItemsService
的新实例,并且它不会按预期工作。
因此,作用域服务并不意味着每个 Web 请求的实例。
你可能认为你没有犯这么明显的错误(在一个子的作用域内解决一个作用域)。 但是,这不是一个错误(一种非常常见的用法),而且情况可能并不那么简单。 如果服务之间存在很大的依赖关系图,无法知道是否有人创建了子作用域并解析了注入另一个服务的服务……最终注入了作用域服务。
最佳实践:
可以将作用域服务视为一种优化,它在 Web 请求中被太多服务注入。 因此,所有这些服务将在同一个 Web 请求期间使用该服务的单个实例。
作用域内服务不需要设计为线程安全。因为它们通常应由单个Web请求/线程使用。但在这种情况下,不应在不同线程之间共享服务作用域。
如果设计一个范围服务以在 Web 请求中的其他服务之间共享数据(如上所述),可以将每个 Web 请求数据存储在 HttpContext 中(注入
IHttpContextAccessor
以访问它),这是更安全的方法。 HttpContext 的生命周期没有作用域。 实际上,它根本没有注册到 DI(这就是为什么不注入它,而是注入 IHttpContextAccessor 的原因)。 HttpContextAccessor 实现使用 AsyncLocal 在 Web 请求期间共享相同的 HttpContext。
HttpAbstractions/HttpContextAccessor.cs at master · aspnet/HttpAbstractions (github.com)
结论
依赖关系注入似乎很容易使用,但是如果不遵循一些严格的原则,则存在潜在的多线程和内存泄漏问题。