В предыдущей статье мы обсудили недостатки решений Spring в части доступа к данным.

В ходе анализа решений Spring стало очевидно, что оба фреймворка используют радикально разные подходы в работе с данными. Казалось бы, контроль над запросами очень важен для приложений, особенно высоконагруженных. Но Spring Data JPA такого контроля не даёт. Лёгкость и простота изменения кода является залогом его чистоты и работоспособности, однако с этим есть сложности уже у Spring JDBC.

Всего-то нужен фреймворк, предоставляющий полный контроль над запросами со стороны разработчика и не создающий трудностей при развитии, изменении и рефакторинге кода. В этой статье мы разберём две альтернативы, которые, на мой взгляд, в меньшей степени подвержены проблемам Spring Data JPA и Spring JDBC.

Это будут jOOQ и Exposed.

Есть два фреймворка...
Есть два фреймворка...

Начнём с jOOQ.

jOOQ. Что это?

Среди альтернатив решениям Spring jOOQ достаточно популярен. Например, когда мы анонсировали доклад на эту тему, многие предположили, что одним из разбираемых фреймворков будет именно jOOQ. Это DSL-фреймворк, который позволяет «собирать» SQL-запрос посредством цепочки Java-методов (как работает обычный Builder).

Что ж, давайте разберём его на соответствие нашим требованиям.

jOOQ. Контроль над запросами

В отличие от Spring Data JPA, здесь всё в порядке. Нет никаких побочных эффектов. Нет проблемы N + 1 (если, конечно, вы сами её себе не создадите неоптимальными запросами, хе-хе). Все запросы будет выполнены именно так и именно в том количестве, в котором вы их опишете.

Код проекта лежит здесь.

DSL-фреймворки решают проблему «перевода» из Java в SQL и обратно, позволяя написать запрос в базу данных в архитектуре Java таким образом, чтобы он точно соответствовал ожидаемому запросу на языке SQL. Выше мы уже разбирали один такой SQL-запрос из Spring JDBC:

SELECT restaurants.id, restaurants.name 
FROM restaurants
    RIGHT JOIN dishes ON restaurants.id = dishes.restaurant_id
    RIGHT JOIN orders ON dishes.id = orders.dish_id
WHERE orders.user_id = :userId

Такой запрос написан на SQL, и поэтому предельно однозначен с точки зрения понимания СУБД. Написать более понятный для СУБД запрос мы не сможем. Тем не менее, jOOQ позволяет написать запрос, который при интерпретации в SQL будет точно соответствовать нашим ожиданиям. Приведённый в пример SQL-запрос будет выглядеть так:

override fun getAllByUserOrdered(userId: UUID): List<Restaurant> =
    dsl
        .select(Restaurants.RESTAURANTS.ID, Restaurants.RESTAURANTS.NAME)
        .from(Restaurants.RESTAURANTS)
        .rightJoin(Dishes.DISHES).on(Dishes.DISHES.RESTAURANT_ID.eq(Restaurants.RESTAURANTS.ID))
        .rightJoin(Orders.ORDERS).on(Orders.ORDERS.DISH_ID.eq(Dishes.DISHES.ID))
        .where(Orders.ORDERS.USER_ID.eq(userId))
        .fetchInto(Restaurant::class.java)

Структура "сборки" повторяет структуру SQL-запроса (select ... from ... right join ... right join ... where ...), что делает написание запроса интуитивно понятным. После интерпретации в SQL запрос в базу данных выглядит следующим образом:

select "public"."restaurants"."id", "public"."restaurants"."name"
from "public"."restaurants"
         right outer join "public"."dishes" on "public"."dishes"."restaurant_id" = "public"."restaurants"."id"
         right outer join "public"."orders" on "public"."orders"."dish_id" = "public"."dishes"."id"
where "public"."orders"."user_id" = cast('1031f963-957c-425f-962e-767080a699cb' as uuid);

Да, jOOQ подставил схему, кастанул строчку в UUID и экранировал имена. Но по своей сути этот запрос ничем не отличается от SQL-запроса, который мы написали выше.

Экранирование имён, кстати, jOOQ реализует в зависимости от диалекта базы данных, с которой он работает (кавычки для Postgres, обратные кавычки для MySQL, квадратные скобки для SQL Server и так далее). Впрочем, этот параметр при желании можно отключить.

Написать можно любой запрос.

Когда я рассказываю про jOOQ какой-нибудь команде, обязательно возникает вопрос:

Но это же DSL на Java! Разве может он покрыть всю функциональность диалекта, с которым работает? Наверняка есть какие-то специфичные команды, которые используются раз в десятилетие, и вот именно они не реализованы в jOOQ?

И я отвечаю, что таких команд я не видел. jOOQ поддерживает все популярные диалекты (PostgreSQL, MySQL, MariaDB, Oracle, Microsoft SQL Server и ещё несколько десятков других). Первый релиз фреймворка в Maven состоялся в 2011 году, и фреймворк продолжает очень активно развиваться, что даёт уверенность в очень глубокой проработке функциональности фреймворков.

Во всяком случае, за несколько лет использования я ни разу не наткнулся на функциональность в СУБД, которая не была бы реализована в jOOQ. Таким образом, в части контроля над запросами, jOOQ не выглядит хуже блестящего в этом отношении Spring JDBC.

jOOQ. Удобство поддержки кода

 Удобство в поддержке кода: всё почти великолепно
Удобство в поддержке кода: всё почти великолепно

В части удобства поддержки кода у jOOQ (и ему подобных) есть несколько существенных преимуществ.

Type Safety — то, чем не мог похвалиться Spring JDBC

Самым очевидным преимуществом jOOQ в части поддержки кода является, на мой взгляд, типобезопасность запросов. Используя Spring JDBC, мы вынуждены писать запросы в SQL на свой страх и риск, надеясь, что мы не опечатались и, в целом, написали правильный запрос. При этом такие запросы могут не отработать вообще, и вы узнаете об этом только после его исполнения. jOOQ (и другие DSL-фреймворки) отвечают за корректность запросов, если приложение успешно скомпилировалось.

Если в сравнении Spring Data JPA и Spring JDBC типобезопасность уравновешивала контроль над запросами (или типобезопасность в Spring Data JPA, или контроль над запросами в Spring JDBC), то в случае с jOOQ мы не платим типобезопасностью за выбор контроля — мы получаем и то, и другое.

Генератор репозиторных сущностей

Об этой киллер-фиче jOOQ можно написать отдельную статью, чтобы разобрать все достоинства и недостатки. Здесь же мы рассмотрим генератор репозиторных сущностей в общих чертах. В jOOQ он является огромной благодатью и небольшим проклятием. При помощи генератора мы извлекаем из базы данных всю информацию в рамках интересующих нас схем. Но без этих репозиторных сущностей фреймворк не работает. Разберём подробнее.

Генератор — это плагин, позволяющий сгенерировать программные компоненты, необходимые для работы фреймворка, а именно:

  • репозиторные сущности;

  • информацию о таблицах;

  • информацию о схеме;

  • некоторые функции для построения запросов;

  • индексы, ограничения, ключи...

В общем, всё, что можно получить из интересующей нас схемы и описать при помощи Java-кода.

Когда я впервые столкнулся с jOOQ, я потратил немало времени на настройку генератора. При написании примера для доклада и статьи у меня ушло на описание генератора около 5 минут, и он запустился с первого раза. Описание плагина выглядит следующим образом:

plugins {
	kotlin("jvm") version "1.9.25"
	kotlin("plugin.spring") version "1.9.25"
	id("org.springframework.boot") version "3.4.2"
	id("io.spring.dependency-management") version "1.1.7"
	id("org.jooq.jooq-codegen-gradle") version "3.19.18"
}

...

jooq {
	configuration {
		jdbc { //настройки подключения к базе данных
			driver = "org.postgresql.Driver"
			url = "jdbc:postgresql://localhost:5432/postgres"
			user = "postgres"
			password = "postgres"
		}

		generator { //настройки генерации
			database {
				name = "org.jooq.meta.postgres.PostgresDatabase"
				inputSchema = "public" //схема
			}

			target { //целевой пакет для генерации
				packageName = "ru.xpendence.jooq.repository.entity"
				directory = "build/generated-src/jooq/main"
			}
		}
	}
}

Это всё, что требуется для настройки генератора. При запуске задачи Gradle в пакете build/generated-src/jooq/main сгенерируется всё необходимое для работы фреймворка. Конечно, вы можете указать любой пакет для генерации, в том числе и в пакете repository.entity вашего проекта, получив, таким образом, репозиторные сущности из коробки прямо в пакете main.

При огромном преимуществе, которое даёт генератор, есть и недостатки. Репозиторные сущности выглядят откровенно плохо. В них очень тяжело разобраться. По сути, это геттеры и сеттеры, без прямого объявления полей. Получить полную информацию о типах сущностей вы не сможете: всё будет описано в пределах, достаточных для работы с ними. И не более.

Так выглядит типичный record (репозиторная сущность, сгенерированная jOOQ):

public class RestaurantsRecord extends UpdatableRecordImpl<RestaurantsRecord> {

    private static final long serialVersionUID = 1L;

    /**
     * Setter for <code>public.restaurants.id</code>.
     */
    public void setId(UUID value) {
        set(0, value);
    }

    /**
     * Getter for <code>public.restaurants.id</code>.
     */
    public UUID getId() {
        return (UUID) get(0);
    }

    /**
     * Setter for <code>public.restaurants.name</code>.
     */
    public void setName(String value) {
        set(1, value);
    }

    /**
     * Getter for <code>public.restaurants.name</code>.
     */
    public String getName() {
        return (String) get(1);
    }

    // -------------------------------------------------------------------------
    // Primary key information
    // -------------------------------------------------------------------------

    @Override
    public Record1<UUID> key() {
        return (Record1) super.key();
    }

    // -------------------------------------------------------------------------
    // Constructors
    // -------------------------------------------------------------------------

    /**
     * Create a detached RestaurantsRecord
     */
    public RestaurantsRecord() {
        super(Restaurants.RESTAURANTS);
    }

    /**
     * Create a detached, initialised RestaurantsRecord
     */
    public RestaurantsRecord(UUID id, String name) {
        super(Restaurants.RESTAURANTS);

        setId(id);
        setName(name);
        resetChangedOnNotNull();
    }
}

Пользоваться можно, читать — нет.

Присутствие рутины в реализации репозитория

Впрочем, использование jOOQ и подобных ему DSL-фреймворков влечёт и некоторые неудобства, особенно заметные при переходе со Spring Data JPA. Огромным и неоспоримым достоинством Spring Data JPA является очень высокий уровень автоматизации, предлагающий разработчику очень абстрактное решение с готовой реализацией «из коробки». Да, автоматизация в jOOQ тоже частично присутствует, благодаря генератору репозиторных сущностей, но запросы теперь придётся писать в полной мере, как и в случае со Spring JDBC.

Определение полей

Запись новой строки в базу данных реализована в jOOQ двумя способами: через определение полей непосредственно в записи таблицы и передачу репозиторной сущности в качестве параметра. Определение полей и является той самой рутиной.

override fun insertAsFields(restaurant: Restaurant): Restaurant =
    dsl
        .insertInto(Restaurants.RESTAURANTS)
        .set(Restaurants.RESTAURANTS.ID, restaurant.id)
        .set(Restaurants.RESTAURANTS.NAME, restaurant.name)
        ...
        .set(Restaurants.RESTAURANTS.<поле номер 100+>, restaurant.<значение поля номер 100+>)
        .returning()
        .map { it.toRestaurant() }
        .single()

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

Второй способ: автоматическое проецирование бизнес-сущности в репозиторную с последующей записью в таблицу целиком.

override fun insertAsRecord(restaurant: Restaurant): Restaurant =
    dsl
        .insertInto(Restaurants.RESTAURANTS)
        .set(dsl.newRecord(Restaurants.RESTAURANTS, restaurant)) //автоматический мапинг полей
        .returning()
        .map { it.toRestaurant() }
        .single()

Но, в таком случае, вам нужно будет поддерживать контракт на наименования полей между бизнес-сущностью и репозиторной — или воспользоваться Jakarta Persistence API.

import jakarta.persistence.Column //втащили Джакарту
import java.util.UUID

data class Restaurant(
  
    val id: UUID? = null,

    @Column(name = "name") //мапим табличные колонки на поля бизнес-сущности
    var description: String? = null
)

И мы получаем нарушение чистой архитектуры по тому же рецепту, по которому это происходит в Spring Data JPA. Да, отныне бизнес-сущность знает о деталях таблиц базы данных (пусть и в ограниченном виде) и начинает зависеть от них. Рутина или нарушение архитектуры — решать вам.

Таким образом, в части удобства в поддержке кода jOOQ вполне соответствует нашим требованиям: типобезопасность; проверка запросов со стороны фреймворка; чтобы получить изменения в таблице, достаточно перезапустить генератор.

jOOQ. Соблюдение чистой архитектуры

В части соблюдения чистой архитектуры использование jOOQ не доставляет никаких неудобств. Фреймворк позволяет разместить репозиторные сущности в любом месте приложения и за его пределами и инкапсулировать репозиторий в пределах своего слоя.

Впрочем, как мы обсуждали буквально парой абзацев выше, jOOQ допускает возможность навредить архитектуре приложения, если вы пошли на автоматическое проецирование полей и спроецировали репозиторную сущность на бизнес-сущность прямо в предметной области.

jOOQ. Выводы

В какой-то момент вам могло показаться, что соблюдение в достаточной мере всех трёх вышеперечисленных требований недостижимо и придётся чем-то пожертвовать. В случае со Spring Data JPA мы пожертвовали контролем над запросами и чистой архитектурой в угоду удобству в поддержке кода. В случае со Spring JDBC мы, наоборот, получили контроль над запросами ценой принятия сложностей, связанных с поддержкой запросов, описанных в SQL при помощи строковых литералов.

Что же касается чистой архитектуры, то здесь решать вам: jOOQ предоставляет инструменты, позволяющие этой самой архитектуре навредить.

jOOQ и подобные ему DSL-фреймворки в достаточной мере реализуют все три требования, предоставляя решения, позволяющие контролировать запросы в базу данных и при этом делать это типобезопасно.

