8
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 1 year has passed since last update.

LaravelでEager Loadingしていたつもりが、まったくしていなくて泣いた

Last updated at Posted at 2022-10-22

普段LT会のネタ用に取っておいたりするのですが、感情が爆発しているので産地直送でお届けします。

追記:未解決でしたが解決しました

泣くことになった経緯

趣味で「かどで日記」という日記サービスを運営していて、そこでのお話です。

戦犯となるページ

指定した月の日記を表示するページです。何の変哲も無いですね。
image.png

環境

Laravel:9系
PHP:8.1

今回の登場人物となるテーブル
image.png
diariesテーブルに日記の本文やタイトルなどが入っており、statistic_per_datesテーブルに日記の統計情報(固有表現情報、感情分析値など)が入っています。
ご覧の通りdiary_idで繋がってる形ですね。

※diary_processedsには日記の統計情報の元となる自然言語処理したデータ(形態素解析した情報など)が入っていますが今回は直接関係ないので割愛です。

具体的な処理

先程のページを作るために必要なのは、指定した月の日記とその統計情報の取得です。

日記と一緒にその日記の統計情報も表示したい訳なので、まずはDiaryモデルからwithできるようにHasOneで関連付けておいて……

app\Models\Diary.php
public function statisticPerDate(): HasOne
{
    return $this->hasOne(StatisticPerDate::class);
}

下のようにDiaryモデルをコントローラーで呼び出してbladeで表示する形ですね。

app\Http\Actions\Diary\ShowMonthDiaryAction.php
final class ShowMonthDiaryAction extends Controller
{
    public function __construct(
        private GetMonthlyStatisticByMonth $getMonthlyStatisticByMonth,
        private GetDiariesByMonth $getDiariesByMonth,
    ) {
    }

    public function __invoke(int $year, int $month): View|Factory
    {
        $diaries = $this->getDiariesByMonth->invoke($year, $month);
        $statisticPerMonth = $this->getMonthlyStatisticByMonth->invoke($year, $month);

        return view('diary/archive/monthArchive', ['diaries' => $diaries, 'year' => $year, 'month' => $month, 'statisticPerMonth' => $statisticPerMonth]);
    }
}

といっても実際の処理はUseCasesでしてまして、下記のような形です。

app\UseCases\Diary\GetDiariesByMonth.php
public function invoke(int $year, int $month): array
{
    $diaries = Diary::with('StatisticPerDate')->whereYear('date', $year)->whereMonth('date', $month)->orderby('date', 'desc')->get();

    $arrangedDiaries = [];
    foreach ($diaries as $diary) {
        $statisticStatus = $this->checkStatisticStatusByDiary->invoke($diary);
        $arrangedDiaries[] = $this->arrangeDiaryStatistic->invoke($diary, $statisticStatus)->toArray();
    }

    return $arrangedDiaries;
}

ここで日記のデータをDBから読み出しています。Diary::with('StatisticPerDate')することで統計情報が入ったテーブルの値も一緒に持ってくる形ですね。
ループの中で統計情報が入ったテーブルをwhereで呼び出したりしていないなんて、えらいなぁ……

ループの中で呼んでるcheckStatisticStatusByDiaryクラスでは日記の統計情報の状態をEnumで持たせることで表示の制御の処理をしやすくしています。

app\UseCases\Diary\Statistic\CheckStatisticStatusByDiary.php
/**
 * 日記の統計情報のチェックを行い、Enumで返す.
 */
public function invoke(Diary $diary): StatisticStatus
{
    return match (true) {
        null === $diary->statisticPerDate => StatisticStatus::notExist,
        100 !== $diary->statisticPerDate->statistic_progress => StatisticStatus::generating,
        $diary->statisticPerDate->updated_at < $diary->updated_at => StatisticStatus::outdated,
        true => StatisticStatus::existCorrectly,
    };
}

必ずしも日記に対して統計情報が存在する訳ではないので(日記が作られた直後など)null === $diary->statisticPerDateのところで統計情報が入ってなかったら無いよっていうEnumの値にしている形ですね。

そして実行結果を見る。

Laravel debuggerを使うと内部で生成されたSQLが見れます。さてさて、日記とその統計情報はwithで呼び出してるしクエリ数も少なくできちゃうんだよなぁ……!

image.png

…………!?

image.png

34???

N+1問題のお手本みたいなクエリ数。

異常クエリの発生源をたどる

Laravel Debuggerではクエリを発行した位置まで示してくれるんですよ……(便利~)
image.png

この画像の1行目は統計情報をまとめて取っているクエリですね。

select * from `statistic_per_dates` where `statistic_per_dates`.`diary_id` in (112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141)

発行場所はapp/UseCases/Diary/GetDiariesByMonth.phpです。
Diary::with('statisticPerDate')で生成されてますね。納得です。

2行目から、1行目でまとめて取ったデータを1つずつ再度データベースから取り出す謎の処理が走っています。なんだこれ……

select * from `statistic_per_dates` where `statistic_per_dates`.`diary_id` = 141 and `statistic_per_dates`.`diary_id` is not null limit 1
select * from `statistic_per_dates` where `statistic_per_dates`.`diary_id` = 140 and `statistic_per_dates`.`diary_id` is not null limit 1
-- 以下略

発行場所はapp/UseCases/Diary/Statistic/CheckStatisticStatusByDiary.phpです。
Enumの値を決定するnull === $diary->statisticPerDate => StatisticStatus::notExistしていることろでした……

Eager Loading

LaravelではDiary::with('statisticPerDate')のようにすることでEager Loadingの恩恵にあやかれます。
Eager Loadingは大雑把に言えば予め必要なデータを取ってきて、毎度クエリ叩かないようにするものです。(joinするわけではないので、最低1クエリは増えますが……)
逆に言えば、これ無しでは$statistic = $diary->statisitcPerDateみたいなコードを書くだけで裏側ではクエリを発行してデータを都度取ってきてしまいます。

ということは、本来であれば先程の画像のようなことは起きないはずです。withしてますから。

予めそれぞれの日記の統計情報取ってきているので、そのあと再度

select * from `statistic_per_dates` where `statistic_per_dates`.`diary_id` = 141 and `statistic_per_dates`.`diary_id` is not null limit 1
select * from `statistic_per_dates` where `statistic_per_dates`.`diary_id` = 140 and `statistic_per_dates`.`diary_id` is not null limit 1
-- 以下略

と同じデータを取ることは完全な無駄です。

なぜ………

物語は意外な結末を迎えまする予定でしたが、迎えませんでした。

HasOneとBelongsTo

一番最初にお見せしたコードに

app\Models\Diary.php
public function statisticPerDate(): HasOne
{
    return $this->hasOne(StatisticPerDate::class);
}

こんなものがありました。Diary::with('statisticPerDate')するためにDiaryモデルに定義している他のモデルとの関連付けですね。
これによりDiary::withのように使うことで他のテーブルの情報も取ってこれる形です。

ご存知のとおりテーブル同士の関連付けには数種類のパターンがあります。
LaravelではHasOne,HasMeny,BelogsToみたいな形として表されます。

HasOne

HasOneは1対1の関係を表します。
例えば日記(diaries)とその日記の統計情報(statistic_per_dates)の関係ですね。

RDBでは統計情報側(statistic_per_dates)にdiary_idというカラムを持たせて日記側(diaries)と紐付けられるようにします。こんな形。

※statistic_per_datesは日毎の統計データの意ですが、原則1つの日記は1つの日付なので1対1対応です

HasMany

HasManyは1対多の関係を表します。
例えばユーザー(users)と日記(diaries)の関係ですね。

1人のユーザーに対して複数の日記が紐づきます。

RDBでは日記側にはuser_idというカラムを持たせて関連付けている形です。

BelongsTo

BelongsToも1対1の関係を表しますがHasOneとは逆です。HasOneの例を流用すると

逆版ですね。

つまり?

日記モデルDiaryから個別の統計情報モデルStatisticPerDiaryへはHasOneが適切だと考えられます。
ということで、日記モデルと日記の統計情報は

app\Models\Diary.php
    public function statisticPerDate(): HasOne
    {
        return $this->hasOne(StatisticPerDate::class);
    }

と、最初に示した通りDiary.phpに書けばよい訳ですね。ですよね……? 
ちょっと不安なのでBelongsToでも書いてみようかな……

BelongsToに変えてみた

app/Models/Diary.php
public function statisticPerDate(): BelongsTo
{
    return $this->belongsTo(StatisticPerDate::class);
}

これは違うはずなんだけど…………

おもむろにローカルホストで開くと
image.png
image.png

4クエリになりました!!
兵どもが夢の跡。劇的ビフォーアフターですね。

いやー、HasOneとBelongsToを間違えるなんて、おっちょこちょいだなぁ……

・・・・・・・・?

・・・・・・・?

・・・・・・?

・・・・?

・・?

・?

image.png

where 0 = 1???

…………

気付けないよそんなの……

Laravelだと変哲もないコードで実はクエリ発行してますとか、実はCarbonのインスタンス化やってますみたいな、まるで外からは見えないけどこのケーキ屋さん、カフェも併設してるみたいなノリがよくあります。中まで見ないと気付けないんですよ……

今回は月ごとの日記データだったので高々30クエリな訳ですが、数が増えるに従ってO(n)的な形で増えていきます……Laravelを低速化する技術の完成です。

結局のところ私のミスではあるのですが、HasOneとBelongsToを逆にしても、クエリ数が20増えたところで9ミリ秒しか変わってなかったため、Debuggerでクエリでも確認しない限り気づけなかった……

解決していませんよ??

執筆途中where 0=1に気づかず、無事クエリ4つに減って解決したし記事書いて寝ようと、たかを括っていました。
でも気づいてしまったんです。where 0=1に……
このクエリだとテーブル内のすべての情報取ってきているので、最初より悪化しています。気づけてよかった……

やはりHasOneが正しい気がする。でもクエリ異常に増える……
分かりません……ぐぬぬです……もうEloquentやめてクエリビルダで取ってこようかな……

???「いかがでしたか? 調べた結果、よく分かりませんでした!」
これだけはしたくなかった……

その謎を解明すべく、我々はアマゾンの奥地へ向かいかけた……

LaravelのEloquentなんて、読むだけでcomnpassのイベントになるほどの巨大な世界です。
ちょっとだけ足を踏み入れました。

BelongsToを探ろうとしたけど最初から違ったので帰る

app/Models/Diary.php
public function statisticPerDate(): BelongsTo
{
    return $this->belongsTo(StatisticPerDate::class);
}

こちらのbelongsToの引数は第1引数以外省略できるのですが、省略しない場合は第2引数にStatisticPerDateの外部キー、第3引数にDiaryの対応するキー名を入れる形です。
省略された場合は自動で探してくれて、

laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasRelationships.php
//中略
if (is_null($foreignKey)) {
    $foreignKey = Str::snake($relation) . '_' . $instance->getKeyName();
}
//中略
$ownerKey = $ownerKey ?: $instance->getKeyName();
//中略

みたいな形で推測されます。結果として
image.png

第2引数となる$foreignKeyにstatistic_per_date_id、第3引数となる$localKeyにidが入っているみたいです。おっとこれはおかしい……

こうしたいのにぜんぜん違う……
この時点で明らかに違いますよね……自動で推論された結果が意図したものと反対なので、やはりBelongsToでなくHasOneを使うのが適切です。

Eloquent巨大すぎて間違った$foreignKey$ownerKeyがどうなれば 0 = 1になるのか辿るのは諦めました……ごめんなさい……この時点で違う訳ですから……
、どこかで暗黙的な型変換が走ってintキャストされたのかなみたいな想定しかできません……

HasOneでやっぱり正しいよね……

hasOneもbelongsToと同様第2引数以降は省略できます。

laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasRelationships.php
//中略
$foreignKey = $foreignKey ?: $this->getForeignKey();

$localKey = $localKey ?: $this->getKeyName();
//中略

hasOneのメソッド見たところ、書き方違ってびっくりです……モダンの牙を向けてきましたね……

で、結果は
image.png

第2引数となる$foreignKeyにdiary_id、第3引数となる$localKeyにidが入っているみたいです。そうそうこれですよ、これ……!
なのでやはりHasOneで間違いはなさそうです。

ただ、肝心のEager Loadingが効かない部分が辿れていません……Eloquentでかすぎる……

気づき

もしかしてControllerの継承クラスじゃないとだめなのかな……とか色々探って見たりしてましたが一向に改善の兆しが見えません。
いやー、ちゃんとwithしてるのになぁ……

\DB::enableQueryLog();
$diaries = Diary::with('StatisticPerDate')->whereYear('date', $year)->whereMonth('date', $month)->orderby('date', 'desc')->get();
foreach ($diaries as $diary) {
    echo $diary->statisticPerDate->updated_at;
}
dd(\DB::getQueryLog());

Laravel Debuggerにバグがあって、実はクエリ数少ない説とかないのかな……とdd()でクエリを見たりもしたのですが、やはり異常なクエリ数は変わっていません。

わからないすぎる……ちゃんとwithしてるのになぁ……
image.png
ちゃんとHasOneにしてるのになぁ……
image.png

………………

………

…!?

image.png
あれ、先頭大文字になってません????

with()の引数には文字列または配列でDiaryで定義したメソッド名を入れるものです。
ということで、本来ここは
image.png
statisticPerDateメソッドですから、Sでなく、sが入るべきですよね。

さて、早速Diary::with('statisticPerDate')に変えてみると……
image.png
はい……4クエリですね……Eager Loadingしてますね……
世界に平和と秩序がもたらされました。

結論

メソッド名を間違えているです。

メソッド名が間違っていてもちゃんとEager Loadginのためのinクエリ

select * from `statistic_per_dates` where `statistic_per_dates`.`diary_id` in (112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141)

が走っているのは罠すぎる……

高度なLaravelを低速化する技術ですね……

おわり

Laravelは誰にも優しいんです。
with内のメソッド名が間違っていてもエラーなんて出しません。なんなら正しいクエリも発行してくれます(Eager Loadingは効かないけど……)。
Eager Loadingが効かなくても普通の読み込みをしてくれて何事もなかったかのように表示されます。

Laravel開発者、Taylor Otwellさんのイケメン笑顔が頭に浮かびます。
Taylorさんへの敬意を込めて、Qiitaの名前にTaylorさんのTwitterと同じ🪐を付けました。

おわり

8
5
2

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
8
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?