粘包問題

Ligo6發表於2024-05-28

【一】什麼是粘包

  • 只有TCP有粘包現象,UDP永遠不會粘包

【1】TCP

  • TCP(transport control protocol,傳輸控制協議)是面向連線的,面向流的,提供高可靠性服務。
    • 收發兩端(客戶端和伺服器端)都要有一一成對的socket
    • 因此,傳送端為了將多個發往接收端的包,更有效的發到對方,使用了最佳化方法(Nagle演算法),將多次間隔較小且資料量小的資料,合併成一個大的資料塊,然後進行封包。
    • 這樣,接收端,就難於分辨出來了,必須提供科學的拆包機制。 即面向流的通訊是無訊息保護邊界的。

【2】UDP

  • UDP(user datagram protocol,使用者資料包協議)是無連線的,面向訊息的,提供高效率服務。
    • 不會使用塊的合併最佳化演算法,, 由於UDP支援的是一對多的模式,所以接收端的skbuff(套接字緩衝區)採用了鏈式結構來記錄每一個到達的UDP包,在每個UDP包中就有了訊息頭(訊息來源地址,埠等資訊)
    • 這樣,對於接收端來說,就容易進行區分處理了。 即面向訊息的通訊是有訊息保護邊界的。

【3】小結

  • tcp是基於資料流的,於是收發的訊息不能為空,這就需要在客戶端和服務端都新增空訊息的處理機制,防止程式卡住,而udp是基於資料包的,即便是你輸入的是空內容(直接回車),那也不是空訊息,udp協議會幫你封裝上訊息頭
  • udp的recvfrom是阻塞的
    • 一個recvfrom(x)必須對唯一一個sendinto(y),收完了x個位元組的資料就算完成,若是y>x資料就丟失,這意味著udp根本不會粘包,但是會丟資料,不可靠
  • tcp的協議資料不會丟,沒有收完包,下次接收,會繼續上次繼續接收,己端總是在收到ack時才會清除緩衝區內容。資料是可靠的,但是會粘包。
  • 兩種情況下會發生粘包。
    • 傳送端需要等緩衝區滿才傳送出去,造成粘包(傳送資料時間間隔很短,資料了很小,會合到一起,產生粘包)

【二】什麼是粘包問題

【1】粘包問題

  • 在 TCP 協議中是流式協議,資料是源源不斷的傳入到客戶端中,但是客戶端可以接受到的資訊的長度是有限的
  • 當接收到指定長度的資訊後,客戶端進行列印輸出
    • 剩餘的其他資料會被快取到 記憶體中
  • 當再次執行其他命令時
    • 新的資料的反饋結果,會疊加到上一次沒有完全列印完全的資訊的後面,造成資料的錯亂
  • 當客戶端想列印新的命令的資料時,列印的其實是上一次沒有列印完的資料
    • 對資料造成的錯亂

【2】解決思路

#【1】資料量接受不完整是發生在接收端(因為接收端不知道總的資料量的大小,所以在傳送端進行處理)
# (1)獲取到原始資料的大小(資料長度)
# (2)定義一個字典(裡面儲存相應的引數,必須是當前數量的總大小 + 資料雜湊值 + 檔案的描述資訊..)
# (3)在python中,字典轉為字串後再轉回字典資料格式就變了
# 藉助json模組將字典資料轉為json字串型別
# (4)將json字串型別轉為二進位制資料,json二進位制資料可能也會非常大(獲取到json二進位制資料的總長度,藉助struct模組使用i模式對長度進行壓縮)
# (5)得到壓縮後的二進位制資料,只有四個位元組
# (6)傳送四個位元組的二進位制資料,透過struct模組得到打包後的資料
# (7)json二進位制資料打包後的資料就是json二進位制資料的長度
# (8)傳送原始資料,json資料裡面包含了總的資料長度

#【2】接收端要處理傳送端傳送的資料
# (1)接收四個位元組的二進位制資料,透過struct模組得到打包後的資料
# (2)對struct模組打包後的資料進行解包,得到json二進位制資料的長度
# (3)接收json二進位制資料的長度再轉成json二進位制資料
# (4)json二進位制資料再轉成json字串資料
# (5)json字串資料再轉成Python的字典
# (6)Python的字典獲取到總的資料長度,包括描述性資訊 ...
# (7)可以根據每次接收到的資料的大小,結合總的資料長度進行不斷的接收,直到所有資料接收完成
# (8)最後得到處理完整的二進位制資料

