chore: import zh skill api-and-interface-design
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- Skill 名称:`api-and-interface-design`
|
||||
- 中文类目:API 设计评审
|
||||
- 上游仓库:`addyosmani__agent-skills`
|
||||
- 上游路径:`skills/api-and-interface-design/SKILL.md`
|
||||
- 上游链接:https://github.com/addyosmani/agent-skills/blob/HEAD/skills/api-and-interface-design/SKILL.md
|
||||
- 本仓库为 WeHub 中文 Skill 汉化包,基于 skill 市场筛选 Top200 清单整理
|
||||
- 原作者、版权和许可证信息以上游仓库为准
|
||||
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: api-and-interface-design
|
||||
description: 指导稳定 API 与接口设计。在设计 API、模块边界或任何公共接口时使用。在创建 REST 或 GraphQL 端点、定义模块之间的类型契约、或建立前后端边界时使用。
|
||||
---
|
||||
|
||||
# API 与接口设计
|
||||
|
||||
## 概述
|
||||
|
||||
设计稳定、文档完善且难以被误用的接口。优秀的接口能让正确的事情变得简单,让错误的事情变得困难。这适用于 REST API、GraphQL Schema、模块边界、组件 Props,以及任何一段代码与另一段代码交互的表面。
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 设计新的 API 端点
|
||||
- 定义模块边界或团队间的契约
|
||||
- 创建组件 Props 接口
|
||||
- 建立影响 API 形态的数据库 Schema
|
||||
- 修改已有的公共接口
|
||||
|
||||
## 核心原则
|
||||
|
||||
### Hyrum 定律
|
||||
|
||||
> 当 API 拥有足够多的用户时,系统的所有可观测行为都会被某个人依赖,无论你在契约中承诺了什么。
|
||||
|
||||
这意味着:每个公开行为——包括未文档化的 quirks、错误消息文本、时序和排序——一旦被用户依赖,就变成了事实上的契约。设计上的启示:
|
||||
|
||||
- **有意识地选择暴露什么。** 每个可观测的行为都是一项潜在的承诺。
|
||||
- **不要泄漏实现细节。** 如果用户能观测到它,他们就会依赖它。
|
||||
- **在设计时就规划好弃用策略。** 参见 `deprecation-and-migration`,了解如何安全地移除用户依赖的内容。
|
||||
- **仅有测试是不够的。** 即使有了完美的契约测试,Hyrum 定律也意味着「安全」的变更可能会破坏那些依赖未文档化行为的真实用户。
|
||||
|
||||
### 单一版本原则
|
||||
|
||||
避免强制消费者在同一依赖或 API 的多个版本之间做出选择。当不同消费者需要同一事物的不同版本时,就会出现菱形依赖问题。设计成一次只存在一个版本——扩展而非分叉。
|
||||
|
||||
### 1. 契约优先
|
||||
|
||||
在实现之前先定义接口。契约就是规格说明——实现随后跟上。
|
||||
|
||||
```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 → 服务端错误(绝不暴露内部细节)
|
||||
```
|
||||
|
||||
**不要混用模式。** 如果某些端点抛出异常,另一些返回 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);
|
||||
});
|
||||
```
|
||||
|
||||
验证应放在何处:
|
||||
- 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——破坏现有消费者
|
||||
}
|
||||
```
|
||||
|
||||
### 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 → 为任务添加评论
|
||||
```
|
||||
|
||||
### 分页
|
||||
|
||||
列表端点应支持分页:
|
||||
|
||||
```typescript
|
||||
// 请求
|
||||
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)
|
||||
|
||||
接受部分对象——仅更新提供的字段:
|
||||
|
||||
```typescript
|
||||
// 仅修改标题,其他保持不变
|
||||
PATCH /api/tasks/123
|
||||
{ "title": "Updated title" }
|
||||
```
|
||||
|
||||
## TypeScript 接口模式
|
||||
|
||||
### 使用可辨识联合类型表示变体
|
||||
|
||||
```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 'Pending';
|
||||
case 'in_progress': return `In progress (${status.assignee})`;
|
||||
case 'completed': return `Done on ${status.completedAt}`;
|
||||
case 'cancelled': return `Cancelled: ${status.reason}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 输入/输出分离
|
||||
|
||||
```typescript
|
||||
// 输入:调用者提供的内容
|
||||
interface CreateTaskInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 输出:系统返回的内容(包含服务端生成的字段)
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdBy: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 为 ID 使用品牌类型
|
||||
|
||||
```typescript
|
||||
type TaskId = string & { readonly __brand: 'TaskId' };
|
||||
type UserId = string & { readonly __brand: 'UserId' };
|
||||
|
||||
// 防止在需要 TaskId 的地方意外传入 UserId
|
||||
function getTask(id: TaskId): Promise<Task> { ... }
|
||||
```
|
||||
|
||||
## 常见借口
|
||||
|
||||
| 借口 | 现实 |
|
||||
|---|---|
|
||||
| 「我们后面再写 API 文档」 | 类型本身就是文档。先定义它们。 |
|
||||
| 「现在不需要分页」 | 一旦有 100 条以上数据,你就需要了。从一开始就加上。 |
|
||||
| 「PATCH 太复杂了,直接用 PUT 吧」 | PUT 每次都需要完整的对象。PATCH 才是客户端真正想要的。 |
|
||||
| 「需要时再对 API 做版本管理」 | 没有版本管理的破坏性变更会破坏消费者。从一开始就设计成可扩展的。 |
|
||||
| 「没人会用那个未文档化的行为」 | Hyrum 定律:如果它是可观测的,就有人依赖它。把每个公开行为都视为一项承诺。 |
|
||||
| 「我们可以维护两个版本」 | 多个版本会成倍增加维护成本并产生菱形依赖问题。优先采用单一版本原则。 |
|
||||
| 「内部 API 不需要契约」 | 内部消费者仍然是消费者。契约能防止耦合并支持并行开发。 |
|
||||
|
||||
## 警示标志
|
||||
|
||||
- 端点根据条件返回不同结构
|
||||
- 不同端点之间错误格式不一致
|
||||
- 验证散落在内部代码各处而非集中在边界
|
||||
- 对已有字段进行破坏性变更(类型变更、字段删除)
|
||||
- 列表端点没有分页
|
||||
- REST URL 中包含动词(`/api/createTask`,`/api/getUsers`)
|
||||
- 第三方 API 响应未经验证或清理就直接使用
|
||||
|
||||
## 检查清单
|
||||
|
||||
设计完 API 后,请确认:
|
||||
|
||||
- [ ] 每个端点都有带类型的输入和输出 Schema
|
||||
- [ ] 错误响应遵循单一一致的格式
|
||||
- [ ] 验证仅在系统边界处进行
|
||||
- [ ] 列表端点支持分页
|
||||
- [ ] 新增字段是累加且可选的(向后兼容)
|
||||
- [ ] 所有端点的命名遵循一致的约定
|
||||
- [ ] API 文档或类型与实现一同提交
|
||||
Reference in New Issue
Block a user