TCP粘"包"問題淺析及解決方案Golang代碼實現


一、粘“包”問題簡介

在socket網絡編程中,都是端到端通信,客戶端端口+客戶端IP+服務端端口+服務端IP+傳輸協議就組成一個可以唯一可以明確的標識一條連接。在TCP的socket編程中,發送端和接收端也同樣遵循這樣的規則。

1、部分字符和亂碼的可能原因

如果發送端多次發送字符串,接收端從socket讀取數據放到接收數據的recv數組,由於recv數組初始化為\0,僅收到部分字符串就開始打印。該部分字符串放在recv數組中,末尾仍是以\0結尾,打印函數見到\0則默認結束打印輸出,后部分數據還未讀取到就出現讀取字符不完整的情況。如果出現亂碼,則可能是因為,定義的recv數組容量不夠,接收端的數據占滿recv數組之后,打印函數仍會尋找以\0為邊界的字符作為結束標志,這樣從內存中就會讀取數據的時候越界。內存中存在的數據不一定可讀,打印函數在按照字符的格式輸出就會顯示亂碼。所以有時候在socket編程的時候,會出現讀取字符串不完整或者亂碼的現象。

接收雙方收發數據的時候直接在這樣一條連接中進行,TCP是面向字節流的協議,數據像是在管道中流動一樣。在TCP看來,數據之間並沒有明確的邊界。

2、粘“包”的可能原因

TCP並沒有包這一概念,而所謂的包可能是報文段或者,發送端一次發送的數據被誤稱為包。而粘包的現象主要表現在兩方面:
1、發送端在發送數據的時候,為了避免頻繁發送負載量極小的報文段導致的傳輸性價比低的問題,默認使用優化算法,在收集多個小的報文之后,在適當的條件一次發送。由於TCP發送的數據沒有邊界,發送方發送的數據就看起來像粘在一起形成一個包一樣。
2、接收端在接受數據的時候,由於緩存的存在,並不會直接把接受的數據直接移交上層應用層。而是會考慮時間和緩存容量從緩存中讀取數據,如果TCP接收數據包的緩存的速度大於應用層從緩存中讀取數據包的速度,多個包就會被緩存,應用程序就有可能讀取到多個首尾相接像是粘到一起的包。

3、粘“包”的發生

粘“包”問題也並不是一直都需要解決,如果發送方發送的多組數據本來就是同一塊數據的不同部分,比如說一個文件被分成多個部分發送,這時則不需要處理粘包現象。當時更多的情況下,發送的多個數據包並不相關,則需要去解決粘包問題。

比如甲和乙要進行通信,甲先后給乙發送大小為200字節和100字節的數據包A和B。如果將數據包A分為a1和a2兩個負載量更小的數據包,那么這兩個數據包之間就不存在粘包問題,因為它們本來就屬於同一組數據。但是由於是順序發送的,a2和B就可能產生粘包問題,發送端應用層知道A和B的邊界,但是對於接收端TCP接受的是字節流,所以乙的應用層並不知道要把哪些作為一個有效的數據包。

4、解決方案

所以粘包根本問題還是在於,TCP是面向字節流的,而字節流是沒有邊界的。因此要解決粘包問題就要發送端和接收端約定某個字節作為數據包的邊界或者規定固定大小的數據作為一個包。放在了上層應用層來實現。

方案一:結束標志控制。以指定字符(串)為包的結束標志,這種協議就是接收端在字節流中每次遇到標志符號,比如"\r\n"就認為是一個包的末尾,把每次遇到"\r\n"之前的數據進行封裝當做一個數據包。但是有時候發送的數據本身就攜帶這些標志字符,因此需要做轉義,以免接收方誤地當成包結束標志而錯誤的進行數據打包。
方案二:固定數據包長度。就是每次發送的數據包的長度固定,如果數據不夠,需要用特殊填充填滿數據包。如果過長,則需要分包。
方案三:包頭包體格式數據(TLV:Type, Lenght, Value),也就是發送方接收方事先約定好,每個包由包頭和數據負載部分組成。包頭長度固定,包含數據類型和數據長度,這兩個字段占用的長度固定,假設分別為4個字節,數據負載部分占用的長度由包頭中數據長度字段的值決定。比如一個數據包如下,那么接收端的先接收到8個字節的數據就取出包頭,從而得到數據的類型,知道數據的長度為個字節,依次從接下來的數據流中讀取10個字節,就可以得到該數據包的完整內容。

Type(消息類型) Length(數據部分的字節長度) Value(Data實際的數據部分)
1 10(4+6) asdf大小

上述例子假設采用UTF-8編碼,一個英文字符等於一個字節,一個中文(含繁體)等於三個字節。

無法解析?
那會不會出現個別字節的丟失,導致某些數據包的包頭無法解析,從而錯誤封包呢?
至少在發送端和發送過程中不會,因為TCP是可靠通信,可以通過序列號和重傳機制保證數據包有序並且正確的到達接收端。

二、Golang代碼實現

基於上述方案三,代碼實現采用的是發送端和接收端兩方約定好數據(消息)的封包和拆包機制,那么接收方發送的時候按照消息頭(消息ID或者消息類型+消息長度)和消息實體部分發送,接收方按照同樣的格式讀取,從消息頭中讀取消息類型和消息長度,然后從管道中讀取消息長度的字節數。

1、數據打包接口

先定義數據打包工具的抽象接口

/*
定義一個解決TCP粘包問題的封包和拆包的模塊
——針對Message進行TLV格式的封裝
  ——先后Message的長度,ID和內容
——這對Message進行TLV格式的拆包
  ——先讀取固定長度的head-->消息內容長度和消息的類型
  ——再根據消息的長度,進行一次讀寫,從conn中讀取消息的內容
 */

//封包,拆包模塊,直接面向TCP連接中的數據流,用於處理TCP粘包的問題

type IDataPack interface {
	// 獲取包的長度
	GetHeadLen() uint32
	//封包方法
	Pack(msg IMessage) ([]byte, error)
	//拆包方法
	Unpack([]byte) (IMessage, error)
}

2、消息封裝

數據封裝成消息

//消息包含消息ID,消息長度,消息內容三部分
type Message struct {
	Id      uint32 //消息的ID
	DataLen uint32    // 消息長度
	Data    []byte //消息內容
}

//創建一個Message消息包
func NewMsgPackage(id uint32, data []byte) *Message{
	return &Message{
		Id: id,
		DataLen: uint32(len(data)),
		Data: data,
	}
}

//獲取消息ID
func (m *Message) GetMessageID() uint32{
	return m.Id
}

//獲取消息內容
func (m *Message) GetMessageData() []byte{
	return m.Data
}

//獲取消息長度
func (m *Message) GetMessageLen() uint32{
	return m.DataLen
}

//設置消息相關
func (m *Message) SetMessageID(id uint32){
	m.Id = id
}

//設置消息相關
func (m *Message) SetMessageData(data []byte){
	m.Data = data
}

//設置消息長度
func (m *Message) SetMessageLen(length uint32){
	m.DataLen = length
}

3、封包拆包實現

具體的拆包和封包邏輯實現

//拆包封包的具體模塊
type DataPack struct {
	dataHeadLen uint32
}

//拆包封包實例的初始化方法
func NewDataPack() *DataPack {
	return &DataPack{}
}

// 獲取包的長度
func (dp *DataPack) GetHeadLen() uint32{
	//DataHeadLen:uint32(4個字節)+ID:uint32(4個字節)=8個字節
	return 8
}

//封包方法, Message結構體變成二進制序列化的格式數據
func (dp *DataPack) Pack(msg *IMessage) ([]byte, error){
	//創建一個存放bytes字節的緩沖
	dataBuff := bytes.NewBuffer([]byte{})

	//注意寫入的順序
	//將dataLen寫入databuff中
	//這里涉及到一個網絡傳輸的大小端問題,大端序,小端序
	if err := binary.Write(dataBuff, binary.LittleEndian, msg.GetMessageLen()); err !=nil{
		return nil, err
	}

	//將MessageID寫入databuff中
	if err := binary.Write(dataBuff, binary.LittleEndian, msg.GetMessageID()); err !=nil{
		return nil, err
	}

	//將data寫入databuff中
	if err := binary.Write(dataBuff, binary.LittleEndian, msg.GetMessageData()); err !=nil{
		return nil, err
	}

	//二進制的序列化返回
	return dataBuff.Bytes(), nil
}

//拆包方法()
func (dp *DataPack) Unpack(binaryData []byte) (*Message, error){
	//創建一個輸入二進制數據的ioReader
	dataBuff := bytes.NewBuffer(binaryData)

	//接受消息,直解壓head,獲得datalen和id
	msg := &Message{}

	//讀取dataLen
	//這里的&msg.DataLen是為了寫入地址
	if err := binary.Read(dataBuff, binary.LittleEndian, &msg.DataLen); err!=nil{
		return nil, err
	}

	//這里的從dataBuff讀取數據,應該是連續讀,先讀len,然后讀id,不會重復
	//讀取dataID
	if err := binary.Read(dataBuff, binary.LittleEndian, &msg.Id); err != nil{
		return nil, err
	}

	//這里還可以加一個判斷datalen是否超出定義的長度的邏輯

	//只需拆包湖區msg的head,然后通過head的長度,從conn中讀取一次數據
	return msg, nil
}

封包拆包的時候還涉及到大小端的問題,具體是指,一個字符需要多個字節才能表示,在內存中這些字節是按照從大到小的地址空間存儲還是從小到大。發送接收雙方事先約定好,否則就會不同的順尋着對接收數據的解析順序不同出錯。還有從Socket中讀取數據流的時候是按照順序的,因此一旦讀出來socket中就沒了。

其他:具體的建立socket鏈接,創建數組接收數據就不寫了= _ =...,博客僅作為學習筆記的記錄,如果說的不對,及時改正,輕噴輕噴,感謝感謝

三、參考

粘包問題:詳解傳送門1
粘包問題:詳解傳送門2
大小端問題:詳解傳送門


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM