兼容的定义很窄:老代码不改,行为不变。 加字段基本安全,删字段基本危险,改类型一定危险。 Demo 里 v1→v2 两条变更全兼容,v2→v3 三条全破坏。
上一篇把需求拆成了六条验收。这一篇把验收翻译成接口。 为什么接口要单独成篇? 因为它是前后端唯一共享的东西,也是唯一一处「一边改了另一边不知道」的地方。 Demo 拿三版契约做差分:哪些变更老前端不用改,哪些必须同步改。 然后把契约直接变成校验函数,真跑四个样例。
一、契约先行的两份文件
「契约先行」听起来像流程口号,其实落地只有两份东西。
| 文件 | 写什么 | 谁看 |
|---|---|---|
| 请求契约 | 字段名、类型、必填 | 前端照它发 |
| 响应契约 | 字段名、类型、可空 | 前端照它解 |
两份东西的价值不在「有文档」,在「能被机器读」。
人写的文档会过期,机器读的契约不会——因为一旦对不上,校验就挂了。
Demo 里那份契约就是几个字典:字段名对类型。
简陋,但它能直接变成一个 validate(payload, spec) 函数——
四行代码,跑起来就能挡住「缺字段」和「类型不对」。
手写校验和从契约生成校验,差别不在省几行代码,在「会不会跑偏」。
手写的那份会跟着实现走,实现变了它未必跟着变;从契约生成的那份永远跟契约一致。
二、类型从哪来
类型只有一个来源:契约。
前后端各自定义一遍,是 bug 的温床——
前端把 price 写成字符串,后端写成数字,联调第一天就会撞上。
Demo 里第四个样例就是这个:
price 给了字符串 "3.5",按 v1 契约校验直接挂,报的是「期望 float,实际 str」。
这条报错的价值在于它出现得早。
如果等联调时才发现,你要先怀疑是网络、是序列化、是哪一层,排查半小时;而在这里它是一行日志。
所以「类型从契约生成」的实际做法是:
契约是源文件,前端的类型定义和后端的校验都从它生成。
生成物不进人工编辑区,改类型只能改契约。
这条规矩听起来严,但它省掉的正是「两边都改了但改得不一样」这一类事故。
三、破坏性变更的判定
判定标准只有一句:老代码不改,行为会不会变。 会变,就是破坏性的。
| 变更 | 判定 | 为什么 |
|---|---|---|
| 响应加一个字段 | 兼容 | 老代码不看它就行 |
| 请求加一个可选字段 | 兼容 | 不传就是老行为 |
| 请求加一个必填字段 | 破坏 | 老调用方没传,直接被拒 |
| 删一个响应字段 | 破坏 | 老代码读它,读到 undefined |
| 改一个字段的类型 | 破坏 | 解析会失败或静默截断 |
第二行和第三行只差一个词:可选还是必填。
但它们的判定完全相反——这是最容易被搞混的一处,也是 Demo 里 v2→v3 那三条破坏之一。
「静默截断」这四个字值得停一下。
类型从数字改成字符串,如果前端只是拿去做展示,可能什么都不报——
直到某天有人拿它做加法,得到 "3.5" + 1 = "3.51"。
破坏性变更里最危险的不是报错的那些,是不报错的那些。
四、动手 Demo:API 契约的演进:三版对比,哪些变更会打断前端
三版契约:v1 基础版、v2 加标签、v3 删字段并收紧必填。 逐对比差分,每条变更判一个「兼容」或「破坏」。 然后把契约变成校验函数,真跑四个样例响应——缺字段、类型不对,都会被挡下来。
def diff_schema(a, b):
out = []
for ep in sorted(set(a) | set(b)):
if ep not in b:
out.append((ep, "整个端点被删", "破坏"))
continue
for part in ("response", "request"):
pa, pb = a[ep].get(part), b[ep].get(part)
for f in sorted(set(pa or {}) | set(pb or {})):
if f not in (pb or {}):
out.append((ep, part + "." + f + " 被删", "破坏"))
elif f not in (pa or {}):
req = b[ep].get("required", [])
out.append((ep, "新增" + f,
"破坏" if f in req else "兼容"))
return out
实跑输出:
--------------------------------------------------------------------
API 契约的演进:三版对比,哪些变更会打断前端
--------------------------------------------------------------------
契约先行不是「先画个表」,是把变更分成两类:前端不用改的,和必须改的。
① 三版契约
--------------------------------------------------------------------
v1:2 个端点,GET 响应 3 个字段,POST 必填 2 个。
v2:2 个端点,GET 响应 4 个字段,POST 必填 2 个。
v3:2 个端点,GET 响应 2 个字段,POST 必填 3 个。
② 逐对差分
--------------------------------------------------------------------
| 升级 | 端点 | 变更 | 判定 |
|---|---|---|---|
| v1→v2 | GET /items | response.tags 新增 | 兼容 |
| v1→v2 | POST /items | request.tags 新增 | 兼容 |
| v2→v3 | GET /items | response.price 字段被删 | 破坏 |
| v2→v3 | GET /items | response.tags 字段被删 | 破坏 |
| v2→v3 | POST /items | tags 变成必填 | 破坏 |
两对升级里,破坏性变更 3 条。全在 v2 到 v3 那一步。
③ 为什么 v1→v2 是兼容的
--------------------------------------------------------------------
| 变更 | 老前端会怎样 |
|---|---|
| 响应多一个 tags 字段 | 忽略它,照常工作 |
| 请求多一个可选 tags | 不传就行,照常工作 |
| 必填列表没变 | 没有新的必填项 |
兼容的定义很窄:老代码不改,行为不变。多给的字段它可以不看,少给的字段它一定会崩。
! 所以加字段基本安全,删字段基本危险,改类型一定危险。
这条经验的价值在于:它能把一次评审变成一次查表。
④ 从契约直接生成校验
--------------------------------------------------------------------
| 样例 | 按哪版校验 | 结果 | 第一处问题 |
|---|---|---|---|
| 正常响应 | v1 | 过 | — |
| 缺 price | v1 | 挂 | 缺 price |
| price 给成字符串 | v1 | 挂 | price 期望 float,实际 str |
| v2 带 tags | v2 | 过 | — |
四组里挂两组——缺字段和类型不对都能被这张表挡住。
! 这就是契约先行的实际收益:校验逻辑不用手写,从契约生成的那一份永远跟契约一致。
手写的那份会慢慢跑偏。
v1→v2 两条全兼容,v2→v3 三条全破坏。
这个分布不是巧合:一次是「加功能」,一次是「改结构」。
加功能通常兼容,改结构通常破坏——这条经验能让你在写代码之前就猜到判定结果。
v2→v3 那三条里,最阴的是 tags 变成必填。
字段本来就在,只是从可选变成必填——
看 diff 会觉得「没加字段啊」,但它确实会让所有老调用方挂掉。
校验那四个样例要注意第三行。
price 给成字符串,报错是「期望 float,实际 str」。
如果这个校验只在后端有,前端还是会发出错误的数据——
所以校验要从同一份契约生成,两边都装一份。
最后那句「手写的那份会慢慢跑偏」是这一篇的落点。
契约先行的收益不在文档,在「校验和类型都是生成的」——生成的东西不会跟源文件不一致。
30 秒实验
- 翻一次你们的接口文档,找出一个「文档说必填、代码里可选」的字段。 每找到一个,就说明有一处变更没被记下来。
- 拿你们最近一次接口变更,自己判一遍是兼容还是破坏。 判完再看实际有没有让前端改代码——这两件事对不上,就是没想清。
- 把一份契约写成字典,配一个十行的校验函数。 跑三个错误样例,看能不能都挡住。
常见误区
- 「加字段不会影响前端。」 加可选字段不会,加必填字段会。 两者只差一个词,判定完全相反。
- 「接口文档写清楚就行。」 人写的文档会过期。 契约要能被机器读,校验和类型从它生成,才不会跑偏。
- 「类型不对会报错,不用担心。」 从数字变字符串,很多时候不报错。 它会静默地传下去,直到某天有人拿它做运算。
自测三问(点开对答案)
1. 兼容的判定标准是什么? —— **老代码不改,行为不变。** **会变就是破坏性的。** 2. 为什么「请求加必填字段」是破坏性的? —— **因为老调用方没传这个字段,请求会被直接拒掉。** **字段有没有、必不必填,是两个独立的判定。** 3. 为什么校验要从契约生成? —— **因为手写的会跟着实现跑偏。** **生成的永远跟契约一致,而且两边都能装一份。**下一篇讲这一章风险最高的一步:数据库迁移,以及为什么它不能一步到位。
- ☐ (本篇完,下一篇 11.3 数据库迁移:风险最高的一步)