55
46

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 3 years have passed since last update.

【Laravel】動的プロパティとリレーションメソッド。そしてN+1問題を真剣に調べてみた

Posted at

##はじめに
PFを作るときにリレーションのデータを取得する方法に苦しんだことと、よく聞くN+1問題について概念程度しか知らなかったので一度しっかり学んでおこうと思い調べまとめてみました。

##結論まとめ
$user->posts();はリレーションオブジェクトでクエリビルダが使える

$user->posts;は動的プロパティを呼び出しコレクションを返す

N+1問題はlazyloadが原因で起きるのでeagarloadしてやって解決する!

はい何のことかわからないので自分で調べた結果を自分なりに解釈してまとめてみました。

##動的プロパティとリレーションメソッド
UserテーブルとPostテーブルがあるとして、両テーブルは1対多の関係になっています。

User.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    public function posts()
    {
        return $this->hasMany('App\Post');
    }
}

このような関係で以下のような2つの具体的な違いについてわからずなんとなくで過ごしてきました・・・。

$user->posts;
$user->posts();

結論から先に述べるとこの2つには決定的な違いがあります。
それはEloquantリレーション動的プロパティという違いがあるということです。
###$user->postsは動的プロパティ
idが1のユーザーの全ての投稿を取得したいとします。

idが1のユーザーの全ての投稿を取得
$user = User::find(1);
$posts = $user->posts; //select * from `posts` where `posts`.`user_id` = 1;

dd($posts)で中身を見ていくと

Illuminate\Database\Eloquent\Collection {#1263 ▼
  #items: array:3 [▼
    0 => App\Post {#1266 ▶}
    1 => App\Post {#1267 ▶}
    2 => App\Post {#1268 ▶}
  ]
}

のようにPostモデルのインスタンスのコレクションが返ってきています.
$user->posts->titleとすればそのユーザーの持つ投稿のタイトルにアクセスできます。
つまり$user->postsは動的プロパティを呼び出すということになります。

###動的プロパティって何ですかorz
なぜ動的プロパティと呼ばれるか見ていきます。
dd($user)$userの中身を見ていきます。

App\User {#1260 ▼
//中略
  #attributes: array:9 [▶]
//中略
  #relations: array:1 [▼
    "posts" => Illuminate\Database\Eloquent\Collection {#1263 ▼
      #items: array:3 [▶]
    }
  ]
//以下略

attributesにはUserモデルの値が格納されていて、relationsにはkey名に定義したリレーションメソッド名valueにリレーションメソッドの制約にしたがって取得されたインスタンスのコレクションにした連想配列が入っています。
HasManyであればコレクション、HasOneであればひとつのモデルインスタンスが入ります。
attributesには固定の値、relationsにはリレーションによって値が変わるので動的プロパティと呼ばれるみたいです。

###$user->posts()はリレーションオブジェクトを返す
$user->posts()の値を$postsに代入して確認してみると

//$user->posts()を$postsに代入
$user = User::find(1);
$posts = $user->posts();
dd($posts);

dd($posts)の内容は以下のように表示されます

Illuminate\Database\Eloquent\Relations\HasMany {#1259 ▼
  #foreignKey: "posts.user_id"
  #localKey: "id"
  #query: Illuminate\Database\Eloquent\Builder {#1253 ▶}
  #parent: App\User {#1260 ▶}
  #related: App\Post {#1248 ▶}
}

これはリレーションオブジェクトといい、こういう関係でリレーションしていますよという内容が表示されているだけです。
なぜこのような内容を返すのかというと先程のUser.php

User.php
    public function posts()
    {
        return $this->hasMany('App\Post');
    }

と定義されていてUserモデルのpostsメソッドを呼び出したときはHasManyオブジェクトを返すようになっているのでリレーションオブジェクトが返されます。
リレーションメソッドはクエリビルダとして使用することができます。
しかし動的プロパティのようにカラム名をつけて値を取得することはできません。
リレーションメソッドのあとにget()やfirst()をつけてデータを取得できます。
get()で取得した場合はコレクションで返ります。
クエリビルダはLaravelをSQLの記法を書きやすくするものです。

公式ドキュメント クエリビルダ
https://readouble.com/laravel/5.7/ja/queries.html

ユーザーのidが1で有効な投稿を全て取得
$user = User::find(1);
$posts = $user->posts()->where('active', 1)->get();

このようにリレーションオブジェクトは使うことができます。
しかし、コレクションが返ってくる動的プロパティではクエリビルダをつなげることができませんので注意して下さい。
##N+1問題とは?
何かとよく聞くN+1問題。
例えば、ユーザーの情報を取得しそのユーザーが持つ投稿の情報を取得したいとして以下のようなコードを書きます

N+1問題が起きるコード
//全てのユーザ情報を取得
$users = User::all()
//それぞれのユーザーデータを取得する
foreach($users as $user) {
    $user->posts;
}

するとこのようなクエリ文が発行されてしまいます。

発行されたクエリ文
select * from users;
select * from posts where user_id = 1; 
select * from posts where user_id = 2; 
select * from posts where user_id = 3; 
.
.
.

select * from posts where user_id =といったクエリ分はレコードの数だけ発行され続けるのでこれが10件程度であればよいのですが、100件はたまた1万件となってはデータを取得するために時間がかかりすぎてサーバーを圧迫してしまいます。
**全ての情報の数をN、そしてそれに加えて全ユーザー情報を取得するクエリ(+1)だけクエリを発行するのでN+1問題と呼ばれます。 **もし上記の例でpostsの数が25であるとすれば26回クエリが発行されるということになります。

クエリ文と重ねてみる
//全てのユーザ情報を取得
$users = User::all() //select * from users;
//それぞれのユーザーデータを取得する
foreach($users as $user) {
    $user->posts;  //select * from posts where user_id = ~ レコードの数だけ繰り返される
}

なのでレコードの数+1回分のクエリが発行されてしまうのですね。

###ではなぜN+1問題が発生するのか
$user->postsは動的プロパティです。
動的プロパティにはそのリレーション先のプロパティが参照されるまでロードされないという**遅延ロード(またはlazyload)**と呼ばれる性質があります。
※lazy(レイジー):だらしない、怠惰。"The lazy song"ってBrunoMarsの曲にもありますね(余談)
遅延ロードは必要最低限のデータがロードされるのでリソースに優しいという点がありますがN+1問題で圧迫している以上解決する必要があります。そこで活用するのがeagerloadです。
※eager(イーガー) :熱心 餃子の王将でよく聞こえるワードではない

動的プロパティは「遅延ロード」されます。つまり実際にアクセスされた時にだけそのリレーションのデータはロードされます。そのため開発者は多くの場合にEagerローディングを使い、モデルをロードした後にアクセスするリレーションを前もってロードしておきます。Eagerロードはモデルのリレーションをロードするため実行されるSQLクエリを大幅に減らしてくれます。

##eagarloadするにはwithメソッドを使う!

プロパティとしてEloquentリレーションにアクセスする場合、そのリレーションデータは「遅延ロード」されます。つまり、そのリレーションデータへ最初にアクセスするまで、実際にはロードされません。しかし、Eloquentでは、親のモデルに対するクエリと同時にリレーションを「Eagerロード」可能です。EagerロードはN+1クエリ問題の解決策です。

つまり当初、親モデル(この場合Userモデル)にアクセスするときにはlazyloadをしてしまっているので、eagarloadすることによってN+1問題を解決できるということになると考えられます。
そしてこのeagarloadをするのがwithメソッドになります。

withメソッドを使ってみる
$users = User::with('posts')->get();
foreach($users as $user) {
    $user->posts;
}
クエリログを見てみる
select * from users;
select * from posts where id in (1,2,3,4....);

このようにeagarloadを用いると先程はレコードの数だけ発行されていたクエリも2つに抑えることができるようになりました!
eagarloadと lazyloadでどのような違いがあるのか確認してみます。

###eagarloadとlazyloadの違い
eagarloadされたコレクションを見ていきます。

eagarload
$users = User::with('posts')->get();
foreach($users as $user) {
    $user->posts;
}
eagarloadでの中身
  #items: Illuminate\Database\Eloquent\Collection {#1252 ▼
    #items: array:3 [▼
      0 => App\User {#1255 ▼
        #dates: array:2 [▶]
        #fillable: array:4 [▶]
        #connection: "mysql"
        #table: "articles"
        #primaryKey: "id"
        #keyType: "int"
        +incrementing: true
        #with: []
        #withCount: []
        #perPage: 15
        +exists: true
        +wasRecentlyCreated: false
        #attributes: array:9 [▶]
        #original: array:9 [▶]
        #changes: []
        #casts: []
        #dateFormat: null
        #appends: []
        #dispatchesEvents: []
        #observables: []
        #relations: array:1 [▼
          "posts" => App\Post {#1261 ▶}
        ]
        #touches: []
        +timestamps: true
        #hidden: []
        #visible: []
        #guarded: array:1 [▶]
        #forceDeleting: false
      }
      1 => App\Post {#1256 ▶}
      2 => App\Post {#1257 ▶}
    ]
  }

App\Postの右の右にあるを押してもらえばその中にPostの内容が入っています!
よってeagarloadすることによってリレーション先の情報を取得しているのでまたデータを取得する必要がなくなっているということになります!
これが

プロパティとしてEloquentリレーションにアクセスする場合、そのリレーションデータは「遅延ロード」されます。つまり、そのリレーションデータへ最初にアクセスするまで、実際にはロードされません

この部分に当たるのですね・・・。

ではlazyloadではどうなっているのでしょうか?
lazyloadで返されたコレクションの中身を確認します。

lazyload
$users = User::all()
dd($users)
lazyloadでの中身
  #items: Illuminate\Database\Eloquent\Collection {#1253 ▼
    #items: array:3 [▼
      0 => App\User {#1254 ▼
        #dates: array:2 [▶]
        #fillable: array:4 [▶]
        #connection: "mysql"
        #table: "posts"
        #primaryKey: "id"
        #keyType: "int"
        +incrementing: true
        #with: []
        #withCount: []
        #perPage: 15
        +exists: true
        +wasRecentlyCreated: false
        #attributes: array:9 [▶]
        #original: array:9 [▶]
        #changes: []
        #casts: []
        #dateFormat: null
        #appends: []
        #dispatchesEvents: []
        #observables: []
        #relations: [] //なにもない!!
        #touches: []
        +timestamps: true
        #hidden: []
        #visible: []
        #guarded: array:1 [▶]
        #forceDeleting: false
      }
      1 => App\User {#1255 ▶}
      2 => App\User {#1256 ▶}
    ]
  }

リレーション先のプロパティにアクセスされていないので何もeagarloadの時と違い、relationsには何も入ってませんでした。これがN+1を引き起こすクエリを出す理由になるのだとようやくわかりました。

##感想
冒頭にも述べたとおり、N+1問題の概念程度は知っていてwithメソッドを使うことで解決ができるということは知っていました。
しかしどういった理由で引き起こされてしまうのかしっかりと理解していなかったのでこれを機会に学んでみた結果、すごく大事なことをたくさん知ることができたと感じました。
複数ページなどを参考に自分なりに解釈しながら書いたのでもし間違っていればコメントで突っ込んで下さい・・・
$user->post;$user->posts();が全く別物だったと知ることができたのも大きな収穫でした・・・。
N+1問題しっかり知っておけよ!とよく言われますが知らべてみると、しっかり解説してあるページってなかなかないものですね・・・。
これで面接で聞かれても胸はって答えることもできる??
##参考リンク
クエリビルダ
【Laravel】N+1問題を完全理解!解消法も!
LaravelのEagerLoadまとめ。動的プロパティとEloquentリレーションの違いなど
【Laravel】Eloquentを理解する
【Laravel】リレーションの違いを整理してみた
[Laravel] Eloquent リレーションと Eager Loading

55
46
1

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
55
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?