集約の実装とLazy Loading
エンティティの設計においてオブジェクト同士の関連(参照)は避けては通れないものだ。 エンティティ単体では不十分で、関連するエンティティ同士が結び付いて始めて意味を成す場面は往々にしてある。 そのような場合、エンティティを他のエンティティの参照を保持させる集約 として設計する。
しかし無闇に関連を持たせた集約を設計すればいいというわけではない。 関連をたくさん持った巨大な集約というのは扱いづらいし、双方向の関連は複雑さを生む。 必要最低限かつ、単方向の関連しか持たない集約が望ましい。 そのためには、エンティティの参照を保持するのではなく、その識別子(ID)を保持させることも検討すべきだ。 この時、エンティティの参照を得るには識別子をリポジトリに問い合わせればいい。
N + 1問題
集約とそれが参照する各エンティティがデータベース内に別々に格納されているとして、集約のエンティティを複数取得するケースではパフォーマンスの悪化が懸念される。 いわゆるN + 1問題がその典型的なケースだ。 N個の集約が与えられた時、集約自身を得るクエリ 1 つと、その関連を辿るクエリN を足したN + 1のクエリを発行してしまうという問題である。
この問題の解決方法は単純で、そもそもクエリを分けずに JOIN を利用して1 つのクエリにしてしまうか、IN句を利用して関連を辿るクエリを 1 つにまとめてしまえばいい。 いわゆるEager Loadingと呼ばれる方法だ。 N + 1問題が発生する方法が関連を参照するまで遅延読み込み(Lazy Loading)するのと対比して、この方法では即時読み込み(Eager Loading)するからだ。
Lazy Loadingと N + 1問題の回避
N + 1問題を回避するためにLazy Loadingは避けて常にEager Loadingすべきかというとそうとも言えない。 ユースケースによっては集約の一部の関連しか参照しない場合もあるかもしれないが、この時Lazy Loadingが利用できればクエリの発行自体を避けることができるのでパフォーマンスで有利になる。 しかしN + 1問題が起きると途端にパフォーマンスが悪化してしまうので、N + 1問題の起きないLazy Loadingの実装があれば使い勝手がいい。 つまり、エンティティごとに個別に読み込みを遅延するのではなく関連ごとに遅延すればいい。
emonkak/ormにはこの方式のLazy Loadingが実装されている。 ocramius/proxy-managerを利用した簡単な実装だ。
利用例:
<?php use Emonkak\Orm\Relation\Relation; use Emonkak\Orm\SelectQuery; $users = (new SelectQuery()) ->with(Relation::lazyOneToOne( Profile::class, 'profiles', 'profile', 'user_id', 'user_id', $pdo )) ->with(Relation::lazyOneToMany( Post::class, 'posts', 'posts', 'user_id', 'user_id', $pdo )) ->from('users') ->getResult($pdo, User::class); foreach ($users as $user) { // $users自体も実際に走査されるまでクエリの発行が遅延されている $users->posts; // Postが格納されたArrayObjectのProxy Object $users->posts[0]; // 実際にアクセスするまでクエリの実行は遅延される // 一度アクセスした時点ですべてのUserのPostが読み込まれる $users->profile; // ProfileのProxyObject $users->profile->introduction; // Post の時と同様にアクセスするまで読み込みは遅延される }
おわりに
集約の読み込みにおいてLazy Loadingを利用することでクエリの発行自体を回避してパフォーマンスを向上させることができる場面があるが、N + 1問題が発生してしまうと逆にパフォーマンスが悪化してしまうので扱いづらい。 そこでN + 1問題の起きないLazy Loadingの実装があると使い勝手がいいのでemonkak/ormに実装した。