Truyền trực tuyến bản cập nhật với các sự kiện do máy chủ gửi

Sự kiện do máy chủ gửi (SSE) gửi bản cập nhật tự động đến ứng dụng từ máy chủ thông qua kết nối HTTP. Sau khi thiết lập kết nối, máy chủ có thể bắt đầu truyền dữ liệu.

Bạn nên sử dụng SSE để gửi thông báo đẩy từ ứng dụng web. SSE gửi thông tin theo một hướng, do đó bạn sẽ không nhận được thông tin cập nhật từ ứng dụng.

Khái niệm SSE có thể đã quen thuộc. Một ứng dụng web "đăng ký" nhận luồng cập nhật do máy chủ tạo và mỗi khi có sự kiện mới xảy ra, thông báo sẽ được gửi đến máy khách. Nhưng để thực sự hiểu được các sự kiện do máy chủ gửi, chúng ta cần hiểu những hạn chế của các phiên bản AJAX trước. Trong đó có:

  • Thăm dò ý kiến: Ứng dụng liên tục thăm dò máy chủ để lấy dữ liệu. Kỹ thuật này được phần lớn các ứng dụng AJAX sử dụng. Với giao thức HTTP, việc tìm nạp dữ liệu xoay quanh định dạng yêu cầu và phản hồi. Ứng dụng đưa ra yêu cầu và chờ máy chủ phản hồi bằng dữ liệu. Nếu không có kết quả nào, thì một phản hồi trống sẽ được trả về. Việc thăm dò ý kiến bổ sung sẽ làm tăng mức hao tổn HTTP.

  • Thăm dò kéo dài (Hanging GET / COMET): Nếu không có dữ liệu, máy chủ sẽ giữ yêu cầu mở cho đến khi có dữ liệu mới. Do đó, kỹ thuật này thường được gọi là "Hanging GET". Khi có thông tin, máy chủ sẽ phản hồi, đóng kết nối và quá trình này sẽ lặp lại. Do đó, máy chủ liên tục phản hồi với dữ liệu mới. Để thiết lập tính năng này, các nhà phát triển thường sử dụng các hình thức tấn công, chẳng hạn như thêm thẻ tập lệnh vào iframe "vô hạn".

Các sự kiện do máy chủ gửi đã được thiết kế từ đầu để mang lại hiệu quả. Khi giao tiếp với SSE, máy chủ có thể đẩy dữ liệu đến ứng dụng của bạn bất cứ khi nào muốn mà không cần đưa ra yêu cầu ban đầu. Nói cách khác, nội dung cập nhật có thể được truyền trực tuyến từ máy chủ này đến máy khách khác khi chúng diễn ra. Các SSE mở một kênh một chiều giữa máy chủ và ứng dụng khách.

Điểm khác biệt chính giữa sự kiện do máy chủ gửi và cuộc thăm dò ý kiến lâu là SSE được trình duyệt xử lý trực tiếp và người dùng chỉ phải lắng nghe thông báo.

Sự kiện do máy chủ gửi so với WebSockets

Tại sao bạn nên chọn sự kiện do máy chủ gửi thay vì WebSockets? Đây là một câu hỏi hay.

WebSockets có giao thức phong phú với khả năng giao tiếp hai chiều, song công. Kênh hai chiều phù hợp hơn cho các trò chơi, ứng dụng nhắn tin, cũng như mọi trường hợp sử dụng mà bạn cần có bản cập nhật gần như theo thời gian thực ở cả hai hướng.

Tuy nhiên, đôi khi bạn chỉ cần giao tiếp một chiều từ máy chủ. Ví dụ: khi một người bạn cập nhật trạng thái, mã cổ phiếu, nguồn cấp tin tức hoặc các cơ chế đẩy dữ liệu tự động khác. Nói cách khác, đó là bản cập nhật cho một Cơ sở dữ liệu Web SQL phía máy khách hoặc kho lưu trữ đối tượng IndexedDB. Nếu bạn cần gửi dữ liệu đến máy chủ, XMLHttpRequest sẽ luôn là một người bạn.

