5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

じゃんけんAdvent Calendar 2020

Day 14

【Day 14】ID の採番問題【じゃんけんアドカレ】

Last updated at Posted at 2020-12-13

じゃんけんアドベントカレンダー の 14 日目です。


前回、集約と Repository パターンを導入することでモデルの整合性を確保しやすくしました。
モデルをより安全にするため、今回は ID の採番に関する問題を解決しようと思います。

現状の確認

まずはコードの該当箇所を確認してみます。

Janken を生成する箇所のコードは以下のようになっています。

:
public class Janken {

    public static Janken play(Long player1Id, Hand player1Hand,
                              Long player2Id, Hand player2Hand) {
        :
        // じゃんけん明細を生成

        val detail1 = new JankenDetail(null, null, player1Id, player1Hand, player1Result);
        val detail2 = new JankenDetail(null, null, player2Id, player2Hand, player2Result);

        // じゃんけんを生成

        val playedAt = LocalDateTime.now();
        return new Janken(null, playedAt, detail1, detail2);
    }
    :

上記のコードは、JankenApplicationService で以下のように使われています。

public class JankenApplicationService {
    :
    public Optional<Player> play(long player1Id, Hand player1Hand,
                                 long player2Id, Hand player2Hand) {

        return tm.transactional(tx -> {

            val janken = Janken.play(player1Id, player1Hand, player2Id, player2Hand);

            jankenRepository.save(tx, janken);

            return janken.winnerPlayerId()
                    .map(playerId -> playerRepository.findPlayerById(tx, playerId));
        });

    }

}

ここで気になるのは、Janken インスタンスや JankenDetail インスタンスを生成した時点では、ID が NULL になっていることです。

現在、データベースの自動採番機能を使って ID を割り当てているため、データベースに保存するまでは ID が NULL になってしまうわけです。

ID が NULL となっていると、予期せず NullPointerException を引き起こしたり、equeals メソッドでの比較や Set への追加でプログラマの想定に反する動作をしてしまう場合があります。

なので、可能であれば ID が NULL な状態がないよう、インスタンスを生成した時点で ID を割り当てた方が安全です。

ID 採番のパターン

ここで、ID 採番のパターンについて整理してみます。

ID の採番については、書籍『実践ドメイン駆動設計』に以下の 4 つのパターンがまとめられています。

  1. ユーザが ID を指定する
  2. アプリケーションが ID を生成する
  3. 永続メカニズムが ID を生成する
  4. 別の境界づけられたコンテキストが ID を割り当てる

※ 書籍の中では「識別子」という単語が使われていますが、この記事では統一して「ID」という単語を使わせていただきます

各パターンについて詳しくは書籍を参照していただくとして、それぞれの簡単な説明と、書籍にない考察を書いていきます。

1. ユーザが ID を指定する

まずは、データを登録する際にユーザが一意な ID を指定する方法です。
この方法が採用できる場合は、Janken などのインスタンス化の際に ID が NULL になることはなくなります

ですが、この方法は「システム内部で利用する ID は、後から変更できないものにしておいた方が良い」ことと相性が悪いです。
ユーザに入力された値は、後から変更できるようにしたいことが多いです。

そう考えると、ユーザに ID を指定させることができる場面は多くないのではないでしょうか。

2. アプリケーションが ID を生成する

次に、アプリケーション内部で ID を生成する方法です。
この方法でも、Janken などのインスタンス化の際に ID が NULL になることはなくなります

UUID の利用

生成する値としては、例えば UUID などを使うことができます。
ID の値が UUID で構わないのであれば、手軽に安全なアプリケーションを実装可能になります。

ただし、UUID を使うことには、DB の自動採番を使う場合と比べて以下のようなデメリットもあると言われています。

  • 長い文字列のため、データサイズが大きくなる
  • 連番の場合よりも、ソート関係の処理によってパフォーマンスが低下しうる
  • 視覚的に分かりにくい

UUID を使うことと DB の自動採番を使うことの比較については、以下の記事が参考になります。

アプリケーションで連番を採番できるか

「UUID ではなく、アプリケーション上で連番を払い出せば良いのではないか」と思われる方もいらっしゃるかもしれません。

結論としては、アプリケーション上で連番を払い出す方針は、何らかの特殊な事情がある場合を除いてオススメしません。

マルチスレッドやアプリケーション自体が並列稼働しうる場面で、一意な連番を採番するのは非常に難しいです。
一意な連番を使いたい場合は、DB の機能などを利用してください。1

アプリケーションの並列処理を一切考慮しなくていい場合はこの方法も採用できるかもしれませんが、一般的な Web アプリケーション等では難しいです。

3. 永続メカニズムが ID を生成する

続いて、DB が ID を生成する方式についてです。

これは以下の 2 つの方法があります。

  • INSERT 時に ID を払い出す
  • 事前に ID を払い出してからインスタンス化して保存する

INSERT 時に ID を払い出す

まず、INSERT 時に ID を払い出す方式についてです。
これは、じゃんけんアプリケーションの現状の実装で採用している方式ですし、よく見かける手法です。

ですが、この記事の最初に説明した通り、Janken などのインスタンスを生成した時点では ID が NULL になってしまうというデメリットがあります。

事前に ID を払い出してからインスタンス化して保存する

DB から何らかの方法で ID を取得して、後から INSERT するという方法です。
アプリケーション側の実装としては、Repository に ID を返す nextIdentity() のようなメソッドを設け、その値を使ってインスタンスを生成することになります。

このパターンであれば、Janken などのインスタンス生成時に ID が NULL になることを避けられます

ただし、採用する DB によってできる・できないがありますし、できるとしてもある程度手間がかかる場合もあります。

4. 別の境界づけられたコンテキストが ID を割り当てる

最後の方法は、別システムに ID を割り当てさせるというものです。
これは発展的な方法であり、また、結局別システムの方でどう採番するのかという問題があると思うので、深堀はしないことにします。

結局どの方法がいいのか

このように、実は ID の採番は DB の自動採番機能ですればいいとは限らないのです。

私の考えとしては、もし UUID を使ってよいのであれば、アプリケーションで UUID を払い出すのが一番簡単で安全なコードになると思います。

とはいえ、実際には DB の自動採番機能を使うことも多いと思うので、次はその際にできる工夫を考えていきます。

DB の自動採番を使うときの実装の工夫

DB の自動採番を使いつつ、なるべくアプリケーションを安全なコードにするために考えられる対応は以下の 3 つです。2

  • Optional で扱う
  • 保存するまでインスタンス化しない
  • ID 採番前のクラスを作成する

順に説明していきます。

Optional で扱う

まずは ID を Optional にするというものです。

例えば Janken クラスは以下のようになります。

:
public class Janken {
    :
    private Optional<Long> maybeId;
    private LocalDateTime playedAt;
    private JankenDetail detail1;
    private JankenDetail detail2;
    :
}

このように Optional を使うことで、「予期せぬ」NullPointerException を避けられるようにはなり、使わないよりは多少マシかもしれません。

しかし、Optional から取り出す箇所で ID 採番済みかを考えないといけなくなりますし、コードを並び順を変えるだけで動作しなくなる可能性もあります。

また、ID という、存在することが多いはずの値を Optional にしてしまうと、面倒なのでよく考えず強引に取り出すコードを書かれてしまう可能性もあります。

さらに言えば、equals での比較や Set への登録が想定外の挙動になりうることは解決していません。

保存するまでインスタンス化しない

次に、DB に保存して ID を払い出すまでインスタンス化しないという方法です。

この場合、Repository に保存するときに、引数としてインスタンスを渡せなくなり、Repository の呼び出しは以下のようになります。

            val janken = jankenRepository.save(tx, playedAt, player1Id, player1Hand, player1Result, player2Id, player2hand, player2Result);

確かに ID が NULL の Janken インスタンスの存在はなくなりましたが、代わりに Repository のインタフェースが非常に複雑になってしまいました。
これは結構苦しいです。

さらに、Repository に保存するまでに Janken などのインスタンスのメソッドを使うことができなるので、オブジェクト指向的なコードが書きにくくなります。

ID 採番前のクラスを作成する

ID 採番前の、ID を持たないバージョンのクラスを実装することも考えられます。

以下のようなイメージです。

@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class JankenWithoutId {
    private LocalDateTime playedAt;
    private JankenDetailWithoutId detail1;
    private JankenDetailWithoutId detail2;
}

Repository の呼び出しは以下のように簡単になります。

            val janken = jankenRepository.save(tx, jankenWithoutId);

しかし、この方法では多くのクラスに対応した ID がないだけのクラスを重複して作ることになり、メソッドも重複してしまうかもしれません。
たしかに ID が NULL の状態がないという安全性は得られますが、代償が大きいのではないかと思います。

このように、DB の自動採番で ID を払い出す場合は、コード上の工夫で ID が NULL ではないという安全性を確保するのは大変です。
DB の自動採番を使う場合は、コードを書く上でのルールやテストなどで保証していくことになると思います。

今回はどうするか

さて、ID の採番について検討が長くなりましたが、今回は一番簡単に実装できる、アプリケーション上で UUID を払い出す方法をやってみようと思います。

※ 上にも書いたように ID として UUID を使うことにもデメリットがあるので、絶対にこうすべきというものではないです。

コードの修正

Janken と JankenDetail のインスタンス生成時に UUID を作って使うようにコードを修正しました。

:
public class Janken {

    public static Janken play(String player1Id, Hand player1Hand,
                              String player2Id, Hand player2Hand) {
        // 勝敗判定

        Result player1Result;
        Result player2Result;

        if (player1Hand.wins(player2Hand)) {
            player1Result = Result.WIN;
            player2Result = Result.LOSE;

        } else if (player2Hand.wins(player1Hand)) {
            player1Result = Result.LOSE;
            player2Result = Result.WIN;

        } else {
            player1Result = Result.DRAW;
            player2Result = Result.DRAW;
        }

        // ID を生成

        val jankenId = generateId();
        val jankenDetail1Id = generateId();
        val jankenDetail2Id = generateId();

        // じゃんけん明細を生成

        val detail1 = new JankenDetail(jankenDetail1Id, jankenId, player1Id, player1Hand, player1Result);
        val detail2 = new JankenDetail(jankenDetail2Id, jankenId, player2Id, player2Hand, player2Result);

        // じゃんけんを生成

        val playedAt = LocalDateTime.now();
        return new Janken(jankenId, playedAt, detail1, detail2);
    }

    private static String generateId() {
        return UUID.randomUUID().toString();
    }
   :

これで、Janken や JankenDetail のインスタンスを生成した時点で ID が NULL でなくなりました。

DB の自動採番を使わなくなったことにより、Repository が採番された ID を返す必要もなくなり、JankenMySQLRepository のコードもシンプルになりました。

public class JankenMySQLRepository implements JankenRepository {
    :
    @Override
    public void save(Transaction tx, Janken janken) {
        jankenDao.insert(tx, janken);
        jankenDetailDao.insertAll(tx, janken.details());
    }

}

次回のテーマ

今回 ID の採番問題に取り組み、さらにモデルの安全性を高めることができました。

まだまだブラッシュアップできる箇所はありますが、全体的にかなり整理されたコードになってきたと思います。
そこでついに、CLI アプリケーションを卒業して Web アプリケーションに変更したいです。

......ですが、Web アプリケーションを開発するのであれば、その前にほぼモックのような状態でデプロイパイプラインを組むべきです。

ということで、次回は Web アプリケーションのデプロイパイプラインを構築しようと思います。

それでは、今回の記事はここまでにします。最後まで読んでくださりありがとうございました。
じゃんけんアドベントカレンダー に興味を持ってくださった方は、是非購読お願いします。

現時点のコード

コードは GitHub の この時点のコミット を参照ください。

  1. ただし、DB の自動採番機能では値がスキップされる場合もあるので、連番であることに依存した実装をすべきではありません

  2. 他にもこんな工夫の仕方がある、という方は是非教えてください

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?