面试官提问: Netty 是如何解决粘包拆包问题的?

706 阅读8分钟

如果想详细学习 Netty 请看 掘金小册《Netty 编程之道》

拆包粘包的场景分析

粘包和拆包的场景主要有下图几种:

粘包拆包现象 (1).png

客户端首先和服务端建立 TCP 连接,然后客户端向服务端发送请求,我们假设客户端发送的请求就是手写 RPC 项目的 NettyRpcRequest, 我们假设一个 NettyRpcRequest 的字节大小为128,客户端向服务端发送了三个数据包:

  1. 数据包1:粘包现象。数据包1包涵两个完整的 rpcRequest 字节数组包括 rpcRequest0 和 rpcRequest1。
  2. 数据包2:拆包现象。数据包2只包涵 rpcRequest2 从0到65的字节,也就是说只有部分的 rpcRequest2 的数据。
  3. 数据包3:同时具有粘包和拆包现象。数据包3包涵了 rpcRequest2 从66到127的字节,同时包涵了全部的 rpcRequest3 的字节。

而服务端收到每一个数据包的时候是无法判断数据包内的情况的,也就是说TCP 协议是面向字节流的而不是语义。我们再看一个代码例子,这样会更加有说服力。

拆包粘包的例子

配套的源码源代码地址:github.com/sean417/net… 的 netty-rpc 模块。

下面的 sticky 包做了一个模拟粘包的例子:

image.png

首先,我们看看客户端的代码:

image.png

客户端会发送500个带有换行符的字节数组请求 pingBytes,每个 pingBytes 的末尾都会有一个换行符。

然后,再看一下 服务端的接收请求的代码:

image.png

分别执行服务端启动类 NettyStickyPacketServer 和 客户端启动类 NettyStickyPacketClient。 服务端输出结果:

image.png

image.png 大家可以看出来既有拆包的情况也有粘包的情况。拆包和粘包对接收方来说必须要解决的问题,因为接收方是要把字节流反序列化为业务对象的,而业务对象是由语义的,需要把没有语义的字节流转换为有语义的业务对象。那么我们应该怎么处理呢?

粘包拆包的解决方案

主要有两种解决方案:

一个请求由请求长度和请求体组成

反序列化类 RpcDecoder:


    public class RpcDecoder extends ByteToMessageDecoder {

        private static final int MESSAGE_BYTES_LENGTH = 4;

        private static final int MESSAGE_LENGTH_NORMAL_LENGTH = 0;

        private Class<?> targetClass;

        public RpcDecoder(Class<?> targetClass){
            this.targetClass = targetClass;
        }


        @Override
        protected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf, List<Object> list) throws Exception {
            //1.校验整体数据长度
            if(byteBuf.readableBytes()< MESSAGE_BYTES_LENGTH){
                return;
            }
            // 2.对于 byteBuf 当前可以读的 readerIndex 做一个 mark 标记
            // 这样以后可以找到发起 read 之前的 readerIndex 的位置
            byteBuf.markReaderIndex();

            // 3.读取 4 个字节的 int 类型用来表示消息的 byte 长度。
            int messageLength = byteBuf.readInt();
            // 4.如果长度小于正常对象长度,就关闭 channel。
            if(messageLength < MESSAGE_LENGTH_NORMAL_LENGTH){
                channelHandlerContext.close();
            }

            // 5.此时如果可读字节数小于对象的字节长度
            // 本质是检查是否是拆包问题
            if(byteBuf.readableBytes() < messageLength){
                复位 readerIndex,下次再从对象体开始的地方读。
                byteBuf.resetReaderIndex();
                return;
            }
            // 6.读出 byte 数组
            byte[] bytes = new byte[messageLength];
            byteBuf.readBytes(bytes);
            // 7.反序列化
            Object object = HessianSerialization.deserialize(bytes, RpcRequest.class);
        }
    }

decode 的逻辑比较复杂,我给大家分步骤介绍一下。

一个请求由请求长度和请求体组成.png

  1. 首先,byteBuf 有两个部分,分别为:
  • 对象体字节长度:Int 类型,表示对象序列化后的字节长度。

  • 对象体:对象序列化后的字节数组。 对于上图来说,如果对象体字节大小为10,对象体的字节数就是10。

    我们首先要把对象体字节长度解析出来,因为对象体长度是一个 Int 类型,如果 byteBuf 的长度连 4 个字节都没有,那么就要等待下次一起解析。原因也很好理解,TCP 是流式传输的,我们不能控制 TCP 每次发送了多少数据,每次能发送多少数据是 TCP 本身根据网络与系统的具体情况决定的。

  1. 把 readerIndex 的值通过调用 byteBuf.markReaderIndex() 保存下来,后面的步骤会有用。
  2. 我们把对象体长度读取出来。
  3. 如果对象长度小于 0,或其他我设定的值,那么我们就认为对象不合法,这时会关闭 channel。
  4. 检验当前 byteBuf 对象剩余长度是否小于获得的对象体长度,如果小于,那么就会把 readerIndex 复位,然后返回,等到下次再读取。因为这时出现了拆包,我们这次读的不是一个完整的对象体,需要等待对象体都接收完了再反序列化。
  5. 如果可读的 byte 数组长度大于等于对象长度,那么就把序列化的对象读到 byte 数组中。
  6. 反序列化。

