Teeda向けS2JDBC-GenでのDB構成管理
S2JDBC-Genの使い方としては、想定外の使い方をしています。他にも構成管理をする方法はありますので、よく検討してから参照してください。
基本概要
Teedaに限ったことではありませんが、S2JDBCを利用していないプロジェクト向けに、無理やりS2JDBC-Genを利用してDB構成管理をしてみる方法について記述しています。
そのため、S2JDBCを開発で利用している場合の想定手順と違う方法や、手法などを利用しています。
ERDツールとの差別化
構成管理だけであれば、ERDツールなりエクセルなりで管理が可能だと思います。ただしS2JDBC-Genではデータの管理も行うことができます。通常開発用の最低限のマスターデータが入ったもの。負荷テスト用に最大限のデータが入ったものなどを準備し、気軽に準備することができます。
もちろん開発用の共有で使えるデータベースを準備して、接続先を切り替えて使う方式でも対応できますが、S2JDBC-Genを使うとローカルのデータベースをコマンド一発で初期状態に戻したりできますし、確実に最新環境に入れ替えたりが可能ですのでより失敗することが少なく開発をすることが可能です。
準備
データベースのバックアップ
おそらく実験途中で何度かデータベースを壊します。復帰できるようにあらかじめデータベースのバックアップを行いましょう。
データベースへの設計反映思想
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.xmlがS2JDBC-Genの設定ファイルになります。内容はAntタスクであり、実行するタスクが記述されています。
S2JDBC-Genを拡張する場合には、準備されているタスクの設定を変更するか、新規のAntタスクを作成することになります。比較的簡単に拡張が可能ですので、いろいろと作ってみると楽しいと思います。
DBに日本語のコメントを追加する
以下、実際のS2JDBC-Genの設定になります。
当初なくって残念でしたが、追加された機能です。JavadocのAPIを利用してコメントに埋め込む形で対応を行っています。昔は自作タスクを作って対応していましたが現在は標準機能を利用したほうがスマートだと思います。
<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; /
その他の注意点
カラムの順番が変わる
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にも構成管理がありますのでそちらの利用をお勧めします。