TCP 粘包拆包是指发送方发送的若干包数据到接收方接收时粘成一包或某个数据包被拆开接收。如下图所示,Client 发了两个数据包 D1和 D2,但是 server 端可能会收到如下几种情况的数据。
为什么出现粘包现象?
TCP 是面向连接的,面向流的,提供高可靠性服务。 收发两端(客户端和服务器端)都要有成对的 Socket,因此, 发送端为了将多个发给接收端的包,更有效的发给对方,使用了优化方法(Nagle 算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。 这样做虽然提高了效率,但是接收端就难于分辨出完整的数据包了,因为面向流的通信是无消息保护边界的。
粘包示例
这里我们采用【Netty】Socket 编程(C/S) --基于Netty的Server、Client示例(少注释)中的示例代码,将 NettyClientHandler 代码修改一下
channel 调用了 100次 writeAndFlush() ,照理说应该 Server 接收100次,但实际是这样吗?我们来启动 Server 和 Client 来测试一下:
可以看到,并不是我们预期的那 Server 接收100次消息,而是有的消息单条接收,有的是二合一,有的是三合一,其实后面还显示的还有十合一。。。这就是粘包。
=> 解决方案–拆包
针对上面出现的粘包现象,我们有什么解决方案呢?答:拆包!
-
格式化数据:每条数据有固定的格式(开始符、结束符),比如 Just do it!&&(其中&&表示这条消息结束了)。这种方法简单易行,但选择开始符和结束符的时候一定要注意,每条数据的内部一定不能出现开始符或结束符。
-
发送长度:发送每条数据的时候,将数据的长度一并发送,比如可以选择每条数据的前4位是数据的长度,应用层处理时可以根据长度来判断每条数据的开始和结束。
这方法也可以理解成自定义协议,因为它不是直接发送原数据,我们自定义消息格式
我们一般推荐使用第二种方案,因为它的实现更加优雅稳妥。下面就来看看如何通过自定义协议解决上面的问题…
1.自定义协议
这个协议很简单,就是在消息内容的基础上再添一个消息长度
/**
* 自定义协议包
*/
public class MyMessageProtocol {
// 一次发送包体内容
// 注:这里直接是字节数组,目的是适用于各种类型数据(String,int,Object...)
private byte[] content;
// 一次发送包体长度
// 注:对于不同的数据有不同长度,肯定是不能写死的,
// 如果没有特殊要求,其实也可以在 setContent 中设置 len
private int len;
public byte[] getContent() {
return content;
}
public void setContent(byte[] content) {
this.content = content;
}
public int getLen() {
return len;
}
public void setLen(int len) {
this.len = len;
}
}
2.自定义编码器
我们还需要自定义编码器,因为 Netty 没有能处理我们自定义消息体 MyMessageProtocol 的编码器,所以我们需要自己实现一个编码器,去将我们的 MyMessageProtocol 消息体转换为二进制形式
/**
* 通过继承 MessageToByteEncoder 自定义编码器
* 注:通过泛型指定编码何种类型消息
*/
public class MyMessageEncoder extends MessageToByteEncoder<MyMessageProtocol> {
@Override
protected void encode(ChannelHandlerContext ctx, MyMessageProtocol msg, ByteBuf out) throws Exception {
System.out.println("MyMessageEncoder encode 方法被调用");
// 1.通过 Netty 的 writeBytes 写出消息
out.writeBytes(msg.getContent());
// 2.通过 Netty 的 writeInt 写出长度
out.writeInt(msg.getLen());
}
}
3.注册编码器
注意,这里我们可以注册多个编码器,你想啊,我们 writeAndFlush() 发送的可能是我们自己的 MyMessageProtocol,也可能是 String,也可能是一个普通 Object。
4.修改 ClientHandler
在发送消息的时候我们需要发送的是自定义协议的消息体
5.自定义解码器
上面我们定义了处理 MyProtocol 的编码器,同样的,在 Server 收到消息后怎么拿到消息内容?
/**
* 通过继承 ByteToMessageDecoder 实现自定义解码器
* 得到二进制字节码-> MyMessageProtocol 数据包(对象)
*/
public class MyMessageDecoder extends ByteToMessageDecoder {
// 用来记录把二进制消息读到哪了
int length = 0;
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
System.out.println();
System.out.println("MyMessageDecoder decode 被调用");
//
System.out.println(in);
// 首先要读入消息长度,然后才能知道处理后面的哪些字节
// int 为 4 字节
if(in.readableBytes() >= 4) {
if (length == 0){
// 通过readInt()将4字节的length读入
length = in.readInt();
}
// 根据 length 读入内容
if (in.readableBytes() < length) {
System.out.println("当前可读数据不够,继续等待。。");
return;
}
// 构建一个 length(目标消息长度)的缓冲数组
// 注:也正是这个数组保证了读入的长度
byte[] content = new byte[length];
if (in.readableBytes() >= length){
in.readBytes(content);
// 构建 MyMessageProtocol对象,传递到下一个handler业务处理
MyMessageProtocol messageProtocol = new MyMessageProtocol();
messageProtocol.setLen(length);
messageProtocol.setContent(content);
out.add(messageProtocol);
}
length = 0;
}
}
}
6.注册解码器
跟上面注册编码器一样,只有经过解码,我们才能拿到 MyMessageProtocol 对象
注意,这里切记不能先把 StringDecoder 注册在 MyMessageDecoder 前面!因为 StringDecoder 的 decode() 就是直接把所有内容 toString(),而我们的想法是把所有消息都转成 MyMessageProtocol 的格式去接收和处理。而且,大多数情况下,解码器注册一个就够了。
7.修改 ServerHandler
既然收到的消息是 MyMessageProtocol 了,那我们的 Handler 肯定也要修改
注:修改 SimpleChannelInboundHandler 的处理类型为 MyMessageProtocol 十分重要!另外,我刚开始还尝试再新写一个 Handler,不起作用…
OK,到此大功告成!下面我们就来测试一下把。
结果演示
依次启动 Server 和 Client(注:Client 还是发送100次Just do it!),结果如下:
整个项目结构如下:
源码我放在 GitHub 上了,有兴趣的同学点击这里跳转…