1. 从代码先跑起来到规格先对齐为什么我会把OpenSpec纳入工作流大概两年前我接手了一个内部中台项目的API治理工作。第一周开需求评审会产品讲完新功能前端同事打开Postman集合指着一个接口说这个字段什么时候能加上后端同事翻了两分钟代码说其实早加了文档没更新。两边都觉得自己没毛病但联调效率就是上不去。真正让我下决心换方案的是有一次线上事故——一个接口的字段类型从string被改成了integerSwagger文档里还是老类型第三方对接方照着文档传参线上直接抛了一串校验异常。查到最后没有人是故意的纯粹是代码改完没人记得同步规格文件。那时候我意识到团队缺的不是文档工具而是一个能让规格本身变成开发流程一环的东西。后来接触到OpenSpec它是规格驱动开发(Spec-Driven Development)方向的开源工具核心思路是先写规格再写代码规格不是代码的副产品而是代码的源头。这篇文章我会把自己从调研到落地、再到跑进CI流程的完整过程写清楚包括OpenSpec的目录设计、Change的工作方式、怎么生成OpenAPI文档、怎么接代码生成以及一堆文档里不会写的坑。适合正在做API治理、想引入规格优先流程的团队参考也适合一个人想把接口设计做得更扎实的后端开发者。先说结论OpenSpec本身不是一个文档工具它是一套把规格变更纳入Git工作流、用Markdown维护OpenAPI规范、再通过构建命令产出标准OpenAPI文档的规格管理方案。它解决的痛点是传统Swagger那种一个巨大的YAML/JSON文件所有人一起改的模式在多人协作时冲突不断代码优先(Code-First)的模式又天然导致文档滞后。OpenSpec把规格拆成细粒度的变更文件让每个人都改自己的那一小片最后统一构建出可发布的OpenAPI规格。1.1 传统API开发的两种姿势以及它们各自的账第一种是Code-First也就是先写业务代码然后用注解或者库自动生成OpenAPI文档。Java系的SpringFox/springdoc、Python系的FastAPI都是这个路子。好处是文档跟代码天然同步坏处是文档长什么样完全被代码实现绑架——你想把某个字段标记为废弃(deprecated)但代码结构不支持这种语义你想在下游不需要的字段上少暴露一些信息代码里却已经是全量返回。说白了Code-First的文档是代码的影子影子永远没法反过来约束本体。第二种是Design-First也就是先在Swagger Editor里画出OpenAPI YAML然后团队照着这个文档写代码。问题在于一个中大型系统的OpenAPI文件动辄几千行放Git里多人同时改几乎每周都在解决合并冲突。更麻烦的是评审一个改动的时候PR里全是几千行文件里挑那么几处变化reviewer很难看出这个改动到底影响了谁。OpenSpec的思路是把Design-First继续往前推了一步规格不是一大份成品文档而是一堆小粒度的变更单(Change)。每个变更单有独立的目录、独立的Markdown文件、独立的评审上下文。等到一个迭代结束再把所有变更单构建成一份完整的OpenAPI文档。这个先小粒度变更、后统一构建的设计是我觉得它最值得借鉴的地方。1.2 OpenSpec和Swagger/OpenAPI到底什么关系需要先澄清一个常见的误解OpenSpec不是Swagger的替代品它反而是OpenAPI生态的上游工具。OpenSpec团队自己维护了一套Markdown格式的OpenAPI规范也就是说你写规格的时候用的是带特定约定的Markdown而不是直接写YAML。构建之后它会生成标准的openapi.json这个JSON就是标准的OpenAPI 3.1文档可以被Swagger UI、Redoc、Postman导入也可以交给openapi-generator之类的工具去生成代码。所以在技术栈上你的项目完全可以用OpenSpec管规格、用Swagger UI展示文档、用Fastify写业务代码、用各种代码生成器产出客户端SDK这套组合并不冲突。我自己的团队现在就是这样一个搭配OpenSpec负责规格的编写和版本管理构建产物喂给Redoc做对外文档喂给openapi-generator做TypeScript客户端服务端则用OpenSpec内置的Fastify代码生成插件直接长出路由骨架。2. OpenSpec的核心设计一个Change就是一个可评审的规格提交单元2.1 openspec init之后仓库里到底多了什么我建议你在一个干净的目录里先跑一遍初始化看看它到底生成了什么。装好CLI之后执行openspec init正常情况下它会生成一个openspec/目录里面有project.md和changes/两个部分。project.md是项目级说明我一般放项目简介、负责人、文档主页链接这些全局信息。changes/目录则非常关键——每一个尚未合并的规格变更都会占用一个独立的子目录。这里要强调一个OpenSpec和其他工具不太一样的地方它把规格文件和变更管理绑在了一起。以前我们建分支改代码、PR里附带改一下swagger.yamlOpenSpec反过来了它的变更单本身就是规格层面的PR。每个变更单目录有自己的README.md变更说明、specs/规格正文、以及构建后生成的openapi.json完整OpenAPI文档片段。用Git管理OpenSpec变更时我最常用的协作模式是一个变更单对应一个Git分支分支名就用变更单ID评审通过后合并主分支。这样就做到了规格变更可追踪、可回滚、可评审而不是散布在几十个YAML文件里找diff。2.2 Change文件夹的内部结构与规格语法用命令openspec new可以交互式地创建变更单。它会问你变更名称、选择变更类型然后自动生成一个类似openspec/changes/2025-05-create-user-api/的目录。这个ID是带日期的目的是保证全局唯一、排序自然。目录内部通常长这样openspec/ changes/ 2025-05-create-user-api/ README.md specs/ openapi/ users.md meta/ example.jsonREADME.md是整个变更的背景描述写清楚为什么做、影响哪些模块、有没有破坏性变更。specs/openapi/下放具体的接口规格每个文件按模块拆分——比如用户模块就放users.md订单模块就放orders.md。规格正文的Markdown格式是OpenSpec团队定义的一套约定举个例子# createUser ## POST /users 创建一个新用户。 ## PARAMETERS - body: 用户对象必须包含用户名和邮箱 ## REQUEST ### BODY json { type: object, properties: { username: { type: string }, email: { type: string, format: email }, avatarUrl: { type: string, format: uri } }, required: [username, email] }RESPONSE201创建成功返回用户对象。{ type: object, properties: { id: { type: string, format: uuid }, username: { type: string }, email: { type: string, format: email } } }看懂这个语法之后你基本就掌握了OpenSpec的80%。它用Markdown的标题层级来表达操作ID# createUser、HTTP方法加路径## POST /users、请求体### BODY、响应状态码### 201用JSON Schema来表达字段结构。相比直接在OpenAPI YAML里写paths、components、responses这些嵌套结构Markdown的可读性确实高一个档次而且Git diff出来的结果非常容易人肉审查——这行加了一个字段就是一行改动不会因为层级嵌套导致整个大块标红。 ### 2.3 规格文件如何变成OpenAPI文档 手写这个Markdown格式只是第一步真正重要的是构建出的openapi.json。执行 bash openspec build命令执行之后每个变更单目录下会生成对应的openapi.json这就是一份标准的OpenAPI 3.1片段。多个变更单可以合并构建也可以基于某个--base的OpenAPI文件增量构建生成一份叠加了所有变更的完整文档。这一步的原理值得说一下OpenSpec的构建器会把Markdown里每个# operationId解析成一个OpenAPI的paths条目把Markdown里的JSON Schema片段原样搬进OpenAPI的requestBody和responses结构再把PARAMETERS里的参数映射到parameters数组。它做的本质是格式转换加结构装配所以你在Markdown里写的内容越规范构建出来的OpenAPI文档就越干净。我遇到过有人把请求体描述写在## REQUEST下面的正文段落里构建时确实能过但信息会丢失因为描述文本在OpenAPI里对应的字段是description直接写在正文里没地方挂。细节要按格式来这个后面避坑部分细说。3. 实操全流程从零接手一个OpenSpec项目这一节我会按一个真实需求的完整流程走一遍。假设团队要新增一个获取用户详情的接口并且要让前端和客户端都能直接从构建产物拿类型定义。3.1 安装与初始化CLI的目标目录和文件结构OpenSpec是用TypeScript写的CLI工具官方推荐用npm全局安装npm install -g fission-ai/openspec装完验证一下版本openspec --version然后在你的仓库根目录执行openspec init那一步。这里有几个细节是我在多个项目里验证过的建议OpenSpec目录建议放在仓库根目录不要放在src或者api子模块里。因为规格是跨前后端团队共享的放根目录最容易让所有人都看到也能避免某些构建工具只监听特定目录导致规格更新不触发流水线。project.md要写清楚这个API的base URL和全局信息。虽然构建的时候不会强制校验但后面合并多个Change生成完整文档时OpenAPI的servers、info这些字段会从项目配置里补齐。初始化完成之后openspec/目录下的文件都应提交Git纳入版本管理。3.2 创建第一个Change并编写规格从需求到Markdown执行openspec newCLI会进入交互式问答让你输入变更名称和变更类型。输入create-user-api之类的名称它会自动生成变更目录。进入目录编辑README.md--- change-id: 2025-05-create-user-api authors: devexample.com --- # 创建用户接口 新增POST /users接口用于在用户中心创建一个新用户。 ## 背景 当前系统没有用户注册接口用户数据只能由管理员后台手工创建。 本次变更将开放用户自助注册能力。 ## 影响范围 - API网关新增路由 - 用户服务新增注册逻辑 - 数据库新增用户表由服务端迁移管理 ## 破坏性变更 无Frontmatter里的change-id和authors会用于变更的追踪。README.md本身不参与OpenAPI构建但它是评审变更时最重要的入口文档。接着打开specs/openapi/users.md开始写接口规格。这里我要分享一个写规格的顺序技巧先写响应再写请求最后写参数。因为大多数接口的字段都是围绕返回什么来定义的先把返回结构定下来请求体和参数自然就有了参照评审的时候也更容易发现遗漏字段。反过来先写请求容易写着写着把响应定义窄了联调时才被动加字段。写好后Change目录里可以放一个示例响应文件mkdir -p specs/meta echo {id:123e4567-e89b-12d3-a456-426614174000,username:alice,email:aliceexample.com} specs/meta/example.json这个specs/meta/目录不是构建必需的但可以放一些示例数据、枚举字典、字段约束说明等辅助材料方便评审人理解规格意图。我个人觉得这是OpenSpec一个被低估的设计规格不仅仅是机器能读的schema也是人能快速看懂的设计提案。3.3 构建与校验让规格转成可用的openapi.json写完规格后在仓库根目录执行openspec build openspec validatebuild命令负责生成openapi.jsonvalidate命令负责检查规格格式是否正确——比如请求体有没有定义type: object、参数有没有重复、operationId是否重复等。我第一次跑validate的时候就被提示operationId在多个文件中重复。原因是我在两个模块里都写了# getUser构建时OpenAPI出现两个同路径条目校验器直接报错。这种问题在传统YAML文件里很难一眼发现但用OpenSpec的构建机制模块越多越容易暴露算是好事逼着你在命名上花心思。构建完成后打开生成的openapi.json看一下确认paths、components、requestBody这些结构都正确。这一步一定要亲自看不要只盯着命令行输出因为只有确认构建产物符合预期你才能放心地把它交给下游工具。3.4 生成代码让Fastify路由定义直接从规格里长出来OpenSpec官方提供了代码生成扩展支持Fastify等后端框架的路由生成也支持生成TypeScript客户端SDK。用的时候在Change目录里加一个生成器配置然后执行openspec codegen它会基于构建出的openapi.json生成对应的路由骨架代码。比如对于POST /users生成的Fastify路由大概长这样import { FastifyPluginAsync } from fastify; export const createUser: FastifyPluginAsync async (app) { app.post(/users, async (request, reply) { // 校验通过请求体已按schema约束 const { username, email } request.body as { username: string; email: string; }; // TODO: 接入业务逻辑 return reply.status(201).send({ id: generated-uuid, username, email }); }); };需要说明的是这只是路由骨架业务逻辑还是得自己写。这个生成的意义在于路由路径、请求体类型、响应状态码这些协议层面的东西不再有代码和文档不一致的机会了因为代码就是从规格里长出来的。如果你的团队服务端不是Fastify也没关系构建出的openapi.json可以喂给OpenAPI Generator社区版生成Spring、Go、Python等各种语言的服务端和客户端代码。我自己在别的项目里就用过openapi-generator生成Kotlin客户端效果很稳定。4. 把OpenSpec变成团队协作的锚点分支策略与评审流工具用起来不难难的是怎么把它嵌进团队的日常协作流程。这一节讲的是我们团队磨合了一个月之后定下来的分工和规范。4.1 用Git分支管理变更一个Change一条分支我们的标准流程是开发者从main分支切出功能分支分支名直接叫change/2025-05-create-user-api和Change目录名一一对应。在这个分支上编辑openspec/changes/2025-05-create-user-api/下的文件提交时分开提交两种内容规格文件一个commit生成的代码一个commit。推送分支之后PR的描述里贴Change目录的README.md内容。评审通过后合入main然后执行一次openspec build把完整的openapi.json发布到文档站点。这套流程的好处是规格变更和代码变更在同一分支里被评审但逻辑上又分得清清楚楚。Review的时候评审人可以只看规格文件专门从协议设计的角度挑毛病——字段命名、类型定义、状态码语义也可以只看代码从实现角度把关。不会像以前那样一份PR里密密麻麻全是代码diff规格埋在角落里没人看。4.2 自动化校验CI里拦住那些看起来没事的改动OpenSpec的CLI是纯命令行工具意味着它可以很方便地接进GitHub Actions或者GitLab CI。我们流水线里加了这样几步- name: Validate OpenSpec run: | openspec validate - name: Build OpenAPI Spec run: | openspec build --checkvalidate负责检查每个Change的格式合法性--check参数会让build命令在检测到构建结果与上次不一致时返回非零退出码从而阻断合并。这个--check特别有用能防止有人在本地构建过但忘了把生成的openapi.json提交上来。另外我建议在CI里增加一步检查Change目录是否存在但未在README中记录。我们踩过坑有人创建了Change目录却没写README本地build正常文档里也看不出来异常但评审人打开PR后一头雾水。后来在流水线里加了一段简单的shell脚本用find openspec/changes -name README.md -mmin -1这种粗笨方式兜底虽然不优雅但有用。4.3 团队约定changeID、命名规则和多人并发多人同时提变更时最怕撞Change ID。OpenSpec的new命令生成的ID自带日期所以同一天内两人创建变更的ID前缀大概率相同。我们的约定是在交互式命名时把功能名写得足够具体比如2025-05-create-user-api和2025-05-update-user-profile而不是api-fix这种模糊名称。ID的唯一性不光是为了目录不冲突更是为了以后在Git历史里快速定位这个变更到底干了什么。还有一个并发相关的细节如果两个变更同时改了同一个模块比如两个人都改了users.md合到main时会冲突。我们的做法是评审时先在本地把main合进来、重新build一遍确认合并后的OpenAPI结构没有异常再推远程。OpenSpec构建逻辑是多份规格叠加两个变更如果都定义了同一个路径的同一个方法构建时会覆盖而不是报错。这个行为容易被忽略但恰恰是多人协作时最危险的——后合并的人悄悄覆盖先合并的人的接口定义。5. 高级玩法与避坑指南OpenSpec用起来的真正门槛最后这部分写几个我在实际项目中踩过、或者帮助团队成员排查过的典型问题以及一些文档之外的经验判断。5.1 已删除接口的兼容性处理规格驱动开发里最容易忽视的是下线接口的规格怎么写。直接在users.md里删掉一个# getUser重新build后openapi.json就少了一个path线上文档也同步消失。如果现有客户端还在调用这个接口这就是一次未通知的破坏性变更。我的建议是对于要废弃的接口规格先标记为deprecated而不是直接删除。在OpenSpec的Markdown格式里可以在操作描述下加一个说明字段表示废弃状态类似# getUser ## GET /users/{id} 查询用户详情。 deprecated: true deprecationMessage: 请使用 /users/me 替代默认构建时不会把这个字段翻译成OpenAPI标准的deprecated属性但你可以把这种团队自定义标记约定好让评审流程去把关。真正要从文档里移除某个接口时单独提一个移除接口的Change在README里写明原因和删除时间给调用方一个明确的观察窗口。这个习惯在对外API尤其重要Clien端的SDK更新时间往往比服务端慢一拍别因为删个规格把下游坑了。5.2 生成代码的覆盖与手写代码的分工OpenSpec的代码生成有一个需要提前规划的问题如果反复跑codegen生成的路由文件会不会覆盖你写的业务逻辑官方生成器默认的行为是生成独立的文件并不会动你手写的代码文件。但我们团队的实践是把生成的代码放在src/routes/generated/目录手写的业务逻辑放在src/services/和src/handlers/目录生成的路由文件只做参数校验和转发。这样即使每次codegen重新生成了路由文件也不会丢失业务代码。另外生成代码的版本控制建议采用生成物可提交、但别手工改的策略。如果生成的代码有Bug先找生成器的问题或者调整规格文件不要直接在生成文件里改。否则下轮codegen一跑改动就丢了排查的时候还以为是生成了随机代码。5.3 常见报错与我的解决笔记我在用下来的过程中记录了几个高频问题整理成一张表供参考现象可能原因解决方案openspec validate提示operationId重复不同模块文件里用了相同标题给标题增加模块前缀例如users.getUser和admin.getUser构建后openapi.json里没有某个Change的内容Change目录下的specs目录没建对或者文件名不是.md检查是否放在specs/openapi/下文件名后缀必须是.md请求体字段全部丢失在### BODY下面写了JSON示例而不是JSON Schema### BODY下放type: object开头的JSON Schema而不是{ id: 1 }示例数据示例数据放到specs/meta/openspec build --check在CI里一直失败本地和CI的版本不一致或者构建产物没提交固定CLI版本号确保提交了最新生成的openapi.json5.4 迁移老项目时怎么无痛把现有文档转过来如果你的项目已经有一份OpenAPI YAML/JSON想迁移到OpenSpec不用推倒重来。OpenSpec的build命令支持--base参数意思是基于已有的OpenAPI文件做增量构建。你可以把现有文档作为base新需求都走Change流程由OpenSpec把base和changes合并输出。这样一来存量文档可以先不拆解新规格逐块引入团队不会因为一次大迁移而阻塞业务开发。迁移节奏上我的建议是先加一个小模块试水比如选一个改动不频繁、字段定义清晰的模块让团队把流程跑顺再逐步扩展到全量。不要一上来就要求所有人在所有Change里用OpenSpec——规格驱动开发的收益是长期一致性和可追踪性它需要团队习惯的转变这个转变需要时间。最后再分享一个小技巧用OpenSpec这一年多我最深的一个体会是它真正厉害的地方不是Markdown输写格式也不是代码生成而是把规格变更变成了Git仓库里一个个可追踪的单元。以前评审API设计要在聊天软件里发截图、传文件现在每个接口设计的来龙去脉都沉淀在仓库里新人入职看一个月的Change历史就能对这个系统的API演进脉络有清晰的认识。如果你准备在自己的项目里试我建议从一个最小闭环开始装好CLI建一个Change写上两个接口规格构建出openapi.json跑一次codegen看看生成的代码长什么样。整个过程一小时就能跑完。跑完你就明白规格驱动开发不是额外增加文档负担而是把原来写代码、补文档的顺序颠倒过来让文档成为起点——这个颠倒省掉的是无数次的联调扯皮和数据不一致的事故。
阅读完成 · 觉得有帮助?