Đang tải...

Ếch Trendy

Cùng chú ếch nhỏ khám phá thế giới Web đầy biến động. Cập nhật trending nhanh như cách ếch đớp mồi! ⌨️🌿


0

WebSocket và Socket.io: Xây dựng realtime chat, live dashboard, multiplayer game

Ếch Trendy
Ếch Trendy

2 tháng trước · 9 phút đọc

Tại sao HTTP không đủ cho realtime?

HTTP hoạt động theo mô hình request-response: client hỏi, server trả lời, xong. Mỗi kết nối mở ra rồi đóng lại. Cách này ổn với blog hay REST API, nhưng với chat app thì sao?

Bạn phải liên tục poll server mỗi vài giây để kiểm tra tin nhắn mới. HTTP long polling sử dụng khoảng 7.4× lượng dữ liệu so với WebSocket (884,698 bytes vs 119,692 bytes cho 1,000 sự kiện) (Nghiên cứu so sánh HTTP polling vs WebSocket 2023-2024) lượt poll không cần thiết mỗi giây - tốn băng thông, tốn CPU, và người dùng vẫn thấy delay.

346724 HTTP polling vs WebSocket - chi phí kết nối chênh lệch rõ rệt

WebSocket giải quyết bằng cách duy trì một kết nối persistent. Sau handshake ban đầu (vẫn qua HTTP/HTTPS), kết nối được "nâng cấp" lên giao thức WebSocket. Từ đó server có thể chủ động push data xuống client bất cứ lúc nào mà không cần client hỏi trước.

Socket.io không phải là WebSocket thuần. Nó wrap WebSocket và thêm một đống thứ quan trọng cho production: tự động reconnect, fallback sang long-polling nếu WebSocket bị chặn, rooms, namespaces, acknowledgements. Với Socket.io 5.0, engine đã được viết lại hoàn toàn - connection overhead giảm đáng kể so với v2.


Architecture cơ bản: Server và Client setup

Trước khi đi vào từng feature, mình cần có base architecture rõ ràng. Đây là setup production-ready cho một Node.js server với Socket.io 5.0.

// server.js - Khởi tạo server với Socket.io
import { createServer } from 'http';
import { Server } from 'socket.io';
import express from 'express';

const app = express();
const httpServer = createServer(app);

// Cấu hình CORS và options production
const io = new Server(httpServer, {
  cors: {
    origin: process.env.ALLOWED_ORIGINS?.split(',') || [],
    methods: ['GET', 'POST'],
    credentials: true
  },
  // Timeout cho connection (ms)
  pingTimeout: 60000,
  pingInterval: 25000,
  // Tắt transport fallback nếu không cần
  transports: ['websocket', 'polling']
});

// Middleware auth - chạy trước khi accept connection
io.use(async (socket, next) => {
  const token = socket.handshake.auth.token;
  if (!token) return next(new Error('Unauthorized'));

  try {
    // Verify JWT token
    const user = await verifyToken(token);
    socket.data.user = user; // Gán user vào socket
    next();
  } catch {
    next(new Error('Invalid token'));
  }
});

// Connection handler
io.on('connection', (socket) => {
  console.log(`User connected: ${socket.data.user.id}`);

  socket.on('disconnect', (reason) => {
    console.log(`Disconnected: ${reason}`);
  });
});

httpServer.listen(3000);

346725 Middleware auth chạy một lần trước handshake - hiệu quả hơn check trong mỗi event

Phía client với React:

// hooks/useSocket.js - Custom hook quản lý connection
import { useEffect, useRef, useState } from 'react';
import { io } from 'socket.io-client';

export function useSocket(url) {
  const socketRef = useRef(null);
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    // Lấy token từ auth store
    const token = getAuthToken();

    socketRef.current = io(url, {
      auth: { token },
      // Tự động reconnect với backoff
      reconnection: true,
      reconnectionDelay: 1000,
      reconnectionDelayMax: 5000,
      reconnectionAttempts: 5
    });

    socketRef.current.on('connect', () => setConnected(true));
    socketRef.current.on('disconnect', () => setConnected(false));

    // Cleanup khi unmount
    return () => socketRef.current?.disconnect();
  }, [url]);

  return { socket: socketRef.current, connected };
}

Một điểm nhiều người bỏ qua: reconnection logic. Khi network mất rồi có lại, client cần tự reconnect và re-join các rooms. Socket.io có sẵn cơ chế này, nhưng bạn vẫn cần handle connect event để restore state (re-join room, re-subscribe data).


Rooms và Namespaces: Tổ chức connection có cấu trúc

Đây là hai khái niệm hay bị lẫn lộn.

Namespace giống như endpoint riêng biệt - /chat, /dashboard, /game. Mỗi namespace có connection pool, middleware và event handler riêng. Dùng khi bạn có nhiều "ứng dụng" trong một server.

