图文并茂剖析Netty编解码以及背后的设计理念
点击上方“中间件兴趣圈”,选择“设为星标”
本文主要介绍网络通信中与通信息息相关的重要部分:通信协议的底层实现原理:编码与解码。
温馨提示:源码分析或许比较枯燥,在浏览的过程中建议重点关注黑色字体与流程图,是根据源码进行的提炼,突出源码背后的设计理念。
1、Netty4编码解码概述
Netty中定义的编码解码器核心类图如下:
Decoder(解码器) 继承自 Inbound 事件处理器,而Encoder(编码器)继承自Outbound事件处理器。
其实不难理解,以服务端接收请求、返回响应结果的视角来看这个问题:
当服务端通过网络IO接收到字节序列时,从底层网络套接字中将字节流读取到接受缓存区(ByteBuf),服务端的职责首先需要从二进制流中解码出一个完整的请求,然后“读懂请求”的含义进行对应的业务逻辑处理,处理完毕后首先需要将响应结果(通常为JSON字符串)编码成二级制流,通过网络进行传递,客户端收到二进制流后同样进行解码。
解码:针对的是输入,故继承 InBound 入端事件。
编码:针对的是输出,故继承 OutBound 出端事件。
了解了上述基本点后,接下来对上述核心类一一做个介绍:
ByteToMessageDecoder
解码器,将字节流解码成消息(message)。MessageToByteEncoder
编码器,将消息(message)编码成字节流。MessageToMessageEncoder
编码器,将消息编码成”另一种消息“,更通用,”另一种消息“由泛型指定。MessageToMessageDecoder
解码器,将消息解码成“另一种消息”,更通用,“另一种消息”由泛型指定。
下面介绍Netty4自带的协议解码器,是ByteToMessageDecoder的子类。
LineBasedFrameDecoder
基于换行符的分隔符,使用\n或\r\n分隔符来标志一个字节序列的结束。DelimiterBasedFrameDecoder
基于自定义的分隔符,使用定义的分隔符来标志一个字节序列的结束。FixedLengthFrameDecoder
固定长度的编码器。在实际使用时,如果单条消息不足定义的长度,通常需要人为填充。LengthFieldBasedFrameDecoder
基于长度字段的协议,通过指定一个长度字段,该字段的存储字节固定,例如3个字节或4个字节等,然后该字段中存储消息的长度,这样在解码时可以非常方便的判断一条消息的长度,这是一个非常经典的client-server协议格式,下面会对其进行详细解读。
2、源码分析解码器实现原理
ByteToMessageDecoder 是 Netty 解码器实现的基类,典型的模板设计模式。
解码器引入的目的是为了解决网络编程中的“粘包问题”,网络传输基于字节流,客户端多个线程通过一条长连接向服务端发送多个请求,服务端在处理命令之前如何正确拆解出一条完整的请求信息呢?
例如客户端A的三个线程t1、t2、t3 使用同一条连接(类比Dubbo客户端)发送了3个请求,内容分别为 A, BCD, E 。
服务端基于NIO来处理,当请求陆续到达服务端的接受缓存区,NIO 读事件触发,可能第一次网络读,从网络中读取的内容为AB字节序列(包含第一个请求包全部,第二个请求包部分),紧接着再读取CDE序列,如果服务端每接受到一部分数据就当成一个完整的请求去处理的话,明显与客户端原始请求存在差别。
故为了解决服务端、客户端能对同一个字节流具有相同的理解语义,所谓的通信协议因此诞生了,通俗一点就是客户端、服务端如果界定一个完整请求包。
最常见的几种协议:
每一行一个数据包,即在每一个请求包最后以 /r/n 结尾
固定长度,请求内容不足使用特殊字符填充
协议头 + 协议体 ,其中协议头定长,并且内部会含有一个表示包长度的字段。
上述具体协议,将在下篇文章中如何定制私有化协议(编码解码)
接下来将通过阅读源码的方式探究Netty中解码的实现原理,并总结其核心设计关键点。
ByteToMessageDecoder Netty 网络解码器的模板父类, Netty 的扩展是基于事件链机制,即解码器实现的是 InBound 事件处理器。
在阅读解码器实现原理的同时,大家可以关注一个解码器实现的事件方法,再次感悟一下不同场景应该选用实现哪个事件方法。
2.1 channelRead
通道读时间,Netty底层通过Nio Socket 读取到的字节序列后通过传播 channelRead 事件,让上层的事件处理器对接受到的数据进行处理,解码器的职责就是从二进制流中解码出一条条消息。
其处理的代码如下图所示:
上面的实现要点如下:
代码@1:对待处理数据类型进行判断,如果是ByteBuf,则尝试从流中解码请求,如果不是合适的类型,直接调用ctx.fireChannelRead 方法继续向事件链进行传播。
代码@2:构造CodecOutputList out对象,用来存储经过该解码器解码出来的的消息,其内部数据结构为List。
代码@3:如果该解码器中的接收缓冲区(累积缓存区)为空,表示第一次接码消息,设置 first 为 true,并直接将接收到的数据设置为接收缓存区。
代码@4:如果当前累积缓存区不为空,需要计算累积缓存区是否能容纳当前接收到的数据,如果无法存储,则需要对累积缓存区进行扩容,扩容的套路就是先申请一个容量大的缓存区,然后将原先的累积缓存区中的数据复制到新的缓存区,然后释放旧的缓存区。
代码@5:调用callDecode方法对累积缓存区中的数据,进行尝试解码,将解码后的结果存放在out对象中(稍后会对该方法详细进行讲解)。
在调用完用户自定义的协议解码后,开始进行资源的回收逻辑。
代码@6:如果累积缓存区不为空,并且读写缓存区中所有的数据已全部处理,重置numReads与累积缓存区cumulation。
代码@7:如果 numReads 超过 discardAfterReads,需要对累积缓存去进行压缩
设计目的:主要是避免内存泄漏,节省内存空间。numReads 表示的含义是对累积缓存区解码的次数,如果多次解码都未全部将累积缓存区全部处理完成,当新的数据到达累积缓存区,极大可能需要进行扩容,从而造成累积缓存区的膨胀,如果不丢弃已处理的数据,及时释放内存空间,避免扩容,否则会导致累积缓存区无限扩容,内存资源得到极大的消耗。
代码@8:将解码后的请求继续向事件链进行传播,例如业务处理器,业务处理器可以基于请求对象进行编码的根本原因就是首先进入的解码器,解码出一个一个请求后,业务处理器根据请求进行对应的业务逻辑处理。
代码@9:处理完后,对out结果list对象进行回收,这里使用了Netty的对象缓存机制(对象池)。
接下来探究一下 callDecode 的核心实现逻辑:
该方法的实现要点如下:
代码@1:首先对参数进行一个详细介绍:
ChannelHandlerContext ctx:事件处理器链当前处理器的上下文环境。
ByteBuf in:累积缓存区。
List< Object > out:解码后的结果列表。
代码@2:while (in.isReadable()) ,NIO读取经典写法,判断读缓存区是否还有可读字节,从@3到@8都是对该缓存区的处理。
代码@3:由于这里处于一个循环中,一次循环后如果out解码结果列表不为空,会立即将解码后的请求通过调用 fireChannelRead 向后面的事件处理其传播。
代码@4:oldInputLength,当前累积缓存区可读大小。
代码@5:decode 该方法是一个抽象方法,尝试从累积缓存区中解码出完整的请求,由具体的协议实现类去实现。
代码@6,7:如果累积缓存区中不包含一条完整的请求,本次解码结束,等待更多数据到达接受缓存区(下一次读事件触发,继续通过网络读API从Socket中读取字节流)。
代码@8:如果singleDecode=true,表示不支持多次解码,故跳出。
为了加深理解上述流程,Netty 解码器的核心实现流程如下:
2.3 channelReadComplete 事件
通道读完成事件,这是每一次读就绪事件处理完成后,会传播该事件。
在每一次读处理完成后,Netty为了保证累积缓存区不至于浪费空间,进行一次压缩,其设计理念在上文已提到。
2.4 channelInactive事件
通道在非激活状态时会触发该事件。
代码@1:尝试通过调用 channelInputClosed方法最后尝试进行解码。
代码@2:如果累积缓存区不为空,释放累积缓存区。
代码@3:传播一次通道读事件。
代码@4:如果代码@1在通道非激活时还解码到新数据了,则传播一次通道完成度事件。
代码@5:根据callChannelInactive参数,决定是否传播通道非激活事件。
代码@1:如果累积缓冲区不为空,则调用callDecode方法,对累积缓存区进行解码,因为累积缓存区中的数据的读取已经和底层网络通道无关了,通道关闭后,该部分数据还是要尽量处理。
代码@2:再解码一次,由于这个方法,是直接调用抽象方法decode,最终解码的结果放在out中,解码后,如果有消息,最终还会触发一次通道读事件和通道读完成事件。
2.5 handlerRemoved 事件
handlerRemoved事件,该事件的触发有两种情况:
在调用handlerAdd事件失败后,接着调用handlerRemoved事件。
在通道关闭后,DefaultChannelPipeline 的 HeadContext 的 channelUnregistered 中传播完通道事件取消注册事件后,会销毁注册在该通道上的事件注册器,此时也会触发handlerRemoved事件。
从实现来看,也非常简单,就是将累积缓存区中未处理的数据传播到其下游的事件处理器,传播之后再从事件链中移除,体现了其“高度负责”的一面。
对于解码的核心设计理念再做一个总结:
引入累积缓存区,存储从网络底层接受的数据。
对累积缓存区中的数据尝试解码,如果能解码出一条请求,就解码并将数据传入到后续处理器。
如果累积缓存区中不包含一条完整的消息,则结束本次解码,等待后续更多的数据到达缓存区。
那问题又来了,如何判别累积缓存区中是否包含一条完整的消息呢?如何进行协议的设计呢?
此部分内容将在下文:如何使用Netty设计一款通信协议。
3、源码分析编码器实现原理
Netty将消息(请求对象、响应结果) 按特定格式转换为二进制流。
MessageToByteEncoder的核心类图如下:
其核心属性如下:
private final TypeParameterMatcher matcher
参数类型匹配器,其实就是匹配MessageToByteEncoder的泛型参数。private final boolean preferDirect
在解码时,是否倾向与使用堆外内存。
MessageToByteEncoder是outbound处理器,只需 wrtie 事件做处理。
代码@1:如果待处理的对象类型符合该编码器期待的类型,则对数据进行编码,否则直接调用ctx.write方法@6,传播write事件。
代码@2:根据是否使用堆外内存,使用内存分配器分配堆内存或堆外内存, 其bufer默认的大小为256字节。
代码@3:根据协议将数据编码到ByteBuf中,由协议设计者去实现。
代码@4:对输入参数进行回收。因为经过该方法的处理,已经将输入参数转换为其他形式的数据,该数据的生命周期结束了,尝试回收(引用计数法)。
代码@5:如果byteBuf可读,则将这些数据传播到下一个事件处理器处理。
代码@7:对内存进行回收。
本文就介绍到这里了,通过原理的讲解,大家对Netty的编码解码是否有了清晰的认知,如果让你来设计一个简易 Dubbo RPC 框架,是否能 Hold 住,关于这些问题,我们下期见。
欢迎关注『中间件兴趣圈』,获取12个JAVA主流中间件的源码剖析专栏。