SSE được gửi qua HTTP. Bạn không cần phải triển khai giao thức hoặc máy chủ đặc biệt nào để hoạt động. WebSocket cần có kết nối song công hoàn toàn và máy chủ WebSocket mới để xử lý giao thức.

Ngoài ra, các sự kiện do máy chủ gửi có nhiều tính năng mà WebSockets không có trong thiết kế, bao gồm khả năng kết nối lại tự động, mã sự kiện và khả năng gửi các sự kiện tuỳ ý.

Tạo EventSource bằng JavaScript

Để đăng ký một luồng sự kiện, hãy tạo đối tượng EventSource và truyền đối tượng đó URL của luồng:

const source = new EventSource('stream.php');

Tiếp theo, hãy thiết lập một trình xử lý cho sự kiện message. Bạn có thể tuỳ ý nghe openerror:

source.addEventListener('message', (e) => {
  console.log(e.data);
});

source.addEventListener('open', (e) => {
  // Connection was opened.
});

source.addEventListener('error', (e) => {
  if (e.readyState == EventSource.CLOSED) {
    // Connection was closed.
  }
});

Khi các bản cập nhật được đẩy từ máy chủ, trình xử lý onmessage sẽ kích hoạt và dữ liệu mới sẽ có trong thuộc tính e.data. Điều kỳ diệu là bất cứ khi nào kết nối bị đóng, trình duyệt sẽ tự động kết nối lại với nguồn sau ~3 giây. Quá trình triển khai máy chủ của bạn thậm chí có thể kiểm soát thời gian chờ kết nối lại này.

Vậy là xong. Giờ đây, khách hàng của bạn có thể xử lý các sự kiện từ stream.php.

Định dạng luồng sự kiện

Việc gửi một luồng sự kiện từ nguồn liên quan đến việc tạo phản hồi văn bản thuần tuý, được phân phát bằng Content-Type text/event-stream, tuân theo định dạng SSE. Ở dạng cơ bản, phản hồi phải chứa dòng data:, theo sau là tin nhắn của bạn, sau đó là hai ký tự "\n" để kết thúc luồng:

data: My message\n\n

Dữ liệu nhiều dòng

Nếu thư dài hơn, bạn có thể chia nhỏ thư bằng cách sử dụng nhiều dòng data:. Hai hoặc nhiều dòng liên tiếp bắt đầu bằng data: được coi là một phần dữ liệu duy nhất, nghĩa là chỉ một sự kiện message được kích hoạt.

Mỗi dòng phải kết thúc bằng một "\n" (trừ dòng cuối cùng sẽ kết thúc bằng hai). Kết quả được chuyển đến trình xử lý message là một chuỗi đơn nối với các ký tự dòng mới. Ví dụ:

data: first line\n
data: second line\n\n</pre>

Thao tác này sẽ tạo ra "dòng đầu tiên\ndòng thứ hai" trong e.data. Sau đó, bạn có thể dùng e.data.split('\n').join('') để tạo lại thông báo không có ký tự "\n".

Gửi dữ liệu JSON

Việc sử dụng nhiều dòng sẽ giúp bạn gửi JSON mà không bị ngắt cú pháp:

data: {\n
data: "msg": "hello world",\n
data: "id": 12345\n
data: }\n\n

Và mã phía máy khách có thể dùng để xử lý luồng đó:

source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log(data.id, data.msg);
});

Liên kết mã nhận dạng với một sự kiện

Bạn có thể gửi mã nhận dạng duy nhất kèm theo sự kiện phát trực tuyến bằng cách bao gồm một dòng bắt đầu bằng id::

id: 12345\n
data: GOOG\n
data: 556\n\n

Việc đặt mã nhận dạng cho phép trình duyệt theo dõi sự kiện gần đây nhất được kích hoạt để nếu kết nối đến máy chủ bị gián đoạn, thì một tiêu đề HTTP đặc biệt (Last-Event-ID) sẽ được đặt cùng với yêu cầu mới. Điều này cho phép trình duyệt xác định sự kiện nào thích hợp để kích hoạt. Sự kiện message chứa một thuộc tính e.lastEventId.

Kiểm soát thời gian chờ kết nối lại

