
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.

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

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