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 层面的落地。

好了,这一章就到这里。下一章我会聊聊服务解析的细节,包括如何从容器里拿服务、如何避免常见的解析陷阱。到时候见。