Jettyで始めるWebSocket超入門

第3回サーバ側の実装(前編)

今回と次回を通して、WebSocketを使ったチャットアプリケーションのサーバ側の実装を解説します。

WebSocketプロトコル

サーバ側の実装を行なう前に、WebSocket APIを使うにあたり知っておいたほうが良いと思われるWebSocketプロトコルの仕様について簡単に説明します。

リビジョンについて

WebSocketはプロトコルもAPIもまだ策定中の仕様であり、リビジョンの違いにより問題が起こる可能性があります。最近の例では、リビジョン75と76では互換性がありません。両方のリビジョンに対応するため、片方の仕様で接続し失敗した時に他方で接続し直すサーバや、起動時にオプションでどちらを使用するのかを変更するサーバ等の実装があります。仕様策定中は特に、クライアントや中継サーバ等がどのリビジョンに対応しているかを考慮に入れ、サーバのライブラリのバージョン変更を行なう必要があります。

Jettyの対応状況については情報がほとんどありませんが、コード[1]を確認すると接続要求のヘッダを見てリビジョン75と76で処理を切り換えている部分があります。しかし、リビジョン76で導入されたセキュリティ対策用の処理がなく、完全ではないようです。

中継機の対応

プロキシサーバやゲートウェイがWebSocketプロトコルに対応していない可能性があります。以前のドラフト版はHTTPとの親和性が高く、プロキシサーバやゲートウェイに何もしなくても通過しやすく、特にwssを使用しWebSocketプロトコル本体を隠蔽すればさほど問題になりませんでした。しかし、リビジョン76から接続確立の方法が変更になったため、プロキシサーバ等の対応が必要になる可能性が高くなりました。

接続の確立

連載第1回目でも説明しましたが、WebSocketプロトコルは接続の確立にHTTPを使用します。通常のHTTP接続であれば、要求→応答で接続が切断されますが、WebSocketではHTTP接続時に「Upgrade」というヘッダに「WebSocket」を指定し、プロトコルを切り換えることを通知します。プロトコルが切り替わると、HTTPのようには直ぐには切断せずに、接続状態を保ったまま双方向のデータ送信が可能になります。

ポート番号

ドラフト版のリビジョン35までは81番と815番となっていましたが、現行のリビジョンではHTTPと同様に「ws://」では80番を「wss://」では443番を使用することになっています。

バイナリ送信

WebSocketプロトコルはバイナリの送信にも対応する方向で策定が進んでいます。詳しくは「データ送信の詳細」の項で説明します。

WebSocketの詳細について

WebSocketプロトコルについて詳細を知りたい場合、IETFやWHATWGのサイトをご覧ください。IETFで公開されているドラフト版のWebSocketプロトコルは、当初はdraft-hixie-thewebsocketprotocolの名前で公開されていましたが、リビジョン76が公開された後に名称がdraft-ietf-hybi-thewebsocketprotocolに変更され、リビジョン番号もリセットされ00となったようです。今後はリビジョン番号だけではどのドラフトを指しているのか特定できず、混乱する可能性もあるため注意が必要です。また、WHATWGでは最新のリビジョンよりもさらに新しいドラフト版が公開されています。

作成するクラス

それでは、サーバ側の実装を行ないます。作成するクラスは次の3つです。

WebSocketChat
HTTPサーバとWebSocketサーバを設定します。HTTPサーバはJettyが準備しているクラスを使用し、WebSocketサーバには下記のMyWebSocketServletを使用します。
MyWebSocketServlet
JettyのWebSocketServletクラスを継承したクラスです。WebSocketの接続が発生した際に、下記のMyWebSocketをインスタンス化します。
MyWebSocket
JettyのWebSocketインターフェイスを実装したクラスです。WebSocketの接続別にインスタンス化されます。

実装はこの逆の順番で行ないます。

MyWebSocket

それでは新規クラス作成します。⁠ファイル⁠⁠→⁠新規⁠⁠→⁠その他...」を選択してダイアログを表示し、⁠Java⁠⁠→⁠クラス」を選択し「次へ」をクリックします。

図1 新規→その他から、Javaのクラスを選択
図1 新規→その他から、Javaのクラスを選択

次のように入力します。

  • ソース・フォルダー:WebSocketChat/src/main/java
  • パッケージ:webSocketChat
  • 名前:MyWebSocket
  • インターフェイス:org.eclipse.jetty.websocket.WebSocket

これ以外は既定のままで構いません。

図2 新規Javaクラスを作成
図2 新規Javaクラスを作成

「完了」をクリックすると、次のようなコードが生成されます。

package webSocketChat;

import org.eclipse.jetty.websocket.WebSocket;

public class MyWebSocket implements WebSocket {

  @Override
  public void onConnect(Outbound outbound) {
    // TODO Auto-generated method stub

  }

  @Override
  public void onDisconnect() {
    // TODO Auto-generated method stub

  }

  @Override
  public void onMessage(byte frame, String data) {
    // TODO Auto-generated method stub

  }

  @Override
  public void onMessage(byte frame, byte[] data, int offset, int length) {
    // TODO Auto-generated method stub

  }

}

「onConnect」メソッド及び「onDisconnect」メソッドは、それぞれWebSocketの接続/切断時に呼び出され、オーバーロードされている「onMessage」メソッドは接続先からメッセージを受け取った時に呼び出されます。⁠onConnect」の引数の型「Outbound」はインターフェイスで、実体は「org.eclipse.jetty.websocket.WebSocketConnection」です[2]⁠。⁠WebSocketConnection」はWebSocketの接続先を表すクラスです。

チャットアプリケーションでは、受け取ったメッセージをすべての接続先に送信する必要があるため、⁠MyWebSocket」のインスタンスから全接続先が参照できなくてはいけません。

図3 受信したデータは全ての接続先に送信
図3 受信したデータは全ての接続先に送信

そこで、⁠onConnect」の引数と、すべての接続先を保持します。接続時に自分自身をクラス変数に追加し、切断時に保持されている接続先から自分自身を削除します。

接続・切断処理とメッセージ送信処理を実装したコードは、次のようになります。

package webSocketChat;

import java.io.IOException;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;

import org.eclipse.jetty.websocket.WebSocket;

public class MyWebSocket implements WebSocket {

  private Outbound outbound_;
  private static Set<MyWebSocket> members_ = new CopyOnWriteArraySet<MyWebSocket>();

  @Override
  public void onConnect(Outbound outbound) {
    outbound_ = outbound;
    members_.add(this);
  }

  @Override
  public void onDisconnect() {
    members_.remove(this);
  }

  @Override
  public void onMessage(byte frame, String data) {
    for(MyWebSocket member : members_) {
      try {
        member.outbound_.sendMessage(frame, data);
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }

  @Override
  public void onMessage(byte frame, byte[] data, int offset, int length) {
    for(MyWebSocket member : members_) {
      try {
        member.outbound_.sendMessage(frame, data, offset, length);
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }

}

members_はメッセージ送信のたびに参照されるため、⁠Set」クラスではなく、参照系に有利な「CopyOnWriteArraySet」クラスを使用しています。オーバーロードされている「onMessage」メソッドは、サーバがデータを受信した時に呼ばれます。逆に送信を行なうには「Outbound」インターフェイスの「sendMessage」メソッドを呼び出します。⁠sendMessage」メソッドもオーバーロードされており、⁠onMessage」メソッドと同じ引数をとるメソッドが定義されています。上記のコードでは、for文を使ってすべての接続先の「sendMessage」を呼び出しています。

「frame」という引数については「データ送信の詳細」の項で説明しますが、ここではそのまま「sendMessage」に渡している点を理解していただければ結構です。

データ送信の詳細

JavaScriptから利用することになるWebSocket APIでは、現時点ではバイナリの送信についての記述はありませんが、WebSocketプロトコルのドラフト版には、テキスト送信と共にバイナリ送信について定められています。

仕様では、データのひとかたまりを「フレーム」と呼んでおり、⁠テキストフレーム」「バイナリフレーム」に大別されます。このふたつは、フレームの先頭にある1バイトの「フレームタイプ」で区別されます。フレームタイプが「0x00~0x7F」の場合はテキストフレーム、⁠0x80~0xFF」の場合はバイナリフレームになります。

テキストフレームは、フレームタイプのバイトの後にテキストデータが続き、末端を示す「0xFF」※3まで続きます。つまり、テキストフレームの長さは、送信するテキストの長さよりも2バイト長いだけになります。これが可能なのは、テキストデータが「0x00~0xFE」の範囲内で完結し、末端を示す「0xFF」を含まないためです。

また、バイナリデータは「0x00~0xFF」すべてを使用する可能性があるため、バイナリフレームはいわゆる「TLV(type-length-value⁠⁠」の形式になっています。つまり、フレームタイプのバイトの後に、バイナリの「データ長を表すバイト[4]⁠」が続き、そのうしろにバイナリデータが続きます。

現状では、フレームタイプが「0x00」のテキストフレームがUTF-8の文字列、⁠0xFF」のバイナリフレームがWebSocket接続を明示的に切断するためのクロージングフレームに割り当てられています[5]⁠。フレームタイプの「0x01~0xFE」は今後の定義のための領域です。つまり、現在のWebSocketプロトコルの仕様では、送信できるデータはテキストのみとなります。

上記のようにWebSocketは、必要最低限の情報のみ送受信されるため、オーバーヘッドが非常に小さいのが特徴となっています。

「MyWebSocket」クラスの「onMessage」メソッドの引数である「frame」は、フレームタイプそのものです。受け取った「frame」をそのまま「sendMessage」に渡していたのは、受信したデータを何もいじらずに送信するためです。

Jettyでは、⁠名前が非常にわかりにくいですが)フレームタイプ用の定数として、テキストフレーム用の「WebSocket.SENTINEL_FRAME(=0x00)」とバイナリフレーム用の「WebSocket.LENGTH_FRAME(=0x80)」が定義されています。

図4 WebSocketのフレームタイプ
図4 WebSocketのフレームタイプ

次回予告

次回は、残りのクラスの「WebSocketChat」「MyWebSocketServlet」の実装を解説します。

おすすめ記事

記事・ニュース一覧