服务化基石之远程通信系列五:序列化协议之二进制序列化

撸了今年阿里、腾讯和美团的面试,我有一个重要发现…….

二进制Java序列化

基于JSON或XML的文本序列化的方式简单清晰,且文本传输对于异构语言都天然的优势,只要各开发语言可以JSON或XML格式即可。但文本格式由于未经压缩,其内容所占据的空间较大,并且解析较慢,因此,对于性能要求高的互联网场景,二进制的序列化的方案更受青睐。对于由Java语言所搭建而成的同构系统,有很多仅针对Java语言的序列化方案。仅针对Java的二进制序列化方案可以很好的和Java语言本身结合,能够给开发工作带来很大的便利。


Java原生序列化

服务化基石之远程通信系列五:序列化协议之二进制序列化

Java提供了原生的序列化方式,非常简单易用。只要一个类实现了java.io.Serializable接口,那么它就可以被序列化。使用Java对象序列化保存对象,会将其状态转化为字节数组。当某个字段被声明为transient后,序列化机制会忽略该字段。另外,序列化保存的是对象的成员变量,即对象的状态。因此,对象序列化不会保存静态变量,因为它们是类的属性。


在上文的Netty介绍中,我们已经引入了序列化这个概念,使用的正是Java的原生序列化方案。


Java原生序列化使用serialVersionUID来控制兼容性。凡是实现Serializable接口的类都有一个标识序列化版本标识符的静态变量:

服务化基石之远程通信系列五:序列化协议之二进制序列化


如果不显示指定,它将由Java运行时环境根据类的内部细节自动生成的。修改源码再重新编译的话,类文件的serialVersionUID的取值可能会发生变化。


Java的序列化机制是通过在运行时判断类的serialVersionUID来验证版本是否一致的。反序列化时,JVM会将字节流中的serialVersionUID与相应类中的serialVersionUID比较,如果不同,则抛出序列化版本不一致的异常。


如果希望实现序列化接口的实体能够兼容之前的版本,可以显式指定serialVersionUID,以保证不同版本的类对序列化兼容。


虽然Java原生支持的序列化机制足够简单,但在性能方面,它简直可以用灾难来形容。由于Java原生的序列化后的字节大小过于臃肿,导致非常不利于在网络中的传输性能;并且它序列化与反序列化本身的性能也并不理想。因此在互联网这样对性能要求很高的场景,不会采用Java原生的序列化的方案,它仅仅适合于对性能要求不高的场景。


对于Java提供的RMI、EJB等原生组件,由于采用了其原生序列化的方式,导致吞吐量无法突破瓶颈,也逐渐被弃用。


高性能序列化框架Kryo

服务化基石之远程通信系列五:序列化协议之二进制序列化

由于Java原生的序列化方案性能无法满足互联网的需要,很多优秀的第三方高性能序列化框架层出不穷。它们在不同的场景性能可能略有波动起伏,但总体来说,高于Java原生的序列化方案十几倍的性能,是很容易达成的。


Kryo是一个高效的Java序列化框架。Kryo可以选择不将类的元信息序列化,因此,当一个类第一次被Kryo序列化时,它需要需要时间去加载该类。这虽然导致Kryo在其序列化工具的初始化时间较长,但这仅仅是一次性消耗。另外可以使用注册序列化类的方式将这样的开销放在应用程序启动时,用于避免不确定的第一次序列化时间。这样做的好处是使得序列化字节的容量大小明显降低,增加了字节信息网络传输的效率;并且由于类信息均已经在内存只加载,让其序列化和反序列化的性能也有所提升。使用Kryo无需再实现Serializable接口。


下面是使用Kryo序列化的核心代码:

服务化基石之远程通信系列五:序列化协议之二进制序列化

下面是使用Kryo反序列化的核心代码:

服务化基石之远程通信系列五:序列化协议之二进制序列化


使用Kryo必须有一个无参的构造器,否则程序将无法正确运行。如果不提供无参构造器,可以通过Kryo的setInstantiatorStrategy方法设置对象初始化策略为StdInstantiatorStrategy,该策略可以直接创建一个空对象。但如果构造函数中需要一些初始化操作,使用这种策略会破壳对象的完整性。因此最佳实践还是从一开始就考虑设计一个无参的构造器为妙。


Kryo有3种序列化方法。


1.   调用Kryo的writeObject方法。它只会序列化对象的实例,而不会记录对象所属类的元信息。它的优势是进一步的节省空间,劣势是需要提供该类作为反序列化的模板。上文的程序示例即采用此种方案。


2.   调用Kryo的writeClassAndObject方法。它将一并序列化对象数据信息和类的元信息。它的优势是整个程序的声明周期都无需再提供该类信息,劣势是空间占用大,网络间传输带宽消耗多。


3.   先调用Kryo的register方法注册需要序列化的类,再通过调用Kryo的writeClassAndObject方法序列化。Kryo通过对类的注册而绑定一个唯一的数字作为id,在writeClassAndObject时仅需要序列化id即可,无需序列化类的全部元信息。优势是在节省空间的同时也无需在反序列化时提供原始类的信息。劣势是对于通过Kyro写序列化通用框架的开发者并不友好,需要提供额外的接口提供使用方程序员注册相关类。


使用Kryo基本可以替代Java原生序列化的场景,并且性能提升很大。因此,在Java同构语言的序列化框架选择上,Kryo是一个理想的解决方案。


二进制Java序列化

之前讲述的序列化框架都是Java语言的,而完全由单一语言组成的现代系统已不多见。由于每种开发语言都有各自的优势和适用的场景,因此,一个复杂系统由异构语言组成是很常见的。

高性能异构语言序列化框架Protobuf

服务化基石之远程通信系列五:序列化协议之二进制序列化

Protobuf的全称是Protocol Buffers,是google开源的跨平台、跨语言的轻便高效的序列化协议。它是Google内部广泛使用的异构语言数据标准。它支持反序列化后的对象支持向前兼容。与同构语言的序列化方式不同,Protobuf使用预先定义完成的协议格式生成代码的方式。


使用Protobuf首先需要在系统上安装它的命令用于编译proto协议文件。


截止至本书写作时,最新的稳定版本是3.4.0,因此本书将以这个版本举例说明。我们介绍一下在Mac系统上如何安装protobuf,其他操作系统请自行查阅相关资料。请确保Mac系统安装了Homebrew,然后在命令行直接中输入“brew install protobuf”命令等待安装完成即可。


校验protobuf是否正确安装,只需在命令行中输入“protoc –version”,即可返回当前安装的protobuf版本号,brew命令会非常聪明的将Protobuf的环境变量自动设置完成。


Protobuf通过proto协议文件来定义程序中需要处理的结构化数据,结构化数据在Protobuf中的术语被称为消息(Message)。proto 协议文件以.proto结尾,它类似于 Java语言中数据对象的定义。一个消息类型由一个或多个字段组成,每个字段至少应该包括类型、名称和标识符。


标识符是一个正整数,每个标识符在该消息体中必须是唯一的。标识符是用于在转化为二进制的消息中识别各个字段,一旦开始使用则不允许更改。有一个压缩生成二进制消息大小的窍门,1-15的数字,在16进制中是0x1-0xF,仅占用一个字节;以此类推,16-2047会占用2个字节。因此,应尽量将频繁出现的消息字段保留在1-15标识符之内。另外,可以为将来可能出现的字段预留标识符。标识符的只增不删特性,是Protobuf的消息能够保持向后兼容的关键。


我们以一个简单的例子来开始:

服务化基石之远程通信系列五:序列化协议之二进制序列化


      这是一个标准的proto协议文件,我们来逐行说明一下:


1.   指明正在使用proto3语法。缺省使用proto2。Syntax语句必须是proto文件的空行和注释行之外的第一行。Protobuf 2.x与Protobuf 3.x的语法不完全兼容,相比之下,3.x的语法更加简明清晰。


2.   指明该文件编译为类之后的包名称是protobuf.pojo。


3.   定义消息类型,对应于Java即为类名称。该消息名称为ProtoPojo,消息体包含3个字段。


4.   定义名为id的属性,类型是32位的整数,标识符是1。


5.   定义名为name的属性,类型是字符串,标识符是2。


6.   定义名为messages的属性,类型是可重复的字符串,对应Java是一个List集合类型,标识符是3。


对于Protobuf的协议有了直观的了解之后,我们再系统的了解一下proto3所支持的消息类型。下表摘自Protobuf官方网站,展示了它所支持的所有消息类型。为了简单起见,我们仅将C++、Java、Python和Go这几种语言的相关类型展示出来,Protobuf支持的其他语言还包括Ruby、C#和PHP。

服务化基石之远程通信系列五:序列化协议之二进制序列化


Protobuf还可以使用枚举类型和嵌套使用其他消息类型,还可以使用import命令将其他文件中定义的消息类型导入至当前文件中使用。


Protobuf是一个向后兼容的协议,更新消息的结构而不破坏已有代码是非常简单的。在更新时需要满足以下规则:


1.   不能更改已有字段的数字标识符。


2.   使用旧代码产生的消息被新代码解析时,新增字段将被赋为默认值;使用新代码产生的消息被旧代码解析时,新增字段将被忽略。需要注意的是,未识别的字段会在反序列化时将被丢弃。


3.   非必填的字段可以删除,但必须保证它们的数字标识符在新的消息中不再被使用。


4.   int32, uint32, int64, uint64,和bool是全部兼容的,它们之间可以任意转换,而不会破坏其兼容性。需要注意的是,如果解析出来的数字与对应的类型不相符,将进行强制类型转换,这可能会导致精度的丢失。例如,将一个int64的数字当作int32来读取,那么它将会被截断为32位的数字。


5.   sint32与sint64相互兼容,但是与其他整数类型不兼容;string与有效的UTF-8编码的bytes相互兼容;fixed32与sfixed32相互兼容;fixed64与sfixed64相互兼容;枚举类型与int32,uint32,int64和uint64相兼容。


关于Protobuf协议的格式定义还有很多细节,更加详细的信息请阅览它的官方网址:https://developers.google.com/protocol-buffers/docs/proto3


在完成消息的定义之后,即可以通过Protobuf提供的命令行生成相关开发语言的代码。这里仍然以Java语言为例,在命令行中输入:“protoc –java_out=. ./Pojo.proto”,即可在当前路径生成相关的Java代码。命令行中的protoc即为Protobuf编译器的命令,它应该已随着Mac系统的brewhome配置至系统的环境变量;–java_out=.则是指定生成Java语言编译的类,位置是当前路径;./Pojo.proto则是目标的协议文件路径。命令执行之后即在生成的目标路径按照配置的包名生成好了相应的.java文件。更多的protoc命令的使用细节可以在命令行中输入:“protoc –help”来查看。


为了使生成的代码通过编译,需要在Maven的pom.xml文件中引用Protobuf的相应版本,在这里我们使用的是3.4.0版本,Maven坐标如下:


服务化基石之远程通信系列五:序列化协议之二进制序列化


下面我们看一下从.proto文件生成了什么。


对Java语言来说,编译器为每个.proto文件对应生成一个.java文件。这个Java文件的主类名称与.proto的文件名保持一致,并且为每一个消息类型定义一个消息对象的内部类以及一个用来创建消息的构建内部接口。每个消息类型的内部类中会再包含一个名为Builder的内部类用于实现消息构建接口。


值得注意的是,一个Java类中可以包含多个定义的消息类型。我们之前的例子为了简单起见,在协议中仅定义了一个名为ProtoPojo的消息,如果在同一个协议文件中定义了多个消息,那么每个消息类型将会被生成为一对消息内部类和消息构建内部接口。


下面是ProtoPojo构建接口的生成代码展示:

服务化基石之远程通信系列五:序列化协议之二进制序列化
服务化基石之远程通信系列五:序列化协议之二进制序列化


可以看到生成的ProtoPojoOrBuilder接口中包含了协议文件中定义的3个属性的getter方法。 相关属性的方法上保留着协议文件中原始定义的字符串以及相关注释。下面我们一一对应下协议文件中声明的属性和Java文件中生成的属性,为了清晰起见,我们将生成文件中的包名都去掉。


1.   协议文件中的int32 id = 1,对应的代码中仅生成了一个int getId()方法。因为int32类型的数据无需做复杂的序列化。


