Spring Data JPA 最佳实践【2/2】:存储库设计指南

Spring Data JPA(系列文章共 2 篇)

  1. Spring Data JPA 最佳实践【1/2】:实体设计指南
  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 的先前接口。大多数时候,我只使用这个接口。

你应该在何时使用 RepositoryJpaRepository 或者介于两者之间的接口呢?我认为,如果你需要为其他开发者提供严格的 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 篇)

  1. Spring Data JPA 最佳实践【1/2】:实体设计指南
  2. Spring Data JPA 最佳实践【2/2】:存储库设计指南

【注】本文译自:Spring Data JPA Best Practices: Repositories Design Guide

Spring Data JPA 最佳实践【1/2】:实体设计指南

Spring Data JPA(系列文章共 2 篇)

  1. Spring Data JPA 最佳实践【1/2】:实体设计指南
  2. Spring Data JPA 最佳实践【2/2】:存储库设计指南

这一系列文章是我在审查一个包含大量不良实践的大型遗留代码库时撰写的总结。为了解决这些问题,我创建了这份指南,旨在向我之前的同事推广 Spring Data JPA 在设计实体方面的最佳实践。

现在是将这份指南从尘封中取出、更新并发布给更广泛受众的时候了。该指南内容详实,我决定将其拆分为两篇独立的文章。

文中的一些示例可能看起来显而易见,但事实并非如此——这只是从您经验丰富的角度得出的看法。它们都来自生产代码库中的真实案例。

1 深入 Spring Data JPA

为了便捷快速地开发数据库驱动的软件,推荐使用以下库和框架:

  • Spring Boot — 通过提供自动配置、起步依赖和约定优于配置的默认值(例如,内嵌服务器、Actuator),简化了在 Spring 框架之上构建 Web 应用程序的过程。它利用了 Spring 现有的依赖注入模型,而非引入新的模型。
  • Spring Data JPA 在为数据库操作创建存储库时节省时间。它提供了现成的接口用于 CRUD 操作、事务管理以及通过注解或方法名定义查询。另一个优势是其与 Spring 上下文的集成,以及依赖注入带来的相应好处。
  • Lombok – 通过生成 getter、setter 和其他重复性代码,减少了样板代码。

实体代表数据库表中的行。它们是使用 @Entity 和其他 JPA 注解标注的普通 Java 对象。DTO(数据传输对象) 是普通 Java 对象,用于以相较于底层实体受限或转换后的形式呈现数据。

在 Spring 应用程序中,存储库 是一种特殊的接口,提供对数据库/数据的访问。这类存储库通常使用 @Repository 注解,但实际上,当您继承自 JpaRepositoryCrudRepository 或其他 Spring Data JPA 存储库时,无需单独标注。如果您不继承 Spring Data 的基础接口,可以使用 @RepositoryDefinition。此外,在共享的基础接口上使用 @NoRepositoryBean

服务 是封装业务逻辑和功能的特殊类。控制器 是您应用程序的端点;用户与控制器交互,控制器继而注入服务而非存储库。

为清晰起见,您的项目应按职责或其他方式组织成不同的包。代码组织是一个好话题,但总是依赖于您的服务、代码约定等。给出的示例代表一个具有单一业务领域的微服务。

  • entity – 数据库实体,
  • repository – 数据访问存储库,
  • service – 服务,包括存储过程的包装器,
  • controller – 应用程序端点,
  • dtos – DTO 类。

当 Spring Boot 应用程序启动时,基于 application.properties/application.yml 中的配置,到数据库的连接会被自动配置。常见属性包括:

  • spring.datasource.url – 数据库连接 URL
  • spring.datasource.driver-class-name – 数据库驱动类,Spring Boot 通常可以从 JDBC URL 推断出它,仅在推断失败时设置此属性。
  • spring.jpa.database-platform – 要使用的 SQL 方言
  • spring.jpa.hibernate.ddl-auto – Hibernate 应如何创建数据库模式,可用值:none|validate|update|create|create-drop

2 使用 Spring Data JPA 开发实体

