Netty快速入门系列(六)-理解粘包以及处理
Netty快速入门系列(六)-理解粘包拆包以及处理
1. 粘包拆包的定义
我们通常说的粘包都是指的在 TCP 传输过程中,多个数据包被合并成了一个包,或者一个大的数据包。
举个例子:
假设客户端往服务端发送两条消息:【Hello, 】和【World!】,就有可能会出现几种情况。
- 情况1:两条消息被分 2 次发送,服务端收到的是包1【Hello, 】和包2【World!】 这两个包。
- 情况2:两条消息被合并在一起发送。 这样服务端收到的就是【Hello, World!】 这一个包。
- 情况3:两条消息有一部分消息被一起发送,可能服务端收到的是【Hello, Wor】【ld!】 这两个包。
上面的情况 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 数据通常基于 {} [] 等进行检测是否完整对象, 所以也是需要进行自定义解码的。
总之通过上面的这些解码器,就可以很好的处理应用层的粘包、拆包问题。 在实际项目开发过程中,也是一个比较核心的知识点。
参考链接
- Transmission Control Protocol https://en.wikipedia.org/wiki/Transmission_Control_Protocol
- 传输控制协议 https://baike.baidu.com/item/TCP/33012