“规范驱动开发”这个概念我念叨了好几年,直到最近在一个后端团队里真正落地 Spec-kit,才觉得 SDD 从“理念正确”变成了“工程上真的能跑通”。不少人对 SDD 的理解还停留在“先写文档再写代码”,实际上它和 TDD(测试驱动开发)一样是一套完整的开发循环,区别在于 TDD 以测试为锚点,SDD 以规范(Spec)为锚点,而 Spec-kit 就是这套方法论里最缺的那块工程化拼图:规范解析、测试生成、Mock 服务、契约校验,一条龙。
如果你正在做前后端分离、微服务拆分,或者受够了“接口文档永远比代码旧”的协作方式,这篇文章值得看完。我会从 SDD 的核心思想讲起,再手把手拆 Spec-kit 的完整实操流程,最后把踩过的坑一次性列清楚。
1. SDD与TDD的关系:为什么规范要先于代码
1.1 传统开发流程的三个痛点
先聊聊我自己的经历。早年在做单体应用的时候,前后端都在一个仓库里,改接口的成本很低,出问题了直接改代码重启,没人抱怨。后来团队拆成了前端组、后端组、测试组,问题就来了:前端要等后端接口写完才能联调,后端写完接口要手动维护一份文档,文档更新不及时,前端拿到错误参数,一调试就是半天。更难受的是,测试同学要等双方都完成才能写用例,整个交付节奏被串行依赖拖得很重。
这个场景我相信很多人都熟悉。它本质上暴露了传统开发流程的三个痛点:第一,需求到代码之间缺少一个“可执行的中间层”,大家靠口头对齐和零散的需求文档做事,理解出现偏差要等到联调阶段才暴露;第二,前后端并行开发没有抓手,前端只能等后端,后端只能凭经验猜前端要什么;第三,测试用例、接口文档、类型定义这些本该从同一源头生成的产物,被人为地分开维护,于是“文档过时”“类型对不上”“测试补不完”成了常态化问题。
1.2 从TDD到SDD:锚点从测试前移到规范
TDD 的核心循环是“红-绿-重构”:先写一个失败的测试,再写最小代码让测试通过,最后重构。这套玩法对单元级别的逻辑非常有效,但放到接口级、系统级就有点吃力了。因为接口测试需要前后端契约稳定,而契约在 TDD 体系里没有被显式建模,大家还是靠口头商量或者临时翻代码。
SDD 做了一件很关键的事:把契约本身变成代码库里的“一等公民”。它要求你在写测试和实现之前,先用一种声明式语言描述输入、输出、错误场景,这就是 Spec。Spec 是人和机器都能读的:人能读懂业务约定,工具能基于它生成测试桩、类型定义、Mock 数据。TDD 里测试是锚点,SDD 里测试只是从规范推导出来的其中一种产物,真正的锚点是规范本身。
打个比方,TDD 像你直接开始砌墙然后拿尺子量,SDD 则是先画施工图,再用图纸来指导砌墙和验收。后者看起来多了一道工序,但在多团队协作时,这道工序省下的沟通成本是巨大的。
| 对比维度 | 传统开发 | TDD | SDD |
|---|---|---|---|
| 首要产物 | 代码 | 测试 | 规范(Spec) |
| 契约载体 | 口头/文档 | 测试代码 | 声明式规范文件 |
| 并行支持 | 弱,前后端串行 | 中等 | 强,基于Spec生成Mock |
| 文档一致性 | 靠自觉 | 测试即文档 | 规格即契约自动同步 |
| 适合场景 | 简单项目 | 算法/单模块 | 接口层/多团队协作 |
2. Spec-kit的核心能力拆解:它到底解决了什么
2.1 从规范到代码的自动生成链
Spec-kit 的核心定位通俗一点说,就是“让规范文件长成一套可运行的工程骨架”。你写一份描述接口行为的 YAML 或 JSON,它负责生成对应的 TypeScript 类型、OpenAPI Schema、Jest/Vitest 测试脚手架以及 Mock 服务的数据模板。工具本身用 CLI 来驱动,命令大致是spec-kit generate、spec-kit mock、spec-kit validate这三板斧。
它解决的最直接问题,是消灭“手写样板代码”。我见过太多团队,明明定了接口规范,还要有人手工把字段一个个翻译成 TypeScript interface,再另一个人手工写测试断言。这个过程不仅枯燥,而且极其容易出低级错误——字段名拼错、类型写反、可选非可选搞混。Spec-kit 把这一层自动化之后,人只需要把精力放在规范本身是否合理,而不是机械翻译。
2.2 为什么专门为“规范驱动”做一个工具
听到这里你可能会问:这不是搞个 JSON Schema + 一个代码生成器就能解决的吗,为什么非得用 Spec-kit?我的看法是,SDD 的特殊性在于规范要贯穿整个项目生命周期,这个链路比“生成一次类型定义”复杂得多。规范更新了,测试桩要跟着更新;Mock 数据要符合新规范;CI 上要自动校验业务代码是否还符合规范;甚至错误响应的格式也要从规范里推导出来。这些事情散落在不同工具里没问题,但拼起来很麻烦,Spec-kit 做的是把所有和规范相关的工程化能力收敛到一处,让“规范即代码”这件事变成一整个体系。
另外,Spec-kit 的规范文件本身很强调可读性和可评审性。代码评审时看一堆实现逻辑很费劲,但看一份声明式的 Spec 就快很多——每个字段有没有,返回什么错误码,约束是否合理,一目了然。这个设计选择是有意为之:SDD 的本质是沟通,如果规范文件本身复杂到没人愿意看,那 SDD 就失去了意义。
2.3 Spec的三种典型粒度
使用过程中我发现,Spec 并不是越大越好,规范驱动开发也不是要求你给所有代码都配一份规格说明。根据实际项目体量,我会把 Spec 分成三种粒度来做区分。
第一种是接口级规范,描述一个 HTTP API 的入参、出参和错误码,这是最常见也最容易推行的;第二种是领域方法级规范,描述一个 Service 方法的行为,比如“createOrder 在库存不足时抛异常”,这种规范不需要关注 HTTP,只关注业务逻辑的输入输出;第三种是事件级规范,描述一条消息在消息队列里的结构,适用于事件驱动的架构。粒度选择的原则很简单:接口层面越细越好,内部私有方法没必要写 Spec,写了反而拖慢节奏。Spec 的边界就是系统对外的契约边界。
3. Spec-kit实操流程:从一份规范到可运行代码
3.1 编写你的第一份规范文件
实践 SDD 的第一步,是先写一份最小化的规范文件。以我的经验,用 YAML 起步最合适,因为它比 TypeScript 更容易被非后端同事读懂,也天然表达嵌套结构。下面我给你看一个真实项目中“创建订单”接口的规范,这个示例基本涵盖了 Spec-kit 最常用的语法。
name: createOrder version: "1.0.0" description: 创建订单接口 input: userId: type: string required: true items: type: array required: true item: productId: type: string quantity: type: number minimum: 1 output: orderId: type: string totalAmount: type: number status: type: string enum: [CREATED, PENDING] errors: - code: PRODUCT_NOT_FOUND message: 商品不存在 - code: INSUFFICIENT_STOCK message: 库存不足这里有几个值得强调的设计。第一,output 里的 status 用了 enum 而不是自由字符串,这是为了约束返回值的取值空间,测试生成器可以直接拿它做边界断言。第二,required 单独拿出来,是为了让生成器能构造“缺字段”的负面测试用例。第三,errors 区定义了业务错误码,后续 Mock 服务可以根据这些错误码生成模拟失败响应,联调时非常方便。
写规范的时候最忌讳的,是试图把实现细节也写进去。比如你不需要在规范里指定数据库字段名、不需要指定 HTTP Method 之外的 URL 结构(这些通常由路由配置管理),规范应该聚焦行为约定。一旦你开始在规范里表达“如何实现”,它就变成了第二份设计文档,维护成本立刻翻倍。
3.2 生成测试桩与类型契约
规范文件写好后,进入第二步。把order.yaml放到项目的specs/目录下,然后运行:
spec-kit generate ./specs/order.yaml --language typescript --test-framework vitest这个命令会扫描 Spec 文件并生成三个关键产物。第一个是 TypeScript 类型定义,创建订单接口的入参和出参会被编译成 interface,团队不再需要手写任何 DTO。第二个是 Vitest 测试桩文件,里面包含了 happy path 断言和基于 errors 生成的异常场景用例。第三个是 OpenAPI 规范片段,可以直接合并到团队的 API 文档体系中。
以测试桩为例,上面那份规范会生成类似这样的代码:
import { describe, it, expect } from 'vitest'; import { createOrder } from '../src/services/order'; import type { CreateOrderInput, CreateOrderOutput } from '../types/order'; describe('createOrder spec', () => { it('should return a valid success response', async () => { const input: CreateOrderInput = { userId: 'user_001', items: [{ productId: 'prod_001', quantity: 2 }], }; const result: CreateOrderOutput = await createOrder(input); expect(result.status).toBe('CREATED'); expect(result.orderId).toEqual(expect.any(String)); expect(result.totalAmount).toEqual(expect.any(Number)); }); it('should reject input that violates required constraints', async () => { // @ts-expect-error 缺少 userId await expect(createOrder({ items: [] })).rejects.toThrow(); }); it('should return PRODUCT_NOT_FOUND error when product is missing', async () => { await expect(createOrder({ userId: 'user_001', items: [{ productId: 'not_exist', quantity: 1 }], })).rejects.toMatchObject({ code: 'PRODUCT_NOT_FOUND' }); }); });注意,Spec-kit 生成的测试是“桩”而不是“成品”。它的价值是把你从测试脚手架的体力劳动中解放出来,但具体的业务 mock(比如createOrder内部应该怎么判断商品是否存在)仍旧需要你手动实现。很多人栽在“生成完直接跑”的预期上,实际上生成器只负责骨架和断言,业务逻辑永远要自己补全。
3.3 Mock服务与前后端并行开发
测试生成之外,Spec-kit 最让我觉得值回票价的能力是 Mock 服务。前端在接口还没开发出来的时候,直接基于规范跑一个本地 Mock 服务就能联调,流程瞬间从串行变成了并行。启动方式很简单:
spec-kit mock ./specs/order.yaml --port 3001Mock 服务会读取规范里的 input/output 和 errors 定义,自动生成符合结构的模拟响应。对于createOrder,访问/createOrder就会返回类似:
{ "orderId": "mock-8f3a", "totalAmount": 99.9, "status": "CREATED" }如果想模拟异常情况,可以带一个x-mock-error: PRODUCT_NOT_FOUND的请求头,Mock 服务就会返回 404 和对应的错误体。这个细节对前端联调“异常分支”非常有用,以前想让后端临时造一个商品不存在的场景,要改数据库,现在 Mock 时代一个 header 就搞定了。
我在团队里推行的做法是,前端启动命令默认指向localhost:3001,后端接口开发完成后,前端只要改一个环境变量切换成真实服务,页面代码一行都不用动。这个体验相比以前“等接口、对字段、调不通”的流程,效率提升是非常明显的,尤其适合多个前端并行、后端又有独立开发节奏的项目。
3.4 用validate命令守住规范一致性
有了规范、测试和 Mock,还缺一个“守门员”角色,确保后续代码变更没有悄悄偏离当初约定的契约。Spec-kit 提供了validate命令,可以扫描代码目录,结合已生成的测试桩运行契约校验,检查实现是否符合规范定义。
spec-kit validate ./specs --code ./src --test ./tests这个命令的本质,是利用规范生成一套断言,再拿这个断言去约束真实实现。只要有人改了接口返回结构,却没有更新 Spec,或者改了 Spec 却没有更新实现和测试,CI 里跑一次 validate 就能立刻发现。这个“三方校验”的能力是我认为 Spec-kit 区别于普通代码生成器最关键的地方:它让规范不是一次性资产,而是持续发挥作用的活文档。
我建议把 validate 放进 CI 流程里,作为 merge request 的必过检查之一。这一步最好的结果是让团队形成一种条件反射——改接口之前先想“规范要不要改”。一旦形成这种习惯,接口管理混乱的问题就会自然消退。
4. 落地SDD的常见问题与排查技巧实录
4.1 规范膨胀:当Spec变成第二份代码
规范驱动开发落地最常见的坑就是“规范膨胀”。我见过一个团队,把每个service方法的每一行逻辑都试图用声明式语言表达出来,最后 Spec 文件比代码还要长,维护 Spec 的时间远超写代码的时间,项目还没上线就想放弃 SDD。
Spec 的正确边界是“外部可观察到的行为”,不是“内部实现步骤”。比如createOrder内部要计算税费、扣减库存、生成订单号,这些不需要写进 Spec;但“入参缺 userId 时拒绝请求”“商品不存在时返回 PRODUCT_NOT_FOUND”就值得写。换句话说,规范描述的是接口契约和业务规则,不是算法流程。这个原则一定要在一开始就和团队达成共识。
4.2 规范漂移:代码改了Spec没更新
第二个高频问题是我们内部叫“规范漂移”,也就是实现已经被改得面目全非,但 Spec 还停留在两周前的状态。这种情况最容易发生在“这人只改了代码没跑 validate”的时点。唯一的解法就是 CI 硬性卡点,把spec-kit validate设成必须通过的任务,而不是靠自觉。
另外有一个小技巧:在代码 review 模板里加一个勾选项“本次变更是否涉及接口契约变化?如果是,是否已同步更新对应 Spec?”这一行不起眼但特别有效,团队潜意识里会开始把规范当成接口的一部分来重视。习惯养成的成本,其实比想象中低。
4.3 关于Spec和OpenAPI标准的关系
还有一个很多人问的问题:有了 OpenAPI,还需要 Spec-kit 吗?或者反过来,有了 Spec-kit,还要写 OpenAPI 文档吗?我的实践结论是两者互不取代。Spec-kit 的规范文件是源头,它更偏向“行为契约”,写起来比 OpenAPI 精简得多;而 OpenAPI 是面向消费者(前端、外部系统)的完整文档,包含详细的鉴权方式、响应示例、限流策略等。Spec-kit generate 可以从规范生成 OpenAPI 片段,填入文档系统,这样就做到了“先有行为约定,再有对外文档”,而不是文档硬生生从代码里反推。
4.4 问题排查速查表
根据这几个月的实际踩坑经验,我整理了一份速查表,你可以直接贴在项目 README 里。里面描述的现象、原因和解决方案都是项目里真实发生过的。
| 现象 | 可能原因 | 排查与解法 |
|---|---|---|
| generate 生成的测试跑不通 | 业务 mock 未实现 | 检查对应 service 文件是否有真实逻辑,生成桩不包含业务代码 |
| Mock 返回的数据不符合预期 | 规范里 missing 字段或 enum 写错 | 核对 Spec 中 output 字段拼写与 enum 取值 |
| validate 报错但代码“看起来没问题” | 接口返回值顺序变了/新增了字段 | 运行spec-kit inspect ./specs查看实际契约 |
| 团队不想写 Spec | 规范写得像设计文档 | 从接口级规范开始,控制规范粒度,展示 Mock 并行开发的成效 |
4.5 我踩过的坑与最终的坚持
最后一个想聊的,是我自己最初的误判。我最早觉得 SDD 不就是写文档嘛,多一坨文件徒增负担;真正使用 Spec-kit 之后,我才意识到差别在于“文档是人读的,规范是机器也读的”。这份文件能生成测试、生成 Mock、在 CI 里守门,它的价值不是“写一份说明”,而是把协作契约变成系统工程的一部分。
规范驱动开发不是银弹,它适合的场景是:接口边界清晰、协作团队多、需求变更频繁的工程。如果你是一个人在写一个小工具,SDD 带来的收益确实不大;但如果你在维护一个中大型系统,尤其是前后端分离、多团队并行的时候,Spec-kit 把规范落成工程的这套能力,真能让开发节奏顺滑不少。我的建议是,不要一上来就大而全地铺开,先挑一个核心接口试点,用 Mock 服务让前端尝到甜头,再逐步推广——工具的工程化能力再强,也需要团队一步一步找到适合自己的节奏。