2015年4月22日水曜日

WebSocket クライアントライブラリ (Java SE 1.5+, Android)

WebSocket クライアントライブラリ (Java) nv-websocket-client の説明です。JavaDoc はこちら

最終更新日: 2016 年 5 月 11 日


特長

  1. RFC 6455 (The WebSocket Protocol) に準拠している。
  2. Java SE 1.5+, Android で動く。
  3. 全てのフレームタイプ (continuation/binary/text/close/ping/pong) をサポートしている。
  4. 分割されたフレームを送信するメソッドを提供している。
  5. WebSocket が使用しているソケットを取得して設定できる。
  6. Basic 認証のためのメソッドを提供している。
  7. javax.net.SocketFactory インターフェースを利用するファクトリークラスを提供している。
  8. WebSocket イベントをフックするためのリッチなリスナーインターフェースを提供している。
  9. エラー発生時にきめ細かい制御ができるよう、きめ細かいエラーコードを定義している。
  10. フレームの RSV1/RSV2/RSV3 ビット及びオペコードの検証を無効化することができる。
  11. HTTP プロキシーをサポートしている。特に、セキュアプロキシー経由 (https) のセキュア WebSocket (wss) をサポート。
  12. RFC 7692 (Compression Extensions for WebSocket) をサポート (permessage-deflate として知られているもの)。デフォルトでは有効になっていません。


説明

WebSocketFactory 作成

WebSocketFactoryWebSocket インスタンスを作成するためのファクトリークラスです。最初のステップは WebSocketFactory インスタンスの作成です。

// WebSocketFactory のインスタンスを作成する。
WebSocketFactory factory = new WebSocketFactory();

デフォルトでは、WebSocketFactory はセキュアでない WebSocket 接続 (ws:) には SocketFactory.getDefault() を、セキュアな WebSocket 接続 (wss:) には SSLSocketFactory.getDefault() を使用します。このデフォルト動作は、WebSocketFactory.setSocketFactory メソッド、WebSocketFactory.setSSLSocketFactory メソッド、WebSocketFactory.setSSLContext メソッドで変更することができます。なお、デフォルトの SSL 設定を用いる場合は setSSL* メソッドを呼ぶ必要はまったくありません。また、setSSLContext メソッドを呼んだあとに setSSLSocketFactory メソッドを呼んでも意味はないので注意してください。詳細は WebSocketFactory.createSocket(URI) メソッドの説明を参照してください。

次の例は WebSocketFactory インスタンスにカスタムの SSL コンテキストを設定する例です。(もう一度言います。デフォルトの SSL 設定を用いる場合は setSSL* メソッドを呼ぶ必要はありません。)

// カスタムの SSL コンテキストを作成する。
SSLContext context = NaiveSSLContext.getInstance("TLS");

// カスタムの SSL コンテキストをセットする。
factory.setSSLContext(context);

上記の例で使用している NaiveSSLContext は、検証無しで全ての証明書を受け付ける SSLContext を作成するファクトリークラスです。テスト用としてはこれで十分です。 テスト中に "unable to find valid certificate path to requested target" というエラーメッセージをみたときは NaiveSSLContext を試してみてください。


HTTP プロキシー

WebSocket エンドポイントに HTTP プロキシー経由でアクセスする必要があるときは、WebSocket インスタンスを生成する前に、プロキシーサーバーに関する情報を WebSocketFactory インスタンスに設定しなければなりません。プロキシー設定は ProxySettings クラスによってあらわされます。WebSocketFactory インスタンスには、それに紐づいた ProxySettings インスタンスがあり、そのインスタンスは WebSocketFactory.getProxySettings() メソッドで取得できます。

// ProxySettings インスタンスを取得する。
ProxySettings settings = factory.getProxySettings();

ProxySettings クラスには setHost メソッドや setPort メソッドといった、 プロキシーサーバーの情報を設定するためのメソッドがあります。 次の例ではセキュア (https) プロキシーサーバーを設定しています。

// プロキシーサーバーを設定する。
settings.setServer("https://proxy.example.com");

プロキシーサーバーで認証が要求される場合は、setId メソッドと setPassword メソッド、もしくは setCredentials メソッドで認証情報を設定できます。ただし、現在の実装でサポートしているのは Basic 認証のみです。

// プロキシーサーバー用の認証情報を設定する。
settings.setCredentials(id, password);


WebSocket 作成

WebSocket は、WebSocket クラスで表現されます。WebSocket クラスのインスタンスは、WebSocketFactory インスタンスの createSocket メソッドのいずれかを使って作成します。WebSocket インスタンスを作成する最も簡単な例は次のようになります。

// WebSocket を作成する。スキーム部は ws, wss, http, https の
// いずれか (大文字小文字関係無し)。ユーザー情報部は、もしあれば、
// 期待した通りに解釈される。ソケットの生成に失敗した場合、または
// HTTP プロキシ―ハンドシェイクや SSL ハンドシェイクが失敗した場合、
// IOException が投げられる。
WebSocket ws = new WebSocketFactory()
    .createSocket("ws://localhost/endpoint");

ソケット接続のタイムアウト値を設定する方法が二つあります。一つは WebSocketFactorysetConnectionTimeout(int timeout) メソッドを呼ぶ方法です。

// WebSocketFactory を作成し、ソケット接続のタイムアウト値として
// 5000 ミリ秒を設定します。
WebSocketFactory factory
    = new WebSocketFactory().setConnectionTimeout(5000);

// WebSocket を作成します。上でセットしたタイムアウト値が使用されます。
WebSocket ws = factory.createSocket("ws://localhost/endpoint");

もう一つは、createSocket メソッドにタイムアウト値を渡す方法です。

// WebSocketFactory を作成します。タイムアウト値は 0 のままです。
WebSocketFactory factory = new WebSocketFactory();

// ソケット接続のタイムアウト値付きで WebSocket を作成します。
WebSocket ws = factory.createSocket("ws://localhost/endpoint", 5000);

タイムアウト値は、java.net.Socketconnect(SocketAddress, int) メソッドに渡されます。


リスナー登録

WebSocket インスタンス作成後、WebSocket イベントを受け取るため、WebSocketListener を登録します。WebSocketAdapterWebSocketListener インターフェースの空実装です。

// WebSocket イベントを受け取るため、リスナーを登録する。
ws.addListener(new WebSocketAdapter() {
    @Override
    public void onTextMessage(WebSocket websocket, String message)
        throws Exception {
        // テキスト・メッセージを受信
        ......
    }
});


WebSocket 設定

サーバーとのオープニング・ハンドシェイク (opening handshake) を実行する前に、次のメソッド群を用いて WebSocket インスタンスを設定することができます。


メソッド 説明
addProtocol Sec-WebSocket-Procotol に要素を追加する。
addExtension Sec-WebSocket-Extensions に要素を追加する。
addHeader 任意の HTTP ヘッダーを追加する。
setUserInfo Basic 認証用の Authorization ヘッダを追加する。
getSocket 設定するため、Socket インスタンスを取得する。
setExtended フレームの RSV1/RSV2/RSV3 ビット及びオペコードの検証をおこなわない。
setFrameQueueSize 輻輳制御のためにフレームキューのサイズを設定する。
setMaxPayloadSize ペイロードサイズの上限を設定する。


バージョン 1.17 で permessage-deflate 拡張 (RFC 7692) がサポートされました。この拡張を有効にするには、"permessage-deflate" を引数にして addExtension メソッドを呼びます。

// permessage-deflate 拡張 (RFC 7692) を有効にする。
ws.addExtension(WebSocketExtension.PERMESSAGE_DEFLATE);

permessage-deflate サポートは新しく、テストが必要です。フィードバックは歓迎します。


サーバー接続

connect() メソッドを呼ぶと、サーバーとの接続が確立され、オープニング・ハンドシェイクが同期で実行されます。ハンドシェイク中にエラーが発生したときは、WebSocketException が投げられます。一方、ハンドシェイクが成功したときは、WebSocket フレームの読み書きを非同期でおこなうためのスレッド群が生成されます。

try
{
    // サーバーに接続し、オープニング・ハンドシェイクを実行する。
    // このメソッドはハンドシェイクが終了するまでブロックする。
    ws.connect();
}
catch (OpeningHandshakeException e)
{
    // オープニング・ハンドシェイク中に WebSocket プロトコルに
    // 対する仕様違反が検出された。
}
catch (WebSocketException e)
{
    // WebSocket 接続の確立に失敗
}


connect() メソッドが WebSocketException のサブクラスである OpeningHandshakeException を投げる場合があります (バージョン 1.19 以降)。OpeningHandshakeException には、サーバーからのレスポンスにアクセスするための getStatusLine()getHeaders()getBody() というメソッド群が追加されています。 次のコードは、OpeningHandshakeException が保持する情報を表示する例です。

catch (OpeningHandshakeException e)
{
    // ステータス・ライン
    StatusLine sl = e.getStatusLine();
    System.out.println("=== ステータス・ライン ===");
    System.out.format("HTTP バージョン    = %s\n", sl.getHttpVersion());
    System.out.format("ステータス・コード = %d\n", sl.getStatusCode());
    System.out.format("理由               = %s\n", sl.getReasonPhrase());

    // HTTP headers.
    Map<String, List<String>> headers = e.getHeaders();
    System.out.println("=== HTTP ヘッダー ===");
    for (Map.Entry<String, List<String>> entry : headers.entrySet())
    {
        // ヘッダー名
        String name = entry.getKey();

        // ヘッダーの値のリスト
        List<String> values = entry.getValue();

        if (values == null || values.size() == 0)
        {
            // ヘッダー名のみ表示する。
            System.out.println(name);
            continue;
        }

        for (String value : values)
        {
            // ヘッダー名と値を表示する。
            System.out.format("%s: %s\n", name, value);
        }
    }
}


非同期サーバー接続

connect() メソッドを非同期で呼ぶ最も簡単な方法は connectAsynchronously() メソッドを使う方法です。このメソッドの実装は、スレッドを生成し、そのスレッドの中で connect() メソッドを呼びます。 connect() が失敗したときは WebSocketListeneronConnectError() メソッドが呼ばれます。 onConnectError() が呼ばれるのは、 connectAsynchronously() が使用され、バックグラウンド・スレッド内で実行された connect() が失敗したときのみです。同期版の connect() を直接呼んだときや、 connect(ExecutorService) (後述)を呼んだときは onConnectError() は呼ばれないので注意してください。

// サーバーに非同期で接続する。
ws.connectAsynchronously();

connect() メソッドを非同期で実行するもう一つの方法は connect(ExecutorService) メソッドを使う方法です。このメソッドは、渡された ExecutorService を使ってオープニング・ハンドシェイクを非同期で実行します。

// ExecutorService を用意する。
ExecutorService es = Executors.newSingleThreadExecutor();

// サーバーに非同期で接続する。
Future<WebSocket> future = ws.connect(es);

try
{
    // オープニング・ハンドシェイクが完了するのを待つ。
    future.get();
}
catch (ExecutionException e)
{
    if (e.getCause() instanceof WebSocketException)
    {
        ......
    }
}

connect(ExecutorService) メソッドの実装は、 connectable() メソッド呼んで Callable<WebSocket> インスタンスを作成し、そのインスタンスを ExecutorServicesubmit(Callable) メソッドに渡します。 Callable インスタンスの call() メソッドがやっているのは、同期バージョンの connect() を呼ぶことだけです。


フレーム送信

