2. .NET内置DI容器:IServiceCollection详解、ServiceDescriptor、服务生命周期

好,咱们进入第二个章节。上一章我们聊了DI的核心思想,说白了就是「我要什么,你给我什么」。那在.NET里,谁来干这个「给」的活儿?就是内置的DI容器。今天我就带你把它的底裤扒干净。

2.1 IServiceCollection:容器的「蓝图」

先问个问题:你见过哪个建筑工地是直接开干的?肯定得先有张图纸。IServiceCollection 就是这张图纸。它定义了你整个应用程序里,哪些接口对应哪些实现,以及这些实现该怎么活、活多久。

我个人习惯在 Program.cs 里这么写:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// 这里开始注册服务
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddTransient<IEmailService, EmailService>();
builder.Services.AddSingleton<ICacheProvider, MemoryCacheProvider>();

你看,builder.Services 就是一个 IServiceCollection 的实例。每次调用 AddScoped、AddTransient、AddSingleton,其实就是在往这张图纸上添加一条记录。每条记录就是一个 ServiceDescriptor。

核心理解: IServiceCollection 本质上就是一个 List<ServiceDescriptor>。你注册了多少服务,它就存了多少条描述信息。容器启动时,就根据这个列表来构建对象。

2.2 ServiceDescriptor:服务的「身份证」

ServiceDescriptor 是什么?它记录了三个关键信息:

  • 服务类型(ServiceType):你对外暴露的接口或抽象类,比如 IUserRepository。
  • 实现类型(ImplementationType):真正干活的类,比如 UserRepository。
  • 生命周期(Lifetime):Transient、Scoped、Singleton 三选一。

你可以手动创建 ServiceDescriptor,虽然平时不这么干,但理解它有助于你深入理解DI容器:

var descriptor = new ServiceDescriptor(
    typeof(IUserRepository),   // 服务类型
    typeof(UserRepository),    // 实现类型
    ServiceLifetime.Scoped     // 生命周期
);
builder.Services.Add(descriptor);

嗯,这里要注意:Add 方法其实可以接受一个 ServiceDescriptor 实例。你平时用的 AddScoped<T>() 底层也是这么干的。说白了,就是语法糖。

小技巧: 如果你需要批量注册服务,可以自己遍历程序集,生成 ServiceDescriptor 列表,然后一次性 Add 进去。我在项目中就写过这样的扩展方法,省了不少重复代码。

2.3 服务生命周期:Transient / Scoped / Singleton

这三个生命周期,是DI容器里最容易踩坑的地方。我一个个说。

2.3.1 Transient(瞬态)

每次请求都创建一个新实例。说白了就是「用完即弃」。适合轻量级、无状态的服务,比如日志记录器、工具类。

builder.Services.AddTransient<ILoggerService, LoggerService>();

我曾经在项目中犯过一个错:把一个数据库上下文注册成 Transient。结果每次请求都 new 一个新连接,性能直接崩了。后来改成 Scoped 才解决问题。你想想看,Transient 适合什么?适合那些不依赖外部资源、创建成本极低的服务。

2.3.2 Scoped(作用域)

在同一个作用域内(比如一个 HTTP 请求),只创建一个实例。这是最常用的生命周期,尤其适合数据库上下文(DbContext)。

builder.Services.AddScoped<IOrderService, OrderService>();

为什么?因为一个请求内,你可能多次用到同一个服务,如果每次都 new 一个,数据就乱了。Scoped 保证了请求内的单例。我记得有一次排查一个Bug,发现用户A的数据莫名其妙出现在用户B的请求里,查了半天,原来是某个服务被注册成了 Singleton,而它内部持有了一个请求级别的状态。嗯,这就是生命周期选错的典型后果。

2.3.3 Singleton(单例)

整个应用程序生命周期内,只创建一个实例。适合配置对象、缓存服务、日志记录器等全局唯一的服务。

builder.Services.AddSingleton<IConfiguration>(configuration);
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();

警告: Singleton 服务不能依赖 Scoped 或 Transient 服务。因为 Singleton 活得久,它持有的 Scoped 服务实例会一直存在,导致内存泄漏或数据错乱。我曾经见过一个项目,Singleton 的缓存服务里注入了 Scoped 的数据库上下文,结果上下文一直不被释放,连接池直接爆了。这个坑,我替你们踩过了。

2.4 生命周期对比表

生命周期 创建时机 实例数量 典型用途 注意事项
Transient 每次注入时 每次一个新实例 轻量级、无状态服务 创建成本高时慎用
Scoped 每个作用域一次 每个作用域一个实例 DbContext、请求级别服务 不要在Singleton中注入
Singleton 第一次请求时 全局唯一 配置、缓存、日志 注意线程安全

2.5 避坑指南:我踩过的三个坑

讲完理论,我分享几个真实教训:

  1. Singleton 里注入 Scoped:前面说了,会导致作用域泄漏。解决方案是用 IServiceScopeFactory 手动创建作用域。
  2. Transient 服务里持有大对象:每次请求都 new,内存压力大。我建议用对象池或者改成 Scoped。
  3. 忘记注册依赖链:如果 A 依赖 B,B 依赖 C,你必须把 A、B、C 都注册了。容器不会自动推断。我曾经漏注册了一个仓储层,结果运行时才报错,排查了半天。

总结一下: IServiceCollection 是蓝图,ServiceDescriptor 是每一条记录,生命周期决定了对象的存活策略。选对生命周期,你的应用才能跑得稳、跑得快。下一章我们聊聊如何用 Autofac 扩展这个内置容器,实现更灵活的模块化注册。

公众号:蓝海资料掘金营,微信deep3321