Netty 教程 – Netty粘包/拆包解决之道

粘包和拆包

学过TCP的都知道,它是属于传输层的协议,传输层除了有TCP协议外还有UDP协议,但是UDP是不存在拆包和粘包的。UDP是基于报文发送的,从UDP的帧结构可以看出,在UDP首部采用了16bit来指示UDP数据报文的长度,因此在应用层能很好的将不同的数据报文区分开,从而避免粘包和拆包的问题。

TCP是基于字节流的,虽然应用层和TCP传输层之间的数据交互是大小不等的数据块,但是TCP把这些数据块仅仅看成一连串无结构的字节流,没有边界;另外从TCP的帧结构也可以看出,在TCP的首部没有表示数据长度的字段,基于上面两点,在使用TCP传输数据时,才有粘包或者拆包现象发生的可能。

  • 服务端分两次读取到了两个独立的数据包,分别是D1D2没有粘包和拆包
  • 服务端一次接受到两个粘在一起的数据包,D2D1,被称为TCP粘包服务端分两次读取到了两个数据包,第一次读取到完整的D1D2部分内容,第二次读取了D2的剩余内容,这被称之为TCP拆包操作
  • 服务端分两次读取到了两个数据包,第一次读取到了D1_1,第二次读取到了D1包的剩余内容和完整的D2数据包
  • 如果此时服务端TCP接收滑窗非常小,而数据包内容相对较大的情况,很可能发生服务端多次拆包才能将D1D2数据接收完整

产生原因

  • 要发送的数据大于TCP发送缓冲区剩余空间大小,将会发生拆包。
  • 待发送数据大于MSS(最大报文长度),TCP在传输前将进行拆包。
  • 要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去,将会发生粘包。
  • 接收数据端的应用层没有及时读取接收缓冲区中的数据,将发生粘包。

解决方法

通过以上分析,我们清楚了粘包或拆包发生的原因,那么如何解决这个问题呢?解决问题的关键在于如何给每个数据包添加边界信息,常用的方法有如下几个:

  • 发送端给每个数据包添加包首部(类似UDP),首部中应该至少包含数据包的长度,这样接收端在接收到数据后,通过读取包首部的长度字段,便知道每一个数据包的实际长度了
  • 发送端将每个数据包封装为固定长度(不够的可以通过补0填充),这样接收端每次从接收缓冲区中读取固定长度的数据就自然而然的把每个数据包拆分开来。
  • 可以在数据包之间设置边界,如添加特殊符号(如\r\n),这样,接收端通过这个边界就可以将不同的数据包拆分开。

Netty 解决方案

io.netty.handler.codec.callDecode(ChannelHandlerContext ctx, ByteBuf in, List out)

protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List out) {
    try {
        while (in.isReadable()) {
            int outSize = out.size();
            int oldInputLength = in.readableBytes();
            decode(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()) {
                if (oldInputLength == in.readableBytes()) {
                    break;
                } else {
                    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 (Throwable cause) {
        throw new DecoderException(cause);
    }
} 
  

当上面一个ChannelHandlerContext传入的ByteBuf有数据的时候,这里我们可以把in参数看成网络流,这里有不断的数据流入,而我们要做的就是从这个byte流中分离出message,然后把message添加给out

分开描述下代码逻辑:

  • out中有message的时候,直接将out中的内容交给后面的ChannelHandlerContext去处理
  • 当用户逻辑把当前ChannelHandlerContext移除的时候,立即停止对网络数据的处理
  • 调用readableBytes记录当前in中可读字节数
  • decode是抽象方法,交给子类具体实现
  • 判断当前ChannelHandlerContext是移除的时候,立即停止对网络数据的处理
  • 如果子类实现没有分理出任何message的时候,且子类实现也没有动ByteBuf中的数据的时候,这里直接跳出,等待后续有数据来了再进行处理
  • 如果子类实现没有分理出任何message的时候,且子类实现动了ByteBuf的数据,则继续循环,直到解析出message或者不在对ByteBuf中数据进行处理为止
  • 如果子类实现解析出了message但是又没有动ByteBuf中的数据,那么是有问题的,抛出异常。
  • 如果标志位只解码一次,则退出

如果要实现具有处理粘包、拆包功能的子类,及decode实现,必须要遵守上面的规则,我们以实现处理第一部分的第二种粘包情况和第三种情况拆包情况的服务器逻辑来举例:

粘包:decode需要实现的逻辑对应于将客户端发送的两条消息都解析出来分为两个message加入out,这样的话callDecode只需要调用一次decode即可。

拆包:decode需要实现的逻辑主要对应于处理第一个数据包的时候,第一次调用decode的时候outsize不变,从continue跳出并且由于不满足继续可读而退出循环,处理第二个数据包的时候,对于decode的调用将会产生两个message放入out,其中两次进入callDecode上下文中的数据流将会合并为一个ByteBuf和当前ChannelHandlerContext实例关联,两次处理完毕即清空这个ByteBuf

尽管介绍了ByteToMessageDecoder,用户自己去实现处理粘包、拆包的逻辑还是有一定难度的,Netty已经提供了一些基于不同处理粘包、拆包规则的实现,我们可以根据规则自行选择使用Netty提供的Decoder来进行具有粘包、拆包处理功能的网络应用开发。

  • DelimiterBasedFrameDecoder 基于消息边界方式进行粘包拆包处理的。
  • FixedLengthFrameDecoder 基于固定长度消息进行粘包拆包处理的。
  • LengthFieldBasedFrameDecoder 基于消息头指定消息长度进行粘包拆包处理的。
  • LineBasedFrameDecoder 基于行来进行消息粘包拆包处理的。

测试一把

通过例子,来更加清晰的认识TCP粘包/拆包带来的问题,以及使用Netty内置的解决方案解决粘包/拆包的问题

你可能感兴趣的:(网络,udp,tcp/ip)