108
76

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 5 years have passed since last update.

リードモデルのN+1問題とCQRS

Last updated at Posted at 2019-01-31

背景

集約とリポジトリなどをアプリケーションサービスやコントローラから呼び出し、書き込みや読み込みの要求を実装することがよくあります。ほとんどの場合、トランザクション整合性の観点から考えると、書き込み要求は集約単位になりますが、読み込みは結果整合性も含めると、複数種の集約を合成した、いわゆるリードモデルを返すことが多いです。この記事では、このリードモデルに起こるN+1問題とCQRSの関連性についてまとめたいと思います。

リードモデルを返す処理

みなさんは、どのようにしてリードモデルを構築していますか?
いろいろな方法がありますが、ここでは以下に観点を絞ってみたい。

  1. 複数種のリポジトリを使って集約を取得し、リードモデル用DTOに詰め直す
  2. リポジトリを使わず、ストレージに対応したDAOで、JOINするような問い合わせを行う

対象のドメイン

話をわかりやすくするために、想定のドメインが必要ですね。
ホテルの予約とします。登場するモデルは以下。

  • ホテル(Hotel)
    • ホテルID
    • ホテル名
  • 顧客(Customer)
    • 顧客ID
    • 顧客名
  • 予約(Reservation)
    • 予約ID
    • ホテルID
    • 顧客ID
    • 予約日時

そして、クライアントなどに返すリードモデルは予約一覧です。予約一覧の内容は、以下のように上記のモデルを合成したような結果が必要です。

  • 予約ID
  • ホテルID
  • ホテル名
  • 顧客ID
  • 顧客名
  • 予約日時

リポジトリを使う方法

いくらなんでもこんなコードは書いてないですよね…。N+1問題ではこのような事例がでてきます。以下に参考リンクを貼っておきますね。

N+1問題
N+1問題は1+N問題

1度の問い合わせ(1)でN回の問い合わせ(2)(3)が発生します。さらに、この実装では(2),(3)で発生する問い合わせはID値の重複を排除しないまま、問い合わせているのでつらいことになってます…。(最低でもID集合をSetに変形してからやってほしい…)
(4)のリードモデルの構築も、DAOのレイヤーで効率的にやっていたものが、アプリケーション空間に露出していて適切な設計に思えません。(といいながら、過去にこういう設計をやったことがある。心より恥じる…)

val reservations = reservationRepository.resolveAllWithOffsetLimit(...) // (1)
val hotels = reservations.map{ reservation => hotelRepository.resolveId(reservation.hotelId) } // (2)
val customers = reservations.map{ reservation => customerRepository.resolveById(reservation.cusotmerId) } // (3)
val readModel: Seq[ReservationReadModel] = reservations.zip(hotels).zip(customers).map{ case ((r, h), c) =>
   ReservationReadModel(r.id, h.id, h.name, c.id, c.name, r.createdAt)
} // (4)

もう少し緩和したとしても、バッチ問い合わせするぐらいでしょうか。とはいえ、問い合わせの基となったモデル(今回の場合は予約)の構造によっては、関連して発生する問い合わせを減らせない可能性もあります。

val reservations = reservationRepository.resolveAllWithOffsetLimit(...)
val hotels = hotelRepository.resolveMulti(reservations.map(_.hotelId)) // 引数Setでもらいましょう
val customers = customerRepository.resolveMulti(reservations.map(_.customerId)) // 引数Setでもらいましょう
val readModel: Seq[ReservationReadModel] = ...

(1),(2),(3)の問い合わせをわざわざアプリケーション空間に戻して、合成処理を掛ける意味はほとんどありません。こんなコードをだらだら読まされるほど暇じゃないはずです(笑) まぁ、まだデータの規模が少ないうちはいいですけど、増えると…。

読み込み専用DAOを使う方法(CQRS)

上記のような複雑な問い合わせはドメインのコンポーネントを使って本当に実現すべきでしょうか?。CQRSでは、複雑なクエリ要件(複数種の集約を合成するようなクエリ要件)はドメイン層の責務とせずに、リード側のスタックで解決しようとします。これにならうと、上記のほとんどの問題が解決できます。
たとえば、MySQLなどのRDBMSであれば、以下のようなVIEWを定義し、読み込み専用DAOを実装すれば、SQLの問い合わせは一度で済みますし、アプリケーション空間で非効率なメモリ操作も軽減できます。読み込み専用のDAOは書き込みのメソッドを持たないので、ドメイン側に予期せぬ副作用を与えることがありません。

CREATE VIEW
  reservation_r
  (  
    id, 
    hotel_id, 
    hotel_name, 
    customer_id, 
    customer_name, 
    created_at,
  )
AS
  SELECT
    r.id, 
    h.id, 
    h.name, 
    c.id, 
    c.name,
    r.created_at
  FROM
    reservation AS r 
    LEFT JOIN hotel AS h ON r.hotel_id = h.id
    LEFT JOIN customer AS c ON r.customer_id = c.id
;
val readModel: Seq[ReservationReadModel] = ReservationReadDao.findAllWithOffsetLimit(...)

考えてみたらすごく自明のことなのですが、書き込みと読み込みの責任を分ける発想がないと、なかなか気づけないのではないでしょうか。

懸念点

  • 読み込み専用DAOにビジネスロジックは入り込まないのか?
    • 副作用を起こすときにビジネスルールを満たしているか確認するようにすれば、読み込み側では関与する部分がほとんどないはずです。
  • ドメインモデルの変更をした際に、リードモデルの変更が漏れるのでは?
    • 副作用を起こすドメイン側と、副作用を扱わないリード側は、アプリケーション上では疎結合になりますが、ドメインモデルは結局永続化と紐付くことが多いので、スキーマに変更が適切に伝搬されていれば、リードモデルの変更漏れも防げるはずです。
  • RDBMSじゃない場合でもできるの?
    • 少し複雑になりますが、読み込み用のストレージがRDBMSでない場合も、書き込み操作のイベントをフックし、リードモデルを構築するプロセスをはさめば、リードモデルを構築することも可能です。

ということで、集約を跨ぐクエリ要件は読み込み専用DAOを使う方法がよいのではという提案でした。

108
76
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
108
76

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?