JavaScript 工作原理之五-深入理解 WebSockets 和帶有 SSE 機制的HTTP/2 以及正確的使用姿勢(譯)

tristan發表於2018-05-11

原文請查閱這裡,略有改動,本文采用知識共享署名 4.0 國際許可協議共享,BY Troland

本系列持續更新中,Github 地址請查閱這裡

這是 JavaScript 工作原理的第五章。

現在,我們將會深入通訊協議的世界,繪製並討論它們的特點和內部構造。我們將會給出一份 WebSockets 和 HTTP/2 的快速比較 。在文末,我們將會分享如何正確地選擇網路協議的一些見解。

簡介

現在,複雜的網頁程式擁有豐富的功能,這得多虧網頁的動態互動能力。而這並不令人感到驚訝-因為自網際網路誕生,它經歷了一段相當長的時間。

起初,網際網路並不是用來支援如此動態和複雜的網頁程式的。它本來設想是由大量的 HTML 頁面組成的,每個頁面連結到其它的頁面,這樣就形成了包含資訊的網頁的概念。一切都是極大地圍繞著所謂的 HTTP 請求/響應模式來建立的。客戶端載入一個網頁,直到使用者點選頁面並導航到下一個網頁。

大約在 2005 年,引入了 AJAX,然後很多人開始探索客戶端和服務端雙向通訊的可能性。然而,所有的 HTTP 連結是由客戶端控制的,意即必須由使用者進行操作或者定期輪詢以從伺服器載入資料。

讓 HTTP 支援雙向通訊

支援伺服器主動向客戶端推送資料的技術已經出現了好一段時間了。比如 "Push" 和 "Comet" 技術。

長輪詢是服務端主動向客戶端傳送資料的最常見的 hack 之一。通過長輪詢,客戶端開啟了一個到服務端的 HTTP 連線直到返回響應資料。當服務端有新資料需要傳送時,它會把新資料作為響應傳送給客戶端。

讓我們看一下簡單的長輪詢程式碼片段:

(function poll(){
   setTimeout(function(){
      $.ajax({ 
        url: 'https://api.example.com/endpoint', 
        success: function(data) {
          // 處理 `data`
          // ...

          //遞迴呼叫下一個輪詢
          poll();
        }, 
        dataType: 'json'
      });
  }, 10000);
})();
複製程式碼

這基本上是一個自執行函式,第一次會自動執行。它每隔 10 秒鐘非同步請求伺服器並且當每次發起對伺服器的非同步請求之後,會在回撥函式裡面再次呼叫 ajax 函式。

其它技術涉及到 Flash 和 XHR 多方請求以及所謂的 htmlfiles

所有這些方案都有一個共同的問題:都帶有 HTTP 開銷,這樣就會使得它們無法滿足要求低延遲的程式。試想一下瀏覽器中的第一人稱射擊遊戲或者其它要求實時元件功能的線上遊戲。

WebSockets 的出現

WebSocket 規範定義了一個 API 用以在網頁瀏覽器和伺服器建立一個 "socket" 連線。通俗地講:在客戶端和伺服器保有一個持久的連線,兩邊可以在任意時間開始傳送資料。

JavaScript 工作原理之五-深入理解 WebSockets 和帶有 SSE 機制的HTTP/2 以及正確的使用姿勢(譯)

客戶端通過 WebSocket 握手的過程來建立 WebSocket 連線。在這一過程中,首先客戶端向伺服器發起一個常規的 HTTP 請求。請求中會包含一個 Upgrade 的請求頭,通知伺服器客戶端想要建立一個 WebSocket 連線。

讓我們看下如何在客戶端建立 WebSocket 連線:

// 建立新的加密 WebSocket 連線
var socket = new WebSocket('ws://websocket.example.com');
複製程式碼

WebSocket 地址使用了 ws 方案。wss 是一個等同於 HTTPS 的安全的 WebSocket 連線。

該方案是開啟到 websocket.example.com 的 WebSocket 連線的開始。

下面是初始化請求頭的簡化例子。

GET ws://websocket.example.com/ HTTP/1.1
Origin: http://example.com
Connection: Upgrade
Host: websocket.example.com
Upgrade: websocket
複製程式碼

如果伺服器支援 WebSocket 協議,它將會同意升級請求,然後通過在響應裡面返回 Upgrade 頭來進行通訊。

讓我們看下 Node.js 的實現:

// 我們將會使用 https://github.com/theturtle32/WebSocket-Node 來實現 WebSocket
var WebSocketServer = require('websocket').server;
var http = require('http');

var server = http.createServer(function(request, response) {
  // 處理 HTTP 請求
});
server.listen(1337, function() { });

// 建立伺服器
wsServer = new WebSocketServer({
  httpServer: server
});

// WebSocket 伺服器
wsServer.on('request', function(request) {
  var connection = request.accept(null, request.origin);

  // 這是最重要的回撥,在這裡處理所有使用者返回的資訊
  connection.on('message', function(message) {
      // 處理 WebSocket 資訊
  });

  connection.on('close', function(connection) {
    // 關閉連線
  });
});
複製程式碼

連線建立之後,伺服器使用升級來作為回覆:

HTTP/1.1 101 Switching Protocols
Date: Wed, 25 Oct 2017 10:07:34 GMT
Connection: Upgrade
Upgrade: WebSocket
複製程式碼

一旦連線建立,會觸發客戶端 WebSocket 例項的 open 事件。

var socket = new WebSocket('ws://websocket.example.com');

// WebSocket 連線開啟的時候,列印出 WebSocket 已連線的資訊
socket.onopen = function(event) {
  console.log('WebSocket is connected.');
};
複製程式碼

現在,握手結束了,最初的 HTTP 連線被替換為 WebSocket 連線,該連線底層使用同樣的 TCP/IP 連線。現在兩邊都可以開始傳送資料了。

通過 WebSocket,你可以隨意傳送資料而不用擔心傳統 HTTP 請求所帶來的相關開銷。資料是以訊息的形式通過 WebSocket 進行傳輸的,每條資訊是由包含你所傳輸的資料(有效載荷)的一個或多個幀所組成的。為了保證當訊息到達客戶端的時候被正確地重新組裝出來,每一幀都會前置關於有效載荷的 4-12 位元組的資料。使用這種基於幀的資訊系統可以幫助減少非有效載荷資料的傳輸,從而顯著地減少資訊延遲。

**注意:**這裡需要注意的是隻有當所有的訊息幀都被接收到而且原始的資訊有效載荷被重新組裝的時候,客戶端才會接收到新訊息的通知。

WebSocket 地址

前面我們簡要地談到 WebSockets 引進了一個新的地址協議。實際上,WebSocket 引進了兩種新協議:ws://wss://

URL 地址含有指定方案的語法。WebSocket 地址特別之處在於,它不支援錨(sample_anchor)。

WebSocket 和 HTTP 風格的地址使用相同的地址規則。ws 是未加密且預設是 80 埠,而 wss 要求 TSL 加密且預設 443 埠。

幀協議

讓我們深入瞭解下幀協議。這是 RFC 提供的:

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

由於 WebSocket 版本是由 RFC 所規定的,所以每個包前面只有一個頭部資訊。然而,這個頭部資訊相當的複雜。這是其組成模組的說明:

  • fin(1 位):指示是否是組成資訊的最後一幀。大多數時候,資訊只有一幀所以該位通常有值。測試表明火狐的第二幀資料在 32K 之後。

  • rsv1rsv2rsv3(每個一位):必須是 0 除非使用協商擴充套件來定義非 0 值的含義。如果收到一個非 0 值且沒有協商擴充套件來定義非零值的含義,接收端會中斷連線。

  • opcode(4 位):表示第幾幀。目前可用的值:

    0x00:該幀接續前面一幀的有效載荷。

    0x01:該幀包含文字資料。

    0x02:該幀包含二進位制資料。

    0x08:該幀中斷連線。

    0x09:該幀是一個 ping。

    0x0a:該幀是一個pong。

    (正如你所看到的,有相當一部分值未被使用;它們是保留以備未來使用的)。

  • mask(1 位):指示該連線是否被遮罩。正其所表示的意義,每一條從客戶端發往伺服器的資訊都必須被遮罩,然後如果資訊未遮罩,根據規範會中斷該連線。

  • payload_len(7 位):有效載荷的長度。WebSocket 幀有以下幾類長度:

    0-125 表示有效載荷的長度。126 意味著接下來兩個位元組表示有效載荷長度,127 意味著接下來的 8 個位元組表示有效載荷長度。所以有效載荷的長度大概有 7 位,16 位和 64 位這三類。

  • masking-key (32 位):所有從客戶端發往伺服器的幀都由幀內的一個 32 位值所遮罩。

  • payload:一般情況下都會被遮罩的實際資料。其長度取決於 payload_len 的長度。

為什麼 WebSocket 是基於幀而不是基於流的呢?我和你一樣一臉懵逼,我也想多學點,如果你有任何想法,歡迎在下面的評論區新增評論和資源。另外,HackerNews 上面有關於這方面的討論。

幀資料

正如之前提到的,資料可以被拆分為多個幀。第一幀所傳輸的資料裡面含有一個操作碼錶示資料的傳輸順序。這是必須的,因為當規範完成的時候,JavaScript 並不能很好地支援二進位制資料的傳輸。0x01 表示 utf-8 編碼的文字資料,0x02 表示二進位制資料。大多數人在傳輸 JSON 資料的時候都會選擇文字操作碼。當你傳輸二進位制資料的時候,它會以瀏覽器指定的 Blob 來表示。

通過 WebSocket 來傳輸資料的 API 是非常簡單的:

var socket = new WebSocket('ws://websocket.example.com');
socket.onopen = function(event) {
  socket.send('Some message'); // 向伺服器傳送資料
};
複製程式碼

當 WebSocket 正在接收資料的時候(客戶端),會觸發 message 事件。該事件會帶有一個 data 屬性,裡面包含了訊息的內容。

// 處理伺服器返回的訊息
socket.onmessage = function(event) {
  var message = event.data;
  console.log(message);
};
複製程式碼

你可以很容易地利用 Chrome 開發者工具的網路選項卡來檢查 WebSocket 連線中的每一幀的資料。

JavaScript 工作原理之五-深入理解 WebSockets 和帶有 SSE 機制的HTTP/2 以及正確的使用姿勢(譯)

資料分片

有效載荷資料可以被分成多個獨立的幀。接收端會緩衝這些幀直到 fin 位有值。所以你可以把字串『Hello World』拆分為 11 個包,每個包由 6(頭長度) + 1 位元組組成。資料分片不能用來控制包。然而,規範想要你有能力去處理交錯控制幀。這是為了預防 TCP 包無序到達客戶端。

連線幀的大概邏輯如下:

  • 接收第一幀
  • 記住操作碼
  • 連線幀有效載荷直到 fin 位有值
  • 斷言每個包的操作碼都為 0

資料分片的主要目的在於允許開始時傳輸不明大小的資訊。通過資料分片,伺服器可能需要設定一個合理的緩衝區大小,然後當緩衝區滿,返回一個資料分片。資料分片的第二個用途即多路複用,邏輯通道上的大量資料佔據整個輸出通道是不合理的,所以利用多路複用技術把資訊拆分成更小的資料分片以更好地共享輸出通道。

心跳包

握手之後的任意時刻,客戶端和伺服器可以隨意地 ping 對方。當接收到 ping 的時候,接收方必須儘快回覆一個 pong。此即心跳包。你可以用它來確保客戶端是否保持連線。

ping 或者 pong 雖然只是一個普通幀,但卻是一個控制幀。Ping 包含 0x9 操作碼,而 Pong 包含 0xA 操作碼。當你接收到 ping 的時候,返回一個和 ping 攜帶同樣有效載荷資料的 pong(ping 和 pong 最大有效載荷長度都為 125)。你可能接收到一個 pong 而不用傳送一個 ping。忽略它如果有發生這樣的情況。

心跳包非常有用。利用服務(比如負載均衡器)來中斷空閒的連線。另外,接收端不可能知道服務端是否已經中斷連線。只有在傳送下一幀的時候,你才會意識到發生了錯誤。

