<rt id="bn8ez"></rt>
<label id="bn8ez"></label>

  • <span id="bn8ez"></span>

    <label id="bn8ez"><meter id="bn8ez"></meter></label>

    Jack Jiang

    我的最新工程MobileIMSDK:http://git.oschina.net/jackjiang/MobileIMSDK
    posts - 503, comments - 13, trackbacks - 0, articles - 1

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

    1、引言

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

    閱讀完本文,你將了解以下內(nèi)容:

    • 1)了解 WebSocket 的誕生背景、WebSocket 是什么及它的優(yōu)點;
    • 2)了解 WebSocket 含有哪些 API 及如何使用 WebSocket API 發(fā)送普通文本和二進(jìn)制數(shù)據(jù);
    • 3)了解 WebSocket 的握手協(xié)議和數(shù)據(jù)幀格式、掩碼算法等相關(guān)知識;
    • 4)了解 WebSocket 與http、長輪詢、socket等的關(guān)系,理清常識性的理解錯誤;
    • 5)了解如何實現(xiàn)一個支持發(fā)送普通文本的 WebSocket 服務(wù)器。

    學(xué)習(xí)交流:

    - 即時通訊/推送技術(shù)開發(fā)交流5群:215477170 [推薦]

    - 移動端IM開發(fā)入門文章:《新手入門一篇就夠:從零開發(fā)移動端IM

    - 開源IM框架源碼:https://github.com/JackJiang2011/MobileIMSDK

    本文同步發(fā)布于:http://www.52im.net/thread-3713-1-1.html

    2、關(guān)于作者

    作者網(wǎng)名:阿寶哥

    個人博客:http://www.semlinker.com/

    作者Github:https://github.com/semlinker/

    3、什么是 WebSocket

    3.1 WebSocket 誕生背景

    早期,很多網(wǎng)站為了實現(xiàn)推送技術(shù),所用的技術(shù)都是輪詢(也叫短輪詢)。輪詢是指由瀏覽器每隔一段時間向服務(wù)器發(fā)出 HTTP 請求,然后服務(wù)器返回最新的數(shù)據(jù)給客戶端。

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

     

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

     

    這種傳統(tǒng)的模式帶來很明顯的缺點,即瀏覽器需要不斷的向服務(wù)器發(fā)出請求,然而 HTTP 請求與響應(yīng)可能會包含較長的頭部,其中真正有效的數(shù)據(jù)可能只是很小的一部分,所以這樣會消耗很多帶寬資源。

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

    比較新的輪詢技術(shù)是 Comet。這種技術(shù)雖然可以實現(xiàn)雙向通信,但仍然需要反復(fù)發(fā)出請求。而且在 Comet 中普遍采用的 HTTP 長連接也會消耗服務(wù)器資源。

    在這種情況下,HTML5 定義了 WebSocket 協(xié)議,能更好的節(jié)省服務(wù)器資源和帶寬,并且能夠更實時地進(jìn)行通訊。

    Websocket 使用 ws 或 wss 的統(tǒng)一資源標(biāo)志符(URI),其中 wss 表示使用了 TLS 的 Websocket。

    如:

    ws://echo.websocket.org

    wss://echo.websocket.org

    WebSocket 與 HTTP 和 HTTPS 使用相同的 TCP 端口,可以繞過大多數(shù)防火墻的限制。

    默認(rèn)情況下:

    • 1)WebSocket 協(xié)議使用 80 端口;
    • 2)若運行在 TLS 之上時,默認(rèn)使用 443 端口。

    3.2 WebSocket 簡介

    WebSocket 是一種網(wǎng)絡(luò)傳輸協(xié)議,可在單個 TCP 連接上進(jìn)行全雙工通信,位于 OSI 模型的應(yīng)用層。WebSocket 協(xié)議在 2011 年由 IETF 標(biāo)準(zhǔn)化為 RFC 6455,后由 RFC 7936 補(bǔ)充規(guī)范。

    WebSocket 使得客戶端和服務(wù)器之間的數(shù)據(jù)交換變得更加簡單,允許服務(wù)端主動向客戶端推送數(shù)據(jù)。在 WebSocket API 中,瀏覽器和服務(wù)器只需要完成一次握手,兩者之間就可以創(chuàng)建持久性的連接,并進(jìn)行雙向數(shù)據(jù)傳輸。

    介紹完輪詢和 WebSocket 的相關(guān)內(nèi)容之后,接下來用一張圖看一下 XHR Polling(短輪詢) 與 WebSocket 之間的區(qū)別。

    XHR Polling與 WebSocket 之間的區(qū)別如下圖所示:

     

    3.3 WebSocket 優(yōu)點

    普遍認(rèn)為,WebSocket的優(yōu)點有如下幾點:

    • 1)較少的控制開銷:在連接創(chuàng)建后,服務(wù)器和客戶端之間交換數(shù)據(jù)時,用于協(xié)議控制的數(shù)據(jù)包頭部相對較小;
    • 2)更強(qiáng)的實時性:由于協(xié)議是全雙工的,所以服務(wù)器可以隨時主動給客戶端下發(fā)數(shù)據(jù)。相對于 HTTP 請求需要等待客戶端發(fā)起請求服務(wù)端才能響應(yīng),延遲明顯更少;
    • 3)保持連接狀態(tài):與 HTTP 不同的是,WebSocket 需要先創(chuàng)建連接,這就使得其成為一種有狀態(tài)的協(xié)議,之后通信時可以省略部分狀態(tài)信息;
    • 4)更好的二進(jìn)制支持:WebSocket 定義了二進(jìn)制幀,相對 HTTP,可以更輕松地處理二進(jìn)制內(nèi)容;
    • 5)可以支持?jǐn)U展:WebSocket 定義了擴(kuò)展,用戶可以擴(kuò)展協(xié)議、實現(xiàn)部分自定義的子協(xié)議。

    由于 WebSocket 擁有上述的優(yōu)點,所以它被廣泛地應(yīng)用在即時通訊/IM、實時音視頻、在線教育和游戲等領(lǐng)域。

    對于前端開發(fā)者來說,要想使用 WebSocket 提供的強(qiáng)大能力,就必須先掌握 WebSocket API,下面帶大家一起來認(rèn)識一下 WebSocket API。

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

    4、WebSocket API 學(xué)習(xí)

    4.1 基本情況

    在介紹 WebSocket API 之前,我們先來了解一下它的兼容性:

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

    由上圖可知:目前主流的 Web 瀏覽器都支持 WebSocket,所以我們可以在大多數(shù)項目中放心地使用它。

    在瀏覽器中要使用 WebSocket 提供的能力,我們就必須先創(chuàng)建 WebSocket 對象,該對象提供了用于創(chuàng)建和管理 WebSocket 連接,以及可以通過該連接發(fā)送和接收數(shù)據(jù)的 API。

    使用 WebSocket 構(gòu)造函數(shù),我們就能輕易地構(gòu)造一個 WebSocket 對象。

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

    • 1)WebSocket 構(gòu)造函數(shù);
    • 2)WebSocket 對象的屬性;
    • 3)WebSocket 的方法;
    • 4)WebSocket 事件。

    接下來我們從 WebSocket 的構(gòu)造函數(shù)入手開始學(xué)習(xí)。

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

    4.2 構(gòu)造函數(shù)

    WebSocket 構(gòu)造函數(shù)的語法為:

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

    相關(guān)參數(shù)說明如下:

    • 1)url:表示連接的 URL,這是 WebSocket 服務(wù)器將響應(yīng)的 URL;
    • 2)protocols(可選):一個協(xié)議字符串或者一個包含協(xié)議字符串的數(shù)組。

    針對第2)點:這些字符串用于指定子協(xié)議,這樣單個服務(wù)器可以實現(xiàn)多個 WebSocket 子協(xié)議。

    比如:你可能希望一臺服務(wù)器能夠根據(jù)指定的協(xié)議(protocol)處理不同類型的交互。如果不指定協(xié)議字符串,則假定為空字符串。

    使用WebSocket 構(gòu)造函數(shù)時,當(dāng)嘗試連接的端口被阻止時,會拋出 SECURITY_ERR 異常。

    PS:有關(guān)WebSocket構(gòu)造函數(shù)的更詳細(xì)說明,可以參見官方API文檔

    4.3 屬性

    WebSocket 對象包含以下屬性:

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

    • 1)binaryType:使用二進(jìn)制的數(shù)據(jù)類型連接;
    • 2)bufferedAmount(只讀):未發(fā)送至服務(wù)器的字節(jié)數(shù);
    • 3)extensions(只讀):服務(wù)器選擇的擴(kuò)展;
    • 4)onclose:用于指定連接關(guān)閉后的回調(diào)函數(shù);
    • 5)onerror:用于指定連接失敗后的回調(diào)函數(shù);
    • 6)onmessage:用于指定當(dāng)從服務(wù)器接受到信息時的回調(diào)函數(shù);
    • 7)onopen:用于指定連接成功后的回調(diào)函數(shù);
    • 8)protocol(只讀):用于返回服務(wù)器端選中的子協(xié)議的名字;
    • 9)readyState(只讀):返回當(dāng)前 WebSocket 的連接狀態(tài),共有 4 種狀態(tài):
    •     - CONNECTING — 正在連接中,對應(yīng)的值為 0;
    •     - OPEN — 已經(jīng)連接并且可以通訊,對應(yīng)的值為 1;
    •     - CLOSING — 連接正在關(guān)閉,對應(yīng)的值為 2;
    •     - CLOSED — 連接已關(guān)閉或者沒有連接成功,對應(yīng)的值為 3
    • 10)url(只讀):返回值為當(dāng)構(gòu)造函數(shù)創(chuàng)建 WebSocket 實例對象時 URL 的絕對路徑。

    4.4 方法

    WebSocket 主要方法有兩個:

    • 1)close([code[, reason]]):該方法用于關(guān)閉 WebSocket 連接,如果連接已經(jīng)關(guān)閉,則此方法不執(zhí)行任何操作;
    • 2)send(data):該方法將需要通過 WebSocket 鏈接傳輸至服務(wù)器的數(shù)據(jù)排入隊列,并根據(jù)所需要傳輸?shù)臄?shù)據(jù)的大小來增加 bufferedAmount 的值 。若數(shù)據(jù)無法傳輸(比如數(shù)據(jù)需要緩存而緩沖區(qū)已滿)時,套接字會自行關(guān)閉。

    4.5 事件

    使用 addEventListener() 或?qū)⒁粋€事件監(jiān)聽器賦值給 WebSocket 對象的 oneventname 屬性,來監(jiān)聽下面的事件。

    以下是幾個事件:

    • 1)close:當(dāng)一個 WebSocket 連接被關(guān)閉時觸發(fā),也可以通過 onclose 屬性來設(shè)置;
    • 2)error:當(dāng)一個 WebSocket 連接因錯誤而關(guān)閉時觸發(fā),也可以通過 onerror 屬性來設(shè)置;
    • 3)message:當(dāng)通過 WebSocket 收到數(shù)據(jù)時觸發(fā),也可以通過 onmessage 屬性來設(shè)置;
    • 4)open:當(dāng)一個 WebSocket 連接成功時觸發(fā),也可以通過 onopen 屬性來設(shè)置。

    介紹完 WebSocket API,我們來舉一個使用 WebSocket 發(fā)送普通文本的示例。

    4.6 代碼實踐:發(fā)送普通文本

     

    在以上示例中:我們在頁面上創(chuàng)建了兩個 textarea,分別用于存放 待發(fā)送的數(shù)據(jù) 和 服務(wù)器返回的數(shù)據(jù)。當(dāng)用戶輸入完待發(fā)送的文本之后,點擊 發(fā)送 按鈕時會把輸入的文本發(fā)送到服務(wù)端,而服務(wù)端成功接收到消息之后,會把收到的消息原封不動地回傳到客戶端。

    // 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);

    }

    當(dāng)然客戶端接收到服務(wù)端返回的消息之后,會把對應(yīng)的文本內(nèi)容保存到 接收的數(shù)據(jù) 對應(yīng)的 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ā)者工具來看一下相應(yīng)的過程。

    如下圖所示:

    以上示例對應(yīng)的完整代碼如下所示:

    <!DOCTYPE html>

    <html>

      <head>

        <metacharset="UTF-8"/>

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

        <title>WebSocket 發(fā)送普通文本示例</title>

        <style>

          .block {

            flex: 1;

          }

        </style>

      </head>

      <body>

        <h3>WebSocket 發(fā)送普通文本示例</h3>

        <divstyle="display: flex;">

          <divclass="block">

            <p>即將發(fā)送的數(shù)據(jù):<button>發(fā)送</button></p>

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

          </div>

          <divclass="block">

            <p>接收的數(shù)據(jù):</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");

          // 監(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ā)送二進(jìn)制數(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ā)送二進(jìn)制ArrayBuffer

      const buffer = newArrayBuffer(128);

      socket.send(buffer);

      // 發(fā)送二進(jìn)制ArrayBufferView

      const intview = new Uint32Array(buffer);

      socket.send(intview);

      // 發(fā)送二進(jìn)制Blob

      const blob = new Blob([buffer]);

      socket.send(blob);

    };

    以上代碼成功運行后,通過 Chrome 開發(fā)者工具,我們可以看到對應(yīng)的數(shù)據(jù)交互過程。

    如下圖所示:

     下面以發(fā)送 Blob 對象為例,來介紹一下如何發(fā)送二進(jìn)制數(shù)據(jù)。

    Blob(Binary Large Object)表示二進(jìn)制類型的大對象。在數(shù)據(jù)庫管理系統(tǒng)中,將二進(jìn)制數(shù)據(jù)存儲為一個單一個體的集合。Blob 通常是影像、聲音或多媒體文件。在 JavaScript 中 Blob 類型的對象表示不可變的類似文件對象的原始數(shù)據(jù)。

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

    4.7 代碼實踐:發(fā)送二進(jìn)制數(shù)據(jù)

     在以上示例中,我們在頁面上創(chuàng)建了兩個 textarea,分別用于存放 待發(fā)送的數(shù)據(jù) 和 服務(wù)器返回的數(shù)據(jù)。

    當(dāng)用戶輸入完待發(fā)送的文本之后,點擊 發(fā)送 按鈕時,我們會先獲取輸入的文本并把文本包裝成 Blob 對象然后發(fā)送到服務(wù)端,而服務(wù)端成功接收到消息之后,會把收到的消息原封不動地回傳到客戶端。

    當(dāng)瀏覽器接收到新消息后,如果是文本數(shù)據(jù),會自動將其轉(zhuǎn)換成 DOMString 對象,如果是二進(jìn)制數(shù)據(jù)或 Blob 對象,會直接將其轉(zhuǎn)交給應(yīng)用,由應(yīng)用自身來根據(jù)返回的數(shù)據(jù)類型進(jìn)行相應(yīng)的處理。

    數(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 = newBlob([message], { type: "text/plain"});

      if(message) socket.send(blob);

      console.log(`未發(fā)送至服務(wù)器的字節(jié)數(shù):${socket.bufferedAmount}`);

    }

    當(dāng)客戶端接收到服務(wù)端返回的消息之后,會判斷返回的數(shù)據(jù)類型,如果是 Blob 類型的話,會調(diào)用 Blob 對象的 text() 方法,獲取 Blob 對象中保存的 UTF-8 格式的內(nèi)容,然后把對應(yīng)的文本內(nèi)容保存到 接收的數(shù)據(jù) 對應(yīng)的 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 instanceofBlob) {

        receivedMsgContainer.value = await receivedData.text();

      } else{

        receivedMsgContainer.value = receivedData;

      }

     });

    同樣,我們使用 Chrome 瀏覽器的開發(fā)者工具來看一下相應(yīng)的過程:

    通過上圖我們可以很明顯地看到,當(dāng)使用發(fā)送 Blob 對象時,Data 欄位的信息顯示的是 Binary Message,而對于發(fā)送普通文本來說,Data 欄位的信息是直接顯示發(fā)送的文本消息。

    以上示例對應(yīng)的完整代碼如下所示:

    <!DOCTYPE html>

    <html>

      <head>

        <meta charset="UTF-8"/>

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

        <title>WebSocket 發(fā)送二進(jìn)制數(shù)據(jù)示例</title>

        <style>

          .block {

            flex: 1;

          }

        </style>

      </head>

      <body>

        <h3>WebSocket 發(fā)送二進(jìn)制數(shù)據(jù)示例</h3>

        <div style="display: flex;">

          <div class="block">

            <p>待發(fā)送的數(shù)據(jù):<button>發(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 instanceofBlob) {

              receivedMsgContainer.value = await receivedData.text();

            } else{

              receivedMsgContainer.value = receivedData;

            }

          });

     

          functionsend() {

            const message = sendMsgContainer.value;

            if(socket.readyState !== WebSocket.OPEN) {

              console.log("連接未建立,還不能發(fā)送消息");

              return;

            }

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

            if(message) socket.send(blob);

            console.log(`未發(fā)送至服務(wù)器的字節(jié)數(shù):${socket.bufferedAmount}`);

          }

        </script>

      </body>

    </html>

    可能有一些小伙伴了解完 WebSocket API 之后,覺得還不夠過癮。下面將帶大家來實現(xiàn)一個支持發(fā)送普通文本的 WebSocket 服務(wù)器。

    5、手寫 WebSocket 服務(wù)器

    5.1 寫在前面

    在介紹如何手寫 WebSocket 服務(wù)器前,我們需要了解一下 WebSocket 連接的生命周期。

    從上圖可知:在使用 WebSocket 實現(xiàn)全雙工通信之前,客戶端與服務(wù)器之間需要先進(jìn)行握手(Handshake),在完成握手之后才能開始進(jìn)行數(shù)據(jù)的雙向通信。

    握手是在通信電路創(chuàng)建之后,信息傳輸開始之前。

    握手用于達(dá)成參數(shù),如:

    • 1)信息傳輸率
    • 2)字母表
    • 3)奇偶校驗
    • 4)中斷過程;
    • 5)其他協(xié)議特性。

    握手有助于不同結(jié)構(gòu)的系統(tǒng)或設(shè)備在通信信道中連接,而不需要人為設(shè)置參數(shù)。

    既然握手是 WebSocket 連接生命周期的第一個環(huán)節(jié),接下來我們就先來分析 WebSocket 的握手協(xié)議。

    5.2 握手協(xié)議

    WebSocket 協(xié)議屬于應(yīng)用層協(xié)議,它依賴于傳輸層的 TCP 協(xié)議。WebSocket 通過 HTTP/1.1 協(xié)議的 101 狀態(tài)碼進(jìn)行握手。為了創(chuàng)建 WebSocket 連接,需要通過瀏覽器發(fā)出請求,之后服務(wù)器進(jìn)行回應(yīng),這個過程通常稱為 “握手”(Handshaking)。

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

    • 1)首先:讓 WebSocket 與現(xiàn)有 HTTP 基礎(chǔ)設(shè)施兼容——使得 WebSocket 服務(wù)器可以運行在 80 和 443 端口上,這通常是對客戶端唯一開放的端口;
    • 2)其次:讓我們可以重用并擴(kuò)展 HTTP 的 Upgrade 流,為其添加自定義的 WebSocket 首部,以完成協(xié)商。

    下面我們以前面已經(jīng)演示過的發(fā)送普通文本的例子為例,來具體分析一下握手過程。

    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:必須設(shè)置 Upgrade,表示客戶端希望連接升級;
    • 2)Upgrade:字段必須設(shè)置 websocket,表示希望升級到 WebSocket 協(xié)議;
    • 3)Sec-WebSocket-Version:表示支持的 WebSocket 版本。RFC6455 要求使用的版本是 13,之前草案的版本均應(yīng)當(dāng)棄用;
    • 4)Sec-WebSocket-Key:是隨機(jī)的字符串,服務(wù)器端會用這些數(shù)據(jù)來構(gòu)造出一個 SHA-1 的信息摘要;
    • 5)Sec-WebSocket-Extensions:用于協(xié)商本次連接要使用的 WebSocket 擴(kuò)展:客戶端發(fā)送支持的擴(kuò)展,服務(wù)器通過返回相同的首部確認(rèn)自己支持一個或多個擴(kuò)展;
    • 6)Origin:字段是可選的,通常用來表示在瀏覽器中發(fā)起此 WebSocket 連接所在的頁面,類似于 Referer。但是,與 Referer 不同的是,Origin 只包含了協(xié)議和主機(jī)名稱。

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

    5.2.2)服務(wù)端響應(yīng):

    HTTP/1.1 101 Web Socket Protocol Handshake ①

    Connection: Upgrade ②

    Upgrade: websocket ③

    Sec-WebSocket-Accept: 52Rg3vW4JQ1yWpkvFlsTsiezlqw= ④

    備注:已忽略部分 HTTP 響應(yīng)頭。

    針對上述響應(yīng)中的字段說明如下:

    •  101 響應(yīng)碼確認(rèn)升級到 WebSocket 協(xié)議;
    •  設(shè)置 Connection 頭的值為 “Upgrade” 來指示這是一個升級請求(HTTP 協(xié)議提供了一種特殊的機(jī)制,這一機(jī)制允許將一個已建立的連接升級成新的、不相容的協(xié)議);
    •  Upgrade 頭指定一項或多項協(xié)議名,按優(yōu)先級排序,以逗號分隔。這里表示升級為 WebSocket 協(xié)議;
    •  簽名的鍵值驗證協(xié)議支持。

    介紹完 WebSocket 的握手協(xié)議,接下來將使用 Node.js 來開發(fā)我們的 WebSocket 服務(wù)器。

    5.3 實現(xiàn)握手功能

    要開發(fā)一個 WebSocket 服務(wù)器,首先我們需要先實現(xiàn)握手功能。這里我使用 Node.js 內(nèi)置的 http 模塊來創(chuàng)建一個 HTTP 服務(wù)器。

    具體代碼如下所示:

    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);

      // 設(shè)置HTTP響應(yīng)頭

      const responseHeaders = [

        "HTTP/1.1 101 Web Socket Protocol Handshake",

        "Upgrade: WebSocket",

        "Connection: Upgrade",

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

      ];

      // 返回握手請求的響應(yīng)信息

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

    });

     

    server.listen(port, () =>

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

    );

    在以上代碼中:我們首先引入了 http 模塊,然后通過調(diào)用該模塊的 createServer() 方法創(chuàng)建一個 HTTP 服務(wù)器,接著我們監(jiān)聽 upgrade 事件,每次服務(wù)器響應(yīng)升級請求時就會觸發(fā)該事件。由于我們的服務(wù)器只支持升級到 WebSocket 協(xié)議,所以如果客戶端請求升級的協(xié)議非 WebSocket 協(xié)議,我們將會返回 “400 Bad Request”。

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

    上述的過程看起來好像有點繁瑣,其實利用 Node.js 內(nèi)置的 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ā)完握手功能之后,我們可以使用前面的示例來測試一下該功能。待服務(wù)器啟動之后,我們只要對 “發(fā)送普通文本” 示例,做簡單地調(diào)整,即把先前的 URL 地址替換成 ws://localhost:8888,就可以進(jìn)行功能驗證。

    感興趣的小伙們可以試試看,以下是我的本地運行后的結(jié)果:

    從上圖可知:我們實現(xiàn)的握手功能已經(jīng)可以正常工作了。那么握手有沒有可能失敗呢?答案是肯定的。比如網(wǎng)絡(luò)問題、服務(wù)器異常或 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 服務(wù)器要支持子協(xié)議的話,你可以參考以下代碼進(jìn)行子協(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é)議相關(guān)的內(nèi)容基本已經(jīng)介紹完了。下一步我們來介紹開發(fā)消息通信功能需要了解的一些基礎(chǔ)知識。

    5.4 消息通信基礎(chǔ)

    在 WebSocket 協(xié)議中,數(shù)據(jù)是通過一系列數(shù)據(jù)幀來進(jìn)行傳輸?shù)摹?/p>

    為了避免由于網(wǎng)絡(luò)中介(例如一些攔截代理)或者一些安全問題,客戶端必須在它發(fā)送到服務(wù)器的所有幀中添加掩碼。服務(wù)端收到?jīng)]有添加掩碼的數(shù)據(jù)幀以后,必須立即關(guān)閉連接。

    5.4.1)數(shù)據(jù)幀格式:

    要實現(xiàn)消息通信,我們就必須了解 WebSocket 數(shù)據(jù)幀的格式:

    可能有一些小伙伴看到上面的內(nèi)容之后,就開始有點 “懵逼” 了。

    下面我們來結(jié)合實際的數(shù)據(jù)幀來進(jìn)一步分析一下:

    在上圖中:簡單分析了 “發(fā)送普通文本” 示例對應(yīng)的數(shù)據(jù)幀格式。這里我們來進(jìn)一步介紹一下 Payload length,因為在后面開發(fā)數(shù)據(jù)解析功能的時候,需要用到該知識點。

    Payload length 表示以字節(jié)為單位的 “有效負(fù)載數(shù)據(jù)” 長度。

    它有以下幾種情形:

    • 1)如果值為 0-125,那么就表示負(fù)載數(shù)據(jù)的長度;
    • 2)如果是 126,那么接下來的 2 個字節(jié)解釋為 16 位的無符號整形作為負(fù)載數(shù)據(jù)的長度;
    • 3)如果是 127,那么接下來的 8 個字節(jié)解釋為一個 64 位的無符號整形(最高位的 bit 必須為 0)作為負(fù)載數(shù)據(jù)的長度。

    備注:多字節(jié)長度量以網(wǎng)絡(luò)字節(jié)順序表示,有效負(fù)載長度是指 “擴(kuò)展數(shù)據(jù)” + “應(yīng)用數(shù)據(jù)” 的長度。“擴(kuò)展數(shù)據(jù)” 的長度可能為 0,那么有效負(fù)載長度就是 “應(yīng)用數(shù)據(jù)” 的長度。

    另外:除非協(xié)商過擴(kuò)展,否則 “擴(kuò)展數(shù)據(jù)” 長度為 0 字節(jié)。在握手協(xié)議中,任何擴(kuò)展都必須指定 “擴(kuò)展數(shù)據(jù)” 的長度,這個長度如何進(jìn)行計算,以及這個擴(kuò)展如何使用。如果存在擴(kuò)展,那么這個 “擴(kuò)展數(shù)據(jù)” 包含在總的有效負(fù)載長度中。

    PS:關(guān)于數(shù)據(jù)幀格式的詳細(xì)講解,可以深入讀讀以下幾篇:

    1. WebSocket從入門到精通,半小時就夠!
    2. 理論聯(lián)系實際:從零理解WebSocket的通信原理、協(xié)議格式、安全性

    5.4.2)掩碼算法:

    掩碼字段是一個由客戶端隨機(jī)選擇的 32 位的值。掩碼值必須是不可被預(yù)測的。因此,掩碼必須來自強(qiáng)大的熵源(entropy),并且給定的掩碼不能讓服務(wù)器或者代理能夠很容易的預(yù)測到后續(xù)幀。掩碼的不可預(yù)測性對于預(yù)防惡意應(yīng)用的作者在網(wǎng)上暴露相關(guān)的字節(jié)數(shù)據(jù)至關(guān)重要。

    掩碼不影響數(shù)據(jù)荷載的長度,對數(shù)據(jù)進(jìn)行掩碼操作和對數(shù)據(jù)進(jìn)行反掩碼操作所涉及的步驟是相同的。

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

    j = i MOD 4

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

    解釋一下:

    • 1)original-octet-i:為原始數(shù)據(jù)的第 i 字節(jié);
    • 2)transformed-octet-i:為轉(zhuǎn)換后的數(shù)據(jù)的第 i 字節(jié);
    • 3)masking-key-octet-j:為 mask key 第 j 字節(jié)。

    為了讓小伙伴們能夠更好的理解上面掩碼的計算過程,我們來對示例中 “我是阿寶哥” 數(shù)據(jù)進(jìn)行掩碼操作。

    這里 “我是阿寶哥” 對應(yīng)的 UTF-8 編碼如下所示:

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

    而對應(yīng)的 Masking-Key 為 0x08f6efb1。

    根據(jù)上面的算法,我們可以這樣進(jìn)行掩碼運算:

    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(' '));

    以上代碼成功運行后,控制臺會輸出以下結(jié)果:

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

    上述結(jié)果與 WireShark 中的 Masked payload 對應(yīng)的值是一致的,具體如下圖所示:

    在 WebSocket 協(xié)議中,數(shù)據(jù)掩碼的作用是增強(qiáng)協(xié)議的安全性。但數(shù)據(jù)掩碼并不是為了保護(hù)數(shù)據(jù)本身,因為算法本身是公開的,運算也不復(fù)雜。

    那么為什么還要引入數(shù)據(jù)掩碼呢?引入數(shù)據(jù)掩碼是為了防止早期版本的協(xié)議中存在的代理緩存污染攻擊等問題。

    了解完 WebSocket 掩碼算法和數(shù)據(jù)掩碼的作用之后,我們再來介紹一下數(shù)據(jù)分片的概念。

    5.4.3)數(shù)據(jù)分片:

    WebSocket 的每條消息可能被切分成多個數(shù)據(jù)幀。當(dāng) WebSocket 的接收方收到一個數(shù)據(jù)幀時,會根據(jù) FIN 的值來判斷,是否已經(jīng)收到消息的最后一個數(shù)據(jù)幀。

    利用 FIN 和 Opcode,我們就可以跨幀發(fā)送消息。

    操作碼告訴了幀應(yīng)該做什么:

    • 1)如果是 0x1,有效載荷就是文本;
    • 2)如果是 0x2,有效載荷就是二進(jìn)制數(shù)據(jù);
    • 3)如果是 0x0,則該幀是一個延續(xù)幀(這意味著服務(wù)器應(yīng)該將幀的有效負(fù)載連接到從該客戶機(jī)接收到的最后一個幀)。

    為了讓大家能夠更好地理解上述的內(nèi)容,我們來看一個來自 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!

    在以上示例中:客戶端向服務(wù)器發(fā)送了兩條消息,第一個消息在單個幀中發(fā)送,而第二個消息跨三個幀發(fā)送。

    其中:第一個消息是一個完整的消息(FIN=1 且 opcode != 0x0),因此服務(wù)器可以根據(jù)需要進(jìn)行處理或響應(yīng)。而第二個消息是文本消息(opcode=0x1)且 FIN=0,表示消息還沒發(fā)送完成,還有后續(xù)的數(shù)據(jù)幀。該消息的所有剩余部分都用延續(xù)幀(opcode=0x0)發(fā)送,消息的最終幀用 FIN=1 標(biāo)記。

    好的,簡單介紹了數(shù)據(jù)分片的相關(guān)內(nèi)容。接下來,我們來開始實現(xiàn)消息通信功能。

    5.5 實現(xiàn)消息通信功能

    筆者把實現(xiàn)消息通信功能,分解為消息解析與消息響應(yīng)兩個子功能,下面我們分別來介紹如何實現(xiàn)這兩個子功能。

    5.5.1)消息解析:

    利用消息通信基礎(chǔ)環(huán)節(jié)中介紹的相關(guān)知識,我實現(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ù)幀。當(dāng) Opcode 為 0 時,表示本次數(shù)據(jù)傳輸采用了數(shù)據(jù)分片,當(dāng)前收到的數(shù)據(jù)幀為其中一個數(shù)據(jù)分片;

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

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

       * %x3-7:保留的操作代碼,用于后續(xù)定義的非控制幀;

       * %x8:表示連接斷開;

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

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

       * %xB-F:保留的操作代碼,用于后續(xù)定義的控制幀。

       */

      const opcode = firstByte & 0x0f;

      if(opcode === 0x08) {

        // 連接關(guān)閉

        return;

      }

      if(opcode === 0x02) {

        // 二進(jìn)制幀

        return;

      }

      if(opcode === 0x01) {

        // 目前只處理文本幀

        let offset = 1;

        const secondByte = buffer.readUInt8(offset);

        // MASK: 1位,表示是否使用了掩碼,在發(fā)送給服務(wù)端的數(shù)據(jù)幀里必須使用掩碼,而服務(wù)端返回時不需要掩碼

        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位)就應(yīng)該被直接識別成掩碼;

        if(payloadLen <= 0x7d) {

          // 載荷長度小于125

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

          offset += 4;

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

        } elseif(payloadLen === 0x7e) {

          // 如果這個值是126,則后面兩個字節(jié)(16位)內(nèi)容應(yīng)該,被識別成一個16位的二進(jìn)制數(shù)表示數(shù)據(jù)內(nèi)容大小;

          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位)內(nèi)容應(yīng)該被識別成一個64位的二進(jìn)制數(shù)表示數(shù)據(jù)內(nèi)容大小

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

          offset += 12;

        }

        // 開始讀取后面的payload,與掩碼計算,得到原來的字節(jié)內(nèi)容

        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 服務(wù)器:

    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;

      }

      // 省略已有代碼

    });

    更新完成之后,我們重新啟動服務(wù)器,然后繼續(xù)使用 “發(fā)送普通文本” 的示例來測試消息解析功能。

    以下發(fā)送 “我是阿寶哥” 文本消息后,WebSocket 服務(wù)器輸出的信息:

    Server running at http://localhost:8888

    isFIN:  true

    use MASK:  true

    payload length:  15

    Message from client:我是阿寶哥

    通過觀察以上的輸出信息,我們的 WebSocket 服務(wù)器已經(jīng)可以成功解析客戶端發(fā)送包含普通文本的數(shù)據(jù)幀,下一步我們來實現(xiàn)消息響應(yīng)的功能。

    5.5.2)消息響應(yīng):

    要把數(shù)據(jù)返回給客戶端,我們的 WebSocket 服務(wù)器也得按照 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é)的負(fù)載

      const lengthByteCount = jsonByteLength < 126 ? 0 : 2;

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

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

      // 設(shè)置數(shù)據(jù)幀首字節(jié),設(shè)置opcode為1,表示文本幀

      buffer.writeUInt8(0b10000001, 0);

      buffer.writeUInt8(payloadLength, 1);

      // 如果payloadLength為126,則后面兩個字節(jié)(16位)內(nèi)容應(yīng)該,被識別成一個16位的二進(jìn)制數(shù)表示數(shù)據(jù)內(nèi)容大小

      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 服務(wù)器:

    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 }));

        } elseif(message === null) {

          console.log("WebSocket connection closed by the client.");

        }

      });

    });

    到這里,我們的 WebSocket 服務(wù)器已經(jīng)開發(fā)完成了,接下來我們來完整驗證一下它的功能。

    從上圖中可知:以上開發(fā)的簡易版 WebSocket 服務(wù)器已經(jīng)可以正常處理普通文本消息了。

    最后我們來看一下完整的代碼。

    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);

      // 設(shè)置HTTP響應(yīng)頭

      const responseHeaders = [

        "HTTP/1.1 101 Web Socket Protocol Handshake",

        "Upgrade: WebSocket",

        "Connection: Upgrade",

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

      ];

      // 返回握手請求的響應(yīng)信息

      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) {

      // 第一個字節(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ù)幀。當(dāng) Opcode 為 0 時,表示本次數(shù)據(jù)傳輸采用了數(shù)據(jù)分片,當(dāng)前收到的數(shù)據(jù)幀為其中一個數(shù)據(jù)分片;

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

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

       * %x3-7:保留的操作代碼,用于后續(xù)定義的非控制幀;

       * %x8:表示連接斷開;

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

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

       * %xB-F:保留的操作代碼,用于后續(xù)定義的控制幀。

       */

      const opcode = firstByte & 0x0f;

      if(opcode === 0x08) {

        // 連接關(guān)閉

        return;

      }

      if(opcode === 0x02) {

        // 二進(jìn)制幀

        return;

      }

      if(opcode === 0x01) {

        // 目前只處理文本幀

        let offset = 1;

        const secondByte = buffer.readUInt8(offset);

        // MASK: 1位,表示是否使用了掩碼,在發(fā)送給服務(wù)端的數(shù)據(jù)幀里必須使用掩碼,而服務(wù)端返回時不需要掩碼

        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位)就應(yīng)該被直接識別成掩碼;

        if(payloadLen <= 0x7d) {

          // 載荷長度小于125

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

          offset += 4;

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

        } else if(payloadLen === 0x7e) {

          // 如果這個值是126,則后面兩個字節(jié)(16位)內(nèi)容應(yīng)該,被識別成一個16位的二進(jìn)制數(shù)表示數(shù)據(jù)內(nèi)容大小;

          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位)內(nèi)容應(yīng)該被識別成一個64位的二進(jìn)制數(shù)表示數(shù)據(jù)內(nèi)容大小

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

          offset += 12;

        }

        // 開始讀取后面的payload,與掩碼計算,得到原來的字節(jié)內(nèi)容

        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字節(jié)的負(fù)載

      const lengthByteCount = jsonByteLength < 126 ? 0 : 2;

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

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

      // 設(shè)置數(shù)據(jù)幀首字節(jié),設(shè)置opcode為1,表示文本幀

      buffer.writeUInt8(0b10000001, 0);

      buffer.writeUInt8(payloadLength, 1);

      // 如果payloadLength為126,則后面兩個字節(jié)(16位)內(nèi)容應(yīng)該,被識別成一個16位的二進(jìn)制數(shù)表示數(shù)據(jù)內(nèi)容大小

      let payloadOffset = 2;

      if(lengthByteCount > 0) {

        buffer.writeUInt16BE(jsonByteLength, 2);

        payloadOffset += lengthByteCount;

      }

      // 把JSON數(shù)據(jù)寫入到Buffer緩沖區(qū)中

      buffer.write(json, payloadOffset);

      return buffer;

    }

     

    module.exports = {

      generateAcceptValue,

      parseMessage,

      constructReply,

    };

    其實服務(wù)器向瀏覽器推送信息,除了使用 WebSocket 技術(shù)之外,還可以使用 SSEServer-Sent Events)。它讓服務(wù)器可以向客戶端流式發(fā)送文本消息,比如服務(wù)器上生成的實時消息。

    為實現(xiàn)這個目標(biāo),SSE 設(shè)計了兩個組件:瀏覽器中的 EventSource API 和新的 “事件流” 數(shù)據(jù)格式(text/event-stream)。其中,EventSource 可以讓客戶端以 DOM 事件的形式接收到服務(wù)器推送的通知,而新數(shù)據(jù)格式則用于交付每一次數(shù)據(jù)更新。

    實際上:SSE 提供的是一個高效、跨瀏覽器的 XHR 流實現(xiàn),消息交付只使用一個長 HTTP 連接。然而,與我們自己實現(xiàn) XHR 流不同,瀏覽器會幫我們管理連接、 解析消息,從而讓我們只關(guān)注業(yè)務(wù)邏輯。篇幅有限,關(guān)于 SSE 的更多細(xì)節(jié),就不展開介紹了,對 SSE 感興趣的小伙伴可以自行閱讀以下幾篇:

    1. Web端即時通訊技術(shù)盤點:短輪詢、Comet、Websocket、SSE
    2. SSE技術(shù)詳解:一種全新的HTML5服務(wù)器推送事件技術(shù)
    3. 使用WebSocket和SSE技術(shù)實現(xiàn)Web端消息推送
    4. 詳解Web端通信方式的演進(jìn):從Ajax、JSONP 到 SSE、Websocket
    5. 網(wǎng)頁端IM通信技術(shù)快速入門:短輪詢、長輪詢、SSE、WebSocket
    6. 搞懂現(xiàn)代Web端即時通訊技術(shù)一文就夠:WebSocket、socket.io、SSE

    6、WebSocket學(xué)習(xí)過程中的易錯常識

    6.1 WebSocket 與 HTTP 有什么關(guān)系?

    WebSocket 是一種與 HTTP 不同的協(xié)議。兩者都位于 OSI 模型的應(yīng)用層,并且都依賴于傳輸層的 TCP 協(xié)議。

    雖然它們不同,但是 RFC 6455 中規(guī)定:WebSocket 被設(shè)計為在 HTTP 80 和 443 端口上工作,并支持 HTTP 代理和中介,從而使其與 HTTP 協(xié)議兼容。 為了實現(xiàn)兼容性,WebSocket 握手使用 HTTP Upgrade 頭,從 HTTP 協(xié)議更改為 WebSocket 協(xié)議。

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

    當(dāng)然,WebSocket與HTTP的關(guān)系顯然不是這三兩句話可以說的清,有興趣的讀者可以詳讀下面這兩篇:

    1. WebSocket詳解(四):刨根問底HTTP與WebSocket的關(guān)系(上篇)
    2. WebSocket詳解(五):刨根問底HTTP與WebSocket的關(guān)系(下篇)

    6.2 WebSocket 與長輪詢有什么區(qū)別?

    長輪詢就是:客戶端發(fā)起一個請求,服務(wù)器收到客戶端發(fā)來的請求后,服務(wù)器端不會直接進(jìn)行響應(yīng),而是先將這個請求掛起,然后判斷請求的數(shù)據(jù)是否有更新。如果有更新,則進(jìn)行響應(yīng),如果一直沒有數(shù)據(jù),則等待一定的時間后才返回。

    長輪詢的本質(zhì)還是基于 HTTP 協(xié)議,它仍然是一個一問一答(請求 — 響應(yīng))的模式。而 WebSocket 在握手成功后,就是全雙工的 TCP 通道,數(shù)據(jù)可以主動從服務(wù)端發(fā)送到客戶端。

    要理解WebSocket 與長輪詢的區(qū)別,需要深刻理解長輪詢的技術(shù)原理,以下3篇中有關(guān)長輪詢的技術(shù)介紹建議深入閱讀:

    1. Comet技術(shù)詳解:基于HTTP長連接的Web端實時通信技術(shù)
    2. 新手入門貼:史上最全Web端即時通訊技術(shù)原理詳解
    3. Web端即時通訊技術(shù)盤點:短輪詢、Comet、Websocket、SSE
    4. 網(wǎng)頁端IM通信技術(shù)快速入門:短輪詢、長輪詢、SSE、WebSocket

    6.3 什么是 WebSocket 心跳?

    網(wǎng)絡(luò)中的接收和發(fā)送數(shù)據(jù)都是使用 Socket 進(jìn)行實現(xiàn)。但是如果此套接字已經(jīng)斷開,那發(fā)送數(shù)據(jù)和接收數(shù)據(jù)的時候就一定會有問題。

    可是如何判斷這個套接字是否還可以使用呢?這個就需要在系統(tǒng)中創(chuàng)建心跳機(jī)制。

    所謂 “心跳” 就是定時發(fā)送一個自定義的結(jié)構(gòu)體(心跳包或心跳幀),讓對方知道自己 “在線”,以確保鏈接的有效性。

    而所謂的心跳包就是客戶端定時發(fā)送簡單的信息給服務(wù)器端告訴它我還在而已。代碼就是每隔幾分鐘發(fā)送一個固定信息給服務(wù)端,服務(wù)端收到后回復(fù)一個固定信息,如果服務(wù)端幾分鐘內(nèi)沒有收到客戶端信息則視客戶端斷開。

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

    • 1)心跳 Ping 幀包含的操作碼是 0x9:如果收到了一個心跳 Ping 幀,那么終端必須發(fā)送一個心跳 Pong 幀作為回應(yīng),除非已經(jīng)收到了一個關(guān)閉幀。否則終端應(yīng)該盡快回復(fù) Pong 幀;
    • 2)心跳 Pong 幀包含的操作碼是 0xA:作為回應(yīng)發(fā)送的 Pong 幀必須完整攜帶 Ping 幀中傳遞過來的 “應(yīng)用數(shù)據(jù)” 字段。

    針對第2)點:如果終端收到一個 Ping 幀但是沒有發(fā)送 Pong 幀來回應(yīng)之前的 Ping 幀,那么終端可以選擇僅為最近處理的 Ping 幀發(fā)送 Pong 幀。此外,可以自動發(fā)送一個 Pong 幀,這用作單向心跳。

    PS:這里有篇WebSocket心跳方面的IM實戰(zhàn)總結(jié)文章,有興趣可以閱讀《Web端即時通訊實踐干貨:如何讓你的WebSocket斷網(wǎng)重連更快速?》。

    6.4 Socket 是什么?

    網(wǎng)絡(luò)上的兩個程序通過一個雙向的通信連接實現(xiàn)數(shù)據(jù)的交換,這個連接的一端稱為一個 Socket(套接字),因此建立網(wǎng)絡(luò)通信連接至少要一對端口號。

    Socket 本質(zhì):是對 TCP/IP 協(xié)議棧的封裝,它提供了一個針對 TCP 或者 UDP 編程的接口,并不是另一種協(xié)議。通過 Socket,你可以使用 TCP/IP 協(xié)議。

    百度百科上關(guān)于Socket的描述是這樣:

    Socket 的英文原義是“孔”或“插座”:作為 BSD UNIX 的進(jìn)程通信機(jī)制,取后一種意思。通常也稱作”套接字“,用于描述IP地址和端口,是一個通信鏈的句柄,可以用來實現(xiàn)不同虛擬機(jī)或不同計算機(jī)之間的通信。

    在Internet 上的主機(jī)一般運行了多個服務(wù)軟件,同時提供幾種服務(wù)。每種服務(wù)都打開一個Socket,并綁定到一個端口上,不同的端口對應(yīng)于不同的服務(wù)。Socket 正如其英文原義那樣,像一個多孔插座。一臺主機(jī)猶如布滿各種插座的房間,每個插座有一個編號,有的插座提供 220 伏交流電, 有的提供 110 伏交流電,有的則提供有線電視節(jié)目。 客戶軟件將插頭插到不同編號的插座,就可以得到不同的服務(wù)。

    關(guān)于 Socket,可以總結(jié)以下幾點:

    • 1)它可以實現(xiàn)底層通信,幾乎所有的應(yīng)用層都是通過 socket 進(jìn)行通信的;
    • 2)對 TCP/IP 協(xié)議進(jìn)行封裝,便于應(yīng)用層協(xié)議調(diào)用,屬于二者之間的中間抽象層;
    • 3)TCP/IP 協(xié)議族中,傳輸層存在兩種通用協(xié)議: TCP、UDP,兩種協(xié)議不同,因為不同參數(shù)的 socket 實現(xiàn)過程也不一樣。

    下圖說明了面向連接的協(xié)議的套接字 API 的客戶端/服務(wù)器關(guān)系:

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

    7、參考資料

    [1] 新手快速入門:WebSocket簡明教程

    [2] WebSocket從入門到精通,半小時就夠!

    [3] 新手入門貼:史上最全Web端即時通訊技術(shù)原理詳解

    [4] Web端即時通訊技術(shù)盤點:短輪詢、Comet、Websocket、SSE

    [5] SSE技術(shù)詳解:一種全新的HTML5服務(wù)器推送事件技術(shù)

    [6] Comet技術(shù)詳解:基于HTTP長連接的Web端實時通信技術(shù)

    [7] WebSocket詳解(四):刨根問底HTTP與WebSocket的關(guān)系(上篇)

    [8] WebSocket詳解(五):刨根問底HTTP與WebSocket的關(guān)系(下篇)

    [9] WebSocket詳解(六):刨根問底WebSocket與Socket的關(guān)系

    [10] Web端即時通訊實踐干貨:如何讓你的WebSocket斷網(wǎng)重連更快速?

    [11] 理論聯(lián)系實際:從零理解WebSocket的通信原理、協(xié)議格式、安全性

    [12] WebSocket硬核入門:200行代碼,教你徒手?jǐn)]一個WebSocket服務(wù)器

    [13] 網(wǎng)頁端IM通信技術(shù)快速入門:短輪詢、長輪詢、SSE、WebSocket

    [14] 搞懂現(xiàn)代Web端即時通訊技術(shù)一文就夠:WebSocket、socket.io、SSE

    本文已同步發(fā)布于“即時通訊技術(shù)圈”公眾號。

    同步發(fā)布鏈接是:http://www.52im.net/thread-3713-1-1.html



    作者:Jack Jiang (點擊作者姓名進(jìn)入Github)
    出處:http://www.52im.net/space-uid-1.html
    交流:歡迎加入即時通訊開發(fā)交流群 215891622
    討論:http://www.52im.net/
    Jack Jiang同時是【原創(chuàng)Java Swing外觀工程BeautyEye】【輕量級移動端即時通訊框架MobileIMSDK】的作者,可前往下載交流。
    本博文 歡迎轉(zhuǎn)載,轉(zhuǎn)載請注明出處(也可前往 我的52im.net 找到我)。


    只有注冊用戶登錄后才能發(fā)表評論。


    網(wǎng)站導(dǎo)航:
     
    Jack Jiang的 Mail: jb2011@163.com, 聯(lián)系QQ: 413980957, 微信: hellojackjiang
    主站蜘蛛池模板: 久久久久久久尹人综合网亚洲| 亚洲精品国产高清嫩草影院| 色www永久免费视频| 在线免费不卡视频| 免费看国产精品麻豆| 亚洲人成图片小说网站| 亚洲V无码一区二区三区四区观看| 久久亚洲sm情趣捆绑调教| 亚洲va精品中文字幕| 最新亚洲人成无码网www电影| 黄床大片免费30分钟国产精品| 久久久久成人片免费观看蜜芽| 黄页免费的网站勿入免费直接进入| 韩国二级毛片免费播放| 狠狠色婷婷狠狠狠亚洲综合| 亚洲黄网在线观看| 亚洲日韩看片无码电影| 国产精品免费视频观看拍拍| 3344永久在线观看视频免费首页 | 暖暖日本免费中文字幕| 91手机看片国产永久免费| 国产成人3p视频免费观看| 好看的电影网站亚洲一区| 亚洲AV无码专区在线亚| 老司机午夜性生免费福利| 午夜免费福利小电影| 永久免费bbbbbb视频| 亚洲精品白浆高清久久久久久| 亚洲沟沟美女亚洲沟沟| 菠萝菠萝蜜在线免费视频| 嫩草成人永久免费观看| 精品无码国产污污污免费| 欧洲亚洲国产清在高| 亚洲精品国产首次亮相| 青青操免费在线视频| 成人免费毛片视频| 久久精品国产精品亚洲色婷婷| 亚洲精品欧美综合四区| 暖暖日本免费中文字幕| 免费成人黄色大片| 亚洲1234区乱码|