search
LoginSignup
2

More than 1 year has passed since last update.

posted at

updated at

【Day 18】OR マッパと DB マイグレーションツールを導入【じゃんけんアドカレ】

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


前回は Spring Framework を導入しました。
今回は OR マッパと DB マイグレーションツールを導入して、DB まわりを整理していこうと思います。

ツールの選択

まず最初にどんなツールを使うかそれぞれ決めていこうと思います。

OR マッパの選択

OR マッパの種類

以前の記事にも書きましたが、「Java ORマッパー選定のポイント #jsug」というスライドを参考にすると、広義の OR マッパには以下の 4 種類があります。

  • JDBC ラッパ型 (Spring JDBC など)
  • SQL マッパ型 (MyBatis など)
  • クエリビルダ型 (jOOQ など)
  • (狭義の) OR マッパ型 (JPA など)

現状は JDBC ラッパ型に近いライブラリを自前で実装していますが、より高度な OR マッパを導入したいところです。

上記のスライドにも書かれていますが、(狭義の) OR マッパとして挙げられる JPA は、使用の難易度が非常に高いため、特別な理由がない限りオススメしません

なので、選択肢に残るのは SQL マッパ型とクエリビルダ型になります。

SQL マッパ型 vs クエリビルダ型

SQL マッパ型とクエリビルダ型のメリット・デメリットは以下のようになると思います。

  • SQL マッパ型
    • メリット ... SQL をそのまま書くため、複雑な SQL でも記述しやすい。また、OR マッパの学習コストが低い
    • デメリット ... 実行時まで SQL の文法が正しいか保証されない
  • クエリビルダ型
    • メリット ... 型安全に記述できる
    • デメリット ... 複雑な SQL の記述は SQL マッパ型より大変。DB のスキーマからコード自動生成を行ったりするため、セットアップのコストが高い

このどちらのタイプであっても、実績豊富なものを採用すれば問題ないと思います。

個人的には Java の静的型付けを生かせるクエリビルダ型の方がオススメなので、今回はクエリビルダ型の OR マッパである jOOQ を導入します。
(自分は Java の OR マッパで何か 1 つ選べと言われたら jOOQ をオススメします)

※ SQL マッパ型とクエリビルダ型の両方の機能を持つ DBFlute という OR マッパもありますが、jOOQ と異なり Spring のスターターが提供されていないため、自前でセットアップする範囲が少し広くなると思われます

マイグレーションツールの選択

次に、マイグレーションツールとして何を選ぶかを決めます。

Java の DB マイグレーションツールはいくつかありますが、実績が豊富かつ Spring との連携が簡単な Flyway を使うことにします。

ちなみに、Flyway は並列での実行も制御してくれるので、冗長構成やオートスケールとも共存可能です。

jOOQ と Flyway の導入

では、jOOQ と Flyway を導入していきます。

依存関係の追加

まずは build.gradle に jOOQ と Flyway を追加します。

dependencies {
    :
    implementation 'org.springframework.boot:spring-boot-starter-jooq'
    implementation 'org.flywaydb:flyway-core'
    :

jOOQ や Flyway を使う際は追加でいくつか Gradle の設定が必要です。
少し長くなるのでこちらには掲載しませんが、気になる方は こちら を参照ください。

これらの設定や jOOQ、Flyway の使い方のポイントについては、「jOOQで自動生成したJavaコードの管理方法&ついでにFlywayのサンプル」という記事が参考になります。

jOOQ による Repository の実装

では、jOOQ を使って Repository を実装します。
ついでに、トランザクション管理も自前の TransactionManager から Spring の機能に置き換えます。

JankenMySQLRepository は以下のようになりました。

@Repository
@AllArgsConstructor
public class JankenMySQLRepository implements JankenRepository {

    private static final JANKENS_TABLE J = JANKENS_TABLE.JANKENS.as("J");
    private static final JANKEN_DETAILS_TABLE JD = JANKEN_DETAILS_TABLE.JANKEN_DETAILS.as("JD");

    private DSLContext db;

    @Override
    public List<Janken> findAllOrderByPlayedAt() {
        val groups = selectFrom()
                .orderBy(J.PLAYED_AT)
                .fetchGroups(J);

        return groups2Models(groups);
    }

    @Override
    public Optional<Janken> findById(String id) {
        val groups = selectFrom()
                .where(J.ID.eq(id))
                .fetchGroups(J);

        return groups2Models(groups).stream()
                .findFirst();
    }
    :
    private SelectJoinStep<Record7<String, LocalDateTime, String, String, String, UInteger, UInteger>> selectFrom() {
        return db.select(J.ID, J.PLAYED_AT, JD.ID, JD.JANKEN_ID, JD.PLAYER_ID, JD.HAND, JD.RESULT)
                .from(J)
                .innerJoin(JD)
                .on(J.ID.eq(JD.JANKEN_ID));
    }

    private List<Janken> groups2Models(Map<JankensRecord, Result<Record7<String, LocalDateTime, String, String, String, UInteger, UInteger>>> groups) {
        return groups.entrySet().stream()
                .map(this::group2Model)
                .collect(Collectors.toList());
    }

    private Janken group2Model(Map.Entry<JankensRecord, Result<Record7<String, LocalDateTime, String, String, String, UInteger, UInteger>>> group) {
        val j = group.getKey();

        val jankenDetails = group.getValue().stream()
                .map(jd -> new JankenDetail(
                        jd.get(JD.ID),
                        jd.get(JD.JANKEN_ID),
                        jd.get(JD.PLAYER_ID),
                        Hand.of(jd.get(JD.HAND).intValue()),
                        com.example.janken.domain.model.janken.Result.of(jd.get(JD.RESULT).intValue())))
                .sorted(Comparator.comparing(JankenDetail::getId))
                .collect(Collectors.toList());

        return new Janken(
                j.getId(),
                j.getPlayedAt(),
                jankenDetails.get(0),
                jankenDetails.get(1));
    }
    :
}

ジェネリクスがちょっと難しそうに見えますが、慣れればそれほど難しくはありません。1

ポイントとしては、SELECT・FROM・JOIN までを selectFrom というメソッドに切り出していることが挙げられます。

    private SelectJoinStep<Record7<String, LocalDateTime, String, String, String, UInteger, UInteger>> selectFrom() {
        return db.select(J.ID, J.PLAYED_AT, JD.ID, JD.JANKEN_ID, JD.PLAYER_ID, JD.HAND, JD.RESULT)
                .from(J)
                .innerJoin(JD)
                .on(J.ID.eq(JD.JANKEN_ID));
    }

以前の記事「【Day 13】集約と Repository パターンの導入【じゃんけんアドカレ】」にも書きましたが、Repository は集約単位で作成し、集約のコレクションのように使えるようにするのが実装のコツです。

そうすると、find 系のメソッドの戻り値は集約の List や Optional になり、SELECT・FROM・JOIN までが全てのクエリで一致するようになります。

@Repository
@AllArgsConstructor
public class JankenMySQLRepository implements JankenRepository {
    :
    @Override
    public List<Janken> findAllOrderByPlayedAt() {
        val groups = selectFrom()
                .orderBy(J.PLAYED_AT)
                .fetchGroups(J);

        return groups2Models(groups);
    }

    @Override
    public Optional<Janken> findById(String id) {
        val groups = selectFrom()
                .where(J.ID.eq(id))
                .fetchGroups(J);

        return groups2Models(groups).stream()
                .findFirst();
    }
    :

このように、Repository が扱う単位を集約というサイズに限定すると、Repository の実装が単純化されてかなり見通しが良くなります
また、アプリケーション層で集約を扱うことが強制され、アプリケーション層やドメイン層のコードが整理されやすくなる場合もあります

「DB から一部のデータだけを取得したいのに集約全体を取得するのは効率が悪い」と思われるかもしれませんが、パフォーマンスが特に重要なシステムでない限りは、許容範囲であることがほとんどだと思います。

どうしてもパフォーマンス上の問題が発生し、必要最小限のデータだけを取得するようにしたいといった場合は、そもそものシステム設計を見直すか、次回導入する QueryService の利用を検討した方がいいかもしれません。

ApplicationService の修正

トランザクション管理を Spring の機能に変更したので、JankenApplicationService は以下のようになりました。

@Service
@Transactional
@AllArgsConstructor
public class JankenApplicationService {

    private JankenRepository jankenRepository;
    private PlayerRepository playerRepository;

    /**
     * じゃんけんを実行し、結果を保存して、勝者を返します。
     */
    public Optional<Player> play(String player1Id, Hand player1Hand,
                                 String player2Id, Hand player2Hand) {

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

        jankenRepository.save(janken);

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

}

自前で実装していた TransactionManager を使う必要がなくなり、@Transactional というアノテーションを付けるだけでトランザクションが実現されるようになっています。

Flyway のセットアップ

Spring での Flyway のセットアップは非常に簡単です。

SQL を app/src/main/resources/db/migration 以下に特定の命名規則で配置するだけです。

$ tree app/src/main/resources/db/migration/
app/src/main/resources/db/migration/
└── V1_0_0__init.sql

0 directories, 1 file

これだけで、Spring の起動時や ./gradlew flywayMigrate の実行時に上記の SQL が自動で実行されます。

もしもすでに運用している DB がある状態で Flyway を導入する場合でも、application.yaml に以下のような設定を追加するだけです。

spring:
  flyway:
    baseline-on-migrate: true
    baseline-version: 1.0.1

ローカルの MySQL のマイグレーション

もともと、ローカルで開発する際は、MySQL の公式 Docker イメージの docker-entrypoint-initdb.d の機能を使って SQL を実行していました。

docker-compose.yaml
version: '3'
services:
  mysql:
    image: mysql:8.0.22
    ports:
      - 3306:3306
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: janken
      MYSQL_USER: user
      MYSQL_PASSWORD: password
    volumes:
      - ${PWD}/mysql/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d

この機能を使う必要はなくなったので、volumes の部分を削除します。

docker-compose.yaml
version: '3'
services:
  mysql:
    image: mysql:8.0.22
    ports:
      - 3306:3306
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: janken
      MYSQL_USER: user
      MYSQL_PASSWORD: password

あとは必要なタイミングでマイグレーションを実行してあげれば、ローカルでの DB の管理も問題ありません。

このように、Flyway の導入は非常に簡単です。
こういったツールの導入はやってみるまでは腰が重いものですが、実は想像よりずっと簡単だということが少なくありません。
気になった方は是非さわってみてください。

次回のテーマ

今回は jOOQ と Flyway を導入し、DB まわりがかなり整理されました。

途中では、改めて Repository について考えました。

Repository は集約の単位でデータを扱うのには適している一方で、実は苦手なこともあります。
分かりやすいのは、「集約をまたがるクエリを実行したいときはどうするか」だと思います。

次回は QueryService というクラスを設けて、Repository の課題を解決しようと思います

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

現時点のコード

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


  1. Kotlin などの言語であれば、typealias を使ってジェネリクスの部分をもっと簡潔に書けたりします 

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
What you can do with signing up
2