もっとJavaEE6っぽくcometチャットを実装する

もっとJavaEE6っぽくやってみよう

昨日のエントリでは、AsyncContextの使いかたを試すため、サーブレットだけを使って実装してみました。
でも、すこし泥臭いコードも多くなっていたし、このまま実用的なコードにしていくときにゴテゴテとコードを継ぎ足していくというのもイヤな感じです。
そこで、もっとJavaEE6っぽいコードに書き換えてみましょう。

少し準備

今回は、JAX-RSでのRESTful Webサービスと、CDIでのインジェクションを使ってみます。

JAX-RSの準備

まずは、JAX-RSを使うための設定クラスを作成します。

package chat2;

@javax.ws.rs.ApplicationPath("rs")
public class ApplicationConfig extends javax.ws.rs.core.Application {
}

こういうクラスがどこかにあればOKです。ApplicationPathアノテーションで、JAX-RSのルートディレクトリを指定しておきます。ちなみにNetBeansJAX-RSアノテーションを使おうとしたときに自動的にこのクラスを作成してくれます。便利*1

CDIの準備

つぎにCDIを使う準備です。これは簡単で、WEB-INFフォルダにbeans.xmlという名前で空のファイルを作成するだけです。
これもNetBeansだと自動的に作ってくれます。

いつものSetCharacterEncodingFilter

とりあえずこれ必要。ただ、非同期サーブレットにもフィルタをかける場合は、サーブレットの場合と同様にasyncSupported=trueの指定が必要です。

package chat2;

import java.io.IOException;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;

@WebFilter(filterName = "SetCharacterEncodingFilter", asyncSupported=true, urlPatterns = {"/*"})
public class SetCharacterEncodingFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain)
            throws IOException, ServletException {
        request.setCharacterEncoding("utf-8");
        chain.doFilter(request, response);
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }

    @Override
    public void destroy() {
    }
}

AsyncContextを管理するクラス

まずは持続接続のAsyncContextを管理するクラスを作りましょう。といっても、ここではAsyncContextのListをもつだけのクラスですけど。

package chat2;

import java.util.ArrayList;
import java.util.List;
import javax.inject.Named;
import javax.inject.Singleton;
import javax.servlet.AsyncContext;

@Named
@Singleton
public class ContextHolder {
    public List<AsyncContext> contexts = new ArrayList<AsyncContext>();
}

ここで、@Namedアノテーションによってインジェクション可能なようにしておきます。また@Singletonアノテーションをつけて、インスタンスがひとつしか生成されないように指定します。

持続接続を受け付けるサーブレット

さて、AsyncContextを管理するクラスができたので、それを使ったサーブレットを書いてみます。前回も出ていたクラス・インタフェースに関しては、import文を省略します。

package chat2;

import javax.inject.Inject;
(他のimportは省略)

@WebServlet(name = "PollingServlet2", urlPatterns = {"/polling2"}, asyncSupported=true)
public class PollingServlet2 extends HttpServlet {
    @Inject
    private ContextHolder contextHolder;

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        AsyncContext ac = request.startAsync();
        synchronized (contextHolder){
            contextHolder.contexts.add(ac);
        }
    }
}

ここで、AsyncContextを管理するContextHolderクラスのフィールドを用意して、ここに@Injectアノテーションをつけています。こうすると、CDIが勝手にContextHolderのオブジェクトをつっこんでくれます。しかも、ContextHolderクラスには@Singletonアノテーションをつけていたので、常に同じオブジェクトがつっこまれることになります。
そしたら、実際の処理のコードは、AsyncContextを取ってきてContextHolderに追加するという単純なものになりました。

Pushするデータを管理するクラス

さて、前回は入力文字列だけをPushしていたので単にprintlnしただけでしたが、やはり実際はもっと複雑なデータをPushすることになると思います。そういうとき、やはりPush用のデータを管理するクラスが欲しくなります。
今回は、名前とメッセージを送り返すことにして、それらをまとめるためのクラスを作ってみます。

package chat2;

import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;

@XmlRootElement(name="response")
@XmlAccessorType(XmlAccessType.FIELD)
public class Response {
    @XmlElement
    String name = "nobody";
    
    @XmlElement
    String message = "none";
}

クラスとしては、nameとmessageの2つのフィールドを持つだけなんですが、あとでXMLとして送信したいので、JAXBのアノテーションXMLの構造を指定しています。@XmlRootElementアノテーションでルートタグの名前を指定し、@XmlElementアノテーションでひとつのエレメントになることを指定しています。

投稿を受け付ける

さて、では投稿を受け付けるようにしてみます。今回は、名前とメッセージを受け付けるようにします。
で、通常ならここではサーブレットを使うのですが、はっきりいってサーブレットはめんどくさいです。そこでJAX-RSを使います。特に、Ajax通信用の処理のようにHTMLではないただのデータを返すような場合には、JAX-RSが非常に便利です。

package chat2;

import javax.annotation.ManagedBean;
import javax.inject.Inject;
import javax.servlet.AsyncContext;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;

@Path("/push2")
@ManagedBean
public class PushService {
    @Inject
    private ContextHolder contextHolder;
    
    @GET
    @Produces("text/plain")
    public String push(
            @QueryParam("name") String name, 
            @QueryParam("message") String message)
    {
        //返答用のオブジェクトを作成
        Response pushResponse = new Response();
        pushResponse.name = name;
        pushResponse.message = message;
        
        synchronized (contextHolder){
            //待機中のコンテキストに送信
            for(AsyncContext ac : contextHolder.contexts){
                try{
                    ac.getRequest().setAttribute("res", pushResponse);
                    ac.dispatch("/rs/response");
                }catch(Exception e){
                    e.printStackTrace();
                }
            }
            //待機コンテキストを消去
            contextHolder.contexts.clear();
        }
        
        //結果レスポンスを返す
        return "push ok!";
    }
}

ここで、@Pathアノテーションでパスを指定して、このクラスが"/push2"というパスでのリクエストを処理することを指定しています。
また、CDIによるインジェクションを有効にするために@ManagedBeanアノテーションをつけています。JAX-RSJSFのクラスで必要になります。ここではContextHolderオブジェクトをインジェクションしています。
内容としては、pushメソッドを定義しているわけですが、ここに@GETアノテーションをつけて、HTTP GETリクエストを処理するメソッドであることを指定しています。また、@Producesアノテーションで、このメソッドの処理結果をtext/plainとして返すことを指定します。
引数には@QueryParamアノテーションをつけています。こうすることで、リクエスト時のクエリーパラメータが自動的に引数に設定されるようになります。フォームからの入力値を受けとる場合は@FormParamアノテーションになります。
@PathParamアノテーションを使って、URLパスの一部をパラメータとして割り当てることもできます。
受けとったパラメータは、先ほど作成したResponseクラスのオブジェクトに割り当てて、各接続に送信しています。ここでは、dispatchメソッドを使って別のクラスに処理を移譲しています。
そのときResponseオブジェクトはrequest属性として渡していますが、これはちょっとイケてない。どうにかならないものか。

JAX-RSのメリット

@QueryParamアノテーションは、記述量としてはHttpServletRequestオブジェクトからgetParameterメソッドで値を取ってくる場合とほとんど差はありません。決定的に違うのは、入力パラメータの受け取りがただの引数になるので、JavaEE6コンテナの外でもそのまま動かせるということです。処理後のレスポンスも、単に戻り値を返しているだけです。HTTPリクエストパラメータの受け取り、レスポンスの送信が単なる引数・戻り値になることで、テストなどがかなりやりやすくなると思います。
今回は、「持続リクエストに対して返答を送る」というように処理自体がWebにからんだものなので、このクラスをJavaEEコンテナ外で動かすのは単純ではありませんが、多くの場合は、JAX-RSのクラスはJavaEEとは独立して動かしやすくなるはずです。

持続接続にXMLデータを送信する

さて、先ほどのJAX-RSクラスでは、持続接続への送信を移譲したので、ここでは移譲先のクラスを作ります。今回は、Responseオブジェクトの内容をXMLにして送信するJAX-RS サービスを作成します。

package chat2;

import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;

@Path("/response")
public class PushResponseService {
    @GET
    @Produces("application/xml")
    public Response pushResponse(@Context HttpServletRequest request){
        Response response = (Response) request.getAttribute("res");
        return response;
    }
}

@Pathアノテーションでパスを指定して、@GETアノテーションをつけたメソッドで@ProducesアノテーションXMLを返すことを指定して、引数で@ContextアノテーションをつけてHttpServletRequestオブジェクトを受けとるようにして、そこからResponseオブジェクトを取り出して、戻り値として返すだけ!
そしたらあとは、JAX-RSとJAXBがこのResponseオブジェクトをXMLに展開して送信してくれます。
まあ、こんだけってことは、これを不要にもできるということなんですけど。JAXBContextを簡潔に取得できれば、そのまま送信したほうがいいかも。
ただ、AsyncContextのdispatchメソッドは非同期で送信前に制御をもどすので、今回のように複数のAsyncContextにデータ送信する場合には、dispatchしたほうがいいかも。

