依赖注入 (Dependency Injection, DI) 把对象创建与使用职责分离。在 .NET 中,它与配置、日志和 Options 等一起成为框架级能力。
通常情况下,使用框架内置的依赖注入容器就足够了;如果需要更多功能,可以考虑使用第三方库。
从设计上,DI 是依赖倒置原则 (Dependency Inversion Principle, DIP) 最直接的实现方式,使高层依赖抽象而非具体实现,类把所需的依赖显式声明出来,而非内部new或去找全局实例。
背景
如果一个类在内部创建了它所依赖的对象,如new SqlOrderRepository(),那么它就把业务和装配实现耦合在了一起。这会导致:
- 要替换实现必须修改调用者
- 依赖的依赖会让配置代码分散
- 难做隔离单元测试
Martin Fowler 提到了三种注入风格:构造、Setter 和接口注入。在 .NET 生态中,构造注入是最常用的方式。
为什么
既然 DI 将对象的配置和使用分离开来,那么它的好处也就显而易见了。
首先是可测试性。在单元测试中,我们可以用 Mock 对象替换掉真实的依赖,从而隔离被测对象。
其次是可维护性。Microsoft Learn 文档举了个例子:
1 | |
上述代码中,MessageWriter 类的 Write 方法可能被其他类依赖,比如:
1 | |
由于 Worker 类直接依赖 MessageWriter 类,从可维护性问题的角度看,如果要用不同的实现替换 MessageWriter,就必须修改 Worker 类的代码。
这也是为什么 DI 会和日志、配置、IHttpClientFactory 等一起成为框架级能力的原因。如果需要替换实现,不应该修改调用方内部逻辑,而应更新注册映射、配置文件或环境变量等。
然后就是 DI 特性的对象图和生命周期管理。框架能够根据注册时声明的生命周期统一创建、缓存和释放对象:
- 瞬态 (Transient):每次请求都会创建一个新的对象实例。
- 作用域 (Scoped):在同一个请求中共享同一个对象实例。
- 单例 (Singleton):整个应用程序中共享同一个对象实例。
只要作用域划分合理,比手动维护工厂类更容易管理对象的生命周期。
最后就是架构一致性,.NET Host 和 ASP.NET Core 都将 DI 作为宿主资源之一,大多能力也围绕 IServiceCollection 扩展。
设计
DI 解决的只是对象创建和装配的问题。在设计 DI 时:
- 避免有状态的、静态类和成员,应设计使用单例服务的应用。
- 避免在服务中直接实例化依赖对象,应通过构造函数注入依赖。
- 保持服务的职责单一,结构合理且易于测试。
然而问题很多时候并不如建议那么显著,比如 Service Locator 模式(如GetService())或是静态访问就会导致依赖关系不明确,难以测试和维护。
内置 DI
在 .NET 中,内置 DI 容器是 Microsoft.Extensions.DependencyInjection,其中:
IServiceCollection是注册描述的集合IServiceProvider是解析入口
1 | |
上面是一个简单的注册与解析示例,手动创建了ServiceCollection,注册了三个服务,调用BuildServiceProvider 创建了一个 ServiceProvider,然后创建了一个作用域,最后解析了 OrderAppService。
同时,BuildServiceProvider 有一个可选参数 validateScopes,如果设置为 true,它会执行检查以验证作用域服务是否永远不会从根 Provider 解析。这样可以帮助开发者在开发阶段发现潜在的作用域问题。
生命周期
Transient 每次都是新对象;Scoped 在同一请求内相同、跨请求不同;Singleton 整个应用程序中相同。
同时,Transient 和 Scoped 会在所属作用域结束时释放,而 Singleton 会在应用程序结束时释放,绝不应该手动释放它们。
生命周期本质上是维护业务边界的。如果一个对象天然就应在一次请求、一条处理、一次作业执行之间共享,那么它就应该是 Scoped;如果它内部拥有共享可变状态,在保证线程安全的情况下,它就应该是 Singleton。
这里能注意到,内置容器保证解析过程的线程安全,并不保证被解析出来的对象本身是线程安全的。
所以,从内存占用、作用域泄露、耦合度等角度考虑,不要滥用 Singleton。
并不是写出AddSingleton就意味着它是由服务容器创建的服务了,对于builder.Services.AddSingleton(new ExampleService());,它是由你自己创建的对象,容器只是持有它的引用而已,不会自动释放,需要你自己在适当的时候释放它。
注入方式
在 .NET 内置容器中,构造注入是一等公民。它要求公开构造函数,并且当存在多个构造函数时,容器会选择参数最多的那个。
1 | |
对于属性注入,内置容器不提供原生支持,但并不意味着整个生态没有这个注入方式。在 Razor 视图中,可以通过@inject把服务注入为属性:
1 | |
日常用法
IServiceCollection 作为注册入口,而 IServiceProvider 作为解析入口。对大多数应用来说,IServiceProvider 不应该在业务代码中到处使用;同时,Microsoft 也明确反对用GetService() 代替 DI,以及把 ApplicationServices 或 RequestServices 作为静态全局入口。
当你需要创建作用域时,应显式CreateScope():
1 | |
如果有多个同接口实现,内置容器会遵循:解析单个服务时,通常会得到最后注册的实现;解析IEnumerable<T>时,会得到按注册顺序排列的所有实现。
这个规则在做策略模式、工厂模式、装饰器拼装等场景时非常有用。
1 | |
如果类型不应被长期注册,但又需要借助容器来补全依赖,可以试试 ActivatorUtilities,它可以用于创建未注册类型的对象,对于需要有限生命周期且不希望让容器持有其释放责任的对象,考虑工厂模式配合ActivatorUtilities.CreateInstance()在容器外部创建实例。
1 | |
开放泛型 (Open Generic) 是内置容器的另一特性,它允许注册和解析泛型类型,而不需要为每个具体类型都注册一次。
1 | |
如果你需要同接口多实现,按 Key 选择,可以考虑使用 Keyed Services。
1 | |
迁移与反模式
如果要迁移到 DI,先找出高层入口(如Program、Host)作为组合根 (Composition Root),再把最容易替换或最难测试的硬依赖提成抽象;随后把new从服务类、控制器、处理和后台任务里移出去,改成构造函数显式声明;最后再根据对象的业务边界标注生命周期。
对于 Service Locator 模式,和上面一样,主要把隐性依赖改成显式依赖,避免在服务中直接调用GetService()或GetRequiredService()。
同时,DI 存在很多反模式。
| 反模式 | 问题 | 解决方案 |
|---|---|---|
| Service Locator / 到处注入 IServiceProvider | 依赖隐藏、可读性差、测试模糊 | 优先构造注入;只在组合根、极少数工厂使用 provider |
| Singleton 捕获 Scoped | 典型 captive dependency,生命周期误提升 | 开启 validateScopes;重审依赖方向或改生命周期 |
| 从根容器解析 IDisposable 的 Transient/Scoped | 容器会跟踪这些对象到根释放,可能引发内存泄漏 | 用 CreateScope();或用工厂 + ActivatorUtilities 在容器外创建 |
| 配置服务时调用 BuildServiceProvider() | 容易导致重复单例、过早解析、配置流程混乱 | 在注册工厂重载中使用 IServiceProvider 参数,而不是提前 build |
| 异步 DI 工厂 / 在工厂里 .Result | 可能死锁 | 保持注册工厂快速、同步;异步工作放到服务方法里 |
| 常规中间件构造函数直接注入 Scoped | 会把 Scoped 强行变成 Singleton 语义并导致运行时异常 | 改为 InvokeAsync 方法注入,或使用工厂型中间件 |
| 构造函数参数过多 | 通常是 SRP 失衡的 code smell | 把职责拆小、按用例重组服务 |
结尾
之前说到 SOLID,DI 是其中 DIP 在 .NET 应用中的工程落地方案。把依赖显式放在构造函数上,把装配集中在组合根,再根据业务边界处理好生命周期,DI 自然地会提高代码的可测试性、可维护性和可扩展性。