mcpField
A
api-and-interface-design

api-and-interface-design

github.com/addyosmani开发111.5k64.5k 次安装2 周前更新
代码仓库

关于 api-and-interface-design

面向软件开发者和编码 Agent 的 API 与接口设计指南,涵盖 REST API、模块边界和组件接口。它强调先定义契约、保持错误语义一致,并在边界验证输入。

主要功能

  1. 01先定义接口契约
  2. 02统一错误处理语义
  3. 03在系统边界验证输入
  4. 04以兼容方式扩展接口
  5. 05使用一致的命名和分页模式
  6. 06安全处理幂等请求

适用场景

  1. 01设计新的 API 端点
  2. 02定义模块或团队间契约
  3. 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),而不是任其由偶然结果决定
  • 键的保留时长应长于最长重试路径,包括死信重放

评论

支持 Markdown