netty 传输数据为啥要序列化

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了netty 传输数据为啥要序列化相关的知识,希望对你有一定的参考价值。

参考技术A import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioserverSocketChannel;
import io.netty.handler.codec.serialization.ClassResolvers;
import io.netty.handler.codec.serialization.ObjectDecoder;
import io.netty.handler.codec.serialization.ObjectEncoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

public class Server

public Server()


public void bind(int port) throws Exception
// 配置NIO线程组
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try
// 服务器辅助启动类配置
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.INFO))
.childHandler(new ChildChannelHandler())//
.option(ChannelOption.SO_BACKLOG, 1024) // 设置tcp缓冲区 // (5)
.childOption(ChannelOption.SO_KEEPALIVE, true); // (6)
// 绑定端口 同步等待绑定成功
ChannelFuture f = b.bind(port).sync(); // (7)
// 等到服务端监听端口关闭
f.channel().closeFuture().sync();
finally
// 优雅释放线程资源
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();



/**
* 网络事件处理器
*/
private class ChildChannelHandler extends ChannelInitializer<SocketChannel>
@Override
protected void initChannel(SocketChannel ch) throws Exception
// 添加对象解码器 负责对序列化POJO对象进行解码 设置对象序列化最大长度为1M 防止内存溢出
// 设置线程安全的WeakReferenceMap对类加载器进行缓存 支持多线程并发访问 防止内存溢出
ch.pipeline().addLast(
new ObjectDecoder(1024 * 1024, ClassResolvers
.weakCachingConcurrentResolver(this.getClass()
.getClassLoader())));
// 添加对象编码器 在服务器对外发送消息的时候自动将实现序列化的POJO对象编码
ch.pipeline().addLast(new ObjectEncoder());
// 处理网络IO
ch.pipeline().addLast(new ServerHandler());



public static void main(String[] args) throws Exception
new Server().bind(9999);


5、服务端处理器代码
[java] view plain copy print?
import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;

public class ServerHandler extends ChannelHandlerAdapter
// 用于获取客户端发送的信息
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg)
throws Exception
// 用于获取客户端发来的数据信息
Request body = (Request) msg;
System.out.println("Server接受的客户端的信息 :" + body.toString());

// 会写数据给客户端
Response response = new Response(Integer.parseInt(body.getUrl()),
"xiaoming");
// 当服务端完成写操作后,关闭与客户端的连接
ctx.writeAndFlush(response);
// .addListener(ChannelFutureListener.CLOSE);

// 当有写操作时,不需要手动释放msg的引用
// 当只有读操作时,才需要手动释放msg的引用


@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
throws Exception
// cause.printStackTrace();
ctx.close();

Netty编解码框架分析

1. 背景

1.1. 编解码技术

通常我们也习惯将编码(Encode)称为序列化(serialization),它将对象序列化为字节数组,用于网络传输、数据持久化或者其它用途。

反之,解码(Decode)/反序列化(deserialization)把从网络、磁盘等读取的字节数组还原成原始对象(通常是原始对象的拷贝),以方便后续的业务逻辑操作。

进行远程跨进程服务调用时(例如RPC 调用),需要使用特定的编解码技术,对需要进行网络传输的对象做编码或者解码,以便完成远程调用。

1.2. 常用的编解码框架

1.2.1. Java 序列化

相信大多数Java 程序员接触到的第一种序列化或者编解码技术就是Java默认提供的序列化机制, 需要序列化的Java 对象只需要实现

java.io.Serializable 接口并生成序列化ID , 这个类就能够通过

java.io.ObjectInput 和java.io.ObjectOutput 序列化和反序列化。

由于使用简单,开发门槛低,Java 序列化得到了广泛的应用,但是由于它自身存在很多缺点,因此大多数的RPC 框架并没有选择它。Java 序列化的主要缺点如下:

1) 无法跨语言:是Java 序列化最致命的问题。对于跨进程的服务调用,服务提供者可能会使用C++或者其它语言开发,当我们需要和异构语言进程交互时,Java 序列化就难以胜任。由于Java 序列化技术是Java 语言内部的私有协议,其它语言并不支持,对于用户来说它完全是黑盒。Java 序列化后的字节数组,别的语言无法进行反序列化,这就严重阻碍了它的应用范围;

2) 序列化后的码流太大: 例如使用二进制编解码技术对同一个复杂的POJO 对象进行编码,它的码流仅仅为Java 序列化之后的20%左右;目前主流的编解码框架,序列化之后的码流都远远小于原生的Java 序列化;

3) 序列化效率差:在相同的硬件条件下、对同一个POJO 对象做100W 次序列化,二进制编码和Java 原生序列化的性能对比测试如下图所示:Java 原生序列化的耗时是二进制编码的16.2 倍,效率非常差。


图1-1 二进制编码和Java 原生序列化性能对比

1.2.2. Google 的Protobuf

Protobuf 全称Google Protocol Buffers,它由谷歌开源而来,在谷歌内部久经考验。它将数据结构以.proto 文件进行描述,通过代码生成工具可以生成对应数据结构的POJO 对象和Protobuf 相关的方法和属性。

它的特点如下:

1) 结构化数据存储格式(XML,JSON 等);

2) 高效的编解码性能;

3) 语言无关、平台无关、扩展性好;

4) 官方支持Java、C++和Python 三种语言。

首先我们来看下为什么不使用XML,尽管XML 的可读性和可扩展性非常好,也非常适合描述数据结构,但是XML 解析的时间开销和XML 为了可读性而牺牲的空间开销都非常大,因此不适合做高性能的通信协议。Protobuf 使用二进制编码,在空间和性能上具有更大的优势。

Protobuf 另一个比较吸引人的地方就是它的数据描述文件和代码生成机制,利用数据描述文件对数据结构进行说明的优点如下:

1) 文本化的数据结构描述语言,可以实现语言和平台无关,特别适合异构系统

间的集成;

2) 通过标识字段的顺序,可以实现协议的前向兼容;

3) 自动代码生成,不需要手工编写同样数据结构的C++和Java 版本;

4) 方便后续的管理和维护。相比于代码,结构化的文档更容易管理和维护。


1.2.3. Apache 的Thrift

Thrift 源于Facebook,在2007 年Facebook 将Thrift 作为一个开源项目提交给Apache 基金会。对于当时的Facebook 来说,创造Thrift 是为了解决Facebook各系统间大数据量的传输通信以及系统之间语言环境不同需要跨平台的特性,因此Thrift 可以支持多种程序语言,如C++、C#、Cocoa、Erlang、Haskell、Java、Ocami、Perl、PHP、Python、Ruby 和Smalltalk。

在多种不同的语言之间通信,Thrift 可以作为高性能的通信中间件使用,它支持数据(对象)序列化和多种类型的RPC 服务。Thrift 适用于静态的数据交换,需要先确定好它的数据结构,当数据结构发生变化时,必须重新编辑IDL 文件,生成代码和编译,这一点跟其他IDL 工具相比可以视为是Thrift 的弱项。Thrift适用于搭建大型数据交换及存储的通用工具,对于大型系统中的内部数据传输,相对于JSON 和XML 在性能和传输大小上都有明显的优势。

Thrift 主要由5 部分组成:

1) 语言系统以及IDL 编译器:负责由用户给定的IDL 文件生成相应语言的接口

代码;

2) TProtocol:RPC 的协议层,可以选择多种不同的对象序列化方式,如JSON

和Binary;

3) TTransport:RPC 的传输层,同样可以选择不同的传输层实现,如socket、

NIO、MemoryBuffer 等;

4) TProcessor:作为协议层和用户提供的服务实现之间的纽带,负责调用服务

实现的接口;

5) TServer:聚合TProtocol、TTransport 和TProcessor 等对象。

我们重点关注的是编解码框架,与之对应的就是TProtocol。由于Thrift 的RPC 服务调用和编解码框架绑定在一起,所以,通常我们使用Thrift 的时候会采取RPC 框架的方式。但是,它的TProtocol 编解码框架还是可以以类库的方式独

立使用的。

与Protobuf 比较类似的是,Thrift 通过IDL 描述接口和数据结构定义,它支持8 种Java 基本类型、Map、Set 和List,支持可选和必选定义,功能非常强大。因为可以定义数据结构中字段的顺序,所以它也可以支持协议的前向兼容。

Thrift 支持三种比较典型的编解码方式:

1) 通用的二进制编解码;

2) 压缩二进制编解码;

3) 优化的可选字段压缩编解码。

由于支持二进制压缩编解码,Thrift 的编解码性能表现也相当优异,远远超过Java 序列化和RMI 等。

1.2.4. JBoss Marshalling

JBoss Marshalling 是一个Java 对象的序列化API 包,修正了JDK 自带的序列化包的很多问题,但又保持跟java.io.Serializable 接口的兼容;同时增加了一些可调的参数和附加的特性,并且这些参数和特性可通过工厂类进行配置。

相比于传统的Java 序列化机制,它的优点如下:

1) 可插拔的类解析器,提供更加便捷的类加载定制策略,通过一个接口即可实

现定制;

2) 可插拔的对象替换技术,不需要通过继承的方式;

3) 可插拔的预定义类缓存表,可以减小序列化的字节数组长度,提升常用类型

的对象序列化性能;

4) 无须实现java.io.Serializable 接口,即可实现Java 序列化;

5) 通过缓存技术提升对象的序列化性能。

相比于前面介绍的两种编解码框架,JBoss Marshalling 更多是在JBoss 内部使用,应用范围有限。

1.2.5. 其它编解码框架

除了上述介绍的编解码框架和技术之外,比较常用的还有MessagePack、kryo、hession 和Json 等。限于篇幅所限,不再一一枚举,感兴趣的朋友可以自行查阅相关资料学习。

2. Netty 编解码框架

2.1. Netty 为什么要提供编解码框架

作为一个高性能的异步、NIO 通信框架,编解码框架是Netty 的重要组成部分。尽管站在微内核的角度看,编解码框架并不是Netty 微内核的组成部分,但是通过ChannelHandler 定制扩展出的编解码框架却是不可或缺的。

下面我们从几个角度详细谈下这个话题,首先一起看下Netty 的逻辑架构图:

Netty编解码框架分析

图2-1 Netty 逻辑架构图

从网络读取的inbound 消息,需要经过解码,将二进制的数据报转换成应用层协议消息或者业务消息,才能够被上层的应用逻辑识别和处理;同理,用户发送到网络的outbound 业务消息,需要经过编码转换成二进制字节数组(对于Netty 就是ByteBuf)才能够发送到网络对端。编码和解码功能是NIO 框架的有机组成部分,无论是由业务定制扩展实现,还是NIO 框架内置编解码能力,该功能是必不可少的。

为了降低用户的开发难度,Netty 对常用的功能和API 做了装饰,以屏蔽底层的实现细节。编解码功能的定制,对于熟悉Netty 底层实现的开发者而言,直接基于ChannelHandler 扩展开发,难度并不是很大。但是对于大多数初学者或者不愿意去了解底层实现细节的用户,需要提供给他们更简单的类库和API,而不是ChannelHandler。

Netty 在这方面做得非常出色,针对编解码功能,它既提供了通用的编解码框架供用户扩展,又提供了常用的编解码类库供用户直接使用。在保证定制扩展性的基础之上,尽量降低用户的开发工作量和开发门槛,提升开发效率。Netty 预置的编解码功能列表如下:base64、Protobuf、JBoss Marshalling、spdy 等。

Netty编解码框架分析

2.2. 常用的解码器

2.2.1. LineBasedFrameDecoder 解码器

LineBasedFrameDecoder 是回车换行解码器,如果用户发送的消息以回车换行符作为消息结束的标识,则可以直接使用Netty 的LineBasedFrameDecoder对消息进行解码, 只需要在初始化Netty 服务端或者客户端时将LineBasedFrameDecoder 正确的添加到ChannelPipeline 中即可,不需要自己重新实现一套换行解码器。

LineBasedFrameDecoder 的工作原理是它依次遍历ByteBuf 中的可读字节,判断看是否有“\n”或者“\r\n”,如果有,就以此位置为结束位置,从可读索引到结束位置区间的字节就组成了一行。它是以换行符为结束标志的解码器,支持携带结束符或者不携带结束符两种解码方式,同时支持配置单行的最大长度。如果连续读取到最大长度后仍然没有发现换行符,就会抛出异常,同时忽略掉之前读到的异常码流。防止由于数据报没有携带换行符导致接收到ByteBuf 无限制积压,引起系统内存溢出。

它的使用效果如下:

解码之前:

+------------------------------------------------------------------+

接收到的数据报

