在 Laravel 當中,我們可以透過「動態關聯屬性(Dynamic relationship properties)」直接取得 Model 的關聯資料,而該行為被官方稱為 「Lazy Loading」,當取值時就會自動將關聯資料載入,非常方便。但使用上一不小心就很有可能會造成 N+1 問題。
N+1 問題通常是指,在得到一個 Models of Collection/Array 後,又在遍歷每個 Model 時,透過 Lazy Loading 取得其關聯資料,會導致 有 N 個 Model 就會執行 N+1 次 Query。過多的 DB Query 次數可能會對效能造成嚴重影響。
Laravel 8 開始就有提供避免 Lazy Loading 的方法,
只要在 App\Providers\AppServiceProvider
當中的 boot()
方法裡面加上:
|
|
如此一來,當 Lazy Loading 被觸發時,就會直接拋出例外 (Illuminate\Database\LazyLoadingViolationException
)
而當初該新功能發布時,laravel-news 網站有一篇文章就在介紹這個功能。該篇文章當中舉的例子為,有兩個 Model - User 和 Posts 為一對多關係:
|
|
所以如果加上 Model::preventLazyLoading()
,執行上面範例時應該就會拋出 Illuminate\Database\LazyLoadingViolationException
阻止我們透過 Lazy Loading 得到 posts。
於是打開專案來試試,卻發現一切正常,沒有例外拋出…
但如果換一個方法試試:
這時候才會如預期拋出例外。
另外,如果當 DB 裡只有一筆 user 資料,就算像上面這樣取 all()
,也不會拋出例外。
一開始很疑惑為什麼範例的 code 執行後沒有拋出例外,所以就研究了一下原始碼的部分,順便看一下這個功能是如何實現的。
原始碼分析
首先,從 Illuminate\Database\Eloquent\Model::preventLazyLoading()
這個方法開始看起:
|
|
這邊會將 Illuminate\Database\Eloquent\Model
當中的一個靜態屬性 $modelsShouldPreventLazyLoading
設值成傳入的布林值。
該屬性預設為 false
:
|
|
但其實 Illuminate\Database\Eloquent\Model
另外還有一個非靜態屬性 $preventsLazyLoading
(待會會提到在哪裡被使用)
|
|
當執行 DB Query,取得 App\Models\User
時(eg: User::first();
),會進入以下方法
|
|
如果執行 User::first()
,此處的 $items
引數會是一個陣列,內含一個 stdClass 物件 (從 users table 取出的第一個 row 的資料),像這樣:
|
|
接著在遍歷陣列的過程中,將遍歷到的 stdClass 物件轉成 Eloquent Model 時,會先判斷如果 $items
陣列長度大於 1,才會將這個 Model(在此範例也就是 App\Models\User
)的 $preventsLazyLoading
屬性設為Model::preventsLazyLoading()
的值,而這個靜態方法其實回傳的就是先前我們在 App\Providers\AppServiceProvider
設定的靜態屬性 $modelsShouldPreventLazyLoading
:
|
|
接著,當呼叫 $user->posts
時,會進入 Illuminate\Database\Eloquent\Concerns\HasAttributes
當中的 getRelationValue()
方法:
|
|
當 $preventsLazyLoading
為 true
時,就會拋出例外
|
|
也就是說,從 DB 取出的資料筆數需大於 1 ,對該 Model 取關聯時才會觸發 Prevent Lazy Loading 的效果。
所以如果撈出的結果都是一筆資料,就也不會觸發 Prevent Lazy Loading 了。
|
|
其實按照常理,大概能夠理解爲什麼會有這種效果,因為只有一個 Model 的時候,取得其底下關聯,應該不能算是 N+1 問題。(不過該網站的例子為什麼這樣舉就不得而知,算是小踩坑了下)