Jooq 3.15+. Сравнение R2DBC и JDBC подходов к разработке

Kate

Administrator
Команда форума
Приветствую!

На протяжении нескольких лет моего опыта в качестве разработчика ПО, не раз сталкивался с ситуацией, когда нужно было выпонить не самый просто SQL запрос по производительности. Несмотря на то, что не всегда здорово большие аналитические запросы генерить и отдавать на фронт, я напишу статью именно на эту тему. Для примера воспользуемся двумя технологиями, чтобы реализовать простой пример: R2DBC и JDBC

Входные данные​


  • Бизнес сильно ограничен в ресурсах и не готов тратить большие деньги на NoSql специалистов
  • В команде есть сильный front-end разработчик, который не боится кешировать данные на фронте и их обрабатывать. Умеет эффективно работать с большим списком
  • Есть back-end разработчик, который уважает себя, пишет код для не очень мощного железа
  • Задача связана с отобращением большого массива данных на фронте (будь то геоданные на карте или картотека в интернет-магазине)


Что будем делать?​


  1. Взять большой массив данных из базы. В примере загужена база ФИАС
  2. Выполнить запрос и отдать клиенту


План работ​


  1. Написать SQL запрос на выборку данных
  2. Написать код, который выполнял запрос и после чего отдавать результат клиенту
  3. Написать код, который бы формировал запрос на выборку данных и такой запрос, чтобы при первом найденном результате поиска данные бы отдавались клиенту для скорейшей обработки
  4. Сравнить результат


Технологический стек​


  • Spring boot 2.5+
  • PostgreSQL
  • Jooq Framework
  • Spring Webflux
  • Srping Web MVC
  • R2dbc driver для PostgreSQL
  • Jdbc driver для PostgreSQL


Исходники​


Лежат тут

Подготовка данных и запрос на выборку данных​



Для примера возьмём какую-нибудь большу базу данных их открытого источника. Так как я проживаю в России, то на ум пришла база данных ФИАСа (Федеральная информационная адресная система).

Для начала создадим базу данных jooq_test в PostgreSQL.

CREATE DATABASE jooq_test;



Перейдём по ссылке ФИАСа и скачаем нужный архив

webogc4prrnqjlscsxidqitxwo4.png


Благодаря Linux утилитки pgdbf загрузим данные в базу:

pgdbf DOMA.DBF | iconv -c -f cp866 -t UTF-8| psql jooq_test
pgdbf FLAT.DBF | iconv -c -f cp866 -t UTF-8| psql jooq_test
pgdbf STREET.DBF | iconv -c -f cp866 -t UTF-8| psql jooq_test



Дождёмся когда все данные в базу прогрузятся и перейдем к следующему этапу.

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

Чтобы чуть усложнить запрос сделаем JOIN трёх таблиц, при этом данные возьмём из одной (doma):
select d.*
from doma d
join street s on d.ocatd = s.ocatd
join flat f on f.gninmb = s.gninmb



И так, с запросом мы определились. Теперь можно приступить к написанию кода.

Jdbc подход​



Как уже было описано выше, стек у нас Spring Boot + MVC + PostgreSQL Driver. Собирать проект мы будем с помощью Gradle

build.gradle.kts
import nu.studer.gradle.jooq.JooqEdition
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile


// Необходимы для работы плагины
plugins {
id("org.springframework.boot") version "2.5.6"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
id("nu.studer.jooq") version ("6.0.1")
kotlin("jvm") version "1.5.31"
kotlin("plugin.spring") version "1.5.31"
}

group = "ru.jooq.test"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

repositories {
mavenCentral()
}

val postgresVersion = "42.3.1"

dependencies {
// Зависимость для jooq
jooqGenerator("org.postgresql:postgresql:$postgresVersion")
// Spring стартеры
implementation("org.springframework.boot:spring-boot-starter-jooq")
implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "11"
}
}

tasks.withType<Test> {
useJUnitPlatform()
}

// генерация классов для Jooq Framework
jooq {
edition.set(JooqEdition.OSS)

configurations {
create("main") {
jooqConfiguration.apply {
jdbc.apply {
driver = "org.postgresql.Driver"
url = "jdbc:postgresql://localhost:5432/jooq_test"
user = "postgres"
password = "postgres"
}
generator.apply {
name = "org.jooq.codegen.DefaultGenerator"
generate.apply {
isDeprecated = false
isRecords = true
isImmutablePojos = false
isFluentSetters = false
isJavaBeansGettersAndSetters = false
}
database.apply {
name = "org.jooq.meta.postgres.PostgresDatabase"
inputSchema = "public"
}
target.apply {
packageName = "ru.jooq.test.jooqtest.domain"
}
strategy.name = "org.jooq.codegen.DefaultGeneratorStrategy"
}
}
}
}
}




Подробно останавливаться на описании зависимостей мы не будем. Об этом много статей.

Опишем файл настроек в ресурсах проекта

application.yml
spring:
datasource:
driverClassName: org.postgresql.Driver
hikari:
jdbc-url: jdbc:postgresql://localhost:5432/jooq_test
username: postgres
password: postgres
jooq:
sql-dialect: postgres




Мы не просто так описали проперти подключения к базе конкретно по пути spring.datasource.hikari. У нас в проекта будет несколько источников данных. Один из которых будет Хикари, а второй R2dbc

В нашем примере весь код лежит в одном пакете: ru.jooq.test.jooqtest. Классов будет немного и для наглядного представления создавать их в бизнесовые пакеты мы не будем.

Как и любое приложение Spring Boot'a всё начинается с аннотации @SpringBootApplication

JooqTestApplication.kt
package ru.jooq.test.jooqtest

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

@SpringBootApplication
class JooqTestApplication

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




Здесь тоже ничего нового мы для себя не находим. Продолжим…

Настройки подключения к базе данных у нас лежат в классе JooqConfiguration

JooqConfiguration.kt
package ru.jooq.test.jooqtest

import javax.sql.DataSource
import org.jooq.DSLContext
import org.jooq.impl.DSL
import org.jooq.impl.DataSourceConnectionProvider
import org.jooq.impl.DefaultConfiguration
import org.jooq.impl.DefaultDSLContext
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.jdbc.DataSourceBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy


@Configuration
class JooqConfiguration {

// конфигурируем dslConext для запросов к СУБД
@Bean(value = ["jdbcDSLContext"])
fun jdbcDSLContext(): DSLContext {
return DefaultDSLContext(configuration())
}

// DataSource из настроек application.yml
@Bean
@ConfigurationProperties(prefix = "spring.datasource.hikari")
fun dataSource(): DataSource {
return DataSourceBuilder.create().build()
}

// Открываем подключение
@Bean
fun lazyConnectionDataSource(): LazyConnectionDataSourceProxy {
return LazyConnectionDataSourceProxy(dataSource())
}

@Bean
fun connectionProvider(): DataSourceConnectionProvider {
return DataSourceConnectionProvider(lazyConnectionDataSource())
}

@Bean
fun configuration(): DefaultConfiguration {
val jooqConfiguration = DefaultConfiguration()
jooqConfiguration.set(connectionProvider())
return jooqConfiguration
}
}




Как уже ранее было сказано, у нас два dataSource и по этой причине настройки не по умолчанию spring boot'а.

Обращаться к данным мы будем через протокол HTTP, выходная модель которого представлена в ResultDto классе

ResultDto.kt

class ResultDto(
val queryTime: LocalDateTime, // Время запроса
val dataTime: LocalDateTime, // Время маппинга данных
timeToResult: Long? = 0,
val dataDto: DataDto // Набор данных из таблицы
) {
val timeToResult: Long = Duration.between(queryTime, dataTime).toMillis()
}

data class DataDto(
val name: String,
val korp: String,
val socr: String,
val code: String,
val index: String,
val gninmb: String,
val uno: String,
val ocatd: String
)




Теперь надо реализовать слой взаимодействия с БД для запроса, который мы описывали ранее.

SampleRepository
package ru.jooq.test.jooqtest

import java.time.LocalDateTime
import org.jooq.DSLContext
import org.jooq.Record8
import org.jooq.SelectLimitPercentStep
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Repository
import ru.jooq.test.jooqtest.domain.Tables

@Repository
class SampleRepository(
@Qualifier("jdbcDSLContext")
private val jdbcDSLContext: DSLContext
) {

private val d = Tables.DOMA
private val s = Tables.STREET
private val f = Tables.FLAT

// Маппинг данных на модель ResultDto
fun getBlockingJooq(queryTime: LocalDateTime, limit: Long): List<ResultDto> {
return getQuery(jdbcDSLContext, limit)
.map { r ->
ResultDto(
queryTime = queryTime,
dataTime = LocalDateTime.now(),
dataDto = r.into(d).into(DataDto::class.java)
)
}
}
// SQL запрос
private fun getQuery(dslContext: DSLContext, limit: Long): SelectLimitPercentStep<Record8<String, String, String, String, String, String, String, String>> {
return dslContext
.select(d.NAME, d.KORP, d.SOCR, d.CODE, d.INDEX, d.GNINMB, d.UNO, d.OCATD)
.from(d)
.join(s).on(s.OCATD.eq(d.OCATD))
.join(f).on(f.GNINMB.eq(s.GNINMB))
.limit(limit)
}
}




Для нашего примера мы ограничимся условием лишь на количество строк. Этого достаточно для понимания всех процессов описанных в данной статье. Qualifier(«jdbcDSLContext») мы используем для того, чтобы однозначно определить тот bean, который нам нужен.

Метод getBlockingJooq должен вызываться в сервисе SampleService

SampleService.kt

package ru.jooq.test.jooqtest

import java.time.LocalDateTime
import org.springframework.stereotype.Service

@Service
class SampleService(private val sampleRepository: SampleRepository) {

// метод, который вызывает репозиторий с sql запросом
fun getBlockingJooq(queryTime: LocalDateTime, limit: Long): List<ResultDto> {
return sampleRepository.getBlockingJooq(queryTime, limit)
}
}




Непосредственно сам end-point опишем в SampleController

SampleController.kt

package ru.jooq.test.jooqtest

import java.time.LocalDateTime
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

@RestController
class SampleController(private val sampleService: SampleService) {

@GetMapping("/sample/jooq-blocking")
fun jooqBlocking(@RequestParam limit: Long): List<ResultDto> {
return sampleService.getBlockingJooq(LocalDateTime.now(), limit)
}

}




Здесь мы видим простой RestController и метод jooqBlocking с одним лишь параметром запроса: limit. В сервис мы передаём текущую дату и пришедший параметр от клиента. Ресурс запроса состоит из строки /sample/jooq-blocking, а полный запрос выглядит так:

curl http://localhost:8080/sample/jooq-blocking?limit=1000000


Чтобы наглядно продемонстрировать работоспособность урла, было записано видео и лежит оно тут

R2dbc подход​



The Reactive Relational Database Connectivity (R2DBC) принёс реактивное API для реляционных баз данных


О нём много полезной информации можно найти на Хабре. Первоисточник: r2dbc.io

Технологический стек для данного примера: Spring Boot + Web Flux + R2dbc PostgreSQL Driver

Зависимости в build.gradle.kt нужно подкорректировать:


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




spring-boot-starter-webflux — это стартер spring WebFlux, который является самодостаточным и несёт в себе практически все необходимые зависимости для «реактивного» взаимодействия.
r2dbc-postgresql — r2dbc драйвер для postgreSQL

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

aplication.yml
spring:
r2dbc:
url: r2dbc:postgresql://localhost:5432/jooq_test
password: postgres
username: postgres
pool:
initial-size: 3
max-size: 10
max-idle-time: 30m
jooq:
sql-dialect: postgres



@Configuration
class JooqConfiguration(
private val connectionFactory: ConnectionFactory
) {

@Bean(value = ["r2dbcDSLContext"])
fun createContext(): DSLContext {
return DSL.using(connectionFactory)
}
...
}



Метод в Repository на получение данных реализован через Spring Reactor библиотеку:

@Repository
class SampleRepository(
@Qualifier("r2dbcDSLContext")
private val r2dbcDSLContext: DSLContext,
@Qualifier("jdbcDSLContext")
private val jdbcDSLContext: DSLContext
) {

...

fun getReactiveJooq(queryTime: LocalDateTime, limit: Long): Flux<ResultDto> {
return Flux.from(getQuery(r2dbcDSLContext, limit))
.map { r ->
ResultDto(
queryTime = queryTime,
dataTime = LocalDateTime.now(),
dataDto = r.into(d).into(DataDto::class.java)
)
}
}
...
}



Flux — это stream котороый может отправлять от 0 до N элементов в поток. Он идеально подходит для нашего кейса. Flux наследуется от Publisher, который и публикует сообщение в поток. Более подробно можно почитать тут

Метод очень похож на то, что мы видели в Jdbc подходе, с одним изменением: вместо списка — stream.

Слой сервиса по аналогии c JDBC тоже следует обновить:

@Service
class SampleService(private val sampleRepository: SampleRepository) {

fun getReactiveJooq(queryTime: LocalDateTime, limit: Long): Flux<ResultDto> {
return sampleRepository.getReactiveJooq(queryTime, limit)
}
...
}



Здесь просто вызываем наш метод из репозитория.

Слой контроллера в WebFlux имеет отличительную особенность в сравнении с MVC. Дело в том, что привычный нам Content-Type: application/json не подходит, так как это блокирующий вызов. Вместо этого нужно использовать Content-Type: application/x-ndjson для стриминга индивидуальных элементов, разделённых новой строкой. Он идеально подходит для нашей концепции.

@RestController
class SampleController(private val sampleService: SampleService) {


@GetMapping("/sample/jooq-reactive", produces = [MediaType.APPLICATION_NDJSON_VALUE])
fun jooqReactive(@RequestParam limit: Long): Flux<ResultDto> {
return sampleService.getReactiveJooq(LocalDateTime.now(), limit)
}

...

}



Curl запрос выглядит так:

curl http://localhost:8080/sample/jooq-reactive?limit=1000000



Видео, которое демонстирирует работу с R2dbc выложено тут

Сравнительный анализ​



Для начала запустим наши запросы и посмотрим, какая «картина». Будем запускать 1 млн. строк как для jooq-blocking, так и для jooq-reactive

Потребление памяти​



gbygtucjhplmxtqvc7epvoeqpss.png


jd54v9dkxsvr5rhvgjroejdwfuw.png


На первом скриншоте изображён Jdbc запуск, на втором R2dbc

Загрузка CPU​



h-lyd5pkog3j4a8p7dv0uv8y24y.png


h3uns7t0dxzfwxxr6xa6z8ewp7a.png


На первом скриншоте изображён Jdbc запуск, на втором R2dbc

Время выполнения​



Jdbc
GET http://localhost:8080/sample/jooq-blocking?limit=1000000

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 279170577

> 2021-11-18T181435.200.json

Response code: 200 (OK); Time: 25541ms; Content length: 273258295 bytes


R2dbc
GET http://localhost:8080/sample/jooq-reactive?limit=1000000

HTTP/1.1 200 OK
transfer-encoding: chunked
Content-Type: application/x-ndjson

> 2021-11-18T180754.200.txt

Response code: 200 (OK); Time: 47203ms; Content length: 279975556 bytes



Заключение​



В данной статье продемонстированы самые простые примеры применения реактивного подхода к разработке. Кто-то считает такой способ — будущем, кто-то настоящим, а кто-то и вовсе ненужной фичей. Сравнительный анализ показал, что в целом Spring MVC справляется быстрее с задачей, однако Spring WebFlux + r2dbc использует значительно меньше ресурсов приложения.

 
Сверху