Learning-Python【29】:網路程式設計之粘包
粘包問題
上一篇部落格遺留了一個問題,在接收的最大位元組數設定為 1024 時,當接收的結果大於1024,再執行下一條命令時還是會返回上一條命令未執行完成的結果。這就是粘包問題。
因為TCP協議又叫流式協議,每次傳送給客戶端的資料實際上是傳送到客戶端所在作業系統的快取上,客戶端就是一個應用程式,需要通過作業系統才能調到快取內的資料,而快取的空間是一個佇列,有 “先進先出” 的思想,當第一次的 tasklist 資料未接收完,第二次又來一個 dir 的資料時,只能等第一次先全部接收完成才會接收後面的。
有一個解決方法是每次在接收資料時,都將資料的完整結果全部接收,這樣就不會出現粘包現象。那該怎麼樣才能全部接收呢?有人說將接收的最大位元組數設定大點不就能接收 tasklist 的全部執行結果了嗎?這樣做確實可以,但如果是檔案的上傳下載呢?客戶端執行下載命令,服務端將下載的結果傳送給客戶端,客戶端再接收,檔案的大小是超過 GB、TB 的,那最大位元組數該設定多大?其實設定再大也沒有意義,因為客戶端接收資料是通過自己作業系統的快取空間接收的,快取空間的大小不可能比自己計算機的實體記憶體還大,就算和實體記憶體一樣大,假設實體記憶體是 8G,那你也只能一次收到 8GB 的資料,當傳送的資料超過 8G 呢?
TCP協議為了優化傳輸效率,而導致了粘包問題。客戶端和服務端之間是基於網路收發資料,網路的 I/O 是越少越好,TCP有一種 Nagle 演算法,是將多次時間間隔較短且資料量小的資料,合併成一個大的資料塊,然後進行封包,這樣,儘可能多的降低 I/O,從而提升程式的執行效率。但是接收端很難分辨出來,這就導致了粘包問題。
總結粘包問題:
粘包不一定會發生
如果發生了:1)可能是在客戶端已經粘了
2)客戶端沒有粘,可能是在服務端粘了
客戶端粘包:傳送資料時間間隔很短,資料量很小,TCP優化演算法會當做一個包發出去,產生粘包
from socket import *
server = socket(AF_INET, SOCK_STREAM)
server.bind(("127.0.0.1", 8080))
server.listen(5)
conn, client_addr = server.accept()
data1 = conn.recv(1024)
print("第一次收: ", data1)
data2 = conn.recv(1024)
print("第二次收: ",data2)
data3 = conn.recv(1024)
print("第三次收: ",data3)
服務端
from socket import *
client = socket(AF_INET, SOCK_STREAM)
client.connect(("127.0.0.1", 8080))
# TCP協議會將資料量較小且時間間隔較短的資料合併成一個數據報傳送
client.send(b'hello')
client.send(b'world')
client.send(b'qiu')
客戶端
第一次收: b'helloworldqiu'
第二次收: b''
第三次收: b''
執行結果
服務端粘包:客戶端傳送了一段資料,服務端只收了一小部分,服務端下次再收的時候還是從緩衝區拿上次遺留的資料,產生粘包
from socket import *
server = socket(AF_INET, SOCK_STREAM)
server.bind(("127.0.0.1", 8080))
server.listen(5)
conn, client_addr = server.accept()
data1 = conn.recv(1)
print("第一次收: ", data1)
data2 = conn.recv(2)
print("第二次收: ",data2)
data3 = conn.recv(1024)
print("第三次收: ",data3)
服務端
from socket import *
client = socket(AF_INET, SOCK_STREAM)
client.connect(("127.0.0.1", 8080))
client.send(b'hello')
client.send(b'world')
client.send(b'qiu')
客戶端
第一次收: b'h'
第二次收: b'el'
第三次收: b'loworldqiu'
執行結果
粘包問題的解決思路
問題的根源在於,接收端不知道傳送端將要傳送的位元組流的長度,所以解決粘包的方法就是傳送端在傳送資料前,發一個頭檔案包,裡面包含每次要傳送資料的長度,構成一個總長度,然後接收端用迴圈接收完所有資料,但是長度是整型,傳送的資料是位元組,所以要將整型轉成位元組型別再發送。
struct 模組
使用 struct 模組可以用於將 Python 的 int 型別轉換為 bytes 型別
struct 模組中最重要的三個函式是pack(), unpack(), calcsize()
pack(fmt, v1, v2, ...):按照給定的格式 (fmt),把資料封裝成字串(實際上是類似於 C 語言中結構體的位元組流)
unpack(fmt, string):按照給定的格式 (fmt) 解析位元組流 string,返回解析出來的 tuple
calcsize(fmt):計算給定的格式 (fmt) 佔用多少位元組的記憶體
struct 中支援的格式如下表
import struct
obj = struct.pack('i', 1231)
print(obj)
print(len(obj)) # C語言中int型別佔4個位元組
res = struct.unpack("i", obj)
print(res)
print(res[0])
# 執行結果
b'\xcf\x04\x00\x00'
4
(1231,)
1231
struct模組
模擬ssh實現遠端執行命令(解決粘包問題簡單版)
from socket import *
import subprocess
import struct
server = socket(AF_INET, SOCK_STREAM)
server.bind(("127.0.0.1", 8080))
server.listen(5)
# 連線迴圈
while True:
conn, client_addr = server.accept()
# 通訊迴圈
while True:
try:
cmd = conn.recv(1024) # cmd = b'dir'
# 針對Linux系統
if len(cmd) == 0:
break
# 命令的執行結果
obj = subprocess.Popen(cmd.decode("utf-8"),
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
stdout = obj.stdout.read()
stderr = obj.stderr.read()
# 1. 先製作固定長度的報頭
header = struct.pack("i", len(stdout) + len(stderr))
# 2. 再發送報頭
conn.send(header)
# 3. 最後傳送真實的資料
conn.send(stdout)
conn.send(stderr)
except ConnectionResetError:
break
conn.close()
server.close()
服務端
from socket import *
import struct
client = socket(AF_INET, SOCK_STREAM)
client.connect(("127.0.0.1", 8080))
# 通訊迴圈
while True:
cmd = input("請輸入: ").strip()
if len(cmd) == "0":
continue
client.send(cmd.encode("utf-8"))
# 1. 先收報頭, 從報頭裡解出資料的長度
header = client.recv(4)
total_size = struct.unpack("i", header)[0]
# 2. 接收真正的資料
cmd_res = b""
# 接收資料的長度初始值為0
recv_size = 0
# 當接收的資料長度小於報頭長度
while recv_size < total_size:
data = client.recv(1024)
recv_size += len(data)
cmd_res += data
print(cmd_res.decode("gbk"))
client.close()
客戶端
這樣寫有一個限制,我在 struct 模組中設定的是 i 格式,只能用於傳輸較小的位元組數,且此時報頭裡只包含資料長度資訊,如果是上傳下載檔案,還可能包含檔名、檔案大小、檔案的 md5 值等其它資訊,那這種方法就不適用了
可以考慮將報頭設定成一個字典,包含相關的資訊,然後將字典序列化成 JSON 格式傳送,在接收方反序列化還能得到字典格式,且可以設定字典裡的檔案大小很大,但 JSON 的長度卻很小
import json
header_dic = {
"filename": "a.txt",
"md5": "DASHJH423465CSA",
"total_size":456165446511564651351456413514543543
}
header_json = json.dumps(header_dic)
print(len(header_json))
# 執行
99
報頭字典序列化成JSON的長度
模擬ssh實現遠端執行命令(解決粘包問題終極版)
from socket import *
import subprocess
import struct
import json
server = socket(AF_INET, SOCK_STREAM)
server.bind(("127.0.0.1", 8080))
server.listen(5)
# 連線迴圈
while True:
conn, client_addr = server.accept()
# 通訊迴圈
while True:
try:
cmd = conn.recv(1024) # cmd = b'dir'
# 針對Linux系統
if len(cmd) == 0:
break
# 命令的執行結果
obj = subprocess.Popen(cmd.decode("utf-8"),
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
stdout = obj.stdout.read()
stderr = obj.stderr.read()
# 1. 先製作報頭
header_dic = {
"filename": "a.txt",
"md5": "DASHJH423465CSA",
"total_size": len(stdout) + len(stderr)
}
# 將報頭序列化成json格式的字串
header_json = json.dumps(header_dic)
# json格式的字串轉成bytes型別
header_bytes = header_json.encode("utf-8")
# 2. 先發送4個bytes(包含報頭的長度)
conn.send(struct.pack("i", len(header_bytes)))
# 3. 傳送報頭
conn.send(header_bytes)
# 4. 最後傳送真實的資料
conn.send(stdout)
conn.send(stderr)
except ConnectionResetError:
break
conn.close()
server.close()
服務端
from socket import *
import struct
import json
client = socket(AF_INET, SOCK_STREAM)
client.connect(("127.0.0.1", 8080))
# 通訊迴圈
while True:
cmd = input("請輸入: ").strip()
if len(cmd) == "0":
continue
client.send(cmd.encode("utf-8"))
# 1. 先收4個bytes, 解出報頭長度
header_size = struct.unpack("i", client.recv(4))[0]
# 2. 再接收報頭, 拿到head_dic
header_bytes = client.recv(header_size)
header_json = header_bytes.decode("utf-8")
head_dic = json.loads(header_json)
print(head_dic)
total_size = head_dic["total_size"]
# 3. 接收真正的資料
cmd_res = b""
# 接收資料的長度初始值為0
recv_size = 0
# 當接收的資料長度小於報頭長度
while recv_size < total_size:
data = client.recv(1024)
recv_size += len(data)
cmd_res += data
print(cmd_res.decode("gbk"))
client.close()
客戶端