-IPageRecorderのすべて

ページオブジェクトのプロパティを、abstractにして、<property-specification>エレメント定義をするとき、<property-specification>エレメントのpersistent属性値で「yes」とすると、プロパティ値がIPageRecorderを利用して短期永続化されることを紹介しました。今回はこのIPageRecorderについて解説します。
Javassistによって実装されるpersistent="yes"なプロパティは、イメージとして以下のような実装が施されます(あくまでイメージ。実際はTapestry#fireObservedChange()メソッドに代わり、AbstractComponent#fireObservedChange()というdeplecatedなメソッドが実装されたりしている)。

package sample.simple;
public abstract class Home$Enhance_0 extends Home {
  private int _$count;
  public int getCount() {
    return _$count;
  }
  public void setCount(int count) {
    _$count = count;
    Tapestry.fireObservedChange(this, "count", count);
  }
}

このTapestry#fireObservedChange()は、コンポーネント(ページ)が結びついたエンジンからIEngine#getPageRecorder()を通してIPageRecorderオブジェクトという短期永続化ユーティリティを呼び出し、プロパティの値を保存します。
IPageRecorderは、エンジンがファクトリとして働き、インスタンスを返します。デフォルトでは、HTTPセッションに情報を永続化する、SessionPageRecorderを利用します。Tapestryが標準で用意しているIPageRecorderの実装はこのSessionPageRecorderのみです。IPageRecorderは、<property-specification>エレメントによってJavassistが実装する場合だけでなく、手動で用いることも可能です。以下にシンプルな手動の例を示します。

package sample.pagerecorder;
import org.apache.tapestry.IRequestCycle;
import org.apache.tapestry.Tapestry;
import org.apache.tapestry.html.BasePage;
public class Home extends BasePage  {
  private int count = 1;
  public int getCount() {
    return count;
  }
  public  void setCount(int count) {
    this.count = count;
    Tapestry.fireObservedChange(this, "count", count);
  }
  protected void initialize() {
    count = 1;
  }
  public void click(IRequestCycle cycle) {
    int i = getCount() + 1;
    setCount(i);
  }
}

ページは繰り返し言及してきたように、プール管理される共有オブジェクトですから、ページ内にメンバーをおく場合には、inithialize()メソッドをオーバーライドしメンバー値の破棄を行わなければなりません。ページに状態を持たせたい場合、1)レンダリング前に値を初期化し、レンダリングを行った後、2)値を永続化します。この1)レンダリング前に値を初期化する方法は、IPageRecorderを用いた場合にはTapestryで自動的に行ってくれます。

コラム:ページの値の初期化

ページの初期化は、いくつか方法があります。手動でPageRenderListener#pageBeginRender()内部で行うほか、自動的に解決してくれる方法として、<property-specification>エレメントが行うPropertyInithializerユーティリティの機能、IPageRecorderの初期化機能、そしてForm等を用いる場合のrewind処理があります。rewind処理はFormの動きを解説するときに触れることとします。

2)の永続化は、プロパティSetterメソッド内で、Tapestry.fireObservedChange()メソッドを呼び出すことで行われます。引数型によるバリエーションがありますが、「永続化するコンポーネント」「プロパティ名」「新しい値」の順で引数を与えます。永続化方法はIPageRecorderの実装クラス依存です。SessionRecorderでは、HTTPセッションに、アトリビュートに、「サーブレット名」/「ページ名」/「コンポーネント名」/「プロパティ名」というようなスラッシュ区切りの名前で値を登録しています。SessionRecorderは説明のとおりHTTPセッションを使うので、Visitオブジェクトを利用するのと結果として大きくは変わりませんが、VisitオブジェクトをOGNL式経由で用いるとオブジェクトの型を失うので実装が堅くないと思われる向きには、プロパティのSetter内に実装を行うIPageRecorderの利用は有利でしょう。また、前回のように<property-specification>エレメントを用いる方法はとても強力です。
さて、IPageRecorderのJavaDocには、実装のバリエーションの可能性としてRDBMSを用いることや、Cookieを使うことが示唆的に記述されています。ここでは、Cookieを用いて行ったIPageRecorderの実装を示したいと思います。

