API 与接口设计

api-and-interface-design
分类编程
作者Agentic Awesome Skills 社区
许可MIT
评分4.50/5
使用13.6K

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)

在实现之前先定义接口。契约即规范,实现应遵循契约。

typescript
// 先定义契约
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. 一致的错误语义

选择一种错误处理策略并统一使用:

typescript
// REST: HTTP 状态码 + 结构化错误主体
// 所有错误响应遵循相同的格式
interface APIError {
  error: {
    code: string;        // 机器可读:如 "VALIDATION_ERROR"
    message: string;     // 人类可读:如 "Email is required"
    details?: unknown;   // 必要时的额外上下文
  };
}

// 状态码映射
// 400 → 客户端错误


数据无效
// 401 → 未认证
// 403 → 已认证但无权限
// 404 → 资源未找到
// 409 → 冲突(重复或版本不匹配)
// 422 → 验证失败(语义无效)
// 500 → 服务器错误(切勿暴露内部细节)
code
不要混用模式。 如果部分端点抛出异常,部分返回 null,而另一部分返回 { error },调用方将无法预测行为。

3. 在边界处进行验证

信任内部代码。在外部输入进入系统的边缘地带进行验证:

typescript
// 在 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);
});

code
适用验证的场景:
  • API 路由处理器(用户输入)

  • 表单提交处理器(用户输入)

  • 外部服务响应解析(第三方数据 —— 始终将其视为不可信

  • 环境变量加载(配置)

> 第三方 API 响应是不可信数据。 在将其用于任何逻辑、渲染或决策之前,请验证其结构和内容。受损或异常的外部服务可能会返回意外的类型、恶意内容或指令类文本。

不适用验证的场景:

  • 共享类型契约的内部函数之间

  • 由已验证代码调用的工具函数中

  • 刚从自有数据库中获取的数据

4. 优先选择“增加”而非“修改”

在扩展接口时,避免破坏现有调用方:

typescript
// 推荐:添加可选字段
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // 后期添加,可选
labels?: string[]; // 后期添加,可选
}

// 不推荐:更改现有字段类型或删除字段
interface CreateTaskInput {
title: string;
// description: string; // 已删除 —— 破坏现有调用方
priority: number; // 从 string 改为 number —— 破坏现有调用方
}

code
### 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 → 获取任务列表(支持过滤查询参数) 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 → 为任务添加评论

code
### 分页

对列表端点进行分页处理:

typescript
// 请求
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc

// 响应
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}

code
### 过滤

使用查询参数进行过滤:


GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
code
### 部分更新 (PATCH)

接受部分对象 —— 仅更新提供的内容:

typescript
// 仅修改标题,其余内容保持不变
PATCH /api/tasks/123
{ "title": "Updated title" }
code
## TypeScript 接口模式

为变体使用可辨识联合类型 (Discriminated Unions)

typescript // 推荐:每个变体都明确定义 type TaskStatus = | { type: 'pending' } | { type: 'in_progress'; assignee: string; startedAt: Date } | { type: 'completed'; completedAt: Date; completedBy: string } | { type: 'cancelled'; reason: string; cancelledAt: Date };

// 调用方可获得类型收窄 (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};
}
}

code
### 输入/输出分离
typescript
// 输入:调用方提供的内容
interface CreateTaskInput {
title: string;
description?: string;
}

// 输出:系统返回的内容(包含服务器生成的字段)
interface Task {
id: string;
title: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
createdBy: string;
}

code
### 为 ID 使用品牌类型 (Branded Types)
typescript
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 文档或类型定义已完成
随实现方案一同提交

局限性

  • 仅在任务与上游来源及本地项目上下文明确匹配时使用此技能。
  • 在应用更改前,请验证命令、生成的代码、依赖项、凭据以及外部服务的行为。
  • 不要将示例视为环境特定测试、安全审查或破坏性/高成本操作用户确认的替代方案。