“This is a netty example for using the nio framework.\r\n When you“

+------------------------------------------------------------------+

解码之后的ChannelHandler 接收到的Object 如下:

+------------------------------------------------------------------+

解码之后的文本消息

“This is a netty example for using the nio framework.“

+------------------------------------------------------------------+

通常情况下,LineBasedFrameDecoder 会和StringDecoder 配合使用,组合成按行切换的文本解码器,对于文本类协议的解析,文本换行解码器非常实用,例如对HTTP 消息头的解析、FTP 协议消息的解析等。

下面我们简单给出文本换行解码器的使用示例:

@Override

1. protected void initChannel(SocketChannel arg0) throws Exception {

2. arg0.pipeline().addLast(new LineBasedFrameDecoder(1024));

3. arg0.pipeline().addLast(new StringDecoder());

4. arg0.pipeline().addLast(new UserServerHandler());

5. }

初始化Channel 的时候, 首先将LineBasedFrameDecoder 添加到ChannelPipeline 中,然后再依次添加字符串解码器StringDecoder,业务Handler。

2.2.2. DelimiterBasedFrameDecoder 解码器

DelimiterBasedFrameDecoder 是分隔符解码器,用户可以指定消息结束的分隔符,它可以自动完成以分隔符作为码流结束标识的消息的解码。回车换行解码器实际上是一种特殊的DelimiterBasedFrameDecoder 解码器。分隔符解码器在实际工作中也有很广泛的应用,笔者所从事的电信行业,很多简单的文本私有协议,都是以特殊的分隔符作为消息结束的标识,特别是对于那些使用长连接的基于文本的私有协议。

分隔符的指定:与大家的习惯不同,分隔符并非以char 或者string 作为构造参数,而是ByteBuf,下面我们就结合实际例子给出它的用法。

假如消息以“$_”作为分隔符,服务端或者客户端初始化ChannelPipeline的代码实例如下:


@Override

1. public void initChannel(SocketChannel ch)

2. throws Exception {

3. ByteBuf delimiter = Unpooled.copiedBuffer("$_"

4. .getBytes());

5. ch.pipeline().addLast(

6. new DelimiterBasedFrameDecoder(1024,

7. delimiter));

8. ch.pipeline().addLast(new StringDecoder());

9. ch.pipeline().addLast(new UserServerHandler());

10. }


首先将“ $_ ” 转换成ByteBuf 对象, 作为参数构造DelimiterBasedFrameDecoder,将其添加到ChannelPipeline 中,然后依次添加字符串解码器(通常用于文本解码)和用户Handler,请注意解码器和Handler 的添加顺序,如果顺序颠倒,会导致消息解码失败。

DelimiterBasedFrameDecoder 原理分析:解码时,判断当前已经读取的ByteBuf 中是否包含分隔符ByteBuf,如果包含,则截取对应的ByteBuf 返回,源码如下:

Netty编解码框架分析

详细分析下indexOf(buffer, delim)方法的实现,代码如下:

Netty编解码框架分析

该算法与Java String 中的搜索算法类似,对于原字符串使用两个指针来进行搜索,如果搜索成功,则返回索引位置,否则返回-1。

2.2.3. FixedLengthFrameDecoder 解码器

FixedLengthFrameDecoder 是固定长度解码器,它能够按照指定的长度对消息进行自动解码,开发者不需要考虑TCP 的粘包/拆包等问题,非常实用。

对于定长消息,如果消息实际长度小于定长,则往往会进行补位操作,它在一定程度上导致了空间和资源的浪费。但是它的优点也是非常明显的,编解码比较简单,因此在实际项目中仍然有一定的应用场景。

利用FixedLengthFrameDecoder 解码器,无论一次接收到多少数据报,它都会按照构造函数中设置的固定长度进行解码, 如果是半包消息,FixedLengthFrameDecoder 会缓存半包消息并等待下个包到达后进行拼包,直到读取到一个完整的包。

假如单条消息的长度是20 字节,使用FixedLengthFrameDecoder 解码器的效果如下:

解码前:

+------------------------------------------------------------------+

接收到的数据报

“HELLO NETTY FOR USER DEVELOPER“

+------------------------------------------------------------------+

解码后:

+------------------------------------------------------------------+

解码后的数据报

“HELLO NETTY FOR USER“

+------------------------------------------------------------------+

2.2.4. LengthFieldBasedFrameDecoder 解码器

了解TCP 通信机制的读者应该都知道TCP 底层的粘包和拆包,当我们在接收消息的时候,显示不能认为读取到的报文就是个整包消息,特别是对于采用非阻塞I/O 和长连接通信的程序。

如何区分一个整包消息,通常有如下4 种做法:

1) 固定长度,例如每120 个字节代表一个整包消息,不足的前面补位。解码器

在处理这类定常消息的时候比较简单,每次读到指定长度的字节后再进行解

码;

2) 通过回车换行符区分消息,例如HTTP 协议。这类区分消息的方式多用于文

本协议;

3) 通过特定的分隔符区分整包消息;

4) 通过在协议头/消息头中设置长度字段来标识整包消息。

前三种解码器之前的章节已经做了详细介绍,下面让我们来一起学习最后一种通用解码器-LengthFieldBasedFrameDecoder。

大多数的协议(私有或者公有),协议头中会携带长度字段,用于标识消息体或者整包消息的长度,例如SMPP、HTTP 协议等。由于基于长度解码需求的通用性, 以及为了降低用户的协议开发难度, Netty 提供了LengthFieldBasedFrameDecoder,自动屏蔽TCP 底层的拆包和粘包问题,只需要传入正确的参数,即可轻松解决“读半包“问题。

下面我们看看如何通过参数组合的不同来实现不同的“半包”读取策略。第一种常用的方式是消息的第一个字段是长度字段,后面是消息体,消息头中只包含一个长度字段。它的消息结构定义如图所示:

Netty编解码框架分析

使用以下参数组合进行解码:

1) lengthFieldOffset = 0;

2) lengthFieldLength = 2;

3) lengthAdjustment = 0;

4) initialBytesToStrip = 0。

解码后的字节缓冲区内容如图所示:

Netty编解码框架分析

通过ByteBuf.readableBytes()方法我们可以获取当前消息的长度,所以解码后的字节缓冲区可以不携带长度字段,由于长度字段在起始位置并且长度为2,所以将initialBytesToStrip 设置为2,参数组合修改为:

1) lengthFieldOffset = 0;

2) lengthFieldLength = 2;

3) lengthAdjustment = 0;

4) initialBytesToStrip = 2。

解码后的字节缓冲区内容如图所示:

Netty编解码框架分析

解码后的字节缓冲区丢弃了长度字段,仅仅包含消息体,对于大多数的协议,解码之后消息长度没有用处,因此可以丢弃。在大多数的应用场景中,长度字段仅用来标识消息体的长度,这类协议通常由消息长度字段+消息体组成,如上图所示的几个例子。但是,对于某些协议,长度字段还包含了消息头的长度。在这种应用场景中, 往往需要使用lengthAdjustment 进行修正。由于整个消息(包含消息头)的长度往往大于消息体的长度,所以,lengthAdjustment 为负数。图2-6 展示了通过指定lengthAdjustment

字段来包含消息头的长度:

1) lengthFieldOffset = 0;

2) lengthFieldLength = 2;

3) lengthAdjustment = -2;

4) initialBytesToStrip = 0。

Netty编解码框架分析

由于协议种类繁多,并不是所有的协议都将长度字段放在消息头的首位,当标识消息长度的字段位于消息头的中间或者尾部时,需要使用lengthFieldOffset字段进行标识,下面的参数组合给出了如何解决消息长度字段不在首位的问题:

1) lengthFieldOffset = 2;

2) lengthFieldLength = 3;

3) lengthAdjustment = 0;

4) initialBytesToStrip = 0。

其中lengthFieldOffset 表示长度字段在消息头中偏移的字节数,lengthFieldLength表示长度字段自身的长度,解码效果如下:

Netty编解码框架分析

由于消息头1 的长度为2,所以长度字段的偏移量为2;消息长度字段Length为3,所以lengthFieldLength 值为3。由于长度字段仅仅标识消息体的长度,所以lengthAdjustment 和initialBytesToStrip 都为0。

最后一种场景是长度字段夹在两个消息头之间或者长度字段位于消息头的中间,前后都有其它消息头字段,在这种场景下如果想忽略长度字段以及其前面的其它消息头字段,则可以通过initialBytesToStrip 参数来跳过要忽略的字节长度,它的组合配置示意如下:

1) lengthFieldOffset = 1;

2) lengthFieldLength = 2;

3) lengthAdjustment = 1;

4) initialBytesToStrip = 3。

解码之前的码流(16 字节):

Netty编解码框架分析

由于HDR1 的长度为1,所以长度字段的偏移量lengthFieldOffset 为1;长度字段为2 个字节,所以lengthFieldLength 为2。由于长度字段是消息体的长度,解码后如果携带消息头中的字段,则需要使用lengthAdjustment 进行调整,此处它的值为1,代表的是HDR2 的长度,最后由于解码后的缓冲区要忽略长度字段和HDR1 部分,所以lengthAdjustment 为3。解码后的结果为13 个字节,HDR1和Length 字段被忽略。

事实上,通过4 个参数的不同组合,可以达到不同的解码效果,用户在使用过程中可以根据业务的实际情况进行灵活调整。由于TCP 存在粘包和组包问题,所以通常情况下用户需要自己处理半包消息。利用LengthFieldBasedFrameDecoder 解码器可以自动解决半包问题,它的习惯用法如下:

pipeline.addLast("frameDecoder", new LengthFieldBasedFrameDecoder(65536,0,

2));

pipeline.addLast("UserDecoder", new UserDecoder());

在pipeline 中增加LengthFieldBasedFrameDecoder 解码器,指定正确的参数组合,它可以将Netty 的ByteBuf 解码成整包消息,后面的用户解码器拿到的就是个完整的数据报,按照逻辑正常进行解码即可,不再需要额外考虑“读半包”问题,降低了用户的开发难度。

2.3.常用的编码器

Netty 并没有提供与2.2 章节匹配的编码器,原因如下:

1) 2.2 章节介绍的4 种常用的解码器本质都是解析一个完整的数据报给后端,主要用于解决TCP 底层粘包和拆包;对于编码,就是将POJO 对象序列化为ByteBuf,不需要与TCP 层面打交道,也就不存在半包编码问题。从应用场景和需要解决的实际问题角度看,双方是非对等的;

2) 很难抽象出合适的编码器,对于不同的用户和应用场景,序列化技术不尽相同,在Netty 底层统一抽象封装也并不合适。

Netty 默认提供了丰富的编解码框架供用户集成使用,本文对较常用的Java序列化编码器进行讲解。其它的编码器,实现方式大同小异。

2.3.1. ObjectEncoder 编码器

ObjectEncoder 是Java 序列化编码器,它负责将实现Serializable 接口的对象序列化为byte [],然后写入到ByteBuf 中用于消息的跨网络传输。

下面我们一起分析下它的实现:

首先,我们发现它继承自MessageToByteEncoder,它的作用就是将对象编码

成ByteBuf:

Netty编解码框架分析

如果要使用Java 序列化,对象必须实现Serializable 接口,因此,它的泛型类型为Serializable。

MessageToByteEncoder 的子类只需要实现encode(ChannelHandlerContext ctx,I msg, ByteBuf out)方法即可,下面我们重点关注encode 方法的实现:

Netty编解码框架分析

首先创建ByteBufOutputStream 和ObjectOutputStream,用于将Object 对象序列化到ByteBuf 中,值得注意的是在writeObject 之前需要先将长度字段(4 个字节)预留,用于后续长度字段的更新。

依次写入长度占位符(4 字节)、序列化之后的Object 对象,之后根据ByteBuf的writeIndex 计算序列化之后的码流长度,最后调用ByteBuf 的setInt(int index, intvalue)更新长度占位符为实际的码流长度。

有个细节需要注意,更新码流长度字段使用了setInt 方法而不是writeInt,原因就是setInt 方法只更新内容,并不修改readerIndex 和writerIndex。

3. Netty 编解码框架可定制性

3.1.解码器

3.1.1. ByteToMessageDecoder 抽象解码器

使用NIO 进行网络编程时,往往需要将读取到的字节数组或者字节缓冲区解码为业务可以使用的POJO 对象。为了方便业务将ByteBuf 解码成业务POJO对象,Netty 提供了ByteToMessageDecoder 抽象工具解码类。

用户自定义解码器继承ByteToMessageDecoder,只需要实现void decode(ChannelHandler Context ctx, ByteBuf in, List<Object> out)抽象方法即可完成ByteBuf 到POJO 对象的解码。

由于ByteToMessageDecoder 并没有考虑TCP 粘包和拆包等场景,用户自定义解码器需要自己处理“读半包”问题。正因为如此,大多数场景不会直接继承ByteToMessageDecoder,而是继承另外一些更高级的解码器来屏蔽半包的处理。

实际项目中, 通常将LengthFieldBasedFrameDecoder 和ByteToMessageDecoder 组合使用,前者负责将网络读取的数据报解码为整包消息,后者负责将整包消息解码为最终的业务对象。

除了和其它解码器组合形成新的解码器之外,ByteToMessageDecoder 也是很多基础解码器的父类,它的继承关系如下图所示:

Netty编解码框架分析

3.1.2. MessageToMessageDecoder 抽象解码器

MessageToMessageDecoder 实际上是Netty 的二次解码器,它的职责是将一个对象二次解码为其它对象。为什么称它为二次解码器呢?我们知道,从SocketChannel 读取到的TCP 数据报是ByteBuffer,实际就是字节数组。我们首先需要将ByteBuffer 缓冲区中的数据报读取出来,并将其解码为Java 对象;然后对Java 对象根据某些规则做二次解码,将其解码为另一个POJO 对象。因为MessageToMessageDecoder 在ByteToMessageDecoder 之后,所以称之为二次解码器。

二次解码器在实际的商业项目中非常有用,以HTTP+XML 协议栈为例,第一次解码往往是将字节数组解码成HttpRequest 对象,然后对HttpRequest 消息中的消息体字符串进行二次解码,将XML 格式的字符串解码为POJO 对象,这就用到了二次解码器。类似这样的场景还有很多,不再一一枚举。

事实上,做一个超级复杂的解码器将多个解码器组合成一个大而全的MessageToMessageDecoder 解码器似乎也能解决多次解码的问题,但是采用这种方式的代码可维护性会非常差。例如,如果我们打算在HTTP+XML 协议栈中增加一个打印码流的功能,即首次解码获取HttpRequest 对象之后打印XML 格式的码流。如果采用多个解码器组合,在中间插入一个打印消息体的Handler 即可,不需要修改原有的代码;如果做一个大而全的解码器,就需要在解码的方法中增加打印码流的代码,可扩展性和可维护性都会变差。

用户的解码器只需要实现void decode(ChannelHandlerContext ctx, I msg,List<Object> out)抽象方法即可,由于它是将一个POJO 解码为另一个POJO,所以一般不会涉及到半包的处理,相对于ByteToMessageDecoder 更加简单些。它

的继承关系图如下所示:

Netty编解码框架分析

3.2.编码器

3.2.1. MessageToByteEncoder 抽象编码器

MessageToByteEncoder 负责将POJO 对象编码成ByteBuf,用户的编码器继承Message ToByteEncoder,实现void encode(ChannelHandlerContext ctx, I msg,ByteBuf out)接口接口,示例代码如下:

public class IntegerEncoder extends MessageToByteEncoder<Integer> {

@Override

public void encode(ChannelHandlerContext ctx, Integer msg,ByteBuf out)

throws Exception {

out.writeInt(msg);

}

}

它的实现原理如下:调用write 操作时,首先判断当前编码器是否支持需要发送的消息,如果不支持则直接透传;如果支持则判断缓冲区的类型,对于直接内存分配ioBuffer(堆外内存),对于堆内存通过heapBuffer 方法分配,源码如下:

Netty编解码框架分析

编码完成之后,调用ReferenceCountUtil 的release 方法释放编码对象msg。对编码后的ByteBuf 进行以下判断:

1) 如果缓冲区包含可发送的字节,则调用ChannelHandlerContext 的write 方法发送ByteBuf;

2) 如果缓冲区没有包含可写的字节,则需要释放编码后的ByteBuf,写入一个空的ByteBuf 到ChannelHandlerContext 中。

发送操作完成之后,在方法退出之前释放编码缓冲区ByteBuf 对象。

3.2.2. MessageToMessageEncoder 抽象编码器

