Teeda向けS2JDBC-GenでのDB構成管理

S2JDBC-Genの使い方としては、想定外の使い方をしています。他にも構成管理をする方法はありますので、よく検討してから参照してください。

基本概要

Teedaに限ったことではありませんが、S2JDBCを利用していないプロジェクト向けに、無理やりS2JDBC-Genを利用してDB構成管理をしてみる方法について記述しています。

そのため、S2JDBCを開発で利用している場合の想定手順と違う方法や、手法などを利用しています。

ERDツールとの差別化

構成管理だけであれば、ERDツールなりエクセルなりで管理が可能だと思います。ただしS2JDBC-Genではデータの管理も行うことができます。通常開発用の最低限のマスターデータが入ったもの。負荷テスト用に最大限のデータが入ったものなどを準備し、気軽に準備することができます。

もちろん開発用の共有で使えるデータベースを準備して、接続先を切り替えて使う方式でも対応できますが、S2JDBC-Genを使うとローカルのデータベースをコマンド一発で初期状態に戻したりできますし、確実に最新環境に入れ替えたりが可能ですのでより失敗することが少なく開発をすることが可能です。

準備

データベースのバックアップ

おそらく実験途中で何度かデータベースを壊します。復帰できるようにあらかじめデータベースのバックアップを行いましょう。

プロジェクト作成

Doltengを利用してSAStrutsS2JDBCの構成でプロジェクトを作成します。作成する名前はメインプロジェクトがnekoであれば、neko-dbなどわかりやすい名前が良いと思います。

ポイントとしては、なるべく最新のDoltengを利用して作成してください。Seasar本体のバージョンはなるべく新しいほうが安定していると思います。

DBの設定

resouecesの中にある「jdbc.dicon」と「s2jdbc.dicon」を編集して利用しているデータベースに接続できるようにします。必要があればJDBCなどをプロジェクトの中に追加する必要があります。
Teedaで利用されているS2Daoと違うところは、「s2jdbc.dicon」にて利用するデータエースの種類を選択することになります。ファイルの中身を見ればわかると思いますがdialectをデフォルトのH2から利用しているものに変更しましょう。

データベースへの設計反映思想

S2JDBC推奨 Entity -> DDL作成 S2JDBCで開発をするのであれば自然な流れ
個人的推奨 DDL -> Entity作成 新しく覚えることが最小

S2JDBCはEntityを作成して、それをデータベースに反映するサイクルを推奨しています。S2JDBCをベースに開発をしているのであれば上記の流れでもよいのですが、以下の場合にはこの流れを変えたほうがいいと思います。

  • ERDツールなどでDBを設計している
  • エクセルでDBを設計している
  • CREATE TABLE文を書かないと落ちつかない
  • S2JDBCのEntityの書き方を覚えるのが面倒

何をマスターとするかにかかわってきますが、S2JDBCはEntityをマスターとする考えになります。逆にERDツールを利用していると、ERDツールがマスターになりますので、推奨パターンでは面倒になります。あとは何気にエクセルで管理しているところもまだ多いですよね?

この流れの変化を嫌ってS2JDBC-Genを使っていない人って結構いるんじゃないでしょうか?

DDLからEntityへの私の推奨反映方法

新規作成したテーブル gen-entity、gen-ddlを実行
軽微なテーブル変更 Entityを編集後、gen-ddlを実行
大規模なテーブル変更 Entityを削除後gen-entity、gen-ddlを実行

もちろん、S2JDBC推奨のEntityを新規作成してもかまいません。軽微な変更でも毎回Entityを削除してからでもかまわないと思います。最終的にはgen-ddlが作成したDDLが、元のDDLと同一であるかが重要だと思います。

マッピングの不一致

実際のところgen-entity、gen-ddlをして作成されたDDLは当初のDDLと完全に一致するとは限りません。これは使っているデータ種などによるのですが、Javaの型とデータベースの型が1対1でマッピングできないので仕方がないと思います。

なので、出来上がったDDLを確認してからEntityの修正を行う必要があります。この作業があるためすでにある程度の数のテーブルがある場合にはかなりの手間になると思います。

プロジェクトの初期からの導入以外で、すでに稼動しているプロジェクトなどに適用するのはテスト工数などを考えると非常に大変になると思ってください。基本的に最初に導入を決めないと、途中からは危ないのでお勧めできません。

S2JDBC-Genの仕組み

プロジェクト直下にあるs2jdbc-gen-build.xmlS2JDBC-Genの設定ファイルになります。内容はAntタスクであり、実行するタスクが記述されています。
S2JDBC-Genを拡張する場合には、準備されているタスクの設定を変更するか、新規のAntタスクを作成することになります。比較的簡単に拡張が可能ですので、いろいろと作ってみると楽しいと思います。

DBに日本語のコメントを追加する

以下、実際のS2JDBC-Genの設定になります。

当初なくって残念でしたが、追加された機能です。JavadocAPIを利用してコメントに埋め込む形で対応を行っています。昔は自作タスクを作って対応していましたが現在は標準機能を利用したほうがスマートだと思います。

    <gen-entity
      rootpackagename="${rootpackagename}"
      entitypackagename="${entitypackagename}"
      javafiledestdir="${javafiledestdir}"
      javafileencoding="${javafileencoding}"
      env="${env}"
      jdbcmanagername="${jdbcmanagername}"
      classpathref="classpath"
      applyDbCommentToJava="true"
    />

上記のように最後にapplyDbCommentToJavaを追加します。これでデータベースに設定されているコメントをEntity作成時に取り込むようになります。

    <gen-ddl
      classpathdir="${classpathdir}"
      rootpackagename="${rootpackagename}"
      entitypackagename="${entitypackagename}"
      env="${env}"
      jdbcmanagername="${jdbcmanagername}"
      classpathref="classpath"
      applyJavaCommentToDdl="true"
    />

EntityからDDL作成時にもコメントを利用するように設定します。gen-entityとgen-ddlで設定の名前が違うので注意してください。上記の設定ができたら、gen-entityを実行し作成されたEntityにコメントが振られていることを確認してください。

package neko.entity;

import java.io.Serializable;
import java.math.BigInteger;
import java.sql.Date;
import javax.annotation.Generated;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;

/**
 * イベント履歴
 * 
 */
@Entity
@Generated(value = {"S2JDBC-Gen 2.4.39", "org.seasar.extension.jdbc.gen.internal.model.EntityModelFactoryImpl"}, date = "2009/08/18 23:58:19")
public class EventList implements Serializable {

    private static final long serialVersionUID = 1L;

    /** イベントの連番 */
    @Id
    @Column(precision = 22, nullable = false, unique = true)
    public BigInteger eventId;

    /** イベントの発生日時 */
    @Column(nullable = false, unique = false)
    public Date eventDate;

    /** イベントコメント */
    @Column(length = 4000, nullable = true, unique = false)
    public String eventComment;
}

たとえば上記のような形になります。

このファイルができたところで、gen-ddlを実行します。

com.sun.tools.javadoc.Docletが使用できません。JDKのtools.jarがクラスパスに通されていることを確認してください。

上記のエラーがgen-ddl時に出た場合にはJDKのtools.jarをクラスパスに追加しましょう。Eclipse上でJREなどを利用して開発を行っている場合にはtools.jarが入っていないのでエラーになります。

この場合には、お行儀が悪いですがどこかのJDKからtools.jarをコピーしてきて、プロジェクト直下のlibフォルダなどにいれてクラスパスに追加すると良いと思います。

そうするとdbフォルダ上にddlなどが作成されます。

create table EVENT_LIST (
    EVENT_ID number(22,0) not null,
    EVENT_DATE date not null,
    EVENT_COMMENT varchar2(4000),
    constraint FORM_EVENT_LIST_PK primary key(EVENT_ID)
);

comment on table EVENT_LIST is 'イベント履歴';
comment on column EVENT_LIST.EVENT_ID is 'イベントの連番';
comment on column EVENT_LIST.EVENT_DATE is 'イベントの発生日時';
comment on column EVENT_LIST.EVENT_COMMENT is 'イベントコメント';

