Создание реактивных сервисов 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:

  • 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!


ссылка на оригинал статьи https://habr.com/ru/articles/807597/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *