GORM 陷阱 (第 2 部分)

工程 | Peter Ledbrook | 2010年07月02日 | ...

在本系列的 第 1 部分 中,我向您介紹了一些與使用 GORM 持久化領域例項相關的細微差別。這次,我將重點關注關係處理,特別是:hasMany還是belongsTo.

GORM 僅提供了幾個基本的元素來定義域類之間的關係,但它們足以滿足大多數需求。當我講授 Grails 培訓課程時,每次講到關係的部分,所用的幻燈片數量之少總是讓我感到驚訝。正如你所料,這種表面的簡單性確實隱藏了一些可能讓不留神的人犯錯的微妙行為。讓我們從最基本的關係開始:多對一。

多對一

假設我有以下兩個域類:

class Location {
    String city
}

class Author {
    String name
    Location location
}

當你看到一個Author域類時,你就知道一個Book類也不會太遠。沒錯,也會有一個Book類,但現在我們只關注上面這兩個域類和多對一location關係。

看起來很簡單,對吧?事實也是如此。只需將location屬性設定為一個Location例項,你就將一個作者關聯到了一個地點。但看看我們在 Grails 控制檯執行以下程式碼時會發生什麼:

def a = new Author(name: "Niall Ferguson", location: new Location(city: "Boston"))
a.save()

丟擲了一個異常。如果你檢視最後的“原因:”異常,你會看到訊息“not-null property references a null or transient value: Author.location”。這是怎麼回事?

關於“瞬時值”的部分是這裡的關鍵。瞬時例項是沒有附加到 Hibernate 會話的例項。從程式碼中可以看出,我們正在將Author.location屬性設定為一個新的Location例項,而不是從資料庫檢索的例項。因此,該例項是瞬時的。顯而易見的解決方法是透過儲存例項來使其變為持久化例項Location所以,如果我們的多對一屬性的值必須是持久化例項,為什麼 GORM 的許多示例看起來像我們的原始程式碼,其中我們建立了一個新的

def l = new Location(city: "Boston")
l.save()

def a = new Author(name: "Niall Ferguson", location: l)
a.save()

例項?這是因為域類通常在這種情況下使用Location屬性。belongsTo級聯與

每次處理 Hibernate 中的關係時,你都需要很好地掌握級聯的含義。這同樣適用於 GORM。級聯決定了當某個操作應用於域例項時,哪些型別的操作也會應用於該例項的關係。例如,給定上述模型,當我們儲存作者時,作者的地點是否也會被儲存?當我們刪除作者時,地點是否也會被刪除?如果我們刪除地點呢?關聯的作者是否也會被刪除?belongsTo

儲存和刪除是最常見的與級聯相關的操作,而且它們是你真正需要理解的全部。所以,如果你回到上一節,你會明白為什麼

例項沒有與作者一起儲存,因為該LocationAuthor -> Location關係沒有啟用級聯。如果我們現在將改為這樣Location,我們會發現異常消失了,並且

class Location {
    String city

    static belongsTo = Author
}

例項與作者一起被儲存了。這行程式碼確保了儲存操作從Location級聯。正如文件所說,它也級聯刪除操作,所以如果你刪除一個作者,與之關聯的地點也會被刪除。但是,儲存或刪除一個地點不會儲存或刪除作者。belongsTo哪個Author轉換為Location人們經常感到困惑的一點是,

支援兩種不同的語法。上面使用的語法只是定義了兩個類之間的級聯,而另一種語法也添加了相應的反向引用,自動將關係轉換為雙向關係belongsTo?

在這種情況下,一個belongsTo屬性在定義級聯的同時被新增到
class Location {
    String city

    static belongsTo = [ author: Author ]
}

。這種語法的優點是可以定義多個級聯關係。作者如果你使用後一種語法,你可能會注意到一點:當你用一個地點儲存一個新的Location時,Grails 會自動將

'sAuthor屬性設定為Location例項。換句話說,反向引用被初始化了,而你無需顯式操作。作者在我轉向集合之前,我想關於多對一關係說最後一點。有時人們認為我們上面所做的新增反向引用會將關係變成一對一。事實上,除非你在關係的一側或另一側新增唯一性約束,否則它在技術上不是一對一。例如Author當然,在這種特定情況下,將

Author - Location

