1 of 36

Spring Data JDBC

4 года в проде, полёт отличный

2 of 36

Что за прод?

2

Проект

Кол-во строк кода*

Кол-во таблиц

№1

11 500

8

№2

7 000

16

№3

6 500

21

№4

39 000

66

№5**

16 000

10

TA***

13 000

18

Итого

87 000

139

* с учётом тестов

** проект в разработке

*** с открытым кодом- https://github.com/ergonomic-code/Trainer-Advisor

Все проекты не нагруженные:

  1. 7 rps в суточную пиковую нагрузку
  2. 3.1M строк в самой большой таблице
  3. +218К строк месячного прироста в этой таблице

3 of 36

Какую проблему решаем?

3

4 of 36

Как решаем

4

Функциональная архитектура =

Неизменяемая модель данных + разделение ввода-вывода и бизнес-логики

Неизменяемя модель данных:

Разделение io и логики:

5 of 36

ФА + SDJ = 💗

5

ФА требует неизменяемой модели данных (Java record, Kotlin data class)

SDJ рекомендует использование неизменяемых сущностей

ФА требует декомпозиции модели на независимые элементы

SDJ требует декомпозиции модели на агрегаты

ФА требует удаления циклов из модели

SDJ требует удаления циклов из модели

ФА требует разделения IO и логики

SDJ не делает никакого IO под ковром

ФА требует тщательного проектирования IO

SDJ требует тщательного проектирования IO

6 of 36

Получилось ли решить?

6

7 of 36

Получилось ли решить?

7

8 of 36

Получилось ли решить?

8

9 of 36

Проблема №1:

агрегаты

9

Сущность

Операция

Агрегат

10 of 36

Проблема №1:

агрегаты

10

Две сущности объединяются в агрегат, если:

  1. Жизненный цикл одной из сущностей ограничен жизненным циклом другой
  2. Связаны отношением One-To-One или One-To-Few
  3. Создаются в одной операции

11 of 36

Проблема №1:

агрегаты

11

Алгоритм проектирования агрегатов:

  1. Построить ER-диаграмму
  2. Найти связи 1-F
  3. Добавить на неё операции, создающие сущности
  4. Для каждой связи
    1. Объединить сущности в агрегат, если они соответствуют эвристике

12 of 36

Проблема №1:

агрегаты

12

  1. Жизненный цикл адреса ограничен ж/ц пассажира
  2. У пассажира вряд ли будет более 5 избранных адресов
  3. Но создаются в разных операциях - разные сущности

13 of 36

Проблема №1:

агрегаты

13

  • Жизненный цикл заказа ограничен ещё и ж/ц пассажира
  • У водителя неограниченное количество заказов
  • Создаются в разных транзакциях

14 of 36

Проблема №1:

агрегаты

14

  • Жизненный цикл точки маршрута ограничен ж/ц заказа
  • В заказе вряд ли будет больше 5 точек
  • Создаются в одной транзакции�
  • агрегат

15 of 36

Проблема №2: наследование

  1. Нет и в обозримом будущем не ожидается решения из коробки
  2. Пробовали через jdbcAggregateTemplate и VIEW UNION-ов и через JSONB. Это боль.

15

16 of 36

Проблема №3: загрузка нескольких агрегатов

16

data class Product(

val name: String,

val description: String,

val producer: AggregateReference<Producer, Long>,

@Id

val id: Long = 0,

@CreatedDate

val createdAt: Instant = Instant.now()

)

data class Producer(

val name: String,

val address: String,

@Id

val id: Long = 0,

@CreatedDate

val createdAt: Instant = Instant.now()

)

val product = productsRepo.findById(1L)

println(product.producer.name) 😭

val product = productsRepo.findById(1L)

val producer = producersRepo.findById(product.producer) 😱

println(producer.name)

17 of 36

17

fun getProduct(productId: Long): Product {

val product = productsRepo.findById(productId, fetchSpec = FetchSpec(Product::producer)))

?: error("Product not found: $productId")

println(product.producer.resolveOrThrow().name)😍

return product

}

18 of 36

18

fun getProduct(productId: Long): Product {

val product = productsRepo.findById(productId, fetchSpec = FetchSpec(Product::producer))

?: error("Product not found: $productId")

println(product.producer.resolveOrThrow().name)😍

return product

}

data class Product(

val name: String,

val description: String,

val producer: AggregateReference<Producer, Long>,� @Id

val id: Long = 0,

@CreatedDate

val createdAt: Instant = Instant.now()

)

