ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

软件工程文档包如何做到版本对齐、自动校验与规范交付

软件工程文档包如何做到版本对齐、自动校验与规范交付

简介:这份压缩包面向软件工程课程设计的学习者与初学者,围绕需求分析、概要设计、详细设计、编码实现、测试与项目管理等环节,提供可直接参考的文档范例与配套素材,帮助梳理完整开发生命周期的文档脉络。包内共141个文件,以doc需求分析、概要设计和详细设计说明等文档为主,并含html、php、css等页面与脚本文件、jpg/png/gif界面截图及mid/mp3等多媒体素材,整体约14MB,便于按阶段查阅和借鉴。已有162人学习下载,适合作为课程作业、项目实训和文档写作的对照材料。读者可从中了解各阶段文档应包含的章节结构、描述粒度与交付形式,也能结合页面和数据库相关文件体会设计文档与实现之间的对应关系,进而提升文档规范意识、协作沟通能力和项目实践能力。

1. 一个叫「软件工程各种文档」的压缩包,八成解压完就没人再打开

接手一个没写完的项目,第一件事一般是索要文档。对方很配合,发来一个压缩包,名字就叫「软件工程各种文档下载.rar」。解压出来二十几个 doc:需求说明书有「最终版」和「最终版-改」两份,设计文档里画的模块在代码里根本找不到,测试报告只写了一句“测试通过”。这种软件工程文档包的共同点是数量够、格式齐、评审能交差,却没有一份能回答“这个模块为什么这么拆”“这条需求谁验收、验什么”。

真正有用的文档包不是数量堆出来的,它应该像代码一样有目录结构、有版本、有一致性检查、能被一条命令重新构建出来。这篇讲的就是怎么把一堆散落的 Word 变成这样一份东西:哪些文档必须写、每份写到什么颗粒度、怎么跟代码放在同一个仓库里构建、怎么用脚本把“文档和代码对不上”这种反复出现的问题按住,以及打包交付时那几个最容易出错的点。适合被评审追着要文档的开发、要接手别人系统的维护者,还有想把文档流程固定下来的小团队。

2. 全生命周期文档清单:每份文档只回答三个问题

文档该不该写,判断标准不是“评审要不要”,而是“有没有人会在某个具体时刻翻开它”。我一般按读者分格子:决策者关心范围、成本和风险;开发者关心接口、数据结构和异常路径;测试关心输入输出的边界;运维关心部署、配置和回滚;验收方关心“做到什么程度算完成”。一份文档如果同时想服务这五类人,结果就是谁都不满意。

所以每份文档我要求它只回答三个问题,写在文档第一页的说明区里。回答不了,说明这份文档还没想清楚要干什么,先别动笔。

文档主要读者必须回答的三个问题合理篇幅
需求规格说明书开发、测试、验收方系统要做什么、不做什么、怎么判定做完了按需求条目计,不按页数
概要设计开发、架构评审模块怎么分、模块间靠什么契约通信、为什么不用另一种拆法10~25 页
详细设计开发、维护者关键流程的状态怎么流转、表怎么建、异常走哪条路按模块写,单模块 5~15 页
测试计划与用例测试、验收方测什么、不测什么、每条用例对应哪条需求用例表 + 一页策略说明
测试报告验收方、维护者通过率多少、残留缺陷是什么、风险谁承担5~10 页
部署与运维手册运维、接手的人怎么装、配置项有哪些、出问题按什么顺序查越具体越好
验收单验收方逐条对照需求签字、不通过怎么处理1~3 页

这张表的价值在于它规定了“不写什么”。可行性研究报告里的市场分析、概设里大段的“系统采用先进的三层架构”这类话,除了占页数没有别的用处。评审老师翻到这种段落通常直接跳过,然后在你真正想让人看的那段(比如为什么订单状态机要引入中间态)上停留三秒。

2.1 需求类:把“用户说要快”写成可验证的条目

需求规格说明书最容易写崩的地方是把用户原话抄进去。用户说“查询要快”,你写“系统应保证查询响应迅速”,这条需求在开发眼里等于没写,在测试眼里等于随便测,在验收会上等于双方各说各话。

我的做法是每条需求强制五列:编号、触发条件、输入、可观测输出、阈值。少一列就不许进基线。

编号触发条件输入可观测输出阈值
REQ-ORD-007用户提交订单,且库存服务响应超过 800 ms订单号、商品快照订单进入“待确认”状态,前端展示排队提示2 s 内返回受理结果,成功率不低于 99.5%
REQ-ORD-012同一用户 60 s 内重复提交相同商品用户 ID、商品 ID第二次请求被合并,返回首次的订单号不产生重复订单记录

“可观测输出”这一列是关键。它逼着写文档的人回答“这个行为从外面看是什么样”,而不是“内部怎么实现”。内部实现属于详细设计,写进需求文档只会让需求变更时多改一处。

编号规则也要提前定死:需求用 REQ-模块缩写-三位序号,模块缩写跟代码仓库里的目录名保持一致。这样一来,“这条需求对应哪部分代码”这个问题,看编号前缀就能猜个大概。需求变更不进原文,另开一份变更记录,写清变更前后、影响哪些需求编号、谁提的、什么时候生效。原文只保留当前有效的版本,历史全部沉到变更记录里。血泪经验是:一旦允许在原文上直接改并加一句“已更新”,三个月后没人说得清当前基线到底是哪一版。

每份需求文档头部放一段元信息,用固定字段,方便后面用脚本批量检查:

--- doc_id: SRS-ORDER-001 title: 订单模块需求规格说明书 version: 1.4.0 owner: 某开发者 status: 已评审 baseline: 2024-06-18 related: [HLD-ORDER-001, TC-ORDER-*] ---

字段说明:doc_id全局唯一,跟文件名解耦,改文件名不影响引用;version用三段式,第三段是措辞修订、第二段是条目增删、第一段是范围变更;status只允许“草稿 / 评审中 / 已评审 / 已废弃”四个值,别自创;baseline是进入基线那天,后面做追溯矩阵就靠它对齐代码 tag。

2.2 设计类:概要设计和详细设计不要混在一份里

很多项目只有一份“设计文档”,前半段画架构图,后半段贴建表语句和一堆伪代码,最后写到一半就烂尾了。问题不在懒,在于这两类内容的读者和变更频率完全不同:概要设计面向评审,一次定版很少改;详细设计面向开发和维护,跟着代码一起变,一个模块一个文件才合理。

概要设计我只保留四块:模块划分与职责边界、模块间的接口契约、数据流向、技术选型的备选方案对比。第四块是最容易被砍掉、也是最该保留的。写“我们用了消息队列”,价值为零;写“这里要在写库后触发下游两个动作,备选是同步调用和消息队列,选后者是因为下游任何一个超时都会拖长主链路响应,代价是下游需要自己做幂等”,这段话在半年后有人想改回同步调用时会救他一命。

接口契约我要求列成表,不写成散文:

调用方被调方方法/主题入参关键字段出参关键字段超时失败处理
订单服务库存服务POST /stock/lockskuId, qty, reqIdlockId, expireAt800 ms重试 1 次后转人工队列
订单服务计费服务topic: order.createdorderId, amount无(异步)不适用进死信表,定时补偿

详细设计按模块拆文件,每个文件回答四件事:关键状态怎么流转、表结构和索引怎么建、异常路径走哪里、容量估算怎么来的。第四项经常被忽略,但它决定了你上线三个月后是扩容还是重构。写容量估算不需要多精确,给个量级推导就够:日订单量 × 峰值系数 × 单条记录大小 × 保留期,得出主表一年的大致体量,再据此决定要不要提前分表。

2.3 测试与验收类:编号对不上,前面全白写

