2022 年 10 月 14 日

WebSocket

在規格 RFC 6455 中所描述的 WebSocket 協定,提供了一種透過持續連線在瀏覽器和伺服器之間交換資料的方法。資料可以透過「封包」的形式雙向傳遞,而不會中斷連線,也不需要額外的 HTTP 要求。

WebSocket 特別適合需要持續資料交換的服務,例如線上遊戲、即時交易系統等。

一個簡單的範例

要開啟一個 websocket 連線,我們需要使用 url 中的特殊協定 ws 來建立 new WebSocket

let socket = new WebSocket("ws://javascriptinfo.dev.org.tw");

也有加密的 wss:// 協定。它就像 websockets 的 HTTPS。

總是優先使用 wss://

wss:// 協定不僅加密,而且更可靠。

這是因為 ws:// 資料未加密,任何中介者都可以看到。舊的代理伺服器不了解 WebSocket,它們可能會看到「奇怪」的標頭並中止連線。

另一方面,wss:// 是透過 TLS 的 WebSocket(就像 HTTPS 是透過 TLS 的 HTTP),傳輸安全層會在傳送端加密資料,並在接收端解密資料。因此,資料封包會透過代理伺服器加密傳遞。它們看不到裡面的內容,並讓它們通過。

一旦建立 socket,我們應該監聽它的事件。總共有 4 個事件

  • open – 建立連線,
  • message – 收到資料,
  • error – websocket 錯誤,
  • close – 連線關閉。

…如果我們想要傳送一些東西,那麼 socket.send(data) 就會執行此動作。

以下是一個範例

let socket = new WebSocket("wss://javascriptinfo.dev.org.tw/article/websocket/demo/hello");

socket.onopen = function(e) {
  alert("[open] Connection established");
  alert("Sending to server");
  socket.send("My name is John");
};

socket.onmessage = function(event) {
  alert(`[message] Data received from server: ${event.data}`);
};

socket.onclose = function(event) {
  if (event.wasClean) {
    alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
  } else {
    // e.g. server process killed or network down
    // event.code is usually 1006 in this case
    alert('[close] Connection died');
  }
};

socket.onerror = function(error) {
  alert(`[error]`);
};

為了示範目的,有一個使用 Node.js 編寫的小型伺服器 server.js,針對上面的範例執行。它會回應「Hello from server, John」,然後等待 5 秒並關閉連線。

因此,您將看到事件 openmessageclose

實際上就是這樣,我們已經可以討論 WebSocket 了。很簡單,不是嗎?

現在讓我們更深入地討論。

開啟 websocket

當建立 new WebSocket(url) 時,它會立即開始連線。

在連線期間,瀏覽器(使用標頭)會詢問伺服器:「您是否支援 Websocket?」如果伺服器回答「是」,則會繼續使用 WebSocket 協定進行對話,這根本不是 HTTP。

以下是由 new WebSocket("wss://javascriptinfo.dev.org.tw/chat") 請求所建立的瀏覽器標頭範例。

GET /chat
Host: javascript.info
Origin: https://javascriptinfo.dev.org.tw
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
  • Origin – 來源客戶端頁面,例如 https://javascriptinfo.dev.org.tw。WebSocket 物件本質上是跨來源的。沒有特殊標頭或其他限制。舊伺服器無法處理 WebSocket,因此沒有相容性問題。但是 Origin 標頭很重要,因為它允許伺服器決定是否與此網站對話 WebSocket。
  • Connection: Upgrade – 表示客戶端想要變更協定。
  • Upgrade: websocket – 請求的協定是「websocket」。
  • Sec-WebSocket-Key – 瀏覽器隨機產生的金鑰,用於確保伺服器支援 WebSocket 協定。它是隨機的,以防止代理伺服器快取任何後續通訊。
  • Sec-WebSocket-Version – WebSocket 協定版本,13 是目前的版本。
WebSocket 握手無法模擬

我們無法使用 XMLHttpRequestfetch 來進行這種 HTTP 請求,因為 JavaScript 不允許設定這些標頭。

如果伺服器同意切換到 WebSocket,它應該傳送代碼 101 回應

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=

這裡的 Sec-WebSocket-AcceptSec-WebSocket-Key,使用特殊演算法重新編碼。瀏覽器看到它之後,就會了解伺服器確實支援 WebSocket 協定。

之後,資料會使用 WebSocket 協定傳輸,我們很快就會看到它的結構(「框架」)。那完全不是 HTTP。

擴充功能和子協定

可能會出現其他標頭 Sec-WebSocket-ExtensionsSec-WebSocket-Protocol,用來描述擴充功能和子協定。

