Đ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

Socket.io Realtime Chat App: Rooms, Broadcasting và Scale Với Redis Cho Dự Án Việt

Ếch Trendy
Ếch Trendy

5 tháng trước · 11 phút đọc

Socket.io là gì và tại sao dùng cho dự án Việt?

Bạn có bao giờ build một tính năng chat rồi phải tự hỏi: "Tại sao polling mỗi 2 giây mà vẫn delay 3 giây?" Đó là lúc bạn cần WebSocket - và Socket.io là cách nhanh nhất để làm điều đó mà không cần tự xử lý hàng chục edge case.

Socket.io là thư viện JavaScript cho phép giao tiếp bidirectional, realtime giữa client và server. Thay vì client phải hỏi server liên tục (polling), server có thể chủ động đẩy data xuống ngay khi có sự kiện. Phiên bản stable hiện tại là v4.8.1, đã ổn định và được dùng rộng rãi trong production.

343621 WebSocket giữ kết nối liên tục - khác hoàn toàn với HTTP request/response thông thường

Điểm hay là Socket.io không chỉ dùng WebSocket. Nếu mạng của người dùng chặn WebSocket (một số mạng nội bộ doanh nghiệp ở Việt Nam vẫn làm vậy), thư viện tự động fallback sang HTTP long-polling. Người dùng không biết gì, app vẫn chạy.

Các use case phổ biến trong dự án Việt:

  • E-learning: chat giữa học viên theo phòng học, thông báo realtime khi giảng viên reply
  • Hỗ trợ khách hàng: live chat multi-tenant cho nhiều shop trên cùng một platform
  • Collaborative tools: nhiều người cùng edit, xem trạng thái online

Socket.io giúp tiết kiệm đáng kể thời gian phát triển so với tự build WebSocket server từ đầu, nhờ ecosystem adapter sẵn (Redis, MongoDB, Postgres) và API đơn giản.


Setup server: Rooms và namespaces cho multi-tenant

Mình sẽ đi thẳng vào code. Project structure dùng Node.js + Express, không cần framework phức tạp ở bước này.

npm install socket.io express

Namespaces là cấp độ tách biệt cao nhất - như các "kênh" riêng, mỗi namespace có event handler độc lập. Rooms là nhóm con bên trong namespace, client có thể join/leave tự do.

Ví dụ cho platform e-learning: mỗi khóa học là một namespace, mỗi buổi học là một room.

// server.js
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');

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

const io = new Server(httpServer, {
  cors: {
    origin: process.env.CLIENT_URL || 'http://localhost:3000',
    methods: ['GET', 'POST']
  }
});

// Namespace cho module chat học viên
const chatNS = io.of('/chat');

chatNS.on('connection', (socket) => {
  console.log(`User connected: ${socket.id}`);

  // Client join vào room (ví dụ: roomId = courseId)
  socket.on('join_room', (roomId) => {
    socket.join(roomId);
    // Thông báo cho tất cả trong room (trừ người mới vào)
    socket.to(roomId).emit('user_joined', {
      userId: socket.data.userId,
      roomId
    });
  });

  // Gửi message vào room
  socket.on('send_message', ({ roomId, message }) => {
    // Broadcast tới tất cả trong room (kể cả sender)
    chatNS.to(roomId).emit('receive_message', {
      userId: socket.data.userId,
      message,
      timestamp: Date.now()
    });
  });

  socket.on('leave_room', (roomId) => {
    socket.leave(roomId);
    socket.to(roomId).emit('user_left', { userId: socket.data.userId });
  });

  socket.on('disconnect', () => {
    console.log(`User disconnected: ${socket.id}`);
  });
});

httpServer.listen(3001, () => console.log('Server running on :3001'));

343622 Namespace /chat chứa nhiều rooms - tách biệt hoàn toàn với namespace khác

Broadcasting patterns cần nhớ:

  • io.emit('event', data) - gửi cho tất cả client đang kết nối
  • socket.broadcast.emit('event', data) - gửi cho tất cả trừ người gửi
  • io.to('roomId').emit('event', data) - gửi cho tất cả trong room
  • socket.to('roomId').emit('event', data) - gửi room nhưng trừ người gửi

Pattern cuối dùng nhiều nhất trong chat app - bạn không muốn message của mình xuất hiện hai lần (đã render optimistically ở client rồi).


JWT authentication với Socket.io middleware

Chat app mà không có auth thì ai cũng vào được. Vấn đề ở đâu? Client kết nối Socket.io trước khi bạn kịp verify họ là ai.

Socket.io có middleware system cho phép chặn kết nối ngay tại handshake - trước khi connection event fire. Đây là chỗ verify JWT.

// middleware/auth.js
const jwt = require('jsonwebtoken');

const JWT_SECRET = process.env.JWT_SECRET;

function socketAuthMiddleware(socket, next) {
  // Client gửi token qua socket.handshake.auth
  const token = socket.handshake.auth?.token;

  if (!token) {
    return next(new Error('Thiếu token xác thực'));
  }

  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    // Gắn user data vào socket để dùng sau
    socket.data.userId = decoded.userId;
    socket.data.username = decoded.username;
    socket.data.role = decoded.role;
    next();
  } catch (err) {
    next(new Error('Token không hợp lệ hoặc đã hết hạn'));
  }
}

