kagamihogeの日記

kagamihogeの日記です。

The Java EE 7 TutorialのJava API for WebSocketのセクションをテキトーに訳した

The Java EE 7 Tutorial18 Java API for WebSocketのセクションを読んで訳した。

18 Java API for WebSocket

この章はWebSocketアプリケーションの開発をサポートするJava API for WebSocket(JSR 356)の解説をします。WebSocketはTCPプロトコルを介して二つのピア間で全二重通信を行うアプリケーションプロトコルです。

HTTPで使用される伝統的なリクエスト-レスポンスモデルでは、クライアントはリソースをリクエストし、それからサーバはレスポンスを返します。やり取りは常にクライアントから開始され、サーバはクライアントがまずリクエストをしないことには一切のデータ送信は出来ません。ごく稀に変更されるドキュメントにクライアントが時々リクエストを作成する場合にはこのモデルはWorld Wide Webで上手くいきましたが、コンテンツがすぐに更新されてユーザがWebによりインタラクティブな体験を期待するようになるとこのアプローチの限界が徐々に明らかになりつつあります。WebSocketプロトコルはクライアント・サーバ間で全二重通信チャネルを提供することでこの限界に取り組みます。JavaScriptHTML5などの他のクライアント技術と組み合わせて、WebSocketはユーザ体験をより豊かに出来ます。

以下のトピックをこの章では扱います。

  • Introduction to WebSocket
  • Creating WebSocket Applications in the Java EE Platform
  • Programmatic Endpoints
  • Annotated Endpoints
  • Sending and Receiving Messages
  • Maintaining Client State
  • Using Encoders and Decoders
  • Path Parameters
  • Handling Errors
  • Specifying an Endpoint Configurator Class
  • The dukeetf2 Example Application
  • The websocketbot Example Application
  • Further Information about WebSocket

18.1 Introduction to WebSocket

WebSocketアプリケーションでは、サーバはWebSocketエンドポイント(endpoint)にパブリッシュし、クライアントはサーバとの接続にエンドポイントのURIを使用します。WebSocketプロトコルはコネクション確立後は対称構造になり、クライアントとサーバはコネクションが開かれている間は何時でも互いにメッセージ送信が可能で、何時でもどちらかがコネクションをクローズできます。クライアントは通常一つのサーバとのみ接続し、サーバは複数クライアントからのコネクションを受け入れます。

WebSocketプロトコルは二つの構造からなり、それはハンドシェイク(handshake)とデータ転送(data transfer)です。クライアントはWebSocketエンドポイントにURLでリクエストを送信することでハンドシェイクを初期化します。ハンドシェイクは既存のHTTPベースのインフラと互換性があります。WebサーバーはHTTPコネクションのアップグレードリクエスト(HTTP connection upgrade request)として解釈します。クライアントからのハンドシェイクの例は以下のようになります。

GET /path/to/websocket/endpoint HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: xqBt3ImNzJbYqRINxEFlkg==
Origin: http://localhost
Sec-WebSocket-Version: 13

サーバがクライアントに返すレスポンスのハンドシェイクの例は以下のようになります。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: K7DJLdLooIwIG/MOpvWFB3y3FE8=

サーバはSec-WebSocket-Acceptヘッダーの値を生成するためにSec-WebSocket-Keyに演算を適用します。クライアントはSec-WebSocket-Keyヘッダーの値に同じ演算を適用し、もしサーバから受け取った値と演算結果が一致する場合、コネクションの確立が成功します。クライアントとサーバはハンドシェイク成功後は互いにメッセージ送信が可能になります。

WebSocketはテキストメッセージ(UTF-8エンコードされたもの)とバイナリメッセージをサポートします。WebSocketのコントロールフレーム(control frames)は、close, ping, pongpingフレームのレスポンス)です。pingとpongフレームはアプリケーションデータを含むこともあります。

WebSocketエンドポイントは以下の形式を持つURIによって表現されます。

ws://host:port/path?query
wss://host:port/path?query

wsスキーマは暗号化されないWebSocketの接続を表現し、wssスキーマは暗号化された接続を表現します。portはオプションで、デフォルトポート番号は暗号化されない接続では80で暗号化された接続では443です。pathはサーバ内のエンドポイントの位置を示します。queryはオプションです。

モダンなwebブラウザはWebSocketプロトコルを実装し、JavaScript APIを提供し、このAPIはエンドポイントへの接続・メッセージ送信・WebSocketイベント(コネクションの開始、メッセージ受信、切断など)にコールバックメソッドの割り当て、をすることが出来ます。

18.2 Creating WebSocket Applications in the Java EE Platform

Java EEプラットフォームにはJava API for WebSocket (JSR 356)が含まれており、アプリケーションにWebSocketエンドポイントを作成・設定・デプロイすることが出来ます。また、JSR 356で定義されるWebSocketクライアントAPIは任意のJavaアプリケーションからリモートのWebSocketエンドポイントにアクセスできます。

Java API for WebSocketは以下のパッケージで構成されています。

  • javax.websocket.serverパッケージには、サーバーエンドポイントの設定と作成をするためのアノテーション・クラス・インタフェースが含まれています。
  • javax.websocketパッケージには、クライアントとサーバーエンドポイントに共通のアノテーション・クラス・インタフェース・例外が含まれています。

WebSocketエンドポイントはjavax.websocket.Endpointクラスのインスタンスです。Java API for WebSocketには二種類のエンドポイント作成が可能で、programmaticとannotatedの二つです。programmatic endpointを作成するには、Endpointクラスを拡張してライフサイクルメソッドをオーバーライドします。annotated endpointを作成するには、上記リストに書かれているパッケージで提供されるアノテーションJavaクラスとメソッドに付与します。エンドポイント作成後、リモートクライアントが接続できるようにアプリケーションを特定のURIでデプロイします。

Note:
大抵のケースにおいて、programmatic endpointよりもannotated endpointで作成とデプロイをする方が簡単です。このチャプターではprogrammatic endpointの単純な例を示しますが、annotated endpointsに焦点を当てていきます。

WebSocketエンドポイントの作成とデプロイ手順は以下のようになります。

  1. エンドポイントクラスの作成。
  2. エンドポイントのライフサイクルメソッドの実装。
  3. エンドポイントにビジネスロジックを追加。
  4. webアプリケーションにエンドポイントをデプロイ。

この手順はprogrammaticとannotatedエンドポイントで多少異なりますが、以降のセクションで詳細を説明します。

Note:
servletと比較すると、WebSocketエンドポイントは複数回インスタンス化されます。コンテナはデプロイメントURIへの接続ごとにエンドポイントのインスタンスを生成します。各インスタンスは単一の接続とだけ関連付けられます。エンドポイントインスタンスのコードを実行するスレッドは常に一つだけなので、各コネクションにユーザ状態を保持し続けたりデプロイすることが容易になります。

18.3 Programmatic Endpoints

以下の例はEndpointクラスを拡張してエンドポイントを作成する方法です。

public class EchoEndpoint extends Endpoint {
   @Override
   public void onOpen(final Session session, EndpointConfig config) {
      session.addMessageHandler(new MessageHandler.Whole<String>() {
         @Override
         public void onMessage(String msg) {
            try {
               session.getBasicRemote().sendText(msg);
            } catch (IOException e) { ... }
         }
      });
   }
}

このエンドポイントは受信したメッセージをエコーバックします。Endpointクラスは三つのライフサイクルメソッドonOpen, onClose, onErrorを定義しています。EchoEndpointクラスはEndpointクラスで唯一の抽象メソッドonOpenメソッドを実装しています。

Session引数はこことリモートのエンドポイント間の対話(conversation)を表現します。addMessageHandlerメソッドはメッセージハンドラーを登録し、getBasicRemoteメソッドはリモートのエンドポイントを表現するオブジェクトを返します。Sessionインタフェースはこのチャプター内で詳細を説明します。

メッセージハンドラーは無名内部クラスとして実装されています。メソッドハンドラーのonMessageメソッドはエンドポイントがテキストメッセージを受信するときに呼び出されます。

このprogrammaticエンドポイントをデプロイするには、Java EEアプリケーションで以下のコードを使用します。

ServerEndpointConfig.Builder.create(EchoEndpoint.class, "/echo").build();

アプリケーションをデプロイすると、エンドポイントはws://<host>:<port>/<application>/echoで利用可能になり、たとえばws://localhost:8080/echoapp/echoなどで使用します。

18.4 Annotated Endpoints

以下の例はアノテーションを使用してProgrammatic Endpointsの例と同様なエンドポイントを作成しています。

@ServerEndpoint("/echo")
public class EchoEndpoint {
   @OnMessage
   public void onMessage(Session session, String msg) {
      try {
         session.getBasicRemote().sendText(msg);
      } catch (IOException e) { ... }
   }
}

annotated endpointはprogrammatic endpointと比べるとシンプルで、ServerEndpointアノテーションで定義されるパスでアプリケーションへ自動的にデプロイされます。メッセージハンドラー用に追加でクラスを作る代わりに、この例ではメッセージ処理を実行するメソッドを指定するOnMessageアノテーションを使用しています。

Table 18-1はライサイクルメソッドを処理するメソッドを指定するためにjavax.websocketパッケージで利用可能なアノテーションの一覧表です。この表の例の列はそのメソッドで良く使う引数です。各ケースで使用可能な引数の組み合わせの詳細についてはAPIリファレンスを参照してください。

Table 18-1 WebSocket Endpoint Lifecycle Annotations

http://docs.oracle.com/javaee/7/tutorial/doc/websocket004.htm#BABDGEJH を参照。*1

18.5 Sending and Receiving Messages

WebSocketエンドポイントはテキストとバイナリメッセージを送受信可能です。また、ping/pongフレームの送受信も可能です。このセクションでは、接続されたピアにメッセージを送るためのSessionRemoteEndpointインタフェースと、メッセージを受信するためのOnMessageアノテーションの使い方について説明します。

18.5.1 Sending Messages

エンドポイントにメッセージ送信するステップは以下のステップを踏みます。

  1. コネクションからSessionオブジェクトを取得する。
    Sessionオブジェクトは、Table18-1に示したように、エンドポイントでアノテーション付与されたライフサイクルメソッドの引数として利用可能です。ピアからのメッセージに応答する場合、メッセージを受信したメソッド@OnMessageアノテーションを付与されたメソッド)内でSessionオブジェクトが利用可能です。もしレスポンスではなくメッセージ送信をする場合、エンドポイントクラスのインスタンス変数としてSessionオブジェクトを@OnOpenアノテーションメソッドで保存し、他のメソッドでこのオブジェクトにアクセスすることが出来ます。
  2. RemoteEndpointオブジェクトを取得するためにSessionオブジェクトを使用する。
    Session.getBasicRemoteSession.getAsyncRemoteメソッドは、それぞれRemoteEndpoint.BasicRemoteEndpoint.Asyncを返します。RemoteEndpoint.Basicインタフェースは送信メッセージのメソッドをブロックしますが、RemoteEndpoint.Asyncはノンブロッキングです。
  3. ピアにメッセージを送信するためのRemoteEndpointオブジェクトを使用する。
    以下にピアへのメッセージ送信するメソッドの使用例を示します。
    • void RemoteEndpoint.Basic.sendText(String text)
      ピアにテキストメッセージを送信します。このメソッドはメッセージがすべて送信されるまでブロックします。
    • void RemoteEndpoint.Basic.sendBinary(ByteBuffer data)
      ピアにバイナリメッセージを送信します。このメソッドはメッセージがすべて送信されるまでブロックします。
    • void RemoteEndpoint.sendPing(ByteBuffer appData)
      ピアにpingフレームを送信します。
    • void RemoteEndpoint.sendPong(ByteBuffer appData)
      ピアにpongフレームを送信します。

到着するテキストメッセージに応答する方法についてはAnnotated Endpointsで説明しています。

18.5.1.1 Sending Messages to All Peers Connected to an Endpoint

エンドポイントの各インスタンスは一つ以上のコネクションとピアに関連付けられます。場合によっては、エンドポイントインスタンスが接続している全ピアにメッセージ送信する必要があることもあります。例としてはチャットやネットオークションなどです。Sessionインタフェースはこの目的のためにgetOpenSessionsメソッドを提供しています。以下の例は接続している全ピアに到着したテキストメッセージをフォワードするためのこのメソッドを使用しています。

@ServerEndpoint("/echoall")
public class EchoAllEndpoint {
   @OnMessage
   public void onMessage(Session session, String msg) {
      try {
         for (Session sess : session.getOpenSessions()) {
            if (sess.isOpen())
               sess.getBasicRemote().sendText(msg);
         }
      } catch (IOException e) { ... }
   }
}

18.5.2 Receiving Messages

OnMessageアノテーションは到着するメッセージを処理するメソッドを指定するものです。エンドポイントには最大三つの@OnMessageアノテーションを付与するメソッドを作成でき、メッセージタイプがテキストかバイナリのものと、pongです。以下にメッセージのタイプ別に三つのメソッドを作成する方法を示します。

@ServerEndpoint("/receive")
public class ReceiveEndpoint {
   @OnMessage
   public void textMessage(Session session, String msg) {
      System.out.println("Text message: " + msg);
   }
   @OnMessage
   public void binaryMessage(Session session, ByteBuffer msg) {
      System.out.println("Binary message: " + msg.toString());
   }
   @OnMessage
   public void pongMessage(Session session, PongMessage msg) {
      System.out.println("Pong message: " + 
                          msg.getApplicationData().toString());
   }
}

18.6 Maintaining Client State

コンテナはコネクションごとにエンドポイントクラスのインスタンスを生成するので、クライアント状態の情報を格納するためにインスタンス変数を使用可能です。また、Session.getUserPropertiesメソッドがユーザープロパティを格納するための更新可能なmapを提供します。たとえば、以下のエンドポイントは到着したテキストメッセージに対して、各クライアントからの直前のメッセージ内容を返します。

@ServerEndpoint("/delayedecho")
public class DelayedEchoEndpoint {
   @OnOpen
   public void open(Session session) {
      session.getUserProperties().put("previousMsg", " ");
   }
   @OnMessage
   public void message(Session session, String msg) {
      String prev = (String) session.getUserProperties()
                                    .get("previousMsg");
      session.getUserProperties().put("previousMsg", msg);
      try {
         session.getBasicRemote().sendText(prev);
      } catch (IOException e) { ... }
   }
}

すべての接続クライアントに共通な情報を格納するには、クラスのstatic変数が使用可能です。ただし、プログラマにはそれらの変数へのアクセスがスレッドセーフなことを保証する責任があります。

18.7 Using Encoders and Decoders

Java API for WebSocketは、WebSocketメッセージとカスタムJavaクラス間での変換を行うエンコーダ・デコーダをサポートしています。エンコーダはJavaオブジェクトを受け取ってWebSocketメッセージとして送信可能な表現を生成します。たとえば、エンコーダは、一般的にはJSON,XMLあるいはバイナリ表現を生成します。デコーダはその逆をするもので、WebSocketメッセージを読み込んでJavaオブジェクトを生成します。

このメカニズムはWebSocketアプリケーションをシンプルにするもので、その理由はオブジェクトのシリアライズ・デシリアライズからビジネスロジックを切り離すためです。

18.7.1 Implementing Encoders to Convert Java Objects into WebSocket Messages

以下にエンドポイントでエンコーダを使用して実装する手順を示します。

  1. 以下のインタフェースの一つを実装します。
    • テキストメッセージ用のEncoder.Text<T>
    • バイナリメッセージ用のEncoder.Binary<T>
      これらのインタフェースはencodeメソッドを定義しています。WebSocketメッセージを送信したい各カスタムJavaクラスごとにエンコーダクラスを実装します。
  2. ServerEndpointのオプションパラメータencodersにエンコーダ実装を追加します。
  3. メッセージとしてオブジェクトを送信するためにインタフェースRemoteEndpoint.BasicもしくはRemoteEndpoint.AsyncメソッドsendObject(Object data)を使用します。コンテナは型が一致するエンコーダを検索し、オブジェクトをWebSocketメッセージへ変換するためにそのクラスを使用します。

たとえば、いま二つのJavaクラス(MessageAMessageB)をテキストメッセージとして送信したいとすると、以下のようにEncoder.Text<MessageA>Encoder.Text<MessageB>インタフェースを実装します。

public class MessageATextEncoder implements Encoder.Text<MessageA> {
   @Override
   public void init(EndpointConfig ec) { }
   @Override
   public void destroy() { }
   @Override
   public String encode(MessageA msgA) throws EncodeException {
      // msgAのプロパティにアクセスしてJSONテキストに変換する。。。
      return msgAJsonString;
   }
}

Encoder.Text<MessageB>も同じように実装します。それから、以下のようにServerEndpointアノテーションencodersパラメータを追加します。

@ServerEndpoint(
   value = "/myendpoint",
   encoders = { MessageATextEncoder.class, MessageBTextEncoder.class }
)
public class EncEndpoint { ... }

WebSocketメッセージとしてMessageAMessageBを送信するにはsendObjectメソッドを以下のように使用します。

MessageA msgA = new MessageA(...);
MessageB msgB = new MessageB(...);
session.getBasicRemote.sendObject(msgA);
session.getBasicRemote.sendObject(msgB);

この例のように、テキスト・バイナリメッセージそれぞれのエンコーダを複数指定可能です。エンドポイント同様、エンコーダは一つ以上のWebSocketコネクションとピアに関連付けられ、エンコーダインスタンスのコードを実行するスレッドは常時一つのみです。

18.7.2 Implementing Decoders to Convert WebSocket Messages into Java Objects

以下にエンドポイントでデコーダを使用して実装する手順を示します。

  1. 以下のインタフェースの一つを実装します。
    • テキストメッセージ用のDecoder.Text<T>
    • バイナリメッセージ用のDecoder.Binary<T>
      これらのインタフェースはwillDecodedecodeメソッドを定義しています。
      Note: エンコーダとは異なり、バイナリメッセージ・テキストメッセージにそれぞれ最大一つだけデコーダを定義可能です。
  2. ServerEndpointのオプションパラメータdecodersデコーダ実装を追加します。
  3. エンドポイントで引数としてカスタムJavaクラスを取るメソッドOnMessageアノテーションを使用します。指定したデコーダでデコード可能なメッセージをエンドポイントが受信するとき、コンテナは引数としてカスタムJavaクラスを取る@OnMessageアノテーションを付与したメソッドが存在すればこれを呼び出します。

いま、テキストメッセージとして送受信したい二つのJavaクラス(MessageAMessageB)があり、共通クラス(Message)を拡張して定義している、とします。テキストメッセージのデコーダは一つだけ定義可能なので、Messageクラスのデコーダは以下のように実装します。

public class MessageTextDecoder implements Decoder.Text<Message> {
   @Override
   public void init(EndpointConfig ec) { }
   @Override
   public void destroy() { }
   @Override
   public Message decode(String string) throws DecodeException {
      // Read message...
      if ( /* message is an A message */ )
         return new MessageA(...);
      else if ( /* message is a B message */ )
         return new MessageB(...);
   }
   @Override
   public boolean willDecode(String string) {
      // メッセージがMessageAかMessageBのどちらかに変換可能か
      // 判定します。
      return canDecode;
   }
}

以下のようにServerEndpointアノテーションのパラメータにdecoderを追加します。

@ServerEndpoint(
   value = "/myendpoint",
   encoders = { MessageATextEncoder.class, MessageBTextEncoder.class },
   decoders = { MessageTextDecoder.class }
)
public class EncDecEndpoint { ... }

そして、MessageAMessageBを受信するエンドポイントのメソッドを以下のように定義します。

@OnMessage
public void message(Session session, Message msg) {
   if (msg instanceof MessageA) {
      // We received a MessageA object...
   } else if (msg instanceof MessageB) {
      // We received a MessageB object...
   }
}

エンドポイント同様に、デコーダインスタンスは一つのWebSocketコネクションとピア にだけ関連付けられるため、ある時間にデコーダインスタンスのコードを実行するスレッドは一つだけです。

18.8 Path Parameters

ServerEndpointアノテーションはアプリケーションのパラメータとしてエンドポイントデプロイメントURIの一部をURIテンプレートを使用して定義可能です。

@ServerEndpoint("/chatrooms/{room-name}")
public class ChatEndpoint {
   ...
}

いま、ローカルのJava EEサーバにポート8080でchatappというwebアプリケーションでエンドポイントをデプロイしているとすると、クライアントは以下のようなURIを使用してエンドポイントに接続可能です。

http://localhost:8080/chatapp/chatrooms/currentnews
http://localhost:8080/chatapp/chatrooms/music
http://localhost:8080/chatapp/chatrooms/cars
http://localhost:8080/chatapp/chatrooms/technology

annotated endpointは@OnOpen, @OnMessage, @OnCloseアノテーションを付与されたメソッドの引数としてパスパラメータを受け取ることができます。たとえば、クライアントが入室したいチャットルームを決定するために@OnOpenメソッドでパラメータを使用できます。

@ServerEndpoint("/chatrooms/{room-name}")
public class ChatEndpoint {
   @OnOpen
   public void open(Session session, 
                    EndpointConfig c, 
                    @PathParam("room-name") String roomName) {
      // 選択したチャットルームにクライアントを追加する。
   }
}

メソッドの引数として使用可能なパスパラメータは、文字列・プリミティブ型とそのラッパー型のいずれかです。

18.9 Handling Errors

アノテーションが付与されているWebSocketエンドポイントでエラー処理をするメソッドを定義するには、@OnErrorを付与します。

@ServerEndpoint("/testendpoint")
public class TestEndpoint {
   ...
   @OnError
   public void error(Session session, Throwable t) {
      t.printStackTrace();
      ...
   }
}

このメソッドが呼び出されるのは、コネクションに問題が発生・メッセージ処理中のランタイムエラー・メッセージデコード時の変換エラー、の場合です。

18.10 Specifying an Endpoint Configurator Class

Java API for WebSocketでは、コンテナがサーバーエンドポイントのインスタンスをどのように生成するかを設定できます。以下のようなロジックのエンドポイント設定を作成可能です。

  • WebSocketコネクション用の初回HTTPリクエストの詳細へのアクセス。
  • OriginHTTPヘッダーのカスタムチェックの実行。
  • WebSocketハンドシェークレスポンスの修正。
  • クライアントからリクエストされるWebSocketサブプロトコルの選択。
  • エンドポイントインスタンスの初期化とインスタンス化の制御。

カスタムのエンドポイント設定ロジックを構築するには、ServerEndpointConfig.Configuratorクラスを拡張してそのメソッドをオーバーライドします。エンドポイントクラスでは、ServerEndpointアノテーションconfiguratorパラメータを使用してconfiguratorクラスを指定します。

例として、以下のconfiguratorクラスはエンドポイントインスタンスでハンドシェイクリクエストオブジェクトを使用可能にしています。

public class CustomConfigurator extends ServerEndpointConfig.Configurator {

    @Override
    public void modifyHandshake(ServerEndpointConfig conf,
                                HandshakeRequest req,
                                HandshakeResponse resp) {

        conf.getUserProperties().put("handshakereq", req);
    }

}

以下のエンドポイントクラスは、ハンドシェイクリクエストオブジェクトにアクセス可能なカスタムconfiguratorクラスを、エンドポイントインスタンスに設定しています。

@ServerEndpoint(
    value = "/myendpoint",
    configurator = CustomConfigurator.class
)
public class MyEndpoint {

    @OnOpen
    public void open(Session s, EndpointConfig conf) {
        HandshakeRequest req = (HandshakeRequest) conf.getUserProperties()
                                                      .get("handshakereq");
        Map<String,List<String>> headers = req.getHeaders();
        ...
    }
}

エンドポイントクラスは、HttpSessionオブジェクトやヘッダーのような、HTTPリクエストの詳細にアクセスするためのハンドシェイクリクエストオブジェクトを使用可能です。

エンドポイント設定の詳細な情報については、ServerEndpointConfig.ConfiguratorAPIリファレンスを参照してください。

18.11 The dukeetf2 Example Application

dukeetf2サンプルアプリケーションは、tut-install/examples/web/websocket/dukeetf2/ディレクトリ*2にアップされており、webクライアントへデータアップデートをするためのWebSocketエンドポイントの使用方法のサンプルとなっています。このサンプルは電子証券取引における定期的な出来高と価格の更新を提供するサービスのようなものです。

18.11.1 Architecture of the dukeetf2 Sample Application

dukeetf2サンプルアプリケーションは、WebSocketエンドポイント・EJB・HTMLページ、から構成されます。

  • エンドポイントはクライアントからの接続を受け付け、出来高と価格の新しいデータが使用可能ならその更新情報を送信します。
  • EJBは毎秒出来高と価格を更新します。
  • HTMLページはWebSocketエンドポイントに接続するためにJavaScriptを使用し、受信メッセージをパースし、ページをリロードせずに出来高と価格の情報を更新します。

18.11.1.1 The Endpoint

WebSocketエンドポイントはETFEndpointクラスで実装されており、キューにすべての接続セッションを格納し、送信する新情報があるときにEJBが呼び出すメソッドを定義しています。

@ServerEndpoint("/dukeetf")
public class ETFEndpoint {
   private static final Logger logger = Logger.getLogger("ETFEndpoint");
   /* Queue for all open WebSocket sessions */
   static Queue<Session> queue = new ConcurrentLinkedQueue<>();

   /* PriceVolumeBean calls this method to send updates */
   public static void send(double price, int volume) {
      String msg = String.format("%.2f, %d", price, volume);
      try {
         /* Send updates to all open WebSocket sessions */
         for (Session session : queue) {
            session.getBasicRemote().sendText(msg);
            logger.log(Level.INFO, "Sent: {0}", msg);
         }
      } catch (IOException e) {
         logger.log(Level.INFO, e.toString());
      }
    }
    ...
}

エンドポイントのライフサイクルメソッドはキューにセッションの追加と削除を行います。

@ServerEndpoint("/dukeetf")
public class ETFEndpoint {
   ...
   @OnOpen
   public void openConnection(Session session) {
      /* Register this connection in the queue */
      queue.add(session);
      logger.log(Level.INFO, "Connection opened.");
   }

   @OnClose
   public void closedConnection(Session session) {
      /* Remove this connection from the queue */
      queue.remove(session);
      logger.log(Level.INFO, "Connection closed.");
   }

   @OnError
   public void error(Session session, Throwable t) {
      /* Remove this connection from the queue */
      queue.remove(session);
      logger.log(Level.INFO, t.toString());
      logger.log(Level.INFO, "Connection error.");
   }
}

18.11.1.2 The Enterprise Bean

EJBは毎秒出来高と価格の情報を生成するためにタイマーサービスを使用します。

@Startup
@Singleton
public class PriceVolumeBean {
   /* Use the container's timer service */
   @Resource TimerService tservice;
   private Random random;
   private volatile double price = 100.0;
   private volatile int volume = 300000;
   private static final Logger logger = Logger.getLogger("PriceVolumeBean");
   
   @PostConstruct
   public void init() {
       /* Initialize the EJB and create a timer */
       logger.log(Level.INFO, "Initializing EJB.");
       random = new Random();
       tservice.createIntervalTimer(1000, 1000, new TimerConfig());
   }
   
   @Timeout
   public void timeout() {
       /* Adjust price and volume and send updates */
       price += 1.0*(random.nextInt(100)-50)/100.0;
       volume += random.nextInt(5000) - 2500;
       ETFEndpoint.send(price, volume);
   }
}

EJBタイムアウトメソッドETFEndpointクラスのsendメソッドを呼び出します。タイマーサービスの詳細な情報についてはChapter 34, "Running the Enterprise Bean Examples"Using the Timer Serviceを参照してください。

18.11.1.3 The HTML Page

HTMLページはテーブルとJavaScriptのコードで構成されます。テーブルはJavaScriptから参照する二つのフィールドを持っています。

<!DOCTYPE html>
<html>
<head>...</head>
<body>
  ...
  <table>
    ...
    <td id="price">--.--</td>
    ...
    <td id="volume">--</td>
    ...
  </table>
</body>
</html>

JavaScriptはサーバエンドポイントへの接続と受信メッセージ用のコールバックメソッドを指定するためにWebSocket APIを使用します。コールバックメソッドは新しい情報でページを更新します。

var wsocket;
function connect() {
   wsocket = new WebSocket("ws://localhost:8080/dukeetf2/dukeetf");
   wsocket.onmessage = onMessage;
}
function onMessage(evt) {
   var arraypv = evt.data.split(",");
   document.getElementById("price").innerHTML = arraypv[0];
   document.getElementById("volume").innerHTML = arraypv[1];
}
window.addEventListener("load", connect, false);

WebSocket APIは多くのモダンなブラウザでサポートされており、HTML5 webクライアントの開発に広く使用されています。

18.11.2 Running the dukeetf2 Example Application

このセクションではNetBeans IDEコマンドラインdukeetf2サンプルアプリケーションを実行する方法について説明します。

18.11.2.1 To Run the dukeetf2 Example Application Using NetBeans IDE

  1. GlassFishサーバが実行中なことを確認(Starting and Stopping GlassFish Serverを参照)
  2. FileメニューからOpen Projectを選択
  3. Open Projectダイアログボックスから以下のディレクトリに移動。
    tut-install/examples/web/websocket
  4. dukeetf2フォルダを選択。
  5. Open Projectをクリック。
  6. Projectsタブでdukeetf2プロジェクトを右クリックしてRunを選択。
    このコマンドはアプリケーションをビルドしてWARファイル(dukeetf2.war)にパッケージしてtargetディレクトリに配置し、サーバにデプロイし、以下のURLでwebブラウザーを開きます。
    http://localhost:8080/dukeetf2/
  7. 同じURLを別のwebブラウザーのタブかウィンドウで開き、両方のページが出来高と値を同時に更新するのを確認します。

18.11.2.2 To Run the dukeetf2 Example Application Using Maven

  1. GlassFishサーバが実行中なことを確認(Starting and Stopping GlassFish Serverを参照)
  2. ターミナルで以下に移動:
    tut-install/examples/web/websocket/dukeetf2/
  3. アプリケーションをデプロイするために以下のコマンドを実行。
    mvn install
  4. webブラウザを開いて以下のアドレスを入力。
    http://localhost:8080/dukeetf2/
  5. 同じURLを別のwebブラウザーのタブかウィンドウで開き、両方のページが出来高と値を同時に更新するのを確認します。

18.12 The websocketbot Example Application

websocketbotサンプルアプリケーションは、tut-install/examples/web/websocket/websocketbot/ディレクトリ*3にアップされており、チャットの実装にWebSocketエンドポイントを使用する方法のサンプルとなっています。このサンプルは多数のユーザが入室して会話できるチャットルームの例です。ユーザはチャットルームで常に使用可能なbotエージェントに簡単な質問を投げることができます。

18.12.1 Architecture of the websocketbot Example Application

