WebSocket 從入門到寫出開源庫

AsyncIns發表於2019-03-04

前言

我已經 2 個月沒有發文了,看到有人問: '那個專注爬蟲小奎因去哪了?',我就趕緊跳出來了。

WebSocket 從入門到寫出開源庫
另外說明一下,德瑪西亞之翼-奎因這個 ID 現在換成了 AsyncIns

我計劃在今年的夏天去北京,在去之前我需要做好技術準備,所以最近一直是在學習。我的學習方式很簡單明瞭:看文件、讀原始碼、造輪子。造輪子是我認為能讓人進步的最快、最有效的方法。

前段時間需要通過 WebSocket 爬取一些資料,網上文章介紹中,都是使用了 websocket-client 這個庫。但我的專案是非同步的,我希望 websocket 資料讀取也能夠是非同步的,然後我在 github 上搜尋到了 websockets 這個庫,在使用和原始碼閱讀中,我發現 websockets 仍然不是我認為理想的庫,所以我決定自己開發一個非同步的 WebSocket 連線客戶端(async websocket client)。

這一次我就跟大家分享 WebSocket 協議知識以及介紹我的開源庫 aiowebsocket。

WebSocket 協議和知識

WebSocket是一種在單個TCP連線上進行全雙工通訊的協議。WebSocket通訊協議於2011年被IETF定為標準RFC 6455,並由RFC7936補充規範。WebSocket API也被W3C定為標準。

WebSocket使得客戶端和伺服器之間的資料交換變得更加簡單,允許服務端主動向客戶端推送資料。在WebSocket API中,瀏覽器和伺服器只需要完成一次握手,兩者之間就直接可以建立永續性的連線,並進行雙向資料傳輸。

為什麼會有 WebSocket

以前,很多網站為了實現推送技術,所用的技術都是輪詢。輪詢是在特定的的時間間隔(如每1秒),由瀏覽器對伺服器發出HTTP請求,然後由伺服器返回最新的資料給客戶端的瀏覽器。這種傳統的模式帶來很明顯的缺點,即瀏覽器需要不斷的向伺服器發出請求,然而HTTP請求可能包含較長的頭部,其中真正有效的資料可能只是很小的一部分,顯然這樣會浪費很多的頻寬等資源。 而比較新的技術去做輪詢的效果是Comet。這種技術雖然可以雙向通訊,但依然需要反覆發出請求。而且在Comet中,普遍採用的長連結,也會消耗伺服器資源。 在這種情況下,HTML5定義了WebSocket協議,能更好的節省伺服器資源和頻寬,並且能夠更實時地進行通訊。

WebSocket 有什麼優點

開銷少、時時性高、二進位制支援完善、支援擴充套件、壓縮更優。

  • 較少的控制開銷。在連線建立後,伺服器和客戶端之間交換資料時,用於協議控制的資料包頭部相對較小。在不包含擴充套件的情況下,對於伺服器到客戶端的內容,此頭部大小隻有2至10位元組(和資料包長度有關);對於客戶端到伺服器的內容,此頭部還需要加上額外的4位元組的掩碼。相對於HTTP請求每次都要攜帶完整的頭部,此項開銷顯著減少了。
  • 更強的實時性。由於協議是全雙工的,所以伺服器可以隨時主動給客戶端下發資料。相對於HTTP請求需要等待客戶端發起請求服務端才能響應,延遲明顯更少;即使是和Comet等類似的長輪詢比較,其也能在短時間內更多次地傳遞資料。 保持連線狀態。與HTTP不同的是,Websocket需要先建立連線,這就使得其成為一種有* 狀態的協議,之後通訊時可以省略部分狀態資訊。而HTTP請求可能需要在每個請求都攜帶狀態資訊(如身份認證等)。
  • 更好的二進位制支援。Websocket定義了二進位制幀,相對HTTP,可以更輕鬆地處理二進位制內容。
  • 可以支援擴充套件。Websocket定義了擴充套件,使用者可以擴充套件協議、實現部分自定義的子協議。如部分瀏覽器支援壓縮等。
  • 更好的壓縮效果。相對於HTTP壓縮,Websocket在適當的擴充套件支援下,可以沿用之前內容的上下文,在傳遞類似的資料時,可以顯著地提高壓縮率。

握手是怎麼回事?

WebSocket 是獨立的、建立在 TCP 上的協議。

Websocket 通過HTTP/1.1 協議的101狀態碼進行握手。

為了建立Websocket連線,需要通過瀏覽器發出請求,之後伺服器進行回應,這個過程通常稱為“握手”(handshaking)。

WebSocket 協議規範

WebSocket 是一個通訊協議,它規定了一些規範和標準。它的協議標準為 RFC 6455,具體的協議內容可以在tools.ietf.org中檢視。

協議共有 14 個部分,其中包括協議背景與介紹、握手、設計理念、術語約定、雙端要求、掩碼以及連線關閉等內容。

雙端互動流程

客戶端與服務端互動流程如下所示:

客戶端 - 發起握手請求 - 伺服器接到請求後返回資訊 - 連線建立成功 - 訊息互通

所以,要解決的第一個問題就是握手問題。

握手 - 客戶端

關於握手標準,在協議中有說明:

The opening handshake is intended to be compatible with HTTP-based server-side software and intermediaries, so that a single port can be used by both HTTP clients talking to that server and WebSocket clients talking to that server. To this end, the WebSocket client's handshake is an HTTP Upgrade request:

    GET /chat HTTP/1.1
    Host: server.example.com
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    Origin: http://example.com
    Sec-WebSocket-Protocol: chat, superchat
    Sec-WebSocket-Version: 13
複製程式碼

In compliance with [RFC2616], header fields in the handshake may be sent by the client in any order, so the order in which different header fields are received is not significant.

WebSocket 握手時使用的並不是 WebSocket 協議,而是 HTTP 協議,握手時發出的請求可以叫做升級請求。客戶端在握手階段通過:

Upgrade: websocket
Connection: Upgrade
複製程式碼

Connection 和 Upgrade 這兩個頭域告知服務端,要求將通訊的協議轉換為 websocket。其中 Sec-WebSocket-Version、Sec-WebSocket-Protocol 這兩個頭域表明通訊版本和協議約定, Sec-WebSocket-Key 則作為一個防止無端連線的保障(其實並沒有什麼保障作用,因為 key 的值完全由客戶端控制,服務端並無驗證機制),其他幾個頭域則與 HTTP 協議的作用一致。

握手 - 服務端

剛才只是客戶端發出一個 HTTP 請求,表明想要握手,服務端需要對資訊進行驗證,確認以後才算握手成功(連線建立成功,可以雙向通訊),然後服務端會給客戶端回覆:"小老弟你好,沒有內鬼,連線達成!"

服務端需要回復什麼內容呢?

Status Code: 101 Web Socket Protocol Handshake
Sec-WebSocket-Accept: T5ar3gbl3rZJcRmEmBT8vxKjdDo=
Upgrade: websocket
Connection: Upgrade
複製程式碼

首先,服務端會給出狀態碼,101 狀態碼錶示伺服器已經理解了客戶端的請求,並且回覆 Connection 和 Upgrade 表示已經切換成 websocket 協議。Sec-WebSocket-Accept 則是經過伺服器確認,並且加密過後的 Sec-WebSocket-Key。

這樣,客戶端與服務端就完成了握手操作,達成一致,使用 WebSocket 協議進行通訊。

你來我往 - 資料交流

雙方握手成功並確認協議後,就可以互相傳送資訊了。它們的資訊是如何傳送的呢?難道是:

client: Hello, server boy

server: Hello, client girl
複製程式碼

跟我們在微信和 QQ 中發資訊是一樣的嗎?

雖然我們看到的資訊是這樣的,但是在傳輸過程中可不是這樣子的。傳輸這部也有相應的規定:

In the WebSocket Protocol, data is transmitted using a sequence of frames. To avoid confusing network intermediaries (such as intercepting proxies) and for security reasons that are further discussed in Section 10.3, a client MUST mask all frames that it sends to the server (see Section 5.3 for further details). (Note that masking is done whether or not the WebSocket Protocol is running over TLS.) The server MUST close the connection upon receiving a frame that is not masked. In this case, a server MAY send a Close frame with a status code of 1002 (protocol error) as defined in Section 7.4.1. A server MUST NOT mask any frames that it sends to the client. A client MUST close a connection if it detects a masked frame. In this case, it MAY use the status code 1002 (protocol error) as defined in Section 7.4.1. (These rules might be relaxed in a future specification.)

The base framing protocol defines a frame type with an opcode, a payload length, and designated locations for "Extension data" and "Application data", which together define the "Payload data". Certain bits and opcodes are reserved for future expansion of the protocol.

協議中規定傳輸時並不是直接使用 unicode 編碼進行傳輸,而是使用幀(frame),資料幀協議定義了帶有操作碼的幀型別,有效載荷長度,以及“擴充套件資料”和的指定位置應用程式資料”,它們共同定義“有效載荷資料”。某些位和操作碼保留用於將來的擴充套件協議。

資料幀的格式如圖所示:

WebSocket 從入門到寫出開源庫

幀由以下幾部分組成: FIN、RSV1、RSV2、RSV3、opcode、MASK、Payload length、Masking-key、Payload-Data。它們的含義和作用如下:

1.FIN: 佔 1bit

0:不是訊息的最後一個分片

1:是訊息的最後一個分片
複製程式碼

2.RSV1, RSV2, RSV3:各佔 1bit

一般情況下全為 0。當客戶端、服務端協商採用 WebSocket 擴充套件時,這三個標誌位可以非 0,且值的含義由擴充套件進行定義。如果出現非零的值,且並沒有採用 WebSocket 擴充套件,連線出錯。

3.Opcode: 4bit

%x0:表示一個延續幀。當 Opcode 為 0 時,表示本次資料傳輸採用了資料分片,當前收到的資料幀為其中一個資料分片;

%x1:表示這是一個文字幀(text frame);

%x2:表示這是一個二進位制幀(binary frame);

%x3-7:保留的操作程式碼,用於後續定義的非控制幀;

%x8:表示連線斷開;

%x9:表示這是一個心跳請求(ping);

%xA:表示這是一個心跳響應(pong);

%xB-F:保留的操作程式碼,用於後續定義的控制幀。
複製程式碼

4.Mask: 1bit

表示是否要對資料載荷進行掩碼異或操作。

0:否

1:是
複製程式碼

5.Payload length: 7bit or (7 + 16)bit or (7 + 64)bit

表示資料載荷的長度。

0~126:資料的長度等於該值;

126:後續 2 個位元組代表一個 16 位的無符號整數,該無符號整數的值為資料的長度;

127:後續 8 個位元組代表一個 64 位的無符號整數(最高位為 0),該無符號整數的值為資料的長度。
複製程式碼

6.Masking-key: 0 or 4bytes

當 Mask 為 1,則攜帶了 4 位元組的 Masking-key;

當 Mask 為 0,則沒有 Masking-key。

掩碼演算法:按位做迴圈異或運算,先對該位的索引取模來獲得 Masking-key 中對應的值 x,然後對該位與 x 做異或,從而得到真實的 byte 資料。
複製程式碼

注意:掩碼的作用並不是為了防止資料洩密,而是為了防止早期版本的協議中存在的代理快取汙染攻擊(proxy cache poisoning attacks)等問題。

7.Payload Data: 載荷資料

雙端接收到數幀之後,就可以根據資料幀各個位置的值進行處理或資訊提取。

掩碼

這裡要注意的是從客戶端向服務端傳送資料時,需要對資料進行掩碼操作;從服務端向客戶端傳送資料時,不需要對資料進行掩碼操作。如果服務端接收到的資料沒有進行過掩碼操作,服務端需要斷開連線。如果Mask是1,那麼在Masking-key中會定義一個掩碼鍵(masking key),並用這個掩碼鍵來對資料載荷進行反掩碼。所有客戶端傳送到服務端的資料幀,Mask都是1。

保持連線

剛才提到 WebSocket 協議是雙向通訊的,那麼一旦連線上,就不會斷開了嗎?

事實上確實是這樣,但是服務端不可能讓所有的連線都一直保持,所以服務端通常會在一個定期的時間給客戶端傳送一個 ping 幀,而客戶端收到 Ping 幀後則回覆一個 Pong 幀,如果客戶端不響應,那麼服務端就會主動斷開連線。

opcode 幀為 0x09 則代表這是一個 Ping ,為 0x0A 則代表這是一個 Pong。

WebSocket 協議學習小結

WebSocket 的協議寫得比較規範,比較容易閱讀和理解。只要遵循協議中的規定,就可以實現穩定的通訊連線和資料傳輸。

aiowebsocket 設計

基於對協議的學習,我編了一個開源的非同步 WebSocket 庫 - aiowebsocket,它的檔案結構和類的設計如下圖所示:

WebSocket 從入門到寫出開源庫

aiowebsocket

