python入門系列:Python socket編程

引言

sockets的歷史悠久,它們最早在 1971 年的 APPANET 中使用,後來成爲1983年發佈的Berkeley Software Distribution(BSD)操作系統中的API,稱爲Berkeley sockets。

Web服務器和瀏覽器並不是使用sockets的唯一程序,各種規模和類型的客戶端 - 服務器(client - server)應用程序也得到了廣泛使用。

今天,儘管socket API使用的底層協議已經發展多年,而且已經有新協議出現,但是底層 API 仍然保持不變。

最常見的套接字應用程序類型是客戶端 - 服務器(client - server)應用程序,其中一方充當服務器並等待來自客戶端的連接。

Socket API介紹

Python中的socket模塊提供了一個到Berkeley sockets API的接口,其中的主要接口函數如下:

socket()
bind()
listen()
accept()
connect()
connect_ex()
send()
recv()
close()
這些方便使用的接口函數和系統底層的功能調用相一致。

TCP Sockets

我們準備構建一個基於 TCP 協議的socket對象,爲什麼使用 TCP 呢,因爲:

可靠性:如果在傳輸過程中因爲網絡原因導致數據包丟失,會有相關機制檢測到並且進行重新傳輸
按序到達:一方發送到另一方的數據包是按發送順序被接收的。
對比之下,UDP 協議是不提供這些保證的,但是它的響應效率更高,資源消耗更少。

TCP 協議並不需要我們自己去實現,在底層都已經實現好了,我們只需要使用Python的socket模塊,進行協議指定就可以了。socket.SOCK_STREAM表示使用 TCP 協議,socket.SOCK_DGRAM表示使用 UDP 協議

我們來看看基於 TCP 協議socket的 API 調用和數據傳送流程圖,右邊的一列是服務器端(server),左邊的一列是客戶端(client)。
python入門系列:Python socket編程
要實現左邊的處於監聽狀態的server,我們需要按照順序調用這樣幾個函數:

socket(): 創建一個socket對象
bind(): 關聯對應 ip 地址和端口號
listen(): 允許對象接收其他socket的連接
accept(): 接收其他socket的連接,返回一個元組(conn, addr),conn 是一個新的socket對象,代表這個連接,addr 是連接端的地址信息。
client調用connect()時,會通過 TCP 的三次握手,建立連接。當client連接到server時,server會調用accept()完成這次連接。

雙方通過send()和recv()來接收和發送數據,最後通過close()來關閉這次連接,釋放資源。一般server端是不關閉的,會繼續等待其他的連接。

Echo Client and Server

剛纔我們弄清楚了server和client使用socket進行通信的過程,我們現在要自己進行一個簡單的也是經典的實現:server複述從client接收的信息。

Echo Server

import socket
HOST = '127.0.0.1' # Standard loopback interface address (localhost)
PORT = 65431 # Port to listen on (non-privileged ports are > 1023)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen()
conn, addr = s.accept()
with conn:
print('Connected by', addr)
while True:
data = conn.recv(1024)
if not data:
break
conn.sendall(data)
socket.socket()創建了一個socket對象,它實現了上下文管理器協議,我們直接用 with 語句進行創建即可,而且最後不需要調用close()函數。

socket()中的兩個參數指明瞭連接需要的 ip地址類型和傳輸協議類型,socket.AF_INET 表示使用 IPv4的地址進行連接,socket.SOCK_STREAM 表示使用 TCP 協議進行數據的傳輸。

bind()用來將socket對象和特定的網絡對象和端口號進行關聯,函數中的兩個參數是由創建socket對象時指定的 ip地址類型 決定的,這裏使用的是socket.AF_INET(IPv4),因此,bind()函數接收一個元組對象作爲參數(HOST, PORT)

host可以是一個主機名,IP地址,或者空字符串。如果使用的是 IP地址,host必須是 IPv4格式的地址字符串。127.0.0.1是本地環路的標準寫法,因此只有在主機上的進程才能夠連接到server,如果設置爲空字符串,它可以接受所有合法 IPv4地址的連接。
port應該是從1 - 65535的一個整數(0被保留了),它相當於是一個窗口和其他的客戶端建立連接,如果想使用1 - 1024的端口,一些系統可能會要求要有管理員權限。
listen()使得server可以接受連接,它可以接受一個參數:backlog,用來指明系統可以接受的連接數量,雖然同一時刻只能與一端建立連接,但是其他的連接請求可以被放入等待隊列中,當前面的連接斷開,後面的請求會依次被處理,超過這個數量的連接請求再次發起後,會被server直接拒絕。