module.exports = socketAuthMiddleware;
// server.js - áp dụng middleware
const socketAuthMiddleware = require('./middleware/auth');

// Áp dụng cho namespace /chat
chatNS.use(socketAuthMiddleware);

// Giờ trong handler, dùng socket.data trực tiếp
chatNS.on('connection', (socket) => {
  console.log(`${socket.data.username} đã kết nối`);
  // socket.data.userId, socket.data.role đều có sẵn
});

343623 Token verify tại handshake - kết nối bị từ chối trước khi vào server

Phía client React gửi token khi khởi tạo kết nối:

import { io } from 'socket.io-client';

// Lấy token từ localStorage hoặc cookie
const token = localStorage.getItem('access_token');

const socket = io('http://localhost:3001/chat', {
  auth: { token },
  // Nếu token hết hạn, tự retry với token mới
  retries: 3
});

socket.on('connect_error', (err) => {
  if (err.message === 'Token không hợp lệ hoặc đã hết hạn') {
    // Refresh token rồi reconnect
    refreshTokenAndReconnect(socket);
  }
});

Một lưu ý thực tế: JWT token có expiry. Bạn cần handle trường hợp token hết hạn khi người dùng đang chat. Pattern phổ biến là dùng auth option kết hợp với refresh token flow - khi connect_error do token expired, gọi refresh endpoint rồi update socket.auth.tokensocket.connect() lại.


Tích hợp React + Next.js App Router

Đây là phần hay gây nhầm lẫn nhất. Next.js App Router chạy nhiều thứ trên server bằng default, nhưng Socket.io client lại cần window - chỉ có ở browser.

Cách 1: Custom server (recommended cho production)

Với Next.js, cách clean nhất là tách Socket.io server ra file riêng, chạy song song:

// server.ts (chạy song song với Next.js)
import { createServer } from 'http';
import next from 'next';
import { Server } from 'socket.io';

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handler = app.getRequestHandler();

app.prepare().then(() => {
  const httpServer = createServer(handler);
  const io = new Server(httpServer, {
    cors: { origin: process.env.CLIENT_URL }
  });

  // Setup namespaces và rooms như phần trước
  setupSocketHandlers(io);

  httpServer.listen(3000);
});

Cách 2: Custom hook cho React client

// hooks/useSocket.ts
import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';

interface Message {
  userId: string;
  username: string;
  message: string;
  timestamp: number;
}

export function useSocket(roomId: string, token: string) {
  const socketRef = useRef<Socket | null>(null);
  const [messages, setMessages] = useState<Message[]>([]);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    // Guard: chỉ chạy ở browser
    if (typeof window === 'undefined') return;

    const socket = io(
      process.env.NEXT_PUBLIC_SOCKET_URL + '/chat',
      { auth: { token } }
    );
    socketRef.current = socket;

    socket.on('connect', () => {
      setIsConnected(true);
      socket.emit('join_room', roomId);
    });

    socket.on('receive_message', (msg: Message) => {
      setMessages(prev => [...prev, msg]);
    });

    socket.on('disconnect', () => setIsConnected(false));

    // Cleanup khi unmount
    return () => {
      socket.emit('leave_room', roomId);
      socket.disconnect();
    };
  }, [roomId, token]);

  const sendMessage = (message: string) => {
    socketRef.current?.emit('send_message', { roomId, message });
  };

  return { messages, isConnected, sendMessage };
}

343624 Custom hook encapsulate toàn bộ socket logic - component chỉ cần gọi useSocket

// components/ChatRoom.tsx - dùng hook
'use client'; // BẮT BUỘC cho App Router

import { useSocket } from '@/hooks/useSocket';

export default function ChatRoom({ roomId, token }: { roomId: string; token: string }) {
  const { messages, isConnected, sendMessage } = useSocket(roomId, token);
  const [input, setInput] = useState('');

  return (
    <div className="flex flex-col h-screen">
      <div className="p-2 text-sm text-gray-500">
        {isConnected ? '🟢 Đang kết nối' : '🔴 Mất kết nối'}
      </div>
      <div className="flex-1 overflow-y-auto p-4 space-y-2">
        {messages.map((msg, i) => (
          <div key={i} className="bg-gray-100 rounded p-2">
            <span className="font-bold">{msg.username}: </span>
            {msg.message}
          </div>
        ))}
      </div>
      <div className="flex gap-2 p-4">
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => e.key === 'Enter' && (sendMessage(input), setInput(''))}
          className="flex-1 border rounded px-3 py-2"
          placeholder="Nhập tin nhắn..."
        />
        <button
          onClick={() => { sendMessage(input); setInput(''); }}
          className="bg-blue-500 text-white px-4 py-2 rounded"
        >
          Gửi
        </button>
      </div>
    </div>
  );
}

