TCP與應用層協議

zko0發表於2023-03-05

1、小故事與前言

最近返校做畢業設計,嵌入式和伺服器需要敲定一個通訊協議。

我問:"服務端選什麼協議,MQTT或者Websocket?"

他來句:"TCP"

我又確認了一遍:"用什麼通訊協議?"

他又來了句:"TCP"

給我氣笑了。最後在我的堅持下,他才勉強以他啥都能做,以我為準的理由妥協了。

簡單回想盤點下問題:

  1. TCP如何處理訊息半包,粘包
  2. TCP如何確認應用狀態
  3. TCP如何處理服務認證問題

他的回答:

  1. 訊息傳送,服務端加個標識字元檢驗,進行資料幀分割
  2. 發個心跳包

2、TCP與應用層協議

TCP位於傳輸層,而這位大哥想做的就是基於Socket去完成網路訊息傳輸。(Socket本身並不是協議,而是一個呼叫介面,透過Socket,我們才能使用TCP/IP協議)

MQTT,Websocket屬於應用層協議,基於TCP。

一.訊息半包、粘包

①介紹

首先,如果你使用過Java的原生Socket API,並進行過實驗,你就能知道Socket是無法處理半包與粘包的。

什麼是半包與粘包?可以參考我的Netty進階文章

②演示

測試演示:

服務端程式碼:

縮小服務端的接收緩衝區為20位元組,客戶端10次傳送"abc"資料。

請不要拿readUTF方法進行測試,sendUTF與readUTF是配套使用的,jdk原生進行了半包粘包的處理,對於傳送訊息新增了額外的訊息用於處理問題。

如readLine等方法都是基於特定條件的處理了半包粘包問題(如本方法為換行劃分一個訊息幀),不適用於通用訊息的網路傳輸。

import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * @author  zko0
 * @date  2023/3/5 0:02
 * @description
 */
public class SimpleSocketServer {
     public static void main(String[] args) {
          DataInputStream dataInputStream=null;
          ServerSocket serverSocket=null;
          Socket accept=null;
          try {
               serverSocket= new ServerSocket(8081);
               System.out.println("socket服務建立");
               //accept
               accept = serverSocket.accept();
               System.out.println("client連線");
               //只設定接收緩衝區大小
               accept.setReceiveBufferSize(10);
               InputStream inputStream = accept.getInputStream();
               dataInputStream= new DataInputStream(inputStream);
               while (true){
                    //建立byte陣列用於接收資料
                    byte[] bytes=new byte[10];
                    dataInputStream.read(bytes);
                    System.out.println(new String(bytes));
               }
          } catch (IOException e) {
               e.printStackTrace();
          }finally {
               try {
                    if (dataInputStream!=null)dataInputStream.close();
                    if (serverSocket!=null)serverSocket.close();
                    if (accept!=null)accept.close();
               } catch (IOException e) {
                    e.printStackTrace();
               }
          }
     }
}

客戶端程式碼:

public class SimpleSocketClient {
    public static void main(String[] args) {
        Socket socket =null;
        DataOutputStream dataOutputStream=null;
        try{
            socket=new Socket("127.0.0.1", 8081);
            System.out.println("socket連線");
            OutputStream outputStream= socket.getOutputStream();
            dataOutputStream= new DataOutputStream(outputStream);
            for (int i = 0; i < 10; i++) {
                dataOutputStream.writeBytes("abc");
            }
            dataOutputStream.flush();
            while (true);
        }catch (IOException e) {
            e.printStackTrace();
        }
    }
}

測試結果:

如下圖所示,Server接收Client訊息存在著半包粘包問題,一次訊息abc無法被完整接收,在服務端第一次接收到了a,第二次接收到了bc+abc+ab一次完整+兩次一半的訊息。

很明顯,如果服務端直接透過獲取的訊息進行處理,是存在問題的,對於TCP訊息,需要進行半包和粘包的處理,才能使用訊息。

image-20230305012503986

③問題解決

正如那位“高含金量選手”所說,要對半包粘包進行處理。但是這種處理不僅僅是直接使用識別符號就可以的,對於多出的額外資訊或者不足的訊息,是需要保留來拼湊出完整訊息的。這部分你可以參考一下Java中readLine()方法

