GORM 的陷阱(第二部分)

工程 | Peter Ledbrook | 2010年7月2日 | ...

本系列的第 1 部分中,我向您介紹了使用 GORM 持久化領域例項的一些細微之處。這一次,我將重點講解關係,特別是關注hasManybelongsTo.

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()

丟擲了一個異常。如果你檢視最終的 "caused by" 異常,你會看到訊息 "not-null property references a null or transient value: Author.location"(非空屬性引用了空或瞬態值:Author.location)。這是怎麼回事?

這裡的關鍵是關於 "transient value"(瞬態值)的部分。瞬態例項是沒有附加到 Hibernate 會話中的例項。正如你從程式碼中看到的,我們將Author.location屬性設定為一個新的Location例項,而不是從資料庫中檢索到的例項。因此該例項是瞬態的。明顯的修復方法是透過儲存使其Location例項持久化

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

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

那麼,如果我們的多對一屬性必須具有持久化例項作為值,為什麼許多 GORM 示例看起來像我們的原始程式碼那樣,我們在其中建立了一個新的Location例項?這是因為領域類在這種情況下通常使用belongsTo屬性。

使用 belongsTo 進行級聯belongsTo

無論何時處理 Hibernate 中的關係,都需要很好地理解級聯(cascading)的含義。這對於 GORM 同樣適用。級聯決定了當應用於一個領域例項時,哪些型別的操作也會應用於該例項的關係。例如,給定上面的模型,當我們儲存作者時,作者的位置是否也會儲存?當我們刪除作者時,位置是否也會刪除?如果我們刪除位置呢?相關的作者是否也會刪除?

儲存和刪除是與級聯相關的最常見操作,也是您真正需要理解的唯一操作。所以,如果您回到上一節,您就會明白Location例項沒有隨作者一起儲存,因為對於該Author -> Location關係,級聯不起作用。如果我們現在將Location改為這樣

class Location {
    String city

    static belongsTo = Author
}

我們就會發現異常消失了,並且Location例項隨作者一起儲存了。該belongsTo行確保了儲存操作從AuthorLocation級聯。正如文件所述,它也級聯刪除操作,所以如果你刪除一個作者,其關聯的位置也會被刪除。然而,儲存或刪除一個位置不會儲存或刪除作者。

哪個 belongsTobelongsTo?

一件經常令人困惑的事情是belongsTo支援兩種不同的語法。上面使用的一種只是定義了兩個類之間的級聯,而另一種則會新增一個對應的反向引用,自動將關係變成雙向的
class Location {
    String city

    static belongsTo = [ author: Author ]
}

在這種情況下,一個author屬性被新增到Location的同時定義了級聯。這種語法的優點在於你可以定義多個級聯關係。

如果你使用後一種語法,你可能會注意到,當你儲存一個新的Author和一個位置時,Grails 會自動將Locationauthor屬性設定為Author例項。換句話說,反向引用被初始化了,而你無需顯式地進行操作。

在我繼續討論集合之前,我想對多對一關係再說最後一點。有時人們認為像我們上面那樣新增一個反向引用會將關係變成一對一。事實上,除非你在關係的一側或另一側新增唯一性約束,否則它在技術上並非一對一。例如

class Author {
    String name
    Location location

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

當然,在這種特定情況下,將Author - Location關係變成一對一沒有意義,但希望您能明白如何定義一對一關係。

多對一關係一旦你理解了belongsTo的工作方式,就相當簡單。另一方面,涉及集合的關係,如果你不熟悉 Hibernate,可能會出現一些令人不快的意外。

集合(一對多/多對多)

集合是面嚮物件語言中建模一對多關係的自然方式,考慮到幕後發生的事情,GORM 使其使用變得相當容易。儘管如此,這絕對是面嚮物件語言和關係資料庫之間的阻抗失配問題顯現的一個領域。首先,您必須記住您的記憶體中資料可能與資料庫中的資料不同。

領域例項集合 vs 資料庫記錄

當領域例項上有一個集合時,你正在處理記憶體中的物件。這意味著你可以像處理任何其他物件集合一樣處理它。你可以遍歷它,也可以修改它。然後,在某個時候,你會想將任何更改持久化到資料庫中,這可以透過儲存包含該集合的物件來完成。我稍後會回到這一點,但首先我想演示一下與物件集合和實際資料之間的這種斷開相關的一些細微之處。為此,我將引入Book

class Book {
    String title

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

class Author {
    String name
    Location location

