­

WebSocket 原理淺析與實現簡單聊天

  • 2019 年 12 月 12 日
  • 筆記

隨著 Web 的發展,用戶對於 Web 的實時推送要求也越來越高,在 WebSocket 出現之前,大多數情況下是通過客戶端發起輪詢來拿到服務端實時更新的數據,因為 HTTP1.x 協議有一個缺陷就是通訊只能由客戶端發起,服務端沒法主動給客戶端推送。

這種方式在對實時性要求比較高的場景下,比如即時通訊、即時報價等,顯然會十分低效,體驗也不好。為了解決這個問題,便出現了 WebSocket 協議,實現了客戶端和服務端雙向通訊的能力。介紹 WebSocket 之前,還是讓我們先了解下輪詢實現推送的方式。

短輪詢(Polling)

短輪詢的實現思路就是瀏覽器端每隔幾秒鐘向伺服器端發送 HTTP 請求,服務端在收到請求後,不論是否有數據更新,都直接進行響應。在服務端響應完成,就會關閉這個 TCP 連接,程式碼實現也最簡單,就是利用 XHR , 通過 setInterval 定時向後端發送請求,以獲取最新的數據。

setInterval(function() {    fetch(url).then((res) => {        // success code    })  }, 3000);

複製程式碼

  • 優點:實現簡單。
  • 缺點:會造成數據在一小段時間內不同步和大量無效的請求,安全性差、浪費資源。

長輪詢(Long-Polling)

客戶端發送請求後伺服器端不會立即返回數據,伺服器端會阻塞請求連接不會立即斷開,直到伺服器端有數據更新或者是連接超時才返回,客戶端才再次發出請求新建連接、如此反覆從而獲取最新數據。大致效果如下:

客戶端程式碼如下:

function async() {      fetch(url).then((res) => {          async();          // success code      }).catch(() => {          // 超時          async();      })  }

複製程式碼

  • 優點:比 Polling 做了優化,有較好的時效性。
  • 缺點:保持連接掛起會消耗資源,伺服器沒有返回有效數據,程式超時。

WebSocket

前面提到的短輪詢(Polling)和長輪詢(Long-Polling), 都是先由客戶端發起 Ajax 請求,才能進行通訊,走的是 HTTP 協議,伺服器端無法主動向客戶端推送資訊。 當出現類似體育賽事、聊天室、實時位置之類的場景時,輪詢就顯得十分低效和浪費資源,因為要不斷發送請求,連接伺服器。WebSocket 的出現,讓伺服器端可以主動向客戶端發送資訊,使得瀏覽器具備了實時雙向通訊的能力。 沒用過 WebSocket 的人,可能會以為它是個什麼高深的技術。其實不然,WebSocket 常用的 API 不多也很容易掌握,不過在介紹如何使用之前,讓我們先看看它的通訊原理。

通訊原理

當客戶端要和服務端建立 WebSocket 連接時,在客戶端和伺服器的握手過程中,客戶端首先會向服務端發送一個 HTTP 請求,包含一個 Upgrade 請求頭來告知服務端客戶端想要建立一個 WebSocket 連接。 在客戶端建立一個 WebSocket 連接非常簡單:

let ws = new WebSocket('ws://localhost:9000');

複製程式碼

類似於 HTTP 和 HTTPS,ws 相對應的也有 wss 用以建立安全連接,本地已 ws 為例。這時的請求頭如下:

Accept-Encoding: gzip, deflate, br  Accept-Language: zh-CN,zh;q=0.9  Cache-Control: no-cache  Connection: Upgrade // 表示該連接要升級協議  Cookie: _hjMinimizedPolls=358479; ts_uid=7852621249; CNZZDATA1259303436=1218855313-1548914234-%7C1564625892; csrfToken=DPb4RhmGQfPCZnYzUCCOOade; JSESSIONID=67376239124B4355F75F1FC87C059F8D; _hjid=3f7157b6-1aa0-4d5c-ab9a-45eab1e6941e; acw_tc=76b20ff415689655672128006e178b964c640d5a7952f7cb3c18ddf0064264  Host: localhost:9000  Origin: http://localhost:9000  Pragma: no-cache  Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits  Sec-WebSocket-Key: 5fTJ1LTuh3RKjSJxydyifQ==     // 與響應頭 Sec-WebSocket-Accept 相對應  Sec-WebSocket-Version: 13   // 表示 websocket 協議的版本  Upgrade: websocket  // 表示要升級到 websocket 協議  User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.132 Safari/537.36

複製程式碼

響應頭如下:

Connection: Upgrade  Sec-WebSocket-Accept: ZUip34t+bCjhkvxxwhmdEOyx9hE=  Upgrade: websocket

複製程式碼

此時響應行(General)中可以看到狀態碼 status code 是 101 Switching Protocols , 表示該連接已經從 HTTP 協議轉換為 WebSocket 通訊協議。 轉換成功之後,該連接並沒有中斷,而是建立了一個全雙工通訊,後續發送和接收消息都會走這個連接通道。 注意,請求頭中有個 Sec-WebSocket-Key 欄位,和相應頭中的 Sec-WebSocket-Accept 是配套對應的,它的作用是提供了基本的防護,比如惡意的連接或者無效的連接。Sec-WebSocket-Key 是客戶端隨機生成的一個 base64 編碼,伺服器會使用這個編碼,並根據一個固定的演算法:

GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";    //  一個固定的字元串  accept = base64(sha1(key + GUID));  // key 就是 Sec-WebSocket-Key 值,accept 就是 Sec-WebSocket-Accept 值

複製程式碼

其中 GUID 字元串是 RFC6455 官方定義的一個固定字元串,不得修改。 客戶端拿到服務端響應的 Sec-WebSocket-Accept 後,會拿自己之前生成的 Sec-WebSocket-Key 用相同演算法算一次,如果匹配,則握手成功。然後判斷 HTTP Response 狀態碼是否為 101(切換協議),如果是,則建立連接,大功告成。

實現簡單單聊

下面來實現一個純文字消息類型的一對一聊天(單聊)功能,廢話不多說,直接上程式碼,注意看注釋。 客戶端:

function connectWebsocket() {      ws = new WebSocket('ws://localhost:9000');      // 監聽連接成功      ws.onopen = () => {          console.log('連接服務端WebSocket成功');          ws.send(JSON.stringify(msgData));   // send 方法給服務端發送消息      };        // 監聽服務端消息(接收消息)      ws.onmessage = (msg) => {          let message = JSON.parse(msg.data);          console.log('收到的消息:', message)          elUl.innerHTML += `<li class="b">小秋:${message.content}</li>`;      };        // 監聽連接失敗      ws.onerror = () => {          console.log('連接失敗,正在重連...');          connectWebsocket();      };        // 監聽連接關閉      ws.onclose = () => {          console.log('連接關閉');      };  };  connectWebsocket();

複製程式碼

從上面可以看到 WebSocket 實例的 API 很容易理解,簡單好用,通過 send() 方法可以發送消息,onmessage 事件用來接收消息,然後對消息進行處理顯示在頁面上。 當 onerror 事件(監聽連接失敗)觸發時,最好進行執行重連,以保持連接不中斷。 服務端 Node : (這裡使用 ws 庫)

const path = require('path');  const express = require('express');  const app = express();  const server = require('http').Server(app);  const WebSocket = require('ws');    const wss = new WebSocket.Server({ server: server });    wss.on('connection', (ws) => {      // 監聽客戶端發來的消息    ws.on('message', (message) => {      console.log(wss.clients.size);      let msgData = JSON.parse(message);      if (msgData.type === 'open') {        // 初始連接時標識會話        ws.sessionId = `${msgData.fromUserId}-${msgData.toUserId}`;      } else {        let sessionId = `${msgData.toUserId}-${msgData.fromUserId}`;        wss.clients.forEach(client => {          if (client.sessionId === sessionId) {            client.send(message);  // 給對應的客戶端連接發送消息          }        })      }    })      // 連接關閉    ws.on('close', () => {      console.log('連接關閉');    });  });

複製程式碼

同理,服務端也有對應的發送和接收的方法。完整示例程式碼見 這裡 ​ 這樣瀏覽器和服務端就可以愉快的發送消息了,效果如下:

其中綠色箭頭表示發出的消息,紅色箭頭表示收到的消息。

心跳保活

在實際使用 WebSocket 中,長時間不通消息可能會出現一些連接不穩定的情況,這些未知情況導致的連接中斷會影響客戶端與服務端之前的通訊, 為了防止這種的情況的出現,有一種心跳保活的方法:客戶端就像心跳一樣每隔固定的時間發送一次 ping ,來告訴伺服器,我還活著,而伺服器也會返回 pong ,來告訴客戶端,伺服器還活著。ping/pong 其實是一條與業務無關的假消息,也稱為心跳包。 ​ 可以在連接成功之後,每隔一個固定時間發送心跳包,比如 60s:

setInterval(() => {      ws.send('這是一條心跳包消息');  }, 60000)

複製程式碼

總結

通過上面的介紹,大家應該對 WebSocket 有了一定認識,其實並不神秘,這裡對文章內容簡單總結一下。當創建 WebSocket 實例的時候,會發一個 HTTP 請求,請求報文中有個特殊的欄位 Upgrade ,然後這個連接會由 HTTP 協議轉換為 WebSocket 協議,這樣客戶端和服務端建立了全雙工通訊,通過 WebSocket 的 send 方法和 onmessage 事件就可以通過這條通訊連接交換資訊。

《Long-Polling》http://www.caishui114.com/

《WebSocket 》http://www.0755dyx.com/