本文阿寶哥將從多個方面入手,全方位帶你一塊兒探索 WebSocket 技術。閱讀完本文,你將瞭解如下內容:html
-
瞭解 WebSocket 的誕生背景、WebSocket 是什麼及它的優勢; -
瞭解 WebSocket 含有哪些 API 及如何使用 WebSocket API 發送普通文本和二進制數據; -
瞭解 WebSocket 的握手協議和數據幀格式、掩碼算法等相關知識; -
瞭解如何實現一個支持發送普通文本的 WebSocket 服務器。
在最後的 阿寶哥有話說 環節,阿寶哥將介紹 WebSocket 與 HTTP 之間的關係、WebSocket 與長輪詢有什麼區別、什麼是 WebSocket 心跳及 Socket 是什麼等內容。前端
下面咱們進入正題,爲了讓你們可以更好地理解和掌握 WebSocket 技術,咱們先來介紹一下什麼是 WebSocket。web
1、什麼是 WebSocket
1.1 WebSocket 誕生背景
早期,不少網站爲了實現推送技術,所用的技術都是輪詢。輪詢是指由瀏覽器每隔一段時間向服務器發出 HTTP 請求,而後服務器返回最新的數據給客戶端。常見的輪詢方式分爲輪詢與長輪詢,它們的區別以下圖所示:算法
爲了更加直觀感覺輪詢與長輪詢之間的區別,咱們來看一下具體的代碼:數據庫
這種傳統的模式帶來很明顯的缺點,即瀏覽器須要不斷的向服務器發出請求,然而 HTTP 請求與響應可能會包含較長的頭部,其中真正有效的數據可能只是很小的一部分,因此這樣會消耗不少帶寬資源。編程
比較新的輪詢技術是 Comet。這種技術雖然能夠實現雙向通訊,但仍然須要反覆發出請求。並且在 Comet 中廣泛採用的 HTTP 長鏈接也會消耗服務器資源。json
在這種狀況下,HTML5 定義了 WebSocket 協議,能更好的節省服務器資源和帶寬,而且可以更實時地進行通信。Websocket 使用 ws 或 wss 的統一資源標誌符(URI),其中 wss 表示使用了 TLS 的 Websocket。如:數組
ws://echo.websocket.org
wss://echo.websocket.org
WebSocket 與 HTTP 和 HTTPS 使用相同的 TCP 端口,能夠繞過大多數防火牆的限制。默認狀況下,WebSocket 協議使用 80 端口;若運行在 TLS 之上時,默認使用 443 端口。瀏覽器
1.2 WebSocket 簡介
WebSocket 是一種網絡傳輸協議,可在單個 TCP 鏈接上進行全雙工通訊,位於 OSI 模型的應用層。WebSocket 協議在 2011 年由 IETF 標準化爲 RFC 6455,後由 RFC 7936 補充規範。緩存
WebSocket 使得客戶端和服務器之間的數據交換變得更加簡單,容許服務端主動向客戶端推送數據。在 WebSocket API 中,瀏覽器和服務器只須要完成一次握手,二者之間就能夠建立持久性的鏈接,並進行雙向數據傳輸。
介紹完輪詢和 WebSocket 的相關內容以後,接下來咱們來看一下 XHR Polling 與 WebSocket 之間的區別:
1.3 WebSocket 優勢
-
較少的控制開銷。在鏈接建立後,服務器和客戶端之間交換數據時,用於協議控制的數據包頭部相對較小。 -
更強的實時性。因爲協議是全雙工的,因此服務器能夠隨時主動給客戶端下發數據。相對於 HTTP 請求須要等待客戶端發起請求服務端才能響應,延遲明顯更少。 -
保持鏈接狀態。與 HTTP 不一樣的是,WebSocket 須要先建立鏈接,這就使得其成爲一種有狀態的協議,以後通訊時能夠省略部分狀態信息。 -
更好的二進制支持。WebSocket 定義了二進制幀,相對 HTTP,能夠更輕鬆地處理二進制內容。 -
能夠支持擴展。WebSocket 定義了擴展,用戶能夠擴展協議、實現部分自定義的子協議。
因爲 WebSocket 擁有上述的優勢,因此它被普遍地應用在即時通訊、實時音視頻、在線教育和遊戲等領域。對於前端開發者來講,要想使用 WebSocket 提供的強大能力,就必須先掌握 WebSocket API,下面阿寶哥帶你們一塊兒來認識一下 WebSocket API。
2、WebSocket API
在介紹 WebSocket API 以前,咱們先來了解一下它的兼容性:
(圖片來源:https://caniuse.com/#search=WebSocket)
從上圖可知,目前主流的 Web 瀏覽器都支持 WebSocket,因此咱們能夠在大多數項目中放心地使用它。
在瀏覽器中要使用 WebSocket 提供的能力,咱們就必須先建立 WebSocket 對象,該對象提供了用於建立和管理 WebSocket 鏈接,以及能夠經過該鏈接發送和接收數據的 API。
使用 WebSocket 構造函數,咱們就能輕易地構造一個 WebSocket 對象。接下來咱們將從 WebSocket 構造函數、WebSocket 對象的屬性、方法及 WebSocket 相關的事件四個方面來介紹 WebSocket API,首先咱們從 WebSocket 的構造函數入手:
2.1 構造函數
WebSocket 構造函數的語法爲:
const myWebSocket = new WebSocket(url [, protocols]);
相關參數說明以下:
-
url:表示鏈接的 URL,這是 WebSocket 服務器將響應的 URL。 -
protocols(可選):一個協議字符串或者一個包含協議字符串的數組。這些字符串用於指定子協議,這樣單個服務器能夠實現多個 WebSocket 子協議。好比,你可能但願一臺服務器可以根據指定的協議(protocol)處理不一樣類型的交互。若是不指定協議字符串,則假定爲空字符串。
當嘗試鏈接的端口被阻止時,會拋出 SECURITY_ERR
異常。
2.2 屬性
WebSocket 對象包含如下屬性:
每一個屬性的具體含義以下:
-
binaryType:使用二進制的數據類型鏈接。 -
bufferedAmount(只讀):未發送至服務器的字節數。 -
extensions(只讀):服務器選擇的擴展。 -
onclose:用於指定鏈接關閉後的回調函數。 -
onerror:用於指定鏈接失敗後的回調函數。 -
onmessage:用於指定當從服務器接受到信息時的回調函數。 -
onopen:用於指定鏈接成功後的回調函數。 -
protocol(只讀):用於返回服務器端選中的子協議的名字。 -
readyState(只讀):返回當前 WebSocket 的鏈接狀態,共有 4 種狀態: -
CONNECTING — 正在鏈接中,對應的值爲 0; -
OPEN — 已經鏈接而且能夠通信,對應的值爲 1; -
CLOSING — 鏈接正在關閉,對應的值爲 2; -
CLOSED — 鏈接已關閉或者沒有鏈接成功,對應的值爲 3。 -
url(只讀):返回值爲當構造函數建立 WebSocket 實例對象時 URL 的絕對路徑。
2.3 方法
-
close([code[, reason]]):該方法用於關閉 WebSocket 鏈接,若是鏈接已經關閉,則此方法不執行任何操做。 -
send(data):該方法將須要經過 WebSocket 連接傳輸至服務器的數據排入隊列,並根據所須要傳輸的數據的大小來增長 bufferedAmount 的值 。若數據沒法傳輸(好比數據須要緩存而緩衝區已滿)時,套接字會自行關閉。
2.4 事件
使用 addEventListener() 或將一個事件監聽器賦值給 WebSocket 對象的 oneventname 屬性,來監聽下面的事件。
-
close:當一個 WebSocket 鏈接被關閉時觸發,也能夠經過 onclose 屬性來設置。 -
error:當一個 WebSocket 鏈接因錯誤而關閉時觸發,也能夠經過 onerror 屬性來設置。 -
message:當經過 WebSocket 收到數據時觸發,也能夠經過 onmessage 屬性來設置。 -
open:當一個 WebSocket 鏈接成功時觸發,也能夠經過 onopen 屬性來設置。
介紹完 WebSocket API,咱們來舉一個使用 WebSocket 發送普通文本的示例。
2.5 發送普通文本
在以上示例中,咱們在頁面上建立了兩個 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>
<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 onclick="send()">發送</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", 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 = new ArrayBuffer(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」 這篇文章。
2.6 發送二進制數據
在以上示例中,咱們在頁面上建立了兩個 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 = new Blob([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 instanceof Blob) {
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 onclick="send()">發送</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 instanceof Blob) {
receivedMsgContainer.value = await receivedData.text();
} else {
receivedMsgContainer.value = receivedData;
}
});
function send() {
const message = sendMsgContainer.value;
if (socket.readyState !== WebSocket.OPEN) {
console.log("鏈接未創建,還不能發送消息");
return;
}
const blob = new Blob([message], { type: "text/plain" });
if (message) socket.send(blob);
console.log(`未發送至服務器的字節數:${socket.bufferedAmount}`);
}
</script>
</body>
</html>
可能有一些小夥伴瞭解完 WebSocket API 以後,以爲還不夠過癮。下面阿寶哥將帶你們來實現一個支持發送普通文本的 WebSocket 服務器。
3、手寫 WebSocket 服務器
在介紹如何手寫 WebSocket 服務器前,咱們須要瞭解一下 WebSocket 鏈接的生命週期。
從上圖可知,在使用 WebSocket 實現全雙工通訊以前,客戶端與服務器之間須要先進行握手(Handshake),在完成握手以後才能開始進行數據的雙向通訊。
握手是在通訊電路建立以後,信息傳輸開始以前。握手用於達成參數,如信息傳輸率,字母表,奇偶校驗,中斷過程,和其餘協議特性。 握手有助於不一樣結構的系統或設備在通訊信道中鏈接,而不須要人爲設置參數。
既然握手是 WebSocket 鏈接生命週期的第一個環節,接下來咱們就先來分析 WebSocket 的握手協議。
3.1 握手協議
WebSocket 協議屬於應用層協議,它依賴於傳輸層的 TCP 協議。WebSocket 經過 HTTP/1.1 協議的 101 狀態碼進行握手。爲了建立 WebSocket 鏈接,須要經過瀏覽器發出請求,以後服務器進行迴應,這個過程一般稱爲 「握手」(Handshaking)。
利用 HTTP 完成握手有幾個好處。首先,讓 WebSocket 與現有 HTTP 基礎設施兼容:使得 WebSocket 服務器能夠運行在 80 和 443 端口上,這一般是對客戶端惟一開放的端口。其次,讓咱們能夠重用並擴展 HTTP 的 Upgrade 流,爲其添加自定義的 WebSocket 首部,以完成協商。
下面咱們之前面已經演示過的發送普通文本的例子爲例,來具體分析一下握手過程。
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 協議。 -
Sec-WebSocket-Version 表示支持的 WebSocket 版本。RFC6455 要求使用的版本是 13,以前草案的版本均應當棄用。 -
Sec-WebSocket-Key 是隨機的字符串,服務器端會用這些數據來構造出一個 SHA-1 的信息摘要。把 「Sec-WebSocket-Key」 加上一個特殊字符串 「258EAFA5-E914-47DA-95CA-C5AB0DC85B11」,而後計算 SHA-1 摘要,以後進行 Base64 編碼,將結果作爲 「Sec-WebSocket-Accept」 頭的值,返回給客戶端。如此操做,能夠儘可能避免普通 HTTP 請求被誤認爲 WebSocket 協議。 -
Sec-WebSocket-Extensions 用於協商本次鏈接要使用的 WebSocket 擴展:客戶端發送支持的擴展,服務器經過返回相同的首部確認本身支持一個或多個擴展。 -
Origin 字段是可選的,一般用來表示在瀏覽器中發起此 WebSocket 鏈接所在的頁面,相似於 Referer。可是,與 Referer 不一樣的是,Origin 只包含了協議和主機名稱。
3.1.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 服務器。
3.2 實現握手功能
要開發一個 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 握手協議相關的內容基本已經介紹完了。下一步咱們來介紹開發消息通訊功能須要瞭解的一些基礎知識。
3.3 消息通訊基礎
在 WebSocket 協議中,數據是經過一系列數據幀來進行傳輸的。爲了不因爲網絡中介(例如一些攔截代理)或者一些安全問題,客戶端必須在它發送到服務器的全部幀中添加掩碼。服務端收到沒有添加掩碼的數據幀之後,必須當即關閉鏈接。
3.3.1 數據幀格式
要實現消息通訊,咱們就必須瞭解 WebSocket 數據幀的格式:
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 ... |
+---------------------------------------------------------------+
可能有一些小夥伴看到上面的內容以後,就開始有點 「懵逼」 了。下面咱們來結合實際的數據幀來進一步分析一下:
在上圖中,阿寶哥簡單分析了 「發送普通文本」 示例對應的數據幀格式。這裏咱們來進一步介紹一下 Payload length,由於在後面開發數據解析功能的時候,須要用到該知識點。
Payload length 表示以字節爲單位的 「有效負載數據」 長度。它有如下幾種情形:
-
若是值爲 0-125,那麼就表示負載數據的長度。 -
若是是 126,那麼接下來的 2 個字節解釋爲 16 位的無符號整形做爲負載數據的長度。 -
若是是 127,那麼接下來的 8 個字節解釋爲一個 64 位的無符號整形(最高位的 bit 必須爲 0)做爲負載數據的長度。
多字節長度量以網絡字節順序表示,有效負載長度是指 「擴展數據」 + 「應用數據」 的長度。「擴展數據」 的長度可能爲 0,那麼有效負載長度就是 「應用數據」 的長度。
另外,除非協商過擴展,不然 「擴展數據」 長度爲 0 字節。在握手協議中,任何擴展都必須指定 「擴展數據」 的長度,這個長度如何進行計算,以及這個擴展如何使用。若是存在擴展,那麼這個 「擴展數據」 包含在總的有效負載長度中。
3.3.2 掩碼算法
掩碼字段是一個由客戶端隨機選擇的 32 位的值。掩碼值必須是不可被預測的。所以,掩碼必須來自強大的熵源(entropy),而且給定的掩碼不能讓服務器或者代理可以很容易的預測到後續幀。掩碼的不可預測性對於預防惡意應用的做者在網上暴露相關的字節數據相當重要。
掩碼不影響數據荷載的長度,對數據進行掩碼操做和對數據進行反掩碼操做所涉及的步驟是相同的。掩碼、反掩碼操做都採用以下算法:
j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
-
original-octet-i:爲原始數據的第 i 字節。 -
transformed-octet-i:爲轉換後的數據的第 i 字節。 -
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 掩碼算法和數據掩碼的做用以後,咱們再來介紹一下數據分片的概念。
3.3.3 數據分片
WebSocket 的每條消息可能被切分紅多個數據幀。當 WebSocket 的接收方收到一個數據幀時,會根據 FIN 的值來判斷,是否已經收到消息的最後一個數據幀。
利用 FIN 和 Opcode,咱們就能夠跨幀發送消息。操做碼告訴了幀應該作什麼。若是是 0x1,有效載荷就是文本。若是是 0x2,有效載荷就是二進制數據。可是,若是是 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, 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!
在以上示例中,客戶端向服務器發送了兩條消息。第一個消息在單個幀中發送,而第二個消息跨三個幀發送。
其中第一個消息是一個完整的消息(FIN=1 且 opcode != 0x0),所以服務器能夠根據須要進行處理或響應。而第二個消息是文本消息(opcode=0x1)且 FIN=0,表示消息還沒發送完成,還有後續的數據幀。該消息的全部剩餘部分都用延續幀(opcode=0x0)發送,消息的最終幀用 FIN=1 標記。
好的,簡單介紹了數據分片的相關內容。接下來,咱們來開始實現消息通訊功能。
3.4 實現消息通訊功能
阿寶哥把實現消息通訊功能,分解爲消息解析與消息響應兩個子功能,下面咱們分別來介紹如何實現這兩個子功能。
3.4.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);
} 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 "";
}
建立完 parseMessage 函數,咱們來更新一下以前建立的 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;
}
// 省略已有代碼
});
更新完成以後,咱們從新啓動服務器,而後繼續使用 「發送普通文本」 的示例來測試消息解析功能。如下發送 「我是阿寶哥」 文本消息後,WebSocket 服務器輸出的信息。
Server running at http://localhost:8888
isFIN: true
use MASK: true
payload length: 15
Message from client:我是阿寶哥
經過觀察以上的輸出信息,咱們的 WebSocket 服務器已經能夠成功解析客戶端發送包含普通文本的數據幀,下一步咱們來實現消息響應的功能。
3.4.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);
// 新增如下👇代碼
socket.write(constructReply({ message }));
} else if (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 感興趣的小夥伴能夠自行查閱相關資料。
4、阿寶哥有話說
4.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.com/2019/07/what-is-osi-model.html)
4.2 WebSocket 與長輪詢有什麼區別
長輪詢就是客戶端發起一個請求,服務器收到客戶端發來的請求後,服務器端不會直接進行響應,而是先將這個請求掛起,而後判斷請求的數據是否有更新。若是有更新,則進行響應,若是一直沒有數據,則等待必定的時間後才返回。
長輪詢的本質仍是基於 HTTP 協議,它仍然是一個一問一答(請求 — 響應)的模式。而 WebSocket 在握手成功後,就是全雙工的 TCP 通道,數據能夠主動從服務端發送到客戶端。
4.3 什麼是 WebSocket 心跳
網絡中的接收和發送數據都是使用 SOCKET 進行實現。可是若是此套接字已經斷開,那發送數據和接收數據的時候就必定會有問題。但是如何判斷這個套接字是否還可使用呢?這個就須要在系統中建立心跳機制。所謂 「心跳」 就是定時發送一個自定義的結構體(心跳包或心跳幀),讓對方知道本身 「在線」。以確保連接的有效性。
而所謂的心跳包就是客戶端定時發送簡單的信息給服務器端告訴它我還在而已。代碼就是每隔幾分鐘發送一個固定信息給服務端,服務端收到後回覆一個固定信息,若是服務端幾分鐘內沒有收到客戶端信息則視客戶端斷開。
在 WebSocket 協議中定義了 心跳 Ping 和 心跳 Pong 的控制幀:
-
心跳 Ping 幀包含的操做碼是 0x9。若是收到了一個心跳 Ping 幀,那麼終端必須發送一個心跳 Pong 幀做爲迴應,除非已經收到了一個關閉幀。不然終端應該儘快回覆 Pong 幀。 -
心跳 Pong 幀包含的操做碼是 0xA。做爲迴應發送的 Pong 幀必須完整攜帶 Ping 幀中傳遞過來的 「應用數據」 字段。若是終端收到一個 Ping 幀可是沒有發送 Pong 幀來回應以前的 Ping 幀,那麼終端能夠選擇僅爲最近處理的 Ping 幀發送 Pong 幀。此外,能夠自動發送一個 Pong 幀,這用做單向心跳。
4.4 Socket 是什麼
網絡上的兩個程序經過一個雙向的通訊鏈接實現數據的交換,這個鏈接的一端稱爲一個 socket(套接字),所以創建網絡通訊鏈接至少要一對端口號。socket 本質是對 TCP/IP 協議棧的封裝,它提供了一個針對 TCP 或者 UDP 編程的接口,並非另外一種協議。經過 socket,你可使用 TCP/IP 協議。
Socket 的英文原義是「孔」或「插座」。做爲 BSD UNIX 的進程通訊機制,取後一種意思。一般也稱做"套接字",用於描述IP地址和端口,是一個通訊鏈的句柄,能夠用來實現不一樣虛擬機或不一樣計算機之間的通訊。
在Internet 上的主機通常運行了多個服務軟件,同時提供幾種服務。每種服務都打開一個Socket,並綁定到一個端口上,不一樣的端口對應於不一樣的服務。Socket 正如其英文原義那樣,像一個多孔插座。一臺主機猶如佈滿各類插座的房間,每一個插座有一個編號,有的插座提供 220 伏交流電, 有的提供 110 伏交流電,有的則提供有線電視節目。客戶軟件將插頭插到不一樣編號的插座,就能夠獲得不一樣的服務。—— 百度百科
關於 Socket,能夠總結如下幾點:
-
它能夠實現底層通訊,幾乎全部的應用層都是經過 socket 進行通訊的。 -
對 TCP/IP 協議進行封裝,便於應用層協議調用,屬於兩者之間的中間抽象層。 -
TCP/IP 協議族中,傳輸層存在兩種通用協議: TCP、UDP,兩種協議不一樣,由於不一樣參數的 socket 實現過程也不同。
下圖說明了面向鏈接的協議的套接字 API 的客戶端/服務器關係。
5、參考資源
-
維基百科 - WebSocket -
MDN - WebSocket -
MDN - Protocol_upgrade_mechanism -
rfc6455 -
Web 性能權威指南
聚焦全棧,專一分享 TypeScript、Web API、Deno 等技術乾貨。
本文分享自微信公衆號 - 前端自習課(FE-study)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。