什么是依赖注入
依赖注入(Dependency Injection,简称 DI)是 .NET 8 内置的核心机制之一,它帮助我们解耦代码、方便单元测试。在注册服务时,最重要的一个决定就是选择服务的生命周期。生命周期决定了容器每次请求服务时,是返回新实例还是复用已有实例。
.NET 8 提供了三种服务生命周期:Transient(瞬时)、Scoped(作用域)、Singleton(单例)。
三种生命周期详解
Transient 瞬时
每次请求都会创建一个全新的实例,永远不会共享。适用于轻量、无状态的服务。
// 定义一个瞬时服务
public interface IGuidService
{
Guid GetGuid();
}
public class TransientGuidService : IGuidService
{
private readonly Guid _guid = Guid.NewGuid(); // 每个实例都有唯一标识
public Guid GetGuid() => _guid;
}
// 注册为瞬时
builder.Services.AddTransient<IGuidService, TransientGuidService>();
特点: - 每次获取都是新对象 - 不会跨请求共享状态 - 适合轻量级、无状态服务
Scoped 作用域
在同一个作用域(Scope)内,多次请求会返回同一个实例;不同作用域则返回不同实例。在 Web 应用中,每次 HTTP 请求就是一个作用域。
public class ScopedGuidService : IGuidService
{
private readonly Guid _guid = Guid.NewGuid();
public Guid GetGuid() => _guid;
}
// 注册为作用域
builder.Services.AddScoped<IGuidService, ScopedGuidService>();
特点: - 同一请求内共享实例 - 不同请求之间隔离 - 适合 EF Core 的 DbContext、仓储等需要事务一致性的服务
Singleton 单例
整个应用程序生命周期内只创建一个实例,所有请求共享。
public class SingletonGuidService : IGuidService
{
private readonly Guid _guid = Guid.NewGuid();
public Guid GetGuid() => _guid;
}
// 注册为单例
builder.Services.AddSingleton<IGuidService, SingletonGuidService>();
特点: - 全局唯一实例 - 所有请求共享 - 适合配置类、缓存类、线程安全的共享服务
一段对比示例
// 同时注册三种服务,便于对比
builder.Services.AddTransient<TransientOperation>();
builder.Services.AddScoped<ScopedOperation>();
builder.Services.AddSingleton<SingletonOperation>();
app.MapGet("/test", (TransientOperation t1, TransientOperation t2,
ScopedOperation s1, ScopedOperation s2,
SingletonOperation n1, SingletonOperation n2) =>
{
return new
{
// 两次获取 Transient,Guid 不同
Transient = new { First = t1.Id, Second = t2.Id, Same = t1.Id == t2.Id },
// 同一请求内 Scoped,Guid 相同
Scoped = new { First = s1.Id, Second = s2.Id, Same = s1.Id == s2.Id },
// 单例永远相同
Singleton = new { First = n1.Id, Second = n2.Id, Same = n1.Id == n2.Id }
};
});
生命周期冲突问题
并不是所有依赖关系都合法。一个基本原则是:长生命周期的服务不能依赖短生命周期的服务。
| 服务的生命周期 | 可以依赖 |
|---|---|
| Singleton | 只能依赖 Singleton |
| Scoped | 可以依赖 Singleton、Scoped |
| Transient | 可以依赖任意 |
常见错误:让 Singleton 依赖 Scoped。
// ❌ 错误:单例依赖作用域服务
public class MySingleton
{
public MySingleton(MyScoped scoped) { } // 会导致 captived dependency
}
builder.Services.AddSingleton<MySingleton>();
builder.Services.AddScoped<MyScoped>(); // 这里的 Scoped 会被"囚禁"在 Singleton 中
这会抛出 InvalidOperationException。即使使用 ValidateScopes = false 绕过检查,Scoped 服务也会被”囚禁”在 Singleton 中,导致原本应该每次请求都不同的实例变成全局共享,引发难以排查的 bug。
解决方法:调整生命周期,让依赖方比被依赖方”活得短或一样长”。
多线程下 Singleton 的注意事项
Singleton 是全局共享的,因此必须考虑线程安全。如果多个请求同时修改单例中的状态,就会出现竞态条件。
// ❌ 非线程安全的计数器
public class CounterService
{
private int _count = 0;
public int Increment() => ++_count; // 多线程下数据会错乱
}
// ✅ 使用 Interlocked 保证原子操作
public class SafeCounterService
{
private int _count = 0;
public int Increment() => Interlocked.Increment(ref _count); // 线程安全
}
// ✅ 或者使用锁
public class LockCounterService
{
private readonly object _lock = new();
private int _count = 0;
public int Increment()
{
lock (_lock) { return ++_count; }
}
}
要点: - 单例中的可变状态必须做好同步 - 优先使用 ConcurrentDictionary、Interlocked 等并发原语 - 能用不可变对象就用不可变对象,从根上避免问题
IServiceScopeFactory 手动创建作用域
在 Singleton 或后台服务中,我们经常需要使用 Scoped 服务(比如 DbContext)。这时不能直接注入,而要通过 IServiceScopeFactory 手动创建作用域。
public class BackgroundWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public BackgroundWorker(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory; // 注入工厂而不是直接注入 Scoped 服务
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// 手动创建作用域
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
// 在此作用域内使用 Scoped 服务
await db.SaveChangesAsync();
} // 作用域结束,Scoped 服务会被释放
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
要点: - using 包裹 scope,确保释放资源 - 不要将 Scoped 服务缓存到 Singleton 字段中 - 每次”业务单元”开始时创建 scope,结束时释放
最佳实践建议
- 默认选 Scoped:在 Web 应用中,Scoped 是最稳妥的选择,既有请求内的一致性,又不会全局共享。
- 轻量无状态用 Transient:如果服务很小且无状态,Transient 也很合适。
- 慎用 Singleton:只有真正需要全局共享且线程安全的服务才用 Singleton。
- 开发期开启校验:在
Program.cs中使用ValidateScopes = true(开发环境默认开启),提前发现囚禁依赖。 - 后台任务用 IServiceScopeFactory:需要 Scoped 服务时,务必手动创建作用域。
- 单例避免可变状态:实在要存状态,务必保证线程安全。
掌握这三种生命周期,写出健壮的 .NET 8 应用就更有底气了。