架构解密:从分布式到微服务(第2版)
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.4 网络传输中的对象序列化问题

仅仅懂了Socket编程还不够,因为我们不是简单地写一个发送字符串的Hello World程序,需要实现复杂的对象实例传输,因此,如何将一个对象实例编码成为高效的二进制数据报文传输到对端,并且正确地“还原”出来,就是一个专业的技术问题了。

对象序列化技术是Java本身的重要底层机制之一,因为Java一开始就是面向网络的,远程方法调用(RPC)是必不可少的,需要方便地将一个对象实例通过网络传输到远端。Java自身的序列化机制有两个大问题。

● 序列化的数据比较大,传输效率低。

● 其他语言无法识别和对接。

在后来相当长的一段时间内,基于XML格式编码的对象序列化机制盛行,它解决了多语言兼容的问题,同时比二进制的序列化方式更容易理解和排错,于是基于XML的SOAP和其上的Web Service框架几乎成为各个主流开发语言的必备扩展包,会不会熟练定义和开发Web Service接口,一度成为一个“高级技能”。后来,基于JSON的简单文本格式编码的HTTP REST接口又基本取代了复杂的Web Service接口,成为事实上的分布式架构中远程通信的首要选择。

JSON序列化存在占用空间大、性能低下等缺陷,随着多语言协作开发的互联网应用越来越普及,更多的移动客户端应用需要更高效地传输数据,以提升用户体验。在这种情况下,与语言无关的高效二进制编码协议就成为热点技术之一。

首先,诞生了一个知名开源二进制序列化框架——MessagePack,它的出现比Google的Protocol Buffers要早,是模仿JSON设计的一个高性能二进制的通用序列化框架,它有两大优势:序列化后空间占用最小,而且更快。如下所示是它的序列化机制原理示意图。

我们看到,在MessagePack中,数据类型被分为两大类:定长数据(整数、浮点数、布尔、空值等)与变长数据(字节数组、通用数组、集合类型的数据)。对于定长数据,只要在序列化时标明数据类型与对应的值即可;对于变长数据,则多了一个“长度”属性,用来表明数据的真实长度。下图是其数据类型(Type)的分类详情,我们看到,为了最大可能地节省存储空间,MessagePack把数值型又细分了很多种,不仅如此,连正数和负数都分开了。

对照上面的数据类型定义,我们就可以理解下图中一个有27个字节的JSON的Map是如何在MessagePack里用18个字节序列化的。

其次,除了MessagePack,Google开源的多语言支持的Protocol Buffers编码协议也是这方面的代表作品,其官方实现了C++、Python、Java三种语言的API接口,其他语言版本也相继由不同的作者实现。据统计,截至2010年,采用Protocol Buffers定义的报文格式就接近5万种,这些报文格式被大量用于RPC调用与持久化数据传输和存储系统中。

如果要做到语言中立及多语言支持,就不能用任何一种已有语言的语法来定义协议,只能用一种新的“中立”的第三方语言来描述协议。这种指导思想早在20世纪90年代的COBRA里就体现过了。当时,为了定义各种语言都能使用的RPC接口,COBRA设计了IDL接口定义文档及语言相关的编译器,将接口语言编译成相应语言定义的接口,并且配套复杂的多语言支持的数据序列化机制,从而描绘了一个大一统的、从未真正实现的“完美IT世界”。Protocol Buffers同样创建了一个后缀名为.proto的描述文件,用来定义一个数据对象的具体结构。下面是一个简单的例子:

在这个例子中定义了一个被称为helloworld的数据对象,也被称为“消息”,它有3个属性:一个32位整数的id、一个字符串类型的str变量,是必须赋值的属性;一个可选的32位整数变量opt。在定义好proto文件后,就可以将其编译成支持各种语言的接口代码了。如果有兴趣,则建议将其编译成自己熟悉的语言,分析隐藏在其背后的复杂的编码和解码细节,这有助于你加深对RPC实现机制的理解,因为高性能RPC的关键技术点之一就在于如何设计和实现一个高效的数据序列化机制。这里提示一点,与MessagePack类似,Protocol Buffers为了减少序列化后的存储空间,也使用了一些技巧,比如Varint是Protocol Buffers中的变长整数类型,用一个或多个字节来表示一个数字,数字的值越小,使用的字节数存储越少,可减少用来表示数字的字节。比如对于int32类型的数字,一般需要4个字节来表示。但是采用Varint后,对于很小的int32类型的数字,则可以用1个字节来表示。

在Protocol Buffers之后,Google又开源了一个新项目——Google FlatBuffers,在性能、序列化过程中内存占用的大小、第三方依赖库的数量、编译后生成的中间代码数量等方面都做了大幅改进。随后Cap'n公司发布声明,称Google这个FlatBuffers的设计实现很像该公司的Cap'n Proto,并且给出Cap'n Proto和Protocol Buffers的性能对比图,来证明Google FlatBuffers的确做了很多改变。

接下来说说Apache Avro(后简称Avro),它是一个开源项目,主要使用Java实现,支持多语言。它完全针对Protocol Buffers而来,是一种新的设计思路,其作者说:“这个世界上的每个问题都有几种解决思路”。

Avro原本是Hadoop中的一个子项目,用于实现RPC调用,Hadoop的其他项目中例如HBase和Hive的Client端与服务端的数据传输也采用了这个项目。Avro与Protocol Buffers的最大区别在于,它采用了预先定义的Schema(模式)来描述对象的序列化结构,从而无须编译。在使用Avro时必须先确定Schema,而Schema类似于表结构的定义,正是模式的引入,使得数据具有了自描述的功能,同时能够实现动态加载。另外,与其他数据序列化系统如Protocol Buffers相比,在数据之间不存在其他任何标识,有利于提高数据处理效率。

Avro的模式采用JSON来描述,下面的代码定义了一个名为User的对象及其属性:

Avro独有的Schema模式的设计,以及无须编译生成中间代码的做法,大大简化和加速了各种格式数据传输的开发联调工作。2014年,微软也发布了自己对Avro通信协议的实现,即.NET版本的语言实现,截至2020年2月,Avro已经有了C、C++、C#、Java、PHP、Python与Ruby等几个主流编程语言的实现版本。

本节最后讨论一下RPC中的数据序列化可能带来的风险问题,这个问题在Java RMI中比较明显,因为Java RMI采用了Java对象序列化机制在网络中传输数据。Java序列化就是把Java对象转换成字节流,以便将其保存在内存、文件、数据库中;反序列化是Java序列化对应的逆过程,即将字节流还原成对象本身。这看起来没什么问题,但是如果某个应用可以让用户输入一些数据,并且将这些输入的数据作为某个Java对象的属性通过Java序列化机制传输到服务器端,则攻击者可以通过构造“恶意输入”,让服务器端对应的反序列化程序产生“非预期的对象”,而这些非预期的对象有可能导致恶意代码的执行,与经典的SQL注入这样的安全漏洞在本质上是一样的。

对象序列化的安全漏洞问题并非Java所特有的,在PHP和Python中也有类似的问题。Java序列化安全问题的根源在于,ObjectInputStream在反序列化时没有对生成的对象的类型做限制!直到JDK 9才增加了一个filter机制来解决这个问题,后面才打补丁到JDK 6、7、8的特定版本上!反观ZeroC Ice、Thrift、ProtoBuf等传统RPC,它们通过IDL生成代码,并在IDL中严格控制数据类型,因而是安全的。这又印证了一个道理:代码实现越复杂,Bug越多!