接手过团队里一堆乱糟糟的接口文档之后,我对“SDD 规格驱动开发”这个词有了完全不一样的认识。很多人第一次听到SDD,以为它就是“多写一份文档”,或者“用Swagger生成个接口页面”。真不是这样。规格驱动开发(Specification-Driven Development)的核心,是把一份机器可读、人也可读的规格描述,比如OpenAPI规范的JSON或YAML文件,当作整个开发流程的唯一事实源。代码、测试、文档、Mock服务,全部从这份规格里生长出来。我最近整理了一套可直接复用的SDD文档模板,结合JSON接口文档的写法,今天把完整的思路、模板结构和落地过程都拆开讲讲。这套东西适合正在做前后端分离的团队、被接口变更折磨的项目组,也适合想建立一套干净契约机制的开发者。
1. SDD到底解决什么问题——重新认识规格驱动开发
规格驱动开发并不是什么横空出世的新概念,它的底层逻辑其实很朴素:在写业务代码之前,先把“系统之间怎么对话”这件事定义清楚。但真正把它落到项目里,你会发现它远比“写文档”这件事要深得多。
1.1 为什么是“规格”而不是“文档”
传统开发流程里,文档和代码是两条线。产品写了需求文档,后端据此设计数据库和接口,前端根据后端口头描述或者一份Markdown文档来联调。问题在于,这两条线一旦开始并行,过两周文档就过期了。代码改了接口参数,文档没更新;前端按文档传参,后端接口直接报400。这种“文档与代码漂移”的问题,几乎每个团队都遇过。
SDD的思路是把文档升级为“规格”。规格不是一个给人阅读的Word说明,而是一份结构化的、机器可校验的数据,最常见的形式就是OpenAPI规范文件(JSON或YAML格式)。它同时具备两个特性:人能看懂,机器能解析。这就意味着规格文件可以被自动化工具校验、被代码生成器消费、被Mock服务加载、被测试框架断言。它不再是开发的附属产物,而是开发的起点。
拿一个最简单的例子来说:你在规格文件里定义了/orders这个路径的POST请求需要接收一个order_id字段,类型是string。后端可以直接从这个规格生成接口骨架代码,前端可以生成类型定义和请求方法,测试团队可以生成基本的参数校验用例。同样一份文件,驱动了三条线的产出,而且大家看到的版本完全一致。这就是SDD最核心的价值:单一事实源。
1.2 SDD、TDD、DDD的关系与边界
经常有人把SDD和TDD放在一起比较。TDD是测试驱动开发,先写失败的单测,再写让它通过的实现代码。DDD是领域驱动设计,先从业务领域建模出发设计系统结构。而SDD关注的是系统之间、模块之间的契约。它不是用来替代TDD或DDD的,而是和它们配合使用。
打个比方:DDD负责的是“你要盖什么样的房子”,TDD负责的是“每一块砖怎么砌才结实”,而SDD负责的是“几个房间之间门怎么开、门框尺寸是多少”。没有门窗的规格,房间盖好也没法住人。在实际项目中,我在设计微服务间API时先写SDD规格,在写核心业务逻辑时配合TDD,在划分模块边界时参考DDD的思路,三者在不同层面各司其职,互不冲突。
1.3 规格驱动开发的三层收益
把SDD落到项目里,我感受到的收益可以分成三个层次。第一层是协作层面,前后端可以真正做到并行开发。前端拿到规格文件后,可以用Mock工具直接模拟出后端服务,不用干等后端代码完成;后端照着规格开发,也不用反复被前端问“这个字段到底叫userId还是user_id”。第二层是质量层面,规格本身就是可执行的契约,生成的校验代码可以拦截掉大量低级参数错误。第三层是维护层面,当接口需要变更时,先改规格、评审规格、再改实现,这个过程会强制团队思考变更的兼容性,避免“随手改个参数”引发的连锁故障。
我自己最深的感受是:SDD把“沟通成本”转化成了“规范成本”。前期定义规格会花一些时间,但后期省掉的是大量扯皮、返工和线上事故排查的时间。
2. 核心蓝图——一套直接用得上的SDD文档模板
规格驱动开发的第一步,也是最重要的一步,就是搭建文档模板。这里的“模板”不是指一份空的Markdown模板,而是一个符合OpenAPI规范的JSON或YAML骨架。我推荐用YAML格式写规格,原因后面细说。先把模板的核心结构和字段拆开来看。
2.1 模板的顶层结构
一份标准的OpenAPI 3.0规格文件,顶层必须包含这几个核心字段:openapi、info、paths。可选但强烈建议的字段有servers、components、tags、security。我平时整理团队模板时,会把这些字段全部铺出来,并且写好注释和示例值,让新成员拿到模板就知道哪里该填什么。
openapi: 3.0.3 info: title: 订单服务 API version: 1.0.0 description: 订单服务的SDD规格文档,所有订单相关的接口契约以此文件为准。 contact: name: API 负责人 email: api-owner@example.com servers: - url: https://api.example.com/v1 description: 生产环境 - url: https://staging.api.example.com/v1 description: 预发布环境 paths: /orders: $ref: './paths/orders.yaml' /orders/{order_id}: $ref: './paths/orders_{order_id}.yaml' components: schemas: $ref: './schemas/_index.yaml' tags: - name: Order description: 订单相关操作这个模板看起来简单,但里面有三个值得注意的设计选择。
第一个选择是用$ref拆分文件。很多初学者喜欢把整个API规格写在一个超大文件里,几百行甚至上千行。说实话,短期内能用,但一旦路径变多,文件会变得极其难以维护。我习惯把schema放一个目录、paths放一个目录,通过$ref引用。这有点像写代码时的模块化拆分,每个文件只管自己的事。
第二个选择是明确指定了servers。很多团队忽略这个字段,但在多环境部署的场景下,明确标注生产、预发布环境地址能避免非常多的联调乌龙。前端拿到规格后可以直接替换baseURL进行联调。
第三个选择是tags。给接口打标签不是形式主义,它在生成文档时会把接口按标签分组,同时也引导后端在代码里按模块拆分控制器。我见过太多不分模块的巨型Controller,tag的约定能从契约层面助推代码结构改善。
2.2 路径与操作的写法规范
在paths部分,每一个操作(也就是HTTP方法)都需要定义operationId、summary、parameters、requestBody和responses。其中operationId特别重要,它是前端生成调用方法名、后端生成函数名的依据。比如我们定义operationId: listOrders,前端生成的请求方法就会叫listOrders,后端生成的控制器方法也会叫listOrders。两边代码浑然一体,debug的时候对得上号。
paths: /orders: get: tags: - Order operationId: listOrders summary: 查询订单列表 description: 按条件分页查询当前用户的订单列表。 parameters: - name: status in: query description: 按订单状态过滤,不传则返回全部。 required: false schema: type: string enum: [pending, paid, shipped, completed] - name: page in: query description: 页码,从1开始。 required: false schema: type: integer minimum: 1 default: 1 - name: page_size in: query description: 每页条数,最大100。 required: false schema: type: integer maximum: 100 default: 20 responses: '200': description: 查询成功 content: application/json: schema: type: array items: $ref: '#/components/schemas/Order' '400': $ref: '#/components/responses/BadRequest'这里有个小细节:description字段。很多团队的规格文件里description是空的,我觉得特别可惜。description不仅仅是给人看的注释,在生成接口文档时它会直接展示给调用方。好的description应该写清楚这个接口是干嘛的、参数有什么限制逻辑、返回数据大概长什么样。比如“不传则返回全部”这句话,就能帮前端省掉一次追问。
还有enum和minimum这类约束条件,不要怕麻烦,能写就写。这些约束不仅仅是文档,它们会被代码生成器转换为类型定义里的联合类型或校验规则。我在实际项目里就遇到过前端传了status=finished而后端只接受completed的情况,如果规格里定义了enum,前端在编译阶段就能发现错误,根本不用等到联调去踩雷。
2.3 为什么选YAML而不是JSON
严格来说,OpenAPI支持JSON和YAML两种格式。我强烈推荐YAML,原因有三个。第一是可读性,YAML的缩进结构天然适合表达层级关系,比JSON满屏幕的大括号和中括号看起来舒服太多。第二是注释支持,JSON官方的格式不支持注释,虽然有些工具能解析带注释的JSON,但毕竟是非标准行为。而YAML支持#注释,规格文件里可以写大量解释性文字,对团队协作非常友好。第三是配置能力,YAML支持锚点和别名,在定义重复结构时可以用&和*复用,减少冗余。
当然,JSON也有自己的优势:几乎所有编程语言都有原生的JSON解析库,不需要额外引入YAML解析依赖。如果团队有工具链层面的强约束,比如某些API网关只接受JSON格式的规格,那用JSON也没问题。但是从日常开发和维护体验来说,YAML赢得很轻松。我的习惯是:源文件用YAML维护,在CI流程中通过工具转换成JSON格式的产物,用于生成代码和文档。
3. 实操记录——从零搭一份SDD规格文档
理论说得再多,不如亲手过一遍。下面我把整个SDD规格文档的搭建过程,按照我实际执行过的路径,完整走一遍。这次以“订单服务”为例,目标是把订单查询、订单详情、创建订单三个接口,定义成一份可以直接驱动开发和测试的规格文档。
3.1 准备基础工具链
工欲善其事,必先利其器。我推荐的SDD工具链非常简单,不需要昂贵的商业产品,都是免费开源的。
| 工具 | 用途 | 备注 |
|---|---|---|
| VS Code | 编缉规格文件 | 安装OpenAPI(Swagger) Editor扩展 |
| swagger-cli | 校验和打包规格文件 | npm全局安装 |
| openapi-generator | 生成前后端代码 | 支持Java、TypeScript、Go等 |
| Redocly CLI | 生成美观的HTML文档 | 也可以直接用Swagger UI |
安装命令很简单:
npm install -g @apidevtools/swagger-cli npm install -g @openapitools/openapi-generator-cli这里给新手一个提醒:swagger-cli validate是校验语法用的,openapi-generator是生成代码用的,两个不要搞混。我之前见过同事把校验失败当成代码生成失败,排查半天才发现是工具用错了。
3.2 定义API的整体轮廓
在动手写paths之前,我会先花时间梳理“这个系统对外提供哪些能力”。这个步骤特别像画房子之前先画平面图。订单服务需要提供三类能力:订单列表查询、订单详情查询、订单创建。每个能力对应一个路径和方法:
GET /orders:查询订单列表GET /orders/{order_id}:查询订单详情POST /orders:创建订单
路径设计看起来简单,但有两个决策点。第一是资源命名用复数/orders而不是/order,这是RESTful的通行惯例,复数表达的是资源的集合概念。第二是路径参数用{order_id}而不是{id},在复杂系统里,这种明确的命名能避免歧义,代码生成时变量名也不会叫模棱两可的id。
3.3 定义共享Schema
规格文档里最容易被忽视、但最该花时间的部分是components/schemas。只用过一两次的地方可以直接在路径里写匿名结构,但像订单状态、金额、分页结果这类会被反复引用的结构,一定要抽取成共享Schema。抽取的好处是:改一处,全链路生效。
components: schemas: OrderStatus: type: string enum: [pending, paid, shipped, completed, cancelled] description: 订单状态枚举 Currency: type: string enum: [CNY, USD, EUR] description: 货币类型 default: CNY Money: type: object required: [amount, currency] properties: amount: type: number format: decimal description: 金额数值,单位为主币单位 example: 99.90 currency: $ref: '#/components/schemas/Currency' Order: type: object required: [order_id, user_id, status, items, total_amount, created_at] properties: order_id: type: string format: uuid description: 订单ID,全局唯一 example: 7a4f3e2b-9876-4a1b-9c3d-2e8f0b1a5c42 user_id: type: string description: 下单用户ID example: user_10086 status: $ref: '#/components/schemas/OrderStatus' items: type: array description: 订单商品明细 items: $ref: '#/components/schemas/OrderItem' total_amount: $ref: '#/components/schemas/Money' created_at: type: string format: date-time description: 下单时间,ISO 8601格式 example: '2024-06-01T10:00:00Z' OrderItem: type: object required: [sku_id, name, quantity, unit_price] properties: sku_id: type: string description: 商品SKU ID example: sku_8899 name: type: string description: 商品名称快照 example: 无线蓝牙耳机 quantity: type: integer minimum: 1 description: 购买数量 example: 2 unit_price: $ref: '#/components/schemas/Money' PageResult: type: object required: [items, total, page, page_size] properties: items: type: array description: 当前页数据 total: type: integer description: 总条数 example: 365 page: type: integer example: 1 page_size: type: integer example: 20这里面有三个我自己总结的经验点。
第一个经验是关于金额类型的定义。绝不要用float或double表示金额。为什么?因为浮点数在计算机内部是二进制表示的,0.1在二进制里是一个无限循环小数,浮点数运算会产生精度误差。比如0.1 + 0.2在JavaScript里结果是0.30000000000000004,这在金额计算里是不能接受的。我的习惯是在规格层面定义Money对象,把金额拆成amount(数值)和currency(币种),后端在实现时用decimal定点数或整数分存储。
第二个经验是枚举值用OrderStatus而不是在properties里直接写字符串。这样做的好处是,前端生成的TypeScript类型里会对应生成一个OrderStatus联合类型,后端Java代码里会生成对应的枚举类,前后端的合法取值天然统一。我有次因为图省事直接写了type: string,结果前端把pending拼成了pendng,直到联调才发现。从那以后凡是有固定取值范围的字段,一律抽枚举Schema。
第三个经验是PageResult这种泛型结构。严格来说OpenAPI 3.0不支持真正的泛型,但可以通过items: {}配合具体接口的引用覆盖来实现类似效果。我在实际项目中,还是会为每个分页接口单独定义返回结构,虽然会多写几行,但生成的代码类型更精确,前端调用时不需要手动作类型断言。
3.4 实现核心接口路径
有了共享Schema打底,接口路径的定义就变成填空了。创建订单接口在定义requestBody和responses时有一些值得展开说明的细节。
paths: /orders: post: tags: [Order] operationId: createOrder summary: 创建订单 description: | 提交商品清单创建订单。订单创建成功后,状态为 pending。 需要认证,请求头需携带用户的token。 requestBody: required: true content: application/json: schema: type: object required: [items, shipping_address] properties: items: type: array description: 商品SKU及数量列表 minItems: 1 items: type: object required: [sku_id, quantity] properties: sku_id: type: string example: sku_8899 quantity: type: integer minimum: 1 maximum: 99 example: 1 shipping_address: type: object required: [receiver, phone, province, city, detail] properties: receiver: type: string description: 收货人姓名 example: 张三 phone: type: string description: 收货人手机号 pattern: '^1[3-9]\d{9}$' example: '13800138000' province: type: string example: 广东省 city: type: string example: 深圳市 detail: type: string example: 南山区科技园某某路1号 responses: '201': description: 创建成功,返回新订单详情 content: application/json: schema: $ref: '#/components/schemas/Order' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '422': $ref: '#/components/responses/UnprocessableEntity'注意这里响应码用的是201而不是200。创建类接口返回201 Created是HTTP语义的规范做法,表示资源创建成功。很多后端开发习惯所有成功都返回200,前端拿到响应体再去判断有没有创建成功,这其实是模糊了HTTP状态码本身的表达力。从规格层面把这个规范定死,后端实现时就有据可依。
还有pattern字段定义了手机号的正则校验。可能有人觉得手机正则写进接口规格里有点过度设计,但我认为这种约束越早定义越好。它可以被前端表单校验直接复用,也能在Mock阶段就拦截掉非法数据。
再来看订单详情接口,重点在于路径参数的处理和错误响应的定义。
paths: /orders/{order_id}: get: tags: [Order] operationId: getOrderById summary: 查询订单详情 parameters: - name: order_id in: path required: true description: 订单ID schema: type: string format: uuid responses: '200': description: 查询成功 content: application/json: schema: $ref: '#/components/schemas/Order' '404': $ref: '#/components/responses/NotFound'in: path的参数会自动放进URL路径里,和paths中{order_id}的占位符对应。这里有个常见的坑:路径参数的schema里如果定义了example,生成代码时会出现在URL路径的示例值中,但不会影响实际调用。真正重要的是required: true必须显式声明,虽然路径参数按规范必然必填,但显式声明后生成的代码签名会正确标注非空。
3.5 统一错误响应规范
一个非常容易被忽略的点,就是全局的错误响应格式。很多团队的规格文件里,每个接口的4xx响应只有一个description字段,响应体完全没定义。这让前端在报错处理时非常痛苦,因为不知道后端会返回什么结构,只能靠猜。
我建议在components区域统一定义响应结构,再通过$ref引用。下面是我常用的错误响应规范。
components: responses: BadRequest: description: 请求参数错误 content: application/json: schema: $ref: '#/components/schemas/ApiError' Unauthorized: description: 认证失败或登录已过期 content: application/json: schema: $ref: '#/components/schemas/ApiError' NotFound: description: 资源不存在 content: application/json: schema: $ref: '#/components/schemas/ApiError' UnprocessableEntity: description: 请求体校验失败 content: application/json: schema: $ref: '#/components/schemas/ApiError' schemas: ApiError: type: object required: [code, message, request_id] properties: code: type: string description: 业务错误码,用于程序化处理 example: ORDER_STOCK_NOT_ENOUGH message: type: string description: 人类可读的错误信息,可展示给用户 example: 库存不足 request_id: type: string description: 服务端请求ID,用于排查问题 example: c3ab8ff7-4d5e-4f1a-9f2d-0e66bca2f78c details: type: object additionalProperties: type: array items: type: string description: 字段级别的错误详情,key为字段名,value为错误描述列表 example: quantity: ['不能大于库存数量']统一错误响应的好处,在联调阶段体现得最明显。前端只需要写一个拦截器统一处理ApiError结构,弹错误提示、上报日志、跳转登录页,逻辑可以做得非常干净。我在项目里规定所有接口的4xx错误都返回这个结构,后端实现时只需要往对应字段里填充内容,对错和排查都容易得多。
3.6 校验打包与代码生成
规格文件写完之后,不能直接扔进代码仓库就完事。先做校验,再做打包,最后才是代码生成。校验可以检查出格式错误、引用是否存在、字段类型是否正确等问题。
# 校验单体文件 swagger-cli validate openapi.yaml # 如果使用 $ref 拆分了多个文件,先打包成单个文件再校验 swagger-cli bundle openapi.yaml -o dist/openapi.bundle.yaml swagger-cli validate dist/openapi.bundle.yaml校验通过之后,就可以生成代码了。这里以Java Spring Boot和TypeScript前端为例展示命令。
# 生成Spring Boot接口骨架 openapi-generator-cli generate \ -i dist/openapi.bundle.yaml \ -g spring \ -o ./backend \ --api-package com.example.order.api \ --model-package com.example.order.model # 生成TypeScript类型定义和请求客户端 openapi-generator-cli generate \ -i dist/openapi.bundle.yaml \ -g typescript-axios \ -o ./frontend/src/api生成的Spring Boot代码会把路径、参数、返回值的定义全部转换成对应的Controller和DTO类。生成的TypeScript代码里,Order类型、OrderStatus联合类型、createOrder函数都会自动出现。前后端从开发的第一行代码开始,用的就是同一套类型,这种“契约一致性”带来的安心感,用过的都懂。
4. 实战避坑手记——那些代码里看不出来的教训
把SDD真正推进到团队里,过程中会踩到不少坑。有些坑来自工具本身的限制,有些来自团队协作的方式。我把这些经验和排查思路整理成清单,希望能帮你跳过这些坑。
4.1 规格与实现漂移,怎么防
SDD落地最大的敌人,是规格文件改了,但代码没跟上,或者代码改了,规格文件没人更新。这个问题不解决,规格文件很快就变成又一份没人看的死文档。
我的对策是把规格文件当作代码来管理。规格文件的变更必须走Merge Request评审流程,和代码变更一样。同时在CI流水线里加一个检查任务:每次构建时先校验规格文件能否通过swagger-cli validate,再跑一次代码生成,如果生成的代码和仓库里已有的代码有差异,就说明规格和实现发生了漂移,构建直接失败。这个检查机制能强制团队保持规格和实现同步。
4.2 枚举值变更引发的兼容性危机
枚举值看起来人畜无害,但一旦上线后删除或改名,就会造成严重的前后端不同步问题。比如后端把订单状态从completed改成了done,规格文件里改掉了枚举,前端没有及时更新类型,就会拿到一个不符合类型定义的接口响应。
所以在SDD规格中,我会明确一个原则:枚举值只能新增,不能修改或删除。如果业务上确实需要废弃某个枚举值,要新增一个枚举值替换它,并且在description里标注废弃原因和迁移路径。这样即使前端暂时没更新,旧的调用也不会直接报错,只是会进入一个需要处理的业务分支。
4.3 生成代码太重,怎么办
openapi-generator默认生成的代码量比较大,特别是Spring Boot的生成结果,会生成一大堆ApiUtil、ModelBase之类的辅助类。有同事吐槽过:“这生成的代码比我自己写的还多。”确实,如果你只是想用一个DTO定义,生成一整个Controller层确实显得臃肿。
如果不想用生成器,可以只在CI里用swagger-cli生成校验和文档,代码里的模型类自己维护,但保持与规格文件一致。或者用更轻量的生成配置,只生成模型,不生成API层。我的观点是,生成器是手段不是目的,关键是把规格文件作为事实源,至于生成多少代码,团队可以根据项目大小决定。
4.4 团队不买账,怎么推下去
最后说一个偏“软技能”的坑。SDD的推广,技术上其实没有太大难度,难的是改变团队的工作习惯。很多开发会觉得“我先码代码,之后补文档”是更高效的路径,你要他先写规格再写代码,他会觉得流程变重了。
我的经验是从小的切口开始。不要一上来就要求所有接口都先写规格,选一个正在开发的新模块作为试点,拉上前端、后端、测试负责人,一起把这一个模块的规格文档写出来。前端提前用Mock工具联调,后端按规格开发,让团队切实感受到“一次把契约定清楚,不用来回扯皮”的效率提升,再逐步推广到其他模块。有实际收益做支撑,推动就会顺畅很多。
4.5 常见问题与排查速查表
| 现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
swagger-cli validate报schema is missing | 响应或请求体的schema定义缺失 | 检查每个operation的responses和requestBody是否都引用了schema |
| 生成的代码里没有某个字段 | 规格中的schema没有放在components下,而是就地定义 | 把可复用的结构移入components/schemas并提取为具名schema |
| 前端联调时拿到404 | 路径参数名不匹配 | 检查paths中的{order_id}和parameters里的name是否完全一致 |
| Mock服务启动报错 | 规格中有YAML语法错误 | 用swagger-cli validate逐个排查,注意缩进不能混用空格和Tab |
生成的TS类型出现string而不是联合类型 | 枚举值没有显式声明enum | 在schema中显式列出enum数组,不要只写在description里 |
| 接口文档页面样式错乱 | 打包后的YAML中$ref解析失败 | 使用swagger-cli bundle先打包,再让文档工具加载打包后的文件 |
5. 我踩过几次坑之后的一些体会
规格驱动开发这项工作,维护好一份可落地的模板仅仅是开始,真正的价值——以及一些额外的挑战——在于后续的实际应用和迭代。我自己从最初“先写代码、文档以后再说”的状态,到现在“先定契约、再写实现”的习惯,转变过程也是反复在“省事”和“规范”之间找平衡。
有句话想对刚开始尝试SDD的你说:不要把规格文档当成负担,把它当成团队的“沟通协议”。你不需要第一次就写出完美的规格,可以先定义一个最小可用版本,然后随着业务演进持续迭代。关键是把规格文件提升到“唯一事实源”的位置,让所有人都围着它对齐。当你发现因为一份规格文件,前后端不再为字段命名争论、测试不用为了数据准备反复确认、就连新同事上手都能在半天内理清接口关系时,你会觉得前期那点额外成本,真的不算什么。
再分享最后一个小技巧:把写规格文件当作写代码一样对待,保持结构的干净、命名的一致、注释的完整。它能给你和团队带来的回报,远超你写出那几行YAML的时间。