Socket編程
前言
在網絡編程裏總會涉及到socket編程,或者說,網絡編程是基於socket之上的。通過socket,我們可以建立tcp連接,或是udp通訊方式。虧得Python的完美封裝,Socket編程變得容易上手。
接下來會寫一個基於tcp方式的簡易終端聊天系統。
實例一個socket
在Python中創建一個socket對象需要導入socket包,還需要指定協議。
import socket
socketObj = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- AF_INET 表示使用ipv4協議;
- SOCK_STREAM 表示連接基於tcp,如果想用udp,則爲SOCK_DGRAM。
此時socketObj是程序中的關鍵。關於它的常用方法有以下:
- bind() 綁定地址(ip和端口),接收參數爲元組類型。如:
socketObj.bind(("0.0.0.0", 8080))
; - listen() 設置監聽,只有服務器端需要設置。接收參數爲int類型。
socketObj.listen(10)
表示最多接收10個客戶端的連接; - accept() 等待連接,只有服務器端需要設置。返回一個元組,包含了與客戶端連接的socket和客戶端的地址信息(這個地址信息也是個元組,與bind()函數接收的參數格式一樣);
- connect() 用於客戶端連接服務器,參數爲服務器的地址信息。如:
client.connect(("0.0.0.0", 8080))
; - recv() 接收數據。接收參數爲int型,表示一次接收數據的字節長度;返回接收到的數據,此時data爲byte型。存在**recvfrom()**方法,不但返回接收到的數據信息,還會返回發送端的地址信息;
- send() 發送數據。接收參數爲byte型,返回發送出去的字節長度。這個方法不需要指定接收端的地址信息,但存在**sendto()**方法,需要指定接收端的地址信息:
client.sendto(data, address)
; - close() 關閉套接字。
說明:accept()和recv()都會阻塞程序,通過socket.setblocking(False)
可以設置爲非阻塞,但程序會拋BlockingIOError異常,可以用try-except忽略掉。
服務器與客戶端
作爲服務器所需步驟:
第一步綁定地址(bind),第二步設置監聽(listen),第三步等待連接(accept),第四步循環接收數據與發送數據操作(send、recv)。
作爲客戶端所需步驟:
第一步連接服務器(connect),第二步循環接收數據與發送數據操作( send、recv)。
這裏在網上找了一張流程圖方便理解,侵刪。
簡易聊天系統
在這個聊天系統中,有以下幾個要求:
- 服務器只能被一個客戶端連接;
- 服務器與客戶端可以任意時候發送數據和接收數據;
- 客戶端通過輸入q! 或者Q! 命令,實現退出聊天系統的操作;
- 客戶端退出後,服務器進入等待連接狀態,直到下一個客戶端進入連接。
要求1很好實現,只需要listen(1)
即可。
對於要求2,不論是客戶端還是服務器,因爲需要 “任意時候可以發送數據和接收數據”,涉及到的input
和recv
都會阻塞程序,所以需要抽象出兩個方法send_data() 和recv_data() ,由兩個線程執行,防止終端被霸佔。
def send_data(sock):
while True:
words = input(">>")
...
sock.send(words.encode("utf-8"))
...
def recv_data(sock, addr):
while True:
...
data = sock.recv(1024)
...
# 格式化打印接收到的數據
dataUTF8 = data.decode("utf-8")
print("\r【時間:{time}】【來自:{ip}】\n【內容:{content}\n{separate}".format(
time=time.strftime("%Y/%m/%d %H:%M:%S", time.localtime()),
ip="%s:%d" % addr,
content=dataUTF8,
separate="-"*50
))
print(">>", end="")
sys.stdout.flush() # 強刷管道,不然`>>`可能打印不出來
這裏需要注意,多線程時print(">>", end="")
會打印不出來,但print(">>")
這種格式可以正常輸出到屏幕。我知道是因爲內容被緩存到了管道中,導致終端沒有輸出,然而根本原因不明確。不過sys.stdout.flush()
可以刷新管道,數據能被正常打印了。
要求3不難,只需要在send_data()中增加標準輸入的檢查,當輸入的內容是q!或者Q!,退出循環,結束此線程。
# client.py
def send_data(sock):
while True:
words = input(">>")
if words in ("q!", "Q!"):
break
sock.send(words.encode("utf-8"))
執行send_data()的線程結束後,需要程序正常往下執行,所以主線程只等待send_data() ,而不等待執行recv_data() 函數的線程:
# client.py
sendthreading = threading.Thread(target=send_data, args=(client, ))
recvthreading = threading.Thread(target=recv_data, args=(client, ("127.0.0.1", 8080)))
sendthreading.start()
recvthreading.start()
sendthreading.join() # 只等待send_data()的線程結束
client.close()
win與linux的差別:
接下來需要區分win與linux中的區別。當send_data()結束,程序接下來會執行client.close()
,同時還有個子線程負責recv_data(),也就是說這個函數中還使用着套接字client。然而,套接字卻被主線程關閉了——注意這個大前提。現在,在win中,當套接字被關閉,程序客戶端(client.py)會拋異常ConnectionAbortedError,但linux中什麼也不會發生;此時服務器與客戶端之間的連接斷開,win中,服務器端會在recv句中拋異常ConnectionResetError,而在linux中服務器的recv此時將一直接收空數據,導致程序服務器一直打印空數據。爲了兼容兩個系統,在server.py中需要判斷接收到的數據是否爲有效數據:
def recv_data(addr):
global clientsock
while True:
try:
data = clientsock.recv(1024)
# 兼容linux
if not data: # 如果不是有效數據,拋出異常
raise ConnectionResetError
except ConnectionResetError:
...
客戶端裏,當拋出ConnectionAbortedError時,跳出接收線程的循環:
# client.py
def recv_data(sock, addr):
while True:
try:
data = sock.recv(1024)
except ConnectionAbortedError:
break
...
當然還要考慮到服務器異常退出時,此時客戶端——如果在linux系統,會一直接收到空數據,如果在win系統,會引發ConnectionResetError異常,所以繼續完善上面代碼:
# client.py
def recv_data(sock, addr):
while True:
try:
data = sock.recv(1024)
# 兼容linux
if not data:
raise ConnectionResetError("遠程主機強迫關閉了一個現有的連接。")
except ConnectionAbortedError:
break
認爲服務器退出之後,客戶端應該拋錯提示,所以不對ConnectionResetError做異常處理。
又因爲在linux中客戶端套接字被關掉,recv不會報錯,它所在線程(負責recv_data()的線程)不會終止。正常情況下,主線程運行結束後等待子線程結束。但我們希望主線程結束後子線程也會跟着死亡,所以把該線程設置爲守護。表示主線程結束,子線程跟着結束:
recvthreading.setDaemon(True) # 兼容linux
爲滿足要求4,在文件server.py中做以下準備:
# server.py
def recv_data():
global clientsock
while True:
try:
data = clientsock.recv(1024)
# 兼容linux
if not data:
raise ConnectionResetError
except ConnectionResetError:
# 用戶退出後進入等待模式
print("\r【用戶已退出】")
print("【等待用戶連接】")
clientsock, addr = server.accept()
print("【接入用戶】")
print(">>", end="")
continue
...
運行效果
此次完整代碼已經上傳Github,見簡易聊天系統-多線程目錄。