Room là nhóm logic bên trong một namespace. Một socket có thể join nhiều rooms cùng lúc. Dùng để broadcast message đến subset của users - ví dụ channel Slack, phòng chờ trong game.

// Namespace riêng cho chat
const chatNS = io.of('/chat');

chatNS.use(authMiddleware); // Middleware riêng

chatNS.on('connection', (socket) => {
  // Join room khi người dùng mở channel
  socket.on('join-channel', async (channelId) => {
    // Kiểm tra quyền trước khi join
    const canJoin = await checkPermission(socket.data.user.id, channelId);
    if (!canJoin) {
      socket.emit('error', { message: 'Không có quyền truy cập' });
      return;
    }

    socket.join(`channel:${channelId}`);

    // Load 50 tin nhắn gần nhất
    const history = await getMessageHistory(channelId, 50);
    socket.emit('message-history', history);

    // Thông báo cho những người trong room
    socket.to(`channel:${channelId}`).emit('user-joined', {
      userId: socket.data.user.id,
      username: socket.data.user.name
    });
  });

  // Gửi tin nhắn
  socket.on('send-message', async ({ channelId, content }) => {
    // Validate và lưu vào DB
    const message = await saveMessage({
      channelId,
      userId: socket.data.user.id,
      content,
      timestamp: Date.now()
    });

    // Broadcast đến cả room (kể cả người gửi)
    chatNS.to(`channel:${channelId}`).emit('new-message', message);
  });

  // Rời room
  socket.on('leave-channel', (channelId) => {
    socket.leave(`channel:${channelId}`);
  });
});

346726 Rooms cho phép broadcast đúng nhóm - không tốn băng thông gửi cho người không liên quan

Một pattern hữu ích: Private rooms. Mỗi user join một room tên theo user ID của họ. Cần gửi notification đến user cụ thể? Emit đến room đó thay vì track socket ID (vì socket ID thay đổi sau mỗi reconnect).

// Join personal room ngay khi connect
socket.on('connection', (socket) => {
  // Mỗi user có room riêng theo ID
  socket.join(`user:${socket.data.user.id}`);
});

// Gửi notification đến user cụ thể từ bất kỳ đâu
function notifyUser(userId, event, data) {
  io.to(`user:${userId}`).emit(event, data);
}

Xây live dashboard với broadcasting

Live dashboard là use case điển hình: server liên tục push metrics, chart cập nhật real-time. Thay vì mỗi tab browser poll riêng, bạn aggregate data một chỗ rồi broadcast.

// Dashboard namespace với rate limiting
const dashboardNS = io.of('/dashboard');

// Chỉ admin mới access được
dashboardNS.use(async (socket, next) => {
  const user = socket.data.user;
  if (user.role !== 'admin') {
    return next(new Error('Admin only'));
  }
  next();
});

dashboardNS.on('connection', (socket) => {
  socket.on('subscribe-metrics', (metricTypes) => {
    // Join room theo từng loại metric
    metricTypes.forEach(type => socket.join(`metric:${type}`));
    socket.emit('subscribed', { metrics: metricTypes });
  });
});

// Worker riêng collect và broadcast metrics
async function metricsWorker() {
  setInterval(async () => {
    const [serverMetrics, dbMetrics, apiMetrics] = await Promise.all([
      collectServerMetrics(),
      collectDBMetrics(),
      collectAPIMetrics()
    ]);

    // Chỉ broadcast đến người subscribe metric đó
    dashboardNS.to('metric:server').emit('metrics-update', {
      type: 'server',
      data: serverMetrics,
      timestamp: Date.now()
    });

    dashboardNS.to('metric:database').emit('metrics-update', {
      type: 'database',
      data: dbMetrics,
      timestamp: Date.now()
    });
  }, 2000); // Cập nhật mỗi 2 giây
}

346727 Subscribe theo metric type - không broadcast thừa data không ai cần

Phía React client:

// components/LiveChart.jsx
function LiveChart({ metricType }) {
  const { socket } = useSocket('http://localhost:3000/dashboard');
  const [data, setData] = useState([]);

  useEffect(() => {
    if (!socket) return;

    // Subscribe khi mount
    socket.emit('subscribe-metrics', [metricType]);

    socket.on('metrics-update', ({ type, data: newPoint }) => {
      if (type !== metricType) return;

      setData(prev => {
        // Giữ tối đa 60 data points (2 phút nếu update mỗi 2s)
        const updated = [...prev, newPoint];
        return updated.slice(-60);
      });
    });

    return () => {
      socket.off('metrics-update');
    };
  }, [socket, metricType]);

  return <LineChart data={data} />;
}

Một điểm cần chú ý với dashboard: backpressure. Nếu client xử lý chậm hơn server push, data sẽ queue up và gây memory leak. Giải pháp đơn giản: chỉ giữ N data points gần nhất (như ví dụ trên), hoặc dùng volatile emit - Socket.io sẽ bỏ qua message nếu socket đang busy.


