[譯] JavaScript 是如何工作的:深入剖析 WebSockets 和擁有 SSE 技術 的 HTTP/2,以及如何在二者中做出正確的選擇

吳曉軍發表於2018-01-07

歡迎來到旨在探索 JavaScript 以及它的核心元素的系列文章的第五篇。在認識、描述這些核心元素的過程中,我們也會分享一些當我們構建 SessionStack 的時候遵守的一些經驗規則,這是一個輕量級的 JavaScript 應用,其具備的健壯性和高效能讓它在市場中保有一席之地。

如果你錯過了前面的文章,你可以在這兒找到它們:

  1. 對引擎、執行時和呼叫棧的概述
  2. 深入 V8 引擎以及 5 個寫出更優程式碼的技巧
  3. 記憶體管理以及四種常見的記憶體洩漏的解決方法
  4. 事件迴圈和非同步程式設計的崛起以及 5 個如何更好的使用 async/await 編碼的技巧

這一次,我們將深入到通訊協議中,去討論和對比 WebSockets 和 HTTP/2 的屬性和構成。我們將快速比較 WebSockets 和 HTTP/2,並在最後,針對網路協議,分享一些如何選擇這2種技術的想法。

簡介

現在,富互動 web 應用已然司空見慣了。由於 internet 經過了漫長的發展,這一點看起來也不足為奇了。

最初,internet 的建立不是為了支援這樣動態的、複雜的 web 應用程式。它只被認為是一個 HTML 頁面的集合,頁面間能夠連結到其他頁面,從而構成了一個 “web” 這樣一個資訊載體的概念。internet 中每個事物都是由 HTTP 中的請求/響應(request/response)正規化構建而成。一個客戶端載入了一個頁面後將不會再發生任何事,除非使用者點選並跳轉到了下一頁。

2005 年左右,AJAX 技術的引入讓許多人開始探索客戶端和伺服器間**雙向通訊(bidirectional)**的可能。然而,所有的 HTTP 通訊都是由客戶端掌控的,這要求使用者互動式地或者週期輪詢式地去從伺服器拉取新資料。

讓 HTTP 成為 “雙向通訊的”

能夠讓伺服器“主動地”傳送資料給客戶端的技術已經出現了一段時間了,例如 “Push”“Comet”

為了製造出伺服器主動給客戶端傳送資料的假象,最常用的一個 hack 是長輪詢(long polling)。通過長輪詢,客戶端開啟了一個到服務端的 HTTP 連線,該連線會一直保持直到有資料返回。無論什麼時候伺服器有了需要被送達的資料,它都會將資料作為一個響應傳輸到客戶端。

讓我們看看一個非常簡單的長輪詢程式碼片段長什麼樣:

(function poll(){
   setTimeout(function(){
      $.ajax({ 
        url: 'https://api.example.com/endpoint', 
        success: function(data) {
          // 使用 `data` 來做一些事
          // ...

          // 遞迴地開始下一次輪詢
          poll();
        }, 
        dataType: 'json'
      });
  }, 10000);
})();
複製程式碼

這是一個自執行函式,它將自動執行。其設定了一個 10 秒的間隔,當一個非同步請求傳送完成後,在其回撥方法中又會再次呼叫這個非同步請求`。

其他一些技術還涉及到了 Flash 、 XHR multipart request 以及 htmlfiles

所有的這些方案都面臨了相同的問題:它們都是建立在 HTTP 上的,這就使得它們不適合那些需要低延遲的應用。例如瀏覽器中的第一人稱射擊這樣實時性要求高的線上遊戲。

WebSockets 簡介

WebSocket 規範定義了一個 API 用來建立一個 web 瀏覽器和伺服器之間的 “socket” 通訊。通俗點說,客戶端和伺服器間將建立一個持續的連線,這讓雙方都能在任何時候傳送資料給彼此。

[譯] JavaScript 是如何工作的:深入剖析 WebSockets 和擁有 SSE 技術 的 HTTP/2,以及如何在二者中做出正確的選擇

客戶端通過一個被稱為 WebSocket **握手(handshake)**的過程建立一個 WebSocket 連線。該過程開始於客戶端傳送了一個普通的 HTTP 請求到伺服器。一個 Upgrade header 包含在了請求頭中,它告訴了伺服器現在客戶端想要建立一個 WebSocket 連線。

讓我們看看在客戶端如何開啟一個 WebSocket 連線:

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

WebSocket URL 使用了 ws scheme。也可以使用 wss 來服務於安全的 WebSocket 連線,這類似於 HTTPS

這個 scheme 僅只是啟動了一個程式來開啟客戶端到 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 中這是如何實現的:

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

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

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

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

  // 下面這個回撥方法很重要,我們將在這裡處理所有來自使用者的訊息
  connection.on('message', function(message) {
      // 處理 WebSocket 訊息
  });

  connection.on('close', function(connection) {
    // 連線關閉時進行的操作
  });
});
複製程式碼

在連線建立以後,伺服器通過響應頭的 Upgrade 進行回覆:

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 被開啟後,顯示一條已連線訊息。
socket.onopen = function(event) {
  console.log('WebSocket is connected.');
};
複製程式碼

現在,握手完成,最初的一個 HTTP 連線被一個使用相同底層 TCP/IP 連線的 WebSocket 連線所取代。自此,任何一方都可以開始傳送資料了。

通過 WebSockets,你可以盡情地傳輸資料,而不會遇到使用傳統 HTTP 請求時的瓶頸。使用 WebSocket 傳輸的資料被稱作訊息(messages),每一條訊息都包含了一個或多個幀(frames),它們承載了你要傳送的資料(payload)。為了保證訊息在送達客戶端以後能夠被正確解析,每一幀都會在頭部填充關於 payload 的 4-12 個位元組。基於幀的訊息系統能夠減少非 payload 資料的傳輸數量,從而大幅減少延遲。

注意:需要留意的是,只有當所有幀都到達,並且原始訊息 payload 也被解析,客戶端才會接受新訊息通知。

WebSocket URLs

前文中,我們簡要介紹了 WebSocket 引入了一個新的 URL scheme。實際上,其引入了兩個新的 schema(協議識別符號):ws://wss://

WebSocket URLs 則有一個指定 schema 的語法。WebSocket URLs 較為特別,它們並不支援錨點(anchor),例如 #sample_anchor

WebSocket 風格的 URL 與 HTTP 風格的 URL 具有相同的規則。ws 不會進行加密編碼,並且預設埠是 80。而 wss 則要求 TLS 編碼,且預設埠是 443。

成幀協議(Framing Protocal)

讓我們深入到成幀協議中。下面是 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 ...                |
+---------------------------------------------------------------+
複製程式碼

在 RFC 所規定的 WebSocket 版本中,每個包只有一個頭部,但是這個頭部非常複雜。現在我們解釋下它的組成部分:

  • fin (1 bits):指出了當前幀是訊息的最後一幀。絕大多數時候訊息都能被一幀容納,所以這一個 bit 通常都會被設定。實驗顯示 FireFox 將會在 32K 之後建立第二個幀。
  • rsv1rsv2rsv3(每個都是 1 bits):除非擴充套件協議為它們定義了非零值的含義,否則三者都應當被設定為 0。如果收到了一個非零值,並且沒有任何沒有任何擴充套件協議定義了該非零值的意義,那麼接收端將會使這次連線失敗。
  • opcode(4 bits):說明了幀的含義。下面是一些經常使用的取值:

0x00:當前幀繼續傳輸上一幀的 payload。

0x01:當前幀含有文字資料。

0x02:當前幀含有二進位制資料。

0x08:當前幀終止了連線。 ​ 0x09:當前幀為 ping。 ​ 0x0a:當前幀為 pong。

​ (如你所見,還有很多取值未被使用,未來它們會被用作表示其他含義。)

  • mask(1 bits):指示了連線是否被掩碼。就目前來說,每條從客戶端到伺服器的訊息都必須經過掩碼處理,否則,按規定需要終止連線。

  • payload_len(7 bits):payload 長度。WebSocket 的幀長度區間為:

    如果是 0–125,則直接指示了 payload 長度。如果是 126,則意味著接下來兩個位元組將指明長度,如果是 127,則意味著接下來 8 個位元組將指明長度。所以,一個 payload 的長度將可能是 7 bit、16 bit 或者 64 bit 以內。

  • masking-key(32 bits):所有由客戶端傳送給伺服器的幀都被一個包含在幀裡面的 32 bit 的值進行了掩碼處理。

  • payload:極大可能被掩碼了的實際資料,由 payload_len 標識了長度。

為什麼 WebSocket 是基於幀(frame-based)的,而不是基於流(stream-based)的?我和你一樣都不清楚,我也苛求學到更多,如果你對此有任何見解,可以在文章下面評論留言。當然,也可以加入到 HackerNews 上這個主題的討論中

幀裡面的資料

如上文所述,一段資料可以被分片為多個幀。傳輸資料的第一幀中通過一個 opcode 指出了需要被傳輸的資料是什麼型別。這是非常必要的,因為當規範出臺時,JavaScript 尚未對二進位制資料提供支援。0x01 指出了資料是 utf-8 編碼的文字資料,0x02 指出了資料是二進位制資料。大多數人們會在傳輸 JSON 時選擇文字 opcode。當你傳送二進位制資料時,資料會在瀏覽器中以一種特殊的 Blob 形式展現。

通過 WebSocket 傳送資料的 API 非常簡單:

var socket = new WebSocket('ws://websocket.example.com');
socket.onopen = function(event) {
  socket.send('Some message'); // Sends data to server.
};
複製程式碼

當 WebSocket 開始接收資料(在客戶端),一個 message 事件就會被觸發。該事件包含了一個叫做 data 的屬性可以被用來訪問訊息內容。

// 處理伺服器送來的資料。
socket.onmessage = function(event) {
  var message = event.data;
  console.log(message);
};
複製程式碼

通過 Chrome 開發者工具中的 Network Tab,你可以很容易地檢視 WebSocket 連線中的每一幀資料。

[譯] JavaScript 是如何工作的:深入剖析 WebSockets 和擁有 SSE 技術 的 HTTP/2,以及如何在二者中做出正確的選擇

分片(Fragmentation)

payload 可以被劃分為多個獨立的幀。接收端被認為能夠快取這些幀,直到某個幀的 fin 位被設定。所以你可以用 11 個包傳輸 “Hello World” 字串,每個包大小為 6(頭部長度)+ 1 位元組。對於控制包(control package)來說,分片則是不被允許的。然而,你被要求能夠處理交錯的控制幀。這是為了應付 TCP 包是以任意序列到達的狀況。

合併各個幀的邏輯大致如下:

  • 收到第一幀
  • 記住 opcode
  • 連線各個幀的 payload 直到 fin 被設定
  • 斷言每個包的 opcode 都是 0

分片的主要目的在於當訊息傳輸開始時,允許傳輸一個未知大小的訊息。通過分片技術,伺服器可以選擇合理的大小的 buffer,並在 buffer 充滿時,寫入一個分片到網路中。分片技術的次要用例則是多路複用(multiplexing),讓某個邏輯通道上的大訊息佔據整個輸出通道是不可取的,因此多路複用需要能夠支援將訊息劃分為若干小的分片,從而更好的共享輸出通道。

什麼是心跳機制?

握手完成之後的任意時刻,客戶端或者伺服器都能夠傳送一個 ping 到對面。當 ping 被接收以後,接收方必須儘快回送一個 pong。這就是一次心跳,你可以通過這個機制來確保客戶端仍處於連線狀態。

一個 ping 或者 pong 只是普通的一個幀,但它們是控制幀(control frame)。Ping 的 opcode 為 0x9,pong 則為 0xA。當你收到了一個 ping,你回送的 pong 需要和 ping 具有一樣的 payload data(ping 和 pong 允許的最大 payload 長度為 125)。如果你收到了沒有和一個 ping 結對的 pong 的話,直接忽略即可。

心跳機制是非常有用的。例如負載均衡這樣的一些服務可能會終止掉空閒連線,因此你需要利用心跳機制觀測連線狀況。另外,收信方是無法知道遠端連線是否終止。只有下一次傳送訊息時才能知道遠端是否被終止。

錯誤處理

你能夠通過監聽 event 事件處理任何發生的錯誤。

就像下面這樣:

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

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

關閉連線

為了關閉連線,客戶端或服務端都可以傳送一個 opcode 為 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 verb、狀態碼以及大多數頭部內容都仍然保持了一致。HTTP/2 著眼於提高資料的傳輸效率。

現在,如果我們對比 HTTP/2 和 WebSocket,會發現二者許多相似之處:

HTTP/2 WebSocket
頭部(Headers) 壓縮(HPACK) 不壓縮
二進位制資料(Binary) Yes 二進位制或文字資料
多路複用(Multiplexing) Yes Yes
優先順序技術(Prioritization) Yes Yes
壓縮(Compression) Yes Yes
方向(Direction) Client/Server + Server Push 雙向的
全雙工(Full-deplex) Yes Yes

正如我們之前提到的,HTTP/2 引入了 Server Push 來允許伺服器主動地傳送資源到客戶端快取中。但是,並不允許直接傳送資料到客戶端應用程式中。伺服器推送的內容只能被瀏覽器處理,而不是客戶端應用程式程式碼,這意味著應用中沒有 API 能夠感知到推送。

這也讓 Server-Sent Events(SSE)變得很有用。當客戶端和伺服器的連線建立後,SSE 這個機制能夠讓伺服器非同步地推送資料到客戶端。之後,伺服器隨時都可以在準備好後傳送資料。這可以被看作是單向的 釋出-訂閱 模型。SSE 還提供了一個叫做 EventSource 的標準 JavaScript 客戶端 API,這個 API 已經被大多數現代瀏覽器作為 W3C 所制定的HTML5 標準的一部分所實現了。對於那些不支援 EventSource API 的瀏覽器來說,這些 API 也能被輕易地 polyfill。

由於 SSE 是基於 HTTP 的,所以它天然親和 HTTP/2,因此可以組合二者,以吸取各自精華:HTTP/2 通過多路複用流來提高傳輸層的效率,SSE 則為客戶端應用程式提供了接收推送的 API。

為了完整地解釋流和多路複用是什麼,讓我們先看看 IETF 對此的定義:

“流(stream)” 是一個獨立的、雙向的幀序列,這些幀在處於 HTTP/2 連線中的客戶端和伺服器之間交換。其主要特徵是一個單個 HTTP/2 連線可以包含多個同時開啟的流,任意一端都可以交錯地使用這些流中的幀。

[譯] JavaScript 是如何工作的:深入剖析 WebSockets 和擁有 SSE 技術 的 HTTP/2,以及如何在二者中做出正確的選擇

要記住 SSE 是基於 HTTP 的。這意味著通過使用 HTTP/2,不僅能夠將 SSE 流交錯地送入到一個 TCP 連線中去,也能完成 SSE 流(伺服器向客戶端推送)的合併的和客戶端請求(客戶端到伺服器)的合併。得益於 HTTP/2 和 SSE,我們現在得到了一個具有簡潔 API 的 HTTP 雙向連線,這讓應用程式碼能監聽到伺服器推送。曾幾何時,雙向通訊能力的缺失成為了 SSE 相對於 WebSocket 的主要缺陷。但 HTTP/2 讓這不再成為問題。這使得開發者能夠迴歸到基於 HTTP 的通訊方式,而不再使用 WebSocket。

如何在 WebSocket 和 HTTP/2 中作出選擇?

在 HTTP/2 + SSE 的大浪潮中,WebSocket 仍將保有一席之地,因為它已經被廣泛使用,在一些非常特殊的使用場景下,相較於 HTTP/2,其優勢在於能夠以更少的開銷(如頭部資訊)來構建應用的雙向通訊能力。

倘若你想要構建一個端到端之間需要傳輸大量訊息的大型多人線上遊戲,WebSocket 將非常非常適合。

一般而言,當你需要真正的低延遲,希望客戶端和伺服器能有接近實時的連線,就使用 WebSocket。這就可能需要你重新審視和構建你的服務端應用,並聚焦到事件佇列這樣的技術上。

如果你的使用場景是展示實時市場新聞、市場資料、或是聊天應用等等,那麼 HTTP/2 + SSE 能讓你繼續受益於 HTTP 世界時,還能享受到高效的雙向通訊通道:

  • WebSocket 在處理瀏覽器相容性時讓人頭痛,因為其將 HTTP 連線更新到了一個完全不同協議,因此無法再用 HTTP 做任何事。
  • 擴充套件性和安全性:Web 元件(防火牆、入侵檢測、負載均衡)是基於 HTTP 來構建、維護和配置的,考慮到彈性伸縮、安全性和可擴充套件,那些大型/重要的應用會選擇使用 HTTP。

接下來,你可以看下幾種技術的瀏覽器支援狀況。首先看到 WebSocket:

[譯] JavaScript 是如何工作的:深入剖析 WebSockets 和擁有 SSE 技術 的 HTTP/2,以及如何在二者中做出正確的選擇

WebSocket 相容性問題現在好多了,是吧?

HTTP/2 則有些尷尬:

[譯] JavaScript 是如何工作的:深入剖析 WebSockets 和擁有 SSE 技術 的 HTTP/2,以及如何在二者中做出正確的選擇

  • TLS-only (這倒不算壞)
  • 只有在 Windows 10 系統下才對 IE 11 部分支援
  • Safari 支援則需要系統是 OSX 10.11+
  • 只有在你可以通過 ALPN(你的伺服器需要支援的擴充套件)進行協商時,才能支援 HTTP/2

SSE 的支援則更好一些:

[譯] JavaScript 是如何工作的:深入剖析 WebSockets 和擁有 SSE 技術 的 HTTP/2,以及如何在二者中做出正確的選擇

只有 IE/Edge 沒有提供支援(Opera Mini 既不支援 SSE,也不支援 WebSocket,我們把它排除在外)。但在 IE/Edge 中,有一些正式的 polyfill 能夠幫助支援 SSE。

在 SessionStack 中,我們是如何作出決策的

我們在 SessionStack 中按需使用了 WebSocket 和 HTTP。一旦你將 SessionStack 整合到你的應用中,它就開始記錄所有的 DOM 改變、使用者互動、JavaScript 異常、堆疊跟蹤、失敗的網路請求以及 debug 資訊,允許你通過視訊來複現問題,從而瞭解到使用者到底做了什麼。SessionStack 是完全實時的並且不會對你的應用造成任何的效能影響。

這意味著,當使用者在使用瀏覽器時,你可以實時地觀察使用者的行為。在這個場景下,由於不需要雙向通訊(只是伺服器將資料流傳送到瀏覽器),所以我們選擇了 HTTP。WebSocket 在這個場景下則顯得大材小用了,難於維護和擴充套件。

然而整合到你應用中的 SessionStack 庫卻是使用的 WebSocket(如果支援的話,否則會退回到 HTTP)。其批量傳送數資料到我們伺服器,這也是一個單向通訊。這個場景下,我們仍選擇 WebSocket 是因為其為產品藍圖中的一些需要雙向通訊的特性提供了支援。

嘗試使用 SessionStack 來了解和重現你 web 應用中存在的技術或者體驗問題,我們為你提供了一個免費計劃讓你 快速開始

[譯] JavaScript 是如何工作的:深入剖析 WebSockets 和擁有 SSE 技術 的 HTTP/2,以及如何在二者中做出正確的選擇

參考資料


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章