[譯]Python 中的 Socket 編程(指南)

博客原文: https://keelii.com/2018/09/24/socket-programming-in-python/

說明

本書翻譯自 realpython 網站上的文章教程 Socket Programming in Python (Guide),由於原文比較長,所以整理成了 Gitbook 方便閱讀

原作者

Nathan Jennings 是 Real Python 教程團隊的一員,他在很早之前就使用 C 語言開始了自己的編程生涯,但是最終發現了 Python,從 Web 應用和網絡數據收集到網絡安全,他喜歡任何 Pythonic 的東西
—— realpython

譯者注

譯者 是一名前端工程師,平常會寫很多的 JavaScript。但是當我使用 JavaScript 很長一段時間後,會對一些 語言無關 的編程概念感興趣,比如:網絡/socket 編程、異步/併發、線/進程通信等。然而恰好這些內容在 JavasScript 領域很少見

因爲一直從事 Web 開發,所以我認爲理解了網絡通信及其 socket 編程就理解了 Web 開發的某些本質。過程中我發現 Python 社區有很多我喜歡的內容,並且很多都是高質量的公開發布且開源的。

最近我發現了這篇文章,系統地從底層網絡通信講到了應用層協議及其 C/S 架構的應用程序,由淺入深。雖然代碼、API 使用了 Python,但是底層原因都是相通的。非常值得一讀,推薦給大家

另外,由於本人水平所限,翻譯的內容難免出現偏差,如果你在閱讀的過程中發現問題,請毫不憂慮的提醒我或者開新 PR。或者有什麼不理解的地方也可以開 issue 討論

授權

本文(翻譯版)通過了 realpython 官方授權,原文版權歸其所有,任何轉載請聯繫他們

開始

網絡中的 Socket 和 Socket API 是用來跨網絡的消息傳送的,它提供了 進程間通信(IPC) 的一種形式。網絡可以是邏輯的、本地的電腦網絡,或者是可以物理連接到外網的網絡,並且可以連接到其它網絡。英特網就是一個明顯的例子,就是那個你通過 ISP 連接到的網絡

本篇教程有三個不同的迭代階段,來展示如何使用 Python 構建一個 Socket 服務器和客戶端

  1. 我們將以一個簡單的 Socket 服務器和客戶端程序來開始本教程
  2. 當你看完 API 瞭解例子是怎麼運行起來以後,我們將會看到一個具有同時處理多個連接能力的例子的改進版
  3. 最後,我們將會開發出一個更加完善且具有完整的自定義頭信息和內容的 Socket 應用

教程結束後,你將學會如何使用 Python 中的 socket 模塊 來寫一個自己的客戶端/服務器應用。以及向你展示如何在你的應用中使用自定義類在不同的端之間發送消息和數據

所有的例子程序都使用 Python 3.6 編寫,你可以在 Github 上找到 源代碼

網絡和 Socket 是個很大的話題。網上已經有了關於它們的字面解釋,如果你還不是很瞭解 Socket 和網絡。當你你讀到那些解釋的時候會感到不知所措,這是非常正常的。因爲我也是這樣過來的

儘管如此也不要氣餒。 我已經爲你寫了這個教程。 就像學習 Python 一樣,我們可以一次學習一點。用你的瀏覽器保存本頁面到書籤,以便你學習下一部分時能找到

讓我們開始吧!

背景

Socket 有一段很長的歷史,最初是在 1971 年被用於 ARPANET,隨後就成了 1983 年發佈的 Berkeley Software Distribution (BSD) 操作系統的 API,並且被命名爲 Berkeleysocket

當互聯網在 20 世紀 90 年代隨萬維網興起時,網絡編程也火了起來。Web 服務和瀏覽器並不是唯一使用新的連接網絡和 Socket 的應用程序。各種類型不同規模的客戶端/服務器應用都廣泛地使用着它們

時至今日,儘管 Socket API 使用的底層協議已經進化了很多年,也出現了許多新的協議,但是底層的 API 仍然保持不變

Socket 應用最常見的類型就是 客戶端/服務器 應用,服務器用來等待客戶端的鏈接。我們教程中涉及到的就是這類應用。更明確地說,我們將看到用於 InternetSocket 的 Socket API,有時稱爲 Berkeley 或 BSD Socket。當然也有 Unix domain sockets —— 一種用於 同一主機 進程間的通信

Socket API 概覽

Python 的 socket 模塊提供了使用 Berkeley sockets API 的接口。這將會在我們這個教程裏使用和討論到

主要的用到的 Socket API 函數和方法有下面這些:

  • socket()
  • bind()
  • listen()
  • accept()
  • connect()
  • connect_ex()
  • send()
  • recv()
  • close()

Python 提供了和 C 語言一致且方便的 API。我們將在下面一節中用到它們

作爲標準庫的一部分,Python 也有一些類可以讓我們方便的調用這些底層 Socket 函數。儘管這個教程中並沒有涉及這部分內容,你也可以通過socketserver 模塊 中找到文檔。當然還有很多實現了高層網絡協議(比如:HTTP, SMTP)的的模塊,可以在下面的鏈接中查到 Internet Protocols and Support

TCP Sockets

就如你馬上要看到的,我們將使用 socket.socket() 創建一個類型爲 socket.SOCK_STREAM 的 socket 對象,默認將使用 Transmission Control Protocol(TCP) 協議,這基本上就是你想使用的默認值

爲什麼應該使用 TCP 協議?

  • 可靠的:網絡傳輸中丟失的數據包會被檢測到並重新發送
  • 有序傳送:數據按發送者寫入的順序被讀取

相反,使用 socket.SOCK_DGRAM 創建的 用戶數據報協議(UDP) Socket 是 不可靠 的,而且數據的讀取寫發送可以是 無序的

爲什麼這個很重要?網絡總是會盡最大的努力去傳輸完整數據(往往不盡人意)。沒法保證你的數據一定被送到目的地或者一定能接收到別人發送給你的數據

網絡設備(比如:路由器、交換機)都有帶寬限制,或者系統本身的極限。它們也有 CPU、內存、總線和接口包緩衝區,就像我們的客戶端和服務器。TCP 消除了你對於丟包、亂序以及其它網絡通信中通常出現的問題的顧慮

下面的示意圖中,我們將看到 Socket API 的調用順序和 TCP 的數據流:

TCP Socket 流

左邊表示服務器,右邊則是客戶端

左上方開始,注意服務器創建「監聽」Socket 的 API 調用:

  • socket()
  • bind()
  • listen()
  • accept()

「監聽」Socket 做的事情就像它的名字一樣。它會監聽客戶端的連接,當一個客戶端連接進來的時候,服務器將調用 accept() 來「接受」或者「完成」此連接

客戶端調用 connect() 方法來建立與服務器的鏈接,並開始三次握手。握手很重要是因爲它保證了網絡的通信的雙方可以到達,也就是說客戶端可以正常連接到服務器,反之亦然

上圖中間部分往返部分表示客戶端和服務器的數據交換過程,調用了 send()recv()方法

下面部分,客戶端和服務器調用 close() 方法來關閉各自的 socket

打印客戶端和服務端

你現在已經瞭解了基本的 socket API 以及客戶端和服務器是如何通信的,讓我們來創建一個客戶端和服務器。我們將會以一個簡單的實現開始。服務器將打印客戶端發送回來的內容

打印程序服務端

下面就是服務器代碼,echo-server.py

#!/usr/bin/env python3

import socket

HOST = '127.0.0.1'  # 標準的迴環地址 (localhost)
PORT = 65432        # 監聽的端口 (非系統級的端口: 大於 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)
注意:上面的代碼你可能還沒法完全理解,但是不用擔心。這幾行代碼做了很多事情,這
只是一個起點,幫你看見這個簡單的服務器是如何運行的
教程後面有引用部分,裏面有很多額外的引用資源鏈接,這個教程中我將把鏈接放在那兒

讓我們一起來看一下 API 調用以及發生了什麼

socket.socket() 創建了一個 socket 對象,並且支持 context manager type,你可以使用 with 語句,這樣你就不用再手動調用 s.close() 來關閉 socket 了

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    pass  # Use the socket object without calling s.close().

調用 socket() 時傳入的 socket 地址族參數 socket.AF_INET 表示因特網 IPv4 地址族SOCK_STREAM 表示使用 TCP 的 socket 類型,協議將被用來在網絡中傳輸消息

bind() 用來關聯 socket 到指定的網絡接口(IP 地址)和端口號:

HOST = '127.0.0.1'
PORT = 65432

# ...

s.bind((HOST, PORT))

bind() 方法的入參取決於 socket 的地址族,在這個例子中我們使用了 socket.AF_INET (IPv4),它將返回兩個元素的元組:(host, port)

host 可以是主機名稱、IP 地址、空字符串,如果使用 IP 地址,host 就應該是 IPv4 格式的字符串,127.0.0.1 是標準的 IPv4 迴環地址,只有主機上的進程可以連接到服務器,如果你傳了空字符串,服務器將接受本機所有可用的 IPv4 地址

端口號應該是 1-65535 之間的整數(0是保留的),這個整數就是用來接受客戶端鏈接的 TCP 端口號,如果端口號小於 1024,有的操作系統會要求管理員權限

使用 bind() 傳參爲主機名稱的時候需要注意:

如果你在 host 部分 主機名稱 作爲 IPv4/v6 socket 的地址,程序可能會產生非確
定性的行爲,因爲 Python 會使用 DNS 解析後的 第一個 地址,根據 DNS 解析的結
果或者 host 配置 socket 地址將會以不同方式解析爲實際的 IPv4/v6 地址。如果想得
到確定的結果傳入的 host 參數建議使用數字格式的地址 引用

我稍後將在 使用主機名 部分討論這個問題,但是現在也值得一提。目前來說你只需要知道當使用主機名時,你將會因爲 DNS 解析的原因得到不同的結果

可能是任何地址。比如第一次運行程序時是 10.1.2.3,第二次是 192.168.0.1,第三次是 172.16.7.8 等等

繼續看上面的服務器代碼示例,listen() 方法調用使服務器可以接受連接請求,這使它成爲一個「監聽中」的 socket

