前言

前幾天研究了一下 Client Pull 和 Server Push 的幾種方式,其中屬於 Server Push 的 SSE(Server Send Event)已經在之前的文章 資料實時更新的方式 - Client Pull vs Server Push 中實作過,這次來研究另一種 Server Push 的方式,同時也是較常聽到的 WebSocket,來研究看看如何實作一個簡單的多人聊天室

簡介 Socket.io

因為待會 demo 的前後端會使用 Socket.io 套件來實作,所以開始前先來介紹一下 Socket.io 這個套件 (以下內容整理自官網介紹)

Socket.IO is a library that enables low-latency, bidirectional and event-based communication between a client and a server.

Socket.io 也是為了解決網路即時通訊而誕生,但是他並不像 WebSocket 一樣屬於通用協定,而是將底層 WebSocket 抽象化的 JavaScript Library(包含前端與後端的 npm 套件),所以 Socket.io 是建於 WebSocket 之上,有自己定義的溝通格式。

Socket.IO is NOT a WebSocket implementation. → Although Socket.IO indeed uses WebSocket for transport when possible, it adds additional metadata to each packet. That is why a WebSocket client will not be able to successfully connect to a Socket.IO server, and a Socket.IO client will not be able to connect to a plain WebSocket server either.

正因如此,Socket.io 必須用自己提供的 client 才能跟 server 溝通,意味著 socket.io 只能用於 web,與 Websocket 有很大的差異,因為 Websocket 還可以完成 P2P communications, 實時 server-to-server 資料傳遞…等任務

It is built on top of the WebSocket protocol and provides additional guarantees like fallback to HTTP long-polling or automatic reconnection.

Socket.io 是 web 中處理 websocket 的套件,當某些 browser 不支援 websockets 協議時,就會 fallback 成 HTTP long polling,這項特色在早期大部分瀏覽器尚未支援 websocket 時是一個成功的替代方案,但近幾年瀏覽器已經大多都支援了,所以該特色漸漸變得不再新鮮與必要.

React + Node.js 前後端 Socket.io 實作雙向溝通

介紹完 Socket.io 的基本概念與特色之後,就來實做簡易的多人聊天室,嘗試使用 Socket.io 來完成前後端的實時溝通

Server

首先後端要安裝 socket.io 套件

npm install socket.io

在 server.ts 中撰寫發送 event 的邏輯,用原生的 http 套件來 wrap 另一個 express 物件,以監聽 connection 的連線。底下包含三種發送方式:

  • socket.emit: 僅回覆訊息給該事件的寄送者
  • io.sockets.emit: 推播給所有監聽事件的收聽人
  • socket.broadcast.emit: 推播給所有監聽事件的收聽人,但不包含該事件的寄送者

最後,由於原生的 http 套件較底層,所以依舊可以監聽 request 的請求,並將一般的 HTTP 請求(用於 API)導給 express 實例化的 app 物件來處理

// server.ts

import { createServer } from "http";
import { Server } from "socket.io";
import app from "./app";

const httpServer = createServer(app);
const io = new Server(httpServer, {
  cors: { origin: "http://localhost:3000" }, // socket cors for client url
});

io.on("connection", (socket) => {
  console.log("connect!");

  socket.on("getMessage", (msg: string) => {
    console.log(`server receive message: ${msg}`);

    // Only return message to the specific client listening on `getMessage` key
    socket.emit("getMessage", "only response to sender");

    // Return message to all connected clients listening on `getMessage` key
    io.sockets.emit("getMessage", "broadcast to all listeners");

    // Return message to all connect clients listening on `getMessage` key except for sender
    socket.broadcast.emit("getMessage", "exclude the sender");
  });
});

// remain normal http request
io.on("request", app);

const PORT = process.env.PORT || 8000;

httpServer.listen(PORT, () => {
  console.log(`Server is listening at port ${PORT}`);
});

Client

再來安裝前端的套件 Socket.io-client

npx create-react-app client --template=typescript
npm install socket.io-client

接著前端有兩個按鈕,connect 按鈕按下後會建立與後端的 websocket 連線,另一個 send 按鈕按下後會對 server 發送一個 hello 的訊息

接著 useEffect 中該 web 就會監聽所有 getMessage 這個 event key 的訊息,並將他用 console.log 輸出至瀏覽器的控制台中

// Chatroom.tsx

import React, { useEffect, useState } from "react";
import { io } from "socket.io-client";

const Chatroom = () => {
  const [ws, setWs] = useState<any>(null);

  const connectWebSocket = () => {
    setWs(io("http://localhost:8000")); // server url
  };

  const sendMessage = () => {
    ws.emit("getMessage", "hello"); // `getMessage` is the event key
  };

  useEffect(() => {
    if (ws) {
      ws.on("getMessage", (msg: string) => {
        console.log(msg);
      });
    }
  }, [ws]);

  return (
    <div>
      <input type="button" value="connect" onClick={connectWebSocket} />
      <input type="button" value="send" onClick={sendMessage} />
    </div>
  );
};

export default Chatroom;

OK,完成簡易開發後,就來實測看看怎麼溝通

首先在瀏覽器上開啟三個 tab,通通連到 http://localhost:3000,姑且稱之為 client1, client2, client3

接著 client1client2 在各自的網頁上點擊 connect 與後端建立 websocket 連線。再來在 client1 的頁面上點擊 send,模擬 client1 作為寄送者來發送一個 hello 訊息。

server 會收到:-

server receive message: hello

client1 會收到

only response to sender
broadcast to all listeners

因為client1是發送訊息的人,所以只有他會收到 only response to sender

client2會收到

broadcast to all listeners
exclude the sender

因為client2不是發送訊息的人,所以他會收到 exclude the sender

而 client1 和 client2 都會收到 broadcast to all listeners,因為他們都有監聽(訂閱)目前的channel,但是 client3 就不會收到任何訊息,因為它並沒有和 server 建立 websocket 連線

結語

以上就是簡易的聊天室實作,透過 Socket.io 這個 JavaScript Library 來完成 server 和 client 的溝通,並且支援以下功能:單獨回覆給寄送者、推播給所有訂閱的聽眾、排除寄送者的回覆訊息…等等。當然有了這些基本的操作,要完成進階功能,例如: userA 已經退出群組、userB 已經加入群組…等等的通知也不算難事

聽某前輩提到,若以系統設計的角度來看,傳統 websocket 較難 scale out,因為每一個連線都是獨立且需要被記錄保持的,儼然是一個架構師的難題,而 Socket.io 官網有提到 scalability 的做法,簡單來說是使用類似 sticky-session 的方式來實作 sticky load balancing(ex: by origin IP address),但就之前看到的系統設計文章中常提到 sticky-session 在架構上某種程度算是 anti-pattern,所以這部分就留給未來再研究看看,有機會再寫一篇文章來紀錄