【嚴選-高質量文章】開發者必知必會的 WebSocket 協議

夜幕鎮嶽丨韋世東發表於2019-08-09

文章介紹

關於 WebSocket,我之前也寫過了兩篇文章進行介紹:《WebSocket 從入門到寫出開源庫》和《Python如何爬取實時變化的WebSocket資料》。今天這篇文章,大體上與之前的文章內容結構相似。但質量更進一步,適合想要完全掌握 WebSocket 協議的朋友,因此特來掘金分享給大家。

WebSocket 是一種在單個 TCP 連線上進行全雙工通訊的協議,它的出現使客戶端和伺服器之間的資料交換變得更加簡單。WebSocket 通常被應用在實時性要求較高的場景,例如賽事資料、股票證券、網頁聊天和線上繪圖等。

WebSocekt 與 HTTP 協議完全不同,但同樣被廣泛應用。無論是後端開發者、前端開發者、爬蟲工程師或者資訊保安工作者,都應該掌握 WebSocekt 協議的知識。

在本篇文章中,你將收穫如下知識:

  • 讀懂 WebSocket 協議規範文件 RFC6455
  • WebSocket 與 HTTP 的關係
  • 資料幀格式及欄位含義
  • 客戶端與服務端互動流程
  • 客戶端與服務端如何保持連線
  • 何時斷開連線

本篇文章適用於網際網路領域的開發者和產品經理


開始

WebSocket 是一種在單個 TCP 連線上進行全雙工通訊的協議。WebSocket 通訊協議於 2011 年被 IETF 定為標準 RFC6455,並由 RFC7936 補充規範。看到這裡,很多讀者會有疑問:什麼是 RFC?

RFC 是一系列以編號排定的檔案,它由一系列草案和標準組成。幾乎所有網際網路通訊協議均記錄在 RFC 中,例如 HTTP 協議標準、本篇介紹的 WebSocket 協議標準、Base64 編碼規範等。除此之外,RFC 還加入了許多論題。在本篇 Chat 中,我們對 WebSocekt 的學習和討論將基於 RFC6455

WebSocket 協議的來源

在 WebSocket 協議出現以前,網站通常使用輪詢來實現類似“資料實時更新”這樣的效果。要注意的是,這裡的“資料實時更新”是帶有引號的,這表示並不是真正意義上的資料實時更新。輪詢指的是在特定的時間間隔內,由客戶端主動向服務端發起 HTTP 請求,以確認是否有新資料的行為。下圖描述了輪詢的過程:

【嚴選-高質量文章】開發者必知必會的 WebSocket 協議

首先,客戶端會向服務端發出一個 HTTP 請求,這個請求的意圖就是向伺服器詢問“大哥,有新資料嗎?”。伺服器在接收到請求後,根據實際情況(有資料或無資料)做出響應:

  • 有資料,我發給你;
  • 無資料,你待會再問;

這種一問一答的方式有著明顯的缺點,即瀏覽器需要不斷的向伺服器發出請求。由於 HTTP 請求包含較長的頭部資訊(例如 User-Agent、Referer 和 Host 等),其中真正有效的資料可能只是很小的一部分,所以這樣會浪費很多的頻寬資源。

比輪詢更好的“資料實時更新”手段是 Comet。這種技術可以實現雙向通訊,但依然需要反覆發出請求。而且在 Comet 中,採用的是 HTTP 長連線,這同樣會消耗伺服器資源。在這種情況下,HTML5 定義了更節省資源,且能夠讓雙端穩定實時通訊的 WebSocket 協議。在 WebSocket 協議下,客戶端和服務端只需要完成一次握手,就直接可以建立永續性的連線,並進行雙向資料傳輸。下圖描述了 WebSocket 協議中,雙端通訊的過程:

【嚴選-高質量文章】開發者必知必會的 WebSocket 協議

WebSocket 的優點

相對於 HTTP 協議來說,WebSocket 具有開銷少、實時性高、支援二進位制訊息傳輸、支援擴充套件和更好的壓縮等優點。這些優點如下所述:

較少的開銷

