Привет, Хабр. Меня зовут Михаил Тимошкин. Я тимлид в команде Тематических каналов в Дзене. 

Одна из классических головных болей при создании сервиса — синхронизация кода приложения со схемой БД. Есть много инструментов для управления изменениями схемы базы данных, таких как Liquibase или Flyway, для которых существуют плагины с кодогенерацией. Но что, если на проекте ничего подобного нет? Хорошая новость в том, что даже в такой ситуации можно найти эффективное решение! В этом мы убедились на собственном опыте при переходе с Hibernate на jOOQ.

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

Сразу к сути задачи

На текущем проекте использовался Hibernate вместе с Spring Data JPA - популярная связка для работы с базой данных. Они позволяли не писать много громоздкого SQL-кода для сохранения, загрузки, обновления или удаления данных (CRUD) — достаточно просто объявить интерфейс с необходимым названием методов, после чего фреймворк сам сгенерирует нужный SQL «под капотом».

Но несмотря на популярность и функциональность, у этой связки есть ряд ограничений:

  • автоматическая генерация SQL-запросов часто приводит к неоптимальным конструкциям;

  • есть сложности с поддержкой сложных Postgres-фич (materialized views, window functions);

  • для правильной настройки нужна большая экспертиза в JPA и Hibernate, что сложнее, чем написание SQL-запросов.

Более того, с ростом нагрузки мы стали фиксировать деградацию сервиса и БД. После ряда тестов оказалось, что переписывание нагруженных методов на jOOQ позволяет поднять производительность. Продолжение тестов подтвердило эту гипотезу. Поэтому в результате мы решили модернизировать архитектуру приложения и заменить Hibernate & Spring Data JPA на jOOQ для более прямого контроля над SQL-запросами и оптимизации работы с Postgres.

Сложности и методы их преодоления

Уже на первых этапах реализации перехода мы обнаружили проблему — в проекте не было централизованной системы управления схемой БД. Это создавало два вызова:

  • было невозможно точно определить текущую структуру БД, поскольку отсутствовал единый источник истины;

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

Примечание: Прошлая команда вела лог скриптов, которые применяла к БД на проде, что несколько компенсировало отсутствие инструмента миграции.

Исходя из этого, нам предстояло найти подход, который позволит заменить Hibernate на jOOQ без централизованной системы управления схемой БД. Для этого мы начали рассматривать различные варианты.

Подключение к БД и получение метаданных

По умолчанию jOOQ предлагает подключаться к БД напрямую, чтобы скачать оттуда метаданные о таблицах и другую информацию. 

Нюанс в том, что для генерирования кода, который потом может быть использован в разных модулях (например, как те же proto или openapi), придется каждый раз подключаться к БД. Это делает компиляцию и билд проекта, а тем более тесты, нестабильными. Более того, могут быть БД, доступа к которым нет. Поэтому этот подход нам не подошел.

Генерация по SQL-скриптам (H2)

jOOQ поддерживает генерацию кода через DDL-скрипты. Это реализуется с помощью поднятия H2 Database и накатывания в нее скриптов. Но этот подход нам также не подошел — H2 не поддерживает ряд Postgres-фич, которые мы используем (например, не работает MATERIALIZED VIEW).

Добавление инструмента миграции

Помимо прочего, мы могли добавить инструмент миграции, например, Liquibase, и использовать кодогенерацию по change sets с помощью LiquibaseDatabase, который позволяет симулировать миграции в памяти. Но и здесь из-за специфичных Postgres-фич мы столкнулись с барьером — в документации jOOQ в блоке «Vendor specific features» явно пишут:

The LiquibaseDatabase simulates your migration in memory and may thus not support all vendor specific features of your target dialect. If you run into such limitations, know that it is just a “quick-and-dirty” approach to such a simulation. Running an actual migration on your actual target RDBMS (e.g. using testcontainers) will be more reliable.

То есть, Liquibase использует «легковесную» имитацию БД в памяти (не реальный движок СУБД), поэтому эмулятор не поддерживает специфичные функции реальной БД (например, особые типы данных, хранимые процедуры, нюансы SQL-диалекта). Таким образом, миграция, которая успешно проходит на реальной БД, может сломать кодогенерацию. Это нас также не устраивало, поэтому от варианта отказались.

Testcontainers + SQL-скрипты: выбранное решение

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

Этот подход во многом похож на testcontainers-jooq-codegen-maven-plugin, но на нашем проекте используется Gradle, для которого подобного плагина нет, поэтому мы написали таску generateJooq, которая состоит из нескольких шагов:

  • поднятие контейнера Postgres той же версии, что на проде;

  • выполнение скриптов из истории миграции src/main/resources/db (по сути, те же sql-скрипты, как если бы был инструмент миграции);

  • генерация кода jOOQ Codegen на основе метаданных контейнера.

Получили следующую конфигурацию
def generateJooqOutputDir = "src/main/jooq"
sourceSets.main.java.srcDirs += [generateJooqOutputDir]
tasks.register('generateJooq') {

    dependencies {
        implementation("org.postgresql:postgresql:$psqlJdbcVersion")
        implementation("org.testcontainers:postgresql:$testcontainersVersion")
        implementation("com.zaxxer:HikariCP:$hikariVersion")
    }

    doLast {

        // Создаем контейнер с PostgreSQL
        def postgres = new PostgreSQLContainer("postgres:15")
                .withDatabaseName("my_db")
                .withUsername("my_user")
                .withPassword("build")

        postgres.start()
        def jdbcUrl = postgres.getJdbcUrl()
        def username = postgres.getUsername()
        def paswd = postgres.getPassword()
        println "Started Postgres TestContainer with user=$username, password=$paswd, jsbcUrl=$jdbcUrl"

        try {
            // Получаем соединение с базой данных
            // Применяем миграции
            def config = new HikariConfig()
            config.setJdbcUrl(jdbcUrl)
            config.setUsername(username)
            config.setPassword(paswd)

            def dataSource = new HikariDataSource(config)
            // Получаем соединение из пула
            Connection connection = dataSource.getConnection()

            def sqlDir = file('src/main/resources/db')
            sqlDir.listFiles()
                    .sort { a,b -> a.name <=> b.name}
                    .each { file ->
                        if (file.name.endsWith('.sql')) {
                            println "Executing script: ${file.name}"
                            def sql = new String(Files.readAllBytes(file.toPath()))
                            connection.createStatement().execute(sql)
                        }
                    }

            connection.close()
            // Конфигурируем jOOQ для использования временной базы данных
            jooq {
                configuration {
                    jdbc {
                        driver = 'org.postgresql.Driver'
                        url = jdbcUrl
                        user = username
                        password = paswd
                    }
                    generator {
                        name = 'org.jooq.codegen.DefaultGenerator'
                        strategy {
                            name = 'org.jooq.codegen.DefaultGeneratorStrategy'
                        }
                        database {
                            name = 'org.jooq.meta.postgres.PostgresDatabase'
                            inputSchema = 'public'
                        }
                        generate {
                            defaultCatalog = false
                            defaultSchema = false
                            generatedAnnotation = true
                            generatedAnnotationType = "DETECT_FROM_JDK"
                            generatedAnnotationDate = true
                            generatedAnnotationJooqVersion = true
                            nullableAnnotation = true
                            nullableAnnotationOnWriteOnlyNullableTypes = true
                            nullableAnnotationType = "javax.annotation.Nullable"
                            nonnullAnnotation = true
                            nonnullAnnotationType = "javax.annotation.Nonnull"
                            generatedSerialVersionUID = "HASH"
                            globalObjectReferences = false
                            javaTimeTypes = false
                        }
                        target {
                            packageName = 'ru.dzen.topic.channel.db.jooq.gen'
                            directory = generateJooqOutputDir
                        }
                    }
                }
            }

            println "Starting jOOQ code generation"
            // Генерируем код с использованием jOOQ
            jooqCodegen.execute()
            println "jOOQ code generated"
        } finally {
            // Останавливаем контейнер после генерации
            postgres.stop()
        }
    }
}

Поскольку БД мы обновляем редко и запускать таску нужно нечасто, мы решили хранить сгенерированный с помощью jOOQ код вместе с обычным — в Git. Но для обособленности положили его не в стандартный src/main/java, а в src/main/jooq, чтобы новые разработчики, которые придут на проект, точно заметили разницу.

Особенности выбранного подхода

У реализованного нами гибридного подхода, безусловно, есть недостатки. Например:

  • Разработчик должен вручную запускать таску после каждого изменения. Но это не выглядит критичным.

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

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

Но преимуществ у реализации больше:

  • генерация происходит по той же структуре, что и на проде;

  • контейнер не зависит от тестовой/продовой инфраструктуры;

  • сгенерированный код фиксируется в Git, поэтому не требуется запускать generateJooq при каждом билде;

  • в контейнере поднимается именно та БД, которая будет на проде, что позволяет применить все фичи БД.

Именно поэтому мы приняли гибридный подход в качестве целевой реализации.

Краткие выводы

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

  • возможность поддержания актуальности схемы без прямого подключения к БД;

  • отсутствие необходимости переписывать вручную все классы @Entity и @Repository на jOOQ;

  • внедрение jOOQ с учетом Postgres-специфичных фич без дополнительных инструментов.

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

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


  1. maxzh83
    18.08.2025 12:34

    достаточно просто объявить интерфейс с необходимым названием методов, после чего Hibernate сам сгенерирует нужный SQL «под капотом»

    Hibernate сам по себе не умеет так делать. Вероятно, у вас была Spring Data JPA. Если так, то как вариант, можно было точечно переписать только тормозные запросы руками, прям на чистом SQL (в том числе с фичами pg), а не добавлять еще и JOOQ. И, таким образом, не пытаться подружить две разные концепции работы с БД.


    1. timoshkin-m Автор
      18.08.2025 12:34

      Спасибо за уточнение, поправил!


  1. AlekseyShibayev
    18.08.2025 12:34

    1. У вас был вариант писать в нужных местах native query через entity manager. Почему выбрали долгий путь и переписали весь проект на альтернативную технологию вместо этого варианта?

    2. У вас теперь нет из коробки фичей hibernate, таких как оптимистик лок, кеши, блокировки. Или есть? Не знаю как обстоят дела с транзакцией. Это всё надо теперь самим реализовывать?

    3. Уже потраченные силы и будущие затраты, плюс тот факт, что на проекте теперь не самый популярный рыночный стек. Не слишком ли много лишней работы? Поправьте если я не прав.


    1. maxzh83
      18.08.2025 12:34

      У вас был вариант писать в нужных местах native query через entity manager

      Там же Spring Data JPA, можно изящнее, просто в интерфейсе репозитория пишется метод с аннотацией Query и там native-запрос


      1. AlekseyShibayev
        18.08.2025 12:34

        Ага, можно. Проблема в том, что если у тебя сложный запрос, использующий несколько @Entity, то в чьем репозитории его хранить? Вторая проблема, если пихать "одноразовые" методы c @Query в репозиторий, то репозиторий пухнет. Если таких методов немного - @Query будет отличным вариантом.


    1. Krokochik
      18.08.2025 12:34

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