架构图上画了六个模块,评审也通过了。半年后看代码:每个模块都 import 同一个 common 包,common 里有三十七个类,其中十一个直接操作数据源。模块之间没有一行直接调用——所以"模块化"这个结论在架构图上是成立的,在代码里也一样成立,只是它不说明任何事。
模块边界不是一个声明,而是一种可被测量、可被打破、也会悄悄退化的约束。这篇只谈测量:耦合能被分成几种、每种用什么证据量、量出来的数字什么时候可信、以及什么时候干脆别量。
1. 耦合的四个维度
把"耦合"当成一个分数是常见错误。至少四类耦合各自独立,可以一紧一松。
| 维度 | 表现 | 测量方式 | 打破后的后果 |
|---|---|---|---|
| 编译期依赖 | A 的代码引用 B 的类型 | 依赖图、模块描述文件、import 图 |
改 B 的签名会让 A 编译失败 |
| 运行时调用 | A 在运行中请求 B 的服务 | 链路追踪的跨模块边、RPC/HTTP 目标 | 改 B 的语义会让 A 行为漂移 |
| 数据共享 | 两边读写同一批表或同一个缓存键 | 表/键 → 模块的反向映射 | 改 schema 影响面无法静态推导 |
| 发布耦合 | 两者必须同版本上线 | 发布单的联合出现次数 | 任何一方都无法单独回滚 |
四类的危险程度依次上升:编译期依赖至少会给你一个编译错误;运行时调用会给你一个超时或异常;数据共享什么都不给,直到某天改字段的人不知道还有另一个读者。
─── 编译期依赖:先拿到原始边,再谈度量
mvn -q dependency:tree -DoutputType=json > deps.json
jdeps --multi-release 17 -recursive -verbose:package target/app.jar | \
awk '$2 ~ /->/ {print $1, $3}' | sort -u > pkg-edges.txt
// 只看 package 层,class 层的边太密,读不出结构
// common 包的扇入如果占全部边的三成以上,边界基本已经失效
其中一列数字要单独看:common 的扇入占比。公共层被所有模块依赖本身不是问题,问题是它同时依赖了业务模块——那时它已经从"工具"变成了"中枢"。
─── 扇入占比:判断公共层是不是已经变成中枢
total=$(wc -l < pkg-edges.txt)
common=$(grep -c ' common' pkg-edges.txt)
echo "scale=2; $common / $total" | bc
// 占比超过 0.3:边界已被公共层吃掉
// 再看 common 是否依赖业务包:有边就是中枢,不只是工具
// 中枢一旦形成,任何"拆模块"都会退化为"拆目录"
判定顺序建议固定:数据共享 → 双向依赖 → 运行时调用 → 发布耦合。反过来会得到一份"看起来很松"的报告,真正的问题在最后一层才暴露。
2. 方向比强度重要
强弱只影响改动成本,方向决定能不能拆。
| 方向形态 | 依赖图特征 | 拆分可行性 | 典型成因 |
|---|---|---|---|
| 单向(A→B) | 无环 | 高,切一刀就能得到两个可编译单元 | 分层约定执行得好 |
| 双向(A↔B) | 成环 | 低,必须先抽第三处 | 互相需要对方的实体或回调 |
| 隐式共享 | 图上没有边 | 极低,切断点不在代码里 | 共享表、共享缓存键、共享枚举常量 |
| 单向但高频 | 无环,边权重高 | 中,能拆但同步成本高 | 每次请求都跨模块调用 |
双向依赖几乎无法直接拆:把 A、B 切成两个仓,编译立刻失败;补一层中间接口,等于把双向变成一个共同的上游,而那个上游会变成新的 common。可行路径只有一条——先把两侧共用的一部分下沉,让环消失,再切。
─── 共享表是"不在依赖图里"的耦合,只能反查
SELECT table_name, GROUP_CONCAT(DISTINCT module ORDER BY module) AS readers
FROM code_table_usage
GROUP BY table_name
HAVING COUNT(DISTINCT module) > 1
ORDER BY COUNT(DISTINCT module) DESC;
// readers 里出现两个以上模块的表,就是耦合的真实位置
// schema 迁移的影响面 = readers,而不是 ER 图上的外键
共享数据库是最难解的一类,因为它不出现在任何依赖图里。CI 不会因为它失败,架构评审看不到它,只有改动时的线上故障会提醒你它存在。测量它的唯一办法是从代码里反向抽取表引用(SQL、ORM 映射、手写查询都算),做成表 → 模块的映射,然后按读者数量排序。
比表更隐蔽的是缓存键与共享枚举。两个模块读写同一个 Redis 键前缀,耦合强度与共享表等价,但没有任何 schema 会记录它;一个枚举常量被五个模块 switch,新增一个枚举值时,五个模块的默认分支会静默走进错误路径。
─── 隐式共享的三种载体,都要进同一张耦合台账
① 表 / 视图 → 反向抽取 SQL 与 ORM 映射
② 缓存键前缀 → 扫 key 前缀常量与序列化格式
③ 枚举 / 状态码 / 错误码 → 谁 switch 了它,谁就与它耦合
// 台账粒度:载体 → 读者模块列表 → 最近一次变更日期
// 只在变更时更新,不做每日同步
隐式共享的判定标准很实用:如果新增一个取值时你需要改三个以上模块,那它们就是耦合的。这条判据不需要任何工具,只要在评审时问一句"加一个状态要动几处"。
3. 三个测量层次与各自的盲区
| 层次 | 数据来源 | 能看出什么 | 盲区 |
|---|---|---|---|
| 静态依赖 | 构建产物、import 图 |
编译期与包结构 | 运行时反射、配置驱动的调用 |
| 变更共现 | 提交历史(同一次提交/同一个 PR 触及的模块) | 实际改动是否跨模块 | 大重构会制造假共现 |
| 故障共现 | 事故与告警的模块归属 | 风险是否集中在一处 | 归因依赖值班人的判断 |
三层要一起看:静态依赖说"应该没关系",变更共现说"总一起改",故障共现说"总一起坏"。不一致处就是边界定义错处。
─── 变更传播半径:改一个文件,实际要动几个模块
files_changed_per_pr : p50 = 3, p90 = 11
modules_touched_per_pr : p50 = 1, p90 = 3
cross_module_pr_ratio : 0.42 // 跨模块改动占比
// 跨模块占比长期高于 0.3 且没有下降趋势:边界画错了位置
// 指标本身不判优劣,它只指出"哪条边最常被一起改"
"耦合度低"不等于"设计好"。一个模块没有任何外部依赖,也可能只是因为没人用它——零扇入的模块要么是未来的骨架,要么是遗留的死代码。度量必须配一个使用量,否则"低耦合"会把废弃误判成优雅。
| 度量 | 定义 | 可信阈值形态 | 常见误读 |
|---|---|---|---|
| 跨模块依赖数 | 跨模块的类型/调用边总数 | 看趋势与分布,不设绝对值 | 数量少就是设计好 |
| 双向依赖数 | 成环的模块对数 | 目标为零,但不含共享表 | 为零就等于可拆 |
| 变更传播半径 | 一个 PR 触及的模块数(p90) | 与自己历史比 | 半径小就是边界清晰 |
| 共享表数 | 被两个以上模块读写的表数 | 逐季收敛,冻结新增 | 数少就等于数据边界清楚 |
四个量的读法一致:先与自己比,再与上一个季度比。跨团队比较无效,因为模块粒度、提交粒度、登记习惯都不同。
─── 把边界写成可执行断言,而不是评审意见
@Test
void moduleBoundary() {
noClasses()
.that().resideInAPackage("..order..")
.should().dependOnClassesThat()
.resideInAPackage("..billing.internal..")
.check(importedClasses);
}
// 只拦新增违规(冻结清单),否则历史耦合会让规则永远红着
断言的维护成本被严重低估:规则写得太理想,第一次运行就报出几百处历史违规,团队的处理方式是加豁免,豁免清单涨到五十条,规则就死了。可行的做法是冻结 + 只拦新增,每季度收敛十条,收敛不动就承认这条边界不该存在。
边界的执行手段有三档,强度与成本同步上升:目录约定(零成本、零强制)、编译期可见性(internal 包或模块系统,改造成本中等)、依赖断言(CI 强制,需要冻结清单)。多数团队缺的不是第三档,而是第二档——把 internal 的可见性真正用起来,比写一百条断言更省维护。
4. 排错:度量结果反常的五种情况
| 现象 | 原因 | 处理 |
|---|---|---|
| 耦合度全绿但改一处动五处 | 只测了编译期依赖,漏了共享表 | 补数据共享维度 |
| 双向依赖数为零 | 只扫了 import,漏了反射/SPI/配置文件 | 加运行时调用边 |
| 跨模块改动占比很高 | 提交粒度太粗(一个 PR 混多个需求) | 先看 PR 拆分,再判边界 |
| 某模块扇出异常高 | 它其实是"编排层",职责本是调用多方 | 换判据:看它的变更频率与故障率 |
| 加了规则后所有人都在豁免 | 规则按理想边界写,现实是历史耦合 | 冻结清单 + 只拦新增 |
第四条容易被误判成设计问题:编排层天然高扇出,压它的扇出等于让业务逻辑散回各个模块。扇出要结合职责判断,不能单独设阈值。
5. 边界:什么时候正式耦合度量不划算
| 场景 | 度量的收益 | 建议 |
|---|---|---|
| 单体应用早期,模块少于五个 | 低,边界还在变 | 只做目录约定,不做断言 |
| 团队只有一人 | 接近零,协调成本本就不存在 | 用变更半径自我约束即可 |
| 代码库即将被替换 | 低,改进无处落地 | 记录问题,不再投入 |
| 模块划分每月都在调 | 负值,度量基线不断失效 | 先稳定划分,再谈测量 |
| 强监管要求可追溯 | 高(合规驱动,不是质量驱动) | 按审计口径做,不必追求精度 |
拆分的收益与成本要一起算:换来独立发布与独立演进,付出构建复杂度、跨模块调试、发布协调。模块数从五涨到十五,构建与联调成本通常超线性增长,收益却是线性的。没有独立发布需求时,模块化的收益主要是认知收益,用目录与断言就能拿到大半。
6. 自测
- 为什么"能编译通过"是最弱的边界证据?
- 共享数据库为什么不出现在依赖图里?用什么方式才能测到它?
- 双向依赖为什么不能靠"补一个中间接口"解决?
- 静态依赖、变更共现、故障共现三者不一致时,说明了什么?
- "耦合度低"在什么情况下反而是坏信号?
- 依赖规则断言为什么会死于豁免清单?可行的写法是什么?