3113 字
约 10 分钟
13
S02-A10 · 模块边界的测量:耦合到底有多紧

架构图上画了六个模块,评审也通过了。半年后看代码:每个模块都 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. 自测

  1. 为什么"能编译通过"是最弱的边界证据?
  2. 共享数据库为什么不出现在依赖图里?用什么方式才能测到它?
  3. 双向依赖为什么不能靠"补一个中间接口"解决?
  4. 静态依赖、变更共现、故障共现三者不一致时,说明了什么?
  5. "耦合度低"在什么情况下反而是坏信号?
  6. 依赖规则断言为什么会死于豁免清单?可行的写法是什么?
S02-A10 · 模块边界的测量:耦合到底有多紧
https://www.clxhxhhr.top/posts/6310/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。