エンタープライズギークス (Enterprise Geeks)

企業システムの企画・開発に携わる技術者集団のブログです。開発言語やフレームワークなどアプリケーション開発に関する各種情報を発信しています。ウルシステムズのエンジニア有志が運営しています。

JavaEE7をはじめよう(20) - WebSocket - デコーダとエンコーダ

前回の記事では WebSocket の基本的な実装方法を示した。

WebSocket を用いて本格的なアプリケーションを作る場合、メッセージの送受信をどのように行うかが課題となる。WebSocket には、HTML の FORM のように、構造的なデータを送る仕組みはない。このため、多種多様なデータをやり取りする際には、文字列やバイナリをプログラムで扱いやすいように変換する必要がある。

今回は、チャットアプリケーションを題材として、Java EE WebSocket API のデータ変換の仕組みであるデコーダとエンコーダを解説する。

チャットアプリケーション

サンプルのチャットアプリケーションは こちら で公開しているのでアクセスしてみていただきたい。(前回と同様に、アプリケーションの起動に時間がかかる場合があるため、アクセス時にエラーとなる場合は、しばらく待ってから再度アクセスしていただきたい)。

このアプリケーションでは、各自が名前を設定して、メッセージや画像ファイルを送受信できる。そのため、アプリケーションではバイナリと4種類のテキストをやり取りできるようにしている。

種類 内容 送信データ
Ping サーバーとの疎通確認。 文字列 "Ping"
メッセージ 名前とメッセージの内容。送受信される。 JSON {name:"", message:""}
文字列 JSON以外の文字列。 任意の文字列
画像ファイル 画像ファイルを全クライアントに送信する バイナリ
ファイル情報 画像ファイルの情報 JSON {name:"", fileName:"", type:""}

文字列で構造化データを扱うために、JSON 形式を採用した。

では、サーバー側でこれらのメッセージの種類を見分け、かつプログラムで扱いやすくするにはどうすればよいだろうか。(ちなみに、テキストとバイナリの区別だけなら、前回の記事で紹介したように WebSocket API で行える。)

サーバーエンドポイントの@OnMessageメソッドで文字列を解析してメッセージを判別すると煩雑になってしまう。このような時に、デコーダとエンコーダを使用すると良い。

デコーダ

デコーダの定義

デコーダは、クライアントが送信したテキストやバイナリのメッセージを Java の任意のオブジェクトに変換する仕組みである。 @OnMessageメソッドが実行される前にデコーダの変換処理が動くため、@OnMessageメソッドデコーダが変換したオブジェクトの型を引数に取ることができる。

デコーダは、javax.websocketパッケージのDecoder.TextまたはDecoder.Binaryインターフェースを実装したクラスである。Textは文字列からの変換、Binaryはバイナリからの変換である。

デコーダのソースの全体は こちら にある。以下ではコードの要点を解説する。

/** デコーダをまとめるクラス */
public abstract class Decoders {
    // 各デコータの基底クラス。初期化・破棄は何もしないデフォルト実装とする。
    private static  abstract class BaseTextDecoder<T extends TextBase> 
            implements Decoder.Text<T> {
        @Override
        public void init(EndpointConfig config) {}
        @Override
        public void destroy() {}
    }

    // 基底クラスを継承したデコーダ実装の1つ
    /** 1. JSONメッセージ {name:"xx", message:"xxx"} のデコーダ */
    public static class MessageDecoder extends BaseTextDecoder<Message> {

        // 2. デコードできるか判定する。
        @Override
        public boolean willDecode(String s) {
            // 単一のオブジェクトで、"message", "name"プロパティを
            // 持たない場合、エンコード不可.
            try(JsonReader reader = Json.createReader(new StringReader(s))){
                JsonObject obj = reader.readObject();
                return obj.containsKey("message")
                        && obj.containsKey("name");
            } catch(JsonParsingException e) {
                return false;
            }
        }

        // 3. デコード処理
        @Override
        public Message decode(String s) throws DecodeException {
            try(JsonReader reader = Json.createReader(new StringReader(s))){
                JsonObject obj = reader.readObject();
                return new Message(
                        obj.getString("name"),
                        obj.getString("message"));
            }
        }
    }
    // 他のデコータ実装は割愛
}

まず、デコーダは個々の型ごとに作成する必要がある。今回は、テキスト用のメッセージが4種類あるので、それらの種類ごとにデータ用のクラスとデコーダを用意している。上記のコードは、メッセージ用のクラス(Message)への変換を行うMessageDecoderである。

1.で BaseTextDecoderを継承しているが、これは4種類作成したデコーダごとの共通処理を定義した自作の基底クラスである。javax.websocket.Decoder.Textを実装している。

デコーダの実装クラスではwillEncodeおよびdecodeを実装する必要がある。

2.のwillEncodeではクライアントが送信したテキストの内容から、変換が可能かどうかを判定する。 ここでtrueが返る場合は後続のdecodeが実行され、変換されたオブジェクトが、@OnMessageメソッドに渡される。ここでfalseが返ると、別のデコーダによる変換が試みられる。全てのデコーダで変換できない場合、@OnMessageメソッドは実行されない。

