API 设计评审模板:先定契约,再写实现

0阅读10分钟

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_infot_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/createOrderHTTP 方法已表达意图,动词冗余
单数名词/api/order/123集合和实例不易区分
暴露表名/t_user_info存储细节泄漏,重构即破坏
层级过深/a/b/c/d/e超过三层关系难以维护
名词复数 + 层级/customers/123/orders符合 REST 惯例,调用方易理解

案例二:错误处理只考虑了成功路径

场景

一个用户注册接口,成功时返回 { code: 0, data: { userId: "xxx" } },失败时也返回 HTTP 200,把错误信息放在 codemessage 字段中:

// 所有响应都是 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 Requestinvalid_request_error
未认证401 Unauthorizedauthentication_error
无权限403 Forbiddenpermission_denied
资源不存在404 Not Foundresource_not_found
资源冲突(重复)409 Conflictconflict_error
限流429 Too Many Requestsrate_limit_exceeded
服务端异常500 Internal Server Errorinternal_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-15URL 干净日志和调试不直观
内容协商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 评审从发起到完成的完整路径。评审不是一次性会议,而是设计阶段的持续检查:

流程图画布 · 115%
Mermaid 流程图加载中...

完整评审检查清单

以下清单按评审阶段分组,每项都对应一个具体的检查点。可以在评审会议中逐项对照,也可以作为接口设计文档的自查工具。

设计阶段(编码前)

  • 资源命名是否使用名词复数,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)
  • 关键接口是否暴露了监控指标(延迟、错误率、吞吐量)

文档与示例

  • 是否提供了至少一个成功请求/响应示例
  • 是否提供了至少一个错误请求/响应示例
  • 是否有字段级别的说明文档(类型、约束、默认值)

输出物

评审结束后,应留下以下产出物:

  1. 接口草案:包含 URL、方法、请求参数、响应格式的完整定义
  2. 示例请求和响应:至少包含成功、参数错误、权限失败三个示例
  3. 错误码清单:列出该接口可能返回的所有错误码及其含义
  4. 开放问题:记录评审中未达成一致的决策点,明确负责人和截止日期

这些产出物能让前后端并行推进,测试也能提前设计用例。

参考资料

评论 0

0 / 1000