47.6 API 设计规范 (REST / OpenAPI / 版本策略)
一句话
API 规范的核心不是“用不用 REST”,而是把“什么算破坏性变更”写死并自动检查:加字段安全、删/改字段危险、改语义最危险——规范的价值在于让这条判定不依赖人的记忆。
REST 的三条硬规则
| 规则 | 正确 | 错误 |
|---|---|---|
| 资源用名词、动作用方法 | POST /orders、GET /orders/{id} | POST /createOrder |
| 状态码表达结果 | 201 创建、400 参数、401 未认证、403 越权、409 冲突、429 限流 | 全部 200 + {"code":500} |
| 幂等用方法语义 | GET/PUT/DELETE 幂等,POST 不幂等 | 用 POST 做“更新”却没幂等键 |
🔑 判据:“同一个请求重发一次,结果会不会变?” 会变 → 必须带幂等键(25.4);不会变 → 用 PUT/DELETE。
代码:破坏性变更检查器
package main
import "fmt"
type field struct {
name string
typ string
required bool
}
type spec struct {
fields []field
}
func find(s spec, name string) (field, bool) {
for _, f := range s.fields {
if f.name == name {
return f, true
}
}
return field{}, false
}
func main() {
old := spec{[]field{
{"id", "string", true},
{"amount", "int64", true},
{"status", "string", true},
{"remark", "string", false},
}}
new := spec{[]field{
{"id", "string", true},
{"amount", "string", true}, // 类型改了
{"status", "string", true},
{"created_at", "string", true}, // 新增必填
}}
// 规则:新增可选=兼容;新增必填=破坏;删字段=破坏;改类型=破坏;改名=删+加
breaking := 0
compatible := 0
fmt.Println("=== 逐字段对比 ===")
for _, f := range old.fields {
nf, ok := find(new, f.name)
if !ok {
fmt.Printf("[破坏] 删除字段 %s\n", f.name)
breaking++
continue
}
if nf.typ != f.typ {
fmt.Printf("[破坏] 字段 %s 类型 %s → %s\n", f.name, f.typ, nf.typ)
breaking++
} else {
fmt.Printf("[兼容] 字段 %s 未变\n", f.name)
compatible++
}
}
for _, nf := range new.fields {
if _, ok := find(old, nf.name); ok {
continue
}
if nf.required {
fmt.Printf("[破坏] 新增必填字段 %s(旧客户端不会传)\n", nf.name)
breaking++
} else {
fmt.Printf("[兼容] 新增可选字段 %s\n", nf.name)
compatible++
}
}
fmt.Printf("\n结论:破坏性变更 %d 处,兼容变更 %d 处\n", breaking, compatible)
if breaking > 0 {
fmt.Println("处理方式:升主版本(/v2)或提供兼容期 + 弃用公告")
}
}
本机实测(macOS,Apple M1,Go 1.27):
=== 逐字段对比 ===
[兼容] 字段 id 未变
[破坏] 字段 amount 类型 int64 → string
[兼容] 字段 status 未变
[破坏] 删除字段 remark
[破坏] 新增必填字段 created_at(旧客户端不会传)
结论:破坏性变更 3 处,兼容变更 2 处
处理方式:升主版本(/v2)或提供兼容期 + 弃用公告
读数:
| 变更 | 判定 | 理由 |
|---|---|---|
| 删字段 | 破坏 | 旧客户端仍在读 |
改类型 int64 → string | 破坏 | JSON 解析行为变了 |
| 新增必填字段 | 破坏 | 旧客户端不会传,请求被拒 |
| 新增可选字段 | 兼容 | 旧客户端忽略未知字段即可 |
🔑 规范落地的最小闭环:OpenAPI 文件进仓库 → CI 里跑兼容性检查 → 破坏性变更必须升版本。没有这步,“规范”只是一份 wiki。
三种版本策略
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| URL 版本 | /v1/orders | 直观、易调试、网关易路由 | URL 变长;资源身份变了 |
| Header 版本 | Accept: application/vnd.x.v2+json | URL 稳定;同一资源多版本 | 难调试、缓存与网关要支持 |
| 无版本(兼容演进) | 只加字段、不改语义 | 客户端永不改 | 约束强,历史包袱累积 |
⚠️ 最常见错误:URL 里放了版本,但破坏性变更没升版本(“小改,不用升”)。版本策略的判据不是“改动大小”,而是“旧客户端还能不能正确工作”。
反模式
| 反模式 | 后果 |
|---|---|
| 全 200 + 业务码 | 网关/监控无法按状态码统计错误 |
| 分页用 offset 且无上限 | 深分页拖垮数据库(30.7) |
| 返回全量字段 | 移动端流量浪费、字段演进被绑死 |
| 错误响应不带错误码与定位信息 | 排障靠猜 |
| 版本号只写在文档里 | 网关无法路由,回滚只能整体回 |
| 破坏性变更“偷偷发” | 客户端随机报错,且难定位 |
自检
Q1 为什么“新增一个必填字段”是破坏性变更,而“新增可选字段”不是?
▸ 答案:因为旧客户端按旧契约发请求,不知道有这个字段,也不会传。服务端把缺失的必填字段判为 400,旧客户端立刻全部失败。可选字段则不同:旧客户端不传,服务端用默认值;新客户端传了,旧服务端忽略(或旧服务端不认,但客户端能容忍)。判据是“旧客户端在不修改的情况下还能不能成功调用”。
Q2 URL 版本与 Header 版本,什么时候选哪个?
▸ 答案:对外公开 API 优先 URL 版本:直观、可分享、网关与 CDN 规则简单、调试一眼看出。内部服务间(gRPC/IDL)通常不显式放版本,靠“只加不改”的兼容演进 + 契约测试(53.5)。Header 版本适合“同一资源要并行多个表示”的场景,但运维与调试成本高。
Q3 一个团队说“我们的 API 从不做破坏性变更”,这句话怎么验证?
▸ 答案:用自动检查验证,而不是信任:把每个版本的 OpenAPI 存进仓库,CI 里跑“新旧 spec 对比”(删字段/改类型/新增必填/改枚举/改语义),任何一项命中就失败。还要覆盖“行为变更”(同一字段含义变了,但类型没变)——这类只能靠契约测试与评审清单。没有自动检查的“我们不做破坏性变更”,通常只是“我们还没被发现”。