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 máy khách 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.

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

  • Khảo sát: Ứng dụng liên tục thăm dò ý kiến 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 một định dạng yêu cầu và phản hồi. Ứng dụng khách gửi yêu cầu và chờ máy chủ phản hồi bằng dữ liệu. Nếu không có, hệ thống sẽ trả về một phản hồi trống. Việc thăm dò thêm sẽ tạo ra nhiều chi phí HTTP hơn.

  • Thăm dò ý kiến dài hạn (GET treo / 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à "GET bị treo". Khi có thông tin, máy chủ sẽ phản hồi, đóng kết nối và lặp lại quá trình này. Do đó, máy chủ liên tục phản hồi bằng 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 thủ thuật như thêm thẻ tập lệnh vào một iframe "vô hạn".

Sự kiện do máy chủ gửi được thiết kế từ đầu để hoạt động 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 tạo yêu cầu ban đầu. Nói cách khác, các bản cập nhật có thể được truyền trực tuyến từ máy chủ đến ứng dụng khi chúng diễn ra. SSE mở một kênh một chiều giữa máy chủ và ứng dụng.

Điểm khác biệt chính giữa sự kiện do máy chủ gửi và tính năng thăm dò ý kiến dài hạn là SSE được trình duyệt xử lý trực tiếp và người dùng chỉ cần nghe tin nhắn.

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

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

WebSockets có một giao thức phong phú với giao tiếp hai chiều, toàn bộ kênh đôi. Kênh hai chiều phù hợp hơn với trò chơi, ứng dụng nhắn tin và mọi trường hợp sử dụng mà bạn cầ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 bạn bè cập nhật trạng thái, mã chứng khoán, 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, một bản cập nhật cho Cơ sở dữ liệu SQL web phía máy khách hoặc kho đối tượng IndexedDB. Nếu bạn cần gửi dữ liệu đến một máy chủ, XMLHttpRequest luôn là một đối tượng bạn bè.

SSE được gửi qua HTTP. Không có giao thức hoặc cách triển khai máy chủ đặc biệt nào để hoạt động. WebSocket yêu cầu kết nối toàn bộ 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 thiếu theo thiết kế, bao gồm cả tính năng kết nối lại tự động, mã sự kiện và khả năng gửi sự kiện tuỳ ý.

Tạo EventSource bằng JavaScript

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

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

Tiếp theo, hãy thiết lập 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 khoảng 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, ứng dụ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 luồng sự kiện từ nguồn là vấn đề về việc tạo phản hồi văn bản thuần tuý, được phân phát bằng Loại nội dung text/event-stream, tuân theo định dạng SSE. Ở dạng cơ bản, phản hồi phải chứa một dòng data:, theo sau là thông báo của bạn, rồi đến 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ông báo của bạn dài hơn, bạn có thể chia thông báo đó 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 ký tự "\n" (ngoại trừ dòng cuối cùng, phải kết thúc bằng hai ký tự này). Kết quả được truyền đến trình xử lý message là một chuỗi duy nhất được nối với nhau bằng 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ể sử dụng e.data.split('\n').join('') để tạo lại thông báo mà không có ký tự "\n".

Gửi dữ liệu JSON

Việc sử dụng nhiều dòng giúp bạn gửi JSON mà không làm hỏng 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ể 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ột mã nhận dạng duy nhất với một sự kiện phát trực tiếp bằng cách thê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 cuối cùng được kích hoạt để nếu kết nối với máy chủ bị ngắt, thì một tiêu đề HTTP đặc biệt (Last-Event-ID) sẽ được đặt bằng 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 sau khoảng 3 giây sau khi mỗi kết nối bị đó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 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 ra nhiều loại sự kiện bằng cách thêm tên sự kiện. Nếu có một dòng bắt đầu bằng event:, sau đó là một 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 để nghe sự kiện cụ thể đó.

Ví dụ: đầu ra máy chủ sau đây gửi ba loại sự kiện, một sự kiện "message" (thông báo) chung, sự kiện "userlogon" (đăng nhập người dùng) và sự kiện "update" (cập nhật):

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 thiết lập trình nghe sự kiện trên ứng dụng:

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 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ỷ luồng từ ứng dụng, hãy gọi:

source.close();

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

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

Đôi lời về tính bảo mật

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