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


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


Для простого тестирования даже 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!