1. 简介

Kotlin 的一大特性是它与 Java 生态的无缝互操作性,而 JPA(Java Persistence API)作为 Java 领域中最常用的 ORM 标准之一,自然也成为了 Kotlin 开发中经常打交道的部分。

本文将带你了解如何在 Kotlin 中使用类作为 JPA 实体,以及需要注意的一些坑。

2. 依赖配置

为了演示方便,我们使用 Hibernate 作为 JPA 的实现框架。在 Maven 项目中添加以下依赖:

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>5.2.15.Final</version>
</dependency>
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-testing</artifactId>
    <version>5.2.15.Final</version>
    <scope>test</scope>
</dependency>

同时,使用 H2 嵌入式数据库进行测试:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.4.196</version>
    <scope>test</scope>
</dependency>

Kotlin 相关依赖:

<dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-stdlib</artifactId>
    <version>1.8.0</version>
</dependency>

⚠️ 注意:以上版本仅供参考,实际开发中请使用 Maven Central 上最新的版本。

3. 编译器插件:jpa-plugin

JPA 要求实体类必须拥有一个无参构造函数。

然而,Kotlin 类默认是没有无参构造函数的,这就需要借助 Kotlin 的 jpa-plugin 插件来生成:

<plugin>
    <artifactId>kotlin-maven-plugin</artifactId>
    <groupId>org.jetbrains.kotlin</groupId>
    <version>1.8.0</version>
    <configuration>
         <compilerPlugins>
             <plugin>spring</plugin>
             <plugin>jpa</plugin>
         </compilerPlugins>
    </configuration>
    <dependencies>
        <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-maven-noarg</artifactId>
        <version>1.8.0</version>
        </dependency>
    </dependencies>
    <!--...-->
</plugin>

这个插件会为标注了 @Entity 的类自动生成无参构造函数,让 Hibernate 能够正常实例化实体对象。

4. 在 Kotlin 类中使用 JPA

配置完成后,我们就可以愉快地使用 Kotlin 类作为 JPA 实体了。

先来定义一个简单的 Person 类:

@Entity
class Person(
    @Column(nullable = false)
    val name: String,
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Int?=null,
)

✅ 你可以像在 Java 中一样使用 JPA 注解,比如 @Entity@Column@Id 等。

⚠️ 注意:建议将 id 字段放在最后,因为它是可空的(由数据库自动生成),放在构造函数最后更符合 Kotlin 的习惯。

写个测试验证一下:

@Test
fun givenPerson_whenSaved_thenFound() {
    doInHibernate(({ this.sessionFactory() }), { session ->
        val personToSave = Person("John")
        session.persist(personToSave)
        val personFound = session.find(Person::class.java, personToSave.id)
        session.refresh(personFound)

        assertTrue(personToSave.name == personFound.name)
    })
}

运行后你会看到 Hibernate 输出的 SQL:

Hibernate: insert into Person (id, name) values (null, ?)
Hibernate: select person0_.id as id1_0_0_, person0_.name as name2_0_0_ from Person person0_ where person0_.id=?

✅ 一切正常。

❌ 如果没有启用 jpa-plugin,你会遇到如下异常:

javax.persistence.PersistenceException: org.hibernate.InstantiationException: No default constructor for entity: : com.baeldung.entity.Person

4.1. 处理可空字段和关联关系

我们再扩展一下 Person 类,添加可空字段和一对多关系:

    //...
    @Column(nullable = true)
    val email: String? = null,

    @Column(nullable = true)
    @OneToMany(cascade = [CascadeType.ALL])
    val phoneNumbers: List<PhoneNumber>? = null

对应的 PhoneNumber 类如下:

@Entity
class PhoneNumber(   
    @Column(nullable = false)
    val number: String,
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int?=null,
)

测试一下带空值的情况:

@Test
fun givenPersonWithNullFields_whenSaved_thenFound() {
    doInHibernate(({ this.sessionFactory() }), { session ->
        val personToSave = Person("John", null, null)
        session.persist(personToSave)
        val personFound = session.find(Person::class.java, personToSave.id)
        session.refresh(personFound)

        assertTrue(personToSave.name == personFound.name)
    })
}

输出 SQL:

Hibernate: insert into Person (id, email, name) values (null, ?, ?)
Hibernate: select person0_.id as id1_0_1_, person0_.email as email2_0_1_, person0_.name as name3_0_1_, phonenumbe1_.Person_id as Person_i1_1_3_, phonenumbe2_.id as phoneNum2_1_3_, phonenumbe2_.id as id1_2_0_, phonenumbe2_.number as number2_2_0_ from Person person0_ left outer join Person_PhoneNumber phonenumbe1_ on person0_.id=phonenumbe1_.Person_id left outer join PhoneNumber phonenumbe2_ on phonenumbe1_.phoneNumbers_id=phonenumbe2_.id where person0_.id=?

再测试一下带完整数据的情况:

@Test
fun givenPersonWithFullData_whenSaved_thenFound() {
    doInHibernate(({ this.sessionFactory() }), { session ->
        val personToSave = Person(          
          "John", 
          "[email protected]", 
          Arrays.asList(PhoneNumber("202-555-0171"), PhoneNumber("202-555-0102")))
        session.persist(personToSave)
        val personFound = session.find(Person::class.java, personToSave.id)
        session.refresh(personFound)

        assertTrue(personToSave.name == personFound.name)
    })
}

输出 SQL:

Hibernate: insert into Person (id, email, name) values (null, ?, ?)
Hibernate: insert into PhoneNumber (id, number) values (null, ?)
Hibernate: insert into PhoneNumber (id, number) values (null, ?)

✅ 三行插入语句,说明一对多关系也保存成功。

5. Kotlin Data Classes 与 JPA

Kotlin 的 Data Classes 是为数据载体设计的类,提供了默认的 equalshashCodetoString 实现。

但 ❌ 不推荐将 Data Classes 用作 JPA 实体,原因如下。

我们先看一个例子:

@Entity
data class Address(    
    val name: String,
    @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
    val phoneNumbers: List<PhoneNumber>,
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null,
)

5.1. equals 与 hashCode 的问题

大多数 JPA 实体都有自动生成的字段(如主键),这些字段在持久化前是 null,持久化后才赋值。

这就导致在持久化前后,equalshashCode 的计算结果不同,容易引发在 Set 或 Map 中找不到对象的问题:

@Test
fun givenAddressWithDefaultEquals_whenAddedToSet_thenNotFound() {
    doInHibernate({ sessionFactory() }) { session ->
        val addresses = mutableSetOf<Address>()
        val address = Address(name = "Berlin", phones = listOf(PhoneNumber("42")))
        addresses.add(address)

        assertTrue(address in addresses)
        session.persist(address)
        assertFalse { address in addresses }
    }
 }

❌ 持久化后,对象的 hashCode 变了,Set 就找不到了。

5.2. toString 可能触发懒加载

Data Classes 的 toString 是基于所有属性生成的,如果其中有懒加载的字段(如 @OneToMany),调用 toString 会触发额外查询:

@Test
fun givenAddress_whenLogging_thenFetchesLazyAssociations() {
    doInHibernate({ this.sessionFactory() }) { session ->
        val addressToSave = Address(name = "Berlin", phoneNumbers = listOf(PhoneNumber("42")))
        session.persist(addressToSave)
        session.clear()

        val addressFound = session.find(Address::class.java, addressToSave.id)
            
        assertFalse { Hibernate.isInitialized(addressFound.phoneNumbers) }
        logger.info("found the entity {}", addressFound) // initializes the lazy collection
        assertTrue(Hibernate.isInitialized(addressFound.phoneNumbers))
    }
}

输出 SQL:

Hibernate: select * from Address address0_ where address0_.id=?
Hibernate: select * from Address_PhoneNumber phonenumbe0_ inner join PhoneNumber phonenumbe1_ on phonenumbe0_.phoneNumbers_id=phonenumbe1_.id where phonenumbe0_.Address_id=?

✅ 本来只是打印日志,却触发了懒加载,影响性能。

6. 总结

本文带你体验了如何在 Kotlin 中使用 JPA 实体类,重点介绍了:

  • 如何通过 jpa-plugin 解决无参构造函数问题
  • 如何定义实体类和处理可空字段
  • 为什么 不推荐 使用 Data Classes 作为 JPA 实体(坑多)

✅ 总之,Kotlin 和 JPA 可以愉快地合作,但要注意细节,尤其是 Data Classes 的使用场景。

💡 本文源码见:GitHub 项目地址


原始标题:Working with Kotlin and JPA