分類: 網頁開發

HTML5 的 Server-Sent Events 串流使用教學

Server-Sent Events 是一個已經被 W3C 納入 HTML5 標準的 API,它可以讓伺服器透過一般的 HTTP 協定主動更新瀏覽器的資料。

傳統的網頁架構下,如果瀏覽器要持續接收來自於伺服器端的新資料時,通常都是透過 Polling、Long-Polling 或 Streaming 等方式來達成,而後來出現的 WebSocket 徹底解決了這個問題,不過除此之外,在 HTML5 標準中還有一個 Server-Sent Events 也可以處理這類型的問題。


Server-Sent Events(簡稱 SSE)是一個 HTML5 中的標準 API,它提供一個跨平台與瀏覽器的資料傳輸方式,可以讓伺服器主動傳送資料給瀏覽器。

SSE 基本的概念跟 WebSocket 有點類似,連覽器透過 SSE 來「訂閱」伺服器上的一個資料來源,每當伺服器有新資料產生的時候,就會傳送通知訊息給瀏覽器,以便及時更新瀏覽器的網頁內容。

Server-Sent Events 與 WebSocket?

SSE 鮮為人知的原因主要是由於 WebSocket 功能實在太強大了,WebSocket 提供了雙向(bi-directional)且全雙工(full-duplex)的優異傳輸能力,這樣的架構非常適合用於線上遊戲、聊天程式或是各種需要即時雙向傳輸的應用,乍看之下 SSE 可以做到的事情,WebSocket 可以做得更好。

不過在某些應用上,瀏覽器不太需要傳送資料給伺服器,瀏覽器只是負責接收資料而已,例如股市行情、即時新聞等,這個時候就可以使用 SSE 這種單向的傳輸方式,如果偶爾需要傳送少量的資訊給伺服器時,也可以使用傳統的 XMLHttpRequest 來處理。

由於 WebSocket 所使用的是雙向全雙工的連線,所以需要特別支援 WebSocket 協定的網頁伺服器軟體才能讓它正常運作,而 SSE 則是一種架構在傳統的 HTTP 協定之上的傳輸方式,也就是說你可以在不需要加裝任何特別的通訊協定或伺服器軟體即可直接使用 SSE,另外 SSE 也有一些 WebSocket 所沒有的特性,例如自動重新連線、事件 ID 與傳送任意的事件等,這些都是 SSE 才有的優點。

瀏覽器端實作

接下來我們要開始介紹如何使用 SSE,瀏覽器端在使用 SSE 的 API 前,要先確認瀏覽器是否支援,如果瀏覽器有支援,則以資料來源的網址作為參數,建立一個 EventSource 物件:

if (!!window.EventSource) {
  var source = new EventSource('stream.php');
} else {
  // 瀏覽器不支援 SSE,使用傳統的 xhr polling :(
}

這裡如果網址是以完整的 URL 來指定的話,那麼其網址的 scheme、domain 與 port 都要與呼叫這個 API 的網頁一致,否則會因為安全性問題而無法正常運作。

接著設定一個 message 事件的 listener:

source.addEventListener('message', function(e) {
  console.log(e.data);
}, false);

當伺服器有新資料送出來的時候,onmessage 這個回呼函數就會被呼叫,而伺服器所送出來的資料就可以透過 e.data 來取得。

另外你也可以另外加上連線建立與關閉的事件 listener:

source.addEventListener('open', function(e) {
  // 連線已建立
}, false);

source.addEventListener('error', function(e) {
  if (e.readyState == EventSource.CLOSED) {
    // 連線已關閉
  }
}, false);

SSE 很特別的一點是如果連線因為某些原因中斷了,它會自動在大約 3 秒後重新連線,而開發者也可以自己設定這個重新連線的等待時間(接下來的文章中會介紹)。

以上就是瀏覽器端所有的程式碼,這樣設定好之後,瀏覽器就可以接收來自於伺服器的資料了。

資料格式

接下來的工作就是伺服器要如何將資料傳送給瀏覽器了,SSE 定義了一些特別的資料傳輸格式,所有要從伺服器透過 SSE 傳輸的資料都要符合它所定義的格式。

最基本的資料格式就是以 data: 開頭,加上資料的內容,最後以兩個換行字元 \n\n 結尾:

data: My message\n\n

如果要傳輸的資料量比較大,也可以將資料分成多行來傳輸,每一行資料都是以 data: 開頭,然後以一個換行字元 \n 結尾(最後一行列外):

data: first line\n
data: second line\n\n

連續的 data: 會被視為同一筆資料,這些資料傳送至瀏覽器時,只會觸發一個事件,而這些資料會以換行字元為分隔,合併為一個字串,以這個例子來說瀏覽器收到 的 e.data 會是 "first line\nsecond line" 這樣的字串。如果想要除去這些換行字元,可以使用 e.data.replace("\n","") 來將其置換掉。

