Domaでバイテンポラルデータモデルを素振りする
最近履歴データをちゃんと扱いたい需要があったので、Doma2でバイテンポラルデータモデルをやってみる素振りをしてみました。Reladomo使えよって話なんですがMySQLに対応してなかったんですよね…残念。
モチベーション
今回の例ではクーポンを扱いますが、発行したクーポンの値引き額を修正したい場合修正の履歴は全て残したいといったケースを考えています。経理処理上どうとかあとクーポンではないかもしれないけど監査とかあるとそういったケースはよく出てくると思う。たぶん。知らんけど。
同時にクーポンの発行日時と有効期限はユーザーに見せる必要があるので別で管理したいです。なので、システム的な時間とビジネス的な時間2つを考えて追いかけられるようにしようとするのがバイテンポラルデータモデルです。
テーブルを作る
CREATE TABLE `coupon` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `process_from` datetime NOT NULL, `process_thru` datetime NOT NULL, `business_in` datetime NOT NULL, `business_out` datetime NOT NULL, `amount` int(11) NOT NULL, `user_id` bigint(20) NOT NULL, PRIMARY KEY (`id`, `process_from`) ) ENGINE = InnoDB
とりあえず簡単に試したいだけなのでインデックス貼ってませんが、よしなに貼ってください。
process_XXXがシステム的な時間の始まりと終わり、business_XXXがビジネス的な時間の始まりと終わりの時間です。
ここで注目したいのは主キーがidとprocess_fromになっていますね。idが同じレコードは別のシステム時間に存在していた同じクーポンを表すので、idとprocess_from(システム的な開始時間)になっています。
Entityを作る
@Entity(immutable = true, naming = NamingType.SNAKE_LOWER_CASE) @Table public class Coupon { @org.seasar.doma.Id @GeneratedValue(strategy = GenerationType.IDENTITY) final Id<Coupon> id; final TransactionTime transactionTime; final ValidTime validTime; final CouponAmount amount; final Id<User> userId; // コンストラクタとgetter } @Domain(valueType = long.class) public class CouponAmount { final long value; public CouponAmount(long value) { this.value = value; } public long getValue() { return value; } // valueOf(String)があると、Spring MVCのControllerでRequestParamな引数で使えるようになるので便利 public static CouponAmount valueOf(String value) { return new CouponAmount(Long.valueOf(value)); } }
とりあえずEntityと値クラスをつくちゃいます。
次にバイテンポラルデータモデルに使うクラスを作る。
/** * ビジネス的な有効時間 */ @Embeddable public class ValidTime { @Column private final LocalDateTime businessIn; @Column private final LocalDateTime businessOut; private static final LocalDateTime MAX = LocalDateTime.of(9999, 12, 31, 23, 59, 59); ValidTime(LocalDateTime businessIn, LocalDateTime businessOut) { this.businessIn = businessIn; this.businessOut = businessOut; } public static ValidTime create(LocalDateTime in, LocalDateTime out) { return new ValidTime(in, out); } public static ValidTime createInfinity(LocalDateTime in) { return create(in, LocalDateTime.MAX); } public ValidTime terminate(LocalDateTime time) { return new ValidTime(businessIn, time); } public LocalDateTime businessIn() { return businessIn; } public LocalDateTime businessOut() { return businessOut; } } * システム的な有効時間 */ @Embeddable public class TransactionTime { @Column private final LocalDateTime processFrom; @Column private final LocalDateTime processThru; static final LocalDateTime MAX = LocalDateTime.of(9999, 12, 31, 23, 59, 59); TransactionTime(LocalDateTime processFrom, LocalDateTime processThru) { this.processFrom = processFrom; this.processThru = processThru; } public static TransactionTime create(LocalDateTime time) { return new TransactionTime(time, MAX); } public TerminatedTransactionTime terminate(LocalDateTime time) { return new TerminatedTransactionTime( new TransactionTime(time, MAX), new TransactionTime(processFrom, time)); } public LocalDateTime processFrom() { return processFrom; } public LocalDateTime processThru() { return processThru; } } /** * システム的な変更時のシステム時間の変化 */ public class TerminatedTransactionTime { private final TransactionTime newTransaction; private final TransactionTime terminatedTransaction; TerminatedTransactionTime(TransactionTime newTransaction, TransactionTime terminatedTransaction) { this.newTransaction = newTransaction; this.terminatedTransaction = terminatedTransaction; } public TransactionTime newTransaction() { return newTransaction; } public TransactionTime terminatedTransaction() { return terminatedTransaction; } }
システム的な時間の最新のレコードはprocess_thruに最大値を入れる。修正が入った場合は無効化するレコードのprocess_thruに現在時刻をいれて、現在時刻-最大値のレコードをinsertする。そのためのTransactionTimeのペアをTerminatedTransactionTimeとして作っておいた。
修正時のレコードの動きは後で解説する。
Daoを作る
@Dao @ConfigAutowireable public interface CouponRepository { @Insert Result<Coupon> insertIdIncrement(Coupon coupon); @Insert Result<Coupon> insert(Coupon coupon); @Update(sqlFile = true) Result<Coupon> update(Coupon coupon); @Select Optional<Coupon> findNewestById(Id<Coupon> id); @Select List<Coupon> findByValidTime(LocalDateTime time); @Select List<Coupon> findByValidAndTransactionTime(LocalDateTime time); }
insert.sql
insert into coupon(id, process_from, process_thru, business_in, business_out, amount, user_id) value ( /*coupon.id*/0, /*coupon.transactionTime.processFrom*/'9999-12-31 23:59:59', /*coupon.transactionTime.processThru*/'9999-12-31 23:59:59', /*coupon.validTime.businessIn*/'9999-12-31 23:59:59', /*coupon.validTime.businessOut*/'9999-12-31 23:59:59', /*coupon.amount*/0, /*coupon.userId*/0);
クーポンのデータを書き換えるときにidを指定してレコードを積み上げるときに使うinsert文
update.sql
update coupon set amount = /*coupon.amount*/0, user_id = /*coupon.userId*/0, process_from = /*coupon.transactionTime.processFrom*/'', process_thru = /*coupon.transactionTime.processThru*/'', business_in = /*coupon.validTime.businessIn*/'', business_out = /*coupon.validTime.businessOut*/'' where id = /*coupon.id*/1 and process_thru = '9999-12-31 23:59:59'
最新のレコードを編集するときのupdate文。process_thruが最大値になっているものが最新として扱っている。
クーポンの発行と修正
@Transactional @Service public class CouponService { final CouponRepository repository; final TimeSignal timeSignal; public CouponService(CouponRepository repository, TimeSignal timeSignal) { this.repository = repository; this.timeSignal = timeSignal; } /** * 7日間有効なクーポンを発行 * @param userId user * @param amount amount */ public Id<Coupon> giftToUser(Id<User> userId, CouponAmount amount) { LocalDateTime now = timeSignal.now(); Coupon coupon = new Coupon(Id.notAssigned(), TransactionTime.create(now), ValidTime.create(now, now.plusDays(7)), amount, userId); Result<Coupon> result = repository.insertIdIncrement(coupon); return result.getEntity().getId(); } /** * amountを修正します * @param id coupon id * @param fixedAmount 修正後のamount */ public void fixAmount(Id<Coupon> id, CouponAmount fixedAmount) { LocalDateTime now = timeSignal.now(); Coupon coupon = repository.findNewestById(id).orElseThrow(() -> new RuntimeException("record not found")); TerminatedTransactionTime terminate = coupon.getTransactionTime().terminate(now); // terminatedTransactionを入れて期限切れにする repository.update(new Coupon(coupon.getId(), terminate.terminatedTransaction(), coupon.getValidTime(), coupon.getAmount(), coupon.getUserId())); // newTransactionで新しくinsertする。修正した項目以外は前のコピー repository.insert(new Coupon(coupon.getId(), terminate.newTransaction(), coupon.getValidTime(), fixedAmount, coupon.getUserId())); } }
クーポン発行
クーポンを発行する場合は単純にinsertするだけでよい。こんな感じにレコードが挿入される。
id | amount | user_id | process_from | process_thru | business_in | business_out |
---|---|---|---|---|---|---|
1 | 1000 | 1 | 2019-03-10 00:00:00 | 9999-12-31 23:59:59 | 2019-03-10 00:00:00 | 2019-03-17 00:00:00 |
クーポンの発行額の修正
現在の最新のrocess_thruに現在時刻をいれて、新しいレコードを挿入する。
レコードの動きとしてはこんなかんじ。
id | amount | user_id | process_from | process_thru | business_in | business_out |
---|---|---|---|---|---|---|
1 | 1000 | 1 | 2019-03-10 00:00:00 | 2019-03-11 00:00:00 | 2019-03-10 00:00:00 | 2019-03-17 00:00:00 |
1 | 1500 | 1 | 2019-03-11 00:00:00 | 9999-12-31 23:59:59 | 2019-03-10 00:00:00 | 2019-03-17 00:00:00 |
process_thruとprocess_fromで時間がチェーンしていくイメージ。ちゃんと同じ時間を入れないと一瞬クーポンが消えている時間が生まれるのでLocalDateTime.now()を別に呼び出さないように注意。
ビジネスベースでクーポン一覧の取得
あるビジネス時間軸でのクーポン一覧を取得するときは以下のようなクエリになる。
select /*%expand*/* from coupon where process_thru = '9999-12-31 23:59:59' -- システム的な最新値に絞る and business_in <= /*time*/'2019-01-01 00:00:00' and /*time*/'2019-01-01 00:00:00' < business_out
このようなクエリを打つと、11日にクーポンの値段を1000->1500に変更していても10日に有効なクーポンを調べると変更後の1500のクーポンが出てくる。
システムベースでクーポン一覧の取得
11日にクーポンの値段を1000->1500に変更していて、10日時点で1000が有効になっていたことを知りたい場合は以下のようなクエリを打つと良い。簡単ですね。
select /*%expand*/* from coupon where process_from <= /*time*/'2019-01-01 00:00:00' and /*time*/'2019-01-01 00:00:00' < process_thru and business_in <= /*time*/'2019-01-01 00:00:00' and /*time*/'2019-01-01 00:00:00' < business_out
疑問:単純に打ち消したりする方法でよいのでは?
銀行の通帳のように赤黒処理をすることで履歴を残すという方法もある。以下のような例。
id | amount | user_id | occurred_at |
---|---|---|---|
1 | 1000 | 1 | 2019-03-10 00:00:00 |
2 | -1000 | 1 | 2019-03-11 00:00:00 |
3 | 1500 | 1 | 2019-03-11 00:00:00 |
これで解決できる場合はこの方法で良さそう。バイテンポラルデータモデルは難しいしできるだけ単純な方法で解決できる方が当然良い。
ECサイトの売上などで次のシナリオを考える必要が出てきた場合とたんに難しくなる。
例が強引なのは許してほしい。いい例が思いつかなかった…!
- キャンセルが発生するケースがある
- 売上金の発生元が知りたい
売上金テーブル
id | amount | user_id | occurred_at | 発生理由 |
---|---|---|---|---|
1 | 1000 | 1 | 2019-03-10 00:00:00 | 売上 |
2 | 100 | 1 | 2019-03-10 00:00:00 | 売上10%増しキャンペーン |
3 | -1100 | 1 | 2019-03-10 00:00:00 | 振込 |
4 | 1100 | 1 | 2019-03-10 00:00:00 | 振込キャンセル |
振込キャンセルテーブル
id | plus | minus |
---|---|---|
1 | 3 | 4 |
こういったキャンセル系はリレーションを再帰的にたどっていかないといけなくなるのでRDBが苦手とする問題だとおもう。バイテンポラルデータモデルの場合は追いやすいイメージ。多分こんなかんじ?
id | amount | user_id | 発生理由 | process_from | process_thru | business_in | business_out |
---|---|---|---|---|---|---|---|
1 | 1000 | 1 | 売上 | 2019-03-10 00:00:00 | 9999-12-31 23:59:59 | 2019-03-10 00:00:00 | 9999-12-31 23:59:59 |
2 | 100 | 1 | 売上10%増しキャンペーン | 2019-03-10 00:00:00 | 9999-12-31 23:59:59 | 2019-03-10 00:00:00 | 9999-12-31 23:59:59 |
3 | -1100 | 1 | 振込 | 2019-03-10 00:00:00 | 2019-03-10 00:00:00 | 2019-03-10 00:00:00 | 9999-12-31 23:59:59 |
3 | 0 | 1 | 振込キャンセル | 2019-03-10 00:00:00 | 9999-12-31 23:59:59 | 2019-03-10 00:00:00 | 9999-12-31 23:59:59 |
最後に
とりあえず素振りしてみた系エントリでした。
サンプルコードは以下のリポジトリにおいてます。