API 是系统的"对外契约",一旦发布就几乎不可撤回。好的 API 设计决定外部开发者(或跨团队消费者)的体验与信任;差的 API 则是长久的返工与抱怨来源。本文覆盖 REST 建模、版本化、OpenAPI 契约先行、错误契约与契约测试,给你一套可落地的 API 工程方法。
1. REST 成熟度与资源建模
1.1 从 URL 到 HTTP 语义
REST 的核心是"资源 + 标准动词 + 状态码",而非"动词式 URL":
✗ 反模式:/getUser /createOrder /deleteOrderById
✓ 资源式:/users/{id}(GET/PUT/DELETE)、/orders(POST)
语义靠 HTTP 动词与状态码表达,而不是在 URL 里塞动词。
1.2 资源层级与命名
- 名词复数:
/users、/orders; - 子资源:
/users/{id}/addresses(关系用嵌套,动作用子资源); - 操作:状态变更用
POST /orders/{id}/cancel(视为"cancel 这个子动作")。
GET /orders/{id} 查单个订单
POST /orders 创建订单
PATCH /orders/{id} 部分更新
DELETE /orders/{id} 删除
POST /orders/{id}/cancel 取消(动作子资源)
1.3 幂等与安全
| 方法 | 幂等 | 安全 | 典型 |
|---|---|---|---|
| GET | ✓ | ✓ | 查询 |
| PUT | ✓ | ✗ | 全量替换 |
| PATCH | ✗ | ✗ | 部分更新 |
| POST | ✗ | ✗ | 创建/动作 |
| DELETE | ✓ | ✗ | 删除 |
一句话:API 建模先回答"什么是资源、什么动词、什么语义"——用标准 HTTP 语义 + 名词资源 + 状态码,避免动词式 URL 和语义错位。
2. API 版本化策略
2.1 何时需要版本
- 破坏性变更(字段改名、语义改变、删除)必须新版本;
- 非破坏性变更(新增字段/端点)尽量向后兼容,不必升版。
2.2 版本化方式对比
| 方式 | 位置 | 优劣 |
|---|---|---|
| URL 版本 | /v1/orders | 直观、易于路由;会污染 URL |
| Header 版本 | Accept: application/vnd.api+json; version=2 | 不污染 URL;调试繁琐 |
| 参数版本 | ?version=2 | 简单;易被忽略 |
实践建议:
- URL 版本适合外部公开 API(好理解、好缓存);
- 内部服务间倾向 Header 或语义化版本,尽量免于 URL 污染。
2.3 兼容性铁律
只加不改不删:新增字段/端点向后兼容
必填字段新增 → 破坏性
语义变化(哪怕字段不变)→ 破坏性
一句话:版本化的核心是区分"破坏性 vs 非破坏性"——能兼容就别升版,升版要有清晰的废弃与迁移期(deprecation window)。
3. 契约先行:OpenAPI 作为单一事实
3.1 什么是 OpenAPI 契约
用一份 OpenAPI(Swagger)描述文件定义"接口长什么样"(路径、方法、入参、出参、错误码),作为前后端与多服务的共同契约。
# openapi.yaml(片段)
openapi: 3.0.0
info: { title: Order API, version: v1 }
paths:
/orders/{id}:
get:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
"200":
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/Order' }
3.2 契约先行的收益
| 收益 | 说明 |
|---|---|
| 前后端并行开发 | 先定契约,双方各自实现 |
| 自动生成代码 | 客户端 SDK、服务端骨架、Mock |
| 文档同步 | Swagger UI 即文档 |
| 校验一致 | 请求/响应按 schema 校验 |
# 工具链(示例)
openapi-generator generate -i openapi.yaml -g typescript-fetch -o client/
redocly bundle openapi.yaml # 文档渲染
3.3 契约即文档、即测试
契约文件进代码库,契约变更走 PR 评审——接口改动不再悄悄发生,而是有记录、有评审。
一句话:契约先行 = 一份 OpenAPI 定义接口 + 生成 SDK/文档/校验——把"接口长什么样"变成可评审、可测试的代码资产。
4. 错误契约与响应规范
4.1 统一错误结构
{
"code": "ORDER_NOT_FOUND", // 稳定、机器可读
"message": "订单不存在", // 人类可读
"traceId": "a1b2c3", // 可追踪
"details": { "orderId": "10086" } // 可选上下文
}
4.2 错误码设计
- code 稳定:前端/客户端据此分支,不要用 message 匹配;
- 分层:
4xx客户端错误(参数、鉴权、不存在)、5xx服务端错误; - 不泄露内部:5xx 一律脱敏返回"服务器内部错误",详情只进日志。
4.3 分页、幂等、限流等横切契约
分页:page/pageSize 或 cursor 模式,返回 total
幂等:POST 带 Idempotency-Key,重复提交只执行一次
限流:429 + Retry-After header
一句话:错误与横切约定是API 契约的一部分——统一错误结构、稳定 code、不泄露内部、横切能力有明确定义,客户端才能可靠消费。
5. 契约测试:防止悄悄破坏
5.1 三层测试
| 层 | 关注 | 手段 |
|---|---|---|
| 单元测试 | 端点行为 | 服务层 |
| 契约测试 | 请求/响应符合契约 | 消费者契约测试(Pact) |
| 端到端 | 真实链路 | 环境冒烟 |
5.2 Pact 消费者驱动契约
消费者声明"我需要这样的响应",供应方验证"我能满足":
消费者(前端/下游)写 Pact 契约
→ 放到 Pact Broker
→ 供应方(服务)跑 provider verification
→ 不符即构建失败
// Pact 示例(消费者侧期望)
pact
.get('/orders/10086')
.willRespondWith({ status: 200, body: { id: '10086', status: 'PAID' } });
一句话:契约测试(Pact)把"接口别被悄悄改坏"变成 CI 门禁——消费者声明期望,供应方验证履约,两边独立演进但契约始终对齐。
6. API 网关与开发者体验
- 网关统一:认证、限流、路由、聚合、日志(配合 API 网关与 BFF);
- 开发者体验:Swagger UI、Mock Server、版本在线切换;
- 可观测性:每条 API 的延迟/错误率/耗时 p95 进大盘;
- 生命周期:Deprecation → 提醒 → 下线时间表,杜绝"悄悄下线"。
一句话:API 工程不仅是"定义接口"——网关统一入口、文档与 Mock 提升开发者体验、可观测与下线管理托住生命周期,才是一套完整治理。
7. 踩坑清单
| 坑 | 现象 | 对策 |
|---|---|---|
| 动词式 URL | 语义混乱、难缓存 | 资源 + HTTP 动词 |
| 无版本化 | 一个接口到处 break | 明确破坏性规则 + URL 版本 |
| 契约后行 | 前后端各自猜 | OpenAPI 契约先行 |
| 错误结构不统一 | 客户端解析分支乱 | 统一 code/message/traceId |
| 5xx 泄露内部 | 暴露栈与细节 | 脱敏 + 详情只进日志 |
| 无契约测试 | 悄悄改坏下游 | Pact + CI 门禁 |
| 悄悄下线接口 | 消费者崩溃 | deprecation 窗口 + 沟通 |
8. 总结
| 环节 | 要点 |
|---|---|
| 建模 | 名词资源 + HTTP 动词 + 状态码 |
| 版本 | 破坏性才升版,兼容即免版 |
| 契约 | OpenAPI 契约先行,生成 SDK/文档/校验 |
| 错误 | 统一结构、稳定 code、不泄露内部 |
| 测试 | 契约测试(Pact)进 CI |
| 治理 | 网关入口 + 生命周期 + 可观测 |
一句话记住:API 是对外"永不撤回的承诺"——用资源建模想清楚语义,用版本化守住兼容,用 OpenAPI 契约把接口变成可评审、可测试的资产,再用契约测试防止悄悄破坏。API 设计省下来的返工,往往比写的代码还多。
延伸阅读
- API 网关与 BFF 架构 — 网关入口与聚合
- 安全架构设计 — API 认证授权与加密
- 架构决策记录(ADR) — 记录 API 版本与契约决策
- 性能优化与扩展性设计 — API 性能与扩展约束
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。