序列化与协议

在前面的章节中,我们对整个ZooKeeper的系统模型进行了全局性的了解,从本节开始,我们将深入ZooKeeper的每个组成部分来讲解其内部的实现原理。

从上面的介绍中,我们已经了解到,ZooKeeper 的客户端和服务端之间会进行一系列的网络通信以实现数据的传输。

对于一个网络通信,首先需要解决的就是对数据的序列化和反序列化处理,在ZooKeeper中,使用了Jute这一序列化组件来进行数据的序列化和反序列化操作。

同时,为了实现一个高效的网络通信程序,良好的通信协议设计也是至关重要的。

本章将围绕 ZooKeeper 的序列化组件 Jute 以及通信协议的设计原理来讲解ZooKeeper在网络通信底层的一些技术内幕。

Jute介绍

Jute是 ZooKeeper中的序列化组件,最初也是 Hadoop 中的默认序列化组件,其前身是Hadoop Record IO中的序列化组件,后来由于Apache Avro[1]具有出众的跨语言特性、丰富的数据结构和对MapReduce的天生支持,并且能非常方便地用于RPC调用,从而深深吸引了Hadoop。

因此Hadoop从0.21.0版本开始,废弃了Record IO,使用了Avro这个序列化框架,同时Jute也从Hadoop工程中被剥离出来,成为了独立的序列化组件。

ZooKeeper则从第一个正式对外发布的版本(0.0.1版本)开始,就一直使用Jute组件来进行网络数据传输和本地磁盘数据存储的序列化和反序列化工作,一直使用至今。

其实在前些年,ZooKeeper 官方也一直在寻求一种高性能的跨语言序列化组件,期间也多次提出要替换 ZooKeeper 的序列化组件。

关于序列化组件的改造还需要追溯到 2008 年左右,那时候 ZooKeeper 官方就提出要使用类似于 Apache Avro、Thrift 或是 Google 的protobuf 这样的组件来替换 Jute,但是考虑到新老版本序列化组件的兼容性,官方团队一直对替换序列化组件工作的推进持保守和观望态度。

值得一提的是,在替换序列化组件这件事上,ZooKeeper官方团队曾经也有过类似于下面这样的方案:服务器开启两个客户端服务端口,让包含新序列化组件的新版客户端连接单独的服务器端口,老版本的客户端则连接另一个端口。

但考虑到其实施的复杂性,这个想法设计一直没有落地。更为有趣的是,ZooKeeper开发团队曾经甚至考虑将“如何让依赖Jute组件的老版本客户端/服务器和依赖Avro组件的新版本客户端/服务器进行无缝通信”这个问题作为Google Summer of Code的题目。

当然,另一个重要原因是针对Avro早期的发布版本,ZooKeeper官方做了一个 Jute 和 Avro 的性能测试,但是测试结果并不理想,因此也并没有决定使用Avro——时至今日,Jute的序列化能力都不曾是ZooKeeper的性能瓶颈。

总之,因为种种原因以及 2009 年以后 ZooKeeper 快速地被越来越多的系统使用,开发团队需要将更多的精力放在解决更多优先级更高的需求和Bug修复上,以致于替换Jute序列化组件的工作一度被搁置——于是我们现在看到,在最新版本的ZooKeeper中,底层依然使用了Jute这个古老的,并且似乎没有更多其他系统在使用的序列化组件。

在本节接下来的部分,我们将向读者重点介绍 Jute 这种序列化组件在 Java 语言中的使用和实现原理。

使用Jute进行序列化

下面我们通过一个例子来看看如何使用 Jute 来完成 Java 对象的序列化和反序列化。

假设我们有一个实体类MockReqHeader(代表了一个简单的请求头),其定义如清单7-9所示。

深入 Jute

从上面的讲解中可以看出,使用 Jute 来进行 Java 对象的序列化和反序列化是非常简单的。

接下去我们再通过Record序列化接口、序列化器和Jute配置文件三方面来深入了解下Jute。

Record 接口

Jute定义了自己独特的序列化格式Record,ZooKeeper中所有需要进行网络传输或是本地磁盘存储的类型定义,都实现了该接口,其结构简单明了,操作灵活可变,是Jute序列化的核心。

Record接口定义了两个最基本的方法,分别是serialize和deserialize,分别用于序列化和反序列化:

所有实体类通过实现Record接口的这两个方法,来定义自己将如何被序列化和反序列化。

其中 archive 是底层真正的序列化器和反序列化器,并且每个 archive 中可以包含对多个对象的序列化和反序列化,因此两个接口方法中都标记了参数tag,用于向序列化器和反序列化器标识对象自己的标记。

例如在清单 7-10 所示的代码片段中,将MockReqHeader对象交付给boa序列化器进行序列化,并标记为header。

