大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
依赖注入与生命周期(对照 Spring IoC)
这一课你将能做到
- 用三种生命周期(Singleton/Scoped/Transient)正确注册判题平台的服务并说明各自的存活范围
- 处理多实现注入:
IEnumerable<T>策略注入与 .NET 8 的 Keyed Services - 识别并修复「Singleton 捕获 Scoped」这个最高频的 DI 生产事故
为什么要学
ASP.NET Core 内置了完整 IoC 容器,从 Spring 迁移来最大的不适应是:没有注解扫描。@Service 一贴就完事的习惯在这里行不通,每个服务要在 Program.cs 显式注册一行,换来的是装配关系完全透明、启动更快。
人话:DI 容器就是一个「对象工厂登记处」。你登记「接口 → 实现」和「它的寿命」,框架在需要时自动造出来、递归造好它的依赖、并在寿命到期时销毁。Spring 的 ApplicationContext 与 IServiceProvider 做的是同一件事,只是登记方式一个靠注解、一个靠代码。
先建立图像
三种生命周期的存活范围(判断题平台视角):
Singleton ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 判题引擎/Redis连接 整个进程一个
Scoped ━━━┫请求1┣━━━ ━━━┫请求2┣━━━ ━━━┫请求3┣━ DbContext/事务 每请求一个
Transient ┈┈┋┋┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ 轻量无状态对象 谁要谁new
Spring 对照表:
| Spring | ASP.NET Core | 说明 |
|---|---|---|
@Component/@Service | builder.Services.AddScoped<IFoo, Foo>() | 扫描注册 vs 显式注册 |
@Autowired 构造注入 | 构造函数参数 | 两者都推荐构造注入 |
@Scope("singleton") | AddSingleton | 默认就是单例 |
@Scope("prototype") | AddTransient | 语义基本对齐 |
| request/session scope | AddScoped(.NET 里以请求为单位) | 无 session 概念 |
@Qualifier("name") | [FromKeyedServices("name")](.NET 8) | 具名多实现 |
ApplicationContext.getBean() | IServiceProvider.GetService() | 服务定位器兜底 |
跟着做
步骤 1:接口抽象 + 显式注册 + 构造注入
public interface IJudgeService
{
Task<JudgeResult> JudgeAsync(Submission submission, CancellationToken ct);
}
public class SandboxJudgeService(
HttpClient http,
ILogger<SandboxJudgeService> logger) : IJudgeService
{
public async Task<JudgeResult> JudgeAsync(Submission s, CancellationToken ct)
{
logger.LogInformation("提交 {Id} 进入沙箱", s.Id);
// 调用沙箱……
return new JudgeResult(s.Id, "AC", 120, 8192);
}
}
// Program.cs 注册
builder.Services.AddScoped<IJudgeService, SandboxJudgeService>();
Minimal API 与 Controller 都支持构造注入和参数注入:
app.MapPost("/api/submissions", async (SubmitDto dto, IJudgeService judge) =>
Results.Ok(await judge.JudgeAsync(dto.ToSubmission(), default)));
步骤 2:亲眼验证三种生命周期
public interface IInstanceId { string Id { get; } }
public class SingletonSvc : IInstanceId { public string Id { get; } = Guid.NewGuid().ToString("N")[..8]; }
public class ScopedSvc : IInstanceId { public string Id { get; } = Guid.NewGuid().ToString("N")[..8]; }
public class TransientSvc : IInstanceId { public string Id { get; } = Guid.NewGuid().ToString("N")[..8]; }
builder.Services.AddSingleton<IInstanceId, SingletonSvc>();
builder.Services.AddScoped<IInstanceId, ScopedSvc>();
builder.Services.AddTransient<IInstanceId, TransientSvc>();
app.MapGet("/di-demo", (IInstanceId s1, IInstanceId s2) =>
Results.Ok(new { singleton = s1.Id, scoped = s2.Id, transient1 = s1 is not ScopedSvc ? "见下" : "" }));
// 连续刷新页面:Singleton 的 Id 永不变;Scoped 的 Id 每个请求变;Transient 同一请求内解析两次也各不相同
步骤 3:多实现——策略注入与 Keyed Services
判题平台按语言分发判题器,是策略模式的教科书场景:
public interface ILanguageJudge { string Language { get; } Task<string> RunAsync(string code); }
public class CppJudge : ILanguageJudge { public string Language => "cpp"; /* ... */ public Task<string> RunAsync(string code) => Task.FromResult("WA"); }
public class JavaJudge : ILanguageJudge { public string Language => "java"; /* ... */ public Task<string> RunAsync(string code) => Task.FromResult("TLE"); }
builder.Services.AddScoped<ILanguageJudge, CppJudge>();
builder.Services.AddScoped<ILanguageJudge, JavaJudge>();
// 注入 IEnumerable<T> 拿到全部实现(Spring 注入 List<ILanguageJudge> 同款)
public class JudgeDispatcher(IEnumerable<ILanguageJudge> judges)
{
public Task<string> DispatchAsync(string lang, string code) =>
judges.FirstOrDefault(j => j.Language == lang)?.RunAsync(code)
?? throw new NotSupportedException($"语言 {lang} 未支持");
}
// .NET 8 具名注册:按 Key 精确取用(对应 @Qualifier)
builder.Services.AddKeyedScoped<ILanguageJudge, CppJudge>("cpp");
app.MapGet("/judge/cpp", ([FromKeyedServices("cpp")] ILanguageJudge j) => j.RunAsync("int main(){}"));
步骤 4:Options 模式(配置即服务)
builder.Services.Configure<JudgeOptions>(builder.Configuration.GetSection("Judge"));
public class JudgeDispatcher(IEnumerable<ILanguageJudge> judges, IOptions<JudgeOptions> opts)
{
public int Timeout => opts.Value.TimeoutSeconds; // 强类型配置
}
区别在于刷新语义:IOptions<T> 启动时定死(Singleton 安全);IOptionsSnapshot<T> 每请求刷新(Scoped);IOptionsMonitor<T> 变更即通知(Singleton 里要热更新用这个)。
步骤 5:需要复杂构造时用工厂注册
builder.Services.AddSingleton<IRedisCache>(sp =>
{
var conn = sp.GetRequiredService<IConfiguration>()["Redis:Connection"]!;
return new RedisCache(ConnectionMultiplexer.Connect(conn));
});
工厂 lambda 的 sp 参数就是容器本身,可在工厂内解析其它服务——等价 Spring 的 @Bean 方法。
步骤 6:打开生命周期校验,提前抓事故
builder.Host.UseDefaultServiceProvider((ctx, opt) =>
{
opt.ValidateScopes = true; // 开发环境默认已开
opt.ValidateOnBuild = true; // 启动时检查全部注册可解析
});
讲清楚要点
- 捕获依赖(Captive Dependency)是第一大坑:Singleton 在启动时创建,若它构造函数要了个 Scoped(如 DbContext),这个 Scoped 就被「扣留」成事实上的 Singleton,跨请求复用引发线程安全与脏数据。表现:偶发
DbContext并发异常。修法:Singleton 里改用IServiceScopeFactory临时开 scope,或把依赖改成工厂。 - 释放由容器负责:容器创建的
IDisposable随生命周期终结自动释放;Scoped 对象在请求结束时释放,等价 Spring 的@PreDestroy。自己new的对象容器不管。 - 注册是追加不是覆盖:同接口多次
AddScoped是多实现并存;需要覆盖语义时先Remove再Add,或用ReplaceAll(Scrutor 扩展)。 - 服务定位器是逃生舱不是日常:中间件、后台服务才用
HttpContext.RequestServices/IServiceScopeFactory;能构造注入就别用定位器(与 Spring 对getBean()的态度一致)。
你可能会懵的地方
-
为什么没有注解扫描?写起来不累吗? 第三方 Scrutor 可按约定批量注册,效果等同
@ComponentScan。官方不内置是因为显式注册可读、可调试、启动无扫描开销,装配点集中一处利大于弊。 -
Scoped 在没有 HTTP 请求的后台服务里怎么活? 用
IServiceScopeFactory.CreateScope()手动开作用域,从scope.ServiceProvider解析 DbContext 等 Scoped 服务,用完释放。这是写BackgroundService的标准姿势。 -
Transient 注入到 Singleton 会出现什么? 被 Singleton 捕获,退化成 Singleton。Spring 里有
ObjectProvider/@Lookup解决同类问题,.NET 侧对应Func<T>工厂注册或IServiceScopeFactory。 -
构造函数能注入具体类吗? 能,只要注册过(
AddScoped<Foo>()自注册)。但工程惯例面向接口:测试时可替换实现。 -
[FromServices]和构造注入选哪个? Minimal API 的处理方法参数默认按 DI 解析(无需特性);[FromServices]用于消除与模型绑定的歧义。Controller 一律构造注入;方法级注入只在 Minimal API 常用。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| Singleton 构造函数注入 Scoped 服务(如 DbContext) | 捕获依赖:实例跨请求共享,并发崩溃 | 用 IServiceScopeFactory 按需开 scope,或降级为 Scoped |
每个服务注册忘加接口(AddScoped<Foo>() 又想按 IFoo 取) | 解析失败 | AddScoped<IFoo, Foo>();两种都要就再补一条自注册 |
在 Program.cs 顶部 new 一个服务塞进容器 | 绕过容器,其依赖没人管 | 注册类型或工厂,让容器负责装配与释放 |
用 sp.GetService<T>() 满地取服务 | 服务定位器反模式,依赖不可见 | 构造注入为主,定位器只留给中间件/后台任务 |
| Scoped 服务存「当前用户」跨中间件手传 | 同一请求内直接解析即可拿到同实例 | Scoped 即请求内单例,各层自行构造注入 |
卡点急救
- 启动报错「Cannot consume scoped service from singleton」:就是捕获依赖被校验拦下了。把 Singleton 依赖的 Scoped 改成
IServiceScopeFactory,或把类本身降到 Scoped。 - 后台任务里用 DbContext 报「A second operation was started」:DbContext 被并发使用。每轮循环
CreateScope()新开上下文,不要复用。 - 两个实现都注册了,构造注入
ILanguageJudge报多候选异常:直接注入接口时容器无法二选一,改注入IEnumerable<ILanguageJudge>或用 Keyed Services 指定 key。
面试常问
-
三种生命周期各自的应用场景与误用后果? Singleton:无状态或自身线程安全的基础设施(Redis 连接、判题引擎配置);误存请求态会串数据。Scoped:请求级共享(DbContext、用户上下文、事务单元);在 Singleton 中使用即捕获依赖。Transient:轻量无状态对象;注意被长寿命对象捕获后失去瞬态语义。
-
.NET 8 Keyed Services 解决什么问题?和 Spring 怎么对应? 同接口多实现按 key 解析,替代「注入集合再过滤」的样板。注册
AddKeyedScoped<IFoo, A>("a"),消费[FromKeyedServices("a")] IFoo。对应 Spring 的@Qualifier/@Resource(name)。 -
如何验证 DI 配置的正确性?
ValidateOnBuild启动时检查全部注册可解析,ValidateScopes拦截「Singleton 消费 Scoped」,在部署前暴露装配错误——对应 Spring 容器启动时的依赖检查。
自测三问
- 为什么 DbContext 是 Scoped 而判题引擎是 Singleton?
- Singleton 想临时用 DbContext,正确姿势是什么?
IOptions、IOptionsSnapshot、IOptionsMonitor三者的刷新时机?
小结一句话
ASP.NET Core 的 DI = Spring IoC 的同构物 + 显式注册哲学:记住三种寿命、永远提防 Singleton 捕获 Scoped,多实现交给集合注入或 .NET 8 Keyed Services。
延伸
- 官方文档:ASP.NET Core 依赖注入
- 下一课:构建 REST API——Minimal API 与 Controller 双形态、模型绑定与校验、全局异常处理。