Создание реактивных сервисов Micronaut и Kotlin

В данной статье обсудим создание REST-сервиса в «реактивном» исполнении. Приведу примеры кода на Kotlin в двух вариантах: Reactor и Сoroutines. Добавлю что почти всё, написанное в статье о реактивной реализации сервисов, относится и к SpringBoot.

Micronaut

Micronaut — это JVM-фреймворк для создания микросервисов, это JVM-инфраструктура для создания микросервисов на Java, Kotlin или Groovy. Создатель фреймворка Грэм Роше (Graeme Rocher). Он создал структуру Grails и применил многие свои знания для создания Micronaut. Micronaut предоставляет множество преимуществ в качестве платформы.

  • Быстрое время запуска

  • Низкое потребление памяти

  • Эффективное внедрение зависимостей во время компиляции

  • Реактивный.

Создание проекта Micronaut

Существует три способа создания проекта Micronaut.

Суть сервиса, описанного в статье

Для того, чтобы оценить все особенности реализации сервисов на Micronaut давайте рассмотрим пример, реализующий сервис REST API с CRUD-функциональностью. Информацию будем хранить во внешней базе данных PostgreSql. Сервис соберем в двух вариантах: 1) привычная JVM-сборка 2) нативная сборка. Напомню, нам любящим Java (Kotlin), теперь доступна возможность нативной сборки.

Я выбрал для демонстрации функциональность «Справочники». На пользовательском уровне это человекочитаемый ключ, к которому привязаны некие значения. Записями такого справочника могут быть, например: «типы документов» : { «Паспорт РФ», «Свидетельство о рождении» и тд }, «Типы валют»: { «Рубль», «Доллар», «Евро», и др }. Примеров использования справочников можно примести много. Дополнительна ценность таких реализаций в том, что некие данные, претендующие на «константность», не «хардкодятся» в системе, а находятся за пределами кода со всеми вытекающими из такой реализации удобствами сопровождения систем. Т.е. это некая простая двухуровневая структура, где на верхнем уровне агрегирующая запись, а на подчиненном уровне — связанные с записью элементы.

Код полного приложения можно скачать по ссылке в конце статьи. Также добавлю, что всё, что мы обсуждаем в части реализации, можно отнести и к SpringBoot, так как REST-контроллеры SpringBoot и Micronaut практически идентичны. 

Пример учебный, не претендующий на идеальную реализацию концепции «чистой архитектуры», местами реализация может показаться кому-то и спорной.

Также сервис будет уметь самодокументироваться: при запуске сервиса будет доступна информация по его endpoit-ам, т.е. будет визуальное представление его сутевых endpoit-ов  в привычном виде swagger (OpenApi).

База данных (PostgreSql)

Записи будут хранится в базе данных PostgreSql. Структура записей — простая только для демонстрации технологий. В реальной системе «справочники» немного сложнее по своей структуре.

Скрипт создания записей в базе данных:

CREATE TABLE public."dictionary"
(
    id int PRIMARY KEY GENERATED BY DEFAULT AS identity,
    "name" varchar(255)  NOT NULL,
    CONSTRAINT unique_name UNIQUE ("name")
);

CREATE TABLE public.dictionary_value
(
    id int PRIMARY KEY GENERATED BY DEFAULT AS identity,
    code          varchar(255)  NOT NULL,
    value         varchar(255)  NOT NULL,
    dictionary_id int NULL references public."dictionary" (id)
);

Создание проекта Micronaut

Для работы создадим проект Micronaut со следующими feature:

Структура папок проекта

Структура папок проекта

В реализации будем придерживаться рекомендаций «чистой архитектуры»

Классы моделей

@Serdeable
data class Dictionary(
    val id: Long?,
    @Size(max = 255) val name: String,
    val values: List,
) {
    constructor(id: Long? = null, name: String) : this(id, name, emptyList())
}
@Serdeable
data class DictionaryValue(
    val id: Long = 0L,
    @JsonProperty("parent_id")
    val parentId: Long,
    @Size(max = 80) val code: String,
    @Size(max = 255) val value: String,
)

@Serdeable
data class ShortDictionaryValue(
    @JsonProperty("parent_id")
    val parentId: Long,
    @Size(max = 80) val code: String,
    @Size(max = 255) val value: String,
)

Я не стал усложнять код и совместил модель и dto-шки.

Аннтонтация @Serdeable   нужна, чтобы разрешить сериализацию или десериализацию типа. О об особенностях сериализации Micronaut в том числе и преимуществах реализации по сравнению с Jackson Databind можно почитать здесь: Micronaut Serialization .

Класс «ShortDictionaryValue» нужен для более чистого кода при реализации функциональности добавления записей значения словаря. Нам не нужно какими-то способами при добавлении записи «скрывать» лишнее в контексте данной операции поле «val id: Long». И OpenApi в представлении «swager» будет более корректным. Это распространенный приём, который часто можно встретить в разных реализациях.

Спецификации сервисов

Reactor

interface ReactorStorageService {
    fun findAll(): Flux
    fun findAll(pageable: Pageable): Mono>
    fun save(obj: M): Mono
    fun get(id: K): Mono
    fun update(obj: M): Mono
    fun delete(id: K): Mono
}
interface ReactorStorageChildrenService {
    fun findAllByDictionaryId(id: K): Flux
}

Coroutine

interface CoStorageService {
    fun findAll(): Flow
    suspend fun findAll(pageable: Pageable): Page
    suspend fun save(obj: M): M
    suspend fun get(id: K): M?
    suspend fun update(obj: M): M
    suspend fun delete(id: K): K
}
interface CoStorageChildrenService {
    suspend fun findAllByDictionaryId(id: K): Flow
}

Обратите внимание на разницу Reactor vs Coroutine:

fun noResultFunc(): Mono
suspend fun noResultFunc()
fun singleItemResultFunc(): Mono
fun singleItemResultFunc(): T?
fun multiItemsResultFunc(): Flux
fun mutliItemsResultFunc(): Flow

Реализация сервисов

Вся специфика имплементации — в другом модуле (пакете).

Классы-модели, связанные с конкретной СУБД

@Serdeable
@MappedEntity(value = "dictionary")
data class DictionaryDb(
    @GeneratedValue
    @field:Id val id: Long? = null,
    @Size(max = 255) val name: String,
)

@Serdeable
@MappedEntity(value = "dictionary_value")
data class DictionaryValueDb(
    @GeneratedValue
    @field:Id val id: Long? = null,
    @Size(max = 80) val code: String,
    @Size(max = 255) val value: String,
    @MappedProperty(value = "dictionary_id")
    @JsonProperty(value = "dictionary_id")
    val dictionaryId: Long,
)

Классы-«репозитории»

Coroutine:

@R2dbcRepository(dialect = Dialect.POSTGRES)
abstract class CoDictionaryRepository : CoroutinePageableCrudRepository {
    @Query("SELECT * FROM public.dictionary where id = :id;")
    abstract fun findByDictionaryId(id: Long): Flow
}
@R2dbcRepository(dialect = Dialect.POSTGRES)
abstract class CoDictionaryValueRepository : CoroutinePageableCrudRepository {

    @Query("SELECT * FROM public.dictionary_value where dictionary_id = :id;")
    abstract fun findAllByDictionaryId(id: Long): Flow
}

Reactor

@R2dbcRepository(dialect = Dialect.POSTGRES)
abstract class DictionaryRepository : ReactorPageableRepository
@R2dbcRepository(dialect = Dialect.POSTGRES)
abstract class DictionaryValueRepository : ReactorPageableRepository {

    @Query("SELECT * FROM public.dictionary_value where dictionary_id = :id;")
    abstract fun findAllByDictionaryId(id: Long): Flux

    @Query("SELECT * FROM public.dictionary_value where dictionary_id = :dictionaryId and code = :code;")
    abstract fun findByCodeAndDictionaryId(
        code: String,
        dictionaryId: Long,
    ): Mono
}

Обратите внимание на похожесть реализации этих классов с подобной реализацией в SpringBoot.

Ну и все наши классы репозитории для работы с БД — реактивные (аннотоция @R2dbcRepository)

Имплементация сервисов

Для сохращения размера статьи буду показывать только принципиальные моменты. Весь код доступен по ссылке в конце статьи.

@Singleton
class DictionaryService(private val repository: DictionaryRepository) : ReactorStorageService {

    override fun findAll(pageable: Pageable): Mono> =
        repository.findAll(pageable).map {
            it.map { itDict ->
                itDict.toResponse()
            }
        }

    override fun findAll(): Flux = repository.findAll().map { it.toResponse() }
    override fun save(obj: Dictionary): Mono = repository.save(obj.toDb()).mapNotNull { it.toResponse() }
    override fun get(id: Long): Mono = repository.findById(id).map { it.toResponse() }
    override fun update(obj: Dictionary): Mono = repository.update(obj.toDb()).map { it.toResponse() }
    override fun delete(id: Long): Mono = repository.deleteById(id)
}
@Singleton
class CoDictionaryValueService(private val repository: CoDictionaryValueRepository) :
    CoStorageService, CoStorageChildrenService {

    override fun findAll(): Flow = repository.findAll().map { it.toResponse() }
    override suspend fun findAll(pageable: Pageable): Page = repository.findAll(pageable).map {
        it.toResponse()
    }
    override suspend fun delete(id: Long): Long = repository.deleteById(id).toLong()
    override suspend fun update(obj: DictionaryValue): DictionaryValue = repository.update(obj.toDb()).toResponse()
    override suspend fun get(id: Long): DictionaryValue? = repository.findById(id)?.toResponse()
    override suspend fun save(obj: DictionaryValue): DictionaryValue = repository.save(obj.toDb()).toResponse()

    override suspend fun findAllByDictionaryId(id: Long): Flow {
        return repository.findAllByDictionaryId(id).map { it.toResponse() }
    }
}

Обратите внимание на аннотацию Singleton. По смыслу она близка аннотации Service в SpringBoot. И таких отличий очень немного. Т.е. я ещё раз акцентирую внимание на общую сильную схожесть Micronaut и SpringBoot. И как следствие на лёгкость перехода, если кому-то тоже понравится Micronaut.

Реализация контроллеров

Не буду веь код копировать, а остановлюсь на наиболее на мой взгляд интересных моментах. Обратите внимание на аннотации. Аналогичны SpringBoot. Еще важный момент: все переменные-параметры контроллеров имеют тип интерфейсов, а не конкретных имплементаций. Контроллеры ничего не знают ни про специфику базы данных, ни про репозитории.

@Controller("/api/v1/co-dictionary")
open class CoDictionaryController(
    private val service: CoStorageService,
    private val dictionaryValueService: CoStorageChildrenService,
) {

    @Get("/list")
    fun findAll(): Flow = service.findAll()

    @Get("/list-pageable")
    open suspend fun list(@Valid pageable: Pageable): Page = service.findAll(pageable)

    @Get("/list-with-values")
    fun getAll(): Flow {
        return service.findAll().mapNotNull(::readDictionary)
    }

    // todo для статьи
    @Get("/stream")
    fun stream(): Flow =
        flowOf(1,2,3)
            .onEach { delay(700) }

    @Post
    suspend fun save(@NotBlank name: String): HttpResponse {
        return createDictionary(service.save(Dictionary(name = name)))
    }
    // ...

}
@Controller("/api/v1/dictionary-value")
class DictionaryValueController(private val dictionaryService: ReactorStorageService) {
    @Get("/list-pageable")
    open fun list(@Valid pageable: Pageable): Mono> = dictionaryService.findAll(pageable)

    @Get("/list")
    fun findAll(): Flux = dictionaryService.findAll()

    @Post
    fun save(@NotBlank @Body value: ShortDictionaryValue): Mono> {
        return dictionaryService.save(value.toResponse()).mapNotNull {
            createDictionaryValue(it!!)
        }
    }

    @Get("/{id}")
    fun get(id: Long): Mono = dictionaryService.get(id)
// ...
}

Часто при реализации получения подобных иерархических  данных спорят, нужно ли сразу получать дочерние элементы в общую структуру? Или получать в режиме lazy только при необходимости. У обоих подходах есть плюсы и минусы и нужно принимать решение, исходя их контекста задачи. Например, «фронту» иногда удобнее иметь и основную запись и дочерние записи сразу, чтобы сэкономить усилия и не делать дополнительный запрос в строну бэка. Я добавил такую реализацию для примера. Вот здесь появляется очень сильное и тонкое отличие между Coroutine и Reactor. Нужно применять преобразования реактивных потоков. Я напомню, что реактивно мы получаем и основную запись и реактивно получаем связанные с этой основной записью и её дочерние элементы. Реализация на Coroutine например выглядит так:

@Get("/list-with-values")
    fun getAll(): Flow {
        return service.findAll().mapNotNull(::readDictionary)
    }
// ...
private suspend fun readDictionary(dictionary: Dictionary): Dictionary {
        if (dictionary.id == null) return dictionary
        val values = dictionaryValueService.findAllByDictionaryId(dictionary.id).toList()
        if (values.isEmpty()) return dictionary
        return dictionary.copy(
            values = values
        )
    }

Реактивность сохранена, мы возвращаем Flow. Для версии с Coroutine я еще оставил такой «безполезный» код:

// todo для статьи
    @Get("/stream")
    fun stream(): Flow =
        flowOf(1,2,3)
            .onEach { delay(700) }

Отдаем данные в реактивном стриме и при этом специально засыпаем :) .

Теперь давайте посмотрим как это всё вместе работает.

Работа сервиса

Сборку будет собирать в двух вариантах:

1) версия на JVM

2) Нативная сборка

Для нативной сборки удобно использовать соответсвующую задачу gradle:

./gradlew dockerBuildNative

Про сборку в docker-образ для Micronaut можно почитать здесь: Building a Docker Image of your Micronaut application

Сборку в нативном исполнении я также выложил в docker-hub который доступен для скачивания как «pawga777/micronaut-dictionary: latest».

Запустить на исполнение собранное приложение можно через docker compose используя следующий конфигурационный файл (docker-compose.yml):

version: '3.5'
services:
  app:
    network_mode: "host"
    environment:
      DB_HOST: localhost
      DB_USERNAME: postgres
      DB_PASSWORD: ZSE4zse4
      DB_NAME: r2-dict-isn
      DB_PORT: 5432
    image: pawga777/micronaut-dictionary:latest

Запуск:

Запуск JVM-версии

Запуск JVM-версии

Запуск

Запуск «нативной» версии

Обратите внимание на разницу времени старта: 1548ms vs 144ms. Впечатляет? При этом аналогичная версия на SpringBoot стартует около 3000ms (Micronaut принципиально быстрее чем SpringBoot). В JVM-версии Micronaut еще можно использовать технологию CRaC, что улучшит характеристика старта, если по каким-то причинам нативная сборка не подойдет. Пример с CRaC от Micronaut: Micronaut CRaC .

Swagger (OpenApi)

Swagger (OpenApi)

тестовый запрос

тестовый запрос

Для простого тестирования даже Postman не требуется так как есть работающий «swagger».

Тесты

Тема тестов также обширная. В примере используется Kotest. Micronaut умеет тестировать работу с базой данных через технологию «тестовых» контейнеров (Testcontainers). При этом в Micronaut добавлено дополнительно небольшое упрощение Testcontainers как «Test Resources». Ключевое в фразе выше «добавили», т…е оба подхода существуют. Хороший пример Micronaut, где они эти технологии описывает (СУБД H2 и PostgreSQL): REPLACE H2 WITH A REAL DATABASE FOR TESTING. Но мне показалось подозрительным, что у них нет примера на Kotlin. Оказалось есть причина, т е. у Micronaut есть именно с реализацией на Kotlin. Для демонстрационного примера я показал подход тестирования контроллеров на одном примере. Обходим тестирование репозиториев моками:

@MockBean(CoDictionaryRepository::class)
    fun mockedPostRepository() = mockk()

Код тестирования одной «ручки» выглядит так:

given("CoDictionaryController") {
        `when`("test find all") {
            val repository = getMock(coDictionaryRepository)
            coEvery { repository.findAll() }
                .returns(
                    flowOf(
                        DictionaryDb(
                            id = 1,
                            name = "test1",
                        ),
                        DictionaryDb(
                            id = 2,
                            name = "test2",
                        ),
                    )
                )
            val response = client.toBlocking().exchange("/list", Array::class.java)

            then("should return OK") {
                response.status shouldBe HttpStatus.OK
                val list = response.body()!!
                list.size shouldBe 2
                list[0].name shouldBe "test1"
                list[1].name shouldBe "test2"
            }
        }

Мы подменяем только запрос получения из базы данных непосредственно записей, оставляя без моков остальной код (сервисы, контроллеры).

Kotest, кстати, может дать разработчику некую свободу. В примере подход BehaviorSpec, который подходит для любителей стиля BDD. BehaviorSpec позволяет использовать context, given, when, then. Но есть еще и другие спецификации: FunSpec, AnnotationSpec, ShouldSpec, FeatureSpec и так далее. Их немного, можно подобрать для себя более привычный подход. AnnotationSpec, например, применяется при переходе с JUnit, позволяя перенести существующие тесты (каждый тест в виде отдельной функции с аннотацией Test:

class AnnotationSpecExample : AnnotationSpec() {

    @BeforeEach
    fun beforeTest() {
        println("Before each test")
    }

    @Test
    fun test1() {
        1 shouldBe 1
    }

    @Test
    fun test2() {
        3 shouldBe 3
    }
}

FunSpec позволяет создавать тесты, вызывая функцию, вызываемую test со строковым аргументом для описания теста, а затем сам тест в виде лямбды. Если у вас есть сомнения, используйте этот стиль:

class MyTests : FunSpec({
    test("String length should return the length of the string") {
        "sammy".length shouldBe 5
        "".length shouldBe 0
    }
})

Подробности о тестировании в Micronaut здесь: Micronaut Test

Эпилог

Код примера, описанного в статье: micronaut-dictionary

Напомню, что я докер-образ решения выложил в docker-hub.

Всем прочитавшим до конца мою статью — спасибо.

Happy Coding!

© Habrahabr.ru