2020-7-26 seo達人
在最后的 阿寶哥有話說 環(huán)節(jié),阿寶哥將介紹 WebSocket 與 HTTP 之間的關系、WebSocket 與長輪詢有什么區(qū)別、什么是 WebSocket 心跳及 Socket 是什么等內容。
下面我們進入正題,為了讓大家能夠更好地理解和掌握 WebSocket 技術,我們先來介紹一下什么是 WebSocket。
一、什么是 WebSocket
1.1 WebSocket 誕生背景
早期,很多網站為了實現(xiàn)推送技術,所用的技術都是輪詢。輪詢是指由瀏覽器每隔一段時間向服務器發(fā)出 HTTP 請求,然后服務器返回的數(shù)據(jù)給客戶端。常見的輪詢方式分為輪詢與長輪詢,它們的區(qū)別如下圖所示:
為了更加直觀感受輪詢與長輪詢之間的區(qū)別,我們來看一下具體的代碼:
這種傳統(tǒng)的模式帶來很明顯的缺點,即瀏覽器需要不斷的向服務器發(fā)出請求,然而 HTTP 請求與響應可能會包含較長的頭部,其中真正有效的數(shù)據(jù)可能只是很小的一部分,所以這樣會消耗很多帶寬資源。
比較新的輪詢技術是 Comet)。這種技術雖然可以實現(xiàn)雙向通信,但仍然需要反復發(fā)出請求。而且在 Comet 中普遍采用的 HTTP 長連接也會消耗服務器資源。
在這種情況下,HTML5 定義了 WebSocket 協(xié)議,能更好的節(jié)省服務器資源和帶寬,并且能夠更實時地進行通訊。Websocket 使用 ws 或 wss 的統(tǒng)一資源標志符(URI),其中 wss 表示使用了 TLS 的 Websocket。如:
ws://echo.websocket.org
wss://echo.websocket.org
WebSocket 與 HTTP 和 HTTPS 使用相同的 TCP 端口,可以繞過大多數(shù)防火墻的限制。默認情況下,WebSocket 協(xié)議使用 80 端口;若運行在 TLS 之上時,默認使用 443 端口。
1.2 WebSocket 簡介
WebSocket 是一種網絡傳輸協(xié)議,可在單個 TCP 連接上進行全雙工通信,位于 OSI 模型的應用層。WebSocket 協(xié)議在 2011 年由 IETF 標準化為 RFC 6455,后由 RFC 7936 補充規(guī)范。
WebSocket 使得客戶端和服務器之間的數(shù)據(jù)交換變得更加簡單,允許服務端主動向客戶端推送數(shù)據(jù)。在 WebSocket API 中,瀏覽器和服務器只需要完成一次握手,兩者之間就可以創(chuàng)建持久性的連接,并進行雙向數(shù)據(jù)傳輸。
介紹完輪詢和 WebSocket 的相關內容之后,接下來我們來看一下 XHR Polling 與 WebSocket 之間的區(qū)別:
1.3 WebSocket 優(yōu)點
較少的控制開銷。在連接創(chuàng)建后,服務器和客戶端之間交換數(shù)據(jù)時,用于協(xié)議控制的數(shù)據(jù)包頭部相對較小。
更強的實時性。由于協(xié)議是全雙工的,所以服務器可以隨時主動給客戶端下發(fā)數(shù)據(jù)。相對于 HTTP 請求需要等待客戶端發(fā)起請求服務端才能響應,延遲明顯更少。
保持連接狀態(tài)。與 HTTP 不同的是,WebSocket 需要先創(chuàng)建連接,這就使得其成為一種有狀態(tài)的協(xié)議,之后通信時可以省略部分狀態(tài)信息。
更好的二進制支持。WebSocket 定義了二進制幀,相對 HTTP,可以更輕松地處理二進制內容。
可以支持擴展。WebSocket 定義了擴展,用戶可以擴展協(xié)議、實現(xiàn)部分自定義的子協(xié)議。
由于 WebSocket 擁有上述的優(yōu)點,所以它被廣泛地應用在即時通信、實時音視頻、在線教育和游戲等領域。對于前端開發(fā)者來說,要想使用 WebSocket 提供的強大能力,就必須先掌握 WebSocket API,下面阿寶哥帶大家一起來認識一下 WebSocket API。
二、WebSocket API
在介紹 WebSocket API 之前,我們先來了解一下它的兼容性:
(圖片來源:https://caniuse.com/#search=W...)
從上圖可知,目前主流的 Web 瀏覽器都支持 WebSocket,所以我們可以在大多數(shù)項目中放心地使用它。
在瀏覽器中要使用 WebSocket 提供的能力,我們就必須先創(chuàng)建 WebSocket 對象,該對象提供了用于創(chuàng)建和管理 WebSocket 連接,以及可以通過該連接發(fā)送和接收數(shù)據(jù)的 API。
使用 WebSocket 構造函數(shù),我們就能輕易地構造一個 WebSocket 對象。接下來我們將從 WebSocket 構造函數(shù)、WebSocket 對象的屬性、方法及 WebSocket 相關的事件四個方面來介紹 WebSocket API,首先我們從 WebSocket 的構造函數(shù)入手:
2.1 構造函數(shù)
WebSocket 構造函數(shù)的語法為:
const myWebSocket = new WebSocket(url [, protocols]);
相關參數(shù)說明如下:
url:表示連接的 URL,這是 WebSocket 服務器將響應的 URL。
protocols(可選):一個協(xié)議字符串或者一個包含協(xié)議字符串的數(shù)組。這些字符串用于指定子協(xié)議,這樣單個服務器可以實現(xiàn)多個 WebSocket 子協(xié)議。比如,你可能希望一臺服務器能夠根據(jù)指定的協(xié)議(protocol)處理不同類型的交互。如果不指定協(xié)議字符串,則假定為空字符串。
當嘗試連接的端口被阻止時,會拋出 SECURITY_ERR 異常。
2.2 屬性
WebSocket 對象包含以下屬性:
每個屬性的具體含義如下:
binaryType:使用二進制的數(shù)據(jù)類型連接。
bufferedAmount(只讀):未發(fā)送至服務器的字節(jié)數(shù)。
extensions(只讀):服務器選擇的擴展。
onclose:用于指定連接關閉后的回調函數(shù)。
onerror:用于指定連接失敗后的回調函數(shù)。
onmessage:用于指定當從服務器接受到信息時的回調函數(shù)。
onopen:用于指定連接成功后的回調函數(shù)。
protocol(只讀):用于返回服務器端選中的子協(xié)議的名字。
readyState(只讀):返回當前 WebSocket 的連接狀態(tài),共有 4 種狀態(tài):
CONNECTING — 正在連接中,對應的值為 0;
OPEN — 已經連接并且可以通訊,對應的值為 1;
CLOSING — 連接正在關閉,對應的值為 2;
CLOSED — 連接已關閉或者沒有連接成功,對應的值為 3。
url(只讀):返回值為當構造函數(shù)創(chuàng)建 WebSocket 實例對象時 URL 的絕對路徑。
2.3 方法
close([code[, reason]]):該方法用于關閉 WebSocket 連接,如果連接已經關閉,則此方法不執(zhí)行任何操作。
send(data):該方法將需要通過 WebSocket 鏈接傳輸至服務器的數(shù)據(jù)排入隊列,并根據(jù)所需要傳輸?shù)臄?shù)據(jù)的大小來增加 bufferedAmount 的值 。若數(shù)據(jù)無法傳輸(比如數(shù)據(jù)需要緩存而緩沖區(qū)已滿)時,套接字會自行關閉。
2.4 事件
使用 addEventListener() 或將一個事件監(jiān)聽器賦值給 WebSocket 對象的 oneventname 屬性,來監(jiān)聽下面的事件。
close:當一個 WebSocket 連接被關閉時觸發(fā),也可以通過 onclose 屬性來設置。
error:當一個 WebSocket 連接因錯誤而關閉時觸發(fā),也可以通過 onerror 屬性來設置。
message:當通過 WebSocket 收到數(shù)據(jù)時觸發(fā),也可以通過 onmessage 屬性來設置。
open:當一個 WebSocket 連接成功時觸發(fā),也可以通過 onopen 屬性來設置。
介紹完 WebSocket API,我們來舉一個使用 WebSocket 發(fā)送普通文本的示例。
2.5 發(fā)送普通文本
在以上示例中,我們在頁面上創(chuàng)建了兩個 textarea,分別用于存放 待發(fā)送的數(shù)據(jù) 和 服務器返回的數(shù)據(jù)。當用戶輸入完待發(fā)送的文本之后,點擊 發(fā)送 按鈕時會把輸入的文本發(fā)送到服務端,而服務端成功接收到消息之后,會把收到的消息原封不動地回傳到客戶端。
// 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("連接未建立,還不能發(fā)送消息");
return;
}
if (message) socket.send(message);
}
當然客戶端接收到服務端返回的消息之后,會把對應的文本內容保存到 接收的數(shù)據(jù) 對應的 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;
});
為了更加直觀地理解上述的數(shù)據(jù)交互過程,我們使用 Chrome 瀏覽器的開發(fā)者工具來看一下相應的過程:
以上示例對應的完整代碼如下所示:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebSocket 發(fā)送普通文本示例</title>
<style>
.block {
flex: 1;
}
</style>
</head>
<body>
<h3>阿寶哥:WebSocket 發(fā)送普通文本示例</h3>
<div style="display: flex;">
<div class="block">
<p>即將發(fā)送的數(shù)據(jù):<button onclick="send()">發(fā)送</button></p>
<textarea id="sendMessage" rows="5" cols="15"></textarea>
</div>
<div class="block">
<p>接收的數(shù)據(jù):</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");
// 監(jiān)聽連接成功事件
socket.addEventListener("open", function (event) {
console.log("連接成功,可以開始通訊");
});
// 監(jiān)聽消息
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("連接未建立,還不能發(fā)送消息");
return;
}
if (message) socket.send(message);
}
</script>
</body>
</html>
其實 WebSocket 除了支持發(fā)送普通的文本之外,它還支持發(fā)送二進制數(shù)據(jù),比如 ArrayBuffer 對象、Blob 對象或者 ArrayBufferView 對象:
const socket = new WebSocket("ws://echo.websocket.org");
socket.onopen = function () {
// 發(fā)送UTF-8編碼的文本信息
socket.send("Hello Echo Server!");
// 發(fā)送UTF-8編碼的JSON數(shù)據(jù)
socket.send(JSON.stringify({ msg: "我是阿寶哥" }));
// 發(fā)送二進制ArrayBuffer
const buffer = new ArrayBuffer(128);
socket.send(buffer);
// 發(fā)送二進制ArrayBufferView
const intview = new Uint32Array(buffer);
socket.send(intview);
// 發(fā)送二進制Blob
const blob = new Blob([buffer]);
socket.send(blob);
};
以上代碼成功運行后,通過 Chrome 開發(fā)者工具,我們可以看到對應的數(shù)據(jù)交互過程:
下面阿寶哥以發(fā)送 Blob 對象為例,來介紹一下如何發(fā)送二進制數(shù)據(jù)。
Blob(Binary Large Object)表示二進制類型的大對象。在數(shù)據(jù)庫管理系統(tǒng)中,將二進制數(shù)據(jù)存儲為一個單一個體的集合。Blob 通常是影像、聲音或多媒體文件。在 JavaScript 中 Blob 類型的對象表示不可變的類似文件對象的原始數(shù)據(jù)。
對 Blob 感興趣的小伙伴,可以閱讀 “你不知道的 Blob” 這篇文章。
2.6 發(fā)送二進制數(shù)據(jù)
在以上示例中,我們在頁面上創(chuàng)建了兩個 textarea,分別用于存放 待發(fā)送的數(shù)據(jù) 和 服務器返回的數(shù)據(jù)。當用戶輸入完待發(fā)送的文本之后,點擊 發(fā)送 按鈕時,我們會先獲取輸入的文本并把文本包裝成 Blob 對象然后發(fā)送到服務端,而服務端成功接收到消息之后,會把收到的消息原封不動地回傳到客戶端。
當瀏覽器接收到新消息后,如果是文本數(shù)據(jù),會自動將其轉換成 DOMString 對象,如果是二進制數(shù)據(jù)或 Blob 對象,會直接將其轉交給應用,由應用自身來根據(jù)返回的數(shù)據(jù)類型進行相應的處理。
數(shù)據(jù)發(fā)送代碼
// 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("連接未建立,還不能發(fā)送消息");
return;
}
const blob = new Blob([message], { type: "text/plain" });
if (message) socket.send(blob);
console.log(`未發(fā)送至服務器的字節(jié)數(shù):${socket.bufferedAmount}`);
}
當然客戶端接收到服務端返回的消息之后,會判斷返回的數(shù)據(jù)類型,如果是 Blob 類型的話,會調用 Blob 對象的 text() 方法,獲取 Blob 對象中保存的 UTF-8 格式的內容,然后把對應的文本內容保存到 接收的數(shù)據(jù) 對應的 textarea 文本框中。
數(shù)據(jù)接收代碼
// 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 instanceof Blob) {
receivedMsgContainer.value = await receivedData.text();
} else {
receivedMsgContainer.value = receivedData;
}
});
同樣,我們使用 Chrome 瀏覽器的開發(fā)者工具來看一下相應的過程:
通過上圖我們可以很明顯地看到,當使用發(fā)送 Blob 對象時,Data 欄位的信息顯示的是 Binary Message,而對于發(fā)送普通文本來說,Data 欄位的信息是直接顯示發(fā)送的文本消息。
以上示例對應的完整代碼如下所示:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebSocket 發(fā)送二進制數(shù)據(jù)示例</title>
<style>
.block {
flex: 1;
}
</style>
</head>
<body>
<h3>阿寶哥:WebSocket 發(fā)送二進制數(shù)據(jù)示例</h3>
<div style="display: flex;">
<div class="block">
<p>待發(fā)送的數(shù)據(jù):<button onclick="send()">發(fā)送</button></p>
<textarea id="sendMessage" rows="5" cols="15"></textarea>
</div>
<div class="block">
<p>接收的數(shù)據(jù):</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");
// 監(jiān)聽連接成功事件
socket.addEventListener("open", function (event) {
console.log("連接成功,可以開始通訊");
});
// 監(jiān)聽消息
socket.addEventListener("message", async function (event) {
console.log("Message from server ", event.data);
const receivedData = event.data;
if (receivedData instanceof Blob) {
receivedMsgContainer.value = await receivedData.text();
} else {
receivedMsgContainer.value = receivedData;
}
});
function send() {
const message = sendMsgContainer.value;
if (socket.readyState !== WebSocket.OPEN) {
console.log("連接未建立,還不能發(fā)送消息");
return;
}
const blob = new Blob([message], { type: "text/plain" });
if (message) socket.send(blob);
console.log(`未發(fā)送至服務器的字節(jié)數(shù):${socket.bufferedAmount}`);
}
</script>
</body>
</html>
可能有一些小伙伴了解完 WebSocket API 之后,覺得還不夠過癮。下面阿寶哥將帶大家來實現(xiàn)一個支持發(fā)送普通文本的 WebSocket 服務器。
三、手寫 WebSocket 服務器
在介紹如何手寫 WebSocket 服務器前,我們需要了解一下 WebSocket 連接的生命周期。
從上圖可知,在使用 WebSocket 實現(xiàn)全雙工通信之前,客戶端與服務器之間需要先進行握手(Handshake),在完成握手之后才能開始進行數(shù)據(jù)的雙向通信。
握手是在通信電路創(chuàng)建之后,信息傳輸開始之前。握手用于達成參數(shù),如信息傳輸率,字母表,奇偶校驗,中斷過程,和其他協(xié)議特性。 握手有助于不同結構的系統(tǒng)或設備在通信信道中連接,而不需要人為設置參數(shù)。
既然握手是 WebSocket 連接生命周期的第一個環(huán)節(jié),接下來我們就先來分析 WebSocket 的握手協(xié)議。
3.1 握手協(xié)議
WebSocket 協(xié)議屬于應用層協(xié)議,它依賴于傳輸層的 TCP 協(xié)議。WebSocket 通過 HTTP/1.1 協(xié)議的 101 狀態(tài)碼進行握手。為了創(chuàng)建 WebSocket 連接,需要通過瀏覽器發(fā)出請求,之后服務器進行回應,這個過程通常稱為 “握手”(Handshaking)。
利用 HTTP 完成握手有幾個好處。首先,讓 WebSocket 與現(xiàn)有 HTTP 基礎設施兼容:使得 WebSocket 服務器可以運行在 80 和 443 端口上,這通常是對客戶端唯一開放的端口。其次,讓我們可以重用并擴展 HTTP 的 Upgrade 流,為其添加自定義的 WebSocket 首部,以完成協(xié)商。
下面我們以前面已經演示過的發(fā)送普通文本的例子為例,來具體分析一下握手過程。
3.1.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 請求頭
字段說明
Connection 必須設置 Upgrade,表示客戶端希望連接升級。
Upgrade 字段必須設置 websocket,表示希望升級到 WebSocket 協(xié)議。
Sec-WebSocket-Version 表示支持的 WebSocket 版本。RFC6455 要求使用的版本是 13,之前草案的版本均應當棄用。
Sec-WebSocket-Key 是隨機的字符串,服務器端會用這些數(shù)據(jù)來構造出一個 SHA-1 的信息摘要。把 “Sec-WebSocket-Key” 加上一個特殊字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后計算 SHA-1 摘要,之后進行 Base64 編碼,將結果做為 “Sec-WebSocket-Accept” 頭的值,返回給客戶端。如此操作,可以盡量避免普通 HTTP 請求被誤認為 WebSocket 協(xié)議。
Sec-WebSocket-Extensions 用于協(xié)商本次連接要使用的 WebSocket 擴展:客戶端發(fā)送支持的擴展,服務器通過返回相同的首部確認自己支持一個或多個擴展。
Origin 字段是可選的,通常用來表示在瀏覽器中發(fā)起此 WebSocket 連接所在的頁面,類似于 Referer。但是,與 Referer 不同的是,Origin 只包含了協(xié)議和主機名稱。
3.1.2 服務端響應
HTTP/1.1 101 Web Socket Protocol Handshake ①
Connection: Upgrade ②
Upgrade: websocket ③
Sec-WebSocket-Accept: 52Rg3vW4JQ1yWpkvFlsTsiezlqw= ④
備注:已忽略部分 HTTP 響應頭
① 101 響應碼確認升級到 WebSocket 協(xié)議。
② 設置 Connection 頭的值為 "Upgrade" 來指示這是一個升級請求。HTTP 協(xié)議提供了一種特殊的機制,這一機制允許將一個已建立的連接升級成新的、不相容的協(xié)議。
③ Upgrade 頭指定一項或多項協(xié)議名,按優(yōu)先級排序,以逗號分隔。這里表示升級為 WebSocket 協(xié)議。
④ 簽名的鍵值驗證協(xié)議支持。
介紹完 WebSocket 的握手協(xié)議,接下來阿寶哥將使用 Node.js 來開發(fā)我們的 WebSocket 服務器。
3.2 實現(xiàn)握手功能
要開發(fā)一個 WebSocket 服務器,首先我們需要先實現(xiàn)握手功能,這里阿寶哥使用 Node.js 內置的 http 模塊來創(chuàng)建一個 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() 方法創(chuàng)建一個 HTTP 服務器,接著我們監(jiān)聽 upgrade 事件,每次服務器響應升級請求時就會觸發(fā)該事件。由于我們的服務器只支持升級到 WebSocket 協(xié)議,所以如果客戶端請求升級的協(xié)議非 WebSocket 協(xié)議,我們將會返回 “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");
}
開發(fā)完握手功能之后,我們可以使用前面的示例來測試一下該功能。待服務器啟動之后,我們只要對 “發(fā)送普通文本” 示例,做簡單地調整,即把先前的 URL 地址替換成 ws://localhost:8888,就可以進行功能驗證。
感興趣的小伙們可以試試看,以下是阿寶哥本地運行后的結果:
從上圖可知,我們實現(xiàn)的握手功能已經可以正常工作了。那么握手有沒有可能失敗呢?答案是肯定的。比如網絡問題、服務器異?;?Sec-WebSocket-Accept 的值不正確。
下面阿寶哥修改一下 “Sec-WebSocket-Accept” 生成規(guī)則,比如修改 MAGIC_KEY 的值,然后重新驗證一下握手功能。此時,瀏覽器的控制臺會輸出以下異常信息:
WebSocket connection to 'ws://localhost:8888/' failed: Error during WebSocket handshake: Incorrect 'Sec-WebSocket-Accept' header value
如果你的 WebSocket 服務器要支持子協(xié)議的話,你可以參考以下代碼進行子協(xié)議的處理,阿寶哥就不繼續(xù)展開介紹了。
// 從請求頭中讀取子協(xié)議
const protocol = req.headers["sec-websocket-protocol"];
// 如果包含子協(xié)議,則解析子協(xié)議
const protocols = !protocol ? [] : protocol.split(",").map((s) => s.trim());
// 簡單起見,我們僅判斷是否含有JSON子協(xié)議
if (protocols.includes("json")) {
responseHeaders.push(`Sec-WebSocket-Protocol: json`);
}
好的,WebSocket 握手協(xié)議相關的內容基本已經介紹完了。下一步我們來介紹開發(fā)消息通信功能需要了解的一些基礎知識。
3.3 消息通信基礎
在 WebSocket 協(xié)議中,數(shù)據(jù)是通過一系列數(shù)據(jù)幀來進行傳輸?shù)?。為了避免由于網絡中介(例如一些攔截代理)或者一些安全問題,客戶端必須在它發(fā)送到服務器的所有幀中添加掩碼。服務端收到沒有添加掩碼的數(shù)據(jù)幀以后,必須立即關閉連接。
3.3.1 數(shù)據(jù)幀格式
要實現(xiàn)消息通信,我們就必須了解 WebSocket 數(shù)據(jù)幀的格式:
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 ... |
+---------------------------------------------------------------+
可能有一些小伙伴看到上面的內容之后,就開始有點 “懵逼” 了。下面我們來結合實際的數(shù)據(jù)幀來進一步分析一下:
在上圖中,阿寶哥簡單分析了 “發(fā)送普通文本” 示例對應的數(shù)據(jù)幀格式。這里我們來進一步介紹一下 Payload length,因為在后面開發(fā)數(shù)據(jù)解析功能的時候,需要用到該知識點。
Payload length 表示以字節(jié)為單位的 “有效負載數(shù)據(jù)” 長度。它有以下幾種情形:
如果值為 0-125,那么就表示負載數(shù)據(jù)的長度。
如果是 126,那么接下來的 2 個字節(jié)解釋為 16 位的無符號整形作為負載數(shù)據(jù)的長度。
如果是 127,那么接下來的 8 個字節(jié)解釋為一個 64 位的無符號整形(最高位的 bit 必須為 0)作為負載數(shù)據(jù)的長度。
多字節(jié)長度量以網絡字節(jié)順序表示,有效負載長度是指 “擴展數(shù)據(jù)” + “應用數(shù)據(jù)” 的長度。“擴展數(shù)據(jù)” 的長度可能為 0,那么有效負載長度就是 “應用數(shù)據(jù)” 的長度。
另外,除非協(xié)商過擴展,否則 “擴展數(shù)據(jù)” 長度為 0 字節(jié)。在握手協(xié)議中,任何擴展都必須指定 “擴展數(shù)據(jù)” 的長度,這個長度如何進行計算,以及這個擴展如何使用。如果存在擴展,那么這個 “擴展數(shù)據(jù)” 包含在總的有效負載長度中。
3.3.2 掩碼算法
掩碼字段是一個由客戶端隨機選擇的 32 位的值。掩碼值必須是不可被預測的。因此,掩碼必須來自強大的熵源(entropy),并且給定的掩碼不能讓服務器或者代理能夠很容易的預測到后續(xù)幀。掩碼的不可預測性對于預防惡意應用的作者在網上暴露相關的字節(jié)數(shù)據(jù)至關重要。
掩碼不影響數(shù)據(jù)荷載的長度,對數(shù)據(jù)進行掩碼操作和對數(shù)據(jù)進行反掩碼操作所涉及的步驟是相同的。掩碼、反掩碼操作都采用如下算法:
j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
original-octet-i:為原始數(shù)據(jù)的第 i 字節(jié)。
transformed-octet-i:為轉換后的數(shù)據(jù)的第 i 字節(jié)。
masking-key-octet-j:為 mask key 第 j 字節(jié)。
為了讓小伙伴們能夠更好的理解上面掩碼的計算過程,我們來對示例中 “我是阿寶哥” 數(shù)據(jù)進行掩碼操作。這里 “我是阿寶哥” 對應的 UTF-8 編碼如下所示:
E6 88 91 E6 98 AF E9 98 BF E5 AE 9D E5 93 A5
而對應的 Masking-Key 為 0x08f6efb1,根據(jù)上面的算法,我們可以這樣進行掩碼運算:
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 協(xié)議中,數(shù)據(jù)掩碼的作用是增強協(xié)議的安全性。但數(shù)據(jù)掩碼并不是為了保護數(shù)據(jù)本身,因為算法本身是公開的,運算也不復雜。那么為什么還要引入數(shù)據(jù)掩碼呢?引入數(shù)據(jù)掩碼是為了防止早期版本的協(xié)議中存在的代理緩存污染攻擊等問題。
了解完 WebSocket 掩碼算法和數(shù)據(jù)掩碼的作用之后,我們再來介紹一下數(shù)據(jù)分片的概念。
3.3.3 數(shù)據(jù)分片
WebSocket 的每條消息可能被切分成多個數(shù)據(jù)幀。當 WebSocket 的接收方收到一個數(shù)據(jù)幀時,會根據(jù) FIN 的值來判斷,是否已經收到消息的最后一個數(shù)據(jù)幀。
利用 FIN 和 Opcode,我們就可以跨幀發(fā)送消息。操作碼告訴了幀應該做什么。如果是 0x1,有效載荷就是文本。如果是 0x2,有效載荷就是二進制數(shù)據(jù)。但是,如果是 0x0,則該幀是一個延續(xù)幀。這意味著服務器應該將幀的有效負載連接到從該客戶機接收到的最后一個幀。
為了讓大家能夠更好地理解上述的內容,我們來看一個來自 MDN 上的示例:
Client: FIN=1, opcode=0x1, msg="hello"
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (listening, new message 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 new year to you too!
在以上示例中,客戶端向服務器發(fā)送了兩條消息。第一個消息在單個幀中發(fā)送,而第二個消息跨三個幀發(fā)送。
其中第一個消息是一個完整的消息(FIN=1 且 opcode != 0x0),因此服務器可以根據(jù)需要進行處理或響應。而第二個消息是文本消息(opcode=0x1)且 FIN=0,表示消息還沒發(fā)送完成,還有后續(xù)的數(shù)據(jù)幀。該消息的所有剩余部分都用延續(xù)幀(opcode=0x0)發(fā)送,消息的最終幀用 FIN=1 標記。
好的,簡單介紹了數(shù)據(jù)分片的相關內容。接下來,我們來開始實現(xiàn)消息通信功能。
3.4 實現(xiàn)消息通信功能
阿寶哥把實現(xiàn)消息通信功能,分解為消息解析與消息響應兩個子功能,下面我們分別來介紹如何實現(xiàn)這兩個子功能。
3.4.1 消息解析
利用消息通信基礎環(huán)節(jié)中介紹的相關知識,阿寶哥實現(xiàn)了一個 parseMessage 函數(shù),用來解析客戶端傳過來的 WebSocket 數(shù)據(jù)幀。出于簡單考慮,這里只處理文本幀,具體代碼如下所示:
function parseMessage(buffer) {
// 第一個字節(jié),包含了FIN位,opcode, 掩碼位
const firstByte = buffer.readUInt8(0);
// [FIN, RSV, RSV, RSV, OPCODE, OPCODE, OPCODE, OPCODE];
// 右移7位取首位,1位,表示是否是最后一幀數(shù)據(jù)
const isFinalFrame = Boolean((firstByte >>> 7) & 0x01);
console.log("isFIN: ", isFinalFrame);
// 取出操作碼,低四位
/**
* %x0:表示一個延續(xù)幀。當 Opcode 為 0 時,表示本次數(shù)據(jù)傳輸采用了數(shù)據(jù)分片,當前收到的數(shù)據(jù)幀為其中一個數(shù)據(jù)分片;
* %x1:表示這是一個文本幀(text frame);
* %x2:表示這是一個二進制幀(binary frame);
* %x3-7:保留的操作代碼,用于后續(xù)定義的非控制幀;
* %x8:表示連接斷開;
* %x9:表示這是一個心跳請求(ping);
* %xA:表示這是一個心跳響應(pong);
* %xB-F:保留的操作代碼,用于后續(xù)定義的控制幀。
*/
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位,表示是否使用了掩碼,在發(fā)送給服務端的數(shù)據(jù)幀里必須使用掩碼,而服務端返回時不需要掩碼
const useMask = Boolean((secondByte >>> 7) & 0x01);
console.log("use MASK: ", useMask);
const payloadLen = secondByte & 0x7f; // 低7位表示載荷字節(jié)長度
offset += 1;
// 四個字節(jié)的掩碼
let MASK = [];
// 如果這個值在0-125之間,則后面的4個字節(jié)(32位)就應該被直接識別成掩碼;
if (payloadLen <= 0x7d) {
// 載荷長度小于125
MASK = buffer.slice(offset, 4 + offset);
offset += 4;
console.log("payload length: ", payloadLen);
} else if (payloadLen === 0x7e) {
// 如果這個值是126,則后面兩個字節(jié)(16位)內容應該,被識別成一個16位的二進制數(shù)表示數(shù)據(jù)內容大小;
console.log("payload length: ", buffer.readInt16BE(offset));
// 長度是126, 則后面兩個字節(jié)作為payload length,32位的掩碼
MASK = buffer.slice(offset + 2, offset + 2 + 4);
offset += 6;
} else {
// 如果這個值是127,則后面的8個字節(jié)(64位)內容應該被識別成一個64位的二進制數(shù)表示數(shù)據(jù)內容大小
MASK = buffer.slice(offset + 8, offset + 8 + 4);
offset += 12;
}
// 開始讀取后面的payload,與掩碼計算,得到原來的字節(jié)內容
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 "";
}
創(chuàng)建完 parseMessage 函數(shù),我們來更新一下之前創(chuàng)建的 WebSocket 服務器:
server.on("upgrade", function (req, socket) {
socket.on("data", (buffer) => {
const message = parseMessage(buffer);
if (message) {
console.log("Message from client:" + 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;
}
// 省略已有代碼
});
更新完成之后,我們重新啟動服務器,然后繼續(xù)使用 “發(fā)送普通文本” 的示例來測試消息解析功能。以下發(fā)送 “我是阿寶哥” 文本消息后,WebSocket 服務器輸出的信息。
Server running at http://localhost:8888
isFIN: true
use MASK: true
payload length: 15
Message from client:我是阿寶哥
通過觀察以上的輸出信息,我們的 WebSocket 服務器已經可以成功解析客戶端發(fā)送包含普通文本的數(shù)據(jù)幀,下一步我們來實現(xiàn)消息響應的功能。
3.4.2 消息響應
要把數(shù)據(jù)返回給客戶端,我們的 WebSocket 服務器也得按照 WebSocket 數(shù)據(jù)幀的格式來封裝數(shù)據(jù)。與前面介紹的 parseMessage 函數(shù)一樣,阿寶哥也封裝了一個 constructReply 函數(shù)用來封裝返回的數(shù)據(jù),該函數(shù)的具體代碼如下:
function constructReply(data) {
const json = JSON.stringify(data);
const jsonByteLength = Buffer.byteLength(json);
// 目前只支持小于65535字節(jié)的負載
const lengthByteCount = jsonByteLength < 126 ? 0 : 2;
const payloadLength = lengthByteCount === 0 ? jsonByteLength : 126;
const buffer = Buffer.alloc(2 + lengthByteCount + jsonByteLength);
// 設置數(shù)據(jù)幀首字節(jié),設置opcode為1,表示文本幀
buffer.writeUInt8(0b10000001, 0);
buffer.writeUInt8(payloadLength, 1);
// 如果payloadLength為126,則后面兩個字節(jié)(16位)內容應該,被識別成一個16位的二進制數(shù)表示數(shù)據(jù)內容大小
let payloadOffset = 2;
if (lengthByteCount > 0) {
buffer.writeUInt16BE(jsonByteLength, 2);
payloadOffset += lengthByteCount;
}
// 把JSON數(shù)據(jù)寫入到Buffer緩沖區(qū)中
buffer.write(json, payloadOffset);
return buffer;
}
創(chuàng)建完 constructReply 函數(shù),我們再來更新一下之前創(chuàng)建的 WebSocket 服務器:
server.on("upgrade", function (req, socket) {
socket.on("data", (buffer) => {
const message = parseMessage(buffer);
if (message) {
console.log("Message from client:" + message);
// 新增以下