[連載 1] 如何將協議規範變成開源庫系列文章之 WebSocket

這是系列文章的第一篇,也是非常重要的一篇,希望大家能讀懂我想要表達的意思。

系列文章開篇概述

相對於其他編程語言來說,Python 生態中最突出的就是第三方庫。任何一個及格的 Python 開發者都使用過至少 5 款第三方庫。

就爬蟲領域而言,必將用到的例如網絡請求庫 Requests、網頁解析庫 Parsel 或 BeautifulSoup、數據庫對象關係映射 Motor 或 SQLAlchemy、定時任務 Apscheduler、爬蟲框架 Scrapy 等。

這些開源庫的使用方法想必大家已經非常熟練了,甚至還修煉出了自己的一套技巧,日常工作中敲起鍵盤肯定也是噠噠噠的響。

但是你有沒有想過:

  • 那個神奇的功能是如何實現的?
  • 這個功能背後的邏輯是什麼?
  • 爲什麼要這樣做而不是選擇另一種寫法?
  • 編寫這樣的庫需要用到哪些知識?
  • 這個論點是否有明確的依據?


如果你從未這樣想過,那說明你還沒到達應該「渡劫」的時機;如果你曾提出過 3 個以上的疑問,那說明你即將到達那個重要的關口;如果你常常這麼想,而且也嘗試着尋找對應的答案,那麼恭喜你,你現在正處於「渡劫」的關口之上。


偶有羣友會拋出這樣的問題:初級工程師、中級工程師、高級工程師如何界定?

這個問題有兩種不同的觀點,第一個是看工作職級,第二個則是看個人能力。工作職級是一個浮動很大的參照物,例如阿里巴巴的高級研發和我司的高級研發,職級名稱都是「高級研發」,但能力可能會有很大的差距。

個人能力又如何評定呢?

難不成看代碼寫的快還是寫的慢嗎?

當然不是!

個人能力應當從廣度和深度兩個方面進行考量,這並沒有一個明確的標準。當兩人能力差異很大的時候,外人可以輕鬆的分辨孰強孰弱。

自己怎樣分辨個人能力的進與退呢?

這就回到了上面提到的那些問題:WHO WHAT WHERE WHY WHEN HOW?

我想通過這篇文章告訴你,不要做那個用庫用得很熟練的人,要做那個創造庫的人。計算機世界如此吸引人,就是因爲我們可以在這個世界裏盡情創造。

你想做一個創造者嗎?

如果不想,那現在你就可以關掉瀏覽器窗口,回到 Hub 的世界裏。

內容介紹

這是一套系列文章,這個系列將爲大家解讀常見庫(例如 WebSocket、HTTP、ASCII、Base64、MD5、AES、RSA)的協議規範和對應的代碼實現,幫助大家「知其然,知其所以然」。

目標

這次我們要學習的是 WebSocket 協議規範和代碼實現,也可以理解爲從 0 開始編寫 aiowebsocket 庫。至於爲什麼選擇它,那大概是因爲全世界沒有比我更熟悉的它的人了。

我是 aiowebsocket 庫的作者,我花了 7 天編寫這個庫。寫庫的過程,讓我深刻體會到造輪子和駕駛的區別,也讓我有了飛速的進步。我希望用連載系列文章的形式幫助大家從駕駛者轉換到創造者,擁有「編程思考」。

前置條件

WebSocket 是一種在單個 TCP 連接上進行全雙工通信的協議,它的出現使客戶端和服務器之間的數據交換變得更加簡單。下圖描述了雙端交互的流程:

WebSocket 通常被應用在實時性要求較高的場景,例如賽事數據、股票證券、網頁聊天和在線繪圖等。WebSocket 與 HTTP 協議完全不同,但同樣被廣泛應用。

無論是後端開發者、前端開發者、爬蟲工程師或者信息安全工作者,都應該掌握 WebSocket 協議的知識。

我曾經發表過幾篇關於 WebSocket 的文章:

其中,《【嚴選-高質量文章】開發者必知必會的 WebSocket 協議》介紹了協議規範的相關知識。這篇文章的內容大體如下:

  • WebSocket 協議來源
  • WebSocket 協議的優點
  • WebSocket 協議規範
  • 一些實際代碼演示

如果沒有掌握 WebSocket 協議的朋友,我建議先去閱讀這篇文章,尤其是對 WebSocket 協議規範介紹的那部分。

要想將協議規範 RFC6455 變成開源庫,第一步就是要熟悉整個協議規範,所以你需要閱讀【嚴選-高質量文章】開發者必知必會的 WebSocket 協議。當然,有能力的同學直接閱讀 RFC6455 也未嘗不可。