Oracle上の場合ですが、上記のようなDDLが作成されました。この場合にはオリジナルのDDLと同一の構成なのでこのままで大丈夫ですが、違っていた場合には編集する必要があります。

たとえばOracleの場合CHARとVARCHAR2は両方Entity上ではStringになりますが、StringからはVARCHAR2で作成されるので非対称の変換になります。この場合には

    @Column(length = 8, nullable = false, unique = false)
    public String textData;

上記のようなカラムの場合には

    @Column(length = 8, nullable = false, unique = false, columnDefinition = "char(8)")
    public String textData;

このようにDBの宣言をそのものずばり書いてあげれば大丈夫です。(http://s2container.seasar.org/2.4/ja/s2jdbc_gen/entity_definition.html

シーケンスでのINSERT

S2JDBCはシーケンスの機能を備えていますが、データベースごとの機能の差を吸収するためだと思いますが、独自に実装しています。そのためS2JDBCを利用した開発の場合には問題がないのですが、S2Daoやツール上からInsertした場合などにシーケンスが自動的に更新されません。

たとえばOracleの場合にはシーケンスを作成して、Insertのトリガーとして処理を追加する必要があります。このようなデータベースに依存する実装方法はS2JDBC-Genではサポートしていません。

そこで自分でトリガーやシーケンスを作成する必要があります。Oracleの場合ですのでMySQLなどであれば違った手順になります。

    /** eventIdプロパティ */
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "EVENT_ID")
    @SequenceGenerator(name = "EVENT_ID",allocationSize=1)
    @Column(name = "EVENT_ID", precision = 22, nullable = false, unique = true)
    public BigInteger formEventId;

上記のようにSequenceGeneratorでEVENT_IDという名前で、増分1のシーケンスを作成し、GeneratedValueで設定します。この状態でgen-ddlを実行すると030-sequenceというフォルダが作成され、中にシーケンスを作成するSQLが入っています。

このままですとシーケンスができただけで、トリガーができていませんのでトリガーは自分で追加してあげる必要があります。任意の名前でフォルダを作成して、中にSQLファイルを入れることで自動的に実行してくれますので999-USERとします。

db
 +- 0000 初期構成
 +- 0001
   +- create
   +  +- 010-table
   +  +- 020-uniquekey
   +  +- 030-sequence
   +  +- 040-dump
   +  +- 050-foreignkey
   +  +- 999-user
   +- drop

上記のようなフォルダ構成にします。数値の場所はDBのリビジョン番号ですのでどんどん増えていきます。システムが自動作成したフォルダ以外は次のリビジョンを作成したときに自動的にコピーしてくれますので、最新のリビジョンに入れることで次のリビジョンから常に入った状態になります。

CREATE OR REPLACE TRIGGER "BI_EVENT_LIST" 
  before insert on "EVENT_LIST" 
  for each row 
begin 
    select "EVENT_LIST_EVENT_ID".nextval into :NEW.EVENT_ID from dual; 
end; 
/

上記のようなSQLをevent_list.sqlなどとして999_userに入れておきます。

その他の注意点

カラムの順番が変わる

gen-entityはプライマリーキーを一番上に移動させますので、飛んでいるカラムにプライマリーキーを設定している場合には注意してください。

プライマリーキーの数など

念のためプライマリーキー関連は確かめた方がよいと思います。

外部制約など

外部制約なども確かめた方がよいと思います。

viewについて

viewはS2JDBC-Genでは管理されません。
トリガーと同じように適当なフォルダを作成して、中にSQLを入れておきます。

DBのリビジョン管理について

標準的に利用しているとdbフォルダ上にgen-ddlを実行するたびにリビジョンが増えていきます。どうもファイルが増えていくのが個人的には好きになれないので、標準のリビジョン管理を回避しています。

まず最初にdbフォルダを削除して、gen-ddlを実行します。

[db]
+- [migreate]
    +- [0000]
    +- [0001]
    +- ddl-info.txt

すると上記のような構成になります。
このddl-info.txtが内部で管理してあるデータベースのリビジョン番号を記述したファイルです。このファイルをコピーしてddl-info_base.txtとします。

[db]
+- [migreate]
    +- [0000]
    +- [0001]
    +- ddl-info.txt
    +- ddl-info_base.txt

