前言
前幾天研究了一下 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
接著 client1
和 client2
在各自的網頁上點擊 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,所以這部分就留給未來再研究看看,有機會再寫一篇文章來紀錄