背景
可以通过图解对TCP粘包和拆包进行说明,具体内容如下图
设想一下,客户端向服务端分别发送了两个数据包,名为 D1 和 D2。鉴于服务端每次读取的字节数量并不确定,于是就可能出现以下五种情形:
- 服务端通过两次读取操作,获取到了两个相互独立的数据包,即 D1 和 D2,在此过程中不存在粘包或者拆包现象。
- 服务端一次性接收了两个数据包,此时 D1 和 D2 粘合在了一起,这种情况被称作 TCP 粘包。
- 服务端分两次进行读取,第一次读取到了完整的 D1 包以及 D2 包的部分内容,第二次则读取到了 D2 包的剩余内容,这便被称为 TCP 拆包。
- 服务端同样分两次读取,第一次读取到了 D1 包的部分内容 D1_1,第二次读取到了 D1 包的剩余内容 D1_2 以及 D2 包的完整内容。
- 如果滑动窗口特别小,那么可能 D1 D2 包就会变成多个拆分的包
总之,除了第一种情况是正常的,其余都是不正常的。
解决策略
鉴于底层的 TCP 无法理解上层的业务数据,因而在底层层面无法确保数据包不会被拆分以及重组。这个问题唯有通过上层的应用协议栈设计来加以解决。
依据业界主流协议的解决方案,可以总结如下:
- 设定消息为定长,例如规定每个报文的大小固定为 200 字节,若长度不足,则用空位填补空格。
- 在包尾添加回车换行符进行分割,就像 FTP 协议那样。
- 把消息划分为消息头和消息体两部分,在消息头中包含能够表示消息总长度(或者消息体长度)的字段,通常的设计思路是在消息头的第一个字段使用 int32 来表示消息的总长度。
- 更为复杂的应用层协议。
评估如下:
- 第一种方案,在具体业务中,日志的长度不是固定的,要是设置的小了,可能会拆包,要是设置的大可以填补,但是具体我要设置多大呢?1K,1M 么?
- 第二种方案,要是根据特殊符号进行分割,那日志中刚好有对应的特殊符号,要怎么处理呢?
- 第三种方案,可以封装成一个合适的消息对象,然后再进行交互,这个方式还是可以的。
- 第四种方案,直接使用更上层的应用协议,其实就是不用自己再去实现第三种方案,上层协议已经帮你实现了,在这里就不讨论了。
那么我们就看下只用 TCP 协议的话,如何解决粘包和拆包的问题吧
方案
我选择添加一个特殊的头部信息进行处理,如果不是对应的头部信息,将不进行处理,其中消息内容如下:
描述 | 长度(位) | 描述 |
---|---|---|
标识符 | 2 | 确认该信息是否是自定义的信息 |
长度 | 4 | 表示这个消息的长度是多少 |
数据 | n | 数据 |
发送端添加头部信息(Client)
在调用 writeAndFlush
时,对数据 body 添加头部信息代码如下所示:
scss
private static byte[] appendCustomPrefix(byte[] body) {
byte[] bodyLength = intToByteArray(body.length);
byte[] result = new byte[TCP_PREFIX.length + bodyLength.length + body.length];
//添加自定义标识前缀
System.arraycopy(TCP_PREFIX, 0, result, 0, TCP_PREFIX.length);
//添加长度前缀
System.arraycopy(bodyLength, 0, result, TCP_PREFIX.length, bodyLength.length);
//添加数据
System.arraycopy(body, 0, result, TCP_PREFIX.length + bodyLength.length, body.length);
return result;
}
public static final byte[] TCP_PREFIX = new byte[]{(byte) '0', (byte) '1'};
private static byte[] intToByteArray(int value) {
return new byte[]{(byte) ((value >> 24) & 0xFF), (byte) ((value >> 16) & 0xFF), (byte) ((value >> 8) & 0xFF), (byte) (value & 0xFF)};
}
接收端去掉头部信息(Server)
在 TcpServer 端的 ChannelHandler 中decode 数据时,添加如下的逻辑,去掉头部信息,代码如下:
ini
public class TioDelimiterBasedFrameDecoder {
public List<byte[]> unpack(byte[] message) {
List<byte[]> result = new ArrayList<>();
if (message == null || message.length < 6) {
return addMessage(message, 0, 0, result);
}
return unpack(message, 0, result);
}
private List<byte[]> unpack(byte[] message, int start, List<byte[]> result) {
if (start >= message.length) {
return result;
}
if (!isCustomMessage(message, start)) {
return addMessage(message, start, message.length, result);
}
start += TCP_PREFIX.length;
int length = byteArrayToInt(message, start);
start += 4;
int end = start + length;
if (end > message.length) {
return addMessage(message, start, message.length, result);
}
addMessage(message, start, end, result);
unpack(message, end, result);
return result;
}
private boolean isCustomMessage(byte[] message, int start) {
for (int i = 0; i < TCP_PREFIX.length; i++) {
if (message[start + i] != TCP_PREFIX[i]) {
return false;
}
}
return true;
}
private List<byte[]> addMessage(byte[] message, int start, int end, List<byte[]> result) {
if (start == 0 || end == 0) {
result.add(message);
} else {
byte[] data = new byte[end - start];
System.arraycopy(message, start, data, 0, end - start);
result.add(data);
}
return result;
}
private int byteArrayToInt(byte[] bytes, int start) {
int value = 0;
for (int i = start; i < start + 4; i++) {
value = (value << 8) | (bytes[i] & 0xFF);
}
return value;
}
}
其他方案
如果是使用的 netty 可以添加 DelimiterBasedFrameDecoder
和 StringDecoder
的ChannelHandler 的方式。