【三】TCP協議粘包問題演示

  • UDP協議不存在粘包問題就不進行演示了

  • 服務端

import socket

# 【1】伺服器端先初始化socket物件
# AF_INET:當前連線是基於網路的套接字
# SOCK_STREAM:連線模式是TCP協議的流式模式
server_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
ip = '127.0.0.1'
port = 8080
ADDR = (ip, port)
# 【2】然後與本地埠和ip繫結(bind)
server_socket.bind(ADDR)
# 【3】監聽連線物件
server_socket.listen(5)

# 【4】連線客戶端
conn, addr = server_socket.accept()
while True:
    try:
        # 【5】服務端接收並讀取客戶端資料
        from_client_data = conn.recv(1024)  # 1024個位元組
        if not from_client_data:
            break
        print(f"這是來自客戶端的訊息:{from_client_data.decode()}")  # 解碼二進位制資料
        # 【6】服務端返回給客戶端資料
        while True:
            # #只能傳送二進位制資料
            to_client_data = input("請輸入傳送給客戶端的訊息:").strip()
            if not to_client_data:
                print("傳送的訊息不能為空!")
                continue
            if to_client_data == 'q':
                print("該連線已斷開!")
                break
            conn.send(to_client_data.encode())  # 編碼二進位制資料
            break
    except Exception as e:
        break
# 【7】最後關閉連線(close)
conn.close()
server_socket.close()

# 可以看到第一次沒有接收完的資料,在返回給客戶端時,又把上一次沒有接收完的資料接收到了。導致了資料混亂
'''
這是來自客戶端的訊息:
Windows IP 配置


乙太網介面卡 乙太網:

   媒體狀態  . . . . . . . . . . . . : 媒體已斷開連線
   連線特定的 DNS 字尾 . . . . . . . : 

無線區域網介面卡 本地連線* 9:

   媒體狀態  . . . . . . . . . . . . : 媒體已斷開連線
   連線特定的 DNS 字尾 . . . . . . . : 

無線區域網介面卡 本地連線* 10:

   媒體狀態  . . . . . . . . . . . . : 媒體已斷開連線
   連線特定的 DNS 字尾 . . . . . . . : 

無線區域網介面卡 本地連線* 11:

   連線特定的 DNS 字尾 . . . . . . . : 
   本地連結 IPv6 地址. . . . . . . . : fe80::1101:1a8a:a264:eaf5%9
   IPv4 地址 . . . . . . . . . . . . : 192.168.137.1
   子網掩碼  . . . . . . . . . . . . : 255.255.255.0
   預設閘道器. . . . . . . . . . . . . : 

乙太網介面卡 VMware Network Adapter VMnet1:

   連線特定的 DNS 字尾 . . . . . . . : 
   本地連結 IPv6 地址. . . . . . . . : fe80::8c84:5a1c:a223:630a%19
   IPv4 地址 . . . . . . . . . . . . : 192.168.237.
   
請輸入傳送給客戶端的訊息:11  
這是來自客戶端的訊息:1
   子網掩碼  . . . . . . . . . . . . : 255.255.255.0
   預設閘道器. . . . . . . . . . . . . : 

乙太網介面卡 VMware Network Adapter VMnet8:

   連線特定的 DNS 字尾 . . . . . . . : 
   本地連結 IPv6 地址. . . . . . . . : fe80::70b8:24ff:7cdc:f31%4
   IPv4 地址 . . . . . . . . . . . . : 192.168.107.1
   子網掩碼  . . . . . . . . . . . . : 255.255.255.0
   預設閘道器. . . . . . . . . . . . . : 

無線區域網介面卡 WLAN:

   連線特定的 DNS 字尾 . . . . . . . : 
   本地連結 IPv6 地址. . . . . . . . : fe80::d3c5:d4d4:410b:64a0%21
   IPv4 地址 . . . . . . . . . . . . : 192.168.0.115
   子網掩碼  . . . . . . . . . . . . : 255.255.255.0
   預設閘道器. . . . . . . . . . . . . : 192.168.0.1

乙太網介面卡 藍芽網路連線:

   媒體狀態  . . . . . . . . . . . . : 媒體已斷開連線
   連線特定的 DNS 字尾 . . . . . . . : 

請輸入傳送給客戶端的訊息:
'''
  • 客戶端
