Python課程回顧(day27)
socket程式設計
socket介紹
socket其實就是TCP或UPD協議與應用程式之間的一種抽象層,更確切的說它只是一組介面,在設計模式中,socket其實就是一個門面模式,它將一系列基於TCP或UDP的複雜操作隱藏在socket後面,對於使用者來說一組簡單的介面就是全部,讓socket去組織資料以符合指定的協議。所以我們其實不需要去深入理解TCP與UDP協議,socket已經為我們封裝好了一系列的介面,我們只需要根據socket的規定去寫程式,那我們寫出的程式自然就是根據TCP與UDP協議的。
舉一個生活中的例子
假設你要給一個朋友打電話,首先呢你必須得有一個電話吧,其次你還得有電話卡,再然後你要知道你朋友的電話是多少,然後你根據號碼打過去你的朋友還得接聽,然後你們交流過後相互掛掉電話,這樣才算一個完整的通訊。
基於TCP協議的工作流程圖
socket: 你的電話或對方的電話
bind: 插入電話卡
listren: 等待接電話
accept: 接電話的按鈕
connect: 打電話的按鈕
write,read:交流
close: 掛電話
我們先從伺服器端說起,伺服器端先初始化socket,然後與IP埠進行繫結bind,對埠進行監聽,接著呼叫accept阻塞等待客戶端的連結。假設在這時有一個客戶端初始化了一個客戶端的socket,然後進行連結伺服器connect,如果連結成功,那麼伺服器與客戶端的連結就算建立完成,服務端接收並處理由客戶端發來的一系列請求,並將結果返回給客戶端,客戶端讀取相應的資料並關閉連結,然後互動結束。
基於TCP協議的基本通訊
服務端程式碼:
# 可以直接匯入socket, 也可以匯入socket的所有功能, 推薦使用第二種, 節省程式碼 from socket import * # 建立服務端物件 server = socket() # 給服務端繫結固定的IP和我們寫的應用程式的埠 # 測試專用的本機IP預設為127.0.0.1, 該IP地址指向的就是本機IP # 以後寫程式時要繫結具體的IP地址 server.bind(('127.0.0.1', 8888)) # 監聽發起的連結請求, 設定同一時間的最大請求數量 # 注意:是最大請求數量,不是最大連結數量 server.listen(5) # 接收由客戶端返回的資訊, 會以元組形式返回兩個值 # 第一個是客戶端傳送的訊息或指令 # 第二個是客戶端的地址 conn, client_address = server.accept() # 設定接收的最大訊息數量 # 注:收發訊息均是以位元組型別進行收發 res = conn.recv(1024) # 解碼並列印訊息 print('來自客戶端的訊息:', res.decode('utf-8')) # 將處理結果傳送到客戶端(返回元資料的大寫) conn.send(res.upper()) # 斷開連結 conn.close() # 關閉伺服器 server.close()
客戶端程式碼: from socket import * # 建立客戶端物件 client = socket() # 客戶端要建立連結的伺服器IP地址與埠 client.connect(('127.0.0.1', 8888)) # 客戶端傳送資料 client.send(b'hello') # 接收服務端發來的處理結果 res = client.recv(1024) # 解碼列印 print('來自服務端的訊息:', res.decode('utf-8')) 輸出結果為:HELLO
我們要知道,我們在使用應用程式與服務端互動的時候並不可能只產生一次通訊就結束,例如我們的QQ微信聊天,所以單純的一次通訊其實並不能解決客戶端的問題,我們不能在客戶端傳送一次資料之後就關閉連結,正確的做法是要等待客戶端處理完問題之後由客戶端主動斷開連結,在這個過程中我們是要與客戶端進行不斷的互動的。
加上通訊迴圈
服務端程式碼: from socket import * server = socket() server.bind(('127.0.0.1', 8888)) server.listen(5) conn, client_address = server.accept() # 服務端迴圈接收由客戶端傳送的資料並處理後傳送 while True: res = conn.recv(1024) print('來自客戶端的訊息:', res.decode('utf-8')) conn.send(res.upper()) conn.close() server.close()
客戶端程式碼: from socket import * client = socket() client.connect(('127.0.0.1', 8888)) # 客戶端迴圈傳送資料 while True: msg = input('>>:').strip() client.send(msg.encode('utf-8')) res = client.recv(1024) print('來自服務端的訊息:', res.decode('utf-8'))
基於上面的程式碼,我們基本實現了與使用者不斷的互動並一直處理使用者發來的資料與請求,但問題是,我們的客戶端並不是只有一個,那麼面對成千上萬個的客戶端上面的程式碼也行不通,因為我們在與一個客戶端進行互動之後就徹底斷開連結並關閉了伺服器,很顯然我們的伺服器是不可以關閉的,所以我們在與第一個客戶端斷開連結之後也要與下一個客戶端進行連結。
加上鍊接迴圈
服務端程式碼: from socket import * server = socket() server.bind(('127.0.0.1', 8888)) server.listen(5) # 迴圈接收客戶端發來的連結請求 while True: conn, client_address = server.accept() # 服務端迴圈接收由客戶端傳送的資料並處理後傳送 while True: # 我們不能確定客戶端是怎樣斷開連結的 # 而客戶端又一定會在事情做完之後斷開連結 # 所以我們要捕獲相應的異常 try: res = conn.recv(1024) # 若客戶端傳送空資料則判斷是否接收到客戶端的資料 if not res: conn.close() break print('來自客戶端的訊息:', res.decode('utf-8')) conn.send(res.upper()) except ConnectionResetError: print('客戶端異常關閉') break # 會在客戶端異常關閉後回收與客戶端的連結 conn.close() server.close()
from socket import * client = socket() client.connect(('127.0.0.1', 8888)) # 客戶端迴圈傳送資料 while True: msg = input('>>:').strip() if not msg: print('不能傳送空字元') continue if msg == 'q': break client.send(msg.encode('utf-8')) res = client.recv(1024) print('來自服務端的訊息:', res.decode('utf-8'))
當然,我們的伺服器不可能是都放在我們身邊,假設我們的程式較大時就必須要有一個專門的伺服器來帶動我們的整個程式,而伺服器對硬體的要求無疑的很高的,通常情況下伺服器大都是放在機房內,而機房通常又設立在距離公司較遠的地方來有人專門看管,那麼我們又如何檢視或檢查我們的伺服器呢?硬體是有人專門維護的,而面對伺服器與客戶端互動的一些突發情況我們又該怎麼處理呢?很顯然我們不可能每次都跑去機房處理,那麼這時候就需要再編寫一個服務端軟體來遠端的去控制我們的伺服器。
TCP的遠端執行命令
比如我們想檢視一下伺服器的相關資訊:
伺服器程式碼: from socket import * import subprocess server = socket() server.bind(('127.0.0.1', 8888)) server.listen(5) while True: conn, client_address = server.accept() while True: try: res = conn.recv(1024) if not res: conn.close() break # 通過subprocess模組來遠端執行cmd的命令 obj = subprocess.Popen(res.decode('utf-8'), shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # 獲取執行正確的資訊 stdout = obj.stdout.read() # 獲取執行錯誤的資訊 stderr = obj.stderr.read() # 由於subprocess返回的本身就是位元組型別所以不用encode conn.send(stdout)
conn.send(atderr) except ConnectionResetError: print('客戶端異常關閉') break conn.close()
客戶端程式碼: from socket import * client = socket() client.connect(('127.0.0.1', 8888)) while True: cmd = input('>>:').strip() if not cmd: print('不能傳送空字元') continue if cmd == 'q': break client.send(cmd.encode('utf-8')) res = client.recv(1024) # subprocess返回的是Unicode編碼, 解碼時要使用gbk print('來自服務端的訊息:', res.decode('gbk'))
從上圖我們可以看出,我們所輸出的命令確實得到了反饋的資訊,比如dir等等,但其實我們使用recv接收的最大位元組數就只有1024個位元組,而dir命令的結果大概也就300多個位元組,若我們需要檢視的資料量較大時就會出現一種叫做粘包的問題,粘包問題則是由於要傳送的資料遠大於recv的接收量導致的,它會先發送我們所設定的recv所能接收的最大資料量的資料,然後將剩餘的資料殘留在傳輸管道中,直到下次執行send時再將剩餘的資料量傳送,若資料量更大則會分多次傳送,這就直接導致了我們可能不知道它是否是一份完整的資料,若我們再執行第二次命令時也不知道資料是否是正確資料,有人會說,調大recv的接收量不就可以了。問題是我們是不可能知道資料的總量為多少,而設定recv的接收量也是有限制的,這是第一點。
第二點,很明顯我們在傳送資料時是分兩次進行傳送的,一份是執行的正確資料,一份是執行錯誤資訊,而我們在接收時接收到的也是黏在一起的資料,而這個問題則是TCP協議的流式協議所導致的,它會將資料量較小且間隔時間較短的多次資料合併到一起傳送,以便減少IO次數,但對於我們則是在很大程度上限制了我們。
那麼我們要怎麼解決粘包問題呢?
解決TCP的粘包問題:
服務端程式碼: from socket import * import subprocess import struct server = socket() server.bind(('127.0.0.1', 8888)) server.listen(5) while True: conn, client_address = server.accept() while True: try: res = conn.recv(1024) if not res: conn.close() break obj = subprocess.Popen(res.decode('utf-8'), shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout = obj.stdout.read() stderr = obj.stderr.read() # 先計算資料的總長度, 若想分開發送就單獨計算正確或錯誤資料的總長度 # 此處合併一起發 total_size = len(stdout) + len(stderr) # 將資料的總長度使用struct的i格式打包成固定的4個位元組的報頭 # 接收端在接收到這四個位元組之後會將報頭解包得到資料的總長度 # 然後接收端會按照資料的總長度迴圈接收資料,直到接收完為止 header = struct.pack('i', total_size) # 要優先發送報頭 conn.send(header) # 此處發一次或兩次TCP協議都會合併成一次傳送 conn.send(stdout) conn.send(stderr) except ConnectionResetError: print('客戶端異常關閉') break conn.close()
客戶端程式碼: from socket import * import struct client = socket() client.connect(('127.0.0.1', 8888)) while True: cmd = input('>>:').strip() if not cmd: print('不能傳送空字元') continue if cmd == 'q': break client.send(cmd.encode('utf-8')) # 先接收固定長度的報頭 header = client.recv(4) # 使用struct將報頭解包得到具體的資料長度(struct會將整形打包成固定的bytes,i模式打包成4個bytes,q模式打包成8個bytes) # 反解會得到一個元組型別, 第一個就是報頭的總長度 total_size = struct.unpack('i', header)[0] # 得到報頭的總長度之後要迴圈接收, 因為我們不知道資料的總長度 # 定義一個變數來記錄已經接收的位元組數量 receive_size = 0 # 定義一個變數來記錄接收的結果, 每迴圈一次則加一次結果 res = b'' # 使用while迴圈接收, 條件為 已接收的位元組數量小於總位元組數量 while receive_size < total_size: # 接收資料, 並設定單次接收的最大位元組數量 receive_data = client.recv(1024) # 每接收一次都將結果加到上面定義的變數後面 res += receive_data # 已接收數量要加上本次接收的位元組數量 receive_size += len(receive_data) # 列印總結果 print('來自服務端的訊息:', res.decode('gbk'))
TCP的自定義報頭
基於TCP流式協議的特性,以後我們在收發資料時已經可以解決粘包問題了,但其實我們一開始所傳送的報頭只是單純的去描述一個數據的總長度的,而通常情況下發送的報頭都是有對資料的整體描述,比如檔案的名稱,檔案的大小,或已經加密好的MD5值。而對資料的描述我們就可以使用字典來進行存放,接收端再接收時也會得到一個字典,再根據字典內key取得相應的值,比如校驗我們的MD5值等等。另外就是struct打包成固定的4個位元組或8個位元組其實是有上限的,若資料長度真的比較大,比如99999999999999999999999999999999999999,struct就不能再進行打包了,但若將這個數字存到字典中,再將字典進行編碼並計算得到的bytes長度則就不會很大,通常也就是幾百個bytes,然後計算bytes長度得到一個整形並使用struct將這個整形打包豈不是戳戳有餘呢?
總的流程大概就是
1.定義報頭(字典格式)
2.將字典變為字串並編碼得到bytes
3.使用struct將bytes的長度打包成簡單的幾個位元組併發送
4.接收端接收到固定的位元組
5.使用struct反解包得到第二步的內容
6.獲取定義時的字典得到具體內容
7.根據字典內的key得到資料的總長度(也可以根據key獲取其他,比如檔名或MD5值)
8.迴圈接收資料的總長度
自定義報頭示例:
伺服器端: import socket import subprocess import json import struct server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind(('127.0.0.1', 3344)) server.listen(5) while True: conn, client = server.accept() while True: try: cmd = conn.recv(1024) obj = subprocess.Popen(cmd.decode('utf-8'), shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout = obj.stdout.read() stderr = obj.stderr.read() total_size = len(stdout) + len(stderr) # 1.自定義報頭 head_dic = { 'MD5': '2e5d8aa3dfa8ef34ca5131d20f9dad51', 'filename': 'a.txt', 'total_size': total_size } # 2.首先考慮若跨平臺要使用通用的資料格式 # 字典不能直接轉換bytes所以要先將字典轉為json格式的字串 head_json = json.dumps(head_dic) # 3.再通過json格式的字串encode成bytes head_bytes = head_json.encode('utf-8') # 4.根據解碼出的bytes得到報頭的長度 # 使用struct中的i格式進行打包得到4個bytes的首先發送 head_size = len(head_bytes) conn.send(struct.pack('i', head_size)) # 此時已經有了固定的報頭長度且客戶端也只會根據報頭的長度進行收取並解析報頭的內容 # 此時再發送報頭的內容客戶端則會根據報頭的內容順利接收 conn.send(head_bytes) # 傳送真實資料 conn.send(stdout) conn.send(stderr) except ConnectionResetError: break conn.close()
接收端: import socket import struct import json client = socket.socket() client.connect(('127.0.0.1', 3344)) while True: cmd = input('>>:') if not cmd: continue client.send(cmd.encode('utf-8')) # 首先接收報頭的長度,並根據服務端的打包格式進行解包得到4個bytes # 拿到元組型別的第一個值就是報頭的長度 head_size = struct.unpack('i', client.recv(4))[0] # 再根據報頭的長度接收得到報頭的具體內容(bytes型別) head_bytes = client.recv(head_size) # 由於服務端傳送時使用的是json格式序列化的 # 此時進行解碼會得到一個json格式的字串 head_json = head_bytes.decode('utf-8') # 將json格式的字串反序列化得到初始化報頭的字典 head_dic = json.loads(head_json) print(head_dic) # 根據報頭內的格式拿到具體的總資料長度 total_size = head_dic['total_size'] # 開始迴圈接收 receive_size = 0 res = b'' while receive_size < total_size: receive_data = client.recv(1024) res += receive_data receive_size += len(receive_data) print(res.decode('gbk'))