测试计划最容易写成流程说明——“本次测试分为单元测试、集成测试、系统测试三个阶段”。这种内容没人看。有用的是一页策略加一张用例表:测什么、不测什么、为什么这些不测、每条用例对应哪条需求编号。

用例编号规则我固定成 TC-模块-序号,缺陷编号 DEF-模块-序号,两者通过需求编号串联。测试报告的开头不是“测试结论:通过”,而是三行数字:用例总数、通过数、阻塞数,紧接着一段残留风险说明。残留风险这段是测试报告真正的价值所在,写清“哪些场景没覆盖、为什么没覆盖、出问题的影响面多大”,比一个漂亮的通过率有用得多。

验收单单独一份,逐条列需求编号、验收方式(演示/查日志/看数据)、验收人、结论。不通过的那条旁边要写处理方式:修、降级接受、还是移出本期范围。没有这一列,验收会开成辩论会。

3. 用 doc-as-code 把文档放进代码仓库:目录、构建与导出

把文档放进代码仓库,最大的好处不是“方便”,而是版本对齐。代码有 commit,文档有 commit,两者在同一个时间线上,出问题能对照着看。用独立的文档管理系统,代价是你永远不知道线上那版文档对应哪个代码版本。

当然有代价:产品经理不会用 Git,评审方要 Word。所以流程要设计成“源文件在仓库里,交付物由构建产出”,两边都满足。

3.1 目录结构与命名:让文件名自己说清归属

我一般用这样的结构,模块目录名跟代码仓库的模块名一致:

docs/ ├── index.md # 文档包首页,放阅读顺序和当前基线 ├── req/ │ ├── srs-order.md │ └── change-log.md ├── design/ │ ├── hld-order.md │ └── lld-order-state.md ├── test/ │ ├── plan.md │ └── report-1.4.0.md └── ops/ └── deploy.md

命名规则两条:文件名只用小写字母、数字和连字符;禁止出现“最终”“新版”“改2”这类词。版本靠 Git 管,交付时靠构建产物带上版本号,文件名本身不承担版本信息。这条规则看似小题大做,但它直接消灭了“到底哪份是最终版”这个每天都要问一遍的问题。

图片统一放docs/assets/,引用一律用相对路径../assets/xxx.png。绝对路径是后面转换 Word 时图片全丢的头号原因,这个坑在第 5 章还会再提。

3.2 mkdocs 配置:一次配置,评审时直接给链接

内部评审不需要 Word,给个能搜索的网页比发文件高效得多。配置文件大概长这样:

site_name: 某跨平台系统 技术文档 docs_dir: docs site_dir: build/site theme: name: material language: zh nav: - 首页: index.md - 需求: - 需求规格说明书: req/srs-order.md - 变更记录: req/change-log.md - 设计: - 概要设计: design/hld-order.md - 详细设计(订单状态机): design/lld-order-state.md - 测试: - 测试计划: test/plan.md - 运维: - 部署手册: ops/deploy.md markdown_extensions: - tables - footnotes - admonition - toc: permalink: true toc_depth: 3 plugins: - search

参数逐个说:docs_dir决定构建范围,把它指向docs/而不是仓库根目录,能避免把代码里的 README 全抓进来;site_dir指到build/下,加进.gitignore,构建产物不进版本库;nav的顺序就是评审时的阅读顺序,这个顺序值得花十分钟想清楚,它是你引导读者理解系统的唯一机会;toc_depth: 3让右侧目录只到三级标题,再深就变成噪音;tables和footnotes是必需的,需求表和注释都靠它们。

一个需要提前知道的限制:默认的搜索插件对中文是按字切分的,长文档里搜“订单”会召回一堆含“订”或“单”的页面。想让中文检索好用,得额外接分词插件,或者在写标题时就把关键词写全(比如写“订单状态机”而不是“状态机”)。这也是为什么我在前面强调二级标题要具体——它同时服务于人读和机器搜。

3.3 用 pandoc 批量导出 docx:模板、目录与字体

