API 与接口设计
API 与接口设计
概述
设计稳定、文档齐全且难以被误用的接口。优秀的接口应让正确操作变得简单,让错误操作变得困难。这适用于 REST API、GraphQL Schema、模块边界、组件 Props 以及任何代码交互的接触面。
使用场景
- 设计新的 API 端点
- 定义模块边界或团队间的契约
- 创建组件 Prop 接口
- 建立决定 API 结构的数据库 Schema
- 修改现有的公共接口
核心原则
Hyrum 定律 (Hyrum's Law)
> 当 API 的用户数量足够多时,无论你在契约中承诺了什么,系统所有可观察到的行为都会被某些用户依赖。
这意味着:一旦用户产生依赖,所有公共行为(包括未记录的特性、错误消息文本、执行时机和顺序)都成为了事实上的契约。设计启示:
- 谨慎决定暴露的内容。 每一个可观察的行为都是一个潜在的承诺。
- 不要泄露实现细节。 只要用户能观察到,他们就会依赖它。
- 在设计阶段就规划弃用方案。 关于如何安全地移除用户依赖的功能,请参阅
deprecation-and-migration。
- 仅靠测试是不够的。 即使有完美的契约测试,根据 Hyrum 定律,“安全”的更改仍可能破坏依赖于未记录行为的真实用户。
单版本原则 (The One-Version Rule)
避免强迫消费者在同一依赖或 API 的多个版本之间做出选择。当不同的消费者需要同一事物的不同版本时,就会出现“钻石依赖”问题。在设计时应假设同一时间仅存在一个版本——倾向于扩展而非分叉。
1. 契约优先 (Contract First)
在实现之前先定义接口。契约即规范,实现应遵循契约。
// 先定义契约
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; // 从 string 改为 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" |
REST API 模式
资源设计
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 接口模式
为变体使用可辨识联合类型 (Discriminated Unions)
// 调用方可获得类型收窄 (Type Narrowing)
function getStatusLabel(status: TaskStatus): string {
switch (status.type) {
case 'pending': return 'Pending';
case 'in_progress': return In progress (${status.assignee});
case 'completed': return Done on ${status.completedAt};
case 'cancelled': return Cancelled: ${status.reason};
}
}
### 输入/输出分离// 输入:调用方提供的内容
interface CreateTaskInput {
title: string;
description?: string;
}
// 输出:系统返回的内容(包含服务器生成的字段)
interface Task {
id: string;
title: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
createdBy: string;
}
### 为 ID 使用品牌类型 (Branded Types)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 做版本控制” | 没有版本控制的破坏性变更会搞崩溃调用方。从一开始就为扩展而设计。 |
| “没人会用那个未记录的行为” | Hyrum 定律:只要行为可被观察,就有人依赖它。将所有公开行为视为承诺。 |
| “我们可以同时维护两个版本” | 多版本会成倍增加维护成本并导致钻石依赖问题。优先遵循“单一版本原则”。 |
| “内部 API 不需要契约” | 内部调用方依然是调用方。契约能防止过度耦合并支持并行开发。 |
警示信号 (Red Flags)
- 接口根据不同条件返回不同的数据结构
- 不同接口的错误格式不统一
- 校验逻辑散落在内部代码中,而非在边界层处理
- 对现有字段进行破坏性变更(类型更改、删除)
- 列表接口不支持分页
- REST URL 中包含动词(如 /api/createTask
,/api/getUsers`)
- 第三方 API 响应在未经过校验或清洗的情况下直接使用
验证清单
设计 API 后请检查:
- [ ] 每个接口都有定义类型的输入和输出 Schema
- [ ] 错误响应遵循统一的格式
- [ ] 校验仅在系统边界层执行
- [ ] 列表接口支持分页
- [ ] 新增字段为增量且可选(向后兼容)
- [ ] 所有接口的命名遵循一致的规范
- [ ] API 文档或类型定义已完成
局限性
- 仅在任务与上游来源及本地项目上下文明确匹配时使用此技能。
- 在应用更改前,请验证命令、生成的代码、依赖项、凭据以及外部服务的行为。
- 不要将示例视为环境特定测试、安全审查或破坏性/高成本操作用户确认的替代方案。