1 概述
当Server在读取客户端数据的时候,如果一次读取不完整,就会触发channelRead事件,那么Netty是如何处理这类问题的?本节会详细讲解。
2 TCP拆包、粘包
TCP是一个“流”协议。所谓流,就是没有界限的一长串二进制数据。TCP作为传输层协议,并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行数据包的划分,所以在业务上认为是一个完整的包,可能会被TCP拆分成多个包进行发送,也有可能把多个小的包分装成一个大的数据包发送,也就是TCP拆包和粘包问题。
同样,在Netty的编码器中,也会对半包和粘包问题做相应的处理。什么是半包,就是不完整的数据包,因为Netty在轮询事件的时候,每次从Channel中读取的数据,不一定是一个完整的数据包,这种情况就叫做半包。什么是粘包,Client往Server发送数据包时,如果发送频繁很有可能会将多个数据包的数据都发送到通道中,Server在读取的时候可能会读取到超过一个完整数据包的长度,这种情况就叫粘包。有关半包和粘包,如下图所示:
3 粘包问题的解决策略
由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决。目前业界主流协议的解决方案如下:
1、消息定长,报文长度固定,例如每个报文的长度固定为200字节,如果不够空位补空格。
2、报文尾部添加特殊分隔符,例如每条报文结束都添加回车换行符或者指定特殊字符作为报文分隔符,接收方通过特殊分隔符区分报文。
3、将消息分为消息头和消息体,消息头包含表示信息的总长度属性。
4、更复杂的自定义应用层协议。
Netty对半包或粘包的处理其实很简单。每个Handler都是和Channel唯一绑定的,一个Handler只对应一个Channel,所以Channel中的数据读取的时候经过解析,如果不是一个完整的数据包,则解析失败,将这个数据包进行保存,等下次解析时再和这个数据包进行组装解析,直到解析到完整的数据包,才会将数据包向下传递。
4 编解码技术
通常习惯将编码(Encode)称为序列化,它将对象序列化为字节数组,用于网路传输、数据持久化等其他用途。反之,解码(Decode)/反序列化 把从网络、磁盘等读取的字节数组还原成原始对象(通常是原始对象的拷贝),以便后续的业务逻辑操作。进行远程跨进程服务调用时(例如RPC),需要使用特定的编解码技术,对需要进行网络传输的对象做编码或者解码,以便完成远程调用。
作为一个高性能的NIO通信框架,编解码框架是Netty的重要组成部分。尽管站在微内核的角度看,编解码框架并不是Netty微内核的组成部分,但是通过ChannelHandler定制扩展出的编解码框架却是不可获取的。
然后在Netty中,从网络读取的Inbound消息,需要经过解码,将二进制数据报文转换成应用程协议消息或者业务消息,才能够被上层的应用逻辑识别和处理;同理,用户发送到网络的Outbound业务消息 ,需要经过编码转换成二进制字节数组(对于Netty就是ByteBuf)才能发送到网络对端。编码和解码功能是NIO框架的有机组成部分,无论是由业务定制扩展实现,还是NIO框架内置的编解码能力,该功能都是必不可少的。
为了降低用户的开发难度,Netty对常用的功能和API做了封装,已屏蔽底层的实现细节。编解码功能的定制,对于熟悉Netty底层实现的开发者,直接基于ChannelHandler扩展开发,难度并不大。但是对于大多数初学者或者不愿意去了解底层实现细节的用户,需要给他们提供更简单的类库和API,而不是ChannelHandler。
Netty在这方面做得很出色,针对编解码功能,它既提供了通用的编解码框架,也提供了常用的编解码类库供用户直接使用。在保证定制扩展性的基础上,尽量减低用户的开发工作量和开发门槛,提升开发效率。
Netty预置的编解码功能包括Base64、Bytes、Compression、JSON等,如下图所示:
5 Netty常用的解码器
Netty默认提供了多个解码器,可以进行分包操作,满足大部分编码需求。
5.1 ByteToMessageDecoder抽象解码器
使用NIO进行网络编程时,往往需要将读取到的字节数组或字节缓冲区解码为业务可以使用的POJO对象,因此Netty提供了ByteToMessageDecoder抽象解码类工具。
用户自定义解码器继承ByteToMessageDecoder,只需要实现void decode(ChannelHandlerContext ctx ,ByteBuf in,List<Object> out)抽象方法即可完成Bytebuf到POJO对象的解码。
由于ByteToMessageDecoder并没有考虑TCP粘包和拆包等场景,用户自定义解码器需要自己处理“读半包”问题。正因为如此,大多数场景不会直接继承ByteToMessageDecoder,而是继承另外一些更高级的解码器来屏蔽半包的处理。
实际项目中,通常将LengthFieldBaseFrameDecoder和ByteToMessageDecoder组合使用,前者负责将网络读取的数据报文解码为整包消息,后者负责将整包消息解码为最终的业务对象。除了和其他解码器组合形成新的解码器,ByteToMessageDecoder也是很多基础解码器的父类,它的继承关系如下图:
下面看一下ByteToMessageDecoder类的定义代码。
public abstract class ByteToMessageDecoder extends ChannelInboundHandlerAdapter {}
ByteToMessageDecoder继承了ChannelInboundHandlerAdapter。这是个Inbound类型的Handler,也就是处理流向自身时间的Handler。其次,该类通过abstract关键字修饰,说明是个抽象类,在实际使用的时候,并不是直接使用这个类,而是使用其子类,类定义了解码器的骨架方法,具体实现逻辑交给子类,同样在半包处理中也是由该类实现的。Netty中很多解码器都实现了这个类,也可以通过实现该类自定义解码器。
重点关注该类的cumulation属性,Netty会将不完整的数据包进行保存,就是保存在这个属性中。Bytebuf读取完整数据会传递ChannelRead事件,传播过程中会调用Handler的channelRead方法。ByteToMessageDecoder的channelRead方法是编码的关键部分。来看channelRead方法的代码。
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//如果msg是Bytebuf类型
if (msg instanceof ByteBuf) {
//简单当成一个数组,用来保存解析的对象
CodecOutputList out = CodecOutputList.newInstance();
try {
ByteBuf data = (ByteBuf) msg;
//当前累加器为空,说明这是第一次从I/O流里读取数据
first = cumulation == null;
if (first) {
//如果是第一次,则把当前累加的数据复制为刚读进来的对象
cumulation = data;
} else {
//如果不是第一次,则把当前累加的数据和读进来的数据进行累加
cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
}
//调用子类的方法进行解析
callDecode(ctx, cumulation, out);
} catch (DecoderException e) {
throw e;
} catch (Throwable t) {
throw new DecoderException(t);
} finally {
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();
}
//记录List的长度
int size = out.size();
decodeWasNull = !out.insertSinceRecycled();
//向下传播
fireChannelRead(ctx, out, size);
out.recycle();
}
} else {
//不是Bytebuf类型,就向下传播
ctx.fireChannelRead(msg);
}
}
首先判断如果传来啊的数据是Bytebuf,则进入if中,可以把 CodecOutputList out = CodecOutputList.newInstance();当成一个ArrayList,用于保存解码完成的数据;ByteBuf data = (ByteBuf) msg 将数据转化成Bytebuf;first = cumulation == null 表示如果cumulation 是null,说明没有存储半包数据,则将当前的数据保存到cumulation中,如果 cumulation != null,说明存储了半包数据,cumulator.cumulate(ctx.alloc(), cumulation, data) 将读取的数据和原来的数据进行累加,保存在cumulation属性中。来看一下cumulation属性的定义,代码如下:
private Cumulator cumulator = MERGE_CUMULATOR
这里调用了其静态属性 MERGE_CUMULATOR,代码如下:
public static final Cumulator MERGE_CUMULATOR = new Cumulator() { @Override public ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in) { ByteBuf buffer; //不能超过最大内存 if (cumulation.writerIndex() > cumulation.maxCapacity() - in.readableBytes() || cumulation.refCnt() > 1) { buffer = expandCumulation(alloc, cumulation, in.readableBytes()); } else { buffer = cumulation; } buffer.writeBytes(in); in.release(); return buffer; } };
这里创建了 Cumulator 类型的静态对象,并重写了 cumulate 方法。这个cumulate方法就是用于将Bytebuf进行拼接的方法。在方法中,首先判断cumulation 的写指针+In的可读字节数是否超过了cumulation的最大长度,如果超过了,将对cumulation进行扩容;如果没超过,则将其赋值到局部变量Buffer中。然后将In的数据写入buffer,将In进行释放,返回写入数据后的Bytebuf。回到channelRead方法,最后调用callDecode()方法进行解码,这里传入了Context对象、缓冲区cumulation和集合Out。下面来看callDecode()方法。
protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
try {
//如果累加器里有数据
while (in.isReadable()) {
int outSize = out.size();
//判断当前List是否有对象
if (outSize > 0) {
//如果有对象,则向下传播事件
fireChannelRead(ctx, out, outSize);
out.clear();
//解码过程中判断如果ctx被删除就跳出循环
if (ctx.isRemoved()) {
break;
}
outSize = 0;
}
//当前可读数据长度
int oldInputLength = in.readableBytes();
//子类实现
//子类解析,解析完对象放到Out里面
decode(ctx, in, out);
if (ctx.isRemoved()) {
break;
}
//List解析前大小和解析后长度一样
if (outSize == out.size()) {
//原来可读的长度==解析后可读长度
//说明没有读取数据 (当前累加的数据并没有拼成一个完整的数据包)
if (oldInputLength == in.readableBytes()) {
//跳出循环(下次在读取数据才能进行后续的解析)
break;
} else {
//没有解析到数据,但是进行读取了
continue;
}
}
//Out里面有数据,但是没有从累加器读取数据
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 (Throwable cause) {
throw new DecoderException(cause);
}
}
分析上面的源码,首先循环判断传入的Bytebuf是否还有可读字节,如果有可读字节说明没有解码完成,则继续循环解码。然后判断集合Out的 size值,如果size大于1,说明Out中装入了解码完成之后的数据,接下来将事件向下传播并清空Out。
因为第一次解码Out是空的,所以不会if语句块,这部分稍后分析。继续往下看,通过oldInputLength == in.readableBytes() 获取当前的Bytebuf,其实就是属性cumulation的可读字节数,这里就是一个用于比较的备份。继续往下看,decode()方法是最终的解码操作,这步会读取cumulation并且将解码后的数据放入集合Out中,在ByteToMessageDecoder中的该方法是一个抽象方法,让子类进行实现,Netty很多解码都是集成ByteToMessageDecoder并实现decode方法从而完成了解码操作,同样也可以遵循相应的规则进行自定义解码器。
继续往看下,if (outSize == out.size()) 这个判断表示将解析之前的Out大小和解析之后的Out大小进行比较,如果相同,说明并没有解析出数据,进入if语句块。如果oldInputLength == in.readableBytes() 表示cumulation的可读字节数在解析之前和解析之后是相同的,说明解码方法中并没有解析数据,也就是当前的数据并不是一个完整的数据包,则跳出循环,留个下次解析;否则,说明没有解析到数据,但是读取了,所以跳过该次循环进入下次循环。最后判断 oldInputLength == in.readableBytes() ,这里代表Out中有数据,但是并没有从cumulation读取数据,说明这个Out的内容是非法的,直接抛出异常。现在回到channelRead方法,关于finally代码块中的内容。
finally {
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();
}
//记录List长度
int size = out.size();
decodeWasNull = !out.insertSinceRecycled();
//向下传播
fireChannelRead(ctx, out, size);
out.recycle();
}
首先判断 cumulation 不为null,并且没有可读字节,则将累加器进行释放,并设置为null,之后记录Out的长度,通过 fireChannelRead()方法将channelRead 事件进行向下传播,并回收Out对象。跟进fireChannelRead方法。
static void fireChannelRead(ChannelHandlerContext ctx, CodecOutputList msgs, int numElements) {
//遍历List
for (int i = 0; i < numElements; i ++) {
//逐个向下传播
ctx.fireChannelRead(msgs.getUnsafe(i));
}
}
上面代码中遍历Out集合,并将里面的元素逐个向下传递。以上就是关于解码的骨架逻辑。
5.2 LineBasedFrameDecoder行解码器
LineBasedFrameDecoder是回车换行解码器,如果用户发送的消息以回车换行符(以\r\n或者直接以\n结尾)作为消息结束的标识,则可以直接使用Netty的LineBasedFrameDecoder对消息进行解码,只需要在初始化Netty服务端或者客户端时将LineBasedFrameDecoder正确地添加到ChannelPipeline中即可,不需要自己重新实现一套换行解码器。
LineBasedFrameDecoder的工作原理是它依次遍历Bytebuf中的可读字节,判断是否有“\n”,或者“\r\n”,如果有,就以此位置为结束位置,从可读索引到结束位置区间的字节就组成了一行。它是以 换行符为结束标志的解码器,支持携带结束符或者不携带 结束符两种解码方式,同时支持配置单行的最大长度。如果连续读取到最大长度后,仍然没有发现换行符,就会抛出异常,同时忽略之前读到的异常码流,防止由于数据报文没有携带换行符而导致接收的Bytebuf无限积压,引起系统内存溢出。它的使用效果如下:
通常情况下,LineBasedFrameDecoder会和StringDecoder配合使用,组合成按行切换的文本解码器,对于文本类协议的解析,文本换行符解码器非常实用 ,例如对HTTP消息头的解析、FTP消息的解析等。
下面简单给出文本换行解码器的使用实例。
ch.pipeline().addLast(new LineBasedFrameDecoder(1024)); ch.pipeline().addLast(new StringDecoder());
初始化Channel的时候,首先将LineBasedFrameDecoder添加到ChannelPipeline中,然后以此添加字符串解码器StringDecoder、业务Handler。
接下来看LineBasedFrameDecoder的源码,LineBasedFrameDecoder也继承了ByteToMessageDecoder,其参数定义如下:
public class LineBasedFrameDecoder extends ByteToMessageDecoder { //数据包的最大长度,超过该长度会进行丢弃模式 private final int maxLength; //超出最大长度是否要抛出异常 private final boolean failFast; //最终解析的数据包是否带有换行符 private final boolean stripDelimiter; //为true说明当前解码过程为丢弃模式 private boolean discarding; //丢弃了多少字节 private int discardedBytes;}
其中的丢弃模式,后面会在源码中看到其含义。先来看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方法调用重载的decode方法,并将解码后的内容放到out集合中。看一下重载的decode方法,代码如下:
protected Object decode(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception {
//找这行的结尾
final int eol = findEndOfLine(buffer);
if (!discarding) {
if (eol >= 0) {
final ByteBuf frame;
//计算从换行符到可读字节之间的长度
final int length = eol - buffer.readerIndex();
//获得分隔符长度,如果是\r\n结尾,分隔符长度为2
final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;
//如果长度大于最大长度
if (length > maxLength) {
//指向换行符之后的可读字节(这段数据丢弃)
buffer.readerIndex(eol + delimLength);
//传播异常事件
fail(ctx, length);
return null;
}
//如果这次解析的数据是有效的
//分隔符是否算在完整数据包里
//true为丢弃分隔符
if (stripDelimiter) {
//截取有效长度
frame = buffer.readRetainedSlice(length);
//跳过分隔符的字节
buffer.skipBytes(delimLength);
} else {
//包含分隔符
frame = buffer.readRetainedSlice(length + delimLength);
}
return frame;
} else {
//如果没找到分隔符(非丢弃模式)
//可读字节长度
final int length = buffer.readableBytes();
//如果超过能解析的最大长度
if (length > maxLength) {
//将当前长度标记为可丢弃的
discardedBytes = length;
//直接将读指针移动到写指针
buffer.readerIndex(buffer.writerIndex());
//标记为丢弃模式
discarding = true;
//超过最大长度抛出异常
if (failFast) {
fail(ctx, "over " + discardedBytes);
}
}
//没有超过,直接返回
return null;
}
} else {
//丢弃模式
if (eol >= 0) {
//找到分隔符
//当前丢弃的字节(前面已经丢弃的+现在丢弃的位置-写指针)
final int length = discardedBytes + eol - buffer.readerIndex();
//当前换行符长度为多少
final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;
//读指针直接移动到换行符+换行符的长度
buffer.readerIndex(eol + delimLength);
//当前丢弃的字节为0
discardedBytes = 0;
//设置为丢弃模式
discarding = false;
//丢弃完字节后触发异常
if (!failFast) {
fail(ctx, length);
}
} else {
//累计已丢弃的字节个数+当前可读的长度
discardedBytes += buffer.readableBytes();
//移动
buffer.readerIndex(buffer.writerIndex());
}
return null;
}
}
上面代码解析:
final int eol = findEndOfLine(buffer):是寻找当前行的结尾的索引值,也就是\r\n或者\n,如下图:
上图可以看出,如果是以\n结尾,返回的索引值是\n的索引值;如果是\r\n结尾,返回的是\r的索引值。findEndOfLine(buffer)方法的代码如下:
private static int findEndOfLine(final ByteBuf buffer) {
//找到\n字节
int i = buffer.forEachByte(ByteProcessor.FIND_LF);
//如果找到了,并且前面的字符是\r,则指向\r字符
if (i > 0 && buffer.getByte(i - 1) == '\r') {
i--;
}
return i;
}
回到重载的decode方法,if (!discarding) 判断是否为非丢弃模式,默认是非丢弃模式,所以进入if语句块中。根据 if (eol >= 0) 判断是否找到了换行符,来看非丢弃模式下找到换行符的相关逻辑。
final ByteBuf frame; final int length = eol - buffer.readerIndex(); final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1; if (length > maxLength) { buffer.readerIndex(eol + delimLength); fail(ctx, length); return null; } if (stripDelimiter) { frame = buffer.readRetainedSlice(length); buffer.skipBytes(delimLength); } else { frame = buffer.readRetainedSlice(length + delimLength); } return frame;
首先获得换行符到可读字节之间的长度,然后获取换行符的长度,如果是\n结尾,则长度为1;如果是\r结尾,长度是2。if (length > maxLength) 代表如果长度超过最大长度,则直接通过buffer.readerIndex(eol + delimLength) 这种方式将读指针指向换行符之后的字节,说明换行符之前的字节需要完全丢弃,如下图所示:
丢弃之后通过fail方法传播异常,并返回null。继续往下看,执行到下一步,说明解析出来的数据长度没有超过最大长度,说明是有效数据包。if (stripDelimiter) 表示是否要将分隔符放在完整数据包里面,如果是true,说明要丢弃分隔符,然后截取有效长度,并跳过分隔符,将包含的分隔符进行截取。
再看飞丢弃模式下没有找到换行符的相关逻辑,也就是飞丢弃模式下if(eol >=0)的else语句块。
else { final int length = buffer.readableBytes(); if (length > maxLength) { discardedBytes = length; buffer.readerIndex(buffer.writerIndex()); discarding = true; if (failFast) { fail(ctx, "over " + discardedBytes); } } return null; }
首先通过 final int length = buffer.readableBytes() 获取所有的可读字节数,然后判断可读字节数是否超过最大值,如果超过最大值,则属性 discardedBytes 被标记为这个长度,代表这段内容要进行丢弃,如下图:
这里 buffer.readerIndex(buffer.writerIndex()) 直接将读指针移动到写指针,并且将discarding设置为true,表示丢弃模式。如果可读字节没有超过最大长度,则返回null,表示什么都没解析出来,等下次解析。再看丢弃模式的处理逻辑,也就是 if (!discarding) 分支的else语句块,这里也分两种情况,根据 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; discarding = false; if (!failFast) { fail(ctx, length); }
如果找到换行符,则需要将换行符之前的数据全部丢弃掉,如下图所示
final int length = discardedBytes + eol - buffer.readerIndex():获得丢弃的字节总数,也就是之前丢弃的字节数+现在需要丢弃的字节数。然后计算换行符的长度,如果\n则是1,如果是\r\n 就是2。buffer.readerIndex(eol + delimLength) 将读指针移动到换行符之后的位置,然后将discarding设置为false,表示当前是飞丢弃状态。再看丢弃模式下未找到换行符的情况,也就是丢弃模式下if (eol >= 0) 分支的else 语句块,代码如下:
discardedBytes += buffer.readableBytes(); buffer.readerIndex(buffer.writerIndex());
这里做的非常简单,就是累计丢弃的字节数,并将读指针移动到写指针,即把数据全部丢弃。最后在丢弃模式下,decode方法返回null,代表本次没有解析出任何数据。