如果你经历过“接口还没定前后端就开始各写各的”那你一定明白联调那几周的滋味。最近半年我把团队的开发流程彻底翻了过来——先写OpenAPI规范再用Spec-kit把规范变成服务端骨架、客户端SDK和契约测试。说白了就是SDDSpecification-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看一眼它会帮你拦住绝大多数不负责任的“顺手改一下”。
阅读完成 · 觉得有帮助?