
1. Spec 系统到底在解决什么问题第一次接触“Spec 系统”这个概念是在一个前后端分离的中型项目里。当时团队有 6 个人接口文档散落在三个地方后端 Swagger 上有一份、前端 TypeScript 类型定义里有一份、产品经理的 PRD 里还有一份。每次改一个字段三处都要同步漏掉一处就是线上事故。最夸张的一次后端把userId改成了user_id前端没跟上联调当天直接卡了四个小时。后来我们引入了一套以 Spec 文件为核心的协作方式把接口定义、数据模型、校验规则全部收敛到一个 YAML 文件里前后端都从这个文件生成各自的代码。那次改造之后类似的字段不一致问题基本归零。这就是 Spec 系统的核心价值——它是能力的“单一真相源”Source of Truth。所谓“单一真相源”说白了就是一个系统里任何一项能力、一个接口、一个数据结构只允许有一个权威定义的地方其他所有地方要么引用它要么从它派生绝不允许各自维护一份。这个原则在 DDD领域驱动设计里其实早有体现——聚合根是领域状态的唯一入口值对象是不可变的本质上都是在收敛“真相”的存放位置。Spec 系统适合谁来参考如果你正在经历下面任何一种情况那这套东西对你就有直接价值前后端接口频繁对不齐联调成本高微服务之间靠口头约定或聊天记录同步契约测试用例和实际接口行为脱节回归测试形同虚设文档写完就过期没人愿意维护它不挑语言、不挑框架Java、Go、TypeScript、Python 都能用核心思路是通用的。下面我按自己实际落地的经验把整套东西拆开讲。2. Spec 系统的整体设计与核心思路2.1 为什么是“单一真相源”而不是“统一文档”很多人第一反应是那我搞一个统一的文档平台不就行了Confluence 或者飞书文档集中管理。这个思路的问题在于文档是给人看的不是给机器用的。人可以读文档但编译器、代码生成器、测试框架读不了。只要文档和代码之间还需要人工翻译就一定会出现偏差。Spec 系统的关键区别在于Spec 文件本身是结构化的、机器可读的。它通常用 YAML 或 JSON 编写带有严格的 schema 约束。这意味着它可以同时服务于三个消费者消费者用途派生方式后端生成 Controller 骨架、DTO、校验逻辑代码生成器前端生成 TypeScript 类型、API 调用函数代码生成器测试生成契约测试用例、Mock 数据测试框架插件一份 Spec三处消费任何一方要改都得先改 Spec然后重新生成。这就从流程上杜绝了“各自维护一份”的可能。2.2 Spec 文件的结构设计frontmatter 与正文的分工Spec 文件通常采用 Markdown frontmatter 的形式。frontmatter 是文件顶部用---包裹的 YAML 块存放机器可读的元数据正文部分则是给人看的说明文档。这种设计的好处是同一份文件既能被工具解析又能被人阅读不需要维护两套东西。一个典型的 Spec 文件长这样--- spec_version: 1.2.0 domain: user capability: createUser method: POST path: /api/v1/users request: body: type: object required: [username, email] properties: username: type: string minLength: 3 maxLength: 32 email: type: string format: email response: 201: type: object properties: userId: type: string createdAt: type: string format: date-time --- ## createUser 能力说明 该能力用于创建一个新用户。调用方 SHALL 提供唯一的 username 和合法的 email。 若 username 已存在服务端 SHALL 返回 409 冲突错误。这里有几个关键点值得展开说。第一spec_version字段。这是整个 Spec 系统的版本锚点。每次 Spec 发生不兼容变更这个版本号必须递增。工具链在生成代码时会校验版本防止旧版本生成的代码和新 Spec 混用。我踩过的坑是早期没加这个字段结果前端拿着两周前生成的类型去对接新接口报了一堆莫名其妙的错排查了半天才发现是版本错位。第二SHALL这个词的用法。这是从需求工程规范里借来的关键词表示强制性要求。与之对应的还有SHOULD建议、MAY可选。在 Spec 正文里用这些词能让契约的约束力变得明确。比如“调用方 SHALL 提供唯一 username”意思是这是硬性要求不满足就是调用方的错而“服务端 SHOULD 在 100ms 内返回”意思是建议但不强制。这种区分在跨团队协作时特别有用能减少“这到底算不算 bug”的扯皮。2.3 与 DDD 的配合Spec 是能力的边界描述DDD 强调限界上下文Bounded Context每个上下文有自己独立的领域模型。Spec 系统天然适合作为限界上下文之间的契约层。一个上下文对外暴露的能力全部用 Spec 描述上下文内部怎么实现Spec 不关心。这样做的好处是上下文之间的依赖被显式化了。以前 A 服务调用 B 服务可能直接读 B 的数据库或者依赖 B 的某个内部接口耦合很深。现在 A 只能通过 B 发布的 Spec 来调用Spec 里没写的能力A 就用不了。这从架构层面强制了边界。我在一个订单系统里实践过这套东西。订单上下文需要调用用户上下文查询用户等级我们就在用户上下文的 Spec 里定义了一个getUserLevel能力订单侧只能通过这个能力拿数据。后来用户上下文内部把等级计算逻辑从实时计算改成了缓存订单侧完全无感知因为 Spec 没变。3. 核心细节解析与实操要点3.1 Spec 文件的命名与组织规范Spec 文件怎么放、怎么命名直接决定了后期维护的成本。我试过几种方案最后稳定下来的做法是按领域 能力两级目录组织specs/ user/ createUser.spec.md getUserLevel.spec.md updateProfile.spec.md order/ createOrder.spec.md cancelOrder.spec.md文件名用驼峰和capability字段保持一致。这样做的原因是当你要找某个能力的定义时路径是可预测的不需要全局搜索。工具链也能按目录批量扫描生成对应的代码模块。注意不要按 HTTP 方法或 URL 路径来组织目录。我早期按post/、get/分目录结果一个能力的读写操作被拆到两个地方改的时候容易漏。按领域和能力组织一个能力的完整定义始终在一起。3.2 请求与响应定义的粒度控制Spec 里定义请求响应最容易犯的错是定义得太细或太粗。定义太细的典型表现把每个字段的数据库列类型、索引信息都写进去。这是把实现细节泄漏到了契约层。契约应该只描述外部可见的行为不描述内部怎么存。定义太粗的典型表现只写body: object不写具体字段。这等于没定义工具链生成不出有用的代码。我的经验是抓住三个层次必填/可选用required数组明确列出这是最容易被忽略但最重要的信息类型与格式type给基础类型format给语义类型email、date-time、uuid 等约束条件minLength、maxLength、minimum、maximum、pattern这些校验规则至于字段的业务含义放在正文里用自然语言描述不要塞进 frontmatter。frontmatter 保持精简只放机器需要的东西。3.3 版本演进与兼容性处理Spec 的版本管理是整套系统里最需要谨慎对待的部分。我总结了一个简单的判断规则变更类型是否兼容版本号处理新增可选字段兼容次版本号 1新增必填字段不兼容主版本号 1删除字段不兼容主版本号 1修改字段类型不兼容主版本号 1放宽约束如 minLength 变小兼容次版本号 1收紧约束不兼容主版本号 1不兼容变更时不要直接改原 Spec而是新建一个版本目录比如specs/user/v2/createUser.spec.md旧版本保留一段时间给调用方迁移窗口。这个窗口期我一般留两周同时在旧版本的 Spec 正文顶部加一个醒目的废弃提示。提示invalid version spec这类报错十有八九是版本号格式不对或者版本目录不存在。检查spec_version是否符合语义化版本规范主.次.补丁以及工具链配置的版本路径是否正确。3.4 工具链选型自研还是用现成的Spec 系统的工具链包括三部分解析器、代码生成器、校验器。市面上有一些开源方案可以直接用但我的建议是解析器和校验器用现成的代码生成器自研。原因是解析和校验是通用逻辑开源方案已经做得很成熟没必要重复造轮子。但代码生成器高度依赖你团队的技术栈和代码风格用现成的往往要迁就它的模板改起来比自己写还麻烦。自研一个代码生成器核心逻辑其实不复杂读 Spec 文件遍历字段按模板输出代码。我用 Node.js 写过一个核心代码不到 300 行覆盖了 TypeScript 类型生成和 API 调用函数生成。4. 实操过程与核心环节实现4.1 从零搭建 Spec 系统的完整步骤下面是我在一个新项目里从零搭建 Spec 系统的实际流程按顺序执行即可。第一步确定 Spec 文件的 schema。先定义好 frontmatter 里允许出现哪些字段每个字段的类型和约束是什么。这一步用 JSON Schema 来描述后续校验器直接加载这个 schema。{ $schema: http://json-schema.org/draft-07/schema#, type: object, required: [spec_version, domain, capability, method, path], properties: { spec_version: { type: string, pattern: ^\\d\\.\\d\\.\\d$ }, domain: { type: string }, capability: { type: string }, method: { enum: [GET, POST, PUT, DELETE, PATCH] }, path: { type: string, pattern: ^/ } } }第二步编写解析器。解析器负责读取 Spec 文件分离 frontmatter 和正文把 frontmatter 解析成 JavaScript 对象。用gray-matter这个库可以一行搞定const matter require(gray-matter); const fs require(fs); function parseSpec(filePath) { const raw fs.readFileSync(filePath, utf-8); const { data, content } matter(raw); return { meta: data, doc: content }; }第三步编写校验器。加载第一步定义的 JSON Schema用ajv校验解析出来的 frontmatterconst Ajv require(ajv); const ajv new Ajv(); function validateSpec(meta, schema) { const validate ajv.compile(schema); const valid validate(meta); if (!valid) { throw new Error(Spec 校验失败: ${JSON.stringify(validate.errors)}); } }第四步编写代码生成器。以生成 TypeScript 类型为例遍历请求体的 properties输出 interfacefunction generateTypeScript(meta) { const { capability, request } meta; const props request.body.properties; const lines [export interface ${capitalize(capability)}Request {]; for (const [name, def] of Object.entries(props)) { const optional request.body.required.includes(name) ? : ?; lines.push( ${name}${optional}: ${mapType(def)};); } lines.push(}); return lines.join(\n); } function mapType(def) { if (def.type string) return string; if (def.type number || def.type integer) return number; if (def.type boolean) return boolean; return any; }第五步接入 CI。在 CI 流程里加一个步骤每次提交 Spec 文件时自动跑校验和代码生成生成结果和仓库里的代码做 diff不一致就报错。这一步是保证“单一真相源”不被绕过的关键。4.2 参数计算约束条件的实际取值Spec 里的约束条件不是拍脑袋定的得有依据。以username的minLength和maxLength为例我的取值逻辑是minLength: 3低于 3 个字符的用户名区分度太低容易冲突maxLength: 32数据库列宽通常设VARCHAR(64)留一半余量给多字节字符32 个字符足够覆盖绝大多数场景再比如分页参数pageSizeminimum: 1不允许查 0 条maximum: 100单次查询上限防止调用方一次拉全表default: 20不传时的默认值兼顾性能和体验这些数字看起来简单但每一个都应该有明确的理由。我在 Spec 正文里会把这些理由写清楚方便后来人理解为什么是这个值而不是随便改。4.3 实操现场一次接口变更的完整流程假设产品要求给createUser增加一个可选的nickname字段。完整流程如下修改specs/user/createUser.spec.md在request.body.properties里加nickname类型 stringmaxLength: 64不加进required把spec_version从1.2.0改成1.3.0兼容变更次版本号 1在正文里补充说明nickname为可选字段不传时服务端 SHALL 使用 username 作为默认昵称提交CI 自动跑校验和代码生成后端拉取最新代码在 Controller 里处理nickname字段前端拉取最新代码类型定义里自动多了nickname?: string测试拉取最新代码契约测试自动覆盖新字段整个过程没有一次口头沟通没有一份额外的文档所有变更都通过 Spec 文件流转。这就是“单一真相源”的威力。5. 常见问题与排查技巧实录5.1 常见问题速查表问题现象可能原因排查方向invalid version spec报错版本号格式错误或版本目录缺失检查spec_version是否符合主.次.补丁格式生成的代码编译不过Spec 里字段类型和实际代码冲突对比 Spec 定义和现有代码确认是否漏改前后端类型对不上一方没重新生成代码检查 CI 是否跑过本地是否拉取最新生成结果校验器报 schema 错误frontmatter 里有未定义的字段检查 schema 是否覆盖了所有使用的字段契约测试失败Spec 定义和实际接口行为不一致以 Spec 为准修实现或改 Spec 并升版本5.2 独家避坑技巧技巧一Spec 文件里不要写实现细节。我见过有人在 Spec 里写“该字段对应数据库的user_name列”这是大忌。一旦数据库列名改了Spec 就得跟着改但对外契约其实没变。Spec 只描述外部可见的行为内部怎么存是另一回事。技巧二给 Spec 文件加 lint 规则。除了 schema 校验还可以加一些自定义 lint 规则比如“所有POST能力的响应必须包含201状态码”、“所有required字段必须在正文里有说明”。这些规则能提前发现很多低级错误。技巧三生成代码时保留文件头注释。生成的代码文件顶部加一行// AUTO-GENERATED FROM specs/user/createUser.spec.md, DO NOT EDIT。这样能防止有人直接改生成结果改完下次生成又被覆盖白白浪费时间。技巧四Spec 变更要走 code review。Spec 文件本质上就是代码变更必须走 review。我在团队里定的规矩是Spec 变更至少要有前后端各一人 approve确保双方都清楚契约变了什么。技巧五定期做 Spec 和实现的漂移检测。再严格的流程也可能有漏网之鱼。我每周跑一次漂移检测脚本对比 Spec 定义和实际接口的响应结构发现不一致就报警。这个脚本用契约测试框架很容易实现本质就是拿 Spec 生成的期望值和实际调用结果做 diff。5.3 关于SHALL等关键词的使用心得SHALL、SHOULD、MAY这三个词在 Spec 正文里的使用我摸索出一套自己的判断标准如果违反了这个要求接口调用会失败或数据会出错用SHALL如果违反了这个要求功能还能用但体验会变差用SHOULD如果这个要求只是锦上添花用MAY举个例子“调用方 SHALL 在请求头里带上Authorization”——不带就 401必须用 SHALL。“服务端 SHOULD 在响应头里返回X-Request-Id”——不返回也能用但排查问题时有用用 SHOULD。“服务端 MAY 返回X-RateLimit-Remaining”——可选用 MAY。这套标准用久了团队里对契约的严肃程度会有明显提升。以前大家觉得文档就是随便写写现在看到 SHALL 就知道这是硬约束不敢马虎。6. Spec 系统的扩展玩法6.1 从接口 Spec 扩展到能力 SpecSpec 系统最初是用来描述 HTTP 接口的但它的思路可以扩展到更广的“能力”层面。比如一个内部的消息队列消费者它消费什么消息、处理什么逻辑、产出什么结果也可以用 Spec 描述。再比如一个定时任务触发条件、执行逻辑、失败重试策略同样可以 Spec 化。我最近在一个数据管道项目里做了这个扩展。每个数据处理能力对应一个 Spec 文件描述输入数据格式、输出数据格式、处理规则。调度系统读取这些 Spec自动生成任务配置。这样新增一个数据处理能力只需要写一个 Spec 文件不用改调度系统的代码。6.2 Spec 驱动的 Mock 服务Spec 里定义了请求和响应的结构天然可以用来生成 Mock 服务。我写了一个小工具读取 Spec 文件根据响应定义自动生成符合结构的假数据起一个本地 HTTP 服务返回这些假数据。前端在后端接口还没开发完的时候就能基于 Mock 服务联调。这个工具的核心逻辑是遍历响应定义的 properties按类型生成随机值。string 生成随机字符串number 生成随机数字date-time 生成当前时间。对于有enum约束的字段从枚举值里随机选一个。整个过程不到 100 行代码但省掉了前端等后端的大量时间。6.3 Spec 与 API 网关的联动如果团队用了 API 网关Spec 可以直接驱动网关配置。网关需要的路由规则、限流策略、鉴权配置都可以从 Spec 的 frontmatter 里读取。比如在 Spec 里加一个rateLimit字段rateLimit: window: 60 max: 100网关配置生成器读取这个字段自动生成限流规则。这样限流策略和接口定义始终在一起不会出现接口改了但限流没改的情况。7. 我个人的一些实践体会Spec 系统这套东西技术实现其实不难难的是让团队接受并坚持用。我见过太多团队一开始热情很高搞了一套 Spec 规范用了两个月就荒废了又回到各自维护文档的老路。根据我的经验能坚持下来的团队通常做对了这几件事第一降低使用门槛。Spec 文件的编写要尽可能简单不要搞太复杂的 schema。我见过一个团队定义了 50 多个 frontmatter 字段结果没人记得住写 Spec 比写代码还累。我的做法是核心字段控制在 10 个以内其他都是可选的。第二让不写 Spec 的代价大于写 Spec。CI 里强制校验Spec 和代码不一致就构建失败。这样大家就算嫌麻烦也得写写着写着就习惯了。第三从一个小模块开始试点。不要一上来就全公司推广先在一个小团队、一个模块里跑通积累经验和工具再逐步扩大。我第一个试点模块只用了两周就跑通了完整流程然后拿着成果去说服其他团队比空口讲道理有效得多。第四Spec 的维护要有明确 owner。每个领域的 Spec 指定一个人负责 review 和版本管理避免出现“人人都能改人人都不负责”的局面。这套系统跑顺之后最大的感受是沟通成本断崖式下降。以前前后端联调光对齐字段就要开好几次会现在大家看同一份 Spec有疑问直接在 Spec 的 PR 里讨论讨论结果直接落到文件里。接口变更从“通知一圈人”变成了“改一个文件”效率提升非常明显。如果你正准备在团队里推这套东西我的建议是先从工具链入手把解析、校验、生成这三个环节跑通让团队看到实实在在的便利再逐步推广规范。工具先行规范跟上比反过来要顺利得多。