本文參考: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. 返回構建的數據包 } } } }
解析包
-
用到的ref關鍵字,在參數前面加ref標明這個參數會在方法中被改變,這里為地址處存儲的值的更新,因為數據包(緩沖區)被解析后返回數據,那么原本的數據包就可以情況並接收之后的數據了,所以需要更新
-
用到的 List<>,數據包用List來接收是為了在解析時候的方便,這個在查看代碼中使用到的地方就可以理解
-
用到的BinaryReader,與上述內容的BinaryWriter類似,一個是寫入,一個是讀取
new BynaryReader().ReadInt32(),這個方法表示讀取數據流的前四個字節並使流的當前位置提升四個字節(就好比一個隊列中被去除了四個字節其他的依次向前移)
// 承接上面EncodeTool類 public static byte[] DecodePacket(ref List<byte> cache) { if (cache.Count < 4) { // cache的數據長度還不到一個int類型數據->包頭也不夠->沒數據 return null; } using(MemoryStream ms = new MemoryStream()) { 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關鍵字的原因 int remainLengthAgain = (int)(ms.Length - ms.Position);// 清空之后重新獲取剩下的數據長度 cache.AddRange(br.ReadBytes(remainLengthAgain));// 讀取處已解析數據包外的數據部分並轉移至緩沖區 // 3. 返回解析的數據 return data; } } }