На этом можно было бы остановиться, но существует ещё один DSL-фреймворк, в разной степени отличный от первых трёх решений, который хотелось бы разобрать. Это Exposed от JetBrains.

Exposed. Что это?

Это не осьминог. Это репозиторный фреймворк от JetBrains.
Это не осьминог. Это репозиторный фреймворк от JetBrains.

Это DSL-фреймворк от JetBrains, реализованный целиком на Kotlin, что привносит некоторую специфику в работу с ним. С одной стороны, вы не сможете использовать его в Java-проектах. С другой стороны, разработчики используют синтаксис Kotlin по максимуму, что делает решение очень приятным в работе.

В отличие от jOOQ, Exposed не имеет собственного генератора, что является и достоинством, и недостатком: да, придётся потратить время на описание таблиц, но такие описания будут более понятными и управляемыми.

Впрочем, об этом ниже.

Код проекта лежит здесь.

Exposed. Контроль над запросами

Контроль над запросами: всё прилично
Контроль над запросами: всё прилично

Да, по сравнению с jOOQ Exposed имеет свою специфику написания запросов, заточенную под синтаксис Kotlin, но это не делает такие запросы менее управляемыми. Вы по-прежнему можете написать любой запрос, какой только захотите.

Причудливый синтаксис

Что же касается специфики написания запросов, то, в отличие от jOOQ, предлагаемое решение фокусируется больше на объекте, чем на повторении SQL-синтаксиса. Использование High-order функций этому вовсю способствует.

Типичный запрос выглядит так:

override fun getAllByUserOrdered(userId: UUID): List<Restaurant> = transaction {
    RestaurantTable
        .rightJoin(DishTable)
        .join(
            otherTable = OrderTable,
            joinType = JoinType.RIGHT,
            onColumn = DishTable.id,
            otherColumn = OrderTable.dishId
        )
        .select(RestaurantTable.columns)
        .where { OrderTable.userId eq userId }
        .map { it.toRestaurant() }
}

Да, сначала мы определяем ресурс, из которого будем получать данные, создаём и настраиваем join-ы, и только потом определяем, какую часть ресурса мы хотим получить, а также условия выборки. Да, такая компоновка запроса отличается от исходного синтаксиса SQL, который так скрупулёзно повторяет jOOQ, но конечный запрос от этого не пострадает и будет выглядеть так:

SELECT restaurants.id, restaurants."name"
FROM restaurants
         RIGHT JOIN dishes ON restaurants.id = dishes.restaurant_id
         RIGHT JOIN orders ON dishes.id = orders.dish_id
WHERE orders.user_id = '1031f963-957c-425f-962e-767080a699cb';

Такой запрос даже более отвечает тому, который мы писали для Spring JDBC. Нет излишнего экранирования из коробки и кастинга строк в UUID. Да, мы видим, что поле name экранировано, но это происходит только в случае необходимости, а не по умолчанию, как в jOOQ.

Рукописные модели

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

Первым очевидным достоинством является точечность переноса. Если jOOQ высасывает всю схему целиком «как есть», то для Exposed мы сами определяем состав нужных таблиц. В схеме может быть огромное количество служебных таблиц: например, для Quartz или Camunda. Конечно, разные области хранения нужно разделять по схемам, но так происходит не всегда. В случае с jOOQ генератор заботливо сгенерирует модели по всем имеющимся в схеме таблицам. Exposed генератором не располагает, а раз нет генератора, значит, нет и проблем, которые он порождает.

Типичная модель хоть и выглядит так же своеобразно, как и запросы, но позволяет вытащить из таблиц намного больше информации, чем генератор jOOQ.

object DishTable : IdTable<UUID>("dishes") {
    override val id = uuid("id").entityId()
    override val primaryKey = PrimaryKey(id)
    val name = varchar("name", 255)
    val price = decimal("price", 19, 2)
    val active = bool("active").nullable()
    val restaurantId = reference("restaurant_id", RestaurantTable)
}

Да, для работы с Exposed не нужно создавать бины — для этого достаточно синглтонов, унаследованных от нужных интерфейсов. Все репозиторные сущности наследуются от интерфейса Table, который имеет несколько наследников: UUIDTable, IdTable, LongTable и прочих. Будьте осторожны: некоторые из них при запросе сохранения данных генерируют идентификатор самостоятельно.

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

Обещанная точность описания заключается в переносе не только точного типа колонки, но и её ёмкости, при наличии. Также, необходимо будет отметить нулабельные поля, поскольку это определяет специфику работы с полем в связи с поддержкой null-safety в Kotlin.

Поаккуратнее с Kotlin
Поаккуратнее с Kotlin

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

//примеры абстрактной регистрации колонок 
fun uuid(name: String): Column<UUID> = registerColumn(name, UUIDColumnType())

fun bool(name: String): Column<Boolean> = registerColumn(name, BooleanColumnType())

...

//пример перерегистрации колонки как нулабельной
fun <T : Any> Column<T>.nullable(): Column<T?> {
    val newColumn = Column<T?>(table, name, columnType)
    newColumn.foreignKey = foreignKey
    newColumn.defaultValueFun = defaultValueFun
    @Suppress("UNCHECKED_CAST")
    newColumn.dbDefaultValue = dbDefaultValue as Expression<T?>?
    newColumn.isDatabaseGenerated = isDatabaseGenerated
    newColumn.columnType.nullable = true
    newColumn.extraDefinitions = extraDefinitions
    return replaceColumn(this, newColumn)
}

...

//в конце перерегистрации происходит подмена старой колонки на новую
fun <TColumn : Column<*>> replaceColumn(oldColumn: Column<*>, newColumn: TColumn): TColumn {
    _columns.remove(oldColumn)
    _columns.addColumn(newColumn)
    return newColumn
}

Всё это происходит на этапе компиляции.

Диалекто-специфичные запросы

Когда я рассказываю командам про новые для них репозиторные фреймворки, то первый вопрос, который мне задают:

А как у этого фреймворка обстоит со специфичными запросами? А ну как я напишу такой запрос, который DSL не понимает? а? А?

И это очень важный вопрос, на который необходимо найти однозначный ответ ещё на этапе выбора фреймворка.

Да, Spring Data JPA и jOOQ поддерживают приличное количество диалектов. Для Spring JDBC такая проблема попросту отсутствует, поскольку мы пишем запросы «в сыром виде», и можем писать их на любом диалекте, на котором захотим.

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

Например, мы выбрали PostgreSQL, в котором есть оператор ILIKE, позволяющий производить регистронезависимый поиск по частичному вхождению. Тот же LIKE, но без учёта регистра. Оператор очень специфичен, и его поддержки по умолчанию в Exposed попросту нет. Но мы можем его создать следующим образом:

class ILikeOp(expr1: Expression<*>, expr2: Expression<*>) : ComparisonOp(expr1, expr2, "ILIKE")

infix fun ExpressionWithColumnType<String>.iLike(pattern: String): Op<Boolean> =
    ILikeOp(this, QueryParameter(pattern, columnType))

Мы создаём класс ILikeOp, который содержит необходимый нам оператор с учётом синтаксиса Postgres. Далее, мы создаём функцию iLike, которая применяет этот оператор в запросе. Запрос при этом будет выглядеть так:

override fun getByNameILike(namePart: String): List<Restaurant> = transaction {
    RestaurantTable
        .selectAll()
        .where { RestaurantTable.name iLike "%$namePart%" }
        .map { it.toRestaurant() }
}

Такой запрос целиком интегрируется в архитектуру запроса Exposed и не вызывает никаких вопросов. Конечный запрос в базу данных выглядит следующим образом:

SELECT restaurants.id, restaurants."name"
FROM restaurants
WHERE restaurants."name" ILIKE '%большая%'

ILIKE и есть.

В общем, что касается способности Exposed контролировать запросы, то здесь нет никаких ограничений. Фреймворк делает это блестяще. При этом, мы контролируем не только запросы, но и структуры данных через ручную поддержку репозиторных сущностей.

Exposed. Удобство поддержки кода

Удобство в поддержке кода: всё идеально. Почти.
Удобство в поддержке кода: всё идеально. Почти.

В части удобства поддержки кода у Exposed всё так же замечательно, как и у jOOQ. Являясь DSL-фреймворком, Exposed предоставляет все те же плюшки.

Простота поддержки

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

object DishTable : IdTable<UUID>("dishes") {
    override val id = uuid("id").entityId()
    override val primaryKey = PrimaryKey(id)
    val name = varchar("name", 255)
    val price = decimal("price", 19, 2)
    val active = bool("active")
    val restaurantId = reference("restaurant_id", RestaurantTable)
}

Все свойства находятся в одном месте.

Типобезопасность

Exposed отвечает за type safety так же, как это делает любой другой DSL-фреймворк. Если ваш код скомпилировался, значит фреймворк отвечает за корректность запроса как минимум с точки зрения синтаксиса.

Правда, за неимением генератора, фреймворк при этом всецело опирается на то, как вы описали репозиторные сущности, и это накладывает некоторую специфику на работу с ними. Как минимум, придётся:

  • быть очень внимательным при заведении таблиц в проект;

  • следить за обновлениями свойств базы данных и вовремя их актуализировать.

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

Повторюсь: покрывать проект тестами нужно вне зависимости от того, каким репозиторным фреймворком вы пользуетесь — Spring JDBC, Spring Data JPA, jOOQ или Exposed. Тесты — это самый дешёвый путь проверить работоспособность вашего кода. Пишутся один раз, работают бесконечно.

Вывод: в части удобства Exposed так же удобен, как и jOOQ.

Exposed. Соблюдение чистой архитектуры