对外交付还是要 Word。手动复制粘贴二十份文档是不可持续的做法,用脚本跑。

先做一份样式模板:让 pandoc 导出一份默认的 reference.docx,然后在 Word 里把“标题 1/标题 2/正文/表格”几个样式的字体、字号、行距改好,存成templates/ref.docx。之后所有导出都复用它,格式统一靠这一份文件保证,不靠人记。

#!/usr/bin/env bash set -euo pipefail SRC_DIR="docs" OUT_DIR="build/docx" REF_DOC="templates/ref.docx" mkdir -p "$OUT_DIR" # 按固定顺序导出,保证每次产出的文件清单一致 find "$SRC_DIR" -name '*.md' | sort | while read -r f; do rel="${f#"$SRC_DIR"/}" out="$OUT_DIR/${rel%.md}.docx" mkdir -p "$(dirname "$out")" pandoc "$f" \ --from gfm+tex_math_dollars \ --reference-doc="$REF_DOC" \ --toc --toc-depth=2 \ --number-sections \ -o "$out" echo "built: $out" done

参数说明:--from gfm让 pandoc 按 GitHub 风格解析,表格和任务列表不会变形;--reference-doc复用刚才那份样式模板;--toc --toc-depth=2生成两级目录;--number-sections自动加章节编号,省掉手写“3.2.1”这种一改就全错的活。set -euo pipefail别省,任何一份文档转换失败都应该让整个构建停下来,而不是默默跳过——跳过的那份往往就是评审要看的。

有一个必须记住的操作细节:pandoc 生成的目录是 Word 域,打开文档时不会自动更新,页码和条目可能显示不全。交付前用 Word 打开,全选后按 F9 更新一次域,或者用脚本调 Word 的 COM 接口批量更新。这一步漏了,评审现场打开文档看到目录里只有一行,很尴尬。

4. 文档和代码对不上,是这类文档包最容易出问题的地方

文档包烂掉通常不是因为没人写,而是因为写完之后没人维护,几个月后接口加了两个字段、删了一个废弃方法,文档还停在旧版本。等到有人照着文档联调,才发现返回结构对不上。这种问题靠“大家记得同步更新”是解决不了的,只能靠机器检查。

4.1 先把接口定义变成唯一事实来源

做法很朴素:接口定义(OpenAPI 之类的描述文件)进仓库,作为唯一事实来源;文档里的接口表由它派生或者至少由它校验;代码里的路由注册也对照它检查。三方对齐之后,任何一方改动都会在检查环节暴露。

关键在于别让文档成为手工维护的第二份接口定义。人写表格必然滞后,只是滞后多久的问题。常见做法是让接口表在构建时自动生成,文档正文只写“为什么这么设计”“什么场景下用哪个接口”这类描述性内容。生成的东西不会撒谎,手写的东西会。

4.2 一个 python 校验脚本:把 markdown 接口表和接口定义逐条比对

如果团队还没做到自动生成,退一步的方案是把文档里的接口表当成“待校验的副本”,用脚本跟描述文件对账。