s.listen()
conn, addr = s.accept()

listen() 方法有一個 backlog 參數。它指定在拒絕新的連接之前系統將允許使用的 未接受的連接 數量。從 Python 3.5 開始,這是可選參數。如果不指定,Python 將取一個默認值

如果你的服務器需要同時接收很多連接請求,增加 backlog 參數的值可以加大等待鏈接請求隊列的長度,最大長度取決於操作系統。比如在 Linux 下,參考 /proc/sys/net/core/somaxconn

accept() 方法阻塞並等待傳入連接。當一個客戶端連接時,它將返回一個新的 socket 對象,對象中有表示當前連接的 conn 和一個由主機、端口號組成的 IPv4/v6 連接的元組,更多關於元組值的內容可以查看 socket 地址族 一節中的詳情

這裏必須要明白我們通過調用 accept() 方法擁有了一個新的 socket 對象。這非常重要,因爲你將用這個 socket 對象和客戶端進行通信。和監聽一個 socket 不同的是後者只用來授受新的連接請求

conn, addr = s.accept()
with conn:
    print('Connected by', addr)
    while True:
        data = conn.recv(1024)
        if not data:
            break
        conn.sendall(data)

accept() 獲取客戶端 socket 連接對象 conn 後,使用一個無限 while 循環來阻塞調用 conn.recv(),無論客戶端傳過來什麼數據都會使用 conn.sendall() 打印出來

如果 conn.recv() 方法返回一個空 byte 對象(b''),然後客戶端關閉連接,循環結束,with 語句和 conn 一起使用時,通信結束的時候會自動關閉 socket 鏈接

打印程序客戶端

現在我們來看下客戶端的程序, echo-client.py

#!/usr/bin/env python3

import socket

HOST = '127.0.0.1'  # 服務器的主機名或者 IP 地址
PORT = 65432        # 服務器使用的端口

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((HOST, PORT))
    s.sendall(b'Hello, world')
    data = s.recv(1024)

print('Received', repr(data))

與服務器程序相比,客戶端程序簡單很多。它創建了一個 socket 對象,連接到服務器並且調用 s.sendall() 方法發送消息,然後再調用 s.recv() 方法讀取服務器返回的內容並打印出來

運行打印程序的客戶端和服務端

讓我們運行打印程序的客戶端和服務端,觀察他們的表現,看看發生了什麼事情

如果你在運行示例代碼時遇到了問題,可以閱讀 如何使用 Python 開發命令行命令,如果
你使用的是 windows 操作系統,請查看 Python Windows FAQ

打開命令行程序,進入你的代碼所在的目錄,運行打印程序的服務端:

$ ./echo-server.py

你的命令行將被掛起,因爲程序有一個阻塞調用

conn, addr = s.accept()

它將等待客戶端的連接,現在再打開一個命令行窗口運行打印程序的客戶端:

$ ./echo-client.py
Received b'Hello, world'

在服務端的窗口你將看見:

$ ./echo-server.py
Connected by ('127.0.0.1', 64623)

上面的輸出中,服務端打印出了 s.accept() 返回的 addr 元組,這就是客戶端的 IP 地址和 TCP 端口號。示例中的端口號是 64623 這很可能是和你機器上運行的結果不同

查看 socket 狀態

想查找你主機上 socket 的當前狀態,可以使用 netstat 命令。這個命令在 macOS, Window, Linux 系統上默認可用

下面這個就是啓動服務後 netstat 命令的輸出結果:

$ netstat -an
Active Internet connections (including servers)
Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4       0      0  127.0.0.1.65432        *.*                    LISTEN

注意本地地址是 127.0.0.1.65432,如果 echo-server.py 文件中 HOST 設置成空字符串 '' 的話,netstat 命令將顯示如下:

$ netstat -an
Active Internet connections (including servers)
Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4       0      0  *.65432                *.*                    LISTEN

本地地址是 *.65432,這表示所有主機支持的 IP 地址族都可以接受傳入連接,在我們的例子裏面調用 socket() 時傳入的參數 socket.AF_INET 表示使用了 IPv4 的 TCP socket,你可以在輸出結果中的 Proto 列中看到(tcp4)

上面的輸出是我截取的只顯示了咱們的打印程序服務端進程,你可能會看到更多輸出,具體取決於你運行的系統。需要注意的是 Proto, Local Address 和 state 列。分別表示 TCP socket 類型、本地地址端口、當前狀態

另外一個查看這些信息的方法是使用 lsof 命令,這個命令在 macOS 上是默認安裝的,Linux 上需要你手動安裝

$ lsof -i -n
COMMAND     PID   USER   FD   TYPE   DEVICE SIZE/OFF NODE NAME
Python    67982 nathan    3u  IPv4 0xecf272      0t0  TCP *:65432 (LISTEN)

isof 命令使用 -i 參數可以查看打開的 socket 連接的 COMMAND, PID(process id) 和 USER(user id),上面的輸出就是打印程序服務端

netstatisof 命令有許多可用的參數,這取決於你使用的操作系統。可以使用 man page 來查看他們的使用文檔,這些文檔絕對值得花一點時間去了解,你將受益匪淺,macOS 和 Linux 中使用命令 man netstat 或者 man lsof 命令,windows 下使用 netstat /? 來查看幫助文檔

一個通常會犯的錯誤是在沒有監聽 socket 端口的情況下嘗試連接:

$ ./echo-client.py
Traceback (most recent call last):
  File "./echo-client.py", line 9, in <module>
    s.connect((HOST, PORT))
ConnectionRefusedError: [Errno 61] Connection refused

也可能是端口號出錯、服務端沒啓動或者有防火牆阻止了連接,這些原因可能很難記住,或許你也會碰到 Connection timed out 的錯誤,記得給你的防火牆添加允許我們使用的端口規則

引用部分有一些常見的 錯誤

通信的流程分解

讓我們再仔細的觀察下客戶端是如何與服務端進行通信的:

host

當使用迴環地址時,數據將不會接觸到外部網絡,上圖中,迴環地址包含在了 host 裏面。這就是迴環地址的本質,連接數據傳輸是從本地到主機,這就是爲什麼你會聽到有迴環地址或者 127.0.0.1::1 的 IP 地址和表示本地主機

應用程序使用迴環地址來與主機上的其它進程通信,這使得它與外部網絡安全隔離。由於它是內部的,只能從主機內訪問,所以它不會被暴露出去

如果你的應用程序服務器使用自己的專用數據庫(非公用的),則可以配置服務器僅監聽迴環地址,這樣的話網絡上的其它主機就無法連接到你的數據庫

如果你的應用程序中使用的 IP 地址不是 127.0.0.1 或者 ::1,那就可能會綁定到連接到外部網絡的以太網上。這就是你通往 localhost 王國之外的其他主機的大門

external network

這裏需要小心,並且可能讓你感到難受甚至懷疑全世界。在你探索 localhost 的安全限制之前,確認讀過 使用主機名 一節。 一個安全注意事項是 **不要使用主機名,要使用
IP 地址**

處理多個連接

打印程序的服務端肯定有它自己的一些侷限。這個程序只能服務於一個客戶端然後結束。打印程序的客戶端也有它自己的侷限,但是還有一個問題,如果客戶端調用了下面的方法s.recv() 方法將返回 b'Hello, world' 中的一個字節 b'H'

data = s.recv(1024)

1024 是緩衝區數據大小限制最大值參數 bufsize,並不是說 recv() 方法只返回 1024個字節的內容

send() 方法也是這個原理,它返回發送內容的字節數,結果可能小於傳入的發送內容,你得處理這處情況,按需多次調用 send() 方法來發送完整的數據

應用程序負責檢查是否已發送所有數據;如果僅傳輸了一些數據,則應用程序需要嘗試傳
遞剩餘數據 引用

我們可以使用 sendall() 方法來回避這個過程

和 send() 方法不一樣的是,sendall() 方法會一直髮送字節,只到所有的數據傳輸完成
或者中途出現錯誤。成功的話會返回 None 引用

到目前爲止,我們有兩個問題:

  • 如何同時處理多個連接請求
  • 我們需要一直調用 send() 或者 recv() 直到所有數據傳輸完成

應該怎麼做呢,有很多方式可以實現併發。最近,有一個非常流程的庫叫做 Asynchronous I/O 可以實現,asyncio 庫在 Python 3.4 後默認添加到了標準庫裏面。傳統的方法是使用線程

併發的問題是很難做到正確,有許多細微之處需要考慮和防範。可能其中一個細節的問題都會導致整個程序崩潰

我說這些並不是想嚇跑你或者讓你遠離學習和使用併發編程。如果你想讓程序支持大規模使用,使用多處理器、多核是很有必要的。然而在這個教程中我們將使用比線程更傳統的方法使得邏輯更容易推理。我們將使用一個非常古老的系統調用:select()

select() 允許你檢查多個 socket 的 I/O 完成情況,所以你可以使用它來檢測哪個 socket I/O 是就緒狀態從而執行讀取或寫入操作,但是這是 Python,總會有更多其它的選擇,我們將使用標準庫中的selectors 模塊,所以我們使用了最有效的實現,不用在意你使用的操作系統:

