简介
序列化和反序列化几乎是工程师们每天都要面对的事情.然而它的出现往往隐秘而含蓄:或者,由于每天都在接触,我们已经忘记了它的存在;或者,它往往作为一个解决方案的一部分出现,我们没有注意到它的存在.然而,当我们考虑一个新分布式系统的设计或者对其做重构的的时候,序列化和反序列化是一个不可回避的问题.恰当的序列化协议不仅可以提高系统的通用性,可靠性,安全性,优化系统性能,而且会让系统更加易于调试,便于扩展.
本文首先从定义着手,讲述了序列化和反序列化在通讯协议中所处的位置;接着从使用者的角度去探讨序列化协议的一些特性;随后描述在具体的实施过程中典型的序列化组件;基于以上三个部分的介绍,文章详细讲解了目前常见的几种序列化协议的特性,应用场景并给出了相关组件的举例;最后,基于本文主要介绍的xml,json,thrift,protobuf和avro序列化协议, 给出了技术选型建议.
定义
互联网的产生带来了机器间通讯的需求,而互联通讯的双方需要采用约定的协议.目前互联网的主流通讯协议采用的是TCP/IP协议.TCP/IP是一个五层协议,对于传输层以上的协议,全部被包含在应用层协议里面,所以序列化协议属于TCP/IP协议中应用层协议的一部分.就理论模型而言,最广泛接受的互联网通讯协议是OSI 7层协议模型.在这个模型中展现层(Presentation Layer)的主要功能就是把应用层的对象转换成一段连续的二进制串,或者反过来,把二进制串转换成应用层的对象.展现层协议的这两个功能就是序列化和反序列化.
- 序列化: 将数据结构或对象转换成二进制串的过程
- 反序列化:将在序列化过程中所生成的二进制串转换成数据结构或者对象的过程
数据结构 对象 二进制串
对于应用层使用者来说,在不同的计算机语言中,数据结构,对象以及二进制串的表示方式并不相同.
就数据结构和对象而言.对于类似java这种完全面向对象的语言,工程师所操作的一切都是对象(object),来自于类的实例化.如果非要在java中找出最类似于数据结构的概念,那就是pojo(Plain Old Java Object)或者javabean--那些只有set/get方法的类. 而对于c++这种半面向对象的语言,数据结构和struct对应,对象和class对应.
序列化之后的二进制串实际上指的是保存在内存中的一块数据. 在c++语言中,二进制串的概念就比较容易理解,因为c++语言的操作符可以直接操作内存.例如,C++的string本质上就是以“/0"结尾的保存在内存中的二进制串,所以如果某种序列化的结果转换成了string类型的数据,它可以直接被传输层协议使用;实际上所有的c++对象都是内存中的一块二进制串.在java语言里面,二进制串的概念容易和String混淆.实际上String 是java里面的一等公民,是一种特殊对象(object). 对于跨机器间的通讯,序列化后的数据当然不能是某种语言的特殊数据类型. 二进制串在java里面所指的是byte[].byte是java的8中原生数据类型之一(primitive data types).
序列化方法特性
每种序列化协议都有自己的优点和缺点,它们在设计之初有自己独特的应用场景.在设计一个分布式系统的过程中,需要考虑序列化需求的方方面面,综合考虑各种序列化方法的特性,最终给出一个折衷的方案.
通用性
通用性有两个层面的意义:
第一,技术层面,序列化协议是否支持跨平台,跨语言.如果不支持,在技术面上的通用性就大大缩小了.
第二,流行程度,序列化和反序列化需要多方参与,很少人使用的协议往往意味着昂贵的学习成本. 另一方面,流行度低的协议,往往缺乏稳定而成熟的跨语言,跨平台的公共包.
可靠性
一个协议从制定到实施,到最后成熟往往是一个漫长的阶段. 对于致力于提供高质量服务的系统,采用处于测试阶段的序列化协议会带来很高的风险.
可调试性
序列化将应用层的对象转化成二进制串,然后再传给下层协议.从应用层发送方到应用层接收方,按照标准的OSI七层协议模型,中间一共要经历12层协议的转化.如果没有良好的调试机制,开发效率会大大降低. 一个常用的调试模式是:写入方提供一个查询服务平台,使用者通过输入条件查看序列化之后的数据反序列化后的结果. 但是在实际操作中有三个问题比较难以解决:
第一,时间先后问题,查询服务平台往往滞后于调试时间点.
第二,习惯问题,由于查询服务和写入方为同一个团队,有些问题可能不会暴露,但确实存在,这令反序列化反很费解.
第三,访问限制,参与多方可能来自不同的公司,查询服务平台未必可用.
如果序列化后的数据人眼可读,这将大大提高调试效率. xml和json就具有人眼可读的优点.
性能
性能是分布式系统设计需要考虑的一个重要方面.性能包括两个方面,时间复杂度和空间复杂度:
第一,序列化大小(verbosity), 序列化需要在原有的数据上加上描述字段,以为反序列化解析之用.如果序列化过程引入的overhead过高,可能会导致网络,磁盘等各方面的压力.对于海量分布式存储系统,数据量往往以TB为单位,1倍的overhead可能意味着几十万甚至更高的成本.
第二,序列化时间(complexity), 复杂的序列化协议会导致较长的解析时间,对于对性能要求比较高的实时系统,序列化所带来的时间延迟是需要考虑的.
可扩展性/兼容性
移动互联时代,业务系统需求的更新周期可能是小时级别的,新的需求不断涌现,而老的系统还是需要继续维护.如果序列化协议能支持自动增加新的业务字段,而不影响老的服务,这显然是一个重要的优点.
安全性
与可调试性相反,有时候序列化的目的是为了不可见,所以加密解密从某种角度上讲也是一种序列化和反序列化. 另外,当涉及到跨防火墙访问的时候,开放端口号往往会是选择序列化协议的一个重要决定因素.
序列化和反序列化的组件
典型的序列化和反序列化过程往往包含如下几个重要组件:
- IDL(Interface description language)文件:参与通讯的各方需要对通讯的内容需要做相关的约定(specifications).为了建立一个与语言和平台无关的约定,这个约定需要采用使用语言平台无关的语言来进行描述.而这种语言一般被称为接口描述语言(IDL),采用IDL撰写的协议约定称之为IDL文件.
- IDL Compiler:IDL文件中约定的内容为了在各语言和平台可见,需要有一个编译器,将IDL文件转换成各语言对应的动态库.
- Stub/Skeleton lib:负责序列化和反序列化的工作代码.早期的rpc框架中,基于内存的考虑,client stub和server skeleton往往是分开的两个动态库文件. 新的框架中往往只产生一个动态库文件.
- client/server:指的是程序的应用层,他们面对的往往是IDL所生存的特定语言的class或struct.
- 底层协议栈和互联网:序列化之后的数据将会通过网络层,链路层以及物理层协议转换成数字信号在互联网中传递.
序列化组件与数据库访问组件的对比
数据库访问对于很多工程师来说相对熟悉,所用到的组件也相对容易理解.下表对比了序列化过程中用到的部分组件和数据库访问组件的对应关系,以便于大家更好的把握序列化相关组件的概念.
序列化组件 | 数据库组件 | 说明 |
---|---|---|
IDL | DDL | 用于建表或者模型的语言 |
DL file | db schema | 表创建文件或模型文件 |
Stub/Skeleton lib | OR-mapping | 将class和table或者数据模型进行映射 |
几种常见的序列化和反序列化协议
互联网早期的序列化方法主要有COM, CORBA.
COM主要用于windows平台,并没有真正实现跨平台,另外COM的序列化的原理利用了编译器中虚表,使得其学习成本巨大(想一下这个场景, 工程师需要是简单的序列化方法,但却要先掌握语言编译器).由于序列化的数据与编译器紧耦合,扩展属性非常麻烦.
CORBA是早期比较好的实现了跨平台,跨语言的序列化方法.COBRA的主要问题是参与方过多带来了版本过多,版本之间兼容性较差,以及使用复杂晦涩.这些政治经济,技术实现以及早期设计不合理的问题,最终导致corba的渐渐消亡.J2SE1.3之后的版本提供了基于CORBA协议的rmi-iiop技术,这使得java开发者可以采用纯粹的java语言进行CORBA的开发.
这里主要介绍和对比几种当下比较流行的序列化方法,包括xml,json,protobuf,thrift和avro.
一个例子
如前所述,序列化和反序列化的出现往往晦涩而隐蔽,它与其他概念之间往往相互包容,为了更好了让大家理解序列化和反序列化的各个概念在每种协议里面的具体实现.我将一个例子参插在各种序列化方法讲解中.在我们的例子中,我们的需求是要将一个用户信息在多个系统里面进行传递.在应用层,如果采用java语言,所面对的类对象如下所示:
class Address { private String city; private String postcode; private String street; } public class UserInfo { private Integer userid; private String name; private List<Address> address; }
xml&soap
xml是一种常用的序列化和反序列化协议,具有跨机器,跨语言等优点. xml历史悠久,其1.0版本早在1998年就形成标准,并被广泛使用至今.xml的最初产生目标是对互联网文档(document)进行标记,所以它的设计理念中就包含了包括机器和人可读. 但是,当这种标记文档的设计被用来序列化对象的时候,显得有点的冗长而复杂(verbose and complex). xml本质上一种描述语言,并且具有self-describing的属性,所以xml自身就被用于xml序列化的IDL. 标准的xml描述格式有两种:DTD(Document Type Definition) 和 XSD (XML Schema Definition.作为一种human-readable描述语言,xml被广泛使用在大量配置文件,例如OR-mapping, spring bean configuration file 等.
SOAP(simple object access protocol) 是一种被广泛应用的,基于xml为序列化和反序列化协议的消息传递框架.SOAP在互联网影响如此巨大,以至于我们给基于SOAP的解决方案一个特殊的名字Webservice. SOAP虽然可以支持多种传输层协议,不过SOAP最常见的使用方式还是 xml + http. Soap协议的主要接口描述语言(IDL)是wsdl(web service description language) . SOAP具有安全,可扩展,跨语言,跨平台并支持多种传输层协议.如果不考虑跨平台和跨语言的需求,xml的在某些语言里面具有非常简单易用的序列化使用方法,无需IDL file和第三方编译器. 例如java+XStream.
自我描述与递归
SOAP是一种采用xml进行序列化和反序列化的协议,它的IDL是WSDL和UDDI. 而WSDL和UDDI的描述文件是xsd,而xsd自身是一种xml文件. 这里产生了一种有趣的在数学上称之为“递归”的问题,这种现象往往发生在一些具有self-description属性的事物上.
IDL文件举例
采用wsdl描述上述用户基本信息的例子如下:
<xsd:complexType name='Address'> <xsd:attribute name='city' type='xsd:string' /> <xsd:attribute name='postcode' type='xsd:string' /> <xsd:attribute name='street' type='xsd:string' /> </xsd:complexType> <xsd:complexType name='UserInfo'> <xsd:sequence> <xsd:element name='address' type='tns:Address'/> <xsd:element name='address1' type='tns:Address'/> </xsd:sequence> <xsd:attribute name='userid' type='xsd:int' /> <xsd:attribute name='name' type='xsd:string' /> </xsd:complexType>
典型应用场景和非应用场景
Soap协议具有广泛的群众基础,基于http的传输协议使得其在进行防火墙穿越时具有良好安全特性,xml所具有的human-readable特性使得其具有出众的调试性能,互联网带宽的日益剧增也大大弥补了其verbosity的缺点. 对于在公司之间传输数据量相对小,实时性要求相对低(例如秒级别)的服务是一个好的选择.
由于xml的verbosity属性,序列化之后的数据量剧增,对于数据量巨大序列持久化应用常景,这意味着巨大的内存和磁盘开销,不太适合xml.另外,xml的verbosity和complexity导致其序列化和反序列化时间较长,对于对性能要求在ms级别的服务,不推荐使用.WSDL虽然具备了描述对象的能力,soap的s代表的也是simple,但是soap的使用绝对不简单,对于习惯于面向对象编程的用户,wsdl文件不直观.
json(javascript object notation)
json起源于弱类型语言Javascript, 它的产生来自于一种称之为“associative array”的概念, 其本质是就是采用“attribute-value”的方式来描述对象.实际上在javascript和php等弱类型语言中,类的描述方式就是“associative array”.json的如下优点,使得它快速成为最广泛使用的序列化协议:
1、 这种associative array格式非常符合工程师对对象的理解
2、它保持了xml的human-readable的优点
3、相对于xml而言,序列化后的数据更加简洁. 来自于的以下链接的研究表明:xml所产生序列化之后文件的大小接近json的两倍. http://www.codeproject.com/Articles/604720/JSON-vs-XML-Some-hard-numbers-about-verbosity
4、它天生具有javascript的支持,所以被广泛应用于web-browser的应用常景中,是ajax的事实标准协议
5、与xml相比,其协议比较简单,使得其解析性能比较快.
6、松散的associative array使得其具有良好的可扩展性和兼容性.
IDL悖论
json实在是太简单了,或者说太像各种语言里面的类了,所以采用json进行序列化不需要IDL.这实在是太奇怪了,存在一种天然的序列化方法,自身就实现了跨语言和跨平台.然而事实没有那么神奇,之所以产生这种假象,来自于两个原因.
第一,associative array在弱类型语言里面就是类的概念,在php和javascript里面associative array就是其class的实际实现方式,所以在这些弱类型语言里面,json得到了非常良好的支持.
第二,IDL的目的是撰写IDL文件,而IDL文件被IDL compiler编译后能够产生一些代码(Stub/Skeleton),而这些代码是真正负责相应的序列化和反序列化工作的组件. 但是由于associative array和一般语言里面的class太像了,他们之间形成了一一对应关系,所以可以采用一套标准的代码进行相应的转化.对于自身支持associative array的弱类型语言,语言自身就具备操作json序列化后的数据的能力,对于java这强类型语言,可以采用反射的方式统一解决,例如google提供的Gson.
典型应用场景和非应用场景
json在很多应用场景中可以替代xml,更简洁并且解析性能更快.典型应用场景包括:
1、公司之间提供数据量相对小,实时性要求相对低(例如秒级别)的服务.
2、基于web-browser的ajax请求.
3、由于json具有非常强的兼容性,对于接口经常发生变化,并对可调式性要求非常高的场景,例如app客户端与服务端的通讯.
4、由于json的典型应用场景是json+http,适合跨防火墙访问.
总的来说,采用json进行序列化的数据相对来说还是比较冗长,对于大数据量服务或持久化,这意味着巨大的内存和磁盘开销,这种场景不适合.没有统一可用的IDL降低了对参与方的约束,实际操作中往往只能采用文档方式来进行约定,这可能会给调试带来一些不便,延长开放周期. 由于json在一些语言中的序列化和反序列化需要采用反射机制,所以在性能要求为ms级别,不建议使用.
IDL文件举例
以下是UserInfo序列化之后的一个例子:
{"userid":1,"name":"messi","address":[{"city":"北京","postcode":"1000000","street":"wangjingdonglu"}]}
Thrift
Thrift 是facebook开源提供的一个高性能,轻量级量级rpc服务框架.其产生正是为了解决目前大数据量,分布式,跨语言,跨平台数据通讯的需求. 但是,thrift并不仅仅是序列化协议,而是一个rpc框架.相对于json和xml而言,thrift在verbosity和complexity有了比较大的提升,对于对性能要求比较高的分布式系统,它是一个优秀的rpc解决方案;但是由于由于thrift的序列化被嵌入到thrift框架里面,thrift框架本身并没有透出序列化和反序列化接口,这导致其很难和其他传输层协议共同使用(例如http).
典型应用场景和非应用场景
对于需求为高性能,分布式的rpc服务,thrift是一个优秀的解决方案.它支持众多语言和丰富的数据类型,并对于数据字段的增删具有较强的兼容性.所以非常适用于作为公司内部的面向服务构建(soa)的标准rpc框架.
不过thrift的文档相对比较缺乏,目前使用的群众基础相对较少,另外由于其server是基于自身的socket服务,所以在跨防火墙访问时,安全是一个顾虑,所以在公司间进行通讯时需要谨慎. 另外thrift序列化之后的数据是binary数组,不具有可读性,调试代码时相对困难. 最后,由于thrift的序列化和框架紧耦合,无法支持向持久层直接读写数据,所以不适合做数据持久化序列化协议.
IDL文件举例
struct Address { 1: required string city; 2: optional string postcode; 3: optional string street; } struct UserInfo { 1: required string userid; 2: required i32 name; 3: optional list<Address> address; }
Protobuf
protobuf具备了优秀的序列化协议的所需的众多典型特征:
1、标准的IDL和IDL编译器,这使得其对工程师非常友好
2、序列化数据非常简洁,紧凑,与xml相比,其序列化之后的数据量约为1/3到1/10
3、解析性能非常快,比对应的xml快约20-100倍
4、提供了非常友好的动态库,使用非常简介,反序列化只需要一行代码.
protobuf是一个纯粹的展示层协议,可以和各种传输层协议一起使用;protobuf的文档也非常完善. 但是由于protobuf产生于google,所以目前其仅仅支持Java, C++, Python三种语言.另外protobuf支持的数据类型相对较少,不支持常量类型.由于其设计的理念是纯粹的presentation协议,目前并没有一个专门支持protobuf的rpc框架.
典型应用场景和非应用场景
protobuf具有广泛的用户基础,数据量以及性能是其亮点,非常适合于公司内部的对性能要求比较高的rpc调用.由于protobuf提供了标准的IDL以及对应的编译器,其IDL文件是参与各方的非常强的业务约束,另外,protobuf与传输层无关,采用http具有良好的跨防火墙的访问属性,所以protobuf也适用于公司间对性能要求比较高的场景.由于其解析性能高,序列化后数据量相对少,非常适合应用层对象的持久化场景.它的主要问题在于其所支持的语言相对较少,另外由于没有绑定的标准底层传输层协议,在公司间进行传输层协议的调试工作相对麻烦.
IDL文件举例
message Address { required string city=1; optional string postcode=2; optional string street=3; } message UserInfo { required string userid=1; required string name=2; repeated Address address=3; }
Avro
Avro的产生解决了json的冗长和没有IDL的问题,Avro属于Apache Hadoop的一个子项目. Avro提供两种序列化方法:json格式或者binary格式.binary格式在verbosity和complexity方面可以和protobuf媲美,json格式方便测试阶段的调试. Avro支持的数据类型非常丰富,包括c++语言里面的union类型.Avro支持json格式的IDL和类似与thrift和protobuf的IDL(实验阶段),这两者之间可以互转.schema可以在传输数据的同时发送,另外由于json的自我描述属性,这使得Avro非常适合动态类型语言. Avro在做文件持久化的时候,一般会和schema一起存储,所以Avro序列化文件自身具有自我描述属性, 所以非常适合于做hive,pig和mapreduce的持久化数据格式. 对于不同版本的schema, 在进行rpc调用的时候,服务端和客户端可以在握手阶段对schema进行互相确认,大大提高了最终的数据解析速度.
典型应用场景和非应用场景
Avro解析性能高并且序列化之后的数据非常简洁,所以比较适合于高性能的序列化服务.
但是由于avro目前非json格式的IDL处于实验阶段,而json格式的IDL对于习惯于静态类型语言的工程师来说相对不直观.
IDL文件举例
protocol Userservice { record Address { string city; string postcode; string street; } record UserInfo { string name; int userid; array<Address> address = []; } }
所对应的json schema格式如下:
{ "protocol" : "Userservice", "namespace" : "org.apache.avro.ipc.specific", "version" : "1.0.5", "types" : [ { "type" : "record", "name" : "Address", "fields" : [ { "name" : "city", "type" : "string" }, { "name" : "postcode", "type" : "string" }, { "name" : "street", "type" : "string" } ] }, { "type" : "record", "name" : "UserInfo", "fields" : [ { "name" : "name", "type" : "string" }, { "name" : "userid", "type" : "int" }, { "name" : "address", "type" : { "type" : "array", "items" : "Address" }, "default" : [ ] } ] } ], "messages" : { } }
Benchmark
以下数据来自 https://code.google.com/p/thrift-protobuf-compare/wiki/Benchmarking
解析性能
序列化之后数据的大小
从上图可得出如下结论:
1、xml序列化(xstream)无论在性能和简洁性上比较差,
2、thrift与protobuf相比具有一定的劣势.
3、protobuf和avro在两方面表现都非常优越.
各种序列化方法应用场景总结
以上描述的五种序列化和反序列化协议都各自具有相应的特点,适用于不同的场景:
1、对于公司间的系统调用,如果性能要求在100ms以上的服务,基于xml的soap协议是一个值得考虑的方案.
2、基于web-browser的ajax,以及app与服务端之间的通讯,json协议是首选.对于其他性能要求不算太高,或者以动态类型语言为主的运用场景,json也是非常不错的选择.
3、对于调试环境比较恶劣的场景,采用json或xml能够极大的提高调试效率,降低系统开发成本.
4、当对性能和简洁性有极高要求的时候,protobuf,thrift,avro之间具有一定的竞争关系.
5、对于T级别的数据的持久化应用场景,protobuf和avro是首要选择.如果持久化后的数据保存在hadoop子项目里,avro会是更好的选择.
6、由于avro的设计理念偏向于动态类型语言,对于动态语言为主的应用场景,avro是更好的选择.
7、对于持久层非hadoop项目,以静态类型语言为主的应用场景,protobuf会更符合工程师的开发习惯.
8、如果需要提供一个完整的rpc解决方案,thrift是一个好的选择.
9、如果序列化之后需要支持不同的传输层协议,或者需要跨防火墙访问的高性能场景,protobuf可以优先考虑.