新消息显式设置 count 为零。不认识 count 的二进制转发者保留其未知显式存在性;认识 count 却采用隐式存在性的转发者省略默认零。最终两者取值都为零,但只有一条路径仍保留显式设置状态。

Protobuf 演进只能在明确契约内保留含义

gRPC 滚动发布期间,旧生产方、新消费方与中间服务可能同时运行。Protobuf 通过字段编号和线上类型识别二进制内容,帮助这些版本交换消息。但能读懂格式,并不等于应用已经兼容:消息完全可以解析成功,消费方却误解数值、忽略新指令,或者丢掉“未提供”和“显式设置为零”的区别。审阅时需要把这些承诺分别列出来。 分开编号、含义与字节 二进制标签由字段编号与线上类型组合而成,字段名不在这个标签里传输。保持编号和类型不变而改名,可以保留二进制解码能力,但仍会改变生成代码的访问器,也可能改变 JSON 或文本接口。gRPC 的方法名与服务名属于另一层契约,不能因为字段编号没有变化,就认为任意接口改名都不影响调用。 已经退役的编号绝不能拿来表达另一种意思。不同声明类型可能共享相同线上类型,例如有符号与无符号整数都使用变长整数编码,但负数可能被另一种解释读成很大的正数。解析成功不是语义验证。删除字段后,应在 schema 中保留对应编号与名称的 reserved 声明,让后续声明无法意外占用;它是禁止使用的范围,不是留待日后激活的占位字段。 未知字段可以在二进制解析后重新序列化的转发路径中保留,但只复制已知字段,或中途转换为 JSON,都可能丢掉它们。保留解码信息也不保证每次序列化的字节完全相同,字段顺序与编码选择可能随实现或版本变化。兼容测试应比较必要取值和存在状态,不能把普通重新序列化当成跨版本规范化摘要算法。即使一次小样本碰巧字节相等,也不能推广成所有消息的承诺。 存在性承载另一层含义 在 proto3 中,没有 optional 修饰的单值标量通常采用隐式存在性,正常接口无法区分默认值与未提供。显式可选标量则记录字段是否被设置,即便取值为零也能保留这个事实。消息字段与 oneof 成员本来就具有存在性;重复字段和映射不能区分空集合与没有提供。因此,不能把所有 proto3 字段都归为同一种默认规则。 单值字段在隐式与显式存在性之间变化,可能仍然可以二进制互读,却改变转发后的结果。一个知道该字段、但仍用隐式存在性解释它的旧读方,可以读入显式零,然后在重新编码时把默认值省略。仅在一端加上 optional,不会迫使每个中间节点保留这一区别。需要测试实际修改或转发消息的整条路径,而不是只比较两个最终端点。 坏掉的样子 新版消费方可能要求一个旧写方从未提供的字段。虽然 proto3 不支持 required 声明,应用层校验仍然可以制造同样的滚动升级故障。应先约定缺失时的行为,或安排好发布顺序,再开始强制要求这个值。可选语法本身不会定义合理默认值,不会授权某个操作,也不会让旧服务突然实现新功能。这些决定必须由业务契约给出。 还应覆盖已保存消息、延迟任务以及可能回滚到的版本,它们的存活时间可能远长于发布窗口。只比较紧邻的上一个版本,会漏掉仍然可能重新出现的旧记录。即便当前流量已经不使用某个字段,退役编号仍然需要保留。重新投递旧任务与读取历史数据,都不能被当成无关的边缘情况。 两种转发结果的取值都为零,但未知字段转发路径保留显式存在性,认识字段的隐式存在性转发路径则丢失它。历史 Python 实验在本地范围内通过,未测试 reserved 声明、真实 gRPC 调用或跨语言客户端。本次绘图未重跑示例。 快速验证 在 Python 环境中准备 Google 的 protobuf 7.36.2 包,再运行下面完整程序。本次使用 Python 3.12.14。独立描述符池创建真实的新旧消息类,不依赖编译器,不创建磁盘样本,不启动 gRPC 服务,也不建立网络连接。它验证 Python 二进制运行时,不能代替跨语言互操作或生成客户端兼容性测试。 ...

