最近コードを書くときに考えていること

最近Rubyを書くことから少し離れてKotlinをメインに書くようになったので、最近どんな事を考えてコードを書いているか書いてみる。

ValueObjectに注目する

とにかくクラスをサボらず作る。そうすると大体はValueObjectになる。ロジックの置き場はとにかくValueObjectにおいていく(というか自然な場所を探すと大体そうなる気がする)
Listみたいなコレクションもクラスにラップする。区分の組み合わせから値を導き出すとかよくあるロジックだけど、抽象度がだいぶ低いので隠して名前をつけたい。
具体的な例で言うとOpenID Connectのresponse_typeとか。

response_typeの例

openid-foundation-japan.github.io

OpenID Connectの仕様によると、response_type(3種類の列挙値)は組み合わせ可能で、組み合わせによって選択されるフローがかわる。しかも存在しない組み合わせもある(!)
ResponseTypesにListみたいなのをもたせて GrantType grantType() みたいなメソッド生やすのが良いのかなーとか思ったり。

永続化層をできるだけ意識させない

RDBに保存するとかHibernateやMybatis使うとかそのへんをあまり意識させないようにする。infrastructure層から外にライブラリの都合を出したくないなー。ORMを置き換えたりデータストアを置き換えるのはそうそうないと思うけど、なんだか気持ち悪いや。
生やすメソッドはRDBのRepositoryならjava.util.Listっぽいかんじ。RedisみたいなKVSならMapっぽい感じのメソッドがあるとよさそう〜。あとfindByXXX系のfinder系のメソッド。
Transactionはapplication層でかけちゃうけどこれいいのかな・・・これってインフラ都合じゃね・・・?とか思わなかったり。

クラスの依存関係に気を配る

ArchUnitを入れてレイヤー間の依存関係、サブパッケージ間の循環依存に気を配れるようにする。テストが落ちたらなにかモデリングがまずそうなので考え直す。

値の範囲に気をつける

ValueObjectの取りうる範囲が分かっていると考えることが一気に減るので考えるのが楽になる。区分なら列挙だし、期間なら小さい日付から大きい日付の間、個数なら0以上、X以下になりそう。
システムのリプレースならGROUP BYやMIN/MAXをDBに打って値の取りうる範囲を見つける。まぁ大体変な値が入っていてDBのお掃除をするところから始まったりする。 GROUP BYを打ったら謎の区分が出てきて膝から崩れ落ちる回とかも発生しがちなので心を強く持つ。
BIG QUERYにデータが乗っているとかあれば、NOT LIKE '%XXX%' みたいな感じでバリデーションかけられるか調べてみても良さそう。BIG QUERYなら謎の技術でめっちゃ早い。indexとか存在しない世界なのでとりあえず殴っていける。

おわり

たぶんこんな事考えながらコードを書いたりしてる。ところでDDDの境界付けられたコンテキストの概念難しいんだけど、おれきゅーさんにおしえてくれませんか?😭

技術的な判断をするときの考え方を文字にしてみる

会社に新卒が入ってきて、判断をするときにこういうこと考えるといいよねーってことを伝えられるように、いつも考えてることを文字に起こしてみる。

作る必要ある?

何かしらを作ると作っただけ複雑になる。なにもないのが一番単純。作らないのが一番楽だよね〜。
だから作らずに目的達成できるならそっちのほうがいいし、もしかしたらそもそもいらないかも。何もしなくて良いなら絶対そっちがいい!
働かなくて良い道を選びたい。

これは取り返しがつく選択?

なにか技術的な選択を迫られたとき、取り返しがつくかどうかを考える。
あとからもう片方の選択肢に切り替えられるなら、まぁどちらでもいいかなー。メリット・デメリットの天秤にはかけるけど、取り返しつくし。
AとBの選択肢があって、A→Bの切り替えはできるけどB→Aにするのは難しいといった場合は、Aを選ぶことが多いかも。もちろん天秤にかけたあとで。判断がどうしてもできない場合は取り返しが付きやすい方を選んでおいたほうが失敗したときにリカバリーしやすい。
どちらに転んでもリスクが小さい場合(たとえばlintを入れるか入れないかとか)は「どっちでもいいし、強いモチベーションがない」とかを明言しつつ試しにやってみて微妙ならやめよう!みたいな感じで良さそう。

運用するとき困らない?

人間が頑張って運用でカバー!は最後の選択肢。人間はミスるし、そういう面倒な作業は精神的にしんどい。楽しくない。
あと積み重なると気づいたときにはタスクに押しつぶされてたりする。運用も一緒に考える。

あとから変更するときこまらない?

区分を増やすとか処理をふやすとか、ある程度想定のつくものを増やそうとしたときどこを編集すればいいか考えておく。
「こことこことここを変えないといけない!(すごい散らばった場所に対して」とか変更漏れ発生しそうだなーと思ったら考え直す。

普通を好む

フレームワークの普通とかRFCに書いてある普通とかそういったものを好む。こういったものを作っている人は大体は自分たちより経験値を持っていてすごいエンジニア。できる限りこれにそって作ったほうが良い。オレオレの何かをやると大体のケースでバグったりセキュリティホール作ったりする。
フレームワーク文脈でいえばリフレクションとかで「privateなメソッド呼んでやったぜ!」とかドヤ顔してしまうとほぼ確実に痛い目を見る。普通に使わない人は互換性のサポート対象外だ。

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

最後に

とりあえず素振りしてみた系エントリでした。
サンプルコードは以下のリポジトリにおいてます。

github.com