試してみる

ということで、「/polling2」を待ち受けさせておいて、別ブラウザで「/rs/push2?name=aa&message=bb」にアクセスすると次のようにXMLが送信されてきました。

さてチャット画面をつくります。

こんな感じ。

<!DOCTYPE html>
<html>
    <head>
        <title>チャット</title>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <script type="text/javascript" src="resources/jquery-1.4.4.min.js"></script>
        <script type="text/javascript">
            $(function(){
                connect();
                $("#btn").click(button)
            });
            /* 接続 */
            function connect(){
                $.ajax({
                    url:"./polling2", 
                    type:"GET",
                    data:{}, 
                    complete: fin,
                    success:pushed
                });
                
            }
            /* 投稿 */
            function pushed(data, status, hxr){
                var d = $(data);
                var name = d.find("name").text();
                var message = d.find("message").text();

                var ntag = $("<span>").text(name);
                ntag.css("color", "blue")
                var stag = $("<span>").text(":");
                stag.css("color", "glay");
                var mtag = $("<span>").text(message);
                var t = $("<div>")
                    .append(ntag)
                    .append(stag)
                    .append(mtag);
                $("#result").append(t);
            }
            /* 接続終了の再接続 */
            function fin(hxr, status){
                connect();
            }
            /* ボタンが押されたときの処理 */
            function button(){
                $.get("./rs/push2", {name: $("#nm").val(), message: $("#msg").val()});
                $("#msg").val("");
            }
        </script>
    </head>
    <body>
        <h1>comet chat</h1>
        名前:<input type="text" id="nm"/><br/>
        メッセージ:<input type="text" id="msg"/><button id="btn">入力</button>
        <div id="result"></div>
    </body>
</html>


チャットができました。

接続が切れたときの処理

ところで、今回は、ブラウザが終了したときなど接続が不意に切れたときの処理を書いていないので、例外をはいたりします。
接続が切れたときはAsyncContextのリストから削除するようにしましょう。AsyncContextにリスナーを登録します。

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        final AsyncContext ac = request.startAsync();
        synchronized (contextHolder){
            contextHolder.contexts.add(ac);
        }
        
        ac.addListener(new AsyncListener() {
            private void remove(){
                contextHolder.contexts.remove(ac);                
            }
            @Override
            public void onComplete(AsyncEvent event) throws IOException {
            }

            @Override
            public void onTimeout(AsyncEvent event) throws IOException {
                remove();
            }

            @Override
            public void onError(AsyncEvent event) throws IOException {
                remove();
            }

            @Override
            public void onStartAsync(AsyncEvent event) throws IOException {
            }
        });
    }

AsyncContextの変数acはfinalにしておきます。
これで、案外安定した動きになるんじゃないかと思います。

まとめ

JavaEE6では、処理は必要なものだけで、オブジェクトをどのように扱うかとかデータをどう取得してどう送信するかというのはアノテーションで指定するという形になっています。
扱うデータや処理が増えた場合も、データや処理が増えた分だけコードを書いてアノテーションを書けばいいということになります。
今までは、データや処理を増やすと、それを登録するコードだとか、結びつけるコードだとか、オブジェクトを準備するコードだとか、それに付随するコードがたくさん必要になってたのですけど、だいぶやりたいことだけ書けばいいようになっています。
まあ、アノテーションだらけで本来の処理がどこにあるのか、わけがわかんなくなったりしますけど、まあJavaらしいといえばJavaらしい。本質的ではない処理で本質的な処理がうずもれるよりは、かなりいいかなーと思います。

AsyncContextのリクエストスコープに注意

最後に、入力を受け取るリクエストスコープと、AsyncContextの処理でのリクエストスコープが別のものであることには注意が必要です。
最初、PushServiceとPushResponseServiceでのResponseオブジェクトの共有をリクエストスコープでインジェクションすればええやんとか思ってはまってしまったのでした。まあ、ここでオブジェクトを共有するためのスコープあればいいなーとか思うんですけども。

*1:実は、勝手にApplicationPathの内容を書き換えてしまうことがあってハマることがあるので手放しで喜べません