「這個功能上線好一段時間了,也沒人回報問題,代表程式碼沒問題吧?」
這句話有一個隱藏的前提:沒人回報,不代表沒問題,只代表問題還沒被踩到,或者踩到的人沒意識到那是問題。今天要看一個真實案例——這個系統啟用 Model::shouldBeStrict() 之後,一次性抓出了好幾個「能動,但其實有問題」的地方,包括一個藏了不知道多久的 typo。
Model::shouldBeStrict() 這個開發輔助機制在防什麼shouldBeStrict() 除了 N+1,還能抓出哪些容易被忽略的錯誤Model::shouldBeStrict() 在做什麼Laravel 的 Eloquent Model 預設是「寬鬆」的——存取一個沒有 eager load 的關聯,它會默默幫你發一次額外的查詢;賦值一個不在 $fillable 裡的欄位,它會默默忽略。這種寬鬆對開發很方便,但也代表很多本來該被抓出來的問題,會被悄悄吞掉。
Model::shouldBeStrict() 就是把這層寬鬆關掉,通常只在非正式環境開啟:
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Model::shouldBeStrict(! $this->app->isProduction());
// ...
}
開啟之後,存取未 eager load 的關聯會直接丟例外,而不是默默多發一次查詢。這代表:只要你在本機或測試環境正常操作過一次功能,N+1 查詢就會立刻現形,不用等到正式環境流量大了才被效能監控抓到。
這個系統有一支 Controller,負責顯示「快速連結」分類頁:
// app/Http/Controllers/QuickLinkController.php(修正前)
public function __invoke(int $id)
{
$categories = QuickLinkCategory::query()->tap(new ByType(QuickLinkCategory::TYPE_LINK))->get();
// ...
return view('quick_link', [
'children' => $categories,
// ...
]);
}
View 裡要顯示每個分類底下的連結清單($category->links),但查詢 $categories 的時候沒有 eager load links 這個關聯。在寬鬆模式下,這支程式碼「能動」——每次 View 存取 $category->links 時,Eloquent 會自動補發一次查詢,畫面顯示完全正常,只是分類數量越多,額外查詢的次數就跟著線性增加。這正是典型的 N+1:1 次查詢拿到分類清單,再對每個分類各發 1 次查詢拿連結,N 個分類就是 N+1 次查詢。
開啟嚴格模式之後,這支 Controller 在本機測試時直接丟出例外,修法很直接:
// 修正後
$categories = QuickLinkCategory::query()->with('links')->tap(new ByType(QuickLinkCategory::TYPE_LINK))->get();
加上 with('links'),一次查詢就把所有分類跟對應的連結都拿到,View 存取 $category->links 時不需要再補發查詢。
同一次修正裡,還有好幾個地方犯了一樣的問題——首頁的跑馬燈元件、影音元件、快速連結元件,各自查詢 QuickLinkCategory 時都沒有 eager load links:
// 修正前
public function links(): Collection
{
return QuickLinkCategory::query()
->tap(new ByType(QuickLinkCategory::TYPE_MARQUEE))
->first()
?->links ?? new Collection;
}
// 修正後
public function links(): Collection
{
return QuickLinkCategory::query()
->with('links')
->tap(new ByType(QuickLinkCategory::TYPE_MARQUEE))
->first()
?->links ?? new Collection;
}
這幾處問題有一個共通點:都是同一種「查詢分類、再存取分類底下的關聯」模式,只是出現在不同的元件裡各自被複製了一次。 沒有嚴格模式的話,這些問題會一直安靜地存在,直到某天流量夠大、資料夠多,才會在效能監控上看到異常。
同一次修正裡,還修掉了一個藏在後台管理頁面裡的真實 typo——一段判斷條件寫成 $record->fist,應該是 $record->first(少打一個字母)。這個 typo 沒有讓程式報錯,因為 Eloquent 的寬鬆模式下,存取一個不存在的屬性只會回傳 null,null 在條件判斷裡會被當成 falsy,程式碼照樣「能執行」,只是這個判斷式永遠不會成立。這正是嚴格模式最有價值的地方:它抓的不只是效能問題,是任何「因為 Eloquent 太寬容,所以錯誤被吞掉沒有顯現」的情況。
$user->pages; // 沒 eager load,靜靜地多發一次查詢
$record->fist; // typo,靜靜地回傳 null,判斷式永遠是 false
Model::shouldBeStrict(! $this->app->isProduction());
你的專案有沒有開啟過 Model::shouldBeStrict()?如果現在打開它,你猜會冒出幾個「能動但其實有問題」的地方?
Model::shouldBeStrict() 把 Eloquent 的寬鬆行為關掉,讓 N+1 查詢跟屬性 typo 在開發階段就直接現形QuickLinkController 跟三個首頁元件都因為缺少 with('links') 觸發 N+1,問題模式相同,只是複製到不同檔案fist 應為 first),因為 Eloquent 的寬鬆行為把這個錯誤吞掉了明天要看這個系統裡一個身兼多職的 Model——用階層樹狀結構管理整站頁面,還用一個欄位動態決定自己的行為模式。