OpenSpec 规格驱动开发实战:从契约优先到 CI 校验的落地指南
1. 从“规格散落各处”说起OpenSpec 到底想解决什么问题如果你参与过稍微有点规模的软件项目大概率经历过这样的场景需求文档在飞书里接口定义在 Swagger 里数据库字段说明在某个人的 Notion 里而真正上线后行为对不对只能靠翻代码和问人。等到新同学入职或者半年后自己回头看某个模块脑子里只剩一句话——“当时为什么这么设计来着”OpenSpec 就是冲着这个痛点来的。它不是一个具体的业务框架也不是某个语言专属的库而是一套围绕“规格Spec”组织项目信息的约定和工具链。核心思路很朴素把项目里那些容易散落、容易过期的关键信息——接口契约、数据结构、行为规则、边界条件——用一种结构化、可版本管理、可被工具消费的方式沉淀下来放在代码仓库里跟代码一起演进。我第一次接触 OpenSpec 这个概念时第一反应是“又一个文档规范”但实际用下来发现它跟传统文档最大的区别在于规格不是写给人看的散文而是能被校验、能被引用、能驱动代码生成的契约。这一点决定了它和 README、Wiki 的本质差异。这篇文章适合几类人看一是正在被“文档和代码两张皮”折磨的团队技术负责人二是想给自己项目建立一套轻量规格体系但不知道从哪下手的独立开发者三是对 OpenSpec 这个词有耳闻、搜了一圈却只看到零散信息、想搞清楚它到底怎么落地的人。我会从核心概念讲起一路讲到目录结构、实操步骤、常见坑尽量把“为什么这么设计”也讲透而不是只丢一堆命令。需要先说明一点OpenSpec 目前并没有一个官方钦定的、唯一正确的实现形态社区里围绕它的实践有不同流派。下面讲的内容是基于我实际项目中反复验证过的一套做法同时结合了社区里比较主流的约定。你在自己项目里落地时可以按需裁剪。2. OpenSpec 的核心概念拆解规格、契约与可执行性2.1 规格不是文档而是“可被机器读的约定”很多人第一次听到“规格”两个字脑子里浮现的是 Word 文档或者 Confluence 页面。OpenSpec 语境下的规格形态更接近配置文件或者结构化文本。它通常用 YAML、JSON 或者带 front-matter 的 Markdown 来写目的是让人类可读的同时工具也能解析。举个直观的对比。传统文档里你可能写“用户登录接口接收用户名和密码返回 token密码错误返回 401。”这句话人看得懂但机器没法用。OpenSpec 风格的规格会写成类似这样endpoint: /api/v1/login method: POST request: username: string, required password: string, required response: 200: token: string 401: error: string差别在哪后者可以被校验工具读取可以自动生成 mock server可以在 CI 里检查实现是否和规格一致。规格的价值不在于“记录”而在于“约束”。这是理解 OpenSpec 的第一道门槛。2.2 契约优先先定规格再写实现OpenSpec 推崇的工作流是“契约优先”contract-first。传统做法是先写代码写完再补文档文档天然滞后。契约优先反过来先把接口、数据结构、行为规则用规格描述清楚评审通过后再动手写实现。这个顺序看起来只是流程调整实际影响很大。我在一个多人协作的后端项目里推行过这套流程最明显的变化是联调阶段扯皮少了很多。前端拿着规格就能自己 mock 数据开发不用等后端接口 ready后端写实现时也有明确的验收标准不用反复确认“这个字段到底可不可以为空”。当然契约优先不是银弹。它对需求稳定度有要求如果需求一天三变先写规格反而是浪费。我的经验是核心接口、跨团队接口、对外暴露的 API值得契约优先内部快速迭代的模块可以代码先行、规格后补。一刀切地要求所有东西都先写规格团队会抵触。2.3 规格的三种粒度接口级、模块级、系统级OpenSpec 实践里规格通常分三个粒度对应不同的关注点粒度关注点典型内容更新频率接口级单个 API 的输入输出字段、类型、错误码高模块级一个功能域的规则状态机、业务约束中系统级整体架构与依赖服务边界、数据流低新手最容易犯的错是只写接口级规格忽略了模块级和系统级。结果接口都对但整体行为对不上。比如订单模块单个接口的字段都没问题但“订单状态从待支付到已支付必须经过支付回调”这条规则没人写下来实现时就可能被绕过。模块级规格恰恰是承载这类“跨接口规则”的地方不能省。2.4 为什么用 Markdown front-matter 而不是纯 YAML社区里对规格文件格式有争论。纯 YAML 机器友好但人写起来痛苦尤其是要加注释和说明时。纯 Markdown 人友好但机器解析麻烦。目前比较主流的折中是Markdown 正文 front-matter 结构化元数据。front-matter 里放机器需要的关键字段接口路径、方法、版本等正文里放人类需要的说明、示例、注意事项。这样工具解析 front-matter人读正文各取所需。我实测下来这种格式的接受度最高因为写起来跟写普通文档差不多学习成本低。3. 目录结构怎么设计让规格和代码住在一起3.1 推荐的顶层布局OpenSpec 落地第一步是定目录结构。我的建议是规格目录和源码目录平级放在仓库根目录下命名统一用specs/或者openspec/。不要塞进docs/里因为docs/通常混着教程、FAQ 等非规格内容混在一起会让工具解析范围失控。一个我用了两年多的布局长这样project-root/ ├── specs/ │ ├── api/ │ │ ├── user-login.md │ │ └── order-create.md │ ├── modules/ │ │ ├── order-state-machine.md │ │ └── payment-flow.md │ └── system/ │ └── service-boundaries.md ├── src/ ├── tests/ └── openspec.config.yamlapi/放接口级规格modules/放模块级system/放系统级。文件名用短横线连接的英文跟接口路径或模块名对应方便检索。3.2 命名约定为什么不用中文文件名有同学问过规格文件能不能用中文名。技术上可以但我不推荐。原因有三个一是跨平台兼容性某些系统对中文路径处理有坑二是命令行操作时中文输入麻烦三是 CI 脚本里引用中文路径容易出编码问题。文件名用英文内容用中文是性价比最高的组合。命名上我习惯用“资源-动作”的形式比如user-login.md、order-cancel.md。这样一眼能看出这个规格管的是什么。避免用spec1.md、temp.md这种名字过两周自己都不记得是什么。3.3 配置文件 openspec.config.yaml 该写什么配置文件是工具链的入口主要告诉工具去哪里找规格、怎么校验。一个最小可用的配置大概是这样specRoot: ./specs include: - **/*.md exclude: - **/draft/** validation: requireFrontMatter: true requiredFields: - id - version - statusspecRoot指定规格根目录include/exclude控制扫描范围validation定义校验规则。requiredFields里我强烈建议加上status字段用来标记规格是草稿、已评审还是已废弃。没有状态标记半年后你分不清哪些规格还有效。3.4 规格文件的 front-matter 模板每个规格文件头部的 front-matter我固定用这几个字段--- id: api-user-login version: 1.2.0 status: approved owner: backend-team lastReviewed: 2024-05-20 ---id全局唯一工具和代码里引用它version跟接口版本对应status标记生命周期owner明确责任人出问题知道找谁lastReviewed记录最后评审时间超过一定周期没评审的规格可以自动提醒。这几个字段看着简单但责任人和评审时间这两个字段是让规格“活起来”的关键没有它们规格很快会变成没人管的僵尸文档。4. 从零跑通 OpenSpec一份可复现的实操流程4.1 环境准备与工具选型OpenSpec 本身是一套约定具体工具可以用现成的也可以自己写脚本。如果你不想引入额外依赖用 Node.js 写个几百行的校验脚本就够了。如果想省事社区里有几个开源实现功能大同小异选一个活跃度高的即可。我自己的项目里用的是 Node.js 几个轻量库gray-matter解析 front-matterajv做 JSON Schema 校验glob做文件扫描。这套组合装下来不到 5MB跑起来很快。选 Node.js 的原因是前端后端都能用同一套工具团队里会 JS 的人多维护成本低。提示不要一上来就追求工具链完备。先用最简脚本把“规格能被解析和校验”这件事跑通再逐步加功能。工具越复杂团队越不愿意用。4.2 写第一份规格以登录接口为例假设我们要为一个登录接口写规格。新建specs/api/user-login.md内容如下--- id: api-user-login version: 1.0.0 status: draft owner: backend-team lastReviewed: 2024-05-20 --- # 用户登录接口 ## 请求 - 路径/api/v1/login - 方法POST - 内容类型application/json ## 请求字段 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | username | string | 是 | 用户名长度 3-32 | | password | string | 是 | 密码长度 8-64 | ## 响应 ### 成功200 返回 token 和过期时间。 ### 失败401 用户名或密码错误返回统一错误信息不区分具体原因。 ## 业务规则 - 连续失败 5 次账号锁定 15 分钟。 - 密码传输必须走加密通道。这份规格里front-matter 是给机器看的正文是给人看的。注意“业务规则”这一节它承载的是接口之外的行为约束恰恰是最容易被忽略、又最容易出问题的部分。4.3 校验脚本怎么写一个最小校验脚本的核心逻辑分三步扫描文件、解析 front-matter、检查必填字段和格式。伪代码大概是这样const matter require(gray-matter); const fs require(fs); const glob require(glob); const files glob.sync(specs/**/*.md); let errors []; files.forEach(file { const content fs.readFileSync(file, utf8); const { data } matter(content); [id, version, status, owner].forEach(field { if (!data[field]) { errors.push(${file} 缺少字段 ${field}); } }); }); if (errors.length) { console.error(errors.join(\n)); process.exit(1); }这段脚本跑在 CI 里任何规格文件缺字段都会让构建失败。强制校验是让规格体系不烂尾的核心手段靠自觉是撑不过三个月的。4.4 接入 CI让规格校验成为合并门槛把校验脚本挂到 CI 的 PR 检查里配置大概是这样spec-check: stage: test script: - node scripts/validate-specs.js only: - merge_requests这样任何改动规格的 PR如果格式不合规直接卡住。我踩过的一个坑是一开始只校验新增文件结果老文件里的问题一直没人管。后来改成全量校验虽然第一次跑出一堆历史遗留问题但花半天清理完之后整个规格库干净了很多。全量校验 增量修复比只校验增量更有效。4.5 让规格驱动 mock 和测试规格写好了如果只用来校验格式价值只发挥了一半。更进一步的做法是从规格自动生成 mock server 和测试用例骨架。比如解析user-login.md的请求字段表自动生成一个返回假 token 的 mock 接口前端就能直接联调。测试用例骨架同理根据响应定义生成“成功返回 200”“密码错误返回 401”这样的用例模板测试同学填断言就行。这一步的投入产出比很高规格从“文档”变成“生产力工具”团队的接受度会明显提升。5. 落地过程中最容易踩的五个坑5.1 坑一规格写得像散文机器读不了最常见的失败模式是规格文件写得很漂亮但全是自然语言段落没有结构化字段。工具解析不了只能当普通文档看那跟写 Wiki 没区别。规格里凡是能被结构化的信息一律用表格或 front-matter 表达自然语言只用来补充说明和背景。5.2 坑二规格和代码不同步规格写完就没人更新代码改了规格没改几次之后大家就不信任规格了。解决办法有两个一是把规格更新纳入代码评审流程改接口必须同时改规格二是在 CI 里做一致性检查比如对比规格里的接口路径和代码里实际注册的路由对不上就报警。第二个办法更硬核但需要额外开发。5.3 坑三一开始就追求大而全有团队一上来就想把所有接口、所有模块的规格都补齐结果工作量巨大做到一半就放弃了。我的建议是从最痛的那个点切入——通常是跨团队联调最多的那个接口或者最容易出 bug 的那个模块。先做一两个跑通流程让团队看到好处再逐步铺开。5.4 坑四没有责任人规格变成孤儿规格文件没有 owner出了问题没人认领慢慢就没人维护了。front-matter 里的owner字段不是摆设要真正落实到人。我习惯在团队里指定“规格守护者”每个模块一个人负责该模块规格的准确性和及时更新。5.5 坑五工具链太重团队抵触有的实现引入了一堆依赖装环境就要半天团队自然不愿意用。工具链要轻能用一个脚本解决就别搞一套框架。我见过最极端的反例是一个团队为了“规范规格管理”引入了一个需要独立部署的服务结果没人愿意维护三个月后下线了。6. 规格体系的长期维护让 OpenSpec 不烂尾6.1 定期评审机制规格和代码一样会腐化。我建议每个季度做一次规格评审重点看三类status还是draft但已经上线很久的、lastReviewed超过半年的、owner已经离职的。这三类规格要么更新要么标记废弃不能放着不管。评审不用开大会异步做就行。工具可以自动生成一份“待评审清单”发给对应的 owner谁的部分谁处理。把评审成本降到最低机制才能持续。6.2 废弃规格的处理废弃的规格不要直接删移到specs/archive/目录下status改成deprecated并在正文顶部注明废弃原因和替代规格的 id。这样后来人查历史时有据可依不会一脸茫然。直接删除的代价是半年后有人问“这个接口以前是怎么设计的”谁也答不上来。6.3 规格的版本管理规格的版本跟代码版本要能对应上。我的做法是规格 front-matter 里的version跟接口的 API 版本一致同时在 git tag 里记录“这个发布对应哪些规格版本”。这样线上出问题时能快速定位到当时的规格是怎么定义的排查效率高很多。6.4 新人如何快速上手规格库规格库建好了新人怎么用我通常会在specs/README.md里写一份导航按模块分类的规格索引、常用查询命令、规格编写模板链接。新人入职第一周的任务之一就是读一遍自己负责模块的规格并尝试改一处小地方跑通校验流程。让新人通过动手熟悉规格体系比单纯讲解有效得多。7. 一些实操心得与工具之外的思考用了两年多 OpenSpec 这套东西最大的体会是规格体系能不能活下来技术只占三成剩下七成是团队习惯和文化。工具再好如果团队不认可“先定契约再写代码”这个理念规格迟早会变成摆设。我踩过最深的坑是早期太追求规格的“完备性”要求每个接口都必须有规格才能合并代码。结果团队为了过检查随便写几行糊弄规格质量反而更差。后来改成“核心接口强制、其他接口鼓励”配合定期的质量抽查效果反而好很多。强制和弹性之间要找平衡点一刀切往往适得其反。另一个心得是关于规格的粒度。刚开始我倾向于写得非常细字段级、错误码级都写全。后来发现太细的规格维护成本极高改一个字段要动好几个文件。现在的做法是接口级规格写到字段和错误码模块级规格只写关键规则和状态流转系统级规格画个依赖关系就够。粒度分层各司其职维护起来轻松很多。还有一点值得说规格的价值会随着项目规模增长而放大。小项目里规格可能显得多余几个人口头沟通就够了。但项目一旦超过一定规模或者人员流动变快规格带来的收益会指数级上升。所以我的建议是项目早期可以轻量做但别完全不做等到痛了再补成本会高得多。最后分享一个我常用的技巧把规格里的关键规则用注释的形式同步到代码里对应的位置并标注规格文件的 id。这样读代码的人能顺藤摸瓜找到规格读规格的人也能定位到实现。两边互相引用同步的动力会强很多。这个技巧不复杂但实测下来对保持规格和代码一致很有效。