import socket
import subprocess


def run(command):
    result = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='gbk',
                            timeout=1)
    # command:子程序要執行的命令
    # shell=True:執行的是shell的命令
    # stdout=subprocess.PIPE:存放的是執行命令成功的結果
    # stderr=subprocess.PIPE;存放的是執行命令失敗的結果
    # returncode屬性是run()函式返回結果的狀態
    if result.returncode == 0:
        return result.stdout
    else:
        return result.stderr


# 【1】客戶端初始化一個Socket
# AF_INET:當前連線是基於網路的套接字
# SOCK_STREAM:連線模式是TCP協議的流式模式
client_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
ip = '127.0.0.1'
port = 8080
ADDR = (ip, port)
# 【2】連線伺服器
client_socket.connect(ADDR)
while True:
    # 【3】客戶端直接向伺服器端傳送資料
    command = input('請輸入執行的命令:').strip()
    if not command:
        print("傳送的資料不能為空!")
        continue
    if command == 'q':
        print("該連線已斷開!")
        break
    to_server_data = run(command=command)
    client_socket.send(to_server_data.encode())

    # 【4】客戶端接收並讀取服務端的資料
    from_server_data = client_socket.recv(1024)
    if from_server_data == 'q':
        break
    print(f"這是來自服務端的訊息:{from_server_data.decode()}")

# 【5】最後關閉連線(close)
client_socket.close()

【四】粘包問題的解決辦法

  • 服務端
import socket
import json
import struct

# 【1】伺服器端先初始化socket物件
# AF_INET:當前連線是基於網路的套接字
# SOCK_STREAM:連線模式是TCP協議的流式模式
server_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
ip = '127.0.0.1'
port = 8080
ADDR = (ip, port)
# 【2】然後與本地埠和ip繫結(bind)
server_socket.bind(ADDR)
# 【3】監聽連線物件
server_socket.listen(5)

# 【4】連線客戶端
conn, addr = server_socket.accept()
while True:
    try:
        # 【5】服務端接收並讀取客戶端資料
        # 接收到struct打包的四個位元組資料
        json_pack_data = conn.recv(4)  # 1024個位元組
        if not json_pack_data:
            break
        json_bytes_length = struct.unpack('i', json_pack_data)[0]
        # 根據資料長度轉為json二進位制資料
        json_bytes = conn.recv(json_bytes_length)
        # json二進位制資料轉為json字串資料
        json_str = json_bytes.decode()
        # json字串資料轉為字典資料
        data_info = json.loads(json_str)
        # 從字典中獲取總的資料長度
        data_length = data_info.get('result_length')
        # 定義引數
        # 總資料
        all_data = b''
        # 每次接收的資料大小
        size = 1024
        count, last_size = divmod(data_length, size)
        # 已經接收的資料大小
        all_size = 0
        while all_size < count+1:
            all_size += 1
            if all_size == count+1:
                all_data += conn.recv(last_size)
            else:
                all_data += conn.recv(size)
        print(f"這是來自客戶端的訊息:{all_data.decode()}")
        # 【6】服務端返回給客戶端資料
        while True:
            # #只能傳送二進位制資料
            to_client_data = input("請輸入傳送給客戶端的訊息:").strip()
            if not to_client_data:
                print("傳送的訊息不能為空!")
                continue
            if to_client_data == 'q':
                print("該連線已斷開!")
                break
            conn.send(to_client_data.encode())  # 編碼二進位制資料
            break
    except Exception as e:
        break
# 【7】最後關閉連線(close)
conn.close()
server_socket.close()

