java网络编程——粘包拆包出现的原因及解决方式(代码片段)

胡玉洋  胡玉洋      2023-03-18     256

关键词:

在基于TCP协议的网络编程中,不可避免地都会遇到粘包和拆包的问题。


什么是粘包和拆包?

先来看个例子,还是上篇文章 《Java网络编程——NIO的阻塞IO模式、非阻塞IO模式、IO多路复用模式的使用》 中“IO多路复用模式”一节中的代码:
服务端

@Slf4j
public class NIOServer 

    public static void main(String[] args) throws Exception 
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.bind(new InetSocketAddress("127.0.0.1", 8080), 50);
        Selector selector = Selector.open();
        SelectionKey serverSocketKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        while (true) 
            int count = selector.select();
            log.info("select event count:" + count);
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) 
                SelectionKey selectionKey = iterator.next();
                if (selectionKey.isAcceptable()) 
                    handleAccept(selectionKey);
                
                else if (selectionKey.isReadable()) 
                    handleRead(selectionKey);
                
                iterator.remove();
            
        
    

    private static void handleAccept(SelectionKey selectionKey) throws IOException 
        ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel();
        SocketChannel socketChannel = serverSocketChannel.accept();
        if (Objects.nonNull(socketChannel)) 
            log.info("receive connection from client. client:", socketChannel.getRemoteAddress());
            socketChannel.configureBlocking(false);
            Selector selector = selectionKey.selector();
            socketChannel.register(selector, SelectionKey.OP_READ);
        
    

    private static void handleRead(SelectionKey selectionKey) throws IOException 
        SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
        ByteBuffer readBuffer = ByteBuffer.allocate(4);
        int length = socketChannel.read(readBuffer);
        if (length > 0) 
            log.info("receive message from client. client: message:", socketChannel.getRemoteAddress()
                    , new String(readBuffer.array(), 0, length, "UTF-8"));
         else if (length == -1) 
            socketChannel.close();
            return;
        
    
    

客户端

@Slf4j
public class NIOClient 

    @SneakyThrows
    public static void main(String[] args) 
        SocketChannel socketChannel = SocketChannel.open();
        try 
            socketChannel.connect(new InetSocketAddress("127.0.0.1", 8080));
            ByteBuffer byteBuffer1, byteBuffer2;
            socketChannel.write(byteBuffer1 = ByteBuffer.wrap("你".getBytes(StandardCharsets.UTF_8)));
            socketChannel.write(byteBuffer2 = ByteBuffer.wrap("好".getBytes(StandardCharsets.UTF_8)));
            log.info("client send finished");
         catch (Exception e) 
            e.printStackTrace();
         finally 
            socketChannel.close();
        
    
    

Run模式启动服务端后后,再运行客户端,发现服务端接收并打印的结果如下:

receive message from client. client:/127.0.0.1:63618 message:你�
receive message from client. client:/127.0.0.1:63618 message:��

咦?客户端发送的虽然是汉字,但发送和接收的都是UTF-8编码格式,怎么会乱码呢?而且第一个“你”字也被服务端解析出来了,并没有乱码。


再分别以Debug模式启动服务端、客户端来分析:
当客户端运行到log.info("client send finished");时,可以发现“你”转化为UTF-8的字节数组为[-28, -67, -96] ,“好”转化为UTF-8的字节数组为其中“你”转化为[-27, -91, -67] ,先后两次分别向服务端发送了3个字节的数据:

服务端读数据的Buffer大小为4字节,所以得分两次读取,第一次读取了前4个字节[-28, -67, -96, -27]

在第一次读取到前4个字节并根据UTF-8规则解析为汉字时,前3个字节是完整的,可以转换为“你”,但第4个字节只是“好”对应的UTF-8字节数组的一部分,是不完整的,所以在解析的时候失败了,就显示出了乱码符号。
同理,在第二次读取的后2个字节也是不完整的,解析也不会成功,也显示了2个乱码符号。

