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

Eric Bidelman

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

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

Khái niệm về SSE có thể quen thuộc. Một ứng dụng web "đăng ký" đến một luồng các bản cập nhật do máy chủ tạo ra 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 được những hạn chế của các phiên bản AJAX trước đó. Nội dung như vậy bao gồm:

  • Thăm dò ý kiến: Ứ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 định dạng yêu cầu và phản hồi. Khách hà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ó giá trị nào, thì trường này trống được trả về. Việc thăm dò thêm sẽ tạo ra mức hao tổn HTTP lớn hơn.

  • Thăm dò ý kiến trong thời gian dài (tạm ngưng hoạt động GET / COMET): Nếu máy chủ không có dữ liệu máy chủ sẽ 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à "treo GET". Thời gian thông tin xuất hiện, máy chủ phản hồi, đóng kết nối, và quy trình này 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 điều này, các nhà phát triển thường sử dụng các kỹ thuật tấn công như phần thêm các thẻ tập lệnh thành mã "vô hạn" iframe.

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 ứng dụng đó muốn mà không cần đưa ra 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ày sang máy khách 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à cuộc thăm dò ý kiến dài là SSE được trình duyệt xử lý trực tiếp và người dùng chỉ phải 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ó một giao thức đa dạng thức với giao tiếp hai chiều, song công hoàn toàn. Kênh hai chiều sẽ phù hợp hơn cho trò chơi, ứng dụng nhắn tin và bất cứ trường hợp sử dụng nào khi bạn cần cập nhật gần như theo thời gian thực trong 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, bảng giá 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, bản cập nhật cho 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ột máy chủ, XMLHttpRequest luôn là một người bạn.

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

Tạo EventSource bằng JavaScript

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

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 máy chủ đẩy thông tin cập nhật, trình xử lý onmessage sẽ kích hoạt và dữ liệu mới sẽ có sẵn trong thuộc tính e.data. Điều tuyệt vời là mỗi khi 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. Việc triển khai máy chủ của bạn thậm chí có thể có quyền kiểm soát hết thời gian chờ kết nối lại này.

Vậy là xong. Khách hàng của bạn hiệ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 đề cần tạo nội dung phản hồi bằng văn bản thuần tuý, được phân phát với Content-Type text/event-stream, 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, theo sau là hai "\n" ký tự để kết thúc luồng:

data: My message\n\n

Dữ liệu nhiều dòng

Nếu thông điệp của bạn dài hơn, bạn có thể chia nhỏ bằng cách sử dụng nhiều data: dòng. Hai hoặc nhiều dòng liên tiếp bắt đầu bằng data: được coi là 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" (ngoại trừ mục cuối cùng, sẽ kết thúc có 2). Kết quả được chuyển đến trình xử lý message là một chuỗi đơn được nối bở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 tạo ra "dòng đầu tiên\ndòng thứ hai" trong e.data. Sau đó, người dùng có thể sử dụng e.data.split('\n').join('') để tạo lại tin nhắn Sans "\n" ký tự.

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 làm hỏng cú pháp:

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

Có thể có mã phía máy khách để 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 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 đến máy chủ bị gián đoạn, tiêu đề HTTP đặc biệt (Last-Event-ID) sẽ đặ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 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 trong khoảng 3 giây sau mỗi lần kết nối được đóng. Bạn có thể thay đổi thời gian chờ đó bằng cách thêm dòng bắt đầu bằng retry:, theo sau là số mili giây hãy đợi trước khi cố gắng kết nối lại.

Ví dụ sau đây 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 một tên sự kiện. Nếu có một dòng bắt đầu bằng event:, tiếp theo là tên riêng biệt cho sự kiện, thì sự kiện đó sẽ được liên kết với tên đó. Trên máy khách, bạn có thể thiết lập một trình nghe sự kiện để theo dõi sự kiện cụ thể đó.

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

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 Nút JS bằng cách sử dụng Trình xử lý nhanh:

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ừ máy khách hoặc máy chủ.

Để huỷ phiên phát trực tiếp từ ứng dụng, hãy gọi:

source.close();

Để huỷ một luồng dữ liệu từ máy chủ, hãy phản hồi bằng một mã không phải text/event-stream Content-Type hoặc trả về 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ề bảo mật

Các yêu cầu do EventSource tạo ra phải tuân theo 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 cần điểm cuối SSE trên máy chủ của mình có thể truy cập từ nhiều nguồn gốc, đọc cách bật bằng Chia sẻ tài nguyên trên nhiều nguồn gốc (CORS).