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