標籤:

全雙工通信的 WebSocket

全雙工通信的 WebSocket

來自專欄大前端時代

一. WebSocket 是什麼?

WebSocket 是一種網路通信協議。在 2009 年誕生,於 2011 年被 IETF 定為標準 RFC 6455 通信標準。並由 RFC7936 補充規範。WebSocket API 也被 W3C 定為標準。

WebSocket 是 HTML5 開始提供的一種在單個 TCP 連接上進行全雙工(full-duplex)通訊的協議。沒有了 Request 和 Response 的概念,兩者地位完全平等,連接一旦建立,就建立了真?持久性連接,雙方可以隨時向對方發送數據。

(HTML5 是 HTML 最新版本,包含一些新的標籤和全新的 API。HTTP 是一種協議,目前最新版本是 HTTP/2 ,所以 WebSocket 和 HTTP 有一些交集,兩者相異的地方還是很多。兩者交集的地方在 HTTP 握手階段,握手成功後,數據就直接從 TCP 通道傳輸。)

二. 為什麼要發明 WebSocket ?

在沒有 WebSocket 之前,Web 為了實現即時通信,有以下幾種方案,最初的 polling ,到之後的 Long polling,最後的基於 streaming 方式,再到最後的 SSE,也是經歷了幾個不種的演進方式。

(1) 最開始的短輪詢 Polling 階段

這種方式下,是不適合獲取實時信息的,客戶端和伺服器之間會一直進行連接,每隔一段時間就詢問一次。客戶端會輪詢,有沒有新消息。這種方式連接數會很多,一個接受,一個發送。而且每次發送請求都會有 HTTP 的 Header,會很耗流量,也會消耗 CPU 的利用率。

這個階段可以看到,一個 Request 對應一個 Response,一來一回一來一回。

在 Web 端,短輪詢用 AJAX JSONP Polling 輪詢實現。

由於 HTTP 無法無限時長的保持連接,所以不能在伺服器和 Web 瀏覽器之間頻繁的長時間進行數據推送,所以 Web 應用通過通過頻繁的非同步 JavaScript 和 XML (AJAX) 請求來實現輪循。

  • 優點:短連接,伺服器處理簡單,支持跨域、瀏覽器兼容性較好。
  • 缺點:有一定延遲、伺服器壓力較大,浪費帶寬流量、大部分是無效請求。

(2) 改進版的長輪詢 Long polling 階段(Comet Long polling)

長輪詢是對輪詢的改進版,客戶端發送 HTTP 給伺服器之後,有沒有新消息,如果沒有新消息,就一直等待。直到有消息或者超時了,才會返回給客戶端。消息返回後,客戶端再次建立連接,如此反覆。這種做法在某種程度上減小了網路帶寬和 CPU 利用率等問題。

這種方式也有一定的弊端,實時性不高。如果是高實時的系統,肯定不會採用這種辦法。因為一個 GET 請求來回需要 2個 RTT,很可能在這段時間內,數據變化很大,客戶端拿到的數據已經延後很多了。

另外,網路帶寬低利用率的問題也沒有從根源上解決。每個 Request 都會帶相同的 Header。

對應的,Web 也有 AJAX 長輪詢,也叫 XHR 長輪詢。

客戶端打開一個到伺服器端的 AJAX 請求,然後等待響應,伺服器端需要一些特定的功能來允許請求被掛起,只要一有事件發生,伺服器端就會在掛起的請求中送迴響應並關閉該請求。客戶端在處理完伺服器返回的信息後,再次發出請求,重新建立連接,如此循環。

  • 優點:減少輪詢次數,低延遲,瀏覽器兼容性較好。
  • 缺點:伺服器需要保持大量連接。

(3) 基於流(Comet Streaming)

1. 基於 Iframe 及 htmlfile 的流(Iframe Streaming)

iframe 流方式是在頁面中插入一個隱藏的 iframe,利用其 src 屬性在伺服器和客戶端之間創建一條長鏈接,伺服器向 iframe 傳輸數據(通常是 HTML,內有負責插入信息的 JavaScript),來實時更新頁面。iframe 流方式的優點是瀏覽器兼容好。

使用 iframe 請求一個長連接有一個很明顯的不足之處:IE、Morzilla Firefox 下端的進度欄都會顯示載入沒有完成,而且 IE 上方的圖標會不停的轉動,表示載入正在進行。

Google 的天才們使用一個稱為 「htmlfile」 的 ActiveX 解決了在 IE 中的載入顯示問題,並將這種方法用到了 gmail+gtalk 產品中。Alex Russell 在 「What else is burried down in the depths of Googles amazing JavaScript?」文章中介紹了這種方法。Zeitoun 網站提供的 comet-iframe.tar.gz,封裝了一個基於 iframe 和 htmlfile 的 JavaScript comet 對象,支持 IE、Mozilla Firefox 瀏覽器,可以作為參考。

  • 優點:實現簡單,在所有支持 iframe 的瀏覽器上都可用、客戶端一次連接、伺服器多次推送。
  • 缺點:無法準確知道連接狀態,IE瀏覽器在 iframe 請求期間,瀏覽器 title 一直處於載入狀態,底部狀態欄也顯示正在載入,用戶體驗不好(htmlfile 通過 ActiveXObject 動態寫入內存可以解決此問題)。

2. AJAX multipart streaming(XHR Streaming)

實現思路:瀏覽器必須支持 multi-part 標誌,客戶端通過 AJAX 發出請求 Request,伺服器保持住這個連接,然後可以通過 HTTP1.1 的 chunked encoding 機制(分塊傳輸編碼)不斷 push 數據給客戶端,直到 timeout 或者手動斷開連接。

  • 優點:客戶端一次連接,伺服器數據可多次推送。
  • 缺點:並非所有的瀏覽器都支持 multi-part 標誌。