從Python 3.5開始,這個參數是可選的,如果我們不明確指明,它就採用系統默認值。如果server端在同一時刻會收到大量的連接請求,通常要把這個值調大一些,在Linux中,可以在/proc/sys/net/core/somaxconn看到值的情況,詳細請參閱:

Will increasing net.core.somaxconn make a difference?
How TCP backlog works in Linux
accept()監聽連接的建立,是一個阻塞式調用,當有client連接之後,它會返回一個代表這個連接的新的socket對象和代表client地址信息的元組。對於 IPv4 的地址連接,地址信息是 (host, port),對於 IPv6 ,(host, port, flowinfo, scopeid)

有一件事情需要特別注意,accept()之後,我們獲得了一個新的socket對象,它和server以及client都不同,我們用它來進行和client的通信。

conn, addr = s.accept()
with conn:
print('Connected by', addr)
while True:
data = conn.recv(1024)
if not data:
break
conn.sendall(data)
conn是我們新獲得的socket對象,conn.recv()也是一個阻塞式調用,它會等待底層的 I/O 響應,直到獲得數據才繼續向下執行。外面的while循環保證server端一直監聽,通過conn.sendall將數據再發送回去。

Echo Client

import socket
HOST = '127.0.0.1' # The server's hostname or IP address
PORT = 65431 # The port used by the server
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, PORT))
s.sendall("Hello, world".encode("utf8"))
data = s.recv(1024)

print('Received', data.decode("utf8"))
和server相比,client更加簡單,先是創建了一個socket對象,然後將它和server連接,通過s.sendall()將信息發送給server,通過s.recv()獲得來自server的數據,然後將其打印輸出。

在發送數據時,只支持發送字節型數據,所以我們要將需要發送的數據進行編碼,在收到server端的迴應後,將得到的數據進行解碼,就能還原出我們能夠識別的字符串了。

啓動程序

我們要先啓動server端,做好監聽準備,然後再啓動client端,進行連接。

這個信息是在client連接後打印出來的。
python入門系列:Python socket編程
python入門系列:Python socket編程
可以使用netstat這個命令查看socket的狀態,更詳細使用可以查閱幫助文檔。

查看系統中處於監聽狀態的socket,過濾出了使用 TCP協議 和 IPv4 地址的對象:

python入門系列:Python socket編程
如果先啓動了client,會有下面這個經典的錯誤:
python入門系列:Python socket編程
造成的原因可能是端口號寫錯了,或者server根本就沒運行,也可能是在server端存在防火牆阻值了連接建立,下面是一些常見的錯誤異常:

Exceptionerrno ConstantDescriptionBlockingIOErrorEWOULDBLOCKResource temporarily unavailable. For example, in non-blocking mode, when calling send() and the peer is busy and not reading, the send queue (network buffer) is full. Or there are issues with the network. Hopefully this is a temporary condition.OSErrorADDRINUSEAddress already in use. Make sure there’s not another process running that’s using the same port number and your server is setting the socket option SO_REUSEADDR: socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1).ConnectionResetErrorECONNRESETConnection reset by peer. The remote process crashed or did not close its socket properly (unclean shutdown). Or there’s a firewall or other device in the network path that’s missing rules or misbehaving.TimeoutErrorETIMEDOUTOperation timed out. No response from peer.ConnectionRefusedErrorECONNREFUSEDConnection refused. No application listening on specified port.

連接的建立

現在我們仔細看一下server和client是怎樣建通信的:
python入門系列:Python socket編程
當使用環路網絡((IPv4 address 127.0.0.1 or IPv6 address ::1))的時候,數據沒有離開過主機跑到外部的網絡。如圖所示,環路網絡是在主機內部建立的,數據就經過它來發送,從主機上運行的一個程序發送到另一個程序,從主機發到主機。這就是爲什麼我們喜歡說環路網絡和 IP地址 127.0.0.1(IPv4) 或 ::1(IPv6) 都表示主機

