netty 拆、粘包

定义

TCP是个“流”协议,所谓流,就是没有界限的一串数据。大家可以想象河里的流水,它们是连成一片的,其间并没有分界线。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。

TCP粘包/拆包问题说明
假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取的字节数是不定的,故可能存在以下四种情况。
(1) 服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包;
(2) 服务端一次接收到了两个数据包,D1和D2粘合在一起,被称为TCP粘包;
(3) 服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这被称为TCP拆包;
(4) 服务端分两次读取到了两个数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余内容D1_2和D2包的整包。

如果此时服务端TCP接收滑窗非常小,而数据包D1和D2比较大,很有可能会发生第5种可能,即服务端分多次才能将D1和D2包接收完全,期间发生多次拆包。

缘由

问题产生的原因有三个,分别如下:

  1. 应用程序write写入的字节大小大于套接口发送缓冲区大小;
  2. 进行MSS大小的TCP分段;
  3. 以太网帧的payload大于MTU进行IP分片;


    image.png

由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决

TCP 是一个面向字节流的协议,它是性质是流式的,所以它并没有分段。就像水流一样,你没法知道什么时候开始,什么时候结束。所以他会根据当前的套接字缓冲区的情况进行拆包或是粘包。

首先,人的正常思维习惯是,数据传输应该是基于报文的,人们不论是对话还是写信,肯定是一句一句的话或者一封一封的信,这就是所谓的报文。而 TCP 是什么东西呢?TCP 是一种流式协议,简单说,你使用它的时候,根本就没有所谓的报文,无论是聊天说的话还是发的图片,对于 TCP 来说,通通都是没有边界的,TCP 根本不认识这些东西,不知道你按换行时是一句话,你发的图片是一张图片,所有的东西都是数据流没有任何边界。这显然不符合人的正常思维,是扭曲的。而无论是人的思维,还是实际的东西,一定是要有边界的。这就有矛盾了。所以,对于程序员,在使用 TCP 之前,你必须进行报文协议设计。要么你使用别人设计好的报文协议,要么你自己设计。如果你自己不设计,也不使用别人设计好的,那么你肯定错了,一定会遇到所谓的粘包和拆包。

一般的程序员当然不愿意自己设计,一是没能力,二是不需要重新发明。那么设计完之后呢,必然是实现。因为自身水平的问题,还是会遇到 TCP 粘包和拆包问题。所以,我的建议是,不要自己去实现,应该去使用别人已经写好的代码。

下图展示了一个 TCP 协议传输的过程:

image.png

发送端的字节流都会先传入缓冲区,再通过网络传入到接收端的缓冲区中,最终由接收端获取。

当我们发送两个完整包到接收端的时候:

image.png

正常情况会接收到两个完整的报文。

但也有以下的情况:

image.png

接收到的是一个报文,它是由发送的两个报文组成的,这样对于应用程序来说就很难处理了(这样称为粘包)。

image.png

还有可能出现上面这样的虽然收到了两个包,但是里面的内容却是互相包含,对于应用来说依然无法解析(拆包)

接下来讲一讲如果自己直接使用 TCP 的接口,为什么会遇到粘包和拆包问题呢?TCP 的接口简单说只有两个:

send(data, length);
recv(buff);

不懂的人会基于人的正常思维想当然地认为 send() 就是发送报文,recv() 就是接收报文。但是,前面已经提到了,TCP 是没有所谓的报文边界的,所以你错了。为什么错?因为 send() 和 recv() 不是一一对应的。也就是说,send() 的调用次数和 recv() 的调用次数是独立的,有时是相等的(你在自己机器上和内网测试时基本都是相等的),有时是不相等的(互联网上非常容易出现)。

现在明白了吧,TCP 的 send() 和 recv() 不是一一对应的,理所当然会粘应用层的包,拆应用层的包。明白了之后怎么解决?简单,用别人封装好的代码。或者,你自己慢慢琢磨去吧!
正确地从TCP socket中读取报文的代码必须长这个样子,如果不长这个样子,就是错的!

char recv_buf[];
Buffer buffer;
// 网络循环:必须在一个循环中读取网络,因为网络数据是源源不断的。
while(1){
    // 从TCP流中读取不定长度的一段流数据,不能保证读到的数据是你期望的长度
    tcp.read(recv_buf);
    // 将这段流数据和之前收到的流数据拼接到一起
    buffer.append(recv_buf);
    // 解析循环:必须在一个循环中解析报文,避免所谓的粘包
    while(1){
        // 尝试解析报文
        msg = parse(buffer);
        if(!msg){
            // 报文还没有准备好,糟糕,我们遇到拆包了!跳出解析循环,继续读网络。
            break;
        }
        // 将解析过的报文对应的流数据清除
        buffer.remove(msg.length);
        // 业务处理
        process(msg);
    }
}

对于具体的解析报文这样的问题只能通过上层的应用来解决,常见的方式有:

  • 在报文末尾增加换行符表明一条完整的消息,这样在接收端可以根据这个换行符来判断消息是否完整。
  • 将消息分为消息头、消息体。可以在消息头中声明消息的长度,根据这个长度来获取报文(比如 808 协议)。
  • 规定好报文长度,不足的空位补齐,取的时候按照长度截取即可。

以上的这些方式我们在 Netty 的 pipline 中里加入对应的解码器都可以手动实现。
但其实 Netty 已经帮我们做好了,完全可以开箱即用。

LineBasedFrameDecoder 可以基于换行符解决。
DelimiterBasedFrameDecoder可基于分隔符解决。
FixedLengthFrameDecoder可指定长度解决。

字符串拆、粘包

简单的来说:
多次发送较少内容,会发生粘包现象。
单次发送内容过多,会发生拆包现象

下面来模拟一下最简单的字符串传输。

public class NettyClientHandler extends ChannelInboundHandlerAdapter{  
    
    private byte[] req;  
      
    private int counter;   
      
    public NettyClientHandler() {  
        req = ("书到用时方恨少,事非经过不知难!").getBytes();
    }  
      
    @Override  
    public void channelActive(ChannelHandlerContext ctx) throws Exception {  
        ByteBuf message = null;  
        //会发生粘包现象
        for (int i = 0; i < 100; i++) {
            message = Unpooled.buffer(req.length);
            message.writeBytes(req);
            ctx.writeAndFlush(message);
        }
    }  
      
    @Override  
    public void channelRead(ChannelHandlerContext ctx, Object msg)  
        throws Exception {  
        String buf = (String) msg;  
        System.out.println("现在 : " + buf + " ; 条数是 : "+ ++counter);  
    }  
  
    @Override  
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {  
        ctx.close();  
    }  
}

书到用时方恨少,事非经过不知难!发送一百次,就会发送粘包现象;
server端

public class NettyServerHandler extends ChannelInboundHandlerAdapter{  
    private int counter;  
    @Override  
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {  
          
        String body = (String)msg;  
        System.out.println("接受的数据是: " + body + ";条数是: " + ++counter);  
    }  
    @Override  
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {  
        cause.printStackTrace();  
        ctx.close();  
    }  
}  
服务端启动成功,端口为 :2345
接受的数据是: 书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时;条数是: 1
接受的数据是: 方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,;条数是: 2
接受的数据是: 事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过;条数是: 3
接受的数据是: 不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!�;条数是: 4
接受的数据是: �到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!书到用时方恨少,事非经过不知难!;条数是: 5

可以看到 ctx.writeAndFlush(message) 在client了调用了100次,也即100次send,但是在server端 read只调用了5次,这也验证了前面讲的send和receive次数不一致问题。

而将《春江花月夜》和《行路难》发送一次就会发送拆包现象;

接受的数据是: 春江潮水连海平,海上明月共潮生。  滟滟随波千万里,何处春江无月明!  江流宛转绕芳甸,月照花林皆似霰;    空里流霜不觉飞,汀上白沙看不见。    江天一色无纤尘,皎皎空中孤月轮。  江畔何人初见月?江月何年初照人?  人生代代无穷已,江月年年望相似。    不知江月待何人,但见长江送流水。    白云一片去悠悠,青枫浦上不胜愁。    谁家今夜扁舟子?何处相思明月楼?    可怜楼上月徘徊,应照离人妆镜台。    玉户帘中卷不去,捣衣砧上拂还来。    此时相望不相闻,愿逐月华流照君。    鸿雁长飞光不度,鱼龙潜跃水成文。    昨夜闲潭梦落花,可怜春半不还家。    江水流春去欲尽,江潭落月复西斜。    斜月沉沉藏海雾,碣石潇湘无限路。    不知乘月几人归,落月摇情满江树。 噫吁嚱,危乎高哉!蜀道之难,难于上青天!蚕丛及鱼凫,开国何茫然!尔来四万八千岁,不与秦塞通人烟�;条数是: 1
接受的数据是: � 西当太白有鸟道,可以横绝峨眉巅。地崩山摧壮士死,然后天梯石栈相钩连。上有六龙回日之高标,下有冲波逆折之回川。 黄鹤之飞尚不得过,猿猱欲度愁攀援。青泥何盘盘,百步九折萦岩峦。扪参历井仰胁息,以手抚膺坐长叹。 问君西游何时还?畏途巉岩不可攀。但见悲鸟号古木,雄飞雌从绕林间。又闻子规啼夜月,愁空山。 蜀道之难,难于上青天,使人听此凋朱颜!连峰去天不盈尺,枯松倒挂倚绝壁。飞湍瀑流争喧豗,砯崖转石万壑雷。 其险也如此,嗟尔远道之人胡为乎来哉!剑阁峥嵘而崔嵬,一夫当关,万夫莫开。 所守或匪亲,化为狼与豺。朝避猛虎,夕避长蛇;磨牙吮血,杀人如麻。锦城虽云乐,不如早还家。 蜀道之难,难于上青天,侧身西望长咨嗟!
;条数是: 2

实际只发送了一次,但receive了两次,发生了拆包行为。

该怎么解决呢?这便可采用之前提到的 LineBasedFrameDecoder 利用换行符解决。LineBasedFrameDecoder 解码器使用非常简单,只需要在 pipline 链条上添加即可。

//字符串解析,换行防拆包
.addLast(new LineBasedFrameDecoder(1024))
.addLast(new StringDecoder())

构造函数中传入了 1024 是指报的长度最大不超过这个值,具体可以看下文的源码分析。

注意,由于 LineBasedFrameDecoder 解码器是通过换行符来判断的,所以在发送时,一条完整的消息需要加上 \n。

LineBasedFrameDecoder 的原理

image.png

从这个逻辑中可以看出就是寻找报文中是否包含换行符,并进行相应的截取。

由于是通过缓冲区读取的,所以即使这次没有换行符的数据,只要下一次的报文存在换行符,上一轮的数据也不会丢。

为什么要粘包拆包

为什么要粘包
在用户数据量非常小的情况下,极端情况下,一个字节,该TCP数据包的有效载荷非常低,传递100字节的数据,需要100次TCP传送,100次ACK,在应用及时性要求不高的情况下,将这100个有效数据拼接成一个数据包,那会缩短到一个TCP数据包,以及一个ack,有效载荷提高了,带宽也节省了。

非极端情况,有可能两个数据包拼接成一个数据包,也有可能一个半的数据包拼接成一个数据包,也有可能两个半的数据包拼接成一个数据包。