class Author {
    String name
    Location location

    static constraints = {
        location(unique: true)
    }
}

關係變成一對一是沒有意義的,但希望你能明白一對一是如何定義的。一旦你理解了的工作原理,多對一關係就相當直接了。另一方面,涉及集合的關係,如果你不習慣 Hibernate,可能會出現一些令人不快的意外。

集合(一對多/多對多)belongsTo在面向物件的語言中,集合是建模一對多關係的自然方式,而 GORM 考慮到幕後的工作,使用它們非常容易。儘管如此,這絕對是面嚮物件語言和關係型資料庫之間阻抗不匹配抬頭的地方。首先,你必須記住,記憶體中的資料可能與資料庫中的資料不同。

域例項集合 vs 資料庫記錄

當你有一個域例項上的集合時,你處理的是記憶體中的物件。這意味著你可以像處理任何其他物件集合一樣處理它。你可以迭代它,也可以修改它。然後,在某個時候,你將希望將任何更改持久化到資料庫,這可以透過儲存擁有該集合的物件來完成。我稍後會回來談論這個,但首先我想演示一些與你的物件集合和實際資料之間的這種斷開關聯相關的微妙之處。要做到這一點,我將介紹

這建立了一個單向(

沒有反向引用到Book類中看出的那樣:

class Book {
    String title

    static constraints = {
        title(blank: false)
    }
}

class Author {
    String name
    Location location

    static hasMany = [ books: Book ]
}

)一對多關係,其中一個作者有零個或多個書籍。現在,讓我們假設我在 Grails 控制檯(一個用於試驗 GORM 的絕佳工具)中執行此程式碼:Book輸出將是這樣的:Author所以你可以列印書籍的集合,但它們還沒有在資料庫中。你甚至可以在第二次

def a = new Author(name: "Niall Ferguson", location: new Location(city: "Boston"))
a.save(flush: true)

a.addToBooks(title: "Colossus")
a.addToBooks(title: "Empire")

println a.books*.title
println Book.list()*.title

a.save()

[Empire, Colossus]
[]

之後插入a.addToBooks(),但效果不明顯。還記得上一篇文章我說過呼叫save()並不保證資料立即持久化嗎?這是一個具體的例子。如果你想在查詢中看到新新增的書籍,你必須新增一個顯式的 flush這兩個println

...
a.addToBooks(title: "Colossus")
a.addToBooks(title: "Empire")
a.save(flush: true)   // <---- This line added

println a.books*.title
println Book.list()*.title

語句然後會輸出相同的書籍,儘管不一定是相同的順序。記憶體中的集合和資料庫資料之間的這種差異的另一個症狀是,如果你用以下方式替換語句:即使在一次語句:(沒有顯式 flush)之後,這也會列印

println a.books*.id

null這兩個s。只有當你 flush 會話時,子域例項的 ID 才會設定。這與我們之前看到的多對一情況非常不同,在那種情況下,你不需要顯式 flush 就可以將例項持久化到資料庫!認識到這種差異很重要,否則你將面臨困難。作為一點題外話,如果你自己跟蹤 Grails 控制檯中的示例,請注意,你在控制檯中執行指令碼時儲存的任何內容都將保留到你執行下一個指令碼時。資料只有在重新啟動控制檯時才會清除。此外,會話總是在指令碼完成後重新整理。Location好的,回到集合。上面的示例展示了一些我接下來想討論的有趣行為。為什麼

例項會持久化到資料庫,即使我沒有定義

Book上?belongsTo級聯Book?

與其他關係一樣,掌握集合意味著掌握它們的級聯行為。首先要注意的是,儲存操作總是從父物件級聯到其子物件,即使沒有指定

。如果是這種情況,那麼使用belongsTo還有意義嗎?是的。belongsTo考慮我們在添加了作者和他的書籍後,在控制檯中執行此程式碼會發生什麼:

輸出如下:

def a = Author.get(1)
a.delete(flush: true)

println Author.list()*.name
println Book.list()*.title

換句話說,作者已被刪除,但書籍尚未刪除。這就是

[]
[Empire, Colossus]

的作用:它確保刪除操作像儲存操作一樣被級聯。只需新增這行belongsTostatic belongsTo = Author,上述程式碼將為轉換為Book列印空列表。很簡單,對吧?在這種情況下,是的,但真正有趣的部分才剛剛開始。Author 還是 Book題外話:請看我們在上面的示例中強制重新整理會話的方式?如果我們不這樣做,

