Protobuffers的设计缺陷与替代方案思考
深入分析Protobuffers在类型系统设计、向后兼容性承诺以及代码污染方面的根本性问题,探讨现代数据序列化协议的更好选择
Protobuffers的设计缺陷与替代方案思考
在当今的分布式系统和微服务架构中,数据序列化协议的选择至关重要。Google的Protocol Buffers(简称protobuf)长期以来被视为行业标准,但近期一篇题为《Protobuffers Are Wrong》的文章引发了深刻的技术反思。作为曾在Google工作过的工程师,作者从内部视角揭示了protobuf在设计上的根本性缺陷。
类型系统的业余设计
缺乏组合性
Protobuf的类型系统设计充满了临时性和非正交的特性组合限制:
oneof
字段不能是repeated
map<k,v>
字段有专门的键值语法,但不适用于其他类型- 用户定义的类型无法参数化,导致需要手动实现通用数据结构的特化版本
map
字段不能是repeated
map
键可以是string
但不能是bytes
,也不能是enum
map
值不能是其他map
这些限制源于无原则的设计选择和事后补丁式的功能添加。如果采用现代类型系统的理念,只需要三个核心特性就能构建完整的类型表达能力:
- 所有字段都是
required
(积类型) oneof
提升为独立的数据类型(和类型)- 支持积类型和和类型的参数化
可疑的设计选择
Protobuf在标量类型和消息类型之间做出了令人困惑的语义区分:
- 标量字段总是存在,即使未设置也会获得默认值
- 无法区分缺失字段和设置为默认值的字段
- 消息字段的行为更加疯狂:获取时返回默认初始化副本,修改时却会影响父容器
这种行为破坏了基本的编程定律:msg.foo = msg.foo
这样的赋值操作在某些情况下会静默地改变消息状态。
向后兼容性的谎言
Protobuf号称提供"无忧的向后和向前兼容API",但实际上这只是通过默认执行错误操作来实现的宽容性。它通过不对数据做出任何承诺来避免在接收过去或未来消息时出现问题。
这种方法的问题在于,它将健全性检查的责任从明确定义的边界分散到整个代码库中。谨慎的程序员确实可以(也应该)编写对接收到的protobuf进行健全性检查的代码,但如果每个使用站点都需要编写防御性检查来确保数据合理,那么反序列化步骤可能就过于宽容了。
代码污染问题
Protobuf最严重的问题是其对代码库的污染效应。理论上,应该将protobuf使用限制在网络边界,但实际上很少有解决方案能够在真实软件中良好工作。
应用程序需要的数据与要通过网络发送的数据通常是相关的,但不完全相同。这迫使我们在三个糟糕的替代方案中选择:
- 维护一个描述实际所需数据的单独类型,并确保两者同步演进
- 将丰富数据打包到线格式中以供应用程序使用
- 每次需要时从简洁的线格式中推导丰富信息
由于protobuf的语言表达能力不足,无法编码可以同时充当线和应用程序格式的类型,选项1在实践中难以实施。
现代替代方案
Cap'n Proto
Cap'n Proto是protobuf的一个有趣替代品,它采用零拷贝设计,避免了序列化/反序列化的开销。其类型系统更加一致,支持更好的组合性。
FlatBuffers
同样来自Google的FlatBuffers提供了类似的零拷贝优势,但在类型系统设计上更加现代化。
基于Schema的现代序列化
对于大多数应用场景,基于JSON Schema或Avro的解决方案可能更加合适。这些方案提供了更好的工具支持和更丰富的生态系统。
自定义类型转换层
最稳健的方法是在应用程序边界维护一个丰富的领域模型,并在网络边界进行明确的类型转换。虽然这需要更多的前期工作,但长期来看提供了更好的可维护性和类型安全性。
结论
Protobuf的设计反映了Google特定规模下的工程权衡——在程序员时间与网络利用率之间的权衡。但对于绝大多数公司来说,这种权衡并不适用。
我们应该停止仅仅因为"Google使用它"就将其视为"行业最佳实践"的技术崇拜。在选择数据序列化方案时,需要根据实际需求评估各种选项,而不是盲目跟随大公司的技术选择。
现代软件开发应该追求类型安全、表达力和可维护性,而不是过度优化那些在大多数应用场景中无关紧要的字节节省。