import re import sys import yaml # 需要 pyyaml SPEC = "api/openapi.yaml" DOC = "docs/design/hld-order.md" METHODS = {"get", "post", "put", "delete", "patch"} def load_spec(path): """从接口描述文件里抽出 (方法, 路径) 到状态的映射""" with open(path, encoding="utf-8") as f: spec = yaml.safe_load(f) rows = {} for route, ops in spec.get("paths", {}).items(): for method, op in ops.items(): if method not in METHODS: continue key = f"{method.upper()} {route}" rows[key] = { "summary": (op.get("summary") or "").strip(), "deprecated": bool(op.get("deprecated", False)), } return rows def load_doc(path): """按约定格式解析文档里的接口表:| GET /x | 说明 | 状态 |""" pattern = re.compile( r"^\|\s*(GET|POST|PUT|DELETE|PATCH)\s+(\S+)\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|\s*$" ) rows = {} with open(path, encoding="utf-8") as f: for line in f: m = pattern.match(line.strip()) if not m: continue method, route, summary, flag = m.groups() rows[f"{method} {route}"] = { "summary": summary, "deprecated": "废弃" in flag, } return rows def main(): spec, doc = load_spec(SPEC), load_doc(DOC) problems = [] for key in sorted(spec.keys() - doc.keys()): problems.append(f"[文档缺失] {key}") for key in sorted(doc.keys() - spec.keys()): problems.append(f"[文档多余] {key} 接口描述里已经没有了") for key in sorted(spec.keys() & doc.keys()): if spec[key]["deprecated"] != doc[key]["deprecated"]: problems.append(f"[状态不一致] {key} 描述文件={spec[key]['deprecated']} 文档={doc[key]['deprecated']}") if problems: print("\n".join(problems)) sys.exit(1) # 非零退出码,让流水线直接红 print(f"接口表与描述文件一致,共 {len(spec)} 条") if __name__ == "__main__": main()

逻辑说明:先用集合的三个运算把问题分类——spec - doc是漏写的接口,doc - spec是文档里残留的已删接口,交集部分只比deprecated状态,因为摘要文案允许有措辞差异,人为去比字符串只会天天误报。最后用退出码 1 把检查结果升级成构建失败,这一步是整个方案能生效的前提;只打印警告的检查脚本,运行一周后就没人看了。

参数上需要调整的只有两个:SPEC指向你们的接口描述文件,DOC指向正文里的接口表所在文件。如果接口表分散在多个文件,把DOC改成目录遍历即可。正则那条要求表格严格三列,这个约束本身就是收益——以前大家各写各的,五列表六列表混着来,现在格式统一了,脚本能读,人也能扫。

4.3 挂到提交钩子或流水线:让不一致在合并前就变红

脚本单独跑没意义,得挂在必经之路上。最轻的做法是本地钩子:

#!/usr/bin/env bash # .git/hooks/pre-commit set -e python scripts/check_api_doc.py python scripts/check_meta.py # 检查每份文档的元信息字段是否齐全

更稳的做法是放在流水线的第一个作业里,本地钩子可以被--no-verify绕过,流水线绕不过。钩子的价值在于反馈速度——提交时就知道错了,比推上去等三分钟看红叉的修复成本低得多。

这里有个取舍要想清楚:校验太严会挡住紧急修复,团队会开始想办法绕过;校验太松等于没有。我的经验是只校验“机器能百分百判定的东西”——接口是否存在、废弃状态是否一致、元信息字段是否缺失。至于“这段设计描述是否合理”,交给评审,别交给脚本。

5. 打包交付前的避坑清单:五类常见问题与排查顺序

到这一步文档都写完了,构建也通过了,最后打成压缩包发出去。真正出问题的往往就是这一步。下面五条是我遇到过、并且反复在别人身上看到的。

5.1 解压后中文文件名变成乱码

现象:自己机器上解压一切正常,对方(尤其是另一套操作系统)解压出来一堆问号和方块,文件名全部不可读。

原因:压缩时文件名的编码跟解压端预期的不一致。有些打包工具在不同系统上默认编码不同,压缩包里没有标记,解压端只能猜。

解决:打包脚本里显式指定编码,或者干脆走一条更保险的路——压缩包内文件名全部用 ASCII(srs-order-1.4.0.docx),中文标题只出现在文档内部和一份index.md索引表里。索引表列文件名、中文标题、对应需求编号,人看索引,机器看文件名。这个做法看着笨,但跨系统交付从来没出过错。

5.2 图片在 Word 里全部变成红叉

现象:Markdown 里图片显示正常,导出的 docx 打开全是裂图。

原因:Markdown 里用了绝对路径(/Users/xxx/docs/assets/a.png)或者带协议头的路径。pandoc 转换时按相对路径找资源,找不到就留个空引用。

