読者です 読者をやめる 読者になる 読者になる

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

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

JavaEE7をはじめよう(19) - WebSocketの基本

JavaEE WebSocket JavaScript

今回から数回に分けて、 Java EE7 で追加された機能の1つである WebSocket の解説を行う。

WebSocket はクライアント(ブラウザ)とサーバー間で相互通信を行うためのプロトコルで、1つのコネクションを使ってブラウザとサーバーが何度もメッセージの送受信を行うことができる。

HTTP では原則として、クライアントからのリクエストが発生しない限り、サーバー側は何もできない。このため WebSocket は、サーバー側が起点となってクライアントに通信を行ったり、チャットのような多人数でかつ即時応答が求められるようなアプリケーションに適している。

サーバー側のプログラムには、WebSocket に対応している任意の言語が使用できる。クライアント側のプログラムは原則として JavaScript を使用する。
(ただし、Java EE7 の WebSocket はクライアント用 API も提供しており、後日紹介する。)

今回は簡単なサンプルを通じて、クライアント(JavaScript)とサーバー(Java)の基本的な仕組みを紹介する。

サンプルアプリケーション

まずは こちらのサンプル にアクセスしてみていただきたい。(アプリケーションの起動に時間がかかる場合があるため、アクセス時にエラーとなる場合は、しばらく待ってから再度アクセスしていただきたい)。

WebSocket では、テキストとバイナリの2種類のデータを送受信することができるため、このサンプルでも以下の2つの機能を用意している。

  • テキストを入力して送信すると、同じテキストを全てのクライアントに送る。
  • 画像ファイルを選択して送信すると、自分だけにグレースケールした画像を送り返す。

複数のブラウザを起動して、試してみて欲しい。 1つのブラウザでテキストを入力して送信すると、 他のブラウザにもテキストが反映されるはずだ。

ソースコード全体については github を参照いただきたい。

クライアント側の実装

クライアント側の JavaScript の実装は以下のようになっている。
JQuery を使用し、ウィンドウの読み込み時に行う処理として記述している。)

$(function(){
    
    // 1. バイナリの読み込み関数の定義
    var readBinary = function(blob) {
        var reader = new FileReader();
        reader.onload = function(){
            $("#img").attr("src", reader.result);
        }
        reader.readAsDataURL(blob);
    }
    
    // 2. WebSocketサーバーの接続
    var ws = new WebSocket("ws://" + window.location.host 
               + "/java_ee_example/websocket_simple");
    
    ws.onopen = function(){
        console.log("connect")
    };
    // 受信時の処理
    ws.onmessage = function(data) {
        if (data.data instanceof Blob) {
            //バイナリ受信
            readBinary(data.data);
        } else {
            //テキスト受信
            $("#text").text(data.data);
        }
    };
    // 3.入力欄のテキストを送る
    $("#send").click(function(){
        var text = $("#input").val()
        ws.send(text);
    });
    // 4.画像を送る
    $("#upload").click(function(){
        
        // 4-1 入力ファイルの確認
        var file = $("#file").get(0).files[0];
        if(!file){    
            alert("ファイルを選択");
            return;
        }
        if(!file.type.match('image.*')) {
            alert("画像のみ送信可能")
            return;
        }
        // 4-2 ファイルを読込み、バイナリを送信する。
        var reader = new FileReader();
        reader.onload = function(){
            ws.send(reader.result);
        }
        reader.readAsArrayBuffer(file);
    });
});

クライアント側の実装は、new WebSocket(URL)で接続を開始し、sendで送信し、onmessageで受信する、という3点が基本である。以下、詳細を説明する。

1.ではバイナリファイルを読み込むreadBinary関数を定義している。

JavaScript の File API を用いることで、JavaScript でもバイナリファイルを扱える。

バイナリを扱うには、FileReaderを使用する。FileReaderは、非同期に読み込みを行いコールバックで読み込み完了を知らせる。 そのため、コード上の定義が逆だが、reader.readAsDataURLバイナリの読み込みを開始したあと、読み込み完了後に readr.onload で定義した関数が実行される。)

reader.readAsDataURL メソッドは バイナリをデータURLとして読込むメソッドである。 データURL は画像などの本来は HTML 上にないファイルを HTML の URL に Base64 エンコードで直接埋め込む方式で、この形式で読み取ったデータは、imgタグのsrc属性などに直接記述できる。
このようにして、JavaScript で受け取ったバイナリファイルを HTML に反映している。

2.では、WebSocket のサーバーへの接続を行っている。new WebSocket(URL)で接続を行い、通信用のエンドポイントオブジェクトを得る。

その後のコードは、コールバック用の関数の設定である。

