本エントリでは近代的なWebアプリケーションでは欠かせないWebSocketをJavaEEで実現する方法を説明します。
前段・WebSocketはなぜ生まれたか
伝統的なWebアプリケーションは、HTTPというプロトコルで成立していました。HTTPとは単純なリクエスト・レスポンスモデルであり「クライアントであるWebブラウザがWebサーバに対してリクエストを投げ、返って来たレスポンスを処理する」というモデルで成り立っていました。あくまでクライアントが処理の起点となっているのです。
一方、Webアプリケーションであっても「サーバ側で起きた何らかの出来事を、Webブラウザに通知したい」というニーズは常にありました。これを「サーバプッシュ」と言います。これまで、HTTPを使って擬似的なサーバプッシュを実現するために技術者は様々な工夫を凝らしてきました。しかしやはり、リクエスト・レスポンスモデルの下では無理があったのです。
このような経緯があり、Webブラウザでの真のサーバプッシュを実現するべく策定されたのがWebSocketというプロトコルです。
もちろんJavaEEでもWebSocketはサポートされています。
本エントリではJavaEE7で導入されたWebSocketサーバを簡単に実現する機能をご紹介します。
最小のサンプル
これまで紹介してきたように、JavaEEでは、ある機能を使うのにアノテーションを付与したクラスを単に作成するだけで良い、というスタイルが広く採用されています。WebSocketも例外ではありません。
下記は最小のサンプルです。
package sandbox.websocket.minimum; import javax.websocket.OnMessage; import javax.websocket.server.ServerEndpoint; @ServerEndpoint("/ws/min") public class MinimumEndpoint { @OnMessage public String onMessage(final String pText) { System.out.println(pText); return "Re: " + pText; } }
@ServerEndpointアノテーションを付けたクラスはWebSocketサーバとなります。このようなクラスをEndpointクラスと呼びます。
さらに@OnMessageアノテーションでクライアントから送信されたメッセージを処理するメソッドを指定しています。メッセージの処理内容は、送信されたメッセージの頭に「Re: 」を付けて送り返しています。
このクラスのテストのために、次のようなHTMLを作ってみましょう。
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>WebSocket最小のサンプル</title> </head> <body> <input type="text"> <button type="button" class="btn btn-primary form-control">送信</button> <script src="https://code.jquery.com/jquery-2.2.0.min.js"></script> <script> var ws = null; var onOpen = function() { $('button').click(function() { ws.send($('input').val()); }); }; var onMessage = function(e) { alert(e.data); }; var connect = function() { ws = new WebSocket('ws://localhost:' + location.port + '/ws/min'); ws.onopen = onOpen; ws.onmessage = onMessage; }; connect(); </script> </body> </html>
テキストフィールドに文字を入力して「送信」ボタンを押すと、先頭に「Re: 」を付けた文字列がアラートで表示されるサンプルです。
いかがでしょうか。いとも簡単にWebSocketを使ったアプリケーションが作れてしまうことが分かっていただけたかと思います。
ただ、このサンプルはWebブラウザからのリクエストが起点であるため、Ajaxでも実現可能でありWebSocketの旨味がないサンプルです。そこで以降では、WebSocketを使うための実践的な機能を紹介していきます。
@OnMessage以外のアノテーション
@OnMessageアノテーションの他にも@OnOpen/@OnError/@OnCloseというアノテーションがあり、これらのアノテーションが付与されたメソッドは、適切なタイミングでJavaEEコンテナから呼び出されるコールバックメソッドとなります。
下記に典型的なEndpointクラスの宣言方法を示します。
package sandbox.websocket.sample; import javax.websocket.OnClose; import javax.websocket.OnError; import javax.websocket.OnMessage; import javax.websocket.OnOpen; import javax.websocket.Session; import javax.websocket.server.ServerEndpoint; @ServerEndpoint("/ws/sample") public class SampleEndpoint { @OnOpen public void onOpen(Session pSession) { } @OnMessage public void onMessage(String pMessage) { } @OnError public void onError(Throwable pError) { } @OnClose public void onClose(Session pSession) { } }
各メソッドはpublicである必要があります。
また@OnErrorコールバックメソッドは、Throwableを引数に取る必要があります。
それ以外のメソッドの引数は必要に応じて増減させることが可能です。例えば、@OnMessageのコールバックでSessionが必要なら、引数に追加してください。
URIテンプレート
接続先を分類するために、URLにパラメータを含めることが出来ます。
@ServerEndpoint("/ws/rooms/{room-descriptor}") public class DescriptorEndpoint { ...
これを使うと、チャットルームの入室管理のようなことが比較的簡単に実装できます。簡単なチャットサーバのコードをご紹介します。
package sandbox.websocket.descritpor; import java.io.IOException; import javax.inject.Inject; import javax.websocket.OnClose; import javax.websocket.OnMessage; import javax.websocket.OnOpen; import javax.websocket.Session; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; @ServerEndpoint("/ws/rooms/{room-descriptor}") public class DescriptorEndpoint { @Inject WebSocketSessionManager sessionManager; @OnOpen public void onOpen(@PathParam("room-descriptor") final String pRoomDescriptor, final Session pSession) { this.sessionManager.addSession(pRoomDescriptor, pSession); } @OnClose public void onClose(@PathParam("room-descriptor") final String pRoomDescriptor, final Session pSession) { this.sessionManager.removeSession(pRoomDescriptor, pSession); } @OnMessage public void onMessage(@PathParam("room-descriptor") final String pRoomDescriptor, final String pText) { for (final Session session : this.sessionManager.getSessions(pRoomDescriptor)) { try { session.getBasicRemote().sendText("Re: " + pText); } catch (final IOException e) { e.printStackTrace(); } } } }
注目していただきたいのは次の2点です。
- @ServerEndpointの引数に
room-descriptor
というパラメータを含めている - メソッドの引数に
@PathParam("room-descriptor")
を指定することでパラメータの値を得ている
なおEndpointクラスはCDIによるスコープ管理が効きません。たとえシングルトンな作りにしたくても、それが叶わないのです。
しかしインジェクション対象にすることはできます。ここでは、チャットルーム毎のWebSocket接続を管理するクラス(WebSocketSessionManager)をシングルトンとして作成し、@Injectアノテーションでインジェクションしています。
WebSocketSessionManagerクラスのコードも掲載しておきます。
package sandbox.websocket.descritpor; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; import javax.enterprise.context.ApplicationScoped; import javax.websocket.Session; @ApplicationScoped public class WebSocketSessionManager { private final ConcurrentMap<String, Lock> locks = new ConcurrentHashMap<>(); private final ConcurrentMap<String, List<Session>> sessions = new ConcurrentHashMap<String, List<Session>>() { public List<Session> get(final Object key) { List<Session> ret = super.get(key); if (ret == null) { ret = new CopyOnWriteArrayList<>(); this.put((String) key, ret); } return ret; } }; public void addSession(final String pRoomDescriptor, final Session pSession) { synchronized (this.getLock(pRoomDescriptor)) { this.sessions.get(pRoomDescriptor).add(pSession); } } public List<Session> getSessions(final String pRoomDescriptor) { return this.sessions.get(pRoomDescriptor); } public void removeSession(final String pRoomDescriptor, final Session pSession) { synchronized (this.getLock(pRoomDescriptor)) { this.sessions.get(pRoomDescriptor).remove(pSession); } } private Lock getLock(final String pRoomDescriptor) { final Lock newLock = new Lock(); final Lock alreadyLock = this.locks.putIfAbsent(pRoomDescriptor, newLock); return alreadyLock == null ? newLock : alreadyLock; } private static class Lock { // nodef } }
スレッドセーフにするために少し複雑なコードになっていますが、結局やっていることは部屋毎にWebSocket接続を束ねているだけなので、臆さず読み解いてみてください。
最後に、これをテストするためのHTMLを掲載します。
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>WebSocket識別子のサンプル</title> </head> <body> <input type="text" name="desc" placeholder="部屋の識別子"> <button type="button" class="connector">接続</button> <hr/> <input type="text" name="text" placeholder="送信テキスト"> <button type="button" class="sender" disabled="disabled">送信</button> <hr/> <h3>レスポンス</h3> <p class="response"></p> <script src="https://code.jquery.com/jquery-2.2.0.min.js"></script> <script> var ws = null; $('button.connector').click(function() { if (ws) ws.close(); var url = 'ws://localhost:' + location.port + '/ws/rooms/' + $('input[name="desc"]').val(); ws = new WebSocket(url); ws.onmessage = function(e) { $('.response').text(e.data); }; ws.onopen = function() { $('button.sender').removeAttr('disabled'); }; ws.onclose = function() { $('button.sender').attr('disabled', 'disabled'); }; }); $('button.sender').click(function() { if (!ws || ws.readyState !== WebSocket.OPEN) { alert('先に接続して下さい。'); return; } ws.send($('input[name="text"]').val()); }); </script> </body> </html>
動作を見てみるときは、ぜひ複数のWebブラウザを開いてください。誰かの投稿が同じ部屋の全員に配信されることが分かると思います。とてもシンプルですが、チャットアプリケーションができました。
DecoderとEncoder
WebSocketはテキストデータとバイナリデータをやり取りできます。一般的には扱いやすいJSON形式のテキストを使うことが多いでしょう。しかしEndpointクラスを普通に作ると、@OnMessageコールバックの引数や戻り値の型は、単なるString型です。せっかくJavaを使っているのですから、ここはデータクラスに変換して型安全にメッセージを扱いたいものです。
この要求に応えるのが、Decoder/Encoderです。
Decoderクラス
Decoderクラスにはクライアント(一般的にはWebブラウザ)から送られてきたメッセージ内容を任意のJavaオブジェクトに変換する処理を実装します。このクラスを作成すると、Endpointクラスの@OnMessageコールバックメソッドに引数にString以外の型が使えるようになります。
ここでは ClientMessageというクラスを導入して、Decoderクラスを作ってみます。なおJSONとJavaオブジェクトの変換にはJSONICというライブラリを使っています。
まずはClientMessageクラスです。getterとsetterのみの単なるデータクラスとして作りましょう。
package sandbox.websocket.json; import java.util.Date; public class ClientMessage { private Date date; private String body; // getter/setterは省略 }
次にDecoderです。JsonDecoderという名前で作ります。なお使い方は後で説明します。
package sandbox.websocket.json; import javax.websocket.DecodeException; import javax.websocket.Decoder; import javax.websocket.EndpointConfig; import net.arnx.jsonic.JSON; public class JsonDecoder implements Decoder.Text<ClientMessage> { @Override public void init(@SuppressWarnings("unused") final EndpointConfig pConfig) { // nop } @Override public boolean willDecode(@SuppressWarnings("unused") final String pRequest) { return true; // デコード対象としない場合はfalseを返すが、通常はtrueでOK. } @Override public ClientMessage decode(final String pRequestJsonString) throws DecodeException { return JSON.decode(pRequestJsonString, ClientMessage.class); } @Override public void destroy() { // nop } }
なおJSON文字列とJavaオブジェクトの変換にはJSONICというライブラリを使っています。
Encoderクラス
次にEncoderクラスを作ってみます。
Encoderクラスには、Javaオブジェクトをクライアントに送信するメッセージに変換する処理を実装します。このクラスを作成すると、Endpointクラスの@OnMessageコールバックメソッドの戻り値にString以外の型が使えるようになります。
ここではServerMessageというクラスを導入して、Encoderクラスを作ってみます。
ServerMessageクラスも、getterとsetterのみの単なるデータクラスとして作ります。
package sandbox.websocket.json; import java.util.Date; public class ServerMessage { private Date date; private String body; private String senderSessionId; // getterとsetterは省略 }
次に、JsonEncoderを作りましょう。これも使い方は後で説明します。
package sandbox.websocket.json; import javax.websocket.EncodeException; import javax.websocket.Encoder; import javax.websocket.EndpointConfig; import net.arnx.jsonic.JSON; public class JsonEncoder implements Encoder.Text<ServerMessage> { @Override public void init(@SuppressWarnings("unused") final EndpointConfig pConfig) { // nop } @Override public String encode(final ServerMessage pResponse) throws EncodeException { return JSON.encode(pResponse); } @Override public void destroy() { // nop } }
DecoderとEncoderを使う
作成したDecoderとEncoderはEndpointクラスの中で使います。ポイントは次の2点です。
- Endpointクラスの@ServerEndpointアノテーションのdecodersとencodersに作ったクラスを指定
- @OnMessageコールバックメソッドの引数と戻り値にデータクラスを指定
それではサンプルコードを見てみましょう。
package sandbox.websocket.json; import java.util.Calendar; import javax.websocket.OnMessage; import javax.websocket.Session; import javax.websocket.server.ServerEndpoint; @ServerEndpoint(value = "/ws/dec-enc" // , decoders = JsonDecoder.class // , encoders = JsonEncoder.class) public class DecEncEndpoint { @OnMessage public ServerMessage onMessage(final Session pSession, final ClientMessage pRequest) { final ServerMessage ret = new ServerMessage(); ret.setBody("Re: " + pRequest.getBody()); ret.setDate(Calendar.getInstance().getTime()); ret.setSenderSessionId(pSession.getId()); return ret; } }
String型がなくなり、データクラスを通して型安全にデータ操作ができるようになりました。
テスト用のHTMLも掲載します。
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Decoder/Encoderのサンプル</title> </head> <body> <input type="text" name="text" placeholder="送信テキスト"> <button type="button" class="btn btn-primary form-control">送信</button> <script src="https://code.jquery.com/jquery-2.2.0.min.js"></script> <script> var ws = null; var onOpen = function() { $('button').click(function() { ws.send(JSON.stringify({ body: $('input[name="text"]').val() })); }); }; var onMessage = function(e) { alert(e.data); }; var connect = function() { ws = new WebSocket('ws://localhost:' + location.port + '/ws/dec-enc'); ws.onopen = onOpen; ws.onmessage = onMessage; }; connect(); </script> </body> </html>
動作させてみると、JSON形式のデータをやり取り出来ていることが分かると思います。
データ変換の流れのまとめ
登場人物が多くなり少し複雑になったため、データ変換の流れを整理してみました。
変換処理の流れと、Decoder/Encoderがどのような役割を担っているかを理解いただけたでしょうか。
最後に
以上、JavaEEのWebSocketに関する機能をご紹介しました。
実戦ではもっとたくさんのことに気を配る必要があります。例えばエラー処理やスケーリング、認証、リクエスト内容の解析などの処理が必要です。
しかしWebSocketの根幹となる機能は、本エントリでご紹介した機能で充分カバーされていると思います。JavaEEで作るWebアプリケーションにもどんどんWebSocketを取り入れて、新しいユーザ体験を提供していきましょう。