# 可以看到現在資料是一次性全部接收到了
'''
這是來自客戶端的訊息:
Windows IP 配置


乙太網介面卡 乙太網:

   媒體狀態  . . . . . . . . . . . . : 媒體已斷開連線
   連線特定的 DNS 字尾 . . . . . . . : 

無線區域網介面卡 本地連線* 9:

   媒體狀態  . . . . . . . . . . . . : 媒體已斷開連線
   連線特定的 DNS 字尾 . . . . . . . : 

無線區域網介面卡 本地連線* 10:

   媒體狀態  . . . . . . . . . . . . : 媒體已斷開連線
   連線特定的 DNS 字尾 . . . . . . . : 

無線區域網介面卡 本地連線* 11:

   連線特定的 DNS 字尾 . . . . . . . : 
   本地連結 IPv6 地址. . . . . . . . : fe80::1101:1a8a:a264:eaf5%9
   IPv4 地址 . . . . . . . . . . . . : 192.168.137.1
   子網掩碼  . . . . . . . . . . . . : 255.255.255.0
   預設閘道器. . . . . . . . . . . . . : 

乙太網介面卡 VMware Network Adapter VMnet1:

   連線特定的 DNS 字尾 . . . . . . . : 
   本地連結 IPv6 地址. . . . . . . . : fe80::8c84:5a1c:a223:630a%19
   IPv4 地址 . . . . . . . . . . . . : 192.168.237.1
   子網掩碼  . . . . . . . . . . . . : 255.255.255.0
   預設閘道器. . . . . . . . . . . . . : 

乙太網介面卡 VMware Network Adapter VMnet8:

   連線特定的 DNS 字尾 . . . . . . . : 
   本地連結 IPv6 地址. . . . . . . . : fe80::70b8:24ff:7cdc:f31%4
   IPv4 地址 . . . . . . . . . . . . : 192.168.107.1
   子網掩碼  . . . . . . . . . . . . : 255.255.255.0
   預設閘道器. . . . . . . . . . . . . : 

無線區域網介面卡 WLAN:

   連線特定的 DNS 字尾 . . . . . . . : 
   本地連結 IPv6 地址. . . . . . . . : fe80::d3c5:d4d4:410b:64a0%21
   IPv4 地址 . . . . . . . . . . . . : 192.168.0.115
   子網掩碼  . . . . . . . . . . . . : 255.255.255.0
   預設閘道器. . . . . . . . . . . . . : 192.168.0.1

乙太網介面卡 藍芽網路連線:

   媒體狀態  . . . . . . . . . . . . : 媒體已斷開連線
   連線特定的 DNS 字尾 . . . . . . . : 

請輸入傳送給客戶端的訊息:good
'''
  • 客戶端
import hashlib
import json
import socket
import struct
import subprocess
import uuid


def run(command):
    result = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='gbk',
                            timeout=1)
    # command:子程序要執行的命令
    # shell=True:執行的是shell的命令
    # stdout=subprocess.PIPE:存放的是執行命令成功的結果
    # stderr=subprocess.PIPE;存放的是執行命令失敗的結果
    # returncode屬性是run()函式返回結果的狀態
    if result.returncode == 0:
        return result.stdout
    else:
        return result.stderr


def encrypted_data(data, salt):
    data = str(data) + str(salt)
    data = data.encode()
    md5 = hashlib.md5(data)
    md5.update(data)
    return md5.hexdigest()


