API 变更

Protobuf 破坏性变更:线上格式到底能保住什么

阅读约 1 分钟

REST API 会在 JSON 的形状变化时发生变化,而这个形状的大部分内容都能在浏览器里读到的响应中看 见。gRPC API 会在 .proto 文件变化时发生变化,而 Protocol Buffers 的二进制线上格式,对于 客户端能承受什么,有一套自己的规则,这套规则和字段名说了什么完全没有关系。两个在 diff 里看 起来同样微不足道的改动,重新编号一个字段和添加一个新字段,落在破坏性变更 通常划出的那条线的两侧:一个对每一个现有客户端都不可见,另一个会同时破坏它们全部。要把 protobuf 里真正的破坏性变更 和安全的改动区分开,靠的是去读线上格式自己的规则,而不是靠猜测这个改动在 .proto diff 里看 起来怎么样。

为什么在 Protobuf 里字段编号比字段名更重要

因为线上格式是按编号而不是按名字来编码字段的。每种语言生成的代码都读写这些编号;.proto 文件里 email 这个字段名只是给人看的便利,从来不会碰到在网络上传输的二进制字节。重命名一个 字段,把 email 改成 email_address,只要编号保持不变,在二进制线上格式里就是安全的,这会让习惯了 REST 的工程师感到惊讶,因为在 REST 里,重命名的 JSON 键恰恰就是那种会破坏客户端的改动。例外恰恰就是 REST 那种情况:ProtoJSON 和文本格式 会序列化字段名,所以重命名会破坏 JSON 转码(例如 grpc-gateway)、文本格式文件和字段掩码。给 同一个字段重新编号,保持名字不变但把 1 改成 7,则恰恰相反:在只显示名字的代码审查里 不可见,却会破坏客户端从那一刻起发送或接收的每一条消息。

改动在线上是否安全原因
重命名字段,保留编号二进制是,JSON 和文本否二进制编码使用的是编号;ProtoJSON 和文本格式使用的是名字
更改字段编号否每一条现有消息现在都会被读成另一个字段
用新编号添加新字段是旧客户端会忽略它们不认识的字段
删除一个字段,把它的旧编号重新用于别的东西否旧数据会被解码进错误的新字段
不兼容地更改字段类型(例如把 int32 改成 string)否不同类型的线上编码方式不同

为什么删除字段和在 REST JSON 响应里做同样的事不一样

因为编号会变得带有放射性。Protobuf 自己的指引建议把已删除字段的编号标记为 reserved, 而不是允许它被重新使用,因为真正的损害恰恰发生在重用那一刻:一个仍然运行着几个月前生成代码 的客户端,为一个旧值发送了旧的字段编号,而服务端现在期望那个编号表示别的东西,于是它没有直接 拒绝数据,而是悄悄地把数据解释错了。REST 没有与之对应的陷阱,因为被删除的 JSON 键只是不再 出现而已;没有任何方式能让旧客户端的请求被悄悄重新解释成别的东西。消息开头带有 reserved 4, 9, 12; 的 .proto 文件是一道永久的伤疤,而这正是它的意义所在:它阻止那个编号 被不了解其历史的人交给一个新字段。

message Invoice {
  reserved 4; // 曾经是 `legacy_customer_id`,于 2026-06-01 删除
  reserved "legacy_customer_id"; // 名字也保留,为了 JSON/文本格式
  string customer_id = 5;
  string status = 6;
}

添加字段本身需要一条体验日志条目吗

通常不需要作为破坏性变更的条目,但往往需要一条普通条目,因为”在线上安全”和”对在意的读者 可见”是两个不同的主张。给响应消息添加一个字段在结构上是免费的,旧客户端解码消息时会自动 忽略新字段。但如果没有人告诉她,正在针对这项服务构建新集成的人根本无从得知这个字段的存在, 因为无论是成功的构建还是通过的测试,都不会让一个新的可选字段变得可见。体验日志 API 一般性地讨论了增量条目对读者的义务;而 gRPC 特有的原因是,没有任何等价的东西能让人在调试器 里浏览 REST 响应时注意到一个新键的出现。

这和 GraphQL 调用方面对的情况有什么不同

关于新增字段的规则是一样的,但暴露面不同。GraphQL 模式弃用讨论了一种 模型,客户端只会收到自己明确请求的字段,这让增量变化本质上没有风险,只有删除才是真正的 危险。相比之下,gRPC 客户端会收到服务端发送的一切内容,并针对自己编译好的模式副本解码 全部内容;客户端的暴露程度不是由它请求了什么决定的,而是仅由它生成的代码能读懂什么决定的。 这个区别在写体验日志时很重要:GraphQL 的条目可以合理地假设客户端受到保护,不会受到它们没 请求的字段影响,而 gRPC 的条目完全不能做这种假设。

gRPC 服务的版本控制和 REST 的 /v1/、/v2/ 工作方式一样吗

意图相同,机制不同。REST API 中的 v1 和 v2 是什么 把版本控制当作提供不同契约的并行 URL 路径来处理;gRPC 服务通常是通过 .proto 文件本身内部 的包名来做版本控制的,payments.v1.InvoiceService 会变成 payments.v2.InvoiceService, 这改变的是客户端拨号的完全限定服务名,而不是它请求的 URL 段。两种方法解决的是同一个问题: 让新契约存在的同时,旧契约继续工作。但有着 REST 背景的团队常常在错误的地方寻找版本号, 从而错过了这项工作是由包声明来完成的这一点。

gRPC 的体验日志条目实际上应该点名什么

消息、字段编号,以及这个改动是增量的还是需要迁移的删除,这就是对决定是否要采取行动的读者来说 重要性的顺序。“在 Order 中添加了 shipping_address(字段 8)“告诉集成者更新生成代码并 开始使用它所需的一切。“保留了 Invoice 中的字段 4,legacy_customer_id 已消失”告诉她去 检查自己代码库里有没有什么东西还在读那个字段,这是 REST 风格的”从响应中删除了字段”这条注记 无法传达出的同等紧迫性,因为 REST 的删除只是返回更少的数据,而 Protobuf 的字段重用会主动 破坏数据。

FAQ

字段类型能否在不破坏线上格式的情况下被更改? 只能在 Protobuf 文档记录的特定兼容组内更改,比如在某些情况下把 int32 扩展成 int64。 除非你已经对照 Protobuf 自己的兼容性表核实过,否则把任何类型更改都当作破坏性变更来对待; 按语言类型系统的类比来假设兼容性,正是出问题的方式。

Protobuf 里的字段弃用工作方式和 GraphQL 的 @deprecated 指令一样吗? 类似。Protobuf 支持工具可以显示的字段选项 [deprecated = true]。两者都不是强制的: GraphQL 服务端仍然会响应对已弃用字段的查询,protobuf 客户端也仍然会对它进行编码。两者都只是 建议性的,都需要同样的体验日志支持。

如果你控制着每一个客户端,重新编号是否安全? 在一个完全封闭的系统里,原则上是安全的,但这会消除字段编号存在的整个安全属性,而”我们控制 着每一个客户端”这句话,会在构建被缓存、部署被延迟、或者添加了没人记得的客户端的那一刻起就 不再成立。即使在公司内部,也要保留编号而不是重新使用它。

gRPC 服务是否需要像公开的 REST API 一样有一个体验日志页面? 只有当外部团队消费它、而不是直接阅读 .proto 的 diff 时才需要,这和内部 API 体验 日志一般性适用的”对方是谁”这个测试是一样的。一个只被 同一团队的其他服务消费的 gRPC 服务,往往可以不用正式的体验日志,靠提交历史就够了,因为 任何阅读它的人本来就已经打开了那份模式定义。


本文的技术内容未经独立审核。如有错误,请告诉我们,我们会更正。

changeloop 相关页面: 开发者文档, changelog 工具对比

changeloop
打造闭环 changelog 的团队。用户提出需求,你的团队交付,提出需求的人得知结果。