Files
2026-07-13 21:35:57 +08:00

445 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: fullstack-dev-api-design
description: "API 设计模式与最佳实践。适用于创建端点、选择方法/状态码、实现分页或编写 OpenAPI 规范。可避免常见的 REST/GraphQL/gRPC 错误。"
license: MIT
metadata:
version: "2.0.0"
sources:
- Microsoft REST API Guidelines
- Google API Design Guide
- Zalando RESTful API Guidelines
- JSON:API Specification
- RFC 9457 (Problem Details for HTTP APIs)
- RFC 9110 (HTTP Semantics)
---
# API 设计指南
面向后端与全栈工程师的框架无关 API 设计指南。涵盖 10 大类共 50 余条规则,按影响程度排序。覆盖 REST、GraphQL 和 gRPC。
## 适用范围
**使用此技能的场景:**
- 设计新 API 或添加端点
- 审查 API 拉取请求
- 在 REST / GraphQL / gRPC 之间做选择
- 编写 OpenAPI 规范
- 迁移或对现有 API 进行版本管理
**不适用的场景:**
- 框架特定的实现细节(请使用对应框架的技能/文档)
- 前端数据获取模式(请使用 React Query / SWR 文档)
- 认证实现细节(请使用认证库的文档)
- 数据库 schema 设计(→ `database-schema-design`
## 所需上下文
在应用此技能前,请收集以下信息:
| 必填项 | 可选项 |
|----------|----------|
| 目标消费者(浏览器、移动端、服务) | 项目中已有的 API 约定 |
| 预期请求量(RPS 预估) | 当前的 OpenAPI / Swagger 规范 |
| 认证方式(JWT、API Key、OAuth | 限流需求 |
| 数据模型 / 领域实体 | 缓存策略 |
---
## 快速开始清单
新建 API 端点?写代码前逐一检查:
- [ ] 资源命名为**复数名词**`/orders`,而不是 `/getOrders`
- [ ] URL 使用**短横线命名**,请求体字段使用**驼峰命名**
- [ ] 使用正确的 **HTTP 方法**GET=读取,POST=创建,PUT=全量替换,PATCH=部分更新,DELETE=删除)
- [ ] 使用正确的**状态码**201 Created、422 Validation、404 Not Found……)
- [ ] 错误响应遵循 **RFC 9457** 格式
- [ ] 所有列表端点实现**分页**(默认 20,最多 100)
- [ ] 要求**认证**Bearer Token,而非查询参数)
- [ ] 响应头中包含**请求 ID**`X-Request-Id`
- [ ] 包含**限流**响应头
- [ ] 端点已在 **OpenAPI 规范**中记录
---
## 快速导航
| 需要…… | 跳转至 |
|----------|---------|
| 命名资源 URL | [1. 资源建模](#1-资源建模关键) |
| 选择 HTTP 方法与状态码 | [3. HTTP 方法与状态码](#3-http-方法与状态码关键) |
| 格式化错误响应 | [4. 错误处理](#4-错误处理高优先级) |
| 添加分页或过滤 | [6. 分页与过滤](#6-分页与过滤高优先级) |
| 选择 API 风格(REST vs GraphQL vs gRPC | [10. API 风格决策](#10-api-风格决策树) |
| 对现有 API 进行版本管理 | [7. 版本管理](#7-版本管理中高优先级) |
| 避免常见错误 | [反模式清单](#反模式清单) |
---
## 1. 资源建模(关键)
### 核心规则
```
✅ /users — 复数名词
✅ /users/{id}/orders — 1 层嵌套
✅ /reviews?orderId={oid} — 使用查询参数展平深层嵌套
❌ /getUsers — URL 中包含动词
❌ /user — 单数
❌ /users/{uid}/orders/{oid}/items/{iid}/reviews — 超过 3 层嵌套
```
**最大嵌套层级:2 层。** 超过此限制时,提升为顶层资源并通过过滤参数查询。
### 领域对齐
资源映射到**领域概念**,而非数据库表:
```
✅ /checkout-sessions (领域聚合)
✅ /shipping-labels (领域概念)
❌ /tbl_order_header (数据库表泄露)
❌ /join_user_role (内部 schema 泄露)
```
---
## 2. URL 与命名(关键)
| 上下文 | 规范 | 示例 |
|---------|-----------|---------|
| URL 路径 | 短横线命名 | `/order-items` |
| JSON 请求体字段 | 驼峰命名 | `{ "firstName": "Jane" }` |
| 查询参数 | 驼峰命名或下划线命名(保持一致) | `?sortBy=createdAt` |
| 请求头 | 连字符大写命名 | `X-Request-Id` |
**Python 例外:** 如果整个技术栈都是 Python/下划线命名,可以在 JSON 中使用 `snake_case`——但必须在**所有端点间保持一致**。
```
✅ GET /users ❌ GET /users/
✅ GET /reports/annual ❌ GET /reports/annual.json
✅ POST /users ❌ POST /users/create
```
---
## 3. HTTP 方法与状态码(关键)
### 方法语义
| 方法 | 语义 | 幂等 | 安全 | 请求体 |
|--------|-----------|-----------|------|-------------|
| GET | 读取 | ✅ | ✅ | ❌ 从不 |
| POST | 创建 / 动作 | ❌ | ❌ | ✅ 始终 |
| PUT | 全量替换 | ✅ | ❌ | ✅ 始终 |
| PATCH | 部分更新 | ❌* | ❌ | ✅ 始终 |
| DELETE | 删除 | ✅ | ❌ | ❌ 极少 |
### 状态码快速参考
**成功类:**
| 状态码 | 使用时机 | 响应体 |
|------|------|--------------|
| 200 OK | GET、PUT、PATCH 成功 | 资源 / 结果 |
| 201 Created | POST 创建了资源 | 已创建的资源 + `Location` 请求头 |
| 202 Accepted | 异步操作已启动 | 任务 ID / 状态 URL |
| 204 No Content | DELETE 成功、PUT 无请求体 | 无 |
**客户端错误类:**
| 状态码 | 使用时机 | 关键区别 |
|------|------|-----------------|
| 400 Bad Request | 语法格式错误 | 无法解析 |
| 401 Unauthorized | 缺少或无效的认证凭据 | "你是谁?" |
| 403 Forbidden | 已认证但无权限 | "我认识你,但不行" |
| 404 Not Found | 资源不存在 | 也可用于隐藏 403 |
| 409 Conflict | 重复、版本不匹配 | 状态冲突 |
| 422 Unprocessable | 语法正确但验证失败 | 语义错误 |
| 429 Too Many Requests | 触发限流 | 包含 `Retry-After` |
**服务端错误类:** 500(意外错误)、502(上游失败)、503(过载)、504(上游超时)
---
## 4. 错误处理(高优先级)
### 标准错误格式(RFC 9457
所有错误响应均使用此格式:
```json
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "余额不足",
"status": 422,
"detail": "账户余额 $10.00 低于提现金额 $50.00。",
"instance": "/transactions/txn_abc123",
"request_id": "req_7f3a8b2c",
"errors": [
{ "field": "amount", "message": "超出余额", "code": "INSUFFICIENT_BALANCE" }
]
}
```
### 多语言实现
**TypeScriptExpress):**
```typescript
class AppError extends Error {
constructor(
public readonly title: string,
public readonly status: number,
public readonly detail: string,
public readonly code: string,
) { super(detail); }
}
// 中间件
app.use((err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.status).json({
type: `https://api.example.com/errors/${err.code}`,
title: err.title, status: err.status,
detail: err.detail, request_id: req.id,
});
}
res.status(500).json({ title: '内部错误', status: 500, request_id: req.id });
});
```
**PythonFastAPI):**
```python
from fastapi import Request
from fastapi.responses import JSONResponse
class AppError(Exception):
def __init__(self, title: str, status: int, detail: str, code: str):
self.title, self.status, self.detail, self.code = title, status, detail, code
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(status_code=exc.status, content={
"type": f"https://api.example.com/errors/{exc.code}",
"title": exc.title, "status": exc.status,
"detail": exc.detail, "request_id": request.state.request_id,
})
```
### 铁律
```
✅ 所有错误均返回 RFC 9457 错误格式
✅ 每个错误响应都包含 request_id
✅ 字段级验证错误在 `errors` 数组中返回
❌ 绝不在生产环境暴露堆栈信息
❌ 绝不为错误返回 200 状态码
❌ 绝不静默吞掉错误
```
---
## 5. 认证与授权(高优先级)
```
✅ Authorization: Bearer eyJhbGci... (请求头)
❌ GET /users?token=eyJhbGci... URL——会出现在日志中)
✅ 401 → "你是谁?" (缺少或无效的凭据)
✅ 403 → "你不能这样做" (已认证,无权限)
✅ 404 → 隐藏资源存在性 (需要时用来替代 403)
```
**限流响应头(始终包含):**
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1625097600
Retry-After: 30
```
---
## 6. 分页与过滤(高优先级)
### 游标分页 vs 偏移分页
| 策略 | 适用场景 | 优点 | 缺点 |
|----------|------|------|------|
| **游标分页**(推荐) | 大型/动态数据集 | 数据一致,无跳变 | 无法跳转到第 N 页 |
| **偏移分页** | 小型/稳定数据集、管理后台 | 简单,支持页面跳转 | 插入/删除时数据漂移 |
**游标分页响应:**
```json
{
"data": [...],
"pagination": { "next_cursor": "eyJpZCI6MTIwfQ", "has_more": true }
}
```
**偏移分页响应:**
```json
{
"data": [...],
"pagination": { "page": 3, "per_page": 20, "total": 256, "total_pages": 13 }
}
```
**始终执行:** 默认 20 条,最多 100 条。
### 标准过滤模式
```
GET /orders?status=shipped&created_after=2025-01-01&sort=-created_at&fields=id,status
```
| 模式 | 规范 |
|---------|-----------|
| 精确匹配 | `?status=shipped` |
| 范围查询 | `?price_gte=10&price_lte=100` |
| 日期范围 | `?created_after=2025-01-01&created_before=2025-12-31` |
| 排序 | `?sort=field`(升序)、`?sort=-field`(降序) |
| 稀疏字段 | `?fields=id,name,email` |
| 搜索 | `?q=搜索+关键词` |
---
## 7. 版本管理(中高优先级)
| 策略 | 格式 | 最佳适用 |
|----------|--------|----------|
| **URL 路径**(推荐) | `/v1/users` | 公开 API |
| **请求头** | `Api-Version: 2` | 内部 API |
| **查询参数** | `?version=2` | 遗留系统(避免使用) |
**非破坏性变更(无需升级版本号):** 新增可选的响应字段、新增端点、新增可选参数。
**破坏性变更(需新版本):** 删除/重命名字段、更改类型、收紧验证规则、删除端点。
**弃用响应头:**
```
Sunset: Sat, 01 Mar 2026 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/v2/users>; rel="successor-version"
```
---
## 8. 请求/响应设计(中优先级)
### 统一的响应格式
```json
{
"data": { "id": "ord_123", "status": "pending", "total": 99.50 },
"meta": { "request_id": "req_abc123", "timestamp": "2025-06-15T10:30:00Z" }
}
```
### 关键规则
| 规则 | 正确 | 错误 |
|------|---------|-------|
| 时间戳 | `"2025-06-15T10:30:00Z"`ISO 8601 | `"06/15/2025"``1718447400` |
| 公开 ID | UUID `"550e8400-..."` | 自增 `42` |
| Null 与缺省(PATCH | `{ "nickname": null }` = 清空字段 | 缺省字段 = 不做更改 |
| HATEOAS(公开 API | `"links": { "cancel": "/orders/123/cancel" }` | 无可发现性 |
---
## 9. 文档——OpenAPI(中优先级)
**设计优先的工作流程:**
```
1. 编写 OpenAPI 3.1 规范
2. 与相关方审查规范
3. 生成服务端桩代码 + 客户端 SDK
4. 实现处理器
5. 在 CI 中对照规范验证响应
```
每个端点均需记录:摘要、所有参数、请求体及示例、所有响应码及 schema、认证要求。
---
## 10. API 风格决策树
```
什么样的 API
├─ 浏览器 + 移动端客户端,需要灵活查询
│ └─ GraphQL
│ 规则:DataLoader(避免 N+1)、深度限制 ≤7、Relay 分页
├─ 标准 CRUD,面向公开消费者,缓存很重要
│ └─ REST(本指南)
│ 规则:资源、HTTP 方法、状态码、OpenAPI
├─ 服务间通信,高吞吐量,强类型
│ └─ gRPC
│ 规则:Protobuf schema、大数据流式传输、截止时间
├─ 全栈 TypeScript,同一团队拥有客户端和服务端
│ └─ tRPC
│ 规则:共享类型,无需代码生成
└─ 实时双向通信
└─ WebSocket / SSE
规则:心跳、重连、消息排序
```
---
## 反模式清单
| # | ❌ 不要这样做 | ✅ 应该这样做 |
|---|---------|--------------|
| 1 | URL 中包含动词(`/getUser`) | HTTP 方法 + 名词资源 |
| 2 | 错误时返回 200 | 正确的 4xx/5xx 状态码 |
| 3 | 混合使用多种命名风格 | 每个上下文保持一种规范 |
| 4 | 暴露数据库 ID | 使用 UUID 作为公开标识符 |
| 5 | 列表不进行分页 | 始终分页(默认 20 条) |
| 6 | 静默吞掉错误 | 结构化的 RFC 9457 错误 |
| 7 | Token 放在 URL 查询参数中 | Authorization 请求头 |
| 8 | 深层嵌套(3 层以上) | 使用查询参数展平 |
| 9 | 破坏性变更不升级版本 | 保持兼容或使用版本管理 |
| 10 | 不设限流 | 实现限流并通过请求头告知 |
| 11 | 无请求 ID | 每个响应都包含 `X-Request-Id` |
| 12 | 生产环境暴露堆栈信息 | 安全的错误消息 + 内部日志 |
---
## 常见问题
### 问题 1:"这应该是新资源还是子资源?"
**现象:** URL 路径不断增长(`/users/{id}/orders/{id}/items/{id}/reviews`
**规则:** 如果子实体本身有意义,则提升为顶层资源。如果它只在父级上下文中存在,则保持嵌套(最多 2 层)。
```
/reviews?orderId=123 ✅ reviews 独立存在)
/orders/{id}/items ✅ items 属于 orders1 层)
```
### 问题 2"PUT 还是 PATCH"
**现象:** 团队对更新语义无法达成一致。
**规则:**
- PUT = 客户端发送**完整**资源(缺失字段 → 设为默认值/null)
- PATCH = 客户端只发送**发生变化的字段**(缺失字段 → 保持不变)
- 不确定时 → **PATCH**(更安全,更少意外)
### 问题 3"400 还是 422"
**现象:** 验证错误码不一致。
**规则:**
- 400 = 完全无法解析请求(格式错误的 JSON、错误的 content-type
- 422 = 解析成功,但值未通过验证(无效的 email、负数数量)