了解最新技术文章
像大多数开发人员一样,我们在 TeamDev 第一次遇到 Protobuf,当时我们需要在不同平台之间传输数据。
我们对这项技术掌握得越好,除了简单的序列化之外,我们发现将其应用于我们的软件开发任务的方法就越多。
在这个周期中,我们总结了五年来使用 Protobuf 处理各种软件项目的经验:我们为客户所做的项目,以及我们为开发人员开发的产品:Spine Event Engine,这是一个用于构建基于域驱动设计的云应用程序的 CQRS/ES 框架,以及用于桌面应用程序的浏览器组件 - JxBrowser (Java) 和DotNetBrowser (.NET)。
对于本系列中的所有示例,我们都使用Protobuf v3语法。
我们的 Protobuf 故事始于一个 SaaS 项目。该系统是基于面向事件的,或者最近称为反应式领域驱动设计的。要显示的事件和数据作为 JSON 对象传输到 JavaScript 浏览器应用程序。然后客户决定添加 Android 和 iOS 客户端。当客户端应用程序的工作开始时,事件模型已经形成为 Java 类的层次结构。其中有很多。
所以我们面临以下问题:
如果需要更改或添加某些内容,我们如何在不同语言之间实现类型一致性?当有几十个事件类时,您宁愿不手动执行,因为很可能会出错。如果有一些工具来处理这个问题,那就太好了。
我们可以避免转换为 JSON 和向后转换吗?我们系统的负载适中,所以此时不是为了提高性能。但是,很直观,跳过转换为文本并向后转换会很好,因为它只是为了转移到另一个节点而发生。
放弃数据类型并仅对所有任务使用 JSON 不是一种选择。它将使用领域模型(业务的“心脏和大脑”)转换为字符串和原始类型的操作!我们开始寻找。
我们进入的第一个选择是 Square 的 Wire 库。当时它是 1.x 版本,支持 Protobuf v2,而 Protobuf v3 是 alpha-3 版本。The Wire 并没有解决我们对平台支持的所有问题,因为它仅适用于 Android 和 Java,但它让我们了解了 Protobuf 技术和应用代码生成。与其他人相比,Protobuf 看起来是最好的选择。
又出现了一个问题。在 Java 中,我们有一个用于事件和其他类型数据的类层次结构。由于它们中的大多数是在客户端和服务器之间传输的,因此我们一直在寻找用所有相关语言定义类似数据结构的方法。但是 Java 中的继承与 JavaScript 等语言中可用的继承方式不同。需要大量的手写代码来实现,然后为所有平台维护相同类型的层次结构。
Protobuf 看起来像是一个解决方案(对于当时的我们来说只是理论上的)。我们可以将继承调整到组合中,并为所有语言生成数据类型。但这也意味着我们必须从头开始重写应用程序的一部分。
我们提出了以下解决方案:
在 Protobuf 中描述命令、事件和所有其他数据类型,并为项目的所有语言生成代码。
由于 Protobuf仅描述数据,因此我们将在从 proto-types 生成的数据类之上将域实体实现为 Java 类。
将这些解决方案变为现实,我们:
Created Spine Event Engine——项目的框架,使用领域驱动设计方法开发,Protobuf 使处理数据变得更容易。它加快了开发速度并降低了软件的人工成本。
减少在我们的集成库JxBrowser和DotNetBrowser中手动编写的代码量,其中 Chromium 的 C++ 代码与 Java 和 C# 结合使用。
我们将在本系列的下一篇文章中详细讨论这些内容。同时,让我们看一下 Protobuf 的特性,这些特性使它对许多项目都很有用,无论它们的性质如何。
最新版本的 Protobuf支持C#、C++、Dart、Go、Java、JavaScript、Objective-C、Python、PHP、Ruby。Apple 自己有一个 Swift 实现。如果您需要一个用于 Closure、Erlang или Haskell 的工具,那么适用于不同语言的第三方库列表非常丰富。
正如文章的名字所暗示的,基于 Protobuf 的代码可以用于所有的数据操作,而不仅仅是序列化。这是我们推荐的方法。不过,序列化也值得讨论。这是一切通常开始的地方。
Protobuf 采用二进制序列化机制来有效地在节点之间传输数据,无需额外努力即可从不同语言进行写入和读取,并在不破坏兼容性的情况下引入格式更改。
假设我们有一个Task
定义如下的数据类型:
消息任务 {
字符串名称 = 1;
字符串描述 = 2;
}
然后在Java中,您可以通过这种方式以二进制形式获取此类对象:
字节[] 字节 = task.toByteArray();
或这个:
ByteString 字节 = task.toByteString();
该ByteString
库提供的类有助于转换 Java 字符串并使用ByteBuffer
、InputStream
和OutputStream
.
在 JavaScript 中,Task
可以使用以下serializeBinary()
方法将类型的对象转换为字节:
常量字节 = task.serializeBinary();
结果,我们得到了一个 8 位数字的数组。对于逆变换,deserializeBinary()
生成函数。它的调用方式类似于 Java 中的静态方法:
const task = myprotos.Task.deserializeBinary(bytes);
Dart 的情况与此类似。具有生成类的共同祖先的writeToBuffer()
方法返回一个无符号 8 位整数列表:
var bytes = task.writeToBuffer();
构造函数执行fromBuffer()
反向转换:
var task = Task.fromBuffer(bytes);
在现代 IDE 中编写代码可以更轻松地修改现有数据结构。例如,对于 Java,有许多类型的自动代码重构可用。然而,域类型的实例通常被持久化到一些存储中,然后一遍又一遍地读回。同样,对于 Java,需要付出相当大的努力——无论是时间还是成本——来构建细粒度的序列化机制以保持多年前的数据在今天仍然存在。
Protobuf 旨在确保序列化数据的向后兼容性。当需要更改现有数据类型时,它足够灵活。我们不会描述所有的可能性,而只关注最有趣的。
添加字段
这个很简单。您添加一个字段,更新后的类型将与旧值二进制兼容。新字段的存在对于新代码是透明的,但在序列化过程中会被考虑在内。
删除字段
这个应该处理得更细致。您可以简单地删除该字段,一切都会继续工作。但这样的骑兵冲锋是有风险的。
在下一次类型修改期间,有人可能会添加一个与最近删除的索引具有相同索引的字段。它会破坏二进制兼容性。如果将来有人添加具有相同名称但类型不同的字段,则会破坏与调用代码的兼容性。
Protobuf 的作者建议通过添加前缀或删除它们来重命名这些字段,并用指令OBSOLETE_
标记该字段的索引。reserved
为避免不愉快的意外,我们建议使用以下周期来处理删除:
步骤 1.deprecated
使用以下选项标记字段:
消息MyMessage {
...
int32 old_field = 6 [不推荐使用 = true ];
}
步骤 2.为更新的类型生成代码。
步骤 3.更新调用代码,摆脱@Deprecated
方法调用。
步骤 4.删除字段,将其索引和名称标记为reserved
:
消息MyMessage {
...
保留6;
保留“旧字段”;
}
通过这样做,我们可以确保自己不会意外使用旧索引或名称,并且不必保留过时的代码。重命名字段并不难。
重命名字段
序列化是基于字段索引,而不是字段名称,所以如果命名不好,重命名将意味着需要在所有节点上转换数据和升级软件。如果该字段被重命名,则更新的类型将是二进制兼容的。
这种重命名最好以相反的顺序进行。首先,您应该重命名与生成代码中的该字段一起使用的方法。反过来,这将更新从项目代码到这些方法的链接。只有在那之后,该字段才应该在原型中重命名。
假设我们的Task
类型有一个name
字段,我们希望将其命名title
为name
.
为了避免手动更正所有方法调用,我们应该执行以下顺序:
步骤 1.Task
将Java 中的类方法重命名getName()
为getTitle()
.
步骤 2.相应地重命名Task.Builder
方法。getName()
setName()
步骤 3.对项目的其他语言执行这些步骤。只有在这之后……
步骤 4.Task
重命名原型中的字段本身。
显然,相比于环境中的普通重命名,它的便利性较差。但值得注意的是:
我们几乎不花任何精力来生成代码。
重命名可能很多,但并不广泛。对领域语言给予应有的初步关注,可以减少此类问题的数量。
另请注意,如果您在 JSON 中使用序列化,最好不要重命名太多,因为将客户端更新到新版本需要额外的努力。
正如我们之前提到的,JSON 并不是交换数据的最有效方式。这种基于字符串的协议没有正式的模式格式,支持任何类型的 JSON 数据类型的技术差异很大。
在大多数情况下,当您需要序列化时,Protobuf 会做得很好。作为一个单独的序列化协议,它转换为简洁的二进制表示,所有支持 Protobuf 的平台都支持。
但是,在某些情况下,JSON 是不可替代的。例如,在调试开发服务器请求时,将请求和响应设置为可读格式会很方便。此外,一些数据库支持 JSON 结构,这有助于他们的用户避免在现有类型模型之上定义额外数据模式的繁琐工作。对于这种情况,Protobuf 支持 JSON 序列化。
在 Java 中
为了在 Java 中使用 JSON,我们使用库JsonFormat
中包含的实用程序类。protobuf-java-util
该类JsonFormat.Printer
负责输出。一个简单的案例如下所示:
var 打印机 = JsonFormat.printer();
尝试{
var json = printer.print(myMessage);
} catch (InvalidProtocolBufferException e) {
// 在 `Any` 字段中遇到未知类型。
}
从这个例子可以看出,该Printer.print()
方法可以抛出一个InvalidProtocolBufferException
.
发生这种情况时,打印的消息包含Any
类型字段,其中包含打印机的类型未知。
如果您不在Any
要转换为 JSON 的消息中使用,则无需执行任何操作。如果你确实使用了它,那么你应该为它配备Printer
一个类型的对象,并TypeRegistry
在其中添加你希望在其中使用的所有类型Any
:
var registry =
TypeRegistry.newBuilder()
.add(TypeA.getDescriptor()) .add(TypeB.getDescriptor()) .add
(TypeC.getDescriptor())
.build(
);
var 打印机 = JsonFormat.printer()
.usingTypeRegistry(registry);
默认情况下,Printer
输出易于阅读。但是,您可以创建一个以紧凑格式打印的版本:
var compactPrinter = printer.omittingInsignificantWhitespace();
JsonFormat.Parser
用于反向转换。它还需要TypeRegistry
了解Any
类型字段的内容:
var parser = JsonFormat.parser()
.usingTypeRegistry(typeRegistry);
var builder = MyMessage.newBuilder();
parser.merge(json, builder);
MyMessage 消息 = builder.build();
在 JavaScript 中
这种情况对 JavaScript 的吸引力较小。它的开箱即用实现不允许与 JSON 进行序列化。方法支持从和到“简单”对象的转换toObject()
。但在某些情况下,例如 for google.protobuf.Timestamp
、google.protobuf.Any
和其他内置类型, 的输出toObject()
将与 Java 库打印的输出不匹配。JavaScript 的 Protobuf 的用户只剩下扩展生成的 API,这是我们自己第一次遇到这个缺陷时所做的。
在飞镖
在构建客户端应用程序时,Dart 看起来像是 JavaScript 的一个可行的新替代方案。这就是为什么我们选择在 Spine 框架中支持它,并使用 Protobuf 为这种语言生成代码。
对于 Dart,转换为 JSON 与 Java 中使用的转换非常相似。
序列化:
var jsonStr = task.toProto3Json();
反序列化:
var任务 = 任务();
task.mergeFromProto3Json(jsonStr);
这两种方法都有一个TypeRegistry
类型的可选参数,需要处理google.protobuf.Any
. 我们创建一个TypeRegistry
,将空消息的示例传输给它:
var registry = TypeRegistry([Task(), TaskLabel(), User()]);
并将参数添加到方法中:
task.mergeFromProto3Json(jsonStr, typeRegistry: registry);
不可变类型使开发人员的生活变得更好。例如,看看这个关于不变性如何帮助构建出色的 UI 的演讲。
Java 中的 Protobuf 对象是不可变的。这很方便。在前一个的基础上新建一个对象也很方便:
任务更新 = task.toBuilder()
.setDescription(“...”) .build
();
但对于 JavaScript 和 Dart 来说,它并不是那么光明。