Привет, Хабр! Меня зовут Александр Каненков, я backend-разработчик в Домклик. Не так давно я с головой погрузился в мир реактивного программирования и очень заинтересовался этой темой. Хочу поделиться кратким введением в Spring Data R2DBC: зачем это нужно, как начать использовать и какие преимущества даёт. Мы разработаем небольшое приложение, добавим Flyway и напишем пару тестов.

Зачем R2DBC, если есть JDBC

Итак, в начале было слово JDBC. Однако с появлением реактивного стека в мире Spring (WebFlux, Project Reactor) перед многими разработчиками встал вопрос: как работать с традиционными реляционными базами данных в неблокирующем режиме? Классический JDBC по своей природе является блокирующим, что сводит на нет все преимущества реактивного подхода на уровне веб‑слоя и бизнес‑логики. Когда ваше приложение отправляет запрос в базу данных через JDBC, поток приложения блокируется до тех пор, пока не получит ответ. В традиционных синхронных приложениях эта проблема решается с помощью пула потоков, но в высоконагруженных системах, использующих реактивный подход, где один поток асинхронно обрабатывает множество запросов, такой подход становится узким местом.

Решением стала спецификация R2DBC (Reactive Relational Database Connectivity) — инициатива, направленная на создание неблокирующего, реактивного API для SQL-баз данных. А Spring Data R2DBC предоставляет привычные абстракции и репозитории для работы с этим API. R2DBC использует неблокирующие драйверы, что позволяет эффективно использовать ресурсы сервера и обрабатывать гораздо больше одновременных запросов с меньшим количеством потоков.

Начинаем работу: добавление зависимостей

Для начала работы вам понадобится проект Spring Boot с использованием Spring WebFlux, реактивный драйвер к вашей базе данных (в моём случае это Postgres) и Spring Data R2DBC. Все примеры кода представлены на Kotlin с использованием Gradle, поэтому зависимости добавляются в файл build.gradle.kts.

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
    implementation("org.springframework.boot:spring-boot-starter-webflux")
    runtimeOnly("org.postgresql:r2dbc-postgresql")
}

Часто драйвер базы данных добавляют в runtimeOnly, чтобы он был доступен во время выполнения, а не во время компиляции, как при использовании implementation. Это позволяет избежать включения зависимостей, необходимых только для работы приложения, в артефакт (например, JAR-файл), что уменьшает его размер.

Конфигурация базы данных

В application.yml вам нужно указать параметры подключения к вашей реактивной базе данных. Для начала работы с R2DBC будет достаточно этих. Обратите внимание на префикс r2dbc вместо привычного jdbc:

spring:
  r2dbc:
    url: r2dbc:postgresql://localhost:5432/postgres
    username: postgres
    password: postgres

При свойстве spring.data.r2dbc.repositories.enabled=true, установленном по умолчанию, Spring Boot сам автоматически найдёт и создаст бины всех расширений интерфейсов Spring Data R2DBC, как, например, ReactiveCrudRepository.

Создание сущности и репозитория

Процесс создания сущностей и репозиториев в целом аналогичен подходу в Spring Data JPA. Однако есть несколько замечаний:

  • Spring Data R2DBC не обладает такой богатой функциональностью по сопоставлению и загрузке связанных сущностей, поэтому придётся писать много шаблонного кода

  • Entity-класс должен быть помечен аннотацией @Table из пакета org.springframework.data для сопоставления с таблицей в базе данных

  • Идентификатор сущности должен быть помечен аннотацией @Id из пакета org.springframework.data

@Table("users")
data class User(
    @Id
    val id: Long? = null,
    val username: String? = null,
    val email: String? = null
)

Все репозитории стоит отмечать аннотацией @Repository, так Spring автоматически преобразует непроверяемые исключения, связанные с доступом к данным, в специфичные для Spring исключения для более удобной работы.

@Repository
interface UserRepository : ReactiveCrudRepository<User, Long> {

    fun findByUsername(username: String): Mono<User>

    fun findByEmailContaining(emailPart: String): Mono<User>

    @Query("select * from users where username in (:usernames)")
    fun findAllByUsernames(usernames: List<String>): Flux<User>
}

Так же, как в привычном Spring Data JPA, но с использованием реактивных типов Mono (для одного или нуля элементов) и Flux (для потока из N элементов). Если в базе данных не найдено записей, удовлетворяющих заданным условиям, то вернётся Mono.empty(), с Flux — аналогично.

Добавление Flyway

Если коротко, то Flyway — это инструмент с открытым исходным кодом для управления миграцией баз данных, который применяет принципы контроля версий к схеме баз данных. Для внедрения этого инструмента в наше приложение потребуется три простых шага.

Добавляем зависимости в наш build.gradle.kts в блок dependencies. Однако, если говорить о Flyway в контексте приложения с использованием R2DBC, то эта технология пока не адаптирована под реактивную среду, поскольку использует JDBC-драйверы. Поэтому, помимо зависимостей Flyway, вам также потребуется добавить и JDBC-драйвер.

implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-database-postgresql")
runtimeOnly("org.postgresql:postgresql")

Затем прописываем конфигурацию Flyway в application.yml, обязательно с префиксом jdbc в URL подключения к вашей базе данных.

spring:
  flyway:
    url: jdbc:postgresql://localhost:5432/postgres
    user: postgres
    password: postgres

Добавляем первую миграцию с SQL-скриптом по пути resources/db/migration (это расположение по умолчанию).

create table users(
    id bigserial,
    username text,
    email text
);

Если требуется изменить путь до файлов с миграциями, то надо задать в application.yml переменной spring.flyway.locations нужное значение.

spring:
  flyway:
    url: jdbc:postgresql://localhost:5432/postgres
    user: postgres
    password: postgres
    locations: #заменить на ваше значение

Тестирование Spring Data R2DBC-репозиториев

Разумеется, для тестов необходимо поднять отдельную базу данных, и в этом нам поможет Testcontainers. Хотя Spring предлагает простой способ запуска тестовой in-memory базы данных H2, у этого решения есть недостатки. Testcontainers — это библиотека с открытым исходным кодом, которая позволяет запускать реальные зависимости приложения (например, базы данных, брокеры сообщений или веб-браузеры) в изолированных Docker-контейнерах во время выполнения интеграционных тестов. Она позволяет создать тестовое окружение по требованию, автоматически запускать и останавливать контейнеры, и обеспечивает одинаковые условия для всех тестов, избавляя от необходимости ручного управления зависимостями. Это значит, что мы можем поднимать Docker-контейнер с нужной нам СУБД для прогона тестов.

Для работы с Testcontainers нам понадобятся следующие зависимости:

testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")

Непосредственное создание и запуск контейнера можно вынести в отдельный абстрактный класс, от которого будут наследоваться все наши классы, в которых тестируются репозитории. Ниже пример использования единого контейнера (так гораздо быстрее один раз поднять базу данных для всех тестов).

@DataR2dbcTest
@ExtendWith(SpringExtension::class)
@Testcontainers
@ActiveProfiles("test")
abstract class BaseRepositoryTest {

    companion object {
        @Container
        private var postgres = PostgreSQLContainer("postgres:15-alpine")

        @JvmStatic
        @DynamicPropertySource
        fun registerPgProperties(registry: DynamicPropertyRegistry) {
            registry.add("spring.flyway.url") { postgres.jdbcUrl }
            registry.add("spring.flyway.user") { postgres.username }
            registry.add("spring.flyway.password") { postgres.password }
            registry.add("spring.flyway.locations") { "classpath:db/migration" }
            registry.add("spring.r2dbc.url") { postgres.jdbcUrl.replace("jdbc", "r2dbc") }
            registry.add("spring.r2dbc.username") { postgres.username }
            registry.add("spring.r2dbc.password") { postgres.password }
        }
    }

    @Autowired
    protected lateinit var userRepository: UserRepository

    @BeforeEach
    fun setUp() {
        userRepository.deleteAll().block()
    }
}

Рассмотрим подробнее этот класс. Начнём с аннотаций над классом:

  • @DataR2dbcTest — это аннотация, используемая для тестирования компонентов Spring Data R2DBC, которая обеспечивает минимальный и релевантный тестовый контекст, специально предназначенный для функций, связанных с R2DBC, таких как репозитории и сущности

  • @ExtendWith(SpringExtension::class) — аннотация, предоставляющая возможности Spring Framework при написании тестов, например, Application Context или Dependency Injection

  • @ActiveProfiles("test") — устанавливает активный профиль, определяющий значения некоторых переменных для environment‑based бинов (полезно в случаях, когда в тестовой среде нужно переопределить значения переменных в файле application.yml)

  • @Testcontainers — маркер для расширения Testcontainers, который включает в себя автоматическое управление Docker‑контейнерами, используемыми в интеграционных тестах

Ниже видим объявление самого контейнера. Аннотация @Container используется для автоматического управления Testcontainers жизненным циклом контейнера.

@Container
private var postgres = PostgreSQLContainer("postgres:15-alpine")

Далее переопределяются свойства подключения Spring Data R2DBC и Flyway на те, которые предоставил нам созданный контейнер.

@JvmStatic
@DynamicPropertySource
fun registerPgProperties(registry: DynamicPropertyRegistry) {
    registry.add("spring.flyway.url") { postgres.jdbcUrl }
    registry.add("spring.flyway.user") { postgres.username }
    registry.add("spring.flyway.password") { postgres.password }
    registry.add("spring.flyway.locations") { "classpath:db/migration" }
    registry.add("spring.r2dbc.url") { postgres.jdbcUrl.replace("jdbc", "r2dbc") }
    registry.add("spring.r2dbc.username") { postgres.username }
    registry.add("spring.r2dbc.password") { postgres.password }
}

Метод обязательно должен быть статическим, поэтому его следует пометить аннотацией @JvmStatic. Аннотация @DynamicPropertySource применяется к статическому методу, в котором свойства среды задаются supplier`ами, определяющими значения в момент запроса. Для Flyway мы используем URK подключения к базе данных с префиксом jdbc, а для R2DBC — с префиксом r2dbc.

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

@Autowired
protected lateinit var userRepository: UserRepository

Важно отметить, что перед каждым тестом рекомендуется чистить базу, поскольку зависимые друг от друга тесты — истинное зло. Более предпочтительный подход — менять данные внутри каждого тестового метода перед выполнением тестовой логики.

@BeforeEach
fun setUp() {
    userRepository.deleteAll().block()
}

При добавлении в приложение новых сущностей и репозиториев стоит также внедрять бин репозитория и вызывать у него метод deleteAll().

Теперь напишем два теста для метода пользователя по username, проверив два сценария: «пользователь успешно найден» и «пользователь отсутствует». Для этого создадим класс, который наследуется от уже знакомого нам абстрактного класса BaseRepositoryTest.

class UserRepositoryTest : BaseRepositoryTest() {

    @Test
    fun `findByUsername - success`() {
        val username = "testUser"
        val email = "test@gmail.com"
        StepVerifier.create(userRepository.save(
            User(null, username, email)
        )).expectNextMatches { it.id != null }
            .verifyComplete()

        val result = userRepository.findByUsername(username)
        StepVerifier.create(result)
            .expectNextMatches {
                it.username == username
                it.email == email
            }
            .verifyComplete()
    }

    @Test
    fun `findByUsername - not found`() {
        val result = userRepository.findByUsername("qwerty")
        StepVerifier.create(result)
            .verifyComplete()
    }
}

Рассмотрим первый тестовый метод. Для начала сохраним нашего пользователя. Так как мы имеем дело с реактивными потоками, требуется оборачивать возвращаемые значения методов репозитория в StepVerifier. StepVerifier — это специальная утилита из Project Reactor, позволяющая тестировать реактивные потоки в декларативной и асинхронной манере. После сохранения пользователя вызываем метод поиска по username, передав в него значение, с которым пользователь был создан. Результат проверяем схожим образом, используя StepVerifier и проверяя данные пользователя на соответствие ожидаемым.

Во втором тестовом методе подход аналогичный, только пользователь предварительно не сохраняется, а это значит, что нам вернётся пустой Mono, и мы не ожидаем сигнала onNext.

Подводим итоги

Spring Data R2DBC — крайне интересная и уже достаточно зрелая технология. В этой статье мы кратко осветили её преимущества и шаги по интеграции в ваше приложение. Проект активно развивается, во многом параллельно со Spring Data JDBC. Например, оба проекта скоро получат поддержку составных ключей. Несмотря на то, что на Хабре не так много статей по этой теме, интерес сообщества к реактивному программированию (и R2DBC в частности) очевиден. Надеемся, эта публикация помогла ответить на вопросы тех, кто только начинает знакомиться с этой технологией.

Код можно посмотреть на GitHub.

Комментарии (0)