Kotlin + Hibernate: всё сложно

Kate

Administrator
Команда форума
Котлин — отличный язык. По сравнению с Java он лаконичнее, выразительнее и безопаснее. Также, код написанный на Котлине полностью совместим с Java-кодом. Благодаря этой совместимости, проекты можно мигрировать на Котлин постепенно, не переписывая всё за раз. Такие миграции — одна из причин, по которой JPA может использоваться в Котлине. К тому же, JPA — это зрелая технология, знакомая разработчикам. Поэтому есть смысл ее использовать и в проектах, написанных на Котлине с нуля.
Невозможно представить JPA без сущностей. Однако, их определение в Котлине таит некоторое количество подводных камней. Давайте посмотрим, как избежать распространенных ошибок и использовать возможности Котлина на максимум. Внимание спойлер! Классы данных — не лучший вариант для сущностей.
В этой статье основное внимание будет уделено Hibernate, поскольку он является несомненным лидером среди всех реализаций JPA.

Правила для JPA-сущностей:​

Сущности — это не обычные DTO. Чтобы они не просто работали, а работали хорошо, надо придерживаться определенных требований. В спецификации JPA есть свои требования к сущностям, из них нам наиболее интересны эти два:
1. Класс сущности должен иметь конструктор без параметров. Класс сущности также может иметь другие конструкторы. Конструктор без параметров должен быть public или protected.
2. Класс сущности не должен быть final. Никакие методы и атрибуты сущности не могут быть final.
Этих требований достаточно, чтобы сущности работали. Чтобы они работали хорошо, определим еще парочку:
3. Lazy ассоциации должны загружаться только по явному запросу. В противном случае мы можем столкнуться с проблемами с производительностью или с LazyInitializationException.
4. Реализации equals() и hashCode() должны учитывать мутабельность сущностей.

Конструктор без параметров​

Первичные (primary) конструкторы очень удобны, так как позволяют одновременно с конструктором определить поля класса. Однако, если их использовать в классе сущности, компилятор не сгенерирует конструктор по умолчанию без параметров. Без него Hibernate не сможет создавать экземпляры сущности и будет бросать следующее исключение: org.hibernate.InstantiationException: No default constructor for entity.
Чтобы решить эту проблему, можно вручную определить конструктор без параметров для всех сущностей в проекте. Но существует более удобное решение: плагин для компилятора kotlin-jpa. Этот плагин гарантирует, что конструктор без параметров будет сгенерирован в байт-коде для всех JPA-классов: @Entity, @MappedSuperclass или @Embeddable.
Чтобы включить kotlin-jpa, его надо добавить в зависимости 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:
buildscript {
dependencies {
classpath "org.jetbrains.kotlin:kotlin-noarg:$kotlin_version"
}
}

apply plugin: "kotlin-jpa"

Open классы и свойства​

Согласно спецификации JPA, все классы и свойства, связанные с JPA, не должны быть final. Некоторые реализации JPA не требуют исполнения этого правила. Например, Hibernate не бросает исключение, когда встречает final класс сущности. Однако, от final класса нельзя наследоваться, из-за чего отключается механизм проксирования Hibernate. Нет прокси — нет lazy загрузки. Это означает, что ассоциации ToOne всегда будут не lazy, а eager, что может заметно сказаться на производительности. Это не проблема для EclipseLink с включенным static weaving, поскольку он не использует подклассы в механизме lazy загрузки.
В отличие от Джавы, в Котлине классы, свойства и методы по умолчанию 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. Он помечает нужные классы и свойства ключевым словом open прямо в байт-коде. Главное его правильно настроить, чтобы он применялся к классам, помеченным не только @Entity, но и @MappedSuperclass и @Embeddable. Так выглядит конфигурация для Maven:
<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. Классы данных являются final по задумке и для них автоматически генерируются реализации equals(), hashCode() и toString(). Однако эти реализации не подходят для JPA-сущностей. И вот почему.
Во-первых, классы данных по замыслу final, и сделать их open в коде нельзя. Единственный возможный вариант — это использовать плагин для компилятора all-open.
Для дальнейших примеров будем использовать следующую сущность Client. У нее есть генерируемый базой данных 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.
Как с этим быть? Метод toString() можно переопределить и исключить из него все lazy поля. Главное — случайно их не выбрать во время автогенерации метода в IDE. В JPA Buddy есть свой конструктор для toString(), который просто не дает включить lazy поля в метод.
@Override
override fun toString(): String {
return this::class.simpleName + "(id = $id , name = $name )"
}
С equals() и hashCode() ситуация сложнее: недостаточно просто исключить из них lazy ассоциации, нужно что-то сделать и с мутабельными полями.

Проблема с equals() и hashCode()​

Сущности мутабельны по своей природе. Даже ID зачастую генерируется базой данных, то есть изменяется после первого сохранения сущности. Получается, не существует полей, на основе которых можно было бы консистентно вычислить hashCode.
Давайте запустим простой тест с сущностью Client:
val awesomeClient = Client(name = "Awesome client")

val hashSet = hashSetOf(awesomeClient)

clientRepository.save(awesomeClient)

assertTrue(awesomeClient in hashSet)
Assert в последней строчке упадет с ошибкой, хотя сущность добавлена в Set всего несколькими строками выше. При первом сохранении сущности ID изменяется. Соответственно, меняется и hashCode. Именно поэтому HashSet и не может найти объект, который мы только что создали, так как он ищет его в другом бакете. Проблем бы не было, если бы ID был установлен во время создания объекта сущности (например, в качестве ID использовался бы UUID, генерируемый приложением), но чаще всего за генерацию идентификаторов отвечает именно база данных.
Чтобы решить эту проблему, всегда переопределяйте методы equals() и hashCode() при использовании классов данных для сущностей. Как их реализовать, можно почитать в статьях Vlad Mihalcea или Thorben Janssen. Например, для сущности 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, установленного приложением​

Методы класса данных генерируются на основе полей, указанных в первичном конструкторе. Если он включает только неизменяемые поля, то класс данных не имеет вышеупомянутых проблем. Пример такого поля — неизменяемый 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 в базе данных, можно использовать неизменяемый @NaturalId:
@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(). Обычный класс подойдет для сущностей лучше.

Null безопасность​

Одно из преимуществ Котлина перед Джавой — встроенная null безопасность (null safety). Она также может быть обеспечена на стороне БД с помощью ограничений NOT NULL. Эти два подхода можно и нужно использовать вместе.
Самый простой способ — определить все обязательные атрибуты в первичном конструкторе, используя non-null типы Котлина и указав nullable = false:
@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
}
Таким образом, если свойство точно не будет NULL в БД, мы также можем опустить все проверки на null в Kotlin-коде.

Заключение​

Подводя итог, определяя JPA-сущности в Котлине, необходимо придерживаться следующих правил:
  • Убедитесь, что все связанные с JPA классы и их атрибуты помечены open. Это поможет избежать серьезных проблем с производительностью и включит lazy загрузку для ассоциаций «многие/один к одному». Или используйте плагин для компилятора all-open и применяйте его ко всем классам, помеченным аннотациями @Entity, @MappedSuperclass и @Embeddable.
  • Определите конструкторы без параметров во всех классах, связанных с JPA, или используйте плагин для компилятора kotlin-jpa. Иначе вы столкнетесь с InstantiationException.
  • Чтобы использовать классы данных:
    • Включите плагин all-open, как описано выше, т.к. это единственный способ пометить классы данных ключевым словом open.
    • Переопределите методы equals(), hashCode() и toString() в соответствии со статей Vlad Mihalcea или Thorben Janssen.
JPA Buddy придерживается всех этих правил и всегда генерирует для вас рабочие сущности, а также отлично справляется с генерацией методов equals(), hashCode() и toString(). Больше примеров с тестами можно найти в нашем GitHub репозитории.


 
Сверху