IoC 实战:从控制反转到依赖注入容器

“不要调用我们,我们会调用你。”——这是好莱坞原则,也是 IoC(Inversion of Control)的精髓。很多人用了好几年 ASP.NET Core 的 AddScoped / AddTransient,却说不清 IoC 和 DI 的区别,更不理解容器内部到底做了什么。本文从控制反转的本质讲起,手写一个迷你容器,再深入 .NET Core DI 容器的生命周期、作用域、验证机制和高级模式,结合 PMS 项目的真实场景,把 IoC 讲透。


目录

  1. 控制反转到底"反转"了什么
  2. DI 的三种注入方式
  3. 生命周期:Singleton / Scoped / Transient
  4. Captive Dependency:最隐蔽的陷阱
  5. IServiceScopeFactory:手动创建作用域
  6. .NET Core DI 容器内部原理
  7. 手写迷你 DI 容器
  8. PMS 项目 IoC 演进:从静态类到 DI 容器
  9. 注册策略与程序集扫描
  10. Keyed Services:同一接口多实现
  11. 泛型服务与开放泛型
  12. ActivatorUtilities 与工厂模式
  13. 第三方容器:Autofac / DryIoc 集成
  14. DI 验证与诊断工具
  15. Checklist

1. 控制反转到底"反转"了什么

1.1 没有 IoC 的世界

假设我们要实现一个备件入库服务,需要保存数据、发通知、记录日志:

// ❌ 紧密耦合:SparePartService 直接 new 了所有依赖
public class SparePartService
{
    private readonly SqlServerRepository _repository = new SqlServerRepository();
    private readonly EmailNotifier _notifier = new EmailNotifier();
    private readonly FileLogger _logger = new FileLogger();

    public void StockIn(SparePart part)
    {
        _repository.Save(part);
        _notifier.Notify("备件入库", part.PartName);
        _logger.Log($"备件 {part.PartNo} 已入库");
    }
}

问题显而易见:

  • 换不了实现:想把数据库从 SQL Server 换成 PostgreSQL?改 Service 代码
  • 测不了试:单元测试必须连真实数据库、真实邮件服务器
  • 管不了生命周期:每次 new 一个 Repository,连接池被打爆
  • 违反开闭原则:加一种通知方式就要改 Service

1.2 引入控制反转

核心思想:把对象的创建和依赖关系的管理,从对象自身"反转"给外部容器

// ✅ 依赖抽象,不依赖具体实现
public class SparePartService
{
    private readonly IRepository<SparePart> _repository;
    private readonly INotifier _notifier;
    private readonly ILogger<SparePartService> _logger;

    // 依赖由外部注入,自己不创建
    public SparePartService(
        IRepository<SparePart> repository,
        INotifier notifier,
        ILogger<SparePartService> logger)
    {
        _repository = repository;
        _notifier = notifier;
        _logger = logger;
    }

    public void StockIn(SparePart part)
    {
        _repository.Save(part);
        _notifier.Notify("备件入库", part.PartName);
        _logger.LogInformation("备件 {PartNo} 已入库", part.PartNo);
    }
}

1.3 IoC ≠ DI ≠ 容器

这三个概念经常被混用,但它们有明确的区别:

概念含义关系
IoC(控制反转)一种设计原则——把控制权从业务代码转移给框架/容器最上层的思想
DI(依赖注入)实现 IoC 的一种模式——通过构造函数/属性/方法传入依赖IoC 的具体实现方式之一
IoC 容器负责注册、解析、释放依赖的框架DI 的基础设施

IoC 的其他实现方式还包括:模板方法模式、服务定位器模式、事件驱动等。DI 是最主流、最推荐的方式。

💬 互动一下:你在项目中见过服务定位器(Service Locator)模式吗?那种在类内部 IoC.Resolve<IFoo>() 的写法,虽然也实现了控制反转,但隐藏了依赖关系,被称为"DI 的反面模式"——你觉得它和构造函数注入比,最大的问题是什么?


2. DI 的三种注入方式

2.1 构造函数注入(推荐)

public class SparePartService
{
    private readonly IRepository<SparePart> _repository;
    private readonly INotifier _notifier;

    // 依赖在构造函数中声明,类创建时就必须提供
    public SparePartService(
        IRepository<SparePart> repository,
        INotifier notifier)
    {
        _repository = repository;
        _notifier = notifier;
    }
}

优点

  • 依赖一目了然,看构造函数就知道这个类需要什么
  • 依赖声明为 readonly,创建后不可变
  • 容器在创建时就能检测缺失依赖,快速失败
  • 单元测试时直接传入 Mock 即可

适用场景:必选依赖(类没有这些依赖就无法工作)。

2.2 属性注入(谨慎使用)

public class SparePartService
{
    // 可选依赖,通过属性设置
    public IAuditLogger? AuditLogger { get; set; }

    private readonly IRepository<SparePart> _repository;

    public SparePartService(IRepository<SparePart> repository)
    {
        _repository = repository;
    }
}

.NET Core DI 原生不支持属性注入,需要第三方容器(如 Autofac)。

适用场景

  • 可选依赖(有则用,没有也不影响核心功能)
  • 循环依赖的变通方案(但更好的做法是重构消除循环依赖)

风险:属性可能为 null,使用时必须判空。

2.3 方法注入(特定场景)

public class SparePartService
{
    public void StockIn(
        SparePart part,
        [FromKeyedServices("email")] INotifier notifier)
    {
        notifier.Notify("备件入库", part.PartName);
    }
}