# 【1】客戶端初始化一個Socket物件
# AF_INET:當前連線是基於網路的套接字
# SOCK_STREAM:連線模式是TCP協議的流式模式
client_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM)
ip = '127.0.0.1'
port = 8080
ADDR = (ip, port)
# 【2】連線伺服器
client_socket.connect(ADDR)
while True:
    # 【3】客戶端直接向伺服器端傳送資料
    command = input('請輸入執行的命令:').strip()
    if not command:
        print("傳送的資料不能為空!")
        continue
    if command == 'q':
        print("該連線已斷開!")
        break
    # 【1】執行本地的命令,獲取到當前命令結果
    result = run(command=command)
    # 【2】對命令結果編碼成二進位制資料
    result_bytes = result.encode()
    # 【3】計算長度
    result_length = len(result_bytes)
    # 【4】增加一個資料概覽
    salt = uuid.uuid4().hex
    encrypted_data = encrypted_data(data=result_bytes,salt=salt)
    send_data_info = {
        'command': command,
        'result_length': result_length,
        'salt': salt,
        'encrypted_data': encrypted_data
    }
    # 【5】將打包好的資料送給服務端
    # 利用json將字典轉為字串
    # dump : 處理檔案資料
    # dumps : 做格式轉換的
    json_str = json.dumps(send_data_info)
    # 將字串轉為二進位制資料
    json_bytes = json_str.encode()
    # 【6】利用struct模組將二進位制資料變短
    json_length_pack = struct.pack('i', len(json_bytes))
    # 【7】傳送struct打包的四個位元組資料+json資料+原始資料
    # 服務端接受的順序取決於客戶端傳送的順序
    # 先傳送struct打包的資料
    client_socket.send(json_length_pack)
    # 再傳送json打包的資料
    client_socket.send(json_bytes)
    # 最後傳送原始資料
    client_socket.send(result_bytes)
    from_server_data = client_socket.recv(1024)
    if from_server_data == 'q':
        break
    print(f"這是來自服務端的訊息:{from_server_data.decode()}")
# 【5】最後關閉連線(close)
client_socket.close()

# 可以看到客戶端也可以接收到服務端返回的資訊
'''
請輸入執行的命令:ipconfig
這是來自服務端的訊息:good
請輸入執行的命令:
'''

【補充】struct模組

  • struct.pack()是Python內建模組struct中的一個函式

    • 它的作用是將指定的資料按照指定的格式進行打包,並將打包後的結果轉換成一個位元組序列(byte string)
    • 可以用於在網路上傳輸或者儲存於檔案中。
  • struct.pack(fmt, v1, v2, ...)

    • 其中,fmt為格式字串,指定了需要打包的資料的格式,後面的v1,v2,...則是需要打包的資料。
    • 這些資料會按照fmt的格式被編碼成二進位制的位元組串,並返回這個位元組串。
  • fmt的常用格式符如下:

    • x --- 填充位元組
    • c --- char型別,佔1位元組
    • b --- signed char型別,佔1位元組
    • B --- unsigned char型別,佔1位元組
    • h --- short型別,佔2位元組
    • H --- unsigned short型別,佔2位元組
    • i --- int型別,佔4位元組
    • I --- unsigned int型別,佔4位元組
    • l --- long型別,佔4位元組(32位機器上)或者8位元組(64位機器上)
    • L --- unsigned long型別,佔4位元組(32位機器上)或者8位元組(64位機器上)
    • q --- long long型別,佔8位元組
    • Q --- unsigned long long型別,佔8位元組
    • f --- float型別,佔4位元組
    • d --- double型別,佔8位元組
    • s --- char[]型別,佔指定位元組個數,需要用數字指定長度
    • p --- char[]型別,跟s一樣,但通常用來表示字串
    • ? --- bool型別,佔1位元組
    import struct
    
    # 定義一個包含不同型別欄位的格式字串
    format_string = 'i'
    
    # 示例資料:整數、四個位元組的原始資料、短整數
    data_to_pack = '十七dasdadsad asd 撒大撒多所adsaddasdadsa da dsa asad撒大大帶我去大青蛙大大大大大薩達去問問恰飯恰飯放散閥昂發昂發沙發阿發發發放上千萬請傳送方三房啟發法阿發發發ad sada dsa dsa dsa sa dsa dsa as ad sad ad ada頓撒大大三大撒打我前端'
    data_to_pack_bytes = data_to_pack.encode()
    
    data_to_pack_len = len(data_to_pack_bytes)
    print(data_to_pack_len)
    # 使用 struct.pack 將資料打包成二進位制位元組串
    packed_data = struct.pack(format_string, data_to_pack_len)
    
    # 41000000
    # 64000000
    # 19010000
    print("Packed data:", len(packed_data))  # 列印打包後的十六進位制表示
    
    # 解析二進位制位元組串,恢復原始資料
    unpacked_data = struct.unpack(format_string, packed_data)
    #
    print("Unpacked data:", unpacked_data)  # 列印解析後的資料
    

相關文章