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

    Ссылка на приложение.
    ДомКлик
    Место силы

    Комментарии 17

      0
      Эта статься для новичков в чем?
        +3
        Для новичков, которые переезжают с Java на Kotlin), которым сам и являюсь. Для меня статья была позеной. Спасибо автору.
        +2
        В итогах вы немного лукавите — «Мы создали приложение». Правильно было бы — мы разобрались как использовать kotlin c jpa.
        Вот константый hashcode — это интересный ньюанс. В целом спасибо за статью!
          +1
          Спасибо за комментарий и отзыв! В термин приложение вкладывалось понятие тестового приложения, на примере которого, можно поиграться с сущностями, поэкспериментировать вручную при желании, и ничего более. Практический пример является одной из целей
          +1
          Очень похоже, что это просто перевод этой статьи
          kotlinexpertise.com/hibernate-with-kotlin-spring-boot
            +1
            У статей на эту тематику всегда будет много общего. Так как, на данный момент, иначе полноценно завести это на Kotlin просто не получится. Да и про тот же hashCode с константой, относящийся и тематике Java, тоже есть статьи, это не является секретом.
              +3
              А зачем вы вообще пытаетесь слепить вместе фреймворки ориентированные на Java Enterprise и Kotlin? Из статьи я увидел только несколько упоминаний про синтаксический сахар. Но вся прелесть Kotlin в многопоточности, его замечательных корутинах, которая в таких связках рубится. Если это новый проект, то почему бы его целиком не сделать на Kotlin. В качестве сервера взять Ktor, а для работы с БД Exposed. Что бы всё было стопроцентно совместимо и поддерживало все фичи Kotlin.
                0
                Потому что могут быть решения, что на Java используется «такой-то» технический стек, а при замене ЯП никто не готов и стек полностью заменить. Для этого нужно время, апробирование на внутренних тестовых сервисах. Нельзя так просто вылить в прод новый сервис когда у остальной команды нет тех. экспертизы для его поддержки.

                И да, Spring Framework, Hibernate и прочие из мира Java EE писались именно под особенности языка Java SE. Так как у Kotlin другая философия то приходится применять плагины, не меняя в остальном работу с Kotlin.

                Фреймворки-то не изменить уже, работаем с тем что есть.
                Время пройдёт, возможно будут заменены и фреймворки, уже написанные под Kotlin.
                  0
                  Поэтому я и пишу, что это как минимум необдуманное решение. Т.е. заменять язык имеет смысл, если планируется использовать все те возможности, которые он предоставляет. И соответственно с новым языком должна приходить новая философия и архитектура. Но если же на новом языке ведётся разработка в джава-стиле, то появляются только новые проблемы и никакой выгоды. А отсюда и возникает вопрос: а зачем это вообще нужно?
                  Джава сейчас развивается ничуть не медленнее, чем Котлин. Все удачные решения одного языка наверняка найдут отражение и в другом. Так что не проще ли было просто апдейтится до актуальной версии Джавы?
                    0
                    Например я получил опыт такой работы и он тоже интересный. Как минимум я могу дать ответы на вопросы, а не теоритизировать «почему так надо» или «почему так не надо». Что плохого-то? =) Ни-че-го.
                      0
                      Когда такая поделка — это домашний проект, то конечно же ничего плохого. Но когда мы говорим о сервисе продажи недвижимости крупнейшего банка страны, то эксперименты в таком проекте выглядят крайне странно.
            0

            А использование LocalDateTime для created и modified это правильный выбор? Там же хранится не момент времени, а дата без привязки к таймзоне. Мне кажется Instant тут лучше подходит.

              +1
              LocalDateTime использован для простоты, целью было продемонстрировать применения lateinit var. Да, в самом LocalDateTime не хранится момент времени, но для его инициализации в качестве поля аудита используется дефолтная тайм зона а на сам timestamp конвертируется значение как есть. Поэтому для примера в статье тайм зоны я не рассматривал так как в этом не было необходимости
                0
                кстати я тоже Instant использую и не парюсь.
              +1
              Замечательная статья) Узнал много интересных вещей. Пользуюсь тем же стеком. Было бы интересно узнать как пишите тесты. А ещё мне очень интересно узнать, как решаете проблему с десериализацией POST/PUT запросов. jackson требует (даже при десериализации через конструктор), чтобы все поля были nullable даже те, что определённо такими быть не должны, и я навесил на них соответствующую валидацию с помощью hibernate-validator. Сам решаю классом-обёрткой и оператором !!, но есть ощущение, что можно и лучше.
                +1
                Спасибо за отзыв! Рассмотрю возможность включение тестов в следующих статьях. По поводу десериализации: если я правильно понял Ваш вопрос, то мы используем data классы в качестве dto и jackson-module-kotlin — это как раз модуль который позволяет объявить primary constructor у data класса с не nullable полями
                +1
                Отличная статья!
                Отдельная благодарность за детальное описание причины недопустимости использования Data-классов в качестве сущностей Hibernate!

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое