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

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

若是你錯過了前面的文章,你能夠在這兒找到它們:html

  1. 對引擎、運行時和調用棧的概述
  2. 深刻 V8 引擎以及 5 個寫出更優代碼的技巧
  3. 內存管理以及四種常見的內存泄漏的解決方法
  4. 事件循環和異步編程的崛起以及 5 個如何更好的使用 async/await 編碼的技巧

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

簡介

如今,富交互 web 應用已然司空見慣了。因爲 internet 通過了漫長的發展,這一點看起來也不足爲奇了。java

最初,internet 的創建不是爲了支持這樣動態的、複雜的 web 應用程序。它只被認爲是一個 HTML 頁面的集合,頁面間可以連接到其餘頁面,從而構成了一個 「web」 這樣一個信息載體的概念。internet 中每一個事物都是由 HTTP 中的請求/響應(request/response)範式構建而成。一個客戶端加載了一個頁面後將不會再發生任何事,除非用戶點擊並跳轉到了下一頁。react

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

讓 HTTP 成爲 「雙向通訊的」

可以讓服務器「主動地」發送數據給客戶端的技術已經出現了一段時間了,例如 「Push」「Comet」ios

爲了製造出服務器主動給客戶端發送數據的假象,最經常使用的一個 hack 是長輪詢(long polling)。經過長輪詢,客戶端打開了一個到服務端的 HTTP 鏈接,該鏈接會一直保持直到有數據返回。不管何時服務器有了須要被送達的數據,它都會將數據做爲一個響應傳輸到客戶端。git

讓咱們看看一個很是簡單的長輪詢代碼片斷長什麼樣:github

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

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

這是一個自執行函數,它將自動運行。其設置了一個 10 秒的間隔,當一個異步請求發送完成後,在其回調方法中又會再次調用這個異步請求`。web

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

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

WebSockets 簡介

WebSocket 規範定義了一個 API 用來創建一個 web 瀏覽器和服務器之間的 「socket」 通訊。通俗點說,客戶端和服務器間將創建一個持續的鏈接,這讓雙方都能在任什麼時候候發送數據給彼此。

客戶端經過一個被稱爲 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 鏈接中的每一幀數據。

分片(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 鏈接能夠包含多個同時打開的流,任意一端均可以交錯地使用這些流中的幀。

要記住 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:

WebSocket 兼容性問題如今好多了,是吧?

HTTP/2 則有些尷尬:

  • TLS-only (這倒不算壞)
  • 只有在 Windows 10 系統下才對 IE 11 部分支持
  • Safari 支持則須要系統是 OSX 10.11+
  • 只有在你能夠經過 ALPN(你的服務器須要支持的擴展)進行協商時,才能支持 HTTP/2

SSE 的支持則更好一些:

只有 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 應用中存在的技術或者體驗問題,咱們爲你提供了一個免費計劃讓你 快速開始

參考資料


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

相關文章
相關標籤/搜索