技术文档版本管理:把文档当代码维护的工程实践
一、文档与代码脱节:那个永远过时的 README
每个项目都有个 README,每个 README 都大概率过时。代码改了一版又一版,文档还停留在半年前的描述。新人按文档配置环境,报错一片。老人早就不用文档了,靠口口相传和翻代码。
这背后是文档与代码的"双轨制"。代码在 Git 里,有 PR、有 review、有 CI。文档在 Confluence 或语雀里,改了没人知道,错了一直错。两条线天然不同步。
更深层的问题是文档没有"变更联动"。改代码的人不必改文档,改文档的人未必懂代码。PR 合了,文档还在原地。等发现文档错时,已经不知道是哪次改动导致的。
Docs as Code 是解决这个的思路。把文档当代码一样管理:同仓库、同 PR、同 review、同 CI。代码改动必须连带文档改动。文档的版本与代码的版本绑定在一起。
出问题能追溯,改动能审计。但 Docs as Code 不只是"把 md 放进 Git"这么简单。要解决文档的 CI 检查:链接是否还有效,术语是否一致,API 是否对得上。要解决文档与代码的强绑定:哪个 PR 改了代码却没改文档,要能拦下来。
要解决多版本文档的并存:v1 和 v2 的文档要能同时维护。本文探讨把文档当代码维护的工程方案。
二、Docs as Code 的机制:同仓库、同 PR、同 CI
Docs as Code 的核心是"四个同"。同仓库:文档与代码住一个 Git 仓库。同 PR:代码改动与文档改动在同一个 PR 里提交。同 review:文档也要经过 review,不能凑数。同 CI:文档变更触发检查,不通过则阻断合并。
同仓库带来版本绑定。代码的某个 commit,对应的文档就是同 commit 下的文档。回溯某版本代码的行为,直接查那个 commit 的文档即可。不会出现"代码是 v2,文档还停在 v1"的错位。
同 PR带来变更联动。规定改代码的 PR 必须连带改文档。review 时文档与代码一起看,逻辑是否一致。CI 检查 PR 是否触碰了文档目录。
改了代码没改文档,要么说明不需要改,要么就是漏改。
同 CI带来自动校验。检查文档里的链接是否还有效,避免指向已删除的页面。检查术语是否一致,避免同一个概念叫三种名字。检查 API 文档与代码签名是否对得上,避免参数名漂移。
检查通过才能合并,把问题挡在合并前。
版本绑定解决多版本并存。通过分支或子目录维护不同版本的文档。v1 分支对应 v1 文档,v2 分支对应 v2 文档。发布时一并发布对应版本,不互相覆盖。
整体机制如下:
flowchart LR A[代码改动] --> B[同 PR 改文档] B --> C[Review 文档与代码] C --> D[CI 检查] D -->|链接有效| E[术语一致] D -->|API 对齐| F[签名匹配] E --> G{全通过?} F --> G G -->|是| H[合并并发布] G -->|否| I[阻断合并] style I fill:#ffebee style H fill:#e8f5e9关键在"自动化拦截"。靠人工记得改文档,注定会漏。靠 reviewer 盯着文档,效率低且不可靠。CI 把规则固化下来,每次 PR 都强制跑一遍。漏改的文档在合并前就被拦住。
三、生产级实现:文档 CI 检查器
下面用 Python 实现一个文档 CI 检查器。检查链接有效性与术语一致性,含错误聚合与退出码。
import re import sys from dataclasses import dataclass, field from pathlib import Path @dataclass class CheckResult: """单条检查结果:文件、行号、问题、级别""" file: str line: int issue: str level: str = "error" # error 阻断合并,warning 仅提示 @dataclass class TermDict: """术语词典:规范名与别名的映射""" canonical: dict[str, list[str]] = field(default_factory=dict) def add(self, canonical: str, aliases: list[str]) -> None: # 别名统一映射到规范名,避免一个概念多种写法 self.canonical[canonical] = aliases def violations(self, text: str) -> list[tuple[str, str]]: found = [] for canon, aliases in self.canonical.items(): for alias in aliases: if re.search(rf"\b{re.escape(alias)}\b", text): # 命中别名即视为违规,提示改用规范名 found.append((alias, canon)) return found class DocCI: """文档 CI 检查器:链接、术语、退出码聚合""" def __init__(self, root: Path, terms: TermDict) -> None: self.root = root self.terms = terms self.results: list[CheckResult] = [] def check_links(self, md_path: Path) -> None: """检查 Markdown 内的相对链接是否指向真实存在的文件""" text = md_path.read_text(encoding="utf-8") # 匹配 [text](path) 形态的相对链接,排除 http 链接 for m in re.finditer(r"\[([^\]]+)\]\(([^)]+)\)", text): link = m.group(2).split("#")[0] if link.startswith("http") or not link: continue target = (md_path.parent / link).resolve() if not target.exists(): # 行号用于 PR 评论里精确定位 line = text[: m.start()].count("\n") + 1 self.results.append( CheckResult(str(md_path), line, f"死链: {link}") ) def check_terms(self, md_path: Path) -> None: """检查术语是否使用了规范名而非别名""" text = md_path.read_text(encoding="utf-8") for alias, canon in self.terms.violations(text): # 多次出现的同一别名只在第一次记录,避免噪声 self.results.append( CheckResult( str(md_path), 0, f"术语 '{alias}' 应改用规范名 '{canon}'", level="warning", ) ) def run(self) -> int: """跑全部检查,返回退出码:0 通过,1 有 error""" md_files = list(self.root.rglob("*.md")) for f in md_files: try: self.check_links(f) self.check_terms(f) except Exception as e: # 单文件检查失败不阻断其他文件 self.results.append( CheckResult(str(f), 0, f"检查异常: {e}", level="warning") ) errors = [r for r in self.results if r.level == "error"] for r in self.results: tag = "ERR" if r.level == "error" else "WARN" print(f"[{tag}] {r.file}:{r.line} {r.issue}") print(f"\n总计 {len(self.results)} 项,其中 {len(errors)} 项 error") return 1 if errors else 0 if __name__ == "__main__": # 术语词典:规范名 -> 别名列表 terms = TermDict() terms.add("PostgreSQL", ["postgres", "Postgres"]) terms.add("Kubernetes", ["k8s", "K8s"]) terms.add("LLM", ["llm", "大模型"]) root = Path(sys.argv[1] if len(sys.argv) > 1 else "docs") ci = DocCI(root, terms) sys.exit(ci.run())真实工程会在这之上扩展。链接检查支持跳过外部链接或带超时重试。术语检查用 AST 而非正则,避免代码块里的误报。API 文档与代码签名对比,用解析器提取双方签名做 diff。结果输出为 SARIF 或 GitHub Annotation,直接在 PR 上标红。
四、技术文档版本管理的代价与边界
Docs as Code 解决了一类问题,也带来新的负担。
维护成本上升。每次改代码都要想"文档要不要改"。PR 体量变大,review 时间变长。团队若没有文档文化,会把文档当成"凑数"应付。
流程会形同虚设。
自动化的局限。链接和术语能机器查,语义对不对查不出来。API 签名能对比,但"用法说明是否准确"机器读不懂。自动检查只能兜底,不能替代人工 review。
评审负担。reviewer 既要懂代码又要懂文档写作。不是所有工程师都擅长写文档。可能变成"文档没人认真 review,CI 绿了就合"。
质量依然参差。
多版本维护。同时维护 v1、v2 文档,backport 成本高。文档的 bugfix 要同步到多个分支。版本越多,维护越累。
Docs as Code 的"落地节奏"很关键。一上来就强卡 CI,团队会反弹,文档质量反而更差。建议先从"鼓励同 PR 改文档"开始,配套模板和示例,等团队习惯后再加 CI 拦截。另一个常被忽视的点是"文档的可测试性":示例代码如果能作为可执行测试跑一遍,就能避免"文档里的代码跑不起来"这种最尴尬的情况。最后,文档 CI 的告警要分级,死链是 error,术语不一致是 warning,别把所有问题都设成阻断,否则团队会想办法绕过检查而非真正修复。
五、总结
技术文档版本管理的本质,是让文档与代码同频演进。机制上靠同仓库、同 PR、同 review、同 CI 的四同绑定。工程上靠自动化检查守住链接、术语、签名的底线。落地路线:先把文档迁入代码仓库;约定 PR 必须连带文档改动;上 CI 检查死链与术语;逐步加 API 签名对比;最后做多版本文档的分支维护。文档不是代码的附属品,而是代码的一部分。