4
5

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.

YiiAdvent Calendar 2015

Day 25

Yiiでわかる「妻の夫は私以外の誰か」問題と対策

Last updated at Posted at 2015-12-24

YiiのActiveRecord/ActiveQueryは、比較的SQLを隠蔽しないタイプの、軽量なORMです。軽いORMは、永続オブジェクトを一元管理しないため、都度場合に応じてSQLをチューニングできるメリットがある反面、全体を意識しないと、すでに取得済みのレコードを何度も取得し、同じレコードに対して異なるオブジェクトを生成してしまうリスクがあります。

これを勝手に「妻の夫は私以外の誰か」問題と名づけました。「彼女の彼氏が俺じゃない」問題でもかまいません。

1 to 1 の例

簡単な例として、互いに hasOne 関係なレコードを考えてみてください。2つのレコードは pair で相互にリンクし合っている、夫婦のような関係があるとします。つまり、pairpair は自分になるはずです。

$model = SomeModel::find()->where(['id' => 1])->one();
$model->pair->pair->id === $model->id;

ペアのペアが自分にならない

Yiiのリレーション定義は、固定されたプロパティへのマッピングではなく、「任意の関係レコード取得クエリ」を返すメソッドを実装することで実現しています。(Yiiほど自由ではありませんが、Laravelも似た方式をとっています)

そのため、何も工夫しないで Yii (とLaravel) で関係オブジェクトを取得すると、暗黙のレイジーロードによって、つねに毎回別のオブジェクトが生成されてしまいます。別のオブジェクトが生成されるということは、ペアのペアが、 自分と同じ属性を持つ自分ではないオブジェクト になってしまいます。

public function getPair()
{
    return $this->hasOne(static::className(), [
        'id' => 'pair_id'
    ]);
}
$model->pair->pair->id === $model->id; // ここでリレーション取得のSQLが2つ走る
$model->pair->pair !== $model;  // 違う!
SELECT * FROM some_table WHERE id=1;
SELECT * FROM some_table WHERE id=2;
SELECT * FROM some_table WHERE id=1; -- あれ?

このままだと、->pair->pair->pair->... とプロパティを参照するたびに、新たなSQLが発行されます。

あらかじめイーガーロードしていても同じです。

$model = SomeModel::find()->where(['id' => 1])->with([
    'pair', 'pair.pair'
])->one(); // ここでリレーション取得のSQLが2つ
$model->pair->pair->id === $model->id;
$model->pair->pair !== $model;

$model->pair->pair->pair; // ここでまた1つ余分に

オブジェクトが同一インスタンスでないと、たとえば、せっかく最初のオブジェクトで重い初期化を済ませていても、新たに取得したオブジェクトは未初期化なので、また同じ処理が必要になってしまいます。場合によっては、2者の状態に矛盾する変化が生じたら、永続化すべきデータはどちらなのか、わからなくなってしまいます。

ペアのペアが自動的に自分になるようにしたい

そもそも、2オブジェクトしか必要ないはずのペア関係の処理において、3つ目のクエリが走ることがおかしいですね。相手を取得したとき、相手に対して「あなたのリレーション先は未定義じゃないよ、自分だよ」と教えてやれれば、問題は解決します。

Yii では、リレーション定義時に inverseOf() を指定することでそれが可能になります。

public function getPair()
{
    return $this->hasOne(static::className(), [
        'id' => 'pair_id'
    ])->inverseOf('pair');
}

例がわかりにくいですが、inverseOf の引数は、相手のリレーション名です。この指定があると、関係オブジェクトを取得したとき (イーガーロードの場合はクエリ時に)、同時にリレーション関係を逆向きにも貼ってくれます。

$model = SomeModel::find()->where(['id' => 1])->one();
$model->pair;
// クエリ後、$model に pair が保持されると同時に、
// pair にも $model が保持される。
$model->pair->pair; // クエリは発生せず、オブジェクトも生成されない

$model->pair->pair->id === $model->id;
$model->pair->pair === $model; // 同じ!

いくら ->pair->pair->pair->... と繰り返しても、クエリは2つしか発生しません。

SELECT * FROM some_table WHERE id=1;
SELECT * FROM some_table WHERE id=2;

別々に取得したペアを関連付けたい場合

リレーションを辿って取得した場合、inverseOf のおかげでうまく関係付けが行われます。

しかしYiiのORMは、永続化エンティティをマネージャーが管理しない、軽量なORMです。異なる初期クエリで取得されたレコードは、同じIDを持つレコードであっても、異なるオブジェクトとして分離されます。

$model = SomeModel::findOne(['id' => 1]);
$pairModel = SomeModel::findOne(['id' => $model->pair_id]);

$model->pair->pair === $model; // これは成立
$pairModel->pair->pair === $pairModel; // これも成立

$model->pair !== $pairModel; // 違う
$pairModel->pair !== $model; // 違う

$model$model->pair は世界Aにいて、$pairModel$pairModel->pair は世界Bにいる。別の世界のオブジェクト同士に同一性は成り立たない、といった感じになります。

注: $pairModel = $model->getPair()->one(); でも同じく「異なるクエリ」です。getPair() の戻り値はあくまで「任意のActiveQuery」です。オブジェクトのプロパティ参照による問い合わせである保証がありません。後でもっと加工して使うよう、取っておくこともできるのですから。(DataProvider の query がよい例です)

このように、異なるクエリで取得された ActiveRecord を関係づけたいときは、関係オブジェクトをプロパティで取得する前 (つまり暗黙のSQLが発生する前) に、明示的に populateRecord() を使います。populateRecord() は、関係オブジェクトのための特別なプロパティセッターです。

$model = SomeModel::findOne(['id' => 1]);
$pairModel = SomeModel::findOne(['id' => $model->pair_id]);

$model->populateRecord('pair', $pairModel);
$pairModel->populateRecord('pair', $model);

$model->pair === $pairModel; // 同じ
$pairModel->pair === $model; // 同じ

実は inverseOf は、関係オブジェクト取得クエリのあと、自動的に populateRecord をコールする設定です。基本、フレームワークの流儀にしたがって楽をすればいいものの、どうしてもうまくいかない時は、必要なレコードを自力でユニークに(つまりSQLが重複しないように)取得し、それを populateRecord で関係づけてやればよい、ということを憶えておいてください。

ここまでが基本です。しつこい説明でしたが、ここから先、多重度が複雑になっても、このペア問題の応用にすぎません。まず1:1でしっかり理解してください。

1 to Many の例

親子関係のときは、下手をすると意図しないレイジーロードの影響が大きくなります。いわゆる N+1問題 というやつに関係するアレですね。

Blog に Article が複数公開されていて、そこに Comment が複数投稿されるというモデルがあります。ひとつの Article を Web ページに表示する場合を考えましょう。

$article = Article::find()->where(...)->one();

$article->blog->...;

foreach ($article->comments as $comment) {
   $comment->...;
}

イーガーロードでどうにかしようとした場合

inverseOf が設定されていないと仮定します。

foreach ($article->comments as $comment) {
   echo $view->render('comment', ['comment' => $comment]);
}

...までは大丈夫に見えても、コメント単体をレンダリングするのが次のようなビューだったとするといかがですか。

comment.php
$blog = $comment->article->blog;
echo mb_strimwdth($comment->body,
    0, $blog->comment_text_limit, '...', 'utf-8'
);

すべてのコメント表示時に、->article->article->blog でレイジーロードが発生します。

SELECT * FROM `comment` WHERE article_id=1
SELECT * FROM article WHERE id=1
SELECT * FROM blog WHERE id=1
SELECT * FROM article WHERE id=1
SELECT * FROM blog WHERE id=1
SELECT * FROM article WHERE id=1
SELECT * FROM blog WHERE id=1
SELECT * FROM article WHERE id=1
SELECT * FROM blog WHERE id=1
SELECT * FROM article WHERE id=1
SELECT * FROM blog WHERE id=1
...

これをイーガーロードで防ぐにはこうです。最初の問い合わせでアクセスしうるレコードをいちどにフェッチします。

$article = Article::find()->where(...)->with([
    'blog',
    'comments',
    'comments.article',
    'comments.article.blog',
])->one();
SELECT * FROM article WHERE id=1
SELECT * FROM blog WHERE id=1
SELECT * FROM `comment` WHERE article_id=1
SELECT * FROM article WHERE id=1
SELECT * FROM blog WHERE id=1

ここまでイーガー対象を広げて、やっとクエリがいくつも発行される現象を防げました。が、これ以上の重複削減はできませんでした。上へ下へのイーガーや、hasMany に対しての利用は、あまり有効ではありません。また、オブジェクトの同一性もまったく保証できていません。

$article !== $article->comments[0]->article;
$article->blog !== $article->comments[0]->article->blog;

N+1問題への対策として簡単なのはイーガーロードですが、いっぽうでYiiはキャッシュのサポートをかなり活かせるフレームワークであることも考慮に入れたいところです。目に見えるものをすべてイーガーロードするよりも、レイジーロードとキャッシュを組み合わせたほうが効率がいい場合が多々あります。

また、N+1問題はあくまで、レイジーロードがSQLを無駄に小分けにして発行することにのみ着目しています。オブジェクトの同一性について問題視してはいません。「妻の夫〜問題」とは異なる問題なのです。

inverseOfで対処できる範囲

適切に inverseOf があったとします。

Blog
class Blog extends ActiveRecord
{
    public function getArticles()
    {
        return $this->hasMany(Article::className(), [
            'blog_id' => 'id'
        ])->inverseOf('blog');
    }
}
Article
class Article extends ActiveRecord
{
    public function getBlog()
    {
        return $this->hasOne(Blog::className(), [
            'id' => 'blog_id'
        ]);
    }

    public function getComments()
    {
        return $this->hasMany(Comment::className(), [
            'article_id' => 'id'
        ])->inverseOf('article');
    }
}
Comment
class Comment extends ActiveRecord
{
    public function getArticle()
    {
        return $this->hasOne(Article::className(), [
            'id' => 'article_id'
        ]);
    }
}

コードを見れば適切な指定箇所がわかると思います。inverseOf相手が hasOne のとき のみに指定します。

よほど変わったトリックを期待しないかぎり、逆はすべきではありません。「事前にひとつしか取得していないものを、1要素の配列として、複数形のプロパティに当てはめる」ことになります。おかしいですね。(まあもっとおかしいのは、Yiiが、やってしまったとき適切にそのような振る舞いになるよう設計されていて、だれがそんな黒魔術使いこなせるのかと...)

さて、もういちど最初のシンプルなコードを見て下さい。まったく同じ呼び出しコードでも、inverseOf によって、レイジーでありながら、レコードが必要になったときの初回アクセス時のみ、SQLが発行されるようになりました。

$article = Article::find()->where(...)->one();

foreach ($article->comments as $comment) {
   echo $view->render('comment', ['comment' => $comment]);
}
comment.php
$blog = $comment->article->blog;
echo mb_strimwdth($comment->body,
    0, $blog->comment_text_limit, '...', 'utf-8'
);
SELECT * FROM article WHERE id=1
SELECT * FROM `comment` WHERE article_id=1
SELECT * FROM blog WHERE id=1

また、余分なSQLが発生しない範囲であれば、オブジェクトの同一性も保証されます。

$article === $article->comments[0]->article;
$article->blog === $article->comments[0]->article->blog;

明示的な populateRecord が必要な場合

inverseOf は「相手が hasOne のときのみ」に使え、「プロパティアクセスをした場合のみ」同一性が保証されるものでした。

では hasMany の場合は?

hasMany で定義される複数形のプロパティは、多くの場合、ページングが必要です。何ヶ月も書き続けたブログの記事を全てメモリにロードすることはできませんね。コメントも、いつ炎上して数千件の苦情が寄せられるかわかりません。

Yii でページングといえば、DataProvider です。全データのうち、ページ番号に応じて適切にスライスした部分を得られる機能です。ActiveRecord 用のDataProvider である ActiveDataProvider が要求するのは、リレーションではなく「任意の ActiveQuery」です。

$dataProvider = new ActiveDataProvider([
    'query' => $article->getComments()->orderBy([
        'created_at' => SORT_DESC
    ]),
]);

1 to 1 の例でも登場した、独立したクエリになってしまいます。これでは inverseOf があてにできません。それよりも、個々の要素をレンダリングにのみ使い、保持させずに捨てるほうが賢明です。なぜなら、どうせ全要素ロードされない(物理的にできない)不完全なものなのだから。結局 hasMany の逆マッピングは「どうせできない場合が多い」のです。

Comment から見て Article は hasOne です。個別の Comment をレンダリングするビューでは、->article->article->blog を他と共有したいものです。

comment.php
$blog = $comment->article->blog;
echo mb_strimwdth($comment->body,
    0, $blog->comment_text_limit, '...', 'utf-8'
);

自動で解決できない独立クエリ、とくれば populateRelation() です。ただ、最初のクエリ自体を遅延評価する DataProvider では、ちょっとブサイクな場所に記述しなければなりません。

echo ListView::widget([
    'dataProvider' => $dataProvider,
    'itemView' => function($comment) use($article) {
        $comment->populateRelation('article', $article);
        return $this->render('comment', ['comment' => $comment]);
    },
]);

クエリが評価される箇所がビューなのでどうしても...

DataProvider でも populateRecord をエレガントにしたい

たとえばこういうの

$dataProvider = new ActiveDataProvider([
    'query' => $article->getComments()->orderBy([
        'created_at' => SORT_DESC
    ])->on(ActiveQuery::EVENT_AFTER_FIND, function($event) use($article) {
        foreach ($event->models as $comment) {
            $comment->populateRelation('article', $article);
        }
    })
]);

...は、次のマイナーバージョンアップにご期待下さい

クリスマス・プルリクエストは間に合いませんでした。

まとめ

みなさん、場合に応じて別んとこに都合のいい相手が作れるチャラいORMは、まあそれはそれで便利なんですが、そういうのだけじゃなく時には、妻の夫は私しかいない、彼女の彼氏は俺だ、と普遍的に言えるORMを使いましょう。この夜を過ごしたカップルが、クリスマスシーズンだけの恋人でありませんように。メリクリ

4
5
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
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?