著者が専門とするAjax関連の話題では「OpenAjax」と「Comet」が様々な場面で触れられていました。特にCometに関してはBOFを中心に、以下のセッションで取り上げられています。
- TS-5250 Asynchronous Ajax for Revolutionary Web Applications
- TS-4883 Advanced Java NIO Technology-Based Applications Using the Grizzly Framework
- BOF-6584 Using Comet to Create a Two-Player Web Game
- BOF-5661 Comet: The Rise of Highly Interactive Web Sites
- BOF-4922 Writing Real-Time Web Applications, Using Google Web Toolkit and Comet
誕生から2年を経てCometは「何が出来るのか」という議論から、「いかに実現するか」という議論に関心が移ってきたように見えます。そこで本稿では同じくJavaOneで数多く取り上げられたNetBeans 6.1とGlassFish v3を使いながら、サンプルを交えてCometを解説していく事にします。Servlet 3.0の目玉機能の一つでもあるCometを先取りしてみましょう。
Cometとは?
Cometとは持続的なHTTPコネクションを使用して、クライアントからの指示を必要とせずにサーバーからデータを送信するWebアプリケーションのアーキテクチャを指します。何かの仕様やライブラリではなくAjaxと同様に「アーキテクチャ」であり、2006年3月にDojoライブラリの開発者であるAlex Russell氏によって命名(source)されました。従来のWebアプリケーションでは、マウスクリックといったブラウザ上で発生したイベントを起点として処理が行われてきましたが、Cometによってサーバーで発生したイベントで駆動するアプリケーションを実現できます。サーバーで発生したイベントを検出するには、次に示すような方法があります。
- ポーリング
- ロングポーリング
- ストリーミング
このうち、持続的なHTTPコネクションを使用する、つまりCometと呼べる方法は後ろの2つです。まずは各手法の特徴を順に見ていきましょう。それぞれの手法の動作を次の図に示します。
まず最初に、ポーリングは定期的にリクエストを送信してイベントが発生したかどうか確かめる方法です。例えば、javascriptの setInterval関数を使用してXHRリクエストを何度も送信することなどが相当します。従来のリクエスト-レスポンス型に近い形でクライアントとサーバの両端を実装できるので実現が容易ですが、無駄なリクエストとレスポンスが発生してしまう欠点があります。また、リクエストを送信する間隔を短くするとサーバの負荷が高くなり、長くするとイベントの通知が遅くなるというジレンマを抱える事になります。
続いて、ロングポーリングはサーバーがイベントが発生するまでレスポンスを返すのを保留して、クライアント側はレスポンスが返ると同時に再度リクエストを送信する方法です。例えば、XHRのコールバック関数で再度通信を行うことなどが相当します。ポーリングと違い、無駄なレスポンスを排除する事ができますが、リクエストの方は依然として残る欠点があります。また、サーバー側はイベントが発生するまでの間にスレッドをブロックするので、すぐに接続キューの空きが不足してしまう問題を抱えます。
最後に、ストリーミングはコネクションを張りっぱなしの状態にして、イベントが発生するたびにレスポンスを返す方法です。本法ではクライアント側において、全てのレスポンスが届く前にデータを解析する必要があるので、少々トリッキーな実装を行う必要があります。例えばIEのXHR通信では、全ての読み込みが完了するまでコールバック関数は実行されません。未完了のレスポンスを読み込むにはいくつか方法がありますが、最も簡単なのはiframeを使う方法です。
本手法では空のiframeとレスポンスの処理を記述した関数を予めページに記述しておき、Comet処理が必要となった時点でiframeのsrc属性にサーバーへのパスを指定します。すると自動的にGETリクエストが送信されるので、サーバは応答を返さずに保留させ、イベントが発生するたびにレスポンスを返すようにします。このレスポンスはiframeの中に流れていくので、レスポンスに親ページで定義した関数を呼び出す内容を含めておくと、それが到着するごとに解析処理が実行されます。
以上のような方法で実現できるストリーミングですが、無駄なリクエストとレスポンスを完全に排除できる一方で、ロングポーリングと同様のスレッド問題が残ります。この問題を解決するために、現在は以下のようなアプリケーションサーバが提供されています。
- Jetty
- Tomcat
- Glass Fish
- Resin
- WebLogic
開発環境の整備
Grizzlyの最新バージョンはGlassFishを必要とせずに単体でHTTPサーバーとして動作しますが、GlassFishに同梱されているものを使用するとNetBeansから起動できるので管理が簡単です。ここではGlassFishに同梱されているものを使用してみましょう。 GlassFishの公式サイトとv3 TP2のダウンロードページのURLを次に示します。- https://glassfish.dev.java.net/
- https://glassfish.dev.java.net/downloads/v3-techPreview-2.html#How_do_I_get_TP2
GlassFishのインストールが完了したら、次にNetBeansの設定を行います。NetBeansのバージョンは先日に日本語版が出た6.1が良いでしょう。6.1ではJavaScriptのエディタが強化されているので、Cometアプリケーションのようにサーバーとクライアントの両端を実装するのに便利です。NetBeansの公式サイトとバージョン6.1のダウンロードページのURLを次に示します。
本体のインストールが完了したら、次はNetBeans上からGlassFish V3を管理するためのプラグインを導入します。NetBeansのメニューから「ツール」 -> 「プラグイン」を実行して下さい。すると、プラグインの管理画面が表示されますので「GlassFish V3 JavaEE Integration」というプラグインにチェックを入れた上でインストールボタンをクリックしてください。
プラグインのインストールが完了したら、最後にGlassFishサーバの指定を行います。サービスタブを表示し、サーバーの右クリックメニューから「サーバーを追加」を実行して下さい。サーバーの種類を指定する画面が表示されますので、「GlassFish V3 TP2」を指定して先ほどインストールした「glassfishv3-tp2」ディレクトリを指定して下さい。GlassFishサーバの指定が完了すると、サーバーの下に「GlassFish V3 TP2」というノードが表示されます。この右クリックメニューから「Properties」をクリックし、表示された画面の「Enable Comet Support」にチェックを入れて下さい。本来ならばGlassFishの設定ファイルにCometをサポートするように記述する必要がありますが、 NetBeansではこのように簡単に指定する事ができます。
Cometアプリケーションの作成
ここまででGlassFishとNetBeansのセットアップが完了しました。次はいよいよCometアプリケーションを作成していきます。まずは Webアプリケーションプロジェクトを作成してGrizzlyライブラリへの参照を設定して下さい。ライブラリへの参照を設定するには、プロジェクトのツリーから「ライブラリ」をクリックし、その右クリックメニューから「プロパティ」を実行します。するとライブラリの管理画面が表示されますので、右の「ライブラリを追加」ボタンをクリックして「Comet-GlassFish-V3」を追加して下さい。なお、このライブラリはGlassFishサーバの中に含まれていますので、Warに含まれないようにパッケージのチェックを外しておきます。
次にサーブレットを追加します。「Source Packages」の右クリックメニューから「New Servlet」をクリックして下さい。ここではクラス名を「StreamingServlet」、パッケージ名を「web」、URLパターンを「/streaming」と指定して下さい。生成されたサーブレットにはprocessRequestなどのメソッドが実装されています。まずはGETリクエスト時にログ出力されるように、次のように修正しておきましょう。
StreamingServlet.java:
package web;次にクライアント側の準備をします。先に述べたように、ストリーミング方式では予めレスポンスを解析する処理とiframeをページに記述しておきます。index.htmlというファイルを作成し、次のように内容を修正して下さい。
import java.io.IOException;
import java.util.logging.Logger;
import javax.servlet.ServletException;
import javax.servlet.http.*;
public class StreamingServlet extends HttpServlet {
private static final Logger logger = Logger.getLogger("servlet");
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
logger.info("connect");
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
}
}
index.html:
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">iframeのsrc属性にサーブレットのパスを指定しているので、ブラウザで開いた瞬間にdoGetメソッドが実行されます。ここで一度アプリケーションを起動して、さきほど追加したログが出力されるか確かめて下さい。
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>HTTP Streamingのサンプル</title>
<script type="text/javascript">
function update(message) {
alert(message);
}
</script>
</head>
<body>
<iframe src="streaming" frameborder="0" width="0" height="0"></iframe>
</body>
</html>
以上でひとまずリクエストを投げる事はできました。しかしサーバーサイドではログ出力以外の処理を何もしていないので、リクエストを受けた直後にクライアントにレスポンスを返して終了してしまいます。次にGrizzlyを導入してCometの処理を追加していきましょう。
GrizzlyでComet処理を実現するにはCometContextと呼ばれるイベントを伝達するグループをCometEngineから取得して、それにCometHandlerと呼ばれるイベントハンドラを追加します。CometEngineは com.sun.grizzly.comet.CometEngineクラスのgetEngineメソッドで取得できます。また、 CometContextはCometEngineのregisterメソッドにキーとなる文字列を指定する事で登録・取得する事ができます。今回はサーブレットの起動時に一つだけCometContextを作成するようにしましょう。サーブレットにinitメソッドを追加して、次のように修正して下さい。
StreamingServlet.java:
private String cometContextPath;CometContextを作成したら、次にCometHandlerを定義します。CometHandlerは com.sun.grizzly.comet.CometHandlerインターフェイスを実装する事で表現する事ができます。インターフェイスのAPI のうち、onEventメソッドがイベントの発生時に呼び出されるメソッドです。FormHandlerというCometHandler実装クラスを作成し、次のようにイベント発生時にクライアントのupdate関数を実行するように修正して下さい。
@Override
public void init(ServletConfig config) throws ServletException {
cometContextPath = config.getServletContext().getContextPath() + "/streaming";
CometContext context = CometEngine.getEngine().register(cometContextPath);
context.setExpirationDelay(10 * 60 * 60 *1000);
}
FormHandler.java:
Package web.handler;
import com.sun.grizzly.comet.CometEvent;
import com.sun.grizzly.comet.CometHandler;
import java.io.IOException;
import java.io.PrintWriter;
public class FormHandler implements CometHandler<PrintWriter> {
private final static String SCRIPT_START_TAG = "<script type='text/javascript'>";
private final static String SCRIPT_END_TAG = "</script>";
private PrintWriter writer = null;
public void attach(PrintWriter writer) {
this.writer = writer;
}
public void onEvent(CometEvent event) throws IOException {
if (event.getType() == CometEvent.NOTIFY) {
writer.write(SCRIPT_START_TAG);
writer.write("window.parent.update('Hello Comet!');");
writer.write(SCRIPT_END_TAG);
writer.flush();
}
}
public void onInitialize(CometEvent event) throws IOException {
}
public void onTerminate(CometEvent event) throws IOException {
onInterrupt(event);
}
public void onInterrupt(CometEvent event) throws IOException {
writer.close();
event.getCometContext().removeCometHandler(this);
}
}
CometHandlerを定義したら、それらのインスタンスをCometContextに登録します。CometHandlerは CometContexのaddCometHandlerメソッドで登録できます。また、登録済みのCometContextは com.sun.grizzly.comet.CometEngineクラスのgetCometContetメソッドにキーを指定する事で取得できます。今回はdoGetメソッドを次のように修正して下さい。
StreamingServlet.java:
@Override登録したCometHandlerはCometContextのnotifyメソッドを実行する事で呼び出す事ができます。次の図に示すように、 CometContextは登録された全てのCometHandlerを呼び出します。なお、特定のCometHandlerを実行したい場合は addCometHandlerの返り値にIDが返りますので、それを退避してnotifyメソッドに指定して下さい。
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
logger.info("connect");
FormHandler handler = new FormHandler();
handler.attach(response.getWriter());
CometContext context = CometEngine.getEngine().getCometContext(cometContextPath);
context.addCometHandler(handler);
}
notifyを実行するタイミングはいつでも構いません。例えばOpenESBなどを使用して外部サーバーと連携するのも面白いでしょう。今回はPOSTリクエストが届いた時に実行するように、次のようにdoPostメソッドを修正します。
StreamingServlet.java:
@Override後はXHRなどでPOSTリクエストを送信するようにページにボタンを付け加えると、ボタンが押されるたびに全てのブラウザに「Hello Comet!」と書かれたダイアログが表示されるようになります。なお、今回は固定値を表示しましたが、動的なデータをCometHandlerに渡したい場合は、notifyメソッドの引数にデータを指定します。するとonEvent側では次のようにデータを参照することができます。
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
CometContext context = CometEngine.getEngine().getCometContext(cometContextPath);
context.notify(null);
}
MyClass output = (MyClass) event.attachment();一例として、Prototype.js(Script.aculo.us)とJson-libを使用してフォームの内容をCometHandlerに渡すサンプルを以下に示します。これらを参考に、自分だけのCometアプリケーションを作成してみて下さい。
index.html:
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Cometフォーム</title>
<script src="lib/prototype.js" type="text/javascript"></script>
<script src="lib/scriptaculous.js?load=effects" type="text/javascript"></script>
<script type="text/javascript">
var CometForm = Class.create();
CometForm.prototype = {
initialize: function(url,root,button) {
this.url = url;
this.root = root;
Event.observe($(button), 'click',
this.kick.bindAsEventListener(this), false);
},
connect: function(iframeEl) {
$(iframeEl).src = this.url + '?date=' + new Date().getTime();
},
kick: function() {
var query = Form.serialize(this.root);
new Ajax.Request( this.url, {
"method": "post",
"parameters": query
});
}
}
function update(obj) {
new Effect.Highlight(this.root);
$('id').value = obj.id;
$('name').value = obj.name;
$('tel').value = obj.tel;
}
Event.observe(window, 'load', function() {
var myform = new CometForm('streaming','address','exec');
myform.connect("connecter");
});
</script>
</head>
<body>
<iframe id="connecter" frameborder="0" height="0" width="100%"></iframe>
<table id="address">
<tr>
<td>id</td>
<td><input type="text" name="id" id="id" /></td>
</tr>
<tr>
<td>氏名</td>
<td><input type="text" name="name" id="name" /></td>
</tr>
<tr>
<td>電話番号</td>
<td><input type="text" name="tel" id="tel" /></td>
</tr>
</table>
<button id="exec">サンプル実行</button>
</body>
</html>
StreamingServlet.java:
package web;
import com.sun.grizzly.comet.CometContext;
import com.sun.grizzly.comet.CometEngine;
import java.io.IOException;
import java.util.logging.Logger;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.*;
import web.handler.FormHandler;
public class StreamingServlet extends HttpServlet {
private static final Logger logger = Logger.getLogger("servlet");
private String cometContextPath;
@Override
public void init(ServletConfig config) throws ServletException {
cometContextPath = config.getServletContext().getContextPath() + "/streaming";
CometContext context = CometEngine.getEngine().register(cometContextPath);
context.setExpirationDelay(10 * 60 * 60 *1000);
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
logger.info("connect");
response.setContentType("text/html");
response.setCharacterEncoding("UTF-8");
response.setHeader("Cache-Control", "private");
response.setHeader("Pragma", "no-cache");
FormHandler handler = new FormHandler();
handler.attach(response.getWriter());
CometContext context = CometEngine.getEngine().getCometContext(cometContextPath);
context.addCometHandler(handler);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
Address address = new Address();
address.setName(request.getParameter("name"));
address.setId(request.getParameter("id"));
address.setTel(request.getParameter("tel"));
CometContext context = CometEngine.getEngine().getCometContext(cometContextPath);
context.notify(address);
}
}
FormHandler.java:
package web.handler;
import com.sun.grizzly.comet.CometEvent;
import com.sun.grizzly.comet.CometHandler;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.logging.Logger;
import net.sf.json.JSONObject;
import web.Address;
public class FormHandler implements CometHandler{
private PrintWriter writer = null;
private final static Logger logger = Logger.getLogger("handler");
private final static String SCRIPT_START_TAG = "";
public void attach(PrintWriter writer) {
this.writer = writer;
}
public void onEvent(CometEvent event) throws IOException {
if (event.getType() == CometEvent.NOTIFY) {
if (event.attachment() instanceof Address) {
Address output = (Address) event.attachment();
JSONObject jsonObject = JSONObject.fromObject(output);
logger.info(jsonObject.toString());
writer.write(SCRIPT_START_TAG);
writer.write("window.parent.update(" + jsonObject + ");");
writer.write(SCRIPT_END_TAG);
writer.flush();
}
}
}
public void onInitialize(CometEvent event) throws IOException {
}
public void onTerminate(CometEvent event) throws IOException {
onInterrupt(event);
}
public void onInterrupt(CometEvent event) throws IOException {
writer.close();
event.getCometContext().removeCometHandler(this);
}
}
まとめ
本稿ではJavaOneからCometをピックアップして、GlassFish(Grizzly)とNetBeansを使用したCometアプリケーションの作成方法を解説しました。コードの内容を見て解るように、現状のCometはJavaScriptを生成するなどセキュリティ面で不安が残ります。しかし、例えば利用者を限定できる社内システムなどでは十分に使える技術になってきています。本稿を参考にCometアプリケーションの第一歩を踏み出してみて下さい。参考文献
- JavaOne2008 TS-5250 セッション資料
- Grizzly公式サイト
- Jean-Francois Arcand's Blog
- Jim Driscoll's Blog
- ヨッシーブログ