1. 程式人生 > 實用技巧 >TCP的粘包和拆包問題及解決辦法(C#)

TCP的粘包和拆包問題及解決辦法(C#)

本文參考:https://blog.csdn.net/wxy941011/article/details/80428470

原因

如果客戶端連續不斷的向服務端傳送資料包時,服務端接收的資料會出現兩個資料包粘在一起的情況,這就是TCP協議中經常會遇到的粘包以及拆包的問題。

我們都知道TCP屬於傳輸層的協議,傳輸層除了有TCP協議外還有UDP協議。

TCP

TCP是基於位元組流的,雖然應用層和TCP傳輸層之間的資料互動是大小不等的資料塊,但是TCP把這些資料塊僅僅看成一連串無結構的位元組流,沒有邊界;另外從TCP的幀結構也可以看出,在TCP的首部沒有表示資料長度的欄位(也就是說TCP並不知道傳送的單個數據的長度,只要緩衝區空間足夠或是缺少,就有可能發生粘包(和下一個資料流黏在一起)或拆包(本資料流被拆分)),基於上面兩點,在使用TCP傳輸資料時,才有粘包或者拆包現象發生的可能。

UDP

那麼UDP是否會發生粘包或拆包的現象呢?答案是不會。UDP是基於報文傳送的,從UDP的幀結構可以看出,在UDP首部採用了16bit來指示UDP資料報文的長度,因此在應用層能很好的將不同的資料報文區分開,從而避免粘包和拆包的問題。

打個比方

TCP中傳輸資料位元組流就像是在兩個人之間傳遞水,它們通過一個容器(緩衝區)來盛裝水,甲連續地向乙傳輸水,如果有兩次的水量剛好可以被容器裝進去那就有可能將兩次的水(資料位元組流)放到同一個容器(緩衝區)中進行傳遞(粘包),如果不夠那麼一次的水可能分兩次傳輸(拆包),基本上有三種情況:

容器>=兩次水量,兩次水量>容器>一次水量,容器<一次水量【要注意每一次的水量可能不一樣】

部分原因

  • 要傳送的資料大於TCP傳送緩衝區剩餘空間大小,將會發生拆包

  • 待發送資料大於MSS(最大報文長度),TCP在傳輸前將進行拆包

  • 要傳送的資料小於TCP傳送緩衝區的大小,TCP將多次寫入緩衝區的資料一次傳送出去,將會發生粘包

  • 接收資料端的應用層沒有及時讀取接收緩衝區中的資料,將發生粘包

原因並不全面,可能會有其他原因,常見的為上述內容

表現形式

現在假設客戶端向服務端連續傳送了兩個資料包,用packet1和packet2來表示,那麼服務端收到的資料可以分為三種,

  • 第一種情況

    接收端正常收到兩個資料包,即沒有發生拆包和粘包的現象,此種情況不在本文的討論範圍內。

  • 第二種情況

    接收端只收到一個數據包,由於TCP是不會出現丟包的,所以這一個資料包中包含了傳送端傳送的兩個資料包的資訊,這種現象即為粘包。這種情況由於接收端不知道這兩個資料包的界限,所以對於接收端來說很難處理。

  • 第三種情況

    這種情況有兩種表現形式,如下圖。接收端收到了兩個資料包,但是這兩個資料包要麼是不完整的,要麼就是多出來一塊,這種情況即發生了拆包和粘包。這兩種情況如果不加特殊處理,對於接收端同樣是不好處理的。

解決方法

就像上述原因中提到1. TCP是基於位元組流的,雖然應用層和TCP傳輸層之間的資料互動是大小不等的資料塊,但是TCP把這些資料塊僅僅看成一連串無結構的位元組流,沒有邊界;2. 另外從TCP的幀結構也可以看出,在TCP的首部沒有表示資料長度的欄位(也就是說TCP並不知道傳送的單個數據的長度

因此,解決問題的關鍵在於如何給每個資料包新增邊界資訊,基本上有以下三種常見解決辦法

  • 傳送端給每個資料包新增包首部,首部中應該至少包含資料包的長度,這樣接收端在接收到資料後,通過讀取包首部的長度欄位,便知道每一個數據包的實際長度了。【本次專案的解決方法】

  • 傳送端將每個資料包封裝為固定長度(不夠的可以通過補0填充),這樣接收端每次從接收緩衝區中讀取固定長度的資料就自然而然的把每個資料包拆分開來。

  • 可以在資料包之間設定邊界,如新增特殊符號,這樣,接收端通過這個邊界就可以將不同的資料包拆分開。

程式碼

使用第一種方法,寫一個構造包的類,包括 包頭(資料長度)和包尾(資料)

  • 用到的using形式的程式碼的解釋

// 正常情況下
 Memory ms = new MemoryStream();
 // 各種流操作
 ms.Close();// 關閉流
// 使用using
 using (Memory ms = new MemoryStream()){
     // 各種流操作
     // 使用完畢自動關閉流
 }
  • 用到的MemoryStream,記憶體流物件

    new MemoryStream().GetBuffer() 從中返回其中寫入的無符號位元組陣列(就是返回寫入這個記憶體流物件的資料流資料)

  • 用到的BinaryWriter,二進位制寫入

    new BinaryWriter.Write(byte[] targetBuffer) 用於寫入資料(與上述記憶體流物件聯合使用)

  • 用到的Buffer.BlockCopy,拷貝資料流至

    引數(源資料流,源資料流偏移,要copy到的位元組陣列物件,位元組流物件偏移,要copy的資料長度)

上面三條的使用流程:

  • 建立記憶體流物件 new MemoryStream()

  • 向記憶體流物件寫入資料 new BynaryWriter.Write(data)

  • 將寫入的資料(原緩衝區內容,即下面程式碼中的data)拷貝至新建的位元組陣列(目標緩衝區)

  • 關閉流(auto)

構建包

就是將資料構建為一個包——包頭(資料長度)+包尾(資料)

class EncodeTool{
    // 構造包 包頭+包尾
    public static byte[] EncodePacket(byte[] data){
        using(MemoryStream ms = new MemoryStream()){
            using(BinaryWriter bw = new BinaryWriter(ms)){
                // 1. 寫入包頭(資料長度)
                bw.Write(data.length);
                // 2. 寫入包尾(資料)
                bw.Write(data);
                // 3. 拷貝
                byte[] targetBuffer = new byte[ms.length];
                Buffer.BlockCopy(ms.GetBuffer(),0,targetBuffer,0,(int)ms.Length);
                // 這裡的ms.Length是長整型的,為了匹配形參,強制轉換為int型別
                return targetBuffer;
                // 4. 自動關閉流
                // 5. 返回構建的資料包
                return targetPacket;
            }
        }
    }    
}

解析包

上面講解了如何將資料構建為資料包,那麼如何對這個包進行讀取呢?方法為將資料包按照資料長度來讀取緩衝區中相應長度的資料

  • 用到的ref關鍵字,在引數前面加ref標明這個引數會在方法中被改變,這裡為地址處儲存的值的更新,因為資料包(緩衝區)被解析後返回資料,那麼原本的資料包就可以情況並接收之後的資料了,所以需要更新

  • 用到的 List<>,資料包用List來接收是為了在解析時候的方便,這個在檢視程式碼中使用到的地方就可以理解

  • 用到的BinaryReader,與上述內容的BinaryWriter類似,一個是寫入,一個是讀取

    new BynaryReader().ReadInt32(),這個方法表示讀取資料流的前四個位元組並使流的當前位置提升四個位元組(就好比一個佇列中被去除了四個位元組其他的依次向前移)

// 承接上述 EncodeTool類
public static byte[] DecodePacket(ref List<byte> catch){
    using(BinaryReader br = new BinaryReader(ms)){
        int length = br.ReadInt32();// 剛好對應構建包中的包頭部分
        // 1. 計算緩衝區剩下的資料位元組長度(即資料部分)
        int remainLength = (int)(ms.Length - ms.Position);// 這裡的ms.position自行體會
        if(remainLength < length){
            // 如果剩下的資料長度小於讀取到的資料長度,就說明這個緩衝區內並不存在一個完整的包(也就是資料>緩衝區的情況)
            return null;
        }
        // 至少包括一個完整的包
        byte[] data = br.ReadBytes(length);// 讀取length長度的資料(即解析的資料包中的資料)並存儲至data位元組陣列
        // 2. 更新資料快取,將被讀取的資料移除,繼續讀取下一個資料包
        cache.Clear();// 這裡就是為什麼本方法形參前需要加ref關鍵字的原因
        cache.AddRange(br.ReadBytes(remainLength));// 讀取處已解析資料包外的資料部分並轉移至緩衝區
        // 3. 返回解析的資料
        return data;
    }
}