2.では、文字列を JSON として解釈し、nameおよびmessageという属性を持っているかを確認している。ちなみに、ここで JSON の解析を行っているのは、Java EE7 から追加された JSON API である。

今回はデータ構造が単純なので、JSON API でチェックを行っているが、複雑ならば JAXB のようなオブジェクト変換ツールを使用してもよいだろう。

3.では、文字列からMessageインスタンスを生成している。2.と同様に JSON API を使用して、文字列を JSON に変換し、namemessageの値をMessageコンストラクタに渡している。

デコーダの利用

デコーダを利用するには、サーバーエンドポイントのアノテーションデコーダクラスを指定する。 サーバーエンドポイントのソースは こちら を参照いただきたい。以下では要点を抜粋して説明する。

まずはデコーダの利用の宣言だ。

@ServerEndpoint(value="/websocket_sample",
        decoders = { /* デコーダ定義 */
           Decoders.PingDecoder.class, 
           Decoders.MessageDecoder.class, 
           Decoders.FileAttrDecoder.class, 
           Decoders.TextDataDecoder.class},
        encoders = { /* エンコーダ定義 */
           Encoders.MessageEncoder.class, 
           Encoders.FileAttrEncorder.class})
@Dependent
public class WebSocketSampleEndPoint {
// 割愛

@ServerEndpointアノテーションdecoders属性に、作成したデコーダを指定する。ここでは複数デコーダを指定している。デコードの判定は、ここに定義した順番で行われる。

次に、@OnMessageメソッドの定義だ。

    @OnMessage
    // 1. 各メッセージオブジェクトの基底クラスを引数に
    public void onMessage(TextBase obj, Session client) 
                        throws IOException, EncodeException {
        // 2. それぞれの実際の型に応じたメソッドに処理を振り分ける。
    if (obj instanceof Message) {
      // メッセージ受信時の処理
            onMessageToBroadCast((Message)obj, client);
        } else if (obj instanceof Ping) {
            // Ping 受信時の処理
            onPingMessage((Ping)obj, client);
        } else if(obj instanceof TextData) {
            // 不正な文字列受信時の処理
            onInvalidMessage((TextData)obj, client);
        } else if(obj instanceof FileAttr) {
            // ファイル情報受信時の処理
            onUploadFile((FileAttr)obj);
        }
    }

ここは、若干残念な実装になっている。

このアプリケーションで扱うテキストメッセージは4種類なので(バイナリはデコーダを通さずそのまま扱うのでここでは関係ない)、デコードした型に対応するメソッドも4つ定義したい。このため、上記のコードではonMessageToBroadCast, onPingMessage, onInvalidMessage, onUploadFileの4つのメソッドを定義した。可能なら、それらのメソッド@OnMessageアノテーションを指定して、デコードしたオブジェクトの型に対応したメソッドが実行されるとよいのだが、残念ながらそれは仕様上できない。これは、前回の記事にも書いたように@OnMessageを設定できるメソッドは、テキストとバイナリでそれぞれ1つだけだからだ。

よってその対策として、TextBaseというマーカーインターフェースを作成して、デコード対象のすべての型で実装するようにした。これにより、4種類のいずれかのデコーダで変換されたオブジェクトは全てTextBase型で扱えるので、上記のTextBaseを引数にとる@OnMessageメソッドが呼び出される。

そしてメソッド内では、実際の型をinstanceofで判定して、対応するメソッドを呼び出している。

onMessageToBroadCastの実装は以下の通りだ。