2.   协议文件中的string name = 2,对应的代码生成了两个方法,分别是String getName()和ByteString getNameBytes(),用于反序列化和序列化。com.google.protobuf.ByteString是序列化后的二进制数据格式。


3.   协议中的repeated string messages = 3,对应的代码生成了四个方法,分别是List< String> getMessagesList()、int getMessagesCount()、String getMessages(int index)和ByteString getMessagesBytes(int index)。由于是repeated类型,因此将messages映射为一个集合,并且提供了集合长度以及通过索引获取集合中元素的方法。


使用Protobuf API进行序列化和反序列化比较简单,序列化的方式主要是两个:


1.   byte[] toByteArray():这个方法可以将Java对象序列化为二进制字节数组,以便进行网络传递。


2.   void writeTo(OutputStream output):这个方法用于将Java对象直接序列化并写入一个输出流。


两个序列化的方法分别对应的两个反序列化方法,与序列化方法不同,反序列化方法都是类的静态方法:


1.   static T parseFrom(byte[] data):将二进制的字节数组反序列化为Java对象。其中返回值T借用了Java的泛型概念,用于表示其返回类型与调用它的类的类型一致。该方法是对应于byte[] toByteArray()的反序列化方式。


2.   static T parseFrom(InputStream input):通过一个输入流读取二进制字节数组并反序列化为Java对象。该方法是对应于void writeTo(OutputStream output) 的反序列化方式。


下面是使用Protobuf将Java对象序列化的核心代码:

服务化基石之远程通信系列五:序列化协议之二进制序列化


下面是使用Protobuf将Java对象反序列化的核心代码:

服务化基石之远程通信系列五:序列化协议之二进制序列化


小结

面对种类如此之多的序列化方案,如何选择合适的序列化框架呢?从调试的便利性以及协议的清晰度来说,基于文本的JSON协议是不错的选择;从性能方面考虑,文本协议比二进制协议差一些。二进制协议中,无论是Protobuf还是Kryo都是高效的,而Java原生的序列化方案则并不理想;在异构语言方面,本文协议全方位支持,二进制的协议中则只有类似于Protobuf这种静态代码生成方式可以支持,但在日常开发中却略显麻烦,因为它们即使在同构语言的交互中,也仍然需要根据协议文件静态生成代码。因此,使用何种序列化框架是需要综合考量的。我们通过下表的各类序列化框架的直观对比来结束本节的话题。


服务化基石之远程通信系列五:序列化协议之二进制序列化


以上内容节选自

《 Java云原生 新一代分布式中间件架构》

服务化基石之远程通信系列五:序列化协议之二进制序列化

 内容简介

【互联网架构不断演化,经历了从集中式架构到分布式架构,再到云原生架构的过程。云原生因能解决传统应用升级缓慢、架构臃肿、不能快速迭代等问题而成为未来云端应用的目标。本书首先介绍了架构演化及云原生的概念,让读者对基础概念有一个准确的了解。接着阐述容器调度、服务化、分布式等体系的原理,讲解分布式中间件设计方法。最后辅以实战,以中心化和平台化角度切入,深度揭秘两大开源项目Elastic-Job和Sharding-JDBC的实现】

服务化基石之远程通信系列五:序列化协议之二进制序列化

尽请期待

服务化基石之远程通信系列五:序列化协议之二进制序列化

《Java云原生 新一代分布式中间件架构》

2018 与您见面

书名尚未完全确定,欢迎您宝贵建议。

感谢大家关注“点亮架构”,欢迎对公众号文章的内容批评指正,如果有其他想要了解的技术问题,也可以留言提出。

‘点亮架构’的火炬,燃烧云原生‘

服务化基石之远程通信系列五:序列化协议之二进制序列化

点击下面


赞(0) 打赏

如未加特殊说明,此网站文章均为原创,转载必须注明出处。Java 技术驿站 » 服务化基石之远程通信系列五:序列化协议之二进制序列化
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

关注【Java 技术驿站】公众号,每天早上 8:10 为你推送一篇技术文章

扫描二维码关注我!


关注【Java 技术驿站】公众号 回复 “VIP”,获取 VIP 地址永久关闭弹出窗口

免费获取资源

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