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 社区所有即将举行的活动。

查看全部