SDD 规格驱动开发:代码写对了,但做的是对的东西吗?

写在前面

上一篇文章 我们聊了 TDD——用测试保证代码写得对。但这只回答了问题的一半。

另一半更致命:你做的东西,是对方想要的吗?

去年做课设的时候我就遇到过。花了两天把一个模块写完了,接口调通了,测试也绿了,拿给队友看。对方沉默了几秒说:「呃……我想要的不是你这样。」

代码没 Bug,测试全覆盖,但不合预期。推倒重来。

这种事的根本原因不是技术问题,是信息不对称——需求和实现之间有一个巨大的空白地带,每个人在这个地带里用自己的理解把空白填满。填出来的东西不一样,等到集成的时候才发现,代价已经付了。

**SDD(Specification-Driven Development,规格驱动开发)**就是在这个空白地带插一面旗子,让所有人都能看见。

SDD 概念篇:把「我以为」换成「我们都同意的这份文档」

什么是 SDD

SDD 的核心思想很简单:在写代码之前,先写一份规格文档,各方认可后再动手。

规格(Specification)不同于需求文档。需求说的是「用户要什么」(what),规格说的是「系统怎么提供」(how)——接口长什么样、数据怎么流转、边界条件怎么处理、错误怎么返回。

打个比方:

相当于 谁写 谁看
需求 点菜:「我想吃辣的、不要香菜、15 分钟内上桌」 PM / 产品 所有人
规格 菜谱:「里脊肉 300g 切丝、郫县豆瓣酱 2 勺、大火爆炒 90 秒」 开发者 开发者
实现 炒菜 开发者 机器

没有菜谱也能炒菜,但换个人炒就不一定是那个味了。没有规格也能写代码,但换个人接手(或者你自己两周后回头看)就不一定知道当时怎么想的了。

SDD 的四个层次

规格不是只有一种写法。根据项目的规模和复杂度,SDD 可以落在不同层次:

1
2
3
4
5
6
7
8
9
10
高抽象                   低抽象
│ │
▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 接口协议 │ │ 数据模型 │ │ 行为合约 │ │ 算法规格 │
│ │ │ │ │ │ │ │
│ REST API │ │ DB Schema│ │ 函数签名 │ │ 伪代码 │
│ GraphQL │ │ 类型定义 │ │ 前置条件 │ │ 复杂度约束│
│ gRPC │ │ JSON Sch. │ │ 后置条件 │ │ 精度要求 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘

接口协议层——最宏观。前后端之间、服务之间的通信契约。REST API 文档、GraphQL Schema、gRPC 的 .proto 文件都在这个层面。

数据模型层——数据库中流转的数据长什么样。字段类型、约束、默认值、关联关系。

行为合约层——一个函数或模块的契约。输入什么、输出什么、哪些输入会抛异常、异常是什么类型。说白了就是把 TDD 的测试用例用自然语言或类型系统先写一遍,但不直接跑。

算法规格层——最微观。特定算法的计算方式、精度要求、时间复杂度约束。比如排序算法要稳定、浮点精度到小数点后 6 位。

平时说「写个接口文档」「定义一下 schema」其实已经在做 SDD 了,只不过大部分人没有意识到这是一种方法论。

SDD 和 TDD 的关系

这两个 D 不是互斥的,是互补的。一个形象的类比:

SDD 是蓝图,TDD 是验收。

建筑师画完蓝图(规格),施工队盖楼(实现),质检员对照蓝图验收(测试)。你不能跳过蓝图直接找质检员——质检员都不知道标准是什么,怎么验?

同样,你不能只有蓝图没有质检——施工队可能会偷工减料。

实际项目里,SDD 和 TDD 的协作关系是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
需求分析
│
▼
编写规格文档 (SDD) ← 各方 review 确认
│
▼
根据规格写测试用例 (TDD 的 Red) ← 测试即规格的可执行版本
│
▼
编码实现 (TDD 的 Green)
│
▼
重构优化 (TDD 的 Refactor)
│
▼
对照规格验收 ← 规格是验收的唯一标准

实践篇:SDD 在前端协作中的落地方式

理论好讲,实践起来要解决的问题是:规格文档放哪?谁来维护?怎么保证不变成一纸空文?

方式一:OpenAPI — 让 API 规格变成活文档

