接手一个项目,上一份评估报告写着"代码质量良好:命名规范、分层清晰、注释齐全"。三个月后真实感受是:改一个字段要动七个文件,跑一次回归要四十分钟,出了问题只能靠日志猜。报告没错——它评估的是代码的样子,而日常消耗你的是变更的成本。
可维护性不是代码属性,是变更成本属性。它衡量的是一件很具体的事:把一个已知的改动从想法送到线上,需要多少时间、多少上下文、多少次验证、多少次返工。这篇给一套可观测的判据,以及一套不给出分数、只给出场景的结论写法。
1. 四个可观测维度
四个维度都能从历史数据里量出来,不需要读代码。
| 维度 | 观测方式 | 典型量级(按团队历史校准) | 恶化信号 |
|---|---|---|---|
| 改动波及面 | 一个需求 PR 触及的文件/模块数 | p50 三到五个文件 | p90 突然翻倍 |
| 上下文需求 | 完成改动前需要读多少个文件才敢动手 | 五个以内可接受 | 需要跨三个模块才敢改 |
| 局部可验证 | 能否只跑一部分测试就确认正确 | 单模块测试一分钟内 | 任何改动都要全量回归 |
| 失败可定位 | 从报警到定位到代码行的时长 | 分钟级 | 只能靠复现与猜测 |
四个维度里,改动波及面最容易量,失败可定位最难量但最有决策价值——它决定值班的人半夜能不能睡着。
─── 变更半径:从提交历史反推,不需要任何工具链改造
git log --since="180 days" --pretty=%H --name-only | \
awk 'BEGIN{RS=""} {print NF-1}' | sort -n | \
awk '{a[NR]=$1} END{print "p50="a[int(NR*0.5)], "p90="a[int(NR*0.9)]}'
// 只看同一 PR 内的文件数,跨 PR 统计会被拆单污染
// 数字本身不判优劣,要和自己半年前的同口径结果比
一个隐含前提:变更半径必须在同一批需求类型上比。修文案的 PR 和改结算逻辑的 PR 天然差十倍,混在一起算平均,得到的数字既不可比也不可解释。
"局部可验证"这一维度常被误读成"测试多不多"。它实际问的是:改完之后,能不能只跑一小块就获得信心。这取决于测试的切分方式——按模块切、按依赖注入切、还是全都依赖同一个数据库与同一套夹具。一个所有测试都要起完整容器环境的应用,局部验证能力等于零,因为每次验证的最小单位就是全量。
─── 局部可验证的判定:不看用例数,看"改一个类要跑什么"
change: OrderPricing.java
must_run:
- OrderPricingTest // 单模块,20s
- PricingContractTest // 依赖跨模块契约,2min
- full_regression // 若改了费用汇总口径,则必须
// 前两项能在本地跑完 = 局部可验证
// 三项都指向 full_regression = 该维度实际上已经失效
2. 三个证据来源与各自的偏差
| 证据 | 怎么取 | 强项 | 偏差 |
|---|---|---|---|
| 变更历史 | 提交、PR、故障单的时间序列 | 客观、可回溯 | 受提交粒度与拆单习惯影响 |
| 上手时间 | 新人从入职到首个非平凡提交 | 直接反映认知成本 | 样本极少,一个人就是噪声 |
| 故障恢复时长 | 从报警到恢复的时长分布 | 反映定位能力 | 受值班熟练度与工具影响 |
三个来源的偏差方向不同,所以结论要交叉:变更历史说"波及面在涨",上手时间说"新人两周才敢改",故障恢复说"平均一小时"——三者同向时结论确定;只有一项异常时,先怀疑度量。
上手时间是唯一直接测"需要多少上下文"的证据,也是最容易被浪费的:多数团队让新人第一周看文档、第二周改文案,这两周的数据被排除在统计外。从第一天开始记录他卡在哪,比事后补一份文档有用得多。
故障恢复时长要拆成两段才有信息量:定位时长与修复时长。前者反映可观测性与代码结构,后者反映改动难度与发布链路。合成一个总时长,会被发布排队这种与代码无关的因素主导。
─── 故障恢复时长:必须是分段中位数,不能是平均值
SELECT incident_id,
TIMESTAMPDIFF(MINUTE, alerted_at, located_at) AS locate_min,
TIMESTAMPDIFF(MINUTE, located_at, fixed_at) AS fix_min,
TIMESTAMPDIFF(MINUTE, fixed_at, restored_at) AS release_min
FROM incident
WHERE severity IN ('P1','P2') AND created_at >= DATE_SUB(NOW(), INTERVAL 180 DAY);
// locate_min 长时间不降 = 代码结构或可观测性问题
// release_min 长时间不降 = 发布链路问题,与代码无关
─── 上手轨迹:按天记三件事,不做主观评分
D1 能跑起来吗 → 卡在依赖下载 / 环境变量 / 证书
D3 能改一处并验证吗 → 卡在不知道改完该跑哪些测试
D7 能独立改一个需求吗 → 卡在跨模块调用链读不懂
// 每一"卡"都是一个可改进项,直接对应文档或工具缺口
静态指标的失效要单独说。圈复杂度、代码行数、注释率与维护难度的相关性很弱:三十行的状态机可能是全项目最难改的地方,八百行的纯数据映射文件可能三年没人碰。复杂度分数测的是代码形态,不是变更成本。
| 静态指标 | 看起来在测什么 | 实际测到什么 | 结论 |
|---|---|---|---|
| 圈复杂度 | 逻辑难懂程度 | 分支数量 | 与改动成本弱相关 |
| 注释率 | 可读性 | 注释字符占比 | 与理解成本几乎无关 |
| 重复代码率 | 一致性风险 | 文本相似度 | 误报多,且常与解耦冲突 |
| 测试覆盖率 | 安全网强度 | 执行过的行数 | 不反映断言质量 |
覆盖率尤其要小心:它证明代码被执行过,不证明失败会被发现。一条只断言"不抛异常"的用例,能给覆盖率贡献两个百分点,对可维护性没有贡献。
3. 判据、时间维度与结论写法
"有没有测试"不是判据,"测试能不能定位失败"才是。判据可以写得很硬:改动引入一个 bug,测试是否在十分钟内指出具体位置。做不到这一点,测试数量再多也只是回归成本。
─── 可维护性判据写进验收,而不是写进文档
@Test
void failurePointsAtCause() {
var result = pricing.apply(orderWithNegativeQty());
// 断言要指向业务不变量与具体字段
assertThat(result.errors())
.extracting("field", "code")
.containsExactly(tuple("quantity", "MUST_BE_POSITIVE"));
}
// 只断言"抛了异常"的用例,失败时仍然需要人工定位
文档的作用被高估:文档描述意图,不描述约束。真正拖慢改动的是那些没人写下来的隐含约束——字段必须按某个顺序赋值、缓存键必须带租户前缀、某个接口必须幂等。约束只能靠断言与测试固化,文档里的约束会随版本漂移。
时间维度是最容易被忽略的一条:今天好维护不代表半年后好维护。评判对象是变化率而不是当前状态,所以下一条的结论写法必须带有效期。
─── 评估结论的写法:给场景,不给分数
不合格:可维护性评分 7.2/10,代码质量良好
合格: 改结算金额字段需动 6 个文件、跨 2 个模块;
单模块测试无法覆盖,需全量回归 40 分钟;
上月同类改动 3 次,其中 1 次引发线上问题
// 每条都对应一个可验证的动作,分数对应不了任何动作
与团队熟悉度的混淆是最危险的错觉:熟悉的人觉得好维护,因为他已经把约束记在脑子里了。判据很简单——换一个人来改,成本是否相同。如果答案是"只有他知道怎么改",那这个模块的可维护性接近零,无论代码多漂亮。
结论必须带有效期,并写清重测触发条件。可维护性是变化率而不是状态:模块被拆、依赖大版本升级、核心成员离开、月度改动次数翻倍——这四件事发生后,旧结论一律作废,不必等到重测周期。
改进优先级只有一条顺序:先降低变更传播半径,再谈代码风格。传播半径是结构问题,风格是表达问题;结构没理顺时改风格,等于给一个漏水的管子刷漆。
4. 排错:评估结论与实际感受不一致的五种情况
| 现象 | 原因 | 处理 |
|---|---|---|
| 报告说良好,实际很难改 | 只看了静态指标 | 补变更历史与故障时长 |
| 变更半径很小但仍很慢 | 慢在验证与等待,不在改动 | 量"改动到合并"的分段耗时 |
| 新人上手快,老人却总踩坑 | 老人绕过了既有约束,隐性知识在退化 | 把踩过的坑转成断言 |
| 覆盖率很高但故障多 | 断言只覆盖 happy path | 看变异杀伤与逃逸缺陷 |
| 换个团队评估结论完全相反 | 熟悉度差异被当成了质量差异 | 统一判据:换人改一次要多久 |
第二条值得展开:可维护性差经常表现为等待——等评审、等流水线、等环境、等联调。这些时间不在代码里,却往往是变更成本的主要部分。
5. 边界:什么时候可维护性投入该压到最低
| 场景 | 合理投入 | 理由 |
|---|---|---|
| 一次性脚本、数据迁移 | 零,只保证能重跑 | 生命周期以天计 |
| 即将废弃的模块 | 只做隔离,不做重构 | 改进无处落地 |
| 变更频率极低的代码(年改一次) | 低,够用即可 | 摊销不了改造成本 |
| 原型与验证性代码 | 低,明确标注"不维护" | 混淆原型与产品才是真风险 |
| 强监管、长生命周期系统 | 高 | 变更成本会持续复利 |
判据可以统一成一句话:预期剩余变更次数 × 单次改动成本。剩余变更次数少于三次,重构的投入收不回来;大于二十次,任何降低单次成本的动作都划算。这条乘法比任何评分都可靠,因为它把"未来还会改几次"这个前提摆到了明面上。
原型代码要标注"不维护",否则它会以产品代码的身份进入评估,把预算引开。标注不是免责,而是分配。
6. 自测
- 为什么说可维护性是变更成本属性,而不是代码属性?
- 四个可观测维度里,哪一个最难量但最有决策价值?
- 上手时间被统计浪费的常见原因是什么?
- 覆盖率为什么不能作为可维护性的判据?
- "熟悉的人觉得好维护"为什么是最危险的错觉?用什么判据拆穿它?
- 改进优先级里,为什么先降变更传播半径而不是先改代码风格?