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 对比”(删字段/改类型/新增必填/改枚举/改语义),任何一项命中就失败。还要覆盖“行为变更”(同一字段含义变了,但类型没变)——这类只能靠契约测试与评审清单。没有自动检查的“我们不做破坏性变更”,通常只是“我们还没被发现”。

关联