TCP协议粘包、半包,Netty解码器核心逻辑

4 阅读9分钟

粘包、半包

Netty 是一个基于传输层协议构建的异步事件驱动的网络应用框架,可以非常方便构建应用层协议,Netty本身也提供了一些应用层协议的实现,如HTTP这些。

传输层负责端到端的数据传输,协议主要有:

  • TCP 面向字节:发送的最小单位是字节 有连接,可靠交付。
  • UDP: 面向报文:每次发送和接受都是以报文为单位。 无连接,不可靠交付 : 不需要建立连接,直接发送,不管是否正常收到。

数据传输: 数据先写到TCP 连接的发送缓冲区* *,TCP 协议栈后续会从这个缓冲区中取出数据,封装成 TCP 段(segments),交给 IP 层发送。

  • netty中flush,是指立即写入TCP发送缓冲区。

数据发出的时机:TCP采用滑动窗口机制,以字节为单位考虑多方面的因素将应用层数据封装成多个TCP段,交给IP层发送出去。TCP段的大小需要考虑拥塞控制(网络还能承受多少数据)、接收方窗口(接受方还能接受多少数据)、 MSS(Maximum Segment Size)等因素。

  • 所以我们每次写入的数据,不一定封装成一个完整TCP段同时发出去,这就是编程时(应用层)需要考虑的问题。
写数据-->用户缓冲区->OS缓冲区->确定这次能发送TCP段->发送TCP段->tcp段到达接受方缓冲区->接受方对应应用读取数据
  • 这里tcp段可能会因为网络原因是乱序到达,乱序到达不会立即触发读取数据。

Nagle 算法:根据一系列机制减少小数据包数量、提升网络效率的优化机制,也就是说多次写入的数据可能会放在同一个一起TCP段发出。

根据TCP发送数据的机制(面向字节,分段发送和接受,Nagle算法 ),接受方缓冲区数据的情况,基于TCP的应用层需要考虑的问题有:粘包、半包

导致这些原因主要是是应用层调用send时,传输层并没有和我们想象中的那样,立即全部将我们要发送数据一起发送出去。

粘包

多条消息被合并成一条消息,接收方无法区分数据边界。

  • Nagle算法优化
  • 网络问题多段同时到达客户端,且接收方能接收
  • 发送方发送速度过快,多条数据被合并成一个段发送

半包

一条消息被拆分成多段发送,接收方处理时无法区分数据是否完整

  • 数据量过大,被拆分成多段发送,网络问题读完一段下一段才到。

解决方案

  • 短连接:一次连接只发送一条消息,以断开连接为边界 缺点:消息长度短的时候效率低,频繁的握手和挥手。
  • 固定消息长度:接收方接收够固定长度消息后再处理 缺点:不够灵活,长度短的浪费空间,不方便处理长度长的消息
  • 采用分割符号:对于数据中包含分割符需要对分割符号进行转义处理
  • 消息分为head和body两部分,head固定长度,存放一些和body长度相关的元数据

解码器

可以用于解决半包、粘包问题的同时将字符串数据转换为对应的消息类型。属于入站处理器,通常来说需要放在入站的第一个。

Netty提供的现成的解码器:

  • netty中固定长度解码器:FixedLengthFrameDecoder
  • netty中预设长度解码器:LengthFieldBasedFrameDecoder

    new LengthFieldBasedFrameDecoder(1024, 0, 1, 0, 1);
    // 1024:数据最大1024字节
    // 0:定义长度字段的起始偏移量,即从数据包开头跳过多少字节后才能找到长度字段
    // 1:指定长度字段自身占用的字节数(如2字节可表示0~65535)
    // 0:长度字段后,0字节用于表示其他内容
    // 1:解码后需要跳过的字节数量,这里跳过长度字段(1字节)// 还有一个构造方法,最后会有一个failFast的boolean值,设置true,数据包长度超过maxFrameLength立即抛出异常
    

自定义解码器:解码器是一个入站处理器,点开FixedLengthFrameDecoder源码,仿照其写一个自己的解码器即可。想了解为什么这么做,只要往上找到对应的channelRead方法,阅读对应源码。

下面Netty解码器的核心逻辑,文末是一个总结实现一个解码器需要做那些事情。

// io.netty.handler.codec.ByteToMessageDecoder#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) {
                    // ByteBuf cumulation;
                    cumulation = data;
                } else {
                    // 积累数据:用ctx.alloc创建新的cumulation
                    cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
                }
                // 解码:显然已经猜到用了模板方法模式,具体解码过程放到子类实现,且先往后看整个方法。然后猜测out干啥
                callDecode(ctx, cumulation, out);
            } catch (DecoderException e) {
                throw e;
            } catch (Exception e) {
                throw new DecoderException(e);
            } finally {
                if (cumulation != null && !cumulation.isReadable()) {
                    // 读取完毕,释放内存
                    numReads = 0;
                    cumulation.release();
                    cumulation = null;
                } else if (++ numReads >= discardAfterReads) {
                    // 读取次数达到上限,尝试释放一些数据,防止OOM
                    // We did enough reads already try to discard some bytes so we not risk to see a OOME.
                    numReads = 0;
                    discardSomeReadBytes();
                }
             
                // out的长度
                int size = out.size();
                // 按位或:insertSinceRecycled 返回true就是本次积累已经达到解码要求(说明已经完成解码)
                // 如果完成解码:异或操作会为true,
                // firedChannelRead需要配合channelReadComplete去看:如果已经完成了一次复合要求的数据累计,就允许channelReadComplete往下进行传播,否则就继续从channel中读数据
                firedChannelRead |= out.insertSinceRecycled();
                // 往pipeline中传播读事件:点开方法,重点还是在out,依靠size往下传播
                // 看到这里能大致猜测,会在解码过程中,对out进行一些设置,以至于out的insertSinceRecycled和是size行为使得整个pipeline能够符合完整解码的行为。
                fireChannelRead(ctx, out, size);
                // 回收out:这里的数据是在 cumulation中,不关注out的recycle的行为
                // 点进去也只是把对应数组重置为空,如果好奇为什么,查看初始化这个out的方法,会发现用的是一个线程变量缓存了16个对象,这里保证每次执行完这个方法后out是干净的,至于cumulation,handler在初始化的时候始终是new,所以每个channel的cumulation是隔离的。这倒是提醒了我们,如果某些handler的执行过程是线程安全,完全可以只创建一次。
                out.recycle();
            }
        } else {
            ctx.fireChannelRead(msg);
        }
    }

