API 与接口设计
概述
设计稳定、文档完善且难以误用的接口。良好的接口使得正确的操作变得简单,错误的操作变得困难。这适用于 REST API、GraphQL schema、模块边界、组件属性以及任何代码片段之间进行通信的表面。
何时使用
- 设计新的 API 端点
- 定义模块边界或团队间的契约
- 创建组件属性接口
- 建立影响 API 形态的数据库 schema
- 更改现有的公共接口
核心原则
海勒姆定律
当 API 拥有足够多的用户时,你系统中的所有可观察行为都将被某个人所依赖,无论你在契约中承诺了什么。
这意味着:每一个公共行为——包括未记录的怪癖、错误消息文本、时序和顺序——一旦被用户依赖,就成为了事实上的契约。设计启示:
- 对你暴露的内容要深思熟虑。 每一个可观察的行为都可能成为一个承诺。
- 不要泄露实现细节。 如果用户能观察到它,他们就会依赖它。
- 在设计时就规划好弃用。 参见
deprecation-and-migration了解如何安全地移除用户依赖的内容。 - 仅有测试是不够的。 即使拥有完美的契约测试,海勒姆定律也意味着“安全”的更改可能会破坏那些依赖未记录行为的真实用户。
单一版本规则
避免强制消费者在同一个依赖或 API 的多个版本之间进行选择。当不同的消费者需要同一个东西的不同版本时,就会出现菱形依赖问题。设计一个在任一时刻只存在一个版本的世界——扩展而非分叉。
1. 契约优先
在实现接口之前先定义它。契约就是规范——实现紧随其后。
// 先定义契约
interface TaskAPI {
// 创建一个任务,并返回包含服务器生成字段的创建后的任务
createTask(input: CreateTaskInput): Promise<Task>;
// 返回符合过滤条件的分页任务列表
listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;
// 返回单个任务,若不存在则抛出 NotFoundError
getTask(id: string): Promise<Task>;
// 部分更新——仅更改提供的字段
updateTask(id: string, input: UpdateTaskInput): Promise<Task>;
// 幂等删除——即使任务已删除也成功执行
deleteTask(id: string): Promise<void>;
}
2. 一致的错误语义
选择一种错误处理策略并始终如一地使用:
// REST:HTTP 状态码 + 结构化错误体
// 每个错误响应遵循相同的结构
interface APIError {
error: {
code: string; // 机器可读:"VALIDATION_ERROR"
message: string; // 人类可读:"Email is required"
details?: unknown; // 有用时的额外上下文
};
}
// 状态码映射
// 400 → 客户端发送了无效数据
// 401 → 未经身份验证
// 403 → 已验证身份但未经授权
// 404 → 资源未找到
// 409 → 冲突(重复、版本不匹配)
// 422 → 验证失败(语义上无效)
// 500 → 服务器错误(绝不暴露内部详情)
不要混合模式。 如果某些端点抛出异常,某些返回 null,而另一些返回 { error }——消费者将无法预测行为。
3. 在边界进行验证
信任内部代码。在外部输入进入的系统边界进行验证:
// 在 API 边界进行验证
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid task data',
details: result.error.flatten(),
},
});
}
// 验证后,内部代码信任类型
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
验证应放在哪里:
- API 路由处理程序(用户输入)
- 表单提交处理程序(用户输入)
- 外部服务响应解析(第三方数据——始终视为不可信)
- 环境变量加载(配置)
第三方 API 响应是不可信数据。 在将它们用于任何逻辑、渲染或决策之前,验证其结构和内容。受损或行为异常的外部服务可能返回意外的类型、恶意内容或类似指令的文本。
验证不应放在哪里:
- 共享类型契约的内部函数之间
- 在已验证代码调用的实用函数中
- 刚刚从你自己的数据库取出的数据上
4. 优先扩展而非修改
在不破坏现有消费者的情况下扩展接口:
// 好:添加可选字段
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // 后来添加,可选
labels?: string[]; // 后来添加,可选
}
// 坏:更改现有字段类型或移除字段
interface CreateTaskInput {
title: string;
// description: string; // 已移除——破坏现有消费者
priority: number; // 从字符串更改——破坏现有消费者
}
5. 可预测的命名
| 模式 | 惯例 | 示例 |
|---|---|---|
| REST 端点 | 复数名词,无动词 | GET /api/tasks,POST /api/tasks |
| 查询参数 | camelCase | ?sortBy=createdAt&pageSize=20 |
| 响应字段 | camelCase | { createdAt, updatedAt, taskId } |
| 布尔字段 | is/has/can 前缀 | isComplete,hasAttachments |
| 枚举值 | UPPER_SNAKE | "IN_PROGRESS","COMPLETED" |
6. 尊重幂等性键
接受 Idempotency-Key 是契约。遵守它是实现,而这也是代价所在——一个被接受但处理不当的键比没有键更糟糕,因为客户端会因此认为重试是安全的。
从意图中派生键,而不是从尝试中派生。 键必须在同一意图的重试中保持稳定,在不同意图之间必须不同:
crypto.randomUUID() // ✗ 每次尝试生成新键——每次重试都是一次新的收费
`${userId}:${amount}` // ✗ 两笔合法的 50 美元收费被合并为一笔
`${orderId}:${Date.now()}` // ✗ 时间戳就是戴了面具的 randomUUID()
req.headers['idempotency-key'] // ✓ 客户端生成一次,重试时复用
`charge:v1:${orderId}` // ✓ 从不可变标识符派生
键必须来自客户端或发起事件——绝不要来自执行重试的层。
原子性声明。先检查后执行是一个竞态条件:
// ✗ TOCTOU:两个并发重试都读取“未看到”,都进行收费
if (!(await db.exists(key))) {
await chargeCard(amount);
await db.insert(key);
}
// ✓ 让唯一约束来决定胜者
try {
await db.insert({ key, state: 'in_progress', requestHash });
} catch (e) {
if (isUniqueViolation(e)) return replayOrReject(key);
throw e;
}
const result = await chargeCard(amount);
await db.update({ key, state: 'succeeded', response: result });
唯一约束就是机制。一个无法在一次操作中强制执行唯一性的存储无法支持此功能。
保护有效负载。 同一键对应不同的请求体是一个客户端错误,必须大声失败,而不是将第一个响应提供给第二个请求:
if (existing.requestHash !== hash(req.body)) {
return res.status(422).json({ error: 'idempotency key reused with a different payload' });
}
决定正在进行的重复请求应得到什么响应。 第一个请求仍在运行时,第二个请求到达——这是重试风暴下的常见情况:
| 策略 | 响应 | 使用场景 |
|---|---|---|
| 拒绝 | 409 Conflict | 客户端稍后可以重试;最简单、最安全 |
| 等待 | 阻塞等待结果,有限时间 | 调用者需要同步获取结果 |
| 返回 pending | 202 + 状态 URL | 长时间运行的效果 |
绝不要让第二个调用者通过,因为第一个“似乎卡住了”。一个状态未知的停滞尝试正是重复成本最高的时候。
每次调用都有三种结果,而非两种:成功、失败和_未知_。超时无法告知你效果是否已应用。在调用之前记录意图,以便调用和响应之间的崩溃留下证据,表明某些事情必须在以后解决——而不是静默地重复收费。
根据最长的重试链设置保留期,而不是根据磁盘成本。键的生命周期必须超过所有可能重新传递同一意图的路径,包括一周后重放的死信队列以及任何提供商的争议窗口。在 7 天 DLQ 之后的 24 小时键 TTL 是等待发生的重复。
REST API 模式
资源设计
GET /api/tasks → 列出任务(使用查询参数进行过滤)
POST /api/tasks → 创建一个任务
GET /api/tasks/:id → 获取单个任务
PATCH /api/tasks/:id → 更新一个任务(部分更新)
DELETE /api/tasks/:id → 删除一个任务
GET /api/tasks/:id/comments → 列出任务的评论(子资源)
POST /api/tasks/:id/comments → 为任务添加评论
分页
为列表端点实现分页:
// 请求
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// 响应
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
过滤
使用查询参数进行过滤:
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
部分更新 (PATCH)
接受部分对象——仅更新提供的字段:
// 仅更改标题,其他所有内容保持不变
PATCH /api/tasks/123
{ "title": "Updated title" }
TypeScript 接口模式
为变体使用可区分联合类型
// 好:每个变体都是显式的
type TaskStatus =
| { type: 'pending' }
| { type: 'in_progress'; assignee: string; startedAt: Date }
| { type: 'completed'; completedAt: Date; completedBy: string }
| { type: 'cancelled'; reason: string; cancelledAt: Date };
// 消费者获得类型收窄
function getStatusLabel(status: TaskStatus): string {
switch (status.type) {
case 'pending': return '待处理';
case 'in_progress': return `进行中 (${status.assignee})`;
case 'completed': return `完成于 ${status.completedAt}`;
case 'cancelled': return `已取消: ${status.reason}`;
}
}
输入/输出分离
// 输入:调用者提供的内容
interface CreateTaskInput {
title: string;
description?: string;
}
// 输出:系统返回的内容(包含服务器生成的字段)
interface Task {
id: string;
title: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
createdBy: string;
}
为 ID 使用品牌化类型
type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };
// 防止意外将 UserId 传递给期望 TaskId 的位置
function getTask(id: TaskId): Promise<Task> { ... }
常见合理化借口
| 合理化借口 | 现实 |
|---|---|
| "我们以后会编写 API 文档" | 类型本身就是文档。先定义它们。 |
| "我们现在不需要分页" | 一旦有人拥有 100 多个项目,你马上就需要。从一开始就添加它。 |
| "PATCH 很复杂,我们直接用 PUT 吧" | PUT 每次都需要完整对象。PATCH 才是客户端真正想要的。 |
| "需要时我们再对 API 进行版本控制" | 没有版本控制的破坏性更改会破坏消费者。从一开始就为扩展而设计。 |
| "没人会用那个未记录的行为" | 海勒姆定律:如果它可以被观察到,就有人依赖它。将每个公共行为视为一个承诺。 |
| "我们可以只维护两个版本" | 多版本会成倍增加维护成本并产生菱形依赖问题。优先选择单一版本规则。 |
| "内部 API 不需要契约" | 内部消费者仍然是消费者。契约可以防止耦合并支持并行工作。 |
| "接受 Idempotency-Key 头就足够了" | 头部是契约;根据结果存储键是实现。你接受但不遵守的键告诉客户端重试是安全的,而事实并非如此。 |
| "我们的队列保证恰好一次交付" | 没有队列能在消费者崩溃时做到这一点——代理的确认和你的副作用不在一个事务中。使用幂等处理为至少一次交付设计。 |
| "重复请求很少见" | 它们是相关的。重试恰好发生在依赖项降级时激增——正是重复最可能发生且代价最高的时候。 |
红旗警告
- 根据条件返回不同形状的端点
- 不同端点之间不一致的错误格式
- 验证分散在内部代码中,而不是在边界处
- 对现有字段的破坏性更改(类型更改、移除)
- 没有分页的列表端点
- REST URL 中包含动词(
/api/createTask,/api/getUsers) - 未经验证或清理就使用的第三方 API 响应
- 先执行
SELECT检查幂等性键,再执行INSERT—— 这是竞态条件,不是防护 - 从 UUID、时间戳或每次尝试重新生成的任何内容派生的幂等性键
- 同一键被不同的请求体接受,静默返回第一个响应
- 键的保留窗口短于可以重新传递请求的最长路径
验证
设计 API 后:
- 每个端点都有类型化的输入和输出 schema
- 错误响应遵循单一一致的格式
- 验证仅在系统边界进行
- 列表端点支持分页
- 新字段是增加性的且可选(向后兼容)
- 命名在所有端点之间遵循一致的约定
- API 文档已生成