4、构造函数注入:构造器注入原理、多构造函数问题、循环依赖检测与解决
4.1 构造器注入的原理
构造器注入,说白了就是通过构造函数把依赖“塞”进来。这是我最推荐的一种注入方式,没有之一。
为什么?因为它强制你遵守一个原则:对象在创建时就必须是完整的。你想想看,如果一个类依赖某个服务,那这个服务就应该在对象出生时就准备好,而不是等对象活到一半再通过属性赋值。
我在项目中遇到过不少团队,一开始用属性注入觉得方便,结果后来发现对象状态不可控——有时候依赖是 null,有时候不是,排查起来特别头疼。后来统一改成构造器注入,这类问题基本绝迹了。
来看一个典型的例子:
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly ILogger _logger;
// 构造器注入:依赖通过参数传入
public OrderService(IOrderRepository repository, ILogger logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void CreateOrder(Order order)
{
_logger.Log("开始创建订单...");
_repository.Save(order);
}
}
注意看,我在构造函数里做了 null 检查。这是我个人的习惯——防御式编程。一旦传入 null,直接抛异常,绝不藏着掖着。这样问题在启动时就能暴露,而不是等到运行时才炸。
核心要点:构造器注入保证了依赖的不可变性和非空性。只要对象构造成功,你就可以放心使用它的所有依赖。
4.2 多构造函数问题
嗯,这里要注意。当一个类有多个构造函数时,DI 容器会怎么选?
我记得有一次,一个同事写了两个构造函数:
public class PaymentService
{
private readonly IPaymentGateway _gateway;
private readonly ILogger _logger;
// 构造函数 A:两个依赖
public PaymentService(IPaymentGateway gateway, ILogger logger)
{
_gateway = gateway;
_logger = logger;
}
// 构造函数 B:一个依赖
public PaymentService(IPaymentGateway gateway)
{
_gateway = gateway;
_logger = new NullLogger(); // 默认实现
}
}
结果 DI 容器(比如 ASP.NET Core 自带的容器)直接报错了——它不知道选哪个。为什么?因为容器发现两个构造函数都能满足,就懵了。
不同的容器处理方式不一样:
| DI 容器 | 多构造函数处理策略 |
|---|---|
| ASP.NET Core 内置容器 | 选择参数最多的构造函数 |
| Autofac | 默认报错,需要显式指定 |
| Unity | 选择参数最多的构造函数 |
| Castle Windsor | 选择参数最多的构造函数 |
避坑指南:我曾经在一个项目里看到有人故意写多个构造函数,想通过参数数量来区分不同场景。结果换了个 DI 容器,整个系统就崩了。我的建议是——一个类只保留一个构造函数。如果确实需要不同的构造方式,用工厂模式或者建造者模式,别在构造函数上玩花样。
4.3 循环依赖检测与解决
循环依赖,说白了就是 A 依赖 B,B 又依赖 A。这在构造器注入里是个大坑。
为什么会这样?因为构造器注入要求对象在创建时就把所有依赖准备好。如果 A 需要 B,B 又需要 A,那谁先创建?这就成了死锁。
来看一个典型的循环依赖:
public class AService
{
private readonly BService _b;
public AService(BService b) => _b = b;
}
public class BService
{
private readonly AService _a;
public BService(AService a) => _a = a;
}
你试试注册这两个服务,DI 容器会直接抛出 CircularDependencyException。ASP.NET Core 的容器会检测到并告诉你:检测到循环依赖。
4.3.1 如何检测循环依赖
大多数现代 DI 容器都内置了循环依赖检测。但如果你用的是自定义容器或者老版本,可以手动检测:
// 伪代码:检测循环依赖
public void ValidateDependencies(Type type, HashSet<Type> visited)
{
if (visited.Contains(type))
{
throw new InvalidOperationException($"检测到循环依赖:{type.Name}");
}
visited.Add(type);
var dependencies = GetConstructorDependencies(type);
foreach (var dep in dependencies)
{
ValidateDependencies(dep, visited);
}
visited.Remove(type);
}
这个算法其实就是深度优先搜索。每访问一个类型,就把它加入已访问集合。如果再次遇到,说明有环。
4.3.2 如何解决循环依赖
我个人总结了三种解法:
- 重构设计(最推荐)
- 使用属性注入(临时方案)
- 引入中介者模式(架构级方案)
解法一:重构设计
循环依赖往往意味着职责划分不清。比如 A 和 B 互相调用,那是不是应该把公共逻辑抽出来?
// 重构前:A 和 B 互相依赖
public class OrderService
{
private readonly INotificationService _notification;
public OrderService(INotificationService notification) => _notification = notification;
}
public class NotificationService
{
private readonly IOrderService _orderService;
public NotificationService(IOrderService orderService) => _orderService = orderService;
}
// 重构后:引入 IOrderNotificationCoordinator
public class OrderNotificationCoordinator : IOrderNotificationCoordinator
{
private readonly IOrderService _orderService;
private readonly INotificationService _notification;
// 现在两者都依赖协调器,不再互相依赖
}
解法二:属性注入(临时方案)
如果实在来不及重构,可以用属性注入打破循环。但这是下策,我一般只在遗留系统中用:
public class AService
{
// 构造器注入 B
private readonly BService _b;
public AService(BService b) => _b = b;
}
public class BService
{
// 属性注入 A(延迟解析)
public AService A { get; set; }
public void DoSomething()
{
// 注意:此时 A 可能为 null
A?.SomeMethod();
}
}
我的经验:属性注入虽然能解决循环依赖,但会引入 null 检查的麻烦。我曾经在一个项目里用属性注入解决循环依赖,结果上线后某个路径下 A 还没赋值就被调用了,直接 NullReferenceException。从那以后,我宁愿花时间重构,也不走这个捷径。
解法三:中介者模式
这是最优雅的解法。引入一个中介者,让 A 和 B 都只依赖中介者:
public interface IMediator
{
void Notify(object sender, string eventName);
}
public class AService
{
private readonly IMediator _mediator;
public AService(IMediator mediator) => _mediator = mediator;
public void DoWork()
{
_mediator.Notify(this, "AFinished");
}
}
public class BService
{
private readonly IMediator _mediator;
public BService(IMediator mediator) => _mediator = mediator;
public void DoWork()
{
_mediator.Notify(this, "BFinished");
}
}
这样 A 和 B 不再直接依赖对方,循环依赖自然就解了。
4.4 小结
构造器注入是 DI 的基石。记住三点:
- 一个类只留一个构造函数,避免容器选择困难
- 循环依赖是设计问题,不是技术问题。遇到先重构,别急着用属性注入
- 防御式编程:构造函数里做 null 检查,让问题暴露在起点
我个人觉得,构造器注入就像盖房子的地基——地基打好了,上面的结构才稳。别图一时方便用属性注入,后面维护起来真的会哭。