19 of 36

19

fun getProduct(productId: Long): Product {

val product = productsRepo.findById(productId, fetchSpec = FetchSpec(Product::producer))

?: error("Product not found: $productId")

println(product.producer.resolveOrThrow().name)😍

return product

}

class SmartRepositoryImpl<T : Any, ID>(

private val jdbcAggregateTemplate: JdbcAggregateOperations,

private val entity: PersistentEntity<T, *>,

jdbcConverter: JdbcConverter,

) : SimpleJdbcRepository<T, ID>(jdbcAggregateTemplate, entity, jdbcConverter), SmartRepository<T, ID> {

override fun findById(id: ID, fetchSpec: FetchSpec<T>): T? {

val root = jdbcAggregateTemplate.findById(id!!, entity.type)

?: return null

return jdbcAggregateTemplate.hydrate(listOf(root), fetchSpec).single()

}

}

20 of 36

20

fun getProduct(productId: Long): Product {

val product = productsRepo.findById(productId, fetchSpec = FetchSpec(Product::producer))

?: error("Product not found: $productId")

return product

}

class SmartRepositoryImpl<T : Any, ID>(

private val jdbcAggregateTemplate: JdbcAggregateOperations,

private val entity: PersistentEntity<T, *>,

jdbcConverter: JdbcConverter,

) : SimpleJdbcRepository<T, ID>(jdbcAggregateTemplate, entity, jdbcConverter), SmartRepository<T, ID> {

override fun findById(id: ID, fetchSpec: FetchSpec<T>): T? {

val root = jdbcAggregateTemplate.findById(id!!, entity.type)

?: return null

return jdbcAggregateTemplate.hydrate(listOf(root), fetchSpec).single()

}

}

fun <T : Any> JdbcAggregateOperations.hydrate(

entities: Iterable<T>,

fetchSpec: FetchSpec<T>

): List<T> {

val refs: Map<KProperty1<*, AggregateReference<*, *>?>, Map<Any, Any>> =

fetchSpec.propertyFetchSpecs

.filter { detectRefType(it.property) != null }

.associate { it: PropertyFetchSpec<T, *> ->

val property = it.property as KProperty1<*, AggregateReference<*, *>?>

property to fetchPropertyRefs(entities, it)

}

if (refs.isEmpty()) {

return entities.toList()

}

return entities.map { hydrateEntity(it, refs)}

}

21 of 36

21

fun getProduct(productId: Long): Product {

val product = productsRepo.findById(productId, fetchSpec = FetchSpec(listOf(Product::producer)))

?: error("Product not found: $productId")

return product

}

class SmartRepositoryImpl<T : Any, ID>(

private val jdbcAggregateTemplate: JdbcAggregateOperations,

private val entity: PersistentEntity<T, *>,

jdbcConverter: JdbcConverter,

) : SimpleJdbcRepository<T, ID>(jdbcAggregateTemplate, entity, jdbcConverter), SmartRepository<T, ID> {

override fun findById(id: ID, fetchSpec: FetchSpec<T>): T? {

val root = jdbcAggregateTemplate.findById(id!!, entity.type)

?: return null

return jdbcAggregateTemplate.hydrate(listOf(root), fetchSpec).single()

}

}

fun <T : Any> JdbcAggregateOperations.hydrate(

entities: Iterable<T>,

fetchSpec: FetchSpec<T>

): List<T> {

val refs: Map<KProperty1<*, AggregateReference<*, *>?>, Map<Any, Any>> =

fetchSpec.propertyFetchSpecs

.filter { detectRefType(it.property) != null }

.associate { it: PropertyFetchSpec<T, *> ->

val property = it.property as KProperty1<*, AggregateReference<*, *>?>

property to fetchPropertyRefs(entities, it)

}

if (refs.isEmpty()) {

return entities.toList()

}

return entities.map { hydrateEntity(it, refs)}

}

data class PropertyFetchSpec<T : Any?, V>(

val property: KProperty1<T, V?>,

val fetchSpec: FetchSpec<*> = FetchSpec(

emptyList<PropertyFetchSpec<Any, Any>>()

)

)

data class FetchSpec<T : Any?>(

val propertyFetchSpecs: List<PropertyFetchSpec<T, *>>

) {

constructor(propertyFetchSpec: Iterable<KProperty1<T, *>>) : this(propertyFetchSpec.map {

PropertyFetchSpec(

it

)

})

}

fun <T : Any> JdbcAggregateOperations.hydrate(

entities: Iterable<T>,

fetchSpec: FetchSpec<T>

): List<T> {

val refs: Map<KProperty1<*, AggregateReference<*, *>?>, Map<Any, Any>> =

fetchSpec.propertyFetchSpecs

.filter {

detectRefType(

it.property

) != null

}

.associate { it: PropertyFetchSpec<T, *> ->

val property = it.property as KProperty1<*, AggregateReference<*, *>?>

property to fetchPropertyRefs(entities, it)

}

if (refs.isEmpty()) {

return entities.toList()

}

return entities.map {

hydrateEntity(it, refs)

}

}

private fun <T : Any> JdbcAggregateOperations.fetchPropertyRefs(

entities: Iterable<T>,

propertyFetchSpec: PropertyFetchSpec<T, *>

): Map<Any, Any> {

val property = propertyFetchSpec.property

val ids = fetchIds(entities, property)

val targetType = (property.returnType.arguments[0].type!!.classifier!! as KClass<*>).java

val refs = hydrate(this.findAllById(ids, targetType), propertyFetchSpec.fetchSpec as FetchSpec<Any>)

.associateBy { (it as Identifiable<*>).id }

return refs

}

private fun <T : Any> fetchIds(

entities: Iterable<T>,

property: KProperty1<T, Any?>

): Set<Any?> = when (detectRefType(property)) {

RefType.SCALAR ->

entities

.map { (property.getter.invoke(it) as AggregateReference<*, *>?)?.id }

.toSet()

else -> error("Unsupported property type: $property")

}

private fun <T : Any> hydrateEntity(entity: T, refs: Map<KProperty1<*, AggregateReference<*, *>?>, Map<Any, Any>>): T {

val constructorParams = entity::class.primaryConstructor!!.parameters

val paramValues = constructorParams.associateWith { param ->

val prop =

entity::class.memberProperties.find { prop -> param.name == prop.name }!! as KProperty1<T, AggregateReference<*, *>?>

val currentValue: Any? = prop.invoke(entity)

val newValue = if (prop in refs) {

val id = (currentValue as AggregateReference<*, *>?)?.id

val res: Any? = if (id != null) {

val ref = refs[prop]!![id] as Identifiable<Any>?

checkNotNull(ref) { "Aggregate ${prop.returnType.arguments[0]} not found by id=$id" }

val res: AggregateReferenceTarget<*, *> = AggregateReferenceTarget(ref)

res

} else {

null

}

res

} else {

currentValue

}

newValue

}

return entity::class.primaryConstructor!!.callBy(paramValues)

}

private fun detectRefType(property: KProperty1<*, *>): RefType? = when {

property.returnType.jvmErasure.isSubclassOf(AggregateReference::class) ->

RefType.SCALAR

else ->

null

}

enum class RefType {

SCALAR,

}

22 of 36

22

fun getProduct(productId: Long): Product {

val product = productsRepo.findById(productId, fetchSpec = FetchSpec(Product::producer)))

?: error("Product not found: $productId")

println(product.producer.resolveOrThrow().name)😍

return product

}

fun <R : AggregateReference<T, ID>?, ID : Any, T : Identifiable<ID>> R.resolveOrThrow(): T =

(this as? AggregateReferenceTarget<T, ID>)?.entity

?: error("$this is not instance of AggregateReferenceTarget")

23 of 36

Проблема №3: загрузка нескольких агрегатов

23

24 of 36

Проблема №4: динамические запросы

24

25 of 36

Проблема №4: динамические запросы

  • MyBatis
  • QueryDsl
  • QBE (SDJ >=2.2)
  • JdbcAggregateTemplate + Query API (SDJ >= 3.0.0)
  • Кастомный базовый репозиторий + кастомный Query Dsl

25

26 of 36

26

fun findProducts(

name: String?,

description: String?,

pageRequest: Pageable

): Page<Product> {

val byNameOrDescr = (Product::name isLikeIfNotNull name) OR

(Product::description isLikeIfNotNull description)

return productsRepo.findPage(Query.query(byNameOrDescr), pageRequest)

}

27 of 36

27

fun findProducts(

name: String?,

description: String?,

pageRequest: Pageable

): Page<Product> {

val byNameOrDescr = (Product::name isLikeIfNotNull name) OR

(Product::description isLikeIfNotNull description)

return productsRepo.findPage(Query.query(byNameOrDescr), pageRequest)

}

data class Product(

val name: String,

val description: String,

val producer: AggregateReference<Producer, Long>,

@Id

val id: Long = 0,

@CreatedDate

val createdAt: Instant = Instant.now()

)

28 of 36

28

infix fun <T, V> KProperty1<T, V>.isLikeIfNotNull(value: String?): Criteria {

return if (value != null) {

this.columnName().isLike(value)

} else {

Criteria.empty()

}

}

fun findProducts(

name: String?,

description: String?,

pageRequest: Pageable

): Page<Product> {

val byNameOrDescr = (Product::name isLikeIfNotNull name) OR

(Product::description isLikeIfNotNull description)

return productsRepo.findPage(Query.query(byNameOrDescr), pageRequest)

}

29 of 36

29

infix fun <T, V> KProperty1<T, V>.isLikeIfNotNull(value: String?): Criteria {

return if (value != null) {

this.columnName().isLike(value)

} else {

Criteria.empty()

}

}

fun findProducts(

name: String?,

description: String?,

pageRequest: Pageable

): Page<Product> {

val byNameOrDescr = (Product::name isLikeIfNotNull name) OR

(Product::description isLikeIfNotNull description)

return productsRepo.findPage(Query.query(byNameOrDescr), pageRequest)

}

infix fun Criteria.OR(criteria: Criteria): Criteria =

when {

this != Criteria.empty() && criteria != Criteria.empty() ->

this.or(criteria)

this == Criteria.empty() ->

criteria

else ->

this

}

30 of 36

30

infix fun <T, V> KProperty1<T, V>.isLikeIfNotNull(value: String?): Criteria {

return if (value != null) {

this.columnName().isLike(value)

} else {

Criteria.empty()

}

}

fun findProducts(

name: String?,

description: String?,

pageRequest: Pageable

): Page<Product> {

val byNameOrDescr = (Product::name isLikeIfNotNull name) OR

(Product::description isLikeIfNotNull description)

return productsRepo.findPage(Query.query(byNameOrDescr), pageRequest)

}

infix fun Criteria.OR(criteria: Criteria): Criteria =

when {

this != Criteria.empty() && criteria != Criteria.empty() ->

this.or(criteria)

this == Criteria.empty() ->

criteria

else ->

this

}

class SmartRepositoryImpl<T : Any, ID>(

private val jdbcAggregateTemplate: JdbcAggregateOperations,

private val operations: NamedParameterJdbcOperations,

private val entity: RelationalPersistentEntity<T>,

jdbcConverter: JdbcConverter,

) : SimpleJdbcRepository<T, ID>(jdbcAggregateTemplate, entity, jdbcConverter), SmartRepository<T, ID> {

protected val rowMapper = EntityRowMapper(entity, jdbcConverter)

override fun findPage(query: Query, pageRequest: Pageable): Page<T> {

return jdbcAggregateTemplate.findAll(query, entity.type, pageRequest)

}

31 of 36

31

fun findProducts(

name: String?,

description: String?,

producerName: String?,

pageRequest: Pageable

): Page<Product> {

val product = Table.create("product")

val producer = Table.create("producer")

val query = Select.builder()

.select(product.all())

.from(product)

.join(producer)

.on(create(product["producer"], "=", producer["id"]))

.where(TrueCondition.INSTANCE)

.andWhereIfPresent(name) {

Like.create(product["name"], literalOf("%$it%")) }

.andWhereIfPresent(description) {� Like.create(product["description"], literalOf("%$it%")) }

.andWhereIfPresent(producerName) {� Like.create(producer["name"], literalOf("%$it%")) }

.build()

.let { SqlRenderer.create().render(it) }

val params = mapOf(

"name" to name,

"description" to description,

"producerName" to producerName,

)

return productsRepo.findPage(query, params, pageRequest)

}

32 of 36

32

fun findProducts(

name: String?,

description: String?,

producerName: String?,

pageRequest: Pageable

): Page<Product> {

val product = Table.create("product")

val producer = Table.create("producer")

val query = Select.builder()

.select(product.all())

.from(product)

.join(producer)

.on(create(product["producer"], "=", producer["id"]))

.where(TrueCondition.INSTANCE)

.andWhereIfPresent(name) {

Like.create(product["name"], literalOf("%$it%")) }

.andWhereIfPresent(description) {� Like.create(product["description"], literalOf("%$it%")) }

.andWhereIfPresent(producerName) {� Like.create(producer["name"], literalOf("%$it%")) }

.build()

.let { SqlRenderer.create().render(it) }

val params = mapOf(

"name" to name,

"description" to description,

"producerName" to producerName,

)

return productsRepo.findPage(query, params, pageRequest)

}

fun SelectBuilder.SelectWhereAndOr.andWhereIfPresent(

param: String?,

condition: (String) -> Condition

): SelectBuilder.SelectWhereAndOr =

if (param != null) {

this.or(condition(param))

} else {

this

}

fun Table.all() =

AsteriskFromTable.create(this)

operator fun Table.get(columnName: String): Expression =

Column.create(columnName, this)

33 of 36

33

fun findProducts(

name: String?,

description: String?,

producerName: String?,

pageRequest: Pageable

): Page<Product> {

val product = Table.create("product")

val producer = Table.create("producer")

val query = Select.builder()

.select(product.all())

.from(product)

.join(producer)

.on(create(product["producer"], "=", producer["id"]))

.where(TrueCondition.INSTANCE)

.andWhereIfPresent(name) {

Like.create(product["name"], literalOf("%$it%")) }

.andWhereIfPresent(description) {� Like.create(product["description"], literalOf("%$it%")) }

.andWhereIfPresent(producerName) {� Like.create(producer["name"], literalOf("%$it%")) }

.build()

.let { SqlRenderer.create().render(it) }

val params = mapOf(

"name" to name,

"description" to description,

"producerName" to producerName,

)

return productsRepo.findPage(query, params, pageRequest)

}

class SmartRepositoryImpl<T : Any, ID>(

/* ** */

) : SimpleJdbcRepository<T, ID>(jdbcAggregateTemplate, entity, jdbcConverter), SmartRepository<T, ID> {

protected val rowMapper = EntityRowMapper(entity, jdbcConverter)

override fun findPage(

query: String,

paramMap: Map<String, Any?>,

pageRequest: Pageable,

): Page<T> {

// RowMapper из SDJ

return operations.queryForPage(query, paramMap, pageRequest, rowMapper)

}

}

34 of 36

34

fun findProducts(

name: String?,

description: String?,

producerName: String?,

pageRequest: Pageable

): Page<Product> {

val product = Table.create("product")

val producer = Table.create("producer")

val query = Select.builder()

.select(product.all())

.from(product)

.join(producer)

.on(create(product["producer"], "=", producer["id"]))

.where(TrueCondition.INSTANCE)

.andWhereIfPresent(name) {

Like.create(product["name"], literalOf("%$it%")) }

.andWhereIfPresent(description) {� Like.create(product["description"], literalOf("%$it%")) }

.andWhereIfPresent(producerName) {� Like.create(producer["name"], literalOf("%$it%")) }

.build()

.let { SqlRenderer.create().render(it) }

val params = mapOf(

"name" to name,

"description" to description,

"producerName" to producerName,

)

return productsRepo.findPage(query, params, pageRequest)

}

fun <T : Any> NamedParameterJdbcOperations.queryForPage(

baseSql: String,

filterParams: Map<String, Any?>,

pageRequest: Pageable,

rowMapper: RowMapper<T>

): Page<T> {

val count = this.queryForObject(

"SELECT count(*) FROM ($baseSql) AS data", filterParams, Long::class.java)

if (count == 0L) {

return PageImpl(emptyList(), pageRequest, count)

}

val dataQuery = buildString {

appendLine("SELECT * FROM($baseSql) data")

if (pageRequest.sort.isSorted) {

append("ORDER BY ")

appendLine(pageRequest.sort� .map { it.property + " " + it.direction.name }

.joinToString(", "))

}

if (pageRequest.isPaged) {

appendLine("""

LIMIT :pageSize

OFFSET :offset

"""

)

}

}

val pagingParams = if (pageRequest.isPaged) {

mapOf("pageSize" to pageRequest.pageSize, "offset" to pageRequest.offset)

} else {

emptyMap()

}

val data = this.query(

dataQuery,

filterParams + pagingParams,

rowMapper

)

return PageImpl(data, pageRequest, count)

}

35 of 36

Проблема №4: динамические запросы

35

36 of 36

Заключение

  1. На SDJ можно делать коммерческие проекты
  2. Если использовать функциональную архитектуру
  3. И знать и любить SQL
  4. И не бояться писать код
  5. Эти проекты будут намного более простыми в поддержке

36