解決方式:

  1. 使用識別符號進行訊息幀分割

    這種方式只適用於特定場景,因為在傳送的訊息中如果存在你的識別符號,那麼在進行幀解碼的時候,就會將這部分內容作為識別符號將字串分割。由於分割位置錯誤,一次訊息你可能會得到分割後的多個錯誤訊息。

    就比如readLine()使用於讀取一行資料。

  2. 使用短連線

    這種方式可以解決粘包的問題,但是無法解決半包問題。因為一次傳送後,連線就會中斷。那麼Server獲取的資料只會小於單次傳送的資料。

  3. 定長幀解碼器

    在傳送資料中,定義協議。如設定前5個位元組代表傳送資料的長度。

    如果傳送abc三位元組內容,傳送資料為:0 0 0 0 3 97 98 99

    伺服器讀取前5個位元組,就知道需要再讀取3位元組內容,為一次完整資料。如果單次只讀取了2位元組,那麼會等待1個位元組內容,拼湊出最後的3位元組,單次完整訊息。

    你可以參考readUTF()方法,思路相同。

二.心跳

如果你瞭解TCP,你就會知道TCP協議是自帶心跳的。

KEEP_ALIVE
在TCP協議裡,本身的心跳包機制SO_KEEPALIVE,系統預設設定的是7200秒的啟動時間,預設是關閉的

有三個引數可以設定:p_keepalive_time、tcp_keepalive_probes、tcp_keepalive_intvl

分別表示連線閒置多久才開始發心跳包、連發幾次心跳包沒有回應表示連線已斷開、心跳包之間間隔時間。

但是眾所周知,幾乎所有基於TCP協議的應用層協議,都自定義了心跳報文:如WebSocket,MQTT

Socket中Client關閉,Server能自動感受到連線斷開啊?:

當SocketClient應用關閉,系統呼叫Socket的close、或者程式結束。作業系統會傳送FIN資料包給伺服器,所以Sevrer能夠關閉TCP連線。

如果你在Client執行中,拔掉網線或者關閉電源。這時Client是沒有機會傳送FIN資料包的,這時Server就無法感知到Client已經斷開了連線。

為什麼TCP自帶心跳還需要應用層定義心跳:

  1. TCP心跳機制是傳輸層實現的,只要當前連線是可用的,對端就會ACK我們的心跳,而對於當前對端應用是否能正常提供服務,TCP層的心跳機制是無法獲知的
  2. tcp_keepalive_time引數的設定是秒級,對於極端情況,我們可能想在毫秒級就檢測到連線的狀態,這個TCP心跳機制就無法辦到
  3. 通用性,應用層心跳功能不依賴於傳輸層協議,如果有一天我們想將傳輸層協議由TCP改為UDP,那麼傳輸層不提供心跳機制了,應用層的心跳是通用的,此時或許只用修改少量地方程式碼即可;
  4. 如果連線中存在Sock Proxy或者NAT,他們可能不會處理TCP的Keep Alive包

三.應用報文

這部分比較偏向應用層內容,僅為個人感想。

在應用層協議中,協議報文都是對應用每個功能定製,且縮小了資料量的。

以MQTT3.1舉例

MQTT第一次報文為CONNETCT報文。在一個網路連線上,客戶端只能傳送一次CONNECT報文。服務端必須將客戶端傳送的第二個CONNECT報文當作協議違規處理並斷開客戶端的連線 。

報文內容:

  1. 固定頭:1--->確定了報文的型別:CONNECT

  2. 可變頭:

    • 第6位:使用者名稱標誌 User Name Flag
    • 第7位:密碼標誌 Password Flag
  3. 有效載荷:CONNECT報文的有效載荷(payload)包含一個或多個以長度為字首的欄位,可變報頭中的標誌決定是否包含這些欄位。如果包含的話,必須按這個順序出現:客戶端識別符號,遺囑主題,遺囑訊息,使用者名稱,密碼。

僅僅是一個客戶端連線報文,就設計的非常全面且優雅,且完全契合應用目的。

3、小結

個人思考後,個人認為將應用層協議分為通用協議特定系統協議倆種。

通用協議:如HTTP協議,Websocket,MQTT協議,搭載使用者自定義資料體進行通訊

特定系統協議:如Mysql協議,Redis的RESP協議,都是基於特定應用自己開發的一套網路協議

對於應用開發,個人認為通用協議更為合理,不僅進行了上述眾多問題的處理。而且還有對應協議的應用意義:如身份驗證等。

實在難以想象那種程式碼水平是否能將Socket自定義協議與訊息處理寫出基本的骨架出來。希望技術人員專注於技術,共同進步,不要盲目自大。

如果你有較好思考,歡迎指點。

相關文章