导语:Protocol Buffers是一种广泛使用结构化数据存储格式,可以用于结构化数据的序列化/反序列化,也是很多rpc框架的基础之一,在Google内部大规模使用。本文作者根据自己多年研发经验对PB的很多坑提出了自己的看法。
在我的职业生涯中,我花了很多时间来讨论protobuffers的问题。PB显然是由业余选手写的临时作品,容易陷入困境且难于编译,当然也解决了Google的问题。如果protobuffers的这些问题在序列化抽象中隔离出来,那么我也不会继续抱怨。 但不幸的是,protobuffers的糟糕设计是如此有感染力,以至于这些问题也会渗透到你的代码中。
我曾经在Google工作过。 Google是我第一次使用protobuffers地方(很遗憾不是最后一个)。 我今天要讨论的所有问题都存在于Google的代码库中; 这不是“错误使用protobuffers”的问题。
protobuffers的最大问题是其可怕的类型系统。 Java的粉丝应该感觉宾至如归,但不幸的是,大家都不认为Java有一个设计良好的类型系统。使用动态语言的人抱怨这样的类型系统太令人窒息了,而像我这样的静态语言粉丝会抱怨这种设计没有给你真正想要的类型系统。
临时性和业余爱好者的建立是相辅相成的。大量protobuffer规范都是亡羊补牢的做法。 规范的许多限制会让你禁不住发问,为何使用PB如此困难。然而这都只是表象,真正的原因是:
Protobuffers显然是由业余爱好者建造的,因为它们为广为人知且已经解决的问题提供了不好的解决方案。
Protobuffers提供了几个“特性”,但多数无法相互配合。 例如,以下是几个正交但受约束的特性列表。
oneof字段不能repeated 。
map<k,v>字段具有专用语法,不能用于任何其他类型。
尽管可以对map字段进行参数化,但是不支持用户定义的类型。 这意味着需要手动处理很多工作。
map字段不能repeated 。
map键可以是string s,但不能是bytes 。 它们也不能enum ,即使enum在protobuffer规范中实际上是整数。
map值不能是其他map 。
这种疯狂的限制列表是无原则设计和事后打补丁的结果。 例如,oneof字段不能repeated是因为代码生成器不会产生副产品类型,而是为您提供互斥的可选字段的产品。 这种转换仅对单个字段有效。
map字段无法repeated也是相关的,但揭示了类型系统的不同限制。 原理上,map<k,v>应该类似于repeated Pair<k,v> 。 但是因为repeated是一个语言关键词,而不是一个独立的类型,它不能再次修饰自己,因此map字段无法使用repeated。
估计你对为什么enum不能用作map键,已经有了自己猜想。(译者注:没想到的或者想印证自己想法的,应该参加GIAC了)
令人沮丧的是,哪怕对现代类型系统如何工作有一点理解,就可以在大大简化 protobuffer规范的同时消除很多限制。
解决方案如下:
使所有字段都必须是required。这使得消息都是产品类型。
将oneof字段替换为独立数据类型。这提供副产品类型。
提供通过其他类型参数化产品和副产品类型的能力。
只需要这三个功能,你就能定义任何可能的数据类型。 我们可以根据它们重新实现其余的protobuffer规范。
例如,我们可以重建optional字段:
构建repeated字段也很简单:
当然,实际允许的序列化逻辑比通过网络推送链表更高明 - 毕竟, 实现和语义不需要一对一对齐
在Java的基础上,protobuffers区分了标量类型和消息类型。 标量类型或多或少与机器原语相对应 - 比如int32 , bool和string 。 另一方面,其他类型都是消息类型。 所有库和用户定义的类型都是消息类型。
当然,这两种类型的语义完全不同。
即使你没有设置它们,标量类型的字段也存在。 我提到过(至少在proto 3中 )所有protobuffers都可以零初始化。标量字段获取false-y值---例如, uint32初始化为0 , string初始化为"" 。将protobuffer中缺失的字段与默认值字段区分开来是不可能的。 这么做是为了优化默认值(减少传输的数据)。
protobuffers声称可以向后向和向前兼容,然而无法区分未设置值和默认值是一场噩梦。 如果确实是为了每个字段节约一位(有或没有)而做出如此设计,那么有点不值当。
相比之下, 虽然标量类型设计的不够好,但消息类型字段的行为就完全放飞自我了。消息类型字段无论是否存在,它们的行为都异常疯狂。 其访问代码值得细细剖析。 假设如下伪Java代码:
我们的想法是,如果未设置foo字段,则无论何时请求都会看到默认初始化的副本,但实际上不会修改其容器。 但是如果修改foo ,它也会修改它的父级! 所有这一切只是为了避免使用Maybe Foo类型和相关的细微差别,需要弄清楚未设置值意味着什么。
这种行为特别令人震惊,因为它破坏了规律! 我们期望赋值不会引起别的动作。 而PB将悄悄地更改msg以获得foo的零初始化副本。
与标量字段不同,我们至少可以检测消息字段是否未设置。 protobuffers提供了生成的bool has_foo()方法。如果想复制foo,则需要编写以下代码:
请注意,至少在静态类型语言中,由于方法foo() , set_foo()和has_foo()之间的命名关系, 我们无法抽象处理。 除了预处理器宏之外,我们无法以编程方式生成它们:
(但预处理器宏是由Google code style指南禁止的。)
如果所有可选字段都被实现为Maybe s,那么将很容易抽象处理这种情况。
让我们谈谈另一个有问题的决定。 虽然你可以在protobuffers中定义一个字段,但它们的语义不是副产品类型! 相反,对于每种情况你得到一个可选字段,以及setter中的魔术代码。如果设置了一个,它会将其他情况清除掉。
乍一看,这似乎应该在语义上等同于union类型。 但相反,它是bug之源! 这种行为允许默默地删除任意数量的数据! 在protobuffers上编写通用的,无错误的,多态的代码实际上是不可能的。
这不是任何人都喜欢听到的东西,更不用说我们这些已经爱上参数多态性的人 - 这给了我们完全相反的承诺。
protobuffers的另一个杀手特性是它们“编写向后兼容API的能力”。
protobuffers 默认情况下通过偷偷地执行错误操作来实现其兼容性。 当然,谨慎的程序员可以(并且应该)会对接收到的protobuffers消息进行检查。 但是你需要不停编写防御性检查代码以确保您的数据没有问题,也许这只是意味着反序列化步骤过于宽松。 您所能做的就是将健全性检查逻辑从定义良好的边界中分散开来,并将扩散整个代码库中。
另一个论点是,protobuffers将保留他们不理解的消息中存在的任何信息。 原则上,这意味着发送路由消息(不知道其schema版本)是非破坏性的。
当然,在纸面上它是一个很酷的功能。 但我从来没有见过一个真正保留该属性的应用程序。 除了路由软件之外,没有什么其他软件仅检查消息的某些位然后在未更改的情况下转发消息。 使用protobuffers的绝大多数程序将解码消息,将其转换为别的消息,并将其发送给其他程序。 这些变换是需要手动编码的。 从一个protobuffer到另一个protobuffer的手动编码转换不会保留两者之间的未知字段,因为它实际上毫无意义。
这种对待protobuffers的态度总是与其他丑陋的方式并举。protobuffers的风格指南积极倡导反DRY,并建议尽可能内联定义。 这背后的原因是,如果这些定义在将来发生分歧,它允许您单独修改消息。
这个问题的根源在于Google将数据的含义与其物理表示混为一谈。 当你处于谷歌规模时,这种事情可能是有道理的。 毕竟,他们有一个内部工具,允许您比较程序员时间与网络利用率背后(或者其他事情)的成本。 与大多数公司不同,工程师薪水是谷歌最小的开支之一。 从财务角度来说,浪费程序员的时间以减少几个字节是有道理的。
在排名前五的科技公司之外,我们都跟Google的规模差了好几个数量级。 你的创业公司不应该浪费工程师的时间来削减字节数。 但是削减字节并浪费程序员的时间正是protobuffers优化的原因。
面对现实吧。大部分公司永远达不到Google的规模。 对那些只是因为“谷歌使用它”,因此“它是行业最佳实践”的技术,我们应该停止搬到自己公司。
如果可以将protobuffer的使用限制在网络传输,我就不会如此为难。 不幸的是,虽然原则上有一些解决方案,但它们都不足以实际用于真实软件。
Protobuffers对应于您希望发送的数据,这通常与应用程序要使用的实际数据相关但不相同 。 这使我们处于一种令人不安的境地,需要在三种不良选择中选择一种:
维护一个描述您实际需要的数据的单独类型,并确保两者同步。
将数据打包成传输格式以供应用程序使用。
每次需要时都可以通过传输格式获取信息。
选项1显然是“正确的”解决方案,但它与protobuffers无法匹配。 该语言的功能不够强大,无法同时作为传输格式和应用程序数据格式。这意味着需要写一个完全独立的数据类型,与protobuffer同步,并在两者之间显式使用序列化代码来同步。而大部分人使用PB就是为了不写序列化代码,所以这种情况不会发生。
相反,使用protobuffers的代码会在整个代码库中扩散。 我在谷歌的参与的主要项目是一个编译器,它用各种各样的protobuffer作为输入,并在另一个程序中输出一个等价的“程序”。 输入和输出格式表达能力都足够,然而保持适当并行的C++版本永远不工作。代码无法利用我们为编写编译器而实现的任何丰富技术,因为protobuffer(以及由此产生的代码)过于僵化,无法做任何有趣的事情。
结果是,可能有50行递归代码就能实现的事情需要10,000行PB代码。 我想实现的功能因为PB的限制而无法实现。
虽然这是仅仅是一个例子,但它不是孤立的。 由于它们严格的代码生成,语言中的protobuffers的表现形式从来都不是惯用的方式。
但即使这样,你仍然需要将一个糟糕的类型系统嵌入到目标语言中。 因为大多数protobuffers的功能都是不完善的,这些令人讨厌的属性也会泄漏到我们的代码库中。 这意味着我们不仅要实现,而且还要在任何与之交互的项目中延续这些糟糕的想法。
在坚实的基础上实现无用的功能很容易,但走向反面则是是挑战。
简而言之,放弃将protobuffers引入项目吧。
原文链接:
http://reasonablypolymorphic.com/blog/protos-are-wrong/
相关阅读:
微博开源的Motan RPC最新进展:新增跨语言及服务治理支持
本文作者Sandy Maguire,由方圆翻译,转载本文请注明出处,技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。
活动预告:
11 月 23 ~ 24 日,GIAC 全球互联网架构大会将于上海举行。GIAC 是高可用架构技术社区推出的面向架构师、技术负责人及高端技术从业人员的技术架构大会。今年的 GIAC 已经有微软,腾讯、阿里巴巴、蚂蚁金服,华为,科大讯飞、新浪微博、京东、七牛、美团点评、饿了么,才云,格灵深瞳,Databricks,等公司专家出席。本周购买可享门票88折优惠,高可用架构会员低至6折。
本期 GIAC 大会上,微服务部分精彩的议题如下:
参加 GIAC,盘点2018最新技术。点击“阅读原文”了解大会更多详情。