uhyohyo.net

JavaScript初級者から中級者になろう

十三章第二回 Server-Sent Events

今回紹介するのはServer-Sent Eventsと呼ばれる仕組みです。これは、「サーバーから送られたイベント」という程度の意味でしょう。

これはどういうことかというと、push型のデータ通信が可能になります。push型というのは、「サーバーから能動的にデータを送ってくる」ということです。

今まではどうだったかというと、サーバーからデータをもらうには、HTTP通信でこちらからリクエストを送り、それに応じてサーバーがレスポンスを返すろいう形でした。これは、こちらからデータをもらいに行かないとデータがもらえないということです(JavaScriptからなら、前回のXMLHttpRequestが使用できるでしょう)。

例えばJavaScriptでチャットを作りたいとしましょう。新しい発言があったかどうかを知るためには、一定時間ごとにサーバーにリクエストを送ってそれで確かめる必要があるのです。

それに対しpush型ならば、新しい発言があった時点でサーバー側からデータを送ってもらうということが可能です。前者の場合には一定時間ごとにしか最新の発言を得るチャンスはありませんでしたが、後者ならばリアルタイムで発言を取得することが可能です。

そのpush型を可能にするのがServer-Sent Eventsなのです。

Server-Sent Eventsの仕組み

Server-Sent Eventsにおいては、やはりHTTP通信を使用します。通常のHTTP通信においては、こちら側からサーバー側へリクエストを送り、サーバーからレスポンスが帰ってくるとそれで終了します。しかし、push型にするためには、通信が切れてしまっては困ります。そこで、Server-Sent Eventsでは、HTTP通信でサーバーから応答しても、接続を終了せずに維持します。こうすることで、その接続を利用してサーバー側からメッセージを継続的に送ることができます。これは形としては、サーバーからのレスポンスを時間をかけて送り続けているという体裁になります。

ちなみに、これだとサーバーから一方的に送るだけで、逆にこちらからサーバーからへは送れません。なぜなら、HTTP通信においてはあくまで「リクエスト→レスポンス→終了」という流れをとるため、一度サーバーがデータを送り始めたらもうこちら側からデータを送れなくなるからです。

それを克服した仕組みにWebSocketがあります。これも機会があったら紹介します。これはもはやHTTPではない別のプロトコルを使うので、双方向に通信が可能なリアルタイムのやつです。これも使えると楽しいです。

さて、Server-Sent Eventsを使うには、サーバー側からそれに対応したデータを送ってもらう必要があります。

HTTP通信なのですが、サーバーはtext/event-streamというMIMEタイプでレスポンスを返します。サーバーから送られてくるデータの1単位はイベントといいます。イベントは文字列です。

イベントは、サーバーから次のようなテキストで送られてきます。

data: foo

この場合「foo」という中身のイベントがサーバーから送られてきます。このように、サーバーから送られてくるイベントは行の最初に「data: 」と書かれてある必要があります。また、内容は複数行になっても可能です。この場合、すべての行の最初にdata:が必要です。

data: foo
data: bar

これは、「foo(改行)bar」という2行の内容からなる1つのイベントです。

また、複数のイベントは、空行によって区切られます。例えば、

data: foo
data: bar

data: test

とした場合、「foo(改行)bar」というイベントと「test」というイベントの2つが送られてきていることを意味します。

さて、「push型」というくらいですから、一度に全てのイベントが送られてくる必要はありません。サーバーが送りたいときに、新しいイベントを追加で送ってきてもいいのです。むしろ、それこそがServer-Sent Eventsの意味であるとも言えるでしょう。

具体例を見せましたが、ここで大事なことは実は、イベントは複数行にわたって送られてきてもよく、イベントを区切るには空行をはさむということです。また、送られてくるイベントデータの一行一行をフィールドといいます。

イベントの中身

フィールドは実は、

フィールド名: フィールドの中身

という構成になっています。上の例は全て、フィールド名が「data」であった場合です。フィールド名がdataであった場合、そのフィールドは、イベントの内容を表します。上の例のように、dataフィールドは2つ以上あっても構いません。その場合、1つ1つがイベントの中身の1行1行を表すことになりますね。

それでは、フィールド名には他に何があるかというと、eventというイベント名があります。つまり例えば、

event: test

これは何を表すかというと、イベント名です。つまり、イベントに、中身とは別に名前をつけられるのです。ちなみに、今までの例のような、eventフィールドがない場合、イベントの名前はデフォルトである「message」になります。

今のところ、このdataフィールドとeventフィールドを覚えておくと良いでしょう。

JavaScriptからの利用

では、JavaScriptからServer-Sent Eventsを利用してサーバーからデータを受信する方法を紹介します。それは、EventSourceというオブジェクトを使います。

var stream= new EventSource("test.cgi");

ここで第一引数にURLを指定しています。これは、前述のイベントを配信してくれるページのURLですね。

このとき、同オリジンポリシーが適用されます。これは、前回も登場した、同じオリジンでないと通信できないということです。また、Access-Control-Allow-Originを使えば異なるオリジンでも通信可能という点も同じです。

こうしてEventSourceのインスタンスができましたが、これは3つのプロパティを持ちます。urlは接続先のURLです。また、withCredentialsを持っています。これは前回のXHRと同じ意味です。また、readyStateも持っています。readyStateは前回のXHRよりも単純で、値は3つしかありません。また、readystatechangeイベントもありません。

0 (CONNECTING)
また接続が確立されていないことを表します。
1 (OPEN)
現在接続中で、イベントを受信する準備ができていることを表します。
2 (CLOSED)
接続が終了した状態です。