例如

  • Sec-WebSocket-Extensions: deflate-frame 表示瀏覽器支援資料壓縮。擴充功能是與傳輸資料相關的項目,是延伸 WebSocket 協定的功能。標頭 Sec-WebSocket-Extensions 會由瀏覽器自動傳送,其中包含它支援的所有擴充功能清單。

  • Sec-WebSocket-Protocol: soap, wamp 表示我們想要傳輸的不只是任何資料,而是 SOAP 或 WAMP(「WebSocket 應用程式訊息傳遞協定」)協定中的資料。WebSocket 子協定會註冊在 IANA 目錄 中。因此,這個標頭描述了我們要使用的資料格式。

    這個選用標頭會使用 new WebSocket 的第二個參數來設定。那是子協定的陣列,例如如果我們想要使用 SOAP 或 WAMP

    let socket = new WebSocket("wss://javascriptinfo.dev.org.tw/chat", ["soap", "wamp"]);

伺服器應該回應它同意使用的協定和擴充功能清單。

例如,請求

GET /chat
Host: javascript.info
Upgrade: websocket
Connection: Upgrade
Origin: https://javascriptinfo.dev.org.tw
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: deflate-frame
Sec-WebSocket-Protocol: soap, wamp

回應

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=
Sec-WebSocket-Extensions: deflate-frame
Sec-WebSocket-Protocol: soap

這裡的伺服器回應它支援擴充功能「deflate-frame」,以及請求的子協定中只有 SOAP。

資料傳輸

WebSocket 通訊包含「框架」- 資料片段,可以從任一端傳送,而且可以有數種種類

  • 「文字框架」- 包含各方彼此傳送的文字資料。
  • 「二進位資料框架」- 包含各方彼此傳送的二進位資料。
  • 「ping/pong 框架」用於檢查連線,由伺服器傳送,瀏覽器會自動回應這些框架。
  • 還有「連線關閉框架」和一些其他服務框架。

在瀏覽器中,我們直接只使用文字或二進制框架。

WebSocket .send() 方法可以傳送文字或二進制資料。

呼叫 socket.send(body) 允許 body 為字串或二進制格式,包括 BlobArrayBuffer 等。不需要任何設定:只要以任何格式傳送即可。

當我們收到資料時,文字總是會以字串形式出現。而對於二進制資料,我們可以在 BlobArrayBuffer 格式之間選擇。

這由 socket.binaryType 屬性設定,預設為 "blob",因此二進制資料會以 Blob 物件形式出現。

Blob 是一個高階二進制物件,它直接與 <a><img> 和其他標籤整合,因此這是一個合理的預設值。但對於二進制處理,若要存取個別資料位元組,我們可以將其變更為 "arraybuffer"

socket.binaryType = "arraybuffer";
socket.onmessage = (event) => {
  // event.data is either a string (if text) or arraybuffer (if binary)
};

速率限制

想像一下,我們的應用程式產生大量資料要傳送。但使用者的網路連線很慢,可能在行動網路中,在城市外。

我們可以一再呼叫 socket.send(data)。但資料會緩衝(儲存)在記憶體中,並且只會以網路速度允許的速度傳送出去。

socket.bufferedAmount 屬性儲存目前有多少位元組仍處於緩衝狀態,等待透過網路傳送。

我們可以檢查它,看看 socket 是否實際上可供傳輸。

// every 100ms examine the socket and send more data
// only if all the existing data was sent out
setInterval(() => {
  if (socket.bufferedAmount == 0) {
    socket.send(moreData());
  }
}, 100);

連線關閉

通常,當一方想要關閉連線時(瀏覽器和伺服器具有相同的權利),他們會傳送一個「連線關閉框架」,其中包含一個數字代碼和一個文字原因。

方法如下

socket.close([code], [reason]);
  • code 是特殊的 WebSocket 關閉代碼(可選)
  • reason 是描述關閉原因的字串(可選)

然後,close 事件處理程式中的另一方會取得代碼和原因,例如

// closing party:
socket.close(1000, "Work complete");

// the other party
socket.onclose = event => {
  // event.code === 1000
  // event.reason === "Work complete"
  // event.wasClean === true (clean close)
};

最常見的代碼值

  • 1000 – 預設值,正常關閉(如果沒有提供 code,則使用)
  • 1006 – 無法手動設定此類代碼,表示連線已中斷(沒有關閉框架)。

還有其他代碼,例如

  • 1001 – 一方即將離開,例如伺服器正在關閉,或瀏覽器離開頁面,
  • 1009 – 訊息過大,無法處理,
  • 1011 – 伺服器發生意外錯誤,
  • …等等。

完整清單可在 RFC6455, §7.4.1 中找到。

WebSocket 狀態碼有點像 HTTP 狀態碼,但不同。特別是,低於 1000 的狀態碼是保留的,如果我們嘗試設定此類狀態碼,將會發生錯誤。

// in case connection is broken
socket.onclose = event => {
  // event.code === 1006
  // event.reason === ""
  // event.wasClean === false (no closing frame)
};

連線狀態

若要取得連線狀態,另外還有 socket.readyState 屬性,其值為

  • 0 – “CONNECTING”:連線尚未建立,
  • 1 – “OPEN”:正在通訊,
  • 2 – “CLOSING”:連線正在關閉,
  • 3 – “CLOSED”:連線已關閉。

聊天範例

讓我們檢閱一個聊天範例,使用瀏覽器 WebSocket API 和 Node.js WebSocket 模組 https://github.com/websockets/ws。我們將主要關注於客戶端,但伺服器也很簡單。

HTML:我們需要一個 <form> 來傳送訊息,以及一個 <div> 來接收訊息

<!-- message form -->
<form name="publish">
  <input type="text" name="message">
  <input type="submit" value="Send">
</form>

<!-- div with messages -->
<div id="messages"></div>

從 JavaScript,我們想要三件事

  1. 開啟連線。
  2. 在表單提交時 – socket.send(message) 傳送訊息。
  3. 在接收訊息時 – 將其附加到 div#messages

以下是程式碼

let socket = new WebSocket("wss://javascriptinfo.dev.org.tw/article/websocket/chat/ws");

// send message from the form
document.forms.publish.onsubmit = function() {
  let outgoingMessage = this.message.value;

  socket.send(outgoingMessage);
  return false;
};

// message received - show the message in div#messages
socket.onmessage = function(event) {
  let message = event.data;

  let messageElem = document.createElement('div');
  messageElem.textContent = message;
  document.getElementById('messages').prepend(messageElem);
}

伺服器端程式碼超出了我們的範圍。這裡我們將使用 Node.js,但你不需要。其他平台也有自己的方式來處理 WebSocket。

伺服器端演算法將會是

  1. 建立 clients = new Set() – 一組 socket。
  2. 對於每個已接受的 websocket,將其加入到集合 clients.add(socket) 中,並設定 message 事件監聽器來取得其訊息。
  3. 當收到訊息時:遍歷客戶端,並將訊息傳送給所有人。
  4. 當連線關閉時:clients.delete(socket)
const ws = new require('ws');
const wss = new ws.Server({noServer: true});

const clients = new Set();

http.createServer((req, res) => {
  // here we only handle websocket connections
  // in real project we'd have some other code here to handle non-websocket requests
  wss.handleUpgrade(req, req.socket, Buffer.alloc(0), onSocketConnect);
});

function onSocketConnect(ws) {
  clients.add(ws);

  ws.on('message', function(message) {
    message = message.slice(0, 50); // max message length will be 50

    for(let client of clients) {
      client.send(message);
    }
  });

  ws.on('close', function() {
    clients.delete(ws);
  });
}

以下是運作範例

你也可以下載它(iframe 中的右上角按鈕)並在本地執行。在執行之前,別忘了安裝 Node.jsnpm install ws

摘要

WebSocket 是一種現代的方式,可以建立持久的瀏覽器伺服器連線。

  • WebSocket 沒有跨來源限制。
  • 它們在瀏覽器中獲得良好的支援。
  • 可以傳送/接收字串和二進位資料。

API 很簡單。

方法

  • socket.send(data),
  • socket.close([code], [reason]).

事件

  • open,
  • message,
  • error,
  • close.

WebSocket 本身不包含重新連線、驗證和許多其他高階機制。因此,有客戶端/伺服器程式庫可以做到這一點,也可以手動實作這些功能。

有時,為了將 WebSocket 整合到現有的專案中,人們會在主 HTTP 伺服器平行執行 WebSocket 伺服器,並且共用一個資料庫。對 WebSocket 的請求使用 wss://ws.site.com,一個指向 WebSocket 伺服器的子網域,而 https://site.com 則指向主 HTTP 伺服器。

當然,其他整合方式也是可行的。

教學課程地圖

留言

留言前請先閱讀…
  • 如果你有改善建議,請 提交 GitHub 問題 或提出 pull 請求,而不是留言。
  • 如果你看不懂文章中的某個部分,請說明。
  • 要插入少數幾個字的程式碼,請使用 <code> 標籤;要插入多行程式碼,請用 <pre> 標籤將它們包起來;要插入 10 行以上的程式碼,請使用沙盒 (plnkrjsbincodepen…)