那就有人会说了,不能在读取的时候把Buffer的大小置为3、6、9吗?
这只是模拟这种情况的一个简单的例子,如果把Buffer大小设置为6,那客户端要发送“你a好”呢(UTF-8字节数组为[-28, -67, -96, 97, -27, -91, -67])?还是可能会乱码(还是会把UTF-8字节数组拆开为[-28, -67, -96, 97, -27, -91][-67]),服务端分收到这两段数据后同样无法成功解析。


这就是我们常说的拆包(也有人叫半包),对应的还有粘包,就是在通过TCP协议交互数据过程中,TCP底层并不了解它的上层业务数据(比如此文的例子中放入ByteBuffer中要发送的数据,或者HTTP报文等)的具体含义,可能会根据实际情况(比如TCP缓冲区或者此文中定义的NIO接收数据的缓冲区ByteBuffer)对数据包进行拆分或合并。
当客户端发送了一段较长的数据包时,在客户端可能会分成若干个较小的数据包分别发送,或者在服务端也可能分成了若干个较小的数据包来接收。用一句话总结就是,客户端发送的一段数据包到服务端接收后可能会被拆分为多个数据包。
当客户端发送了若干个较短的数据包时,在发送端可能会拼接成一个较大的数据包来发送,在接收端也可能会合并成一个较大的数据包来接收。用一句话总结就是,客户端发送的多段数据包到服务端接收后可能会合并分为一个数据包。
在之前的文章 《TCP协议学习笔记、报文分析》 中也遇到了粘包的情况,客户端先后向服务端分别发送了长度为20、30、40的字符串,但是通过tcpdump抓包分析的结果是客户端向服务端只发送了一条length=90的TCP报文。

如上图所示:
从逻辑上来说,客户端先后向服务端发送了两段数据包:“你”和“好”对应的字节数组。实际上可能会发生很多种情况:
理想情况:理想情况下,数据包会按照正常逻辑一样,一个一个发送到服务端。
粘包:在某些情况下,比如当TCP缓冲区剩余空间大于所有数据包的大小,且发送时间间隔很短时,客户端也有可能会把这两段数据包合并成一个进行发送。
拆包:在某些情况下,比如当TCP缓冲区剩余空间大于某个数据包的大小时,客户端可能会把这个大的数据包拆分成若干个小的数据包分别发送。


如何解决粘包和拆包?

解决粘包、拆包问题的核心,就是要确认消息边界,当接收到一个较大的数据包时,可以正确把它拆分为几段正确的、有意义的业务数据,当收到若干个小的数据包时,也可以根据消息边界把他们合并、再拆分为正确的、有意义的业务数据。

常见的解决粘包、拆包的思路有:分隔符、固定消息长度、TLV格式消息等。

1、分隔符解决粘包、拆包问题

可以用特定的分隔符来分隔消息,比如当发送“你好”([-28, -67, -96, -27, -91, -67])时,需要让“你”对应的字节数组([-28, -67, -96])作为一个整体被服务端解析,让“好”对应的字节数组([-27, -91, -67])作为一个整体被服务端解析,所以就可以在发送的时候,在“你”和“好”后面加一个分隔符(比如 “\\n”),当服务端解析到“\\n”就表示一个完整的数据包结束了。

当发生粘包时,服务端把“\\n”之前的数据当成一个完整的数据包来处理,然后继续读取数据直到再遇到“\\n”时,说明又读取到了一个完整的数据包,…… 直到把数据读完。需要注意的是,在最后一段数据最后也需要加分隔符,因为不加的话服务端可能会认为还有数据没发送完,就不会把最后一段数据当作一个完整的数据包。
在发生拆包①时,服务端读取到第一个数据包([-28, -67, -96, 10, -27])后,只会把 [-28, -67, -96] 当成一个完整的数据包来处理,然后把剩余的 [-27] 缓存起来,到了后面遇到“\\n”后,再把 [-27] 和“\\n”前面的 [-91, -67] 拼接起来当成一个完整的数据包,就可以解析成“好”。拆包②也一样。

代码实现
服务端

@Slf4j
public class NIOServer 

    private final static char SPLIT = '\\n';

    public static void main(String[] args) throws Exception 
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.bind(new InetSocketAddress("127.0.0.1", 8080), 50);
        Selector selector = Selector.open();
        SelectionKey serverSocketKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        while (true) 
            int count = selector.select();
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) 
                SelectionKey selectionKey = iterator.next();
                if (selectionKey.isAcceptable()) 
                    handleAccept(selectionKey);
                 else if (selectionKey.isReadable()) 
                    handleRead(selectionKey);
                
                iterator.remove();
            
        
    

    private static void handleAccept(SelectionKey selectionKey) throws IOException 
        ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel();
        SocketChannel socketChannel = serverSocketChannel.accept();
        if (Objects.nonNull(socketChannel)) 
            log.info("receive connection from client. client:", socketChannel.getRemoteAddress());
            socketChannel.configureBlocking(false);
            Selector selector = selectionKey.selector();
            socketChannel.register(selector, SelectionKey.OP_READ);
        
    

    private static void handleRead(SelectionKey selectionKey) throws IOException 
        SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
        ByteBuffer readBuffer = Objects.isNull(selectionKey.attachment()) ? ByteBuffer.allocate(4) : (ByteBuffer) selectionKey.attachment();
        int length = socketChannel.read(readBuffer);
        if (length > 0) 
            String readMessage = getSplitMessage(readBuffer);
            log.info("receive message from client. client: message:", socketChannel.getRemoteAddress(), readMessage);
            if (readBuffer.position()==readBuffer.capacity())
                ByteBuffer newReadBuffer=ByteBuffer.allocate(readBuffer.capacity()*2);
                readBuffer.flip();
                newReadBuffer.put(readBuffer);
                readBuffer=newReadBuffer;
            
            selectionKey.attach(readBuffer);
         else if (length == -1) 
            socketChannel.close();
            return;
        
    

    private static String getSplitMessage(ByteBuffer readBuffer) throws UnsupportedEncodingException 
        readBuffer.flip();
        StringBuilder receivedMessage = new StringBuilder();
        for (int i = 0; i < readBuffer.limit(); i++) 
            byte split = readBuffer.get(i);
            if (split == SPLIT) 
                int length = i - readBuffer.position();
                ByteBuffer byteBuffer = ByteBuffer.allocate(length);
                for (int j = 0; j < length; j++) 
                    byteBuffer.put(readBuffer.get());
                
                readBuffer.get();//把间隔符取出来
                receivedMessage.append(new String(byteBuffer.array(),0,length,"UTF-8")).append("\\n");
            
        
        readBuffer.compact();
        return receivedMessage.toString();
    
    

客户端

@Slf4j
public class NIOClient 
    @SneakyThrows
    public static void main(String[] args) 
        SocketChannel socketChannel = SocketChannel.open();
        try 
            socketChannel.connect(new InetSocketAddress("127.0.0.1", 8080));
            ByteBuffer byteBuffer1, byteBuffer2, byteBuffer3, byteBuffer4;
            socketChannel.write(byteBuffer1 = ByteBuffer.wrap("你".getBytes(StandardCharsets.UTF_8)));
            socketChannel.write(byteBuffer2 = ByteBuffer.wrap("\\n".getBytes(StandardCharsets.UTF_8)));
            socketChannel.write(byteBuffer3 = ByteBuffer.wrap("好".getBytes(StandardCharsets.UTF_8)));
            socketChannel.write(byteBuffer4 = ByteBuffer.wrap("\\n".getBytes(StandardCharsets.UTF_8)));
            log.info("client send finished");
         catch (Exception e) 
            e.printStackTrace();
         finally 
            socketChannel.close();
        
    


2、固定消息长度解决粘包、拆包问题

让每个具有意义的数据包占用固定长度的空间进行传送,当实际数据长度小于固定长度时用某种无意义的数据填充(比如空格)。假设固定长度为4,用空格填充无效数据,当发送“你好”([-28, -67, -96, -27, -91, -67])时,需要把“你”对应的字节数组([-28, -67, -96])放到一个固定长度为4的数组里([-28, -67, -96, 32]),因为“你”对应字节数组只占3位,所以剩余的一位用空格(32)来填充。同理,“好”也放到一个长度为4的字节数组中([-27, -91, -67, 32])。

当发生粘包时,服务端会依次把每4(约定的固定长度)个字节当成一个完整的数据包来处理,如果收到的数据包长度不是4的倍数,说明有拆包的情况,会把剩余数据缓存起来,等后面读取到新的数据包,会把加上之前剩余未处理的数据再次每4个字节、4个字节地读取。

代码实现
服务端

@Slf4j
public class NIOServer 

    private final static int FIXED_LENGTH = 4;

    public static void main(String[] args) throws Exception 
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.bind(new InetSocketAddress("10.100.63.93", 8080), 50);
        Selector selector = Selector.open();
        SelectionKey serverSocketKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        while (true) 
            int count = selector.select();
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) 
                SelectionKey selectionKey = iterator.next();
                if (selectionKey.isAcceptable()) 
                    handleAccept(selectionKey);
                 else if (selectionKey.isReadable()) 
                    handleRead(selectionKey);
                
                iterator.remove();
            
        
    

    private static void handleAccept(SelectionKey selectionKey) throws IOException 
        ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel();
        SocketChannel socketChannel = serverSocketChannel.accept()查看详情  

java网络编程——粘包拆包出现的原因及解决方式(代码片段)

在基于TCP协议的网络编程中,不可避免地都会遇到粘包和拆包的问题。什么是粘包和拆包?先来看个例子,还是上篇文章《Java网络编程——NIO的阻塞IO模式、非阻塞IO模式、IO多路复用模式的使用》中“IO多路复用模式... 查看详情

12.netty中tcp粘包拆包问题及解决方法(代码片段)

...文源代码总结自B站《netty-尚硅谷》;2.本文介绍了tcp粘包拆包问题;3.本文po出了粘包拆包问题解决方案及源代码实现;【1】tcp粘包拆包问题refer2HowtodealwiththeproblemofpacketstickingandunpackingduringTCPtransmission?-编程知识【1.1... 查看详情

12.netty中tcp粘包拆包问题及解决方法(代码片段)

...文源代码总结自B站《netty-尚硅谷》;2.本文介绍了tcp粘包拆包问题;3.本文po出了粘包拆包问题解决方案及源代码实现;【1】tcp粘包拆包问题refer2HowtodealwiththeproblemofpacketstickingandunpackingduringTCPtransmission?-编程知识【1.1... 查看详情

tcp的粘包拆包问题+解决方案

 为什么TCP有而UDP没有粘包❓1️⃣因为udp的数据包有保护边界。2️⃣tcp是以字节流的形式,也就是没有边界,所以应用层的数据在传输层的时候就可能会出现粘包和拆包问题。出现这种问题的原因图解 查看详情

tcp粘包拆包原因与解决方案

文章目录TCP粘包和拆包问题背景举例说明产生原因解决方案思考:UDP会不会产生粘包问题呢?TCP粘包和拆包问题背景TCP是一个“流”协议,所谓流,就是没有界限的一长串二进制数据。TCP作为传输层协议并不了解... 查看详情

tcp粘包拆包原因与解决方案

文章目录TCP粘包和拆包问题背景举例说明产生原因解决方案思考:UDP会不会产生粘包问题呢?TCP粘包和拆包问题背景TCP是一个“流”协议,所谓流,就是没有界限的一长串二进制数据。TCP作为传输层协议并不了解... 查看详情

面试官:什么是netty粘包拆包?怎么解决netty粘包拆包问题

哈喽!大家好,我是小奇,一位热爱分享的程序员小奇打算以轻松幽默的对话方式来分享一些技术,如果你觉得通过小奇的文章学到了东西,那就给小奇一个赞吧文章持续更新一、前言书接上回,昨天肯定是狗蛋通风报信,导致... 查看详情

netty之粘包拆包bytetomessagedecoder(代码片段)

目录拆包粘包处理的方式netty的处理方式netty实现编码解码1、cumulator.cumulate()拼接数据包2、callDecode()解析数据包总结LengthFieldBasedFrameDecoder拆包粘包粘包产生的原因:两个包小于缓存区的大小,传送数据会将两个包都放在... 查看详情

netty之粘包拆包bytetomessagedecoder(代码片段)

目录拆包粘包处理的方式netty的处理方式netty实现编码解码1、cumulator.cumulate()拼接数据包2、callDecode()解析数据包总结LengthFieldBasedFrameDecoder拆包粘包粘包产生的原因:两个包小于缓存区的大小,传送数据会将两个包都放在... 查看详情

netty之粘包拆包bytetomessagedecoder(代码片段)

目录拆包粘包处理的方式netty的处理方式netty实现编码解码1、cumulator.cumulate()拼接数据包2、callDecode()解析数据包总结LengthFieldBasedFrameDecoder拆包粘包粘包产生的原因:两个包小于缓存区的大小,传送数据会将两个包都放在... 查看详情

tcp粘包拆包

粘包、拆包发生原因:发生TCP粘包或拆包有很多原因,现列出常见的几点,可能不全面,欢迎补充,1、要发送的数据大于TCP发送缓冲区剩余空间大小,将会发生拆包。2、待发送数据大于MSS(最大报文长度),TCP在传输前将进行... 查看详情

tcp粘包拆包基本解决方案

上个小节我们浅析了在Netty的使用的时候TCP的粘包和拆包的现象,Netty对此问题提供了相对比较丰富的解决方案 Netty提供了几个常用的解码器,帮助我们解决这些问题,其实上述的粘包和拆包的问题,归根结底的解决方案就是... 查看详情

netty之启动类编解码器等源码解析及粘包拆包问题(代码片段)

...析netty的启动类、以及编解码器、各种协议的支持、及tcp粘包拆包的解决Netty引导BootstrapBootStrap是Netty中负责引导服务器和 查看详情

粘包拆包(分包)半包

粘包、拆包、半包理解TCP是一种面向流的网络层传输协议,在使用TCP作为传输层协议时,可保证数据的顺序性和可靠性。应用层在使用TCP协议传输数据时,可采取两种方式:短链接:客户端同服务端完成一次通信(客户端只发送... 查看详情

netty是如何解决tcp粘包拆包的?

点击关注公众号,Java干货及时送达作者:rickiyang出处:www.cnblogs.com/rickiyang/p/11074235.html我们都知道TCP是基于字节流的传输协议。那么数据在通信层传播其实就像河水一样并没有明显的分界线,而数据具体表示什么... 查看详情

十二.netty入门到超神系列-tcp粘包拆包处理(代码片段)

...别完整的数据包了(TCP无消息保护边界),可能会出现粘包拆包的问题。粘包拆包理解下面我用一个图来带大家理解什么是粘包和拆包解释一下第一次传输没有问题,数据1和数据2没有粘合,也没有拆分第二次传输,... 查看详情

tcp粘包拆包

一、什么是粘包拆包?粘包拆包是TCP协议传输中一种现象概念。TCP是传输层协议,他传输的是“流”式数据,TCP并不知道传输是哪种业务数据,或者说,并不关心。它只是根据缓冲区状况将数据进行包划分,然后进行传输... 查看详情