WebSocket 只需要一次握手,在每次傳輸資料時只傳輸資料幀即可。而 HTTP 協議下,每次請求都需要攜帶完整的請求頭資訊,例如 User-Agent、Referer 和 Host 等。所以 WebSocket 的開銷相對於 HTTP 來說會少很多。

更強的實時性

由於協議是全雙工的,所以伺服器可以隨時主動給客戶端下發資料。相對於一問一答的 HTTP 來說,WebSocket 協議下的資料傳輸的延遲明顯更少。

支援二進位制訊息傳輸

WebSocket 定義了二進位制幀,可以更輕鬆地處理二進位制內容。

支援擴充套件

開發者可以擴充套件協議,或者實現部分自定義的子協議。

更好的壓縮

Websocket 在適當的擴充套件支援下,可以沿用之前內容的上下文。這樣在傳遞類似結構的資料時,可以顯著地提高壓縮率。

WebSocket 協議規範

WebSocket 是一個通訊協議,該協議的規範與標準均記錄在 RFC6455 中。協議共有 14 個部分,但與協議規範相關的只有 11 個部分:

  1. 介紹
  2. 術語和其他約定
  3. WebSocket URI
  4. 握手規範
  5. 資料幀
  6. 傳送和接收資料
  7. 關閉連線
  8. 錯誤處理
  9. 擴充套件
  10. 通訊安全
  11. 注意事項

而與本篇 Chat 相關的為 4、5、6、7 部分的內容,這些也是 WebSocket 中較為重要的內容。接下來,我們就來學習這些知識。

雙端互動流程

客戶端與服務端連線成功之前,使用的通訊協議是 HTTP。連線成功後,使用的才是 WebSocket 協議。下圖描述了雙端互動的流程:

【嚴選-高質量文章】開發者必知必會的 WebSocket 協議

首先,客戶端向服務端發出一個 HTTP 請求,請求中攜帶了服務端規定的資訊,並在資訊中表明希望將協議升級為 WebSocket。這個請求被稱為升級請求,雙端升級協議的整個過程叫做握手。然後服務端驗證客戶端傳送的資訊,如果符合規範則將協議替換成 WebSocket,並將升級成功的資訊響應給客戶端。最後,雙方就可以基於 WebSocket 協議互相推送資訊了。現在,我們需要學習的第一個知識點就是握手。

雙端握手

我們先來看看 RFC6455 對客戶端握手的規定,原文錨點連結為 Opening Handshak。此段原文如下:

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.
複製程式碼

原文表明,握手時使用的並不是 WebSocekt 協議,而是 HTTP 協議,握手時發出的請求叫做升級請求。客戶端在握手階段通過 ConnectionUpgrade 頭域及對應的值告知服務端,要求將當前通訊協議升級為指定協議,此處指定的是 WebSocket 協議。其他頭域名及值的作用如下:

  • GET /chat HTTP/1.1 表明本次請求基於 HTTP/1.1,請求方式為 GET
  • Sec-WebSocket-Protocol 用於指定子協議;
  • Sec-WebSocket-Version 表明協議版本,要求雙端版本一致。當前 WebSocekt 協議版本預設為 13
  • Origin 表明請求來自於哪個站點;
  • Host 表明目標主機;
  • Sec-WebSocket-Key 用於防止攻擊者惡意欺騙服務端;

也就是說,握手時客戶端只需要按照上述規定向服務端發出一個 HTTP 請求即可。

服務端收到客戶端發起的請求後,按照 RFC6455 的約定驗證請求資訊。驗證通過就代表握手成功,此時服務端應當按照約定將以下內容響應給客戶端:

 HTTP/1.1 101 Switching Protocols
 Upgrade: websocket
 Connection: Upgrade
 Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
 Sec-WebSocket-Protocol: chat
複製程式碼

服務端會給出代表連線結果的響應狀態碼,101 狀態碼錶示表示本次請求成功且得到服務端的正確處理。ConnectionUpgrade 表示已經切換成 websocket 協議。Sec-WebSocket-Accept 則是經過伺服器確認,並且加密過後的 Sec-WebSocket-Key,這個值根據客戶端傳送的 Sec-WebSocket-Key 生成。Sec-WebSocket-Protocol 表明雙端約定的子協議。

