Thông báo thời gian thực nhẹ nhàng: Làm chủ Server-Sent Events (SSE) trong Node.js

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Sự cồng kềnh của giao tiếp Full-Duplex

Hầu hết các lập trình viên đều theo bản năng chọn WebSockets ngay khi yêu cầu dự án nhắc tới cụm từ “thời gian thực” (real-time). Tôi cũng từng như vậy. Chúng ta thường hình dung về một đường ống hai chiều duy trì liên tục, nơi dữ liệu luân chuyển tự do giữa client và server. Nghe thì có vẻ hoàn hảo trên lý thuyết, nhưng trong thực tế, đối với các tính năng như thông báo, luồng hoạt động (activity feeds) hay bảng điều khiển trực tiếp, WebSockets thường mang lại nhiều sự phức tạp hơn là giải quyết vấn đề.

Khi xây dựng hệ thống thông báo cho một nền tảng thương mại điện tử có lưu lượng truy cập cao, ban đầu tôi đã chọn giải pháp WebSockets. Rất nhanh sau đó, tôi gặp phải các rào cản: quản lý heartbeat để duy trì kết nối, xử lý logic kết nối lại phức tạp ở phía client, và cấu hình Nginx để xử lý đúng việc nâng cấp giao thức từ HTTP sang WS. Đội ngũ hạ tầng của tôi lo ngại về mức chiếm dụng bộ nhớ khi phải duy trì hàng ngàn kết nối hai chiều ở trạng thái rảnh rỗi. Chúng tôi đã dùng dao mổ trâu để giết gà.

Tại sao chúng ta làm phức tạp hóa các tính năng thời gian thực

Nguyên nhân gốc rễ của sự phức tạp này là sự hiểu lầm về nhu cầu giao tiếp. Hầu hết các tính năng thời gian thực trong ứng dụng web đều là đơn hướng (unidirectional). Một thông báo xảy ra trên server — một tin nhắn mới, một khoản thanh toán đã xử lý, một thay đổi trạng thái — và nó cần được đẩy tới người dùng. Người dùng hiếm khi cần gửi ngược dữ liệu qua chính kết nối duy trì đó.

WebSockets được thiết kế cho giao tiếp hai chiều, độ trễ thấp (như trò chơi nhiều người chơi hoặc trình chỉnh sửa tài liệu cộng tác). Sử dụng chúng cho các thông báo đơn giản buộc bạn phải quản lý một giao thức full-duplex trong khi một luồng (stream) đơn giản là đã đủ. Hơn nữa, WebSockets không tuân theo ngữ nghĩa HTTP tiêu chuẩn; chúng yêu cầu các cấu hình proxy đặc thù và có thể bị chặn bởi một số tường lửa hoặc proxy doanh nghiệp không nhận diện được header Upgrade.

Server-Sent Events: Ứng cử viên nhẹ ký

Server-Sent Events (SSE) mang lại một hướng đi gọn gàng hơn nhiều. Không giống như WebSockets, SSE hoạt động hoàn toàn trên nền tảng HTTP tiêu chuẩn. Nó sử dụng một kết nối HTTP lâu dài (long-lived), nơi server giữ phản hồi mở và gửi dữ liệu theo một định dạng cụ thể (text/event-stream).

Vẻ đẹp của SSE nằm ở sự đơn giản. Vì bản chất là HTTP, nó hoạt động ngay lập tức với các bộ cân bằng tải (load balancers), tường lửa và logic xác thực hiện có của bạn.

API EventSource của trình duyệt sẽ xử lý phần khó chịu nhất trong lập trình thời gian thực: tự động kết nối lại. Nếu kết nối bị ngắt, trình duyệt sẽ tự thử kết nối lại mà bạn không cần viết bất kỳ dòng code setInterval hay logic backoff nào. Tôi đã áp dụng phương pháp này vào môi trường production và kết quả luôn ổn định, đặc biệt là về mặt quản lý tài nguyên và dễ bảo trì.

Thực hành: Xây dựng hệ thống thông báo với Node.js

Để chứng minh sự đơn giản này, hãy cùng xây dựng một server thông báo tối giản bằng Node.js và Express. Chúng ta sẽ tạo một endpoint để client đăng ký nhận tin và một endpoint phụ để “kích hoạt” thông báo.

1. Thiết lập Server

Đầu tiên, khởi tạo dự án và cài đặt Express:

mkdir sse-notifications
cd sse-notifications
npm init -y
npm install express

Bây giờ, hãy tạo file server.js. Script này quản lý danh sách các client đang kết nối và đẩy dữ liệu tới họ khi có sự kiện mới xảy ra.

const express = require('express');
const app = express();
const PORT = 3000;

app.use(express.json());
app.use(express.static('public'));

// Lưu trữ các kết nối client đang hoạt động
let clients = [];

