一次 schema 变更可能适用于二进制消息,却不适用于同一服务的 JSON 网关。Protobuf 二进制格式按编号识别字段,ProtoJSON 通常暴露字段名称。因此应把生产方、消费方和中间转换组成兼容矩阵,而不是只给变更贴一个“向后兼容”的标签。编译成功,或两个端点互相读懂,并不能证明所有转发环节都保留了它们交换的信息。
新增字段是否安全,取决于表示形式
使用全新编号增加字段,通常属于二进制线上安全变更。旧解析器可以保留未知字段,同时继续读取熟悉的内容;新版解析器读取旧数据时,则得到相应默认值或缺失状态。缺失是否符合业务要求仍是另一项决定,不能在旧写方还无法提供新值时,就让新读方强制依赖它。格式接受能力与功能实施能力必须分开验收。
ProtoJSON 的规则不同。解析器默认拒绝未知字段,所以旧 JSON 消费方可能拒绝带有新增属性的消息。配置忽略未知字段,可以让解析继续,却不会为后续消费方保留被丢弃的信息。这是明确允许信息损失的策略,不是二进制未知字段转发的等价实现。应先确认新字段是否只是补充说明,还是会影响业务动作的必要输入。
保持编号而修改字段名,能很好地展示这个差别。二进制读方仍然可以按编号取得数值,但生成代码的访问器和默认 JSON 属性会变化,旧属性对新 JSON 解析器可能变成未知字段。json_name 选项同样参与这个契约。发布前应列出实际输出与接受的名称,而不是只比较编号表,便宣布改名完全没有代价。
跟踪中间转换,不只比较两个端点
旧消息对象能够在内部保留未知二进制数据,即便公开字段接口无法描述它们。把这个对象序列化成 JSON 时,只输出已知字段,未知信息就会丢失。遍历已知字段并填入一个新消息,也会产生类似损失。需要保留未知数据时,应优先使用面向整个消息的复制接口,并用测试明确验证保留结果,而不是依赖表面上相同的字段值。
这也不同于序列化字节稳定。等价消息在不同版本或实现间,不一定产生完全相同的二进制输出。比较时应检查所需值与存在状态,不应要求相同字段顺序,除非协议另外定义了规范表示。对重新序列化结果签名或计算身份摘要,需要单独、明确的规则;某一次小样本字节相等,并不能替所有版本建立这种规则。
字段编号退役后应永久保留,并把删除的名称加入 reserved,阻止 schema 中意外复用。但名称保留声明不会让运行时 JSON 解析器继续接受旧属性。deprecated 标记也不会自动删除字段,它更不会清除旧写方、已保存记录或下游依赖。真正删除之前,需要知道哪些对象还在使用它,以及怎样完成迁移。
坏掉的样子
网关可能接受一条新的二进制请求,经过旧 schema 的 JSON 对象再输出,从而悄悄抹掉新增指令。末端消费方仍可解析成功,却因缺失而进入默认分支。另一条路径则是严格旧 JSON 解析器直接拒绝整条消息。需要同时检查拒绝与静默省略,两者的运行症状不同,却可能来自同一个表示转换边界。
也要区分 schema 兼容与生成代码、运行时版本兼容。即便线上格式没有变化,新生成代码也可能要求受支持的特定运行时组合。应遵循供应方支持规则,并测试仍有意义的最老读方、延迟消息和回滚路径。几行把类型名称放进字典的自制检查器,不能证明真实解析器、代码生成或运行时的这些性质,更不能替代跨版本测试。
快速验证
准备 Python 和 Google 的 protobuf 7.36.2 包,然后运行完整程序。本次在 Python 3.12.14 上通过。独立描述符池为三套 schema 创建真实消息类,二进制与 JSON 都调用库本身的解析实现,不是手写序列化模型。实验不调用编译器,不启动服务,也不会发送网络请求,作用范围限定为本地运行时转换。
from google.protobuf import descriptor_pb2 as schema, descriptor_pool, message_factory, json_format
def message(fields):
file = schema.FileDescriptorProto(name='probe.proto', package='probe', syntax='proto3')
record = file.message_type.add(name='Record')
for name, number, kind in fields:
record.field.add(name=name, number=number, type=kind, label=1)
pool = descriptor_pool.DescriptorPool()
pool.Add(file)
return message_factory.GetMessageClass(pool.FindMessageTypeByName('probe.Record'))
INT32 = schema.FieldDescriptorProto.TYPE_INT32
STRING = schema.FieldDescriptorProto.TYPE_STRING
Old = message([('id', 1, INT32)])
New = message([('id', 1, INT32), ('note', 2, STRING)])
Renamed = message([('event_id', 1, INT32)])
producer = New(id=7, note='retain me')
old_relay = Old.FromString(producer.SerializeToString())
assert New.FromString(old_relay.SerializeToString()).note == 'retain me'
# Known-field JSON serialization omits unknown binary fields.
relay_json = json_format.MessageToJson(old_relay)
through_json = json_format.Parse(relay_json, Old())
assert New.FromString(through_json.SerializeToString()).note == ''
print('binary relay keeps note; old-schema JSON relay drops it')
new_json = json_format.MessageToJson(producer)
try:
json_format.Parse(new_json, Old())
except json_format.ParseError:
pass
else:
raise AssertionError('unknown JSON field was accepted by the strict old parser')
ignored = json_format.Parse(new_json, Old(), ignore_unknown_fields=True)
assert ignored.id == 7
assert New.FromString(ignored.SerializeToString()).note == ''
print('strict old JSON parser rejects new field; permissive parsing discards it')
assert Renamed.FromString(Old(id=7).SerializeToString()).event_id == 7
try:
json_format.Parse(json_format.MessageToJson(Old(id=7)), Renamed())
except json_format.ParseError:
pass
else:
raise AssertionError('renamed JSON field unexpectedly accepted')
print('same-number rename reads binary but rejects the previous JSON name')
二进制转发保留 note,而同一个旧消息对象经过 JSON 后会丢掉它。用严格旧 schema 解析新版 JSON,会得到 ParseError;打开忽略未知字段后,已知编号能留下,但新说明仍然消失。最后,保持编号的改名能够继续读取二进制,却拒绝此前的 JSON 属性名称。这些结果分别验证保留、拒绝和省略,不能只把“程序没有报错”当成成功条件。
这些断言覆盖一个运行时中的特定 schema 版本,没有认证任意变更、gRPC 传输或所有语言的生成客户端。真正的发布门禁应换成实际 schema,并把必要语义值、依赖存在性的默认行为,以及生产环境每一种中间表示都加入同一张跨版本测试矩阵。只有转换路径也被验证,端点兼容才足以支撑发布判断。
来源
- Protobuf:proto3 语言指南,二进制安全新增与未知字段保留,已核验 2026-10-15。
- Protobuf:ProtoJSON,未知字段、属性名称与兼容性,已核验 2026-10-15。
- Protobuf:序列化不是规范形式,确定性输出的限制,已核验 2026-10-15。
- Protobuf:运行时版本保证,生成代码与运行时的受支持组合,已核验 2026-10-15。