websocketbotサンプルアプリケーションは以下の要素で構成されています。

  • メッセージに応答するbotエージェントのロジックを持つCDIビーン(BotBean
  • チャットルームを実装するWebSocketエンドポイント(BotEndpoint
  • アプリケーションのメッセージを表現するクラス(Message, ChatMessage, InfoMessage, JoinMessage, UsersMessage
  • アプリケーションのメッセージをJSONデータ形式のWebSocketテキストメッセージにエンコードするクラス(ChatMessageEncoder, InfoMessageEncoder, JoinMessageEncoder, UsersMessageEncoder
  • JSONデータ形式のWebSocketテキストメッセージをパースしてJoinMessageChatMessageにデコードするクラス(MessageDecoder
  • チャットルームのクライアントを実装するJavaScriptコードを含むHTMLページ(index.html

18.12.1.1 The CDI Bean

CDIビーンはrespondメソッドを持つJavaクラスです。このメソッドは事前設定された質問とチャットメッセージを比較してレスポンスを返します。

@Named
public class BotBean {
    public String respond(String msg) { ... }
}

18.12.1.2 The WebSocket Endpoint

WebSocketエンドポイント(BotEndpoint)はannotated endpointで以下の機能を実行します。

  • クライアントからのメッセージ受信
  • クライアントへのメッセージの転送
  • 接続クライアントリストのメンテナンス
  • botエージェント機能の呼び出し

エンドポイントはServerEndpointアノテーションを使用してデプロイメントURI・エンコーダ・デコーダを定義します。エンドポイントは、DI経由でBotBeanクラスとmanaged executor service resourceのインスタンスを取得します。

@ServerEndpoint(
   value = "/websocketbot",
   decoders = { MessageDecoder.class }, 
   encoders = { JoinMessageEncoder.class, ChatMessageEncoder.class, 
                InfoMessageEncoder.class, UsersMessageEncoder.class }
)
/* There is a BotEndpoint instance per connection */
public class BotEndpoint {
   private static final Logger logger = Logger.getLogger("BotEndpoint");
   /* Bot functionality bean */
   @Inject private BotBean botbean;
   /* Executor service for asynchronous processing */
   @Resource(name="comp/DefaultManagedExecutorService")
   private ManagedExecutorService mes;
   
   @OnOpen
   public void openConnection(Session session) {
       logger.log(Level.INFO, "Connection opened.");
   }
   ...
}

messageメソッドはクライアントから送られるメッセージを処理します。デコーダは到着するテキストメッセージを、Messageクラスを拡張するJoinMessageChatMessageに変換します。messageメソッドは引数としてMessageオブジェクトを受け取ります。

@OnMessage
public void message(Session session, Message msg) {
   logger.log(Level.INFO, "Received: {0}", msg.toString());
   
   if (msg instanceof JoinMessage) {
      /* Add the new user and notify everybody */
      JoinMessage jmsg = (JoinMessage) msg;
      session.getUserProperties().put("name", jmsg.getName());
      session.getUserProperties().put("active", true);
      logger.log(Level.INFO, "Received: {0}", jmsg.toString());
      sendAll(session, new InfoMessage(jmsg.getName() + 
              " has joined the chat"));
      sendAll(session, new ChatMessage("Duke", jmsg.getName(), 
              "Hi there!!"));
      sendAll(session, new UsersMessage(this.getUserList(session)));
      
   } else if (msg instanceof ChatMessage) {
      /* Forward the message to everybody */
      ChatMessage cmsg = (ChatMessage) msg;
      logger.log(Level.INFO, "Received: {0}", cmsg.toString());
      sendAll(session, cmsg);
      if (cmsg.getTarget().compareTo("Duke") == 0) {
         /* The bot replies to the message */
         mes.submit(new Runnable() {
            @Override
            public void run() {
               String resp = botbean.respond(cmsg.getMessage());
               sendAll(session, new ChatMessage("Duke",
                       cmsg.getName(), resp));
            }
         });
      }
   }
}

メッセージが入室メッセージなら、エンドポイントは新規ユーザをリストに追加してすべての接続クライアントに通知をします。メッセージがチャットメッセージなら、エンドポイントはすべての接続クライアントにメッセージを転送します。

チャットメッセージがbotエージェントに対してなら、エンドポイントはBotBeanインスタンスからレスポンスを取得してすべての接続クライアントに送信します。sendAllメソッドについてはSending Messages to All Peers Connected to an Endpointの例も参照してください。

Asynchronous Processing and Concurrency Considerations

WebSocketエンドポイントはbotのレスポンスを取得するためにBotBean.respondメソッドを呼び出します。このサンプルでは、これはブロック操作のため、メッセージ送信をしたユーザは操作が完了するまで他のチャットメッセージの送受信が出来なくなります。この問題を避けるために、エンドポイントはコンテナからexecutor serviceを取得してConcurrency Utilities for Java EEManagedExecutorService.submitメソッドを使用して異なるスレッドでブロック操作を実行します。

Java EE実装を要求するJava API for WebSocket仕様はコネクションごとにエンドポイントクラスをインスタンス化します。WebSocketエンドポイントクラスのコードを実行するスレッドは常に唯一つだけだと保障されるので、WebSocketエンドポイントの構築が容易になります。この例のように、エンドポイントで新規スレッドを生成する場合、一つ以上のスレッドからアクセスされる変数とメソッドがスレッドセーフだと保障する必要があります。この例では、BotBeanのコードはスレッドセーフで、BotEndpoint.sendAllメソッドsynchronized宣言がされています。

managed executor serviceとConcurrency Utilities for Java EEの詳細な情報についてはChapter 56, "Concurrency Utilities for Java EE"を参照してください。

18.12.1.3 The Application Messages

アプリケーションメッセージ(Message, ChatMessage, InfoMessage, JoinMessage, UsersMessage)を表現するクラスは単にプロパティとgetterとsetterメソッドだけです。たとえばChatMessageクラスは以下のようになっています。

public class ChatMessage extends Message {
    private String name;
    private String target;
    private String message;
    /* ... Constructor, getters, and setters ... */
}

18.12.1.4 The Encoder Classes

エンコーダクラスはJava API for JSON Processingを使用してアプリケーションメッセージをJSONテキストに変換します。たとえば、ChatMessageEncoderクラスは以下のように実装されています。

/* JSONにChatMessageをエンコードします。
 * たとえば
 * (new ChatMessage("Peter","Duke","How are you?"))
 * は以下のようにエンコードされます。
 * {"type":"chat","target":"Duke","message":"How are you?"}
 */
public class ChatMessageEncoder implements Encoder.Text<ChatMessage> {
   @Override
   public void init(EndpointConfig ec) { }
   @Override
   public void destroy() { }
   @Override
   public String encode(ChatMessage chatMessage) throws EncodeException {
      // Access properties in chatMessage and write JSON text...
   }
}

Java API for JSON Processingの詳細な情報についてはChapter 19, JSON Processingを参照してください。

18.12.1.5 The Message Decoder

メッセージデコーダMessageDecoder)クラスはJSONテキストをパースしてWebSocketテキストメッセージをアプリケーションメッセージに変換します。これは以下のように実装されます。

/* JSONメッセージをJoinMessageかChatMessageに変換します。
 * たとえば受信メッセージが以下の場合、
 * {"type":"chat","name":"Peter","target":"Duke","message":"How are you?"}
 * 下記のようにデコードされます。
 * (new ChatMessage("Peter", "Duke", "How are you?"))
 */
public class MessageDecoder implements Decoder.Text<Message> {
    /* MapでJSONメッセージのname-valueペアを格納します。 */
    private Map<String,String> messageMap;

    @Override
    public void init(EndpointConfig ec) { }
    @Override
    public void destroy() { }

    /* メッセージがデコード可能であれば新しいメッセージオブジェクトを生成します。*/
    @Override
    public Message decode(String string) throws DecodeException {
       Message msg = null;
       if (willDecode(string)) {
          switch (messageMap.get("type")) {
             case "join":
                msg = new JoinMessage(messageMap.get("name"));
                break;
             case "chat":
                msg = new ChatMessage(messageMap.get("name"),
                                      messageMap.get("target"),
                                      messageMap.get("message"));
          }
       } else {
          throw new DecodeException(string, "[Message] Can't decode.");
       }
       return msg;
   }
   

   /* JSONメッセージをMAPにデコードしてこの型で要求する全フィールドを
    * 含むかチェックします。*/
   @Override
   public boolean willDecode(String string) {
      // メッセージからJSONデータをname-valueマップに変換。
      // メッセージがこの型のフィールドを持つかどうかチェック。
   }
}

18.12.1.6 The HTML Page

HTMLページ(index.html)にはユーザ名のフィールドがあります。ユーザが名前を入力してJoinをクリックすると、三つのテキストエリアが利用可能になります。送信メッセージの入力欄、チャットルーム、ユーザのリスト。また、JSONテキストとして送受信されるメッセージを表示するWebSocketコンソールもあります。

このページのJavaScriptコードはWebSocket APIを使用して、エンドポイントへの接続・メッセージの送信・コールバックメソッドの指定、を行います。WebSocket APIは多くのモダンなブラウザでサポートされており、HTML5 webクライアントの開発に広く使用されています。

18.12.2 Running the websocketbot Example Application

このセクションではNetBeans IDEコマンドラインwebsocketbotサンプルアプリケーションを実行する方法について説明します。

18.12.2.1 To Run the websocketbot Example Application Using NetBeans IDE

  1. GlassFishサーバが実行中なことを確認(Starting and Stopping GlassFish Serverを参照)
  2. FileメニューからOpen Projectを選択
  3. Open Projectダイアログボックスから以下のディレクトリに移動。
    tut-install/examples/web/websocket
  4. websocketbotフォルダを選択。
  5. Open Projectをクリック。
  6. Projectsタブでwebsocketbotプロジェクトを右クリックしてRunを選択。
    このコマンドはアプリケーションをビルドしてWARファイル(websocketbot.war)にパッケージしてtarget/ディレクトリに配置し、サーバにデプロイし、以下のURLでwebブラウザーを開きます。
    http://localhost:8080/websocketbot/
    テストにはTo Test the websocketbot Example Applicationを参照してください。

18.12.2.2 To Run the websocketbot Example Application Using Maven

  1. GlassFishサーバが実行中なことを確認(Starting and Stopping GlassFish Serverを参照)
  2. ターミナルで以下に移動:
    tut-install/examples/web/websocket/websocketbot/
  3. アプリケーションをデプロイするために以下のコマンドを実行。
    mvn install
  4. webブラウザを開いて以下のアドレスを入力。
    http://localhost:8080/websocketbot/
    テストにはTo Test the websocketbot Example Applicationを参照してください。

18.12.2.3 To Test the websocketbot Example Application

1. メインページで、最初のテキストフィールドに名前を入力してエンターキーを押します。
右側のテキストエリアに接続中のユーザリストが表示され、左側のテキストエリアがチャットルームです。

2. ログインボタン下のテキストエリアにメッセージを入力します。たとえば、以下のように太字部分*4のメッセージを入力してエンターを押すとこのようなレスポンスが得られます。

[--Peter has joined the chat--]
Duke: @Peter Hi there!!
Peter: @Duke how are you?
Duke: @Peter I'm doing great, thank you!
Peter: @Duke when is your birthday?
Duke: @Peter My birthday is on May 23rd. Thanks for asking!

3. 別のブラウザウィンドウのアドレスバーにURIをコピペして別の名前でチャットルームに入室します。
双方のブラウザウィンドウのユーザリストに新しい名前が表示されます。どちらのウィンドウからでもメッセージ送信ができ、メッセージが別のウィンドウではどのように表示されるかを確認できます。

4.Show WebSocket Consoleをクリックします。
JSONテキストとして送受信されるメッセージをコンソールで確認できます。

18.13 Further Information about WebSocket

Java EEにおけるWebSocketの詳細な情報については、Java API for WebSocket仕様を参照してください。

http://www.jcp.org/en/jsr/detail?id=356

関連リンク

*1:markdownでテーブル中にソースコードをキレイに入れ込むやり方が分からなかかったため。

*2:https://java.net/projects/javaeetutorial/sources/svn/show/trunk/examples/web/websocket/dukeetf2

*3:https://java.net/projects/javaeetutorial/sources/svn/show/trunk/examples/web/websocket/websocketbot

*4:下記のうち、@Duke how are you?と@Duke when is your birthday?のこと