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

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

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

SSE のコンセプトはおなじみのものかもしれません。ウェブアプリは、サーバーによって生成された更新のストリームに「登録」し、新しいイベントが発生するたびに、クライアントに通知を送信します。ただし、サーバーから送信されたイベントを真に理解するには、AJAX の前の関数の制約を理解する必要があります。該当するものは次のとおりです。

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

  • ロング ポーリング(ハング GET / COMET): サーバーに使用可能なデータがない場合、サーバーは新しいデータが使用可能になるまでリクエストを開いたままにします。そのため、この手法はしばしば「ハンギング 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.
  }
});

サーバーから更新が push されると、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 ハンドラに渡される結果は、改行文字で連結された 1 つの文字列です。次に例を示します。

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)で有効にする方法をご覧ください。