ASP.NET Core 中 [FromServices] 也是一种方法注入:

[HttpPost]
public IActionResult StockIn(
    [FromBody] SparePartDto dto,
    [FromServices] ISparePartService service)
{
    service.StockIn(dto);
    return Ok();
}

适用场景:仅在某个方法中使用的依赖,不想让整个类都持有它。

2.4 选择原则

必选依赖 → 构造函数注入
可选依赖 → 属性注入(或构造函数中给默认值)
方法级依赖 → [FromServices] / [FromKeyedServices]

99% 的场景用构造函数注入就够了。 如果一个类的构造函数参数超过 5 个,往往说明这个类职责过多,应该考虑拆分。


3. 生命周期:Singleton / Scoped / Transient

3.1 三种生命周期对比

// Singleton:整个应用程序生命周期内只有一个实例
builder.Services.AddSingleton<ICacheService, RedisCacheService>();

// Scoped:每个请求(作用域)一个实例
builder.Services.AddScoped<IRepository<SparePart>, EfRepository<SparePart>>();

// Transient:每次请求都创建新实例
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
生命周期创建时机释放时机适用场景
Singleton首次解析时应用关闭时无状态服务、缓存、配置
Scoped每个 Scope 首次解析时Scope 释放时DbContext、仓储、工作单元
Transient每次解析时Scope 释放时(如果实现了 IDisposable)轻量无状态服务

3.2 验证生命周期

用最简单的代码验证三种生命周期的行为:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ISingletonService, SingletonService>();
builder.Services.AddScoped<IScopedService, ScopedService>();
builder.Services.AddTransient<ITransientService, TransientService>();

var app = builder.Build();

app.MapGet("/lifetime", (
    ISingletonService singleton1,
    ISingletonService singleton2,
    IScopedService scoped1,
    IScopedService scoped2,
    ITransientService transient1,
    ITransientService transient2) =>
{
    return new
    {
        SingletonSame = ReferenceEquals(singleton1, singleton2),  // true
        ScopedSame = ReferenceEquals(scoped1, scoped2),            // true
        TransientSame = ReferenceEquals(transient1, transient2),   // false
        SingletonId = singleton1.Id,
        ScopedId = scoped1.Id,
        Transient1Id = transient1.Id,
        Transient2Id = transient2.Id
    };
});

app.Run();

连续请求两次:

  • Singleton Id 两次相同
  • Scoped Id 同一次请求内相同,不同请求不同
  • Transient Id 每次注入都不同

3.3 DbContext 为什么默认是 Scoped

// EF Core AddDbContext 默认注册为 Scoped
builder.Services.AddDbContext<PmsDbContext>(options =>
    options.UseSqlServer(connStr));
// 等价于:builder.Services.AddScoped<PmsDbContext>();

原因:

  1. 线程安全:DbContext 不是线程安全的,Scoped 保证同一时刻只有一个请求使用它
  2. 变更跟踪:Change Tracker 跟踪同一请求内的所有实体变更,SaveChanges 时统一提交
  3. 事务边界:一个请求通常是一个业务事务,Scoped 生命周期与事务边界对齐
  4. 连接管理:DbContext 内部管理数据库连接,Scoped 确保连接及时释放

3.4 Transient 的 IDisposable 陷阱

public class FileExporter : IFileExporter, IDisposable
{
    private readonly FileStream _stream;
    public FileExporter() { _stream = File.OpenWrite("export.tmp"); }
    public void Dispose() => _stream.Dispose();
}

builder.Services.AddTransient<IFileExporter, FileExporter>();

⚠️ 关键细节:.NET Core DI 容器会跟踪所有它创建的 IDisposable 对象,即使是 Transient,也会在 Scope(请求)结束时才释放。如果你在一个 Singleton 服务中反复解析 Transient 的 IDisposable 对象,它们会被 Singleton 的 Scope(根容器)持有,直到应用关闭才释放——内存泄漏

解决方式

// 方式1:用工厂注册,告诉容器不要跟踪
builder.Services.AddTransient<IFileExporter>(sp =>
    ActivatorUtilities.CreateInstance<FileExporter>(sp));

// 方式2:不实现 IDisposable,或者让消费者负责释放
// 方式3:用 using var scope = _scopeFactory.CreateScope() 在短作用域内解析

4. Captive Dependency:最隐蔽的陷阱

4.1 什么是 Captive Dependency

当一个长生命周期的服务依赖了一个短生命周期的服务,短生命周期服务被"俘虏"在长生命周期的作用域中,无法及时释放——这就是 Captive Dependency(俘虏依赖)。

// ❌ 经典错误:Singleton 依赖 Scoped
builder.Services.AddSingleton<ICacheWarmer, CacheWarmer>();
builder.Services.AddScoped<PmsDbContext>();

public class CacheWarmer : ICacheWarmer
{
    private readonly PmsDbContext _dbContext;

    // CacheWarmer 是 Singleton,只会被创建一次
    // 但它注入了 Scoped 的 DbContext
    // 结果:DbContext 被俘虏,整个应用生命周期用同一个实例
    public CacheWarmer(PmsDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public void Warm()
    {
        // 第一次请求正常,之后 DbContext 的 Change Tracker 会越来越大
        // 最终导致内存泄漏和并发异常
        var parts = _dbContext.SpareParts.ToList();
    }
}

4.2 实际案例:PMS 项目的 Singleton 中间件

// ❌ 中间件默认是 Singleton!
public class ShipContextMiddleware
{
    private readonly RequestDelegate _next;
    private readonly PmsDbContext _dbContext;  // 陷阱!