Среди сравниваемых в этой статье фреймворков лучшая реализация соблюдения чистой архитектуры, на мой взгляд, принадлежит Exposed. Мы можем где угодно и как угодно размещать наши репозиторные сущности. Да, нам приходится вбивать все данные вручную, но это малая плата за свободу в архитектуре.

Инструментов навредить архитектуре, как в случае с jOOQ, Exposed не предоставляет. С этой точки зрения он ближе к реализации Spring JDBC, чем Spring Data JPA и jOOQ.

Exposed. Выводы

Exposed: итого
Exposed: итого

Как и jOOQ, Exposed предоставляет отличное, на мой взгляд, решение для работы с базой данных. Да, код запросов фреймворка не так точно повторяет архитектуру запросов в SQL, но, тем не менее, запросы Exposed позволяют добиться в точности тех запросов, которые нам нужны.

При этом функциональность фреймворка не ограничивается кодом библиотеки. Мы можем расширить её любыми диалекто-специфичными командами и операторами.

Написанный в стиле и синтаксисе Kotlin, Exposed невероятно приятен в использовании (лично для меня), но это является и ограничением: использовать фреймворк в приложениях на Java не получится.

При этом Exposed никак не ограничивает нас в соблюдении чистой архитектуры, не предлагая, как это делает jOOQ, способов «срастить» бизнес- и репозиторные сущности.

В своё время я внедрил Exposed в несколько проектов. Зачастую, сопротивление было жутким. Однажды я долго и безрезультатно уговаривал команду, в которой я оказался уже по завершении активной разработки, перейти со Spring JDBC на Exposed. Уговоры особо не помогали, и я решил хоть как-то облегчить свою участь.

Я решил, что раз уж приходится иметь дело с монструозной сложности JDBC-запросами и, что самое страшное, поддерживать изменения в них, не лишним будет выделить одинаковые куски (например, объединения таблиц) в отдельные строки и добавлять их в запросы при помощи шаблонизации. Например, если при запросе к таблице А в 90 % случаев к ней присоединяются таблицы Б, В и Г, то этот кусок можно оптимизировать.

Хорошо. Я оптимизировал объединения, и запросы стали намного проще. Но как быть с огромным количеством самых разных полей? Одно и то же поле используется в десятках запросов. Стоит ему поменяться в базе данных, и я утону в бесконечном поиске этого поля в многочисленных запросах. Я решил вынести поля в константы и использовать их во всех запросах вместо простого перечисления этих полей.

И тут я заметил, что, по сути, пишу Exposed. К этому времени команда присмотрелась к реализуемой мной оптимизации, и большинство коллег сочли такой рефакторинг рациональным. И, опираясь на проделанную работу, я смог доказать, что Exposed делает то же самое, просто его решение уже готовое, а моё — ещё нет. Команда сдалась, и мы стали затаскивать Exposed во все другие проекты.

Спустя несколько месяцев я перешёл в другую команду, использующую документоориентированную СУБД, и о реляционных фреймворках на какое-то время пришлось забыть.

Спустя год я встретил лида того кластера и поинтересовался, как у них дела с использованием Exposed: оставили ли они его в текущих проектах или убрали с облегчением после моего ухода? Он ответил, что с тех пор он закладывает Exposed в технологический стек каждого последующего проекта.

Таким образом, Exposed выглядит как решение, отвечающее всем требованиям, предъявляемым в этой статье.

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

Чтобы итог получился максимально беспристрастным, сделаем что-то наподобие турнирной таблицы, как в футболе. За каждое соответствие требованию фреймворк получит 3 балла. За частичное соответствие — 1 балл. За несоответствие — 0.

Сводная таблица у нас такая
Сводная таблица у нас такая

Spring Data JPA

С точки зрения удобства использования Spring Data JPA выше всяких похвал. Околонулевой порог вхождения и действительно удобная абстракция над базой данных подкупает многих разработчиков, особенно начинающих. Фреймворк обеспечивает быстрый запуск, особенно когда дело касается небольших и несложных проектов. За соответствие требованию к удобству использования Spring Data JPA получает свои заслуженные 3 балла.

Что же касается чистой архитектуры, то здесь уже всё намного печальнее. Перед очень сильным соблазном «сращивания» бизнес- и репозиторной сущностей устоит редкая команда, а реализация репозиторного API посредством интерфейсов делает задачу «разведения» сущностей сложнореализуемой. Сам подход Spring Data JPA призывает ломать чистую архитектуру и смешивать доменный и репозиторный слои. Итого, 1 балл.

Контроль над запросами в базу данных в основном инструменте Spring Data JPA (за исключением альтернативы в виде Query) напрочь отсутствует. Вам вряд ли удастся выполнить именно те запросы, которые вам нужны, и именно в той последовательности. Итого, 0 баллов.

В сумме Spring Data JPA получает 3 + 1 + 0 = 4 балла.

Spring JDBC

