3. 依赖注入与生命周期

在 ASP.NET Core 中,依赖注入(Dependency Injection,DI)不是可选项,而是框架内建的默认编程模型。控制器、中间件、过滤器、后台服务、甚至框架自身的很多组件,都通过构造函数声明自己需要什么,再由容器在运行时把实例“送进来”。理解 DI 的关键,不是记住几个注册方法,而是理解服务注册、生命周期、解析时机这三件事如何共同决定对象被创建几次、在哪里被创建、以及是否可能被错误地长期持有。

一、IServiceCollection:注册的入口

应用启动时,WebApplicationBuilder 会暴露一个 builder.Services,它的类型就是 IServiceCollection。这个集合本质上是一张“服务描述表”:每一项记录服务类型(通常是接口)、实现类型、生命周期,以及可选的工厂委托。容器在解析时按这张表查找并构造对象。

最常见的注册方式是泛型扩展方法:

builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();

当构造过程需要参数、或需要根据配置选择实现时,可以使用工厂委托:

builder.Services.AddSingleton<ICache>(sp =>
    new RedisCache(builder.Configuration["Redis:ConnectionString"]!));

还可以注册“自身类型”,即服务类型与实现类型相同,例如 AddScoped<OrderService>()。此外,AddSingleton<TService>(instance) 可以直接注册一个已创建好的实例,适合无状态、线程安全的工具对象。

二、三种生命周期:Singleton、Scoped、Transient

生命周期决定实例的复用范围,是 DI 中最容易出错的部分。可以用一张关系图来理解三者的作用域差异:

服务生命周期作用域对比 Singleton 整个应用一个实例 请求 A 请求 B 请求 C 请求 D 1 个 配置、缓存、无状态工具 Scoped 每个请求一个实例 请求 A 请求 B 请求 C 请求 D N 个 DbContext、请求级业务服务 Transient 每次解析都新建 请求 A 请求 B 请求 C 请求 D N×M 轻量、无状态、短命对象

三者的适用场景可以这样判断:

  • Singleton:应用级共享、无状态或线程安全。例如配置读取器、内存缓存、IHttpClientFactory 生成的客户端、日志器。注意:单例中不能直接注入 Scoped 服务,否则会形成“捕获依赖”。
  • Scoped:与一次 HTTP 请求绑定。最典型的是 EF Core 的 DbContext,以及依赖它的仓储、工作单元、当前用户上下文。同一个请求内多次解析得到同一实例,请求结束即释放。
  • Transient:每次解析都创建新实例。适合轻量、无状态、创建成本低的辅助对象,如格式化器、验证器、短生命周期的计算服务。若被 Singleton 反复解析,可能造成对象堆积。

三、构造函数注入与 IServiceProvider

首选方式是构造函数注入:把依赖声明为构造参数,容器自动解析。它让依赖关系显式、可测试,也避免在方法内部到处取服务。

public class OrderService
{
    private readonly IOrderRepository _repo;
    private readonly IClock _clock;

    public OrderService(IOrderRepository repo, IClock clock)
    {
        _repo = repo;
        _clock = clock;
    }
}

当确实需要动态解析时,可以注入 IServiceProvider,使用 GetRequiredService<T>() 或 GetService<T>()。前者在找不到服务时抛异常,后者返回 null。但要注意:不要把它当作服务定位器到处使用,否则依赖关系被隐藏,测试和排错都会变难。它更适合工厂、策略选择、或框架回调中无法使用构造函数注入的场景。

四、在控制器与中间件中解析服务

控制器天然支持构造函数注入,框架在激活控制器时完成解析:

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly OrderService _service;
    public OrdersController(OrderService service) => _service = service;

    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
        => Ok(await _service.GetAsync(id));
}

中间件的情况不同。中间件在应用启动时构造一次,属于 Singleton 语义,因此不能在构造函数中注入 Scoped 服务。正确做法是在 InvokeAsync 方法参数中注入,或从 HttpContext.RequestServices 解析:

public class AuditMiddleware
{
    private readonly RequestDelegate _next;
    public AuditMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext ctx, IAuditWriter writer)
    {
        await writer.WriteAsync(ctx.Request.Path);
        await _next(ctx);
    }
}

这里 IAuditWriter 若是 Scoped,会在每个请求的中间件调用时由容器解析,作用域正确。若写成构造函数参数,则会在应用启动时解析一次,导致 Scoped 被提升为 Singleton,产生“捕获依赖”。

五、避免捕获依赖与内存泄漏

“捕获依赖”(Captive Dependency)指生命周期短的服务被生命周期长的服务持有。典型错误是 Singleton 构造函数注入 Scoped:

// 错误示例:Singleton 捕获 Scoped
public class CacheWarmer
{
    private readonly AppDbContext _db; // Scoped
    public CacheWarmer(AppDbContext db) => _db = db;
}
builder.Services.AddSingleton<CacheWarmer>();

ASP.NET Core 在开发环境下会抛出 InvalidOperationException,提示 “Cannot consume scoped service from singleton”。即使未抛异常,也会导致 DbContext 被长期持有,连接无法释放,最终引发内存泄漏或并发异常。

避免策略有三条:

  1. 让依赖方向与生命周期一致:Singleton 只依赖 Singleton,Scoped 可依赖 Scoped 或 Singleton,Transient 可依赖任意。
  2. 确实需要跨作用域时,使用 IServiceScopeFactory 创建显式作用域,用完即释放。
  3. 对实现 IDisposable 的服务,容器会按生命周期负责释放;但若被错误捕获,释放时机被推迟,等于泄漏。
public class CacheWarmer
{
    private readonly IServiceScopeFactory _scopeFactory;
    public CacheWarmer(IServiceScopeFactory f) => _scopeFactory = f;

    public async Task WarmAsync()
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // 使用 db,离开 using 后作用域释放
    }
}
判断生命周期的实用口诀:无状态共享用 Singleton,请求内一致用 Scoped,每次新建用 Transient。注册后可用 builder.Services.BuildServiceProvider(validateScopes: true) 在开发环境提前暴露捕获依赖问题。
常见误区:把 DbContext 注册为 Singleton 或 Transient。Singleton 会导致并发冲突和状态污染;Transient 会让每个服务各持一个上下文,事务和跟踪失效。EF Core 场景下应保持 Scoped。

最后,注册顺序不影响解析结果,容器按服务类型查找;但同一服务类型重复注册时,GetRequiredService<T>() 返回最后注册的实现,而 IEnumerable<T> 会返回全部注册项。理解这一点,有助于在插件式或策略模式中正确组织服务集合。