    public ShipContextMiddleware(RequestDelegate next, PmsDbContext dbContext)
    {
        _next = next;
        _dbContext = dbContext;  // Scoped 被 Singleton 俘虏
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var shipId = context.Request.Headers["X-Ship-Id"];
        // 多个请求共享同一个 DbContext → 并发异常!
        var ship = await _dbContext.Ships.FindAsync(long.Parse(shipId));
        await _next(context);
    }
}

4.3 修复方式

// ✅ 方式1:在 InvokeAsync 中注入 Scoped 依赖
public class ShipContextMiddleware
{
    private readonly RequestDelegate _next;

    public ShipContextMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    // 中间件的 Invoke/InvokeAsync 是按请求执行的
    // 可以在这里注入 Scoped 服务
    public async Task InvokeAsync(
        HttpContext context,
        PmsDbContext dbContext)  // ✅ 每次请求解析
    {
        var shipId = context.Request.Headers["X-Ship-Id"];
        var ship = await dbContext.Ships.FindAsync(long.Parse(shipId));
        await _next(context);
    }
}

// ✅ 方式2:用 IServiceScopeFactory
public class CacheWarmer : ICacheWarmer
{
    private readonly IServiceScopeFactory _scopeFactory;

    public CacheWarmer(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public void Warm()
    {
        using var scope = _scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<PmsDbContext>();
        var parts = dbContext.SpareParts.AsNoTracking().ToList();
        // scope 释放时,DbContext 也被释放
    }
}

4.4 生命周期规则速查

Singleton  → 可以依赖 Singleton(✅)
Singleton  → 依赖 Scoped(❌ Captive Dependency)
Singleton  → 依赖 Transient(⚠️ 看情况,IDisposable 会泄漏)

Scoped     → 可以依赖 Singleton(✅)
Scoped     → 可以依赖 Scoped(✅)
Scoped     → 可以依赖 Transient(✅)

Transient  → 可以依赖任何(✅)

一句话规则:生命周期短的不能被生命周期长的持有。

4.5 开启验证

开发环境开启范围验证,启动时就能发现 Captive Dependency:

builder.WebHost.UseDefaultServiceProvider((context, options) =>
{
    options.ValidateScopes = context.HostingEnvironment.IsDevelopment();
    options.ValidateOnBuild = true;  // 启动时验证所有依赖能否解析
});

开启后,如果存在 Singleton 依赖 Scoped 的情况,启动时直接抛异常:

InvalidOperationException: Cannot consume scoped service 'PmsDbContext'
from singleton 'ICacheWarmer'.

💬 互动一下:你有没有被 Captive Dependency 坑过?我见过一个真实案例:一个 Singleton 的 EventAggregator 持有了 Scoped 仓储的委托,运行 3 天后内存从 200MB 涨到 4GB,排查了整整一周。评论区聊聊你的经历。


5. IServiceScopeFactory:手动创建作用域

5.1 什么时候需要手动创建 Scope

  • 后台任务BackgroundServiceIHostedService):没有 HTTP 请求上下文,需要自己创建 Scope
  • Singleton 服务中需要访问 Scoped 服务
  • 需要在子作用域中隔离一批操作(如批量处理,每批一个 Scope)

5.2 后台任务中的标准模式

public class ShipSyncBackgroundService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<ShipSyncBackgroundService> _logger;

    public ShipSyncBackgroundService(
        IServiceScopeFactory scopeFactory,
        ILogger<ShipSyncBackgroundService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                // 每次同步创建独立 Scope
                using var scope = _scopeFactory.CreateScope();

                // 从 Scope 中解析 Scoped 服务
                var syncService = scope.ServiceProvider
                    .GetRequiredService<ISyncService>();
                var dbContext = scope.ServiceProvider
                    .GetRequiredService<PmsDbContext>();

                await syncService.SyncAllShipsAsync(stoppingToken);

                // scope.Dispose() 会释放所有 Scoped 和 Transient 的 IDisposable
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "船岸同步失败");
            }

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

5.3 批量处理的作用域管理

public async Task BatchProcessAsync(
    IEnumerable<long> partIds, CancellationToken ct)
{
    var batches = partIds.Chunk(100);  // 每100条一批

    foreach (var batch in batches)
    {
        // 每批创建独立 Scope,避免 Change Tracker 膨胀
        using var scope = _scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<PmsDbContext>();
        var service = scope.ServiceProvider.GetRequiredService<ISparePartService>();

        await service.ProcessBatchAsync(batch, ct);
        await dbContext.SaveChangesAsync(ct);

        // scope 释放,DbContext 和跟踪的实体全部释放
    }
}

5.4 AsyncLocal 与 Scope 的关系

.NET Core DI 的 Scope 是基于 AsyncLocal<ServiceProviderEngineScope> 实现的。这意味着:

// 在 async/await 中,Scope 会自动流动到下游
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<ISparePartService>();

// 即使在异步方法中,AsyncLocal 也会传播当前 Scope
await service.DoWorkAsync();  // 内部解析的 Scoped 服务来自同一个 Scope

但要注意 Task.Run 不一定会传播 AsyncLocal(取决于 ExecutionContext 的流动),最安全的方式是在 Task.Run 内部创建新 Scope。


6. .NET Core DI 容器内部原理

6.1 容器的两个核心接口

// 注册服务
public interface IServiceCollection : IList<ServiceDescriptor>
{
    // 本质就是一个 ServiceDescriptor 列表
}