onopen はサーバーと接続したときに呼び出される関数である。ここではコンソールに出力するだけだ。

onmessage はサーバーからメッセージを受信したときに呼ばれる関数である。
ここでは、取得したデータがバイナリ( JavaScirpt では、バイナリはBlobで表す)かテキストかを判定し、バイナリであれば 1.のreadBinary関数を呼び出す。テキストならその内容をブラウザの DOM に反映している。

イベントハンドラは他にも、onclose, onerrorなどがあり必要に応じて実装する。 (コネクションの切断は明示的に行ってもよいが、ブラウザの画面遷移や再ロード時などに自動的に切断されるため、通常は実装しなくてよい。)

3.は、クライアント側からのテキスト送信を行う処理である。 ボタンが押されたときに、入力欄(id=text)の入力値を取得し、 エンドポイントのws.send関数を呼ぶことでサーバー側へメッセージを送信する。 (サーバーからの受信は、前述したonmessageで行うので、sendで送信後に何か戻り値があるわけではない。)

4.は、バイナリファイルの送信を行う処理である。

4-1 ではアップロードするファイルの入力有無や形式チェックを行っている。4-2 でも1同様に、File API の1つであるFileReaderを使用してファイルを読み込んでバイナリを取得し、読込み完了後のonloadコールバックでws.sendでバイナリを送信している。

1.で使用した File API との使用方法の違いは、reader.readAsArrayBuffer を使用している点で、 このメソッドは バイナリをバイト配列として読み込むものである。

サーバー側の実装

WebSocket API は、Java EE に含まれてはいるが、他の仕様にはほとんど依存していない。このため、サーブレットが動く環境であれば、WebSocket API関連の jar ファイルを設定すれば動作する。Java SE 環境でも動作が可能だ。

Java EE7 の WebSocket API による、サーバー側の実装例を示す。

// 1. サーバーエンドポイントのURLの設定を行う。
@ServerEndpoint(value="/websocket_simple")
public class WebSocketBasicEndpoint {
    
    /** 
     * 2.クライアントからの接続時にコールされる。
     * 
     * 引数は以下が設定可能だが、メソッド内で使用しないなら省略できる。
     * @param client クライアントの接続情報
     * @param config 設定情報
     */
    @OnOpen
    public void onOpen(Session client, EndpointConfig config) {
        System.out.println(client.getId() + " was connected.");
    }
    
    /** 
     * 3.クライアントの切断時にコールされる
     * 
     * 引数は前述の通り、省略可能
     * @param client 接続
     * @param reason 切断理由
     */
    @OnClose
    public void onClose(Session client, CloseReason reason) {
        System.out.println(client.getId() + " was closed by " 
                + reason.getCloseCode()
                + "[" + reason.getCloseCode().getCode()+"]");
    }
    
    /**
     * 4.エラー時にコールされる。
     * 
     * @param client クライアント接続
     * @param error エラー
     */
    @OnError
    public void onError(Session client, Throwable error) {
        System.out.println(client.getId() + " was error.");
        error.printStackTrace();
    }
    
    /**
     * 5.テキストメッセージ受信時の処理
     * 
     * 全クライアントにメッセージを送信する。
     * 
     * 引数は使用しなければ省略可能。
     * @param text クライアントから送信されたテキスト
     * @param client 接続情報
     */
    @OnMessage
    public void onMessage(String text, Session client) 
            throws IOException {
        for(Session other : client.getOpenSessions()) {
            other.getBasicRemote().sendText(text);
        }
    }
    /**
     * 6.バイナリ受信時の処理
     * 
     * 送信元に画像を変換して送り返す。
     * 
     * 引数は使用しなければ省略可能。
     * @param buf クライアントから送信されたバイナリ
     * @param client 接続情報
     */
    @OnMessage
    public void onMessage(ByteBuffer buf, Session client) 
        throws IOException {
        client.getBasicRemote().sendBinary(grayScall(buf));
    }
    
    /** 7.画像をグレースケールに変換する。本筋とは関係ない。 */
    private ByteBuffer grayScall(ByteBuffer input) 
        throws IOException {
    
        BufferedImage img = ImageIO.read(
        new ByteArrayInputStream(input.array()));
        BufferedImage glay = new BufferedImage(img.getWidth(), 
        img.getHeight(), BufferedImage.TYPE_BYTE_GRAY);
        glay.getGraphics().drawImage(img, 0, 0, null);
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ImageIO.write(glay, "png", bos);
        return ByteBuffer.wrap(bos.toByteArray());
        
    }
}

1.では@ServerEndpointアノテーションを指定して、このクラスが WebSocket のサーバー側のエンドポイントであることを示している。アノテーションの値は URL の一部となるため、この場合はws://[サーバーURL]:[サーバーポート]/[コンテキストルート/websocket_simpleで接続できることになる。