Nếu bạn mới làm quen React, khóa Xây Dựng Website với ReactJS cover đủ hooks và component pattern để hiểu đoạn code trên. Còn nếu muốn đi sâu vào Node.js phía server, khóa Node & ExpressJS cũng rất hữu ích.


Scale với Redis adapter và deploy production

Local chạy một process thì Socket.io hoạt động ngon. Vấn đề xuất hiện khi bạn scale lên 2-3 server instance: client A kết nối server 1, client B kết nối server 2, message A gửi vào room không đến được B vì hai server không biết nhau.

Redis adapter giải quyết bằng cách dùng Redis pub/sub làm message broker giữa các instances.

npm install @socket.io/redis-adapter ioredis
// server.js - thêm Redis adapter
const { createClient } = require('redis'); // hoặc ioredis
const { createAdapter } = require('@socket.io/redis-adapter');

async function setupRedisAdapter(io) {
  // Dùng Upstash Redis trên Vercel hoặc Redis tự host
  const pubClient = createClient({
    url: process.env.REDIS_URL // redis://... hoặc rediss://...
  });
  const subClient = pubClient.duplicate();

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

  io.adapter(createAdapter(pubClient, subClient));
  console.log('Redis adapter đã kết nối');
}

// Trong main function
await setupRedisAdapter(io);

343625 Redis làm cầu nối giữa các server - message từ instance 1 broadcast được tới client ở instance 2

Sau khi có Redis adapter, toàn bộ io.to('room').emit() tự động sync qua tất cả instances mà không cần thay đổi code logic.

Deploy thực tế cho dự án Việt:

Vercel serverless không support persistent WebSocket connection. Options thực tế:

  1. Render.com - free tier, support Node.js server, deploy dễ với render.yaml
  2. Railway - $5/tháng, tương tự Render nhưng UI cleaner
  3. VPS DigitalOcean/Vultr - tự manage, $6/tháng, dùng PM2 + Nginx

Nếu bạn nhất quyết dùng Vercel cho Next.js frontend, deploy Socket.io server riêng trên Render/Railway rồi point NEXT_PUBLIC_SOCKET_URL vào đó.

# render.yaml
services:
  - type: web
    name: chat-socket-server
    env: node
    buildCommand: npm install
    startCommand: node server.js
    envVars:
      - key: REDIS_URL
        fromService:
          type: redis
          name: chat-redis
          property: connectionString

databases:
  - name: chat-redis
    type: redis

Fallback polling tự động hoạt động với config mặc định của Socket.io - không cần làm gì thêm. Nhưng nếu muốn tối ưu:

const io = new Server(httpServer, {
  // Bắt đầu bằng polling, upgrade lên WebSocket
  transports: ['polling', 'websocket'],
  // Hoặc chỉ dùng WebSocket (nếu biết user không bị firewall chặn)
  // transports: ['websocket']
});

Takeaways:

  • Một server instance thì không cần Redis - đừng over-engineer
  • Redis adapter dùng version @socket.io/redis-adapter@^8.x với Socket.io v4.x
  • Upstash Redis free tier đủ dùng cho MVP - 10,000 requests per day (dev.to article: From Zero to Cached) request/ngày
  • Monitor io.engine.clientsCount để biết khi nào cần scale thêm

Checklist và bước tiếp theo

Mình đã build đủ prototype chat app theo kiểu này cho vài dự án - phần hay bị bỏ quên nhất không phải technical mà là rate limiting và abuse prevention.

Một user gửi 1000 message/giây là socket.io vẫn forward hết. Thêm rate limit đơn giản bằng middleware:

// Rate limiting đơn giản với Map
const rateLimiter = new Map();

chatNS.use((socket, next) => {
  const userId = socket.data.userId;
  const now = Date.now();
  const userRecord = rateLimiter.get(userId) || { count: 0, resetAt: now + 60000 };

  if (now > userRecord.resetAt) {
    userRecord.count = 0;
    userRecord.resetAt = now + 60000; // Reset mỗi phút
  }

  if (userRecord.count > 60) { // Tối đa 60 msg/phút
    return next(new Error('Gửi tin quá nhanh, vui lòng thử lại'));
  }

  userRecord.count++;
  rateLimiter.set(userId, userRecord);
  next();
});

343626 Checklist build chat app production-ready từ dev đến deploy

Bước tiếp theo nếu muốn đưa lên production:

  1. Setup server: Node.js + Express + Socket.io v4.x, namespace /chat
  2. Auth middleware: verify JWT tại handshake, gắn socket.data.userId
  3. Rooms logic: join_room, send_message, leave_room events
  4. React client: custom hook useSocket với cleanup trong useEffect
  5. Redis adapter: thêm khi cần scale lên 2+ instances (đừng làm sớm)
  6. Rate limiting: tối thiểu 60 msg/phút/user
  7. Deploy: Render/Railway cho socket server, Vercel cho Next.js frontend

Download checklist đầy đủ tại đây: Tải checklist Socket.io Chat App

Gặp vướng mắc phần nào, drop comment. Phần hay bị hỏi nhất là CORS config và JWT refresh khi đang chat - mình sẽ reply.