錯誤處理

你可以通過監聽 error 事件來處理錯誤。

像這樣:

var socket = new WebSocket('ws://websocket.example.com');

// 處理錯誤
socket.onerror = function(error) {
  console.log('WebSocket Error: ' + error);
};
複製程式碼

關閉連線

客戶端或伺服器可以傳送一個包含 0x8 操作碼資料的控制幀來關閉連線。當接收到控制幀的時候,另一個節點會返回一個關閉幀。之後第一個節點會關閉連線。關閉連線之後,之後接收的任何資料都會被遺棄。

這是初始化關閉客戶端的 WebSocket 連線的程式碼:

// 如果連線開啟著則關閉
if (socket.readyState === WebSocket.OPEN) {
    socket.close();
}
複製程式碼

同樣地,為了在完成關閉連線後執行任意的清理工作,你可以為 close 事件新增事件監聽函式:

// 執行必要的清理工作
socket.onclose = function(event) {
  console.log('Disconnected from WebSocket.');
};
複製程式碼

伺服器不得不監聽 close 事件以便在需要的時候處理:

connection.on('close', function(reasonCode, description) {
    // 關閉連線
});
複製程式碼

WebSockets 和 HTTP/2 對比

雖然 HTTP/2 提供了很多的功能,但是它並不能完全取代當前的 push/streaming 技術。

關於 HTTP/2 需要注意的最重要的事即它並不能完全取代 HTTP。詞彙,狀態碼以及大部分的頭部資訊都會保持和現在一樣。HTTP/2 只是提升了線路上的資料傳輸效率。

現在,如果我們對比 WebSocket 和 HTTP/2,將會發現很多類似的地方:

JavaScript 工作原理之五-深入理解 WebSockets 和帶有 SSE 機制的HTTP/2 以及正確的使用姿勢(譯)

正如以上所顯示的那樣,HTTP/2 引進了 Server Push 技術用來讓伺服器主動向客戶端快取傳送資料。然而,它並不允許直接向客戶端程式本身傳送資料。服務端推送只能由瀏覽器處理而不能夠在程式程式碼中進行處理,意即程式程式碼沒有 API 可以用來獲取這些事件的通知。

這時候服務端推送事件(SSE)就派上用場了。SSE 是這樣的機制一旦客戶端-伺服器連線建立,它允許伺服器非同步推送資料給客戶端。之後,每當伺服器產生新資料的時候,就推送資料給客戶端。這可以看成是單向的釋出-訂閱模型。它也提供了一個被稱為 EventSource 的 標準 JavaScript 客戶端 API,該 API 作為 W3C 組織釋出的 HTML5 標準的一部分已經在大多數的現代瀏覽器中實現。請注意不支援原生 EventSource API 的瀏覽器可以通過墊片實現。

由於 SSE 是基於 HTTP 的,所以它天然相容於 HTTP/2 並且可以混合使用以利用各自的優勢: HTTP/2 處理一個基於多路複用流的高效傳輸層而 SSE 為程式提供了 API 用來支援服務端推送。

為了完全理解流和多路複用技術,先讓我們來了解一下 IETF 的定義:『流』即是在一個 HTTP/2 連線中,在客戶端和服務端間進行交換傳輸的一個獨立的雙向幀序列。它的主要特點之一即單個的 HTTP/2 連線可以包含多個併發開啟的流,在每一終端交錯傳輸來自多個流的幀。

JavaScript 工作原理之五-深入理解 WebSockets 和帶有 SSE 機制的HTTP/2 以及正確的使用姿勢(譯)

必須記住的是 SSE 是基於 HTTP 的。這意味著,通過使用 HTTP/2,不僅僅可以把多個 SSE 流交叉合併成單一的 TCP 連線,還可以把多個 SSE 流(服務端向客戶端推送)和多個客戶端請求(客戶端到服務端)合併成單一的 TCP 連線。多虧了 HTTP/2 和 SSE,現在我們有了一個純粹的 HTTP 雙向連線,該連線帶有一個簡單的 API 允許程式程式碼註冊監聽服務端的資料推送。缺乏雙向通訊能力一直被認為是 SSE 對比 WebSocket 的主要缺點。多虧了 HTTP/2,這不再是缺點。這就讓你有機會堅持使用基於 HTTP 的通訊系統而非 WebSockets。

WebSocket 和 HTTP/2 的使用場景

WebSockets 依然可以在 HTTP/2 + SSE 的統治下存在,主要是由於它是廣受好評的技術,在特殊情況下,和 HTTP/2 比較它有一個優點即它天生擁有更少的開銷(比如,頭部資訊)的雙向通訊能力。

假設你想要構建一個大型的多人線上遊戲,在各個連線終端會產生大量的資訊。在這樣的情況下,WebSockets 會表現得更加完美。

總之,當你需要在客戶端和服務端建立一個真正的低延遲的,接近實時連線的時候使用 WebSockets。記住這可能要求你重新考慮如何構建伺服器端程式,同時也需要你關注諸如事件佇列的技術。

如果你的使用場景要求顯示實時市場新聞,市場資料,聊天程式等等,HTTP/2 + SSE 將會為你提供一個高效的雙向通訊通道且你可以得到 HTTP 的所有益處:

  • 當考慮現有架構的相容性的時候,WebSockets 經常會是一個痛點,因為升級 HTTP 連線到一個完全和 HTTP 不相關的協議。
  • 可擴充套件性和安全:網路元件(防火牆,入侵檢測,負載均衡器)的建立,維護和配置都是為 HTTP 所考慮的,大型/重要的程式會更喜歡具有彈性,安全和可伸縮性的環境。

同樣地,你不得不考慮瀏覽器相容性。檢視下 WebSocket 相容情況:

JavaScript 工作原理之五-深入理解 WebSockets 和帶有 SSE 機制的HTTP/2 以及正確的使用姿勢(譯)

相容性還不錯。

然而,HTTP/2 的情況就不太妙了:

JavaScript 工作原理之五-深入理解 WebSockets 和帶有 SSE 機制的HTTP/2 以及正確的使用姿勢(譯)

  • 僅支援 TLS(還不算壞)
  • 僅限於 Windows 10 的 IE 11 部分支援
  • 僅支援 OSX 10.11+ Safari 瀏覽器
  • 僅當你協商應用 ALPN(伺服器需要明確支援的東西)才會支援 HTTP/2

SSE 的支援情況要好些:

JavaScript 工作原理之五-深入理解 WebSockets 和帶有 SSE 機制的HTTP/2 以及正確的使用姿勢(譯)

僅 IE/Edge 不支援。(好吧,Opera Mini 即不支援 SSE 也不支援 WebSockets,因此我們把它完全排隊在外)。有一些優雅的墊片來讓 IE/Edge 支援 SSE。

SessionStack 是如何選擇的?

SessionStack  同時使用 WebSockets 和 HTTP,這取決於使用場景。

一旦整合 SessionStack 進網頁程式,它會開始記錄 DOM 變化,使用者互動,JavaScript 異常,堆疊追蹤,失敗的網路請求以及除錯資訊,允許你用視訊回放網頁程式中的問題及發生在使用者身上的一切事情。全部都是實時發生的並且要求對網頁程式不會產生任何的效能影響。

這意味著你可以實時加入到使用者會話,而使用者仍然在瀏覽器中。這樣的情況下,我們會選擇使用 HTTP,因為這並不需要雙向通訊(服務端把資料傳輸到瀏覽器端)。當前情況下,使用 WebSocket 就是過度使用,難以維護和擴充套件。

然而,整合進網頁程式的 SessionStack 庫應用了 WebSocket(優先使用,否則回滾到 HTTP)。它會打包並且向我們的伺服器傳送資料,這是單向通訊。在這種情況下,之所以選擇 WebSocket 是因為計劃中的某些產品功能可能需要進行雙向通訊。

打個廣告 ^.^

今日頭條招人啦!傳送簡歷到 likun.liyuk@bytedance.com ,即可走快速內推通道,長期有效!國際化PGC部門的JD如下:c.xiumi.us/board/v5/2H…,也可內推其他部門!

本系列持續更新中,Github 地址請查閱這裡

相關文章