16
12

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.

【Day 19】Repository の課題を QueryService で解決【じゃんけんアドカレ】

Last updated at Posted at 2020-12-18

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


前回 jOOQ と Flyway を導入し、DB まわりを整理しました。
その中で、Repository は集約の単位でデータを扱うのがコツだと書きました。

しかし、実際にアプリケーションを開発していると、集約をまたがったデータを取得したい場面がまず確実に登場します。
今回は QueryService を導入し、集約をまたがったデータをうまく扱えるようにします

追加する API

Repository だけだと苦しい状況を考えるため、じゃんけんアプリケーションに新しい API を 1 つ追加しようと思います。

追加するのは、プレイヤーの一覧を取得する API です。
プレイヤーの一覧画面で使うようなイメージです。

API のレスポンスとしては、以下のようにプレイヤーの ID・名前・勝利した回数の配列を返すようにします。

{
    "players": [
        {
            "id": "1",
            "name": "Alice",
            "winCount": 10,
        }
    ]
}

※ 実際の開発ではページングやソートも実施すると思いますが、今回は単純化のため省略します

それでは、この API の実装について考えていきます。

Repository で実装

Repository は集約の単位でデータを扱うものとすると、ApplicationService のコードは以下のようになりました。

@Service
@Transactional
@AllArgsConstructor
public class PlayerApplicationService {

    private PlayerRepository playerRepository;
    private JankenRepository jankenRepository;

    public PlayerFindAllOutput findAll() {
        val players = playerRepository.findAllOrderById();

        val jankens = jankenRepository.findAll();

        List<PlayerFindAllOutputPlayer> outputPlayers = players.stream()
                .map(p -> {
                    val winCount = jankens.stream()
                            .filter(j -> j.isWinner(p))
                            .count();

                    return new PlayerFindAllOutputPlayer(
                            p.getId(),
                            p.getName(),
                            winCount);
                })
                .collect(Collectors.toList());

        return new PlayerFindAllOutput(outputPlayers);
    }
    :

findAll メソッドの戻り値の型は、専用の入れ物クラス (DTO) として定義しています。

@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class PlayerFindAllOutput {
    private List<PlayerFindAllOutputPlayer> players;
}
@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class PlayerFindAllOutputPlayer {
    private String id;
    private String name;
    private long winCount;
}

PlayerApplicationService では、PlayerRepository と JankenRepository からそれぞれデータを取得し、アプリケーション上でカウント・JOIN しています

SQL でカウント・JOIN するのに比べて実装が複雑ですし、勝利数を数えるためだけにじゃんけんを全件取得することになってしまっています1

このくらいであれば許容範囲かもしれませんが、さらに他のデータも結合して返したい場合などは、どんどん処理が複雑になり、かつ DB から取得するデータサイズが大きくなるなど、多くの問題が発生していきます。

QueryService の導入

このように、Repository だけでデータの取得を完結させようとすると、アプリケーションが不必要に複雑になるなどの問題が発生します
そこで、CQRS を導入し、データをより自由に取得できるようにします

CQRS というと書き込み系と読み込み系で DB も分離したりすることを想像されることもありますが、このようなアプリケーション上の分離だけでも CQRS に含まれます

Repository と QueryService を分けたくなる理由や CQRS のメリット・デメリット等については、以下の記事が非常に分かりやすいです。

構成

今回、QueryService は、以下の構成で実装していきます。

Day19_クラス図_queryservice追加.png

アプリケーション層に QueryService のインタフェースと戻り値の型 QueryModel (DTO) を設け、インフラストラクチャ層で MySQL にクエリを実行する実装を行います。

実装

では、QueryService を実装します。

まずは application.query 以下にインタフェースを設けます。

public interface PlayerQueryService {

    PlayerListQueryModel queryAll();

}

戻り値の PlayerListQueryModel クラスは、前述の Repository による実装で作った PlayerFindAllOutput クラスをリネームして application.query 以下に移動したものです。

@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class PlayerListQueryModel {
    private List<PlayerListQueryModelPlayer> players;
}
@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class PlayerListQueryModelPlayer {
    private String id;
    private String name;
    private long winCount;
}

infrastructure.mysqlquery 以下に配置する実装クラスは以下のようになりました。

@Repository
@AllArgsConstructor
public class PlayerMySQLQueryService implements PlayerQueryService {

    private static final PLAYERS_TABLE P = PLAYERS_TABLE.PLAYERS.as("p");
    private static final JANKEN_DETAILS_TABLE JD = JANKEN_DETAILS_TABLE.JANKEN_DETAILS.as("jd");

    private DSLContext db;

    /**
     * select
     *     `p`.`id`,
     *     `p`.`name`,
     *     coalesce(`pw`.`win_count`, ?)
     * from `janken`.`players` as `p`
     * left outer join (
     *     select
     *         max(`p`.`id`) as `player_id`,
     *         count(*) as `win_count`
     *     from `janken`.`players` as `p`
     *     join `janken`.`janken_details` as `jd`
     *     on `p`.`id` = `jd`.`player_id`
     *     where `jd`.`result` = ?
     * ) as `pw`
     * on `p`.`id` = `pw`.`player_id`
     */
    @Override
    public PlayerListQueryModel queryAll() {
        val winValue = UInteger.valueOf(Result.WIN.getValue());

        val pw = db.select(
                max(P.ID).as("player_id"),
                count().as("win_count"))
                .from(P)
                .innerJoin(JD)
                .on(P.ID.eq(JD.PLAYER_ID))
                .where(JD.RESULT.eq(winValue))
                .asTable("pw");

        val players = db.select(
                P.ID,
                P.NAME,
                coalesce(pw.field("win_count"), 0))
                .from(P)
                .leftJoin(pw)
                .on(P.ID.eq(pw.field(0, String.class)))
                .fetch()
                .stream()
                .map(r -> new PlayerListQueryModelPlayer(
                        r.get(0, String.class),
                        r.get(1, String.class),
                        r.get(2, Long.class)))
                .collect(Collectors.toList());

        return new PlayerListQueryModel(players);
    }

}

PlayerApplicationService は、単に PlayerQueryService のメソッドを呼び出すだけになりました。

@Service
@Transactional
@AllArgsConstructor
public class PlayerApplicationService {

    private PlayerRepository playerRepository;
    private PlayerQueryService playerQueryService;

    public PlayerListQueryModel findAll() {
        return playerQueryService.queryAll();
    }
    :

じゃんけんの勝利数をアプリケーション上でカウント・JOIN する必要がなくなったため、ApplicationService が単純化され、パフォーマンス上の問題も発生しにくくなりました

これで QueryService の実装は完了です。

検討事項

QueryService の実装を踏まえて、以下の 4 点について検討してみようと思います。

  • Repository に任意のクエリを実行させればいいのではないか
  • アプリケーション上での JOIN ではダメか
  • ApplicationService が単純になった代わりに QueryService の実装クラスが複雑ではないか
  • QueryService のインタフェースはどの層に置くか

Repository に任意のクエリを実行させればいいのではないか

今回の実装で QueryService に実装した処理は、自然と Repository クラスで実装されている場合があります。

Repository クラスにこの役割を持たせることもできますが、QueryService と分割することで、Repository が肥大化して複雑になりすぎることを防げます

Repository は集約を扱うことに集中し、QueryModel として別の型を返却したい場合は QueryService を使うという分担です。

アプリケーション上での JOIN ではダメか

最初に書いたコードのように、QueryService を導入せずにアプリケーション上での JOIN 等で実装することもできます。

今回の内容であればアプリケーション上の JOIN でもそれほど大変ではありませんが、扱う集約がさらに増えたりすると、アプリケーション上で JOIN するコードはどんどん複雑になっていきます
また、今回の勝利数のカウントのように、取得したいデータによっては DB からロードするデータのサイズが大きくなりすぎる可能性もあります

ただし、QueryService にはデメリットもあります
具体的には、構成が複雑になるため学習 (説明) コストがかかったり、データを参照している箇所を把握しにくくなったりします
また、QueryService の実装クラスは DB と結合しているため、自動テストも比較的大変です

なので、簡単な処理はアプリケーション上での JOIN で実現してしまい、どうしてもコードが複雑になったりパフォーマンスが気になる箇所だけに QueryService を導入するといった対応も可能です。
また、非常に簡単な、例えば集約の件数をただ count する程度であれば Repository に実装することを許容する、といった考え方もあります。

ApplicationService が単純になった代わりに QueryService の実装クラスが複雑ではないか

PlayerMySQLQueryService をもう一度見てみます。

@Repository
@AllArgsConstructor
public class PlayerMySQLQueryService implements PlayerQueryService {
    :
    private static final PLAYERS_TABLE P = PLAYERS_TABLE.PLAYERS.as("p");
    private static final JANKEN_DETAILS_TABLE JD = JANKEN_DETAILS_TABLE.JANKEN_DETAILS.as("jd");

    private DSLContext db;

    /**
     * select
     *     `p`.`id`,
     *     `p`.`name`,
     *     coalesce(`pw`.`win_count`, ?)
     * from `janken`.`players` as `p`
     * left outer join (
     *     select
     *         max(`p`.`id`) as `player_id`,
     *         count(*) as `win_count`
     *     from `janken`.`players` as `p`
     *     join `janken`.`janken_details` as `jd`
     *     on `p`.`id` = `jd`.`player_id`
     *     where `jd`.`result` = ?
     * ) as `pw`
     * on `p`.`id` = `pw`.`player_id`
     */
    @Override
    public PlayerListQueryModel queryAll() {
        val winValue = UInteger.valueOf(Result.WIN.getValue());

        val pw = db.select(
                max(P.ID).as("player_id"),
                count().as("win_count"))
                .from(P)
                .innerJoin(JD)
                .on(P.ID.eq(JD.PLAYER_ID))
                .where(JD.RESULT.eq(winValue))
                .asTable("pw");

        val players = db.select(
                P.ID,
                P.NAME,
                coalesce(pw.field("win_count"), 0))
                .from(P)
                .leftJoin(pw)
                .on(P.ID.eq(pw.field(0, String.class)))
                .fetch()
                .stream()
                .map(r -> new PlayerListQueryModelPlayer(
                        r.get(0, String.class),
                        r.get(1, String.class),
                        r.get(2, Long.class)))
                .collect(Collectors.toList());

        return new PlayerListQueryModel(players);
    }

}

サブクエリを使っているため、ある程度複雑になってしまっています。

たしかに、今回の例ではアプリケーション上で JOIN したパターンのコードのほうが単純かもしれません。
ですが、プレイヤーの一覧として取得したいデータがさらに増えていったりすると、どこかの時点で SQL のほうが単純になる場合があります

とはいえ、QueryService の実装をもう少し単純にしたい気持ちはあります。
この例であれば、サブクエリの部分 (プレイヤー ID と勝利数) だけを返すメソッドを QueryService に設けて、PlayerRepository から取得したデータとアプリケーション上で JOIN する、という手段で単純化することも可能です

その場合、プレイヤーのデータを取得する箇所が PlayerRepository に一本化され、データモデル変更時の影響範囲が小さくなるというメリットもあります。

QueryService のインタフェースはどの層に置くか

最後に、QueryService のインタフェースをどの層に置くかについてです。

今回は application.query 以下に配置しましたが、今回実装した QueryService が返している QueryModel は、API の ResponseBody 型と完全に一致しています

アプリケーション層がプレゼンテーション層に依存するわけにはいかないので、QueryModel 型と ResponseBody 型を別々に設けてマッピングしていますが、このマッピングは無駄と考えることもできます

個人的には、ApplicationService の戻り値と API の Response が完全に一致する設計にする場合は、マッピングは無駄なので、QueryService のインタフェースをプレゼンテーション層に配置して、インフラストラクチャ層からプレゼンテーション層を参照してもいいと思っています

ただしこの場合は、チーム内で実装ルールを徹底しないと、プレゼンテーション層が担う役割が増えていってしまう可能性があります
不安があるのであれば、QueryService のインタフェースはアプリケーション層で定義した方がいいと思います。

なお、QueryService のインタフェースをプレゼンテーション層に設ける構成については、以前の記事「簡易 CQRS で「画面に ~~ も表示したいんだけど」に強い API を実装」に書いています。

その他の対応として、ModelMapper などのライブラリを使ってマッピング処理の実装を簡略化することも考えられますが、QueryModel と ResponseBody で全く同じ型 (DTO) を実装することになる問題は残ります。

参考

2020/12/20 追記

