python TCP Socket的粘包和分包的處理詳解,pythonsocket
概述
在進行TCP Socket開發時,都需要處理資料包粘包和分包的情況。本文詳細講解解決該問題的步驟。使用的語言是Python。實際上解決該問題很簡單,在應用程式層下,定義一個協議:訊息頭部+訊息長度+訊息本文即可。
那什麼是粘包和分包呢?
關於分包和粘包
粘包:發送方發送兩個字串”hello”+”world”,接收方卻一次性接收到了”helloworld”。
分包:發送方發送字串”helloworld”,接收方卻接收到了兩個字串”hello”和”world”。
雖然socket環境有以上問題,但是TCP傳輸資料能保證幾點:
- 順序不變。例如發送方發送hello,接收方也一定順序接收到hello,這個是TCP協議承諾的,因此這點成為我們解決分包、黏包問題的關鍵。
- 分割的包中間不會插入其他資料。
因此如果要使用socket通訊,就一定要自己定義一份協議。目前最常用的協議標準是:訊息頭部(包頭)+訊息長度+訊息本文
TCP為什麼會分包
TCP是以段(Segment)為單位發送資料的,建立TCP連結後,有一個最大訊息長度(MSS)。如果應用程式層資料包超過MSS,就會把應用程式層資料包拆分,分成兩個段來發送。這個時候接收端的應用程式層就要拼接這兩個TCP包,才能正確處理資料。
相關的,路由器有一個MTU( 傳輸單元最大值),一般是1500位元組,除去IP頭部20位元組,留給TCP的就只有MTU-20位元組。所以一般TCP的MSS為MTU-20=1460位元組。
當應用程式層資料超過1460位元組時,TCP會分多個資料包來發送。
擴充閱讀
TCP的RFC定義MSS的預設值是536,這是因為 RFC 791裡說了任何一個IP裝置都得最少接收576尺寸的大小(實際上來說576是撥號的網路的MTU,而576減去IP頭的20個位元組就是536)。
TCP為什麼會粘包
有時候,TCP為了提高網路的利用率,會使用一個叫做Nagle的演算法。該演算法是指,發送端即使有要發送的資料,如果很少的話,會延遲發送。如果應用程式層給TCP傳送資料很快的話,就會把兩個應用程式層資料包“粘”在一起,TCP最後只發一個TCP資料包給接收端。
開發環境
- Python版本:3.5.1
- 作業系統:Windows 10 x64
訊息頭部(包含訊息長度)
訊息頭部不一定只能是一個位元組比如0xAA什麼的,也可以包含協議版本號碼,指令等,當然也可以把訊息長度合并到訊息頭部裡,唯一的要求是包頭長度要固定的,包體則可變長。下面是我自訂的一個包頭:
版本號碼(ver) |
訊息長度(bodySize) |
指令(cmd) |
版本號碼,訊息長度,指令資料類型都是無符號32位整型變數,於是這個訊息長度固定為4×3=12位元組。在Python由於沒有類型定義,所以一般是使用struct模組產生包頭。樣本:
import structimport jsonver = 1body = json.dumps(dict(hello="world"))print(body) # {"hello": "world"}cmd = 101header = [ver, body.__len__(), cmd]headPack = struct.pack("!3I", *header)print(headPack) # b'\x00\x00\x00\x01\x00\x00\x00\x12\x00\x00\x00e'
關於用自訂結束符分割資料包
有的人會想用自訂的結束符分割每一個資料包,這樣傳輸資料包時就不需要指定長度甚至也不需要包頭了。但是如果這樣做,網路傳輸效能損失非常大,因為每一讀取一個位元組都要做一次if判斷是否是結束符。所以建議還是選擇訊息頭部+訊息長度+訊息本文這種方式。
而且,使用自訂結束符的時候,如果訊息本文中出現這個符號,就會把後面的資料截止,這個時候還需要處理符號轉義,類比於\r\n的反斜線。所以非常不建議使用結束符分割資料包。
訊息本文
訊息本文的資料格式可以使用Json格式,這裡一般是用來存放獨特資訊的資料。在下面代碼中,我使用{"hello","world"}資料來測試。在Python使用json模組來產生json資料
Python樣本
下面使用Python代碼展示如何處理TCP Socket的粘包和分包。核心在於用一個FIFO隊列接收緩衝區dataBuffer和一個小while迴圈來判斷。
具體流程是這樣的:把從socket讀取出來的資料放到dataBuffer後面(入隊),然後進入小迴圈,如果dataBuffer內容長度小於訊息長度(bodySize),則跳出小迴圈繼續接收;大於訊息長度,則從緩衝區讀取包頭並擷取包體的長度,再判斷整個緩衝區是否大於訊息頭部+訊息長度,如果小於則跳出小迴圈繼續接收,如果大於則讀取包體的內容,然後處理資料,最後再把這次的訊息頭部和訊息本文從dataBuffer刪掉(出隊)。
下面用Markdown畫了一個流程圖。
伺服器端代碼
# Python Version:3.5.1import socketimport structHOST = ''PORT = 1234dataBuffer = bytes()headerSize = 12sn = 0def dataHandle(headPack, body): global sn sn += 1 print("第%s個資料包" % sn) print("ver:%s, bodySize:%s, cmd:%s" % headPack) print(body.decode()) print("")if __name__ == '__main__': with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind((HOST, PORT)) s.listen(1) conn, addr = s.accept() with conn: print('Connected by', addr) while True: data = conn.recv(1024) if data: # 把資料存入緩衝區,類似於push資料 dataBuffer += data while True: if len(dataBuffer) < headerSize: print("資料包(%s Byte)小於訊息頭部長度,跳出小迴圈" % len(dataBuffer)) break # 讀取包頭 # struct中:!代表Network order,3I代表3個unsigned int資料 headPack = struct.unpack('!3I', dataBuffer[:headerSize]) bodySize = headPack[1] # 分包情況處理,跳出函數繼續接收資料 if len(dataBuffer) < headerSize+bodySize : print("資料包(%s Byte)不完整(總共%s Byte),跳出小迴圈" % (len(dataBuffer), headerSize+bodySize)) break # 讀取訊息本文的內容 body = dataBuffer[headerSize:headerSize+bodySize] # 資料處理 dataHandle(headPack, body) # 粘包情況的處理 dataBuffer = dataBuffer[headerSize+bodySize:] # 擷取下一個資料包,類似於把資料pop出
測試伺服器端的用戶端代碼
下面附上測試粘包和分包的用戶端代碼
# Python Version:3.5.1import socketimport timeimport structimport jsonhost = "localhost"port = 1234ADDR = (host, port)if __name__ == '__main__': client = socket.socket() client.connect(ADDR) # 正常資料包定義 ver = 1 body = json.dumps(dict(hello="world")) print(body) cmd = 101 header = [ver, body.__len__(), cmd] headPack = struct.pack("!3I", *header) sendData1 = headPack+body.encode() # 分包資料定義 ver = 2 body = json.dumps(dict(hello="world2")) print(body) cmd = 102 header = [ver, body.__len__(), cmd] headPack = struct.pack("!3I", *header) sendData2_1 = headPack+body[:2].encode() sendData2_2 = body[2:].encode() # 粘包資料定義 ver = 3 body1 = json.dumps(dict(hello="world3")) print(body1) cmd = 103 header = [ver, body1.__len__(), cmd] headPack1 = struct.pack("!3I", *header) ver = 4 body2 = json.dumps(dict(hello="world4")) print(body2) cmd = 104 header = [ver, body2.__len__(), cmd] headPack2 = struct.pack("!3I", *header) sendData3 = headPack1+body1.encode()+headPack2+body2.encode() # 正常資料包 client.send(sendData1) time.sleep(3) # 分包測試 client.send(sendData2_1) time.sleep(0.2) client.send(sendData2_2) time.sleep(3) # 粘包測試 client.send(sendData3) time.sleep(3) client.close()
伺服器端列印結果
下面是測試出來的列印結果,可見接收方已經完美的處理粘包和分包問題了。
Connected by ('127.0.0.1', 23297)第1個資料包ver:1, bodySize:18, cmd:101{"hello": "world"}資料包(0 Byte)小於包頭長度,跳出小迴圈資料包(14 Byte)不完整(總共31 Byte),跳出小迴圈第2個資料包ver:2, bodySize:19, cmd:102{"hello": "world2"}資料包(0 Byte)小於包頭長度,跳出小迴圈第3個資料包ver:3, bodySize:19, cmd:103{"hello": "world3"}第4個資料包ver:4, bodySize:19, cmd:104{"hello": "world4"}
在架構下處理粘包和分包
其實無論是使用阻塞還是非同步socket開發架構,架構本身都會提供一個接收資料的方法提供給開發人員,一般來說開發人員都要覆寫這個方法。下面是在Twidted開發架構處理粘包和分包的樣本,只上核心程式:
# Twiestedclass MyProtocol(Protocol): _data_buffer = bytes() # 代碼省略 def dataReceived(self, data): """Called whenever data is received.""" self._data_buffer += data headerSize = 12 while True: if len(self._data_buffer) < headerSize: return # 讀取訊息頭部 # struct中:!代表Network order,3I代表3個unsigned int資料 headPack = struct.unpack('!3I', self._data_buffer[:headerSize]) # 擷取訊息本文長度 bodySize = headPack[1] # 分包情況處理 if len(self._data_buffer) < headerSize+bodySize : return # 讀取訊息本文的內容 body = self._data_buffer[headerSize:headerSize+bodySize] # 處理資料 self.dataHandle(headPack, body) # 粘包情況的處理 self._data_buffer = self._data_buffer[headerSize+bodySize:]
總結
以上就是本文關於python TCP Socket的粘包和分包的處理詳解的全部內容,希望對大家有所協助。感興趣的朋友可以繼續參閱本站其他相關專題,如有不足之處,歡迎留言指出。感謝朋友們對本站的支援!