ログ日記

作業ログと日記とメモ

S2JDBCからApache Cayenneに移行した

WicketSeasar(S2Container、S2Wicket、S2JDBC)で一通り開発したけれど、なんだか合わないなと感じた。

不満だったのは以下の点。

  • S2JDBC-Genで生成したサービスクラスが使いにくい。
  • S2ContainerとWicketの両方にオブジェクト生成機能がある
    • HOT Deployが使えない。
    • S2Containerの管理するパッケージ(service、entityなど)とReloadingWicketFilterが管理するパッケージ(page、component)を意識する必要がある。
    • WARM deployを使うのでS2Containerが管理するservice以下などを変更するとWebサーバーの再起動が必要。
  • 開発モードではリロード・戻るなどの動作でエラーになる。

元々はS2JDBCを使いたいからPHPからJavaに来たのだが、S2JDBCに不満が出てきた。
あるページではItemDetailテーブル+Itemテーブルからデータを取得し、別のページではItemDetailテーブル+Itemテーブル+ItemCategoryテーブルからデータを取得する必要があったとする。このときJoinはどこに書くかという問題。ItemDetailService#getItemDetailWithItemとItemDetailService#getItemDetailWithItemAndCategoryという二つのメソッドを作るか、それとも例え使わないとしても関連テーブルから全部データを持ってくるか。
そもそも、どの情報をビューに出したいのかはビューが決めることで、表示する関連項目が一つ増えたくらいでServiceを変更したくない。

jdbcManager.from(Employee.class)
    .where(
        eq(name(), name),
        gt(salary(), salary))
    .getResultList();
http://s2container.seasar.org/2.4/ja/s2jdbc_typesafe.html

この処理が異なる5ページから呼ばれているとして、例えば詳細検索画面から呼び出すときだけ条件を追加する場合、新着ページから呼び出す場合だけソートを変更する場合、などなど。別のメソッドを作ればいいのか引数をどんどん増やしていくのが良いのかイマイチ分からない。
ServiceのメソッドがAutoSelectを返すようにしたパターンもやってみたけど二度手間感がある。


もう一つ、データベースを変更する場合、個人的には「ER図を書く → SQLを生成 → DBに反映 → Javaコード生成」の順でやりたい。
これがS2JDBC-Genだと逆にJavaに基づいてデータベースを定義する流れになる。ER図から書きたい。


# ※1
# 今書いていて気付いたが、トランザクションが必要な更新処理だけServiceクラスに書いて、取得処理はWicketのPageやComponentからJdbcManagerを呼び出してServiceを使わないっていう方法もあるね。自動生成されたServiceクラスは一切触らず、複数テーブルを更新するときは別途Serviceクラスを作る感じで。そうするとS2JDBC-Genを繰り返し実行しても問題にならない。
# 最初にSeasar2を使ったときにいきなりS2JDBC-Genから入ったけど、データの取得または一行更新なら別にサービスクラスすら必要ないんだよね。例題がServiceクラスを使う例ばかりなので使わないという選択肢がすっぽり抜けていた。


そこで現在22テーブル、50ページある制作中のシステムをApache Cayenneに移行した。


参考:
http://d.hatena.ne.jp/t_yano/20081118/1227008018
http://cayenne.apache.org/doc30/tutorial-persistent-objects.html
http://www.atmarkit.co.jp/fjava/products/cayenne/cayenne_2.html

Cayenneの設定

マニュアルに従ってGUIツールでDBからテーブル定義よ読み込みCayenneの設定ファイルを作りJavaコードを生成する。
プライマリーキーはMeaningful Primary Keyにして、Java側でも取得できるようにしておく。
プライマリキー名を指定せずにserialまたはbigserial型で生成した場合、テーブルごとのSequence Nameをテーブル名_列名_seqに指定する。

pom.xml

<dependency>
   <groupId>org.apache.cayenne</groupId>
   <artifactId>cayenne-server</artifactId>
   <version>3.0.2</version>
   <!--
   <version>3.1M3</version>
   -->
</dependency>
<dependency>
    <groupId>com.google.inject</groupId>
    <artifactId>guice</artifactId>
    <version>3.0</version>