如果server使用的時其他的合法IP地址,它就會通過以太網接口與外部網絡建立聯繫:
python入門系列:Python socket編程
如何處理多端連接

echo server最大的缺點就是它同一時間只能服務一個client,直到連接的斷開,echo client同樣也有不足,當client進行如下操作時,有可能s.recv()只返回了一個字節的數據,數據並不完整。

data = s.recv(1024)
這裏所設定的參數 1024 表示單次接收的最大數據量,並不是說會返回 1024 字節的數據。在server中使用的send()與之類似,調用後它有一個返回值,標示已經發送出去的數據量,可能是小於我們實際要發送的數據量,比如說有 6666 字節的數據要發送,用上面的發送方式要發送很多此才行,也就是說一次調用send()數據並沒有被完整發送,我們需要自己做這個檢查來確保數據完整發送了。

因此,這裏使用了sendall(),它會不斷地幫我們發送數據直到數據全部發送或者出現錯誤。

所以,目前有兩個問題:

怎樣同時處理多個連接?
怎樣調用send()和recv()直到數據全部發送或接收。
要實現併發,傳統方法是使用多線程,最近比較流行的方法是使用在Python3.4中引入的異步IO模塊asyncio。

這裏準備用更加傳統,但是更容易理解的方式來實現,基於系統底層的一個調用:select(),Python中也提供了

對應的模塊:selectors

select()通過了一種機制,它來監聽操作發生情況,一旦某個操作準備就緒(一般是讀就緒或者是寫就緒),然後將需要進行這些操作的應用程序select出來,進行相應的讀和寫操作。到這裏,你可能會發現這並沒有實現併發,但是它的響應速度非常快,通過異步操作,足夠模擬併發的效果了。

Muti-Connection Client and Server

Multi-Connection Server

import selectors
sel = selectors.DefaultSelector()

...

lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lsock.bind((host, port))
lsock.listen()
print('listening on', (host, port))
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)
和echo server最大的不同就在於,通過lsock.setblocking(False),將這個socket對象設置成了非阻塞形式,與sel.select()一起使用,就可以在一個或多個socket對象上等待事件,然後在數據準備就緒時進行數據的讀寫操作。

sel.register()給server註冊了我們需要的事件,對server來說,我們需要 I/O 可讀,從而進行client發送數據的讀入,因此,通過selector.EVENT_READ來指明。

data用來存儲和socket有關的任何數據,當sel.select()返回結果時,它也被返回,我們用它作爲一個標誌,來追蹤擁有讀入和寫入操作的socket對象。

接下來是事件循環:

import selectors
sel = selectors.DefaultSelector()

...

while True:
events = sel.select(timeout=None)
for key, mask in events:
if key.data is None:
accept_wrapper(key.fileobj)
else:
service_connection(key, mask)
sel.select(timeout=None)是一個阻塞式調用,直到有socket對象準備好了 I/O 操作,或者等待時間超過設定的timeout。它將返回(key, events)這類元組構成的一個列表,每一個對應一個就緒的socket對象。

key是一個SeletorKey類型的實例,它有一個fileobj的屬性,這個屬性就是sokect對象。

mask是就緒操作的狀態掩碼。

如果key.data is None,我們就知道,這是一個server對象,於是要調用accept()方法,用來等待client的連接。不過我們要調用我們自己的accept_wrapper()函數,裏面還會包含其他的邏輯。

如果key.data is not None,我們就知道,這是一個client對象,它帶着數據來建立連接啦!然後我們要爲它提供服務,於是就調用service_connection(key, mask),完成所有的服務邏輯。

def accept_wrapper(sock):
conn, addr = sock.accept() # Should be ready to read
print('accepted connection from', addr)
conn.setblocking(False)
data = types.SimpleNamespace(addr=addr, inb=b'', outb=b'')
events = selectors.EVENT_READ | selectors.EVENT_WRITE
sel.register(conn, events, data=data)
這個函數用來處理與client的連接,使用conn.setblocking(False)將該對象設置爲非阻塞狀態,這正是我們在這個版本的程序中所需要的,否則,整個server會停止,直到它返回,這意味着其他socket對象進入等待狀態。

