サーバー送信イベントで最新情報をストリーミングする

サーバー送信イベント(SSE)は、HTTP 接続を使用して、サーバーからクライアントに自動更新を送信します。接続が確立されると、サーバーはデータ転送を開始できます。

SSE を使用してウェブアプリからプッシュ通知を送信することもできます。SSE は情報を一方向に送信するため、クライアントから更新を受け取ることはできません。

SSE のコンセプトはご存じかもしれません。ウェブアプリは、サーバーが生成した更新ストリームを「サブスクライブ」し、新しいイベントが発生するたびにクライアントに通知を送信します。ただし、サーバー送信イベントを本当に理解するには、その前身である AJAX の制限事項を理解する必要があります。以下が該当します。

  • ポーリング: アプリがサーバーにデータを繰り返しポーリングします。この手法は、ほとんどの AJAX アプリケーションで使用されています。HTTP プロトコルでは、データの取得はリクエストとレスポンスの形式を中心に行われます。クライアントはリクエストを行い、サーバーがデータを返すのを待ちます。利用できない場合は、空のレスポンスが返されます。ポーリングを追加すると、HTTP オーバーヘッドが増加します。

  • ロングポーリング(ハングリング GET / COMET): サーバーに利用可能なデータがない場合は、新しいデータが利用可能になるまでサーバーがリクエストを開いたままにします。したがって、この手法は「Hanging GET」と呼ばれます。情報が利用可能になると、サーバーが応答して接続を閉じます。プロセスが繰り返されます。したがって、サーバーは常に新しいデータで応答します。これを設定するには、通常、デベロッパーは「無限」iframe にスクリプトタグを追加するなどのハックを使用します。

サーバーに送信されるイベントは、効率を考慮して一から設計されています。SSE と通信する場合、サーバーは最初のリクエストを行わずに、いつでもデータをアプリに push できます。つまり、更新は発生したときにサーバーからクライアントにストリーミングできます。SSE は、サーバー間とクライアント間の単一の単方向チャネルを開きます。

サーバー送信イベントと長時間ポーリングの主な違いは、SSE はブラウザによって直接処理され、ユーザーはメッセージをリッスンするだけで済む点です。

サーバー送信イベントと WebSocket の比較

WebSocket ではなくサーバー送信イベントを選択する理由その問いが重要です。

WebSockets には、双方向の全二重通信を備えた豊富なプロトコルがあります。双方向チャネルは、ゲーム、メッセージ アプリ、双方向のほぼリアルタイムの更新が必要なユースケースに適しています。

ただし、サーバーからの一方通行の通信のみが必要な場合もあります。たとえば、友だちがステータス、株価ティッカー、ニュース フィード、その他の自動データ プッシュ メカニズムを更新したときなどです。つまり、クライアントサイドの Web SQL Database または IndexedDB オブジェクト ストアの更新です。サーバーにデータを送信する必要がある場合は、XMLHttpRequest が常に役に立ちます。

SSE は HTTP 経由で送信されます。特別なプロトコルやサーバーの実装は必要ありません。WebSocket では、プロトコルを処理するために全二重接続と新しい WebSocket サーバーが必須です。

さらに、サーバー送信イベントには、自動再接続、イベント ID、任意のイベントを送信する機能など、WebSocket には設計上欠けているさまざまな機能があります。

JavaScript を使用して EventSource を作成する

イベント ストリームに登録するには、EventSource オブジェクトを作成し、ストリームの URL を渡します。

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: 行、メッセージ、2 つの「\n」文字(ストリームを終了)が含まれます。

data: My message\n\n

複数行のデータ

メッセージが長い場合は、複数の data: 行を使用して分割できます。data: で始まる連続する 2 つ以上の行は、1 つのデータとして扱われます。つまり、message イベントは 1 つだけ発生します。

各行は 1 つの「\n」で終わる必要があります(最後の行は 2 つで終わる必要があります)。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

イベント名を指定します

1 つのイベントソースにイベント名を含めることで、さまざまなタイプのイベントを生成できます。event: で始まる行があり、その後にイベントの一意の名前が続く場合、イベントはその名前に関連付けられます。クライアントでは、特定のイベントをリッスンするようにイベント リスナーを設定できます。

たとえば、次のサーバー出力は、汎用の「message」イベント、「userlogon」、「update」イベントの 3 種類のイベントを送信します。

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 によって生成されたリクエストには、フェッチなどの他のネットワーク API と同じオリジン ポリシーが適用されます。サーバーの SSE エンドポイントに別の生成元からアクセスする必要がある場合は、クロスオリジン リソース シェアリング(CORS)で有効にする方法をご覧ください。