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

查看所有