使用帶有Kotlin的JPA(Hibernate)的優秀實踐
譯文【51CTO.com快譯】作為適合多平臺應用的靜態編程語言,Kotlin比Java更簡潔、更有表現力、也更具有代碼的安全性。同時,Kotlin提供了與Java的無縫互操作性。也就是說,Java允許開發人員將他們的項目遷移到Kotlin處,而無需重寫整個代碼庫。當然,針對此類遷移,我們可能需要在Kotlin應用中使用JPA(Java Persistence API)。雖然許多開發者普遍認為:沒有JPA,就不存在實體。但是他們在Kotlin中定義JPA時,往往會遇到各種警告。下面,我們來一起討論:作為JPA的經典實現—Hibernate,是如何避免各種常見的錯誤,并充分利用Kotlin的。
JPA實體的各項規則
注意,此處的實體并非常規的數據傳輸對象(Data Transfer Object,DTO)。為了能夠順暢運行,實體需要得到正確地定義。鏈接中詳細闡述了針對JPA的一系列規范和限制。其中最重要的是如下兩項:
- 實體類雖然可以擁有其他構造函數,但是必須具有一個無參數(no-arg)的構造函數。而且這個無參數構造函數必須是公共的(public)或受保護的(protected)。
- 實體類的任何方法、或持久性實例變量,都不能為final類型。
上述規范足以讓實體類運行起來。不過為了使之更流暢地運行,我們需要附加如下兩條規則:
- 只有在明確的請求時,所有Lasy關聯才能被加載。否則,我們可能會遇到LazyInitializationException、或各種意外的性能問題。
- equals()和hashCode()的實現,必須考慮到實體的可變特性。
無參數的構造函數
主構造函數(Primary constructors)是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(譯者注:一個基于Apache Ant和Apache Maven概念的項目自動化構建開源工具)中的代碼段為:
- buildscript {
- dependencies {
- classpath "org.jetbrains.kotlin:kotlin-noarg:$kotlin_version"
- }
- }
- apply plugin: "kotlin-jpa"
打開各種類和屬性
根據JPA規范,所有與JPA相關的類和屬性都必須是open的。不過,某些JPA提供程序可能不會強制執行該規范。例如,Hibernate在遇到最終實體類時,是不會拋出異常的。然而,由于final類無法被子類化(subclassed),因此Hibernate的代理機制會就此關閉。而沒有了代理,又何談lazy加載呢?而且,由于程序急需獲取所有的ToOne關聯,因此它很可能會導致嚴重的性能問題。
不過,對于使用靜態編織(static weaving)的EclipseLink而言,情況則不同,畢竟它的lazy加載機制并不會用到子類化。
如下代碼段所示,與Java不同的是,在Kotlin中,所有的類、屬性、以及方法,默認都是final類型的。您必須將它們明確地標記為open:
- @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)式的編譯器插件(https://kotlinlang.org/docs/all-open-plugin.html),來默認開啟所有與JPA相關的類和屬性。通過正確的配置,它能夠適用于所有被注釋為@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中的代碼段為:
- buildscript {
- dependencies {
- classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlin_version"
- }
- }
- apply plugin: "kotlin-allopen"
- allOpen {
- annotations("javax.persistence.Entity", "javax.persistence.MappedSuperclass", "javax.persistence.Embedabble")
- }
針對JPA實體使用各種數據類
數據類(Data classes)是專為DTO設計的一項超棒的Kotlin功能。它被默認設計、并配備了各種非常實用的針對equals()、hashCode()、以及toString()的實現。不過,此類實現并不太適合JPA實體。其原因在于,雖然數據類被設計為final類,但是它不能夠像Kotlin那樣被標記為open。因此,為了適用于實體,而將它們標記為open的唯一方法便是,啟用全開放式的編譯器插件。
如下代碼段所示,我們將使用以下實體,來進一步檢查數據類。它帶有一個已生成的id、一個name屬性、以及兩個lazy的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(),
- )
意外獲取LAZY關聯
默認情況下,所有ToMany關聯都是lazy的,其原因在于:非必要地加載它們,往往很容易會影響到程序性能。例如,equals()、hashCode()、以及toString()在實現的過程中,通常會用到包括lazy在內的所有屬性。因此,調用它們會導致向數據庫產生不需要的請求、以及出現LazyInitializationException。而且,數據類的默認行為是:在其方法中,使用來自主構造函數的所有字段。
在此,我們可以使用IDE來生成toString(),以通過簡單的覆蓋方式,排除所有的LAZY字段。如下代碼段所示,由于JPA Buddy有著自己的toString()產生機制,因此它完全不會提供LAZY字段。
- @Override
- override fun toString(): String {
- return this::class.simpleName + "(id = $id , name = $name )"
- }
當然,僅從equals()和hashCode()中排除LAZY字段是遠遠不夠的,畢竟它們可能仍然包含著可變的屬性。
Equals()和HashCode()的問題
由于JPA實體在本質上是可變的,因此為其實現equals()和hashCode(),并不像常規的DTO那么簡單。某些實體的id甚至都是由數據庫所生成的,因此id會在實體首次被持久化后發生變化。這就意味著我們將沒有可依賴的字段,去計算hashCode。
下面,讓我們對Client實體進行一個簡單的測試。
- val awesomeClient = Client(name = "Awesome client")
- val hashSet = hashSetOf(awesomeClient)
- clientRepository.save(awesomeClient)
- assertTrue(awesomeClient in hashSet)
如上面的代碼段所說,即便該實體被添加到前面幾行的集合中,它的最后一行斷言也會出現錯誤。畢竟,id在被首次生成時,hashCode就會發生改變。這就導致了HashSet在不同的存儲桶中是無法查找到該實體的。可見,如果id是在實體對象的創建期間被設置的(例如,是由應用程序設置的UUID),那么就不會出現問題;而如果是由數據庫生成的id(其實更為常見),就會出現上述問題。
對此,我們可以在使用實體的數據類時,持續性地覆蓋equals()和hashCode()。如果您想詳細地了解具體使用方法,請參見--https://vladmihalcea.com/the-best-way-to-implement-equals-hashcode-and-tostring-with-jpa-and-hibernate/。其中,對于Client實體而言,其對應的代碼段為:
- 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
使用由應用程序設置的ID
其實,數據類的各種方法主要是由主構造函數中那些指定的字段所生成的。如果只包含了eager immutable字段,那么數據類就不會存在上述問題。如下代碼段展示了由應用程序設置的不可變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
- }
雖然您可以放心地使用上述方法,但是它幾乎違背了使用數據類的初衷。畢竟,該用法不但讓分解(decomposition)變得無效,而且讓toString()對于實體而言,還不如直接使用普通的舊類。
空指針安全性(null-safety)
Kotlin相對于Java的一項優勢便是,內置有空指針安全性的功能。我們可以通過非空約束(non-null constraints)在數據庫端確保空指針的安全性。其最簡單的實現方法為,在主構造函數中使用非空類型,來定義各種非空的屬性(請參考如下代碼段):
- @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
- }
據此,如果該屬性在數據庫中被確認為非空,那么我們便可以省略在Kotlin代碼中,對于所有空值的檢查。
小結
讓我們通過如下列表,一起來總結一下如何在Kotlin中定義JPA實體:
- 請將所有與JPA相關的類、及其屬性標記為open,以避免出現顯著的性能問題。
- 為ManytoOne和OnetoOne關聯開啟lazy加載,或者將全開放式的編譯器插件,應用到所有被注釋為@Entity、@MappedSuperclass、以及@Embeddable的類。
- 為了避免出現InstantiationException,請在所有與JPA相關的類中,定義無參數的構造函數,或使用kotlin-jpa編譯器插件。
- 通過啟用全開放式的插件,在已編譯的字節碼中創建數據類,并使之具有open屬性。
- 覆蓋equals()、hashCode()、以及toString()。
- 讓JPA Buddy生成諸如:equals()、hashCode()、以及toString()等有效的實體。
此外,如果您想深入研究此類實踐,請通過鏈接,參考我們在GitHub存儲庫中為您準備的帶有測試的示例。
原文標題:Best Practices of Using JPA(Hibernate) With Kotlin,作者:Andrey Oganesyan
【51CTO譯稿,合作站點轉載請注明原文譯者和出處為51CTO.com】




























