2560 字
约 8 分钟
14
S06-A16 · 多模块项目的依赖方向:谁不该依赖谁

一个 Maven 多模块项目,order-service 依赖 user-service,user-service 为了"复用订单里的用户工具类"又依赖了 order-common,而 order-common 反向引用了 order-service 的领域模型——三条依赖围成一个环。编译能过(因为 Maven 允许某些环),但构建顺序开始不稳定,某个模块单独打包时会缺类。

依赖方向的问题不会立刻报错,它表现为"构建时快时慢""单独跑某模块失败""改一处要连带改三处"。更麻烦的是它"能工作":环在多数构建里能过,于是团队会认为"没问题",直到某次拆分发布或独立测试时才暴露。这类问题往往在"要拆服务/要独立部署"时才被重视。
而这些症状都不指向"依赖方向"这个原因。


1. 三种依赖方向

方向 含义
向下(到基础设施) 正常,如 service → repo
横向(同层模块之间) 需谨慎,容易成环
向上(到上层) 危险,如 repo → service

第三项是"架构被反转"的信号:底层模块引用了上层模块的类第三项的典型例子是"工具类里出现了业务概念":一个本应通用的工具方法,参数类型是业务领域对象,于是工具模块被迫依赖领域模块。
,于是底层不再能独立使用或测试。

第二种在业务拆分时最常见:order 与 user 互相需要对方的一点东西,于是互相依赖。判据是"这点东西属于谁"——如果它是"用户"的概念,就该在 user 模块里,而 order 依赖 user 是正常方向。

2. 依赖成环的三种来源

来源 表现
共用工具类无处安放 谁先写就放谁家
领域模型互相引用 Order 里有 User,User 里有 List<Order>
常量/枚举共享 两边都 import

第一项还有一个"时间"版本:A 模块先写了某个工具,半年后 B 模块也需要,于是 B 依赖 A——而这条依赖在当时的评审里是"复用",看起来是好事。
第一项最普遍:"这段代码两边都用"这个判断,本身说明它属于第三个模块。而多数团队的做法是"放到先写的那个模块里",于是依赖方向立刻反向。

第二项是建模问题:双向引用意味着这两个概念在同一个聚合里双向引用还会带来"加载"问题:序列化 Order 会带出 User,再带出他的 orders,于是出现无限递归或意外的全量加载。这类问题在接上 JSON 序列化后才暴露。
,或者需要"ID 引用"而非"对象引用"。

// 反例:双向对象引用,两个模块互相依赖
class Order { private User user; }
class User { private List<Order> orders; }

// 正例:单向引用或 ID 引用
class Order { private Long userId; }        // order 依赖 user 的 ID,不依赖 user 类
class User { private List<Long> orderIds; }
// 或把关系放进第三个模块(如 order-user-rel)

3. 拆环的四种做法

做法 代价
抽出公共模块 需判断归属
依赖倒置(接口在上层) 需要接口与实现分离
事件解耦 引入异步与最终一致
合并模块 承认拆得太细

第一项最直接:把"两边都用"的部分抽到新模块,让两个原模块都依赖它,于是依赖方向变成单向树。

第二项的实现要点是"接口的归属":接口放在上层(调用方)模块,实现放在底层,于是底层编译时不依赖上层的具体类,只依赖接口——而接口属于上层,所以底层仍然依赖上层?

正确的做法是把接口放在更底层的"契约模块",于是上下都依赖契约,而不互相依赖。
第二项在"底层需要通知上层"时用:上层定义接口,底层实现或调用它。这是把"向上依赖"变成"向下依赖"的标准手法。

依赖方向还有一个"细粒度"的层次:包级依赖。模块之间干净、但模块内部包与包互相引用,同样会让"改动扩散"。所以边界检查通常要看两遍——先看模块,再看包。

// 包级依赖的三条约定(示例,六边形/分层风格)
domain     可依赖:无(纯模型)
application 可依赖:domain
infra      可依赖:domain, application(实现它们的接口)
web        可依赖:application, domain(不直接依赖 infra)
// 反向依赖一律违例:domain 不得 import infra

4. 怎么让坏依赖立刻暴露

手段 说明
构建工具的依赖检查 Maven Enforcer / Gradle 插件
架构测试 ArchUnit 类断言
模块图可视化 生成依赖图看环
编译顺序警告 出现警告就查

