領先一步
VMware 提供培訓和認證,助您加速進步。
瞭解更多很高興聽到這些文章對人們有所幫助,因此我非常樂意為這個系列再添一篇。這次我將再次討論關聯,但重點放在它們何時被載入到記憶體中。
更新 2010年8月2日 我為了一對多關係增加了關於延遲載入的更多資訊,因為有一些問題需要注意。
人們最先了解的 GORM 關係之一就是它們預設是惰性載入的。換句話說,當你從資料庫獲取一個領域例項時,它的任何關係都不會被載入。相反,GORM 只會在你實際使用某個關係時才載入它。
讓我們透過上一篇文章中的例子來具體說明一下。
class Location {
String city
}
class Book {
String title
static constraints = {
title(blank: false)
}
}
class Author {
String name
Location location
static hasMany = [ books: Book ]
}
如果我們獲取一個Author例項,在不執行額外查詢的情況下,唯一可以使用的資訊就是作者的名字。當我們嘗試獲取關聯的地點或書籍時,會啟動更多查詢來獲取我們需要的額外資料。
這確實是唯一合理的預設選項,特別是對於擁有長關聯鏈的複雜模型。如果預取是預設選項,那麼透過獲取一個單獨的例項,你可能會最終從資料庫中拉取一半的資料。
儘管如此,這個選項並非沒有代價。我將探討惰性關聯的三個副作用,以便你瞭解它們是什麼,能夠識別症狀,並修復由此產生的任何問題。
關聯的惰性載入涉及一些技巧。畢竟,你不希望上面的location屬性返回null,對吧?所以 Hibernate 使用代理和自定義集合類來提供對惰性載入的集合和關聯的透明訪問——你無需擔心它們尚未在記憶體中的事實。通常,這些代理在隱藏幕後工作方面做得很好,但偶爾實現會暴露出來。
例如,考慮這個領域模型
class Pet {
String name
}
class Dog extends Pet {
}
這是一個非常簡單的繼承層次結構,因此你不會期望有任何令人不快的事情。現在想象一下,我們在資料庫中有一個 ID 為 1 的Dog例項。你認為以下程式碼會發生什麼?
def pet = Pet.load(1)
assert pet instanceof Dog
直觀地說,這應該是有效的。畢竟,ID 為 1 的寵物是Dog。那麼為什麼斷言會失敗呢?`load()` 方法並沒有從資料庫獲取底層例項,而是返回一個代理,該代理會在需要時執行所需的查詢,例如當你嘗試訪問非屬性時。這個代理是id的一個動態子類,而不是的子類,所以instanceofDog檢查會失敗。即使在從資料庫載入例項後,它仍然會失敗!如圖所示:改變Pet.load()
為Dog.load()轉換為將解決問題,因為代理將是的動態子類。你也可以透過替換Dog為屬性時。來使其工作,因為後者的實現會自動解開代理並返回底層的get()例項。事實上,Grails 在許多其他情況下都會努力執行這種自動解開,所以你不太可能遇到這個問題。這也是當你確實遇到問題時,它會如此令人驚訝的原因之一。Dog還有一種情況可能會引起一些麻煩,儘管這種情況應該相當罕見。想象一下你有一個類,
Person,它有一個到的關聯的子類,所以目錄下有一個 Java 類,如下所示:
class Person {
String name
Pet pet
}
的。這個pet,它有一個到關係是惰性的,所以當你獲取。這個例項時,
def p = Person.get(1)
assert p.pet instanceof Dog
assert Pet.get(1) instanceof Dog
assert Pet.findById(1) instanceof Dog
assert Pet.list()[0] instanceof Dog
屬性將是一個代理。通常情況下,GORM 會隱藏這一點,但請注意以下行為:,它有一個到假設我們有一個的子類,所以例項和一個 ID 為 1 的Dog例項,並且假設這兩個透過。這個屬性相關聯,前三個斷言將成功,但最後一個將不會。刪除其他程式碼行,突然那個斷言就會成功。嗯?
這種行為無疑令人困惑,但其根源在於 Hibernate 會話。當你從資料庫檢索,它有一個到時,它的。這個屬性是一個代理。該代理儲存在會話中,代表 ID 為 1 的的子類,所以例項。現在,Hibernate 會話保證,無論你在單個會話中從資料庫中檢索某個領域例項多少次,Hibernate 都會返回相同的物件。所以當我們呼叫Pet.get(1)時,*Hibernate* 會給我們代理。對應的斷言之所以成功,是因為 GORM 會自動解開代理。對於findBy*()以及其他只能返回單個例項的查詢,情況也是如此。
然而,GORM 不會為list(), findAllBy*()以及可以返回多個結果的其他查詢解開代理。因此,Pet.list()[0]返回我們未解開代理的例項。如果,它有一個到尚未首先獲取,Pet.list()將返回實際的例項:代理此時不在會話中,因此查詢不必返回它。
你可以通過幾種方式來防範這個問題。首先,你可以使用動態instanceOf()方法而不是改變運算子。它在所有 GORM 領域例項上都可用,並且是代理感知的。Pet.get(1).instanceOf(Dog)。其次,使用def宣告變數,而不是靜態領域類型別,否則你可能會遇到類轉換異常。因此,與其使用
Person p = Person.get(1)
Dog dog = Pet.list()[0] // Throws ClassCastException!
,不如使用
def p = Person.get(1)
def dog = Pet.list()[0]
。使用這種方法,即使你正在處理代理,你仍然可以訪問特定於Dog的任何屬性或方法。
必須說,GORM 在保護開發人員免受代理影響方面做得非常出色。它們很少會洩露到你的應用程式程式碼中,尤其是在較新版本的 Grails 中。儘管如此,有些人仍然會遇到問題,因此瞭解症狀和原因很有用。
我在最後一個示例中展示了會話的行為如何與惰性載入相結合可能產生一些有趣的結果。這種組合也是一個更常見的錯誤的原因:org.hibernate.LazyInitializationException.
正如我已經提到的,當你有一個惰性載入的關係時,如果你以後想導航該關係,Hibernate 就必須執行一個額外的查詢。在正常情況下,這不成問題(除非你擔心效能),因為 Hibernate 會透明地處理。但如果你嘗試在不同的會話中訪問該關係,會發生什麼?
假設你在控制器操作中載入了 ID 為 1 的Author例項,並將其儲存在 HTTP 會話中。此時,沒有任何程式碼觸及books集合。在下一個請求中,使用者訪問與此控制器操作對應的 URL:
class MyController {
def index = {
if (session.author) {
render "Author ${session.author.name} has written the books: ${session.author.books*.title}"
else {
render "No author in session"
}
}
...
}
這裡的意圖是,如果我們的 HTTP 會話包含一個作者變數,該操作將渲染該作者書籍的標題。除了在這種情況下它不會。它丟擲了一個LazyInitializationException。
問題在於,我們稱之為*分離物件*的Author例項。它是在一個 Hibernate 會話中載入的,然後該會話在請求結束時關閉。一旦物件的會話關閉,它就變成分離的,你無法訪問它上的任何會導致查詢的屬性。
“但我的操作中有一個會話是開啟的,為什麼會出現這個問題?”我聽到你喊。這是個好問題。不幸的是,這是一個新的 Hibernate 會話,它不知道我們的Author例項。只有當物件顯式附加到新會話時,你才能訪問其惰性關聯。有幾種技術可以做到這一點:
def author = session.author
// Re-attach object to session, but don't sync the data with the database.
author.attach()
// Re-attach object, but merge any changes with the data in the database.
// You *must* use the instance returned by the merge() method.
author = author.merge()
的attach()方法在領域例項在被分離物件檢索後不太可能在資料庫中發生更改的情況下很有用。如果資料可能已更改,則必須小心。請參閱Grails 參考指南,瞭解merge()還是refresh().
的行為。LazyInitializationException現在,如果你遇到
N+1 選擇
Author.list().each { author ->
println author.location.city
}
讓我們回到文章前面提到的作者/書籍/地點示例。想象一下,我們在資料庫中有四個作者,並且我們執行以下程式碼:
將執行多少個查詢?答案是五個:一個用於獲取所有作者,然後每個作者一個用於檢索相應的地點。這就是所謂的 N+1 選擇問題,編寫受其影響的程式碼非常容易。上面的例子乍一看似乎無害。在開發過程中,這實際上不是問題,但在部署到生產環境時,執行如此多的查詢會損害應用程式的響應能力。因此,在應用程式向終端使用者開放之前,分析應用程式的資料庫使用情況是個好主意。最簡單的方法是在grails-app/conf/DataSource.groovy
dataSource {
...
loggingSql = true
}
中啟用 Hibernate 日誌記錄,以確保所有查詢都記錄到 stdout。當然,你可以在每個環境中啟用它。另一種方法是使用像P6Spy這樣的特殊資料庫驅動程式,它可以攔截查詢並記錄它們。
那麼如何避免這些額外的查詢呢?透過預取關聯而不是惰性載入。這種方法也解決了與惰性載入相關的其他問題。我提到的。
GORM 允許你覆蓋每個關係預設的惰性載入行為。例如,我們可以配置 GORM 透過以下對映始終與作者一起載入作者的地點:
class Author {
String name
Location location
static hasMany = [ books: Book ]
static mapping = {
location fetch: 'join'
}
}
在這種情況下,地點不僅與作者一起載入,而且使用 SQL join 在同一個查詢中檢索。所以這段程式碼:
Author.list().each { a ->
println a.location.city
}
將只導致一個查詢。你也可以使用lazy: false選項而不是fetch: 'join',但這將導致一個額外的查詢來載入地點。換句話說,關聯是預取的,但有一個單獨的 SQL 選擇。大多數時候你可能希望使用fetch: 'join'來最小化執行的查詢數量,但有時它可能是更昂貴的方法。這真的取決於你的模型。
還有其他選項,但我在這裡不詳細介紹。它們在 Grails 使用者指南的第 5.3.4 和 5.5.2.8 部分有完整文件記錄,如果你想了解更多資訊(儘管我會等待 Grails 1.3.4 版本,它將附帶一些重要的文件更新)。
在領域類對映中配置預取的缺點是關聯將*始終*預取。但是,如果你只偶爾需要這些資訊呢?任何只想顯示作者姓名的頁面都會不必要地變慢,因為還必須載入地點。對於像這樣的簡單關聯,成本可能很低,但對於集合來說,成本會更高。這就是為什麼你還可以選擇按查詢預取關聯。
查詢是上下文敏感的,所以它們是指定是否應預取特定關聯的理想位置。假設我們已經恢復到Author的預設行為,現在我們想獲取所有作者並顯示他們的城市。在此上下文中,當我們獲取作者時,我們顯然希望檢索地點。方法如下:
Author.list(fetch: [location: 'join']).each { a ->
println a.location.city
}
我們所做的只是添加了一個fetch引數到查詢中,並使用一個對映:關聯名稱 -> 獲取模式。如果程式碼還顯示了作者書籍的標題,我們也會將books關聯新增到對映中。動態查詢器支援完全相同的fetch選項。
Author.findAllByNameLike("John%", [ sort: 'name', order: 'asc', fetch: [location: 'join'] ]).each { a->
...
}
我們也可以用 Criteria 查詢達到同樣的目的:
def authors = Author.withCriteria {
like("name", "John%")
join "location"
}
以上所有內容也適用於一對多關係,但你需要考慮一些額外的因素。
我上面說過,你通常希望在預取關聯時使用 join,但這套經驗法則對於一對多關係並不奏效。為了理解為什麼,請考慮這個查詢:
Author.list(max: 2, fetch: [ books: 'join' ])
很可能,這隻會返回一個Author例項。這可能不是你期望或想要的行為。那麼發生了什麼?
在底層,Hibernate 使用左外連線來獲取每個作者的書籍。這意味著你會得到重複的Author例項:每個作者關聯的書籍都有一個。如果你沒有max選項,你不會看到這些重複項,因為 GORM 會刪除它們。但問題在於max選項應用於*在*刪除重複項*之前*的結果。所以在上面的例子中,Hibernate 只返回兩個結果,這兩個結果很可能都有相同的作者。然後 GORM 刪除重複項,你最終得到一個Author例項。
這個問題在領域類對映配置和 Criteria 查詢中都會發生。事實上,Criteria 查詢預設不會從結果中刪除重複項!對於這種混亂,只有一個合理的解決方案:對於一對多關係,始終使用“select”模式。例如,在領域對映中使用:lazy: false:
class Author {
...
static hasMany = [ books: Book ]
static mapping = {
location fetch: 'join'
books lazy: false
}
}
在查詢中,根據你使用的是動態查詢器還是 Criteria 查詢,使用適當的設定:
import org.hibernate.FetchMode
Author.list(fetch: [ books: 'select' ])
Author.withCriteria {
fetchMode "books", FetchMode.SELECT
}
是的,你將得到一個額外的查詢來獲取集合,但它只有一個,並且你獲得了一致性和簡單性。如果你發現確實需要減少查詢數量,那麼你總是可以回退到 HQL。
除了與一對多的情況,GORM 中的預取很簡單,如果你遵循一對多關係使用“select”獲取模式的原則,這對它們也適用。主要工作是分析應用程式的資料庫訪問,以確定在哪裡應該預取關聯,或者專門使用 join。只需警惕過早最佳化!
正如你所見,關聯的惰性載入會引發各種問題,特別是與 Hibernate 會話結合使用時。儘管存在這些問題,惰性載入仍然是物件圖的合理預設選項,是一個重要功能。一旦你瞭解了它們,通常很容易識別並解決這些問題。如果什麼都沒有浮現在腦海中,你總是可以回退到明智地使用預取。
說了這麼多,隨著 Grails 版本號的提高,使用者遇到這些問題的可能性越來越小。考慮到 Hibernate 在後臺所做的工作,這真是一個令人印象深刻的技巧!