最成熟的 SDD 实践之一就是 OpenAPI(原 Swagger)。核心思路:用一个 YAML 或 JSON 文件描述所有 API 接口,然后从这个文件自动生成文档、类型定义、甚至客户端代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# openapi.yaml — API 规格,各方认可的单一真相来源
openapi: 3.0.0
info:
title: 任务管理系统 API
version: 1.0.0
paths:
/tasks:
get:
summary: 获取任务列表
parameters:
- name: priority
in: query
schema:
type: string
enum: [high, medium, low]
- name: sortBy
in: query
schema:
type: string
enum: [createdAt, priority]
default: createdAt
responses:
'200':
description: 任务列表
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Task'
'400':
$ref: '#/components/responses/ValidationError'
post:
summary: 创建任务
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateTaskRequest'
responses:
'201':
description: 创建成功
content:
application/json:
schema:
$ref: '#/components/schemas/Task'

components:
schemas:
Task:
type: object
required: [id, title, priority, createdAt]
properties:
id:
type: integer
description: 任务唯一标识
title:
type: string
minLength: 1
maxLength: 200
priority:
type: string
enum: [high, medium, low]
createdAt:
type: string
format: date-time

CreateTaskRequest:
type: object
required: [title, priority]
properties:
title:
type: string
minLength: 1
maxLength: 200
priority:
type: string
enum: [high, medium, low]
default: medium

responses:
ValidationError:
description: 参数校验失败
content:
application/json:
schema:
type: object
properties:
code:
type: integer
example: 400
message:
type: string
example: 'title 不能为空'

这份文件的威力在于它不是写完了就搁置的静态文档。项目可以围绕它形成一个自动化的工作流:

1
2
3
4
5
6
graph LR
A[OpenAPI 规格文件] --> B[生成 TypeScript 类型]
A --> C[自动校验后端返回]
A --> D[生成接口文档页面]
B --> E[前端直接用类型安全地调接口]
C --> F[CI 中拦截不兼容变更]

前后端协作流程变成:

  1. 后端同学改接口 → 先改 openapi.yaml,提 PR
  2. 前端同学 review 这个 PR → 「这个字段能不能加个 nullable」「这个接口的 query 参数要不要支持分页」
  3. 双方在规格文件上达成一致 → 后端实现接口,前端并行写界面(可以用 mock server 模拟响应)
  4. 双方都对照规格验收 → 没有「我以为」「你以为」的空间

这比「后端改完代码,前端发现不对,沟通,重改」的效率高太多了。因为沟通发生在动手之前,而不是集成阶段。

方式二:Protobuf — 服务间通信的强类型规格

如果做的是微服务架构或者 gRPC,Protobuf(.proto 文件)天然就是 SDD 的载体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// task_service.proto
syntax = "proto3";

service TaskService {
rpc ListTasks(ListTasksRequest) returns (ListTasksResponse);
rpc CreateTask(CreateTaskRequest) returns (Task);
}

message Task {
int64 id = 1;
string title = 2;
Priority priority = 3;
google.protobuf.Timestamp created_at = 4;
}

enum Priority {
PRIORITY_UNSPECIFIED = 0;
HIGH = 1;
MEDIUM = 2;
LOW = 3;
}

message ListTasksRequest {
Priority priority = 1; // 可选,按优先级筛选
string sort_by = 2; // 排序字段,默认 "created_at"
int32 page = 3; // 页码,从 1 开始
int32 page_size = 4; // 每页数量,默认 20,最大 100
}

.proto 文件的好处是编译期就能发现不兼容变更——你删掉了一个还在用的字段?编译器直接报错,根本到不了运行时。这种「规格即约束」的能力是纯文档做不到的。

方式三:轻量级规格 — 小团队的最小可行 SDD

如果你不开 API、不做微服务、就一个人或者两三个人写全栈,上面这些工具确实有点重。但这不意味着可以跳过规格,只是规格的形式可以更轻量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
## 任务排序模块规格

### 输入
- `tasks: Task[]` — 任务数组,可能为空
- `order: 'asc' | 'desc'` — 升序或降序

### 输出
- `Task[]` — 排序后的**新数组**,原数组不变

### 排序规则
1. 优先级 > 创建时间
2. 优先级权重:high(1) > medium(2) > low(3)
3. 创建时间用 ISO string 转时间戳比较
4. 若全部相同,按 id 升序兜底保证稳定

### 异常处理
- 若 `tasks` 为 null/undefined,返回空数组
- 若 `order` 不是 'asc' 或 'desc',默认 'asc'
- 若 task 缺少 priority 字段,按 low 处理

