Logo image
GitHub LinkedIn

Websocket & Heartbeat 實作

Relume placeholder avatar
張三

2022年1月11日

5分鐘閱讀

Relume placeholder image

在前幾個月的專案,需要用 WebSocket 來廣播共同訊息,但是因為網路不好,導致有時候會斷線,然後就會看到很多人都在『F5』刷新訊息。或是因為太久沒有操作,導致斷線。後來也在思考如何做會比較好,因此有了這篇筆記。

目錄

Websocket vs HTTP

通訊協定:白話來說,就是指在網路溝通時,確保雙方能夠正確收到訊息,因此制定的通訊規則。

如果回到讓人很頭痛的網路概論,大概還記得 TCP/IP 等通訊模型:

    1. 應用層:例如 HTTP(文本)/FTP(檔案傳送)
    1. 傳輸層:例如 TCP(可靠)/UDP(不可靠)
    1. 網路層:例如 IP(路由)
    1. 連結層:例如乙太網路

而 HTTP 是基於 TCP 的應用層協定,短暫建立請求後,就會斷開連線。但像是聊天室、遊戲等,需要由伺服器主動推送訊息,這時候就需要 Websocket 這個協定。

:::note Websocket vs HTTP

特性WebSocketHTTP
連接方式持久連接,雙向通信短連接,請求-回應模式
通信效率高,因為連接持久且無需每次都建立新連接低,每次請求都需建立新連接
延遲低,適合實時應用高,不適合實時應用
資源消耗低,因為連接持久且無需頻繁建立和關閉連接高,因為每次請求都需建立和關閉連接
使用情境即時聊天、遊戲、股票行情、實時通知等需要實時數據更新的應用網頁瀏覽、文件下載、API 請求等不需要實時數據更新的應用
安全性需要額外的安全措施來防止攻擊(如 XSS、CSRF)通過 HTTPS 提供內建的安全性
瀏覽器支持現代瀏覽器均支持所有瀏覽器均支持
協議ws:// 或 wss://http:// 或 https://
數據格式任意格式(如 JSON、XML、二進制數據等)主要是文本格式(如 HTML、JSON 等)
:::

Socket vs Websocket vs Socket.io

在前後端實作 socket 時,首先要確保前後端的通訊協定一致,然後再進行協作。

:::note Socket vs WebSocket vs Socket.io

特性SocketWebSocketSocket.io
定義低層次的網路通信接口基於 TCP 的應用層協定WebSocket 的封裝庫,提供更高層次的 API
連接方式需要手動管理連接持久連接,雙向通信持久連接,雙向通信
通信效率
使用難度高,需要處理底層細節中等,需要處理協定細節低,封裝了很多細節
瀏覽器支持不直接支持現代瀏覽器均支持現代瀏覽器均支持
資源消耗
使用情境低層次網路通信即時聊天、遊戲、股票行情、實時通知等需要實時數據更新的應用即時聊天、遊戲、股票行情、實時通知等需要實時數據更新的應用
安全性需要額外的安全措施需要額外的安全措施提供內建的安全措施
數據格式任意格式任意格式任意格式
:::

實作:以 Websocket 為例

如何避免失去連線?Heartbeat!

在前幾個月的專案,需要用 WebSocket 來廣播共同訊息,但是因為網路不好,導致有時候會斷線,然後就會看到很多人都在『F5』刷新訊息。或是因為太久沒有操作,導致斷線。後來也在思考如何做會比較好。

概念介紹

如果參考 MDN 的文章,可以看到有一個 pingpong 的機制,這個機制可以用來確保連線是否還在,如果沒有收到 pong 就可以重新連線。

既然 WebSocket 很容易掉封包,那麼 Heartbeat 就是由前端主動發起的監控方式,來確保使用者的連線狀態。

下面是前後端實作需要注意的地方:

前端

  1. 主動發起 ping 請求(大約間隔 30 - 60 秒鐘)。

    • 通常 ping 的訊息會很簡短。
  2. 檢測是否收到pong 回應

    • 設定等待 pong 回應的超時時間(通常 3-5 秒)
    • 沒收到回應要有重試機制
    • 達到重試上限要斷開重連