二月 26, 2028 · 2 分钟 · XB Field Notes
文章的新消息含 id 和 note。旧二进制转发者可以保留未知 note;把旧消息对象转为 JSON 只输出已知字段,因而丢掉 note。最终解析成功也可能已经发生信息损失。

Protobuf 兼容性会在 JSON 边界发生变化

一次 schema 变更可能适用于二进制消息,却不适用于同一服务的 JSON 网关。Protobuf 二进制格式按编号识别字段,ProtoJSON 通常暴露字段名称。因此应把生产方、消费方和中间转换组成兼容矩阵,而不是只给变更贴一个“向后兼容”的标签。编译成功,或两个端点互相读懂,并不能证明所有转发环节都保留了它们交换的信息。 新增字段是否安全,取决于表示形式 使用全新编号增加字段,通常属于二进制线上安全变更。旧解析器可以保留未知字段,同时继续读取熟悉的内容;新版解析器读取旧数据时,则得到相应默认值或缺失状态。缺失是否符合业务要求仍是另一项决定,不能在旧写方还无法提供新值时,就让新读方强制依赖它。格式接受能力与功能实施能力必须分开验收。 ProtoJSON 的规则不同。解析器默认拒绝未知字段,所以旧 JSON 消费方可能拒绝带有新增属性的消息。配置忽略未知字段,可以让解析继续,却不会为后续消费方保留被丢弃的信息。这是明确允许信息损失的策略,不是二进制未知字段转发的等价实现。应先确认新字段是否只是补充说明,还是会影响业务动作的必要输入。 保持编号而修改字段名,能很好地展示这个差别。二进制读方仍然可以按编号取得数值,但生成代码的访问器和默认 JSON 属性会变化,旧属性对新 JSON 解析器可能变成未知字段。json_name 选项同样参与这个契约。发布前应列出实际输出与接受的名称,而不是只比较编号表,便宣布改名完全没有代价。 跟踪中间转换,不只比较两个端点 旧消息对象能够在内部保留未知二进制数据,即便公开字段接口无法描述它们。把这个对象序列化成 JSON 时,只输出已知字段,未知信息就会丢失。遍历已知字段并填入一个新消息,也会产生类似损失。需要保留未知数据时,应优先使用面向整个消息的复制接口,并用测试明确验证保留结果,而不是依赖表面上相同的字段值。 这也不同于序列化字节稳定。等价消息在不同版本或实现间,不一定产生完全相同的二进制输出。比较时应检查所需值与存在状态,不应要求相同字段顺序,除非协议另外定义了规范表示。对重新序列化结果签名或计算身份摘要,需要单独、明确的规则;某一次小样本字节相等,并不能替所有版本建立这种规则。 字段编号退役后应永久保留,并把删除的名称加入 reserved,阻止 schema 中意外复用。但名称保留声明不会让运行时 JSON 解析器继续接受旧属性。deprecated 标记也不会自动删除字段,它更不会清除旧写方、已保存记录或下游依赖。真正删除之前,需要知道哪些对象还在使用它,以及怎样完成迁移。 坏掉的样子 网关可能接受一条新的二进制请求,经过旧 schema 的 JSON 对象再输出,从而悄悄抹掉新增指令。末端消费方仍可解析成功,却因缺失而进入默认分支。另一条路径则是严格旧 JSON 解析器直接拒绝整条消息。需要同时检查拒绝与静默省略,两者的运行症状不同,却可能来自同一个表示转换边界。 也要区分 schema 兼容与生成代码、运行时版本兼容。即便线上格式没有变化,新生成代码也可能要求受支持的特定运行时组合。应遵循供应方支持规则,并测试仍有意义的最老读方、延迟消息和回滚路径。几行把类型名称放进字典的自制检查器,不能证明真实解析器、代码生成或运行时的这些性质,更不能替代跨版本测试。 ...

十一月 19, 2027 · 2 分钟 · XB Field Notes