如果你经历过“接口还没定,前后端就开始各写各的”,那你一定明白联调那几周的滋味。最近半年,我把团队的开发流程彻底翻了过来——先写OpenAPI规范,再用Spec-kit把规范变成服务端骨架、客户端SDK和契约测试。说白了就是SDD(Specification-Driven Development,规范驱动开发)落到工程上,而Spec-kit是这套思路在命令行和CI/CD里的具体实体。这篇文章不是官方文档解读,是我把一个工具真正推进团队日常流程之后的复盘,适合正在做前后端分离、微服务治理,或者一开会就为接口定义扯皮的团队参考。
1. 我为什么把团队从“代码先行”扭到“规范先行”:SDD的价值与代价
1.1 “接口文档永远滞后”这个死结
以前的项目里,最常见的场景是后端把接口写完了,对着Swagger UI复制几个字段丢到群里,说“先按这个联调”。前端拿到JSON以后发现数组里少了个状态字段,等你补完,前端自己写好的页面逻辑又要改。到最后“以文档为准”还是“以代码为准”都得吵一轮。
这个问题本质上是把代码当成了唯一事实源。代码会变,写文档的人的情绪会变,但接口契约不该跟着开发过程中的随手修正来回摆。SDD的思路很简单:在写业务代码之前,先把接口边界、数据模型、错误定义全部写进一份机器可读的规范里。这份规范就是契约,前后端都围绕它展开。好处是评审可以发生在第一行业务代码之前,问题越早暴露越便宜。
但我得提前说一句:规范先行不是万能药。它要求写规范的人对业务边界有足够的判断力,也要求团队愿意在开发前多花一点时间。很多团队不是不知道SDD,而是在“先写文档再写代码”和“先写代码再补文档”之间反复横跳,最后两边都没做好。规范成了摆设,这就是“伪规范”。
1.2 规范先行,但要提防“伪规范”
我见过一个团队,辛辛苦苦写了几百行的OpenAPI文件,但服务代码根本没按文件里定义的路径来。原因是没人校验规范与实现之间是否一致。规范文件躺在仓库里的唯一作用是应付文档盘点,这种规范写了等于没写。
所以我对SDD有两个硬要求:第一,规范必须机器可读,像OpenAPI、AsyncAPI、JSON Schema这样能被程序解析;第二,规范必须能被工具消费,能生成代码、能校验语义、能做契约测试。只要规范能和产物自动关联,它才真正承担起“单一事实源”的角色。如果规范只能靠人在PR里肉眼比对,那它在那个团队里就是死文档。
这一点,恰恰是Spec-kit这类工程化工具的价值所在。它不是为了把YAML写得更漂亮,而是把“规范”变成开发流水线的一等公民。
1.3 Spec-kit在这件事里扮演的角色
Spec-kit在我这里的定位是一个围绕规范文件工作的命令行工具集。它读取一份规范,解析成内部模型,然后往下游输出三样东西:服务端接口骨架、客户端调用代码、契约测试用例。
很多人第一次看到Spec-kit会问:这不就是另一个代码生成器吗?区别在于,代码生成器通常是“从规范生成代码然后就不管了”,而Spec-kit还把校验和契约测试做了闭环。也就是说,它不光是帮你省去打字的功夫,它还逼着你在合并代码之前回答“规范的改动是不是破坏性的”“生成出来的服务端实现是不是真的符合规范”。
这也解释了为什么我最终选它而不是自己写一堆脚本:自建脚本能解决单点问题,但无法形成工作流。Spec-kit提供的是从规范评审到代码产出到CI集成的完整链路,省掉的是我们团队“什么都从头造”的惯性。
2. Spec-kit的三根柱子:生成、校验、契约测试
2.1 生成:一份spec同时长出服务端和客户端,不用再手工翻译字段
先说最直观的能力:代码生成。我们内部的服务端主要用Node.js和TypeScript,客户端是Web和部分小程序。以前每一端都要根据接口文档手写类型定义和请求函数,同样的字段要在三四个地方重复维护。字段一改,要么全局搜索,要么等联调时炸雷。
用Spec-kit之后,我把OpenAPI规范文件丢给它,一条命令生成服务端路由处理器的骨架,另一条命令生成TypeScript客户端的API层。比如这样:
spec-kit generate server -i openapi/todo.yaml -l nodejs -o server/src -f handlers-only spec-kit generate client -i openapi/todo.yaml -l typescript -o web/src/lib/api-f handlers-only这里值得说一下。生成器有几种输出粒度:只生成路由处理器签名、生成完整controller、或者连数据访问层一起生成。我一般只用到handlers-only,因为数据访问层和业务规则高度定制,强行生成只会让代码变丑。生成出来的handler只负责解包请求、校验参数、调用业务方法、写响应。真正业务逻辑留在service层手工编写。
同时,它会把规范里的components/schemas转换成对应语言的类型定义。String枚举会映射成TypeScript的union类型,数字也会映射成literal类型。这些类型和OpenAPI定义严丝合缝,前端页面拿到类型提示后,可以少看很多文档页。
2.2 校验:把规范质量关前置到提交之前
如果说生成是省力,那校验就是在省命。手写YAML规范特别容易出低级错误:路径拼写不一致、引用了未定义的schema、response里忘了包裹data字段。这些错误在编写时很难发现,但等生成代码时就会变成一串难懂的异常。
Spec-kit的validate命令做的不是简单的YAML语法检查,更偏语义检查。比如:路径模板里的变量必须在parameters里有定义;$ref不能形成循环引用;operationId在全局不能重复;4xx/5xx响应必须出现在全局responses或operation级别。
我还用它的规则配置能力加了团队自己的规范:
# .spec-kit/rules.yaml rules: camelCaseOperationId: error requireSummary: warning forbidTrailingSlash: error paginationParamsRequired: paths: - /todos这个文件是团队内部review的产物。比如我们要求所有operationId必须是camelCase,因为生成到TypeScript里就是函数名;要求summary必填,是为了后续自动生成文档的质量。paginationParamsRequired是我们自己写的扩展规则,专门检查列表接口是否都带了page和pageSize参数。
校验命令跑完以后,退出码会区分error和warning。CI里我们把error作为硬性门槛,warning只统计不拦截,这样既能把标准定住,又不会让琐碎的风格分歧阻塞合并。
2.3 契约测试:让“联调”变成一条命令的事
联调是很多团队的痛点根源,但契约测试能把它往前挪一大截。Spec-kit的contract命令,核心思路是:消费者端根据规范生成一个mock服务,提供最接近真实的接口行为;生产者端把服务跑起来,让契约测试工具请求真实接口,再用规范里的schema校验响应。
在开发阶段,前端不用等后端代码,直接跑一个本地mock服务,所有字段和响应示例都来自规范。这里的关键是“响应示例”。规范的example字段不只是文档装饰,Spec-kit会把这些示例织进mock服务,让前端拿到的假数据和后端仍在开发中的预期行为保持一致。
在后端,我们会在CI里跑一组契约断言:
spec-kit contract test --config .spec-kit/contract.yaml契约测试的配置长这样:
provider: baseURL: http://localhost:3000 healthCheck: /health contracts: - consumer: web file: ./tests/contracts/web-todo.yaml每条契约都来源于共享的规范文件,只是按消费者使用的路径做裁剪。后端改接口时,先跑一遍这批用例,如果哪里和规范不符会直接告诉你是哪个路径哪个字段。这比前端跑过来问一句“你把接口改成什么样了”要有章法得多。
3. 从零到一:用一份OpenAPI描述把待办服务完整落地
3.1 先写一份能过审的OpenAPI spec
我拿当时第一个用Spec-kit落地的服务举例:一个待办事项服务,功能不算复杂,但有标准和状态枚举,足够说明问题。我们选择OpenAPI 3.0.3,三个核心路径:创建待办、获取列表、更新待办状态。
规范文件的骨架是这样的:
openapi: 3.0.3 info: title: todo-service version: 1.0.0 paths: /todos: get: operationId: listTodos summary: 列出待办事项 parameters: - name: page in: query schema: type: integer - name: pageSize in: query schema: type: integer responses: '200': description: ok content: application/json: schema: type: object properties: items: type: array items: $ref: '#/components/schemas/Todo' total: type: integer post: operationId: createTodo summary: 新建待办事项 requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/NewTodo' responses: '201': description: created content: application/json: schema: $ref: '#/components/schemas/Todo' components: schemas: Todo: type: object required: - id - title - status properties: id: type: string title: type: string status: $ref: '#/components/schemas/TodoStatus' TodoStatus: type: string enum: - open - doing - done写规范有几个体会。第一,operationId想好再写,因为生成代码后它就是函数名,改名要连同测试一起改。第二,不要急着把所有响应字段都塞进去,缺少字段的后端响应能在契约测试里暴露,反而是好事。第三,$ref要尽量复用,同一个Todo定义在列表和详情里都引用同一处,改字段时可以一次到位。
写完后就跑spec-kit validate,它会先把循环引用和非法枚举这种低级问题扫出来。这一步我在本地做,成本很低,能省掉不少CI排队时间。
3.2 三行命令完成服务端与客户端生成
生成过程本身不复杂,配置才是重点。我们通过一个spec-kit.yaml把输入输出定死:
servers: - id: todo-service input: ./openapi/todo.yaml generators: - type: server language: nodejs framework: express output: ./server/src/routes options: handlerStyle: handlers-only withValidator: true - type: client language: typescript output: ./web/src/lib/todo-api options: fetchImpl: axios我特别在意withValidator这个开关。打开以后,生成的handler里会注入基于JSON Schema的请求校验逻辑。前端传了个不在枚举里的状态,直接在入口层被拦下来,返回统一格式的400。这套校验在过去是我手写一遍又嫌麻烦、不写又心慌的部分,现在由工具包住了。
执行生成命令后,服务端的目录结构大致是:
server/src/routes/todos/ listTodos.handler.ts createTodo.handler.ts server/src/routes/todos/types.tstypes.ts里的类型完全由components/schemas转换而来,我不用再看一遍文档确认字段类型。客户端的生成结果类似:
web/src/lib/todo-api/ client.ts types.ts listTodos.ts createTodo.ts前端只需要import { listTodos } from '@/lib/todo-api',传参时有完整的类型提示,接口函数的命名也跟规范保持一致,几乎不用看文档就能写业务代码。
3.3 业务代码与生成代码如何分工
生成代码能省力,但如果把业务逻辑写进生成代码,那规范一变,重新生成的时候你的手写部分就全没了。我一开始踩过这个坑,后来团队定下一条规矩:生成代码只出现在routes层,业务逻辑必须放在独立的services层。
比如待办服务的创建逻辑是这样的:handler层把请求体转成领域对象,然后调用todosService.create,领域规则全部在service里。handler层只做翻译,不写业务判断。
// listTodos.handler.ts(生成代码,不手改) import { todoService } from '../../services/todoService'; export async function listTodos(req: Request, res: Response) { const result = await todoService.list(req.query.page, req.query.pageSize); res.json(result); }这样即使我们改了规范里某个字段名,重新生成后只需要把handler里传给service的参数名顺一下,业务逻辑一行不用动。我的比喻是:生成代码是插座,业务代码是电器。插座位置可以挪、样式可以换,电器只要插头兼容就不会烧掉。
4. 在真实协作里跑通Spec-kit:配置、CI、团队习惯
4.1 breaking change检测:规范变更不能靠自觉
SDD带来的最大变化是:改规范变成了一件需要走流程的事。我们不再允许谁悄悄给接口加个字段,然后原子更新线上服务。任何人要改规范,都要先在PR里说清楚为什么改、是不是破坏性变更。
Spec-kit这里有个diff命令,直接对比两个版本的规范文件,自动判定变更语义:
spec-kit diff openapi/todo.yaml openapi/todo.next.yaml --format json它会输出一个变更清单,区分major和minor。比如删除一个字段、改字段类型、删掉一个枚举值,都会标记为major;新增一个可选参数、新增一个响应头,则是minor。判断口径和语义化版本一致。
我们在PR模板里要求提交者附上diff输出,并在描述里写清楚“本次变更将影响哪些消费方”。对于major变更,规定必须同时更新所有受影响客户端的调用代码,或者至少在PR描述里列出后续跟进计划。这一条规则执行了一个月后,以前最常见的“前端还在用旧字段”的冲突明显变少了。
4.2 CI流水线里的四道闸门
工具光在本地用是不够的,必须放进CI里才能保证每个人都遵守。我们的CI流水线围绕Spec-kit设了四道闸门,按先后顺序跑:
第一道是validate,规范文件的基础检查和规则检查,失败直接红灯。第二道是diff对比,当PR里包含规范文件改动时,CI会输出major或minor判断,并要求在PR描述里贴上结果。第三道是生成代码编译,服务端和客户端的生成代码都会执行一次类型检查,确保新规范没有产出类型错误。第四道是契约测试,后端服务以测试模式启动后运行契约用例,重点验证响应字段和状态码符合规范。
在GitLab CI里,对应的stage大概是这样的:
stages: - spec-check - build - contract spec-validate: stage: spec-check script: - spec-kit validate openapi/todo.yaml only: changes: - openapi/**/* build-server: stage: build script: - spec-kit generate server -i openapi/todo.yaml -l nodejs -o ./server/src - cd server && npm run typecheck contract-test: stage: contract script: - spec-kit generate client -i openapi/todo.yaml -l typescript -o ./web/src/lib/todo-api - cd server && npm run start:test & - sleep 5 - spec-kit contract test --config .spec-kit/contract.yaml有个细节是build-server里先跑生成再跑编译。因为在多人协作时,可能有人本地忘了重新生成代码直接提交,CI会先产出一份新鲜的代码,再验证它能不能通过类型检查。这样能够保证仓库里的生成物和规范是同步的。
4.3 模板定制和版本升级的教训
最后一节我想讲两个容易被忽略的工程问题:模板定制和工具升级。
Spec-kit默认生成的代码风格不一定完全适配团队规范,比如缩进、import风格、函数注释格式。刚开始我们想直接在生成器默认模板上改,后来发现升级工具时会覆盖。正确做法是把默认模板复制一份到.spec-kit/templates目录,然后在配置项里指定模板路径。这样工具升级时旧模板不会被冲洗,团队自己的约定可以长期保留。
另一个教训是版本升级不要跳过release notes。Spec-kit从0.8到0.9的时候,生成器配置项从framework改名成了runtime,并且校验规则的默认级别从warning变成了error。我们因为没看升级说明,CI直接红了一片。后来我们把这个教训固化成一条团队习惯:任何命令行工具的版本升级,都要在PR里附带官方迁移说明的链接,并且先在分支上跑一遍全量生成与校验。
团队习惯这块,我最后想补充一点:规范文件的review优先级应该和业务代码等同。看PR不能只看业务逻辑,还要看规范变更对下游的影响。我们的做法是每个涉及规范改动的PR都会指定一位前端同事参与review,因为他就是那个会被major变更砸中的角色。让受影响的人参与决策,比任何流程文档都管用。
如果让我给还没试过Spec-kit的人一句话建议:不要一上来就追求全自动生成全套服务,那样反而会被工具绑架。先把规范和契约测试跑起来,让“规范变更”这件事变得透明可追踪,你感受到的好处会推着你继续往生成的深度走。最后分享一个小技巧:把spec文件的首行version当成项目里最敏感的数字,每次改动都通过spec-kit diff看一眼,它会帮你拦住绝大多数不负责任的“顺手改一下”。