aiowebsocket 是一個比同型別庫更快、更輕、更靈活的 WebSocket 客戶端,它基於 asyncio 開並具備了與 websocket-client 和 websockets 庫簡單易用的特點。這是我用 7 天時間學習 WebSocket 知識以及 Python 文件 Stream 知識的成果。

WebSocket 從入門到寫出開源庫

安裝與使用

安裝:跟其他庫一樣,你可以通過 pip 進行安裝:pip install aiowebsocket,也可以在 github 上 clone 到本地使用。

使用:WebSocket 協議的簡寫是 ws,它與 http/https 類似,具有更安全的協議 wss。使用上的區別並不大,只需要在建立連線時開啟 ssl 即可。

ws 協議示例程式碼:

import asyncio
import logging
from datetime import datetime
from aiowebsocket.converses import AioWebSocket


async def startup(uri):
    async with AioWebSocket(uri) as aws:
        converse = aws.manipulator
        message = b'AioWebSocket - Async WebSocket Client'
        while True:
            await converse.send(message)
            print('{time}-Client send: {message}'
                  .format(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), message=message))
            mes = await converse.receive()
            print('{time}-Client receive: {rec}'
                  .format(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), rec=mes))


if __name__ == '__main__':
    remote = 'ws://echo.websocket.org'
    try:
        asyncio.get_event_loop().run_until_complete(startup(remote))
    except KeyboardInterrupt as exc:
        logging.info('Quit.')
複製程式碼

執行後就會得到如下結果:

2019-03-04 15:11:25-Client send: b'AioWebSocket - Async WebSocket Client'
2019-03-04 15:11:25-Client receive: b'AioWebSocket - Async WebSocket Client'
2019-03-04 15:11:25-Client send: b'AioWebSocket - Async WebSocket Client'
2019-03-04 15:11:25-Client receive: b'AioWebSocket - Async WebSocket Client'
複製程式碼

這代表客戶端與服務連線成功並正常通訊。

wss 協議示例程式碼:

# 開啟 ssl 即可
import asyncio
import logging
from datetime import datetime
from aiowebsocket.converses import AioWebSocket


async def startup(uri):
    async with AioWebSocket(uri, ssl=True) as aws:
        converse = aws.manipulator
        message = b'AioWebSocket - Async WebSocket Client'
        while True:
            await converse.send(message)
            print('{time}-Client send: {message}'
                  .format(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), message=message))
            mes = await converse.receive()
            print('{time}-Client receive: {rec}'
                  .format(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), rec=mes))


if __name__ == '__main__':
    remote = 'wss://echo.websocket.org'
    try:
        asyncio.get_event_loop().run_until_complete(startup(remote))
    except KeyboardInterrupt as exc:
        logging.info('Quit.')
複製程式碼

執行結果與上方執行結果類似。除此之外,aiowebsocket 還允許自定義請求頭,在連線一些需要校驗 origin、user-agent 和 host 頭域資訊的網站時,自定義請求頭就非常有用了:

import asyncio
import logging
from datetime import datetime
from aiowebsocket.converses import AioWebSocket


async def startup(uri, header):
    async with AioWebSocket(uri, headers=header) as aws:
        converse = aws.manipulator
        message = b'AioWebSocket - Async WebSocket Client'
        while True:
            await converse.send(message)
            print('{time}-Client send: {message}'
                  .format(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), message=message))
            mes = await converse.receive()
            print('{time}-Client receive: {rec}'
                  .format(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), rec=mes))


if __name__ == '__main__':
    remote = 'ws://123.207.167.163:9010/ajaxchattest'
    header = [
        'GET /ajaxchattest HTTP/1.1',
        'Connection: Upgrade',
        'Host: 123.207.167.163:9010',
        'Origin: http://coolaf.com',
        'Sec-WebSocket-Key: RmDgZzaqqvC4hGlWBsEmwQ==',
        'Sec-WebSocket-Version: 13',
        'Upgrade: websocket',
        ]
    try:
        asyncio.get_event_loop().run_until_complete(startup(remote, header))
    except KeyboardInterrupt as exc:
        logging.info('Quit.')

複製程式碼

ws://123.207.167.163:9010/ajaxchattest 是一個免費的、開放的 WebSocket 連線測試介面,它在握手階段會校驗 origin 頭域,如果不符合規範則不允許客戶端連線。

專案 Github 地址為

https://github.com/asyncins/aiowebsocket

歡迎各位前去 star ,如果能給出建議或者發現 bug 那就更美了。

相關文章