    static hasMany = [ books: Book ]
}

這建立了一個單向的(Book沒有指向Author的反向引用)一對多關係,其中一個作者擁有零本或更多本書。現在假設我在 Grails 控制檯中執行這段程式碼(一個用於試驗 GORM 的絕佳工具)

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

輸出將是這樣

[Empire, Colossus]
[]

所以你可以列印圖書的集合,但它們還沒有在資料庫中。你甚至可以在第二個a.save()之後插入a.addToBooks()似乎沒有任何效果。還記得上一篇文章中我說過呼叫save()並不能保證資料立即持久化嗎?這就是一個具體的例子。如果你想在查詢中看到新書,你必須新增一個顯式的 flush

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

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

這兩個println語句將輸出相同的書籍,儘管順序不一定相同。記憶體集合與資料庫資料之間這種差異的另一個症狀是,如果你將println語句替換為

println a.books*.id

即使在save()(沒有顯式 flush)之後,這也會列印null。只有當你 flush 會話時,子領域例項才會設定它們的 ID。這與我們之前看到的多對一情況完全不同,在那種情況下,你不需要顯式的 flush 就可以將Location例項持久化到資料庫!重要的是要意識到這種區別的存在,否則你會遇到困難。

順便提一下,如果你自己在 Grails 控制檯中跟著例子練習,請注意,你在控制檯中執行指令碼時儲存的任何東西都會在執行下一個指令碼時仍然存在。資料只有在你重啟控制檯時才會清除。此外,指令碼完成後會話總是會被 flush。

好的,回到集合。上面的例子展示了一些我接下來想討論的有趣行為。為什麼Book例項被持久化到資料庫,即使我沒有在belongsTo上定義Book?

級聯

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

考慮一下在我們添加了作者和他的書之後,如果在控制檯中執行這段程式碼會發生什麼

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

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

輸出看起來是這樣

[]
[Empire, Colossus]

換句話說,作者已經被刪除了,但書沒有。這就是belongsTo的作用:它確保刪除操作和儲存操作一樣也會級聯。只需新增一行static belongsTo = AuthorBook,上面的程式碼將為Author Book列印空列表。很簡單,對吧?在這種情況下是這樣,但真正的樂趣才剛剛開始。

另外:看看我們在上面的例子中是如何強制 flush 會話的?如果我們不這樣做,Author.list()可能還會顯示剛剛被刪除的作者,僅僅因為更改可能還沒有在那時被持久化。

刪除子物件

刪除像Author例項並讓 GORM 自動刪除子物件是很直接的。但如果你只是想刪除作者的一本或多本書,而不是作者本人呢?你可能會嘗試這樣做

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

認為這將刪除所有書籍。但實際上這段程式碼會產生一個異常

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]

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

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

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()
}

這樣做可以,但天哪,這需要一些努力。

如果你有雙向關係,例如如果你的belongsTo使用這個語法static belongsTo = [ author: Author ]。如果我們不刪除它們,只是像這樣從集合中移除書

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

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

我們會得到一個 "not-null property references a null or transient value: Book.author"(非空屬性引用了空或瞬態值:Book.author)錯誤。正如我稍後解釋的,那是因為這些書的author屬性被設定為null。由於該屬性不可為空,這會觸發一個驗證錯誤。這足以把任何人逼瘋!

不要害怕,因為有解決方案。如果我們向Author:

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

中新增這個對映,那麼從作者中移除的任何書都會被 GORM 自動刪除。最後一個程式碼示例,即從集合中移除所有書的程式碼,現在就可以工作了。事實上,如果關係是單向的,你可以大幅減少程式碼量

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

這將一下子移除所有書並刪除它們!

這個故事的寓意很簡單:如果你在集合中使用belongsTo,並且該集合與父物件相關聯,那麼在父物件的mapping塊中顯式將級聯型別設定為 "all-delete-orphan"。事實上,有很強的理由將此設為belongsTo和 GORM 中一對多關係的預設行為。

這引發了一個有趣的問題:為什麼clear()方法在雙向關係中不起作用?我不是百分之百確定,但我認為這是因為書保留了對作者的反向引用。要理解為什麼這會影響clear()的行為,你首先必須認識到 GORM 對單向和雙向一對多關係對映到資料庫表的方式是不同的。對於單向關係,GORM 預設建立一個連線表,所以當你清空書的集合時,記錄只是從那個連線表中移除。雙向關係則是在子表上使用一個直接的外部索引鍵來對映,也就是我們例子中的書表。一個圖示應該能更清楚地說明這一點

one-to-many-mappings

當你清空書的集合時,那個外部索引鍵仍然存在,因為 GORM 不會清除author屬性的值。因此,就好像集合從未被清空過一樣。

集合部分差不多就講完了。我只想用快速檢視一下addTo*()removeFrom*()方法來結束本節。

addTo*()addTo*() vs removeFrom*()<<

在我的例子中,我使用了addTo*()removeFrom*()GORM 提供的動態方法。為什麼呢?畢竟,如果這些是標準的 Java 集合,我們難道不能直接使用這樣的程式碼嗎

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

當然可以,但使用 GORM 方法有一些微妙的好處。考慮這段程式碼

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

看起來沒什麼問題,對吧?然而,如果你執行這段程式碼,你會得到一個NullPointerException因為books集合還沒有被初始化。這與你從資料庫中獲取作者(例如使用get())時的行為完全不同。在這種情況下,我們可以愉快地向books集合中新增項。我們只有在透過new建立作者時才會遇到這個問題。如果你使用addTo*()方法,你就完全不用擔心這個問題,因為它null-safe(空值安全)。

現在考慮我們使用get()獲取作者,然後向其集合新增新書的例子。如果關係是雙向的,我們會遇到一個 "property not-null or transient"(屬性非空或瞬態)異常,因為書的author屬性沒有被設定。如果你使用標準的集合方法,你必須手動初始化反向引用。使用addTo*()方法,它會為你完成此操作。

方法的一個特性是隱式建立正確的領域類。注意在我們的例子中,我們只是將書的初始屬性值傳遞給方法,而不是顯式地例項化addTo*()方法的最後一個特性是隱式建立正確的領域類。注意在我們的例子中,我們只是將書的初始屬性值傳遞給方法,而不是顯式地例項化Book?那是因為該方法可以從hasMany屬性推斷出集合包含的型別。很巧妙,對吧?

removeFrom*()removeFrom*()方法用處較少,但它可以清除反向引用。當然,這與我之前討論的 "all-delete-orphan" 級聯選項配合使用效果最佳。

最後要考慮的關係型別是多對多。

多對多

如果你願意,可以讓 GORM 為你管理多對多關係。不過,這樣做需要注意幾點

刪除不級聯,句號。

關係的一方必須有一個belongsTo,但這通常不重要是哪一方有它。

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

總是使用連線表,但你無法在上面儲存任何額外資訊。

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

總結

好了,這可能是我目前寫過的最長的文章了,但你已經讀到了結尾。恭喜你!如果你無法一次性消化所有內容,也不必擔心,你可以隨時回過頭來查閱。

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

你可能不相信,但我還沒有涵蓋你需要了解的關於集合的所有內容。還有一些有趣的圍繞延遲載入的問題需要討論,但我會在下一篇文章中向你講解。

下次再見!

訂閱 Spring 新聞通訊

透過 Spring 新聞通訊保持聯絡

訂閱

取得領先

VMware 提供培訓和認證,助你快速前進。

瞭解更多

獲取支援

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

瞭解更多

即將舉行的活動

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

檢視全部