什么是拆包和粘包

  • TCP是面向连接的,面向流的,提供高可靠性服务。收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发给接收端的包,更有效的发给对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。这样做虽然提高了效率,但是接收端就难于分辨出完整的数据包了,因为面向流的通信是无消息保护边界的。
  • 由于TCP无消息保护边界,需要在接收端处理消息边界问题,也就是我们所说的粘包、拆包问题。
  • 通常的解决方案是:发送端每发送一次消息,就需要在消息的内容之前携带消息的长度,这样,接收方每次先接受消息的长度,再根据长度去读取该消息剩余的内容。如果socket中还有没有读取的内容,也只能放在下一次读取事件中进行。

    拆包、粘包的图解

    TCP粘包和拆包 - 图1

    假设客户端同时发送了两个数据包D1和D2给服务端,由于服务端一次读取到字节数是不确定的,固可能存在以下四种情况:

  • 服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包。

  • 服务端一次接受到了两个数据包,D1和D2粘合在一起,称之为TCP粘包。
  • 服务端分两次读取到了数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这称之为TCP拆包。
  • 服务端分两次读取到了数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余部分内容D1_2和完整的D2包。

    解决方案图解

    我们可以在数据包的前面加上一个固定字节数的数据长度,如加上一个 int(固定四个字节)类型的数据内容长度
    就算客户端同时发送两个数据包到服务端,当服务端接受时,也可以先读取四个字节的长度,然后根据长度获取消息的内容,这样就不会出现多读取或者少读取的情况了。
    TCP粘包和拆包 - 图2

    TCP粘包代码示例

    本实例主要演示出现拆包和粘包的场景。
    客户端:
    1. 我们将使用循环连续发送10String类型的字符串。这里相当于发送了10次。
    2. @Override
    3. public void channelActive(ChannelHandlerContext ctx) throws Exception {
    4. //使用客户端发送10条数据,hello,server
    5. for (int i = 0; i < 10; i++) {
    6. String msg = "server" + i + " ";
    7. System.out.println("发送消息 " + msg);
    8. ByteBuf byteBuf = Unpooled.copiedBuffer(msg, CharsetUtil.UTF_8);
    9. ctx.writeAndFlush(byteBuf);
    10. }
    11. }
    服务端:
    我们接受客户端发过来的字符串。
    1. private int count = 0;
    2. @Override
    3. protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
    4. byte[] bytes = new byte[msg.readableBytes()];
    5. msg.readBytes(bytes);
    6. //将buffer转成字符串
    7. String message = new String(bytes, CharsetUtil.UTF_8);
    8. System.out.println("服务器接收到数据 " + message);
    9. System.out.println("服务器接收到消息量 = " + (++this.count));
    10. //服务器回送数据到客户端,回送一个随机Id
    11. ByteBuf response = Unpooled.copiedBuffer(UUID.randomUUID().toString() + "--", CharsetUtil.UTF_8);
    12. ctx.writeAndFlush(response);
    13. }
    服务端输出结果如下:
    TCP粘包和拆包 - 图3
    我们可以看到,服务端直接一次就把我们客户端10次发送的内容读取完成了。
    这里也印证了我们开篇所说的,当数据量小且发送间隔短,如果我们客户端每次发送的都是不同的结果,这种情况下我们就不知道客户端返回了多少次结果以及每次结果究竟是什么。这就是我们本篇需要解决的问题。

    解决方案代码示例

    使用自定义协议 + 编解码器 来解决
    关键就是要解决 服务器端每次读取数据长度的问题, 这个问题解决,就不会出现服务器多读或少读数据的问题,从而避免的TCP 粘包、拆包 。
    自定义Message对象:
    1. public class MessageProtocol {
    2. private int len; //关键
    3. private byte[] content;
    4. }
    添加将ByteBuf转换成Message的解码器:
    1. public class MessageDecoder extends ReplayingDecoder<Void> {
    2. @Override
    3. protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
    4. System.out.println("MessageDecoder 被调用");
    5. //需要将获取到的二进制字节码转换成 MessageProtocol
    6. int length = in.readInt();
    7. byte[] content = new byte[length];
    8. in.readBytes(content);
    9. //封装成 MessageProtocol 对象,放入 out,传递到下一个Handler
    10. MessageProtocol messageProtocol = new MessageProtocol();
    11. messageProtocol.setLen(length);
    12. messageProtocol.setContent(content);
    13. out.add(messageProtocol);
    14. }
    15. }
    添加将Message转换为ByteBuf的编码器:
    1. public class MessageEncoder extends MessageToByteEncoder<MessageProtocol> {
    2. @Override
    3. protected void encode(ChannelHandlerContext ctx, MessageProtocol msg, ByteBuf out) throws Exception {
    4. System.out.println("MessageEncoder 方法被调用");
    5. out.writeInt(msg.getLen());
    6. out.writeBytes(msg.getContent());
    7. }
    8. }
    客户端连续发送3个Message对象:
    1. @Override
    2. public void channelActive(ChannelHandlerContext ctx) throws Exception {
    3. //使用客户端发送10条数据,"今天天气冷,吃火锅" 编号
    4. for (int i = 0; i < 3; i++) {
    5. String message = "Server" + i;
    6. byte[] content = message.getBytes(CharsetUtil.UTF_8);
    7. int length = content.length;
    8. //创建协议包对象
    9. MessageProtocol messageProtocol = new MessageProtocol();
    10. messageProtocol.setLen(length);
    11. messageProtocol.setContent(content);
    12. ctx.writeAndFlush(messageProtocol);
    13. }
    14. }
    服务端接收:
    1. //接收的Handler继承了SimpleChannelInboundHandler,以MessageProtocol的类型接受消息
    2. @Override
    3. protected void channelRead0(ChannelHandlerContext ctx, MessageProtocol msg) throws Exception {
    4. //接收到数据,并处理
    5. int len = msg.getLen();
    6. byte[] content = msg.getContent();
    7. System.out.println("服务器第 " + (++count) +" 次接收到信息如下:");
    8. System.out.println("长度:" + len);
    9. System.out.println("内容:" + new String(content, CharsetUtil.UTF_8));
    10. //回复消息
    11. String response = UUID.randomUUID().toString();
    12. int length = response.getBytes(CharsetUtil.UTF_8).length;
    13. MessageProtocol messageProtocol = new MessageProtocol();
    14. messageProtocol.setLen(length);
    15. messageProtocol.setContent(response.getBytes());
    16. ctx.writeAndFlush(messageProtocol);
    17. }
    结果展示:
    TCP粘包和拆包 - 图4

首先,当客户端的通道激活后,就直接调用方法发送10个Message对象。
服务端接收对象时,首先调用MessageDecoder进行解码,将ByteBuf类型的数据转换成MessageProtocol,然后再进入进行读取的Handler中读取消息。
最后返回给客户端消息,调用MessageEncoder将MessageProtocol转换成Byte然后发送出去。