前回のあらすじ
ユーザーが所属する会社を横断したプロジェクトに属している場合に、ログインユーザーと関わりのあるプロジェクトの会社名すべて取得するリレーションを 紹介した。
$user = User::find(1);
$names = $user->projects->pluck('users')
->flatten()->pluck('company')
->pluck('name')->unique();
しかし、このままではループの中でクエリが発行される「N+1問題」が生じ、クエリ数がとんでもないことになってしまう。
このことについて、実際にデータを作成して検証してみる。
テスト用のデータ作成はこちらを参照。
tinker でリレーションを試す
今回はクエリを確認するために tinker を起動したらすぐに DB::enableQueryLog()
を実行する。
$ php artisan tinker
Psy Shell v0.11.4 (PHP 7.4.11 — cli) by Justin Hileman
>>> DB::enableQueryLog()
=> null
これ以降の操作で発生したクエリは DB::getQueryLog()
で確認することができる。ユーザーを1件指定して、リレーションで紐づく関連会社の名前を取得してみる。
>>> $user = User::find(1);
[!] Aliasing 'User' to 'App\Models\User' for this Tinker session.
=> App\Models\User {#3588
id: 1,
name: "鈴木 修平",
email: "cyamada@example.com",
company_id: 1,
email_verified_at: "2022-06-19 05:12:17",
#password: "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi",
#remember_token: "14rSAboFzn",
created_at: "2022-06-19 05:12:17",
updated_at: "2022-06-19 05:12:17",
}
>>> $names = $user->projects->pluck('users')->flatten()->pluck('company')->pluck('name')->unique();
=> Illuminate\Support\Collection {#3605
all: [
0 => "有限会社 喜嶋",
3 => "有限会社 渚",
5 => "有限会社 藤本",
10 => "株式会社 伊藤",
13 => "株式会社 吉本",
15 => "有限会社 村山",
21 => "有限会社 佐々木",
27 => "有限会社 山岸",
30 => "株式会社 山田",
],
}
>>> count(DB::getQueryLog())
=> 234
>>> collect(DB::getQueryLog())->sum('time')
=> 41.26
N+1が発生してるのが明らかなクエリ数だ。レコードの件数が増えれば、クエリ数も実行時間も比例して増大していくだろう。
どんなクエリが発行されたかログを確認してみる。
>>> DB::getQueryLog()
=> [
[
"query" => "select * from `users` where `users`.`id` = ? limit 1",
"bindings" => [
1,
],
"time" => 1.55,
],
[
"query" => "select `projects`.*, `project_user`.`user_id` as `pivot_user_id`, `project_user`.`project_id` as `pivot_project_id` from `projects` inne
r join `project_user` on `projects`.`id` = `project_user`.`project_id` where `project_user`.`user_id` = ?",
"bindings" => [
1,
],
"time" => 0.79,
],
[
"query" => "select `users`.*, `project_user`.`project_id` as `pivot_project_id`, `project_user`.`user_id` as `pivot_user_id` from `users` inner join
`project_user` on `users`.`id` = `project_user`.`user_id` where `project_user`.`project_id` = ?",
"bindings" => [
2,
],
"time" => 0.51,
],
[
"query" => "select `users`.*, `project_user`.`project_id` as `pivot_project_id`, `project_user`.`user_id` as `pivot_user_id` from `users` inner join
`project_user` on `users`.`id` = `project_user`.`user_id` where `project_user`.`project_id` = ?",
"bindings" => [
4,
],
"time" => 0.34,
],
~~(省略)~~
[
"query" => "select `users`.*, `project_user`.`project_id` as `pivot_project_id`, `project_user`.`user_id` as `pivot_user_id` from `users` inner join
`project_user` on `users`.`id` = `project_user`.`user_id` where `project_user`.`project_id` = ?",
"bindings" => [
9,
],
"time" => 0.31,
],
[
"query" => "select * from `companies` where `companies`.`id` = ? limit 1",
"bindings" => [
1,
],
"time" => 0.27,
],
[
"query" => "select * from `companies` where `companies`.`id` = ? limit 1",
"bindings" => [
1,
],
"time" => 0.16,
],
~~(以下略)~~
いくつかのクリエがくり返されているが、ログを from で検索 してみると理解しやすくなる。順に……
-
from users
が1回 -
from projects
が1回 -
from users
がいくつか -
from companies
が延々と続く
そこで、ユーザーを起点して会社までのリレーション名(テーブル名ではない)をドット記法でつなげた projects.users.company
を load メソッドで指定することにする(Eager ロード)。
クエリログを初期化するために tinker を立ち上げなおす。
$ php artisan tinker
Psy Shell v0.11.4 (PHP 7.4.11 — cli) by Justin Hileman
>>> DB::enableQueryLog()
=> null
>>> $user = User::find(1);
[!] Aliasing 'User' to 'App\Models\User' for this Tinker session.
=> App\Models\User {#3590
~~(省略)~~
}
>>> $user->load('projects.users.company')
load コマンドの実行結果がズラズラ表示されるがここでは省略する。
スクロールが止まったら、いよいよリレーションを取得。
>>> $names = $user->projects->pluck('users')->flatten()->pluck('company')->pluck('name')->unique();
=> Illuminate\Support\Collection {#4358
all: [
0 => "有限会社 喜嶋",
3 => "有限会社 渚",
5 => "有限会社 藤本",
10 => "株式会社 伊藤",
13 => "株式会社 吉本",
15 => "有限会社 村山",
21 => "有限会社 佐々木",
27 => "有限会社 山岸",
30 => "株式会社 山田",
],
}
>>> count(DB::getQueryLog())
=> 4
>>> collect(DB::getQueryLog())->sum('time')
=> 3.67
結果は同じなのに、クエリ数はたったの4つ!!
この4つとは、先に調べたように from したテーブルの数だ。クエリの内容を確認みると、必要な値を先に取得して一度ずつ取得しているのがわかる。
>>> DB::getQueryLog()
=> [
[
"query" => "select * from `users` where `users`.`id` = ? limit 1",
"bindings" => [
1,
],
"time" => 1.53,
],
[
"query" => "select `projects`.*, `project_user`.`user_id` as `pivot_user_id`, `project_user`.`project_id` as `pivot_project_id` from `projects` inner join `project_user` on `projects`.`id` = `project_user`.`project_id` where `project_user`.`user_id` in (1)",
"bindings" => [],
"time" => 0.72,
],
[
"query" => "select `users`.*, `project_user`.`project_id` as `pivot_project_id`, `project_user`.`user_id` as `pivot_user_id` from `users` inner join `project_user` on `users`.`id` = `project_user`.`user_id` where `project_user`.`project_id` in (2, 4, 5, 6, 7, 8, 9)",
"bindings" => [],
"time" => 0.96,
],
[
"query" => "select * from `companies` where `companies`.`id` in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)",
"bindings" => [],
"time" => 0.46,
],
]
レコード数が増えてもクエリ数は変わらなくなった。
Eager ロードは load と with
今回はユーザーを1件取得後に Eager ロードするため load を使用したが、ユーザー一覧的に取得する場合には with を用いる(load のほうが「遅延 Eager ロード」と呼ばれる)。
複数のリレーションを Eager ロードしなければならないときには、load も with も配列で指定できる。次の例では「所属会社」のところでもN+1が起きるので、配列で指定を追加してる。
Controller
$users = User::with(['company', 'projects.users.company'])->get();
Blade テンプレート
<tr>
<th>ID</th>
<th>氏名</th>
<th>所属会社</th>
<th>関連会社</th>
</tr>
@foreach ($users as $user)
<tr>
<td>{{ $user->id }}</td>
<td>{{ $user->name }}</td>
<td>{{ $user->company->name }}</td>
<td>{{ $user->projects->pluck('users')->flatten()->pluck('company')->pluck('name')->unique()->join('、') }}</td>
</tr>
@endofreach
with 指定で思考停止しない
モデルに記述されたリレーションをなんでもかんでも with 指定すればよい……とか、なんだかよくわからないけど with は指定しなければならない……等と思考停止はしない。with は書かなくても動く のであり、使いもしないリレーションを指定すればかえって無駄なクエリが実行されるものだ。
- 最初は with を(無駄に)つけず にプログラムを書き、出力結果の正しさを優先する。
- 画面表示(または API 出力)が確定したら Lravel Telescope でクエリログを調べる。
- Blade 画面表示なら laravel-debugbar でもOK。
- クエリ数を確認しながら with に指定するリレーションを追加する。