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 避坑指南:我踩过的三个坑
讲完理论,我分享几个真实教训:
- Singleton 里注入 Scoped:前面说了,会导致作用域泄漏。解决方案是用 IServiceScopeFactory 手动创建作用域。
- Transient 服务里持有大对象:每次请求都 new,内存压力大。我建议用对象池或者改成 Scoped。
- 忘记注册依赖链:如果 A 依赖 B,B 依赖 C,你必须把 A、B、C 都注册了。容器不会自动推断。我曾经漏注册了一个仓储层,结果运行时才报错,排查了半天。
总结一下: IServiceCollection 是蓝图,ServiceDescriptor 是每一条记录,生命周期决定了对象的存活策略。选对生命周期,你的应用才能跑得稳、跑得快。下一章我们聊聊如何用 Autofac 扩展这个内置容器,实现更灵活的模块化注册。
公众号:蓝海资料掘金营,微信deep3321