Google Apps Engine/Javaのセッション管理

Google Apps Engine(GAE)では、Java6.0の実行環境とServlet API 2.5に準ずるサーバー環境が提供されている。Servlet APIバージョンはスタブとして作成されるweb.xmlから見て取れる。

仕様の中で気になることの1つが「セッション管理」である。

Webアプリケーション・サーバーをスケールアウトした構成にする際に必ず出てくるのが、「セッション・レプリケーション」という技術。これは、複数のサーバー間で、セッションIDやセッションオブジェクトを、active-activeな関係でコピーし合うものである。これができないと、アプリケーションサーバーをスケールアウトし、完全な形でロードをバランスさせることができない。
(とはいえ、ロードバランシングの方法論にも色々あって、クライアントのIPアドレスでスティックするような方法を取れば、セッション・レプリケーションを行う必要はない。)

GAEはクラウドであるから、実行環境は「雲の中のどこか」。このような大規模なスケールアウトの環境では、セッション管理をいかに行うかには、相当なオリジナリティーが必要なのだろうと思う

GAEのガイドにあるセッション管理のくだりを読むと、

  • javax.servlet.http.HttpSessionインターフェイスによるセッション管理機能を実装して提供している(なぜか、Servlet API 2.2へのリンクが貼ってある)。
  • この実装では、App Engine のデータストアと memcache を使用してセッション データを保管する。
  • appengine-web.xmlに以下の記述をすることで、この実装を利用することができる。

このうち、2番目が肝なのかなと思う。要するに、セッションはデータストアか、(その前段の)メモリーキャッシュに永続化される(シリアライズされる)という意味。実際に、セッションスコープで管理されるオブジェクトは、java.io.Serializableをimplementsして、シリアライズ可能でなければならない

Googleでは、「HWは壊れるものだから、永続化されたデータをロストしないために、数回のレプリカの作成をしている」と聞く。このコンセプト(というか「リスク管理」)はGFS上で実装されているが、セッション管理もこの機能を前提に設計されていると考えられる。

ここでは、ローカル環境にあるTomcat6.0(Servlet API2.5の参照実装)と、クラウド上のGAEに、HttpSessionを利用する同じサーブレットをデプロイして、以下のことをテストしてみた。

<テスト内容>

  • HttpSessionインターフェイスから得られる結果の比較
  • HttpSessionインターフェイスを用いて、JavaBeanを格納し、その取り出し(復元)、更新、保存を行う。JavaBeanの状態が、同一セッションの中で「保存」されることの確認
  • web.xmlの設定(セッションのインターバルタイム)が反映されるか?

以下の作業は、GAEプラグインを導入したEclipse(3.4;Ganymede)で行った。

Web Application Projectの作成

Eclipseで、「New」->「Web Application Project」で、新規プロジェクト(GaeSessionTest)とGAEアプリのスタブを作成する。このとき、GWTは利用しないので、チェックをはずす。

GaeSessionTest.javaの改造

スタブとしてできたサーブレット(GaeSessionTest.java)を改造する。まず、その前に、SessionオブジェクトにシリアライズするJavaBean(TestBean.java)を作成しておく。メンバー変数は、全てシリアライズ可能であること(メンバー変数の実装クラスがjava.io.Serializableをimplementsしていること)に注意する。以下の例では、Map型の変数testMapには、java.util.HashMapを格納する。このAPIドキュメントをみれば、Serializableをimplしていることがわかる。
(注記)serialVersionUIDは、永続化されたJavaBeanのバージョンの整合をとるものであるが、これを変更してGAEに再デプロイをかけた際に、GAE上でエラーが発生してしまう事象がでた。アプリケーションを一度消すなり、変更しないなりの手続きを踏んだ方がいいかもしれない。

TestBean.java

package test;

import java.io.Serializable;
import java.util.Map;

public class TestBean implements Serializable{
    private static final long serialVersionUID = -9110143420906726458L;

    private Map<Integer,String> testMap;
    
    public TestBean() {
    }

    
    public Map<Integer,String> getTestMap() {
        return testMap;
    }

    public void setTestMap(Map<Integer,String> testMap) {
        this.testMap = testMap;
    }
}

GaeSessionTest.java
JSPすら使わないサーブレットで、上記の<テスト内容>を検証するものとなっている。
Sessionオブジェクトに格納するのは、アクセスカウンター(Integer)とTestBeanで、このサーブレットが呼び出された時に、以下のようなロジックで、Sessionオブジェクトへのデータの保存と更新、復元をテストする。

  • Sessionが新しければカウンターは1にし、HashMapインスタンスに5つの要素をputする。
  • Sessionが新しくなければ、Sessionオブジェクトからカウンターを取り出して+1し、TestBeanインスタンスを復元する。
  • HashMapインスタンスが空でなければ、1つ要素を減らした後にTestBeanを更新し、Sessionオブジェクトに保存する。

