有个字段改了名,却没有抛出异常
一句话总结
和别人的系统对接时,我们要守住的不是对方的完整 schema,而是我们实际读取的字段清单,而且要把这份清单钉死在每次都会运行的测试上,而不是文档里,这样版本升级才无法悄悄地把我们的数字弄错。
为什么需要它
集成中代价最高的事故,并不是连接中断的事故。连接中断时告警会响起,人会赶过来。真正昂贵的,是没有任何错误、却算出错误数字的事故。
假设合作方把订单 API 从 1.3 升级到了 1.4。变化有三处:region 改名为 market,amount 从整数 12300 变成字符串 "12300.00",status 多了一个 on_hold 值。我们的采集器会怎样呢?
rec.get("region")不会抛出异常,而是返回None。按地区统计的销售报告中,所有行都会汇聚到“未分类”。sum(r["amount"] for r in rows)如果是 Python,会抛出TypeError——这算是幸运的情况。但如果代码是把字符串首尾拼接,或者是 JavaScript,就会悄悄地变成"1230012300…"。- 在只写了
if status == "paid"这种分支的代码里,on_hold进不了任何一个分支,那笔订单会整个从汇总中漏掉。
这三种情况都不会触发告警。几周之后,当客户说“数字好像有点不对”时,错误的报告已经发出去好几份了。
工作原理
处理这个问题的方法有三个。
第一,知道是什么会打破它。从消费者(读取响应的一方)的角度划分变更,规则很简单。增加字段是安全的,而字段消失、改名或类型变化都是不安全的。枚举值增多也不安全——因为一旦来了我们分支里没有的值,就哪个分支都进不去。方向相反的请求一侧,规则也相反。请求中增加可选字段是安全的,而增加必填字段会因为我们的请求被拒绝而造成破坏。
这里成为基准的,是“如何处理未知字段”。JSON Schema 2020-12 的 core 规范用 additionalProperties 来规定对它的处理,required 则是必须存在的键的清单。编写消费者契约时,通常会允许未知字段。这样,合作方每增加一个字段,我们都不会因此而出问题。
第二,契约由我们这边来写。人们会有一种诱惑,想把合作方的 OpenAPI 规范直接当作我们的契约,但这样一来,即使是我们不读取的字段发生变更,我们的测试也会亮红灯。噪音一多,人就会把测试关掉。所以契约中只写我们实际读取的字段。这种方式通常称为消费者驱动契约(consumer-driven contract)。
第三,把契约做成测试,而不是文档。只需要一个脚本:拿到合作方的响应,与契约比对,不一致就以非 0 的退出码结束。把它放进流水线,即使合作方在没有通知的情况下有所变动,我们也能最先知道。“没看到版本升级通知邮件”,是事故报告中最常出现的一句话。
파트너 응답 ──▶ 계약 대조기 ──▶ 위반 0 ? 통과
│
└─ 위반 n ? 파이프라인 실패 + 무엇이 어긋났는지 필드 단위로 출력
版本号本身也是一种约定。语义化版本(Semantic Versioning)规定,破坏兼容的变更要提升主版本号。但那是发布者的约定,合作方也可能不遵守。如果从 1.3 升到 1.4 而我们出了问题,那不是我们读错了,而是对方违背了约定——不过要证明这一点,就需要契约测试的输出。
在现场相遇的样子
第一,版本升级不会一次到位。旧版本和新版本会并行好几个月。所以读取的一方必须同时接受二者。改了名字的位置用别名表吸收,改了类型的位置用规范化函数吸收,并且在内部只使用一种形态。如果把别名表散落在代码各处,三个月后要停掉旧版本时,就没有人知道该删哪里。
第二,“必填但偶尔为空”最常见。规范里写的是必填,实际响应中却有 3% 是空字符串。所以契约测试不是去读规范,而是要对实际响应做全量而不是抽样的扫描。
第三,金额和时刻永远是问题。经常会从整数最小单位变成十进制字符串,或者反过来。一旦用浮点数接收就会产生舍入,所以规范化要用整数或字符串来做,不要经过浮点数。
第四,契约测试在哪里运行,才是真正的争议点。不可能每分钟都对合作方的生产环境运行。通常是对对方提供的沙箱,每天运行几次,再在部署之前运行一次。有时沙箱和生产环境运行的是不同的版本,所以契约测试的输出中一定要留下是对哪个地址测量的。
下一项实验要做什么
启动一台同时提供合作方订单 API 1.3 和 1.4 的服务器,把两个版本的响应拿到手。以机器可读的形式提取字段差异,并制作一个小工具,按变更类型判定哪些会打破我们。然后制作只写我们所读取字段的消费者契约和比对该契约的比对器,再接上一个同时接受旧版本和新版本的读取层。最后制造合作方在没有通知的情况下又一次变动的局面,确认契约测试能否逐字段地抓到它。