JSON

如果要傳送 JSON 格式的資料,可以這樣寫:

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

至於瀏覽器端則可以這樣處理接收到的 JSON 資料:

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

指定事件 ID

如果要準確的區分每一個事件,可以使用 id: 指定每個事件的序號:

id: 12345\n
data: first line\n
data: second line\n\n

設定事件 ID 之後,瀏覽器會自動紀錄最後一個接收到的事件 ID,一旦發生連線中斷的情況,在重新連線時,瀏覽器會自動在連線請求的表頭中加入一個 Last-Event-ID 欄位,告訴伺服器重新連線之後該從哪一個事件開始發送。

重新連線等待時間

瀏覽器在連線中斷之後,大約會等待 3 秒左右的時間,才會重新建立連線,如果想要更改這個設定,可以使用 retry: 來指定等待的時間:

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

retry: 所使用的單位是千分之一秒,所以這個例子就是讓瀏覽器等待 10 秒。

指定事件名稱

伺服器中同一個資料來源可以藉由事件名稱的方式,同時發送許多不同的類型的資料,事件名稱是以 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', function(e) {
  var data = JSON.parse(e.data);
  console.log(data.msg);
}, false);

source.addEventListener('userlogon', function(e) {
  var data = JSON.parse(e.data);
  console.log('User login:' + data.username);
}, false);

source.addEventListener('update', function(e) {
  var data = JSON.parse(e.data);
  console.log(data.username + ' is now ' + data.emotion);
}, false);

伺服器範例

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

以下則是使用 Node.js 來實作的伺服器:

var http = require('http');
var sys = require('sys');
var fs = require('fs');

http.createServer(function(req, res) {
  //debugHeaders(req);

  if (req.headers.accept && req.headers.accept == 'text/event-stream') {
    if (req.url == '/events') {
      sendSSE(req, res);
    } else {
      res.writeHead(404);
      res.end();
    }
  } else {
    res.writeHead(200, {'Content-Type': 'text/html'});
    res.write(fs.readFileSync(__dirname + '/sse-node.html'));
    res.end();
  }
}).listen(8000);

function sendSSE(req, res) {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive'
  });

  var id = (new Date()).toLocaleTimeString();

  // Sends a SSE every 5 seconds on a single connection.
  setInterval(function() {
    constructSSE(res, id, (new Date()).toLocaleTimeString());
  }, 5000);

  constructSSE(res, id, (new Date()).toLocaleTimeString());
}

function constructSSE(res, id, data) {
  res.write('id: ' + id + '\n');
  res.write("data: " + data + '\n\n');
}

function debugHeaders(req) {
  sys.puts('URL: ' + req.url);
  for (var key in req.headers) {
    sys.puts(key + ': ' + req.headers[key]);
  }
  sys.puts('\n\n');
}

其對應的 sse-node.html 為:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
</head>
<body>
  <script>
    var source = new EventSource('/events');
    source.onmessage = function(e) {
      document.body.innerHTML += e.data + '<br>';
    };
  </script>
</body>
</html>

關閉連線

在正常的狀況下,只要 SSE 的連線中斷之後,瀏覽器就會自動嘗試重新連線,而如果資料已經傳送完畢,就可以透過瀏覽器或伺服器端設定關閉連線,讓瀏覽器不用繼續嘗試重新連線。

若要在瀏覽器端關閉連線,只要呼叫

source.close();

這樣就可以了。

而如果要在伺服器端中止連線的話,可以在連線時回傳非 text/event-streamContent-Type,或是直接回傳非 200 OK 的回應(例如 404 Not Found),這兩種方式都可以避免瀏覽器繼續嘗試重新連線。

安全性

根據 WHATWG 的說明,在使用 SSE 接收訊息時,應該要檢查訊息中的 e.origin 是否跟應用程式正確的來源相符合:

source.addEventListener('message', function(e) {
  if (e.origin != 'http://example.com') {
    alert('Origin was not http://example.com');
    return;
  }
  // ...
}, false);

這樣可以避免被惡意的網站利用。

參考資料:HTML5ROCKS

G. T. Wang

個人使用 Linux 經驗長達十餘年,樂於分享各種自由軟體技術與實作文章。

Share
Published by
G. T. Wang

Recent Posts

光陽 KYMCO GP 125 機車接電發動、更換電瓶記錄

本篇記錄我的光陽 KYMCO ...

2 年 ago

[開箱] YubiKey 5C NFC 實體金鑰

本篇是 YubiKey 5C ...

3 年 ago

[DIY] 自製竹火把

本篇記錄我拿竹子加上過期的苦茶...

3 年 ago