public class ServiceDescriptor
{
    public Type ServiceType { get; set; }      // 接口类型(如 IRepository<SparePart>)
    public Type? ImplementationType { get; set; } // 实现类型(如 EfRepository<SparePart>)
    public object? ImplementationInstance { get; set; } // 单例实例
    public Func<IServiceProvider, object>? ImplementationFactory { get; set; } // 工厂
    public ServiceLifetime Lifetime { get; set; }  // 生命周期
}

// 解析服务
public interface IServiceProvider
{
    object? GetService(Type serviceType);  // 找不到返回 null
}

6.2 注册到解析的完整流程

// 1. 注册阶段:只是往列表里加描述
builder.Services.AddScoped<IRepository<SparePart>, EfRepository<SparePart>>();
// 等价于:
builder.Services.Add(new ServiceDescriptor(
    serviceType: typeof(IRepository<SparePart>),
    implementationType: typeof(EfRepository<SparePart>),
    lifetime: ServiceLifetime.Scoped));

// 2. Build 阶段:编译表达式树,生成委托工厂
// 容器会分析 EfRepository<SparePart> 的构造函数
// 生成类似这样的委托:
// (IServiceProvider sp) => new EfRepository<SparePart>(
//     sp.GetRequiredService<PmsDbContext>(),
//     sp.GetRequiredService<ILogger<EfRepository<SparePart>>>()
// );

// 3. 解析阶段:调用委托,缓存单例
var repo = serviceProvider.GetRequiredService<IRepository<SparePart>>();

6.3 表达式树编译

.NET Core DI 容器不使用反射 Emit,而是使用表达式树编译来创建对象。首次解析时编译,后续直接调用编译后的委托,性能接近 new。

简化版的编译逻辑:

public object CreateInstance(IServiceProvider sp, Type implementationType)
{
    var constructor = implementationType
        .GetConstructors()
        .OrderByDescending(c => c.GetParameters().Length)
        .First();

    var parameters = constructor.GetParameters()
        .Select(p =>
        {
            // 递归解析每个构造函数参数
            var service = sp.GetService(p.ParameterType);
            if (service == null && !p.IsOptional)
                throw new InvalidOperationException(
                    $"无法解析 {p.ParameterType} for {implementationType}");
            return service!;
        })
        .ToArray();

    return constructor.Invoke(parameters);
}

实际的容器还会处理:

  • 循环依赖检测
  • 泛型参数推断
  • 开放泛型匹配
  • 作用域级别的实例缓存
  • Disposable 跟踪

7. 手写迷你 DI 容器

为了真正理解容器原理,我们手写一个支持三种生命周期的迷你容器:

public enum LifeTime { Singleton, Scoped, Transient }

public class ServiceRegistration
{
    public Type ServiceType { get; set; } = null!;
    public Type ImplementationType { get; set; } = null!;
    public LifeTime Lifetime { get; set; }
}

public class MiniContainer : IServiceProvider, IDisposable
{
    private readonly List<ServiceRegistration> _registrations = new();
    private readonly Dictionary<Type, object> _singletonCache = new();
    private readonly Dictionary<Type, object> _scopedCache = new();
    private readonly List<IDisposable> _disposables = new();
    private bool _isRoot = true;

    public void Register<TService, TImpl>(LifeTime lifetime = LifeTime.Transient)
        where TImpl : TService
    {
        _registrations.Add(new ServiceRegistration
        {
            ServiceType = typeof(TService),
            ImplementationType = typeof(TImpl),
            Lifetime = lifetime
        });
    }

    public object? GetService(Type serviceType)
    {
        var reg = _registrations.FirstOrDefault(r => r.ServiceType == serviceType)
            ?? throw new InvalidOperationException($"未注册 {serviceType.Name}");

        // Singleton:根容器缓存
        if (reg.Lifetime == LifeTime.Singleton)
        {
            if (!_singletonCache.TryGetValue(serviceType, out var instance))
            {
                instance = CreateInstance(reg.ImplementationType);
                _singletonCache[serviceType] = instance;
                if (instance is IDisposable d) _disposables.Add(d);
            }
            return instance;
        }

        // Scoped:当前作用域缓存
        if (reg.Lifetime == LifeTime.Scoped)
        {
            if (_isRoot)
                throw new InvalidOperationException("不能从根容器解析 Scoped 服务");

            if (!_scopedCache.TryGetValue(serviceType, out var instance))
            {
                instance = CreateInstance(reg.ImplementationType);
                _scopedCache[serviceType] = instance;
                if (instance is IDisposable d) _disposables.Add(d);
            }
            return instance;
        }

        // Transient:每次创建
        var transient = CreateInstance(reg.ImplementationType);
        if (transient is IDisposable disposable) _disposables.Add(disposable);
        return transient;
    }

    private object CreateInstance(Type implementationType)
    {
        var ctor = implementationType
            .GetConstructors()
            .OrderByDescending(c => c.GetParameters().Length)
            .First();

        var args = ctor.GetParameters()
            .Select(p => GetService(p.ParameterType))
            .ToArray();

        return ctor.Invoke(args);
    }

    public MiniContainer CreateScope()
    {
        var scope = new MiniContainer
        {
            _registrations = _registrations,  // 共享注册
            _singletonCache = _singletonCache, // 共享单例
            _isRoot = false
        };
        return scope;
    }

