NETTY序列化与反序列化
对象序列化/反序列化简介
在实际开发中,为了简化开发,通常请求和响应都使用使用Java对象表示,对使用者屏蔽底层的协议细节。例如一些RPC框架,支持把用户定义的Java对象当做参数去请求服务端,服务端响应也是一个Java对象。
而前面我们讲解的案例都是以字符串作为请求和响应参数,实际上,Netty对于我们的自定义的Java对象作为请求响应参数也是支持的,其默认支持通过以下机制对Java对象进行序列化和反序列化:
- ObjectEncoder/ObjectDecoder:使用JDK序列化机制编解码
- ProtobufEncoder/ ProtobufDecoder:使用google protocol buffer进行编解码
- MarshallingEncoder/MarshallingDecoder:使用JBoss Marshalling进行编解码
- XmlDecoder:使用Aalto XML parser进行解码,将xml解析成Aalto XML parser中定义的Java对象,没有提供相应的编码器
- JsonObjectDecoder:使用Json格式解码。当检测到匹配数量的”{“ 、”}”或”[””]”时,则认为是一个完整的json对象或者json数组。这个解码器只是将包含了一个完整Json格式数据的ByteBuf实例交给之后的ChannelInbounderHandler解析,因此我们需要依赖其他的JSON框架,如Gson、jackson、fastjson等。没有提供相应的编码器。
除了Netty默认值支持的这些序列化机制,事实上还有很多的其他的序列化框架,如:hessian
、Kryo
、Avro
、fst
、msgback
、thrift、protostuff
等。
在实际开发中,通常我们没有必要支持上述所有的序列化框架,支持部分即可。主要的选择依据如下表:
选择依据 | 说明 |
---|---|
效率 | 即序列化和反序列化的性能。这方面Kryo、Avro、fst、hessian等都不错。 |
序列化后的占用字节数 | 对于同一个Java对象,不同的框架序列化后占用的字节数不同。例如JDK序列化体积较大,而Kryo的体积较小。体积过大的话,会增加网络带宽压力。 |
是否有可视化需求 | json、xml序列化机制的结果能以文本形式展示;但是其他的框架大多是二进制的,因此可视化。 |
开发成本 | 一些序列化框架使用较为复杂,如thrift、protocol buffer;另外则很简单,如JDK序列化、Hessian等 |
从本教程而言,主要是为了介绍如何在Netty中使用这些序列化框架,方式类似,因此不会对每一种都进行介绍。在后续的文章中,我们将会对:JDK序列化、Hessian序列化、protocol buffer序列化进行讲解。
通信协议格式要求
另外一点需要注意的是,上面提到的这些序列化框架通常不能单独使用。例如发送方只是将Java对象序列化成二进制字节,对于接收方而言,则无法判断到底哪些字节可以构成一个完整的Java对象,也就无法反序列化。因此我们通常需要结合长度编码,可以使用上一节提到的LengthFieldBasedFrameDecoder/LengthFieldPrepender来协助完成。
最简单的通信协议格式如下:
1 | +--------+----------+ |
Length:表示Content字段占用的字节数,Length本身占用的字节数我们可以指定为一个固定的值。
Content:对象经过序列化后二进制字节内容
对于上述协议,通常我们只能选择支持一种序列化框架,如果要支持多个序列化框架,我们可以对通信协议格式稍作改造,增加一个字段来表示使用的序列化框架,如:
1 | +--------+-------------+------------+ |
其中:Serializer我们可以指定使用1个字节表示,因此可以有256个值可选,我们用不同的值代表不同的框架。在编码时,选择好序列化框架后,进行序列化,并指定Serializer字段的值。在解码时,根据Serializer的值选择对应的框架进行反序列化;
JDK序列化
ObjectEncoder与ObjectDecoder简介
ObjectEncoder
/ObjectDecoder
使用JDK序列化机制编解码,因此我们可以使用Java对象作为请求和响应参数,限制是对象必须实现Serializable。JDK序列化机制的缺点是:序列化的性能以及序列化后对象占用的字节数比较多。优点是:这是对JDK默认支持的机制,不需要引入第三方依赖。
如果仅仅是对象序列化,字节通过网络传输后,那么在解码时,无法判断到底多少个字节可以构成一个Java对象。因此需要结合长度编码,也就是添加一个Length字段,表示序列化后的字节占用的字节数。因此ObjectEncoder/ObjectDecoder采用的通信协议如下:
1 | +--------+----------+ |
- Length:表示Content字段占用的字节数,Length本身占用的字节数我们可以指定为一个固定的值
- Content:对象经过JDK序列化后二进制字节内容
乍一看,这与我们在上一节讲解的LengthFieldBasedFrameDecoder
/LengthFieldPrepender
采用的通信协议很类似。事实上ObjectDecoder本身就继承了LengthFieldBasedFrameDecoder。不过ObjectEncoder略有不同,其并没有继承LengthFieldPrepender,而是内部直接添加了Length字段。
ObjectEncoder源码如下:
1 |
|
ObjectDecoder源码如下所示:
1 | //注意ObjectDecoder继承了LengthFieldBasedFrameDecoder |
下面我们通过实际案例来演示ObjectEncoder与ObjectDecoder的使用。
使用案例
系列化类
首先我们分别编写两个Java对象Request/Response分别表示请求和响应。
1 | public class Request implements Serializable{ |
客户端
1 | public class JdkSerializerClient { |
由于client既要发送Java对象Request作为请求,又要接受服务端响应的Response对象,因此在ChannelPipeline
中,我们同时添加了ObjectDecoder和ObjectEncoder。
另外我们自定义了一个ChannelInboundHandler,在连接建立时,其channelActive方法会被回调,我们在这个方法中构造一个Request对象发送,通过ObjectEncoder进行编码。服务端会返回一个Response对象,此时channelRead方法会被回调,由于已经经过ObjectDecoder解码,因此可以直接转换为Reponse对象,然后打印。
服务端
1 | public class JdkSerializerServer { |
Server端与Client端分析类似,这里不再赘述。
先后启动Server端与Client端,在Server端控制台我们将看到:
1 | JdkSerializerServer Started on 8080... |
在Client端控制台我们将看到:
1 | receive response:Response{response='response to:i am request!', responseTime=2018-9-9 18:48:36} |
总结
把ObjectEncoder/ObjectDecoder作为序列化/反序列化入门是非常合适的,因为其足够简单。同时通过这个案例,我们也发现了,通信协议中必须包含了一个Length字段,用于表示对象序列化后的字节数。事实上,这种模式也可以套用到我们接下来要介绍的其他序列化框架中。
JDK序列化的缺点
Java序列化从JDK1.1版本就已经提供,它不需要添加额外的类库,只需实现java.io.Serializable并生成序列ID即可,因此,它从诞生之初就得到了广泛的应用。
但是在远程服务调用(RPC)时,很少直接使用Java序列化进行消息的编解码和传输,这又是什么原因呢?下面通过分析Java序列化的缺点来找出答案。
无法跨语言
对于跨进程的服务调用,服务提供者可能会使用C十+或者其他语言开发,当我们需要和异构语言进程交互时Java序列化就难以胜任。由于Java序列化技术是Java语言内部的私有协议,其他语言并不支持,对于用户来说它完全是黑盒。对于Java序列化后的字节数组,别的语言无法进行反序列化,这就严重阻碍了它的应用。
序列化后的码流太大
JDK序列化后的二进制比本身要大。
序列化性能太低
无论是序列化后的码流大小,还是序列化的性能,JDK默认的序列化机制表现得都很差。因此,我们边常不会选择Java序列化作为远程跨节点调用的编解码框架。
Protocol Buffer编解码
Google 的 protobuf 在业界非常流行,很多商业项目选择 protobuf 作为编码解码框架,这里我们首先介绍一下 Protobuf 的优点:
- 在 Google 内长期使用,产品成熟度高;
- 跨语言,支持多种语言,包括 c++,Java, 和 Python;
- 编码后的消息更小,更加有利于存储和传输;
- 编码的性能非常高;
- 支持不同协议版本的向前兼容;
- 支持定义可选和必选字段;
Protobuf基本使用
这一部分的内容主要源自Protocol Buffer的官方文档,地址如下:
https://developers.google.com/protocol-buffers/docs/javatutorial
需要注意的是,这部分并不是一个在java中如何使用protocol buffer的完整的文档。更多的文档,请参考: Protocol Buffer Language Guide, the Java API Reference, the Java Generated Code Guide, and the Encoding Reference.
使用步骤
ProtocolBuf使用主要包括三个步骤:
在.proto文件中定义消息的格式
使用protocol buffer编译器根据.proto文件生成java类
使用Java protocol buffer api读写消息
定义.proto文件
proto是protocol的简写,因此定义proto文件实际上就是定义服务端与客户端通信协议。ProtocolBuffer提供了工具可以自动根据这个文件生成相应的java类,我们将在后面介绍。
举例来说,假设我们有一个提供查询个人联系方式的Server,当接受到Client查询某个人联系方式的请求时,将相关信息返回。那么我们就可以定义一个类似以下的addressbook.proto文件。
1 | package tutorial; |
可以看到.proto文件的语法与C++或者java很类似。让我们来看看文件中的每一个部分都做了什么。
package
.proto文件以一个package声明开始,这用于阻止不同项目间的文件命名冲突。在java中,这个package用于指定生成的Java类的所属的包。
在package声明之后的两个option: java_package和 java_outer_classname是java独有的。
java_package
在指定了 java_package之后,生成的java类的包名则由java_package指定。不过即使指定java_package,我们依旧也应该声明package,来防止可能的根据这个文件生成非java源码的造成命名冲突的可能。
java_outer_classname
java_outer_classname
说明应该生成一个类,将.proto文件中定义的所有message生成的类,包含进此类中。如果没有显示的指定这个option,默认将会按照.proto文件的名称按照驼峰命名的法则生成这个类。例如my_proto.proto生成的类名为MyProto。在我们的案例中,生成的是AddressBookProtos.java。
message
我们为每个希望序列化的数据结构定义一个message。上例中,在addressbook.proto文件中直接定义了两个message:AddressBook
message和Person
message。生成java类之后,将会在最外层类AddressBookProtos中定义两个内部类AddressBook和Person。你甚至可以在一个message内,嵌套定义其他的message。如你所见,PhoneNumber
定义在 Person中,那么生成的java类中,PhoneNumber类将会定义在Person类中。
一个message是一系列不同类型的字段的集合。一些常见的简单数据类型都可以用来定义message中字段的类型,包括: bool
, int32
, float
, double
, 和 string
。
你也可以在一个message中使用其他的message作为字段。可以看到,AddressBook
message中引用了Person
message, Person
message中引用了 PhoneNumber
message。
enum
你也可以定义 enum
类型,如果你希望某个字段有一些预定义的值,例如上述文件指定了电话类型(PhoneType)必须是: MOBILE
,HOME
, or WORK
中的一种。
字段修饰符
对于message中定义的每一个字段,都必须添加required、optional、repeated其中之一作为修饰符。
required:表明这个字段的值必须提供。当我们在构建一个message实例的时候,如果使用required修饰的字段没有提供值,protobuf会认为这个message不能初始化,将会抛出一个 UninitializedMessageException。尝试去解析一个没有被初始化的message,将会抛出 IOException。除了这两个特性,required字段与optional字段其他特性都是相同的。
optional:表明这个字段的值可以设置,也可以不设置。如果一个optional的字段没有设置值,一个默认值将会被使用(如果提供了默认值的话)。对于简单的数据类型,你可以指定自己的默认值,就像我们在案例中指定电话类型的type字段一样,否则,将会使用系统提供的默认值:数字类型默认值为0,字符串类型默认值是空串,布尔类型默认值是false。对于内嵌的message,默认值总是其默认实例或者称之为原型,也就是没有显示指定任何字段的值(可以理解为刚创建的一个java对象,没有对其进行任何设置)。
repeated:这个字段可以被重复多次(包含0)。在生成java代码时,对于repeat修饰的字段,将用一个List来表示。
注意:对于required注释的使用要慎重,考虑到不同版本的兼容问题。如果一开始设置某个字段为required,后来又想修改。这就可能会导致老版本的不兼容。一些Google的工程师的总结:optional或者repeated通常是更好的选择,但这不是绝对的。
tag
在addressbook.proto文件中,我们可以发现每个字段都有类似于 “ = 1”, “ = 2”这样的标记,在protobuf中称之为tag。注意这不是给字段赋予默认值,默认值的赋予是使用DEFAULT关键字,例如上例中给PhoneType字段设置了默认值。
tag的作用是表示在生成的java类中,这些字段的出现类中先后顺序,tag必须是唯一的,不能重复。对于Protocol Buffer而言,tag值为1到15的字段在编码时可以得到优化,只需要占用一个字节,tag范围是16-2047的字段在编码时将占用两个bytes。而Protocol Buffer可以支持的字段数量则为2的29次方减一。有鉴于此,我们在设计消息结构时,可以尽可能考虑让repeated类型的字段标签位于1到15之间,这样便可以有效的节省编码后的字节数量。
要查看.proto文件的完整语法,请参考 Protocol Buffer Language Guide。不要尝试去查找类似于java中的类继承这样的功能,protobuf不提供此类功能。
根据.proto文件生成java类
现在我们已经有了.proto文件,下一步是根据这个文件生成类。每一个message都会生成一个对应的类,并且最终都包含在java_outer_classname 指定的类中。
如果你还没有安装protobuf编译器,可以到 download the package下载。
如果你使用的是Windows操作系统,可以下载:
protoc命令的用法如下:
1 | protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto |
$SRC_DIR是.proto文件所在目录,如果都不提供的话,默认就生成在当前目录 , $DST_DIR是生成的java类存储目录。
最简单的情况,直接输入
1 | protoc --java_out=. addressbook.proto |
生成的文件位于:com/example/tutorial/AddressBookProtos.java
现在我们来看一下protobuf为我们生成的类 AddressBookProtos.java。在这个类中,包含了我们在addressbook.proto 文件中指定的message生成的类。每一个类都有一个自己的 Builder
,我们可以用这些builder来创建这些类的实例。
这些根据message生成的类和对应的builder都拥有访问message中每个字段的方法。messages类只拥有getters方法,而对应的builder同时拥有getters和setters方法。
以下是生成的Person类字段的访问方法(实现被省略了):
1 | // required string name = 1; |
如你所见,protobuf为message中的每个字段生成了Java-Bean风格的getters,同时,针对每个字段还有一个has方法,如果这个字段的值被设置了,就会返回true,否则返回false。
而 Person.Builder类拥有同样的getter方法和has方法外,还有额外的setter方法,和clear方法,可以修改字段的值。
1 | // required string name = 1; |
当我们想修改一个字段的值的时候,就调用set方法,如果要移除这个字段的值,就调用clear方法。
对于repeated注释的字段还有额外的方法– count方法(这是list.size()的便捷方法),针对list索引位置的get和set方法,添加一个元素到list的add方法,以及批量添加元素的addAll方法。
注意这个字段的访问方法使用的也是驼峰命名的法则,这个是protobuf自动做的工作,以使得生成的代码更加符合java语言的变成风格。在proto文件中,对于多个单词组成的字段名,我们应该始终使用小写+下划线的方式,以使得生成代码风格良好的代码。阅读 style guide查看更多的细节。
如果你想参考更多的生成Java代码的细节,请参考 Java generated code reference.。
在Person类中,还生成了Java 5中引入的枚举, PhoneType
:
1 | public static enum PhoneType { |
而PhoneNumber,如你所想,是Person类的一个嵌套类。
使用Java protocol buffer api读写消息
protobuf根据.proto文件中message生成的类,都是不可变的( immutable)。一旦这样的一个对象被构建了,就不可以被修改(因为没有提供setter方法)。要构建类实例,我们必须首先创建Builder,然后为每个字段设置值,最后调用build方法来构建实例。
为了方便开发,builder方法是基于链式风格设计的,以下是一个构建Person实例的案例:
1 | Person john = |
标准的message生成类方法
每个message生成类和对应的Builder都有一些其他方法来帮助我们检查和操作这个类,包括:
**isInitialized()**:检查所有的required字段是否都被设置
**toString()**:返回可以阅读的message信息,以便于调试
**mergeFrom(Message other)**:只有builder拥有这个方法,合并其他的message中的内容到这个字段中。如果字段是required或者option,其他的message中内容会覆写原始message字段中的内容,如果字段是repeated,其他message中的字段会添加这个字段中。
**clear()**:只有builder具有这个方法,清空message中所有字段的值到初始状态。
完整的Message API文档请参考 complete API documentation for Message.
解析和序列化
最后,每一个生成的类都具有对应的读写为protobuf二进制格式的方法:
**byte[] toByteArray()**:序列化成字节数组
**static Person parseFrom(byte[] data)**:根据字节数组反序列化
**void writeTo(OutputStream output)**:序列化并写入到一个 OutputStream。
**static Person parseFrom(InputStream input)**:从一个 InputStream中反序列化
同样,这也只是序列化与反序列的部分方法,完整的API文档请参考 Message API reference。
注意:protobuf生成的类并没有非常好的匹配面向对象设计的思想,其只是一个数据持有者(dumb data holders)。如果你想为已生成的类添加丰富的行为,更好的方法是把已生成的protocol buffer类封装在一个特定于应用程序的类。封装protocol buffers是一个好想法,特别是在你还没能完全掌握某个.proto 文件设计的思路的情况下。你可以通过一个类实现某个接口,接口中只定义必要的访问方法,从而隐藏部分你尚未能完全掌握的字段和方法。你永远不应该写一个类来继承protobuf 生成的类,这可能会打破protobuf的内部机制。
写消息
1 | import com.example.tutorial.AddressBookProtos.AddressBook; |
读消息
1 | import com.example.tutorial.AddressBookProtos.AddressBook; |
在Netty中使用ProtocolBuf
引入坐标
首先我们需要项目中引入maven依赖
1 | <dependency> |
服务端
1 | b.group(bossGroup, workerGroup) |
我们首先在ChannelPipeline中添加了ProtobufVarint32FrameDecoder,其主要用于半包处理
随后添加ProtobufDecoder,它的参数类型是com.google.protobuf.MessageLite,实际上就是告诉ProtobufDecoder需要解码的目标类是什么,否则仅仅从字节数组中是无法判断出要解码的目标类型的,这里我们设置的是AddressBookProtos.Person类型实例,在.proto文件中所有的定义的message在生成的java类,例如这里的Person,都实现了MessageLite接口。
ProtobufEncoder类用于对输出的消息进行编码。
AddressBookHandler是我们自己定义处理类,在其channelRead方法参数中,Object msg就是解码后的Person,在返回数据时,
1 |
|
可以看到,在Netty中使用了protoBuf之后,我们接受数据与响应数据的协议就是.proto文件生成java对象,这极大的简化了自定义协议的开发。