Servlet3.0でcometチャットを作ってみる

Cometとは?

ブラウザベースのチャットをつくろうとする場合、以前は定期的にクライアントからリクエストを送信して更新を確認するという手法がとられました。そうすると、平均して更新間隔の1/2の遅延が発生し、更新がないときの問い合わせが無駄になるなど、ユーザーにもサーバーにもうれしい手法ではありませんでした。
そこで使われるようになったのがCometです。
Cometは、HTTPでクライアントからの接続への返答を保留して、サーバーからデータを送信する必要がでたときに返答を返すことで、サーバーからのリアルタイムデータ送信を行う手法の総称です。

Servlet3.0でのComet対応

Cometでは、クライアントからの接続を保持しつづけるので、これまでのServletの仕組みをつかって実現しようとすると、各接続にスレッドを割り当てることになり、スレッド数が多くなりすぎるため、多くのユーザーには対応できませんでした。
そこで、サーバー側で専用の仕組みを持つ必要があるのですが、これまでTomcatGlassfish、Jettyなどで独自の仕様で対応されていました。
Servlet3.0では、Comet対応が標準化されたので、共通のコードで実現することができるようになりました。

とりあえず非同期サーブレット

ということで、接続を持続するサーブレットを書いてみます。

package chat;

import java.io.IOException;
import java.util.*;
import javax.servlet.AsyncContext;
import javax.servlet.AsyncEvent;
import javax.servlet.AsyncListener;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;

@WebServlet(name = "PollingServlet", urlPatterns = {"/polling"}, asyncSupported=true)
public class PollingServlet extends HttpServlet {
    public static final String CONTEXT_NAME = "contexts";

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        ServletContext servletContext = request.getServletContext();
        List<AsyncContext> contexts = (List<AsyncContext>)servletContext.getAttribute(CONTEXT_NAME);
        if(contexts == null){
            contexts = new ArrayList<AsyncContext> ();
            servletContext.setAttribute(CONTEXT_NAME, contexts);
        }
        
        final AsyncContext ac = request.startAsync();
        contexts.add(ac);
    }
}

WebServletアノテーションでasyncSupported=trueの指定をすると、非同期サーブレットになります。
HttpServletRequestオブジェクトのstartAsync()メソッドを呼び出すと、接続を管理するAsyncContextオブジェクトが得られるので、どこかに保存して、処理を終えます。
今回は、AsyncContextオブジェクトをservletContextに保持させています。
サーブレット自体の処理が終わっても、接続が引き継がれます。


なんらかのタイミングでこの接続に対してデータを送信すればいいのですが、今回は別のリクエストを受け取ったタイミングでデータを送信してみます。

package chat;

import java.io.*;
import java.util.*;
import javax.servlet.*;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;

@WebServlet(name = "PushServlet", urlPatterns = {"/push"})
public class PushServlet extends HttpServlet {

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("text/html;charset=UTF-8");
        PrintWriter out = response.getWriter();
        try {
            request.setCharacterEncoding("utf-8");
            String text = request.getParameter("text");
            ServletContext servletContext = request.getServletContext();
            List<AsyncContext> contexts = (List<AsyncContext>) servletContext.getAttribute(PollingServlet.CONTEXT_NAME);
            if(contexts != null){
                for(AsyncContext ac : contexts.iterator()){
                    try{
                        ac.getResponse().setCharacterEncoding("utf-8");
                        PrintWriter writer = ac.getResponse().getWriter();
                        writer.println(text);
                        writer.close();
                        ac.complete();
                    }catch(Exception e){
                        e.printStackTrace();
                    }
                }
                contexts.clear();
            }
            out.println("ok");
        } finally {            
            out.close();
        }
    }
}

これはサーブレット自体は通常のものですが、ここで保存していたAsyncContextに対してレスポンスを返しています。


このようにすると、/pollingを開くと接続が保持されたままになって、別のブラウザ画面で/push?text=hogeのようにパラメータを渡して開くと、/pollingを開いていた画面にパラメータの文字列が表示されます。

チャット画面の作成

あとは、これらを呼び出す画面を作ると、チャットっぽくなります。

<!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:"./polling", 
                    type:"GET",
                    data:{}, 
                    complete: fin,
                    success:pushed
                });
                
            }
            /* 投稿 */
            function pushed(data, status, hxr){
                $t = $("<div>").wrapInner(data);
                $("#result").append($t);
            }
            /* 接続終了の再接続 */
            function fin(hxr, status){
                connect();
            }
            /* ボタンが押されたときの処理 */
            function button(){
                $.post("./push", {text: $("#txt").val()});
                $("#txt").val("");
            }
        </script>
    </head>
    <body>
        <h1>comet chat</h1>
        <input type="text" id="txt"/><button id="btn">入力</button>
        <div id="result"></div>
    </body>
</html>

ここではjQueryを使ってAjax通信を行っています。初期化のときと接続が切れたときとconnect関数を呼び出して、常に接続が行われているようにしているのが、Cometです。


画面を2つ開くと、入力がリアルタイムで反映されることがわかります。

まとめ

かなり簡単にCometのサーバー処理がかけることがわかります。標準のServletコンテナだけで別サーバーを立てる必要なくCometサーバーが実現できるのも、ありがたいことです。
今回は排他制御などをやっていませんが、ちゃんとAsyncContextを管理するListに対しての操作ではsynchronizeなどを適切に書く必要があります。

余談

関係ないけど、このCometチャット作る作業の間、一度も明示的にコンパイルや配備の操作をしていません。保存したら勝手にコンパイルして配備されるので、リロードするだけで動作の確認ができていました。Javaだからコンパイルが必要だから配備も必要でWebアプリの開発は手間、というのは必ずしも成り立たないなーと実感。もちろんPHPとかのほんとに保存するだけで動作が反映できるダイレクトさには負けますが。