在设计与数据库交互的软件时,正确使用 Java 持久化 API(JPA)注解的简单 Java 对象起着至关重要的作用。这类对象通常包含映射到表列的字段,被称为实体。并非每个字段都是一对一映射的:关系、嵌入的值对象和 @Transient 字段都很常见。

至少,一个实体类必须使用 @Entity 注解来标记该类为数据库实体,并使用 @Id@EmbeddedId 声明一个主键。JPA 还要求一个无参构造函数(public 或 protected)。包含 @Table 以显式定义目标表也是一个好习惯。@Table 注解是可选的,当您需要覆盖默认表名时使用它。

使用 @Entity 注解时,最好设置 name 属性,因为此名称用于 JPQL 查询。如果省略它,JPQL 将使用简单的类名,设置它可以解耦查询与重构.

还有一个有用的注解 @Table,可以在表名与命名策略不同时帮助您选择表名。

以下示例演示了不好和好的用法:

@Entity
@Table(name = "COMPANY")
public class CompanyEntity {
    // 字段省略
}

// 后续使用:
Query q = entityManager.createQuery("FROM " + CompanyEntity.class.getSimpleName() + " c")

这里,@Entity 上缺少 name 属性,因此在查询中使用类名。这可能在重构时导致代码脆弱。这里还有另一个问题:它使用了 entityManager 而不是预配置的 Spring Data JPA 存储库。entityManager 提供了更多的灵活性,但也让您可能在代码库中制造混乱,而不是使用更可取的数据获取方式。

您发现这里还有一个不良实践了吗?没错,就是使用字符串拼接来构建查询。在这种情况下,它不会导致 SQL 注入,但最好避免这种方法,尤其是在像这样将用户输入传递给查询时。

@Entity(name = "Company")
@Table(name = "COMPANY")
public class CompanyEntity {
    // 字段省略
}

// 后续使用:
Query q = entityManager.createQuery("FROM Company c");

在改进版本中,显式指定了实体名称,因此 JPQL 查询可以通过名称引用实体,而不必依赖类名。

注意:JPQL 实体名称(@Entity(name))和 @Table 中的物理表名是两个独立的概念。

3 避免魔法数字/字面量

明智地选择字段的类型:

  • 如果字段代表数字枚举,则使用 Integer 或适当的小型数值类型。
  • 如果选择类型,则基于值域范围和可空性(如果列可为空,则使用包装类型,如 Integer);并记住,在 JPA 中,较小的数值类型很少带来实际好处。
  • 如果值是货币或需要精确计算,则使用具有适当精度/小数位数的 BigDecimal
  • 如果您需要关于枚举的详细信息,将在后面介绍。

例如,假设一个字段 statusCode 代表公司的状态。使用数字类型并在注释中记录每个值的含义,会导致代码难以阅读且容易出错:

// 公司状态:
// 1 – 活跃
// 2 – 暂停
// 3 – 解散
// 4 – 合并
@Column(name = "STATUS_CODE")
private Long statusCode;

相反,应创建一个枚举并将其用作字段的类型。这使得代码自文档化并减少了出错的机会。在使用 Spring Data JPA 持久化枚举时,请指定其存储方式,这是一个好习惯。优先使用 @Enumerated(EnumType.STRING),这样数据库中包含的是可读的名称,并且您不会因常量重新排序而受影响。同时,确保列类型/长度适合枚举名称(如果需要,设置 lengthcolumnDefinition)。

// 存储为可读名称;确保列能容纳它们(例如,length = 32)。
@Column(name = "STATUS", length = 32)
@Enumerated(EnumType.STRING)
private CompanyStatus status;

public enum CompanyStatus {
    /** 活跃公司 */           ACTIVE,
    /** 暂时暂停 */    SUSPENDED,
    /** 正式解散 */     DISSOLVED,
    /** 合并到其他组织 */  MERGED;
}

如果您现有的列存储数字代码(例如 1–4)且必须保持为数字,不要使用 EnumType.ORDINAL(它写入的是基于 0 的序号,与 1–4 不匹配)。使用 AttributeConverter<CompanyStatus, Integer> 将显式代码映射到枚举值:

@Converter(autoApply = false)
public class CompanyStatusConverter implements AttributeConverter<CompanyStatus, Integer> {
    @Override
    public Integer convertToDatabaseColumn(CompanyStatus v) {
        if (v == null) return null;
        return switch (v) {
            case ACTIVE    -> 1;
            case SUSPENDED -> 2;
            case DISSOLVED -> 3;
            case MERGED    -> 4;
        };
    }

    @Override
    public CompanyStatus convertToEntityAttribute(Integer db) {
        if (db == null) return null;
        return switch (db) {
            case 1 -> CompanyStatus.ACTIVE;
            case 2 -> CompanyStatus.SUSPENDED;
            case 3 -> CompanyStatus.DISSOLVED;
            case 4 -> CompanyStatus.MERGED;
            default -> throw new IllegalArgumentException("未知 STATUS_CODE: " + db);
        };
    }
}

// 在列中保持数字 1..4,同时在 Java 中暴露类型安全的枚举。
@Column(name = "STATUS_CODE")
@Convert(converter = CompanyStatusConverter.class)
private CompanyStatus status;

4 类型的一致性使用

如果一个字段在多个实体中使用,请确保它在各处具有相同的类型。对概念上相同的字段使用不同的类型会导致业务逻辑不明确。例如,以下不好的用法展示了两个代表布尔标志但使用不同类型和名称的字段:

// 对逻辑相同的字段选择了不好的类型
// A – 自动, M – 手动
@Column(name = "WAY_FLG")
private String wayFlg;

@Column(name = "WAY_FLG")
private Boolean wayFlg;

更好的选择是对两个字段都使用 Boolean,或者,如果您需要两个以上的值,或者这两个值是带有领域标签的(例如,Automatic/Manual),则对两个字段都使用枚举。如果它确实是二元的 是/否,使用 Boolean(对于可空列使用包装类型)即可。否则,为了清晰性和面向未来,优先使用枚举。以下是不使用转换器的一致性映射示例:

// 两个带标签的状态:为了清晰,优先使用枚举
public enum WayMode { A, M } // 或 AUTOMATIC, MANUAL

// 在每个涉及 WAY_FLG 的实体中使用相同的映射
@Column(name = "WAY_FLG", length = 1) // 确保长度适合枚举名称
@Enumerated(EnumType.STRING)
private WayMode wayFlg;

// 真正的二元情况(例如,活跃/非活跃):
@Column(name = "IS_ACTIVE")
private Boolean active; // 如果列可为 NULL,则使用包装类型

本文有意省略了关于 Spring Data JPA 中表关系部分,因为这是一个广泛的主题,值得另写一篇关于最佳实践的文章。

5 Lombok 的使用

为了减少样板源代码的数量,推荐使用 Lombok 进行代码生成——但应明智地使用。生成 getter 和 setter 是一个理想的选择。最好坚持这种做法,并且仅在需要某些预处理时才重写 getter 和 setter。

对于 JPA,确保存在无参构造函数。使用 Lombok,您可以添加 @NoArgsConstructor(access = AccessLevel.PROTECTED) 来清晰地满足规范。

警告提示:避免在实体上使用 @Data,因为它生成的 equals/hashCode/toString 可能与 JPA 产生问题(延迟关系、可变标识符)。优先使用针对性的注解@Getter, @Setter, @NoArgsConstructor),并且如果需要,使用 @EqualsAndHashCode(onlyExplicitlyIncluded = true) 和排除关联字段来显式定义相等性。下文将详细说明。

此外,Lombok 支持以下常用注解。您可以在其网站上找到完整列表:https://projectlombok.org/

6 重写 equals 和 hashCode

在数据库实体中重写 equalshashCode 时,会出现许多问题。例如,许多应用程序使用从 Object 继承的标准方法也能正常工作。

上下文:在单个持久化上下文中,Spring Data JPA/Hibernate 已经确保了标识语义(相同的数据库行 -> 相同的 Java 实例)。通常只有在跨上下文依赖值语义或在哈希集合中使用时,才需要自定义 equals/hashCode

