写点什么

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

作者:码界行者
  • 2025-11-25
    上海
  • 本文字数:7511 字

    阅读完需:约 25 分钟

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))是独立的。


  1. 避免魔法数字/字面量


  • 根据值域范围和可空性选择类型;如果列可为 NULL,使用包装类型(Integer, Boolean)。

  • 货币/精度计算 -> 使用具有适当精度/小数位数的 BigDecimal

  • 用枚举替换数字代码。使用 @Enumerated(EnumType.STRING) 持久化,并确保列长度适合名称。

  • 遗留的数字代码列:使用 AttributeConverter<Enum, Integer>不要使用 EnumType.ORDINAL


  1. 类型的一致性使用


  • 对相同的概念性列在所有地方使用相同的 Java 类型。

  • 二元标志 -> Boolean(包装类型)。领域标签化或未来可扩展的标志 -> 一致地使用枚举。

  • 一致地映射枚举(@Enumerated(EnumType.STRING), @Column(length=…));避免对同一列混合使用 String/Boolean/枚举。


  1. Lombok 的使用


  • 使用 Lombok 处理样板代码:@Getter, @Setter, @NoArgsConstructor(access = PROTECTED) 用于 JPA。

  • 避免在实体上使用 @Data (生成的 equals/hashCode/toString 可能与延迟关系和标识符冲突)。

  • 仅当需要前/后处理时才重写访问器。


  1. 重写 equals 和 hashCode


  • 仅当您需要跨上下文的值语义或在哈希集合中使用时才重写。

  • 业务键策略:比较唯一、不可变的键。

  • 基于 ID 的策略:将临时(id == null)实体视为不相等;使用稳定/恒定的 hashCode() 以避免持久化后重新计算哈希。

  • 避免全字段相等性;排除关联以防止延迟加载/递归。

  • 使用 Lombok 时,优先使用 @EqualsAndHashCode(onlyExplicitlyIncluded = true) 并显式包含稳定的标识符;对关系使用 @EqualsAndHashCode.Exclude / @ToString.Exclude

  • 在层次结构(映射的超类与子类)中保持相等性规则的一致性。


  1. 开发 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

发布于: 刚刚阅读数: 2
用户头像

码界行者

关注

分享程序人生。 2019-07-04 加入

“码”界老兵,分享程序人生。

评论

发布
暂无评论
Spring Data JPA 最佳实践【1/2】:实体设计指南_Java_码界行者_InfoQ写作社区