sendFrame メソッドで WebSocket フレームを送信することができます。sendText などの他の sendXxx メソッドは sendFrame メソッドのエイリアスです。全ての sendXxx メソッドは非同期で動作します。ただし、ある条件下では、sendXxx メソッドがブロックすることがあります。詳細は「輻輳制御」を参照してください。

sendXxx メソッドの例を幾つか示します。なお、普通は sendClose メソッドや sendPong メソッドを明示的に呼ぶ必要はありません。これらのメソッドは適切なときに自動的に呼ばれるからです。

// テキストフレームを送信する。
ws.sendText("Hello.");

// バイナリフレームを送信する。
byte[] binary = ......;
ws.sendBinary(binary);

// PING フレームを送信する。
ws.sendPing("Are you there?");

分割されたフレームを送信したい場合は、仕様 (5.4. Fragmentation) を詳細に理解しておかなければなりません。下記は、3 つのフレームで構成されるテキストメッセージ ("How are you?") を送信する例です。

// 一番目のフレームはテキストフレームかバイナリフレームのどちらか。
// FIN ビットはクリアされていなければならない。
WebSocketFrame firstFrame = WebSocketFrame
    .createTextFrame("How ")
    .setFin(false);

// 後に続くフレームは継続フレーム。最後のフレームを除き、全ての継続
// フレームの FIN ビットはクリアされていなければならない。
// WebSocketFrame.createContinuationFrame() メソッドが返すフレームの
// FIN ビットはクリアされているので、下記の例では FIN ビットをクリア
// する処理を省略している。
WebSocketFrame secondFrame = WebSocketFrame
    .createContinuationFrame("are ");

// 最後のフレームは FIN ビットがセットされた継続フレーム。
// WebSocketFrame.createContinuationFrame() メソッドが返すフレームの
// FIN ビットはクリアされているので、下記の例では FIN ビットを明示的に
// 設定している。
WebSocketFrame lastFrame = WebSocketFrame
    .createContinuationFrame("you?")
    .setFin(true);

// 3 つのフレームで構成されるテキストメッセージを送信する。
ws.sendFrame(firstFrame)
  .sendFrame(secondFrame)
  .sendFrame(lastFrame);

なお、上記と同じことを次のように別の方法で実行することもできます。

// 3 つのフレームで構成されるテキストメッセージを送信する。
ws.sendText("How ", false)
  .sendContinuation("are ")
  .sendContinuation("you?", true);


Ping/Pong フレーム定期送信

Ping フレーム間のインターバル(ミリ秒単位)を setPingInterval メソッドに渡すことで、Ping フレームの定期送信を実行することができます。このメソッドは connect() メソッドの前後どちらでも呼ぶことができます。ゼロを渡すと定期送信は停止します。

// 60 秒に一回 Ping を送信する。
ws.setPingInterval(60 * 1000);

// 定期送信をやめる。
ws.setPingInterval(0);

同様に、setPongInterval メソッドを呼べば Pong フレームを定期送信することができます。「要求されていないときでも Pong フレームを送信してかまいません。」 (RFC 6455, 5.5.3. Pong)

自動送信する Ping/Pong フレームのペイロードは、setPingPayloadGenerator() メソッドと setPongPayloadGenerator() メソッドでカスタマイズすることができます。どちらのメソッドも PayloadGenerator インターフェースのインスタンスを引数に取ります。次のコードは、現在時刻の文字列表現を Ping フレームのペイロードに設定する例です。

ws.setPingPayloadGenerator(new PayloadGenerator() {
    @Override
    public byte[] generate() {
        // 現在時刻の文字列表現
        return new Date().toString().getBytes();
    }
});

Ping フレーム等の制御フレームの最大ペイロード長は 125 なので注意してください。このため、generate() メソッドが返すバイト配列の長さは 125 を超えてはいけません。


自動フラッシュ

