萬字長文,一篇吃透WebSocket:概念、原理、易錯常識、動手實踐

JackJiang發表於2021-10-11

本文由作者“阿寶哥”分享,原題“你不知道的 WebSocket”,有修訂和改動。

1、引言

本文將從基本概念、技術原理、常見易錯常識、動手實踐等多個方面入手,萬字長文,帶你一起全方位探索 WebSocket 技術。

閱讀完本文,你將瞭解以下內容:

1)瞭解 WebSocket 的誕生背景、WebSocket 是什麼及它的優點;
2)瞭解 WebSocket 含有哪些 API 及如何使用 WebSocket API 傳送普通文字和二進位制資料;
3)瞭解 WebSocket 的握手協議和資料幀格式、掩碼演算法等相關知識;
4)瞭解 WebSocket 與http、長輪詢、socket等的關係,理清常識性的理解錯誤;
5)瞭解如何實現一個支援傳送普通文字的 WebSocket 伺服器。

學習交流:

  • 即時通訊/推送技術開發交流5群:215477170 [推薦]
  • 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM》
  • 開源IM框架原始碼:https://github.com/JackJiang2...

(本文同步釋出於:http://www.52im.net/thread-37...

2、關於作者

作者網名:阿寶哥
個人部落格:http://www.semlinker.com/
作者Github:https://github.com/semlinker/

3、什麼是 WebSocket

3.1 WebSocket 誕生背景
早期,很多網站為了實現推送技術,所用的技術都是輪詢(也叫短輪詢)。輪詢是指由瀏覽器每隔一段時間向伺服器發出 HTTP 請求,然後伺服器返回最新的資料給客戶端。

常見的輪詢方式分為輪詢與長輪詢,它們的區別如下圖所示:

為了更加直觀感受輪詢與長輪詢之間的區別,我們來看一下具體的程式碼:

這種傳統的模式帶來很明顯的缺點,即瀏覽器需要不斷的向伺服器發出請求,然而 HTTP 請求與響應可能會包含較長的頭部,其中真正有效的資料可能只是很小的一部分,所以這樣會消耗很多頻寬資源。

PS:關於短輪詢、長輪詢技術的前世今身,可以詳細讀這兩篇:《新手入門貼:史上最全Web端即時通訊技術原理詳解》、《Web端即時通訊技術盤點:短輪詢、Comet、Websocket、SSE》。

比較新的輪詢技術是 Comet。這種技術雖然可以實現雙向通訊,但仍然需要反覆發出請求。而且在 Comet 中普遍採用的 HTTP 長連線也會消耗伺服器資源。

在這種情況下,HTML5 定義了 WebSocket 協議,能更好的節省伺服器資源和頻寬,並且能夠更實時地進行通訊。

Websocket 使用 ws 或 wss 的統一資源標誌符(URI),其中 wss 表示使用了 TLS 的 Websocket。

如:

ws://echo.websocket.org
wss://echo.websocket.org

WebSocket 與 HTTP 和 HTTPS 使用相同的 TCP 埠,可以繞過大多數防火牆的限制。

預設情況下:

  • 1)WebSocket 協議使用 80 埠;
  • 2)若執行在 TLS 之上時,預設使用 443 埠。

3.2 WebSocket 簡介
WebSocket 是一種網路傳輸協議,可在單個 TCP 連線上進行全雙工通訊,位於 OSI 模型的應用層。WebSocket 協議在 2011 年由 IETF 標準化為 RFC 6455,後由 RFC 7936 補充規範。

WebSocket 使得客戶端和伺服器之間的資料交換變得更加簡單,允許服務端主動向客戶端推送資料。在 WebSocket API 中,瀏覽器和伺服器只需要完成一次握手,兩者之間就可以建立永續性的連線,並進行雙向資料傳輸。

介紹完輪詢和 WebSocket 的相關內容之後,接下來用一張圖看一下 XHR Polling(短輪詢) 與 WebSocket 之間的區別。

XHR Polling與 WebSocket 之間的區別如下圖所示:

3.3 WebSocket 優點
普遍認為,WebSocket的優點有如下幾點:

  • 1)較少的控制開銷:在連線建立後,伺服器和客戶端之間交換資料時,用於協議控制的資料包頭部相對較小;
  • 2)更強的實時性:由於協議是全雙工的,所以伺服器可以隨時主動給客戶端下發資料。相對於 HTTP 請求需要等待客戶端發起請求服務端才能響應,延遲明顯更少;
  • 3)保持連線狀態:與 HTTP 不同的是,WebSocket 需要先建立連線,這就使得其成為一種有狀態的協議,之後通訊時可以省略部分狀態資訊;
  • 4)更好的二進位制支援:WebSocket 定義了二進位制幀,相對 HTTP,可以更輕鬆地處理二進位制內容;
  • 5)可以支援擴充套件:WebSocket 定義了擴充套件,使用者可以擴充套件協議、實現部分自定義的子協議。

由於 WebSocket 擁有上述的優點,所以它被廣泛地應用在即時通訊/IM、實時音視訊、線上教育和遊戲等領域。

對於前端開發者來說,要想使用 WebSocket 提供的強大能力,就必須先掌握 WebSocket API,下面帶大家一起來認識一下 WebSocket API。

PS:如果你想要更淺顯的WebSocket入門教程,可以先讀這篇《新手快速入門:WebSocket簡明教程》後,再回來繼續學習。

4、WebSocket API 學習

4.1 基本情況
在介紹 WebSocket API 之前,我們先來了解一下它的相容性:

(圖片引用自:https://caniuse.com/#search=W...

由上圖可知:目前主流的 Web 瀏覽器都支援 WebSocket,所以我們可以在大多數專案中放心地使用它。

在瀏覽器中要使用 WebSocket 提供的能力,我們就必須先建立 WebSocket 物件,該物件提供了用於建立和管理 WebSocket 連線,以及可以通過該連線傳送和接收資料的 API。

使用 WebSocket 建構函式,我們就能輕易地構造一個 WebSocket 物件。

接下來我們將從以下四個方面來介紹 WebSocket API:

1)WebSocket 建構函式;
2)WebSocket 物件的屬性;
3)WebSocket 的方法;
4)WebSocket 事件。

接下來我們從 WebSocket 的建構函式入手開始學習。

PS:如果你想要更淺顯的WebSocket入門教程,可以先讀這篇《新手快速入門:WebSocket簡明教程》後,再回來繼續學習。

4.2 建構函式
WebSocket 建構函式的語法為:

const myWebSocket = newWebSocket(url [, protocols]);

相關引數說明如下:

1)url:表示連線的 URL,這是 WebSocket 伺服器將響應的 URL;
2)protocols(可選):一個協議字串或者一個包含協議字串的陣列。

針對第2)點:這些字串用於指定子協議,這樣單個伺服器可以實現多個 WebSocket 子協議。

比如:你可能希望一臺伺服器能夠根據指定的協議(protocol)處理不同型別的互動。如果不指定協議字串,則假定為空字串。

使用WebSocket 建構函式時,當嘗試連線的埠被阻止時,會丟擲 SECURITY_ERR 異常。

PS:有關WebSocket建構函式的更詳細說明,可以參見官方API文件。

4.3 屬性
WebSocket 物件包含以下屬性:

每個屬性的具體含義如下:

1)binaryType:使用二進位制的資料型別連線;
2)bufferedAmount(只讀):未傳送至伺服器的位元組數;
3)extensions(只讀):伺服器選擇的擴充套件;
4)onclose:用於指定連線關閉後的回撥函式;
5)onerror:用於指定連線失敗後的回撥函式;
6)onmessage:用於指定當從伺服器接受到資訊時的回撥函式;
7)onopen:用於指定連線成功後的回撥函式;
8)protocol(只讀):用於返回伺服器端選中的子協議的名字;
9)readyState(只讀):返回當前 WebSocket 的連線狀態,共有 4 種狀態:

- CONNECTING — 正在連線中,對應的值為 0;
- OPEN — 已經連線並且可以通訊,對應的值為 1;
- CLOSING — 連線正在關閉,對應的值為 2;
- CLOSED — 連線已關閉或者沒有連線成功,對應的值為 3

10)url(只讀):返回值為當建構函式建立 WebSocket 例項物件時 URL 的絕對路徑。

4.4 方法
WebSocket 主要方法有兩個:

1)close([code[, reason]]):該方法用於關閉 WebSocket 連線,如果連線已經關閉,則此方法不執行任何操作;
2)send(data):該方法將需要通過 WebSocket 連結傳輸至伺服器的資料排入佇列,並根據所需要傳輸的資料的大小來增加 bufferedAmount 的值 。若資料無法傳輸(比如資料需要快取而緩衝區已滿)時,套接字會自行關閉。

4.5 事件
使用 addEventListener() 或將一個事件監聽器賦值給 WebSocket 物件的oneventname 屬性,來監聽下面的事件。

以下是幾個事件:

1)close:當一個 WebSocket 連線被關閉時觸發,也可以通過 onclose 屬性來設定;
2)error:當一個 WebSocket 連線因錯誤而關閉時觸發,也可以通過 onerror 屬性來設定;
3)message:當通過 WebSocket 收到資料時觸發,也可以通過 onmessage 屬性來設定;
4)open:當一個 WebSocket 連線成功時觸發,也可以通過 onopen 屬性來設定。

介紹完 WebSocket API,我們來舉一個使用 WebSocket 傳送普通文字的示例。

4.6 程式碼實踐:傳送普通文字

在以上示例中:我們在頁面上建立了兩個 textarea,分別用於存放 待傳送的資料 和 伺服器返回的資料。當使用者輸入完待傳送的文字之後,點選 傳送 按鈕時會把輸入的文字傳送到服務端,而服務端成功接收到訊息之後,會把收到的訊息原封不動地回傳到客戶端。

// const socket = new WebSocket("ws://echo.websocket.org");
// const sendMsgContainer = document.querySelector("#sendMessage");
引用
function send() {
const message = sendMsgContainer.value;
引用
if(socket.readyState !== WebSocket.OPEN) {

console.log("連線未建立,還不能傳送訊息");

引用

return;

引用
}
引用
if(message) socket.send(message);
引用
}
引用
當然客戶端接收到服務端返回的訊息之後,會把對應的文字內容儲存到 接收的資料 對應的 textarea 文字框中。
引用
// const socket = new WebSocket("ws://echo.websocket.org");
引用
// const receivedMsgContainer = document.querySelector("#receivedMessage");
引用
socket.addEventListener("message", function(event) {
console.log("Message from server ", event.data);
引用
receivedMsgContainer.value = event.data;
引用
});

為了更加直觀地理解上述的資料互動過程,我們使用 Chrome 瀏覽器的開發者工具來看一下相應的過程。

如下圖所示:

以上示例對應的完整程式碼如下所示:

<!DOCTYPE html>
<html>
引用
<head>
引用

<metacharset="UTF-8"/>

引用

<metaname="viewport"content="width=device-width, initial-scale=1.0"/>

引用

<title>WebSocket 傳送普通文字示例</title>

引用

<style>

引用

  .block {
    flex: 1;

引用

  }

引用

</style>

引用
</head>
引用
<body>
引用

<h3>WebSocket 傳送普通文字示例</h3>

引用

<divstyle="display: flex;">

引用

  <divclass="block">

引用

    <p>即將傳送的資料:<button>傳送</button></p>

引用

    <textareaid="sendMessage"rows="5"cols="15"></textarea>

引用

  </div>

引用

  <divclass="block">

引用

    <p>接收的資料:</p>

引用

    <textareaid="receivedMessage"rows="5"cols="15"></textarea>

引用

  </div>

引用

</div>

引用

<script>

引用

  const sendMsgContainer = document.querySelector("#sendMessage");

引用

  const receivedMsgContainer = document.querySelector("#receivedMessage");

引用

  const socket = new WebSocket("ws://echo.websocket.org");

引用

  // 監聽連線成功事件

引用

  socket.addEventListener("open", function (event) {
    console.log("連線成功,可以開始通訊");

引用

  });

引用

  // 監聽訊息

引用

  socket.addEventListener("message", function (event) {
    console.log("Message from server ", event.data);

引用

    receivedMsgContainer.value = event.data;

引用

  });

引用

  function send() {
    const message = sendMsgContainer.value;

引用

    if (socket.readyState !== WebSocket.OPEN) {
      console.log("連線未建立,還不能傳送訊息");

引用

      return;

引用

    }

引用

    if (message) socket.send(message);

引用

  }

引用

</script>

引用
</body>
引用
</html>

其實 WebSocket 除了支援傳送普通的文字之外,它還支援傳送二進位制資料,比如 ArrayBuffer 物件、Blob 物件或者 ArrayBufferView 物件。

程式碼示例如下:

const socket = new WebSocket("ws://echo.websocket.org");
引用
socket.onopen = function() {
// 傳送UTF-8編碼的文字資訊
引用
socket.send("Hello Echo Server!");
引用
// 傳送UTF-8編碼的JSON資料
引用
socket.send(JSON.stringify({ msg: "我是阿寶哥"}));
引用
// 傳送二進位制ArrayBuffer
引用
const buffer = newArrayBuffer(128);
引用
socket.send(buffer);
引用
// 傳送二進位制ArrayBufferView
引用
const intview = new Uint32Array(buffer);
引用
socket.send(intview);
引用
// 傳送二進位制Blob
引用
const blob = new Blob([buffer]);
引用
socket.send(blob);
引用
};

以上程式碼成功執行後,通過 Chrome 開發者工具,我們可以看到對應的資料互動過程。

如下圖所示:

下面以傳送 Blob 物件為例,來介紹一下如何傳送二進位制資料。

Blob(Binary Large Object)表示二進位制型別的大物件。在資料庫管理系統中,將二進位制資料儲存為一個單一個體的集合。Blob 通常是影像、聲音或多媒體檔案。在 JavaScript 中 Blob 型別的物件表示不可變的類似檔案物件的原始資料。

對 Blob 感興趣的小夥伴,可以閱讀 《你不知道的 Blob》這篇文章。

4.7 程式碼實踐:傳送二進位制資料

在以上示例中,我們在頁面上建立了兩個 textarea,分別用於存放 待傳送的資料 和 伺服器返回的資料。

當使用者輸入完待傳送的文字之後,點選 傳送 按鈕時,我們會先獲取輸入的文字並把文字包裝成 Blob 物件然後傳送到服務端,而服務端成功接收到訊息之後,會把收到的訊息原封不動地回傳到客戶端。

當瀏覽器接收到新訊息後,如果是文字資料,會自動將其轉換成 DOMString 物件,如果是二進位制資料或 Blob 物件,會直接將其轉交給應用,由應用自身來根據返回的資料型別進行相應的處理。

資料傳送程式碼:

// const socket = new WebSocket("ws://echo.websocket.org");
引用
// const sendMsgContainer = document.querySelector("#sendMessage");
引用
function send() {
const message = sendMsgContainer.value;
引用
if(socket.readyState !== WebSocket.OPEN) {

console.log("連線未建立,還不能傳送訊息");

引用

return;

引用
}
引用
const blob = newBlob([message], { type: "text/plain"});
引用
if(message) socket.send(blob);
引用
console.log(未傳送至伺服器的位元組數:${socket.bufferedAmount});
引用
}

當客戶端接收到服務端返回的訊息之後,會判斷返回的資料型別,如果是 Blob 型別的話,會呼叫 Blob 物件的 text() 方法,獲取 Blob 物件中儲存的 UTF-8 格式的內容,然後把對應的文字內容儲存到 接收的資料 對應的 textarea 文字框中。

資料接收程式碼:

// const socket = new WebSocket("ws://echo.websocket.org");
引用
// const receivedMsgContainer = document.querySelector("#receivedMessage");
引用
socket.addEventListener("message", async function(event) {
console.log("Message from server ", event.data);
引用
const receivedData = event.data;
引用
if(receivedData instanceofBlob) {

receivedMsgContainer.value = await receivedData.text();

引用
} else{

receivedMsgContainer.value = receivedData;

引用
}
引用
});

同樣,我們使用 Chrome 瀏覽器的開發者工具來看一下相應的過程:

通過上圖我們可以很明顯地看到,當使用傳送 Blob 物件時,Data 欄位的資訊顯示的是 Binary Message,而對於傳送普通文字來說,Data 欄位的資訊是直接顯示傳送的文字訊息。

以上示例對應的完整程式碼如下所示:

<!DOCTYPE html>
引用
<html>
引用
<head>
引用

<meta charset="UTF-8"/>

引用

<meta name="viewport"content="width=device-width, initial-scale=1.0"/>

引用

<title>WebSocket 傳送二進位制資料示例</title>

引用

<style>

引用

  .block {
    flex: 1;

引用

  }

引用

</style>

引用
</head>
引用
<body>
引用

<h3>WebSocket 傳送二進位制資料示例</h3>

引用

<div style="display: flex;">

引用

  <div class="block">

引用

    <p>待傳送的資料:<button>傳送</button></p>

引用

    <textarea id="sendMessage"rows="5"cols="15"></textarea>

引用

  </div>

引用

  <div class="block">

引用

    <p>接收的資料:</p>

引用

    <textarea id="receivedMessage"rows="5"cols="15"></textarea>

引用

  </div>

引用

</div>

引用

<script>

引用

  const sendMsgContainer = document.querySelector("#sendMessage");

引用

  const receivedMsgContainer = document.querySelector("#receivedMessage");

引用

  const socket = new WebSocket("ws://echo.websocket.org");

引用

  // 監聽連線成功事件

引用

  socket.addEventListener("open", function(event) {
    console.log("連線成功,可以開始通訊");

引用

  });

引用

  // 監聽訊息

引用

  socket.addEventListener("message", async function(event) {
    console.log("Message from server ", event.data);

引用

    const receivedData = event.data;

引用

    if(receivedData instanceofBlob) {
      receivedMsgContainer.value = await receivedData.text();

引用

    } else{
      receivedMsgContainer.value = receivedData;

引用

    }

引用

  });

引用

  functionsend() {
    const message = sendMsgContainer.value;

引用

    if(socket.readyState !== WebSocket.OPEN) {
      console.log("連線未建立,還不能傳送訊息");

引用

      return;

引用

    }

引用

    const blob = newBlob([message], { type: "text/plain"});

引用

    if(message) socket.send(blob);

引用

    console.log(`未傳送至伺服器的位元組數:${socket.bufferedAmount}`);

引用

  }

引用

</script>

引用
</body>
引用
</html>

可能有一些小夥伴瞭解完 WebSocket API 之後,覺得還不夠過癮。下面將帶大家來實現一個支援傳送普通文字的 WebSocket 伺服器。

5、手寫 WebSocket 伺服器

5.1 寫在前面
在介紹如何手寫 WebSocket 伺服器前,我們需要了解一下 WebSocket 連線的生命週期。

從上圖可知:在使用 WebSocket 實現全雙工通訊之前,客戶端與伺服器之間需要先進行握手(Handshake),在完成握手之後才能開始進行資料的雙向通訊。

握手是在通訊電路建立之後,資訊傳輸開始之前。

握手用於達成引數,如:

1)資訊傳輸率
2)字母表
3)奇偶校驗
4)中斷過程;
5)其他協議特性。

握手有助於不同結構的系統或裝置在通訊通道中連線,而不需要人為設定引數。

既然握手是 WebSocket 連線生命週期的第一個環節,接下來我們就先來分析 WebSocket 的握手協議。

5.2 握手協議
WebSocket 協議屬於應用層協議,它依賴於傳輸層的 TCP 協議。WebSocket 通過 HTTP/1.1 協議的 101 狀態碼進行握手。為了建立 WebSocket 連線,需要通過瀏覽器發出請求,之後伺服器進行回應,這個過程通常稱為 “握手”(Handshaking)。

利用 HTTP 完成握手有幾個好處:

1)首先:讓 WebSocket 與現有 HTTP 基礎設施相容——使得 WebSocket 伺服器可以執行在 80 和 443 埠上,這通常是對客戶端唯一開放的埠;
2)其次:讓我們可以重用並擴充套件 HTTP 的 Upgrade 流,為其新增自定義的 WebSocket 首部,以完成協商。

下面我們以前面已經演示過的傳送普通文字的例子為例,來具體分析一下握手過程。

5.2.1)客戶端請求:

GET ws://echo.websocket.org/ HTTP/1.1
引用
Host: echo.websocket.org
引用
Origin: file://
引用
Connection: Upgrade
引用
Upgrade: websocket
引用
Sec-WebSocket-Version: 13
引用
Sec-WebSocket-Key: Zx8rNEkBE4xnwifpuh8DHQ==
引用
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

備註:已忽略部分 HTTP 請求頭。

針對上述請求中的欄位說明如下:

1)Connection:必須設定 Upgrade,表示客戶端希望連線升級;
2)Upgrade:欄位必須設定 websocket,表示希望升級到 WebSocket 協議;
3)Sec-WebSocket-Version:表示支援的 WebSocket 版本。RFC6455 要求使用的版本是 13,之前草案的版本均應當棄用;
4)Sec-WebSocket-Key:是隨機的字串,伺服器端會用這些資料來構造出一個 SHA-1 的資訊摘要;
5)Sec-WebSocket-Extensions:用於協商本次連線要使用的 WebSocket 擴充套件:客戶端傳送支援的擴充套件,伺服器通過返回相同的首部確認自己支援一個或多個擴充套件;
6)Origin:欄位是可選的,通常用來表示在瀏覽器中發起此 WebSocket 連線所在的頁面,類似於 Referer。但是,與 Referer 不同的是,Origin 只包含了協議和主機名稱。

針對上述第4)點:把 “Sec-WebSocket-Key” 加上一個特殊字串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然後計算 SHA-1 摘要,之後進行 Base64 編碼,將結果做為 “Sec-WebSocket-Accept” 頭的值,返回給客戶端。如此操作,可以儘量避免普通 HTTP 請求被誤認為 WebSocket 協議。

5.2.2)服務端響應:

HTTP/1.1 101 Web Socket Protocol Handshake ①
引用
Connection: Upgrade ②
引用
Upgrade: websocket ③
引用
Sec-WebSocket-Accept: 52Rg3vW4JQ1yWpkvFlsTsiezlqw= ④

備註:已忽略部分 HTTP 響應頭。

針對上述響應中的欄位說明如下:

① 101 響應碼確認升級到 WebSocket 協議;
② 設定 Connection 頭的值為 “Upgrade” 來指示這是一個升級請求(HTTP 協議提供了一種特殊的機制,這一機制允許將一個已建立的連線升級成新的、不相容的協議);
③ Upgrade 頭指定一項或多項協議名,按優先順序排序,以逗號分隔。這裡表示升級為 WebSocket 協議;
④ 簽名的鍵值驗證協議支援。

介紹完 WebSocket 的握手協議,接下來將使用 Node.js 來開發我們的 WebSocket 伺服器。

5.3 實現握手功能
要開發一個 WebSocket 伺服器,首先我們需要先實現握手功能。這裡我使用 Node.js 內建的 http 模組來建立一個 HTTP 伺服器。

具體程式碼如下所示:

const http = require("http");
引用
const port = 8888;
引用
const { generateAcceptValue } = require("./util");
引用
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8"});
引用
res.end("大家好,我是阿寶哥。感謝你閱讀“你不知道的WebSocket”");
引用
});
引用
server.on("upgrade", function(req, socket) {
if(req.headers["upgrade"] !== "websocket") {

socket.end("HTTP/1.1 400 Bad Request");

引用

return;

引用
}
引用
// 讀取客戶端提供的Sec-WebSocket-Key
引用
const secWsKey = req.headers["sec-websocket-key"];
引用
// 使用SHA-1演算法生成Sec-WebSocket-Accept
引用
const hash = generateAcceptValue(secWsKey);
引用
// 設定HTTP響應頭
引用
const responseHeaders = [
引用

"HTTP/1.1 101 Web Socket Protocol Handshake",

引用

"Upgrade: WebSocket",

引用

"Connection: Upgrade",

引用

`Sec-WebSocket-Accept: ${hash}`,

引用
];
引用
// 返回握手請求的響應資訊
引用
socket.write(responseHeaders.join("\r\n") + "\r\n\r\n");
引用
});
引用
server.listen(port, () =>
引用
console.log(Server running at http://localhost:${port})
引用
);

在以上程式碼中:我們首先引入了 http 模組,然後通過呼叫該模組的 createServer() 方法建立一個 HTTP 伺服器,接著我們監聽 upgrade 事件,每次伺服器響應升級請求時就會觸發該事件。由於我們的伺服器只支援升級到 WebSocket 協議,所以如果客戶端請求升級的協議非 WebSocket 協議,我們將會返回 “400 Bad Request”。

當伺服器接收到升級為 WebSocket 的握手請求時,會先從請求頭中獲取 “Sec-WebSocket-Key” 的值,然後把該值加上一個特殊字串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然後計算 SHA-1 摘要,之後進行 Base64 編碼,將結果做為 “Sec-WebSocket-Accept” 頭的值,返回給客戶端。

上述的過程看起來好像有點繁瑣,其實利用 Node.js 內建的 crypto 模組,幾行程式碼就可以搞定了。

程式碼如下:

// util.js
引用
const crypto = require("crypto");
引用
const MAGIC_KEY = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
引用
function generateAcceptValue(secWsKey) {
return crypto
引用

.createHash("sha1")

引用

.update(secWsKey + MAGIC_KEY, "utf8")

引用

.digest("base64");

引用
}

開發完握手功能之後,我們可以使用前面的示例來測試一下該功能。待伺服器啟動之後,我們只要對 “傳送普通文字” 示例,做簡單地調整,即把先前的 URL 地址替換成 ws://localhost:8888,就可以進行功能驗證。

感興趣的小夥們可以試試看,以下是我的本地執行後的結果:

從上圖可知:我們實現的握手功能已經可以正常工作了。那麼握手有沒有可能失敗呢?答案是肯定的。比如網路問題、伺服器異常或 Sec-WebSocket-Accept 的值不正確。

下面來改一下 “Sec-WebSocket-Accept” 生成規則,比如修改 MAGIC_KEY 的值,然後重新驗證一下握手功能。

此時,瀏覽器的控制檯會輸出以下異常資訊:

WebSocket connection to 'ws://localhost:8888/'failed: Error during WebSocket handshake: Incorrect 'Sec-WebSocket-Accept'header value

如果你的 WebSocket 伺服器要支援子協議的話,你可以參考以下程式碼進行子協議的處理,這裡就不繼續展開介紹了。

// 從請求頭中讀取子協議
引用
const protocol = req.headers["sec-websocket-protocol"];
引用
// 如果包含子協議,則解析子協議
引用
const protocols = !protocol ? [] : protocol.split(",").map((s) => s.trim());
引用
// 簡單起見,我們僅判斷是否含有JSON子協議
引用
if(protocols.includes("json")) {
responseHeaders.push(Sec-WebSocket-Protocol: json);
引用
}

好的,WebSocket 握手協議相關的內容基本已經介紹完了。下一步我們來介紹開發訊息通訊功能需要了解的一些基礎知識。

5.4 訊息通訊基礎
在 WebSocket 協議中,資料是通過一系列資料幀來進行傳輸的。

為了避免由於網路中介(例如一些攔截代理)或者一些安全問題,客戶端必須在它傳送到伺服器的所有幀中新增掩碼。服務端收到沒有新增掩碼的資料幀以後,必須立即關閉連線。

5.4.1)資料幀格式:

要實現訊息通訊,我們就必須瞭解 WebSocket 資料幀的格式:

可能有一些小夥伴看到上面的內容之後,就開始有點 “懵逼” 了。

下面我們來結合實際的資料幀來進一步分析一下:

在上圖中:簡單分析了 “傳送普通文字” 示例對應的資料幀格式。這裡我們來進一步介紹一下 Payload length,因為在後面開發資料解析功能的時候,需要用到該知識點。

Payload length 表示以位元組為單位的 “有效負載資料” 長度。

它有以下幾種情形:

1)如果值為 0-125,那麼就表示負載資料的長度;
2)如果是 126,那麼接下來的 2 個位元組解釋為 16 位的無符號整形作為負載資料的長度;
3)如果是 127,那麼接下來的 8 個位元組解釋為一個 64 位的無符號整形(最高位的 bit 必須為 0)作為負載資料的長度。

備註:多位元組長度量以網路位元組順序表示,有效負載長度是指 “擴充套件資料” + “應用資料” 的長度。“擴充套件資料” 的長度可能為 0,那麼有效負載長度就是 “應用資料” 的長度。

另外:除非協商過擴充套件,否則 “擴充套件資料” 長度為 0 位元組。在握手協議中,任何擴充套件都必須指定 “擴充套件資料” 的長度,這個長度如何進行計算,以及這個擴充套件如何使用。如果存在擴充套件,那麼這個 “擴充套件資料” 包含在總的有效負載長度中。

PS:關於資料幀格式的詳細講解,可以深入讀讀以下幾篇:

《WebSocket從入門到精通,半小時就夠!》
《理論聯絡實際:從零理解WebSocket的通訊原理、協議格式、安全性》

5.4.2)掩碼演算法:

掩碼欄位是一個由客戶端隨機選擇的 32 位的值。掩碼值必須是不可被預測的。因此,掩碼必須來自強大的熵源(entropy),並且給定的掩碼不能讓伺服器或者代理能夠很容易的預測到後續幀。掩碼的不可預測性對於預防惡意應用的作者在網上暴露相關的位元組資料至關重要。

掩碼不影響資料荷載的長度,對資料進行掩碼操作和對資料進行反掩碼操作所涉及的步驟是相同的。

掩碼、反掩碼操作都採用如下演算法:

j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j

解釋一下:

1)original-octet-i:為原始資料的第 i 位元組;
2)transformed-octet-i:為轉換後的資料的第 i 位元組;
3)masking-key-octet-j:為 mask key 第 j 位元組。

為了讓小夥伴們能夠更好的理解上面掩碼的計算過程,我們來對示例中 “我是阿寶哥” 資料進行掩碼操作。

這裡 “我是阿寶哥” 對應的 UTF-8 編碼如下所示:

E6 88 91 E6 98 AF E9 98 BF E5 AE 9D E5 93 A5

而對應的 Masking-Key 為 0x08f6efb1。

根據上面的演算法,我們可以這樣進行掩碼運算:

let uint8 = new Uint8Array([0xE6, 0x88, 0x91, 0xE6, 0x98, 0xAF, 0xE9, 0x98,0xBF, 0xE5, 0xAE, 0x9D, 0xE5, 0x93, 0xA5]);
引用
let maskingKey = new Uint8Array([0x08, 0xf6, 0xef, 0xb1]);
引用
let maskedUint8 = new Uint8Array(uint8.length);
引用
for(let i = 0, j = 0; i < uint8.length; i++, j = i % 4) {
maskedUint8[i ] = uint8[i ] ^ maskingKey[j];
引用
}
引用
console.log(Array.from(maskedUint8).map(num=>Number(num).toString(16)).join(' '));

以上程式碼成功執行後,控制檯會輸出以下結果:

ee 7e 7e 57 90 59 6 29 b7 13 41 2c ed 65 4a

上述結果與 WireShark 中的 Masked payload 對應的值是一致的,具體如下圖所示:

在 WebSocket 協議中,資料掩碼的作用是增強協議的安全性。但資料掩碼並不是為了保護資料本身,因為演算法本身是公開的,運算也不復雜。

那麼為什麼還要引入資料掩碼呢?引入資料掩碼是為了防止早期版本的協議中存在的代理快取汙染攻擊等問題。

瞭解完 WebSocket 掩碼演算法和資料掩碼的作用之後,我們再來介紹一下資料分片的概念。

5.4.3)資料分片:

WebSocket 的每條訊息可能被切分成多個資料幀。當 WebSocket 的接收方收到一個資料幀時,會根據 FIN 的值來判斷,是否已經收到訊息的最後一個資料幀。

利用 FIN 和 Opcode,我們就可以跨幀傳送訊息。

操作碼告訴了幀應該做什麼:

1)如果是 0x1,有效載荷就是文字;
2)如果是 0x2,有效載荷就是二進位制資料;
3)如果是 0x0,則該幀是一個延續幀(這意味著伺服器應該將幀的有效負載連線到從該客戶機接收到的最後一個幀)。

為了讓大家能夠更好地理解上述的內容,我們來看一個來自 MDN 上的示例:

Client: FIN=1, opcode=0x1, msg="hello"

Server: (process complete message immediately) Hi.

Client: FIN=0, opcode=0x1, msg="and a"

Server: (listening, newmessage containing text started)

Client: FIN=0, opcode=0x0, msg="happy new"

Server: (listening, payload concatenated to previous message)

Client: FIN=1, opcode=0x0, msg="year!"

Server: (process complete message) Happy newyear to you too!

在以上示例中:客戶端向伺服器傳送了兩條訊息,第一個訊息在單個幀中傳送,而第二個訊息跨三個幀傳送。

其中:第一個訊息是一個完整的訊息(FIN=1 且 opcode != 0x0),因此伺服器可以根據需要進行處理或響應。而第二個訊息是文字訊息(opcode=0x1)且 FIN=0,表示訊息還沒傳送完成,還有後續的資料幀。該訊息的所有剩餘部分都用延續幀(opcode=0x0)傳送,訊息的最終幀用 FIN=1 標記。

好的,簡單介紹了資料分片的相關內容。接下來,我們來開始實現訊息通訊功能。

5.5 實現訊息通訊功能
筆者把實現訊息通訊功能,分解為訊息解析與訊息響應兩個子功能,下面我們分別來介紹如何實現這兩個子功能。

5.5.1)訊息解析:

利用訊息通訊基礎環節中介紹的相關知識,我實現了一個 parseMessage 函式,用來解析客戶端傳過來的 WebSocket 資料幀。

出於簡單考慮,這裡只處理文字幀,具體程式碼如下所示:

function parseMessage(buffer) {
  // 第一個位元組,包含了FIN位,opcode, 掩碼位

  const firstByte = buffer.readUInt8(0);

  // [FIN, RSV, RSV, RSV, OPCODE, OPCODE, OPCODE, OPCODE];

  // 右移7位取首位,1位,表示是否是最後一幀資料

  const isFinalFrame = Boolean((firstByte >>> 7) & 0x01);

  console.log("isFIN: ", isFinalFrame);

  // 取出操作碼,低四位

  /**

   * %x0:表示一個延續幀。當 Opcode 為 0 時,表示本次資料傳輸採用了資料分片,當前收到的資料幀為其中一個資料分片;

   * %x1:表示這是一個文字幀(text frame);

   * %x2:表示這是一個二進位制幀(binary frame);

   * %x3-7:保留的操作程式碼,用於後續定義的非控制幀;

   * %x8:表示連線斷開;

   * %x9:表示這是一個心跳請求(ping);

   * %xA:表示這是一個心跳響應(pong);

   * %xB-F:保留的操作程式碼,用於後續定義的控制幀。

   */

  const opcode = firstByte & 0x0f;

  if(opcode === 0x08) {
    // 連線關閉

    return;

  }

  if(opcode === 0x02) {
    // 二進位制幀

    return;

  }

  if(opcode === 0x01) {
    // 目前只處理文字幀

    let offset = 1;

    const secondByte = buffer.readUInt8(offset);

    // MASK: 1位,表示是否使用了掩碼,在傳送給服務端的資料幀裡必須使用掩碼,而服務端返回時不需要掩碼

    const useMask = Boolean((secondByte >>> 7) & 0x01);

    console.log("use MASK: ", useMask);

    const payloadLen = secondByte & 0x7f; // 低7位表示載荷位元組長度

    offset += 1;

    // 四個位元組的掩碼

    let MASK = [];

    // 如果這個值在0-125之間,則後面的4個位元組(32位)就應該被直接識別成掩碼;

    if(payloadLen <= 0x7d) {
      // 載荷長度小於125

      MASK = buffer.slice(offset, 4 + offset);

      offset += 4;

      console.log("payload length: ", payloadLen);

    } elseif(payloadLen === 0x7e) {
      // 如果這個值是126,則後面兩個位元組(16位)內容應該,被識別成一個16位的二進位制數表示資料內容大小;

      console.log("payload length: ", buffer.readInt16BE(offset));

      // 長度是126, 則後面兩個位元組作為payload length,32位的掩碼

      MASK = buffer.slice(offset + 2, offset + 2 + 4);

      offset += 6;

    } else{
      // 如果這個值是127,則後面的8個位元組(64位)內容應該被識別成一個64位的二進位制數表示資料內容大小

      MASK = buffer.slice(offset + 8, offset + 8 + 4);

      offset += 12;

    }

    // 開始讀取後面的payload,與掩碼計算,得到原來的位元組內容

    const newBuffer = [];

    const dataBuffer = buffer.slice(offset);

    for(let i = 0, j = 0; i < dataBuffer.length; i++, j = i % 4) {
      const nextBuf = dataBuffer[i ];

      newBuffer.push(nextBuf ^ MASK[j]);

    }

    return Buffer.from(newBuffer).toString();

  }

  return "";

}

建立完 parseMessage 函式,我們來更新一下之前建立的 WebSocket 伺服器:

server.on("upgrade", function(req, socket) {
  socket.on("data", (buffer) => {
    const message = parseMessage(buffer);

    if(message) {
      console.log("Message from client:"+ message);

    } elseif(message === null) {
      console.log("WebSocket connection closed by the client.");

    }

  });

  if(req.headers["upgrade"] !== "websocket") {
    socket.end("HTTP/1.1 400 Bad Request");

    return;

  }

  // 省略已有程式碼

});

更新完成之後,我們重新啟動伺服器,然後繼續使用 “傳送普通文字” 的示例來測試訊息解析功能。

以下傳送 “我是阿寶哥” 文字訊息後,WebSocket 伺服器輸出的資訊:

Server running at http://localhost:8888

isFIN:  true

use MASK:  true

payload length:  15

Message from client:我是阿寶哥

通過觀察以上的輸出資訊,我們的 WebSocket 伺服器已經可以成功解析客戶端傳送包含普通文字的資料幀,下一步我們來實現訊息響應的功能。

5.5.2)訊息響應:

要把資料返回給客戶端,我們的 WebSocket 伺服器也得按照 WebSocket 資料幀的格式來封裝資料。

與前面介紹的 parseMessage 函式一樣,我也封裝了一個 constructReply 函式用來封裝返回的資料。

該函式的具體程式碼如下:

function constructReply(data) {
  const json = JSON.stringify(data);

  const jsonByteLength = Buffer.byteLength(json);

  // 目前只支援小於65535位元組的負載

  const lengthByteCount = jsonByteLength < 126 ? 0 : 2;

  const payloadLength = lengthByteCount === 0 ? jsonByteLength : 126;

  const buffer = Buffer.alloc(2 + lengthByteCount + jsonByteLength);

  // 設定資料幀首位元組,設定opcode為1,表示文字幀

  buffer.writeUInt8(0b10000001, 0);

  buffer.writeUInt8(payloadLength, 1);

  // 如果payloadLength為126,則後面兩個位元組(16位)內容應該,被識別成一個16位的二進位制數表示資料內容大小

  let payloadOffset = 2;

  if(lengthByteCount > 0) {
    buffer.writeUInt16BE(jsonByteLength, 2);

    payloadOffset += lengthByteCount;

  }

  // 把JSON資料寫入到Buffer緩衝區中

  buffer.write(json, payloadOffset);

  return buffer;

}

建立完 constructReply 函式,我們再來更新一下之前建立的 WebSocket 伺服器:

server.on("upgrade", function(req, socket) {
socket.on("data", (buffer) => {

const message = parseMessage(buffer);

if(message) {
  console.log("Message from client:"+ message);

  // 新增以下&#128071;程式碼

  socket.write(constructReply({ message }));

} elseif(message === null) {
  console.log("WebSocket connection closed by the client.");

}

});

});

到這裡,我們的 WebSocket 伺服器已經開發完成了,接下來我們來完整驗證一下它的功能。

從上圖中可知:以上開發的簡易版 WebSocket 伺服器已經可以正常處理普通文字訊息了。

最後我們來看一下完整的程式碼。

custom-websocket-server.js檔案:

const http = require("http");

const port = 8888;

const { generateAcceptValue, parseMessage, constructReply } = require("./util");

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8"});

  res.end("大家好,我是阿寶哥。感謝你閱讀“你不知道的WebSocket”");

});

server.on("upgrade", function(req, socket) {
  socket.on("data", (buffer) => {
    const message = parseMessage(buffer);

    if(message) {
      console.log("Message from client:"+ message);

      socket.write(constructReply({ message }));

    } else if(message === null) {
      console.log("WebSocket connection closed by the client.");

    }

  });

  if(req.headers["upgrade"] !== "websocket") {
    socket.end("HTTP/1.1 400 Bad Request");

    return;

  }

  // 讀取客戶端提供的Sec-WebSocket-Key

  const secWsKey = req.headers["sec-websocket-key"];

  // 使用SHA-1演算法生成Sec-WebSocket-Accept

  const hash = generateAcceptValue(secWsKey);

  // 設定HTTP響應頭

  const responseHeaders = [

    "HTTP/1.1 101 Web Socket Protocol Handshake",

    "Upgrade: WebSocket",

    "Connection: Upgrade",

    `Sec-WebSocket-Accept: ${hash}`,

  ];

  // 返回握手請求的響應資訊

  socket.write(responseHeaders.join("\r\n") + "\r\n\r\n");

});

server.listen(port, () =>

  console.log(`Server running at http://localhost:${port}`)

);

util.js檔案:

const crypto = require("crypto");
引用
const MAGIC_KEY = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
引用
function generateAcceptValue(secWsKey) {
return crypto
引用

.createHash("sha1")

引用

.update(secWsKey + MAGIC_KEY, "utf8")

引用

.digest("base64");

引用
}
引用
function parseMessage(buffer) {
// 第一個位元組,包含了FIN位,opcode, 掩碼位
引用
const firstByte = buffer.readUInt8(0);
引用
// [FIN, RSV, RSV, RSV, OPCODE, OPCODE, OPCODE, OPCODE];
引用
// 右移7位取首位,1位,表示是否是最後一幀資料
引用
const isFinalFrame = Boolean((firstByte >>> 7) & 0x01);
引用
console.log("isFIN: ", isFinalFrame);
引用
// 取出操作碼,低四位
引用
/**
引用

  • %x0:表示一個延續幀。當 Opcode 為 0 時,表示本次資料傳輸採用了資料分片,當前收到的資料幀為其中一個資料分片;

引用

  • %x1:表示這是一個文字幀(text frame);

引用

  • %x2:表示這是一個二進位制幀(binary frame);

引用

  • %x3-7:保留的操作程式碼,用於後續定義的非控制幀;

引用

  • %x8:表示連線斷開;

引用

  • %x9:表示這是一個心跳請求(ping);

引用

  • %xA:表示這是一個心跳響應(pong);

引用

  • %xB-F:保留的操作程式碼,用於後續定義的控制幀。

引用
*/
引用
const opcode = firstByte & 0x0f;
引用
if(opcode === 0x08) {

// 連線關閉

引用

return;

引用
}
引用
if(opcode === 0x02) {

// 二進位制幀

引用

return;

引用
}
引用
if(opcode === 0x01) {

// 目前只處理文字幀

引用

let offset = 1;

引用

const secondByte = buffer.readUInt8(offset);

引用

// MASK: 1位,表示是否使用了掩碼,在傳送給服務端的資料幀裡必須使用掩碼,而服務端返回時不需要掩碼

引用

const useMask = Boolean((secondByte >>> 7) & 0x01);

引用

console.log("use MASK: ", useMask);

引用

const payloadLen = secondByte & 0x7f; // 低7位表示載荷位元組長度

引用

offset += 1;

引用

// 四個位元組的掩碼

引用

let MASK = [];

引用

// 如果這個值在0-125之間,則後面的4個位元組(32位)就應該被直接識別成掩碼;

引用

if(payloadLen <= 0x7d) {
  // 載荷長度小於125

引用

  MASK = buffer.slice(offset, 4 + offset);

引用

  offset += 4;

引用

  console.log("payload length: ", payloadLen);

引用

} else if(payloadLen === 0x7e) {
  // 如果這個值是126,則後面兩個位元組(16位)內容應該,被識別成一個16位的二進位制數表示資料內容大小;

引用

  console.log("payload length: ", buffer.readInt16BE(offset));

引用

  // 長度是126, 則後面兩個位元組作為payload length,32位的掩碼

引用

  MASK = buffer.slice(offset + 2, offset + 2 + 4);

引用

  offset += 6;

引用

} else{
  // 如果這個值是127,則後面的8個位元組(64位)內容應該被識別成一個64位的二進位制數表示資料內容大小

引用

  MASK = buffer.slice(offset + 8, offset + 8 + 4);

引用

  offset += 12;

引用

}

引用

// 開始讀取後面的payload,與掩碼計算,得到原來的位元組內容

引用

const newBuffer = [];

引用

const dataBuffer = buffer.slice(offset);

引用

for(let i = 0, j = 0; i < dataBuffer.length; i++, j = i % 4) {
  const nextBuf = dataBuffer[i ];

引用

  newBuffer.push(nextBuf ^ MASK[j]);

引用

}

引用

return Buffer.from(newBuffer).toString();

引用
}
引用
return "";
引用
}
引用
function constructReply(data) {
const json = JSON.stringify(data);
引用
const jsonByteLength = Buffer.byteLength(json);
引用
// 目前只支援小於65535位元組的負載
引用
const lengthByteCount = jsonByteLength < 126 ? 0 : 2;
引用
const payloadLength = lengthByteCount === 0 ? jsonByteLength : 126;
引用
const buffer = Buffer.alloc(2 + lengthByteCount + jsonByteLength);
引用
// 設定資料幀首位元組,設定opcode為1,表示文字幀
引用
buffer.writeUInt8(0b10000001, 0);
引用
buffer.writeUInt8(payloadLength, 1);
引用
// 如果payloadLength為126,則後面兩個位元組(16位)內容應該,被識別成一個16位的二進位制數表示資料內容大小
引用
let payloadOffset = 2;
引用
if(lengthByteCount > 0) {

buffer.writeUInt16BE(jsonByteLength, 2);

引用

payloadOffset += lengthByteCount;

引用
}
引用
// 把JSON資料寫入到Buffer緩衝區中
引用
buffer.write(json, payloadOffset);
引用
return buffer;
引用
}
引用
module.exports = {
generateAcceptValue,
引用
parseMessage,
引用
constructReply,
引用
};

其實伺服器向瀏覽器推送資訊,除了使用 WebSocket 技術之外,還可以使用 SSE(Server-Sent Events)。它讓伺服器可以向客戶端流式傳送文字訊息,比如伺服器上生成的實時訊息。

為實現這個目標,SSE 設計了兩個元件:瀏覽器中的 EventSource API 和新的 “事件流” 資料格式(text/event-stream)。其中,EventSource 可以讓客戶端以 DOM 事件的形式接收到伺服器推送的通知,而新資料格式則用於交付每一次資料更新。

實際上:SSE 提供的是一個高效、跨瀏覽器的 XHR 流實現,訊息交付只使用一個長 HTTP 連線。然而,與我們自己實現 XHR 流不同,瀏覽器會幫我們管理連線、 解析訊息,從而讓我們只關注業務邏輯。篇幅有限,關於 SSE 的更多細節,就不展開介紹了,對 SSE 感興趣的小夥伴可以自行閱讀以下幾篇:

《Web端即時通訊技術盤點:短輪詢、Comet、Websocket、SSE》
《SSE技術詳解:一種全新的HTML5伺服器推送事件技術》
《使用WebSocket和SSE技術實現Web端訊息推送》
《詳解Web端通訊方式的演進:從Ajax、JSONP 到 SSE、Websocket》
《網頁端IM通訊技術快速入門:短輪詢、長輪詢、SSE、WebSocket》
《搞懂現代Web端即時通訊技術一文就夠:WebSocket、socket.io、SSE》

6、WebSocket學習過程中的易錯常識

6.1 WebSocket 與 HTTP 有什麼關係?
WebSocket 是一種與 HTTP 不同的協議。兩者都位於 OSI 模型的應用層,並且都依賴於傳輸層的 TCP 協議。

雖然它們不同,但是 RFC 6455 中規定:WebSocket 被設計為在 HTTP 80 和 443 埠上工作,並支援 HTTP 代理和中介,從而使其與 HTTP 協議相容。 為了實現相容性,WebSocket 握手使用 HTTP Upgrade 頭,從 HTTP 協議更改為 WebSocket 協議。

既然已經提到了 OSI(Open System Interconnection Model)模型,這裡分享一張很生動、很形象描述 OSI 模型的示意圖(如下圖所示)。


(圖片引用自:https://www.networkingsphere....

當然,WebSocket與HTTP的關係顯然不是這三兩句話可以說的清,有興趣的讀者可以詳讀下面這兩篇:

《WebSocket詳解(四):刨根問底HTTP與WebSocket的關係(上篇)》
《WebSocket詳解(五):刨根問底HTTP與WebSocket的關係(下篇)》

6.2 WebSocket 與長輪詢有什麼區別?
長輪詢就是:客戶端發起一個請求,伺服器收到客戶端發來的請求後,伺服器端不會直接進行響應,而是先將這個請求掛起,然後判斷請求的資料是否有更新。如果有更新,則進行響應,如果一直沒有資料,則等待一定的時間後才返回。

長輪詢的本質還是基於 HTTP 協議,它仍然是一個一問一答(請求 — 響應)的模式。而 WebSocket 在握手成功後,就是全雙工的 TCP 通道,資料可以主動從服務端傳送到客戶端。

要理解WebSocket 與長輪詢的區別,需要深刻理解長輪詢的技術原理,以下3篇中有關長輪詢的技術介紹建議深入閱讀:

《Comet技術詳解:基於HTTP長連線的Web端實時通訊技術》
《新手入門貼:史上最全Web端即時通訊技術原理詳解》
《Web端即時通訊技術盤點:短輪詢、Comet、Websocket、SSE》
《網頁端IM通訊技術快速入門:短輪詢、長輪詢、SSE、WebSocket》
6.3 什麼是 WebSocket 心跳?
網路中的接收和傳送資料都是使用 Socket 進行實現。但是如果此套接字已經斷開,那傳送資料和接收資料的時候就一定會有問題。

可是如何判斷這個套接字是否還可以使用呢?這個就需要在系統中建立心跳機制。

所謂 “心跳” 就是定時傳送一個自定義的結構體(心跳包或心跳幀),讓對方知道自己 “線上”,以確保連結的有效性。

而所謂的心跳包就是客戶端定時傳送簡單的資訊給伺服器端告訴它我還在而已。程式碼就是每隔幾分鐘傳送一個固定資訊給服務端,服務端收到後回覆一個固定資訊,如果服務端幾分鐘內沒有收到客戶端資訊則視客戶端斷開。

在 WebSocket 協議中定義了 心跳 Ping 和 心跳 Pong 的控制幀:

1)心跳 Ping 幀包含的操作碼是 0x9:如果收到了一個心跳 Ping 幀,那麼終端必須傳送一個心跳 Pong 幀作為回應,除非已經收到了一個關閉幀。否則終端應該儘快回覆 Pong 幀;
2)心跳 Pong 幀包含的操作碼是 0xA:作為回應傳送的 Pong 幀必須完整攜帶 Ping 幀中傳遞過來的 “應用資料” 欄位。
針對第2)點:如果終端收到一個 Ping 幀但是沒有傳送 Pong 幀來回應之前的 Ping 幀,那麼終端可以選擇僅為最近處理的 Ping 幀傳送 Pong 幀。此外,可以自動傳送一個 Pong 幀,這用作單向心跳。

PS:這裡有篇WebSocket心跳方面的IM實戰總結文章,有興趣可以閱讀《Web端即時通訊實踐乾貨:如何讓你的WebSocket斷網重連更快速?》。

6.4 Socket 是什麼?
網路上的兩個程式通過一個雙向的通訊連線實現資料的交換,這個連線的一端稱為一個 Socket(套接字),因此建立網路通訊連線至少要一對埠號。

Socket 本質:是對 TCP/IP 協議棧的封裝,它提供了一個針對 TCP 或者 UDP 程式設計的介面,並不是另一種協議。通過 Socket,你可以使用 TCP/IP 協議。

百度百科上關於Socket的描述是這樣:

Socket 的英文原義是“孔”或“插座”:作為 BSD UNIX 的程式通訊機制,取後一種意思。通常也稱作”套接字“,用於描述IP地址和埠,是一個通訊鏈的控制程式碼,可以用來實現不同虛擬機器或不同計算機之間的通訊。
引用
在Internet 上的主機一般執行了多個服務軟體,同時提供幾種服務。每種服務都開啟一個Socket,並繫結到一個埠上,不同的埠對應於不同的服務。Socket 正如其英文原義那樣,像一個多孔插座。一臺主機猶如佈滿各種插座的房間,每個插座有一個編號,有的插座提供 220 伏交流電, 有的提供 110 伏交流電,有的則提供有線電視節目。 客戶軟體將插頭插到不同編號的插座,就可以得到不同的服務。

關於 Socket,可以總結以下幾點:

1)它可以實現底層通訊,幾乎所有的應用層都是通過 socket 進行通訊的;
2)對 TCP/IP 協議進行封裝,便於應用層協議呼叫,屬於二者之間的中間抽象層;
3)TCP/IP 協議族中,傳輸層存在兩種通用協議: TCP、UDP,兩種協議不同,因為不同引數的 socket 實現過程也不一樣。

下圖說明了面向連線的協議的套接字 API 的客戶端/伺服器關係:

PS:要說WebSocket和Socket的關係,這篇《WebSocket詳解(六):刨根問底WebSocket與Socket的關係》有專門進行詳細分享,建議閱讀。

7、參考資料

[1] 新手快速入門:WebSocket簡明教程
[2] WebSocket從入門到精通,半小時就夠!
[3] 新手入門貼:史上最全Web端即時通訊技術原理詳解
[4] Web端即時通訊技術盤點:短輪詢、Comet、Websocket、SSE
[5] SSE技術詳解:一種全新的HTML5伺服器推送事件技術
[6] Comet技術詳解:基於HTTP長連線的Web端實時通訊技術
[7] WebSocket詳解(四):刨根問底HTTP與WebSocket的關係(上篇)
[8] WebSocket詳解(五):刨根問底HTTP與WebSocket的關係(下篇)
[9] WebSocket詳解(六):刨根問底WebSocket與Socket的關係
[10] Web端即時通訊實踐乾貨:如何讓你的WebSocket斷網重連更快速?
[11] 理論聯絡實際:從零理解WebSocket的通訊原理、協議格式、安全性
[12] WebSocket硬核入門:200行程式碼,教你徒手擼一個WebSocket伺服器
[13] 網頁端IM通訊技術快速入門:短輪詢、長輪詢、SSE、WebSocket
[14] 搞懂現代Web端即時通訊技術一文就夠:WebSocket、socket.io、SSE

本文已同步釋出於“即時通訊技術圈”公眾號。
同步釋出連結是:http://www.52im.net/thread-37...

相關文章