В случае со Spring JDBC ситуация прямо противоположная. С точки зрения контроля над запросами всё практически идеально. Запросы в базу данных контролируете только вы, как их автор. Вы же отвечаете за их соответствие состоянию базы данных и их корректность, вплоть до синтаксиса. Такова цена полного и безусловного контроля над запросами. За соответствие требованию к контролю над запросами Spring JDBC получает 3 балла.

С точки зрения чистой архитектур, всё также отлично. Spring JDBC лишён каких бы то ни было архитектурных причуд, явно или косвенно заставляющих разработчика нарушать чистую архитектуру. За соблюдение чистой архитектуры Spring JDBC тоже получает 3 балла.

А вот удобством использования Spring JDBC похвастаться не может. Отсутствие типобезопасности, расположение запросов в строковых литералах и отсутствие проверки запросов при компиляции делает проекты с использованием фреймворка крайне сложными для изменения и рефакторинга. Итого, 0 баллов.

В сумме Spring JDBC получает 3 + 3 + 0 = 6 баллов.

jOOQ.

Являясь DSL-фреймворком, jOOQ в корне отличается от решений Spring. Последние сосредоточены на каком-то одном требовании в ущерб другим, а в реализации jOOQ можно отметить баланс между требованиями.

jOOQ отвечает за типобезопасность запросов, проверяя их при компиляции. Генерирование репозиторных сущностей позволяет избежать ошибок, допускаемых при их ручном заведении. При изменении таблиц и связей между ними даже не нужен рефакторинг — достаточно перезапустить генератор. За соответствие требованиям к удобству поддержки кода jOOQ получает 3 балла.

Контроль над запросами такой же безусловный, как и в случае со Spring JDBC. Да, запросы не пишутся напрямую, но разработчик контролирует запрос целиком и полностью. Структура запроса точно повторяет структуру запроса в SQL. Итого, 3 балла.

А вот с архитектурой не всё так хорошо. С одной стороны, бизнес- и репозиторные сущности легко разделяемы. С другой стороны, для упрощения проецирования jOOQ предлагает механизмы «сращивания» сущностей в одно целое. Итого, 1 балл.

В сумме jOOQ получает 3 + 3 + 1 = 7 баллов.

Exposed.

Подход, заложенный в Exposed, напоминает jOOQ, но отличия есть. Во-первых, отсутствует генератор. Во-вторых, Exposed написан на Kotlin, и в Java-проектах его использовать не получится.

Как и jOOQ, Exposed типобезопасен. Проверяет запросы при компиляции. Да, репозиторные сущности придётся заводить вручную, но это также даёт больше контроля при рефакторинге. Изменение и рефакторинг удобны, как и для остальной кодовой базы. Мы можем расширить функциональность при помощи добавления любых команд и операторов, специфичных для диалекта. Единственная проблема — Exposed недоступен для проектов на Java, что сказывается на удобстве использования. Итого, 1 балл.

Контроль над запросами такой же полный, как и в jOOQ. 3 балла, соответственно.

Что же касается архитектуры, то, в отличие от jOOQ, Exposed исключает лазейки в доменную область. 3 балла.

В сумме Exposed получает 3 + 1 + 3 = 7 баллов.

Итого:

  • Spring Data JPA: 4 балла

  • Spring JDBC: 6 баллов

  • jOOQ: 7 баллов

  • Exposed: 7 баллов

Счёт, как говорится, на табло.

Счёт на табло.
Счёт на табло.

Заключение.

Большинство разработчиков начинает ознакомление с репозиторными фреймворками с решений Spring. Чаще это Spring Data JPA, реже — Spring JDBC. Spring Data JPA очень хорошо подходит для быстрого запуска небольшого проекта и очень удобен для обучения новичков. Spring JDBC даёт больше контроля там, где это необходимо. Оба решения так хороши, что за всю карьеру Java-разработчика можно так и не познакомиться с другими решениями.

Тем не менее, они есть. И в некоторых аспектах превосходят решения Spring. Надо пробовать, коллеги! И тогда наш код будет более быстрым, оптимальным и качественным, чем когда бы то ни было!

Если некогда читать...

...есть полная запись доклада по теме, который прошёл на конференции Spring NOW 2025 6 марта 2025 года. Доклад полностью раскрывает тему — пусть и в немного сокращённом виде, зато с live-кодингом.

Запись доклада также есть на других площадках.

На YouTube.

