Đ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 Realtime Tính Năng Live Cho Web App

Ếch Trendy
Ếch Trendy

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

Tại sao realtime lại quan trọng

Hôm nay, người dùng không chịu chờ đợi. Chat app phải update ngay, dashboard phải hiển thị dữ liệu sống. Nếu dùng HTTP polling (gọi server mỗi 500ms), bạn sẽ phí dữ liệu và server sẽ chết. Realtime là yêu cầu, không phải tính năng bonus.

WebSocket và Socket.io là 2 công cụ chính để làm điều này. Nhưng chúng khác nhau, và việc chọn sai sẽ dẫn tới production nightmare.

344485 HTTP polling (cũ) vs WebSocket (mới) - chênh lệch overhead rõ ràng

Bài này mình sẽ giải thích từng công cụ, cách chọn, cách deploy, và các lỗi thường gặp khi triển khai realtime.


WebSocket thuần: Cơ bản nhưng đơn giản

WebSocket là protocol HTTP upgrade. Client gửi request Upgrade, server chấp nhận, đóng kết nối HTTP, mở kết nối TCP hai chiều.

Uu điểm:

  • Nhẹ, spec tiêu chuẩn W3C
  • Latency cực kỳ thấp (10-20ms)
  • Server có thể push data xuống client mà không cần client gửi request trước

Nhược điểm:

  • Không tự động reconnect nếu mất kết nối
  • Không có concept "room" - phải tự quản lý
  • Không xử lý fallback nếu server không support WebSocket
  • Scaling khó: nếu có 2 server, kết nối từ client A trên server 1 không biết client B trên server 2

Code WebSocket đơn giản:

// Server (Node + ws library)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws) => {
  console.log('Client connect');
  
  ws.on('message', (message) => {
    // Broadcast tới tất cả client
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(message);
      }
    });
  });
  
  ws.on('close', () => {
    console.log('Client disconnect');
  });
});

344486 Server phải tự quản lý broadcast nếu không dùng Socket.io - rất dễ sai lầm

// Client
const ws = new WebSocket('ws://localhost:8080');

ws.onmessage = (event) => {
  console.log('Message:', event.data);
};

ws.send('Hello Server');

Nhìn có vẻ đơn giản, nhưng vấn đề bắt đầu khi:

  1. Client disconnect đột ngột (WiFi mất) - server vẫn giữ connection 30 giây trước khi detect
  2. Muốn gửi message chỉ cho user X, không phải broadcast tất cả
  3. Deploy 2 server, 2 database - connection từ client A trên server 1 gửi message, client B trên server 2 không nhận được

Dây là lúc Socket.io vào cuộc.


Socket.io: WebSocket + nhiều tiện ích

Socket.io là thư viện wrapper trên WebSocket. Nó không tự implement WebSocket mà dùng nó nếu browser support, còn fallback sang long-polling hoặc polling.

Thêm vào đó:

  • Tự động reconnect (exponential backoff)
  • Event-driven API (dễ dùng hơn message string thuần)
  • Room & namespace (quản lý group client)
  • Middleware support
  • Message acknowledgment
  • Broadcasting helpers

Code Socket.io:

// Server
const express = require('express');
const http = require('http');
const socketIO = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = socketIO(server, {
  cors: { origin: '*' }
});

io.on('connection', (socket) => {
  console.log('User', socket.id, 'connected');
  
  // User join room
  socket.on('join_room', (roomId) => {
    socket.join(roomId);
    io.to(roomId).emit('user_joined', socket.id);
  });
  
  // Send message to room
  socket.on('send_message', (roomId, message) => {
    io.to(roomId).emit('new_message', {
      from: socket.id,
      text: message
    });
  });
  
  socket.on('disconnect', () => {
    console.log('User', socket.id, 'disconnected');
  });
});

server.listen(3000);

344487 Socket.io server: join room, emit to room, clean API

// Client
const socket = io('http://localhost:3000');

socket.on('connect', () => {
  console.log('Connected');
  socket.emit('join_room', 'chat-room-1');
});

socket.on('new_message', (data) => {
  console.log('Message from', data.from, ':', data.text);
});

socket.emit('send_message', 'chat-room-1', 'Hello!');

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

Bạn thấy khác biệt không? Socket.io có join(), to(), event name thay vì raw string. Dễ manage hơn.

Với WebSocket thuần, nếu muốn join room, bạn phải:

  1. Client send message {type: 'join_room', roomId: 'xxx'}
  2. Server parse message, check type
  3. Server quản lý object {roomId: [clientList]}
  4. Khi broadcast, loop qua list này

Socket.io làm hết điều này cho bạn. Trade-off? Thêm một lượng metadata nhỏ bytes overhead vì Socket.io gửi kèm metadata.


Room, Namespace, Reconnect: Socket.io nâng cao

Socket.io có 3 concept quan trọng.

Room: Một tập hợp socket bên trong namespace. Dùng socket.join(room), io.to(room).emit(event, data).

// User A join phòng 'office-chat'
socket.join('office-chat');

// User B send message tới phòng
socket.emit('send_message', {
  room: 'office-chat',
  text: 'Hello office'
});

// Server: chỉ gửi tới user trong 'office-chat'
server.to('office-chat').emit('new_message', {...});

