1. 程式人生 > >【Python】TCP Socket的粘包和分包的處理

【Python】TCP Socket的粘包和分包的處理

概述

在進行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 struct
import json

ver = 1
body = json.dumps(dict(hello="world"))
print(body) # {"hello": "world"}
cmd = 101
header = [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畫了一個流程圖。

Created with Raphaël 2.1.2開始等待資料到達把資料push緩衝區緩衝區小於訊息長度?讀取訊息頭部的內容緩衝區小於訊息頭部和訊息正文長度?讀取訊息正文的內容處理資料從緩衝區pop資料yesnoyesno

伺服器端程式碼

# Python Version:3.5.1
import socket
import struct

HOST = ''
PORT = 1234

dataBuffer = bytes()
headerSize = 12

sn = 0
def 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.1
import socket
import time
import struct
import json

host = "localhost"
port = 1234

ADDR = (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開發框架處理粘包和分包的示例,只上核心程式:

# Twiested
class 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:]

後話

處理粘包和分包的C語言版本有時間再補充。

本文最後編輯時間:2018年1月16日