依赖注入 (Dependency Injection, DI) 不只是在调像 AddSingleton 这样的容器 API,它真正处理的问题是,一个对象应该自己寻找并创建依赖,还是只声明自己需要什么,把创建、装配留给外部。
在 .NET 中,DI 已经和配置、日志、选项模式之类一样,成为 Host 提供的基础能力。类通过构造函数显式声明依赖,具体实现则集中在应用启动位置进行注册装配。
DI 与依赖倒置原则 (Dependency Inversion Principle, DIP) 的方向一样,只是 DIP 关注于代码的依赖方向,而 DI 讨论依赖由谁创建和装配。
从 new 开始
当一个类写下 new SqlOrderRepository() 时,它便一边处理业务,一边决定仓储使用什么实现、如何创建。
前者属于业务职责,后者属于应用装配。两者揉在一起后,替换实现需要修改调用者;依赖继续拥有依赖时,配置代码会沿调用链散开;而测试也很难避开数据库、网络或文件系统等外部资源。
依赖可以从构造函数、Setter 或专门的接口进入对象。Martin Fowler 将其概括为构造函数注入 (Constructor Injection)、Setter 注入 (Setter Injection) 和接口注入 (Interface Injection)。在 .NET 中,构造函数注入最为常见,因为它能让依赖在对象创建时就完整、显式的出现。
移出职责
把创建职责移出后,依赖就变得可以替换、对象图可以集中装配、生命周期也可以统一管理了。
最直观的是测试。测试的时候可以传入桩或 Mock 对象,而不必让被测试类接触真实的数据库、网络服务或系统时钟之类外部资源。
1 | |
上述代码中,MessageWriter 类的 Write 方法可能被其他类依赖,比如:
1 | |
我们可以借助构造函数注入:
1 | |
这样,Worker 只声明自己需要一个 IMessageWriter。至于消息最终写入控制台、日志还是队列,由应用的装配部分决定即可。
以后替换实现时,只需要修改注册的地方,不必动 Worker 的这个循环逻辑。依赖因此从类内部的实现细节,变成了构造函数上可以直接看到的关系。
当一个依赖拥有其他依赖对象时,容器需要创建的则是一整张对象图 (Object Graph)。它从入口类型出发,递归补齐构造函数所需的服务,再按照注册时声明的生命周期创建、复用和释放对象。
- 瞬态 (Transient):每次从容器请求服务时创建新实例。
- 作用域 (Scoped):同一个作用域内复用实例,不同作用域使用不同实例。
- 单例 (Singleton):同一个根容器始终使用同一实例。
在 ASP.NET Core 中,一个请求通常对应一个作用域;后台任务、桌面 App 和控制台程序可能需要自行建立作用域。
在 .NET 中,这套装配机制又恰好是 Host 的共同基础。日志、配置、Options、IHttpClientFactory 和 Hosted Service 等能力都通过 IServiceCollection 接入。
设计
DI 只负责按照注册规则创建和连接对象,不能替我们判断对象的边界是否合理。
一个类即使由容器创建,拥有十几个构造函数时,仍然可能承担过多职责;一个共享可变状态的类型即使注册为 Singleton,也不会自动获得线程安全;给每个类补上接口,也不代表系统拥有了良好的抽象。
接口应该表达业务边界的替换点。只为了能注册进容器而抽出的接口,通常只是把复杂度放到另一块地方了。
Service Locator 正好走向了另一侧:对象表面上没有依赖,内部却随时调用 GetService() 寻找服务,依赖从构造函数藏进了运行时中,变得隐性。
.NET 内置容器
.NET 的默认 DI 抽象与实现主要位于 Microsoft.Extensions.DependencyInjection:
IServiceCollection是注册描述的集合。IServiceProvider在容器构建完成后负责解析对象图。
1 | |
先记录服务类型、实现类型和生命周期之间的映射,再构建根容器。解析 OrderAppService 时,容器还会继续补齐它构造函数中的依赖。
因为 IOrderRepository 被注册为 Scoped,这里先建立作用域,再从作用域对应的 Provider 中解析服务。
这类写法适合展示容器机制。在 Generic Host 或 ASP.NET Core 应用中,通常应让 Host 构建最终容器,不要在服务注册过程中额外调用 BuildServiceProvider()。
validateScopes: true 主要检查两类生命周期越界:从根容器直接解析 Scoped 服务,以及把 Scoped 服务注入 Singleton。它用于检查作用域是否被错误提升。
生命周期
Transient 关心的是每次解析;Scoped 关心的是同一个作用域;Singleton 关心的是同一个根容器。不能把三者简单记成每次请求、一次请求和整个应用,因为请求只是 ASP.NET Core 中最常见的一种作用域。
生命周期应该贴合状态的共享边界。彼此独立、创建成本较低的短期对象可以使用Transient;需要在一次请求、一次消息处理或一次作业执行中共享的工作适合使用Scoped;确实需要跨应用共享,或者创建成本较高且可以安全复用的对象,才考虑Singleton。
容器只保证自身的并发解析过程,不会替被解析出的服务加锁。Singleton 必须自行保证线程安全,尤其需要谨慎处理共享可变状态。
由容器创建并跟踪的 IDisposable 或 IAsyncDisposable 服务,会随着所属作用域或根容器一起释放。从容器解析出的服务不应由调用方再次手动 Dispose。
但 AddSingleton(new ExampleService()) 传入的是外部创建的实例。容器只保存它的引用,不负责自动释放,生命周期仍然由创建者管理。
Singleton 会让对象及其依赖长期存活,还可能把原本应该隔离的请求状态连接在一起。
注入方式
在 .NET 内置容器中,构造注入是内置容器的主要使用方式。被激活的类型需要拥有公开构造函数;存在多个构造函数时,容器会选择参数最多且全部可以解析的候选。如果多个候选同样适用、无法确定唯一构造函数,解析会直接失败。
1 | |
内置容器没有面向普通 C# 类型的通用属性注入机制。Razor 中的@inject是框架在生成视图或组件代码时创建接收服务的属性,不能据此认为任意类型都支持属性注入。需要通用属性注入等高级能力时,才有必要评估第三方容器。
1 | |
日常用法
IServiceProvider 是容器的底层解析入口,但它不应该沿着业务调用链到处传播。业务类一旦拿到 Provider,就可以在运行时随意索取服务,构造函数也不再能够说明对象真正需要什么。
GetService() 没有减少依赖,只是把依赖藏成了一次运行时查找。Provider 更适合留在组合根 (Composition Root) 以及确实承担对象创建职责的少数工厂中。
长生命周期服务需要使用 Scoped 服务时,应注入 IServiceScopeFactory,在每次工作开始时显式创建作用域:
1 | |
同一个服务类型可以注册多个实现。默认容器解析单个 T 时返回最后注册的实现;解析 IEnumerable<T> 时,则按注册顺序返回全部实现。
IEnumerable<T> 更适合“把所有实现都执行一遍”的场景:
1 | |
若对象本身不值得长期注册,但部分构造参数仍然来自容器,可以尝试使用 ActivatorUtilities。它只负责完成对象构造,不会把新对象纳入容器的生命周期跟踪;如果新对象需要释放,创建者就必须承担释放责任。
1 | |
开放泛型 (Open Generic) 允许我们一次注册一组结构相同的泛型服务,而不必为每个封闭类型重复注册:
1 | |
当同一个接口存在多个实现,并且选择规则本身就是一个稳定的键时,可以使用 Keyed Services。它比在业务代码中手写字典或 switch 工厂更直接。
1 | |
迁移与反模式
如果要迁移到 DI,先找出高层入口(如Program、Host)作为组合根 (Composition Root),再把最容易替换或最难测试的硬依赖提成抽象;随后把 new 从服务类、控制器、处理和后台任务里移出去,改成构造函数显式声明;最后再根据对象的业务边界标注生命周期。
对于 Service Locator 模式,和上面一样,主要把隐性依赖改成显式依赖,避免在服务中直接调用 GetService() 或 GetRequiredService()。
同时,DI 存在很多反模式。
| 反模式 | 问题 | 解决方案 |
|---|---|---|
Service Locator / 业务层注入 IServiceProvider | 依赖隐藏、可读性差、测试模糊 | 优先构造注入;只在组合根、极少数工厂使用 provider |
Singleton 捕获 Scoped | 形成 Captive Dependency,短生命周期对象被意外提升 | 开启 validateScopes;重审依赖方向或改生命周期 |
从根容器解析 IDisposable 的 Transient/Scoped | 对象可能一直被跟踪到根容器释放,资源存活时间被拉长 | 建立作用域;或用工厂 + ActivatorUtilities 在容器外创建 |
配置服务时调用 BuildServiceProvider() | 创建额外容器,可能产生重复单例和过早解析 | 使用带 IServiceProvider 参数的注册工厂重载 |
在同步注册工厂中调用异步方法并.Result | 可能死锁 | 保持注册工厂同步、快速;把异步初始化移到服务方法 |
常规中间件构造函数直接注入 Scoped | 中间件实例长期存活,作用域依赖无法正确进入构造函数 | 改为 InvokeAsync 方法注入,或使用工厂型中间件 |
| 构造函数参数过多 | 通常职责边界已经失控 | 按用例拆分服务 |
结尾
之前说到 SOLID,DI 是其中 DIP 在 .NET 应用中的工程落地方案。把依赖显式放在构造函数上,把装配集中在组合根,再根据业务边界处理好生命周期,DI 能让依赖关系变得显式,也会让原本隐藏的职责混乱、生命周期越界和抽象滥用更早暴露出来。