1. 程式人生 > >day028兩種粘包現象,兩種解決粘包的方法,subprocess, struck模組

day028兩種粘包現象,兩種解決粘包的方法,subprocess, struck模組

本節內容:

1.兩種粘包現象
2.struck模組的使用
3.兩種解決粘包的解決方案
4.驗證客戶端的連結合法性
參考文章:https://www.cnblogs.com/clschao/articles/9593164.html?tdsourcetag=s_pctim_aiomsg#part_9

一、兩種粘包現象

粘包1:連續的小包,會被優化機制給合併
粘包2:服務端一次性無法完全就收完客戶端傳送的資料,第二再次接收的時候,會接收到第一次遺留的內容

1、緩衝區

socket緩衝區解釋

每個 socket 被建立後,都會分配兩個緩衝區,輸入緩衝區和輸出緩衝區。

write()/send() 並不立即向網路中傳輸資料,而是先將資料寫入緩衝區中,再由TCP協議將資料從緩衝區傳送到目標機器。一旦將資料寫入到緩衝區,函式就可以成功返回,不管它們有沒有到達目標機器,也不管它們何時被髮送到網路,這些都是TCP協議負責的事情。

TCP協議獨立於 write()/send() 函式,資料有可能剛被寫入緩衝區就傳送到網路,也可能在緩衝區中不斷積壓,多次寫入的資料被一次性發送到網路,這取決於當時的網路情況、當前執行緒是否空閒等諸多因素,不由程式設計師控制。

read()/recv() 函式也是如此,也從輸入緩衝區中讀取資料,而不是直接從網路中讀取。

這些I/O緩衝區特性可整理如下:

1.I/O緩衝區在每個TCP套接字中單獨存在;
2.I/O緩衝區在建立套接字時自動生成;
3.即使關閉套接字也會繼續傳送輸出緩衝區中遺留的資料;
4.關閉套接字將丟失輸入緩衝區中的資料。

輸入輸出緩衝區的預設大小一般都是 8K,可以通過 getsockopt() 函式獲取:

1.unsigned optVal;
2.int optLen = sizeof(int);
3.getsockopt(servSock, SOL_SOCKET, SO_SNDBUF,(char*)&optVal, &optLen);
4.printf("Buffer length: %d\n", optVal);
# 獲取socket緩衝區大小的程式碼
import socket
from socket import SOL_SOCKET,SO_REUSEADDR,SO_SNDBUF,SO_RCVBUF sk = socket.socket(type=socket.SOCK_DGRAM) # sk.setsockopt(SOL_SOCKET,SO_RCVBUF,80*1024) sk.bind(('127.0.0.1',8090)) print('>>>>', (sk.getsockopt(SOL_SOCKET, SO_SNDBUF))/1024) print('>>>>', sk.getsockopt(SOL_SOCKET, SO_RCVBUF))

2、windows下cmd視窗呼叫系統指令(linux下沒有寫出來,大家仿照windows的去摸索一下吧)

.a.首先win+r,彈出左下角的下圖,輸入cmd指令,確定

b.在開啟的cmd視窗中輸入dir

(dir:檢視當前資料夾下的所有檔案和資料夾),你會看到下面的輸出結果。


另外還有ipconfig(檢視當前電腦的網路資訊),在windows沒有ls這個指令(ls在linux下是檢視當前資料夾下所有檔案和資料夾的指令,和windows下的dir是類似的),那麼沒有這個指令就會報下面這個錯誤

3、粘包現象(2種)

粘包1:連續的小包,會被優化機制給合併
粘包2:服務端一次性無法完全就收完客戶端傳送的資料,
第二再次接收的時候,會接收到第一次遺留的內容
先上圖:(本圖是做出來為了有個大致的瞭解用的,其中很多地方更加的複雜,那就需要將來大家有多餘的精力的時候去做一些深入的研究了,這裡就不)


關於MTU大家可以看看這篇文章 https://yq.aliyun.com/articles/222535

MTU簡單解釋:

MTU是Maximum Transmission Unit的縮寫。意思是網路上傳送的最大資料包。
MTU的單位是位元組。 大部分網路裝置的MTU都是1500個位元組,也就是1500B。
如果本機一次需要傳送的資料比閘道器的MTU大,大的資料包就會被拆開來傳送,
這樣會產生很多資料包碎片,增加丟包率,降低網路速度

1、 超出緩衝區大小會報下面的錯誤,

或者udp協議的時候,你的一個數據包的大小超過了你一次recv能接受的大小,
也會報下面的錯誤,tcp不會,但是超出快取區大小的時候,肯定會報這個錯誤。

