CakePHP

CakePHP2 でシャカリキに効率よく contain する

More than 1 year has passed since last update.

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 の変更と、アソシエーションの切り離しによって実装されています。

ここに UserGroupDomain および Post という 4 つのモデルがあるとします。UserGroup に、GroupDomain にそれぞれ従属し、また、UserPost を所有します。

なお、この他にもモデル名こそ登場しませんが、さまざまなアソシエーションが存在すると考えてください。

さて、User と関連する Group および Domain のデータが必要になったとします。

User から Domain までを一息に取得するには recursive レベルを 2 以上にすればよいのですが、それでは、必要のない Post やその先に存在するであろう、何だかよくわからないアソシエーションのデータまで一緒くたに返されてしまうでしょう。

$User->find('first', [

'recursive' => 2,
]);

contain1.png

上の図でいえば、オレンジ色で示したアソシエーションまでが返されてしまいます。

ContainableBehavior では、この問題を解決するために Model.beforeFind イベントを捉まえて unbindModel() を呼び出し、不要なアソシエーションを切り離してしまいます。

$User->find('first', [

'contain' => [
'Group.Domain',
],
]);

上の例では、User には Group との関係のみを残し、Post 等のアソシエーションを切り離します。同様に Group についても Domain との関係のみを残します。

contain2.png

切り離し後のアソシエーションはこのようになります。

不要なアソシエーションを切り離した都合、User から見える関係は Group とその先の Domain のみになっていますので、この状態で recursive レベルを 2 に上げて全てのデータを取得すれば、結果的に contain したことになるという寸法です。

なお、切り離されてしまったアソシエーションについては、find() がデータを返却する前に、内部的に resetAssociations() を呼び出すことで、ひそかに元に戻して誤魔化しています。

これが ContainableBehavior の正体です。

なかなか見事な実装で、とてもクレイジーだとは思えません。むしろ、発案者は賞賛に値するのではないでしょうか。

――しかし悲しいかな、クレイジーであろうとなかろうと、ContainableBehavior は確かにハッキ―ではあるのです。


単一インスタンスの落とし穴

実は ContainableBehavior によるハックでは処理できない contain パターンがあります。

それが以下のようなパターンです。

$Comment->find('all', [

'contain' => [
'Post.User.Group',
'User',
]
]);

問題となるのは UserGroup の関係です。

Post を投稿した User については Group を要求していますが、Comment を投稿した User については要求していません。

しかし、CakePHP2 のアソシエーションでは、各モデルのインスタンスは単一で、アプリケーション全体で共有します。したがって Comment を投稿した User についても Group との関係は維持されることになります。

この例の場合、 ContainableBehaviorrecursive レベルを 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 回はさて置き、次なる UserProfile の取得は、これらが hasOne の関係であれば、LEFT JOIN によって処理し、結果として 2 回のクエリで済ませるべきでしょう。

しかし、DboSourceProfile を結合せずに再帰処理へ回してしまいます。

さらに悪いことに、DboSourceIN による効率化を 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/boostrap.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',
],
]);

インストールはこれで完了です。

最後まで記事をお読みいただきありがとうございます。そして、プラグインをお使いになった皆様からのフィードバックをお待ちしております。