Namespace: Như subdomain socket. Dùng khi có nhiều feature độc lập (chat, notification, dashboard).

// Server
const chatNS = io.of('/chat');
const notifyNS = io.of('/notify');

chatNS.on('connection', (socket) => {
  socket.on('message', (data) => {
    chatNS.emit('broadcast', data);
  });
});

notifyNS.on('connection', (socket) => {
  socket.on('notify', (data) => {
    notifyNS.emit('broadcast', data);
  });
});

// Client
const chatSocket = io('http://localhost:3000/chat');
const notifySocket = io('http://localhost:3000/notify');

Reconnect: Socket.io tự động reconnect với exponential backoff (1s, 2s, 4s, ..., max 1 phút).

const socket = io({
  reconnection: true,
  reconnectionDelay: 1000,
  reconnectionDelayMax: 5000,
  reconnectionAttempts: 5
});

socket.on('reconnect', () => {
  console.log('Reconnected');
  // Re-join room, re-sync data
  socket.emit('rejoin_room', roomId);
});

344488 3 concept: room (nhóm trong namespace), namespace (subdomain), reconnect (tự động kết nối lại)

Nhưng có 1 vấn đề: Nếu client disconnect 5 phút, server sẽ xóa session của user đó. Khi reconnect, user phải login lại. Cách xử lý:

  1. Client cache sessionId/token
  2. Server check token, restore session nếu còn valid
  3. Client emit 'rejoin' event

Scaling realtime: Red is, cluster, queue

Khi có 1000+ concurrent user, 1 server không đủ. Vấn đề: user A connect server 1, user B connect server 2. Khi A send message, B không nhận vì message chỉ tới server 1.

Giải pháp: Adapter.

Socket.io mặc định dùng in-memory adapter. Có thể swap sang Redis:

const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');

const pubClient = createClient({ host: 'localhost', port: 6379 });
const subClient = pubClient.duplicate();

Promise.all([
  pubClient.connect(),
  subClient.connect()
]).then(() => {
  io.adapter(createAdapter(pubClient, subClient));
});

Now, message từ server 1 sẽ publish tới Redis, server 2 subscribe và forward tới user. Tất cả server như 1 hệ thống.

344489 Cấu trúc scaling: 2 server, 1 Redis pub/sub - message từ server 1 tới server 2 qua Redis

Alternative: Message queue (RabbitMQ, Kafka). Nhưng Socket.io Redis adapter đã đủ cho 90% use case.

Vấn đề khác: Persistent message. Nếu user offline 5 phút, họ nhận không các message lúc offline. Giải pháp:

  1. Database: lưu message, khi user online, query message từ lúc họ offline
  2. Message queue: RabbitMQ dead letter queue, keep message 7 ngày

Mình recommend cách 1 (database) vì simple và cost thấp.


Lỗi thường gặp khi triển khai realtime

1. Memory leak từ event listener

Khi user disconnect, bạn quên remove listener.

// ❌ Sai
socket.on('message', (data) => {
  console.log(data);
  // Nếu user disconnect, listener vẫn tồn tại
});

// ✅ Đúng
const handleMessage = (data) => {
  console.log(data);
};

socket.on('message', handleMessage);

socket.on('disconnect', () => {
  socket.removeListener('message', handleMessage);
});

Hoặc đơn giản, Socket.io tự động clean khi user disconnect, nên không cần lo.

2. Lặp vô hạn emit

User A send message → Server broadcast → User A nhận → emit lại → broadcast → lặp vô hạn.

// ❌ Sai
socket.on('message', (data) => {
  io.emit('message', data); // Broadcast lại, user sẽ nhận, emit lại
});

// ✅ Đúng
socket.on('message', (data) => {
  socket.broadcast.emit('message', data); // Chỉ gửi tới user khác
});

3. Không xử lý connection timeout

Server mất kết nối nhưng client chưa detect. Timeout setting:

const io = socketIO(server, {
  pingInterval: 25000, // Server ping client mỗi 25s
  pingTimeout: 60000   // Nếu client không pong trong 60s, disconnect
});

4. Không validate message từ client

// ❌ Sai
socket.on('message', (data) => {
  io.emit('message', data); // data có thể là anything
});

// ✅ Đúng
socket.on('message', (data) => {
  if (typeof data.text !== 'string' || data.text.length > 1000) {
    return; // Reject
  }
  io.to(data.roomId).emit('message', data);
});

Bạn đã bao giờ tìm thấy chat app bị spam 10000 message trong 1 giây chưa? Đó là lý do.

344490 Một đoạn code nhỏ validate có thể chặn được 90% attack

5. Không rate limit

Thêm rate limit vào socket event:

const rateLimit = {};

socket.on('message', (data) => {
  const userId = socket.id;
  rateLimit[userId] = rateLimit[userId] || 0;
  
  if (rateLimit[userId] > 10) { // 10 message per period
    socket.disconnect(true);
    return;
  }
  
  rateLimit[userId]++;
  setTimeout(() => rateLimit[userId]--, 1000); // Reset mỗi 1s
  
  io.to(data.roomId).emit('message', data);
});

Hoặc dùng library: socket.io-rate-limit.

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