1. 程式人生 > 程式設計 >[連載 1] 如何將協議規範變成開源庫系列文章之 WebSocket

[連載 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,領域涵蓋爬蟲、深度學習、服務研發和物件儲存等。團隊非正亦非邪,只做認為對的事情,請大家小心。