後端

  1. 收到 ping 請求後,回應 pong

:::note 心跳的頻率可以根據實際情況調整,如果是網路訊號差的環境,可以提高心跳的頻率,但相對而言會增加伺服器的負擔。 :::

實作

:::note 這邊準備了一個簡單的範例,可以參考 Github,可以使用 docker-compose 的方式來啟動。 :::

websocket-chatroom

可以透過 ws://localhost:3000 來連線,並透過聊天室傳送訊息。

websocket-heartbeat

每過 30 秒,會在 Message 看到 pong,代表從後端收到回應。

後端

點擊查看後端程式碼 ```javascript // index.js const app = require('express'); const server = require('http').createServer(app);

const wss = new WebSocket.Server({ server });

// 存儲所有連接的客戶端 const clients = new Map();

wss.on(‘connection’, ws => { const clientId = Date.now(); clients.set(clientId, { ws, isAlive: true, lastHeartbeat: Date.now(), });

// 處理接收到的消息 ws.on(‘message’, message => { const data = message.toString();

if (data === 'ping') {
  // 回應心跳
  ws.send('pong');
  clients.get(clientId).lastHeartbeat = Date.now();
  clients.get(clientId).isAlive = true;
} else {
  // 廣播消息給所有客戶端
  broadcastMessage(data, clientId);
}

});

// 處理連接關閉 ws.on(‘close’, () => { clients.delete(clientId); });

// 處理錯誤 ws.on(‘error’, error => { console.error(Client ${clientId} error:, error); clients.delete(clientId); }); });

</details>


#### `前端`

:::note
React 的 WebSocket 實作可以參考 [useEffect](https://react.dev/reference/react/useEffect)

`useEffect` is a React Hook that lets you [synchronize a component with an external system.](https://react.dev/learn/synchronizing-with-effects)

:::

<details>
  <summary>WebSocket</summary>
````javascript
export const useWs = (url: string) => {
  const [isReady, setIsReady] = useState(false);
  const [val, setVal] = useState<any>(null);

  useEffect(() => {
    const ws = new WebSocket(url);

    ws.onopen = () => {
      setIsReady(true);
    };

    ws.onmessage = (event) => {
      setVal(event.data);
    };

    ws.onclose = () => {
      setIsReady(false);
    };

    return () => {
      ws.close();
    };
  }, []);

  const send = (message: string) => {
    if (isReady) {
      val.send(message);
    }
  };

  return [isReady, val, send]; // return 這三種hook方法(isReady: 是否連線, val: 聊天室傳送的值, send: 傳送訊息的方法)
WebSocket with Heartbeat ````javascript export const useWs = (url: string) => { const [isReady, setIsReady] = useState(false); const [val, setVal] = useState(null);

useEffect(() => { const ws = new WebSocket(url);

ws.onopen = () => {
  setIsReady(true);
};

ws.onmessage = (event) => {
  setVal(event.data);
};

ws.onclose = () => {
  setIsReady(false);
};

startHeartbeat(); // add heartbeat

return () => {
  ws.close();
};

}, []);

// add heartbeat

const startHeartbeat = () => {
heartbeatInterval.current = setInterval(() => {
  if (ws.current && ws.current.readyState === WebSocket.OPEN) {
    ws.current.send('ping');
  }
}, 30000); // 30 seconds

};

const stopHeartbeat = () => { clearInterval(heartbeatInterval.current); };


</details>

<details>
  <summary>App</summary>
````javascript
const App = () => {
  const [isReady, val, send] = useWs('ws://localhost:3000');

const handleSend = () => {
if (isReady) {
val.send(message);
setMessage('');
}
};

return (

<div>
  <input
    type="text"
    value={message}
    onChange={(e) => setMessage(e.target.value)}
  />
  <button onClick={handleSend}>Send</button>
  <div>
    {isReady ? 'Connected' : 'Disconnected'} // 顯示連線狀態
  </div>
  <div>
    {val} // 顯示收到的訊息
  </div>
</div>
)

````
</details>

## 參考資料

1. [Writing WebSocket servers](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#pings_and_pongs_the_heartbeat_of_websockets)

探索更多精彩內容

繼續閱讀,了解更多技術與個人經歷的精彩文章。