第二项最实用:把"允许的依赖方向"写成测试断言,违反即失败。第三项(依赖图)的价值在"人看得懂":把生成的图贴在架构文档里,每季度更新一次,新人能在五分钟内理解模块边界,而不必读三年代码。
它比文档有效,因为文档不会被 CI 执行。

// ArchUnit:把依赖方向写成可执行断言
@ArchTest static final ArchRule 领域层不依赖基础设施 =
    noClasses().that().resideInAPackage("..domain..")
        .should().dependOnClassesThat().resideInAPackage("..infra..");

@ArchTest static final ArchRule 模块之间无环 =
    slices().matching("com.shop.(*)..").should().beFreeOfCycles();
// 一旦有人加了反向依赖,CI 立刻红

监控"依赖健康度"有个便宜的指标:每季度统计一次"改动一个模块会连带重编多少模块"。这个数在变大,说明依赖在变密,而它比"图上有没有环"更早发出信号。

// 粗略统计:某模块的直接+传递下游数量(Maven 示意)
mvn -q dependency:tree -Dincludes=com.shop:order-common -DoutputFile=deps.txt
grep -c 'com.shop' deps.txt        # 依赖了它的模块数(近似)
// 趋势比绝对值重要:三个月内从 4 涨到 9 → 该看边界了

5. 依赖方向与构建时间

现象 原因
全量构建慢 依赖链长,无并行
改一个模块要重编多个 依赖是"向下流入"的
单独打包失败 依赖未声明(靠传递)
构建顺序不稳 存在环

第五项(测试要起整个项目)还有一个"传导"效应:测试变慢 → 开发不跑测试 → 质量下降。所以依赖过重的代价最终落在质量上,而不是构建时间上。
第四项在 Maven 里表现为"reactor 排序不稳定":有环时排序依赖图的遍历顺序,于是同一次改动在不同机器上可能编出不同的顺序,偶尔失败。

第一项的解法是"减少跨层依赖":依赖链越浅,可并行的模块越多。

还有一类"隐性依赖"不在编译期出现:共享数据库或共享 Redis 键空间。两个模块通过同一张表交换数据,编译上完全独立,运行上强耦合——改表结构时两边都要改,而依赖图上看不出来。

// 隐性依赖的三种形态(编译期看不见)
1 共享表/字段(模块 A 写,模块 B 读)
2 共享缓存键(前缀相同,语义不同)
3 共享消息主题(格式未约定)
// 判据:改一处是否需要"协调另一个团队"

6. 排错:五种"依赖问题的表现"

现象 成因 处理方向
单独跑某模块缺类 依赖靠传递获得 显式声明
改一处连带三处 横向依赖过密 抽公共或合并
循环依赖报错 真的成环 抽公共/倒置
编译顺序偶尔失败 环导致排序不稳 拆环
测试要起整个项目 依赖太重 倒置或抽接口

第五项是"依赖过重"的直接代价:一个本该单测的类,因为依赖链上有数据库与消息队列,只能起整个应用才能测。

7. 边界:什么情况可以接受横向依赖

场景 为什么 做法
明确的上下游(order → user) 业务方向清晰 可接受
同一限界上下文内 本就是一个模块 可接受
临时脚本/工具 不长期 随意
已用事件解耦 不是编译期依赖 无问题

判断口径一句话:这个依赖会不会让"单独构建/测试这个模块"变难。最后一条与"团队"有关的边界:模块边界往往跟团队边界一致。两个模块互相依赖,通常意味着两个团队在互相等对方发版——而"拆环"实际上是组织协调问题的技术表达。
会,就必须处理;不会(依赖很轻、无环),横向依赖可以接受。


8. 自测

  1. 三种依赖方向里,哪一种意味着"架构被反转"?它会让底层模块失去什么?
  2. "这段代码两边都用"这个判断为什么说明它属于第三个模块?
  3. Order 里有 User、User 里有 List<Order> 意味着什么建模问题?两种正解是什么?
  4. 依赖倒置如何把"向上依赖"变成"向下依赖"?
  5. 为什么"构建顺序偶尔失败"指向环的存在?
  6. 判断"横向依赖能否接受"的那个问题是什么?
S06-A16 · 多模块项目的依赖方向:谁不该依赖谁
https://www.clxhxhhr.top/posts/6502/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。