数据库实体通常代表现实世界的对象,您可以选择不同的方式来重写:

  • 基于实体的主键(它是不可变的)。细微差别:如果 ID 是数据库生成的,则在持久化/刷新之前它为 null。需要处理临时状态,以免对象在哈希集合中时哈希值发生改变。
  • 基于业务键(例如,员工的税号/INN),因为它不依赖于数据库实现。细微差别:如果键是唯一、不可变且始终可用的,则效果很好;避免使用可变字段/关联。
  • 基于所有字段。不安全:可变数据、潜在的延迟加载、通过关联的递归以及性能成本,使得这对于 JPA 实体来说很脆弱。

什么时候应该重写 equalshashCode

  • 当对象在 Map 中用作键时。细微差别:当对象位于哈希结构内部时,不要修改被 hashCode 使用的字段。
  • 当使用仅存储唯一对象的结构时(例如 Set)。细微差别:同样的注意事项——修改相等性/重要字段会破坏集合的不变性。
  • 当需要比较数据库实体时。细微差别:通常比较标识符就足够了;如果标识比较符合您的用例,则重写不是强制性的。

综上所述,您应该谨慎使用 Lombok 的 @EqualsAndHashCode@Data,因为除非另行配置,否则 Lombok 会为所有字段生成这些方法。

扩展说明:优先使用 @EqualsAndHashCode(onlyExplicitlyIncluded = true) 并仅标记稳定的标识符/业务键;避免在实体上使用 @Data(它生成的 equals/hashCode/toString 可能与延迟关系产生不良交互)。您还可以使用 @EqualsAndHashCode.Exclude / @ToString.Exclude 将关联从相等性或 toString 中排除。

继承的细微差别:如果在映射的超类中定义了相等性,请确保规则对所有子类一致,并且与整个层次结构的标识定义方式相匹配。

A) 业务键相等性(当键唯一且不可变时安全)

public class Employee {
    private String taxId; // 自然键:唯一且不可变

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false; // 这里保持简单
        Employee other = (Employee) o;
        return taxId != null && taxId.equals(other.taxId);
    }

    @Override
    public int hashCode() {
        return (taxId == null) ? 0 : taxId.hashCode();
    }
}

B) 基于 ID 的相等性(处理临时状态;避免哈希变化)

public class Order {
    private Long id; // 数据库生成

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Order other = (Order) o;
        // 临时实体 (id == null) 除了自身外,不等于任何东西
        return id != null && id.equals(other.id);
    }

    @Override
    public int hashCode() {
        // 返回常量,避免在后续分配 ID 后重新计算哈希值
        return getClass().hashCode();
    }
}

C) Lombok 模式(显式包含;避免全字段默认)

@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Customer {
    @EqualsAndHashCode.Include
    private String externalId; // 稳定的业务键

    // 排除关联和可变细节
    // @EqualsAndHashCode.Exclude private List<Order> orders;
}

7 开发 DTO

DTO(数据传输对象) 是专门设计的对象,用于向客户端呈现数据,因为将原始数据库实体直接发送给客户端被认为是一种不良实践。有些团队确实会在内部边界传递实体,但对于公开/面向客户端的 API,优先使用 DTO 以避免泄露持久化细节。

创建各种 DTO 会增加开发和维护时间。如果使用像 ModelMapper 这样的库,对象映射还会带来内存开销。

DTO 的另一个特性是通过传输更少的数据量来减少网络传输的数据量,并通过请求更少的字段来降低 DBMS 的负载。最重要的是,只有当您确实选择了更少的列时(使用构造函数表达式、Spring Data JPA 投影或仅返回所需字段的本机查询),您才能真正减少数据库负载。获取完整实体然后进行映射不会减少选择的列数,这是显而易见的。

设计 DTO 有不同的方式:

  • 使用类(对象)。对于外部 API(序列化、验证、文档),类或 Java record 通常更清晰。
  • 使用接口。接口适用于 Spring Data 基于接口的投影(只读、仅有 getter 的视图),而不适用于写入模型。