    /** 各クライアントに、メッセージを送信する */
    // 本当はここに、@OnMessageを付けたかった。
    public void onMessageToBroadCast(Message message, Session client) 
              throws IOException, EncodeException {
        // Encoderの設定に基づいて、適切な変換が行われる。
        for(Session other : client.getOpenSessions()) {
            other.getAsyncRemote().sendObject(message);
        }
    }

チャットアプリケーションなので、ここでは受信したメッセージを各クライアントに同報している。

前回の記事のコードでは、リモートエンドポイントに対して、setTextメッセージを呼び出していたが、今回はこの後で説明するエンコーダを利用するために、Message型の変数を渡してsendObjectメソッドを呼び出している。

WebSocket はテキストとバイナリの送受信しかできないため、sendObjectで渡したオブジェクトも何らかの方法でテキストかバイナリに変換する必要がある。

ここで登場するのが、エンコーダである。

エンコーダ

エンコーダはデコーダの逆であり、 Javaのオブジェクトをテキストやバイナリデータに変換する仕組みである。

エンコーダの定義

エンコーダは、javax.websocketパッケージのEncoder.TextまたはEncoder.Binaryを実装して作成する。Textは文字列への変換で、Binaryはバイナリへの変換である。

Messageクラスに関するエンコーダのコードの一部を以下に示す。ソースの全体は こちら にある。

public abstract class Encoders {
    // 1. 初期化・破棄は何もしないデフォルト実装とする。
    private static abstract class BaseTextEncorder<T> 
       implements Encoder.Text<T> {
        @Override
        public void destroy() {}

        @Override
        public void init(EndpointConfig config) {}
        
        protected String toMessageJson(String name, String message) {
            StringWriter w = new StringWriter();
            JsonGenerator gen = Json.createGenerator(w);
            gen.writeStartObject()
                .write("name", name)
                .write("message", message)
                .writeEnd().close();
            return w.toString();
        }
    }
    // 2. メッセージクラスのエンコーダ
    public static class MessageEncoder 
               extends BaseTextEncorder<Message> {
        @Override
        public String encode(Message message) throws EncodeException{
            return toMessageJson(message.name, message.message);
        }
    }
// 割愛

1.では、デコーダと同様に、共通処理を持たせたBaseTextEncoderを基底クラスとして定義している。

2.がMessageクラスのエンコーダで、encodeメソッドを実装している。encodeメソッドでは JSON API を用いて、{name:"", message:""}となる JSON 文字列を生成している。

ここで生成した文字列が、実際にクライアントに送信される内容になる。

エンコーダの利用

デコーダと同じく、@ServerEndpointアノテーションencoders属性に指定する。以下にエンドポイントの定義を再掲する。

@ServerEndpoint(value="/websocket_sample",
        decoders = {
           Decoders.PingDecoder.class, 
           Decoders.MessageDecoder.class, 
           Decoders.FileAttrDecoder.class, 
           Decoders.TextDataDecoder.class},
        encoders = { /* エンコーダ定義 */
           Encoders.MessageEncoder.class, 
           Encoders.FileAttrEncorder.class})
@Dependent
public class WebSocketSampleEndPoint {
// 割愛

デコーダの場合、テキストとバイナリのそれぞれで1つしか定義できないため、複数デコーダに対応させるための工夫が必要だったが、エンコーダは特別な工夫なしに何種類でも定義できる。

これは、デコーダがテキストおよびバイナリからのオブジェクト変換なのに対して、エンコーダはオブジェクトからテキストやバイナリへの変換であるためだ。

送信処理では、sendObjectメソッドが呼び出されると、内部で@ServerEndpointアノテーションencoders属性に指定したエンコーダを探し、sendObjectの引数の型と一致するエンコーダのencodeメソッドを呼び出す仕組みになっている。

デコーダ、エンコーダの処理の流れ

ここで、デコーダ、エンコーダ、サーバエンドポイントのシーケンス図を示しておく。

まずは、メッセージの受信時である。

f:id:enterprisegeeks:20151208193253p:plain

次がメッセージの送信時である。

f:id:enterprisegeeks:20151208193300p:plain

このように、デコータ、エンコーダを介したデータ変換は、サーバーエンドポイントの受信と送信処理の途中で行われる。

クライアント側の実装

クライアント側では、文字列として JSON を受け取るので、JSON への変換処理を行い、DOM 操作を行っている。特筆すべき内容はないので、ソースへのリンクのみを示しておく。

クライアント側のJavaScriptのコード

テキストとバイナリをまとめて送信したい場合の対応

WebSocket は、テキストとバイナリを区別して送る。

そのため、画像ファイルをバイナリで送信する場合、ファイル名などの情報は同時には送れない。

今回のアプリケーションでは画像送信を行う際に、バリナリファイルの情報を得るために以下の2つのメッセージを送るようにした。

  • ファイル情報の JSON 文字列({name:"送信者", fileName:"ファイル名", type:"ファイルのMIME"}
  • ファイルのバイナリそのもの

サーバー側では、ファイル情報が到着したら、サーバーエンドポイントのインスタンスフィールドに保持しておく。 次に、ファイルのバイナリが着いたら、まずファイル情報のメッセージを送信して、次にバイナリを送信している。

簡単な方法なので、この方式を採用したが、以下の課題がある。

  • サーバーエンドポイントで、2つのメッセージを受けてから処理を実行するといったステートを管理している。 (サーバーエンドポイントはクライアントごとにインスタンスが作成されるのでステートを持つことは問題ないが、コードやテストが複雑になるためできればステート管理はしたくない)。
  • 2つのメッセージが、ファイル情報、バイナリの順で来ることを前提にしてしまっている(送信においても同様)。

きちんと対処するなら、ファイル情報とバイナリを1つのメッセージに載せるしかないだろう。

その場合には、次のいずれかの方法を採る必要がある。

  • ファイルを Base64 エンコードして、テキストデータに載せて送る
  • バイナリデータにファイルのバイナリとファイル情報を書き込んで送る

どちらにしても、受信データからテキスト部分とバイナリ部分を分割する処理が必要になる。今回は実装しなかったが、確実性を求めるなら検討する必要があるだろう。

まとめ

今回はデコーダとエンコーダを紹介した。この仕組みを利用すると面倒なデータ変換処理を外出しできるので、WebSocket でアプリケーションを作るなら導入した方がよいだろう。

次回はサーバー側からのプッシュ送信について解説する。

[前多 賢太郎]