デフォルトでは、sendFrame メソッド実行後すぐ、フレームは自動的にサーバーへとフラッシュされます。 setAutoFlush(false) を呼べば、この自動フラッシュを無効にすることができます。

// 自動フラッシュを無効にする。
ws.setAutoFlush(false);

手動でフラッシュをおこなうときは、flush() メソッドを呼びます。このメソッドは非同期です。

// 手動でフレームをサーバーへとフラッシュする。
ws.flush();


輻輳制御

sendXxx メソッドは WebSocketFrame のインスタンスを内部キューに挿入します。デフォルトでは、キューのサイズに上限は課されていないため、sendXxx メソッド群はブロックしません。しかし、WebSocket サーバーが処理しきれないほどの数の WebSocket フレームを WebSocket クライアントアプリケーションが短時間に送ると、この動作は問題を引き起こすかもしれません。このような場合、キューイングされているフレームの数が多いときには sendXxx メソッドがブロックしてほしいと思うかもしれません。

setFrameQueueSize(int) メソッドを呼ぶことで、内部キューに上限を設定することができます。結果として、sendXxx メソッドが呼んだ時にキュー内のフレームの数が上限に達していた場合、キューに空きができるまで sendXxx メソッドがブロックするようになります。次のコードは、内部フレームキューの上限として 5 を設定する例です。

// フレームキューのサイズに 5 を設定する。
ws.setFrameQueueSize(5);

ある条件下では、キューがいっぱいであっても sendXxx メソッドはブロックしません。例えば、フレーム送信をおこなうスレッド (WritingThread) が停止しようとしている場合、または既に停止している場合です。また、コントロールフレームを送信するメソッド (sendClose()sendPing() など) はブロックしません。


ペイロードサイズ最大値

正数値を指定して setMaxPayloadSize(int) メソッドを呼ぶことにより、WebSocket フレームのペイロードサイズに上限を設定することができます。 設定したペイロードサイズ最大値を超えるテキストフレーム、バイナリフレーム、継続フレームは、複数のフレームに分割されます。

// ペイロードサイズの上限として 1024 を設定する。
ws.setMaxPayloadSize(1024);

仕様により、制御フレーム (Close フレーム、Ping フレーム、Pong フレーム) は、分割されることはありません。

permessage-deflate 拡張が有効になっていて、圧縮後のペイロードサイズがペイロード最大値を超えない場合、圧縮前のペイロードサイズがペイロード最大値を超えていたとしても、当該 WebSocket フレームは分割されません。


WebSocket 切断

WebSocket が閉じられる前には、クロージング・ハンドシェイク (closing handshake) が実行されます。クロージング・ハンドシェイクは、(1) サーバーがクライアントにクローズフレーム (close frame) を送るか、(2) クライアントがサーバーにクローズフレームを送るか、のどちらかにより開始されます。disconnect() メソッドを呼ぶことにより (または手作業でクローズフレームを送ることにより) クロージング・ハンドシェイクを開始することができます。

// WebSocket 接続を閉じる。
ws.disconnect();

disconnect() メソッドには幾つかバリアントがあります。クライアントがサーバーに送信するクローズフレームのクローズコードと切断理由を変更したい場合は、disconnect(int, String) などのバリアントを使用してください。 disconnect() メソッド自体は disconnect(WebSocketCloseCode.NORMAL, null) のエイリアスです。


再接続

WebSocket.connect() メソッドは、成功したか失敗したかにかかわらず、多くても一回しか呼ぶことができません。 WebSocket エンドポイントに再接続したいときは、WebSocketFactorycreateSocket メソッドで再度新しい WebSocket インスタンスを作成する必要があります。

元のインスタンスと同じ設定を持つ WebSocket インスタンスを作るのであれば、recreate() メソッドが便利でしょう。 ただし、元のインスタンスの低レベルソケットに加えた変更はコピーされないので注意してください。

// 新しい WebSocket インスタンスを作成して同じエンドポイントに接続する。
ws = ws.recreate().connect();

