GORM 常见问题 (第二部分)

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

本系列的第一部分中,我向您介绍了与使用 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()

抛出了一个异常。如果您查看最终的“由…导致”异常,您将看到消息“not-null property references a null or transient value: Author.location”。发生了什么事?

关于“瞬态值”的部分是这里的关键。瞬态实例是指未附加到 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

在处理 Hibernate 中的关系时,您需要很好地理解级联的含义。这对 GORM 也是如此。级联确定应用于域实例时,哪些类型的操作也应用于该实例的关系。例如,鉴于上述模型,当我们保存作者时,是否会保存作者的位置?当我们删除作者时,位置是否会被删除?如果我们删除位置会怎样?关联的作者也会被删除吗?

保存和删除是与级联相关的最常见操作,它们也是您真正需要理解的唯一操作。因此,如果您回到上一节,您就会理解为什么Location实例没有与作者一起保存,因为该Author -> Location关系未启用级联。如果我们现在将Location更改为此

class Location {
    String city

    static belongsTo = Author
}

我们会发现异常消失了,并且Location实例与作者一起保存。该belongsTo行确保保存从Author级联到Location。如文档中所述,它还会级联删除,因此,如果您删除一个作者,其关联的位置也将被删除。但是,保存或删除位置不会保存或删除作者。

哪个belongsTo?

经常让用户感到困惑的一件事是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 使使用它们变得非常容易。尽管如此,这绝对是一个面向对象语言和关系数据库之间的阻抗不匹配凸显其丑陋之处的领域。首先,您必须记住,内存中的数据可能与数据库中的数据不同。

域实例集合与数据库记录

当域实例上有一个集合时,您正在处理内存中的对象。这意味着您可以像处理任何其他对象集合一样处理它。您可以迭代它,也可以修改它。然后,在某些时候,您需要将任何更改持久化到数据库中,您可以通过保存具有该集合的对象来做到这一点。我稍后会回到这一点,但首先我想演示与对象集合和实际数据之间的这种断开连接相关的一些细微差别。为此,我将介绍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()并不能保证立即持久化数据吗?这是一个具体的例子。如果您想在查询中看到新书籍,您必须添加一个显式刷新

...
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()(没有显式刷新)之后,这也会打印nulls。只有在刷新会话后,子域实例才会设置其 ID。这与我们之前看到的许多对一情况完全不同,在许多对一情况中,您不需要显式刷新即可将Location实例持久化到数据库!重要的是要意识到存在这种差异,否则您将遇到困难。

顺便说一句,如果您自己在 Grails 控制台中遵循示例,请注意,当您在控制台中运行脚本时保存的任何内容在您执行下一个脚本时仍然存在。只有在重新启动控制台时才会清除数据。此外,脚本完成后总是会刷新会话。

好的,回到集合。上面的示例展示了一些我想在接下来讨论的有趣行为。为什么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 = Author级联到Book,上面的代码将为Author Book打印空列表。很简单,对吧?在这种情况下,是的,但真正的乐趣才刚刚开始。

旁注:看看我们如何在上面的示例中强制刷新会话?如果我们不这样做,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]

哇,一个有用的堆栈跟踪消息!是的,问题在于书籍仍然在作者的集合中,因此当会话被刷新时,它们将被重新创建。请记住,不仅保存会被级联,而且修改后的域实例也会自动持久化(因为 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 属性引用空或瞬态值:Book.author”错误。正如我稍后将解释的那样,这是因为书籍的author属性被设置为null。由于该属性不可为空,因此会触发验证错误。这足以让任何人抓狂!

不要害怕,因为有一个解决方案。如果我们将此映射添加到Author:

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

那么从其作者中删除的任何书籍都将自动由 GORM 删除。最后一个代码示例(我们从集合中删除所有书籍)现在将起作用。事实上,如果关系是单向的,你可以大幅减少代码

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

这将删除所有书籍并在一次操作中删除它们!

这个故事的寓意很简单:如果你使用belongsTo与集合一起使用时,在父级的映射块中显式将级联类型设置为“all-delete-orphan”。事实上,有充分的理由将此作为 GORM 中belongsTo和一对多关系的默认行为。

这引发了一个有趣的问题:为什么clear()方法在双向关系上不起作用?我不确定 100%,但我认为这是因为书籍保留了对作者的反向引用。要了解为什么这会影响clear()的行为,你首先必须意识到 GORM 以不同的方式将单向和双向一对多关系映射到数据库表。对于单向关系,GORM 默认创建连接表,因此当你清除书籍集合时,记录只是从该连接表中删除。双向关系使用子表(例如我们示例中的书籍表)上的直接外键进行映射。图表应该可以更清楚地说明这一点。

one-to-many-mappings

当你清除书籍集合时,该外键仍然存在,因为 GORM 不会清除author属性的值。因此,就像集合从未被清空一样。

关于集合的内容差不多就是这样了。我只是想用快速浏览一下addTo*()removeFrom*()方法来结束本节。

addTo*()对比<<

在我的示例中,我使用了 GORM 提供的addTo*()removeFrom*()动态方法。为什么?毕竟,如果这些是标准的 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*()方法,则完全不必担心此问题,因为它对空值是安全的。

现在考虑在将新书籍附加到其集合之前,我们使用get()获取作者的示例。如果关系是双向的,我们将遇到“属性非空或瞬态”异常,因为书籍的author属性尚未设置。如果你使用标准的集合方法,则必须手动初始化反向引用。使用addTo*()方法,这将为你完成。

的最后一个功能addTo*()方法是正确域类的隐式创建。请注意,在我们的示例中,我们只是将书籍的初始属性值传递给该方法,而不是显式实例化Book?这是因为该方法可以从hasMany属性推断集合包含什么类型。很巧妙,对吧?

removeFrom*()方法不太有用,但它确实清除了反向引用。当然,这与我之前讨论的“all-delete-orphan”级联选项配合使用效果最佳。

要考虑的最后一种关系类型是多对多关系。

多对多

如果需要,你可以让 GORM 为你管理多对多关系。但是,如果你这样做,有一些需要注意的事项

删除不会级联,就是这样。

关系的一方必须具有belongsTo,但通常哪一方具有它并不重要。

belongsTo仅影响级联保存的方向 - 它不会导致级联删除

始终使用连接表,但你无法在其上存储任何额外信息。

抱歉在级联删除上着重强调,但了解其行为与多对一和一对多关系有很大不同非常重要。理解最后一点也很重要:许多多对多关系都有关联信息。例如,一个用户可能有多个角色,一个角色可能有多个用户。但是用户在不同的项目中可能具有不同的角色,因此项目与关系本身相关联。在这些情况下,最好自己管理多对多关系。

总结

好吧,这可能是我写过的最长的文章了,但你已经到达了结尾。恭喜!如果你没有能够一次性消化所有内容,也不要担心,你可以随时回头参考。

我认为 GORM 在以面向对象的方式处理数据库关系方面做得很好,提供了一个不错的抽象,但正如你所看到的,你不能真的忘记你最终是在处理数据库。但是,有了本文提供的信息,你应该在处理 GORM 集合的基本知识方面没有任何问题。希望这将意味着你可以享受在应用程序中使用一对多关系并获得好处。

你可能不相信,但我还没有涵盖你关于集合需要了解的所有内容。在延迟加载方面仍然有一些有趣的问题需要讨论,但我会在下一篇文章中向你详细介绍。

下次再见!

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,以加速你的进步。

了解更多

获得支持

Tanzu Spring 在一个简单的订阅中提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

查看 Spring 社区中所有即将举行的活动。

查看全部