面試突擊70:什麼是粘包和半包?怎麼解決?

語言: CN / TW / HK

攜手創作,共同成長!這是我參與「掘金日新計劃 · 8 月更文挑戰」的第3天,點選檢視活動詳情

粘包和半包問題是資料傳輸中比較常見的問題,所謂的粘包問題是指資料在傳輸時,在一條訊息中讀取到了另一條訊息的部分資料,這種現象就叫做粘包。 比如傳送了兩條訊息,分別為“ABC”和“DEF”,那麼正常情況下接收端也應該收到兩條訊息“ABC”和“DEF”,但接收端卻收到的是“ABCD”,像這種情況就叫做粘包,如下圖所示: image.png

半包問題是指接收端只收到了部分資料,而非完整的資料的情況就叫做半包。比如傳送了一條訊息是“ABC”,而接收端卻收到的是“AB”和“C”兩條資訊,這種情況就叫做半包,如下圖所示: image.png

PS:大部分情況下我們都把粘包問題和半包問題看成同一個問題,所以下文就用“粘包”問題來替代“粘包”和“半包”問題。

1.為什麼會有粘包問題?

粘包問題發生在 TCP/IP 協議中,因為 TCP 是面向連線的傳輸協議,它是以“流”的形式傳輸資料的,而“流”資料是沒有明確的開始和結尾邊界的,所以就會出現粘包問題

2.粘包問題程式碼演示

接下來我們用程式碼來演示一下粘包和半包問題,為了演示的直觀性,我會設定兩個角色:

  • 伺服器端用來接收訊息;
  • 客戶端用來發送一段固定的訊息。

然後通過列印伺服器端接收到的資訊來觀察粘包問題。 伺服器端程式碼實現如下:

java /** * 伺服器端(只負責接收訊息) */ class ServSocket { // 位元組陣列的長度 private static final int BYTE_LENGTH = 20; public static void main(String[] args) throws IOException { // 建立 Socket 伺服器 ServerSocket serverSocket = new ServerSocket(8888); // 獲取客戶端連線 Socket clientSocket = serverSocket.accept(); // 得到客戶端傳送的流物件 try (InputStream inputStream = clientSocket.getInputStream()) { while (true) { // 迴圈獲取客戶端傳送的資訊 byte[] bytes = new byte[BYTE_LENGTH]; // 讀取客戶端傳送的資訊 int count = inputStream.read(bytes, 0, BYTE_LENGTH); if (count > 0) { // 成功接收到有效訊息並列印 System.out.println("接收到客戶端的資訊是:" + new String(bytes)); } count = 0; } } } }

客戶端實現程式碼如下:

java /** * 客戶端(只負責傳送訊息) */ static class ClientSocket { public static void main(String[] args) throws IOException { // 建立 Socket 客戶端並嘗試連線伺服器端 Socket socket = new Socket("127.0.0.1", 8888); // 傳送的訊息內容 final String message = "Hi,Java."; // 使用輸出流傳送訊息 try (OutputStream outputStream = socket.getOutputStream()) { // 給伺服器端傳送 10 次訊息 for (int i = 0; i < 10; i++) { // 傳送訊息 outputStream.write(message.getBytes()); } } } }

以上程式的執行結果如下圖所示: image.png 通過上述結果我們可以看出,伺服器端發生了粘包問題,因為客戶端傳送了 10 次固定的“Hi,Java.”的訊息,正確的結果應該是伺服器端也接收到了 10 次固定訊息“Hi,Java.”才對,但實際執行結果並非如此。

3.解決方案

粘包問題的常見解決方案有以下 3 種:

  1. 傳送方和接收方固定傳送資料的大小,當字元長度不夠時用空字元彌補,有了固定大小之後就知道每條訊息的具體邊界了,這樣就沒有粘包的問題了;

  2. 在 TCP 協議的基礎上封裝一層自定義資料協議,在自定義資料協議中,包含資料頭(儲存資料的大小)和 資料的具體內容,這樣服務端得到資料之後,通過解析資料頭就可以知道資料的具體長度了,也就沒有粘包的問題了;

  3. 以特殊的字元結尾,比如以“\n”結尾,這樣我們就知道資料的具體邊界了,從而避免了粘包問題(推薦方案)。

### 解決方案1:固定資料大小

收、發固定大小的資料,伺服器端的實現程式碼如下:

java /** * 伺服器端,改進版本一(只負責接收訊息) */ static class ServSocketV1 { private static final int BYTE_LENGTH = 1024; // 位元組陣列長度(收訊息用) public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(9091); // 獲取到連線 Socket clientSocket = serverSocket.accept(); try (InputStream inputStream = clientSocket.getInputStream()) { while (true) { byte[] bytes = new byte[BYTE_LENGTH]; // 讀取客戶端傳送的資訊 int count = inputStream.read(bytes, 0, BYTE_LENGTH); if (count > 0) { // 接收到訊息列印 System.out.println("接收到客戶端的資訊是:" + new String(bytes).trim()); } count = 0; } } } }

客戶端的實現程式碼如下:

java /** * 客戶端,改進版一(只負責接收訊息) */ static class ClientSocketV1 { private static final int BYTE_LENGTH = 1024; // 位元組長度 public static void main(String[] args) throws IOException { Socket socket = new Socket("127.0.0.1", 9091); final String message = "Hi,Java."; // 傳送訊息 try (OutputStream outputStream = socket.getOutputStream()) { // 將資料組裝成定長位元組陣列 byte[] bytes = new byte[BYTE_LENGTH]; int idx = 0; for (byte b : message.getBytes()) { bytes[idx] = b; idx++; } // 給伺服器端傳送 10 次訊息 for (int i = 0; i < 10; i++) { outputStream.write(bytes, 0, BYTE_LENGTH); } } } }

以上程式碼的執行結果如下圖所示: image.png

#### 優缺點分析

從以上程式碼可以看出,雖然這種方式可以解決粘包問題,但這種固定資料大小的傳輸方式,當資料量比較小時會使用空字元來填充,所以會額外的增加網路傳輸的負擔,因此不是理想的解決方案。

### 解決方案2:自定義請求協議

這種解決方案的實現思路是將請求的資料封裝為兩部分:訊息頭(傳送的資料大小)+訊息體(傳送的具體資料),它的格式如下圖所示: 此解決方案的實現分為以下 3 部分:

  1. 編寫一個訊息封裝類

  2. 編寫客戶端

  3. 編寫伺服器端

接下來我們一一來實現。

① 訊息封裝類

訊息的封裝類中提供了兩個方法:一個是將訊息轉換成訊息頭 + 訊息體的方法,另一個是讀取訊息頭的方法,具體實現程式碼如下:

```java /* * 訊息封裝類 / class SocketPacket { // 訊息頭儲存的長度(佔 8 位元組) static final int HEAD_SIZE = 8;

/**
 * 將協議封裝為:協議頭 + 協議體
 * @param context 訊息體(String 型別)
 * @return byte[]
 */
public byte[] toBytes(String context) {
    // 協議體 byte 陣列
    byte[] bodyByte = context.getBytes();
    int bodyByteLength = bodyByte.length;
    // 最終封裝物件
    byte[] result = new byte[HEAD_SIZE + bodyByteLength];
    // 藉助 NumberFormat 將 int 轉換為 byte[]
    NumberFormat numberFormat = NumberFormat.getNumberInstance();
    numberFormat.setMinimumIntegerDigits(HEAD_SIZE);
    numberFormat.setGroupingUsed(false);
    // 協議頭 byte 陣列
    byte[] headByte = numberFormat.format(bodyByteLength).getBytes();
    // 封裝協議頭
    System.arraycopy(headByte, 0, result, 0, HEAD_SIZE);
    // 封裝協議體
    System.arraycopy(bodyByte, 0, result, HEAD_SIZE, bodyByteLength);
    return result;
}

/**
 * 獲取訊息頭的內容(也就是訊息體的長度)
 * @param inputStream
 * @return
 */
public int getHeader(InputStream inputStream) throws IOException {
    int result = 0;
    byte[] bytes = new byte[HEAD_SIZE];
    inputStream.read(bytes, 0, HEAD_SIZE);
    // 得到訊息體的位元組長度
    result = Integer.valueOf(new String(bytes));
    return result;
}

} ```

② 客戶端

客戶端中我們新增一組待發送的訊息,隨機給伺服器端傳送一個訊息,實現程式碼如下:

java /** * 客戶端 */ class MySocketClient { public static void main(String[] args) throws IOException { // 啟動 Socket 並嘗試連線伺服器 Socket socket = new Socket("127.0.0.1", 9093); // 傳送訊息合集(隨機發送一條訊息) final String[] message = {"Hi,Java.", "Hi,SQL~", "關注公眾號|Java中文社群."}; // 建立協議封裝物件 SocketPacket socketPacket = new SocketPacket(); try (OutputStream outputStream = socket.getOutputStream()) { // 給伺服器端傳送 10 次訊息 for (int i = 0; i < 10; i++) { // 隨機發送一條訊息 String msg = message[new Random().nextInt(message.length)]; // 將內容封裝為:協議頭+協議體 byte[] bytes = socketPacket.toBytes(msg); // 傳送訊息 outputStream.write(bytes, 0, bytes.length); outputStream.flush(); } } } }

③ 伺服器端

伺服器端使用執行緒池來處理每個客戶端的業務請求,實現程式碼如下:

java /** * 伺服器端 */ class MySocketServer { public static void main(String[] args) throws IOException { // 建立 Socket 伺服器端 ServerSocket serverSocket = new ServerSocket(9093); // 獲取客戶端連線 Socket clientSocket = serverSocket.accept(); // 使用執行緒池處理更多的客戶端 ThreadPoolExecutor threadPool = new ThreadPoolExecutor(100, 150, 100, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000)); threadPool.submit(() -> { // 客戶端訊息處理 processMessage(clientSocket); }); } /** * 客戶端訊息處理 * @param clientSocket */ private static void processMessage(Socket clientSocket) { // Socket 封裝物件 SocketPacket socketPacket = new SocketPacket(); // 獲取客戶端傳送的訊息物件 try (InputStream inputStream = clientSocket.getInputStream()) { while (true) { // 獲取訊息頭(也就是訊息體的長度) int bodyLength = socketPacket.getHeader(inputStream); // 訊息體 byte 陣列 byte[] bodyByte = new byte[bodyLength]; // 每次實際讀取位元組數 int readCount = 0; // 訊息體賦值下標 int bodyIndex = 0; // 迴圈接收訊息頭中定義的長度 while (bodyIndex <= (bodyLength - 1) && (readCount = inputStream.read(bodyByte, bodyIndex, bodyLength)) != -1) { bodyIndex += readCount; } bodyIndex = 0; // 成功接收到客戶端的訊息並列印 System.out.println("接收到客戶端的資訊:" + new String(bodyByte)); } } catch (IOException ioException) { System.out.println(ioException.getMessage()); } } }

以上程式的執行結果如下: image.png 從上述結果可以看出,訊息通訊正常,客戶端和伺服器端的互動中並沒有出現粘包問題。

優缺點分析

此解決方案雖然可以解決粘包問題,但訊息的設計和程式碼的實現複雜度比較高,所以也不是理想的解決方案。

解決方案3:特殊字元結尾

以特殊字元結尾就可以知道流的邊界了,它的具體實現是:使用 Java 中自帶的 BufferedReader 和 BufferedWriter,也就是帶緩衝區的輸入字元流和輸出字元流,通過寫入的時候加上 \n 來結尾,讀取的時候使用 readLine 按行來讀取資料,這樣就知道流的邊界了,從而解決了粘包的問題。 伺服器端實現程式碼如下:

java /** * 伺服器端,改進版三(只負責收訊息) */ static class ServSocketV3 { public static void main(String[] args) throws IOException { // 建立 Socket 伺服器端 ServerSocket serverSocket = new ServerSocket(9092); // 獲取客戶端連線 Socket clientSocket = serverSocket.accept(); // 使用執行緒池處理更多的客戶端 ThreadPoolExecutor threadPool = new ThreadPoolExecutor(100, 150, 100, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000)); threadPool.submit(() -> { // 訊息處理 processMessage(clientSocket); }); } /** * 訊息處理 * @param clientSocket */ private static void processMessage(Socket clientSocket) { // 獲取客戶端傳送的訊息流物件 try (BufferedReader bufferedReader = new BufferedReader( new InputStreamReader(clientSocket.getInputStream()))) { while (true) { // 按行讀取客戶端傳送的訊息 String msg = bufferedReader.readLine(); if (msg != null) { // 成功接收到客戶端的訊息並列印 System.out.println("接收到客戶端的資訊:" + msg); } } } catch (IOException ioException) { ioException.printStackTrace(); } } }

PS:上述程式碼使用了執行緒池來解決多個客戶端同時訪問伺服器端的問題,從而實現了一對多的伺服器響應。

客戶端的實現程式碼如下:

java /** * 客戶端,改進版三(只負責傳送訊息) */ static class ClientSocketV3 { public static void main(String[] args) throws IOException { // 啟動 Socket 並嘗試連線伺服器 Socket socket = new Socket("127.0.0.1", 9092); final String message = "Hi,Java."; // 傳送訊息 try (BufferedWriter bufferedWriter = new BufferedWriter( new OutputStreamWriter(socket.getOutputStream()))) { // 給伺服器端傳送 10 次訊息 for (int i = 0; i < 10; i++) { // 注意:結尾的 \n 不能省略,它表示按行寫入 bufferedWriter.write(message + "\n"); // 重新整理緩衝區(此步驟不能省略) bufferedWriter.flush(); } } } }

以上程式碼的執行結果如下圖所示: image.png

優缺點分析

以特殊符號作為粘包的解決方案的最大優點是實現簡單,但存在一定的侷限性,比如當一條訊息中間如果出現了結束符就會造成半包的問題,所以如果是複雜的字串要對內容進行編碼和解碼處理,這樣才能保證結束符的正確性。

總結

粘包和半包問題是資料傳輸中比較常見的問題,它的解決方案有很多,比較常見的解決方案有:設定固定的資料傳輸大小、自定義請求協議的封裝,在請求頭中加入傳輸資料的長度、使用特殊符號作為結束符等。

是非審之於己,譭譽聽之於人,得失安之於數。

公眾號:Java面試真題解析

面試合集:http://gitee.com/mydb/interview