    public void Dispose()
    {
        foreach (var d in _disposables) d.Dispose();
        _disposables.Clear();
        _scopedCache.Clear();
        if (_isRoot) _singletonCache.Clear();
    }
}

使用:

var container = new MiniContainer();
container.Register<ISparePartService, SparePartService>(LifeTime.Scoped);
container.Register<IRepository<SparePart>, EfRepository<SparePart>>(LifeTime.Scoped);
container.Register<ICacheService, RedisCacheService>(LifeTime.Singleton);
container.Register<IEmailSender, SmtpEmailSender>(LifeTime.Transient);

using var scope = container.CreateScope();
var service = scope.GetService(typeof(ISparePartService)) as ISparePartService;
service!.StockIn(new SparePart());

这个迷你容器缺少很多特性(开放泛型、Keyed Services、工厂注册、循环依赖检测等),但核心原理和 .NET Core 容器是一样的:注册描述 → 构造函数分析 → 递归解析 → 生命周期缓存 → Dispose 跟踪


8. PMS 项目 IoC 演进:从静态类到 DI 容器

8.1 旧架构:静态 IoC 类

PMS 项目早期使用了一个静态 IoC 类作为服务定位器:

// 旧代码:静态 IoC 容器包装
public static class IoC
{
    private static IDependencyResolver? _resolver;

    public static void Initialize(IDependencyResolver resolver)
        => _resolver = resolver;

    public static T Resolve<T>() where T : class
        => _resolver?.Resolve<T>()
           ?? throw new InvalidOperationException("IoC 未初始化");

    public static void RegisterType<TFrom, TTo>(LifeTime lifetime = LifeTime.PerCall)
        => _resolver?.RegisterType<TFrom, TTo>(lifetime);

    public static void RegisterInstance<TInterface>(TInterface instance)
        where TInterface : class
        => _resolver?.RegisterInstance(instance);
}

// 调用方:服务定位器模式
public class SparePartController : Controller
{
    private readonly ISparePartService _service = IoC.Resolve<ISparePartService>();

    public ActionResult StockIn(SparePartDto dto)
    {
        _service.StockIn(dto);
        return Json(new { success = true });
    }
}

问题

  • 依赖关系隐藏——看类的构造函数不知道它需要什么
  • 单元测试困难——必须先初始化静态容器
  • 静态状态在测试间泄漏
  • 生命周期管理混乱(PerCall / Singleton / PerResolve / PerThread / External 五种生命周期难以追踪)

8.2 迁移到 ASP.NET Core DI

// ✅ 迁移后:构造函数注入,依赖一目了然
public class SparePartController : ControllerBase
{
    private readonly ISparePartService _service;

    public SparePartController(ISparePartService service)
    {
        _service = service;
    }

    [HttpPost]
    public async Task<IActionResult> StockIn(
        SparePartDto dto, CancellationToken ct)
    {
        await _service.StockInAsync(dto, ct);
        return Ok();
    }
}

8.3 兼容旧代码的过渡方案

迁移过程中,仍有大量旧代码调用 IoC.Resolve<T>()。为了不一次性重写所有代码,可以做一个适配器:

// 旧接口适配器:让静态 IoC 包装新的 IServiceProvider
public class ServiceProviderDependencyResolver : IDependencyResolver
{
    private readonly IServiceProvider _serviceProvider;

    public ServiceProviderDependencyResolver(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public T Resolve<T>() where T : class
        => _serviceProvider.GetRequiredService<T>();

    public void RegisterType<TFrom, TTo>(LifeTime lifetime)
    {
        // ASP.NET Core 容器在 Build 后不支持动态注册
        // 这部分需要在 Startup/Program 中统一注册
        throw new NotSupportedException(
            "请在 Program.cs 中使用 IServiceCollection 注册服务");
    }
}

// 在 Program.cs 中初始化
var app = builder.Build();
IoC.Initialize(new ServiceProviderDependencyResolver(app.Services));

这样旧代码不需要改动就能继续工作,新代码全部用构造函数注入,逐步迁移。

8.4 生命周期映射

旧系统到新系统的生命周期映射:

旧 IoC LifeTime新 DI ServiceLifetime说明
PerCallTransient每次解析创建新实例
SingletonSingleton全局唯一
PerResolveScoped(近似)每次解析图中共享
PerThread无对应ASP.NET Core 不推荐线程本地存储
External外部实例传入RegisterInstance 或工厂

9. 注册策略与程序集扫描

9.1 手动注册 vs 程序集扫描

小型项目手动注册没问题:

builder.Services.AddScoped<ISparePartService, SparePartService>();
builder.Services.AddScoped<IEquipmentService, EquipmentService>();
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
// ... 100 个服务写 100 行

大型项目用程序集扫描自动注册:

// 约定:所有实现 ITransient 接口的类注册为 Transient
//       所有实现 IScoped 接口的类注册为 Scoped
//       所有实现 ISingleton 接口的类注册为 Singleton

public interface IScoped { }
public interface ITransient { }
public interface ISingleton { }

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddAssemblyServices(
        this IServiceCollection services,
        params Assembly[] assemblies)
    {
        var types = assemblies.SelectMany(a => a.GetTypes())
            .Where(t => !t.IsAbstract && !t.IsInterface && !t.IsGenericTypeDefinition)
            .ToList();

        foreach (var type in types)
        {
            var lifetime = GetLifetime(type);
            if (lifetime == null) continue;

            // 注册该类实现的所有接口
            var interfaces = type.GetInterfaces()
                .Where(i => i != typeof(IScoped) &&
                            i != typeof(ITransient) &&
                            i != typeof(ISingleton));

            foreach (var @interface in interfaces)
            {
                var descriptor = new ServiceDescriptor(
                    @interface, type, lifetime.Value);
                services.Add(descriptor);
            }

            // 同时注册具体类型自身
            services.Add(new ServiceDescriptor(type, type, lifetime.Value));
        }

        return services;
    }

