
规范和代码之间那道墙我替你们先撞了一回。先说项目背景。我所在的小组负责一个订单中台后端拆成了四个服务前端两个端管理后台 小程序外加一个数据看板。早期大家依赖接口文档协作后端先写 Controller然后补 Swagger 注解前端照着文档联调。听起来挺正常对吧真正跑起来才发现文档永远滞后于代码注释和实现经常各说各话前端等到后端部署完才能看到真实返回结构一改字段整个联调周期就往后滚两三天。后来我们引入了一个叫 Spec-kit 的工程化工具把整个协作方式改成“规范驱动开发”SDDSpec-Driven Development效果比我预期要好很多。这篇文章不是纯理论科普是把我们实际落地 Spec-kit 的过程、踩过的坑、参数怎么定、目录怎么拆、生成物怎么用原原本本写出来。如果你也在处理多端协作、接口漂移、文档不同步这类问题或者单纯想看看“契约先行”在一个真实项目中怎么跑通这篇应该能给你一套可以复用的思路。1. 为什么我会把目光转向规范驱动开发很多团队听到“规范驱动开发”第一反应是这不就是先写接口文档再写代码吗我们一直这么干的。但 SDD 和“先写文档”有本质区别。普通流程里文档是辅助产物代码才是唯一事实来源SDD 里规范文件才是唯一事实来源代码、测试、文档、Mock 数据全部由规范单向生成。这个顺序一旦反转整个协作模型就变了。1.1 API 文档和代码脱节的老大难问题先复盘一下我们之前踩的坑你可能也遇到过。第一类是文档失踪。后端同事写完接口Swagger 页面能看但没人维护 markdown 版的接口说明前端新人接手时只能靠猜。第二类是结构漂移。某个字段从number改成了string但只改了代码没改注解前端传参传 number 也能跑通等上线后真正打到线上环境才发现类型对不上。第三类是联调阻塞。前后端并行开发时前端需要的是可用的 Mock 数据后端需要的是不被频繁打断的编码时间但传统 Swagger 只能看到数据结构不能直接生成可调试的模拟服务前后端还是得按“后端完成 → 前端联调”的串行节奏走。这些问题的根子在于信息从代码到文档中间经过了人的转译。只要目标是“先有代码、后补文档”文档就永远是次等公民。要打破这个循环就得让规范从一开始就存在并且让所有产出物都从规范里长出来而不是等人去写第二遍。1.2 从“代码先行”到“契约先行”的思路转变我们决定试点整条链路以一份 YAML 规范为起点也就是把“接口长什么样”这个问题前置。一份订单查询接口的规范大致长这样endpoint: path: /v1/orders/{orderId} method: GET request: pathParams: orderId: type: string description: 订单号 queryParams: includeItems: type: boolean default: false description: 是否返回订单明细 response: status: 200 body: type: object properties: orderId: type: string status: type: enum values: [CREATED, PAID, SHIPPED, DONE, CANCELLED] totalAmount: type: number precision: 2 items: type: array items: $ref: #/definitions/OrderItem先别急着纠结语法。这段规范描述了三件事接口怎么调、参数什么类型、返回什么结构。于是后端可以根据这份契约去实现 Controller前端可以根据这份契约生成请求层代码测试可以根据这份契约自动生成用例文档工具可以根据这份契约生成更新及时的接口文档。这个转变最微妙的地方在于“责任前置”。之前是后端想清楚接口前端去适配现在是大家一起对着一份规范评审。整个团队对“这个接口该不该这样设计”的讨论被前置到了编码之前。虽然前期讨论时间变长了但后期返工次数明显减少。1.3 Spec-kit 在 SDD 里扮演什么角色规范文件本身只是静态文本要驱动整个工程流程必须有个工具把它变成动态资产。Spec-kit 做的就是这件事读入一个specs目录下的规范文件经过解析、校验、组装生成前端类型定义、后端路由标记、校验逻辑、Mock 数据和接口文档。你可以把它理解成一个“代码生产的流水线中枢”。我们用的命令大概有这几类spec-kit init # 初始化工程结构 spec-kit validate # 校验规范文件本身 spec-kit generate --target typescript # 生成 TS 类型与校验器 spec-kit generate --target openapi # 导出 OpenAPI 3.0 文档 spec-kit mock --port 4010 # 启动 Mock 服务 spec-kit watch # 监听规范变更并自动重新生成通过这个工具链规范文件成了唯一输入其余都是派生品。我看到有团队用原生脚本也能做类似的事但 Spec-kit 的价值在于它把解析、校验、模板渲染、增量生成这些脏活都封装好了semantic 上更接近一个完整的方案。2. Spec-kit 的整体设计拆解很多人第一次拿到 Spec-kit 会误以为它只是一个“OpenAPI 生成器”。实际上它的设计比 OpenAPI 生成器更贴近工程实践因为它的颗粒度是“团队协作流”不只是“文档流”。2.1 一份 Spec 文件怎么定义Spec-kit 的规范文件采用 YAML 格式我建议一个业务模块一个目录、一个接口一个文件。我们当时落地的目录结构长这样specs/ common/ address.yaml page.yaml order/ create.yaml detail.yaml list.yaml user/ profile.yamlCommon 目录放共享定义比如分页结构、地址结构、错误结构。Order 和 User 目录放业务接口。这样设计的好处是多人同时改不同模块Git 冲突概率大大降低评审时也能按模块走不用一次 Review 一整个大文件。每个接口文件内部需要声明以下区块基本元信息、请求定义、响应定义、错误定义、扩展钩子。元信息里我建议强制填owner和versionowner 写负责人的 IM 名字version 写接口版本号不是为了做给领导看而是后续变更通知能精准发到人。一个容易忽略的点是“扩展钩子”。规范文件里可以加x-handler这类自定义字段Spec-kit 会原样透传。我们用它标记“这个接口走异步消息队列”或“这个接口需要登录态”生成文档时就能把这些约束一并展示。规范要贴近业务不能只停留在 HTTP 层面。2.2 工程化流程解析、加工、生成Spec-kit 的流水线大致分四步我把它拆开讲便于遇到问题你知道是哪一段出的错。第一步是解析。工具把 YAML 读入内存转成一棵规范树同时对格式做基础校验。这步最容易出的问题我之前遇到过YAML 里写了两个同名属性解析器静默覆盖了后者。虽然 YAML 语法本身允许这种写法但 Spec-kit 会给一个 warning大家别忽略这个 warning。第二步是引用解析。刚才示例里有$ref: #/definitions/OrderItem这一步会把所有引用递归展开拼成一棵完整的对象图。这一步要特别注意循环引用。订单里引用用户用户里又引用订单列表处理不好就会死循环。Spec-kit 的处理方式是对象引用默认展开到第一层后续引用变成id引用。这样既能保证结构清晰又不会炸栈。第三步是校验。这一步不只是检查 YAML 合法还检查语义是否一致。比如 response 中标记了required: [orderId, status]但 properties 里没定义这两个字段规范过不了再比如枚举值有两个重复项也会报 warning。我们当时在 CI 里挂了spec-kit validate --strict一旦规范有问题构建就红这块投入很值。第四步是渲染。处理完的规范树按不同的 target 模板渲染成代码文件。这里有个细节Spec-kit 支持增量生成只有变更过的部分会重写对应文件不会把整个 generated 目录清空重建。这样能让 Git 历史更清晰也降低误伤手写文件的风险。2.3 生成物有哪些Spec-kit 最有价值的地方在于“同一份规范喂给不同端”。我们实际使用的生成物主要有这几类生成物目标端作用TypeScript 类型定义前端、后端避免手写 interface消除类型漂移Zod schema前端、后端运行时校验拒绝非法数据进入逻辑层OpenAPI 3.0 文档文档站、网关对接已有 API 生态方便非 Spec-kit 用户查看Mock 路由前端联调直接起一个本地 mock server按规范返回数据测试用例骨架测试根据边界条件自动生成用例模板枚举常量表多端状态机、下拉选项、字典统一维护列举一个实际收益。之前我们订单状态枚举前端维护一份orderStatus后端维护一份OrderStatus数据库里还有一份 tinyint 的映射。三处总有对不齐的时候。用 Spec-kit 后状态枚举只定义在common/order.yaml里生成出的 TS 常量、Java 枚举、数据库字典 SQL 都来自同一个源。后来加了一个REFUNDING状态前后端同时生效再也没有“前端枚举里没有这个状态”的鬼故事。有一点要提醒生成物不要手改。generated/目录应该在.gitignore里排除CI 里统一生成。如果某个生成物确实需要特殊处理我建议写一个薄薄的 wrapper 文件手写逻辑放在外面不要破坏“generate 产物 规范投影”的原则。否则一改生成文件下次 generate 就冲突又回到多份事实来源的老路上。3. 实操从一个订单接口看完整流程理论说再多不如跑一遍。我带大家完整过一遍“查询订单详情”接口从规范到联调的全流程命令和文件都可以直接抄。3.1 项目初始化与目录结构假设你已经把 Spec-kit 装好了npm 安装的spec-kit全局包或者项目内 devDependency 都行。第一步是初始化mkdir ecommerce-spec cd ecommerce-spec spec-kit init初始化后会自动生成specs/目录和一个spec-kit.config.yaml。配置文件里可以先只定义输出目录和生成规则version: 1 paths: specs: ./specs generated: typescript: ../frontend/src/api/generated openapi: ./dist/openapi.yaml mock: ../mock-server/routes targets: - typescript - openapi - mock mock: port: 4010 latency: 200这个配置文件的核心在于paths.generated它决定了生成物落到哪个目录。我们实际是把前端和后端放在同一个 monorepo 下所以 generated 目录可以跨项目写。如果你前后端仓库分开我建议把生成物提交到仓库或者走内部 npm 包分发不要让每个成员本地都配一套环境。3.2 编写接口规范在specs/order/detail.yaml写订单详情接口的规范。我们一步一步来先写基础元信息。meta: name: orderDetail caption: 订单详情查询 owner: 张三 version: 1.2.0 endpoint: path: /v1/orders/{orderId} method: GET tags: [order]接着定义请求参数。这里我用两个维度path 参数和可选 query 参数。request: pathParams: orderId: type: string pattern: ^ORD\\d{12}$ description: 订单号ORD 开头加 12 位数字 queryParams: includeItems: type: boolean default: false description: 是否携带商品明细 expand: type: array items: [user, payment] default: [] description: 需要展开的子资源这里pattern字段很实用。订单号通常有格式约定以前这段正则散落在前后端各自校验逻辑里现在写进规范后生成的多端校验规则天然一致。然后定义响应体。订单详情结构里一定包含状态、金额、地址、明细为了演示$ref引用我把通用结构拆到specs/common/address.yaml里。response: status: 200 body: type: object properties: orderId: type: string status: $ref: #/definitions/CommonOrderStatus totalAmount: type: number precision: 2 description: 订单应付总额单位元 items: type: array items: type: object properties: skuId: type: string name: type: string count: type: integer minimum: 1 price: type: number precision: 2 shippingAddress: $ref: common/address.yaml#/Address required: [orderId, status, totalAmount]这里注意required我故意只列了三个顶层字段因为items和shippingAddress有可能为空。空值处理是接口设计里最容易被忽略的地方规范里必须想清楚“哪些字段一定返回”“哪些字段可空”。Spec-kit 生成的校验器会自动带上optional标记前端类型也会体现为可空联合类型不会出现运行时炸undefined的情况。3.3 生成与消费产物写完后先跑校验spec-kit validate specs/order/detail.yaml校验通过后执行生成spec-kit generate --target typescript --target openapi到前端目录看看生成的generated/order.ts大概会是这样一个类型export interface OrderDetail { orderId: string; status: CommonOrderStatus; totalAmount: number; items?: Array{ skuId: string; name: string; count: number; price: number; }; shippingAddress?: Address; }前端请求层不再需要手写类型定义直接import { OrderDetail } from /api/generated/order。关键是当后端改了totalAmount的精度前端不需要知道任何事只要重新跑一次spec-kit generate所有用到该类型的代码地方都会在编译期暴露问题。对于后端Spec-kit 不是生成 Controller而是生成校验中间件和路由约束。我们接入的是一个小型 Node 服务生成物是一个基于 Zod 的 schema。请求参数进来先过校验不合法直接 400不需要在业务代码里写一堆 if。import { orderDetailRequestSchema } from ./generated/order.schema; router.get(/v1/orders/:orderId, async (req, res) { const result orderDetailRequestSchema.safeParse(req.params); if (!result.success) { return res.status(400).json({ error: result.error.flatten() }); } // 业务代码此时参数已经是被信任的 });这层校验很薄但能挡住大量“挂了半天才发现是参数格式不对”的低级问题。我们线上日志里跟参数校验相关的报警在接入后一周内下降了七八成。3.4 接入 Mock 与联调前端联调最烦的是等后端环境。Spec-kit 提供的 mock 模式可以直接按规范返回数据。执行spec-kit mock --port 4010然后前端把环境变量指向http://localhost:4010就行。Mock 数据不是死的它根据字段类型生成合理值字符串生成“字符串示例”枚举值在枚举里随机选金额生成保留两位小数的数字。更贴心的是它支持x-mock扩展比如你希望状态字段固定返回PAID可以这样写status: $ref: #/definitions/CommonOrderStatus x-mock: PAID前端对一个接口发起请求时Mock 服务会返回符合规范的数据。对比我们之前用 json-server 手写一个个 mock json这套方案省掉的是“mock 数据与真实接口不同步”的维护成本。因为 mock 路由由规范生成接口改了路径或参数mock 同步变更前端联调永远基于最新契约。4. 常见问题与排查技巧实录工具链跑起来之后更多的问题不是“工具不工作”而是“规范、类型、运行时”三者之间的精怪问题。这几类我实际遇到最多。4.1 Spec 变更后的版本管理问题场景第 3 周产品说订单金额要增加一个payableAmount字段但“不影响老的字段”。于是有人直接改了detail.yaml前端 generate 后所有相关类型都多了一个必填字段结果老页面编译报错同事怨声载道。这个问题的本质不是变更本身而是“破坏性变更”和“兼容性变更”没有被规范工具区分。后来我们在 meta 里加了compatibility字段meta: name: orderDetail version: 1.2.0 compatibility: additive同时把 CI 脚本里加入一个规则如果本次变更删除了字段、修改了类型、把 optional 改成 required则流水线提示“变更破坏性升级大版本”。这样至少让团队知道自己在干什么。经验是规范文件的 version 要和接口语义同步而不是和文件修改次数同步。一个字段改名哪怕只改一次也是 breaking change。另外规范要建立 review 机制。我们参考了代码 review 的做法在 MR 描述里自动带上spec-kit diff --format markdown的输出展示这次规范变更对前端类型、Mock 数据、文档的影响面。评审者看的是“影响”不是“别人改了哪一行”。4.2 类型定义与校验规则不一致问题场景totalAmount在规范里是number精度2但数据库存储是分整数后端返回前忘记除以 100前端展示金额直接少了一位。这种问题上手查半天最后发现是“规范声明和实际处理逻辑脱节”。Spec-kit 管不到业务代码里的换算逻辑但它给了我们一个改进机会把“单位”写进规范。我们在金额字段上加x-unit: 元在数量字段上加x-unit: 件。前端拿到生成表后可以通过读取x-unit来决定展示层是否要转换虽然这需要写一点自定义逻辑但至少信息不再只存在于后端同事的脑子里。运行时校验这一层也值得细说。Zod schema 的好处是它在运行时把关但生成的 schema 默认允许未知字段吗这个问题很关键。Spec-kit 的策略是默认strip也就是剥离未定义字段但你可以配置为passthrough或strict。我建议对外接口默认strip内部 RPC 之间默认strict这样既能容忍第三方客户端多传字段又能抓住内部服务之间字段错配。4.3 多团队协作时的口径冲突问题场景订单服务和支付服务都要引用CommonOrderStatus但有位同事在order/模块里又定义了一个status枚举枚举值顺序还不一样。合并代码时前端拿到了两个常量表到底信哪个这个问题的根源是“同一定义散落在多个规范文件”。后来我们立了一条铁规跨模块复用的类型只能定义在specs/common/下各业务模块不允许自带同语义类型。在 Spec-kit 的引用机制里跨文件引用要写完整的相对路径所以我们干脆给 common 目录开了一个别名common/拉齐了引用写法。现在做 code review 时凡是看到业务规范里出现与 common 重复的枚举定义都会被直接打回。还有一个小技巧我们给spec-kit validate加了一条自定义规则用正则检测 common 类型是否被业务模块重复声明。实际执行就一句命令spec-kit validate --strict --rule no-duplicate-enums这条规则来自一个我们内部的插件Spec-kit 支持自定义规则钩子大家可以按团队情况去写。一次投入的成本不高但能把“规范污染”在源头上拦住。常见问题速查表现象可能原因排查动作前端类型缺字段generated 目录被手改或旧版本缓存删除 generated 后重新 generate检查是否 trackedMock 数据不符合预期规范里缺x-mock配置给具体字段加x-mock确认枚举值拼写校验器报未知字段schema 是 strict 模式按内外网场景配置 strip/passthrough/strict生成后出现重复定义同一类型被多个 spec 引用统一收到 common 目录并启用去重校验循环引用导致生成卡死对象间互相引用使用引用折叠策略只展开一层深引用5. 一些体感和后续玩法上面这些流程跑顺之后最大的变化不是说代码写得少了而是整个团队的“焦虑感”降低了。以前前端总怕后端改接口不通知后端总怕前端拿错样例数据。现在大家对着规范的 diff 说话谁也别想悄悄改。我从这个项目里最深的体感是SDD 不是多一个文档步骤而是把团队从“低水平重复沟通”里解放出来。规范本身要花时间但省下的是更贵的联调、返工、上线事故。针对想要上手的团队我建议不要一开始就推全量规范先找一条两个月内不会大改的只读查询接口试点把“规范 → 生成 TS → 前端直接使用 → Mock 联调”这条最小链路跑通。团队成员看到收益后再逐步拓展到变更频繁的核心链路阻力会小很多。最后再分享一个小技巧配合 Git Hooks 使用很香。我们在pre-push阶段挂了spec-kit validate --strict任何不规范的文件都推不上远程。一开始大家觉得烦后来慢慢变成了“规范的底线由工具守而不是靠某个负责人吼”。如果你已经在为接口维护头疼可以试试这套思路让规范真正变成开发流程里的一等公民。