将一个POJO 对象编码成另一个对象,以HTTP+XML 协议为例,它的一种实现方式是:先将POJO 对象编码成XML 字符串,再将字符串编码为HTTP 请求或者应答消息。对于复杂协议,往往需要经历多次编码,为了便于功能扩展,可以通过多个编码器组合来实现相关功能。

用户的解码器继承MessageToMessageEncoder 解码器, 实现voidencode(Channel HandlerContext ctx, I msg, List<Object> out)方法即可。注意,它与MessageToByteEncoder 的区别是输出是对象列表而不是ByteBuf,示例代码如下:

public class IntegerToStringEncoder extends MessageToMessageEncoder

<Integer>

{

@Override

public void encode(ChannelHandlerContext ctx, Integer message,

List<Object> out)

throws Exception

{

out.add(message.toString());

}

}

MessageToMessageEncoder 编码器的实现原理与之前分析的MessageToByteEncoder 相似,唯一的差别是它编码后的输出是个中间对象,并非最终可传输的ByteBuf。

简单看下它的源码实现:创建RecyclableArrayList 对象,判断当前需要编码的对象是否是编码器可处理的类型, 如果不是, 则忽略, 执行下一个ChannelHandler 的write 方法。

具体的编码方法实现由用户子类编码器负责完成, 如果编码后的RecyclableArrayList 为空,说明编码没有成功,释放RecyclableArrayList 引用。

如果编码成功,则通过遍历RecyclableArrayList,循环发送编码后的POJO对象,代码如下所示:

Netty编解码框架分析

3.2.3. LengthFieldPrepender 编码器

如果协议中的第一个字段为长度字段,Netty 提供了LengthFieldPrepender编码器,它可以计算当前待发送消息的二进制字节长度,将该长度添加到ByteBuf的缓冲区头中,如图所示:

Netty编解码框架分析

LengthFieldPrepender 工作原理分析如下:首先对长度字段进行设置,如果需要包含消息长度自身,则在原来长度的基础之上再加上lengthFieldLength 的长度。

如果调整后的消息长度小于0,则抛出参数非法异常。对消息长度自身所占的字节数进行判断,以便采用正确的方法将长度字段写入到ByteBuf 中,共有以下6 种可能:

1) 长度字段所占字节为1:如果使用1 个Byte 字节代表消息长度,则最大长度需要小于256 个字节。对长度进行校验,如果校验失败,则抛出参数非法异常;若校验通过,则创建新的ByteBuf 并通过writeByte 将长度值写入到ByteBuf 中;

2) 长度字段所占字节为2:如果使用2 个Byte 字节代表消息长度,则最大长度需要小于65536 个字节,对长度进行校验,如果校验失败,则抛出参数非法异常;若校验通过,则创建新的ByteBuf 并通过writeShort 将长度值写入到ByteBuf 中;

3) 长度字段所占字节为3:如果使用3 个Byte 字节代表消息长度,则最大长度需要小于16777216 个字节,对长度进行校验,如果校验失败,则抛出参数非法异常;若校验通过,则创建新的ByteBuf 并通过writeMedium 将长度值写入到ByteBuf 中;

4) 长度字段所占字节为4:创建新的ByteBuf,并通过writeInt 将长度值写入到ByteBuf 中;

5) 长度字段所占字节为8:创建新的ByteBuf,并通过writeLong 将长度值写入到ByteBuf 中;

6) 其它长度值:直接抛出Error。

相关代码如下:


最后将原需要发送的ByteBuf 复制到List<Object> out 中,完成编码:


4. 作者简介

李林锋,2007 年毕业于东北大学,2008 年进入华为公司从事高性能通信软件的设计和开发工作,有7 年NIO 设计和开发经验,精通Netty、Mina 等NIO框架和平台中间件。现任华为软件平台架构部架构师,负责华为下一代中间件、PaaS平台的架构设计,《Netty 权威指南》第1和第2版作者。

对于Netty 学习中遇到的问题,或者认为有价值的Netty 或者NIO 相关案例,可以通过上述几种方式联系我。

以上是关于netty 传输数据为啥要序列化的主要内容,如果未能解决你的问题,请参考以下文章

为啥socket传输对象的时候要将对象序列化?

对象的传递为啥要 序列化 呢

十一.Netty入门到超神系列-Netty使用Protobuf编码解码

intent传对象为啥要序列化

使用 Netty 整合 protobuf

java实体类为啥要实现serlializable接口