SpringData-和-Hibernate-的-Java-持久化指南-全-
SpringData 和 Hibernate 的 Java 持久化指南(全)
原文:Java Persistence with Spring Data and Hibernate
译者:飞龙
前置材料
前言
当卡塔林请我写这篇前言时,我意识到在我的 17 年职业生涯中,我大部分时间都在处理这本书中讨论和解决的问题。从历史上看,我们已经到了一个大多数数据都保存在关系型数据库管理系统中的状态。这个任务听起来可能相当简单;将数据保存在数据库中,读取它,如果需要就修改它,最后删除它。许多(甚至高级)开发者没有意识到在这些几个操作中有多少计算机科学。用 Java 这样的面向对象语言与关系型数据库交谈,就像与来自另一个世界、完全遵循不同规则的人交谈一样。
在我的职业生涯早期,我大部分时间都在做将“结果集”映射到 Java 对象的工作,没有任何复杂的逻辑。这并不难,但确实很耗时。我只是在做梦,想着我们的架构师不会突然改变对象结构,这样我就不得不从头开始重写一切。而且,我并不是唯一一个这样想的人!
为了节省手动工作并自动化这些翻译任务,创建了像 Hibernate 这样的框架,后来还有 Spring Data。它们确实为你做了很多工作。你只需要将它们作为依赖项添加,在你的代码中添加一些注解,魔法就会发生!这在小型项目中工作得很好,但在现实生活中,项目要大得多,有很多边缘情况!
Hibernate 和 Spring Data 有着相当长的历史,投入了巨大的努力来实现这一魔法。在这本书中,你会发现每个框架的功能、它们的边缘情况、建议的优化和最佳实践的定义性描述。
这本书的流程设计得如此之好,以至于你首先理解关系型数据库的基本理论和对象/关系映射(ORM)的主要问题。然后,你会看到如何用 Hibernate 解决这个问题,以及如何在 Spring Data 为 Spring 框架宇宙扩展功能。最后,你将了解 ORM 在 NoSQL 解决方案中的应用。
我可以说,这些技术无处不在!字面意义上无处不在!无论是开设银行账户、购买机票、向政府发送请求,还是在博客文章上留言,幕布后面,有很大概率 Hibernate 和/或 Spring Data 正在处理这些应用程序的持久化!这些技术很重要,这本书提供了关于它们各种应用的信息。
了解你的工具对于正确完成工作至关重要。在这本书中,你将找到所有你需要的工作,有效地使用 Hibernate 和 Spring Data,这些工作都基于计算机科学的理论。对于所有 Java 开发者来说,这是一本必读之书,尤其是那些在企业技术领域工作的开发者。
——德米特里·亚历山德罗夫
Oracle 软件工程师,Java 冠军
保加利亚 Java 用户组联合负责人,《Helidon in Action》作者
数据持久性是任何应用程序的关键部分,数据库无疑是现代企业的核心。虽然像 Java 这样的编程语言提供了面向对象的业务实体视图,但这些实体背后的数据通常是关系型的。正是这个挑战——连接关系型数据和 Java 对象——Hibernate 和 Spring Data 通过对象/关系映射(ORM)来承担。
正如 Cătălin 在这本书中展示的那样,在除了最简单的企业环境之外,ORM 技术的有效使用需要理解和配置关系型数据和对象之间的中介。这要求开发者对应用程序及其数据需求、SQL 查询语言、关系型存储结构以及优化潜力有深入了解。
本书全面概述了使用行业领先的工具 Spring Data 和 Hibernate 进行 Java 持久性的方法。它涵盖了如何使用它们的类型映射能力和建模关联和继承的设施;如何通过 Querydsl 查询 JPA 来高效检索对象;如何使用 Spring Data 和 Hibernate 处理和管理事务;如何创建获取计划、策略和配置文件;如何过滤数据;如何配置 Hibernate 以在受管理和不受管理环境中使用;以及如何使用它们的命令。此外,你还将了解如何构建 Spring Data REST 项目,使用非关系型数据库进行 Java 持久性,以及测试 Java 持久性应用程序。在整本书中,作者提供了对 ORM 底层问题的见解以及 Hibernate 背后的设计选择。这些见解将使读者对 ORM 作为企业技术的有效使用有深入的理解。
使用 Spring Data 和 Hibernate 进行 Java 持久性是这些流行工具持久性的权威指南。你将受益于对 Spring Data JPA、Spring Data JDBC、Spring Data REST、JPA 和 Hibernate 的详细覆盖,比较和对比替代方案,以便能够为当今的企业计算选择最适合你代码的方案。
由于两个原因,我有幸推荐这本书。首先,我与作者分享了一个希望,那就是它将帮助你生产出越来越高效、安全、可测试的软件,其质量是其他人可以自信依赖的。其次,我认识作者本人,他在个人和技术层面都非常出色。他在软件开发行业有丰富的经验,他的专业活动,包括视频、书籍和文章,都是面向全球开发者社区受益的。
——穆罕默德·塔曼
首席解决方案架构师,Nortal
Java 冠军,Oracle ACE,JCP 成员
前言
我很幸运,在 IT 行业已经工作了超过 25 年。我在学生时代和职业生涯的初期就开始了 C++和 Delphi 编程。我从青少年时期的数学背景转向计算机科学,并一直努力将这两方面都保持在心中。
2000 年,我的注意力第一次转向 Java 编程语言。当时它非常新,但许多人都在预测它有一个光明的未来。我是网络游戏开发团队的一员,我们当时使用的技术是 applets,这在那些年是非常流行的。在应用程序背后,程序需要访问数据库,我们的团队花了一些时间开发访问和与数据库交互的逻辑。当时还没有使用 ORM,但我们能够开发自己的库来与数据库交互,这塑造了 ORM 的初步想法。
自 2004 年以来,我超过 90%的时间都在使用 Java 进行工作。对我来说,这是一个新时代的开始,像代码重构、单元测试和对象/关系映射这样的东西在我们的专业生活中变得越来越正常。
目前,有很多 Java 程序访问数据库,并依赖于高级技术和服务,如 JPA、Hibernate 和 Spring Data。使用 JDBC 的旧方法几乎已经不被记住。作为 Luxoft 的 Java 和 Web 技术专家以及 Java 章节负责人,我的一个活动就是开展关于 Java 持久化主题的课程,并指导我的同事关于这个话题。
我在 2020 年为 Manning Publications 写了我的第一本书,《JUnit in Action》,并且我很幸运能继续与他们合作。这本书的前几版主要关注 Hibernate,而如今 Spring 和 Spring Data 在 Java 程序中扮演着越来越重要的角色。当然,我们也专门用一章来介绍测试 Java 持久化应用程序。
对象/关系映射和 Java 持久化技术自从它们早期以来以及我开始使用它们以来已经取得了长足的进步。这些概念需要仔细的考虑和规划,因为特定的应用程序和技术需要特定的知识和方法。这本书有效地提供了这些信息,并附带了大量的示例。你还会找到简洁且易于遵循的程序来解决一系列任务。我希望这里概述的方法能帮助你决定在面临工作中新的情况时应该做什么。
致谢
首先,我想感谢我的教授和同事多年来给予的所有支持,以及参加我面对面和在线课程的所有参与者——他们激励我追求高质量的工作,并始终寻求改进。
我还想感谢 Luxoft,我在那里活跃了近 20 年,并且目前作为 Java 和 Web 技术专家以及 Java 章节负责人在那里工作。
感谢 Christian Bauer、Gavin King 和 Gary Gregory,他们是《Java Persistence with Hibernate》的共同作者,这本书为本书奠定了坚实的基础。希望有一天能亲自见到你们所有人。
向 Luxoft 的同事们表示最诚挚的感谢,Vladimir Sonkin 和 Oleksii Kvitsynskyi,我们共同研究新技术,开发 Java 课程和 Java 章节内容。在历史的长河中,能够如此有效地与俄罗斯和乌克兰工程师合作,是一个难得的机会。
我还想感谢 Manning 的员工:收购编辑 Mike Stephens、开发编辑 Katie Sposato Johnson 和 Christina Taylor、技术校对员 Jean-François Morin、校对员 Andy Carroll 以及幕后的制作团队。Manning 团队帮助我创作了一本高水平的书籍,我期待着更多这样的机会。
我很高兴两位杰出的专家,Dmitry Aleksandrov 和 Mohamed Taman,欣赏这本书并为其撰写了序言——与技术问题共同分析总是令人愉快的。
致所有审稿人:Amrah Umudlu、Andres Sacco、Bernhard Schuhmann、Bhagvan Kommadi、Damián Mazzini、Daniel Carl、Abayomi Otebolaku、Fernando Bernardino、Greg Gendron、Hilde Van Gysel、Jan van Nimwegen、Javid Asgarov、Kim Gabrielsen、Kim Kjærsulf、Marcus Geselle、Matt Deimel、Mladen Knezic、Najeeb Arif、Nathan B. Crocker、Özay Duman、Piotr Gliźniewicz、Rajinder Yadav、Richard Meinsen、Sergiy Pylypets、Steve Prior、Yago Rubio、Yogesh Shetty 和 Zorodzayi Mukuy——你们的建议帮助使这本书变得更好。
关于本书
Java Persistence with Spring Data and Hibernate 探讨了使用最流行的可用工具进行持久化。你将受益于对 Spring Data JPA、Spring Data JDBC、Spring Data REST、JPA 和 Hibernate 的详细覆盖,比较和对比了各种替代方案,以便你可以选择最适合你代码的方案。
我们将从对对象/关系映射(ORM)的实战介绍开始,然后深入探讨如何将对象与数据库连接的映射策略。你将了解 Hibernate 和 Spring Data 中事务的不同处理方法,甚至如何使用非关系型数据库实现 Java 持久化。最后,我们将探讨持久化应用程序的测试策略,以保持代码的清洁和错误最少。
适合阅读本书的人群
本书面向已经熟练编写 Java 核心代码的应用程序开发者,他们有兴趣学习如何开发能够轻松有效地与数据库交互的应用程序。你应该熟悉面向对象编程,并至少具备 Java 的工作知识。你还需要具备 Maven 的工作知识,能够构建 Maven 项目,在 IntelliJ IDEA 中打开 Java 程序,编辑它,并在执行中启动它。一些章节需要了解 Spring 或 REST 等技术的基本知识。
本书组织结构概述:路线图
本书共有 6 部分,20 章。第一部分将帮助你开始 ORM 之旅。
-
第一章介绍了对象/关系范式不匹配以及处理它的几种策略,首先是对象/关系映射(ORM)。
-
第二章通过 Jakarta Persistence API、Hibernate 和 Spring Data 教程逐步指导你,你将实现并测试一个“Hello World”示例。
-
第三章教你如何在 Java 中设计和实现复杂的企业领域模型,以及你有哪些可用的映射元数据选项。
-
第四章将提供 Spring Data JPA 的第一印象,介绍如何使用它及其功能。
第二部分是关于 ORM,从类和属性到表和列。
-
第五章从常规类和属性映射开始,解释了如何映射细粒度的 Java 领域模型。
-
第六章演示了如何映射基本属性和可嵌入组件,以及如何控制 Java 和 SQL 类型之间的映射。
-
第七章演示了如何使用四种基本的继承映射策略将实体的继承层次映射到数据库中。
-
第八章是关于映射集合和实体关联。
-
第九章深入探讨了高级实体关联映射,如一对一实体关联映射、一对多映射选项以及多对多和三元实体关系。
-
第十章讨论了数据管理,检查对象的生命周期和状态,以及如何有效地使用 Jakarta Persistence API。
第三部分是关于使用 Hibernate 和 Java Persistence 加载数据和存储数据。它介绍了编程接口,如何编写事务性应用程序,以及 Hibernate 如何最有效地从数据库加载数据。
-
第十一章定义了数据库和系统事务的基本要素,并解释了如何使用 Hibernate、JPA 和 Spring 控制并发访问。
-
第十二章探讨了延迟加载和即时加载、获取计划、策略和配置文件,并以优化 SQL 执行讨论结束。
-
第十三章涵盖了级联状态转换、监听和拦截事件、使用 Hibernate Envers 进行审计和版本控制,以及动态过滤数据。
第四部分将 Java 持久化与目前最广泛使用的 Java 框架:Spring 连接起来。
-
第十四章教你创建 JPA 或 Hibernate 应用程序的最重要策略,以及如何将其与 Spring 集成。
-
第十五章介绍了使用大型 Spring Data 框架的另一部分:Spring Data JDBC 开发持久化应用程序的可能性,并进行了分析。
-
第十六章探讨了 Spring Data REST,你可以使用它来构建表示状态转换(REST)架构风格的应用程序。
第五部分将 Java 应用程序连接到常用的 NoSQL 数据库:MongoDB 和 Neo4j。
-
第十七章介绍了 Spring Data MongoDB 框架最重要的特性,并将其与已讨论的 Spring Data JPA 和 Spring Data JDBC 进行比较。
-
第十八章介绍了 Hibernate OGM 框架,并演示了如何使用 JPA 代码连接到不同的 NoSQL 数据库(MongoDB 和 Neo4j)。
第六部分教您如何编写查询以及如何测试 Java 持久化应用程序。
-
第十九章讨论了使用 Querydsl 进行工作,这是使用 Java 程序查询数据库的替代方案之一。
-
第二十章探讨了如何测试 Java 持久化应用程序,介绍了测试金字塔,并分析了在特定环境下的持久化测试。
关于代码
本书包含(主要是)大块代码,而不是短小的代码片段。因此,所有代码列表都有注释和解释。
您可以通过从 GitHub 下载获取所有这些示例的完整源代码github.com/ctudose/java-persistence-spring-data-hibernate。您还可以从本书的 liveBook(在线)版本获取可执行的代码片段livebook.manning.com/book/java-persistence-with-spring-data-and-hibernate。本书中示例的完整代码可在 Manning 网站www.manning.com/books/java-persistence-with-spring-data-and-hibernate上下载。
liveBook 讨论论坛
购买使用 Spring Data 和 Hibernate 进行 Java 持久化包括免费访问 liveBook,Manning 的在线阅读平台。使用 liveBook 的独特讨论功能,您可以在全球范围内或针对特定章节或段落添加评论。为自己做笔记、提问和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/java-persistence-with-spring-data-and-hibernate/discussion。您还可以在livebook.manning.com/discussion了解更多关于 Manning 论坛和行为准则的信息。
Manning 对我们读者的承诺是提供一个场所,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要本书有售,论坛和以前讨论的存档将可通过出版社的网站访问。
关于作者

Cătălin Tudose 出生于罗马尼亚的 Piteşti,Argeş,1997 年在布加勒斯特获得计算机科学学位。他还拥有这个领域的博士学位。他在 Java 领域拥有超过 20 年的经验,目前担任 Luxoft Romania 的 Java 和网络技术专家。作为布加勒斯特自动化与计算机学院的助教和教授,他教授了超过 2,000 小时的课程和应用。Cătălin 还在公司内部教授了超过 3,000 小时的 Java 课程,包括企业初级项目,该项目在波兰培养了大约 50 名新的 Java 程序员。他在 UMUC(马里兰大学大学学院)教授了在线课程:使用 Java 的计算机图形学(CMSC 405)、Java 中级编程(CMIS 242)和 Java 高级编程(CMIS 440)。Cătălin 为 Pluralsight 开发了六个与 JUnit 5、Spring 和 Hibernate 相关的课程:“使用 JUnit 5 进行 TDD”、“Java BDD 基础”、“在 Java 中实现测试金字塔策略”、“Spring 框架:使用 Spring AOP 的面向方面编程”、“从 JUnit 4 迁移到 JUnit 5 测试平台”和“Java Persistence with Hibernate 5 基础”。除了 IT 领域和数学,Cătălin 对世界文化和足球也感兴趣。他是 FC Argeş Piteşti 的终身支持者。
《Java Persistence with Hibernate,第二版》的作者
Christian Bauer 是 Hibernate 开发团队的成员;他担任培训师和顾问。
Gavin King 是 Hibernate 的创造者,并在 Red Hat 担任杰出工程师。他帮助设计了 JPA 和 EJB 3,并担任了 CDI 规范的规范负责人和作者。他最近参与了 Hibernate 6 和 Hibernate Reactive 的工作,并参与了 Quarkus 设计的咨询。Gavin 在世界各地的数百个会议和 Java 用户组上进行了演讲。
Gary Gregory 是 Rocket Software 的首席软件工程师,负责应用程序服务器和遗留集成。他是 Manning 出版的 JUnit in Action 和 Spring Batch in Action 的合著者,并担任 Apache 软件基金会项目(Commons、HttpComponents、Logging Services 和 Xalan)的项目管理委员会成员。
关于封面插图
《Java Persistence with Spring Data and Hibernate》封面上的图像是“Homme Maltois”,或“马耳他人”,取自 Jacques Grasset de Saint-Sauveur 的作品集,该作品集于 1788 年出版。每一幅插图都是手工精心绘制和着色的。
在那些日子里,人们通过他们的服装很容易就能识别出他们住在哪里,以及他们的职业或社会地位。Manning 通过基于几个世纪前丰富的地方文化多样性的书封面,以及像这样的作品集中的图片,庆祝计算机行业的创新和主动性。
第一部分. ORM 入门
在第一部分,我们将向你展示对象持久化为什么是一个如此复杂的话题,以及你可以在实践中应用哪些解决方案。第一章介绍了对象/关系范式不匹配以及处理它的几种策略,首先是对象/关系映射(ORM)。在第二章,我们将逐步引导你通过使用 Jakarta Persistence API(JPA)、Hibernate 和 Spring Data 的教程——你将实现并测试一个“Hello World”示例。这样准备之后,在第三章,你将准备好学习如何设计和实现复杂的业务领域模型,以及你有哪些可用的映射元数据选项。然后第四章将探讨使用 Spring Data JPA 及其功能。
阅读完这本书的这一部分,你就会明白为什么你需要 ORM,以及 JPA、Hibernate 和 Spring Data 在实际中是如何工作的。你将已经完成了你的第一个小型项目,并且准备好去应对更复杂的问题。你还将了解如何在 Java 领域模型中实现现实世界的业务实体,以及你更喜欢以什么格式与 ORM 元数据一起工作。
1 理解对象/关系持久化
本章涵盖
-
在 Java 应用程序中使用 SQL 数据库进行持久化
-
分析对象/关系范式不匹配
-
介绍 ORM、JPA、Hibernate 和 Spring Data
这本书是关于 JPA、Hibernate 和 Spring Data 的;我们的重点是使用 Hibernate 作为 Jakarta Persistence API(以前称为 Java Persistence API)的提供者,以及 Spring Data 作为基于 Spring 的数据访问编程模型。我们将涵盖基本和高级功能,并描述一些使用 Java Persistence API 开发新应用的方法。这些推荐通常不仅限于 Hibernate 或 Spring Data。有时,它们是我们关于在处理持久数据时最佳做法的自己的想法,这些想法在 Hibernate 和 Spring Data 的背景下进行解释。
在许多软件项目中,选择管理持久数据的方法可能是一个关键的设计决策。持久性一直是 Java 社区的热门辩论话题。持久性是一个已经被 SQL 及其扩展(如存储过程)解决的问题,还是一个更普遍的问题,必须通过特殊的 Java 框架来解决?我们应该手动编写即使是最低级的 CRUD(创建、读取、更新、删除)操作,使用 SQL 和 JDBC,还是应该将这些工作交给中间层?如果每个数据库管理系统都有自己的 SQL 方言,我们如何实现可移植性?我们应该完全放弃 SQL 并采用不同的数据库技术,如对象数据库系统或 NoSQL 系统吗?辩论可能永远不会结束,但现在有一个名为对象/关系映射(ORM)的解决方案已经得到了广泛的认可。这很大程度上归功于 Hibernate,一个开源的 ORM 服务实现,以及 Spring Data,它是 Spring 家族的一个伞形项目,其目的是统一并简化对不同类型持久化存储的访问,包括关系数据库系统和 NoSQL 数据库。
然而,在我们开始使用 Hibernate 和 Spring Data 之前,你需要了解对象持久化和 ORM 的核心问题。本章解释了为什么你需要像 Hibernate 和 Spring Data 这样的工具以及像Jakarta Persistence API(JPA)这样的规范。
首先,我们将定义软件应用程序中的持久数据管理,并讨论 SQL、JDBC 和 Java 之间的关系,Hibernate 和 Spring Data 构建在底层技术和标准之上。然后,我们将讨论所谓的对象/关系范式不匹配以及我们在面向对象软件开发中使用 SQL 数据库时遇到的通用问题。这些问题清楚地表明,我们需要工具和模式来最小化我们在应用程序中花费在持久化相关代码上的时间。
学习 Hibernate 和 Spring Data 的最佳方式不一定是一成不变的。我们理解你可能想立即尝试 Hibernate 或 Spring Data。如果你这样想,请跳到下一章,并使用“Hello World”示例设置一个项目。我们建议你在阅读本书的过程中某个时候返回这里;这样,你将准备好,并拥有完成剩余材料所需的所有背景概念。
1.1 什么是持久性?
大多数应用都需要持久化数据。持久性是应用开发中的基本概念之一。如果一个信息系统在断电时没有保留数据,那么这个系统将几乎没有实际用途。对象持久性意味着单个对象可以比应用过程存在得更久;它们可以被保存到数据存储中,并在稍后的某个时间点重新创建。当我们谈论 Java 中的持久性时,我们通常是指使用 SQL 将对象实例映射和存储到数据库中。
我们将首先简要地看看持久性及其在 Java 中的使用。有了这些信息,我们将继续讨论持久性,并探讨它在面向对象应用中的实现方式。
1.1.1 关系型数据库
你,像大多数其他软件工程师一样,可能已经使用过 SQL 和关系型数据库;我们中的许多人每天都在处理这样的系统。关系型数据库管理系统有基于 SQL 的应用程序编程接口,所以我们称今天的关系型数据库产品为 SQL 数据库管理系统(DBMS)或,当我们谈论特定系统时,称为 SQL 数据库。
关系型技术是一个众所周知的技术,仅此一点就足以成为许多组织选择它的充分理由。关系型数据库也是数据管理的一个极其灵活和强大的方法。由于关系数据模型经过充分研究的理论基础,关系型数据库可以保证并保护存储数据的完整性,同时具有其他可取的特性。你可能熟悉 E.F. Codd 五十年前提出的关于关系模型的介绍,“大型共享数据银行的关系数据模型”(Codd,1970)。一本值得阅读的更近期的汇编,专注于 SQL,是 C.J. Date 的《SQL 和关系理论》(Date,2015)。
关系型数据库管理系统并不特定于 Java,SQL 数据库也不特定于某个特定应用。这个重要的原则被称为数据独立性。换句话说,数据通常比应用存在的时间更长。关系型技术提供了一种在不同应用之间或同一整体系统的不同部分之间(例如,数据录入应用和报告应用)共享数据的方法。关系型技术是许多不同系统和技术平台的一个共同基础。因此,关系数据模型通常是企业级业务实体表示的基础。
在我们深入探讨 SQL 数据库的实际应用方面之前,我们需要提到一个重要的问题:尽管被宣传为关系型,但仅提供 SQL 数据语言接口的数据库系统实际上并不是关系型的,而且在很多方面甚至与原始概念相去甚远。这自然导致了混淆。SQL 实践者将 SQL 语言中的不足归咎于关系数据模型,而关系数据管理专家则指责 SQL 标准是关系模型和理想的薄弱实现。本书中我们将突出一些与这个问题相关的重要方面,但总体上我们将关注实际应用。如果你对更多背景资料感兴趣,我们强烈推荐 Ramez Elmasri 和 Shamkant B. Navathe 所著的《数据库系统基础》(Elmasri, 2016),以了解关系数据库系统的理论和概念。
1.1.2 理解 SQL
为了有效地使用 JPA、Hibernate 和 Spring Data,你必须从对关系模型和 SQL 的扎实理解开始。你需要理解关系模型和信息模型,以及诸如规范化等主题,以确保数据的完整性,并且你需要使用你的 SQL 知识来调整应用程序的性能——这些都是阅读本书的先决条件。Hibernate 和 Spring Data 简化了众多重复的编码任务,但如果你想充分利用现代 SQL 数据库的全部功能,你的持久化技术知识必须超越这些框架本身。要深入了解,请参阅本书末尾参考文献列表中的资源。
你可能已经使用 SQL 多年了,并且熟悉这种语言中编写的各种基本操作和语句。然而,根据我们自己的经验,SQL 有时很难记住,而且一些术语的使用方式也有所不同。
你应该对这些术语感到熟悉,因此让我们简要回顾一下本书中我们将使用的 SQL 术语。SQL 被用作 数据定义语言(DDL),具有用于在 DBMS 目录中 创建、修改 和 删除 如表和约束等实体的语法。当这个 模式 准备就绪时,你可以使用 SQL 作为 数据操纵语言(DML)来对数据进行操作,包括 插入、更新 和 删除。你可以通过执行带有 限制、投影 和 笛卡尔积 的 数据查询语言(DQL)语句来检索数据。为了高效地报告,你可以根据需要使用 SQL 来 连接、聚合 和 分组 数据。你甚至可以将 SQL 语句嵌套在彼此内部——这是一种使用 子查询 的技术。当你的业务需求发生变化时,你必须在数据存储后使用 DDL 语句再次修改数据库模式;这被称为 模式演变。你还可以使用 SQL 作为 数据控制语言(DCL)来 授予和撤销 对数据库或其部分的访问权限。
如果你是一位 SQL 老手,并且想了解更多关于优化以及 SQL 是如何执行的信息,可以获取 Dan Tow(Tow,2003)所著的优秀书籍《SQL Tuning》。如果你想从如何不使用 SQL 的角度了解 SQL 的实际应用,Bill Karwin(Karwin,2010)所著的《SQL Antipatterns: Avoiding the Pitfalls of Database Programming》是一本很好的资源。
虽然 SQL 数据库是 ORM 的一部分,但另一部分,当然,是由需要持久化到数据库并从中加载的 Java 应用程序中的数据组成。
1.1.3 在 Java 中使用 SQL
当你在 Java 应用程序中与 SQL 数据库一起工作时,你通过 Java 数据库连接(JDBC)API 向数据库发出 SQL 语句。无论 SQL 是手动编写并嵌入到 Java 代码中,还是由 Java 代码即时生成,你都会使用 JDBC API 在准备查询参数、执行查询、滚动查询结果、从结果集中检索值等操作时绑定参数。这些都是低级的数据访问任务;作为应用工程师,我们更感兴趣的是需要这种数据访问的业务问题。我们真正想写的是能够保存和检索我们类实例的代码,从而让我们摆脱这种低级劳动。
由于这些数据访问任务通常非常繁琐,我们必须问:关系型数据模型和(尤其是)SQL 是否是面向对象应用程序持久化的正确选择?我们可以明确地回答这个问题:是的!有许多原因使得 SQL 数据库在计算行业中占据主导地位——关系型数据库管理系统是唯一经过验证的通用数据管理技术,并且它们几乎总是 Java 项目的要求。
注意,我们并不是声称关系型技术总是是最好的解决方案。许多数据管理需求需要完全不同的方法。例如,互联网规模的分布式系统(如网络搜索引擎、内容分发网络、对等共享、即时通讯)必须处理异常的交易量。许多这些系统不需要在数据更新完成后,所有进程都看到相同的更新数据(强事务一致性)。用户可能对弱一致性感到满意;在所有进程看到更新数据之前,可能会有一段时间的不一致性。相比之下,一些科学应用处理的是巨大但非常专业的数据集。这些系统和它们独特的挑战通常需要同样独特且通常是定制的持久化解决方案。像 ACID 兼容的事务 SQL 数据库、JDBC、Hibernate 和 Spring Data 这样的通用数据管理工具,对于这些类型的系统只扮演着较小的角色。
互联网规模的关系型系统
要理解为什么关系型系统及其相关的数据完整性保证难以扩展,我们建议您首先熟悉CAP 定理。根据这一规则,一个分布式系统不能同时保证一致性、可用性和对分区故障的容错性。
一个系统可能保证所有节点将同时看到相同的数据,并且数据读写请求总是得到响应。但是,当系统的一部分由于主机、网络或数据中心问题而失败时,您必须放弃强一致性或 100%的可用性。在实践中,这意味着您需要一个策略来检测分区故障,并在一定程度上恢复一致性或可用性(例如,通过暂时使系统的一部分不可用,以便在后台进行数据同步)。通常,数据、用户或操作将决定是否需要强一致性。
在本书中,我们将从使用领域模型的对象导向应用程序的上下文中考虑数据存储和共享的问题。应用程序的业务逻辑不会直接与java.sql.ResultSet的行和列交互,而是与特定于应用程序的对象导向领域模型交互。例如,如果在线拍卖系统的 SQL 数据库模式有ITEM和BID表,Java 应用程序将定义相应的Item和Bid类。应用程序不会使用ResultSet API 读取和写入特定行和列的值,而是加载和存储Item和Bid类的实例。
因此,在运行时,应用程序使用这些类的实例操作。每个Bid实例都有一个对拍卖Item的引用,每个Item可能有一组对Bid实例的引用。业务逻辑不在数据库中执行(作为 SQL 存储过程),而是在 Java 中实现并在应用层执行。这允许业务逻辑使用复杂的面向对象概念,如继承和多态。例如,我们可以使用如策略、中介者和组合等知名设计模式(参见设计模式:可重用面向对象软件元素 [Gamma, 1994]),所有这些模式都依赖于多态方法调用。
现在有一个警告:并不是所有的 Java 应用程序都是这样设计的,也不应该这样设计。简单的应用程序可能在没有领域模型的情况下更好。如果只需要 JDBC ResultSet,请使用它。调用现有的存储过程,并读取它们的 SQL 结果集。许多应用程序需要执行修改大量数据的存储过程,这些数据接近数据源。您还可以使用普通的 SQL 查询实现一些报告功能,并将结果直接显示在屏幕上。SQL 和 JDBC API 在处理表格数据表示方面非常适用,JDBC RowSet使 CRUD 操作更加容易。与这种持久数据的表示形式一起工作简单明了,且易于理解。
但对于具有非平凡业务逻辑的应用程序,领域模型方法有助于显著提高代码重用性和可维护性。在实践中,两种策略都是常见且必要的。
几十年来,开发者一直在谈论一种范式不匹配。这里所指的范式是指对象建模和关系建模,或者更实际地说,是面向对象编程和 SQL。这种不匹配解释了为什么每个企业项目都要在持久化相关问题上投入大量精力。有了这种观念,您就可以开始看到必须解决的问题——一些问题被充分理解,而另一些问题则不太被理解——这些问题必须在一个结合了面向对象领域模型和持久化关系模型的程序中解决。让我们更仔细地看看这个所谓的范式不匹配。
1.2 范式不匹配
对象/关系范式不匹配可以分为几个部分,我们将逐一检查。让我们从一个简单且无问题的例子开始我们的探索。随着我们的构建,您将看到不匹配开始出现。
假设您必须设计和实现一个在线电子商务应用程序。在这个应用程序中,您需要一个类来表示系统用户的信息,并且您需要一个类来表示用户的账单详细信息,如图 1.1 所示。

图 1.1 User和BillingDetails实体的简单 UML 图
在这个图中,您可以看到一个User有多个BillingDetails。这是一个组合,由完整的菱形表示。组合是一种关联类型,其中对象(在我们的例子中是BillingDetails)在概念上不能独立于容器(在我们的例子中是User)存在。您可以双向导航类之间的关系;这意味着您可以通过迭代集合或调用方法来访问关系的“另一边”。代表这些实体的类可能非常简单:
Path: Ch01/e-commerce/src/com/manning/javapersistence/ch01/User.java
public class User {
private String username;
private String address;
private Set<BillingDetails> billingDetails = new HashSet<>();
// Constructor, accessor methods (getters/setters), business methods
}
Path: Ch01/e-commerce/src/com/manning/javapersistence/ch01
➥ /BillingDetails.java
public class BillingDetails {
private String account;
private String bankname;
private User user;
// Constructor, accessor methods (getters/setters), business methods
}
注意,我们只对实体的持久化状态感兴趣,因此省略了构造函数、访问方法和业务方法的实现。
为此情况(以下查询的语法适用于 MySQL)设计 SQL 模式很简单:
CREATE TABLE USERS (
USERNAME VARCHAR(15) NOT NULL PRIMARY KEY,
ADDRESS VARCHAR(255) NOT NULL
);
CREATE TABLE BILLINGDETAILS (
ACCOUNT VARCHAR(15) NOT NULL PRIMARY KEY,
BANKNAME VARCHAR(255) NOT NULL,
USERNAME VARCHAR(15) NOT NULL,
FOREIGN KEY (USERNAME) REFERENCES USERS(USERNAME)
);
在 BILLINGDETAILS 中的外键约束列 USERNAME 代表了两个实体之间的关系。对于这个简单的领域模型,对象/关系不匹配几乎不明显;编写 JDBC 代码来插入、更新和删除用户和账单详细信息的信息是直接的。
现在,让我们看看当我们考虑一些更现实的情况时会发生什么。当我们向应用程序添加更多实体和实体关系时,范式不匹配将变得明显。
1.2.1 粒度问题
当前实现中最明显的问题是,我们将地址设计为一个简单的 String 值。在大多数系统中,有必要分别存储街道、城市、州、国家和邮政编码信息。当然,你可以直接将这些属性添加到 User 类中,但由于系统中的其他类也可能携带地址信息,因此创建一个 Address 类来重用它更有意义。图 1.2 显示了更新后的模型。

图 1.2 User 有一个 Address。
User 和 Address 之间的关系是一个聚合,由空菱形表示。我们是否也应该添加一个 ADDRESS 表?不一定;在 USERS 表中,将地址信息保存在单独的列中是很常见的。这种设计可能性能更好,因为如果你想要在单个查询中检索用户和地址,不需要表连接。最好的解决方案可能是创建一个新的 SQL 数据类型来表示地址,并在 USERS 表中添加一个该新类型的单列,而不是添加几个新列。
添加几个列或单个新 SQL 数据类型的单列的选择是 粒度 问题。广义上讲,粒度指的是你正在使用的类型的相对大小。
让我们回到例子。将新的数据类型添加到数据库目录中,以在单个列中存储 Address Java 实例,听起来是最好的方法:
CREATE TABLE USERS (
USERNAME VARCHAR(15) NOT NULL PRIMARY KEY,
ADDRESS ADDRESS NOT NULL
);
Java 中新增的 Address 类型(类)和新的 ADDRESS SQL 数据类型应该能保证互操作性。但如果你检查当前 SQL 数据库管理系统对用户定义数据类型(UDTs)的支持,你会发现各种问题。
UDT 支持是传统 SQL 的几种所谓 对象/关系扩展 之一。这个术语本身就很令人困惑,因为它意味着数据库管理系统(或应该支持)一个复杂的数据类型系统。不幸的是,UDT 支持是大多数 SQL DBMS 的一个相对不为人知的特性,而且它肯定在不同产品之间不可移植。此外,SQL 标准支持用户定义数据类型,但支持得并不好。
这种限制并不是关系数据模型的错误。你可以将未能标准化这样重要功能的功能视为 20 世纪 90 年代中期供应商之间对象/关系数据库战争的结果。如今,大多数工程师都接受 SQL 产品具有有限类型系统——无需质疑。即使你的 SQL 数据库管理系统中有一个复杂的 UDT 系统,你也可能仍然需要重复类型声明,既在 Java 中编写新类型,又在 SQL 中再次编写。试图为 Java 空间找到更好的解决方案,如 SQLJ,不幸的是并没有取得很大成功。数据库管理系统产品很少支持直接在数据库上部署和执行 Java 类,如果支持可用,通常也仅限于日常使用中的非常基本的功能。
由于这些以及其他原因,目前在 SQL 数据库中使用 UDTs 或 Java 类型并不是常见做法,你不太可能遇到大量使用 UDTs 的遗留模式。因此,我们无法也不打算将我们新的Address类的实例存储在具有与 Java 层相同数据类型的单个新列中。
解决这个问题的实用方案涉及多个内置的供应商定义的 SQL 类型列(如布尔型、数值型和字符串数据类型)。你通常会按照以下方式定义USERS表:
CREATE TABLE USERS (
USERNAME VARCHAR(15) NOT NULL PRIMARY KEY,
ADDRESS_STREET VARCHAR(255) NOT NULL,
ADDRESS_ZIPCODE VARCHAR(5) NOT NULL,
ADDRESS_CITY VARCHAR(255) NOT NULL
);
Java 领域模型中的类具有不同粒度级别:从粗粒度的实体类如User到更细粒度的类如Address,再到简单的扩展AbstractNumericZipCode的SwissZipCode(或任何你期望的抽象级别)。相比之下,SQL 数据库中只可见两个级别的类型粒度:你创建的关系类型,如USERS和BILLINGDETAILS,以及内置的数据类型,如VARCHAR、BIGINT和TIMESTAMP。
许多简单的持久化机制未能认识到这种不匹配,因此最终迫使 SQL 产品在面向对象模型上采用更不灵活的表示形式,实际上将其扁平化。实际上,粒度问题并不特别难以解决,尽管它在许多现有系统中都可见。我们将在 5.1.1 节中探讨这个问题的解决方案。
当我们考虑依赖于继承的领域模型时,会出现一个更困难且更有趣的问题,继承是面向对象设计中的一个特性,你可以用它以新颖和有趣的方式对你的电子商务应用程序的用户进行计费。
1.2.2 继承问题
在 Java 中,您通过使用超类和子类来实现类型继承。为了说明这可能会引起不匹配问题,让我们修改我们的电子商务应用程序,使其现在不仅可以接受银行账户账单,还可以接受信用卡。在模型中反映这种变化的最自然方式是使用BillingDetails超类的继承,以及多个具体的子类:CreditCard、BankAccount。这些子类中的每一个都定义了略微不同的数据(以及完全不同的、作用于这些数据的功能)。图 1.3 中的 UML 类图展示了这个模型。

图 1.3 使用继承实现不同的账单策略
为了支持这个更新的 Java 类结构,我们必须做出哪些改变?我们能否创建一个扩展BILLINGDETAILS的CREDITCARD表?SQL 数据库产品通常不实现表继承(甚至数据类型继承),如果它们实现了,也不遵循标准语法。
我们还没有结束对继承的讨论。一旦我们将继承引入模型,我们就有了可能实现多态性的可能性。User类与BillingDetails超类有一个多态关联。在运行时,一个User实例可能引用BillingDetails的任何子类实例。同样,我们希望能够编写多态查询,这些查询引用BillingDetails类,并返回其子类的实例。
SQL 数据库缺乏一种明显的方式(或者至少是一种标准化的方式)来表示多态关联。外键约束指向一个确切的目标表;定义一个指向多个表的外键并不直接。
这种子类型不匹配的结果是,模型中的继承结构必须持久化到一个不提供继承机制的 SQL 数据库中。在第七章中,我们将讨论如何使用 Hibernate 等 ORM 解决方案将类层次结构持久化到 SQL 数据库表或表中,以及如何实现多态行为。幸运的是,这个问题在社区中现在已经得到了很好的理解,并且大多数解决方案都支持大约相同的功能。
对象/关系不匹配问题的下一个方面是对象身份的问题。
1.2.3 身份问题
你可能已经注意到,示例中将USERNAME定义为USERS表的主键。这是一个好的选择吗?如何在 Java 中处理相同对象?
虽然身份问题一开始可能不明显,但你在不断增长和扩展的电子商务系统中经常会遇到它,例如当你需要检查两个实例是否相同的时候。有三种方法可以解决这个问题:Java 世界中的两种和 SQL 数据库中的一种。正如预期的那样,它们只有在一些帮助的情况下才能一起工作。
Java 定义了两种不同的相同性概念:
-
实例身份(大致相当于内存位置,通过
a==b进行检查) -
实例相等性,由
equals()方法的实现确定(也称为值相等性)
另一方面,数据库行身份的表达是通过比较主键值来完成的。正如您将在第 9.1.2 节中看到的,equals()和==并不总是等同于主键值的比较。在 Java 中,几个非相同实例同时表示数据库的同一行是很常见的,例如在并发运行的应用程序线程中。此外,在实现持久化类的equals()方法时,以及理解何时可能需要这样做时,存在一些微妙的问题。
让我们用一个例子来讨论与数据库身份相关的问题。在USERS表的表定义中,USERNAME是主键。不幸的是,这个决定使得更改用户名变得困难;您不仅需要更新USERS表中的行,还需要更新(许多)BILLINGDETAILS表中的外键值。为了解决这个问题,本书后面我们将建议您在无法找到合适的自然键时使用代理键。我们还将讨论什么因素使主键成为一个好的选择。代理键列是一个对应用程序用户没有意义的键列——换句话说,一个不会呈现给应用程序用户的键。它的唯一目的是在应用程序内部标识数据。
例如,您可以将表格定义修改为如下所示:
CREATE TABLE USERS (
ID BIGINT NOT NULL PRIMARY KEY,
USERNAME VARCHAR(15) NOT NULL UNIQUE,
. . .
);
CREATE TABLE BILLINGDETAILS (
ID BIGINT NOT NULL PRIMARY KEY,
ACCOUNT VARCHAR(15) NOT NULL,
BANKNAME VARCHAR(255) NOT NULL,
USER_ID BIGINT NOT NULL,
FOREIGN KEY (USER_ID) REFERENCES USERS(ID)
);
ID列包含系统生成的值。这些列纯粹是为了数据模型的好处而引入的,因此它们如何在 Java 领域模型中(如果有的话)表示?我们将在第 5.2 节中讨论这个问题,并使用 ORM 找到解决方案。
在持久性的背景下,身份与系统如何处理缓存和事务密切相关。不同的持久化解决方案选择了不同的策略,这已经成为一个容易混淆的领域。我们将在第 9.1 节中涵盖所有这些有趣的话题,并探讨它们之间的关系。
到目前为止,我们设计的电子商务应用的骨架已经暴露了映射粒度、子类型和身份不匹配的问题。我们需要进一步讨论重要的概念关联:实体之间的关系是如何映射和处理的。数据库中的外键约束是否就是您所需要的全部?
1.2.4 关联问题
在领域模型中,关联表示实体之间的关系。User、Address和BillingDetails类都是关联的;但与Address不同,BillingDetails独立存在。BillingDetails实例存储在其自己的表中。关联映射和实体关联的管理是任何对象持久化解决方案中的核心概念。
面向对象的语言使用对象引用来表示关联,但在关系型世界中,一个外键约束列代表与键值副本的关联。约束是一个保证关联完整性的规则。这两种机制之间存在重大差异。
对象引用本质上是方向性的;关联是从一个实例到另一个实例的。它们是指针。如果实例之间的关联应该双向可导航,你必须定义关联两次,一次在每个关联的类中。图 1.4 中的 UML 类图使用单对多关联展示了这个模型。

图 1.4 User和BillingDetails之间的单对多关联
你已经在领域模型类中看到了这一点:
Path: Ch01/e-commerce/src/com/manning/javapersistence/ch01/User.java
public class User {
private Set<BillingDetails> billingDetails = new HashSet<>();
}
Path: Ch01/e-commerce/src/com/manning/javapersistence/ch01
➥ /BillingDetails.java
public class BillingDetails {
private User user;
}
在特定方向上的导航对于关系型数据模型没有意义,因为你可以使用连接和投影运算符创建数据关联。挑战是将一个完全开放的数据模型映射到应用程序依赖的导航模型——这个特定应用程序所需的关联的约束视图。
Java 关联可以有多对多的基数。图 1.5 中的 UML 类图展示了这个模型。

图 1.5 User和BillingDetails之间的多对多关联
类可能看起来像这样:
Path: Ch01/e-commerce/src/com/manning/javapersistence/ch01/User.java
public class User {
private Set<BillingDetails> billingDetails = new HashSet<>();
}
Path: Ch01/e-commerce/src/com/manning/javapersistence/ch01
➥ /BillingDetails.java
public class BillingDetails {
private Set<User> users = new HashSet<>();
}
然而,在BILLINGDETAILS表上的外键声明是一个多对一关联:每个银行账户都与特定的用户相关联,但每个用户可能有多个关联的银行账户。
如果你希望在 SQL 数据库中表示一个多对多关联,你必须引入一个新的表,通常称为链接表。在大多数情况下,这个表不会出现在领域模型中。对于这个例子,如果你认为用户和账单信息之间的关系是多对多,你会定义链接表如下:
CREATE TABLE USER_BILLINGDETAILS (
USER_ID BIGINT,
BILLINGDETAILS_ID BIGINT,
PRIMARY KEY (USER_ID, BILLINGDETAILS_ID),
FOREIGN KEY (USER_ID) REFERENCES USERS(ID),
FOREIGN KEY (BILLINGDETAILS_ID) REFERENCES BILLINGDETAILS(ID)
);
你不再需要在BILLINGDETAILS表上使用USER_ID外键列和约束;这个额外的表现在管理两个实体之间的链接。我们将在第八章详细讨论关联和集合映射。
到目前为止,我们考虑的问题主要是结构性的:你可以通过考虑系统的纯粹静态视图来看到它们。也许面向对象持久性中最困难的问题是动态的问题:如何在运行时访问数据。
1.2.5 数据导航问题
在 Java 代码中访问数据和在关系数据库中访问数据之间存在根本性的区别。在 Java 中,当你访问一个用户的账单信息时,你调用someUser.getBillingDetails().iterator().next()或类似的东西。或者,从 Java 8 开始,你可能调用someUser.getBillingDetails().stream() .filter(someCondition).map(someMapping).forEach(billingDetails-> {doSomething (billingDetails)})。这是访问面向对象数据最自然的方式,通常描述为遍历对象网络。你从一个实例导航到另一个实例,甚至迭代集合,遵循类之间的准备好的指针。不幸的是,这不是从 SQL 数据库检索数据的有效方式。
你能做的最重要的事情之一是提高数据访问代码的性能,那就是最小化对数据库的请求数量。做到这一点最明显的方法是最小化 SQL 查询的数量。(当然,其他更复杂的方法——例如广泛的缓存——可以作为第二步来实施。)
因此,使用 SQL 高效访问关系数据通常需要在感兴趣的表之间进行连接。在检索数据时包含在连接中的表的数量决定了你可以在内存中导航的对象网络的深度。例如,如果你需要检索一个User并且不感兴趣用户的账单信息,你可以编写这个简单的查询:
SELECT * FROM USERS WHERE ID = 123
另一方面,如果你需要检索一个User然后随后访问每个相关的BillingDetails实例(比如说,列出用户的银行账户),你需要编写一个不同的查询:
SELECT * FROM USERS, BILLINGDETAILS
WHERE USERS.ID = 123 AND
BILLINGDETAILS.ID = USERS.ID
如你所见,为了有效地使用连接,你需要在开始导航对象网络之前就知道你打算访问对象网络的哪一部分!不过要小心:如果你检索了太多的数据(可能比你需要的还多),你正在浪费应用层中的内存。你也可能因为巨大的笛卡尔积结果集而压倒 SQL 数据库。想象一下,不仅在一个查询中检索用户和银行账户,还要检索每个银行账户支付的所有订单,每个订单中的产品等等。
任何对象持久化解决方案都允许你在 Java 代码中首次访问关联时才获取关联实例的数据。这被称为延迟加载:仅在需要时检索数据。这种分块的数据访问方式在 SQL 数据库的上下文中基本是低效的,因为它需要为访问的对象网络中的每个节点或节点集合执行一个语句。这就是可怕的n+1 次选择问题。在我们的例子中,你需要一个选择来检索一个User,然后为每个相关的n 个BillingDetails实例执行n 个*选择。
在 Java 代码中访问数据的方式与关系数据库中的方式不匹配,这可能是 Java 信息系统中最常见的性能问题来源。避免笛卡尔积和n+1 选择问题仍然是许多 Java 程序员面临的问题。Hibernate 提供了高效且透明地从数据库到访问它们的应用程序中检索对象网络的高级功能。我们将在第十二章中讨论这些功能。
现在我们有一长串对象/关系不匹配问题:粒度问题、继承问题、标识问题、关联问题以及数据导航问题。找到解决方案可能会耗费大量时间与精力,正如你可能从经验中得知的那样。本书的大部分内容将用于提供对这些问题的详细答案,并展示 ORM 作为一种可行的解决方案。让我们从 ORM、Java 持久化标准(JPA)以及 Hibernate 和 Spring Data 项目概述开始。
1.3 ORM、JPA、Hibernate 和 Spring Data
简而言之,对象/关系映射(ORM)是将 Java 应用程序中的对象自动(且透明地)持久化到关系数据库管理系统(RDBMS)的表中,使用描述应用程序类与 SQL 数据库模式之间映射的元数据。本质上,ORM 通过(可逆地)将数据从一种表示形式转换为另一种表示形式来工作。使用 ORM 的程序将提供有关如何将对象从内存映射到数据库的元信息,而有效的转换将由 ORM 完成。
有些人可能认为 ORM 的一个优点是它保护开发者免受混乱的 SQL 的困扰。这种观点认为,面向对象的开发者不应该期望深入 SQL 或关系数据库。相反,Java 开发者必须对关系建模和 SQL 有足够的熟悉程度和欣赏能力,才能与 Hibernate 和 Spring Data 一起工作。ORM 是那些已经艰难地完成过这项工作的开发者所使用的一种高级技术。
JPA(Jakarta Persistence API,之前称为 Java Persistence API)是一个定义了管理对象持久化和对象/关系映射的 API 的规范。Hibernate 是这个规范最流行的实现。因此,JPA 将指定持久化对象必须执行的操作,而 Hibernate 将决定如何执行。Spring Data Commons 作为 Spring Data 家族的一部分,提供了支持所有 Spring Data 模块的核心 Spring 框架概念。Spring Data JPA 是 Spring Data 家族中的另一个项目,它是 JPA 实现(如 Hibernate)之上的一个额外层。Spring Data JPA 不仅能够使用 JPA 的所有功能,还增加了自己的功能,例如从方法名称生成数据库查询。本书将深入探讨许多细节,但如果你现在想获得一个整体的观点,可以快速跳转到图 4.1。
为了有效地使用 Hibernate,你必须能够查看和解释它发出的 SQL 语句,并理解其性能影响。为了利用 Spring Data 的好处,你必须能够预测样板代码和生成的查询是如何创建的。
JPA 规范定义了以下内容:
-
一种指定映射元数据的方法——持久化类及其属性如何与数据库模式相关联。JPA 在领域模型类中大量依赖于 Java 注解,但你也可以在 XML 文件中编写映射。
-
用于在持久化类实例上执行基本 CRUD 操作的 API,最著名的是
javax.persistence.EntityManager用于存储和加载数据。 -
一种用于指定引用类和类属性的查询的语言和 API。这种语言是 Jakarta Persistence Query Language(JPQL),其外观类似于 SQL。标准化的 API 允许通过程序创建条件查询,而不需要字符串操作。
-
持久化引擎如何与事务实例交互以执行脏检查、关联抓取和其他优化功能。JPA 规范涵盖了某些基本的缓存策略。
Hibernate 实现了 JPA 并支持所有标准化的映射、查询和编程接口。让我们看看 Hibernate 的一些好处:
-
生产力——Hibernate 消除了大量重复性工作(比你预期的要多),让你能够专注于业务问题。无论你更喜欢哪种应用程序开发策略——自顶向下(从领域模型开始)还是自底向上(从现有的数据库模式开始),与适当的工具一起使用 Hibernate 将显著减少开发时间。
-
可维护性——使用 Hibernate 的自动 ORM 减少了代码行数,使系统更易于理解且更容易重构。Hibernate 在领域模型和 SQL 模式之间提供了一个缓冲区,隔离每个模型免受对方微小变化的影响。
-
性能—尽管手动编码的持久性可能在某种程度上比汇编代码比 Java 代码更快,但像 Hibernate 这样的自动化解决方案允许始终使用许多优化。一个例子是应用层中高效且易于调整的缓存。这意味着开发者可以将更多精力用于手动优化剩余的少数真实瓶颈,而不是过早地优化一切。
-
供应商独立性—Hibernate 可以帮助减轻与供应商锁定相关的某些风险。即使您计划永远不会更改您的 DBMS 产品,支持多个不同 DBMS 的 ORM 工具也能提供一定程度的可移植性。此外,DBMS 独立性有助于工程师在开发场景中使用轻量级本地数据库,但在测试和生产时部署到不同的系统上。
Spring Data 使持久层的实现更加高效。Spring Data JPA 是该家族中的一个项目,位于 JPA 层之上。Spring Data JDBC 是该家族中的另一个项目,位于 JDBC 之上。让我们看看 Spring Data 的一些好处:
-
共享基础设施—Spring Data Commons 是 Spring Data 项目的一部分,为持久化 Java 类和提供技术中立仓库接口提供了元数据模型。它将其能力提供给其他 Spring Data 项目。
-
移除 DAO 实现—JPA 实现使用 数据访问对象(DAO)模式。这种模式从抽象接口到数据库的概念开始,将应用程序调用映射到持久化层,同时隐藏数据库的细节。Spring Data JPA 使得完全移除 DAO 实现成为可能,因此代码会更短。
-
自动类创建—使用 Spring Data JPA,一个 DAO 接口需要扩展 JPA 特定的
Repository接口—JpaRepository。Spring Data JPA 将自动为该接口创建实现—程序员无需关心这一点。 -
方法的默认实现—Spring Data JPA 将为它的仓库接口中定义的每个方法生成默认实现。基本的 CRUD 操作不再需要实现。这减少了样板代码,加快了开发速度,并消除了引入错误的可能性。
-
生成的查询—您可以在您的仓库接口上根据命名模式定义一个方法。您无需手动编写查询;Spring Data JPA 将解析方法名称并为其创建查询。
-
需要时接近数据库—Spring Data JDBC 可以直接与数据库通信并避免 Spring Data JPA 的“魔法”。它允许您通过 JDBC 与数据库交互,但通过使用 Spring 框架功能来移除样板代码。
本章重点介绍了理解对象/关系持久化和对象/关系范式不匹配产生的问题。第二章将探讨 Java 应用程序的一些持久化替代方案:JPA、Hibernate Native 和 Spring Data JPA。
摘要
-
在 对象持久化 中,单个对象可以超出其应用程序过程,保存到数据存储中,并在以后重新创建。当数据存储是一个基于 SQL 的关系型数据库管理系统时,对象/关系不匹配就会发挥作用。例如,一个对象网络不能保存到数据库表中;它必须被拆分并持久化到可移植的 SQL 数据类型列中。解决这个问题的好方法是对象/关系映射(ORM)。
-
ORM 并非所有持久化任务的万能药;它的任务是减轻开发者大约 95% 的对象持久化工作,例如编写包含多个表连接的复杂 SQL 语句,以及从 JDBC 结果集中复制值到对象或对象图中。
-
一个功能齐全的 ORM 中间件解决方案可能提供数据库可移植性、某些优化技术如缓存以及其他不易在有限时间内使用 SQL 和 JDBC 手动编写的可行功能。在 Java 世界中,ORM 解决方案意味着 JPA 规范和 JPA 实现——Hibernate 目前是最受欢迎的。
-
Spring Data 可能建立在 JPA 实现之上,并且进一步简化了数据持久化过程。它是一个遵循 Spring 框架原则的伞形项目,提供了一种更加简单的方法,包括移除 DAO 模式、自动代码生成和自动查询生成。
2 开始一个项目
本章节涵盖
-
介绍 Hibernate 和 Spring Data 项目
-
使用 Jakarta Persistence API、Hibernate 和 Spring Data 开发“Hello World”
-
检查配置和集成选项
在本章中,我们将从 Jakarta Persistence API(JPA)、Hibernate 和 Spring Data 开始,逐步进行示例。我们将查看持久化 API,并了解使用标准化的 JPA、本地的 Hibernate 或 Spring Data 的好处。
我们将从 JPA、Hibernate 和 Spring Data 的简要浏览开始,查看一个简单的“Hello World”应用程序。JPA(Jakarta Persistence API,以前称为 Java Persistence API)是一个定义管理对象和对象/关系映射的 API 的规范——它指定了持久化对象必须执行的操作。Hibernate 是这个规范最流行的实现,它将使持久化成为可能。Spring Data 使持久化层的实现更加高效;它是一个遵循 Spring 框架原则的伞形项目,并提供了一种更简单的方法。
2.1 介绍 Hibernate
对象/关系映射(ORM)是一种编程技术,用于在面向对象系统的不兼容世界和关系数据库之间建立联系。Hibernate 是一个雄心勃勃的项目,旨在为 Java 中持久化数据的问题提供一个完整的解决方案。如今,Hibernate 不仅是一个 ORM 服务,而且是一个数据管理工具集合,其范围远远超出了 ORM。
Hibernate 项目套件包括以下内容:
-
Hibernate ORM—Hibernate ORM 由核心、用于与 SQL 数据库持久化的基础服务以及本地专有 API 组成。Hibernate ORM 是套件中其他几个项目的基石,也是 Hibernate 最古老的项目。您可以在没有任何框架或特定运行时环境的情况下独立使用 Hibernate ORM,适用于所有 JDK。只要数据源可访问,您就可以为 Hibernate 配置它,并且它将正常工作。
-
Hibernate EntityManager—这是 Hibernate 对标准 Jakarta Persistence API 的实现。它是一个可选模块,可以堆叠在 Hibernate ORM 之上。Hibernate 的本地特性在各个方面都是 JPA 持久化特性的超集。
-
Hibernate Validator—Hibernate 提供了 Bean Validation (JSR 303)规范的参考实现。与其他 Hibernate 项目独立,它为领域模型(或任何其他)类提供声明式验证。
-
Hibernate Envers—Envers 致力于审计日志记录和保留 SQL 数据库中的多个数据版本。这有助于向应用程序添加数据历史和审计跟踪,类似于您可能已经熟悉的任何版本控制系统,例如 Subversion 或 Git。
-
Hibernate Search—Hibernate Search 在 Apache Lucene 数据库中维护领域模型数据的最新索引。它允许您使用强大且自然集成的 API 查询此数据库。许多项目在 Hibernate ORM 之外使用 Hibernate Search,增加了全文搜索功能。如果您在应用程序的用户界面中有一个免费文本搜索表单,并且希望用户满意,请使用 Hibernate Search。Hibernate Search 在本书中未涵盖,但您可以通过 Emmanuel Bernard 的 Hibernate Search in Action(Bernard, 2008)获得良好的起点。
-
Hibernate OGM—这个 Hibernate 项目是一个对象/网格映射器。它为 NoSQL 解决方案提供 JPA 支持,重用 Hibernate 核心引擎,但将映射的实体持久化到键/值、文档或图导向的数据存储中。
-
Hibernate Reactive—Hibernate Reactive 是 Hibernate ORM 的反应式 API,以非阻塞方式与数据库交互。它支持非阻塞数据库驱动程序。Hibernate Reactive 在本书中未涵盖。
Hibernate 源代码可以从 github.com/hibernate 免费下载。
2.2 介绍 Spring Data
Spring Data 是 Spring 框架的一系列项目,其目的是简化对关系型数据库和无 SQL 数据库的访问:
-
Spring Data Commons—Spring Data Commons 是 Spring Data 项目的一部分,提供了持久化 Java 类的元数据模型和技术中立的数据仓库接口。
-
Spring Data JPA—Spring Data JPA 处理基于 JPA 的仓库实现。它通过减少样板代码并为仓库接口创建实现来提供对基于 JPA 的数据访问层的改进支持。
-
Spring Data JDBC—Spring Data JDBC 处理基于 JDBC 的仓库实现。它提供了对基于 JDBC 的数据访问层的改进支持。它不提供一系列 JPA 功能,如缓存或懒加载,从而实现了一个更简单且功能有限的 ORM。
-
Spring Data REST—Spring Data REST 处理将 Spring Data 仓库作为 RESTful 资源导出。
-
Spring Data MongoDB—Spring Data MongoDB 处理对 MongoDB 文档数据库的访问。它依赖于仓库式数据访问层和 POJO 编程模型。
-
Spring Data Redis—Spring Data Redis 处理对 Redis 键/值数据库的访问。它依赖于释放开发人员管理基础设施,并提供对数据存储的高层和低层抽象。Spring Data Redis 在本书中未涵盖。
Spring Data 源代码(与其他 Spring 项目一起)可以从 github.com/spring-projects 免费下载。
让我们开始我们的第一个 JPA、Hibernate 和 Spring Data 项目。
2.3 使用 JPA 的“Hello World”
在本节中,我们将编写我们的第一个 JPA 应用程序,该程序将在数据库中存储一条消息,然后检索它。我们运行代码的机器上安装了 MySQL Release 8.0。要安装 MySQL Release 8.0,请遵循官方文档中的说明:dev.mysql.com/doc/refman/8.0/en/installing.xhtml。
为了执行源代码中的示例,您首先需要运行 Ch02.sql 脚本,如图 2.1 所示。打开 MySQL Workbench,转到文件 > 打开 SQL 脚本,选择 SQL 文件并运行它。示例使用默认凭证的 MySQL 服务器:用户名 root,无密码。

图 2.1 通过运行 Ch02.sql 脚本创建 MySQL 数据库
在“Hello World”应用程序中,我们希望将消息存储在数据库中,并从数据库中加载它们。Hibernate 应用程序定义了映射到数据库表的持久化类。我们根据对业务领域的分析来定义这些类;因此,它们是领域模型。本例将包括一个类及其映射。我们将编写可执行的测试示例,其中包含验证每个操作正确结果的断言。我们已经测试了本书中的所有示例,因此我们可以确信它们可以正常工作。
让我们先安装和配置 JPA、Hibernate 以及其他所需依赖项。我们将使用 Apache Maven 作为本书中所有示例的项目构建工具。有关基本 Maven 概念和如何设置 Maven 的详细信息,请参阅附录 A。
我们将在以下列表中声明依赖项。
列表 2.1 Maven 对 Hibernate、JUnit Jupiter 和 MySQL 的依赖
Path: Ch02/helloworld/pom.xml
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
<version>5.6.9.Final</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.29</version>
</dependency>
hibernate-entitymanager 模块包括对其他我们将需要的模块的传递依赖,例如 hibernate-core 和 JPA 接口存根。我们还需要 junit-jupiter-engine 依赖项,以使用 JUnit 5 运行测试,以及 mysql-connector-java 依赖项,这是 MySQL 的官方 JDBC 驱动程序。
在 JPA 中,我们的起点是 持久化单元。持久化单元是将我们的领域模型类映射与数据库连接以及一些其他配置设置配对。每个应用程序至少有一个持久化单元;如果它们与多个(逻辑或物理)数据库通信,则某些应用程序可能有多个。因此,我们的第一步是在应用程序配置中设置持久化单元。
2.3.1 配置持久化单元
持久化单元的标准配置文件位于类路径上的 META-INF/persistence.xml。为“Hello World”应用程序创建以下配置文件。
列表 2.2 persistence.xml 配置文件
Path: Ch02/helloworld/src/main/resources/META-INF/persistence.xml
<persistence
xsi:schemaLocation="http://java.sun.com/xml/ns/persistence
➥ http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"
version="2.0">
<persistence-unit name="ch02"> Ⓐ
<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider> Ⓐ
<properties>
<property name="javax.persistence.jdbc.driver" Ⓑ
value="com.mysql.cj.jdbc.Driver"/> Ⓒ
<property name="javax.persistence.jdbc.url" Ⓓ
value="jdbc:mysql://localhost:3306/CH02?serverTimezone=UTC "/> Ⓓ
<property name="javax.persistence.jdbc.user" value="root"/> Ⓔ
<property name="javax.persistence.jdbc.password" value=""/> Ⓕ
<property name="hibernate.dialect" Ⓖ
value="org.hibernate.dialect.MySQL8Dialect"/> Ⓖ
<property name="hibernate.show_sql" value="true"/> Ⓗ
<property name="hibernate.format_sql" value="true"/> Ⓘ
<property name="hibernate.hbm2ddl.auto" value="create"/> Ⓙ
</properties>
</persistence-unit>
</persistence>
Ⓐ persistence.xml 文件配置了至少一个持久化单元;每个单元必须有一个唯一名称。
Ⓑ 由于 JPA 只是一个规范,我们需要指出 API 的供应商特定 PersistenceProvider 实现。我们定义的持久化将由 Hibernate 提供商支持。
Ⓒ 指定 JDBC 属性——驱动程序。
Ⓓ 数据库的 URL。
Ⓔ 用户名。
Ⓕ 没有访问密码。我们运行程序的机器上安装了 MySQL 8,访问凭证来自 persistence.xml。你应该修改凭证以匹配你机器上的凭证。
Ⓖ Hibernate 方言是 MySQL8,因为我们交互的数据库是 MySQL Release 8.0。
Ⓗ 在执行过程中,显示 SQL 代码。
Ⓘ Hibernate 会很好地格式化 SQL 并在 SQL 字符串中生成注释,这样我们就能知道 Hibernate 为什么执行这个 SQL 语句。
Ⓙ 每次程序执行时,数据库都将从头创建。这对于自动化测试来说很理想,当我们希望每次测试运行都使用一个干净的数据库时。
让我们看看一个简单的持久化类的样子,映射是如何创建的,以及我们可以用 JPA 中持久化类的实例做的一些事情。
2.3.2 编写持久化类
本例的目标是将消息存储在数据库中,并检索它们以供显示。应用程序有一个简单的持久化类,Message。
列表 2.3 Message 类
Path: Ch02/helloworld/src/main/java/com/manning/javapersistence/ch02
➥ /Message.java
@Entity Ⓐ
public class Message {
@Id Ⓑ
@GeneratedValue(strategy = GenerationType.*IDENTITY*) Ⓒ
private Long id;
private String text; Ⓓ
public String getText() { Ⓓ
return text; Ⓓ
} Ⓓ
public void setText(String text) { Ⓓ
this.text = text; Ⓓ
} Ⓓ
}
Ⓐ 每个持久化实体类至少必须有一个 @Entity 注解。Hibernate 将此类映射到名为 MESSAGE 的表。
Ⓑ 每个持久化实体类都必须有一个用 @Id 注解的标识符属性。Hibernate 将此属性映射到名为 id 的列。
Ⓒ 必须有人生成标识符值;这个注解允许自动生成 ID。
Ⓓ 我们通常使用私有字段和公共 getter/setter 方法对来实现持久化类的常规属性。Hibernate 将此属性映射到名为 text 的列。
持久化类的标识符属性允许应用程序访问持久化实例的数据库身份——主键值。如果两个 Message 实例具有相同的标识符值,它们代表数据库中的同一行。本例使用 Long 作为标识符属性的类型,但这不是必需的。Hibernate 允许你使用几乎任何东西作为标识符类型,正如你将在本书后面的内容中看到的。
你可能已经注意到 Message 类的 text 属性具有 JavaBeans 风格的属性访问器方法。该类还有一个(默认的)无参数构造函数。我们将在示例中展示的持久化类通常看起来像这样。请注意,我们不需要实现任何特定的接口或扩展任何特殊超类。
Message 类的实例可以由 Hibernate 管理(使其持久化),但它们不必这样做。因为 Message 对象没有实现任何持久化特定的类或接口,我们可以像使用任何其他 Java 类一样使用它:
Message msg = new Message();
msg.setText("Hello!");
System.out.println(msg.getText());
这可能看起来我们在这里试图卖弄聪明;实际上,我们正在展示 Hibernate 与其他一些持久化解决方案区分开来的一个重要特性。我们可以在任何执行上下文中使用持久化类——不需要特殊容器。
我们不必使用注解来映射持久化类。稍后我们将展示其他映射选项,例如 JPA orm.xml 映射文件和本地的 hbm.xml 映射文件,并探讨它们何时比源注解(目前最常用的方法)更优。
Message类现在已准备好。我们可以在数据库中存储实例并编写查询将它们再次加载到应用程序内存中。
2.3.3 存储和加载消息
你真正来这里想看的是与 Hibernate 一起使用的 JPA,所以让我们将一个新的Message保存到数据库中。
列表 2.4 HelloWorldJPATest类
Path: Ch02/helloworld/src/test/java/com/manning/javapersistence/ch02
➥ /HelloWorldJPATest.java
public class HelloWorldJPATest {
@Test
public void storeLoadMessage() {
EntityManagerFactory emf = Ⓐ
Persistence.createEntityManagerFactory("ch02"); Ⓐ
try {
EntityManager em = emf.createEntityManager(); Ⓑ
em.getTransaction().begin(); Ⓒ
Message message = new Message(); Ⓓ
message.setText("Hello World!"); Ⓓ
em.persist(message); Ⓔ
em.getTransaction().commit(); Ⓕ
//INSERT into MESSAGE (ID, TEXT) values (1, 'Hello World!')
em.getTransaction().begin(); Ⓖ
List<Message> messages = Ⓗ
em.createQuery("select m from Message m", Message.class) Ⓗ
.getResultList(); Ⓗ
//SELECT * from MESSAGE Ⓗ
messages.get(messages.size() - 1). Ⓘ
setText("Hello World from JPA!"); Ⓘ
em.getTransaction().commit(); Ⓙ
//UPDATE MESSAGE set TEXT = 'Hello World from JPA!'
➥ where ID = 1
assertAll( Ⓚ
() -> assertEquals(1, messages.size()), Ⓚ
() -> assertEquals("Hello World from JPA!", Ⓛ
messages.get(0).getText()) Ⓛ
);
em.close(); Ⓜ
} finally {
emf.close(); Ⓝ
}
}
}
Ⓐ 首先,我们需要一个EntityManagerFactory来与数据库通信。这个 API 代表持久化单元,大多数应用程序为一个配置的持久化单元有一个EntityManagerFactory。一旦启动,应用程序应该创建EntityManagerFactory;工厂是线程安全的,应用程序中所有访问数据库的代码都应该共享它。
Ⓑ 通过创建EntityManager来与数据库开始一个新的会话。这是所有持久化操作上下文。
Ⓒ 获取标准事务 API 的访问权限,并在当前执行线程上开始一个事务。
Ⓓ 创建一个映射的领域模型类Message的新实例,并设置其text属性。
Ⓔ 将瞬态实例注册到持久化上下文中;我们使其持久化。Hibernate 现在知道我们希望存储这些数据,但它并不一定立即调用数据库。
Ⓕ 提交事务。Hibernate 会自动检查持久化上下文并执行必要的 SQL INSERT语句。为了帮助您理解 Hibernate 的工作原理,我们在源代码注释中展示了自动生成和执行的 SQL 语句,当它们发生时。Hibernate 在MESSAGE表中插入一行,为ID主键列自动生成一个值,以及TEXT值。
Ⓖ 每次与数据库的交互都应该在事务边界内进行,即使我们只是在读取数据,因此我们开始一个新的事务。从现在开始出现的任何潜在失败都不会影响之前提交的事务。
Ⓗ 执行查询以从数据库中检索所有Message实例。
Ⓘ 我们可以更改属性的值。Hibernate 会自动检测这一点,因为加载的Message仍然附加在它被加载的持久化上下文中。
Ⓙ 在提交时,Hibernate 会检查持久化上下文是否存在脏状态,并自动执行 SQL UPDATE语句以同步内存中的对象与数据库状态。
Ⓚ 检查从数据库检索到的消息列表的大小。
Ⓛ 检查我们持久化的消息是否在数据库中。我们使用 JUnit 5 的assertAll方法,该方法始终检查传递给它的所有断言,即使其中一些失败。我们验证的两个断言在概念上是相关的。
Ⓜ 我们创建了一个EntityManager,因此我们必须关闭它。
Ⓝ 我们创建了一个EntityManagerFactory,因此我们必须关闭它。
在这个示例中您看到的查询语言不是 SQL,它是 Jakarta Persistence Query Language(JPQL)。尽管在这个简单的例子中在语法上没有区别,但查询字符串中的Message并不指向数据库表名,而是指向持久化类名。因此,查询中的Message类名是区分大小写的。如果我们将类映射到不同的表,查询仍然会工作。
此外,请注意 Hibernate 如何检测消息文本属性的修改,并自动更新数据库。这是 JPA 的自动脏检查功能在起作用。它节省了我们显式请求持久化管理器在事务中修改实例状态时更新数据库的努力。
图 2.2 显示了在数据库侧检查我们插入和更新记录存在性的结果。如您所回忆的,我们通过从章节源代码中运行 Ch02.sql 脚本来创建了一个名为 CH02 的数据库。

图 2.2 检查数据库侧插入和更新记录存在性的结果
您刚刚完成了您的第一个 JPA 和 Hibernate 应用程序。现在让我们快速了解一下本地的 Hibernate 引导和配置 API。
2.4 本地 Hibernate 配置
尽管 JPA 中基本(和广泛)的配置是标准化的,但我们无法通过persistence.xml中的属性访问 Hibernate 的所有配置功能。请注意,大多数应用程序,即使是相当复杂的,也不需要这样的特殊配置选项,因此不需要访问本节中我们将展示的引导 API。如果您不确定,您可以跳过这一节,稍后再回来,当您需要扩展 Hibernate 类型适配器、添加自定义 SQL 函数等时。
当使用本地 Hibernate 时,我们将直接使用 Hibernate 依赖项和 API,而不是 JPA 依赖项和类。JPA 是一个规范,它可以通过相同的 API 使用不同的实现(Hibernate 是一个例子,但 EclipseLink 是另一个替代方案)。Hibernate 作为一个实现,提供了它自己的依赖项和类。虽然使用 JPA 提供了更多的灵活性,但您会在整本书中看到,直接访问 Hibernate 实现允许您使用 JPA 标准未涵盖的功能(我们将在相关的地方指出这一点)。
标准 JPA EntityManagerFactory的原生等价物是org.hibernate.SessionFactory。我们通常每个应用程序有一个,它涉及与数据库连接配置相同的类映射配对。
要配置原生 Hibernate,我们可以使用一个 hibernate.properties Java 属性文件或一个 hibernate.cfg.xml XML 文件。我们将选择第二种选项,配置将包含数据库和会话相关的选项。这个 XML 文件通常放置在 src/main/resource 或 src/test/resource 文件夹中。由于我们需要在测试中使用 Hibernate 配置信息,我们将选择第二个位置。
列表 2.5 hibernate.cfg.xml 配置文件
Path: Ch02/helloworld/src/test/resources/hibernate.cfg.xml
<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE hibernate-configuration PUBLIC
"-//Hibernate/Hibernate Configuration DTD//EN"
➥ "http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
<hibernate-configuration> Ⓐ
<session-factory> Ⓑ
<property name="hibernate.connection.driver_class"> Ⓒ
com.mysql.cj.jdbc.Driver Ⓒ
</property> Ⓒ
<property name="hibernate.connection.url"> Ⓓ
jdbc:mysql://localhost:3306/CH02?serverTimezone=UTC Ⓓ
</property> Ⓓ
<property name="hibernate.connection.username">root</property> Ⓔ
<property name="hibernate.connection.password"></property> Ⓕ
<property name="hibernate.connection.pool_size">50</property> Ⓖ
<property name="show_sql">true</property> Ⓗ
<property name="hibernate.hbm2ddl.auto">create</property> Ⓘ
</session-factory>
</hibernate-configuration>
Ⓐ 我们使用标签来表示我们正在配置 Hibernate。
Ⓑ 更确切地说,我们正在配置SessionFactory对象。SessionFactory是一个接口,我们需要一个SessionFactory来与一个数据库交互。
Ⓒ 指定 JDBC 属性——驱动程序。
Ⓓ 数据库的 URL。
Ⓔ 用户名。
Ⓕ 访问它不需要密码。我们运行的程序机器上安装了 MySQL 8,访问凭证来自 hibernate .cfg.xml。您应该修改凭证以匹配您机器上的凭证。
Ⓖ 将 Hibernate 数据库连接池中等待的连接数限制为 50。
Ⓗ 在执行过程中,会显示 SQL 代码。
Ⓘ 每次程序执行时,数据库将从零开始创建。这对于自动化测试来说很理想,当我们希望每次测试运行都使用一个干净的数据库时。
让我们使用原生 Hibernate 将一个Message保存到数据库中。
列表 2.6 HelloWorldHibernateTest类
Path: Ch02/helloworld/src/test/java/com/manning/javapersistence/ch02
➥ /HelloWorldHibernateTest.java
public class HelloWorldHibernateTest {
private static SessionFactory createSessionFactory() {
Configuration configuration = new Configuration(); Ⓐ
configuration.configure().addAnnotatedClass(Message.class); Ⓑ
ServiceRegistry serviceRegistry = new Ⓒ
StandardServiceRegistryBuilder(). Ⓒ
applySettings(configuration.getProperties()).build(); Ⓒ
return configuration.buildSessionFactory(serviceRegistry); Ⓓ
}
@Test
public void storeLoadMessage() {
try (SessionFactory sessionFactory = createSessionFactory(); Ⓔ
Session session = sessionFactory.openSession()) { Ⓕ
session.beginTransaction(); Ⓖ
Message message = new Message(); Ⓗ
message.setText("Hello World from Hibernate!"); Ⓗ
session.persist(message); Ⓘ
session.getTransaction().commit(); Ⓙ
// INSERT into MESSAGE (ID, TEXT)
// values (1, 'Hello World from Hibernate!')
session.beginTransaction(); Ⓚ
CriteriaQuery<Message> criteriaQuery = Ⓛ
session.getCriteriaBuilder().createQuery(Message.class); Ⓛ
criteriaQuery.from(Message.class); Ⓜ
List<Message> messages = Ⓝ
session.createQuery(criteriaQuery).getResultList(); Ⓝ
// SELECT * from MESSAGE
session.getTransaction().commit(); Ⓞ
assertAll( Ⓟ
() -> assertEquals(1, messages.size()), Ⓟ
() -> assertEquals("Hello World from Hibernate!", Ⓠ
messages.get(0).getText()) Ⓠ
);
}
}
}
Ⓐ 要创建一个SessionFactory,我们首先需要创建一个配置。
Ⓑ 我们需要调用它的configure方法,并将Message作为注解类添加到其中。configure方法的执行将加载默认的 hibernate.cfg.xml 文件的内容。
Ⓒ 建造者模式帮助我们创建不可变的服务注册表,并通过链式方法调用应用设置来配置它。ServiceRegistry托管并管理需要访问SessionFactory的服务。服务是提供不同类型功能可插拔实现的类。
Ⓓ 使用配置和之前创建的服务注册表构建一个SessionFactory。
Ⓔ 使用我们之前定义的createSessionFactory方法创建的SessionFactory作为参数传递给一个try资源,因为SessionFactory实现了AutoCloseable接口。
Ⓕ 类似地,我们通过创建一个Session来与数据库开始一个新的会话,该Session也实现了AutoCloseable接口。这是我们所有持久化操作的环境。
Ⓕ 获取标准事务 API 的访问权限,并在执行线程上开始一个事务。
Ⓗ 创建一个映射的域模型类Message的新实例,并设置其text属性。
Ⓘ 将瞬态实例注册到持久化上下文中;我们使其持久化。Hibernate 现在知道我们希望存储该数据,但它不一定立即调用数据库。原生的 Hibernate API 与标准 JPA 非常相似,并且大多数方法具有相同的名称。
Ⓙ 同步会话与数据库,并在事务提交时自动关闭当前会话。
Ⓚ 开始另一个事务。每次与数据库的交互都应在事务边界内进行,即使我们只是在读取数据。
Ⓛ 通过调用CriteriaBuilder的createQuery()方法创建CriteriaQuery实例。CriteriaBuilder用于构建查询、复合选择、表达式、谓词和排序。CriteriaQuery定义了顶层查询特有的功能。CriteriaBuilder和CriteriaQuery属于 Criteria API,它允许我们以编程方式构建查询。
Ⓜ 创建并添加一个与给定Message实体对应的查询根。
Ⓝ 调用查询对象的getResultList()方法以获取结果。创建并执行查询将是SELECT * FROM MESSAGE。
Ⓞ 提交事务。
Ⓟ 检查从数据库检索到的消息列表的大小。
Ⓠ 检查我们持久化的消息是否在数据库中。我们使用 JUnit 5 的assertAll方法,该方法始终检查传递给它的所有断言,即使其中一些失败。我们验证的两个断言在概念上是相关的。
图 2.3 显示了使用原生 Hibernate 在数据库侧检查我们插入的记录存在性的结果。

图 2.3 在数据库侧检查插入记录存在性的结果
本书中的大多数示例都不会使用SessionFactory或Session API。偶尔,当某个特定功能仅在 Hibernate 中可用时,我们会向您展示如何使用unwrap()方法来解包原生接口。
2.5 在 JPA 和 Hibernate 之间切换
假设您正在使用 JPA 并需要访问 Hibernate API。或者,相反,您正在使用原生 Hibernate,您需要从 Hibernate 配置中创建一个EntityManagerFactory。要从EntityManagerFactory获取SessionFactory,您必须从第二个中解包第一个。
列表 2.7 从EntityManagerFactory获取SessionFactory
Path: Ch02/helloworld/src/test/java/com/manning/javapersistence/ch02
➥ /HelloWorldJPAToHibernateTest.java
private static SessionFactory getSessionFactory
(EntityManagerFactory entityManagerFactory) {
return entityManagerFactory.unwrap(SessionFactory.class);
}
从 JPA 版本 2.0 开始,您可以访问底层实现的 API。EntityManagerFactory(以及EntityManager)声明了一个unwrap方法,该方法将返回属于 JPA 实现类别的对象。当使用 Hibernate 实现时,您可以获取相应的SessionFactory或Session对象,并开始像示例 2.6 中所示那样使用它们。当某个特定功能仅在 Hibernate 中可用时,您可以使用unwrap方法切换到它。
你可能对反向操作感兴趣:从一个初始 Hibernate 配置中创建EntityManagerFactory。
列表 2.8 从 Hibernate 配置中获取EntityManagerFactory
Path: Ch02/helloworld/src/test/java/com/manning/javapersistence/ch02
➥ /HelloWorldHibernateToJPATest.java
private static EntityManagerFactory createEntityManagerFactory() {
Configuration configuration = new Configuration(); Ⓐ
configuration.configure().addAnnotatedClass(Message.class); Ⓑ
Map<String, String> properties = new HashMap<>(); Ⓒ
Enumeration<?> propertyNames = Ⓓ
configuration.getProperties().propertyNames(); Ⓓ
while (propertyNames.hasMoreElements()) { Ⓔ
String element = (String) propertyNames.nextElement(); Ⓔ
properties.put(element, Ⓔ
configuration.getProperties().getProperty(element)); Ⓔ
}
return Persistence.createEntityManagerFactory("ch02", properties); Ⓕ
}
Ⓐ 创建一个新的 Hibernate 配置。
Ⓑ 调用configure方法,该方法将默认 hibernate .cfg.xml 文件的内容添加到配置中,然后显式添加Message作为注解类。
Ⓒ 创建一个新的哈希表,用于填充现有的属性。
Ⓓ 获取 Hibernate 配置中的所有属性名称。
Ⓔ 将属性名称逐个添加到之前创建的映射中。
Ⓕ 返回一个新的EntityManagerFactory,向其提供 ch02.ex01 持久化单元名称和之前创建的属性映射。
2.6 使用 Spring Data JPA 的“Hello World”
现在让我们编写第一个 Spring Data JPA 应用程序,该程序将在数据库中存储一条消息,然后检索它。
我们首先将 Spring 依赖项添加到 Apache Maven 配置中。
列表 2.9 Spring 的 Maven 依赖项
Path: Ch02/helloworld/pom.xml
<dependency> Ⓐ
<groupId>org.springframework.data</groupId> Ⓐ
<artifactId>spring-data-jpa</artifactId> Ⓐ
<version>2.7.0</version> Ⓐ
</dependency> Ⓐ
<dependency> Ⓑ
<groupId>org.springframework</groupId> Ⓑ
<artifactId>spring-test</artifactId> Ⓑ
<version>5.3.20</version> Ⓑ
</dependency> Ⓑ
Ⓐ spring-data-jpa模块为 JPA 提供仓库支持,并包含对我们需要的其他模块的传递依赖,例如spring-core和spring-context。
Ⓑ 我们还需要spring-test依赖项来运行测试。
Spring Data JPA 的标准配置文件是一个 Java 类,它创建并设置 Spring Data 所需的 bean。配置可以使用 XML 文件或 Java 代码完成,我们选择了第二种选择。为“Hello World”应用程序创建以下配置文件。
列表 2.10 SpringDataConfiguration类
Path: Ch02/helloworld/src/test/java/com/manning/javapersistence/ch02
➥ /configuration/SpringDataConfiguration.java
@EnableJpaRepositories("com.manning.javapersistence.ch02.repositories") Ⓐ
public class SpringDataConfiguration {
@Bean
public DataSource dataSource() { Ⓑ
DriverManagerDataSource dataSource = new DriverManagerDataSource(); Ⓑ
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver"); Ⓒ
dataSource.setUrl( Ⓓ
"jdbc:mysql://localhost:3306/CH02?serverTimezone=UTC"); Ⓓ
dataSource.setUsername("root"); Ⓔ
dataSource.setPassword(""); Ⓕ
return dataSource; Ⓑ
}
@Bean
public JpaTransactionManager Ⓖ
transactionManager(EntityManagerFactory emf) { Ⓖ
return new JpaTransactionManager(emf); Ⓖ
}
@Bean
public JpaVendorAdapter jpaVendorAdapter() { Ⓗ
HibernateJpaVendorAdapter jpaVendorAdapter = new Ⓗ
HibernateJpaVendorAdapter(); Ⓗ
jpaVendorAdapter.setDatabase(Database.MYSQL); Ⓘ
jpaVendorAdapter.setShowSql(true); Ⓙ
return jpaVendorAdapter; Ⓗ
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(){ Ⓚ
LocalContainerEntityManagerFactoryBean Ⓚ
localContainerEntityManagerFactoryBean =
new LocalContainerEntityManagerFactoryBean(); Ⓛ
localContainerEntityManagerFactoryBean.setDataSource(dataSource());
Properties properties = new Properties(); Ⓜ
properties.put("hibernate.hbm2ddl.auto", "create"); Ⓜ
localContainerEntityManagerFactoryBean. Ⓜ
setJpaProperties(properties); Ⓜ
localContainerEntityManagerFactoryBean. Ⓝ
setJpaVendorAdapter(jpaVendorAdapter()); Ⓝ
localContainerEntityManagerFactoryBean. Ⓞ
setPackagesToScan("com.manning.javapersistence.ch02"); Ⓞ
return localContainerEntityManagerFactoryBean; Ⓚ
}
}
Ⓐ @EnableJpaRepositories注解使 Spring Data 仓库能够扫描作为参数接收的包。
Ⓑ 创建一个数据源 bean。
Ⓒ 指定 JDBC 属性——驱动程序。
Ⓓ 数据库的 URL。
Ⓜ 用户名。
Ⓕ 访问不需要密码。我们运行的程序机器上安装了 MySQL 8,访问凭证来自此配置。你应该修改凭证以匹配你机器上的凭证。
Ⓖ 基于实体管理器工厂创建一个事务管理器 bean。每次与数据库的交互都应在事务边界内进行,Spring Data 需要一个事务管理器 bean。
Ⓗ 创建 JPA 供应商适配器 bean,这是 JPA 与 Hibernate 交互所需的。
Ⓘ 配置此供应商适配器以访问 MySQL 数据库。
Ⓙ 在执行时显示 SQL 代码。
Ⓚ 创建一个LocalContainerEntityManagerFactoryBean。这是一个工厂 bean,它根据 JPA 标准容器启动合同生成EntityManagerFactory。
Ⓛ 设置数据源。
Ⓜ 设置每次程序执行时从头创建数据库。
Ⓝ 设置供应商适配器。
Ⓞ 设置扫描实体类的包。由于Message实体位于com.manning.javapersistence.ch02,我们将此包设置为扫描。
Spring Data JPA 通过减少样板代码并创建仓库接口的实现来支持基于 JPA 的数据访问层。我们只需要定义自己的仓库接口,以扩展 Spring Data 接口之一。
列表 2.11 MessageRepository 接口
Path: Ch02/helloworld/src/main/java/com/manning/javapersistence/ch02
➥ /repositories/MessageRepository.java
public interface MessageRepository extends CrudRepository<Message, Long> {
}
MessageRepository 接口扩展了 CrudRepository <Message, Long>。这意味着它是一个具有 Long 标识符的 Message 实体仓库。记住,Message 类有一个被注解为 @Id 的 id 字段,其类型为 Long。我们可以直接调用从 CrudRepository 继承的方法,如 save、findAll 或 findById,并且我们可以使用它们来执行对数据库的常规操作,而无需任何其他附加信息。Spring Data JPA 将创建一个实现 MessageRepository 接口的代理类,并在创建代理类时生成其方法(见图 2.4)。

图 2.4 Spring Data JPA 的 Proxy 类实现了 MessageRepository 接口。
让我们使用 Spring Data JPA 将一个 Message 对象保存到数据库中。
列表 2.12 HelloWorldSpringDataJPATest 类
Path: Ch02/helloworld/src/test/java/com/manning/javapersistence/ch02
➥ /HelloWorldSpringDataJPATest.java
@ExtendWith(SpringExtension.class) Ⓐ
@ContextConfiguration(classes = {SpringDataConfiguration.class}) Ⓑ
public class HelloWorldSpringDataJPATest {
@Autowired Ⓒ
private MessageRepository messageRepository; Ⓒ
@Test
public void storeLoadMessage() {
Message message = new Message(); Ⓓ
message.setText("Hello World from Spring Data JPA!"); Ⓓ
messageRepository.save(message); Ⓔ
List<Message> messages =
➥ (List<Message>)messageRepository.findAll(); Ⓕ
assertAll( Ⓖ
() -> assertEquals(1, messages.size()), Ⓖ
() -> assertEquals("Hello World from Spring Data JPA!", Ⓗ
messages.get(0).getText()) Ⓗ
);
}
}
Ⓐ 使用 SpringExtension 扩展测试。这个扩展用于将 Spring 测试上下文与 JUnit 5 Jupiter 测试集成。
Ⓑ 使用之前展示的 SpringDataConfiguration 类中定义的 beans 配置 Spring 测试上下文。
Ⓒ 通过自动装配,Spring 注入了一个 MessageRepository bean。这是可能的,因为 MessageRepository 所在的 com.manning.javapersistence.ch02.repositories 包被用作列表 2.8 中 @EnableJpaRepositories 注解的参数。如果我们调用 messageRepository.getClass(),我们会看到它返回类似 com.sun.proxy.$Proxy41 的内容——这是 Spring Data 生成的代理,如图 2.4 所解释的。
Ⓓ 创建一个映射的领域模型类 Message 的新实例,并设置其 text 属性。
Ⓔ 持久化 message 对象。save 方法是从 CrudRepository 接口继承的,并且当创建代理类时,其体将由 Spring Data JPA 生成。它将简单地保存一个 Message 实体到数据库中。
Ⓕ 从仓库中检索消息。findAll 方法是从 CrudRepository 接口继承的,并且当创建代理类时,其体将由 Spring Data JPA 生成。它将简单地返回属于 Message 类的所有实体。
Ⓖ 检查从数据库检索的消息列表的大小,以及我们持久化的消息是否在数据库中。
Ⓗ 使用 JUnit 5 的 assertAll 方法,该方法检查传递给它的所有断言,即使其中一些失败。我们验证的两个断言在概念上是相关的。
你会注意到,Spring Data JPA 测试比使用 JPA 或原生 Hibernate 的测试要短得多。这是因为已经去除了样板代码——不再有显式的对象创建或显式的事务控制。仓库对象被注入,并提供代理类生成的相关方法。现在,配置方面的负担更重,但这个操作应该在每个应用程序中只进行一次。
图 2.5 展示了使用 Spring Data JPA 插入的记录在数据库中存在的检查结果。

图 2.5 检查插入的记录在数据库中存在的结果
2.7 比较实体持久化方法
我们实现了一个简单的应用程序,它与数据库交互,并交替使用 JPA、原生 Hibernate 和 Spring Data JPA。我们的目的是分析每种方法,并查看配置和代码如何变化。表 2.1 总结了这些方法的特点。
表 2.1 JPA、原生 Hibernate 和 Spring Data JPA 的工作比较
| 框架 | 特征 |
|---|---|
| JPA |
-
使用通用的 JPA API 并需要一个持久化提供者。
-
我们可以从配置中切换到不同的持久化提供者。
-
需要显式管理
EntityManagerFactory、EntityManager和事务。 -
配置和需要编写的代码量与原生 Hibernate 原生方法类似。
-
我们可以通过从原生 Hibernate 配置中构建一个
EntityManagerFactory来切换到 JPA 方法。
|
| Native Hibernate |
|---|
-
使用原生 Hibernate API。你将锁定使用这个选定的框架。
-
从默认的 Hibernate 配置文件(hibernate.cfg.xml 或 hibernate.properties)开始构建其配置。
-
需要显式管理
SessionFactory、Session和事务。 -
配置和需要编写的代码量与 JPA 方法类似。
-
我们可以通过从
EntityManagerFactory解包SessionFactory或从EntityManager解包Session来切换到原生 Hibernate 原生方法。
|
| Spring Data JPA |
|---|
-
需要在项目中添加额外的 Spring Data 依赖项。
-
配置还将负责创建项目所需的豆类,包括事务管理器。
-
仓库接口只需要声明,Spring Data 将为其创建一个代理类实现,并生成与数据库交互的方法。
-
必要的仓库被注入,而不是由程序员显式创建。
-
这种方法需要编写的代码量最少,因为配置处理了大部分的负担。
|
关于每种方法的性能信息,请参阅 Cătălin Tudose 和 Carmen Odubășteanu 撰写的文章“使用 JPA、Hibernate 和 Spring Data JPA 进行对象关系映射”(Tudose, 2021)。
为了分析运行时间,我们使用三种方法执行了一系列的插入、更新、选择和删除操作,记录数量从 1,000 逐渐增加到 50,000。测试在 Windows 10 Enterprise 操作系统上进行,该系统运行在四核 Intel i7-5500U 处理器上,主频为 2.40 GHz,内存为 8 GB。
Hibernate 和 JPA 的插入执行时间非常接近(见表 2.2 和图 2.6)。随着记录数量的增加,Spring Data JPA 的执行时间增长速度更快。
表 2.2 按框架的插入执行时间(单位:毫秒)
| 记录数 | Hibernate | JPA | Spring Data JPA |
|---|---|---|---|
| 1,000 | 1,138 | 1,127 | 2,288 |
| 5,000 | 3,187 | 3,307 | 8,410 |
| 10,000 | 5,145 | 5,341 | 14,565 |
| 20,000 | 8,591 | 8,488 | 26,313 |
| 30,000 | 11,146 | 11,859 | 37,579 |
| 40,000 | 13,011 | 13,300 | 48,913 |
| 50,000 | 16,512 | 16,463 | 59,629 |

图 2.6 按框架的插入执行时间(单位:毫秒)
Hibernate 和 JPA 的更新执行时间也非常接近(见表 2.3 和图 2.7)。同样,随着记录数量的增加,Spring Data JPA 的执行时间增长速度更快。
表 2.3 按框架的更新执行时间(单位:毫秒)
| 记录数 | Hibernate | JPA | Spring Data JPA |
|---|---|---|---|
| 1,000 | 706 | 759 | 2,683 |
| 5,000 | 2,081 | 2,256 | 10,211 |
| 10,000 | 3,596 | 3,958 | 17,594 |
| 20,000 | 6,669 | 6,776 | 33,090 |
| 30,000 | 9,352 | 9,696 | 46,341 |
| 40,000 | 12,720 | 13,614 | 61,599 |
| 50,000 | 16,276 | 16,355 | 75,071 |

图 2.7 按框架的更新执行时间(单位:毫秒)
对于选择操作,情况也类似,Hibernate 和 JPA 之间几乎没有差异,而随着记录数量的增加,Spring Data 的曲线陡峭(见表 2.4 和图 2.8)。
表 2.4 按框架的选择执行时间(单位:毫秒)
| 记录数 | Hibernate | JPA | Spring Data JPA |
|---|---|---|---|
| 1,000 | 1,138 | 1,127 | 2,288 |
| 5,000 | 3,187 | 3,307 | 8,410 |
| 10,000 | 5,145 | 5,341 | 14,565 |
| 20,000 | 8,591 | 8,488 | 26,313 |
| 30,000 | 11,146 | 11,859 | 37,579 |
| 40,000 | 13,011 | 13,300 | 48,913 |
| 50,000 | 16,512 | 16,463 | 59,629 |

图 2.8 按框架的选择执行时间(单位:毫秒)
删除操作的行为也类似,Hibernate 和 JPA 非常接近,而随着记录数量的增加,Spring Data 的执行时间增长速度更快(见表 2.5 和图 2.9)。
表 2.5 按框架的删除执行时间(单位:毫秒)
| 记录数 | Hibernate | JPA | Spring Data JPA |
|---|---|---|---|
| 1,000 | 584 | 551 | 2,430 |
| 5,000 | 1,537 | 1,628 | 9,685 |
| 10,000 | 2,992 | 2,763 | 17,930 |
| 20,000 | 5,344 | 5,129 | 32,906 |
| 30,000 | 7,478 | 7,852 | 47,400 |
| 40,000 | 10,061 | 10,493 | 62,422 |
| 50,000 | 12,857 | 12,768 | 79,799 |

图 2.9 按框架删除执行时间(时间单位:毫秒)
这三种方法提供不同的性能。Hibernate 和 JPA 正面交锋——它们的时代图形在所有四个操作(插入、更新、选择和删除)上几乎重叠。尽管 JPA 在 Hibernate 之上有自己的 API,但这额外的层引入了没有开销。
Spring Data JPA 插入的执行时间从大约是 Hibernate 和 JPA 的 2 倍开始,对于 10,000 条记录增加到大约 3.5 倍,对于 50,000 条记录。Spring Data JPA 框架的额外开销相当可观。
对于 Hibernate 和 JPA,更新和删除的执行时间低于插入的执行时间。相比之下,Spring Data JPA 的更新和删除执行时间比插入执行时间长。
对于 Hibernate 和 JPA,选择时间随着行数的增加而缓慢增长。Spring Data JPA 的选择执行时间随着行数的增加而急剧增长。
使用 Spring Data JPA 主要在特定情况下是合理的:如果项目已经使用 Spring 框架并且需要依赖其现有范式(例如控制反转或自动管理的事务),或者如果存在强烈的减少代码量的需求,从而缩短开发时间(如今获取更多计算能力比获取更多开发者更便宜)。
本章重点介绍了从 Java 应用程序与数据库交互的替代方案——JPA、本地 Hibernate 和 Spring Data JPA,并分别介绍了它们的入门示例。第三章将介绍一个更复杂的示例,并更深入地探讨领域模型和元数据。
摘要
-
一个 Java 持久化项目可以使用三种替代方案实现:JPA、本地 Hibernate 和 Spring Data JPA。
-
你可以创建、映射和注解一个持久化类。
-
使用 JPA,你可以实现持久化单元的配置和启动,并且可以创建
EntityManagerFactory入口点。 -
你可以通过调用
EntityManager与数据库交互,存储和加载持久化领域模型类的实例。 -
本地 Hibernate 提供了启动和配置选项,以及等效的基本 Hibernate API:
SessionFactory和Session。 -
你可以通过从
EntityManagerFactory解包SessionFactory或从 Hibernate 配置中获取EntityManagerFactory在 JPA 方法和 Hibernate 方法之间切换。 -
你可以通过创建存储库接口来实现 Spring Data JPA 应用程序的配置,然后使用它来存储和加载持久化领域模型类的实例。
-
比较和对比这三种方法(JPA、本地 Hibernate 和 Spring Data JPA)展示了它们在可移植性、所需依赖项、代码量和执行速度方面的优缺点。
3 领域模型和元数据
本章涵盖
-
介绍 CaveatEmptor 示例应用程序
-
实现领域模型
-
检查对象/关系映射元数据选项
上一章中的“Hello World”示例介绍了 Hibernate、Spring Data 和 JPA,但它对于理解具有复杂数据模型的现实世界应用程序的需求并不有用。在本书的其余部分,我们将使用一个更复杂的示例应用程序——CaveatEmptor,一个在线拍卖系统——来展示 JPA、Hibernate 以及稍后的 Spring Data。(Caveat emptor意味着“让买家小心。”)
JPA 2 中的主要新功能
JPA 持久化提供程序现在可以自动与 Bean Validation 提供程序集成。当数据存储时,提供程序会自动验证持久化类上的约束。
Metamodel API 也已添加。你可以获取持久单元中类的名称、属性和映射元数据。
我们将通过对 CaveatEmptor 应用程序的分层应用架构进行介绍来开始对其的讨论。然后,你将学习如何识别问题域的业务实体。你将创建这些实体及其属性的概念模型,称为领域模型,并通过创建持久化类在 Java 中实现它。我们将花一些时间探讨这些 Java 类应该是什么样子,以及它们在典型的分层应用架构中的位置。我们还将查看这些类的持久化能力以及这对应用程序的设计和实现有何影响。我们还将添加 Bean Validation,这将帮助你自动验证领域模型数据的完整性——包括持久化信息和业务逻辑。
然后,我们将探讨一些映射元数据选项——这些选项是告诉 Hibernate 持久化类及其属性如何与数据库表和列相关联的方式。这可以简单到直接在类的 Java 源代码中添加注解,或者编写你最终与 Hibernate 在运行时访问的编译 Java 类一起部署的 XML 文档。
在阅读本章后,你将了解如何在复杂现实世界项目中设计领域模型的持久化部分,以及你将主要偏好使用的映射元数据选项。让我们从示例应用程序开始。
3.1 示例 CaveatEmptor 应用程序
CaveatEmptor 示例是一个在线拍卖应用程序,它展示了 ORM 技术、JPA、Hibernate 和 Spring Data 功能。在这本书中,我们不会过多关注用户界面(它可能是基于 Web 的或富客户端);相反,我们将专注于数据访问代码。
要理解 ORM 中涉及的设计挑战,让我们假设 CaveatEmptor 应用程序尚不存在,并且我们从零开始构建它。让我们首先看看架构。
3.1.1 分层架构
对于任何非平凡的应用程序,通常按关注点组织类是有意义的。持久性是一个关注点;其他包括表示、工作流和业务逻辑。典型的面向对象架构包括代表这些关注点的代码层。
跨切面关注点
还存在所谓的跨切面关注点,这些关注点可以通过框架代码等通用方式实现。典型的跨切面关注点包括日志记录、授权和事务划分。
分层架构定义了实现各种关注点的代码之间的接口,允许在不显著干扰其他层代码的情况下更改一个关注点的实现方式。分层决定了层间依赖的类型。规则如下:
-
层从上到下进行通信。一个层只依赖于直接位于其下方的层的接口。
-
每个层除了直接位于其下方的层之外,对任何其他层都一无所知,如果它收到来自该层的显式请求,最终也会知道位于其上方的层。
不同的系统按不同的方式分组关注点,因此它们定义了不同的层。典型的、经过验证的、高级应用程序架构使用三个层:每个层分别用于表示、业务逻辑和持久性,如图 3.1 所示。

图 3.1 持久层是分层架构的基础。
-
表示层—用户界面逻辑是最顶层的。负责页面和屏幕导航的表示和控制代码位于表示层。用户界面代码可以直接访问共享领域模型中的业务实体,并在屏幕上渲染它们,同时提供执行动作的控件。在某些架构中,业务实体实例可能无法直接由用户界面代码访问,例如当表示层不在与系统其他部分相同的机器上运行时。在这种情况下,表示层可能需要自己的特殊数据传输模型,仅表示领域模型的可传输子集。表示层的良好例子是与浏览器一起交互以与应用程序进行交互。
-
业务层—业务层通常负责实现任何属于问题域的业务规则或系统需求。此层通常包括某种控制组件——知道何时调用哪个业务规则的代码。在某些系统中,此层有自己的业务领域实体的内部表示。或者,它可能依赖于与应用程序其他层共享的领域模型实现。业务层的良好例子是负责执行业务逻辑的代码。
-
持久层——持久层是一组负责将数据存储到、从一个或多个数据存储中检索数据的类和组件。此层需要一个模型来表示你希望保持持久状态的业务领域实体。持久层是 JPA、Hibernate 和 Spring Data 大量使用的地方。
-
数据库——数据库通常是外部的。它是系统状态的实际持久表示。如果使用 SQL 数据库,数据库包括模式,以及可能用于在数据附近执行业务逻辑的存储过程。数据库是数据长期持久化的地方。
-
辅助和实用类——每个应用程序都有一组基础设施辅助或实用类,这些类在应用程序的每一层都被使用。这些可能包括通用类或横切关注类(如日志记录、安全和缓存)。这些共享的基础设施元素不构成一层,因为它们不遵循分层架构中层间依赖的规则。
现在我们有了高级架构,我们可以专注于业务问题。
3.1.2 分析业务领域
在这个阶段,你应该在领域专家的帮助下,分析你的软件系统需要解决的业务问题,识别相关的主要实体及其交互。领域模型分析和设计的背后主要目标是捕捉业务信息的核心,以适应应用程序的目的。
实体通常是系统用户理解的概念:支付、客户、订单、项目、出价等等。一些实体可能是用户思考的更不具体事物的抽象,例如定价算法,但这些通常对用户来说是可理解的。你可以在业务的概念视图中找到所有这些实体,有时也称为信息模型。
从这个业务模型中,面向对象软件的工程师和架构师创建了一个面向对象模型,仍然处于概念层面(没有 Java 代码)。此模型可能只是一个存在于开发者心中的心理图像,或者它可能像 UML 类图一样复杂。图 3.2 展示了用 UML 表达的一个简单模型。

图 3.2 典型在线拍卖模型的类图
此模型包含你在任何典型的电子商务系统中都可能会找到的实体:类别、项目和用户。此领域模型表示所有实体及其关系(以及可能它们的属性)。这种从问题域中提取的实体面向对象模型,仅包含对用户感兴趣的实体,被称为领域模型。它是对现实世界的抽象视图。
与使用面向对象模型不同,工程师和架构师可能从数据模型开始应用设计。这可以用实体关系图来表示,它将包含 CATEGORY、ITEM 和 USER 实体,以及它们之间的关系。我们通常说,在持久性方面,这两种模型之间几乎没有区别;它们只是不同的起点。最终,你使用哪种建模语言是次要的;我们最感兴趣的是业务实体的结构和关系。我们关心必须应用以确保数据完整性的规则(例如,模型中包含的关系多重性)以及用于操作数据的代码过程(通常不包括在模型中)。
在下一节中,我们将完成对 CaveatEmptor 问题领域的分析。生成的领域模型将成为本书的中心主题。
3.1.3 CaveatEmptor 领域模型注意事项
CaveatEmptor 网站将允许用户拍卖多种不同类型的物品,从电子设备到机票。拍卖按照英语拍卖策略进行:用户继续对该物品进行出价,直到该物品的出价期结束,最高出价者获胜。
在任何商店中,商品都按类型分类,并按相似商品分组到区域和货架上。拍卖目录需要某种物品类别的层次结构,以便买家可以浏览类别或任意按类别和物品属性搜索。物品列表将出现在类别浏览器和搜索结果屏幕上。从列表中选择一个物品将带买家到物品详情视图,其中物品可能附有图片。
拍卖由一系列出价组成,其中一个是获胜出价。用户详细信息将包括姓名、地址和账单信息。

图 3.3 CaveatEmptor 领域模型的持久化类及其关系
本分析的结果,即领域模型的高级概述,如图 3.3 所示。让我们简要讨论一下这个模型的一些有趣特性:
-
每个物品只能拍卖一次,因此你不需要将
Item与任何拍卖实体区分开来。相反,你有一个名为Item的单一拍卖物品实体。因此,Bid直接与Item相关联。你将User的Address信息建模为一个单独的类——一个User可能有三个地址,分别是家庭、账单和配送。你允许用户拥有多个BillingDetails。抽象类的子类代表各种账单策略(允许未来扩展)。 -
应用程序可以在一个
Category内部嵌套另一个Category,依此类推。从Category实体到自身的递归关联表达了这种关系。请注意,一个单独的Category可以有多个子类别,但最多只有一个父类别。每个Item至少属于一个Category。 -
这种表示法并不是一个完整的领域模型;它只是那些需要持久化能力的类。你将需要存储和加载
Category、Item、User等类的实例。我们对这个高级概述进行了一些简化;在需要更复杂的示例时,我们会修改这些类。 -
领域模型中的实体应该封装状态和行为。例如,
User实体应该定义客户的姓名和地址以及计算特定客户物品的运费所需的逻辑。 -
领域模型中可能还有其他只有瞬态运行时实例的类。考虑一个封装最高出价者赢得拍卖事实的
WinningBidStrategy类。这可能在业务层(控制器)代码检查拍卖状态时被调用。在某个时候,你可能需要弄清楚如何计算已售物品的税费或系统如何批准新的用户账户。我们不认为这样的业务规则或领域模型行为是不重要的;相反,这些关注点大多与持久性问题正交。
现在你已经有一个(初步的)具有领域模型的应用程序设计,下一步是将它用 Java 实现。
无领域模型的 ORM
使用完整的 ORM 进行对象持久化最适合基于丰富领域模型的应用程序。如果你的应用程序没有实现复杂的业务规则或实体之间的复杂交互,或者如果你有很少的实体,你可能不需要领域模型。许多简单的问题和一些不那么简单的问题非常适合面向表解决方案,其中应用程序是围绕数据库数据模型设计的,而不是围绕面向对象的领域模型,逻辑通常在数据库(使用存储过程)中执行。
值得考虑的还有学习曲线:一旦你精通 Hibernate 和 Spring Data,你将使用它们来处理所有应用程序——甚至是一个简单的 SQL 查询生成器和结果映射器。如果你刚开始学习 ORM,一个简单的用例可能不足以证明所涉及的时间和开销。
3.2 实现领域模型
让我们从任何实现都必须处理的任何问题开始:关注点的分离——哪一层负责什么责任。领域模型实现通常是中心、组织性的组件;在实现新的应用程序功能时,它被大量重用。因此,你应该采取一些措施来确保非业务关注点不会渗入领域模型实现。
3.2.1 解决关注点泄漏问题
当持久性、事务管理或授权等关注点开始出现在领域模型类中时,这是一个关注点泄漏的例子。领域模型实现是重要的代码,不应依赖于正交 API。例如,领域模型中的代码不应直接调用数据库或通过中间抽象调用。这将允许你在几乎任何地方重用领域模型类。
应用程序的架构包括以下层:
-
呈现层在渲染视图时可以访问领域模型实体的实例和属性。用户可以使用前端(如浏览器)与应用程序交互。这个关注点应该与其他层的关注点分开。
-
业务层中的控制器组件可以访问领域模型实体的状态并调用这些实体的方法。这是执行业务计算和逻辑的地方。这个关注点应该与其他层的关注点分开。
-
持久化层可以从数据库加载领域模型实体的实例并将它们存储到数据库中,保留其状态。这是信息长期持久化的地方。这个关注点也应该与其他层的关注点分开。
防止关注点泄漏使得在不需要特定运行时环境或容器或模拟任何服务依赖的情况下,可以轻松地对领域模型进行单元测试。你可以编写单元测试来验证领域模型类的正确行为,而无需任何特殊的测试框架。(这里我们谈论的是像“计算运费和税费”这样的单元测试,而不是像“从数据库加载”和“存储到数据库”这样的性能和集成测试。)
Jakarta EE 标准通过代码中的元数据(如注解)或外部 XML 描述符解决了关注点泄漏的问题。这种方法允许运行时容器通过拦截对应用程序组件的调用以通用方式实现一些预定义的横切关注点——安全性、并发性、持久性、事务性和远程性。
JPA 将实体类定义为主要的编程工件。这种编程模型实现了透明持久化,并且 JPA 提供者(如 Hibernate)也提供了自动化持久化。Hibernate 不是一个 Jakarta EE 运行时环境,也不是一个应用程序服务器。它是 ORM 技术的实现。
3.2.2 透明和自动持久化
我们使用术语透明来指代领域模型持久化类和持久化层之间的关注点完全分离。持久化类对持久化机制一无所知,并且没有依赖。从持久化类内部,没有对外部持久化机制的引用。我们使用术语自动来指代一个持久化解决方案(你的注解领域、层和机制),它让你摆脱处理低级机械细节,例如编写大多数 SQL 语句和与 JDBC API 交互。作为一个现实世界的用例,让我们分析透明和自动化的持久化如何在Item类级别得到体现。
CaveatEmptor 领域模型的Item类不应该对 Jakarta Persistence 或 Hibernate API 有任何运行时依赖。此外,JPA 也不要求持久化类继承或实现任何特殊的超类或接口。也不使用任何特殊类来实现属性和关联。你可以在任何运行时环境中使用常规的 Java new操作符创建实例,从而保持可测试性和可重用性。
在具有透明持久化的系统中,实体实例对底层数据存储一无所知;它们甚至不需要知道它们正在被持久化或检索。JPA 将持久化关注点外部化到一个通用的持久化管理器 API。因此,你的大部分代码,当然包括你的复杂业务逻辑,不需要关心单个执行线程中领域模型实体实例的当前状态。我们将透明性视为一个要求,因为它使得应用程序更容易构建和维护。透明持久化应该是任何 ORM 解决方案的主要目标之一。
显然,没有自动化的持久化解决方案是完全透明的:每个自动化的持久化层,包括 JPA 和 Hibernate,都对持久化类提出了一些要求。例如,JPA 要求集合类型的属性被类型化为接口,如java.util.Set或java.util.List,而不是实际的实现,如java.util.HashSet(这本身就是一种好习惯)。同样,JPA 实体类必须有一个特殊属性,称为数据库标识符(这虽然不是限制,但通常很方便)。
你现在知道持久化机制应该对如何实现领域模型的影响最小化,并且需要透明和自动化的持久化。我们实现这一目标的首选编程模型是 POJO。
注意,POJO 是Plain Old Java Objects的缩写。马丁·福勒(Martin Fowler)、丽贝卡·帕森斯(Rebecca Parsons)和乔希·麦肯齐(Josh Mackenzie)在 2000 年提出了这个术语。
在 2000 年代初,许多开发者开始讨论 POJO,这是一种回归基础的方法,本质上复兴了 JavaBeans,一个用于 UI 开发的组件模型,并将其重新应用于系统的其他层。EJB 和 JPA 规范的几个版本带来了新的轻量级实体,我们可以称它们为 持久化能力 JavaBeans。Java 工程师通常将这些术语用作相同基本设计方法的同义词。
您不必太在意我们在本书中使用哪些术语;我们的最终目标是尽可能透明地将持久化方面应用于 Java 类。如果您遵循一些简单的实践,几乎任何 Java 类都可以成为持久化能力类。让我们看看代码中的样子。
注意:要能够执行章节源代码中的示例,您首先需要运行 Ch03.sql 脚本。这些示例使用默认凭据的 MySQL 服务器:用户名为 root,无密码。
3.2.3 编写持久化能力类
支持细粒度和丰富的领域模型是 Hibernate 的一个主要目标。这是我们与 POJO 一起工作的原因之一。一般来说,使用细粒度对象意味着比表更多的类。
一个持久化能力的普通 Java 类声明了属性,这些属性代表状态,以及业务方法,这些方法定义了行为。一些属性代表与其他持久化能力类的关联。
以下列表展示了领域模型中 User 实体的 POJO 实现(源代码中 domainmodel 文件夹中的示例 1)。让我们来分析这段代码。
列表 3.1 User 类的 POJO 实现
Path: Ch03/domainmodel/src/main/java/com/manning/javapersistence/ch03/ex01
➥ /User.java
\1 User {
private String username;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
该类可以是抽象的,如果需要,可以扩展非持久化类或实现一个接口。它必须是一个顶级类,不能嵌套在其他类内部。持久化能力类及其任何方法 不应 是最终的(这是 JPA 规范的要求)。Hibernate 不那么严格,它将允许您声明最终类作为实体或具有最终方法的实体,这些方法访问持久化字段。然而,这不是一个好的实践,因为这将阻止 Hibernate 使用代理模式来提高性能。一般来说,如果您希望应用程序能够在不同的 JPA 提供商之间保持可移植性,您应该遵循 JPA 要求。
Hibernate 和 JPA 要求每个持久化类都必须有一个无参数的构造函数。或者,如果您根本不编写构造函数,Hibernate 将使用默认的 Java 构造函数。Hibernate 通过 Java 反射 API 调用此类无参数构造函数来创建实例。构造函数不需要是公共的,但它至少必须是包可见的,以便 Hibernate 使用运行时生成的代理进行性能优化。
POJO 的属性实现了业务实体的属性,例如 User 的 username。你通常会实现属性作为私有或受保护的成员字段,以及公共或受保护的属性访问器方法:对于每个字段,你需要一个用于获取其值的方法,另一个用于设置其值。这些方法分别被称为 获取器 和 设置器。列表 3.1 中的示例 POJO 声明了 username 属性的获取器和设置器方法。
JavaBean 规范定义了访问器方法的命名指南;这允许像 Hibernate 这样的通用工具轻松发现和操作属性值。获取器方法名称以 get 开头,后跟属性名称(首字母大写)。设置器方法名称以 set 开头,并类似地后跟属性名称。对于布尔属性,你可以用 is 而不是 get 开头来命名获取器方法。
Hibernate 不需要访问器方法。你可以选择如何持久化你的持久化类实例的状态。Hibernate 将直接访问字段或调用访问器方法。这些考虑不会在很大程度上干扰你的类设计。你可以使一些访问器方法非公共的,或者完全删除它们,然后配置 Hibernate 依赖于字段访问这些属性。
将属性字段和访问器方法设置为私有、受保护或包可见
通常,你不会允许直接访问类的内部状态,因此你不会使属性字段公共的。如果你使字段或方法私有,你实际上是在声明没有人应该永远访问它们;只有你被允许这样做(或像 Hibernate 这样的服务)。这是一个明确的声明。
有时候,有人访问你的“私有”内部结构往往有很好的理由——通常是为了修复你的一个错误——如果你让他们在紧急情况下不得不回退到反射访问,你只会让人感到愤怒。相反,你可能会假设或知道接替你的工程师可以访问你的代码,并且知道他们在做什么。
虽然简单的访问器方法很常见,但我们喜欢使用 JavaBeans 风格的访问器方法之一的原因是它们提供了封装:你可以更改属性的隐藏内部实现,而无需对公共接口进行任何更改。如果你配置 Hibernate 通过方法访问属性,你将类的内部数据结构(实例变量)从数据库的设计中抽象出来。
例如,如果你的数据库将用户名称存储为单个 NAME 列,但你的 User 类有 firstname 和 lastname 字段,你可以在类中添加以下持久的 name 属性(这是来自 domainmodel 文件夹源代码的示例 2)。
列表 3.2 User 类的 POJO 实现,其中包含访问器方法中的逻辑
Path: Ch03/domainmodel/src/main/java/com/manning/javapersistence/ch03/ex02
➥ /User.java
public class User {
private String firstname;
private String lastname;
public String getName() {
return firstname + ' ' + lastname;
}
public void setName(String name) {
StringTokenizer tokenizer = new StringTokenizer(name);
firstname = tokenizer.nextToken();
lastname = tokenizer.nextToken();
}
}
后续你将看到,在持久化服务中有一个自定义的类型转换器是处理这些情况的一个更好的方法。有几个选项可供选择。
另一个需要考虑的问题是脏检查。Hibernate 自动检测状态变化,以便它能将更新的状态与数据库同步。通常,从获取器方法返回一个与 Hibernate 传递给设置器方法的实例不同的实例是安全的。Hibernate 通过值而不是通过对象身份来比较它们,以确定属性是否需要更新其持久状态。例如,以下获取器方法不会导致不必要的 SQL UPDATE:
public String getFirstname() {
return new String(firstname);
}
在持久化集合时,关于脏检查有一个重要的问题需要注意。如果你有一个Item实体,它有一个通过setBids设置器访问的Set<Bid>字段,这段代码将导致不必要的 SQL UPDATE:
item.setBids(bids);
em.persist(item);
item.setBids(bids);
这是因为 Hibernate 有自己的集合实现:PersistentSet、PersistentList、PersistentMap。提供整个集合的设置器在任何情况下都不是一个好的做法。
当你的访问器方法抛出异常时,Hibernate 如何处理这些异常?如果 Hibernate 在加载和存储实例时使用访问器方法,并且抛出了一个RuntimeException(未检查的异常),则当前事务将回滚,异常将由调用 Jakarta Persistence(或原生 Hibernate)API 的代码处理。如果你抛出一个检查的应用程序异常,Hibernate 会将异常包装成一个RuntimeException。
接下来,我们将关注实体之间的关系以及持久化类之间的关联。
3.2.4 实现 POJO 关联
现在我们来看看如何关联和创建对象之间不同类型的关系:一对一、多对一和双向关系。我们将查看创建这些关联所需的脚手架代码,如何简化关系管理,以及如何强制执行这些关系的完整性。
你可以创建属性来表示类之间的关联,并且你将在运行时通过访问器方法从实例导航到实例。让我们考虑由Item和Bid持久化类定义的关联,如图 3.4 所示。

图 3.4 Item和Bid类之间的关联
我们在图 3.4 中省略了与关联相关的属性,Item#bids 和 Bid#item。这些属性及其操作值的函数被称为脚手架代码。以下是Bid类的脚手架代码示例:
Path: Ch03/domainmodel/src/main/java/com/manning/javapersistence/ch03/ex03
➥ /Bid.java
public class Bid {
private Item item;
public Item getItem() {
return item;
}
public void setItem(Item item) {
this.item = item;
}
}
item属性允许从Bid导航到相关的Item。这是一个具有多对一多重性的关联;用户可以为每个物品投多个标。
这是Item类的脚手架代码:
Path: Ch03/domainmodel/src/main/java/com/manning/javapersistence/ch03/ex03
➥ /Item.java
public class Item {
private Set<Bid> bids = new HashSet<>();
public Set<Bid> getBids() {
return Collections.unmodifiableSet(bids);
}
}
这两个类之间的关联允许进行双向导航:从这一角度看,多对一是多对一多重性的。一个项目可以有多个出价——它们是同一类型,但在拍卖过程中由不同的用户以不同的金额生成,如表 3.1 所示。
表 3.1 一个Item在拍卖期间生成了多个Bid
| 项目 | 出价 | 用户 | 金额 |
|---|---|---|---|
| 1 | 1 | John | 100 |
| 1 | 2 | Mike | 120 |
| 1 | 3 | John | 140 |
bids属性的脚手架代码使用集合接口类型,java.util.Set。JPA 要求集合类型属性使用接口,您必须使用java.util.Set、java.util.List或java.util.Collection,而不是例如HashSet。无论如何,编写针对集合接口的代码而不是具体实现是良好的编程习惯,所以这个限制不应该让您感到烦恼。
您可以选择一个Set并将字段初始化为一个新的HashSet,因为应用程序不允许重复的出价。这是一个好习惯,因为当有人访问一个新Item的属性时,该Item将有一个空的出价集合,这样可以避免任何NullPointer-Exception。JPA 提供者还必须在任何映射的集合值属性上设置非空值,例如,当从数据库中加载没有出价的Item时。(它不必使用HashSet;实现由提供者决定。Hibernate 有自己的集合实现,具有额外的功能,例如脏检查。)
项目的出价不应该存储在列表中吗?
第一个反应通常是保留用户输入元素顺序,因为这也可能是您稍后显示它们的顺序。当然,在拍卖应用程序中,用户看到出价的顺序必须是有定义的,例如,最高的出价首先显示或最新的出价最后显示。您甚至可以在用户界面代码中使用java.util.List来对出价进行排序和显示。
然而,这并不意味着这种显示顺序应该是持久的。数据完整性不受出价显示顺序的影响。您需要存储每个出价的金额,这样您总能找到最高的出价,并且您需要存储每个出价创建的时间戳,这样您总能找到最新的出价。当有疑问时,请保持您的系统灵活,在从数据存储(在查询中)或向用户显示(在 Java 代码中)数据时对其进行排序,而不是在存储时排序。
关联的访问器方法需要声明为 public,仅当它们是应用程序逻辑用于在两个实例之间创建链接的持久化类的公共接口的一部分时。我们现在将关注这个问题,因为在 Java 代码中管理 Item 和 Bid 之间的链接比在具有声明性外键约束的 SQL 数据库中要复杂得多。根据我们的经验,工程师们往往没有意识到这种复杂性,它源于具有双向引用(指针)的网络对象模型。让我们一步一步地解决这个问题。
将 Bid 与 Item 链接的基本步骤如下:
anItem.getBids().add(aBid);
aBid.setItem(anItem);
每次创建这种双向链接时,需要执行两个操作:
- 您必须将
Bid添加到Item的bids集合中(如图 3.5 所示)。

图 3.5 链接 Bid 与 Item 的第一步:将 Bid 添加到 Item 的 Bids 集合中
Bid的item属性必须设置(如图 3.6 所示)。

图 3.6 链接 Bid 与 Item 的第二步:在 Bid 方面设置 Item
JPA 不管理持久化关联。如果您想操作一个关联,您必须编写与不使用 Hibernate 时的相同代码。如果一个关联是双向的,您必须考虑关系的两个方面。如果您在理解 JPA 中关联的行为时遇到问题,只需问自己,“没有 Hibernate 我会怎么做?”Hibernate 不会改变常规的 Java 语义。
我们建议您添加便利方法来分组这些操作,以便重用并帮助确保正确性,最终保证数据完整性(Bid 必须有一个对 Item 的引用)。下面的列表显示了 Item 类中的一个此类便利方法(这是来自 domainmodel 文件夹源代码的示例 3)。
列表 3.3 一个便利方法简化了关系管理
Path: Ch03/domainmodel/src/main/java/com/manning/javapersistence/ch03/ex03
➥ /Item.java
public void addBid(Bid bid) {
if (bid == null)
throw new NullPointerException("Can't add null Bid");
if (bid.getItem() != null)
throw new IllegalStateException(
"Bid is already assigned to an Item");
bids.add(bid);
bid.setItem(this);
}
addBid() 方法不仅减少了处理 Item 和 Bid 实例时的代码行数,还强制了关联的基数。您避免了由于遗漏两个必需操作之一而引起的错误。如果可能,您应该始终为关联提供这种操作分组。如果您将其与 SQL 数据库中外键的关系模型进行比较,您就可以很容易地看到网络和指针模型如何使一个简单的操作复杂化:您需要一个程序性代码来保证数据完整性,而不是一个声明性约束。
因为您希望 addBid() 是唯一的外部可见的修改器方法,用于修改一个项目的出价(可能还包括一个 removeBid() 方法),考虑使 Bid#setItem() 方法对包可见。
Item#getBids()获取器方法不应返回一个可修改的集合,这样客户端就不能使用该集合进行更改,而这些更改在另一侧没有反映出来。直接添加到集合中的出价可能属于一个项目,但它们不会对该项目有引用,这会根据数据库约束创建一个不一致的状态。为了防止这个问题,你可以在从获取器方法返回之前用Collections.unmodifiableCollection(c)和Collections.unmodifiableSet(s)包装内部集合。然后,如果客户端尝试修改集合,它将得到一个异常。因此,你可以强制每个修改都通过关系管理方法进行,从而保证完整性。始终返回不可修改的集合是良好的实践,这样客户端就没有直接访问它的权限。
另一种策略是使用不可变实例。例如,你可以在Bid构造函数中要求一个Item参数来强制完整性,如下面的列表所示(来自domainmodel文件夹源代码的示例 4)。
列表 3.4 使用构造函数强制关系的完整性
Path: Ch03/domainmodel/src/main/java/com/manning/javapersistence/ch03/ex04
➥ /Bid.java
public class Bid {
private Item item;
public Bid(Item item) {
this.item = item;
item.bids.add(this); // Bidirectional
}
public Item getItem() {
return item;
}
}
在这个构造函数中,设置了item字段;不应进一步修改字段值。另一侧的集合也更新以实现双向关系,而Item类的bids字段现在是包私有。没有Bid#setItem()方法。
然而,这种方法有几个问题。首先,Hibernate 不能调用这个构造函数。你需要为 Hibernate 添加一个无参数构造函数,并且它至少需要是包可见的。此外,因为没有setItem()方法,Hibernate 必须配置为直接访问item字段。这意味着字段不能是final,所以类不能保证是不可变的。
至于你想要围绕持久关联属性或字段包裹多少便利方法和层,这取决于你,但我们建议保持一致,并将相同的策略应用于所有领域模型类。为了可读性,我们不会在未来的代码示例中始终显示我们的便利方法、特殊构造函数和其他类似的脚手架;你应该根据自己的品味和需求添加它们。
你现在已经看到了领域模型类以及如何表示它们的属性和它们之间的关系。接下来,我们将提高抽象级别:我们将在领域模型实现中添加元数据,并声明诸如验证和持久性规则等方面。
3.3 领域模型元数据
元数据是关于数据的数据,因此领域模型元数据是关于你的领域模型的信息。例如,当你使用 Java 反射 API 来发现领域模型中类的名称或它们的属性名称时,你正在访问领域模型元数据。
ORM 工具也需要元数据来指定类和表、属性和列、关联和外键、Java 类型和 SQL 类型之间的映射,等等。这种对象/关系映射元数据控制着面向对象和 SQL 系统中的不同类型系统和关系表示之间的转换。JPA 有一个元数据 API,你可以调用它来获取关于你的领域模型持久化方面的详细信息,例如持久化实体和属性的名称。作为工程师,你的任务是创建和维护这些信息。
JPA 标准化了两种元数据选项:Java 代码中的注解和外部化的 XML 描述符文件。Hibernate 为原生功能提供了一些扩展,这些扩展也作为注解或 XML 描述符提供。我们通常更喜欢将注解作为映射元数据的主要来源。阅读本节后,你将拥有足够的信息来为你的项目做出明智的决定。
我们还将在本节中讨论Bean Validation(JSR 303),以及它是如何为你的领域模型(或任何其他)类提供声明性验证的。本规范的参考实现是Hibernate Validator项目。如今,大多数工程师更喜欢将 Java 注解作为声明元数据的主要机制。
3.3.1 基于注解的元数据
注解的一个主要优势是它们将元数据,如@Entity,放置在它所描述的信息旁边,而不是将其分离到不同的文件中。以下是一个示例:
import javax.persistence.Entity;
@Entity
public class Item {
}
你可以在javax.persistence包中找到标准的 JPA 映射注解。此示例使用@javax.persistence.Entity注解将Item类声明为持久化实体。现在,它的所有属性都自动使用默认策略实现持久化。这意味着你可以加载和存储Item的实例,并且该类的所有属性都是管理状态的一部分。
注解是类型安全的,JPA 元数据包含在编译后的类文件中。注解在运行时仍然可访问,当应用程序启动时,Hibernate 使用 Java 反射读取类和元数据。IDE 也可以轻松验证和突出显示注解——毕竟,它们是常规的 Java 类型。当你重构代码时,你会重命名、删除和移动类和属性。大多数开发工具和编辑器无法重构 XML 元素和属性值,但注解是 Java 语言的一部分,并包含在所有重构操作中。
我的类现在是否依赖于 JPA?
当你编译领域模型类的源代码时,需要在类路径上包含 JPA 库。在创建类的实例时,例如在客户端应用程序中(该应用程序不执行任何 JPA 代码),类路径上不需要 JPA。只有当你通过反射在运行时访问注解(如 Hibernate 在读取你的元数据时内部所做的那样)时,你才需要在类路径上包含这些包。
当标准化的 Jakarta Persistence 注解不足时,JPA 提供者可能会提供额外的注解。
使用供应商扩展
即使你使用来自javax.persistence包的与 JPA 兼容的注解映射了应用程序的大部分模型,你可能在某些时候不得不使用供应商扩展。例如,一些你期望在高质量持久化软件中可用的性能调整选项,仅作为 Hibernate 特定注解提供。这就是 JPA 供应商竞争的方式,因此你无法避免其他包中的注解——你选择使用 Hibernate 的原因是有其道理。
以下片段再次显示了Item实体源代码,并带有 Hibernate 特有的映射选项:
import javax.persistence.Entity;
@Entity
@org.hibernate.annotations.Cache(
usage = org.hibernate.annotations.CacheConcurrencyStrategy.READ_WRITE
)
public class Item {
}
我们更喜欢使用完整的org.hibernate.annotations包名作为 Hibernate 注解的前缀。这是一个好的做法,因为它使你能够轻松地看到这个类的哪些元数据来自 JPA 规范,哪些是供应商特定的。你还可以轻松地在源代码中搜索org.hibernate.annotations,并通过单个搜索结果获得应用程序中所有非标准注解的完整概述。
如果你切换到 Jakarta Persistence 提供者,你只需替换供应商特定的扩展,并且可以期待从大多数成熟的 JPA 实现中获得类似的功能集。当然,我们希望你永远不需要这样做,实际上这种情况很少发生——只是做好准备。
类上的注解仅覆盖适用于该特定类的元数据。你通常还需要更高层次的元数据,用于整个包或整个应用程序。
全局注解元数据
@Entity注解映射一个特定的类。JPA 和 Hibernate 也有用于全局元数据的注解。例如,@NamedQuery有一个全局作用域;你不需要将其应用于特定的类。这个注解应该放在哪里?
尽管将此类全局注解放置在类的源文件中(任何类的顶部)是可能的,但我们更喜欢将全局元数据保存在单独的文件中。包级别注解是一个不错的选择;它们位于特定包目录下的名为package_info.java的文件中。你将能够在单个位置找到它们,而不是浏览多个文件。以下列表显示了一个全局命名查询声明的示例(来自domainmodel文件夹源代码的第 5 个示例)。
列表 3.5 包含在 package_info.java 文件中的全局元数据
Path: Ch03/domainmodel/src/main/java/com/manning/javapersistence/ch03/ex05
➥ /package-info.java
@org.hibernate.annotations.NamedQueries({
@org.hibernate.annotations.NamedQuery(
name = "findItemsOrderByName",
query = "select i from Item i order by i.name asc"
)
,
@org.hibernate.annotations.NamedQuery(
name = "findItemBuyNowPriceGreaterThan",
query = "select i from Item i where i.buyNowPrice > :price",
timeout = 60, // Seconds!
comment = "Custom SQL comment"
)
})
package com.manning.javapersistence.ch03.ex05;
除非你之前使用过包级别注解,否则这个文件中包含包和导入声明的语法可能对你来说很陌生。
在本书中,注解将是我们的主要 ORM 元数据工具,关于这个主题有很多东西要学习。在我们查看使用 XML 文件替代的映射风格之前,让我们使用一些简单的注解来通过验证规则改进我们的领域模型类。
3.3.2 将约束应用于 Java 对象
大多数应用程序都包含大量的数据完整性检查。当你违反了最简单的数据完整性约束之一时,你可能会得到一个NullPointerException,因为某个值不可用。当字符串值的属性不应该为空(空字符串不是null)时,当字符串必须匹配特定的正则表达式模式时,或者当数字或日期值必须在某个范围内时,你也可能会得到这个异常。
这些业务规则影响应用程序的每一层:用户界面代码必须显示详细和本地化的错误消息。业务和持久化层必须在将值传递给数据存储之前检查从客户端接收到的输入值。SQL 数据库必须是最终的验证器,保证持久数据的完整性。
Bean Validation 背后的理念是,声明规则,例如“这个属性不能为 null”或“这个数字必须在给定的范围内”,比反复编写 if-then-else 过程要容易得多,且错误率更低。此外,在应用程序的核心组件——领域模型实现上声明这些规则,使得可以在系统的每一层进行完整性检查。这些规则随后对表示层和持久层都是可用的。如果你考虑到数据完整性约束不仅影响你的 Java 应用程序代码,还影响你的 SQL 数据库模式——这是一个完整性规则的集合——你可能会将 Bean Validation 约束视为额外的 ORM 元数据。
看一下validation文件夹源代码中的以下扩展的Item领域模型类。
列表 3.6 在Item实体字段上应用验证约束
Path: Ch03/validation/src/main/java/com/manning/javapersistence/ch03
➥ /validation/Item.java
import javax.validation.constraints.Future;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.Date;
public class Item {
@NotNull
@Size(
min = 2,
max = 255,
message = "Name is required, maximum 255 characters."
)
private String name;
@Future
private Date auctionEnd;
}
当拍卖结束时,我们添加两个属性:物品的name和auctionEnd日期。这两个都是添加额外约束的典型候选者。首先,我们想要保证名称始终存在且可读(单字符的物品名称没有太多意义),但不要太长——你的 SQL 数据库在长度最多为 255 个字符的变长字符串上效率最高,而且你的用户界面也会对可见标签空间有一些约束。其次,拍卖的结束时间显然应该在将来。如果我们没有为约束提供错误消息,将使用默认消息。消息可以是外部属性文件中的国际化键。
如果你注解了字段,验证引擎将直接访问字段。如果你更喜欢通过访问器方法进行调用,请用验证约束注解 getter 方法,而不是 setter(setter 上的注解不受支持)。然后,约束将成为类 API 的一部分,并包含在其 Javadoc 中,这使得领域模型实现更容易理解。请注意,约束作为类 API 的一部分与 JPA 提供者的访问无关;例如,Hibernate Validator 可能会调用访问器方法,而 Hibernate ORM 可能会直接调用字段。
Bean Validation 不仅限于内置注解;您可以创建自己的约束和注解。使用自定义约束,您甚至可以使用类级别的注解,并在类的实例上同时验证多个属性值。以下测试代码展示了如何手动检查 Item 实例的完整性。
列表 3.7 测试 Item 实例的约束违规
Path: Ch03/validation/src/test/java/com/manning/javapersistence/ch03
➥ /validation/ModelValidation.java
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Item item = new Item();
item.setName("Some Item");
item.setAuctionEnd(new Date());
Set<ConstraintViolation<Item>> violations = validator.validate(item);
ConstraintViolation<Item> violation = violations.iterator().next();
String failedPropertyName =
violation.getPropertyPath().iterator().next().getName();
// Validation error, auction end date was not in the future!
assertAll(() -> assertEquals(1, violations.size()),
() -> assertEquals("auctionEnd", failedPropertyName),
() -> {
if (Locale.getDefault().getLanguage().equals("en"))
assertEquals(violation.getMessage(),
"must be a future date");
});
我们不会详细解释此代码,但提供给您探索。您很少会编写这种验证代码;通常这种验证是由您的用户界面和持久化框架自动处理的。因此,在选择 UI 框架时寻找 Bean Validation 集成是很重要的。
Hibernate,如任何 JPA 提供程序所要求的,如果类路径上有库,也会自动与 Hibernate Validator 集成,并提供了以下功能:
-
在将实例传递给 Hibernate 进行存储之前,您不需要手动验证实例。
-
Hibernate 识别持久化领域模型类上的约束,并在数据库插入或更新操作之前触发验证。当验证失败时,Hibernate 会抛出一个包含失败详情的
ConstraintViolationException异常给调用持久化管理操作的代码。 -
Hibernate 自动 SQL 模式生成工具集理解许多约束,并为您生成 SQL DDL 等价的约束。例如,
@NotNull注解转换为 SQL 的NOT NULL约束,而@Size(n)规则定义了VARCHAR(n)类型列中的字符数。
您可以使用持久化.xml 配置文件中的 <validation-mode> 元素来控制 Hibernate 的此行为。默认模式是 AUTO,因此 Hibernate 只有在运行应用程序的类路径上找到 Bean Validation 提供程序(如 Hibernate Validator)时才会进行验证。使用 CALLBACK 模式,验证将始终发生,如果您忘记捆绑 Bean Validation 提供程序,则会收到部署错误。NONE 模式禁用了 JPA 提供程序的自动验证。
您将在本书后面的内容中再次看到 Bean Validation 注解;您也会在示例代码包中找到它们。我们本可以写更多关于 Hibernate Validator 的内容,但我们只会重复项目优秀参考指南中已有的内容(mng.bz/ne65)。请查看并了解更多关于验证组以及约束发现元数据 API 的功能。
3.3.3 使用 XML 文件外部化元数据
您可以用 XML 描述符元素替换或覆盖 JPA 中的每个注解。换句话说,如果您不想使用注解,或者出于某种原因将映射元数据与源代码分开对您的系统设计有利,您不必使用注解。将映射元数据分开的好处是避免在 JPA 注解中混入 Java 代码,并使您的 Java 类更具可重用性,尽管您会失去类型安全性。这种方法现在使用较少,但我们仍将分析它,因为您可能仍然会遇到它或选择这种方法用于您自己的项目。
带有 JPA 的 XML 元数据
以下列表显示了一个特定持久化单元的 JPA XML 描述符(metadataxmljpa 文件夹源代码)。
列表 3.8 包含持久化单元映射元数据的 JPA XML 描述符
Path: Ch03/metadataxmljpa/src/test/resources/META-INF/orm.xml
<entity-mappings
version="2.2"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence/orm
http://xmlns.jcp.org/xml/ns/persistence/orm_2_2.xsd">
<persistence-unit-metadata> Ⓐ
<xml-mapping-metadata-complete/> Ⓑ
<persistence-unit-defaults> Ⓒ
<delimited-identifiers/> Ⓓ
</persistence-unit-defaults>
</persistence-unit-metadata>
<entity class="com.manning.javapersistence.ch03.metadataxmljpa.Item" Ⓔ
access="FIELD"> Ⓔ
<attributes> Ⓕ
<id name="id"> Ⓕ
<generated-value strategy="AUTO"/> Ⓕ
</id> Ⓕ
<basic name="name"/> Ⓕ
<basic name="auctionEnd"> Ⓕ
<temporal>TIMESTAMP</temporal> Ⓕ
</basic> Ⓕ
</attributes>
</entity>
</entity-mappings>
Ⓐ 声明全局元数据。
Ⓑ 忽略所有映射注解。如果我们包含 <xml-mapping-metadata-complete> 元素,JPA 提供商将忽略此持久化单元中域模型类上的所有注解,并仅依赖于在 XML 描述符中定义的映射。
Ⓒ 默认设置会转义所有 SQL 列、表和其他名称。
Ⓓ 如果 SQL 名称实际上是关键字(例如,“USER”表),则转义很有用。
Ⓔ 将 Item 类声明为一个具有字段访问权限的实体。
Ⓕ 它的属性包括自动生成的 id、name 和时间字段 auctionEnd。
如果您将此描述符放置在持久化单元类路径上的 META-INF/orm.xml 文件中,JPA 提供商将自动获取此描述符。如果您希望使用不同的文件名或多个文件,您必须更改您 META-INF/persistence.xml 文件中的持久化单元配置:
<persistence-unit name="persistenceUnitName">
. . .
<mapping-file>file1.xml</mapping-file>
<mapping-file>file2.xml</mapping-file>
. . .
</persistence-unit>
如果您不想忽略注解元数据而是覆盖它,不要将 XML 描述符标记为“完整”,并命名您想要覆盖的类和属性:
<entity class="com.manning.javapersistence.ch03.metadataxmljpa.Item">
<attributes>
<basic name="name">
<column name="ITEM_NAME"/>
</basic>
</attributes>
</entity>
在这里,我们将 name 属性映射到 ITEM_NAME 列;默认情况下,属性会映射到 NAME 列。Hibernate 现在将忽略 Item 类的 name 属性上来自 javax.persistence.annotation 和 org.hibernate.annotations 包的任何现有注解。但 Hibernate 不会忽略 Bean Validation 注解,并且仍然应用它们进行自动验证和模式生成!Item 类上的其他所有注解也被识别。请注意,我们在此映射中没有指定访问策略,因此根据 @Id 注解在 Item 中的位置,将使用字段访问或访问器方法。(我们将在下一章中回到这个细节。)
在本书中,我们不会过多地讨论 JPA XML 描述符。这些文档的语法与 JPA 注解语法相同,因此您在编写它们时不应有任何问题。我们将关注重要方面:映射策略。
3.3.4 在运行时访问元数据
JPA 规范提供了访问持久类(模型信息)的编程接口。该 API 有两种类型。一种在本质上更动态,类似于基本的 Java 反射。第二种选项是静态元模型。对于这两种选项,访问都是只读的;你无法在运行时修改元数据。
Jakarta Persistence 中的动态元模型 API
有时候你可能会想要以编程方式访问实体的持久属性,例如当你想要编写自定义验证或通用 UI 代码时。你希望动态地知道你的领域模型有哪些持久类和属性。以下列表中的代码展示了如何使用 Jakarta Persistence 接口(来自 metamodel 文件夹的源代码)读取元数据。
列表 3.9 使用元模型 API 获取实体类型信息
Path: Ch03/metamodel/src/test/java/com/manning/javapersistence/ch03
➥ /metamodel/MetamodelTest.java
Metamodel metamodel = emf.getMetamodel();
Set<ManagedType<?>> managedTypes = metamodel.getManagedTypes();
ManagedType<?> itemType = managedTypes.iterator().next();
assertAll(() -> assertEquals(1, managedTypes.size()),
() -> assertEquals(
Type.PersistenceType.ENTITY,
itemType.getPersistenceType()));
你可以从 EntityManagerFactory 获取 Metamodel 对象,通常在应用程序中每个数据源只有一个实例,或者,如果更方便的话,通过调用 EntityManager#getMetamodel()。管理类型集合包含有关所有持久实体和嵌入类的信息(我们将在下一章中讨论)。在这个例子中,只有一个管理类型:Item 实体。这就是你可以深入了解并获取每个属性更多信息的方法。
列表 3.10 使用元模型 API 获取实体属性信息
Path: Ch03/metamodel/src/test/java/com/manning/javapersistence/ch03
➥ /metamodel/MetamodelTest.java
SingularAttribute<?, ?> idAttribute =
itemType.getSingularAttribute("id"); Ⓐ
assertFalse(idAttribute.isOptional()); Ⓑ
SingularAttribute<?, ?> nameAttribute =
itemType.getSingularAttribute("name"); Ⓒ
assertAll(() -> assertEquals(String.class, nameAttribute.getJavaType()), Ⓓ
() -> assertEquals( Ⓓ
Attribute.PersistentAttributeType.BASIC, Ⓓ
nameAttribute.getPersistentAttributeType() Ⓓ
));
SingularAttribute<?, ?> auctionEndAttribute =
itemType.getSingularAttribute("auctionEnd"); Ⓔ
assertAll(() -> assertEquals(Date.class, Ⓕ
auctionEndAttribute.getJavaType()), Ⓕ
() -> assertFalse(auctionEndAttribute.isCollection()), Ⓕ
() -> assertFalse(auctionEndAttribute.isAssociation()) Ⓕ
);
Ⓐ 实体的属性通过字符串访问:id。
Ⓑ 检查 id 属性不是可选的。这意味着它不能为 NULL,因为它是主键。
Ⓒname.
Ⓓ 检查 name 属性是否具有 String Java 类型以及基本持久属性类型。
Ⓔ auctionEnd 日期。这显然不是类型安全的,如果你更改属性的名称,此代码将损坏并过时。字符串不会自动包含在你的 IDE 的重构操作中。
Ⓕ 检查 auctionEnd 属性是否具有 Date Java 类型,并且它不是一个集合或关联。
JPA 还提供了静态类型安全的元模型。
使用静态元模型
在 Java(至少到版本 17),你不能以类型安全的方式访问 bean 的字段或访问器方法——只能通过它们的名称,使用字符串。这对于 JPA 条件查询来说尤其不方便,它是一种基于字符串查询语言的类型安全替代品。以下是一个例子:
Path: Ch03/metamodel/src/test/java/com/manning/javapersistence/ch03
➥ /metamodel/MetamodelTest.java
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Item> query = cb.createQuery(Item.class); Ⓐ
Root<Item> fromItem = query.from(Item.class);
query.select(fromItem);
List<Item> items = em.createQuery(query).getResultList();
assertEquals(2, items.size());
Ⓐ 查询等同于 select i from Item i。此查询返回数据库中的所有项目,在这个例子中有两个。如果你想要限制这个结果并只返回具有特定名称的项目,你必须使用 like 表达式,将每个项目的 name 属性与参数中设置的模式进行比较。
以下代码在读取操作上引入了一个过滤器:
Path: Ch03/metamodel/src/test/java/com/manning/javapersistence/ch03
➥ /metamodel/MetamodelTest.java
Path<String> namePath = fromItem.get("name");
query.where(cb.like(namePath, cb.parameter(String.class, "pattern")));
List<Item> items = em.createQuery(query).
setParameter("pattern", "%Item 1%"). Ⓐ
getResultList();
assertAll(() -> assertEquals(1, items.size()),
() -> assertEquals("Item 1", items.iterator().next().getName()));
Ⓐ 查询相当于 select i from Item i where i.name like :pattern。请注意,namePath 查询需要 name 字符串。这就是类型安全的查询条件查询崩溃的地方。你可以使用你的 IDE 的重构工具重命名 Item 实体类,查询仍然会工作。但是,一旦你触及 Item#name 属性,就需要手动调整。幸运的是,你会在测试失败时捕捉到这一点。
一种更好的方法,它对重构是安全的,并在编译时而不是运行时检测不匹配的是类型安全的静态元模型:
Path: Ch03/metamodel/src/test/java/com/manning/javapersistence/ch03
➥ /metamodel/MetamodelTest.java
query.where(
cb.like(
fromItem.get(Item_.name),
cb.parameter(String.class, "pattern")
)
);
这里的特殊类是 Item_;注意下划线。这个类是一个元数据类,它列出了 Item 实体类的所有属性:
Path: Ch03/metamodel/target/classes/com/manning/javapersistence/ch03
➥ /metamodel/Item_.class
@Generated(value = "org.hibernate.jpamodelgen.JPAMetaModelEntityProcessor")
@StaticMetamodel(Item.class)
public abstract class Item_ {
public static volatile SingularAttribute<Item, Date> auctionEnd;
public static volatile SingularAttribute<Item, String> name;
public static volatile SingularAttribute<Item, Long> id;
public static final String AUCTION_END = "auctionEnd";
public static final String NAME = "name";
public static final String ID = "id";
}
此类将自动生成。Hibernate JPA 2 Metamodel Generator(Hibernate 套件的一个子项目)负责此操作。它的唯一目的是从你的受管理持久化类中生成静态元模型类。你需要在 pom.xml 文件中添加此 Maven 依赖项:
Path: Ch03/metamodel/pom.xml
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-jpamodelgen</artifactId>
<version>5.6.9.Final</version>
</dependency>
每当你构建项目时,它将自动运行并生成适当的 Item_ 元数据类。你将在目标\ generated-sources 文件夹中找到生成的类。
本章讨论了领域模型的构建以及动态和静态元模型。尽管你在前面的部分中看到了一些映射结构,但我们还没有介绍任何更复杂的类和属性映射。现在你应该决定在你的项目中想使用哪种映射元数据策略——我们建议使用更常用的注解,而不是已经较少使用的 XML。然后你可以阅读本书的第二部分,从第五章开始,了解更多关于类和属性映射的内容。
摘要
-
我们分析了不同的抽象概念,如信息模型和数据模型,然后跳入 JPA/Hibernate,以便我们可以在 Java 程序中与数据库一起工作。
-
你可以无任何跨切面关注点(如日志记录、授权和事务划分)地实现持久化类。
-
持久化类仅在编译时依赖于 JPA。
-
与持久化相关的关注点不应泄漏到领域模型实现中。
-
透明持久化对于你想要独立执行和测试业务对象来说非常重要。
-
POJO 概念和 JPA 实体编程模型有几个共同点,这些共同点源于旧的 JavaBean 规范:它们将属性实现为私有或受保护的成员字段,而属性访问方法通常是公共或受保护的。
-
我们可以使用动态元模型或静态元模型来访问元数据。
4 使用 Spring Data JPA 进行工作
本章涵盖了
-
介绍 Spring Data 及其模块
-
检查 Spring Data JPA 的主要概念
-
调查查询构建器机制
-
检查投影、修改和删除查询
-
检查基于示例的查询
Spring Data 是一个包含许多针对各种数据库的特定项目的伞形项目。这些项目是与创建数据库技术的公司合作开发的。Spring Data 的目标是提供数据访问的抽象,同时保留各种数据存储的底层细节。
我们将讨论 Spring Data 提供的以下一般功能:
-
通过 JavaConfig 和 XML 配置与 Spring 集成
-
存储库和自定义对象映射抽象
-
通过自定义存储库代码进行集成
-
根据存储库方法名称动态创建查询
-
与其他 Spring 项目(如 Spring Boot)集成
我们在第二章中列出了主要的 Spring Data 模块。在这里,我们将重点关注 Spring Data JPA,它主要用作从 Java 程序访问数据库的替代方案。它在一个 JPA 提供商(如 Hibernate)之上提供了一层抽象,遵循 Spring 框架的精神,控制配置和事务管理。我们将在接下来的章节中的许多示例中使用它来与数据库交互,因此本章将深入分析其功能。我们仍然将使用 JPA 和 Hibernate 定义和管理我们的实体,但我们将提供 Spring Data JPA 作为与之交互的替代方案。
4.1 介绍 Spring Data JPA
Spring Data JPA 提供了与 JPA 存储库交互的支持。如图 4.1 所示,它建立在 Spring Data Commons 项目和 JPA 提供商(在我们的例子中是 Hibernate)的功能之上。要回顾主要的 Spring Data 模块,请参阅第二章。

图 4.1 Spring Data JPA 是建立在 Spring Data Commons 和 JPA 提供商之上的。
在整本书中,我们将使用 Hibernate JPA 和 Spring Data 作为替代方案来与数据库交互。本章以及第 1-3 章提供的背景信息将帮助您开始使用 Spring Data JPA 最重要的功能。当需要时,我们将进一步检查 Spring Data JPA 的功能,并在各自的章节中查看其他 Spring Data 项目。
正如您在 2.6 节中创建“Hello World”应用程序时所见,Spring Data JPA 可以做几件事情来简化与数据库的交互:
-
配置数据源 Bean
-
配置实体管理器工厂 Bean
-
配置事务管理器 Bean
-
通过注解管理事务
4.2 开始一个新的 Spring Data JPA 项目
我们将使用在第三章中介绍的 CaveatEmptor 示例应用程序来演示和分析 Spring Data JPA 的功能。我们将使用 Spring Data JPA 作为持久化框架来管理和持久化 CaveatEmptor 用户,Hibernate JPA 作为底层的 JPA 提供者。Spring Data JPA 可以对数据库执行 CRUD 操作和查询,并且它可以由不同的 JPA 实现支持。它提供了另一层抽象来与数据库交互。
注意:要能够执行源代码中的示例,您首先需要运行 Ch04.sql 脚本。源代码位于 springdatajpa 文件夹中。
我们将创建一个 Spring Boot 应用程序来使用 Spring Data JPA。为此,我们将使用 start.spring.io/ 网站的 Spring Initializr 创建一个具有以下特性的新 Spring Boot 项目(见图 4.2):
-
组:com.manning.javapersistence
-
工件:springdatajpa
-
描述:Spring Data 与 Spring Boot

图 4.2 使用 Spring Data JPA 和 MySQL 创建新的 Spring Boot 项目
我们还将添加以下依赖项:
-
Spring Data JPA(这将在 Maven pom.xml 文件中添加
spring-boot-starter-data-jpa) -
MySQL 驱动程序(这将在 Maven pom.xml 文件中添加
mysql-connector-java)
在您点击生成按钮(如图 4.2 所示)后,Spring Initializr 网站将提供一个要下载的存档。此存档包含一个使用 Spring Data JPA 和 MySQL 的 Spring Boot 项目。图 4.3 显示了在 IntelliJ IDEA IDE 中打开的此项目。

图 4.3 打开使用 Spring Data JPA 和 MySQL 的 Spring Boot 项目
项目的骨架包含四个文件:
-
SpringDataJpaApplication包含一个骨架main方法。 -
SpringDataJpaApplicationTests包含一个骨架测试方法。 -
application.properties在开始时为空。 -
pom.xml包含 Maven 需要的管理信息。
由于前三个文件是标准文件,我们现在将更详细地查看 Spring Initializr 生成的 pom.xml 文件。
列表 4.1 Maven 文件 pom.xml
Path: Ch04/springdatajpa/pom.xml
<parent> Ⓐ
<groupId>org.springframework.boot</groupId> Ⓐ
<artifactId>spring-boot-starter-parent</artifactId> Ⓐ
<version>2.7.0</version> Ⓐ
<relativePath/> <!-- lookup parent from repository --> Ⓐ
</parent> Ⓐ
<groupId>com.manning.javapersistence</groupId> Ⓑ
<artifactId>springdatajpa</artifactId> Ⓑ
<version>0.0.1-SNAPSHOT</version> Ⓑ
<name>springdatajpa</name> Ⓑ
<description>Spring Data with Spring Boot</description> Ⓑ
<properties> Ⓑ
<java.version>17</java.version> Ⓑ
</properties> Ⓑ
<dependencies>
<dependency> Ⓒ
<groupId>org.springframework.boot</groupId> Ⓒ
<artifactId>spring-boot-starter-data-jpa</artifactId> Ⓒ
</dependency> Ⓒ
<dependency> Ⓓ
<groupId>mysql</groupId> Ⓓ
<artifactId>mysql-connector-java</artifactId> Ⓓ
<scope>runtime</scope> Ⓓ
</dependency> Ⓓ
<dependency> Ⓔ
<groupId>org.springframework.boot</groupId> Ⓔ
<artifactId>spring-boot-starter-test</artifactId> Ⓔ
<scope>test</scope> Ⓔ
</dependency> Ⓔ
</dependencies>
<build>
<plugins>
<plugin> Ⓕ
<groupId>org.springframework.boot</groupId> Ⓕ
<artifactId>spring-boot-maven-plugin</artifactId> Ⓕ
</plugin> Ⓕ
</plugins>
</build>
Ⓐ 父 POM 是 spring-boot-starter-parent。此父项目为 Maven 应用程序提供默认配置、依赖项和插件管理。它还从其父项目 spring-boot-dependencies 继承依赖项管理。
Ⓑ 表示项目的 groupId、artifactId、version、name 和 description,以及 Java 版本。
Ⓒ spring-boot-starter-data-jpa 是 Spring Boot 用于通过 Spring Data JPA 连接到关系数据库的启动依赖项。它使用 Hibernate 作为传递依赖项。
Ⓓ mysql-connector-java 是 MySQL 的 JDBC 驱动程序。它是一个运行时依赖项,表示在编译时不需在类路径中,但仅在运行时需要。
Ⓔ spring-boot-starter-test 是用于测试的 Spring Boot 启动依赖项。这个依赖项仅在测试编译和执行阶段需要。
Ⓕ spring-boot-maven-plugin 是用于构建和运行 Spring Boot 项目的实用插件。
4.3 配置 Spring Data JPA 项目的第一步
我们现在将编写描述 User 实体的类。CaveatEmptor 应用程序必须跟踪与之交互的用户,因此自然地要从实现这个类开始。
列表 4.2 User 实体
Path: Ch04/springdatajpa/src/main/java/com/manning/javapersistence
➥ /springdatajpa/model/User.java
@Entity Ⓐ
@Table(name = "USERS") Ⓐ
public class User { Ⓐ
@Id Ⓑ
@GeneratedValue Ⓑ
private Long id; Ⓑ
private String username; Ⓒ
private LocalDate registrationDate; Ⓒ
public User() {
} Ⓓ
public User(String username) { Ⓓ
this.username = username; Ⓓ
} Ⓓ
public User(String username, LocalDate registrationDate) { Ⓓ
this.username = username; Ⓓ
this.registrationDate = registrationDate; Ⓓ
} Ⓓ
public Long getId() { Ⓔ
return id; Ⓔ
} Ⓔ
public String getUsername() { Ⓕ
return username; Ⓕ
} Ⓕ
public void setUsername(String username) { Ⓕ
this.username = username; Ⓕ
} Ⓕ
public LocalDate getRegistrationDate() { Ⓕ
return registrationDate; Ⓕ
} Ⓕ
public void setRegistrationDate(LocalDate registrationDate) { Ⓕ
this.registrationDate = registrationDate; Ⓕ
} Ⓕ
@Override Ⓖ
public String toString() { Ⓖ
return "User{" + Ⓖ
"id=" + id + Ⓖ
", username='" + username + '\'' + Ⓖ
", registrationDate=" + registrationDate + Ⓖ
'}'; Ⓖ
} Ⓖ
}
Ⓐ 创建 User 实体并使用 @Entity 和 @Table 注解进行标注。我们指定 USERS 作为对应表的名称,因为大多数数据库系统中默认的 USER 名称已被保留。
Ⓑ 将 id 字段指定为主键,并包含一个获取器。@GeneratedValue 注解可以启用 id 的自动生成。我们将在第五章中更详细地介绍这一点。
Ⓒ 声明 username 和 registrationDate 字段,以及相应的获取器和设置器。
Ⓓ 声明三个构造函数,包括一个无参构造函数。回想一下,JPA 要求每个持久化类都必须有一个无参构造函数。JPA 使用 Java 反射 API 在这样的无参构造函数上创建实例。
Ⓔ 创建 toString 方法以优雅地显示 User 类的实例。
Ⓕ spring-boot-starter-test 是用于测试的 Spring Boot 启动依赖项。这个依赖项仅在测试编译和执行阶段需要。
Ⓖ spring-boot-maven-plugin 是用于构建和运行 Spring Boot 项目的实用插件。
我们还将创建 UserRepository 接口。
列表 4.3 UserRepository 接口
Path: Ch04/springdatajpa/src/main/java/com/manning/javapersistence
➥ /springdatajpa/repositories/UserRepository.java
public interface UserRepository extends CrudRepository<User, Long> {
}
UserRepository 接口扩展了 CrudRepository<User, Long>。这意味着它是一个 User 实体仓库,这些实体具有 Long 类型的标识符。记住,User 类有一个类型为 Long 并被 @Id 注解的 id 字段。我们可以直接调用从 CrudRepository 继承的方法,如 save、findAll 和 findById,并且我们可以不提供任何额外信息来执行对数据库的常规操作。Spring Data JPA 将创建一个实现 UserRepository 接口的代理类并实现其方法。
值得注意的是,CrudRepository 是一种通用的技术无关的持久化接口,我们不仅可以用于 JPA/关系数据库,还可以用于 NoSQL 数据库。例如,我们可以通过更改依赖项从原始的 spring-boot-starter-data-jpa 到 spring-boot-starter-data-mongodb,轻松地将数据库从 MySQL 更改为 MongoDB,而无需触及实现。
下一步将是填写 Spring Boot 的 application.properties 文件。Spring Boot 会自动从类路径中查找并加载 application.properties 文件;Maven 会将 src/main/resources 文件夹添加到类路径中。
列表 4.4 application.properties 文件
Path: Ch04/springdatajpa/src/main/resources/application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/CH04_SPRINGDATAJPA Ⓐ
?serverTimezone=UTC Ⓐ
spring.datasource.username=root Ⓑ
spring.datasource.password= Ⓑ
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect Ⓒ
spring.jpa.show-sql=true Ⓓ
spring.jpa.hibernate.ddl-auto=create Ⓔ
Ⓐ application.properties文件将指示数据库的 URL。
Ⓑ 用户名,没有密码用于访问。
Ⓒ Hibernate 方言是 MySQL8,因为我们将要交互的数据库是 MySQL Release 8.0。
Ⓓ 在执行过程中,会显示 SQL 代码。
Ⓔ 每次程序执行时,数据库都会从头创建。
现在,我们将编写代码将两个用户保存到数据库中,然后尝试查找它们。
列表 4.5 持久化和查找User实体
Path: Ch04/springdatajpa/src/main/java/com/manning/javapersistence
➥ /springdatajpa/SpringDataJpaApplication.java
@SpringBootApplication Ⓐ
public class SpringDataJpaApplication {
public static void main(String[] args) {
SpringApplication.run(SpringDataJpaApplication.class, args); Ⓑ
}
@Bean Ⓒ
public ApplicationRunner configure(UserRepository userRepository) { Ⓒ
return env ->
{
User user1 = new User("beth", LocalDate.of(2020,
➥ Month.AUGUST, 3));
User user2 = new User("mike", Ⓓ
LocalDate.of(2020, Month.JANUARY, 18)); Ⓓ
userRepository.save(user1); Ⓔ
userRepository.save(user2); Ⓔ
userRepository.findAll().forEach(System.out::println); Ⓕ
};
}
}
Ⓐ 由 Spring Boot 添加到包含main方法的类上的@SpringBootApplication注解将启用 Spring Boot 自动配置机制,并扫描应用程序所在的包,同时允许在上下文中注册额外的 bean。
Ⓑ SpringApplication.run将从main方法中加载独立的 Spring 应用程序。它将创建一个适当的ApplicationContext实例并加载 bean。
Ⓒ Spring Boot 将在SpringApplication.run()完成之前运行被@Bean注解的方法,返回一个ApplicationRunner。
Ⓓ 创建两个用户。
Ⓔ 将它们保存到数据库中。
Ⓕ 检索它们并显示它们的信息。
当我们运行此应用程序时,我们会得到以下输出(由User类的toString()方法的工作方式决定):
User{id=1, username='beth', registrationDate=2020-08-03}
User{id=2, username='mike', registrationDate=2020-01-18}
4.4 使用 Spring Data JPA 定义查询方法
我们将通过添加字段email、level和active来扩展User类。用户可能有不同的级别,这将允许他们执行特定的操作(例如,在某个金额以上出价)。用户可能是活跃的,也可能是退休的(以前在 CaveatEmptor 拍卖系统中是活跃的,但不再是)。这是 CaveatEmptor 应用程序需要保留关于其用户的重要信息。
注意:本章余下部分讨论的源代码可以在springdatajpa2文件夹中找到。
列表 4.6 修改后的User类
Path: Ch04/springdatajpa2/src/main/java/com/manning/javapersistence
➥ /springdatajpa/model/User.java
@Entity
@Table(name = "USERS")
public class User {
@Id
@GeneratedValue
private Long id;
private String username;
private LocalDate registrationDate;
private String email;
private int level;
private boolean active;
public User() {
}
public User(String username) {
this.username = username;
}
public User(String username, LocalDate registrationDate) {
this.username = username;
this.registrationDate = registrationDate;
}
//getters and setters
}
现在,我们将开始向UserRepository接口添加新方法,并在新创建的测试中使用它们。我们将把UserRepository接口改为扩展JpaRepository而不是CrudRepository。JpaRepository扩展了PagingAndSortingRepository,而PagingAndSortingRepository又扩展了CrudRepository。
CrudRepository提供了基本的 CRUD 功能,而PagingAndSortingRepository提供了方便的方法来排序和分页记录(我们将在本章后面讨论)。JpaRepository提供了与 JPA 相关的功能,例如刷新持久化上下文和批量删除记录。此外,JpaRepository覆盖了CrudRepository的一些方法,例如findAll、findAllById和saveAll,以返回List而不是Iterable。
我们还将向UserRepository接口添加一系列查询方法,如下所示
列表 4.7 带有新方法的UserRepository接口
Path: Ch04/springdatajpa2/src/main/java/com/manning/javapersistence
➥ /springdatajpa/repositories/UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
User findByUsername(String username);
List<User> findAllByOrderByUsernameAsc();
List<User> findByRegistrationDateBetween(LocalDate start,
➥ LocalDate end);
List<User> findByUsernameAndEmail(String username, String email);
List<User> findByUsernameOrEmail(String username, String email);
List<User> findByUsernameIgnoreCase(String username);
List<User> findByLevelOrderByUsernameDesc(int level);
List<User> findByLevelGreaterThanEqual(int level);
List<User> findByUsernameContaining(String text);
List<User> findByUsernameLike(String text);
List<User> findByUsernameStartingWith(String start);
List<User> findByUsernameEndingWith(String end);
List<User> findByActive(boolean active);
List<User> findByRegistrationDateIn(Collection<LocalDate> dates);
List<User> findByRegistrationDateNotIn(Collection<LocalDate> dates);
}
这些查询方法的目的是从数据库中检索信息。Spring Data JPA 提供了一个查询构建器机制,它将根据方法名称创建仓库方法的操作。稍后我们将探讨修改查询,这些查询会修改它们找到的数据;现在,我们将专注于那些旨在查找信息的查询。此查询机制从方法名称中移除了 find...By、get...By、query...By、read...By 和 count...By 等前缀和后缀,并解析剩余的部分。
你可以将包含表达式的函数声明为 Distinct 以设置一个唯一子句;将运算符声明为 LessThan、GreaterThan、Between 或 Like;或者使用 And 或 Or 声明复合条件。你可以在查询方法的名称中使用 OrderBy 子句进行静态排序,引用一个属性并提供排序方向(Asc 或 Desc)。对于支持此类子句的属性,你可以使用 IgnoreCase。对于删除行,你必须在方法名称中将 find 替换为 delete。此外,Spring Data JPA 将检查方法的返回类型。如果你想找到一个 User 并将其返回在 Optional 容器中,方法返回类型将是 Optional<User>。可能的返回类型完整列表及其详细说明可以在 Spring Data JPA 参考文档的附录 D 中找到(mng.bz/o51y)。
方法名称需要遵循规则。如果方法命名错误(例如,查询方法中的实体属性不匹配),则在加载应用程序上下文时将会出现错误。表 4.1 描述了 Spring Data JPA 支持的基本关键字以及每个方法名称如何在 JPQL 中转换。对于更完整的列表,请参阅本书末尾的附录 B。
表 4.1 Spring Data JPA 关键字及其生成的 JPQL
| 关键字 | 示例 | 生成的 JPQL |
|---|---|---|
Is, Equals |
findByUsername |
findByUsernameIs |
And |
findByUsernameAndRegistrationDate |
. . . where e.username = ?1 and e.registrationdate = ?2 |
Or |
findByUsernameOrRegistrationDate |
. . . where e.username = ?1 or e.registrationdate = ?2 |
LessThan |
findByRegistrationDateLessThan |
. . . where e.registrationdate < ?1 |
LessThanEqual |
findByRegistrationDateLessThanEqual |
. . . where e.registrationdate <= ?1 |
GreaterThan |
findByRegistrationDateGreaterThan |
. . . where e.registrationdate > ?1 |
GreaterThanEqual |
findByRegistrationDateGreaterThanEqual |
. . . where e.registrationdate >= ?1 |
Between |
findByRegistrationDateBetween |
. . . where e.registrationdate between ?1 and ?2 |
OrderBy |
findByRegistrationDateOrderByUsernameDesc |
. . . where e.registrationdate = ?1 order by e.username desc |
Like |
findByUsernameLike |
. . . where e.username like ?1 |
NotLike |
findByUsernameNotLike |
. . . where e.username not like ?1 |
Before |
findByRegistrationDateBefore |
. . . where e.registrationdate < ?1 |
After |
findByRegistrationDateAfter |
. . . where e.registrationdate > ?1 |
Null, IsNull |
findByRegistrationDate(Is)Null |
. . . where e.registrationdate is null |
NotNull, IsNotNull |
findByRegistrationDate(Is)NotNull |
. . . where e.registrationdate is not null |
Not |
findByUsernameNot |
. . . where e.username <> ?1 |
作为未来测试的基础类,我们将编写一个SpringDataJpaApplicationTests抽象类。
列表 4.8 SpringDataJpaApplicationTests抽象类
Path: Ch04/springdatajpa2/src/test/java/com/manning/javapersistence
➥ /springdatajpa/SpringDataJpaApplicationTests.java
@SpringBootTest Ⓐ
@TestInstance(TestInstance.Lifecycle.PER_CLASS) Ⓑ
abstract class SpringDataJpaApplicationTests {
@Autowired Ⓒ
UserRepository userRepository; Ⓒ
@BeforeAll Ⓓ
void beforeAll() { Ⓓ
userRepository.saveAll(generateUsers()); Ⓓ
} Ⓓ
private static List<User> generateUsers() {
List<User> users = new ArrayList<>();
User john = new User("john", LocalDate.of(2020, Month.APRIL, 13));
john.setEmail("john@somedomain.com");
john.setLevel(1);
john.setActive(true);
//create and set a total of 10 users
users.add(john);
//add a total of 10 users to the list
return users;
}
@AfterAll Ⓔ
void afterAll() { Ⓔ
userRepository.deleteAll(); Ⓔ
} Ⓔ
}
Ⓐ Spring Boot 通过添加到最初创建的类的@SpringBootTest注解,告诉 Spring Boot 搜索主配置类(例如,@SpringBootApplication注解的类)并创建用于测试的ApplicationContext。回想一下,Spring Boot 添加到包含main方法的类的@SpringBootApplication注解将启用 Spring Boot 自动配置机制,启用对应用程序所在包的扫描,并允许在上下文中注册额外的 bean。
Ⓑ 使用@TestInstance(TestInstance.Lifecycle.PER_CLASS)注解,我们请求 JUnit 5 创建一个测试类的单个实例,并为其所有测试方法重用它。这将允许我们将@BeforeAll和@AfterAll注解的方法设置为非静态,并直接在它们内部使用自动装配的UserRepository实例字段。
Ⓒ 自动装配一个UserRepository实例。这种自动装配是由于@SpringBootApplication注解,它启用了对应用程序所在包的扫描并在上下文中注册了 bean。
Ⓓ @BeforeAll注解的方法将在执行扩展SpringDataJpaApplicationTests的类的所有测试之前执行一次。此方法将不是静态的(见Ⓑ以上)。
Ⓔ @AfterAll注解的方法将在执行扩展SpringDataJpaApplicationTests的类的所有测试之后执行一次。此方法将不是静态的(见Ⓑ以上)。
下一个测试将扩展此类并使用已填充的数据库。为了测试现在属于UserRepository的方法,我们将创建FindUsersUsingQueriesTest类,并遵循相同的测试编写方法:调用仓库方法并验证其结果。
列表 4.9 FindUsersUsingQueriesTest类
Path: Ch04/springdatajpa2/src/test/java/com/manning/javapersistence
➥ /springdatajpa/FindUsersUsingQueriesTest.java
public class FindUsersUsingQueriesTest extends
➥ SpringDataJpaApplicationTests {
@Test
void testFindAll() {
List<User> users = userRepository.findAll();
assertEquals(10, users.size());
}
@Test
void testFindUser() {
User beth = userRepository.findByUsername("beth");
assertEquals("beth", beth.getUsername());
}
@Test
void testFindAllByOrderByUsernameAsc() {
List<User> users = userRepository.findAllByOrderByUsernameAsc();
assertAll(() -> assertEquals(10, users.size()),
() -> assertEquals("beth", users.get(0).getUsername()),
() -> assertEquals("stephanie",
users.get(users.size() - 1).getUsername()));
}
@Test
void testFindByRegistrationDateBetween() {
List<User> users = userRepository.findByRegistrationDateBetween(
LocalDate.of(2020, Month.JULY, 1),
LocalDate.of(2020, Month.DECEMBER, 31));
assertEquals(4, users.size());
}
//more tests
}
4.5 限制查询结果、排序和分页
first和top关键字(等效使用)可以限制查询方法的结果。top和first关键字后面可以跟一个可选的数值,表示要返回的最大结果大小。如果此数值缺失,则结果大小为 1。
Pageable 是一个分页信息的接口,但在实践中我们使用实现它的 PageRequest 类。这个类可以指定页码、页面大小和排序标准。
我们将把列表 4.10 中显示的方法添加到 UserRepository 接口。
列表 4.10 限制查询结果、排序和分页
Path: Ch04/springdatajpa2/src/main/java/com/manning/javapersistence
➥ /springdatajpa/repositories/UserRepository.java
User findFirstByOrderByUsernameAsc();
User findTopByOrderByRegistrationDateDesc();
Page<User> findAll(Pageable pageable);
List<User> findFirst2ByLevel(int level, Sort sort);
List<User> findByLevel(int level, Sort sort);
List<User> findByActive(boolean active, Pageable pageable);
接下来我们将编写以下测试来验证这些新添加的方法的工作情况。
列表 4.11 测试限制查询结果、排序和分页
Path: Ch04/springdatajpa2/src/test/java/com/manning/javapersistence
➥ /springdatajpa/FindUsersSortingAndPagingTest.java
public class FindUsersSortingAndPagingTest extends
SpringDataJpaApplicationTests {
@Test
void testOrder() {
User user1 = userRepository.findFirstByOrderByUsernameAsc(); Ⓐ
User user2 = userRepository.findTopByOrderByRegistrationDateDesc(); Ⓐ
Page<User> userPage = userRepository.findAll(PageRequest.of(1, 3)); Ⓑ
List<User> users = userRepository.findFirst2ByLevel(2, Ⓒ
Sort.by("registrationDate")); Ⓒ
assertAll(
() -> assertEquals("beth", user1.getUsername()),
() -> assertEquals("julius", user2.getUsername()),
() -> assertEquals(2, users.size()),
() -> assertEquals(3, userPage.getSize()),
() -> assertEquals("beth", users.get(0).getUsername()),
() -> assertEquals("marion", users.get(1).getUsername())
);
}
@Test
void testFindByLevel() {
Sort.TypedSort<User> user = Sort.sort(User.class); Ⓓ
List<User> users = userRepository.findByLevel(3, Ⓔ
user.by(User::getRegistrationDate).descending()); Ⓔ
assertAll(
() -> assertEquals(2, users.size()),
() -> assertEquals("james", users.get(0).getUsername())
);
}
@Test
void testFindByActive() {
List<User> users = userRepository.findByActive(true, Ⓕ
PageRequest.of(1, 4, Sort.by("registrationDate"))); Ⓕ
assertAll(
() -> assertEquals(4, users.size()),
() -> assertEquals("burk", users.get(0).getUsername())
);
}
}
Ⓐ 第一个测试将按用户名的升序查找第一个用户,按注册日期的降序查找第二个用户。
Ⓑ 查找所有用户,将它们分成页面,并返回第 1 页,大小为 3(页码从 0 开始)。
Ⓒ 查找前两个 2 级用户,并按注册日期排序。
Ⓓ 第二个测试将在 User 类上定义一个排序标准。Sort.TypedSort 扩展 Sort 并可以使用方法句柄来定义排序的属性。
Ⓔ 查找 3 级用户并按注册日期降序排序。
Ⓕ 第三个测试将按注册日期查找活跃用户,将它们分成页面,并返回第 1 页,大小为 4(页码从 0 开始)。
4.6 流式传输结果
返回多个结果的查询方法可以使用标准的 Java 接口,如 Iterable、List、Set。此外,Spring Data 支持 Streamable,它可以作为 Iterable 或任何集合类型的替代品。您可以连接 Streamables 并直接过滤和映射元素。
我们将把以下方法添加到 UserRepository 接口。
列表 4.12 在 UserRepository 接口中添加返回 Streamable 的方法
Path: Ch04/springdatajpa2/src/main/java/com/manning/javapersistence
➥ /springdatajpa/repositories/UserRepository.java
Streamable<User> findByEmailContaining(String text);
Streamable<User> findByLevel(int level);
我们将编写以下测试来验证这些新添加的方法是否工作。
列表 4.13 测试返回 Streamable 的方法
Path: Ch04/springdatajpa2/src/test/java/com/manning/javapersistence
➥ /springdatajpa/QueryResultsTest.java
@Test
void testStreamable() {
try(Stream<User> result = Ⓐ
userRepository.findByEmailContaining("someother") Ⓐ
.and(userRepository.findByLevel(2)) Ⓑ
.stream().distinct()) { Ⓒ
assertEquals(6, result.count()); Ⓓ
}
}
Ⓐ 测试将调用 findByEmailContaining 方法,搜索包含“someother”的电子邮件。
Ⓑ 测试将把生成的 Streamable 与提供 2 级用户的 Streamable 连接起来。
Ⓒ 它将把这个转换成一个流,并保留不同的用户。这个流作为 try 块的资源,所以它将被自动关闭。另一种选择是显式调用 close() 方法。否则,流将保持与数据库的底层连接。
Ⓓ 确保生成的流包含六个用户。
4.7 @Query 注解
使用 @Query 注解,您可以在方法上创建一个查询,然后在该查询上编写自定义查询。当您使用 @Query 注解时,方法名称不需要遵循任何命名约定。自定义查询可以是参数化的,可以通过位置或名称标识参数,并在查询中使用 @Param 注解绑定这些名称。@Query 注解可以生成带有 nativeQuery 标志设置为 true 的本地查询。然而,您应该意识到,本地查询可能会影响应用程序的可移植性。为了排序结果,您可以使用 Sort 对象。您排序的属性必须解析为查询属性或查询别名。
Spring Data JPA 支持在 @Query 注解定义的查询中使用 Spring 表达式语言 (SpEL) 表达式,并且 Spring Data JPA 支持使用 entityName 变量。在一个如 select e from #{#entityName} e 的查询中,entityName 的解析基于 @Entity 注解。在我们的情况下,在 UserRepository extends JpaRepository<User, Long> 中,entityName 将解析为 User。
我们将在 UserRepository 接口中添加以下方法。
列表 4.14 限制查询结果、排序和分页
Path: Ch04/springdatajpa2/src/main/java/com/manning/javapersistence
➥ /springdatajpa/repositories/UserRepository.java
@Query("select count(u) from User u where u.active = ?1") Ⓐ
int findNumberOfUsersByActivity(boolean active); Ⓐ
@Query("select u from User u where u.level = :level
➥ and u.active = :active")
List<User> findByLevelAndActive(@Param("level") int level, Ⓑ
@Param("active") boolean active); Ⓑ
@Query(value = "SELECT COUNT(*) FROM USERS WHERE ACTIVE = ?1", Ⓒ
nativeQuery = true) Ⓒ
int findNumberOfUsersByActivityNative(boolean active); Ⓒ
@Query("select u.username, LENGTH(u.email) as email_length from Ⓓ
#{#entityName} u where u.username like %?1%") Ⓓ
List<Object[]> findByAsArrayAndSort(String text, Sort sort); Ⓓ
Ⓐ findNumberOfUsersByActivity 方法将返回活跃用户数量。
Ⓑ findByLevelAndActive 方法将返回具有指定 level 和 active 状态的用户。@Param 注解将查询中的 :level 参数与方法的 level 参数匹配,将查询中的 :active 参数与方法的 active 参数匹配。这在您更改参数顺序时特别有用,因为此时查询尚未更新。
Ⓒ findNumberOfUsersByActivityNative 方法将返回具有给定 active 状态的用户数量。将 nativeQuery 标志设置为 true 表示,与之前使用 JPQL 编写的查询不同,此查询使用的是针对特定数据库的本地 SQL。
Ⓓ findByAsArrayAndSort 方法将返回一个数组列表,每个数组包含 username 和 email 的长度,在根据 username 过滤后。第二个 Sort 参数将允许您根据不同的标准对查询结果进行排序。
我们将为这些查询方法编写测试,这些测试相当直接。我们只讨论为第四个查询方法编写的测试,该测试允许对排序标准进行一些变化。
列表 4.15 测试查询方法
Path: Ch04/springdatajpa2/src/test/java/com/manning/javapersistence
➥ /springdatajpa/QueryResultsTest.java
public class QueryResultsTest extends SpringDataJpaApplicationTests {
// testing the first 3 query methods
@Test
void testFindByAsArrayAndSort() {
List<Object[]> usersList1 = Ⓐ
userRepository.findByAsArrayAndSort("ar", Sort.by("username")); Ⓐ
List<Object[]> usersList2 = Ⓑ
userRepository.findByAsArrayAndSort("ar", Ⓑ
Sort.by("email_length").descending()); Ⓑ
List<Object[]> usersList3 = userRepository.findByAsArrayAndSort( Ⓒ
"ar", JpaSort.unsafe("LENGTH(u.email)")); Ⓒ
assertAll(
() -> assertEquals(2, usersList1.size()),
() -> assertEquals("darren", usersList1.get(0)[0]),
() -> assertEquals(21, usersList1.get(0)[1]),
() -> assertEquals(2, usersList2.size()),
() -> assertEquals("marion", usersList2.get(0)[0]),
() -> assertEquals(26, usersList2.get(0)[1]),
() -> assertEquals(2, usersList3.size()),
() -> assertEquals("darren", usersList3.get(0)[0]),
() -> assertEquals(21, usersList3.get(0)[1])
);
}
}
Ⓐ findByAsArrayAndSort 方法将返回用户名中包含 %ar% 的用户,并将它们按 username 排序。
Ⓑ findByAsArrayAndSort 方法将返回用户名中包含 %ar% 的用户,并将它们按 email_length 降序排序。请注意,需要将 email_length 别名指定在查询内部,以便用于排序。
Ⓒ findByAsArrayAndSort方法将返回username字段类似于%ar%的用户,并将它们按LENGTH(u.email)排序。JpaSort是一个扩展了Sort的类,它可以用于排序,除了属性引用和别名之外,还可以使用其他内容。unsafe属性处理意味着提供的字符串不一定是属性或别名,但可以是查询中的任意表达式。
如果任何遵循 Spring Data JPA 命名约定(例如,查询方法中的实体属性不匹配)的前置方法命名错误,则在加载应用程序上下文时将得到错误。如果你使用@Query注解,并且你编写的查询错误,则在执行该方法时将在运行时得到错误。因此,@Query注解的方法更加灵活,但它们也提供了更少的安全性。
4.8 投影
实体的不是所有属性都是始终需要的,所以我们有时可能只访问其中的一些。例如,前端可能会减少 I/O,只显示对最终用户感兴趣的信息。因此,你可能会想基于那些实体的某些属性创建投影,而不是返回由存储库管理的根实体实例。Spring Data JPA 可以调整返回类型,以选择性地返回实体的属性。
基于接口的投影需要创建一个接口,该接口声明了要包含在投影中的属性的 getter 方法。这样的接口也可以使用@Value注解和 SpEL 表达式计算特定值。通过在运行时执行查询,执行引擎为每个返回的元素创建接口的代理实例,并将对公开方法的调用转发到目标对象。
我们将创建一个Projection类,并将UserSummary作为一个嵌套接口添加。我们将对投影进行分组,因为它们在逻辑上是相互关联的。
列表 4.16 基于接口的投影
Path: Ch04/springdatajpa2/src/main/java/com/manning/javapersistence
➥ /springdatajpa/model/Projection.java
public class Projection {
public interface UserSummary {
String getUsername(); Ⓐ
@Value("#{target.username} #{target.email}") Ⓑ
String getInfo(); Ⓑ
}
}
Ⓐ getUsername方法将返回username字段。
Ⓑ getInfo方法被@Value注解标记,并将返回username字段、一个空格和email字段的连接。
在实践中我们应该如何处理投影?如果我们只包含像Ⓐ在列表 4.16 中的方法,我们将创建一个封闭投影——这是一个所有 getter 都对应目标实体属性的接口。当你使用封闭投影时,Spring Data JPA 可以通过在开始时就了解投影代理所需的所有属性来优化查询执行。
如果我们包含像Ⓑ这样的方法,我们将创建一个开放投影,这更加灵活。然而,Spring Data JPA 将无法优化查询执行,因为 SpEL 表达式在运行时评估,可能包括实体根的任何属性或属性组合。
通常情况下,当你需要提供有限的信息而不暴露完整实体时,你应该使用投影。出于性能考虑,如果你一开始就知道你想要返回哪些信息,你应该优先选择封闭投影。如果你有一个返回完整对象的查询,而你有一个类似的查询只返回投影,你可以使用不同的命名约定,例如将一个方法命名为find...By,另一个方法命名为get...By。
基于类的投影需要创建一个数据传输对象(DTO)类,该类声明了要包含在投影中的属性和 getter 方法。使用基于类的投影类似于使用基于接口的投影。然而,Spring Data JPA 不需要为管理投影创建代理类。Spring Data JPA 将实例化声明投影的类,要包含的属性由类的构造函数的参数名称确定。
以下列表将UsernameOnly作为Projection类的嵌套类添加。
列表 4.17 基于类的投影
Path: Ch04/springdatajpa2/src/main/java/com/manning/javapersistence
➥ /springdatajpa/model/Projection.java
public class Projection {
// . . .
public static class UsernameOnly { Ⓐ
private String username; Ⓑ
public UsernameOnly(String username) { Ⓒ
this.username = username; Ⓒ
} Ⓒ
public String getUsername() { Ⓓ
return username; Ⓓ
}
}
}
Ⓐ UsernameOnly类
Ⓑ username字段
Ⓒ 声明的构造函数
Ⓓ 通过 getter 暴露的username字段
我们将添加到UserRepository接口中的方法将看起来像这些:
Path: Ch04/springdatajpa2/src/main/java/com/manning/javapersistence
➥ /springdatajpa/repositories/UserRepository.java
List<Projection.UserSummary> findByRegistrationDateAfter(LocalDate date);
List<Projection.UsernameOnly> findByEmail(String username);
这些仓库方法使用了我们在本节之前示例中应用的相同命名约定,并且它们从编译时就知道它们的返回类型是投影类型的集合。然而,我们可以泛化仓库方法的返回类型,这将使它们变得动态。我们将在UserRepository接口中添加一个新方法:
Path: Ch04/springdatajpa2/src/main/java/com/manning/javapersistence
➥ /springdatajpa/repositories/UserRepository.java
<T> List<T> findByEmail(String username, Class<T> type);
我们将使用投影编写这些查询方法的测试。
列表 4.18 使用投影测试查询方法
Path: Ch04/springdatajpa2/src/test/java/com/manning/javapersistence
➥ /springdatajpa/ProjectionTest.java
public class ProjectionTest extends SpringDataJpaApplicationTests {
@Test
void testProjectionUsername() {
List<Projection.UsernameOnly> users = Ⓐ
userRepository.findByEmail("john@somedomain.com"); Ⓐ
assertAll( Ⓑ
() -> assertEquals(1, users.size()), Ⓑ
() -> assertEquals("john", users.get(0).getUsername()) Ⓑ
); Ⓑ
}
@Test
void testProjectionUserSummary() {
List<Projection.UserSummary> users = Ⓒ
userRepository.findByRegistrationDateAfter( Ⓒ
LocalDate.of(2021, Month.FEBRUARY, 1)); Ⓒ
assertAll(
() -> assertEquals(1, users.size()), Ⓓ
() -> assertEquals("julius", users.get(0).getUsername()), Ⓓ
() -> assertEquals("julius julius@someotherdomain.com", Ⓓ
users.get(0).getInfo()) Ⓓ
); Ⓓ
}
@Test
void testDynamicProjection() {
List<Projection.UsernameOnly> usernames = Ⓔ
userRepository.findByEmail("mike@somedomain.com", Ⓔ
Projection.UsernameOnly.class); Ⓔ
List<User> users =
➥ userRepository.findByEmail("mike@somedomain.com", Ⓕ
User.class); Ⓕ
assertAll( Ⓖ
() -> assertEquals(1, usernames.size()), Ⓖ
() -> assertEquals("mike", usernames.get(0).getUsername()), Ⓖ
() -> assertEquals(1, users.size()), Ⓖ
() -> assertEquals("mike", users.get(0).getUsername()) Ⓖ
);
}
}
Ⓐ findByEmail方法将返回一个Projection.UsernameOnly实例的列表。
Ⓑ 验证断言。
Ⓒ findByRegistrationDateAfter方法将返回一个Projection.UserSummary实例的列表。
Ⓓ 验证断言。
Ⓔ 这个findByEmail方法提供了一个动态投影。它将返回一个Projection.UsernameOnly实例的列表。
Ⓕ 这个findByEmail方法也可能返回一个User实例的列表,具体取决于它被泛化的类。
Ⓖ 验证断言。
4.9 修改查询
您可以使用 @Modifying 注解定义修改方法。例如,INSERT、UPDATE 和 DELETE 查询,或 DDL 语句,修改数据库的内容。@Query 注解将修改查询作为参数,可能需要绑定参数。这样的方法还必须注解 @Transactional 或从程序管理的事务中运行。修改查询的优点是清楚地强调它们针对的是哪一列,并且可以包含条件,与持久化或删除整个对象相比,可以使代码更清晰。此外,更改数据库中有限数量的列将执行得更快。
Spring Data JPA 也可以根据方法名生成删除查询。该机制与表 4.1 中的示例类似,但将 find 关键字替换为 delete。
我们将在 UserRepository 接口中添加以下方法。
列表 4.19 将修改方法添加到 UserRepository 接口
Path: Ch04/springdatajpa2/src/main/java/com/manning/javapersistence
➥ /springdatajpa/repositories/UserRepository.java
@Modifying Ⓐ
@Transactional Ⓐ
@Query("update User u set u.level = ?2 where u.level = ?1") Ⓐ
int updateLevel(int oldLevel, int newLevel); Ⓐ
@Transactional Ⓑ
int deleteByLevel(int level); Ⓑ
@Transactional Ⓒ
@Modifying Ⓒ
@Query("delete from User u where u.level = ?1") Ⓒ
int deleteBulkByLevel(int level); Ⓒ
Ⓐ updateLevel 方法将根据 @Query 注解的参数更改 oldLevel 参数用户的 level 并将其设置为 newLevel。该方法还注解了 @Modifying 和 @Transactional。
Ⓑ deleteByLevel 方法将根据方法名生成查询;它将移除所有具有指定 level 的用户。该方法注解了 @Transactional。在这种情况下,不需要 @Modifying,因为查询是由框架生成的。
Ⓒ deleteBulkByLevel 方法将根据 @Query 注解的参数移除所有具有指定 level 的用户。该方法还注解了 @Modifying 和 @Transactional。
deleteByLevel 和 deleteBulkByLevel 方法之间的区别是什么?第一个方法运行查询,然后逐个删除返回的实例。如果有控制每个实例生命周期的回调方法(例如,在用户被删除时运行的某个方法),它们将被执行。第二个方法将批量删除用户,执行单个 JPQL 查询。没有任何 User 实例(甚至已经加载到内存中的实例)将执行生命周期回调方法。
我们现在可以编写修改方法的测试。
列表 4.20 测试修改方法
Path: Ch04/springdatajpa2/src/test/java/com/manning/javapersistence
➥ /springdatajpa/ModifyQueryTest.java
@Test
void testModifyLevel() {
int updated = userRepository.updateLevel(5, 4);
List<User> users = userRepository.findByLevel(4, Sort.by("username"));
assertAll(
() -> assertEquals(1, updated),
() -> assertEquals(3, users.size()),
() -> assertEquals("katie", users.get(1).getUsername())
);
}
我们还将为删除方法编写测试。
列表 4.21 测试删除方法
Path: Ch04/springdatajpa2/src/test/java/com/manning/javapersistence
➥ /springdatajpa/DeleteQueryTest.java
@Test
void testDeleteByLevel() {
int deleted = userRepository.deleteByLevel(2);
List<User> users = userRepository.findByLevel(2, Sort.by("username"));
assertEquals(0, users.size());
}
@Test
void testDeleteBulkByLevel() {
int deleted = userRepository.deleteBulkByLevel(2);
List<User> users = userRepository.findByLevel(2, Sort.by("username"));
assertEquals(0, users.size());
}
4.10 查询示例
查询示例(QBE)是一种不需要编写经典查询来包含实体和属性的查询技术。它允许动态创建查询,并包含三个部分:探针、ExampleMatcher 和 Example。
探针是一个具有已设置属性的域对象。ExampleMatcher 提供了匹配特定属性的规则。一个 Example 将探针和 ExampleMatcher 结合起来并生成查询。多个 Example 可以复用单个 ExampleMatcher。
这些是 QBE 最合适的用例:
-
当你将代码从底层数据存储 API 解耦时。
-
当域对象的内部结构频繁变化,并且这些变化没有传播到现有查询时。
-
当你构建一组静态或动态约束以查询存储库时。
QBE 有一些限制:
-
它仅支持字符串属性的开始/结束/包含正则表达式匹配,以及其他类型的精确匹配。
-
它不支持嵌套或分组属性约束,例如
username = ?0 or (username = ?1 and email = ?2)。
我们不会向UserRepository接口添加更多方法。我们只会编写测试来构建探针、ExampleMatcher和Example。
列表 4.22 查询示例测试
Path: Ch04/springdatajpa2/src/test/java/com/manning/javapersistence
➥ /springdatajpa/QueryByExampleTest.java
public class QueryByExampleTest extends SpringDataJpaApplicationTests {
@Test
void testEmailWithQueryByExample() {
User user = new User(); Ⓐ
user.setEmail("@someotherdomain.com"); Ⓐ
ExampleMatcher matcher = ExampleMatcher.matching() Ⓑ
.withIgnorePaths("level", "active") Ⓑ
.withMatcher("email", match -> match.endsWith()); Ⓑ
Example<User> example = Example.of(user, matcher); Ⓒ
List<User> users = userRepository.findAll(example); Ⓓ
assertEquals(4, users.size()); Ⓔ
}
@Test
void testUsernameWithQueryByExample() {
User user = new User(); Ⓕ
user.setUsername("J"); Ⓕ
ExampleMatcher matcher = ExampleMatcher.matching() Ⓖ
.withIgnorePaths("level", "active") Ⓖ
.withStringMatcher(ExampleMatcher.StringMatcher.STARTING) Ⓖ
.withIgnoreCase(); Ⓖ
Example<User> example = Example.of(user, matcher); Ⓗ
List<User> users = userRepository.findAll(example); Ⓘ
assertEquals(3, users.size()); Ⓙ
}
}
Ⓐ 初始化一个User实例并为它设置一个email。这将代表探针。
Ⓑ 使用构建器模式创建ExampleMatcher。任何null引用属性都将被匹配器忽略。然而,我们需要显式忽略level和active属性,它们是原始数据类型。如果不忽略,它们将以默认值(level为 0 和active为false)包含在匹配器中,并会改变生成的查询。我们将配置匹配器条件,以便email属性将以给定的字符串结尾。
Ⓒ 创建一个Example实例,将探针和ExampleMatcher结合在一起并生成查询。该查询将搜索具有以探针定义的email字符串结尾的email属性的用 户。
Ⓓ 执行查询以找到所有与探针匹配的用户。
Ⓔ 验证是否存在四种此类用户。
Ⓕ 初始化一个User实例并为它设置一个name。这将代表第二个探针。
Ⓖ 使用构建器模式创建ExampleMatcher。任何null引用属性都将被匹配器忽略。再次,我们需要显式忽略level和active属性,它们是原始数据类型。我们配置匹配器条件,以便对配置属性的开头字符串进行匹配(在我们的例子中是探针的username属性)。
Ⓗ 创建一个Example实例,将探针和ExampleMatcher结合在一起并生成查询。该查询将搜索具有以探针定义的username字符串开头的username属性的用 户。
Ⓘ 执行查询以找到所有与探针匹配的用户。
Ⓙ 验证是否存在六种此类用户。
为了强调忽略默认原始属性的重要性,我们将比较带有和不带有对withIgnorePaths("level", "active")方法的调用生成的查询。对于第一个测试,这是调用withIgnorePaths("level", "active")方法生成的查询:
select user0_.id as id1_0_, user0_.active as active2_0_, user0_.email as
➥ email3_0_, user0_.level as level4_0_, user0_.registration_date as
➥ registra5_0_, user0_.username as username6_0_ from users user0_ where
➥ user0_.email like ? escape ?
这是调用withIgnorePaths("level", "active")方法之前生成的查询:
select user0_.id as id1_0_, user0_.active as active2_0_, user0_.email as
➥ email3_0_, user0_.level as level4_0_, user0_.registration_date as
➥ registra5_0_, user0_.username as username6_0_ from users user0_ where
➥ user0_.active=? and (user0_.email like ? escape ?) and user0_.level=0
对于第二次测试,这是调用withIgnorePaths("level", "active")方法生成的查询:
select user0_.id as id1_0_, user0_.active as active2_0_, user0_.email as
➥ email3_0_, user0_.level as level4_0_, user0_.registration_date as
➥ registra5_0_, user0_.username as username6_0_ from users user0_ where
➥ lower(user0_.username) like ? escape ?
这是未调用withIgnorePaths("level", "active")方法的查询生成的:
select user0_.id as id1_0_, user0_.active as active2_0_, user0_.email as
➥ email3_0_, user0_.level as level4_0_, user0_.registration_date as
➥ registra5_0_, user0_.username as username6_0_ from users user0_ where
➥ user0_.active=? and user0_.level=0 and (lower(user0_.username) like ?
➥ escape ?)
注意在移除withIgnorePaths("level", "active")方法时添加到原始属性上的条件:
user0_.active=? and user0_.level=0
这将改变查询结果。
摘要
-
您可以使用 Spring Boot 创建和配置一个 Spring Data JPA 项目。
-
您可以使用 Spring Data JPA 查询构建器机制定义并使用一系列查询方法来通过仓库访问。
-
Spring Data JPA 提供了限制查询结果、排序、分页和流式传输结果的能力。
-
您可以使用
@Query注解来定义非原生和原生的自定义查询。 -
您可以实现投影来塑造返回类型,并选择性地返回实体的属性,您还可以创建和使用修改查询来更新和删除实体。
-
查询示例(QBE)查询技术允许动态创建查询,并包括三个部分:一个探测器、一个
ExampleMatcher和一个Example。
第二部分. 映射策略
这一部分书籍主要介绍 ORM,从类和属性到表和列。无论您使用 Hibernate 还是 Spring Data JPA 作为持久化框架,您在这里获得的知识都是至关重要的。第五章从常规类和属性映射开始,解释了如何映射细粒度的 Java 领域模型。接下来,在第六章中,您将看到如何映射基本属性和可嵌入组件,以及如何控制 Java 和 SQL 类型之间的映射。在第七章中,您将使用四种基本的继承映射策略将实体的继承层次映射到数据库中;您还将映射多态关联。第八章全部关于映射集合和实体关联:您将映射持久化集合、基本和可嵌入类型的集合,以及简单的多对一和一对多实体关联。第九章深入探讨了高级实体关联映射,如映射一对一实体关联、一对多映射选项,以及多对多和三元实体关系。
阅读本书的这一部分后,您将准备好快速且正确地创建甚至最复杂的映射。您将了解继承映射问题如何解决,以及如何映射集合和关联。
5 映射持久化类
本章涵盖
-
理解实体和值类型
-
使用标识符映射实体类
-
控制实体级映射选项
本章介绍了一些基本的映射选项,并解释了如何将实体类映射到 SQL 表。这是在应用程序中构建类的基本知识,无论您是使用 Hibernate、Spring Data JPA 还是其他实现 JPA 规范的持久化框架。我们将演示和分析您如何处理数据库标识符和主键,以及您如何使用各种其他元数据设置来自定义 Hibernate 或 Spring Data JPA(以 Hibernate 作为持久化提供者)如何加载和存储您的领域模型类的实例。
Spring Data JPA 作为一种数据访问抽象,位于 JPA 提供者(如 Hibernate)之上,并将显著减少与数据库交互所需的样板代码。这就是为什么一旦持久化类的映射完成,它就可以从 Hibernate 和 Spring Data JPA 中使用。我们的示例将演示这一点,并且我们所有的映射示例都将使用 JPA 注解。
在我们查看映射之前,我们将定义实体和值类型之间的基本区别,并解释您应该如何处理领域模型的面向对象/关系映射。工程师的角色是在 应用领域(即系统需要解决的问题的环境)和 解决方案领域(即构建此系统的软件和技术)之间建立联系。在图 5.1 中,应用领域由应用领域模型(真实实体)表示,而解决方案领域由系统模型(软件应用中的对象)表示。

图 5.1 需要连接的不同领域和模型
5.1 理解实体和值类型
当您查看您的领域模型时,您会注意到类之间的差异:一些类型似乎更重要,代表一等业务对象(这里的 对象 一词使用其自然意义)。一些例子是 Item、Category 和 User 类:这些是在现实世界中您试图表示的实体(参见图 3.3 以查看示例领域模型)。在您的领域模型中出现的其他类型,如 Address,似乎不太重要。在本节中,我们将探讨使用细粒度领域模型的意义,并区分实体和值类型。
5.1.1 细粒度领域模型
Hibernate 和 Spring Data JPA(以 Hibernate 作为持久化提供者)的一个主要目标是提供对细粒度和丰富领域模型的支持。这也是我们与 POJOs(Plain Old Java Objects)合作的原因——普通的 Java 对象,不受任何框架的约束。粗略地说,细粒度意味着拥有比表更多的类。
例如,在领域模型中,一个用户可能有一个家庭地址。在数据库中,你可能有一个包含 HOME_STREET、HOME_CITY 和 HOME_ZIPCODE 列的单一 USERS 表。(还记得我们在 1.2.1 节中讨论的 SQL 类型问题吗?)在领域模型中,你可以使用相同的方法,将地址表示为 User 类的三个字符串值属性。但使用 Address 类来建模会更好,其中 User 有一个 homeAddress 属性。这个领域模型实现了更好的内聚性和更大的代码重用,并且比具有不灵活类型系统的 SQL 更易于理解。
JPA 强调了细粒度类在实现类型安全和行为方面的有用性。例如,许多人将电子邮件地址建模为 User 的字符串值属性。然而,一个更复杂的方法是定义一个 EmailAddress 类,它添加了更高级别的语义和行为。它可能提供 prepareMail() 方法(但不应该有 sendMail() 方法,因为你不希望你的领域模型类依赖于邮件子系统)。
这个粒度问题使我们区分了 ORM 中的一个中心问题。在 Java 中,所有类都是平等的——所有实例都有自己的身份和生命周期。当你引入持久性时,一些实例可能没有自己的身份和生命周期,而是依赖于其他实例。让我们通过一个例子来了解一下。
5.1.2 定义应用程序概念
假设有两个人住在同一个房子里,并且他们都在 CaveatEmptor 上注册了用户账户。让我们称他们为约翰和简。User 的一个实例代表每个账户。因为你想要独立地加载、保存和删除这些 User 实例,所以 User 是一个实体类,而不是值类型。寻找实体类是容易的。
User 类有一个 homeAddress 属性;它与 Address 类相关联。User 的实例是否都引用同一个 Address 实例,或者每个 User 实例都引用自己的 Address?约翰和简住在同一个房子里是否重要?

图 5.2 两个 User 实例引用单个 Address。
在图 5.2 中,你可以看到两个 User 实例共享一个表示他们家庭地址的单个 Address 实例(这是一个 UML 对象图,而不是类图)。如果 Address 应该支持共享的运行时引用,它就是一个实体类型。Address 实例有自己的生命周期。当约翰删除他的 User 账户时,你不能删除它——简可能仍然引用这个 Address。
现在,让我们看看另一种模型,其中每个User都有一个对其自己的homeAddress实例的引用,如图 5.3 所示。在这种情况下,您可以使Address实例依赖于User实例:您将其作为值类型。当 John 删除他的User账户时,您可以安全地删除他的Address实例。没有人会持有对该实例的引用。

图 5.3 两个User实例各自有自己的依赖Address。
因此,我们可以做出以下基本区分:
-
实体类型—您可以使用其持久标识符检索一个实体类型的实例;例如,一个
User、Item或Category实例。实体实例的引用(在 JVM 中是一个指针)作为数据库中的引用(一个外键约束值)持久化。实体实例有自己的生命周期;它可以独立于任何其他实体存在。您可以将域模型中选定的类映射为实体类型。 -
值类型—一个值类型的实例没有持久标识符属性;它属于一个实体实例,其生命周期绑定到拥有该实体实例。值类型实例不支持共享引用。您可以将自己的域模型类映射为值类型;例如,
Address和MonetaryAmount。
如果您阅读 JPA 规范,您会发现相同的概念,但在 JPA 中值类型被称为基本属性类型或可嵌入类。我们将在下一章中回到这一点。
在您的域模型中识别实体和值类型不是一项临时任务,而是遵循一定的程序。
5.1.3 区分实体和值类型
您可能会发现,将构型信息添加到您的 UML 类图中很有帮助,这样您可以立即识别实体和价值类型(构型是 UML 的可扩展机制)。这种做法还将迫使您思考所有类的这种区别,这是实现最佳映射和良好性能持久层的第一步。图 5.4 展示了示例,其中构型信息位于双尖括号内。

图 5.4 实体和价值类型的图示化构型
Item和User类是明显的实体。它们各自有自己的标识符,它们的实例有来自许多其他实例的引用(共享引用),并且它们有独立的生命周期。
将Address标记为值类型也很简单:单个User实例引用特定的Address实例。您知道这一点是因为关联已被创建为组合,其中User实例完全负责引用的Address实例的生命周期。因此,Address实例不能被其他人引用,也不需要自己的标识符。
Bid类可能存在问题。在面向对象建模中,这被标记为组合(Item和Bid之间的关联,带有完整的菱形)。组合是一种关联类型,其中对象只能作为容器的一部分存在。如果容器被销毁,那么包含的对象也会被销毁。因此,Item是其Bid实例的所有者,并持有引用集合。Bid实例在没有Item的情况下无法存在。起初,这似乎是合理的,因为当为拍卖系统中的物品制作的出价消失时,出价在拍卖系统中是无用的。
但如果领域模型的未来扩展需要包含特定User所做所有出价的User#bids集合呢?目前,Bid和User之间的关联是单向的;一个Bid有一个bidder引用。如果这是双向的会怎样?
在那种情况下,你将不得不处理对Bid实例可能的共享引用,因此Bid类需要成为一个实体。它具有依赖的生命周期,但它必须有自己的身份以支持(未来的)共享引用。
你经常会遇到这种混合行为,但你的第一个反应应该是将所有内容都做成值类型类,并且只有在绝对必要时才将其提升为实体。Bid是一个值类型,因为它的身份由Item和User定义。这并不一定意味着它不会生活在自己的表中。尝试简化你的关联;例如,持久化集合经常增加复杂性,而不提供任何优势。你可以编写查询来获取Item的所有出价以及特定User所做的出价,而不是映射Item#bids和User#bids集合。UML 图中的关联将单向地从Bid指向Item和User,而不是相反。Bid类的构造型将是<<值类型>>。我们将在第八章回到这个话题。
接下来,你可以将你的领域模型图转换为所有实体和值类型的 POJO 实现。你需要注意三件事:
-
共享引用——在编写你的 POJO 类时,避免对值类型实例的共享引用。例如,确保只有一个
User可以引用一个Address。你可以通过没有公共setUser()方法使Address不可变,并通过具有User参数的公共构造函数强制关系。当然,你仍然需要一个无参数的,可能受保护的构造函数,正如我们在第三章中讨论的那样,这样 Hibernate 或 Spring Data JPA 也可以创建实例。 -
生命周期依赖性—如果删除一个
用户,其地址依赖也将必须被删除。持久化元数据将包括所有此类依赖的级联规则,因此 Hibernate、Spring Data JPA 或数据库可以处理删除过时的地址。您必须设计应用程序流程和用户界面以尊重并期望此类依赖性—相应地编写您的领域模型 POJO。 -
身份—在几乎所有情况下,实体类都需要一个标识属性。值类型类(当然,还包括像
String和Integer这样的 JDK 类)没有标识属性,因为实例是通过拥有它们的实体来识别的。
当我们在后面的章节中讨论更高级的映射时,我们将回到引用、关联和生命周期规则。对象身份和标识属性是我们下一个话题。
5.2 使用身份映射实体
使用身份映射实体需要您理解 Java 的身份和相等性。一旦您知道了这一点,我们就可以通过一个实体类示例及其映射来探讨,讨论像数据库身份这样的术语,并查看 JPA 如何管理身份。之后,我们将能够深入挖掘并选择主键,配置键生成器,并最终通过标识生成器策略。
5.2.1 理解 Java 的身份和相等性
Java 开发者理解 Java 对象身份和相等性之间的区别。对象身份(==)是由 Java 虚拟机定义的一个概念。如果两个引用指向相同的内存位置,则它们是相同的。
相反,对象相等性是由类的equals()方法定义的一个概念,有时也称为等价性。等价性意味着两个不同的(非相同的)实例具有相同的值—相同的状态。如果您有一堆同类的全新书籍,您必须从中选择一本,这意味着您将不得不从几个非相同但等价的对象中选择一本。
两个不同的String实例如果表示相同的字符序列,则它们是相等的,尽管每个实例在虚拟机的内存空间中都有自己的位置。(如果您是 Java 大师,我们承认String是一个特殊情况。假设我们使用了一个不同的类来阐述同样的观点。)
持久化使这个情况变得复杂。在对象/关系持久化中,持久化实例是数据库表(或表)中特定行(或行)的内存表示。除了 Java 的身份和相等性之外,我们还定义了数据库身份。现在您有三种方法来区分引用:
-
对象身份—如果对象在 JVM 中占据相同的内存位置,则它们是相同的。这可以通过
a == b运算符来检查。这个概念被称为对象身份。 -
对象相等性——如果对象具有相同的由
a.equals(Object b)方法定义的状态,则它们是相等的。没有显式重写此方法的类继承由java.lang.Object定义的实现,该实现通过==比较对象身份。这个概念被称为 对象相等性。你可能还记得,对象相等性的属性是自反性、对称性和传递性。它们暗示的一件事是,如果a == b,那么a.equals(b)和b.equals(a)都应该是真的。 -
数据库身份——存储在关系数据库中的对象如果它们共享相同的表和主键值,则被认为是相同的。这个概念映射到 Java 空间中,被称为 数据库身份。
我们现在需要探讨数据库身份与对象身份之间的关系,以及我们如何在映射元数据中表达数据库身份。作为一个例子,你将映射一个领域模型中的实体。
5.2.2 第一个实体类及其映射
@Entity 注解不足以映射一个持久化类。你还需要一个 @Id 注解,如以下列表所示(源代码请参阅 generator 文件夹)。
注意:要执行源代码中的示例,你首先需要运行 Ch05.sql 脚本。
列表 5.1 带有标识属性映射的 Item 实体类
Path: Ch05/generator/src/main/java/com/manning/javapersistence/ch05/model
➥ /Item.java
@Entity
\1 Item {
@Id
@GeneratedValue(generator = "ID_GENERATOR")
private Long id;
public Long getId() {
return id;
}
}
这是一个最基本的实体类,使用 @Entity 注解标记为“持久化能力”,并为数据库标识属性提供了一个 @Id 映射。该类默认映射到数据库模式中的名为 ITEM 的表。
每个实体类都必须有一个 @Id 属性;这是 JPA 向应用程序暴露数据库身份的方式。在我们的图中,我们没有展示标识属性,但我们假设每个实体类都有一个。在我们的示例中,我们总是将标识属性命名为 id。这对于你的项目来说是一个好的实践;为所有你的领域模型实体类使用相同的标识属性名称。如果你没有指定其他内容,这个属性将映射到数据库模式中的表的主键列 ID。
Hibernate 和 Spring Data JPA 将使用该字段在加载和存储项目时访问标识属性值,而不是 getter 或 setter 方法。因为 @Id 在字段上,Hibernate 或 Spring Data JPA 默认将类的每个字段都启用为持久化属性。JPA 的规则是:如果 @Id 在字段上,JPA 提供商将直接访问类的字段,并默认将所有字段视为持久状态的一部分。根据我们的经验,字段访问通常比使用访问器更好,因为它为你提供了更多访问器方法设计的自由度。
你应该有一个公共获取器方法来获取标识属性吗?应用程序通常将数据库标识符用作特定实例的方便处理方式,即使在持久化层之外也是如此。例如,对于网络应用程序来说,将搜索结果以摘要列表的形式显示给用户是很常见的。当用户选择特定元素时,应用程序可能需要检索所选项目,并且通常使用标识符查找来达到这个目的——你可能已经以这种方式使用标识符,即使在依赖于 JDBC 的应用程序中也是如此。
你应该有一个设置器方法吗?主键的值永远不会改变,因此你不应该允许标识属性值被修改。Hibernate 和 Spring Data JPA 使用 Hibernate 作为提供者时不会更新主键列,你不应该在实体上公开标识符设置器方法。
标识属性属性的 Java 类型,如前例中的 java.lang.Long,取决于 ITEM 表中的主键列类型以及键值的生成方式。这引出了 @GeneratedValue 注解,以及主键的一般概念。
5.2.3 选择主键
实体的数据库标识符映射到表的主键,因此我们首先了解一下主键的背景,不必担心映射问题。退一步想想,你是如何识别实体的。
候选键 是一个或一组列,你可以用它来识别表中的特定行。要成为主键,候选键必须满足以下要求:
-
任何候选键列的值永远不会为空。你不能用未知的数据来识别某物,关系模型中没有空值。一些 SQL 产品允许你定义(组合)主键,其中包含可空列,因此你必须小心。
-
候选键列(或列)的值对于任何行都是唯一的。
-
候选键列(或列)的值永远不会改变;它是不可变的。
主键必须是不可变的吗?
关系模型要求候选键必须是唯一的且不可约的(键属性子集没有唯一性属性)。除此之外,选择候选键作为 主键 是一个品味问题。但 Hibernate 和 Spring Data JPA 预期候选键在用作主键时是不可变的。Hibernate 和 Spring Data JPA 使用 Hibernate 作为提供者时不支持通过 API 更新主键值;如果你试图绕过这个要求,你会在 Hibernate 的缓存和脏检查引擎中遇到问题。如果你的数据库模式依赖于可更新的主键(并且可能使用 ON UPDATE CASCADE 外键约束),你必须在该模式与 Hibernate 或 Spring Data JPA 使用 Hibernate 作为提供者之前更改该模式。
如果一个表只有一个标识属性,那么根据定义,它就是主键。但几个列或列的组合可能满足特定表的这些属性;您可以在候选键之间进行选择,以决定表的最佳主键。如果它们的值确实是唯一的(但可能不是不可变的),那么您应该在数据库中将未选择的候选键声明为唯一键。
许多遗留的 SQL 数据模型使用自然主键。自然键是一个具有业务意义的键:一个或多个属性的组合,其唯一性源于其业务语义。自然键的例子包括美国的社保号码和澳大利亚的税号。区分自然键很简单:如果候选键属性在数据库上下文之外有意义,那么它就是一个自然键,无论它是否是自动生成的。考虑应用程序的用户:如果他们在讨论和操作应用程序时引用键属性,那么它就是一个自然键:“你能把 A23-abc 项目的图片发给我吗?”
经验表明,自然主键通常最终会引发问题。一个好的主键必须是唯一的、不可变的且永不为空。很少有实体属性满足这些要求,而且一些满足这些要求的属性可能无法被 SQL 数据库有效地索引(尽管这是一个实现细节,不应成为决定或反对特定键的因素)。您还应该确保候选键的定义在整个数据库生命周期中永不改变。更改主键的值(甚至定义)以及所有引用它的外键是一个令人沮丧的任务。预期您的数据库架构将生存数十年,即使您的应用程序不会。
此外,您通常只能通过组合一个复合自然键的几个列来找到自然候选键。这些复合键,尽管对于某些架构工件(如多对多关系中的链接表)肯定适用,但可能会使维护、即席查询和架构演变变得更加困难。
由于这些原因,我们强烈建议您添加合成标识符,也称为代理键。代理键没有业务意义——它们是由数据库或应用程序生成的唯一值。理想情况下,应用程序用户不会看到或引用这些键值;它们是系统内部的一部分。在没有候选键的常见情况下,引入代理键列也是合适的。换句话说,您架构中的几乎每个表都应该有一个专门的代理主键列,仅为此目的。
存在几种生成代理键值的方法。前面提到的@GeneratedValue注解就是如何配置这个的。
5.2.4 配置键生成器
@Id注解是必需的,用于标记实体类的标识符属性。如果没有紧挨着@GeneratedValue,JPA 提供者会假设你在保存实例之前会负责创建和分配标识符值。我们称这种标识符为应用程序分配的标识符。当你处理遗留数据库或自然主键时,手动分配实体标识符是必要的。
通常,你希望在保存实体实例时让系统生成主键值,所以你可以将@GeneratedValue注解放在@Id旁边。JPA 使用javax.persistence.GenerationType枚举标准化了几个值生成策略,你可以通过@GeneratedValue(strategy = ...)来选择:
-
GenerationType.AUTO—Hibernate(或使用 Hibernate 作为持久化提供者的 Spring Data JPA)会选择一个合适的策略,询问配置的数据库的 SQL 方言什么是最合适的。这相当于没有设置任何内容的@GeneratedValue()。 -
GenerationType.SEQUENCE—Hibernate(或使用 Hibernate 作为持久化提供者的 Spring Data JPA)期望(如果你使用工具,则会创建)一个名为HIBERNATE_SEQUENCE的序列在你的数据库中。在每次INSERT之前,将单独调用序列,生成顺序数值。 -
GenerationType.IDENTITY—Hibernate(或使用 Hibernate 作为持久化提供者的 Spring Data JPA)期望(并在表 DDL 中创建)一个特殊的自增主键列,该列在数据库中INSERT时自动生成一个数值。 -
GenerationType.TABLE—Hibernate(或使用 Hibernate 作为持久化提供者的 Spring Data JPA)将在你的数据库模式中使用一个额外的表,该表包含下一个数值主键值,每个实体类有一行。在INSERT之前,将读取和更新此表。默认表名为HIBERNATE_SEQUENCES,包含SEQUENCE_NAME和NEXT_VALUE列。
虽然AUTO看起来很方便,但有时你需要对 ID 的创建有更多的控制,所以通常你应该明确配置一个主键生成策略。大多数应用程序使用数据库序列,但你可能想自定义数据库序列的名称和其他设置。因此,而不是选择 JPA 策略之一,你可以使用@GeneratedValue(generator="ID_GENERATOR")将标识符映射,如列表 5.1 所示。这是一个命名的标识符生成器;你现在可以独立于你的实体类设置ID_GENERATOR配置。
JPA 有两个内置的注解可以用来配置命名生成器:@javax.persistence.SequenceGenerator和@javax.persistence.TableGenerator。使用这些注解,你可以创建一个具有自己的序列和表名的命名生成器。与 JPA 注解的常规用法一样,不幸的是,你只能在(可能为空的)类的顶部使用它们,而不能在package-info.java文件中使用。
由于这个原因,并且因为 JPA 注解没有给你提供访问完整的 Hibernate 功能集,我们更喜欢使用原生的@org.hibernate.annotations.GenericGenerator注解作为替代。它支持所有 Hibernate 标识生成策略及其配置细节。与相对有限的 JPA 注解不同,你可以在package-info.java文件中使用 Hibernate 注解,通常与你的领域模型类在同一个包中。以下列表显示了一个推荐的配置,这个配置也可以在generator文件夹中找到。
列表 5.2 Hibernate 标识生成器配置为包级元数据
Path: Ch05/generator/src/main/java/com/manning/javapersistence/ch05
➥ /package-info.java
@org.hibernate.annotations.GenericGenerator(
name = "ID_GENERATOR",
strategy = "enhanced-sequence", Ⓐ
parameters = {
@org.hibernate.annotations.Parameter(
name = "sequence_name", Ⓑ
value = "JPWHSD_SEQUENCE"
),
@org.hibernate.annotations.Parameter(
name = "initial_value", Ⓒ
value = "1000"
)
})
Ⓐ enhanced-sequence策略产生顺序数字值。如果你的 SQL 方言支持序列,Hibernate(或使用 Hibernate 作为持久化提供者的 Spring Data JPA)将使用实际的数据库序列。如果你的数据库管理系统不支持原生序列,Hibernate(或使用 Hibernate 作为持久化提供者的 Spring Data JPA)将管理并使用一个额外的“序列表”,模拟序列的行为。这为你提供了真正的可移植性:生成器总是在执行 SQL INSERT之前被调用,与例如自动递增的标识列不同,后者在INSERT时产生一个值,之后必须将其返回给应用程序。
Ⓑ 你可以配置sequence_name。Hibernate(或使用 Hibernate 作为持久化提供者的 Spring Data JPA)将使用现有的序列或在你自动生成 SQL 模式时创建一个。如果你的数据库管理系统不支持序列,这将是一个特殊的“序列表”名称。
Ⓒ 你可以从一个initial_value开始,这为你提供了测试数据的空间。例如,当你的集成测试运行时,Hibernate(或使用 Hibernate 作为持久化提供者的 Spring Data JPA)将使用测试代码中的标识值大于 1,000 的新数据插入。你想要在测试之前导入的任何测试数据可以使用 1 到 999 的数字,你可以在测试中引用稳定的标识值:“加载 id 为 123 的项目并对其运行一些测试。”这适用于 Hibernate(或使用 Hibernate 作为持久化提供者的 Spring Data JPA)生成 SQL 模式和序列;这是一个 DDL 选项。
你可以在所有领域模型类之间共享相同的数据库序列。在所有实体类中指定@GeneratedValue(generator="ID_GENERATOR")没有任何害处。对于特定实体,主键值不连续没有关系,只要它们在表中是唯一的。
最后,你可以在实体类中将java.lang.Long用作标识符属性的类型,这完美映射到数值数据库序列生成器。你也可以使用原始类型long。主要区别在于新项目在数据库中未存储时someItem.getId()返回的值:要么是null,要么是0。如果你想测试一个项目是否为新项目,对null的检查可能更容易让其他人阅读你的代码时理解。你不应该使用其他整型,如int或short作为标识符。虽然它们可能工作一段时间(甚至可能几年),但随着数据库大小的增长,你可能会受到它们的范围的限制。如果你以每毫秒生成一个新标识符且没有空缺的方式生成标识符,Integer将适用于大约两个月,而Long将适用于大约 3 亿年。
虽然对于大多数应用来说这是推荐的,但如列表 5.2 所示,enhanced-sequence策略只是 Hibernate 内置的策略之一。关键生成器的配置不了解使用它的框架,程序员永远不会管理主键的值。这是在框架层面完成的。代码将类似于列表 5.3 和 5.4。
列表 5.3 使用 Hibernate JPA 持久化具有生成主键的Item
Path: Ch05/generator/src/test/java/com/manning/javapersistence/ch05
➥ /HelloWorldJPATest.java
em.getTransaction().begin();
Item item = new Item();
item.setName("Some Item");
item.setAuctionEnd(Helper.tomorrow());
em.persist(item);
em.getTransaction().commit();
列表 5.4 使用 Spring Data JPA 持久化具有生成主键的Item
Path: Ch05/generator/src/test/java/com/manning/javapersistence/ch05
➥ /HelloWorldSpringDataJPATest.java
Item item = new Item();
item.setName("Some Item");
item.setAuctionEnd(Helper.tomorrow());
itemRepository.save(item);
在运行任何 Hibernate JPA 或 Spring Data JPA 程序后,将在数据库中插入一个新的ITEM,其id为 1000,这是生成器指定的第一个值(图 5.5)。下一次插入要生成的值保留在JPWHSD_SEQUENCE中(图 5.6)。

图 5.5 插入具有生成主键的行后ITEM表的内容

图 5.6 JPWHSD_SEQUENCE保留的下一个生成值
5.2.5 标识符生成策略
使用 Hibernate 作为提供者的 Hibernate 和 Spring Data JPA 提供了几种标识符生成策略,我们将在本节中列出并讨论它们。我们不会讨论已弃用的生成策略。
如果你现在不想阅读整个列表,请启用GenerationType.AUTO并检查 Hibernate 为你数据库方言默认的设置。它很可能是sequence或identity——这是不错的选择,但可能不是最有效或最便携的选择。如果你需要一致、便携的行为以及INSERT之前可用的标识符值,请使用enhanced-sequence,如前节所示。这是一个便携、灵活且现代的策略,同时也为大数据集提供了各种优化器。
在INSERT之前或之后生成标识符:有什么区别?
一个 ORM 服务试图优化 SQL INSERT,例如通过在 JDBC 级别批量处理多个INSERT。因此,SQL 执行尽可能晚地发生在工作单元期间,而不是在您调用entityManager.persist(someItem)时。这仅仅是将插入排队以供稍后执行,并在可能的情况下分配标识符值。然而,如果您现在调用someItem.getId(),如果引擎在INSERT之前未能生成标识符,您可能会得到null。
通常,我们更喜欢在INSERT之前独立生成标识符值的pre-insert生成策略。一个常见的选择是使用共享且可并发访问的数据库序列。自增列、列默认值和触发器生成的键仅在INSERT之后才可用。
在我们讨论标识符生成策略的完整列表之前,以下是对这些策略的建议:
-
通常,优先选择在
INSERT之前独立生成标识符值的预插入生成策略。 -
使用
enhanced-sequence,当数据库支持时使用原生数据库序列,否则退回到使用一个带有单列和单行的额外数据库表,模拟序列。
以下列表概述了 Hibernate 的标识符生成策略及其选项,以及我们的使用建议。我们还讨论了每个标准 JPA 策略与其原生 Hibernate 等效策略之间的关系。由于 Hibernate 是自然增长的,现在有两套标准策略和原生策略之间的映射;我们在列表中将它们称为*旧*和*新*。您可以通过在persistence.xml文件中的hibernate .id.new_generator_mappings设置来切换此映射。默认值为true,这意味着使用新映射。软件并不像酒那样越陈越香。
-
native—此选项根据配置的 SQL 方言自动选择策略,例如sequence或identity。您必须查看在persistence.xml中配置的 SQL 方言的 Javadoc(甚至源代码)以确定将选择哪种策略。这与旧映射中的 JPAGenerationType.AUTO等效。 -
sequence—此策略使用名为HIBERNATE_ SEQUENCE的原生数据库序列。在每次新行的INSERT之前调用序列。您可以自定义序列名称并提供额外的 DDL 设置;请参阅org.hibernate.id.SequenceGenerator类的 Javadoc。 -
enhanced-sequence—这种策略在支持原生数据库序列时使用原生数据库序列;否则,它将回退到使用一个包含单个列和行的额外数据库表,模拟序列(默认表名为HIBERNATE_ SEQUENCE)。使用此策略始终在INSERT之前调用数据库“序列”,无论 DBMS 是否支持真实序列,都能提供相同的行为。此策略还支持org.hibernate.id.enhanced .Optimizer以避免在每次INSERT之前击中数据库,并且默认不进行优化,并为每次INSERT获取新值。这与启用新映射的 JPAGenerationType.SEQUENCE和GenerationType.AUTO等效,可能是内置策略中最好的选择。有关所有参数,请参阅org.hibernate.id.enhanced .SequenceStyleGenerator类的 Javadoc。 -
enhanced-table—这种策略使用一个名为HIBERNATE_ SEQUENCES的额外表,默认情况下有一个行表示序列并存储下一个值。当需要生成标识值时,将选择并更新此值。您可以配置此生成器使用多行,每行代表一个生成器(请参阅org.hibernate.id.enhanced .TableGenerator的 Javadoc)。这与启用新映射的 JPAGenerationType.TABLE等效。它取代了过时但类似的org.hibernate .id.MultipleHiLoPerTableGenerator,它是 JPAGenerationType.TABLE的老映射。 -
identity—这种策略支持 DB2、MySQL、MS SQL Server 和 Sybase 中的IDENTITY和自动增长列。主键列的标识值将在插入行时生成。它没有选项。不幸的是,由于 Hibernate 代码中的一个怪癖,您不能在@GenericGenerator中配置此策略。DDL 生成将不包括主键列的标识或自动增长选项。唯一使用它的方法是使用 JPAGenerationType.IDENTITY和旧或新映射,使其成为GenerationType.IDENTITY的默认选项。 -
increment—在 Hibernate 启动时,此策略读取每个实体表的最大的(数值)主键列值,并在每次插入新行时将值增加一。如果非聚集的 Hibernate 应用程序具有对数据库的独占访问权限,则此策略特别有效,但在任何其他场景中不要使用它。 -
select—使用此策略,Hibernate 不会在INSERT语句中生成键值或包含主键列。Hibernate 预期数据库管理系统在插入时为该列分配一个值(模式中的默认值或触发器提供的值)。Hibernate 然后在插入后通过SELECT查询检索主键列。所需的参数是key,命名数据库标识符属性(如id)的SELECT。此策略效率不高,并且仅应与无法直接返回生成键的老旧 JDBC 驱动程序一起使用。 -
uuid2—此策略在应用层生成一个唯一的 128 位 UUID。当您需要在数据库之间具有全局唯一标识符时很有用(例如,如果您每晚在批量运行中将来自几个不同生产数据库的数据合并到存档中)。UUID 可以编码为实体类中的java.lang.String、byte[16]或java.util.UUID属性。这取代了传统的uuid和uuid.hex策略。您可以通过org.hibernate.id.UUIDGenerationStrategy配置它;有关org.hibernate.id.UUIDGenerator类的更多详细信息,请参阅 Javadoc。 -
guid—此策略使用数据库生成的全局唯一标识符,在 Oracle、Ingres、MS SQL Server 和 MySQL 上有可用的 SQL 函数。Hibernate 在INSERT之前调用数据库函数。值映射到java.lang.String标识符属性。如果您需要完全控制标识符生成,请使用实现org.hibernate.id.IdentityGenerator接口的类的完全限定名称配置@GenericGenerator策略。
如果您还没有这样做,请继续为您的领域模型中的实体类添加标识符属性。确保您不要在业务逻辑之外暴露标识符,例如通过 API——此标识符没有业务逻辑意义,它仅与持久性相关。
在完成每个实体及其标识符属性的基本映射后,您可以继续映射实体的值类型属性。我们将在下一章中讨论值类型映射。不过,首先,请继续阅读一些可以简化并增强您的类映射的特殊选项。
5.3 实体映射选项
您现在已使用 @Entity 映射了一个持久化类,并使用默认值设置了所有其他设置,例如映射的 SQL 表名。现在,我们将探讨一些类级别选项以及您如何控制它们:
-
命名默认值和策略
-
动态 SQL 生成
-
实体可变性
这些是选项。如果您愿意,现在可以跳过这一部分,稍后再回来处理具体问题时再回来。
5.3.1 控制名称
让我们谈谈实体类和表的命名。如果你只在持久化能力类上指定 @Entity,默认映射的表名将与类名相同。例如,Java 实体类 Item 映射到 ITEM 表。名为 BidItem 的 Java 实体类将映射到 BID_ITEM 表(在这里,驼峰式命名将被转换为蛇形命名)。你可以使用 JPA 的 @Table 注解覆盖表名,如下所示。(请参阅 mapping 文件夹以获取源代码。)
注意:我们用 UPPERCASE 写 SQL 艺术品名称,以便更容易区分——SQL 实际上是大小写不敏感的。
列表 5.5 使用 @Table 注解覆盖映射的表名
Path: Ch05/mapping/src/main/java/com/manning/javapersistence/ch05/model
➥ /User.java
@Entity
@Table(name = "USERS")
public class User {
// . . .
}
User 实体将映射到 USER 表,但在大多数 SQL 数据库管理系统(DBMS)中,这是一个保留关键字,因此你不能有这样一个名称的表。相反,我们将其映射到 USERS。如果你的数据库布局需要这些作为命名前缀,@javax.persistence.Table 注解也有 catalog 和 schema 选项。
如果你真的需要,引用允许你使用保留的 SQL 名称,甚至可以处理大小写敏感的名称。
引用 SQL 标识符
有时,特别是在旧数据库中,你会遇到包含奇怪字符或空白的标识符,或者你希望强制执行大小写敏感性。或者,就像前面的示例一样,类或属性的自动映射可能需要一个保留关键字的表或列名。Hibernate 和使用 Hibernate 作为提供者的 Spring Data JPA 通过配置的数据库方言知道你的 DBMS 的保留关键字,并且它们可以在生成 SQL 时自动在这些字符串周围添加引号。你可以在持久化单元配置中使用 hibernate .auto_quote_ keyword=true 启用此自动引用。如果你使用的是较旧的 Hibernate 版本,或者你发现方言的信息不完整,你必须仍然在你的映射中手动应用引号,如果与关键字冲突。
如果你使用反引号在你的映射中引用表或列名,Hibernate 总是在生成的 SQL 中引用此标识符。这仍然在 Hibernate 的最新版本中有效,但 JPA 2.0 将此功能标准化为 delimited identifiers,即双引号。
这是仅适用于 Hibernate 的使用反引号的引用,修改了前面的示例:
@Table(name = "`USER`")
要符合 JPA 规范,你还需要在字符串中转义引号:
@Table(name = "\"USER\"")
无论哪种方式,对于使用 Hibernate 作为提供者的 Hibernate 和 Spring Data JPA 都可以正常工作。它知道你方言的本地引号字符,并相应地生成 SQL:[USER] 用于 MS SQL Server,'USER' 用于 MySQL,"USER" 用于 H2 等。
如果你必须引用 所有 SQL 标识符,创建一个 orm.xml 文件,并在其 <persistence-unit-defaults> 部分添加设置 <delimited-identifiers/>,就像你在列表 3.8 中看到的那样。Hibernate 然后在所有地方强制执行引用标识符。
在可能的情况下,你应该考虑重命名具有保留关键字名称的表或列。如果你必须手动正确地引用和转义所有内容,那么在 SQL 控制台中编写临时 SQL 查询会很困难。此外,你还应该避免为也通过 Hibernate、JPA 或 Spring Data(例如,用于报告)以外的其他方式访问的数据库使用引号标识符。在(复杂)报告查询中必须使用分隔符来标识所有标识符,这真的很痛苦。
接下来,让我们看看当遇到对数据库表和列名称有严格约定的组织时,Hibernate 和 Spring Data JPA(以 Hibernate 作为提供者)如何提供帮助。
实现命名约定
Hibernate 提供了一个功能,允许你自动强制执行命名标准。假设 CaveatEmptor 中所有表名都应该遵循 CE_<table name> 的模式。一种解决方案是手动在所有实体类上指定 @Table 注解,但这种方法既耗时又容易忘记。相反,你可以实现 Hibernate 的 PhysicalNamingStrategy 接口或覆盖现有的实现,如下所示。
列表 5.6 使用 PhysicalNamingStrategy 覆盖默认命名约定
Path: Ch05/mapping/src/main/java/com/manning/javapersistence/ch05
➥ /CENamingStrategy.java
public class CENamingStrategy extends PhysicalNamingStrategyStandardImpl {
@Override
public Identifier toPhysicalTableName(Identifier name,
JdbcEnvironment context) {
return new Identifier("CE_" + name.getText(), name.isQuoted());
}
}
覆盖的 toPhysicalTableName() 方法将 CE_ 前缀添加到你的模式中所有生成的表名。查看 PhysicalNamingStrategy 接口的 Javadoc;它提供了用于自定义命名列、序列和其他实体的方法。
你必须启用命名策略实现。在 Hibernate JPA 中,这通过 persistence.xml 完成:
Path: Ch05/mapping/src/main/resources/META-INF/persistence.xml
<persistence-unit name="ch05.mapping">
...
<properties>
...
<property name="hibernate.physical_naming_strategy"
value="com.manning.javapersistence.ch05.CENamingStrategy"/>
</properties>
</persistence-unit>
使用 Spring Data JPA 并以 Hibernate 作为持久化提供者时,这通过 LocalContainerEntityManagerFactoryBean 配置来完成:
Path: Ch05/mapping/src/test/java/com/manning/javapersistence/ch05
➥ /configuration/SpringDataConfiguration.java
properties.put("hibernate.physical_naming_strategy",
CENamingStrategy.class.getName());
现在我们快速看一下另一个相关的问题,即查询实体的命名。
为查询命名实体
默认情况下,所有实体名称都会自动导入查询引擎的命名空间。换句话说,你可以在 JPA 查询字符串中使用不带包前缀的简短类名,这很方便:
Path: Ch05/generator/src/test/java/com/manning/javapersistence/ch05
➥ /HelloWorldJPATest.java
List<Item> items = em.createQuery("select i from Item i",
➥ Item.class).getResultList();
这仅在你在持久化单元中有一个 Item 类时才有效。如果你在另一个包中添加另一个 Item 类,如果你想继续在查询中使用简短形式,你应该为 JPA 重命名其中一个:
package my.other.model;
@javax.persistence.Entity(name = "AuctionItem")
public class Item {
// . . .
}
对于 my.other.model 包中的 Item 类,简短的查询形式现在是 select i from AuctionItem i。因此,你解决了另一个包中另一个 Item 类的命名冲突。当然,你始终可以使用带有包前缀的完全限定长名称。
这完成了我们对命名选项的浏览。接下来,我们将讨论 Hibernate 和 Spring Data JPA 使用 Hibernate 生成包含这些名称的 SQL 的方法。
5.3.2 动态 SQL 生成
默认情况下,Hibernate 和使用 Hibernate 作为提供者的 Spring Data JPA 在启动时创建持久化单元时为每个持久化类创建 SQL 语句。这些语句是简单的创建、读取、更新和删除(CRUD)操作,用于读取单行、删除单行等。与每次在运行时执行此类简单查询时生成 SQL 字符串相比,创建和缓存它们更便宜。此外,如果语句较少,JDBC 层的预处理语句缓存也更为高效。
Hibernate 如何在启动时创建 UPDATE 语句?毕竟,此时并不知道要更新的列。答案是生成的 SQL 语句更新所有列,如果特定列的值没有被修改,则语句将其设置为旧值。
在某些情况下,例如具有数百列的遗留表,即使是最简单的操作(例如仅需要更新一列时)的 SQL 语句也会很大,你应该禁用此启动 SQL 生成,并切换到在运行时动态生成的语句。大量实体也可能影响启动时间,因为 Hibernate 必须预先生成所有 CRUD 的 SQL 语句。如果必须为数千个实体缓存数十个语句,此查询语句缓存的内存消耗也会很高。这在内存限制的虚拟环境中或低功耗设备上可能是一个问题。
要禁用启动时生成 INSERT 和 UPDATE SQL 语句,你需要使用本机 Hibernate 注解:
@Entity
@org.hibernate.annotations.DynamicInsert
@org.hibernate.annotations.DynamicUpdate
public class Item {
// . . .
}
通过启用动态插入和更新,你告诉 Hibernate 在需要时而不是一开始就生成 SQL 字符串。UPDATE 语句将只包含具有更新值的列,而 INSERT 语句将只包含非空列。
5.3.3 使实体不可变
特定类的实例可能是不可变的。例如,在 CaveatEmptor 中,为物品出价的一个 Bid 是不可变的。因此,Hibernate 或使用 Hibernate 作为提供者的 Spring Data JPA 永远不需要在 BID 表上执行 UPDATE 语句。Hibernate 还可以执行一些其他优化,例如,如果你映射了一个不可变类,可以避免脏检查,如下一个示例所示。这里 Bid 类是不可变的,实例永远不会被修改:
@Entity
@org.hibernate.annotations.Immutable
public class Bid {
// . . .
}
如果类的任何属性都没有公开的设置器方法,则 POJO 是不可变的——所有值都在构造函数中设置。Hibernate 或使用 Hibernate 作为提供者的 Spring Data JPA 应在加载和存储实例时直接访问字段。我们在此章中之前已经讨论过这一点:如果 @Id 注解在字段上,Hibernate 将直接访问字段,你可以自由设计你的获取器和设置器方法。此外,请记住,并非所有框架都支持没有设置器方法的 POJO。
当你无法在数据库模式中创建视图时,你可以将不可变实体类映射到 SQL SELECT 查询。
5.3.4 将实体映射到子查询
有时你的数据库管理员可能不允许你更改数据库模式。甚至添加一个新的视图也可能不可能。假设你想要创建一个包含拍卖 Item 的标识符和为此项进行的出价数量的视图(请参阅 subselect 文件夹中的源代码)。使用 Hibernate 注解,你可以创建一个应用程序级别的视图,一个映射到 SQL SELECT 的只读实体类:
Path: Ch05/subselect/src/main/java/com/manning/javapersistence/ch05/model
➥ /ItemBidSummary.java
@Entity
@org.hibernate.annotations.Immutable
@org.hibernate.annotations.Subselect(
value = "select i.ID as ITEMID, i.NAME as NAME, " +
"count(b.ID) as NUMBEROFBIDS " +
"from ITEM i left outer join BID b on i.ID = b.ITEM_ID " +
"group by i.ID, i.NAME"
)
@org.hibernate.annotations.Synchronize({"ITEM", "BID"})
public class ItemBidSummary {
@Id
private Long itemId;
private String name;
private long numberOfBids;
public ItemBidSummary() {
}
// Getter methods . . .
// . . .
}
你应该在 @org.hibernate.annotations.Synchronize 注解中列出你 SELECT 中引用的所有表名。然后框架将知道在执行针对 ItemBidSummary 的查询之前,它必须刷新 Item 和 Bid 实例的修改。如果有尚未持久化到数据库但可能影响查询的内存中修改,Hibernate(或作为提供者的 Spring Data JPA)会检测到这一点,并在执行查询之前刷新更改。否则,结果可能是一个过时的状态。由于 ItemBidSummary 类上没有 @Table 注解,框架不知道在执行查询之前何时必须自动刷新。@org.hibernate.annotations.Synchronize 注解表明框架需要在执行查询之前刷新 ITEM 和 BID 表。
使用 Hibernate JPA 中的只读 ItemBidSummary 实体类将看起来像这样:
Path: Ch05/subselect/src/test/java/com/manning/javapersistence/ch05
➥ /ItemBidSummaryTest.java
TypedQuery<ItemBidSummary> query =
em.createQuery("select ibs from ItemBidSummary ibs where
➥ ibs.itemId = :id",
ItemBidSummary.class);
ItemBidSummary itemBidSummary =
query.setParameter("id", 1000L).getSingleResult();
要使用 Spring Data JPA 中的只读 ItemBidSummary 实体类,你首先需要引入一个新的 Spring Data 仓库:
Path: Ch05/mapping/src/main/java/com/manning/javapersistence/ch05
➥ /repositories/ItemBidSummaryRepository.java
public interface ItemBidSummaryRepository extends
CrudRepository<ItemBidSummary, Long> {
}
仓库将有效使用如下:
Path: Ch05/subselect/src/test/java/com/manning/javapersistence/ch05
➥ /ItemBidSummarySpringDataTest.java
Optional<ItemBidSummary> itemBidSummary =
itemBidSummaryRepository.findById(1000L);
摘要
-
实体是系统更粗粒度的类。它们的实例具有独立的生命周期和自己的标识,并且许多其他实例可以引用它们。
-
值类型依赖于特定的实体类。值类型实例绑定到其所属的实体实例,并且只有一个实体实例可以引用它——它没有单独的标识。
-
Java 标识符、对象相等性和数据库标识符是不同的概念:前两个适用于面向对象的世界,最后一个适用于关系数据库世界。
-
一个好的主键永远不会为空,是唯一的,并且永远不会改变。
-
主键生成器可以使用不同的策略进行配置。
-
你可以使用来自 Hibernate JPA 和 Spring Data JPA 的实体、映射选项和命名策略。
6 映射值类型
本章涵盖
-
映射基本属性
-
映射可嵌入组件
-
控制 Java 和 SQL 类型之间的映射
在上一章几乎完全专注于实体及其类和身份映射选项之后,我们现在将关注各种形式的价值类型。在开发中的类中经常遇到值类型。我们将值类型分为两类:随 JDK 一起提供的基值类型类,如 String、Date、原始类型及其包装器;以及开发者定义的值类型类,如 CaveatEmptor 中的 Address 和 MonetaryAmount。
在本章中,我们首先将使用 JDK 类型映射持久化属性,并讨论基本映射注解。我们将查看如何处理属性的各个方面:覆盖默认值、自定义访问和生成值。我们还将看到 SQL 如何与派生属性和转换列值一起使用。我们将处理基本属性、时间属性和枚举映射。
然后,我们将检查自定义值类型类并将它们作为可嵌入组件进行映射。我们将查看类如何与数据库模式相关联,并使类可嵌入,同时允许覆盖嵌入属性。通过映射嵌套组件,我们将完成对可嵌入组件的考察。最后,我们将分析如何使用灵活的 JPA 转换器在较低级别自定义属性值的加载和存储,这些转换器是每个 JPA 提供商的标准扩展点。
JPA 2 中的主要新功能
JPA 2.2 支持 Java 8 日期和时间 API。不再需要使用如 @Temporal 这样的额外映射注解,以前需要用这些注解来标注 java.util.Date 类型的字段。
6.1 映射基本属性
映射是 ORM 技术的核心。它连接了面向对象世界和关系世界。当我们映射一个持久化类,无论是实体还是可嵌入类型(更多内容请参考第 6.2 节),所有属性默认都被视为持久化。
这些是持久化类属性的默认 JPA 规则:
-
如果属性是原始类型或原始类型包装器,或为
String、BigInteger、BigDecimal、java.time.LocalDateTime、java.time.LocalDate、java.time.LocalTime、java.util.Date、java.util.Calendar、java.sql.Date、java.sql.Time、java.sql.Timestamp、byte[]、Byte[]、char[]或Character[]类型,则它自动持久化。Hibernate 或使用 Hibernate 的 Spring Data JPA 会将属性的值加载和存储在具有适当 SQL 类型且与属性同名的列中。 -
否则,如果我们将属性的类标注为
@Embeddable,或者将属性本身映射为@Embedded,则该属性将被映射为拥有类的嵌入组件。我们将在本章后面分析组件的嵌入,当我们查看 CaveatEmptor 的Address和MonetaryAmount嵌入类时。 -
否则,如果属性的类型是
java.io.Serializable,其值将存储在其序列化形式中。这可能会引起兼容性问题(我们可能使用一种类格式存储信息,而希望稍后使用另一种类格式检索它)以及性能问题(序列化/反序列化操作成本高昂)。我们应该始终映射 Java 类,而不是在数据库中存储一系列字节。当应用程序可能在几年后消失时,维护包含这种二进制信息的数据库意味着映射到序列化版本的类将不再可用。 -
否则,启动时将抛出异常,抱怨属性的类型无法理解。
这种 异常配置 方法意味着我们不必注解属性来使其持久化;我们只需在异常情况下配置映射即可。JPA 中有多个注解可用于自定义和控制基本属性映射。
6.1.1 覆盖基本属性默认值
我们可能不希望实体类的所有属性都持久化。那么哪些信息应该持久化,哪些不应该?例如,虽然有一个持久的 Item#initialPrice 属性是有意义的,但如果我们在运行时仅计算和使用其值,则 Item#totalPriceIncludingTax 属性不应该在数据库中持久化。要排除一个属性,请使用注解 @javax.persistence.Transient 标记属性的字段或 getter 方法,或者使用 Java 的 transient 关键字。transient 关键字既排除 Java 序列化,也排除持久化,因为它也被 JPA 提供者识别。@javax.persistence.Transient 注解将仅排除字段不被持久化。
要决定一个属性是否应该持久化,请自问以下问题:这是一个塑造实例的基本属性吗?我们是否从一开始就需要它,还是将基于其他属性计算它?在一段时间后重建信息有意义吗,或者信息将不再重要?这是敏感信息,我们宁愿避免持久化以防止稍后泄露(例如明文密码)吗?这是在其他环境中没有意义的信息(例如在另一个网络中无意义的本地 IP 地址)吗?
我们稍后会回到在字段或 getter 方法上放置注释的位置。首先,让我们假设,就像我们之前做的那样,Hibernate 或使用 Hibernate 的 Spring Data JPA 将直接访问字段,因为这些字段上已经放置了@Id。因此,所有其他 JPA 和 Hibernate 映射注释也都在字段上。
注意:要执行源代码中的示例,您首先需要运行 Ch06.sql 脚本。源代码可以在mapping-value-types文件夹中找到。
在我们的 CaveatEmptor 应用程序中,我们的目标不仅是处理程序中的持久化逻辑,还要构建灵活且易于更改的代码。如果我们不想依赖于属性映射默认值,我们可以将@Basic注释应用于特定属性,例如Item的initialPrice:
@Basic(optional = false)
BigDecimal initialPrice;
这个注释没有提供很多替代方案。它只有两个参数:optional和fetch。当我们探索第 12.1 节中的优化策略时,我们将讨论fetch选项。这里显示的选项optional将属性标记为在 Java 对象级别上不是可选的。
默认情况下,所有持久化属性都是可空的和可选的,这意味着Item可能有一个未知的initialPrice。如果要在 SQL 模式中的INITIALPRICE列上有一个NOT NULL约束,将initialPrice属性映射为非可选的就有意义。生成的 SQL 模式将自动为非可选属性包含一个NOT NULL约束。
现在,如果应用程序尝试存储一个未在initialPrice字段上设置值的Item,在将 SQL 语句发送到数据库之前,将会抛出一个异常。为了执行INSERT或UPDATE,initialPrice字段需要一个值。如果我们没有将initialPrice属性标记为可选,并尝试保存一个NULL值,数据库将拒绝 SQL 语句,并抛出一个约束违反异常。
除了@Basic之外,我们还可以使用@Column注释来声明可空性:
@Column(nullable = false)
BigDecimal initialPrice;
我们现在已经展示了三种声明属性值是否必需的方法:使用@Basic注释、使用@Column注释,以及之前使用 Bean Validation 的@NotNull注释(在第 3.3.2 节中)。所有这些对 JPA 提供者都有相同的效果:在保存时执行null检查,并在数据库模式中生成NOT NULL约束。我们建议使用 Bean Validation 的@NotNull注释,这样您就可以手动验证Item实例,并让您的用户界面代码在表示层自动执行验证检查。最终结果没有太大差异,但避免使用失败的语句访问数据库会更干净。
@Column注释也可以覆盖属性名到数据库列的映射:
@Column(name = "START_PRICE", nullable = false)
BigDecimal initialPrice;
@Column 注解有几个其他参数,其中大多数控制 SQL 级别的细节,例如 catalog 和 schema 名称。它们很少需要,我们仅在本书中在必要时演示它们。
属性注解不一定位于字段上,我们可能不希望 JPA 提供者直接访问字段。让我们看看如何自定义属性访问。
6.1.2 自定义属性访问
持久化引擎通过字段直接或通过 getter 和 setter 方法间接访问类的属性。我们现在将尝试回答“我们应该如何访问每个持久属性?”的问题。注解实体从必需的 @Id 注解的位置继承默认值。例如,如果我们在一个字段上声明 @Id,而不是使用 getter 方法,那么该实体的所有其他映射注解都应预期为字段。注解不支持在 setter 方法上。
默认访问策略不仅适用于单个实体类。任何 @Embedded 类都继承其拥有根实体类的默认或显式声明的访问策略。我们将在本章后面介绍嵌入式组件。此外,任何 @MappedSuperclass 属性都使用默认或显式声明的访问策略来访问映射的实体类。继承是第七章的主题。
JPA 规范提供了 @Access 注解来覆盖默认行为,使用参数 AccessType.FIELD(通过字段访问)和 AccessType.PROPERTY(通过 getter)。当你在类或实体级别设置 @Access 时,类的所有属性都将根据所选策略进行访问。任何其他映射注解,包括 @Id,都可以设置在字段或 getter 方法上。
我们还可以使用 @Access 注解来覆盖单个属性的访问策略,如下例所示。请注意,其他映射注解(如 @Column)的位置没有改变——只有运行时实例的访问方式发生了变化。
列表 6.1 覆盖 name 属性的访问策略
Path: Ch06/mapping-value-types/src/main/java/com/manning/javapersistence
➥ /ch06/model/Item.java
@Entity
public class Item {
@Id Ⓐ
@GeneratedValue(generator = "ID_GENERATOR") Ⓐ
private Long id; Ⓐ
@Access(AccessType.PROPERTY) Ⓑ
@Column(name = "ITEM_NAME") Ⓑ
private String name; Ⓑ
public String getName() { Ⓒ
return name; Ⓒ
} Ⓒ
public void setName(String name) { Ⓒ
this.name = Ⓒ
!name.startsWith("AUCTION: ") ? "AUCTION: " + name : name; Ⓒ
} Ⓒ
}
Ⓐ Item 实体默认为字段访问。@Id 注解位于字段上。
Ⓑ 在 name 字段上的 @Access(AccessType.PROPERTY) 设置将此特定属性切换为在运行时通过 getter/setter 由 JPA 提供者访问。
Ⓒ 使用 Hibernate 或 Spring Data JPA 的 Hibernate 在加载和存储项目时调用 getName() 和 setName()。
现在反过来:如果实体(默认或显式)的访问类型是通过属性 getter 和 setter 方法,则 getter 方法上的 @Access(AccessType.FIELD) 将告诉 Hibernate 或使用 Hibernate 的 Spring Data JPA 直接访问字段。所有其他映射信息仍然必须位于 getter 方法上,而不是字段上。
一些属性没有映射到列。特别是,派生属性(如计算字段)从 SQL 表达式中获取其值。
6.1.3 使用派生属性
我们现在已经来到了派生属性——由其他属性产生的属性。派生属性的值在运行时通过使用 @org.hibernate.annotations.Formula 注解声明的 SQL 表达式进行计算,如下一列表所示。
列表 6.2 两个只读派生属性
Path: Ch06/mapping-value-types/src/main/java/com/manning/javapersistence
➥ /ch06/model/Item.java
@Formula(
"CONCAT(SUBSTR(DESCRIPTION, 1, 12), '...')"
)
private String shortDescription;
@Formula(
"(SELECT AVG(B.AMOUNT) FROM BID B WHERE B.ITEM_ID = ID)"
)
private BigDecimal averageBidAmount;
每次从数据库检索 Item 实体时都会评估 SQL 公式,而不是在其他任何时候,因此如果其他属性被修改,结果可能会过时。这些属性永远不会出现在 SQL INSERT 或 UPDATE 中,只出现在 SELECTs 中。评估发生在数据库中;SQL 公式在加载实例时嵌入到 SELECT 子句中。
SQL 公式可以引用数据库表的列,它们可以调用特定的数据库 SQL 函数,甚至可以包括 SQL 子查询。在上一个示例中,调用了 SUBSTR() 和 CONCAT() 函数。
SQL 表达式原样传递给底层数据库。依赖于特定数据库产品的特定运算符或关键字可能会将映射元数据绑定到特定的数据库产品。例如,前一个列表中的 CONCAT() 函数是针对 MySQL 的,因此您应该意识到可移植性可能会受到影响。请注意,未指定列名引用的是派生属性所属的类表的列。
Hibernate 还支持一种称为 列转换器 的公式变体,允许您为读取 和 写入属性值编写自定义 SQL 表达式。让我们调查这种功能。
6.1.4 转换列值
现在我们来处理在面向对象系统和关系系统中具有不同表示的信息。假设一个数据库有一个名为 IMPERIALWEIGHT 的列,存储 Item 的重量(磅)。然而,应用程序有一个 Item#metricWeight 属性,以千克为单位,因此我们必须在从 读取 和写入 ITEM 表时转换数据库列的值。我们可以通过 Hibernate 扩展来实现这一点:@org.hibernate.annotations.ColumnTransformer 注解。
列表 6.3 使用 SQL 表达式转换列值
Path: Ch06/mapping-value-types/src/main/java/com/manning/javapersistence
➥ /ch06/model/Item.java
@Column(name = "IMPERIALWEIGHT")
@ColumnTransformer(
read = "IMPERIALWEIGHT / 2.20462",
write = "? * 2.20462"
)
private double metricWeight;
当从 ITEM 表中读取一行时,Hibernate 或使用 Hibernate 的 Spring Data JPA 会嵌入表达式 IMPERIALWEIGHT / 2.20462,因此计算发生在数据库中,并且度量值作为结果返回到应用层。对于写入列,Hibernate 或使用 Hibernate 的 Spring Data JPA 设置度量值在强制性的单个占位符(问号)上,SQL 表达式计算要插入或更新的实际值。
Hibernate 还在查询限制中应用列转换器。例如,以下列表中的查询检索所有重量为 2 千克的项。
列表 6.4 在查询限制中应用列转换器
Path: Ch06/mapping-value-types/src/test/java/com/manning/javapersistence
➥ /ch06/MappingValuesJPATest.java
List<Item> result =
em.createQuery("SELECT i FROM Item i WHERE i.metricWeight = :w")
.setParameter("w", 2.0)
.getResultList();
实际执行此查询的 SQL 语句在 WHERE 子句中包含以下限制:
// . . .
where
i.IMPERIALWEIGHT / 2.20462=?
注意,数据库可能无法依赖于索引来执行此限制;将执行全表扫描,因为必须计算所有 ITEM 行的权重以评估限制。
另一种特殊的属性依赖于数据库生成的值。
6.1.5 生成和默认属性值
数据库有时会生成属性值,这通常发生在我们第一次插入行时。数据库生成的值的例子包括创建时间戳、项目的默认价格或每次修改时运行的触发器。
通常,Hibernate(或使用 Hibernate 的 Spring Data JPA)应用程序需要刷新包含数据库在保存后生成值的属性的实例。这意味着应用程序必须再次往返数据库以读取插入或更新行后的值。然而,将属性标记为生成值后,应用程序可以将此责任委托给 Hibernate 或使用 Hibernate 的 Spring Data JPA。本质上,每当为声明了生成属性的实体发出 SQL INSERT 或 UPDATE 语句时,SQL 会立即执行一个 SELECT 语句来检索生成的值。
我们使用 @org.hibernate.annotations.Generated 注解来标记生成属性。对于时间属性,我们使用 @CreationTimestamp 和 @UpdateTimestamp 注解。@CreationTimestamp 注解用于标记 createdOn 属性。这告诉 Hibernate 或使用 Hibernate 的 Spring Data JPA 自动生成属性值。在这种情况下,值在实体实例插入数据库之前设置为当前日期。另一个类似的内置注解是 @UpdateTimestamp,它在实体实例更新时自动生成属性值。
列表 6.5 数据库生成的属性值
Path: Ch06/mapping-value-types/src/main/java/com/manning/javapersistence
➥ /ch06/model/Item.java
@CreationTimestamp
private LocalDate createdOn;
@UpdateTimestamp
private LocalDateTime lastModified;
@Column(insertable = false)
@ColumnDefault("1.00")
@Generated(
org.hibernate.annotations.GenerationTime.INSERT
)
private BigDecimal initialPrice;
GenerationTime 枚举的可用设置是 ALWAYS 和 INSERT。使用 GenerationTime.ALWAYS,Hibernate 或使用 Hibernate 的 Spring Data JPA 在每次 SQL UPDATE 或 INSERT 之后刷新实体实例。使用 GenerationTime.INSERT,刷新仅在 SQL INSERT 之后发生,以检索数据库提供的默认值。我们还可以将 initialPrice 属性映射为不可插入的。@ColumnDefault 注解设置列的默认值,当 Hibernate 或使用 Hibernate 的 Spring Data JPA 导出并生成 SQL 模式 DDL 时。
时间戳通常由数据库自动生成,如前例所示,或者由应用程序生成。只要我们使用 JPA 2.2 和 Java 8 的 LocalDate、LocalDateTime 和 LocalTime 类,我们就不需要使用 @Temporal 注解。java.time 包中的枚举 Java 8 类本身就包含时间精度:日期、日期和时间,或者仅时间。让我们看看您可能仍然会遇到的使用 @Temporal 注解的情况。
6.1.6 @Temporal 注解
JPA 规范允许您使用 @Temporal 注解来注解时间属性,以声明映射列的 SQL 数据类型的精度。Java 8 之前的 Java 时间类型包括 java.util.Date、java.util.Calendar、java.sql.Date、java.sql.Time 和 java.sql.Timestamp。以下列表提供了一个使用 @Temporal 注解的示例。
列表 6.6 必须使用 @Temporal 注解的时间类型属性
@CreationTimestamp
@Temporal(TemporalType.DATE)
private Date createdOn;
@UpdateTimestamp
@Temporal(TemporalType.TIMESTAMP)
private Date lastModified;
可用的 TemporalType 选项是 DATE、TIME 和 TIMESTAMP,确定时间值应存储在数据库中的哪一部分。如果没有 @Temporal 注解,默认为 TemporalType.TIMESTAMP。
另一种特殊属性类型由枚举表示。
6.1.7 映射枚举
枚举类型 是一种常见的 Java 习惯用法,其中类具有一个(小量)不可变的实例常量。例如,在 CaveatEmptor 中,我们可以将此应用于具有有限类型的拍卖:
Path: Ch06/mapping-value-types/src/main/java/com/manning/javapersistence
➥ /ch06/model/AuctionType.java
public enum AuctionType {
HIGHEST_BID,
LOWEST_BID,
FIXED_PRICE
}
我们现在可以为每个 Item 设置适当的 auctionType:
Path: Ch06/mapping-value-types/src/main/java/com/manning/javapersistence
➥ /ch06/model/Item.java
@NotNull
@Enumerated(EnumType.STRING)
private AuctionType auctionType = AuctionType.HIGHEST_BID;
没有使用 @Enumerated 注解,Hibernate 或使用 Hibernate 的 Spring Data JPA 会存储值的 ORDINAL 位置。也就是说,它会为 HIGHEST_BID 存储 1,为 LOWEST_BID 存储 2,为 FIXED_PRICE 存储 3。这是一个脆弱的默认值;如果您更改 AuctionType 枚举并添加一个新实例,现有值可能不再映射到相同的位置并破坏应用程序。因此,EnumType.STRING 选项是一个更好的选择;使用 Hibernate 的 Hibernate 或 Spring Data JPA 可以按原样存储枚举值的标签。
这完成了我们对基本属性及其映射选项的浏览。到目前为止,我们已经看到了 JDK 提供的类型(如 String、Date 和 BigDecimal)的属性。领域模型还有自定义值类型类——那些在 UML 图中有组合关联的类。
6.2 映射可嵌入组件
我们领域模型中的映射类到目前为止都是实体类,每个都有自己的生命周期和标识。然而,User 类与 Address 类有一种特殊的关联方式,如图 6.1 所示。

图 6.1 User 和 Address 的组合
在对象建模术语中,这种关联是一种 聚合——一种 部分 关系。聚合是一种关联形式,但它有关对象生命周期的某些附加语义。在这种情况下,我们有一种更强的形式,组合,其中部分的生命周期完全依赖于整体的生命周期。Address 对象不能在没有 User 对象的情况下存在,因此 UML 中的组合类,如 Address,通常是对象/关系映射的候选值类型。
6.2.1 数据库架构
我们可以使用 Address 作为值类型(与 String 或 BigDecimal 相同的语义)和 User 作为实体来映射这种组合关系。目标 SQL 架构如图 6.2 所示。

图 6.2 组件的列嵌入在实体表中。
对于 User 实体,只有一个映射表 USERS。此表嵌入所有组件的详细信息,其中一行包含特定的 User 以及他们的 homeAddress 和 billingAddress。如果另一个实体引用了 Address(例如 Shipment#deliveryAddress),则 SHIPMENT 表也将包含存储 Address 所需的所有列。
此架构反映了值类型语义:特定的 Address 无法共享;它没有自己的标识。其主键是拥有实体的映射数据库标识符。嵌入组件具有依赖的生命周期:当拥有实体的实例被保存时,组件实例也被保存。当拥有实体的实例被删除时,组件实例也被删除。为此不需要执行特殊的 SQL;所有数据都在一行中。
“类多于表”是支持细粒度领域模型的方式。让我们为这个结构编写类和映射。
6.2.2 使类可嵌入
Java 没有组合的概念——一个类或属性不能被标记为组件。组件和实体之间的唯一区别是数据库标识符:组件类没有单独的标识,因此组件类不需要标识符属性或标识符映射。它是一个简单的 POJO,如下所示。
列表 6.7 Address 类:一个可嵌入的组件
Path: Ch06/mapping-value-types/src/main/java/com/manning/javapersistence
➥ /ch06/model/Address.java
@Embeddable Ⓐ
public class Address {
@NotNull Ⓑ
@Column(nullable = false) Ⓒ
private String street;
@NotNull
@Column(nullable = false, length = 5) Ⓓ
private String zipcode;
@NotNull Ⓔ
@Column(nullable = false) Ⓔ
private String city; Ⓔ
public Address() { Ⓕ
}
public Address(String street, String zipcode, String city) { Ⓖ
this.street = street;
this.zipcode = zipcode;
this.city = city;
}
//getters and setters
}
Ⓐ 与 @Entity 不同,这个组件 POJO 被标记为 @Embeddable。它没有标识符属性。
Ⓑ @NotNull 注解在 DDL 生成中被忽略。
Ⓒ 使用 @Column(nullable=false) 用于 DDL 生成。
Ⓓ @Column 注解的长度参数将覆盖默认的列生成,作为 VARCHAR(255)。
Ⓔ city 列的默认类型将是 VARCHAR(255)。
Ⓕ 使用 Hibernate 或 Spring Data JPA 的 Hibernate 调用这个无参数构造函数来创建实例,然后直接填充字段。
Ⓖ 为了方便,我们可以有额外的(公共)构造函数。
在前面的列表中,嵌入类的属性默认都是持久的,就像持久实体类的属性一样。可以使用相同的注解配置属性映射,例如@Column或@Basic。Address类的属性映射到STREET、ZIPCODE和CITY列,并且它们被约束为NOT NULL。这就是整个映射。
问题:Hibernate Validator 不会生成NOT NULL约束
在撰写本文时,Hibernate Validator 仍然存在一个开放缺陷:当生成数据库模式时,Hibernate 不会将@NotNull约束映射到NOT NULL约束。Hibernate 仅在运行时使用@NotNull对组件属性进行 Bean Validation。我们必须使用@Column(nullable = false)映射属性以在模式中生成约束。Hibernate 缺陷数据库正在跟踪此问题,编号为 HVAL-3(见mng.bz/lR0R)。
User实体没有特别之处。
列表 6.8 包含对Address引用的User类
Path: Ch06/mapping-value-types/src/main/java/com/manning/javapersistence
➥ /ch06/model/User.java
@Entity
@Table(name = "USERS")
public class User {
@Id
@GeneratedValue(generator = Constants.ID_GENERATOR)
private Long id;
private Address homeAddress; Ⓐ
// . . .
}
Ⓐ Address是@Embeddable,因此这里不需要注解。
在前面的列表中,Hibernate 或使用 Hibernate 的 Spring Data 检测到Address类被注解为@Embeddable;STREET、ZIPCODE和CITY列映射到USERS表,即拥有实体的表。
当我们在本章前面讨论属性访问时,我们提到可嵌入组件从其所属实体继承其访问策略。这意味着 Hibernate 或使用 Hibernate 的 Spring Data 将以与User属性相同的策略访问Address类的属性。这种继承也影响了可嵌入组件类中映射注解的位置。规则如下:
-
如果嵌入组件的拥有
@Entity使用字段访问进行映射,无论是通过字段上的@Id隐式映射还是通过类上的@Access(AccessType.FIELD)显式映射,都期望嵌入组件类的所有映射注解位于组件类的字段上。期望在Address类的字段上使用注解,并且字段在运行时直接读取和写入。Address上的 getter 和 setter 方法可选。 -
如果嵌入组件的拥有
@Entity使用属性访问进行映射,无论是通过 getter 方法上的@Id隐式映射还是通过类上的@Access(AccessType.PROPERTY)显式映射,都期望嵌入组件类的所有映射注解位于组件类的 getter 方法上。值通过调用嵌入组件类上的 getter 和 setter 方法进行读取和写入。 -
如果拥有实体类的嵌入属性(如列表 6.8 中的
User#homeAddress)被标记为@Access(AccessType.FIELD),则预期在Address类的字段上使用注解,并且字段在运行时被访问。 -
如果拥有实体类的嵌入属性——列表 6.8 中的
User#homeAddress——被标记为@Access(AccessType.PROPERTY),则期望在Address类的 getter 方法上有注解,并且运行时使用 getter 和 setter 方法进行访问。 -
如果
@Access注解了可嵌入的类本身,所选策略将被用于读取嵌入类上的映射注解和运行时访问。
让我们现在比较基于字段和基于属性的访问。为什么你应该使用其中一个?
-
基于字段的访问——当你使用基于字段的访问时,你可以省略不应公开的字段的 getter 方法。此外,字段在单行上声明,而访问器方法分散在多行上,因此基于字段的访问会使代码更易读。
-
基于属性的访问——访问器方法可以执行额外的逻辑。如果你希望在持久化对象时发生这种情况,你可以使用基于属性的访问。如果持久化想要避免这些额外操作,你可以使用基于字段的访问。
还有一件事要记住:没有优雅的方式来表示对Address的null引用。考虑如果STREET、ZIPCODE和CITY列是可空的会发生什么。如果你加载一个没有任何地址信息的User,someUser.getHomeAddress()应该返回什么?在这种情况下,将返回null。Hibernate 或使用 Hibernate 的 Spring Data 也将null嵌入属性作为组件映射的所有列中的NULL值存储。因此,如果你存储一个具有“空”Address(Address实例存在,但所有属性都是null)的User,在加载User时将不会返回Address实例。这可能是不直观的;你可能根本不应该有可空列,并避免三重逻辑,因为你很可能希望你的用户有一个实际的地址。
我们应该重写Address类的equals()和hashCode()方法,通过值来比较实例。然而,只要我们不需要比较实例,例如将它们放入HashSet中,这并不是至关重要的事情。我们将在第 8.2.1 节中讨论这个问题的集合上下文。
在一个现实场景中,用户可能为不同的目的拥有不同的地址。图 6.1 展示了User和Address之间的额外组合关系:billingAddress。
6.2.3 重写嵌入属性
billingAddress是User类中另一个需要使用的嵌入组件属性,因此必须在USERS表中存储另一个Address。这创建了一个映射冲突:到目前为止,我们只有STREET、ZIPCODE和CITY列在模式中存储一个Address。
我们需要额外的列来存储每个USERS行上的另一个Address。当我们映射billingAddress时,我们可以重写列名。
列表 6.9 重写列名
Path: Ch06/mapping-value-types/src/main/java/com/manning/javapersistence
➥ /ch06/model/User.java
@Entity
@Table(name = "USERS")
public class User {
@Embedded Ⓐ
@AttributeOverride(name = "street", Ⓑ
column = @Column(name = "BILLING_STREET")) Ⓑ
@AttributeOverride(name = "zipcode", Ⓑ
column = @Column(name = "BILLING_ZIPCODE", length = 5)) Ⓑ
@AttributeOverride(name = "city", Ⓑ
column = @Column(name = "BILLING_CITY")) Ⓑ
private Address billingAddress;
public Address getBillingAddress() {
return billingAddress;
}
public void setBillingAddress(Address billingAddress) {
this.billingAddress = billingAddress;
}
// . . .
}
Ⓐ billingAddress 字段被标记为嵌入。实际上,@Embedded 注解并不是必需的。你可以在拥有实体类中的组件类或属性上标记(同时使用两者不会造成伤害,但也不会提供任何优势)。当你想映射一个没有源代码、没有注解但使用正确的 getter 和 setter 方法(如常规 JavaBeans)的第三方组件类时,@Embedded 注解是有用的。
Ⓑ 可重复的 @AttributeOverride 注解有选择地覆盖了嵌入类的属性映射。在这个例子中,我们覆盖了所有三个属性并提供了不同的列名。现在我们可以在 USERS 表中存储两个 Address 实例,每个实例在不同的列集中(再次查看图 6.2 中的模式)。
对于组件属性的每个 @AttributeOverride 注解都是“完整的”;任何在覆盖属性上的 JPA 或 Hibernate 注解都将被忽略。这意味着 Address 类上的 @Column 注解将被忽略,因此所有 BILLING_* 列都是 NULL 可选的!(尽管 Bean Validation 仍然识别组件属性上的 @NotNull 注解;只有持久化注解被覆盖。)
我们将创建两个 Spring Data JPA 仓库接口以与数据库交互。UserRepository 接口仅扩展 CrudRepository,它将继承此接口的所有方法。它通过 User 和 Long 进行泛型化,因为它管理具有 Long ID 的 User 实体。
列表 6.10 UserRepository 接口
Path: Ch06/mapping-value-types/src/main/java/com/manning/javapersistence
➥ /ch06/repositories/UserRepository.java
public interface UserRepository extends CrudRepository<User, Long> {
}
ItemRepository 接口扩展 CrudRepository,它将继承此接口的所有方法。此外,它声明了 findByMetricWeight 方法,遵循 Spring Data JPA 命名约定。它通过 Item 和 Long 进行泛型化,因为它管理具有 Long ID 的 Item 实体。
列表 6.11 ItemRepository 接口
Path: Ch06/mapping-value-types/src/main/java/com/manning/javapersistence
➥ /ch06/repositories/ItemRepository.java
public interface ItemRepository extends CrudRepository<Item, Long> {
Iterable<Item> findByMetricWeight(double weight);
}
我们将使用 Spring Data JPA 框架测试我们编写的代码的功能,如下所示。本书的源代码还包含使用 JPA 和 Hibernate 的测试代码替代方案。
列表 6.12 测试持久化代码的功能
Path: Ch06/mapping-value-types/src/test/java/com/manning/javapersistence
➥ /ch06/MappingValuesSpringDataJPATest.java
@ExtendWith(SpringExtension.class) Ⓐ
@ContextConfiguration(classes = {SpringDataConfiguration.class}) Ⓑ
public class MappingValuesSpringDataJPATest {
@Autowired Ⓒ
private UserRepository userRepository; Ⓒ
@Autowired Ⓓ
private ItemRepository itemRepository; Ⓓ
@Test
void storeLoadEntities() {
User user = new User(); Ⓔ
user.setUsername("username"); Ⓔ
user.setHomeAddress(new Address("Flowers Street", Ⓔ
"12345", "Boston")); Ⓔ
userRepository.save(user); Ⓕ
Item item = new Item(); Ⓖ
item.setName("Some Item"); Ⓖ
item.setMetricWeight(2); Ⓖ
item.setDescription("descriptiondescription"); Ⓖ
itemRepository.save(item); Ⓗ
List<User> users = (List<User>) userRepository.findAll(); Ⓘ
List<Item> items = (List<Item>) Ⓙ
itemRepository.findByMetricWeight(2.0); Ⓙ
assertAll(
() -> assertEquals(1, users.size()), Ⓚ
() -> assertEquals("username", users.get(0).getUsername()), Ⓛ
() -> assertEquals("Flowers Street", Ⓜ
users.get(0).getHomeAddress().getStreet()), Ⓜ
() -> assertEquals("12345", Ⓝ
users.get(0).getHomeAddress().getZipcode()), Ⓝ
() -> assertEquals("Boston", Ⓞ
users.get(0).getHomeAddress().getCity()), Ⓞ
() -> assertEquals(1, items.size()), Ⓟ
() -> assertEquals("AUCTION: Some Item", Ⓠ
items.get(0).getName()), Ⓠ
() -> assertEquals("descriptiondescription", Ⓡ
items.get(0).getDescription()), Ⓡ
() -> assertEquals(AuctionType.HIGHEST_BID, Ⓢ
items.get(0).getAuctionType()), Ⓢ
() -> assertEquals("descriptiond...", Ⓣ
items.get(0).getShortDescription()), Ⓣ
() -> assertEquals(2.0, items.get(0).getMetricWeight()), Ⓤ
() -> assertEquals(LocalDate.now(),
items.get(0).getCreatedOn()), Ⓥ
() -> Ⓥ
assertTrue(ChronoUnit.SECONDS.between( Ⓦ
LocalDateTime.now(), Ⓦ
items.get(0).getLastModified()) < 1), Ⓦ
() -> assertEquals(new BigDecimal("1.00"), Ⓧ
items.get(0).getInitialPrice()) Ⓧ
);
}
}
Ⓐ 我们使用 SpringExtension 扩展测试。这个扩展用于将 Spring 测试上下文与 JUnit 5 Jupiter 测试集成。
Ⓑ 使用 SpringDataConfiguration 类中定义的 bean 配置 Spring 测试上下文。
Ⓒ 通过自动装配,Spring 将 UserRepository 实例注入。
Ⓓ 通过自动装配,Spring 将 ItemRepository 实例注入。这是可能的,因为 com.manning.javapersistence.ch06.repositories 包(其中包含 UserRepository 和 ItemRepository)被用作 SpringDataConfiguration 类上 @EnableJpaRepositories 注解的参数。你可以回顾第二章以刷新 SpringDataConfiguration 类的外观。
Ⓔ 创建并设置一个用户。
Ⓕ 将其保存到仓库中。
Ⓖ 创建并设置一个项目。
Ⓗ 保存到仓库中。
Ⓘ 获取所有用户的列表。
Ⓙ 获取具有公制 2.0 的项目的列表。
Ⓚ 检查用户列表的大小。
Ⓛ 检查名称。
Ⓜ 检查街道地址。
Ⓝ 检查 ZIP 代码。
Ⓞ 检查列表中第一个用户的城市。
Ⓟ 检查项目列表的大小。
Ⓠ 检查第一个项目的名称。
Ⓡ 检查其描述。
Ⓢ 检查拍卖类型。
Ⓣ 检查简短描述。
Ⓤ 检查其公制重量。
Ⓥ 检查创建日期。
Ⓦ 检查最后修改日期和时间。
Ⓧ 检查列表中第一个项目的初始价格。最后修改日期和时间与当前日期和时间进行比较,以确保它在 1 秒之内(考虑到检索延迟)。
之前列出的领域模型可以通过嵌套嵌入组件进一步提高可重用性并变得更加精细。
6.2.4 映射嵌套嵌入组件
让我们考虑 Address 类及其如何封装地址细节;而不是简单地有一个 city 字符串,我们可以将此细节移动到一个新的 City 可嵌入类中。修订后的领域模型图如图 6.3 所示。针对映射的 SQL 模式仍然只有一个 USERS 表,如图 6.4 所示。接下来的源代码(列表 6.13 和 6.14)可以在 mapping-value-types2 文件夹中找到。

图 6.3 Address 和 City 的嵌套组合

图 6.4 嵌入列包含 Address 和 City 的细节。
可嵌入类可以有一个嵌入属性,Address 有一个 city 属性。
列表 6.13 带有 city 属性的 Address 类
Path: Ch06/mapping-value-types2/src/main/java/com/manning/javapersistence
➥ /ch06/model/Address.java
@Embeddable
public class Address {
@NotNull
@Column(nullable = false)
private String street;
@NotNull
@AttributeOverride(
name = "name",
column = @Column(name = "CITY", nullable = false)
)
private City city;
// . . .
}
我们将只使用基本属性创建可嵌入的 City 类。
列表 6.14 可嵌入的 City 类
Path: Ch06/mapping-value-types2/src/main/java/com/manning/javapersistence
➥ /ch06/model/City.java
@Embeddable
public class City {
@NotNull
@Column(nullable = false, length = 5)
private String zipcode;
@NotNull
@Column(nullable = false)
private String name;
@NotNull
@Column(nullable = false)
private String country;
// . . .
}
我们可以通过创建一个 Country 类等来继续这种嵌套。所有嵌入属性,无论它们在组合中的深度如何,都映射到拥有实体的表的列,这里是指 USERS 表。
City 类的 name 属性映射到 CITY 列。这可以通过在 Address 中使用 @AttributeOverride(如所示)或根实体类 User 中的覆盖来实现。可以通过点符号引用嵌套属性;例如,在 User#address 上,@AttributeOverride(name="city.name") 引用了 Address#city#name 属性。
我们将在第 8.2 节中回到嵌入组件,我们将探讨映射组件集合和使用从组件到实体的引用。
在本章开头,我们分析了基本属性以及 Hibernate 或 Spring Data JPA 如何使用 Hibernate 将 JDK 类型(如 java.lang.String)映射到适当的 SQL 类型。让我们更深入地了解这个类型系统以及值是如何在较低级别转换的。
6.3 使用转换器映射 Java 和 SQL 类型
到目前为止,我们假设 Hibernate 或使用 Hibernate 的 Spring Data JPA 会在我们映射java.lang.String属性时选择正确的 SQL 类型。但 Java 和 SQL 类型之间的正确映射是什么,我们如何控制它?随着我们深入具体细节,我们将构建这些类型之间的对应关系。
6.3.1 内置类型
任何 JPA 提供者都必须支持一组 Java 到 SQL 类型转换的最小集合。Hibernate 和 Spring Data JPA 使用 Hibernate 支持所有这些映射,以及一些在实践中有用的非标准适配器。首先,让我们看看 Java 原语及其 SQL 等效类型。
原始和数值类型
表 6.1 中显示的内置类型将 Java 原语及其包装器映射到适当的 SQL 标准类型。我们还包含了一些其他数值类型。名称列中的名称是 Hibernate 特定的;我们将在自定义类型映射时使用它们。
表 6.1 映射到 SQL 标准类型的 Java 原语类型
| 名称 | Java 类型 | ANSI SQL 类型 |
|---|---|---|
integer |
int, java.lang.Integer |
INTEGER |
long |
long, java.lang.Long |
BIGINT |
short |
short, java.lang.Short |
SMALLINT |
float |
float, java.lang.Float |
FLOAT |
double |
double, java.lang.Double |
DOUBLE |
byte |
byte, java.lang.Byte |
TINYINT |
boolean |
boolean, java.lang.Boolean |
BOOLEAN |
big_decimal |
java.math.BigDecimal |
NUMERIC |
big_integer |
java.math.BigInteger |
NUMERIC |
你可能已经注意到你的 DBMS 产品不支持列出的某些 SQL 类型。这些 SQL 类型名称是 ANSI 标准类型名称。大多数 DBMS 供应商忽略了 SQL 标准的这一部分,通常是因为他们的遗产类型系统在标准之前。然而,JDBC 提供了对供应商特定数据类型的部分抽象,允许 Hibernate 在执行如INSERT和UPDATE之类的 DML 语句时使用 ANSI 标准类型。对于特定产品的模式生成,Hibernate 使用配置的 SQL 方言将 ANSI 标准类型转换为适当的供应商特定类型。这意味着如果我们让 Hibernate 为我们创建模式,我们通常不需要担心 SQL 数据类型。
如果我们有一个现有的模式或者我们需要知道我们 DBMS 的原生数据类型,我们可以查看我们配置的 SQL 方言的源代码。例如,Hibernate 附带提供的H2Dialect包含从 ANSI NUMERIC类型到供应商特定DECIMAL类型的映射:registerColumnType(Types.NUMERIC, "decimal($p,$s)")。
NUMERIC SQL 类型支持小数精度和比例设置。例如,BigDecimal属性的默认精度和比例设置是NUMERIC(19,2)。要覆盖此设置以生成模式,请在属性上应用@Column注解并设置其precision和scale参数。
接下来是映射到数据库字符串类型的类型。
字符类型
表 6.2 显示了映射字符和字符串值表示的类型。
表 6.2 字符和字符串值适配器
| 名称 | Java 类型 | ANSI SQL 类型 |
|---|---|---|
string |
java.lang.String |
VARCHAR |
character |
char[], Character[], java.lang.String |
CHAR |
yes_no |
boolean, java.lang.Boolean |
CHAR(1), 'Y' 或 'N' |
true_false |
boolean, java.lang.Boolean |
CHAR(1), 'T' 或 'F' |
class |
java.lang.Class |
VARCHAR |
locale |
java.util.Locale |
VARCHAR |
timezone |
java.util.TimeZone |
VARCHAR |
currency |
java.util.Currency |
VARCHAR |
Hibernate 类型系统根据字符串值的声明长度选择一个 SQL 数据类型:如果 String 属性被注解为 @Column(length = ...) 或 Bean Validation 中的 @Length,Hibernate 将选择适合给定字符串大小的正确 SQL 数据类型。此选择还取决于配置的 SQL 方言。例如,对于 MySQL,当模式由 Hibernate 生成时,长度最多为 65,535 将产生一个常规的 VARCHAR(length) 列。对于长度最多为 16,777,215 的情况,将产生一个 MySQL 特定的 MEDIUMTEXT 数据类型,而更大的长度则使用 LONGTEXT。Hibernate 为所有 java.lang.String 属性的默认长度是 255,因此在没有进一步映射的情况下,String 属性映射到 VARCHAR(255) 列。您可以通过扩展您的 SQL 方言类来自定义此类型选择;阅读方言文档和源代码以了解您 DBMS 产品详情。
数据库通常通过为整个数据库或至少整个表设置合理的(UTF-8)默认字符集来启用文本的国际化。这是一个 DBMS 特定的设置。如果您需要更精细的控制,并希望切换到字符数据类型的本土化变体(例如,NVARCHAR、NCHAR 或 NCLOB),请使用 @org.hibernate.annotations.Nationalized. 注解属性映射。
对于具有有限类型系统的旧数据库或 DBMS(如 Oracle),也内置了一些特殊转换器。Oracle DBMS 甚至没有布尔值数据类型;关系模型所需的数据类型。因此,许多现有的 Oracle 模式使用 Y/N 或 T/F 字符表示布尔值。或者——这是 Hibernate Oracle 方言的默认设置——期望并生成类型为 NUMBER(1,0) 的列。再次提醒,如果您想了解从 ANSI 数据类型到供应商特定类型的所有映射,请参考 DBMS 的 SQL 方言。
接下来是映射到数据库中日期和时间的类型。
日期和时间类型
表 6.3 列出了与日期、时间和时间戳相关的类型。
表 6.3 日期和时间类型
| 名称 | Java 类型 | ANSI SQL 类型 |
|---|---|---|
date |
java.util.Date, java.sql.Date |
DATE |
time |
java.util.Date, java.sql.Time |
TIME |
timestamp |
java.util.Date, java.sql.Timestamp |
TIMESTAMP |
calendar |
java.util.Calendar |
TIMESTAMP |
calendar_date |
java.util.Calendar |
DATE |
duration |
java.time.Duration |
BIGINT |
instant |
java.time.Instant |
TIMESTAMP |
localdatetime |
java.time.LocalDateTime |
TIMESTAMP |
localdate |
java.time.LocalDate |
DATE |
localtime |
java.time.LocalTime |
TIME |
offsetdatetime |
java.time.OffsetDateTime |
TIMESTAMP |
offsettime |
java.time.OffsetTime |
TIME |
zoneddatetime |
java.time.ZonedDateTime |
TIMESTAMP |
在领域模型中,我们可以将日期和时间数据表示为java.util.Date、java.util.Calendar或java.sql包中定义的java.util.Date的子类,或者 Java 8 包中的 Java 8 类。此时的最佳选择是使用java.time包中的 Java 8 API。这些类可以表示一个日期、一个时间、一个带时间的日期,甚至可能包括对 UTC 时区的偏移量(OffsetDateTime和OffsetTime)。JPA 2.2 官方支持 Java 8 日期和时间类。
Hibernate 对于java.util.Date属性的行为一开始可能会让人感到惊讶:当存储一个java.util.Date时,Hibernate 在加载后不会返回一个java.util.Date。它将返回一个java.sql.Date、一个java.sql.Time或一个java.sql.Timestamp,具体取决于属性是否与TemporalType.DATE、TemporalType.TIME或TemporalType.TIMESTAMP映射。
Hibernate 在从数据库加载数据时必须使用 JDBC 子类,因为数据库类型比java.util.Date具有更高的精度。一个java.util.Date具有毫秒精度,但一个java.sql.Timestamp包含了数据库中可能存在的纳秒信息。Hibernate 不会截断这些信息以适应java.util.Date的值,这可能导致在尝试使用equals()方法比较java.util.Date值时出现问题;它与java.sql.Timestamp子类的equals()方法不对称。
在这种情况下,解决方案简单且不仅限于 Hibernate:不要调用aDate.equals(bDate)。你应该始终通过比较 Unix 时间毫秒数(假设你不在乎纳秒)来比较日期和时间:例如,如果aDate.getTime() > bDate.getTime(),则aDate是晚于bDate的时间。但请注意:像HashSet这样的集合也会调用equals()方法。不要在这样的集合中混合java.util.Date和java.sql.Date|Time|Timestamp值。
你不会在Calendar属性上遇到这类问题。当存储Calendar值时,Hibernate 将始终返回一个Calendar值,使用Calendar .getInstance()创建——实际类型取决于区域设置和时区。
或者,你可以像 6.3.2 节中展示的那样编写自己的 转换器,将 Hibernate 中的任何 java.sql 时间类型实例转换为普通的 java.util.Date 实例。如果例如 Calendar 实例在从数据库加载值后应该有一个非默认时区,自定义转换器也是一个好的起点。
如果你选择使用 Java 8 的类 LocalDate, LocalTime, LocalDateTime 来表示日期和时间数据,就像在 6.1.5 节中之前演示的那样,那么所有这些担忧都将消失。你可能仍然会遇到大量使用旧类代码的情况,你应该意识到这些类可能引发的问题。
接下来是映射到数据库中二进制数据和大型值的类型。
二进制和大型值类型
表 6.4 列出了处理二进制数据和大型值的类型。请注意,只有 binary 支持作为标识符属性的类型。
如果持久化 Java 类中的属性类型为 byte[],Hibernate 会将其映射到 VARBINARY 列。实际的 SQL 数据类型将取决于方言;例如,在 PostgreSQL 中,数据类型是 BYTEA,在 Oracle DBMS 中是 RAW。在某些方言中,使用 @Column 设置的 length 也会影响所选的本地类型;例如,Oracle 中长度为 2,000 及以上的使用 LONG RAW。在 MySQL 中,默认的 SQL 数据类型将是 TINYBLOB。根据 @Column 设置的 length,它可能是 BLOB、MEDIUMBLOB 或 LONGBLOB。
表 6.4 二进制和大型值类型
| 名称 | Java 类型 | ANSI SQL 类型 |
|---|---|---|
binary |
byte[], java.lang.Byte[] |
VARBINARY |
text |
java.lang.String |
CLOB |
clob |
java.sql.Clob |
CLOB |
blob |
java.sql.Blob |
BLOB |
serializable |
java.io.Serializable |
VARBINARY |
java.lang.String 属性映射到 SQL 的 VARCHAR 列,对于 char[] 和 Character[] 也是如此。正如我们之前讨论的,某些方言根据声明的长度注册不同的本地类型。
当包含属性变量的实体实例被加载时,Hibernate 会立即初始化属性值。当你必须处理可能的大型值时,这很不方便,所以你通常会想要覆盖这个默认映射。JPA 规范为此目的提供了一个方便的快捷注解 @Lob:
@Entity
public class Item {
@Lob
private byte[] image;
@Lob
private String description;
}
这将 byte[] 映射到 SQL 的 BLOB 数据类型,将 String 映射到 CLOB。不幸的是,你仍然无法使用这种设计实现延迟加载。Hibernate 或使用 Hibernate 的 Spring Data JPA 必须拦截字段访问,例如,当你调用 someItem.getImage() 时,加载 image 的字节。这种方法需要在编译后对类进行字节码仪器化,以注入额外的代码。我们将在 12.1.2 节中讨论通过字节码仪器化和拦截实现延迟加载。
或者,您可以在 Java 类中切换属性的类型。JDBC 直接支持大型对象(LOBs)。如果 Java 属性是 java.sql.Clob 或 java.sql.Blob,您将获得无需字节码插装的懒加载:
@Entity
public class Item {
@Lob
private java.sql.Blob imageBlob;
@Lob
private java.sql.Clob description;
}
BLOB/CLOB 是什么意思?
提出 LOB 概念的 Jim Starkey 表示,市场营销部门创造了 BLOB 和 CLOB 这些术语。BLOB 被解释为二进制大型对象:存储为单个实体的二进制数据(通常是多媒体对象——图像、视频或音频)。CLOB 表示字符大型对象——存储在单独位置中的字符数据,该表仅引用。
这些 JDBC 类包括按需加载值的操作。当拥有实体实例被加载时,属性值是一个占位符,实际值不会立即实现。一旦您访问属性,在同一个事务中,值就会被实现,甚至可以直接(发送到客户端)而不消耗临时内存:
Item item = em.find(Item.class, ITEM_ID);
InputStream imageDataStream = item.getImageBlob().getBinaryStream(); Ⓐ
ByteArrayOutputStream outStream = new ByteArrayOutputStream(); Ⓑ
StreamUtils.copy(imageDataStream, outStream); Ⓒ
byte[] imageBytes = outStream.toByteArray();
Ⓐ 直接流式传输字节。
Ⓑ 或者将它们实现到内存中。
Ⓒ org.springframework.util.StreamUtils 是一个提供处理流实用方法的类。
缺点是,领域模型随后绑定到 JDBC;在单元测试中,没有数据库连接就无法访问 LOB 属性。
要创建和设置 Blob 或 Clob 值,Hibernate 提供了一些便利方法。此示例直接从 InputStream 读取 byteLength 字节到数据库中,而不消耗临时内存:
Session session = em.unwrap(Session.class); Ⓐ
Blob blob = session.getLobHelper() Ⓑ
.createBlob(imageInputStream, byteLength);
someItem.setImageBlob(blob);
em.persist(someItem);
Ⓐ 我们需要原生 Hibernate API,因此我们必须从 EntityManager 中解包 Session。
Ⓑ 然后我们需要知道从流中读取的字节数。
最后,Hibernate 为任何 java.io.Serializable 类型的属性提供回退序列化。此映射将属性值转换为存储在 VARBINARY 列中的字节流。序列化和反序列化发生在拥有实体实例存储和加载时。自然地,您应该非常谨慎地使用此策略,因为数据比应用程序存活的时间更长。总有一天,没有人会知道数据库中那些字节的意思。序列化有时对临时数据很有用,例如用户偏好、登录会话数据等。
Hibernate 将根据属性的 Java 类型选择正确的适配器类型。如果您不喜欢默认映射,请继续阅读以覆盖它。
选择类型适配器
您在前面的章节中已经看到了许多适配器和它们的 Hibernate 名称。当覆盖 Hibernate 的默认类型选择时,请使用该名称并显式选择特定的适配器:
@Entity
public class Item {
@org.hibernate.annotations.Type(type = "yes_no")
private boolean verified = false;
}
而不是 BIT,这个 boolean 现在映射到一个包含值 Y 或 N 的 CHAR 列。
您还可以在 Hibernate 启动配置中全局覆盖适配器,使用自定义用户类型,我们将在下一节中演示如何编写:
metaBuilder.applyBasicType(new MyUserType(), new String[]{"date"});
这个设置将覆盖内置的 date 类型适配器,并将 java.util.Date 属性的值转换委托给自定义实现。
我们认为这个可扩展的类型系统是 Hibernate 的核心特性之一,也是使其如此灵活的重要方面。接下来,我们将更详细地探讨类型系统和 JPA 自定义转换器。
6.3.2 创建自定义 JPA 转换器
在在线拍卖系统中引入一个新的要求是使用多种货币,并且推出这种改变可能会很复杂。我们必须修改数据库模式,可能还需要将现有数据从旧模式迁移到新模式,并且必须更新所有访问数据库的应用程序。在本节中,我们将演示如何使用 JPA 转换器和可扩展的 Hibernate 类型系统来协助这个过程,为应用程序和数据库之间提供额外的、灵活的缓冲区。
为了支持多种货币,我们将在 CaveatEmptor 领域模型中引入一个新的类:MonetaryAmount,如下面的列表所示。
列表 6.15 不可变的 MonetaryAmount 值类型类
Path: Ch06/mapping-value-types2/src/main/java/com/manning/javapersistence
➥ /ch06/model/MonetaryAmount.java
public class MonetaryAmount implements Serializable { Ⓐ
private final BigDecimal value; Ⓑ
private final Currency currency; Ⓑ
public MonetaryAmount(BigDecimal value, Currency currency) { Ⓑ
this.value = value;
this.currency = currency;
}
public BigDecimal getValue() { Ⓑ
return value;
}
public Currency getCurrency() { Ⓑ
return currency;
}
@Override
public boolean equals(Object o) { Ⓒ
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MonetaryAmount that = (MonetaryAmount) o;
return Objects.equals(value, that.value) &&
Objects.equals(currency, that.currency);
}
public int hashCode() { Ⓒ
return Objects.hash(value, currency);
}
public String toString() { Ⓓ
return value + " " + currency;
}
public static MonetaryAmount fromString(String s) { Ⓔ
String[] split = s.split(" ");
return new MonetaryAmount(
new BigDecimal(split[0]),
Currency.getInstance(split[1])
);
}
}
Ⓐ 这个值类型的类应该是 java.io.Serializable:当 Hibernate 将实体实例数据存储在共享的二级缓存中时,它会分解实体的状态。如果一个实体有一个 MonetaryAmount 属性,属性值的序列化表示将存储在二级缓存区域中。当从缓存区域检索实体数据时,属性值将被反序列化和重新组装。
Ⓑ 该类定义了值和货币字段,一个使用这两个字段的构造函数,以及这些字段的获取器。
Ⓒ 该类实现了 equals() 和 hashCode() 方法,并通过“值”比较货币金额。
Ⓓ 该类实现了 toString() 方法。
Ⓔ 该类实现了一个静态方法,可以从 String 创建一个实例。
转换基本属性值
正如通常情况那样,数据库人员不能立即实现多种货币。他们能快速提供的是数据库模式中列数据类型的变化。
我们将向 Item 类添加 buyNowPrice 字段。
Path: Ch06/mapping-value-types/src/main/java/com/manning/javapersistence
➥ /ch06/model/Item.java
@NotNull
@Convert(converter = MonetaryAmountConverter.class)
@Column(name = "PRICE", length = 63)
private MonetaryAmount buyNowPrice;
我们将在 ITEM 表中用 VARCHAR 列存储 BUYNOWPRICE,并将货币代码附加到货币金额的字符串值上。例如,我们将存储值 11.23 USD 或 99 EUR。
我们将在存储数据时将 MonetaryAmount 实例转换为这样的 String 表示形式。当加载数据时,我们将 String 转换回 MonetaryAmount。对此的最简单解决方案是在 JPA 中实现一个标准化的扩展点,即 javax.persistence.AttributeConverter,在前面代码片段中使用的 @Convert 注解的 MonetaryAmoutConverter 类中。它将在下一个列表中展示。
列表 6.16 在字符串和 MonetaryValue 之间转换
Path: Ch06/mapping-value-types2/src/main/java/com/manning/javapersistence
➥ /ch06/converter/MonetaryAmountConverter.java
@Converter Ⓐ
public class MonetaryAmountConverter Ⓐ
implements AttributeConverter<MonetaryAmount, String> { Ⓐ
@Override Ⓑ
public String convertToDatabaseColumn(MonetaryAmount monetaryAmount) { Ⓑ
return monetaryAmount.toString(); Ⓑ
} Ⓑ
@Override Ⓒ
public MonetaryAmount convertToEntityAttribute(String s) { Ⓒ
return MonetaryAmount.fromString(s); Ⓒ
} Ⓒ
}
Ⓐ 转换器必须实现 AttributeConverter 接口;两个参数是 Java 属性的类型和数据库模式中的类型。Java 类型是 MonetaryAmount,数据库类型是 String,通常映射到 SQL VARCHAR。我们必须用 @Converter 注解该类。
Ⓑ convertToDatabaseColumn 方法将 MonetaryAmount 实体类型转换为字符串数据库列。
Ⓒ convertToEntityAttribute 方法将字符串数据库列转换为 MonetaryAmount 实体类型。
为了测试持久化代码的功能,我们将使用 Spring Data JPA 框架,如下所示。本书的源代码还包含了使用 JPA 和 Hibernate 的测试代码替代方案。
列表 6.17 测试持久化代码的功能
Path: Ch06/mapping-value-types2/src/test/java/com/manning/javapersistence
➥ /ch06/MappingValuesSpringDataJPATest.java
@ExtendWith(SpringExtension.class) Ⓐ
@ContextConfiguration(classes = {SpringDataConfiguration.class}) Ⓑ
public class MappingValuesSpringDataJPATest {
@Autowired Ⓒ
private UserRepository userRepository; Ⓒ
@Autowired Ⓓ
private ItemRepository itemRepository; Ⓓ
@Test
void storeLoadEntities() {
City city = new City(); Ⓔ
city.setName("Boston"); Ⓔ
city.setZipcode("12345"); Ⓔ
city.setCountry("USA"); Ⓔ
User user = new User(); Ⓕ
user.setUsername("username"); Ⓕ
user.setHomeAddress(new Address("Flowers Street", city)); Ⓕ
userRepository.save(user); Ⓖ
Item item = new Item(); Ⓗ
item.setName("Some Item"); Ⓗ
item.setMetricWeight(2); Ⓗ
item.setBuyNowPrice(new MonetaryAmount( Ⓗ
BigDecimal.valueOf(1.1), Currency.getInstance("USD"))); Ⓗ
item.setDescription("descriptiondescription"); Ⓗ
itemRepository.save(item); Ⓘ
List<User> users = (List<User>) userRepository.findAll(); Ⓙ
List<Item> items = (List<Item>) Ⓚ
itemRepository.findByMetricWeight(2.0); Ⓚ
assertAll(
() -> assertEquals(1, users.size()), Ⓛ
() -> assertEquals("username", users.get(0).getUsername()), Ⓜ
() -> assertEquals("Flowers Street", Ⓝ
users.get(0).getHomeAddress().getStreet()), Ⓝ
() -> assertEquals("Boston", Ⓞ
users.get(0).getHomeAddress().getCity().getName()), Ⓞ
() -> assertEquals("12345", Ⓟ
users.get(0).getHomeAddress().getCity().getZipcode()), Ⓟ
() -> assertEquals("USA", Ⓠ
users.get(0).getHomeAddress().getCity().getCountry()), Ⓠ
() -> assertEquals(1, items.size()), Ⓡ
() -> assertEquals("AUCTION: Some Item", Ⓢ
items.get(0).getName()), Ⓢ
() -> assertEquals("1.1 USD", Ⓣ
items.get(0).getBuyNowPrice().toString()), Ⓣ
() -> assertEquals("descriptiondescription", Ⓤ
items.get(0).getDescription()), Ⓤ
() -> assertEquals(AuctionType.HIGHEST_BID, Ⓥ
items.get(0).getAuctionType()), Ⓥ
() -> assertEquals("descriptiond...", Ⓦ
items.get(0).getShortDescription()), Ⓦ
() -> assertEquals(2.0, items.get(0).getMetricWeight()), Ⓧ
() -> assertEquals(LocalDate.now(), Ⓨ
items.get(0).getCreatedOn()), Ⓨ
() ->
assertTrue(ChronoUnit.SECONDS.between(
LocalDateTime.now(),
items.get(0).getLastModified()) < 1), Ⓩ
() -> assertEquals(new BigDecimal("1.00"),
items.get(0).getInitialPrice()) ⓐ
);
}
}
Ⓐ 使用 SpringExtension 扩展测试。此扩展用于将 Spring 测试上下文与 JUnit 5 Jupiter 测试集成。
Ⓑ 使用 SpringDataConfiguration 类中定义的 bean 配置 Spring 测试上下文。
Ⓒ 通过自动装配,Spring 注入了一个 UserRepository 实例。
Ⓓ 通过自动装配,Spring 注入了一个 ItemRepository 实例。这是可能的,因为 com.manning.javapersistence.ch06.repositories 包(其中包含 UserRepository 和 ItemRepository)被用作 SpringDataConfiguration 类上 @EnableJpaRepositories 注解的参数。要回忆 SpringDataConfiguration 类的样子,请参阅第二章。
Ⓜ 创建并设置一个城市。
Ⓕ 创建并设置一个用户。
Ⓖ 保存到仓库中。
Ⓗ 创建并设置一个项目。
Ⓘ 保存到仓库中。
Ⓙ 获取所有用户的列表。
Ⓚ 获取具有度量值 2.0 的项目列表。
Ⓛ 检查用户列表的大小。
Ⓜ 检查列表中第一个用户的名称。
Ⓝ 检查列表中第一个用户的街道地址。
Ⓞ 检查列表中第一个用户的所在城市。
Ⓟ 检查列表中第一个用户的邮政编码。
Ⓠ 检查列表中第一个用户的国籍。
Ⓡ 检查项目列表的大小。
Ⓢ 检查第一个项目的名称。
Ⓣ 检查其当前购买价格。
Ⓤ 检查其描述。
Ⓥ 检查拍卖类型。
Ⓦ 检查其简短描述。
Ⓧ 检查其度量重量。
Ⓨ 检查创建日期。
Ⓩ 检查列表中第一个项目的最后修改日期和时间以及初始价格。最后修改日期和时间与当前日期和时间进行比较,以确保在 1 秒内(允许有检索延迟)。
ⓐ 检查第一个项目的初始价格。
之后,当数据库管理员升级数据库模式并提供了货币金额和货币的单独列时,我们只需在应用程序的几个地方进行更改。我们将从项目中删除MonetaryAmountConverter,并将MonetaryAmount设为@Embeddable;它将自动映射到两个数据库列。如果某些表尚未升级,也可以轻松地选择性地启用和禁用转换器。
我们刚刚编写的转换器是为MonetaryAmount,这是一个新的领域模型类。转换器不仅限于自定义类——我们甚至可以覆盖 Hibernate 的内置类型适配器。例如,我们可以为领域模型中的某些或所有java.util.Date属性创建自定义转换器。
我们可以将转换器应用于实体类的属性,如列表 6.17 中的Item#buyNowPrice。我们也可以将它们应用于可嵌入类的属性。
转换组件属性
在本章中,我们一直在为细粒度领域模型进行辩护。之前,我们将User的地址信息隔离出来,并映射了可嵌入的Address类。我们将继续这个过程,并使用抽象的Zipcode类引入继承,如图 6.5 所示。随后的源代码可以在 mapping-value-types3 文件夹中找到。

图 6.5 抽象类Zipcode有两个具体子类。
Zipcode类很简单,但我们必须通过值实现相等性:
Path: Ch06/mapping-value-types3/src/main/java/com/manning/javapersistence
➥ /ch06/model/Zipcode.java
public abstract class Zipcode {
private String value;
public Zipcode(String value) {
this.value = value;
}
public String getValue() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Zipcode zipcode = (Zipcode) o;
return Objects.equals(value, zipcode.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
我们现在可以封装领域子类,德国和瑞士邮政编码之间的差异,以及任何处理:
Path: Ch06/mapping-value-types3/src/main/java/com/manning/javapersistence
➥ /ch06/model/GermanZipcode.java
public class GermanZipcode extends Zipcode {
public GermanZipcode(String value) {
super(value);
}
}
我们在子类中没有实现任何特殊处理。我们将从最明显的区别开始:德国的 ZIP 代码是五位数字长,瑞士的是四位。自定义转换器将处理这一点。
列表 6.18 ZipcodeConverter类
Path: Ch06/mapping-value-types3/src/main/java/com/manning/javapersistence
➥ /ch06/converter/ZipcodeConverter.java
@Converter
public class ZipcodeConverter
implements AttributeConverter<Zipcode, String> {
@Override Ⓐ
public String convertToDatabaseColumn(Zipcode attribute) { Ⓐ
return attribute.getValue(); Ⓐ
} Ⓐ
@Override Ⓑ
public Zipcode convertToEntityAttribute(String s) { Ⓑ
if (s.length() == 5) Ⓒ
return new GermanZipcode(s); Ⓒ
else if (s.length() == 4) Ⓓ
return new SwissZipcode(s); Ⓓ
throw new IllegalArgumentException( Ⓔ
"Unsupported zipcode in database: " + s Ⓔ
); Ⓔ
}
}
Ⓐ 当 Hibernate 存储属性值时,会调用此转换器的convertToDatabaseColumn()方法;我们返回一个字符串表示。模式中的列是VARCHAR。当加载值时,我们检查其长度,并创建一个GermanZipcode或SwissZipcode实例。这是一个自定义类型区分例程;我们可以选择给定值的 Java 类型。
Ⓑ 当 Hibernate 从数据库加载属性时,会调用此转换器的convertToEntityAttribute方法。
Ⓒ 如果字符串长度为 5,则创建一个新的GermanZipcode。
Ⓓ 如果字符串长度为 4,则创建一个新的SwissZipcode。
Ⓔ 否则,将抛出异常——数据库中的 ZIP 代码不受支持。
现在我们将此转换器应用于一些Zipcode属性,例如User的嵌入式homeAddress:
Path: Ch06/mapping-value-types3/src/main/java/com/manning/javapersistence
➥ /ch06/model/User.java
@Entity
@Table(name = "USERS")
public class User {
@Convert(
converter = ZipcodeConverter.class,
attributeName = "city.zipcode"
)
private Address homeAddress;
// . . .
}
attributeName 声明了可嵌入的 Address 类的 zipcode 属性。此设置支持属性路径的点语法;如果 zipcode 不是 Address 类的属性而是嵌套的可嵌入 City 类的属性,则通过 city.zipcode,其嵌套路径来引用。
在 JPA 2.2 中,我们可以在单个嵌入属性上应用多个 @Convert 注解来转换 Address 的多个属性。在 JPA 2.1 之前,我们必须将它们组合在单个 @Converts 注解中。我们还可以将转换器应用于集合和映射的值,如果它们的值或键是基本类型或可嵌入类型。例如,我们可以在持久的 Set<Zipcode> 上添加 @Convert 注解。我们将在第八章中演示如何使用 @ElementCollection 映射持久化集合。
对于持久化映射,@Convert 注解的 attributeName 选项有一些特殊语法:
-
在一个持久的
Map<Address, String>上,我们可以通过属性名key.zipcode为每个映射键的zipcode属性应用一个转换器。 -
在一个持久的
Map<String, Address>上,我们可以通过属性名value.zipcode为每个映射值的zipcode属性应用一个转换器。 -
在一个持久的
Map<Zipcode, String>上,我们可以通过属性名key为每个映射条目的键应用一个转换器。 -
在一个持久的
Map<String, Zipcode>上,我们可以通过不设置任何attributeName来为每个映射条目的值应用一个转换器。
如前所述,如果嵌套类是嵌套的,则属性名可以是点分隔的路径;我们可以编写 key.city.zipcode 来引用与 Address 类组合的 City 类的 zipcode 属性。
JPA 转换器的一些限制如下:
-
我们不能将它们应用于实体的标识符或版本属性。
-
我们不应该在用
@Enumerated或@Temporal映射的属性上应用转换器,因为这些注解已经声明了必须发生的转换类型。如果我们想为枚举或日期/时间属性应用自定义转换器,则不应使用@Enumerated或@Temporal注解它们。
我们将不得不稍微修改我们编写的测试代码。我们将替换这一行,
city.setZipcode("12345");
为这一行:
city.setZipcode(new GermanZipcode("12345"));
我们还将替换这一行,
() -> assertEquals("12345",
users.get(0).getHomeAddress().getCity().getZipcode())
为这一行:
() -> assertEquals("12345",
users.get(0).getHomeAddress().getCity().getZipcode().getValue())
书籍的源代码包含使用 Spring Data JPA、Hibernate 和 JPA 的这些测试。
让我们回到 CaveatEmptor 的多货币支持。数据库管理员再次更改了模式,我们现在必须更新应用程序。
6.3.3 使用 UserTypes 扩展 Hibernate
最后,数据库模式中已添加新列以支持多种货币。ITEM 表现在有 BUYNOWPRICE_AMOUNT 和一个单独的金额货币列,BUYNOWPRICE_CURRENCY。还有 INITIALPRICE_AMOUNT 和 INITIALPRICE_CURRENCY 列。我们必须将这些列映射到 Item 类的 MonetaryAmount 属性,即 buyNowPrice 和 initialPrice。
理想情况下,我们不想更改领域模型;现有的属性已经使用了MonetaryAmount类。不幸的是,标准化的 JPA 转换器不支持从或向多个列的值转换。JPA 转换器的另一个限制是与查询引擎的集成。我们无法编写以下查询:select i from Item i where i.buyNowPrice.amount > 100。多亏了上一节中的转换器,Hibernate 知道如何将MonetaryAmount转换为字符串以及从字符串转换回来。然而,它不知道MonetaryAmount有一个amount属性,因此无法解析这样的查询。
一个简单的解决方案是将MonetaryAmount映射为@Embeddable,正如你在本章前面为Address类所看到的(列表 6.13)。MonetaryAmount的每个属性——amount和currency——映射到其各自的数据库列。
然而,数据库管理员在其要求中增加了一个转折:由于其他旧应用程序也访问数据库,我们必须在将值存储到数据库之前将其转换为目标货币。例如,Item#buyNowPrice应存储为美元,而Item#initialPrice应存储为欧元。(如果这个例子看起来有些牵强,我们可以向你保证,在现实世界中你会看到更糟糕的情况。共享数据库模式的发展可能代价高昂,但当然是必要的,因为数据总是比应用程序存在的时间更长。)Hibernate 提供了一个本机转换器 API:一个允许更详细和低级定制的扩展点。
扩展点
Hibernate 的类型系统扩展接口可以在org.hibernate.usertype包中找到。以下接口可用:
-
UserType——你可以通过与原始 JDBC 的PreparedStatement(存储数据时)和ResultSet(加载数据时)交互来转换值。通过实现此接口,你还可以控制 Hibernate 如何缓存和脏检查值。 -
CompositeUserType——你可以告诉 Hibernate,MonetaryAmount组件有两个属性:amount和currency。然后你可以在查询中使用点符号引用这些属性,例如selectavg(i.buyNowPrice .amount)fromItemi。 -
ParameterizedType——这为映射中的适配器提供设置。我们可以为MonetaryAmount转换实现此接口,因为在某些映射中我们希望将金额转换为美元,在其他映射中转换为欧元。我们只需编写一个适配器,然后可以在映射属性时自定义其行为。 -
DynamicParameterizedType——这个更强大的设置 API 提供了对适配器中动态信息的访问,例如映射的列和表名。我们不妨使用这个而不是ParameterizedType;这不会带来额外的成本或复杂性。 -
EnhancedUserType—这是一个可选接口,用于标识属性和区分符的适配器。与 JPA 转换器不同,Hibernate 中的UserType可以是任何类型实体属性的适配器。因为MonetaryAmount不会是标识属性或区分符的类型,所以我们不需要它。 -
UserVersionType—这是一个可选接口,用于版本属性的适配器。 -
UserCollectionType—这个很少需要的接口用于实现自定义集合。我们必须实现它以持久化非 JDK 集合(例如 Google Guava 集合:Multiset、Multimap、BiMap、Table等)并保留额外的语义。
MonetaryAmount的自定义类型适配器将实现这些接口中的几个。接下来的源代码可以在mapping-value-types4文件夹中找到。
实现 UserType
MonetaryAmountUserType是一个大类,正如你在下面的列表中可以看到的。
列表 6.19 MonetaryAmountUserType类
Path: Ch06/mapping-value-types4/src/main/java/com/manning/javapersistence
➥ /ch06/converter/MonetaryAmountUserType.java
public class MonetaryAmountUserType Ⓐ
implements CompositeUserType, DynamicParameterizedType { Ⓐ
private Currency convertTo; Ⓑ
public void setParameterValues(Properties parameters) { Ⓒ
String convertToParameter = parameters.getProperty("convertTo"); Ⓓ
this.convertTo = Currency.getInstance(
convertToParameter != null ? convertToParameter : "USD" Ⓔ
); Ⓔ
}
public Class returnedClass() { Ⓕ
return MonetaryAmount.class; Ⓕ
} Ⓕ
public boolean isMutable() { Ⓖ
return false; Ⓖ
} Ⓖ
public Object deepCopy(Object value) { Ⓗ
return value; Ⓗ
} Ⓗ
public Serializable disassemble(Object value, Ⓘ
SharedSessionContractImplementor session){ Ⓘ
return value.toString(); Ⓘ
} Ⓘ
public Object assemble(Serializable cached, Ⓙ
SharedSessionContractImplementor session, Object owner) { Ⓙ
return MonetaryAmount.fromString((String) cached); Ⓙ
} Ⓙ
public Object replace(Object original, Object target, Ⓚ
SharedSessionContractImplementor session, Object owner) { Ⓚ
return original; Ⓚ
} Ⓚ
public boolean equals(Object x, Object y) { Ⓛ
return x == y || !(x == null || y == null) && x.equals(y); Ⓛ
} Ⓛ
public int hashCode(Object x) { Ⓛ
return x.hashCode(); Ⓛ
} Ⓛ
public Object nullSafeGet(ResultSet resultSet, Ⓜ
String[] names, Ⓜ
SharedSessionContractImplementor session, Ⓜ
Object owner) throws SQLException { Ⓜ
BigDecimal amount = resultSet.getBigDecimal(names[0]); Ⓝ
if (resultSet.wasNull()) Ⓝ
return null; Ⓝ
Currency currency = Ⓝ
Currency.getInstance(resultSet.getString(names[1])); Ⓝ
return new MonetaryAmount(amount, currency); Ⓞ
}
public void nullSafeSet(PreparedStatement statement, Ⓟ
Object value, int index, Ⓟ
SharedSessionContractImplementor session) throws SQLException { Ⓟ
if (value == null) { Ⓠ
statement.setNull( Ⓠ
index, Ⓠ
StandardBasicTypes.BIG_DECIMAL.sqlType()); Ⓠ
statement.setNull( Ⓠ
index + 1, Ⓠ
StandardBasicTypes.CURRENCY.sqlType()); Ⓠ
} else {
MonetaryAmount amount = (MonetaryAmount) value; Ⓡ
MonetaryAmount dbAmount = convert(amount, convertTo); Ⓡ
statement.setBigDecimal(index, dbAmount.getValue()); Ⓢ
statement.setString(index + 1, convertTo.getCurrencyCode()); Ⓢ
}
}
public MonetaryAmount convert(MonetaryAmount amount, Ⓣ
Currency toCurrency) { Ⓣ
return new MonetaryAmount( Ⓤ
amount.getValue().multiply(new BigDecimal(2)), Ⓤ
toCurrency Ⓤ
); Ⓤ
}
public String[] getPropertyNames() { Ⓥ
return new String[]{"value", "currency"}; Ⓥ
} Ⓥ
public Type[] getPropertyTypes() { Ⓦ
return new Type[]{ Ⓦ
StandardBasicTypes.BIG_DECIMAL, Ⓦ
StandardBasicTypes.CURRENCY Ⓦ
}; Ⓦ
} Ⓦ
public Object getPropertyValue(Object component, Ⓧ
int property) { Ⓧ
MonetaryAmount monetaryAmount = (MonetaryAmount) component; Ⓧ
if (property == 0) Ⓧ
return monetaryAmount.getValue(); Ⓧ
else Ⓧ
return monetaryAmount.getCurrency(); Ⓧ
} Ⓧ
public void setPropertyValue(Object component, Ⓨ
int property, Ⓨ
Object value) { Ⓨ
throw new UnsupportedOperationException( Ⓨ
"MonetaryAmount is immutable" Ⓨ
); Ⓨ
} Ⓨ
}
Ⓐ 我们实现的接口是CompositeUserType和DynamicParameterizedType。
Ⓑ 目标货币。
Ⓒ setParameterValues方法是从DynamicParameterizedType接口继承的。
Ⓓ 使用convertTo参数确定在将值保存到数据库时目标货币。
Ⓔ 如果参数尚未设置,则默认为美元。
Ⓕ returnedClass方法适配给定的类,在这种情况下,是MonetaryAmount。这个方法和接下来的方法都是从CompositeUserType接口继承的。
Ⓖ 如果 Hibernate 知道MonetaryAmount是不可变的,它可以启用一些优化。
Ⓗ 如果 Hibernate 必须复制值,它会调用这个deepCopy方法。对于像MonetaryAmount这样的简单不可变类,我们可以返回给定的实例。
Ⓘ 当 Hibernate 在全局共享的二级缓存中存储值时,它会调用disassemble方法。我们需要返回一个Serializable表示。对于MonetaryAmount,一个String表示是一个简单的解决方案。或者,因为MonetaryAmount是Serializable的,我们可以直接返回它。
Ⓙ 当 Hibernate 从全局共享的二级缓存中读取序列化表示时,它会调用assemble方法。我们从一个String表示中创建一个MonetaryAmount实例。或者,如果我们存储了一个序列化的MonetaryAmount,我们可以直接返回它。
Ⓚ 在EntityManager#merge()操作期间调用replace方法。我们需要返回原始副本。或者,如果值类型是不可变的,如MonetaryAmount,我们可以返回原始值。
Ⓛ Hibernate 使用值相等性来确定值是否已更改,数据库需要更新。我们依赖于我们在MonetaryAmount类上已经编写的相等性和哈希码例程。
Ⓜ 当需要从数据库中检索MonetaryAmount值时,会调用nullSafeGet方法来读取ResultSet。
Ⓝ 将查询结果中给出的amount和currency值作为已知值。
Ⓞ 创建一个 MonetaryAmount 的新实例。
Ⓟ 当需要将 MonetaryAmount 值存储在数据库中时,会调用 nullSafeSet 方法。
Ⓠ 如果 MonetaryAmount 为 null,我们调用 setNull() 来准备语句。
Ⓡ 否则,我们将值转换为目标货币。
Ⓢ 然后我们在提供的 PreparedStatement() 上设置 amount 和 currency。
Ⓣ 我们可以实现所需的任何货币转换程序。
Ⓤ 为了这个示例,我们将值加倍,这样我们就可以轻松地测试转换是否成功。在实际应用中,我们必须用真正的货币转换器替换此代码。这个 convert 方法不是 Hibernate UserType API 中的方法。
Ⓥ 从 CompositeUserType 继承的剩余方法提供了 MonetaryAmount 属性的详细信息,因此 Hibernate 可以将此类与查询引擎集成。getPropertyNames 方法将返回一个包含两个元素的 String 数组,value 和 currency——这是 MonetaryAmount 类属性的名称。
Ⓦ getPropertyTypes 方法将返回一个包含两个元素的 Type 数组,BIG _DECIMAL 和 CURRENCY——这是 MonetaryAmount 类属性的类型。
Ⓧ getPropertyValue 方法将根据 property 索引返回 MonetaryAmount 对象的 value 字段或 currency 字段。
Ⓨ setPropertyValue 方法将不允许设置 MonetaryAmount 对象的任何字段,因为这个对象是不可变的。
MonetaryAmountUserType 类现在已经完成,我们可以在映射中使用它,在 @org.hibernate.annotations.Type 中使用其完全限定类名,如“选择类型适配器”部分(在第 6.3.1 节中)所示。此注解还支持参数,因此我们可以将 convertTo 参数设置为目标货币。
然而,我们建议创建 类型定义,将适配器与一些参数捆绑在一起。
使用类型定义
我们需要一个将货币转换为美元的适配器,另一个将货币转换为欧元。如果我们将这些参数作为 类型定义 一次性声明,我们就不必在属性映射中重复它们。类型定义的好位置是包元数据,在 package-info.java 文件中:
Path: Ch06/mapping-value-types4/src/main/java/com/manning/javapersistence
➥ /ch06/converter/package-info.java
@org.hibernate.annotations.TypeDefs({
@org.hibernate.annotations.TypeDef(
name = "monetary_amount_usd",
typeClass = MonetaryAmountUserType.class,
parameters = {@Parameter(name = "convertTo", value = "USD")}
),
@org.hibernate.annotations.TypeDef(
name = "monetary_amount_eur",
typeClass = MonetaryAmountUserType.class,
parameters = {@Parameter(name = "convertTo", value = "EUR")}
)
})
package com.manning.javapersistence.ch06.converter;
import org.hibernate.annotations.Parameter;
现在,我们已经准备好在映射中使用适配器,使用名称 monetary_ amount_usd 和 monetary_amount_eur。
我们可以将 Item 的 buyNowPrice 和 initialPrice 进行映射:
Path: Ch06/mapping-value-types4/src/main/java/com/manning/javapersistence
➥ /ch06/model/Item.java
@Entity
public class Item {
@NotNull
@org.hibernate.annotations.Type(
type = "monetary_amount_usd"
)
@org.hibernate.annotations.Columns(columns = {
@Column(name = "BUYNOWPRICE_AMOUNT"),
@Column(name = "BUYNOWPRICE_CURRENCY", length = 3)
})
private MonetaryAmount buyNowPrice;
@NotNull
@org.hibernate.annotations.Type(
type = "monetary_amount_eur"
)
@org.hibernate.annotations.Columns(columns = {
@Column(name = "INITIALPRICE_AMOUNT"),
@Column(name = "INITIALPRICE_CURRENCY", length = 3)
})
private MonetaryAmount initialPrice;
// . . .
}
如果 UserType 只转换单个列的值,我们不需要 @Column 注解。然而,MonetaryAmountUserType 访问两个列,因此我们需要在属性映射中显式声明两个列。由于 JPA 不支持在单个属性上使用多个 @Column 注解,我们将不得不使用专有的 @org.hibernate.annotations.Columns 注解将它们分组。请注意,注解的顺序现在很重要!重新检查 MonetaryAmountUserType 的代码;许多操作依赖于数组的索引访问。访问 PreparedStatement 或 ResultSet 的顺序与映射中声明的列的顺序相同。此外,请注意,列的数量对于选择 UserType 与 CompositeUserType 并不重要——只有暴露值类型属性以供查询的愿望。
我们将不得不更改我们编写的测试代码。我们将添加这一行来设置 Item:
item.setBuyNowPrice(new MonetaryAmount(BigDecimal.valueOf(1.1),
Currency.getInstance("USD")));
我们将替换这一行,
() -> assertEquals("1.1 USD",
items.get(0).getBuyNowPrice().toString())
使用这个:
() -> assertEquals("2.20 USD",
items.get(0).getBuyNowPrice().toString())
我们还将替换这一行,
() -> assertEquals(new BigDecimal("1.00"),
items.get(0).getInitialPrice())
使用这一行替换:
() -> assertEquals("2.00 EUR",
items.get(0).getInitialPrice().toString())
这是因为 MonetaryAmountUserType 类的 convert 方法将金额的值加倍(如列表 6.19 所示)。本书的源代码包含使用 Spring Data JPA、Hibernate 和 JPA 的测试。
通过 MonetaryAmountUserType,我们扩展了 Java 领域模型和 SQL 数据库模式之间的缓冲区。现在,两种表示方法在变化方面都更加健壮,我们可以处理甚至相当古怪的要求,而无需修改领域模型类的本质。
摘要
-
您可以将实体类的基元和嵌入属性映射。
-
您可以覆盖基本映射,更改映射列的名称,使用派生、默认、时间和枚举属性,并对其进行测试。
-
您可以实现可嵌入的组件类并创建细粒度的领域模型。
-
您可以将几个 Java 类的属性映射到一个实体表中,例如
Address和City。 -
任何 JPA 提供商都支持一组基本的 Java 到 SQL 类型转换,以及一些额外的适配器。
-
您可以编写一个自定义类型转换器,就像我们为
MonetaryAmount类所做的那样,使用标准的 JPA 扩展接口。您还可以编写一个低级适配器,就像我们使用原生 HibernateUserTypeAPI 所做的那样。
7 映射继承
本章涵盖
-
检查继承映射策略
-
调查多态关联
到目前为止,我们故意没有过多地讨论继承映射。映射建立了面向对象世界和关系世界之间的联系,但继承是面向对象系统的特性。因此,将类层次映射到表可能是一个复杂的问题,我们将在本章中展示各种策略。
将类映射到数据库表的基本策略可能是“每个持久化实体类一个表”。这种方法听起来足够简单,并且确实在遇到继承之前工作得很好。
继承是面向对象和关系世界之间的一种明显的结构不匹配,因为面向对象模型提供了“是”和“有”的关系。基于 SQL 的模型只提供“有”的关系;基于 SQL 的数据库管理系统不支持类型继承,即使它可用,通常也是专有的或不完整的。
有四种不同的策略来表示继承层次:
-
每个具体类使用一个表,并默认启用运行时多态行为。
-
每个具体类使用一个表,但完全从 SQL 模式中丢弃多态性和继承关系。使用 SQL
UNION查询来实现运行时多态行为。 -
每个类层次使用一个表:通过非规范化 SQL 模式启用多态性,并依赖于基于行的区分来确定超类型和子类型。
-
每个子类使用一个表:将“是”关系(继承)表示为“有”关系(外键),并使用 SQL
JOIN操作。
本章采用自顶向下的方法,假设我们从领域模型开始,试图推导出一个新的 SQL 模式。这里描述的映射策略,如果你是从现有的数据库表开始的,也是同样相关的。我们将检查一些技巧,以帮助你处理不完美的表布局。
7.1 每个具体类一个表,具有隐式多态
我们正在开发 CaveatEmptor 应用程序,为类层次实现持久化。我们可以坚持最简单的方法建议:每个具体类恰好使用一个表。我们可以将类的所有属性,包括继承属性,映射到表的列中,如图 7.1 所示。
注意:要能够执行本章源代码中的示例,您首先需要运行 Ch07.sql 脚本。

图 7.1 将所有具体类映射到独立表
依赖于这种隐式多态,我们将使用 @Entity 映射具体类,就像通常一样。默认情况下,超类的属性被忽略且不持久化!我们必须在超类上注解 @MappedSuperclass 以启用其属性在具体子类表中的嵌入;参见列表 7.1,它位于 mapping-inheritance-mappedsuperclass 文件夹中。
列表 7.1 映射 BillingDetails(抽象超类)并使用隐式多态
Path: Ch07/mapping-inheritance-mappedsuperclass/src/main/java/com/manning
➥ /javapersistence/ch07/model/BillingDetails.java
@MappedSuperclass
\1 BillingDetails {
@Id
@GeneratedValue(generator = "ID_GENERATOR")
private Long id;
@NotNull
private String owner;
// . . .
}
现在,我们将映射具体子类。
列表 7.2 映射 CreditCard(具体子类)
Path: Ch07/mapping-inheritance-mappedsuperclass/src/main/java/com/manning
➥ /javapersistence/ch07/model/CreditCard.java
@Entity
@AttributeOverride(
name = "owner",
column = @Column(name = "CC_OWNER", nullable = false))
public class CreditCard extends BillingDetails {
@NotNull
private String cardNumber;
@NotNull
private String expMonth;
@NotNull
private String expYear;
// . . .
}
我们可以在子类中使用 @AttributeOverride 注解覆盖从超类继承的列映射。从 JPA 2.2 开始,我们可以在同一类上使用多个 @AttributeOverride 注解;在 JPA 2.1 之前,我们必须在 @AttributeOverrides 注解内分组 @AttributeOverride 注解。前面的示例将 CREDITCARD 表中的 OWNER 列重命名为 CC_OWNER。
下面的列表显示了 BankAccount 子类的映射。
列表 7.3 映射 BankAccount(具体子类)
Path: Ch07/mapping-inheritance-mappedsuperclass/src/main/java/com/manning
➥ /javapersistence/ch07/model/BankAccount.java
@Entity
public class BankAccount extends BillingDetails {
@NotNull
private String account;
@NotNull
private String bankname;
@NotNull
private String swift;
// . . .
}
我们可以在超类中声明标识属性,为所有子类共享列名和生成策略(如列表 7.3 所示),或者我们可以在每个具体类内部重复它。
要使用这些类,我们将创建三个 Spring Data JPA 仓库接口。
列表 7.4 BillingDetailsRepository 接口
Path: Ch07/mapping-inheritance-mappedsuperclass/src/main/java/com/manning
➥ /javapersistence/ch07/repositories/BillingDetailsRepository.java
@NoRepositoryBean
public interface BillingDetailsRepository<T extends BillingDetails, ID>
extends JpaRepository<T, ID> {
List<T> findByOwner(String owner);
}
在前面的列表中,BillingDetailsRepository 接口被注解为 @NoRepositoryBean。这阻止了其作为 Spring Data JPA 仓库实例的实例化。这是必要的,因为根据图 7.1 的模式,将没有 BILLINGDETAILS 表。然而,BillingDetailsRepository 接口意图被仓库接口扩展以处理 CreditCard 和 BankAccount 子类。这就是为什么 BillingDetailsRepository 通过一个扩展 BillingDetails 的 T 进行泛型化。此外,它还包含 findByOwner 方法。BillingDetails 中的 owner 字段将包含在 CREDITCARD 和 BANKACCOUNT 表中。
现在,我们将创建另外两个 Spring Data 仓库接口。
列表 7.5 BankAccountRepository 接口
Path: Ch07/mapping-inheritance-mappedsuperclass/src/main/java/com/manning
➥ /javapersistence/ch07/repositories/BankAccountRepository.java
public interface BankAccountRepository
extends BillingDetailsRepository<BankAccount, Long> {
List<BankAccount> findBySwift(String swift);
}
BankAccountRepository 接口扩展了 BillingDetailsRepository,通过 BankAccount 泛型化(因为它处理 BankAccount 实例)和通过 Long 泛型化(因为类的 ID 是这种类型)。它添加了 findBySwift 方法,其名称遵循 Spring Data JPA 规范(见第四章)。
列表 7.6 CreditCardRepository 接口
Path: Ch07/mapping-inheritance-mappedsuperclass/src/main/java/com/manning
➥ /javapersistence/ch07/repositories/CreditCardRepository.java
public interface CreditCardRepository
extends BillingDetailsRepository<CreditCard, Long> {
List<CreditCard> findByExpYear(String expYear);
}
CreditCardRepository 接口扩展了 BillingDetailsRepository,通过 CreditCard 泛型化(因为它处理 CreditCard 实例)和通过 Long 泛型化(因为类的 ID 是这种类型)。它添加了 findByExpYear 方法,其名称遵循 Spring Data JPA 规范(见第四章)。
我们将创建以下测试来检查持久化代码的功能。
列表 7.7 测试持久化代码的功能
Path: Ch07/mapping-inheritance-mappedsuperclass/src/test/java/com/manning
➥ /javapersistence/ch07/MappingInheritanceSpringDataJPATest.java
@ExtendWith(SpringExtension.class) Ⓐ
@ContextConfiguration(classes = {SpringDataConfiguration.class}) Ⓑ
public class MappingInheritanceSpringDataJPATest {
@Autowired Ⓒ
private CreditCardRepository crediCardRepository; Ⓒ
@Autowired Ⓓ
private BankAccountRepository bankAccountRepository; Ⓓ
@Test
void storeLoadEntities() {
CreditCard creditCard = new CreditCard( Ⓔ
"John Smith", "123456789", "10", "2030"); Ⓔ
creditCardRepository.save(creditCard); Ⓔ
BankAccount bankAccount = new BankAccount( Ⓕ
"Mike Johnson", "12345", "Delta Bank", "BANKXY12"); Ⓕ
bankAccountRepository.save(bankAccount); Ⓕ
List<CreditCard> creditCards = Ⓖ
creditCardRepository.findByOwner("John Smith"); Ⓖ
List<BankAccount> bankAccounts = Ⓗ
bankAccountRepository.findByOwner("Mike Johnson"); Ⓗ
List<CreditCard> creditCards2 = Ⓘ
creditCardRepository.findByExpYear("2030"); Ⓘ
List<BankAccount> bankAccounts2 = Ⓙ
bankAccountRepository.findBySwift("BANKXY12"); Ⓙ
assertAll(
() -> assertEquals(1, creditCards.size()), Ⓚ
() -> assertEquals("123456789", Ⓛ
creditCards.get(0).getCardNumber()), Ⓛ
() -> assertEquals(1, bankAccounts.size()), Ⓜ
() -> assertEquals("12345", Ⓝ
bankAccounts.get(0).getAccount()), Ⓝ
() -> assertEquals(1, creditCards2.size()), Ⓞ
() -> assertEquals("John Smith", Ⓟ
creditCards2.get(0).getOwner()), Ⓟ
() -> assertEquals(1, bankAccounts2.size()), Ⓠ
() -> assertEquals("Mike Johnson", Ⓡ
bankAccounts2.get(0).getOwner()) Ⓡ
);
}
}
Ⓐ 使用SpringExtension扩展测试。这个扩展用于将 Spring 测试上下文与 JUnit 5 Jupiter 测试集成。
Ⓑ 使用在SpringDataConfiguration类中定义的 bean 配置 Spring 测试上下文。
Ⓒ 通过 Spring 自动装配注入CreditCardRepository对象。
Ⓓ 通过 Spring 自动装配注入BankAccountRepository对象。这是可能的,因为CreditCardRepository和BankAccountRepository所在的com.manning.javapersistence.ch07.repositories包被用作SpringDataConfiguration类上@EnableJpaRepositories注解的参数。要回忆SpringDataConfiguration类的样子,请参阅第二章。
Ⓔ 创建一张信用卡并将其保存到仓库中。
Ⓕ 创建一个银行账户并将其保存到仓库中。
Ⓖ 获取所有以 John Smith 为所有者的信用卡列表。
Ⓗ 获取所有以 Mike Johnson 为所有者的银行账户列表。
Ⓘ 获取 2030 年到期信用卡。
Ⓙ 获取拥有 SWIFT BANKXY12 的银行账户。
Ⓚ 检查信用卡列表的大小。
Ⓛ 获取列表中第一张信用卡的编号。
Ⓜ 检查银行账户列表的大小。
Ⓝ 检查列表中第一个银行账户的数量。
Ⓞ 检查 2030 年到期信用卡列表的大小。
Ⓟ 检查此列表中第一张信用卡的所有者。
Ⓠ 检查拥有 SWIFT BANKXY12 银行账户的账户列表的大小。
Ⓡ 检查此列表中第一个银行账户的所有者。
本章的源代码还演示了如何使用 JPA 和 Hibernate 测试这些类。
隐式继承映射的主要问题是它不支持多态关联。在数据库中,我们通常将关联表示为外键关系。在图 7.1 的方案中,如果所有子类都映射到不同的表,那么到其超类(抽象的BillingDetails)的多态关联就无法表示为一个简单的外键关系。我们不能有另一个实体通过外键“引用BILLINGDETAILS”——没有这样的表。这在领域模型中会存在问题,因为BillingDetails与User相关联;CREDITCARD和BANKACCOUNT表都需要一个外键引用到USERS表。这些问题都无法轻易解决,因此我们应该考虑一种替代的映射策略。
返回与查询类接口匹配的所有类实例的多态查询也存在问题。Hibernate 必须对超类执行多个 SQL SELECT查询——每个具体的子类一个。JPA 查询select bd from BillingDetails bd需要两个 SQL 语句:
select
ID, OWNER, ACCOUNT, BANKNAME, SWIFT
from
BANKACCOUNT
select
ID, CC_OWNER, CARDNUMBER, EXPMONTH, EXPYEAR
from
CREDITCARD
Hibernate 或使用 Hibernate 的 Spring Data JPA 对每个具体子类使用单独的 SQL 查询。另一方面,对具体类的查询既简单又表现良好——Hibernate 只使用一条语句。
这种映射策略的进一步概念问题是,不同表中的几个不同列具有完全相同的语义。这使得模式演变更加复杂。例如,更改超类属性的名称或类型会导致多个表中的多个列发生变化。许多由您的 IDE 提供的标准重构操作都需要手动调整,因为自动程序通常不考虑像 @AttributeOverride 或 @AttributeOverrides 这样的东西。实现适用于所有子类的数据库完整性约束要困难得多。
我们只推荐这种方法用于类层次结构的顶层,在那里通常不需要多态,并且未来修改超类的可能性不大。这可能适用于您在现实生活中的应用程序中遇到的特定领域模型,但它不适合 CaveatEmptor 领域模型,其中查询和其他实体引用 BillingDetails。我们将寻找其他替代方案。
通过 SQL UNION 操作的帮助,我们可以消除大多数与多态查询和关联相关的问题。
7.2 每个具体类一个表与联合
让我们考虑一个具有 BillingDetails 作为抽象类(或接口)的联合子类映射,就像上一节中那样。在这种情况下,又有两个表和重复在两个表中的超类列:CREDITCARD 和 BANKACCOUNT。这里的新特点是声明在超类上的一个称为 TABLE_PER_CLASS 的继承策略,如下面的列表所示。源代码可以在 mapping-inheritance-tableperclass 文件夹中找到。
注意:JPA 标准指定 TABLE_PER_CLASS 是可选的,因此并非所有 JPA 实现都支持它。
列表 7.8 使用 TABLE_PER_CLASS 映射 BillingDetails
Path: Ch07/mapping-inheritance-tableperclass/src/main/java/com/manning
➥ /javapersistence/ch07/model/BillingDetails.java
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class BillingDetails {
@Id
@GeneratedValue(generator = “ID_GENERATOR”)
private Long id;
@NotNull
private String owner;
// . . .
}
数据库标识符及其映射必须在超类中存在,以便在所有子类及其表中共享。这不再是可选的,就像之前的映射策略那样。CREDITCARD 和 BANKACCOUNT 表都有 ID 主键列。所有具体类映射都从超类(或接口)继承持久属性。每个子类上的 @Entity 注解就足够了。
列表 7.9 CreditCard 映射
Path: Ch07/mapping-inheritance-tableperclass/src/main/java/com/manning
➥ /javapersistence/ch07/model/CreditCard.java
@Entity
@AttributeOverride(
name = "owner",
column = @Column(name = "CC_OWNER", nullable = false))
public class CreditCard extends BillingDetails {
@NotNull
private String cardNumber;
@NotNull
private String expMonth;
@NotNull
private String expYear;
// . . .
}
列表 7.10 BankAccount 映射
Path: Ch07/mapping-inheritance-tableperclass/src/main/java/com/manning
➥ /javapersistence/ch07/model/BankAccount.java
@Entity
public class BankAccount extends BillingDetails {
@NotNull
private String account;
@NotNull
private String bankName;
@NotNull
private String swift;
// . . .
}
我们将不得不更改 BillingDetailsRepository 接口并移除 @NoRepositoryBean 注解。这个更改,加上 BillingDetails 类现在被标注为 @Entity,将允许此存储库与数据库交互。这就是现在的 BillingDetailsRepository 接口的样子。
列表 7.11 BillingDetailsRepository 接口
Path: Ch07/mapping-inheritance-tableperclass/src/main/java/com/manning
➥ /javapersistence/ch07/model/BillingDetailsRepository.java
public interface BillingDetailsRepository<T extends BillingDetails, ID>
extends JpaRepository<T, ID> {
List<T> findByOwner(String owner);
}
请记住,SQL 模式仍然没有意识到继承;表看起来完全一样,如图 7.1 所示。
如果BillingDetails是具体的,我们需要一个额外的表来存储实例。请记住,数据库表之间仍然没有关系,除了它们有一些(许多)相似的列。
如果我们检查多态查询,这种映射策略的优点将更加明显。
我们可以使用 Spring Data JPA 的BillingDetailsRepository接口来查询数据库,如下所示:
billingDetailsRepository.findAll();
或者,从 JPA 或 Hibernate 执行以下查询:
select bd from BillingDetails bd
这两种方法都会生成以下 SQL 语句:
select
ID, OWNER, EXPMONTH, EXPYEAR, CARDNUMBER,
ACCOUNT, BANKNAME, SWIFT, CLAZZ_
from
( select
ID, OWNER, EXPMONTH, EXPYEAR, CARDNUMBER,
null as ACCOUNT,
null as BANKNAME,
null as SWIFT,
1 as CLAZZ_
from
CREDITCARD
union all
select
id, OWNER,
null as EXPMONTH,
null as EXPYEAR,
null as CARDNUMBER,
ACCOUNT, BANKNAME, SWIFT,
2 as CLAZZ_
from
BANKACCOUNT
) as BILLINGDETAILS
这个SELECT语句使用一个FROM子句子查询从所有具体类表中检索所有BillingDetails实例。这些表通过UNION运算符组合在一起,并在中间结果中插入一个字面量(在这种情况下,1和2);Hibernate 读取这个来根据特定行的数据实例化正确的类。联合要求组合的查询在相同的列上投影,因此你必须用NULL填充不存在的列。你可能想知道这个查询是否真的会比两个单独的语句表现得更好。在这里,你可以让数据库优化器找到最佳执行计划来合并来自多个表的行,而不是像 Hibernate 的多态加载引擎那样在内存中合并两个结果集。
一个重要的优点是能够处理多态关联;例如,从User到BillingDetails的关联映射现在将变得可能。Hibernate 可以使用UNION查询来模拟关联映射的目标作为单个表。
到目前为止,我们检查的继承映射策略不需要对 SQL 模式进行额外考虑。这种情况在下一个策略中发生了变化。
7.3 每个类层次结构的表
我们可以将整个类层次结构映射到一个单独的表中。这个表包括层次结构中所有类的所有属性列。额外的类型判别器列或公式的值标识了特定行表示的具体子类。图 7.2 显示了这种方法。下面的源代码可以在mapping-inheritance-singletable文件夹中找到。

![图 7.2 将整个类层次结构映射到单个表]
这种映射策略在性能和简单性方面都是赢家。这是表示多态的最佳性能方式——多态和非多态查询都表现良好,而且手动编写查询也很容易。无需复杂的连接或联合即可进行临时报告。模式演变简单直接。
存在一个主要问题:数据完整性。我们必须声明子类声明的属性列可以为空。如果子类各自定义了多个不可为空的属性,那么 NOT NULL 约束的丢失可能从数据正确性的角度来看是一个严重问题。想象一下,信用卡的到期日期是必需的,但数据库模式无法强制执行此规则,因为表的所有列都可以是 NULL。一个简单的应用程序编程错误可能导致无效数据。
另一个重要的问题是规范化。我们已经在非键列之间创建了函数依赖关系,违反了第三范式。像往常一样,出于性能原因的规范化可能会产生误导,因为它牺牲了长期稳定性、可维护性和数据完整性,以换取可能通过正确优化 SQL 执行计划(换句话说,询问数据库管理员)也能实现的即时收益。
我们将使用 SINGLE_TABLE 继承策略来创建一个表级类层次结构映射,如下所示。
列表 7.12 使用 SINGLE_TABLE 映射 BillingDetails
Path: Ch07/mapping-inheritance-singletable/src/main/java/com/manning
➥ /javapersistence/ch07/model/BillingDetails.java
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "BD_TYPE")
public abstract class BillingDetails {
@Id
@GeneratedValue(generator = "ID_GENERATOR")
private Long id;
@NotNull
@Column(nullable = false)
private String owner;
// . . .
}
继承层次结构的根类 BillingDetails 自动映射到 BILLINGDETAILS 表。超类共享属性在模式中可以是 NOT NULL;每个子类实例必须有一个值。Hibernate 的一个实现怪癖要求我们使用 @Column 声明可空性,因为当 Hibernate 生成数据库模式时,它会忽略 Bean Validation 的 @NotNull。
我们必须添加一个特殊的区分器列来区分每一行代表的内容。这不是实体属性;它是 Hibernate 内部使用的。列名为 BD_TYPE,值是字符串——在这种情况下,"CC" 或 "BA"。Hibernate 或使用 Hibernate 的 Spring Data JPA 自动设置和检索区分器值。
如果我们不在超类中指定区分器列,其名称默认为 DTYPE,值是字符串。继承层次结构中的所有具体类都可以有区分器值,例如 CreditCard。
列表 7.13 使用 SINGLE_TABLE 继承策略映射 CreditCard
Path: Ch07/mapping-inheritance-singletable/src/main/java/com/manning
➥ /javapersistence/ch07/model/CreditCard.java
@Entity
@DiscriminatorValue("CC")
public class CreditCard extends BillingDetails {
@NotNull
private String cardNumber;
@NotNull
private String expMonth;
@NotNull
private String expYear;
// . . .
}
如果没有显式的区分器值,当使用 Hibernate XML 文件时,Hibernate 默认使用完全限定类名;如果使用注解或 JPA XML 文件,则默认使用简单实体名称。请注意,JPA 没有指定非字符串区分器类型的默认值;每个持久化提供者可能有不同的默认值。因此,我们应该始终为具体类指定区分器值。
我们将使用 @Entity 注解每个子类,然后将子类的属性映射到 BILLINGDETAILS 表的列上。记住,由于 BankAccount 实例不会有 expMonth 属性,EXPMONTH 列必须为 NULL,因此架构中不允许使用 NOT NULL 约束。Hibernate 和 Spring Data JPA 在生成架构 DDL 时忽略 @NotNull,但在插入行之前在运行时观察它。这有助于我们避免编程错误;我们不希望意外保存没有到期日期的信用卡数据。(当然,其他行为不佳的应用程序仍然可以在这个数据库中存储错误数据。)
我们可以使用 Spring Data JPA 的 BillingDetailsRepository 接口来查询数据库,如下所示:
billingDetailsRepository.findAll();
或者,从 JPA 或 Hibernate,我们可以执行以下查询:
select bd from BillingDetails bd
这两种方法都会生成以下 SQL 语句:
select
ID, OWNER, EXPMONTH, EXPYEAR, CARDNUMBER,
ACCOUNT, BANKNAME, SWIFT, BD_TYPE
from
BILLINGDETAILS
要查询 CreditCard 子类,我们也有其他选择。
我们可以使用 Spring Data JPA 的 CreditCardRepository 接口来查询数据库,如下所示:
creditCardRepository.findAll();
或者,从 JPA 或 Hibernate,我们可以执行以下查询:
select cc from CreditCard cc
Hibernate 对区分符列添加了一个限制:
select
ID, OWNER, EXPMONTH, EXPYEAR, CARDNUMBER
from
BILLINGDETAILS
where
BD_TYPE='CC'
有时,尤其是在遗留架构中,我们无法在实体表中包含额外的区分符列。在这种情况下,我们可以对每一行应用一个表达式来计算一个区分符值。区分公式不是 JPA 规范的一部分,但 Hibernate 有一个扩展注解,@DiscriminatorFormula。
列表 7.14 使用 @DiscriminatorFormula 映射 BillingDetails
Path: Ch07/mapping-inheritance-singletableformula/src/main/java/com/manning
➥ /javapersistence/ch07/model/BillingDetails.java
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@org.hibernate.annotations.DiscriminatorFormula(
"case when CARDNUMBER is not null then 'CC' else 'BA' end"
)
public abstract class BillingDetails {
// . . .
}
架构中没有区分符列,因此这种映射依赖于 SQL 的 CASE/WHEN 表达式来确定特定行是否代表信用卡或银行账户(许多开发者从未使用过这种类型的 SQL 表达式;如果你不熟悉它,请查看 ANSI 标准)。表达式的结果是声明在子类映射中的字面量,CC 或 BA。
表-类层次结构策略的缺点可能对你的设计来说过于严重——非规范化架构可能会在长期内成为主要负担,你的 DBA 可能根本不喜欢它。下一个继承映射策略不会让你面临这个问题。
7.4 表-子类与连接
第四种选项是将继承关系表示为 SQL 外键关联。每个声明持久属性(包括抽象类甚至接口)的类或子类都有自己的表。以下源代码可以在 mapping-inheritance-joined 文件夹中找到。
与我们最初映射的表-具体类策略不同,这里的具体 @Entity 表只包含子类本身声明的非继承属性列,以及也是超类表外键的主键。这比听起来容易;看看图 7.3。

图 7.3 将层次结构中的所有类映射到它们自己的表
如果我们使CreditCard子类实例持久化,Hibernate 将插入两行:BillingDetails超类声明的属性值存储在BILLINGDETAILS表的新行中。只有子类声明的属性值存储在CREDITCARD表的新行中。两行共享的主键将它们链接在一起。稍后,可以通过将子类表与超类表连接来从数据库中检索子类实例。
这种策略的主要优势是它使 SQL 模式规范化。模式演变和完整性约束定义简单明了。引用特定子类表的表的外键可能代表对该特定子类的多态关联。我们将使用JOINED继承策略创建按子类创建的表层次结构映射。
列表 7.15 使用JOINED映射BillingDetails
Path: Ch07/mapping-inheritance-joined/src/main/java/com/manning
➥ /javapersistence/ch07/model/BillingDetails.java
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class BillingDetails {
@Id
@GeneratedValue(generator = "ID_GENERATOR")
private Long id;
@NotNull
private String owner;
// . . .
}
根类BillingDetails映射到BILLINGDETAILS表。请注意,此策略不需要使用鉴别器。
在子类中,如果子类表的主键列具有(或应该具有)与超类表的主键列相同的名称,我们不需要指定连接列。在以下列表中,BankAccount将是BillingDetails的子类。
列表 7.16 映射 BankAccount(具体类)
Path: Ch07/mapping-inheritance-joined/src/main/java/com/manning
➥ /javapersistence/ch07/model/BankAccount.java
@Entity
public class BankAccount extends BillingDetails {
@NotNull
private String account;
@NotNull
private String bankname;
@NotNull
private String swift;
// . . .
}
该实体没有标识符属性;它自动继承自超类的ID属性和列,如果我们要检索BankAccount实例,Hibernate 知道如何连接表。
当然,我们可以通过使用@PrimaryKeyJoinColumn注解显式指定列名,如下所示。
列表 7.17 映射 CreditCard
Path: Ch07/mapping-inheritance-joined/src/main/java/com/manning
➥ /javapersistence/ch07/model/CreditCard.java
@Entity
@PrimaryKeyJoinColumn(name = "CREDITCARD_ID")
public class CreditCard extends BillingDetails {
@NotNull
private String cardNumber;
@NotNull
private String expMonth;
@NotNull
private String expYear;
// . . .
}
BANKACCOUNT和CREDITCARD表的主键列各自也有一个外键约束,该约束引用BILLINGDETAILS表的主键。
我们可以使用 Spring Data JPA 的BillingDetailsRepository接口查询数据库,如下所示:
billingDetailsRepository.findAll();
或者,从 JPA 或 Hibernate 中,我们可以执行以下查询:
select bd from BillingDetails bd
Hibernate 依赖于 SQL 外连接,并将生成以下内容:
select
BD.ID, BD.OWNER,
CC.EXPMONTH, CC.EXPYEAR, CC.CARDNUMBER,
BA.ACCOUNT, BA.BANKNAME, BA.SWIFT,
case
when CC.CREDITCARD_ID is not null then 1
when BA.ID is not null then 2
when BD.ID is not null then 0
end
from
BILLINGDETAILS BD
left outer join CREDITCARD CC on BD.ID=CC.CREDITCARD_ID
left outer join BANKACCOUNT BA on BD.ID=BA.ID
SQL 的CASE . . . WHEN子句检测子类表CREDITCARD和BANKACCOUNT中的行是否存在(或不存在),因此 Hibernate 或使用 Hibernate 的 Spring Data 可以确定BILLINGDETAILS表的特定行的具体子类。
对于这样的狭窄子类查询,
creditCardRepository.findAll();
或者这样,
select cc from CreditCard cc,
Hibernate 使用内连接:
select
CREDITCARD_ID, OWNER, EXPMONTH, EXPYEAR, CARDNUMBER
from
CREDITCARD
inner join BILLINGDETAILS on CREDITCARD_ID=ID
如您所见,这种映射策略手动实现起来更为复杂——即使是临时报告也更为复杂。如果您计划将 Spring Data JPA 或 Hibernate 代码与手写的 SQL 混合使用,这是一个重要的考虑因素。一种常见的方法和可移植的解决方案可能是使用 JPQL(Jakarta Persistence Query Language)并使用 JPQL 查询注解方法。
此外,尽管这种映射策略表面上看起来很简单,但我们的经验是,对于复杂的类层次结构,性能可能无法接受。查询总是需要跨多个表或多个顺序读取。
使用连接和区分器的继承
Hibernate 不需要一个特殊的区分器数据库列来实现 InheritanceType.JOINED 策略,JPA 规范也没有任何要求。SQL SELECT 语句中的 CASE . . . WHEN 子句是一种区分检索到的每一行实体类型的智能方式。
然而,你可能在其他地方找到的一些 JPA 示例使用了 InheritanceType.JOINED 和 一个 @DiscriminatorColumn 映射。显然,一些其他的 JPA 提供商不使用 CASE . . . WHEN 子句,并且仅依赖于一个区分值,即使是对于 InheritanceType.JOINED 策略。Hibernate 不需要区分器,但使用声明的 @DiscriminatorColumn,即使在 JOINED 映射策略中也是如此。如果你更喜欢忽略 JOINED(在较旧的 Hibernate 版本中已被忽略)的区分器映射,请启用配置属性 hibernate .discriminator.ignore_explicit_for_joined。
在我们探讨何时应该选择哪种策略之前,让我们考虑在单个类层次结构中混合继承映射策略。
7.5 混合继承策略
我们可以使用 TABLE_PER_CLASS、SINGLE_TABLE 或 JOINED 策略来映射整个继承层次结构。我们不能混合它们——例如,从具有区分器的表层次结构切换到规范化的表层次结构策略。一旦我们决定了一个继承策略,我们就必须坚持使用它。
然而,这并不完全正确。通过使用一些技巧,我们可以切换特定子类的映射策略。例如,我们可以将类层次结构映射到单个表中,但对于特定的子类,可以切换到使用外键映射策略的单独表中,就像在表层次结构中一样。查看图 7.4 中的模式。下面的源代码可以在 mapping-inheritance-mixed 文件夹中找到。

图 7.4 将子类分离到其自己的二级表
我们将使用 InheritanceType.SINGLE_TABLE 映射超类 BillingDetails,就像之前做的那样。然后我们将我们想要从单表中分离出来的 CreditCard 子类映射到一个二级表中。
列表 7.18 映射 CreditCard
Path: Ch07/mapping-inheritance-mixed/src/main/java/com/manning
➥ /javapersistence/ch07/model/CreditCard.java
@Entity
@DiscriminatorValue("CC")
@SecondaryTable(
name = "CREDITCARD",
pkJoinColumns = @PrimaryKeyJoinColumn(name = "CREDITCARD_ID")
)
public class CreditCard extends BillingDetails {
@NotNull
@Column(table = "CREDITCARD", nullable = false)
private String cardNumber;
@Column(table = "CREDITCARD", nullable = false)
private String expMonth;
@Column(table = "CREDITCARD", nullable = false)
private String expYear;
// . . .
}
@SecondaryTable 和 @Column 注解将一些属性分组并告诉 Hibernate 从辅助表中获取它们。我们使用辅助表的名字来映射我们移动到辅助表中的所有属性。这是通过 @Column 的 table 参数实现的,我们之前没有展示过。这种映射有许多用途,你将在本书后面的内容中再次看到它。在这个例子中,它将 CreditCard 属性从单表策略中分离到 CREDITCARD 表中。如果我们想添加一个新的类来扩展 BillingDetails,例如 Paypal,这将是一个可行的解决方案。
这个表的 CREDITCARD_ID 列也是主键,并且有一个外键约束引用单继承表中的 ID。如果我们没有指定辅助表的主键连接列,则使用单继承表的主键名称——在这种情况下,是 ID。
记住,InheritanceType.SINGLE_TABLE 强制所有子类的列都必须是可空的。这种映射的一个好处是,我们现在可以声明 CREDITCARD 表的列作为 NOT NULL,从而保证数据完整性。
在运行时,Hibernate 执行一个外连接来多态地获取 BillingDetails 和所有子类实例:
select
ID, OWNER, ACCOUNT, BANKNAME, SWIFT,
EXPMONTH, EXPYEAR, CARDNUMBER,
BD_TYPE
from
BILLINGDETAILS
left outer join CREDITCARD on ID=CREDITCARD_ID
我们也可以将这个技巧用于类层次结构中的其他子类。对于异常广泛的类层次结构,外连接可能会成为一个问题。一些数据库系统(例如 Oracle)限制了外连接操作中表的数量。对于广泛的层次结构,你可能想要切换到不同的获取策略,该策略执行一个立即的第二 SQL 查询而不是外连接。
7.6 嵌入类继承
嵌入类是其所属实体的一个组成部分,因此本章中介绍的正常实体继承规则不适用。作为 Hibernate 扩展,我们可以映射一个嵌入类,该类从超类(或接口)继承一些持久属性。让我们考虑拍卖物品的两个新属性:尺寸和重量。
一个项目的尺寸是其宽度、高度和深度,以给定的单位和其符号表示:例如,英寸 (") 或厘米 (cm)。一个项目的重量也携带一个度量单位:例如,磅 (lbs) 或千克 (kg)。为了捕获度量(名称和符号)的常见属性,我们将为 Dimensions 和 Weight 定义一个名为 Measurement 的超类。接下来的源代码可以在 mapping-inheritance-embeddable 文件夹中找到。
列表 7.19 映射 Measurement 抽象嵌入超类
Path: Ch07/mapping-inheritance-embeddable/src/main/java/com/manning
➥ /javapersistence/ch07/model/Measurement.java
@MappedSuperclass
public abstract class Measurement {
@NotNull
private String name;
@NotNull
private String symbol;
// . . .
}
我们在映射的嵌入类超类上使用了 @MappedSuperclass 注解,就像我们会对一个实体做的那样。子类将继承这个类的属性作为持久属性。
我们将定义 Dimensions 和 Weight 子类为 @Embeddable。对于 Dimensions,我们将覆盖所有超类属性并添加一个列名前缀。
列表 7.20 映射Dimensions类
Path: Ch07/mapping-inheritance-embeddable/src/main/java/com/manning
➥ /javapersistence/ch07/model/Dimensions.java
@Embeddable
@AttributeOverride(name = "name",
column = @Column(name = "DIMENSIONS_NAME"))
@AttributeOverride(name = "symbol",
column = @Column(name = "DIMENSIONS_SYMBOL"))
public class Dimensions extends Measurement {
@NotNull
private BigDecimal depth;
@NotNull
private BigDecimal height;
@NotNull
private BigDecimal width;
// . . .
}
如果没有这个覆盖,同时嵌入Dimensions和Weight的Item将映射到一个具有冲突列名的表。
接下来是Weight类;其映射也覆盖了列名前缀(为了统一,我们避免与之前的覆盖冲突)。
列表 7.21 映射Weight类
Path: Ch07/mapping-inheritance-embeddable/src/main/java/com/manning
➥ /javapersistence/ch07/model/Weight.java
@Embeddable
@AttributeOverride(name = "name",
column = @Column(name = "WEIGHT_NAME"))
@AttributeOverride(name = "symbol",
column = @Column(name = "WEIGHT_SYMBOL"))
public class Weight extends Measurement {
@NotNull
@Column(name = "WEIGHT")
private BigDecimal value;
// . . .
}
拥有实体Item定义了两个常规持久化嵌入式属性。
列表 7.22 映射Item类
Path: Ch07/mapping-inheritance-embeddable/src/main/java/com/manning
➥ /javapersistence/ch07/model/Item.java
@Entity
public class Item {
private Dimensions dimensions;
private Weight weight;
// . . .
}
图 7.5 展示了这种映射。

图 7.5 映射具有继承属性的实体类
或者,我们可以在Item类中覆盖嵌入式属性的冲突Measurement列名,正如在第 6.2 节中演示的那样。然而,我们更喜欢在@Embeddable类中一次性覆盖它们,这样这些类的消费者就不必解决冲突。
一个需要注意的陷阱是将抽象超类类型(如Measurement)的属性嵌入到实体(如Item)中。这永远不可能工作;JPA 提供者不知道如何以多态方式存储和加载Measurement实例。它没有必要的信息来决定数据库中的值是Dimensions还是Weight实例,因为没有区分器。这意味着虽然我们可以让@Embeddable类从@MappedSuperclass继承一些持久化属性,但引用实例不是多态的——它总是指一个具体类。
将此与第 6.3.2 节中“转换组件属性”部分检查的嵌入式类的替代继承策略进行比较,该策略支持多态性,但需要一些自定义类型区分代码。
接下来,我们将提供一些关于如何为应用程序的类层次结构选择合适的映射策略的技巧。
7.7 选择策略
您选择的继承映射策略将取决于超类在实体层次结构中的使用方式。您必须考虑您查询超类实例的频率以及您是否有针对超类的关联。另一个重要方面是超类和子类的属性:子类是否具有许多额外的属性,或者是否仅与超类有不同的行为。以下是一些经验法则:
-
如果您不需要多态关联或查询,则倾向于按具体类创建表——换句话说,如果您从不或很少
selectbdfrom BillingDetails bd,并且没有具有BillingDetails关联的类。应首选基于显式UNION映射的InheritanceType.TABLE_PER_CLASS,因为(优化后的)多态查询和关联将在以后成为可能。 -
如果你确实需要多态关联(一个指向超类的关联,因此指向在运行时动态解析的具体类)或查询,并且子类声明了相对较少的属性(尤其是如果子类之间的主要区别在于它们的行为),则倾向于使用
InheritanceType.SINGLE_TABLE。如果这涉及到设置尽可能少的可空列,则可以选择这种方法。你需要说服自己(以及数据库管理员),非规范化的模式在长期内不会造成问题。 -
如果你确实需要多态关联或查询,并且子类声明了许多(非可选)属性(子类主要在它们持有的数据上有所不同),则倾向于使用
InheritanceType.JOINED。或者,根据继承层次结构的宽度和深度以及连接与联合的可能成本,使用InheritanceType.TABLE_PER_CLASS。这个决定可能需要评估带有真实数据的 SQL 执行计划。
默认情况下,仅对于简单问题选择 InheritanceType.SINGLE_TABLE。对于复杂情况,或者当数据模型师坚持认为 NOT NULL 约束和规范化比你的需求更重要时,你应该考虑 Inheritance-Type .JOINED 策略。在这种情况下,你应该问自己,是否可能将继承在类模型中重新建模为代理更好。由于各种与持久化或 ORM 无关的原因,复杂的继承通常最好避免。Hibernate 作为领域模型和关系模型之间的缓冲区,但这并不意味着在设计类时可以完全忽略持久化问题。
当你开始考虑混合继承策略时,你必须记住,Hibernate 中的隐式多态足够智能,可以处理复杂的情况。此外,你必须考虑,你无法在接口上放置继承注解;这在 JPA 中没有标准化。
例如,假设你需要在 CaveatEmptor 应用程序中添加一个接口:ElectronicPaymentOption。这是一个没有持久化方面的业务接口,除了持久化类如 CreditCard 可能会实现这个接口。无论我们如何映射 BillingDetails 层次结构,Hibernate 都可以正确地回答查询 select o from ElectronicPaymentOption o。即使其他不属于 BillingDetails 层次结构的类被映射为持久化并实现这个接口,这也同样适用。Hibernate 总是知道要查询哪些表,要构造哪些实例,以及如何返回多态结果。
我们可以将所有映射策略应用于抽象类。即使我们查询或加载它,Hibernate 也不会尝试实例化一个抽象类。
我们多次提到了User和BillingDetails之间的关系,并探讨了它如何影响继承映射策略的选择。在接下来的最后一节中,我们将详细探讨这个更高级的主题:多态关联。如果你现在模型中没有这样的关系,你可能希望在遇到应用程序中的问题时再回过头来研究这个主题。
7.8 多态关联
多态是像 Java 这样的面向对象语言的一个定义性特征。对多态关联和多态查询的支持是像 Hibernate 这样的 ORM 解决方案的一个基本特性。令人惊讶的是,我们设法走到了这里而不需要过多地谈论多态。令人耳目一新的是,关于这个话题没有太多可说的——多态在 Hibernate 中如此容易使用,以至于我们不需要花费太多精力来解释它。
为了提供一个概述,我们首先考虑一个可能具有子类的一个到多关联,然后是一个多对一关系。对于这两个例子,领域模型中的类是相同的;参见图 7.6。

图 7.6 一个用户默认的账单详情要么是信用卡要么是银行账户。
7.8.1 多态多对一关联
首先,考虑User的defaultBilling属性。它引用了一个特定的BillingDetails实例,在运行时可以是该类任何具体的实例。下面的源代码可以在mapping-inheritance-manytoone文件夹中找到。
我们将把这个单向关联映射到抽象类BillingDetails,如下所示:
Path: Ch07/mapping-inheritance-manytoone/src/main/java/com/manning
➥ /javapersistence/ch07/model/User.java
@Entity
@Table(name = "USERS")
public class User {
@ManyToOne
private BillingDetails defaultBilling;
// . . .
}
USERS表现在有一个表示这个关系的连接/外键列DEFAULTBILLING_ID。它是一个可空列,因为一个User可能没有分配默认的账单方法。因为BillingDetails是抽象的,所以关联必须在运行时引用其子类之一——CreditCard或BankAccount。
在 Hibernate 中启用多态关联不需要做任何特殊的事情。如果一个关联的目标类用@Entity和@Inheritance映射,那么这个关联就是自然的多态的。
下面的 Spring Data JPA 代码演示了创建一个到CreditCard子类实例的关联:
Path: Ch07/mapping-inheritance-manytoone/src/test/java/com/manning
➥ /javapersistence/ch07/MappingInheritanceSpringDataJPATest.java
CreditCard creditCard = new CreditCard(
"John Smith", "123456789", "10", "2030"
);
User john = new User("John Smith");
john.setDefaultBilling(creditCard);
creditCardRepository.save(creditCard);
userRepository.save(john);
现在,当我们在一个工作单元中导航这个关联时,Hibernate 会自动检索CreditCard实例:
List<User> users = userRepository.findAll();
users.get(0).getDefaultBilling().pay(123);
这里的第二行将调用BillingDetails具体子类的pay方法。
我们可以以相同的方式处理一对一关联。那么对于多对一关联,比如每个User的billingDetails集合,怎么办呢?让我们接下来看看。
7.8.2 多态集合
一个 User 可能会引用多个 BillingDetails,而不仅仅是单个默认值(许多中的一个可能是默认值,但现在我们忽略这一点)。我们可以通过双向 一对多 关联来映射这一点。接下来的源代码可以在 mapping-inheritance-onetomany 文件夹中找到。
Path: Ch07/mapping-inheritance-onetomany/src/main/java/com/manning
➥ /javapersistence/ch07/model/User.java
@Entity
@Table(name = "USERS")
public class User {
@OneToMany(mappedBy = "user")
private Set<BillingDetails> billingDetails = new HashSet<>();
// . . .
}
接下来,这是关系的拥有方(在前一个映射中用 mappedBy 声明)。通过“拥有方”,我们指的是在数据库中拥有外键的关系那一方,在这个例子中是 BillingDetails。
Path: Ch07/mapping-inheritance-onetomany/src/main/java/com/manning
➥ /javapersistence/ch07/model/BillingDetails.java
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class BillingDetails {
@ManyToOne
private User user;
// . . .
}
到目前为止,这种关联映射并没有什么特别之处。BillingDetails 类层次结构可以使用 TABLE_PER_CLASS、SINGLE_TABLE 或 JOINED 继承类型进行映射。Hibernate 足够智能,在加载集合元素时,会使用正确的 SQL 查询,无论是使用 JOIN 还是 UNION 操作符。
然而,有一个限制:如第 7.1 节所述,BillingDetails 类不能是 @MappedSuperclass。它必须使用 @Entity 和 @Inheritance 进行映射。
摘要
-
按具体类表与隐式多态是映射实体继承层次结构的最简单策略,但它并不很好地支持多态关联。
-
来自不同表的列具有完全相同的语义,这使得模式演变更加复杂。
-
这种按具体类表的方法仅推荐用于类层次结构的顶层,因为在这些地方通常不需要多态性,并且未来修改超类不太可能。
-
按具体类表与并集的策略是可选的,并且 JPA 实现可能不支持它,但它确实处理了多态关联。
-
按类层次结构表的方法在性能和简单性方面都是赢家。可以执行临时报告,而无需复杂的连接或并集操作,并且模式演变是直接的。
-
单表策略的一个主要问题是数据完整性,因为我们必须将一些列声明为可空的。另一个关注点是规范化:这种策略在非键列之间创建了函数依赖关系,违反了第三范式。
-
按子类表连接的策略的主要优点是它使 SQL 模式规范化,使得模式演变和完整性约束定义变得直接。缺点是手动实现更困难,并且对于复杂的类层次结构,性能可能无法接受。
8 映射集合和实体关联
本章涵盖
-
映射持久集合
-
检查基本类型和可嵌入类型的集合
-
调查简单的多对一和一对多实体关联
许多开发者在开始使用 Hibernate 或 Spring Data JPA 时,首先尝试做的事情是映射一个 父/子关系。这通常是它们第一次遇到集合。这也是他们第一次必须考虑实体和价值类型之间的区别,或者陷入 ORM 的复杂性中。
管理类之间的关联和表之间的关系是 ORM 的核心。在实现 ORM 解决方案时遇到的许多难题都与集合和实体关联管理有关。我们将从一些基本的集合映射概念和简单示例开始本章。之后,你将准备好处理实体关联中的第一个集合——我们将在下一章回到更复杂的实体关联映射。为了全面了解,我们建议你阅读本章和下一章。
JPA 2 中的主要新功能
增加了基本类型和可嵌入类型集合和映射的支持。
增加了持久列表的支持,其中每个元素的索引存储在额外的数据库列中。
一对多关联现在有一个孤儿删除选项。
8.1 值类型的集合、包、列表和映射
Java 拥有一个丰富的集合 API,我们可以从中选择最适合领域模型设计的接口和实现。在本章中,我们将使用 Java 集合框架来实现,并介绍最常见的集合映射,包括对 Image 和 Item 的相同示例进行细微的修改。
我们首先将查看数据库模式,并创建和映射一个集合属性。数据库通常是首先设计的,我们的程序必须与之协同工作。然后我们将继续选择特定的集合接口,并映射各种集合类型:一个集合、一个标识符包、一个列表、一个映射,最后是排序和有序集合。
8.1.1 数据库模式
我们将扩展 CaveatEmptor 以支持将图像附加到拍卖物品上。带有相关图像的物品对潜在买家更有吸引力。现在我们将忽略 Java 代码,只考虑数据库模式。随后的源代码可以在 mapping-collections 文件夹中找到。
注意:要执行源代码中的示例,你首先需要运行 Ch08.sql 脚本。
对于拍卖物品和图像示例,假设图像存储在文件系统中的某个位置,我们只将文件名存储在数据库中。当从数据库中删除图像时,必须有一个单独的过程从磁盘上删除文件。
我们需要在数据库中有一个IMAGE表来存储图像,或者可能只是存储图像的文件名。此表还将有一个外键列,例如ITEM_ID,它引用ITEM表。参见图 8.1 所示的架构。

图 8.1 IMAGE表存储图像文件名,每个文件名都引用一个ITEM_ID。
这就是所有模式的内容——没有集合或组合。
8.1.2 创建和映射集合属性
我们将如何使用我们目前所知道的信息来映射这个IMAGE表?我们可能会将其映射为一个名为Image的@Entity类。在本章的后面,我们将映射一个外键列与@ManyToOne属性相关联,以建立实体之间的关联。我们还需要为实体类创建一个复合主键映射,我们将在第 10.2.2 节中首先演示。我们现在需要知道的是,复合主键是多个列的组合,用于在表中唯一标识一行。单个列可能不是唯一的,但它们的组合必须是唯一的。
没有映射的图像集合;它们不是必需的。当我们需要某个项目的图像时,我们可以用 JPA 查询语言编写和执行一个查询:
select img from Image img where img.item = :itemParameter
持久化集合总是可选的。
我们可以创建一个Item#images集合,它引用特定项目的所有图像。我们可以创建并映射这个集合属性来完成以下操作:
-
当我们调用
someItem.getImages()时,会自动执行 SQL 查询SELECT * from IMAGE where ITEM_ID = ?。只要域模型实例处于管理状态(稍后会有更多介绍),我们就可以在导航类之间的关联时按需从数据库中读取。我们不必手动编写和执行查询来加载数据。另一方面,当我们开始迭代集合时,集合查询始终是“此项目的所有图像”,而不是“仅匹配 XYZ 条件的图像。” -
避免使用
entityManager.persist()或imageRepository .save()来保存每个Image。如果我们有一个映射的集合,通过someItem.getImages().add()将Image添加到集合中,当保存Item时,它将自动持久化。这种级联持久化很方便,因为我们可以在不调用存储库或EntityManager的情况下保存实例。 -
Image具有依赖的生命周期。当一个Item被删除时,Hibernate 会通过额外的 SQLDELETE删除所有附加的Image。我们不必担心图像的生命周期和清理孤儿(假设数据库外键约束没有ON DELETE CASCADE)。JPA 提供者处理组合生命周期。
重要的是要认识到,尽管这些好处听起来很棒,但我们付出的代价是额外的映射复杂性。许多 JPA 初学者在集合映射上挣扎,而且经常有人问“你为什么要这样做?”答案是“我以为这个集合是必需的。”
如果我们分析如何处理拍卖物品的图像场景,我们会发现我们可以从集合映射中受益。图像具有依赖的生命周期;当删除一个项目时,所有附加的图像都应该被删除。当存储一个项目时,所有附加的图像都应该被存储。当显示一个项目时,我们通常会显示所有图像,因此 someItem.getImages() 在 UI 代码中很方便——这实际上是一种信息的需求加载。我们不需要再次调用持久化服务来获取图像;它们只是“在那里”。
现在,我们将继续选择最适合领域模型设计的集合接口和实现。让我们通过最常用的集合映射来探讨,以微小的变化重复使用相同的 Image 和 Item 示例。
8.1.3 选择集合接口
这是 Java 领域模型中集合属性的惯用方法:
<<Interface>> images = new <<Implementation>>();
// Getter and setter methods
// . . .
使用接口来声明属性的类型,而不是实现。选择一个匹配的实现,并立即初始化集合;这样做可以避免未初始化的集合。我们不推荐在构造函数或设置方法中较晚初始化集合。
使用泛型,这是一个典型的 Set:
Set<Image> images = new HashSet<Image>();
不使用泛型的原始集合
如果我们没有使用泛型指定集合元素的类型,或者映射的键/值类型,我们需要告诉 Hibernate 类型(或类型)。例如,我们可以使用 @ElementCollection(targetClass= String.class) 将原始 Set 映射为 Set<String>。这也适用于 Map 的类型参数。使用 @MapKeyClass 指定 Map 的键类型。
本书中的所有示例都使用泛型集合和映射,你们也应该这样做。
Hibernate 默认支持最重要的 JDK 集合接口,并以持久化的方式保留了 JDK 集合、映射和数组语义。每个 JDK 接口都有一个由 Hibernate 支持的对应实现,并且使用正确的组合非常重要。Hibernate 在字段声明时封装已初始化的集合,有时如果它不是正确的类型,则会替换它。这样做是为了实现诸如延迟加载和集合元素的脏检查等功能。
在不扩展 Hibernate 的情况下,我们可以从以下集合中进行选择:
-
一个使用
java.util.HashSet初始化的java.util.Set属性。元素顺序不被保留,不允许重复元素。所有 JPA 提供商都支持此类型。 -
一个使用
java.util.TreeSet初始化的java.util.SortedSet属性。此集合支持元素的稳定顺序:排序发生在 Hibernate 加载数据之后。这是 Hibernate 独有的扩展;其他 JPA 提供商可能会忽略集合的“排序”方面。 -
一个使用
java.util.ArrayList初始化的java.util.List属性。Hibernate 使用数据库表中的额外索引列来保留每个元素的位置。所有 JPA 提供商都支持此类型。 -
一个使用
java.util.ArrayList初始化的java.util.Collection属性。这个集合具有 包 语义;可能存在重复项,但元素的顺序不会被保留。所有 JPA 提供商都支持此类型。 -
一个使用
java.util.HashMap初始化的java.util.Map属性。映射中的键值对可以在数据库中保留。所有 JPA 提供商都支持此类型。 -
一个使用
java.util.TreeMap初始化的java.util.SortedMap属性。它支持元素的稳定顺序:排序发生在 Hibernate 加载数据后。这是一个仅 Hibernate 扩展;其他 JPA 提供商可能会忽略映射的“排序”方面。 -
Hibernate 支持持久化数组,但 JPA 不支持。它们很少使用,本书中不会展示它们。Hibernate 不能包装数组属性,因此集合的许多好处,如按需懒加载,将不会工作。只有在你确定不需要懒加载时,才在你的领域模型中使用持久化数组。(你可以按需加载数组,但这需要通过字节码增强进行拦截,如 12.1.3 节中所述。)
如果我们想要映射 Hibernate 直接不支持集合接口和实现,我们需要告诉 Hibernate 关于自定义集合的语义。Hibernate 的扩展点是 org.hibernate.collection.spi 包中的 PersistentCollection 接口,我们通常扩展现有的 PersistentSet、PersistentBag 和 PersistentList 中的一个类。自定义持久化集合不易编写,我们不推荐如果你不是经验丰富的 Hibernate 用户就进行此操作。
事务性文件系统
如果我们只将图像的文件名存储在 SQL 数据库中,我们必须将每张图片的二进制数据——文件——存储在某个地方。我们可以在 SQL 数据库的 BLOB 列中存储图像数据(参见 6.3.1 节中的“二进制和大型值类型”)。
如果我们决定不将图像存储在数据库中,而是作为常规文件,我们应该意识到标准的 Java 文件系统 API,java.io.File 和 java.nio.file.Files,不是事务性的。文件系统操作不会被纳入 Java 事务 API (JTA) 系统事务;一个事务可能成功完成,Hibernate 将文件名写入 SQL 数据库,但文件在文件系统中的存储或删除可能会失败。我们无法将这些操作作为一个原子单元回滚,并且我们无法获得操作的正确隔离性。
你可以使用一个单独的系统事务管理器,如 Bitronix。然后,文件操作将与 Hibernate 的 SQL 操作一起在同一个事务中注册、提交和回滚。
让我们映射一个 Item 的图像文件名集合。
8.1.4 映射集合
映射集合的最简单实现是 String 图像文件名的 Set。向 Item 类中添加一个集合属性,如下所示列表中所示。
列表 8.1 将图像映射为简单的字符串集合
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/setofstrings/Item.java
@Entity
public class Item {
// . . .
@ElementCollection Ⓐ
@CollectionTable(
name = "IMAGE", Ⓑ
joinColumns = @JoinColumn(name = "ITEM_ID")) Ⓒ
@Column(name = "FILENAME") Ⓓ
private Set<String> images = new HashSet<>(); Ⓔ
Ⓐ 将 images 字段声明为 @ElementCollection。在这里,我们指的是系统中的图像路径,但为了简洁起见,我们将使用字段和列的名称,例如 image 或 images。
Ⓑ 集合表将被命名为 IMAGE。否则,它将默认为 ITEM_IMAGES。
Ⓒ ITEM 表和 IMAGE 表之间的连接列将是 ITEM_ID(实际上是默认名称)。
Ⓓ 将包含 images 集合中的字符串信息的列命名为 FILENAME。否则,它将默认为 IMAGES。
Ⓔ 将 images 集合初始化为 HashSet。
在前面的列表中,@ElementCollection JPA 注解对于值类型元素的集合是必需的。如果没有 @CollectionTable 和 @Column 注解,Hibernate 将使用默认的模式名称。查看图 8.2 中的模式:主键列被下划线标注。

图 8.2 字符串集合的表结构和示例数据
IMAGE 表具有由 ITEM_ID 和 FILENAME 列组成的复合主键。这意味着我们不能有重复的行:每个图像文件只能附加到一项上一次。此外,图像的顺序没有存储。这符合领域模型和 Set 集合。图像存储在文件系统中的某个位置,我们只保留数据库中的文件名。
为了与 Item 实体交互,我们将创建以下 Spring Data JPA 仓库。
列表 8.2 ItemRepository 接口
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/repositories/setofstrings/ItemRepository.java
public interface ItemRepository extends JpaRepository<Item, Long> {
@Query("select i from Item i inner join fetch i.images where i.id = :id") Ⓐ
Item findItemWithImages(@Param("id") Long id); Ⓐ
@Query(value = "SELECT FILENAME FROM IMAGE WHERE ITEM_ID = ?1", Ⓑ
nativeQuery = true) Ⓑ
Set<String> findImagesNative(Long id); Ⓑ
}
Ⓐ 声明一个名为 findItemWithImages 的方法,该方法将通过 id 获取 Item,包括 images 集合。为了 eager 获取此集合,我们将使用 Jakarta Persistence Query Language (JPQL) 的 inner join fetch 功能。
Ⓑ 声明 findImagesNative 方法,该方法被注解为原生查询,并将获取表示给定 id 的 images 的字符串集合。
我们还将创建以下测试。
列表 8.3 MappingCollectionsSpringDataJPATest 类
Path: Ch08/mapping-collections/src/test/java/com/manning/javapersistence
➥ /ch08//setofstrings/MappingCollectionsSpringDataJPATest.java
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {SpringDataConfiguration.class})
public class MappingCollectionsSpringDataJPATest {
@Autowired
private ItemRepository itemRepository;
@Test
void storeLoadEntities() {
Item item = new Item("Foo"); Ⓐ
item.addImage("background.jpg"); Ⓑ
item.addImage("foreground.jpg"); Ⓑ
item.addImage("landscape.jpg"); Ⓑ
item.addImage("portrait.jpg"); Ⓑ
itemRepository.save(item); Ⓒ
Item item2 = itemRepository.findItemWithImages(item.getId()); Ⓓ
List<Item> items2 = itemRepository.findAll(); Ⓔ
Set<String> images = itemRepository.findImagesNative(item.getId()); Ⓕ
assertAll(
() -> assertEquals(4, item2.getImages().size()), Ⓖ
() -> assertEquals(1, items2.size()), Ⓖ
() -> assertEquals(4, images.size()) Ⓖ
);
}
}
Ⓐ 创建一个 Item。
Ⓑ 向其中添加 4 个图像路径。
Ⓒ 将其保存到数据库中。
Ⓓ 访问仓库以获取包含 images 集合的项。正如我们在 findItemWithImages 方法上注解的 JPQL 查询中所指定的,该集合也将从数据库中获取。
Ⓔ 从数据库中获取所有 Item。
Ⓕ 使用 findImagesNative 方法获取表示图像的字符串集合。
Ⓖ 检查我们获取的不同集合的大小。
看起来我们不太可能允许用户将相同的图像多次附加到同一项上,但让我们假设我们确实这样做了。在这种情况下,哪种映射是合适的?
8.1.5 映射标识符包
一个包是一个无序集合,允许重复元素,就像java.util.Collection接口一样。奇怪的是,Java 集合框架没有包含包实现。我们可以用ArrayList初始化属性,Hibernate 在存储和加载元素时忽略元素的索引。
列表 8.4 字符串包,允许重复元素
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/bagofstrings/Item.java
@Entity
public class Item {
// . . .
@ElementCollection
@CollectionTable(name = "IMAGE")
@Column(name = "FILENAME")
@GenericGenerator(name = "sequence_gen", strategy = "sequence") Ⓐ
@org.hibernate.annotations.CollectionId( Ⓑ
columns = @Column(name = "IMAGE_ID"), Ⓒ
type = @org.hibernate.annotations.Type(type = "long"), Ⓓ
generator = "sequence_gen") Ⓔ
private Collection<String> images = new ArrayList<>(); Ⓕ
Ⓐ 声明一个名为"sequence_gen"的@GenericGenerator,使用"sequence"策略来处理IMAGE表中的代理键。
Ⓑ IMAGE集合表需要一个不同的主键,以便为每个ITEM_ID允许重复的FILENAME值。
Ⓒ 引入一个名为IMAGE_ID的代理主键列。你可以同时检索所有图片或同时存储它们,但数据库表仍然需要一个主键。
Ⓓ 使用仅 Hibernate 的注解。
Ⓔ 配置主键的生成方式。
Ⓕ JDK 中没有包实现。我们初始化集合为ArrayList。
通常,当你保存实体实例时,你希望系统生成一个主键值。如果你需要刷新关于键生成器的记忆,请参阅第 5.2.4 节。修改后的模式如图 8.3 所示。Spring Data JPA 仓库和测试将与前一个示例相同。

图 8.3 包字符串的代理主键列
这里有一个有趣的问题:如果你只看到这个模式,你能说出 Java 中表是如何映射的吗?ITEM和IMAGE表看起来很相似:每个表都有一个代理主键列和一些其他规范化列。每个表都可以用一个@Entity类来映射。然而,我们可以决定使用 JPA 的一个特性,将一个集合映射到IMAGE,即使有组合生命周期。这实际上是一个设计决策,即对于这个表,我们只需要一些预定义的查询和操作规则,而不是更通用的@Entity映射。当你做出这样的决定时,一定要知道为什么以及会有什么后果。
下一个映射技术保留列表中图片的顺序。
8.1.6 映射列表
如果你之前没有使用过 ORM 软件,持久化列表似乎是一个非常强大的概念;想象一下,使用纯 JDBC 和 SQL 存储和加载java.util.List<String>需要多少工作。如果我们向列表中间添加一个元素,列表会将其后的所有元素向右移动或重新排列指针,具体取决于列表的实现。如果我们从列表中间删除一个元素,会发生其他事情,依此类推。如果 ORM 软件可以自动为数据库记录做所有这些事情,持久化列表开始看起来比它实际上更有吸引力。
正如我们在 3.2.4 节中提到的,最初的反应通常是保留用户输入的数据元素的顺序,因为你通常会按相同的顺序稍后显示它们。但如果可以使用其他标准对数据进行排序,如条目时间戳,那么在查询时应对数据进行排序,而不是存储显示顺序。如果你需要使用的显示顺序发生变化怎么办?数据显示的顺序通常不是数据的一个组成部分,而是一个正交的关注点,所以在映射持久化 List 之前要三思,因为 Hibernate 并不像你想象中那么聪明,你将在下一个例子中看到。
让我们更改 Item 实体及其集合属性。
列表 8.5 持久化列表,保留数据库中元素顺序
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/listofstrings/Item.java
@Entity
public class Item {
// . . .
@ElementCollection
@CollectionTable(name = "IMAGE")
@OrderColumn // Enables persistent order, Defaults to IMAGES_ORDER
@Column(name = "FILENAME")
private List<String> images = new ArrayList<>();
在这个例子中有一个新的注解:@OrderColumn。这个列存储持久化列表中的索引,从零开始。列名默认为 IMAGES_ ORDER。请注意,Hibernate 将索引存储在数据库中,并期望它是连续的。如果有空隙,Hibernate 在加载和构建 List 时会添加 null 元素。查看图 8.4 中的模式。

图 8.4 集合表保留每个列表元素的位置。
IMAGE 表的主键是 ITEM_ID 和 IMAGES_ORDER 的组合。这允许重复的 FILENAME 值,这与 List 的语义一致。记住,图像存储在文件系统中的某个位置,我们只保留数据库中的文件名。Spring Data JPA 仓库和测试将与上一个例子相同。
我们之前提到过,Hibernate 并不像你想象中那么聪明。考虑对列表进行修改:假设列表中有三个图像,A、B 和 C,按此顺序排列。如果你从列表中删除 A 会发生什么?Hibernate 为该行执行一个 SQL DELETE 操作。然后它执行两个 UPDATE 操作,针对 B 和 C,将它们的位移动到左边以关闭索引中的空隙。对于删除元素右侧的每个元素,Hibernate 执行一个 UPDATE 操作。如果我们手动编写 SQL,我们可以用一个 UPDATE 操作来完成。对于列表中间的插入也是如此——Hibernate 会逐个将所有现有元素向右移动。至少,Hibernate 足够聪明,在我们调用 clear() 清除列表时执行单个 DELETE 操作。
现在假设一个项目的图像除了文件名外还有用户提供的名称。在 Java 中,可以通过使用键/值对映射来实现这种模型。
8.1.7 映射映射
为了适应用户提供的图像文件名称,我们将更改 Java 类以使用 Map 属性。
列表 8.6 持久化映射存储其键和值对
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/mapofstrings/Item.java
@Entity
public class Item {
// . . .
@ElementCollection
@CollectionTable(name = "IMAGE")
@MapKeyColumn(name = "FILENAME") Ⓐ
@Column(name = "IMAGENAME") Ⓑ
private Map<String, String> images = new HashMap<>();
Ⓐ 每个映射条目都是一个键/值对。在这里,键通过 @MapKeyColumn 映射到 FILENAME。
Ⓑ 值是 IMAGENAME 列。这意味着用户只能使用一个文件一次,因为 Map 不允许重复的键。
如您从图 8.5 中的模式中看到的那样,集合表的键是 ITEM_ID 和 FILENAME 的组合。示例使用 String 作为映射的键,但 Hibernate 支持任何基本类型,例如 BigDecimal 或 Integer。如果键是 Java enum,则必须使用 @MapKeyEnumerated。对于任何时间类型,如 java.util.Date,使用 @MapKeyTemporal。

图 8.5 使用字符串作为索引和元素的映射表
在前面的例子中,映射是无序的。如果文件列表很长,我们想要快速浏览寻找某个东西,我们如何始终按文件名对映射条目进行排序?
8.1.8 有序和排序集合
我们可以使用 Java 比较器在内存中对集合进行排序。当从数据库加载集合时,我们可以使用带有 ORDER BY 子句的 SQL 查询来排序集合。
让我们将图像映射转换为有序映射。我们需要更改 Java 属性和映射。
列表 8.7 使用比较器在内存中对映射条目进行排序
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/sortedmapofstrings/Item.java
@Entity
public class Item {
// . . .
@ElementCollection
@CollectionTable(name = "IMAGE")
@MapKeyColumn(name = "FILENAME")
@Column(name = "IMAGENAME")
@org.hibernate.annotations.SortComparator(ReverseStringComparator.class)
private SortedMap<String, String> images = new TreeMap<>();
有序集合是 Hibernate 的功能;因此使用 org.hibernate.annotations.SortComparator 注解,该注解实现了 java.util.Comparator <String>——这里显示的是按逆序排序字符串。数据库模式不会改变,所有以下示例也是如此。如果需要提醒,请查看前几节中的图 8.1–8.5。
我们将在测试中添加以下两行,这将检查键现在是否为逆序:
() -> assertEquals("Portrait", item2.getImages().firstKey()),
() -> assertEquals("Background", item2.getImages().lastKey())
我们将在下面的示例中将 java.util.SortedSet 进行映射。您可以在映射集合文件夹中的 sortedsetofstrings 示例中找到它。
列表 8.8 使用 String#compareTo() 在内存中对集合元素进行排序
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/sortedsetofstrings/Item.java
@Entity
public class Item {
// . . .
@ElementCollection
@CollectionTable(name = "IMAGE")
@Column(name = "FILENAME")
@org.hibernate.annotations.SortNatural
private SortedSet<String> images = new TreeSet< >();
这里使用了自然排序,回退到 String#compareTo() 方法。
不幸的是,我们无法对包进行排序;没有 TreeBag。列表元素的索引预先定义了它们的顺序。或者,我们可能想要从数据库中检索集合元素的正确顺序,而不是在内存中进行排序。在下面的列表中,我们将使用 java.util.LinkedHashSet 而不是切换到 Sorted* 接口。而不是使用 java.util.SortedSet,我们将使用 java.util.LinkedHashSet。
列表 8.9 LinkedHashSet 提供了迭代顺序
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/setofstringsorderby/Item.java
@Entity
public class Item {
// . . .
@ElementCollection
@CollectionTable(name = "IMAGE")
@Column(name = "FILENAME")
// @javax.persistence.OrderBy // One possible order: "FILENAME asc"
@org.hibernate.annotations.OrderBy(clause = "FILENAME desc")
private Set<String> images = new LinkedHashSet<>();
LinkedHashSet 类在其元素上具有稳定的迭代顺序,当加载集合时,Hibernate 将以正确的顺序填充它。为此,Hibernate 将 ORDER BY 子句应用于加载集合的 SQL 语句。我们必须使用专有的 @org.hibernate.annotations.OrderBy 注解声明此 SQL 子句。我们可以调用一个 SQL 函数,例如 @OrderBy("substring(FILENAME, 0, 3) desc"),这将按文件名的第一个三个字母进行排序,但请注意检查所调用的 DBMS 是否支持该 SQL 函数。此外,您可以使用 SQL:2003 语法 ORDER BY . . . . NULLS FIRST|LAST,Hibernate 将自动将其转换为您的 DBMS 所支持的方言。
如果表达式只是一个带有 ASC 或 DESC 的列名,则 @javax.persistence.OrderBy 注解也适用。如果您需要一个更复杂的子句(如前一段中的 substring() 示例),则需要 @org.hibernate.annotations.OrderBy 注解。
Hibernate 的 @OrderBy 与 JPA 的 @OrderBy
您可以将 @org.hibernate.annotations.OrderBy 注解应用于任何集合;其参数是一个简单的 SQL 片段,Hibernate 会将其附加到加载集合的 SQL 语句中。
Java 持久性有一个类似的注解,@javax.persistence.OrderBy。它的唯一参数不是 SQL,而是 someProperty DESC|ASC。String 或 Integer 元素值没有属性,因此当我们对基本类型集合应用 JPA 的 @OrderBy 注解时,例如列表 8.9 中的 Set<String>,根据规范,“排序将按基本对象的值进行”。这意味着我们无法更改排序值(只是方向,asc 或 desc)。当元素值类具有持久属性且不是基本/标量类型时,我们将在 8.2.2 节中使用 JPA 注解。
bagofstringsorderby 的下一个示例展示了使用映射包在加载时的相同排序。您可以在映射集合文件夹中找到它。
列表 8.10 ArrayList 提供稳定的迭代顺序
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/bagofstringsorderby/Item.java
@Entity
public class Item {
// . . .
@ElementCollection
@CollectionTable(name = "IMAGE")
@Column(name = "FILENAME")
@GenericGenerator(name = "sequence_gen", strategy = "sequence")
@org.hibernate.annotations.CollectionId(
columns = @Column(name = "IMAGE_ID"),
type = @org.hibernate.annotations.Type(type = "long"),
generator = "sequence_gen")
@org.hibernate.annotations.OrderBy(clause = "FILENAME desc")
private Collection<String> images = new ArrayList<>();
最后,我们可以使用 LinkedHashMap 加载有序键/值对。
列表 8.11 LinkedHashMap 保持键/值对顺序
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/mapofstringsorderby/Item.java
@Entity
public class Item {
// . . .
@ElementCollection
@CollectionTable(name = "IMAGE")
@MapKeyColumn(name = "FILENAME")
@Column(name = "IMAGENAME")
@org.hibernate.annotations.OrderBy(clause = "FILENAME desc")
private Map<String, String> images = new LinkedHashMap<>();
请记住,有序集合的元素只有在加载时才处于所需顺序。一旦我们添加或删除元素,集合的迭代顺序可能与“按文件名”不同;它们的行为类似于常规的链接集合、映射或列表。我们展示了技术方法,但我们需要意识到其不足之处,并得出结论,这些使其成为一种不太可靠的解决方案。
在实际系统中,我们可能需要存储的不仅仅是图像名称和文件名。我们可能需要创建一个 Image 类来存储额外信息(如标题、宽度和高度)。这是一个组件集合的完美用例。
8.2 组件集合
我们之前映射了一个可嵌入组件:User 的 address。我们本章正在处理的示例是不同的,因为 Item 有许多对 Image 的引用,如图 8.6 所示。UML 图中的关联是组合(黑色菱形);因此,引用的 Image 与拥有 Item 的生命周期绑定。

图 8.6 Item 中的 Image 组件集合
下面的列表中的代码演示了新的 Image 可嵌入类,捕获了我们感兴趣的图像的所有属性。
列表 8.12 封装图像的所有属性
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/setofembeddables/Image.java
@Embeddable
public class Image {
@Column(nullable = false)
private String filename;
private int width;
private int height;
// . . .
}
首先,请注意,所有属性都是非可选的,NOT NULL。大小属性是非可空的,因为它们的值是原始数据类型。其次,我们必须考虑相等性,以及数据库和 Java 层如何比较两个图像。
8.2.1 组件实例的相等性
假设我们想在 HashSet 中保留几个 Image 实例。我们知道集合不允许重复元素,但集合是如何检测重复的呢?HashSet 会调用我们放入 Set 中的每个 Image 的 equals() 方法。(显然,它也会调用 hashCode() 方法来获取哈希值。)
以下集合中有多少个图像?
someItem.addImage(new Image("background.jpg", 640, 480));
someItem.addImage(new Image("foreground.jpg", 800, 600));
someItem.addImage(new Image("landscape.jpg", 1024, 768));
someItem.addImage(new Image("landscape.jpg", 1024, 768));
assertEquals(3, someItem.getImages().size());
你预期有四个图像而不是三个吗?你说对了:常规的 Java 相等性检查依赖于标识符。java.lang.Object#equals() 方法通过 a==b 比较实例。使用这个程序,我们会在集合中有四个 Image 实例。显然,对于这个用例,三个是“正确”的答案。
对于 Image 类,我们不依赖于 Java 的标识符——我们覆盖了 equals() 和 hashCode() 方法。
列表 8.13 使用 equals() 和 hashCode() 实现自定义相等性
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/setofembeddables/Image.java
@Embeddable
public class Image {
// . . .
@Override
public boolean equals(Object o) { Ⓐ
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Image image = (Image) o;
return width == image.width &&
height == image.height &&
filename.equals(image.filename) &&
item.equals(image.item);
}
@Override
public int hashCode() { Ⓑ
return Objects.hash(filename, width, height, item);
}
// . . .
}
Ⓐ 在 equals() 中的这个自定义相等性检查比较了一个 Image 的所有值与另一个 Image 的值。如果所有值都相同,则图像必须相同。
Ⓑ hashCode() 方法必须满足合同要求,即如果两个实例相等,它们必须具有相同的哈希码。
为什么在第 6.2 节我们没有覆盖相等性,当我们映射 User 的 Address 时?事实上,我们可能真的应该这么做。我们唯一的借口是,除非我们将可嵌入组件放入 Set 或将它们用作使用 equals() 和 hashCode() 进行存储和比较的 Map 的键(这意味着它不是一个 TreeMap,它通过比较项目来排序和定位它们),否则我们不会遇到常规的标识符相等性问题。我们还应该根据值而不是标识符重新定义相等性。最好是在每个 @Embeddable 类上覆盖这些方法;所有值类型都应该按值进行比较。
现在考虑数据库主键:Hibernate 将生成一个包含 IMAGE 集合表所有非空列的复合主键的模式。这些列必须是不可为空的,因为我们无法识别我们不知道的东西。这反映了 Java 类中的等价实现。我们将在下一节中查看模式,并详细介绍主键。
注意:Hibernate 的模式生成器存在一个小的问题:如果我们用 @NotNull 而不是 @Column (nullable=false) 注解一个可嵌入属性的属性,Hibernate 不会为集合表的列生成 NOT NULL 约束。实例的 Bean Validation 检查按预期工作,但数据库模式缺少完整性规则。如果可嵌入类在集合中映射,并且属性应该是主键的一部分,请使用 @Column(nullable=false)。
组件类现在已准备就绪,我们可以在集合映射中使用它。
8.2.2 组件的 Set
我们可以映射一个如以下所示组件的 Set。请记住,Set 是一种只允许唯一项的集合类型。
列表 8.14 带覆盖的嵌入组件 Set
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence,
➥ /ch08/setofembeddables/Item.java
@Entity
public class Item {
// . . .
@ElementCollection Ⓐ
@CollectionTable(name = "IMAGE") Ⓑ
@AttributeOverride(
name = "filename",
column = @Column(name = "FNAME", nullable = false)
)
private Set<Image> images = new HashSet<>();
Ⓐ 如前所述,需要 @ElementCollection 注解。Hibernate 自动知道集合的目标是一个 @Embeddable 类型,这是从泛型集合的声明中得知的。
Ⓑ @CollectionTable 注解覆盖了集合表的默认名称,原本应该是 ITEM_IMAGES。
Image 映射定义了集合表的列。正如对于单个内嵌值一样,我们可以使用 @AttributeOverride 注解来定制映射,而不需要修改目标可嵌入类。
查看图 8.7 中的数据库模式。我们正在映射一个集合,因此集合表的主键是由外键列 ITEM_ID 和所有“内嵌”的非空列 FNAME、WIDTH 和 HEIGHT 组成的复合键。

图 8.7 组件集合的示例数据表
如前所述,ITEM_ID 值没有包含在 Image 的覆盖 equals() 和 hashCode() 方法中。因此,如果我们在一个集合中混合不同项目的图像,我们将在 Java 层遇到等价问题。在数据库表中,我们可以区分不同项目的图像,因为项目的标识符包含在主键等价检查中。
如果我们想在 Image 的等价例程中包含 Item,以与数据库主键保持对称,我们需要一个 Image#item 属性。这是 Hibernate 在加载 Image 实例时提供的一个简单回指针:
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/setofembeddables/Image.java
@Embeddable
public class Image {
// . . .
@org.hibernate.annotations.Parent
private Item item;
// . . .
}
我们现在可以将父 Item 值包含在 equals() 和 hashCode() 实现中。
在下一个代码片段中,我们将使用 @AttributeOverride 注解将 FILENAME 字段匹配到 FNAME 列:
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/setofembeddables/Item.java
@AttributeOverride(
name = "filename",
column = @Column(name = "FNAME", nullable = false)
)
我们还必须在 ItemRepository 接口中更改原生查询:
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/repositories/setofembeddables/ItemRepository.java
@Query(value = "SELECT FNAME FROM IMAGE WHERE ITEM_ID = ?1",
nativeQuery = true)
Set<String> findImagesNative(Long id);
如果我们需要在加载时对元素进行排序,并使用 LinkedHashSet 保持稳定的迭代顺序,我们可以使用 JPA @OrderBy 注解:
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/setofembeddablesorderby/Item.java
@Entity
public class Item {
// . . .
@ElementCollection
@CollectionTable(name = "IMAGE")
@OrderBy("filename DESC, width DESC")
private Set<Image> images = new LinkedHashSet<>();
@OrderBy 注解的参数是 Image 类的属性,后面跟着 ASC 表示升序或 DESC 表示降序。默认是升序。此示例按图像文件名降序排序,然后按每个图像的宽度降序排序。请注意,这与在第 8.1.8 节中讨论的专有 @org.hibernate.annotations.OrderBy 注解不同,它接受一个纯 SQL 子句。
将 Image 的所有属性声明为 @NotNull 可能不是我们想要的。如果任何属性是可选的,我们需要为集合表提供一个不同的主键。
8.2.3 组件包
在向集合表添加代理键列之前,我们使用了 @org.hibernate.annotations.CollectionId 注解。然而,集合类型不是一个 Set,而是一个通用的 Collection,一个包。这与我们的更新模式一致:如果我们有一个代理主键列,允许重复的元素值。让我们通过 bagofembeddables 示例来了解这一点。
首先,Image 类现在可以有可空属性,因为我们会有一个代理键:
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/bagofembeddables/Image.java
@Embeddable
public class Image {
@Column(nullable = true)
private String title;
@Column(nullable = false)
private String filename;
private int width;
private int height;
// . . .
}
记得在通过值比较实例时,考虑 Image 的可选 title 在重写的 equals() 和 hashCode() 方法中的情况。例如,标题字段的比较将在 equals 方法中这样进行:
Objects.equals(title, image.title)
接下来,查看 Item 中的包集合映射。和之前一样,在第 8.1.5 节中,我们声明了一个额外的代理主键列,IMAGE_ID,使用专有的 @org.hibernate.annotations.CollectionId 注解:
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/bagofembeddables/Item.java
@Entity
public class Item {
// . . .
@ElementCollection
@CollectionTable(name = "IMAGE")
@GenericGenerator(name = "sequence_gen", strategy = "sequence")
@org.hibernate.annotations.CollectionId(
columns = @Column(name = "IMAGE_ID"),
type = @org.hibernate.annotations.Type(type = "long"),
generator = "sequence_gen")
private Collection<Image> images = new ArrayList<>();
// . . .
}
图 8.8 显示了数据库模式。标识符为 2 的 Image 的 title 是 null。

图 8.8 带有代理主键列的组件集合表
接下来,我们将分析使用 Map 改变集合表主键的另一种方法。
8.2.4 组件值映射图
一个映射将信息保持为键值对的组合。如果 Image 存储在映射中,文件名可以是映射键:
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/mapofstringsembeddables/Item.java
@Entity
public class Item {
// . . .
@ElementCollection
@CollectionTable(name = "IMAGE")
@MapKeyColumn(name = "TITLE") Ⓐ
private Map<String, Image> images = new HashMap<>();
// . . .
}
Ⓐ 映射的关键列设置为 TITLE。否则,它将默认为 IMAGES_KEY。
测试将通过执行此类指令来设置 TITLE 列:
item.putImage("Background", new Image("background.jpg", 640, 480));
集合表的主键,如图 8.9 所示,现在是外键列 ITEM_ID 和映射的关键列 TITLE。

图 8.9 组件映射的数据库表
可嵌入的 Image 类映射所有其他列,这些列可能是可空的:
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/mapofstringsembeddables/Image.java
@Embeddable
public class Image {
@Column(nullable = true) Ⓐ
private String filename;
private int width;
private int height;
// . . .
}
Ⓐ filename 字段现在可以是 null;它不是主键的一部分。
在这里,映射中的值是可嵌入组件类的实例,而键是基本字符串。接下来,我们将为键和值都使用可嵌入类型。
8.2.5 组件作为映射键
我们的最终示例是将一个Map映射,如图 8.10 所示,其中键和值都是可嵌入类型。

图 8.10 Item有一个按Filename键的Map。
我们可以用自定义类型来表示文件名,而不是字符串表示。
列表 8.15 使用自定义类型表示文件名
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/mapofembeddables/Filename.java
@Embeddable
public class Filename {
@Column(nullable = false) Ⓐ
private String name;
// . . .
}
Ⓐ name字段不能为空,因为它是主键的一部分。如果我们想用这个类作为映射的键,映射的数据库列不能为可空,因为它们都是复合主键的一部分。我们还必须重写equals()和hashCode()方法,因为映射的键是一个集合,每个Filename必须在给定的键集中是唯一的。
我们不需要任何特殊的注解来映射集合:
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/mapofsembeddables/Item.java
@Entity
public class Item {
@ElementCollection
@CollectionTable(name = "IMAGE")
private Map<Filename, Image> images = new HashMap<>();
// . . .
}
事实上,我们不能应用@MapKeyColumn和@AttributeOverrides;当映射的键是一个@Embeddable类时,它们没有任何效果。
IMAGE表的复合主键包括ITEM_ID和NAME列,如图 8.11 所示。像Image这样的复合可嵌入类不仅限于基本类型的基本属性。您已经看到了如何嵌套其他组件,例如Address中的City。我们可以在新的Dimensions类中提取和封装Image的width和height属性。

图 8.11 Images 的Filenames 键的Map数据库表
可嵌入类也可以有自己的集合。
8.2.6 可嵌入组件中的集合
假设对于每个Address,我们想要存储一个联系人列表。在可嵌入类中,这是一个简单的Set<String>:
Path: Ch08/mapping-collections/src/main/java/com/manning/javapersistence
➥ /ch08/embeddablesetofstrings/Address.java
@Embeddable
public class Address {
@NotNull
@Column(nullable = false)
private String street;
@NotNull
@Column(nullable = false, length = 5)
private String zipcode;
@NotNull
@Column(nullable = false)
private String city;
@ElementCollection
@CollectionTable(
name = "CONTACT", Ⓐ
joinColumns = @JoinColumn(name = "USER_ID")) Ⓑ
@Column(name = "NAME", nullable = false) Ⓒ
private Set<String> contacts = new HashSet<>();
// . . .
}
Ⓐ @ElementCollection是唯一必需的注解;表和列名有默认值。表名默认为USER_CONTACTS。
Ⓑ 连接列默认为USER_ID。
Ⓒ 列名默认为CONTACTS。
看图 8.12 中的模式:USER_ID列有一个外键约束,引用拥有实体的表USERS。集合表的主键是USER_ID和NAME列的复合,防止重复元素,因此Set是合适的。

图 8.12 USER_ID有一个外键约束,引用USERS。
我们可以用列表、包或基本类型的映射来代替Set。Hibernate 还支持可嵌入类型的集合,因此我们可以写一个可嵌入的Contact类,让Address持有Contacts的集合。
虽然 Hibernate 在组件映射和细粒度模型方面提供了很多灵活性,但请注意,代码的阅读次数往往多于编写次数。想想几年后将要维护这个项目的下一个开发者。
转移焦点,让我们将注意力转向实体关联:特别是简单的多对一和一对多关联。
8.3 映射实体关联
在本章的开头,我们承诺要讨论父子关系。到目前为止,我们已经研究了实体Item的映射。假设这是父实体,并且它有一个子实体的集合:Image实例的集合。术语父子意味着某种生命周期依赖性,因此字符串集合或可嵌入组件是合适的。子实体完全依赖于父实体;它们将始终与父实体一起保存、更新和删除,而不会单独存在。
我们已经映射了一个父子关系!父实体是一个实体,而许多子实体是值类型。当删除一个Item时,其Image实例的集合也将被删除。(实际的图像可能会以事务方式删除,这意味着我们将一起从数据库中删除行和从磁盘上的文件,或者什么都不做。然而,这却是一个独立的问题,我们在这里不会处理。)
现在,我们想要映射不同类型的关联:两个实体类之间的关联。它们的实例不会具有依赖的生命周期——一个实例可以保存、更新和删除,而不会影响另一个实例。当然,有时实体实例之间也会有依赖关系,但我们需要对两个类之间的关系如何影响实例状态有更细粒度的控制,这与完全依赖(嵌入)类型不同。我们在这里还在讨论父子关系吗?事实证明,父子这个术语是模糊的,每个人都有自己的定义。我们将尽量不再使用这个术语,而将依靠更精确的,或者至少是定义良好的,词汇。

图 8.13 Item和Bid之间的关系
在接下来的几节中,我们将探讨的关系将保持不变:Item和Bid实体类之间的关系,如图 8.13 所示。从Bid到Item的关联是一个多对一关联。稍后我们将使这个关联双向,因此从Item到Bid的反向关联将是一对多。
多对一关联是最简单的,所以我们将首先讨论它。其他关联,多对多和一对多,更复杂,我们将在下一章讨论。
让我们从需要在 CaveatEmptor 应用程序中实现的多对一关联开始,并看看我们有哪些替代方案。下面的源代码可以在 mapping-associations 文件夹中找到。
8.3.1 最简单的关联
我们称 Bid#item 属性的映射为 单向多对一关联。在我们分析这个映射之前,看看图 8.14 中的数据库模式和列表 8.16 中的代码。

图 8.14 SQL 模式中的一种多对一关系
列表 8.16 Bid 对 Item 有单一引用
Path: Ch08/mapping-associations/src/main/java/com/manning/javapersistence
➥ /ch08/onetomany/bidirectional/Bid.java
@Entity
public class Bid {
@ManyToOne(fetch = FetchType.LAZY) Ⓐ
@JoinColumn(name = "ITEM_ID", nullable = false)
private Item item;
// . . .
}
Ⓐ @ManyToOne 注解将一个属性标记为实体关联,并且是必需的。它的获取参数默认为 EAGER,这意味着当加载 Bid 时,关联的 Item 也会被加载。我们通常更喜欢将懒加载作为默认策略,我们将在第 12.1.1 节中进一步讨论。
一个 多对一 实体关联自然映射到外键列:BID 表中的 ITEM_ID。在 JPA 中,这被称为 连接列。我们只需要在属性上使用 @ManyToOne 注解。连接列的默认名称是 ITEM_ID:Hibernate 自动使用目标实体名称及其标识属性的组合,用下划线分隔。
我们可以用 @JoinColumn 注解覆盖外键列,但在这里我们使用它的另一个原因:当 Hibernate 生成 SQL 模式时,使外键列 NOT NULL。一个出价总是必须有一个对项目的引用;它不能独立存在。(注意,这已经表明我们必须注意某种生命周期依赖。)或者,我们可以用 @ManyToOne(optional = false) 或通常的 Bean Validation 的 @NotNull 标记这个关联为非可选。
这很简单。重要的是要意识到,我们可以编写一个完整且复杂的应用程序,而不需要使用任何其他东西。
我们不需要映射这个关系的另一边;我们可以忽略从 Item 到 Bid 的 一对多 关联。数据库模式中只有一个外键列,我们已经映射了它。我们对此是认真的:当你看到一个外键列和两个实体类时,你可能应该用 @ManyToOne 来映射,而不用其他任何东西。
现在,我们可以通过调用 someBid.getItem() 来获取每个 Bid 的 Item。JPA 提供商会取消引用外键并为我们加载 Item,它还会负责管理外键值。我们如何获取一个项目的所有出价?我们可以编写一个查询,并用 EntityManager 或 JpaRepository 在 Hibernate 支持的任何查询语言中执行它。例如,在 JPQL 中,我们会使用 select b from Bid b where b.item = :itemParameter。我们使用 Hibernate 或 Spring Data JPA 的一个原因当然是,在大多数情况下,我们不想自己编写和执行那个查询。
8.3.2 使其双向
在本章的开头,在第 8.1.2 节中,我们列出了为什么将Item#images集合映射为一个好主意的原因。让我们为Item#bids集合做同样的事情。这个集合将实现Item和Bid实体类之间的一对一关联。如果我们创建并映射这个集合属性,我们将得到以下内容:
-
当我们调用
someItem.getBids()并开始遍历集合元素时,Hibernate 会自动执行 SQL 查询SELECT * FROM BID WHERE ITEM_ID = ?。 -
我们可以从一个
Item对象级联状态变化到集合中所有引用的Bid对象。我们可以选择哪些生命周期事件应该是可传递的;例如,我们可以声明当保存Item时,所有引用的Bid实例都应该被保存,这样我们就不必反复调用EntityManager#persist()或ItemRepository#save()来保存所有竞标。
好吧,这不是一个非常长的列表。一对一映射的主要好处是数据导航访问。这是 ORM 的核心承诺之一,它使我们能够通过仅调用我们的 Java 域模型的方法来访问数据。ORM 引擎应该在我们使用自己设计的高级接口工作时,以智能的方式加载所需的数据:someItem.getBids().iterator().next().getAmount(),等等。
你可以选择级联一些状态变化到相关实例,这是一个很好的额外功能。然而,请考虑,某些依赖在 Java 级别指示了值类型,而没有指示实体。问问自己,模式中的任何表是否将有一个BID_ID外键列。如果没有,使用与之前相同的表映射Bid类为@Embeddable,而不是@Entity,但对于传递状态变化的映射使用不同的规则。如果有任何其他表在BID行的任何地方有外键引用,我们需要一个共享的Bid实体;它不能与Item一起嵌入映射。
那么,我们是否应该映射Item#bids集合呢?我们将获得导航数据访问,但我们必须付出的代价是额外的 Java 代码和显著增加的复杂性。这通常是一个困难的决策;很少应该选择映射集合。在应用程序中,我们多久会调用一次someItem.getBids(),然后按预定义的顺序访问或显示所有竞标?如果我们只想显示竞标的一个子集,或者如果我们每次都需要以不同的顺序检索它们,我们无论如何都需要手动编写和执行查询。一对一映射及其集合将只是一种维护负担。根据我们的经验,这是常见的问题和错误来源,尤其是对于 ORM 初学者。

图 8.15 Item和Bid之间的双向关联
在 CaveatEmptor 的情况下,答案是肯定的,我们经常会调用 someItem.getBids() 然后向想要参加拍卖的用户展示一个列表。图 8.15 显示了我们需要实现的具有双向关联的更新后的 UML 图。
集合映射和一对一映射的映射如下。
列表 8.17 Item 拥有一组 Bid 引用
Path: Ch08/mapping-associations/src/main/java/com/manning/javapersistence
➥ /ch08/onetomany/bidirectional/Item.java
@Entity
public class Item {
// . . .
@OneToMany(mappedBy = "item", Ⓐ
fetch = FetchType.LAZY) Ⓑ
private Set<Bid> bids = new HashSet<>();
// . . .
}
Ⓐ 要使关联双向,需要使用 @OneToMany 注解。在这种情况下,我们还需要设置 mappedBy 参数。
Ⓑ 参数是“另一边”属性的名称。默认情况下,获取将是 LAZY。
再次看看另一边——列表 8.16 中的多对一映射。Bid 类中的属性名是 item。出价方负责外键列,ITEM_ID,我们使用 @ManyToOne 进行了映射。在这里,mappedBy 告诉 Hibernate 使用给定属性已映射的外键列来“加载此集合”——在这种情况下,Bid#item。当一对一双向且已映射外键列时,mappedBy 参数始终是必需的。我们将在下一章再次讨论这一点。
集合映射的 fetch 参数的默认值始终是 FetchType.LAZY,因此我们将来不需要此选项。这是一个好的默认设置;相反,很少需要的 EAGER。我们不希望在每次加载 Item 时都懒加载所有 bids。它们应该在访问时按需加载。
我们现在可以创建以下两个 Spring Data JPA 仓库。
列表 8.18 ItemRepository 接口
Path: Ch08/mapping-associations/src/test/java/com/manning/javapersistence
➥ /ch08/repositories/onetomany/bidirectional/ItemRepository.java
public interface ItemRepository extends JpaRepository<Item, Long> {
}
列表 8.19 BidRepository 接口
Path: Ch08/mapping-associations/src/test/java/com/manning/javapersistence
➥ /ch08/repositories/onetomany/bidirectional/BidRepository.java
public interface BidRepository extends JpaRepository<Bid, Long> {
Set<Bid> findByItem(Item item);
}
这些是常规的 Spring Data JPA 仓库,BidRepository 添加了一个通过 Item 获取出价的方法。
将 Item#bids 集合映射的第二个原因是能够级联状态变化,让我们看看这一点。
8.3.3 级联状态
如果实体状态变化可以通过关联级联到另一个实体,我们需要的代码行数会更少来管理关系。但这可能带来严重的性能影响。
以下代码创建了一个新的 Item 和一个新的 Bid,然后将它们链接起来:
Item someItem = new Item("Some Item");
Bid someBid = new Bid(new BigDecimal("123.00"), someItem);
someItem.addBid(someBid);
我们必须考虑这个关系的两个方面:Bid 构造函数接受一个用于填充 Bid#item 的项目。为了保持内存中实例的完整性,我们需要将出价添加到 Item#bids。现在从 Java 代码的角度来看,链接已经完成;所有引用都已设置。如果你不确定为什么需要这段代码,请参阅第 3.2.4 节。
让我们先保存项目及其出价到数据库,首先是无需传递性持久化,然后是有传递性持久化。
启用传递性持久化
使用当前的 @ManyToOne 和 @OneToMany 映射,我们需要编写以下代码来保存一个新的 Item 和几个 Bid 实例。
列表 8.20 分别管理独立的实体实例
Path: Ch08/mapping-associations/src/test/java/com/manning/javapersistence
➥ /ch08/onetomany/bidirectional/MappingAssociationsSpringDataJPATest.java
Item item = new Item("Foo");
Bid bid = new Bid(BigDecimal.valueOf(100), item);
Bid bid2 = new Bid(BigDecimal.valueOf(200), item);
itemRepository.save(item);
item.addBid(bid);
item.addBid(bid2);
bidRepository.save(bid);
bidRepository.save(bid2);
当我们创建多个出价时,对每个出价调用 EntityManager#persist() 或 BidRepository #save() 似乎有些冗余。新实例是瞬时的,必须使它们持久化。Bid 和 Item 之间的关系不影响它们的生命周期。如果 Bid 是一个值类型,Bid 的状态将自动与拥有它的 Item 相同。然而,在这种情况下,Bid 有它自己的完全独立的状态。
我们之前提到,有时需要细粒度控制来表达关联实体类之间的依赖关系;这是一个例子。JPA 中实现这一机制的选项是 cascade。例如,为了在保存项目时保存所有出价,可以将集合映射如下。
列表 8.21 从 Item 到所有 bids 的级联持久化状态
Path: Ch08/mapping-associations/src/main/java/com/manning/javapersistence
➥ /ch08/onetomany/cascadepersist/Item.java
@Entity
public class Item {
// . . .
@OneToMany(mappedBy = "item", cascade = CascadeType.PERSIST)
private Set<Bid> bids = new HashSet<>();
// . . .
}
在这里,级联选项旨在是传递性的,因此我们在 ItemRepository#save() 或 EntityManager#persist() 操作中使用 CascadeType.PERSIST。现在我们可以简化连接项目和出价并保存它们的代码。
列表 8.22 所有引用的 bids 都自动变为持久化
Path: Ch08/mapping-associations/src/test/java/com/manning/javapersistence
➥ /ch08/onetomany/cascadepersist/MappingAssociationsSpringDataJPATest.java
Item item = new Item("Foo");
Bid bid = new Bid(BigDecimal.valueOf(100), item);
Bid bid2 = new Bid(BigDecimal.valueOf(200), item);
item.addBid(bid);
item.addBid(bid2);
itemRepository.save(item); Ⓐ
Ⓐ 我们自动保存出价,但稍后。在提交时间,Spring Data JPA 使用 Hibernate 检查管理的/持久化的 Item 实例,并查看出价集合。然后,它对每个引用的 Bid 实例内部调用 save(),也将它们保存。列 BID#ITEM_ID 中的值通过检查 Bid#item 属性从每个 Bid 中获取。外键列是 mappedBy,在该属性上使用 @ManyToOne。
@ManyToOne 注解也有 cascade 选项。我们不会经常使用它。例如,我们真的不能说,“当出价被保存时,也保存项目。”项目必须先存在;否则,出价在数据库中将无效。考虑另一个可能的 @ManyToOne 关系:Item#seller 属性。User 必须存在,他们才能出售 Item。
传递性持久化是一个简单但经常有用的概念,尤其是在使用 @OneToMany 或 @ManyToMany 映射时。另一方面,我们必须谨慎地应用传递性删除。
级联删除
似乎合理的推断是,项目的删除意味着所有相关出价的删除,因为它们单独并不相关。这正是 UML 图中 组合(填充的菱形)所表示的。使用当前的级联选项,我们将不得不编写以下代码来删除一个项目:
Path: Ch08/mapping-associations/src/test/java/com/manning/javapersistence
➥ /ch08/onetomany/cascadepersist/MappingAssociationsSpringDataJPATest.java
Item retrievedItem = itemRepository.findById(item.getId()).get();
for (Bid someBid : bidRepository.findByItem(retrievedItem)) {
bidRepository.delete(someBid); Ⓐ
}
itemRepository.delete(retrievedItem); Ⓑ
Ⓐ 首先,我们删除所有的出价。
Ⓑ 然后我们删除项目所有者。
JPA 提供了一个级联选项来帮助解决这个问题。持久化引擎可以自动删除关联的实体实例。
列表 8.23 从 Item 到所有 bids 的级联删除
Path: Ch08/mapping-associations/src/main/java/com/manning/javapersistence
➥ /ch08/onetomany/cascaderemove/Item.java
@Entity
public class Item {
// . . .
@OneToMany(mappedBy = "item",
cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
private Set<Bid> bids = new HashSet<>();
// . . .
}
就像之前的 PERSIST 一样,这个关联上的 delete() 操作将被级联。如果我们对 Item 调用 ItemRepository#delete() 或 EntityManager#remove(),Hibernate 将加载 bids 集合元素并在每个实例上内部调用 remove():
Path: Ch08/mapping-associations/src/test/java/com/manning/javapersistence
➥ /ch08/onetomany/cascaderemove/MappingAssociationsSpringDataJPATest.java
itemRepository.delete(item);
一行代码就足够逐个删除出价了。
然而,这个删除过程效率不高:Hibernate 或 Spring Data JPA 必须始终加载集合并逐个删除每个 Bid。一个 SQL 语句对数据库会产生相同的效果:delete from BID where ITEM_ID = ?。
数据库中没有人在 BID 表上有外键引用。然而,Hibernate 并不知道这一点,并且它无法在整个数据库中搜索任何可能具有链接 BID_ID(即实际的 Item 外键)的行。
如果 Item#bids 是可嵌入组件的集合,则 someItem.getBids().clear() 将执行单个 SQL DELETE。对于值类型集合,Hibernate 假设没有人可能持有出价的引用,并且仅从集合中删除引用使其成为孤儿可删除数据。
启用孤儿删除
JPA 提供了一个标志,可以为 @OneToMany(仅限 @OneToMany)实体关联启用相同的行为。
列表 8.24 在 @OneToMany 集合上启用孤儿删除
Path: Ch08/mapping-associations/src/main/java/com/manning/javapersistence
➥ /ch08/onetomany/orphanremoval/Item.java
@Entity
public class Item {
// . . .
@OneToMany(mappedBy = "item",
cascade = CascadeType.PERSIST, orphanRemoval = true)
private Set<Bid> bids = new HashSet<>();
// . . .
}
orphanRemoval=true 参数告诉 Hibernate,当 Bid 从集合中删除时,我们希望永久删除它。
我们将按照以下列表更改 ItemRepository 接口。
列表 8.25 修改后的 ItemRepository 接口
Path: Ch08/mapping-associations/src/test/java/com/manning/javapersistence
➥ /ch08/repositories/onetomany/orphanremoval/ItemRepository.java
public interface ItemRepository extends JpaRepository<Item, Long> {
@Query("select i from Item i inner join fetch i.bids where i.id = :id") Ⓐ
Item findItemWithBids(@Param("id") Long id); Ⓐ
}
Ⓐ 新的 findItemWithBids 方法将通过 id 获取 Item,包括出价集合。为了获取这个集合,我们将使用 JPQL 的内部连接获取功能。
这里是一个删除单个 Bid 的示例:
Path: Ch08/mapping-associations/src/test/java/com/manning/javapersistence
➥ /ch08/onetomany/orphanremoval/MappingAssociationsSpringDataJPATest.java
Item item1 = itemRepository.findItemWithBids(item.getId());
Bid firstBid = item1.getBids().iterator().next();
item1.removeBid(firstBid);
itemRepository.save(item1);
Hibernate 或 Spring Data JPA 使用 Hibernate 将监视集合,并在事务提交时注意到我们从集合中删除了一个元素。现在 Hibernate 认为该 Bid 是孤儿的。我们已保证没有人持有对该对象的引用;唯一的引用是我们刚刚从集合中删除的那个。因此,Hibernate 或 Spring Data JPA 使用 Hibernate 将自动执行一个 SQL DELETE 来删除数据库中的 Bid 实例。
我们仍然不会得到与组件集合相同的 clear() 一次性 DELETE。Hibernate 尊重常规实体状态转换,并且出价都是逐个加载和删除的。
孤儿删除是一个有争议的过程。在这个例子中,没有其他表在数据库中具有对 BID 的外键引用,所以这是可以的。从 BID 表中删除一行没有后果;对出价的所有内存引用都在 Item#bids 中。
只要所有这些条件都成立,启用孤儿删除就没有问题。当表示层可以从集合中删除一个元素以删除某些内容时,这是一个方便的选项。我们只需要处理领域模型实例,并且不需要调用服务来执行此操作。
但考虑当我们创建一个 User#bids 集合映射——另一个 @OneToMany——如图 8.16 所示时会发生什么。这是测试你对 Hibernate 知识的好时机:在此更改后,表和模式将是什么样子?(答案:BID 表有一个 BIDDER_ID 外键列,引用 USERS。)

图 8.16 Item、Bid 和 User 之间的双向关联
下面的列表中显示的测试将不会通过。
列表 8.26 数据库删除后内存引用无清理
Path: Ch08/mapping-associations/src/test/java/com/manning/javapersistence
➥ /ch08/onetomany/orphanremoval/MappingAssociationsSpringDataJPATest.java
User user = userRepository.findUserWithBids(john.getId());
assertAll(
() -> assertEquals(1, items.size()),
() -> assertEquals(2, bids.size()),
() -> assertEquals(2, user.getBids().size())
);
Item item1 = itemRepository.findItemWithBids(item.getId());
Bid firstBid = item1.getBids().iterator().next();
item1.removeBid(firstBid);
itemRepository.save(item1);
//FAILURE
//assertEquals(1, user.getBids().size());
assertEquals(2, user.getBids().size());
List<Item> items2 = itemRepository.findAll();
List<Bid> bids2 = bidRepository.findAll();
assertAll(
() -> assertEquals(1, items2.size()),
() -> assertEquals(1, bids2.size()),
() -> assertEquals(2, user.getBids().size())
//FAILURE
//() -> assertEquals(1, user.getBids().size())
);
Hibernate 或 Spring Data JPA 认为已删除的 Bid 是孤儿且可删除的;它将在数据库中自动删除,但我们仍然在另一个集合 User#bids 中持有对该实体的引用。当此事务提交时,数据库状态良好;删除的 BID 表行包含两个外键,ITEM_ID 和 BIDDER_ID。但现在我们在内存中有一个不一致性,因为当我们说“从集合中移除引用时删除实体实例”时,这自然与共享引用相冲突。
而不是孤儿删除,甚至 CascadeType.REMOVE,始终考虑一个更简单的映射。在这里,Item#bids 作为组件集合,使用 @ElementCollection 映射,将很好。Bid 将是 @Embeddable 并具有一个 @ManyToOne bidder 属性,引用一个 User。(可嵌入组件可以拥有对实体的单向关联。)
这将提供我们寻找的生命周期:对拥有实体的完全依赖。我们将必须避免共享引用;图 8.16 中的 UML 图使 Bid 到 User 的关联变为单向。删除 User#bids 集合——我们不需要这个 @OneToMany。如果我们需要获取用户做出的所有出价,我们可以编写一个查询:select b from Bid b where b.bidder = :userParameter。(在下一章中,我们将使用可嵌入组件中的 @ManyToOne 完成此映射。)
启用外键上的 ON DELETE CASCADE
到目前为止,我们展示的所有删除操作都是低效的。必须将出价加载到内存中,并且需要许多 SQL DELETE 操作。SQL 数据库支持一个更高效的外键特性:ON DELETE 选项。在 DDL 中,它看起来像这样:foreign key (ITEM_ID) references ITEM on delete cascade 对于 BID 表。
此选项告诉数据库为所有访问数据库的应用程序透明地维护复合的引用完整性。每当我们在 ITEM 表中删除一行时,数据库将自动删除 BID 表中具有相同 ITEM_ID 键值的任何行。我们只需要一个 DELETE 语句来递归地删除所有相关数据,并且不需要将任何内容加载到应用程序(服务器)内存中。
你应该检查你的模式是否已经在外键上启用了此选项。如果你想要将此选项添加到 Hibernate 生成的模式中,请使用 Hibernate @OnDelete 注解。
你还应该检查这个选项是否与你的 DBMS 兼容,以及 Hibernate 或使用 Hibernate 的 Spring Data JPA 是否生成带有ON DELETE CASCADE选项的外键。这在 MySQL 中不起作用,所以我们选择在 H2 数据库上展示这个特定的例子。你可以在源代码中找到它(在 pom.xml 中的 Maven 依赖项和 Spring Data JPA 配置)。
列表 8.27 在架构中生成外键ON DELETE CASCADE
Path: Ch08/mapping-associations/src/main/java/com/manning/javapersistence
➥ /ch08/onetomany/ondeletecascade/Item.java
@Entity
public class Item {
// . . .
@OneToMany(mappedBy = "item", cascade = CascadeType.PERSIST)
@org.hibernate.annotations.OnDelete(
action = org.hibernate.annotations.OnDeleteAction.CASCADE
)
private Set<Bid> bids = new HashSet<>(); Ⓐ
// . . .
}
Ⓐ Hibernate 的一个特性在这里可见:@OnDelete注解仅影响 Hibernate 的架构生成。影响架构生成的设置通常位于“另一边”的mappedBy,即外键/连接列映射的地方。在Bid中,@OnDelete注解通常位于@ManyToOne旁边。然而,当关联双向映射时,Hibernate 只会识别@OneToMany这一侧。
在数据库中启用外键级联删除不会影响 Hibernate 的运行时行为。我们仍然可能遇到列表 8.26 中显示的相同问题。内存中的数据可能不再准确反映数据库的状态。如果当ITEM表中的一行被删除时,BID表中的所有相关行都会自动删除,那么应用程序代码负责清理引用并更新数据库状态。如果我们不小心,甚至可能保存我们或其他人之前删除的内容。
Bid实例不会经过常规的生命周期,并且像@PreRemove这样的回调没有效果。此外,Hibernate 不会自动清除可选的二级全局缓存,该缓存可能包含过时数据。从根本上说,在数据库级别遇到的外键级联问题与我们的应用程序之外的另一个应用程序访问同一数据库,或者任何其他数据库触发器进行更改时遇到的问题相同。在这种情况下,Hibernate 可以是一个非常有效的工具,但还有其他需要考虑的动态部分。
如果你在一个新的架构上工作,最简单的方法是不要启用数据库级别的级联,并在你的领域模型中将组合关系映射为内嵌/可嵌入的,而不是作为实体关联。然后 Hibernate 或使用 Hibernate 的 Spring Data JPA 可以执行高效的 SQL DELETE操作来删除整个组合。我们在上一节中提出了这个建议:如果你可以避免共享引用,将Bid映射为Item中的@ElementCollection,而不是作为具有@ManyToOne和@OneToMany关联的独立实体。当然,你也可以选择完全不映射任何集合,只使用最简单的映射:一个带有@ManyToOne的外键列,在@Entity类之间单向映射。
摘要
-
使用简单的集合映射,例如
Set<String>,你可以处理丰富的一组接口和实现。 -
你可以使用排序集合,以及 Hibernate 提供的选项,让数据库按所需顺序返回集合元素。
-
你可以使用复杂集合,包括用户定义的可嵌入类型和集合、包以及组件的映射。
-
你可以在映射中使用组件作为键和值。
-
你可以在一个可嵌入的组件中使用一个集合。
-
将第一个外键列映射到实体的多对一关联使其作为一对多关系是双向的。你可以实现级联选项。
9 高级实体关联映射
本章涵盖
-
通过一对一实体关联应用映射
-
使用一对一映射选项
-
创建多对多和三元实体关系
-
使用映射与实体关联的 map
在上一章中,我们演示了一个单向的多对一关联,使其双向,并最终通过级联选项启用传递状态变化。我们之所以在单独的一章中讨论更高级的实体映射,是因为我们认为其中许多是罕见的,或者至少是可选的。可能只使用组件映射和多对一(偶尔一对一)实体关联。您可以在不映射集合的情况下编写复杂的应用程序!我们在上一章中展示了从集合映射中获得的具体好处,并且何时进行集合映射的规则也适用于本章的所有示例。始终确保您确实需要集合,然后再尝试复杂的集合映射。
我们将从不涉及集合的映射开始:一对一实体关联。
JPA 2 的主要新特性
多对一和一对一关联现在可以通过中间连接/链接表进行映射。
可嵌入组件类可以与实体具有单向关联,即使是有集合的多值关联。
9.1 一对一关联
我们在第 6.2 节中论证,User和Address(用户有一个billingAddress、homeAddress和shippingAddress)之间的关系最好用@Embeddable组件映射来表示。这通常是表示一对一关系的最简单方式,因为在这种情况下生命周期通常是依赖的。在 UML 中,这要么是聚合,要么是组合。
关于使用专门的ADDRESS表并将User和Address都映射为实体的想法如何?这种模型的一个好处是可能存在共享引用——另一个实体类(让我们说Shipment)也可以引用特定的Address实例。如果一个User也将此实例作为他们的shippingAddress引用,则Address实例必须支持共享引用并需要其自己的标识。
在这种情况下,User和Address类有一个真正的一对一关联。请看图 9.1 中修改后的类图。

图 9.1 Address作为具有两个关联的实体,支持共享引用
我们正在开发 CaveatEmptor 应用程序,我们需要将图 9.1 中的实体进行映射。一对一关联有几种可能的映射方式。我们将考虑的第一种策略是共享主键值。
注意 要能够执行源代码中的示例,您首先需要运行 Ch09.sql 脚本。
9.1.1 共享主键
由主键关联关系连接的两个表中的行共享相同的键值。如果每个用户恰好有一个送货地址,那么方法将是 User 与(送货)Address 具有相同的键值。这种方法的主要困难在于确保在实例保存时分配给关联实例相同的键值。
在我们查看这个问题之前,让我们创建基本的映射。Address 类现在是一个独立的实体;它不再是组件。以下源代码可以在 onetoone-sharedprimarykey 文件夹中找到。
列表 9.1 Address 类作为一个独立的实体
Path: onetoone-sharedprimarykey/src/main/java/com/manning/javapersistence
➥ /ch09/onetoone/sharedprimarykey/Address.java
\1
public class Address {
@Id
@GeneratedValue(generator = Constants.ID_GENERATOR)
private Long id;
@NotNull
private String street;
@NotNull
private String zipcode;
@NotNull
private String city;
// . . .
}
User 类也是一个具有 shippingAddress 关联属性的实体。在这里,我们将引入两个新的注解:@OneToOne 和 `@PrimaryKeyJoinColumn``。
@OneToOne 做了你期望的事情:它将实体值属性标记为一对一关联。我们将需要 User 有一个 Address,带有 optional=false 子句。我们将通过 cascade = CascadeType.ALL 子句强制从 User 到 Address 的更改级联。@PrimaryKeyJoinColumn 注解选择了我们想要映射的共享主键策略。
列表 9.2 User 实体和 shippingAddress 关联
Path: onetoone-sharedprimarykey/src/main/java/com/manning/javapersistence
➥ /ch09/onetoone/sharedprimarykey/User.java
@Entity
@Table(name = "USERS")
public class User {
@Id Ⓐ
private Long id;
private String username;
@OneToOne( Ⓑ
fetch = FetchType.LAZY, Ⓒ
optional = false, Ⓓ
cascade = CascadeType.ALL Ⓔ
)
@PrimaryKeyJoinColumn Ⓕ
private Address shippingAddress;
public User() {
}
public User(Long id, String username) { Ⓖ
this.id = id;
this.username = username;
}
// . . .
}
Ⓐ 对于 User,我们没有声明标识符生成器。如第 5.2.4 节所述,这是我们很少使用应用程序分配的标识符值的情况之一。
Ⓑ User 和 Address 之间的关系是一对一。
Ⓒ 如同往常,我们应该优先考虑延迟加载策略,因此我们覆盖了默认的 FetchType.EAGER 为 LAZY。
Ⓓ optional=false 开关指定一个 User 必须有一个 shippingAddress。
Ⓔ Hibernate 生成的数据库模式通过外键约束反映了这一点。任何更改都必须级联到 Address。USERS 表的主键也具有引用 ADDRESS 表主键的外键约束。请参阅图 9.2 中的表。
Ⓕ 使用 @PrimaryKeyJoinColumn 使得这成为一个单向共享主键一对一关联映射,从 User 到 Address。
Ⓖ 构造函数设计弱化了这一点:类的公共 API 需要一个标识符值来创建一个实例。

图 9.2 USERS 表在其主键上有一个外键约束。
对于本章的一些示例,我们需要对我们的测试配置进行一些更改,因为执行需要是事务性的。SpringDataConfiguration 类将需要更多的注解:
Path: onetoone-sharedprimarykey/src/test/java/com/manning/javapersistence
➥ /ch09/configuration/onetoone/sharedprimarykey
➥ /SpringDataConfiguration.java
@Configuration Ⓐ
@EnableTransactionManagement Ⓑ
@ComponentScan(basePackages = "com.manning.javapersistence.ch09.*") Ⓒ
@EnableJpaRepositories("com.manning.javapersistence.ch09.repositories. Ⓓ
onetoone.sharedprimarykey") Ⓓ
public class SpringDataConfiguration {
// . . .
}
Ⓐ @Configuration 指定这个类声明了一个或多个由 Spring 容器使用的 bean 定义。
Ⓑ @EnableTransactionManagement 通过注解启用 Spring 的事务管理功能。
Ⓒ 我们需要以事务方式执行一些操作来测试本章的代码。@ComponentScan要求 Spring 扫描提供的包及其子包以查找组件。
Ⓓ @EnableJpaRepositories扫描指定的包以查找 Spring Data 仓库。
我们将在专门的TestService类中隔离对数据库的操作:
Path: onetoone-sharedprimarykey/src/test/java/com/manning/javapersistence
➥ /ch09/onetoone/sharedprimarykey/TestService.java
@Service Ⓐ
public class TestService {
@Autowired Ⓑ
private UserRepository userRepository; Ⓑ
@Autowired Ⓑ
private AddressRepository addressRepository; Ⓑ
@Transactional Ⓒ
public void storeLoadEntities() { Ⓒ
// . . .
Ⓐ TestService类被注解为@Service,以允许 Spring 自动创建一个豆,稍后将其注入到有效的测试中。记住,在SpringDataConfiguration类中,我们扫描com.manning.javapersistence .ch09包及其子包以查找组件。
Ⓑ 注入两个仓库豆。
Ⓒ 定义storeLoadEntities方法,并使用@Transactional注解。我们需要对数据库执行的操作必须是事务性的,我们将让 Spring 来控制这一点。
测试类将与之前展示的不同,因为它将委托给TestService类。这将允许我们将事务性操作隔离在其自己的方法中,并从测试中调用这些方法。
Path: onetoone-sharedprimarykey/src/test/java/com/manning/javapersistence
➥ /ch09/onetoone/sharedprimarykey/AdvancedMappingSpringDataJPATest.java
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {SpringDataConfiguration.class})
public class AdvancedMappingSpringDataJPATest {
@Autowired
private TestService testService;
@Test
void testStoreLoadEntities() {
testService.storeLoadEntities();
}
}
JPA 规范没有包括处理共享主键生成问题的标准化方法。这意味着在我们将User实例保存到关联的Address实例的标识符值之前,我们必须正确设置User实例的标识符值:
Path: onetoone-sharedprimarykey/src/test/java/com/manning/javapersistence
➥ /ch09/onetoone/sharedprimarykey/TestService.java
Address address =
new Address("Flowers Street", "01246", "Boston");
addressRepository.save(address); Ⓐ
User john = new User(address.getId(),"John Smith"); Ⓑ
john.setShippingAddress(address);
userRepository.save(john); Ⓒ
Ⓐ 持久化Address。
Ⓑ 取其生成的标识符值并将其设置在User上。
Ⓒ 保存它。
映射和代码有三个问题:
-
我们必须记住,
Address必须首先保存,然后才能获取其标识符值。这只有在Address实体具有在INSERT之前在save()时产生值的标识符生成器的情况下才可能,正如我们在 5.2.5 节中讨论的那样。否则,someAddress.getId()返回null,我们无法手动设置User的标识符值。 -
仅当关联是非可选的时,使用代理的延迟加载才有效。这对于刚开始接触 JPA 的开发者来说通常是一个惊喜。
@OneToOne的默认值是FetchType.EAGER:当 Hibernate 或 Spring Data JPA 使用 Hibernate 加载一个User时,它会立即加载shippingAddress。从概念上讲,只有当 Hibernate 知道存在一个链接的shipping-Address时,使用代理的延迟加载才有意义。如果属性是可空的,Hibernate 将不得不通过查询ADDRESS表来检查属性值是否为NULL。如果我们必须检查数据库,我们不妨立即加载值,因为使用代理将没有好处。 -
一对一关联是单向的;有时我们需要双向导航。
第一个问题没有其他解决方案。在先前的例子中,我们正是这样做的:保存Address,获取其主键,并将其手动设置为User的标识符值。这也是我们应该始终优先选择能够在任何 SQL INSERT之前产生值的标识符生成器的原因之一。
@OneToOne(optional=true)关联不支持使用代理的延迟加载。这与 JPA 规范一致。FetchType.LAZY是对持久化提供者的提示,而不是要求。我们可以通过字节码插装获得可空@OneToOne的延迟加载,如第 12.1.3 节所示。
关于最后一个问题,如果我们使关联双向(其中Address引用User,而User引用Address),我们也可以使用一个仅 Hibernate 专用的标识符生成器来帮助分配键值。
9.1.2 外部主键生成器
双向映射始终需要一个mappedBy端。我们将选择User端——这是一个口味和可能的其他次要要求的问题。(随后的源代码可以在onetoone-foreigngenerator文件夹中找到。)
Path: onetoone-foreigngenerator/src/main/java/com/manning/javapersistence
➥ /ch09/onetoone/foreigngenerator/User.java
@Entity
@Table(name = "USERS")
public class User {
@Id
@GeneratedValue(generator = Constants.ID_GENERATOR)
private Long id;
private String username;
@OneToOne(
mappedBy = "user",
cascade = CascadeType.PERSIST
)
private Address shippingAddress;
// . . .
}
我们添加了mappedBy选项,告诉 Hibernate 或 Spring Data JPA 使用 Hibernate,现在低级细节由“另一边的属性”映射,命名为user。为了方便,我们启用了CascadeType.PERSIST;传递性持久化将使按正确顺序保存实例变得更容易。当我们使User持久化时,Hibernate 会使其shippingAddress持久化并自动生成主键标识符。
接下来,让我们看看“另一边”:Address。我们将在标识符属性上使用@GenericGenerator来定义一个具有 Hibernate 专用foreign策略的特殊用途主键值生成器。在 5.2.5 节概述中我们没有提到这个生成器,因为共享主键一对一关联是其唯一用例。当持久化Address实例时,这个特殊的生成器会抓取user属性的值并获取引用实体实例的标识符值,即User。
列表 9.3 Address具有特殊的 foreign key 生成器
Path: onetoone-foreigngenerator/src/main/java/com/manning/javapersistence
➥ /ch09/onetoone/foreigngenerator/Address.java
@Entity
public class Address {
@Id
@GeneratedValue(generator = "addressKeyGenerator")
@org.hibernate.annotations.GenericGenerator( Ⓐ
name = "addressKeyGenerator",
strategy = "foreign",
parameters =
@org.hibernate.annotations.Parameter(
name = "property", value = "user"
)
)
private Long id;
// . . .
@OneToOne(optional = false) Ⓑ
@PrimaryKeyJoinColumn Ⓒ
private User user;
public Address() {
}
public Address(User user) { Ⓓ
this.user = user;
}
public Address(User user, String street, Ⓓ
String zipcode, String city) { Ⓓ
this.user = user;
this.street = street;
this.zipcode = zipcode;
this.city = city;
}
// . . .
}
Ⓐ 使用@GenericGenerator注解,当我们持久化Address实例时,这个特殊的生成器会抓取user属性的值并获取引用实体实例的标识符值,即User。
Ⓑ @OneToOne映射被设置为optional=false,因此Address必须有一个对User的引用。
Ⓒ user属性被标记为具有@PrimaryKeyJoinColumn注解的共享主键实体关联。
Ⓓ Address的公共构造函数现在需要一个User实例。反映optional=false的外键约束现在位于ADDRESS表的主键列上,如图 9.3 中的模式所示。

图 9.3 ADDRESS 表在其主键上有一个外键约束。
多亏了这段新代码,我们不再需要在我们的工作单元中调用address.getId()或user.getId()。存储数据简化了:
Path: onetoone-foreigngenerator/src/test/java/com/manning/javapersistence
➥ /ch09/onetoone/foreigngenerator/AdvancedMappingJPATest.java
User john = new User("John Smith");
Address address =
new Address(
john, Ⓐ
"Flowers Street", "01246", "Boston"
);
john.setShippingAddress(address); Ⓐ
userRepository.save(john); Ⓑ
Ⓐ 我们必须链接双向实体关联的两边。注意,使用这种映射,我们不会得到User#shippingAddress的懒加载(它是可选的/可以为 null),但我们可以按需使用代理加载Address#user(它不是可选的)。
Ⓑ 当我们持久化用户时,我们将获得shippingAddress的传递性持久化。
共享主键的一对一关联相对较少。相反,我们通常会使用外键列和唯一约束来映射“一对一”关联。
9.1.3 使用外键连接列
两个行不必共享主键,它们可以根据一个简单的附加外键列建立关系。一个表有一个外键列,它引用相关表的主键。(这个外键约束的源和目标甚至可以是同一个表:我们称之为自引用关系。)接下来的源代码可以在onetoone-foreignkey文件夹中找到。
让我们改变User#shippingAddress的映射。我们不再使用共享主键,而是在USERS表中添加一个SHIPPINGADDRESS_ID列。这个列有一个UNIQUE约束,所以没有两个用户可以引用相同的送货地址。查看图 9.4 中的模式。

图 9.4 USERS 表和 ADDRESS 表之间的一对一连接列关联
Address是一个常规的实体类,就像我们在本章中演示的第一个一样,在列表 9.1 中。User实体类有shippingAddress属性,实现了这个单向关联。
我们应该为这个User–Address关联启用懒加载。与共享主键不同,这里我们没有懒加载的问题:当USERS表的一行被加载时,它包含SHIPPINGADDRESS_ID列的值。因此,Hibernate 或使用 Hibernate 的 Spring Data 知道是否存在ADDRESS行,并且可以使用代理按需加载Address实例。
Path: onetoone-foreignkey/src/main/java/com/manning/javapersistence
➥ /ch09/onetoone/foreignkey/User.java
@Entity
@Table(name = "USERS")
public class User {
@Id
@GeneratedValue(generator = Constants.ID_GENERATOR)
private Long id;
@OneToOne(
fetch = FetchType.LAZY,
optional = false, Ⓐ
cascade = CascadeType.PERSIST
)
@JoinColumn(unique = true) Ⓑ
private Address shippingAddress;
// . . .
}
Ⓐ 我们不需要任何特殊的标识符生成器或主键分配;我们将确保shippingAddress不为空。
Ⓑ 我们不使用@PrimaryKeyJoinColumn,而是应用常规的@JoinColumn,它将默认为SHIPPINGADDRESS_ID。如果你比 JPA 更熟悉 SQL,每次你在映射中看到@JoinColumn时,都将其视为“外键列”会有所帮助。
在映射中,我们设置optional=false,因此用户必须有一个运输地址。这不会影响加载行为,但它是在@JoinColumn上的unique=true设置的逻辑后果。此设置向生成的 SQL 模式添加了唯一约束。如果SHIPPINGADDRESS_ID列的值对所有用户都必须是唯一的,那么可能只有一个用户没有“运输地址”。因此,可空唯一列通常没有意义。
创建、链接和存储实例是直接的:
Path: onetoone-foreignkey/src/test/java/com/manning/javapersistence
➥ /ch09/onetoone/foreignkey/AdvancedMappingSpringDataJPATest.java
User john = new User("John Smith");
Address address = new Address("Flowers Street", "01246", "Boston");
john.setShippingAddress(address); Ⓐ
userRepository.save(john); Ⓑ
Ⓐ 创建用户和地址之间的链接。
Ⓑ 当我们保存john时,我们将递归地保存address。
我们现在已经完成了两个基本的一对一关联映射:第一个使用共享主键,第二个使用外键引用和唯一列约束。我们想要讨论的最后一个选项稍微有些特别:通过一个额外的表来映射一对一关联。
9.1.4 使用连接表
你可能已经注意到,可空列可能会带来问题。有时,对于可选值来说,使用一个中间表是一个更好的解决方案,如果存在链接,则该表包含一行,如果不存,则不包含。
让我们考虑 CaveatEmptor 中的Shipment实体,并讨论其目的。卖家和买家通过在 CaveatEmptor 中开始和竞标拍卖来互动。运输商品似乎超出了应用程序的范围;拍卖结束后,卖家和买家会就运输和支付方式达成一致。他们可以在 CaveatEmptor 之外离线完成此事。
另一方面,我们可以在 CaveatEmptor 中提供托管服务。拍卖结束后,卖家将使用此服务创建一个可追踪的运输。买家将拍卖物品的价格支付给受托人(我们),我们会通知卖家资金已可用。一旦运输到达且买家接受,我们将把资金转给卖家。
如果你曾经参与过价值重大的在线拍卖,你可能已经使用过这样的托管服务。但我们在 CaveatEmptor 中希望提供更多:我们不仅将为完成的拍卖提供信任服务,还允许用户为他们在拍卖之外、在 CaveatEmptor 之外达成的任何交易创建可追踪和可信任的运输。这种情况需要一个具有可选一对一关联到Item的Shipment实体。该领域模型的类图如图 9.5 所示。

图 9.5 Shipment与拍卖Item有一个可选的链接。
注意:我们简要考虑放弃本节的 CaveatEmptor 示例,因为我们找不到一个需要可选一对一关联的自然场景。如果这个托管示例看起来很牵强,请考虑将员工分配到工作站的问题。这也是一个可选的一对一关系。
在数据库模式中,我们将添加一个名为 ITEM_SHIPMENT 的中间链接表。这个表中的一行代表在拍卖背景下进行的运输。图 9.6 显示了这些表。

图 9.6 中间表连接了项目和运输。
注意模式如何强制唯一性和一对一关系:ITEM_SHIPMENT 的主键是 SHIPMENT_ID 列,而 ITEM_ID 列是唯一的。因此,一个项目只能在一个运输中。当然,这也意味着一个运输只能包含一个项目。
我们将使用 Shipment 实体类中的 @OneToOne 注解来映射此模型。下面的源代码可以在 onetoone-jointable 文件夹中找到。
Path: onetoone-jointable/src/main/java/com/manning/javapersistence
➥ /ch09/onetoone/jointable/Shipment.java
@Entity
public class Shipment {
// . . .
@OneToOne(fetch = FetchType.LAZY) Ⓐ
@JoinTable(
name = "ITEM_SHIPMENT", Ⓑ
joinColumns =
@JoinColumn(name = "SHIPMENT_ID"), Ⓒ
inverseJoinColumns =
@JoinColumn(name = "ITEM_ID", Ⓓ
nullable = false,
unique = true) Ⓔ
)
private Item auction;
// . . .
}
Ⓐ 懒加载已被启用,但有一个转折:当 Hibernate 或 Spring Data JPA 使用 Hibernate 加载一个 Shipment 时,它会查询 SHIPMENT 和 ITEM_ SHIPMENT 连接表。Hibernate 在使用代理之前必须知道是否存在对 Item 的链接。它通过一个外连接 SQL 查询来完成,所以我们不会看到任何额外的 SQL 语句。如果 ITEM_SHIPMENT 中有一行,Hibernate 将使用一个 Item 代理。
Ⓑ @JoinTable 注解是新的;我们总是必须指定中间表的名字。这种映射有效地隐藏了连接表;没有对应的 Java 类。注解定义了 ITEM_SHIPMENT 表的列名。
Ⓒ 连接列是 SHIPMENT_ID(默认为 ID)。
Ⓓ 反向连接列是 ITEM_ID(默认为 AUCTION_ID)。
Ⓔ Hibernate 在模式中生成 UNIQUE 约束在 ITEM_ID 列上。Hibernate 还在连接表的列上生成适当的外键约束。
在这里,我们存储了一个没有 Item 的 Shipment 和另一个与单个 Item 链接的 Shipment:
Path: onetoone-jointable/src/test/java/com/manning/javapersistence
➥ /ch09/onetoone/jointable/AdvancedMappingSpringDataJPATest.java
Shipment shipment = new Shipment();
shipmentRepository.save(shipment);
Item item = new Item("Foo");
itemRepository.save(item);
Shipment auctionShipment = new Shipment(item);
shipmentRepository.save(auctionShipment);
这完成了我们对一对一关联映射的讨论。总结来说,如果两个实体中有一个总是存储在另一个之前并且可以作为主键源,则应使用共享主键关联。在其他所有情况下,使用外键关联,或者当一对一关联是可选的时候,使用隐藏的中间连接表。
我们现在将关注复数或多值实体关联,从一对一的一些高级选项开始。
9.2 一对多关联
根据定义,复数实体关联是一组实体引用。我们在第 8.3.2 节中映射了其中之一,即一对一关联。一对多关联是涉及集合的最重要的一种实体关联。当简单的一对多或双向多对一足以完成任务时,我们将劝阻使用更复杂的关联样式。
此外,请记住,如果你不想映射任何实体集合,你完全可以这样做;你总是可以写一个显式查询而不是通过迭代直接访问。如果你决定映射实体引用集合,你有一些选择,我们现在将分析一些更复杂的情况。
9.2.1 考虑一对多袋
到目前为止,我们只看到了在 Set 上的 @OneToMany,但我们可以为双向一对多关联使用袋映射。我们为什么要这样做呢?
袋具有所有可用于双向一对多实体关联的集合中最有效的性能特征。默认情况下,Hibernate 中的集合在第一次访问时加载。因为袋不需要维护其元素的索引(如列表)或检查重复元素(如集合),所以我们可以向袋中添加新元素而不触发加载。如果我们打算映射可能很大的实体引用集合,这是一个重要的特性。
另一方面,我们不能同时 eager-fetch 两个袋类型集合,因为生成的 SELECT 查询是无关的,需要分别保存。例如,如果 Item 的 bids 和 images 是一对多袋,这种情况可能会发生。这并不是什么大损失,因为同时获取两个集合总是会产生笛卡尔积;我们想要避免这种操作,无论集合是袋、集合还是列表。我们将在第十二章中回到获取策略。一般来说,如果我们把袋映射为 @OneToMany(mappedBy = "..."),那么袋是一个一对多关联的最佳反向集合。
为了将我们的双向一对多关联映射为袋,我们必须将 Item 实体中 bids 集合的类型替换为 Collection 和 ArrayList 实现。Item 和 Bid 之间的关联映射基本上保持不变。(后续的源代码可以在 onetomany-bag 文件夹中找到。)
Path: onetomany-bag/src/main/java/com/manning/javapersistence/ch09
➥ /onetomany/bag/Item.java
@Entity
public class Item {
/ . . .
@OneToMany(mappedBy = "item")
private Collection<Bid> bids = new ArrayList<>();
// . . .
}
与其 @ManyToOne(即“mapped by”侧)以及表格相同的 Bid 侧。
一个集合允许重复元素,而集合则不允许:
Path: onetomany-bag/src/test/java/com/manning/javapersistence/ch09
➥ /onetomany/bag/AdvancedMappingSpringDataJPATest.java
Item item = new Item("Foo");
itemRepository.save(item);
Bid someBid = new Bid(new BigDecimal("123.00"), item);
item.addBid(someBid);
item.addBid(someBid);
bidRepository.save(someBid);
assertEquals(2, someItem.getBids().size());
结果表明,在这种情况下这不相关,因为 重复 意味着我们已经将特定的引用添加到同一个 Bid 实例中多次。我们不会在我们的应用程序代码中这样做。即使我们多次将相同的引用添加到这个集合中,Hibernate 或使用 Hibernate 的 Spring Data JPA 也会忽略它——没有持久化效果。与数据库更新相关的方面是 @ManyToOne,关系已经由那一侧“映射”。当我们加载 Item 时,集合不包含重复项:
Path: onetomany-bag/src/test/java/com/manning/javapersistence/ch09/
➥ onetomany/bag/AdvancedMappingSpringDataJPATest.java
Item item2 = itemRepository.findItemWithBids(item.getId());
assertEquals(1, item2.getBids().size());
如前所述,袋的优势在于当我们添加新元素时,集合不需要初始化:
Path: onetomany-bag/src/test/java/com/manning/javapersistence/ch09/
➥ onetomany/bag/AdvancedMappingSpringDataJPATest.java
Bid bid = new Bid(new BigDecimal("456.00"), item);
item.addBid(bid); Ⓐ
bidRepository.save(bid);
Ⓐ 这个代码示例触发一个 SQL SELECT来加载Item。每当调用item.addBid()时,Hibernate 都会立即初始化并返回一个带有SELECT的Item代理。但只要我们不迭代Collection,就不需要更多的查询,并且会为新的Bid执行一个INSERT操作,而不需要加载所有出价。如果集合是Set或List,当添加另一个元素时,Hibernate 会加载所有元素。
现在,我们将集合更改为持久List。
9.2.2 单向和双向列表映射
如果我们需要一个真正的列表来保存集合中元素的位置,我们必须在额外的列中存储该位置。对于一对一映射,这也意味着我们应该将Item#bids属性更改为List并使用ArrayList初始化变量。这将是一个单向映射:将没有其他“由...映射”的一侧。Bid将没有@ManyToOne属性。对于持久列表索引,我们将使用@OrderColumn注解。(随后的源代码可以在onetomany-list文件夹中找到。)
Path: onetomany-list/src/main/java/com/manning/javapersistence/ch09/
➥ onetomany/list/Item.java
@Entity
public class Item {
@OneToMany
@JoinColumn(
name = "ITEM_ID",
nullable = false
)
@OrderColumn(
name = "BID_POSITION", Ⓐ
nullable = false Ⓑ
)
private List<Bid> bids = new ArrayList<>();
// . . .
}
Ⓐ 如前所述,这是一个单向映射:没有其他“由...映射”的一侧。Bid没有@ManyToOne属性。@OrderColumn注解将索引列的名称设置为BID_POSITION。否则,它将默认为BIDS_ORDER。
Ⓑ 如同往常,我们应该使列NOT NULL。
BID表的数据库视图,包括连接和排序列,如图 9.7 所示。

图 9.7 BID表包含ITEM_ID(连接列)和BID_POSITION(排序列)。
每个集合的存储索引从零开始,并且是连续的(没有间隔)。当我们添加、删除和移动List的元素时,Hibernate 将执行可能很多的 SQL 语句。我们已经在第 8.1.6 节中讨论了这个问题。
让我们将这个映射改为双向的,在Bid实体上添加一个@ManyToOne属性:
Path: onetomany-list/src/main/java/com/manning/javapersistence/ch09/
➥ onetomany/list/Bid.java
@Entity
public class Bid {
// . . .
@ManyToOne
@JoinColumn(
name = "ITEM_ID",
updatable = false, insertable = false Ⓐ
)
@NotNull
private Item item;
// . . .
}
Ⓐ Item#bids集合不再是只读的,因为 Hibernate 现在必须存储每个元素的索引。如果Bid#item端是关系的所有者,Hibernate 将忽略集合在存储数据时,并且不会写入元素索引。我们必须将@JoinColumn映射两次,然后使用updatable=false和insertable=false禁用@ManyToOne端的写入。现在,Hibernate 在存储数据时考虑集合端,包括每个元素的索引。@ManyToOne实际上是只读的,就像它有mappedBy属性一样。
你可能期望不同的代码——可能是@ManyToOne(mappedBy="bids")和没有额外的@JoinColumn注解。但是@ManyToOne没有mappedBy属性:它总是关系的“拥有”端。我们必须将另一端,@OneToMany,作为mappedBy端。
最后,Hibernate 模式生成器始终依赖于@ManyToOne侧的@JoinColumn。因此,如果我们想要生成正确的模式,我们应该在这侧添加@NotNull或声明@JoinColumn(nullable=false)。如果存在@ManyToOne,生成器会忽略@OneToMany侧及其连接列。
在实际应用中,我们不会用List来映射这个关联。在数据库中保留元素顺序似乎是一个常见的用例,但实际上并不太有用:有时我们可能希望首先显示最高或最新的出价列表,或者只显示某个特定用户的出价,或者显示在一定时间范围内的出价。这些操作都不需要持久化的列表索引。如第 3.2.4 节所述,最好避免在数据库中存储显示顺序,因为显示顺序可能会频繁更改;而是通过查询保持其灵活性,而不是使用硬编码的映射。此外,当应用程序在列表中删除、添加或移动元素时,维护索引可能会很昂贵,并可能触发许多 SQL 语句。使用@ManyToOne映射外键连接列,并删除集合。
接下来我们将处理另一个具有一对多关系的场景:一个关联映射到中间连接表。
9.2.3 使用连接表的可选一对多
Item类的一个有用补充是buyer属性。然后我们可以调用someItem.getBuyer()来访问出价获胜的User。如果将其双向化,这个关联也将帮助我们渲染一个显示特定用户赢得的所有拍卖的屏幕:我们可以调用someUser.getBoughtItems()而不是编写查询。
从User类的角度来看,这个关联是一对多。图 9.8 显示了类及其关系。

图 9.8 User-Item“购买”关系
为什么这个关联与Item和Bid之间的关联不同?UML 中的多重度0..*表示引用是可选的。这不会对 Java 领域模型产生太大影响,但它对底层表有影响。我们期望在ITEM表中有一个BUYER_ID外键列,但现在这个列必须是可空的,因为用户可能没有购买特定的Item(只要拍卖仍在进行中)。
我们可以接受外键列可以是NULL,并应用额外的约束:“只有在拍卖结束时间未到达或没有出价的情况下才允许为NULL。”然而,我们总是尽量避免在关系数据库模式中存在可空列。未知信息会降低我们存储的数据质量。元组代表真实的命题,我们不能断言我们不知道的事情。此外,在实践中,许多开发人员和数据库管理员没有创建正确的约束,而是依赖于经常出现错误的程序代码来提供数据完整性。
一个可选的实体关联,无论是一对一还是一对多,在 SQL 数据库中最好通过连接表来表示。图 9.9 显示了示例模式。

图 9.9 一个中间表连接用户和物品。
我们在本章早期添加了一个连接表用于一对一关联。为了保证一对一的多重性,我们在连接表的外键列上应用了唯一约束。在当前情况下,我们有一个一对多的多重性,因此只有ITEM_ID主键列必须是唯一的:只有一个User可以购买任何给定的Item一次。BUYER_ID列不是唯一的,因为一个User可以购买多个Item。(随后的源代码可以在onetomany-jointable文件夹中找到。)
User#boughtItems集合的映射很简单:
Path: onetomany-jointable/src/main/java/com/manning/javapersistence/ch09/
➥ onetomany/jointable/User.java
@Entity
@Table(name = "USERS")
public class User {
// . . .
@OneToMany(mappedBy = "buyer")
private Set<Item> boughtItems = new HashSet<>();
// . . .
}
这通常是双向关联的只读一侧,实际映射到“由...映射”侧的模式,即Item#buyer。这将是一个干净、可选的一对多/多对一关系。
Path: onetomany-jointable/src/main/java/com/manning/javapersistence/ch09/
➥ onetomany/jointable/Item.java
@Entity
public class Item {
// . . .
@ManyToOne(fetch = FetchType.LAZY)
@JoinTable(
name = "ITEM_BUYER",
joinColumns =
@JoinColumn(name = "ITEM_ID"), Ⓐ
inverseJoinColumns =
@JoinColumn(nullable = false) Ⓑ
)
private User buyer;
// . . .
}
Ⓐ 如果一个Item没有被购买,ITEM_BUYER连接表中就没有对应的行。因此,这种关系将是可选的。连接列名为ITEM_ID(它将默认为ID)。
Ⓑ 反向连接列将默认为BUYER_ID,且不可为空。
我们的模式中没有任何有问题的可空列。尽管如此,我们应该为ITEM_BUYER表编写一个过程约束和一个在INSERT上运行的触发器:“只有当给定物品的拍卖结束时间到达且用户做出了获胜出价时,才允许插入买家。”
下一个例子是我们最后一个关于一对多关联的例子。到目前为止,你已经看到了从一个实体到另一个实体的多对一关联。一个嵌入式组件类也可以有一个到实体的多对一关联,这正是我们现在要处理的。
9.2.4 嵌入式类中的一对多关联
再次考虑我们重复了几章的嵌入式组件映射:User的Address。现在我们将通过添加从Address到Shipment的一对多关联来扩展这个例子:一个名为deliveries的集合。图 9.10 显示了该模型的 UML 类图。

图 9.10 从Address到Shipment的一对多关系
Address是一个@Embeddable类,不是一个实体。它可以拥有一个到实体的单向关联;这里,它是一个到Shipment的一对多多重性。我们将在下一节中查看一个与实体有多个到一关联的嵌入式类。(随后的源代码可以在onetomany-embeddable文件夹中找到。)
Address类有一个Set<Shipment>来表示这个关联:
Path: onetomany-embeddable/src/main/java/com/manning/javapersistence/ch09/
➥ onetomany/embeddable/Address.java
@Embeddable
public class Address {
@NotNull
@Column(nullable = false)
private String street;
@NotNull
@Column(nullable = false, length = 5)
private String zipcode;
@NotNull
@Column(nullable = false)
private String city;
@OneToMany
@JoinColumn(
name = "DELIVERY_ADDRESS_USER_ID", Ⓐ
nullable = false
)
private Set<Shipment> deliveries = new HashSet<>();
// . . .
}
Ⓐ 这个关联的第一个映射策略是与一个名为@JoinColumn的DELIVERY_ADDRESS_USER_ID(它将默认为DELIVERIES_ID)。
这个外键约束列位于 SHIPMENT 表中,如图 9.11 所示。

图 9.11 USERS 表中的主键将 USERS 和 SHIPMENT 表链接起来。
可嵌入组件没有自己的标识符,因此外键列中的值是 User 标识符的值,它嵌入 Address。在这里,我们也声明连接列 nullable = false,因此 Shipment 必须有一个相关的配送地址。当然,双向导航是不可能的:Shipment 不能引用 Address,因为嵌入组件不能有共享引用。
如果关联是可选的,并且我们不希望有可空列,我们可以将关联映射到一个中间连接/链接表,如图 9.12 所示。

图 9.12 使用 USERS 和 SHIPMENT 之间的中间表来表示可选关联
Address 集合的映射现在使用 @JoinTable 而不是 @JoinColumn。(后续的源代码可以在 onetomany-embeddable-jointable 文件夹中找到。)
Path: onetomany-embeddable-jointable/src/main/java/com/manning
➥ /javapersistence/ch09/onetomany/embeddablejointable/Address.java
@Embeddable
public class Address {
@NotNull
@Column(nullable = false)
private String street;
@NotNull
@Column(nullable = false, length = 5)
private String zipcode;
@NotNull
@Column(nullable = false)
private String city;
@OneToMany
@JoinTable(
name = "DELIVERIES", Ⓐ
joinColumns =
@JoinColumn(name = "USER_ID"), Ⓑ
inverseJoinColumns =
@JoinColumn(name = "SHIPMENT_ID") Ⓒ
)
private Set<Shipment> deliveries = new HashSet<>();
// . . .
}
Ⓐ 连接表的名称将是 DELIVERIES(否则默认为 USERS_SHIPMENT)。
Ⓑ 连接列的名称将是 USER_ID(否则默认为 USERS_ID)。
Ⓒ 反向连接列的名称将是 SHIPMENT_ID(否则默认为 SHIPMENTS_ID)。
注意,如果我们既没有声明 @JoinTable 也没有声明 @JoinColumn,则嵌入类中的 @OneToMany 默认为连接表策略。
在拥有实体类内部,我们可以使用 @AttributeOverride 覆盖嵌入类的属性映射,如 6.2.3 节中所示。如果我们想覆盖嵌入类中实体关联的连接表或列映射,我们可以在拥有实体类中使用 @AssociationOverride。然而,我们无法切换映射策略;嵌入组件类中的映射决定是否使用连接表或连接列。
连接表映射当然也适用于真正的 多对多 映射。
9.3 多对多和三元关联
Category 和 Item 之间的关联是一个 多对多 关联,如图 9.13 所示。在一个真实系统中,我们可能没有多对多关联——我们的经验是,几乎总是有其他信息必须附加到关联实例之间的每个链接上。一些例子是 Item 被添加到 Category 时的戳记,以及创建链接的 User。我们将在本节稍后扩展示例以涵盖此类情况,但我们将从一个常规和更简单的多对多关联开始。

图 9.13 Category 和 Item 之间的多对多关联
9.3.1 单向和双向多对多关联
数据库中的连接表代表了一个常规的多对多关联,一些开发者也将其称为 链接表 或 关联表。图 9.14 展示了一个带有链接表的多对多关系。

图 9.14 CategorizedItem 是 Category 和 Item 之间的链接。
CATEGORY_ITEM 链接表有两列,这两列分别通过外键约束引用 CATEGORY 和 ITEM 表。它的主键是这两列的复合键。我们只能将特定的 Category 和 Item 链接一次,但我们可以将相同的项链接到多个类别。下面的源代码可以在 manytomany-bidirectional 文件夹中找到。
在 JPA 中,我们使用 @ManyToMany 注解在集合上映射多对多关联:
Path: manytomany-bidirectional/src/main/java/com/manning
➥ /javapersistence/ch09/manytomany/bidirectional/Category.java
@Entity
public class Category {
// . . .
@ManyToMany(cascade = CascadeType.*PERSIST*)
@JoinTable(
name = "CATEGORY_ITEM",
joinColumns = @JoinColumn(name = "CATEGORY_ID"),
inverseJoinColumns = @JoinColumn(name = "ITEM_ID")
)
private Set<Item> items = new HashSet<>();
// . . .
}
如同往常,我们可以启用 CascadeType.PERSIST 以便更容易保存数据。当我们从集合中引用新的 Item 时,Hibernate 或使用 Hibernate 的 Spring Data JPA 会使其持久化。让我们使这个关联双向(如果我们不需要,我们不必这样做):
Path: manytomany-bidirectional/src/main/java/com/manning
➥ /javapersistence/ch09/manytomany/bidirectional/Item.java
@Entity
public class Item {
// . . .
@ManyToMany(mappedBy = "items")
private Set<Category> categories = new HashSet<>();
// . . .
}
在任何双向映射中,一方是“由”另一方“映射”的。Item#categories 集合实际上是只读的;Hibernate 将在存储数据时分析 Category#items 方的内容。
接下来,我们将创建两个类别和两个项目,并以多对多的方式将它们链接起来:
Path: manytomany-bidirectional/src/test/java/com/manning
➥ /javapersistence/ch09/manytomany/bidirectional/TestService.java
Category someCategory = new Category("Some Category");
Category otherCategory = new Category("Other Category");
Item someItem = new Item("Some Item");
Item otherItem = new Item("Other Item");
someCategory.addItem(someItem);
someItem.addCategory(someCategory);
someCategory.addItem(otherItem);
otherItem.addCategory(someCategory);
otherCategory.addItem(someItem);
someItem.addCategory(otherCategory);
categoryRepository.save(someCategory);
categoryRepository.save(otherCategory);
由于我们启用了传递性持久化,保存类别会使整个实例网络持久化。另一方面,级联选项 ALL、REMOVE 和孤儿删除(在第 8.3.3 节中讨论)对于多对多关联来说没有意义。这是一个测试我们是否理解实体和价值类型的好点。试着想出合理的答案,解释为什么这些级联类型对于多对多关联来说没有意义。提示:考虑如果删除一条记录会自动删除相关记录可能会发生什么。
我们能否用 List 代替 Set,甚至是一个包?Set 完美地匹配数据库模式,因为在 Category 和 Item 之间不能有重复的链接。一个包意味着有重复的元素,因此我们需要为连接表提供一个不同的主键。Hibernate 的专有 @CollectionId 注解可以提供这一点,如第 8.1.5 节中所示。然而,如果我们需要支持重复链接,我们将在稍后讨论的替代多对多策略是一个更好的选择。
我们可以使用常规的 @ManyToMany 将索引集合(如 List)映射,但只能在一方进行。记住,在双向关系中,一方必须由另一方“映射”,这意味着当 Hibernate 与数据库同步时,其值会被忽略。如果双方都是列表,我们只能使一方的索引持久化。
正规的@ManyToMany映射隐藏了链接表;没有对应的 Java 类,只有一些集合属性。所以,每当有人说,“我的链接表有更多关于链接的信息的列”(在我们的经验中,总是有人会早点说),我们需要将这个信息映射到一个 Java 类。
9.3.2 通过中间实体进行多对多
我们总是可以将多对多关联表示为两个多对一关联到一个中间类,这就是我们接下来要做的。我们不会隐藏链接表;我们将用 Java 类来表示它。这种模型通常更容易扩展,所以我们通常不会在应用程序中使用常规的多对多关联。当不可避免地添加更多列到链接表时,更改代码的工作量很大,所以在映射如前节所示的@ManyToMany之前,请考虑图 9.15 中所示的替代方案。

图 9.15 CategorizedItem将是Category和Item之间的链接。
想象一下,每次我们将一个Item添加到Category时,我们需要记录一些信息。CategorizedItem实体捕获了时间戳和创建链接的用户。这个领域模型需要在链接表上添加额外的列,如图 9.16 所示。

图 9.16 多对多关系中链接表上的附加列
CategorizedItem实体映射到链接表,你将在列表 9.4 中看到。(随后的源代码可以在manytomany-linkentity文件夹中找到。)这将涉及一大段代码和一些新的注解。首先,它将是一个不可变实体类(用@org.hibernate.annotations.Immutable注解),因此我们永远不会在创建后更新属性。如果我们声明类不可变,Hibernate 可以执行一些优化,例如在持久化上下文刷新期间避免脏检查。
实体类将有一个组合键,我们将将其封装在一个静态嵌套的可嵌入组件类中,以便于使用。标识符属性及其组合键列将通过@EmbeddedId注解映射到实体表。
列表 9.4 使用CategorizedItem映射多对多关系
Path: manytomany-linkentity/src/main/java/com/manning/javapersistence
➥ /ch09/manytomany/linkentity/CategorizedItem.java
@Entity
@Table(name = "CATEGORY_ITEM")
@org.hibernate.annotations.Immutable Ⓐ
public class CategorizedItem {
@Embeddable
public static class Id implements Serializable { Ⓑ
@Column(name = "CATEGORY_ID")
private Long categoryId;
@Column(name = "ITEM_ID")
private Long itemId;
public Id() {
}
public Id(Long categoryId, Long itemId) {
this.categoryId = categoryId;
this.itemId = itemId;
}
//implementing equals and hashCode
}
@EmbeddedId Ⓒ
private Id id = new Id();
@Column(updatable = false)
@NotNull
private String addedBy; Ⓓ
@Column(updatable = false)
@NotNull
@CreationTimestamp
private LocalDateTime addedOn; Ⓔ
@ManyToOne
@JoinColumn(
name = "CATEGORY_ID",
insertable = false, updatable = false)
private Category category; Ⓕ
@ManyToOne
@JoinColumn(
name = "ITEM_ID",
insertable = false, updatable = false)
private Item item; Ⓖ
public CategorizedItem(
String addedByUsername, Ⓗ
Category category,
Item item) {
this.addedBy = addedByUsername; Ⓘ
this.category = category;
this.item = item;
this.id.categoryId = category.getId(); Ⓙ
this.id.itemId = item.getId();
category.addCategorizedItem(this); Ⓘ
item.addCategorizedItem(this);
}
// . . .
}
Ⓐ 该类是不可变的,如通过@org.hibernate.annotations .Immutable注解所示。
Ⓑ 实体类需要一个标识符属性。链接表的主键是由CATEGORY_ID和ITEM_ID组合而成的。当然,我们可以将这个Id类外部化到它自己的文件中。
Ⓒ 新的@EmbeddedId注解将标识符属性及其组合键列映射到实体表。
Ⓓ 将addedBy用户名映射到链接表的一个列的基本属性。
Ⓔ 将addedOn时间戳映射到链接表的一个列的基本属性。这是我们感兴趣的“关于链接的附加信息”。
Ⓕ @ManyToOne 属性 category 已经在标识符中进行了映射。
Ⓖ @ManyToOne 属性 item 已经在标识符中进行了映射。这里的技巧是使它们只读,使用 updatable=false、insertable=false 设置。这意味着 Hibernate 或使用 Hibernate 的 Spring Data JPA 会通过取 CategorizedItem 的标识符值来写入这些列的值。同时,我们可以通过 categorizedItem.getItem() 和 getCategory() 读取和浏览关联的实例。(如果我们没有将同一列映射为只读,Hibernate 或使用 Hibernate 的 Spring Data JPA 在启动时会抱怨重复的列映射。)
Ⓗ 我们还可以看到,构建一个 CategorizedItem 涉及到设置标识符的值。应用程序始终分配复合键值;Hibernate 不生成它们。
Ⓘ 构造函数设置了 addedBy 字段值,并通过管理关联两边的集合来保证引用完整性。
Ⓙ 构造函数设置了 categoryId 字段值。我们将映射这些集合以启用双向导航。这是一个单向映射,足以支持 Category 和 Item 之间的多对多关系。要创建一个链接,我们实例化并持久化一个 CategorizedItem。如果我们想断开一个链接,我们删除 CategorizedItem。CategorizedItem 的构造函数要求我们提供已经持久化的 Category 和 Item 实例。
如果需要双向导航,我们可以在 Category 和/或 Item 中映射一个 @OneToMany 集合。这里是在 Category 中的示例:
Path: manytomany-linkentity/src/main/java/com/manning/javapersistence
➥ /ch09/manytomany/linkentity/Category.java
@Entity
public class Category {
// . . .
@OneToMany(mappedBy = "category")
private Set<CategorizedItem> categorizedItems = new HashSet<>();
// . . .
}
这里是 Item 中的示例:
Path: manytomany-linkentity/src/main/java/com/manning
➥ /javapersistence/ch09/manytomany/linkentity/Item.java
@Entity
public class Item {
// . . .
@OneToMany(mappedBy = "item")
private Set<CategorizedItem> categorizedItems = new HashSet<>();
// . . .
}
双方都由 CategorizedItem 中的注解“映射”,所以当遍历由 getCategorizedItems() 方法返回的集合时,Hibernate 已经知道该怎么做。
This is how we create and store links:
Path: manytomany-linkentity/src/test/java/com/manning/javapersistence
➥ /ch09/manytomany/linkentity/TestService.java
Category someCategory = new Category("Some Category");
Category otherCategory = new Category("Other Category");
categoryRepository.save(someCategory);
categoryRepository.save(otherCategory);
Item someItem = new Item("Some Item");
Item otherItem = new Item("Other Item");
itemRepository.save(someItem);
itemRepository.save(otherItem);
CategorizedItem linkOne = new CategorizedItem(
"John Smith", someCategory, someItem
);
CategorizedItem linkTwo = new CategorizedItem(
"John Smith", someCategory, otherItem
);
CategorizedItem linkThree = new CategorizedItem(
"John Smith", otherCategory, someItem
);
categorizedItemRepository.save(linkOne);
categorizedItemRepository.save(linkTwo);
categorizedItemRepository.save(linkThree);
这种策略的主要优势是双向导航的可能性:我们可以通过调用 someCategory.getCategorizedItems() 来获取一个类别中的所有项目,我们也可以通过 someItem.getCategorizedItems() 从相反方向导航。一个缺点是需要更复杂的代码来管理 CategorizedItem 实例,以创建和删除链接,我们必须独立地保存和删除它们。我们还需要在 CategorizedItem 类中提供一些基础设施,例如复合标识符。一个小改进是,可以在一些关联上启用 CascadeType.PERSIST,从而减少对 save() 的调用次数。
在这个例子中,我们将创建 Category 和 Item 之间链接的用户存储为一个简单的名称字符串。如果连接表有一个名为 USER_ID 的外键列,我们就会有一个三元关系。CategorizedItem 将会有一个 @ManyToOne 用于 Category、Item 和 User。
在下一节中,我们将演示另一种多对多策略。为了使其更有趣,我们将使其成为一个三元关联。
9.3.3 使用组件的三元关联
在前一节中,我们使用一个映射到链接表的实体类来表示多对多关系。一个潜在的更简单替代方案是映射到一个可嵌入组件类。以下源代码可以在manytomany-ternary文件夹中找到。
Path: manytomany-ternary/src/main/java/com/manning/javapersistence
➥ /ch09/manytomany/ternary/CategorizedItem.java
@Embeddable
public class CategorizedItem {
@ManyToOne
@JoinColumn(
name = "ITEM_ID",
nullable = false, updatable = false
)
private Item item;
@ManyToOne
@JoinColumn(
name = "USER_ID",
updatable = false
)
@NotNull Ⓐ
private User addedBy;
@Column(updatable = false)
@NotNull Ⓐ
private LocalDateTime addedOn = LocalDateTime.now();
public CategorizedItem() {
}
public CategorizedItem(User addedBy,
Item item) {
this.addedBy = addedBy;
this.item = item;
}
// . . .
}
Ⓐ @NotNull注解不会生成 SQL 约束,因此被注解的字段不会成为主键的一部分。
这里的新映射是@Embeddable中的@ManyToOne关联以及额外的外键连接列USER_ID,这使得这是一个三元关系。查看图 9.17 中的数据库模式。

图 9.17 一个具有三个外键列的链接表
可嵌入组件集合的所有者是Category实体:
Path: manytomany-ternary/src/main/java/com/manning/javapersistence
➥ /ch09/manytomany/ternary/Category.java
@Entity
public class Category {
// . . .
@ElementCollection
@CollectionTable(
name = "CATEGORY_ITEM",
joinColumns = @JoinColumn(name = "CATEGORY_ID")
)
private Set<CategorizedItem> categorizedItems = new HashSet<>();
// . . .
}
不幸的是,这种映射并不完美:当我们映射可嵌入类型的@ElementCollection时,目标类型中所有nullable=false的属性都成为(组合)主键的一部分。我们希望CATEGORY_ITEM中的所有列都是NOT NULL。尽管如此,只有CATEGORY_ID和ITEM_ID列应该包含在主键中。技巧是使用 Bean Validation @NotNull注解在不应包含在主键中的属性上。在这种情况下(因为它是一个可嵌入类),Hibernate 会忽略 Bean Validation 注解用于主键实现和 SQL 模式生成。缺点是生成的模式不会在USER_ID和ADDEDON列上有适当的NOT NULL约束,我们应该手动修复。
这种策略的优势在于链接组件的隐式生命周期。要创建Category和Item之间的关联,请向集合中添加一个新的CategorizedItem实例。要断开链接,请从集合中删除该元素。不需要额外的级联设置,Java 代码也简化了(尽管分散在更多行中):
Path: manytomany-ternary/src/test/java/com/manning/javapersistence
➥ /ch09/manytomany/ternary/TestService.java
Category someCategory = new Category("Some Category");
Category otherCategory = new Category("Other Category");
categoryRepository.save(someCategory);
categoryRepository.save(otherCategory);
Item someItem = new Item("Some Item");
Item otherItem = new Item("Other Item");
itemRepository.save(someItem);
itemRepository.save(otherItem);
User someUser = new User("John Smith");
userRepository.save(someUser);
CategorizedItem linkOne = new CategorizedItem(
someUser, someItem
);
someCategory.addCategorizedItem(linkOne);
CategorizedItem linkTwo = new CategorizedItem(
someUser, otherItem
);
someCategory.addCategorizedItem(linkTwo);
CategorizedItem linkThree = new CategorizedItem(
someUser, someItem
);
otherCategory.addCategorizedItem(linkThree);
无法启用双向导航:可嵌入组件,如CategorizedItem,按定义不能有共享引用。我们不能从Item导航到CategorizedItem,在Item中也没有这种链接的映射。相反,我们可以编写一个查询来检索类别,给定一个Item:
Path: manytomany-ternary/src/test/java/com/manning/javapersistence
➥ /ch09/manytomany/ternary/TestService.java
List<Category> categoriesOfItem =
categoryRepository.findCategoryWithCategorizedItems(item1);
assertEquals(2, categoriesOfItem.size());
findCategoryWithCategorizedItems方法被@Query注解标注:
Path: manytomany-ternary/src/main/java/com/manning/javapersistence
➥ /ch09/repositories/manytomany/ternary/CategoryRepository.java
@Query("select c from Category c join c.categorizedItems ci where
ci.item = :itemParameter")
List<Category> findCategoryWithCategorizedItems(
@Param("itemParameter") Item itemParameter);
我们现在已经完成了第一个三元关联映射。在前几章中,我们看到了使用映射的 ORM 示例;那些映射的键和值总是基本或可嵌入类型。在下一节中,我们将使用更复杂的关键/值对类型及其映射。
9.4 使用映射的实体关联
映射键和值可以是其他实体的引用,这为映射多对多和三元关系提供了另一种策略。首先,让我们假设每个映射条目的值仅是另一个实体的引用。
9.4.1 使用属性键的一对多
如果每个映射条目的值是另一个实体的引用,我们有一个一对一的实体关系。映射的键是基本类型,例如Long值。(后续的源代码可以在maps-mapkey文件夹中找到。)
这种结构的例子是一个具有Bid实例映射的Item实体,其中每个映射条目是一个由Bid标识符和Bid实例引用组成的对。当我们遍历someItem.getBids()时,我们遍历看起来像(1, <对具有 PK 1 的 Bid 的引用>)、(2, <对具有 PK 2 的 Bid 的引用>)等映射条目:
Path: maps-mapkey/src/test/java/com/manning/javapersistence
➥ /ch09/maps/mapkey/TestService.java
Item item = itemRepository.findById(someItem.getId()).get();
assertEquals(2, item.getBids().size());
for (Map.Entry<Long, Bid> entry : item.getBids().entrySet()) {
assertEquals(entry.getKey(), entry.getValue().getId());
}
这种映射的底层表没有什么特别之处;我们拥有ITEM和BID表,在BID表中有一个ITEM_ID外键列。这与图 8.14 中展示的具有常规集合而不是Map的一对多/多对一映射的架构相同。我们在这里的动机是应用中数据的不同表示形式。
在Item类中,我们将包含一个名为bids的Map属性:
Path: maps-mapkey/src/main/java/com/manning/javapersistence
➥ /ch09/maps/mapkey/Item.java
@Entity
public class Item {
// . . .
@MapKey(name = "id")
@OneToMany(mappedBy = "item")
private Map<Long, Bid> bids = new HashMap<>();
// . . .
}
新增的是@MapKey注解。它将目标实体的一个属性映射为映射的键:在这种情况下,是Bid实体,作为映射的键。如果我们省略name属性,则默认是目标实体的标识符属性,所以这里的name选项是多余的。因为映射的键形成一个集合,我们应该期望特定映射的值是唯一的。对于Bid主键来说是这样,但对于Bid的任何其他属性可能不是这样。确保所选属性具有唯一值的责任在我们身上——Hibernate 或使用 Hibernate 的 Spring Data JPA 不会进行检查。
这种映射技术的首要且罕见的使用场景是将具有实体值属性作为条目键的映射条目迭代,可能是因为它符合我们想要呈现数据的方式。更常见的情况是在三元关联中间的映射。
9.4.2 关键字/值三元关系
你可能已经对我们执行的所有映射实验感到有些厌烦了,但我们承诺这是我们最后一次展示另一种表示Category和Item之间关联的方式。之前,在 9.3.3 节中,我们使用了一个可嵌入的CategorizedItem组件来表示链接。在这里,我们将展示一个使用Map而不是额外 Java 类的关联表示。每个映射条目的键是一个Item,相关的值是添加Item到Category中的User,如图 9.18 所示。

![图 9.18 具有实体关联作为键/值对的 Map]
如图 9.19 所示,架构中的链接/连接表有三个列:CATEGORY_ID、ITEM_ID和USER_ID。Map属于Category实体。后续的源代码可以在maps-ternary文件夹中找到。

![图 9.19 链接表表示 Map 键/值对]
以下代码使用映射来表示Category和Item之间的关系。
Path: maps-ternary/src/main/java/com/manning/javapersistence
➥ /ch09/maps/ternary/Category.java
@Entity
public class Category {
// . . .
@ManyToMany(cascade = CascadeType.PERSIST)
@MapKeyJoinColumn(name = "ITEM_ID") Ⓐ
@JoinTable(
name = "CATEGORY_ITEM",
joinColumns = @JoinColumn(name = "CATEGORY_ID"),
inverseJoinColumns = @JoinColumn(name = "USER_ID")
)
private Map<Item, User> itemAddedBy = new HashMap<>();
// . . .
}
Ⓐ @MapKeyJoinColumn是可选的;Hibernate 或使用 Hibernate 的 Spring Data JPA 将默认使用列名ITEMADDEDBY_KEY作为引用ITEM表的连接/外键列。
要在这三个实体之间创建链接,所有实例必须已经处于持久状态,然后放入映射中:
Path: maps-ternary/src/test/java/com/manning/javapersistence
➥ /ch09/maps/ternary/TestService.java
someCategory.putItemAddedBy(someItem, someUser);
someCategory.putItemAddedBy(otherItem, someUser);
otherCategory.putItemAddedBy(someItem, someUser);
要移除链接,请从映射中删除条目。这管理了一个复杂的关系,隐藏了一个具有三个列的数据库链接表。但请记住,在实际操作中,链接表通常会增长额外的列,如果你依赖于Map API,那么稍后更改所有 Java 应用程序代码将非常昂贵。早些时候,我们有一个ADDEDON列,用于记录链接创建的时间戳,但我们必须为了这次映射而丢弃它。
摘要
-
复杂实体关联可以使用一对一关联、一对多关联、多对多关联、三元关联以及带有映射的实体关联进行映射。
-
你可以通过共享主键、使用外键主键生成器、使用外键连接列或使用连接表来创建一对一关联。
-
你可以通过考虑一对多包、使用单向和双向列表映射、应用可选的一对多与连接表或在一个可嵌入类中创建一对多关联来创建一对多关联。
-
你可以创建单向和双向多对多关联,以及带有中间实体的多对多关联。
-
你可以使用组件和带有映射的实体关联来构建三元关联。
-
你通常可以将多对多实体关联表示为从中间实体类或组件集合出发的两个多对一关联。
-
在尝试复杂的集合映射之前,请务必确保你确实需要集合。问问自己你是否经常遍历其元素。
-
本章中使用的 Java 结构有时可以简化数据访问,但通常它们会复杂化数据存储、更新和删除。
第三部分. 事务数据处理
在第三部分中,您将使用 Hibernate 和 Java 持久化来加载和存储数据。您将了解编程接口、编写事务性应用以及 Hibernate 如何最有效地从数据库加载数据。
从第十章开始,您将学习在 JPA 应用中与实体实例交互的最重要策略。您将看到实体实例的生命周期:它们如何变得持久、分离和移除。这一章是您将了解 JPA 中最重要接口的地方:EntityManager。接下来,第十一章定义了数据库和系统事务的基本知识以及如何使用 Hibernate、JPA 和 Spring 控制并发访问。您还将看到非事务性数据访问。在第十二章中,我们将讨论延迟加载和立即加载、抓取计划、策略和配置文件,并以优化 SQL 执行作为总结。最后,第十三章涵盖了级联状态转换、监听和拦截事件、使用 Hibernate Envers 进行审计和版本控制,以及动态过滤数据。
阅读本书的这一部分后,您将了解如何使用 Hibernate 和 Java 持久化编程接口来高效地加载、修改和存储对象。您将理解事务的工作原理以及为什么会话处理可以为应用设计开辟新的方法。您将准备好优化任何对象修改场景,并应用最佳的抓取和缓存策略以提高性能和可伸缩性。
10 管理数据
本章涵盖了
-
检查对象的生命周期和状态
-
使用
EntityManager接口 -
使用分离状态
你现在已经理解了 ORM 如何解决对象/关系不匹配的静态方面。根据你目前所知,你可以创建 Java 类和 SQL 模式之间的映射,从而解决结构不匹配问题。正如你将记得的,范式不匹配涵盖了粒度、继承、标识、关联和数据导航等问题。为了更深入地了解,请回顾第 1.2 节。
然而,除此之外,一个高效的应用程序解决方案还需要更多:你必须研究运行时数据管理的策略。这些策略对于应用程序的性能和正确行为至关重要。
在本章中,我们将分析实体实例的生命周期——实例如何变得持久,以及它如何停止被视为持久,以及触发这些转换的方法调用和管理操作。JPA 的 EntityManager 是访问数据的主要接口。
在我们查看 JPA 之前,让我们从实体实例、其生命周期以及触发状态变化的触发事件开始。尽管其中一些材料可能是正式的,但对持久化生命周期的深入了解是必不可少的。
JPA 2 中的主要新特性
我们可以通过 EntityManager#unwrap() 获取持久化管理器 API 的供应商特定变体,例如 org.hibernate.Session API。使用已演示的 EntityManagerFactory#unwrap() 方法获取 org.hibernate.SessionFactory 实例(参见第 2.5 节)。
新的 detach() 操作提供了对持久化上下文的细粒度管理,可以逐个移除实体实例。
从现有的 EntityManager 中,我们可以通过 getEntityManagerFactory() 获取用于创建持久化上下文的 EntityManagerFactory。
新的静态 PersistenceUtil 和 PersistenceUnitUtil 辅助方法确定实体实例(或其实例的属性)是否已完全加载或是一个未初始化的引用(Hibernate 代理或未加载的集合包装器)。
10.1 持久化生命周期
由于 JPA 是一种透明的持久化机制,其中类对其自身的持久化能力一无所知,因此可以编写不知道其操作的数据代表持久状态还是仅存在于内存中的临时状态的逻辑。应用程序在调用其方法时不必一定关心实例是否持久。例如,我们可以调用 Item#calculateTotalPrice() 业务方法而不必考虑任何持久化(例如在单元测试中)。在执行过程中,该方法可能对任何持久化概念一无所知。
任何具有持久化状态的应用程序都必须在需要将内存中持有的状态传播到数据库(或反之亦然)时与持久化服务交互。换句话说,我们必须调用 Jakarta Persistence 接口来存储和加载数据。
当以这种方式与持久化机制交互时,应用程序必须关注实体实例相对于持久化的状态和生命周期。我们称这为持久化生命周期:实体实例在其生命周期中经历的状态,我们将在稍后分析它们。我们还使用术语工作单元:一组(可能)改变状态的运算,被视为一个(通常是原子)组。拼图中的另一部分是持久化服务提供的持久化上下文。将持久化上下文想象成一个服务,它记得我们在特定工作单元中对数据进行的所有修改和状态变化(这有些简化,但是一个好的起点)。
现在,我们将剖析以下术语:实体状态、持久化上下文和管理范围。你可能更习惯于思考你需要管理哪些 SQL 语句来将数据放入和取出数据库,但 Java 持久化成功的关键因素之一是状态管理的分析,所以请跟随我们通过这一节。
10.1.1 实体实例状态
不同的 ORM 解决方案使用不同的术语,并为持久化生命周期定义不同的状态和状态转换。此外,内部使用的状态可能与客户端应用程序暴露的状态不同。JPA 定义了四种状态,隐藏了 Hibernate 内部实现的复杂性,从而避免了客户端代码的复杂性。图 10.1 显示了这些状态及其转换。

图 10.1 实体实例状态及其转换
图 10.1 还包括调用EntityManager(和Query)API 的方法调用,这些调用触发转换。我们将在本章中讨论此图表;在需要概述时请参考它。
现在,让我们更详细地探讨状态和转换。
瞬时状态
使用new Java 运算符创建的实例是瞬时的,这意味着一旦不再被引用,它们的状态就会丢失并被垃圾回收。例如,new Item()创建了一个Item类的瞬时实例,就像new Long()和new BigDecimal()创建那些类的瞬时实例一样。Hibernate 不提供任何对瞬时实例的回滚功能;如果我们修改瞬时Item的价格,我们无法自动撤销更改。
要使实体实例从瞬时状态转换为持久状态,需要调用EntityManager#persist()方法或从已持久化的实例创建引用,并启用该映射关联的状态级联。
持久化状态
一个持久实体实例在数据库中有表示。它存储在数据库中——或者当工作单元完成时将被存储。它是一个具有数据库身份的实例,如第 5.2 节中定义的;其数据库标识符被设置为数据库表示的主键值。
应用程序可能通过调用EntityManager#persist()方法创建了实例,并使它们持久化。实例也可能在应用程序创建了一个指向 JPA 提供者已管理的另一个持久实例的对象引用时变得持久。一个持久实体实例可能是一个通过执行查询、标识符查找或从另一个持久实例开始导航对象图检索的实例。
持久实例始终与持久化上下文相关联。我们稍后将了解更多关于这一点。
移除状态
我们可以通过几种方式从数据库中删除一个持久实体实例。例如,我们可以使用EntityManager#remove()方法将其删除。如果启用了孤儿删除,从映射集合中删除对其的引用也可能使其可删除。
然后,实体实例将处于移除状态:提供者将在工作单元结束时删除它。我们在完成与其实例的工作后——例如,在用户看到的移除确认屏幕渲染后——应该丢弃我们可能持有的任何引用。
分离状态
要理解分离的实体实例,考虑加载一个实例。我们调用EntityManager#find()通过其(已知)标识符检索实体实例。然后我们结束我们的工作单元并关闭持久化上下文。应用程序仍然有一个处理——即对我们加载的实例的引用。现在它处于分离状态,数据正在变得过时。我们可以丢弃引用并让垃圾回收器回收内存。或者,我们可以在分离状态下继续处理数据,稍后调用merge()方法以新工作单元保存我们的修改。我们将在第 10.3 节中讨论分离和合并。
您现在应该对实体实例状态及其转换有一个基本理解。我们接下来要讨论的主题是持久化上下文:任何 Jakarta Persistence 提供者的一项基本服务。
10.1.2 持久化上下文
在 Java Persistence 应用程序中,EntityManager有一个持久化上下文。当我们调用EntityManagerFactory#createEntityManager()时创建持久化上下文。上下文在调用EntityManager#close()时关闭。在 JPA 术语中,这是一个应用程序管理的持久化上下文;我们的应用程序定义了持久化上下文的范围,界定工作单元。
持久化上下文监控和管理所有处于持久状态中的实体。持久化上下文是 JPA 提供者功能的核心。
持久化上下文还允许持久化引擎执行自动脏检查,检测应用程序修改了哪些实体实例。然后提供者将与持久化上下文监控的实例的状态与数据库同步,无论是自动的还是按需的。通常,当工作单元完成时,提供者通过执行 SQL INSERT、UPDATE和DELETE语句(所有都是数据操作语言,DML 的一部分)将内存中持有的状态传播到数据库。此刷新过程也可能在其他时间发生。例如,Hibernate 可能在查询执行之前与数据库同步。这确保查询了解工作单元早期所做的更改。
持久化上下文还充当一级缓存;它记住特定工作单元中处理的所有实体实例。例如,如果我们要求 Hibernate 使用主键值(通过标识符查找)加载实体实例,Hibernate 首先会检查持久化上下文中的当前工作单元。如果 Hibernate 在持久化上下文中找到实体实例,则不会发生数据库访问——这是应用程序的可重复读。具有相同持久化上下文的连续em.find(Item.class, ITEM_ID)调用将产生相同的结果。
此缓存还会影响任意查询的结果,例如使用javax.persistence.Query API 执行的查询。Hibernate 读取查询的 SQL 结果集并将其转换为实体实例。此过程首先尝试通过标识符查找解决持久化上下文中的每个实体实例。只有当无法在当前持久化上下文中找到具有相同标识符值的实例时,Hibernate 才会从结果集行中读取其余数据。由于数据库级别的读已提交事务隔离,如果实体实例已在持久化上下文中,Hibernate 会忽略结果集中任何可能更新的数据。
持久化上下文缓存始终开启——无法关闭。它确保以下内容:
-
在对象图中的循环引用情况下,持久化层不会受到栈溢出的影响。
-
在工作单元结束时,同一数据库行的冲突表示永远不会出现。提供者可以安全地将对实体实例所做的所有更改写入数据库。
-
同样,在特定持久化上下文中做出的更改总是立即对所有在该工作单元及其持久化上下文中执行的代码可见。JPA 保证了可重复的实体实例读取。
持久化上下文提供了一个保证的对象身份作用域;在单个持久化上下文的范围内,只有一个实例代表特定的数据库行。考虑引用entityA == entityB的比较。这只有在两者都是堆上同一 Java 实例的引用时才为true。现在考虑比较entityA.getId().equals(entityB.getId())。如果两者都有相同的数据库标识符值,则为true。在单个持久化上下文中,Hibernate 保证这两个比较将产生相同的结果。这解决了我们在 1.2.3 节中讨论的基本对象/关系不匹配问题之一。
实体实例的生命周期和持久化上下文提供的服务一开始可能难以理解。让我们看看一些关于脏检查、缓存以及保证身份作用域在实际中如何工作的代码示例。为此,我们将与持久化管理器 API 一起工作。
处理作用域的身份是否更好?
对于一个典型的 Web 或企业应用程序,持久化上下文作用域的身份更受欢迎。处理作用域的身份,其中只有内存中的一个实例代表整个过程中的行(JVM),在缓存利用率方面提供了一些潜在的优势。然而,在一个广泛的多线程应用程序中,始终同步对持久实例的共享访问的代价太高。每个线程在持久化上下文中使用各自的数据副本,这更简单且更具可扩展性。
10.2 EntityManager 接口
任何透明的持久化工具都包括一个持久化管理器 API。这个持久化管理器通常提供基本 CRUD(创建、读取、更新、删除)操作、查询执行和控制持久化上下文的服务。在 Jakarta Persistence 应用程序中,我们与之交互的主要接口是EntityManager以创建单元工作。
注意:要执行源代码中的示例,您首先需要运行 Ch10.sql 脚本。接下来的源代码可以在managing-data和managing-data2文件夹中找到。
在本章中,我们不会使用 Spring Data JPA,甚至不会使用 Spring 框架。接下来的示例将使用 JPA,有时还会使用 Hibernate API,而不进行任何 Spring 集成——它们对我们的演示和分析来说粒度更细。
10.2.1 规范的单元工作
在 Java SE 和某些 EE 架构中(如果我们只有普通的 servlets,例如),我们通过调用EntityManagerFactory#createEntityManager()来获取EntityManager。应用程序代码共享EntityManagerFactory,代表一个持久化单元,或一个逻辑数据库。大多数应用程序只有一个共享的EntityManagerFactory。
我们在一个线程中使用EntityManager进行单个单元工作,创建它并不昂贵。以下列表显示了单元工作的规范、典型形式。
列表 10.1 一个典型的单元工作
Path: managing-data/src/test/java/com/manning/javapersistence/ch10
➥ /SimpleTransitionsTest.java
EntityManagerFactory emf =
Persistence.createEntityManagerFactory("ch10");
// . . .
EntityManager em = emf.createEntityManager();
\1 {
em.getTransaction().begin();
// . . .
em.getTransaction().commit();
} catch (Exception ex) {
// Transaction rollback, exception handling
// . . .
} finally {
if (em != null && em.isOpen())
em.close();
}
在 em.getTransaction().begin() 和 em.getTransaction().commit() 之间的所有操作都在一个事务中完成。现在,请记住,在事务作用域内的所有数据库操作,例如 Hibernate 执行的 SQL 语句,要么完全成功,要么完全失败。现在不必过于担心事务代码;你将在下一章中了解更多关于并发控制的内容。我们将在那里用同样的例子,重点关注事务和异常处理代码。不过,不要在代码中编写空的 catch 块——你将不得不回滚事务并处理异常。
创建 EntityManager 会启动其持久化上下文。Hibernate 不会在必要时访问数据库;EntityManager 不会从连接池中获取 JDBC Connection,直到需要执行 SQL 语句。我们甚至可以在不接触数据库的情况下创建和关闭 EntityManager。当我们在持久化上下文中查找或查询数据以及当 Hibernate 将检测到的更改刷新到数据库时,Hibernate 执行 SQL 语句。当创建 EntityManager 时,Hibernate 会加入正在进行的系统事务并等待事务提交。当 Hibernate 被通知提交时,它会执行持久化上下文的脏检查并与数据库同步。我们还可以通过在任何时候调用 EntityManager#flush() 来手动强制执行脏检查同步。
我们通过选择何时 close() EntityManager 来确定持久化上下文的范围。我们必须在某个时候关闭持久化上下文,所以总是将 close() 调用放在 finally 块中。
持久化上下文应该保持多长时间开放?让我们假设以下示例中我们正在编写一个服务器,并且每个客户端请求都将在一个多线程环境中使用一个持久化上下文和系统事务进行处理。如果你熟悉 servlet,可以想象列表 10.1 中的代码嵌入在 servlet 的 service() 方法中。在这个工作单元内,你通过访问 EntityManager 来加载数据和存储数据。
10.2.2 使数据持久化
让我们创建一个实体的新实例,并将其从临时状态转换为持久状态。你将在想要将新创建的对象的信息保存到数据库时这样做。我们可以在图 10.2 中看到相同的工作单元以及 Item 实例如何改变状态。

图 10.2 在工作单元中使实例持久化
要使一个实例持久化,你可以使用以下类似的代码:
Path: managing-data/src/test/java/com/manning/javapersistence/ch10
➥ /SimpleTransitionsTest.java – makePersistent()
Item item = new Item();
item.setName("Some Item");
em.persist(item);
Long ITEM_ID = item.getId();
按照常规,会实例化一个新的临时 Item 对象。当然,我们也可以在创建 EntityManager 之前就实例化它。调用 persist() 方法会使 Item 的临时实例变为持久化。然后,它将由当前持久化上下文管理和关联。
要将Item实例存储到数据库中,Hibernate 必须执行一个 SQL INSERT语句。当这个工作单元的事务提交时,Hibernate 刷新持久化上下文,INSERT就在那时发生。Hibernate 甚至可能将INSERT与其他语句一起在 JDBC 级别批量执行。当我们调用persist()时,只有Item的标识符值被分配。或者,如果标识符生成器不是pre-insert,则INSERT语句将在调用persist()时立即执行。你可能想回顾第 5.2.5 节,以刷新对标识符生成器策略的了解。
使用标识符检测实体状态
有时我们需要知道一个实体实例是持久化的、瞬时的还是分离的。
-
持久状态——如果
EntityManager#contains(e)返回true,实体实例就处于持久状态。 -
瞬时状态——如果
PersistenceUnitUtil#getIdentifier(e)返回null,它就处于瞬时状态。 -
分离状态——如果它不是持久的,它就处于分离状态,
PersistenceUnitUtil#getIdentifier(e)将返回实体标识符属性的值。
我们可以从EntityManagerFactory获取到PersistenceUnitUtil。
有两个需要注意的问题。首先,要注意标识符值可能直到持久化上下文刷新后才被分配和可用。其次,Hibernate(与一些其他 JPA 提供者不同)如果标识符属性是原始类型(一个long而不是Long),则从PersistenceUnitUtil#getIdentifier()永远不会返回null。
在使用持久化上下文管理Item实例之前完全初始化该实例更好(但不是必需的)。SQL INSERT语句包含在调用persist()时实例持有的值。如果我们不在使Item持久化之前设置其name,可能会违反NOT NULL约束。我们可以在调用persist()之后修改Item,并且这些更改将通过额外的 SQL UPDATE语句传播到数据库。
如果在刷新过程中INSERT或UPDATE语句中的任何一个失败,Hibernate 将在数据库级别回滚此事务中对持久化实例所做的更改。但 Hibernate 不会回滚对持久化实例的内存更改。如果我们更改Item#name之后persist(),提交失败不会回滚到旧名称。这是合理的,因为事务的失败通常是不可恢复的,我们必须立即丢弃失败的持久化上下文和EntityManager。我们将在下一章讨论异常处理。
接下来,我们将加载和修改存储的数据。
10.2.3 检索和修改持久化数据
我们可以使用 EntityManager 从数据库检索持久实例。在实际应用中,我们会在上一节中某处保存 Item 的标识符值,现在我们通过标识符在新工作单元中查找相同的实例。图 10.3 以图形方式展示了这一转换。

图 10.3 在工作单元中使实例持久化
要在一个工作单元中使一个实例持久化,你可以使用以下代码片段:
Path: managing-data/src/test/java/com/manning/javapersistence/ch10
➥ /SimpleTransitionsTest.java – retrievePersistent()
Item item = em.find(Item.class, ITEM_ID); Ⓐ
if (item != null)
item.setName("New Name"); Ⓑ
Ⓐ 如果 item 已经不在持久上下文中,指令将击中数据库。
Ⓑ 然后我们修改名称。
我们不需要将 find() 操作的返回值进行类型转换;它是一个泛型方法,其返回类型是第一个参数的副作用。检索到的实体实例处于持久状态,现在我们可以在工作单元内对其进行修改。
如果找不到具有给定标识符值的持久实例,find() 方法将返回 null。如果持久上下文缓存中没有给定实体类型和标识符的匹配项,find() 操作总是会击中数据库。在加载实体实例时,实例总是被初始化。我们可以期待在分离状态下稍后获得其所有值,例如在关闭持久上下文后渲染屏幕时。(如果启用了可选的二级缓存,Hibernate 可能不会击中数据库。)
我们可以修改 Item 实例,持久上下文将检测这些更改并将它们自动记录在数据库中。当 Hibernate 在提交期间刷新持久上下文时,它执行必要的 SQL DML 语句以同步数据库中的更改。Hibernate 尽可能晚地将状态更改传播到数据库,即在事务结束时。DML 语句通常会在数据库中创建锁,直到事务完成才释放,因此 Hibernate 尽可能缩短数据库中的锁持续时间。
Hibernate 使用 SQL UPDATE 将新的 Item#name 写入数据库。默认情况下,Hibernate 将映射的 ITEM 表的所有列包括在 SQL UPDATE 语句中,更新未更改的列到其旧值。因此,Hibernate 可以在启动时生成这些基本 SQL 语句,而不是在运行时。如果我们只想在 SQL 语句中包含已修改的(或对于 INSERT 为非可空的)列,我们可以启用动态 SQL 生成,如第 5.3.2 节中所示。
Hibernate 通过比较 Item 与它在从数据库加载 Item 时所拍摄的快照来检测更改的 name。如果 Item 与快照不同,则需要执行 UPDATE。持久上下文中的这个快照消耗内存。使用快照的脏检查也可能很耗时,因为 Hibernate 必须在刷新期间将持久上下文中的所有实例与其快照进行比较。
我们之前提到,持久化上下文使实体实例的可重复读成为可能,并提供对象身份保证:
Path: managing-data/src/test/java/com/manning/javapersistence/ch10
➥ /SimpleTransitionsTest.java – retrievePersistent()
Item itemA = em.find(Item.class, ITEM_ID); Ⓐ
Item itemB = em.find(Item.class, ITEM_ID); Ⓑ
assertTrue(itemA == itemB);
assertTrue(itemA.equals(itemB));
assertTrue(itemA.getId().equals(itemB.getId()));
Ⓐ 第一次find()操作击中数据库,并使用SELECT语句检索Item实例。
Ⓑ 第二次find()是一个可重复读操作,并在持久化上下文中解决,并返回相同的缓存Item实例。
有时候我们需要一个实体实例,但我们不想击中数据库。
10.2.4 获取引用
如果我们不想在加载实体实例时击中数据库,因为我们不确定是否需要一个完全初始化的实例,我们可以告诉EntityManager尝试检索一个空白的占位符——一个代理。
如果持久化上下文已经包含具有给定标识符的Item,则getReference()会返回该Item实例,而不会击中数据库。此外,如果当前没有管理具有该标识符的持久实例,Hibernate 将生成空白的占位符:代理。这意味着getReference()不会访问数据库,并且它不会返回null,与find()不同。JPA 提供了PersistenceUnitUtil辅助方法。isLoaded()辅助方法用于检测我们是否正在处理一个未初始化的代理。
一旦我们调用任何方法,例如Item#getName(),在代理上,就会执行一个SELECT来完全初始化占位符。这个规则的例外是一个映射的数据库标识符获取方法,例如getId()。代理可能看起来像是真实的东西,但它只是一个携带实体实例标识值占位符。如果代理初始化时数据库记录已不存在,则会抛出EntityNotFoundException。请注意,异常可能在调用Item#getName()时抛出。Hibernate 类有一个方便的静态initialize()方法,可以加载代理的数据。
在持久化上下文关闭后,item处于分离状态。如果我们不在持久化上下文仍然打开时初始化代理,当我们访问代理时,会抛出LazyInitializationException,如下面的代码所示。一旦持久化上下文关闭,我们就不能按需加载数据。解决方案很简单:在关闭持久化上下文之前加载数据。
Path: managing-data/src/test/java/com/manning/javapersistence/ch10
➥ /SimpleTransitionsTest.java – retrievePersistentReference()
Item item = em.getReference(Item.class, ITEM_ID); Ⓐ
PersistenceUnitUtil persistenceUtil = Ⓑ
emf.getPersistenceUnitUtil();
assertFalse(persistenceUtil.isLoaded(item)); Ⓒ
// assertEquals("Some Item", item.getName()); Ⓓ
// Hibernate.initialize(item); Ⓔ
em.getTransaction().commit();
em.close(); Ⓕ
assertThrows(LazyInitializationException.class, () -> item.getName()); Ⓖ
Ⓐ 持久化上下文。
Ⓑ 辅助方法。
Ⓒ 检测未初始化的代理。
Ⓓ 将异常映射到规则。
Ⓔ 加载代理数据。
Ⓕ item处于分离状态。
Ⓖ 在关闭持久化上下文后加载数据。
我们将在第十二章中详细介绍代理、延迟加载和按需获取。
接下来,如果我们想从数据库中删除实体实例的状态,我们必须将其设置为瞬态。
10.2.5 使数据瞬态
要使实体实例瞬态并删除其数据库表示,我们可以在EntityManager上调用remove()方法。图 10.4 显示了此过程。

图 10.4 在工作单元中移除一个实例
如果我们调用find(),Hibernate 将执行SELECT来加载Item。如果我们调用getReference(),Hibernate 试图避免SELECT并返回一个代理。调用remove()将在工作单元完成时将实体实例排队以供删除;它现在处于移除状态。如果对代理调用remove(),Hibernate 将执行SELECT来加载数据。实体实例必须在生命周期转换期间完全初始化。我们可能启用了生命周期回调方法或实体监听器(见第 13.2 节),并且实例必须通过这些拦截器来完成其完整生命周期。
已移除状态的实体不再处于持久状态。我们可以通过contains()操作来检查这一点。我们可以使已移除的实例再次持久化,取消删除操作。
当事务提交时,Hibernate 将状态转换与数据库同步并执行 SQL DELETE。JVM 垃圾回收器检测到item不再被任何东西引用,最终删除数据的最后痕迹。我们最终可以关闭EntityManager:
Path: managing-data/src/test/java/com/manning/javapersistence/ch10
➥ /SimpleTransitionsTest.java – makeTransient()
Item item = em.find(Item.class, ITEM_ID); Ⓐ
em.remove(item); Ⓑ
assertFalse(em.contains(item)); Ⓒ
// em.persist(item); Ⓓ
assertNull(item.getId()); Ⓔ
em.getTransaction().commit(); Ⓕ
em.close(); Ⓖ
Ⓐ 调用find();Hibernate 执行SELECT来加载Item。
Ⓑ 调用remove();Hibernate 在工作单元完成时将实体实例排队以供删除。
Ⓒ 已移除状态中的实体不再包含在持久化上下文中。
Ⓓ 取消删除操作使已移除的实例再次持久化。
Ⓔ item现在将看起来像一个瞬态实例。
Ⓕ 事务提交;Hibernate 将状态转换与数据库同步并执行 SQL DELETE。
Ⓖ 关闭EntityManager。
默认情况下,Hibernate 不会更改已移除实体实例的标识符值。这意味着item.getId()方法仍然返回现在过时的标识符值。有时进一步处理“已删除”的数据是有用的:例如,如果我们用户决定撤销,我们可能希望再次保存已移除的Item。如示例所示,我们可以在持久化上下文刷新之前对已移除的实例调用persist()来取消删除。或者,如果我们将在 persistence.xml 中将属性hibernate.use_identifier_rollback设置为true,Hibernate 将在实体实例删除后重置标识符值。在先前的代码示例中,标识符值重置为默认值null(它是一个Long)。现在Item与瞬态状态相同,我们可以在新的持久化上下文中再次保存它。
假设我们从数据库中加载一个实体实例并处理数据。由于某种原因,我们知道另一个应用程序或可能是应用程序的另一个线程已更新数据库中的底层行。接下来我们将看到如何刷新内存中持有的数据。
10.2.6 刷新数据
在您加载实体实例之后,可能存在其他进程更改了数据库中与该实例对应的信息。以下示例演示了刷新持久化实体实例:
Path: managing-data/src/test/java/com/manning/javapersistence/ch10
➥ /SimpleTransitionsTest.java – refresh()
Item item = em.find(Item.class, ITEM_ID);
item.setName("Some Name");
// Someone updates this row in the database with "Concurrent UpdateName"
em.refresh(item);
em.close();
assertEquals("Concurrent UpdateName", item.getName());
在我们加载实体实例后,我们意识到(不重要的是如何)有人更改了数据库中的数据。调用refresh()会导致 Hibernate 执行一个SELECT来读取和序列化整个结果集,覆盖我们在应用程序内存中持久实例所做的更改。因此,item的name被更新为另一侧设置的值。如果数据库行不再存在(如果有人删除了它),则在refresh()时 Hibernate 会抛出EntityNotFoundException。
大多数应用程序不需要手动刷新内存中的状态;并发修改通常在事务提交时解决。刷新的最佳用例是与扩展的持久化上下文一起使用,这可能跨越几个请求/响应周期或系统事务。当我们等待用户输入并保持持久化上下文打开时,数据会变得过时,并且根据对话的持续时间和用户与系统之间的对话,可能需要选择性地刷新。如果用户取消对话,刷新可以用来撤销在对话期间在内存中做出的更改。
另一个不常用的操作是复制实体实例。
10.2.7 复制数据
复制在需要从数据库检索数据并将其存储在另一个数据库中时很有用。复制将一个持久上下文中加载的分离实例使其在另一个持久上下文中持久化。我们通常从两个不同的EntityManagerFactory配置中打开这些上下文,从而启用两个逻辑数据库。我们必须在这两个配置中映射实体。
replicate()操作仅在 Hibernate Session API 上可用。以下是一个示例,它从一个数据库中加载一个Item实例并将其复制到另一个数据库中:
Path: managing-data/src/test/java/com/manning/javapersistence/ch10
➥ /SimpleTransitionsTest.java – replicate()
EntityManager emA = getDatabaseA().createEntityManager();
emA.getTransaction().begin();
Item item = emA.find(Item.class, ITEM_ID);
emA.getTransaction().commit();
EntityManager emB = getDatabaseB().createEntityManager();
emB.getTransaction().begin();
emB.unwrap(Session.class)
.replicate(item, org.hibernate.ReplicationMode.*LATEST_VERSION*);
Item item1 = emB.find(Item.class, ITEM_ID);
assertEquals("Some Item", item1.getName());
emB.getTransaction().commit();
emA.close();
emB.close();
ReplicationMode控制复制过程的细节:
-
IGNORE—当数据库中存在具有相同标识符的现有数据库行时忽略该实例。 -
OVERWRITE—覆盖数据库中具有相同标识符的任何现有数据库行。 -
EXCEPTION—如果在目标数据库中存在具有相同标识符的现有数据库行,则抛出异常。 -
LATEST_VERSION—如果数据库中的行版本比给定实体实例的版本旧,则覆盖数据库中的该行,否则忽略该实例。需要启用具有实体版本控制的乐观并发控制(在第 11.2.2 节中讨论)。
当我们协调不同数据库中输入的数据时,可能需要复制。一个用例是产品升级:如果应用程序的新版本需要新的数据库(模式),我们可能希望迁移并复制现有数据一次。
持久化上下文为您做了很多事情:自动脏检查、保证对象身份的范畴,等等。同样重要的是,您需要了解其管理的一些细节,有时您还需要影响幕后发生的事情。
10.2.8 持久化上下文中的缓存
持久化上下文是持久实例的缓存。每个处于持久状态的实体实例都与持久化上下文相关联。
许多忽视这一简单事实的 Hibernate 用户会遇到 OutOfMemoryError。这通常发生在我们在工作单元中加载了成千上万的实体实例,但从未打算修改它们的情况下。Hibernate 仍然需要在持久化上下文缓存中为每个实例创建一个快照,这可能导致内存耗尽。(显然,如果我们修改了成千上万行,我们应该执行批量数据操作。)
持久化上下文缓存永远不会自动缩小,因此您应该将持久化上下文的大小保持在最小必要范围内。通常,上下文中的许多持久实例都是意外存在的——例如,因为我们只需要几个项目,但查询了多个。极大的图可能对性能有严重影响,并需要大量内存来存储状态快照。请确保查询返回您所需的数据,并考虑以下方法来控制 Hibernate 的缓存行为。
您可以调用 EntityManager#detach(i) 手动将持久实例从持久化上下文中驱逐。您还可以调用 EntityManager#clear() 来断开所有持久实体实例,使持久化上下文为空。
原生的 Session API 有一些额外的操作,您可能会觉得很有用。您可以将整个持久化上下文设置为只读模式。这将禁用状态快照和脏检查,Hibernate 不会将修改写入数据库:
Path: managing-data2/src/test/java/com/manning/javapersistence/ch10
➥ /ReadOnly.java – selectiveReadOnly()
em.unwrap(Session.class).setDefaultReadOnly(true); Ⓐ
Item item = em.find(Item.class, ITEM_ID);
item.setName("New Name");
em.flush(); Ⓑ
Ⓐ 将持久化上下文设置为只读。
Ⓑ 因此,flush() 不会更新数据库。
您可以禁用单个实体实例的脏检查:
Path: managing-data2/src/test/java/com/manning/javapersistence/ch10
➥ /ReadOnly.java – selectiveReadOnly()
Item item = em.find(Item.class, ITEM_ID);
em.unwrap(Session.class).setReadOnly(item, true); Ⓐ
item.setName("New Name");
em.flush(); Ⓑ
Ⓐ 在持久化上下文中将 item 设置为只读。
Ⓑ 因此,flush() 不会更新数据库。
使用 org.hibernate.Query 接口的查询可以返回只读结果,Hibernate 不会检查修改:
Path: managing-data2/src/test/java/com/manning/javapersistence/ch10
➥ /ReadOnly.java – selectiveReadOnly()
org.hibernate.query.Query query = em.unwrap(Session.class)
.createQuery("select i from Item i");
query.setReadOnly(true).list(); Ⓐ
List<Item> result = query.list();
for (Item item : result)
item.setName("New Name");
em.flush(); Ⓑ
Ⓐ 将查询设置为只读。
Ⓑ 因此,flush() 不会更新数据库。
使用查询提示,您还可以禁用使用 JPA 标准的 javax.persistence.Query 接口获得的实例的脏检查:
Query query = em.createQuery(queryString)
.setHint(
org.hibernate.annotations.QueryHints.READ_ONLY,
true
);
对于只读实体实例要小心:您仍然可以删除它们,对集合的修改也很棘手!如果您使用这些设置与映射集合一起使用,Hibernate 手册中有一个很长的特殊案例列表,您需要阅读。
到目前为止,持久化上下文的刷新和同步是在事务提交时自动发生的。然而,在某些情况下,我们需要对同步过程有更多的控制。
10.2.9 刷新持久化上下文
默认情况下,Hibernate 在联合事务提交时刷新EntityManager的持久化上下文,并将更改与数据库同步。所有之前的代码示例,除了最后一节的一些示例,都使用了那种策略。JPA 允许实现选择在其它时间同步持久化上下文。
作为 JPA 实现,Hibernate 在以下时间同步:
-
当一个联合的 Java 事务 API (JTA) 系统事务提交时。
-
在执行查询之前——我们这里说的不是使用
find()进行的查找,而是使用javax.persistence.Query或类似的 Hibernate API 进行的查询。 -
当应用程序显式调用
flush()时。
我们可以通过EntityManager的FlushModeType设置来控制此行为:
Path: managing-data/src/test/java/com/manning/javapersistence/ch10
➥ /SimpleTransitionsTest.java – flushModeType()
em.getTransaction().begin();
Item item = em.find(Item.class, ITEM_ID);
item.setName("New Name");
em.setFlushMode(FlushModeType.COMMIT);
assertEquals(
"Original Name",
em.createQuery("select i.name from Item i where i.id = :id", String.class)
.setParameter("id", ITEM_ID).getSingleResult()
);
em.getTransaction().commit(); // Flush!
em.close();
在这里,我们加载一个Item实例并更改其名称。然后我们查询数据库,检索项目的名称。通常,Hibernate 会识别内存中数据已更改,并在查询之前将这些修改与数据库同步。这是FlushModeType.AUTO的行为,如果我们用事务连接EntityManager,这是默认设置。使用FlushModeType.COMMIT我们禁用在查询之前的刷新,因此我们可能会看到查询返回的数据与内存中的数据不同。同步仅在事务提交时发生。
在事务进行过程中,我们可以通过调用EntityManager#flush()来强制进行脏检查和与数据库的同步。
这就结束了我们对临时、持久和已移除实体状态的讨论,以及EntityManager API 的基本用法。掌握这些状态转换和 API 方法是至关重要的;每个 JPA 应用程序都是通过这些操作构建的。
接下来,我们将探讨分离的实体状态。我们已经提到了当实体实例不再与持久化上下文关联时会出现的一些问题,例如禁用延迟初始化。让我们通过一些示例来探索分离状态,以便我们知道在处理持久化上下文之外的数据时可以期待什么。
10.3 与分离状态一起工作
如果一个引用离开了保证身份的作用域,我们称它为分离实体实例的引用。当持久化上下文关闭时,它不再提供身份映射服务。当你处理分离的实体实例时,会遇到别名问题,所以请确保你理解如何处理分离实例的身份。
10.3.1 分离实例的标识
如果我们在相同的持久化上下文中使用相同的数据库标识符值查找数据,结果将是两个指向 JVM 堆上相同内存实例的引用。当从相同的持久化上下文中获取不同的引用时,它们具有相同的 Java 身份。这些引用可能相等,因为默认情况下equals()依赖于 Java 身份比较。它们显然具有相同的数据库身份。它们引用的是由该工作单元的持久化上下文管理的相同实例的持久状态。
当第一个持久化上下文关闭时,引用处于分离状态。我们可能正在处理存在于对象身份保证范围之外的实例。
列表 10.2 Java 持久性中对象身份的保证范围
Path: managing-data/src/test/java/com/manning/javapersistence/ch10
➥ /SimpleTransitionsTest.java – scopeOfIdentity()
em = emf.createEntityManager(); Ⓐ
em.getTransaction().begin(); Ⓑ
Item a = em.find(Item.class, ITEM_ID); Ⓒ
Item b = em.find(Item.class, ITEM_ID); Ⓒ
assertTrue(a == b); Ⓓ
assertTrue(a.equals(b)); Ⓔ
assertEquals(a.getId(), b.getId()); Ⓕ
em.getTransaction().commit(); Ⓖ
em.close(); Ⓗ
em = emf.createEntityManager();
em.getTransaction().begin();
Item c = em.find(Item.class, ITEM_ID);
assertTrue(a != c); Ⓘ
assertFalse(a.equals(c)); Ⓙ
assertEquals(a.getId(), c.getId()); Ⓚ
em.getTransaction().commit();
em.close();
Ⓐ 创建持久化上下文。
Ⓑ 开始事务。
Ⓒ 加载一些实体实例。
Ⓓ 引用a和b是从相同的持久化上下文中获得的;它们具有相同的 Java 身份。
Ⓔ equals()依赖于 Java 身份比较。
Ⓕ a和b引用的是由该工作单元的持久化上下文管理的相同Item实例,处于持久状态。
Ⓖ 提交事务。
Ⓗ 关闭持久化上下文。当第一个持久化上下文关闭时,引用a和b处于分离状态。
Ⓘ 在不同的持久化上下文中加载的a和c并不相同。
Ⓙ a.equals(c)也是false,因为equals()方法没有被重写,这意味着它使用实例相等性(==)。
Ⓚ 对数据库身份的测试仍然返回true。
如果我们在分离状态下将实体实例视为相等,这可能会导致问题。例如,考虑以下代码的扩展,在第二个工作单元结束后:
em.close();
Set<Item> allItems = new HashSet<>();
allItems.add(a);
allItems.add(b);
allItems.add(c);
assertEquals(2, allItems.size());
此示例将所有三个引用添加到Set中,并且所有都是分离实例的引用。现在,如果我们检查集合的大小——元素的数量——我们应该期待什么结果?
Set不允许重复元素。重复项通过Set检测;每次我们向HashSet添加引用时,都会自动调用Item#equals()方法与集合中已存在的所有其他元素进行比较。如果equals()对集合中任何已存在的元素返回true,则不会发生添加。
默认情况下,所有 Java 类都继承自java.lang.Object的equals()方法。此实现使用双等号(==)比较来检查两个引用是否指向 Java 堆上的相同内存实例。
你可能会猜测集合中的元素数量将是 2。毕竟,a 和 b 是对同一内存实例的引用;它们是在同一个持久化上下文中加载的。我们从另一个持久化上下文中获得了引用 c;它指向堆上的不同实例。我们有三个引用指向两个实例,但我们之所以知道这一点,仅仅是因为我们看到了加载数据的代码。在实际应用中,我们可能不知道 a 和 b 是在不同于 c 的上下文中加载的。此外,我们可能会期望集合恰好有一个元素,因为 a、b 和 c 代表相同的数据库行,相同的 Item。
无论何时我们与分离状态的实例一起工作并测试它们的相等性(通常在基于哈希的集合中),我们都需要为我们的映射实体类提供自己的 equals() 和 hashCode() 方法实现。这是一个重要的问题:如果我们不与分离状态的实体实例一起工作,则不需要采取任何行动,java.lang.Object 的默认 equals() 实现就足够好了。我们将依赖 Hibernate 在持久化上下文中保证的对象身份范围。即使我们与分离实例一起工作,如果我们从不检查它们是否相等,或者从不将它们放入 Set 中或用作 Map 中的键,我们也不必担心。如果我们所做的只是将分离的 Item 在屏幕上渲染出来,我们并没有将它与任何东西进行比较。
假设我们想要使用分离的实例,并且必须使用我们自己的方法来测试它们的相等性。
10.3.2 实现相等性方法
我们可以以多种方式实现 equals() 和 hashCode() 方法。记住,当我们重写 equals() 方法时,我们也需要重写 hashCode() 方法,以确保这两个方法的一致性。如果两个实例相等,它们必须具有相同的哈希值。
一种看似聪明的做法是实现 equals() 来仅比较数据库标识符属性,这通常是代理主键值。基本上,如果两个 Item 实例通过 getId() 返回相同的标识符,它们必须是相同的。如果 getId() 返回 null,它必须是一个尚未保存的瞬态 Item。
不幸的是,这个解决方案有一个巨大的问题:标识符值是在实例变得持久化之前由 Hibernate 分配的。如果在保存之前将一个瞬态实例添加到 Set 中,那么当我们保存它时,它的哈希值会在它被 Set 包含时发生变化。这与 java.util.Set 的契约相矛盾,破坏了集合。特别是,这个问题使得基于集合的映射关联的级联持久化状态变得无用。我们强烈反对使用数据库标识符相等性。
要得到我们推荐的解决方案,你需要理解业务键的概念。业务键是一个属性或一些属性的组合,对于具有相同数据库身份的每个实例都是唯一的。本质上,如果我们不使用代理主键,它就是我们会使用的自然键。与自然主键不同,业务键永远不会改变并不是一个绝对的要求——只要它很少改变,这就足够了。
我们认为,本质上每个实体类都应该有一个业务键,即使它包括类的所有属性(这对于某些不可变类可能是合适的)。如果我们的用户正在查看屏幕上的项目列表,他们如何区分项目 A、B 和 C?相同的属性或属性组合是我们的业务键。业务键是用户认为可以唯一标识特定记录的东西,而代理键是应用程序和数据库系统所依赖的。业务键属性或属性在我们的数据库模式中最可能是受约束的UNIQUE。
让我们为User实体类编写自定义的相等性方法;这比比较Item实例要容易。对于User类,username是一个很好的业务键候选。它总是必需的,它与数据库约束唯一,并且很少改变,如果有的话。
列表 10.3 User的相等性自定义实现
@Entity
@Table(name = "USERS",
uniqueConstraints =
@UniqueConstraint(columnNames = "USERNAME"))
public class User {
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (other == null) return false;
if (!(other instanceof User)) return false;
User that = (User) other;
return this.getUsername().equals(that.getUsername());
}
@Override
public int hashCode() {
return getUsername().hashCode();
}
// . . .
}
你可能已经注意到,equals()方法的代码总是通过 getter 方法访问“其他”引用的属性。这一点非常重要,因为作为other传递的引用可能是一个 Hibernate 代理,而不是实际持有持久状态的实例。我们无法直接访问User代理的username字段。为了初始化代理以获取属性值,我们需要使用 getter 方法来访问它。这是 Hibernate 不是完全透明的一个地方,但无论如何,使用 getter 方法而不是直接访问实例变量是一个好的实践。
使用instanceof检查other引用的类型,而不是通过比较getClass()的值。再次强调,other引用可能是一个代理,它是运行时生成的User的子类,因此this和other可能不是完全相同的类型,但可以是有效的超类型或子类型。你将在第 12.1.1 节中了解更多关于代理的信息。
我们现在可以安全地比较持久状态中的User引用:
em = emf.createEntityManager();
em.getTransaction().begin();
User a = em.find(User.class, USER_ID);
User b = em.find(User.class, USER_ID);
assertTrue(a == b);
assertTrue(a.equals(b));
assertEquals(a.getId(), b.getId());
em.getTransaction().commit();
em.close();
当然,如果我们比较持久状态和分离状态中实例的引用,我们也会得到正确的行为:
em = emf.createEntityManager();
em.getTransaction().begin();
User c = em.find(User.class, USER_ID);
assertFalse(a == c); Ⓐ
assertTrue(a.equals(c)); Ⓑ
assertEquals(a.getId(), c.getId());
em.getTransaction().commit();
em.close();
Set<User> allUsers = new HashSet();
allUsers.add(a);
allUsers.add(b);
allUsers.add(c);
assertEquals(1, allUsers.size()); Ⓒ
Ⓐ 比较这两个引用当然仍然是假的。
Ⓑ 现在它们是相等的。
Ⓒ 集合的大小最终是正确的。
对于某些其他实体,业务键可能更复杂,由属性的组合组成。以下是一些可以帮助你在领域模型类中识别业务键的提示:
-
考虑当应用程序的用户需要识别一个对象(在现实世界中)时会参考哪些属性。如果它们在屏幕上显示,用户如何区分一个元素与另一个元素?这可能是你寻找的业务键。
-
每个不可变属性可能是业务键的良好候选者。如果它们很少更新或者你可以控制它们更新的情况,例如确保实例不在
Set中,那么可变属性也可能是好的候选者。 -
每个具有
UNIQUE数据库约束的属性都是业务键的良好候选者。记住,业务键的精度必须足够高,以避免重叠。 -
任何基于日期或时间的属性,例如记录的创建时间戳,通常是业务键的一个良好组成部分,但
System .currentTimeMillis()的准确性取决于虚拟机和操作系统。我们推荐的安全缓冲区是 50 毫秒,如果基于时间的属性是业务键的唯一属性,这可能不够准确。 -
你可以将数据库标识符作为业务键的一部分。这似乎与我们的先前声明相矛盾,但我们讨论的并不是给定实体的数据库标识符值。你可能能够使用关联实体实例的数据库标识符。例如,
Bid类的一个候选业务键是与它匹配的Item的标识符,以及投标金额。你甚至可以在数据库模式中有一个唯一约束来表示这个复合业务键。你可以使用关联的Item的标识符值,因为它在Bid的生命周期中永远不会改变——Bid构造函数可以要求一个已经持久化的Item。
如果你遵循这些建议,你不太会遇到为所有业务类找到好的业务键的困难。如果你遇到困难的情况,尝试在不考虑 Hibernate 的情况下解决它。毕竟,这是一个纯粹面向对象的问题。请注意,在子类上重写 equals() 并在比较中包含另一个属性的情况极为罕见。在这种情况下,满足 Object 标识和相等要求有点棘手,即相等既是对称的也是传递的,更重要的是,业务键可能不对应于数据库中的任何定义良好的候选自然键(子类属性可能映射到不同的表)。有关自定义相等比较的更多信息,请参阅 Joshua Bloch 所著的第三版《Effective Java》(Bloch,2017),这是所有 Java 程序员必读的书籍。
User 类现在已准备好进入分离状态;我们可以安全地将不同持久化上下文中加载的实例放入一个 Set 中。接下来,我们将查看一些涉及分离状态的示例,你将看到这个概念的一些好处。
10.3.3 分离实体实例
有时我们可能想手动将实体实例从持久化上下文中分离出来。我们不必等待持久化上下文关闭。我们可以手动驱逐实体实例:
Path: managing-data/src/test/java/com/manning/javapersistence/ch10
➥ /SimpleTransitionsTest.java – detach()
User user = em.find(User.class, USER_ID);
em.detach(user);
assertFalse(em.contains(user));
此示例还演示了 EntityManager#contains() 操作,如果给定实例在此持久化上下文中处于管理持久状态,则返回 true。
现在,我们可以以分离状态使用 user 引用了。许多应用程序只在持久化上下文关闭后读取和渲染数据。
在持久化上下文关闭后修改加载的 user 对其在数据库中的持久化表示没有影响。尽管如此,JPA 允许我们在新的持久化上下文中合并任何更改回数据库。
10.3.4 合并实体实例
假设我们在之前的持久化上下文中检索了一个 User 实例,现在我们想修改它并保存这些修改:
Path: managing-data/src/test/java/com/manning/javapersistence/ch10
➥ /SimpleTransitionsTest.java – mergeDetached()
detachedUser.setUsername("johndoe");
em = emf.createEntityManager();
em.getTransaction().begin();
User mergedUser = em.merge(detachedUser);
mergedUser.setUsername("doejohn");
em.getTransaction().commit();
em.close();
考虑图 10.5 中此过程的图形表示。目标是记录分离的 User 的新 username。这并不像看起来那么困难。

图 10.5 在工作单元中使实例持久化
首先,当我们调用 merge() 时,Hibernate 会检查持久化上下文中的持久实例是否具有与我们要合并的分离实例相同的数据库标识符。在这个例子中,持久化上下文是空的;什么都没有从数据库中加载。因此,Hibernate 从数据库中加载具有此标识符的实例。然后 merge() 将分离的实体实例 复制到 这个已加载的持久实例上。换句话说,我们在分离的 User 上设置的新 username 也被设置在持久合并的 User 上,这是 merge() 返回给我们的。
现在,我们丢弃了旧的、过时的分离状态的引用;detachedUser 不再代表当前状态。我们可以继续修改返回的 mergedUser;在提交期间,Hibernate 将在刷新持久化上下文时执行单个 UPDATE。
如果持久化上下文中没有具有相同标识符的持久实例,并且数据库中的按标识符查找为负,Hibernate 会实例化一个新的 User。然后 Hibernate 将我们的分离实例复制到这个新实例上,当我们同步持久化上下文与数据库时,它会将这个新实例插入到数据库中。
如果我们传递给 merge() 的实例不是分离的,而是临时的(它没有标识符值),Hibernate 会实例化一个新的 User,将临时 User 的值复制到它上面,然后使其持久化并返回给我们。用更简单的话说,merge() 操作可以处理分离的 和 临时的实体实例。Hibernate 总是作为持久化实例返回结果给我们。
基于分离和合并的应用程序架构可能不会调用persist()操作。我们可以合并新的和分离的实体实例以存储数据。重要的区别是返回的当前状态以及我们在应用程序代码中如何处理这种引用切换。我们必须丢弃detachedUser,从现在开始引用当前的mergedUser。我们应用程序中的其他任何其他组件仍然持有detachedUser的,都必须切换到mergedUser。
我能否重新附加一个分离实例?
Hibernate Session API 有一个名为saveOrUpdate()的重新附加方法。它接受瞬态或分离实例,并且不返回任何内容。操作后,给定实例将处于持久化状态,因此我们不需要切换引用。如果给定实例是瞬态的,Hibernate 将执行INSERT;如果是分离的,将执行UPDATE。我们建议您依赖合并,因为它标准化了,因此更容易与其他框架集成。此外,合并可能只会触发SELECT而不是UPDATE,如果分离的数据没有被修改。如果您想知道Session API 的saveOrUpdateCopy()方法做什么,它与EntityManager上的merge()相同。
如果我们想要删除一个分离实例,我们必须先合并它。然后我们可以在merge()方法返回的持久化实例上调用remove()。
摘要
-
实体实例的生命周期包括瞬态、持久化、分离和移除状态。
-
JPA 中最重要的接口是
EntityManager。 -
我们可以使用
EntityManager来使数据持久化,检索和修改持久化数据,获取引用,使数据瞬态,刷新和复制数据,在持久化上下文中缓存,以及刷新持久化上下文。 -
我们可以处理分离状态,使用分离实例的身份并实现相等方法。
11 事务和并发
本章涵盖
-
定义数据库和系统事务基础
-
使用 Hibernate 和 JPA 控制并发访问
-
使用非事务性数据访问
-
使用 Spring 和 Spring Data 管理事务
在本章中,我们最终将讨论事务:如何在应用程序中创建和控制并发作业单元。一个作业单元是一组原子的操作,事务允许我们设置作业单元的边界,并帮助我们隔离一个作业单元与另一个作业单元。在多用户应用程序中,我们可能也会并发地处理这些作业单元。
为了处理并发,我们首先将关注最低级别的作业单元:数据库和系统事务。你将学习事务界定和如何在 Java 代码中定义作业单元的 API。我们将演示如何使用悲观和乐观策略来保持隔离和控制并发访问。系统的整体架构影响事务的范围;糟糕的架构可能导致脆弱的事务。
然后我们将基于不使用显式事务访问数据库分析一些特殊情况以及 JPA 功能。最后,我们将演示如何使用 Spring 和 Spring Data 处理事务。
让我们从一些背景信息开始。
JPA 2 中的主要新功能
对于悲观锁定,有新的锁定模式和异常:
-
你可以在
Query上设置锁定模式,悲观或乐观。 -
你可以在调用
EntityManager#find()、refresh()或lock()时设置锁定模式。对于悲观锁定模式,也标准化了锁定超时提示。
当抛出新的QueryTimeoutException或LockTimeoutException时,事务不需要回滚。
持久化上下文现在可以处于一个未同步模式,并且禁用了自动刷新。这允许我们将修改排队,直到我们加入一个事务,并将EntityManager的使用与事务解耦。
11.1 事务基础
应用功能要求一次性完成几件事情。例如,当拍卖结束时,CaveatEmptor 应用程序必须执行三个不同的任务:
-
找到拍卖物品的最高出价(最高金额)。
-
向物品的卖家收取拍卖费用。
-
通知卖家和成功的竞标者。
如果由于外部信用卡系统故障而无法收取拍卖费用,会发生什么?业务需求可能声明所有列出的操作必须成功,或者没有任何操作必须成功。如果是这样,我们将这些步骤统称为一个事务或作业单元。如果只有单个步骤失败,整个作业单元必须失败。
11.1.1 ACID 属性
ACID代表原子性、一致性、隔离性、持久性。原子性是指事务中的所有操作作为一个原子单元执行。此外,事务允许多个用户同时使用相同的数据,而不会损害数据的一致性(与数据库完整性规则一致)。特定的事务不应对其他并发运行的事务可见;它们应在隔离下运行。事务中做出的更改应该是持久的,即使系统在事务成功完成后失败。
此外,我们希望事务的正确性。例如,业务规则规定应用程序只向卖家收费一次,而不是两次。这是一个合理的假设,但我们可能无法用数据库约束来表示它。因此,事务的正确性是应用程序的责任,而一致性是数据库的责任。这些事务属性共同定义了ACID标准。
11.1.2 数据库和系统事务
我们还提到了系统和数据库事务。再次考虑最后一个例子:在结束拍卖的工作单元中,我们可能在数据库系统中标记出价。然后,在同一个工作单元中,我们与外部系统通信以向卖家的信用卡收费。这是一个跨越几个系统的交易,涉及在可能多个资源上的协调从属事务,例如数据库连接和外部计费处理器。本章重点介绍跨越一个系统和一个数据库的事务。
数据库事务必须简短,因为打开的事务消耗数据库资源,并可能由于对数据的排他性锁定而阻止并发访问。单个数据库事务通常只涉及单个数据库操作批次。
要在系统事务中执行所有数据库操作,我们必须设置这个工作单元的边界。我们必须开始事务,并在某个时刻提交更改。如果在执行数据库操作或提交事务时发生错误,我们必须回滚更改以保持数据的一致性。这个过程定义了事务边界,并且根据我们使用的技巧,涉及在代码中手动定义事务边界。通常,开始和结束事务的事务边界可以设置为在应用程序代码中程序化或声明化。我们将演示这两种方法,在处理 Spring 和 Spring Data 时,我们将重点关注声明化事务。
注意:本章中的所有示例都在任何 Java SE 环境中工作,无需特殊的运行时容器。因此,从现在起,您将看到程序化事务边界代码,直到我们转向特定的 Spring 应用程序示例。
接下来,我们将关注 ACID 属性中最复杂的一个方面:如何将并发运行的作业单位彼此隔离。
11.2 控制并发访问
数据库(以及其他事务性系统)试图确保事务隔离性,这意味着从每个并发事务的角度来看,似乎没有其他事务正在进行。传统上,数据库系统通过锁定来实现隔离。一个事务可以在数据库中的特定数据项上放置一个锁,暂时阻止其他事务对该项的读取和/或写入访问。一些现代数据库引擎使用多版本并发控制(MVCC)来实现事务隔离,供应商通常认为这更具有可伸缩性。我们将基于锁定模型分析隔离,但我们的大多数观察结果也适用于 MVCC。
数据库如何实现并发控制对于 Java 持久性应用至关重要。应用程序可能继承数据库管理系统提供的隔离保证,但框架可能在这些之上,允许你以资源无关的方式启动、提交和回滚事务。如果你考虑到数据库供应商在实现并发控制方面多年的经验,你会看到这种方法的优点。此外,Java 持久性中的某些特性可以提高隔离保证,超过数据库提供的保证,无论是你明确使用这些特性还是由于设计。
我们将分几个步骤讨论并发控制。首先,我们将探索最底层:数据库提供的交易隔离保证。之后,你将看到 Java 持久性在应用层面的悲观和乐观并发控制特性,以及 Hibernate 可以提供的其他隔离保证。
11.2.1 理解数据库级别的并发
当我们谈论隔离时,你可以假设两个事务要么是隔离的,要么不是。当我们谈论数据库事务时,完全隔离的代价很高。你无法停止整个世界来专门访问多用户在线事务处理(OLTP)系统中的数据。因此,有几种隔离级别可供选择,这些级别自然地削弱了完全隔离,但增加了系统的性能和可伸缩性。
事务隔离问题
首先,让我们检查在削弱完全事务隔离时可能出现的几个问题。ANSI SQL 标准根据哪些现象是可允许的来定义标准的事务隔离级别。
当两个并发事务同时更新数据库中的相同信息时,会发生丢失更新。第一个事务读取一个值。第二个事务在第一个事务之后不久开始读取相同的值。第一个事务更改并写入更新后的值,而第二个事务用其自己的更新覆盖该值。因此,第一个事务的更新丢失了,被第二个事务覆盖。最后提交的胜出。这发生在未实现并发控制、并发事务未隔离的系统中的系统。这如图 11.1 所示。buyNowPrice字段由两个事务更新,但只有一次更新发生,另一次更新丢失了。

图 11.1 丢失更新:两个事务在未隔离的情况下更新相同的数据。
如果事务 2 读取了事务 1 所做的更改,而这些更改尚未提交,则发生脏读。这是危险的,因为事务 1 所做的更改可能会稍后回滚,而事务 2 将读取无效的数据。这如图 11.2 所示。

图 11.2 脏读:事务 2 从事务 1 读取未提交的数据。
如果一个事务在两次读取数据项时读取到不同的状态,则发生不可重复读。例如,另一个事务可能在两次读取之间向数据项写入并提交,如图 11.3 所示。

图 11.3 不可重复读:在事务 1 执行过程中,最高出价发生了变化。
当一个事务执行两次查询,第二次查询的结果包括第一次查询中不可见的数据(因为添加了某些内容),或者包含更少的数据(因为删除了某些内容)时,就称发生了幻读。这不必一定是完全相同的查询。另一个事务在两次查询执行之间插入或删除数据会导致这种情况,如图 11.4 所示。

图 11.4 幻读:事务 1 在第二次查询中读取了新数据。
现在既然你已经了解了可能发生的所有不良情况,我们可以定义事务隔离级别并查看它们可以防止哪些问题。
ANSI 隔离级别
标准的隔离级别由 ANSI SQL 标准定义,但它们并不特定于 SQL 数据库。Spring 定义了完全相同的隔离级别,我们将使用这些级别来声明所需的交易隔离级别。随着隔离级别的提高,成本会更高,性能和可扩展性会严重下降:
-
未提交读隔离—不允许丢失更新的系统在未提交读隔离下运行。如果一个未提交的事务已经写入了一行,则一个事务可能无法写入该行。然而,任何事务都可以读取任何行。DBMS 可以使用独占写锁来实现此隔离级别。
-
可重复读隔离—在可重复读隔离模式下运行的系统不允许丢失更新、脏读或不可重复读。可能会发生幻读。读事务不会阻止其他事务访问行,但未提交的写事务会阻止所有其他事务访问该行。
-
可重复读隔离—在可重复读隔离模式下运行的系统不允许丢失更新、脏读或不可重复读。可能会发生幻读。读事务会阻止写事务,但不会阻止其他读事务,而写事务会阻止所有其他事务。
-
可串行化隔离—这是最严格的隔离级别,可串行化,模拟串行执行,就像事务一个接一个地执行,而不是并发执行。数据库管理系统(DBMS)可能无法仅使用行级锁来实现可串行化隔离。相反,DBMS 必须提供一些其他机制,以防止新插入的行对已执行查询并返回该行的交易可见。一种简单的机制是在写入后独占锁定整个数据库表,以防止发生幻读。
表 11.1 总结了 ANSI 隔离级别及其解决的问题。
表 11.1 ANSI 隔离级别及其解决的问题
| 隔离级别 | 幻读 | 不可重复读 | 脏读 | 丢失更新 |
|---|---|---|---|---|
READ_UNCOMMITTED |
– | – | – | + |
READ_COMMITTED |
– | – | + | + |
REPEATABLE_READ |
– | + | + | + |
SERIALIZABLE |
+ | + | + | + |
DBMS 如何实现其锁定系统各不相同;每个供应商都有不同的策略。你应该研究你的 DBMS 文档,以了解更多关于其锁定系统、锁如何升级(例如,从行级到页面到整个表)以及每个隔离级别对系统性能和可扩展性的影响。
了解所有这些技术术语的定义固然很好,但这对我们如何为应用程序选择隔离级别有何帮助?
选择隔离级别
开发者(包括我们自己)在生产应用程序中经常不确定使用哪种事务隔离级别。过高的隔离级别会损害高度并发应用程序的可扩展性。隔离不足可能导致微妙且难以复现的 bug,我们直到系统在高负载下运行时才会发现这些问题。
注意,在以下解释中,我们将提到乐观锁(带有版本控制),这是一个在本章后面部分分析的概念。当需要为您的应用选择隔离级别时,您可能需要回顾这一部分。毕竟,选择正确的隔离级别高度依赖于特定场景。以下讨论应被视为建议,而不是刻在石头上的教条。
Hibernate 尽力使数据库的事务语义尽可能透明。尽管如此,持久化上下文缓存和版本控制会影响这些语义。在 JPA 应用中选择一个合理的数据库隔离级别是什么?
首先,对于几乎所有场景,都应该消除读取未提交的隔离级别。允许一个事务未提交的更改被另一个事务使用是非常危险的。一个事务的回滚或失败将影响其他并发事务。第一个事务的回滚可能会将其他事务一同拉下,或者甚至可能使它们处于不正确的数据库状态(例如,拍卖物品的卖家可能会被收取两次费用——这与数据库完整性规则一致,但却是错误的)。可能的情况是,最终被回滚的事务所做的更改无论如何都会被提交,因为它们可能被另一个成功的事务读取并传播!你可以为了调试目的使用读取未提交的隔离级别,以跟踪长插入查询的执行,对聚合函数(如SUM(*)或COUNT(*))做一些粗略估计。
其次,大多数应用不需要可序列化的隔离级别。幻读通常不会造成问题,而这个隔离级别往往扩展性不佳。现有的应用中很少在生产环境中使用可序列化隔离级别,而是依赖于在特定情况下有效强制序列化操作的选择性悲观锁。
接下来,让我们考虑可重复读。这个级别在数据库事务持续期间为查询结果集提供可重复性。这意味着如果我们多次查询数据库,我们不会读取已提交的更新,但幻读仍然可能发生:新行可能会出现,而我们认为存在的行可能会消失,如果另一个事务同时提交更改。尽管我们有时可能想要可重复读,但我们通常不需要在每个事务中都使用它们。
JPA 规范假定读取提交是默认的隔离级别。这意味着我们必须处理不可重复读和幻读。
假设我们正在启用对领域模型实体的版本控制,这是 Hibernate 可以为我们自动完成的。持久化上下文缓存和版本控制的组合已经为我们提供了可重复读隔离的大部分优点。持久化上下文缓存确保了一个事务加载的实体实例的状态与其他事务所做的更改相隔离。如果我们在一个工作单元中两次检索相同的实体实例,第二次查找将在持久化上下文缓存中解决,而不会击中数据库。因此,我们的读取是可重复的,我们不会看到冲突的已提交数据。(尽管如此,我们仍然可能遇到幻读,但这些通常更容易处理。)此外,版本控制切换到“先提交者胜出”。因此,对于几乎所有多用户 JPA 应用程序,启用实体版本控制后,所有数据库事务的“读取提交”隔离级别是可以接受的。
Hibernate 保留数据库连接的隔离级别;它不会改变级别。尽管大多数产品默认为读取提交隔离级别,但 MySQL 默认为可重复读。我们可以通过几种方式更改默认事务隔离级别或当前事务的设置。
首先,我们可以检查 DBMS 是否在其专有配置中有一个全局事务隔离级别设置。如果 DBMS 支持标准的 SQL 语句SET SESSION CHARACTERISTICS,我们可以执行它来设置在此特定数据库会话(这意味着数据库的特定连接,而不是 Hibernate 的Session)中启动的所有事务的设置。(这意味着数据库的特定连接,而不是 Hibernate 的Session)。SQL 还标准化了SET TRANSACTION语法,该语法设置当前事务的隔离级别。最后,JDBC Connection API 提供了setTransactionIsolation()方法,根据其文档,“尝试更改此连接的事务隔离级别。”在 Hibernate/JPA 应用程序中,我们可以从本地的Session API 获取 JDBC Connection。
通常,数据库连接默认处于读取提交隔离级别。有时,应用程序中的特定工作单元可能需要不同的、通常更严格的隔离级别。我们不应该改变整个事务的隔离级别,而应该使用 Jakarta Persistence API 在相关数据上获取额外的锁。这种细粒度锁定在高度并发的应用程序中更具可伸缩性。JPA 提供了乐观版本检查和数据库级别的悲观锁定。
11.2.2 乐观并发控制
当并发修改很少且在单元工作后期检测冲突可行时,以乐观方式处理并发是合适的。JPA 提供自动版本检查作为乐观冲突检测过程。
前面的章节可能有些枯燥;现在是时候看看一些代码了。首先,我们将启用版本控制,因为默认情况下它是关闭的。大多数多用户应用程序,尤其是 Web 应用程序,应该为任何并发修改的@Entity实例依赖版本控制,以实现更用户友好的先提交者胜出。
在启用自动版本检查后,我们将了解手动版本检查是如何工作的,以及何时需要使用它。
注意:要能够从源代码执行示例,您首先需要运行 Ch11.sql 脚本。
启用版本控制
我们可以通过在实体类的特殊额外属性上使用@Version注解来启用版本控制,如下所示。
列表 11.1 在映射实体上启用版本控制
Path: Ch11/transactions/src/main/java/com/manning/javapersistence/ch11
➥ /concurrency/Item.java
@Entity
public class Item {
@Version
private long version;
// . . .
}
在这个例子中,每个实体实例都携带一个数字版本。它映射到ITEM数据库表的额外列中;通常,列名默认为属性名,这里为VERSION。属性和列的实际名称并不重要——如果我们认为VERSION是数据库管理系统中的保留关键字,我们可以将其重命名。
我们可以在类中添加一个getVersion()方法,但不应该有 setter 方法,并且应用程序不应该修改该值。Hibernate 会自动更改版本值:在持久化上下文刷新期间,每当发现Item实例被标记为脏时,它就会增加版本号。版本是一个简单的计数器,除了并发控制之外没有其他有用的语义值。我们可以使用int、Integer、short、Short或Long来代替long;如果版本号达到数据类型的限制,Hibernate 会将其包装并从零开始。
在刷新期间增加检测到的脏Item实例的版本号后,Hibernate 在执行UPDATE和DELETE SQL 语句时比较版本。例如,假设在一个工作单元中我们加载一个Item并更改其名称,如下所示。
列表 11.2 Hibernate 自动增加和检查版本
Path: /Ch11/transactions/src/test/java/com/manning/javapersistence/ch11
➥ /concurrency/Versioning.java – firstCommitWins()
EntityManager em1 = emf.createEntityManager();
em1.getTransaction().begin();
Item item = em1.find(Item.class, ITEM_ID); Ⓐ
// select * from ITEM where ID = ?
assertEquals(0, item.getVersion()); Ⓑ
item.setName("New Name");
// . . . Another transaction changes the record
*assertThrows*(OptimisticLockException.class, () -> em1.flush()); Ⓒ
// update ITEM set NAME = ?, VERSION = 1 where ID = ? and VERSION = 0
Ⓐ 通过标识符检索实体实例将使用SELECT从数据库中加载当前版本。
Ⓑ Item实例的当前版本是0。
Ⓒ 当持久化上下文刷新时,Hibernate 会检测到脏的Item实例并增加其版本到1。SQL UPDATE现在执行版本检查,将新版本存储在数据库中,但只有当数据库版本仍然是0时。
注意到 SQL 语句,特别是UPDATE及其WHERE子句。此更新只有在数据库中存在VERSION = 0的行时才会成功。JDBC 将更新的行数返回给 Hibernate;如果该结果为零,则意味着ITEM行已不存在或不再具有版本0。Hibernate 在刷新期间检测到这种冲突,并抛出javax.persistence.OptimisticLockException异常。
现在想象有两个用户同时执行这个工作单元,如图 11.1 所示。第一个提交的用户将更新Item的名称并将增加的版本1刷新到数据库中。第二个用户的刷新(和提交)将失败,因为他们的UPDATE语句在数据库中找不到版本为0的行。数据库版本是1。因此,第一个提交获胜,我们可以捕获OptimisticLockException并专门处理它。例如,我们可以向第二个用户显示以下消息:“你正在处理的数据已被其他人修改。请使用新鲜数据重新开始你的工作单元。点击重启按钮继续。”
哪些修改会触发实体版本的递增?Hibernate 在实体实例脏时递增版本。这包括实体所有脏的值类型属性,无论它们是单值(如String或int属性),嵌入的(如Address),还是集合。例外的是使用mappedBy标记为只读的@OneToMany和@ManyToMany关联集合。在这些集合中添加或删除元素不会增加拥有实体实例的版本号。你应该知道,JPA 中没有任何标准化的内容——不要依赖于两个 JPA 提供者在访问共享数据库时实施相同的规则。
如果我们不希望在特定属性的值发生变化时增加实体实例的版本,我们可以使用@org.hibernate.annotations.OptimisticLock(excluded = true)注解该属性。
你可能不喜欢数据库模式中额外的VERSION列。或者,你已经在实体类上有一个“最后更新”的时间戳属性以及匹配的数据库列。Hibernate 可以使用时间戳而不是额外的计数器字段来检查版本。
使用共享数据库进行版本控制
如果多个应用程序访问数据库,并且它们并不都使用 Hibernate 的版本控制算法,我们将遇到并发问题。一个简单的解决方案是使用数据库级别的触发器和存储过程:一个INSTEAD OF触发器可以在任何UPDATE操作时执行一个存储过程;它将代替更新操作。在存储过程中,我们可以检查应用程序是否增加了行的版本;如果版本没有更新或者版本列没有被包含在更新中,我们知道这个语句不是由 Hibernate 应用程序发送的。然后我们可以在应用UPDATE之前在存储过程中增加版本。
使用时间戳进行版本控制
如果数据库模式已经包含一个时间戳列,如LASTUPDATED或MODIFIED_ON,我们可以将其映射以进行自动版本检查,而不是使用额外的计数器字段。
列表 11.3 启用时间戳版本控制
Path: Ch11/transactions2/src/main/java/com/manning/javapersistence/ch11
➥ /timestamp/Item.java
@Entity
public class Item {
@Version
// Optional: @org.hibernate.annotations.Type(type = "dbtimestamp")
private LocalDateTime lastUpdated;
// . . .
}
此示例将LASTUPDATED列映射到java.time.LocalDateTime属性;Date或Calendar类型也可以与 Hibernate 一起使用。JPA 标准没有为版本属性定义这些类型;JPA 仅考虑java.sql.Timestamp是可移植的。这不太吸引人,因为我们不得不在域模型中导入那个 JDBC 类。我们应该尽量将像 JDBC 这样的实现细节从域模型类中排除,以便它们可以在尽可能多的环境中进行测试、实例化、序列化和反序列化。
理论上,使用时间戳进行版本控制稍微不太安全,因为两个并发事务可能在同一毫秒内同时加载和更新相同的Item;这一点由于 JVM 通常没有毫秒级的精度(您应该检查您的 JVM 和操作系统文档以获取保证的精度)而加剧。此外,在集群环境中从 JVM 检索当前时间并不一定安全,因为节点的时间可能没有同步,或者时间同步的精度不如您所需的交易负载精度。
您可以通过在版本属性上放置一个@org.hibernate.annotations.Type(type="dbtimestamp")注解来切换为从数据库机器检索当前时间。Hibernate 现在在更新之前会向数据库请求当前时间,这为同步提供了一个单一的时间源。并非所有 Hibernate SQL 方言都支持此功能,因此请检查配置方言的来源。此外,每次增量都会产生访问数据库的开销。
我们建议新项目依赖数字计数器的版本控制,而不是时间戳。如果您正在使用遗留数据库模式或现有的 Java 类,可能无法引入版本或时间戳属性和列。如果是这种情况,Hibernate 提供了一个替代策略。
无版本号或时间戳的版本控制
如果您没有版本或时间戳列,Hibernate 仍然可以执行自动版本控制。这种版本控制的替代实现将当前数据库状态与 Hibernate 检索实体实例(或持久化上下文最后刷新)时的持久属性未修改的值进行比较。
您可以使用专有的 Hibernate 注解@org.hibernate.annotations.OptimisticLocking来启用此功能:
Path: Ch11/transactions3/src/main/java/com/manning/javapersistence/ch11
➥ /versionall/Item.java
@Entity
@org.hibernate.annotations.OptimisticLocking(
type = org.hibernate.annotations.OptimisticLockType.ALL)
@org.hibernate.annotations.DynamicUpdate
public class Item {
// . . .
}
对于这个策略,您还必须启用UPDATE语句的动态 SQL 生成,使用@org.hibernate.annotations.DynamicUpdate,如第 5.3.2 节所述。
Hibernate 现在执行以下 SQL 来刷新一个Item实例的修改:
update ITEM set NAME = 'New Name'
where ID = 123
and NAME = 'Old Name'
and PRICE = '9.99'
and DESCRIPTION = 'Some item for auction'
and ...
and SELLER_ID = 45
Hibernate 在WHERE子句中列出所有列及其最后已知值。如果任何并发事务修改了这些值中的任何一个,或者甚至删除了该行,则此语句将返回零行更新。然后 Hibernate 在刷新时抛出异常。
或者,如果你切换到OptimisticLockType.DIRTY,Hibernate 只包括修改过的属性在限制条件中(在这个例子中是NAME)。这意味着两个工作单元可以并发修改同一个Item,而 Hibernate 只有在它们都修改了相同值类型的属性(或外键值)时才会检测到冲突。最后一条 SQL 摘录的WHERE子句将简化为where ID = 123 and NAME = 'Old Name'。其他人可以并发修改价格,Hibernate 不会检测到任何冲突。只有当应用程序并发修改名称时,我们才会得到javax.persistence.OptimisticLockException。
在大多数情况下,仅检查脏属性并不是业务实体的好策略。如果描述发生变化,改变商品的价格可能是不合适的!这种策略也不适用于分离实体和合并:如果我们把分离的实体合并到一个新的持久化上下文中,我们不知道“旧”值。分离的实体实例将不得不携带一个版本号或时间戳以进行乐观并发控制。
Java 持久性中的自动版本控制可以防止两个并发事务尝试对同一份数据提交修改时丢失更新。版本控制还可以在我们需要时帮助我们手动获得额外的隔离保证。
手动版本检查
这里有一个需要可重复数据库读取的场景:想象一下,拍卖系统中有一些类别,每个Item都位于一个Category中。这是Item#category实体关联的常规@ManyToOne映射。
假设你想要汇总几个类别中所有商品的价格。这需要查询每个类别中的所有商品以累加价格。问题是,如果你在查询和遍历所有类别和商品时有人将一个Item从一个Category移动到另一个Category,会发生什么?在有提交读隔离的情况下,同一个Item可能在你的程序运行期间出现两次!
要使“获取每个类别的商品”读取可重复,JPA 的Query接口有一个setLockMode()方法。查看以下列表中的过程。
列表 11.4 在刷新时请求版本检查以确保可重复读取
Path: /Ch11/transactions/src/test/java/com/manning/javapersistence/ch11
➥ /concurrency/Versioning.java – manualVersionChecking()
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
BigDecimal totalPrice = BigDecimal.ZERO;
for (Long categoryId : CATEGORIES) {
List<Item> items = Ⓐ
em.createQuery("select i from Item i where i.category.id = :catId",
➥ Item.class)
.setLockMode(LockModeType.OPTIMISTIC)
.setParameter("catId", categoryId)
.getResultList();
for (Item item : items)
totalPrice = totalPrice.add(item.getBuyNowPrice());
}
em.getTransaction().commit(); Ⓑ
em.close();
assertEquals("108.00", totalPrice.toString());
Ⓐ 对于每个Category,以OPTIMISTIC锁定模式查询所有Item实例。Hibernate 现在知道它必须在刷新时检查每个Item。
Ⓑ 对于之前通过锁定查询加载的每个Item,Hibernate 在刷新期间执行一个SELECT。它检查每个ITEM行的数据库版本是否仍然与加载时相同。如果任何ITEM行版本不同或行不再存在,将抛出OptimisticLockException。
不要被锁定术语所迷惑:JPA 规范没有明确说明每个LockModeType是如何实现的。对于OPTIMISTIC,Hibernate 执行版本检查;实际上没有涉及任何锁。我们必须像前面解释的那样在Item实体类上启用版本控制;否则,我们无法使用 Hibernate 的乐观LockModeTypes。
Hibernate 不会对手动版本检查的SELECT语句进行批处理或优化;如果我们汇总 100 个项目,在刷新时我们会得到 100 个额外的查询。正如我们在本章后面将要展示的,一种悲观的策略可能在这个特定情况下是一个更好的解决方案。
为什么持久化上下文缓存不能防止并发修改问题?
“获取特定类别中的所有项目”查询返回一个ResultSet中的项目数据。然后 Hibernate 查看这些数据中的主键值,并首先尝试在持久化上下文缓存中解决每个Item的其余详细信息——它检查是否已经使用该标识符加载了Item实例。
然而,这个缓存在这个示例过程中并没有帮助:如果一个并发事务将项目移动到另一个类别,该项目可能会在不同的ResultSets 中多次返回。Hibernate 将执行其持久化上下文查找并说,“哦,我已经加载了那个Item实例;让我们使用我们已经在内存中的内容。”Hibernate 甚至没有意识到分配给项目的类别已经改变,或者项目再次出现在不同的结果中。
因此,这是一个持久化上下文的可重复读特性隐藏了并发提交的数据的案例。我们需要手动检查版本号来确定在我们预期数据不会改变的时候,数据是否已经发生了变化。
如前例所示,Query接口接受一个LockModeType。TypedQuery和NamedQuery接口也支持显式锁定模式,使用相同的setLockMode()方法。
JPA 中有一个额外的乐观锁定模式,可以强制实体版本的递增。
强制版本递增
如果两个用户同时为同一拍卖物品出价会发生什么?当用户提交新的出价时,应用程序必须做两件事:
-
从数据库中检索
Item的当前最高Bid。 -
将新的
Bid与最高Bid进行比较;如果新的Bid更高,它必须存储在数据库中。
在这两个步骤之间可能存在竞争条件。如果在读取最高Bid和放置新的Bid之间,另一个Bid被提交,你将看不到它。这种冲突是不可见的,即使启用了Item的版本控制,也无法帮助。在程序过程中,Item永远不会被修改。然而,强制Item的版本递增可以使冲突变得可检测。
列表 11.5 强制实体实例的版本递增
Path: /Ch11/transactions/src/test/java/com/manning/javapersistence/ch11
➥ /concurrency/Versioning.java – forceIncrement()
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Item item = em.find( Ⓐ
Item.class,
ITEM_ID,
LockModeType.OPTIMISTIC_FORCE_INCREMENT
);
Bid highestBid = queryHighestBid(em, item);
// . . . Another transaction changes the record
Bid newBid = new Bid(
new BigDecimal("45.45"),
item,
highestBid);
em.persist(newBid); Ⓑ
assertThrows(RollbackException.class,
() -> em.getTransaction().commit()); Ⓒ
em.close();
Ⓐ find() 接受一个 LockModeType。OPTIMISTIC_FORCE_INCREMENT 模式告诉 Hibernate,在加载检索到的 Item 后,即使它在工作单元中从未被修改,也应增加其版本。
Ⓑ 代码持久化了一个新的 Bid 实例;这不会影响 Item 实例的任何值。在 BID 表中插入了一行新记录。如果没有强制版本增加的 Item,Hibernate 不会检测到并发提交的出价。
Ⓒ 当刷新持久化上下文时,Hibernate 执行一个 INSERT 以持久化新的 Bid,并强制执行带有版本检查的 Item 的 UPDATE。如果有人在此过程中并发修改了 Item 或与此过程同时提交了一个 Bid,Hibernate 将抛出异常。
对于拍卖系统,并发提交出价当然是一个频繁的操作。在许多情况下,当我们插入或修改数据并希望聚合的某些根实例的版本增加时,手动增加版本是有用的。
注意,如果我们没有使用 Bid#item 实体关联的 @ManyToOne,而是有一个 @ElementCollection 的 Item#bids,向集合中添加一个 Bid 将会增加 Item 的版本。强制增加在这种情况下是不必要的。你可能想回顾第 8.3 节中关于父/子歧义以及聚合和 ORM 一起工作的讨论。
到目前为止,我们一直关注乐观并发控制:我们预计并发修改很少,因此我们不阻止并发访问,并在晚些时候检测冲突。然而,有时我们知道冲突将频繁发生,并且我们希望对某些数据放置排他锁。这需要一种悲观的方法。
11.2.3 显式悲观锁定
让我们重复上一节“手动版本检查”中演示的程序,但这次使用悲观锁定而不是乐观版本检查。我们再次总结几个类别中所有项目的总价。这是与前面列表 11.5 中显示的相同代码,但 LockModeType 不同。
列表 11.6 悲观锁定数据
Path: /Ch11/transactions/src/test/java/com/manning/javapersistence/ch11
➥ /concurrency/Locking.java – pessimisticReadWrite()
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
BigDecimal totalPrice = BigDecimal.ZERO;
for (Long categoryId : CATEGORIES) {
List<Item> items = Ⓐ
em.createQuery("select i from Item i where i.category.id = :catId",
➥ Item.class)
.setLockMode(LockModeType.PESSIMISTIC_READ)
.setHint("javax.persistence.lock.timeout", 5000)
.setParameter("catId", categoryId)
.getResultList();
for (Item item : items) Ⓑ
totalPrice = totalPrice.add(item.getBuyNowPrice());
// . . . Another transaction tries to obtain a lock and fails
}
em.getTransaction().commit(); Ⓒ
em.close();
assertEquals(0, totalPrice.compareTo(new BigDecimal("108")));
Ⓐ 对于每个 Category,以 PESSIMISTIC_READ 锁定模式查询所有 Item 实例。Hibernate 使用 SQL 查询锁定数据库中的行。如果另一个事务持有冲突锁,如果可能,等待 5 秒。如果无法获得锁,查询将抛出异常。
Ⓑ 如果查询成功返回,我们知道我们持有对数据的排他锁,并且没有其他事务可以以排他锁访问它或修改它,直到此事务提交。
Ⓒ 事务完成后提交时,锁被释放。
JPA 规范定义了 PESSIMISTIC_READ 锁定模式保证可重复读。JPA 还标准化了 PESSIMISTIC_WRITE 模式,并提供了额外的保证:除了可重复读之外,JPA 提供商必须序列化数据访问,并且不能发生幻读。
实现这些要求取决于 JPA 提供者。对于这两种模式,Hibernate 在加载数据时将FOR UPDATE子句附加到 SQL 查询。这将在数据库级别对行进行锁定。Hibernate 使用的锁类型取决于LockModeType和 Hibernate 数据库方言:
-
在 H2 数据库中,查询语句为
SELECT * FROM ITEM ... FOR UPDATE。因为 H2 数据库只支持一种类型的排他锁,所以 Hibernate 为所有悲观模式生成相同的 SQL 语句。 -
另一方面,PostgreSQL 支持共享读锁:
PESSIMISTIC_READ模式将FOR SHARE附加到 SQL 查询。PESSIMISTIC_WRITE使用带有FOR UPDATE的排他写锁。 -
在 MySQL 中,
PESSIMISTIC_READ转换为LOCK IN SHARE MODE,而PESSIMISTIC_WRITE转换为FOR UPDATE。
检查你的数据库方言。锁是通过getReadLockString()和getWriteLockString()方法配置的。
JPA 中悲观锁的持续时间是一个单独的数据库事务。这意味着我们无法使用排他锁来阻塞并发访问超过单个数据库事务的时间。当数据库锁无法获取时,会抛出异常。
将此与乐观方法进行比较,Hibernate 在提交时而不是在查询时抛出异常。在悲观策略中,我们知道一旦锁定查询成功,我们就可以安全地读取和写入数据。在乐观方法中,我们寄希望于最好的结果,但可能在稍后提交时感到惊讶。
离线锁
悲观数据库锁仅保留在一个事务中。其他锁实现是可能的:例如,内存中的锁,或数据库中的所谓锁表。这类锁的常见名称是离线锁。
悲观锁超过单个数据库事务通常会导致性能瓶颈:每次数据访问都涉及对全局同步锁管理器的额外锁检查。然而,乐观锁是长运行会话(如你将在下一章中看到的)的完美并发控制策略,并且性能良好。根据冲突解决策略——它决定了检测到冲突后会发生什么——应用程序的用户可能会像喜欢阻塞并发访问一样喜欢乐观锁。他们也可能欣赏应用程序在其他人查看相同数据时不会将他们锁定在特定屏幕之外。
我们可以通过javax.persistence.lock.timeout提示来配置数据库等待获取锁并阻塞查询的时间(以毫秒为单位)。与提示一样,Hibernate 可能会根据数据库产品忽略它。例如,H2 数据库不支持特定查询的锁超时,只支持连接的全局锁超时(默认为 1 秒)。在某些方言中,例如 PostgreSQL 和 Oracle,将0作为锁超时将NOWAIT子句附加到 SQL 字符串中。
我们已经演示了应用于 Query 的锁超时提示。我们也可以为 find() 操作设置超时提示:
Path: /Ch11/transactions/src/test/java/com/manning/javapersistence/ch11
➥ /concurrency/Locking.java – findLock()
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.lock.timeout", 5000);
Category category = Ⓐ
em.find(
Category.class,
CATEGORY_ID,
LockModeType.PESSIMISTIC_WRITE,
Hints
);
category.setName("New Name");
em.getTransaction().commit();
em.close();
Ⓐ 如果方言支持,执行 SELECT ... FOR UPDATE WAIT 5000。
当无法获取锁时,Hibernate 抛出 javax.persistence.LockTimeoutException 或 javax.persistence.PessimisticLockException。如果 Hibernate 抛出 PessimisticLockException,则必须回滚事务,并且工作单元结束。另一方面,超时异常对事务不是致命的。Hibernate 抛出哪种异常再次取决于 SQL 方言。例如,因为 H2 不支持每语句锁超时,所以我们总是得到 PessimisticLockException。
即使没有启用实体版本化,我们也可以使用 PESSIMISTIC_READ 和 PESSIMISTIC_WRITE 锁模式。它们转换为数据库级别的锁的 SQL 语句。
特殊的 PESSIMISTIC_FORCE_INCREMENT 模式需要版本化实体。在 Hibernate 中,此模式执行一个 FOR UPDATE NOWAIT 锁(或方言支持的任何内容;检查其 getForUpdateNowaitString() 实现)。然后,在查询返回后立即,Hibernate 增加版本并对每个返回的实体实例执行 UPDATE。这向任何并发事务表明我们已经更新了这些行,即使我们尚未修改任何数据。此模式很少有用,除了在本章前面讨论的“强制版本增量”部分中讨论的聚合锁定之外。
那么 READ 和 WRITE 锁模式呢?
这些是来自 JPA 1.0 的旧锁模式,你应该不再使用它们。LockModeType.READ 等同于 OPTIMISTIC,而 LockModeType.WRITE 等同于 OPTIMISTIC_FORCE_INCREMENT。
如果我们启用悲观锁,Hibernate 只锁定与实体实例状态相对应的行。换句话说,如果我们锁定一个 Item 实例,Hibernate 将锁定 ITEM 表中的其行。如果我们有一个联合继承映射策略,Hibernate 将识别这一点并锁定超表和子表中的适当行。这也适用于实体的任何二级表映射。因为 Hibernate 锁定整个行,所以任何外键在该行中的关系也将被有效地锁定:如果 SELLER_ID 外键列在 ITEM 表中,则 Item#seller 关联将被锁定,但实际的 Seller 实例不会被锁定!Item 的集合或其他关联也不会被锁定,其中外键在其他表中。
扩展锁作用域
JPA 2.0 定义了 PessimisticLockScope.EXTENDED 选项。它可以作为一个查询提示通过 javax.persistence.lock.scope 设置。如果启用,持久化引擎将锁定数据的范围扩展到包括被锁定实体集合和关联连接表中的任何数据。
在 DBMS 中使用排他锁时,你可能会遇到事务失败,因为你遇到了死锁情况。让我们看看如何避免这种情况。
11.2.4 避免死锁
如果 DBMS 依赖于排他锁来实现事务隔离,则可能会发生死锁。考虑以下工作单元,按特定顺序更新两个Item实体实例:
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Item itemOne = em.find(Item.class, ITEM_ONE_ID);
itemOne.setName("First new name");
Item itemTwo = em.find(Item.class, ITEM_TWO_ID);
itemTwo.setName("Second new name");
em.getTransaction().commit();
em.close();
当持久化上下文被刷新时,Hibernate 执行两个 SQL UPDATE语句。第一个UPDATE锁定代表Item一的行,第二个UPDATE锁定Item二:
update ITEM set ... where ID = 1; Ⓐ
update ITEM set ... where ID = 2; Ⓑ
Ⓐ 锁定行 1
Ⓑ 尝试锁定行 2
如果一个类似的过程,以相反的Item更新顺序执行,在并发事务中,可能会(或可能不会!)发生死锁:
update ITEM set ... where ID = 2; Ⓐ
update ITEM set ... where ID = 1; Ⓑ
Ⓐ 锁定行 2
Ⓑ 尝试锁定行 1
在死锁的情况下,两个事务都被阻塞,无法前进,每个都在等待锁被释放。死锁的可能性通常很小,但在高度并发的应用程序中,两个 Hibernate 应用程序可能会执行这种交错更新的操作。请注意,我们可能在测试期间看不到死锁(除非我们编写了正确的测试)。当应用程序在生产中必须处理高事务负载时,死锁可能会突然出现。通常,DBMS 在超时后终止其中一个死锁事务,该事务失败;然后另一个事务可以继续进行。或者,DBMS 可能会自动检测死锁情况并立即中止其中一个事务。
应该尽量避免事务失败,因为它们在应用程序代码中很难恢复。一个解决方案是在更新单行时以serializable模式运行数据库连接,这将锁定整个表。并发事务必须等待第一个事务完成其工作。或者,第一个事务可以在你SELECT数据时获取所有数据的排他锁,如前节所示。然后任何并发事务也必须等待这些锁被释放。
一种替代的实用优化可以显著降低死锁的概率,即按主键值对UPDATE语句进行排序:Hibernate 应始终在更新数据被应用程序加载和修改的顺序之前更新主键为1的行,无论数据以何种顺序加载和修改。您可以通过hibernate.order_updates配置属性为整个持久化单元启用此优化。Hibernate 随后按修改的实体实例和集合元素的主键值升序排序执行所有UPDATE语句。(如前所述,请确保您完全理解您的 DBMS 产品的事务和锁定行为。Hibernate 从 DBMS 继承了其大部分事务保证;例如,您的 MVCC 数据库产品可能避免读锁,但可能依赖于排他锁来实现写隔离,您可能会看到死锁。)
我们没有机会提到EntityManager#lock()方法。它接受一个已加载的持久化实体实例和一个锁定模式。它执行与find()和Query中看到的相同的锁定操作,只不过它不会加载实例。此外,如果版本化实体正在以悲观方式锁定,lock()方法将在数据库上立即执行版本检查,并可能抛出OptimisticLockException。如果数据库表示不再存在,Hibernate 将抛出EntityNotFoundException。最后,EntityManager#refresh()方法也接受一个锁定模式,具有相同的语义。
我们现在已经涵盖了最低级别的并发控制——数据库——以及 JPA 的乐观和悲观锁定特性。我们还有一个并发方面需要检查:在事务外访问数据。
11.3 非事务性数据访问
默认情况下,JDBC Connection处于自动提交模式。这种模式对于执行临时的 SQL 很有用。
想象一下,你使用 SQL 控制台连接到数据库,并运行几个查询,甚至更新和删除行。这种交互式数据访问是临时的;大多数时候,你没有计划或一系列你认为是一个工作单元的语句。数据库连接上的默认自动提交模式非常适合这种数据访问——毕竟,你不想为每个你编写和执行的 SQL 语句都输入begin transaction和end transaction。
在自动提交模式下,每个发送到数据库的 SQL 语句都会开始和结束一个(短)数据库事务。你实际上是在非事务模式下工作,因为你的会话与 SQL 控制台之间没有任何原子性或隔离保证。(唯一的保证是单个 SQL 语句是原子的。)
根据定义,应用程序总是执行一系列计划的语句。因此,你可以因此始终创建事务边界来将语句分组为原子且相互隔离的单位似乎是合理的。然而,在 JPA 中,与自动提交模式相关联的是特殊行为,你可能需要它来实现长时间运行的会话。你可以在自动提交模式下访问数据库并读取数据。
11.3.1 在自动提交模式下读取数据
考虑以下示例,它加载了一个Item实例,更改了其name,然后通过刷新撤销了该更改。
当我们创建EntityManager时,没有任何事务是活跃的。持久化上下文将处于一种特殊的未同步模式;Hibernate 不会自动刷新。你可以访问数据库来读取数据,此类操作执行一个SELECT,该操作在自动提交模式下发送到数据库。
通常,当你执行一个Query时,Hibernate 会刷新持久化上下文。如果上下文是非同步的,则不会发生刷新,查询将返回旧的、原始的数据库值。具有标量结果的查询是不可重复的:你将看到数据库中存在的任何值,以及 Hibernate 在ResultSet中接收到的值。即使在同步模式下,这也不是可重复读。
获取一个托管实体实例涉及在当前持久化上下文中进行查找,在 JDBC 结果集序列化期间。从持久化上下文中返回已加载的具有更改名称的实例;忽略数据库中的值。这是一个即使没有系统事务也能重复读取实体实例的情况。
如果你尝试手动刷新持久化上下文以存储新的Item#name,Hibernate 会抛出javax.persistence.TransactionRequiredException异常。在非同步模式下执行UPDATE是不允许的,因为你将无法回滚更改。
你可以使用refresh()方法回滚使用该方法所做的更改。它从数据库中加载当前的Item状态,并覆盖你在内存中做的更改。
列表 11.7 在自动提交模式下读取数据
Path: Ch11/transactions4/src/test/java/com/manning/javapersistence/ch11
➥ /concurrency/NonTransactional.java
EntityManager em = emf.createEntityManager(); Ⓐ
Item item = em.find(Item.class, ITEM_ID); Ⓑ
item.setName("New Name");
assertEquals( Ⓒ
"Original Name",
em.createQuery("select i.name from Item i where i.id = :id", String.class)
.setParameter("id", ITEM_ID).getSingleResult()
);
assertEquals( Ⓓ
"New Name",
em.createQuery("select i from Item i where i.id = :id". Item.class)
.setParameter("id", ITEM_ID).getSingleResult().getName()
);
// em.flush(); Ⓔ
em.refresh(item); Ⓕ
assertEquals("Original Name", item.getName());
em.close();
Ⓐ 在创建EntityManager时没有活动的事务。
Ⓑ 访问数据库以读取数据。
Ⓒ 因为上下文是非同步的,所以不会发生刷新,查询返回旧的、原始的数据库值。
Ⓓ 从持久化上下文中返回具有更改名称的已加载Item实例;忽略数据库中的值。
Ⓔ 你不能在非同步模式下执行UPDATE,因为你将无法回滚更改。
Ⓕ 回滚使用refresh()方法所做的更改。
在非同步持久化上下文中,你可以使用find()、getReference()、refresh()或查询在自动提交模式下读取数据。你也可以按需加载数据:如果你访问它们,代理将被初始化,如果你开始遍历它们的元素,集合将被加载。但是,如果你尝试使用除LockModeType.NONE之外的方式刷新持久化上下文或锁定数据,将会发生TransactionRequiredException异常。
到目前为止,自动提交模式似乎并不很有用。事实上,许多开发者经常出于错误的原因依赖自动提交:
-
许多小的每语句数据库事务(这就是自动提交的含义)不会提高应用程序的性能。
-
你不会提高应用程序的可伸缩性。你可能会认为,与每个 SQL 语句的许多小事务相比,一个运行时间较长的数据库事务可能会更长时间地持有数据库锁,但这是一个次要的担忧,因为 Hibernate 在事务中尽可能晚地写入数据库(在提交时刷新),因此数据库已经持有写锁一段时间了。
-
如果应用程序并发修改数据,自动提交提供较弱的隔离保证。在自动提交模式下,基于读取锁的重复读取是不可能的。(持久化上下文缓存在这里自然有所帮助。)
-
如果你的数据库管理系统有 MVCC(例如,Oracle 或 PostgreSQL),你可能会想使用其 快照隔离 功能来避免不可重复读取和幻读。每个事务都获得其自己的数据快照;你只能看到在事务开始之前的数据(数据库内部版本)。在自动提交模式下,快照隔离没有意义,因为没有事务范围。
-
如果你使用自动提交,你的代码将更难以理解。任何阅读你代码的人都将必须特别注意持久化上下文是否与事务关联,或者它是否处于未同步模式。如果你总是将操作分组在系统事务中,即使你只读取数据,每个人都可以遵循这个简单的规则,难以发现的并发问题的可能性就会降低。
那么,未同步持久化上下文有哪些好处?如果刷新不会自动发生,你可以在事务之外准备和 排队 修改。
11.3.2 排队修改
以下示例使用未同步的 EntityManager 存储一个新的 Item 实例。
你可以使用 persist() 在未同步的持久化上下文中保存一个临时实体实例。Hibernate 仅通过调用数据库序列来获取一个新的标识符值,并将其分配给实例。该实例将在上下文中处于持久状态,但 SQL INSERT 尚未发生。请注意,这仅适用于 预插入 标识符生成器;请参阅第 5.2.5 节。
当你准备好存储更改时,你必须使用事务将持久化上下文与事务关联。当事务提交时,通常会发生同步和刷新。Hibernate 将所有排队操作写入数据库。
Path: Ch11/transactions4/src/test/java/com/manning/javapersistence/ch11
➥ /concurrency/NonTransactional.java
EntityManager em = emf.createEntityManager();
Item newItem = new Item("New Item");
em.persist(newItem); Ⓐ
assertNotNull(newItem.getId());
em.getTransaction().begin(); Ⓑ
if (!em.isJoinedToTransaction()) {
em.joinTransaction();
}
em.getTransaction().commit(); Ⓒ
em.close();
Ⓐ 调用 persist() 以保存一个具有未同步持久化上下文的临时实体实例。
Ⓑ 使用事务将持久化上下文与事务关联。
Ⓒ 当事务提交时发生同步和刷新。
分离的实体实例的合并更改也可以排队:
Path: Ch11/transactions4/src/test/java/com/manning/javapersistence/ch11
➥ /concurrency/NonTransactional.java
detachedItem.setName("New Name");
EntityManager em = emf.createEntityManager();
Item mergedItem = em.merge(detachedItem); Ⓐ
em.getTransaction().begin();
em.joinTransaction();
em.getTransaction().commit(); Ⓑ
em.close();
Ⓐ 当你 merge() 时,Hibernate 会在自动提交模式下执行一个 SELECT。
Ⓑ Hibernate 将 UPDATE 延迟到关联事务提交。
排队也适用于实体实例的删除和 DELETE 操作:
Path: Ch11/transactions4/src/test/java/com/manning/javapersistence/ch11
➥ /concurrency/NonTransactional.java
EntityManager em = emf.createEntityManager();
Item item = em.find(Item.class, ITEM_ID);
em.remove(item);
em.getTransaction().begin();
em.joinTransaction();
em.getTransaction().commit();
em.close();
因此,未同步的持久化上下文允许你将持久化操作与事务解耦。能够在事务(和客户端/服务器请求)之外排队数据修改的能力是持久化上下文的主要特性之一。
Hibernate 的手动刷新模式
Hibernate 提供了 Session#setFlushMode() 方法,以及额外的 FlushMode.MANUAL。这是一个更方便的开关,它禁用了持久化上下文的任何自动刷新,即使在联合事务提交时也是如此。使用此模式,您必须显式调用 flush() 以与数据库同步。在 JPA 中,想法是“事务提交应始终写入任何未决更改”,因此读取与写入通过 非同步 模式分离。如果您不同意这一点或不想自动提交语句,请通过 Session API 启用手动刷新。然后,您可以为所有工作单元拥有常规的事务边界,包括可重复读甚至来自您的 MVCC 数据库的快照隔离,但仍然将更改排队在持久化上下文中以供稍后执行,并在事务提交之前手动 flush()。
11.4 使用 Spring 和 Spring Data 管理事务
我们现在将继续并演示如何使用 Spring 和 Spring Data 实现事务。Spring 使用的交易模型适用于不同的 API,例如 Hibernate、JPA 和 Spring Data JPA。事务的管理可以是程序性的(正如我们之前所演示的)或声明性的,通过注解(这是我们将在本章的这一部分主要使用的)。
Spring 事务抽象的关键由 org.springframework.transaction.PlatformTransactionManager 接口定义。
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(
➥ @Nullable TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}
通常,这个接口不会直接使用。您将要么通过注解声明性地标记事务,或者您最终可能使用 TransactionTemplate 进行程序性事务定义。
Spring 使用之前讨论的 ANSI 隔离级别。为了复习,请回顾 11.2.1 节,特别是表 11.1,它总结了隔离级别和它们解决的问题。
11.4.1 事务传播
Spring 处理事务传播问题。简而言之,如果 method-A 是事务性的并且它调用 method-B,那么从事务的角度来看,后者将如何表现?请看图 11.5:
-
bean-1包含事务性的method-A,它在事务TX1中执行。 -
method-A调用也是事务性的bean-2.method-B()。

图 11.5 事务传播概念
method-B 将在哪个事务中执行?
Spring 通过 org.springframework.transaction.annotation.Propagation 枚举定义了可能的传播列表:
-
REQUIRED— 如果存在事务,则执行将在该事务内继续。否则,将创建新的事务。REQUIRED是 Spring 中事务的默认传播。 -
SUPPORTS— 如果存在事务,则执行将在该事务内继续。否则,不会创建新的事务。 -
MANDATORY—如果存在事务,则执行将在该事务内继续。否则,将抛出TransactionRequiredException异常。 -
REQUIRES_NEW—如果存在事务,则它将被挂起,并将启动一个新事务。否则,无论如何都将创建一个新事务。 -
NOT_SUPPORTED—如果存在事务,则它将被挂起,并将继续非事务性执行。否则,执行将简单地继续。 -
NEVER—如果存在事务,则将抛出IllegalTransactionStateException异常。否则,执行将简单地继续。 -
NESTED—如果存在事务,则将创建此事务的子事务,并同时创建一个保存点。如果子事务失败,执行将回滚到此保存点。如果没有原始事务,则将创建一个新事务。
表 11.2 总结了 Spring 中可能的事务传播(T1 和 T2 分别是事务 1 和事务 2)。
表 11.2 Spring 中的事务传播
| 事务传播 | 调用方法中的事务 | 被调用方法中的事务 |
|---|---|---|
REQUIRED |
无 | T1 |
| T1 | T1 | SUPPORTS |
| 无 | 无 | T1 |
| T1 | MANDATORY |
无 |
| 异常 | T1 | T1 |
REQUIRES_NEW |
无 | T1 |
| T1 | T2 | NOT_SUPPORTED |
| 无 | 无 | T1 |
| 无 | NEVER |
无 |
| 无 | T1 | 异常 |
NESTED |
无 | T1 |
| T1 | 带保存点的 T2 |
11.4.2 事务回滚
Spring 事务定义了默认的回滚规则:对于 RuntimeException,事务将被回滚。此行为可以被覆盖,并且我们可以指定哪些异常会自动回滚事务,哪些不会。这是通过 @Transactional 注解属性 rollbackFor、rollbackForClassName、noRollbackFor、noRollbackForClassName 来实现的。这些属性确定的行为总结在表 11.3 中。
表 11.3 事务回滚规则
| 属性 | 类型 | 行为 |
|---|---|---|
rollbackFor |
扩展 Throwable 的 Class 对象数组 |
定义必须导致回滚的异常类 |
rollbackForClassName |
扩展 Throwable 的类名数组 |
定义必须导致回滚的异常类名 |
noRollbackFor |
扩展 Throwable 的 Class 对象数组 |
定义必须不导致回滚的异常类 |
noRollbackForClassName |
扩展 Throwable 的类名数组 |
定义必须不导致回滚的异常类名 |
11.4.3 事务属性
@Transactional 注解定义了表 11.4 中的属性。在这里,我们将处理已经检查的隔离和传播以及其他属性。所有元信息都将转换到事务性操作执行的水平。
表 11.4 @Transactional 注解属性
| 属性 | 类型 | 行为 |
|---|---|---|
isolation |
Isolation 枚举 |
声明隔离级别,遵循 ANSI 标准。 |
propagation |
Propagation 枚举 |
遵循表 11.2 中的值的传播设置。 |
timeout |
int(秒) |
超时后事务将自动回滚。 |
readOnly |
boolean |
声明事务是只读的还是读写。只读事务允许进行优化,从而使其更快。 |
@Transactional注解可以应用于接口、接口中的方法、类或类中的方法。一旦应用于接口或类,注解就会接管该类或接口的所有方法。您可以通过以不同的方式注解特定方法来更改行为。此外,一旦应用于接口或接口中的方法,注解就会由实现该接口的类或实现该接口的相应方法接管。行为可以被覆盖。因此,为了实现细粒度的行为,建议将@Transactional注解应用于类的方法。
11.4.4 程序化事务定义
在使用 Spring 的应用程序时,声明式事务管理通常是首选的方法。它需要编写的代码更少,行为是通过注解提供的元信息确定的。然而,仍然可以使用TransactionTemplate类进行程序化事务管理。
一旦创建TransactionTemplate对象,就可以通过以下方式程序化地定义事务的行为:
TransactionTemplate transactionTemplate;
// . . .
transactionTemplate.setIsolationLevel(
TransactionDefinition.ISOLATION_REPEATABLE_READ);
transactionTemplate.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
transactionTemplate.setTimeout(5);
transactionTemplate.setReadOnly(false);
一旦定义,TransactionTemplate对象通过execute方法支持回调方法,该方法接收一个TransactionCallback作为参数,如下面的代码所示。事务中要执行的操作在doInTransaction方法中定义。
transactionTemplate.execute(new TransactionCallback() {
public Object doInTransaction(TransactionStatus status) {
//operations to be executed in transaction
}
});
由于TransactionCallback是一个函数式接口(它甚至带有@FunctionalInterface注解),前面的代码片段可以缩短如下:
transactionTemplate.execute(status -> {
// operations to be executed in transaction
});
11.4.5 使用 Spring 和 Spring Data 进行事务开发
我们一直在开发 CaveatEmptor 应用程序,现在我们准备实现一个功能,该功能将记录我们在处理项目时的操作结果。我们将使用 Spring Data JPA 开始实现,首先创建ItemRepositoryCustom接口及其方法,如列表 11.8 所示。这样的接口被称为片段接口,其目的是通过自定义功能扩展仓库,这些功能将由后续实现提供。
列表 11.8 ItemRepositoryCustom接口
Path: Ch11/transactions5-springdata/src/main/java/com/manning
➥ /javapersistence/ch11/repositories/ItemRepositoryCustom.java
public interface ItemRepositoryCustom {
void addItem(String name, LocalDate creationDate);
void checkNameDuplicate(String name);
void addLogs();
void showLogs();
void addItemNoRollback(String name, LocalDate creationDate);
}
接下来,我们将创建一个ItemRepository接口,它扩展了JpaRepository和之前声明的ItemRepositoryCustom接口。此外,我们将声明findByName方法,遵循 Spring Data JPA 的命名约定。
列表 11.9 ItemRepository接口
Path: Ch11/transactions5-springdata/src/main/java/com/manning
➥ /javapersistence/ch11/repositories/ItemRepository.java
public interface ItemRepository extends JpaRepository<Item, Long>,
ItemRepositoryCustom {
Optional<Item> findByName(String name);
}
然后,我们将创建 LogRepositoryCustom 接口及其方法,如列表 11.10 所示。同样,这是一个片段接口,其目的是扩展存储库以提供由后续实现提供的自定义功能。
列表 11.10 LogRepositoryCustom 接口
Path: Ch11/transactions5-springdata/src/main/java/com/manning
➥ /javapersistence/ch11/repositories/LogRepositoryCustom.java
public interface LogRepositoryCustom {
void log(String message);
void showLogs();
void addSeparateLogsNotSupported();
void addSeparateLogsSupports();
}
我们现在将创建 LogRepository 接口,它扩展了 JpaRepository 以及之前声明的 LogRepositoryCustom 接口。
列表 11.11 LogRepository 接口
Path: Ch11/transactions5-springdata/src/main/java/com/manning
➥ /javapersistence/ch11/repositories/LogRepository.java
public interface LogRepository extends JpaRepository<Log, Integer>,
LogRepositoryCustom {
}
接下来,我们将为 ItemRepository 提供一个实现类。这个类名中的 Impl 结尾是关键部分。它与 Spring Data 无关,并且仅实现了 ItemRepositoryCustom。在注入 ItemRepository 实例时,Spring Data 必须创建一个代理类;它将检测到 ItemRepository 实现了 ItemRepositoryCustom,并将查找名为 ItemRepositoryImpl 的类作为自定义存储库实现。因此,注入的 ItemRepository 实例的方法将与 ItemRepositoryImpl 类的方法具有相同的行为。
列表 11.12 ItemRepositoryImpl 类
Path: Ch11/transactions5-springdata/src/main/java/com/manning
➥ /javapersistence/ch11/repositories/ItemRepositoryImpl.java
public class ItemRepositoryImpl implements ItemRepositoryCustom {
@Autowired Ⓐ
private ItemRepository itemRepository; Ⓐ
@Autowired Ⓐ
private LogRepository logRepository; Ⓐ
@Override
@Transactional(propagation = Propagation.MANDATORY) Ⓑ
public void checkNameDuplicate(String name) {
if (itemRepository.findAll().stream().map(item -> Ⓒ
item.getName()).filter(n -> n.equals(name)).count() > 0) { Ⓒ
throw new DuplicateItemNameException("Item with name " + name + Ⓒ
" already exists");
}
}
@Override
@Transactional Ⓓ
public void addItem(String name, LocalDate creationDate) {
logRepository.log("adding item with name " + name);
checkNameDuplicate(name);
itemRepository.save(new Item(name, creationDate));
}
@Override
@Transactional(noRollbackFor = DuplicateItemNameException.class) Ⓔ
public void addItemNoRollback(String name, LocalDate creationDate) {
logRepository.save(new Log(
"adding log in method with no rollback for item " + name));
checkNameDuplicate(name);
itemRepository.save(new Item(name, creationDate));
}
@Override
@Transactional Ⓓ
public void addLogs() {
logRepository.addSeparateLogsNotSupported();
}
@Override
@Transactional Ⓓ
public void showLogs() {
logRepository.showLogs();
}
}
Ⓐ 自动装配 ItemRepository 和 LogRepository 实例。
Ⓑ MANDATORY 传播行为:Spring Data 将检查是否已有事务正在进行,并继续使用该事务。否则,将抛出异常。
Ⓒ 如果存在具有给定名称的 Item,则抛出 DuplicateItemNameException。
Ⓓ 默认传播行为是 REQUIRED。
Ⓔ 在出现 DuplicateItemNameException 的情况下不会回滚事务。
接下来,我们将为 LogRepository 提供一个实现类。正如 ItemRepositoryImpl 的情况一样,这个类名中的 Impl 结尾是关键部分。它仅实现了 LogRepositoryCustom。当我们注入 LogRepository 实例时,Spring Data 将检测到 LogRepository 实现了 LogRepositoryCustom,并将查找名为 LogRepositoryImpl 的类作为自定义存储库实现。因此,注入的 LogRepository 实例的方法将与 LogRepositoryImpl 类的方法具有相同的行为。
列表 11.13 LogRepositoryImpl 类
Path: Ch11/transactions5-springdata/src/main/java/com/manning
➥ /javapersistence/ch11/repositories/LogRepositoryImpl.java
public class LogRepositoryImpl implements LogRepositoryCustom {
@Autowired Ⓐ
private LogRepository logRepository; Ⓐ
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW) Ⓑ
public void log(String message) {
logRepository.save(new Log(message)); Ⓒ
}
@Override
@Transactional(propagation = Propagation.NOT_SUPPORTED) Ⓓ
public void addSeparateLogsNotSupported() {
logRepository.save(new Log("check from not supported 1"));
if (true) throw new RuntimeException();
logRepository.save(new Log("check from not supported 2"));
}
@Override
@Transactional(propagation = Propagation.SUPPORTS) Ⓔ
public void addSeparateLogsSupports() {
logRepository.save(new Log("check from supports 1"));
if (true) throw new RuntimeException();
logRepository.save(new Log("check from supports 2"));
}
@Override
@Transactional(propagation = Propagation.NEVER) Ⓕ
public void showLogs() {
System.out.println("Current log:");
logRepository.findAll().forEach(System.out::println);
}
}
Ⓐ 自动装配 LogRepository 实例。
Ⓑ REQUIRES_NEW 传播行为。Spring Data 将在单独的事务中执行日志记录,独立于调用 log 方法的最终事务。
Ⓒ log 方法会将消息保存到存储库中。
Ⓓ NOT_SUPPORTED 传播行为。如果已有事务正在进行,它将被挂起,并将继续执行非事务性操作。否则,执行将简单地继续。
Ⓔ SUPPORTS 传播行为。如果已有事务正在进行,执行将在该事务内继续。否则,不会创建事务。
Ⓕ NEVER 传播行为。如果已有事务正在进行,将抛出 IllegalTransactionStateException。否则,执行将简单地继续。
我们现在将编写一系列测试来验证我们刚刚编写的交易方法的操作行为。
列表 11.14 TransactionPropagationTest 类
Path: Ch11/transactions5-springdata/src/test/java/com/manning
➥ /javapersistence/ch11/concurrency/TransactionPropagationTest.java
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {SpringDataConfiguration.class})
public class TransactionPropagationTest {
@Autowired Ⓐ
private ItemRepository itemRepository; Ⓐ
@Autowired Ⓐ
private LogRepository logRepository; Ⓐ
@BeforeEach Ⓑ
public void clean() { Ⓑ
itemRepository.deleteAll(); Ⓑ
logRepository.deleteAll(); Ⓑ
}
@Test
public void notSupported() {
assertAll(
() -> assertThrows(RuntimeException.class, () -> Ⓒ
itemRepository.addLogs()), Ⓒ
() -> assertEquals(1, logRepository.findAll().size()), Ⓓ
() -> assertEquals("check from not supported 1", Ⓓ
logRepository.findAll().get(0).getMessage()) Ⓓ
);
logRepository.showLogs(); Ⓔ
}
@Test
public void supports() {
assertAll(
() -> assertThrows(RuntimeException.class, () -> Ⓕ
logRepository.addSeparateLogsSupports()), Ⓕ
() -> assertEquals(1, logRepository.findAll().size()), Ⓖ
() -> assertEquals("check from supports 1", Ⓖ
logRepository.findAll().get(0).getMessage()) Ⓖ
);
logRepository.showLogs(); Ⓗ
}
@Test
public void mandatory() {
IllegalTransactionStateException ex = Ⓘ
assertThrows(IllegalTransactionStateException.class, Ⓘ
() -> itemRepository.checkNameDuplicate("Item1")); Ⓘ
assertEquals("No existing transaction found for transaction marked Ⓘ
with propagation 'mandatory'", ex.getMessage()); Ⓘ
}
@Test
public void never() {
itemRepository.addItem("Item1", LocalDate.of(2022, 5, 1)); Ⓙ
logRepository.showLogs(); Ⓙ
IllegalTransactionStateException ex = Ⓚ
assertThrows(IllegalTransactionStateException.class, Ⓚ
() -> itemRepository.showLogs()); Ⓚ
assertEquals( Ⓚ
"Existing transaction found for transaction marked with propagation Ⓚ
'never'", ex.getMessage()); Ⓚ
}
@Test
public void requiresNew() {
itemRepository.addItem("Item1", LocalDate.of(2022, 5, 1));
itemRepository.addItem("Item2", LocalDate.of(2022, 3, 1));
itemRepository.addItem("Item3", LocalDate.of(2022, 1, 1));
DuplicateItemNameException ex = Ⓛ
assertThrows(DuplicateItemNameException.class, () -> Ⓛ
itemRepository.addItem("Item2", LocalDate.of(2016, 3, 1))); Ⓛ
assertAll(
() -> assertEquals("Item with name Item2 already exists", Ⓜ
ex.getMessage()), Ⓜ
() -> assertEquals(4, logRepository.findAll().size()), Ⓝ
() -> assertEquals(3, itemRepository.findAll().size()) Ⓝ
);
System.out.println("Logs: ");
logRepository.findAll().forEach(System.out::println);
System.out.println("List of added items: ");
itemRepository.findAll().forEach(System.out::println);
}
@Test
public void noRollback() {
itemRepository.addItemNoRollback("Item1",
➥ LocalDate.of(2022, 5, 1));
itemRepository.addItemNoRollback("Item2",
➥ LocalDate.of(2022, 3, 1));
itemRepository.addItemNoRollback("Item3",
➥ LocalDate.of(2022, 1, 1));
DuplicateItemNameException ex = Ⓞ
assertThrows(DuplicateItemNameException.class, Ⓞ
() -> itemRepository.addItem("Item2", Ⓞ
LocalDate.of(2016, 3, 1))); Ⓞ
assertAll(
() -> assertEquals("Item with name Item2 already exists", Ⓟ
ex.getMessage()), Ⓟ
() -> assertEquals(4, logRepository.findAll().size()), Ⓠ
() -> assertEquals(3, itemRepository.findAll().size()) Ⓠ
);
System.out.println("Logs: ");
logRepository.findAll().forEach(System.out::println);
System.out.println("List of added items: ");
itemRepository.findAll().forEach(System.out::println);
}
}
Ⓐ 自动装配 ItemRepository 和 LogRepository 实例。
Ⓑ 在每个测试执行之前,所有 Item 实体和所有 Log 实体都会从仓库中移除。
Ⓒ addLogs 方法启动一个事务,但它调用 addSeparateLogsNotSupported 方法,这将在显式抛出异常之前挂起已启动的事务。
Ⓓ 在抛出异常之前,logRepository 能够保存一条消息。
Ⓔ showLog 方法将以非事务方式显示一条消息。
Ⓕ addSeparateLogsSupports 方法将显式抛出异常。
Ⓖ 在抛出异常之前,logRepository 能够保存一条消息。
Ⓗ showLog 方法将以非事务方式显示一条消息。
Ⓘ checkNameDuplicate 方法只能在事务中执行,因此在没有事务的情况下调用它将抛出 IllegalTransactionStateException。我们还会检查异常中的消息。
Ⓙ 在将 Item 添加到仓库后,可以安全地从 LogRepository 调用 showLogs 方法而不需要事务。
Ⓚ 然而,我们无法在事务中从 LogRepository 调用 showLogs 方法,因为从 ItemRepository 调用的 showLogs 方法是事务性的。
Ⓛ 尝试在仓库中插入重复的 Item 将会抛出 DuplicateItemNameException。
Ⓜ 然而,即使在异常之后,日志消息也会持久化,因为它是在单独的事务中添加的。
Ⓝ 仓库将包含 4 条 Log 消息(每个尝试插入一个 Item,无论成功与否),但只有 3 个 Item(重复的 Item 被拒绝)。
Ⓞ 尝试在仓库中插入重复的 Item 将会抛出 DuplicateItemNameException。
Ⓟ 然而,即使在异常之后,日志消息也会持久化,因为事务没有被回滚。ItemRepository 的 addItemNoRollback 方法不会对 DuplicateItemNameException 进行回滚。
Ⓠ 仓库将包含 4 条 Log 消息(每个尝试插入一个 Item,无论成功与否),但只有 3 个 Item(重复的 Item 被拒绝)。
摘要
-
Hibernate 依赖于数据库的并发控制机制,但由于自动版本化和持久化上下文缓存,在事务中提供了更好的隔离保证。
-
事务边界可以编程设置,并且您可以处理异常。
-
您可以使用乐观并发控制和显式悲观锁定。
-
您可以在事务之外使用自动提交模式和未同步的持久化上下文。
-
您可以使用 Spring 和 Spring Data 与事务一起工作,使用各种属性定义和配置事务。
12 获取计划、策略和配置文件
本章涵盖
-
使用延迟和即时加载
-
应用获取计划、策略和配置文件
-
优化 SQL 执行
在本章中,我们将探索 Hibernate 解决基本 ORM 导航问题的解决方案,如第 1.2.5 节中介绍:在 Java 代码和关系数据库中访问数据的方式的差异。我们将演示如何从数据库检索数据以及如何优化这种加载。
Hibernate 提供了以下从数据库获取数据并将其放入内存的方法:
-
我们可以通过标识符检索实体实例。当实体实例的唯一标识符值已知时,这是最方便的方法,例如
entityManager.find(Item.class, 123)。 -
我们可以通过访问关联实例的属性访问器方法(如
someItem.getSeller()().getAddress()().getCity()等)从已加载的实体实例开始导航实体图。当我们开始遍历集合时,映射集合的元素也会按需加载。如果持久化上下文仍然打开,Hibernate 会自动加载图中的节点。当我们调用访问器和遍历集合时加载的数据以及如何加载,是本章的重点。 -
我们可以使用 Jakarta Persistence Query Language (JPQL),这是一种基于字符串的完整面向对象查询语言,例如
select i from Item i where i.id = ?。 -
CriteriaQuery接口提供了一种类型安全和面向对象的方式来执行查询,而不需要字符串操作。 -
我们可以编写原生 SQL 查询,调用存储过程,并让 Hibernate 负责将 JDBC 结果集映射到领域模型类的实例。
在我们的 JPA 应用程序中,我们将使用这些技术的组合。到目前为止,你应该熟悉用于通过标识符检索的基本 Jakarta Persistence API。我们将保持我们的 JPQL 和CriteriaQuery示例尽可能简单,你不需要 SQL 查询映射功能。
JPA 2 的主要新特性
我们可以使用新的PersistenceUtil静态辅助类手动检查实体或实体属性的初始化状态。我们还可以使用新的EntityGraph API 创建标准化的声明性获取计划。
本章分析了当我们导航领域模型图并 Hibernate 按需检索数据时幕后发生的事情。在所有示例中,我们将在触发 SQL 执行的操作之后立即在注释中解释 Hibernate 执行的 SQL。
Hibernate 加载的数据取决于获取计划:我们定义应该加载的对象网络子图。然后我们选择正确的获取策略,定义如何加载数据。我们可以将计划和策略的选择存储为获取配置文件并重用它。
定义获取计划和 Hibernate 应该加载哪些数据依赖于两种基本技术:对象网络中的懒惰和急切加载节点。
12.2 懒惰和急切加载
在某个时候,我们必须决定应该从数据库中加载哪些数据到内存中。当我们执行entityManager.find(Item.class, 123)时,内存中有什么,被加载到持久上下文中?如果我们使用EntityManager#getReference()会发生什么?
在领域模型映射中,我们定义全局的默认获取计划,在关联和集合上使用FetchType.LAZY和FetchType.EAGER选项。此计划是涉及持久领域模型类的所有操作的默认设置。当我们通过标识符加载实体实例以及通过跟随关联和遍历持久集合导航实体图时,它始终处于活动状态。
我们推荐的策略是为所有实体和集合使用一个懒惰的默认获取计划。如果我们使用FetchType.LAZY映射所有的关联和集合,Hibernate 将只加载我们访问的数据。当我们导航领域模型实例的图时,Hibernate 将按需、逐步加载数据。在必要时,我们可以根据每个案例覆盖此行为。
为了实现懒惰加载,Hibernate 依赖于运行时生成的实体占位符,称为代理,以及集合的智能包装器。
12.1.1 理解实体代理
考虑EntityManager API 的getReference()方法。在第 10.2.4 节中,我们首次了解了此操作及其可能返回代理的方式。让我们进一步探讨这个重要功能,并了解代理是如何工作的。
注意:要执行源代码中的示例,您首先需要运行 Ch12.sql 脚本。
以下代码不会对数据库执行任何 SQL。Hibernate 所做的只是创建一个Item代理:它看起来(和闻起来)就像真的东西,但它只是一个占位符:
Path: Ch12/proxy/src/test/java/com/manning/javapersistence/ch12/proxy
➥ /LazyProxyCollections.java
Item item = em.getReference(Item.\1, ITEM_ID); Ⓐ
assertEquals(ITEM_ID, item.getId()); Ⓑ
Ⓐ 没有数据库击打,这意味着没有SELECT。
Ⓑ 调用标识符获取器(没有字段访问!)不会触发初始化。
在持久上下文中,现在我们有这个代理在持久状态中可用,如图 12.1 所示。

图 12.1 在 Hibernate 控制下的持久上下文包含一个Item代理。
代理是代表实体实例的运行时生成的Item子类的实例,携带该实体实例的标识符值。这就是为什么 Hibernate(与 JPA 一致)要求实体类至少有一个公共或受保护的零参数构造函数(类也可以有其他构造函数)。实体类及其方法不能是最终的;否则,Hibernate 无法生成代理。请注意,JPA 规范没有提到代理;如何实现懒惰加载取决于 JPA 提供者。
如果我们在代理上调用任何不是“标识符获取器”的方法,我们将触发代理的初始化并击中数据库。如果我们调用 item.getName(),将执行加载 Item 的 SQL SELECT。前面的例子调用 item.getId() 没有触发初始化,因为 getId() 是给定映射中的标识符获取器方法;getId() 方法被注解为 @Id。如果 @Id 在一个字段上,那么调用 getId(),就像调用任何其他方法一样,将初始化代理。(记住,我们通常更喜欢映射和字段上的访问,因为这允许在设计访问器方法时拥有更多自由;请参阅第 3.2.3 节。是否调用 getId() 而不初始化代理取决于你。)
使用代理时,请注意如何比较类。因为 Hibernate 生成代理类,它有一个看起来很奇怪的名称,并且它不等于Item.class:
Path: Ch12/proxy/src/test/java/com/manning/javapersistence/ch12/proxy
➥ /LazyProxyCollections.java
assertNotEquals(Item.class, item.getClass()); Ⓐ
assertEquals(
Item.class,
HibernateProxyHelper.getClassWithoutInitializingProxy(item)
);
Ⓐ 该类是运行时生成的,名称类似于 Item$HibernateProxy$BLsrPly8。
如果我们确实需要获取代理表示的实际类型,我们可以使用 HibernateProxyHelper。
JPA 提供了 PersistenceUtil,我们可以用它来检查实体或其实体属性的初始化状态:
Path: Ch12/proxy/src/test/java/com/manning/javapersistence/ch12/proxy
➥ /LazyProxyCollections.java
PersistenceUtil persistenceUtil = Persistence.getPersistenceUtil();
assertFalse(persistenceUtil.isLoaded(item));
assertFalse(persistenceUtil.isLoaded(item, "seller"));
assertFalse(Hibernate.isInitialized(item));
// assertFalse(Hibernate.isInitialized(item.getSeller())); Ⓐ
Ⓐ 执行这一行代码将有效地触发项目的初始化。
isLoaded() 方法也接受给定实体(代理)实例的属性名称,检查其初始化状态。Hibernate 提供了一个替代 API,即 Hibernate.isInitialized()。如果我们调用 item.getSeller(),则首先初始化 item 代理。
Hibernate 还提供了一个用于快速初始化代理的实用方法:
Path: Ch12/proxy/src/test/java/com/manning/javapersistence/ch12/proxy
➥ /LazyProxyCollections.java
Hibernate.initialize(item); Ⓐ
// select * from ITEM where ID = ?
assertFalse(Hibernate.isInitialized(item.getSeller())); Ⓑ
Hibernate.initialize(item.getSeller()); Ⓒ
// select * from USERS where ID = ?
Ⓐ 第一次调用将击中数据库并加载 Item 数据,用项目的名称、价格等信息填充代理。
Ⓑ 确保已经用 LAZY 覆盖了 @ManyToOne 的默认值 EAGER。这就是为什么 item 的 seller 还未初始化的原因。
Ⓒ 通过初始化 item 的 seller,我们将触发数据库调用并加载 User 数据。
Item 的 seller 是一个通过 FetchType.LAZY 映射的 @ManyToOne 关联,因此当加载 Item 时,Hibernate 会创建一个 User 代理。我们可以检查 seller 代理的状态并手动加载它,就像对 Item 做的那样。记住,JPA 对 @ManyToOne 的默认值是 FetchType.EAGER!我们通常希望覆盖这个默认值以获得一个懒加载的默认获取计划,就像我们在第 8.3.1 节和这里所展示的那样:
Path: Ch12/proxy/src/main/java/com/manning/javapersistence/ch12/proxy
➥ /Item.java
@Entity
public class Item {
@ManyToOne(fetch = FetchType.LAZY)
public User getSeller() {
return seller;
}
// . . .
}
使用这种懒加载获取计划,我们可能会遇到 LazyInitializationException。考虑以下代码:
Path: Ch12/proxy/src/test/java/com/manning/javapersistence/ch12/proxy
➥ /LazyProxyCollections.java
Item item = em.find(Item.class, ITEM_ID); Ⓐ
// select * from ITEM where ID = ?
em.detach(item); Ⓑ
em.detach(item.getSeller());
// em.close();
PersistenceUtil persistenceUtil = Persistence.getPersistenceUtil(); Ⓒ
assertTrue(persistenceUtil.isLoaded(item));
assertFalse(persistenceUtil.isLoaded(item, "seller"));
assertEquals(USER_ID, item.getSeller().getId()); Ⓓ
//assertNotNull(item.getSeller().getUsername()); Ⓔ
Ⓐ 在持久化上下文中加载了一个 Item 实体实例。它的 seller 没有初始化;它是一个 User 代理。
Ⓑ 我们可以手动从持久化上下文中分离数据或关闭持久化上下文并分离所有内容。
Ⓒ PersistenceUtil 辅助工具可以在没有持久化上下文的情况下工作。我们可以在任何时候检查我们想要访问的数据是否已经被加载。
Ⓓ 在分离状态中,我们可以调用User代理的标识符获取方法。
Ⓔ 在代理上调用任何其他方法,例如getUsername(),将抛出LazyInitializationException。数据只能在持久化上下文管理代理时按需加载,而不是在分离状态。
一对一关联的延迟加载是如何工作的?
对于 Hibernate 的新用户来说,一对一实体关联的延迟加载有时会令人困惑。如果我们考虑基于共享主键的一对一关联(见第 9.1.1 节),只有当关联的optional=false时,才能代理关联。例如,Address总是有一个对User的引用。如果这个关联是可空的且可选的,Hibernate 必须首先访问数据库以确定是否应该应用代理或 null,而延迟加载的目的就是根本不访问数据库。
Hibernate 代理除了简单的延迟加载之外还有其他用途。例如,我们可以存储一个新的Bid而不将任何数据加载到内存中。
Path: Ch12/proxy/src/test/java/com/manning/javapersistence/ch12/proxy
➥ /LazyProxyCollections.java
Item item = em.getReference(Item.class, ITEM_ID);
User user = em.getReference(User.class, USER_ID);
Bid newBid = new Bid(new BigDecimal("99.00"));
newBid.setItem(item);
newBid.setBidder(user);
em.persist(newBid); Ⓐ
// insert into BID values (?, ? ,? , . . . )
Ⓐ 在此过程中没有 SQL SELECT,只有一个INSERT。
前两次调用分别产生Item和User的代理。然后,将代理设置到瞬态Bid的item和bidder关联属性中。当持久化上下文刷新时,persist()调用将排队一个 SQL INSERT,并且不需要SELECT来在BID表中创建新行。所有键值都作为Item和User代理的标识符值可用。
运行时代理生成,如 Hibernate 所提供,是透明延迟加载的一个优秀选择。领域模型类不需要实现任何特殊类型或超类型,正如一些较老的 ORM 解决方案所要求的。也不需要代码生成或字节码的后处理,这简化了构建过程。但你应该意识到一些潜在的负面方面:
-
一些运行时代理并不完全透明,例如使用
instanceof测试的多态关联。这个问题在第 7.8.1 节中有所演示。 -
在编写自定义的
equals()和hashCode()方法时,你必须小心不要直接访问字段,正如第 10.3.2 节所讨论的。 -
代理只能用于懒加载实体关联。它们不能用于懒加载单个基本属性或嵌入式组件,例如
Item# description或User#homeAddress。如果你在这样一个属性上设置了@Basic(fetch = FetchType .LAZY)提示,Hibernate 会忽略它;当拥有实体的实例被加载时,值会被立即加载。如果你不是在处理大量的可选或可空列,或者由于系统的物理限制必须按需检索包含大值的列,那么在 SQL 中选择的单个列级别进行优化是不必要的。大值最好用大对象(LOBs)表示;它们根据定义提供懒加载(参见第 6.3.1 节中的“二进制和大值类型”)。
代理允许懒加载实体实例。对于持久化集合,Hibernate 采用略有不同的方法。
12.1.2 懒加载持久化集合
我们使用@ElementCollection将持久化集合映射为基本或嵌入式类型的元素集合,或者使用@OneToMany和@ManyToMany将多值实体关联映射为集合。这些集合与@ManyToOne不同,默认情况下是懒加载的。我们不需要在映射上指定FetchType.LAZY选项。
懒加载的bids一对一集合仅在访问时加载:
Path: Ch12/proxy/src/test/java/com/manning/javapersistence/ch12/proxy
➥ /LazyProxyCollections.java
Item item = em.find(Item.class, ITEM_ID); Ⓐ
// select * from ITEM where ID = ?
Set<Bid> bids = item.getBids(); Ⓑ
PersistenceUtil persistenceUtil = Persistence.getPersistenceUtil();
assertFalse(persistenceUtil.isLoaded(item, "bids"));
assertTrue(Set.class.isAssignableFrom(bids.getClass())); Ⓒ
assertNotEquals(HashSet.class, bids.getClass()); Ⓓ
assertEquals(org.hibernate.collection.internal.PersistentSet.class,
➥ bids.getClass()); Ⓔ
Ⓐ 如您在图 12.2 中看到的,find()操作将Item实体实例加载到持久化上下文中。
Ⓑ Item实例有一个指向未初始化的bids集合的引用。它还有一个指向未初始化的User代理的引用:seller。
Ⓒ bids字段是一个Set。
Ⓓ 然而,bids字段不是一个HashSet。
Ⓔ bids字段是一个 Hibernate 代理类。

图 12.2 在 Hibernate 控制下,代理和集合包装器是加载图的边界。
Hibernate 通过其称为集合包装器的特殊实现来实现集合的懒加载(和脏检查)。尽管bids确实看起来像Set,但在我们没有注意到的情况下,Hibernate 用org.hibernate.collection.internal.PersistentSet替换了实现。它不是一个HashSet,但它具有相同的行为。这就是为什么在领域模型中用接口编程并且只依赖于Set而不是HashSet如此重要的原因。列表和映射也是同样的方式工作。
这些特殊集合可以检测我们何时访问它们,并在那时加载数据。当我们开始遍历bids时,集合和为该物品做出的所有出价都会被加载:
Path: Ch12/proxy/src/test/java/com/manning/javapersistence/ch12/proxy
➥ /LazyProxyCollections.java
Bid firstBid = bids.iterator().next();
// select * from BID where ITEM_ID = ?
// Alternative: Hibernate.initialize(bids);
或者,就像实体代理一样,我们可以调用静态的Hibernate.initialize()实用方法来加载一个集合。它将被完全加载;我们无法说“只加载前两个出价”,例如。要这样做,我们必须编写一个查询。
为了方便起见,这样我们就不必编写许多琐碎的查询,Hibernate 在集合映射上提供了一个专有的LazyCollectionOption.EXTRA设置:
Path: Ch12/proxy/src/main/java/com/manning/javapersistence/ch12/proxy
➥ /Item.java
@Entity
public class Item {
@OneToMany(mappedBy = "item")
@org.hibernate.annotations.LazyCollection(
org.hibernate.annotations.LazyCollectionOption.EXTRA
)
public Set<Bid> getBids() {
return bids;
}
// . . .
}
使用LazyCollectionOption.EXTRA,集合支持不触发初始化的操作。例如,我们可以请求集合的大小:
Path: Ch12/proxy/src/test/java/com/manning/javapersistence/ch12/proxy
➥ /LazyProxyCollections.java
Item item = em.find(Item.class, ITEM_ID);
// select * from ITEM where ID = ?
assertEquals(3, item.getBids().size());
// select count(b) from BID b where b.ITEM_ID = ?
size()操作触发一个SELECT COUNT() SQL 查询,但不会将bids加载到内存中。在所有额外延迟加载的集合上,类似的查询会在isEmpty()和contains()操作时执行。当我们调用add()时,额外的延迟加载Set会通过一个简单的查询检查重复项。额外的延迟加载List只有在调用get(index)时才会加载一个元素。对于Map,额外的延迟操作是containsKey()和containsValue()。
12.1.3 关联和集合的急切加载
我们推荐使用延迟默认获取计划,在所有关联和集合映射上使用FetchType.LAZY。有时,尽管不常见,我们希望相反:指定特定的实体关联或集合应该始终被加载。我们希望保证这些数据在内存中可用,而无需额外的数据库访问。
更重要的是,我们希望有一个保证,例如,一旦Item实例处于分离状态,我们就可以访问其seller。当持久化上下文关闭时,延迟加载不再可用。如果seller是一个未初始化的代理,当我们以分离状态访问它时,我们会得到一个LazyInitializationException。为了在分离状态下提供数据,我们需要在持久化上下文仍然打开时手动加载它,或者如果我们总是希望它被加载,将获取计划改为急切而非延迟。
假设我们总是需要加载Item的seller和bids:
Path: Ch12/eagerjoin/src/main/java/com/manning/javapersistence/ch12
➥ /eagerjoin/Item.java
@Entity
public class Item {
@ManyToOne(fetch = FetchType.EAGER) Ⓐ
private User seller;
@OneToMany(mappedBy = "item", fetch = FetchType.EAGER) Ⓑ
private Set<Bid> bids = new HashSet<>();
// . . .
}
Ⓐ 实体实例的默认获取计划是FetchType.EAGER。
Ⓑ 通常,集合上的FetchType.EAGER不被推荐。与FetchType.LAZY不同,后者是 JPA 提供者可以忽略的提示,FetchType.EAGER是一个硬性要求。提供者必须保证数据被加载并在分离状态下可用;它不能忽略设置。
考虑集合映射:说“每当一个项目被加载到内存中时,立即加载该项目的出价”真的好吗?即使我们只想显示项目的名称或找出拍卖何时结束,所有出价都将被加载到内存中。始终使用FetchType.EAGER作为默认获取计划的集合延迟加载通常不是一个好策略。(在本章的后面部分,我们将分析如果急切加载多个集合时出现的笛卡尔积问题。)如果我们将集合设置为默认的FetchType.LAZY,那就最好不过了。
如果我们现在find()一个Item(或强制初始化一个Item代理),seller和所有bids都将作为持久实例加载到持久化上下文中:
Path: Ch12/eagerjoin/src/test/java/com/manning/javapersistence/ch12
➥ /eagerjoin/EagerJoin.java
Item item = em.find(Item.class, ITEM_ID);
// select i.*, u.*, b.*
// from ITEM i
// left outer join USERS u on u.ID = i.SELLER_ID
// left outer join BID b on b.ITEM_ID = i.ID
// where i.ID = ?
em.detach(item); Ⓐ
assertEquals(3, item.getBids().size()); Ⓑ
assertNotNull(item.getBids().iterator().next().getAmount());
assertEquals("johndoe", item.getSeller().getUsername()); Ⓒ
Ⓐ 当调用detach()时,获取操作完成。将不再进行懒加载。
Ⓑ 在分离状态中,bids集合是可用的,因此我们可以检查其大小。
Ⓒ 在分离状态中,seller是可用的,因此我们可以检查其名称。
对于find(),Hibernate 执行一个单一的 SQL SELECT和三个表的JOIN来检索数据。您可以在图 12.3 中看到持久化上下文的内容。注意加载图的边界是如何表示的:每个Bid都有一个指向未初始化的User代理bidder的引用。如果我们现在断开Item的连接,我们可以访问加载的seller和bids而不会引发LazyInitializationException。如果我们尝试访问bidder代理之一,我们会得到一个异常。

图 12.3 Item的卖家和出价在 Hibernate 持久化上下文中加载。
接下来,我们将探讨当我们通过标识符找到实体实例以及当我们使用映射的关联和集合的指针导航网络时,数据应该如何加载。我们感兴趣的是执行了哪些 SQL 以及如何找到理想的获取策略。
在以下示例中,我们将假设领域模型有一个懒加载的默认获取计划。Hibernate 将只加载我们明确请求的数据以及我们访问的关联和集合。
12.2 选择获取策略
Hibernate 执行 SQL SELECT语句将数据加载到内存中。如果我们加载一个实体实例,将执行一个或多个SELECT,具体取决于涉及的表的数量和我们应用的获取策略。我们的目标是最小化 SQL 语句的数量并简化 SQL 语句,以便查询尽可能高效。
考虑本章前面推荐的获取计划:每个关联和集合都应该按需、懒加载。这个默认的获取计划很可能会导致过多的 SQL 语句,每个语句只加载一小块数据。这将导致n+1 选择问题,因此我们首先关注这个问题。使用预加载的替代获取计划将导致更少的 SQL 语句,因为每次 SQL 查询都会将更大的数据块加载到内存中。我们可能会看到笛卡尔积问题,因为 SQL 结果集变得过大。
我们需要在两种极端之间找到一个平衡点:我们应用程序中每个过程和用例的理想获取策略。就像获取计划一样,我们可以在映射中设置全局获取策略:一个始终激活的默认设置。然后,对于特定的过程,我们可能会用自定义的 JPQL CriteriaQuery或甚至 SQL 查询覆盖默认的获取策略。
首先,让我们探讨基本问题,从n+1 选择问题开始。
12.2.1 n+1 选择问题
通过一些示例代码,我们可以很容易地理解这个问题。假设我们映射了一个懒加载计划,所以所有内容都是按需加载的。以下代码检查每个 Item 的 seller 是否有 username:
Path: Ch12/nplusoneselects/src/test/java/com/manning/javapersistence/ch12
➥ /nplusoneselects/NPlusOneSelects.java
List<Item> items =
em.createQuery("select i from Item i").getResultList();
// select * from ITEM
for (Item item : items) {
assertNotNull(item.getSeller().getUsername()); Ⓐ
// select * from USERS where ID = ?
}
Ⓐ 每当我们访问一个卖家时,每个卖家都必须加载一个额外的 SELECT。
您可以看到一个 SQL SELECT,它加载 Item 实例。然后,当我们遍历所有 items 时,检索每个 User 需要额外的 SELECT。这相当于对一个 Item 的一个查询加上 n 个查询,这取决于我们有多少个项目以及特定的 User 是否出售超过一个 Item。显然,如果我们知道我们将访问每个 Item 的 seller,这是一个非常低效的策略。
我们可以在懒加载集合中看到相同的问题。以下示例检查每个 Item 是否有任何 bids:
Path: Ch12/nplusoneselects/src/test/java/com/manning/javapersistence/ch12
➥ /nplusoneselects/NPlusOneSelects.java
List<Item> items = em.createQuery("select i from Item i").getResultList();
// select * from ITEM
for (Item item : items) {
assertTrue(item.getBids().size() > 0); Ⓐ
// select * from BID where ITEM_ID = ?
}
Ⓐ 每个 bids 集合都必须加载一个额外的 SELECT。
再次强调,如果我们知道我们将访问每个 bids 集合,一次只加载一个是不高效的。如果我们有 100 个出价,我们将执行 101 个 SQL 查询!
根据我们目前所知,我们可能会倾向于更改映射中的默认获取计划,并在 seller 或 bids 关联上放置 FetchType.EAGER。但这样做可能会导致我们下一个主题:笛卡尔积问题。
12.2.2 笛卡尔积问题
如果我们查看领域和数据模型,并说,“每次我需要 Item 时,我也需要该 Item 的 seller,”我们可以使用 FetchType.EAGER 映射关联,而不是懒加载计划。我们希望有一个保证,即每次加载 Item 时,seller 将立即加载——我们希望在 Item 分离和持久化上下文关闭时数据可用:
Path: Ch12/cartesianproduct/src/main/java/com/manning/javapersistence/ch12
➥ /cartesianproduct/Item.java
@Entity
public class Item {
@ManyToOne(fetch = FetchType.EAGER)
private User seller;
// . . .
}
为了实现 eager fetch plan,Hibernate 使用 SQL JOIN 操作在一个 SELECT 中加载一个 Item 和一个 User 实例:
item = em.find(Item.class, ITEM_ID);
// select i.*, u.*
// from ITEM i
// left outer join USERS u on u.ID = i.SELLER_ID
// where i.ID = ?
结果集包含一行数据,来自 ITEM 表和 USERS 表的数据合并,如图 12.4 所示。

图 12.4 Hibernate 通过连接两个表来 eager 获取关联行。
使用默认的 JOIN 策略进行 eager fetching 对 @ManyToOne 和 @OneToOne 关联没有问题。我们可以通过一个 SQL 查询和 JOINs 来 eager 加载一个 Item、它的 seller、用户的 Address、他们居住的 City 等等。即使我们使用 FetchType.EAGER 映射所有这些关联,结果集也只有一个行。
Hibernate 必须在某个时候停止遵循 FetchType.EAGER 计划。连接的表的数量取决于全局 hibernate.max_fetch_depth 配置属性,默认情况下没有设置限制。合理的值通常很小,通常是 1 到 5。我们甚至可以通过将属性设置为 0 来禁用 @ManyToOne 和 @OneToOne 关联的 JOIN 检索。如果 Hibernate 达到限制,它仍然会根据预取计划预取数据,但会使用额外的 SELECT 语句。(注意,某些数据库方言可能已经预设了此属性;例如,MySQLDialect 将其设置为 2。)
另一方面,使用 JOIN 预取集合可能会导致严重的性能问题。如果我们也将 bids 和 images 集合切换到 FetchType.EAGER,我们就会遇到 笛卡尔积问题。
当我们使用一个 SQL 查询和一个 JOIN 操作预取两个集合时,会出现这个问题。首先,让我们创建这样一个预取计划,然后看看问题:
Path: Ch12/cartesianproduct/src/main/java/com/manning/javapersistence/ch12
➥ /cartesianproduct/Item.java
@Entity
public class Item {
@OneToMany(mappedBy = "item", fetch = FetchType.EAGER)
private Set<Bid> bids = new HashSet<>();
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "IMAGE")
@Column(name = "FILENAME")
private Set<String> images = new HashSet<>();
// . . .
}
无论两个集合都是 @OneToMany、@ManyToMany 还是 @ElementCollection,都无关紧要。使用 SQL JOIN 操作符一次性获取多个集合是基本问题,无论集合的内容如何。如果我们加载一个 Item,Hibernate 将执行有问题的 SQL 语句:
Path: Ch12/cartesianproduct/src/test/java/com/manning/javapersistence/ch12
➥ /cartesianproduct/CartesianProduct.java
Item item = em.find(Item.class, ITEM_ID);
// select i.*, b.*, img.*
// from ITEM i
// left outer join BID b on b.ITEM_ID = i.ID
// left outer join IMAGE img on img.ITEM_ID = i.ID
// where i.ID = ?
em.detach(item);
assertEquals(3, item.getImages().size();
assertEquals(3, item.getBids().size());
如您所见,Hibernate 遵循了预取计划,并且我们可以访问处于分离状态的 bids 和 images 集合。问题是它们是如何被加载的,通过一个产生乘积的 SQL JOIN。看看图 12.5 中的结果集。

图 12.5 两个连接的结果是具有多行的乘积。
这个结果集包含许多冗余数据项,只有着色的单元格对 Hibernate 是相关的。Item 有三个出价和三个图片。乘积的大小取决于我们检索的集合的大小:3 × 3 总共是 9 行。现在想象一下,如果我们有一个具有 50 个 bids 和 5 个 images 的 Item,我们可能会看到一个包含 250 行的结果集!当我们使用 JPQL 或 CriteriaQuery 编写自己的查询时,我们甚至可以创建更大的 SQL 乘积;想象一下如果我们加载 500 个项目,并通过 JOIN 预取数十个出价和图片会发生什么。
在数据库服务器上创建此类结果需要大量的处理时间和内存,然后这些结果必须通过网络传输。如果你希望 JDBC 驱动程序以某种方式压缩网络上的数据,你可能对数据库供应商期望过高了。Hibernate 在将结果集序列化为持久实例和集合时立即删除所有重复项;图 12.5 中未着色的单元格中的信息将被忽略。显然,我们无法在 SQL 层面上删除这些重复项;SQL 的 DISTINCT 操作符在这里不起作用。
与使用一个包含极大量结果的 SQL 查询相比,同时检索实体实例和两个集合时,三个单独的查询会更快。我们将在下一部分关注这种优化,并看看我们如何找到并实现最佳预取策略。我们将再次从默认的懒加载预取计划开始,并尝试首先解决 n+1 查询问题。
12.2.3 批量预取数据
如果 Hibernate 只在需要时预取每个实体关联和集合,可能需要许多额外的 SQL SELECT 语句来完成特定的过程。就像之前一样,考虑一个检查每个 Item 的 seller 是否有 username 的常规操作。使用懒加载,这需要一行 SELECT 来获取所有 Item 实例,以及 n 行额外的 SELECT 来初始化每个 Item 的 seller 代理。
Hibernate 提供了可以预取数据的算法。我们将首先查看的算法是 批量预取,它的工作原理如下:如果 Hibernate 必须初始化一个 User 代理,它可以继续初始化几个具有相同 SELECT 的代理。换句话说,如果我们已经知道持久化上下文中存在多个 Item 实例,并且它们的所有 seller 关联都应用了代理,那么我们不妨在数据库往返时初始化几个代理,而不仅仅是其中一个。
让我们看看它是如何工作的。首先,通过一个专有的 Hibernate 注解启用 User 实例的批量预取:
Path: Ch12/batch/src/main/java/com/manning/javapersistence/ch12/batch
➥ /User.java
@Entity
@org.hibernate.annotations.BatchSize(size = 10)
@Table(name = "USERS")
public class User {
// . . .
}
这个设置告诉 Hibernate,如果必须加载,它可以加载多达 10 个 User 代理,所有这些代理都使用相同的 SELECT。批量预取通常被称为 盲猜优化,因为我们不知道特定持久化上下文中可能有多少未初始化的 User 代理。我们无法确定 10 是一个理想值——这是一个猜测。我们知道,与 n+1 SQL 查询相比,我们现在将看到查询,这是一个显著的减少。合理的值通常很小,因为我们不希望将太多数据加载到内存中,尤其是如果我们不确定我们是否需要它。
这是一个优化过程,它会检查每个 seller 的 username:
Path: Ch12/batch/src/test/java/com/manning/javapersistence/ch12/batch
➥ /Batch.java
List<Item> items = em.createQuery("select i from Item i",
➥ Item.class).getResultList();
// select * from ITEM
for (Item item : items) {
assertNotNull(item.getSeller().getUsername());
// select * from USERS where ID in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
}
注意 Hibernate 在我们遍历 items 时执行的 SQL 查询。当我们第一次调用 item.getSeller().getUserName() 时,Hibernate 必须初始化第一个 User 代理。Hibernate 不会只从 USERS 表中加载一行,而是检索多行,并加载多达 10 个 User 实例。一旦我们访问到第十一个 seller,就会再批量加载 10 个,以此类推,直到持久化上下文中不再有未初始化的 User 代理。
真正的批量预取算法是什么?
我们在第 12.2.3 节中对批量加载的解释有些简化,你可能在实践中看到略有不同的算法。
例如,假设批处理大小为 32。在启动时,Hibernate 内部创建多个批处理加载器。每个加载器都知道它可以初始化多少个代理。目标是尽量减少加载器创建的内存消耗,并创建足够的加载器,以便可以生成每个可能的批处理抓取。另一个目标显然是尽量减少 SQL 查询的数量。为了初始化 31 个代理,Hibernate 执行了 3 个批处理(你可能预计为 1 个,因为 32 > 31)。应用的批处理加载器是 16、10 和 5,由 Hibernate 自动选择。
您可以通过持久化单元配置中的hibernate.batch_fetch_style属性来自定义此批处理抓取算法。默认值为LEGACY,它在启动时构建并选择多个批处理加载器。其他选项包括PADDED和DYNAMIC。使用PADDED时,Hibernate 在启动时仅构建一个带有IN子句中 32 个占位符的批处理加载器 SQL 查询,并在需要加载少于 32 个代理时重复绑定标识符。使用DYNAMIC时,Hibernate 在运行时动态构建批处理 SQL 语句,当它知道要初始化的代理数量时。
批处理抓取也适用于集合:
Path: Ch12/batch/src/main/java/com/manning/javapersistence/ch12/batch
➥ /Item.java
@Entity
public class Item {
@OneToMany(mappedBy = "item")
@org.hibernate.annotations.BatchSize(size = 5)
private Set<Bid> bids = new HashSet<>();
// . . .
}
如果我们现在强制初始化一个bids集合,如果它们在当前持久化上下文中未初始化,则最多可以立即加载五个更多的Item#bids集合:
Path: Ch12/batch/src/test/java/com/manning/javapersistence/ch12/batch
➥ /Batch.java
List<Item> items = em.createQuery("select i from Item i",
➥ Item.class).getResultList();
// select * from ITEM
for (Item item : items) {
assertTrue(item.getBids().size() > 0);
// select * from BID where ITEM_ID in (?, ?, ?, ?, ?)
}
当我们在迭代时第一次调用item.getBids().size()时,将为其他Item实例预加载整个Bid集合。
批处理抓取是一种简单且通常聪明的优化,可以显著减少初始化所有代理和集合所需的 SQL 语句数量。尽管我们可能会预取不需要的数据,并消耗更多内存,但减少数据库往返次数可以带来巨大的差异。内存很便宜,但扩展数据库服务器并不便宜。
另一种不是盲目猜测的预取算法使用子查询通过单个语句初始化许多集合。
12.2.4 使用子查询预取集合
加载多个Item实例的所有bids的一个潜在更好的策略是使用子查询进行预取。要启用此优化,请向集合映射添加一个 Fetch Hibernate 注解,并设置 SUBSELECT 参数:
Path: Ch12/subselect/src/main/java/com/manning/javapersistence/ch12
➥ /subselect/Item.java
@Entity
public class Item {
@OneToMany(mappedBy = "item")
@org.hibernate.annotations.Fetch(
org.hibernate.annotations.FetchMode.SUBSELECT
)
private Set<Bid> bids = new HashSet<>();
// . . .
}
一旦我们强制初始化一个bids集合,Hibernate 现在就会立即初始化所有已加载Item实例的所有bids集合:
Path: Ch12/subselect/src/test/java/com/manning/javapersistence/ch12
➥ /subselect/Subselect.java
List<Item> items = em.createQuery("select i from Item i",
➥ Item.class).getResultList();
// select * from ITEM
for (Item item : items) {
assertTrue(item.getBids().size() > 0);
// select * from BID where ITEM_ID in (
// select ID from ITEM
// )
}
Hibernate 会记住用于加载items的原始查询。然后,它在子查询中嵌入此初始查询(略有修改),检索每个Item的bids集合。
注意,作为子查询重新执行的原始查询只由 Hibernate 记忆在特定的持久化上下文中。如果我们没有初始化 bids 集合就分离一个 Item 实例,然后将其与一个新的持久化上下文合并并开始遍历集合,则不会预取其他集合。
如果在映射中坚持使用全局懒加载计划,批处理和子查询预取可以减少特定程序所需的查询数量,从而有助于缓解 n+1 查询问题。相反,如果你的全局加载计划已经预先加载了关联和集合,你必须避免笛卡尔积问题——例如,通过将 JOIN 查询分解为几个 SELECT。
12.2.5 使用多个 SELECT 的 eager fetching
当尝试使用一个 SQL 查询和 JOIN 来获取多个集合时,我们会遇到前面讨论过的笛卡尔积问题。我们可以告诉 Hibernate 使用额外的 SELECT 查询来懒加载数据,从而避免大型结果和重复的 SQL 积:
Path: Ch12/eagerselect/src/main/java/com/manning/javapersistence/ch12
➥ /eagerselect/Item.java
@Entity
public class Item {
@ManyToOne(fetch = FetchType.EAGER)
@org.hibernate.annotations.Fetch(
org.hibernate.annotations.FetchMode.SELECT Ⓐ
)
private User seller;
@OneToMany(mappedBy = "item", fetch = FetchType.EAGER)
@org.hibernate.annotations.Fetch(
org.hibernate.annotations.FetchMode.SELECT Ⓐ
)
private Set<Bid> bids = new HashSet<>();
// . . .
}
Ⓐ FetchMode.SELECT 表示属性应该懒加载。默认值是 FetchMode.JOIN,这意味着属性将通过 JOIN 查询被检索。
现在当加载 Item 时,seller 和 bids 也必须被加载:
Path: Ch12/eagerselect/src/test/java/com/manning/javapersistence/ch12
➥ /eagerselect/EagerSelect.java
Item item = em.find(Item.class, ITEM_ID); Ⓐ
// select * from ITEM where ID = ?
// select * from USERS where ID = ?
// select * from BID where ITEM_ID = ?
em.detach(item);
assertEquals(3, item.getBids().size()); Ⓑ
assertNotNull(item.getBids().iterator().next().getAmount()); Ⓑ
assertEquals("johndoe", item.getSeller().getUsername()); Ⓑ
Ⓐ Hibernate 使用一个 SELECT 从 ITEM 表中加载数据行。然后它立即执行另外两个 SELECT:一个从 USERS 表(卖家)中加载数据行,另一个从 BID 表(出价)中加载数据行。额外的 SELECT 查询不是懒加载执行的;find() 方法产生了多个 SQL 查询。
Ⓑ Hibernate 遵循了 eager fetch 计划;所有数据在分离状态下都是可用的。
然而,所有这些设置都是全局性的;它们始终处于激活状态。危险在于,为了解决应用中的一个特定问题而调整一个设置可能会对其他一些程序产生负面影响。保持这种平衡可能很困难,因此我们的建议是,如前所述,将每个实体关联和集合映射为 FetchType.LAZY。
更好的方法是动态地使用 eager fetching 和 JOIN 操作,仅在需要时,针对特定程序。
12.2.6 动态 eager fetching
如前几节所述,假设我们必须检查每个 Item#seller 的 username。在有懒加载全局计划的情况下,我们可以加载此程序所需的数据,并在查询中应用动态 eager 加载策略:
Path: Ch12/eagerselect/src/test/java/com/manning/javapersistence/ch12
➥ /eagerselect/EagerQueryUsers.java
List<Item> items =
em.createQuery("select i from Item i join fetch i.seller", Item.class)
.getResultList(); Ⓐ
// select i.*, u.*
// from ITEM i
// inner join USERS u on u.ID = i.SELLER_ID
// where i.ID = ?
em.close(); Ⓑ
for (Item item : items) {
assertNotNull(item.getSeller().getUsername()); Ⓒ
}
Ⓐ 在查询中应用动态 eager 策略。
Ⓑ 分离所有。
Ⓒ Hibernate 遵循了 eager fetch 计划;所有数据在分离状态下都是可用的。
在这个 JPQL 查询中,重要的关键字是 join fetch,告诉 Hibernate 使用 SQL JOIN(实际上是 INNER JOIN)在同一查询中检索每个 Item 的 seller。相同的查询可以用 CriteriaQuery API 来表达,而不是 JPQL 字符串:
Path: Ch12/eagerselect/src/test/java/com/manning/javapersistence/ch12
➥ /eagerselect/EagerQueryUsers.java
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Item> criteria = cb.createQuery(Item.class);
Root<Item> i = criteria.from(Item.class);
i.fetch("seller");
criteria.select(i);
List<Item> items = em.createQuery(criteria).getResultList(); Ⓐ
em.close(); Ⓑ
for (Item item : items) {
assertNotNull(item.getSeller().getUsername()); Ⓒ
}
Ⓐ 在使用 CriteriaQuery API 动态构建的查询中应用 eager 策略。
Ⓑ 分离所有。
Ⓒ Hibernate 遵循了 eager 获取计划;所有数据都在分离状态下可用。
动态 eager join 获取也适用于集合。在这里,我们加载每个 Item 的所有 bids:
Path: Ch12/eagerselect/src/test/java/com/manning/javapersistence/ch12
➥ /eagerselect/EagerQueryBids.java
List<Item> items =
em.createQuery("select i from Item i left join fetch i.bids",
➥ Item.class)
.getResultList(); Ⓐ
// select i.*, b.*
// from ITEM i
// left outer join BID b on b.ITEM_ID = i.ID
// where i.ID = ?
em.close(); Ⓑ
for (Item item : items) {
assertTrue(item.getBids().size() > 0); Ⓒ
}
Ⓐ 在查询中应用动态 eager 策略。
Ⓑ 分离所有。
Ⓒ Hibernate 遵循了 eager 获取计划;所有数据都在分离状态下可用。
现在我们用 CriteriaQuery API 做同样的事情:
Path: Ch12/eagerselect/src/test/java/com/manning/javapersistence/ch12
➥ /eagerselect/EagerQueryBids.java
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Item> criteria = cb.createQuery(Item.class);
Root<Item> i = criteria.from(Item.class);
i.fetch("bids", JoinType.LEFT);
criteria.select(i);
List<Item> items = em.createQuery(criteria).getResultList(); Ⓐ
em.close(); Ⓑ
for (Item item : items) {
assertTrue(item.getBids().size() > 0); Ⓒ
}
Ⓐ 在使用 CriteriaQuery API 动态构建的查询中应用 eager 策略。
Ⓑ 分离所有。
Ⓒ Hibernate 遵循了 eager 获取计划;所有数据都在分离状态下可用。
注意,对于集合获取,需要 LEFT OUTER JOIN,因为我们还希望在没有 bids 的情况下从 ITEM 表中获取行。
如果我们想动态地覆盖域模型的全局获取计划,手动编写查询并不是唯一的选择。我们可以声明性地编写 获取配置文件。
12.3 使用获取配置文件
获取配置文件补充了查询语言和 API 中的获取选项。它们允许我们将配置文件定义维护在 XML 或注解元数据中。早期 Hibernate 版本没有特殊获取配置文件的支持,但今天 Hibernate 支持以下内容:
-
获取配置文件——一个基于使用
@org.hibernate.annotations.FetchProfile声明配置文件并使用Session #enableFetchProfile()执行的专有 API。这种简单的机制目前支持有选择地覆盖延迟映射的实体关联和集合,为特定的工作单元启用JOINeager 获取策略。 -
实体图——在 JPA 2.1 中指定,我们可以使用
@EntityGraph注解声明实体属性和关联的图。这个获取计划,或计划的组合,可以在执行EntityManager #find()或查询(JPQL、criteria)时作为提示启用。提供的图控制应该加载什么;不幸的是,它不控制如何加载。
公平地说,这里还有改进的空间,我们期待 Hibernate 和 JPA 的未来版本提供统一且更强大的 API。
我们可以将 JPQL 和 SQL 语句外部化并将它们移动到元数据中。一个 JPQL 查询是一个声明性(命名)的获取配置文件;我们所缺少的是在相同的基查询上轻松叠加不同计划的的能力。我们已经看到了一些通过字符串操作实现的创造性解决方案,但最好避免使用。另一方面,使用条件查询,我们已经有 Java 的全部能力来组织查询构建代码。实体图的价值在于能够在任何类型的查询中重用获取计划。
让我们先谈谈 Hibernate 获取配置文件以及我们如何覆盖特定工作单元的全局延迟获取计划。
12.3.1 声明 Hibernate 获取配置文件
Hibernate 查询配置是全局元数据;它们为整个持久化单元声明。尽管我们可以在类上放置 @FetchProfile 注解,但我们更倾向于将其作为包级别的元数据放在 package-info.java 文件中:
Path: Ch12/profile/src/main/java/com/manning/javapersistence/ch12/profile
➥ /package-info.java
@org.hibernate.annotations.FetchProfiles({
@FetchProfile(name = Item.PROFILE_JOIN_SELLER, Ⓐ
fetchOverrides = @FetchProfile.FetchOverride( Ⓑ
entity = Item.class,
association = "seller",
mode = FetchMode.JOIN Ⓒ
)),
@FetchProfile(name = Item.PROFILE_JOIN_BIDS,
fetchOverrides = @FetchProfile.FetchOverride(
entity = Item.class,
association = "bids",
mode = FetchMode.JOIN
))
})
Ⓐ 每个配置文件都有一个名称。这是一个简单的字符串,被隔离在常量中。
Ⓑ 配置文件中的每个覆盖项命名一个实体关联或集合。
Ⓒ FetchMode.JOIN 表示属性将通过连接方式被积极检索。
现在可以为工作单元启用配置文件。我们需要 Hibernate API 来启用配置文件。这样,在该工作单元中的任何操作都会激活该配置文件。当使用此 EntityManager 加载 Item 时,Item#seller 可以通过相同的 SQL 语句通过连接查询来获取。
我们可以在相同的工作单元上覆盖另一个配置文件。在以下示例中,当加载 Item 时,Item#seller 和 Item#bids 集合将通过相同的 SQL 语句通过连接查询来获取。
Path: Ch12/profile/src/test/java/com/manning/javapersistence/ch12/profile
➥ /Profile.java
Item item = em.find(Item.class, ITEM_ID); Ⓐ
em.clear();
em.unwrap(Session.class).enableFetchProfile(Item.PROFILE_JOIN_SELLER); Ⓑ
item = em.find(Item.class, ITEM_ID);
em.clear();
em.unwrap(Session.class).enableFetchProfile(Item.PROFILE_JOIN_BIDS); Ⓒ
item = em.find(Item.class, ITEM_ID);
Ⓐ Item#seller 被映射为延迟加载,因此默认查询计划仅检索 Item 实例。
Ⓑ 当使用此 EntityManager 加载 Item 时,通过相同的 SQL 语句通过连接查询获取 Item#seller。
Ⓒ 当加载 Item 对象时,使用相同的 SQL 语句通过连接查询 Item#seller 和 Item#bids。
基本的 Hibernate 查询配置可以是一个在小型或简单应用中优化查询的简单解决方案。从 JPA 2.1 版本开始,引入了 实体图,以标准方式实现了类似的功能。
12.3.2 使用实体图
实体图是实体节点和属性的声明,在执行 EntityManager#find() 或在查询操作上放置提示时,它覆盖或增强了默认的查询计划。这是一个使用实体图进行检索操作的示例:
Path: Ch12/fetchloadgraph/src/test/java/com/manning/javapersistence/ch12
➥ /fetchloadgraph/FetchLoadGraph.java
Map<String, Object> properties = new HashMap<>();
properties.put(
"javax.persistence.loadgraph",
em.getEntityGraph(Item.class.getSimpleName()) Ⓐ
);
Item item = em.find(Item.class, ITEM_ID, properties);
// select * from ITEM where ID = ?
Ⓐ 我们正在使用的实体图名称是 Item,而 find() 操作的提示表明它应该是加载图。这意味着由实体图的属性节点指定的属性被视为 FetchType.EAGER,而没有指定的属性则根据映射中指定的或默认的 FetchType 处理。
以下代码显示了此图的声明和实体类的默认查询计划:
Path: Ch12/fetchloadgraph/src/main/java/com/manning/javapersistence/ch12
➥ /fetchloadgraph/Item.java
@NamedEntityGraphs({
@NamedEntityGraph Ⓐ
})
@Entity
public class Item {
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
private User seller;
@OneToMany(mappedBy = "item")
private Set<Bid> bids = new HashSet<>();
@ElementCollection
private Set<String> images = new HashSet<>();
// . . .
}
Ⓐ 元数据中的实体图有名称,并与实体类相关联;它们通常在实体类顶部使用注解声明。如果我们愿意,我们也可以将它们放在 XML 中。如果我们没有为实体图指定名称,它将获得其拥有实体类的简单名称,在这里是 Item。
如果我们在图中没有指定任何属性节点,就像前面示例中的空实体图一样,则使用实体类的默认值。在 Item 中,所有关联和集合都被映射为懒加载;这是默认的抓取计划。因此,我们迄今为止所做的工作影响不大,没有任何提示的 find() 操作将产生相同的结果:加载了 Item 实例,但 seller、bids 和 images 没有被加载。
或者,我们可以使用 API 构建一个实体图:
Path: Ch12/fetchloadgraph/src/test/java/com/manning/javapersistence/ch12
➥ /fetchloadgraph/FetchLoadGraph.java
EntityGraph<Item> itemGraph = em.createEntityGraph(Item.class);
Map<String, Object> properties = new HashMap<>();
properties.put("javax.persistence.loadgraph", itemGraph);
Item item = em.find(Item.class, ITEM_ID, properties);
这又是一个没有属性节点的空实体图,直接提供给检索操作。
假设我们想要编写一个实体图,当启用时,将 Item#seller 的懒加载默认值更改为主动抓取:
Path: Ch12/fetchloadgraph/src/main/java/com/manning/javapersistence/ch12
➥ /fetchloadgraph/Item.java
@NamedEntityGraphs({
@NamedEntityGraph(
name = "ItemSeller",
attributeNodes = {
@NamedAttributeNode("seller")
}
)
})
@Entity
public class Item {
// . . .
}
现在当我们要将 Item 和 seller 主动加载时,我们可以通过名称启用此图:
Path: Ch12/fetchloadgraph/src/test/java/com/manning/javapersistence/ch12
➥ /fetchloadgraph/FetchLoadGraph.java
Map<String, Object> properties = new HashMap<>();
properties.put(
"javax.persistence.loadgraph",
em.getEntityGraph("ItemSeller")
);
Item item = em.find(Item.class, ITEM_ID, properties);
// select i.*, u.*
// from ITEM i
// inner join USERS u on u.ID = i.SELLER_ID
// where i.ID = ?
如果我们不想在注解中硬编码图,我们可以使用 API 来构建它:
Path: Ch12/fetchloadgraph/src/test/java/com/manning/javapersistence/ch12
➥ /fetchloadgraph/FetchLoadGraph.java
EntityGraph<Item> itemGraph = em.createEntityGraph(Item.class);
itemGraph.addAttributeNodes(Item_.seller); Ⓐ
Map<String, Object> properties = new HashMap<>();
properties.put("javax.persistence.loadgraph", itemGraph);
Item item = em.find(Item.class, ITEM_ID, properties);
// select i.*, u.*
// from ITEM i
// inner join USERS u on u.ID = i.SELLER_ID
// where i.ID = ?
Ⓐ Item_ 类属于静态元模型。它通过在项目中包含 Hibernate JPA2 Metamodel Generator 依赖项自动生成。有关更多详细信息,请参阅第 3.3.4 节。
到目前为止,我们只看到了 find() 操作的属性。实体图也可以作为提示启用查询。
Path: Ch12/fetchloadgraph/src/test/java/com/manning/javapersistence/ch12
➥ /fetchloadgraph/FetchLoadGraph.java
List<Item> items =
em.createQuery("select i from Item i", Item.class)
.setHint("javax.persistence.loadgraph", itemGraph)
.getResultList();
// select i.*, u.*
// from ITEM i
// left outer join USERS u on u.ID = i.SELLER_ID
实体图可能很复杂。以下声明显示了如何使用可重用的子图声明:
Path: Ch12/fetchloadgraph/src/main/java/com/manning/javapersistence/ch12
➥ /fetchloadgraph/Bid.java
@NamedEntityGraphs({
@NamedEntityGraph(
name = "BidBidderItemSellerBids",
attributeNodes = {
@NamedAttributeNode(value = "bidder"),
@NamedAttributeNode(
value = "item",
subgraph = "ItemSellerBids"
)
},
subgraphs = {
@NamedSubgraph(
name = "ItemSellerBids",
attributeNodes = {
@NamedAttributeNode("seller"),
@NamedAttributeNode("bids")
})
}
)
})
@Entity
public class Bid {
// . . .
}
当作为加载图启用以检索 Bid 实例时,此实体图还会触发 Bid#bidder、Bid#item 的主动抓取,以及进一步触发 Item#seller 和所有 Item#bids 的主动抓取。尽管您可以自由地以任何方式命名您的实体图,但我们建议您开发一个团队中每个人都可以遵循的约定,并将字符串移动到共享常量中。
使用实体图 API,之前的计划看起来是这样的:
Path: Ch12/fetchloadgraph/src/test/java/com/manning/javapersistence/ch12
➥ /fetchloadgraph/FetchLoadGraph.java
EntityGraph<Bid> bidGraph = em.createEntityGraph(Bid.class);
bidGraph.addAttributeNodes(Bid_.bidder, Bid_.item);
Subgraph<Item> itemGraph = bidGraph.addSubgraph(Bid_.item);
itemGraph.addAttributeNodes(Item_.seller, Item_.bids);
Map<String, Object> properties = new HashMap<>();
properties.put("javax.persistence.loadgraph", bidGraph);
Bid bid = em.find(Bid.class, BID_ID, properties);
到目前为止,我们只看到了实体图作为 加载图。还有一个选项:我们可以使用 javax.persistence.fetchgraph 提示启用实体图作为 抓取图。如果我们使用抓取图执行 find() 或查询操作,不在计划中的任何属性和集合将被设置为 FetchType.LAZY,而计划中的任何节点将被设置为 FetchType.EAGER。这实际上忽略了实体属性和集合映射中所有的 FetchType 设置。
JPA 实体图操作有两个弱点值得提及,因为您很快就会遇到。首先,您只能修改抓取计划,而不能修改 Hibernate 抓取策略(批量/子选择/连接/选择)。其次,在注解或 XML 中声明实体图并不完全类型安全:属性名是字符串。至少 EntityGraph API 是类型安全的。
摘要
-
抓取配置将抓取计划(标识应加载哪些数据)与抓取策略(如何加载数据)结合起来,封装在可重用的元数据或代码中。
-
您可以创建一个全局抓取计划,并定义哪些关联和集合应该始终加载到内存中。
-
您可以根据用例定义获取计划,包括如何访问关联实体以及在应用中遍历集合,以及哪些数据应在分离状态下可用。
-
您可以为获取计划选择合适的获取策略。目标是尽量减少必须执行的 SQL 语句的数量和每个 SQL 语句的复杂性。
-
您可以使用获取策略,特别是为了避免n+1 次选择和笛卡尔积问题。
13 过滤数据
本章涵盖
-
级联状态转换
-
监听和拦截事件
-
使用 Hibernate Envers 进行审计和版本控制
-
动态过滤数据
在本章中,我们将分析许多不同的策略,用于在数据通过 Hibernate 引擎时进行过滤。当 Hibernate 从数据库加载数据时,我们可以通过过滤器透明地限制应用程序看到的数据。当 Hibernate 将数据存储在数据库中时,我们可以监听事件并执行辅助程序:例如,我们可以编写审计日志或将租户标识符分配给记录。
在本章的四个主要部分中,我们将探讨以下数据过滤功能和 API:
-
首先,您将学习如何对实体实例的状态变化做出反应,并将状态变化级联到相关实体。例如,当
User被保存时,Hibernate 可以传递性和自动保存所有相关的BillingDetails。当Item被删除时,Hibernate 可以删除与该Item相关联的所有Bid实例。我们可以通过在实体关联和集合映射中使用特殊属性来启用此标准 JPA 功能。 -
Jakarta Persistence 标准包括生命周期回调和事件监听器。事件监听器是我们编写的具有特殊方法的类,当实体实例的状态发生变化时(例如,Hibernate 加载它或即将从数据库中删除它时),Hibernate 会调用这些方法。这些回调方法也可以在实体类上,并带有特殊注解。这为我们提供了一个在状态转换发生时执行自定义副作用的机会。Hibernate 还具有几个专有扩展点,允许在引擎内部较低级别拦截生命周期事件。
-
一个常见的副作用是编写审计日志;这样的日志通常包含有关更改的数据、何时进行更改以及谁进行了修改的信息。一个更复杂的审计系统可能需要存储多个数据版本和时间视图;例如,我们可能希望要求 Hibernate 加载数据“就像上周那样。”这是一个复杂的问题,我们将介绍 Hibernate Envers,这是一个专门用于 JPA 应用程序版本控制和审计的子项目。
-
最后,我们将检查数据过滤器,这些过滤器也作为专有 Hibernate API 提供。这些过滤器向 Hibernate 执行的 SQL
SELECT语句添加自定义限制。因此,我们可以在应用层有效地定义数据的自定义受限视图。例如,我们可以应用一个过滤器,限制按销售区域或其他授权标准加载的数据。
我们将从用于传递性状态变化的级联选项开始。
注意 要能够执行源代码中的示例,您首先需要运行 Ch13.sql 脚本。
13.1 级联状态转换
当实体实例的状态发生变化——例如从临时状态变为持久状态时——关联的实体实例也可能包含在这个状态转换中。这种状态转换的级联默认情况下是未启用的;每个实体实例都有独立的生命周期。但对于实体之间的某些关联,我们可能希望实现细粒度的生命周期依赖。
例如,在第 8.3 节中,我们创建了 Item 和 Bid 实体类之间的关联。在这种情况下,不仅当它们被添加到 Item 时,Bid 的出价会自动持久化,而且当拥有 Item 被删除时,它们也会自动删除。我们实际上使 Bid 成为一个依赖于另一个实体 Item 的实体类。
在那个关联映射中启用的级联设置是 CascadeType .PERSIST 和 CascadeType.REMOVE。我们还研究了特殊的 orphanRemoval 开关以及数据库级别(使用外键 ON DELETE 选项)的级联删除如何影响应用程序。
因此,我们在第八章中简要介绍了如何处理 级联 状态。在本节中,我们将分析一些其他较少使用的级联选项。
13.1.1 可用的级联选项
表 13.1 总结了 Hibernate 中可用的最重要的级联选项。注意每个选项是如何与 EntityManager 或 Session 操作相关联的。
表 13.1 实体关联映射的级联选项
| 选项 | 描述 |
|---|---|
CascadeType.PERSIST |
当一个实体实例使用 EntityManager #persist() 存储时,在刷新时间任何关联的实体实例也会被设置为持久状态。 |
CascadeType.REMOVE |
当一个实体实例使用 EntityManager #remove() 删除时,在刷新时间任何关联的实体实例也会被删除。 |
CascadeType.DETACH |
当一个实体实例使用 EntityManager#detach() 从持久上下文中移除时,任何关联的实体实例也会被分离。 |
CascadeType.MERGE |
当一个临时或分离的实体实例使用 EntityManager#merge() 合并到持久上下文时,任何关联的临时或分离的实体实例也会被合并。 |
CascadeType.REFRESH |
当一个持久实体实例使用 EntityManager#refresh() 刷新时,任何关联的持久实体实例也会被刷新。 |
CascadeType.REPLICATE |
当一个分离的实体实例使用 Session#replicate() 复制到数据库中时,任何关联的分离实体实例也会被复制。 |
CascadeType.ALL |
这是一个简写,用于启用映射关联的所有级联选项。 |
在 org.hibernate.annotations .CascadeType 枚举中定义了更多的级联选项。然而,今天唯一有趣的选择是 REPLICATE 和 Session#replicate() 操作。所有其他 Session 操作都有在 EntityManager API 上的标准化等效或替代方案,因此我们可以忽略这些设置。
我们已经检查了 PERSIST 和 REMOVE 选项。让我们分析传递性分离、合并、刷新和复制。
13.1.2 传递性分离和合并
我们希望从数据库中检索 Item 和其 bids 并在分离状态下处理这些数据。Bid 类使用 @ManyToOne 映射这个关联。它与 Item 中的 @OneToMany 集合映射是双向的:
Path: Ch13/cascade/src/main/java/com/manning/javapersistence/ch13/filtering
➥ /cascade/Item.java
\1
public class Item {
@OneToMany(
mappedBy = "item",
cascade = {CascadeType.DETACH, CascadeType.MERGE}
)
private Set<Bid> bids = new HashSet<>();
// . . .
}
传递性分离和合并通过 DETACH 和 MERGE 级联类型启用。现在我们可以加载 Item 并初始化其 bids 集合:
Path: Ch13/cascade/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Cascade.java
Item item = em.find(Item.class, ITEM_ID);
assertEquals(2, item.getBids().size()); Ⓐ
em.detach(item); Ⓑ
Ⓐ 访问 item.getBids() 初始化了 bids 集合(它是延迟初始化的)。
Ⓑ EntityManager#detach() 操作是级联的:它将 Item 实例以及集合中的所有 bids 从持久化上下文中清除。如果 bids 没有被加载,它们不会被分离。(当然,我们也可以关闭持久化上下文,从而有效地分离 所有 加载的实体实例。)
在分离状态下,我们可以更改 Item#name,创建一个新的 Bid 并将其与 Item 相关联:
Path: Ch13/cascade/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Cascade.java
item.setName("New Name");
Bid bid = new Bid(new BigDecimal("101.00"), item);
item.addBid(bid);
由于我们正在处理分离的实体状态和集合,我们必须特别注意身份和相等性。如第 10.3 节所述,我们应该在 Bid 实体类上重写 equals() 和 hashCode() 方法:
Path: Ch13/cascade/src/main/java/com/manning/javapersistence/ch13/filtering
➥ /cascade/Bid.java
@Entity
public class Bid {
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o instanceof Bid bid) {
return Objects.equals(id, bid.id) &&
Objects.equals(amount, bid.amount) &&
Objects.equals(item, bid.item);
}
return false;
}
@Override
public int hashCode() {
return Objects.hash(id, amount, item);
}
}
两个 Bid 实例在它们具有相同的 id、相同的 amount 以及与相同的 Item 相关联时是 相等的。
一旦我们在分离状态下完成修改,下一步就是存储更改。使用一个新的持久化上下文,我们可以合并分离的 Item 并让 Hibernate 检测更改。
使用 merge 方法,Hibernate 将合并一个分离实例。首先,它检查持久化上下文中是否已经包含具有给定标识符值的实体。如果没有,则从数据库中加载实体。Hibernate 足够智能,知道在合并过程中它还需要引用的实体,因此它会立即在同一 SQL 查询中获取它们。
当刷新持久化上下文时,Hibernate 检测实体在合并过程中是否有某些属性发生变化。引用的实体也可能被存储:
Path: Ch13/cascade/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Cascade.java
Item mergedItem = em.merge(item); Ⓐ
// select i.*, b.*
// from ITEM i
// left outer join BID b on i.ID = b.ITEM_ID
// where i.ID = ?
for (Bid b : mergedItem.getBids()) { Ⓑ
assertNotNull(b.getId());
}
em.flush(); Ⓒ
// update ITEM set NAME = ? where ID = ?
// insert into BID values (?, ?, ?, . . . )
Ⓐ Hibernate 合并了分离的 item。没有具有给定标识符值的 Item,因此从数据库中加载了 Item。Hibernate 在合并过程中通过相同的 SQL 查询获取 bids。然后,Hibernate 将分离的 item 值复制到加载的实例中,并将其以持久状态返回给我们。相同的程序应用于每个 Bid,Hibernate 将检测到其中一个 bids 是新的。
Ⓑ 在合并过程中,Hibernate 使新的 Bid 持久化。现在它已经分配了一个标识符值。
Ⓒ 当我们刷新持久化上下文时,Hibernate 检测到在合并过程中 Item 的 name 发生了变化。新的 Bid 也将被存储。
使用集合进行级联合并是一个强大的功能;考虑如果没有 Hibernate,我们需要编写多少代码来实现这个功能。
合并时积极获取关联
在 13.1.2 节中的最后一个代码示例中,我们说 Hibernate 足够智能,能够在合并分离的Item时加载Item#bids集合。当关联启用了CascadeType.MERGE时,Hibernate 总是积极使用JOIN加载实体关联。在上述情况下,这是聪明的,因为Item#bids已经被初始化、分离并修改。当使用JOIN合并时,加载集合是必要且最优的,但如果我们将一个未初始化的bids集合或未初始化的seller代理的Item实例合并,Hibernate 将在合并时使用JOIN获取集合和代理。合并初始化返回的管理的Item上的这些关联。CascadeType.MERGE导致 Hibernate 忽略并有效地覆盖任何FetchType.LAZY映射(如 JPA 规范允许的)。
我们接下来的示例更为简单,允许级联刷新相关实体。
13.1.3 级联刷新
User实体类与BillingDetails存在一对一的关系:应用程序的每个用户可能有几张信用卡、银行账户等。如果您想查看BillingDetails类,请查看第七章中的映射。
我们可以将User和BillingDetails之间的关系映射为一个单向的一对多实体关联:
Path: Ch13/cascade/src/main/java/com/manning/javapersistence/ch13/filtering
➥ /cascade/User.java
@Entity
@Table(name = "USERS")
public class User {
@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REFRESH})
@JoinColumn(name = "USER_ID", nullable = false)
private Set<BillingDetails> billingDetails = new HashSet<>();
// . . .
}
为此关联启用的级联选项是PERSIST和REFRESH。PERSIST选项简化了存储账单详情;当我们向已持久化的User集合中添加BillingDetails实例时,它们变得持久化。
REFRESH级联选项确保当我们重新加载User实例的状态时,Hibernate 也会刷新与User关联的每个BillingDetails实例的状态。例如,当我们refresh()管理的User实例时,Hibernate 会将操作级联到管理的BillingDetails,并使用 SQL SELECT刷新每个实例。如果这些实例中没有任何一个留在数据库中,Hibernate 将抛出EntityNotFoundException。然后,Hibernate 刷新User实例,并积极加载整个billingDetails集合以发现任何新的BillingDetails:
Path: Ch13/cascade/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Cascade.java
User user = em.find(User.class, USER_ID); Ⓐ
assertEquals(2, user.getBillingDetails().size()); Ⓑ
for (BillingDetails bd : user.getBillingDetails()) {
assertEquals("John Doe", bd.getOwner());
}
// Someone modifies the billing information in the database!
em.refresh(user); Ⓒ
// select * from CREDITCARD join BILLINGDETAILS where ID = ?
// select * from BANKACCOUNT join BILLINGDETAILS where ID = ?
// select * from USERS
// left outer join BILLINGDETAILS
// left outer join CREDITCARD
// left outer JOIN BANKACCOUNT
// where ID = ?
for (BillingDetails bd : user.getBillingDetails()) {
assertEquals("Doe John", bd.getOwner());
}
Ⓐ 从数据库中加载User实例。
Ⓑ 当我们遍历元素或调用size()时,它的懒加载billingDetails集合会被初始化。
Ⓒ 当我们refresh()管理的User实例时,Hibernate 会将操作级联到管理的BillingDetails,并使用 SQL SELECT刷新每个实例。
这是一个 Hibernate 不够聪明的例子。首先,它为持久化上下文中每个由集合引用的 BillingDetails 实例执行一个 SQL SELECT。然后它再次加载整个集合以查找任何添加的 BillingDetails。Hibernate 显然可以用一个 SELECT 来完成这个操作。
我们希望在另一个事务修改记录后刷新记录,因此我们必须记住 MySQL 的默认事务隔离级别是 REPEATABLE_READ,而大多数其他数据库是 READ_COMMITTED。我们启动了一个事务,然后启动了第二个事务,在第一个事务执行刷新操作之前,第二个事务提交了其更改。为了使第一个事务能够看到第二个事务的更改,我们需要更改 JDBC 驱动程序上的隔离级别。这就是为什么我们提供了以下配置 URL:
Path: Ch13/cascade/src/main/resources/META-INF/persistence.xml
<property name="javax.persistence.jdbc.url"
value="jdbc:mysql://localhost:3306/CH13_CASCADE?
➥ sessionVariables=transaction_isolation=
➥ 'READ-COMMITTED'&serverTimezone=UTC"/>
由于更改仅限于配置级别,只要 persistence.xml 文件包含正确的配置,代码将继续在不同的数据库上正确工作。
最后的级联选项是针对仅 Hibernate 的 replicate() 操作。
13.1.4 级联复制
我们在 10.2.7 节中首先检查了复制。这个非标准操作在 Hibernate Session API 中可用。其主要用例是将数据从一个数据库复制到另一个数据库。
考虑 Item 和 User 之间的多对一实体关联映射:
Path: Ch13/cascade/src/main/java/com/manning/javapersistence/ch13/filtering
➥ /cascade/Item.java
@Entity
public class Item {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "SELLER_ID", nullable = false)
@org.hibernate.annotations.Cascade(
org.hibernate.annotations.CascadeType.REPLICATE
)
private User seller;
// . . .
}
在这里,我们使用 Hibernate 注解启用 REPLICATE 级联选项。接下来,我们将从源数据库加载 Item 和其 seller:
Path: Ch13/cascade/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Cascade.java
em = emf.createEntityManager();
em.getTransaction().begin();
Item item = em.find(Item.class, ITEM_ID);
assertNotNull(item.getSeller().getUsername()); Ⓐ
em.getTransaction().commit();
em.close();
Ⓐ 懒加载 Item#seller。
在我们关闭持久化上下文后,Item 和 User 实体实例处于分离状态。接下来,我们连接到数据库并写入分离的数据:
Path: Ch13/cascade/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Cascade.java
EntityManager otherDatabase = // . . . get EntityManager
otherDatabase.getTransaction().begin();
otherDatabase.unwrap(Session.class)
.replicate(item, ReplicationMode.OVERWRITE);
// select ID from ITEM where ID = ?
// select ID from USERS where ID = ?
otherDatabase.getTransaction().commit();
// update ITEM set NAME = ?, SELLER_ID = ?, . . . where ID = ?
// update USERS set USERNAME = ?, . . . where ID = ?
otherDatabase.close();
当我们在分离的 Item 上调用 replicate() 时,Hibernate 执行 SQL SELECT 语句以确定 Item 和其 seller 是否已经在数据库中存在。然后,在提交时,当持久化上下文被刷新,Hibernate 将 Item 和 seller 的值写入目标数据库。在先前的例子中,这些行已经存在,因此我们会看到每个的 UPDATE 操作,覆盖数据库中的值。如果目标数据库不包含 Item 或 User,则进行两个 INSERT 操作。
13.2 监听和拦截事件
在本节中,我们将分析 JPA 和 Hibernate 中可用的三个不同的自定义事件监听器和持久化生命周期拦截器的 API。它们允许我们做几件事情:
-
使用标准的 JPA 生命周期回调方法和事件监听器。
-
编写一个专有的
org.hibernate.Interceptor并在Session上激活它。 -
使用 Hibernate 核心引擎的扩展点与
org.hibernate.event服务提供程序接口(SPI)。
让我们从标准的 JPA 回调开始。它们提供了对持久化、加载和删除生命周期事件的简单访问。
13.2.1 JPA 事件监听器和回调
假设我们想在存储新的实体实例时记录一条消息。实体监听器类必须有一个隐式或显式的无参数公共构造函数。它不需要实现任何特殊接口。实体监听器是无状态的;JPA 引擎会自动创建和销毁它。这意味着当我们需要更多上下文信息时可能会很困难,但我们将演示一些可能性。
首先,我们将编写一个生命周期事件监听器,其中包含一个带有@PostPersist注解的回调方法,如下所示。我们可以将实体监听器类的任何方法注解为持久化生命周期事件的回调方法。
列表 13.1 当实体实例被存储时通知管理员
Path: Ch13/callback/src/main/java/com/manning/javapersistence/ch13
➥ /filtering/callback/PersistEntityListener.java
public class PersistEntityListener {
@PostPersist Ⓐ
public void logMessage(Object entityInstance) {
User currentUser = CurrentUser.INSTANCE.get(); Ⓑ
Log log = Log.INSTANCE;
log.save(
"Entity instance persisted by "
+ currentUser.getUsername()
+ ": "
+ entityInstance
);
}
}
Ⓐ 带有@PostPersist注解的logMessage()方法,在新的实体实例存储到数据库后会被调用。
Ⓑ 我们需要当前登录用户的上下文信息和访问日志信息。一个原始的解决方案是使用线程局部变量和单例;CurrentUser和Log的来源在示例代码中。
实体监听器类的回调方法有一个单一的Object参数:参与状态变化的实体实例。如果我们只为特定实体类型启用回调,我们可以声明该参数为该特定类型。回调方法可以有任何类型的访问权限;它不必是公开的。它不能是静态的或最终的,并且不返回任何值。如果回调方法抛出未检查的RuntimeException,Hibernate 将中止操作并将当前事务标记为回滚。如果回调方法声明并抛出检查的Exception,Hibernate 将将其包装并作为RuntimeException处理。
我们只能在实体监听器类中使用每个回调注解一次;也就是说,只能有一个方法被注解为@PostPersist。表 13.2 总结了所有可用的回调注解。
表 13.2 生命周期回调注解
| 注解 | 描述 |
|---|---|
@PostLoad |
在实体实例被加载到持久化上下文之后触发,无论是通过标识符查找、通过导航和代理/集合初始化,还是通过查询。在刷新已持久化的实例后也会触发。 |
@PrePersist |
在对实体实例调用persist()时立即调用。如果实体被发现为瞬态,并且在瞬态状态被复制到持久实例之后调用merge(),也会调用。如果我们启用了CascadeType.PERSIST,也会为关联实体调用。 |
@PostPersist |
在执行使实体实例持久化的数据库操作并分配标识符值之后调用。这可能是在调用persist()或merge()时,或者在持久化上下文刷新时(如果标识符生成器是预插入的,见第 5.2.5 节)。如果启用了CascadeType.PERSIST,也会为关联实体调用。 |
@PreUpdate, @PostUpdate |
在持久化上下文与数据库同步之前和之后执行;即,在刷新之前和之后。仅在实体状态需要同步时触发(例如,因为它被认为是脏的)。 |
@PreRemove, @PostRemove |
在调用remove()或实体实例通过级联被移除时触发,以及在持久化上下文刷新后数据库中记录的删除操作之后。 |
实体监听器类必须对任何我们想要拦截的实体启用,例如这个Item:
Path: Ch13/callback/src/main/java/com/manning/javapersistence/ch13
➥ /filtering/callback/Item.java
@Entity
@EntityListeners(
PersistEntityListener.class
)
public class Item {
// . . .
}
@EntityListeners注解接受一个监听器类数组,如果我们有多个拦截器。如果有多个监听器为同一事件定义了回调方法,Hibernate 将按声明的顺序调用监听器。
我们不需要编写一个单独的实体监听器类来拦截生命周期事件。例如,我们可以在User实体类上实现logMessage()方法:
Path: Ch13/callback/src/main/java/com/manning/javapersistence/ch13
➥ /filtering/callback/User.java
@Entity
@Table(name = "USERS")
public class User {
@PostPersist
public void logMessage(){
User currentUser = CurrentUser.INSTANCE.get();
Log log = Log.INSTANCE;
log.save(
"Entity instance persisted by "
+ currentUser.getUsername()
+ ": "
+ this
);
}
// . . .
}
注意,实体类上的回调方法没有任何参数:参与状态变化的“当前”实体是this。在单个类中不允许对同一事件进行重复回调,但我们可以通过多个监听器类或监听器和实体类中的回调方法来拦截同一事件。
我们还可以为整个层次结构中的实体超类添加回调方法。如果我们想禁用特定实体子类的超类回调,我们可以用@ExcludeSuperclassListeners注解该子类。如果我们想禁用特定实体的默认实体监听器,我们可以用@ExcludeDefaultListeners注解标记它:
Path: Ch13/callback/src/main/java/com/manning/javapersistence/ch13
➥ /filtering/callback/User.java
@Entity
@Table(name = "USERS")
@ExcludeDefaultListeners
public class User {
// . . .
}
JPA 事件监听器和回调提供了一个基本的框架,用于使用我们自己的程序来响应生命周期事件。Hibernate 还有一个更细粒度和更强大的替代 API:org.hibernate.Interceptor。
13.2.2 实现 Hibernate 拦截器
假设我们想在单独的数据库表中记录数据修改的审计日志。例如,我们可能想记录每个Item的创建和更新事件的信息。审计日志包括用户、事件的时间和日期、发生的事件类型以及被更改的Item的标识符。
审计日志通常使用数据库触发器来处理。另一方面,有时让应用程序负责会更好,特别是如果需要在不同的数据库之间进行移植时。
我们需要几个元素来实现审计日志。首先,我们必须标记我们想要启用审计日志的实体类。接下来,我们定义要记录的信息,例如用户、日期、时间和修改类型。最后,我们将所有这些通过一个自动创建审计跟踪的org.hibernate.Interceptor结合起来。
首先,我们将创建一个标记接口,Auditable:
Path: Ch13/interceptor/src/main/java/com/manning/javapersistence/ch13
➥ /filtering/interceptor/Auditable.java
public interface Auditable {
Long getId();
}
此接口要求持久化实体类通过 getter 方法公开其标识符;我们需要这个属性来记录审计跟踪。为特定的持久化类启用审计日志记录是微不足道的。我们将它添加到类声明中,例如对于Item:
Path: /model/src/main/java/org/jpwh/model/filtering/interceptor/Item.java
@Entity
public class Item implements Auditable {
// . . .
}
现在我们可以创建一个新的持久化实体类,AuditLogRecord,其中包含我们想要在审计数据库表中记录的信息:
Path: Ch13/interceptor/src/main/java/com/manning/javapersistence/ch13
➥ /filtering/interceptor/AuditLogRecord.java
@Entity
public class AuditLogRecord {
@Id
@GeneratedValue(generator = Constants.ID_GENERATOR)
private Long id;
@NotNull
private String message;
@NotNull
private Long entityId;
@NotNull
private Class<? extends Auditable> entityClass;
@NotNull
private Long userId;
@NotNull
private LocalDateTime createdOn = LocalDateTime.now();
// . . .
}
我们希望在 Hibernate 在数据库中插入或更新Item时存储AuditLogRecord的实例。Hibernate 拦截器可以自动处理此操作。我们不需要在org.hibernate.Interceptor中实现所有方法,而是扩展EmptyInterceptor并仅覆盖我们需要的那些方法,如列表 13.2 所示。我们需要访问数据库来写入审计日志,因此拦截器需要一个 Hibernate Session。
我们还希望在每条审计日志记录中存储当前登录用户的标识符。我们将声明的inserts和updates实例变量将是这个拦截器存储其内部状态集合的地方。
列表 13.2 Hibernate 拦截器记录修改事件
Path: Ch13/interceptor/src/test/java/com/manning/javapersistence/ch13
➥ /filtering/AuditLogInterceptor.java
public class AuditLogInterceptor extends EmptyInterceptor {
private Session currentSession;
private Long currentUserId;
private Set<Auditable> inserts = new HashSet<>();
private Set<Auditable> updates = new HashSet<>();
public void setCurrentSession(Session session) {
this.currentSession = session;
}
public void setCurrentUserId(Long currentUserId) {
this.currentUserId = currentUserId;
}
public boolean onSave(Object entity, Serializable id, Ⓐ
Object[] state, String[] propertyNames,
Type[] types)
throws CallbackException {
if (entity instanceof Auditable aud) {
inserts.add(aud);
}
return false; Ⓑ
}
public boolean onFlushDirty(Object entity, Serializable id, Ⓒ
Object[] currentState,
Object[] previousState,
String[] propertyNames, Type[] types)
throws CallbackException {
if (entity instanceof Auditable aud) {
updates.add(aud);
}
return false; Ⓓ
}
// . . .
}
Ⓐ 当实体实例被持久化时,将调用此方法。
Ⓑ 状态没有被修改。
Ⓒ 当在持久化上下文刷新期间检测到实体实例为脏时,将调用此方法。
Ⓓ 当前状态currentState没有被修改。
拦截器收集inserts和updates中的修改Auditable实例。请注意,在onSave()中,可能没有分配标识符值给给定的实体实例。Hibernate 保证在刷新期间设置实体标识符,因此实际的审计日志轨迹是在postFlush()回调中编写的,该回调在列表 13.2 中没有显示。此方法在持久化上下文刷新完成后被调用。
现在,我们将编写我们之前收集的所有插入和更新的审计日志记录:
Path: Ch13/interceptor/src/test/java/com/manning/javapersistence/ch13
➥ /filtering/AuditLogInterceptor.java
public class AuditLogInterceptor extends EmptyInterceptor {
// . . .
public void postFlush(@SuppressWarnings("rawtypes") Iterator iterator)
➥ throws CallbackException {
Session tempSession = Ⓐ
currentSession.sessionWithOptions()
.connection()
.openSession();
try {
for (Auditable entity : inserts) { Ⓑ
tempSession.persist(
new AuditLogRecord("insert", entity, currentUserId)
);
}
for (Auditable entity : updates) {
tempSession.persist(
new AuditLogRecord("update", entity, currentUserId)
);
}
tempSession.flush(); Ⓒ
} finally {
tempSession.close();
inserts.clear();
updates.clear();
}
}
}
Ⓐ 我们无法访问原始持久化上下文——当前执行此拦截器的Session。在拦截器调用期间,Session处于脆弱状态。Hibernate 允许我们使用sessionWithOptions()方法创建一个新的Session,该Session从原始Session继承一些信息。新的临时Session与原始Session使用相同的事务和数据库连接。
Ⓑ 我们使用临时Session为每个插入和更新存储一个新的AuditLogRecord。
Ⓒ 我们独立于原始Session刷新和关闭临时Session。
我们现在可以启用此拦截器:
Path: Ch13/interceptor/src/test/java/com/manning/javapersistence/ch13
➥ /filtering/AuditLogging.java
EntityManager em = emf.createEntityManager();
SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
Session session = sessionFactory.withOptions().
interceptor(new AuditLogInterceptor()).openSession();
启用默认拦截器
如果我们想要为任何EntityManager默认启用拦截器,我们可以将hibernate.ejb.interceptor属性在 persistence.xml 中设置为一个实现org.hibernate.Interceptor的类。请注意,与会话作用域的拦截器不同,Hibernate 共享这个默认拦截器,因此它必须是线程安全的!示例AuditLogInterceptor不是线程安全的。
现在,这个Session已经启用了AuditLogInterceptor,但拦截器还必须配置为当前的Session和登录用户标识符。这涉及到一些类型转换以访问 Hibernate API:
Path: Ch13/interceptor/src/test/java/com/manning/javapersistence/ch13
➥ /filtering/AuditLogging.java
AuditLogInterceptor interceptor =
(AuditLogInterceptor) ((SessionImplementor) session).getInterceptor();
interceptor.setCurrentSession(session);
interceptor.setCurrentUserId(CURRENT_USER_ID);
现在Session已经准备好使用,并且每次我们使用它存储或修改Item实例时,都会写入一个审计跟踪。
Hibernate 拦截器是灵活的,并且与 JPA 事件监听器和回调方法不同,当事件发生时,我们能够访问更多上下文信息。话虽如此,Hibernate 允许我们通过其基于可扩展事件系统的核心事件系统进一步深入。
13.2.3 核心事件系统
Hibernate 的核心引擎基于事件和监听器的模型。例如,如果 Hibernate 需要保存一个实体实例,它会触发一个事件。任何监听此类事件的人都可以捕获它并处理数据的保存。因此,Hibernate 通过一组默认监听器实现了其所有核心功能,这些监听器可以处理所有 Hibernate 事件。
Hibernate 的设计是开放的:我们可以为 Hibernate 事件编写和启用自己的监听器。我们可以替换现有的默认监听器或扩展它们以执行副作用或附加程序。替换事件监听器是罕见的;这样做意味着我们的监听器实现可以处理 Hibernate 的核心功能的一部分。
实质上,Session接口(及其更窄的表亲EntityManager)的所有方法都与一个事件相关联。find()和load()方法触发一个LoadEvent,默认情况下,此事件由DefaultLoadEventListener处理。
自定义监听器应实现它想要处理的事件的适当接口,并/或扩展 Hibernate 提供的便利基类之一,或任何默认事件监听器。以下是一个自定义加载事件监听器的示例。
列表 13.3 自定义加载事件监听器
Path: Ch13/interceptor/src/test/java/com/manning/javapersistence/ch13
➥ /filtering/SecurityLoadListener.java
public class SecurityLoadListener extends DefaultLoadEventListener {
public void onLoad(LoadEvent event, LoadType loadType)
throws HibernateException {
boolean authorized =
MySecurity.isAuthorized(
event.getEntityClassName(), event.getEntityId()
);
if (!authorized) {
throw new MySecurityException("Unauthorized access");
}
super.onLoad(event, loadType);
}
}
此监听器执行自定义授权代码。应将监听器视为一个单例,这意味着它在持久化上下文中是共享的,因此不应将任何与事务相关的状态作为实例变量保存。有关原生 Hibernate 中所有事件和监听器接口的列表,请参阅org.hibernate.event包的 API Javadoc。
我们在 persistence.xml 中为每个核心事件启用了监听器:
Path: Ch13/interceptor/src/main/resources/META-INF/persistence.xml
<properties>
// . . .
<property name="hibernate.ejb.event.load" value=
"com.manning.javapersistence.ch13.filtering.SecurityLoadListener"/>
</properties>
配置设置的属性名始终以hibernate.ejb.event开头,后面跟着我们想要监听的事件类型。您可以在org.hibernate.event.spi.EventType中找到所有事件类型的列表。属性的值可以是一个以逗号分隔的监听器类名列表;Hibernate 将按指定顺序调用每个监听器。
我们很少需要扩展 Hibernate 核心事件系统以添加自己的功能。大多数时候,org.hibernate.Interceptor足够灵活。然而,拥有更多选项并且能够以模块化方式替换 Hibernate 核心引擎的任何部分是有帮助的。
在上一节中我们展示的审计日志实现非常简单。如果我们需要为审计记录更多信息,例如实体的实际更改属性值,我们会考虑使用 Hibernate Envers。
13.3 使用 Hibernate Envers 进行审计和版本控制
Envers 是 Hibernate 套件中的一个项目,专注于审计日志和保留数据库中的多个数据版本。这与你可能已经熟悉的版本控制系统类似,例如 Subversion 和 Git。
启用 Envers 后,当我们向应用程序的主要表中添加、修改或删除数据时,数据的一个副本将自动存储在单独的数据库表中。Envers 内部使用你在上一节中看到的 Hibernate 事件 SPI。Envers 监听 Hibernate 事件,当 Hibernate 在数据库中存储更改时,Envers 会创建数据的一个副本并在其自己的表中记录一个修订。
Envers 将所有数据修改分组在一个工作单元中——即在一个事务中——作为一个带有修订号的更改集。我们可以使用 Envers API 编写查询以检索历史数据,给定一个修订号或时间戳;例如,“找到上周五所有的Item实例。”
一旦你在你的应用程序中启用了 Envers,你将能够轻松地与之工作,因为它基于注解。它将为你提供在数据库中轻松保留多个数据版本的选择,而你几乎不需要做任何工作。权衡是它将创建大量的额外表(但你将能够控制你想要审计的表)。
13.3.1 启用审计日志
一旦我们将 Envers 的 JAR 文件放在类路径上(我们将将其作为 Maven 依赖项包含),Envers 就可以无需进一步配置即可使用。我们可以通过@org.hibernate.envers.Audited注解有选择地为一个实体类启用审计日志。
列表 13.4 启用Item实体的审计日志
Path: Ch13/envers/src/main/java/com/manning/javapersistence/ch13
➥ /filtering/envers/Item.java
@Entity
@org.hibernate.envers.Audited
public class Item {
@NotNull
private String name;
@OneToMany(mappedBy = "item")
@org.hibernate.envers.NotAudited
private Set<Bid> bids = new HashSet<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "SELLER_ID", nullable = false)
private User seller;
// . . .
}
我们现在已为Item实例及其所有属性启用了审计日志。要禁用特定属性的审计日志,我们可以用@NotAudited注解它。在这种情况下,Envers 忽略bids但审计seller。我们还需要在User类上启用@Audited以启用审计。
Hibernate 现在将生成(或期望)额外的数据库表来存储每个Item和User的历史数据。图 13.1 显示了这些表的架构。

图 13.1 Item和User实体的审计日志表
ITEM_AUD和USERS_AUD表是存储Item和User实例修改历史的地方。当我们修改数据并提交事务时,Hibernate 会在REVINFO表中插入一个新的版本号和时间戳。然后,对于每个更改集中涉及的修改和审计的实体实例,其数据的一个副本将存储在审计表中。版本号列上的外键将更改集链接在一起。REVTYPE列包含更改的类型:实体实例在事务中是插入、更新还是删除。Envers 永远不会自动删除任何版本信息或历史数据;即使我们在remove()一个Item实例之后,我们仍然可以在ITEM_AUD中找到其之前的版本。
让我们运行一些事务来看看这是如何工作的。
13.3.2 创建审计跟踪
在以下代码示例中,我们将查看涉及Item及其seller(卖家)、User的几个事务。我们将创建并存储一个Item和一个User,然后修改这两个实例,最后删除Item。
你应该已经熟悉这段代码。当我们使用EntityManager工作时,Envers 会自动创建审计跟踪:
Path: Ch13/envers/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Envers.java
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
User user = new User("johndoe");
em.persist(user);
Item item = new Item("Foo", user);
em.persist(item);
em.getTransaction().commit();
em.close();
Path: Ch13/envers/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Envers.java
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
Item item = em.find(Item.class, ITEM_ID);
item.setName("Bar");
item.getSeller().setUsername("doejohn");
em.getTransaction().commit();
em.close();
Path: Ch13/envers/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Envers.java
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
EntityManager em = JPA.createEntityManager();
Item item = em.find(Item.class, ITEM_ID);
em.remove(item);
em.getTransaction().commit();
em.close();
Envers 通过记录三个更改集来透明地写入此事务序列的审计跟踪。为了访问这些历史数据,我们首先必须获取表示我们想要访问的更改集的版本号。
13.3.3 查找版本
使用 Envers 的AuditReader API,我们可以找到每个更改集的版本号。主要的 Envers API 是AuditReader。它可以通过EntityManager访问。给定一个时间戳,我们可以找到在该时间戳之前或在该时间戳上做出的更改集的版本号。如果我们没有时间戳,我们可以获取所有涉及特定审计实体实例的版本号。
列表 13.5 获取更改集的版本号
Path: Ch13/envers/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Envers.java
AuditReader auditReader = AuditReaderFactory.get(em); Ⓐ
Number revisionCreate = Ⓑ
auditReader.getRevisionNumberForDate(TIMESTAMP_CREATE);
Number revisionUpdate =
auditReader.getRevisionNumberForDate(TIMESTAMP_UPDATE);
Number revisionDelete =
auditReader.getRevisionNumberForDate(TIMESTAMP_DELETE);
List<Number> itemRevisions =
auditReader.getRevisions(Item.class, ITEM_ID); Ⓒ
assertEquals(3, itemRevisions.size());
for (Number itemRevision : itemRevisions) {
Date itemRevisionTimestamp = auditReader.getRevisionDate(itemRevision); Ⓓ
// . . .
}
List<Number> userRevisions =
auditReader.getRevisions(User.class, USER_ID); Ⓔ
assertEquals(2, userRevisions.size());
Ⓐ 访问 Envers 的AuditReader API。
Ⓑ 查找在给定时间戳之前或在该时间戳上做出的更改集的版本号。
Ⓒ 如果没有时间戳,此操作将找到所有给定Item被创建、修改或删除的更改集。在我们的例子中,我们创建了Item,然后修改了它,最后删除了它。因此,我们有三个版本。
Ⓓ 如果我们有版本号,我们可以获取 Envers 记录更改集时的日志时间戳。
Ⓔ 我们创建并修改了User,因此有两个版本。
在列表 13.5 中,我们假设我们要么知道事务的(近似)时间戳,要么有实体标识值,这样我们就可以获取其版本。如果我们两者都没有,我们可能想通过查询探索审计日志。如果我们必须在应用程序的用户界面中显示所有更改集的列表,这也很有用。
以下代码发现Item实体类的所有版本,并加载每个Item版本和该更改集的审计日志信息:
Path: Ch13/envers/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Envers.java
AuditQuery query = auditReader.createQuery() Ⓐ
.forRevisionsOfEntity(Item.class, false, false);
@SuppressWarnings("unchecked")
List<Object[]> result = query.getResultList(); Ⓑ
for (Object[] tuple : result) {
Item item = (Item) tuple[0]; Ⓒ
DefaultRevisionEntity revision = (DefaultRevisionEntity)tuple[1];
RevisionType revisionType = (RevisionType)tuple[2];
if (revision.getId() == 1) { Ⓓ
assertEquals(RevisionType.ADD, revisionType);
assertEquals("Foo", item.getName());
} else if (revision.getId() == 2) {
assertEquals(RevisionType.MOD, revisionType);
assertEquals("Bar", item.getName());
} else if (revision.getId() == 3) {
assertEquals(RevisionType.DEL, revisionType);
assertNull(item);
}
}
Ⓐ 如果我们不知道修改时间戳或修订版本号,我们可以使用forRevisionsOfEntity()编写查询以获取特定实体的所有审计跟踪详情。
Ⓑ 此查询以List的Object[]形式返回审计跟踪详情。
Ⓒ 每个结果元组包含特定修订版本的实体实例、修订详细信息(包括修订版本号和时间戳),以及修订类型。
Ⓓ 修订类型指示 Envers 创建修订版本的原因——实体实例是否在数据库中插入、修改或删除。
修订版本号是顺序递增的;较高的修订版本号总是实体实例的较新版本。我们现在在审计跟踪中有三个更改集的修订版本号,这使我们能够访问历史数据。
13.3.4 访问历史数据
使用修订版本号,我们可以访问项目及其卖家的不同版本。
列表 13.6 加载实体实例的历史版本
Path: Ch13/envers/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Envers.java
Item item = auditReader.find(Item.class, ITEM_ID, revisionCreate); Ⓐ
assertEquals("Foo", item.getName());
assertEquals("johndoe", item.getSeller().getUsername());
Item modifiedItem = auditReader.find(Item.class, Ⓑ
ITEM_ID, revisionUpdate);
assertEquals("Bar", modifiedItem.getName());
assertEquals("doejohn", modifiedItem.getSeller().getUsername());
Item deletedItem = auditReader.find(Item.class, Ⓒ
ITEM_ID, revisionDelete);
assertNull(deletedItem);
User user = auditReader.find(User.class, Ⓓ
USER_ID, revisionDelete);
assertEquals("doejohn", user.getUsername());
Ⓐ 给定一个修订版本,find()方法返回一个经过审计的实体实例版本,此操作加载了创建后的项目。
Ⓑ 此更改集的卖家也会自动检索。
Ⓒ 在此修订版本中,项目已被删除,因此find()返回null。
Ⓓ 示例中在此修订版本中没有修改用户,因此 Envers 返回其最接近的历史修订版本。
AuditReader#find()操作仅检索单个实体实例,类似于EntityManager#find()。但返回的实体实例不是持久状态:持久上下文不管理它们。如果我们修改项目的旧版本,Hibernate 不会更新数据库。请将AuditReader API 返回的实体实例视为分离的或只读的。
AuditReader还有一个用于执行任意查询的 API,类似于原生 Hibernate Criteria API。
列表 13.7 查询历史实体实例
Path: Ch13/envers/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Envers.java
AuditQuery query = auditReader.createQuery() Ⓐ
.forEntitiesAtRevision(Item.class, revisionUpdate);
query.add(AuditEntity.property("name").like("Ba", MatchMode.START)); Ⓑ
query.add(AuditEntity.relatedId("seller").eq(USER_ID)); Ⓒ
query.addOrder(AuditEntity.property("name").desc()); Ⓓ
query.setFirstResult(0); Ⓔ
query.setMaxResults(10);
assertEquals(1, query.getResultList().size());
Item result = (Item)query.getResultList().get(0);
assertEquals("doejohn", result.getSeller().getUsername());
Ⓐ 此查询返回受特定修订版本和更改集限制的项目实例。
Ⓑ 我们可以进一步限制查询;这里项目#名称必须以“Ba”开头。
Ⓒ 限制可以包括实体关联;例如,我们正在寻找由特定用户销售的项目的修订版本。
Ⓓ 我们可以排序查询结果。
Ⓔ 我们可以分页浏览大量结果。
Envers 支持投影。以下查询仅检索特定版本的项目#名称:
Path: Ch13/envers/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Envers.java
AuditQuery query = auditReader.createQuery()
.forEntitiesAtRevision(Item.class, revisionUpdate);
query.addProjection(AuditEntity.property("name"));
assertEquals(1, query.getResultList().size());
String result = (String)query.getSingleResult();
assertEquals("Bar", result);
最后,我们可能希望将实体实例回滚到较旧版本。这可以通过Session#replicate()操作和覆盖现有行来完成。以下示例从第一个更改集加载用户实例,然后使用较旧版本覆盖数据库中的当前用户:
Path: Ch13/envers/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /Envers.java
User user = auditReader.find(User.class, USER_ID, revisionCreate);
em.unwrap(Session.class)
.replicate(user, ReplicationMode.OVERWRITE);
em.flush();
em.clear();
user = em.find(User.class, USER_ID);
assertEquals("johndoe", user.getUsername());
Envers 还会跟踪此更改作为审计日志中的更新;这只是用户实例的另一个新修订版本。
时间数据是一个复杂的话题,我们鼓励您阅读 Envers 参考文档以获取更多信息。向审计日志添加详细信息,例如更改的用户,并不困难。文档还展示了如何配置不同的跟踪策略和自定义 Envers 使用的数据库模式。
接下来,假设你不想看到数据库中的所有数据。例如,当前登录的应用程序用户可能没有查看所有内容的权限。通常,我们会在查询中添加一个条件并动态地限制结果。但是,如果我们必须处理像安全这样的问题,这会变得很困难,因为我们必须定制应用程序中的大多数查询。我们可以使用 Hibernate 的动态数据过滤器来集中和隔离这些限制。
13.4 动态数据过滤器
动态数据过滤的第一个用例与数据安全相关。CaveatEmptor 中的User可能有一个ranking属性,它是一个简单的整数:
Path: /model/src/main/java/org/jpwh/model/filtering/dynamic/User.java
@Entity
@Table(name = "USERS")
public class User {
@NotNull
private int ranking = 0;
// . . .
}
现在假设用户只能对其他用户提供的具有相同或更低排名的物品进行竞标。从商业角度来说,我们可能有几个由任意排名(一个数字)定义的用户组,并且用户只能与具有相同或更低排名的人进行交易。
为了实现这一要求,我们必须定制所有从数据库加载Item实例的查询。我们需要检查我们想要加载的Item#seller是否与当前登录用户的排名相同或更低。Hibernate 可以用动态过滤器为我们完成这项工作。
13.4.1 定义动态过滤器
首先,我们将定义一个具有名称和它接受的动态运行时参数的过滤器。我们可以将此定义的 Hibernate 注解放置在我们的领域模型中的任何实体类上或package-info.java元数据文件中:
Path: Ch13/dynamic/src/main/java/com/manning/javapersistence/ch13/filtering
➥ /dynamic/package-info.java
@org.hibernate.annotations.FilterDef(
name = "limitByUserRanking",
parameters = {
@org.hibernate.annotations.ParamDef(
name = "currentUserRanking", type = "int"
)
}
)
这个示例将此过滤器命名为limitByUserRanking;请注意,过滤器名称必须在持久化单元中是唯一的。它接受一个类型为int的运行时参数。如果我们有多个过滤器定义,我们可以在@org.hibernate.annotations.FilterDefs中声明它们。
现在过滤器处于非活动状态;没有任何指示表明它应该应用于Item实例。我们必须在我们想要过滤的类或集合上应用和实现该过滤器。
13.4.2 应用动态过滤器
我们希望将定义的过滤器应用于Item类,以便如果登录用户没有必要的排名,则没有任何物品可见:
Path: Ch13/dynamic/src/main/java/com/manning/javapersistence/ch13/filtering
➥ /dynamic/Item.java
@Entity
@org.hibernate.annotations.Filter(
name = "limitByUserRanking",
condition = """
:currentUserRanking >= (
select u.RANKING from USERS u
where u.ID = SELLER_ID
)"""
)
public class Item {
// . . .
}
上一段代码中的condition是一个直接传递给数据库系统的 SQL 表达式,因此我们可以使用任何 SQL 运算符或函数。如果一条记录应该通过过滤器,它必须评估为true。在这个例子中,我们使用子查询来获取物品卖家的ranking。未命名的列,如SELLER_ID,指的是映射到实体类的表。如果当前登录用户的排名不大于或等于子查询返回的排名,则Item实例将被过滤掉。我们可以通过在@org.hibernate.annotations.Filters中分组来应用多个过滤器。
如果为特定的工作单元启用,一个定义并应用的过滤器将过滤掉任何不满足条件的Item实例。让我们启用它。
13.4.3 启用动态过滤器
我们已经定义了一个数据过滤器并将其应用于持久化实体类。它仍然没有过滤任何内容——它必须在应用程序中为特定的工作单元启用并参数化,使用Session API:
Path: Ch13/dynamic/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /DynamicFilter.java
org.hibernate.Filter filter = em.unwrap(Session.class)
.enableFilter("limitByUserRanking");
filter.setParameter("currentUserRanking", 0);
我们通过名称启用过滤器,该方法返回一个Filter,我们可以在其中动态设置运行时参数。我们必须设置我们定义的参数;这里设置为排名 0。这个例子然后过滤掉由在这个Session中排名更高的User销售的Item。
Filter的其他有用方法是getFilterDefinition(),它允许我们遍历参数名称和类型,以及validate(),如果忘记设置参数,它将抛出HibernateException。我们还可以使用setParameterList()设置参数列表;这主要在 SQL 限制包含带有量词运算符的表达式(例如IN运算符)时有用。
现在每次我们在过滤的持久化上下文中执行 JPQL 或标准查询时,都会限制返回的Item实例:
Path: Ch13/dynamic/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /DynamicFilter.java
List<Item> items = em.createQuery("select i from Item i", Item.class).getResultList();
// select * from ITEM where 0 >=
// (select u.RANKING from USERS u where u.ID = SELLER_ID)
Path: Ch13/dynamic/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /DynamicFilter.java
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Item> criteria = cb.createQuery(Item.class);
criteria.select(criteria.from(Item.class));
List<Item> items = em.createQuery(criteria).getResultList();
// select * from ITEM where 0 >=
// (select u.RANKING from USERS u where u.ID = SELLER_ID)
注意 Hibernate 如何动态地将 SQL 限制条件追加到生成的语句中。
当你第一次尝试动态过滤器时,你很可能会遇到通过标识符检索的问题。你可能期望em.find(Item.class, ITEM_ID)也会被过滤。但这并不是事实:Hibernate 不会将过滤器应用于通过标识符的检索操作。其中一个原因是数据过滤条件是 SQL 片段,而通过标识符的查找可能在第一级持久化上下文缓存中完全在内存中解决。类似的推理也适用于多对一或一对一的关联过滤。如果一个多对一关联被过滤(例如,如果你调用anItem.getSeller()返回null),关联的基数会改变!你将不知道项目是否有卖家,或者你是否被允许看到它。
但你可以动态地过滤集合访问。
13.4.4 过滤集合访问
到目前为止,调用someCategory.getItems()返回了所有由该Category引用的Item实例。这可以通过对集合应用过滤器来限制:
Path: Ch13/dynamic/src/main/java/com/manning/javapersistence/ch13/filtering
➥ /dynamic/Category.java
@Entity
public class Category {
@OneToMany(mappedBy = "category")
@org.hibernate.annotations.Filter(
name = "limitByUserRanking",
condition = """
:currentUserRanking >= (
select u.RANKING from USERS u
where u.ID = SELLER_ID
)"""
)
private Set<Item> items = new HashSet<>();
// . . .
}
如果我们现在在Session中启用过滤器,那么遍历Category# items集合的所有迭代都会被过滤:
Path: Ch13/dynamic/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /DynamicFilter.java
filter.setParameter("currentUserRanking", 0);
Category category = em.find(Category.class, CATEGORY_ID);
assertEquals(1, category.getItems().size());
如果当前用户的排名是 0,当我们访问集合时只会加载一个Item。排名为 100 时,我们可以看到更多数据:
Path: Ch13/dynamic/src/test/java/com/manning/javapersistence/ch13/filtering
➥ /DynamicFilter.java
filter.setParameter("currentUserRanking", 100);
category = em.find(Category.class, CATEGORY_ID);
assertEquals(2, category.getItems().size());
您可能已经注意到,两个过滤器应用的 SQL 条件是相同的。如果所有过滤器应用的 SQL 限制都相同,那么我们可以在定义过滤器时将其设置为默认条件,这样我们就不必重复它:
Path: Ch13/dynamic/src/main/java/com/manning/javapersistence/ch13/filtering
➥ /dynamic/package-info.java
@org.hibernate.annotations.FilterDef(
name = "limitByUserRankingDefault",
defaultCondition= """
:currentUserRanking >= (
select u.RANKING from USERS u
where u.ID = SELLER_ID
)""",
parameters = {
@org.hibernate.annotations.ParamDef(
name = "currentUserRanking", type = "int"
)
}
)
动态数据过滤器有许多其他优秀的用例。我们看到了在任意安全相关条件下对数据访问的限制。这可能包括用户排名、用户必须属于的特定组或用户被分配的角色。数据可能带有区域代码(例如,所有销售团队的商务联系人)。或者也许每个销售人员只处理覆盖他们地区的相关数据。
摘要
-
级联状态转换是持久化引擎中生命周期事件的预定义反应。
-
级联提供了选项:传递性分离和合并,级联刷新和复制。
-
您可以实现事件监听器和拦截器,以便在 Hibernate 加载数据和存储数据时添加自定义逻辑。
-
您可以使用 Hibernate Envers 进行审计日志记录和在数据库中保留多个版本的数据(类似于版本控制系统)。
-
您可以使用 Envers 查询历史数据。
-
您可以使用动态数据过滤器,这样 Hibernate 就可以自动将其生成的查询中添加任意 SQL 限制。
-
您可以定义动态过滤器,应用和启用过滤器,并过滤集合访问。
第四部分. 使用 Spring 构建 Java 持久化应用程序
在第四部分,您将连接 Java 持久化与目前最广泛使用的 Java 框架:Spring。
在第十四章,您将学习创建 JPA 或 Hibernate 应用程序并与其集成的重要策略。您将看到实现这一集成的各种替代方案,您将更深入地研究 DAO(数据访问对象)模式,并构建通用的持久化应用程序。
接下来,第十五章介绍了并分析了使用大型 Spring Data 框架的另一部分来开发持久化应用程序的可能性:Spring Data JDBC。在第十六章,我们将使用 Spring Data REST 来构建采用表示状态转移(REST)架构风格的应用程序。
阅读本书的这一部分后,您将了解如何高效地使用 JPA、Hibernate 和 Spring,以及如何决定将持久化应用程序与 Spring 集成的替代方案。
14 集成 JPA 和 Hibernate 与 Spring
本章涵盖
-
介绍 Spring 框架和依赖注入
-
检查数据访问对象(DAO)设计模式
-
使用 DAO 设计模式创建和生成 Spring JPA 应用程序
-
使用 DAO 设计模式创建和生成 Spring Hibernate 应用程序
在本章中,我们将分析几种将 Spring 和 Hibernate 集成的不同可能性。Spring 是一个轻量级但同时也灵活通用的 Java 框架。它是开源的,可以在 Java 应用程序的任何一层使用。我们将研究 Spring 框架背后的原则(依赖注入,也称为 控制反转),并且我们将使用 Spring 与 JPA 或 Hibernate 一起构建 Java 持久化应用程序。
注意:要执行源代码中的示例,你首先需要运行 Ch14.sql 脚本。
14.1 Spring 框架和依赖注入
Spring 框架为开发 Java 应用程序提供了一个全面的架构。它处理基础设施,以便你可以专注于你的应用程序,并使你能够从普通的 Java 对象(POJOs)构建应用程序。
Rod Johnson 于 2002 年创建了 Spring,始于他的书籍 Expert One-on-One J2EE Design and Development(Johnson,2002)。Spring 背后的基本思想是简化传统的企业应用程序设计方法。要快速了解 Spring 框架的功能,Laurențiu Spilcă 的书籍 Spring Start Here(Spilcă,2021)是一个很好的资源。
一个 Java 应用程序通常由协作解决问题的对象组成。程序中的对象相互依赖。你可以使用设计模式(工厂、构建器、代理、装饰器等)来组合类和对象,但这种负担在开发者这边。
Spring 实现了各种设计模式。Spring 框架的 依赖注入 模式(也称为 控制反转,或 IoC)支持创建由不同组件和对象组成的应用程序。
框架的关键特征正是这种依赖注入或 IoC。当你从 JDK 或库中调用方法时,你处于控制地位。相比之下,使用框架时,控制是反转的:框架调用 你(见图 14.1)。你必须遵循框架提供的范式并填写自己的代码。框架定义了一个骨架,而你插入特性来填充这个骨架。你的代码处于框架的控制之下,框架调用它。这样,你可以专注于实现业务逻辑,而不是设计。

图 14.1 你的代码调用库。框架调用你的代码。
Spring 框架下受控的对象的创建、依赖注入和一般生命周期由一个容器管理。容器将结合应用程序类和配置信息(元数据)以获得一个可运行的应用程序(图 14.2)。因此,容器是 IoC 原则的核心。

图 14.2 Spring IoC 容器的功能
由 IoC 容器管理的对象被称为 beans。beans 构成了 Spring 应用的骨架。
14.2 使用 Spring 和 DAO 模式的 JPA 应用程序
在本节中,我们将探讨如何使用 Spring 和数据访问对象(DAO)设计模式构建 JPA 应用程序。DAO 设计模式创建了一个数据库的抽象接口,支持访问操作而不暴露数据库的任何内部信息。
你可能会争辩说,我们已创建并使用过的 Spring Data JPA 存储库已经做到了这一点,这是真的。在本章中,我们将演示如何构建 DAO 类,并讨论何时应优先选择这种方法而不是使用 Spring Data JPA。
CaveatEmptor 应用程序包含 Item 和 Bid 类(列表 14.1 和 14.2)。现在将使用 Spring 框架来管理实体。BID 和 ITEM 表之间的关系将通过 BID 表侧的外键字段保持。带有 @javax.persistence.Transient 注解的字段将排除在持久化之外。
列表 14.1 Item 类
Path: Ch14/spring-jpa-dao/src/main/java/com/manning/javapersistence/ch14
➥ /Item.java
@Entity
\1 Item {
@Id Ⓐ
@GeneratedValue(generator = "ID_GENERATOR") Ⓐ
private Long id; Ⓐ
@NotNull Ⓑ
@Size( Ⓑ
min = 2, Ⓑ
max = 255, Ⓑ
message = "Name is required, maximum 255 characters." Ⓑ
) Ⓑ
private String name; Ⓑ
@Transient Ⓒ
private Set<Bid> bids = new HashSet<>(); Ⓒ
// . . .
}
Ⓐ id 字段是一个生成的标识符。
Ⓑ name 字段不为空,且大小必须在 2 到 255 个字符之间。
Ⓒ 每个 Item 都有一个对其 Bid 集合的引用。该字段被标记为 @Transient,因此它被排除在持久化之外。
我们将关注 Bid 类,如现在所示。它也是一个实体,Item 和 Bid 之间的关系是一对多。
列表 14.2 Bid 类
Path: Ch14/spring-jpa-dao/src/main/java/com/manning/javapersistence/ch14
➥ /Bid.java
@Entity
public class Bid {
@Id Ⓐ
@GeneratedValue(generator = "ID_GENERATOR") Ⓐ
private Long id; Ⓐ
@NotNull Ⓑ
private BigDecimal amount; Ⓑ
@ManyToOne(optional = false, fetch = FetchType.LAZY) Ⓒ
@JoinColumn(name = "ITEM_ID") Ⓒ
private Item item; Ⓒ
// . . .
}
Ⓐ Bid 实体类包含 id 字段作为生成的标识符。
Ⓑ amount 字段不应为空。
Ⓒ 每个 Bid 都有一个非可选的对其 Item 的引用。获取将是延迟的,连接列的名称是 ITEM_ID。
要实现 DAO 设计模式,我们首先将创建两个接口,ItemDao 和 BidDao,并声明将要实现的操作访问:
Path: Ch14/spring-jpa-dao/src/main/java/com/manning/javapersistence/ch14
➥ /dao/ItemDao.java
public interface ItemDao {
Item getById(long id);
List<Item> getAll();
void insert(Item item);
void update(long id, String name);
void delete(Item item);
Item findByName(String name);
}
BidDao 接口声明如下:
Path: Ch14/spring-jpa-dao/src/main/java/com/manning/javapersistence/ch14
➥ /dao/BidDao.java
public interface BidDao {
Bid getById(long id);
List<Bid> getAll();
void insert(Bid bid);
void update(long id, String amount);
void delete(Bid bid);
List<Bid> findByAmount(String amount);
}
@Repository 是一个标记注解,表示该组件代表一个 DAO。除了将注解的类标记为 Spring 组件外,@Repository 还会捕获持久化特定的异常并将它们转换为 Spring 未检查的异常。@Transactional 将使类内部的所有方法都具有事务性,如第 11.4.3 节所述。
EntityManager本身不是线程安全的。我们将使用@PersistenceContext,以便容器注入一个线程安全的代理对象。除了在容器管理的实体管理器上注入依赖项之外,@PersistenceContext注解还有参数。将持久化类型设置为EXTENDED可以保持整个 bean 的生命周期中的持久化上下文。
ItemDao接口的实现,ItemDaoImpl,如下所示。
列表 14.3 ItemDaoImpl类
Path: Ch14/spring-jpa-dao/src/main/java/com/manning/javapersistence/ch14
➥ /dao/ItemDaoImpl.java
@Repository Ⓐ
@Transactional Ⓐ
public class ItemDaoImpl implements ItemDao {
@PersistenceContext(type = PersistenceContextType.EXTENDED) Ⓑ
private EntityManager em; Ⓑ
@Override Ⓒ
public Item getById(long id) { Ⓒ
return em.find(Item.class, id); Ⓒ
}
@Override Ⓓ
public List<Item> getAll() { Ⓓ
return (List<Item>) em.createQuery("from Item", Item.class) Ⓓ
.getResultList(); Ⓓ
}
@Override Ⓔ
public void insert(Item item) { Ⓔ
em.persist(item); Ⓔ
for (Bid bid : item.getBids()) { Ⓔ
em.persist(bid); Ⓔ
} Ⓔ
}
@Override Ⓕ
public void update(long id, String name) { Ⓕ
Item item = em.find(Item.class, id); Ⓕ
item.setName(name); Ⓕ
em.persist(item); Ⓕ
}
@Override Ⓖ
public void delete(Item item) { Ⓖ
for (Bid bid : item.getBids()) { Ⓖ
em.remove(bid); Ⓖ
} Ⓖ
em.remove(item); Ⓖ
}
@Override Ⓗ
public Item findByName(String name) { Ⓗ
return em.createQuery("from Item where name=:name", Item.class) Ⓗ
.setParameter("name", name).getSingleResult(); Ⓗ
}
}
ItemDaoImpl类被注解为@Repository和@Transactional。
EntityManager em字段注入到应用程序中,因为它被注解为@PersistenceContext。持久化类型EXTENDED意味着持久化上下文在整个 bean 的生命周期中保持。
通过id检索Item。
检索所有Item实体。
将Item及其所有Bid持久化。
更新Item的name字段。
移除属于一个Item及其本身的Item的所有出价。
通过name搜索Item。
BidDao接口的实现,BidDaoImpl,如下所示。
列表 14.4 BidDaoImpl类
Path: Ch14/spring-jpa-dao/src/main/java/com/manning/javapersistence/ch14
➥ /dao/BidDaoImpl.java
@Repository Ⓐ
@Transactional Ⓐ
public class BidDaoImpl implements BidDao {
@PersistenceContext(type = PersistenceContextType.EXTENDED) Ⓑ
private EntityManager em; Ⓑ
@Override Ⓒ
public Bid getById(long id) { Ⓒ
return em.find(Bid.class, id); Ⓒ
}
@Override Ⓓ
public List<Bid> getAll() { Ⓓ
return em.createQuery("from Bid", Bid.class).getResultList(); Ⓓ
}
@Override Ⓔ
public void insert(Bid bid) { Ⓔ
em.persist(bid); Ⓔ
}
@Override Ⓕ
public void update(long id, String amount) { Ⓕ
Bid bid = em.find(Bid.class, id); Ⓕ
bid.setAmount(new BigDecimal(amount)); Ⓕ
em.persist(bid); Ⓕ
}
@Override Ⓖ
public void delete(Bid bid) { Ⓖ
em.remove(bid); Ⓖ
}
@Override Ⓗ
public List<Bid> findByAmount(String amount) { Ⓗ
return em.createQuery("from Bid where amount=:amount", Bid.class) Ⓗ
.setParameter("amount", new BigDecimal(amount)).getResultList(); Ⓗ
}
}
BidDaoImpl类被注解为@Repository和@Transactional。
EntityManager em字段注入到应用程序中,因为它被注解为@PersistenceContext。将持久化类型设置为EXTENDED意味着持久化上下文在整个 bean 的生命周期中保持不变。
通过id检索Bid。
检索所有Bid实体。
持久化一个Bid。
更新Bid的amount字段。
移除一个Bid。
通过amount搜索Bid。
为了与数据库交互,我们将提供一个特殊的类DatabaseService,该类将负责填充数据库并从其中删除信息。
列表 14.5 DatabaseService类
Path: Ch14/spring-jpa-dao/src/test/java/com/manning/javapersistence/ch14
➥ /DatabaseService.java
public class DatabaseService {
@PersistenceContext(type = PersistenceContextType.EXTENDED) Ⓐ
private EntityManager em; Ⓐ
@Autowired Ⓑ
private ItemDao itemDao; Ⓑ
@Transactional Ⓒ
public void init() { Ⓒ
for (int i = 0; i < 10; i++) { Ⓒ
String itemName = "Item " + (i + 1); Ⓒ
Item item = new Item(); Ⓒ
item.setName(itemName); Ⓒ
Bid bid1 = new Bid(new BigDecimal(1000.0), item); Ⓒ
Bid bid2 = new Bid(new BigDecimal(1100.0), item); Ⓒ
itemDao.insert(item); Ⓒ
} Ⓒ
}
@Transactional Ⓓ
public void clear() { Ⓓ
em.createQuery("delete from Bid b").executeUpdate(); Ⓓ
em.createQuery("delete from Item i").executeUpdate(); Ⓓ
}
}
EntityManager em字段注入到应用程序中,因为它被注解为@PersistenceContext。将持久化类型设置为EXTENDED可以保持整个 bean 的生命周期中的持久化上下文。
ItemDao的itemDao字段注入到应用程序中,因为它被注解为@Autowired。因为ItemDaoImpl类被注解为@Repository,Spring 将创建属于此类的所需 bean,以在此处注入。
生成 10 个Item对象,每个对象有 2 个Bid,并将它们插入到数据库中。
删除之前插入的所有Bid和Item对象。
Spring 的标准配置文件是一个 Java 类,它创建并设置所需的 bean。@EnableTransactionManagement注解将启用 Spring 的基于注解的事务管理功能。当使用 XML 配置时,此注解由tx:annotation-driven元素镜像。每次与数据库的交互都应发生在事务边界内,Spring 需要一个事务管理器 bean。
我们将为应用程序创建以下配置文件。
列表 14.6 SpringConfiguration 类
Path: Ch14/spring-jpa-dao/src/test/java/com/manning/javapersistence/ch14
➥ /configuration/SpringConfiguration.java
@EnableTransactionManagement Ⓐ
public class SpringConfiguration {
@Bean Ⓑ
public DataSource dataSource() { Ⓑ
DriverManagerDataSource dataSource = new DriverManagerDataSource(); Ⓑ
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver"); Ⓒ
dataSource.setUrl( Ⓓ
"jdbc:mysql://localhost:3306/CH14_SPRING_HIBERNATE Ⓓ
➥ ?serverTimezone=UTC"); Ⓓ
dataSource.setUsername("root"); Ⓔ
dataSource.setPassword(""); Ⓕ
return dataSource; Ⓑ
}
@Bean Ⓖ
public DatabaseService databaseService() { Ⓖ
return new DatabaseService(); Ⓖ
}
@Bean Ⓗ
public JpaTransactionManager Ⓗ
transactionManager(EntityManagerFactory emf){ Ⓗ
return new JpaTransactionManager(emf); Ⓗ
}
@Bean Ⓘ
public LocalContainerEntityManagerFactoryBean entityManagerFactory() { Ⓘ
LocalContainerEntityManagerFactoryBean Ⓘ
localContainerEntityManagerFactoryBean = Ⓘ
new LocalContainerEntityManagerFactoryBean(); Ⓘ
localContainerEntityManagerFactoryBean Ⓙ
.setPersistenceUnitName("ch14"); Ⓙ
localContainerEntityManagerFactoryBean.setDataSource(dataSource()); Ⓚ
localContainerEntityManagerFactoryBean.setPackagesToScan( Ⓛ
"com.manning.javapersistence.ch14"); Ⓛ
return localContainerEntityManagerFactoryBean; Ⓘ
}
@Bean Ⓜ
public ItemDao itemDao() { Ⓜ
return new ItemDaoImpl(); Ⓜ
}
@Bean Ⓝ
public BidDao bidDao() { Ⓝ
return new BidDaoImpl(); Ⓝ
}
}
Ⓐ @EnableTransactionManagement 注解启用了 Spring 的基于注解的事务管理功能。
Ⓑ 创建一个数据源豆。
Ⓒ 指定 JDBC 属性——驱动程序。
Ⓓ 数据库的 URL。
Ⓔ 用户名。
Ⓕ 此配置中没有密码。修改凭证以与您的机器上的凭证对应,并在实际使用中设置密码。
Ⓖ Spring 将使用此 DatabaseService 豆来填充和清除数据库。
Ⓗ 基于实体管理器工厂创建一个事务管理器豆。
Ⓘ LocalContainerEntityManagerFactoryBean 是一个工厂豆,它根据 JPA 标准容器引导合同生成一个 EntityManagerFactory。
Ⓙ 设置持久化单元名称,该名称在 persistence.xml 中定义。
Ⓚ 设置数据源。
Ⓛ 设置扫描实体类的包。豆位于 com.manning .javapersistence.ch14,因此我们将此包设置为扫描包。
Ⓜ 创建一个 ItemDao 豆。
Ⓝ 创建一个 BidDao 豆。
此配置信息由 Spring 用于创建和注入构成应用程序骨干的豆。我们可以使用替代的 XML 配置,并且 application-context.xml 文件反映了 SpringConfiguration .java 中完成的工作。我们只想强调我们之前提到的一点:在 XML 中,我们通过 tx:annotation-driven 元素启用 Spring 的基于注解的事务管理功能,并引用事务管理器豆:
Path: Ch14/spring-jpa-dao/src/test/resources/application-context.xml
<tx:annotation-driven transaction-manager="txManager"/>
使用 SpringExtension 扩展将 Spring 测试上下文与 JUnit 5 Jupiter 测试集成,通过实现几个 JUnit Jupiter 扩展模型回调方法。
对于所有注入的 EntityManager 豆,使用类型 PersistenceContextType.EXTENDED 是很重要的。如果我们使用默认的 PersistenceContextType.TRANSACTION 类型,则在事务执行结束时返回的对象将变为分离的。将其传递给 delete 方法会导致“IllegalArgumentException: Removing a detached instance”异常。
是时候测试我们为持久化 Item 和 Bid 实体所开发的功能了。
列表 14.7 SpringJpaTest 类
Path: Ch14/spring-jpa-dao/src/test/java/com/manning/javapersistence/ch14
➥ /SpringJpaTest.java
@ExtendWith(SpringExtension.class) Ⓐ
@ContextConfiguration(classes = {SpringConfiguration.class}) Ⓑ
//@ContextConfiguration("classpath:application-context.xml") Ⓒ
public class SpringJpaTest {
@Autowired Ⓓ
private DatabaseService databaseService; Ⓓ
@Autowired Ⓓ
private ItemDao itemDao; Ⓓ
@Autowired Ⓓ
private BidDao bidDao; Ⓓ
@BeforeEach Ⓔ
public void setUp() { Ⓔ
databaseService.init(); Ⓔ
}
@Test Ⓕ
public void testInsertItems() { Ⓕ
List<Item> itemsList = itemDao.getAll(); Ⓕ
List<Bid> bidsList = bidDao.getAll(); Ⓕ
assertAll( Ⓕ
() -> assertNotNull(itemsList), Ⓕ
() -> assertEquals(10, itemsList.size()), Ⓕ
() -> assertNotNull(itemDao.findByName("Item 1")), Ⓕ
() -> assertNotNull(bidsList), Ⓕ
() -> assertEquals(20, bidsList.size()), Ⓕ
() -> assertEquals(10, Ⓕ
bidDao.findByAmount("1000.00").size()) Ⓕ
); Ⓕ
}
@Test Ⓖ
public void testDeleteItem() { Ⓖ
itemDao.delete(itemDao.findByName("Item 2")); Ⓖ
assertThrows(NoResultException.class, Ⓗ
() -> itemDao.findByName("Item 2")); Ⓗ
}
// . . .
@AfterEach Ⓘ
public void dropDown() { Ⓘ
databaseService.clear(); Ⓘ
}
}
Ⓐ 使用 SpringExtension 扩展测试。如前所述,这将通过实现几个 JUnit Jupiter 扩展模型回调方法将 Spring TestContext 框架集成到 JUnit 5 中。
Ⓑ 使用之前展示的 SpringConfiguration 类中定义的豆配置 Spring 测试上下文。
Ⓒ 或者,我们可以使用 XML 配置测试上下文。代码中的 Ⓑ 或 Ⓒ 行中只能有一个是活动的。
Ⓓ 自动装配一个 DatabaseService 豆、一个 ItemDao 豆和一个 BidDao 豆。
Ⓔ 在每个测试执行之前,通过注入的 DatabaseService 的 init 方法初始化数据库的内容。
Ⓕ 检索所有 Item 和所有 Bid 并进行验证。
Ⓖ 通过 name 字段查找 Item 并从数据库中删除它。我们将使用 PersistenceContextType.EXTENDED 为所有注入的 EntityManager 实例。否则,将其传递给 delete 方法将导致“IllegalArgument-Exception: Removing a detached instance”异常。
Ⓗ 在数据库中成功删除 Item 后,再次尝试查找它将抛出 NoResultException。其余的测试可以在源代码中轻松调查。
Ⓘ 每次测试执行后,数据库的内容都会被注入的 DatabaseService 中的 clear 方法清除。
我们应该在何时应用 Spring 框架和 DAO 设计模式这样的解决方案?以下是一些我们推荐使用该解决方案的情况:
-
你可能希望将控制实体管理器和事务的任务交给 Spring 框架(记住这是通过 控制反转 实现的)。权衡是,你将失去调试事务的可能性。只是要注意这一点。
-
你可能想创建自己的 API 来管理持久化,或者你无法使用 Spring Data,或者你不想使用 Spring Data。这可能发生在你需要控制非常特定的操作时,或者你想要移除 Spring Data 的开销(包括团队采用它的时间、在现有项目中引入新依赖项以及 Spring Data 的执行延迟,如第 2.7 节所述)。
-
在特定情况下,你可能希望将实体管理器和事务处理交给 Spring 框架,同时仍然不实现自己的 DAO 类。
我们希望改进我们的 Spring 持久化应用程序的设计。接下来的几节将致力于使其更加通用,并使用 Hibernate API 而不是 JPA。我们将关注第一个解决方案和我们的新版本之间的差异,并讨论如何引入这些更改。
14.3 使用 Spring 和 DAO 的 JPA 应用程序的泛化
如果我们仔细查看我们创建的 ItemDao 和 BidDao 接口以及 ItemDaoImpl 和 BidDaoImpl 类,我们会发现一些不足之处:
-
存在类似的操作,如
getById、getAll、insert和delete,它们主要区别在于它们接收的参数类型或返回的结果类型。 -
update方法接收一个特定的属性的值作为第二个参数。如果我们需要更新实体的不同属性,我们可能需要编写多个方法。 -
类似于
findByName或findByAmount这样的方法与特定的属性相关联。我们可能需要为使用不同属性查找实体编写不同的方法。
因此,我们将引入一个 GenericDao 接口。
列表 14.8 GenericDao 接口
Path: Ch14/spring-jpa-dao-gen/src/main/java/com/manning/javapersistence
➥ /ch14/dao/GenericDao.java
public interface GenericDao<T> {
T getById(long id); Ⓐ
List<T> getAll(); Ⓐ
void insert(T entity); Ⓑ
void delete(T entity); Ⓑ
void update(long id, String propertyName, Object propertyValue); Ⓒ
List<T> findByProperty(String propertyName, Object propertyValue); Ⓒ
}
Ⓐ getById 和 getAll 方法具有通用的返回类型。
Ⓑ insert 和 update 方法具有通用的输入,T entity。
Ⓒ update 和 findByProperty 方法将接收 propertyName 和新的 propertyValue 作为参数。
我们将创建一个 GenericDao 接口的抽象实现,称为 AbstractGenericDao,如列表 14.9 所示。在这里,我们将编写所有 DAO 类的共同功能,并让具体类实现它们的特定功能。
我们将在应用程序中注入一个 EntityManager 字段 em,并使用 @PersistenceContext 进行注解。将持久化类型设置为 EXTENDED 可以在整个 bean 的生命周期中保持持久化上下文。
列表 14.9 AbstractGenericDao 类
Path: Ch14/spring-jpa-dao-gen/src/main/java/com/manning/javapersistence
➥ /ch14/dao/AbstractGenericDao.java
@Repository Ⓐ
@Transactional Ⓐ
public abstract class AbstractGenericDao<T> implements GenericDao<T> {
@PersistenceContext(type = PersistenceContextType.EXTENDED) Ⓑ
protected EntityManager em; Ⓑ
private Class<T> clazz; Ⓒ
public void setClazz(Class<T> clazz) { Ⓒ
this.clazz = clazz; Ⓒ
}
@Override Ⓓ
public T getById(long id) { Ⓓ
return em.createQuery( Ⓓ
"SELECT e FROM " + clazz.getName() + " e WHERE e.id = :id", Ⓓ
clazz).setParameter("id", id).getSingleResult(); Ⓓ
}
@Override Ⓔ
public List<T> getAll() { Ⓔ
return em.createQuery("from " + Ⓔ
clazz.getName(), clazz).getResultList(); Ⓔ
}
@Override Ⓕ
public void insert(T entity) { Ⓕ
em.persist(entity); Ⓕ
}
@Override Ⓖ
public void delete(T entity) { Ⓖ
em.remove(entity); Ⓖ
}
@Override
public void update(long id, String propertyName, Object propertyValue) { Ⓗ
em.createQuery("UPDATE " + clazz.getName() + " e SET e." + Ⓗ
propertyName + " = :propertyValue WHERE e.id = :id") Ⓗ
.setParameter("propertyValue", propertyValue) Ⓗ
.setParameter("id", id).executeUpdate(); Ⓗ
}
@Override
public List<T> findByProperty(String propertyName, Ⓘ
Object propertyValue) { Ⓘ
return em.createQuery( Ⓘ
"SELECT e FROM " + clazz.getName() + " e WHERE e." + Ⓘ
propertyName + " = :propertyValue", clazz) Ⓘ
.setParameter("propertyValue", propertyValue) Ⓘ
.getResultList(); Ⓘ
}
}
Ⓐ AbstractGenericDao 类被注解为 @Repository 和 @Transactional。
Ⓑ EntityManager 的 EXTENDED 持久化类型将保持整个 bean 生命周期的持久化上下文。该字段是 protected 的,最终将被子类继承和使用。
Ⓒ clazz 是 DAO 将在其上工作的有效 Class 字段。
Ⓓ 使用 clazz 实体和将 id 作为参数执行一个 SELECT 查询。
Ⓔ 使用 clazz 实体和获取结果列表执行一个 SELECT 查询。
Ⓕ 持久化 entity。
Ⓖ 删除 entity。
使用 propertyName、propertyValue 和 id 执行一个 UPDATE 操作。
Ⓘ 使用 propertyName 和 propertyValue 执行一个 SELECT。
AbstractGenericDao 类提供了大部分通用 DAO 功能。它只需要对特定 DAO 类进行一点定制。ItemDaoImpl 类将扩展 AbstractGenericDao 类并覆盖一些方法。
列表 14.10 扩展 AbstractGenericDao 的 ItemDaoImpl 类
Path: Ch14/spring-jpa-dao-gen/src/main/java/com/manning/javapersistence
➥ /ch14/dao/ItemDaoImpl.java
public class ItemDaoImpl extends AbstractGenericDao<Item> { Ⓐ
public ItemDaoImpl() { Ⓑ
setClazz(Item.class); Ⓑ
}
@Override Ⓒ
public void insert(Item item) { Ⓒ
em.persist(item); Ⓒ
for (Bid bid : item.getBids()) { Ⓒ
em.persist(bid); Ⓒ
} Ⓒ
}
@Override Ⓓ
public void delete(Item item) { Ⓓ
for (Bid bid: item.getBids()) { Ⓓ
em.remove(bid); Ⓓ
} Ⓓ
em.remove(item); Ⓓ
}
}
Ⓐ ItemDaoImpl 扩展了 AbstractGenericDao 并通过 Item 进行泛型化。
Ⓑ 构造函数将 Item.class 设置为要管理的实体类。
Ⓒ 持久化 Item 实体及其所有 Bid 实体。EntityManager em 字段是从 AbstractGenericDao 类继承而来的。
Ⓓ 删除属于 Item 及其本身的全部投标。
BidDaoImpl 类将简单地扩展 AbstractGenericDao 类,并设置要管理的实体类。
列表 14.11 扩展 AbstractGenericDao 的 BidDaoImpl 类
Path: Ch14/spring-jpa-dao-gen/src/main/java/com/manning/javapersistence
➥ /ch14/dao/BidDaoImpl.java
public class BidDaoImpl extends AbstractGenericDao<Bid> { Ⓐ
public BidDaoImpl() { Ⓑ
setClazz(Bid.class); Ⓑ
}
}
Ⓐ BidDaoImpl 扩展了 AbstractGenericDao 并通过 Bid 进行泛型化。
Ⓑ 构造函数将 Bid.class 设置为要管理的实体类。所有其他方法都从 AbstractGenericDao 继承而来,并且以这种方式完全可重用。
需要对配置和测试类进行一些小的更改。SpringConfiguration 类现在将声明两个 DAO 实例作为 GenericDao:
Path: Ch14/spring-jpa-dao-gen/src/test/java/com/manning/javapersistence
➥ /ch14/configuration/SpringConfiguration.java
@Bean
public GenericDao<Item> itemDao() {
return new ItemDaoImpl();
}
@Bean
public GenericDao<Bid> bidDao() {
return new BidDaoImpl();
}
DatabaseService 类将注入 itemDao 字段作为 GenericDao:
Path: Ch14/spring-jpa-dao-gen/src/test/java/com/manning/javapersistence
➥ /ch14/DatabaseService.java
@Autowired
private GenericDao<Item> itemDao;
SpringJpaTest 类将注入 itemDao 和 bidDao 字段作为 GenericDao:
Path: Ch14/spring-jpa-dao-gen/src/test/java/com/manning/javapersistence
➥ /ch14/SpringJpaTest.java
@Autowired
private GenericDao<Item> itemDao;
@Autowired
private GenericDao<Bid> bidDao;
我们现在已经使用 JPA API 开发了一个易于扩展的 DAO 类层次结构。我们可以重用已经编写的通用功能,或者我们可以快速覆盖特定实体的一些方法(如 ItemDaoImpl 的情况)。
现在让我们转向使用 Spring 和 DAO 模式实现 Hibernate 应用程序的替代方案。
14.4 使用 Spring 和 DAO 模式实现的 Hibernate 应用程序
现在我们将展示如何使用 Spring 和 DAO 模式与 Hibernate API。正如我们之前提到的,我们将强调这种方法与之前应用程序之间的差异。
调用 sessionFactory.getCurrentSession() 将创建一个新的 Session(如果不存在的话)。否则,它将使用 Hibernate 上下文中的现有会话。当事务结束时,会话将自动刷新和关闭。在单线程应用程序中使用 sessionFactory.getCurrentSession() 是理想的,因为使用单个会话将提高性能。在多线程应用程序中,会话不是线程安全的,因此您应该使用 sessionFactory.openSession() 并显式关闭打开的会话。或者,因为 Session 实现了 AutoCloseable 接口,它可以在 try-with-resources 块中使用。
由于 Item 和 Bid 类以及 ItemDao 和 BidDao 接口保持不变,我们将转向 ItemDaoImpl 和 BidDaoImpl,看看它们现在是什么样子。
列表 14.12 使用 Hibernate API 的 ItemDaoImpl 类
Path: Ch14/spring-hibernate-dao/src/main/java/com/manning/javapersistence
➥ /ch14/dao/ItemDaoImpl.java
@Repository Ⓐ
@Transactional Ⓐ
public class ItemDaoImpl implements ItemDao {
@Autowired Ⓑ
private SessionFactory sessionFactory; Ⓑ
@Override Ⓒ
public Item getById(long id) { Ⓒ
return sessionFactory.getCurrentSession().get(Item.class, id); Ⓒ
}
@Override Ⓓ
public List<Item> getAll() { Ⓓ
return sessionFactory.getCurrentSession() Ⓓ
.createQuery("from Item", Item.class).list(); Ⓓ
}
@Override Ⓔ
public void insert(Item item) { Ⓔ
sessionFactory.getCurrentSession().persist(item); Ⓔ
for (Bid bid : item.getBids()) { Ⓔ
sessionFactory.getCurrentSession().persist(bid); Ⓔ
} Ⓔ
}
@Override Ⓕ
public void update(long id, String name) { Ⓕ
Item item = sessionFactory.getCurrentSession().get(Item.class, id); Ⓕ
item.setName(name); Ⓕ
sessionFactory.getCurrentSession().update(item); Ⓕ
}
@Override Ⓖ
public void delete(Item item) { Ⓖ
sessionFactory.getCurrentSession() Ⓖ
.createQuery("delete from Bid b where b.item.id = :id") Ⓖ
.setParameter("id", item.getId()).executeUpdate(); Ⓖ
sessionFactory.getCurrentSession() Ⓖ
.createQuery("delete from Item i where i.id = :id") Ⓖ
.setParameter("id", item.getId()).executeUpdate(); Ⓖ
}
@Override
public Item findByName(String name) { Ⓗ
return sessionFactory.getCurrentSession() Ⓗ
.createQuery("from Item where name=:name", Item.class) Ⓗ
.setParameter("name", name).uniqueResult(); Ⓗ
}
}
Ⓐ ItemDaoImpl 类被注解为 @Repository 和 @Transactional。
Ⓑ SessionFactory sessionFactory 字段在应用程序中被注入,因为它被注解为 @Autowired。
Ⓒ 通过 id 检索一个 Item。调用 sessionFactory.getCurrentSession() 将创建一个新的 Session(如果不存在的话)。
Ⓓ 检索所有 Item 实体。
Ⓔ 持久化一个 Item 及其所有的 Bid。
Ⓕ 更新 Item 的 name 字段。
Ⓖ 删除属于 Item 及其本身的全部投标。
Ⓗ 通过 name 搜索一个 Item。
BidDaoImpl 类也将反映之前使用 JPA 和 EntityManager 实现的功能,但它将使用 Hibernate API 和 SessionFactory。
在 SpringConfiguration 类中也有重要的更改。从 JPA 转换到 Hibernate,要注入的 EntityManagerFactory Bean 将被 SessionFactory 替换。同样,要注入的 JpaTransactionManager Bean 将被 HibernateTransactionManager 替换。
列表 14.13 使用 Hibernate API 的 SpringConfiguration 类
Path: Ch14/spring-hibernate-dao/src/test/java/com/manning/javapersistence
➥ /ch14/configuration/SpringConfiguration.java
@EnableTransactionManagement Ⓐ
public class SpringConfiguration {
@Bean Ⓑ
public LocalSessionFactoryBean sessionFactory() { Ⓑ
LocalSessionFactoryBean sessionFactory = Ⓑ
new LocalSessionFactoryBean(); Ⓑ
sessionFactory.setDataSource(dataSource()); Ⓒ
sessionFactory.setPackagesToScan( Ⓒ
new String[]{"com.manning.javapersistence.ch14"}); Ⓒ
sessionFactory.setHibernateProperties(hibernateProperties()); Ⓓ
return sessionFactory; Ⓑ
}
private Properties hibernateProperties() { Ⓔ
Properties hibernateProperties = new Properties(); Ⓔ
hibernateProperties.setProperty(AvailableSettings.HBM2DDL_AUTO, Ⓔ
"create"); Ⓔ
hibernateProperties.setProperty(AvailableSettings.SHOW_SQL, Ⓔ
"true"); Ⓔ
hibernateProperties.setProperty(AvailableSettings.DIALECT, Ⓔ
"org.hibernate.dialect.MySQL8Dialect"); Ⓔ
return hibernateProperties; Ⓔ
}
@Bean Ⓕ
public DataSource dataSource() { Ⓕ
DriverManagerDataSource dataSource = new DriverManagerDataSource(); Ⓕ
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver"); Ⓖ
dataSource.setUrl( Ⓗ
"jdbc:mysql://localhost:3306/CH14_SPRING_HIBERNATE?serverTimezone= Ⓗ
➥ UTC "); Ⓗ
dataSource.setUsername("root"); Ⓘ
dataSource.setPassword(""); Ⓙ
return dataSource; Ⓕ
}
@Bean Ⓚ
public DatabaseService databaseService() { Ⓚ
return new DatabaseService(); Ⓚ
}
@Bean Ⓛ
public HibernateTransactionManager transactionManager( Ⓛ
SessionFactory sessionFactory) { Ⓛ
HibernateTransactionManager transactionManager Ⓛ
= new HibernateTransactionManager(); Ⓛ
transactionManager.setSessionFactory(sessionFactory); Ⓛ
return transactionManager; Ⓛ
}
@Bean Ⓜ
public ItemDao itemDao() { Ⓜ
return new ItemDaoImpl(); Ⓜ
}
@Bean Ⓝ
public BidDao bidDao() { Ⓝ
return new BidDaoImpl(); Ⓝ
}
}
Ⓐ @EnableTransactionManagement 注解将启用 Spring 的基于注解的事务管理功能。
Ⓑ LocalSessionFactoryBean 是要注入的 sessionFactory 对象。
Ⓒ 设置数据源和要扫描的包。
Ⓓ 设置将从单独的方法中提供的 Hibernate 属性。
Ⓔ 在单独的方法中创建 Hibernate 属性。
Ⓕ 创建一个数据源 Bean。
Ⓖ 指定 JDBC 属性——驱动程序。
Ⓗ 数据库的 URL。
Ⓘ 用户名。
Ⓙ 此配置中没有密码。修改凭据以匹配您机器上的凭据,并在实际操作中使用密码。
Ⓚ 将使用 DatabaseService bean 来填充和清除数据库。
Ⓛ 基于会话工厂创建一个事务管理器 bean。每次与数据库的交互都应该在事务边界内进行,因此 Spring 需要一个事务管理器 bean。
Ⓜ 创建一个 ItemDao bean。
Ⓝ 创建一个 BidDao bean。
我们可以使用 XML 配置的替代方案,并且 application-context.xml 文件应该反映 SpringConfiguration.java 中完成的工作。启用 Spring 的基于注解的事务管理功能是通过 tx:annotation-driven 元素实现的,该元素引用一个事务管理器 bean,而不是使用 @EnableTransactionManagement 注解。
我们现在将演示如何泛型化使用 Hibernate API 而不是 JPA 的应用程序。像往常一样,我们将关注与初始解决方案的差异以及如何引入这些更改。
14.5 泛型化使用 Spring 和 DAO 的 Hibernate 应用程序
请记住我们之前为使用数据访问对象 (DAO) 的 JPA 解决方案确定的缺点:
-
存在类似的操作,如
getById、getAll、insert和delete,它们主要区别在于接收的参数类型或返回的结果类型。 -
update方法接收一个特定属性的值作为第二个参数。如果我们需要更新实体的不同属性,我们可能需要编写多个方法。 -
如
findByName或findByAmount这样的方法与特定的属性相关联。我们可能需要为使用不同属性查找实体编写不同的方法。
为了解决这些缺点,我们引入了 GenericDao 接口,如前所述在列表 14.8 中所示。该接口由 AbstractGenericDao 类(列表 14.9)实现,现在需要使用 Hibernate API 重新编写。
列表 14.14 使用 Hibernate API 的 AbstractGenericDao 类
Path: Ch14/spring-hibernate-dao-gen/src/main/java/com/manning
➥ /javapersistence/ch14/dao/AbstractGenericDao.java
@Repository Ⓐ
@Transactional Ⓐ
public abstract class AbstractGenericDao<T> implements GenericDao<T> {
@Autowired Ⓑ
protected SessionFactory sessionFactory; Ⓑ
private Class<T> clazz; Ⓒ
public void setClazz(Class<T> clazz) { Ⓒ
this.clazz = clazz; Ⓒ
}
@Override Ⓓ
public T getById(long id) { Ⓓ
return sessionFactory.getCurrentSession() Ⓓ
.createQuery("SELECT e FROM " + clazz.getName() + Ⓓ
" e WHERE e.id = :id", clazz) Ⓓ
.setParameter("id", id).getSingleResult(); Ⓓ
}
@Override Ⓔ
public List<T> getAll() { Ⓔ
return sessionFactory.getCurrentSession() Ⓔ
.createQuery("from " + clazz.getName(), clazz).getResultList(); Ⓔ
}
@Override Ⓕ
public void insert(T entity) { Ⓕ
sessionFactory.getCurrentSession().persist(entity); Ⓕ
}
@Override
public void delete(T entity) { Ⓖ
sessionFactory.getCurrentSession().delete(entity); Ⓖ
}
@Override Ⓗ
public void update(long id, String propertyName, Object propertyValue) { Ⓗ
sessionFactory.getCurrentSession() Ⓗ
.createQuery("UPDATE " + clazz.getName() + " e SET e." + Ⓗ
propertyName + " = :propertyValue WHERE e.id = :id") Ⓗ
.setParameter("propertyValue", propertyValue) Ⓗ
.setParameter("id", id).executeUpdate(); Ⓗ
}
@Override Ⓘ
public List<T> findByProperty(String propertyName, Ⓘ
Object propertyValue) { Ⓘ
return sessionFactory.getCurrentSession() Ⓘ
.createQuery("SELECT e FROM " + clazz.getName() + " e WHERE e." + Ⓘ
propertyName + " = :propertyValue", clazz) Ⓘ
.setParameter("propertyValue", propertyValue).getResultList(); Ⓘ
}
}
Ⓐ AbstractGenericDao 类被注解为 @Repository 和 @Transactional。
Ⓑ SessionFactory sessionFactory 字段被注入到应用程序中,因为它被注解为 @Autowired。它是 protected 的,以便被子类继承和使用。
Ⓒ clazz 是 DAO 将在其上工作的有效 Class 字段。
Ⓓ 使用 clazz 实体和设置 id 作为参数执行一个 SELECT 查询。
Ⓔ 使用 clazz 实体执行一个 SELECT 查询并获取结果列表。
Ⓕ 持久化 entity。
Ⓖ 移除 entity。
Ⓗ 使用 propertyName、propertyValue 和 id 执行一个 UPDATE 操作。
Ⓘ 使用 propertyName 和 propertyValue 执行一个 SELECT 操作。
当 ItemDaoImpl 和 BidDaoImpl 扩展 AbstractGenericDao 类时,我们将自定义该类,这次使用 Hibernate API。
ItemDaoImpl 类将扩展 AbstractGenericDao 类并重写其中的一些方法。
列表 14.15 使用 Hibernate API 的 ItemDaoImpl 类
Path: Ch14/spring-jpa-hibernate-gen/src/main/java/com/manning
➥ /javapersistence/ch14/dao/ItemDaoImpl.java
public class ItemDaoImpl extends AbstractGenericDao<Item> { Ⓐ
public ItemDaoImpl() { Ⓑ
setClazz(Item.class); Ⓑ
}
@Override Ⓒ
public void insert(Item item) { Ⓒ
sessionFactory.getCurrentSession().persist(item); Ⓒ
for (Bid bid : item.getBids()) { Ⓒ
sessionFactory.getCurrentSession().persist(bid); Ⓒ
} Ⓒ
}
@Override Ⓓ
public void delete(Item item) { Ⓓ
sessionFactory.getCurrentSession() Ⓓ
.createQuery("delete from Bid b where b.item.id = :id") Ⓓ
.setParameter("id", item.getId()).executeUpdate(); Ⓓ
sessionFactory.getCurrentSession() Ⓓ
.createQuery("delete from Item i where i.id = :id") Ⓓ
.setParameter("id", item.getId()).executeUpdate(); Ⓓ
}
}
Ⓐ ItemDaoImpl 扩展了 AbstractGenericDao 并通过 Item 进行泛型化。
Ⓑ 构造函数将Item.class设置为要管理的实体类。
Ⓒ 持久化Item实体及其所有Bid实体。sessionFactory字段继承自AbstractGenericDao类。
Ⓓ 删除属于Item及其本身的全部投标。
BidDaoImpl类将简单地扩展AbstractGenericDao类,并设置要管理的实体类。
列表 14.16 使用 Hibernate API 的BidDaoImpl类
Path: Ch14/spring-hibernate-dao-gen/src/main/java/com/manning
➥ /javapersistence/ch14/dao/BidDaoImpl.java
public class BidDaoImpl extends AbstractGenericDao<Bid> { Ⓐ
public BidDaoImpl() { Ⓑ
setClazz(Bid.class); Ⓑ
}
}
Ⓐ BidDaoImpl扩展了AbstractGenericDao并由Bid泛化。
Ⓑ 构造函数将Bid.class设置为要管理的实体类。所有其他方法都继承自AbstractGenericDao,并且以此方式完全可重用。
我们已经使用 Hibernate API 开发了一个易于扩展的 DAO 类层次结构。我们可以重用已经编写的通用功能,或者我们可以快速覆盖特定实体的一些方法(就像我们对ItemDaoImpl所做的那样)。
摘要
-
依赖注入设计模式是 Spring 框架的基础,支持创建由不同组件和对象组成的应用程序。
-
您可以使用 Spring 框架和 DAO 模式开发 JPA 应用程序。Spring 控制
EntityManager、事务管理器以及应用程序使用的其他 bean。 -
您可以将 JPA 应用程序泛化以提供一个通用且易于扩展的 DAO 基类。DAO 基类包含所有派生 DAO 需要继承的通用行为,并将允许它们仅实现其特定的行为。
-
您可以使用 Spring 框架和 DAO 模式开发 Hibernate 应用程序。Spring 控制
SessionFactory、事务管理器以及应用程序使用的其他 bean。 -
您可以将 Hibernate 应用程序泛化以提供一个通用且易于扩展的 DAO 基类。DAO 基类包含所有派生 DAO 需要继承的通用行为,并将允许它们仅实现其特定的行为。
15 使用 Spring Data JDBC
本章涵盖
-
开始一个 Spring Data JDBC 项目
-
在 Spring Data JDBC 中处理查询和查询方法
-
使用 Spring Data JDBC 建立关系
-
使用 Spring Data JDBC 建模嵌入式实体
我们在第二章介绍了 Spring Data:它是一个包含许多项目的母项目,这些项目的目的是通过遵循 Spring 框架原则来简化对关系型数据库和 NoSQL 数据库的访问。在第四章中,我们详细探讨了 Spring Data JPA 项目的原则和能力。Spring Data JDBC 的目的在于高效地处理基于 JDBC 的仓库。它是这个家族中的较新项目,并且它并不提供所有 JPA 功能,例如缓存或懒加载,因此它提供了一个更简单、更有限的 ORM。然而,它正在不断发展,并在每个版本中引入新功能。
当我们已经有 JPA、Hibernate 和 Spring Data JPA 等替代方案时,为什么还需要 Spring Data JDBC 呢?事实是,对象/关系映射(ORM)使项目变得复杂,你已经在之前的章节中清楚地看到了这一点。有些情况下,我们可能希望消除这种复杂性,并利用当今最受欢迎的 Java 框架 Spring 的好处。我们有什么替代方案?
如果我们回顾传统的 JDBC,我们必须记住它的缺点,比如自己打开和关闭连接或手动处理异常——总的来说,我们不得不处理大量的服务代码。
Spring Data JDBC 允许我们创建自己的查询以在数据库上执行,但它也有自己的 ORM,并使用 JPA、Hibernate 和 Spring Data JPA 已经使用的概念:实体、仓库和@Query注解。Spring Data JDBC 不使用 JPQL,也没有可移植性。查询必须以纯 SQL 编写,并且必须是特定于数据库供应商的。实体加载必须通过 SQL 查询完成,要么是完整的,要么是不存在的。缓存和懒加载不可用。会话和脏检查不存在;我们必须显式保存实体。
此外,在撰写本章时,Spring Data JDBC 不支持模式生成。我们可以像在 Hibernate 或 Spring Data JPA 中那样声明我们的实体,但 DDL 命令需要编写和运行。
让我们创建一个使用 Spring Data JDBC 的项目,并分析其能力以支持我们在引入新功能时的需求。
15.1 创建一个 Spring Data JDBC 项目
在本章中,我们将创建一个应用程序,它将使用 Spring Data JDBC 作为持久化框架来管理和持久化 CaveatEmptor 用户,这与我们在第四章中使用 Spring Data JPA 所做的一样。我们将创建一个 Spring Boot 应用程序来使用 Spring Data JDBC。
要开始,我们将使用 Spring Initializr 网站(start.spring.io/)创建一个新的 Spring Boot 项目(图 15.1),具有以下特性:
-
组:com.manning.javapersistence
-
工件:spring-data-jdbc
-
描述:Spring Data JDBC 项目
我们还将添加以下依赖项:
-
Spring Data JDBC(这将向 Maven pom.xml 文件中添加
spring-boot-starter-data-jdbc) -
MySQL 驱动程序(这将向 Maven pom.xml 文件中添加
mysql-connector-java)
注意:要执行源代码中的示例,您首先需要运行 Ch15.sql 脚本。

图 15.1 使用 Spring Data JDBC 和 MySQL 创建新的 Spring Boot 项目
项目骨架包含四个文件:
-
SpringDataJdbcApplication,包括一个骨架
main方法 -
SpringDataJdbcApplicationTests,包括一个骨架测试方法
-
application.properties,最初为空
-
pom.xml,包括 Maven 需要的管理信息
下面的列表中显示的 pom.xml 文件包括我们添加以启动 Spring Data JDBC 项目的依赖项:我们将使用 Spring Data JDBC 框架来访问 MySQL 数据库,我们需要该驱动程序。
列表 15.1 pom.xml Maven 文件
Path: Ch15/spring-data-jdbc/pom.xml
\1
<dependency> Ⓐ
<groupId>org.springframework.boot</groupId> Ⓐ
<artifactId>spring-boot-starter-data-jdbc</artifactId> Ⓐ
</dependency> Ⓐ
<dependency> Ⓑ
<groupId>mysql</groupId> Ⓑ
<artifactId>mysql-connector-java</artifactId> Ⓑ
<scope>runtime</scope> Ⓑ
</dependency> Ⓑ
<dependency> Ⓒ
<groupId>org.springframework.boot</groupId> Ⓒ
<artifactId>spring-boot-starter-test</artifactId> Ⓒ
<scope>test</scope> Ⓒ
</dependency> Ⓒ
</dependencies>
Ⓐ spring-boot-starter-data-jdbc 是 Spring Boot 用于通过 Spring Data JDBC 连接到关系型数据库的启动依赖项。
Ⓑ mysql-connector-java 是 MySQL 的 JDBC 驱动程序。它是一个运行时依赖项,意味着它仅在运行时需要包含在类路径中。
Ⓒ spring-boot-starter-test 是 Spring Boot 用于测试的启动依赖项。它仅在测试编译和执行阶段需要。
application.properties 文件可以包含应用程序将使用的各种属性。Spring Boot 将自动从类路径中查找并加载 application.properties 文件,并且 Maven 将 src/main/resources 文件夹添加到类路径中。由于初始化脚本默认仅对嵌入式数据库运行,而我们使用的是 MySQL,因此我们必须通过设置初始化模式 spring.sql.init.mode 为 always 来强制执行脚本。配置文件如下所示。
列表 15.2 application.properties 文件
Path: Ch15/spring-data-jdbc/src/main/resources/application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/CH15_SPRINGDATAJDBC
➥ ?serverTimezone=UTC Ⓐ
spring.datasource.username=root Ⓑ
spring.datasource.password= Ⓑ
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect Ⓒ
spring.sql.init.mode=always Ⓓ
Ⓐ 数据库的 URL。
Ⓑ 访问数据库的凭证。请用您机器上的凭证替换它们,并在实际生活中使用密码。
Ⓒ 数据库方言,MySQL。
Ⓓ SQL 初始化模式是 always,因此 SQL 文件将始终执行,重新创建数据库模式。
自动执行的 SQL 脚本将类似于以下列表中的脚本,删除并重新创建 USERS 表。在启动时,Spring Boot 将始终执行类路径中的 schema.sql 和 data.sql 文件。
列表 15.3 schema.sql 文件
Path: Ch15/spring-data-jdbc/src/main/resources/schema.sql
DROP TABLE IF EXISTS USERS;
CREATE TABLE USERS (
ID INTEGER AUTO_INCREMENT PRIMARY KEY,
USERNAME VARCHAR(30),
REGISTRATION_DATE DATE
);
现在,我们将定义与 USERS 表对应的实体类,如下列 15.4 所示。我们将使用一些特定的 Spring 注解来配置类如何映射到数据库中的表:
-
org.springframework.data.relational.core.mapping.Table——这与之前使用的javax.persistence.Table不同,后者是 JPA 特有的。 -
org.springframework.data.annotation.Id——这与之前使用的javax.persistence.Id不同,后者是 JPA 特有的。我们在数据库中定义了相应的列作为IDINTEGERAUTO_INCREMENTPRIMARYKEY,因此数据库将负责生成自动递增的值。 -
org.springframework.data.relational.core.mapping.Column——这与之前使用的javax.persistence.Column不同,后者是 JPA 特有的。对于列名,Spring Data JDBC 将将类字段定义中使用的驼峰式转换为表列定义中使用的蛇形命名。
列表 15.4 User 类
Path: Ch15/spring-data-jdbc/src/main/java/com/manning/javapersistence/ch15
➥ /model/User.java
@Table("USERS") Ⓐ
public class User { Ⓐ
@Id Ⓑ
@Column("ID") Ⓒ
private Long id;
@Column("USERNAME") Ⓓ
private String username;
@Column("REGISTRATION_DATE") Ⓔ
private LocalDate registrationDate;
//constructors, getters and setters
}
Ⓐ 使用 @Table 注解标注 User 类,明确指出对应的表是 USERS。
Ⓑ 使用 @Id 注解标注 id 字段。
Ⓒ 使用 @Column("ID") 注解标注 id 字段,指定数据库中的对应列。这是默认值。
Ⓓ 使用 @Column("USERNAME") 注解标注用户名字段,指定数据库中的对应列。这是默认值。
Ⓔ 使用 @Column("REGISTRATION_DATE") 注解标注 registrationDate 字段,指定数据库中的对应列。这是默认值。
我们还将创建一个扩展 CrudRepository 的 UserRepository 接口,从而提供对数据库的访问。
列表 15.5 UserRepository 接口
Path: Ch15/spring-data-jdbc/src/main/java/com/manning/javapersistence/ch15
➥ /repositories/UserRepository.java
@Repository
public interface UserRepository extends CrudRepository<User, Long> {
List<User> findAll();
}
UserRepository 接口扩展了 CrudRepository<User, Long>。这意味着它是一个 User 实体仓库,具有 Long 标识符。记住,User 类有一个被标注为 @Id 的 id 字段,类型为 Long。我们可以直接调用从 CrudRepository 继承的方法,如 save、findAll 或 findById,而无需任何其他附加信息即可执行对数据库的常规操作。Spring Data JDBC 将创建一个实现 UserRepository 接口的代理类并实现其方法。
注意:值得回顾我们在第 4.3 节中提到的内容:“CrudRepository 是一种通用的、与技术无关的持久化接口,我们不仅可以用于 JPA/关系数据库,正如你迄今为止所看到的。”
我们只重写了 findAll 方法,使其返回 List<User> 而不是 Iterable<User>。这将简化我们未来的测试。作为所有未来测试的基类,我们将编写 SpringDataJdbcApplicationTests 抽象类。
Spring Boot 添加到最初创建的类的@SpringBootTest注解将告诉 Spring Boot 搜索主配置类(例如,被@SpringBootApplication注解的类),并创建用于测试的ApplicationContext。如您所回忆的,Spring Boot 添加到包含main方法的类的@SpringBootApplication注解将启用 Spring Boot 自动配置机制,允许扫描应用程序所在的包,并允许我们在上下文中注册额外的 bean。
使用@TestInstance(TestInstance.Lifecycle.PER_CLASS)注解,我们将要求 JUnit 5 创建一个测试类的单个实例,并为其所有测试方法重用。这将允许我们将@BeforeAll和@AfterAll注解的方法设置为非静态,并直接在它们内部使用自动装配的UserRepository实例字段。被@BeforeAll注解的非静态方法将在所有扩展SpringDataJdbcApplicationTests的类的测试之前执行一次,并将generateUsers方法内部创建的用户列表保存到数据库中。被@AfterAll注解的非静态方法将在所有扩展SpringDataJdbcApplicationTests的类的测试之后执行一次,并将从数据库中删除所有用户。
列表 15.6 SpringDataJdbcApplicationTests抽象类
Path: Ch15/spring-data-jdbc/src/test/java/com/manning/javapersistence/ch15
➥ /SpringDataJdbcApplicationTests.java
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
abstract class SpringDataJdbcApplicationTests {
@Autowired Ⓐ
UserRepository userRepository; Ⓐ
@BeforeAll
void beforeAll() {
userRepository.saveAll(generateUsers());
}
private static List<User> generateUsers() {
List<User> users = new ArrayList<>();
User john = new User("john", LocalDate.of(2020, Month.APRIL, 13));
//create and set a total of 10 users
users.add(john);
//add a total of 10 users to the list
return users;
}
@AfterAll
void afterAll() {
userRepository.deleteAll();
}
}
Ⓐ 自动装配一个UserRepository实例。这是由于@SpringBootApplication注解的存在,它使得 Spring Boot 能够扫描应用程序所在的包,并在上下文中注册这些 bean。
下一个测试将扩展这个类并使用已经填充的数据库。为了测试现在属于UserRepository的方法,我们将创建FindUsersUsingQueriesTest类并遵循编写测试的相同方法:我们将调用仓库方法并验证其结果。
列表 15.7 FindUsersUsingQueriesTest类
Path: Ch15/spring-data-jdbc/src/test/java/com/manning/javapersistence/ch15
➥ /FindUsersUsingQueriesTest.java
public class FindUsersUsingQueriesTest extends
➥ SpringDataJdbcApplicationTests{
@Test
void testFindAll() {
List<User> users = userRepository.findAll();
assertEquals(10, users.size());
}
}
15.2 在 Spring Data JDBC 中处理查询
我们现在将探讨在 Spring Data JDBC 中处理查询。我们将从使用查询构建器机制定义查询开始,然后继续到限制查询结果、排序和分页、流式处理结果、使用修改查询以及创建自定义查询。
15.2.1 使用 Spring Data JDBC 定义查询方法
我们将通过添加email、level和active字段来扩展User类。用户可能有不同的级别,这将允许他们执行特定操作,例如在某个金额以上出价。用户可能是活跃的或退休的(这意味着他们在 CaveatEmptor 拍卖系统中不再活跃)。
我们的目标是编写一个程序,该程序可以处理涉及查找具有特定级别、活跃或不活跃、给定用户名或电子邮件、或具有给定注册日期在特定时间间隔内的用户的用例。
列表 15.8 修改后的User类
Path: Ch15/spring-data-jdbc2/src/main/java/com/manning/javapersistence/ch15
➥ /model/User.java
@Table(name = "USERS")
public class User {
@Id
private Long id;
private String username;
private LocalDate registrationDate;
private String email;
private int level;
private boolean active;
//constructors, getters and setters
}
由于我们现在负责执行 DDL 命令,我们可以修改类路径上 schema.sql 文件的内容。
列表 15.9 修改后的 schema.sql 文件
Path: Ch15/spring-data-jdbc2/src/main/resources/schema.sql
DROP TABLE IF EXISTS USERS;
CREATE TABLE USERS (
ID INTEGER AUTO_INCREMENT PRIMARY KEY,
ACTIVE BOOLEAN,
USERNAME VARCHAR(30),
EMAIL VARCHAR(30),
LEVEL INTEGER,
REGISTRATION_DATE DATE
);
我们现在将向UserRepository接口添加新的查询数据库的方法,并在新创建的测试中使用它们。
列表 15.10 带有新方法的UserRepository接口
Path: Ch15/spring-data-jdbc2/src/main/java/com/manning/javapersistence/ch15
➥ /repositories/UserRepository.java
public interface UserRepository extends CrudRepository<User, Long> {
List<User> findAll();
Optional<User> findByUsername(String username);
List<User> findAllByOrderByUsernameAsc();
List<User> findByRegistrationDateBetween(LocalDate start,
➥ LocalDate end);
List<User> findByUsernameAndEmail(String username, String email);
List<User> findByUsernameOrEmail(String username, String email);
List<User> findByUsernameIgnoreCase(String username);
List<User> findByLevelOrderByUsernameDesc(int level);
List<User> findByLevelGreaterThanEqual(int level);
List<User> findByUsernameContaining(String text);
List<User> findByUsernameLike(String text);
List<User> findByUsernameStartingWith(String start);
List<User> findByUsernameEndingWith(String end);
List<User> findByActive(boolean active);
List<User> findByRegistrationDateIn(Collection<LocalDate> dates);
List<User> findByRegistrationDateNotIn(Collection<LocalDate> dates);
// . . .
}
查询方法的目的在于从数据库中检索信息。从 2.0 版本开始,Spring Data JDBC 提供了一个类似于 Spring Data JPA 的查询构建器机制——它根据方法名称创建仓库方法的操作行为。请记住,查询机制会从方法名称中移除前缀和后缀,如find...By、get...By、query...By、read...By和count...By,然后解析剩余部分。
与 Spring Data JPA 类似,Spring Data JDBC 会查看方法的返回类型。如果我们想查找一个User并将其返回在Optional容器中,方法返回类型将是Optional<User>。
方法的名称需要遵循确定结果查询的规则。目前定义的查询方法可能只能使用可以包含在WHERE子句中的属性,但不能使用连接。如果方法命名错误(例如,实体属性在查询方法中不匹配),则在加载应用程序上下文时将得到错误。表 15.1 总结了在构建 Spring Data JDBC 查询方法及其结果条件中使用的必要关键字。对于更全面的列表,请参阅附录 C。
表 15.1 Spring Data JDBC 中的关键字使用及其结果条件
| 关键字 | 示例 | 条件 |
|---|---|---|
Is、Equals |
findByUsername(String name)``findByUsernameIs(String name)``findByUsernameEquals(String name) |
username = name |
And |
findByUsernameAndRegistrationDate(String name, LocalDate date) |
username = name and registration_date = date |
Or |
findByUsernameOrRegistrationDate(String name, LocalDate date) |
username = name or registrationdatev= name |
LessThan |
findByRegistrationDateLessThan(LocalDate date) |
registrationdate < date |
LessThanEqual |
findByRegistrationDateLessThanEqual(LocalDate date) |
registrationdate <= date |
GreaterThan |
findByRegistrationDateGreaterThan(LocalDate date) |
registrationdate > date |
GreaterThanEqual |
findByRegistrationDateGreaterThanEqual(LocalDate date) |
registrationdate >= date |
Between |
findByRegistrationDateBetween(LocalDate from, LocalDate to) |
registrationdate between from and to |
OrderBy |
findByRegistrationDateOrderByUsernameDesc(LocalDate date) |
registrationdate = date order by username desc |
Like |
findByUsernameLike(String name) |
username like name |
NotLike |
findByUsernameNotLike(String name) |
username not like name |
Before |
findByRegistrationDateBefore(LocalDate date) |
registrationdate < date |
After |
findByRegistrationDateAfter(LocalDate date) |
registrationdate > date |
Null, IsNull |
findByRegistrationDate(Is)Null() |
registrationdate is null |
NotNull, IsNotNull |
findByRegistrationDate(Is)NotNull() |
registrationdate is not null |
Not |
findByUsernameNot(String name) |
username <> name |
我们将通过为每个用户配置新引入的字段email、level和active来扩展SpringDataJdbcApplicationTests抽象类,这是我们的测试的基础类。
列表 15.11 更新的SpringDataJdbcApplicationTests抽象类
Path: Ch15/spring-data-jdbc2/src/test/java/com/manning/javapersistence/ch15
➥ /SpringDataJdbcApplicationTests.java
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
abstract class SpringDataJdbcApplicationTests {
// . . .
private static List<User> generateUsers() {
List<User> users = new ArrayList<>();
User john = new User("john", LocalDate.of(2020, Month.APRIL, 13));
john.setEmail("john@somedomain.com");
john.setLevel(1);
john.setActive(true);
//create and set a total of 10 users
users.add(john);
//add a total of 10 users to the list
return users;
}
// . . .
}
下一个测试扩展了这个类并使用已经填充的数据库。我们想要解决的使用案例是获取满足特定条件(例如在给定区间内的注册日期)的用户或用户列表或按用户名排序。为了测试现在属于UserRepository的方法,我们将创建FindUsersUsingQueriesTest类并遵循编写测试的相同方法:调用仓库方法并验证其结果。
列表 15.12 FindUsersUsingQueriesTest类
Path: Ch15/spring-data-jdbc2/src/test/java/com/manning/javapersistence/ch15
➥ /FindUsersUsingQueriesTest.java
public class FindUsersUsingQueriesTest extends
➥ SpringDataJdbcApplicationTests{
@Test
void testFindAll() {
List<User> users = userRepository.findAll();
assertEquals(10, users.size());
}
@Test
void testFindUser() {
User beth = userRepository.findByUsername("beth").get();
assertEquals("beth", beth.getUsername());
}
@Test
void testFindAllByOrderByUsernameAsc() {
List<User> users = userRepository.findAllByOrderByUsernameAsc();
assertAll(() -> assertEquals(10, users.size()),
() -> assertEquals("beth", users.get(0).getUsername()),
() -> assertEquals("stephanie",
users.get(users.size() - 1).getUsername()));
}
@Test
void testFindByRegistrationDateBetween() {
List<User> users = userRepository.findByRegistrationDateBetween(
LocalDate.of(2020, Month.JULY, 1),
LocalDate.of(2020, Month.DECEMBER, 31));
assertEquals(4, users.size());
}
//more tests
}
15.2.2 限制查询结果、排序和分页
与 Spring Data JPA 类似,first和top关键字(等效使用)可以限制查询方法的结果。top和first关键字后面可以跟一个可选的数值,表示要返回的最大结果大小。如果这个数值缺失,结果大小将是 1。
Pageable是一个用于分页信息的接口。在实际应用中,我们使用实现该接口的PageRequest类。这个类可以指定页码、页面大小和排序标准。
我们想要解决的使用案例包括获取一定数量的用户(例如按用户名或注册日期排序的第一个用户)或具有特定级别的前几个用户,按注册日期排序,或者按页获取大量用户,这样我们可以轻松地操作它们。
我们将向UserRepository接口添加以下方法。
列表 15.13 在UserRepository中限制查询结果、排序和分页
Path: Ch15/spring-data-jdbc2/src/main/java/com/manning/javapersistence/ch15
➥ /repositories/UserRepository.java
Optional<User> findFirstByOrderByUsernameAsc();
Optional<User> findTopByOrderByRegistrationDateDesc();
Page<User> findAll(Pageable pageable);
List<User> findFirst2ByLevel(int level, Sort sort);
List<User> findByLevel(int level, Sort sort);
List<User> findByActive(boolean active, Pageable pageable);
这些方法遵循查询构建器机制所需的模式(总结在表 15.1 中),但这次它们的目的是限制查询结果,以便我们可以进行排序和分页。例如,Optional<User> findFirstByOrderByUsernameAsc()方法将按用户名的升序获取第一个用户(结果是Optional,所以最终可能不存在)。Page<User> findAll(Pageable pageable)方法将按页获取所有用户。我们将编写以下测试来验证这些新添加的方法如何工作。
列表 15.14 测试限制查询结果、排序和分页
Path: Ch15/spring-data-jdbc2/src/test/java/com/manning/javapersistence/ch15
➥ /FindUsersSortingAndPagingTest.java
public class FindUsersSortingAndPagingTest extends
SpringDataJdbcApplicationTests {
@Test
void testOrder() {
User user1 = userRepository.findFirstByOrderByUsernameAsc().get(); Ⓐ
User user2 = Ⓐ
userRepository.findTopByOrderByRegistrationDateDesc().get(); Ⓐ
Page<User> userPage = userRepository.findAll(PageRequest.of(1, 3)); Ⓑ
List<User> users = userRepository.findFirst2ByLevel(2, Ⓒ
Sort.by("registrationDate")); Ⓒ
assertAll(
() -> assertEquals("beth", user1.getUsername()),
() -> assertEquals("julius", user2.getUsername()),
() -> assertEquals(2, users.size()),
() -> assertEquals(3, userPage.getSize()),
() -> assertEquals("beth", users.get(0).getUsername()),
() -> assertEquals("marion", users.get(1).getUsername())
);
}
@Test
void testFindByLevel() {
Sort.TypedSort<User> user = Sort.sort(User.class); Ⓓ
List<User> users = userRepository.findByLevel(3, Ⓔ
user.by(User::getRegistrationDate).descending()); Ⓔ
assertAll(
() -> assertEquals(2, users.size()),
() -> assertEquals("james", users.get(0).getUsername())
);
}
@Test
void testFindByActive() {
List<User> users = userRepository.findByActive(true, Ⓕ
PageRequest.of(1, 4, Sort.by("registrationDate"))); Ⓕ
assertAll(
() -> assertEquals(4, users.size()),
() -> assertEquals("burk", users.get(0).getUsername())
);
}
}
Ⓐ 第一个测试按用户名的升序和按注册日期的降序找到第一个用户。
Ⓑ 查找所有用户,将它们分成页面,并返回第 1 页,大小为 3(页码从 0 开始)。
Ⓒ 查找前两个等级为 2 的用户,并按注册日期排序。
Ⓓ 第二个测试在 User 类上定义了一个排序标准。Sort.TypedSort 扩展了 Sort 并可以使用方法句柄来定义排序的属性。
Ⓔ 查找等级为 3 的用户,并按注册日期降序排序。
Ⓕ 第三个测试按注册日期排序活动用户,将它们分成页面,并返回第 1 页,大小为 4(页码从 0 开始)。
15.2.3 流式结果
返回多个结果的查询方法可以使用标准 Java 接口,如 Iterable、List 和 Set。与 Spring Data JPA 一样,Spring Data JDBC 支持 Streamable,它可以作为 Iterable 或任何集合类型的替代品。它允许我们连接 Streamables 并直接过滤和映射元素。
我们将解决的用例是作为流获取结果,而不必等待整个用户集合或用户页面被获取。这样,我们可以快速开始处理流中流入的第一个结果。与集合不同,流只能消费一次,且不可变。
我们将在 UserRepository 接口中添加以下方法。
列表 15.15 在 UserRepository 中添加返回 Streamable 的方法
Path: Ch15/spring-data-jdbc2/src/main/java/com/manning/javapersistence/ch15
➥ /repositories/UserRepository.java
Streamable<User> findByEmailContaining(String text);
Streamable<User> findByLevel(int level);
我们将编写以下测试来验证这些新添加的方法如何与数据库交互并提供结果作为流。流作为 try 块的资源给出,因此它将被自动关闭。另一种选择是显式调用 close() 方法。否则,流将保持与数据库的底层连接。
列表 15.16 测试返回 Streamable 的方法
Path: Ch15/spring-data-jdbc2/src/test/java/com/manning/javapersistence/ch15
➥ /QueryResultsTest.java
@Test
void testStreamable() {
try(Stream<User> result = Ⓐ
userRepository.findByEmailContaining("someother") Ⓐ
.and(userRepository.findByLevel(2)) Ⓑ
.stream().distinct()) { Ⓒ
assertEquals(6, result.count()); Ⓓ
}
}
Ⓐ 测试将调用 findByEmailContaining 方法来搜索包含 someother 单词的电子邮件。
Ⓑ 测试将结果 Streamable 与提供等级 2 用户的 Streamable 连接起来。
Ⓒ 它将将其转换为流并保留不同的用户。
Ⓓ 检查结果流包含 6 个用户。
15.2.4 @Query 注解
我们可以使用 @Query 注解来创建可以指定自定义查询的方法。使用 @Query 注解,方法名称不需要遵循任何命名约定。自定义查询可以是参数化的,但与 Spring Data JPA 不同,参数只能通过名称识别,并且它们必须使用 @Param 注解在查询中绑定。与 Spring Data JPA 不同,我们不使用 JPQL 而是使用 SQL。因此,没有可移植性——如果你更改数据库供应商,你必须重写查询。
我们将在 UserRepository 接口中添加两个新方法。这些方法将使用 @Query 注解,并且它们的生成行为将取决于这些查询的定义。
列表 15.17 在 UserRepository 接口中使用 @Query 注解的方法
Path: Ch15/spring-data-jdbc2/src/main/java/com/manning/javapersistence/ch15
➥ /repositories/UserRepository.java
@Query("SELECT COUNT(*) FROM USERS WHERE ACTIVE = :ACTIVE") Ⓐ
int findNumberOfUsersByActivity(@Param("ACTIVE") boolean active); Ⓐ
@Query("SELECT * FROM USERS WHERE LEVEL = :LEVEL AND ACTIVE = :ACTIVE") Ⓑ
List<User> findByLevelAndActive(@Param("LEVEL") int level, @Param("ACTIVE") Ⓑ
boolean active); Ⓑ
Ⓐ findNumberOfUsersByActivity方法将返回活跃用户的数量。
Ⓑ findByLevelAndActive方法将返回具有给定命名参数level和active状态的用户。@Param注解将查询的:LEVEL参数与方法的level参数匹配,将查询的:ACTIVE参数与方法的active参数匹配。
为这些查询方法编写测试相对简单,与之前的示例类似。它们可以在本书的源代码中找到。
15.2.5 修改查询
我们可以使用@Modifying注解定义修改方法。例如,INSERT、UPDATE和DELETE查询以及 DDL 语句修改数据库的内容。@Query注解可以将修改查询作为参数,并且可能需要绑定参数。在撰写本文时,Spring Data JDBC 不支持删除方法的查询推导(与 Spring Data JPA 不同)。
我们将添加新的方法,这些方法带有@Query注解,到UserRepository接口中,但这次查询将更新或删除USERS表中的记录。
列表 15.18 向UserRepository接口添加修改方法
Path: Ch15/spring-data-jdbc2/src/main/java/com/manning/javapersistence/ch15
➥ /repositories/UserRepository.java
@Modifying
@Query("UPDATE USERS SET LEVEL = :NEW_LEVEL WHERE LEVEL = :OLD_LEVEL")
int updateLevel(@Param("OLD_LEVEL") int oldLevel,
➥ @Param("NEW_LEVEL")int newLevel); Ⓐ
@Modifying
@Query("DELETE FROM USERS WHERE LEVEL = :LEVEL")
int deleteByLevel(@Param("LEVEL") int level); Ⓑ
Ⓐ updateLevel方法将更改具有oldLevel的用户level并将其设置为newLevel。该方法还带有@Modifying注解。
Ⓑ deleteByLevel方法将删除所有具有作为@Query注解参数的level的用户,正如方法注解所指示的。该方法也带有@Modifying注解。
为这些查询方法编写测试相对简单,与之前的示例类似。它们可以在本书的源代码中找到。
15.3 使用 Spring Data JDBC 建模关系
管理类之间的关联和表之间的关系是 ORM 问题的核心。我们在第八章中探讨了这些问题的可能解决方案,使用 JPA 和 Spring Data JPA,现在我们将探讨 Spring Data JDBC 提供的方法。
15.3.1 使用 Spring Data JDBC 建模一对一关系
Spring Data JPA 可以使用 JPA 注解@OneToOne、@OneToMany、@ManyToMany来建模实体之间的关系。Spring Data JDBC 使用与 JPA 不同的机制。我们将从使用User和Address实体在 Spring Data JDBC 中建模实体之间的一对一关系开始。每个User将只有一个Address,每个Address将属于一个User。
如前所述,Spring Boot 在启动时将始终执行类路径上的 schema.sql 文件。如下所示列表中,它将删除并重新创建ADDRESSES和USERS表。
列表 15.19 一对一关系的 schema.sql 文件
Path: Ch15/spring-data-jdbc3/src/main/resources/schema.sql
DROP TABLE IF EXISTS ADDRESSES;
DROP TABLE IF EXISTS USERS;
CREATE TABLE USERS (
ID INTEGER AUTO_INCREMENT PRIMARY KEY,
ACTIVE BOOLEAN,
USERNAME VARCHAR(30),
EMAIL VARCHAR(30),
LEVEL INTEGER,
REGISTRATION_DATE DATE
);
CREATE TABLE ADDRESSES (
USER_ID INTEGER AUTO_INCREMENT PRIMARY KEY,
STREET VARCHAR(30) NOT NULL,
CITY VARCHAR(20) NOT NULL
);
@MappedCollection 注解(自 Spring Data JDBC 1.1 引入)可以用于一对一直接引用的类型。USERS 表的 ID 字段将成为 ADDRESSES 表的外键,ADDRESSES 表中对应的字段是 USER_ID。在 User 中只有一个 Address 引用将使关系成为一对一。在 User 类中,对 Address 字段的引用将如下所示:
Path: Ch15/spring-data-jdbc3/src/main/java/com/manning/javapersistence/ch15
➥ /model/User.java
@Table("USERS")
public class User {
@Id
private Long id;
// . . .
@MappedCollection(idColumn = "USER_ID")
private Address address;
Address 类也将被注解为 @Table,因为它将对应于数据库中的不同表:
Path: Ch15/spring-data-jdbc3/src/main/java/com/manning/javapersistence/ch15
➥ /model/Address.java
@Table("ADDRESSES")
public class Address {
// . . .
我们将创建两个仓库。第一个是为 User 实体:
Path: Ch15/spring-data-jdbc3/src/main/java/com/manning/javapersistence/ch15
➥ /repositories/UserOneToOneRepository.java
public interface UserOneToOneRepository extends
➥ CrudRepository<User, Long> {
}
第二个是为 Address 实体:
Path: Ch15/spring-data-jdbc3/src/main/java/com/manning/javapersistence/ch15
➥ /repositories/AddressOneToOneRepository.java
public interface AddressOneToOneRepository extends
CrudRepository<Address, Long> {
}
我们将使用这些仓库来填充数据库并执行测试:
Path: Ch15/spring-data-jdbc3/src/test/java/com/manning/javapersistence/ch15
➥ /UserAddressOneToOneTest.java
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class UserAddressOneToOneTest {
@Autowired
private UserOneToOneRepository userOneToOneRepository;
@Autowired
private AddressOneToOneRepository addressOneToOneRepository;
// . . .
@Test
void oneToOneTest() {
assertAll(
() -> assertEquals(10, userOneToOneRepository.count()),
() -> assertEquals(10, addressOneToOneRepository.count())
);
}
// . . .
}
15.3.2 使用 Spring Data JDBC 建模嵌入实体
我们现在继续在 Spring Data JDBC 中对嵌入实体的建模。我们希望将 User 实体和 Address 类嵌入到 User 中。
自动执行的 SQL 脚本如下所示。将只有一个表,USERS,它将嵌入地址信息。
列表 15.20 嵌入实体的 schema.sql 文件
Path: Ch15/spring-data-jdbc4/src/main/resources/schema.sql
DROP TABLE IF EXISTS USERS;
CREATE TABLE USERS (
ID INTEGER AUTO_INCREMENT PRIMARY KEY,
ACTIVE BOOLEAN,
USERNAME VARCHAR(30),
EMAIL VARCHAR(30),
LEVEL INTEGER,
REGISTRATION_DATE DATE,
STREET VARCHAR(30) NOT NULL,
CITY VARCHAR(20) NOT NULL
);
地址将被嵌入到 USERS 表中。如果嵌入的 STREET 和 CITY 列为空,则 address 字段为 null。在 User 类中,对 Address 字段的引用将如下所示:
Path: Ch15/spring-data-jdbc4/src/main/java/com/manning/javapersistence/ch15
➥ /model/User.java
@Table("USERS")
public class User {
@Id
private Long id;
// . . .
@Embedded(onEmpty = Embedded.OnEmpty.USE_NULL)
private Address address;
Address 类将不再被注解为 @Table,因为它将不再对应于数据库中的不同表——所有信息都将嵌入到 USERS 表中。
Path: Ch15/spring-data-jdbc4/src/main/java/com/manning/javapersistence/ch15
➥ /model/Address.java
public class Address {
// . . .
我们将为 User 实体创建一个单独的仓库。
Path: Ch15/spring-data-jdbc4/src/main/java/com/manning/javapersistence/ch15
➥ /repositories/UserAddressEmbeddedRepository.java
public interface UserAddressEmbeddedRepository extends
CrudRepository<User, Long> {
}
然后,我们将使用此仓库来填充数据库并执行测试。
Path: Ch15/spring-data-jdbc4/src/test/java/com/manning/javapersistence/ch15
➥ /UserAddressEmbeddedTest.java
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class UserAddressEmbeddedTest {
@Autowired
private UserAddressEmbeddedRepository userAddressEmbeddedRepository;
// . . .
@Test
void embeddedTest() {
assertEquals(10, userAddressEmbeddedRepository.count());
}
// . . .
}
15.3.3 使用 Spring Data JDBC 建模一对多关系
现在我们将转向 Spring Data JDBC 中一对多关系的建模。我们有 User 实体和 Address 实体。每个用户可以有多个地址。
自动执行的 SQL 脚本如下所示。将有两个表,USERS 和 ADDRESSES。
列表 15.21 用于一对多关系的 schema.sql 文件
Path: Ch15/spring-data-jdbc5/src/main/resources/schema.sql
DROP TABLE IF EXISTS ADDRESSES;
DROP TABLE IF EXISTS USERS;
CREATE TABLE USERS (
ID INTEGER AUTO_INCREMENT PRIMARY KEY,
ACTIVE BOOLEAN,
USERNAME VARCHAR(30),
EMAIL VARCHAR(30),
LEVEL INTEGER,
REGISTRATION_DATE DATE
);
CREATE TABLE ADDRESSES (
ID INTEGER AUTO_INCREMENT PRIMARY KEY,
USER_ID INTEGER,
STREET VARCHAR(30) NOT NULL,
CITY VARCHAR(20) NOT NULL,
FOREIGN KEY (USER_ID)
REFERENCES USERS(ID)
ON DELETE CASCADE
);
USERS 表的 ID 字段将成为 ADDRESSES 表的外键,ADDRESSES 表中对应的字段是 USER_ID。在 User 中有一组 Address 引用将表示一个 User 有多个 Address。在 User 类中,对 Addresses 的引用将如下所示:
Path: Ch15/spring-data-jdbc5/src/main/java/com/manning/javapersistence/ch15
➥ /model/User.java
@Table("USERS")
public class User {
@Id
private Long id;
// . . .
@MappedCollection(idColumn = "USER_ID")
private Set<Address> addresses = new HashSet<>();
Address 类也将被注解为 @Table,因为它将对应于数据库中的不同表:
Path: Ch15/spring-data-jdbc5/src/main/java/com/manning/javapersistence/ch15
➥ /model/Address.java
@Table("ADDRESSES")
public class Address {
// . . .
我们将创建两个仓库:一个用于 User 实体,另一个用于 Address 实体。第二个仓库将包含一个额外的方法。即使 countByUserId 方法的名称遵循了 Spring Data JDBC 和 Spring Data JPA 中讨论的模式,该方法仍需要注解为 @Query,因为 userId 不存在于 Address 类中:
Path: Ch15/spring-data-jdbc5/src/main/java/com/manning/javapersistence/ch15
➥ /repositories/AddressOneToManyRepository.java
public interface AddressOneToManyRepository
extends CrudRepository<Address, Long> {
@Query("SELECT COUNT(*) FROM ADDRESSES WHERE USER_ID = :USER_ID")
int countByUserId(@Param("USER_ID") Long userId);
}
我们将使用仓库来填充数据库并执行测试:
Path: Ch15/spring-data-jdbc5/src/test/java/com/manning/javapersistence/ch15
➥ /UserAddressOneToManyTest.java
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class UserAddressOneToManyTest {
@Autowired
private UserOneToManyRepository userOneToManyRepository;
@Autowired
private AddressOneToManyRepository addressOneToManyRepository;
// . . .
@Test
void oneToManyTest() {
assertAll(
() -> assertEquals(10, userOneToManyRepository.count()),
() -> assertEquals(20, addressOneToManyRepository.count()),
() -> assertEquals(2,
➥ addressOneToManyRepository.countByUserId(users.get(0).getId()))
);
}
// . . .
}
15.3.4 使用 Spring Data JDBC 建模多对多关系
我们现在将转向使用 Spring Data JDBC 建模多对多关系。我们有User和Address实体。每个User可以有多个Address,每个Address也可以有多个User。我们还需要手动引入一个对应于USERS_ADDRESSES中间表的类,该类将建模多对多关系。
自动执行的 SQL 脚本将如下所示。将有三个表:USERS、ADDRESSES和USERS_ADDRESSES。
列表 15.22 多对多关系的 schema.sql 文件
Path: Ch15/spring-data-jdbc6/src/main/resources/schema.sql
DROP TABLE IF EXISTS USERS_ADDRESSES;
DROP TABLE IF EXISTS USERS;
DROP TABLE IF EXISTS ADDRESSES;
CREATE TABLE USERS (
ID INTEGER AUTO_INCREMENT PRIMARY KEY,
ACTIVE BOOLEAN,
USERNAME VARCHAR(30),
EMAIL VARCHAR(30),
LEVEL INTEGER,
REGISTRATION_DATE DATE
);
CREATE TABLE ADDRESSES (
ID INTEGER AUTO_INCREMENT PRIMARY KEY,
STREET VARCHAR(30) NOT NULL,
CITY VARCHAR(20) NOT NULL
);
CREATE TABLE USERS_ADDRESSES (
USER_ID INTEGER,
ADDRESS_ID INTEGER,
FOREIGN KEY (USER_ID)
REFERENCES USERS(ID)
ON DELETE CASCADE,
FOREIGN KEY (ADDRESS_ID)
REFERENCES ADDRESSES(ID)
ON DELETE CASCADE
);
为了建模多对多关系,User类将与中间类UserAddress连接。在User内部拥有UserAddress引用集合将表明一个User有多个UserAddress。USERS表的ID字段将在USERS_ADDRESSES表中作为外键,对应字段为USER_ID。在User类中,对Addresses的引用将如下所示:
Path: Ch15/spring-data-jdbc6/src/main/java/com/manning/javapersistence/ch15
➥ /model/User.java
@Table("USERS")
public class User {
@Id
private Long id;
// . . .
@MappedCollection(idColumn = "USER_ID")
private Set<UserAddress> addresses = new HashSet<>();
Address类也将使用@Table注解,因为它将对应于数据库中的不同表:
Path: Ch15/spring-data-jdbc6/src/main/java/com/manning/javapersistence/ch15
➥ /model/Address.java
@Table("ADDRESSES")
public class Address {
// . . .
此外,我们还将创建UserAddress类并使用@Table注解,因为它将对应于数据库中的不同表。它将只包含Address的 ID,因为User类保持了一组UserAddress类型的引用。
Path: Ch15/spring-data-jdbc6/src/main/java/com/manning/javapersistence/ch15
➥ /model/UserAddress.java
@Table("USERS_ADDRESSES")
public class UserAddress {
private Long addressId;
public UserAddress(Long addressId) {
this.addressId = addressId;
}
public Long getAddressId() {
return addressId;
}
}
我们将创建三个仓库:一个用于User实体,一个用于Address实体,还有一个用于UserAddress实体。第三个仓库将包含一个额外的方法:尽管countByUserId方法的名字遵循了 Spring Data JDBC 和 Spring Data JPA 中讨论的模式,但该方法需要使用@Query注解,因为userId不存在于UserAddress类中。
Path: Ch15/spring-data-jdbc6/src/main/java/com/manning/javapersistence/ch15
➥ /repositories/UserAddressManyToManyRepository.java
public interface UserAddressManyToManyRepository extends
CrudRepository<UserAddress, Long> {
@Query("SELECT COUNT(*) FROM USERS_ADDRESSES WHERE USER_ID = :USER_ID")
int countByUserId(@Param("USER_ID") Long userId);
}
我们将使用仓库来填充数据库并执行测试:
Path: Ch15/spring-data-jdbc6/src/test/java/com/manning/javapersistence/ch15
➥ /UserAddressManyToManyTest.java
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class UserAddressManyToManyTest {
@Autowired
private UserAddressManyToManyRepository
➥ userAddressManyToManyRepository;
@Autowired
private AddressManyToManyRepository addressManyToManyRepository;
@Autowired
private UserManyToManyRepository userManyToManyRepository;
// . . .
@Test
void manyToManyTest() {
assertAll(
() -> assertEquals(10, userManyToManyRepository.count()),
() -> assertEquals(3, addressManyToManyRepository.count()),
() -> assertEquals(20,
➥ userAddressManyToManyRepository.count()),
() -> assertEquals(2,
userAddressManyToManyRepository.countByUserId(
users.get(0).getId()))
);
}
// . . .
}
为了总结并最终比较和对比 Spring Data JPA 和 Spring Data JDBC 当前的功能,请查看表 15.2。我们在此总结了最重要的功能,例如可移植性、在项目中学习和采用它的复杂性、查询推导、原生 SQL 的使用、注解的使用、关系建模、缓存和延迟加载、会话和脏检查。
表 15.2 Spring Data JPA 功能与 Spring Data JDBC 功能对比
| Spring Data JPA | Spring Data JDBC |
|---|---|
| 数据库无关且可移植 | 通常,数据库特定 |
| 通过对象/关系映射(ORM)介绍复杂性 | 简单一些,但仍然遵循 Spring 框架的原则 |
| 基于实体自动生成模式 | 通过程序员侧的 DDL 命令生成模式 |
| 从第一个版本开始就有查询推导 | 从 2.0 版本开始就有查询推导 |
| 使用带有 JPQL 代码和原生 SQL 的查询进行注解 | 只使用原生 SQL 的查询 |
| 可以使用 JPA 注解重用类 | 使用 org.springframework.data 包中的注解 |
通过 @OneToMany、@Embedded 等注解在实体之间建模关系 |
主要在程序员的一侧通过类的设计来建模关系 |
| 缓存和延迟加载 | 没有缓存,没有延迟加载 |
| 会话和脏跟踪 | 没有会话,没有脏跟踪 |
注意:Spring Data JDBC 是一个年轻的项目,目前正处于全面开发阶段。预计在不久的将来将添加许多新功能。
摘要
-
您可以使用 Spring Boot 创建和配置一个 Spring Data JDBC 项目,并添加逐步方法来查询数据库和建模不同类型的关系。
-
您可以通过遵循框架 2.0 版本中介绍的 Spring Data JDBC 查询构建器机制来定义和使用一系列查询方法来访问存储库。
-
Spring Data JDBC 的功能包括限制查询结果、排序、分页、流式结果和
@Query注解。 -
您可以使用修改查询来创建和更新实体。
-
您可以使用 Spring Data JDBC 来建模实体之间的一对一、一对多和多对多关系,以及内嵌实体。
16 使用 Spring Data REST 工作环境
本章涵盖
-
介绍 REST 应用程序
-
创建 Spring Data REST 应用程序
-
使用 ETags 进行条件请求
-
限制对存储库、方法和字段的访问
-
使用 REST 事件进行工作
-
使用投影和摘录
表征状态转移(REST)是创建网络服务的软件架构风格;它还提供了一套约束。美国计算机科学家罗伊·菲尔丁(Roy Fielding),也是 HTTP 规范的作者之一,首先定义了 REST,在他的博士论文中提出了 REST 原则(Fielding,2000)。遵循此 REST 架构风格的服务称为RESTful 网络服务,它们允许互联网和计算机系统之间的互操作性。请求系统可以使用一组众所周知的无状态操作(GET、POST、PUT、PATCH、DELETE)来访问和操作表示为文本的网络资源。无状态操作不依赖于任何其他先前操作;它必须包含所有必要的信息,以便服务器理解。
16.1 介绍 REST 应用程序
我们首先定义术语客户端和资源来描述使 API 成为 RESTful 的因素。客户端是使用 RESTful API 的人或软件。例如,使用 RESTful API 对领英网站执行操作的程序员是客户端,但客户端也可以是网页浏览器。当我们访问领英网站时,我们的浏览器是调用网站 API 并在屏幕上显示获取信息的客户端。资源可以是 API 可以获取信息的任何对象。在领英 API 中,资源可以是消息、照片或用户。每个资源都有一个唯一的标识符。
REST 架构风格定义了六个约束([restfulapi.net/rest-architectural-constraints/](https://restfulapi.net/rest-architectural-constraints/)):
-
客户端-服务器—客户端与服务器分离,各自有其关注点。最常见的是,客户端关注用户表示,而服务器关注数据存储和领域模型逻辑——包括数据和行为的领域概念模型。
-
无状态—服务器在请求之间不保留任何关于客户端的信息。每个客户端的请求都包含响应该请求所需的所有信息。客户端在其一侧保持状态。
-
统一接口—客户端和服务器可以独立于彼此进行演化。它们之间的统一接口使得它们松散耦合。
-
分层系统—客户端没有方法来确定它是否直接与服务器或中介交互。层可以动态添加和移除。它们可以提供安全性、负载均衡或共享缓存。
-
可缓存—客户端能够缓存响应。响应定义自己是否可缓存。
-
代码按需(可选)—服务器能够暂时自定义或扩展客户端的功能。服务器可以将一些逻辑传输到客户端,客户端可以执行这些逻辑,例如 JavaScript 客户端脚本。
RESTful 网络应用程序提供有关其资源的信息,这些资源通过 URL 进行标识。客户端可以针对此类资源执行操作;它可以创建、读取、更新或删除资源。
REST 架构风格不是特定于协议的,但最广泛使用的协议是 HTTP 上的 REST。HTTP 是基于请求和响应的同步应用程序网络协议。
要使我们的 API 成为 RESTful,我们必须在开发时遵循一系列规则。RESTful API 将将信息传输到客户端,客户端使用这些信息作为访问资源的状态的表示。例如,当我们调用 LinkedIn API 来访问特定用户时,API 将返回该用户的状态(姓名、传记、职业经验、帖子)。REST 规则使 API 更易于理解,对于新加入团队的程序员来说更简单易用。
状态的表示可以是 JSON、XML 或 HTML 格式。客户端使用 API 向服务器发送以下信息:
-
我们想要访问的资源标识符(URL)。
-
我们想要服务器对该资源执行的操作。这是一个 HTTP 方法,其中最常见的是
GET、POST、PUT、PATCH和DELETE。
例如,使用 LinkedIn RESTful API 获取特定 LinkedIn 用户需要我们有一个标识用户的 URL,并使用 HTTP 方法 GET。
16.2 创建 Spring Data REST 应用程序
我们的首要目标是创建一个 Spring Data REST 应用程序,该程序将提供一个浏览器界面来与数据库交互,并管理持久化 CaveatEmptor 用户。为此,我们将访问 Spring Initializr 网站 (https://start.spring.io/) 并创建一个新的 Spring Boot 项目(图 16.1),具有以下特性:
-
组:com.manning.javapersistence
-
软件包:spring-data-rest
-
描述:Spring Data REST

图 16.1 使用 Spring Data REST 和 MySQL 创建新的 Spring Boot 项目
我们还将添加以下依赖项:
-
Spring Web (这将在 Maven pom.xml 文件中添加
spring-boot-starter-web) -
Spring Data JPA (这将在 Maven pom.xml 文件中添加
spring-boot-starter-data-jpa) -
REST 仓库(这将在 Maven pom.xml 文件中添加
spring-boot-starter-data-rest) -
MySQL 驱动程序(这将在 Maven pom.xml 文件中添加
mysql-connector-java)
注意:要执行源代码中的示例,您首先需要运行 Ch16.sql 脚本。
下面的列表中的 pom.xml 文件包括了启动 Spring Data REST 项目时我们添加的依赖项。这个 Spring Data REST 应用程序将访问一个 MySQL 数据库,因此我们需要驱动程序。
列表 16.1 pom.xml Maven 文件
Path: Ch16/spring-data-rest/pom.xml
<dependency> Ⓐ
<groupId>org.springframework.boot</groupId> Ⓐ
<artifactId>spring-boot-starter-web</artifactId> Ⓐ
</dependency> Ⓐ
<dependency> Ⓑ
<groupId>org.springframework.boot</groupId> Ⓑ
<artifactId>spring-boot-starter-data-jpa</artifactId> Ⓑ
</dependency> Ⓑ
<dependency> Ⓒ
<groupId>org.springframework.boot</groupId> Ⓒ
<artifactId>spring-boot-starter-data-rest</artifactId> Ⓒ
</dependency> Ⓒ
<dependency> Ⓓ
<groupId>mysql</groupId> Ⓓ
<artifactId>mysql-connector-java</artifactId> Ⓓ
<scope>runtime</scope> Ⓓ
</dependency> Ⓓ
Ⓐ spring-boot-starter-web 是 Spring Boot 用于构建 Web 应用程序的启动依赖项。
Ⓑ spring-boot-starter-data-jpa 是 Spring Boot 用于通过 Spring Data JPA 连接到关系型数据库的启动依赖项。
Ⓒ spring-boot-starter-data-rest 是 Spring Boot 用于 Spring Data REST 应用程序的启动依赖项。
Ⓓ mysql-connector-java 是 MySQL 的 JDBC 驱动程序。它是一个运行时依赖项,因此仅在运行时需要在类路径中。
下一步是填写 Spring Boot 的 application.properties 文件,该文件可以包含应用程序将使用的各种属性。Spring Boot 将自动从类路径中查找并加载 application.properties 文件——Maven 将 src/main/resources 文件夹添加到类路径中。
在 Spring Boot 应用程序中提供参数有几种方式,.properties 文件只是其中之一。参数也可以来自源代码或作为命令行参数——有关详细信息,请参阅 Spring Boot 文档。
对于我们的应用程序,application.properties 配置文件将如下所示。
列表 16.2 application.properties 文件
Path: Ch16/spring-data-rest/src/main/resources/application.properties
server.port=8081 Ⓐ
spring.datasource.url=jdbc:mysql://localhost:3306/CH16_SPRINGDATAREST
➥ ?serverTimezone=UTC Ⓑ
spring.datasource.username=root Ⓒ
spring.datasource.password= Ⓒ
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect Ⓓ
spring.jpa.show-sql=true Ⓔ
spring.jpa.hibernate.ddl-auto=create Ⓕ
Ⓐ 应用程序将在端口 8081 上启动。
Ⓑ 数据库的 URL。
Ⓒ 访问数据库的凭证。将它们替换为您的机器上的凭证,并在实际生活中使用密码。
Ⓓ 数据库的方言是 MySQL。
Ⓔ 在它们执行时显示 SQL 查询。
Ⓕ 在每次应用程序执行时重新创建表。
User 类现在将包含一个由 @Version 注解的字段。如第 11.2.2 节所述,每当修改后的 User 实例被持久化时,字段值都会递增。第 16.3 节将演示如何使用此字段通过 ETags 进行条件 REST 请求。
列表 16.3 修改后的 User 类
Path: Ch16/spring-data-rest/src/main/java/com/manning/javapersistence/ch16
➥ /model/User.java
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
@Version
private Long version;
private String name;
private boolean isRegistered;
private boolean isCitizen;
//constructors, getters and setters
}
用户将参与由 Auction 类表示的拍卖。拍卖由 auctionNumber、seats 的数量和 users 集合来描述。
列表 16.4 Auction 类
Path: Ch16/ spring-data-rest/src/main/java/com/manning/javapersistence/ch16
➥ /model/Auction.java
public class Auction {
private String auctionNumber;
private int seats;
private Set<User> users = new HashSet<>();
//constructors, getters and methods
}
参与拍卖的用户将从 CSV 文件中读取,在 CsvDataLoader 类中。我们将使用 @Bean 注解创建一个 Bean,该 Bean 将由 Spring 管理并注入到应用程序中。
列表 16.5 CsvDataLoader 类
Path: Ch16/spring-data-rest/src/main/java/com/manning/javapersistence/ch16
➥ /beans/CsvDataLoader.java
public class CsvDataLoader {
@Bean Ⓐ
public Auction buildAuctionFromCsv() throws IOException {
Auction auction = new Auction("1234", 20); Ⓑ
try (BufferedReader reader = new BufferedReader( Ⓒ
new FileReader("src/main/resources/users_information.csv"))) { Ⓒ
String line = null;
do {
line = reader.readLine(); Ⓓ
if (line != null) {
User user = new User(line); Ⓔ
user.setIsRegistered(false); Ⓔ
auction.addUser(user); Ⓔ
}
} while (line != null);
}
return auction; Ⓕ
}
}
Ⓐ 方法的结果将是一个由 Spring 管理的 Bean。
Ⓑ 创建 Auction 对象。
Ⓒ 使用 CSV 文件中的信息。
Ⓓ 逐行读取。
Ⓔ 从读取的信息中创建用户,配置它,并将其添加到拍卖中。
Ⓕ 返回 Auction Bean。
UserRepository 接口扩展了 JpaRepository<User, Long>,继承了 JPA 相关的方法,并管理 User 实体,具有 Long 类型的 ID。
列表 16.6 UserRepository 接口
Path: Ch16/spring-data-rest/src/main/java/com/manning/javapersistence/ch16
➥ /repositories/UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
}
Spring Boot 应用程序将导入在CsvDataLoader类中创建的 bean,并自动装配它。它还将创建一个类型为ApplicationRunner的 bean。这是一个 Spring Boot 功能接口(一个只有一个抽象方法的接口),它提供了访问应用程序参数的权限。这个ApplicationRunner接口被创建,并且它的单个方法将在SpringApplication的run()方法完成之前执行。
列表 16.7 Application类
Path: Ch16/spring-data-rest/src/main/java/com/manning/javapersistence/ch16
➥ /Application.java
@SpringBootApplication
@Import(CsvDataLoader.class) Ⓐ
public class Application {
@Autowired Ⓑ
private Auction auction; Ⓑ
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Bean Ⓒ
ApplicationRunner configureRepository(UserRepository userRepository) { Ⓒ
return args -> {
for (User user : auction.getUsers()) { Ⓒ
userRepository.save(user); Ⓒ
}
};
}
}
Ⓐ 导入CsvDataLoader类及其创建的Auction bean。
Ⓑ 自动装配导入的Auction bean。
Ⓒ 浏览拍卖中的所有用户,并将它们保存到仓库中。
我们可以在浏览器中访问 Spring Data REST 应用程序(http://localhost:8081/users),如图 16.2 所示。我们可以获取用户信息,并轻松地在记录之间导航。Spring Data REST 将公开要访问的 API 信息,并为每个记录提供链接。

图 16.2 从浏览器访问 Spring Data REST 应用程序
我们可以使用 REST 客户端测试这个 REST API 端点。IntelliJ IDEA Ultimate 版提供了一个这样的 REST 客户端,但您也可以使用不同的客户端(如 cURL 或 Postman)。我们可以执行以下命令(见图 16.3):
GET http://localhost:8081/users/1

图 16.3 在 IntelliJ IDEA Ultimate 版 REST 客户端中执行GET http://localhost:8081/users/1命令的结果
16.3 使用 ETags 进行条件请求
网络上的任何信息交换都需要时间。信息越小,我们的程序运行得越快。但何时以及如何减少从服务器检索和通过网络传输的信息量呢?
假设我们需要多次执行如下命令:
GET http://localhost:8081/users/1
我们将每次访问服务器,并将相同的信息发送到网络。这是低效的,我们希望限制客户端和服务器之间交换的数据量。
我们可以使用 ETags 进行条件请求,避免发送未更改的信息。ETag 是 Web 服务器返回的 HTTP 响应头。它将帮助我们确定给定 URL 的内容是否已修改,从而允许我们进行条件请求。
在User类中,有一个被@Version注解的字段:
@Version
\1 Long version;
此字段也将用作 ETag。当我们向服务器执行此请求时,
GET http://localhost:8081/users/1
答案将在头部包含记录的版本(0),作为 ETag(见图 16.4):
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
ETag: "0"

图 16.4 服务器的回答,包括头部上的 ETag,表示实体版本
使用这些信息,我们现在可以执行一个条件请求,并且只有当 ETag 与 0 不同时,才能获取 ID 为 1 的用户信息。
GET http://localhost:8081/users/1
If-None-Match: "0"
服务器响应将是 304(未修改)响应代码,以及一个空正文(见图 16.5):
HTTP/1.1 304
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
ETag: "0"
Date: Sat, 04 Dec 2021 13:19:11 GMT
Keep-Alive: timeout=60
Connection: keep-alive
<Response body is empty>

图 16.5 对于匹配现有 ETag 的记录,服务器响应没有正文。
现在,我们可以修改 ID 为 1 的用户的内 容,执行一个PATCH命令。我们使用PATCH而不是PUT,因为PATCH只会更新请求中包含的字段,而PUT将用新的实体替换整个实体。
PATCH http://localhost:8081/users/1
Content-Type: application/json
{
"name": "Amelia Jones",
"isRegistered": "true"
}
服务器响应将是 204(无内容)成功响应代码,ETag 将是记录增加的版本(1)(见图 16.6):
HTTP/1.1 204
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
ETag: "1"
Date: Sat, 04 Dec 2021 13:25:57 GMT
Keep-Alive: timeout=60
Connection: keep-alive
<Response body is empty>

图 16.6 修复用户后,服务器响应将 ETag 增加到 1。
现在,我们可以重新执行条件请求,以获取 ID 为 1 的用户的详细信息,前提是 ETag 与 0 不同:
GET http://localhost:8081/users/1
If-None-Match: "0"
由于记录的版本已从 0 更改为 1,条件请求将获得带有 200(成功)响应代码和用户全部信息的响应(见图 16.7):
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
ETag: "1"

图 16.7 服务器响应包括关于用户的全部信息,ETag 从 0 变为 1。
16.4 限制对存储库、方法和字段的访问
Spring Data REST 默认将所有公共顶级存储库接口导出。但实际用例通常需要限制对特定方法、字段甚至整个存储库的访问。我们可以使用@RepositoryRestResource注解来阻止导出接口或自定义端点的访问。
例如,如果管理实体是User,Spring Data REST 将默认将其导出到/users路径。我们可以通过使用@RepositoryRestResource注解的exported = false选项来阻止整个存储库的导出。存储库将看起来像这样:
@RepositoryRestResource(path = "users", exported = false)
public interface UserRepository extends JpaRepository<User, Long> {
}
对此存储库执行的任何命令都将导致错误。例如,执行
GET http://localhost:8081/users/1
将从服务器生成 404(未找到)响应代码(见图 16.8):
HTTP/1.1 404
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json

图 16.8 阻止导出存储库将阻止 REST 接口的任何交互。
为了方便,我们将使用带有默认选项的@RepositoryRestResource注解来处理UserRepository接口。
默认情况下,Spring Data REST 还将导出存储库接口的所有方法,但我们可以使用@RestResource(exported = false)注解来阻止对这些方法的访问。对于UserRepository接口,我们不会导出删除方法。
列表 16.8 UserRepository接口
Path: Ch16/spring-data-rest/src/main/java/com/manning/javapersistence/ch16
➥ /repositories/UserRepository.java
@RepositoryRestResource(path = "users") Ⓐ
public interface UserRepository extends JpaRepository<User, Long> {
@Override
@RestResource(exported = false) Ⓑ
void deleteById(Long id);
@Override
@RestResource(exported = false) Ⓑ
void delete(User entity);
}
Ⓐ 使用@RepositoryRestResource注解将存储库导出到/users路径。这是默认选项。
使用@RestResource(exported = false)注解来阻止导出存储库的删除方法。
如果我们现在执行DELETE命令,
DELETE http://localhost:8081/users/1
服务器将响应 405(方法不允许)的响应代码,因为删除方法没有被导出(见图 16.9)。允许的方法有GET、HEAD、PUT、PATCH和OPTIONS:
HTTP/1.1 405
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Allow: GET,HEAD,PUT,PATCH,OPTIONS

图 16.9 删除方法不再由 Spring Data REST 导出,服务器也不允许。
我们可以通过使用@JsonIgnore注解来限制对特定字段的访问,并在 REST 接口中不暴露它们。例如,我们可以在User类中,在isRegistered方法上使用此注解:
@JsonIgnore
public boolean isRegistered() {
return isRegistered;
}
通过浏览器访问存储库将不再提供isRegistered字段信息。您可以在图 16.10 中看到这一点,并与图 16.2 进行比较。

图 16.10 REST 客户端不再获取isRegistered信息。
16.5 使用 REST 事件
在某些情况下,我们可能需要在特定事件发生时向应用程序的行为添加副作用。当与实体一起工作时,REST 应用程序可以发出 10 种不同类型的事件。所有这些都扩展了org.springframework.data .rest.core.event.RepositoryEvent类,并属于同一个org.springframework.data.rest.core.event包:
-
BeforeCreateEvent -
AfterCreateEvent -
BeforeSaveEvent -
AfterSaveEvent -
BeforeLinkSaveEvent -
AfterLinkSaveEvent -
BeforeDeleteEvent -
AfterDeleteEvent -
BeforeLinkDelete -
AfterLinkDelete
这些事件可以有两种处理方式:
-
编写注解处理程序
-
编写
ApplicationListener
让我们看看这两种选项。
16.5.1 编写注解处理程序
要通过编写AnnotatedHandler来添加副作用,我们可以创建一个带有@RepositoryEventHandler注解的 POJO 类。这个注解告诉 Spring 管理的BeanPostProcessor,这个类必须检查其处理方法。BeanPostProcessor将浏览带有此注解的类的所有方法,并检测与不同事件对应的注解。
事件处理程序 bean 必须在容器控制之下。我们可以将类注解为@Service(这是一个@Component的子类型),这样它就会被@ComponentScan或@SpringBootApplication考虑。
我们跟踪的事件的实体由注解方法的第一个参数的类型提供。在以下示例中,处理程序的方法将有一个User实体作为参数。
注解方法和事件之间的关系总结在表 16.1 中。
表 16.1 AnnotatedHandler注解及其对应的事件
| 注解 | 事件 |
|---|---|
@HandleBeforeCreate @HandleAfterCreate |
POST事件 |
@HandleBeforeSave @HandleAfterSave |
PUT和PATCH事件 |
@HandleBeforeDelete @HandleAfterDelete |
DELETE事件 |
@HandleBeforeLinkSave @HandleAfterLinkSave |
将链接对象保存到存储库 |
@HandleBeforeLinkDelete |
@HandleAfterLinkDelete |
在以下列表中展示了带有@RepositoryEventHandler注解的 POJO 类UserRepositoryEventHandler。
列表 16.9 UserRepositoryEventHandler类
Path: Ch16/spring-data-rest-events/src/main/java/com/manning
➥ /javapersistence/ch16/events/UserRepositoryEventHandler.java
@RepositoryEventHandler Ⓐ
@Service Ⓑ
public class UserRepositoryEventHandler {
@HandleBeforeCreate Ⓒ
public void handleUserBeforeCreate(User user) { Ⓓ
//manage the event
}
//other methods
}
Ⓐ 使用@RepositoryEventHandler注解注解类,以告知 Spring BeanPostProcessor检查其处理方法。
Ⓑ 使用@Service注解注解类,使其处于容器的控制之下。
Ⓒ 使用@HandleBeforeCreate注解方法,将其与POST事件关联。
Ⓓ 该方法以实体User作为第一个参数,表示我们正在跟踪的事件的类型。
16.5.2 编写 ApplicationListener
要通过编写一个ApplicationListener来添加副作用,我们将扩展AbstractRepositoryEventListener抽象类。这个类通过事件发生的实体类型进行了泛化。它将监听事件并调用相应的方法。我们将自定义监听器注解为@Service(这是一个@Component的子类型),这样它就会被@ComponentScan或@SpringBootApplication考虑。
AbstractRepositoryEventListener抽象类已经包含了一系列空的受保护方法来处理事件。我们需要覆盖并仅将我们感兴趣的公开。
方法与事件之间的关系总结在表 16.2 中。
表 16.2 ApplicationListener方法和相应的事件
| 方法 | 事件 |
|---|---|
onBeforeCreate |
onAfterCreate |
onBeforeSave |
onAfterSave |
onBeforeDelete |
onAfterDelete |
onBeforeLinkSave |
onAfterLinkSave |
onBeforeLinkDelete |
onAfterLinkDelete |
扩展了AbstractRepositoryEventListener抽象类的RepositoryEventListener类包含了响应事件的函数。它将在以下列表中展示。
列表 16.10 RepositoryEventListener类
Path: Ch16/spring-data-rest-events/src/main/java/com/manning
➥ /javapersistence/ch16/events/RepositoryEventListener.java
@Service Ⓐ
public class RepositoryEventListener extends Ⓑ
AbstractRepositoryEventListener<User> { Ⓑ
@Override Ⓒ
public void onBeforeCreate(User user) { Ⓒ
//manage the event
}
//other methods
}
Ⓐ 使用@Service注解注解类,使其处于容器的控制之下。
Ⓑ 通过User实体扩展AbstractRepositoryEventListener,这是事件发生的实体。
Ⓒ 该方法以User实体作为第一个参数,表示我们正在跟踪的事件的类型。
现在我们可以运行应用程序并执行以下 REST 命令:
POST http://localhost:8081/users
Content-Type: application/json
{
"name": "John Smith"
}
处理器和监听器将对事件做出反应,并作为副作用生成额外的行为,如图 16.11 所示。

图 16.11 处理器和监听器对 REST 事件做出反应产生的额外行为(副作用)
处理事件(使用处理器和使用监听器)的两种方法提供类似的行为,并且它们处理相同类型的事件。在其他条件相同的情况下,处理器提供仅在声明性级别(类和方法上的注解)工作的优势,而监听器要求我们扩展一个现有的抽象类,这意味着它们挂在一个现有的层次结构中,这意味着层次结构设计的自由度较低。
16.6 使用投影和摘录
Spring Data REST 提供了您正在处理的域模型的默认视图,但现实世界的用例可能需要对其进行更改或适应特定需求。您可以使用投影和摘录来实现这一点,提供导出信息的特定视图。
我们将添加新的Address类到项目中。它将包含一些字段,我们希望使用toString方法显示其中的信息。
列表 16.11 Address类
Path: Ch16/spring-data-rest-projections/src/main/java/com/manning
➥ /javapersistence/ch16/model/Address.java
@Entity
public class Address {
@GeneratedValue
@Id
private Long id;
private String street, zipCode, city, state;
//constructors and methods
public String toString() {
return String.format("%s, %s %s, %s", street, zipCode, city, state);
}
}
User和Address之间存在一对一的关系,因为我们已经在User实体中引入了一个新字段:
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true)
private Address address;
CascadeType.ALL选项将导致持久化操作级联到相关实体。orphanRemoval=true参数指定我们希望在Address不再被User引用时永久删除它。您可以回顾第八章以获取有关这些选项的更多详细信息。
如果我们访问 http://localhost:8081/users/1 URL,我们将获得 ID 为 1 的用户的默认视图,显示所有字段以及地址字段,如图 16.12 所示。

图 16.12 带有地址的用户默认视图
现在我们将添加新的UserProjection接口到项目中(列表 16.12)。借助@Projection注解,我们可以在User实体上创建summary投影,这将仅导出用户名称和地址,根据toString方法显示的方式。我们将使用 Spring 表达式语言(SpEL)来完成此操作。
列表 16.12 UserProjection接口
Path: Ch16/spring-data-rest-projections/src/main/java/com/manning
➥ /javapersistence/ch16/model/UserProjection.java
@Projection(name = "summary", types = User.class) Ⓐ
public interface UserProjection {
String getName(); Ⓑ
@Value("#{target.address.toString()}") Ⓒ
String getAddress(); Ⓒ
}
Ⓐ 投影名为summary,并应用于User实体。
Ⓑ 由于字段名为name,我们需要编写一个getName方法来导出它,遵循 getter 名称约定。
Ⓒ 根据由toString方法显示的方式导出address。我们使用@Value注解,包含一个 SpEL 表达式。我们还需要遵循 getter 名称约定,因此该方法被命名为getAddress。
如果我们访问 http://localhost:8081/users/1?projection=summary URL(包含作为参数的投影名称),我们将获得 ID 为 1 的用户的视图,显示name字段和由toString方法提供的地址。这如图 16.13 所示。

图 16.13 由summary投影提供的带有地址的用户视图
我们可能希望在集合的整体级别应用投影的默认视图。在这种情况下,我们需要前往已经定义的仓库,并使用@RepositoryRestResource注解的excerptProjection = UserProjection.class选项,如列表 16.13 所示。
列表 16.13 修改后的UserRepository接口
Path: Ch16/spring-data-rest-projections/src/main/java/com/manning
➥ /javapersistence/ch16/repositories/UserRepository.java
@RepositoryRestResource(path = "users",
excerptProjection = UserProjection.class)
public interface UserRepository extends JpaRepository<User, Long> {
}
如果我们访问 http://localhost:8081/users/ URL,我们将获得所有用户的视图,根据投影定义显示,如图 16.14 所示。

图 16.14 根据summary投影显示的整个users集合视图
摘要
-
使用 Spring Boot,您可以创建和配置一个 Spring Data REST 项目,以提供与数据库交互的接口,以及管理和持久化信息。
-
您可以使用 ETags 来执行高效请求,从服务器获取数据,避免传输客户端已经拥有的信息。
-
您可以限制对仓库、方法和字段的访问,并仅导出您希望允许的信息和操作。
-
您可以与 REST 事件一起工作,并通过处理程序和监听器来管理它们。它们可以通过元信息或通过扩展现有类来工作。
-
您可以使用投影和摘录来提供根据不同用户需求定制的仓库信息视图。
第五部分. 使用 Spring 构建 Java 持久化应用程序
在第五部分中,你将连接 Java 应用程序到常用的 NoSQL 数据库:MongoDB 和 Neo4j。
在第十七章中,你将学习 Spring Data MongoDB 框架的最重要特性,并将它们与已经使用的 Spring Data JPA 和 Spring Data JDBC 进行比较。你将使用两种替代方案连接到 MongoDB 数据库:MongoRepository和MongoTemplate。我们将强调这两种替代方案的优势、缺点和最佳使用案例。
接下来,第十八章介绍了 Hibernate OGM 框架,并演示了如何使用 JPA 代码连接到具有不同存储范式(文档导向和图导向)的不同 NoSQL 数据库(MongoDB 和 Neo4j)。我们将通过仅更改配置而不接触 Java 代码在两个数据库之间迁移。
阅读本书的这一部分后,你将了解如何从 Java 程序中操作 NoSQL 数据库,并且能够选择你想要使用的替代框架。
17 使用 Spring Data MongoDB
本章涵盖
-
介绍 MongoDB
-
检查 Spring Data MongoDB
-
使用 MongoRepository 访问数据库
-
使用 MongoTemplate 访问数据库
文档型数据库是 NoSQL 数据库的一种类型,其中信息以键/值存储。MongoDB 就是这样一种数据库程序。Spring Data MongoDB 作为更大的 Spring Data 项目的一部分,简化了 Java 程序与 MongoDB 文档数据库的交互。
17.1 介绍 MongoDB
MongoDB 是一个开源的文档型 NoSQL 数据库。MongoDB 使用类似 JSON 的文档来存储信息,并使用数据库、集合和文档的概念。
-
数据库—数据库代表集合的容器。安装 MongoDB 后,您通常会获得一组数据库。
-
集合 —在关系数据库管理系统(RDBMS)的世界中,集合类似于表。一个集合可能包含一组文档。
-
文档—一个文档代表一组键/值对,相当于 RDBMS 中的行。属于同一集合的文档可能具有不同的字段集。集合中多个文档共有的字段可能包含不同类型的数据—这种情况下称为动态模式。
表 17.1 总结了关系数据库和无 SQL 数据库 MongoDB 之间的术语等效关系。
表 17.1 比较术语:RDBMS 与 MongoDB
| 关系数据库 | MongoDB |
|---|---|
| 数据库 | 数据库 |
| 表 | 集合 |
| 行 | 文档 |
| 列 | 字段 |
您可以在此处下载 MongoDB 社区版安装包:www.mongodb.com/try/download/community。根据您的操作系统,安装说明可以在此处找到:docs.mongodb.com/manual/administration/install-community/.
安装 MongoDB 后,您可以打开 MongoDB Compass 程序,如图 17.1 所示。MongoDB Compass 是用于与 MongoDB 数据库交互和查询的图形用户界面。

列表 17.1 打开 MongoDB Compass 程序
点击连接按钮,您将连接到本地服务器,如图 17.2 所示。

列表 17.2 连接到本地 MongoDB 服务器
MongoDB 集合中的数据以 JSON 格式表示。描述我们 CaveatEmptor 应用程序用户的典型 MongoDB 文档可能看起来像这样:
{
"_id":{
"$oid":"61c9e17e382deb3ba55d65ac"
},
"username":"john",
"firstName":"John",
"lastName":"Smith",
"registrationDate":{
"$date":"2020-04-12T21:00:00.000Z"
},
"email":"john@somedomain.com",
"level":1,
"active":true,
"_class":"com.manning.javapersistence.springdatamongodb.model.User"
}
要选择满足特定条件的文档,您可以在 MongoDB Compass 程序中使用过滤器编辑框插入查询过滤器参数。例如,要选择用户名为“john”的文档,您需要插入查询过滤器{"username":"john"}并点击查找按钮,如图 17.3 所示。

列表 17.3 从 MongoDB 集合中选择文档
关于您可能想要执行的 MongoDB CRUD 操作的详细信息,请参阅官方文档:docs.mongodb.com/manual/crud/。
17.2 介绍 Spring Data MongoDB
Spring Data MongoDB 是 Spring Data 项目的一部分,它允许 Java 程序使用 MongoDB,遵循 Spring Data 方法:仓库和自定义对象映射抽象、注解、基于仓库方法名的动态查询创建、与其他 Spring 项目的集成以及 Spring Boot。
为了演示 Spring Data MongoDB,我们将创建一个应用程序来管理和持久化 CaveatEmptor 用户。我们将创建一个使用 Spring Data MongoDB 的 Spring Boot 应用程序。为此,请访问 Spring Initializr 网站 (start.spring.io/) 并创建一个新的 Spring Boot 项目(见图 17.4),具有以下特性:
-
组:com.manning.javapersistence
-
艺术品:springdatamongodb
-
描述:Spring Data MongoDB

列表 17.4 使用 Spring Data MongoDB 创建新的 Spring Boot 项目
我们还将添加以下依赖项:
-
Spring Data MongoDB(这将向 Maven pom.xml 文件中添加
spring-boot-starter-data-mongodb) -
Lombok(这将向 Maven pom.xml 文件中添加
org.projectlombok,lombok)
pom.xml 文件(列表 17.1)包括我们之前添加到启动项目的依赖项:对 Spring Data MongoDB 框架和 Lombok 的依赖。Lombok 是一个 Java 库,可以通过注解自动创建构造函数、获取器和设置器,从而减少样板代码。Lombok 有其不足之处,包括以下这些:您需要为 IDE 安装插件以理解注解,并避免对缺失的构造函数、获取器和设置器发出警告;并且您无法在生成的代码中设置断点进行调试(但在这类方法中进行调试的情况很少)。
列表 17.1 Maven 的 pom.xml 文件
Path: Ch17/springdatamongodb/pom.xml
\1 Ⓐ
<groupId>org.springframework.boot</groupId> Ⓐ
<artifactId>spring-boot-starter-data-mongodb</artifactId> Ⓐ
</dependency> Ⓐ
<dependency> Ⓑ
<groupId>org.projectlombok</groupId> Ⓑ
<artifactId>lombok</artifactId> Ⓑ
</dependency> Ⓑ
Ⓐ spring-boot-starter-data-mongodb 是 Spring Boot 用于通过 Spring Data 连接到 MongoDB 数据库的启动依赖项。
Ⓑ Lombok 允许我们减少样板代码,并依赖自动生成的构造函数、获取器和设置器。
我们下一步是填写 Spring Boot 应用程序的应用程序.properties 文件,该文件可以包含应用程序将使用的各种属性。Spring Boot 将自动从类路径中查找并加载 application.properties,并且 Maven 将 src/main/resources 文件夹添加到类路径中。应用程序.properties 配置文件如列表 17.2 所示。
列表 17.2 应用程序.properties 文件
Path: Ch17/springdatamongodb/src/main/resources/application.properties
logging.level.org.springframework.data.mongodb.core.MongoTemplate=DEBUG Ⓐ
spring.data.mongodb.auto-index-creation=true Ⓑ
Ⓐ Spring Data MongoDB 应用程序以 DEBUG 级别记录查询。因此,要启用查询记录,我们必须将日志级别设置为 DEBUG。
Ⓑ 在 Spring Data MongoDB 中,索引的创建默认是禁用的。通过将 spring.data.mongodb.auto-index-creation 属性设置为 true 来启用它。
User 类现在将包含特定于 Spring Data MongoDB 的注解。表 17.2 检查了几个注解和类,然后我们将通过操作 User 类来观察它们在实际中的应用。
表 17.2 Spring Data MongoDB 注解和类
| Spring Data MongoDB 注解/类 | 含义 |
|---|---|
@Document |
一个要持久化到 MongoDB 的域对象 |
@Indexed |
一个由 MongoDB 索引的字段 |
@CompoundIndexes |
一个用于复合索引的容器注解;它定义了一个多个 @CompoundIndex 注解的集合 |
@CompoundIndex |
注解一个类以在多个字段上使用复合索引 |
IndexDirection |
一个枚举,用于确定索引方向:ASCENDING(默认)或 DESCENDING |
org.springframework.data.mongodb.core.mapping 包包括 @Document 注解,而与索引相关的注解和枚举属于 org.springframework.data.mongodb.core.index 包。
对于 MongoDB 应用程序,我们还将使用一系列属于 org.springframework.data.annotation 包的核心 Spring Data 注解,如表 17.3 所示。
表 17.3 Spring Data 核心注解
| Spring Data 注解 | 含义 |
|---|---|
@Id |
标记字段为标识符 |
@Transient |
一个瞬态字段,它不会被持久化,也不会被持久化框架检查 |
@PersistenceConstructor |
标记构造函数为持久化框架在从数据库检索信息时使用的首选构造函数 |
本章使用 Lombok 库通过注解自动创建构造函数、获取器和设置器,从而减少了样板代码。属于 lombok 包的最重要 Lombok 注解列于表 17.4 中。
表 17.4 Lombok 注解
| Lombok 注解 | 含义 |
|---|---|
@NoArgsConstructor |
自动为它注解的类创建一个公共的无参数构造函数 |
@Getter |
自动为它注解的字段创建一个公共的获取器 |
@Setter |
自动为它注解的字段创建一个公共的设置器 |
Spring Data MongoDB 应用程序使用的 User 类在列表 17.3 中展示。注有 @Transient 的 password 字段将不会被保存到 MongoDB 数据库中——有许多情况下,您可能希望秘密信息,如密码,不被持久化。注有 @PersistenceConstructor 的构造函数将由 Spring Data MongoDB 在从数据库检索信息时使用。构造函数的 ip 参数注有 @Value ("#root.ip ?: '192.168.1.100'"),这意味着如果在从数据库检索文档时 ip 值不存在,它将自动采用此默认值。
列表 17.3 User 类
Path: Ch17/springdatamongodb/src/main/java/com/manning/javapersistence
➥ /springdatamongodb/model/User.java
@NoArgsConstructor
@Document
@CompoundIndexes({
@CompoundIndex(name = "username_email",
def = "{'username' : 1, 'email': 1}"),
@CompoundIndex(name = "lastName_firstName",
def = "{'lastName' : 1, 'firstName': 1}")
})
public class User {
@Id
@Getter
private String id;
@Getter
@Setter
@Indexed(direction = IndexDirection.ASCENDING)
private String username;
//fields annotated with @Getter and @Setter
@Getter
@Setter
@Transient
private String password;
//another constructor
@PersistenceConstructor
public User(String username, String firstName, String lastName,
@Value("#root.ip ?: '192.168.1.100'") String ip) {
this.username = username;
this.firstName = firstName;
this.lastName = lastName;
this.ip = ip;
}
}
17.3 使用 MongoRepository 访问数据库
UserRepository 接口扩展了 MongoRepository<User, String>,继承了 MongoDB 相关的方法并管理具有 String 类型 ID 的 User 文档。
列表 17.4 UserRepository 接口
Path: Ch17/springdatamongodb/src/main/java/com/manning/javapersistence
➥ /springdatamongodb/repositories/UserRepository.java
public interface UserRepository extends MongoRepository<User, String> {
}
17.3.1 使用 Spring Data MongoDB 定义查询方法
我们将向 UserRepository 接口添加新方法,以便我们可以查询数据库中的某些特定文档并在测试中使用它们。
查询方法的目的在于从数据库中检索信息。Spring Data MongoDB 提供了一个查询构建器机制,类似于 Spring Data JPA 提供的——它将根据方法名称创建仓库方法的操作。请记住,查询机制会从方法名称中移除前缀和后缀,如 find...By、get...By、query...By、read...By 和 count...By,然后解析剩余部分。
与 Spring Data JPA 类似,Spring Data MongoDB 会查看方法的返回类型。如果我们想找到一个 User 并将其返回在 Optional 容器中,那么方法的返回类型将是 Optional<User>。
列表 17.5 新方法的 UserRepository 接口
Path: Ch17/springdatamongodb/src/main/java/com/manning/javapersistence
➥ /springdatamongodb/repositories/UserRepository.java
public interface UserRepository extends MongoRepository<User, String> {
Optional<User> findByUsername(String username);
List<User> findByLastName(String lastName);
List<User> findAllByOrderByUsernameAsc();
List<User> findByRegistrationDateBetween(LocalDate start,
➥ LocalDate end);
List<User> findByUsernameAndEmail(String username, String email);
List<User> findByUsernameOrEmail(String username, String email);
List<User> findByUsernameIgnoreCase(String username);
List<User> findByLevelOrderByUsernameDesc(int level);
List<User> findByLevelGreaterThanEqual(int level);
List<User> findByUsernameContaining(String text);
List<User> findByUsernameLike(String text);
List<User> findByUsernameStartingWith(String start);
List<User> findByUsernameEndingWith(String end);
List<User> findByActive(boolean active);
List<User> findByRegistrationDateIn(Collection<LocalDate> dates);
List<User> findByRegistrationDateNotIn(Collection<LocalDate> dates);
}
方法的名称需要遵循确定结果查询的规则。如果方法命名错误(例如,查询方法中的实体属性不匹配),则在加载应用程序上下文时将得到错误。表 17.5 总结了构建 Spring Data MongoDB 查询方法中使用的必要关键字及其产生的条件。对于更完整的列表,请参阅附录 D。
表 17.5 Spring Data MongoDB 中关键字的使用及其产生的条件
| 关键字 | 示例 | 条件 |
|---|---|---|
Is, Equals |
findByUsername(String name) findByUsernameIs(String name) findByUsernameEquals(String name) |
{"username":"name"} |
And |
findByUsernameAndEmail(String username, String email) |
{"username":"username", "email":"email"} |
Or |
findByUsernameOrEmail(String username, String email) |
{ "$or" : [{ "username" : "username"}, { "email" : "email"}]} |
LessThan |
findByRegistrationDateLessThan(LocalDate date) |
{ "registrationDate" : { "$lt" : { "$date" : "date"}}} |
LessThanEqual |
findByRegistrationDateLessThanEqual(LocalDate date) |
{ "registrationDate" : { "$lte" : { "$date" : "date"}}} |
GreaterThan |
findByRegistrationDateGreaterThan(LocalDate date) |
{ "registrationDate" : { "$gt" : { "$date" : "date"}}} |
GreaterThanEqual |
findByRegistrationDateGreaterThanEqual(LocalDate date) |
{ "registrationDate" : { "$gte" : { "$date" : "date"}}}` |
Between |
findByRegistrationDateBetween(LocalDate from, LocalDate to) |
"registrationDate" : { "$gte" : { "$date" : "from"}, "$lte" : { "$date" : "to"}}} |
OrderBy |
findByRegistrationDateOrderByUsernameDesc(LocalDate date) |
"registrationDate": { "$date": "date"}} |
Like |
findByUsernameLike(String name) |
{ "username": { "$regularExpression": { "pattern": "name", "options": ""}}} |
NotLike |
findByUsernameNotLike(String name) |
{ "username": { "$not": { "$regularExpression": { "pattern": "name", "options": ""}}}} |
Before |
findByRegistrationDateBefore(LocalDate date) |
{ "registrationDate": { "$lt": { "$date": "date"}}} |
After |
findByRegistrationDateAfter(LocalDate date) |
{ "registrationDate": { "$gt": { "$date": "date"}}} |
Null, IsNull |
findByRegistrationDate(Is)Null() |
{ "registrationDate": null} |
NotNull, IsNotNull |
findByRegistrationDate(Is)NotNull() |
{ "registrationDate": { "$ne": null}} |
Not |
findByUsernameNot(String name) |
{ "username": { "$ne": "name"}} |
作为所有未来测试的基础类,我们将编写SpringDataJdbcApplicationTests抽象类(列表 17.6)。
@SpringBootTest注解由 Spring Boot 添加到最初创建的类中,它将告诉 Spring Boot 搜索主配置类(例如,被@SpringBootApplication注解的类)并创建用于测试的ApplicationContext。如您所回忆的,Spring Boot 添加到包含main方法的类上的@SpringBootApplication注解将启用 Spring Boot 自动配置机制,启用对应用程序所在包的扫描,并允许在上下文中注册额外的 bean。
使用@TestInstance(TestInstance.Lifecycle.PER_CLASS)注解,我们将要求 JUnit 5 创建一个测试类的单个实例,并为其所有测试方法重用。这将使我们能够将@BeforeAll和@AfterAll注解的方法设置为非静态,并直接在它们内部使用自动装配的UserRepository实例字段。被@BeforeAll注解的非静态方法将在所有扩展SpringDataJdbcApplicationTests的类的测试之前执行一次,并将generateUsers方法内部创建的用户列表保存到数据库中。被@AfterAll注解的非静态方法将在所有扩展SpringDataJdbcApplicationTests的类的测试之后执行一次,并从数据库中删除所有用户。
列表 17.6 SpringDataJdbcApplicationTests抽象类
Path: Ch17/springdatamongodb/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/SpringDataMongoDBApplicationTests.java
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
abstract class SpringDataJdbcApplicationTests {
@Autowired Ⓐ
UserRepository userRepository; Ⓐ
@BeforeAll
void beforeAll() {
userRepository.saveAll(generateUsers());
}
private static List<User> generateUsers() {
List<User> users = new ArrayList<>();
User john = new User("john", "John", "Smith");
john.setRegistrationDate(LocalDate.of(2020, Month.APRIL, 13));
john.setEmail("john@somedomain.com");
john.setLevel(1);
john.setActive(true);
john.setPassword("password1");
//create and set a total of 10 users
users.add(john);
//add a total of 10 users to the list
return users;
}
@AfterAll
void afterAll() {
userRepository.deleteAll();
}
}
Ⓐ 自动装配一个UserRepository实例。这是由于@SpringBootApplication注解,它启用了对应用程序所在包的扫描并在上下文中注册了 bean。
下一个测试将扩展这个类并使用已经填充的数据库。为了测试现在属于 UserRepository 的方法,我们将创建 FindUsersTest 类并遵循相同的测试编写方法:调用存储库方法并验证其结果。回想一下,在 JUnit 5 中,测试类和方法只需要是包私有的就足够了;它们不需要是公共的。
列表 17.7 FindUsersTest 类
Path: Ch17/springdatamongodb/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/FindUsersTest.java
class FindUsersTest extends SpringDataJdbcApplicationTests{
@Test
void testFindAll() {
List<User> users = userRepository.findAll();
assertEquals(10, users.size());
}
@Test
void testFindUser() {
User beth = userRepository.findByUsername("beth").get();
assertAll(
() -> assertNotNull(beth.getId()),
() -> assertEquals("beth", beth.getUsername())
);
}
@Test
void testFindAllByOrderByUsernameAsc() {
List<User> users = userRepository.findAllByOrderByUsernameAsc();
assertAll(() -> assertEquals(10, users.size()),
() -> assertEquals("beth", users.get(0).getUsername()),
() -> assertEquals("stephanie", users.get(users.size() -
1).getUsername()));
}
@Test
void testFindByRegistrationDateBetween() {
List<User> users = userRepository.findByRegistrationDateBetween(
LocalDate.of(2020, Month.JULY, 1),
LocalDate.of(2020, Month.DECEMBER, 31));
assertEquals(4, users.size());
}
//more tests
}
17.3.2 限制查询结果、排序和分页
正如 Spring Data JPA 和 Spring Data JDBC 一样,first 和 top 关键字(等效使用)可以限制查询方法的结果。top 和 first 关键字后面可以跟一个可选的数值,表示要返回的最大结果大小。如果这个数值缺失,结果大小将是 1。
Pageable 是一个分页信息的接口,在实践中我们使用实现它的 PageRequest 类。这个类可以指定页码、页面大小和排序标准。
我们在这里要解决的问题将是获取一定数量的用户(例如按用户名或注册日期找到第一个用户,或者按给定等级找到第一个用户,并按注册日期排序),或者按页获取大量用户,这样我们就可以轻松地操作它们。
为了限制查询结果并进行排序和分页,我们将向 UserRepository 接口添加以下方法。
列表 17.8 在 UserRepository 中限制查询结果、排序和分页
Path: Ch17/springdatamongodb/src/main/java/com/manning/javapersistence
➥ /springdatamongodb/repositories/UserRepository.java
Optional<User> findFirstByOrderByUsernameAsc();
Optional<User> findTopByOrderByRegistrationDateDesc();
Page<User> findAll(Pageable pageable);
List<User> findFirst2ByLevel(int level, Sort sort);
List<User> findByLevel(int level, Sort sort);
List<User> findByActive(boolean active, Pageable pageable);
这些方法使用表 17.5 中介绍的查询构建器机制,但这次是为了限制查询结果,进行排序和分页。例如,Optional<User> findFirstByOrderByUsernameAsc() 方法将按用户名获取第一个用户(结果是一个 Optional,所以最终可能不存在)。Page<User> findAll(Pageable pageable) 方法将按页获取所有用户。我们将编写以下测试来验证这些新添加的方法是否正常工作。
列表 17.9 测试限制查询结果、排序和分页
Path: Ch17/springdatamongodb/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/FindUsersSortingAndPagingTest.java
class FindUsersSortingAndPagingTest extends SpringDataJdbcApplicationTests {
@Test
void testOrder() {
User user1 = userRepository.findFirstByOrderByUsernameAsc().get(); Ⓐ
User user2 = Ⓐ
userRepository.findTopByOrderByRegistrationDateDesc().get(); Ⓐ
Page<User> userPage = userRepository.findAll(PageRequest.of(1, 3)); Ⓑ
List<User> users = userRepository.findFirst2ByLevel(2, Ⓒ
Sort.by("registrationDate")); Ⓒ
assertAll(
() -> assertEquals("beth", user1.getUsername()),
() -> assertEquals("julius", user2.getUsername()),
() -> assertEquals(2, users.size()),
() -> assertEquals(3, userPage.getSize()),
() -> assertEquals("beth", users.get(0).getUsername()),
() -> assertEquals("marion", users.get(1).getUsername())
);
}
@Test
void testFindByLevel() {
Sort.TypedSort<User> user = Sort.sort(User.class); Ⓓ
List<User> users = userRepository.findByLevel(3, Ⓔ
user.by(User::getRegistrationDate).descending()); Ⓔ
assertAll(
() -> assertEquals(2, users.size()),
() -> assertEquals("james", users.get(0).getUsername())
);
}
@Test
void testFindByActive() {
List<User> users = userRepository.findByActive(true, Ⓕ
PageRequest.of(1, 4, Sort.by("registrationDate"))); Ⓕ
assertAll(
() -> assertEquals(4, users.size()),
() -> assertEquals("burk", users.get(0).getUsername())
);
}
}
Ⓐ 第一次测试将按用户名升序找到第一个用户,按注册日期降序找到第一个用户。
Ⓑ 查找所有用户,将它们分成页面,并返回第 1 页,大小为 3(页码从 0 开始)。
Ⓒ 查找前两个等级为 2 的用户,并按注册日期排序。
Ⓓ 第二次测试将在 User 类上定义一个排序标准。Sort.TypedSort 扩展了 Sort 并可以使用方法处理程序来定义排序属性。
Ⓔ 查找等级为 3 的用户,并按注册日期降序排序。
Ⓕ 第三次测试将找到活跃用户,按注册日期排序,并将它们分成页面,并返回第 1 页,大小为 4(页码从 0 开始)。
17.3.3 流式传输结果
返回多个结果的查询方法可以使用标准的 Java 接口,如 Iterable、List、Set。与 Spring Data JPA 和 Spring Data JDBC 类似,Spring Data MongoDB 支持 Streamable,它可以作为 Iterable 或任何集合类型的替代品。它允许我们连接 Streamables 并直接过滤和映射元素。
我们在这里要解决的问题是以流的形式获取结果,而不是等待整个用户集合或一页用户被获取。这样我们就能快速开始处理流到我们这里的第一批结果。与集合不同,流只能被消费一次,且是不可变的。
我们将在 UserRepository 接口中添加以下方法。
列表 17.10 在 UserRepository 接口中添加返回 Streamable 的方法
Path: Ch17/springdatamongodb/src/main/java/com/manning/javapersistence
➥ /springdatamongodb/repositories/UserRepository.java
Streamable<User> findByEmailContaining(String text);
Streamable<User> findByLevel(int level);
我们将编写以下测试来验证这些新添加的方法如何与数据库交互,并提供作为流的结果。流作为 try 块的资源被自动关闭。另一种选择是显式调用 close() 方法。否则,流将保持与数据库的底层连接。
列表 17.11 测试返回 Streamable 的方法
Path: Ch17/springdatamongodb/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/QueryResultsTest.java
@Test
void testStreamable() {
try(Stream<User> result = Ⓐ
userRepository.findByEmailContaining("someother") Ⓐ
.and(userRepository.findByLevel(2)) Ⓑ
.stream().distinct()) { Ⓒ
assertEquals(7, result.count()); Ⓓ
}
}
Ⓐ 测试将调用 findByEmailContaining 方法,搜索包含 "someother" 单词的电子邮件。
Ⓑ 测试将结果 Streamable 与 Streamable 连接,提供二级用户。
Ⓒ 它将将其转换为流并保留不同的用户。
Ⓓ 检查结果流包含 7 个用户。
17.3.4 @Query 注解
我们可以通过使用 @org.springframework.data.mongodb.repository.Query 注解来创建可以指定自定义查询的方法。使用这个 @Query 注解,方法名不需要遵循任何命名约定。自定义查询将有一个 MongoDB 查询过滤器作为参数,并且这个查询过滤器可以进行参数化。
我们将在 UserRepository 接口中添加新方法:它们将被 @Query 注解标记,并且它们的生成行为将取决于这些查询的定义。value 参数将指示要执行的查询过滤器。fields 参数将指示要包含或排除在结果中的字段。表 17.6 总结了最常见的查询操作和相应的 @Query 参数。对于完整的列表,请参阅 Spring Data MongoDB 文档。
表 17.6 查询操作及相应的 @Query 参数
| 操作 | @Query 参数 |
|---|---|
| 获取给定字段的 数据 | value = { 'field' : ?0} |
| 获取给定正则表达式的数据 | value = { 'lastName' : { $regex: ?0 } } |
| 获取字段大于参数的数据 | value = { 'field' : { $gt: ?0 } } |
| 获取字段大于或等于参数的数据 | value = { 'field' : { $gte: ?0 } } |
| 获取字段小于参数的数据 | value = { 'field' : { $lt: ?0 } } |
| 获取字段小于或等于参数的数据 | value = { 'field' : { $lte: ?0 } } |
| 查询中仅包含一个字段 | fields = "{field : 1}" |
| 从查询中排除一个字段 | fields = "{field : 0}" |
查询过滤器是@Query注解的参数:?0占位符将引用方法的第一个参数,?1占位符将引用方法的第二个参数,依此类推,如下所示。
列表 17.12 在UserRepository中限制查询结果、排序和分页
Path: Ch17/springdatamongodb/src/main/java/com/manning/javapersistence
➥ /springdatamongodb/repositories/UserRepository.java
@Query("{ 'active' : ?0 }") Ⓐ
List<User> findUsersByActive(boolean active); Ⓐ
@Query("{ 'lastName' : ?0 }") Ⓑ
List<User> findUsersByLastName(String lastName); Ⓑ
@Query("{ 'lastName' : { $regex: ?0 } }") Ⓒ
List<User> findUsersByRegexpLastName(String regexp); Ⓒ
@Query("{ 'level' : { $gte: ?0, $lte: ?1 } }") Ⓓ
List<User> findUsersByLevelBetween(int minLevel, int maxLevel); Ⓓ
@Query(value = "{}", fields = "{username : 1}") Ⓔ
List<User> findUsernameAndId(); Ⓔ
@Query(value = "{}", fields = "{_id : 0}") Ⓕ
List<User> findUsersExcludeId(); Ⓕ
@Query(value = "{'lastName' : { $regex: ?0 }}", fields = "{_id : 0}") Ⓖ
List<User> findUsersByRegexpLastNameExcludeId(String regexp); Ⓖ
Ⓐ findUsersByActive 方法将返回具有给定active状态的用户。
Ⓑ findUsersByLastName 方法将返回具有给定lastName的用户。
Ⓒ findUsersByRegexpLastName 方法将返回与?0占位符匹配的lastName的用户,该占位符引用了方法的第一个参数的正则表达式。
Ⓓ findUsersByLevelBetween 方法将返回具有大于或等于?0占位符的level的用户,该占位符引用了方法的第一个参数,并且小于或等于?1占位符,该占位符引用了方法的第二个参数。
Ⓔ findUsernameAndId 方法将选择所有用户(因为value参数是{}),并且只返回id和username字段(因为fields参数是{username : 1})。
Ⓕ findUsersExcludeId 方法将选择所有用户(因为value参数是{}),并将id从返回的字段中排除(因为fields参数是{_id : 0})。
Ⓖ findUsersByRegexpLastNameExcludeId 方法将选择与给定正则表达式匹配的lastName的用户,并将id从返回的字段中排除。
为这些查询方法编写测试相当直接,并且与之前的示例相似。它们可以在本书的源代码中找到。
17.4 示例查询
在我们检查 Spring Data JPA 时,第四章讨论了示例查询(QBE)。它是一种不需要编写经典查询来包含实体和属性的查询技术。它允许动态查询创建,并包含三个部分:一个探测器、一个ExampleMatcher和一个Example。
探测器是一个已经设置了属性的域对象。ExampleMatcher提供了关于匹配特定属性的规则。Example将探测器和ExampleMatcher组合在一起并生成查询。多个Example可以复用单个ExampleMatcher。
如前所述,这些是最适合 QBE 的使用场景:
-
当你想要将代码的工作与底层数据存储 API 解耦时
-
当你想要频繁更改域对象的内部结构而不将其传播到现有查询时
-
当你正在构建一组静态或动态约束以查询存储库时
QBE 有一些限制:
-
它仅支持对
String属性进行起始/结束/包含/正则表达式匹配,以及其他类型的精确匹配。 -
它不支持嵌套或分组属性约束,例如
{"$or":[ {"username":"username"},{{"lastName":"lastName", "email":"email"}}]}。
我们不会向UserRepository接口添加更多方法。相反,我们将编写测试来构建探针、ExampleMatcher和Example。以下列表将创建一个简单的探针和一个设置了lastName的用户。
列表 17.13 查询示例测试
Path: Ch17/springdatamongodb/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/QueryByExampleTest.java
User probe = new User(null, null, "Smith"); Ⓐ
List<User> result = userRepository.findAll(Example.of(probe)); Ⓑ
assertThat(result).hasSize(2) Ⓒ
.extracting("username").contains("john", "burk"); Ⓒ
Ⓐ 初始化一个User实例并为它设置一个lastName。这代表探针。
Ⓑ 执行查询以找到所有与探针匹配的用户。
Ⓒ 验证查询所有与探针匹配的用户返回了 2 个文档,并且包含username为john和burk的文档。
我们现在可以使用构建器模式创建ExampleMatcher。任何null引用属性都将被匹配器忽略。然而,我们需要明确忽略原始类型的属性。如果它们没有被忽略,它们将带有默认值包含在匹配器中,这将改变生成的查询。
列表 17.14 使用匹配器测试的查询示例
Path: Ch17/springdatamongodb/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/QueryByMatcherTest.java
ExampleMatcher matcher = ExampleMatcher.matching() Ⓐ
.withIgnorePaths("level") Ⓐ
.withIgnorePaths("active"); Ⓐ
User probe = new User(); Ⓑ
probe.setLastName("Smith"); Ⓑ
List<User> result = userRepository.findAll(Example.of(probe, matcher)); Ⓒ
assertThat(result).hasSize(2) Ⓓ
.extracting("username").contains("john", "burk"); Ⓓ
Ⓐ 使用构建器模式创建ExampleMatcher。我们明确忽略level和active属性,它们是原始类型。如果它们没有被忽略,它们将带有默认值(level为 0 和active为false)包含在匹配器中,并改变生成的查询。
Ⓑ 创建并设置一个User探针。
Ⓒ 执行查询以找到所有与探针匹配的用户。
Ⓓ 验证查询所有与探针匹配的用户返回了 2 个文档,并且包含username为john和burk的文档。
17.5 引用其他 MongoDB 文档
Spring Data MongoDB 不支持关系数据库中我们考察的一对一、一对多和多对多关系。该框架不支持在另一个文档内嵌入文档。然而,可以使用 DBRefs 从另一个文档引用文档。DBRef 将包括集合名称和另一个文档 ID 字段的值,以及可选的另一个数据库名称。
要使用 DBRefs,我们将创建另一个带有@Document注解的类和另一个 MongoDB 仓库,并在类内部使用@DBRef注解引用新添加的文档。
我们将添加的新Address类如下所示。它像任何对应 MongoDB 文档的类一样带有@Document注解,字段带有 Lombok 的@Getter注解,表示自动生成 getter。
列表 17.15 修改后的Address类
Path: Ch17/springdatamongodb2/src/main/java/com/manning/javapersistence
➥ /springdatamongodb/model/Address.java
@Document
public class Address {
@Id
@Getter
private String id;
@Getter
private String street, zipCode, city, state;
// . . .
}
下一个列表显示了新的AddressRepository接口。它扩展了MongoRepository<Address, String>,继承了 MongoDB 相关方法,并管理Address文档,具有String类型的 ID。
列表 17.16 AddressRepository 接口
Path: Ch17/springdatamongodb2/src/main/java/com/manning/javapersistence
➥ /springdatamongodb/repositories/AddressRepository.java
public interface AddressRepository extends
➥ MongoRepository<Address, String> {
}
我们将修改 User 类以包含一个 address 字段,该字段引用 Address 文档。我们将使用 @DBRef 注解,表示该字段将使用 DBRef 存储方式。我们还将使用 @Field 注解该字段,该注解可以在文档内部提供自定义名称。
列表 17.17 修改后的 User 类
Path: Ch17/springdatamongodb2/src/main/java/com/manning/javapersistence
➥ /springdatamongodb/model/User.java
@NoArgsConstructor
@Document
@CompoundIndexes({
@CompoundIndex(name = "username_email",
def = "{'username' : 1, 'email': 1}"),
@CompoundIndex(name = "lastName_firstName",
def = "{'lastName' : 1, 'firstName': 1}")
})
public class User {
// . . .
@DBRef
@Field("address")
@Getter
@Setter
private Address address;
// . . .
}
描述具有地址的用户的一个 MongoDB 文档可能看起来像这样:
{
"_id": {
"$oid": "61cb2fcfff98d570824fef66"
},
"username": "john",
"firstName": "John",
"lastName": "Smith",
"registrationDate": {
"$date": "2020-04-12T21:00:00.000Z"
},
"email": "john@somedomain.com",
"level": 1,
"active": true,
"address": {
"$ref": "address",
"$id": {
"$oid": "61cb2fcbff98d570824fef30"
}
},
"_class": "com.manning.javapersistence.springdatamongodb.model.User"
}
描述地址的一个 MongoDB 文档可能看起来像这样:
{
"_id": {
"$oid": "61cb2fcbff98d570824fef30"
},
"street": "Flowers Street",
"zipCode": "1234567",
"city": "Boston",
"state": "MA",
"_class": "com.manning.javapersistence.springdatamongodb.model.Address"
}
注意,操作如保存和删除在文档之间不会级联。如果我们保存或删除一个文档,我们必须显式地保存或删除引用的文档。
为引用其他文档的 MongoDB 文档编写测试相对简单,并且与之前的示例类似。它们可以在本书的源代码中找到。
17.6 使用 MongoTemplate 访问数据库
MongoTemplate 是一个提供对 MongoDB 数据库 CRUD 操作访问的类。MongoTemplate 实现了 MongoOperations 接口。MongoOperations 中的方法命名与 MongoDB 驱动程序 Collection 对象的方法类似,以方便理解和使用 API。
17.6.1 通过 MongoTemplate 配置对数据库的访问
要将我们的 Spring Boot 应用程序连接到 MongoDB,我们将扩展 AbstractMongoClientConfiguration 类。此类为 Spring Data MongoDB 的 Java 配置提供支持。我们可以通过 MongoDatabaseFactory 接口和 MongoTemplate 的实现来连接到 MongoDB。
AbstractMongoClientConfiguration 类提供了两个可以在 Spring Boot 应用程序中使用的 Bean:
@Bean
public MongoTemplate mongoTemplate(MongoDatabaseFactory databaseFactory,
MappingMongoConverter converter) {
return new MongoTemplate(databaseFactory, converter);
}
@Bean
public MongoDatabaseFactory mongoDbFactory() {
return new SimpleMongoClientDatabaseFactory(this.mongoClient(),
this.getDatabaseName());
}
我们将创建一个 MongoDBConfig 类,该类扩展 AbstractMongoClientConfiguration,并且我们只重写 getDatabaseName() 方法来指示我们的 Spring Boot 应用程序连接到 test 数据库,如下所示。
列表 17.18 MongoDBConfig 类
Path: Ch17/springdatamongodb3/src/main/java/com/manning/javapersistence
➥ /springdatamongodb/configuration/MongoDBConfig.java
@Configuration
public class MongoDBConfig extends AbstractMongoClientConfiguration {
@Override
public String getDatabaseName() {
return "test";
}
}
17.6.2 使用 MongoTemplate 执行 CRUD 操作
要在数据库中插入文档,我们可以使用 MongoTemplate 的 insert 方法。该方法被重载,以下代码片段使用了带有对象作为参数的方法,或者一个对象集合及其类。
Path: Ch17/springdatamongodb3/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/template/SpringDataMongoDBApplicationTests.java
mongoTemplate.insert(GenerateUsers.address);
mongoTemplate.insert(generateUsers(), User.class);
save 方法有不同的行为:如果 id 已经存在于数据库中,它执行更新操作;否则,它执行插入操作。该方法也是重载的;以下代码片段使用了带有对象和集合名称作为参数的方法:
Path: Ch17/springdatamongodb3/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/template/SaveUpdateTest.java
mongoTemplate.save(user, "user");
我们将使用 org.springframework.data.mongodb.core.query.Query 对象来定义检索 MongoDB 文档的准则、投影和排序。使用默认构造函数初始化的此类 Query 将对应于集合中的所有文档。例如,我们可以使用如下代码片段从集合中删除所有文档:
Path: Ch17/springdatamongodb3/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/template/SpringDataMongoDBApplicationTests.java
mongoTemplate.remove(new Query(), User.class);
mongoTemplate.remove(new Query(), Address.class);
我们可以使用不同的标准来修改Query对象。这里我们将构建一个查询来查找具有level 1 的用户:
Path: Ch17/springdatamongodb3/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/template/FindAndModifyTest.java
Query query = new Query();
query.addCriteria(Criteria.where("level").is(1));
我们可以构建一个查询来查找具有给定username和email的用户:
Path: Ch17/springdatamongodb3/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/template/FindUsersTest.java
Query query1 = new Query();
query1.addCriteria(Criteria.where("username").is("mike")
.andOperator(Criteria.where("email").is("mike@somedomain.com")));
我们可以类似地构建一个查询来查找具有给定username或email的用户:
Path: Ch17/springdatamongodb3/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/template/FindUsersTest.java
Query query2 = new Query(new Criteria().
orOperator(Criteria.where("username").is("mike"),
Criteria.where("email").is("beth@somedomain.com")));
要更新文档,我们可以使用属于org.springframework.data.mongodb.core.query.Update类的对象。这样的对象必须设置为新值以替换旧值。updateFirst更新找到的第一个符合给定标准的文档。以下代码将找到User类中具有level 1 的第一个文档,并将其更新为level 2。然后我们可以使用find方法获取所有剩余的具有level 1 的用户:
Path: Ch17/springdatamongodb3/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/template/UpdateFirstTest.java
Query query = new Query();
query.addCriteria(Criteria.where("level").is(1));
Update update = new Update();
update.set("level", 2);
mongoTemplate.updateFirst(query, update, User.class);
List<User> users = mongoTemplate.find(query, User.class);
updateMulti更新所有找到的符合给定标准的文档。以下代码将找到User类中所有具有level 1 的文档,并将它们更新为level 2:
Path: Ch17/springdatamongodb3/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/template/UpdateMultiTest.java
Query query = new Query();
query.addCriteria(Criteria.where("level").is(1));
Update update = new Update();
update.set("level", 2);
mongoTemplate.updateMulti(query, update, User.class);
findAndModify方法与updateMulti类似,但它返回修改前的对象。在以下代码片段中,我们检查findAndModify方法返回的对象是否仍然具有旧的level值:
Path: Ch17/springdatamongodb3/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/template/FindAndModifyTest.java
Query query = new Query();
query.addCriteria(Criteria.where("level").is(1));
Update update = new Update();
update.set("level", 2);
User user = mongoTemplate.findAndModify(query, update, User.class);
assertEquals(1, user.getLevel());
upsert方法将查找符合某些给定标准的文档。如果找到,它将更新它;如果没有找到,它将创建一个新的文档,该文档结合了查询和更新对象。方法名称是update和insert的组合,MongoDB 将根据文档是否已存在来决定要做什么。以下代码片段使用getMatchedCount()方法检查是否有一个文档与查询匹配,并使用getModifiedCount()方法检查是否有一个文档被更新:
Path: Ch17/springdatamongodb3/src/test/java/com/manning/javapersistence
➥ /springdatamongodb/template/UpsertTest.java
Query query = new Query();
query.addCriteria(Criteria.where("level").is(1));
Update update = new Update();
update.set("level", 2);
UpdateResult result = mongoTemplate.upsert(query, update, User.class);
assertAll(
() -> assertEquals(1, result.getMatchedCount()),
() -> assertEquals(1, result.getModifiedCount())
);
您可以通过访问MongoTemplate在书籍的源代码中找到对 MongoDB 的综合测试。
关于两种方法MongoRepository和MongoTemplate的比较,请参阅表 17.7。
表 17.7 比较MongoRepository和MongoTemplate
| 优点 | 缺点 | |
|---|---|---|
MongoRepository |
-
遵循 Spring Data JPA 和 Spring Data JDBC 仓库的方法。
-
允许我们通过查询构建器机制模式快速创建方法,通过它们的名称定义其行为。
|
-
提供了执行基本 CRUD 操作的方法,这些操作与文档的所有字段一起工作。
-
更新文档必须在步骤中执行(查找、修改相关字段、保存)或使用带有
@Query注解的方法。
|
MongoTemplate |
|---|
-
提供了如
updateFirst、updateMulti、findAndModify、upsert等原子操作。 -
原子操作有利于并发应用程序的工作。
-
Update对象允许我们选择仅更新字段。
|
- 更冗长,需要编写的代码更多,尤其是对于简单操作。
|
概述
-
您可以使用 Spring Boot 创建和配置一个 Spring Data MongoDB 项目。
-
您可以创建文档类,并使用 Spring Data MongoDB 注解来定义简单和复合索引,将字段标记为瞬态,并定义持久化构造函数。
-
您可以构建扩展
MongoRepository的自定义接口,并按照查询构建器机制创建自定义方法,以与 MongoDB 数据库进行交互。 -
您可以使用 Spring Data MongoDB 的能力来限制查询结果、排序、分页以及流式传输结果。
-
您可以使用
@Query注解来定义自定义查询,并且可以使用示例查询(Query by Example,QBE)查询技术来与查询交互。 -
您可以配置应用程序以使用
MongoTemplate来对 MongoDB 数据库执行 CRUD 操作。
18 使用 Hibernate OGM
本章涵盖
-
介绍 Hibernate OGM
-
构建 MongoDB Hibernate OGM 简单应用程序
-
切换到 Neo4j NoSQL 数据库
数据库的世界极其多样和复杂。除了与不同的关系数据库系统合作所面临的挑战外,NoSQL 世界可能还会增加这些挑战。持久性框架的一个目标是要确保代码的可移植性,因此我们现在将探讨 Hibernate OGM 的替代方案以及它是如何尝试支持与 NoSQL 数据库一起工作的 JPA 解决方案的。
18.1 介绍 Hibernate OGM
NoSQL 数据库是一种以不同于关系表的形式存储数据的数据库。通常,NoSQL 数据库提供灵活的模式优势,这意味着数据库的设计者不需要在持久化数据之前确定模式。在需求快速变化的应用程序中,这可能是开发速度的一个重要优势。
根据它们用于存储数据格式的不同,可以将 NoSQL 数据库进行分类:
-
文档导向数据库,如我们在第十七章中介绍的 MongoDB,使用类似 JSON 的文档来存储信息。
-
图导向数据库使用图来存储信息。图由节点和边组成:节点的作用是存储数据,边表示节点之间的关系。Neo4j 是此类数据库的一个例子。
-
键值数据库使用映射结构来存储数据。键标识记录,而值表示数据。Redis 是此类数据库的一个例子。
-
广度列存储使用表、行和列来存储数据。这些与传统的关系数据库之间的区别在于,同一表的行之间的列的名称和格式可以不同。这种能力被称为动态列。Apache Cassandra 是此类数据库的一个例子。
我们之前的许多演示都使用了 JPA 和 Hibernate 来与关系数据库交互。这使我们能够编写可移植的应用程序,独立于关系数据库供应商,并通过框架管理供应商之间的差异。
Hibernate OGM 将关系数据库的可移植性概念扩展到 NoSQL 数据库。可移植性可能需要以影响执行速度为代价,但总体来说,它提供的优势多于不足。OGM 代表对象-网格映射器。它重用了 Hibernate Core 引擎、API 和 JPQL,不仅能够与关系数据库交互,还能与 NoSQL 数据库交互。
Hibernate OGM 支持一系列 NoSQL 数据库,在本章中我们将使用 MongoDB 和 Neo4j。
18.2 构建 MongoDB Hibernate OGM 简单应用程序
我们将开始构建一个由 Maven 管理的简单 Hibernate OGM 应用程序。我们将检查涉及的步骤、需要添加到项目中的依赖项以及需要编写的持久性代码。
我们首先将使用 MongoDB 作为文档导向的 NoSQL 数据库。然后,我们将修改我们的应用程序以使用 Neo4j,这是一个图导向的 NoSQL 数据库。我们只更改一些必要的依赖项和配置——我们不会触及使用 JPA 和 JPQL 的代码。
18.2.1 配置 Hibernate OGM 应用程序
在 Maven pom.xml 文件中,我们将在dependencyManagement部分添加org.hibernate.ogm:hibernate-ogm-bom。BOM 是材料清单的缩写。将 BOM 添加到dependencyManagement块实际上不会将依赖项添加到项目中,但它是一个意向声明。稍后在依赖项部分找到的传递依赖项将由这个初始声明控制版本。
接下来,我们将在依赖项部分添加另外两个东西:hibernate-ogm-mongodb,这是与 MongoDB 一起工作所需的,以及org.jboss.jbossts :jbossjta,这是一个 Hibernate OGM 需要支持的 JTA(Java 事务 API)实现。
我们还将使用 JUnit 5 进行测试和 Lombok,这是一个 Java 库,可以通过注解自动创建构造函数、获取器和设置器,从而减少样板代码。如前所述(在第 17.2 节中),Lombok 有其自身的不足:你需要一个 IDE 插件来理解注解,并且不会对缺少构造函数、获取器和设置器进行抱怨;并且你无法在生成的代码中设置断点进行调试(但调试生成代码的需要相当罕见)。
结果的 Maven pom.xml 文件如下所示。
列表 18.1 pom.xml Maven 文件
Path: Ch18/hibernate-ogm/pom.xml
\1
<dependencies>
<dependency>
<groupId>org.hibernate.ogm</groupId>
<artifactId>hibernate-ogm-bom</artifactId>
<type>pom</type>
<version>4.2.0.Final</version>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.hibernate.ogm</groupId>
<artifactId>hibernate-ogm-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.jboss.jbossts</groupId>
<artifactId>jbossjta</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
</dependencies>
现在,我们将继续处理持久化单元的标准配置文件,即 src/main/resources/META-INF/persistence.xml。
列表 18.2 persistence.xml配置文件
Path: Ch18/hibernate-ogm/src/main/resources/META-INF/persistence.xml
<persistence-unit name="ch18.hibernate_ogm"> Ⓐ
<provider>org.hibernate.ogm.jpa.HibernateOgmPersistence</provider> Ⓑ
<properties>
<property name="hibernate.ogm.datastore.provider" value="mongodb"/> Ⓒ
<property name="hibernate.ogm.datastore.database" Ⓓ
value="hibernate_ogm"/> Ⓓ
<property name="hibernate.ogm.datastore.create_database" Ⓔ
value="true"/> Ⓔ
</properties>
</persistence-unit>
Ⓐ persistence.xml文件配置了ch18.hibernate_ogm持久化单元。
Ⓑ API 的供应商特定提供者实现是 Hibernate OGM。
Ⓒ 数据存储提供者是 MongoDB。
Ⓓ 数据库的名称是hibernate_ogm。
Ⓔ 如果数据库不存在,将会创建它。
18.2.2 创建实体
现在,我们将创建代表应用程序实体的类:User、Bid、Item和Address。它们之间的关系将是单对多、多对一或嵌入式类型。
列表 18.3 User类
Path: Ch18/hibernate-ogm/src/main/java/com/manning/javapersistence
➥ /hibernateogm/model/User.java
@Entity
@NoArgsConstructor
public class User {
@Id Ⓐ
@GeneratedValue(generator = "ID_GENERATOR") Ⓐ
@GenericGenerator(name = "ID_GENERATOR", strategy = "uuid2") Ⓐ
@Getter
private String id;
@Embedded Ⓑ
@Getter
@Setter
private Address address; Ⓑ
@OneToMany(mappedBy = "user", cascade = CascadeType.PERSIST) Ⓒ
private Set<Bid> bids = new HashSet<>(); Ⓒ
// . . .
}
Ⓐ ID 字段是由ID_GENERATOR生成器生成的标识符。这个生成器使用uuid2策略,它产生一个唯一的 128 位 UUID。有关生成器策略的回顾,请参阅第 5.2.5 节。
Ⓑ 地址没有自己的标识符;它是可嵌入的。
Ⓒ User和Bid之间存在一对一的关系,这通过Bid侧的user字段进行映射。CascadeType.PERSIST表示持久化操作将从父User传播到子Bid。
Address类没有自己的持久化标识符,并且它是可嵌入的。
列表 18.4 Address类
Path: Ch18/hibernate-ogm/src/main/java/com/manning/javapersistence
➥ /hibernateogm/model/Address.java
@Embeddable
@NoArgsConstructor
public class Address {
//fields with Lombok annotations, constructor
}
Item类将包含一个与User中类似的生成策略的id字段。Item和Bid之间的关系将是一对多,级联类型将传播持久化操作从父级到子级。
列表 18.5 Item类
Path: Ch18/hibernate-ogm/src/main/java/com/manning/javapersistence
➥ /hibernateogm/model/Item.java
@Entity
@NoArgsConstructor
public class Item {
@Id
@GeneratedValue(generator = "ID_GENERATOR")
@GenericGenerator(name = "ID_GENERATOR", strategy = "uuid2")
@Getter
private String id;
@OneToMany(mappedBy = "item", cascade = CascadeType.PERSIST)
private Set<Bid> bids = new HashSet<>();
// . . .
}
18.2.3 使用 MongoDB 应用程序
要将实体从应用程序持久化到 MongoDB,我们将编写使用常规 JPA 类和 JPQL 的代码。这意味着我们的应用程序可以与关系型数据库以及各种 NoSQL 数据库一起工作。我们只需要更改一些配置。
要以与关系型数据库相同的方式使用 JPA,我们首先初始化一个EntityManagerFactory。ch18.hibernate_ogm持久化单元在 persistence.xml 中已声明。
列表 18.6 初始化EntityManagerFactory
Path: Ch18/hibernate-ogm/src/test/java/com/manning/javapersistence
➥ /hibernateogm/HibernateOGMTest.java
public class HibernateOGMTest {
private static EntityManagerFactory entityManagerFactory;
@BeforeAll
static void setUp() {
entityManagerFactory =
Persistence.createEntityManagerFactory("ch18.hibernate_ogm");
}
// . . .
}
在执行HibernateOGMTest类中的每个测试之后,我们将关闭EntityManagerFactory。
列表 18.7 关闭EntityManagerFactory
Path: Ch18/hibernate-ogm/src/test/java/com/manning/javapersistence
➥ /hibernateogm/HibernateOGMTest.java
@AfterAll
static void tearDown() {
entityManagerFactory.close();
}
在执行HibernateOGMTest类中的每个测试之前,我们将一些实体持久化到 NoSQL MongoDB 数据库。我们的代码将使用 JPA 进行这些操作,它不知道它是在与关系型数据库还是非关系型数据库交互。
列表 18.8 将数据持久化以进行测试
Path: Ch18/hibernate-ogm/src/test/java/com/manning/javapersistence
➥ /hibernateogm/HibernateOGMTest.java
@BeforeEach
void beforeEach() {
EntityManager entityManager = Ⓐ
entityManagerFactory.createEntityManager(); Ⓐ
try {
entityManager.getTransaction().begin(); Ⓑ
john = new User("John", "Smith"); Ⓒ
john.setAddress(new Address("Flowers Street", "12345", "Boston")); Ⓒ
bid1 = new Bid(BigDecimal.valueOf(1000)); Ⓒ
bid2 = new Bid(BigDecimal.valueOf(2000)); Ⓒ
item = new Item("Item1"); Ⓒ
bid1.setItem(item); Ⓒ
item.addBid(bid1); Ⓒ
bid2.setItem(item); Ⓒ
item.addBid(bid2); Ⓒ
bid1.setUser(john); Ⓒ
john.addBid(bid1); Ⓒ
bid2.setUser(john); Ⓒ
john.addBid(bid2); Ⓒ
entityManager.persist(item); Ⓓ
entityManager.persist(john); Ⓓ
entityManager.getTransaction().commit(); Ⓔ
} finally {
entityManager.close(); Ⓕ
}
}
Ⓐ 使用现有的EntityManagerFactory创建一个EntityManager。
Ⓑ 开始一个事务。如您所回忆的,使用 JPA 的操作需要是事务性的。
Ⓒ 创建并设置要持久化的实体。
Ⓓ 持久化Item实体和User实体。由于从Item和User引用的Bid实体使用CascadeType.PERSIST,持久化操作将从父级传播到子级。
Ⓔ 提交之前启动的事务。
Ⓕ 关闭之前创建的EntityManager。
我们将使用 JPA 查询数据库。我们将使用entityManager.find方法,就像与关系型数据库交互时一样。正如之前讨论的,每次与数据库的交互都应该在事务边界内进行,即使我们只是在读取数据,因此我们将启动并提交事务。
列表 18.9 使用 JPA 查询 MongoDB 数据库
Path: Ch18/hibernate-ogm/src/test/java/com/manning/javapersistence
➥ /hibernateogm/HibernateOGMTest.java
@Test
void testCRUDOperations() {
EntityManager entityManager = Ⓐ
entityManagerFactory.createEntityManager(); Ⓐ
try {
entityManager.getTransaction().begin(); Ⓑ
User fetchedUser = entityManager.find(User.class, john.getId()); Ⓒ
Item fetchedItem = entityManager.find(Item.class, item.getId()); Ⓒ
Bid fetchedBid1 = entityManager.find(Bid.class, bid1.getId()); Ⓒ
Bid fetchedBid2 = entityManager.find(Bid.class, bid2.getId()); Ⓒ
assertAll( Ⓓ
() -> assertNotNull(fetchedUser), Ⓓ
() -> assertEquals("John", fetchedUser.getFirstName()), Ⓓ
() -> assertEquals("Smith", fetchedUser.getLastName()), Ⓓ
() -> assertNotNull(fetchedItem), Ⓓ
() -> assertEquals("Item1", fetchedItem.getName()), Ⓓ
() -> assertNotNull(fetchedBid1), Ⓓ
() -> assertEquals(new BigDecimal(1000), Ⓓ
fetchedBid1.getAmount()), Ⓓ
() -> assertNotNull(fetchedBid2), Ⓓ
() -> assertEquals(new BigDecimal(2000), Ⓓ
fetchedBid2.getAmount()) Ⓓ
);
entityManager.getTransaction().commit(); Ⓔ
} finally {
entityManager.close(); Ⓕ
}
}
Ⓐ 使用现有的EntityManagerFactory创建一个EntityManager。
Ⓑ 开始一个事务;操作需要是事务性的。
Ⓒ 根据实体的id检索之前持久化的User、Item和Bid。
Ⓓ 确认检索到的信息包含我们之前持久化的内容。
Ⓔ 提交之前启动的事务。
Ⓕ 关闭之前创建的EntityManager。
我们可以在执行此测试后检查 MongoDB 数据库的内容。打开 MongoDB Compass 程序,如图 18.1 所示。MongoDB Compass 是一个用于与 MongoDB 数据库交互和查询的 GUI。它将显示在执行测试后创建了三个集合。这证明了使用 JPA 编写的代码能够在 Hibernate OGM 的帮助下与 NoSQL MongoDB 数据库交互。

图 18.1 使用 JPA 和 Hibernate OGM 编写的测试在 MongoDB 内部创建了三个集合。
我们还可以检查创建的集合,并查看它们是否包含从测试中持久化的文档(这应该在 afterEach() 方法之前查看,该方法会删除新添加的文档并运行)。例如,Bid 集合包含两个文档,如图 18.2 所示。

图 18.2 Bid 集合包含从测试中持久化的两个文档。
我们还将使用 JPQL 查询数据库。JPQL(Jakarta Persistence Query Language,之前称为 Java Persistence Query Language)是一种独立于平台的面向对象查询语言。
我们之前使用 JPQL 查询与 SQL 方言无关的关系型数据库,现在我们将使用 JPQL 与 NoSQL 数据库交互。
列表 18.10 使用 JPQL 查询 MongoDB 数据库
Path: Ch18/hibernate-ogm/src/test/java/com/manning/javapersistence
➥ /hibernateogm/HibernateOGMTest.java
@Test
void testJPQLQuery() {
EntityManager entityManager = Ⓐ
entityManagerFactory.createEntityManager(); Ⓐ
try {
entityManager.getTransaction().begin(); Ⓑ
List<Bid> bids = entityManager.createQuery( Ⓒ
"SELECT b FROM Bid b ORDER BY b.amount DESC", Bid.class) Ⓒ
.getResultList(); Ⓒ
Item item = entityManager.createQuery( Ⓓ
"SELECT i FROM Item i", Item.class) Ⓓ
.getSingleResult(); Ⓓ
User user = entityManager.createQuery( Ⓔ
"SELECT u FROM User u", User.class).getSingleResult(); Ⓔ
assertAll(() -> assertEquals(2, bids.size()), Ⓕ
() -> assertEquals(new BigDecimal(2000), Ⓕ
bids.get(0).getAmount()), Ⓕ
() -> assertEquals(new BigDecimal(1000), Ⓕ
bids.get(1).getAmount()), Ⓕ
() -> assertEquals("Item1", item.getName()), Ⓕ
() -> assertEquals("John", user.getFirstName()), Ⓕ
() -> assertEquals("Smith", user.getLastName()) Ⓕ
);
entityManager.getTransaction().commit(); Ⓖ
} finally {
entityManager.close(); Ⓗ
}
}
Ⓐ 在现有 EntityManagerFactory 的帮助下创建一个 EntityManager。
Ⓑ 开始一个交易;操作需要是事务性的。
Ⓒ 创建一个 JPQL 查询以按 amount 降序从数据库中获取所有 Bid。
Ⓓ 创建一个 JPQL 查询以从数据库中获取 Item。
Ⓔ 创建一个 JPQL 查询以从数据库中获取 User。
Ⓕ 检查通过 JPQL 获取的信息是否包含我们之前持久化的内容。
Ⓖ 提交之前开始的交易。
Ⓗ 关闭之前创建的 EntityManager。
如前所述,我们希望保持数据库干净,测试独立,因此我们将在 HibernateOGMTest 类中每个测试执行后清理插入的数据。我们的代码将使用 JPA 进行这些操作,它不知道它是在与关系型数据库还是非关系型数据库交互。
列表 18.11 每个测试执行后清理数据库
Path: Ch18/hibernate-ogm/src/test/java/com/manning/javapersistence
➥ /hibernateogm/HibernateOGMTest.java
@AfterEach
void afterEach() {
EntityManager entityManager = Ⓐ
entityManagerFactory.createEntityManager(); Ⓐ
try {
entityManager.getTransaction().begin(); Ⓑ
User fetchedUser = entityManager.find(User.class, john.getId()); Ⓒ
Item fetchedItem = entityManager.find(Item.class, item.getId()); Ⓒ
Bid fetchedBid1 = entityManager.find(Bid.class, bid1.getId()); Ⓒ
Bid fetchedBid2 = entityManager.find(Bid.class, bid2.getId()); Ⓒ
entityManager.remove(fetchedBid1); Ⓓ
entityManager.remove(fetchedBid2); Ⓓ
entityManager.remove(fetchedItem); Ⓓ
entityManager.remove(fetchedUser); Ⓓ
entityManager.getTransaction().commit(); Ⓔ
} finally {
entityManager.close(); Ⓕ
}
}
Ⓐ 在现有 EntityManagerFactory 的帮助下创建一个 EntityManager。
Ⓑ 开始一个交易;操作需要是事务性的。
Ⓒ 根据实体的 id 获取之前持久化的 User、Item 和 Bid。
Ⓓ 删除之前持久化的实体。
Ⓔ 提交之前开始的交易。
Ⓕ 关闭之前创建的 EntityManager。
18.3 切换到 Neo4j NoSQL 数据库
Neo4j 也是一个 NoSQL 数据库,具体来说是一个图型数据库。与使用类似 JSON 的文档来存储数据的 MongoDB 不同,Neo4j 使用图来存储数据。图由节点组成,节点保存数据,边表示关系。Neo4j 可以以桌面版本或嵌入式版本(我们将用于我们的演示)运行。有关 Neo4j 功能的全面指南,请参阅 Neo4j 网站:neo4j.com/。
Hibernate OGM 促进了在不同 NoSQL 数据库之间快速且高效地切换,即使它们在内部使用不同的范式来存储数据。目前,Hibernate OGM 支持 MongoDB,这是我们已演示如何与之交互的文档型数据库,以及 Neo4j,一个我们希望快速切换到的图型数据库。
Hibernate OGM 的效率在于我们仍然可以使用之前提供的 JPA 代码来定义实体并描述与数据库的交互。该代码保持不变。我们只需要在配置层面进行更改:我们需要将 Hibernate OGM MongoDB 依赖项替换为 Hibernate OGM Neo4j,并且需要将持久化单元配置从 MongoDB 更改为 Neo4j。
我们将更新 Maven pom.xml 文件,以包含 Hibernate OGM Neo4j 依赖项。
列表 18.12 包含 Hibernate OGM Neo4j 依赖项的 pom.xml 文件
Path: Ch18/hibernate-ogm/pom.xml
<dependency>
<groupId>org.hibernate.ogm</groupId>
<artifactId>hibernate-ogm-neo4j</artifactId>
</dependency>
我们还将替换 src/main/resources/META-INF/persistence.xml 中的持久化单元配置。
列表 18.13 Neo4j 的 persistence.xml 配置文件
Path: Ch18/hibernate-ogm/src/main/resources/META-INF/persistence.xml
<persistence-unit name="ch18.hibernate_ogm"> Ⓐ
<provider>org.hibernate.ogm.jpa.HibernateOgmPersistence</provider> Ⓑ
<properties>
<property name="hibernate.ogm.datastore.provider" Ⓒ
value="neo4j_embedded" /> Ⓒ
<property name="hibernate.ogm.datastore.database" Ⓓ
value="hibernate_ogm" /> Ⓓ
<property name="hibernate.ogm.neo4j.database_path" Ⓔ
value="target/test_data_dir" /> Ⓔ
</properties>
</persistence-unit>
Ⓐ persistence.xml 文件配置了 ch18.hibernate_ogm 持久化单元。
Ⓑ API 的供应商特定提供者实现是 Hibernate OGM。
Ⓒ 数据存储提供者是 Neo4j;数据库是嵌入式的。
Ⓓ 数据库的名称是 hibernate_ogm。
Ⓔ 数据库路径位于 test_data_dir,在 Maven 创建的目标文件夹中。
与 MongoDB 相同,应用程序的功能在 Neo4j 中也将保持不变。使用 Hibernate OGM,代码保持不变,JPA 可以访问不同类型的 NoSQL 数据库。更改仅限于配置层面。
摘要
-
您可以使用 MongoDB 和设置与数据库交互所需的 Maven 依赖项来创建一个简单的 Hibernate OGM 应用程序。
-
您可以使用 MongoDB 提供者和 MongoDB 数据库来配置持久化单元。
-
您可以创建仅使用 JPA 注释和功能来创建的实体,并将它们持久化到 MongoDB 数据库中,验证实体在 MongoDB 中的插入。
-
您可以通过仅更改 Maven 依赖项和持久化单元配置,从文档型 MongoDB 数据库切换到图型 Neo4j 数据库。
-
您可以在不修改现有代码的情况下,将之前创建的仅使用 JPA 注释的实体持久化到 Neo4j 数据库中。
第六部分. 编写查询和测试 Java 持久化应用程序
在第六部分,您将学习如何编写查询以及如何测试 Java 持久化应用程序。
在第十九章中,您将学习如何使用 Querydsl,这是使用 Java 程序查询数据库的替代方案之一。我们将考察其最重要的功能,并在 Java 持久化项目中应用它们。
接下来,第二十章将探讨如何测试 Java 持久化应用程序。我们将介绍测试金字塔,并在此背景下考察持久化测试。我们将使用 Spring TestContext 框架及其注解,与 Spring 配置文件一起工作,并使用测试执行监听器来测试 Java 持久化应用程序。
阅读本书的这一部分后,您将了解如何使用 Querydsl 编写查询,以及如何使用 Spring TestContext 框架测试持久化应用程序。
19 使用 Querydsl 查询 JPA
本章涵盖
-
介绍 Querydsl
-
创建 Querydsl 应用程序
-
使用 Querydsl 查询数据库
查询数据库对于检索符合特定标准的信息至关重要。本章重点介绍 Querydsl,这是从 Java 程序中查询数据库的替代方案之一。Querydsl 名称中的“dsl”部分指的是领域特定语言(DSLs),这些语言是针对特定应用领域的语言。例如,查询数据库就是这样一种领域。
在本章中,我们将检查 Querydsl 的最重要的功能,并将它们应用于一个 Java 持久化项目中。有关 Querydsl 的全面文档,请参阅其网站:querydsl.com/。
19.1 介绍 Querydsl
从 Java 程序内部查询数据库有多种替代方案。您可以使用 SQL,自从 JDBC 早期以来就可以这样做。这种方法的缺点是缺乏可移植性(查询依赖于数据库和特定的 SQL 方言)以及缺乏类型安全和静态查询验证。
JPQL(Jakarta Persistence Query Language)是一个进步,它是一个面向对象的查询语言,与数据库无关。这意味着没有可移植性的缺乏,但仍然缺乏类型安全和静态查询验证。
Spring Data 允许我们使用查询构建器机制创建方法,并使用 JPQL 和 SQL 查询注解方法(尽管这些仍然有之前提到的缺点)。查询构建器机制也有缺点,即需要预先定义方法,并且它们的名称在编译时不会进行静态检查。
Criteria API 允许您使用 Java API 构建类型安全和可移植的查询。虽然它解决了之前提出的替代方案的缺点,但它最终变得极其冗长,并创建了难以阅读的代码。
Querydsl 保留了类型安全和可移植性的重要思想。此外,它减少了 Criteria API 的冗长性,并且它创建的代码比使用 Criteria API 创建的代码更容易阅读和理解。
19.2 创建 Querydsl 应用程序
我们将首先创建一个由 Maven 管理依赖项的 Querydsl 应用程序。我们将检查涉及到的步骤,需要添加到项目中的依赖项,将要管理的实体,以及如何借助 Querydsl 编写查询。
注意 要执行源代码中的示例,您首先需要运行 Ch19.sql 脚本。
19.2.1 配置 Querydsl 应用程序
我们将在 Maven pom.xml 文件中添加两个依赖项:querydsl-jpa 和 querydsl-apt。querydsl-jpa 依赖项是需要在 JPA 应用程序中使用 Querydsl API 所必需的。querydsl-apt 依赖项是需要在代码编译之前处理 Java 文件中的注解所必需的。
querydsl-apt 中的 APT 代表注解处理工具,使用它,应用程序管理的实体将在所谓的 Q 类型(Q 代表“查询”)中进行复制。这意味着每个 Entity 实体将有一个相应的 QEntity,它将在构建时生成,Querydsl 将使用它来查询数据库。此外,实体的每个字段都将使用特定的 Querydsl 类在 QEntity 中进行镜像。例如,String 字段将镜像到 StringPath 字段,Long 字段到 NumberPath<Long> 字段,Integer 字段到 NumberPath<Integer> 字段,依此类推。
生成的 Maven pom.xml 文件将包含以下列表中显示的依赖项。
列表 19.1 包含 APT 插件的 pom.xml Maven 文件
Path: Ch19/querydsl/pom.xml
\1
<groupId>com.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
<version>5.0.0</version>
</dependency>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<version>5.0.0</version>
<scope>provided</scope>
</dependency>
querydsl-apt 依赖项的作用域指定为 provided。这意味着依赖项仅在构建时需要,当 Maven 生成之前引入的 Q 类型时。然后它就不再需要了,因此它不会包含在应用程序工件中。
要使用 Querydsl,我们还需要在 Maven pom.xml 文件中包含 Maven APT 插件。此插件将在构建过程中负责生成 Q 类型。由于我们在项目中使用 JPA 注解,实际执行此操作的类是 com.querydsl.apt.jpa.JPAAnnotationProcessor。如果我们使用 Hibernate API 和注解,则必须使用 com.querydsl.apt.hibernate.HibernateAnnotationProcessor。
我们还必须指出生成的 Q 类型将驻留的输出目录:在 target Maven 文件夹内。包含所有这些内容的 pom.xml 文件如下所示。
列表 19.2 包含 APT 插件的 pom.xml Maven 文件
Path: Ch19/querydsl/pom.xml
<plugin>
<groupId>com.mysema.maven</groupId>
<artifactId>apt-maven-plugin</artifactId>
<version>1.1.3</version>
<executions>
<execution>
<goals>
<goal>process</goal>
</goals>
<configuration>
<outputDirectory>target/generated-sources/java</outputDirectory> Ⓐ
<processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor> Ⓑ
</configuration>
</execution>
</executions>
</plugin>
Ⓐ 生成的 Q 类型将位于 target/generated-sources/java 文件夹中。
Ⓑ 使用 com.querydsl.apt.jpa.JPAAnnotationProcessor 类生成 Q 类型。
我们现在将转到持久化单元的标准配置文件,位于 src/main/resources/META-INF/persistence.xml。此文件如下所示。
列表 19.3 persistence.xml 配置文件
Path: Ch19/querydsl/src/main/resources/META-INF/persistence.xml
<persistence-unit name="ch19.querydsl"> Ⓐ
<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider> Ⓑ
<properties>
<property name="javax.persistence.jdbc.driver" Ⓒ
value="com.mysql.cj.jdbc.Driver"/> Ⓒ
<property name="javax.persistence.jdbc.url" value="jdbc:mysql:// Ⓓ
➥ localhost:3306/CH19_QUERYDSL?serverTimezone=UTC"/> Ⓓ
<property name="javax.persistence.jdbc.user" value="root"/> Ⓔ
<property name="javax.persistence.jdbc.password" value=""/> Ⓕ
<property name="hibernate.dialect" Ⓖ
value="org.hibernate.dialect.MySQL8Dialect"/> Ⓖ
<property name="hibernate.show_sql" value="true"/> Ⓗ
<property name="hibernate.format_sql" value="true"/> Ⓘ
<property name="hibernate.hbm2ddl.auto" value="create"/> Ⓙ
</properties>
</persistence-unit>
Ⓐ persistence.xml 文件配置了 ch19.querydsl 持久化单元。
Ⓑ 由于 JPA 只是一个规范,我们需要指出 API 的供应商特定 PersistenceProvider 实现。我们定义的持久化将由 Hibernate 提供商支持。
Ⓒ JDBC 属性——驱动程序。
Ⓓ 数据库的 URL。
Ⓔ 用户名。
Ⓕ 访问无密码。我们运行的程序所在的机器上已安装 MySQL 8,访问凭证来自 persistence.xml。您应修改凭证以与您机器上的凭证相匹配。
Ⓖ Hibernate 方言是 MySQL8,因为我们将要交互的数据库是 MySQL Release 8.0。
Ⓗ 执行时显示 SQL 代码。
Ⓘ Hibernate 将格式化 SQL 并在 SQL 字符串中生成注释,以便我们知道 Hibernate 执行 SQL 语句的原因。
Ⓙ 每次程序执行时,数据库将从零开始创建。这对于自动化测试来说很理想,因为我们希望在每次测试运行时都使用一个干净的数据库。
19.2.2 创建实体
我们现在将创建代表应用程序实体的类:User、Bid和Address。它们之间的关系将是单对多、多对一或内嵌类型。
列表 19.4 User类
Path: Ch19/querydsl/src/main/java/com/manning/javapersistence/querydsl
➥ /model/User.java
@Entity
@NoArgsConstructor
public class User {
@Id Ⓐ
@GeneratedValue(generator = Constants.ID_GENERATOR) Ⓐ
@Getter
private Long id;
@Embedded Ⓑ
@Getter
@Setter
private Address address; Ⓑ
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL) Ⓒ
private Set<Bid> bids = new HashSet<>(); Ⓒ
// . . .
}
Ⓐ ID 字段是由Constants.ID_GENERATOR生成器生成的标识符。关于生成器的复习,请回顾第五章。
Ⓑ 地址没有自己的标识符;它是可嵌入的。
Ⓒ User和Bid之间存在一对一的关系,这由Bid侧的user字段映射。CascadeType.ALL表示所有操作都将从父User传播到子Bid。
Address类没有自己的持久化标识符,它将是可嵌入的。
列表 19.5 Address类
Path: Ch19/querydsl/src/main/java/com/manning/javapersistence/querydsl
➥ /model/User.java
@Embeddable
@NoArgsConstructor
public class Address {
//fields with Lombok annotations, constructor
}
Bid类将包含一个具有类似生成策略的id字段,就像User一样。Bid和User之间的关系将是多对一,非可选的,并且抓取类型将是懒加载。
列表 19.6 Bid类
Path: Ch19/querydsl/src/main/java/com/manning/javapersistence/querydsl
➥ /model/Bid.java
@Entity
@NoArgsConstructor
public class Bid {
@Id
@GeneratedValue(generator = Constants.ID_GENERATOR)
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@Getter
@Setter
private User user;
// . . .
}
UserRepository接口扩展了JpaRepository<User, Long>。它管理User实体,并具有Long类型的 ID。我们只使用这个 Spring Data JPA 接口来方便地填充数据库以测试 Querydsl。
列表 19.7 UserRepository接口
Path: Ch19/querydsl/src/main/java/com/manning/javapersistence/querydsl
➥ /repositories/UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
}
19.2.3 创建查询所需的数据
为了填充和操作数据库,我们需要一个SpringDataConfiguration类和一个GenerateUsers类。我们已经多次使用这种方法,所以在这里我们只简要回顾这些类的功能。
列表 19.8 SpringDataConfiguration类
Path: Ch19/querydsl/src/test/java/com/manning/javapersistence/querydsl
➥ /configuration/SpringDataConfiguration.java
@EnableJpaRepositories("com.manning.javapersistence.querydsl.repositories") Ⓐ
public class SpringDataConfiguration {
@Bean
public DataSource dataSource() { Ⓑ
// . . . Ⓑ
return dataSource; Ⓑ
}
@Bean
public JpaTransactionManager Ⓒ
transactionManager(EntityManagerFactory emf) { Ⓒ
return new JpaTransactionManager(emf); Ⓒ
}
@Bean
public JpaVendorAdapter jpaVendorAdapter() { Ⓓ
HibernateJpaVendorAdapter jpaVendorAdapter = new Ⓓ
HibernateJpaVendorAdapter(); Ⓓ
// . . . Ⓓ
return jpaVendorAdapter; Ⓓ
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory() { Ⓔ
LocalContainerEntityManagerFactoryBean Ⓔ
localContainerEntityManagerFactoryBean = Ⓔ
new LocalContainerEntityManagerFactoryBean(); Ⓔ
// . . . Ⓔ
return localContainerEntityManagerFactoryBean; Ⓔ
}
}
Ⓐ @EnableJpaRepositories注解将扫描被注解配置类的包以查找 Spring Data 仓库。
Ⓑ 创建一个数据源 bean 来保存 JDBC 属性:驱动程序、数据库 URL、用户名和密码。
Ⓒ 基于实体管理器工厂创建一个事务管理器 bean。每次与数据库的交互都应在事务边界内进行,Spring Data 需要一个事务管理器 bean。
Ⓓ 创建并配置一个 JPA 供应商适配器 bean,这是 JPA 与 Hibernate 交互所需的。
Ⓔ 创建并配置一个LocalContainerEntityManagerFactoryBean——这是一个工厂 bean,它产生一个EntityManagerFactory。
GenerateUsers类包含generateUsers方法,该方法创建用户及其相关出价列表。
列表 19.9 GenerateUsers类
Path: Ch19/querydsl/src/test/java/com/manning/javapersistence/querydsl
➥ /GenerateUsers.java
public class GenerateUsers {
public static Address address = new Address("Flowers Street",
"1234567", "Boston", "MA");
public static List<User> generateUsers() {
List<User> users = new ArrayList<>();
User john = new User("john", "John", "Smith");
john.setRegistrationDate(LocalDate.of(2020, Month.APRIL, 13));
john.setEmail("john@somedomain.com");
john.setLevel(1);
john.setActive(true);
john.setAddress(address);
Bid bid1 = new Bid(new BigDecimal(100));
bid1.setUser(john);
john.addBid(bid1);
Bid bid2 = new Bid(new BigDecimal(110));
bid2.setUser(john);
john.addBid(bid2);
// . . .
}
}
19.3 使用 Querydsl 查询数据库
如前所述,Maven APT 插件将在构建过程中生成 Q 类型。根据提供的配置(参见列表 19.2),这些源将在target/generated-sources/java文件夹中生成(参见图 19.1)。我们将使用这些生成的类来查询数据库。

图 19.1 在target文件夹中生成的 Q 类型
首先,我们必须填充数据库,为此,我们将使用User-Repository接口。我们还将使用EntityManagerFactory和创建的EntityManager来开始使用JPAQueryFactory和JPAQuery。我们需要一个JPAQueryFactory实例来处理查询,它将通过接受一个EntityManager参数的构造函数创建。然后,JPAQueryFactory将创建JPAQuery实例,以有效地查询数据库。
我们将使用SpringExtension扩展测试。这个扩展用于将 Spring 测试上下文与 JUnit 5 Jupiter 测试集成。
在执行测试之前,我们将使用之前生成的用户及其相应的出价填充数据库。在每次测试之前,我们将创建一个EntityManager并开始一个事务。因此,与数据库的每次交互都将发生在事务边界内。目前,我们不会在这个类内部执行查询,但随着测试和查询将立即添加(从列表 19.11 开始),我们将我们的类命名为QuerydslTest。
列表 19.10 QuerydslTest类
Path: Ch19/querydsl/src/test/java/com/manning/javapersistence/querydsl
➥ /QuerydslTest.java
@ExtendWith(SpringExtension.class) Ⓐ
@TestInstance(TestInstance.Lifecycle.PER_CLASS) Ⓑ
@ContextConfiguration(classes = {SpringDataConfiguration.class}) Ⓒ
class QuerydslTest {
@Autowired Ⓓ
private UserRepository userRepository; Ⓓ
private static EntityManagerFactory entityManagerFactory = Ⓔ
Persistence.createEntityManagerFactory("ch19.querydsl"); Ⓔ
private EntityManager entityManager; Ⓕ
private JPAQueryFactory queryFactory; Ⓕ
@BeforeAll Ⓖ
void beforeAll() { Ⓖ
userRepository.saveAll(generateUsers()); Ⓖ
} Ⓖ
@BeforeEach Ⓗ
void beforeEach() { Ⓗ
entityManager = entityManagerFactory.createEntityManager(); Ⓗ
entityManager.getTransaction().begin(); Ⓗ
queryFactory = new JPAQueryFactory(entityManager); Ⓗ
} Ⓗ
@AfterEach Ⓘ
void afterEach() { Ⓘ
entityManager.getTransaction().commit(); Ⓘ
entityManager.close(); Ⓘ
} Ⓘ
@AfterAll Ⓙ
void afterAll() { Ⓙ
userRepository.deleteAll(); Ⓙ
} Ⓙ
}
Ⓐ 使用SpringExtension扩展测试。
Ⓑ JUnit 将为执行所有测试创建测试类的唯一实例,而不是每个测试一个实例。这样我们就能将UserRepository字段自动装配为实例变量。
Ⓒ Spring 测试上下文是通过之前展示的SpringDataConfiguration类中定义的 bean 进行配置的。
Ⓓ 通过 Spring 的自动装配注入一个UserRepository bean。它将被用来轻松地填充和清理数据库。
Ⓔ 初始化一个EntityManagerFactory以与数据库通信。这个EntityManagerFactory将创建JPAQueryFactory所需的EntityManager。
Ⓕ 声明应用程序所需的EntityManager和JPAQueryFactory。
Ⓖ 使用之前生成的用户和出价填充数据库,供测试使用。
Ⓗ 通过将EntityManager作为参数传递给其构造函数来创建一个JPAQueryFactory。
Ⓘ 在每个测试结束时,提交事务并关闭EntityManager。
Ⓙ 在所有测试执行结束时,清理数据库。
19.3.1 数据过滤
如前所述,应用程序管理的实体将在所谓的 Q 类型中进行复制。这意味着每个Entity实体将有一个相应的QEntity,它将在构建时生成,Querydsl 将使用它来查询数据库。由 Maven APT 插件生成的 Q 类型类每个都包含其类型的静态实例:
public static final QUser user = new QUser("user");
public static final QBid bid = new QBid("bid");
public static final QAddress address = new QAddress("address");
这些实例将被用来查询数据库。我们首先通过调用 queryFactory.selectFrom(user) 获取一个 JPAQuery 实例。然后我们将使用这个 JPAQuery 实例来构建查询的子句。我们将使用 where 方法通过给定的 Predicate 进行过滤,并使用 fetchOne 方法从数据库中获取单个元素。如果找不到满足条件的元素,fetchOne 返回 null,如果找到多个满足条件的元素,则抛出 NonUniqueResultException。
例如,为了获取具有给定 username 的 User,我们将编写以下列表中的代码。
列表 19.11 通过 username 查找 User
Path: Ch19/querydsl/src/test/java/com/manning/javapersistence/querydsl
➥ /QuerydslTest.java
@Test
void testFindByUsername() {
User fetchedUser = queryFactory.selectFrom(QUser.user) Ⓐ
.where(QUser.user.username.eq("john")) Ⓑ
.fetchOne(); Ⓒ
assertAll( Ⓓ
() -> assertNotNull(fetchedUser), Ⓓ
() -> assertEquals("john", fetchedUser.getUsername()), Ⓓ
() -> assertEquals("John", fetchedUser.getFirstName()), Ⓓ
() -> assertEquals("Smith", fetchedUser.getLastName()), Ⓓ
() -> assertEquals(2, fetchedUser.getBids().size()) Ⓓ
); Ⓓ
}
Ⓐ 使用属于 JPAQueryFactory 类的 selectFrom 方法开始构建查询。此方法将获取创建的 Q 类型实例 QUser.user 作为参数,并将返回一个 JPAQuery。
Ⓑ where 方法将根据给定的与 username 相关的 Predicate 进行过滤。
Ⓒ fetchOne 方法将尝试从数据库中获取单个元素。
Ⓓ 验证获取的数据是预期的数据。
以下 SQL 查询由 Hibernate 生成:
select
*
from
User user0_
where
user0_.username=?
select
*
from
Bid bids0_
where
bids0_.user_id=?
我们可以使用 and 或 or 等方法通过多个 Predicate 进行过滤,每个方法都接收一个 Predicate。例如,为了过滤 level 和 active 字段,我们可以编写以下代码:
List<User> users = (List<User>)queryFactory.from(QUser.user)
.where(QUser.user.level.eq(3)
.and(QUser.user.active.eq(true))).fetch();
以下 SQL 查询由 Hibernate 生成:
select
*
from
User user0_
where
user0_.level=?
and user0_.active=?
19.3.2 排序数据
为了排序数据,我们将使用 orderBy 方法,它可以接收多个表示排序标准的参数。例如,为了按 username 排序 User 实例,我们将编写以下代码。
列表 19.12 按 username 排序 User 实例
Path: Ch19/querydsl/src/test/java/com/manning/javapersistence/querydsl
➥ /QuerydslTest.java
@Test
void testOrderByUsername() {
List<User> users = queryFactory.selectFrom(QUser.user) Ⓐ
.orderBy(QUser.user.username.asc()) Ⓑ
.fetch(); Ⓒ
assertAll( Ⓓ
() -> assertEquals(users.size(), 10), Ⓓ
() -> assertEquals("beth", users.get(0).getUsername()), Ⓓ
() -> assertEquals("burk", users.get(1).getUsername()), Ⓓ
() -> assertEquals("mike", users.get(8).getUsername()), Ⓓ
() -> assertEquals("stephanie", users.get(9).getUsername()) Ⓓ
); Ⓓ
}
Ⓐ 使用属于 JPAQueryFactory 类的 selectFrom 方法开始构建查询。此方法将获取创建的 Q 类型实例 QUser.user 作为参数,并将返回一个 JPAQuery。
Ⓑ 按 username 顺序排序结果。orderBy 方法是重载的,可以接收多个排序标准。
Ⓒ fetch 方法将获取 User 实例的列表。
Ⓓ 验证获取的数据是预期的数据。
以下 SQL 查询由 Hibernate 生成:
select
*
from
User user0_
order by
user0_.username asc
19.3.3 分组数据和聚合操作
为了分组数据,我们将使用 groupBy 方法,它接收用于分组的表达式。此类查询将返回一个 List<Tuple>。com.querydsl.core.Tuple 对象是一个包含用于分组的键及其对应值的键/值对。例如,以下代码按 amount 对 Bid 实例进行计数。
列表 19.13 按金额分组投标
Path: Ch19/querydsl/src/test/java/com/manning/javapersistence/querydsl
➥ /QuerydslTest.java
@Test
void testGroupByBidAmount() {
NumberPath<Long> count = Expressions.numberPath(Long.class, "bids"); Ⓐ
List<Tuple> userBidsGroupByAmount = Ⓑ
queryFactory.select(QBid.bid.amount, Ⓒ
QBid.bid.id.count().as(count)) Ⓒ
.from(QBid.bid) Ⓒ
.groupBy(QBid.bid.amount) Ⓓ
.orderBy(count.desc()) Ⓔ
.fetch(); Ⓕ
assertAll( Ⓖ
() -> assertEquals(new BigDecimal("120.00"), Ⓖ
userBidsGroupByAmount.get(0).get(QBid.bid.amount)), Ⓖ
() -> assertEquals(2, userBidsGroupByAmount.get(0).get(count)) Ⓖ
); Ⓖ
}
Ⓐ 保持 count() 表达式,因为我们稍后构建查询时需要多次使用它。
Ⓑ 返回一个 List<Tuple>,包含用于分组的键/值对及其对应的值。
Ⓒ 从 Bid 中选择 amount 和相同 amount 的计数。
Ⓓ 按 amount 值进行分组。
Ⓔ 按相同金额的记录数进行排序。
Ⓕ 获取 List<Tuple> 对象。
Ⓖ 验证检索到的数据是否符合预期。
以下 SQL 查询由 Hibernate 生成:
select
bid0_.amount as col_0_0_,
count(bid0_.id) as col_1_0_
from
Bid bid0_
group by
bid0_.amount
order by
col_1_0_ desc
要使用聚合操作并获取Bid的最大值、最小值和平均值,我们可以使用max、min和avg方法,如下面的代码所示:
queryFactory.from(QBid.bid).select(QBid.bid.amount.max()).fetchOne();
queryFactory.from(QBid.bid).select(QBid.bid.amount.min()).fetchOne();
queryFactory.from(QBid.bid).select(QBid.bid.amount.avg()).fetchOne();
以下 SQL 查询由 Hibernate 生成:
select
max(bid0_.amount) as col_0_0_
from
Bid bid0_
select
min(bid0_.amount) as col_0_0_
from
Bid bid0_
select
avg(bid0_.amount) as col_0_0_
from
Bid bid0_
19.3.4 使用子查询和连接
要使用子查询,我们将使用JPAExpressions静态工厂方法(如select)创建子查询,并使用from和where等方法定义查询参数。我们将把子查询传递给主查询的where方法。例如,以下列表选择具有给定amount的Bid的User。
列表 19.14 使用子查询
Path: Ch19/querydsl/src/test/java/com/manning/javapersistence/querydsl
➥ /QuerydslTest.java
@Test
void testSubquery() {
List<User> users = queryFactory.selectFrom(QUser.user) Ⓐ
.where(QUser.user.id.in( Ⓑ
JPAExpressions.select(QBid.bid.user.id) Ⓒ
.from(QBid.bid) Ⓒ
.where(QBid.bid.amount.eq( Ⓒ
new BigDecimal("120.00"))))) Ⓒ
.fetch(); Ⓓ
List<User> otherUsers = queryFactory.selectFrom(QUser.user) Ⓔ
.where(QUser.user.id.in( Ⓔ
JPAExpressions.select(QBid.bid.user.id) Ⓔ
.from(QBid.bid) Ⓔ
.where(QBid.bid.amount.eq( Ⓔ
new BigDecimal("105.00"))))) Ⓔ
.fetch(); Ⓔ
assertAll( Ⓕ
() -> assertEquals(2, users.size()), Ⓕ
() -> assertEquals(1, otherUsers.size()), Ⓕ
() -> assertEquals("burk", otherUsers.get(0).getUsername()) Ⓕ
); Ⓕ
}
Ⓐ 使用属于JPAQueryFactory类的selectFrom方法开始构建查询。
Ⓑ 将子查询作为where方法的参数传递。
Ⓒ 创建子查询以获取amount为 120.00 的Bid。
Ⓓ 检索结果。
Ⓔ 为amount为 105.00 的Bid创建类似的查询和子查询。
Ⓕ 验证检索到的数据是否符合预期。
以下 SQL 查询由 Hibernate 生成:
select
*
from
User user0_
where
user0_.id in (
select
bid1_.user_id
from
Bid bid1_
where
bid1_.amount=?
)
要使用连接,我们将使用innerJoin、leftJoin和outerJoin方法来定义连接,并使用on方法来声明连接的条件(一个Predicate)。
列表 19.15 使用连接
Path: Ch19/querydsl/src/test/java/com/manning/javapersistence/querydsl
➥ /QuerydslTest.java
@Test
void testJoin() {
List<User> users = queryFactory.selectFrom(QUser.user) Ⓐ
.innerJoin(QUser.user.bids, QBid.bid) Ⓑ
.on(QBid.bid.amount.eq(new BigDecimal("120.00"))) Ⓒ
.fetch(); Ⓓ
List<User> otherUsers = queryFactory.selectFrom(QUser.user) Ⓔ
.innerJoin(QUser.user.bids, QBid.bid) Ⓔ
.on(QBid.bid.amount.eq(new BigDecimal("105.00"))) Ⓔ
.fetch(); Ⓔ
assertAll( Ⓕ
() -> assertEquals(2, users.size()), Ⓕ
() -> assertEquals(1, otherUsers.size()), Ⓕ
() -> assertEquals("burk", otherUsers.get(0).getUsername()) Ⓕ
); Ⓕ
}
Ⓐ 使用属于JPAQueryFactory类的selectFrom方法开始构建查询。
Ⓑ 将内部连接到Bid。
Ⓒ 将连接条件定义为Predicate以使Bid的amount为 120.00。
Ⓓ 检索结果。
Ⓔ 为amount为 105.00 的Bid创建类似的连接。
Ⓕ 验证检索到的数据是否符合预期。
以下 SQL 查询由 Hibernate 生成:
select
*
from
User user0_
inner join
Bid bids1_
on user0_.id=bids1_.user_id
and (
bids1_.amount=?
)
19.3.5 更新实体
要更新实体,我们将使用JPAQueryFactory类的update方法,使用where方法定义将过滤要更新的实体的Predicate(可选),使用set方法定义要进行的更改,并使用execute方法有效地执行更新。
列表 19.16 更新信息
Path: Ch19/querydsl/src/test/java/com/manning/javapersistence/querydsl
➥ /QuerydslTest.java
@Test
void testUpdate() {
queryFactory.update(QUser.user) Ⓐ
.where(QUser.user.username.eq("john")) Ⓑ
.set(QUser.user.email, "john@someotherdomain.com") Ⓒ
.execute(); Ⓓ
entityManager.getTransaction().commit(); Ⓔ
entityManager.getTransaction().begin(); Ⓕ
assertEquals("john@someotherdomain.com", Ⓖ
queryFactory.select(QUser.user.email) Ⓖ
.from(QUser.user) Ⓖ
.where(QUser.user.username.eq("john")) Ⓖ
.fetchOne()); Ⓖ
}
Ⓐ 使用属于JPAQueryFactory类的update方法开始构建查询。
Ⓑ 定义更新时的where条件(可选)。
Ⓒ 使用set方法定义对实体进行的更改。
Ⓓ 有效地执行更新操作。
Ⓔ 在@BeforeEach注解的方法中提交开始的交易。
Ⓕ 在@AfterEach注解的方法中提交新的交易。
Ⓖ 通过检索修改后的实体来检查更新结果。
以下 SQL 查询由 Hibernate 生成:
update
User
set
email=?
where
username=?
19.3.6 删除实体
要删除实体,我们将使用JPAQueryFactory类的update方法,使用where方法定义将过滤要删除的实体的Predicate(可选),并使用execute方法有效地执行删除。
在这里,Querydsl 存在一个大问题。在 Querydsl 参考文档(querydsl.com/static/querydsl/latest/reference/html/)中,关于使用 JPA 的 DELETE 查询的第 2.1.11 节指出,“JPA 中的 DML 子句不考虑 JPA 级级联规则,并且不提供细粒度的二级缓存交互。” 因此,User 类中 @OneToMany 注解的级联属性被忽略;在通过 Querydsl 删除查询删除之前,必须先选择用户并手动删除他们的出价。
作为额外证据,如果用户要通过 userRepository.delete(burk); 指令被删除,@OneToMany 级联属性将被适当考虑,并且不需要手动处理用户的出价。
列表 19.17 删除信息
Path: Ch19/querydsl/src/test/java/com/manning/javapersistence/querydsl
➥ /QuerydslTest.java
@Test
void testDelete() {
User burk = (User) queryFactory.from(QUser.user) Ⓐ
.where(QUser.user.username.eq("burk")) Ⓐ
.fetchOne(); Ⓐ
if (burk != null) { Ⓑ
queryFactory.delete(QBid.bid) Ⓑ
.where(QBid.bid.user.eq(burk)) Ⓑ
.execute(); Ⓑ
} Ⓑ
queryFactory.delete(QUser.user) Ⓒ
.where(QUser.user.username.eq("burk")) Ⓓ
.execute(); Ⓔ
entityManager.getTransaction().commit(); Ⓕ
entityManager.getTransaction().begin(); Ⓖ
assertNull(queryFactory.selectFrom(QUser.user) Ⓗ
.where(QUser.user.username.eq("burk")) Ⓗ
.fetchOne()); Ⓗ
}
Ⓐ 查找用户 burk。
Ⓑ 删除属于之前找到用户的出价。
Ⓒ 使用属于 JPAQueryFactory 类的 delete 方法开始构建查询。
Ⓓ 定义要删除的 where 条件(可选)。
Ⓔ 有效地执行删除操作。
Ⓕ 在 @BeforeEach 注解的方法中提交开始的事务。
Ⓖ 在 @AfterEach 注解的方法中提交一个新的事务。这就是为什么在这个列表中,commit 事务出现在 begin 事务之前。
Ⓗ 通过尝试获取实体来检查删除的结果。如果实体不再存在,fetchOne 方法将返回 null。
以下 SQL 查询由 Hibernate 生成:
delete
from
User
where
username=?
如果 User 有需要先删除的子 Bid,则此查询不足。这揭示了 MySQL 的另一个特定问题:当 Hibernate 创建模式时,在 bid 表的 user_id 列上定义外键约束时,没有添加 ON DELETE CASCADE 子句。否则,无论 Querydsl 是否忽略 @OneToMany 级联属性,此单个 DELETE 查询都将是足够的。
Querydsl API 不提供插入功能。对于插入实体,您可以使用 EntityManager(JPA)、Session(Hibernate)或存储库(Spring Data JPA)。
摘要
-
SQL、JPQL、Criteria API、Spring Data 等查询替代方案有缺点:缺乏可移植性、缺乏类型安全和静态查询验证,以及冗长。Querydsl 解决了类型安全和可移植性的重要思想,并减少了冗长。
-
您可以创建一个持久化应用程序来使用 Querydsl,定义其配置和实体,并持久化和查询数据。
-
要使用 Querydsl,需要将其依赖项添加到应用程序中。Maven APT(注解处理工具)在构建时用于创建 Q 类型,这些类型复制了实体。
-
您可以使用核心 Querydsl 类
JPAQueryFactory和JPAQuery来构建查询,用于检索、更新和删除数据。 -
您可以创建查询以筛选、排序和分组数据,以及执行连接、更新和删除操作。
20 测试 Java 持久化应用程序
本章涵盖
-
介绍测试金字塔并检查其上下文中的持久化测试
-
创建用于测试的持久化应用程序
-
使用 Spring TestContext 框架
-
使用 Spring 配置文件测试 Java 持久化应用程序
-
使用执行监听器测试 Java 持久化应用程序
所有代码都需要进行测试。在开发过程中,我们编写代码、编译和运行。当我们运行时,我们有时会测试代码的工作方式。测试持久化应用程序涉及的内容不止这些。在这些情况下,我们的代码与外部数据库交互,我们的程序的工作方式可能依赖于它。
20.1 介绍测试金字塔
在前面的章节中,我们专注于开发与数据库交互的代码。我们探讨了不同的替代方案,不同的框架,并与各种数据库进行了交互。现在我们需要确保我们的程序是安全和没有错误的。我们需要能够在不创建错误的情况下引入更改,在不影响旧功能的情况下添加新功能,以及在不破坏现有功能的情况下重构代码。这就是本章的目的。
应用程序可以手动测试,但如今大多数测试都是自动执行的,并且针对不同的级别。单体应用程序的软件测试的不同级别可以被视为一个金字塔,如图 20.1 所示。我们可以定义以下软件测试级别(从低到高):
-
单元测试—单元测试是金字塔的基础。它通过在隔离状态下测试每个单元,以确定它是否按预期工作,来关注方法或类(单个单元)。
-
集成测试—将单独验证的软件组件组合成更大的聚合体并一起测试。
-
系统测试—在完整的系统上执行测试,以评估其是否符合规范。系统测试不需要了解设计或代码,但关注整个系统的功能。
-
验收测试—验收测试使用场景和测试用例来检查应用程序是否满足最终用户的期望。

图 20.1 测试金字塔的底部级别(单元测试)较大,而较高的测试级别较小。它们从检查单个单元开始,一直上升到验证软件如何满足用户需求。
测试持久化应用程序属于集成层。我们将代码与数据库交互结合起来,并且依赖于数据库的工作方式。我们希望保持测试行为在重复执行测试之间的一致性,并保持数据库内容与运行测试之前相同。在接下来的章节中,我们将探讨实现这些目标的最佳方法。
20.2 创建用于测试的持久化应用程序
我们将创建一个 Spring Boot 持久化应用程序,以便我们可以测试其功能。为此,请访问 Spring Initializr 网站(start.spring.io/)并创建一个新的 Spring Boot 项目(图 20.2),具有以下特性:
-
组:com.manning.javapersistence
-
工件:测试
-
描述:持久化测试
我们还将添加以下依赖项:
-
Spring Data JPA(这将在 Maven pom.xml 文件中添加
spring-boot-starter-data-jpa)。 -
MySQL 驱动程序(这将在 Maven pom.xml 文件中添加
mysql-connector-java)。 -
Lombok(这将在 Maven pom.xml 文件中添加
org.projectlombok,lombok)。 -
使用 Hibernate validator 进行 Bean 验证(这将在 Maven pom.xml 文件中添加
spring-boot-starter-validation)。 -
spring-boot-starter-test依赖项将被自动添加。

图 20.2 创建一个将使用 MySQL 数据库的 Spring Boot 项目
注意:要执行源代码中的示例,您首先需要运行 Ch20.sql 脚本。
下面的列表中的 pom.xml 文件包括我们在创建 Spring Boot 项目时添加的依赖项。我们将创建一个访问 MySQL 数据库的 Spring 持久化应用程序,我们需要驱动程序,我们还需要验证一些字段。
列表 20.1 pom.xml Maven 文件
Path: Ch20/1 spring testing/pom.xml
\1 Ⓐ
<groupId>org.springframework.boot</groupId> Ⓐ
<artifactId>spring-boot-starter-data-jpa</artifactId> Ⓐ
</dependency> Ⓐ
<dependency> Ⓑ
<groupId>org.springframework.boot</groupId> Ⓑ
<artifactId>spring-boot-starter-validation</artifactId> Ⓑ
</dependency> Ⓑ
<dependency> Ⓒ
<groupId>mysql</groupId> Ⓒ
<artifactId>mysql-connector-java</artifactId> Ⓒ
<scope>runtime</scope> Ⓒ
</dependency> Ⓒ
<dependency> Ⓓ
<groupId>org.projectlombok</groupId> Ⓓ
<artifactId>lombok</artifactId> Ⓓ
<optional>true</optional> Ⓓ
</dependency> Ⓓ
<dependency> Ⓔ
<groupId>org.springframework.boot</groupId> Ⓔ
<artifactId>spring-boot-starter-test</artifactId> Ⓔ
<scope>test</scope> Ⓔ
</dependency> Ⓔ
Ⓐ spring-boot-starter-data-jpa是 Spring Boot 用于通过 Spring Data JPA 连接到关系型数据库的启动依赖项。
Ⓑ spring-boot-starter-validation是用于使用 Hibernate Validator 进行 Java Bean 验证的启动依赖项。
Ⓒ mysql-connector-java是 MySQL 的 JDBC 驱动程序。它是一个运行时依赖项,因此仅在运行时需要在类路径中。
Ⓓ Lombok将允许我们减少样板代码,依靠自动生成的构造函数、获取器和设置器。
Ⓔ spring-boot-starter-test是用于测试 Spring Boot 应用程序的启动依赖项,包括 JUnit 5 库。
下一步是填写 Spring Boot 应用程序的 application.properties 文件。此文件可以包含应用程序将使用的各种属性。Spring Boot 将自动从类路径中查找并加载 application.properties;Maven 将 src/main/resources 文件夹添加到类路径中。对于本章的演示,application.properties 配置文件将类似于列表 20.2。
列表 20.2 application.properties 文件
Path: Ch20/1 spring testing/src/main/resources/application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/
➥ CH20_TESTING?serverTimezone=UTC Ⓐ
spring.datasource.username=root Ⓑ
spring.datasource.password= Ⓑ
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect Ⓒ
spring.jpa.show-sql=true Ⓓ
spring.jpa.hibernate.ddl-auto=create Ⓔ
Ⓐ 数据库的 URL。
Ⓑ 访问数据库的凭据。用您机器上的凭据替换它们,并在实际生活中使用密码。
Ⓒ 数据库的方言,MySQL。
Ⓓ 在执行时显示 SQL 查询。
Ⓔ 在应用程序的每次执行中重新创建表。
注意:在 Spring Boot 应用程序中提供参数的方法有很多种,.properties 文件只是其中之一。在替代方案中,参数可能来自源代码或作为命令行参数。有关详细信息,请参阅 Spring Boot 文档。
我们将要测试的应用程序将包括两个实体,User和Log。以下列表示例显示了User实体。它将有一个生成的id和一个带有长度验证的name字段。无参数构造函数、获取器和设置器将由 Lombok 生成。
列表 20.3 User类
Path: Ch20/1 spring testing/src/main/java/com/manning/javapersistence
➥ /testing/model/User.java
@Entity
@NoArgsConstructor
public class User {
@Id
@GeneratedValue
@Getter
private Long id;
@NotNull
@Size(
min = 2,
max = 255,
message = "Name is required, maximum 255 characters."
)
@Getter
@Setter
private String name;
public User(String name) {
this.name = name;
}
}
以下列表示例显示了Log实体。它将有一个生成的id和一个带有长度验证的info字段。无参数构造函数、获取器和设置器将由 Lombok 生成。
列表 20.4 Log类
Path: Ch20/1 spring testing/src/main/java/com/manning/javapersistence
➥ /testing/model/Log.java
@Entity
@NoArgsConstructor
public class Log {
@Id
@GeneratedValue
@Getter
private Long id;
@NotNull
@Size(
min = 2,
max = 255,
message = "Info is required, maximum 255 characters."
)
@Getter
@Setter
private String info;
public Log(String info) {
this.info = info;
}
}
为了管理这两个实体,我们将创建两个扩展JpaRepository的仓库接口:UserRepository和LogRepository:
public interface UserRepository extends JpaRepository<User, Long> {
}
public interface LogRepository extends JpaRepository<Log, Long> {
}
20.3 使用 Spring TestContext 框架
Spring TestContext 框架旨在提供集成测试支持,因此它非常适合测试持久层。它与我们一起使用的测试框架无关,我们将使用 JUnit 5。我们将检查属于org.springframework.test.context包的其基本类和注解。
Spring TestContext 框架的入口点是TestContextManager类。它的目标是管理一个单一的TestContext并向注册的监听器发送事件。我们将在第 20.8 节中详细检查监听器。
我们将编写一个测试,将User实体保存到数据库中,并使用注入的UserRepository再次检索它。
列表 20.5 SaveRetrieveUserTest类
Path: Ch20/1 spring testing/src/test/java/com/manning/javapersistence
➥ /testing/SaveRetrieveUserTest.java
@SpringBootTest
class SaveRetrieveUserTest {
@Autowired
private UserRepository userRepository;
@Test
void saveRetrieve() {
userRepository.save(new User("User1"));
List<User> users = userRepository.findAll();
assertAll(
() -> assertEquals(1, users.size()),
() -> assertEquals("User1", users.get(0).getName())
);
}
}
如果我们反复运行这个测试几次,它总是会成功,所以我们可能会尝试修改它,并使用 JUnit 5 的@RepeatedTest(2)注解来注释saveRetrieve测试方法。这将在一个类执行中运行两次。修改后的测试将如下所示。
列表 20.6 使用@RepeatedTest的SaveRetrieveUserTest类
Path: Ch20/1 spring testing/src/test/java/com/manning/javapersistence
➥ /testing/SaveRetrieveUserTest.java
@SpringBootTest
class SaveRetrieveUserTest {
@Autowired
private UserRepository userRepository;
@RepeatedTest(2)
void saveRetrieve() {
userRepository.save(new User("User1"));
List<User> users = userRepository.findAll();
assertAll(
() -> assertEquals(1, users.size()),
() -> assertEquals("User1", users.get(0).getName())
);
}
}
让我们现在运行修改后的测试。出人意料还是不出人意料,它第一次执行会成功,第二次执行会失败,从数据库中获取两个User而不是一个(见图 20.3)。

图 20.3 @RepeatedTest第一次执行会成功,第二次执行会失败,因为第一次测试执行后数据库被留下脏数据。
这种情况发生是因为第一次测试执行插入的行没有被删除,第二次测试在表中找到了它,并添加了一个。我们可以跟踪在类运行时执行的 SQL 命令。
在运行两个测试之前,执行的 SQL 命令处理表(重新)创建:
drop table if exists hibernate_sequence
drop table if exists log
drop table if exists user
create table hibernate_sequence (next_val bigint)
insert into hibernate_sequence values ( 1 )
create table log (id bigint not null, info varchar(255) not null,
➥ primary key (id))
create table user (id bigint not null, name varchar(255) not null,
➥ primary key (id))
在运行每个测试之前,以下 SQL 命令被执行,向表中插入一个新行:
select next_val as id_val from hibernate_sequence for update
update hibernate_sequence set next_val= ? where next_val=?
insert into user (name, id) values (?, ?)
select user0_.id as id1_1_, user0_.name as name2_1_ from user user0_
第二次测试的执行将在表中找到一个现有行,将添加一个新的行,因此会失败,因为它期望找到单个行。我们将不得不寻找替代方案,以确保在每个测试结束时数据库的内容与运行之前相同。
20.4 @DirtiesContext 注解
Spring TestContext 框架提供的一个替代方案是使用 @DirtiesContext 注解。@DirtiesContext 注解承认测试方法或测试类会改变 Spring 上下文,Spring TestContext 框架将从头开始重新创建它,并将其提供给下一个测试。该注解可以应用于方法或类。其效果可以在每个测试方法执行之前或之后应用,或者可以在测试类执行之前或之后应用。
我们将修改测试类,如下所示。
列表 20.7 使用 @DirtiesContext 的 SaveRetrieveUserTest 类
Path: Ch20/1 spring testing/src/test/java/com/manning/javapersistence
➥ /testing/SaveRetrieveUserTest.java
@SpringBootTest
@DirtiesContext(classMode =
➥ DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) Ⓐ
class SaveRetrieveUserTest {
@Autowired
private UserRepository userRepository;
@RepeatedTest(2)
void saveRetrieve() {
// . . .
}
}
Ⓐ 在每个测试方法执行后重新创建 Spring 上下文。
如果我们现在运行修改后的测试,它将在第一次和第二次执行中成功(见图 20.4)。这意味着在运行第一次测试之后,第二次测试的执行将不再遇到脏数据库。

图 20.4 @DirtiesContext 注解的测试将在第一次和第二次都成功。
在运行每个测试之前,执行的 SQL 命令处理表(重新)创建和在表中插入一行:
drop table if exists hibernate_sequence
drop table if exists log
drop table if exists user
create table hibernate_sequence (next_val bigint)
insert into hibernate_sequence values ( 1 )
create table log (id bigint not null, info varchar(255) not null,
➥ primary key (id))
create table user (id bigint not null, name varchar(255) not null,
➥ primary key (id))
select next_val as id_val from hibernate_sequence for update
update hibernate_sequence set next_val= ? where next_val=?
insert into user (name, id) values (?, ?)
select user0_.id as id1_1_, user0_.name as name2_1_ from user user0_
这些命令执行了两次,一次在每个测试之前。不仅如此,Spring Boot 标签(也在图 20.4 的底部显示)也将显示两次——每次应用程序启动时各一次。
@DirtiesContext 注解在方法级别上的功能在图 20.5 中得到演示。

图 20.5 在每个测试方法执行之前,@DirtiesContext 注解在方法级别上会创建上下文和缓存,并在执行后移除它们。
这个解决方案是可行的,但它带来了与每个测试执行中表重新创建和应用程序重新初始化相关的性能成本。让我们探索更多替代方案。
20.5 @Transactional 执行
我们在第十一章中详细探讨了事务。事务控制原子操作组,要么完全成功,要么完全失败。使用 Spring 和 Spring Data 以及 @Transactional 注解管理事务在第 11.4 节中进行了详细说明。我们现在要应用的想法是运行每个测试都是事务性的,并在执行结束时回滚事务。
默认情况下,由于TransactionalTestExecutionListener的存在,执行测试时我们的事务将自动回滚。我们将在第 20.8 节中详细讨论监听器;现在,只需注意它们可以在测试执行时提供一些额外的操作。默认行为可以通过@Commit和@Rollback注解进行修改。因此,如果我们想让测试在执行结束时提交,我们可以用@Commit或@Rollback(false)来注解它。
为了在测试执行过程中跟踪事务的活跃状态,我们将使用TransactionSynchronizationManager类。这个类负责管理线程的资源以及事务同步。它的isActualTransactionActive()方法将检查当前是否存在活跃的Transaction对象。
在以下列表中,我们将创建一个测试类,用@Transactional注解它,并跟踪类的方法中的事务状态。
列表 20.8 TransactionalTest类
Path: Ch20/1 spring testing/src/test/java/com/manning/javapersistence
➥ /testing/TransactionalTest.java
@SpringBootTest
@Transactional
class TransactionalTest {
// . . .
@BeforeAll
static void beforeAll() {
System.out.println("beforeAll, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
@BeforeEach
void beforeEach() {
System.out.println("beforeEach, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
@RepeatedTest(2)
void storeRetrieve() {
// . . .
System.out.println("end of method, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
@AfterEach
void afterEach() {
System.out.println("afterEach, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
@AfterAll
static void afterAll() {
System.out.println("afterAll, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
}
执行日志将告诉我们,在@BeforeAll和@AfterAll方法中事务不活跃,但在@BeforeEach和@AfterEach方法以及测试方法本身内部是活跃的。如前所述,默认情况下,事务将在测试结束时回滚,因此@RepeatedTest对所有执行都将成功:
beforeAll, transaction active = false
beforeEach, transaction active = true
end of method, transaction active = true
afterEach, transaction active = true
beforeEach, transaction active = true
end of method, transaction active = true
afterEach, transaction active = true
afterAll, transaction active = false
这种方法仍然存在一个陷阱,我们将演示它。我们引入了一个单独的UserService类,其中包含一个事务方法,如下所示。
列表 20.9 UserService类
Path: Ch20/1 spring testing/src/main/java/com/manning/javapersistence
➥ /testing/service/UserService.java
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Transactional
public void saveTransactionally(User user) {
userRepository.save(user);
}
}
我们将从TransactionalTest类内部的@RepeatedTest中调用此方法,以以事务方式持久化用户。
列表 20.10 调用saveTransactionally方法
Path: Ch20/1 spring testing/src/test/java/com/manning/javapersistence
➥ /testing/TransactionalTest.java
@SpringBootTest
@Transactional
class TransactionalTest {
// . . .
@RepeatedTest(2)
void storeRetrieve() {
List<User> users = buildUsersList();
userRepository.saveAll(users);
assertEquals(getIterations(), userRepository.findAll().size());
userService.saveTransactionally(users.get(0));
System.out.println("end of method, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
// . . .
}
UserService中的saveTransactionally方法具有没有其他参数的@Transactional注解。默认传播行为是REQUIRED(见第 11.4 节)。由于测试已经有一个正在运行的事务,saveTransactionally方法将在同一个事务中执行,并且所有内容将在测试结束时回滚。
我们可以将saveTransactionally方法的注解更改为@Transactional(propagation = Propagation.REQUIRES_NEW)。这将挂起测试中执行的事务,启动一个新的事务,并提交它,如图 20.6 所示。

图 20.6 saveTransactionally方法的事务将被提交,而测试方法的事务将被回滚。
saveTransactionally的事务已被提交。因此,再次运行测试时将遇到数据库中的现有记录,测试将失败(见图 20.7)。

图 20.7 在saveTransactionally方法单独提交其事务的情况下连续运行两个测试将导致第二个测试失败。
结论是,即使你以事务方式运行测试,也要注意启动单独事务中方法的陷阱。这可能导致奇怪的错误。此外,使用这种方法进行调试可能很困难。
为了比较使用 @DirtiesContext 和使用 @Transactional 的性能,我们执行了一系列 10 个测试,记录数从 100 逐步增加到 2,000。MySQL 上的结果如图 20.8 所示。

图 20.8 使用 @DirtiesContext 和 @Transactional 在 MySQL 上执行时间(毫秒)以及记录数从 100 到 2,000 的变化
H2 上的结果如图 20.9 所示。我们使用这个内存数据库执行了相同的 10 个测试系列,记录数从 100 逐步增加到 2,000。

图 20.9 使用 @DirtiesContext 和 @Transactional 在 H2 上执行时间(毫秒)以及记录数从 100 到 2,000 的变化
分析 MySQL 和 H2 的结果,我们可以看到使用 @DirtiesContext 和 @Transactional 的执行时间差异大致是恒定的。它不依赖于记录数,而是依赖于上下文重新初始化的次数。结论是,你应该谨慎使用 @DirtiesContext。将带有此注解的测试推送到 CI/CD(持续集成/持续开发)环境将严重增加它们的执行时间。
20.6 @BeforeTransaction 和 @AfterTransaction 注解
我们现在将检查 @BeforeTransaction 和 @AfterTransaction 注解。正如它们的名称所暗示的,它们指示在事务执行前后要执行的方法。为了我们的分析,我们将检查确实在这些方法内部没有活跃的事务。
使用 Assumptions.assumeFalse JUnit 5 方法,我们将指示运行测试的一个先决条件是当时没有活跃的事务;否则测试将不会运行。所以如果假设未满足,测试将被终止。如果断言未满足,测试将失败。
列表 20.11 使用 @BeforeTransaction 和 @AfterTransaction
Path: Ch20/1 spring testing/src/test/java/com/manning/javapersistence
➥ /testing/TransactionsManagementTest.java
@SpringBootTest
@Transactional
class TransactionsManagementTest {
@Autowired
private UserRepository userRepository;
@Autowired
private LogRepository logRepository;
@BeforeTransaction
void beforeTransaction() {
Assumptions.assumeFalse(
TransactionSynchronizationManager.isActualTransactionActive());
}
// . . .
@AfterTransaction
void afterTransaction() {
Assumptions.assumeFalse(
TransactionSynchronizationManager.isActualTransactionActive());
}
}
这里还有一个需要避免的陷阱:在 @BeforeTransaction 或 @AfterTransaction 方法中持久化数据的可能性。因为这些是在事务外部执行的,数据将不会被回滚,并会影响数据库的内容。更严重的是,如果我们持久化了测试中没有检查的数据(例如,Log 实体,而我们的测试验证的是 User 实体),我们的测试将始终执行正确,但会在它们后面留下已提交的数据,如下面的列表所示。
列表 20.12 在 @BeforeTransaction/@AfterTransaction 中持久化实体
Path: Ch20/1 spring testing/src/test/java/com/manning/javapersistence
➥ /testing/TransactionsManagementTest.java
@SpringBootTest
@Transactional
class TransactionsManagementTest {
@Autowired
private UserRepository userRepository;
@Autowired
private LogRepository logRepository;
@BeforeTransaction
void beforeTransaction() {
Assumptions.assumeFalse(
TransactionSynchronizationManager.isActualTransactionActive());
logRepository.save(new Log("@BeforeTransaction"));
}
// . . .
@AfterTransaction
void afterTransaction() {
Assumptions.assumeFalse(
TransactionSynchronizationManager.isActualTransactionActive());
logRepository.save(new Log("@AfterTransaction"));
}
}
20.7 使用 Spring 配置文件
默认情况下,Spring Boot 创建一个 main/resources/application.properties 文件来保存应用程序的配置。但经常会有需要根据用户的配置文件区分属性的情况。Spring Boot 将允许我们在这种情况下将属性分开,默认情况下在名为 main/resources/application-profilename.properties 的文件中,并允许我们在配置文件之间切换。
一个现实生活中的用例是当你有一个针对程序员的配置文件,在开发期间使用嵌入式数据库,以及另一个针对生产的配置文件,使用真实数据库。程序员希望能够快速执行他们的测试,而在生产环境中,测试将在真实环境中运行。
开发配置的详细信息如下所示。它针对 H2 数据库,并在执行期间显示 SQL 查询,因为程序员对跟踪它们感兴趣。
列表 20.13 application-dev.properties 文件
Path: Ch20/2 spring profiles/src/main/resources
➥ /application-dev.properties
spring.datasource.url=jdbc:h2:mem:ch20_testing
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create
生产配置的详细信息如下所示。它针对 MySQL 数据库,并在执行期间不会显示 SQL 查询,因为这将在生产中消耗资源。
列表 20.14 application-prod.properties 文件
Path: Ch20/2 spring profiles/src/main/resources
➥ /application-prod.properties
spring.datasource.url=
➥ jdbc:mysql://localhost:3306/CH20_TESTING?serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=create
要在开发期间在 H2 数据库上运行测试,我们必须选择dev配置文件。例如,这可以通过在 application.properties 文件中完成。
列表 20.15 包含 dev 配置文件的 application.properties 文件
Path: Ch20/2 spring profiles/src/main/resources/application.properties
spring.profiles.active=dev
为了展示在配置文件之间切换有多简单,我们将运行一个测试,该测试将保存并从数据库中检索一个实体。
列表 20.16 SpringProfilesTest类
Path: Ch20/2 spring profiles/src/test/java/com/manning/javapersistence
➥ /testing/SpringProfilesTest.java
@SpringBootTest
@Transactional
class SpringProfilesTest {
@Autowired
private UserRepository userRepository;
@Test
void storeUpdateRetrieve() {
List<User> users = buildUsersList();
userRepository.saveAll(users);
assertEquals(getIterations(), userRepository.findAll().size());
}
}
要成功运行此测试,我们需要在 pom.xml 文件中包含 H2 驱动依赖项。
列表 20.17 包含 H2 驱动依赖项的 pom.xml 文件
Path: Ch20/2 spring profiles/pom.xml
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.200</version>
<scope>runtime</scope>
</dependency>
在dev配置文件上运行测试的结果显示在图 20.10 中:dev配置文件使用内存中的 H2 数据库。

图 20.10 在dev配置文件上运行测试的结果,使用 H2 数据库并显示 SQL 查询的执行
要在生产环境中在 MySQL 数据库上运行测试,我们必须选择prod配置文件。例如,这可以通过在 application.properties 文件中完成。
列表 20.18 包含 prod 配置文件的 application.properties 文件
Path: Ch20/2 spring profiles/src/main/resources/application.properties
spring.profiles.active=prod
作为修改活动配置的替代方案,我们可以在测试级别使用@ActiveProfiles注解,如图 20.19 所示。此注解将覆盖在 application.properties 中设置的配置文件,但将需要我们修改和重新编译代码。
列表 20.19 SpringProfilesTest类
Path: Ch20/2 spring profiles/src/test/java/com/manning/javapersistence
➥ /testing/SpringProfilesTest.java
@SpringBootTest
@Transactional
@ActiveProfiles("prod")
class SpringProfilesTest {
// . . .
}
要成功运行此测试,我们需要在 pom.xml 文件中包含 MySQL 驱动依赖项。
列表 20.20 包含 MySQL 驱动依赖项的 pom.xml 文件
Path: Ch20/2 spring profiles/pom.xml
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
在 prod 配置文件上运行测试的结果如图 20.11 所示。与 dev 配置文件不同,此配置文件使用 MySQL 数据库。

图 20.11 在 prod 配置文件上运行测试的结果,使用 MySQL 数据库且不显示 SQL 查询的执行
20.8 使用测试执行监听器
控制测试执行生命周期的 一种方式是使用 JUnit 5 注解:@BeforeAll、@AfterAll、@BeforeEach 和 @AfterEach。在某些情况下,这可能会不方便。例如,如果我们需要为多个测试使用相同的 @BeforeEach 和 @AfterEach 行为,我们需要创建一个包含这些方法的基类,并创建多个子类,以便在运行测试时继承并执行它们。这会导致我们的测试挂在一个类层次结构中。或者,我们可以考虑使用测试执行监听器,使用 TestExecutionListener 接口和 @TestExecutionListeners 注解,从而将控制测试生命周期的行为分离出来。
默认情况下,Spring 为每个测试提供了一些已实现的 TestExecutionListener。对我们来说最有兴趣的是 DependencyInjectionTestExecutionListener,它支持测试实例的依赖注入,以及 TransactionalTestExecutionListener,它支持测试的事务性执行和回滚。我们在 20.3.2 节中提到,默认情况下,由于 TransactionalTestExecutionListener,执行测试时事务将自动回滚——这对于测试持久性和在执行测试后留下干净数据库至关重要。
TestExecutionListener 接口定义了一系列空默认方法,这些方法比 JUnit 5 生命周期方法更细粒度,并且按照表 20.1 所示的顺序执行。
表 20.1 TestExecutionListener 接口中的默认方法
| 方法 | 描述 |
|---|---|
beforeTestClass |
在 JUnit 5 的 @BeforeAll 方法之前执行 |
prepareTestInstance |
准备提供的测试上下文的测试实例 |
beforeTestMethod |
在 JUnit 5 的 @BeforeEach 方法之前执行 |
beforeTestExecution |
在测试方法之前执行 |
afterTestExecution |
在测试方法之后执行 |
afterTestMethod |
在 JUnit 5 的 @AfterEach 方法之后执行 |
afterTestClass |
在 JUnit 5 的 @AfterAll 方法之后执行 |
我们将编写自己的监听器,实现 TestExecutionListener 接口,覆盖所有方法,并从每个方法中打印一条消息,这样我们就可以跟踪使用此监听器注解的测试的执行。
正如 20.3.2 节中所述,我们将通过使用TransactionSynchronizationManager类及其isActualTransactionActive()方法来跟踪测试执行期间活跃的事务,该方法将检查是否存在当前活跃的Transaction对象。我们的监听器如下所示。
列表 20.21 DatabaseOperationsListener类
Path: Ch20/3 spring listeners/src/test/java/com/manning/javapersistence
➥ /testing/listeners/DatabaseOperationsListener.java
public class DatabaseOperationsListener implements TestExecutionListener {
@Override
public void beforeTestClass(TestContext testContext) {
System.out.println("beforeTestClass, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
@Override
public void afterTestClass(TestContext testContext) {
System.out.println("afterTestClass, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
@Override
public void beforeTestMethod(TestContext testContext) {
System.out.println("beforeTestMethod, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
@Override
public void afterTestMethod(TestContext testContext) {
System.out.println("afterTestMethod, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
@Override
public void beforeTestExecution(TestContext testContext) {
System.out.println("beforeTestExecution, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
@Override
public void afterTestExecution(TestContext testContext) {
System.out.println("afterTestExecution, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
@Override
public void prepareTestInstance(TestContext testContext) {
System.out.println("prepareTestInstance, transaction active = " +
TransactionSynchronizationManager.isActualTransactionActive());
}
}
我们将创建自己的测试,将其注解为使用新的DatabaseOperationsListener,并打印生命周期方法和测试本身的消息,以跟踪其执行。
列表 20.22 ListenersTest类
Path: Ch20/3 spring listeners/src/test/java/com/manning/javapersistence
➥ /testing/ListenersTest.java
@SpringBootTest
@Transactional
@TestExecutionListeners(value = {DatabaseOperationsListener.class})
class ListenersTest {
@Autowired
private UserRepository userRepository;
@BeforeAll
static void beforeAll() {
System.out.println("@BeforeAll");
}
@BeforeEach
void beforeEach() {
System.out.println("@BeforeEach");
}
@Test
void storeUpdateRetrieve() {
TestContextManager testContextManager = new
TestContextManager(getClass());
System.out.println(
"testContextManager.getTestExecutionListeners().size() = "
+ testContextManager.getTestExecutionListeners().size());
List<User> users = buildUsersList();
userRepository.saveAll(users);
assertEquals(getIterations(), userRepository.findAll().size());
}
@AfterEach
void afterEach() {
System.out.println("@AfterEach");
}
@AfterAll
static void afterAll() {
System.out.println("@AfterAll");
}
}
如果我们现在运行这个测试,它将因为NullPointerException而失败,如图 20.12 所示。

图 20.12 初始注解了我们的自定义监听器的测试因NullPointerException而失败。
如果我们检查正在发生的事情,我们会注意到与数据库交互的对象的userRepository引用是null。正如控制台消息所展示的,在TestContextManager中只注册了一个监听器,即我们的DatabaseOperationsListener。之前需要的支持测试实例依赖注入的DependencyInjectionTestExecutionListener,以及因此对userRepository的依赖注入支持,现在不再注册。这是因为,一旦我们引入了自己的监听器,默认监听器就不再自动注册。
为了解决这个问题,我们将使用MERGE_WITH_DEFAULTS选项作为合并模式,如下所示。
列表 20.23 将默认监听器与我们的自定义监听器合并
Path: Ch20/3 spring listeners/src/test/java/com/manning/javapersistence
➥ /testing/ListenersTest.java
@SpringBootTest
@Transactional
@TestExecutionListeners(value = {
DatabaseOperationsListener.class}, mergeMode =
TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
class ListenersTest {
// . . .
}
如果我们现在再次运行测试,我们将能够跟踪方法执行的顺序,无论是从监听器还是从 JUnit 5 的生命周期方法。我们还会注意到注册的监听器数量现在是 15,这意味着有 14 个默认监听器和我们的自定义监听器:
beforeTestClass, transaction active = false
@BeforeAll
prepareTestInstance, transaction active = false
beforeTestMethod, transaction active = true
@BeforeEach
beforeTestExecution, transaction active = true
testContextManager.getTestExecutionListeners().size() = 15
afterTestExecution, transaction active = true
@AfterEach
afterTestMethod, transaction active = true
@AfterAll
afterTestClass, transaction active = false
表 20.2 总结了 Spring TestContext 框架中最重要的注解,这些注解用于测试持久化应用程序。您将能够使用 Spring TestContext 框架中的这些注解来测试您的持久化应用程序。该框架为集成测试提供了强大的支持,而持久化应用程序测试属于集成测试范畴。在此基础上,您可以继续构建系统测试和验收测试,正如我们在本章开头介绍测试金字塔时演示的那样。有关测试 Java 应用程序的一般信息和关于验收测试的特定信息,您可以参考我的书籍《JUnit in Action》,第三版(Tudose,2020)。
表 20.2 用于测试持久化应用程序的最重要的 Spring TestContext 框架注解
| 注解 | 描述 |
|---|---|
@DirtiesContext |
测试执行期间底层 Spring 上下文发生了变化,应该重新初始化。 |
@BeforeTransaction |
应在具有 Spring @Transactional注解的任何方法之前执行此空方法。 |
@AfterTransaction |
应在具有 Spring @Transactional 注解的任何方法之后执行 void 方法。 |
@Rollback |
对于事务性测试,测试完成后将回滚事务。这是默认行为,可以通过 @Rollback(false) 或 @Commit 进行更改。 |
@Commit |
对于事务性测试,测试完成后将提交事务。 |
@ActiveProfiles |
指定在 Spring 上下文中哪些配置配置文件将处于活动状态。 |
@TestExecutionListeners |
配置要注册到 TestContextManager 的测试执行监听器。 |
通过可预测和安全的测试,只有在代码中存在问题时才会失败,而不是由于外部因素(例如脏数据库中的不适当内容),您作为程序员的职业生涯将会更加美好!
摘要
-
测试金字塔由单元、集成、系统和验收级别组成。持久化测试可以在集成级别进行分类。
-
您可以使用 Spring Boot 创建和配置持久化应用程序,并在其中管理实体和仓库。
-
您可以使用 Spring 测试上下文框架创建持久化测试,并使用
@DirtiesContext或@Transactional来管理它们。 -
您可以使用 Spring 配置文件来测试访问各种数据库并具有不同配置的 Java 持久化应用程序。
-
您可以创建自定义测试执行监听器以跟踪测试的生命周期,并与它以及默认监听器一起工作。
附录 A. Maven
Maven (maven.apache.org) 可以被视为一个源构建 环境。为了更好地理解 Maven 的工作原理,你需要了解 Maven 背后的关键点(原则)。从 Maven 项目的开始,就为软件架构制定了一些基本规则。这些规则旨在简化使用 Maven 的开发,并使开发者更容易实现构建系统。
Maven 的一个基本思想是构建系统应该尽可能简单:软件工程师不应该花费大量时间来实现构建系统。应该能够轻松地从零开始启动一个新项目,然后快速开始软件开发。本附录详细描述了 Maven 的核心原则,并从开发者的角度解释了它们的含义。
A.1 约定优于配置
约定优于配置 是一个软件设计原则,旨在减少软件工程师需要做出的配置数量,而不是引入我们必须严格遵循的常规规则。这样,我们可以跳过繁琐的项目配置,专注于我们工作的更重要部分。
约定优于配置是 Maven 项目中最强的原则之一。其应用的一个例子是构建过程的文件夹结构。使用 Maven,我们需要的所有目录都已经为我们定义好了。例如,src/main/java/ 是 Maven 为项目 Java 代码指定的位置,src/test/java 是项目单元测试的位置,target 是构建文件夹,等等。
这听起来很棒,但我们不是在项目中失去了灵活性吗?如果我们想将源代码放在另一个文件夹中怎么办?Maven 易于配置:它提供了约定,但我们可以在任何点上覆盖这些约定,并使用我们选择的配置。
A.2 强依赖管理
强依赖管理是 Maven 引入的第二个关键点。当 Maven 项目开始时,Java 项目的默认构建系统是另一个构建工具,Ant。使用 Ant,我们必须分发我们项目的依赖项,这意味着每个项目都必须负责它所需的依赖项,而单个项目的依赖项可能分布在不同的位置。此外,相同的依赖项可能被不同的项目使用,但每个项目可能位于不同的位置,导致资源重复。
Maven 引入了 中央仓库 的概念:互联网上存储所有类型工件(依赖项)的位置。Maven 构建工具通过读取项目的构建描述符,下载必要的工件版本,并将它们包含在应用程序的类路径中,来解析这些工件。这样,我们只需要在我们的构建描述符的依赖项部分列出一次依赖项。以下是一个示例:
<dependencies>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.29</version>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jpa</artifactId>
<version>2.7.0</version>
</dependency>
</dependencies>
此后,我们可以在任何其他机器上自由构建软件。我们不需要将依赖项捆绑到我们的项目中。
Maven 还引入了本地仓库的概念:硬盘上的一个文件夹(UNIX 中的 ~/.m2/repository/ 和 Windows 中的 C:\Users<UserName>.m2\repository\),Maven 将从中下载中央仓库中的工件。在我们构建项目之后,我们的工件将被安装在本地仓库中,以便其他项目稍后使用,这样既简单又整洁。
开发者可能加入由 Maven 管理的项目,并且只需要访问项目的源代码。Maven 从中央仓库下载所需的依赖项,并将它们带到本地仓库,在那里它们将可供同一开发者可能工作的其他项目使用。
A.3 Maven 构建生命周期
Maven 的另一个非常强的原则是构建生命周期。Maven 项目围绕定义构建、测试和分发特定工件的过程构建。Maven 项目只能生成一个工件。这样,我们可以使用 Maven 构建项目工件、清理项目的文件夹结构或生成项目文档。这些是三个内置的 Maven 生命周期:
-
默认—用于生成项目工件
-
清理—用于清理项目
-
站点—用于生成项目文档
这些生命周期中的每一个都由几个阶段组成。要导航某个生命周期,构建过程将遵循其阶段(见图 A.1)。

图 A.1 Maven 默认生命周期的阶段,从验证到部署
这些是默认生命周期的阶段:
-
验证—验证项目是否正确,以及所有必要的信息是否可用。
-
编译—编译项目的源代码。
-
测试—使用合适的单元测试框架(例如,在这种情况下可能是 JUnit 5)测试编译后的源代码。测试不应需要将代码打包或部署。
-
打包—将编译后的代码打包成可分发格式,例如.jar 文件。
-
集成测试—在可以运行集成测试的环境中处理和部署包(如果需要)。
-
验证—运行任何检查以验证包是否有效并满足质量标准。
-
安装—将包安装到本地仓库中,以便在本地项目中作为依赖项使用。
-
部署—在集成或发布环境中,将最终包复制到远程仓库以与其他开发者和项目共享。
这里,再次强调 Maven 推崇的约定优于配置原则。这些阶段已经按照这里列出的顺序定义。Maven 以非常严格的顺序调用这些阶段;阶段按列表中的顺序依次执行,以完成生命周期。如果我们调用这些阶段中的任何一个——例如,在我们的项目主目录中键入 mvn compile——Maven 首先验证项目,然后尝试编译项目的源代码。
最后一点:将这些阶段视为扩展点是有用的。我们可以在这些阶段附加额外的 Maven 插件,并编排它们的执行顺序和方式。
A.4 基于插件的架构
我们在这里将要提到的 Maven 的最后一个特性是其基于插件的架构。我们提到 Maven 是一个源构建环境。更具体地说,Maven 是一个插件执行源构建环境。项目的核心非常小,但项目的架构允许将多个插件附加到核心。这样,Maven 构建了一个环境,其中可以执行不同的插件。
在给定的生命周期中,每个阶段都附加了多个插件,并且 Maven 按照插件声明的顺序在通过给定阶段时调用它们。以下是一些核心 Maven 插件:
-
清理—在构建后进行清理
-
编译器—编译 Java 源代码
-
部署—将构建的工件部署到远程仓库
-
安装—将构建的工件安装到本地仓库中
-
资源—将资源复制到输出目录,以便包含在 .jar 文件中
-
站点—生成包含当前项目信息的站点
-
Surefire—在隔离的类加载器中运行 JUnit 测试
-
验证器—验证某些条件是否存在(对集成测试很有用)
除了这些核心 Maven 插件之外,还有许多其他 Maven 插件可用于许多情况,例如 WAR(用于打包 Web 应用程序)和 Javadoc(用于生成项目文档)。
插件在构建配置文件的 plugins 部分声明,如下例所示:
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
</plugin>
</plugins>
</build>
插件声明可以有一个groupId、artifactId和version。这样,插件看起来就像依赖项。实际上,插件的处理方式与依赖项相同;它们像依赖项一样被下载到本地仓库。当我们指定一个插件时,groupId和version参数是可选的;如果我们没有声明它们,Maven 会寻找具有指定artifactId和以下groupId之一的插件:org.apache.maven.plugins或org.codehaus.mojo。由于版本是可选的,Maven 会尝试下载最新可用的插件版本。指定插件版本强烈推荐,以防止自动更新和非可重复构建。我们可能已经使用最新更新的 Maven 插件构建了我们的项目;但后来,如果其他开发者尝试使用相同的配置进行相同的构建,并且如果 Maven 插件在此之后已被更新,使用最新的更新可能会导致非可重复构建。
A.5 Maven 项目对象模型(POM)
Maven 默认有一个名为 pom.xml 的构建描述符(简称项目对象模型)。我们不是强制性地指定我们想要做的事情;我们声明性地指定项目本身的一般信息,如下所示。
列表 A.1 非常简单的 pom.xml
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.manning.javapersistence</groupId>
<artifactId>example-pom</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
</project>
这段代码看起来真的很简单,不是吗?但可能有一个大问题:Maven 是如何仅凭这么少的信息就能构建源代码的?
答案在于 pom.xml 文件的继承功能。每个简单的 pom.xml 都从 Super POM 继承了大部分功能。就像在 Java 中,每个类都从java.lang.Object类继承某些方法一样,Super POM 赋予了每个 pom.xml 文件 Maven 功能。
为了进一步类比 Java 和 Maven,Maven 的 pom.xml 文件可以相互继承;就像在 Java 中,一些类可以作为其他类的父类。如果我们想使用列表 A.1 中的 pom 作为我们的父 pom,我们只需要将其packaging值更改为pom。父项目和聚合(多模块)项目只能将pom作为打包值。我们还需要在我们的父 pom 中定义哪些模块是子模块。
列表 A.2 包含子模块的父 pom.xml
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.manning.javapersistence</groupId>
<artifactId>example-pom</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<module>example-module</module>
</modules>
</project>
列表 A.2 是列表 A.1 的扩展。我们通过将包声明为pom类型并添加一个modules部分来声明这个pom是一个聚合模块。modules部分通过提供项目文件夹(在本例中为 example-module)的相对路径来列出我们的模块拥有的所有子模块。
以下列表显示了子 pom.xml。
列表 A.3 继承父 pom.xml 的 pom.xml
<project>
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.manning.javapersistence</groupId>
<artifactId>example-pom</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>example-child</artifactId>
</project>
记住,这个 pom.xml 位于父 XML 声明的文件夹中(在本例中为 example-module)。
这里有两点值得关注。首先,因为我们从某个其他 pom 继承,所以我们不需要为子 pom 指定groupId和version;其次,Maven 期望这些值与父 pom 中的值相同。
进一步类比 Java,似乎有理由询问 pom 文件可以从其父文件继承哪些类型的对象。以下是 pom 文件可以从其父文件继承的所有元素:
-
依赖项
-
开发者和贡献者
-
插件及其配置
-
报告列表
在父 pom 文件中指定的每个这些元素都会自动在子 pom 文件中指定。
A.6 安装 Maven
安装 Maven 是一个三步过程:
-
从
maven.apache.org下载最新发行版,并将其解压缩/解 tar 到您选择的目录中。 -
定义一个指向您已安装 Maven 的位置的
M2_HOME环境变量。 -
将
M2_HOME\bin(M2_HOME/bin在 UNIX 上) 添加到您的PATH环境变量中,这样您就可以在任何目录中输入mvn。
附录 B. Spring Data JPA 关键字使用
表 B.1 描述了 Spring Data JPA 支持的关键字以及每个方法名如何在 JPQL 中转换。
表 B.1 Spring Data JPA 和生成的 JPQL 中的关键字使用
| 关键词 | 示例 | 生成的 JPQL |
|---|---|---|
Is, Equals |
findByUsername, findByUsernameIs, findByUsernameEquals |
. . . where e.username = ?1 |
And |
findByUsernameAndRegistrationDate |
. . . where e.username = ?1 and e.registrationDate = ?2 |
Or |
findByUsernameOrRegistrationDate |
. . . where e.username = ?1 or e.registrationDate = ?2 |
LessThan |
findByRegistrationDateLessThan |
. . . where e.registrationDate < ?1 |
LessThanEqual |
findByRegistrationDateLessThanEqual |
. . . where e.registrationDate <= ?1 |
GreaterThan |
findByRegistrationDateGreaterThan |
. . . where e.registrationDate > ?1 |
GreaterThanEqual |
findByRegistrationDateGreaterThanEqual |
. . . where e.registrationDate >= ?1 |
Between |
findByRegistrationDateBetween |
. . . where e.registrationDate between ?1 and ?2 |
OrderBy |
findByRegistrationDateOrderByUsernameDesc |
. . . where e.registrationDate = ?1 order by e.username desc |
Like |
findByUsernameLike |
. . . where e.username like ?1 |
NotLike |
findByUsernameNotLike |
. . . where e.username not like ?1 |
Before |
findByRegistrationDateBefore |
. . . where e.registrationDate < ?1 |
After |
findByRegistrationDateAfter |
. . . where e.registrationDate > ?1 |
Null, IsNull |
findByRegistrationDate(Is)Null |
. . . where e.registrationDate is null |
NotNull, IsNotNull |
findByRegistrationDate(Is)NotNull |
. . . where e.registrationDate is not null |
Not |
findByUsernameNot |
. . . where e.username <> ?1 |
In |
findByRegistrationDateIn(Collection |
. . . where e.registrationDate in ?1 |
NotIn |
findByRegistrationDateNotIn(Collection |
. . . where e.registrationDate not in ?1 |
True |
findByActiveTrue |
. . . where e.active = true |
False |
findByActiveFalse |
. . . where e.active = false |
StartingWith |
findByUsernameStartingWith |
. . . where e.username like ?1% |
EndingWith |
findByUsernameEndingWith |
. . . where e.username like %?1 |
Containing |
findByUsernameContaining |
. . . where e.username like %?1% |
IgnoreCase |
findByUsernameIgnoreCase |
. . . where UPPER(e.username) = UPPER(?1) |
附录 C. Spring Data JDBC 关键字使用
表 C.1 描述了 Spring Data JDBC 支持的关键字以及每个方法名如何生成查询条件。
表 C.1 Spring Data JDBC 中关键字的使用及其生成的条件
| 关键字 | 示例 | 条件 |
|---|---|---|
Is, Equals |
findByUsername(String name) findByUsernameIs(String name) findByUsernameEquals(String name) |
username = name |
And |
findByUsernameAndRegistrationDate(String name, LocalDate date) |
username = name and registrationDate = date |
Or |
findByUsernameOrRegistrationDate(String name, LocalDate date) |
username = name or registrationDate = name |
LessThan |
findByRegistrationDateLessThan(LocalDate date) |
registrationDate < date |
LessThanEqual |
findByRegistrationDateLessThanEqual(LocalDate date) |
registrationDate <= date |
GreaterThan |
findByRegistrationDateGreaterThan(LocalDate date) |
registrationDate > date |
GreaterThanEqual |
findByRegistrationDateGreaterThanEqual(LocalDate date) |
registrationDate >= date |
Between |
findByRegistrationDateBetween(LocalDate from, LocalDate to) |
registrationDate between from and to |
OrderBy |
findByRegistrationDateOrderByUsernameDesc(LocalDate date) |
registrationDate = date order by username desc |
Like |
findByUsernameLike(String name) |
username like name |
NotLike |
findByUsernameNotLike(String name) |
username not like name |
Before |
findByRegistrationDateBefore(LocalDate date) |
registrationDate < date |
After |
findByRegistrationDateAfter(LocalDate date) |
registrationDate > date |
Null, IsNull |
findByRegistrationDate(Is)Null() |
registrationDate is null |
NotNull, IsNotNull |
findByRegistrationDate(Is)NotNull() |
registrationDate is not null |
Not |
findByUsernameNot(String name) |
username <> name |
In |
findByRegistrationDateIn(Collection |
registrationDate in (date1, . . . dateN) |
NotIn |
findByRegistrationDateNotIn(Collection |
registrationDate not in (date1, . . . dateN) |
True, IsTrue |
findByActiveTrue() |
active is true |
False, IsFalse |
findByActiveFalse() |
active is false |
StartingWith |
findByUsernameStartingWith(String name) |
username like name% |
EndingWith |
findByUsernameEndingWith(String name) |
username like %name |
Containing |
findByUsernameContaining(String name) |
username like %name% |
IgnoreCase |
findByUsernameIgnoreCase(String name) |
UPPER(username) = UPPER(name) |
附录 D. Spring Data MongoDB 关键字用法
表 D.1 描述了 Spring Data MongoDB 支持的关键字以及每个方法名称如何生成查询条件。
表 D.1 Spring Data MongoDB 关键字用法及其生成的条件
| 关键字 | 示例 | 条件 |
|---|---|---|
Is, Equals |
findByUsername(String name)``findByUsernameIs(String name)``findByUsernameEquals(String name) |
{"username":"name"} |
And |
findByUsernameAndEmail(String username, String email) |
{"username":"username", "email":"email"} |
Or |
findByUsernameOrEmail (String username, String email) |
{ "$or" : [{ "username" : "username"}, { "email" : "email"}]} |
LessThan |
findByRegistrationDateLessThan(LocalDate date) |
{ "registrationDate" : { "$lt" : { "$date" : "date"}}} |
LessThanEqual |
findByRegistrationDateLessThanEqual(LocalDate date) |
{ "registrationDate" : { "$lte" : { "$date" : "date"}}} |
GreaterThan |
findByRegistrationDateGreaterThan(LocalDate date) |
{ "registrationDate" : { "$gt" : { "$date" : "date"}}} |
GreaterThanEqual |
findByRegistrationDateGreaterThanEqual(LocalDate date) |
{ "registrationDate" : { "$gte" : { "$date" : "date"}}} |
Between |
findByRegistrationDateBetween(LocalDate from, LocalDate to) |
"registrationDate" : { "$gte" : { "$date" : "date"}, "$lte" : { "$date" : "date"}}} |
OrderBy |
findByRegistrationDateOrderByUsernameDesc(LocalDate date) |
"registrationDate" : { "$date" : "date"}} |
Like |
findByUsernameLike(String name) |
{ "username" : { "$regularExpression" : { "pattern" : "name", "options" : ""}}} |
NotLike |
findByUsernameNotLike(String name) |
{ "username" : { "$not" : { "$regularExpression" : { "pattern" : "name", "options" : ""}}}} |
Before |
findByRegistrationDateBefore(LocalDate date) |
{ "registrationDate" : { "$lt" : { "$date" : "date"}}} |
After |
findByRegistrationDateAfter(LocalDate date) |
{ "registrationDate" : { "$gt" : { "$date" : "date"}}} |
Null, IsNull |
findByRegistrationDate(Is)Null() |
{ "registrationDate" : null} |
NotNull, IsNotNull |
findByRegistrationDate(Is)NotNull() |
{ "registrationDate" : { "$ne" : null}} |
Not |
findByUsernameNot(String name) |
{ "username" : { "$ne" : "name"}} |
In |
findByRegistrationDateIn(Collection |
"registrationDate" : { "$in" : [{ "$date" : "date1"}, . { "$date" : "daten"}]}} |
NotIn |
findByRegistrationDateNotIn(Collection |
"registrationDate" : { "$nin" : [{ "$date" : "date1"}, . . . { "$date" : "daten"}]}} |
True, IsTrue |
findByActiveTrue() |
{ "active" : true} |
False, IsFalse |
findByActiveFalse() |
{ "active" : false} |
StartingWith |
findByUsernameStartingWith(String name) |
{ "username" : { "$regularExpression" : { "pattern" : "^name", "options" : ""}}} |
以...结尾 |
findByUsernameEndingWith(String name) |
{ "username" : { "$regularExpression" : { "pattern" : "name$", "options" : ""}}} |
包含 |
findByUsernameContaining(String name) |
{ "pattern" : ".*name.*", "options" : ""}}} |
忽略大小写 |
findByUsernameIgnoreCase(String name) |
{ "username" : { "$regularExpression" : { "pattern" : "^name$", "options" : "i"}}} |
参考文献
Bernard, E., and J. Griffin. 2008. Hibernate Search in Action. Manning Publications.
Bloch, J. 2017. 有效 Java, 第三版. Addison-Wesley Professional.
Codd, E.F. 1970. “大型共享数据库的数据关系模型。” ACM 通讯 13 (6): 377-387. dl.acm.org/doi/10.1145/362384.362685.
Date, C.J. 2015. SQL 与关系理论:如何编写准确的 SQL 代码,第三版. O’Reilly Media.
Elmasri, R., and S. Navathe. 2016. 数据库系统基础. Pearson.
Fielding, R. 2000. “网络软件架构的设计与架构风格。” PhD dissertation, University Of California, Irvine. www.ics.uci.edu/~fielding/pubs/dissertation/top.htm.
Gamma, E., R. Helm, R. Johnson, and J. Vlissides. 1994. 设计模式:可重用面向对象软件元素. Addison-Wesley Professional.
Johnson, R. 2002. 一对一专家 J2EE 设计与开发. Wrox.
Karwin, B. 2010. SQL 反模式:避免数据库编程陷阱. The Pragmatic Bookshelf.
Spilcă, L. 2021. Spring 从这里开始. Manning Publications.
Tow, D. 2003. SQL Tuning. O’Reilly Media.
Tudose, C. 2020. JUnit 实战, 第三版. Manning Publications.
Tudose, C., and C. Odubǎșteanu. 2021. “使用 JPA、Hibernate 和 Spring Data JPA 进行对象关系映射。” 2021 年第 23 届国际控制系统与计算机科学会议论文集 (CSCS 2021): 424–431.


浙公网安备 33010602011771号