Cookbook の New ORM Upgrade Guide には、以下のように書かれています。
Containable - Should be part of the ORM, not a crazy hacky behavior.
(Containable - ORM の一部であるべきである、クレイジーでハッキーなビヘイビアではなく)
ContainableBehavior
といえば、効率的なアソシエーションのデータ取得のために contain
を提供する標準のビヘイビアです。
その ContainableBehavior
が、いったいどれほど狂っていたというのでしょうか?
この記事では、ContainableBehavior
の実装とその問題点を検証した上で、CakePHP2 でシャカリキに効率よく contain
するためのプラグイン EagerLoader をご紹介します。
ContainableBehavior の正体
CakePHP2 の ContainableBehavior
は、一時的な recursive
の変更と、アソシエーションの切り離しによって実装されています。
ここに User
、Group
、Domain
および Post
という 4 つのモデルがあるとします。User
は Group
に、Group
は Domain
にそれぞれ従属し、また、User
は Post
を所有します。
なお、この他にもモデル名こそ登場しませんが、さまざまなアソシエーションが存在すると考えてください。
さて、User
と関連する Group
および Domain
のデータが必要になったとします。
User
から Domain
までを一息に取得するには recursive
レベルを 2
以上にすればよいのですが、それでは、必要のない Post
やその先に存在するであろう、何だかよくわからないアソシエーションのデータまで一緒くたに返されてしまうでしょう。
$User->find('first', [
'recursive' => 2,
]);
上の図でいえば、オレンジ色で示したアソシエーションまでが返されてしまいます。
ContainableBehavior
では、この問題を解決するために Model.beforeFind
イベントを捉まえて unbindModel()
を呼び出し、不要なアソシエーションを切り離してしまいます。
$User->find('first', [
'contain' => [
'Group.Domain',
],
]);
上の例では、User
には Group
との関係のみを残し、Post
等のアソシエーションを切り離します。同様に Group
についても Domain
との関係のみを残します。
切り離し後のアソシエーションはこのようになります。
不要なアソシエーションを切り離した都合、User
から見える関係は Group
とその先の Domain
のみになっていますので、この状態で recursive
レベルを 2
に上げて全てのデータを取得すれば、結果的に contain
したことになるという寸法です。
なお、切り離されてしまったアソシエーションについては、find()
がデータを返却する前に、内部的に resetAssociations()
を呼び出すことで、ひそかに元に戻して誤魔化しています。
これが ContainableBehavior
の正体です。
なかなか見事な実装で、とてもクレイジーだとは思えません。むしろ、発案者は賞賛に値するのではないでしょうか。
――しかし悲しいかな、クレイジーであろうとなかろうと、ContainableBehavior
は確かにハッキ―ではあるのです。
単一インスタンスの落とし穴
実は ContainableBehavior
によるハックでは処理できない contain
パターンがあります。
それが以下のようなパターンです。
$Comment->find('all', [
'contain' => [
'Post.User.Group',
'User',
]
]);
問題となるのは User
と Group
の関係です。
Post
を投稿した User
については Group
を要求していますが、Comment
を投稿した User
については要求していません。
しかし、CakePHP2 のアソシエーションでは、各モデルのインスタンスは単一で、アプリケーション全体で共有します。したがって Comment
を投稿した User
についても Group
との関係は維持されることになります。
この例の場合、 ContainableBehavior
は recursive
レベルを 3
に設定しますので、recursive
レベル 2
でも事足りる User.Group
はあえなく鹵獲され、予期せずしてレコードセットに含まれることになります。
afterFind() という悪魔
ContainableBehavior
を台無しにするための、より効果的な方法は afterFind()
コールバック中に find()
を呼び出すことです。find()
は内部的に resetAssociations()
を呼び出しますので、ContainableBehavior
が切り離したアソシエーションをすべて元に戻してしまいます。
先の User
から Group.Domin
を取得する例であれば、たとえば Domain
に従属する Group
の数を Domain::afterFind()
内で求めるようなコードがこの問題を生じます。
public function afterFind($results, $primary = false) {
foreach ($results as &$result) {
if (isset($result[$this->alias]['id'])) {
$result[$this->alias]['group_count'] = $this->Group->find('count', [
'conditions' => ['Group.domain_id' => $result[$this->alias]['id']],
'callbacks' => false,
]);
}
}
return $results;
}
このコードでは find('count')
が resetAssociations()
を呼び出してしまうため、その時点で Group
から User
への所有関係が元に戻ってしまいます。recursive
レベルは 2
ですので Group
に属する全 User
のデータを予期せずレコードセットに含めてしまうでしょう。
さながら、縁を切ったはずの不良グループから悪いメンバーたちが一斉に訪れたかのようです。
ContainableBehavior
には、以上のような問題があります。
しかし、この程度のことであれば、それほど大きな問題とは言えないでしょう。
CakePHP2 の ORM において問題があるとすれば、それは ContainableBehavior
ではなく recursive
の実装――すなわち、DboSource
なのです。
N + 1 問題
ORM には N + 1 問題という避けては通れない問題が存在します。
任意の Group
について、そこに所属する User
を取得する処理を考えてみます。
単純な実装では、まず Group
の取得のためにまず 1 回のクエリが発行されます。
そして、次なる User
の取得は group_id = ?
という条件式で行われることになるのですが、すると、このクエリは先に取得した Group
の個数 N 回発行しなければなりません。
最初に発行した 1 回を足し合わせて、これを N + 1 問題と呼びます。
もっとも CakePHP2 の DboSource
の実装はここまでは単純ではありません。
上記の例では、DboSource
は 2 回のクエリしか発行しません。なぜなら User
の取得は group_id IN (...)
という効率的なクエリによって、一括で行われるためです。
しかし、DboSource
の N + 1 問題対策は万全ではありません。
たとえば、任意の Group
について、そこに所属する User
だけでなく、その Profile
まで取得したい場合に、DboSource
は N + 1 問題を生じます。
優れた実装であれば、Group
の取得のための 1 回はさて置き、次なる User
と Profile
の取得は、これらが hasOne
の関係であれば、LEFT JOIN によって処理し、結果として 2 回のクエリで済ませるべきでしょう。
しかし、DboSource
は Profile
を結合せずに再帰処理へ回してしまいます。
さらに悪いことに、DboSource
は IN
による効率化を hasMany
の関係にあるモデルでしか行わないため、次なる Profile
の取得は完全に User
数に依存して、N 回のクエリを発行してしまいます。
これはパフォーマンス上の大きな問題となります。
CakePHP3 では新しい ORM の一部である Cake\ORM\EagerLoader
クラスがこの問題を解決しています。
contain
を実装しているのも主にこのクラスで、実際に上述したような効率的なクエリを発行する実装になっています。
CakePHP2 においても、どうにかして効率的なクエリで contain
を行うことはできないものでしょうか――?
それが今回ご紹介する EagerLoader
プラグインです。
インストールするだけで contain
が発行するクエリを劇的に改善します。
ちなみに afterFind()
内での find()
呼び出しの問題なども解決しています。
find()
の返値については ContainableBehavior
と同等のレコードセットを返すように実装したつもりですので、通常の使用の範囲では問題は生じないと思いますが、以下に挙げる互換性のない仕様もあります。
ContainableBehavior と互換性のない仕様
contain() メソッド未実装
現時点では contain()
メソッドについては実装していません。
代わりに find()
の contain
オプションをご利用ください。
フィールド指定非サポート
['contain' => 'Group.name']
のようなフィールド指定はサポートしていません。
代わりに ['contain' => ['Group' => ['fields' => 'name']]]
をご利用ください。
hasOne / belongsTo 結合失敗データの形式
常に空配列になります。
ContainableBehavior
は、recursive
レベル 0
で失敗した場合に、すべてのフィールドに NULL
が格納されたレコードを含むことがあります。
なお、EagerLoader
プラグインにおいても afterFind()
コールバックに渡されるデータは、こうした NULL
レコードになる場合があります。
アソシエーションの並び順
各レコードの連想配列において、プライマリモデルを除いて、その他のアソシエーションの並び順は同じにはなりません。
こうした ContainableBehavior
と互換性のない仕様に依存するコードを書いている場合にはご注意ください。
インストール手順
プラグインの取得
Composer からインストールすることができます。
{
"require": {
"chinpei215/cakephp-eager-loader": "^0.1"
}
}
もちろん、GitHub の EagerLoader レポジトリから git clone する、あるいは zip ファイルをダウンロードすることもできます。
プラグインの有効化
app/Config/bootstrap.php
内で EagerLoader
プラグインを有効化してください。
CakePlugin::load('EagerLoader');
ビヘイビアの有効化
モデルで EagerLoader.EagerLoader
ビヘイビアを有効化してください。
public $actsAs = [
'EagerLoader.EagerLoader',
//'Containable',
];
Containable
は無効化するか、EagerLoader
よりも優先度を下げてください。
あるいは、既存のプロジェクトに導入する場合には、互換性の問題の避けるために $actsAs
に設定する代わりに遅いクエリの直前でのみ動的にビヘイビアを読み込む方法も有効です。
// 動的な読み込み
$User->Behaviors->load('EagerLoader.EagerLoader');
$User->find('first', [
'contain' => [
'Group.Domain',
],
]);
インストールはこれで完了です。
最後まで記事をお読みいただきありがとうございます。そして、プラグインをお使いになった皆様からのフィードバックをお待ちしております。