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 |
最後に
とりあえず素振りしてみた系エントリでした。
サンプルコードは以下のリポジトリにおいてます。
エンジニアだって怖いものは怖い
バックエンド系のエンジニアってDBに入れるデータがミスってたり必要な情報が保存されていなかったりすると取り返しつかないことになるし怖いじゃないですか。特にお金とか人の命が絡んでくると絶対にやりたくないと思うんですよね。エンジニアだって人間なので感情があります。
お金とか扱おうとすると過去にさかのぼってこの情報が追えないとだめだとか、特殊フローがいろいろあったり、整合性は絶対ミスれないなんて複雑さがあったりしてつらいんですよね。そういった複雑さとか泥臭さと戦うというのは嫌いではないんですが(うまく動いたりすると楽しいし設計考えるのは楽しい)つらい気持ちで頑張っても「これって動いて当然の仕組みだしこの頑張りは報われるのかな?」って気持ちになるんですよね。
技術的選択をするときに思い出すんですが、チームメンバーが生き生きとしていられないような選択肢はまずありえないと思うんよね。
売上のためにエンジニアが犠牲になって無限に障害対応しないといけないとか無限に複雑さを抱えるとか、そういったつらい状況になっていくとエンジニアは逃げていくし、エンジニアのメンタルリスクも一緒に考慮しないといけないなーと思う最近でした。
エンジニアがいないとプロダクトは作れないし、継続もできないのでやっぱりまずはメンバーの幸せは必須条件だなー。
サーバーサイドエンジニアの仕事について考える
最近フロントエンドエンジニアの人と「フロントエンドエンジニアってこういう仕事だよねー」という話をしたので、自分が普段やっているサーバーサイドエンジニアのしごとについて考えたくなった。
完全に主観だし、主語が大きいから「サーバーサイドエンジニアの仕事」ではなく「おれきゅーの仕事」と読み替えてもらったほうが正しいかもしれない。
整合性
フロントエンドの場合デザイナやPM、バックエンドエンジニアの話をいい感じに吸収して使い勝手の良いフロントを提案したりしながらいい感じにしていく人ってイメージで、それに対してサーバーサイドエンジニアはプロダクトの整合性について責任を持つ人ってイメージ。
DBのデータに変なデータが入ったりすると場合によってはプロダクトが完全に破滅するし、軽くてもいろんなユーザーに被害が出る。被害が出なくても将来的にそれが負債になって身動きが取れなくなったりする。そうならないように全力で考える人って考えてる。
整合性の問題はDBだけではなくて、たとえばアプリに対してのAPIのスキーマとかにも出てくる。
アプリから見た場合、APIのバージョンは1つだけかもしれないがサーバーから見ると複数のアプリのバージョンから叩かれることになる。なのでjsonを返している場合、リネームやプロパティの削除が互換性の問題で一切できないと考えたほうが良い。サーバーサイドエンジニアは将来に対しての覚悟とかそういう慎重さを持ったほうが良いのかなって思ってる。
意思決定
仕事をしていると実装案が複数あり、どれか一つを決めないといけないときが出てくる。というか毎回そうなると思うんだけど僕だけだろうか?
大体の場合サーバーサイドではAPIスキーマやDBとはずっと付き合っていくことになるので大体頭を抱えることになる。そういうときの一つの指標として「後戻りしやすい方を選ぶ」というのが良さそう。
例えば「カラムのnot null制約をかけるかどうか」という話であれば、「not null制約を掛けておいたほうがnullableに変更しやすい」という点で選びやすい。これはかなり簡単な例だけどこんな感じに困ったらどちらのほうが「後戻りしやすい?」という問いをすると良さそう。
結局サーバーサイドエンジニアの仕事って何
おれきゅーはこう思って仕事してる
- 将来に備えて決定をする/責任を持つ
- 正しいデータを守る人
送金メソッドを考える
これは例だが、口座Aから口座Bへお金を送金するといった処理をどのように書くかで悩んでいる。
送金だけでなく、ユーザーAがユーザーBをフォローするといった操作でも同じように困る。
何に困るかと言うと、引数の取り違えが起こりそうだからなんとかしたいなと思っている。
前提
今回の話で前提としているのは3層+ドメインモデルのアーキテクチャで以下のリポジトリを参考にしているので参照してみてほしい。
application層に送金メソッドを作って、ControllerやBatchなどから送金を依頼されるというユースケースを考えている。
愚直に考えるとこんな感じだろうか。いろいろ省略して書いているのであしからず。
@PostMapping("transfer") public String transfer(@RequestParam long amount, @RequestParam long toAccountId, User user) { BankAccount fromBankAccount = bankService.findByUser(user); BankAccount toBankAccount = bankService.findById(Id.of(toAccountId)): TransferAmount amount = new TransferAmount(amount); // ここでfromBankAccountとtoBankAccountの順番を間違えてもコンパイルエラーにならない。間違えやすい! bankService.transfer(fromBankAccount, toBankAccount, amount); return "..."; }
ましな案を考える
FromBankAccountとToBankAccountの型を分ける
@PostMapping("transfer") public String transfer(@RequestParam long amount, @RequestParam long toAccountId, User user) { FromBankAccount fromBankAccount = new FromBankAccount(bankService.findByUser(user)); ToBankAccount toBankAccount = new ToBankAccount(bankService.findById(Id.of(toAccountId))): TransferAmount amount = new TransferAmount(amount); // 型で意図が分かりやすいので間違えにくくなる…? // FromBankAccount作るときに間違えるかもしれないけどまだマシかも。ラベル引数できればよかったのにね。 bankService.transfer(fromBankAccount, toBankAccount, amount); return "..."; }
ちょっと面倒だけど、型を作ってラベルとしての役割をもたせてみる。
順番でやるよりはコードを見たときに「あれ?これミスってね?」と気付けるチャンスが増えているのでまぁマシ。
TransferRequestを渡す
TransferRequestを作って送金依頼をするパターン。よさ。
よりラベル感があって間違えにくいかも。
@PostMapping("transfer") public String transfer(@RequestParam long amount, @RequestParam long toAccountId, User user) { BankAccount fromBankAccount = bankService.findByUser(user); BankAccount toBankAccount = bankService.findById(Id.of(toAccountId)): TransferAmount amount = new TransferAmount(amount); bankService.transfer(TransferRequest.builder().from(fromBankAccount).to(toBankAccount).amount(amount).build()); return "..."; }
BankAccountにtransferメソッドを生やす
EntityやValueObjectができる操作は限られていて、主にできることは計算/判断/変換でDBを叩いたりWebAPIを叩くことはできない。(ActiveRecordみたいに何でもやるマンにすると仕事し過ぎかなってきがする。
とはいえそう呼び出したい気持ちがあるので、TransferRequestを作るメソッドを生やす案を考えてみた。
public class BankAccount { public TransferRequest transfer(BankAccount toBankAccount, TransferAmount amount) { return new TransferRequest(this, toBankAccount, amount); } }
Controllerでの呼び出しはこんなかんじだろうか。
@PostMapping("transfer") public String transfer(@RequestParam long amount, @RequestParam long toAccountId, User user) { BankAccount fromBankAccount = bankService.findByUser(user); BankAccount toBankAccount = bankService.findById(Id.of(toAccountId)): TransferAmount amount = new TransferAmount(amount); // 送金依頼を作って送金する TransferRequest request = fromBankAccount.transferTo(toBankAccount, amount); bankService.transfer(request); return "..."; }
なんかアリな気がしないでもない。筋は良さそう?
引数を非対称にする
送金元はEntity、送金先はIdオブジェクトとすることでミス防ごうという案。
@PostMapping("transfer") public String transfer(@RequestParam long amount, @RequestParam long toAccountId, User user) { BankAccount fromBankAccount = bankService.findByUser(user); Id<BankAccount> toBankAccountId = Id.of(toAccountId): TransferAmount amount = new TransferAmount(amount); // 送金依頼を作って送金する bankService.transfer(fromBankAccount, toBankAccountId, amount); return "..."; }
これは面白いかもしれない。クラスを作るのが面倒なときとかの妥協案として選ぶのはアリ寄りのアリかもしれない。
結局
どれがいいんでしょうね?BankAccount#transferのパターンは結構筋良さそうな気がする。直感だけど。