LoginSignup
36
34

More than 3 years have passed since last update.

簡易 CQRS で「画面に ~~ も表示したいんだけど」に強い API を実装

Last updated at Posted at 2019-07-26

はじめに

x と y と z を JOIN して COUNT した値を画面に表示したいなど、画面が要請する値を DB からごにょごにょと集計して API で返したくなることがあります。1

そんなとき、DB のモデルをドメインモデルにマッピングし、ドメインモデルを API のインターフェースにマッピングして返すような実装をしていると、以下のような問題にぶつかります。

  • 集計後の値を取得したいだけなのに、大量のオブジェクトをアプリケーションのメモリ上にロードすることになる
  • ちょっと取得する値を追加・変更するだけでもドメインモデルに影響が出てしまう
  • 使っている O / R マッパ によっては N + 1 問題が発生しやすい
  • ドメインモデルを集約単位で扱っていると、アプリケーション上で JOIN の処理を実装することになる

この記事では、簡易的な CQRS で上記の問題を解決してみます。

CQRS とは

簡単に言うと、書き込み系 (Commaond) の処理と読み込み系 (Query) の処理を分離する手法が CQRS です。
詳しくは『CQRSの和訳』などを参照ください。

CQRS はイベントソーシングなどと一緒に語られることが多いですが、イベントソーシングと一緒に導入することは必須ではありません。

この記事では、CQRS の第一歩として、アプリケーション内でのコマンドとクエリの分離を実施します。
逆に、さらに発展的な CQRS に登場する以下のような要素は扱いません。

  • DB の分離
  • イベントストア
  • イベントソーシング
  • ドメインイベント

実装のテーマ

Qiita のようなサービスを想定します。

CQRS_ドメインモデル (3).png

Command の例として Like の登録を、Query の例として記事 (Article) 一覧の取得を考えます。

Like を登録する際は、投稿者自身による Like でないことをチェックするビジネスロジックを実装します。
記事一覧の取得では、Qiita のトップページと同じように、タイトル・投稿者名・Like の数を返します。

使用する言語、FW など

この記事のサンプルは Spring Boot (Java) で実装します。
Query 側で自由に SQL を書きたいという理由から、ORM としては MyBatis を使います。

構成

などを参考に、以下の構成としました。

CQRS (2).png

上図をディレクトリ構造で見ると、以下のようになります。

.
src/main/java/
└── com
    └── example
        └── minimumcqrssample
            ├── MinimumCqrsSampleApplication.java
            ├── application
            │   ├── exception
            │   └── service
            ├── domain
            │   └── model
            ├── infrastructure
            │   ├── mysqlquery
            │   └── mysqlrepository
            └── interfaces
                └── api

実装

ここから実装になります。
コードは GitHub にもアップしています。

Command 側

Command 側は、CQRS を導入しない場合と同じような実装になります。

  • インターフェース層
  • アプリケーション層
  • ドメイン層
  • インフラストラクチャ層

の 4 層で実装します。

interfaces.api

Contoller の実装です。

LikeCommandController.java
@RestController
@RequestMapping("/articles/{articleId}/likes")
@AllArgsConstructor
public class LikeCommandController {

  private LikeApplicationService service;

  @PostMapping
  public ResponseEntity<Void> post(@AuthenticationPrincipal SampleUserDetails sampleUserDetails,
                                   @PathVariable long articleId) {

    service.register(new ArticleId(articleId), sampleUserDetails.getUserId());

    return ResponseEntity.status(HttpStatus.CREATED).build();
  }

}

リクエストのパスなどから必要なパラメータを抽出し、ApplicationService を呼び出しています。
もしもリクエストボディが存在する場合は、LikePostCommandRequest のような型を作り、@RequestBody でバインドします。

処理が完了したら、201 Created の HTTP レスポンスを返します。

application.service

アプリケーション層です。
この層は、ユースケースの実現とトランザクションの制御を責務とします。

LikeApplicationService.java
@Service
@Transactional
@AllArgsConstructor
public class LikeApplicationService {

  private LikeRepository likeRepository;
  private ArticleRepository articleRepository;

  public void register(ArticleId articleId, UserId userId) {
    Article article = articleRepository.findById(articleId)
            .orElseThrow(BadRequestException::new);

    Like like = Like.of(article, userId);

    likeRepository.save(like);
  }

}

型安全にするため、articleId や userId は long などではなく専用の型で受け取ります。
ドメインモデルパターンによる実装なので、ApplicationService の仕事は少なく、ドメインモデルのインターフェースを利用してユースケースを実現しているだけです。2

この例では ApplicationService の戻り値を void としていますが、HTTP レスポンス で Location を返したい場合などは、ApplicationService から ID を返すことも考えられます。

domain.model

ドメインモデルにはビジネスロジックを実装します。
この例では、自分が投稿した記事にはいいねできないというロジックを実装します。

上記の ApplicationService では Like と Article の 2 つの集約を扱っているので、その 2 つを見ていきます。

domain.model.like

Like.java
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Setter(AccessLevel.PRIVATE)
@EqualsAndHashCode
public class Like {

  /**
   * Factory.
   */
  public static Like of(Article article, UserId userId) {
    if (article.writtenBy(userId)) {
      throw new IllegalArgumentException();
    }
    return new Like(article.id(), userId);
  }

  private ArticleId articleId;
  private UserId userId;

}

Like の static メソッドとしてビジネスロジックを反映したファクトリを作成し、コンストラクタは private としています。3
また、他の集約を直接参照しないよう、Article 集約や User 集約に対しては、集約ルートの ID だけを参照しています。

Like クラスはデータの永続化の単位である集約の、ルートオブジェクト (集約ルート) です。
Repository は集約ごとに作成することになり、集約ルートを引数とした save メソッドを用意します。

LikeRepository.java
public interface LikeRepository {
  void save(Like like);
}

domain.model.article

Article.java
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Setter(AccessLevel.PRIVATE)
@EqualsAndHashCode
public class Article {
  private ArticleId id;
  private UserId userId;
  private String title;

  public ArticleId id() {
    return this.id;
  }

  public boolean writtenBy(UserId userId) {
    return this.userId.equals(userId);
  }
}

Article クラスでは userId の Getter の代わりに writtenBy メソッドを設け、userId を外から扱わないようにしています。

ArticleRepository.java
public interface ArticleRepository {
  Optional<Article> findById(ArticleId articleId);
}

infrastructure.repositoryimpl

DB アクセスの実装です。

LikeMySQLRepository.java
@Repository
@AllArgsConstructor
public class LikeMySQLRepository implements LikeRepository {

  private LikeMapper likeMapper;