這個模塊提供了高層且高效的 I/O 多路複用,基於原始的 select 模塊構建,推薦用
戶使用這個模塊,除非他們需要精確到操作系統層面的使用控制 [引用
](https://docs.python.org/3/lib...

儘管如此,使用 select() 也無法併發執行。這取決於您的工作負載,這種實現仍然會很快。這也取決於你的應用程序對連接所做的具體事情或者它需要支持的客戶端數量

asyncio 使用單線程來處理多任務,使用事件循環來管理任務。通過使用 select(),我們可以創建自己的事件循環,更簡單且同步化。當使用多線程時,即使要處理併發的情況,我們也不得不面臨使用 CPython 或者 PyPy 中的「全局解析器鎖 GIL」,這有效地限制了我們可以並行完成的工作量

說這些是爲了解析爲什麼使用 select() 可能是個更好的選擇,不要覺得你必須使用 asyncio、線程或最新的異步庫。通常,在網絡應用程序中,你的應用程序就是 I/O 綁定:它可以在本地網絡上,網絡另一端的端,磁盤上等待

如果你從客戶端收到啓動 CPU 綁定工作的請求,查看 concurrent.futures模塊,它包含一個 ProcessPoolExecutor 類,用來異步執行進程池中的調用

如果你使用多進程,你的 Python 代碼將被操作系統並行地在不同處理器或者核心上調度運行,並且沒有全局解析器鎖。你可以通過
Python 大會上的演講 John Reese - Thinking Outside the GIL with AsyncIO and Multiprocessing - PyCon 2018 來了解更多的想法

在下一節中,我們將介紹解決這些問題的服務器和客戶端的示例。他們使用 select() 來同時處理多連接請求,按需多次調用 send()recv()

多連接的客戶端和服務端

下面兩節中,我們將使用 selectors 模塊中的 selector 對象來創建一個可以同時處理多個請求的客戶端和服務端

多連接的服務端

首頁,我們來看眼多連接服務端程序的代碼,multiconn-server.py。這是開始建立監聽 socket 部分

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)

這個程序和之前打印程序服務端最大的不同是使用了 lsock.setblocking(False) 配置 socket 爲非阻塞模式,這個 socket 的調用將不在是阻塞的。當它和 sel.select() 一起使用的時候(下面會提到),我們就可以等待 socket 就緒事件,然後執行讀寫操作

sel.register() 使用 sel.select() 爲你感興趣的事件註冊 socket 監控,對於監聽 socket,我們希望使用 selectors.EVENT_READ 讀取到事件

data 用來存儲任何你 socket 中想存的數據,當 select() 返回的時候它也會返回。我們將使用 data 來跟蹤 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 就緒。它返回一個(key, events) 元組,每個 socket 一個。key 就是一個包含 fileobj 屬性的具名元組。key.fileobj 是一個 socket 對象,mask 表示一個操作就緒的事件掩碼

如果 key.data 爲空,我們就可以知道它來自於監聽 socket,我們需要調用 accept() 方法來授受連接請求。我們將使用一個 accept() 包裝函數來獲取新的 socket 對象並註冊到 selector 上,我們馬上就會看到

如果 key.data 不爲空,我們就可以知道它是一個被接受的客戶端 socket,我們需要爲它服務,接着 service_connection() 會傳入 keymask 參數並調用,這包含了所有我們需要在 socket 上操作的東西

讓我們一起來看看 accept_wrapper() 方法做了什麼:

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)

由於監聽 socket 被註冊到了 selectors.EVENT_READ 上,它現在就能被讀取,我們調用 sock.accept() 後立即再立即調 conn.setblocking(False) 來讓 socket 進入非阻塞模式

請記住,這是這個版本服務器程序的主要目標,因爲我們不希望它被阻塞。如果被阻塞,那麼整個服務器在返回前都處於掛起狀態。這意味着其它 socket 處於等待狀態,這是一種 非常嚴重的 誰都不想見到的服務被掛起的狀態

接着我們使用了 types.SimpleNamespace 類創建了一個對象用來保存我們想要的 socket 和數據,由於我們得知道客戶端連接什麼時候可以寫入或者讀取,下面兩個事件都會被用到:

events = selectors.EVENT_READ | selectors.EVENT_WRITE

事件掩碼、socket 和數據對象都會被傳入 sel.register()

現在讓我們來看下,當客戶端 socket 就緒的時候連接請求是如何使用 service_connection() 來處理的

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 就是從調用 select() 方法返回的一個具名元組,它包含了 socket 對象「fileobj」和數據對象。mask 包含了就緒的事件

如果 socket 就緒而且可以被讀取, mask & selectors.EVENT_READ 就爲真,sock.recv() 會被調用。所有讀取到的數據都會被追加到 data.outb 裏面。隨後被髮送出去

注意 else: 語句,如果沒有收到任何數據:

if recv_data:
    data.outb += recv_data
else:
    print('closing connection to', data.addr)
    sel.unregister(sock)
    sock.close()

這表示客戶端關閉了它的 socket 連接,這時服務端也應該關閉自己的連接。不過別忘了先調用 sel.unregister() 來撤銷 select() 的監控

當 socket 就緒而且可以被讀取的時候,對於正常的 socket 應該一直是這種狀態,任何接收並被 data.outb 存儲的數據都將使用 sock.send() 方法打印出來。發送出去的字節隨後就會被從緩衝中刪除

data.outb = data.outb[sent:]

多連接的客戶端

現在讓我們一起來看看多連接的客戶端程序,multiconn-client.py,它和服務端很相似,不一樣的是它沒有監聽連接請求,它以調用 start_connections() 開始初始化連接:

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)

num_conns 參數是從命令行讀取的,表示爲服務器建立多少個鏈接。就像服務端程序一樣,每個 socket 都設置成了非阻塞模式

由於 connect() 方法會立即觸發一個 BlockingIOError 異常,所以我們使用 connect_ex() 方法取代它。connect_ex() 會返回一個錯誤指示 errno.EINPROGRESS,不像 connect() 方法直接在進程中返回異常。一旦連接結束,socket 就可以進行讀寫並且通過 select() 方法返回

socket 建立完成後,我們將使用 types.SimpleNamespace 類創建想會傳送的數據。由於每個連接請求都會調用 socket.send(),發送到服務端的消息得使用 list(messages) 方法轉換成列表結構。所有你想了解的東西,包括客戶端將要發送的、已發送的、已接收的消息以及消息的總字節數都存儲在 data 對象中

讓我們再來看看 service_connection()。基本上和服務端一樣:

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:]

有一個不同的地方,客戶端會跟蹤從服務器接收的字節數,根據結果來決定是否關閉 socket 連接,服務端檢測到客戶端關閉則會同樣的關閉服務端的連接

運行多連接的客戶端和服務端

現在讓我們把 multiconn-server.pymulticonn-client.py 兩個程序跑起來。他們都使用了命令行參數,如果不指定參數可以看到參數調用的方法:

服務端程序,傳入主機和端口號

$ ./multiconn-server.py
usage: ./multiconn-server.py <host> <port>

客戶端程序,傳入啓動服務端程序時同樣的主機和端口號以及連接數量

$ ./multiconn-client.py
usage: ./multiconn-client.py <host> <port> <num_connections>

下面就是服務端程序運行起來在 65432 端口上監聽迴環地址的輸出:

$ ./multiconn-server.py 127.0.0.1 65432
listening on ('127.0.0.1', 65432)
accepted connection from ('127.0.0.1', 61354)
accepted connection from ('127.0.0.1', 61355)
echoing b'Message 1 from client.Message 2 from client.' to ('127.0.0.1', 61354)
echoing b'Message 1 from client.Message 2 from client.' to ('127.0.0.1', 61355)
closing connection to ('127.0.0.1', 61354)
closing connection to ('127.0.0.1', 61355)

下面是客戶端,它創建了兩個連接請求到上面的服務端:

$ ./multiconn-client.py 127.0.0.1 65432 2
starting connection 1 to ('127.0.0.1', 65432)
starting connection 2 to ('127.0.0.1', 65432)
sending b'Message 1 from client.' to connection 1
sending b'Message 2 from client.' to connection 1
sending b'Message 1 from client.' to connection 2
sending b'Message 2 from client.' to connection 2
received b'Message 1 from client.Message 2 from client.' from connection 1
closing connection 1
received b'Message 1 from client.Message 2 from client.' from connection 2
closing connection 2

應用程序客戶端和服務端

多連接的客戶端和服務端程序版本與最早的原始版本相比肯定有了很大的改善,但是讓我們再進一步地解決上面「多連接」版本中的不足,然後完成最終版的實現:客戶端/服務器應用程序

我們希望有個客戶端和服務端在不影響其它連接的情況下做好錯誤處理,顯然,如果沒有發生異常,我們的客戶端和服務端不能崩潰的一團糟。這也是到現在爲止我們還沒討論的東西,我故意沒有引入錯誤處理機制因爲這樣可以使之前的程序容易理解

現在你對基本的 API,非阻塞 socket、select() 等概念已經有所瞭解了。我們可以繼續添加一些錯誤處理同時討論下「房間裏面的大象」的問題,我把一些東西隱藏在了幕後。你應該還記得,我在介紹中討論到的自定義類

首先,讓我們先解決錯誤:

所有的錯誤都會觸發異常,像無效參數類型和內存不足的常見異常可以被拋出;從 Python
3.3 開始,與 socket 或地址語義相關的錯誤會引發 OSError 或其子類之一的異常 引用

我們需要捕獲 OSError 異常。另外一個我沒提及的的問題是延遲,你將在文檔的很多地方看見關於延遲的討論,延遲會發生而且屬於「正常」錯誤。主機或者路由器重啓、交換機端口出錯、電纜出問題或者被拔出,你應該在你的代碼中處理好各種各樣的錯誤

剛纔說的「房間裏面的大象」問題是怎麼回事呢。就像 socket.SOCK_STREAM 這個參數的字面意思一樣,當使用 TCP 連接時,你會從一個連續的字節流讀取的數據,好比從磁盤上讀取數據,不同的是你是從網絡讀取字節流

然而,和使用 f.seek() 讀文件不同,換句話說,沒法定位 socket 的數據流的位置,如果可以像文件一樣定位數據流的位置(使用下標),那你就可以隨意的讀取你想要的數據

當字節流入你的 socket 時,會需要有不同的網絡緩衝區,如果想讀取他們就必須先保存到其它地方,使用 recv() 方法持續的從 socket 上讀取可用的字節流

相當於你從 socket 中讀取的是一塊一塊的數據,你必須使用 recv() 方法不斷的從緩衝區中讀取數據,直到你的應用確定讀取到了足夠的數據

什麼時候算「足夠」這取決於你的定義,就 TCP socket 而言,它只通過網絡發送或接收原始字節。它並不瞭解這些原始字節的含義

這可以讓我們定義一個應用層協議,什麼是應用層協議?簡單來說,你的應用會發送或者接收消息,這些消息其實就是你的應用程序的協議

換句話說,這些消息的長度、格式可以定義應用程序的語義和行爲,這和我們之前說的從socket 中讀取字節部分內容相關,當你使用 recv() 來讀取字節的時候,你需要知道讀的字節數,並且決定什麼時候算讀取完成

