TL;DR: Server-Sent Events (SSE) は Web サーバーがリアルタイムのデータをクライアントにプッシュするできるようにする標準です。本書では、 React と Node.js でフライト時間表デモアプリを構築してこの標準をどのように使用するかを学んでいきます。ただし、ここで学ぶチュートリアルのコンセプトはどんなプログラミング言語やテクノロジにも適用できます。この GitHub レポジトリでアプリケーションの最後のコードを見つけることもできます。

Server-Sent Events を導入する

ブラウザとサーバーの間の一般的な相互作用はリソースに要求するブラウザーとレスポンスを提供するサーバーから成ります。しかし、サーバーは明確な要求なしでいつでもクライアントにデータを送信できるのでしょうか?

その答えはできるです!これは Server-Sent Events(別名 SSE または イベントソース)、サーバーが非同期的にデータをクライアントにプッシュできるようにする W3C 標準を使って達成できます。これは長いサーバーの処理から進捗状況を得るために実装する面倒なプーリングを使用することを提案しますが、SSE のお陰でサーバーからからのレスポンスを待機するためにプーリングを実装する必要はありません。複雑で不慣れなプロトコルを使う必要はありません。つまり、標準 HTTP プロトコルを続けて使うことができるのです。

ですから、現実的なアプリケーションでどのように Server-Sent Events

Server-Sent Events でリアルタイムのアプリを構築する

どのように Server-Sent Events を使用するかを学ぶために、簡単なフライト時間表アプリケーション(空港で目にするフライトトラッカーに似たようなもの)を作成していきます。この時間表アプリは簡単な Web ページから成り、以下の写真に表示されているようにフライトのリストが表示されます。

Real-time flights tracker timetable

このリアルタイムのアプリを通して、フライトの到着時間表を見ることができ、Server-Sent Events を実装すると、フライトの状態が変更になったときに自動的に最新情報を見ることができます。このデモアプリでは、予定されたイベントを使ってフライトの状態変更をシミュレートしていきます。ただし、このメカニズムは生産準備完了アプリでより現実的なものと簡単に置き換えることができます。

ですから、コーディングから始めて、Server-Sent Events 標準がどのように機能するかを見てみましょう。

React アプリケーションをスキャフォールディングする

まず最初のステップとして、React クライアントアプリケーションをスキャフォールディングしましょう。簡単にするために create-react-app を使って React ベースのクライアントをセットアップしていきます。まず、マシンに Node.js.がインストールされているかを確認し、端末のウィンドウに次のコマンドをタイプしましょう。

# create-react-app をグローバルにインストールする
npm install -g create-react-app

開発マシンに create-react-app をインストールしたら、次をタイプして React アプリケーションの基本的なテンプレートを作成しましょう。

create-react-app real-time-sse-app

数秒後には必要なすべてのファイルと real-time-sse-app と呼ばれる新しいディレクトリを入手します。特に、src サブディレクトリには新品 React アプリケーションのソースコードが含まれています。ここでの目標はこの基本的な React アプリケーションのコードを変更し、それをフライト時間表フロントエンドアプリケーションと交換します。

このアプリケーションはデータが表形式で表示されるので、データを次のテーブル、React Table にレンダリングするタスクを簡素化する React コンポーネントをインストールします。このコンポーネントをアプリケーションに追加するには、次のコマンドを端末にタイプします。

# React アプリプロジェクトのルートであるかを確認します
cd real-time-sse-app

# それから React Table 依存関係をインストールします
npm install react-table

これで、アプリケーションコードを変更する準備ができました。では、App.js ファイル(これは src ディレクトリ内に存在する)を開き、そのコンテンツを次と交換しましょう。

// src/App.js

import React, { Component } from "react";
import ReactTable from "react-table";
import "react-table/react-table.css";
import { getInitialFlightData } from "./DataProvider";

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      data: getInitialFlightData()
    };

    this.columns = [
      {
        Header: "Origin",
        accessor: "origin"
      },
      {
        Header: "Flight",
        accessor: "flight"
      },
      {
        Header: "Arrival",
        accessor: "arrival"
      },
      {
        Header: "State",
        accessor: "state"
      }
    ];
  }

  render() {
    return (
      <div className="App">
        <ReactTable data={this.state.data} columns={this.columns} />
      </div>
    );
  }
}

export default App;

ここでは、フライト時間表をセットアップするために必要なものをインポートします。特に、標準的な React 基本要素に加えて、ReactTable コンポーネントと共に、その基本スタイルシートや DataProvider と呼ばれるモジュールから getInitialFlightData と呼ばれるメソッドをインポートします。このモジュールはまだ定義していませんが、後ほど行っていきます。この機能の目的は App コンポーネントの state を初期化するために使用するフライトデータとアプリケーションを提供することです。

コンストラクターでは、フライトプロパティをテーブル列にマッピングしてテーブルの構造も定義します。ご覧のように、このマッピングは次のようにオブジェクトの配列から成ります。

this.columns = [
  {
    Header: "Origin",
    accessor: "origin"
  },
  {
    Header: "Flight",
    accessor: "flight"
  },
  {
    Header: "Arrival",
    accessor: "arrival"
  },
  {
    Header: "State",
    accessor: "state"
  }
];

上記の定義のように、各フライトオブジェクトは Header プロパティ(列のヘッダーを表す)や accessor プロパティ(値がその列に表示されるフライトプロパティを表す)から成ります。React Table にはテーブルの構造を定義する他のオプションがたくさんありますが、今回の目標を達成するには Headeraccessor で十分です。

最後に、App コンポーネントの render メソッド内で、ReactTable 要素を含み、プロパティとしてそれをフライトデータや列にパスします。

では src フォルダ内に、DataProvider.js という名前のファイルを作成し、それを次のコードで設定します。

// src/DataProvider.js

export function getInitialFlightData() {
  return [
    {
      origin: "London",
      flight: "A123",
      arrival: "08:15",
      state: ""
    },
    {
      origin: "Berlin",
      flight: "D654",
      arrival: "08:45",
      state: ""
    },
    {
      origin: "New York",
      flight: "U213",
      arrival: "09:05",
      state: ""
    },
    {
      origin: "Buenos Aires",
      flight: "A987",
      arrival: "09:30",
      state: ""
    },
    {
      origin: "Rome",
      flight: "I768",
      arrival: "10:10",
      state: ""
    },
    {
      origin: "Tokyo",
      flight: "G119",
      arrival: "10:35",
      state: ""
    }
  ];
}

上記のモジュールは単にフライトデータの静的配列を返す getInitialFlightData 関数をエクスポートします。実際のアプリケーションでは、関数がサーバーからデータをリクエストします。ただし、本書の目的上では、この実装で十分です。

ここまでで、アプリケーションは表形式のフライトデータを提示します。それを Web ブラウザーで見てみましょう!アプリケーションを実行するには、端末に次のコマンドをタイプします。

# real-time-sse-app から React アプリを起動する
npm start

数秒後に、上記の写真で見られるように、フライトのリストがブラウザーに表示されるはずです。ブラウザーが自動的に開かなければ、それを開き、http://localhost:3000 に移動します。

注: 理由が不明で、アプリケーションを始めることができなかった場合は、real-time-sse-app ディレクトリから npm i を発行すると問題解決するかもしれません。

React を使って Server-Sent Events を使用する

React アプリケーションが静的データを生成した後、サーバー側が生成したイベントを消費する必要がある機能を追加しましょう。

これをするには、Server-Sent Events プロトコルと対話するために生成された標準インターフェイス EventSource API を使用していきます。MDN ドキュメントでは、"EventSource インスタンスは HTTP サーバーへの固定接続を開き、それから text/event-stream フォーマットでイベントを送信します。その接続は EventSource.close() を呼び出して閉じるまで開き続けます。"

ですから、第一ステップとして、eventSource プロパティを App コンポーネントに追加し、それに EventSource インスタンスを割り当てましょう。以下のコードはそれをどのようにして達成するかを示します。

// src/App.js

// ... ステートメントをインポートします ...

class App extends Component {
  constructor(props) {
    // ... 上付き、状態、および列 ...

    this.eventSource = new EventSource("events");
  }

  // ... レンダー ...
}

export default App;

EventSource() コンストラクターはクライアントとサーバー間の通信チャネルを初期化するオブジェクトを生成します。このチャネルは一方向なので、イベントはサーバーからクライアントにフローし、絶対に反対方向にはフローしません。新しいインスタンスを生成するには、それを引数としてイベントを受けたい場所のエンドポイントを表す URL に渡します。ここでは HTTP を使っているので、この URL は実行可能なクエリ文字列を含む、有効な相対か絶対 Web アドレスです。

この場合、クライアントは同じクライアントのドメイン内の events エンドポイントからイベントのストリームを予期します。つまり、クライアント URL がこの例では http://localhost:3000 なので、サーバー エンドポイントがプッシュするデータは http://localhost:3000/events にあります。

ここで、いくつかのコード行を追加して、サーバーから送信されるイベントをキャプチャしましょう。updateFlightState と呼ばれる新しいメソッドを App コンポーネントに追加していきます。このメソッドはサーバーからメッセージを受け取るたびに呼び出され、メッセージデータでコンポーネントの状態を更新します。次のコードスニペットはサーバーから受信するイベントを処理する App コンポーネントにどのような変更をする必要があるかを示します。