這樣,客戶端與服務端就完成了握手操作。雙端達成一致,通訊協議將由 HTTP 協議切換成 WebSocket 協議。

傳送和接收資料

雙方握手成功,並確定協議後,就可以互相傳送資訊了。客戶端和服務端互發訊息與我們平時在社交應用中互發訊息類似,例如:

client: Hello, Server boy.

server: Hello, Client Man.
複製程式碼

當然,這裡的 Hello, Server boyHello, Client Man 是有助於我們理解的比喻。實際上,WebSocket 協議的中的資料傳輸格式並不是這樣直接呈現的。

資料幀

WebSocket 雙端傳輸的是一個個資料幀,資料幀的約定原文如下:

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.
A data frame MAY be transmitted by either the client or the server at any time after opening handshake completion and before that endpoint has sent a Close frame (Section 5.5.1).
複製程式碼

原文表明,協議中約定資料傳輸時並不是使用 Unicode 編碼,而是使用資料幀(Frame)。下圖描述了資料幀的組成:

【嚴選-高質量文章】開發者必知必會的 WebSocket 協議

資料幀由幾個部分組成:FIN、RSV1、RSV2、RSV3、opcode、MASK、Payload length、Payload Data、和 Masking-key。下面,我們來了解一下資料幀元件的大體含義或作用。

FIN

佔 1 bit,其值為 01,值對應的含義如下:

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

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

RSV1 RSV2 RSV3

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

Opcode

佔 4 bit,其值可以是 %x0%x1%x2%x3~7%x8%x9%xA%xB~F 中的任何一個。值對應的含義如下:

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

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

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

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

%x8:表示連線斷開,是一個控制幀;

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

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

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

Mask

佔 1 bit,其值為 01。值 0 表示要對資料進行掩碼異或操作,反之亦然。

Payload length

佔 7 bit 或 7+16 bit 或 7+64 bit,表示資料的長度,其值可以是0~127 中的任何一個數。值對應的含義如下:

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

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

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

掩碼

掩碼的作用並不是為了防止資料洩密,而是為了防止早期版本的協議中存在的代理快取汙染攻擊(proxy cache poisoning attacks)問題。這裡要注意的是從客戶端向服務端傳送資料時,需要對資料進行掩碼操作;從服務端向客戶端傳送資料時,不需要對資料進行掩碼操作。

如果服務端接收到的資料沒有進行過掩碼操作,服務端需要斷開連線。如果Mask是1,那麼在Masking-key中會定義一個掩碼鍵(masking key),並用這個掩碼鍵來對資料載荷進行反掩碼。

所有客戶端傳送到服務端的資料幀,Mask都是1。

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

Making-key

佔 0 或 4 bytes,其值為 01。值對應的含義如下:

0:沒有 Masking-key;
1:有 Masking-key;
複製程式碼

Payload Data

雙端接收到資料幀之後,可以根據上述幾個資料幀元件的值對 Payload Data 進行處理或直接提取資料。

資料收發流程

在瞭解到 WebSocket 傳輸的資料幀格式後,我們再來學習資料收發的流程。在雙端建立 WebSocket 連線後,任何一端都可以給另一端傳送訊息,這裡的訊息指的就是資料幀。但平時我們輸入或輸出的資訊都是“明文”,所以在訊息傳送前需要將“明文”通過一定的方法轉換成資料幀。而在接收端,拿到資料幀後需要按照一定的規則將資料幀轉換為”明文“。下圖描述了雙端收發 Hello, world 的主要流程:

【嚴選-高質量文章】開發者必知必會的 WebSocket 協議

保持連線和關閉連線

WebSocket 雙端的連線可以保持長期不斷開,但實際應用中卻不會這麼做。如果保持所有連線不斷開,但連線中有很多不活躍的成員,那麼就會造成嚴重的資源浪費。

服務端如何判斷客戶端是否活躍呢?

