给变更编号,让两边都活着
目标
创建契约工具 contract.py,用带版本号的 schema 处理 JSON Lines。把版本之间的变化分门别类,按 Avro 的 schema 解析规则判定向后兼容和向前兼容,并用一个读取 schema 度过多个版本混在一起流动的过渡期。
为什么重要
写入方和读取方不会在同一时刻改变。其间必然有一段只有一方是新代码的时期,而在这段时期里数据也仍在持续流动。所以设计 schema 变更时该问的,不是“这个变更对不对”,而是“无论按什么顺序部署,它都能存活吗”。 答案分为三种。增加带默认值的字段在两个方向上都是安全的;增加没有默认值的必填字段,会让新的读取代码读不了旧数据;类型变更中,加宽和收窄的结果恰好相反。改名单看 schema 就是一次新增和一次删除,只有靠别名才能还原成一个事件。但别名只在读取方 schema 里起作用,所以改名只在一个方向上存活。 度过过渡期的办法只有一个。给读取 schema 充分加上默认值,让旧版本也能被读出来。本实验用数字展示这一个默认值能救回多少条。 评分器不会相信你写出来的文字。它会在临时目录里摆好评分器生成的 schema 和行,真正运行你的工具,把分类和判定结果与评分器另行实现的值相比对。字段名、类型和金额每次运行都会变化。
步骤
- 创建并运行 /root/evolve/gen_stream.py,在 /root/evolve/schemas 中生成
v1.json到v5.json,在 /root/evolve/stream 中生成每个版本各 12 行的v1.jsonl到v5.jsonl,以及五个版本混在一起的mixed.jsonl。 - 在 /root/evolve/contract.py 中实现
fields <스키마>(占位符为 schema),输出版本号、字段名、必填和可选,以及默认值。 - 加上
diff <옛 스키마> <새 스키마>(占位符依次为旧 schema、新 schema),对新增和删除进行分类。新增要分成带默认值的和不带默认值的。 - 让
diff根据新版本的aliases把改名合并成一个事件。被合并的名字要从新增和删除列表中去掉。 - 让
diff把类型变更分成加宽和收窄。在提升表里就是加宽,不在就是收窄。 - 加上
compat <옛 스키마> <새 스키마>,判定向后兼容和向前兼容,并用固定的代码给出原因。 - 加上
read <읽기 스키마> <스키마폴더> <파일>(占位符依次为读取 schema、schema 目录、文件),把多个版本混在一起的行按一个版本读出来,再创建宽松读取 schema /root/evolve/reader.json,把两种读取的差异写进 /root/evolve/window.json。 - 把五个版本的历史写成 /root/evolve/evolve_report.json 和 /root/evolve/evolve_report.md。
参考
- 运行契约:
python3 /root/evolve/contract.py <명령> ...(占位符为命令)。成功时退出码为 0,给出的路径不存在时为 3,命令不认识或参数个数不对时为 2。结果以一个 JSON 对象输出到标准输出。 - schema 文件的格式:
{"version": 정수, "name": 문자열, "fields": [{"name": 이름, "type": 타입, "default": 기본값, "aliases": [옛이름...]}, ...]}(占位符依次为整数、字符串、名称、类型、默认值、旧名字)。default和aliases可有可无。 - 带默认值的字段是可选的,没有默认值的字段是必填的。本实验中区分必填与可选的,只有这一个键。
- 类型有
int、long、float、double、string、boolean这六种。提升表与 Avro 规范完全一致——int 可提升为 long、float、double,long 可提升为 float、double,float 可提升为 double。相同类型也视为提升。其他组合都不是提升。 fields的响应是{"version": 정수, "names": [선언 순서대로], "required": [정렬], "optional": [정렬], "defaults": {이름: 기본값}}(占位符依次为整数、按声明顺序、排序、排序、名称、默认值)。diff的响应是{"added_with_default": [정렬], "added_required": [정렬], "removed": [정렬], "renamed": [[옛이름, 새이름]...], "widened": [[새이름, 옛타입, 새타입]...], "narrowed": [[새이름, 옛타입, 새타입]...]}(占位符依次为排序、排序、排序、旧名字和新名字、新名字和旧类型和新类型、新名字和旧类型和新类型)。所有列表都要排序后输出。第 3 步只有前三个键,第 4 步加上renamed,第 5 步加上widened、narrowed。- 改名判定:如果新版本独有字段的
aliases中包含旧版本独有的名字,这两者就是同一个字段。不用值的样本去猜——在这里,写别名的一方是我们。 compat的响应是{"backward": 참거짓, "forward": 참거짓, "reasons": [정렬된 코드...]}(占位符依次为布尔值、布尔值、排序后的代码)。原因代码只有四种:backward:missing_default:<필드>(占位符为字段名)是只存在于新版本的必填字段,所以读不了旧数据backward:no_promotion:<필드>(占位符为字段名)是旧类型不能提升为新类型forward:missing_default:<필드>(占位符为字段名)是只存在于旧版本的必填字段,所以在新数据里找不到值forward:no_promotion:<필드>(占位符为字段名)是新类型不能提升为旧类型
- 判定用“读取方能否读取写入方的数据”这一个函数,再把参数对调得到两个方向,这样比较短。向后兼容是读取方为新版本,向前兼容是读取方为旧版本。
- 别名只使用读取方 schema 的。因为它的做法是,把写入方 schema 按读取方的名字改写后再读取。所以改名只在一个方向上存活。
read的响应是{"records": 정수, "by_version": {"판번호": 정수}, "resolved": 정수, "failed": 정수, "amount_total": 정수}(占位符依次为整数;版本号、整数;整数、整数、整数)。版本号键是字符串。每一行通过_v读取版本号,并在 schema 目录里查找v<판번호>.json(占位符为版本号)。如果读取 schema 读不了该版本,就计入failed,且不计入金额。amount_total是按读取 schema 的amount字段解析之后相加的值。- 镜像里没有 Avro 库。只从规范中取规则,用 JSON Lines 和带版本号的 schema 文件自己实现。只使用标准库。
- 官方文档:Avro 规范中的 schema 解析 · Confluent 兼容性类型 · Protocol Buffers 语言指南 · python json
- 常见错误:增加了没有默认值的字段却以为向后兼容,以为改名在两个方向上都安全,以为类型加宽就一定安全,过渡期内只读最新版本而悄悄丢掉旧版本。
生成并输出五个版本
创建并运行 /root/evolve/gen_stream.py,在 /root/evolve/schemas 中生成 v1.json 到 v5.json,在 /root/evolve/stream 中生成 v1.jsonl 到 v5.jsonl,以及五个版本混在一起的 mixed.jsonl。
每个版本只让一个地方发生变化,之后就能看清判定抓住的是什么。v2 增加带默认值的字段,v3 增加没有默认值的字段,v4 在改名(加上别名)的同时加宽类型,v5 再把这个类型收窄回去。每一行都用 _v 写明是哪个版本。
用默认值区分必填和可选
在 /root/evolve/contract.py 中实现 fields <스키마>(占位符为 schema),以 JSON 输出 version、names、required、optional、defaults。带默认值的字段是可选的,没有默认值的字段是必填的。
names 按声明顺序原样输出,required 和 optional 排序后输出。只需要看有没有 default 键,默认值即使是空字符串或 0,也是可选——按键是否存在来区分,而不是按值。
区分新增的和消失的
加上 diff <옛 스키마> <새 스키마>(占位符依次为旧 schema、新 schema),输出 added_with_default、added_required、removed 三个列表。三个列表都要排序后输出。
只需求名字集合的差。新增的字段,按新版本声明中有没有 default 键分成两类。这一步还可以先不看别名和类型变更。
把改名合并成一个事件
给 diff 的响应加上 renamed。如果新版本独有字段的 aliases 中包含旧版本独有的名字,这两者就是同一个字段。被合并的名字要从 added_* 和 removed 中去掉。
单看 schema,改名就是一次新增和一次删除。别名是把这两者还原成一个事件的唯一机制。不要用值的样本去猜——在这里,写别名的一方是我们,没有猜的理由。
区分加宽和收窄
给 diff 的响应加上 widened 和 narrowed。条目是 [새이름, 옛타입, 새타입](占位符依次为新名字、旧类型、新类型),如果旧类型可以提升为新类型,就是加宽,其他的是收窄。改名的同时类型也变了的字段也要看。
把提升表写成一个字典,判定就只要一行。int 到 long、float、double,long 到 float、double,float 到 double。相同类型也要视为提升,后面的兼容判定才会简单。
分别判定向后和向前
加上 compat <옛 스키마> <새 스키마>(占位符依次为旧 schema、新 schema),输出 backward、forward、reasons。原因只使用参考一节里的四种代码,并排序后输出。
写一个函数,判断读取方能否读取写入方的数据,再把参数对调,得到两个方向。向后兼容是读取方为新版本,向前兼容是读取方为旧版本。别名只使用读取方 schema 的,这一点决定了这里的结果。
用一个读取 schema 度过过渡期
加上 read <읽기 스키마> <스키마폴더> <파일>(占位符依次为读取 schema、schema 目录、文件),再创建能读五个版本的宽松读取 schema /root/evolve/reader.json。把用 schemas/v5.json 读出的结果和用 reader.json 读出的结果,以 strict、tolerant、amount_total 写入 /root/evolve/window.json。
严格读取 schema 会因为没有默认值的字段而把旧版本整批丢掉。给这个字段加上默认值后能救回多少条,就是这一步的答案。别名也要放在读取方,才能沿着旧名字读下去。amount_total 写宽松一方的值。
把版本历史留成一页
对相邻的每一对版本运行 compat,在 /root/evolve/evolve_report.json 中写入 versions、steps、full、broken,并在 /root/evolve/evolve_report.md 中写成四节,标题是 ## 어떤 판이 있나(韩文,意为“有哪些版本”)、## 어느 방향이 깨지나(韩文,意为“哪个方向会被破坏”)、## 전환 기간을 어떻게 넘기나(韩文,意为“如何度过过渡期”)、## 다음 판에 지킬 것(韩文,意为“下一个版本要遵守的事项”)。
steps 是 {"from": 정수, "to": 정수, "backward": 참거짓, "forward": 참거짓, "reasons": [...]} 的列表(占位符依次为整数、整数、布尔值、布尔值)。full 放两个方向都成立的版本对,broken 放至少有一个方向被破坏的版本对,都写成 [옛판, 새판](占位符依次为旧版本、新版本)。报告里要用数字写出过渡期内救回的条数。