跳至主要內容

WebSocket 即時應用開發:從連線管理到生產部署

6 分鐘閱讀 1,050 字

WebSocket 即時應用開發:從連線管理到生產部署

WebSocket 讓瀏覽器和伺服器之間建立持久的雙向通訊通道。相較於 HTTP 的「請求-回應」模式,WebSocket 讓伺服器可以主動推送資料,客戶端也可以隨時發送訊息。這讓即時聊天、多人協作、線上遊戲等應用成為可能。

WebSocket 與 HTTP 的差異

HTTP(短連線):
客戶端 → [建立連線] → 伺服器
客戶端 → [發送請求] → 伺服器
客戶端 ← [回傳回應] ← 伺服器
客戶端 → [關閉連線] → 伺服器
(每次請求重複上述流程)

WebSocket(長連線):
客戶端 → [HTTP Upgrade 握手] → 伺服器
客戶端 ↔ [持久雙向通道]  ↔ 伺服器
(一次連線,持續通訊)

伺服器端實作(Node.js + ws 套件)

npm install ws
npm install --save-dev @types/ws
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';

const server = createServer();
const wss = new WebSocketServer({ server });

// 儲存所有連線
const clients = new Map<string, WebSocket>();

wss.on('connection', (ws, req) => {
  const clientId = generateId();
  clients.set(clientId, ws);

  console.log(`客戶端 ${clientId} 已連線,目前共 ${clients.size} 個連線`);

  // 發送歡迎訊息
  ws.send(JSON.stringify({
    type: 'welcome',
    clientId,
    connectedCount: clients.size
  }));

  // 接收訊息
  ws.on('message', (data) => {
    try {
      const message = JSON.parse(data.toString());
      handleMessage(clientId, message);
    } catch {
      ws.send(JSON.stringify({ type: 'error', message: '無效的 JSON 格式' }));
    }
  });

  // 處理斷線
  ws.on('close', (code, reason) => {
    clients.delete(clientId);
    console.log(`客戶端 ${clientId} 已斷線(code: ${code})`);

    // 通知其他客戶端
    broadcast({ type: 'user-left', clientId });
  });

  // 錯誤處理
  ws.on('error', (error) => {
    console.error(`客戶端 ${clientId} 錯誤:`, error);
    clients.delete(clientId);
  });
});

function broadcast(message: object, excludeId?: string) {
  const payload = JSON.stringify(message);
  clients.forEach((ws, id) => {
    if (id !== excludeId && ws.readyState === WebSocket.OPEN) {
      ws.send(payload);
    }
  });
}

function handleMessage(clientId: string, message: any) {
  switch (message.type) {
    case 'chat':
      broadcast({ type: 'chat', from: clientId, text: message.text }, clientId);
      break;

    case 'ping':
      clients.get(clientId)?.send(JSON.stringify({ type: 'pong' }));
      break;
  }
}

server.listen(3000, () => console.log('WebSocket 伺服器啟動在 port 3000'));

心跳機制(Heartbeat)

網路中間設備(NAT、防火牆)可能會在一段時間後斷開閒置的連線。心跳機制可以保持連線活躍,並偵測失效的連線:

const HEARTBEAT_INTERVAL = 30_000; // 30 秒
const HEARTBEAT_TIMEOUT = 10_000;  // 10 秒內未回應視為斷線

interface ExtendedWebSocket extends WebSocket {
  isAlive: boolean;
  clientId: string;
}

wss.on('connection', (ws: ExtendedWebSocket) => {
  ws.isAlive = true;

  ws.on('pong', () => {
    ws.isAlive = true; // 收到 pong,連線存活
  });
});

// 定期發送 ping
const heartbeatTimer = setInterval(() => {
  wss.clients.forEach((ws: ExtendedWebSocket) => {
    if (!ws.isAlive) {
      // 上次 ping 沒有回應,強制斷線
      console.log(`客戶端 ${ws.clientId} 心跳超時,斷開連線`);
      return ws.terminate();
    }

    ws.isAlive = false;
    ws.ping(); // 發送 WebSocket 協定層的 ping
  });
}, HEARTBEAT_INTERVAL);

wss.on('close', () => clearInterval(heartbeatTimer));

客戶端實作(帶自動重連)

class ReconnectingWebSocket {
  private ws: WebSocket | null = null;
  private reconnectDelay = 1000;
  private maxReconnectDelay = 30000;
  private shouldReconnect = true;

  constructor(
    private url: string,
    private onMessage: (data: any) => void
  ) {
    this.connect();
  }

  private connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      console.log('WebSocket 連線成功');
      this.reconnectDelay = 1000; // 重置延遲時間
    };

    this.ws.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        this.onMessage(data);
      } catch {
        console.error('訊息解析失敗');
      }
    };

    this.ws.onclose = (event) => {
      if (this.shouldReconnect && !event.wasClean) {
        console.log(`連線中斷,${this.reconnectDelay}ms 後重連...`);
        setTimeout(() => this.connect(), this.reconnectDelay);
        // 指數退避:每次重連延遲加倍,最多 30 秒
        this.reconnectDelay = Math.min(
          this.reconnectDelay * 2,
          this.maxReconnectDelay
        );
      }
    };

    this.ws.onerror = (error) => {
      console.error('WebSocket 錯誤:', error);
    };
  }

  send(data: object) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    } else {
      console.warn('連線尚未建立,訊息未發送');
    }
  }

  close() {
    this.shouldReconnect = false;
    this.ws?.close(1000, 'Client initiated close');
  }
}

水平擴展:多台伺服器同步

當需要多台 WebSocket 伺服器時,必須有機制讓連線在不同伺服器的客戶端互相接收訊息:

import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

// 使用 Redis Pub/Sub 在伺服器間傳遞訊息
async function broadcastViaRedis(message: object) {
  await pubClient.publish('ws:broadcast', JSON.stringify(message));
}

// 每台伺服器訂閱並轉發給本地客戶端
await subClient.subscribe('ws:broadcast', (payload) => {
  const message = JSON.parse(payload);
  // 發送給這台伺服器上的所有客戶端
  localClients.forEach(ws => ws.send(payload));
});

在 Nginx 後面的設定

map $http_upgrade $connection_upgrade {
    default upgrade;
    '      close;
}

server {
    listen 80;

    location /ws {
        proxy_pass http://websocket_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # WebSocket 連線的超時時間(0 = 永不超時)
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

使用 Socket.io 簡化開發

對於大型應用,直接使用原生 WebSocket 有不少重複工作。Socket.io 在 WebSocket 之上提供了更豐富的功能:

import { Server } from 'socket.io';

const io = new Server(httpServer, {
  cors: { origin: '*' }
});

io.on('connection', (socket) => {
  // 房間功能
  socket.join('general');

  // 收發訊息
  socket.on('chat:message', (msg) => {
    io.to('general').emit('chat:message', {
      from: socket.id,
      text: msg.text,
      timestamp: Date.now()
    });
  });

  // 自動處理斷線重連
  // 自動降級到 Long Polling(不支援 WebSocket 時)
});

小結

WebSocket 是即時應用的基石,但生產環境需要考慮心跳機制、自動重連、水平擴展、Nginx 代理等面向。對於新專案,可以先用 Socket.io 快速開發,待需要精細控制時再考慮原生 WebSocket。關鍵是理解底層機制,才能在遇到問題時快速診斷和解決。

分享這篇文章