ソケット接続のタイムアウト値を引数に取る recreate(int timeout) というメソッドもあります。既存の WebSocket インスタンスを作成したときに使用したものとは異なるタイムアウト値を指定したい場合は、recreate(int timeout) を使ってください。


エラー処理

WebSocketListener には、onFrameError() メソッドや onSendError() メソッドなどの onXxxError() メソッドが幾つかあります。その中でも、onError() メソッドは特別です。このメソッドは、他の onXxxError() メソッドが呼ばれる前に常に呼ばれます。例えば、ReadingThreadrun() メソッドの実装では、Throwable がキャッチされ、onError() メソッドと onUnexpectedError() メソッドがこの順番で呼ばれます。下記はその実装です。

@Override
public void run()
{
    try
    {
        main();
    }
    catch (Throwable t)
    {
        // An uncaught throwable was detected in the reading thread.
        WebSocketException cause = new WebSocketException(
            WebSocketError.UNEXPECTED_ERROR_IN_READING_THREAD,
            "An uncaught throwable was detected in the reading thread", t);

        // Notify the listeners.
        ListenerManager manager = mWebSocket.getListenerManager();
        manager.callOnError(cause);
        manager.callOnUnexpectedError(cause);
    }
}

ですので、onError() メソッドの中で全てのエラーケースを扱うことができます。

全ての onXxxError() メソッドは第二引数に WebSocketException のインスタンスを受け取ります (第一引数は WebSocket のインスタンスです)。この例外クラスには、列挙型 WebSocketError のエントリーを返す getError() というメソッドがあります。列挙型 WebSocketError のエントリー群は、このライブラリの実装内で起こりうるエラーの原因をリストしたものです。エラー原因の粒度が細かいので、エラーが発生したとき、根本原因を見つけるのは容易です。

onXxx() コールバックメソッドが投げた ThrowableWebSocketListenerhandleCallbackError() メソッドに渡されます。

@Override
public void handleCallbackError(WebSocket websocket, Throwable cause)
    throws Exception {
    // onXxx() コールバックメソッドが投げた Throwable はここに来ます。
}


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

このアプリケーションは、websocket.org 上の Echo サーバー (ws://echo.websocket.org) に接続し、exit と入力されるまで、(1) 標準入力から一行読み込み、(2) 読み込んだ行をサーバーに送り、(3) サーバーからの応答を出力する、という処理を繰り返します。ソースコードは Gist からダウンロードできます。




p.s.

実装の動機

オープンソースの WebSocket クライアントライブラリ (Java) は、検索すると幾つか見つかりますが、(1) Android で動かない、もしくは動かすためにはハックが必要、(2) 依存関係を含めるとサイズが大きい、(3) 品質が悪い、というような状況だったので、自作することにしました。

仕様について

WebSocket Java API (javax.websocket) では、無理ではありませんが、Basic 認証を実装しにくいです。

WebSocket JavaScript API は、オープニング・ハンドシェイク時に任意の HTTP ヘッダーを付ける方法を提供していないので、エンドポイントの URL に含まれる userInfo 部を実装 (ブラウザ) が期待通りに処理してくれない限り、Basic 認証はできません。heroku dev center の WebSocket Security という記事Authentication/authorization というセクションにも言及があります。

また、WebSocket JavaScript API では、明示的に接続を開始するためのメソッドがないので、以下推測ですが、「WebSocket インスタンス生成」が接続開始のトリガーにならざるを得ません。しかし、onopen, onmessage, onerror などのコールバックメソッドを登録できるのはインスタンスを生成したあとです。そのため、これらのコールバックメソッドが機能するためには、実際の接続は、インスタンス生成時に同期でやるわけにはいかず、インスタンス生成時には「接続のスケジューリング」をするだけ、という実装になります。しかし、こうなってくると、接続の失敗をちゃんと捕捉できるかどうか、怪しくなってきます。