关于 api-and-interface-design
面向软件开发者和编码 Agent 的 API 与接口设计指南,涵盖 REST API、模块边界和组件接口。它强调先定义契约、保持错误语义一致,并在边界验证输入。
主要功能
- 01先定义接口契约
- 02统一错误处理语义
- 03在系统边界验证输入
- 04以兼容方式扩展接口
- 05使用一致的命名和分页模式
- 06安全处理幂等请求
适用场景
- 01设计新的 API 端点
- 02定义模块或团队间契约
- 03调整现有公共接口
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ addyosmani/ agent-skills --skill api-and-interface-design安装后在 Claude Code 中输入/api-and-interface-design确认已加载。
详细介绍
API 与接口设计
概述
设计稳定、文档完善且不易误用的接口。优秀的接口让正确的做法更容易,让错误的做法更难。这适用于 REST API、GraphQL 架构、模块边界、组件属性,以及任何一段代码与另一段代码交互的接口。
适用场景
- 设计新的 API 端点
- 定义模块边界或团队间的契约
- 创建组件属性接口
- 建立影响 API 形式的数据库架构
- 更改现有公共接口
核心原则
海勒姆定律
当 API 的用户足够多时,无论你在契约中承诺什么,总会有人依赖系统所有可观察到的行为。
这意味着:一旦用户开始依赖,所有公开行为——包括未记录的怪癖、错误消息文本、时序和顺序——都会成为事实上的契约。设计上的启示:
- 有意识地决定要公开什么。 每一种可观察行为都可能成为承诺。
- 不要泄露实现细节。 用户只要能观察到,就会依赖它。
- 在设计阶段规划弃用。 有关如何安全移除用户所依赖内容的信息,请参阅
deprecation-and-migration。 - 测试还不够。 即使契约测试完美无缺,海勒姆定律意味着“安全”的更改仍可能破坏依赖未记录行为的真实用户。
单一版本规则
避免让使用者必须在同一依赖项或 API 的多个版本之间做出选择。当不同使用者需要同一事物的不同版本时,就会出现菱形依赖问题。应针对同一时间只存在一个版本的情况进行设计——扩展,而非分叉。
1. 契约优先
先定义接口,再进行实现。契约就是规范——实现遵循规范。
// Define the contract first
interface TaskAPI {
// Creates a task and returns the created task with server-generated fields
createTask(input: CreateTaskInput): Promise<Task>;
// Returns paginated tasks matching filters
listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;
// Returns a single task or throws NotFoundError
getTask(id: string): Promise<Task>;
// Partial update — only provided fields change
updateTask(id: string, input: UpdateTaskInput): Promise<Task>;
// Idempotent delete — succeeds even if already deleted
deleteTask(id: string): Promise<void>;
}
2. 统一的错误语义
选择一种错误处理策略,并在所有地方统一使用:
// REST: HTTP status codes + structured error body
// Every error response follows the same shape
interface APIError {
error: {
code: string; // Machine-readable: "VALIDATION_ERROR"
message: string; // Human-readable: "Email is required"
details?: unknown; // Additional context when helpful
};
}
// Status code mapping
// 400 → Client sent invalid data
// 401 → Not authenticated
// 403 → Authenticated but not authorized
// 404 → Resource not found
// 409 → Conflict (duplicate, version mismatch)
// 422 → Validation failed (semantically invalid)
// 500 → Server error (never expose internal details)
不要混用模式。 如果某些端点抛出错误,另一些返回 null,还有一些返回 { error },使用者就无法预测行为。
3. 在边界处验证
信任内部代码。在系统边缘、外部输入进入的地方进行验证:
// Validate at the API boundary
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(),
},
});
}
// After validation, internal code trusts the types
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
验证应放在以下位置:
- API 路由处理程序(用户输入)
- 表单提交处理程序(用户输入)
- 外部服务响应解析(三方数据——始终视为不可信数据)
- 环境变量加载(配置)
三方 API 响应是不可信数据。 在将其用于任何逻辑、渲染或决策之前,请验证其结构和内容。遭到入侵或行为异常的外部服务可能会返回意外类型、恶意内容或类似指令的文本。
验证不应放在以下位置:
- 共享类型契约的内部函数之间
- 已经过验证的代码所调用的实用函数中
- 刚从自有数据库读取的数据上
4. 优先新增,而非修改
扩展接口,避免破坏现有使用者:
// Good: Add optional fields
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // Added later, optional
labels?: string[]; // Added later, optional
}
// Bad: Change existing field types or remove fields
interface CreateTaskInput {
title: string;
// description: string; // Removed — breaks existing consumers
priority: number; // Changed from string — breaks existing consumers
}
5. 命名清晰可预测
| 模式 | 约定 | 示例 |
|---|---|---|
| REST 端点 | 使用复数名词,不使用动词 | GET /api/tasks, POST /api/tasks |
| 查询参数 | 驼峰命名法 | ?sortBy=createdAt&pageSize=20 |
| 响应字段 | 驼峰命名法 | { createdAt, updatedAt, taskId } |
| 布尔字段 | 使用 is/has/can 前缀 | isComplete, hasAttachments |
| 枚举值 | 大写蛇形命名法 | "IN_PROGRESS", "COMPLETED" |
6. 遵循幂等键
接受 Idempotency-Key 是契约。遵循该契约则是实现,而金钱正是在这里损失的——服务端接受了密钥却处理不当,比完全没有密钥更糟糕,因为客户端会因此认为重试是安全的。
根据意图而非尝试生成密钥。 同一意图的重试必须使用相同的密钥,不同意图则必须使用不同的密钥:
crypto.randomUUID() // ✗ new key per attempt — every retry is a new charge
`${userId}:${amount}` // ✗ two legitimate $50 charges collapse into one
`${orderId}:${Date.now()}` // ✗ a timestamp is randomUUID() wearing a hat
req.headers['idempotency-key'] // ✓ client generates once, reuses on retry
`charge:v1:${orderId}` // ✓ derived from an immutable identifier
密钥来自客户端或发起事件,绝不能来自负责重试的层。
以原子方式声明。先检查再执行会产生竞争条件:
// ✗ TOCTOU: two concurrent retries both read "not seen", both charge
if (!(await db.exists(key))) {
await chargeCard(amount);
await db.insert(key);
}
// ✓ let the unique constraint pick the winner
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 | 客户端可以稍后重试;最简单、最安全 |
| 等待 | 等待结果,但设定时限 | 调用方需要同步获取结果 |
| 返回待处理状态 | 202 + 状态 URL | 长时间运行的操作 |
绝不要因为第一个调用“似乎卡住”就让第二个调用通过。尝试已停滞、结果又未知时,重复执行的代价恰恰最高。
每次调用有三种结果,而不是两种:成功、失败和 未知。 超时并不能说明操作是否已生效。调用外部服务前先记录意图,这样即使在调用后、收到响应前发生崩溃,也会留下需要后续处理的证据,而不是让扣款被悄悄重试。
根据最长重试链设置保留时间,而不是根据磁盘成本设置。密钥必须在所有可能重新投递同一意图的路径中保持有效,包括一周后重放的死信队列,以及任何服务商争议处理期限。在 7 天的 DLQ 后设置 24 小时密钥 TTL,迟早会导致重复操作。
REST API 模式
资源设计
GET /api/tasks → List tasks (with query params for filtering)
POST /api/tasks → Create a task
GET /api/tasks/:id → Get a single task
PATCH /api/tasks/:id → Update a task (partial)
DELETE /api/tasks/:id → Delete a task
GET /api/tasks/:id/comments → List comments for a task (sub-resource)
POST /api/tasks/:id/comments → Add a comment to a task
分页
为列表端点添加分页:
// Request
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// Response
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
筛选
使用查询参数进行筛选:
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
局部更新(PATCH)
接受部分对象——只更新所提供的内容:
// Only title changes, everything else preserved
PATCH /api/tasks/123
{ "title": "Updated title" }
TypeScript 接口模式
使用可辨识联合表示不同变体
// Good: Each variant is explicit
type TaskStatus =
| { type: 'pending' }
| { type: 'in_progress'; assignee: string; startedAt: Date }
| { type: 'completed'; completedAt: Date; completedBy: string }
| { type: 'cancelled'; reason: string; cancelledAt: Date };
// Consumer gets 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}`;
}
}
分离输入与输出
// Input: what the caller provides
interface CreateTaskInput {
title: string;
description?: string;
}
// Output: what the system returns (includes server-generated fields)
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' };
// Prevents accidentally passing a UserId where a TaskId is expected
function getTask(id: TaskId): Promise<Task> { ... }
常见的自我开脱
| 自我开脱 | 现实 |
|---|---|
| “我们以后再编写 API 文档” | 类型本身就是文档。先定义类型。 |
| “目前我们不需要分页” | 一旦有人拥有 100 个以上的条目,你就会需要。现在就添加。 |
| “PATCH 太复杂了,我们直接用 PUT 吧” | PUT 每次都要求提供完整对象。客户端真正想要的是 PATCH。 |
| “等需要时我们再给 API 版本化” | 不做版本管理就进行破坏性更改,会导致使用方出问题。从一开始就要为扩展而设计。 |
| “没人会用那些未记录的行为” | Hyrum 定律:只要某个行为可被观察到,就会有人依赖它。把每个公开行为都视为承诺。 |
| “我们可以同时维护两个版本” | 多个版本会成倍增加维护成本,并导致菱形依赖问题。优先遵循单版本规则。 |
| “内部 API 不需要契约” | 内部使用方也是使用方。契约可以防止耦合,并支持并行工作。 |
| “接受 Idempotency-Key 请求头就够了” | 请求头只是契约;将键与结果一同存储才是实现。接受键却不兑现承诺,会让客户端误以为重试是安全的。 |
| “我们的队列保证恰好一次投递” | 消费者崩溃时,没有队列能保证这一点——消息代理的确认和你的副作用不在同一个事务中。应按至少一次投递来设计,并采用幂等处理。 |
| “重复请求很少见” | 它们是相关联的。依赖服务降级时,重试会激增——此时最容易出现重复请求,代价也最高。 |
危险信号
- 端点会根据条件返回不同结构的数据
- 不同端点的错误格式不一致
- 验证逻辑散落在内部代码各处,而不是集中在边界处
- 对现有字段进行破坏性更改(类型变更、移除字段)
- 列表端点不支持分页
- REST URL 中使用动词(
/api/createTask、/api/getUsers) - 未经验证或清理便直接使用第三方 API 的响应
- 先对幂等键执行
SELECT,再执行INSERT——这会造成竞态,而非提供保护 - 幂等键由 UUID、时间戳或其他每次尝试都会重新生成的内容派生
- 同一个键搭配不同的请求正文时,仍被接受并静默返回第一个响应
- 键的保留时长短于请求可能被重新投递的最长路径
验证
设计 API 后:
- 每个端点都有类型化的输入和输出架构
- 错误响应遵循统一、一致的格式
- 仅在系统边界处进行验证
- 列表端点支持分页
- 新字段均为增量添加且可选(向后兼容)
- 所有端点的命名遵循一致的约定
- API 文档或类型与实现一同提交
- 状态变更端点要么兑现幂等键的承诺,要么明确说明重试不安全
- 通过唯一约束保护,在单个原子操作中占用该键
- 使用不同负载重用键时,应明确报错,而不是重放错误的响应
- 对正在处理中的重复请求,应明确选择响应方式(409、等待或 202),而不是任其由偶然结果决定
- 键的保留时长应长于最长重试路径,包括死信重放