依赖注入 (Dependency Injection, DI) 把对象创建与使用职责分离。在 .NET 中,它与配置、日志和 Options 等一起成为框架级能力。

通常情况下,使用框架内置的依赖注入容器就足够了;如果需要更多功能,可以考虑使用第三方库。

从设计上,DI 是依赖倒置原则 (Dependency Inversion Principle, DIP) 最直接的实现方式,使高层依赖抽象而非具体实现,类把所需的依赖显式声明出来,而非内部new或去找全局实例。

背景

如果一个类在内部创建了它所依赖的对象,如new SqlOrderRepository(),那么它就把业务和装配实现耦合在了一起。这会导致:

  • 要替换实现必须修改调用者
  • 依赖的依赖会让配置代码分散
  • 难做隔离单元测试

Martin Fowler 提到了三种注入风格:构造、Setter 和接口注入。在 .NET 生态中,构造注入是最常用的方式。

为什么

既然 DI 将对象的配置和使用分离开来,那么它的好处也就显而易见了。

首先是可测试性。在单元测试中,我们可以用 Mock 对象替换掉真实的依赖,从而隔离被测对象。

其次是可维护性。Microsoft Learn 文档举了个例子:

1
2
3
4
5
6
7
public class MessageWriter : IMessageWriter
{
public void Write(string message)
{
Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
}
}

上述代码中,MessageWriter 类的 Write 方法可能被其他类依赖,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Worker : BackgroundService
{
private readonly MessageWriter _messageWriter = new();

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
await Task.Delay(1_000, stoppingToken);
}
}
}

由于 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
2
3
4
5
6
7
8
9
10
11
12
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddSingleton<IClock, SystemClock>();
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddTransient<OrderAppService>();

using var provider = services.BuildServiceProvider(validateScopes: true);

using var scope = provider.CreateScope();
var app = scope.ServiceProvider.GetRequiredService<OrderAppService>();

上面是一个简单的注册与解析示例,手动创建了ServiceCollection,注册了三个服务,调用BuildServiceProvider 创建了一个 ServiceProvider,然后创建了一个作用域,最后解析了 OrderAppService

同时,BuildServiceProvider 有一个可选参数 validateScopes,如果设置为 true,它会执行检查以验证作用域服务是否永远不会从根 Provider 解析。这样可以帮助开发者在开发阶段发现潜在的作用域问题。

生命周期

Transient 每次都是新对象;Scoped 在同一请求内相同、跨请求不同;Singleton 整个应用程序中相同。

同时,TransientScoped 会在所属作用域结束时释放,而 Singleton 会在应用程序结束时释放,绝不应该手动释放它们。

生命周期本质上是维护业务边界的。如果一个对象天然就应在一次请求、一条处理、一次作业执行之间共享,那么它就应该是 Scoped;如果它内部拥有共享可变状态,在保证线程安全的情况下,它就应该是 Singleton

这里能注意到,内置容器保证解析过程的线程安全,并不保证被解析出来的对象本身是线程安全的。

所以,从内存占用、作用域泄露、耦合度等角度考虑,不要滥用 Singleton

并不是写出AddSingleton就意味着它是由服务容器创建的服务了,对于builder.Services.AddSingleton(new ExampleService());,它是由你自己创建的对象,容器只是持有它的引用而已,不会自动释放,需要你自己在适当的时候释放它。

注入方式

在 .NET 内置容器中,构造注入是一等公民。它要求公开构造函数,并且当存在多个构造函数时,容器会选择参数最多的那个。

1
2
3
4
public sealed class OrdersController(IOrderService service)
{
public string Get() => "ok";
}

对于属性注入,内置容器不提供原生支持,但并不意味着整个生态没有这个注入方式。在 Razor 视图中,可以通过@inject把服务注入为属性:

1
2
@inject StatisticsService StatsService
<li>Total Items: @StatsService.GetCount()</li>

日常用法

IServiceCollection 作为注册入口,而 IServiceProvider 作为解析入口。对大多数应用来说,IServiceProvider 不应该在业务代码中到处使用;同时,Microsoft 也明确反对用GetService() 代替 DI,以及把 ApplicationServicesRequestServices 作为静态全局入口。

当你需要创建作用域时,应显式CreateScope()

1
2
using var scope = app.Services.CreateScope();
var dependency = scope.ServiceProvider.GetRequiredService<IMyDependency>();

如果有多个同接口实现,内置容器会遵循:解析单个服务时,通常会得到最后注册的实现;解析IEnumerable<T>时,会得到按注册顺序排列的所有实现。

这个规则在做策略模式、工厂模式、装饰器拼装等场景时非常有用。

1
2
3
4
5
6
7
8
services.AddScoped<INotifier, EmailNotifier>();
services.AddScoped<INotifier, SmsNotifier>();

public sealed class NotifierFactory(IEnumerable<INotifier> notifiers)
{
private readonly IReadOnlyDictionary<string, INotifier> _all =
notifiers.ToDictionary(x => x.Channel, StringComparer.OrdinalIgnoreCase);
}

如果类型不应被长期注册,但又需要借助容器来补全依赖,可以试试 ActivatorUtilities,它可以用于创建未注册类型的对象,对于需要有限生命周期且不希望让容器持有其释放责任的对象,考虑工厂模式配合ActivatorUtilities.CreateInstance()在容器外部创建实例。

1
2
3
4
5
6
public sealed class ReportJob(IClock clock, string reportName)
{
public void Run() => Console.WriteLine($"{reportName} @ {clock.UtcNow:O}");
}

var job = ActivatorUtilities.CreateInstance<ReportJob>(provider, "weekly-sales");

开放泛型 (Open Generic) 是内置容器的另一特性,它允许注册和解析泛型类型,而不需要为每个具体类型都注册一次。

1
services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));

如果你需要同接口多实现,按 Key 选择,可以考虑使用 Keyed Services。

1
2
3
4
5
builder.Services.AddKeyedSingleton<IStringCache, StringCache1>("cache1");
builder.Services.AddKeyedSingleton<IStringCache, StringCache2>("cache2");

app.MapGet("/cache1", ([FromKeyedServices("cache1")] IStringCache cache) =>
cache.Get(1));

迁移与反模式

如果要迁移到 DI,先找出高层入口(如ProgramHost)作为组合根 (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 自然地会提高代码的可测试性、可维护性和可扩展性。