3. Flash Socket(Flash Streaming)

實現思路:在頁面中內嵌入一個使用了 Socket 類的 Flash 程序,JavaScript 通過調用此 Flash 程序提供的 Socket 介面與伺服器端的 Socket 介面進行通信,JavaScript 通過 Flash Socket 接收到伺服器端傳送的數據。

  • 優點:實現真正的即時通信,而不是偽即時。
  • 缺點:客戶端必須安裝 Flash 插件;非 HTTP 協議,無法自動穿越防火牆。

4. Server-Sent Events

伺服器發送事件(SSE)也是 HTML5 公布的一種伺服器向瀏覽器客戶端發起數據傳輸的技術。一旦創建了初始連接,事件流將保持打開狀態,直到客戶端關閉。該技術通過傳統的 HTTP 發送,並具有 WebSockets 缺乏的各種功能,例如自動重新連接、事件 ID 以及發送任意事件的能力。

SSE 就是利用伺服器向客戶端聲明,接下來要發送的是流信息(streaming),會連續不斷地發送過來。這時,客戶端不會關閉連接,會一直等著伺服器發過來的新的數據流,可以類比視頻流。SSE 就是利用這種機制,使用流信息向瀏覽器推送信息。它基於 HTTP 協議,目前除了 IE/Edge,其他瀏覽器都支持。

SSE 是單向通道,只能伺服器向瀏覽器發送,因為流信息本質上就是下載。

伺服器向瀏覽器發送的 SSE 數據,必須是 UTF-8 編碼的文本,具有如下的 HTTP 頭信息。

Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive

上面三行之中,第一行的 Content-Type 必須指定 MIME 類型為event-steam

  • 優點:適用於更新頻繁、低延遲並且數據都是從服務端發到客戶端。
  • 缺點:瀏覽器兼容難度高。

以上是常見的四種基於流的做法,Iframe Streaming、XHR Streaming、Flash Streaming、Server-Sent Events。

從瀏覽器兼容難度看 —— 短輪詢/AJAX > 長輪詢/Comet > 長連接/SSE

WebSocket 的到來

從上面這幾種演進的方式來看,也是不斷改進的過程。

短輪詢效率低,非常浪費資源(網路帶寬和計算資源)。有一定延遲、伺服器壓力較大,並且大部分是無效請求。

長輪詢雖然省去了大量無效請求,減少了伺服器壓力和一定的網路帶寬的佔用,但是還是需要保持大量的連接。

最後到了基於流的方式,在伺服器往客戶端推送,這個方向的流實時性比較好。但是依舊是單向的,客戶端請求伺服器依然還需要一次 HTTP 請求。

那麼人們就在考慮了,有沒有這樣一個完美的方案,即能雙向通信,又可以節約請求的 header 網路開銷,並且有更強的擴展性,最好還可以支持二進位幀,壓縮等特性呢?

於是人們就發明了這樣一個目前看似「完美」的解決方案 —— WebSocket。

在 HTML5 中公布了 WebSocket 標準以後,直接取代了 Comet 成為伺服器推送的新方法。

Comet 是一種用於 web 的推送技術,能使伺服器實時地將更新的信息傳送到客戶端,而無須客戶端發出請求,目前有兩種實現方式,長輪詢和 iframe 流。

  • 優點:
  • 較少的控制開銷,在連接創建後,伺服器和客戶端之間交換數據時,用於協議控制的數據包頭部相對較小。在不包含擴展的情況下,對於伺服器到客戶端的內容,此頭部大小只有2至10位元組(和數據包長度有關);對於客戶端到伺服器的內容,此頭部還需要加上額外的4位元組的掩碼。相對於 HTTP 請求每次都要攜帶完整的頭部,此項開銷顯著減少了。
  • 更強的實時性,由於協議是全雙工的,所以伺服器可以隨時主動給客戶端下發數據。相對於HTTP請求需要等待客戶端發起請求服務端才能響應,延遲明顯更少;即使是和Comet等類似的長輪詢比較,其也能在短時間內更多次地傳遞數據。
  • 長連接,保持連接狀態。與HTTP不同的是,Websocket需要先創建連接,這就使得其成為一種有狀態的協議,之後通信時可以省略部分狀態信息。而HTTP請求可能需要在每個請求都攜帶狀態信息(如身份認證等)。
  • 雙向通信、更好的二進位支持。與 HTTP 協議有著良好的兼容性。默認埠也是 80 和 443,並且握手階段採用 HTTP 協議,因此握手時不容易被屏蔽,能通過各種 HTTP 代理伺服器。
  • 缺點:部分瀏覽器不支持(支持的瀏覽器會越來越多)。 應用場景:較新瀏覽器支持、不受框架限制、較高擴展性。

一句話總結一下 WebSocket:

WebSocket 是 HTML5 開始提供的一種獨立在單個 TCP 連接上進行全雙工通訊有狀態的協議(它不同於無狀態的 HTTP),並且還能支持二進位幀、擴展協議、部分自定義的子協議、壓縮等特性。

目前看來,WebSocket 是可以完美替代 AJAX 輪詢和 Comet 。但是某些場景還是不能替代 SSE,WebSocket 和 SSE 各有所長!

三. WebSocket 握手

WebSocket 的 RFC6455 標準中制定了 2 個高級組件,一個是開放性 HTTP 握手用於協商連接參數,另一個是二進位消息分幀機制用於支持低開銷的基於消息的文本和二進位數據傳輸。接下來就好好談談這兩個高級組件,這一章節詳細的談談握手的細節,下一個章節再談談二進位消息分幀機制。

首先,在 RFC6455 中寫了這樣一段話:

WebSocket 協議嘗試在既有 HTTP 基礎設施中實現雙向 HTTP 通信,因此 也使用 HTTP 的 80 和 443 埠......不過,這個設計不限於通過 HTTP 實現 WebSocket 通信,未來的實現可以在某個專用埠上使用更簡單的握手,而 不必重新定義么一個協議。