Trình duyệt sẽ cố gắng kết nối lại với nguồn khoảng 3 giây sau khi mỗi kết nối đóng. Bạn có thể thay đổi thời gian chờ đó bằng cách thêm một dòng bắt đầu bằng retry:, theo sau là số mili giây cần phải chờ trước khi thử kết nối lại.

Ví dụ sau đây sẽ thử kết nối lại sau 10 giây:

retry: 10000\n
data: hello world\n\n

Chỉ định tên sự kiện

Một nguồn sự kiện có thể tạo nhiều loại sự kiện bằng cách bao gồm tên sự kiện. Nếu có một dòng bắt đầu bằng event:, theo sau là tên duy nhất cho sự kiện, thì sự kiện sẽ được liên kết với tên đó. Trên ứng dụng, bạn có thể thiết lập trình nghe sự kiện để theo dõi sự kiện cụ thể đó.

Ví dụ: dữ liệu đầu ra sau đây của máy chủ sẽ gửi 3 loại sự kiện: sự kiện "message" chung, "userlogon" và "update":

data: {"msg": "First message"}\n\n
event: userlogon\n
data: {"username": "John123"}\n\n
event: update\n
data: {"username": "John123", "emotion": "happy"}\n\n

Khi trình nghe sự kiện được thiết lập trên máy khách:

source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log(data.msg);
});

source.addEventListener('userlogon', (e) => {
  const data = JSON.parse(e.data);
  console.log(`User login: ${data.username}`);
});

source.addEventListener('update', (e) => {
  const data = JSON.parse(e.data);
  console.log(`${data.username} is now ${data.emotion}`);
};

Ví dụ về máy chủ

Dưới đây là cách triển khai máy chủ cơ bản trong PHP:

<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache'); // recommended to prevent caching of event data.

/**
* Constructs the SSE data format and flushes that data to the client.
*
* @param string $id Timestamp/id of this connection.
* @param string $msg Line of text that should be transmitted.
**/

function sendMsg($id, $msg) {
  echo "id: $id" . PHP_EOL;
  echo "data: $msg" . PHP_EOL;
  echo PHP_EOL;
  ob_flush();
  flush();
}

$serverTime = time();

sendMsg($serverTime, 'server time: ' . date("h:i:s", time()));
?>

Dưới đây là cách triển khai tương tự trên Node JS bằng cách sử dụng trình xử lý Express:

app.get('/events', (req, res) => {
    // Send the SSE header.
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    // Sends an event to the client where the data is the current date,
    // then schedules the event to happen again after 5 seconds.
    const sendEvent = () => {
        const data = (new Date()).toLocaleTimeString();
        res.write("data: " + data + '\n\n');
        setTimeout(sendEvent, 5000);
    };

    // Send the initial event immediately.
    sendEvent();
});

sse-node.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <script>
    const source = new EventSource('/events');
    source.onmessage = (e) => {
        const content = document.createElement('div');
        content.textContent = e.data;
        document.body.append(content);
    };
    </script>
  </body>
</html>

Huỷ luồng sự kiện

Thông thường, trình duyệt sẽ tự động kết nối lại với nguồn sự kiện khi kết nối bị đóng, nhưng hành vi đó có thể bị huỷ từ ứng dụng hoặc máy chủ.

Để huỷ quá trình truyền trực tuyến từ ứng dụng, hãy gọi:

source.close();

Để huỷ một luồng từ máy chủ, hãy phản hồi bằng một Content-Type không phải text/event-stream hoặc trả về một trạng thái HTTP không phải 200 OK (chẳng hạn như 404 Not Found).

Cả hai phương pháp đều ngăn trình duyệt thiết lập lại kết nối.

Lưu ý về tính bảo mật

Các yêu cầu do EventSource tạo ra phải tuân thủ các chính sách cùng nguồn gốc như các API mạng khác như tìm nạp. Nếu bạn muốn có thể truy cập điểm cuối SSE trên máy chủ của mình qua nhiều nguồn gốc, hãy đọc cách bật tính năng Chia sẻ tài nguyên trên nhiều nguồn gốc (CORS).