こうなります。

次にs2jdbc-gen-build.xmlのgen-ddlのタスクを書き換えます。

  <target name="gen-ddl">

    (略)

    <!-- セキュリティの設定によっては以下の refresh タスクに時間がかかる場合があります.
      その場合は refresh タスクを削除してください.-->
    <refresh projectName="neko-db"/>

    <delete dir="db/migrate/0000"/>
    <copy todir="db/migrate/0001">
      <fileset dir="db/migrate/0002"/>
    </copy>
    <delete dir="db/migrate/0002"/>
    <copy file="db/ddl-info_base.txt" tofile="db/ddl-info.txt" overwrite="true" />

gen-ddlの最後にdelete以下の文を追加します。

0000の削除 DROPは別タスクで行うので削除します
0002を0001にコピー 新規でできた0002の内容を0001に上書きします。※
0002の削除 追加分を削除します
ddl-infoの書き戻し 常にリビジョン1固定にします

こんな処理をして、常に0001しかないようにしています。ただしこのままだとテーブルを削除した場合にも0001上にファイルが残り続けることになるので、そこは手で消します!

データベースの初期化

リビジョン管理をしなくなった場合、いつのデータベースに対してmigrateをかけるのかがわからないので、存在しないテーブルを削除しようとしてエラーになったり(現在はエラーでも処理が続きます)、管理外のテーブルが消えていなかったりします。

特に実装上必要だけれど、管理化に入っていないテーブルなどが残っていると大変ですので、migrateをしてまっさらな状態から管理しているテーブルを作る手順を取っています。

ただし危険です!

Antタスク開発用にjar追加

ant-1.7.0.jarなどant開発用のjarをプロジェクトに追加します。

削除用タスク作成

Oracleの例ですが、以下のファイルを作成します。

/neko-db/src/main/java/org/seasar/extension/jdbc/gen/internal/command/CleanDatabaseCommand.java
/neko-db/src/main/java/org/seasar/extension/jdbc/gen/task/CleanDatabaseTask.java

CleanDatabaseという、すべてのテーブルなどを削除するタスクを作成します。

/neko-db/src/main/java/org/seasar/extension/jdbc/gen/task/CleanDatabaseTask.java

package org.seasar.extension.jdbc.gen.task;

import org.seasar.extension.jdbc.gen.command.Command;
import org.seasar.extension.jdbc.gen.internal.command.CleanDatabaseCommand;

/**
 * データベースからテーブルなどを削除する{@link Task}です。
 *
 * @see CleanDatabaseCommand
 */
public class CleanDatabaseTask extends GenerateEntityTask {
	/** コマンド */
	protected CleanDatabaseCommand command = new CleanDatabaseCommand();

	@Override
	protected Command getCommand() {
		return command;
	}
}

このファイルは登録だけなので、GenerateEntityTaskを継承してCleanDatabaseCommandを作成して終わりです。

/neko-db/src/main/java/org/seasar/extension/jdbc/gen/internal/command/CleanDatabaseCommand.java

package org.seasar.extension.jdbc.gen.internal.command;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

import org.seasar.extension.jdbc.util.ConnectionUtil;
import org.seasar.extension.jdbc.util.DataSourceUtil;
import org.seasar.framework.exception.SQLRuntimeException;
import org.seasar.framework.util.PreparedStatementUtil;
import org.seasar.framework.util.ResultSetUtil;
import org.seasar.framework.util.StatementUtil;

/**
 * データベースのテーブルなどをすべて削除するコマンドです。
 */
public class CleanDatabaseCommand extends GenerateEntityCommand {
    @Override
    protected void doExecute() {
        dropTables();
        dropSequence();
        dropView();
        dropTrigger();
    }