将实体对象转换为 DTO 有不同的方式:

  • 最优方法是将数据从数据库直接投影到所需的 DTO 中。这既避免了额外的映射工作,又确保选择了更少的列。
  • 您也可以使用像 ModelMapper 这样的库。优先考虑 MapStruct(编译时代码生成,运行时更快,映射明确)。
  • 您也可以编写自己的对象转换器。手写映射器提供了完全的控制,但增加了维护需求。

开发 DTO 的良好实践:

  • 优先为每个用例设计特定用途的 DTO(例如,Summary/Detail/ListItem;CreateRequest 与 Response)。
  • 避免使用一个与实体绑定的巨型 DTO,这会导致过度获取和紧耦合。

8 Spring Data JPA 总结性最佳实践

  1. 使用 JPA 注解开发实体

    • 实体将字段映射到列;关系、可嵌入对象和 @Transient 字段很常见(不总是 1:1)。
    • 最低要求:@Entity + 主键(@Id / @EmbeddedId)+ 无参构造函数(public/protected)。
    • 仅在使用 @Table 覆盖默认值(表、模式、约束)时使用。
    • 优先使用显式的 @Entity(name="…") 以将 JPQL 与类名解耦,使得 JPQL 在类重命名时保持稳定。
    • 避免在 JPQL 中使用字符串拼接,使用参数。
    • JPQL 实体名称(@Entity(name))和物理表名称(@Table(name))是独立的。
  2. 避免魔法数字/字面量

    • 根据值域范围和可空性选择类型;如果列可为 NULL,使用包装类型(Integer, Boolean)。
    • 货币/精度计算 -> 使用具有适当精度/小数位数的 BigDecimal
    • 用枚举替换数字代码。使用 @Enumerated(EnumType.STRING) 持久化,并确保列长度适合名称。
    • 遗留的数字代码列:使用 AttributeConverter<Enum, Integer>不要使用 EnumType.ORDINAL
  3. 类型的一致性使用

    • 对相同的概念性列在所有地方使用相同的 Java 类型。
    • 二元标志 -> Boolean(包装类型)。领域标签化或未来可扩展的标志 -> 一致地使用枚举。
    • 一致地映射枚举(@Enumerated(EnumType.STRING), @Column(length=…));避免对同一列混合使用 String/Boolean/枚举。
  4. Lombok 的使用

    • 使用 Lombok 处理样板代码:@Getter, @Setter, @NoArgsConstructor(access = PROTECTED) 用于 JPA。
    • 避免在实体上使用 @Data (生成的 equals/hashCode/toString 可能与延迟关系和标识符冲突)。
    • 仅当需要前/后处理时才重写访问器。
  5. 重写 equals 和 hashCode

    • 仅当您需要跨上下文的值语义或在哈希集合中使用时才重写。
    • 业务键策略:比较唯一、不可变的键。
    • 基于 ID 的策略:将临时(id == null)实体视为不相等;使用稳定/恒定的 hashCode() 以避免持久化后重新计算哈希。
    • 避免全字段相等性;排除关联以防止延迟加载/递归。
    • 使用 Lombok 时,优先使用 @EqualsAndHashCode(onlyExplicitlyIncluded = true) 并显式包含稳定的标识符;对关系使用 @EqualsAndHashCode.Exclude / @ToString.Exclude
    • 在层次结构(映射的超类与子类)中保持相等性规则的一致性。
  6. 开发 DTO

    • 不要向客户端暴露实体,即使您使用 @JsonIgnore 注解返回它们;设计特定用途的 DTO(Summary/Detail/ListItem;Create/Update/Response)。
    • 通过选择更少的列来减少数据库负载:直接投影到 DTO(使用构造函数表达式),利用基于接口的投影,或使用仅返回必要字段的本机查询。
    • 映射完整实体不会减少选择的列数
    • 优先使用 MapStruct(编译时、快速、明确)而不是 ModelMapper;手写映射器以更高的维护成本提供控制。

最后

希望您觉得这篇文章有帮助。如果您对 Spring Data JPA 感兴趣,请阅读下一篇文章:"Spring Data JPA 最佳实践:存储库设计指南"


【注】本文译自:Spring Data JPA Best Practices: Entity Design Guide