CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
ASP.NET Core 实战入门/课时/依赖注入
RawBlame
课时目录12 节

ASP.NET Core 实战入门

  1. 01ASP.NET Core 基础
  2. 02C# 与 .NET 基础
  3. 03Entity Framework Core
  4. 04依赖注入
  5. 05REST / GraphQL / gRPC
  6. 06数据存储与缓存
  7. 07SignalR 实时通信
  8. 08测试与 E2E
  9. 09消息队列
  10. 10微服务与网关
  11. 11日志、任务与工具链
  12. 12综合练习

依赖注入

Scoped/Singleton、Autofac 与 Scrutor。

30 分钟

依赖注入与生命周期(对照 Spring IoC)

这一课你将能做到

  1. 用三种生命周期(Singleton/Scoped/Transient)正确注册判题平台的服务并说明各自的存活范围
  2. 处理多实现注入:IEnumerable<T> 策略注入与 .NET 8 的 Keyed Services
  3. 识别并修复「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 对照表:

SpringASP.NET Core说明
@Component/@Servicebuilder.Services.AddScoped<IFoo, Foo>()扫描注册 vs 显式注册
@Autowired 构造注入构造函数参数两者都推荐构造注入
@Scope("singleton")AddSingleton默认就是单例
@Scope("prototype")AddTransient语义基本对齐
request/session scopeAddScoped(.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;     // 启动时检查全部注册可解析
});

讲清楚要点

  1. 捕获依赖(Captive Dependency)是第一大坑:Singleton 在启动时创建,若它构造函数要了个 Scoped(如 DbContext),这个 Scoped 就被「扣留」成事实上的 Singleton,跨请求复用引发线程安全与脏数据。表现:偶发 DbContext 并发异常。修法:Singleton 里改用 IServiceScopeFactory 临时开 scope,或把依赖改成工厂。
  2. 释放由容器负责:容器创建的 IDisposable 随生命周期终结自动释放;Scoped 对象在请求结束时释放,等价 Spring 的 @PreDestroy。自己 new 的对象容器不管。
  3. 注册是追加不是覆盖:同接口多次 AddScoped 是多实现并存;需要覆盖语义时先 Remove 再 Add,或用 ReplaceAll(Scrutor 扩展)。
  4. 服务定位器是逃生舱不是日常:中间件、后台服务才用 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 即请求内单例,各层自行构造注入

卡点急救

  1. 启动报错「Cannot consume scoped service from singleton」:就是捕获依赖被校验拦下了。把 Singleton 依赖的 Scoped 改成 IServiceScopeFactory,或把类本身降到 Scoped。
  2. 后台任务里用 DbContext 报「A second operation was started」:DbContext 被并发使用。每轮循环 CreateScope() 新开上下文,不要复用。
  3. 两个实现都注册了,构造注入 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 容器启动时的依赖检查。

自测三问

  1. 为什么 DbContext 是 Scoped 而判题引擎是 Singleton?
  2. Singleton 想临时用 DbContext,正确姿势是什么?
  3. IOptions、IOptionsSnapshot、IOptionsMonitor 三者的刷新时机?

小结一句话

ASP.NET Core 的 DI = Spring IoC 的同构物 + 显式注册哲学:记住三种寿命、永远提防 Singleton 捕获 Scoped,多实现交给集合注入或 .NET 8 Keyed Services。

延伸

  • 官方文档:ASP.NET Core 依赖注入
  • 下一课:构建 REST API——Minimal API 与 Controller 双形态、模型绑定与校验、全局异常处理。
加载本节练习…
← 上一课Entity Framework Core下一课 →REST / GraphQL / gRPC