4、依赖注入与配置:内置DI容器、服务生命周期、IOptions模式与配置文件读取

依赖注入(DI)和配置管理,是 .NET 云原生应用的基石。说白了,它们解决的是「对象怎么创建」和「配置怎么读取」这两个最基础的问题。我刚开始接触 .NET Core 时,也被各种生命周期搞得晕头转向。今天咱们就把它彻底理清楚。

4.1 内置DI容器:为什么我们需要它?

你想想看,一个典型的云原生应用,可能有几十个服务类。如果每个类都自己 new 依赖的对象,代码会变成什么样?

public class OrderService
{
    private readonly ILogger _logger = new ConsoleLogger();
    private readonly IEmailSender _emailSender = new SmtpEmailSender();
    
    public void ProcessOrder(Order order)
    {
        _logger.Log("开始处理订单");
        _emailSender.Send("admin@example.com", "新订单");
    }
}

这样写有什么问题?耦合太紧。哪天你想把 ConsoleLogger 换成 FileLogger,得改所有用到的地方。我在项目中遇到过,一个老项目就是因为这种写法,换个日志库改了三天。

DI 容器的核心思想很简单:你只管声明需要什么,容器负责给你什么。.NET 内置的 DI 容器在 Microsoft.Extensions.DependencyInjection 包里,开箱即用。

核心注册方式:

  • AddSingleton<TService, TImplementation>() - 全局唯一实例
  • AddScoped<TService, TImplementation>() - 每个请求/作用域一个实例
  • AddTransient<TService, TImplementation>() - 每次注入都创建新实例

4.2 服务生命周期:选错了会出大事

生命周期是 DI 里最容易踩坑的地方。我给大家讲个真实案例。

曾经有个同事,把一个数据库上下文(DbContext)注册成了 Singleton。结果呢?多个请求共享同一个上下文,数据全乱套了。因为 DbContext 不是线程安全的,它应该用 Scoped 生命周期。

三种生命周期的区别,我用一张表说清楚:

生命周期 创建时机 典型用途 注意事项
Transient 每次注入时 轻量级、无状态服务 频繁创建有性能开销
Scoped 每个请求开始时 DbContext、工作单元 不要在 Singleton 中注入 Scoped
Singleton 应用启动时 配置对象、日志、缓存 注意线程安全

避坑指南:我曾经犯过一个错误——在 Singleton 服务里注入了 Scoped 的 DbContext。结果第一个请求正常,第二个请求就报「DbContext 已被释放」。因为 Singleton 长期存活,而 Scoped 在请求结束后就被释放了。解决方案是用 IServiceScopeFactory 手动创建作用域。

为什么会这样?因为 Singleton 的实例是应用级别的,它引用的 Scoped 对象在请求结束后就被容器回收了。嗯,这里要注意:永远不要让长生命周期的服务引用短生命周期的服务

4.3 IOptions模式:配置管理的正确姿势

配置文件读取,看起来简单,但要做好并不容易。我个人习惯用 IOptions 模式,它比直接读 IConfiguration 优雅得多。

先看一个典型的 appsettings.json:

{
  "SmtpSettings": {
    "Host": "smtp.example.com",
    "Port": 587,
    "UserName": "admin",
    "Password": "******",
    "UseSsl": true
  }
}

然后定义一个强类型配置类:

public class SmtpSettings
{
    public string Host { get; set; }
    public int Port { get; set; }
    public string UserName { get; set; }
    public string Password { get; set; }
    public bool UseSsl { get; set; }
}

在 Program.cs 里注册:

builder.Services.Configure<SmtpSettings>(
    builder.Configuration.GetSection("SmtpSettings"));

使用时注入 IOptions<SmtpSettings>

public class EmailService
{
    private readonly SmtpSettings _settings;
    
    public EmailService(IOptions<SmtpSettings> options)
    {
        _settings = options.Value;
    }
}

个人经验:我建议用 IOptionsSnapshot<T> 代替 IOptions<T>。为什么?因为 IOptions<T> 是 Singleton 的,配置改了也不会刷新。而 IOptionsSnapshot<T> 是 Scoped 的,每次请求都会重新读取配置。在云原生环境下,配置热更新太重要了。

4.4 配置文件读取:不止是 JSON

.NET 的配置系统非常灵活。它支持多种配置源,而且可以叠加:

  • JSON 文件:最常用,支持多环境(appsettings.Development.json)
  • 环境变量:适合容器化部署,比如 Docker 里传配置
  • 命令行参数:临时覆盖配置
  • Azure Key Vault:敏感信息管理

配置的优先级是:后添加的覆盖先添加的。默认顺序是:appsettings.json → appsettings.{Environment}.json → 环境变量 → 命令行参数。

举个例子,你在开发环境用 JSON 文件,生产环境用环境变量覆盖数据库连接串:

// 环境变量格式
ConnectionStrings__DefaultConnection=Server=prod-db;Database=MyDb;...

// 在 Docker Compose 里
environment:
  - ConnectionStrings__DefaultConnection=Server=prod-db;...

注意那个双下划线 __,这是环境变量配置的分层分隔符。嗯,我第一次看到也觉得奇怪,但用习惯了就觉得很顺手。

4.5 实战:构建一个可配置的云原生服务

最后,咱们把前面学的串起来。假设我们要写一个「订单通知服务」,它需要发邮件和短信:

// 1. 定义配置类
public class NotificationSettings
{
    public bool EnableEmail { get; set; }
    public bool EnableSms { get; set; }
    public SmtpSettings Smtp { get; set; }
    public SmsSettings Sms { get; set; }
}

// 2. 注册服务
builder.Services.Configure<NotificationSettings>(
    builder.Configuration.GetSection("Notification"));

builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<ISmsSender, TwilioSmsSender>();
builder.Services.AddScoped<INotificationService, NotificationService>();

// 3. 使用
public class NotificationService : INotificationService
{
    private readonly NotificationSettings _settings;
    private readonly IEmailSender _emailSender;
    private readonly ISmsSender _smsSender;
    
    public NotificationService(
        IOptionsSnapshot<NotificationSettings> options,
        IEmailSender emailSender,
        ISmsSender smsSender)
    {
        _settings = options.Value;
        _emailSender = emailSender;
        _smsSender = smsSender;
    }
    
    public async Task NotifyAsync(Order order)
    {
        if (_settings.EnableEmail)
            await _emailSender.SendAsync(order);
        if (_settings.EnableSms)
            await _smsSender.SendAsync(order);
    }
}

核心要点回顾:

  1. DI 容器帮你管理对象的创建和生命周期
  2. Singleton、Scoped、Transient 各有适用场景,别搞混
  3. IOptions 模式让配置强类型化,推荐用 IOptionsSnapshot 支持热更新
  4. 配置源可以叠加,环境变量在容器化部署中特别好用

说实话,依赖注入和配置管理,是 .NET 云原生开发的「基本功」。你想想看,一个微服务系统里,几十个服务互相调用,如果没有 DI 容器,手动管理依赖关系会是什么噩梦?

我个人习惯是:所有服务都通过构造函数注入,绝不手动 new 依赖对象。这样代码可测试性极强,单元测试时只需要 mock 接口就行。

下一章,咱们聊聊中间件管道和请求处理。嗯,那又是另一个有意思的话题了。