這些都是怎麼完成的呢?一個方法是隻讀取固定長度的消息,如果它們的長度總是一樣的話,這樣做很容易。當你收到固定長度字節消息的時候,就能確定它是個完整的消息

然而,如果你使用定長模式來發送比較短的消息會比較低效,因爲你還得處理填充剩餘的部分,此外,你還得處理數據不適合放在一個定長消息裏面的情況

在這個教程裏面,我們將使用一個通用的方案,很多協議都會用到它,包括 HTTP。我們將在每條消息前面追加一個頭信息,頭信息中包括消息的長度和其它我們需要的字段。這樣做的話我們只需要追蹤頭信息,當我們讀到頭信息時,就可以查到消息的長度並且讀出所有字節然後消費它

我們將通過使用一個自定義類來實現接收文本/二進制數據。你可以在此基礎上做出改進或者通過繼承這個類來擴展你的應用程序。重要的是你將看到一個例子實現它的過程

我將會提到一些關於 socket 和字節相關的東西,就像之前討論過的。當你通過 socket 來發送或者接收數據時,其實你發送或者接收到的是原始字節

如果你收到數據並且想讓它在一個多字節解釋的上下文中使用,比如說 4-byte 的整形,你需要考慮它可能是一種不是你機器 CPU 本機的格式。客戶端或者服務器的另外一頭可能是另外一種使用了不同的字節序列的 CPU,這樣的話,你就得把它們轉換成你主機的本地字節序列來使用

上面所說的字節順序就是 CPU 的 字節序,在引用部分的字節序 一節可以查看更多。我們將會利用 Unicode 字符集的優點來規避這個問題,並使用UTF-8 的方式編碼,由於 UTF-8 使用了 8字節 編碼方式,所以就不會有字節序列的問題

你可以查看 Python 關於編碼與 Unicode 的 文檔,注意我們只會編碼消息的頭部。我們將使用嚴格的類型,發送的消息編碼格式會在頭信息中定義。這將讓我們可以傳輸我們覺得有用的任意類型/格式數據

你可以通過調用 sys.byteorder 來決定你的機器的字節序列,比如在我的英特爾筆記本上,運行下面的代碼就可以:

$ python3 -c 'import sys; print(repr(sys.byteorder))'
'little'

如果我把這段代碼跑在可以模擬大字節序 CPU「PowerPC」的虛擬機上的話,應該是下面的結果:

$ python3 -c 'import sys; print(repr(sys.byteorder))'
'big'

在我們的例子程序中,應用層的協議定義了使用 UTF-8 方式編碼的 Unicode 字符。對於真正傳輸消息來說,如果需要的話你還是得手動交換字節序列

這取決於你的應用,是否需要它來處理不同終端間的多字節二進制數據,你可以通過添加額外的頭信息來讓你的客戶端或者服務端支持二進制,像 HTTP 一樣,把頭信息做爲參數傳進去

不用擔心自己還沒搞懂上面的東西,下面一節我們看到是如果實現的

應用的協議頭

讓我們來定義一個完整的協議頭:

  • 可變長度的文本
  • 基於 UTF-8 編碼的 Unicode 字符集
  • 使用 JSON 序列化的一個 Python 字典

其中必須具有的頭應該有以下幾個:

名稱 描述
byteorder 機器的字節序列(uses sys.byteorder),應用程序可能用不上
content-length 內容的字節長度
content-type 內容的類型,比如 text/json 或者 binary/my-binary-type
content-encoding 內容的編碼類型,比如 utf-8 編碼的 Unicode 文本,二進制數據

這些頭信息告訴接收者消息數據,這樣的話你就可以通過提供給接收者足夠的信息讓他接收到數據的時候正確的解碼的方式向它發送任何數據,由於頭信息是字典格式,你可以隨意向頭信息中添加鍵值對

發送應用程序消息

不過還有一個問題,由於我們使用了變長的頭信息,雖然方便擴展但是當你使用 recv() 方法讀取消息的時候怎麼知道頭信息的長度呢

我們前面講到過使用 recv() 接收數據和如何確定是否接收完成,我說過定長的頭可能會很低效,的確如此。但是我們將使用一個比較小的 2 字節定長的頭信息前綴來表示頭信息的長度

你可以認爲這是一種混合的發送消息的實現方法,我們通過發送頭信息長度來引導接收者,方便他們解析消息體

爲了給你更好地解釋消息格式,讓我們來看看消息的全貌:

message

消息以 2字節的固定長度的頭開始,這兩個字節是整型的網絡字節序列,表示下面的變長 JSON 頭信息的長度,當我們從 recv() 方法讀取到 2 個字節時就知道它表示的是頭信息長度的整形數字,然後在解碼 JSON 頭之前讀取這麼多的字節

JSON 頭包含了頭信息的字典。其中一個就是 content-length,這表示消息內容的數量(不是JSON頭),當我們使用 recv() 方法讀取到了 content-length 個字節的數據時,就表示接收完成並且讀取到了完整的消息

應用程序類

最後讓我們來看下成果,我們使用了一個消息類。來看看它是如何在 socket 發生讀寫事件時與 select() 配合使用的

對於這個示例應用程序而言,我必須想出客戶端和服務器將使用什麼類型的消息,從這一點來講這遠遠超過了最早時候我們寫的那個玩具一樣的打印程序

爲了保證程序簡單而且仍然能夠演示出它是如何在一個真正的程序中工作的,我創建了一個應用程序協議用來實現基本的搜索功能。客戶端發送一個搜索請求,服務器做一次匹配的查找,如果客戶端的請求沒法被識別成搜索請求,服務器就會假定這個是二進制請求,對應的返回二進制響應

跟着下面一節,運行示例、用代碼做實驗後你將會知道他是如何工作的,然後你就可以以這個消息類爲起點把他修改成適合自己使用的

就像我們之前討論的,你將在下面看到,處理 socket 時需要保存狀態。通過使用類,我們可以將所有的狀態、數據和代碼打包到一個地方。當連接開始或者接受的時候消息類就會爲每個 socket 創建一個實例

類中的很多包裝方法、工具方法在客戶端和服務端上都是差不多的。它們以下劃線開頭,就像 Message._json_encode() 一樣,這些方法通過類使用起來很簡單。這使得它們在其它方法中調用時更短,而且符合 DRY 原則

消息類的服務端程序本質上和客戶端一樣。不同的是客戶端初始化連接併發送請求消息,隨後要處理服務端返回的內容。而服務端則是等待連接請求,處理客戶端的請求消息,隨後發送響應消息

看起來就像這樣:

步驟 動作/消息內容
1 客戶端 發送帶有請求內容的消息
2 服務端 接收並處理請求消息
3 服務端 發送有響應內容的消息
4 客戶端 接收並處理響應消息

下面是代碼的結構:

應用程序 文件 代碼
服務端 app-server.py 服務端主程序
服務端 libserver.py 服務端消息類
客戶端 app-client.py 客戶端主程序
客戶端 libclient.py 客戶端消息類

消息入口點

我想通過首先提到它的設計方面來討論 Message 類的工作方式,不過這對我來說並不是立馬就能解釋清楚的,只有在重構它至少五次之後我才能達到它目前的狀態。爲什麼呢?因爲要管理狀態

當消息對象創建的時候,它就被一個使用 selector.register() 事件監控起來的 socket 關聯起來了

message = libserver.Message(sel, conn, addr)
sel.register(conn, selectors.EVENT_READ, data=message)
注意,這一節中的一些代碼來自服務端主程序與消息類,但是這部分內容的討論在客戶端
也是一樣的,我將在他們之間存在不同點的時候來解釋客戶端的版本

當 socket 上的事件就緒的時候,它就會被 selector.select() 方法返回。對過 key 對象的 data 屬性獲取到 message 的引用,然後在消息用調用一個方法:

while True:
    events = sel.select(timeout=None)
    for key, mask in events:
        # ...
        message = key.data
        message.process_events(mask)

觀察上面的事件循環,可以看見 sel.select() 位於「司機位置」,它是阻塞的,在循環的上面等待。當 socket 上的讀寫事件就緒時,它就會爲其服務。這表示間接的它也要負責調用 process_events() 方法。這就是我說 process_events() 方法是入口的原因

讓我們來看下 process_events() 方法做了什麼

def process_events(self, mask):
    if mask & selectors.EVENT_READ:
        self.read()
    if mask & selectors.EVENT_WRITE:
        self.write()

這樣做很好,因爲 process_events() 方法很簡潔,它只可以做兩件事情:調用 read()write() 方法

這又把我們帶回了狀態管理的問題。在幾次重構後,我決定如果別的方法依賴於狀態變量裏面的某個確定值,那麼它們就只應該從 read()write() 方法中調用,這將使處理socket 事件的邏輯儘量的簡單

可能說起來很簡單,但是經歷了前面幾次類的迭代:混合了一些方法,檢查當前狀態、依賴於其它值、在 read() 或者 write() 方法外面調用處理數據的方法,最後這證明了這樣管理起來很複雜

當然,你肯定需要把類按你自己的需求修改使它能夠符合你的預期,但是我建議你儘可能把狀態檢查、依賴狀態的調用的邏輯放在 read()write() 方法裏面

讓我們來看看 read() 方法,這是服務端版本,但是客戶端也是一樣的。不同之處在於方法名稱,一個(客戶端)是 process_response() 另一個(服務端)是 process_request()

def read(self):
    self._read()

    if self._jsonheader_len is None:
        self.process_protoheader()

    if self._jsonheader_len is not None:
        if self.jsonheader is None:
            self.process_jsonheader()

    if self.jsonheader:
        if self.request is None:
            self.process_request()

_read() 方法首頁被調用,然後調用 socket.recv() 從 socket 讀取數據並存入到接收緩衝區

記住,當調用 socket.recv() 方法時,組成消息的所有數據並沒有一次性全部到達。socket.recv() 方法可能需要調用很多次,這就是爲什麼在調用相關方法處理數據前每次都要檢查狀態