callDecode中的解码流程

// io.netty.handler.codec.ByteToMessageDecoder#callDecode
protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        try {
            while (in.isReadable()) {
                int outSize = out.size();
                if (outSize > 0) {
                    // out 存放解码数据的结果集合
                    // out有值,就传播一下读事件
                    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.
                    //
                    if (ctx.isRemoved()) {
                        // handler被移除了
                        break;
                    }
                    outSize = 0;
                }
                
                //in 就是cumulation 保存可读的长度
                int oldInputLength = in.readableBytes();
                // 解码:同时提供移除重入保护
                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.
                if (ctx.isRemoved()) {
                    break;
                }
                // outSize:为解码前的容量
                if (outSize == out.size()) {
                    // 经历过一次解码后,out中没有解码成功的数据
                    if (oldInputLength == in.readableBytes()) {
                        // cumulation 的可读长度没有变化: 说明数据不够一次完整的解码,需要继续积累
                        break;
                    } else {
                        // 数据有被消费了,说明cumulation中数据被进行了一次解码操作,会重新执行循环,然后消费掉out中数据,然后继续尝试下一次解码
                        continue;
                    }
                }
               
                if (oldInputLength == in.readableBytes()) {
                    // 经历过一次解码后,out解码成功的数据量有变化,同时积累量的buff数据没变,属于不正常情况。
                    throw new DecoderException(
                            StringUtil.simpleClassName(getClass()) +
                                    ".decode() did not read anything but decoded a message.");
                }
​
                // 单次解码模式:循环只执行一次,结束本次解码,cumulation中其余数据,得等到下次被分配到线程资源的时候再执行,解码器对外提供的一种优化机制,避免线程资源被某个channel独占。
                if (isSingleDecode()) {
                    break;
                }
            }
        } catch (DecoderException e) {
            throw e;
        } catch (Exception cause) {
            throw new DecoderException(cause);
        }
    }
​
​

具体调用子类的解码流程:decodeRemovalReentryProtection

// io.netty.handler.codec.ByteToMessageDecoder#decodeRemovalReentryProtection
    final void decodeRemovalReentryProtection(ChannelHandlerContext ctx, ByteBuf in, List<Object> out)
            throws Exception {
        // handler状态标注为在解码中
        decodeState = STATE_CALLING_CHILD_DECODE;
        try {
            // 调用子类解码流程
            decode(ctx, in, out);
        } finally {
            // 解码过程中状态变了:只能是被移除了
            boolean removePending = decodeState == STATE_HANDLER_REMOVED_PENDING;
            // 重新修改为初始化
            decodeState = STATE_INIT;
            if (removePending) {
                // 被移除了,调用handlerRemoved,执行一些在解码后,需要在移除时执行的操作。
                // 为什么有一个protection:这里保证了处于移除状态时,即使handler状态被外部修改了,也能保证这个移除操作延迟到这里这里来执行。外部调用移除,触发移除事件也掉了 handlerRemoved,有对应修改为STATE_HANDLER_REMOVED_PENDING的逻辑
                handlerRemoved(ctx);
            }
        }
    }

到这里,netty能帮我们做的就这么多,核心解码算法都在decode中,需要我们自己去完成。如果你不想了解netty帮助我们做了那些事情,凭借多年的CV嗅觉,点开FixedLengthFrameDecoder代码,FixedLengthFrameDecoder的decode部分就是和你脑子里面想象的那样,拷贝过来稍微加一点逻辑即可。当然顶级的CV工程师,熟练掏出了AI工具,还没等你反应过来他已经在调试了,不幸的是有时候你下班了他还在调试。那些有追求的工程师抄完代码后,晚上下班后打开了别人博客和Netty官网偷偷学起来,摸着自己逐渐稀疏头顶,笑着迎接明日的朝阳。

// FixedLengthFrameDecoder   
@Override
    protected final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        Object decoded = decode(ctx, in);
        if (decoded != null) {
            out.add(decoded);
        }
    }
​
    protected Object decode(
            @SuppressWarnings("UnusedParameters") ChannelHandlerContext ctx, ByteBuf in) throws Exception {
        if (in.readableBytes() < frameLength) {
            // 长度不够,返回空
            return null;
        } else {
            // 长度够了,从in中读取frameLength的长度
            return in.readRetainedSlice(frameLength的长度);
        }
    }

对于自己的解码器,只需要继承ByteToMessageDecoder ,在decode中完成即可对于逻辑,符合要求后从in中读取数据,得到解码结果放入out对象中(list)中。

  • 注意:如果没有解码结果,不要消费in中的数据==>也就是说如果读取响应头,我们需要用get而不是read

我们只要完成核心的解码逻辑,粘包和半包的问题就解决了。

  • 解码逻辑会读一个完整的消息才会放入out集合中
  • Netty会从out集合中获取一个完整消息,然后继续往下传播读事件