// src/App.js

// ... ステートメントをインポートします ...

class App extends Component {
  // ... コンストラクター ...

  componentDidMount() {
    this.eventSource.onmessage = e =>
      this.updateFlightState(JSON.parse(e.data));
  }

  updateFlightState(flightState) {
    let newData = this.state.data.map(item => {
      if (item.flight === flightState.flight) {
        item.state = flightState.state;
      }
      return item;
    });

    this.setState(Object.assign({}, { data: newData }));
  }

  // ... レンダー ...
}

export default App;

ご覧のように、componentDidMount() メソッドにある eventSource オブジェクトの onmessage プロパティにイベントハンドラーを追加しました。Onmessage プロパティはイベントがサーバーから来るときに呼ばれるイベントサーバーをポイントします。今回の場合、割り当てられたイベントハンドラーがサーバーから送信されたデータでコンポーネント state を更新する updateFlightState() メソッドを呼び出します。各イベントが文字列として表示される e.data プロパティでデータを運びます。このアプリケーションでは、このデータは更新されたフライトデータを表す JSON 文字列になり、これについては次のセクションで見ていきます。

アプリケーションのテストはまだできません(まだバックエンドを作り、それからイベントを送信しなければなりません)が、React アプリケーションはサーバーから送信されるイベントを処理する準備ができました。難しくありませんよね?

Server-Sent Events でリアルタイムのバックエンドを構築する

アプリケーションのサーバー側は簡単な Node.js Web サーバーで、events エンドポイントに送信されるリクエストに応答します。それを実装するために、real-time-sse-app ディレクトリの同じレベルにある real-time-sse-backend と呼ばれる新しいディレクトリを作りましょう。server ディレクトリ内で、server.js と呼ばれる新しいファイルを作り、以下のコードをこのディレクトリに入れます。

// server.js

const http = require("http");

http
  .createServer((request, response) => {
    console.log("Requested url: " + request.url);

    if (request.url.toLowerCase() === "/events") {
      response.writeHead(200, {
        Connection: "keep-alive",
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache"
      });

      setTimeout(() => {
        response.write('data: {"flight": "I768", "state": "landing"}');
        response.write("\n\n");
      }, 3000);

      setTimeout(() => {
        response.write('data: {"flight": "I768", "state": "landed"}');
        response.write("\n\n");
      }, 6000);
    } else {
      response.writeHead(404);
      response.end();
    }
  })
  .listen(5000, () => {
    console.log("Server running at http://127.0.0.1:5000/");
  });

ファイルの始めで、http モジュールをインポートし、その createServer メソッドを使って Web サーバーを実行します。サーバーの動作は引数としてパスされるコールバック関数で説明されます。コールバック関数は要求された URL が /events であることを確認し、この場合だけ、数個の HTTP ヘッダーを送ってレスポンスを開始します。サーバーが送信したヘッダーはクライアントとのライブ通信チャネルを確立するために、非常に重要です。

実際、Connection ヘッダーの keep-alive 値は、これが常時接続であることをクライアントに伝えます。これで、このクライアントは受信した最初のデータで終わりにならない接続であることを知ります。

Content-Type ヘッダーの text/event-stream 値はクライアントが受け取るデータを解釈すべき方法を決めます。実際、この値はこの接続が Server-Sent Events プロトコルを使用するということをクライアントに伝えます。

最後に、Cache-Control ヘッダーはそのローカルキャッシュにデータを格納しないようにクライアントに求めるので、クライアントによって読み込まれたデータは本当はサーバーから送信されたもので、過去に受け取った古いデータではありません。

これらヘッダーが送信された後、クライアントは EventSource() コンストラクターを使って新しく使用可能なデータを待ちます。残りの関数本体はフライト状態の変更をシミュレートするために、いくつかの関数実行をスケジュールします。各関数実行がスケジュールされると、以下のフォームの文字列がクライアントに送信されます。

data: xxxxxxx

xxxxxxx はクライアントに送信されるデータを表します。この場合、フライトを表す JSON 文字列を送信します。イベント応答に複数のデータ行を送信できますが、この応答は 2 行の空白行で閉じなければなりません。つまり、イベントメッセージには次のスキーマがある可能性があります。

data: This is a message\n
data: A long message\n
\n
\n

これまで作成した Web サーバーを実行するために、次のコマンドを端末にタイプしましょう。

# real-time-sse-backend ディレクトリであることを確認します
cd real-time-sse-backend

# サーバーを実行します
node server.js

Server-Sent Events を使用する

React クライアントと Node.js サーバーの両方を構築したら、お互いに通信できるようにしますが、実行するドメインが違います。現在、React クライアントは http://localhost:3000 で、Node.js サーバーは http://localhost:5000 で実行しています。

つまり、何もしなければ、React クライアントはブラウザー自体が適用した同一原点ポリシーのため、Node.js サーバーに HTTP リクエストを発行することができなくなります。

単に、create-react-app ドキュメンテーションで記載の通り、proxy 値を package.json [ファイルに追加して問題を解決することもできますが、これは既知の問題のため、この回避策は現在は適用できません。

ですから、クライアントアプリとバックエンドサーバー間で通信ができるようになるには、サーバーサポートを CORS(クロス オリジン リソース共有) にする必要があります。このアプローチで、サーバーはそのリソースを要求するために、異なるドメインで発行されたクライアントを承認します。Node.js サーバーで CORS を有効にするには、クライアントに送信されるために Access-Control-Allow-Origin ヘッダーのように新しいヘッダーを追加しします。この変更が終わったら、response.writeHead への呼び出しは次のようになります。

response.writeHead(200, {
  Connection: "keep-alive",
  "Content-Type": "text/event-stream",
  "Cache-Control": "no-cache",
  "Access-Control-Allow-Origin": "*"
});

Access-Control-Allow-Origin ヘッダーに割り当てられたアスタリスクはこの URL にアクセスが許可されたクライアント(任意のドメインから)を示します。これは運用環境では期待どおりのソリューションではないかもしれません。実際、運用環境では、2 つのアプリケーションを同じドメインにしたり(リバース プロキシを使って)、CORS を有効にして選択性を高めたり(特定のドメインだけを承認するなど)など、異なるアプローチを取るべきです。)

Node.js サーバーで CORS を有効にしたら、EventSource() コンストラクターにパスする URL を変更すべきです。ですから、React アプリケーションで ./src/App.js ファイルを開き、this.eventSource を定義する行を次と交換しましょう。

this.eventSource = new EventSource("http://localhost:5000/events");

ぴったし!もう一度、バックエンドサーバーを実行してクライアントアプリケーションを始めると、数秒で、ブラウザーにリアルタイムのアプリケーションが表示されます。

Locally running the real-time flight tracker web application.

以下は、プロジェクトをどのように実行するかという要約です。

# バックエンドディレクトリから
cd real-time-sse-backend

# Node.js サーバーを開始し、
node server.js

# real-time-sse-app ディレクトリから
cd real-time-sse-app

# React アプリケーションを始めます
npm start

注: 異なるターミナル セッションから両方のアプリケーションを実行するのが簡単かもしれません。そうでなければ、node server.js & を発行してバックグラウンドのバックエンドサーバーを実行しなければならないかもしれません。

Server-Sent Events でイベントタイプを管理する

今回作成した時間表アプリケーションはイベントの data プロパティに送信された特定のフライトを更新して、いつも同じ方法でサーバーイベントに応答します。これは素晴らしいけど、さまざまな状況をどのように管理したらいいのでしょうか?

例えば、このフライトが到着した特定時間後のフライトを説明する行を削除したいとします。状態を変更していないイベントをサーバーがどのようにして送信するのでしょうか?クライアントはどのようにして、テーブルから行を削除すべきイベントをキャプチャするのでしょうか?

さまざまなイベントタイプを区別するイベント情報を特定するために、イベントの data プロパティを使うことを考えることもできますが、Server-Sent Events プロトコルはイベントを特定できるので、さまざまなタイプのイベントを簡単に処理できます。これは、サーバー送信イベント上の event プロパティです。サーバーのコードがどのように変わるか、見てみましょう。

// server.js

const http = require("http");

http
  .createServer((request, response) => {
    console.log("Requested url: " + request.url);

    if (request.url.toLowerCase() === "/events") {
      response.writeHead(200, {
        Connection: "keep-alive",
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        "Access-Control-Allow-Origin": "*"
      });

      setTimeout(() => {
        response.write("event: flightStateUpdate\n");
        response.write('data: {"flight": "I768", "state": "landing"}');
        response.write("\n\n");
      }, 3000);

      setTimeout(() => {
        response.write("event: flightStateUpdate\n");
        response.write('data: {"flight": "I768", "state": "landed"}');
        response.write("\n\n");
      }, 6000);

      setTimeout(() => {
        response.write("event: flightRemoval\n");
        response.write('data: {"flight": "I768"}');
        response.write("\n\n");
      }, 9000);
    } else {
      response.writeHead(404);
      response.end();
    }
  })
  .listen(5000, () => {
    console.log("Server running at http://127.0.0.1:5000/");
  });

応答を定義しながら、data プロパティの前に新しい event プロパティを加えました。この event プロパティはクライアントに送信するイベントのタイプを特定するのに役立ちます。上記の例では、以前に存在したイベントの値として flightStateUpdate を設定し、event プロパティの flightRemoval 値で新しいイベントを追加します。

そのようなものとして、イベントによってフライトの状態(landing から landed になど)を更新したり、フライトを削除したりしなければならないと、クライアントに伝えます。

バックエンドサーバーをリファクタリングした後、これらさまざまなイベントのタイプを処理するためにクライアントアプリケーションを更新する必要があります。ですから、React アプリで src/App.js を開き、次のように更新します。

// src/App.js

// ... ステートメントをインポートします ...

class App extends Component {
  // ... コンストラクター ...

  componentDidMount() {
    this.eventSource.addEventListener("flightStateUpdate", e =>
      this.updateFlightState(JSON.parse(e.data))
    );
    this.eventSource.addEventListener("flightRemoval", e =>
      this.removeFlight(JSON.parse(e.data))
    );
  }

  removeFlight(flightInfo) {
    const newData = this.state.data.filter(
      item => item.flight !== flightInfo.flight
    );

    this.setState(Object.assign({}, { data: newData }));
  }

  // ... レンダー、updateFlightState ...
}

export default App;

ご覧のように、componentDidMount() メソッドの本文にはもはや onmessage プロパティに対するイベントハンドラーの割り当てがありません。ここでは、特定のイベントにイベントハンドラーを割り当てる addEventListener() メソッドを使用します。このようにして、任意の標準 HTML 要素で生成されたように、サーバーで生成された各イベントに特定のイベントハンドラーを簡単に割り当てられるようになります。

この例では、 flightStateUpdate イベントに updateFlightState() メソッドを、flightRemoval イベントに removeFlight() メソッドを割り当てました。

両方のプロジェクトを実行して、ブラウザー(http://localhost:3000/)で React アプリケーションを開くと、9 秒後に (server.js ファイル上の最後の setTimeout で定義されたように)ローマへのフライトは削除されます。

Server-Sent Events で接続クロージャを処理する

クライアントとサーバー間の Server-Sent Event 接続はストリーミング接続です。つまり、接続はクライアントまたはサーバーの実行を止めない限り、永久的にアクティブに保存されます。サーバーが送信するイベントがなかったり、クライアントがもはやサーバーのイベントに関心がなくなったりしたら、現在アクティブな接続をどのようにして明示的に停止できますか?

クライアントがイベントストリームを停止したい場合を考えてみましょう。これについて学ぶには、ユーザーが新しいイベントを受けるのを停止するボタンを作りましょう。では、以下で表示のように、App クラスの render() メソッドを変更しましょう。

render() {
  return (
    <div className="App">
      <button onClick={() => this.stopUpdates()}>Stop updates</button>
      <ReactTable
        data={this.state.data}
        columns={this.columns}
      />
    </div>
  );
}

ここでは <button> 要素を追加し、クリックイベントを同じコンポーネントの stopUpdates() メソッドにバインドします。このメソッドは次のようになります。

stopUpdates() {
  this.eventSource.close();
}

つまり、イベントストリームを停止するには、単に eventSource オブジェクトの close() メソッドを呼び出します。

クライアント上のイベントストリームを閉じても自動的にサーバー側の接続を閉じません。つまり、サーバーはクライアントにイベントを送信し続けるということです。これを避けるには、サーバー側上の接続終了要求を処理する必要があります。これは、次のコードが表示するように、サーバー側上に close イベントのイベントハンドラーを追加して行います。

// server.js

const http = require("http");

http
  .createServer((request, response) => {
    console.log(`Request url: ${request.url}`);

    request.on("close", () => {
      if (!response.finished) {
        response.end();
        console.log("Stopped sending events.");
      }
    });

    // ... etc ...
  })
  .listen(5000, () => {
    console.log("Server running at http://127.0.0.1:5000/");
  });

request.on() メソッドは close リクエストをキャッチし、コールバック関数を実行します。この関数は HTTP 接続を閉じる response.end() メソッドを呼び出します。また、クライアントが複数の終了要求を送信したときに起きる状態のときに接続がすでに閉じているかをチェックします。

サーバーイベントジェネレータが setTimeout() を使ってスケジュールされているので、イベントをクライアントに送信する試行が接続が終了した後に起きる可能性があり、例外を生成します。以下の例で表示されているように、この接続がまだアクティブであるかをチェックして、これを避けます。

setTimeout(() => {
  if (!response.finished) {
    response.write("event: flightStateUpdate\n");
    response.write('data: {"flight": "I768", "state": "landing"}');
    response.write("\n\n");
  }
}, 3000);

注: 他のタイムアウトイベントにも同じ戦略を実装しなければなりません。

ここで、サーバーからの接続を終了する必要があるとき、上記で表示のように、response.end() メソッドを呼び出す必要があります。また、クライアントにはその終了について通知し、そのサイド上のリソースが空きになるようにします。

それを達成するために従う簡単な戦略は、接続を終了しなければならないことをクライアントに通知する特定のイベントタイプを生成することです。例えば、サーバーは次のように単に closedConnection イベントを生成することもできます。

setTimeout(() => {
  if (!response.finished) {
    response.write("event: closedConnection\n");
    response.write("data: ");
    response.write("\n\n");
  }
}, 3000);

それから、React アプリケーション上でこのイベントを処理するには、次のように src/App.js を更新することもできます。

// src/App.js

// ... ステートメントをインポートします ...

class App extends Component {
  // ... コンストラクター ...

  componentDidMount() {
    // ... flightStateUpdate and flightRemoval event handlers ...
    this.eventSource.addEventListener("closedConnection", e =>
      this.stopUpdates()
    );
  }

  // ... updateFlightState および removeFlight メソッド ...

  stopUpdates() {
    this.eventSource.close();
  }

  // ... レンダー ...
}

export default App;

ご覧のように、この新しいイベントタイプを処理することは、stopUpdates() メソッドを closedConnection イベントに割り当てるのと同じくらい簡単です。

Server-Sent Events で接続の回復を処理する

ここまでで、非常に完全な Server-Sent Events をベースにしたリアルタイムアプリケーションを構築しました。サーバーによってプッシュされたさまざまなタイプのイベントを入手し、イベントストリームの終わりをコントロールできました。しかし、ネットワーク障害のため、クライアントがイベントの一部を失ったら、どうなりますか?もちろん、それは特定のアプリケーションによります。場合によっては、失ったイベントの一部を無視できるものもありますが、できないものもあります。

例えば、これまで実装したイベントストリームについて考えてみましょう。ネットワーク問題が起きて、クライアントがフライトを着陸状態にする flightStateUpdate イベントを失っても、大きな問題ではありません。ユーザーは時間表上で着陸段階を失うだけですが、接続が復元されると、その時間表がその後の状態の正しい情報を提供します。

しかしながら、ネットワーク問題がフライトが着陸状態に入ったすぐ後に起きて、接続が flightRemoval イベント後に復元されると、そのフライトは永遠に着陸状態が維持され、この苦しい状態を処理する必要があるという問題が起きます。

Server-Sent Events プロトコルはイベントを特定して、切断された接続を復元するメカニズムを提供します。では、これについて学びましょう。

サーバーがイベントを生成すると、id キーワードをクライアントに送信する応答に添付して識別子を割り当てる能力があります。例えば、次のコードで表示のように、flightStateUpdate イベントを送信することができます。

setTimeout(() => {
  if (!response.finished) {
    const eventString =
      'id: 1\nevent: flightStateUpdate\ndata: {"flight": "I768", "state": "landing"}\n\n';
    response.write(eventString);
  }
}, 3000);

ここでは、コードに少しリファクタリングし、その値として id キーワードに 1 を追加します。

ネットワーク問題が発生し、イベントストリームへの接続が切断されると、ブラウザーは自動的にその接続を復元しようとします。接続が再度、確立されると、ブラウザーは Last-Event-Id HTTP ヘッダーで最後に受け取ったイベントの識別子を自動的に送信します。ですから、サーバーはイベントストリームを適切に復元するこの要求を処理するように変更されるべきです。逃したすべてのイベントに送信するか、あるいは新しく生成されたイベントで続けるかは、サーバーが決めることです。サーバーが逃したすべてのイベントに送信する必要があれば、すでにクライアントに送信したすべてのイベントを格納する必要もあります。

この戦略をサーバーで実装しましょう。次は、少しのリファクタリングをした後のサーバー側コードの最終バージョンです。

// server.js

const http = require("http");

http
  .createServer((request, response) => {
    console.log(`Request url: ${request.url}`);

    const eventHistory = [];

    request.on("close", () => {
      if (!response.finished) {
        response.end();
        console.log("Stopped sending events.");
      }
    });

    if (request.url.toLowerCase() === "/events") {
      response.writeHead(200, {
        Connection: "keep-alive",
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        "Access-Control-Allow-Origin": "*"
      });

      checkConnectionToRestore(request, response, eventHistory);

      sendEvents(response, eventHistory);
    } else {
      response.writeHead(404);
      response.end();
    }
  })
  .listen(5000, () => {
    console.log("Server running at http://127.0.0.1:5000/");
  });

function sendEvents(response, eventHistory) {
  setTimeout(() => {
    if (!response.finished) {
      const eventString =
        'id: 1\nevent: flightStateUpdate\ndata: {"flight": "I768", "state": "landing"}\n\n';
      response.write(eventString);
      eventHistory.push(eventString);
    }
  }, 3000);

  setTimeout(() => {
    if (!response.finished) {
      const eventString =
        'id: 2\nevent: flightStateUpdate\ndata: {"flight": "I768", "state": "landed"}\n\n';
      response.write(eventString);
      eventHistory.push(eventString);
    }
  }, 6000);

  setTimeout(() => {
    if (!response.finished) {
      const eventString =
        'id: 3\nevent: flightRemoval\ndata: {"flight": "I768"}\n\n';
      response.write(eventString);
      eventHistory.push(eventString);
    }
  }, 9000);

  setTimeout(() => {
    if (!response.finished) {
      const eventString = "id: 4\nevent: closedConnection\ndata: \n\n";
      eventHistory.push(eventString);
    }
  }, 12000);
}

function checkConnectionToRestore(request, response, eventHistory) {
  if (request.headers["last-event-id"]) {
    const eventId = parseInt(request.headers["last-event-id"]);

    const eventsToReSend = eventHistory.filter(e => e.id > eventId);

    eventsToReSend.forEach(e => {
      if (!response.finished) {
        response.write(e);
      }
    });
  }
}

バックエンドの新しいバージョンでは、クライアントに送信したイベントを格納する eventHistory と呼ばれる配列を紹介します。それから、切断したかもしれない接続をチェックしたり復元したりする checkConnectionToRestore() 関数に割り当て、sendEvents() 関数で生成されたイベントのコードをカプセル化します。

ここで、イベントがクライアントに送信されるたびに見ることができ、eventHistory に格納されたことも見ることができます。例えば、これは最初のイベントを処理するコードです。

setTimeout(() => {
  if (!response.finished) {
    const eventString =
      'id: 1\nevent: flightStateUpdate\ndata: {"flight": "I768", "state": "landing"}\n\n';
    response.write(eventString);
    eventHistory.push(eventString);
  }
}, 3000);

checkConnectionToRestore() 関数がリクエストに Last-Event-Id HTTP ヘッダーを見つければ、すでに eventHistory 配列に送信したイベントをフィルターし、クライアントに再度、送信します。

function checkConnectionToRestore(request, response, eventHistory) {
  if (request.headers['last-event-id']) {
    const eventId = parseInt(request.headers['last-event-id']);

    const eventsToReSend = eventHistory.filter((e) => e.id > eventId);

    eventsToReSend.forEach((e) => {
      if (!response.finished) {
        response.write(e);
      }
    });
  }

このような変更で、バックエンドをさらに堅牢にし、さらに回復性の高いものにします。

「フロントエンドアプリケーションの状態を Server-Sent Events で更新することはとても簡単です。」

Server-Sent Events のブラウザサポート

caniuse.com によると、Server-Sent Events は現在、Internet Explorer、Edge、および Opera Mini を除くあらゆる主要なブラウザーに対応しています。Edge での対応は現在検討中ですが、世界的サポートの欠如によって Remy Sharp の EventSource.jsYaffle の EventSource 、または AmvTek の EventSource のようなポリファイルの使用が余儀なくさせられています。

これらポリファイルの使用は非常に簡単です。本章では、AmvTek のポリファイルの使い方を見ていきます。他のものを使用するプロセスはそれほど異なりません。AmvTek の EventSource ポリファイルを React クライアントアプリケーションに追加するためには、以下に表示のように、npm を通してそれをインストール必要があります。

npm install eventsource-polyfill

それから、App コンポーネントのモジュールにそのモジュールをインポートします。

// src/App.js

// ... その他のステートメントをインポートします ...
import 'eventsource-polyfill';

// ...アプリクラスの定義とエクスポート ...

それだけです。ポリファイルはネイティブでサポートされない場合のみ、EventSource コンストラクターを定義し、コードは前と同じように、サポートしないブラウザー上で機能し続けます。

Server-Sent Events 対 WebSockets

Server-Sent Events プロトコルを使うことはサーバーからのデータを待つことにを含む一般的な問題を解決するのに役立ちます。ですから、主な難点がクライアントやサーバー側の両方のリソースを消費するという長いポーリングを実装する代わりに、簡単なパフォーマンスソリューションを取得します。

別のアプローチはクライアントとサーバーの間で完全な双方向通信を提供するプロトコルをベースにした標準 TCP、WebSockets を使用することです。WebSockets は Server-Sent Events にどのような利点をもたらしますか?あるテクノロジーを別のものの代わりにいつ使いますか?

以下は Server-Sent Events と WebSockets の間から選択するときに覚えておくべき点です。

  • WebSockets は双方向通信をサポートし、Server-Sent Events はサーバーからクライアントへの通信のみをサポートします。
  • WebSockets は低レベルのプロトコルで、Server-Sent Events は HTTP をベースにしているので、ネットワーク インフラストラクチャには追加の設定を必要としません。
  • WebSockets はバイナリデータの転送をサポートし、Server-Sent Events はテキストベースのデータ転送のみをサポートします。つまり、Server-Sent Events を介してバイナリデータの転送をしたい場合、それを Base64 でエンコードする必要があります。
  • ゲームなどその他使用するアプリケーションでリアルタイムでバイナリデータの転送が必要な場合、WebSockets は低レベルのプロトコルとバイナリデータの転送の両方があるので Server-Sent Events よりも WebSockets の方がよいです。

補足:JavaScript で Auth0 認証

Auth0 では顧客が パスワードのリセット、ユーザーの作成、プロビジョニング、ブロッキング、削除などユーザー ID を管理 するのに役立るためにフルスタックの JavaScript を大いに活用しました。Auth0 Extend と呼ばれるサーバーなしのプラットフォームも作り、顧客が任意の JavaScript 関数を確実に実行できるようにしました。ですから、JavaScript Web アプリで ID 管理プラットフォームを使用するのが簡単なのは全く驚くことではありません。

先進認証を始めるために Auth0 では無料レベルを提供しています。詳細をご確認いただくか、無料 Auth0 アカウントをこちらから登録して ください!

Auth0 Login Page

auth0-jsjwt-decode 次のようなノード モジュールをインストールするのと同じように簡単です。

npm install jwt-decode auth0-js --save

それから次を JS アプリで実装してください。

const auth0 = new auth0.WebAuth({
  clientID: "YOUR-AUTH0-CLIENT-ID", // E.g., you.auth0.com
  domain: "YOUR-AUTH0-DOMAIN",
  scope: "openid email profile YOUR-ADDITIONAL-SCOPES",
  audience: "YOUR-API-AUDIENCES", // See https://auth0.com/docs/api-auth
  responseType: "token id_token",
  redirectUri: "http://localhost:9000" //YOUR-REDIRECT-URL
});

function logout() {
  localStorage.removeItem('id_token');
  localStorage.removeItem('access_token');
  window.location.href = "/";
}

function showProfileInfo(profile) {
  var btnLogin = document.getElementById('btn-login');
  var btnLogout = document.getElementById('btn-logout');
  var avatar = document.getElementById('avatar');
  document.getElementById('nickname').textContent = profile.nickname;
  btnLogin.style.display = "none";
  avatar.src = profile.picture;
  avatar.style.display = "block";
  btnLogout.style.display = "block";
}

function retrieveProfile() {
  var idToken = localStorage.getItem('id_token');
  if (idToken) {
    try {
      const profile = jwt_decode(idToken);
      showProfileInfo(profile);
    } catch (err) {
      alert('There was an error getting the profile: ' + err.message);
    }
  }
}

auth0.parseHash(window.location.hash, (err, result) => {
  if (err || !result) {
     // Handle error
    return;
  }

  // You can use the ID token to get user information in the frontend.
  localStorage.setItem('id_token', result.idToken);
  // You can use this token to interact with server-side APIs.
  localStorage.setItem('access_token', result.accessToken);
  retrieveProfile();
});

function afterLoad() {
  // buttons
  var btnLogin = document.getElementById('btn-login');
  var btnLogout = document.getElementById('btn-logout');

  btnLogin.addEventListener('click', function() {
    auth0.authorize();
  });

  btnLogout.addEventListener('click', function() {
    logout();
  });

  retrieveProfile();
}

window.addEventListener('load', afterLoad);

このコードを使った完全な例 を取得してください。

クイック スタート チュートリアル で、アプリで異なる言語やフレームワークを使用して認証の実装の仕方を学びましょう。

要約

本書では、フライトの時間表をシミュレートするリアルタイムのアプリケーションを作るために Server-Sent Events を使いました。この開発の間、標準はイベント入力や接続の管理、復元をサポートする機能を調査する機会がありました。また、デフォルトではサポートしない、Server-Sent Events のサポートをブラウザー上に 追加する方法についても学びました。最後に、Server-Sent Events と WebSockets を簡単に比較してみました。

必要であれば、この GitHub レポジトリの本書で開発したプロジェクトの最終コードをチェックし、ダウンロードできます。