——WebSocket Protocol RFC 6455

從這段話中我們可看出制定 WebSocket 協議的人的「野心」或者說對未來的規劃有多遠,WebSocket 制定之初就已經支持了可以在任意埠上進行握手,而不僅僅是要依靠 HTTP 握手。

不過目前用的對多的還是依靠 HTTP 進行握手。因為 HTTP 的基礎設施已經相當完善了。

標準的握手流程

接下來看一個具體的 WebSocket 握手的例子。以筆者自己的網站 threes.halfrost.com/ 為例。

打開這個網站,網頁一渲染就會開啟一個 wss 的握手請求。握手請求如下:

GET wss://threes.halfrost.com/sockjs/689/8x5nnke6/websocket HTTP/1.1// 請求的方法必須是GET,HTTP版本必須至少是1.1Host: threes.halfrost.comConnection: UpgradePragma: no-cacheCache-Control: no-cacheUpgrade: websocket// 請求升級到 WebSocket 協議Origin: https://threes.halfrost.comSec-WebSocket-Version: 13// 客戶端使用的 WebSocket 協議版本User-Agent: Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Mobile Safari/537.36Accept-Encoding: gzip, deflate, brAccept-Language: zh-CN,zh;q=0.9,en;q=0.8Cookie: _ga=GA1.2.00000006.14111111496; _gid=GA1.2.23232376.14343448247; Hm_lvt_d60c126319=1524898423,1525574369,1526206975,1526784803; Hm_lpvt_d606319=1526784803; _gat_53806_2=1Sec-WebSocket-Key: wZgx0uTOgNUsHGpdWc0T+w==// 自動生成的鍵,以驗證伺服器對協議的支持,其值必須是 nonce 組成的隨機選擇的 16 位元組的被 base64 編碼後的值Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits// 可選的客戶端支持的協議擴展列表,指示了客戶端希望使用的協議級別的擴展

這裡和普通的 HTTP 協議相比,不同的地方有以下幾處:

請求的 URL 是 ws:// 或者 wss:// 開頭的,而不是 HTTP:// 或者 HTTPS://。由於 websocket 可能會被用在瀏覽器以外的場景,所以這裡就使用了自定義的 URI。類比 HTTP,ws協議:普通請求,佔用與 HTTP 相同的 80 埠;wss協議:基於 SSL 的安全傳輸,佔用與 TLS 相同的 443 埠。

Connection: UpgradeUpgrade: websocket

這兩處是普通的 HTTP 報文一般沒有的,這裡利用 Upgrade 進行了協議升級,指明升級到 websocket 協議。

Sec-WebSocket-Version: 13Sec-WebSocket-Key: wZgx0uTOgNUsHGpdWc0T+w==Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

Sec-WebSocket-Version 表示 WebSocket 的版本,最初 WebSocket 協議太多,不同廠商都有自己的協議版本,不過現在已經定下來了。如果服務端不支持該版本,需要返回一個 Sec-WebSocket-Version,裡面包含服務端支持的版本號。(詳情見下面的多版本的 websocket 握手一節)

最新版本就是 13,當然有可能存在非常早期的版本 7 ,8(目前基本不會不存在 7,8 的版本了)

注意:儘管本文檔的草案版本(09、10、11、和 12)發布了(它們多不是編輯上的修改和澄清而不是改變電報協議 [wire protocol]),值 9、10、11、和 12 不被用作有效的 Sec-WebSocket-Version。這些值被保留在 IANA 註冊中心,但並將不會被使用。

+--------+-----------------------------------------+----------+|Version | Reference | Status || Number | | |+--------+-----------------------------------------+----------+| 0 + draft-ietf-hybi-thewebsocketprotocol-00 | Interim |+--------+-----------------------------------------+----------+| 1 + draft-ietf-hybi-thewebsocketprotocol-01 | Interim |+--------+-----------------------------------------+----------+| 2 + draft-ietf-hybi-thewebsocketprotocol-02 | Interim |+--------+-----------------------------------------+----------+| 3 + draft-ietf-hybi-thewebsocketprotocol-03 | Interim |+--------+-----------------------------------------+----------+| 4 + draft-ietf-hybi-thewebsocketprotocol-04 | Interim |+--------+-----------------------------------------+----------+| 5 + draft-ietf-hybi-thewebsocketprotocol-05 | Interim |+--------+-----------------------------------------+----------+| 6 + draft-ietf-hybi-thewebsocketprotocol-06 | Interim |+--------+-----------------------------------------+----------+| 7 + draft-ietf-hybi-thewebsocketprotocol-07 | Interim |+--------+-----------------------------------------+----------+| 8 + draft-ietf-hybi-thewebsocketprotocol-08 | Interim |+--------+-----------------------------------------+----------+| 9 + Reserved | |+--------+-----------------------------------------+----------+| 10 + Reserved | |+--------+-----------------------------------------+----------+| 11 + Reserved | |+--------+-----------------------------------------+----------+| 12 + Reserved | |+--------+-----------------------------------------+----------+| 13 + RFC 6455 | Standard |+--------+-----------------------------------------+----------+

[RFC 6455]

The |Sec-WebSocket-Key| header field is used in the WebSocket opening handshake. It is sent from the client to the server to provide part of the information used by the server to prove that it received a valid WebSocket opening handshake. This helps ensure that the server does not accept connections from non-WebSocket clients (e.g., HTTP clients) that are being abused to send data to unsuspecting WebSocket servers.

Sec-WebSocket-Key 欄位用於握手階段。它從客戶端發送到伺服器以提供部分內容,伺服器用來證明它收到的信息,並且能有效的完成 WebSocket 握手。這有助於確保伺服器不會接受來自非 WebSocket 客戶端的連接(例如 HTTP 客戶端)被濫用發送數據到毫無防備的 WebSocket 伺服器。

Sec-WebSocket-Key 是由瀏覽器隨機生成的,提供基本的防護,防止惡意或者無意的連接。

Sec-WebSocket-Extensions 是屬於升級協商的部分,這裡放在下一章節進行詳細講解。

接著來看看 Response:

HTTP/1.1 101 Switching Protocols// 101 HTTP 響應碼確認升級到 WebSocket 協議Server: nginx/1.12.1Date: Sun, 20 May 2018 09:06:28 GMTConnection: upgradeUpgrade: websocketSec-WebSocket-Accept: 375guuMrnCICpulKbj7+JGkOhok=// 簽名的鍵值驗證協議支持Sec-WebSocket-Extensions: permessage-deflate// 伺服器選擇的WebSocket 擴展

在 Response 中,用 HTTP 101 響應碼回應,確認升級到 WebSocket 協議。

同樣也有兩個 WebSocket 的 header:

Sec-WebSocket-Accept: 375guuMrnCICpulKbj7+JGkOhok=// 簽名的鍵值驗證協議支持Sec-WebSocket-Extensions: permessage-deflate// 伺服器選擇的 WebSocket 擴展

Sec-WebSocket-Accept 是經過伺服器確認後,並且加密之後的 Sec-WebSocket-Key。

Sec-WebSocket-Accept 的計算方法如下:

  1. 先將客戶端請求頭裡面的 Sec-WebSocket-Key 取出來跟 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接;(258EAFA5-E914-47DA-95CA-C5AB0DC85B11 這個 Globally Unique Identifier (GUID, [RFC4122]) 是唯一固定不變的)
  2. 然後進行 SHA-1 哈希,最後進行 base64-encoded 得到的結果就是 Sec-WebSocket-Accept。

偽代碼:

> toBase64(sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ))

同樣,Sec-WebSocket-Key/Sec-WebSocket-Accept 只是在握手的時候保證握手成功,但是對數據安全並不保證,用 wss:// 會稍微安全一點。

握手中的子協議

WebSocket 握手有可能會涉及到子協議的問題。

先來看看 WebSocket 的對象初始化函數:

WebSocket WebSocket(in DOMString url, // 表示要連接的URL。這個URL應該為響應WebSocket的地址。in optional DOMString protocols // 可以是一個單個的協議名字字元串或者包含多個協議名字字元串的數組。默認設為一個空字元串。);

這裡有一個 optional ,是一個可以協商協議的數組。

var ws = new WebSocket(wss://example.com/socket, [appProtocol, appProtocol-v2]);ws.onopen = function () {if (ws.protocol == appProtocol-v2) { ... } else { ... }}

在創建 WebSocket 對象的時候,可以傳遞一個可選的子協議數組,告訴伺服器,客戶端可以理解哪些協議或者希望伺服器接收哪些協議。伺服器可以從數據裡面選擇幾個支持的協議進行返回,如果一個都不支持,那麼會直接導致握手失敗。觸發 onerror 回調,並斷開連接。

這裡的子協議可以是自定義的協議。

多版本的 websocket 握手

使用 WebSocket 版本通知能力( Sec-WebSocket-Version 頭欄位),客戶端可以初始請求它選擇的 WebSocket 協議的版本(這並不一定必須是客戶端支持的最新的)。如果伺服器支持請求的版本且握手消息是本來有效的,伺服器將接受該版本。如果伺服器不支持請求的版本,它必須以一個包含所有它將使用的版本的 Sec-WebSocket-Version 頭欄位(或多個 Sec-WebSocket-Version 頭欄位)來響應。 此時,如果客戶端支持一個通知的版本,它可以使用新的版本值重做 WebSocket 握手。

舉個例子:

GET /chat HTTP/1.1Host: server.example.comUpgrade: websocketConnection: Upgrade...Sec-WebSocket-Version: 25

伺服器不支持 25 的版本,則會返回:

HTTP/1.1 400 Bad Request...Sec-WebSocket-Version: 13, 8, 7

客戶端支持 13 版本的,則需要重新握手:

GET /chat HTTP/1.1Host: server.example.comUpgrade: websocketConnection: Upgrade...Sec-WebSocket-Version: 13

四. WebSocket 升級協商

在 WebSocket 握手階段,會 5 個帶 WebSocket 的 header。這 5 個 header 都是和升級協商相關的。

  • Sec-WebSocket-Version

    客戶端表明自己想要使用的版本號(一般都是 13 號版本),如果伺服器不支持這個版本,則需要返回自己支持的版本。客戶端拿到 Response 以後,需要對自己支持的版本號重新握手。這個 header 客戶端必須要發送。
  • Sec-WebSocket-Key

    客戶端請求自動生成的一個 key。這個 header 客戶端必須要發送。
  • Sec-WebSocket-Accept

    伺服器針對客戶端的 Sec-WebSocket-Key 計算的響應值。這個 header 服務端必須要發送。
  • Sec-WebSocket-Protocol

    用於協商應用子協議:客戶端發送支持的協議列表,伺服器必須只回應一個協議名。如果伺服器一個協議都不能支持,直接握手失敗。客戶端可以不發送子協議,但是一旦發送,伺服器無法支持其中任意一個都會導致握手失敗。這個 header 客戶端可選發送。
  • Sec-WebSocket-Extensions

    用於協商本次連接要使用的 WebSocket 擴展:客戶端發送支持的擴展,伺服器通過返回相同的首部確認自己支持一或多個擴展。這個 header 客戶端可選發送。服務端如果都不支持,不會導致握手失敗,但是此次連接不能使用任何擴展。

協商是在握手階段,握手完成以後,HTTP 通信結束,接下來的全雙工全部都交給 WebSocket 協議管理(TCP 通信)。

五. WebSocket 協議擴展

負責制定 WebSocket 規範的 HyBi Working Group 就進行了兩項擴展 Sec-WebSocket-Extensions:

  • 多路復用擴展(A Multiplexing Extension for WebSockets)

    這個擴展可以將 WebSocket 的邏輯連接獨立出來,實現共享底層的 TCP 連接。
  • 壓縮擴展(Compression Extensions for WebSocket)

    給 WebSocket 協議增加了壓縮功能。(例如 x-webkit-deflate-frame 擴展)

如果不進行多路復用擴展,每個 WebSocket 連接都只能獨享專門的一個 TCP 連接,而且當遇到一個巨大的消息分成多個幀的時候,容易產生隊首阻塞的情況。隊首阻塞會導致延遲,所以分成多個幀的時候能盡量的小是關鍵。不過在進行了多路復用擴展以後,多個連接復用一個 TCP 連接,每個信道依舊會存在隊首阻塞的問題。除了多路復用,還要進行多路並行發送消息。

如果通過 HTTP2 進行 WebSocket 傳輸,性能會更好一點,畢竟 HTTP2 原生就支持了流的多路復用。利用 HTTP2 的分幀機制進行 WebSocket 的分幀,多個 WebSocket 可以在同一個會話中傳輸。

六. WebSocket 數據幀

WebSocket 另一個高級組件是:二進位消息分幀機制。WebSocket 會把應用的消息分割成一個或多個幀,接收方接到到多個幀會進行組裝,等到接收到完整消息之後再通知接收端。

WebSocket 數據幀結構

WebSocket 數據幀格式如下:

0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued, if payload len == 127 | + - - - - - - - - - - - - - - - +-------------------------------+ | |Masking-key, if MASK set to 1 | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+

  • FIN:0表示不是最後一個分片,1表示是最後一個分片。
  • RSV1, RSV2, RSV3:

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

  • Opcode:

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

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

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

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

%x8:表示連接斷開;

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

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

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

  • Mask:

表示是否要對數據載荷進行掩碼異或操作。1表示需要,0表示不需要。(只適用於客戶端發給伺服器的消息,客戶端給伺服器發送消息,這裡一定為 1

  • Payload len:

表示數據載荷的長度,這裡有 3 種情況:

如果數據長度在 0 - 125 之間,那麼 Payload len 用 7 位表示足以,表示的數也就是凈荷長度;

如果數據長度等於 126,那麼 Payload len 需要用 7 + 16 位表示,接下來 2 位元組表示的 16 位無符號整數才是這一幀的長度;

如果數據長度等於 127,那麼 Payload len 需要用 7 + 64 位表示,接下來 8 位元組表示的 64 位無符號整數才是這一幀的長度。

  • Masking-key:

如果 Mask = 0,則沒有 Masking-key,如果 Mask = 1,則 Masking-key 長度為 4 位元組,32位。

掩碼是由客戶端隨機選擇的 32 位值。 當準備一個掩碼的幀時,客戶端必須從允許的 32 位值集合中選擇一個新的掩碼鍵。 掩碼鍵需要是不可預測的;因此,掩碼鍵必須來自一個強大的熵源, 且用於給定幀的掩碼鍵必須不容易被伺服器/代理預測用於後續幀的掩碼鍵。 掩碼鍵的不可預測性對防止惡意應用的作者選擇出現在報文上的位元組是必要的。 RFC 4086 [RFC4086]討論了什麼需要一個用於安全敏感應用的合適的熵源。

掩碼不影響「負載數據」的長度。 變換掩碼數據到解掩碼數據,或反之亦然,以下演算法被應用。 相同的演算法應用,不管轉化的方向,例如,相同的步驟即應用到掩碼數據也應用到解掩碼數據。

  • original-octet-i:為原始數據的第i位元組。
  • transformed-octet-i:為轉換後的數據的第i位元組。
  • j:為i mod 4的結果。
  • masking-key-octet-j:為mask key第j位元組。

變換數據的八位位組 i ("transformed-octet-i")是原始數據的八位位組 i("original-octet-i")異或(XOR)i 取模 4 位置的掩碼鍵的八位位組("masking-key-octet-j"):

j = i MOD 4transformed-octet-i = original-octet-i XOR masking-key-octet-j

演算法簡單描述:按位做循環異或運算,先對該位的索引取模來獲得 Masking-key 中對應的值 x,然後對該位與 x 做異或,從而得到真實的 byte 數據。

注意:掩碼的作用並不是為了防止數據泄密,而是為了防止客戶端中運行的惡意腳本對不支持 WebSocket 的中間設備進行代理緩存投毒攻擊(proxy cache poisoning attack)

要了解這種攻擊的細節,請參考 W2SP 2011 的論文Talking to Yourself for Fun and Profit。

攻擊主要分2步,

第一步,先進行一次 WebSocket 連接。黑客通過代理伺服器向自己的伺服器進行 WebSocket 握手,由於 WebSocket 握手是 HTTP 消息,所以代理伺服器把黑客自己伺服器的 Response 轉發回給黑客的時候,會認為本次 HTTP 請求結束。

第二步,在代理伺服器上面製造「投毒」攻擊。由於 WebSocket 握手成功,所以黑客可以向自己的伺服器上發送數據了,發送一條精心設置過的 HTTP 格式的文本信息。這條數據的 host 需要偽造成普通用戶即將要訪問的伺服器,請求的資源是普通用戶即將要請求的資源。代理伺服器會認為這是一條新的請求,於是向黑客自己的伺服器請求,這時候也需要黑客自己伺服器配合,收到這條「投毒」以後的消息以後,立即返回「毒藥」,返回一些惡意的腳本資源等等。至此,「投毒」成功。

當用戶通過代理伺服器請求要請求的安全資源的時候,由於 host 和 url 之前已經被黑客利用 HTTP 格式的文本信息緩存進了代理伺服器,「投毒」的資源也被緩存了,這個時候用戶請求相同的 host 和 url 的資源的時候,代理緩存伺服器發現已經緩存了,就立即會把「投毒」以後的惡意腳本或資源返回給用戶。這時候用戶就收到了攻擊。

所以客戶端發給伺服器的時候這裡必須要帶上 Masking-key,它用於標記 Payload data (包含擴展數據和應用數據兩種)

  • Payload Data:

載荷數據分為擴展數據和應用數據兩種。

擴展數據:如果沒有協商使用擴展的話,擴展數據數據為0位元組。擴展數據的長度如果存在,必須在握手階段就固定下來。載荷數據的長度也要算上擴展數據。

應用數據:如果存在擴展數據,則排在擴展數據之後。

WebSocket 控制幀

控制幀由操作碼確定,操作碼最高位為 1。 當前定義的用於控制幀的操作碼包括 0x8 (Close)、0x9(Ping)、和0xA(Pong)。 操作碼 0xB-0xF 保留用於未來尚未定義的控制幀。

控制幀用於傳達有關 WebSocket 的狀態。 控制幀可以插入到分幀消息的中間。

所有的控制幀必須有一個小於等於125位元組的有效載荷長度,控制幀必須不能被分幀。

  • 當接收到 0x8 Close 操作碼的控制幀以後,可以關閉底層的 TCP 連接了。客戶端也可以等待伺服器關閉以後,再一段時間沒有響應了,再關閉自己的 TCP 連接。

在 RFC6455 中給出了關閉時候建議的狀態碼,沒有規範的定義,只是給了一個預定義的狀態碼。

狀態碼說明保留??或者不能使用??0-999該範圍內的狀態碼不被使用。??1000表示正常關閉,意思是建議的連接已經完成了。1001表示端點「離開」(going away),例如伺服器關閉或瀏覽器導航到其他頁面。1002表示端點因為協議錯誤而終止連接。1003表示端點由於它收到了不能接收的數據類型(例如,端點僅理解文本數據,但接收到了二進位消息)而終止連接。1004保留。可能在將來定義其具體的含義。??1005是一個保留值,且不能由端點在關閉控制幀中設置此狀態碼。 它被指定用在期待一個用於表示沒有狀態碼是實際存在的狀態碼的應用中。??1006是一個保留值,且不能由端點在關閉控制幀中設置此狀態碼。 它被指定用在期待一個用於表示連接異常關閉的狀態碼的應用中。??1007表示端點因為消息中接收到的數據是不符合消息類型而終止連接(比如,文本消息中存在非 UTF-8[RFC3629] 數據)。1008表示端點因為接收到的消息違反其策略而終止連接。 這是一個當沒有其他合適狀態碼(例如 1003 或 1009)或如果需要隱藏策略的具體細節時能被返回的通用狀態碼。1009表示端點因接收到的消息對它的處理來說太大而終止連接。1010表示端點(客戶端)因為它期望伺服器協商一個或多個擴展,但伺服器沒有在 WebSocket 握手響應消息中返回它們而終止連接。 所需要的擴展列表應該出現在關閉幀的 reason 部分。1011表示伺服器端因為遇到了一個不期望的情況使它無法滿足請求而終止連接。1012101310141015是一個保留值,且不能由端點在關閉幀中被設置為狀態碼。 它被指定用在期待一個用於表示連接由於執行 TLS 握手失敗而關閉的狀態碼的應用中(比如,伺服器證書不能驗證)。??1000-2999該範圍內的狀態碼保留給本協議、其未來的修訂和一個永久的和現成的公共規範中指定的擴展的定義。??3000-3999該範圍內的狀態碼保留給庫、框架和應用使用。 這些狀態碼直接向 IANA 註冊。本規範未定義這些狀態碼的解釋。??4000-4999該範圍內的狀態碼保留用於私有使用且因此不能被註冊。 這些狀態碼可以被在 WebSocket 應用之間的先前的協議使用。 本規範未定義這些狀態碼的解釋。??

  • 當接收到 0x9 Ping 操作碼的控制幀以後,應當立即發送一個包含 pong 操作碼的幀響應,除非接收到了一個關閉幀。兩端都會在連接建立後、關閉前的任意時間內發送 Ping 幀。Ping 幀可以包含「應用數據」。ping 幀就可以作為 keepalive 心跳包。
  • 當接收到 0xA pong 操作碼的控制幀以後,知道對方還可響應。Pong 幀必須包含與被響應 Ping 幀的應用程序數據完全相同的數據。如果終端接收到一個 Ping 幀,且還沒有對之前的 Ping 幀發送 Pong 響應,終端可能選擇發送一個 Pong 幀給最近處理的 Ping 幀。一個 Pong 幀可能被主動發送,這作為單向心跳。盡量不要主動發送 pong 幀。

WebSocket 分幀規則

分幀規則由 RFC6455 進行定義,應用對如何分幀是無感知的。分幀這一步由客戶端和伺服器完成。

分幀也可以更好的利用多路復用的協議擴展,多路復用需要可以分割消息為更小的分段來更好的共享輸出通道。

RFC 6455 規定的分幀規則如下:

  • 一個沒有分片的消息由單個帶有 FIN 位設置和一個非 0 操作碼的幀組成。
  • 一個分片的消息由單個帶有 FIN 位清零和一個非 0 操作碼的幀組成,跟隨零個或多個帶有 FIN 位清零和操作碼設置為 0 的幀,且終止於一個帶有 FIN 位設置且 0 操作碼的幀。 一個分片的消息概念上是等價於單個大的消息,其負載是等價於按順序串聯片段的負載;然而,在存在擴展的情況下,這個可能不適用擴展定義的「擴展數據」存在的解釋。 例如,「擴展數據」可能僅在首個片段開始處存在且應用到隨後的片段,或 「擴展數據」可以存在於僅用於到特定片段的每個片段。 在沒有「擴展數據」的情況下,以下例子展示了分片如何工作。

例子:對於一個作為三個片段發送的文本消息,第一個片段將有一個 0x1 操作碼和一個 FIN 位清零,第二個片段將有一個 0x0 操作碼和一個 FIN 位清零,且第三個片段將有 0x0 操作碼和一個 FIN 位設置。(0x0 操作碼在上面講解過,表示一個延續幀。當 O操作碼 為 0x0 時,表示本次數據傳輸採用了數據分片,當前收到的數據幀為其中一個數據分片;)

  • 控制幀可能被注入到一個分片消息的中間。 控制幀本身必須不被分割。
  • 消息分片必須按發送者發送順序交付給收件人。
  • 片段中的一個消息必須不能與片段中的另一個消息交替,除非已協商了一個能解釋交替的擴展。
  • 一個端點必須能處理一個分片消息中間的控制幀。
  • 一個發送者可以為非控制消息創建任何大小的片段。
  • 客戶端和伺服器必須支持接收分片和非分片的消息。
  • 由於控制幀不能被分片,一個中間件必須不嘗試改變控制幀的分片。
  • 如果使用了任何保留的位值且這些值的意思對中間件是未知的,一個中間件必須不改變一個消息的分片。
  • 在一個連接上下文中,已經協商了擴展且中間件不知道協商的擴展的語義,一個中間件必須不改變任何消息的分片。同樣,沒有看見 WebSocket 握手(且沒被通知有關它的內容)、導致一個 WebSocket 連接的一個中間件,必須不改變這個鏈接的任何消息的分片。
  • 由於這些規則,一個消息的所有分片是相同類型,以第一個片段的操作碼設置。因為控制幀不能被分片,用於一個消息中的所有分片的類型必須或者是文本、或者二進位、或者一個保留的操作碼。

注意:如果控制幀不能被插入,一個 ping 延遲,例如,如果跟著一個大消息將是非常長的。因此,要求在分片消息的中間處理控制幀。

實現注意:在沒有任何擴展時,一個接收者不必按順序緩衝整個幀來處理它。例如,如果使用了一個流式 API,一個幀的一部分能被交付到應用。但是,請注意這個假設可能不適用所有未來的 WebSocket 擴展。

WebSocket 分幀開銷

由於一個分幀了的消息包含:開始於:單個幀,FIN 設為 0,opcode 非 0;後接 :0 個或多個幀,FIN 設為 0,opcode 設為 0;終結於:單個幀,FIN 設為 1,opcode 設為 0。一個分幀了消息在概念上等價於一個未分幀的大消息,它的有效載荷長度等於所有幀的有效載荷長度的累加;然而,有擴展時,這可能不成立,因為擴展定義了出現的 Extension data 的解釋。例如,Extension data 可能只出現在第一幀,並用於後續的所有幀,或者 Extension data 出現於所有幀,且只應用於特定的那個幀。

幀:最小的通信單位,包含可變長度的幀首部和凈荷部分,凈荷可能包含完整或部分應用消息。

消息:一系列幀,與應用消息對等。

那麼通常來說,伺服器分幀就 3 種,開始幀,中間幀,結束幀。開始幀和結束幀可以帶數據也可以不帶數據。分幀開銷主要花費在新增加的幀頭信息上。在不帶數據的情況下,開銷大小是 1 + 3 + 4 + 1 + 7 + 0 = 16 bit = 2 Byte。中間幀帶數據的情況下,開銷大小是 1 + 3 + 4 + 1 + 7 + 64 = 80 bit = 10 Byte (假設數據長度 127 bit,所以 playload len 要加上 64 bit)

伺服器分幀開銷範圍就在 [2,10] 位元組,客戶端要比伺服器多增加 Masking-key,這個佔用 4 位元組 (32 位),所以客戶端分幀開銷在 [6,14] 位元組。

七. WebSocket API 及數據格式

1. WebSocket API

WebSocket API 及其簡潔,可以調用的函數只有下面這麼幾個:

var ws = new WebSocket(wss://example.com/socket);ws.onerror = function (error) { ... }ws.onclose = function () { ... }ws.onopen = function () {ws.send("Connection established. Hello server!");}ws.onmessage = function(msg) { if(msg.data instanceof Blob) { processBlob(msg.data); } else { processText(msg.data); }}

除去新建 WebSocket 對象和 send() 方法以外,剩下的就是4個回調方法了。

上述的這些方法中,send() 方法需要額外注意一點的是,這個方法是非同步的,並不是同步方法。意味著當我們把要發送的內容丟到這個函數中的時候,函數就非同步返回了,此時不要誤認為已經發送出去了。WebSocket 自身有一個排隊的機制,數據會先丟到數據緩存區中,然後按照排隊的順序進行發送。

如果是一個巨大的文件排隊中,後面又來了一些優先順序比這個消息高的消息,比如系統出錯,需要立即斷開連接。由於排隊排在大文件之後,必須等待大文件發送完畢才能發送這個優先順序更高的消息。這就造成了隊首阻塞的問題了,導致優先順序更高的消息延遲。

WebSocket API 制定者考慮到了這個問題,於是給了我們另外 2 個為數不多的可以改變 WebSocket 對象行為的屬性,一個是 bufferedAmount,另外一個是 binaryType。

if (ws.bufferedAmount == 0) ws.send(evt.data);

在上述這種情況下就可以使用 bufferedAmount 監聽緩存區的數量,從而避免隊首阻塞的問題,更進一步也可以和 Priority Queue 結合到一起,實現按照優先順序高低來發送消息。

2. 數據格式

WebSocket 對傳輸的格式沒有任何限制,可以是文本也可以是二進位,都可以。協議中通過 Opcode 類型欄位來區分是 UTF-8 還是二進位。WebSocket API 可以接收 UTF-8 編碼的 DOMString 對象,也可以接收 ArrayBuffer、 ArrayBufferView 或 Blob 等二進位數據。

瀏覽器對接收到的數據,如果不手動設置任何其他選項的話,默認處理是,文本是默認轉成 DOMString 對象,二進位數據或者 Blob 對象會直接轉給給應用,中間不做任何處理。

var ws = new WebSocket(wss://example.com/socket); ws.binaryType = "arraybuffer";

唯一能干涉的地方就是把接收到的二進位數據全部都強制轉換成 arraybuffer 類型而不是 Blob 類型。至於為何要轉換成 arraybuffer 類型, W3C 的候選人給出的建議如下:

用戶代理可以將這個選項看作一個暗示,以決定如何處理接收到的二進位數據:如果這裡設置為 「blob」,那就可以放心地將其轉存到磁碟上;而如果設置為 「arraybuffer」,那很可能在內存里處理它更有效。自然地,我們鼓勵用戶代理使用更細微的線索,以決定是否將到來的數據放到內存里。

——The WebSocket API W3C Candidate Recommendation

簡單的說:如果轉換成了 Blob 對象,就代表了一個不可變的文件對象或者原始數據。如果不需要修改或者不需要切分它,保留成 Blob 對象是一個好的選擇。如果要處理這段原始數據,放進內存裡面處理明顯會更加合適,那麼就請轉換成 arraybuffer 類型。

八. WebSocket 性能和使用場景

有一張來自 WebSocket.org 網站的測試,用 XHR 輪詢和 WebSocket 進行對比:

上圖中,我們先看藍色的柱狀圖,是 Polling 輪詢消耗的流量,這次測試,HTTP 請求和響應頭信息開銷總共包括 871 位元組。當然每次測試不同的請求,頭的開銷不同。這次測試都以 871 位元組的請求來測試。

Use case A: 1,000 clients polling every second: Network throughput is (871 x 1,000) = 871,000 bytes = 6,968,000 bits per second (6.6 Mbps)

Use case B: 10,000 clients polling every second: Network throughput is (871 x 10,000) = 8,710,000 bytes = 69,680,000 bits per second (66 Mbps)

Use case C: 100,000 clients polling every 1 second: Network throughput is (871 x 100,000) = 87,100,000 bytes = 696,800,000 bits per second (665 Mbps)

而 Websocket 的 Frame 是 just two bytes of overhead instead of 871,僅僅用 2 個位元組就代替了輪詢的 871 位元組!

Use case A: 1,000 clients receive 1 message per second: Network throughput is (2 x 1,000) = 2,000 bytes = 16,000 bits per second (0.015 Mbps)

Use case B: 10,000 clients receive 1 message per second: Network throughput is (2 x 10,000) = 20,000 bytes = 160,000 bits per second (0.153 Mbps)

Use case C: 100,000 clients receive 1 message per second: Network throughput is (2 x 100,000) = 200,000 bytes = 1,600,000 bits per second (1.526 Mbps)

相同的每秒客戶端輪詢的次數,當次數高達 10W/s 的高頻率次數的時候,Polling 輪詢需要消耗 665Mbps,而 Websocket 僅僅只花費了 1.526Mbps,將近 435 倍!!

從結果上看, WebSocket 確實比輪詢效率和網速消耗都要好很多。

從使用場景來說,XHR、SSE、WebSocket 各有優缺點。

XHR 相對其他兩種方式更加簡單,依靠 HTTP 完善的基礎設施,很容易實現。不過它不支持請求流,對相應流也不是完美支持(需要支持 Streams API 才能支持響應流)。傳輸數據格式方面,文本和二進位都支持,也支持壓縮。HTTP 對它的報文負責分幀。

SSE 也同樣不支持請求流,在進行一次握手以後,服務端就可以以事件源協議把數據作為響應流發給客戶端。SSE 只支持文本數據,不能支持二進位。因為 SSE 不是為傳輸二進位而設計的,如果有必要,可以把二進位對象編碼為 base64 形式,然後再使用 SSE 進行傳輸。SSE 也支持壓縮,事件流負責對它進行分幀。

WebSocket 是目前唯一一個通過同一個 TCP 連接實現的全雙工的協議,請求流和響應流都完美支持。支持文本和二進位數據,本身自帶二進位分幀。在壓縮方面差一些,因為有些不支持,例如 x-webkit-deflate-frame 擴展,在筆者上文中距離的那個 ws 請求中伺服器就沒有支持壓縮。

如果所有的網路環境都可以支持 WebSocket 或者 SSE 當然是最好不過的了。但是這是不現實的,網路環境千變萬化,有些網路可能就屏蔽了 WebSocket 通信,或者用戶設備就不支持 WebSocket 協議,於是 XHR 也就有了用武之地。

如果客戶端不需要給服務端發消息,只需要不斷的實時更新,那麼考慮用 SSE 也是不錯的選擇。不過 SSE 目前在 IE 和 Edge 上支持的較差。WebSocket 在這方面比 SSE 強。

所以應該根據不同場景選擇不同的協議,各取所長。


Reference:

RFC6455

Server-Sent Events 教程

Comet:基於 HTTP 長連接的「伺服器推」技術

WEB性能權威指南

What is Sec-WebSocket-Key for?

10.3. Attacks On Infrastructure (Masking)

Why are WebSockets masked?

How does websocket frame masking protect against cache poisoning?

What is the mask in a WebSocket frame?

GitHub Repo:Halfrost-Field

Follow: halfrost · GitHub

Source: halfrost.com/websocket/

推薦閱讀:

Spring boot+web Socket即時通訊
Golang Websocket 實踐
一步一步教您用websocket+nodeJS搭建簡易聊天室(4)

TAG:WebSocket |