Всем привет, меня зовут Олег, я техлид в ДомКлике. В нашей команде ядром стека является Kotlin и Spring Boot. Хочу поделиться с вами своим опытом по взаимодействию и особенностях работы с PostgreSQL и Hibernate в связке со Spring Boot и Kotlin. Также на примере микросервиса, покажу преимущества Kotlin и его отличия от аналогичного приложения на Java. Расскажу о не совсем очевидных сложностях, с которыми могут столкнуться новички при использовании этого стека с Hibernate. Статья будет полезна разработчикам, желающим перейти на Kotlin и знакомых со Spring Boot, Hibernate Java.
Плагины
Для приложения на Kotlin в качестве сборщика проекта возьмём Gradle Kotlin DSL. Список подключенных плагинов будет стандартным для Spring Boot, а для Kotlin с Hibernate у нас появится несколько новых:
plugins {
id("org.springframework.boot") version "2.2.7.RELEASE"
id("io.spring.dependency-management") version "1.0.9.RELEASE"
kotlin("jvm") version "1.3.72"
kotlin("plugin.spring") version "1.3.72"
kotlin("plugin.jpa") version "1.3.72"
}
Рассмотрим три последних.
kotlin(«jvm»)
— базовый плагин Kotlin для работы на JVM. Без которого не заведется ни одно приложение на Java-стеке.
kotlin(«plugin.spring»)
— поскольку классы в Kotlin по умолчанию финальны, то этот плагин автоматически сделает классы, помеченные аннотациями @Component
, @Async
, @Transactional
, @Cacheable
и @SpringBootTest
открытыми к наследованию, а в тематике, относящейся этой статье, это позволит классам, написанным на Kotlin быть проксированными в Spring через CGLib прокси.
Важно отметить, что сущности, помеченные аннотациями @Entity
, @MappedSuperclass
и @Embaddable
, не станут open
после подключения плагина. Более того, get accessor
’ы тоже будут финальными, и тогда мы потеряем возможность работать с entity reference
. Чтобы этого избежать и сделать Entity
и его поля open
, добавим в build.gradle.kts:
allOpen {
annotation("javax.persistence.Entity")
annotation("javax.persistence.MappedSuperclass")
annotation("javax.persistence.Embeddable")
}
kotlin(«plugin.jpa»)
— Если предыдущие два плагина применяются к любому приложению на Kotlin + Spring Boot, то следующий, уже относится напрямую к Hibernate. А он, как известно, для инициализации Entity
использует рефлексию и инициализирует класс с конструктором без аргументов. Но так как мы пишем на Kotlin, такового конструктора может и не найтись. Если мы определили свой собственный первичный конструктор (primary constructor), то при загрузке Entity
у нас выкинет исключение:
org.hibernate.InstantiationException: No default constructor for entity
Зависимости
Набор зависимостей у нас тоже будет не совсем идентичный набору на Java:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.liquibase:liquibase-core")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.testcontainers:testcontainers:$testContainersVer")
testImplementation("org.testcontainers:postgresql:$testContainersVer")
}
Добавим еще пару зависимостей в дополнение к стандартному веб-стартеру Spring Boot и к основному интересующему нас стартеру org.springframework.boot:spring-boot-starter-data-jpa
, который в качестве реализации JPA по умолчанию тянет Hibernate:
org.jetbrains.kotlin:kotlin-reflect
— нужен для рефлексии на Kotlin, которая уже поддерживается в Spring Boot и широко используется для инициализации классов.
org.jetbrains.kotlin:kotlin-stdlib-jdk8
— добавляет возможность работать с коллекциями Java, поддержку стримов и многое другое.
На этом различия в конфигурировании проекта на Kotlin по сравнению с Java у нас заканчиваются, перейдем к самому проекту, его структуре таблиц и сущностей.
Таблицы и сущности
Наше приложение будет состоять из двух таблиц department и employee, которые связаны отношением «один ко многим».
Структура таблиц:
В качестве базы будем использовать СУБД PostgreSQL. Структуру таблиц создадим с помощью liquibase, а в качестве тестовых зависимостей будем использовать стандартный стартер:
org.springframework.boot:spring-boot-starter-test
— тестировать будем в Docker с помощью testcontainers.
Сущности
Как и в любом приложении с более чем одной сущностью, создадим общего предка для всех entity
.
BaseEntity
:
@MappedSuperclass
abstract class BaseEntity<T> {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: T? = null
override fun equals(other: Any?): Boolean {
other ?: return false
if (this === other) return true
if (javaClass != ProxyUtils.getUserClass(other)) return false
other as BaseEntity<*>
return this.id != null && this.id == other.id
}
override fun hashCode() = 25
override fun toString(): String {
return "${this.javaClass.simpleName}(id=$id)"
}
}
DepartmentEntity
:
@Entity
@Table(name = "department")
class DepartmentEntity(
val name: String,
@OneToMany(
mappedBy = "department",
fetch = FetchType.LAZY,
orphanRemoval = true,
cascade = [CascadeType.ALL]
)
val employees: MutableList<EmployeeEntity> = mutableListOf()
) : BaseAuditEntity<Long>() {
fun addEmployee(block: DepartmentEntity.() -> EmployeeEntity) {
employees.add(block())
}
fun setEmployees(block: DepartmentEntity.() -> MutableSet<EmployeeEntity>) {
employees.clear()
employees.addAll(block())
}
}
EmployeeEntity
:
@Entity
@Table(name = "employee")
class EmployeeEntity(
val firstName: String,
var lastName: String? = null,
@ManyToOne
@JoinColumn(name = "department_id")
val department: DepartmentEntity
) : BaseAuditEntity<Long>()
Мы не используем Data-классы. Это кажется явным преимуществом Kotlin перед Java (до 14 версии), и этому есть объяснение.
Почему не использовать?
Data-классы, помимо того, что они финальны сами по себе, имеют по всем полям определенные equals
, hashCode
и toString
. А это недопустимо в связке с Hibernate.
Почему? А также зачем hashCode
всегда равен константе — ответ в документации самого Hibernate. Конкретно нас интересует вот этот раздел:
Although using a natural-id is best for equals and hashCode, sometimes you only have the entity identifier that provides a unique constraint.
It’s possible to use the entity identifier for equality check, but it needs a workaround:
- you need to provide a constant value for hashCode so that the hash code value does not change before and after the entity is flushed.
- you need to compare the entity identifier equality only for non-transient entities.
То есть сравнивать нужно либо по natural id
, либо, как в нашем примере, по primary key id
. Это позволит избежать множества проблем при сравнении сущности и убережет от ее потери при использовании сущности в качестве элемента в Set
.
Наличие же toString
, определенного по всем полям, и вовсе убивает всю ленивость, например, при журналировании сущности, так как будут проинициализированы все поля для вывода в строку.
Учитывая особенности Hibernate, эта функциональность Kotlin нам не подойдет.
Конструктор класса
Kotlin позволяет задавать переменным значения через конструктор, чем грех не воспользоваться. Рассмотрим еще раз DepartmentEntity
:
class DepartmentEntity(
val name: String,
@OneToMany(
mappedBy = "department",
fetch = FetchType.LAZY,
orphanRemoval = true,
cascade = [CascadeType.ALL]
)
val employees: MutableList<EmployeeEntity> = mutableListOf()
) : BaseAuditEntity<Long>() {
Также мы можем проинициализировать через конструктор название подразделения, например:
departmentRepository.save(DepartmentEntity(name = "Department One"))
Через конструктор можно инициализировать, в том числе, и список сотрудников employees. Коллекции, разумеется, объявим изменяемыми.
Используйте var/val в зависимости от необходимости изменения поля
Название организации мы пометили как val
:
class DepartmentEntity(
val name: String,
и оно не может быть null
.
Выбор var
/val
является удобной опцией и зависит от бизнес-логики. Выбирать между var
и val
надо исходя из требования: должно ли поле сущности быть изменяемым.
Допустимость null в полях только в соответствии с БД
Насчет допустимости значений null
в полях всё не так просто. Ранее мы погрузились немного в глубины Hibernate: говоря о plugin.jpa, я упомянул про использование конструктора без аргументов при инициализации сущности.
При инициализации полей тоже используется рефлексия. И если в базе в соответствующей колонке хранилось значение null
, то класс спокойно инициализируется с этим полем со значением null
. При обращении к нему мы рискуем получить NPE, хотя поле и помечено как not nullable
. Чтобы этого не случилось, надо следить за синхронностью структуры таблиц и классов.
Если посмотреть на описанное в последних двух разделах более комплексно, то эти правила применимы не только к примитивам, но и к связке сущностей.
Например, EmployeeEntity
всегда привязан к DepartmentEntity
:
class EmployeeEntity(
val firstName: String,
var lastName: String? = null,
@ManyToOne
@JoinColumn(name = "department_id")
val department: DepartmentEntity
) : BaseAuditEntity<Long>()
Department
является не null
и его нельзя изменить, что может избавить от разного рода ошибок, в особенности, если бизнес-логика требует неизменяемости.
Репозитории
При использовании Kotlin, у репозиториев из коробки появилась проверка на допустимость null
. Так, если мы уверены, что при поиске department
по имени результат будет уникальный и единственный, то можно возвращаемый тип указать как non nullable
:
interface DepartmentRepository : JpaRepository<DepartmentEntity, Long> {
fun findOneByName(name: String) : DepartmentEntity
}
Здесь DepartmentEntity
указан единственным и не может быть null
. Если же по какой-то причине мы не нашли искомый department
, то поймаем уже не NPE, а нечто другое:
org.springframework.dao.EmptyResultDataAccessException: Result must not be null!
Такая обработка достигается с помощью добавления специализированной поддержки Kotlin в MethodInvocationValidator
и ReflectionUtils
в spring data commons.
lateinit var
Ещё одной фичей Kotlin, которую хотелось бы рассмотреть, является lateinit var
.
Добавим новый класс-предок: BaseAuditEntity
.
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseAuditEntity<T> : BaseEntity<T>() {
@CreatedDate
@Column(updatable = false, nullable = false)
lateinit var created: LocalDateTime
@LastModifiedDate
@Column(nullable = false)
lateinit var modified: LocalDateTime
}
Рассмотрим применение lateinit var
на примере полей аудита (created
, modified
).
lateinit var
— это not null
поле с отложенной инициализацией. Обращение к полю до его инициализации генерирует ошибку:
kotlin.UninitializedPropertyAccessException: lateinit property has not been initialized
Как правило, мы обращаемся к полям created
и modified
уже после того, как сущность была сохранена в БД. В нашем случае, данные в этих поляхпроставляются на этапе сохранения и они not null
, то lateinit var
нам более чем подходит.
Итоги
Мы создали приложение, в котором учтены многие преимущества Kotlin, и рассмотрели важные отличия от Java, избежав многих скрытых сюрпризов. Буду рад, если эта статья окажется полезна не только новичкам. Позднее мы продолжим тему общения микросервиса с БД.
Автор: Олег Клименко