TCP 粘包和拆包

TCP 粘包和拆包

基本概念

TCP 是一种面向连接的、可靠的、基于字节流的传输层通信协议,正因为它是基于字节流的传输协议,它的协议字段中是不带有长度的,数据接收方是不知道需要接收多少的数据才算接收完成。因此对于一端发送的消息,另一端读取的时候是不知道是否读到了完整的消息。

在 TCP 协议中,进行数据包的发送时,会根据 TCP 缓冲区的实际情况对包进行划分,可能会将一个包拆分开来进行发送,也可能将多个包合并再一起发送,这就是所谓的 TCP 粘包和拆包问题。

如下图所示,可以对这个问题进行说明。客户端向服务端发送两条消息 Msg1 和 Msg2,由于 TCP 传输协议的特点,服务端一次读取到的字节数是不确定的,可以分为以下四种情况:

  1. 服务端分两次接收到了两个独立的数据包,分别是 Msg1 和 Msg2 ,没有发生粘包和拆包。
  2. 服务端一次接收到一个数据包,其中包括了 Msg1 和 Msg2,发生了粘包。
  3. 服务端分两次接收到了两个数据包,第一个是完整的 Msg1 和 Msg2 的一部分,第二个是 Msg2 剩下的部分,发生了拆包。
  4. 服务段接收到多余两个数据包,两条消息被分成了多个数据包发送。

产生原因

发生 TCP 粘包、拆包主要是以下原因 :

  1. 应用程序写入的数据大于套套接字的缓冲区大小,会发生拆包;小于套接字缓冲区大小会发生粘包。
  2. 进行MSS大小的TCP分段。
  3. 应用程序不计数读取套接字缓冲区内的数据,会发生粘包。

模拟粘包拆包的发生

服务端代码

/**
 * @author XinHui Chen
 * @date 2020/2/6 22:11
 */
public class TCPServer {
    private boolean stop = false;

    private Selector selector = null;

    TCPServer() {
        try {
            selector = Selector.open();
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            serverSocketChannel.configureBlocking(false);
            serverSocketChannel.bind(new InetSocketAddress("localhost", 8888));
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
            System.out.println("server started at localhost:8888.");
        } catch (IOException e) {
            System.out.println("fail to open TCP server " + e.getMessage());
        }
    }

    public void start() {
        while (!stop) {
            try {
                selector.select(1000);
            } catch (IOException e) {
                System.out.println("fail to select " + e.getMessage());
            }
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();
                
                if (key.isAcceptable()) {
                    handleAccept(key);
                }

                if (key.isReadable()) {
                    handleRead(key);
                }

                if (key.isReadable()) {
                    SocketChannel sc = (SocketChannel) key.channel();
                    ByteBuffer byteBuffer = ByteBuffer.allocate(32);
                    int len = 0;
                    try {
                        len = sc.read(byteBuffer);
                    } catch (IOException e) {
                        key.cancel();
                        System.out.println("fail to read " + e.getMessage());
                    }
                    if (len > 0) {
                        byteBuffer.flip();
                        System.out.print("[server] receive: ");
                        System.out.println(new String(byteBuffer.array()));
                    } else if (len == -1) {
                        try {
                            sc.close();
                            System.out.println("close connection...");
                        } catch (IOException e) {
                            System.out.println("fail to close channel " + e.getMessage());
                        }
                        key.cancel();
                    }
                }
            }
        }
    }
    
    public void handleAccept(SelectionKey key) {
        ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
        SocketChannel sc;
        try {
            sc = ssc.accept();
            sc.configureBlocking(false);
            sc.register(selector, SelectionKey.OP_READ);
            System.out.println("client " + sc.getRemoteAddress().toString() + " connected");
        } catch (IOException e) {
            key.cancel();
            System.out.println("fail to accept " + e.getMessage());
        }
    }
    
    public void handleRead(SelectionKey key) {
        SocketChannel sc = (SocketChannel) key.channel();
        ByteBuffer byteBuffer = ByteBuffer.allocate(32);
        int len = 0;
        try {
            len = sc.read(byteBuffer);
        } catch (IOException e) {
            key.cancel();
            System.out.println("fail to read " + e.getMessage());
        }
        if (len > 0) {
            byteBuffer.flip();
            System.out.print("[server] receive: ");
            System.out.println(new String(byteBuffer.array()));
        } else if (len == -1) {
            try {
                sc.close();
                System.out.println("close connection...");
            } catch (IOException e) {
                System.out.println("fail to close channel " + e.getMessage());
            }
            key.cancel();
        }
    }

    public static void main(String[] args) {
        new TCPServer().start();
    }

}

客户端代码

/**
 * @author XinHui Chen
 * @date 2020/2/6 22:11
 */
public class TcpClient {
    private boolean stop = false;

    private Selector selector = null;

    TcpClient() {
        try {
            selector = Selector.open();
            SocketChannel socketChannel = SocketChannel.open();
            socketChannel.configureBlocking(false);
            socketChannel.connect(new InetSocketAddress("localhost", 8888));
            socketChannel.register(selector, SelectionKey.OP_CONNECT);
            System.out.println("connect to localhost:8888");
        } catch (IOException e) {
            System.out.println("fail to connect " + e.getMessage());
        }
    }

    public void start() {
        while (!stop) {
            try {
                selector.select(1000);
            } catch (IOException e) {
                System.out.println("fail to select " + e.getMessage());
            }
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();

                if (key.isConnectable()) {
                    handleConnect(key);
                }

                if (key.isWritable()) {
                    handleWrite(key);
                }
            }
        }
    }

    public void handleConnect(SelectionKey key) {
        SocketChannel sc = (SocketChannel) key.channel();
        try {
            if (sc.finishConnect()) {
                sc.register(selector, SelectionKey.OP_WRITE);
            }
        } catch (IOException e) {
            System.out.println("fail to connect " + e.getMessage());
        }
    }

    public void handleWrite(SelectionKey key) {
        SocketChannel sc = (SocketChannel) key.channel();
        for (int i = 0; i < 100; i++) {
            String message = i + " message from client!";
            int len = message.length();
            ByteBuffer byteBuffer = ByteBuffer.allocate(4 + len);
            byteBuffer.putInt(len);
            byteBuffer.put(message.getBytes());
            System.out.println(String.format("[client] send: length[%d], %s", len, message));
            byteBuffer.flip();
            while (byteBuffer.hasRemaining()) {
                try {
                    sc.write(byteBuffer);
                } catch (IOException e) {
                    System.out.println("fail to write " + e.getMessage());
                }
            }
        }
        try {
            sc.close();
            selector.close();
        } catch (IOException e) {
            System.out.println("fail to close " + e.getMessage());
        }
        stop = true;
    }

    public static void main(String[] args) {
        new TcpClient().start();
    }
}

客户端向服务端连续写100条消息,客户端控制台输出为:

connect to localhost:8888
[client] send: 0 message from client!
[client] send: 1 message from client!
...
[client] send: 64 message from client!
[client] send: 65 message from client!
[client] send: 66 message from client!
[client] send: 67 message from client!
[client] send: 68 message from client!
[client] send: 69 message from client!
[client] send: 70 message from client!
[client] send: 71 message from client!
[client] send: 72 message from client!
[client] send: 73 message from client!
[client] send: 74 message from client!
[client] send: 75 message from client!
[client] send: 76 message from client!
[client] send: 77 message from client!
[client] send: 78 message from client!
[client] send: 79 message from client!
[client] send: 80 message from client!
[client] send: 81 message from client!
[client] send: 82 message from client!
[client] send: 83 message from client!
[client] send: 84 message from client!
...
[client] send: 99 message from client!

服务端接收到的结果为:

server started at localhost:8888.
client /127.0.0.1:56644 connected
[server] receive: 0 message from client!          
[server] receive: 1 message from client!          
...       
[server] receive: 64 message from client!         
[server] receive: 65 message from client!66 messag
[server] receive: e from client!67 message from cl
[server] receive: ient!68 message from client!69 m
[server] receive: essage from client!70 message fr
[server] receive: om client!71 message from client
[server] receive: !72 message from client!        
[server] receive: 73 message from client!         
[server] receive: 74 message from client!75 messag
[server] receive: e from client!76 message from cl
[server] receive: ient!77 message from client!    
[server] receive: 78 message from client!79 messag
[server] receive: e from client!80 message from cl
[server] receive: ient!81 message from client!82 m
[server] receive: essage from client!             
[server] receive: 83 message from client!
[server] receive: 84 message from client!         
...        
[server] receive: 99 message from client!         
close connection...

可以看到在第64条消息到84条消息之间发生了不同程度的拆包(程序的接收缓冲区只有32B,如果大小合适,可以看到粘包现象)。

解决方案

为了解决这一问题,根本目的就是要划分出每一条消息的界限,可以从数据传输的格式出发:

  1. 每次传输一定长度的字节,比如 200 个字节。这样客户端和服务端都约定好了长度,就算遇到粘包和拆包现象也能分清楚消息的界限。
  2. 使用特定的分隔符来表示消息的结束。
  3. 采用消息头和消息体的形式,消息头中可以包含消息长度等信息,消息头的长度固定。

使用 Java NIO 实现第三种方式来解决拆包粘包问题,其中消息头为 4 字节,代表消息的长度,剩余部分为消息本身,修改服务端代码如下所示:

/**
 * @author XinHui Chen
 * @date 2020/2/6 22:11
 */
public class TcpServer {
    private boolean stop = false;

    private int length = -1;

    private Selector selector = null;

    private ByteBuffer byteBuffer = null;

    TcpServer() {
        try {
            byteBuffer = ByteBuffer.allocate(16);
            selector = Selector.open();
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            serverSocketChannel.configureBlocking(false);
            serverSocketChannel.bind(new InetSocketAddress("localhost", 8888));
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
            System.out.println("server started at localhost:8888.");
        } catch (IOException e) {
            System.out.println("fail to open TCP server " + e.getMessage());
        }
    }

    public void start() {
        while (!stop) {
            try {
                selector.select(1000);
            } catch (IOException e) {
                System.out.println("fail to select " + e.getMessage());
            }

            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();
                if (key.isAcceptable()) {
                    handleAccept(key);
                }

                if (key.isReadable()) {
                    handleRead(key);
                }
            }
        }
    }

    public void handleAccept(SelectionKey key) {
        ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
        SocketChannel sc;
        try {
            sc = ssc.accept();
            sc.configureBlocking(false);
            sc.register(selector, SelectionKey.OP_READ);
            System.out.println("client " + sc.getRemoteAddress().toString() + " connected");
        } catch (IOException e) {
            key.cancel();
            System.out.println("fail to accept " + e.getMessage());
        }
    }

    public void handleRead(SelectionKey key) {
        SocketChannel sc = (SocketChannel) key.channel();
        int len = 0;
        try {
            len = sc.read(byteBuffer);
        } catch (IOException e) {
            key.cancel();
            System.out.println("fail to read " + e.getMessage());
        }
        if (len != -1) {
            while (true) {
                byteBuffer.flip();
                if (!byteBuffer.hasRemaining()) {
                    byteBuffer.compact();
                    break;
                }
                if (length == -1) {
                    if (byteBuffer.remaining() >= 4) {
                        length = byteBuffer.getInt();
                    } else {
                        byteBuffer.compact();
                        break;
                    }
                    System.out.print(String.format("[server] receive: length[%d], ", length));
                }

                if (byteBuffer.remaining() < length) {
                    length -= byteBuffer.remaining();
                    byte[] bytes = new byte[byteBuffer.remaining()];
                    byteBuffer.get(bytes);
                    System.out.print(new String(bytes));
                } else {
                    byte[] bytes = new byte[length];
                    byteBuffer.get(bytes);
                    System.out.println(new String(bytes));
                    length = -1;
                }
                byteBuffer.compact();
            }
        } else {
            try {
                sc.close();
                System.out.println("close connection...");
            } catch (IOException e) {
                System.out.println("fail to close channel " + e.getMessage());
            }
            key.cancel();
        }
    }

    public static void main(String[] args) {
        new TcpServer().start();
    }
}

测试后发现,无论程序的缓冲区设置成多大,都能正确的接收到客户端发送的 100 条消息,成功的解决了拆包和粘包的问题。

如果使用之前的服务端,当服务端的缓冲区设置的很小的时候(小于一条消息的长度),那么服务端无法正常输出消息。但是用了修改后的代码,程序可以正常允许。读者可以自己尝试一下。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值