服務端會定期給所有的客戶端傳送一個 opcode 為 %x9 的資料幀,這個資料幀被稱為 Ping 幀。客戶端在收到 Ping 幀時,必須回覆一個 opcode 為 %xA 的資料幀(又稱為 Pong 幀),否則服務端就可以主動斷開連線。反之,如果服務端在傳送 Ping 幀後能夠得到客戶端 Pong 幀的回應,就代表這個客戶端是活躍的,不要斷開連線。

如果需要關閉連線,那麼一端向另一端傳送 opcode 為 %x8 的資料幀即可,這個資料幀被稱為關閉幀。

插個廣告

如果覺得本篇文章對你有幫助,希望你能到 GitChat 上訂閱我發表的 Chat,支援我繼續分享高質量文章。

【嚴選-高質量文章】開發者必知必會的 WebSocket 協議
GitChat 《開發者必知必會的 WebSocket 協議》

【嚴選-高質量文章】開發者必知必會的 WebSocket 協議
GitChat《MongoDB 實戰教程:資料庫與集合的 CRUD 操作篇》


實際程式碼解讀-Python

上面所述均為 RFC6455 中約定的 WebSocket 協議規範。在學習完理論知識後,我們可以通過一些示例(程式碼虛擬碼)來加深對上述知識的理解。

Echo Test 是 websocket.org 提供的一個測試平臺,開發者可以用它測試與 WebSocket 相關的連線、訊息傳送和訊息接收等功能。下面的程式碼演示也將基於 Echo Test

客戶端握手

上面提到過,客戶端向服務端發出升級請求時,請求頭如下:

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
複製程式碼

對應的 Python 程式碼如下:

import requests

url = 'http://echo.websocket.org/?encoding=text'
header = {
    "Host": "echo.websocket.org",
    "Upgrade": "websocket",
    "Connection": "Upgrade",
    "Sec-WebSocket-Key": "9GxOnSwEuBNbLeBwiltymg==",
    "Origin": "http://www.websocket.or",
    "Sec-WebSocket-Protocol": "chat, superchat",
    "Sec-WebSocket-Version": "13"
}

resp = requests.get(url, headers=header)
print(resp.status_code)
複製程式碼

程式碼執行後返回的結果為 101,這說明上方程式碼完成了升級請求的工作。

資料轉換為資料幀

資料轉換為資料幀涉及到很多知識,同時需要執行完整的 WebSocket 客戶端。本篇 Chat 不演示完整的程式碼結構,僅講解對應的程式碼邏輯。完整的 WebSocket 客戶端可在 Github 上克隆我編寫的開源的庫:aiowebsocekt

克隆到本地後開啟 freams.py ,這就是負責資料幀的轉換處理的主要檔案。

首先看 write() 方法,傳送端傳送資料時,資料會經過該方法。write() 方法的完整程式碼如下:

 async def write(self, fin, code, message, mask=True, rsv1=0, rsv2=0, rsv3=0):
        """Converting messages to data frames and sending them.
        Client data frames must be masked,so mask is True.
        """
        head1, head2 = self.pack_message(fin, code, mask, rsv1, rsv2, rsv3)
        output = io.BytesIO()
        length = len(message)
        if length < 126:
            output.write(pack('!BB', head1, head2 | length))
        elif length < 2**16:
            output.write(pack('!BBH', head1, head2 | 126, length))
        elif length < 2**64:
            output.write(pack('!BBQ', head1, head2 | 127, length))
        else:
            raise ValueError('Message is too long')

        if mask:
            # pack mask
            mask_bits = pack('!I', random.getrandbits(32))
            output.write(mask_bits)
            message = self.message_mask(message, mask_bits)

        output.write(message)
        self.writer.write(output.getvalue())
複製程式碼

首先,呼叫 pack_message() 方法構造資料幀中的 FIN、Opcode、RSV1、RSV2、RSV3。然後根據訊息的長度構造資料幀中的 Payload length。接著根據傳送端是客戶端或服務端對資料進行掩碼。最後將資料放到資料幀中,並將資料幀傳送給接收端。這裡用到的 pack_message() 方法程式碼如下:

@staticmethod
    def pack_message(fin, code, mask, rsv1=0, rsv2=0, rsv3=0):
        """Converting message into data frames
        conversion rule reference document:
        https://tools.ietf.org/html/rfc6455#section-5.2
        """
        head1 = (
                (0b10000000 if fin else 0)
                | (0b01000000 if rsv1 else 0)
                | (0b00100000 if rsv2 else 0)
                | (0b00010000 if rsv3 else 0)
                | code
        )
        head2 = 0b10000000 if mask else 0  # Whether to mask or not
        return head1, head2
複製程式碼

用於執行掩碼操作的 message_mask() 方法程式碼如下:

@staticmethod
    def message_mask(message: bytes, mask):
        if len(mask) != 4:
            raise FrameError("The 'mask' must contain 4 bytes")
        return bytes(b ^ m for b, m in zip(message, cycle(mask)))
複製程式碼

以上就是資料轉換為資料幀併傳送給接收端的主要程式碼。

資料幀轉換為資料

同樣是 freams.py 檔案,這次我們來看 read() 方法。接收端接收資料後,資料會經過該方法。read() 方法的完整程式碼如下:

    async def read(self, text=False, mask=False, maxsize=None):
        """return information about message
        """
        fin, code, rsv1, rsv2, rsv3, message = await self.unpack_frame(mask, maxsize)
        await self.extra_operation(code, message)  # 根據操作碼決定後續操作
        if any([rsv1, rsv2, rsv3]):
            logging.warning('RSV not 0')
        if not fin:
            logging.warning('Fragmented control frame:Not FIN')
        if code is DataFrames.binary.value and text:
            if isinstance(message, bytes):
                message = message.decode()
        if code is DataFrames.text.value and not text:
            if isinstance(message, str):
                message = message.encode()
        return message
複製程式碼

首先,呼叫 unpack_frame() 方法從資料幀中提取出 FIN、Opcode、RSV1、RSV2、RSV3 和 Payload Data(程式碼中是 message)。然後根據 Opcode 決定後續的操作,例如提取資料、關閉連線、傳送 Ping 幀或 Pong 幀等。

unpack_frame() 方法的完整程式碼如下:

 async def unpack_frame(self, mask=False, maxsize=None):
        reader = self.reader.readexactly
        frame_header = await reader(2)
        head1, head2 = unpack('!BB', frame_header)

        fin = True if head1 & 0b10000000 else False
        rsv1 = True if head1 & 0b01000000 else False
        rsv2 = True if head1 & 0b00100000 else False
        rsv3 = True if head1 & 0b00010000 else False
        code = head1 & 0b00001111

        if (True if head2 & 0b10000000 else False) != mask:
            raise FrameError("Incorrect masking")

        length = head2 & 0b01111111
        if length == 126:
            message = await reader(2)
            length, = unpack('!H', message)
        elif length == 127:
            message = await reader(8)
            length, = unpack('!Q', message)
        if maxsize and length > maxsize:
            raise FrameError("Message length is too long)".format(length, maxsize))
        if mask:
            mask_bits = await reader(4)
        message = self.message_mask(message, mask_bits) if mask else await reader(length)
        return fin, code, rsv1, rsv2, rsv3, message
複製程式碼

從資料幀中提取 FIN、RSV1、Opcode 和 Payload Data(程式碼中是 message) 等元件時,使用的是按位與運算。對位運算不太瞭解的朋友可以查閱我之前在微信公眾號發表的《七分鐘全面瞭解位運算》文章。接著根據是否掩碼呼叫 message_mask() 方法,最後將得到的元件返回給呼叫方。

總結

本篇 Chat 我們瞭解了 WebSocekt 協議的來源,並討論了它的優點。然後解讀 RFC6455 中對 WebSocket 的約定,瞭解到雙端互動流程、保持連線和關閉連線方面的知識。最後學習到如何將 WebSocket 協議轉換為具體的程式碼。

WebSocket 有幾個關鍵點:握手、資料與資料幀的轉換、保持連線的 Ping 幀和 Pong 幀、主動關閉連線的關閉幀。希望大家在看過本篇 Chat 後,能夠對 WebSocket 協議有一個全新的認識。

相關文章