IntelliJ IDEA
IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin
如何避免使用 JPA 和 Kotlin 时的常见陷阱
这篇博文由我与 Thorben Janssen 共同撰写,Thorben 拥有 20 余年的 JPA 和 Hibernate 经验,并且是“Hibernate Tips: More than 70 Solutions to Common Hibernate Problems”和 JPA 简报的作者。
Kotlin 和 Jakarta Persistence(也称为 JPA)是服务器端开发中备受欢迎的组合。Kotlin 提供了简洁的语法和现代语言功能 ,而 Jakarta Persistence 则为企业应用程序提供了经实践验证的持久性框架。
不过,Jakarta Persistence 最初是为 Java 设计的。Kotlin 的一些备受欢迎的功能和概念(如 null 安全和数据类)在实现业务逻辑时可以提供巨大帮助,但它们并不完全符合规范。
本文介绍的一套最佳做法可以帮助您规避问题,使用 Kotlin 和 Jakarta Persistence 构建可靠的持久性层。在深入探讨之前,要向大家分享一个好消息:IntelliJ IDEA 2026.1 将自动检测许多此类问题,通过警告高亮显示问题,并通过各种检查提供支持。
实体类设计
Jakarta Persistence 针对实体类定义了多项要求,这些要求构成了持久性提供程序管理实体对象的基础。
实体类必须:
实体类必须:
- 提供无实参构造函数
持久性提供程序使用反射调用无实参构造函数,以便在从数据库加载数据时创建实体实例。 - 具有非 final 特性
从数据库获取实体对象时,持久性提供程序会在调用无实参构造函数实例化实体对象后设置所有特性值。这一过程称为“水合”。
完成此操作后,持久性提供程序会保留对实体对象的引用,以执行自动脏检查,在检查过程中,它会检测更改,并自动更新相应的数据库记录。 - 为非 final 类
持久性提供程序通常会创建代理子类,以实现 @ManyToOne 和 @OneToOne 关系的延迟加载等功能。为此,实体类不能为 final 类。
除了这些规范要求外,以下也是广泛认可的最佳做法:
- 谨慎实现
equals、hashCode和toString
这些方法应仅依赖实体的标识符和类型,以避免在持久性上下文中出现意外行为。您可以在此处找到更好的实现方式。
这些规则在 Java 中很容易遵循,但与 Kotlin 的一些默认设置(如 final 类、不可变属性和基于构造函数的初始化)存在冲突。
以下部分将介绍如何调整 Kotlin 类以满足这些要求,同时仍能有效利用 Kotlin 的语言功能。
数据类与实体
Kotlin 的数据类旨在存储数据。它们属于 final 类,并提供多种实用方法,包括所有字段的 getter 和 setter,以及 equals、hashCode 和 toString 方法。
这让数据类非常适合 DTO,后者表示查询结果,且不受持久性提供程序管理。
以下是使用数据类获取数据的典型用例:
data class EmployeeWithCompany(val employeeName: String, val companyName: String)
val query = entityManager.createQuery("""
SELECT new com.company.kotlin.model.EmployeeWithCompany(p.name, c.name)
FROM Employee e
JOIN e.company c
WHERE p.id = :id""")
val employeeWithCompany = query.setParameter("id", 1L).singleResult;
不过,实体有所不同,它们是受管理的对象。当您将实体建模为数据类时,就会引发问题。
对于实体,持久性提供程序会自动检测更改,并对关系使用延迟加载。为支持此功能,它预期实体类遵循 Jakarta Persistence 规范中定义的要求(我们在本章开头讨论过这些要求)。
从下表中可以看出,Kotlin 的数据类并不适合用于实体类:
| Kotlin 数据类 | Jakarta Persistence 实体 | |
| 类类型 | Final | 必须为开放类(非 final 类),以便提供程序创建代理子类 |
| 构造函数 | 具有必需形参的主构造函数 | 必须提供无实参构造函数,供持久性提供程序使用 |
| 可变性 | 默认不可变(val 属性) | 必须具备可变、非 final 特性,以便持久性提供程序执行延迟加载,并检测和持久化更改 |
| equals 和 hashCode | 使用所有属性 | 应仅依靠类型和主键 |
| toString | 包含所有属性 | 应仅引用已提前加载的特性,以避免进行额外的查询 |
推荐的方式是使用普通的开放类为实体建模。这种类具有可变性,且对代理友好,同时不会引发任何与 Jakarta Persistence 相关的问题。
@Entity
open class Person {
@Id
@GeneratedValue
var id: Long? = null
var name: String? = null
}
非 final 类和无实参构造函数
如前文所述,Jakarta Persistence 要求实体类必须为非 final 类,并提供无实参构造函数。
Kotlin 的类默认为 final 类,且无需提供无实参构造函数。
但别担心,无需更改代码或以特定方式实现实体类,即可轻松满足这些要求。只需添加 no-arg 和 all-open 插件,并向依赖项添加 kotlin-reflect 即可。这样便会在构建时添加所需构造函数,并将带注解的类标记为开放类。
目前,您需要使用编译器插件 plugin.spring 和 plugin.jpa,它们会自动添加 no-arg 和 all-open 插件。使用 IntelliJ IDEA 中的 New Project(新建项目)向导或通过 start.spring.io 创建新的 Spring 项目时,IDE 会自动为您配置这两个插件。从 IntelliJ IDEA 2026.1 开始,当您向现有 Java 项目中添加 Kotlin 文件时,也会自动完成这些配置。
plugins {
kotlin("plugin.spring") version "2.2.20"
kotlin("plugin.jpa") version "2.2.20"
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
手动配置时,请务必重点关注此设置的这两部分。plugin.jpa 看似提供了所需配置,但它仅配置了 no-arg 插件,并未配置 all-open 插件。此问题将通过即将发布的 JPA 插件更新得到改进。更新后,您将无需再添加 allOpen 部分。参阅:KT-79389
可变性
作为 Kotlin 开发者,您习惯于分析信息是否可变,并根据分析结果对类进行建模。定义实体时,您可能也要采用同样的做法, 但这可能会引发潜在问题。
var 与 val
在 Kotlin 中,您使用 val 定义不可变字段或属性,使用 var 定义可变字段或属性。在底层,val 会在 Java 中编译为 final 字段。但如前文所述,Jakarta Persistence 规范要求所有字段必须为非 final。
因此,从理论上讲,您在为实体建模时不能使用 val。但如果您查看各种项目,会发现多个实体在使用 val,且未引发任何 bug。
@Entity
class Person(name: String) {
@Id
@GeneratedValue
var id: Long? = null
val name: String = name
}
这是因为,在您使用基于字段的访问时,您的 Jakarta Persistence 实现(持久性提供程序)会通过反射填充实体字段,通过 Kotlin 实现 Jakarta Persistence 实体时通常也是这种做法。final 字段也可以使用反射进行修改。因此,您的持久性提供程序可以修改 val 字段,但这与 Kotlin 的不可变性保证相矛盾。
由此可得,您实际上可以使用 val 字段为您实体类的不可变字段建模。但这并不符合 Jakarta Persistence 规范,并且您的字段不会像预期那样具有不可变性。更糟糕的是,JEP 500: Prepare to Make Final Mean Final 讨论引入警告,且今后将更改为限制通过反射修改 final 字段。这样,您便无法在实体字段中使用 val,并且会破坏许多使用 Jakarta Persistence 和 Kotlin 的持久性层。
为您的实体字段使用 val 时应多加留意,并确保您团队中的每位成员均理解其潜在影响。
从版本 2026.1 开始, IntelliJ IDEA IDE 将显示弱警告,指示当持久性提供程序(如 Hibernate 或 EclipseLink)实例化实体对象时,val 字段将被修改。
访问类型
Jakarta Persistence 规范定义了两种访问类型,用于确定持久性提供程序是通过 getter 和 setter 方法访问实体字段,还是通过反射访问。
您可以为实体类添加 @Access 注解,显式定义访问类型。也可以像大多数开发团队那样,通过映射注解的放置位置进行隐式定义:
- 为实体字段添加注解 → 字段访问 = 使用反射直接访问字段
- 为 getter 方法添加注解 → 属性访问 = 通过 getter 或 setter 方法访问
大多数 Kotlin 开发者会为属性添加注解,Hibernate 默认将此视为字段访问。
@Entity
class Company {
@Id
@GeneratedValue
var id: Long? = null
var name: String? = null
get() {
println("Getter called")
return field
}
set(value) {
println("Setter called")
field = value
}
}
在本例中,看似会调用 getter 和 setter 方法来访问 name 属性, 但这仅适用于您的业务逻辑。由于我们为字段添加了注解,持久性提供程序将使用反射直接访问这些字段,绕过 getter 和 setter 方法。
作为通用最佳做法,建议继续使用字段访问。这种方式更易于阅读,且允许持久性提供程序直接访问实体的字段。您可以随后再提供 getter 和 setter 方法来辅助业务代码,而不会影响数据库映射。
如果您要使用属性访问,可以为实体类添加 @Access(AccessType.PROPERTY) 注解,或为访问器显式添加注解:
@Entity
class Company {
@get:Id
@get:GeneratedValue
var id: Long? = null
var name: String? = null
get() {
println("Getter called")
return field
}
set(value) {
println("Setter called")
field = value
}
}
不过,当您执行此操作时,必须确保所有字段均定义为 var。Kotlin 不会为定义为 val 的字段提供 setter 方法。
@Entity
class Company {
@get:Id
@get:GeneratedValue
var id: Long? = null
val name: String? = null
}
查看上文代码段的 Kotlin 反编译字节码,您就会了解这一点。
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import kotlin.Metadata;
import org.jetbrains.annotations.Nullable;
@Entity
…
public final class Company {
@Nullable
private Long id;
@Nullable
private final String name;
@Id
@GeneratedValue
@Nullable
public final Long getId() {
return this.id;
}
public final void setId(@Nullable Long var1) {
this.id = var1;
}
@Nullable
public final String getName() {
return this.name;
}
}
您的持久性提供程序会检查每个字段是否有 getter 和 setter 方法。只要您使用 var 定义实体字段,Kotlin 便可进行属性访问。
null 安全和默认值
null 安全和默认值是 Kotlin 中两个非常受欢迎的功能,而 Java 中并没有与之对应的功能。因此,如果您想在 Jakarta Persistence 实体中使用这两个功能,必须格外注意。
为 null 性考量因素(包含主键字段)
Kotlin 允许您定义字段或属性是否支持 null 值。但遗憾的是,反射可以绕过 Kotlin 的 null 防范机制,如前文所述,持久性提供程序使用反射来初始化实体对象。
即使您将实体特性定义为不可为 null,如果数据库包含 null 值,持久性提供程序仍会将其设为 null。在您的业务代码中,这可能导致出现与 Java 中类似的运行时异常。
@Entity
@Table(name = "user")
class User(
@Id
var id: Long? = null
var name: String
)
fun testLogic(){
// Suppose the row with id = 1 has name = NULL in the database
val user = userRepository.findById(1).get()
println("Firstname: ${user.name}") // null, because Hibernate saves null via reflection
}
遗憾的是,解决这个问题并不像看起来那么简单。
您可能会认为,所有不可为 null 的实体字段都应映射到具有 not-null 约束的数据库列。因此,数据库不能包含任何 null 值。
总体而言,这是一个很棒的方式, 但并不能完全消除风险。在不同环境之间,或者在迁移过程中,约束可能会不同步。因此,强烈建议对数据库使用 not-null 约束,但这并不能绝对保证您永远不会从数据库中获取 null 值。
更糟糕的是,所有 Jakarta Persistence 实现都会调用实体类的无实参构造函数来实例化对象,随后使用反射初始化每个字段。这意味着,从技术上来讲,所有实体字段必须可为 null。
这对您的实体意味着什么? 您应使用 val 还是 var 为字段建模?
这一决定完全取决于您。两者都可以使用,但建议采用 Kotlin 方式:如果实体字段不应由业务逻辑修改,则使用 val;反之,则使用 var。不过,鉴于前文讨论的问题,还务必确保团队的所有成员都意识到:如果数据库缺少 not-null 约束,Jakarta Persistence 实现可能会将这些字段设为 null。
@Id 和生成的值
前面的段落已讨论过所有实体字段应当可为 null 的原因。不过,很多开发者认为主键特性是个例外,因为数据库要求主键必须有值,且 Jakarta Persistence 规范将其定义为不可变。一旦您将实体对象持久化到数据库中,主键就是必选且不可变的字段。但我们要快速探讨一下为何这并不意味着主键值应当不可为 null,尤其是在您使用数据库生成的主键值时。
当您要在数据库中存储一条新记录时,您会创建一个没有主键的新实体对象,并对其进行持久化处理。
遗憾的是,Jakarta Persistence 规范并未明确定义如何实现持久化操作, 但要求在未提供主键时自动生成一个。不同实现对已提供主键值的处理方式有所不同,但这属于另一篇文章的话题。
重点在于,所有持久性提供程序均将 null 视为未提供主键值。它们随后会使用数据库序列或自动增加列生成主键值,并为实体对象设置主键值。由于这种机制,主键值在实体持久化之前为 null,并在持久化操作过程中更改。
一个有趣的附加说明:在调用 persist 或 merge 方法时,Hibernate 对主键值为 0 的处理方式不同。persist 方法会抛出异常,因为它预期对象是已进行持久化处理的实体。相反,Hibernate 的 merge 方法会生成一个新的主键值,并向数据库插入一条新记录。因此,您对主键建模时可以使用默认值 0,并使用 Spring Data JPA 保存新的实体对象。默认的仓库实现会识别已设置的主键值,并调用 merge 方法,而非 persist 方法。
现在,我们继续讨论主键字段的初始化问题。
从数据库中获取实体对象时,您的持久性提供程序会使用无形参构造函数实例化新对象, 随后会通过反射设置主键值,再将该实体对象返回到您的业务代码。
这一切都明确地表明:Jakarta Persistence 规范预期主键字段是可变的,即使主键值在指定后就不允许更改。为避免不同 Jakarta Persistence 实现间的移植性问题,应使用 null 表示未定义的主键值。
@Entity
class Company {
@Id
@GeneratedValue
var id: Long? = null
}
声明默认值
Kotlin 对默认值的支持可以简化业务代码,并避免出现 null 值。
@Entity class Company( @Id @GeneratedValue var id: Long? = null, @NotNull var name: String = "John Doe", @Email var email: String = "default@email.com" )
但需要注意的是,当持久性提供程序从数据库中获取实体对象时,这些默认值将不起任何作用。
val companyFromDb = companyRepository.findById(1).get() println(companyFromDb.email) // <- If email in DB is empty, it will not set to "default@email.com"
Jakarta Persistence 规范要求提供无形参构造函数,从数据库获取实体对象时,实现会调用该构造函数。随后,实现会通过反射将从数据库检索的所有值映射到相应的实体字段。因此,将不会使用构造函数中定义的默认值,并且,尽管您预期构造函数会指定默认值,但实体对象的某些字段可能仍为未设置状态。这可能不会在您的应用程序中引发任何问题,但您和团队应当了解这一点。
注解放置
在 Java 中,注解通常会直接应用于您要添加注解的字段、方法或类。相比之下,在 Kotlin 中,可以针对不同的元素(如构造函数形参、属性或字段)添加注解。
在 Kotlin 2.2 之前,这经常引发问题,因为应用于属性的注解默认仅会应用于构造函数形参。对于 Jakarta Persistence 和验证框架,这通常会引发问题。@NotNull、@Email 甚至 @Id 等注解最终并未出现在框架期望其出现的位置, 这导致了验证失效或映射问题。
好消息是,Kotlin 2.2 已对此进行了改进。通过新增的编译器选项(IntelliJ IDEA 将建议启用此选项),注解将默认应用于构造函数形参以及属性或字段。因此,您的代码无需进行任何更改便可按预期运行。
如需了解详情,请查阅博文。
IntelliJ IDEA 前来救援!
在已经发布的 2026.1 版本中,IntelliJ IDEA 提供了检查和快速修复,以解决本文提到的多个问题,从而提升您的整体体验。新版本已经发布,请务必进行更新。以下是新版本中的一些示例:
- 高亮显示缺失的无实参构造函数或 final 实体类,并建议启用正确的 Kotlin 插件。
- 在项目中配置 Kotlin 时,自动配置所有必要的设置。
- 检测 JPA 管理的属性中的数据类和 val 字段,并进行快速修复。
还有其他 JPA 相关更新!
作者简介
本博文英文原作者:
