软件设计

【模块化 #2】模块依赖设计总结:循环依赖为何产生、如何打破

上一篇文章中,我们将模块化总结为“把一起变化的东西归在一起,建立边界”。

4 分钟阅读
【模块化 #2】模块依赖设计总结:循环依赖为何产生、如何打破 封面图

上一篇文章中,我们将模块化总结为“把一起变化的东西归在一起,建立边界”。

但拆分模块后,真正的问题才开始。拆出的部分会开始相互引用。

A 使用 B,B 使用 C,某天 C 又使用 A。三者实际上变成一个整体,模块化的好处也就全部消失了。

先说结论:模块依赖设计有两条原则。让依赖方向保持单向,并让经常变化的一方依赖稳定的一方。

本文总结确定依赖方向的标准、循环依赖的典型形成路径,以及三种打破方法。


依赖应该向哪个方向流动?

依赖是有方向的。如果模块 A import 模块 B,A 就依赖于 B。

关于箭头应该指向哪里,有一个经典答案。

不稳定的东西应该依赖稳定的东西。反过来时,小改动就会扩散到整个系统。

稳定模块是不常变化的模块,例如领域模型和公共协议。

不稳定模块是经常变化的模块,例如界面(UI)和随需求持续修改的事件响应逻辑。

因此,健康的依赖图通常是这样的方向。

  • 界面・功能模块 → 领域模块 → 公共接口模块
  • 箭头只向下流向稳定的一侧,不会逆向向上。

Robert C. Martin 将其称为稳定依赖原则(SDP,Stable Dependencies Principle)。


为什么会产生循环依赖?

循环依赖大多不是出于恶意,而是很自然地形成的。典型路径如下。

Order 模块引用 User 模块,因为订单需要下单人的信息。

过了一段时间,需求要求在用户界面显示“最近订单列表”。User 模块开始引用 Order 模块的瞬间,循环就形成了。

当 Order 和 User 互相调用时,循环就会这样闭合。
当 Order 和 User 互相调用时,循环就会这样闭合。

一旦出现循环,三件事会崩溃。

  1. 构建单元分离失败:编译器无法分别处理两者,实际上会变成一个模块(Swift 会直接用编译错误禁止模块间循环 import)。
  2. 无法独立测试:测试 A 需要 B,测试 B 又需要 A,形成死锁。
  3. 无法预测影响范围:无论修复哪一侧,都必须重新检查另一侧。

如何打破循环依赖?

实践中主要有三种方法。

第一,把公共部分下移。

如果双方真正需要的只是“对方的一部分”,就把这部分提取到更稳定的下层模块。例如,只把 Order 和 User 共同需要的类型移到领域模型模块。

第二,通过接口反转方向(DIP)。

用协议替代一侧的依赖。User 模块只定义“提供订单列表的某个东西”这一协议,实现则由 Order 模块负责。

// User 模块:仅通过协议声明所需能力
public protocol OrderHistoryProviding {
    func recentOrders(of userID: String) -> [OrderSummary]
}

// Order 模块: User 采用模块的协议并实现
public struct OrderHistoryProvider: OrderHistoryProviding {
    public func recentOrders(of userID: String) -> [OrderSummary] {
        // 查询订单存储库
    }
}

这样只剩下一条方向:Order → User。上一篇 DIP 文章介绍的依赖反转,就这样直接应用到了模块层面。

第三,把组装上移。

不直接连接两个模块,而由同时了解两者的上层模块(应用目标或组装层)负责连接。功能模块需要互相调用来完成页面跳转时尤其有用。

情况 打破方法
双方只需要对方的一部分类型 将公共类型提取到下层模块
一方调用另一方的功能 定义协议后反转依赖
功能模块之间进行页面跳转 在上层组装层连接

什么时候适用,什么时候过度?

管理依赖方向也有成本,因为会增加协议和组装代码。

  • 如果模块达到 3~4 个以上且团队已经分开,值得记录方向规则并用工具监控循环。
  • 如果是只有两个模块的小型项目,只要避免循环即可。把所有引用都包成协议属于过度设计。
  • 如果是已经存在循环的遗留系统,不要试图一次全部切断,先从变化最频繁的模块整理箭头。

面试时会这样问

Q. 模块间循环依赖为什么是问题?你会如何解决?

循环会让两个模块在构建、测试和部署层面变成一个整体,从而失去模块化的好处。可以把公共类型提取到下层模块,通过协议反转依赖方向(DIP),或在上层组装层连接两个模块,使箭头保持单向。

Q. 请解释稳定依赖原则(SDP)。

模块只能依赖比自己更稳定、也就是变化更少的模块。如果经常变化的模块位于依赖关系下游,其变化就会传播到整个上游,因此应将低变化频率的领域模块和接口模块放在图的下方。

有时连打破一条箭头都需要开会。
有时连打破一条箭头都需要开会。

下一篇将介绍许多团队在模块化过程中会掉入的陷阱:光是名字就很危险的 Common 模块。

我们将讨论“反正是公共的,就放进 Common 吧”如何让整个模块系统重新变成一个整体,以及如何用分层结构预防这种情况。