解决:全部改成相对路径,并且在导出脚本里加--resource-path指定资源根目录。转换完抽查三份文档,专门看图片最多的那份。另外注意文件名大小写,本地不区分大小写的文件系统上跑的通的路径,到区分大小写的构建机上就断了。

5.3 文档里写的版本号和实际发布的 tag 对不上

现象:交付包封面写着 1.4.0,里面测试报告写的是 1.3.2,部署手册里的配置项跟当前环境差了两个。

原因:版本号在多个文件里手工维护,改的时候漏了一处。

解决:版本号只允许出现在一个地方——构建时注入。文档正文里写占位符或者干脆不写版本,构建脚本读取 Git tag 或者一份VERSION文件,统一替换。第 6 章会给出具体做法。

5.4 导出的 Word 目录空白、页码错乱

现象:打开文档,目录只有一行“目录”两个字,下面什么都没有;或者页码从第三页才开始。

原因:pandoc 生成的目录和页码是 Word 域,需要打开后更新才显示;页码问题多半是分节符或者样式模板里的“首页不同”设置被带进来了。

解决:交付前统一跑一遍域更新;样式模板单独维护并在每次改动后回归三份文档;目录深度固定为二级,太深的目录在 Word 里排版必然乱。这条属于玄学范畴,唯一可靠的验证方式是拿最终产物在目标机器上真的打开看一遍,别信转换脚本的“成功”输出。

5.5 压缩包里混进了草稿、内部批注和版本库目录

现象:交付后对方问“你们文档里怎么有 TODO 和一段骂人的批注”,或者包体积突然从 20 MB 涨到 300 MB。

原因:直接用“右键压缩整个文件夹”,把.git/、node_modules/、build/、草稿目录全带进去了。Word 里的批注和修订记录也会随文件一起走。

解决:打包用白名单,不用黑名单。脚本里明确列出要打进去的目录和文件类型,其他一律不碰。Word 输出前在样式模板层面关掉修订,或者在导出后跑一遍清理。这条最省事的做法是把打包也写成脚本,跟构建串在一条命令里,人只需要执行,不需要决策。

6. 把版本号和项目名从正文里抽走:让文档包下次还能用

文档包真正的成本不在第一次写,在于第二个项目开始时你要重新改一遍。所以我习惯把会变的量全部抽成变量,正文里只留占位符,构建时注入。

变量取值来源出现位置
{{PROJECT}}仓库根目录project.env封面、页眉、正文首段
{{VERSION}}git describe --tags或VERSION文件封面、测试报告抬头、部署手册
{{DATE}}构建当天封面、变更记录最后一行
{{BASELINE}}需求基线日期需求文档元信息

注入用一个十几行的脚本就够:读模板、替换、写到build/下的临时目录、再交给 pandoc。关键是源文件里不许出现具体版本号,一旦允许“就这一处手写没关系”,半年后就会有七处手写版本号互相打架。

配套一条命令收口整个流程:

#!/usr/bin/env bash # Makefile 里的 docs 目标,本地和流水线共用同一套 set -euo pipefail make render # 变量注入到 build/src make docx # pandoc 导出 Word make site # mkdocs 构建网页版 python scripts/check_api_doc.py python scripts/check_meta.py make package # 白名单打包,产物带 tag

每次发版前跑一遍,然后看git status——如果构建过程修改了源文件,说明有地方还在手写变量,得回去改模板。这个检查我坚持了很久,它抓出过至少三次“临时手改”留下的隐患。

说个自己的教训。早期我图省事,在一个项目的验收文档里手改了版本号,只改了一处,另一处测试报告忘了动。验收会上对方翻到那页,问“你们到底交付的是哪个版本”,后面半小时全花在解释上。从那以后我给自己定了条规矩:凡是构建能生成的,一个字都不手写;凡是手写的,必须有脚本能校验。文档这件事,靠自觉是靠不住的,靠流程才靠得住。

希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进