まえおき
laravelでリレーションを張ったモデルを扱っているとN+1問題に遭遇してしまいます。
ググっても動的プロバティは遅延ロードされる、eager loadは熱心なロードです、ってなんやねんって思ったのは私だけでしょうか。
とりあえずwith()
使っとけばいいんでしょ?となんとなくコードを書いてきたので、そろそろ仕組みを知りたいなあと思ったのでコードリーディングの旅に出たのであります。
laravelのバージョンは5.8です。
設定
モデルはユーザーと本があり、1:多で結びつきます。
リレーションは以下のように設定しておきます。
public function books()
{
return $this->hasMany('App\User');
}
動的プロパティ
動的プロバティの遅延ロードされるという特性がN+1問題を引き起こしているので、まずは動的プロバティについて追っていきます。
以下のコードを読み解いていきます。
$user = new User();
$user->books;
$user->books
はメソッドを呼んでいるわけではないので、books()
は呼ばれません。
かといってbooks
プロパティを定義しているわけでもないので、マジックメソッドの__get()
が呼ばれます。
モデルはIlluminate\Database\Eloquent\Model
を継承しているので、そこの__get()
を見てみましょう。
public function __get($key)
{
return $this->getAttribute($key);
}
getAttribute()
が呼ばれてますね。traitで実装されています。($key
にはbooks
が入っています。)
public function getAttribute($key)
{
//...
return $this->getRelationValue($key);
}
public function getRelationValue($key)
{
//キャッシュを実現してるとこ
if ($this->relationLoaded($key)) {
return $this->relations[$key];
}
if (method_exists($this, $key)) {
return $this->getRelationshipFromMethod($key);
}
}
1つ目のif文でキャッシュを実現しています。(N+1解消のときに重要です。)
今の時点では関係がないので2つ目のif文を見てみます。
method_exists($this, $key)
の部分ですが、$this
が呼び出し元のオブジェクト(今回だと$user
)を指しているので、$user
にbooks()
というメソッドがあるかどうかを判定しています。
リレーションとしてbooks()
を定義したのでありますね。なのでgetRelationshipFromMethod()
を見ていきます。
protected function getRelationshipFromMethod($method)
{
$relation = $this->$method();
//...
return tap($relation->getResults(), function ($results) use ($method) {
$this->setRelation($method, $results);
});
}
$this->$method()
の部分は$user->books()
ということです。結局定義したメソッドを呼んでます。
tap()
はヘルパー関数で第二引数のクロージャに第一引数を渡して、第一引数を返します。
$relation->getResults()
で実際にクエリを発行してDBからデータをとってきます。
クロージャの中の$this->setRelation()
でリレーションとデータの紐付けをモデルにセットしています。
public function setRelation($relation, $value)
{
$this->relations[$relation] = $value;
return $this;
}
今回の場合だと$user
のrelations
プロパティに
relations['books'] = DBからとってきたデータ
という風にリレーションとデータを紐付けていることになります。
tap()
関数の返り値は第一引数なので、DBからとってきたデータを返していることになります。
つまり$user->books
はDBからとってきたデータを返すだけでなく、$user
のrelations
プロパティに値をセットしていることになります。
説明の途中でキャッシュを実現している箇所が出てきていましたが、relations
プロバティにデータをセットすることで、2回目からは実際にクエリを発行することなくセットされたデータを返すようになっているのです。
$user->books;
$user->books;
このように書いてもクエリが発行されているのは1回目のみで2回目は発行されていないことが確認できます。
動的プロバティではアクセスされたときに初めてクエリが発行されるシステムになっています。これをlaravelでは遅延ロードと呼んでいます。
N+1問題
動的プロバティの遅延ロードという特性上、N+1問題が発生してしまいます。
$users = User::all();
foreach($users as $user) {
$user->books; //ループの回数だけクエリが発行される
}
同じようなクエリが発行されてします。
select * from users;
select * from books where id = 1
select * from books where id = 2
select * from books where id = 3
select * from books where id = 4
//...
そこでlaravelはeager loadという解決策を用意してくれています。(なんでpreloadじゃないんだろう...?)
$users = User::with('books')->get();
この時点でユーザーの情報だけでなく本の情報も一括でとればクエリの発行回数が少なくて済むよねって話です。
select * from users;
select * from books where id in (1, 2, 3, 4, ...);
with()
を使うことによりeager loadがセットされてget()
時にモデルのrelations
プロバティにデータをセットしてくれます。
relations
プロバティにデータがセットされるということは、動的プロバティとしてアクセスすればキャッシュが効くので、都度クエリが発行されることはなくなります。
$users = User::with('books')->get();
foreach($users as $user) {
$user->books; //キャッシュを使うのでクエリの発行はされない
}
これでめでたくN+1問題も解決してドヤ顔でコードを書けるようになりました!
まとめ
- 動的プロバティはアクセスされたときにクエリを発行してデータをとってくる
- なのでループ処理で動的プロバティにアクセスするとクエリ回数がすごいことになる
-
with
でリレーション先のデータも一括でとってきて、relations
プロバティにセットする -
relations
プロバティにデータがセットされていれば、動的プロバティはクエリを発行せずにそのデータを使ってくれる