monorepo 接口文档别每次 commit 都全量生成,我们踩过的坑和现在的触发策略
接口文档生成的触发策略,说到底是“找对依赖关系”。只要把变更范围精确到具体服务,你就能只生成变动的那个服务的文档,而不是每次都全量重跑。
我们在一个 pnpm workspace 管理的 monorepo 里维护了 14 个后端服务,每个服务独立部署、有自己的 OpenAPI 文档。最初,只要有人改了某个服务的 controller 代码,CI 就全量扫描所有服务、重新生成 14 份文档。听起来没什么大问题,但当某个服务需要解析 60 多个 protobuf 文件、编译 300 多个类型定义时,一次全量文档生成能跑 8 分钟以上。PR 一多,CI 队列直接堵死。
问题出在触发条件写得太粗暴,而真正要解决的,是两件事:用声明式依赖图精确描述“谁依赖谁”,以及用文件指纹决定“要不要跑”。
我们当前的策略经历了三轮迭代,最终稳定在一个不需要额外工具的方案上。
第一版:全量生成,靠人力排队
最初用的脚本逻辑特别直接:
# !/bin/bash
# 对所有服务目录循环执行文档生成命令
for service_dir in ./services/*/; do
pnpm --filter "$(basename $service_dir)" run generate:docs
done
在 GitHub Actions 里,这个步骤的触发条件是 paths: ['services/**'],只要 services/ 下面有文件变更就触发。CI 时间从最初的 3 分钟慢慢涨到 8 分钟,因为服务越来越多、proto 文件越来越复杂。到第 9 个服务接入时,单次文档生成步骤的耗时已经到了 11 分钟 30 秒,而我们设置的整个 CI pipeline 超时时间是 15 分钟,直接逼近红线。
更恶心的是,每次改一个服务的 API,要等所有服务的文档都跑完才能看到结果。团队开始自发地在本地先跑一遍文档生成、再 push,等于把 CI 的负担转移到了本地开发环境。
第一版踩的最大的坑,是 paths 过滤的粒度太粗。services/** 匹配了所有服务目录,但它没办法区分“改了 A 服务”和“改了 B 服务”。GitHub Actions 的 paths-filter 能告诉你哪些目录变了,但它不能自动映射到“哪些服务需要重新生成文档”这个结论上。
第二版:按服务目录拆分 workflow,人工维护映射表
为了解决粒度问题,我们试着给每个服务单独建一个 workflow 文件,各自监听自己的路径:
# .github/workflows/docs-service-a.yml
name: Generate Docs - Service A
on:
push:
paths:
- 'services/service-a/src/**'
- 'proto/service-a/**'
- 'packages/shared-types/src/**'
14 个服务就是 14 个 workflow 文件,每个文件里硬编码了该服务依赖的路径列表。效果立竿见影:改 service-a 的代码,只有 service-a 的文档重新生成,CI 时间从 11 分钟降到了单服务最长 2 分 40 秒。
但维护成本很快就失控了。shared-types 这个包被 9 个服务依赖,每次它的类型定义变动,理论上所有 9 个服务的文档都应该重新生成。可 paths 列表是手动写的,开发者经常忘记更新。有一次 shared-types 里改了一个枚举值,只有 3 个服务的文档被触发更新,另外 6 个服务的文档里还保留着旧枚举值。API 消费者拿到的是过时文档,线上对接出了两次乌龙。
这个版本的教训是:依赖关系不能靠人肉维护。当 shared-types 这种公共包被多个服务依赖时,必须有一种自动化的方式把“改了什么”翻译成“谁该重新生成”。
第三版(当前策略):基于依赖图的差分触发
我们最终放弃了自己造轮子,直接用了 Turborepo 的依赖感知能力。monorepo 本身就已经用 Turborepo 管理构建,它的 --filter 和依赖图机制正好能解决我们的问题。
核心思路分三步:
1. 在 turbo.json 里声明文档生成任务及其依赖
{
"pipeline": {
"generate:docs": {
"dependsOn": ["^build"],
"inputs": ["src/**", "proto/**"],
"outputs": ["docs/**"]
}
}
}
每个服务的 package.json 里都有一个 generate:docs 脚本,Turborepo 通过 dependsOn: ["^build"] 知道:如果某个服务的上游依赖发生了变更,该服务的文档生成任务也需要重新执行。inputs 进一步限定了哪些文件变更才算“有意义的变更”——只有 src/ 和 proto/ 下的文件变动才触发,README.md 改了不算。
2. 在 CI 里用 Turborepo 的 --filter 做增量范围计算
# 只对受本次变更影响的服务执行文档生成
pnpm turbo run generate:docs --filter="...[origin/main...HEAD]"
[origin/main...HEAD] 是 Turborepo 的 diff 语法,它会比较当前分支和 main 分支的差异,根据 turbo.json 里声明的依赖图,自动计算出受影响的服务范围。如果只改了 shared-types 包里的一个类型文件,Turborepo 会自动找出所有直接或间接依赖 shared-types 的服务,只对这些服务执行 generate:docs。
3. 用 Turborepo 的远程缓存避免重复计算
pnpm turbo run generate:docs --filter="...[origin/main...HEAD]" --cache-dir=".turbo"
Turborepo 会根据 inputs 里定义的文件指纹计算缓存 key。如果 shared-types 的改动没有影响到某个服务的实际文档生成结果(比如改的字段该服务根本没用到),Turborepo 直接跳过这个服务,从缓存恢复上一次的输出。在我们的实际运行数据里,缓存命中率大约 72%,意味着 10 次文档生成中有 7 次是直接读缓存完成的,实际重跑的只有 3 次。
切换到这套策略后,我们实测的数据是这样的:同样一个改动 shared-types 里枚举值的 PR,之前全量生成要跑 11 分钟,现在 Turborepo 计算出 9 个受影响的服务,但其中有 6 个命中了缓存,实际只跑了 3 个服务,总耗时 1 分 12 秒。
不是所有 monorepo 都适合 Turborepo,关键是“声明依赖”这个思路
Turborepo 确实好用,但如果你不用它,核心思路依然适用。我们自己内部还有一个更老的项目,用 Nx 做构建管理,它的策略几乎一模一样:
nx affected --target=generate-docs --base=origin/main --head=HEAD
Nx 的 affected 命令同样能根据依赖图自动算出受影响的项目范围。即使你既不用 Turborepo 也不用 Nx,用 pnpm 原生的 --filter 也能做到一部分:
# 找出所有依赖 changed-package 的服务
pnpm --filter="...changed-package" list --depth=-1 --json
拿到服务列表后,循环执行文档生成。虽然不如 Turborepo/Nx 那样有缓存机制,但至少能把范围从“所有服务”缩小到“受影响的服务”。
真正关键的,不是用哪个工具,而是你能不能把依赖关系声明出来而不是藏在脚本里。只要每个服务的 package.json 正确声明了它依赖的内部包,构建系统就能推导出“改 A 包会影响到 B、C、D 三个服务”这个结论。剩下的,只是用什么语法把这个结论喂给 CI 执行而已。
常见问题
Q:为什么不用 GitHub Actions 的 paths-filter 配合条件判断,而要引入 Turborepo?
paths-filter 只能告诉你哪些文件目录变了,但没法告诉你“改了这个目录会影响哪些服务”。比如你改了 packages/shared-types/src/enums.ts,paths-filter 能识别出 packages/shared-types 有变更,但它不知道 service-a、service-b、service-c 都依赖这个包。依赖关系推导必须由懂 monorepo 依赖图的工具来做,paths-filter 不具备这个能力。
Q:Turborepo 的缓存 key 是怎么计算的,会不会出现缓存误命中?
Turborepo 的缓存 key 由任务名称、inputs 定义的文件内容哈希、以及上游依赖的哈希共同决定。如果 inputs 里配置了 ["src/**", "proto/**"],它会对这些文件的内容做 SHA-256,任何字节级别的变动都会产生不同的 key。在我们的实践中还没遇到过误命中,但如果你担心,可以在 turbo.json 里把 inputs 配得更细,比如加上特定的配置文件路径。
Q:如果不想引入额外工具,纯脚本方案能做到什么程度?
用 pnpm list --depth=-1 --json 可以拿到完整的依赖图,配合 git diff --name-only origin/main...HEAD 获取变更文件列表,写一个 Node.js 脚本把两者关联起来,能实现“找出受影响的服务”这一步。但缓存、并行执行、增量构建这些特性就需要自己实现了,维护成本会随着服务数量线性增长。14 个服务是我们在纯脚本方案下的痛点临界点,超过这个数量建议直接上 Turborepo 或 Nx。