2、在模擬粘包之前,我們先學習一個模組subprocess。

注意:

    如果是windows,那麼res.stdout.read()讀出的就是GBK編碼的,
在接收端需要用GBK解碼
且只能從管道里讀一次結果,PIPE稱為管道。

<details>
<summary>模組subprocess程式碼</summary>

import subprocess
cmd = input('請輸入指令>>>') res = subprocess.Popen( cmd, #字串指令:'dir','ipconfig',等等 shell=True, #使用shell,就相當於使用cmd視窗 stderr=subprocess.PIPE, #標準錯誤輸出,凡是輸入錯誤指令,錯誤指令輸出的報錯資訊就會被它拿到 stdout=subprocess.PIPE, #標準輸出,正確指令的輸出結果被它拿到 ) print(res.stdout.read().decode('gbk')) print(res.stderr.read().decode('gbk')) subprocess的簡單使用

</details>
#### 3、下面是subprocess和windows上cmd下的指令的對應示意圖:
subprocess的stdout.read()和stderr.read(),拿到的結果是bytes型別,
所以需要轉換為字串打印出來看。

### 4、tcp粘包演示(一):
產生原因:
接收方沒有及時接收緩衝區的包,造成多個包接收
(客戶端傳送了一段資料,服務端只收了一小部分,
服務端下次再收的時候還是從緩衝區拿上次遺留的資料,產生粘包)

<details>
<summary>server端程式碼示例:</summary>

cket import *
import subprocess

ip_port=('127.0.0.1',8080) BUFSIZE=1024 tcp_socket_server=socket(AF_INET,SOCK_STREAM) tcp_socket_server.setsockopt(SOL_SOCKET,SO_REUSEADDR,1) tcp_socket_server.bind(ip_port) tcp_socket_server.listen(5) while True: conn,addr=tcp_socket_server.accept() print('客戶端>>>',addr) while True: cmd=conn.recv(BUFSIZE) if len(cmd) == 0:break res=subprocess.Popen(cmd.decode('gbk'),shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE) stderr=res.stderr.read() stdout=res.stdout.read() conn.send(stderr) conn.send(stdout)

</details>

<details>
<summary>client端程式碼示例:</summary>

import socket
ip_port = ('127.0.0.1',8080) size = 1024 tcp_sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM) res = tcp_sk.connect(ip_port) while True: msg=input('>>: ').strip() if len(msg) == 0:continue if msg == 'quit':break tcp_sk.send(msg.encode('utf-8')) act_res=tcp_sk.recv(size) print('接收的返回結果長度為>',len(act_res)) print('std>>>',act_res.decode('gbk')) #windows返回的內容需要用gbk來解碼,因為windows系統的預設編碼為gbk

</details>
### 5、tcp粘包演示(二):
傳送端需要等緩衝區滿才傳送出去,造成粘包
(傳送資料時間間隔很短,資料也很小,會合到一起,產生粘包)

如果兩次傳送有一定的時間間隔,那麼就不會出現這種粘包情況,
試著在兩次傳送的中間加一個time.sleep(1)

<details>
<summary>server端程式碼示例:</summary>

from socket import *
ip_port=('127.0.0.1',8080) tcp_socket_server=socket(AF_INET,SOCK_STREAM) tcp_socket_server.bind(ip_port) tcp_socket_server.listen(5) conn,addr=tcp_socket_server.accept() data1=conn.recv(10) data2=conn.recv(10) print('----->',data1.decode('utf-8')) print('----->',data2.decode('utf-8')) conn.close() tcp_server.py

</details>

<details>
<summary>client端程式碼示例:</summary>

import socket
BUFSIZE=1024
ip_port=('127.0.0.1',8080) s=socket.socket(socket.AF_INET,socket.SOCK_STREAM) # res=s.connect_ex(ip_port) res=s.connect(ip_port) s.send('hi'.encode('utf-8')) s.send('meinv'.encode('utf-8'))

</details>
#### 示例二的結果:全部被第一個recv接收了

### 6、TCP會粘包、UDP永遠不會粘包

<details>
<summary>解釋原因</summary>