На RuTube.

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


  1. apcs660
    21.05.2025 06:43

    Больше 10 лет назад, в одном банке на проекте, на коленке за пару дней был собран SQL парсер для FileNET, yже не припомню деталей, чем то похожим на JavaCC.

    Похожий пример грамматики SQL

    Использовалось для проверки и модификации запросов в репозиторий документов сторонними сервисами. Репозиторий увеличивался на пару миллиардов документов в год.

    С JPA тоже наигрались тогда, закончилось рукописными запросами.


  1. Lewigh
    21.05.2025 06:43

    В какой-то момент вам могло показаться, что соблюдение в достаточной мере всех трёх вышеперечисленных требований недостижимо и придётся чем-то пожертвовать.

    А вот самое интересное не рассмотрели. Может быть конечно что-то изменилось, но как помню, самой большой проблемой JOOQ было отсутствие нормального маппинга результатов запроса. Если результаты плоские или не сильно сложные то нет проблем, если же у нас что-то чуть сложнее, то JOOQ превращается в тыкву.
    Вот сейчас посмотрел у них на сайте, а воз и ныне там:


    1. panzerfaust
      21.05.2025 06:43

      Не очень понятно, что имеется в виду под более сложными структурами. Типа в поле лежал JSONB и его хочется сразу в разухабистый объект десериализовать? Ну да, так сделать нельзя, но я б не назвал это "большой проблемой". Всегда можно перемапить рекорд джука в другой класс, String распарсить как JSON и перемапить в объект. Если, конечно, не стоит вопрос дикого перфоманса и экономии памяти.


      1. Lewigh
        21.05.2025 06:43

        Не очень понятно, что имеется в виду под более сложными структурами.

        Множественные вложения в отношении many-to-one|one-to-many|many-to-many

        Ну да, так сделать нельзя, но я б не назвал это "большой проблемой". Всегда можно перемапить рекорд джука в другой класс

        А можно просто cразу взять MyBatis. Да нет типизации запросов, зато можно написать запрос любой сложности и получить автоматический маппинг с коробки и не городить городьбу там где JOOQ превращается в тыкву.

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


    1. apcs660
      21.05.2025 06:43

      У меня был подобный in house проект, язык запросов для поиска, там пришлось указывать source для полей, для маппинга. По аналогии с алиасами таблиц в sql.

      Поиск был в гетерогенных репозиториях, черт ногу сломит, и rdbms и nosql, и иерархические агрегаторы с merge типа union в sql. Иначе пришлось бы всю кухню выносить на бизнес уровень.


  1. ris58h
    21.05.2025 06:43

    Тема связанных сущностей не раскрыта. Как, например, выглядит запрос на получение заказа (по id) и всех его блюд? В Spring Data JPA это весьма просто.

    Кстати, так и не понял зачем right join в запросе из примера:

    SELECT restaurants.id, restaurants.name 
    FROM restaurants
        RIGHT JOIN dishes ON restaurants.id = dishes.restaurant_id
        RIGHT JOIN orders ON dishes.id = orders.dish_id
    WHERE orders.user_id = :userId


  1. Filex
    21.05.2025 06:43

    Еще есть Jimmer: https://github.com/babyfish-ct/jimmer
    Jimmer стремится заполнить нишу между Hibernate и jOOQ, сочетая:
    • Легкость запросов реляционных данных (как в Hibernate)
    • Типобезопасность и прозрачность (как в jOOQ)
    • Поддержку для DTO с вложенными связями


  1. MadMaxLab
    21.05.2025 06:43

    Использовали Jooq в проде в 2019 - 2020 годах. Все было хорошо пока не уперлись в специфичный для БД синтаксиc, который не переводился на DSL ферймворка. Например работа с json в Postgresql.

    В итоге переехали тогда на SpringJPA где спец. запросы задавали через Query аннотацию.

    Чем старше становлюсь, тем больше убеждаюсь, что лучший вариант это Spring JDBC + тесты с поднятием контекста и БД в testcontainers самый надежный вариант. Если лень писать тесты, то можно попросить LLM :)


  1. dkfbm
    21.05.2025 06:43

    Вот честно, не понимаю я смысла в ОРМ. В частности, в примерах автора SQL читается намного лучше, чем эквивалентный ему код – при том, что запросы все очень простые. Как уложить в ОРМ по-настоящему сложный запрос и не сойти с ума (особенно на этапе поддержки, когда понадобится его чуток поменять) – для меня загадка. А полный перевод сколько-нибудь сложного проекта на другую базу нужен примерно никогда. Оно точно того сто̀ит?


    1. izibrizi2
      21.05.2025 06:43

      Ну не все пишут сложные проекты. Да и что значит "сложный проект" - как раз таки на сложных проектах где сотни таблицы ОРМ помогает управлять этим зоопарком. Просто не нужно упираться в ОРМ как единственный источник обращения к базе, никто не запрещает комбинировать инструменты.


    1. ris58h
      21.05.2025 06:43

      смысла в ОРМ

      Он понятен из названия - отображение реляционных данных из БД в программные сущности (и обратно).

      SQL читается намного лучше, чем эквивалентный ему код

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

      Как уложить в ОРМ по-настоящему сложный запрос и не сойти с ума

      Да как угодно: c помощью типизированных запросов, с помощью запросов на SQL-like языке (JPQL/HQL), да хоть с помощью сырого SQL.