接着還需要了解編程語言中內置庫 Socket 的基礎用法,例如 Python 中的 socket 或者更高級更潮的 StreamsTransports and Protocols。如果你是 Go 開發者、Rust 開發者,請查找對應語言的內置庫。

假設你已經熟悉了 RFC6455,你應該知道 Frame 打包和解包的時候需要用到位運算,正好我之前寫過位運算相關的文章 7分鐘全面瞭解位運算

至於其它的,現用現學吧!

Python 網絡通信之 Streams

WebSocket,也可以理解爲在 WEB 應用中使用的 Socket,這意味着本篇將會涉及到 Socket 編程。上面提到,Python 中與 Socket 相關的有 socket、Streams、Transports and Protocols。其中 socket 是同步的,而另外兩個是異步的,這倆屬於你常聽到的 asyncio。

Socket 通信過程

Socket 是端到端的通信,所以我們要搞清楚消息是怎麼從一臺機器發送到另一臺機器的,這很重要。假設通信的兩臺機器爲 Client 和 Server,Client 向 Server 發送消息的過程如下圖所示:

Client 通過文件描述符的讀寫 API read & write 來訪問操作系統內核中的網絡模塊爲當前套接字分配的發送 send buffer 和接收 recv buffer 緩存。

Client 進程寫消息到內核的發送緩存中,內核將發送緩存中的數據傳送到物理硬件 NIC,也就是網絡接口芯片 (Network Interface Circuit)。

NIC 負責將翻譯出來的模擬信號通過網絡硬件傳遞到服務器硬件的 NIC。

服務器的 NIC 再將模擬信號轉成字節數據存放到內核爲套接字分配的接收緩存中,最終服務器進程從接收緩存中讀取數據即爲源客戶端進程傳遞過來的 消息。

上述通信過程的描述和圖片均出自錢文品的深入理解 RPC 交互流程。

我嘗試尋找通信過程中每個步驟的依據(尤其是 send buffer to NIC to recv buffer),(我翻閱了 TCP 的 RFC 和 Kernel.org)但遺憾的是並未找到有力的證明(一定是我太菜了),如果有朋友知道,可以評論告訴我或發郵件 [email protected] 告訴我,我可以擴展出另一篇文章。

創建 Streams

那麼問題來了:在 Python 中,我們如何實現端到端的消息發送呢?

答:Python 提供了一些對象幫助我們實現這個需求,其中相對簡單易用的是 Streams。

Streams 是 Python Asynchronous I/O 中提供的 High-level APIs。Python 官方文檔對 Streams 的介紹如下:

Streams are high-level async/await-ready primitives to work with network connections. Streams allow sending and receiving data without using callbacks or low-level protocols and transports.

我尬譯一下:Streams 是用於網絡連接的 high-level async/await-ready 原語。Streams 允許在不使用回調或 low-level protocols and transports 的情況下發送和接收數據。

Python 提供了 asyncio.open_connection() 讓開發者創建 Streams,asyncio.open_connection() 將建立網絡連接並返回 reader 和 writer 對象,這兩個對象其實是 StreamReader 和 StreamWriter 類的實例。

開發者可以通過 StreamReader 從 IO 流中讀取數據,通過 StreamWriter 將數據寫入 IO 流。雖然文檔並沒有給出 IO 流的明確定義,但我猜它跟 buffer (也就是 send buffer to NIC to recv buffer 中的 buffer)有關,你也可以抽象的認爲它就是 buffer。

有了 Streams,就有了端到端消息發送的完整實現。下面將通過一個例子來熟悉 Streams 的用法和用途。這是 Python 官方文檔給出的雙端示例,首先是 Server 端:

# TCP echo server using streams
# 本文出自「夜幕團隊 NightTeam」 轉載請聯繫並取得授權
import asyncio

async def handle_echo(reader, writer):
    data = await reader.read(100)
    message = data.decode()
    addr = writer.get_extra_info('peername')

    print(f"Received {message!r} from {addr!r}")

    print(f"Send: {message!r}")
    writer.write(data)
    await writer.drain()

    print("Close the connection")
    writer.close()

async def main():
    server = await asyncio.start_server(
        handle_echo, '127.0.0.1', 8888)

    addr = server.sockets[0].getsockname()
    print(f'Serving on {addr}')

    async with server:
        await server.serve_forever()

asyncio.run(main())

接着是 Client 端:

# TCP echo client using streams
# 本文出自「夜幕團隊 NightTeam」 轉載請聯繫並取得授權
import asyncio

async def tcp_echo_client(message):
    reader, writer = await asyncio.open_connection(
        '127.0.0.1', 8888)

    print(f'Send: {message!r}')
    writer.write(message.encode())

    data = await reader.read(100)
    print(f'Received: {data.decode()!r}')

    print('Close the connection')
    writer.close()