さて、それではイベントを受信するには、やはりXHR同様にイベントを使います。イベントを検出するには、もちろんaddEventListenerです。イベントは、上で紹介した、eventフィールドを利用して設定するイベント名がそのまま使用されます。例えば、デフォルトのmessageという名前のイベントのときはこうです。

stream.addEventListener("message",function(e){

},false);

ここでイベントオブジェクトeはMessageEventといいます。イベントの中身は、このdataプロパティに入っています。またoriginには配信元のURLが入っています。

ここで、サンプルを見てみましょう。

このサンプルでは、13_2_sample.cgiが配信しています。これは、次のようなイベントを配信します。

data:Hello

data:Hello2

data:foobar

実は、アクセスを受けてから数秒待ったあとにイベントを配信するようになっています。

それで、受信側のソースは簡単で、こうです。

var stream=new EventSource("13_2_sample.cgi");
  stream.addEventListener('message',function(e){
  console.log(e.data);
});

13_2_sample.cgiに接続して、messageという名のイベントが届いたらそのデータをコンソールに表示します。今回は4つのイベントをまとめて配信していますが、間をあけて複数回イベントを送れば、間をあけてイベントが発生するのが見られるでしょう(本当はそのようなサンプルを用意したかったのですが、サーバーの仕様等の関係でうまくいきませんでした)。

ここでイベント名がmessageなのは、上でeventフィールドが無いので省略されているからです。eventフィールドで別の名前を指定すれば、その名前のイベントになります。もちろん、サーバー側から複数の種類(名前)のイベントを配信しても構いません。その場合別々のイベントが発生することになり、複数種類の情報を配信するときも楽です。

今回の場合はただのCGIなのであまり使い道がわかりませんが、もっとちゃんとしたサーバーを用意すれば、チャットとか、実用的な用途も見つかることでしょう。ただし、上でちらりと紹介したWebSocketにそういった方面の座をとられていることも否めません。

接続と切断

ところで、さっきのサンプルを眺めていると、奇妙な動作をすることに気が付きましたか。

イベントを受信したあと、しばらくするともう一度同じイベントを受信します。これは、サーバー側から何度もループしているわけではありません。サーバーは、最後のイベントを出力した後に終了しています。

それではなぜこのような現象が起こるのでしょう。実は、EventSourceでは、サーバーから接続が切断された場合、再接続を試みるのです。ただし、404が返ってきたとか、サーバーに繋がらないとか、そういうときは再接続はしません。一度はつながったのに切れてしまった場合には、再接続を試みるのです。

これは、不慮の事態によって接続が切断されてしまった場合には便利ですが、もう配信を終了したいという場合には困ります。実は上のreadyStateの2(CLOSED)というのは、もう再接続もできず、終了してしまったという状態を指します。

それに対処するために、2つのイベントを紹介します。それはopenerrorです。openは単純であり、配信元との接続が確立して準備が完了したときに発生します。そしてerrorというのは、実は接続が切断され、再接続を試みるときに発生します。

ですから、errorが発生したときに、再接続を阻止してやればいいのです。しかし、DOMのときと違って、preventDefault()すればいいわけではありません。ここで使うのが、EventSourceのcloseメソッド(引数無し)です。これを呼び出すと、接続を切断して、再接続も行わなくなります。つまり、以下のようになります。

stream.addEventListener("error",function(e){
  stream.close();
});

これを追加したサンプル2で確認しましょう。0まで受信した後、待ってみてもまた10から始まることはありません。配信元との接続を終了してしまったからです。

また、再接続に関して、再接続の待機時間(接続が切断されてから、次に再接続する間に待機する時間)を設定することができます。これは、サーバー側から設定します。

どのように設定するかというと、retryフィールドを使います。つまりこうです。

retry: 1000
data:Hello

(以下略)
        

この1000というのはミリ秒なので、1秒という意味になります。接続が切断されたら1秒待ってから再接続しろということです。

ちなみに、今までdataフィールド、eventフィールド、retryフィールドを紹介しましたが、もう一種類だけあります。それがidフィールドです。これは、そのイベントに対してIDを付加したい場合に使用できます。例えばこんな感じです。

data:Hello
id: abc

data:Hello2
id: foo
        

このidはMessageEventのlastEventIdプロパティで参照できますが、もうひとつ意味があります。

接続が切れて再接続したとき、一番最後に受信したイベントのIDがLast-Event-IDというHTTPヘッダとなってサーバーへ送られます。これにより、サーバー側をうまく作れば、接続が切れたとき次の接続で途中から再開するようなことも可能でしょう。

また、再接続といえば、サーバーからの配信が全部終了して切断したときは再接続したくないけれども、それ以外のときは再接続したいという場合もあるでしょう。そのときはerrorイベントでは対処できません。こういうときどうすればいいかというと、考えると単純です。サーバー側から「もう接続を終了しなさい」ということを送ってもらえばいいのです。例えばこうです。

(略)

data:foobar

event: close
        

データを最後まで送った後、「close」という名のイベント(dataフィールドがないので中身はない)が送られてきています。これを受けて、

stream.addEventListener("close",function(e){
  stream.close();
});

こういう感じで、closeを受け取ったときだけ閉じてやればいいのです。

実は今回使用した13_2_sample.cgiでは、最後にevent:closeとしてcloseイベントを配信しています。よければ試してみましょう。

さて、以上でServer-Sent Eventsの説明は終了です。まとめると、HTTPでサーバー側から一方的なpush型の通信が可能だということです。あまり使い所がないかもしれませんが、もし機会があったら使うのもよいでしょう。