當一個方法開始處理消息時,首頁要檢查的就是接受緩衝區保存了足夠的多讀取的數據,如果確定,它們將繼續處理各自的數據,然後把數據存到其它流程可能會用到的變量上,並且清空自己的緩衝區。由於一個消息有三個組件,所以會有三個狀態檢查和處理方法的調用:

Message Component Method Output
Fixed-length header process_protoheader() self._jsonheader_len
JSON header process_jsonheader() self.jsonheader
Content process_request() self.request

接下來,讓我們一起看看 write() 方法,這是服務端的版本:

def write(self):
    if self.request:
        if not self.response_created:
            self.create_response()

    self._write()

write() 方法會首先檢測是否有請求,如果有而且響應還沒被創建的話 create_response() 方法就會被調用,它會設置狀態變量 response_created,然後爲發送緩衝區寫入響應

如果發送緩衝區有數據,write() 方法會調用 socket.send() 方法

記住,當 socket.send() 被調用時,所有發送緩衝區的數據可能還沒進入到發送隊列,socket 的網絡緩衝區可能滿了,socket.send() 可能需要重新調用,這就是爲什麼需要檢查狀態的原因,create_response() 應該只被調用一次,但是 _write() 方法需要調用多次

客戶端的 write() 版大體與服務端一致:

def write(self):
    if not self._request_queued:
        self.queue_request()

    self._write()

    if self._request_queued:
        if not self._send_buffer:
            # Set selector to listen for read events, we're done writing.
            self._set_selector_events_mask('r')

因爲客戶端首頁初始化了一個連接請求到服務端,狀態變量_request_queued被檢查。如果請求還沒加入到隊列,就調用 queue_request() 方法創建一個請求寫入到發送緩衝區中,同時也會使用變量 _request_queued 記錄狀態值防止多次調用

就像服務端一樣,如果發送緩衝區有數據 _write() 方法會調用 socket.send() 方法

需要注意客戶端版本的 write() 方法與服務端不同之處在於最後的請求是否加入到隊列中的檢查,這個我們將在客戶端主程序中詳細解釋,原因是要告訴 selector.select()停止監控 socket 的寫入事件而且我們只對讀取事件感興趣,沒有辦法通知套接字是可寫的

我將在這一節中留下一個懸念,這一節的主要目的是解釋 selector.select() 方法是如何通過 process_events() 方法調用消息類以及它是如何工作的

這一點很重要,因爲 process_events() 方法在連接的生命週期中將被調用很多次,因此,要確保那些只能被調用一次的方法正常工作,這些方法中要麼需要檢查自己的狀態變量,要麼需要檢查調用者的方法中的狀態變量

服務端主程序

在服務端主程序 app-server.py 中,主機、端口參數是通過命令行傳遞給程序的:

$ ./app-server.py
usage: ./app-server.py <host> <port>

例如需求監聽本地迴環地址上面的 65432 端口,需要執行:

$ ./app-server.py 127.0.0.1 65432
listening on ('127.0.0.1', 65432)

<host> 參數爲空的話就可以監聽主機上的所有 IP 地址

創建完 socket 後,一個傳入參數 socket.SO_REUSEADDR 的方法 `to
socket.setsockopt()` 將被調用

# Avoid bind() exception: OSError: [Errno 48] Address already in use
lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

設置這個參數是爲了避免 端口被佔用 的錯誤發生,如果當前程序使用的端口和之前的程序使用的一樣,你就會發現連接處於 TIME_WAIT 狀態

比如說,如果服務器主動關閉連接,服務器會保持爲大概兩分鐘的 TIME_WAIT 狀態,具體時長取決於你的操作系統。如果你想在兩分鐘內再開啓一個服務,你將得到一個OSError 表示 端口被戰勝,這樣做是爲了確保一些在途的數據包正確的被處理

事件循環會捕捉所有錯誤,以保證服務器正常運行:

while True:
    events = sel.select(timeout=None)
    for key, mask in events:
        if key.data is None:
            accept_wrapper(key.fileobj)
        else:
            message = key.data
            try:
                message.process_events(mask)
            except Exception:
                print('main: error: exception for',
                      f'{message.addr}:\n{traceback.format_exc()}')
                message.close()

當服務器接受到一個客戶端連接時,消息對象就會被創建:

def accept_wrapper(sock):
    conn, addr = sock.accept()  # Should be ready to read
    print('accepted connection from', addr)
    conn.setblocking(False)
    message = libserver.Message(sel, conn, addr)
    sel.register(conn, selectors.EVENT_READ, data=message)

消息對象會通過 sel.register() 方法關聯到 socket 上,而且它初始化就被設置成了只監控讀事件。當請求被讀取時,我們將通過監聽到的寫事件修改它

在服務器端採用這種方法的一個優點是,大多數情況下,當 socket 正常並且沒有網絡問題時,它始終是可寫的

如果我們告訴 sel.register() 方法監控 EVENT_WRITE 寫入事件,事件循環將會立即喚醒並通知我們這種情況,然而此時 socket 並不用喚醒調用 send() 方法。由於請求還沒被處理,所以不需要發回響應。這將消耗並浪費寶貴的 CPU 週期

服務端消息類

在消息切入點一節中,當通過 process_events() 知道 socket 事件就緒時我們可以看到消息對象是如何發出動作的。現在讓我們來看看當數據在 socket 上被讀取是會發生些什麼,以及爲服務器就緒的消息的組件片段發生了什麼

libserver.py 文件中的服務端消息類,可以在 Github 上找到 源代碼

這些方法按照消息處理順序出現在類中

當服務器讀取到至少兩個字節時,定長頭的邏輯就可以開始了

def process_protoheader(self):
    hdrlen = 2
    if len(self._recv_buffer) >= hdrlen:
        self._jsonheader_len = struct.unpack('>H',
                                             self._recv_buffer[:hdrlen])[0]
        self._recv_buffer = self._recv_buffer[hdrlen:]

網絡字節序列中的定長整型兩字節包含了 JSON 頭的長度,struct.unpack() 方法用來讀取並解碼,然後保存在 self._jsonheader_len 中,當這部分消息被處理完成後,就要調用 process_protoheader() 方法來刪除接收緩衝區中處理過的消息

就像上面的定長頭的邏輯一樣,當接收緩衝區有足夠的 JSON 頭數據時,它也需要被處理:

def process_jsonheader(self):
    hdrlen = self._jsonheader_len
    if len(self._recv_buffer) >= hdrlen:
        self.jsonheader = self._json_decode(self._recv_buffer[:hdrlen],
                                            'utf-8')
        self._recv_buffer = self._recv_buffer[hdrlen:]
        for reqhdr in ('byteorder', 'content-length', 'content-type',
                       'content-encoding'):
            if reqhdr not in self.jsonheader:
                raise ValueError(f'Missing required header "{reqhdr}".')

self._json_decode() 方法用來解碼並反序列化 JSON 頭成一個字典。由於我們定義的 JSON 頭是 utf-8 格式的,所以解碼方法調用時我們寫死了這個參數,結果將被存放在 self.jsonheader 中,process_jsonheader 方法做完他應該做的事情後,同樣需要刪除接收緩衝區中處理過的消息

接下來就是真正的消息內容,當接收緩衝區有 JSON 頭中定義的 content-length 值的數量個字節時,請求就應該被處理了:

def process_request(self):
    content_len = self.jsonheader['content-length']
    if not len(self._recv_buffer) >= content_len:
        return
    data = self._recv_buffer[:content_len]
    self._recv_buffer = self._recv_buffer[content_len:]
    if self.jsonheader['content-type'] == 'text/json':
        encoding = self.jsonheader['content-encoding']
        self.request = self._json_decode(data, encoding)
        print('received request', repr(self.request), 'from', self.addr)
    else:
        # Binary or unknown content-type
        self.request = data
        print(f'received {self.jsonheader["content-type"]} request from',
              self.addr)
    # Set selector to listen for write events, we're done reading.
    self._set_selector_events_mask('w')

把消息保存到 data 變量中後,process_request() 又會刪除接收緩衝區中處理過的數據。接着,如果 content type 是 JSON 的話,它將解碼並反序列化數據。否則(在我們的例子中)數據將被視 做二進制數據並打印出來

最後 process_request() 方法會修改 selector 爲只監控寫入事件。在服務端的程序 app-server.py 中,socket 初始化被設置成僅監控讀事件。現在請求已經被全部處理完了,我們對讀取事件就不感興趣了

現在就可以創建一個響應寫入到 socket 中。當 socket 可寫時 create_response() 將被從 write() 方法中調用:

def create_response(self):
    if self.jsonheader['content-type'] == 'text/json':
        response = self._create_response_json_content()
    else:
        # Binary or unknown content-type
        response = self._create_response_binary_content()
    message = self._create_message(**response)
    self.response_created = True
    self._send_buffer += message

響應會根據不同的 content type 的不同而調用不同的方法創建。在這個例子中,當 action == 'search' 的時候會執行一個簡單的字典查找。你可以在這個地方添加你自己的處理方法並調用

一個不好處理的問題是響應寫入完成時如何關閉連接,我會在 _write() 方法中調用 close()

def _write(self):
    if self._send_buffer:
        print('sending', repr(self._send_buffer), 'to', self.addr)
        try:
            # Should be ready to write
            sent = self.sock.send(self._send_buffer)
        except BlockingIOError:
            # Resource temporarily unavailable (errno EWOULDBLOCK)
            pass
        else:
            self._send_buffer = self._send_buffer[sent:]
            # Close when the buffer is drained. The response has been sent.
            if sent and not self._send_buffer:
                self.close()

雖然close() 方法的調用有點隱蔽,但是我認爲這是一種權衡。因爲消息類一個連接只處理一條消息。寫入響應後,服務器無需執行任何操作。它的任務就完成了

客戶端主程序

客戶端主程序 app-client.py 中,參數從命令行中讀取,用來創建請求並連接到服務端

$ ./app-client.py
usage: ./app-client.py <host> <port> <action> <value>

來個示例演示一下:

$ ./app-client.py 127.0.0.1 65432 search needle

當從命令行參數創建完一個字典來表示請求後,主機、端口、請求字典一起被傳給 start_connection()