package sample.pagerecorder;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import javax.servlet.http.Cookie;
import org.apache.tapestry.ApplicationRuntimeException;
import org.apache.tapestry.IRequestCycle;
import org.apache.tapestry.record.ChangeKey;
import org.apache.tapestry.record.PageChange;
import org.apache.tapestry.record.PageRecorder;
import org.apache.tapestry.request.RequestContext;
import org.apache.tapestry.util.StringSplitter;
import org.apache.tapestry.util.io.DataSqueezer;
public class CookiePageRecorder extends PageRecorder {
  private Map changes = new HashMap();
  private String attributePrefix;
  private DataSqueezer squeezer;
  private RequestContext context;
  public void commit() {
  }
  public void discard() {
    changes.clear();
  }
  public Collection getChanges() {
    readCookies();
    ArrayList result = new ArrayList(changes.size());
    Iterator i = changes.entrySet().iterator();
    while (i.hasNext()) {
      Map.Entry entry = (Map.Entry) i.next();
      ChangeKey key = (ChangeKey) entry.getKey();
      Object value = entry.getValue();
      PageChange change = new PageChange(
          key.getComponentPath(), key.getPropertyName(), value);
      result.add(change);
    }
    return result;
  }
  public boolean getHasChanges() {
    return (changes.size() > 0);
  }
  public void initialize(String pageName, IRequestCycle cycle) {
    squeezer = cycle.getEngine().getDataSqueezer();
    context = cycle.getRequestContext();
    attributePrefix = context.getServlet().getServletName() + "/" + pageName + "/";
    changes.clear();
    readCookies();
  }
  protected void recordChange(
      String componentPath, String propertyName, Object newValue) {
    ChangeKey key = new ChangeKey(componentPath, propertyName);
    changes.put(key, newValue);
    StringBuffer buffer = new StringBuffer(attributePrefix);
    if (componentPath != null) {
      buffer.append(componentPath);
      buffer.append('/');
    }
    buffer.append(propertyName);
    String attributeKey = buffer.toString();
    try {
      String valueString = squeezer.squeeze(newValue);
      Cookie cookie = new Cookie(attributeKey, valueString);
      cookie.setMaxAge(-1);
      context.getResponse().addCookie(cookie);
    } catch (IOException e) {
      throw new ApplicationRuntimeException(e);
    }
  }
  private void readCookies() {
    changes.clear();
    Cookie cookies = context.getRequest().getCookies();
    if(cookies != null) {
      StringSplitter splitter = new StringSplitter('/');
      for (int i = 0; i < cookies.length; i++) {
        String key = (String) cookies[i].getName();
        if (!key.startsWith(attributePrefix)) {
          continue;
        }
        changes = new HashMap();
        String names = splitter.splitToArray(key);
        int index = 2;
        String componentPath = (names.length == 4) ? names[index++] : null;
        String propertyName = names[index++];
        try {
          Object value = squeezer.unsqueeze(cookies[i].getValue());
          ChangeKey changeKey = new ChangeKey(componentPath, propertyName);
          changes.put(changeKey, value);
        } catch (IOException e) {
          throw new ApplicationRuntimeException(e);
        }
      }
    }
  }
}

IPageRecorderの実装には、抽象クラスであるPageRecorderを継承して作ります。IPageRecorderがエンジンに対して1対1なこと、エンジンがリクエストに対して1対1なことから、IPageRecorderはリクエストサイクル中ステートフルに実装することができます。ただし、リクエストサイクル終了後にはホストされるエンジンとともにプールに格納され、次のリクエストに備えることから値の処理が重要です。IPageRecorder#discard()メソッドはリクエストライフサイクルの最後に呼び出されるので、ここで値の破棄処理を確実に行います。例では状態を保持するMapをクリアしています。

package sample.pagerecorder;
import java.util.Map;
import org.apache.tapestry.IRequestCycle;
import org.apache.tapestry.engine.BaseEngine;
import org.apache.tapestry.engine.IPageRecorder;
public class CookieEngine extends BaseEngine {
  private Map recorders = new HashMap();
  public IPageRecorder getPageRecorder(String pageName, IRequestCycle cycle) {
    IPageRecorder recorder = (IPageRecorder)recorders.get(pageName);
    if (recorder == null) {
      recorder = (CookiePageRecorder)createPageRecorder(pageName, cycle);
      recorders.put(pageName, recorder);
    }
    recorder.initialize(pageName, cycle);
    return recorder;
  }
  public IPageRecorder createPageRecorder(String pageName, IRequestCycle cycle) {
    return new CookiePageRecorder();
  }
}

IPageRecorderは、IEngine#getPageRecorder()/createPagerecorder()で取得/生成されます。ここでの注意点は、IPageRecorderはページに対して汎用に作ることが可能ですが一つのリクエストサイクルにて複数ページを活性化する場合があるので、ページに対して一つづつインスタンスを用意しておく必要があります。
CookiePageRecorder#recordChange()の中で、Cookie#setMaxAge(-1)というようにしていることに注意してください。このプロパティにマイナス値を設定すると(Servlet2.3の仕様ではデフォルトで-1のようですが、コンテナに対する念のため設定しています)、クライアントマシンに保存されないセッションクッキーになります。このCookiePageRecorderは、HTTPセッションもクライアントサイドクッキーも用いていないのですが、ステートフルな作りになっています。
いつもの http://www.kjps.net/user/kurihara/ に今回のサンプルを置いてあります。「PageRecorder sample (include Cookie used IPageRecorder impl.) [2004/06/16]」です。CookiePageRecorderだけでなく、SessionPageRecorderでも、保存するプロパティ値はDataSqueezeを利用して文字列化しています。Cookieは長さに4KB弱と長さ制限があるので、DataSqueezeのアダプタを活用して文字列の長さを短くすることが必要となるでしょう。デバッグ用に、サンプルの中にはCookieの状態をウォッチするServletFilterをおまけでつけておきました。有効活用ください。