
Spring Data JPA(系列文章共 2 篇)
-
Spring Data JPA 最佳实践【1/2】:实体设计指南
-
Spring Data JPA 最佳实践【2/2】:存储库设计指南
在本系列文章中,我将分享我对重构一个采用了大量不良实践的大型遗留代码库的看法。为了解决这些问题并开发出更好的 Spring Data JPA 存储库,我撰写了这份指南,旨在向我之前的同事们推广良好的开发实践。本指南已更新并完全重写,以利用 Spring Data JPA 的最新特性。
有些例子可能看起来显而易见,但事实并非如此。这只是从你经验丰富的角度来看的。它们都是来自生产代码库的真实案例。
请记住,本系列文章讲解的是最新版本的 Spring Data JPA,因此可能会有一些我特别指出的细微差别。
1 设计 Spring Data JPA 存储库
Spring Data JPA 提供了几个带有预定义数据获取方法的存储库接口。我这里只提几个值得关注的:
-
Repository<T, ID>接口是 Spring Data 接口的父接口,是一个用于发现的标记接口。它没有任何方法。使用时,你只需定义你所需的内容。 -
CrudRepository接口添加了基本的 CRUD 方法以加快开发速度,它的孪生接口ListCrudRepository功能相同,但返回List而不是Iterable。 -
PagingAndSortingRepository仅添加了分页和排序功能,它也有一个返回List的孪生接口。猜猜它叫什么?等等,你说对了! -
JpaRepository是我的最爱,它包含了所有返回List的先前接口。大多数时候,我只使用这个接口。
你应该在何时使用 Repository、JpaRepository 或者介于两者之间的接口呢?我认为,如果你需要为其他开发者提供严格的 API,可以从 Repository 扩展并仅实现必要的操作,而不是授予访问全部 CRUD 操作的权限,这可能会损害你的业务逻辑。在你没有访问限制并且希望快速开发的情况下,请使用 JpaRepository。
关于 API 限制的例子:有时你可能需要处理存储在数据库中的逻辑。这涉及到大量的存储过程、逻辑中的细微差别等等。作为开发者,在处理表实体时应格外小心,因为这可能导致不可预测的行为。因此,在这种情况下,你只应设计 JPA 实体,并仅实现一个包含指定查询方法的空接口。通过这种方法,你是在向其他开发者强调,他们应该实现你所需的方法,而不是直接操作原始实体。
实际上,Spring Data JPA 存储库还有一个有趣的特点。你从 CrudRepository/JpaRepository 继承的方法默认是事务性的:读取操作使用 @Transactional(readOnly = true),写入操作使用常规的 @Transactional。
你通常不需要在接口上使用 Spring Framework 的 @Repository 注解(不要与 JPA 的接口混淆)——发现是自动的。对于可重用的基类接口,请使用 @NoRepositoryBean 注解。
扩展这些接口之一会告知 Spring Data JPA 它应该为你的接口生成一个实现。例如:
public interface CompanyRepository extends JpaRepository<Company, Long> {
// 自定义方法将添加在这里
}
2 在存储库中使用查询
使用 Spring Data JPA 存储库查询数据主要有两种方法。实际上不止两种,但我们先关注更流行的(依我看来)。
-
从方法名派生查询。Spring 解析方法名并生成相应的 JPQL。这加快了开发速度,并且对于简单条件来说很直观。
-
使用
@Query注解显式编写查询。这种方法更灵活,允许你使用 JPQL 或原生 SQL。在最新版本的 Spring Data 中,你可以使用@NativeQuery注解来代替传递nativeQuery = true。
对于数据修改查询(UPDATE/DELETE),需要添加 @Modifying,并确保存在事务边界——要么在存储库方法或类上使用 @Transactional 注解,要么从 @Transactional 服务中调用它。
使用两种方法的示例:
// 派生查询
List<Employee> findByDepartmentIdAndActiveTrue(Long departmentId);
// 显式 JPQL 查询
@Query("SELECT e FROM Employee e WHERE e.department.id = :deptId AND e.active = true")
List<Employee> findActiveEmployees(@Param("deptId") Long departmentId);
// 原生 SQL 查询
@Modifying
@Transactional
@NativeQuery(value = "UPDATE employee SET active = false WHERE id = :id")
void deactivateEmployee(@Param("id") Long id);
在上面的例子中,前两个方法是选择查询。最后一个是更新(停用)操作,其目的与选择查询不同。
第一种方法缩短了开发查询所需的时间并且很直观。第二个例子在创建用于操作数据库的方法时提供了额外的能力,允许你使用 JPQL 和原生 SQL 编写查询。
如前所述,继承的数据修改方法默认标记为 @Transactional。对于自定义的修改查询,请使用 @Modifying 注解,并确保存在事务边界(在方法或类上,或在服务层)。
3 Spring Data JPA 投影
对来自数据库的原始实体进行操作可能不切实际或不安全。在应用程序中检索完整实体并进行操作或许可以接受,但更好的做法是调整你的查询,使其仅返回必要的信息。
为了解决这个问题,你应该利用 Spring Data JPA 投影,它能够定义数据库中的数据将如何呈现。在上面描述的示例中,Spring Data JPA 投影仅返回调用者所需的选定属性。
Spring Data JPA 提供以下类型的投影:
-
通过接口定义的投影,也称为基于接口的投影
-
到 DTO 对象的投影。请阅读关于 Spring Data JPA 的系列文章中关于开发 DTO 的指南。
-
动态投影。
基于接口的投影允许你创建只读投影,以便安全地呈现来自数据库的数据。这种方法通常在不需要操作创建的对象,而仅用于显示数据时使用。请注意,访问嵌套属性可能导致连接和额外的查询,因此投影并不总是比获取实体快。务必检查生成的 SQL 以确保最佳性能。
例如,一个基于接口的 Spring Data JPA 投影:
public interface EmployeeView {
String getFirstName();
String getLastName();
BigDecimal getSalary();
}
List<EmployeeView> findBySalaryGreaterThan(BigDecimal amount);
基于 DTO 的投影允许将数据投影到 Java 类上,使你可以使用具体的 DTO 对象而不是接口。对于派生的查询方法,Spring 可以通过其构造函数将结果映射到 DTO,而对于 @Query JPQL,则需要使用构造函数表达式。基于类的投影需要一个单一的全参数构造函数;如果有多个构造函数,请使用 @PersistenceCreator 注解标记目标构造函数。
public class EmployeeDto {
private final String firstName;
private final String lastName;
private final BigDecimal salary;
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public BigDecimal getSalary() { return salary; }
public EmployeeDto(String firstName, String lastName, BigDecimal salary) {
this.firstName = firstName;
this.lastName = lastName;
this.salary = salary;
}
}
@Query("SELECT new com.example.EmployeeDto(e.firstName, e.lastName, e.salary) FROM Employee e WHERE e.salary > :amount")
List<EmployeeDto> findHighEarningEmployees(@Param("amount") BigDecimal amount);
你可以将动态投影与存储库一起使用,以公开一个通用方法,允许调用者在运行时选择投影类型。Class 参数用于选择投影类型。如果你需要将 Class 传递到查询本身中,请使用不同的参数,以免它被用作投影选择器。
当将 DTO 类与动态投影一起使用时,请确保查询提供了构造函数参数(例如,通过 JPQL 构造函数表达式);否则,调用将在运行时失败。
<T> List<T> findBySalaryGreaterThan(BigDecimal amount, Class<T> type);
// 用法:
repo.findBySalaryGreaterThan(new BigDecimal("1000"), EmployeeView.class); // 接口投影
repo.findBySalaryGreaterThan(new BigDecimal("1000"), EmployeeDto.class); // DTO 类投影(需要查询支持)
4 有效使用存储库方法
如前所述,存储库 CRUD 方法默认在事务中运行(读取操作为 readOnly = true,写入操作为常规事务)。关于事务的另一点是避免在调用点手动开启事务。
当对多个实体执行操作时,优先使用批量方法,如 saveAll(),而不是在循环中调用 save()。将操作分组到单个查询中可以减少数据库的往返次数。
优先使用面向批量的写入,但请注意 saveAll() 本身并不会发出单个 SQL 语句。为了实际减少往返次数,需要启用 JDBC 批处理(例如,设置 spring.jpa.properties.hibernate.jdbc.batch_size=50,并且通常设置 hibernate.order_inserts=true/hibernate.order_updates=true)。如果需要插入批处理,请避免使用 GenerationType.IDENTITY,对于非常大的批次,请定期调用 flush()/clear()。
只要可能,将逻辑合并到单个查询中,而不是在 Java 中执行多个查询。在某些情况下,使用 SQL 将部分算法卸载到数据库更高效。
对于大型结果集,使用分页。Page<T> 返回内容加总数,并触发计数查询(对于自定义的 @Query,需要提供 countQuery),Slice<T> 返回内容以及是否有下一个分片(不进行计数查询),而带有 Pageable 参数的 List<T> 应用 limit/offset 但不提供元数据。
// 1) 带有 Page 和排序的派生查询
interface UserRepository extends JpaRepository<User, Long> {
Page<User> findByActive(boolean active, Pageable pageable);
}
// 用法:
Pageable pageable = PageRequest.of(0, 20, Sort.by("createdAt").descending());
Page<User> page = userRepository.findByActive(true, pageable);
List<User> users = page.getContent();
long total = page.getTotalElements();
boolean last = page.isLast();
// 2) 使用 Slice 进行无限滚动(无计数查询)
interface UserRepository extends JpaRepository<User, Long> {
Slice<User> findByActive(boolean active, Pageable pageable);
}
5 存储库中的存储过程
在开发面向数据库的应用程序时,你可以使用 Spring Data JPA 调用数据库中定义的存储过程。有多种方法可以实现。
第一种方法是使用 @NamedStoredProcedureQuery:
-
在实体上使用
@NamedStoredProcedureQuery声明它,指定: -
name– JPA 使用的标识符, -
procedureName– 数据库中存储过程的实际名称, -
parameters–@StoredProcedureParameter对象数组,定义每个参数的模式(IN/OUT)、名称和 Java 类型。 -
在存储库中添加一个方法,并使用
@Procedure注解,引用声明的名称。
对于多个输出参数,当调用由 @NamedStoredProcedureQuery 支持时,Spring Data JPA 可以返回一个 Map<String,Object>。对于单个输出,可以直接返回该值。@Procedure 上还有一个 outputParameterName 属性用于定位特定的输出参数。
在实体上的声明示例:
@NamedStoredProcedureQuery(
name = "Employee.raiseSalary",
procedureName = "raise_employee_salary",
parameters = {
@StoredProcedureParameter(mode = ParameterMode.IN, name = "in_employee_id", type = Long.class),
@StoredProcedureParameter(mode = ParameterMode.IN, name = "in_increase", type = BigDecimal.class),
@StoredProcedureParameter(mode = ParameterMode.OUT, name = "out_new_salary", type = BigDecimal.class)
}
)
@Entity
public class Employee { … }
存储库方法:
@Procedure(name = "Employee.raiseSalary")
BigDecimal raiseSalary(@Param("in_employee_id") Long id,
@Param("in_increase") BigDecimal increase);
第二种方法是不定义 JPA 元数据,直接在存储库方法上使用 @Procedure(procedureName = "…"),甚至通过 @Query(value = "CALL proc(:arg…)", nativeQuery = true) 来调用。
实际上,还有一种方法,但不太规范,就是使用实体管理器调用存储过程,本文不会涵盖这种做法,因为它将在本系列的下一篇文章(也是最后一篇)中讨论。
6 Spring Data JPA 存储库速查表
为了简要总结本设计指南,你可以使用以下速查表。
6.1 选择哪种 Spring Data JPA 存储库?
要扩展的接口
-
Repository<T, ID>— 仅作为标记;你需要自己定义每个方法。 -
CrudRepository<T, ID>— 基本 CRUD;返回Iterable集合。 -
ListCrudRepository<T, ID>— 类似CrudRepository,但返回List集合。 -
PagingAndSortingRepository<T, ID>— 添加分页和排序。 -
ListPagingAndSortingRepository<T, ID>— 返回List的孪生接口。 -
JpaRepository<T, ID>— 包含以上所有功能 + JPA 的便利功能(flush、批量删除等)。大多数应用程序中的默认选择。
何时选择哪个
-
需要严格、最小化的 API?扩展
Repository(或一个精简的基类)并仅暴露允许的方法。 -
需要开发速度?扩展
JpaRepository。
发现与基础配置
-
存储库接口上不需要
@Repository;Spring 通过类型检测它们。 -
对于可重用的基类接口,使用
@NoRepositoryBean注解。 -
默认实现由
SimpleJpaRepository支持。
事务(默认)
-
默认值适用于继承的 CRUD 方法:读取使用
@Transactional(readOnly = true),写入使用常规@Transactional。 -
你自己的查询方法(派生名称或
@Query)默认不是事务性的;需要注解它们或从事务性服务中调用。
6.2 如何使用 Spring Data JPA 查询数据?
两种核心方法
-
派生查询(通过方法名)适用于简单条件。
-
显式查询 使用
@Query(JPQL)或通过@Query(..., nativeQuery = true)或@NativeQuery(现代快捷方式;支持如sqlResultSetMapping等额外功能)进行的原生查询。
修改查询
-
添加
@Modifying并确保存在事务边界(在方法/类上使用@Transactional或从事务性服务中调用)。
使用自定义查询进行分页
-
对于
Page<T>和复杂的 JPQL/原生查询,提供一个显式的countQuery(或countProjection)以避免脆弱的自动计数。
6.3 使用 Spring Data JPA 投影的最佳方式
类型
-
基于接口的投影 — 用于安全数据呈现的只读视图。
-
DTO/基于类的投影 — 映射到具有单个全参数构造函数的类(如果存在多个构造函数,请使用
@PersistenceCreator)。 -
动态投影 — 公开一个通用方法,让调用者传递
Class<T>以在运行时选择投影类型。
注意
-
在投影中访问嵌套属性可能触发连接。投影并不自动比实体快。检查 SQL 和返回的列,并测量查询性能。
-
当将 DTO 与动态投影一起使用时,确保查询提供构造函数参数(例如,通过 JPQL 构造函数表达式)。
6.4 关于有效使用查询的简要说明
批处理与往返次数
-
优先使用
saveAll(...)而不是重复的save(...)。 -
如果需要插入批处理,请避免使用
GenerationType.IDENTITY。优先选择序列/池化优化器。 -
对于非常大的批次,定期调用
flush()/clear()。
让数据库工作
-
尽可能将面向集合的逻辑推入单个查询,而不是多步骤的 Java 循环。
分页选项
-
Page<T>— 内容 + 总数(触发计数查询)。 -
Slice<T>— 内容 + "是否有下一页"(无计数查询,适用于无限滚动)。 -
List<T>带Pageable参数 — 应用 limit/offset,无元数据。
6.5 从 Spring Data JPA 调用存储过程
方法
-
命名存储过程:在实体上使用
@NamedStoredProcedureQuery声明,然后通过使用@Procedure(name = "...")注解的存储库方法调用。 -
直接调用(无实体元数据):在存储库方法上使用
@Procedure(procedureName = "..."),或使用@Query(value = "CALL ...", nativeQuery = true)调用。
输出
-
多个 OUT 参数(使用命名存储过程)可以作为
Map<String,Object>返回。 -
单个 OUT 可以直接返回,或者使用
@Procedure上的outputParameterName来定位特定的输出参数。
Spring Data JPA(系列文章共 2 篇)
-
Spring Data JPA 最佳实践【1/2】:实体设计指南
-
Spring Data JPA 最佳实践【2/2】:存储库设计指南
【注】本文译自:Spring Data JPA Best Practices: Repositories Design Guide