blog

DBスキーマ駆動のJavaコード生成ツールを自作した(2) 〜DB標準機能に敗れる〜

前回、個人的な好みと、カラムデフォルト値問題からツールを作成した経緯を書きました。
今回はもう一つのきっかけである作成日時・更新日時問題について記載します。


更新日時・作成日時カラム問題

これらのカラムを全テーブルに入れることは、多くのプロジェクトで行われるのではないでしょうか。
私が初めて参画したJava プロジェクトもこの方式でした。
そこではORマッパーを使っていたため、更新日時と作成日時をJava でセットする必要がありました。
それは言い換えると、開発者全員がUpdate 前に更新日時をセットするコードを必ず実装することが求められていた状態です。

残念ながらプロジェクトの途中で、実装漏れがいくつも見つかりました。
そして、更新日時がそれほど重要な位置付けでなかったことと、工数がかかるので問題は放置されました。

また、作成日時が更新されてしまう問題も起きていました。
Java のUpdate 処理をInsert 処理と同じように new Entity() から書き始めた場合、作成日時をわざわざデータベースから取得してセットするのが面倒なのでサボりがちです。
作成日時カラムに現在日付でも入れておけば処理は完了すると、実装者は考えてしまいます。
気持ちは良く分かります。

私はこの問題もツールで解消しようとしました。


ツールでの解決

前回、DBのカラム定義がnot null で、デフォルト値を持っている場合に、Java から指定された値がnull の場合、Insert やUpdate の対象から外す機能をつけたと書きました。
しかし、これだけでは足りません。誤ってnull 以外の値をJava で入れた場合にデータが書き変わってしまうからです。
そこで、Update 時はJava で何を書いてもUpdate されない仕組みが必要と思いました。( 正確にはRepository 層以外のJava コードの記載に影響されないという意味です)

このように書いた時に、

Java
var account = new Account();
account.setName("グリーン");
var now = LocalDateTime.now();
account.setCreatedAt(now);
repository.update(account);

下のようなcreated_at を含まないUpdate のSQL を実行したいです。

SQL
update account set name = 'グリーン';

そのため、ツールでは設定でUpdate 対象外とするカラムを指定できる機能を付けました。
ついでに、Insert 対象外とするカラムを指定する機能と、更新する値を常に now() とするカラムを指定する機能も付けました。

例を書いてみます。

DDL(再掲) とツールの設定は以下のとおりです。

SQL
create table account (
  name text not null,
  created_at timestamp not null default now(),
  updated_at timestamp not null default now()
)

ツールの設定

YAML
# param.yml
excludeUpdateColumnsByTable:
   "*":
      - created_at
excludeInsertColumnsByTable:
   "*":
      - created_at
      - updated_at
setNowColumnsByTable:
   "*":
      - updated_at

(”*” は全てのテーブルを意味します)

Insert 時にこのように書いても、

Java
var account = new Account();
account.setName("グリーン");
var createdAt = LocalDateTime.of(2001,1,1,1,1,1);
account.setCreatedAt(createdAt); // 無視される
var updatedAt = LocalDateTime.of(2002,2,2,2,2,2);
account.setUpdatedAt(updatedAt); // 無視される
repository.insert(account);

発行されるSQL はこちらになります。

SQL
insert into account (name) values ('グリーン');

createdAt とupdatedAt はInsert 時に除外されるため、DBがカラム定義に従ってデフォルト値のnow() を入れてくれます。

Update はこのようになります。

Java
var account = new Account();
account.setName("グリーングリーン");
var createdAt = LocalDateTime.of(2003,3,3,3,3,3);
account.setCreatedAt(createdAt); // 無視される
var updatedAt = LocalDateTime.of(2004,4,4,4,4,4);
account.setUpdatedAt(updatedAt); // 無視される
repository.update(account);
SQL
update account set name = 'グリーングリーン', updated_at = now();

Insert と同様に、created_at は除外されます。また、updated_at の値はnow() で置き換えられます。
呼び出す側は更新日時・作成日時を考えなくても、もし誤って変な値をセットしても、Repository 層以下でよろしくやってくれるところが便利な点です。(Java から更新できなくなるとも言える)

これで解決したように思えました。
これで解決ならツールを作った甲斐もあったものです。
しかしですね。。。


トリガーで全て解決

残念ながらツールを作った後に、トリガーで全て解決することに気づきました。
トリガーでUpdate 時に作成日時として時刻で上書きすることが可能です。
同時に、作成日時を更新前の値に強制的に戻すこともできます。
また、Insert 時だけ作成日時を時刻で上書きすることもできます。
アプリケーションの実装漏れを、DBレイヤーで強制的に防げる点が大きいです。

PostgreSQL の例を挙げます。

トリガーの定義

SQL
CREATE OR REPLACE FUNCTION refresh_created_updated_columns()
RETURNS TRIGGER AS $$
BEGIN
    -- 更新時は常に updated_at を現在時刻にする
    NEW.updated_at = NOW();

    IF (TG_OP = 'INSERT') THEN
        -- 新規作成時は created_at を現在時刻にする
        NEW.created_at = NOW();
    
    ELSIF (TG_OP = 'UPDATE') THEN
        -- 更新時は OLD の値を維持して上書きを防止する
        NEW.created_at = OLD.created_at;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

トリガーの適用

SQL
CREATE TRIGGER trigger_account_created_updated_columns
BEFORE INSERT OR UPDATE ON account FOR EACH ROW
EXECUTE FUNCTION refresh_created_updated_columns();

これでこの問題はほぼ解決です。
ツールを作った後に気づいたので、悲しい。本当に悲しい。

1点だけ気になるのは、トリガーと言えば速度が問題になりがちですが、実際どうなのでしょうか。


トリガーの速度を試す

自分のPC にDocker でPostgreSQL を起動して、大量Update を行ってみました。

シェルで簡易に行ったもので、DB 接続とコミットを毎回行っています。

結果
10,000 回Update
トリガーなし 99秒
トリガーあり 103秒 (+4秒)

20,000 回Update
トリガーなし 208秒
トリガーあり 214秒 (+6秒)

3%から4% の速度劣化といったところです。
Update を2回行うよりはずっと少ない負担のようです。
トリガー適用時にBEFORE を指定したため、データベースとしては更新対象のレコードが把握できている状態からトリガーで指定された処理を行うため、高速なようです。

この程度であれば多くのプロジェクトで許容されるのではないでしょうか。
ただ、ツールを使えばトリガーを入れなくても良いという分があるにはあります。

参考 負荷投入shell

ShellScript
date
for i in $(seq 1 20000)
do
  psql -Upostgres <<! >/dev/null 2>&1
update account set name = 'グリーングリーン', created_at='2000-01-01', updated_at='2000-02-02';
!
done
date

最後に感想

多くのプロジェクトはトリガーで解決できるため、ツールが活きるのは以下2つの場合でしょう。

  • トリガーの負荷を許容できない
  • Java からDB アクセスをライブラリの好みが私と近い(Spring JDBC が好き)

いずれにせよ、あまりないかもしれません。ツールはこちらに残しておきます。

別の角度の話ですが、私はJava でJava のコードを作るというのが初めてだったのですが、やってみて自分のコードの抽象度を大きく高めることができると感じました。AOP とは違う角度で、場合によってはより強力に、抽象化ができると思いました。

これを使えば、作成者ID をSpring Security のLoginUserDetail から取得して強制的にセットするという流れを実装することもできそうです。記事では触れませんでしたが、作成者ID・更新者IDも同じ話になります。
思想に反するという反論があるかもしれませんが、実装漏れを必ず食い止めるという視点で、手段の一つとして持っておくと何かの役に立つかもしれないです。

【広告】

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です