Kotlin 遇上 JPA:当「优雅」撞上「反射」

1 阅读4分钟

你以为用 Kotlin 写 JPA 实体很简单?data class 一把梭?抱歉,这可能是你踩坑的开始。

开篇:一个让人困惑的现象

// 看起来完美的代码
@Entity
data class User(
    @Id @GeneratedValue
    val id: Long = 0,
    val name: String,
    val email: String
)

这段代码能编译,能运行,但它埋了至少 4 个雷

很多从 Java 转 Kotlin 的开发者,第一反应就是用 data class 来写实体类——毕竟 Kotlin 官方都说它适合做「数据载体」。但 JPA 的世界观和 Kotlin 完全不同。

今天我们就来聊聊:为什么 Kotlin 的「优雅」在 JPA 面前会失效?


核心矛盾:两种设计哲学的碰撞

Kotlin 的信仰JPA 的需求
不可变性 (val)可变性(反射修改字段)
空安全 (String vs String?)反射绕过空检查
data class 简洁需要非 final 类支持代理
构造函数初始化无参构造 + 反射赋值

JPA 规范(Jakarta Persistence)明确要求实体类必须:

  • 提供无参构造函数(用于反射实例化)
  • 属性不能是 final(支持脏检查和懒加载代理)
  • 不能是 final(支持生成代理子类)

而 Kotlin 的 data class 恰好全部违反——它默认是 final 的,属性通常用 val,也没有无参构造。


陷阱一:data class 不是实体类的正确选择

// ❌ 错误示范
@Entity
data class Company(
    @Id @GeneratedValue
    val id: Long = 0,
    val name: String
)

问题

  1. data class 是 final 的,Hibernate 无法生成代理类
  2. 懒加载会失效
  3. equals() / hashCode() 基于所有属性,而非主键

正确做法:使用普通 class

// ✅ 正确示范
@Entity
class Company {
    @Id @GeneratedValue
    var id: Long? = null

    var name: String = ""
}

陷阱二:val 看似能用,实则是定时炸弹

你可能发现用 val 也能正常运行:

@Entity
class User {
    @Id @GeneratedValue
    val id: Long? = null  // 看起来没问题?

    val name: String = ""
}

真相:JPA 通过反射强行修改 val 字段,这违反了 Kotlin 的不可变性契约。

更危险的是:Java 的 JEP 500 提案正在考虑限制反射修改 final 字段。一旦实施,你的代码会直接崩溃。

结论:所有实体属性都应该用 var,不要心存侥幸。


陷阱三:主键必须是可空类型

这是很多人忽略的细节:

// ❌ 错误
@Id @GeneratedValue
var id: Long = 0

// ✅ 正确
@Id @GeneratedValue
var id: Long? = null

为什么?

JPA 规范规定:主键为 null 表示「尚未持久化」。如果你用 Long = 0,JPA 无法区分:

  • 这是一个新实体(应该 INSERT)
  • 还是 ID 恰好为 0 的已有实体(应该 UPDATE)

陷阱四:Kotlin 的空安全被反射「架空」

@Entity
class Product {
    @Id @GeneratedValue
    var id: Long? = null

    var name: String = ""  // 非空类型,应该安全?
}

残酷的现实:如果数据库里 name 字段是 NULL,JPA 会通过反射直接把 null 塞进去,完全绕过 Kotlin 的空检查。

当你后续访问 product.name 时,会得到一个表面是 String,实际是 null 的值,导致 NPE。

解决方案

  1. 数据库层面加 NOT NULL 约束
  2. 代码层面对可能为空的字段使用 String?
  3. 不要盲目信任 Kotlin 的类型系统

陷阱五:默认值在查询时不生效

@Entity
class Order {
    @Id @GeneratedValue
    var id: Long? = null

    var status: String = "PENDING"  // 期望新建时默认为 PENDING
}

新建实体时:默认值生效,status = "PENDING"

从数据库查询时:JPA 用反射直接赋值,完全忽略默认值

这意味着如果数据库里 statusNULL,查出来就是 null,而不是 "PENDING"


终极解决方案:编译器插件

手动处理这些问题太繁琐,Kotlin 提供了官方插件:

// build.gradle.kts
plugins {
    kotlin("plugin.spring") version "2.1.0"  // 自动 open
    kotlin("plugin.jpa") version "2.1.0"     // 自动生成无参构造
}

plugin.jpa 会为标注了 @Entity@Embeddable@MappedSuperclass 的类自动生成无参构造函数。

plugin.spring(或 all-open)会自动把相关类变成非 final。


最佳实践模板

结合以上所有经验,这是我推荐的 Kotlin JPA 实体写法:

@Entity
@Table(name = "companies")
class Company {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @Column(nullable = false)
    var name: String = ""

    @Column(nullable = false)
    var email: String = ""

    @Column
    var description: String? = null  // 可空字段明确标注

    @ManyToOne(fetch = FetchType.LAZY)
    var parent: Company? = null

    // 基于主键的 equals/hashCode
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Company) return false
        return id != null && id == other.id
    }

    override fun hashCode(): Int = javaClass.hashCode()
}

要点

  1. 普通 class,不是 data class
  2. 所有属性用 var
  3. 主键用 Long?
  4. 可空字段用 ? 类型
  5. 手动实现基于主键的 equals/hashCode

写在最后

Kotlin 和 JPA 的冲突本质上是现代语言设计传统 ORM 规范的碰撞。

JPA 诞生于 2006 年,那时 Java 还没有 record,更没有 Kotlin。它的设计深度依赖反射和可变性——这在当时是合理的,但与 Kotlin 的理念格格不入。

好消息是,JetBrains 在 IntelliJ IDEA 2026.1 中增加了 JPA + Kotlin 的专项检查,能自动发现这些问题。

在那之前,记住这个原则:

在 JPA 的世界里,放下 Kotlin 的「洁癖」,拥抱 varnull