月: 2026年2月

  • 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も同じ話になります。
    思想に反するという反論があるかもしれませんが、実装漏れを必ず食い止めるという視点で、手段の一つとして持っておくと何かの役に立つかもしれないです。

    【広告】

  • DBスキーマ駆動のJavaコード生成ツールを自作した(1)

    いろいろと思うところがあり、Spring JDBC ベースのコード生成ツールを自作しました。
    なぜ今更このようなものを作ったのか、個人的な好みと、カラムデフォルト値問題、更新日時・作成日時問題(次回)から記載します。


    個人的な好み

    Java からDB アクセスを行う仕組みは歴史がある枯れた領域です。
    無料で使えるライブラリが多数あるのですが、個人的にはどれも一長一短だと感じています。
    私が使ったことのあるライブラリについて独断と偏見を列挙します。
    多分私はマイノリティなので、反論は当然あると思います。

    JOOQ

    始めは好印象だった。Java でSQL の全てを書くことができる。ORマッパーとしては最強だと今も思っている。PostgreSQL、MySQL は無償版でも使えるが、Oracle は有償版でないと使えない点は気になる。今後料金体系が変わったりしたら一大事かも。ORマッパーなのでJava のコードからSQL を推測するスキルが必要。私は自分で書いたクエリーを時間が経ってから読んだら意味が分からなかった。

    JPA

    こちらも始めは使いやすいと思っていた。しかし、一次キャッシュにより更新されない問題を踏んだ時に、二度と使わないと思えるほど嫌になった。私の頭では一次キャッシュを使いこなせない。ライブラリの容量が大きいので起動が遅くなる、Boot jar のサイズが大きくなる、といった問題もある。

    MyBatis

    XML にSQL を書くのが嫌。CDATA がたくさん出てきて読みづらくなる。if foreach OGNL を覚えるのが面倒。Java ならすんなり書けるのに使い方を調べながら書くのがストレス。実質業界標準なので業務で使うことは多い。

    Spring JDBC

    機能が薄くてシンプル。SQL をJava で書くのでMyBatis のように独自の文法を覚える必要がない。欠点は簡単なCRUD を書くだけでも、Entity とRepository を手で作成する必要がありそこそこ手間がかかる。Java の文字列結合でSQL を書くとセキュリティ上の問題が起きると信じている人がいる気がする。

    DBFlute

    SQL を書いたらEntity を作ってくれるという、他にはない思想が良い。開発速度向上につながる。Generator が吐き出すjava コードをEclipse で開くと、警告が沢山出たことを当時(10年以上前)気にしていた。後述の問題は解消できない。

    Exposed (kotlin)

    Kotlin 良さと相まって、ものすごく少ない行数でDB アクセスができる点が素晴らしい。後述の問題にも対応できる。だが、ORマッパーの苦しさはJOOQ と同じ。あと、少し前にメジャーバージョンに変わった辺りでIF が変わり書き直しが必要になったのが辛かった。

    よくある、ORマッパー派か生SQL派で言うと、私は生SQL派になります。生SQL を効率よく書きつつ、後述の問題を回避したいというのが私の願いです。

    次に、多くのライブラリでカラムのデフォルト値が使えないという問題があると思うので触れてみます。


    カラムデフォルト値問題

    こちらは業務で影響が出ることはほとんどない重箱の隅のような話です。多くのライブラリは抽象化を優先している(そうあるべき)ため、この問題を孕んでいると思います。

    例えばこのように、create_at カラムにnot null 制約と初期値としてnow() がついているとします。

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

    ORマッパーでInsert を行います。

    Java
    var account = new Account();
    account.setName("グリーン");
    account.setCreatedAt(null);
    account.setUpdatedAt(null);
    repository.insert(account);

    このコードでは not null 制約に抵触してエラーになると思います。
    発行されるSQLがこのようになるためです。

    SQL
    insert into account (name, created_at, updated_at) values ('グリーン', null, null);
    --> ERROR:  リレーション"account"の列"created_at"のNULL値が非NULL制約に違反しています

    DB でデフォルト値を持っているので、エラーにならなくてもよい気がします。
    この問題が業務に影響することはほとんどありません。
    こうするだけです。

    Java
    var now = LocalDateTime.now();
    account.setCreatedAt(now);

    一応、これに対する反論として、Java のサーバとDB サーバのマシン時刻が合っていない可能性があるというものがあります。
    ですが、NTP を使えばずれはその差はほぼありません。僅かな差が許されない要件であることは稀でしょう。

    さらに、初期値をカラムの定義に委ねるのはよろしくない、アプリで初期値を保つべきだという思想もあると思います。

    思想に反する上に業務上困ることはないので気にしなければ良いのですが、私はDB が持つデフォルト値という機能をJava から使えなくなるという点にどうしても違和感を感じます。データはシステムの中心であり、データを管理するデータベースの機能がミドルウェアにより制限されるのは違うと思います。


    ツールで解消

    私が作成したツールは、DBのカラム定義がnot null で、デフォルト値を持っている場合に、Java から指定された値がnull の場合、Insert やUpdate の対象から外すようにしました。

    そのため、先ほどのサンプルのSQL はこの形で実行されます。

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

    ツールはこちら

    このツールは、万人向けではありません。ただ、生SQL派で、DBのデフォルト値を大事にしたい人にはそれなりに刺さるかもしれません。
    次回は、このツールを作るもうひとつのきっかけになった「作成日時・更新日時問題」について書きます。

    【広告】