asyncio.run(tcp_echo_client('Hello World!'))

將示例分別寫入到 server.py 和 client.py 中,然後按序運行。此時 server.py 的窗口會輸出如下內容:

Serving on ('127.0.0.1', 8888)
Received 'Hello World!' from ('127.0.0.1', 59534)
Send: 'Hello World!'
Close the connection

從輸出中得知,服務啓動的 address 和 port 爲 ('127.0.0.1', 8888),從 ('127.0.0.1', 59534) 讀取到內容爲 Hello World! 的消息,接着將 Hello World! 返回給 ('127.0.0.1', 59534) ,最後關閉連接。

client.py 的窗口輸出內容如下:

Send: 'Hello World!'
Received: 'Hello World!'
Close the connection

在創建連接後,Client 向指定的端發送了內容爲 Hello World! 的消息,接着從指定的端接收到內容爲 Hello World! 的消息,最後關閉連接。

有些讀者可能不太理解,爲什麼 Client Send Hello World! ,而 Server 接收到之後也向 Client Send Hello World! 。雙端的 Send 和 Received 都是 Hello World! ,這很容易讓新手懵逼。實際上這就是一個普通的回顯服務器示例,也就是說當 Server 收到消息時,將消息內容原封不動的返回給 Client。

這樣只是爲了演示,並無它意,但這樣的示例卻會給新手帶來困擾。

以上是一個簡單的 Socket 編程示例,整體思路理解起來還是很輕鬆的,接下來我們將逐步解讀示例中的代碼:

* client.py 中用 `asyncio.open_connection()` 連接指定的端,並獲得 reader 和 writer 這兩個對象。
* 然後使用 writer 對象中的 `write()` 方法將 `Hello World!` 寫入到 IO 流中,該消息會被髮送到 Server。
* 接着使用 reader 對象中的 `read()` 方法從 IO 流中讀取消息,並將消息打印到終端。

看到這裏,你或許會有另一個疑問:write() 只是將消息寫入到 IO 流,並沒有發送行爲,那消息是如何傳輸到 Server 的呢?

由於無法直接跟進 CPython 源代碼,所以我們無法得到確切的結果。但我們可以跟進 Python 代碼,得知消息最後傳輸到 transport.write() ,如果你想知道更多,可以去看 Transports and Protocols 的介紹。你可以將這個過程抽象爲上圖的 Client to send buffer to NIC to recv buffer to Server。

功能模塊設計

通過上面的學習,現在你已經掌握了 WebSocket 協議規範和 Python Streams 的基本用法,接下來就可以設計一個 WebSocket 客戶端庫了。

根據 RFC6455 的約定,WebSocket 之前是 HTTP,通過「握手」來升級協議。協議升級後進入真正的 WebSocket 通信,通信包含發送(Send)和接收(Recv)。文本消息要在傳輸過程前轉換爲 Frames,而接受端讀取到消息後要將 Frames 轉換成文本。當然,期間會有一些異常產生,我們可能需要自定義異常,以快速定位問題所在。現在我們得出了幾個模塊:

* 握手 - ShakeHands

* 傳輸 - Transports

* 幀處理 - Frames

* 異常 - Exceptions

一切準備就緒後,就可以進入真正的編碼環節了。

由於實戰編碼篇幅太長,我決定放到下一期,這期的內容,讀者們可能需要花費一些時間吸收。

小結

開篇我強調了「創造能力」有多麼重要,甚至拋出了一些不是很貼切的例子,但我就是想告訴你,不要做調參🐶。

然後我告訴你,本篇文章要講解的是 WebSocket。

接着又跟你說,要掌握 WebSocket 協議,如果你無法獨立啃完 RFC6455,還可以看我寫過的幾篇關於 WebSocket 文章和位運算文章。

過了幾分鐘,給你展示了 Socket 的通信過程,雖然沒有強有力的依據,但你可以假設這是對的。

喝了一杯白開水之後,我向你展示了 Streams 的具體用法併爲你解讀代碼的作用,重要的是將 Streams 與 Socket 通信過程進行了抽象。

這些前置條件都確定後,我又帶着你草草地設計了 WebSocket 客戶端的功能模塊。

下一篇文章將進入代碼實戰環節,請做好環境(Python 3.6+)準備。

總之,要想越過前面這座山,就請跟我來!


文章作者:「夜幕團隊 NightTeam 」- 韋世東

夜幕團隊成立於 2019 年,團隊成員包括崔慶才、周子淇、陳祥安、唐軼飛、馮威、蔡晉、戴煌金、張冶青和韋世東。

涉獵的主要編程語言爲 Python、Rust、C++、Go,領域涵蓋爬蟲、深度學習、服務研發和對象存儲等。團隊非正亦非邪,只做認爲對的事情,請大家小心。

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