「いや〜、node.jsってすごいよね」。カンファレンス参加後、しばらくの間はNew Bamboo社内ではnode.jsの話題が何度も上がってきたのですが、実際にnode.jsを使って何をすれば良いかはちょっと考えあぐねていました。その頃は、node.jsを使用したWebフレームワークなどが雨後のタケノコのように出てきていたのですが、「今までのWebサーバで出来ることをただ置き換えるだけっていうのはあんまり面白くないよね」というのが正直な気持ちだったと思います。
それから1カ月ほど経った2009年の12月、Webの世界に新たなニュースがありました。Googleが開発するブラウザのオープンソース版であるChromiumにWebSocketという新たな機能が加わるというのです。
WebSocketを一言で言うと「WebのためのTCP」です。今までのWebはHTTPプロトコルを基本とした一方向のものでした。クライアント(ブラウザ)はサーバにリクエストを渡し、サーバはクラインとにレスポンスを返すというスタイルです。
TCPはHTTPより下層に位置するネットワークプロトコルで、クライアントとサーバ間の双方向通信を可能とするものです。
TCPサーバをnode.jsで書くと以下のようになります。
var net = require('net'); var sys = require('sys'); var server = net.createServer(function (stream) { var array = []; stream.setEncoding('utf8'); stream.on('connect', function () { stream.write('connected\r\n'); }); stream.on('data', function (data) { array.push(data.slice(0,-2)); stream.write('res: ' + array.join(',') + '\n'); }); stream.on('end', function () { sys.log('disconnected') stream.end(); }); }); server.listen(8127, 'localhost');
この状態でtelnetすると、以下のようにTCPサーバとやりとりすることができます。
$ telnet 127.0.0.1 8127 Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. connected a res: a b res: a,b c res: a,b,c d res: a,b,c,d ^] # Ctrl + ] でtelnetに終了シグナルを送っています telnet> quit Connection closed. $
ここでTCPサーバは各セッションごとにarrayという配列を用意しておき、入力リクエストが来ると、そのデータを配列に代入し、全配列の結果をレスポンスとして戻しています。
HTTPとの重要な違いですが、HTTPは各リクエスト、レスポンスはまったく別個のものであり、各リクエスト間で状態(ステート)を共有することはありません。逆に、TCPの方は一度クライアントとサーバとの間で接続が確立されると、その後のやり取りで状態を共有することができます。HTTPのことをステートレスと呼ぶのに対し、TCPのことをステートフルと呼びます。
今までHTTPで擬似的にステートを共有するには、以下の方法が取られていました。
もともとHTTPはHTML文書を表示するために規定されたものなので、「リクエスト/リスポンス」の単純なモデルで十分だったのですが、多くのWebサイトが「アプリ化」してきている現在では、上のような方法で擬似的にダイナミックなアプリを作るには限界があります。WebSocketはそんな限界を打ち破る可能性を秘めた新たなプロトコルといって良いでしょう。
そしてWebSocketのステートフルな性質を利用することで「サーバサイドプッシュ」を実現することができます。必要なときにサーバサイドから情報を送ることができれば、ムダなトラフィックを減らせます。
例えば、雪が降ったり、災害のときなどに交通機関のサイトやニュースサイトがアクセス超過でサイトがダウンすることがあります。これは、情報が更新されるタイミングが分からないために、みんなが何度も「リフレッシュボタンを」押すからでしょう。これはリクエスト/レスポンスというモデルの弊害と言ってよいでしょう。
もしこういったサイトをWebブラウザで開いておいて(TCPでいう接続の確立)、ニュースがあったときのみサーバからクライアントの方に情報をプッシュしてくれれば、余計なトラフィックを抑えられるはずです。
前置きが長くなってしまいましたが、WebSocketをブラウザから使うためのJavaScript APIは以下です。
var ws = new WebSocket("ws://example.com/service"); ws.onopen = function() { // Web Socket is connected. You can send data by send() method. ws.send("message to send"); .... }; ws.onmessage = function (evt) { var received_msg = evt.data; ... }; ws.onclose = function() { // websocket is closed. };
APIは非常に単純ではないでしょうか。
まず、1行目でWebSocketオブジェクトを作成した後、「onopen,onmessage,onclose」という3つのコールバックファンクションを定義するだけです。サーバの方にメッセージを送りたい場合は「send()」というファンクションを呼び出すだけです。
このWebSocketのAPI、先ほど例として挙げたnode.jsのTCPサーバサイドのコードに非常に似ていると思いませんか? 私はこの例を見たときに「WebSocketとnode.jsって合うのでは」と思いました。早速node.jsのWebSocketサーバを探してみたところ、プロトタイプ版のようなライブラリが2つほど見つかりました。そこで、これを使い、週末を利用してMacのActivity MonitorのようなもののWeb版を作ってみることにしました。
ソースの全文はgithub上にあります。当時は「websocket-server-node.js」というライブラリを使っていたのですが、今はすでにメンテされていないようなので「node-websocket-server」というライブラリに変更して書き直してみました。
なお、今回のサンプルのiostat-client.htmlですが、わざわざWebサーバ上に置かなくとも、ファイルをローカル上で開くだけで実行可能なはずです。
var sys = require('sys') , http = require("http") , ws = require('./node-websocket-server/lib/ws'); var iostat = require('child_process').spawn("iostat", ["-w 1"]); var httpServer = http.createServer(); var server = ws.createServer({}, httpServer); function format (data) { // JSON形式にOutputを変換するコード } // Handle WebSocket Requests server.addListener("connection", function(conn){ server.send(conn.id, "Connected as: "+conn.id); iostat.stdout.on('data', function (data) { server.send(conn.id, format(data)); }); }); server.addListener("close", function(conn){ sys.log("closed connection: "+conn.id); }); server.listen(8000);
「connection」と「close」という2つのコールバック関数を指定している点は、TCPサーバの例と大変似ていますね。少し違う点としては、ブラウザからの入力を取らず、
var iostat = require('child_process').spawn("iostat", ["-w 1"]);
のところで子プロセスを作り、iostatというコマンドが1秒おきにCPUやIO情報を出力するようにしている点です。そして、
iostat.stdout.on('data', function (data) { server.send(conn.id, format(data)); });
のところでチャイルドプロセスの出力結果を1秒ごとに出力するようにしています。
ブラウザ側のコードもいたってシンプルです。
webSocket = new WebSocket('ws://localhost:8000/iostat'); webSocket.onopen = function() { out.html('Connection opened.<br>'); }; webSocket.onmessage = function(event) { stats = event.data; data = JSON.parse(stats); drawCharts(total_array.reverse()); };
「onmessage」コールバックにデータが送られてくるたびに、JSONデータを解析し、その結果をグラフとして再描画しています。
ここまで読んだ読者の皆さんは思うかもしれません。「AjaxやCometでも同じことができるんじゃないの?」
まず以下の図を見てみてください。
最初の例はAjaxです。毎秒ごとにHTTPリクエストを送っています。 HTTPのリクエストとレスポンスにはヘッダー部分に以下のようにいろいろな付帯情報を付けなければいけません。
GET /index.html HTTP/1.1 Host: www.example.com
HTTP/1.1 200 OK Date: Mon, 23 May 2005 22:38:34 GMT Server: Apache/1.3.3.7 (Unix) (Red-Hat/Linux) Last-Modified: Wed, 08 Jan 2003 23:11:55 GMT Etag: "3f80f-1b6-3e1cb03b" Accept-Ranges: bytes Content-Length: 438 Connection: close Content-Type: text/html; charset=UTF-8 ※http://en.wikipedia.org/wiki/Hypertext_Transfer_Protocolからの例
1リクエスト/レスポンスヘッダー自体は数KBと、そう大きくありませんが、リクエストの更新頻度が密になってくると、毎回のリクエスト量は馬鹿になりません。更新頻度を遅くすると、欲しい情報がすぐに手に入らないし、更新頻度を上げるとサーバへのリソース要求が高くなる、というジレンマを抱えることになります。
また疑似サーバプッシュ技術を総称する用語として「Comet」というものがあります。これはいろいろな実装方法が混在しており、なかなか分かりづらい用語なのですが、一般的なものに「Long Polling」と呼ばれるものがあります。これは次のように流れになります。
まず、サーバがクライアントからリクエストを受けた際、すぐにレスポンスを返すのではなく、 コネクションをつなぎっぱなしにしておきます。そして何か情報を更新するときになってからレスポンスを返します。これは私が最初に述べた「災害情報の更新」やチャットなど数秒以上に一度しかレスポンスを返さないようなケースでは有効ですが、更新頻度が上がってくるにつれ、AJAXと同じような問題を抱えることになります。さらにCometで定期的に疑似プッシュしようとした場合、「どの時点までのデータをクライアントに送信したか」というステート情報も別に管理しなければいけません。
私のActivity Monitorの例を見た方から「node.jsとLong Polling方式で実装しました」と教えていただいたので、彼のコードの一部を見てみましょう。
var rb = new lpb.LongPollingBuffer(200); var iostat = process.createChildProcess("iostat", ["-w 1"]) //Setup the listener to handle the flow of data from iostat iostat.addListener("output", function (data) { sys.puts(data); if(data.search(/cpu/i) == -1){ //suppress the column header from iostat rb.push(data.trim().split(/\s+/).join(" ")); } }); //Setup the updater page for long polling fu.get("/update", function (req, res) { res.sendHeader(200,{"Content-Type": "text/html"}); var thesince; if(url.parse(req.url,true).hasOwnProperty('query') && url.parse(req.url,true).query.hasOwnProperty('since')){ thesince = parseInt(url.parse(req.url,true)['query']['since']); } else {thesince = -1;} rb.addListenerForUpdateSince(thesince, function(data){ var body = '['+_.map(data,JSON.stringify).join(',\n')+']'; res.sendBody( body ); res.finish(); }); });
「/update」のURIにクライアントが最後にデータを受け取った時間を毎回送付して、サーバの現在時刻と比較。差分のデータを送るようにしています。毎リクエストごとのステートを管理するコードを別途書かないといけない上に、今回のように時間を扱う場合、「クライアントマシンの時間設定がちゃんと設定されていない場合はどうなるの」といったケースにも対応する必要があるでしょう。
一方のWebSocketですが最初に接続を確立する時に以下のようなリクエストをサーバの方に送ります。
GET /demo HTTP/1.1 Host: example.com Connection: Upgrade Sec-WebSocket-Key2: 12998 5 Y3 1 .P00 Sec-WebSocket-Protocol: sample Upgrade: WebSocket Sec-WebSocket-Key1: 4 @1 46546xW%0l 1 5 Origin: http://example.com ^n:ds[4U
サーバサイドはリクエストを受け取った後、以下のようなレスポンスを返します。
HTTP/1.1 101 WebSocket Protocol Handshake Upgrade: WebSocket Connection: Upgrade Sec-WebSocket-Origin: http://example.com Sec-WebSocket-Location: ws://example.com/demo Sec-WebSocket-Protocol: sample 8jKS'y:G*Co,Wxa-
基本的には通常のHTTPリクエスト/レスポンスとほぼ同じです。
ここまでだとAJAXやCometと同じように見えるかもしれませんが、このやりとりを行ってクライアントとサーバの間で接続が確立された後は、以下のようにデータの前後に1バイトずつ付けた「データフレーミング」という形式でデータのやりとりを行うことになります。
0x00[Data]0xFF
【注】WebSocketのデータフレーム形式(0x00[Data]0xFF)は、この夏ごろから仕様の変更が議論されています。今後、連載中にも仕様が変わる可能性があります。
接続時のHTTPリクエスト/レスポンスに比べて明らかにデータ転送のオーバヘッドが少なくなるのが見て取れると思います。
こういったパフォーマンスの劇的な向上をもたらす可能性を秘めたWebSocketですが、ドラフト起草者であるGoogleのIan Hicksonさんはメーリングリストの中で以下のように述べています。
Reducing kilobytes of data to 2 bytes…and reducing latency from 150ms to 50ms is far more than marginal. In fact, these two factors alone are enough to make WebSocket seriously interesting to Google.
「数キロバイトのデータ転送量を(最低)2バイトへ、150msの遅延を50msにできるとしたら、それはわずかな向上といったような生やさしいものではない。実際この2つの事実だけをもっても、WebSocketをGoogleにとって本気にさせるには十分なものだ」
またWebSocketのうれしい点としてクロスドメインが可能な点も挙げられます。
AJAXやCometの場合、原則クロスドメインスクリプティングができないため、JSONPなどのテクニックと併用する必要がありますが、WebSocketは原則可能です(最初のリクエストヘッダーの部分でSec-WebSocket-Locationを指定しているので、それを利用して既知のドメインのみアクセスを許すといった制限をかけることも可能です)。
さて、今回はここまでです。リアルタイムWebとは何かということと、WebSocketの実例を示し、AjaxやCometとの違いについて述べました。次回は、WebSocketのブラウザの対応状況などを交えつつ、技術的課題やサーバサイドの実装例などについてご紹介したいと思います。
Copyright © ITmedia, Inc. All Rights Reserved.