Kotlin 很棒:它比 Java 更简洁和富有表现力,它允许更安全的代码并提供与 Java 的无缝互操作性。 后者允许开发人员将他们的项目迁移到 Kotlin,而无需重写整个代码库。 这种迁移是我们可能不得不在 Kotlin 中使用 JPA 的原因之一。 为新的 Kotlin 应用程序选择 JPA 也很有意义,因为它是开发人员熟悉的成熟技术。
没有实体就没有 JPA,在 Kotlin 中定义它们会带来一些警告。 让我们看看如何避免常见的陷阱并充分利用 Kotlin。 剧透警告:数据类不是实体类的最佳选择。
本文将主要关注 Hibernate,因为它是所有 JPA 实现中无可置疑的领导者。
1.JPA 实体准则
实体不是常规的 DTO。 为了工作,并且工作得好,它们需要满足某些要求,让我们从定义它们开始。 JPA 规范提供了自己的一组限制,以下是对我们最重要的两个:
-
- 这个实体类必须拥有一个无参构造函数。这个实体类也可以拥有别的构造器。但是这个无参构造函数必须是public或者protected。
-
- 这个实体类不能是final的。实体类的任何方法或持久实例变量都不能是final的。
这些要求足够使实体正常工作,但是我们需要一些附加的规则来使实体工作的更好。
- 这个实体类不能是final的。实体类的任何方法或持久实例变量都不能是final的。
-
- 只有在明确请求时才必须加载所有惰性关联。 否则我们可能会遇到意外的性能问题或 LazyInitializationException
-
- equals() 和 hashCode() 实现必须考虑实体的可变性。
2. 无参构造器
主构造函数是Kotlin中最受欢迎的特性之一。但是,添加主构造函数,会使我们丢失默认的构造函数。因此,如果你尝试将其与Hibernate一起使用,你会得到以下异常:org.hibernate.InstantiationException: No default constructor for entity .
要解决这个问题,你可以在所有实体中手动添加无参构造函数。或者,最后使用kotlin-jpa编译插件,它确保在每个jpa相关类的字节码中生成无参构造函数:@Entity,@MappedSuperclass或者Embeddable
要启用该插件,只需将其添加到 kotlin-maven-plugin 的依赖项和 compilerPlugins 中:
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<compilerPlugins>
...
<plugin>jpa</plugin>
...
</compilerPlugins>
</configuration>
<dependencies>
...
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
<version>${kotlin.version}</version>
</dependency>
...
</dependencies>
</plugin>
在Gradle中:
plugins {
id "org.jetbrains.kotlin.plugin.jpa" version "1.5.21"
}
3. open类和Properties
根据JPA规范,所有与JPA相关的类和属性都必须是open的。一些JPA提供者不会强制执行词规则。例如,Hibernate在遇到final类时,不会抛出异常。但是,一个final类无法被继承(子类化)。因此,Hibernate的代理机制关闭。没有代理,就没有延迟加载。实际上,这意味着所有的ToOne关联总是会被立即加载。这可能导致严重的性能问题。这个情况和使用静态编织的Eclipse Link不同,因为他没有使用子类化来进行其延迟加载机制。
与 Java 不同的是,在 Kotlin 中,所有的类、属性和方法默认都是 final 的。 您必须明确将它们标记为打开:
@Table(name = "project")
@Entity
open class Project {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
open var id: Long? = null
@Column(name = "name", nullable = false)
open var name: String? = null
...
}
或者,您可以使用all-open编译器插件来使所有与 JPA 相关的类和属性默认是open的。 确保正确配置它,使其适用于所有注释为@Entity、@MappedSuperclass、@Embeddable 的类:
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<compilerPlugins>
...
<plugin>all-open</plugin>
</compilerPlugins>
<pluginOptions>
<option>all-open:annotation=javax.persistence.Entity</option>
<option>all-open:annotation=javax.persistence.MappedSuperclass</option>
<option>all-open:annotation=javax.persistence.Embeddable</option>
</pluginOptions>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
在Gradle中:
plugins {
id "org.jetbrains.kotlin.plugin.allopen" version "1.5.21"
}
allOpen {
annotations("javax.persistence.Entity", "javax.persistence.MappedSuperclass", "javax.persistence.Embedabble")
}
4. 用data class作为JPA 实体
data class是专门为Dto设计的一个很棒的功能。data class被设计成final的,并且带有默认的equals方法,hashCode方法和toString方法,这些方法非常有用。但是,这些实现并不适合JPA实体,让我们看看为什么。
首先,data class是被设计成final的,因此不能被标记为open的。因此,唯一的办法就是使他们open,是启用全开放编译器插件。
为了进一步检查data class,我们将使用下面的实体,它有一个生成的id, 一个name属性和两个OneToMany的懒加载。
@Table(name = "client")
@Entity
data class Client(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
var id: Long? = null,
@Column(name = "name", nullable = false)
var name: String? = null,
@OneToMany(mappedBy = "client", orphanRemoval = true)
var projects: MutableSet<Project> = mutableSetOf(),
@JoinColumn(name = "client_id")
@OneToMany
var contacts: MutableSet<Contact> = mutableSetOf(),
)
5. 意外获取lazy关联
默认情况下,所以ToMany的关联都是lazy:不必要地加载他们很容易损坏性能。可能发生这种情况的一个常见情况是当equals(),hashCode()和toString()实现使用所有属性时,包括lazy属性。因此,调用它们会导致对DB的不需要的请求或LazyInitializationException。这是data class的默认行为:主构造函数中的所有字段都在这些方法中使用。
toString()可以简单的被override以排除所有的lazy字段。确保在使用IDE生成的toString()时不要意外添加它们。JPA Buddy 有自己的 toString() 生成,它完全不提供 LAZY 字段作为选项。
@Override
override fun toString(): String {
return this::class.simpleName + "(id = $id , name = $name )"
}
从 equals() 和 hashCode() 中排除 LAZY 字段是不够的,因为它们可能仍然包含可变属性。
6. Equals()和HashCode()问题
JPA 实体本质上是可变的,因此为它们实现 equals() 和 hashCode() 并不像常规 DTO 那样简单。 甚至实体的 id 也经常由数据库生成,因此在实体首次持久化后它会发生变化。 这意味着我们没有可以依赖的字段来计算 hashCode。
让我们写一个Client实体的简单测试:
val awesomeClient = Client(name = "Awesome client")
val hashSet = hashSetOf(awesomeClient)
clientRepository.save(awesomeClient)
assertTrue(awesomeClient in hashSet)
最后一行的断言失败,尽管通过上面几行代码,把实体被添加到set中。一旦生成了Id(在第一次保存时),hashCode就会改变。所以这个hashSet在不同的bucket中找这个entity,因此也找不到。如果id是在实体创建时被设置的(如id是个uuid,有app设置的)将不会有问题。但是更常见的是id是有数据库来产生的。
为了解决这个问题,在使用data class时,请始终重写equals方法和hashCode方法。Vlad Mihalcea 和 Thorben Janssen 详细解释了如何做到这一点。 对于客户端实体,它应该如下所示:
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || Hibernate.getClass(this) != Hibernate.getClass(other)) return false
other as Client
return id != null && id == other.id
}
override fun hashCode(): Int = 1756406093
7. 使用application设置一个Id集合
使用主构造函数中指定的字段生成data class中的方法。如果它只包含急切的不可变字段,则数据类不存在上述问题。此类字段的一个示例是应用程序设置的不可变id:
@Table(name = "contact")
@Entity
data class Contact(
@Id
@Column(name = "id", nullable = false)
val id: UUID,
) {
@Column(name = "email", nullable = false)
val email: String? = null
// other properties omitted
}
如果你更愿意使用数据库生成id,一个不可变的id可以再构造器中使用:
@Table(name = "contact")
@Entity
data class Contact(
@NaturalId
@Column(name = "email", nullable = false, updatable = false)
val email: String
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
var id: Long? = null
// other properties omitted
}
这是绝对安全的使用。 然而,它几乎违背了使用数据类的目的,因为它使分解毫无用处,并且只使用 toString() 中的一个字段。 一个普通的旧类可能是实体的更好选择。
8. null 安全
Kotlin 相对于 Java 的优势之一是内置的 null 安全特性。 也可以通过非空约束在 DB 端确保空安全。 只有将这些功能一起使用才有意义。
最简单的方法是使用非空类型在主构造函数中定义非空属性:
@Table(name = "contact")
@Entity
class Contact(
@NaturalId
@Column(name = "email", nullable = false, updatable = false)
val email: String,
@Column(name = "name", nullable = false)
var name: String
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "client_id", nullable = false)
var client: Client
) {
// id and other properties omitted
}
但是,如果您需要从构造函数中排除它们(例如在数据类中),您可以提供默认值或将 lateinit 修饰符添加到属性:
@Entity
data class Contact(
@NaturalId
@Column(name = "email", nullable = false, updatable = false)
val email: String,
) {
@Column(name = "name", nullable = false)
var name: String = ""
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "client_id", nullable = false)
lateinit var client: Client
// id and other properties omitted
}
因此,如果该属性在 DB 中确定不为 null,我们也可以省略 Kotlin 代码中的所有 null 检查。
9.总结
您可以在我们的 GitHub 存储库中找到更多带有测试的示例。 作为如何在 Kotlin 中定义 JPA 实体的总结,这里有一个清单:
- 确保你标记了所有JPA相关的类,并且把他们的属性标记为open,这样可以防止性能问题,然后enable Many/One to One的懒加载。或者使用all-open编译插件,并应用到所有添加了相关注解的类上:@Entity, @MappedSuperclass and @Embeddable.
- 在所有JPA相关的实体类中定义无参构造函数,或者使用kotlin-jpa的编译插件。否则,你回得到一个实例化异常。
使用data class
- 1.如上面描述的一样,开启all-open插件,这是唯一使data class在编译后的字节码中为open的方法。
-
- 根据 Vlad Mihalcea 或 Thorben Janssen 的其中一篇文章, 重写equals,hashCode以及toString方法.
JPA Buddy 知道所有这些事情,并且总是为您生成有效的实体,包括额外的东西,如 equals()、hashCode()、toString()。