GWTでWebSocketsを使う、その2

2018年11月21日

GWTでWebSocketsを使うの続きである。

WebSocketsの概要

各論に行く前にWebSocketsの概要をごく簡単に述べる。

これはクライアント(ブラウザ)側からサーバに対して、wsプロコトルを使用して要求をすると(例えば、ws://localhost:8080/somthing)、クライアント・サーバ間の双方向のチャネルが開かれるというものだ。

WebSockets以前では、サーバからクライアントへのデータ送信はできなかった。httpプロトコルは、常にサーバへのリクエストを行い、サーバがレスポンスを返すだけという仕様だからだ。もしサーバ側が何らかの事態をクライアント側に伝えたいとしても、それを行うにはクライアントが定期的にポーリングしていなければならない(あるいはロング・ポーリングという手法があるそうだが)。サーバ側は、その「事態」を保存しておき、次のクライアント要求があったときに返すということになる。

ウェブソケットの場合には、いったん接続がなされれば、それは双方向なので、いつでもサーバからクライアントへのデータ送信ができるというわけだ。

ただしもちろん、ブラウザとサーバが両方共にサポートしていなければならない。おそらく現在の最新のブラウザであればサポートされているだろうし、サーブレットエンジンとしてはJettyはサポートしている。

残念なことに、ブラウザ側はAPIが規約で決められているのだが、サーバ側はサーブレットのようにAPIが規定されていないようだ。それどころか、JettyもバージョンによってAPIが異なるようである。

また、ウェブソケット自体にも問題はある。基本的に、一つのクライアントホストからいくつでも接続をオープンできてしまうので、サーバ側の負担は非常に大きいと言われる。不特定多数を相手にするウェブアプリでは使わない方が良いかもしれない。

Jettyを用意する

まずJettyを用意する。gwt-devのJettyを変更する方法で書いたように、GWT開発環境には既に特定のバージョンのJettyが組み込まれているので、これを使用する。デプロイ時には同じバージョンのJettyを入れてやればよい。

GWT2.8.0以降2.8.2までは、jetty-9.2.14.v20151106が組み込まれているので、このバージョンを使うことにする。

サーブレットのセットアップ

Jetty-9.*のサンプルを漁ると、単一のサーブレットで単一のウェブソケットURLを扱う例が多い。つまり、以下のようにアノテーションでこのサーブレットのコンテキストパスを指定するものである。

@WebServlet(urlPatterns="/test")
public class TestServlet extends WebSocketServlet {
  @Override
  public void configure(WebSocketServletFactory factory) {
    factory.register(WebSocketService.class);
  }
}

このサーブレットは、/testというコンテキストパスに要求されたウェブソケット接続について、WebSocketServiceというオブジェクトを作成し、それにウェブソケットサービスを行わせるものなのだが、この方式だと、必要となるウェブソケットURLについて複数のサーブレットを作成しなければならない。

ここでは、一つのサーブレットで複数の異なるウェブソケット接続を提供することにする。例えば、以下のようにGuiceサーブレットを使い、/ws/以下の複数のコンテキストパスについて、それぞれのウェブソケットを提供する。

import com.google.inject.servlet.*;
public class DispatchServletModule extends ServletModule {
  @Override
  public void configureServlets() {
    serve("/ws/*").with(WebSocketServletImpl.class);
  }  
}

これを行うサーブレットは以下になる。WebSocketServletFactoryに単一のウェブソケットサービスクラスをregisterするのではなく、クリエータを指定してしまう。すると、ウェブソケットサービスが必要な都度それがコンテキストパスと共に呼び出される。

そして、コンテキストパスの種類に応じて適切なウェブソケットサービスクラスを作成して返す。

@Singleton
public class WebSocketServletImpl extends WebSocketServlet {  
  @Override
  public void configure(WebSocketServletFactory factory) {    
    WebSocketPolicy policy = factory.getPolicy();
    policy.setIdleTimeout(Long.MAX_VALUE);
    factory.setCreator((req, res)->createWebSocket(req, res));
  }
  private Object createWebSocket(ServletUpgradeRequest req, ServletUpgradeResponse resp) {   
    String requestPath = req.getRequestPath();
    String service = requestPath.substring(requestPath.lastIndexOf('/') + 1);    
    if (service.equals("foo")) return new FooService();
    if (service.equals("bar")) return new BarService();
    throw new WebSocketException("service " + service + " not supported");
  }
}

※setIdleTimeoutは最大限にしている。デフォルトでは5分経過すると切れてしまう。

ウェブソケットサービスクラス

ウェブソケットサービスクラスの構成は、非常に不適切と感じる。なぜなら、これは何らかのインターフェースを実装することではなく、クラス及びメソッドにアノテーションすることによってそれを指示するからだ。

これがために、先にあげたcreateWebSocketメソッドの返り値はObjecty型になる。どのような型でも良いのである。

例を上げる。

@WebSocket
public class WebSocketHelpService {
  @OnWebSocketConnect
  public void onConnect(Session session) throws IOException {
  }
  @OnWebSocketMessage
  public void onText(Session session, String message) throws IOException {
  }
  @OnWebSocketClose
  public void onClose(Session session, int status, String reason) {
  }
}

クライアント側が接続してきたときに、このオブジェクトが作成されるのだが、それと同時にonConnectが呼び出される。もちろん、アノテーションで指示されるので、メソッド名は何でもよいが、しかし引数の型は一致しなければならない。

クライアント側がテキストメッセージを送ってきた場合は、onTextが呼び出される。この他にバイナリメッセージもありうるのだが、その場合は引数をbyte[]等にすることによってそのメッセージを受け取ることができる。もちろん、両方に対応してもよい。

そして、クライアント側がウェブソケットを閉じた場合はonCloseが呼び出されることになる。これもまた、引数の型と順序が想定されるものと一致しなければならない。

このように、サービスクラスの定義方法は、非常に不適切なやり方であり、なぜこんな設計にしたのか理解に苦しむのだが、そうなってしまっているので仕方がない。

参考