14 KiB
14 KiB
name, description, license, metadata
| name | description | license | metadata | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| fullstack-dev-api-design | API 设计模式与最佳实践。适用于创建端点、选择方法/状态码、实现分页或编写 OpenAPI 规范。可避免常见的 REST/GraphQL/gRPC 错误。 | MIT |
|
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. 资源建模 |
| 选择 HTTP 方法与状态码 | 3. HTTP 方法与状态码 |
| 格式化错误响应 | 4. 错误处理 |
| 添加分页或过滤 | 6. 分页与过滤 |
| 选择 API 风格(REST vs GraphQL vs gRPC) | 10. API 风格决策 |
| 对现有 API 进行版本管理 | 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)
所有错误响应均使用此格式:
{
"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" }
]
}
多语言实现
TypeScript(Express):
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 });
});
Python(FastAPI):
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 页 |
| 偏移分页 | 小型/稳定数据集、管理后台 | 简单,支持页面跳转 | 插入/删除时数据漂移 |
游标分页响应:
{
"data": [...],
"pagination": { "next_cursor": "eyJpZCI6MTIwfQ", "has_more": true }
}
偏移分页响应:
{
"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. 请求/响应设计(中优先级)
统一的响应格式
{
"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 属于 orders,1 层)
问题 2:"PUT 还是 PATCH?"
现象: 团队对更新语义无法达成一致。
规则:
- PUT = 客户端发送完整资源(缺失字段 → 设为默认值/null)
- PATCH = 客户端只发送发生变化的字段(缺失字段 → 保持不变)
- 不确定时 → PATCH(更安全,更少意外)
问题 3:"400 还是 422?"
现象: 验证错误码不一致。
规则:
- 400 = 完全无法解析请求(格式错误的 JSON、错误的 content-type)
- 422 = 解析成功,但值未通过验证(无效的 email、负数数量)