ZooKeeper的序列化方式

使用 ZooKeeper 实现一些功能的主要方式,也就是通过客户端与服务端之间的相互通信。那么首先要解决的问题就是通过网络传输数据,而要想通过网络传输我们定义好的 Java 对象数据,必须要先对其进行序列化。
例如,通过 ZooKeeper 客户端发送 ACL 权限控制请求时,需要把请求信息封装成 packet 类型,经过序列化后才能通过网络将 ACL 信息发送给 ZooKeeper 服务端进行处理。

什么是序列化

序列化是指将我们定义好的 Java 类型转化成数据流的形式。之所以这么做是因为在网络传输过程中,TCP 协议采用“流通信”的方式,提供了可以读写的字节流。
而这种设计的好处在于避免了在网络传输过程中经常出现的问题:
比如消息丢失、消息重复和排序等问题。那么什么时候需要序列化呢?如果我们需要通过网络传递对象或将对象信息进行持久化的时候,就需要将该对象进行序列化。
我们较为熟悉的序列化操作是在 Java中,当我们要序列化一个对象的时候,首先要实现一个 Serializable 接口。
public class User implements Serializable{ private static final long serialVersionUID = 1L; private Long ids; private String name; ... }
实现了 Serializable 接口后其实没有做什么实际的工作,它是一个没有任何内容的空接口,起到的作用就是标识该类是需要进行序列化的
这个就与 ZooKeeper 序列化实现方法有很大的不同。
public interface Serializable { }
定义好序列化接口后,我们再看一下如何进行序列化和反序列化的操作。
Java 中进行序列化和反序列化的过程中,主要用到了 ObjectInputStreamObjectOutputStream 两个 IO 类。
ObjectOutputStream 负责将对象进行序列化并存储到本地。
ObjectInputStream 从本地存储中读取对象信息反序列化对象。
//序列化 ObjectOutputStream oo = new ObjectOutputStream() oo.writeObject(user); //反序列化 ObjectInputStream ois = new ObjectInputStream(); User user = (User) ois.readObject();
当我们要把对象进行本地存储或网络传输时是需要进行序列化操作的,而在 ZooKeeper 中需要频繁的网络传输工作,那么在 ZooKeeper 中是如何进行序列化的呢,带着这个问题继续。

ZooKeeper 中的序列化方案

在 ZooKeeper 中并没有采用和 Java 一样的序列化方式,而是采用了一个 Jute 的序列解决方案作为 ZooKeeper 框架自身的序列化方式,说到 Jute 框架,它最早作为 Hadoop 中的序列化组件。
之后 Jute 从 Hadoop 中独立出来,成为一个独立的序列化解决方案。ZooKeeper 从最开始就采用 Jute 作为其序列化解决方案,直到其最新的版本依然没有更改。
虽然 ZooKeeper 一直将 Jute 框架作为序列化解决方案,但这并不意味着 Jute 相对其他框架性能更好,反倒是 Apache Avro、Thrift 等框架在性能上优于前者。
之所以 ZooKeeper 一直采用 Jute 作为序列化解决方案,主要是新老版本的兼容等问题,这里也请注意,也许在之后的版本中,ZooKeeper 会选择更加高效的序列化解决方案。

使用 Jute 实现序列化

如何使用 Jute 在 ZooKeeper 中实现序列化。
如果我们要想将某个定义的类进行序列化,首先需要该类实现 Record 接口的 serilize 和 deserialize 方法,这两个方法分别是序列化和反序列化方法。
下边这段代码给出了我们一般在 ZooKeeper 中进行序列化的具体实现:
首先,我们定义了一个 test_jute 类,为了能够对它进行序列化,需要该 test_jute 类实现 Record 接口,并在对应的 serialize 序列化方法和 deserialize 反序列化方法中编辑具体的实现逻辑。
class test_jute implements Record{ private long ids; private String name; ... public void serialize(OutpurArchive a_,String tag){ ... } public void deserialize(INputArchive a_,String tag){ ... } }
而在序列化方法 serialize 中,我们要实现的逻辑是,首先通过字符类型参数 tag 传递标记序列化标识符,之后使用 writeLong 和 writeString 等方法分别将对象属性字段进行序列化。
public void serialize(OutpurArchive a_,String tag) throws ...{ a_.startRecord(this.tag); a_.writeLong(ids,"ids"); a_.writeString(type,"name"); a_.endRecord(this,tag); }
而调用 derseralize 在实现反序列化的过程则与我们上边说的序列化过程正好相反。
public void deserialize(INputArchive a_,String tag) throws { a_.startRecord(tag); ids = a_.readLong("ids"); name = a_.readString("name"); a_.endRecord(tag); }
需要注意的是,在实现了Record 接口后,具体的序列化和反序列化逻辑要我们自己在 serialize 和 deserialize 函数中完成
序列化和反序列化的实现逻辑编码方式相对固定,首先通过 startRecord 开启一段序列化操作,之后通过 writeLong、writeString 或 readLong、 readString 等方法执行序列化或反序列化。
本例中只是实现了长整型和字符型的序列化和反序列化操作,除此之外 ZooKeeper 中的 Jute 框架还支持 整数类型(Int)、布尔类型(Bool)、双精度类型(Double)以及 Byte/Buffer 类型。