然後,使用types.SimplleNamespace()構建了一個data對象,存儲我們想保存的數據和socket對象。

因爲數據的讀寫都是通過conn,所以使用selectors.EVENT_READ | selectors.EVENT_WRITE,然後用sel.register(conn, events, data=data)進行註冊。

def service_connection(key, mask):
sock = key.fileobj
data = key.data
if mask & selectors.EVENT_READ:
recv_data = sock.recv(1024) # Should be ready to read
if recv_data:
data.outb += recv_data
else:
print('closing connection to', data.addr)
sel.unregister(sock)
sock.close()
if mask & selectors.EVENT_WRITE:
if data.outb:
print('echoing', repr(data.outb), 'to', data.addr)
sent = sock.send(data.outb) # Should be ready to write
data.outb = data.outb[sent:]
這就時服務邏輯的核心,key中包含了socket對象和data對象,mask是已經就緒操作的掩碼。根據sock可以讀,將數據保存在data.outb中,這也將成爲寫出的數據。

if recv_data:
data.outb += recv_data
else:
print('closing connection to', data.addr)
sel.unregister(sock)
sock.close()
如果沒有接收到數據,說明client數據發完了,sock的狀態不再被追蹤,然後關閉這次連接。

Multi-Connection Client

messages = [b'Message 1 from client.', b'Message 2 from client.']
def start_connections(host, port, num_conns):
server_addr = (host, port)
for i in range(0, num_conns):
connid = i + 1
print('starting connection', connid, 'to', server_addr)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False)
sock.connect_ex(server_addr)
events = selectors.EVENT_READ | selectors.EVENT_WRITE
data = types.SimpleNamespace(connid=connid,
msg_total=sum(len(m) for m in messages),
recv_total=0,
messages=list(messages),
outb=b'')
sel.register(sock, events, data=data)
使用connect_ex()而不是connect(),因爲connect()會立即引發BlockingIOError異常。connect_ex()只返回錯誤碼 errno.EINPROGRESS,而不是在連接正在進行時引發異常。連接完成後,socket對象就可以進行讀寫,並通過select()返回。

連接建立完成後,我們使用了types.SimpleNamespace構建出和socket對象一同保存的數據,裏面的messages對我們要發送的數據做了一個拷貝,因爲在後續的發送過程中,它會被修改。client需要發送什麼,已經發送了什麼以及已經接收了什麼都要進行追蹤,總共要發送的數據字節數也保存在了data對象中。

def service_connection(key, mask):
sock = key.fileobj
data = key.data
if mask & selectors.EVENT_READ:
recv_data = sock.recv(1024) # Should be ready to read
if recv_data:
print('received', repr(recv_data), 'from connection', data.connid)
data.recv_total += len(recv_data)
if not recv_data or data.recv_total == data.msg_total:
print('closing connection', data.connid)
sel.unregister(sock)
sock.close()
if mask & selectors.EVENT_WRITE:
if not data.outb and data.messages:
data.outb = data.messages.pop(0)
if data.outb:
print('sending', repr(data.outb), 'to connection', data.connid)
sent = sock.send(data.outb) # Should be ready to write
data.outb = data.outb[sent:]
client要追蹤來自server的數據字節數,如果收到的數據字節數和發送的相等,或者有一次沒有收到數據,說明數據接收完成,本次服務目的已經達成,就可以關閉這次連接了。

data.outb用來維護髮送的數據,前面提到過,一次發送不一定能將數據全部送出,使用data.outb = data.outb[sent:]來更新數據的發送。發送完畢後,再messages中取出數據準備再次發送。

可以在這裏看到最後的完整代碼:

server.py
client.py
最後的運行效果如下:
python入門系列:Python socket編程
python入門系列:Python socket編程
還是要先啓動server,進入監聽狀態,然後client啓動,與server建立兩條連接,要發送的信息有兩條,這裏分開發送,先將fist message分別發送到server,然後再發送second message。server端收到信息後進行暫時保存,當兩條信息都收到了纔開始進行echo,client端收到完整信息後表示服務結束,斷開連接。
注:喜歡python + qun:839383765 可以獲取Python各類免費最新入門學習資料!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章