Author.list()可能會顯示剛剛被刪除的作者,僅僅是因為該更改在那時可能尚未持久化。刪除子項

刪除像

例項這樣並讓 GORM 自動刪除子項是很容易的。但如果你只想刪除作者的一本或多本書,而不是作者本人呢?你可能會嘗試這樣做:Author認為這將刪除所有書籍。但實際上,這段程式碼會生成一個異常:

def a = Author.get(1)
a.books*.delete()

哇,一個有用的堆疊跟蹤訊息!是的,問題在於書籍仍然在作者的集合中,所以當會話重新整理時,它們將被重新建立。記住,不僅儲存操作被級聯,而且修改過的域例項也會自動持久化(因為 Hibernate 的髒資料檢查)。

org.springframework.dao.InvalidDataAccessApiUsageException: deleted object would be re-saved by cascade (remove deleted object from associations): [Book#1]; ...
	at org.springframework.orm.hibernate3.SessionFactoryUtils.convertHibernateAccessException(SessionFactoryUtils.java:657)
	at org.springframework.orm.hibernate3.HibernateAccessor.convertHibernateAccessException(HibernateAccessor.java:412)
	at org.springframework.orm.hibernate3.HibernateTemplate.doExecute(HibernateTemplate.java:411)
	at org.springframework.orm.hibernate3.HibernateTemplate.executeWithNativeSession(HibernateTemplate.java:374)
	at org.springframework.orm.hibernate3.HibernateTemplate.flush(HibernateTemplate.java:881)
	at ConsoleScript7.run(ConsoleScript7:3)
Caused by: org.hibernate.ObjectDeletedException: deleted object would be re-saved by cascade (remove deleted object from associations): [Book#1]

解決方案,正如異常訊息所解釋的,是從集合中移除書籍:

但這也不是一個解決方案,因為書籍仍然在資料庫中。它們只是不再與作者關聯了。好的,那麼我們也需要顯式地刪除它們:

def a = Author.get(1)
a.books.clear()

哎呀,現在我們得到了一個

def a = Author.get(1)
a.books.each { book ->
    a.removeFromBooks(book)
    book.delete()
}

ConcurrentModificationException,因為我們在迭代作者集合的同時正在從中移除書籍。這是標準的 Java 陷阱。我們可以透過建立一個集合的副本來繞過它:這可以工作,但確實需要不少功夫。

def a = Author.get(1)
def l = []
l += a.books

l.each { book ->
    a.removeFromBooks(book)
    book.delete()
}

如果你有一個雙向關係,你也必須小心,例如,如果你的

使用了這種語法:belongsTostatic belongsTo = [ author: Author ]。如果我們像這樣從集合中移除書籍而不刪除它們:我們會得到一個“not-null property references a null or transient value: Book.author”錯誤。正如我稍後會解釋的,這是因為書籍的

def a = Author.get(1)
def l = []
l += a.books

l.each { book ->
    a.removeFromBooks(book)
}

屬性被設定為了作者。由於該屬性不可為空,這會觸發一個驗證錯誤。這足以讓人抓狂!例項持久化到資料庫!認識到這種差異很重要,否則你將面臨困難。不要害怕,有一個解決方案。如果我們向

新增此對映:Author:

static mapping = {
    books cascade: "all-delete-orphan"
}

那麼任何從作者那裡移除的書籍都會被 GORM 自動刪除。最後一個程式碼示例,其中我們從集合中移除了所有書籍,現在將可以正常工作。事實上,如果關係是單向的,你可以大大減少程式碼:

def a = Author.get(1)
a.books.clear()

這將一次性移除並刪除所有書籍!

這個故事的寓意很簡單:如果你在父級的belongsTo對映塊中使用與集合,顯式地將級聯型別設定為“all-delete-orphan”。事實上,有充分的理由讓這成為 GORM 中belongsTo和一對多關係預設的行為。

這引出了一個有趣的問題:為什麼clear()方法在雙向關係上不起作用?我不完全確定,但我認為這是因為書籍保留了對作者的反向引用。要理解為什麼這會影響clear()的行為,你必須首先認識到 GORM 將單向和雙向一對多關係對映到資料庫表的方式不同。對於單向關係,GORM 預設建立一個連線表,所以當你清空書籍集合時,記錄只是從該連線表中刪除。雙向關係是透過子表(例如我們示例中的書籍表)上的直接外部索引鍵對映的。一張圖會更清楚:

one-to-many-mappings

當你清空書籍的集合時,該外部索引鍵仍然存在,因為 GORM 沒有清空作者屬性的值。因此,就好像集合從未被清空一樣。

這幾乎就是關於集合的所有內容了。我只是想快速看看addTo*()還是removeFrom*()

addTo*()方法來結束這個部分。<<

vsaddTo*()還是在我的示例中,我使用了 GORM 提供的

def a = Author.get(1)
a.books << new Book(title: "Colossus")

動態方法。為什麼呢?畢竟,如果這些是標準的 Java 集合,我們不能直接使用類似這樣的程式碼嗎?

def a = new Author(name: "Niall Ferguson", location: new Location(city: "Boston"))
a.books << new Book(title: "Colossus")
a.save()

當然可以,但 GORM 方法有一些微妙的好處。考慮這段程式碼:這段程式碼看起來沒有什麼問題,對吧?然而,如果你執行這段程式碼,你會得到一個NullPointerException,因為books集合還沒有初始化。這與你從資料庫中獲取作者時的行為非常不同,例如使用get(),因為。在這種情況下,我們可以愉快地向集合新增項。只有當我們透過newaddTo*()建立作者時才會遇到這個問題。如果你改用

方法,你就不必擔心這個問題了,因為它是什麼都安全的。集合還沒有初始化。這與你從資料庫中獲取作者時的行為非常不同,例如使用現在考慮我們在使用作者獲取作者並向其集合新增新書的示例。如果關係是雙向的,我們會遇到一個“property not-null or transient”異常,因為書籍的addTo*()屬性尚未設定。如果你使用標準的集合方法,你必須手動初始化反向引用。使用

方法,這已經為你完成了。addTo*()方法最後一個特性是正確域類的隱式建立。注意我們在示例中是如何僅僅將書籍的初始屬性值傳遞給方法的,而不是顯式例項化Book?這是因為該方法可以從hasMany屬性推斷出集合包含的型別。很方便,不是嗎?

方法不太有用,但它確實清除了反向引用。當然,這與我之前討論過的“all-delete-orphan”級聯選項一起效果最好。

最後一種要考慮的關係是多對多。

多對多

如果你願意,你可以讓 GORM 為你管理多對多關係。如果你這樣做,有幾件事需要注意:

刪除永不級聯。

關係的*一側*必須有一個belongsTo,但通常哪一側擁有它並不重要。

belongsTo隻影響級聯儲存的方向——它不會導致級聯刪除

始終使用連線表,但不能在上面儲存任何額外資訊。

抱歉,我要反覆強調級聯刪除這一點,但理解其行為與多對一和一對多關係完全不同很重要。理解最後一點也很重要:許多多對多關係都有相關的額外資訊。例如,一個使用者可能有許多角色,一個角色可能有許多使用者。但使用者在不同的專案中可能有不同的角色,因此專案與關係本身相關聯。在這些情況下,最好自己管理多對多關係。

總結

好了,這可能是我寫過的最長的文章了,但你已經讀完了。恭喜!如果你一次沒有消化所有內容,不要擔心,你可以隨時參考它。

我認為 GORM 在以面向物件的方式處理資料庫關係方面提供了很好的抽象,但正如你所見,你不能真正忘記你最終是在處理資料庫。不過,有了本文提供的資訊,你應該能夠輕鬆應對 GORM 集合的基礎知識。希望這將意味著你可以在你的應用程式中愉快地處理一對多關係並從中受益。

你可能不相信,但我還沒有講完關於集合你需要知道的一切。關於懶載入,仍然有一些有趣的問題需要討論,但我將在下一篇文章中向你介紹。

下次再見!

獲取 Spring 新聞通訊

透過 Spring 新聞通訊保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助您加速進步。

瞭解更多

獲得支援

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案,只需一份簡單的訂閱。

瞭解更多

即將舉行的活動

檢視 Spring 社群所有即將舉行的活動。

檢視所有