Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В статье рассмотрены проблемы и решения, которые возникли при добавлении Kotlin в существующий небольшой микросервис на Spring Boot, написанный изначально на Java. В рамках статьи не будут рассматриваться плюсы и минусы того или иного языка - здесь и так сломано много копий (некоторые статьи на habr: 1, 2, 3) и, как мне кажется, каждая команда должна это решать для себя. Рассматривается стандартный стек Spring WebMVC (не реактивный)
Содержание
Краткое описание микросервиса, с которым будем работать
С чего начать
Переводим тесты на Kotlin
Подключаем Kotlin в проект
Kotlin plugins
Работа со Spring
Работа с Hibernate
Остальные библиотеки
Jackson
MapStruct
Lombok
Mockito
Логирование
Выводы
Использованные материалы
Краткое описание микросервиса, с которым будем работать
До перевода на Kotlin использовался такой стек:
Java 11
Spring Web MVC (в рамках Spring Boot)
Spring Data JPA
Map Struct
Lombook
Maven
С чего начать
Переводим тесты на Kotlin
Первая рекомендация, которую вы найдете, - начинайте с тестов. В рамках своего проекта - подтверждаем, что это самый правильный путь. Пока вы будете добавлять тесты на Kotlin в проект, вы пройдете через следующие этапы:
Настроите сборку совместно с Kotlin
Добавите требуемые библиотеки
Поймаете основные ошибки
Наконец-то напишите тесты на давно заброшенные участки кода
И при этом основной код, отвечающий за бизнес-логику затронут не будет. Начать с тестов нам очень помогло, даже хотя бы тем, что поймали пару ошибок в нашей существующей бизнес-логике.
Подключаем Kotlin в проект
Несколько слов о Gradle Kotlin DSL
Дополнительно к стандартному набору для сборки пакетов (Maven и Gradle на Groovy) теперь добавился еще один - Gradle Kotlin DSL. Это может быть хорошим вариантом для тех, кто хотел бы использовать Gradle, но смущала необходимость использовать Groovy.
Из плюсов данного решения - более корректные подсказки в IDEA (хотя, если честно, IDEA сильно тормозит при анализе build.gradle.kt файлов). Одним из главных неудобств, как мне кажется, является то, что не все возможности Gradle на Groovy реализованы или реализованы не зеркально, и поэтому многие подсказки со stackoverflow не будут работать. Поэтому мы остались на привычном нам Maven.
Maven
Если вы используете стек Spring-boot, то рекомендую для начала создать тестовый проект с нуля через https://start.spring.io/.
Ниже приведены 2 pom.xml файла, которые создает https://start.spring.io/ для Java и Kotlin.
pom.xml для Java
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>demo-java</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo-java</name>
<description>demo-java</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
pom.xml для Kotlin
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>demo-kotlin</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo-kotlin</name>
<description>demo-kotlin</description>
<properties>
<java.version>11</java.version>
<kotlin.version>1.5.31</kotlin.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<args>
<arg>-Xjsr305=strict</arg>
</args>
<compilerPlugins>
<plugin>spring</plugin>
<plugin>jpa</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>
Давайте рассмотрим, в чем разница с Java проектом по мнению https://start.spring.io/:
Добавилась версия Kotlin
<properties>
....
<kotlin.version>1.5.31</kotlin.version>
</properties>
Добавились 2 стандартных библиотеки для работы с Kotlin и библиотека для Jackson (про нее - ниже)
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
</dependency>
В раздел build добавился раздел про Kotlin
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
.....
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<args>
<arg>-Xjsr305=strict</arg>
</args>
<compilerPlugins>
<plugin>spring</plugin>
<plugin>jpa</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
Подробнее про kotlin-maven-plugin можно прочитать здесь.
Данная конфигурация подойдет только, если у вас в проекте не будет кода на Java. Если вы хотите использовать оба языка (что и происходит при миграции), то нужно добавить и корректно настроить maven-compiler-plugin. (Пример можно посмотреть здесь или здесь)
Пример для Kotlin и Java
<build>
<plugins>
.....
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<executions>
<execution>
<id>compile</id>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<goals> <goal>test-compile</goal> </goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/test/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
<configuration>
<args>
<arg>-Xjsr305=strict</arg>
</args>
<compilerPlugins>
<plugin>spring</plugin>
<plugin>jpa</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<!-- Replacing default-compile as it is treated specially by maven -->
<execution>
<id>default-compile</id>
<phase>none</phase>
</execution>
<!-- Replacing default-testCompile as it is treated specially by maven -->
<execution>
<id>default-testCompile</id>
<phase>none</phase>
</execution>
<execution>
<id>java-compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>java-test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
-Xjsr305=strict
позволяет Kotlin корректно использоваться nullable-типы для некоторых api Spring (ссылка)
Про плагины All-open и No-arg будет подробнее рассказано нижe.
Kotlin plugins
Для изменения процесса компиляции в Kotlin используются compiler plugins.
По умолчанию spring-initializer добавляет 2 плагина: all-open и no-arg. Так же часто используются kapt и плагин для Lombok.
all-open
По умолчанию все классы и их члены в Kotlin имеют неявный модификатор final
и, соответственно, они не могут быть наследованы и переопределены, что очень сильно ограничивает их использование в Spring. Чтобы решить эту проблему, используется plugin all-open (документация), который добавляет open
к указанным аннотациями классам и членам классов. Для работы со spring используется уже преднастроенный plugin kotlin-spring.
Kotlin-spring работает со следующими аннотациями:
@Component
@Async
@Transactional
@Cacheable
@SpringBootTest
Если вы делаете свою аннотацию, то возможно вам стоит добавить plugin kotlin-allopen и настроить его для работы с этой аннотацией.
Обратите внимание, что в списке аннотаций нет аннотаций, относящихся к JPA и их нужно добавлять отдельно (см. статью от Haulmont).
No-arg
Плагин добавляет пустой конструктор к указанным аннотациями классам. Для jpa есть преднастроенный kotlin-jpa плагин. Он работает со следующими аннотациями: @Entity
, @Embeddable
, and @MappedSuperclass
. При этом он не делает эти классы open
, это требуется сделать руками с помощью плагина kotlin-allopen.
Kapt
Является адаптером для annotation processors (документация). Используется, например, mapstruct для генерации кода для маперов на kotlin
Пример добавления mapstruct для Kotlin
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>kapt</id>
<goals>
<goal>kapt</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>src/main/kotlin</sourceDir>
<sourceDir>src/main/java</sourceDir>
</sourceDirs>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</annotationProcessorPath>
</annotationProcessorPaths>
</configuration>
</execution>
...
</executions>
....
</plugin>
По опыту использования могу сказать, что довольно часто выдает ошибки и ломает сборку, особенно, если в проекте есть Lombok.
Lombok
Плагин позволяет корректно вызывать сгенерированные lombok методы из kotlin кода. (Документация). Плагин находится еще в бете, и не поддерживает @Builder
.
Работа со Spring
Spring уже хорошо оптимизирован для работы с Kotlin.
По выступлениям можно посмотреть, как и что добавлялось для поддердки Kotlin:
выступление 2020 года (видео и расшифровка)
выступление 2021 года (видео)
В этой части статьи приведены небольшие рекомендации и различия по использования Kotlin со Spring по сранению с Java.
Так как точка запуска в Kotlin является функция main, то запуск Spring-Boot приложения теперь выглядит так:
@SpringBootApplication
class DemoKotlinApplication
fun main(args: Array<String>) {
runApplication<DemoKotlinApplication>(*args)
}
В Spring добавлены и добавляются функции расширения для удобства использования Kotlin, например, RestOperationsExtensions.kt. Поэтому стоит поискать новое API, адаптированное для Kotlin - возможно оно уже есть
Для Kotlin для внедрения зависимостей рекомендуется использовать
val
аргументы в конструкторе,
@Component
class YourBean(
private val mongoTemplate: MongoTemplate,
private val solrClient: SolrClient
)
Но возможны и другие варианты.
Если требуется внедрить зависимость для поля, то можно использовать latenit var
@Component
class YourBean {
@Autowired
lateinit var mongoTemplate: MongoTemplate
@Autowired
lateinit var solrClient: SolrClient
}
Эта запись эквивалентна такой записи в Java:
@Component
public class YourBean {
@Autowired
public MongoTemplate mongoTemplate;
@Autowired
public SolrClient solrClient;
}
Можно использовать инъекцию и через set-методы, но при этом поле становится Nullable
var hello: HelloService? = null
@Autowired
set(value) {
field = value
println("test")
}
Для конфигурации свойств требуется экранировать $:
@Value("\${property}")
Поддерживается создания классов конфигураций через конструктор при использовании
@ConstructorBinding
@ConfigurationProperties("test")
@ConstructorBinding
class TestConfig(
val name:String
)
Так же можно создавать через lateinit var
@ConfigurationProperties("test")
class TestConfig {
lateinit var name: String
}
Для генерации мета-информации нужно настроить spring-boot-configuration-processor для работы с kapt
Работа с Hibernate
Основные ошибки и проблемы хорошо описаны в статье Haulmont.
Еще раз обращу внимание на необходимость корректной настройки плагинов no-args и all-open и на корректную реализацию методов hashCode и equals.
Остальные библиотеки
Jackson
Spring использует Jackson для сериализации/десериализации данных. Для работы с Kotlin требуется добавить зависимость Jackson Module Kotlin. После этого вы сможете работать с Kotlin-специфичными типами. При этом нет необходимости явно указывать типа объектов.
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
data class MyStateObject(val name: String, val age: Int)
...
val mapper = jacksonObjectMapper()
val state = mapper.readValue<MyStateObject>(json)
// or
val state: MyStateObject = mapper.readValue(json)
// or
myMemberWithType = mapper.readValue(json)
MapStruct
MapStruct работает через annotation processor. Поэтому для его работы необходимо корректно настроить kapt. При этом если модели используют аннотации Lombok, то при генерации мапперов будут проблемы (см. Lombok)
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>kapt</id>
<goals>
<goal>kapt</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>src/main/kotlin</sourceDir>
<sourceDir>src/main/java</sourceDir>
</sourceDirs>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</annotationProcessorPath>
</annotationProcessorPaths>
</configuration>
</execution>
...
</executions>
....
</plugin>
Lombok
Работа с Lombok-методами напрямую из Kotlin из коробки не работает. Для этого требуется использовать Lombok compiler plugin.
При этом возникают большие проблемы при совместной работе Kapt и Lombok. По умолчанию kapt начинает запускать все annotation processors и отключает их работу через javac. Для того чтобы Lombok продолжал работать, требуется явно это указать
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</annotationProcessorPath>
</annotationProcessorPaths>
</configuration>
</plugin>
При этом Lombok будет корректно работать с kapt только, если annotation processor, который используют kapt, не зависит от Lombok. В нашем случае это было не так, так как мы используем MapStruct, и поэтому пришлось в первую очередь всю доменную модель переводить на Kotlin. Так же одним из вариантов является использование Delombok. Эта проблема известная и уже поднималась на habr.
Mockito
Mockito некорректно работает с типами Kotlin из коробки. Поэтому Spring рекомендует использовать Mockk. Также для Mockito появился специальный модуль, добавляющий поддержку Kotlin - Mockito-Kotlin.
В нашем проекте мы использовали Mockito-Kotlin. Единственная проблема в нем, что нужно аккуратно следить, откуда что импортируется, так как, например, any()
теперь будут в 2 местах - org.mockito.kotlin
и org.mockito.Mockito
Логирование
При разработке enterprise-приложений привыкаешь ставить аннотацию @Slf4j
от Lombok и получать готовый логгер. Так как Lombok с Kotlin не дружит, то приходится искать другие пути.
Можно пойти по пути создания своей надстройки - как в этой статье. Но для нас оказалась очень удобной библиотека - kotlin-logging, которая предоставляет возможность делать, например, вот такие вещи:
import mu.KotlinLogging
private val logger = KotlinLogging.logger {}
class FooWithLogging {
val message = "world"
fun bar() {
logger.debug { "hello $message" }
}
}
Как видно, нам здесь не нужно указывать явно класс и пакет. А также поддерживается ленивое создание сообщения.
Выводы
Хочется закончить статью краткими выводами. Использование Java и Kotlin совместно в одном проекте требует дополнительныx настроек, но практически все решается и мы получаем возможность использовать 2 языка в одном проекте. Самой большой проблемой для нас оказалось невозможность полностью подружить Lombok и Kotlin.
Kotlin приносит много полезных идей. Но, как мне кажется, полностью удобство Kotlin начинаешь ценить при разработке реактивного кода. В данной статье это не рассмотрено, но это хорошо показано, например, в этой статье.
Использованные материалы
Документация по Kotlin
Выступление 2020 года о поддержке Kotlin в Spring (видео и расшифровка)
Выступление 2021 года о поддержке Kotlin в Spring (видео)
Документация о поддержке Kotlin в Spring
Документация о поддержке Kotlin в Spring Boot
Использование MapStruct с Kotlin
Использование Jackson с Kotlin
Использование Mockito с Kotlin
Библиотека kotlin-logging