Spring Boot, Hibernate и Kotlin для новичков шаг за шагом

от автора

Всем привет, меня зовут Олег, я техлид в ДомКлике. В нашей команде ядром стека является 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, избежав многих скрытых сюрпризов. Буду рад, если эта статья окажется полезна не только новичкам. Позднее мы продолжим тему общения микросервиса с БД.

Ссылка на приложение.

ссылка на оригинал статьи https://habr.com/ru/company/domclick/blog/505860/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *