LaravelでN+1を解決するために雰囲気でeager loadを使っている


## まえおき
laravelでリレーションを張ったモデルを扱っているとN+1問題に遭遇してしまいます。
ググっても動的プロバティは遅延ロードされる、eager loadは熱心なロードです、ってなんやねんって思ったのは私だけでしょうか。
とりあえず`with()`使っとけばいいんでしょ？となんとなくコードを書いてきたので、そろそろ仕組みを知りたいなあと思ったのでコードリーディングの旅に出たのであります。
laravelのバージョンは5.8です。

## 設定

モデルはユーザーと本があり、1:多で結びつきます。

リレーションは以下のように設定しておきます。

```php:User.php
public function books()
{
    return $this->hasMany('App\User');
}
```

## 動的プロパティ
動的プロバティの遅延ロードされるという特性がN+1問題を引き起こしているので、まずは動的プロバティについて追っていきます。

以下のコードを読み解いていきます。


```php
$user = new User();
$user->books;
```


`$user->books`はメソッドを呼んでいるわけではないので、`books()`は呼ばれません。

かといって`books`プロパティを定義しているわけでもないので、マジックメソッドの`__get()`が呼ばれます。

モデルは`Illuminate\Database\Eloquent\Model`を継承しているので、そこの`__get()`を見てみましょう。

```php:Illuminate\Database\Eloquent\Model
public function __get($key)
{
    return $this->getAttribute($key);
}
```

`getAttribute()`が呼ばれてますね。traitで実装されています。（`$key`には`books`が入っています。）

```php:Illuminate/Database/Eloquent/Concerns/HasAttributes.php
public function getAttribute($key)
{

    //...

    return $this->getRelationValue($key);
}
```

```php:Illuminate/Database/Eloquent/Concerns/HasAttributes.php
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()`を見ていきます。

```php:Illuminate/Database/Eloquent/Concerns/HasAttributes.php
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()`でリレーションとデータの紐付けをモデルにセットしています。

```php
public function setRelation($relation, $value)
{
    $this->relations[$relation] = $value;

    return $this;
}
```

今回の場合だと`$user`の`relations`プロパティに

```php
relations['books'] = DBからとってきたデータ
```

という風にリレーションとデータを紐付けていることになります。

`tap()`関数の返り値は第一引数なので、DBからとってきたデータを返していることになります。

つまり`$user->books`はDBからとってきたデータを返すだけでなく、`$user`の`relations`プロパティに値をセットしていることになります。

説明の途中でキャッシュを実現している箇所が出てきていましたが、`relations`プロバティにデータをセットすることで、2回目からは実際にクエリを発行することなくセットされたデータを返すようになっているのです。

```php
$user->books;
$user->books;
```
このように書いてもクエリが発行されているのは1回目のみで2回目は発行されていないことが確認できます。

動的プロバティではアクセスされたときに初めてクエリが発行されるシステムになっています。これをlaravelでは遅延ロードと呼んでいます。

## N+1問題

動的プロバティの遅延ロードという特性上、N+1問題が発生してしまいます。

```php
$users = User::all();
foreach($users as $user) {
    $user->books;   //ループの回数だけクエリが発行される
}
```

同じようなクエリが発行されてします。

```sql
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じゃないんだろう...?）

```php
$users = User::with('books')->get();
```

この時点でユーザーの情報だけでなく本の情報も一括でとればクエリの発行回数が少なくて済むよねって話です。

```sql
select * from users;
select * from books where id in (1, 2, 3, 4, ...);
```

`with()`を使うことによりeager loadがセットされて`get()`時にモデルの`relations`プロバティにデータをセットしてくれます。

`relations`プロバティにデータがセットされるということは、動的プロバティとしてアクセスすればキャッシュが効くので、都度クエリが発行されることはなくなります。

```php
$users = User::with('books')->get();
foreach($users as $user) {
    $user->books;   //キャッシュを使うのでクエリの発行はされない
}
```

これでめでたくN+1問題も解決してドヤ顔でコードを書けるようになりました！

## まとめ

- 動的プロバティはアクセスされたときにクエリを発行してデータをとってくる
- なのでループ処理で動的プロバティにアクセスするとクエリ回数がすごいことになる
- `with` でリレーション先のデータも一括でとってきて、`relations`プロバティにセットする
- `relations`プロバティにデータがセットされていれば、動的プロバティはクエリを発行せずにそのデータを使ってくれる