
本記事はCraig Bilner、Dan Princeが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
リッチインターネットアプリケーションを書くときに重要なのは、データの更新への反応です。以下は、2014年のBrazilJSでGuillermo Rauchが『The 7 Principles of Rich Web Applications』と題して講演した内容から引用したものです。
サーバーでデータが変化した場合、問い合わせがある前にクライアントに知らせてください。ユーザーが手動で再読み込み(F5、再読み込み)をしなくても済むような性能の改善です。新たな課題に、(リ)コネクション管理とステートリコンシリエーション(一致)があります。
この記事では、自己更新する「リアルタイム」UIを作るために、生のWebSocket APIと、Server-Sent Events (SSE)のためのEventSourceの使い方について説明します。まだこの意味がよく分からない人は、『The 7 Principles of Rich Web Applications』のビデオを見るか、対応するブログ記事を読むことをおすすめします。
これまでの経緯
かつて、サーバープッシュを実現する方法としては、もっともよく知られた方法であるlong pollingをシミュレートしなければいけませんでした。サーバーからメッセージをプッシュする準備ができるまで開かれている長いリクエストを、クライアント側で作る必要がありました。メッセージを受信したあとにリクエストが閉じられると、新たなリクエストを作ります。ほかのソリューションとして<iframe>ハックやFlashがありましたが、理想的とは言えませんでした。
次に、Operaが2006年に、WHATWG Web Applications 1.0の仕様の一部としてServer-Sent Events (SSE)を発表しました。SSEがあれば、訪問者のブラウザーに対してサーバーから継続的なストリームを流せます。ほかのブラウザーもSSEに対応し、2011年にHTML5の仕様の一部としてSSEの実装が始まったのです。
WebSocketプロトコルが標準化された2011年、興味深い動きが続きました。WebSocketがあれば、クライアントとサーバーの間で双方向の持続的な接続をオープンできます。クライアントからのリクエストがなくても、サーバー上のデータの変化をクライアントに知らせられるようになりました。たとえばマルチプレイヤー型オンラインゲームのように同時接続数が多く、コンテンツが刻々と変化するアプリにとって、反応の向上は極めて重要です。しかし、WebSocketを使うときにもっとも傑出した手法であるsocket.ioが2014年にリリースされるまで、リアルタイムでやりとりをしながらデータの変化をクライアントに知らせる実験をあまり目にすることはありませんでした。
現在では、新たにリクエストを発行したり非標準的なプラグインに頼ったりせずに、サーバープッシュできる、よりシンプルな方法があります。これらの技術は、サーバーで何かが起こった瞬間にクライアントにデータをストリームバックできる能力を持っています。
WebSockets
常時接続で何が可能になるかを理解するもっとも簡単な方法はデモを見ることです。あとでコードを見ますが、まずは以下からデモをダウンロードして触ってみてください。
デモ
git clone https://github.com/sitepoint-editors/websocket-demo.git
cd websocket-demo
npm install
npm start
マルチウィンドウブラウザーでhttp://localhost:8080/を開いて、ブラウザーとサーバーの両方のログを観察し、メッセージが行ったり来たりする様子を見てみてください。サーバーがメッセージを受け取る時間と、接続されているクライアントに変化が通知されるのにかかる時間に注目しましょう。
クライアント
WebSocketコンストラクターは、wsまたはwss(セキュア)プロトコルでのサーバー接続を起動します。また、サーバーにデータをプッシュするためのsendメソッドを持っており、サーバーからデータを受け取るためのonmessageハンドラーを提供します。
次のコメント付きサンプルコードは、重要なイベントをすべて出力するものです。
// Open a connection
var socket = new WebSocket('ws://localhost:8081/');
// When a connection is made
socket.onopen = function() {
console.log('Opened connection ');
// send data to the server
var json = JSON.stringify({ message: 'Hello ' });
socket.send(json);
}
// When data is received
socket.onmessage = function(event) {
console.log(event.data);
}
// A connection could not be made
socket.onerror = function(event) {
console.log(event);
}
// A connection was closed
socket.onclose = function(code, reason) {
console.log(code, reason);
}
// Close the connection when the window is closed
window.addEventListener('beforeunload', function() {
socket.close();
});
サーバー
サーバー上でWebSocketと一緒に使うノードライブラリーで一番人気が高いのが、wsです。WebSocketサーバーの記述は簡単な作業ではないので、wsを利用してシンプルにします。
var WSS = require('ws').Server;
// Start the server
var wss = new WSS({ port: 8081 });
// When a connection is established
wss.on('connection', function(socket) {
console.log('Opened connection ');
// Send data back to the client
var json = JSON.stringify({ message: 'Gotcha' });
socket.send(json);
// When data is received
socket.on('message', function(message) {
console.log('Received: ' + message);
});
// The connection was closed
socket.on('close', function() {
console.log('Closed Connection ');
});
});
// Every three seconds broadcast "{ message: 'Hello hello!' }" to all connected clients
var broadcast = function() {
var json = JSON.stringify({
message: 'Hello hello!'
});
// wss.clients is an array of all connected clients
wss.clients.forEach(function each(client) {
client.send(json);
console.log('Sent: ' + json);
});
}
setInterval(broadcast, 3000);
wsパッケージはWebSocketが使えるサーバーの作成をシンプルにしますが、商用製品で使う場合にはWebSocket Securityをしっかり読んでください。
ブラウザーの互換性
Opera MiniとIE9以前のバージョンなどの例外はありますが、WebSocketのブラウザーサポートはしっかりしています。古いIEにはFlashを使うpolyfillが用意されています。
デバッグ
Chromeでは、Network > WS > Framesでメッセージ送受信の確認ができます。送信済みのメッセージは緑に表示されます。
FirefoxでのWebSocketのデバッグは、Firefoxの開発ツールWebsocket Monitor addonを使えばできます。Firebug開発チームによって開発されたものです。
Server-Sent Event
WebSocketと同様、SSEも常時接続ができます。サーバー上で何かが変更された瞬間に、接続されたクライアントにデータを送り返します。唯一の注意点は、メッセージを逆方向には送れないことですが、従来のAjaxテクニックが使えるのであまり問題にはなりません。
デモ
git clone https://github.com/sitepoint-editors/server-sent-events-demo.git
cd server-sent-events-demo
npm install
npm start
前の例と同様、マルチウィンドウブラウザーでhttp://localhost:8080/を開いて、ブラウザーとサーバーの両方のログを観察してメッセージの行き来を確認してください。
クライアント
EventSource関数は、おなじみのHTTPまたはHTTPSでのサーバー接続を開始します。WebSocketと似たAPIが用意されていて、サーバーからデータを受け取るときにonmessageハンドラーが使えるようになります。次のコードは、重要なイベントに注釈を付けたサンプルです。
// Open a connection
var stream = new EventSource("/sse");
// When a connection is made
stream.onopen = function() {
console.log('Opened connection ');
};
// A connection could not be made
stream.onerror = function (event) {
console.log(event);
};
// When data is received
stream.onmessage = function (event) {
console.log(event.data);
};
// A connection was closed
stream.onclose = function(code, reason) {
console.log(code, reason);
}
// Close the connection when the window is closed
window.addEventListener('beforeunload', function() {
stream.close();
});
サーバー
サーバーから送られるイベントを作るため、コンパクトでよくできたラッパーにsseがあります。根本的には作業をシンプルにするために使いますが、サーバーからイベントを送ること自体は十分シンプルです。サーバー上でSSEがどのように動作するのか後ほど説明します。
var SSE = require('sse');
var http = require('http');
var server = http.createServer();
var clients = [];
server.listen(8080, '127.0.0.1', function() {
// initialize the /sse route
var sse = new SSE(server);
// When a connection is made
sse.on('connection', function(stream) {
console.log('Opened connection ');
clients.push(stream);
// Send data back to the client
var json = JSON.stringify({ message: 'Gotcha' });
stream.send(json);
console.log('Sent: ' + json);
// The connection was closed
stream.on('close', function() {
clients.splice(clients.indexOf(stream), 1);
console.log('Closed connection ');
});
});
});
// Every three seconds broadcast "{ message: 'Hello hello!' }" to all connected clients
var broadcast = function() {
var json = JSON.stringify({ message: 'Hello hello!' });
clients.forEach(function(stream) {
stream.send(json);
console.log('Sent: ' + json);
});
}
setInterval(broadcast, 3000)
サーバーからイベントを送る
先に書いたように、サーバーからイベントを送る作業は十分シンプルです。
HTTPリクエストがEventSourceから送られてくると、そこにはtext/event-streamのAcceptヘッダーが付いています。ヘッダーに応答し、HTTP接続を維持するとともに、クライアントにデータを送り返す準備ができたら、Responseオブジェクトに特別なフォーマットデータdata: <data>\n\nを書き込みます。
http.createServer(function(req, res) {
// Open a long held http connection
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// Send data to the client
var json = JSON.stringify({ message: 'Hello ' });
res.write("data: " + json + "\n\n");
}).listen(8000);
dataフィールドだけでなく、必要であればイベント、id、リトライフィールドも送れます。
event: SOMETHING_HAPPENED
data: The thing
id: 123
retry: 300
event: SOMETHING_ELSE_HAPPENED
data: The thing
id: 124
retry: 300
SSEの実装はクライアントにもサーバーにもシンプルではあるものの、注意点はクライアントからサーバーへデータを送る手段が提供されていないことです。ただし幸運なことに、XMLHttpRequestやfetchを使えばクライアントからサーバーへデータを送れます。新しく重要なことは、サーバーからクライアントにプッシュできることなのですから。
セキュリティー確保のために、HTTP標準のCross-Originルールが適用されますので、サーバー上およびクライアント上のOriginを次のように常にホワイトリストにしておく必要があります。
stream.onmessage = function(event) {
if (e.origin != 'http://example.com') return;
}
こうしておけば、おなじみのAjaxを使っていつものようにサーバーにプッシュできるようになります。
document.querySelector('#send').addEventListener('click', function(event) {
var json = JSON.stringify({ message: 'Hey there' });
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(json);
log('Sent: ' + json);
});
ブラウザーの互換性
マイクロソフトがSSEをサポートするブラウザーをリリースしなかったため、SEEのブラウザーサポートはWebSocketをサポートするブラウザーよりも世代が古くなります。バグの報告もあるので、次のリリース版に入るように、みんなでSSEに投票して優先度を上げるべきでしょう。
現時点では、IEとEdgeでSSEを動作させる必要がある場合はPolyfill for EventSourceを使います。
デバッグ
Chromeでは、Network > XHR > EventStreamでメッセージ受信を確認できます。
課題
冒頭で引用したGuillermo Rauchの記事の中では、(リ)コネクション管理とステートリコンシリエ―ション(一致)が新たな課題であると述べられています。彼の言うとおりです。接続が途切れたあとに再接続されたら何が起こるか考えておく必要があります。
EventSourceには再接続する機能が組み込まれており、接続が切れた場合、自動で3秒ごとに再接続を試みます。前のSSEのデモを開いてブラウザーで接続状態を作り、Ctrl + Cでサーバーを停止させればテストできます。npm startを使って再度サーバーをスタートさせるまで、ログのエラーが記録され、静かな状態が保たれるでしょう。
WebSocketにはこの機能がありません。接続が切れたあとに同じアクションが必要なら、自分で新たに再接続してイベントを立ち上げなおす必要があります。
ステートリコンシリエーションとは、再接続されたときにクライアントをサーバーに同期させるための作業です。接続切れが発生したタイミングを記録し、再接続されたら未接続時にクライアントが逃していたイベントを送ります。
これらの課題に対するソリューションは、あなたがどんなアプリを作っているかによって異なります。
- マルチプレイヤーオンラインゲームを作っているなら、再接続されるまでゲームを停止させる必要があるかもしれません
- シングルページのアプリなら、ローカルで変更箇所を保存して、再接続時にアップデートをまとめてサーバーに送るとよいかもしれません
- 何枚かの「リアルタイム」ページで持っているだけの従来型のアプリなら、最終的には一貫性があればいいので、接続が切れたかどうかを気にする必要はないかもしれません
フレームワーク
WebSocketの時代がこれからやってきます。サーバーでどのようなプログラム言語を動かしていても、持続的な接続とクライアントへのブロードキャスティングを担うメソッドを含むフレームワークがあります。
クライアント側から見ると、これらのフレームワークは(リ)コネクションマネジメントとステートリコンシリエーションの課題に対処するための方法であり、異なる「チャネル」への登録を簡単にしてくれます。サーバー側から見ると、これらのフレームワークは、開いているコネクションのプーリングとブロードキャストの仕組みをもたらします。
アプリにリアルタイム機能を実装するときに、HTTPの知識を捨ててやり直す必要はありません。あなたは、クライアントが登録できる1つの追加ルートまたはチャネル、リアルタイムで更新されることで利益を得る何かを追加して始めればよいのです。クライアントとサーバー双方のパフォーマンスを改善するために実行してください。クライアントは何かが起こると瞬時にアップデートされますし、サーバーは長いポーリングに応える必要もないからです。
もういますか? 動いていますか?
いまなら、サーバーは起動時に応答できます。
私たちがそこにいるとき、あなたに教えてあげましょう。
関連リンク
(原文:Building Real-time Apps with Websockets & Server-Sent Events)
[翻訳:島田理彩]
[編集:Livit]
更新履歴:「クライアント」の小見出しの段落の翻訳を一部、変更しました。(2016/8/2)
