前言
在前面,我们介绍了Netty的三大组件:
在这篇文章中,我们将介绍Netty中的通用基类的解码器ByteToMessageDecoder
,在大部分的协议解码中,大量的使用了此基类来构造特定的解码器,可以说是大部分的解码器的基石。
同时在后面会介绍读半包的问题,并且揭示ByteToMessageDecoder
是如何解决半包问题的。
解码器基石
故名思义,ByteToMessageDecoder这个解码器其作用是将Byte数据转换为一个Message可读的消息对象,而具体解码转换成什么对象,由子类决定。
首先,我们来看一下ByteToMessageDecoder这个类的类结构是怎样的
很简单,其不过是一个ChannelInboundHandler,从上一篇文章 pipeline事件传播机制源码分析 中,我们可以知道,一个Inbound会关心一个read事件(一般来说),那么看看ByteToMessageDecoder中是否有相应方法,验证我们的猜想
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
...
}
那么,就以此方法为入口,来分析一下都做了什么工作
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
// 其只关心ByteBuf的消息的解码
if (msg instanceof ByteBuf) {
// 存放解码出来的结果的容器
CodecOutputList out = CodecOutputList.newInstance();
try {
ByteBuf data = (ByteBuf) msg;
// cumulation变量是一个可以累加ByteBuf的ByteBuf
first = cumulation == null;
// 如果是第一次解码
if (first) {
// cumulation直接赋值
cumulation = data;
} else {
// 如果是第n次,将data加入cumulation
// 此时cumulation中有没解码完的旧数据和到来的新数据
cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
}
// 子类实现具体的解码逻辑
callDecode(ctx, cumulation, out);
}
// ...
finally {
// cumulation中的数据若被读完,需要被释放
if (cumulation != null && !cumulation.isReadable()) {
numReads = 0;
cumulation.release();
cumulation = null;
} else if (++ numReads >= discardAfterReads) {
// We did enough reads already try to discard some bytes so we not risk to see a OOME.
// See https://github.com/netty/netty/issues/4275
numReads = 0;
discardSomeReadBytes();
}
int size = out.size();
decodeWasNull = !out.insertSinceRecycled();
// 将out中的数据往下传播给下一个Handler
// 此时out存放的是解码之后的结果
// 若out长度size为空,则不会传播
fireChannelRead(ctx, out, size);
out.recycle();
}
} else {
// 不关心,往下传播即可
ctx.fireChannelRead(msg);
}
}
这里最为关键的是callDecode方法,其做了一些操作判断,然后调用了子类的解码逻辑
protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
try {
// 不断循环,直到无数据可读
// 当然,也会有一些条件主动break循环
while (in.isReadable()) {
// 先判断未解码之前,结果集合的长度
int outSize = out.size();
// 结果集合里有东西,先把结果给传播出去
if (outSize > 0) {
fireChannelRead(ctx, out, outSize);
// 清空结果列表
out.clear();
// Check if this handler was removed before continuing with decoding.
// If it was removed, it is not safe to continue to operate on the buffer.
//
// See:
// - https://github.com/netty/netty/issues/4635
if (ctx.isRemoved()) {
break;
}
// reset outSize值
outSize = 0;
}
// 记录没解码之前读的位置,以便后续统计读了多少数据出去
int oldInputLength = in.readableBytes();
// 调用子类decode
decodeRemovalReentryProtection(ctx, in, out);
// Check if this handler was removed before continuing the loop.
// If it was removed, it is not safe to continue to operate on the buffer.
//
// See https://github.com/netty/netty/issues/1664
if (ctx.isRemoved()) {
break;
}
// 如果旧的结果列表的长度和现在的长度一样
// 证明没有解码到东西,证明了现在的消息还不够可以解码出一个数据
if (outSize == out.size()) {
// 如果ByteBuf也没被读过,证明为半包数据(不完整)
if (oldInputLength == in.readableBytes()) {
break;
} else {
// 如果到了这里,证明ByteBuf读了一些数据出去,但结果并没有增加
// 这说明了刚刚只是丢弃了一些不读的数据,再解码一次
continue;
}
}
// 代码到这里还是没读过,是异常的现象
if (oldInputLength == in.readableBytes()) {
throw new DecoderException(
StringUtil.simpleClassName(getClass()) +
".decode() did not read anything but decoded a message.");
}
if (isSingleDecode()) {
break;
}
}
} catch (DecoderException e) {
throw e;
} catch (Exception cause) {
throw new DecoderException(cause);
}
}
总之,以上过程循环往复处理,直到没有数据可读,或是判断出是半包数据(不完整)才跳出循环不处理。那么接下来看看decodeRemovalReentryProtection方法的逻辑
final void decodeRemovalReentryProtection(ChannelHandlerContext ctx, ByteBuf in, List<Object> out)
throws Exception {
...
decode(ctx, in, out);
...
}
这里调用了子类实现的具体的解码方法。那么到这里,我们可以简单的理一理思路:
- 有消息(字节)进来,被ByteToMessageDecoder接收到,判断若为字节则处理,其会在成员变量中保存一个ByteBuf存放字节的数据结构,此数据结构可累加
- 循环处理字节数据,以下两种情况跳出循环,继续下面的步骤
- 读完了数据
- 子类没解码出东西(结果列表没有变化)
- 若结果列表中有元素,则将结果元素一个个往下传播,这里结果列表的一个元素视为一个完整的数据包。这样,在解码器后面的Handler就可以很方便的直接操作某个具体的对象即可,不需要关心数据是否是完整的一个数据包和从字节中解析数据的过程
解决读半包问题
若此时客户端传来的数据并不完整,不足以解析成一个数据包,比如HTTP,此时数据并不足够解析成一个完整的HttpRequest,则子类的解码器就需要什么都不做,不往结果列表中添加值,此时就会什么都不处理,仅仅是将数据累加,直到客户端下一次将余下的数据包继续发过来,此时累加的数据就有可能是一个完整的数据包了,就又经过上述3个步骤。
那如果是多包问题呢?客户端传来的数据会被解析成一个半的数据包,那么只需要解析那一个数据包,将结果放入结果列表即可,余下的半包不需要管它,因为它会保存在解码器中的成员变量中,在后续客户端发来余下完整数据的时候会累加起来一起处理成一个完整的数据包。
具体解码器例子
业务逻辑那层的处理并不需要关心半包问题和协议问题,编解码器很好的解耦,将逻辑分层处理
解码器有很多种:
- 公有协议的解码器:例如HTTP、WebSocket等等的一些协议,其可以很方便的将传来的字节解析成POJO的Java对象,以供我们直接拿来用。
- 私有自定义协议的解码器:例如Dubbo、RocketMq,这些都是自己定义的一些协议规则,需要继承解码器基石来完成自己自定义协议的解码流程。
- 某种规则的解码器
- 行分隔解码:按照换行符
\n
或\r\n
来分隔一个完整的数据包的LineBasedFrameDecoder
- 某符号分隔解码:按照构造函数传入的自定义符号进行分隔数据包的
DelimiterBasedFrameDecoder
- 基于动态长度分隔解码:按照消息中指定的偏移量位置,先获取长度信息,然后根据这个动态的长度来分隔数据包,实现为
LengthFieldBasedFrameDecoder
- 行分隔解码:按照换行符
至于公有协议的解码器,Netty都已经封装好了,由于协议比较复杂,所以解码过程也比较复杂,不适用于刚了解解码器的人,至于私有自定义的解码器,也没必要现在了解。建议先理解了下面的基本规则解码器的流程,例如最简单的LineBasedFrameDecoder,即可触类旁通,理解别的解码器实现了,相信自己写一个解码器也是没有问题的。
基于行分隔的解码器
这里开始介绍最简单的LineBasedFrameDecoder,顾名思义,这个解码器以换行符作为分隔的标记,消息中只要探测到换行符,就分隔成一个完整的消息放入结果列表。首先我们看看这个类的构造函数
public LineBasedFrameDecoder(final int maxLength) {
// 第一个参数为最大长度,若超出次长度将不对这个数据包进行解码,直接丢弃
// 第二个参数为是否剥离分隔符,默认为true,表示结果不带分隔符
// 第三个参数为快速失败,默认为false,表示若超出最大长度,先不报错,延迟到后面才报错
this(maxLength, true, false);
}
构造函数的这三个参数在后面的分析中都会用到。我们上面了解了解码器基石的逻辑,解码过程最终会调用到子类的decode方法,所以这里我们可以直接跳到这个类的decode方法
protected final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
Object decoded = decode(ctx, in);
// 如果解码结果不为空
if (decoded != null) {
// 向结果列表添加元素
out.add(decoded);
}
}
这里继续看decode方法
protected Object decode(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception {
// 寻找换行符在ByteBuf中的位置
final int eol = findEndOfLine(buffer);
// 不是丢弃模式,正常解码
if (!discarding) {
// 若大于等于0,则表示有找到换行符
if (eol >= 0) {
final ByteBuf frame;
// 算出即将要读出数据的长度
final int length = eol - buffer.readerIndex();
// 换行符的长度
// 若为\r,表示是\r\n -> 长度为2
// 若不是\r,表示是\n -> 长度为1
final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;
// 如果要读的长度超过了最大限度的长度,则要丢弃目前的数据,不做处理
if (length > maxLength) {
// 将readIndex设置到换行符index的后面1位或2位,表示还包括了忽略换行符
buffer.readerIndex(eol + delimLength);
// 向context传播异常事件
fail(ctx, length);
// 不做处理
return null;
}
// 如果需要剥离分隔符
if (stripDelimiter) {
// 读出的数据应该不包含分隔符,所以只是length的长度
frame = buffer.readRetainedSlice(length);
// 然后将byteBuf中的数据后移分隔符长度,不然下次再读会读到换行符
buffer.skipBytes(delimLength);
} else {
// 如果不需要剥离分隔符,那么读出的数据是包含换行符的
frame = buffer.readRetainedSlice(length + delimLength);
}
// 将解码的数据返回
return frame;
} else {
// 到这个分支,证明没有读到换行符,通常是半包数据(不完整)
final int length = buffer.readableBytes();
// 如果这个半包数据都超过了最大限度长度,丢弃数据
if (length > maxLength) {
// 当前丢弃数据的长度
discardedBytes = length;
// 直接把byteBuf的读索引移到写索引处,表示清空了整个可读数据区
buffer.readerIndex(buffer.writerIndex());
// 开启丢弃模式
discarding = true;
offset = 0;
// 如果需要快速失败,也就是读半包都要传播异常的话
if (failFast) {
// 传播异常
fail(ctx, "over " + discardedBytes);
}
}
return null;
}
} else {
// 丢弃模式,在下面详细描述
// ...
return null;
}
}
首先,读出换行符在数据区里的位置,如果这个位置为负数,表示没有找到换行符。
丢弃数据
-
找到换行符的情况下
- 此时是可以读到全包的,然而此全包数据太大了,需要被忽略跳过,这里只是将ByteBuf的读索引往后移动到全包的长度,表示不读该全包数据
-
没有找到换行符的情况下
-
此时是不能读到全包的,但此时的数据都还是太大了,所以需要被丢弃,包括剩余没有发来的数据包,直到一个分隔符,都需要被丢弃,所以这次的丢弃是不够的,需要把下次也丢弃直到读到一个分隔符,这样保证了数据的完整丢弃,不然一段段的丢弃数据是不可靠的。由于需要分次丢弃,所以这里需要记录一个成员变量,下次再编码的时候就检查这个标记,若为true就直接开始丢弃数据到分隔符的长度,若还是没有分隔符,那么丢弃模式还是true,下次解码还是会丢弃,以此类推。下面看看丢弃模式下的代码
else { // 这里是丢弃模式 // eol大于等于0表示找到了分隔符 if (eol >= 0) { // 被丢弃的长度计数 final int length = discardedBytes + eol - buffer.readerIndex(); final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1; // 将读索引移动到分隔符的位置,表示数据到分隔符为止都要被丢弃 buffer.readerIndex(eol + delimLength); discardedBytes = 0; // reset丢弃模式标示,表示下次可以继续读数据了 discarding = false; // 若是快速失败,在前面就已经传播过异常了 // 所以如果是快速失败,这里就不传播异常了,保证就传播一次 // 只不过是时机不同 if (!failFast) { fail(ctx, length); } } else { // 到这里说明还没有找到分隔符,丢弃工作还没完,下次的数据还需要继续丢弃 discardedBytes += buffer.readableBytes(); // 直接将可读数据全部丢弃 buffer.readerIndex(buffer.writerIndex()); // We skip everything in the buffer, we need to set the offset to 0 again. offset = 0; } return null; }
以上代码和我上面的语言描述保持一致,读者若不理解可以多看几次,结合ByteToMessageDecoder这个基类的read方法一起来看看,在脑海里跑一跑流程
-
到这里,行分隔符的解码器的逻辑就已经分析完了,若还是不理解,建议自己DEBUG一下,跟着断点一个个看下去,或是脑海中有一个流程,因为这里的代码很可能不是一遍就跑完的,它是一个循环处理,会不断触发decode方法,因为消息一般不太会给你完整的一个包一个包发的。
若最近有空的话,过几天再更新一篇基于动态长度分隔解码,此解码器较为灵活,所以逻辑也比较多,单独开一篇文章进行讲解。