    private void dropTables() {
        String execSql = " select" +
                            "   tbl.TABLE_NAME NAME" +
                            " from" +
                            "   USER_TABLES tbl";
        Connection conn = DataSourceUtil.getConnection(jdbcManager
                .getDataSource());
        try {
            logger.debug(execSql);
            PreparedStatement ps = ConnectionUtil.prepareStatement(conn,
                    execSql);
            try {
                ResultSet rs = PreparedStatementUtil.executeQuery(ps);
                try {
                    while (rs.next()) {
                        String name = rs.getString("NAME");

                        String execSql2 = "DROP TABLE \"" + name + "\" CASCADE CONSTRAINT";

                        try {
                            logger.debug(execSql2);
                            PreparedStatement ps2 = ConnectionUtil.prepareStatement(conn, execSql2);
                            try {
                                PreparedStatementUtil.executeQuery(ps2);
                            } finally {
                                StatementUtil.close(ps2);
                            }
                        } finally {
                        }
                    }
                } catch (SQLException e) {
                    throw new SQLRuntimeException(e);
                } finally {
                    ResultSetUtil.close(rs);
                }
            } finally {
                StatementUtil.close(ps);
            }
        } finally {
            ConnectionUtil.close(conn);
        }
    }

    private void dropSequence() {
        String execSql = " select" +
                            "   tbl.SEQUENCE_NAME NAME" +
                            " from" +
                            "   USER_SEQUENCES tbl";
        Connection conn = DataSourceUtil.getConnection(jdbcManager
                .getDataSource());
        try {
            logger.debug(execSql);
            PreparedStatement ps = ConnectionUtil.prepareStatement(conn,
                    execSql);
            try {
                ResultSet rs = PreparedStatementUtil.executeQuery(ps);
                try {
                    while (rs.next()) {
                        String name = rs.getString("NAME");

                        String execSql2 = "DROP SEQUENCE \"" + name + "\"";

                        try {
                            logger.debug(execSql2);
                            PreparedStatement ps2 = ConnectionUtil.prepareStatement(conn, execSql2);
                            try {
                                PreparedStatementUtil.executeQuery(ps2);
                            } finally {
                                StatementUtil.close(ps2);
                            }
                        } finally {
                        }
                    }
                } catch (SQLException e) {
                    throw new SQLRuntimeException(e);
                } finally {
                    ResultSetUtil.close(rs);
                }
            } finally {
                StatementUtil.close(ps);
            }
        } finally {
            ConnectionUtil.close(conn);
        }
    }

    /**
     * VIEW削除
     */
    private void dropView() {
        String execSql =  "select tbl.VIEW_NAME NAME" +
                            " from USER_VIEWS tbl";
        Connection conn = DataSourceUtil.getConnection(jdbcManager.getDataSource());
        try {
            logger.debug(execSql);
            PreparedStatement ps = ConnectionUtil.prepareStatement(conn,
                    execSql);
            try {
                ResultSet rs = PreparedStatementUtil.executeQuery(ps);
                try {
                    while (rs.next()) {
                        String name = rs.getString("NAME");

                        String execSql2 = "DROP VIEW \"" + name + "\"";

                        try {
                            logger.debug(execSql2);
                            PreparedStatement ps2 = ConnectionUtil.prepareStatement(conn, execSql2);
                            try {
                                PreparedStatementUtil.executeQuery(ps2);
                            } finally {
                                StatementUtil.close(ps2);
                            }
                        } finally {
                        }
                    }
                } catch (SQLException e) {
                    throw new SQLRuntimeException(e);
                } finally {
                    ResultSetUtil.close(rs);
                }
            } finally {
                StatementUtil.close(ps);
            }
        } finally {
            ConnectionUtil.close(conn);
        }
    }

    /**
     * TRIGGER 削除
     */
    private void dropTrigger() {
        String execSql =  "select  trigger_name  NAME from USER_TRIGGERS";

        Connection conn = DataSourceUtil.getConnection(jdbcManager.getDataSource());
        try {
            logger.debug(execSql);
            PreparedStatement ps = ConnectionUtil.prepareStatement(conn,
                    execSql);
            try {
                ResultSet rs = PreparedStatementUtil.executeQuery(ps);
                try {
                    while (rs.next()) {
                        String name = rs.getString("NAME");

                        String execSql2 = "DROP TRIGGER \"" + name + "\"";

                        try {
                            logger.debug(execSql2);
                            PreparedStatement ps2 = ConnectionUtil.prepareStatement(conn, execSql2);
                            try {
                                PreparedStatementUtil.executeQuery(ps2);
                            } finally {
                                StatementUtil.close(ps2);
                            }
                        } finally {
                        }
                    }
                } catch (SQLException e) {
                    throw new SQLRuntimeException(e);
                } finally {
                    ResultSetUtil.close(rs);
                }
            } finally {
                StatementUtil.close(ps);
            }
        } finally {
            ConnectionUtil.close(conn);
        }
    }
}

ここではGenerateEntityCommandを参考にしてSQLを実行しています。詳細はS2JDBC-Genのソースを見て調べることになります。
内容的にはテーブルであれば、テーブルの一覧を取得してすべてのテーブルを削除しています。

タスクの登録

s2jdbc-gen-build.xmlにタスクを登録します。

  <taskdef resource="s2jdbc-gen-task.properties" classpathref="classpath"/>

  <taskdef name="clean-database" classname="org.seasar.extension.jdbc.gen.task.CleanDatabaseTask" classpathref="classpath"/>

s2jdbc-gen-task.propertiesの下ぐらいに追加します。

<target name="migrate">
    <clean-database
      rootpackagename="${rootpackagename}"
      entitypackagename="${entitypackagename}"
      javafiledestdir="${javafiledestdir}"
      javafileencoding="${javafileencoding}"
      env="${env}"
      jdbcmanagername="${jdbcmanagername}"
      classpathref="classpath"
    />

migrateタスクの一番先頭でこのタスクを呼び出します。これで最初にデータベースの内容をすべて消してから0001のSQLを順に実行していきます。

migrateの実行

既存のテーブルなどをすべてDROPしてからCREATE TABLEしていくはずです。完了した後にテーブルのデータなどが正しく入っていれば成功です。この際に当初の構造と同じかを確認して、違っていたらEntityの編集などを行い修正を行います。

Eclipse上のログなどを確認し、エラーが出ていないかも確認することが重要だと思います。

SCHEMA_INFOの削除

データベースのリビジョン管理用のテーブルが作成されますが、邪魔であれば999-userなどにDROPするSQLを作成して入れておきましょう。

/neko-db/db/migrate/0001/create/999-user/drop_chema_info.sql

DROP TABLE SCHEMA_INFO;

環境別データ作成

  <target name="dump-2">
    <dump-data
      classpathdir="${classpathdir}"
      rootpackagename="${rootpackagename}"
      applyenvtoversion="${applyenvtoversion}"
      entitypackagename="${entitypackagename}"
      env="${env}"
      jdbcmanagername="${jdbcmanagername}"
      classpathref="classpath"
      applyEnvToVersion="true"
    />
    <!-- セキュリティの設定によっては以下の refresh タスクに時間がかかる場合があります.
      その場合は refresh タスクを削除してください.-->
    <refresh projectName="${projectname}"/>
  </target>

上記のようにdumpタスクを編集して、applyEnvToVersionを有効にすると

[db]
+-[migreate]
    +-[0000]
    +-[0001]
    +-[0001#ut]
       +-[create]
          +-[040-dump]

とデータのみ別のディレクトリに保存されます。同じ用にloadタスクを作ると通常と、#utで2つのデータを使い分けることができます。通常は無印で作業を行い、パフォーマンスチェック用にデータ件数が多い物を#utに置いておくことなどができます。この名前はenvで制御していますので、もっとたくさんの種類のデータを準備することもできます。

この場合にはまずはmigrateを実行して、テーブルなどを最新の状態にしてからload-2で別環境用のデータを利用する手順になります。

総括

データベースの構成管理は結構導入が大変です。ただし一度できてしまえば比較的横展開はしやすいと思います。また、メリットもたくさんあるので、ぜひ使って見ましょう。また、導入途中でS2JDBC-Genのプロジェクトがおかしくなったり、データベースが破壊されたりしますのでおかしいなと思ったらプロジェクトの作り直しや、データベースのリストアなどをして何度か壊す覚悟で作業を進めてください。

またDBFluteを利用している場合にはS2JDBC-Genではなく、DBFluteにも構成管理がありますのでそちらの利用をお勧めします。