null

Reactive REST API. Kotlin, Spring WebFlux и R2DBC

1. Подготовка и настройка проекта

Начнем с создания нового проекта в IntelliJ Idea.
В окне New project на необходимо выбрать Spring Initializr.
Настраиваем этот генератор как показано на картинке:

Задаем имя, выбираем язык - Kotlin, выбираем тип проекта - Gradle-Kotlin, выбираем версию JDK и Java - я выбрал 17-ую, выбираем тип упаковывания - jar, и жмем на кнопку Next.

В следующем окне выбираем одну из свежих версий SpringBoot, а также нужные нам зависимости:
1) Spring Reactive Web - необходима для создания реактивного веб-приложения с помощью Spring WebFlux и Netty.
2) Spring Data R2DBC - поможет подключиться к реактивной реляционной базе данных. 
3) PostgreSQL Driver - содержит R2DBC-драйвер PostgreSQL.
4) Validation - в нашем случае нужна, чтобы выполнять различные проверки данных на соответствие заданным ограничениям и условиям, а также чтобы задавать эти ограничения и условия.

После этого нажимаем кнопку Create.

Ура, наш проект создан.

Первым делом, зайдите в настройки IntelliJ Idea в раздел Build, Execution, Deployment --> Build Tools --> Gradle и убедитесь что версия Gradle JVM совпадает с той версией JDK, что вы выбрали для проекта.

В моем случае они не совпадали и проект выдавал ошибку Gradle, пока я не установил нужную версию.

Для хранения и работы с данными наше приложение будет использовать базу данных PostgreSQL.

Для удобства будем использовать Docker Compose. 
Установка, настройка Docker, а также правила составления compose файлов не рассматриваются в данной статье и оставляются вам для самостоятельного изучения.

Создадим в корне проекта файл с названием docker-compose.yml со следующим содержимым:

version: '3.8'

services:
  postgres:
    container_name: "reactive-postgres"
    image: postgres
    ports:
      - "127.0.0.1:5432:5432"
    environment:
      POSTGRES_USER: demo
      POSTGRES_PASSWORD: demo
      POSTGRES_DB: coursesshopreactive

Вместо 127.0.0.1 вы можете написать localhost. Просто в моем случае localhost = 127.0.0.1 и я решил указать адрес как есть, не используя синоним. 
Далее запустим терминал из директории где находится этот файл и выполним следующую команду:

docker compose up -d

Для управления контейнерами docker, я обычно использую программу Docker Desktop. Зайдя в неё, я вижу, что предыдущая команда скачала образ PostgreSQL, создала контейнер с именем reactive-postgres, в нем запустила скачанный образ; создала в образе базу данных с именем coursesshopreactive и пользователя demo с паролем demo, и наконец пробросила порт 5432, по которому мы можем взаимодействовать с базой.

Теперь нам необходимо создать в базе данных таблицу, в которой будет храниться информация об онлайн курсах нашего интернет магазина. 
Для этого воспользуемся функционалом IntelliJ Idea для работы с базами данных. 

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

Если у вас не установлен драйвер, среда разработки предложит его скачать.

Далее создадим таблицу со структурой как на картинке:

Также нам необходимо создать конфигурационный файл нашего приложения, где будут храниться настройки подключения к базе данных, настройки веб-сервера и др.
Этот файл нужно назвать application.yml и разместить его в каталоге src->main->resources.
Картинку с итоговой структурой проекта вы увидите дальше в этой статье.

Содержимое файла следующее:

spring:
 r2dbc:
  url: "r2dbc:postgresql://localhost:5432/coursesshopreactive"
  username: demo
  password: demo

server:
  port: 8080
  error:
   include-message: always

В файлах yml важны отступы, которые определяют структуру иерархии параметров. Просто помните об этом.

Отлично! На этом первоначальная подготовка и настройка проекта закончена.
Теперь переходим к созданию функционала приложения. 

2. Программирование функционала приложения

Создадим приложение по классической схеме разработки приложений АПИ.
У нас будет Контроллер, который будет обслуживать различные запросы пользователей нашего АПИ.
Также у нас будет Репозиторий для взаимодействия с базой данных и Сервис, в котором будет реализована бизнес-логика приложения.
Ну и само по себе разумеющееся, у нас будет Модель, описывающая все необходимые нам сущности нашей предметной области.

Начнем с того, что определим какой функционал мы хотим предоставить пользователям нашего АПИ.
Давайте сделаем так, чтобы пользователи могли:

а) получить информацию по всем имеющимся в нашем магазине онлайн курсам ("получить все курсы", GET-запрос)
б) получить информацию о курсе по его id (GET-запрос)
в) добавить информацию о новом курсе в магазин ("добавить новый курс", POST-запрос) 
г) обновить информацию для существующего курса по его id ("обновить курс", PUT-запрос)
д) удалить курс из магазина по его id (DELETE-запрос)

2.1 Программирование Модели

Для начала создадим Package с именем model по адресу src->main->kotlin->com.tuneit.coursesshopreactive->
В нем мы будем располагать основные и вспомогательные классы, описывающие Модель нашего приложения.

Создадим класс OnlineCourse.kt:

package com.tuneit.coursesshopreactive.model

import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Table
import java.time.LocalDate

@Table(name = "online_courses")
data class OnlineCourse (
    @Id
    val id : Long?=null,
    val name : String,
    val price : Float,
    val author : String,
    val direction : String,
    val startDate : LocalDate,
    val endDate: LocalDate?=null,
)

 

Как вы уже смогли понять, это основной класс, который описывает сущность "Онлайн курс". И говоря простым языком, благодаря аннотациям (@Table, @Id), он связан с таблицей online_courses в БД и ее данными (назовем это отображением данных).

Также в этом же package создадим класс CourseRequest.kt:

package com.tuneit.coursesshopreactive.model;

import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.PositiveOrZero
import java.time.LocalDate

data class CourseRequest(
    @field:NotBlank(message = "name should be specified") val name : String,
    @field:PositiveOrZero(message = "price must be >= 0") val price : Float,
    @field:NotBlank(message = "author should be specified") val author : String,
    @field:NotBlank(message = "direction should be specified") val direction : String,
    val startDate : LocalDate,
    val endDate: LocalDate?=null
)

Он необходим для запросов на добавление и обновление курсов (POST, PUT). По сути он отображает тело запроса.
Обратите внимание что мы задали для некоторых полей класса ограничения (с помощью аннотаций @field:NotBlank, @field:PositiveOrZero), которые будут затем валидироваться при обработке запросов. 
В случае ошибок валидации, пользователь будет видеть соответствующие сообщения, которые мы указали для аннотаций в свойстве message.

И последний файл, который мы добавим в package модели, имеет название Errors.kt:

package com.tuneit.coursesshopreactive.model

import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.ResponseStatus

@ResponseStatus(HttpStatus.NOT_FOUND)
data class NotFoundException(val msg: String) : RuntimeException(msg)

В этом файле мы будем описывать ошибки, которые мы хотим возвращать пользователям.
В данном примере ограничимся одной единственной ошибкой NotFoundException.
Пользователи будут видеть ее, когда будут пытаться выполнять запросы в отношении курса, которого не существует в магазине.
Обратите внимание на аннотацию @ResponseStatus(HttpStatus.NOT_FOUND). Если возникнет эта ошибка, пользователь получит ответ именно с этим статусом.

2.2 Программирование Репозитория и Сервиса

Для размещения интерфейса Репозитория создадим Package с именем repository по адресу src->main->kotlin->com.tuneit.coursesshopreactive->

Назовем интерфейс CoursesRepository.kt:

package com.tuneit.coursesshopreactive.repository

import com.tuneit.coursesshopreactive.model.OnlineCourse
import org.springframework.data.repository.reactive.ReactiveCrudRepository
import org.springframework.stereotype.Repository

@Repository
interface CoursesRepository : ReactiveCrudRepository<OnlineCourse, Long> {
}

Обратите внимание на аннотацию @Repository. Так мы даем понять фреймворку, что этот интерфейс есть не что иное, как Репозиторий. 
Немного теории про Репозиторий.

Репозиторий - это абстракция над уровнем доступа к данным в приложении. Он предоставляет возможность взаимодействовать с базой данных или другими системами хранения данных, не прибегая к написанию шаблонного кода для обработки операций CRUD (Create, Read, Update, Delete).

ReactiveCrudRepository - это интерфейс из Spring Data для реактивного программирования в приложениях Spring. Этот интерфейс предоставляет набор методов, позволяющих взаимодействовать с базой данных реактивным способом. Эти методы реализуют все основные операции с сущностями: подсчет (count) сущностей, проверка существования сущности (existsById), поиск (findAll, findAllById, findById), сохранение (save, saveAll), удаления сущности (delete, deleteAll, deleteAllById, deleteById).

Теперь переходим к Сервису.
Для размещения его класса, создадим Package с именем service по адресу.. ну вы уже догадались :)

И сам файл CoursesService.kt:

package com.tuneit.coursesshopreactive.service

import com.tuneit.coursesshopreactive.model.CourseRequest
import com.tuneit.coursesshopreactive.model.NotFoundException
import com.tuneit.coursesshopreactive.model.OnlineCourse
import com.tuneit.coursesshopreactive.repository.CoursesRepository
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono

@Service
class CoursesService(val repo: CoursesRepository) {
    fun getAllCourses() : Flux<OnlineCourse> {
       return repo.findAll()
    }

    fun getCourseById(id:Long): Mono<OnlineCourse> {
       return repo.findById(id)
                  .switchIfEmpty(Mono.error(NotFoundException("The course with id «$id» isn't found")))
    }

   fun saveCourse(request: CourseRequest) : Mono<OnlineCourse> {
       return repo.save(
           OnlineCourse(
               name = request.name.trim(),
               price = request.price,
               author = request.author.trim(),
               direction = request.direction.trim(),
               startDate = request.startDate,
               endDate = request.endDate)
       )
   }

    fun updateCourse (id: Long, request: CourseRequest) : Mono<OnlineCourse> {
        return getCourseById(id)
               .flatMap {
                 repo.save(
                   OnlineCourse(
                    id = id,
                    name = request.name.trim(),
                    price = request.price,
                    author = request.author.trim(),
                    direction = request.direction.trim(),
                    startDate = request.startDate,
                    endDate = request.endDate)
                 )
               }
    }

    fun deleteCourse (id: Long): Mono<Void> {
         return getCourseById(id).flatMap { repo.deleteById(id) }
    }
}

Подобно тому, как мы обозначали Репозиторий, для определения Сервиса в Spring, тоже используем аннотацию, но другую - @Service. 
Сервис необходим для выполнения бизнес-логики нашего приложения. В нее входит: работа с данными из БД, валидация данных, обработка, трансформация данных, все возможные вычисления, логирование, генерация реакций на ошибки и прочее и прочее.

Подробно о методах нашего Сервиса рассказывать не имеет смысла, так как они интуитивно понятны и очевидны, исходя из их названий и реализации.

2.3 Программирование Контроллера

Для размещения класса Контроллера с именем CoursesController.kt, создадим Package с именем controller по небезызвестному нам пути :)

Код контроллера:

package com.tuneit.coursesshopreactive.controller

import com.tuneit.coursesshopreactive.model.CourseRequest
import com.tuneit.coursesshopreactive.model.OnlineCourse
import com.tuneit.coursesshopreactive.service.CoursesService
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono

@RestController
@RequestMapping("/courses")
class CoursesController(val service: CoursesService) {
    @GetMapping
    fun getAllCourses() : Flux<OnlineCourse> {
        return service.getAllCourses()
    }

    @GetMapping("/{id}")
    fun getCourseById(@PathVariable id:Long) : Mono<OnlineCourse> {
        return service.getCourseById(id)
    }

    @PostMapping
    fun saveCourse(@Valid @RequestBody request: CourseRequest) : Mono<OnlineCourse> {
        return service.saveCourse(request)
    }

    @PutMapping("/{id}")
    fun updateCourse(@PathVariable id:Long, @Valid @RequestBody request: CourseRequest) : Mono<OnlineCourse> {
       return service.updateCourse(id, request)
    }

    @DeleteMapping("/{id}")
    fun deleteCourse(@PathVariable id:Long) : Mono<String> {
       return service.deleteCourse(id)
                     .then(Mono.just("The course with id «$id» has been successfully deleted"))
    }
}

Класс помечен аннотацией @RestController и это означает, что каждый его метод автоматически сериализует возвращаемые объекты в HttpResponse. 
Как вы уже наверняка заметили, контроллер не совершает какой-либо серьезной обработки данных. Он просто передает входящий запрос в сервис, а сервис уже выполняют всю необходимую бизнес-логику.
Такой подход считается хорошим тоном проектирования приложений АПИ.

Обратите внимание на аннотацию @Valid в методах сохранения и обновления курса. Мы помним, что в классе CourseRequest мы задали ограничения для некоторых полей (например, цена курса должна быть равной или большей нуля). Если эта аннотация присутствует, то перед тем как выполнить код в теле метода, фреймворк выполнит все необходимые валидации, и в случае, когда проверки не пройдены, клиенту будет возвращен ответ, содержащий причины, по которым не прошла валидация.
По умолчанию фреймворк возвращает ошибки валидации со слишком длинным и подробным описанием, включая названия классов, методов и полей.
К примеру, вместо сообщения "price must be >= 0" пользователь увидит

"Validation failed for argument at index 1 in method: public reactor.core.publisher.Mono<com.tuneit.coursesshopreactive.model.OnlineCourse> com.tuneit.coursesshopreactive.controller.CoursesController.updateCourse(long,com.tuneit.coursesshopreactive.model.CourseRequest), with 1 error(s): [Field error in object 'courseRequest' on field 'price': rejected value [-2.0]; codes [PositiveOrZero.courseRequest.price,PositiveOrZero.price,PositiveOrZero.float,PositiveOrZero]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [courseRequest.price,price]; arguments []; default message [price]]; default message [price must be >= 0]] "  

Для того, чтобы исправить такое поведение фреймворка, создадим свой обработчик ошибок, где зададим нужный нам формат сообщений об ошибках.
Добавим в тот же Package, где и контроллер, класс RestControllerExceptionHandler.kt со следующим содержимым: 

import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Comparator
import java.util.stream.Collectors

@RestControllerAdvice
class RestControllerExceptionHandler {

    @ExceptionHandler(WebExchangeBindException::class)
    fun handleWebExchangeBindException(e: WebExchangeBindException, we: ServerWebExchange): ResponseEntity<ExceptionDetails> {
        val request = we.request
        val errors = e.bindingResult
            .allErrors
            .stream()
            .map { it.defaultMessage?:"" }
            .sorted(Comparator.naturalOrder())
            .collect(Collectors.toSet())
        val details = ExceptionDetails(
            path = request.path.value(),
            status = e.statusCode.value(),
            error =  e.reason?:HttpStatus.BAD_REQUEST.reasonPhrase,
            message = errors.toString().replace("\\[*]*".toRegex(), ""),
            requestId = request.id
        )
        return ResponseEntity.status(e.statusCode).body(details)
    }

    data class ExceptionDetails (
        val timestamp: String = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS+00:00")
                                                  .withZone(ZoneId.from(ZoneOffset.UTC)).format(Instant.now()),
        val path: String="",
        val status: Int,
        val error: String="",
        val message: String="",
        val requestId: String=""
    )
}

Отлично. Работы по программированию нашего АПИ завершены.
Можно переходить к его тестированию.

Однако перед этим, приведу итоговую структуру проекта, чтобы вы могли сравнить и убедиться, что все нужные классы у вас на месте:

Также приведу содержимое файла build.gradle.kts:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "3.1.4"
    id("io.spring.dependency-management") version "1.1.3"
    kotlin("jvm") version "1.8.22"
    kotlin("plugin.spring") version "1.8.22"
}

group = "com.tuneit"
version = "0.0.1-SNAPSHOT"

java {
    sourceCompatibility = JavaVersion.VERSION_17
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
    implementation("org.springframework.boot:spring-boot-starter-webflux")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
    implementation("org.springframework.boot:spring-boot-starter-validation:3.1.4")
    runtimeOnly("org.postgresql:postgresql")
    runtimeOnly("org.postgresql:r2dbc-postgresql")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("io.projectreactor:reactor-test")
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs += "-Xjsr305=strict"
        jvmTarget = "17"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

содержимое файла settings.gradle.kts:

rootProject.name = "courses-shop-reactive"

и содержимое файла CoursesShopReactiveApplication.kt:

package com.tuneit.coursesshopreactive

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class CoursesShopReactiveApplication

fun main(args: Array<String>) {
    runApplication<CoursesShopReactiveApplication>(*args)
}

3. Тестирование приложения

Пришло время протестировать наше АПИ.

Чтобы запустить приложение, вы можете воспользоваться встроенными в IntelliJ Idea командами Run или Debug, либо выполнить следующую команду через терминал:

./gradlew bootRun

Для выполнения запросов к АПИ, я буду использовать программу Postman. Вы можете воспользоваться той, которая вам нравится больше.

3.1 Добавление курса

Давайте к примеру придумаем и добавим в магазин три курса.

Обратите внимание. В Postman для этих и других запросов значение переменной {{defaultUrl}} в строке запроса я задал равным localhost:8080.

Давайте намеренно попытаемся добавить курс с неверными данными, чтобы увидеть как сработает валидация:

3.2 Получение всех курсов

3.3 Получение курса по id

Давайте посмотрим, что будет если запросить курс, которого нет в магазине:

3.4 Обновление курса

Давайте обновим информацию для курса с id = 7. Зададим ему новую цену и установим дату окончания:

3.5 Удаление курса

Давайте удалим курс с id = 5.

Давайте вновь выполним запрос на получение всех курсов, чтобы увидеть итоговое состояние системы:

На этом тестирование закончено. Как видно наше АПИ работает неплохо :) 

Вместо заключения

Спасибо что провели свое время вместе с нами, читая эту статью.
Надеюсь она оказалась для вас интересной и полезной.
В нашем блоге есть еще одна статья на эту тему: вот здесь.
Приглашаю вас прочитать ее, если вы еще не читали.

Желаю вам хорошего кодирования и успехов в ваших проектах, будь то учёба или работа!