また、以下のHttpSessionから得られる情報を取得する。

  • HttpSessionオブジェクトのAPIにある、isNew()、getId()、getCreationTime()、getLastAccessedTime()、getMaxInactiveInterval()を実行する。
package test;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

import javax.servlet.http.*;

@SuppressWarnings("serial")
public class GaeSessionTestServlet extends HttpServlet {

    public void doGet(HttpServletRequest req, HttpServletResponse resp)
        throws IOException {
        
        HttpSession session = req.getSession(true);
        
        boolean ns = session.isNew();
        // 同一セッション内でのアクセスカウンターの保管、取り出し(復元)。
        Integer hit;
        if(ns){
            hit = new Integer(1);
        }else{
            hit = (Integer)session.getAttribute("hit.counter");
            hit = new Integer(hit.intValue()+1);
        }
        session.setAttribute("hit.counter", hit);
        
        // Serialize可能なJava Beanをセッションに保管、取り出し(復元)をする。
        TestBean tb;
        String flg;
        Map<Integer, String> mp;
        if(!ns){
            tb = (TestBean)session.getAttribute("testBean");
            // 呼び出されるごとに1つずつ減らしていく。
            mp = tb.getTestMap();
            Collection<String> cl = mp.values();
            int sz = cl.size();
            if(sz > 0){
                mp.remove(sz);
                tb.setTestMap(mp);
            }
            flg = "sessionにTestBeanあり。サイズ: "+(new Integer(sz)).toString();

        }else{
            mp = new HashMap<Integer,String>();
            mp.put(new Integer(1), "String1");
            mp.put(new Integer(2), "String2");
            mp.put(new Integer(3), "String3");
            mp.put(new Integer(4), "String4");
            mp.put(new Integer(5), "String5");
            tb = new TestBean();
            tb.setTestMap(mp);
            flg = "sessionにTestBeanなし";
        }
        session.setAttribute("testBean", tb);
        
        Date lat = new Date(session.getLastAccessedTime());
        Date ct = new Date(session.getCreationTime());
        
        // HttpSessionインターフェイスのその他の情報を取得する。
        resp.setContentType("text/html; charset=utf-8");
        PrintWriter out = resp.getWriter();
        
        out.println("<html><head>");
        out.println("<title>セッション情報</title>");
        out.println("</head>");
        out.println("<body>");
        out.println("<h2>セッション情報</h2>");
        out.println("セッションid = "+session.getId() + "<br>");
        out.println("生成  = "+
                        ct + "<br>");
        out.println("最終アクセス  = "+
                        lat + "<br>");
        out.println("インターバル(秒)  = "+
            session.getMaxInactiveInterval() + "<br>");
        out.println("セッション内でのアクセス数 = "+hit.intValue() + "<br>");
        
        // TestBeanの抽出状況
        out.println(flg+"<br>");
        
        Iterator<String> it = mp.values().iterator();
        String tmpStr;
        while(it.hasNext()){
            tmpStr = it.next();
            out.println("TestBean: "+tmpStr+"<br>");
        }
        
        out.println("</body></html>");
    }
}

web.xmlの変更

war/WEB-INF/web.xmlに以下を追加し、セッションのインターバル時間を設定する(GAEのデフォルトは1日になっていた)。これが反映されるかは、上のHttpSessionget.getMaxInactiveInterval()で確認できる。

    <session-config>
        <session-timeout>30</session-timeout>
    </session-config>

appengine-web.xmlの変更

war/WEB-INF/appengine-web.xmlに以下を追加し、セッション管理を有効にする。

    <sessions-enabled>true</sessions-enabled>

デプロイと実行

いつもの手順で、これをGAEにデプロイする。今回は比較のためにローカル環境のTomcat6.0にもデプロイしてみた。
以下が、GAEにデプロイしたものにアクセスした際の最初の画面。

同一セッションで、もう一度、アクセスした場合は次の画面になった。セッションオブジェクトに保管したオブジェクトの復元と更新、保管が、HttpSessionインターフェイスを使って(普通どおりに)行われることが分かる。

ただし、getCreationTime()は(クラウド上のどこかにある)サーバーのシステム時間を表し、getLastAccessedTime()は機能しないようだ。getMaxInactiveInterval()には、web.xmlの設定が反映されている。


以下は、Tomcatにデプロイした場合の最初の画面。

この場合には、(当たり前のことだが)getCreationTime()はローカルPCのシステム時間を表し、getLastAccessedTime()も機能している。セッションオブジェクトに保管したオブジェクトの復元と更新、保管については、GAEと同じ動きをすることが確認できた。

まとめ

HttpSessionインターフェイスAPIを使う限りにおいて、

  • getCreationTime()
  • getLastAccessedTime()

は有効な値を返さないが、他のメソッドについては「普通のサーブレットエンジンと同様の処理結果を戻す」ことが分かった。