就写在 Markdown 里,贴在项目 Wiki 上,或者直接放在 docs/specs/ 目录下。关键不是形式,而是「写下来,双方确认,照着实现」这件事本身。

你可能会说这不就是「先想清楚再写代码」吗?对,但大部分人「想清楚了」只是在自己脑子里想,没有落到纸上让对方确认。SDD 的 S 不是 Spec 本身,而是 Spec 作为协作工具这一层含义。

案例实战:没有规格 vs 有规格

说一个真实场景。假设你和另一个同学合作做一个简单的用户信息页,后端负责 API,前端负责页面。

没有 SDD 的剧本

第一天:

  • 后端建了一个 /api/user/profile 接口,返回 { name, avatar, bio }
  • 前端调通接口开始写页面,一切顺利

第二天:

  • 后端觉得 bio 字段应该改叫 introduction,改了
  • 前端页面崩了,跑来找后端:「bio 怎么没了?」
  • 后端:「我觉得 introduction 更准确啊」
  • 一番沟通,前端改字段名

第三天:

  • 前端需要展示用户加入时间,找后端要字段
  • 后端:「啊,我还没存这个数据,得改数据库 schema」
  • 后端加了 joinDate 字段,返回 "2025-09-01"
  • 前端用 new Date().toLocaleDateString() 渲染,在 iOS Safari 上报错——那个日期字符串格式 Safari 不认识
  • 前端:「你能不能返回时间戳?」
  • 后端:「时间戳没有语义啊,别人调接口怎么看懂」

第四天:

  • 聊了半天,最后定下来返回 ISO 8601 格式
  • 对齐成本远超编码时间

有 SDD 的剧本

第一天(写规格,不写代码):

后端和前端坐在一起,花了 30 分钟写好 API 规格:

1
2
3
4
5
6
7
8
GET /api/user/profile
Response 200:
{
"name": "string, 用户昵称, 1-50 字符",
"avatar": "string, 头像 URL, 空字符串表示未设置",
"introduction": "string, 个人简介, 最长 500 字符, 可为 null",
"joinedAt": "string, ISO 8601 格式, 例如 '2025-09-01T00:00:00Z'"
}

双方确认:字段够了、类型清晰、格式统一。

第二天(并行开发):

  • 后端实现接口,对照规格返回数据
  • 前端用 JSON mock 模拟规格里定义的响应格式,独立开发页面

第三天(集成):

  • 前端把 mock 换成真实接口
  • 一切正常,因为双方写的都是规格上约定好的东西
  • 收工

同样的任务,工时差不多,但后者的摩擦成本是零。 SDD 不是在「写代码」这一步砸时间,而是在「代码写完后互相纠正」这一步省钱。

SDD 不是银弹:什么时候该用,什么时候不该用

该用 SDD 不该用 SDD / 轻量即可
多人协作,前后端分离 一个人全栈,接口自己定自己用
接口会被外部调用 内部临时脚本
需求稳定,边界清晰 快速验证阶段,接口一天改八遍
微服务,服务间通信 MVP 原型阶段
项目需长期维护 一次性项目

SDD 最大代价不是写规格的体力活,而是冻结得早。需求还在快速变的时候,规格刚写完就过期了,维护规格的成本反而比维护代码高。什么时候启动 SDD 是一个判断力问题——太早是过度设计,太晚是亡羊补牢。

我的经验法则是:当接口开始被别人(或未来的自己)依赖的时候,就值得写规格。 如果只有你一个人用,脑子里记一下就行;如果两个人以上在用,写下来。

总结

TDD 回答「代码写得对不对」,SDD 回答「做的东西对不对」。两者合并在一起,就是一套完整的质量保障线:

1
2
3
4
5
SDD(规格) → 定义「正确」的标准
↓
TDD(测试) → 验证实现是否符合标准
↓
代码 → 在规格和测试的双重约束下生长

当你经历过太多次「做完了但不对」之后,就会发现自己宁愿开工前多花 20 分钟写规格,也不愿意做完后花 2 小时改需求偏差。

下一篇讲 FSD(Feature-Sliced Design)——当项目越来越大、文件越来越多,你是怎么组织前端代码的?按页面分?按技术分?FSD 给了一种完全不同的思路。

系列文章:

  • 上一篇:TDD 测试驱动开发 — 测试倒逼实现
  • 本篇:SDD 规格驱动开发 — 规格倒逼设计
  • 下一篇:FSD(Feature-Sliced Design) — 前端架构方法论
  • 后续:DDD(Domain-Driven Design) — 领域驱动设计