</dependency>
<dependency>
    <groupId>org.apache.wicket</groupId>
    <artifactId>wicket-guice</artifactId>
    <version>1.5.3</version>
</dependency>

バージョン3.0.2を使った。
そしてS2JDBCを使わないならSeasar2も不要なのでGuiceを使う。


アプリケーションのjava

public class WebApp extends AuthenticatedWebApplication {
    /* for Cayenne 3.1 */
    // private static ServerRuntime cayenneRuntime;

    private static class ObjectContextProvider implements Provider<ObjectContext> {
        @Override
        public ObjectContext get() {
            try {
                ObjectContext ret = BaseContext.getThreadObjectContext();
                return ret;
            }catch (IllegalStateException e) {
                ObjectContext ret = DataContext.createDataContext();
                BaseContext.bindThreadObjectContext(ret);
                return ret;
            }
        }
    }

    private static class GuiceModule extends AbstractModule {
        @Override
        protected void configure() {
            //bind(ObjectContext.class).toInstance(DataContext.createDataContext());
            bind(ObjectContext.class).toProvider(ObjectContextProvider.class).in(Scopes.NO_SCOPE);
        }
    }
    public static Injector getInjector() {
        GuiceInjectorHolder holder = Application.get().getMetaData(GuiceInjectorHolder.INJECTOR_KEY);
        return holder.getInjector();
    }
    public static ObjectContext getDb() {
        return getInjector().getInstance(ObjectContext.class);
    }
    @Override
    protected void init(){
        super.init();
        // 文字コード・タイムゾーン設定
        getRequestCycleSettings().setResponseRequestEncoding("UTF-8");
        getMarkupSettings().setDefaultMarkupEncoding("UTF-8");
        TimeZone.setDefault(TimeZone.getTimeZone("JST"));

        // アプリケーション設定
        IApplicationSettings appSettings = getApplicationSettings();
        appSettings.setInternalErrorPage(ErrorPage.class);
        appSettings.setPageExpiredErrorPage(ExpiredErrorPage.class);

        // フレームワーク設定
        getFrameworkSettings().add(new AnnotationEventDispatcher());

        // Guice設定
        getComponentInstantiationListeners().add(new GuiceComponentInjector(this, new GuiceModule()));

        // Cayenne設定
        /* for Cayenne 3.1
        if (cayenneRuntime != null)
            cayenneRuntime.shutdown();
        cayenneRuntime = new ServerRuntime("cayenne.xml");
        */

        ....

こんな感じで。
staticメソッドでいつでもGuiceからObjectContextを取得できるようにしておく。
bindThreadObjectContext の使い方はまだあまり分かっていない。

ユーティリティクラスを作る

Cayenneにはcount()のようなメソッドがないのでCountHelperを作る。
http://cayenne.195.n3.nabble.com/Create-count-query-based-on-SelectQuery-td131870.html

public class CountHelper {
    public static class RuntimeQuery extends RuntimeException {
        private static final long serialVersionUID = -827119367373517203L;
        public RuntimeQuery() {
            super();
        }
        public RuntimeQuery(String message, Throwable cause) {
            super(message, cause);
        }
        public RuntimeQuery(String message) {
            super(message);
        }
        public RuntimeQuery(Throwable cause) {
            super(cause);
        }
    }

    public static long count(DataContext context, SelectQuery query) {
        return count(context, query, context.getParentDataDomain().
            getDataNodes().iterator().next());
    }
    public static long count(DataContext context, SelectQuery query,
            DataNode node) {
        CountTranslator translator = new CountTranslator();

        translator.setQuery(query);
        translator.setAdapter(node.getAdapter());
        translator.setEntityResolver(context.getEntityResolver());

        Connection con = null;
        PreparedStatement stmt = null;
        try {
            con = node.getDataSource().getConnection();
            translator.setConnection(con);

            stmt = translator.createStatement();

            ResultSet rs = stmt.executeQuery();
            if (rs.next()) {
                return rs.getLong(1);
            }

            throw new RuntimeQuery("Count query returned no result");
        }
        catch (Exception e) {
            throw new RuntimeQuery("Cannot count", e);
        }
        finally {
            try {
                if (stmt != null) {
                    stmt.close();
                }
                if (con != null) {
                    con.close();
                }
            }
            catch (Exception ex) {
                throw new RuntimeQuery("Cannot close connection", ex);
            }
        }
    }

    static class CountTranslator extends SelectTranslator {
        @Override
        public String createSqlString() throws Exception {
            String sql = super.createSqlString();
            int index = sql.indexOf(" FROM ");

            return "SELECT COUNT(*)" + sql.substring(index);
        }
    }
}
http://osdir.com/ml/user-cayenne-apache/2009-09/msg00012.html

SelectQueryやExpressionを組み立てるクラスも作る。
ちょっと長いが全部載せる。

public class Q<T1> implements Serializable {
    private final Class<T1> clazz;
    private Expression exp;
    private Q(Class<T1> clazz) {
        this.clazz = clazz;
    }

    /**
     * 基底となるエンティティクラスを指定する
     * @param <T2>
     * @param clazz
     * @return
     */
    public static <T2> Q<T2> from(Class<T2> clazz) {
        return new Q<T2>(clazz);
    }

    /**
     * 現在のObjectContextでデータを再取得する
     * @param db
     * @param entity
     * @return
     */
    public T1 reload(ObjectContext db, Persistent entity) {
        exp = ExpressionFactory.matchExp(entity);
        return parse().getSingle(db);
    }
    /**
     * プライマリキーでエンティティを検索する
     * @param db
     * @param primaryKey
     * @return
     */
    public T1 getById(ObjectContext db, Object primaryKey) {
        return DataObjectUtils.objectForPK(db, clazz, primaryKey);
    }
    public T1 getSingle(ObjectContext db) {
        return parse().getSingle(db);
    }

    public Executor<T1> parse(){
        return new Executor<T1>(this);
    }

    private void addExp(Expression addition) {
        if (exp == null)
            exp = addition;
        else
            exp = exp.andExp(addition);
    }

    /**
     * 条件を文字列で指定する
     * @param where
     * @return
     */
    public Q<T1> where(String where){
        addExp(Expression.fromString(where));
        return this;
    }
    public Q<T1> where(Expression addition) {
        addExp(addition);
        return this;
    }

    /**
     * = 条件を追加する
     * @param property
     * @param value
     * @return
     */
    public Q<T1> eq(String property, Object value) {
        addExp(ExpressionFactory.matchExp(property, value));
        return this;
    }

    /**
     * != 条件を追加する
     * @param property
     * @param value
     * @return
     */
    public Q<T1> notEq(String property, Object value) {
        addExp(ExpressionFactory.noMatchExp(property, value));
        return this;
    }

    /**
     * col > ? 条件を追加する
     * @param property
     * @param value
     * @return
     */
    public Q<T1> greater(String property, Object value) {
        addExp(ExpressionFactory.greaterExp(property, value));
        return this;
    }
    /**
     * col >= ? 条件を追加する
     * @param property
     * @param value
     * @return
     */
    public Q<T1> greaterEq(String property, Object value) {
        addExp(ExpressionFactory.greaterOrEqualExp(property, value));
        return this;
    }
    /**
     * col < ? 条件を追加する
     * @param property
     * @param value
     * @return
     */
    public Q<T1> less(String property, Object value) {
        addExp(ExpressionFactory.lessExp(property, value));
        return this;
    }
    /**
     * col <= ? 条件を追加する
     * @param property
     * @param value
     * @return
     */
    public Q<T1> lessEq(String property, Object value) {
        addExp(ExpressionFactory.lessOrEqualExp(property, value));
        return this;
    }

    /**
     * 昇順でソートする
     * @param sortPathSpec
     * @return
     */
    public Executor<T1> asc(String sortPathSpec) {
        return parse().asc(sortPathSpec);
    }
    /**
     * 降順でソートする
     * @param sortPathSpec
     * @return
     */
    public Executor<T1> desc(String sortPathSpec) {
        return parse().desc(sortPathSpec);
    }
    /**
     * エンティティのリストを返す
     * @param db
     * @return
     */
    public List<T1> getList(ObjectContext db) {
        return parse().getList(db);
    }
    /**
     * limitを設定する
     * @param limit
     * @return
     */
    public Executor<T1> limit(int limit) {
        return parse().limit(limit);
    }
    /**
     * offsetを設定する
     * @param offset
     * @return
     */
    public Executor<T1> offste(int offset) {
        return parse().offset(offset);
    }

    /**
     * joinのプリフェッチを設定する
     * @param prefetchPath
     * @return
     */
    public Executor<T1> join(String prefetchPath){
        return parse().join(prefetchPath);
    }
    public static class Executor<T3> {
        private final Q<T3> exQuery;
        private final SelectQuery query;
        private Executor(Q<T3> q) {
            exQuery = q;
            query = new SelectQuery(q.clazz, q.exp);
        }
        /**
         * 昇順でソートする
         * @param sortPathSpec
         * @return
         */
        public Executor<T3> asc(String sortPathSpec) {
            query.addOrdering(sortPathSpec, SortOrder.ASCENDING);
            return this;
        }
        /**
         * 降順でソートする
         * @param sortPathSpec
         * @return
         */
        public Executor<T3> desc(String sortPathSpec) {
            query.addOrdering(sortPathSpec, SortOrder.DESCENDING);
            return this;
        }

        /**
         * エンティティのリストを返す
         * @param db
         * @return
         */
        @SuppressWarnings("unchecked")
        public List<T3> getList(ObjectContext db){
            return db.performQuery(query);
        }

        /**
         * joinのプリフェッチを設定する
         * @param prefetchPath
         * @return
         */
        public Executor<T3> join(String prefetchPath){
            query.addPrefetch(prefetchPath);
            return this;
        }
        /**
         * limitを設定する
         * @param limit
         * @return
         */
        public Executor<T3> limit(int limit){
            query.setFetchLimit(limit);
            return this;
        }
        /**
         * offsetを設定する
         * @param offset
         * @return
         */
        public Executor<T3> offset(int offset){
            query.setFetchOffset(offset);
            return this;
        }

        public long getCountLong(ObjectContext db) {
            DataContext dc = (DataContext)db;
            return CountHelper.count(dc, query);
        }
        public Q<T3> getQuery() {
            return exQuery;
        }
        public T3 getSingle(ObjectContext db) {
            List<T3> list = getList(db);
            if (list.isEmpty())
                return null;
            else if (list.size() == 1)
                return list.get(0);

            throw new RuntimeException("Too many result.");
        }
    }

    public long getCountLong(ObjectContext db) {
        return parse().getCountLong(db);
    }

    /**
     * 行数を返す
     * @param db
     * @return
     */
    public int getCount(ObjectContext db) {
        return (int) getCountLong(db);
    }
}

一応流れるようなインターフェースにしたつもり。


他にもCayenne用のDataProviderなどを作る。

public abstract class CayenneDataProvider<T extends Persistent> implements IDataProvider<T> {
    protected abstract Q.Executor<T> getQuery();

    @Override
    public void detach() {
    }
    @Override
    public Iterator<? extends T> iterator(int first, int count) {
        return getQuery().limit(count).offset(first).getList(WebApp.getDb()).iterator();
    }
    @Override
    public int size() {
        return (int) getQuery().getQuery().getCountLong(WebApp.getDb());
    }
    @Override
    public IModel<T> model(T object) {
        return CayenneModel.of(object);
    }
}

ModelUtil.java

public class CayenneModel extends Model<T> {
    public static <T extends Persistent> IModel<T> of(T object){
        return new CayenneModel<T>(object);
    }
    public static <T extends Persistent> PropertyModel<T> of(Object modelObject, String expression){
        return new CayennePropertyModel<T>(modelObject, expression);
    }

    public static <T extends Persistent> ListModel<T> of() {
        return new CayenneListModel<T>();
    }
    public static <T extends Persistent> ListModel<T> of(List<T> list) {
        return new CayenneListModel<T>(list);
    }

    ...

各ページのS2JDBCのEntityクラスをCayenneのPersistentクラスに変える

ひたすら変更。
CompoundPropertyModelなどを使っている場合は、"item.name" と書いてあれば item.nameフィールドまたはitem.getName()メソッドが呼ばれるのでテンプレートのHTMLの変更は少ない。


モデルのデータ格納先をPersistentクラスにしている場合の注意点が二つ。

  • データ更新の場合、FormのonSubmitでバリデーションしてはいけない。
  • データ登録の場合、Persistentクラスはnewで生成し、onSubmitの中でObjectContext#registerNewObjectする。

Persistentクラスのデータを変更する=commitChanges時に反映される、ということに気をつける。


例えば、onSubmitの中でエラーがあるからといってcommitもrollbackもせずにいると、変更された値はセッションに保持され続けるので他のページに遷移した後に別の処理でcommitChangesを呼び出したときにエラーが出る。




他、ページやコンポーネントのフィールドとしてPersistentを保持した場合、セッションから復元されたときにデータが消える。
具体的にはHollowの状態になる。
http://cayenne.195.n3.nabble.com/Conditions-when-PersistenceState-gets-quot-hollow-quot-td691334.html


今のところ

public class CayenneLoader {
    @SuppressWarnings("unchecked")
    public static <T extends Persistent> T load(T obj) {
        if (obj == null)
            return null;
        if (obj.getPersistenceState() == PersistenceState.HOLLOW) {
            ObjectContext db = WebApp.getDb();
            obj = (T) DataObjectUtils.objectForPK(db, obj.getObjectId());
        }
        return obj;
    }
}


public class CayenneModel<T extends Persistent> extends Model<T> {
    public CayenneModel(T object) {
        super(object);
    }

    @Override
    public T getObject() {
        return CayenneLoader.load(super.getObject());
    }

    @Override
    public void setObject(T object) {
        super.setObject(object);
    }
}

このようなモデルを作って回避している。
ちょっと不安が残る。※2

S2Container関連の設定を削除する

mavenの依存関係やS2JDBC関連クラス、diconファイルを削除する。


S2WicketFilterがなくなるのでReloadingWicketFilterを継承したフィルタを作る。

public class AppWicketFilter extends ReloadingWicketFilter {
    private final Logger logger = LoggerFactory.getLogger(AppWicketFilter.class);

    static
    {
        ReloadingClassLoader.includePattern("com.example.project.wicket.**");
        ReloadingClassLoader.excludePattern("com.example.project.wicket.session.**");
    }
    @Override
    public void init(boolean isServlet, FilterConfig filterConfig) throws ServletException {
        // 再読み込み時にアプリケーションが破棄されるようにする
        destroy();

        for (URL str : ReloadingClassLoader.getLocations()) {
            logger.info("[classpath] {}", str);
        }
        for (String str : ReloadingClassLoader.getPatterns()) {
            logger.info("[pattern] {}", str);
        }

        ClassLoader previousClassLoader =
            Thread.currentThread().getContextClassLoader();
        Thread.currentThread().setContextClassLoader(getClassLoader());
        super.init(isServlet, filterConfig);
        Thread.currentThread().setContextClassLoader(previousClassLoader);
    }
}

以上で移行終了。
Cayenneの使い方を調べつつ、22テーブル50ページの書き換えに四日かかった。

感想

移行初期段階はCayenne便利!とか思ってたけど、全部書き換えてみて ※2hollowの状態になるのが結構キツい。
それに加えてJoinのselect文もS2JDBCより沢山発行するわけだから、selectしすぎじゃないか?と思う。


構成はGuiceを使うことによってだいぶ単純になった。
Webサーバーの再起動も若干早い。


現状をまとめると

  • メリット
    • joinを書かなくて良い
    • 構成がシンプル
    • ReloadingWicketFilterがSessionやEventDispatcherなど一部のクラス以外ほとんどで使える
    • 楽観的排他制御が強力
      • DBは変更の必要なし
  • デメリット
    • Hollowの扱いが面倒
      • Persistentオブジェクトをページやコンポーネントに持てない。必ずModelを使う必要がある。
        • 本来はそうすべきなのかも
    • select文発行が多い
      • outer join でprefetchできない?(あとで調べる)
  • 分からないこと
    • PkGeneratorってどうやって取得するの?
      • S2JDBCのinsert → ID取得 → commit(サービスクラスを抜ける) or rollback(例外throw) の方が楽かもしれない


メリットが当初の想定より少ない…。
※1の方法でも良いような気がしている。
結局自動的にjoinしてくれるより自分でjoinした方が直感的で分かりやすいのかも。
また考える。