什么是依赖注入

依赖注入(Dependency Injection,简称 DI)是 .NET 8 内置的核心机制之一,它帮助我们解耦代码、方便单元测试。在注册服务时,最重要的一个决定就是选择服务的生命周期。生命周期决定了容器每次请求服务时,是返回新实例还是复用已有实例。

.NET 8 提供了三种服务生命周期:Transient(瞬时)Scoped(作用域)Singleton(单例)

三种生命周期详解

Transient 瞬时

每次请求都会创建一个全新的实例,永远不会共享。适用于轻量、无状态的服务。

// 定义一个瞬时服务
public interface IGuidService
{
    Guid GetGuid();
}

public class TransientGuidService : IGuidService
{
    private readonly Guid _guid = Guid.NewGuid(); // 每个实例都有唯一标识
    public Guid GetGuid() => _guid;
}

// 注册为瞬时
builder.Services.AddTransient<IGuidService, TransientGuidService>();

特点: - 每次获取都是新对象 - 不会跨请求共享状态 - 适合轻量级、无状态服务

Scoped 作用域

在同一个作用域(Scope)内,多次请求会返回同一个实例;不同作用域则返回不同实例。在 Web 应用中,每次 HTTP 请求就是一个作用域。

public class ScopedGuidService : IGuidService
{
    private readonly Guid _guid = Guid.NewGuid();
    public Guid GetGuid() => _guid;
}

// 注册为作用域
builder.Services.AddScoped<IGuidService, ScopedGuidService>();

特点: - 同一请求内共享实例 - 不同请求之间隔离 - 适合 EF Core 的 DbContext、仓储等需要事务一致性的服务

Singleton 单例

整个应用程序生命周期内只创建一个实例,所有请求共享。

public class SingletonGuidService : IGuidService
{
    private readonly Guid _guid = Guid.NewGuid();
    public Guid GetGuid() => _guid;
}

// 注册为单例
builder.Services.AddSingleton<IGuidService, SingletonGuidService>();

特点: - 全局唯一实例 - 所有请求共享 - 适合配置类、缓存类、线程安全的共享服务

一段对比示例

// 同时注册三种服务,便于对比
builder.Services.AddTransient<TransientOperation>();
builder.Services.AddScoped<ScopedOperation>();
builder.Services.AddSingleton<SingletonOperation>();

app.MapGet("/test", (TransientOperation t1, TransientOperation t2,
                     ScopedOperation s1, ScopedOperation s2,
                     SingletonOperation n1, SingletonOperation n2) =>
{
    return new
    {
        // 两次获取 Transient,Guid 不同
        Transient = new { First = t1.Id, Second = t2.Id, Same = t1.Id == t2.Id },
        // 同一请求内 Scoped,Guid 相同
        Scoped = new { First = s1.Id, Second = s2.Id, Same = s1.Id == s2.Id },
        // 单例永远相同
        Singleton = new { First = n1.Id, Second = n2.Id, Same = n1.Id == n2.Id }
    };
});

生命周期冲突问题

并不是所有依赖关系都合法。一个基本原则是:长生命周期的服务不能依赖短生命周期的服务

服务的生命周期 可以依赖
Singleton 只能依赖 Singleton
Scoped 可以依赖 Singleton、Scoped
Transient 可以依赖任意

常见错误:让 Singleton 依赖 Scoped。

// ❌ 错误:单例依赖作用域服务
public class MySingleton
{
    public MySingleton(MyScoped scoped) { } // 会导致 captived dependency
}

builder.Services.AddSingleton<MySingleton>();
builder.Services.AddScoped<MyScoped>(); // 这里的 Scoped 会被"囚禁"在 Singleton 中

这会抛出 InvalidOperationException。即使使用 ValidateScopes = false 绕过检查,Scoped 服务也会被”囚禁”在 Singleton 中,导致原本应该每次请求都不同的实例变成全局共享,引发难以排查的 bug。

解决方法:调整生命周期,让依赖方比被依赖方”活得短或一样长”。

多线程下 Singleton 的注意事项

Singleton 是全局共享的,因此必须考虑线程安全。如果多个请求同时修改单例中的状态,就会出现竞态条件。

// ❌ 非线程安全的计数器
public class CounterService
{
    private int _count = 0;
    public int Increment() => ++_count; // 多线程下数据会错乱
}

// ✅ 使用 Interlocked 保证原子操作
public class SafeCounterService
{
    private int _count = 0;
    public int Increment() => Interlocked.Increment(ref _count); // 线程安全
}

// ✅ 或者使用锁
public class LockCounterService
{
    private readonly object _lock = new();
    private int _count = 0;
    public int Increment()
    {
        lock (_lock) { return ++_count; }
    }
}

要点: - 单例中的可变状态必须做好同步 - 优先使用 ConcurrentDictionaryInterlocked 等并发原语 - 能用不可变对象就用不可变对象,从根上避免问题

IServiceScopeFactory 手动创建作用域

在 Singleton 或后台服务中,我们经常需要使用 Scoped 服务(比如 DbContext)。这时不能直接注入,而要通过 IServiceScopeFactory 手动创建作用域。

public class BackgroundWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public BackgroundWorker(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory; // 注入工厂而不是直接注入 Scoped 服务
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // 手动创建作用域
            using (var scope = _scopeFactory.CreateScope())
            {
                var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
                // 在此作用域内使用 Scoped 服务
                await db.SaveChangesAsync();
            } // 作用域结束,Scoped 服务会被释放

            await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
        }
    }
}

要点: - using 包裹 scope,确保释放资源 - 不要将 Scoped 服务缓存到 Singleton 字段中 - 每次”业务单元”开始时创建 scope,结束时释放

最佳实践建议

  1. 默认选 Scoped:在 Web 应用中,Scoped 是最稳妥的选择,既有请求内的一致性,又不会全局共享。
  2. 轻量无状态用 Transient:如果服务很小且无状态,Transient 也很合适。
  3. 慎用 Singleton:只有真正需要全局共享且线程安全的服务才用 Singleton。
  4. 开发期开启校验:在 Program.cs 中使用 ValidateScopes = true(开发环境默认开启),提前发现囚禁依赖。
  5. 后台任务用 IServiceScopeFactory:需要 Scoped 服务时,务必手动创建作用域。
  6. 单例避免可变状态:实在要存状态,务必保证线程安全。

掌握这三种生命周期,写出健壮的 .NET 8 应用就更有底气了。