系列文章:
目录
6.5 分页和排序 (Paging and Ordering)
6. 编译时工具
元模型生成器 是 JPA 的标准组件。在前面的代码示例中,我们已经见识过它的作用:它生成了 Book_
类,该类包含实体类 Book
的静态元模型。
元模型生成器
Hibernate 的元模型生成器是一种注解处理器,它生成 JPA 所称的静态元模型(static metamodel)。简单来说,它会为程序中的持久化类生成一个类型化的模型,为我们在 Java 代码中以类型安全的方式引用这些类的属性提供支持。尤其是,它允许我们以完全类型安全的方式指定实体图(Entity Graphs)和条件查询(Criteria Queries)。这个工具的历史颇为有趣。在 Java 的注解处理 API 刚刚推出时,静态元模型的概念由 Gavin King 提出,并在 JPA 2.0 中得以实现,目的是为新生的条件查询 API 提供类型安全的支持。然而在 2010 年,这个 API 并未取得立竿见影的成功。那时的工具并不支持注解处理器,显式泛型类型也让代码显得冗长且难以阅读。(此外,显式引用 CriteriaBuilder 实例的需求也为条件查询 API 增加了更多冗长代码。)多年后,Gavin 将其视为自己较为尴尬的一个失误。
然而,时间对静态元模型是友好的。到 2023 年,所有 Java 编译器、构建工具和 IDE 都已全面支持注解处理,而 Java 的局部类型推断(var 关键字)也消除了冗长的泛型类型。尽管 JPA 的 CriteriaBuilder 和 EntityGraph API 仍不算完美,但这些不足与静态类型安全或注解处理并无直接关系。静态元模型本身已被证明是非常有用且优雅的。
因此,现在 Hibernate 6.3 已准备好在元模型生成器的基础上进一步探索。事实证明,这个工具还有很多潜在的能力尚未被充分利用。
需要注意的是,使用 Hibernate 时,元模型生成器并非必需——前述 API 仍支持普通字符串作为输入。不过,我们发现元模型生成器与 Gradle 搭配良好,并能够与 IDE 无缝集成,同时它在类型安全方面的优势非常吸引人。
在前面我们已经展示了如何在 Gradle 构建中设置注解处理器。如需更多关于如何集成元模型生成器的详细信息,请查阅《用户指南》中的静态元模型生成器部分。
以下是 JPA 规范要求为实体类生成的代码示例:
@StaticMetamodel(Book.class)
public abstract class Book_ {
/**
* @see org.example.Book#isbn
**/
public static volatile SingularAttribute<Book, String> isbn;
/**
* @see org.example.Book#text
**/
public static volatile SingularAttribute<Book, String> text;
/**
* @see org.example.Book#title
**/
public static volatile SingularAttribute<Book, String> title;
/**
* @see org.example.Book#type
**/
public static volatile SingularAttribute<Book, Type> type;
/**
* @see org.example.Book#publicationDate
**/
public static volatile SingularAttribute<Book, LocalDate> publicationDate;
/**
* @see org.example.Book#publisher
**/
public static volatile SingularAttribute<Book, Publisher> publisher;
/**
* @see org.example.Book#authors
**/
public static volatile SetAttribute<Book, Author> authors;
public static final String ISBN = "isbn";
public static final String TEXT = "text";
public static final String TITLE = "title";
public static final String TYPE = "type";
public static final String PUBLICATION_DATE = "publicationDate";
public static final String PUBLISHER = "publisher";
public static final String AUTHORS = "authors";
}
对于实体的每个属性,Book_
类包含:
- 一个值为字符串的常量,例如
TITLE
。 - 一个类型安全的引用,例如
title
,它是一个Attribute
类型的元模型对象。
在前几章中,我们已经使用了类似
Book_.authors
和Book.AUTHORS
的元模型引用。现在,让我们看看元模型生成器还能为我们做些什么。元模型生成器的功能
元模型生成器为 JPA 元模型的元素提供了静态类型的访问方式。但通过
EntityManagerFactory
,元模型也可以以“反射式”的方式访问。例如:EntityType<Book> book = entityManagerFactory.getMetamodel().entity(Book.class); SingularAttribute<Book, Long> id = book.getDeclaredId(Long.class);
这种方式对于在框架或库中编写通用代码非常有用。例如,您可以利用它创建自己的条件查询 API。
Hibernate 的元模型生成器新增了自动生成 Finder 方法和查询方法的功能,这是对 JPA 规范功能的扩展。在本章中,我们将探索这些特性。
功能依赖
本章接下来的功能描述依赖于实体中使用的注解(详见实体章节)。目前,元模型生成器无法为完全通过 XML 声明的实体生成 Finder 方法和查询方法,也无法验证查询这些实体的 HQL。(另一方面,O/R 映射可以通过 XML 指定,因为元模型生成器并不需要它们。)
我们将介绍三种不同类型的生成方法:
- 命名查询方法:根据
@NamedQuery
注解直接生成方法签名和实现。 - 查询方法:明确声明方法签名,并生成一个实现,该实现会执行通过
@HQL
或@SQL
注解指定的 HQL 或 SQL 查询。 - Finder 方法:使用
@Find
注解的方法,明确声明方法签名,并根据参数列表推断生成实现。
为了让大家先睹为快,让我们看看如何使用 @NamedQuery
来实现这些功能。
6.1. 命名查询和元模型生成器
生成查询方法的最简单方法是在任意位置使用 @NamedQuery
注解,并以“#”开头为其命名。例如,将它放在 Book
类中:
@CheckHQL // 在编译时验证查询语法
@NamedQuery(name = "#findByTitleAndType",
query = "select book from Book book where book.title like :titlen and book.type = :type")
@Entity
public class Book { ... }
元模型生成器会在元模型类 Book_
中添加以下方法声明:
/**
* 执行由 {@link Book} 的注解定义的命名查询 {@value #QUERY_FIND_BY_TITLE_AND_TYPE}。
**/
public static List<Book> findByTitleAndType(@Nonnull EntityManager entityManager, String title, Type type) {
return entityManager.createNamedQuery(QUERY_FIND_BY_TITLE_AND_TYPE)
.setParameter("titlePattern", title)
.setParameter("type", type)
.getResultList();
}
我们可以在任意有 EntityManager
的地方调用这个方法,例如:
List<Book> books =
Book_.findByTitleAndType(entityManager, titlePattern, Type.BOOK);
这种方法虽然简单,但在某些方面缺乏灵活性,因此可能并不是生成查询方法的最佳方式。
6.2. 生成查询方法
从 @NamedQuery
注解直接生成查询方法的主要问题是,它无法让我们显式地指定返回类型或参数列表。在上面的例子中,元模型生成器可以合理地推断出返回类型和参数类型,但我们往往需要更高的控制力。
解决方案:显式声明查询方法签名
我们可以将查询方法作为一个抽象方法明确地写在 Java 中。由于 Book
实体不是抽象类,我们可以为此引入一个新的接口:
interface Queries {
@HQL("where title like :title and type = :type")
List<Book> findBooksByTitleAndType(String title, String type);
}
这里,我们使用了新的 @HQL
注解(替代 @NamedQuery
这种类级别注解),直接将 HQL 查询放置在查询方法上。这会生成以下代码到 Queries_
类中:
@StaticMetamodel(Queries.class)
public abstract class Queries_ {
/**
* 执行查询 {@value #FIND_BOOKS_BY_TITLE_AND_TYPE_String_Type}。
*
* @see org.example.Queries#findBooksByTitleAndType(String, Type)
**/
public static List<Book> findBooksByTitleAndType(@Nonnull EntityManager entityManager, String title, Type type) {
return entityManager.createQuery(FIND_BOOKS_BY_TITLE_AND_TYPE_String_Type, Book.class)
.setParameter("title", title)
.setParameter("type", type)
.getResultList();
}
static final String FIND_BOOKS_BY_TITLE_AND_TYPE_String_Type =
"where title like :title and type = :type";
}
注意,生成的方法签名与我们在 Queries
接口中写下的签名略有不同:元模型生成器在参数列表中添加了一个 EntityManager
参数。
显式声明 EntityManager
参数
如果我们希望明确指定这个参数的名称和类型,可以显式声明它:
interface Queries {
@HQL("where title like :title and type = :type")
List<Book> findBooksByTitleAndType(StatelessSession session, String title, String type);
}
元模型生成器默认使用 EntityManager
作为会话类型,但我们也可以使用其他类型:
Session
StatelessSession
Mutiny.Session
(用于 Hibernate Reactive)
译者注:
EntityManager
、Session
、StatelessSession
和 Mutiny.Session
是 Hibernate 中不同的会话接口,它们都可以被元模型生成器用作会话类型,但每种类型的功能和适用场景各有不同。以下是它们的区别和为什么可以替代的详细解析:
1. EntityManager
EntityManager
是 JPA 的标准接口,用于管理持久化上下文,与数据库进行交互。
特点
- 一级缓存:维护一个持久化上下文,缓存当前事务中的实体。
- 自动脏检查:在事务提交或刷新时,自动检测实体的变化并同步到数据库。
- 标准化:作为 JPA 的一部分,具有良好的可移植性。
- 支持 JPA 特性:如
merge
、detach
、persist
等操作。
适用场景
- 适合标准 JPA 应用,特别是复杂的实体关系管理。
- 涉及延迟加载或依赖一级缓存的操作。
2. Session
Session
是 Hibernate 的核心接口,与 EntityManager
类似,但它是 Hibernate 原生的实现。
特点
- 更灵活:支持 Hibernate 特有的功能,如
getCurrentSession()
和更强大的批量操作支持。 - 更贴近数据库:与底层数据库的交互更加直接。
- 对 JPA 的支持:尽管是 Hibernate 原生接口,但可以与 JPA 特性配合使用。
适用场景
- 使用 Hibernate 特性的原生场景。
- 需要直接访问底层数据库功能的操作。
与 EntityManager 的区别
Session
是 Hibernate 特有的,提供了更多特定于 Hibernate 的功能。EntityManager
是 JPA 标准化的接口,更具通用性。
3. StatelessSession
StatelessSession
是 Hibernate 提供的无状态会话接口,用于高性能批量操作或简单查询。
特点
- 没有一级缓存:不会维护持久化上下文,所有操作直接与数据库交互。
- 无自动脏检查:所有更改需要显式提交。
- 高性能:适合大批量数据的插入、更新或删除操作,开销较低。
适用场景
- 批量插入、更新或删除操作。
- 不需要缓存或上下文管理的只读查询。
- 性能敏感的场景,例如处理海量数据时。
4. Mutiny.Session
Mutiny.Session
是 Hibernate Reactive 的核心接口,用于非阻塞式的响应式编程。
特点
- 异步非阻塞:适合响应式环境,支持 Reactor 或 Vert.x 等异步框架。
- 与 Reactive 数据库交互:支持非阻塞式的数据库操作。
- 流式数据处理:可以处理数据流并逐步消费。
适用场景
- 需要响应式架构的应用程序。
- 使用异步数据库(如 PostgreSQL Reactive Driver)的场景。
- 在高并发环境下需要更高性能的异步操作。
为什么可以替代?
尽管它们在特性上各有不同,但它们都可以被元模型生成器用作会话类型,因为:
-
核心功能相似:
- 它们都提供了与数据库交互的基本功能,如执行查询、插入、更新和删除操作。
- 元模型生成器的查询方法需要一个接口来与数据库交互,无论是
EntityManager
还是Session
,本质上都可以满足这个需求。
-
适配不同场景:
EntityManager
是 JPA 标准,适合通用的 JPA 应用。Session
提供更直接的 Hibernate 特性,适合需要自定义行为的应用。StatelessSession
适合高性能场景,如批量操作。Mutiny.Session
适合响应式编程和异步操作。
-
灵活性:
- 元模型生成器本身设计灵活,允许开发者根据具体需求选择会话类型,只需保证生成的方法能够匹配选定的会话接口即可。
对比总结
特性 | EntityManager | Session | StatelessSession | Mutiny.Session |
---|---|---|---|---|
是否为 JPA 标准 | 是 | 否 | 否 | 否 |
一级缓存 | 有 | 有 | 无 | 无 |
自动脏检查 | 有 | 有 | 无 | 无 |
高性能批量操作 | 较弱 | 一般 | 很强 | 强(异步) |
支持异步 | 否 | 否 | 否 | 是 |
适用场景 | 通用 JPA 应用 | Hibernate 原生应用 | 高性能批量处理 | 响应式编程和异步操作 |
选择哪种会话类型,取决于项目的需求和具体的使用场景。
编译时验证的价值
元模型生成器在编译时会验证:
- 抽象方法声明的参数是否与 HQL 查询的参数匹配。
- 对于命名参数
:alice
,必须存在一个名称为alice
且类型完全匹配的方法参数。 - 对于位置参数
?2
,方法的第二个参数必须完全匹配。
- 对于命名参数
- 查询是否语法合法且语义正确,即查询中引用的实体、属性和函数必须存在且类型兼容。
使用
@HQL
注解的查询方法不需要额外添加@CheckHQL
注解,Hibernate 会自动验证命名查询的合法性。
@HQL
注解有一个名为 @SQL
的“伙伴”注解,它允许我们直接编写原生 SQL 查询,而不是使用 HQL。在这种情况下,元模型生成器对查询的合法性和类型校验能力会大大降低。
您可能会疑惑,是否应该在这里使用静态方法。这个设计还有待进一步探索。
6.3 生成实例方法的查询方法
我们之前看到的静态方法生成有一个缺点:无法在不影响客户端的情况下,透明地将生成的静态方法替换为改进后的手写实现。如果查询只在一个地方被调用(这种情况非常常见),这并不是大问题,因此静态方法在这种场景下是完全可以接受的。
然而,如果查询方法在多个地方被调用,则更好的选择是将其提升为某个类或接口的实例方法。这种转换非常简单。
我们只需在 Queries
接口中添加一个用于获取会话对象的抽象 getter 方法(同时从方法参数列表中移除会话对象)。此方法可以任意命名,例如:
interface Queries {
EntityManager entityManager();
@HQL("where title like :title and type = :type")
List<Book> findBooksByTitleAndType(String title, String type);
}
在这里,我们使用了 EntityManager
作为会话类型,但也可以选择其他会话类型。
此时,元模型生成器将生成以下内容:
@StaticMetamodel(Queries.class)
public class Queries_ implements Queries {
private final @Nonnull EntityManager entityManager;
public Queries_(@Nonnull EntityManager entityManager) {
this.entityManager = entityManager;
}
public @Nonnull EntityManager entityManager() {
return entityManager;
}
/**
* Execute the query {@value #FIND_BOOKS_BY_TITLE_AND_TYPE_String_Type}.
*
* @see org.example.Queries#findBooksByTitleAndType(String,Type)
**/
@Override
public List<Book> findBooksByTitleAndType(String title, Type type) {
return entityManager.createQuery(FIND_BOOKS_BY_TITLE_AND_TYPE_String_Type, Book.class)
.setParameter("title", title)
.setParameter("type", type)
.getResultList();
}
static final String FIND_BOOKS_BY_TITLE_AND_TYPE_String_Type =
"where title like :title and type = :type";
}
调用方式也需要随之更改,例如:
Queries queries = new Queries_(entityManager);
List<Book> books = queries.findByTitleAndType(titlePattern, Type.BOOK);
如果我们希望将生成的查询方法替换为手写实现,只需将 Queries
接口中的抽象方法替换为默认方法,例如:
interface Queries {
EntityManager entityManager();
// 手写实现替换生成的实现
default List<Book> findBooksByTitleAndType(String title, String type) {
entityManager()
.createQuery("where title like :title and type = :type", Book.class)
.setParameter("title", title)
.setParameter("type", type)
.setFlushMode(FlushModeType.COMMIT)
.setMaxResults(100)
.getResultList();
}
}
6.4 生成查找方法 (Finder Methods)
在某些简单情况下,可以根据方法签名推断出查询语句,从而不需要手动编写查询。这正是查找方法(Finder Methods)的目的。查找方法是使用 @Find
注解的方法,例如:
@Find
Book getBook(String isbn);
查找方法可以有多个参数:
@Find
List<Book> getBooksByTitle(String title, Type type);
查找方法的名称是任意的,并且没有语义要求。然而:
- 返回类型 决定了要查询的实体类。
- 方法的参数必须在 名称 和 类型 上与实体类的字段完全匹配。
以第一个例子为例,Book
类中有一个持久化字段 String isbn
,因此该查找方法是合法的。如果 Book
类中不存在名为 isbn
的字段,或者字段类型不同,那么方法声明会在编译时被拒绝,并提供有意义的错误提示。同样,第二个例子也是合法的,因为 Book
类包含 String title
和 Type type
字段。
您可能会注意到,我们解决这个问题的方法与其他方式非常不同。在传统的 DAO 风格的仓储框架中,通常要求将查找方法的语义编码到方法名称中。这种思路来源于 Ruby,但我们认为它并不适合 Java。在 Java 中,这种方式显得非常不自然,而且从几乎所有角度来看,除了字符数量更少之外,这种方式都不如直接使用字符串字面量编写查询。字符串字面量至少能够包含空格和标点符号。而且,能够在不改变语义的情况下重命名一个查找方法,这显然是非常实用的 🙄。
生成的代码依赖于方法参数匹配的字段类型:
-
@Id
字段
使用EntityManager.find()
。 -
所有
@NaturalId
字段
使用Session.byNaturalId()
。 -
其他持久化字段,或字段类型的混合
使用条件查询(Criteria Query)。
生成的代码还会根据会话的类型有所不同,因为无状态会话和响应式会话的功能与普通的有状态会话略有不同。
示例:使用 EntityManager
作为会话类型
/**
* Find {@link Book} by {@link Book#isbn isbn}.
*
* @see org.example.Dao#getBook(String)
**/
@Override
public Book getBook(@Nonnull String isbn) {
return entityManager.find(Book.class, isbn);
}
/**
* Find {@link Book} by {@link Book#title title} and {@link Book#type type}.
*
* @see org.example.Dao#getBooksByTitle(String,Type)
**/
@Override
public List<Book> getBooksByTitle(String title, Type type) {
var builder = entityManager.getEntityManagerFactory().getCriteriaBuilder();
var query = builder.createQuery(Book.class);
var entity = query.from(Book.class);
query.where(
title==null
? entity.get(Book_.title).isNull()
: builder.equal(entity.get(Book_.title), title),
type==null
? entity.get(Book_.type).isNull()
: builder.equal(entity.get(Book_.type), type)
);
return entityManager.createQuery(query).getResultList();
}
查找方法甚至可以将参数与关联实体或嵌套对象的属性匹配。自然的语法应该是声明为 String publisher.name
,但由于这不是合法的 Java 写法,我们可以写成 String publisher$name
,利用一个合法但极少使用的 Java 标识符字符:
@Find
List<Book> getBooksByPublisherName(String publisher$name);
查找方法可以指定预取配置,例如:
@Find(namedFetchProfiles = Book_.FETCH_WITH_AUTHORS)
Book getBookWithAuthors(String isbn);
这种方式允许我们通过注解在 Book
类上声明哪些关联关系需要被预取(pre-fetch)。
6.5 分页和排序 (Paging and Ordering)
查询方法可以接受额外的“特殊”参数,这些参数不映射为查询的输入参数:
参数类型 | 用途 | 示例 |
---|---|---|
Page | 指定查询结果的分页 | Page.first(20) |
Order<? super E> | 指定排序字段,E 是实体类型 | Order.asc(Book_.title) |
List<Order<? super E>> | 指定多个排序字段,E 是实体类型 | List.of(Order.asc(Book_.title), Order.asc(Book_.isbn)) |
Order<Object[]> | 指定排序列,适用于返回投影结果的查询 | Order.asc(1) |
List<Object[]> | 指定多个排序列,适用于返回投影结果的查询 | List.of(Order.asc(1), Order.desc(2)) |
修改后的查询方法示例:
interface Queries {
@HQL("from Book where title like :title and type = :type")
List<Book> findBooksByTitleAndType(String title, Page page, Order<? super Book>... order);
}
调用:
List<Book> books =
Queries_.findBooksByTitleAndType(entityManager, titlePattern, Type.BOOK,
Page.page(RESULTS_PER_PAGE, page), Order.asc(Book_.isbn));
6.6 查询和查找方法的返回类型
查询方法的返回类型可以灵活定义:
返回单个实体:
@HQL("where isbn = :isbn")
Book findBookByIsbn(String isbn);
对于包含投影列表(Projection List)的查询方法,其返回值可以是 Object[]
或 List<Object[]>
类型。例如:
@HQL("select isbn, title from Book where isbn = :isbn")
Object[] findBookAttributesByIsbn(String isbn);
如果查询的选择列表(Select List)中只有一个字段,那么方法的返回类型应直接使用该字段的类型。例如:
@HQL("select title from Book where isbn = :isbn")
String getBookTitleByIsbn(String isbn);
@HQL("select local datetime")
LocalDateTime getServerDateTime();
一个返回选择列表(Selection List)的查询方法,可以将结果重新封装为记录类型(Record),如我们在“表示投影列表”部分中看到的那样,例如:
record IsbnTitle(String isbn, String title) {}
@HQL("select isbn, title from Book")
List<IsbnTitle> listIsbnAndTitleForEachBook(Page page);
查询方法可以返回 TypedQuery
或 SelectionQuery
:
@HQL("where title like :title")
SelectionQuery<Book> findBooksByTitle(String title);
在某些情况下,这种方式非常有用,因为它允许客户端对查询进行进一步的操作,例如:
List<Book> books =
Queries_.findBooksByTitle(entityManager, titlePattern)
.setOrder(Order.asc(Book_.title))
.setPage(Page.page(RESULTS_PER_PAGE, page))
.setFlushMode(FlushModeType.COMMIT)
.setReadOnly(true)
.setCacheStoreMode(CacheStoreMode.BYPASS)
.setComment("Hello world!")
.getResultList();
插入、更新和删除查询
必须返回 int
或 void
:
@HQL("delete from Book")
int deleteAllBooks();
@HQL("update Book set discontinued = true where isbn = :isbn")
void discontinueBook(String isbn);
查找方法的限制
另一方面,查找方法目前存在更多的限制。例如:
- 查找方法的返回值必须是实体类型(如
Book
),或实体类型的列表(如List<Book>
)。
正如您可能预料的,对于 Reactive 会话(如使用 Hibernate Reactive),所有的查询方法和查找方法的返回值必须是
Uni
类型。