  • ModelMapper の使い方について こちらのコメント でアドバイスいただいたので、ModelMapper を使ってみたいという方は参照ください

現時点のコード

今回 QueryService を導入し、Repository だけだと大変になってしまう場合の対処法を検討しました。
じゃんけんアドベントカレンダーで予定していたアプリケーション構成のアップデートはこれで完了になります。

現時点のディレクトリ構成は以下のようになっています。

$ tree app/src/main/java/
app/src/main/java/
└── com
    └── example
        └── janken
            ├── JankenCLIApplication.java
            ├── JankenWebApplication.java
            ├── application
            │   ├── query
            │   │   ├── health
            │   │   │   └── HealthQueryService.java
            │   │   └── player
            │   │       ├── PlayerListQueryModel.java
            │   │       ├── PlayerListQueryModelPlayer.java
            │   │       └── PlayerQueryService.java
            │   └── service
            │       ├── health
            │       │   └── HealthApplicationService.java
            │       ├── janken
            │       │   └── JankenApplicationService.java
            │       └── player
            │           └── PlayerApplicationService.java
            ├── domain
            │   ├── model
            │   │   ├── janken
            │   │   │   ├── Hand.java
            │   │   │   ├── Janken.java
            │   │   │   ├── JankenDetail.java
            │   │   │   ├── JankenRepository.java
            │   │   │   └── Result.java
            │   │   └── player
            │   │       ├── Player.java
            │   │       └── PlayerRepository.java
            │   └── transaction
            │       ├── Transaction.java
            │       └── TransactionManager.java
            ├── infrastructure
            │   ├── csvdao
            │   │   ├── CsvDaoUtils.java
            │   │   ├── JankenCsvDao.java
            │   │   ├── JankenDetailCsvDao.java
            │   │   └── PlayerCsvDao.java
            │   ├── dao
            │   │   ├── JankenDao.java
            │   │   ├── JankenDetailDao.java
            │   │   └── PlayerDao.java
            │   ├── jdbctransaction
            │   │   ├── InsertMapper.java
            │   │   ├── JDBCTransaction.java
            │   │   ├── JDBCTransactionManager.java
            │   │   ├── RowMapper.java
            │   │   ├── SimpleJDBCWrapper.java
            │   │   └── SingleRowMapper.java
            │   ├── mysqldao
            │   │   ├── JankenDetailMySQLDao.java
            │   │   ├── JankenMySQLDao.java
            │   │   └── PlayerMySQLDao.java
            │   ├── mysqlquery
            │   │   ├── health
            │   │   │   └── HealthMySQLQueryService.java
            │   │   └── player
            │   │       └── PlayerMySQLQueryService.java
            │   └── mysqlrepository
            │       ├── JankenMySQLRepository.java
            │       └── PlayerMySQLRepository.java
            ├── presentation
            │   ├── api
            │   │   ├── health
            │   │   │   ├── HealthAPIController.java
            │   │   │   └── HealthResponseBody.java
            │   │   ├── janken
            │   │   │   ├── JankenAPIController.java
            │   │   │   ├── JankenPostRequestBody.java
            │   │   │   └── JankenPostResponseBody.java
            │   │   └── player
            │   │       ├── PlayerAPIController.java
            │   │       └── PlayerListResponseBody.java
            │   └── cli
            │       ├── controller
            │       │   └── JankenCLIController.java
            │       └── view
            │           └── StandardOutputView.java
            └── registry
                └── ServiceLocator.java

34 directories, 48 files

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

次回のテーマ

じゃんけんアプリケーションは以前 Heroku へのデプロイをセットアップしましたが、実際に運用するのであれば、ロギング・モニタリングなどのセットアップが必要になると思います。
(実は、前回 jOOQ を導入した時点から、そもそも Heroku へのデプロイもエラーになっていたりします)

次回はロギング・モニタリング周りを無料でできる範囲で整えたり、デプロイのエラーを解消したりしようと思います

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

  1. プレイヤーはページングで取得件数を抑えられますが、じゃんけんは勝利数を数えるために全件必要になります

16
12
2

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
16
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?