Multiplayer game: Đồng bộ state với độ trễ thấp

Game multiplayer phức tạp hơn chat vì yêu cầu state consistency - tất cả người chơi phải thấy cùng một trạng thái. Và phải nhanh.

Pattern phổ biến là server-authoritative: client gửi input (ý định), server xử lý logic và broadcast state đã validated.

// Game room management
const gameNS = io.of('/game');

// State của từng game room
const gameRooms = new Map();

gameNS.on('connection', (socket) => {
  socket.on('create-room', ({ maxPlayers = 4 }) => {
    const roomId = generateRoomId();
    gameRooms.set(roomId, {
      id: roomId,
      players: [],
      state: initGameState(),
      maxPlayers
    });

    socket.join(`game:${roomId}`);
    socket.emit('room-created', { roomId });
  });

  socket.on('join-room', ({ roomId }) => {
    const room = gameRooms.get(roomId);
    if (!room) return socket.emit('error', { message: 'Room không tồn tại' });
    if (room.players.length >= room.maxPlayers) {
      return socket.emit('error', { message: 'Room đã đầy' });
    }

    room.players.push({
      id: socket.data.user.id,
      socketId: socket.id
    });

    socket.join(`game:${roomId}`);

    // Gửi state hiện tại cho người vừa join
    socket.emit('game-state', room.state);

    // Thông báo các player khác
    socket.to(`game:${roomId}`).emit('player-joined', {
      playerId: socket.data.user.id
    });

    // Bắt đầu game khi đủ người
    if (room.players.length === room.maxPlayers) {
      startGame(roomId);
    }
  });

  // Client gửi input, server validate và update state
  socket.on('player-input', ({ roomId, input }) => {
    const room = gameRooms.get(roomId);
    if (!room) return;

    // Server là authority - validate input
    const isValid = validateInput(room.state, socket.data.user.id, input);
    if (!isValid) return;

    // Cập nhật game state
    room.state = applyInput(room.state, socket.data.user.id, input);

    // Broadcast state mới đến tất cả player trong room
    gameNS.to(`game:${roomId}`).emit('state-update', {
      state: room.state,
      timestamp: Date.now()
    });
  });
});

function startGame(roomId) {
  const room = gameRooms.get(roomId);
  room.state.status = 'playing';
  gameNS.to(`game:${roomId}`).emit('game-started', room.state);
}

346728 Server là authority - client không được tự quyết game state để tránh cheating

Về latency: Với game turn-based (cờ, quiz), delay 100-200ms hoàn toàn chấp nhận được. Game action real-time (shooter, racing) cần phức tạp hơn: client-side prediction + server reconciliation. Đây là chủ đề riêng, nhưng Socket.io đủ dùng cho phần transport layer.


Scaling với Redis Adapter

Một Socket.io server single-process chỉ xử lý được đến một ngưỡng nhất định. Khi cần horizontal scaling (nhiều instances), vấn đề xuất hiện: user A connect vào instance 1, user B connect vào instance 2 - nếu A gửi message cho B, instance 1 không biết socket của B đang ở đâu.

Giải pháp: Redis Adapter. Tất cả instances đều sub/pub qua Redis. Khi instance 1 emit đến một room, Redis broadcast tới tất cả instances khác, instance nào có socket trong room đó sẽ deliver.

// server.js - Thêm Redis Adapter
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()]);

// Gắn adapter vào io instance
io.adapter(createAdapter(pubClient, subClient));

// Từ đây emit hoạt động xuyên instances
// io.to('channel:general').emit(...) sẽ reach tất cả sockets
// kể cả khi chúng connect vào instance khác nhau

346729 Redis làm message bus giữa các instances - scale ngang mà không cần sticky sessions

Ngoài Redis Adapter, cần chú ý thêm:

Rate limiting - tránh một client spam events:

import { RateLimiterMemory } from 'rate-limiter-flexible';

const rateLimiter = new RateLimiterMemory({
  points: 10,    // 10 events
  duration: 1,   // per 1 second
});

io.use(async (socket, next) => {
  socket.use(async ([event], next) => {
    try {
      await rateLimiter.consume(socket.data.user.id);
      next();
    } catch {
      socket.emit('error', { message: 'Rate limit exceeded' });
    }
  });
  next();
});

Message queuing khi cần đảm bảo delivery: nếu user offline, message cần được lưu lại. Đơn giản nhất là lưu vào DB và sync lại khi user reconnect - kiểm tra lastSeenAt timestamp.

Khóa Node & ExpressJS của F8 cover phần Redis caching và session management khá chi tiết nếu bạn muốn nền tảng vững hơn trước khi implement adapter này.

Trang web dùng cookie để hoạt động và đo hiệu quả quảng cáo. Xem chính sách bảo mật