JavaでWebSocket 〜Jetty + socket.io.js

Jettyとsocket.io.jsを利用してWebSocketのアプリケーションを動かしてみました。
Javascript側は、node.js+socket.ioの場合と変わらないので、主にJavaのサーバー部分を中心にまとめています。

socket.ioとsocket.io-javaの関係

socket.ioはnode.jsのモジュールの一つで、WebSocketサーバーとJavascriptクライアントを共に提供します。
前回の記事速習!node.js + socket.ioでは、node.js、socket.ioのセットアップについて簡単に紹介しました。
socket.ioは、クロスブラウザに対応していてどのブラウザでも同じインタフェースで実装できます。また、WebSocketに対応していないブラウザでは代替の通信方法を自動的に選択してくれるので便利です。

2011年4月16日現在、WebSocketをネイティブに対応しているブラウザはChromeSafariのみです。(FirefoxOperaプロトコルの仕様に脆弱性が見つかったため開発を中止しています。http://redmine.jp/tech_note/issue_statuses/)
Chromeでのみ利用なWebアプリケーションを作るというケースは、Chrome Web Store用のアプリケーションを作るという場合以外ではあまりないと思いますし、WebSocket APIをネイティブに扱って、FirefoxIEの場合はComet通信をする。といった様なコードを最初から書くのは大変です。
そこで、socket.ioのクライアント側のJavascript socket.io.jsのみを、node.js以外のサーバーでも使えたら便利ではないか?ということで、そのJavaサーバー用のライブラリがsocket.io-javaになります。

socket.io.jsは、以下のような順序で、通信手段を決めます。

websocket -> flashsocket -> xhr-multipart -> htmlfile -> xhr-polling -> jsonp-polling

ちなみに、websocket、flashsocketは読んで字のごとくですが、xhr-multipartはXHRリクエストで接続し続けてバイナリストリームとして通信します。htmlfileは隠しiframeによる通信、xhr-polling、jsonp-pollingはcometです。
socket.io-javaの場合はFlashSocketが動いていなかったので、確認した限りでは書ブラウザで以下のように通信手段が選択されました。

  • Chrome 10 = WebSocket
  • Safari 5 = WebSocket
  • Firefox 4 = xhr-multipart
  • IE 8 = htmlfile
  • Opera 11 = xhr-polling(チャットのサンプルは動きません)

socket.io-javaの入手と動作確認

socket.io-javaのプロジェクトサイトはGoogle Codeにあります。http://code.google.com/p/socketio-java/
リポジトリからソースを取得してantでビルドします。

$ hg clone https://socketio-java.googlecode.com/hg/ socketio-java
$ cd socketio-java
$ ant

socket.io-javaには実行可能なサンプルがいくつか含まれています。
チャットのサンプルの実行してみます。

$ ant run-chat

こんな感じでチャットができます。

以降は、主にサーバー側のJettyの説明になります。

Jettyサーバーの使い方

socket.io-javaはWebSocketサーバーで利用するライブラリとしてJettyを利用しています。
Jettyは、Tomcatの用なサーブレット/JSPコンテナの形でも提供されていますが、主にHttpサーブレットのライブラリーだけを組み込んで使う場合のが一般的だと思われます。
有名どころだとAppEngineとかで使われてます。http://docs.codehaus.org/display/JETTY/Jetty+Powered

Jettyを利用したWebSocketサーバーの構築方法については、技評のサイトに詳しく載っていますので、こちらを参考にしてください。

ここでは、チャットの例から、シンプルなサーブレットコンテナとしてJettyを動作させる手順についてのみ解説します。
以下、 com.glines.socketio.examples.chat.ChatServerより抜粋

public static void main(String args[]){
  Server server = new Server(); // (1)

  SelectChannelConnector connector = new SelectChannelConnector();// (2)
  connector.setHost(host);
  connector.setPort(port);
  server.addConnector(connector);

  ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);// (3)
  ServletHolder holder = new ServletHolder(new ChatSocketServlet());// (4)
  holder.setInitParameter(FlashSocketTransport.FLASHPOLICY_SERVER_HOST_KEY, host);
  holder.setInitParameter(FlashSocketTransport.FLASHPOLICY_DOMAIN_KEY, host);
  holder.setInitParameter(FlashSocketTransport.FLASHPOLICY_PORTS_KEY, ""+ port);
  context.addServlet(holder, "/socket.io/*");// (5)
  context.addServlet(new ServletHolder(new StaticServlet()), "/*");

  server.setHandler(context);// (6)
  server.start();// (7)
}

(1) org.eclipse.jetty.server.Serverオブジェクトを生成します。
(2) org.eclipse.jetty.server.nio.SelectChannelConnectorはJavaのnioを利用したノンブロッキングIO接続を扱うクラスです。
Jetty 7のソースコードを確認しましたが、1.でnew Server(8080)とポートを指定した場合も、SelectChannelConnectorがコンストラクタ内で生成されるので、SelectChannelConnectorを別途生成してaddConnectorしなくてもOKです。コメントには、ブロッキングIO接続を生成すると書いてあるので、実際に使うときはJettyのバージョンを確認して使ってください。

92     public Server(int port)
93     {
94         setServer(this);
95 
96         Connector connector=new SelectChannelConnector();
97         connector.setPort(port);
98         setConnectors(new Connector[]{connector});
99     }

また、setHostをした場合、指定したホスト名以外での接続はできなくなります。つまりチャットのサンプルは、デフォルトで起動した場合はlocalhost:8080でしかアクセスできなくなるので注意が必要です。

193                InetSocketAddress addr = getHost()==null?new InetSocketAddress(getPort()):new InetSocketAddress(getHost(),getPort());
194                _acceptChannel.socket().bind(addr,getAcceptQueueSize());

(3) サーブレットコンテキストの生成
Serverにセットする、サーブレットコンテキストorg.eclipse.jetty.servlet.ServletContextHandlerを生成します。ServletContextHandlerは、実際にはjavax.servlet.ServletContextの実装をラップしているクラスです。
コンストラクタにセッションを有効にするフラグを指定していますが、チャットのサンプルはセッション使わないので指定しなくても動作します。
(4) org.eclipse.jetty.servlet.ServletHolderはServletとServletContext(web.xmlservletタグに指定する情報)を管理するクラスです。サーブレットの初期化パラメータをセットするsetInitParameterメソッドなどがあります。
(5) サーブレットをパスにマッピングします。web.xmlだとservlet-mappingタグで指定する内容です。
(6) サーブレットコンテキストをサーバーに設定します。
(7) サーバーを起動します。

socket.io-javaのおかしなところ

socket.io.jsは、node.js用のモジュールなのでやはりJettyに対してすべての機能が動くわけではありませんでした。
サンプルを動かして気づいた点をいくつか挙げておきます。

FlashSocketが動かない

Google code上のsocket.io-javaでは、FlashSocketが動作していません。
WebSocketMain.swfをダウンロードした後、サーバーとの接続に失敗しているのですが、WebSocketMain.swfの動作はよく解らないので放置しています。
WebSocketMain.swfを使ったサンプルを紹介しているサイトもいくつかあったので、時間ができたらちゃんと調べたいところです。
2011年6月27日削除

2011年6月27日追記ここから

FlashSocketを有功にするためにはFlashソケットポリシーファイルを843ポートでクライアントから取得できる必要があります。

Flashソケットポリシーファイルについては以下のサイトに詳しく説明されています。
http://gimite.net/pukiwiki/index.php?Flash%A4%CE%A5%BD%A5%B1%A5%C3%A5%C8%A5%DD%A5%EA%A5%B7%A1%BC%A5%D5%A5%A1%A5%A4%A5%EB

socketio-javaのFlashSocketTransportクラスには、Flashポリシーファイルを返すサーバーを起動する処理が含まれていますが、root権限が無いユーザで起動した場合に843ポートのソケットが開けないため、Flashソケットポリシーファイルの取得に失敗してSocketが利用できませんでした。
この場合にエラーが何も表示されないためなかなか気づけずorz。

FlashSocketを利用する場合は、起動するユーザの権限に注意が必要です。

2011年6月27日追記ここまで

xhr-multipartは文字化けする。

socket.io-JavaはFlashSocketが動かないため、Firefoxはxhr-multipartの通信が選択されます。
FireFoxの場合はJettyサーバーがメッセージをPUSHするときに文字化けしてしまいます。
(上記の画像は修正後です)

ダンプで見てみると「あ」を送信した場合、3fになっています。

00000063  7e 65 31 7e 31 35 7e 7b  22 6d 65 73 73 61 67 65 ~e1~15~{ "message
00000073  22 3a 5b 22 32 22 2c 22  3f 22 5d 7d 0d 0a 2d 2d ":["2"," ?"]}..--

正しくは「あ」はe3 81 82になるはず。

00000000  00 7e 65 31 7e 31 35 7e  7b 22 6d 65 73 73 61 67 .~e1~15~ {"messag
00000010  65 22 3a 5b 22 31 22 2c  22 e3 81 82 22 5d 7d ff e":["1", "..."]}.

応答を返す前のJavaのログを見ると化けていないので、ServletResponseのOutputStreamに出力する際のエンコードに問題があると考えられます。

     [java] 2011-04-13 01:21:23.976:DBUG::Recieved: あ
     [java] 2011-04-13 01:21:23.976:DBUG::Broadcasting: {"message":["2","あ"]}
     [java] 2011-04-13 01:21:23.976:DBUG::Session[C-DTB9E0vtfVu-vFewcM]: sendMessage(int, String): [1]: {"message":["2","あ"]}
     [java] 2011-04-13 01:21:23.976:DBUG::Session[C-DTB9E0vtfVu-vFewcM]: sendMessage(frame): [DATA]: {"message":["2","あ"]}
     [java] 2011-04-13 01:21:23.976:DBUG::Session[C-DTB9E0vtfVu-vFewcM]: writeData(START): ~e1~15~{"message":["2","あ"]}
     [java] 2011-04-13 01:21:23.978:DBUG::Session[C-DTB9E0vtfVu-vFewcM]: writeData(END): ~e1~15~{"message":["2","あ"]}

com.glines.socketio.server.transport.XHRMultipartTransportのwriteDataメソッドを以下の様に修正すると文字化けは直ります。
このクラスはresponseのsetCharacterEncodingを指定できないので、無理やりエンコードしています。

protected void writeData(ServletResponse response, String data) throws IOException {
	idleCheck.activity();
	Log.debug("Session["+session.getSessionId()+"]: writeData(START): " + data);
	ServletOutputStream os = response.getOutputStream();
	os.println("Content-Type: text/plain");
	os.println();
	os.println(new String(data.getBytes("UTF-8"), "iso-8859-1"));
	os.println(boundarySeperator);
	response.flushBuffer();
	Log.debug("Session["+session.getSessionId()+"]: writeData(END): " + data);
}
HTMLFileも文字化けする

IEも本来ならFlashSocketが選択されるべきですが、FlashSocketは動かないのでHTMLFileが選択されます。
HTMLFileもxhr-multipart同様文字化けします。

com.glines.socketio.server.transport.HTMLFileTransportのwriteDataをxhr-multipartと同様に無理やりエンコードします。
やっぱりresponse.setCharacterEncodingは使えませんでした。

protected void writeData(ServletResponse response, String data) throws IOException {
	idleCheck.activity();
	response.getOutputStream().print("");
	response.flushBuffer();
}
Comet系は動作がおかしい

xhr-polling、jsonp-pollingは、クライアント側で別途対応が必要そうです。

おまけ - Jettyサーバーのデバッグ方法

文字化け箇所を探すときにJettyサーバーのデバッグ方法について調べましたのでついでに載せておきます。
Javaの起動パラメータ-Xrunjdwpを指定し、Eclipseデバッグします。

チャットのサンプルの場合であれば、build.xmlのrun-chatターゲットにデバッグ用のパラメータを追加します。

<jvmarg value="-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8081"/>

suspendを有効にすると、以下の様にデバッグのListenポートが接続されるまでサーバーの起動を待つことができて便利です。

$ ant run-chat

Buildfile: build.xml

run-chat:
     [java] Listening for transport dt_socket at address: 8081

eclipse側では、[Run]->[Debug Configurasions...]を選択、[Remote Java Application]に設定を追加、[Connection Type]で[Standard (Socket Attach)]を選択して[Host]にlocalhost、[Port]にbuild.xmlに指定した8081をセットして[Debug]実行するとデバッグが開始されます。