  @Override
  public void save(Like like) {
    likeMapper.save(like);
  }
}
LikeMapper.java
@Mapper
public interface LikeMapper {
  void save(Like like);
}
LikeMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.example.minimumcqrssample.infrastructure.mysqlrepository.like.LikeMapper">

    <insert id="save" parameterType="com.example.minimumcqrssample.domain.model.like.Like">
        INSERT INTO `likes` (`article_id`, `user_id`) VALUES
        (#{articleId.value}, #{userId.value})
    </insert>

</mapper>

Repository は集約単位で作成して Mapper はテーブル単位で作成するために、MySQLRepository と Mapper は一対多の関係としています。

Query 側

この記事のメインである、Query 側の実装になります。

interfaces.api

Controller と Response 型については普通に実装しているだけです。

ArticleQueryController.java
@RestController
@RequestMapping("/articles")
@AllArgsConstructor
public class ArticleQueryController {

  private ArticleQueryService service;

  @GetMapping
  public ResponseEntity<ArticleListQueryResponse> list() {
    return ResponseEntity.ok(service.list());
  }
}
ArticleListQueryResponse.java
@Data
@AllArgsConstructor
public class ArticleListQueryResponse {
  private List<Article> articles;

  @Data
  @AllArgsConstructor
  public static class Article {
    private String title;
    private String authorName;
    private long likeCount;
  }
}

CQRS を取り入れたことにより、QueryService というインターフェースを作成しています。

ArticleQueryService.java
public interface ArticleQueryService {
  ArticleListQueryResponse list();
}

QueryService インターフェースは普通に考えるとアプリケーション層に配置したほうがよさそうですが、この例ではインターフェース層に配置しました。
その理由は以下の通りです。

  • アプリケーション層に配置すると、アプリケーション層の戻り値として DTO を定義することになり、DTO と Response 型でマッピング処理が必要になる
  • Query の内容はプレゼンテーション (UI) の都合に依存すると考えられる
  • この例では Query 処理以外にアプリケーション層に求められる処理がない

より複雑な処理を実現したい場合などは、アプリケーション層に配置するべきかもしれません。

また、『大失敗した設計、そしてドメイン駆動設計の基本に立ち返る』という記事のように、Query 側が重要なアプリケーションの場合は Query 用のドメイン層も必要になるかもしません。

infrastructure.queryimpl

最後に、Query の実装です。

LikeMySQLRepository.java
@Service
@AllArgsConstructor
public class ArticleMySQLQueryService implements ArticleQueryService {

  private ArticleMySQLQueryMapper mapper;

  @Override
  public ArticleListQueryResponse list() {
    return new ArticleListQueryResponse(mapper.list());
  }
}
ArticleMySQLQueryMapper.java
@Mapper
public interface ArticleMySQLQueryMapper {
  List<ArticleListQueryResponse.Article> list();
}

この例では Repository と Mapper を分離していますが、統合しても問題ありません。

ArticleMySQLQueryMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.example.minimumcqrssample.infrastructure.mysqlquery.article.ArticleMySQLQueryMapper">

    <resultMap id="article"
               type="com.example.minimumcqrssample.interfaces.api.article.ArticleListQueryResponse$Article">
        <result property="title" column="title"/>
        <result property="authorName" column="author_name"/>
        <result property="likeCount" column="like_count"/>
    </resultMap>

    <select id="list" resultMap="article">
        SELECT
            MAX(a.title) AS title,
            MAX(u.name) AS author_name,
            COUNT(*) AS like_count
        FROM articles a
        INNER JOIN users u ON a.user_id = u.id
        INNER JOIN likes l ON a.id = l.article_id
        GROUP BY l.article_id
    </select>

</mapper>

SQL では JOIN や COUNT などを自由に記述しています。

これにより、この記事の最初に挙げた

  • 集計後の値を取得したいだけなのに、大量のオブジェクトをアプリケーションのメモリ上にロードすることになる
  • ちょっと取得する値を追加・変更するだけでもドメインモデルに影響が出てしまう
  • 使っている O / R マッパ によっては N + 1 問題が発生しやすい
  • ドメインモデルを集約単位で扱っていると、アプリケーション上で JOIN の処理を実装することになる

という問題が解決しました。

おわりに

簡易的な CQRS は、参照系で SQL を自由い記述したいというモチベーションに対する結構良い解決策なのではないかと思います。
「画面に ~~ も表示したいんだけど」と言われたときに悩むことも減りそうです。

一方、更新系では単調な SELECT 文や INSERT 文を書くのは手間でしかありません。
findById や save といったメソッドが必要なだけであれば、MyBatis よりも JPA の方が相性がいいかもしれません。
DDD x CQRS - 更新系と参照系で異なるORMを併用して上手くいった話』で紹介されているように、更新系と参照系で ORM を変えるのはかなり良さそうです。

参考

書籍

Web


  1. この記事では API を例にしていますが、API でなくても同じ方法が適用可能です。 

  2. この例ではトランザクションスクリプトではなくドメインモデルで実装していますが、トランザクションスクリプトに置き換えることも可能です。ドメインモデルとトランザクションスクリプトの違いについてはこちらを参照ください。 

  3. static メソッドとする代わりに他のクラスに切り出しても構いません。 

36
34
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
36
34