API 设计评审模板:先定契约,再写实现
API 是长期契约
API 一旦被前端、移动端或外部客户使用,就会形成长期依赖。实现可以重构,契约变更却需要兼容。我在多个项目中见过这样的情况:接口上线三个月后,调用方已经有五六家,此时发现字段命名不一致、分页方式不统一、错误码含义模糊,修改成本远高于一开始就定好规则。
API 评审应发生在编码前,而不是接口写完后补文档。Google 的 API 设计指南(AIP)明确指出,API 是一种「长期契约」(long-term contract),设计阶段的投入可以在后续的维护和演进中成倍收回。Microsoft 的 REST API 设计最佳实践也强调,API 应当支持「平台无关性」和「松耦合」——这两点都需要在编码前通过评审来确认。
评审的核心维度
评审不是走流程填表,而是对着具体问题逐一确认。以下八个维度覆盖了大多数接口风险:
| 维度 | 评审重点 | 常见问题 |
|---|---|---|
| 资源命名 | URL 结构、名词复数、层级关系 | 动词混入 URL、层级过深、单复数不统一 |
| 请求参数 | 参数位置、类型、必填/可选、校验规则 | 参数散落在 query 和 body 中、缺少校验说明 |
| 响应格式 | 字段命名、嵌套结构、元数据 | 直接暴露数据库模型、snake_case 和 camelCase 混用 |
| 错误处理 | 状态码、错误体结构、错误码体系 | 所有错误都返回 200、错误信息只有「系统异常」 |
| 权限边界 | 认证方式、租户隔离、字段级权限 | 越权访问未拦截、敏感字段未脱敏 |
| 分页排序 | 分页方式、游标/偏移、排序字段 | 大表用 offset 分页性能劣化、缺少默认排序 |
| 幂等重试 | 幂等键设计、重试策略、限流 | 重复提交创建多条记录、无幂等保护 |
| 可观测性 | 请求追踪、日志规范、指标暴露 | 缺少 request-id、错误日志不含上下文 |
案例一:资源命名暴露内部实现
场景
一个电商系统的订单模块,初版接口直接映射数据库表名:
GET /t_order_info?user_id=123
POST /t_order_detail/create
GET /t_order_item_list?order_id=456
问题
三个月后,数据库做了分库分表,t_order_info 和 t_order_detail 合并为一张表。外部调用方的 URL 全部失效。同时,t_ 前缀和 _info、_list 后缀把存储细节暴露给了客户端,新加入的开发者看到 URL 就能猜出数据库结构,安全风险也随之增大。
Microsoft 的 REST API 设计指南明确建议:「不要创建镜像数据库内部结构的 API。REST 的目的是建模业务实体,而非暴露存储细节。」
修复方案
以业务实体为中心重新设计资源,URL 只用名词复数,用 HTTP 方法表达操作意图:
# 好:以业务实体为中心
GET /customers/{customerId}/orders
POST /customers/{customerId}/orders
GET /customers/{customerId}/orders/{orderId}/items
# 坏:暴露数据库表名和动词
GET /t_order_info?user_id=123
POST /t_order_detail/create
GET /t_order_item_list?order_id=456
Google AIP-122 推荐采用层级资源命名,格式为 publishers/{publisher}/books/{book},这种模式能清晰表达资源之间的归属关系。
命名规范对比
| 做法 | 示例 | 问题 |
|---|---|---|
| 动词式 URL | /api/createOrder | HTTP 方法已表达意图,动词冗余 |
| 单数名词 | /api/order/123 | 集合和实例不易区分 |
| 暴露表名 | /t_user_info | 存储细节泄漏,重构即破坏 |
| 层级过深 | /a/b/c/d/e | 超过三层关系难以维护 |
| 名词复数 + 层级 | /customers/123/orders | 符合 REST 惯例,调用方易理解 |
案例二:错误处理只考虑了成功路径
场景
一个用户注册接口,成功时返回 { code: 0, data: { userId: "xxx" } },失败时也返回 HTTP 200,把错误信息放在 code 和 message 字段中:
// 所有响应都是 HTTP 200
{ "code": 0, "message": "success", "data": { "userId": "u_123" } }
{ "code": 10001, "message": "手机号已注册" }
{ "code": 10002, "message": "参数错误" }问题
前端需要根据 code 值做分支处理,但不同接口的错误码没有统一规范,有的用数字,有的用字符串。监控系统按 HTTP 状态码统计错误率,所有接口都是 200,完全无法告警。网关层也无法根据状态码做自动重试或熔断。
Stripe 的错误响应设计是一个值得参考的标准——它始终使用标准 HTTP 状态码,并在响应体中提供结构化的错误详情:
// Stripe 风格的错误响应
{
"error": {
"type": "invalid_request_error",
"code": "phone_already_exists",
"message": "该手机号已注册",
"param": "phone",
"doc_url": "https://docs.example.com/errors/phone_already_exists"
}
}修复方案
采用 RFC 7807 Problem Details 规范(Microsoft 和 Google 都推荐),统一错误响应结构:
// 好:RFC 7807 结构化错误
{
"type": "https://api.example.com/errors/phone-exists",
"title": "手机号已注册",
"status": 409,
"detail": "手机号 138****1234 已被注册,请使用其他手机号或直接登录",
"instance": "/api/v1/users/register",
"traceId": "req_abc123"
}
// 坏:自定义 code 混在 200 里
{
"code": 10001,
"message": "手机号已注册"
}错误码体系统一之后,前端可以用一个中间件集中处理所有错误分支,监控也能直接按 HTTP 状态码做告警。
错误状态码对照
| 场景 | 推荐状态码 | 错误类型 |
|---|---|---|
| 参数校验失败 | 400 Bad Request | invalid_request_error |
| 未认证 | 401 Unauthorized | authentication_error |
| 无权限 | 403 Forbidden | permission_denied |
| 资源不存在 | 404 Not Found | resource_not_found |
| 资源冲突(重复) | 409 Conflict | conflict_error |
| 限流 | 429 Too Many Requests | rate_limit_exceeded |
| 服务端异常 | 500 Internal Server Error | internal_error |
案例三:分页设计在大表上性能崩溃
场景
一个订单列表接口,使用传统的 offset 分页:
GET /api/orders?page=10000&size=20
问题
随着数据量增长到千万级,深分页查询越来越慢。OFFSET 200000 意味着数据库要先读取前 200020 条记录,然后丢弃前 200000 条。一个用户翻到第 10000 页,接口响应时间从 50ms 涨到 3 秒以上。
同时,offset 分页在并发写入时会出现数据重复或遗漏——翻页过程中新插入的记录会改变后续页的偏移位置。
修复方案
采用游标分页(cursor-based pagination),这也是 Stripe、GitHub 等 API 的标准做法。游标指向数据集中一个稳定的位置,不受并发写入影响:
# 好:游标分页,稳定高效
GET /api/v1/orders?limit=20&cursor=eyJpZCI6MTIzNDU2fQ
# 响应中包含 next_cursor
{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTIzNDc2fQ",
"has_more": true
}
}
# 坏:offset 分页,深页性能差
GET /api/orders?page=10000&size=20
游标的本质是一个不透明令牌,服务端用它定位到数据集中的确定位置。游标可以是 Base64 编码的主键,也可以是排序字段的值,调用方不需要关心其内部结构。
分页方式对比
| 特性 | Offset 分页 | Cursor 分页 |
|---|---|---|
| 深页性能 | O(offset + limit),随页数线性退化 | O(limit),恒定 |
| 并发一致性 | 可能重复或遗漏 | 基于稳定游标,不受新数据影响 |
| 跳页能力 | 支持直接跳到第 N 页 | 不支持,只能逐页翻 |
| 实现复杂度 | 简单 | 需要对游标编解码 |
| 适用场景 | 数据量小、后台管理系统 | 数据量大、移动端、对外 API |
Stripe 的 API 文档对游标分页的使用场景有清晰说明:当集合数据可能超过几百条,或者调用方是移动端、外部开发者时,应优先使用游标分页。
幂等性:写操作的防重机制
幂等性在评审中经常被忽略,直到出现重复扣款、重复创建订单的线上事故。一个写操作如果没有幂等保护,网络抖动导致的重试就会制造脏数据。
Stripe 的做法是为所有 POST 请求提供 Idempotency-Key 头。服务端收到请求后,将幂等键与第一次执行的结果缓存起来。后续用相同 key 发起的请求,直接返回缓存结果,不会重复执行业务逻辑。
# 好:带幂等键的 POST 请求
curl -X POST https://api.example.com/v1/payments \
-H "Authorization: Bearer sk_xxx" \
-H "Idempotency-Key: uuid-v4-xxxx-xxxx" \
-d '{"amount": 100, "currency": "CNY", "order_id": "ord_123"}'
# 服务端处理逻辑
# 1. 查询 idempotency_key 是否已存在
# 2. 存在:返回缓存的响应(状态码 + body)
# 3. 不存在:执行业务逻辑,缓存结果,返回响应
# 坏:没有幂等保护,重试可能重复扣款
curl -X POST https://api.example.com/v1/payments \
-H "Authorization: Bearer sk_xxx" \
-d '{"amount": 100, "currency": "CNY", "order_id": "ord_123"}'
# 网络超时后客户端重试,服务端再次执行扣款
幂等键的设计有几点要注意:
| 要点 | 说明 |
|---|---|
| 生成方式 | 客户端生成,推荐使用 UUID v4 或高熵随机字符串 |
| 键长度 | 不超过 255 字符 |
| 缓存时长 | 建议 24 小时,过期后视为新请求 |
| 参数校验 | 相同 key 但不同参数应拒绝,防止误用 |
| 适用方法 | 仅 POST 需要;GET / PUT / DELETE 天然幂等 |
GET、PUT、DELETE 方法在 HTTP 语义上天然幂等——相同的 GET 请求多次执行结果一致,PUT 用相同数据覆盖同一条记录结果也一致。只有 POST(创建操作)需要显式的幂等机制。
版本策略:向后兼容优先
API 版本管理是评审中最容易引发争论的话题。三种常见策略各有适用场景:
| 策略 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| URL 路径版本 | /api/v1/orders | 直观、缓存友好、易于路由 | URL 膨胀,旧版本难以清理 |
| Header 版本 | API-Version: 2024-01-15 | URL 干净 | 日志和调试不直观 |
| 内容协商 | Accept: application/vnd.api.v2+json | 最符合 HTTP 语义 | 客户端实现复杂 |
Azure SDK 的版本管理策略值得参考:稳定版本保证向后兼容,代码为某个版本编写的调用可以在不修改的情况下使用更新版本。这意味着新增字段是兼容的(调用方忽略未知字段),删除字段是不兼容的(需要版本化)。
# 好:向后兼容变更
# v1 响应
{ "id": "123", "name": "Alice" }
# v1 新增字段(兼容)
{ "id": "123", "name": "Alice", "email": "[email protected]" }
# 坏:破坏性变更
# v1 响应
{ "id": "123", "name": "Alice" }
# v1 删除字段(不兼容)
{ "id": "123" }
# v1 修改字段类型(不兼容)
{ "id": 123, "name": "Alice" }
废弃字段应通过 Sunset 响应头通知调用方,明确告知字段将在何时移除:
HTTP/1.1 200 OK
Sunset: Sat, 01 Mar 2027 00:00:00 GMT
Link: <https://api.example.com/docs/migration/v2>; rel="successor-version"
{ "id": "123", "legacy_field": "value", "new_field": "value" }
限流:保护服务也保护调用方
限流不是可选的防御措施,而是 API 设计的一部分。评审时应确认限流策略,并在响应头中向调用方透明暴露当前用量:
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1719400000
# 触发限流时
HTTP/1.1 429 Too Many Requests
Retry-After: 30
{
"type": "https://api.example.com/errors/rate-limit",
"title": "请求频率超出限制",
"status": 429,
"detail": "当前限制为每分钟 1000 次请求,请在 30 秒后重试"
}
常见的限流算法有固定窗口、滑动窗口和令牌桶三种。Stripe 采用的是分层限流策略,不同类型的请求有不同的限额——读操作通常比写操作有更高的限额,付费客户的限额高于免费客户。
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 固定窗口 | 按固定时间窗口计数 | 实现简单 | 窗口边界可能突发 |
| 滑动窗口 | 按滚动时间窗口计数 | 流量平滑 | 需要更多存储 |
| 令牌桶 | 按令牌生成速率控制 | 允许短时突发 | 参数调优复杂 |
评审流程图
下面这张流程图展示了 API 评审从发起到完成的完整路径。评审不是一次性会议,而是设计阶段的持续检查:
完整评审检查清单
以下清单按评审阶段分组,每项都对应一个具体的检查点。可以在评审会议中逐项对照,也可以作为接口设计文档的自查工具。
设计阶段(编码前)
- 资源命名是否使用名词复数,URL 层级不超过三层
- 是否避免了动词式 URL(HTTP 方法已表达意图)
- 请求参数位置是否合理(查询参数 vs 请求体),类型和必填/可选是否有明确说明
- 响应格式是否统一(字段命名规范、嵌套层级、元数据结构)
- 是否避免了直接暴露数据库模型(字段名、表结构、关联关系)
- 是否定义了所有错误场景的状态码和错误体结构
安全与权限
- 认证方式是否明确(Bearer Token、API Key、OAuth 2.0)
- 是否存在越权访问风险(水平越权、垂直越权)
- 敏感字段是否脱敏(手机号、身份证、银行卡)
- 是否有请求频率限制(rate limiting)策略
- 是否对输入参数做了长度和类型校验,防止注入攻击
兼容性与演进
- 是否采用向后兼容优先策略(新增字段可兼容,删除字段需版本化)
- 是否有 API 版本策略(URL 路径版本 / Header 版本 / 内容协商)
- 废弃字段是否设置了 Sunset 头,并给出迁移时间窗口
- 破坏性变更是否通过新版本发布,旧版本保留足够的过渡期
性能与可靠性
- 列表接口是否选用了合适的分页方式(大数据量用游标分页)
- 写操作是否需要幂等保护(POST 请求是否需要幂等键)
- 是否有重试策略和退避机制(指数退避 + 抖动)
- 是否在响应头中暴露限流信息(X-RateLimit-Limit、X-RateLimit-Remaining)
可观测性
- 是否在所有响应中返回 request-id / traceId
- 错误日志是否包含请求上下文(参数、用户 ID、traceId)
- 关键接口是否暴露了监控指标(延迟、错误率、吞吐量)
文档与示例
- 是否提供了至少一个成功请求/响应示例
- 是否提供了至少一个错误请求/响应示例
- 是否有字段级别的说明文档(类型、约束、默认值)
输出物
评审结束后,应留下以下产出物:
- 接口草案:包含 URL、方法、请求参数、响应格式的完整定义
- 示例请求和响应:至少包含成功、参数错误、权限失败三个示例
- 错误码清单:列出该接口可能返回的所有错误码及其含义
- 开放问题:记录评审中未达成一致的决策点,明确负责人和截止日期
这些产出物能让前后端并行推进,测试也能提前设计用例。
参考资料
- Google API Design Guide - Google 的 API 设计规范,涵盖资源命名、标准方法、错误处理
- Google AIP-122: Resource Names - 资源命名规范,层级命名模式
- Microsoft REST API Design Best Practices - Azure 架构中心的 REST API 设计最佳实践
- Stripe Idempotent Requests - Stripe 的幂等请求设计,幂等键的使用方式
- Zuplo: API Design Patterns - API 设计模式:分页、版本、错误处理、幂等、限流
- RFC 7807: Problem Details for HTTP APIs - HTTP API 错误详情规范
- Stripe Rate Limiters - Stripe 的限流策略设计
- Azure API Versioning - Azure 的版本管理和向后兼容策略