🛠️ 第11章 场景三:跨前后端功能开发
📄 一句话概括

兼容的定义很窄:老代码不改,行为不变。 加字段基本安全,删字段基本危险,改类型一定危险。 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. 把一份契约写成字典,配一个十行的校验函数。 跑三个错误样例,看能不能都挡住。

常见误区

  1. 「加字段不会影响前端。」 加可选字段不会,加必填字段会。 两者只差一个词,判定完全相反。
  2. 「接口文档写清楚就行。」 人写的文档会过期。 契约要能被机器读,校验和类型从它生成,才不会跑偏。
  3. 「类型不对会报错,不用担心。」 从数字变字符串,很多时候不报错。 它会静默地传下去,直到某天有人拿它做运算。
自测三问(点开对答案) 1. 兼容的判定标准是什么? —— **老代码不改,行为不变。** **会变就是破坏性的。** 2. 为什么「请求加必填字段」是破坏性的? —— **因为老调用方没传这个字段,请求会被直接拒掉。** **字段有没有、必不必填,是两个独立的判定。** 3. 为什么校验要从契约生成? —— **因为手写的会跟着实现跑偏。** **生成的永远跟契约一致,而且两边都能装一份。**

下一篇讲这一章风险最高的一步:数据库迁移,以及为什么它不能一步到位。

相关笔记