--- 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" } ] } ``` ### 多语言实现 **TypeScript(Express):** ```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 }); }); ``` **Python(FastAPI):** ```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: ; 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 属于 orders,1 层) ``` ### 问题 2:"PUT 还是 PATCH?" **现象:** 团队对更新语义无法达成一致。 **规则:** - PUT = 客户端发送**完整**资源(缺失字段 → 设为默认值/null) - PATCH = 客户端只发送**发生变化的字段**(缺失字段 → 保持不变) - 不确定时 → **PATCH**(更安全,更少意外) ### 问题 3:"400 还是 422?" **现象:** 验证错误码不一致。 **规则:** - 400 = 完全无法解析请求(格式错误的 JSON、错误的 content-type) - 422 = 解析成功,但值未通过验证(无效的 email、负数数量)