全雙工通訊的 WebSocket

一縷殤流化隱半邊冰霜發表於2018-05-21

一. 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 階段

全雙工通訊的 WebSocket

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

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

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

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

全雙工通訊的 WebSocket

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

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

全雙工通訊的 WebSocket

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

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

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

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

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

全雙工通訊的 WebSocket

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

(3) 基於流(Comet Streaming)

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

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

全雙工通訊的 WebSocket

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

Google 的天才們使用一個稱為 “htmlfile” 的 ActiveX 解決了在 IE 中的載入顯示問題,並將這種方法用到了 gmail+gtalk 產品中。Alex Russell 在 “What else is burried down in the depth's of Google's 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-stream
Cache-Control: no-cache
Connection: keep-alive
複製程式碼

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

全雙工通訊的 WebSocket

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

全雙工通訊的 WebSocket

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

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

WebSocket 的到來

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

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

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

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

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

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

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

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

全雙工通訊的 WebSocket

  • 優點:

  • 較少的控制開銷,在連線建立後,伺服器和客戶端之間交換資料時,用於協議控制的資料包頭部相對較小。在不包含擴充套件的情況下,對於伺服器到客戶端的內容,此頭部大小隻有2至10位元組(和資料包長度有關);對於客戶端到伺服器的內容,此頭部還需要加上額外的4位元組的掩碼。相對於 HTTP 請求每次都要攜帶完整的頭部,此項開銷顯著減少了。

  • 更強的實時性,由於協議是全雙工的,所以伺服器可以隨時主動給客戶端下發資料。相對於HTTP請求需要等待客戶端發起請求服務端才能響應,延遲明顯更少;即使是和Comet等類似的長輪詢比較,其也能在短時間內更多次地傳遞資料。

  • 長連線,保持連線狀態。與HTTP不同的是,Websocket需要先建立連線,這就使得其成為一種有狀態的協議,之後通訊時可以省略部分狀態資訊。而HTTP請求可能需要在每個請求都攜帶狀態資訊(如身份認證等)。

  • 雙向通訊、更好的二進位制支援。與 HTTP 協議有著良好的相容性。預設埠也是 80 和 443,並且握手階段採用 HTTP 協議,因此握手時不容易被遮蔽,能通過各種 HTTP 代理伺服器。

  • 缺點:部分瀏覽器不支援(支援的瀏覽器會越來越多)。 應用場景:較新瀏覽器支援、不受框架限制、較高擴充套件性。

全雙工通訊的 WebSocket

一句話總結一下 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.1

Host: threes.halfrost.com
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
// 請求升級到 WebSocket 協議

Origin: https://threes.halfrost.com
Sec-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.36
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: _ga=GA1.2.00000006.14111111496; _gid=GA1.2.23232376.14343448247; Hm_lvt_d60c126319=1524898423,1525574369,1526206975,1526784803; Hm_lpvt_d606319=1526784803; _gat_53806_2=1
Sec-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: Upgrade
Upgrade: websocket
複製程式碼

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

Sec-WebSocket-Version: 13
Sec-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.1
Date: Sun, 20 May 2018 09:06:28 GMT
Connection: upgrade
Upgrade: websocket
Sec-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.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
...
Sec-WebSocket-Version: 25
複製程式碼

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

HTTP/1.1 400 Bad Request
...
Sec-WebSocket-Version: 13, 8, 7
複製程式碼

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

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: 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表示不需要。(只適用於客戶端發給伺服器的訊息)

  • 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 4
transformed-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

第二步,在代理伺服器上面製造“投毒”攻擊。由於 WebSocket 握手成功,所以黑客可以向自己的伺服器上傳送資料了,傳送一條精心設定過的 HTTP 格式的文字資訊。這條資料的 host 需要偽造成普通使用者即將要訪問的伺服器,請求的資源是普通使用者即將要請求的資源。代理伺服器會認為這是一條新的請求,於是向黑客自己的伺服器請求,這時候也需要黑客自己伺服器配合,收到這條“投毒”以後的訊息以後,立即返回“毒藥”,返回一些惡意的指令碼資源等等。至此,“投毒”成功。

全雙工通訊的 WebSocket

當使用者通過代理伺服器請求要請求的安全資源的時候,由於 host 和 url 之前已經被黑客利用 HTTP 格式的文字資訊快取進了代理伺服器,“投毒”的資源也被快取了,這個時候使用者請求相同的 host 和 url 的資源的時候,代理快取伺服器發現已經快取了,就立即會把“投毒”以後的惡意指令碼或資源返回給使用者。這時候使用者就收到了攻擊。

  • 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 表示伺服器端因為遇到了一個不期望的情況使它無法滿足請求而終止連線。
1012
1013
1014
1015 是一個保留值,且不能由端點在關閉幀中被設定為狀態碼。 它被指定用在期待一個用於表示連線由於執行 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 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 進行對比:

全雙工通訊的 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: github.com/halfrost/Ha…

相關文章