app.get('/events', (req, res) => {
    // Các header bắt buộc cho SSE
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    // Đăng ký client
    const clientId = Date.now();
    const newClient = {
        id: clientId,
        res
    };
    clients.push(newClient);

    // Xóa client khi kết nối đóng
    req.on('close', () => {
        clients = clients.filter(client => client.id !== clientId);
    });
});

// Endpoint để kích hoạt thông báo
app.post('/notify', (req, res) => {
    const message = req.body.message || 'Thông báo mới!';
    
    // Gửi dữ liệu đến tất cả các client đã kết nối
    clients.forEach(client => {
        client.res.write(`data: ${JSON.stringify({ message, time: new Date() })}\n\n`);
    });

    res.status(200).send({ success: true });
});

app.listen(PORT, () => {
    console.log(`Server SSE đang chạy tại http://localhost:${PORT}`);
});

2. Tạo Client

Việc triển khai phía client còn đơn giản hơn nữa. Chúng ta không cần bất kỳ thư viện bên ngoài nào; API EventSource đã có sẵn trong tất cả các trình duyệt hiện đại.

Tạo file public/index.html:

<!DOCTYPE html>
<html lang="vi">
<head>
    <meta charset="UTF-8">
    <title>Thông báo SSE</title>
</head>
<body>
    <h1>Thông báo trực tiếp</h1>
    <ul id="notif-list"></ul>

    <script>
        const eventSource = new EventSource('/events');
        const list = document.getElementById('notif-list');

        eventSource.onmessage = (event) => {
            const data = JSON.parse(event.data);
            const li = document.createElement('li');
            li.textContent = `[${new Date(data.time).toLocaleTimeString()}] ${data.message}`;
            list.prepend(li);
        };

        eventSource.onerror = (err) => {
            console.error("EventSource gặp lỗi:", err);
        };
    </script>
</body>
</html>

3. Kiểm thử hệ thống

Khởi động server của bạn:

node server.js

Mở http://localhost:3000 trên nhiều tab trình duyệt khác nhau. Sau đó, sử dụng curl hoặc một công cụ như Postman để kích hoạt thông báo:

curl -X POST http://localhost:3000/notify \
     -H "Content-Type: application/json" \
     -d '{"message": "Xin chào từ server!"}'

Bạn sẽ thấy thông báo xuất hiện ngay lập tức trên tất cả các tab đang mở. Không cần handshake phức tạp, không cần quản lý socket, chỉ đơn giản là luồng dữ liệu HTTP thuần túy.

Khả năng mở rộng và Thực tế triển khai

Khi đưa SSE vào môi trường production, có hai điều chính mà tôi luôn lưu ý. Đầu tiên là giới hạn kết nối của trình duyệt. Hầu hết các trình duyệt giới hạn số lượng kết nối HTTP/1.1 mở tới cùng một domain là 6. Nếu người dùng mở 7 tab ứng dụng của bạn, tab thứ 7 sẽ không thể kết nối. Giải pháp rất đơn giản: sử dụng HTTP/2. Với HTTP/2, các kết nối này được đa luồng hóa (multiplexed), cho phép nhiều luồng dữ liệu hơn trên một kết nối TCP duy nhất.

Cân nhắc thứ hai là duy trì kết nối trong thời gian rảnh. Một số proxy hoặc bộ cân bằng tải có thể ngắt một kết nối HTTP “rảnh rỗi” nếu không có dữ liệu nào được gửi trong vòng 30 hoặc 60 giây. Mẹo của tôi là triển khai một cơ chế “heartbeat” — gửi một dòng comment nhỏ (: keep-alive\n\n) mỗi 15 giây. SSE bỏ qua các dòng bắt đầu bằng dấu hai chấm, vì vậy nó giữ cho đường ống luôn mở mà không kích hoạt bất kỳ sự kiện nào ở phía client.

Lựa chọn công cụ phù hợp

WebSockets không hề lỗi thời, nhưng chúng mang tính chuyên dụng. Nếu bạn đang xây dựng một ứng dụng chat nơi người dùng liên tục gửi tin nhắn qua lại, hoặc một bảng trắng cộng tác thời gian thực, hãy trung thành với WebSockets. Sự cồng kềnh của nó là hoàn toàn xứng đáng trong trường hợp đó.

Tuy nhiên, đối với 90% nhu cầu thời gian thực — như thông báo cho người dùng rằng báo cáo của họ đã sẵn sàng, cập nhật thanh tiến trình hoặc đẩy thông tin thay đổi giá trực tiếp — SSE là lựa chọn kiến trúc vượt trội. Nó dễ debug hơn (bạn có thể thấy luồng dữ liệu trong tab Network của Chrome DevTools), dễ mở rộng hơn và triển khai đơn giản hơn nhiều. Lần tới khi bạn có ý định tìm đến Socket.io, hãy xem lại yêu cầu của mình. Nếu luồng dữ liệu chủ yếu là một chiều, hãy tự cứu mình khỏi những phiền toái và sử dụng Server-Sent Events.

Share: