Pull to refresh

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

Level of difficultyMedium
Reading time11 min
Views2.5K

В данной статье обсудим создание 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:

  • data-r2dbc

  • r2dbc

  • postgres

  • http-client

  • kapt

  • kotlin-extension-function

  • micronaut-aot

  • micronaut-http-validation

  • netty-server

  • openapi

  • reactor

  • reactor-http-client

  • serialization-jackson

  • swagger-ui

  • yaml

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

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

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

@Serdeable
data class Dictionary(
    val id: Long?,
    @Size(max = 255) val name: String,
    val values: List<DictionaryValue>,
) {
    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<M, K> {
    fun findAll(): Flux<M>
    fun findAll(pageable: Pageable): Mono<Page<M>>
    fun save(obj: M): Mono<M?>
    fun get(id: K): Mono<M?>
    fun update(obj: M): Mono<M>
    fun delete(id: K): Mono<K?>
}
interface ReactorStorageChildrenService<C, K> {
    fun findAllByDictionaryId(id: K): Flux<C>
}

Coroutine

interface CoStorageService<M, K> {
    fun findAll(): Flow<M>
    suspend fun findAll(pageable: Pageable): Page<M>
    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<C, K> {
    suspend fun findAllByDictionaryId(id: K): Flow<C>
}

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

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

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

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

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

@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<DictionaryDb, Long> {
    @Query("SELECT * FROM public.dictionary where id = :id;")
    abstract fun findByDictionaryId(id: Long): Flow<DictionaryDb>
}
@R2dbcRepository(dialect = Dialect.POSTGRES)
abstract class CoDictionaryValueRepository : CoroutinePageableCrudRepository<DictionaryValueDb, Long> {

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

Reactor

@R2dbcRepository(dialect = Dialect.POSTGRES)
abstract class DictionaryRepository : ReactorPageableRepository<DictionaryDb, Long>
@R2dbcRepository(dialect = Dialect.POSTGRES)
abstract class DictionaryValueRepository : ReactorPageableRepository<DictionaryValueDb, Long> {

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

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

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

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

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

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

@Singleton
class DictionaryService(private val repository: DictionaryRepository) : ReactorStorageService<Dictionary, Long> {

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

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

    override fun findAll(): Flow<DictionaryValue> = repository.findAll().map { it.toResponse() }
    override suspend fun findAll(pageable: Pageable): Page<DictionaryValue> = 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<DictionaryValue> {
        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<Dictionary, Long>,
    private val dictionaryValueService: CoStorageChildrenService<DictionaryValue, Long>,
) {

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

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

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

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

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

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

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

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

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

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

@Get("/list-with-values")
    fun getAll(): Flow<Dictionary> {
        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<Int> =
        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<CoDictionaryRepository>()

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

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<Dictionary>::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!

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 4: ↑4 and ↓0+4
Comments16

Articles