Перейти к содержанию

Kotlin и JPA

Одной из характеристик Kotlin является совместимость с библиотеками Java. JPA является одной из них.

В этом руководстве рассмотрим, как использовать классы Kotlin в качестве сущностей JPA.

Зависимости

Для простоты будем использовать Hibernate в качестве реализации JPA. Нужно добавить следующие зависимости в проект Maven:

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>5.2.15.Final</version>
</dependency>
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-testing</artifactId>
    <version>5.2.15.Final</version>
    <scope>test</scope>
</dependency>

Будем использовать встроенную базу данных H2 для запуска тестов:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.4.196</version>
    <scope>test</scope>
</dependency>

Для Kotlin будем использовать следующее:

<dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-stdlib-jdk8</artifactId>
    <version>1.2.30</version>
</dependency>

Самые последние версии Hibernate, H2 и Kotlin можно найти в Maven Central.

Плагины компилятора (jpa-plugin)

Чтобы использовать JPA, классам сущностей нужен конструктор без параметров.

По умолчанию в классах Kotlin его нет, и для их генерации потребуется использовать jpa-plugin:

<plugin>
    <artifactId>kotlin-maven-plugin</artifactId>
    <groupId>org.jetbrains.kotlin</groupId>
    <version>1.2.30</version>
    <configuration>
        <compilerPlugins>
        <plugin>jpa</plugin>
        </compilerPlugins>
        <jvmTarget>1.8</jvmTarget>
    </configuration>
    <dependencies>
        <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-maven-noarg</artifactId>
        <version>1.2.30</version>
        </dependency>
    </dependencies>
    <!--...-->
</plugin>

JPA с классами Kotlin

После завершения предыдущей настройки можно использовать JPA с простыми классами. Начнем создавать класс Person с двумя атрибутами – name и id:

@Entity
class Person(
    @Column(nullable = false)
    val name: String,
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Int?=null,
)

Как видим, можно свободно использовать аннотации из JPA, такие как @Entity, @Column и @Id.

Примечание. Обязательно поместите атрибут id на последнее место, так как он является необязательным и создается автоматически.

Чтобы увидеть сущность в действии, создадим следующий тест:

@Test
fun givenPerson_whenSaved_thenFound() {
    doInHibernate(({ this.sessionFactory() }), { session ->
        val personToSave = Person("John")
        session.persist(personToSave)
        val personFound = session.find(Person::class.java, personToSave.id)
        session.refresh(personFound)

        assertTrue(personToSave.name == personFound.name)
    })
}
После запуска теста с включенным логированием увидим следующие результаты:
Hibernate: insert into Person (id, name) values (null, ?)
Hibernate: select person0_.id as id1_0_0_, person0_.name as name2_0_0_ from Person person0_ where person0_.id=?

Важно отметить, что если не будем использовать jpa-plugin во время выполнения, то получим InstantiationException из-за отсутствия конструктора по умолчанию:

javax.persistence.PersistenceException: org.hibernate.InstantiationException: No default constructor for entity: : com.baeldung.entity.Person

Теперь снова проверим со значениями null. Для этого расширим сущность Person новым атрибутом email и отношением @OneToMany:

    //...
    @Column(nullable = true)
    val email: String? = null,

    @Column(nullable = true)
    @OneToMany(cascade = [CascadeType.ALL])
    val phoneNumbers: List<PhoneNumber>? = null

Можно увидеть, что поля email и phoneNumbers могут принимать значения null, поэтому они объявлены со знаком вопроса.

Сущность PhoneNumber имеет два атрибута – name и id:

@Entity
class PhoneNumber(   
    @Column(nullable = false)
    val number: String,
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int?=null,
)

Проверим это с помощью теста:

@Test
fun givenPersonWithNullFields_whenSaved_thenFound() {
    doInHibernate(({ this.sessionFactory() }), { session ->
        val personToSave = Person("John", null, null)
        session.persist(personToSave)
        val personFound = session.find(Person::class.java, personToSave.id)
        session.refresh(personFound)

        assertTrue(personToSave.name == personFound.name)
    })
}

На этот раз получим один оператор insert:

Hibernate: insert into Person (id, email, name) values (null, ?, ?)
Hibernate: select person0_.id as id1_0_1_, person0_.email as email2_0_1_, person0_.name as name3_0_1_, phonenumbe1_.Person_id as Person_i1_1_3_, phonenumbe2_.id as phoneNum2_1_3_, phonenumbe2_.id as id1_2_0_, phonenumbe2_.number as number2_2_0_ from Person person0_ left outer join Person_PhoneNumber phonenumbe1_ on person0_.id=phonenumbe1_.Person_id left outer join PhoneNumber phonenumbe2_ on phonenumbe1_.phoneNumbers_id=phonenumbe2_.id where person0_.id=?

Проверим еще раз, но без нулевых данных, чтобы проверить вывод:

@Test
fun givenPersonWithFullData_whenSaved_thenFound() {
    doInHibernate(({ this.sessionFactory() }), { session ->
        val personToSave = Person(          
          "John", 
          "jhon@test.com", 
          Arrays.asList(PhoneNumber("202-555-0171"), PhoneNumber("202-555-0102")))
        session.persist(personToSave)
        val personFound = session.find(Person::class.java, personToSave.id)
        session.refresh(personFound)

        assertTrue(personToSave.name == personFound.name)
    })
}
Теперь получаем три оператора insert:
Hibernate: insert into Person (id, email, name) values (null, ?, ?)
Hibernate: insert into PhoneNumber (id, number) values (null, ?)
Hibernate: insert into PhoneNumber (id, number) values (null, ?)

Классы Data

Классы Data Kotlin – это обычные классы с дополнительными функциями, которые делают их подходящими в качестве держателей данных. Среди этих дополнительных функций есть реализации по умолчанию для методов equals, hashCode и toString.

Можно возразить, что можно использовать классы Data Kotlin в качестве сущностей JPA. В отличие от того, что здесь происходит естественным образом, использование классов Data в качестве сущностей JPA обычно не рекомендуется. Это в основном из-за сложных взаимодействий между миром JPA и теми реализациями по умолчанию, которые компилятор Kotlin предоставляет для каждого класса Data.

Для демонстрации будем использовать эту сущность:

@Entity
data class Address(    
    val name: String,
    @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
    val phoneNumbers: List<PhoneNumber>,
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int? = null,
)

Методы equals и hashCode

Начнем с реализации equals и hashCode. Большинство сущностей JPA содержат как минимум одно сгенерированное значение – например, автоматически сгенерированные идентификаторы. Это означает, что некоторые свойства генерируются только после того, как сохраняем их в базе данных.

Таким образом, вычисляемые equals и hashCode отличаются до и после сохранения, поскольку некоторые свойства, используемые во время вычислений equals и hashCode, генерируются после сохранения. Поэтому нужно быть осторожными при использовании сущностей JPA класса Data с коллекциями на основе хэшей:

@Test
fun givenAddressWithDefaultEquals_whenAddedToSet_thenNotFound() {
    doInHibernate({ sessionFactory() }) { session ->
        val addresses = mutableSetOf<Address>()
        val address = Address(name = "Berlin", phones = listOf(PhoneNumber("42")))
        addresses.add(address)

        assertTrue(address in addresses)
        session.persist(address)
        assertFalse { address in addresses }
    }
 }

В приведенном выше примере, несмотря на то, что добавили адрес в набор, мы не можем найти его в наборе после сохранения в базе данных. Это происходит потому, что значение хэш-кода изменяется после того, как сохраняем адрес.

В дополнение к этому, простые реализации equals и hashCode недостаточно хороши для использования в объектах JPA.

Нежелательное получение lazy-связей

Компилятор Kotlin генерирует реализации методов по умолчанию на основе всех свойств класса Data. Когда используем классы Data для объектов JPA, некоторые из этих свойств могут быть lazy-связью с целевым объектом. Учитывая все это, иногда безобидный вызов toString, equals или hashCode может вызвать еще несколько запросов для загрузки lazy-связи. Это может повредить производительности, особенно если не нужно извлекать эти связи:

@Test
fun givenAddress_whenLogging_thenFetchesLazyAssociations() {
    doInHibernate({ this.sessionFactory() }) { session ->
        val addressToSave = Address(name = "Berlin", phoneNumbers = listOf(PhoneNumber("42")))
        session.persist(addressToSave)
        session.clear()

        val addressFound = session.find(Address::class.java, addressToSave.id)
            
        assertFalse { Hibernate.isInitialized(addressFound.phoneNumbers) }
        logger.info("found the entity {}", addressFound) // initializes the lazy collection
        assertTrue(Hibernate.isInitialized(addressFound.phoneNumbers))
    }
}

В приведенном выше примере, казалось бы, безобидный оператор логирования запускает дополнительный запрос:

Hibernate: select * from Address address0_ where address0_.id=?
Hibernate: select * from Address_PhoneNumber phonenumbe0_ inner join PhoneNumber phonenumbe1_ on phonenumbe0_.phoneNumbers_id=phonenumbe1_.id where phonenumbe0_.Address_id=?

Заключение

В этой статье рассмотрели пример интеграции классов Kotlin с JPA с помощью jpa-plugin и Hibernate. Более того, увидели, почему следует быть осмотрительными при использовании классов Data в качестве сущностей JPA.

Исходный код доступен на GitHub.

Оригинал