七的博客

Netty快速入门系列(六)-理解粘包以及处理

网络通信

Netty快速入门系列(六)-理解粘包拆包以及处理

1. 粘包拆包的定义

我们通常说的粘包都是指的在 TCP 传输过程中,多个数据包被合并成了一个包,或者一个大的数据包。

举个例子:

假设客户端往服务端发送两条消息:【Hello, 】和【World!】,就有可能会出现几种情况。

  • 情况1:两条消息被分 2 次发送,服务端收到的是包1【Hello, 】和包2【World!】 这两个包。

发送情况1

  • 情况2:两条消息被合并在一起发送。 这样服务端收到的就是【Hello, World!】 这一个包。

情况2

  • 情况3:两条消息有一部分消息被一起发送,可能服务端收到的是【Hello, Wor】【ld!】 这两个包。

情况3

上面的情况 2 跟情况 3 都是属于粘包的现象。

而拆包则是相反的现象,就是一个数据包被拆分成多个小包的现象。

假设客户端往服务端发送一条消息【Hello,World!】,这条消息可能会被拆分,服务端可能就会收到2个包,分别是包1【Hello】、包2【, World!】。 这种现象则是称为拆包。

拆包现象

2. 粘包拆包的背景

上面说的拆包或者粘包的现象,本质上都是基于 TCP 通信中,应用层出现的一些问题。 本质上跟 TCP 协议是没有关系的,因为 TCP 协议就是基于流通信的,不关心应用层的包边界在哪里。

粘包产生的原因有下面几点: - TCP 协议是面向流的协议,不保证消息的边界。 - TCP 协议会使用 Nagle 算法延迟发送小的包。如果每次发送的包太小,那么传输效率低,所以通常会将多个小包合并一起发送。

拆包产生的原因有下面几点:

  • TCP 的数据包受 TCP 最大报文长度 MSS ( Maximum Segment Size ) 的限制,不能超过 MSS 的值。 超过这个抄读的包将会被分割成多段,这是传输层的限制。
  • TCP 的数据包受 MTU ( Maximum Transmission Unit ) 的限制。MTU 是链路层每次最大传输数据的大小,通常来说是 1500 字节。大于 MTU 的包, IP 层会进行分包。
  • 接收方的缓冲区不足,就不能发送这么多数据给对方,所以也会导致分多次发送。
  • 网络情况不好的情况下,可能会产生丢包的情况,这种情况下可能会进行重传。

3. 应用层的粘包拆包处理

上面讲了一些粘包拆包的背景,Netty 作为一个应用层的网络通信框架,是有提供一些内置的解码器来解决这些问题的。

3.1 FixedLengthFrameDecoder

基于固定长度的解码器,这个解码器假定所有的消息都会有固定的长度。

使用解码器:

// 下面这个解码器配置切分成固定长度为3字节的消息。
pipeline.addLast(new FixedLengthFrameDecoder(3));

比如下面的消息都是固定3个字节去划分:

ABCDEFGHI 

那么经过解码器进行划分后,应用层就会拿到下面 3 条消息:

ABC
DEF
GHI

在实际的项目中,这种解码器用的场景不多。缺点主要是以下几点:

  • 不太灵活,毕竟不是每一条消息都可以严格控制在指定的长度内。
  • 消息如果小于固定长度的话,需要发送方手动填充空格等等数据,造成资源的浪费。
  • 不适用于长度不固定的消息。

3.2 DelimiterBasedFrameDecoder

基于分隔符的解码器,就是使用一些特殊符号来区分消息的边界,通常用换行符用的比较多。

使用解码器:

final ByteBuf delimiter = Unpooled.copiedBuffer("\n".getBytes());
pipeline.addLast(new DelimiterBasedFrameDecoder(1024, delimiter));

这会使用换行符作为分隔符,将入站数据切分成消息。最大帧长度设置为1024字节。

比如下面 2 条消息通过换行符 \n 来进行划分:

ABC\nDEF\n

那么应用层就会收到 【ABC】 【DEF】 两条消息。

在实际项目中有一定的运用场景,特别是一些文本类的协议。但是缺点也很明显:

  • 数据中如果包含分隔符的话,就需要单独转义下,不然会当做协议的分隔符处理。
  • 分隔符丢失的话,就会出现粘包的情况,数据就会错乱。

3.3 LengthFieldBasedFrameDecoder

基于长度字段的解码器。这个解码器比较灵活,可以适用于消息包含长度字段的情况。

这个解码器是最为常用的一种,很灵活,可以处理各种复杂的消息格式。 而且支持边长的消息,效率也比较高。

使用解码器:

pipeline.addLast(new LengthFieldBasedFrameDecoder(
    1024,  // 最大帧长度
    0,     // 长度字段的偏移量(0就是从消息开始)
    4,     // 长度字段的字节数
    0,     // 长度调整值 (如果长度字段只包含消息体的长度,这里为0)
    4      // 从解码帧中跳过的字节数 (即长度字段的字节数)
));

上面这个例子假设每个消息的前4个字节是一个整数,表示消息体的长度。

上面的解码器假设的报文格式是:

假设的协议格式

这个例子没啥很突出的缺点,就是配置复杂一点,初学者要理解这几个参数需要花点时间。

上面几个参数解释:

  • 最大帧长度。设置可接收的最大消息长度,防止内存溢出。
  • 长度字段偏移量。指定长度这个字段在字节序列中的起始位置。上面的示例中就是0。
  • 长度字段长度。就是长度这个字段几个字节,通常都是指定4个字节,因为长度通常是 int 类型。
  • 长度调整值。如果长度字段的值不包含消息头的长度,就需要调整。上面例子是0。
  • 解码后跳过的字节数。解码时跳过的字节数,通常等于长度字段的长度,以便不在解码后的消息中包含长度字段。

3.4 自定义解码器

有些场景下,内置的解码器可能不太符合要求。可能需要自定义解码器,这种情况下 Netty 也是支持的。

自定义解码器需要继承 ByteToMessageDecoder 抽象类,然后实现 decode 方法。

举个简单的例子:

public class ProtocolMessageSpiltDecoder extends ByteToMessageDecoder {

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        // 读取数据长度,小于4个字节肯定不完整,一条消息至少4个字节以上
        if (in.readableBytes() < 4) {
            return;
        }
        // 标记一下,开始读取数据的位置
        
        in.markReaderIndex();
        // 读取前4个字节,也就是一个 int 数值长度域
        final int length = in.readInt();

        // 如果当前缓冲区的可读字节数小于预期长度的话,说明包还不完整,继续等待完整的包。
        if (in.readableBytes() < length) {
            // 因为本次不读取了,所以将读取下标切换到一开始标记的位置
            in.resetReaderIndex();
            return;
        }

        // 解码数据,只读取指定长度的字节数,这样保证拿到只是一条完整的消息
        byte[] data = new byte[length];
        in.readBytes(data);
        
        final String content = new String(data, StandardCharsets.UTF_8);
        out.add(content);
    }
}

同时 Netty 中也提供一些比较实用的自定义解码器,比如 JsonObjectDecoder ,这个就是对 JSON 数据的处理。 JSON 数据通常基于 {} [] 等进行检测是否完整对象, 所以也是需要进行自定义解码的。

总之通过上面的这些解码器,就可以很好的处理应用层的粘包、拆包问题。 在实际项目开发过程中,也是一个比较核心的知识点。

参考链接