基本的に WebSocket のサーバーを公開する方法は、アノテーションを指定するのみで、設定ファイルなどは不要だ。

2.から6.は、クライアントから何らかのアクションがあったときに実行されるコールバックメソッドである。コールバックの指定はアノテーションで行うため、メソッド名の制約はない。指定可能な引数はアノテーションの種類ごとに異なる。引数が不要なら、指定する必要はない。

言語は異なるものの、クライアントとサーバーで相互にメッセージのやり取りを行うため、クライアント側と実装する内容は似ている。

2.は、クライアントから接続が行われたときに実行されるメソッドで、@OnOpenアノテーションを指定して示している。 引数にはjavax.websocket.SessionEndpointConfigを指定可能だ。 Javadoc コメントにも書いたように、引数はメソッド内で使用しないのなら省略可能だ。 今回はサンプルのため明示した。

Sessionはサーバに接続したクライアントごとに作成される接続情報を示すオブジェクトで、メッセージ送受信において中心的な役割を果たす。 クライアントにメッセージを送信する場合は、Sessionを使用して行う。

また、このサーバー側のエンドポイントクラスのインスタンスも、クライアントの接続ごとに1つのインスタンスが作成される。 よって、クライアントごとの情報を格納するためにこのクラス内にフィールドを作成して、各メソッドからフィールドを変更したりしてもかまわない。 たとえば、クライアントからメッセージを受け取った回数をカウントしたり、初回アクセス時にクライアントの名称を保存したりといったような用途が考えられる。

3.は、クライアントからの切断時に呼ばれるメソッドで、@OnCloseアノテーションで示している。 切断後は、このクライアントとの通信は行えなくなる。

4.は、何らかのエラー時に実行されるメソッドで、@OnErrorアノテーションで示している。

5.と6.はクライアントからのメッセージ受信時に呼ばれるメソッド@OnMessageアノテーションで示している。

仕様により、@OnMessageアノテーションを設定可能なメソッドは、テキスト受信とバイナリ受信でそれぞれ1つずつと決まっている。(厳密にはもう一つ、疎通確認用の PingPong メッセージという種類もあるが、割愛する。)

両者に共通で定義可能な引数はSessionである。

クライアントが送信したデータも引数に指定できるが、そのデータの型は以下のようになる。

  • テキストの場合

    • String
    • プリミティブ型(受信メッセージがプリミティブとして変換可能な場合)
    • java.io.Reader
    • 任意のオブジェクト(文字列を任意のオブジェクトに変換するデコーダーを使用する場合。次回解説する)
  • バイナリの場合

    • byte[]
    • java.nio.ByteBuffer
    • java.io.OutputStream
    • 任意のオブジェクト(デコーダーを使用する場合。)

WebSocket の実行環境が、クライアントから受け取ったデータと、@OnMessageが定義されたメソッドシグネチャを見て、適切な変換を行ってメソッドを実行する。

5.ではテキストデータをStringとして受け取り、現在接続中の全てのクライアントに対して、同じメッセージを送っている。

現在接続中のクライアントは、Session#getOpenSessionsで取得可能なので、それを使って全てのクライアントにメッセージを送信できる。

メッセージの送信方法は、Sessoin#getBasicRemoteまたは、Session#getAsyncRemoteのいずれかの方法で相手側のエンドポイント(ここではクライアント)オブジェクトを取得し、そこに定義されている送信用メソッドを呼び出す。

getBasicRemoteの場合、メッセージデータの送信を全て行うまで処理はブロックされる。getAsyncRemoteの場合、メッセージデータの送信を開始したら、終了を待たず次の処理を開始する。 多数のクライアントに巨大なデータを送信する場合は、後者を使用するとよいだろう。

送信メソッドには、以下のものがある。

  • sendText - テキストデータを送信する。
  • sendBinary - バイナリデータを送信する。
  • sendObject - オブジェクトをテキストデータかバイナリデータに変換して送信する。オブジェクトをデータ変換するためには、エンコーダーと呼ばれるオブジェクトを使用する。エンコーダーについては次回解説する。

6.では、バイナリデータを受信して、送信元のクライアントのみに画像変換を行って送り返している。

画像変換の内容は7.のとおりで、画像をグレースケールに変換している。 バイナリをそのまま送り返すと味気ないと思って適当に追加した処理なので、詳細は触れない。

上記が基本的なサーバーエンドポイントの実装である。

まとめ

基本的な WebSocket の実装例を示した。

次回は、送受信メッセージを型変換して扱う仕組みであるデコーダー・エンコーダーについて紹介する。

[前多 賢太郎]