《翻译》将 JPA (Hibernate) 与 Kotlin 结合使用的最佳实践和常见陷阱

Kotlin 很棒:它比 Java 更简洁和富有表现力,它允许更安全的代码并提供与 Java 的无缝互操作性。 后者允许开发人员将他们的项目迁移到 Kotlin,而无需重写整个代码库。 这种迁移是我们可能不得不在 Kotlin 中使用 JPA 的原因之一。 为新的 Kotlin 应用程序选择 JPA 也很有意义,因为它是开发人员熟悉的成熟技术。

没有实体就没有 JPA,在 Kotlin 中定义它们会带来一些警告。 让我们看看如何避免常见的陷阱并充分利用 Kotlin。 剧透警告:数据类不是实体类的最佳选择。

本文将主要关注 Hibernate,因为它是所有 JPA 实现中无可置疑的领导者。

1.JPA 实体准则

实体不是常规的 DTO。 为了工作,并且工作得好,它们需要满足某些要求,让我们从定义它们开始。 JPA 规范提供了自己的一组限制,以下是对我们最重要的两个:

    1. 这个实体类必须拥有一个无参构造函数。这个实体类也可以拥有别的构造器。但是这个无参构造函数必须是public或者protected。
    1. 这个实体类不能是final的。实体类的任何方法或持久实例变量都不能是final的。
      这些要求足够使实体正常工作,但是我们需要一些附加的规则来使实体工作的更好。
    1. 只有在明确请求时才必须加载所有惰性关联。 否则我们可能会遇到意外的性能问题或 LazyInitializationException
    1. 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的方法。
    1. 根据 Vlad Mihalcea 或 Thorben Janssen 的其中一篇文章, 重写equals,hashCode以及toString方法.

JPA Buddy 知道所有这些事情,并且总是为您生成有效的实体,包括额外的东西,如 equals()、hashCode()、toString()。

猜你喜欢

转载自blog.csdn.net/Apple_wolf/article/details/126076079