JSR 356―Java 標準の WebSocket API

この記事は「Java EE Advent Calendar 2013」、その16日目の記事です。前日は @making さんの「JAX-RSとJavaFXで作るThin Server Architectureのプロジェクト構成」でした。


Java EE 7の一部としてリリースされたWebSocket API仕様であるJSR 356は、これまでのサーバー/サーブレットコンテナ独自のWebSocket実装は大きく異なる高水準APIに仕上がっています。JAX-RSに類似したモダンなインタフェース、データ型を意識させないエンコード/デコード機能、そしてJava EEだけでなくJava SEでも利用できる幅広い適用範囲など、多くのプログラマーがわくわくするような仕掛けが用意されています。

WebSocket APIは標準化されたばかりの仕様で、これからの利用状況のフィードバックにより様々な改善がなされていくことでしょう。そこで今回は最低限押さえておかなければならない事項に絞って、JavaのWebSocket API、すなわちJSR 356についてお話ししようと思います。

1. WebSocketとは?

WebSocketはWebサーバーとクライアントの間で双方向通信を行うためのプロトコルです。主な用途は、従来RESTやLong Pollingで半ば強引に実現していたサーバーPush通信で、従来のやり方に比べてオーバーヘッドがはるかに小さいという特徴があります。WebSocketは当初HTML5の一部として仕様が検討され、数度に渡るドラフト改訂の末にRFC 6455という独立した規格として成立しました。

WebSocketは早期ドラフトの段階から注目を集め、JavaにおいてもいくつかのJava EEサーバーやサーブレットコンテナが独自にWebSocketを実装してきました。そして2011年12月に RFC 6455が発行され、Java EE 7でもHTML5とともにWebSocket APIの標準化を表明しました。JCPでもJSR 356としてWebSocket APIの標準化を進め、2013年6月のJava EE 7と同時にリファレンス実装であるTyrusをリリースしました。それから半年も経たないうちに、下記の主要なJava EEサーバーやサーブレットコンテナが(あるいはJava EE 7対応よりも前に)JSR 356準拠のWebSocket APIをサポートするに至っています。

  • GlassFish 4.0
  • WildFly 8
  • TmaxSoft JEUS 8
  • Apache Tomcat 8/7.0.47
  • Jetty 9.1
  • IBM WebSphere AS 8.5.5.Next Alpha

悲しいことに、JSR 356リリース後にも関わらず、標準規格をあざ笑うかのようにWebSocketの独自実装を行って、しかも「新規格にいち早く対応」と銘打った商用サーバーも、最近になって公式にJSR 356対応を表明しましたが、ここでは無視します。
(ローンチ・イベントの場で「次のリリースではJSR 356に対応させる」と言い訳するくらいなら、初めから独自実装なんてするべきではないのです)

2. WebSocketの仕組み

WebSocketでは、WebサーバーとクライアントのハンドシェイクにHTTP(またはHTTPS)が用いられますが、その後はプロトコルをWebSocketに切り替えてサーバー・クライアントが双方向かつリアルタイムにデータを送受信するようになります。

RESTに対してWebSocketが優れている点は、RESTがポーリングのたびにHTTPセッションを確立しているのに対して(頻度の高いポーリングでは、このオーバーヘッドは無視できないレベルに達します)、WebSocketは一旦プロトコルが切り替わった後はクライアント・サーバーのいずれかがクローズしない限りWebSocketのセッションが継続していることです。これがRESTよりWebSocketの方が軽量であると言われる所以でもあります。

メモリ消費量はRESTよりもWebSocketの方が結果的に大きくなる傾向にあるようです。WebSocketではメモリ消費を増大させるファクターが複数存在するため、メモリ解放のタイミングもRESTより難しいと考えられます。

Javaでは、WebSocketはServletコンテナのレベルで実現します。Servlet 3.0では実装の独自拡張としてWebSocketのAPIを提供していましたが、JSR 356ではServlet 3.1の上で動作する仕様になっています。JSR 356に対応するため、Servlet 3.1ではプロトコル切り替えのリクエストとレスポンス HTTP 101 をサポートしています。

3. JSR 356で始めるWebSocket

JSR 356は、JAX-RSに似たアノテーション・ベースのプログラミングをサポートします(アノテーションを用いないプログラミング・スタイルもサポートしますが、個人的にあまり好きではないので今回は割愛します)。主な構成要素としては次のようなものがあります。

  • クライアント・エンドポイント
  • サーバー・エンドポイント
  • セッション
  • エンコーダ(オプション)
  • デコーダ(オプション)

以下に概念図を示します。

jsr356-overview.png

3.1. クライアント・エンドポイント

WebSocketの開始リクエストを投げる側です。JavaScriptのWebSocket APIが主に想定されますが、JSR 356ではJava SE/EEでクライアント・エンドポイントを実装する方法も提供されます。

最初に、JavaScriptによるクライアント・エンドポイントの全体構造を以下に示します。

// サーバー・エンドポイントの URI
var uri = "ws://localhost:8080/wsapp/hello";

// WebSocket の初期化
var websocket = new WebSocket(uri);

// イベントハンドラの設定
websocket.onopen = function(event) {
  /* セッション確立時の処理 */
};
websocket.onmessage = function(event) {
  /* メッセージ受信時の処理 */
}
websocket.onerror = function(event) {
  /* エラー発生時の処理 */
}
websocket.onclose = function(event) {
  /* セッション解放時の処理 */
}

次にJava SE/EEによるクライアント・エンドポイントの全体構造を以下に示します。以下に示す以外にも追加で受け取ることが出来る要素(例えばOnMessageハンドラでSessionオブジェクトを受け取る、など)があります。

@ClientEndPoint
public HelloClient {
  @OnOpen
  public void onOpen(Session session) {
    /* セッション確立時の処理 */
  }
  
  @OnMessage
  public void onMessage(String message) {
    /* メッセージ受信時の処理 */
  }
  
  @OnError
  public void onError(Throwable t) {
    /* エラー発生時の処理 */
  }
  
  @OnClose
  public void onClose(Session session) {
    /* セッション解放時の処理 */
  }
}

上記のクライアント・エンドポイントは以下のようにJavaのコードに組み込むことで機能するようになります。

// 初期化のため WebSocket コンテナのオブジェクトを取得する
WebSocketContainer container = ContainerProvider.getWebSocketContainer();
// サーバー・エンドポイントの URI
URI uri = URI.create("ws://localhost:8080/wsapp/hello");
// サーバー・エンドポイントとのセッションを確立する
Session session = container.connectToServer(new HelloClient, uri);

3.2. サーバー・エンドポイント

クライアント・エンドポイントからの要求を受ける側です。JSR 356以前から、サーバーサイドJavaがその対象としてきたものです。JSR 356ではアノテーションを使用して簡単に実装することが出来ます。サーバー・エンドポイントの全体構造を以下に示しますが、クライアント・エンドポイントとほぼ同じです。

Webアプリケーションの場合はクライアント・エンドポイントをJavaScriptで記述するため、Java側ではサーバー・エンドポイントのみを実装することになります。なお、サーバー・エンドポイントではJAX-RSのリソース・クラス同様、CDIのInjection(つまり@Inject)が使用可能です。

JAX-RSとWebSocketでCDI連携の見かけはそっくりですが、Jersey 2.0の実装が壊滅的だったのに対し、Tyrusのそれは比較的安定して動作します。

@ServerEndPoint("/hello")
public HelloServer {
  @OnOpen
  public void onOpen(Session session) {
    /* セッション確立時の処理 */
  }
  
  @OnMessage
  public void onMessage(String message) {
    /* メッセージ受信時の処理 */
  }
  
  @OnError
  public void onError(Throwable t) {
    /* エラー発生時の処理 */
  }
  
  @OnClose
  public void onClose(Session session) {
    /* セッション解放時の処理 */
  }
}

3.3. セッション

WebSocketのセッションそのものを表します。セッションにデータを設定すると、WebSocket通信を介してエンドポイント(サーバーまたはクライアント)に送信され、エンドポイントのイベントハンドラが呼び出され、データが取得できる仕組みになっています。

送信側は、セッションから取得できる「リモート・エンドポイント」と呼ばれるオブジェクトのsetXXXというメソッドに値を渡すことで、データを送信できます。

// 送信側
// session は javax.websocket.Sessionのオブジェクト
session.getBasicRemote().sendText("Hello");

受信側は、エンドポイントのクラスにOnMessageハンドラを定義しておくと、データ受信時に呼び出され、引数に値が設定されます。

// 受信側
@OnMessage
public void onMessage(Session session, String text) {
  // text には "Hello" が設定されている
  System.out.printf("Receive: %s\n");
  // 受信側でもsession経由で返信ができる
  session.getRemoteBasic().sendText("Hi");
}

OnMessageハンドラの戻り値を使用すると、上記のコードはさらに簡単になります。

@OnMessage
public String onMessage(String text) {
  // text には "Hello" が設定されている
  System.out.printf("Receive: %s\n");
  // 戻り値がそのまま送信データになる (Session参照不要)
  return "Hi";
}

3.4. エンコーダ・デコーダ

WebSocketで扱えるデータは本来テキスト(JavaではString)かバイナリ(Javaではbyte[]またはInputStream/OutputStream)だけです。従来の独自実装のWebSocket APIでもこの制約に縛られていました(例えば古いTomcatのWebSocket APIではCharBufferとByteBufferを使用します)。JSR 356ではその制約を取り払う、あるギミックが仕込まれています。

JSR 356の後にリリースされた某サーバーの独自WebSocket API実装も、やはりこの制約から逃れられていません。だったら初めからそんなもの作るなと言いたいです(いや、今まで事あるごとに言ってきましたが...)。

JSR 356では任意のデータ型からテキストまたはバイナリに変換するエンコーダー、その逆を行うデコーダーを実装し、エンドポイントに適用することで、Javaのデータ型をそのまま送受信するイメージでプログラミングすることが可能です。エンコーダー・デコーダーにデータ型変換処理を集約できるとともに、積極的な再利用で開発の効率が向上するはずです。筆者は個人的にエンコーダ・デコーダの存在がJSR 356最大の魅力だと感じています。

以下にエンコーダとデコーダの実装例(一部)を示します。この例ではJavaBeanをJAXBでXMLに変換してWebSocketで送受信することになりますが、SOAPエンベロープ級の大規模XMLスキーマでもない限りJSONで表現できますので、実際にはJSONに変換した方が効率的です(サンプルがXMLなのは、手元にあったエンコーダ/デコーダがこれしかなかったから。単なる手抜きですごめんなさい)。

public class TweetEncoder implements Encoder.Text<Tweet> {
  ...
  @Ovrride
  public String encode(Tweet tweet) throws EncodeException {
    try {
      StringWriter writer = new StringWriter();
      JAXBContext context = JAXBComtext.newInstance(Tweet.class);
      Marshaller marshaller = context.createMarshaller();
      marshaller.marshal(tweet, writer);
      return writer.toString();
    } catch (JAXBException e) {
      throw new EncodeException(tweet, "Invalid object", e);
    }
  }
}
public class TweetDecoder implements Decoder.Text<Tweet> {
  ...
  @Override
  public Tweet decode(String s) throws DecodeException {
    try {
      JAXBContext context = JAXBContext.newInstance(Tweet.class);
      Unmarshaller unmarshaller = context.createUnmarshaller();
      return (Tweet) unmarshaller.unmarshal(new StringReader(s));
    } catch (JAXBException e) {
      throw new DecodeException(s, "Invaid format", e);
    }
  }
}

エンコーダとデコーダは、@ClientEndPointおよび@ServerEndPointに設定することで機能します。以下に例を示します。

@ServerEndPoint(value = "/hello",
  decoders = { TweetDecoder.class },
  encoders = { TweetEncoder.class })
public class HelloEndPoint {
  /* OnMessage ハンドラ以外は省略 */
  @OnMessage
  public void onMessage(Session session, Tweet tweet) {
    // Tweet クラスのオブジェクトとして受信可能
    System.out.println(tweet);
    
    // Tweet クラスのオブジェクトのまま送信可能
    Tweet anotherTweet = new Tweet();
    session.getRemoteBasic().sendObject(anotherTweet);
  }
}

エンコーダ・デコーダを設定すると、送受信データに任意のJavaデータ型を使用できるようになります。WebSocketプロトコルを巧妙に抽象化することにより、JAX-RS等と変わりない使い勝手を実現しています。

4. まとめ

WebSocketのJava標準APIであり、かつModernなスタイルのWebSocketフレームワークであるJSR 356について、ほんのさわりの部分をご紹介しました。そのリファレンス実装であるTyrusは、特定のサーバーに依存せず、Java SE環境だけでも利用可能な、使いやすいものに仕上がっています。冒頭にもお話したように、JSR 356対応のサーバーも出揃ってきています。

これからJavaでWebSocketプログラミングをするのなら、JSR 356で決まりです!

cvl_zuihoh_1944.png独自実装とかに戻しちゃ駄目だからね」


明日は @yumix_h です。担当日のswapは先方から申し出があったことなので、戦慄の展開を感じざるを得ません。