我们可以看到,在这个样例实现中,serialize和deserialize的过程基本上是两个相反的过程,serialize过程就是将当前对象的各个成员变量以一定的标记(tag)写入到序列化器中去;而deserialize过程则正好相反,是从反序列化器中根据指定的标记(tag)将数据读取出来,并赋值给相应的成员变量。

OutputArchive和InputArchive

OutputArchive和InputArchive分别是Jute底层的序列化器和反序列化器接口定义。

在最新版本的Jute中,分别有BinaryOutputArchive/BinaryInputArchive、CsvOutputArchive/CsvInputArchive和XmlOutputArchive/XmlInputArchive三种实现。无论哪种实现,都是基于OutputStream和InputStream进行操作。

关于三种序列化/反序列化器的实现,读者可以到ZooKeeper的org.apache.jute包下面进行查阅。BinaryOutputArchive 对数据对象的序列化和反序列化,主要用于进行网路传输和本地磁盘的存储,是 ZooKeeper 底层最主要的序列化方式。CsvOutputArchive对数据的序列化,则更多的是方便数据对象的可视化展现,因此被使用在toString方法中。最后一种XmlOutputArchive,则是为了将数据对象以XML格式保存和还原,但是目前在ZooKeeper中基本没有被使用到。

zookeeper.jute

很多读者在阅读ZooKeeper的代码的过程中,都会发现一个有趣的现象,那就是在很多ZooKeeper类的说明中,都写着“File generated by hadoop record compiler.Do not edit.”

这是因为该类并不是ZooKeeper的开发人员编写的,而是通过Jute组件在编译过程中动态生成的。

在ZooKeeper的src目录下,有一个名叫zookeeper.jute的文件:

  • 清单7-11. zookeeper.jute定义

在这个文件中定义了所有实体类的所属包名、类名以及该类的所有成员变量及其类型。

例如清单 7-11 中的代码片段就分别定义了 org.apache.zookeeper.data.Id 和org.apache.zookeeper.data.ACL两个类。

有了这个定义文件后,在源代码编译阶段,Jute会使用不同的代码生成器来为这些类定义生成实际编程语言(Java 或 C/C++)的类文件。

以 Java 语言为例,Jute 会使用JavaGenerator来生成相应的类文件,这些类文件都会被存放在src\java\generated目录下。

需要注意的一点是,使用这种方式生成的类,都会实现Record接口。

通信协议

基于TCP/IP协议,ZooKeeper实现了自己的通信协议来完成客户端与服务端、服务端与服务端之间的网络通信。

ZooKeeper 通信协议整体上的设计非常简单,对于请求,主要包含请求头和请求体,而对于响应,则主要包含响应头和响应体,如图7-12所示。

图7-12.通信协议体

len 请求头 请求体
len 响应头 响应体

协议解析:请求部分

我们首先来看请求协议的详细设计,图 7-13 定义了一个“获取节点数据”请求的完整协议定义。

  • 图7-13.GetDataRequest请求完整协议定义

完整协议

接下来,我们将从请求头和请求体两方面分别解析ZooKeeper请求的协议设计。

请求头:RequestHeader

请求头中包含了请求最基本的信息,包括xid和type:

xid用于记录客户端请求发起的先后序号,用来确保单个客户端请求的响应顺序。

type代表请求的操作类型,常见的包括创建节点(OpCode.create:1)、删除节点(OpCode.create:2)和获取节点数据(OpCode.getData:4)等,所有这些操作类型都被定义在类org.apache.zookeeper.ZooDefs.OpCode中。

根据协议规定,除非是“会话创建”请求,其他所有的客户端请求中都会带上请求头。

请求体:Request

协议的请求体部分是指请求的主体内容部分,包含了请求的所有操作内容。

不同的请求类型,其请求体部分的结构是不同的,下面我们以会话创建、获取节点数据和更新节点数据这三个典型的请求体为例来对请求体进行详细分析。

ConnectRequest:会话创建

ZooKeeper客户端和服务器在创建会话的时候,会发送ConnectRequest请求,该请求体中包含了协议的版本号 protocolVersion、最近一次接收到的服务器ZXID lastZxidSeen、会话超时时间timeOut、会话标识sessionId和会话密码passwd,其数据结构定义如下:

GetDataRequest:获取节点数据

ZooKeeper 客户端在向服务器发送获取节点数据请求的时候,会发送 GetDataRequest请求,该请求体中包含了数据节点的节点路径 path和是否注册Watcher的标识watch

SetDataRequest:更新节点数据

ZooKeeper客户端在向服务器发送更新节点数据请求的时候,会发送SetDataRequest请求,该请求体中包含了数据节点的节点路径 path、数据内容 data 和节点数据的期望版本号version,其数据结构定义如下:

以上介绍了常见的三种典型请求体定义,针对不同的请求类型,ZooKeeper 都会定义不同的请求体,读者可以到org.apache.zookeeper.proto包下自行查看。

请求协议实例:获取节点数据

上面我们分别介绍了请求头和请求体的协议定义,现在我们通过一个客户端“获取节点数据”的具体例子来进一步了解请求协议。

清单7-12.发起一次简单的获取节点数据请求

清单 7-12 是一个发起一次简单的获取节点数据内容请求的样例程序,读者可以到book.chapter07.$7_2_4包中查看完整的源代码。

客户端调用 getData 接口,实际上就是向 ZooKeeper 服务端发送了一个 GetDataRequest 请求。

使用 Wireshark[2]获取到其发送的网络TCP包,如图7-14所示。

在图7-14中,我们获取到了ZooKeeper客户端请求发出后,在TCP层数据传输的十六进制表示,其中带下划线的部分就是对应的GetDataRequest请求,即“[00,00,00,1d,00,00,00,01,00,00,00,04,00,00,00,10,2f,24,37,5f,32,5f,34,2f,67,65,74,5f,64,61,74,61,01]”。

通过比对图7-13中的GetDataRequest请求的完整协议定义,我们来分析下这个十六进制字节数组的含义,如表7-5所示。

协议解析:响应部分

上面我们已经对ZooKeeper请求部分的协议进行了解析,接下来我们看看服务器端响应的协议解析。

我们首先来看响应协议的详细设计,图7-15定义了一个“获取节点数据”响应的完整协议定义。

响应头:ReplyHeader

响应头中包含了每一个响应最基本的信息,包括xid、zxid和err:

xid和上文中提到的请求头中的xid是一致的,响应中只是将请求中的xid原值返回。

zxid代表 ZooKeeper 服务器上当前最新的事务 ID。

err 则是一个错误码,当请求处理过程中出现异常情况时,会在这个错误码中标识出来,常见的包括处理成功(Code.OK:0)、节点不存在(Code.NONODE:101)和没有权限(Code.NOAUTH:102)等,所有这些错误码都被定义在类org.apache.zookeeper.KeeperException.Code中。

响应体:Response

协议的响应体部分是指响应的主体内容部分,包含了响应的所有返回数据。不同的响应类型,其响应体部分的结构是不同的,下面我们以会话创建、获取节点数据和更新节点数据这三个典型的响应体为例来对响应体进行详细分析。

  • ConnectResponse:会话创建

针对客户端的会话创建请求,服务端会返回客户端一个ConnectResponse响应,该响应体中包含了协议的版本号protocolVersion、会话的超时时间timeOut、会话标识sessionId和会话密码passwd,其数据结构定义如下:

  • GetDataResponse:获取节点数据

针对客户端的获取节点数据请求,服务端会返回客户端一个 GetDataResponse响应,该响应体中包含了数据节点的数据内容 data和节点状态 stat,其数据结构定义如下:

  • SetDataResponse:更新节点数据

针对客户端的更新节点数据请求,服务端会返回客户端一个 SetDataResponse响应,该响应体中包含了最新的节点状态stat,其数据结构定义如下:以上介绍了常见的三种典型响应体定义,针对不同的响应类型,ZooKeeper 都会定义不同的响应体,读者可以到org.apache.zookeeper.proto包下自行查看。

响应协议实例:获取节点数据

在上面的内容中,我们分别介绍了响应头和响应体的协议定义,现在我们再次通过上文中提到的客户端“获取节点数据”的例子来对响应协议做一个实际分析。

这里的测试用例还是使用清单7-12中的示例程序,只是这次我们使用Wireshark获取到服务端响应客户端时的网络TCP包,如图7-16所示。

图7-16.GetDataResponse完整协议十六进制表示

在图7-16中,我们获取到了ZooKeeper服务端响应发出之后,在TCP层数据传输的十六进制表示,其中带下划线的部分就是对应的GetDataResponse响应,即“[00,00,00,63,00,00,00,05,00,00,00,00,00,00,00,04,00,00,00,00,00,00,00,0b,69,27,6d,5f,63,6f,6e,74,65,6e,74,00,00,00,00,00,00,00,04,00,00,00,00,00,00,00,04,00,00,01,43,67,bd,0e,08,00,00,01,43,67,bd,0e,08,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,0b,00,00,00,00,00,00,00,00,00,00,00,04]”。通过比对图7-15中的GetDataResponse响应完整协议定义,我们来分析下这个十六进制字节数组的含义,如表7-6所示。

表7-6.GetDataResponse响应协议解析

表 7-6 中分段解析了 ZooKeeper 服务端的 GetDataResponse 响应发送的数据,其他响应也都类似,感兴趣的读者可以使用相同的方法自行分析。

参考资料

分布式一致性原理与实践