WebSocket 從入門到寫出開源庫

前言

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

另外說明一下,德瑪西亞之翼-奎因這個 ID 如今換成了 AsyncIns

我計劃在今年的夏天去北京,在去以前我須要作好技術準備,因此最近一直是在學習。個人學習方式很簡單明瞭:看文檔、讀源碼、造輪子。造輪子是我認爲能讓人進步的最快、最有效的方法。git

前段時間須要經過 WebSocket 爬取一些數據,網上文章介紹中,都是使用了 websocket-client 這個庫。但個人項目是異步的,我但願 websocket 數據讀取也可以是異步的,而後我在 github 上搜索到了 websockets 這個庫,在使用和源碼閱讀中,我發現 websockets 仍然不是我認爲理想的庫,因此我決定本身開發一個異步的 WebSocket 鏈接客戶端(async websocket client)。github

這一次我就跟你們分享 WebSocket 協議知識以及介紹個人開源庫 aiowebsocket。web

WebSocket 協議和知識

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

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狀態碼進行握手。bash

爲了建立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),數據幀協議定義了帶有操做碼的幀類型,有效載荷長度,以及「擴展數據」和的指定位置應用程序數據」,它們共同定義「有效載荷數據」。某些位和操做碼保留用於未來的擴展協議。

數據幀的格式如圖所示:

幀由如下幾部分組成: FIN、RSV一、RSV二、RSV三、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,它的文件結構和類的設計以下圖所示:

aiowebsocket

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

安裝與使用

安裝:跟其餘庫同樣,你能夠經過 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 那就更美了。

相關文章
相關標籤/搜索