Protobuffers错误

在我的职业生涯的大部分时间里,我都反对使用协议缓冲区。 它们显然是由业余爱好者编写的,具有很高的专业水平,遭受许多陷阱,难以编译和解决,只有Google真正拥有的问题。 如果原型缓冲区的这些问题仍然存在于序列化抽象的隔离中,那么我的主张就到此为止。 但不幸的是,糟糕的Protobuffers设计太过麻烦了,以至于这些问题可能会泄漏到您的代码中。

业余爱好者的狭窄专业化和发展

别这样 关闭您的电子邮件客户端,您已经在信中给我写了一封信,说“世界上最好的工程师在Google工作”,“根据定义,他们的设计不能由业余爱好者创建。” 我不想听。

让我们不讨论这个话题。 全面披露:我曾经在Google工作。 这是我曾经使用Protobuffers的第一个(不幸的是,不是最后一个)地方。 我想谈论的所有问题都存在于Google代码库中。 不只是“滥用原型缓冲区”之类。

到目前为止,Protobuffers的最大问题是可怕的类型系统。 Java爱好者应该在这里感到宾至如归,但是不幸的是,没有人认为Java是一种设计良好的类型系统。 来自动态打字训练营的家伙抱怨不必要的限制,而像我这样的静态打字训练营的代表抱怨不必要的限制以及您真正想要的打字系统所没有的一切。 在两种情况下都输了。

业余爱好者的狭窄专业化和发展是齐头并进的。 许多规范似乎在最后一刻被螺栓固定了-显然在最后一刻被螺栓固定了。 一些限制会迫使您停下来,挠头问:“这是什么鬼?” 但这只是一个更深层次问题的症状:

显然,protobuffers是由业余爱好者创建的,因为它们为解决众所周知的问题提供了糟糕的解决方案。

缺乏成分


Protobuffers提供了一些互不兼容的功能。 例如,查看正交列表,但同时我在文档中找到了有限的键入函数。

  • 字段之一不能repeated
  • 字段map<k,v>具有用于键和值的特殊语法,但未在其他任何类型中使用。
  • 尽管可以对map字段进行参数化,但不再允许用户定义类型。 这意味着您不得不手动在通用数据结构中指定自己的专业化知识。
  • map字段不能repeated
  • map可以string ,但不能是 bytes 。 虽然在Protobuffers规范的所有其他部分都认为后者等效于整数,但也禁止使用Enum。
  • map值不能是其他map

如此疯狂的限制清单是在最后时刻对设计和拧紧功能进行无原则选择的结果。 例如,一个字段不能repeated ,因为代码生成器将生成互斥的可选字段,而不是side类型。 这样的变换仅对奇异字段有效(并且,正如我们稍后将看到的,它甚至对它也不起作用)。

map字段的限制(不能repeated )近似于同一歌剧,但显示出类型系统的不同限制。 在后台, map<k,v>转换为类似于repeated Pair<k,v> 。 而且由于repeated是该语言的魔术关键字,而不是普通类型,因此它不会与自身结合。

您对enum问题的猜测与我的一样真实。

所有这些令人沮丧的是,人们对现代类型系统是如何工作的了解甚少。 这种理解将大大简化 Protobuffers 规范,同时消除所有任意限制

解决方法如下:

  • required消息中填写所有字段。 这使每个消息成为一种产品类型。
  • oneof字段的值提高为独立数据类型。 这将是副产品类型。
  • 使产品类型和其他类型的联产品的参数化成为可能。

仅此而已! 确定所有可能的数据只需完成这三个更改。 使用这个简单的系统,您可以重做所有其他Protobuffers规范。

例如,您可以重做optional字段:

 product Unit { // no fields } coproduct Optional<t> { t value = 0; Unit unset = 1; } 

创建repeated字段也很简单:

 coproduct List<t> { Unit empty = 0; Pair<t, List<t>> cons = 1; } 

当然,序列化的真正逻辑使您可以比通过网络推送链表更聪明地进行操作-毕竟, 实现和语义不必相互对应

可疑的选择


Java风格的原型缓冲区区分标量消息类型。 标量或多或少与机器原语相对应-例如int32boolstring 。 消息类型则全部剩下。 所有库和用户类型均为消息。

当然,这两种类型的语义完全不同。

标量类型的字段始终存在。 即使您没有安装它们。 我已经说过了(至少在proto3 1中 )是否将所有原型缓冲区初始化为零,即使它们绝对没有数据也是如此? 标量字段会得到伪值:例如, uint32初始化为0string初始化为""

无法将不在原型缓冲区中的字段与分配了默认值的字段区分开。 大概是为了优化而做出此决定,以便不转发标量默认值。 这只是一个假设,因为文档没有提到此优化,因此您的假设不会比我的糟糕。

当我们讨论Protobuffers关于向后和将来与API兼容的理想解决方案的主张时,我们将看到无法区分未定义值和默认值是一个真正的噩梦。 特别是如果确实有意识地决定为该字段节省一位(是否设置)。

将此行为与消息类型进行比较。 标量字段为“哑”,而消息字段的行为则完全是疯狂的 。 在内部,消息字段是否存在,但是行为很疯狂。 对于他们的访问者来说,一个小的伪代码值得一千个单词。 想象一下在Java或其他地方的情况:

 private Foo m_foo; public Foo foo { // only if `foo` is used as an expression get { if (m_foo != null) return m_foo; else return new Foo(); } // instead if `foo` is used as an lvalue mutable get { if (m_foo = null) m_foo = new Foo(); return m_foo; } } 

从理论上讲,如果未设置foo字段,则无论是否询问,您都会看到默认的初始化副本,但是您无法更改容器。 但是,如果您更改foo ,它也会更改其父级! 所有这些只是为了避免使用Maybe Foo类型及其关联的“头痛”来弄清楚未定义值的含义。

这种行为尤其令人震惊,因为它违反了法律! 我们期望工作msg.foo = msg.foo; 将无法正常工作。 相反,如果之前不存在,则实现实际上会通过零初始化将msg安静地更改为foo的副本。

与标量字段不同,至少您可以确定未设置消息字段。 bool has_foo()语言绑定提供类似于生成的bool has_foo()方法的功能。 如果存在,则在将消息字段从一个原型缓冲区频繁复制到另一个原型缓冲区的情况下,必须编写以下代码:

 if (src.has_foo(src)) { dst.set_foo(src.foo()); } 

请注意,至少在具有静态类型的语言中,由于foo()set_foo()has_foo()之间的名义关系, 因此无法抽象该模板。 由于所有这些函数都是它们自己的标识符 ,因此除预处理器宏之外,我们没有方法以编程方式生成它们:

 #define COPY_IFF_SET(src, dst, field) \ if (src.has_##field(src)) { \ dst.set_##field(src.field()); \ } 

(但是Google样式指南禁止使用预处理器宏)。

相反,如果所有其他字段都实现为Maybe ,则可以安全地设置抽象的拨号对等体。

为了改变话题,让我们谈谈另一个可疑的决定。 尽管您可以在oneof缓冲区中定义一个字段,但是它们的语义副产品类型不匹配 ! 新手错误的家伙! 取而代之的是,您会在setter中的case和magic代码的每个字段中获得一个可选字段,如果设置了该字段,它将仅撤销其他任何字段。

乍看起来,这在语义上应该等效于正确的联合类型。 但是,相反,我们得到了令人作呕的,难以形容的错误源! 当此行为与非法实现结合在一起时, msg.foo = msg.foo; ,这种看似正常的分配会静默删除任意数量的数据!

结果,这意味着一个字段不会形成守法的Prism ,并且消息也不会形成守法的Lens 。 尝试编写没有错误的简单protobuffer操作祝您好运。 在原型缓冲区上编写通用,无错误的多态代码实际上是不可能的

这听起来并不令人愉快,尤其是对于那些热爱参数多态性的人来说,这恰恰相反

向后和将来的兼容性在于


Protobuffers经常被提及的“杀手级功能”之一是它们的“无故障编写向前和向后兼容的API的能力”。 这句话悬在您的眼前,以掩盖真相。

原始缓冲区是允许的 。 他们设法应对过去或将来的消息,因为他们对您的数据外观完全不做任何保证。 一切都是可选的! 但是,如果您需要它,Protobuffers会很乐意为类型检查做准备并为您提供一些东西,无论它是否有意义。

这意味着Protobuffers会执行承诺的“时间旅行”,同时默认情况下悄悄地做错事 。 当然,谨慎的程序员可以(并且应该)编写代码来检查接收到的原型缓冲区的正确性。 但是,如果您在每个站点上写保护性正确性检查,则可能只是意味着反序列化步骤太宽松了。 您要做的就是从明确定义的边界中分散验证逻辑,并在整个代码库中模糊它。

可能的论据之一是原型缓冲区将保存消息中不了解的所有信息。 原则上,这意味着通过不了解此方案版本的中间人进行无损消息传输。 这是明显的胜利,不是吗?

当然,在纸上这是一个很酷的功能。 但是我从未见过真正存储此属性的应用程序。 除路由软件外,没有程序希望仅检查消息的某些位然后将其保持不变。 原型缓冲区上的绝大多数程序都将解码该消息,将其转换为另一个消息并将其发送到另一个地方。 ,,这些转换是按顺序进行的,并经过手动编码。 从一个原型缓冲区到另一个原型缓冲区的手动转换不会保留未知字段,因为它实际上是毫无意义的。

这种对原型缓冲区普遍兼容的态度也以其他丑陋的方式表现出来。 Protobuffers的样式指南积极反对DRY,并建议尽可能在代码中嵌入定义。 他们认为,如果定义不同,将来将允许使用单独的消息。 我强调,他们提供了放弃60年良好编程实践的机会,以防万一 ,在将来的某个时候,您需要进行一些更改。

问题的根源在于Google将数据的含义与其物理表示形式相结合。 当您使用Google规模时,这很有意义。 最后,他们有一个内部工具,可以比较程序员使用网络的时薪,存储X字节的成本和其他因素。 与大多数科技公司不同,程序员的薪水是Google支出最小的项目之一。 从财务上来说,让他们花时间程序员节省几个字节是有意义的。

除了五家领先的科技公司之外,没有其他一家公司位于Google的五个数量级之内。 您的启动公司无力花费工程时间来节省字节。 但是节省字节并浪费程序员时间是Protobuffers最优化的目标。

让我们面对现实吧。 您不适合Google的规模,也永远不适合。 仅仅因为“谷歌使用它”以及“这些都是行业最佳实践”就停止使用该技术的货运方式。

Protobuffers污染了代码库


如果有可能将Protobuffers的使用限制为仅在网络上使用,那么我就不会对这种技术说得那么苛刻。 不幸的是,尽管原则上有几种解决方案,但是它们都不足以在实际软件中实际使用。

原始缓冲区对应于您要通过通信通道发送的数据。 它们通常与应用程序要使用的实际数据一致 ,但不完全相同 。 这使我们处于不舒服的位置,您必须在以下三个错误选项之一中进行选择:

  1. 维护一个描述您真正需要的数据的单独类型,并确保同时支持这两种类型。
  2. 将完整数据打包为一种格式,以供应用程序传输和使用。
  3. 每次需要从短格式进行传输时检索完整的数据。

选项1显然是“正确”的解决方案,但不适用于Protobuffers。 该语言功能不足,无法编码可以两种格式双重工作的类型。 这意味着您必须编写一个完全独立的数据类型,与Protobuffers同步开发,并专门为其编写序列化代码 。 但是由于大多数人似乎都使用Protobuffers来不编写序列化代码,因此显然从未实现此选项。

相反,使用原型缓冲区的代码允许它们分布在整个代码库中。 这是现实。 我在Google的主要项目是一个编译器,该编译器采用了用Protobuffers的一个变体编写的“程序”,并在另一个变体上产生了等效的“程序”。 输入和输出格式完全不同,因此它们正确的C ++并行版本永远无法使用。 结果,我的代码无法使用任何丰富的编译器编写技术,因为Protobuffers数据(以及生成的代码)太难了,无法对其进行任何有趣的操作。

结果,代替了50行递归方案 ,使用了10,000行特殊缓冲区改组。 我想编写的代码实际上没有原型缓冲区。

尽管这是一种情况,但并非唯一。 由于代码生成的苛刻性质,语言中原型缓冲区的表现将永远不会是惯用的,而且除非是重写代码生成器,否则它们不会成为惯用语言。

但是即使那样,在目标语言中嵌入a脚的类型系统仍然存在问题。 由于Protobuffers的大多数功能都经过深思熟虑,因此这些可疑属性会泄漏到我们的代码库中。 这意味着我们不仅必须实施,而且还要在希望与Protobuffers进行交互的任何项目中使用这些坏主意。

在坚实的基础上,很容易实现毫无意义的事情,但是如果您朝另一个方向前进,充其量只会遇到困难,最糟糕的是会遇到真正的古代恐怖。

通常,对在项目中实现Protobuffers的所有人都寄予希望。



1.迄今为止,在Google上围绕proto2以及是否应将字段标记为required进行了激烈的讨论。 清单“ optional被认为是有害的” required认为是有害的”同时分发。 祝大家好运。

Source: https://habr.com/ru/post/zh-CN427265/


All Articles