为什么要拆包
拆包和粘包是相对的,一端粘了包,另外一端就需要将粘过的包拆开,举个栗子,发送端将三个数据包粘成两个TCP数据包发送到接收端,接收端就需要根据应用协议将两个数据包重新组装成三个数据包. 还有一种情况就是用户数据包超过了mss(最大报文长度),那么这个数据包在发送的时候必须拆分成几个数据包,接收端收到之后需要将这些数据包粘合起来之后,再拆开。

拆包的原理
在没有netty的情况下,用户如果自己需要拆包,基本原理就是不断从TCP缓冲区中读取数据,每次读取完都需要判断是否是一个完整的数据包

1.如果当前读取的数据不足以拼接成一个完整的业务数据包,那就保留该数据,继续从tcp缓冲区中读取,直到得到一个完整的数据包

  1. 如果当前读到的数据加上已经读取的数据足够拼接成一个数据包,那就将已经读取的数据拼接上本次读取的数据,够成一个完整的业务数据包传递到业务逻辑,多余的数据仍然保留,以便和下次读到的数据尝试拼接.

netty 中的拆包也是如上这个原理,内部会有一个累加器,每次读取到数据都会不断累加,然后尝试对累加到的数据进行拆包,拆成一个完整的业务数据包,这个基类叫做 ByteToMessageDecoder,下面我们先详细分析下这个类

累加器
ByteToMessageDecoder 中定义了两个累加器

public static final Cumulator MERGE_CUMULATOR = ...;
public static final Cumulator COMPOSITE_CUMULATOR = ...;

默认情况下,会使用 MERGE_CUMULATOR

private Cumulator cumulator = MERGE_CUMULATOR;

MERGE_CUMULATOR 的原理是每次都将读取到的数据通过内存拷贝的方式,拼接到一个大的字节容器中,这个字节容器在 ByteToMessageDecoder中叫做 cumulation

ByteBuf cumulation;

下面我们看一下 MERGE_CUMULATOR 是如何将新读取到的数据累加到字节容器里的

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;
}

netty 中ByteBuf的抽象,使得累加非常简单,通过一个简单的api调用 buffer.writeBytes(in); 便将新数据累加到字节容器中,为了防止字节容器大小不够,在累加之前还进行了扩容处理

static ByteBuf expandCumulation(ByteBufAllocator alloc, ByteBuf cumulation, int readable) {
        ByteBuf oldCumulation = cumulation;
        cumulation = alloc.buffer(oldCumulation.readableBytes() + readable);
        cumulation.writeBytes(oldCumulation);
        oldCumulation.release();
        return cumulation;
}

下面我们回到netty读的主流程,目光集中在 channelRead 方法,channelRead方法是每次从TCP缓冲区读到数据都会调用的方法,触发点在AbstractNioByteChannel的read方法中,里面有个while循环不断读取,读取到一次就触发一次channelRead

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    if (msg instanceof ByteBuf) {
        CodecOutputList out = CodecOutputList.newInstance();
        try {
            ByteBuf data = (ByteBuf) msg;
            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) {
                numReads = 0;
                discardSomeReadBytes();
            }

            int size = out.size();
            decodeWasNull = !out.insertSinceRecycled();
            fireChannelRead(ctx, out, size);
            out.recycle();
        }
    } else {
        ctx.fireChannelRead(msg);
    }
}

可以分为以下几个逻辑步骤
1.累加数据
2.将累加到的数据传递给业务进行业务拆包
3.清理字节容器
4.传递业务数据包给业务解码器处理

累加数据
如果当前累加器没有数据,就直接跳过内存拷贝,直接将字节容器的指针指向新读取的数据,否则,调用累加器累加数据至字节容器

ByteBuf data = (ByteBuf) msg;
first = cumulation == null;
if (first) {
    cumulation = data;
} else {
    cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
}

将累加到的数据传递给业务进行拆包
到这一步,字节容器里的数据已是目前未拆包部分的所有的数据了

CodecOutputList out = CodecOutputList.newInstance();
callDecode(ctx, cumulation, out);

callDecode 将尝试将字节容器的数据拆分成业务数据包塞到业务数据容器out中

protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
    while (in.isReadable()) {
        // 记录一下字节容器中有多少字节待拆
        int oldInputLength = in.readableBytes();
        decode(ctx, in, out);
        if (out.size() == 0) {
            // 拆包器未读取任何数据
            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;
        }
    }
}

在解码之前,先记录一下字节容器中有多少字节待拆,然后调用抽象函数 decode 进行拆包

protected abstract void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception;

netty中对各种用户协议的支持就体现在这个抽象函数中,传进去的是当前读取到的未被消费的所有的数据,以及业务协议包容器,所有的拆包器最终都实现了该抽象方法

业务拆包完成之后,如果发现并没有拆到一个完整的数据包,这个时候又分两种情况

  1. 一个是拆包器什么数据也没读取,可能数据还不够业务拆包器处理,直接break等待新的数据
  2. 拆包器已读取部分数据,说明解码器仍然在工作,继续解码

业务拆包完成之后,如果发现已经解到了数据包,但是,发现并没有读取任何数据,这个时候就会抛出一个Runtime异常 DecoderException,告诉你,你什么数据都没读取,却解析出一个业务数据包,这是有问题的

3 清理字节容器

业务拆包完成之后,只是从字节容器中取走了数据,但是这部分空间对于字节容器来说依然保留着,而字节容器每次累加字节数据的时候都是将字节数据追加到尾部,如果不对字节容器做清理,那么时间一长就会OOM
正常情况下,其实每次读取完数据,netty都会在下面这个方法中将字节容器清理,只不过,当发送端发送数据过快,channelReadComplete可能会很久才被调用一次

public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
    numReads = 0;
    discardSomeReadBytes();
    if (decodeWasNull) {
        decodeWasNull = false;
        if (!ctx.channel().config().isAutoRead()) {
            ctx.read();
        }
    }
    ctx.fireChannelReadComplete();
}

如果一次数据读取完毕之后(可能接收端一边收,发送端一边发,这里的读取完毕指的是接收端在某个时间不再接受到数据为止),发现仍然没有拆到一个完整的用户数据包,即使该channel的设置为非自动读取,也会触发一次读取操作 ctx.read(),该操作会重新向selector注册op_read事件,以便于下一次能读到数据之后拼接成一个完整的数据包

所以为了防止发送端发送数据过快,netty会在每次读取到一次数据,业务拆包之后对字节字节容器做清理,清理部分的代码如下

if (cumulation != null && !cumulation.isReadable()) {
    numReads = 0;
    cumulation.release();
    cumulation = null;
} else if (++ numReads >= discardAfterReads) {
    numReads = 0;
    discardSomeReadBytes();
}

如果字节容器当前已无数据可读取,直接销毁字节容器,并且标注一下当前字节容器一次数据也没读取

4 传递业务数据包给业务解码器处理
以上三个步骤完成之后,就可以将拆成的包丢到业务解码器处理了,代码如下

int size = out.size();
decodeWasNull = !out.insertSinceRecycled();
fireChannelRead(ctx, out, size);
out.recycle();

期间用一个成员变量 decodeWasNull 来标识本次读取数据是否拆到一个业务数据包,然后调用 fireChannelRead 将拆到的业务数据包都传递到后续的handler

static void fireChannelRead(ChannelHandlerContext ctx, CodecOutputList msgs, int numElements) {
    for (int i = 0; i < numElements; i ++) {
        ctx.fireChannelRead(msgs.getUnsafe(i));
    }
}

这样,就可以把一个个完整的业务数据包传递到后续的业务解码器进行解码,随后处理业务逻辑.

下面,以一个具体的例子来看看业netty自带的拆包器是如何来拆包的

这个类叫做 LineBasedFrameDecoder,基于行分隔符的拆包器,TA可以同时处理 \n以及\r\n两种类型的行分隔符,核心方法都在继承的 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);
    }
}

1 找到换行符位置