Jute 在 ZooKeeper 中的底层实现

Record 接口可以理解为 ZooKeeper 中专门用来进行网络传输或本地存储时使用的数据类型。因此所有我们实现的类要想传输或者存储到本地都要实现该 Record 接口。
public interface Record{ public void serialize(OutputArchive archive, String tag) throws IOException; public void deserialize(InputArchive archive, String tag) throws IOException; }
Record 接口的内部实现逻辑非常简单,只是定义了一个 序列化方法 serialize 和一个反序列化方法 deserialize 。
而在 Record 起到关键作用的则是两个重要的类:OutputArchive InputArchive ,其实这两个类才是真正的序列化和反序列化工具类。
在 OutputArchive 中定义了可进行序列化的参数类型,根据不同的序列化方式调用不同的实现类进行序列化操作。
如下图所示,Jute 可以通过 Binary 、 Csv 、Xml 等方式进行序列化操作。
notion image
而对应于序列化操作,在反序列化时也会相应调用不同的实现类,来进行反序列化操作。
如下图所示:
notion image
注意:无论是序列化还是反序列化,都可以对多个对象进行操作,所以当我们在定义序列化和反序列化方法时,需要字符类型参数 tag 表示要序列化或反序列化哪个对象。

Jute 的底层实现原理

Jute 框架给出了 3 种序列化方式,分别是 Binary 方式、Csv 方式、XML 方式。序列化方式可以通俗地理解成我们将 Java 对象通过转化成特定的格式,从而更加方便在网络中传输和本地化存储。
之所以采用这 3 种方式的格式化文件,也是因为这 3 种方式具有跨平台和普遍的规约特性,

Jute 内部核心算法

ZooKeeper 在实现序列化的时候要实现 Record 接口,而在 Record 接口的内部,真正起作用的是两个工具类,分别是 OutPutArchive InputArchive
OutPutArchive 是一个接口,规定了一系列序列化相关的操作。而要实现具体的相关操作,Jute 是通过三个具体实现类分别实现了 Binary、Csv、XML 三种方式的序列化操作。

Binary 方式的序列化

在 ZooKeeper 中默认的序列化实现方式是 Binary 二进制方式。这是因为二进制具有更好的性能,以及大多数平台对二进制的实现都不尽相同。
Jute 中的第 1 种序列化方式:Binary 序列化方式,即二进制的序列化方式。
正如前边所提到的,采用这种方式的序列化就是将 Java 对象信息转化成二进制的文件格式。
在 Jute 中实现 Binary 序列化方式的类是 BinaryOutputArchive
BinaryOutputArchive 类通过实现 OutPutArchive 接口,在 Jute 框架采用二进制的方式实现序列化的时候,采用其作为具体的实现类。
在这里通过调用 Record 接口中的 writeString 方法为例,该方法是将 Java 对象的 String 字符类型进行序列化。
当调用 writeString 方法后,首先判断所要进行序列化的字符串是否为空。
如果是空字符串则采用 writeInt 方法,将空字符串当作值为 -1 的数字类型进行序列化;如果不为空,则调用 stringtoByteBuffer 方法对字符串进行序列化操作。
void writeString (String s, Sring tag){ if(s==null){ writeInt(-1,"len"); return } ByteBuffer bb = stringtoByteBuffer(s); ... }
stringToByteBuffer 方法也是 BinaryOutputArchive 类的内部核心方法,除了 writeString 序列化方法外,其他的比如 writeIntwirteDoule 等序列化方法则是调用 DataOutPut 接口中的相关方法来实现具体的序列化操作。
在调用 BinaryOutputArchive 类的 stringToByteBuffer 方法时,在将字符串转化成二进制字节流的过程中,首选将字符串转化成字符数组 CharSequence 对象,并根据 ascii 编码判断字符类型,如果是字母等则使用1个 byte 进行存储。如果是诸如 “¥” 等符号则采用两个 byte 进程存储。
如果是汉字则采用3个 byte 进行存储。
... private ByteBuffer bb = ByteBuffer.allocate(1024); ... ByteBuffer stringToByteBuffer(CharSeuquece s){ if (c < 0x80) {                 bb.put((byte) c);     } else if (c < 0x800) {       bb.put((byte) (0xc0 | (c >> 6)));       bb.put((byte) (0x80 | (c & 0x3f)));     } else {       bb.put((byte) (0xe0 | (c >> 12)));       bb.put((byte) (0x80 | ((c >> 6) & 0x3f)));       bb.put((byte) (0x80 | (c & 0x3f)));     } }