    private static ServiceLifetime? GetLifetime(Type type)
    {
        if (typeof(ISingleton).IsAssignableFrom(type))
            return ServiceLifetime.Singleton;
        if (typeof(IScoped).IsAssignableFrom(type))
            return ServiceLifetime.Scoped;
        if (typeof(ITransient).IsAssignableFrom(type))
            return ServiceLifetime.Transient;
        return null;
    }
}

// 使用
builder.Services.AddAssemblyServices(
    typeof(Program).Assembly,                    // API 层
    typeof(SparePartService).Assembly,           // Application 层
    typeof(EfRepository<>).Assembly);            // Infrastructure 层

9.2 标记接口方式

// Service 层标记
public class SparePartService : ISparePartService, IScoped
{
    // 自动注册为 Scoped 的 ISparePartService
}

public class CacheService : ICacheService, ISingleton
{
    // 自动注册为 Singleton 的 ICacheService
}

public class EmailSender : IEmailSender, ITransient
{
    // 自动注册为 Transient 的 IEmailSender
}

优点

  • 新增服务只需加个标记接口,不用修改注册代码
  • 服务的生命周期在类自身上声明,一目了然
  • 避免遗漏注册

注意

  • 标记接口是"横切关注点",不要和领域接口混淆
  • 可以用特性(Attribute)替代:[Service(Lifetime = ServiceLifetime.Scoped)]

9.3 仓储的批量注册

// 泛型仓储注册
builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));

// 特定仓储的自定义实现
builder.Services.AddScoped<ISparePartRepository, SparePartRepository>();

当解析 IRepository<SparePart> 时:

  1. 先查找是否有精确注册 IRepository<SparePart> → 有就用
  2. 没有则匹配开放泛型 IRepository<>EfRepository<SparePart>

10. Keyed Services:同一接口多实现

10.1 基本用法

.NET 8 引入了 Keyed Services(之前叫 Keyed Services,.NET 7 中是 ServicesKeyedService),用于同一接口有多个实现的场景:

// 注册
builder.Services.AddKeyedSingleton<INotifier, EmailNotifier>("email");
builder.Services.AddKeyedSingleton<INotifier, SmsNotifier>("sms");
builder.Services.AddKeyedSingleton<INotifier, WeChatNotifier>("wechat");

// 解析
public class NotificationService
{
    private readonly INotifier _emailNotifier;
    private readonly INotifier _smsNotifier;

    public NotificationService(
        [FromKeyedServices("email")] INotifier emailNotifier,
        [FromKeyedServices("sms")] INotifier smsNotifier)
    {
        _emailNotifier = emailNotifier;
        _smsNotifier = smsNotifier;
    }
}

10.2 PMS 场景:多船舶数据源

不同船舶可能使用不同的数据库连接(SQL Server / SQLite / 远程 API),用 Keyed Services 实现数据源路由:

public interface IShipDataSource
{
    Task<List<SparePart>> GetSparePartsAsync(long shipId, CancellationToken ct);
}

public class SqlServerShipDataSource : IShipDataSource { /* ... */ }
public class SqliteShipDataSource : IShipDataSource { /* ... */ }
public class RemoteApiShipDataSource : IShipDataSource { /* ... */ }

// 注册
builder.Services.AddKeyedScoped<IShipDataSource, SqlServerShipDataSource>("sqlserver");
builder.Services.AddKeyedScoped<IShipDataSource, SqliteShipDataSource>("sqlite");
builder.Services.AddKeyedScoped<IShipDataSource, RemoteApiShipDataSource>("remote");

// 路由器
public class ShipDataSourceRouter : IShipDataSourceRouter
{
    private readonly IServiceProvider _sp;
    private readonly IShipConfigService _shipConfig;

    public ShipDataSourceRouter(
        IServiceProvider sp,
        IShipConfigService shipConfig)
    {
        _sp = sp;
        _shipConfig = shipConfig;
    }

    public IShipDataSource Resolve(long shipId)
    {
        var sourceType = _shipConfig.GetDataSourceType(shipId);
        return _sp.GetRequiredKeyedService<IShipDataSource>(sourceType);
    }
}

10.3 枚举所有 Keyed 服务

public class NotificationDispatcher
{
    private readonly IEnumerable<KeyValuePair<string, INotifier>> _notifiers;

    public NotificationDispatcher(
        IKeyedServiceProvider keyedProvider,
        IEnumerable<INotifier> allNotifiers)
    {
        // 注入所有 INotifier(非 keyed)
    }

    // 或者通过 IServiceProviderIsKeyedService 判断
    public void Dispatch(string channel, string title, string message)
    {
        // 按 channel 解析
    }
}

10.4 .NET 8 之前的替代方案

如果还在用 .NET 6,可以用工厂模式:

public delegate INotifier NotifierFactory(string channel);

builder.Services.AddSingleton<EmailNotifier>();
builder.Services.AddSingleton<SmsNotifier>();
builder.Services.AddSingleton<NotifierFactory>(sp => channel =>
    channel switch
    {
        "email" => sp.GetRequiredService<EmailNotifier>(),
        "sms" => sp.GetRequiredService<SmsNotifier>(),
        _ => throw new KeyNotFoundException($"不支持的通知渠道: {channel}")
    });

