一个 API 的文档写着"支持批量 100 条",而代码三个月前改成了 50。前端按文档做,上线后批量提交全失败——而"改文档"这件事在当时没人想起来,因为改代码的人不知道文档里写了这个数。
文档与代码不同步,几乎从不是"态度问题",而是"没有机制让两者同时被修改"。这个区别在"追责"上也有意义:"没改文档"几乎从不被记为问题(因为不影响功能),于是它在优先级里永远排最后——直到有人按文档做错。
要求"记得改文档"不会生效,因为它依赖人的记忆;而机制能做到"不改就报错"。
1. 三种不同步
| 类型 | 例子 |
|---|---|
| 数值不同步 | 上限、超时、版本号 |
| 结构不同步 | 字段、枚举值、路径 |
| 语义不同步 | 同一字段含义变了 |
第三项最难查:字段名没变、类型没变、文档没变,但"它的含义"变了第三项的"传染性"最强:一个字段含义变了,所有"按旧含义"写的下游逻辑都错了,而它们各自的代码看起来完全正常。
(如"金额"从"含税"改成"不含税"),于是所有按旧理解写的代码都错。
前两项能靠"生成"解决,第三项只能靠"评审时提出"。
2. 四种同步机制(按可靠度)
| 机制 | 可靠度 |
|---|---|
| 从代码生成文档(OpenAPI、JavaDoc) | 高(数值/结构类) |
| 文档测试(示例可运行) | 高(示例类) |
| CI 校验(文档里的常量与代码一致) | 中高 |
| 评审清单("改 API 就要改文档") | 低 |
第一项还有"防遗忘"的附加收益:新写的接口自动有文档,于是"补文档"这个动作从"每次都要做"变成"默认就有"。
第一项最彻底:接口文档从注解/类型生成,于是"改了签名文档就变"。代价是"生成不出的部分"(业务语义、使用场景)仍需人工写。
第三项是"轻量版":把文档里的关键数字与代码常量做比对第三项的"轻"在于"不用改文档体系":它只是在现有文档上加一层检查,于是可以"先做最痛的那几个数"(上限、超时、版本)。
,不一致就报错。它不需要重写文档体系。
3. 生成式文档的边界
| 能生成 | 不能生成 |
|---|---|
| 路径、参数、类型 | 业务语义 |
| 示例请求/响应(结构) | "什么时候用这个接口" |
| 错误码列表 | "为什么这么设计" |
| 版本变更记录 | 迁移建议 |
第二项与第四项还有一个"时间"特征:它们的变化频率低("什么时候用"很少变),于是"人工维护"的成本可控——而"参数与字段"变化频繁,正适合生成。
第二项与第四项是"人写文档"的真正价值:读者来查文档,往往不是"参数是什么",而是"我该用哪个、怎么迁移"。
所以"生成 + 人工补充"是常态:结构自动生成,语义人工维护,两者在同一个页面呈现。
// 生成部分(OpenAPI,从代码来)
paths:
/api/orders:
post:
summary: "创建订单" # 人工写的 summary
parameters: [...] # 生成的
x-when-to-use: "下单流程的第二步,需先校验库存" # 人工扩展字段
x-migration: "v2 起 max_batch 从 100 降为 50" # 人工扩展字段
4. 数值不同步的"轻量防线"
| 做法 | 成本 |
|---|---|
| 文档里用占位符引用代码常量 | 低(但需渲染机制) |
| CI 抽取文档中的数字与常量比对 | 中 |
| 文档只写"最多 N 条(见配置)" | 低(但读者不方便) |
| 每次改常量时搜文档 | 高(易漏) |
第二项最实用:用正则从文档里抽出"最多 100 条"这类描述,与代码常量比对。第二项的"脆弱点"在"文档表述的多样性":"最多 100 条"/"上限 100"/"不超过 100" 三种写法都要匹配,否则检查会漏。所以它适合"关键数值",不适合"所有数字"。
它能抓住最常见的一类不同步,成本一个脚本。
第四项就是"没有机制"的状态:它依赖"想到",而"想到"不可靠。
// CI 校验:文档里的数字 vs 代码常量
doc_max=$(grep -oE '最多 ?[0-9]+ ?条' docs/api.md | grep -oE '[0-9]+')
code_max=$(grep -oE 'MAX_BATCH = [0-9]+' src/main/java/.../OrderApi.java | grep -oE '[0-9]+')
[ "$doc_max" = "$code_max" ] || { echo "文档与代码不一致: $doc_max vs $code_max"; exit 1; }
"文档测试"是被低估的机制:文档里的示例代码如果能被执行,它就永远不会过期——因为它一旦与实现不符,测试就红。
// doctest:把文档里的示例变成测试
def max_batch() -> int:
"""单次批量的上限。
>>> max_batch()
50
"""
return 50
// 若把 50 改成 30 而文档没改 → doctest 失败 → CI 红
5. 语义变更怎么处理
| 做法 | 效果 |
|---|---|
| 改名(让含义显式) | 最好 |
| 文档加"变更说明" | 中(读者可能不看) |
| 保留旧字段、加新字段 | 稳(兼容期) |
| 只更新文档 | 差(旧代码仍按旧理解) |
第一项的成本在"改名"本身:数据库列、接口字段、前端字段都要改,于是它是"一次性大改"。所以"早点改"比"以后改"便宜得多——因为下游还没铺开。
第一项最有效:"金额"改成"金额含税",于是所有按旧理解写的代码在编译或阅读时立刻暴露差异。
第三项是"接口演进"的标准做法:旧字段保留(标废弃),新字段并列,迁移期后再删。
"文档漂移"应当被度量,否则"好像还行"会持续很久。一个便宜的指标:抽查 20 条文档描述,与代码核对,统计不一致的条数。
// 三个可算的数(每季度)
1 抽查不一致率 > 10% → 机制缺失,优先做生成/校验
2 因文档错误导致的工单数 > 0 → 具体哪几处,优先修
3 文档最后一次更新距今天数 > 90 天 → 该复查(不是过期,是"需确认")
// 用法:第 1 项决定"要不要投入机制",第 2 项决定"先修哪里"
6. 排错:五种"文档不生效"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 按文档做失败 | 数值/结构不同步 | 生成或 CI 校验 |
| 字段含义理解错 | 语义变更未标注 | 改名或加说明 |
| 文档里有废弃接口 | 未清理 | 生成式 + 标记废弃 |
| 多版本文档并存 | 无版本管理 | 文档随代码版本发布 |
| 新人按旧文档做 | 找不到最新 | 单一入口 + 重定向 |
第四项很常见:文档站是"独立部署",而代码发布了文档没发第四项还有一个"入口"问题:同一个 API 有两份文档(一份在文档站、一份在代码注释),于是"哪个是最新"要靠猜——而猜错的成本由使用者承担。
,于是"文档对应的是三周前的代码"。
最后一个"演进"路径:先给"最贵的接口"加机制,再逐步铺开。因为"所有接口都生成文档"是一次大工程,而"三个核心接口 + CI 校验"只需几天——而这三个接口往往承担了 80% 的对接工作。
// 三步走(按投入产出)
1 三个核心接口:生成 OpenAPI + 人工补语义(几天)
2 加 CI 校验:文档数字 vs 代码常量(一天)
3 逐步覆盖:新接口默认生成,老接口按需迁移
// 反例:一开始就要求"全部接口必须生成" → 半年没落地
7. 边界:什么情况不必追求同步
| 场景 | 为什么 | 做法 |
|---|---|---|
| 内部一次性脚本 | 无读者 | 不写文档 |
| 代码即文档(自解释) | 命名清晰 | 只写"为什么" |
| 频繁变动的实验接口 | 文档会立刻过期 | 标"实验性,勿依赖" |
| 已有强类型 SDK | 类型即文档 | 只补充语义 |
最后一条与"成本"有关的边界:同步机制的成本主要在"第一次搭建"(生成管线或 CI 检查),之后的边际成本接近零。所以"值不值"应当按"文档被引用的次数"算,而不是按"搭建工时"算。
判断口径一句话:这段文档会不会被"不读代码的人"用来做决定。会(对接方、前端、运维),就必须有机制;不会(只有作者自己看),文档只是备忘。
8. 自测
- 三种不同步里,哪一种最难查?为什么"字段没变、含义变了"最危险?
- 四种同步机制里,哪一种可靠度最高?它的边界在哪?
- 为什么"生成式文档"不能替代人工?读者来查文档时真正要的是什么?
- "文档里用占位符引用代码常量"与"CI 抽取数字比对"各有什么取舍?
- 语义变更时,为什么"改名"比"加说明"更有效?
- 判断"是否需要机制"的那个问题是什么?