final int eol = findEndOfLine(buffer);

private static int findEndOfLine(final ByteBuf buffer) {
    int i = buffer.forEachByte(ByteProcessor.FIND_LF);
    if (i > 0 && buffer.getByte(i - 1) == '\r') {
        i--;
    }
    return i;
}

ByteProcessor FIND_LF = new IndexOfProcessor((byte) '\n');

for循环遍历,找到第一个 \n 的位置,如果\n前面的字符为\r,那就返回\r的位置

2 非discarding模式的处理
接下来,netty会判断,当前拆包是否属于丢弃模式,用一个成员变量来标识

private boolean discarding;

第一次拆包不在discarding模式,于是进入以下环节

非discarding模式下找到行分隔符的处理

// 1.计算分隔符和包长度
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;
  1. 首先,新建一个帧,计算一下当前包的长度和分隔符的长度(因为有两种分隔符)
    2.然后判断一下需要拆包的长度是否大于该拆包器允许的最大长度(maxLength),这个参数在构造函数中被传递进来,如超出允许的最大长度,就将这段数据抛弃,返回null
    3.最后,将一个完整的数据包取出,如果构造本解包器的时候指定 stripDelimiter为false,即解析出来的包包含分隔符,默认为不包含分隔符

2.2 非discarding模式下未找到分隔符的处理
没有找到对应的行分隔符,说明字节容器没有足够的数据拼接成一个完整的业务数据包,进入如下流程处理

final int length = buffer.readableBytes();
if (length > maxLength) {
    discardedBytes = length;
    buffer.readerIndex(buffer.writerIndex());
    discarding = true;
    if (failFast) {
        fail(ctx, "over " + discardedBytes);
    }
}
return null;

首先取得当前字节容器的可读字节个数,接着,判断一下是否已经超过可允许的最大长度,如果没有超过,直接返回null,字节容器中的数据没有任何改变,否则,就需要进入丢弃模式. 使用一个成员变量 discardedBytes 来表示已经丢弃了多少数据,然后将字节容器的读指针移到写指针,意味着丢弃这一部分数据,设置成员变量discarding为true表示当前处于丢弃模式。

3 discarding模式

如果解包的时候处在discarding模式,也会有两种情况发生

3.1 discarding模式下找到行分隔符

在discarding模式下,如果找到分隔符,那可以将分隔符之前的都丢弃掉

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);
}

计算出分隔符的长度之后,直接把分隔符之前的数据全部丢弃,当然丢弃的字符也包括分隔符,经过这么一次丢弃,后面就有可能是正常的数据包,下一次解包的时候就会进入正常的解包流程

3.2discarding模式下未找到行分隔符

这种情况比较简单,因为当前还在丢弃模式,没有找到行分隔符意味着当前一个完整的数据包还没丢弃完,当前读取的数据是丢弃的一部分,所以直接丢弃

discardedBytes += buffer.readableBytes();
buffer.readerIndex(buffer.writerIndex());

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

通常LineBasedFrameDecoder会和StringDecoder搭配使用。StringDecoder的功能非常简单,就是将接收到的对象转换成字符串,然后继续调用后面的Handler。LineBasedFrameDecoder+StringDecoder组合就是按行切换的文本解码器,它本设计用来支持TCP的粘包和拆包。对于文本类协议的解析,文本换行解码器非常实用,例如对 HTTP 消息头的解析、FTP 协议消息的解析等。

而DelimiterBasedFrameDecoder是分隔符解码器,用户可以指定消息结束的分隔符,它可以自动完成以分隔符作为码流结束标识的消息的解码。回车换行解码器实际上是一种特殊的DelimiterBasedFrameDecoder解码器。使用起来十分简单,只需要在ChannelPipeline 中添加即可。

小结

netty中的拆包过程其实是和你自己去拆包过程一样,只不过TA将拆包过程中逻辑比较独立的部分抽象出来变成几个不同层次的类,方便各种协议的扩展.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350