3、服务注册与解析:AddTransient/AddScoped/AddSingleton方法、TryAdd模式、服务工厂模式
好,咱们进入第三章。这一章我打算聊聊服务注册和解析的那些事。说实话,依赖注入的核心就两个动作:注册和解析。注册没做好,解析就会出幺蛾子。我在好几个项目里都吃过这个亏,所以今天把压箱底的经验都掏出来。
3.1 三种生命周期:Transient、Scoped、Singleton
先说说最基础的东西。.NET 内置的 DI 容器提供了三种生命周期,说白了就是三种“活法”。
| 生命周期 | 方法 | 实例创建时机 | 存活范围 |
|---|---|---|---|
| 瞬态 (Transient) | AddTransient | 每次请求都创建 | 当前请求结束即销毁 |
| 作用域 (Scoped) | AddScoped | 每个作用域创建一次 | 作用域生命周期内 |
| 单例 (Singleton) | AddSingleton | 首次请求时创建 | 应用程序生命周期 |
AddTransient:每次从容器里要,都给你一个新的。适合轻量级、无状态的服务。比如日志记录器、工具类。我有个项目把数据库上下文配成了 Transient,结果每个请求开了几十个连接,数据库直接报警。嗯,这个坑我踩过。
AddScoped:在同一个请求(作用域)内,只创建一次。Web 应用里最常用的就是它。比如 EF Core 的 DbContext,天然就该是 Scoped。你想想看,一个请求里多次用到同一个仓储,如果每次都是新实例,事务就没法控制了。
AddSingleton:整个应用就一个实例,大家共享。适合配置对象、缓存服务、日志记录器等。但要注意线程安全。我记得有一次把某个有状态的 Service 配成了 Singleton,结果高并发下数据全乱了。从那以后,我只要看到 Singleton,第一反应就是:这玩意儿线程安全吗?
核心原则:如果你的服务依赖 Scoped 服务,那它自己就不能是 Singleton。否则会出现“生命周期捕获”问题——Singleton 里持有的 Scoped 实例永远不会释放。
3.2 TryAdd 模式:避免重复注册
实际项目中,我们经常要写扩展方法来批量注册服务。这时候问题就来了:如果别人调了你的扩展方法两次,服务就被注册了两次。后注册的会覆盖前面的,但有时候这不是我们想要的。
TryAdd 就是干这个用的。它只在服务尚未注册时才执行注册。说白了就是“有就不动,没有才加”。
// 普通注册:每次都会执行,后覆盖前
services.AddSingleton<IUserService, UserService>();
services.AddSingleton<IUserService, UserService>(); // 第二次覆盖第一次
// TryAdd 注册:只有未注册时才生效
services.TryAddSingleton<IUserService, UserService>();
services.TryAddSingleton<IUserService, UserService>(); // 第二次被忽略
我个人习惯在编写模块化框架时,所有默认注册都用 TryAdd。这样使用者可以自由替换实现,而不会被框架的默认注册覆盖掉。我曾经在一个微服务项目里,因为没注意注册顺序,导致自定义的日志实现被框架默认实现覆盖了,排查了整整一个下午。从那以后,TryAdd 就成了我的标配。
小技巧:TryAdd 有四个变体:TryAddTransient、TryAddScoped、TryAddSingleton 和 TryAdd(自动推断)。建议优先使用带生命周期后缀的方法,代码意图更清晰。
3.3 服务工厂模式:动态创建服务
有时候,服务的创建不是简单的 new 一下就能搞定的。比如需要根据配置文件决定用哪个实现,或者需要传入运行时参数。这时候服务工厂模式就派上用场了。
服务工厂本质上是一个委托:IServiceProvider => TService。你可以在工厂方法里做任何逻辑处理。
// 基础用法:根据配置动态选择实现
services.AddScoped<IPaymentService>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var provider = config["PaymentProvider"];
return provider switch
{
"Alipay" => new AlipayService(),
"WeChat" => new WeChatPayService(),
_ => new DefaultPaymentService()
};
});
// 带参数的工厂
services.AddTransient<IReportGenerator>(sp =>
{
var templateId = sp.GetRequiredService<IHttpContextAccessor>()
.HttpContext?.Request.Headers["TemplateId"].FirstOrDefault()
?? "default";
return new ReportGenerator(templateId);
});
你可能会问:为什么不直接用条件判断?因为工厂模式把创建逻辑集中管理了,而且可以访问容器里的其他服务。我有个项目需要根据租户 ID 动态切换数据库连接,就是用工厂模式实现的。每个请求进来,工厂从容器里拿到租户上下文,然后创建对应的 DbContext。代码干净,扩展也方便。
注意:工厂方法里不要做耗时操作,比如远程调用、文件读写。因为服务解析是同步的,会阻塞请求线程。如果确实需要异步初始化,可以考虑用 Lazy<T> 或者专门的初始化服务。
3.4 综合示例:一个模块的注册策略
最后,我给大家看一个实际项目中常用的注册模式。这个模式把 TryAdd、工厂模式和生命周期选择都揉在一起了。
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddOrderModule(
this IServiceCollection services,
IConfiguration configuration)
{
// 1. 基础设施:单例,全局共享
services.TryAddSingleton<IOrderIdGenerator, SnowflakeIdGenerator>();
// 2. 仓储层:作用域,每个请求一个
services.TryAddScoped<IOrderRepository, OrderRepository>();
// 3. 应用服务:作用域,依赖仓储
services.TryAddScoped<IOrderService, OrderService>();
// 4. 策略服务:工厂模式,根据配置动态选择
services.TryAddScoped<IDiscountStrategy>(sp =>
{
var mode = configuration["Discount:Mode"];
return mode switch
{
"Percentage" => new PercentageDiscountStrategy(),
"Fixed" => new FixedDiscountStrategy(),
_ => new NoDiscountStrategy()
};
});
// 5. 后台任务:瞬态,每次执行都新建
services.TryAddTransient<IOrderExpirationHandler, OrderExpirationHandler>();
return services;
}
}
这个模式的好处是:使用者可以自由覆盖任何服务,而不会影响其他模块。每个模块自己管好自己的注册,互不干扰。说白了,这就是模块化架构在 DI 层面的落地。
好了,这一章就到这里。下一章我会聊聊服务解析的细节,包括如何从容器里拿服务、如何避免常见的解析陷阱。到时候见。