XML 方式的序列化

说完了 Binary 的序列化方式,我们再来看看 Jute 中的另一种序列化方式 XML 方式。
XML 是一种可扩展的标记语言。当初设计的目的就是用来传输和存储数据,很像我们都很熟悉的 HTML 语言,而与 HTML 语言不同的是我们需要自己定义标签。
在 XML 文件中每个标签都是我们自己定义的,而每个标签就对应一项内容。一个简单的 XML 的格式如下面这段代码所示:
<note> <to>学生</to> <from>老师</form> <heading>上课提醒</heading> <body>记得9:00来上课</body> </note>
在 Jute 中使用 XmlOutPutArchive 类作用 XML 方式序列化的具体实现类。与上面讲解二进制的序列化实现一样 ,这里我们还是以 writeString 方法的 XML 序列化方式的实现为例。
首先,当采用XML 方式进行序列化时,调用 writeString 方法将 Java 中的 String 字符串对象进行序列化时,在 writeString 内部首先调用 printBeginEnvelope 方法并传入 tag 参数,标记我们要序列化的字段名称。之后采用“”和“”作用自定义标签,封装好传入的 Java 字符串。
void writeString(String s, String tag){  printBeginEnvelope(tag);    stream.print("<string>");    stream.print(Utils.toXMLString(s));    stream.print("</string>");    printEndEnvelope(tag); }
而在 printBeginEnvelope 方法中,其主要作用就是添加该字段的名称、字段值等信息,用于之后反序列化的过程中。
void printBeginEnvelope (String tag){ ... if ("struct".equals(s)) { putIndent(); stream.print("<member>\n"); addIndent(); putIndent(); stream.print("<name>"+tag+"</name>\n"); putIndent(); stream.print("<value>"); } else if ("vector".equals(s)) { stream.print("<value>"); } else if ("map".equals(s)) { stream.print("<value>"); } } else { stream.print("<value>"); } }
通过上面在 Jute 框架中,对采用 XML 方式序列化的实现类:
XmlOutPutArchive 中的底层实现过程分析,我们可以了解到其实现的基本原理,也就是根据 XML 格式的要求,解析传入的序列化参数,并将参数按照 Jute 定义好的格式,采用设定好的默认标签封装成对应的序列化文件。
而采用 XML 方式进行序列化的优点则是,通过可扩展标记协议,不同平台或操作系统对序列化和反序列化的方式都是一样的,不存在因为平台不同而产生的差异性,
也不会出现如 Binary 二进制序列化方法中产生的大小端的问题。而缺点则是序列化和反序列化的性能不如二进制方式。在序列化后产生的文件相比与二进制方式,同样的信息所产生的文件更大。

Csv 方式的序列化

 
Jute 序列化框架的最后一种序列化方式:Csv,它和 XML 方式很像,只是所采用的转化格式不同,Csv 格式采用逗号将文本进行分割,我们日常使用中最常用的 Csv 格式文件就是 Excel 文件。
在 Jute 框架中实现 Csv 序列化的类是 CsvOutputArchive,我们还是以 String 字符对象序列化为例,在调用 CsvOutputArchive 的 writeString 方法时,writeString 方法首先调用 printCommaUnlessFirst 方法生成一个逗号分隔符,之后将要序列化的字符串值转换成 CSV 编码格式追加到字节数组中。
void writeString(String s, String tag){ printCommaUnlessFirst();   stream.print(Utils.toCSVString(s));   throwExceptionOnError(tag); }
到这里我们已经对 Jute 框架的 3 种序列化方式的底层实现有了一个整体了解,这 3 种方式相比,二进制底层的实现方式最为简单,性能也最好。
而 XML 作为可扩展的标记语言跨平台性更强。而 CSV 方式介于两者之间实现起来也相比 XML 格式更加简单。