流程有些复杂,给大家画了个流程图:

Decode流程.png

总之,这里关键是定长的4个字节的对象体字节长度,有了长度之后可以用固定的逻辑去解决粘包和拆包的问题了。

通过 Netty 内部内置的 handler 来处理粘包拆包

代码都在下图的包下:

image.png

主要的改动是服务端和客户端都加了两个 Handler,分别是:LineBasedFrameDecoder 和 StringDecoder,具体改动如下:

服务端: image.png

客户端:

image.png

运行结果:

服务端: image.png

客户端:

image.png

可以看到,没有粘包和拆包的问题。

LineBasedFrameDecoder 和 StringDecoder 相互配合才能实现上面的效果。LineBasedFrameDecoder 负责把字节流按换行符截取。 StringDecoder 收到截取的字节流后把字节流转换为字符串。

首先,我们来看 LineBasedFrameDecoder:

LineBasedFrameDecoder

这个 Hander 的作用是根据行结束符

我们先看它的构造方法:

image.png 有这么三个参数:

  • maxLength: 最大长度。表示还没遇到换行符的情况下已经读了多少字节。
  • failFast: 虽然读到了分隔符,但是buffer 已读的字节长度超过了最大字节数是否不解析直接抛出异常。默认为 true。
  • stripDelimiter:是否删除分隔符。所谓分隔符是指两个有完整语义的请求之间需要一个标记,用来表示这是两个请求分隔的地方。默认为 true。

接下来,重点多看看核心方法 decode():


protected Object decode(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception {
    // 遍历字符数据,找到一行的换行符的 index
    final int eol = findEndOfLine(buffer);
    // 是否抛弃
    if (!discarding) {
        // 找到换行符的 index 了
        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;
            // 没有找到换行符
        } else {
            final int length = buffer.readableBytes();
            // 判断是否超过最大字符数
            if (length > maxLength) {
                discardedBytes = length;
                buffer.readerIndex(buffer.writerIndex());
                discarding = true;
                offset = 0;
                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);
            discardedBytes = 0;
            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;
    }
}
  1. 首先,通过调用 findEndOfLine(buffer) 找到字节流中的换行符在字节流 buffer 中的索引 eol, eol 的值小于0时,就是没有找到换行符。
  2. 然后,判断是否要抛弃字节流 buffer,抛弃的原因无非是字节流的长度大于最大字节流长度。
  3. 如果不用抛弃,且 eol>0:说明现在的字节流 buffer 里已经有换行符了。这时,我们需要做的是获取换行符以前的字节流 buffer 的分片 frame,这个 frame 我们就认为是一个语义完整的请求,然后跳过换行符,也就是说下次不会读到这个换行符,因为换行符对于语义来说没有任何的意义。
  4. 如果 eol 不大于0,首先,我们要判断现在 buffer 的长度是否超过了我们设定的最大长度,如果超过了,我们就把要抛弃的标志(discarding)设置为 true,等待抛弃。
  5. 如果 buffer 的长度正常我们就认为我们还没有拿到换行符,也就是说出现了拆包,这时,我们把 buffer 的读索引设置为写索引,也就是说读到了 buffer 的末尾。这样做的目的是为了下次接着读取 buffer 上新的数据包。
  6. 最后,如果要抛弃,直接把 buffer 的读索引设置为写索引,也就是 buffer 的当前字节数组的末尾。

除了根据换行符来切分字符流外,还有别的一些切分字符流的方式,比如提供自定义分隔符的 DelimiterBasedFrameDecoder

image.png 这个处理拆包粘包的 Decoder 的好处是可以自定义分隔符,自定义分隔符是通过构造方法的参数 delimiter,可以随意自定义分隔符。

FixedLenghFrameDecoder 还有把定长的字符数作为一个完整语义:

image.png

总结

这节课给大家介绍了粘包拆包的解决方案。

首先给大家介绍了粘包拆包的场景。

然后,给大家介绍了通过设定一个完整语义的字节流长度来避免粘包拆包的问题。

随后,给大家介绍了三种处理粘包拆包的 Netty 内置的 Decoder:

  • LineBasedFrameDecoder
  • DelimiterBasedFrameDecoder
  • FixedLenghFrameDecoder

在生产环境上我还是建议大家用设定字节流长度方法来解决拆包粘包的问题,因为这个方案会更加的通用,而且 4 个字节长度的字节流长度也不会浪费太多流量。

如果想详细学习请看 掘金小册《Netty 编程之道》 你将获得:

  • 全面学习 Netty 各个组件,系统掌握 Netty 的使用;
  • 系统学习 TCP 网络协议及 JAVA NIO;
  • 以 Netty 源码为基础,彻底了解 Netty 底层原理;
  • 2+ 个完整 Netty 项目开发流程及源代码;