傳送端可以是一K一K地傳送資料,而接收端的應用程式可以兩K兩K地提走資料,
當然也有可能一次提走3K或6K資料,或者一次只提走幾個位元組的資料,
也就是說,應用程式所看到的資料是一個整體,或說是一個流(stream),
一條訊息有多少位元組對應用程式是不可見的,因此TCP協議是面向流的協議,
這也是容易出現粘包問題的原因。而UDP是面向訊息的協議,每個UDP段都是一條訊息,
應用程式必須以訊息為單位提取資料,不能一次提取任意位元組的資料,
這一點和TCP是很不同的。怎樣定義訊息呢?可以認為對方一次性write/send的資料為一個訊息,
需要明白的是當對方send一條資訊的時候,無論底層怎樣分段分片,TCP協議層會把構成整條訊息的資料段排序完成後才呈現在核心緩衝區。

例如基於tcp的套接字客戶端往服務端上傳檔案,傳送時檔案內容是按照一段一段的位元組流傳送的,
在接收方看了,根本不知道該檔案的位元組流從何處開始,在何處結束

所謂粘包問題主要還是因為接收方不知道訊息之間的界限,
不知道一次性提取多少位元組的資料所造成的。

此外,傳送方引起的粘包是由TCP協議本身造成的,TCP為提高傳輸效率,
傳送方往往要收集到足夠多的資料後才傳送一個TCP段。
若連續幾次需要send的資料都很少,通常TCP會根據優化演算法把這些資料合成一個TCP段後一次傳送出去,這樣接收方就收到了粘包資料。

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

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

    3.tcp是基於資料流的,於是收發的訊息不能為空,這就需要在客戶端和服務端都新增空訊息的處理機制,
    防止程式卡住,而udp是基於資料報的,即便是你輸入的是空內容(直接回車),那也不是空訊息,
    udp協議會幫你封裝上訊息頭,實驗略
udp的recvfrom是阻塞的,一個recvfrom(x)必須對唯一一個sendinto(y),
收完了x個位元組的資料就算完成,若是y>x資料就丟失,這意味著udp根本不會粘包,但是會丟資料,不可靠

tcp的協議資料不會丟,沒有收完包,下次接收,會繼續上次繼續接收,
己端總是在收到ack時才會清除緩衝區內容。資料是可靠的,但是會粘包。
解釋原因

</details>
### 7、補充問題一:為何tcp是可靠傳輸,udp是不可靠傳輸
tcp在資料傳輸時,傳送端先把資料傳送到自己的快取中,
然後協議控制將快取中的資料發往對端,對端返回一個ack=1,
傳送端則清理快取中的資料,對端返回ack=0,則重新發送資料,所以tcp是可靠的。

而udp傳送資料,對端是不會返回確認資訊的,因此不可靠

8、補充問題二:send(位元組流)和sendall

send的位元組流是先放入己端快取,然後由協議控制將快取內容發往對端,
如果待發送的位元組流大小大於快取剩餘空間,那麼資料丟失,
用sendall就會迴圈呼叫send,資料不會丟失,一般的小資料就用send,
因為小資料也用sendall的話有些影響程式碼效能,簡單來講就是還多while迴圈這個程式碼呢。

用UDP協議傳送時,用sendto函式最大能傳送資料的長度為:
65535- IP頭(20) – UDP頭(8)=65507位元組。
用sendto函式傳送資料時,如果傳送資料長度大於該值,
則函式會返回錯誤。(丟棄這個包,不進行傳送) 

用TCP協議傳送時,由於TCP是資料流協議,
因此不存在包大小的限制(暫不考慮緩衝區的大小),
這是指在用send函式時,資料長度引數不受限制。
而實際上,所指定的這段資料並不一定會一次性發送出去,
如果這段資料比較長,會被分段傳送,
如果比較短,可能會等待和下一次資料一起傳送。

粘包的原因:主要還是因為接收方不知道訊息之間的界限,不知道一次性提取多少位元組的資料所造成的

二、struck模組的運用(python獨有的)

1、關於struck的介紹:

瞭解c語言的人,一定會知道struct結構體在c語言中的作用,不瞭解C語言的同學也沒關係,不影響,
其實它就是定義了一種結構,裡面包含不同型別的資料(int,char,bool等等),方便對某一結構物件進行處理。
而在網路通訊當中,大多傳遞的資料是以二進位制流(binary data)存在的。
當傳遞字串時,不必擔心太多的問題,而當傳遞諸如int、char之類的基本資料的時候,
就需要有一種機制將某些特定的結構體型別打包成二進位制流的字串然後再網路傳輸,而接收端也應該可以通過某種機制進行解包還原出原始的結構體資料。
python中的struct模組就提供了這樣的機制,
該模組的主要作用就是對python基本型別值與用python字串格式表示的C struct型別間的轉化
(This module performs conversions between Python values and C structs represented as Python strings.)。

2、struck模組的使用:struct模組中最重要的兩個函式是pack()打包, unpack()解包。

3、pack():這裡只介紹一下’i’這個int型別,

import struct
a=12
# 將a變為二進位制
bytes=struct.pack('i',a) struct.pack('i',1111111111111) # 如果int型別資料太大會報錯struck.errorstruct.error: 'i' format requires -2147483648 <= number <= 2147483647 #這個是範圍

pack方法圖解:

4、unpack():

注意,unpack返回的是tuple !!
import struct

num = 12235667
num_str = struct.pack('i',num) print(num_str) #b'\x0c\x00\x00\x00' 打包成四個位元組的bytes型別的資料 print(len(num_str)) # 長度為:4 a = struct.unpack('i',num_str)[0] # (12235667,)解包拿到這個元組 print(a) #(12235667,) #

三、兩種粘包解決方案

方案1:先告訴客戶端,資料資訊的長度,然後等客戶端確認之後,再發送真實內容
方案2:通過struct模組,將要傳送的真實資料的長度進行打包,打包成4個位元組,
和真實資料一起一次性發送個客戶端.客戶端取出前4個位元組,
通過struct解包獲得後面真實資料的長度,根據這個長度再進行資料的接收

解決方案(一)使用總位元組長度解決:

問題的根源在於,接收端不知道傳送端將要傳送的位元組流的長度,所以解決粘包的方法就是圍繞,
如何讓傳送端在傳送資料前,把自己將要傳送的位元組流總大小讓接收端知曉,
然後接收端發一個確認訊息給傳送端,然後傳送端再發送過來後面的真實內容,
接收端再來一個死迴圈接收完所有資料。

<details>
<summary>服務端程式碼</summary>

import socket
import subprocess
server = socket.socket() ip_port = ('127.0.0.1',8001) server.bind(ip_port) server.listen() conn,addr = server.accept() while 1: from_client_cmd = conn.recv(1024).decode('utf-8') sub_obj = subprocess.Popen( # 具體解釋參照subprocess模組 from_client_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) #subprocess物件.read 得到的結果是bytes型別的 cmd_res = sub_obj.stdout.read() data_len = len(cmd_res) # 總資料長度 data_len_str = str(data_len) # 將資料int型別轉換成str,str才可以編譯成bytes型別 print('結果長度>>>',data_len) conn.send(data_len_str.encode('utf-8')) client_stutas = conn.recv(1024).decode('utf-8') if client_stutas == 'ok': conn.send(cmd_res) else: print('客戶端長度資訊沒有收到')

</details>

<details>
<summary>客戶端程式碼</summary>

import json
import socket

client = socket.socket() ip_port = ('127.0.0.1',8001) client.connect(ip_port) while 1: client_cmd = input('請輸入系統指令>>>') client.send(client_cmd.encode('utf-8')) from_server_datalen = client.recv(1024).decode('utf-8') # 接收服務端傳送的總資料長度的資訊 client.send(b'ok') # 確認收到總資料長度了,用狀態碼告訴服務端可以發資料過來了 from_server_result = client.recv(int(from_server_datalen)) # 接收資料 print(from_server_result.decode('gbk')) 

</details>

解決方案(二)使用struck解決:

通過struck模組將需要傳送的內容的長度進行打包,打包成一個4位元組長度的資料傳送到對端,
對端只要取出前4個位元組,然後對這四個位元組的資料進行解包,
拿到你要傳送的內容的長度,然後通過這個長度來繼續接收我們實際要傳送的內容。
不是很好理解是吧?哈哈,沒關係,看下面的解釋~~

為什麼要說一下這個模組呢,因為解決方案(一)裡面你發現,我每次要先發送一個我的內容的長度,
需要接收端接收,並切需要接收端返回一個確認訊息,我傳送端才能發後面真實的內容,
這樣是為了保證資料可靠性,也就是接收雙方能順利溝通,但是多了一次傳送接收的過程,
為了減少這個過程,我們就要使struck來發送你需要傳送的資料的長度,來解決上面我們所說的通過傳送內容長度來解決粘包的問題。

<details>
<summary>服務端程式碼</summary>

import socket
import subprocess
import struct
server = socket.socket() ip_port = ('127.0.0.1',8001) data_full_len = 0 #統計傳送資料的長度 server.bind(ip_port) server.listen() conn,addr = server.accept() while 1: from_client_cmd = conn.recv(1024).decode('utf-8') sub_obj = subprocess.Popen( from_client_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) #subprocess物件.read 得到的結果是bytes型別的 cmd_res = sub_obj.stdout.read() data_len = len(cmd_res) #總資料長度 data_len_str = str(data_len) print('結果長度>>>',data_len) #將真實資料長度打包成4個位元組的資料 struct_data_len = struct.pack('i',data_len) conn.send(struct_data_len + cmd_res)

</details>

<details>
<summary>客戶端程式碼</summary>

import json
import socket
import struct
client = socket.socket() ip_port = ('127.0.0.1',8001) client.connect(ip_port) all_recv_len = 0 all_data_byte = b'' while 1: client_cmd = input('請輸入系統指令>>>') client.send(client_cmd.encode('utf-8')) #先接收4個位元組,這4個位元組是真實資料長度加工成的 recv_data_len = client.recv(4) #將4個位元組長度的資料,解包成後面真實資料的長度 real_data_len = struct.unpack('i',recv_data_len)[0] print(real_data_len) server_result = client.recv(real_data_len) print(server_result.decode('gbk'))

</details>

四、驗證客戶端的連結合法性

首先,我們來探討一下,什麼叫驗證合法性, 
舉個例子:
有一天,我開了一個socket服務端,只想讓咱們這個班的同學使用,
但是有一天,隔壁班的同學過來問了一下我開的這個服務端的ip和埠,然後他是不是就可以去連線我了啊,
那怎麼辦,我是不是不想讓他連線我啊,我需要驗證一下你的身份,這就是驗證連線的合法性,

再舉個例子:
就像我們上面說的你的windows系統是不是連線微軟的時間伺服器來獲取時間的啊,
你的mac能到人家微軟去獲取時間嗎,你願意,人家微軟還不願意呢,對吧,
那這時候,你每次連線我來獲取時間的時候,我是不是就要驗證你的身份啊,
也就是你要帶著你的系統資訊,我要判斷你是不是我微軟的windows,對吧,
如果是mac,我是不是不讓你連啊,這就是連接合法性。如果驗證你的連線是合法的,
那麼如果我還要對你的身份進行驗證的需求,也就是要驗證使用者名稱和密碼,
那麼我們還需要進行身份認證。連線認證>>身份認證>>ok你可以玩了。

1、在分散式系統中實現一個簡單的客戶端連結認證功能,又不像SSL那麼複雜,

那麼利用hmac+加鹽的方式來實現,直接看程式碼!(SSL,我們都)

<details>
<summary>合法性連結服務端程式碼</summary>

from socket import *
import hmac,os secret_key=b'Jedan has a big key!' def conn_auth(conn): ''' 認證客戶端連結 :param conn: :return: ''' print('開始驗證新連結的合法性') msg=os.urandom(32)#生成一個32位元組的隨機字串 conn.sendall(msg) h=hmac.new(secret_key,msg) digest=h.digest() respone=conn.recv(len(digest)) return hmac.compare_digest(respone,digest) def data_handler(conn,bufsize=1024): if not conn_auth(conn): print('該連結不合法,關閉') conn.close() return print('連結合法,開始通訊') while True: data=conn.recv(bufsize) if not data:break conn.sendall(data.upper()) def server_handler(ip_port,bufsize,backlog=5): ''' 只處理連結 :param ip_port: :return: ''' tcp_socket_server=socket(AF_INET,SOCK_STREAM) tcp_socket_server.bind(ip_port) tcp_socket_server.listen(backlog) while True: conn,addr=tcp_socket_server.accept() print('新連線[%s:%s]' %(addr[0],addr[1])) data_handler(conn,bufsize) if __name__ == '__main__': ip_port=('127.0.0.1',9999) bufsize=1024 server_handler(ip_port,bufsize)

</details>

<details>
<summary>合法性連結客戶端程式碼</summary>

from socket import *
import hmac,os secret_key=b'Jedan has a big key!' def conn_auth(conn): ''' 驗證客戶端到伺服器的連結 :param conn: :return: ''' msg=conn.recv(32) h=hmac.new(secret_key,msg) digest=h.digest() conn.sendall(digest) def client_handler(ip_port,bufsize=1024): tcp_socket_client=socket(AF_INET,SOCK_STREAM) tcp_socket_client.connect(ip_port) conn_auth(tcp_socket_client) while True: data=input('>>: ').strip() if not data:continue if data == 'quit':break tcp_socket_client.sendall(data.encode('utf-8')) respone=tcp_socket_client.recv(bufsize) print(respone.decode('utf-8')) tcp_socket_client.close() if __name__ == '__main__': ip_port=('127.0.0.1',9999) bufsize=1024 client_handler(ip_port,bufsize)