11. 泛型服务与开放泛型

11.1 开放泛型注册

// 注册开放泛型(不带具体类型参数)
builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
builder.Services.AddScoped(typeof(IValidator<>), typeof(DataAnnotationValidator<>));

// 解析时自动构造闭合泛型
// IRepository<SparePart> → EfRepository<SparePart>
// IRepository<Equipment> → EfRepository<Equipment>

11.2 泛型特性约束

// 仓储只接受继承自 AggregateRoot 的类型
public class EfRepository<T> : IRepository<T> where T : AggregateRoot
{
    // ...
}

// 如果解析 IRepository<SomeDto>(SomeDto 不是 AggregateRoot)
// 容器会尝试找其他注册,找不到则抛异常

11.3 泛型服务的装饰器模式

.NET Core DI 原生不支持装饰器,但可以用工厂实现:

builder.Services.AddScoped<EfRepository<SparePart>>();
builder.Services.AddScoped<IRepository<SparePart>>(sp =>
    new CachingRepository<SparePart>(
        sp.GetRequiredService<EfRepository<SparePart>>(),
        sp.GetRequiredService<ICacheService>()));

如果用了 Autofac,装饰器是内置支持的。


12. ActivatorUtilities 与工厂模式

12.1 ActivatorUtilities.CreateInstance

当你需要创建一个未注册的类型,但希望它的构造函数参数从容器中解析:

// 这个类没有在容器中注册,但它的依赖需要从容器获取
public class ReportExporter
{
    public ReportExporter(
        PmsDbContext dbContext,
        ILogger<ReportExporter> logger,
        string reportType)  // 这个参数容器不知道
    {
        // ...
    }
}

// 使用 ActivatorUtilities 注入容器能解析的参数,手动提供其他参数
var exporter = ActivatorUtilities.CreateInstance<ReportExporter>(
    serviceProvider,
    "monthly");  // reportType 参数

12.2 工厂注册

builder.Services.AddScoped<ISparePartService>(sp =>
{
    var dbContext = sp.GetRequiredService<PmsDbContext>();
    var logger = sp.GetRequiredService<ILogger<SparePartService>>();

    // 根据配置选择不同实现
    var useCaching = sp.GetRequiredService<IConfiguration>()
        .GetValue<bool>("Features:SparePartCaching");

    if (useCaching)
        return new CachedSparePartService(dbContext, logger,
            sp.GetRequiredService<ICacheService>());

    return new SparePartService(dbContext, logger);
});

12.3 条件注册

// 仅在特定环境注册
if (builder.Environment.IsDevelopment())
{
    builder.Services.AddScoped<IEmailSender, FakeEmailSender>();
}
else
{
    builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
}

// 替换已有注册(最后注册的生效)
builder.Services.AddScoped<INotifier, EmailNotifier>();
if (builder.Configuration.GetValue<bool>("Features:SmsNotification"))
{
    // 替换:先移除再添加,或直接 Add 覆盖
    var descriptor = builder.Services.FirstOrDefault(
        d => d.ServiceType == typeof(INotifier));
    if (descriptor != null)
        builder.Services.Remove(descriptor);
    builder.Services.AddScoped<INotifier, CompositeNotifier>();
}

13. 第三方容器:Autofac / DryIoc 集成

13.1 什么时候需要第三方容器

.NET Core 自带的 DI 容器有意保持简单,不支持:

  • 属性注入
  • 装饰器模式(原生支持有限)
  • 子容器/嵌套生命周期
  • 动态代理/AOP
  • 程序集扫描的高级约定
  • 命名服务(.NET 8 的 Keyed Services 填补了这个空白)

如果你的项目用了 AOP(如 AOP 实战那篇中的拦截器),Autofac 是最常见的选择。

13.2 Autofac 集成

dotnet add package Autofac.Extensions.DependencyInjection
// Program.cs
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());

builder.Host.ConfigureContainer<ContainerBuilder>(containerBuilder =>
{
    // 程序集扫描
    containerBuilder.RegisterAssemblyTypes(typeof(SparePartService).Assembly)
        .Where(t => t.Name.EndsWith("Service"))
        .AsImplementedInterfaces()
        .InstancePerLifetimeScope();

    // 属性注入
    containerBuilder.RegisterType<SparePartController>()
        .PropertiesAutowired();

    // 装饰器
    containerBuilder.RegisterType<EfRepository<SparePart>>()
        .As<IRepository<SparePart>>();
    containerBuilder.RegisterDecorator<CachingRepository<SparePart>, IRepository<SparePart>>();

    // AOP 拦截器
    containerBuilder.RegisterType<TransactionInterceptor>();
    containerBuilder.RegisterType<SparePartService>()
        .As<ISparePartService>()
        .EnableInterfaceInterceptors()
        .InterceptedBy(typeof(TransactionInterceptor));
});

13.3 注意事项

  • 第三方容器会覆盖 .NET Core 的 DI 行为,部分诊断工具可能失效
  • 从 .NET 8 开始,Keyed Services 原生支持,很多场景不再需要 Autofac
  • AOP 拦截器有一定性能开销(动态代理),不要在高频核心路径上滥用

14. DI 验证与诊断工具

14.1 启动验证

builder.WebHost.UseDefaultServiceProvider((context, options) =>
{
    options.ValidateScopes = true;        // 验证 Captive Dependency
    options.ValidateOnBuild = true;       // Build 时验证所有服务能解析
});

生产环境建议

  • ValidateScopes = false(有性能开销)
  • ValidateOnBuild = false(启动慢)
  • 在测试环境开启,CI 中跑一次启动测试

14.2 常见异常及解决

异常原因解决
InvalidOperationException: Unable to resolve service未注册或生命周期不对Program.cs 注册对应服务
InvalidOperationException: Cannot consume scoped service from singletonCaptive DependencyIServiceScopeFactory 或改为方法注入
InvalidOperationException: A second operation was startedDbContext 并发检查是否被 Singleton 持有,或是否跨线程共享
ObjectDisposedException使用了已释放的 Scope检查异步操作是否超出了请求生命周期
Circular dependency detected循环依赖重构代码,引入中介者或事件总线解耦

14.3 诊断工具

# 查看已注册的服务
dotnet tool install -g dotnet-dump
# 或在代码中列出所有注册
var allServices = app.Services.GetServices<ServiceDescriptor>();
foreach (var s in allServices)
{
    Console.WriteLine($"{s.ServiceType.Name} -> " +
        $"{s.ImplementationType?.Name ?? "instance/factory"} " +
        $"({s.Lifetime})");
}

14.4 单元测试中的 DI

// 用独立的 ServiceCollection 做测试
public class SparePartServiceTests
{
    [Fact]
    public async Task StockIn_Should_Save_And_Notify()
    {
        // Arrange
        var services = new ServiceCollection();
        services.AddScoped<ISparePartService, SparePartService>();
        services.AddScoped<IRepository<SparePart>>(_ =>
            new Mock<IRepository<SparePart>>().Object);
        services.AddScoped<INotifier>(_ =>
            new Mock<INotifier>().Object);
        services.AddLogging();

        var provider = services.BuildServiceProvider();
        using var scope = provider.CreateScope();
        var service = scope.ServiceProvider.GetRequiredService<ISparePartService>();

        // Act
        await service.StockInAsync(new SparePartDto { /* ... */ });

        // Assert
        // ...
    }
}

更简单的方式——单元测试通常不需要真正的容器,直接 new 就行:

[Fact]
public async Task StockIn_Should_Save_And_Notify()
{
    var mockRepo = new Mock<IRepository<SparePart>>();
    var mockNotifier = new Mock<INotifier>();
    var logger = new TestLogger<SparePartService>();  // 测试用的假 Logger

    var service = new SparePartService(
        mockRepo.Object,
        mockNotifier.Object,
        logger);

    await service.StockInAsync(new SparePartDto());

    mockRepo.Verify(r => r.AddAsync(It.IsAny<SparePart>(), It.IsAny<CancellationToken>()),
        Times.Once);
}

15. Checklist

注册

  • 必选依赖用构造函数注入,声明为 readonly
  • DbContext、Repository、UnitOfWork 注册为 Scoped
  • 无状态服务(缓存、配置、工厂)注册为 Singleton
  • 轻量无状态服务注册为 Transient
  • 开放泛型用 AddScoped(typeof(IRepository<>), typeof(EfRepository<>))
  • 同一接口多实现用 Keyed Services(.NET 8+)或工厂委托
  • 大型项目用程序集扫描自动注册,避免遗漏

生命周期

  • Singleton 不依赖 Scoped 或 Transient(Captive Dependency)
  • 中间件的 Scoped 依赖通过 InvokeAsync 参数注入
  • 后台任务通过 IServiceScopeFactory 创建 Scope
  • 批量处理每批创建独立 Scope,避免 Change Tracker 膨胀
  • 开发环境开启 ValidateScopesValidateOnBuild
  • Transient 的 IDisposable 对象注意泄漏风险

安全与可测试

  • 不使用服务定位器模式(IoC.Resolve<T>()),用构造函数注入
  • 不注入 IServiceProvider 本身(那是服务定位器)
  • 构造函数参数不超过 5 个,过多说明职责过重
  • 单元测试直接 new,不需要真正的容器
  • 不在容器中注册实体/DTO 等数据对象

PMS 项目专项

  • IoC.Resolve<T>() 代码逐步迁移到构造函数注入
  • 旧生命周期映射:PerCall→Transient、Singleton→Singleton
  • 多船舶数据源用 Keyed Services 路由
  • 仓储用开放泛型注册,特殊仓储单独注册
  • 拦截器/装饰器考虑用 Autofac 或第三方容器

总结

IoC/DI 是 .NET Core 应用的骨架,理解它的三个关键问题:

  1. 谁来创建对象? ——容器创建,你只声明依赖
  2. 对象活多久? ——Singleton / Scoped / Transient,按场景选择
  3. 依赖能正确解析吗? ——注意 Captive Dependency、循环依赖、生命周期约束

记住一个核心原则:依赖应该向内流入(构造函数注入),而不是向外拉取(服务定位器)。当你的类只通过构造函数声明需要什么,而不关心这些东西从哪里来,你就真正掌握了 IoC。

💬 最后互动:你的项目中 DI 容器用的是原生 .NET Core DI 还是 Autofac?有没有遇到过特别难排查的 DI 相关问题?我先来:曾经因为一个 Singleton 的 IMemoryCache 内部缓存了 Scoped 仓储的查询结果,缓存的实体被多个请求的 Change Tracker 同时跟踪,间歇性出现"实体已在另一个上下文中跟踪"异常。这个 bug 排查了两天,因为它只在特定缓存命中时才出现。评论区聊聊你的故事。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Lost of 程序猿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值