3.3 WebSocket 伺服器實作:即時聊天室#
3.2 節的延伸:從裸 TCP 到 WebSocket#
3.2 節的 TCP 聊天室要自己定協議、自己處理幀。WebSocket 把這些標準化:握手、幀格式、關閉語意全部由 RFC 6455 規範,瀏覽器原生支援 —— 客戶端從此不需要 nc,直接在網頁裡跑。
用 Node.js(ws 套件)實作#
npm install ws
// ws-server.js
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
const clients = new Map(); // ws → 暱稱
wss.on('connection', (ws, req) => {
// 握手已完成(101 Switching Protocols),直接可用
const name = new URL(req.url, 'http://x').searchParams.get('name')
?? `user${clients.size}`;
clients.set(ws, name);
ws.send(JSON.stringify({ type: 'welcome', from: 'server', text: `哈囉 ${name}` }));
broadcast({ type: 'join', from: 'server', text: `${name} 加入` });
ws.on('message', (buf) => {
const msg = JSON.parse(buf.toString());
broadcast({ type: 'chat', from: name, text: String(msg.text).slice(0, 200) });
});
ws.on('close', () => {
clients.delete(ws);
broadcast({ type: 'leave', from: 'server', text: `${name} 離開` });
});
ws.on('pong', () => (ws.isAlive = true)); // 心跳回應
});
// 心跳:清掃半死連線(2.4 節)
setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) { ws.terminate(); continue; }
ws.isAlive = false;
ws.ping();
}
}, 30000);
function broadcast(obj) {
const data = JSON.stringify(obj);
for (const ws of wss.clients)
if (ws.readyState === 1) ws.send(data);
}
console.log('WebSocket 伺服器: ws://localhost:8080');
協議設計要點:用 JSON 的 type 欄位做應用層訊息分派 —— WebSocket 只保證「把位元組送到」,訊息的語意(join/chat/leave)是應用層契約。
瀏覽器客戶端#
<!-- index.html -->
<input id="name" placeholder="暱稱" />
<input id="input" placeholder="訊息" />
<button onclick="connect()">連線</button>
<ul id="log"></ul>
<script>
let ws;
function connect() {
const name = encodeURIComponent(document.getElementById('name').value || 'anon');
ws = new WebSocket(`ws://localhost:8080?name=${name}`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
const li = document.createElement('li');
li.textContent = msg.from === msg.text ? msg.text : `[${msg.from}] ${msg.text}`;
document.getElementById('log').append(li);
};
ws.onclose = () => setTimeout(connect, 2000); // 自動重連
}
function send() {
ws.send(JSON.stringify({ text: document.getElementById('input').value }));
}
</script>
整合進 HTTP 伺服器:同埠服務#
實務上 WebSocket 通常與 HTTP API 同埠(避免跨域與防火牆問題):
const http = require('http');
const { WebSocketServer } = require('ws');
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/html');
res.end(require('fs').readFileSync('index.html')); // 順便服務網頁
});
const wss = new WebSocketServer({ server }); // 掛在同一個 http server 上
server.listen(8080);
原理:握手就是一個帶 Upgrade 標頭的 HTTP 請求(2.4 節),WebSocketServer 攔下這類請求,其餘交給一般 handler。
水平擴展:單機的牆#
flowchart TB
LB[負載平衡器] --> S1[伺服器1<br/>clients A,B]
LB --> S2[伺服器2<br/>clients C,D]
S1 -.A 只聽得到同機訊息.-> S1
S2 -.C,D 收不到 A 的訊息.-> S2
S1 <--> PUBSUB[Redis Pub/Sub]
S2 <--> PUBSUB問題:A 連在機器 1,B 連在機器 2,機器 1 的 for 迴圈摸不到 B 的 socket。
解法:Redis Pub/Sub 當廣播匯流排:
const redis = require('redis');
const sub = redis.createClient(), pub = redis.createClient();
sub.subscribe('chat');
sub.on('message', (_ch, data) => { // 收到其他機器的廣播 → 轉發給本機客戶端
for (const ws of wss.clients) ws.send(data);
});
ws.on('message', (buf) =>
pub.publish('chat', buf.toString())); // 本機訊息 → 發到匯流排
模式總結:連線狀態留在各機器記憶體,訊息走共享匯流排 —— 這是聊天、協作、即時通知系統的標準架構(Slack、Discord 同理,規模更大而已)。
安全清單#
- wss://(TLS):明文 ws 可被中間人竄改幀
- Origin 檢查:握手時驗
req.headers.origin,防其他網站借用你的用戶頻寬(CSWSH 攻擊) - 輸入限制:訊息長度上限、頻率限制(每秒 N 則)
- 驗證:握手 URL 帶 token 或用 Sec-WebSocket-Protocol 傳 JWT,驗不過就拒絕升級
習題#
- 加入「房間」功能(參考 3.2 節 rooms),並在多機架構下用 Redis channel per room 實現。
- 實作 Origin 白名單與 JWT 握手驗證。
- 若單機需要 10 萬連線,Node.js 單行程式夠嗎?查資料並提出方案(cluster / worker_threads / Rust 重寫)。