使用伺服器傳送的事件串流更新

伺服器推送事件 (SSE) 會透過 HTTP 連線,從伺服器自動傳送更新至用戶端。連線建立後,伺服器就能啟動資料傳輸。

您可能會想使用 SSE 從網路應用程式傳送推播通知。SSE 會單向傳送資訊,因此您不會收到用戶端的更新。

您可能對 SSE 的概念不陌生。網頁應用程式會「訂閱」伺服器產生的更新串流,並在發生新事件時傳送通知給用戶端。不過,要真正瞭解伺服器傳送事件,我們必須瞭解其前身 AJAX 的限制。包括:

  • 輪詢:應用程式會重複輪詢伺服器以取得資料。大多數 AJAX 應用程式都會使用這項技巧。使用 HTTP 通訊協定時,擷取資料會圍繞要求和回應格式進行。用戶端會提出要求,並等待伺服器傳回資料。如果沒有可用的資料,系統會傳回空白回應。額外的輪詢會產生更大的 HTTP 額外負擔。

  • 長時間輪詢 (掛起 GET / COMET):如果伺服器沒有可用的資料,伺服器會將要求保持開啟狀態,直到有新資料可用為止。因此,這項技巧通常稱為「掛起 GET」。當資訊可供使用時,伺服器會回應並關閉連線,然後重複這個程序。因此,伺服器會持續以新資料回應。為此,開發人員通常會使用駭客攻擊,例如在「無限」iframe 中附加指令碼標記。

伺服器傳送的事件是從一開始就以高效率為設計目標。與 SSE 通訊時,伺服器可隨時將資料推送至應用程式,而無需提出初始要求。換句話說,更新內容會在發生時從伺服器串流至用戶端。SSE 會在伺服器和用戶端之間開啟單一單向管道。

伺服器傳送事件和長時間輪詢的主要差異在於,SSE 會直接由瀏覽器處理,而使用者只需監聽訊息即可。

伺服器傳送的事件與 WebSocket

為何要選擇伺服器傳送事件,而非 WebSocket?就讓我來回答您的問題!

WebSockets 提供豐富的通訊協定,可進行雙向全雙工通訊。雙向通道更適合遊戲、訊息應用程式,以及任何需要雙向即時更新的用途。

不過,有時候您只需要伺服器的單向通訊。例如,當好友更新狀態、股票代碼、新聞動態或其他自動推送資料機制時。也就是說,這是用於更新用戶端 Web SQL 資料庫或 IndexedDB 物件儲存庫的更新。如果您需要將資料傳送至伺服器,XMLHttpRequest 一律會是您的好幫手。

SSE 會透過 HTTP 傳送。沒有任何特殊通訊協定或伺服器實作方式可用來運作。WebSocket 需要全雙工連線和新的 WebSocket 伺服器來處理通訊協定。

此外,伺服器傳送的事件具有 WebSocket 缺少的各種功能,包括自動重新連線、事件 ID 和傳送任意事件的功能。

使用 JavaScript 建立 EventSource

如要訂閱事件串流,請建立 EventSource 物件,並傳遞串流的網址:

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

接下來,請為 message 事件設定處理常式。您可以選擇監聽 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.
  }
});

當更新內容從伺服器推送時,onmessage 處理常式會觸發,並在其 e.data 屬性中提供新資料。最神奇的是,無論何時關閉連線,瀏覽器都會在約 3 秒後自動重新連線至來源。您的伺服器實作程式甚至可以控制這個重新連線逾時時間

就是這麼簡單!您的用戶端現在可以處理來自 stream.php 的事件。

事件串流格式

從來源傳送事件串流,就是建構純文字回應,並以符合 SSE 格式的 text/event-stream Content-Type 提供。在基本格式中,回應應包含 data: 行,後面接著您的訊息,然後再加上兩個「\n」字元來結束串流:

data: My message\n\n

多行資料

如果訊息較長,您可以使用多行 data: 來分割訊息。以 data: 開頭的兩行或多行連續文字會視為單一資料,也就是只會觸發一次 message 事件。

每行結尾都應以單一「\n」結尾 (最後一行除外,該行應以兩個「\n」結尾)。傳遞至 message 處理常式的結果,是透過換行字元連接的單一字串。例如:

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

這會在 e.data 中產生「first line\nsecond line」。接著,您可以使用 e.data.split('\n').join('') 重建不含「\n」字元的訊息。

傳送 JSON 資料

使用多行可協助您傳送 JSON,而不會破壞語法:

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

以及可能用於處理該串流的用戶端程式碼:

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

將 ID 與事件建立關聯

您可以透過串流事件傳送專屬 ID,方法是加入以 id: 開頭的一行:

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

設定 ID 可讓瀏覽器追蹤上次觸發的事件,這樣如果與伺服器的連線中斷,系統就會透過新要求設定特殊的 HTTP 標頭 (Last-Event-ID)。這可讓瀏覽器判斷要觸發哪個適當的事件。message 事件包含 e.lastEventId 屬性。

控管重新連線逾時時間

瀏覽器會在每個連線關閉後大約 3 秒,嘗試重新連線至來源。您可以變更逾時時間,方法是加入以 retry: 開頭的程式碼行,後面接著等待嘗試重新連線的毫秒數。

以下範例會在 10 秒後嘗試重新連線:

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

指定事件名稱

單一事件來源可透過加入事件名稱產生不同類型的事件。如果出現以 event: 開頭,後面接著事件專屬名稱的一行,則該事件會與該名稱相關聯。在用戶端上,您可以設定事件監聽器來監聽特定事件。

舉例來說,下列伺服器輸出會傳送三種類型的事件,分別是一般「message」事件、「userlogon」和「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

在用戶端設定事件監聽器:

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}`);
};

伺服器範例

以下是 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()));
?>

以下是使用 Express 處理常式在 Node JS 上實作的類似做法:

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>

取消事件串流

通常,瀏覽器會在連線關閉時自動重新連線至事件來源,但這項行為可透過用戶端或伺服器取消。

如要取消用戶端的串流,請呼叫:

source.close();

如要取消伺服器的串流,請回應非 text/event-stream Content-Type,或傳回 200 OK 以外的 HTTP 狀態 (例如 404 Not Found)。

這兩種方法都能防止瀏覽器重新建立連線。

關於安全性

EventSource 產生的要求,會受到與 fetch 等其他網路 API 相同的來源政策規範。如果您需要從不同來源存取伺服器上的 SSE 端點,請參閱如何啟用跨來源資源共享 (CORS)