本节目标:掌握 API 演进的兼容性规则,会用 deprecated/Sunset 做渐进弃用,能选择版本化策略,并用脚本自动检测破坏性变更。
适用版本:Python 3.12+(实测 3.14.6);fastapi 0.143.0、pydantic 2.13.5
7.3 版本演进与向后兼容
7.2 我们把接口导出成了 openapi.json——一份机器可读的契约。契约一旦发布,就有无数客户端依赖它:前端的 fetch、第三方的 SDK、运维的脚本。改动它,就要为这些依赖方负责。这一节回答一个每个后端迟早要面对的问题:接口要加字段、改结构、下线旧接口,怎么改才不把别人弄挂?
7.3.1 先分清:什么是破坏性变更
向后兼容的定义很朴素:老客户端不改一行代码,继续能用。据此可以把常见变更分成三类:
| 变更 | 是否破坏兼容 | 说明 |
|---|---|---|
| 新增可选响应字段 | ✅ 安全 | 老客户端忽略多余字段即可 |
| 新增可选请求字段(带默认) | ✅ 安全 | 老客户端不传也能跑 |
| 新增必填请求字段 | ❌ 破坏 | 老客户端不传 → 422 |
| 删除响应字段 | ❌ 破坏 | 老客户端读不到会崩 |
| 删除请求字段 | ⚠️ 视情况 | 客户端还在发、且 extra="forbid" 时被拒 |
字段类型变化(number→integer) | ❌ 破坏 | 反序列化可能失败 |
| 移除枚举值 | ❌ 破坏 | 老客户端可能正好传这个值 |
| 新增枚举值 | ⚠️ 需容错 | 老客户端若穷举处理会漏 |
收紧约束(ge 变大) | ❌ 破坏 | 原本合法的输入变非法 |
放宽约束(ge 变小) | ✅ 安全 | 更多输入被接受 |
| 路径被删除 | ❌ 破坏 | 404 |
记住一条经验法则:对客户端「更宽松」的改动通常安全,对客户端「更严格」的改动几乎都是破坏性的。加可选字段是放宽,加必填字段是收紧——方向不同,命运迥异。
7.3.2 字段增删的具体规则
把上表落到 Pydantic 模型上,有几条可直接执行的规则:
- 加字段永远给默认值。
Field(default=...)或X | None = None,老请求不会因缺字段而 422。 - 响应字段只增不删。确实要停用,先标记
deprecated保留一段,再在下一个大版本移除。 - 枚举只增不移除。且要在文档里明确「客户端必须能容忍未知枚举值」,否则新增值也会伤人。
- 类型只放宽不收紧。
int→float尚可(数值兼容),float→int会砍掉小数,破坏性。 - 约束只放宽不收紧。
max_length=64改成max_length=32是隐形的破坏——原本能提交的字符串突然被拒。
第 5 条最容易被忽视:它不改结构,只改范围,代码 diff 看着无害,线上却开始 422。
7.3.3 两个最小实验:安全与破坏
光看表格不如亲手验一遍。用 Pydantic 构造「老请求」,分别喂给两个新版本模型:
from pydantic import BaseModel, ConfigDict, ValidationError
class Cfg(BaseModel):
model_config = ConfigDict(extra="forbid")
class AddRequired(Cfg):
sku: str
name: str
warehouse: str # 新增必填 -> 破坏
class AddOptional(Cfg):
sku: str
name: str
warehouse: str = "CN-01" # 新增可选带默认 -> 安全
old = {"sku": "SKU-0001", "name": "键盘"}
AddRequired.model_validate(old) # 抛 ValidationError
AddOptional.model_validate(old) # 正常通过
实测结果:
老请求: {'sku': 'SKU-0001', 'name': '键盘'}
新增必填 -> missing | Field required
新增可选 -> {'sku': 'SKU-0001', 'name': '键盘', 'warehouse': 'CN-01'}
「新增必填」直接报 missing,老客户端全线 422;「新增可选」则平稳过渡。再看删除请求字段的后果:
删除请求字段(老客户端仍发) -> extra_forbidden | Extra inputs are not permitted
这里有个反直觉的点:服务端「删除」一个请求字段,配合 extra="forbid",会让仍在发送该字段的老客户端被拒。所以删字段前,要么先把它 deprecated 一段时间、观察流量归零,要么把 extra 放宽为 ignore。extra="forbid" 是双刃剑——它挡垃圾,也让删除字段变得危险。
7.3.4 渐进弃用:deprecated + Sunset 头
下线一个接口不能「今天通知、明天删除」。标准做法是先弃用、再观察、后移除。三层手段配合使用:
路由级:装饰器加 deprecated=True,schema 里出现 "deprecated": true,Swagger UI 与代码生成器会打标记。
字段级:Pydantic 2.7+ 支持给字段加 deprecated=True。实测它确实进了 JSON Schema:
from pydantic import BaseModel, Field
class ItemOut(BaseModel):
sku: str
name: str
legacy_code: str | None = Field(default=None, deprecated=True)
{'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'deprecated': True, 'title': 'Legacy Code'}
响应头级:在中间件里给旧路径的响应加上 Deprecation、Sunset(RFC 8594 定义的「停用日期」)、Link 三个头,让客户端在运行时就能感知,而不只是读文档:
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def add_deprecation_headers(request: Request, call_next):
resp = await call_next(request)
if request.url.path.startswith("/v1/"):
resp.headers["Deprecation"] = "true"
resp.headers["Sunset"] = "Wed, 31 Dec 2026 23:59:59 GMT"
resp.headers["Link"] = '</v2/items>; rel="successor-version"'
return resp
实测 /v1/items/SKU-0001 的响应头:
deprecation: true
sunset: Wed, 31 Dec 2026 23:59:59 GMT
link: </v2/items>; rel="successor-version"
Sunset 给客户端一个明确的死线,Link 指向替代接口。这套组合让「弃用」从口头约定变成了协议层面的信号。
7.3.5 版本化策略:URL 还是 Header
当确实需要破坏性变更时,就得上版本。两条主流路线:
| 策略 | 形式 | 优点 | 缺点 |
|---|---|---|---|
| URL 路径版本 | /v1/items、/v2/items | 直观、易调试、缓存友好 | URL 变长;同一资源多个地址 |
| Header 版本 | Accept: application/vnd.api.v2+json | URL 稳定、语义纯粹 | 调试不便、易被网关忽略 |
工程上大多数团队选 URL 路径版本,因为它的可调试性压倒一切——出问题时 curl /v1/items 一眼就能定位。Header 版本更「REST 纯粹」,但对运维和排查不友好。
无论选哪种,都要守住一条:新旧版本并存期间,老版本只做安全变更(只加可选字段),把破坏性变更全部放进新版本。版本不是「每改一次就发一个」,而是「确有破坏性变更时才开新版」。
在 FastAPI 里,URL 版本最直接的落地是分路由模块 + APIRouter(prefix="/v2"),把 v1、v2 的模型彻底隔离——两套 Pydantic 模型各自独立,绝不共享一个会漂移的模型。
7.3.6 用契约 diff 自动抓破坏性变更
人工审查 openapi.json 的 diff 既累又容易漏。写个脚本对比两版 schema,把破坏性变更标出来。下面是一个可运行的最小实现:
def diff_schema(old: dict, new: dict) -> list[str]:
problems: list[str] = []
old_schemas = old.get("components", {}).get("schemas", {})
new_schemas = new.get("components", {}).get("schemas", {})
# 1. 路径被删除
for path in old["paths"]:
if path not in new["paths"]:
problems.append(f"[BREAKING] 路径被删除: {path}")
# 2. 模型字段变化
for name, osch in old_schemas.items():
nsch = new_schemas.get(name)
if nsch is None:
problems.append(f"[BREAKING] 模型被删除: {name}")
continue
oprops, nprops = osch.get("properties", {}), nsch.get("properties", {})
oreq, nreq = set(osch.get("required", [])), set(nsch.get("required", []))
for f in nreq - oreq:
problems.append(f"[BREAKING] {name} 新增必填字段: {f}")
for f in oprops.keys() - nprops.keys():
problems.append(f"[BREAKING] {name} 删除字段: {f}")
for f in oprops.keys() & nprops.keys():
ot, nt = oprops[f].get("type"), nprops[f].get("type")
if ot and nt and ot != nt:
problems.append(f"[BREAKING] {name}.{f} 类型变化: {ot} -> {nt}")
oenum, nenum = set(oprops[f].get("enum", [])), set(nprops[f].get("enum", []))
if oenum and oenum - nenum:
problems.append(f"[BREAKING] {name}.{f} 移除枚举值: {oenum - nenum}")
if oenum and nenum - oenum:
problems.append(f"[WARN] {name}.{f} 新增枚举值(客户端需能容错): {nenum - oenum}")
return problems
用两版构造的 schema 实测,它准确抓出了三处破坏性变更:
== 对比 v1 -> v2(故意塞进多个破坏性变更) ==
[BREAKING] ItemIn 新增必填字段: warehouse
[BREAKING] ItemIn.price 类型变化: number -> integer
[BREAKING] ItemIn.status 移除枚举值: {'preorder'}
而一次纯向后兼容的演进(只加可选字段、只加枚举值、只加新路径),脚本的输出是:
== 纯向后兼容的演进 ==
[WARN] ItemIn.status 新增枚举值(客户端需能容错): {'sold_out'}
(无 [BREAKING] 即为安全)
把它接进 CI:每次 PR 都拿仓库里存档的 openapi.json 和最新生成的对比,出现 [BREAKING] 就让流水线红灯。这样破坏性变更会在合并前被拦下,而不是上线后由用户告诉你。
7.3.7 契约测试与演进节奏
把本节串成一套可执行的节奏:
- 归档基线:每次发版把
openapi.json存进仓库(如contracts/openapi-v1.2.0.json)。 - CI 对比:PR 阶段跑契约 diff,破坏性变更必须显式确认(如加标签
breaking-change才放行)。 - 渐进弃用:破坏性变更进新版本;老版本加
deprecated+Sunset头,给足迁移窗口。 - 契约测试:用 7.2.6 的
TestClient断言关键结构(必填字段、枚举、additionalProperties)。 - 到期移除:
Sunset日期过后,监控旧版本流量归零,再删代码。
一次典型的弃用迁移,时间线大致是这样:
| 阶段 | 动作 | 持续时间(参考) |
|---|---|---|
| T+0 | 新版本上线,老接口标记 deprecated,文档写明迁移方式 | — |
| T+0 | 老接口响应加 Deprecation + Sunset 头 | 持续到下线 |
| T+1 月 | 监控老接口调用量,主动联系仍未迁移的调用方 | 视流量而定 |
| T+3 月 | 若调用量未归零,发邮件/工单做最后一轮提醒 | 1 个月缓冲 |
Sunset 当日 | 老接口返回 410 Gone,保留一小段时间便于排查 | 1~2 周 |
Sunset 之后 | 彻底删除代码与路由 | — |
节奏快慢可以调,但顺序不能乱:先标记、再观察、后下线。最忌讳的是「文档写了弃用,但线上毫无信号,到期直接 404」——调用方根本不知道自己踩了线。
延伸阅读:Python 库与 API 设计 从语义化版本与库作者视角讲兼容,可与此处的 HTTP 契约视角对照阅读。
小结
- 判断兼容性看方向:放宽(加可选字段、加枚举值、放宽约束)通常安全,收紧(加必填、删字段、改类型、收紧约束)几乎都是破坏性的。
- 加字段一律带默认值;响应字段只增不删;枚举只增不移除;类型与约束只放宽不收紧。
- 渐进弃用三件套:路由/字段
deprecated=True+ 响应头Deprecation/Sunset/Link;Sunset给出明确死线。 - 版本化首选 URL 路径(
/v1、/v2),可调试性最好;新旧版本各用独立的 Pydantic 模型。 - 把 OpenAPI 契约 diff 接进 CI,破坏性变更在合并前拦截,而不是上线后暴露。
到这里,第 7 章「数据校验与 API 契约」就闭环了:7.1 设计模型、7.2 导出契约、7.3 让契约安全演进。下一章我们进入性能与缓存的领域,先从 Redis 缓存的层次与键设计讲起。
阅读导航:上一节:OpenAPI 契约与客户端代码生成 · 下一节:Redis 缓存层次与键设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。