def start_connection(host, port, request):
    addr = (host, port)
    print('starting connection to', addr)
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setblocking(False)
    sock.connect_ex(addr)
    events = selectors.EVENT_READ | selectors.EVENT_WRITE
    message = libclient.Message(sel, sock, addr, request)
    sel.register(sock, events, data=message)

對服務器的 socket 連接被創建,消息對象被傳入請求字典並創建

和服務端一樣,消息對象在 sel.register() 方法中被關聯到 socket 上。然而,客戶端不同的是,socket 初始化的時候會監控讀寫事件,一旦請求被寫入,我們將會修改爲只監控讀取事件

這種實現和服務端一樣有好處:不浪費 CPU 生命週期。請求發送完成後,我們就不關注寫入事件了,所以不用保持狀態等待處理

客戶端消息類

消息入口點 一節中,我們看到過,當 socket 使用準備就緒時,消息對象是如何調用具體動作的。現在讓我們來看看 socket 上的數據是如何被讀寫的,以及消息準備好被加工的時候發生了什麼

客戶端消息類在 libclient.py 文件中,可以在 Github 上找到 源代碼

這些方法按照消息處理順序出現在類中

客戶端的第一個任務就是讓請求入隊列:

def queue_request(self):
    content = self.request['content']
    content_type = self.request['type']
    content_encoding = self.request['encoding']
    if content_type == 'text/json':
        req = {
            'content_bytes': self._json_encode(content, content_encoding),
            'content_type': content_type,
            'content_encoding': content_encoding
        }
    else:
        req = {
            'content_bytes': content,
            'content_type': content_type,
            'content_encoding': content_encoding
        }
    message = self._create_message(**req)
    self._send_buffer += message
    self._request_queued = True

用來創建請求的字典,取決於客戶端程序 app-client.py 中傳入的命令行參數,當消息對象創建的時候,請求字典被當做參數傳入

請求消息被創建並追加到發送緩衝區中,消息將被 _write() 方法發送,狀態參數 self._request_queued 被設置,這使 queue_request() 方法不會被重複調用

請求發送完成後,客戶端就等待服務器的響應

客戶端讀取和處理消息的方法和服務端一致,由於響應數據是從 socket 上讀取的,所以處理 header 的方法會被調用:process_protoheader()process_jsonheader()

最終處理方法名字的不同在於處理一個響應,而不是創建:process_response(),_process_response_json_content()_process_response_binary_content()

最後,但肯定不是最不重要的 —— 最終的 process_response() 調用:

def process_response(self):
    # ...
    # Close when response has been processed
    self.close()

消息類的包裝

我將通過提及一些方法的重要注意點來結束消息類的討論

主程序中任意的類觸發異常都由 except 字句來處理:

try:
    message.process_events(mask)
except Exception:
    print('main: error: exception for',
          f'{message.addr}:\n{traceback.format_exc()}')
    message.close()

注意最後一行的方法 message.close()

這一行很重要的原因有很多,不僅僅是保證 socket 被關閉,而且通過調用 message.close() 方法刪除使用 select() 監控的 socket,這是類中的一段非常簡潔的代碼,它能減小複雜度。如果一個異常發生或者我們自己主動拋出,我們很清楚 close() 方法將處理善後

Message._read()Message._write() 方法都包含一些有趣的東西:

def _read(self):
    try:
        # Should be ready to read
        data = self.sock.recv(4096)
    except BlockingIOError:
        # Resource temporarily unavailable (errno EWOULDBLOCK)
        pass
    else:
        if data:
            self._recv_buffer += data
        else:
            raise RuntimeError('Peer closed.')

注意 except 行:except BlockingIOError

_write() 方法也有,這幾行很重要是因爲它們捕獲臨時錯誤並通過使用 pass 跳過。臨時錯誤是 socket 阻塞的時候發生的,比如等待網絡響應或者連接的其它端

通過使用 pass 跳過異常,select() 方法將再次調用,我們將有機會重新讀寫數據

運行應用程序的客戶端和服務端

經過所有這些艱苦的工作後,讓我們把程序運行起來並找到一些樂趣!

在這個救命中,我們將傳一個空的字符串做爲 host 參數的值,用來監聽服務器端的所有IP 地址。這樣的話我就可以從其它網絡上的虛擬機運行客戶端程序,我將模擬一個 PowerPC 的機器

首頁,把服務端程序運行進來:

$ ./app-server.py '' 65432
listening on ('', 65432)

現在讓我們運行客戶端,傳入搜索內容,看看是否能看他(墨菲斯-黑客帝國中的角色):

$ ./app-client.py 10.0.1.1 65432 search morpheus
starting connection to ('10.0.1.1', 65432)
sending b'\x00d{"byteorder": "big", "content-type": "text/json", "content-encoding": "utf-8", "content-length": 41}{"action": "search", "value": "morpheus"}' to ('10.0.1.1', 65432)
received response {'result': 'Follow the white rabbit. 🐰'} from ('10.0.1.1', 65432)
got result: Follow the white rabbit. 🐰
closing connection to ('10.0.1.1', 65432)

我的命令行 shell 使用了 utf-8 編碼,所以上面的輸出可以是 emojis

再試試看能不能搜索到小狗:

$ ./app-client.py 10.0.1.1 65432 search 🐶
starting connection to ('10.0.1.1', 65432)
sending b'\x00d{"byteorder": "big", "content-type": "text/json", "content-encoding": "utf-8", "content-length": 37}{"action": "search", "value": "\xf0\x9f\x90\xb6"}' to ('10.0.1.1', 65432)
received response {'result': '🐾 Playing ball! 🏐'} from ('10.0.1.1', 65432)
got result: 🐾 Playing ball! 🏐
closing connection to ('10.0.1.1', 65432)

注意請求發送行的 byte string,很容易看出來你發送的小狗 emoji 表情被打印成了十六進制的字符串 \xf0\x9f\x90\xb6,我可以使用 emoji 表情來搜索是因爲我的命令行支持utf-8 格式的編碼

這個示例中我們發送給網絡原始的 bytes,這些 bytes 需要被接受者正確的解釋。這就是爲什麼之前需要給消息附加頭信息並且包含編碼類型字段的原因

下面這個是服務器對應上面兩個客戶端連接的輸出:

accepted connection from ('10.0.2.2', 55340)
received request {'action': 'search', 'value': 'morpheus'} from ('10.0.2.2', 55340)
sending b'\x00g{"byteorder": "little", "content-type": "text/json", "content-encoding": "utf-8", "content-length": 43}{"result": "Follow the white rabbit. \xf0\x9f\x90\xb0"}' to ('10.0.2.2', 55340)
closing connection to ('10.0.2.2', 55340)

accepted connection from ('10.0.2.2', 55338)
received request {'action': 'search', 'value': '🐶'} from ('10.0.2.2', 55338)
sending b'\x00g{"byteorder": "little", "content-type": "text/json", "content-encoding": "utf-8", "content-length": 37}{"result": "\xf0\x9f\x90\xbe Playing ball! \xf0\x9f\x8f\x90"}' to ('10.0.2.2', 55338)
closing connection to ('10.0.2.2', 55338)

注意發送行中寫到客戶端的 bytes,這就是服務端的響應消息

如果 action 參數不是搜索,你也可以試試給服務器發送二進制請求

$ ./app-client.py 10.0.1.1 65432 binary 😃
starting connection to ('10.0.1.1', 65432)
sending b'\x00|{"byteorder": "big", "content-type": "binary/custom-client-binary-type", "content-encoding": "binary", "content-length": 10}binary\xf0\x9f\x98\x83' to ('10.0.1.1', 65432)
received binary/custom-server-binary-type response from ('10.0.1.1', 65432)
got response: b'First 10 bytes of request: binary\xf0\x9f\x98\x83'
closing connection to ('10.0.1.1', 65432)

由於請求的 content-type 不是 text/json,服務器會把內容當成二進制類型並且不會解碼 JSON,它只會打印 content-type 和返回的前 10 個 bytes 給客戶端

$ ./app-server.py '' 65432
listening on ('', 65432)
accepted connection from ('10.0.2.2', 55320)
received binary/custom-client-binary-type request from ('10.0.2.2', 55320)
sending b'\x00\x7f{"byteorder": "little", "content-type": "binary/custom-server-binary-type", "content-encoding": "binary", "content-length": 37}First 10 bytes of request: binary\xf0\x9f\x98\x83' to ('10.0.2.2', 55320)
closing connection to ('10.0.2.2', 55320)

故障排除

某些東西運行不了是很常見的,你可能不知道應該怎麼做,不用擔心,所有人都會遇到這種問題,希望你藉助本教程、調試器和萬能的搜索引擎解決問題並且繼續下去

如果還是解決不了,你的第一站應該是 python 的 socket 模塊文檔,確保你讀過文檔中每個我們使用到的方法、函數。同樣的可以從引用一節中找到一些辦法,尤其是錯誤一節中的內容

有的時候問題並不是由你的源代碼引起的,源代碼可能是正確的。有可能是不同的主機、客戶端和服務器。也可能是網絡原因,比如路由器、防火牆或者是其它網絡設備扮演了中間人的角色

對於這些類型的問題,額外的一些工具是必要的。下面這些工具或者集可能會幫到你或者至少提供一些線索

pin

ping 命令通過發送一個 ICMP 報文來檢測主機是否連接到了網絡,它直接與操作系統上的 TCP/IP 協議棧通信,所以它在主機上是獨立於任何應用程序運行的

下面是一段在 macOS 上執行 ping 命令的結果

$ ping -c 3 127.0.0.1
PING 127.0.0.1 (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.058 ms
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.165 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.164 ms

--- 127.0.0.1 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.058/0.129/0.165/0.050 ms

注意後面的統計輸出,這對你排查間歇性的連接問題很有幫助。比如說,是否有數據包丟失?網絡延遲怎麼樣(查看消息的往返時間)

如果你與主機之間有防火牆的話,ping 發送的請求可能會被阻止。防火牆管理員定義了一些規則強制阻止一些請求,主要的原因就是他們不想自己的主機是可以被發現的。如果你的機器也出現這種情況的話,請確保在規則中添加了允許 ICMP 包的發送

ICMP 是 ping 命令使用的協議,但它也是 TCP 和其他底層用於傳遞錯誤消息的協議,如果你遇到奇怪的行爲或緩慢的連接,可能就是這個原因

ICMP 消息通過類型和代號來定義。下面有一些重要的信息可以參考:

ICMP 類型 ICMP 代碼 說明
8 0 打印請求
0 0 打印回覆
3 0 目標網絡不可達
3 1 目標主機不可達
3 2 目標協議不可達
3 3 目標端口不可達
3 4 需要分片,但是 DF(Don't fragmentation) 標識已被設置
11 0 網絡存在環路

查看 Path MTU Discovery 更多關於分片和 ICMP 消息的內容,裏面遇到的問題就是我前面提及的一些奇怪行爲

netstat

查看 socket 狀態 一節中我們已經知道如何使用 netstat 來查看 socket 及其狀態的信息。這個命令在 macOS, Linux, Windows 上都可以使用

在之前的示例中我並沒有提及 Recv-QSend-Q 列。這些列表示發送或者接收隊列中網絡緩衝區數據的字節數,但是由於某些原因這些字節還沒被遠程或者本地應用讀寫

換句話說,這些網絡中的字節還在操作系統的隊列中。一個原因可能是應用程序受 CPU 限制或者無法調用 socket.recv()socket.send() 方法處理,或者因爲其它一些網絡原因導致的,比如說網絡的擁堵、失敗、硬件及電纜的問題

爲了復現這個問題,看看到底在錯誤發生前我應該發送多少數據。我寫了一個測試客戶端可以連接到測試服務器,並且重複的調用 socket.send() 方法。測試服務端永遠不調用 socket.recv() 或者 socket.send() 方法來處理客戶端發送的數據,它只接受連接請求。這會導致服務器上的網絡緩衝區被填滿,最終會在客戶端上報錯

首先運行服務端:

$ ./app-server-test.py 127.0.0.1 65432 listening on ('127.0.0.1', 65432)

然後運行客戶端,看看發生了什麼:

$ ./app-client-test.py 127.0.0.1 65432 binary test
error: socket.send() blocking io exception for ('127.0.0.1', 65432):
BlockingIOError(35, 'Resource temporarily unavailable')

下面是用 netstat 命令在錯誤發生時執行的結果:

$ netstat -an | grep 65432
Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4  408300      0  127.0.0.1.65432        127.0.0.1.53225        ESTABLISHED
tcp4       0 269868  127.0.0.1.53225        127.0.0.1.65432        ESTABLISHED
tcp4       0      0  127.0.0.1.65432        *.*                    LISTEN

第一行就表示服務端(本地端口是 65432)

Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4  408300      0  127.0.0.1.65432        127.0.0.1.53225        ESTABLISHED

注意 Recv-Q: 408300

第二行表示客戶端(遠程端口是 65432)

Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4       0 269868  127.0.0.1.53225        127.0.0.1.65432        ESTABLISHED

注意 Send-Q: 269868

顯然,客戶端試着寫入字節,但是服務端並沒有讀取他們。這導致服務端網絡緩衝隊列中應該保存的數據被積壓在接收端,客戶端的網絡緩衝隊列積壓到發送端

windows

如果你使用的是 windows 電腦,有一個工具套件絕對值得安裝 Windows Sysinternals

裏面有個工具叫 TCPView.exe,它是 windows 下的一個可視化的 netstat 工具。除了地址、端口號和 socket 狀態之外,它還會顯示發送和接收的數據包以及字節數。就像 Unix 工具集 lsof 命令一樣,你也可以看見進程名和 ID,可以在菜單中查看更多選項

TCPView

Wireshark

有時候你可能想查看網絡底層發生了什麼,忽略應用程序的輸出或者外部庫調用,想看看網絡層面到底收發了什麼內容,就像調試器一樣,當你需要看清這些的時候,沒有別的辦法

Wireshark 是一款可以運行在 macOS, Linux, Windows 以及其它系統上的網絡協議分析、流量捕獲工具,GUI 版本的程序叫做
wireshark,命令 行的程序叫做 tshark

流量捕獲是一個非常好用的方法,它可以讓你看到網絡上應用程序的行爲,收集到關於收發消息多少、頻率等信息,你也可以看到客戶端或者服務端如何關閉/取消連接,或者停止響應,當你需要排除故障的時候這些信息非常的有用

網上還有很多關於 wiresharkTShark 的基礎使用教程

這有一個使用 wireshark 捕獲本地網絡數據的例子:

wireshark

還有一個和上面一樣的使用 tshark 命令輸出的結果:

$ tshark -i lo0 'tcp port 65432'
Capturing on 'Loopback'
    1   0.000000    127.0.0.1 → 127.0.0.1    TCP 68 53942 → 65432 [SYN] Seq=0 Win=65535 Len=0 MSS=16344 WS=32 TSval=940533635 TSecr=0 SACK_PERM=1
    2   0.000057    127.0.0.1 → 127.0.0.1    TCP 68 65432 → 53942 [SYN, ACK] Seq=0 Ack=1 Win=65535 Len=0 MSS=16344 WS=32 TSval=940533635 TSecr=940533635 SACK_PERM=1
    3   0.000068    127.0.0.1 → 127.0.0.1    TCP 56 53942 → 65432 [ACK] Seq=1 Ack=1 Win=408288 Len=0 TSval=940533635 TSecr=940533635
    4   0.000075    127.0.0.1 → 127.0.0.1    TCP 56 [TCP Window Update] 65432 → 53942 [ACK] Seq=1 Ack=1 Win=408288 Len=0 TSval=940533635 TSecr=940533635
    5   0.000216    127.0.0.1 → 127.0.0.1    TCP 202 53942 → 65432 [PSH, ACK] Seq=1 Ack=1 Win=408288 Len=146 TSval=940533635 TSecr=940533635
    6   0.000234    127.0.0.1 → 127.0.0.1    TCP 56 65432 → 53942 [ACK] Seq=1 Ack=147 Win=408128 Len=0 TSval=940533635 TSecr=940533635
    7   0.000627    127.0.0.1 → 127.0.0.1    TCP 204 65432 → 53942 [PSH, ACK] Seq=1 Ack=147 Win=408128 Len=148 TSval=940533635 TSecr=940533635
    8   0.000649    127.0.0.1 → 127.0.0.1    TCP 56 53942 → 65432 [ACK] Seq=147 Ack=149 Win=408128 Len=0 TSval=940533635 TSecr=940533635
    9   0.000668    127.0.0.1 → 127.0.0.1    TCP 56 65432 → 53942 [FIN, ACK] Seq=149 Ack=147 Win=408128 Len=0 TSval=940533635 TSecr=940533635
   10   0.000682    127.0.0.1 → 127.0.0.1    TCP 56 53942 → 65432 [ACK] Seq=147 Ack=150 Win=408128 Len=0 TSval=940533635 TSecr=940533635
   11   0.000687    127.0.0.1 → 127.0.0.1    TCP 56 [TCP Dup ACK 6#1] 65432 → 53942 [ACK] Seq=150 Ack=147 Win=408128 Len=0 TSval=940533635 TSecr=940533635
   12   0.000848    127.0.0.1 → 127.0.0.1    TCP 56 53942 → 65432 [FIN, ACK] Seq=147 Ack=150 Win=408128 Len=0 TSval=940533635 TSecr=940533635
   13   0.001004    127.0.0.1 → 127.0.0.1    TCP 56 65432 → 53942 [ACK] Seq=150 Ack=148 Win=408128 Len=0 TSval=940533635 TSecr=940533635
^C13 packets captured

引用

這一節主要用來引用一些額外的信息和外部資源鏈接

Python 文檔

錯誤信息

下面這段話來自 python 的 socket 模塊文檔:

所有的錯誤都會觸發異常,像無效參數類型和內存不足的常見異常可以被拋出;從
Python 3.3 開始,與 socket 或地址語義相關的錯誤會引發 OSError 或其子類之一的異
引用

異常 | errno 常量 | 說明
BlockingIOError | EWOULDBLOCK | 資源暫不可用,比如在非阻塞模式下調用 send() 方法,對方太繁忙面沒有讀取,發送隊列滿了,或者網絡有問題
OSError | EADDRINUSE | 端口被戰用,確保沒有其它的進程與當前的程序運行在同一地址/端口上,你的服務器設置了 SO_REUSEADDR 參數
ConnectionResetError | ECONNRESET | 連接被重置,遠端的進程崩潰,或者 socket 意外關閉,或是有防火牆或鏈路上的設配有問題
TimeoutError | ETIMEDOUT | 操作超時,對方沒有響應
ConnectionRefusedError | ECONNREFUSED | 連接被拒絕,沒有程序監聽指定的端口

socket 地址族

socket.AF_INETsocket.AF_INET6socket.socket() 方法調用的第一個參數
,表示地址協議族,API 使用了一個期望傳入指定格式參數的地址,這取決於是
AF_INET 還是 AF_INET6

地址族 協議 地址元組 說明
socket.AF_INET IPv4 (host, port) host 參數是個如 www.example.com 的主機名稱,或者如 10.1.2.3 的 IPv4 地址
socket.AF_INET6 IPv6 (host, port, flowinfo, scopeid) 主機名同上,IPv6 地址 如:fe80::6203:7ab:fe88:9c23,flowinfo 和 scopeid 分別表示 C 語言結構體 sockaddr_in6 中的 sin6_flowinfosin6_scope_id 成員

注意下面這段 python socket 模塊中關於 host 值和地址元組文檔

對於 IPv4 地址,使用主機地址的方式有兩種:'' 空字符串表示 INADDR_ANY,字符
'<broadcast>' 表示 INADDR_BROADCAST,這個行爲和 IPv6 不兼容,因此如果你的
程序中使用的是 IPv6 就應該避免這種做法。[源文檔
](https://docs.python.org/3/lib...

我在本教程中使用了 IPv4 地址,但是如果你的機器支持,也可以試試 IPv6 地址。socket.getaddrinfo() 方法會返回五個元組的序列,這包括所有創建 socket 連接的必要參數,socket.getaddrinfo() 方法理解並處理傳入的 IPv6 地址和主機名

下面的例子中程序將返回一個通過 TCP 連接到 example.org 80 端口上的地址信息:

>>> socket.getaddrinfo("example.org", 80, proto=socket.IPPROTO_TCP)
[(<AddressFamily.AF_INET6: 10>, <SocketType.SOCK_STREAM: 1>,
 6, '', ('2606:2800:220:1:248:1893:25c8:1946', 80, 0, 0)),
 (<AddressFamily.AF_INET: 2>, <SocketType.SOCK_STREAM: 1>,
 6, '', ('93.184.216.34', 80))]

如果 IPv6 可用的話結果可能有所不同,上面返回的值可以被用於 socket.socket()
socket.connect() 方法調用的參數,在 python socket 模塊文檔中的 [示例
](https://docs.python.org/3/lib... 一節中有客戶端和服務端
程序

使用主機名

這一節主要適用於使用 bind()connect()connect_ex() 方法時如何使用主機名,然而當你使用迴環地址做爲主機名時,它總是會解析到你期望的地址。這剛好與客戶端使用主機名的場景相反,它需要 DNS 解析的過程,比如 www.example.com

下面一段來自 python socket 模塊文檔

如果你主機名稱做爲 IPv4/v6 socket 地址的 host 部分,程序可能會出現非預期的結果
,由於 python 使用了 DNS 查找過程中的第一個結果,socket 地址會被解析成與真正的
IPv4/v6 地址不同的其它地址,這取決於 DNS 解析和你的 host 文件配置。如果想得到
確定的結果,請使用數字格式的地址做爲 host 參數的值 [源文檔
](https://docs.python.org/3/lib...

通常回環地址 localhost 會被解析到 127.0.0.1::1 上,你的系統可能就是這麼設置的,也可能不是。這取決於你係統配置,與所有 IT 相關的事情一樣,總會有例外的情況,沒辦法完全保證 localhost 被解析到了迴環地址上

比如在 Linux 上,查看 man nsswitch.conf 的結果,域名切換配置文件,還有另外一個 macOS 和 Linux 通用的配置文件地址是:/etc/hosts,在 windows 上則是C:\Windows\System32\drivers\etc\hosts,hosts 文件包含了一個文本格式的靜態域名地址映射表,總之 DNS 也是一個難題

有趣的是,在撰寫這篇文章的時候(2018 年 6 月),有一個關於 讓 localhost 成爲真正的 localhost的 RFC 草案,討論就是圍繞着 localhost 使用的情況開展的

最重要的一點是你要理解當你在應用程序中使用主機名時,返回的地址可能是任何東西,如果你有一個安全性敏感的應用程序,不要使用主機名。取決於你的應用程序和環境,這可能會困擾到你

注意: 安全方面的考慮和最佳實踐總是好的,即使你的程序不是安全敏感型的應用。如果你的應用程序訪問了網絡,那它就應該是安全的穩定的。這表示至少要做到以下幾點:

  • 經常會有系統軟件升級和安全補丁,包括 python,你是否使用了第三方的庫?如果是的話,確保他們能正常工作並且更新到了新版本
  • 儘量使用專用防火牆或基於主機的防火牆來限制與受信任系統的連接
  • DNS 服務是如何配置的?你是否信任配置內容及其配置者
  • 在調用處理其他代碼之前,請確保儘可能地對請求數據進行了清理和驗證,還要爲此添加測試用例,並且經常運行

無論是否使用主機名稱,你的應用程序都需要支持安全連接(加密授權),你可能會用到 TLS,這是一個超越了本教程的範圍的話題。可以從 python 的 SSL 模塊文檔瞭解如何開始使用它,這個協議和你的瀏覽器使用的安全協議是一樣的

考慮到接口、IP 地址、域名解析這些「變量」,你應該怎麼應對?如果你還沒有網絡應用程序審查流程,可以使用以下建議:

應用程序 使用 建議
服務端 迴環地址 使用 IP 地址 127.0.0.1 或 ::1
服務端 以太網地址 使用 IP 地址,比如:10.1.2.3,使用空字符串表示本機所有 IP 地址
客戶端 迴環地址 使用 IP 地址 127.0.0.1 或 ::1
客戶端 以太網地址 使用統一的不依賴域名解析的 IP 地址,特殊情況下才會使用主機地址,查看上面的安全提示

對於客戶端或者服務端來說,如果你需要授權連接到主機,請查看如何使用 TLS

阻塞調用

如果一個 socket 函數或者方法使你的程序掛起,那麼這個就是個阻塞調用,比如 accept(), connect(), send(), 和 recv() 都是 阻塞 的,它們不會立即返回,阻塞調用在返回前必須等待系統調用 (I/O) 完成。所以調用者 —— 你,會被阻止直到系統調用結束或者超過延遲時間或者有錯誤發生

阻塞的 socket 調用可以設置成非阻塞的模式,這樣他們就可以立即返回。如果你想做到這一點,就得重構並重新設計你的應用程序

由於調用直接返回了,但是數據確沒就緒,被調用者處於等待網絡響應的狀態,沒法完成它的工作,這種情況下,當前 socket 的狀態碼 errno 應該是 socket.EWOULDBLOCKsetblocking() 方法是支持非阻塞模式的

默認情況下,socket 會以阻塞模式創建,查看 socket 延遲的注意事項 中三種模式的解釋

關閉連接

有趣的是 TCP 連接一端打開,另一端關閉的狀態是完全合法的,這被稱做 TCP「半連接」,是否需要這種保持狀態是由應用程序決定的,通常來說不需要。這種狀態下,關閉方將不能發送任何數據,它只能接收數據

我不是在提倡你採用這種方法,但是作爲一個例子,HTTP 使用了一個名爲「Connection」的頭來標準化規定應用程序是否關閉或者保持連接狀態,更多內容請查看 RFC 7230 中 6.3 節, HTTP 協議 (HTTP/1.1): 消息語法與路由

當你在設計應用程序及其應用層協議的時候,最好先了解一下如何關閉連接,有時這很簡單而且很明顯,或者採取一些可以實現的原型,這取決於你的應用程序以及消息循環如何被處理成期望的數據,只要確保 socket 在完成工作後總是能正確關閉

字節序

查看維基百科 字節序 中關於不同的 CPU 是如何在內存中存儲字節序列的,處理單個字節時沒有任何問題,但是當把多個字節處理成單個值(四字節整型)時,如果和你通信的另一端使用了不同的字節序時字節順序需要被反轉

字節順序對於字符文本來說也很重要,字符文本通過表示爲多字節的序列,就像 Unicode 一樣。除非你只使用 true 和 ASCII 字符來控制客戶端和服務端的實現,否則使用 utf-8 格式或者支持字節序標識(BOM) 的 Unicode 字符集會比較合適

在應用層協議中明確的規定使用編碼格式是很重要的,你可以規定所有的文本都使用 utf-8 或者用「content-encoding」頭指定編碼格式,這將使你的程序不需要檢測編碼方式,當然也應該儘量避免這麼做

當數據被調用存儲到了文件或者數據庫中而且又沒有數據的元信息的時候,問題就很麻煩了,當數據被傳到其它端,它將試着檢測數據的編碼方式。有關討論,請參閱 Wikipedia 的 Unicode 文章,它引用了 RFC 3629:UTF-8, a transformation format of ISO 10646

然而 UTF-8 的標準 RFC 3629 中推薦禁止在 UTF-8 協議中使用標記字節序 (BOM),但是
討 論了無法實現的情況,最大的問題在於如何使用一種模式在不依賴 BOM 的情況下區分
UTF-8 和其它編碼方式

避開這些問題的方法就是總是存儲數據使用的編碼方式,換句話說,如果不只用 utf-8 格式的編碼或者其它的帶有 BOM 的編碼就要嘗試以某種方式將編碼方式存儲爲元數據,然後你就可以在數據上附加編碼的頭信息,告訴接收者編碼方式

TCP/IP 使用的字節順序是 big-endian,被稱做網絡序。網絡序被用來表示底層協議棧中的整型數字,好比 IP 地址和端口號,python 的 socket 模塊有幾個函數可以把這種整型數字從網絡字節序轉換成主機字節序

函數 說明
socket.ntohl(x) 把 32 位的正整型數字從網絡字節序轉換成主機字節序,在網絡字節序和主機字節序相同的機器上這是個空操作,否則將是一個 4 字節的交換操作
socket.ntohs(x) 把 16 位的正整型數字從網絡字節序轉換成主機字節序,在網絡字節序和主機字節序相同的機器上這是個空操作,否則將是一個 2 字節的交換操作
socket.htonl(x) 把 32 位的正整型數字從主機字節序轉換成網絡字節序,在網絡字節序和主機字節序相同的機器上這是個空操作,否則將是一個 4 字節的交換操作
socket.htons(x) 把 16 位的正整型數字從主機字節序轉換成網絡字節序,在網絡字節序和主機字節序相同的機器上這是個空操作,否則將是一個 2 字節的交換操作

你也可以使用 struct 模塊打包或者解包二進制數據(使用格式化字符串):

import struct
network_byteorder_int = struct.pack('>H', 256)
python_int = struct.unpack('>H', network_byteorder_int)[0]

結論

我們在本教程中介紹了很多內容,網絡和 socket 是很大的一個主題,如果你對它們都比較陌生,不要被這些規則和大寫字母術語嚇到

爲了理解所有的東西如何工作的,有很多部分需要了解。但是,就像 python 一樣,當你花時間去了解每個獨立的部分時它纔開始變得有意義

我們看過了 python socket 模塊中底層的一些 API,並瞭解瞭如何使用它們創建客戶端服務器應用程序。我們也創建了一個自定義類來做爲應用層的協議,並用它在不同的端點之間交換數據,你可以使用這個類並在些基礎上快速且簡單地構建出一個你自己的 socket 應用程序

你可以在 Github 上找到 源代碼

恭喜你堅持到最後!你現在就可以在程序中很好地使用 socket 了

我希望這個教程能爲你開始 socket 編程旅途中提供一些信息、示例、或者靈感

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