背景
集約とリポジトリなどをアプリケーションサービスやコントローラから呼び出し、書き込みや読み込みの要求を実装することがよくあります。ほとんどの場合、トランザクション整合性の観点から考えると、書き込み要求は集約単位になりますが、読み込みは結果整合性も含めると、複数種の集約を合成した、いわゆるリードモデルを返すことが多いです。この記事では、このリードモデルに起こるN+1問題とCQRSの関連性についてまとめたいと思います。
リードモデルを返す処理
みなさんは、どのようにしてリードモデルを構築していますか?
いろいろな方法がありますが、ここでは以下に観点を絞ってみたい。
- 複数種のリポジトリを使って集約を取得し、リードモデル用DTOに詰め直す
- リポジトリを使わず、ストレージに対応したDAOで、JOINするような問い合わせを行う
対象のドメイン
話をわかりやすくするために、想定のドメインが必要ですね。
ホテルの予約とします。登場するモデルは以下。
- ホテル(Hotel)
- ホテルID
- ホテル名
- 顧客(Customer)
- 顧客ID
- 顧客名
- 予約(Reservation)
- 予約ID
- ホテルID
- 顧客ID
- 予約日時
そして、クライアントなどに返すリードモデルは予約一覧です。予約一覧の内容は、以下のように上記のモデルを合成したような結果が必要です。
- 予約ID
- ホテルID
- ホテル名
- 顧客ID
- 顧客名
- 予約日時
リポジトリを使う方法
いくらなんでもこんなコードは書いてないですよね…。N+1問題ではこのような事例がでてきます。以下に参考リンクを貼っておきますね。
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を使う方法がよいのではという提案でした。