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

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

說明

本書翻譯自 realpython 網站上的文章教程 Socket Programming in Python (Guide),因爲原文比較長,因此整理成了 Gitbook 方便閱讀前端

原做者

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

譯者注

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

由於一直從事 Web 開發,因此我認爲理解了網絡通訊及其 socket 編程就理解了 Web 開發的某些本質。過程當中我發現 Python 社區有不少我喜歡的內容,而且不少都是高質量的公開發布且開源的。git

最近我發現了這篇文章,系統地從底層網絡通訊講到了應用層協議及其 C/S 架構的應用程序,由淺入深。雖然代碼、API 使用了 Python,可是底層緣由都是相通的。很是值得一讀,推薦給你們github

另外,因爲本人水平所限,翻譯的內容不免出現誤差,若是你在閱讀的過程當中發現問題,請絕不猶豫的提醒我或者開新 PR。或者有什麼不理解的地方也能夠開 issue 討論shell

受權

本文(翻譯版)經過了 realpython 官方受權,原文版權歸其全部,任何轉載請聯繫他們數據庫

開始

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

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

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

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

全部的例子程序都使用 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 編程旅途中提供一些信息、示例、或者靈感

相關文章
相關標籤/搜索