Pull to refresh

JOOQ и его кроличья нора. Как выжить без Hibernate

Reading time13 min
Views57K
В этой статье я не буду топить за JOOQ. Я предпочитаю Hibernate и всю силу Spring Data JPA, которая за ним стоит. Но статья будет не о них.



Когда мы пользуемся Hibernate и Spring Data JPA, нам не нужно задумываться о внутренних процессах — знай ставь аннотации и пиши правильные имена методов в репозитории — остальное за тебя сделают эти два монстра. В случае в JOOQ, к сожалению для многих, придётся малость напрячься и написать больше, чем findAllByUserId(Long userId).

Что такое JOOQ?


Напишем простой SQL-запрос:

select * from countries c where c.population > 10000000;

Это запрос мы можем выполнить в консоли. Окей.

Нам не хочется в консоли. Нам хочется, чтобы этот запрос отправляло наше приложение. Чтобы это был не одноразовый скрипт и чтобы он валидировался хотя бы на синтаксис. Делаем это через Spring Data JPA:

List<Country> findAllByPopulationAfter(Long amount);

Выполняется тот же самый запрос, что и выше, но силами Spring.

Какие явные плюсы такого подхода? Его выполняет мощный фреймворк, он же валидирует запрос на наличие ошибок. Но и управление запросами берёт на себя фреймворк. А нам хочется полностью управлять запросом, но в то же время, чтобы запрос был полностью отвалидирован.
Используем Query:

@Query("select c from Country c where c.population > :amount")
List<Country> findAllPopulationGreaterThan(@Param("amount") Long amount);

Годится и такой компромисс между SQL и DSL. Но если мы не хотим мараться об SQL, нам будет приятно видеть что-то вроде:

return dsl.selectFrom(COUNTRIES)
                .where(COUNTRIES.POPULATION.greaterThan(amount))
                .fetch();

Для этого подойдут несколько библиотек:

QueryDSL
JOOQ
Speedment


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

Запросы JOOQ


Да, один из таких запросов мы уже видели выше:

return dsl.selectFrom(COUNTRIES)
                .where(COUNTRIES.POPULATION.greaterThan(amount))
                .fetch();

Какие бывают ещё?

Например, простой get-запрос:

return dsl.selectFrom(Countries.COUNTRIES)
                .where(Countries.COUNTRIES.ID.eq(id))
                .fetchAny();

Или insert-запрос:

return dsl.insertInto(Countries.COUNTRIES)
                .set(Countries.COUNTRIES.NAME, country.getName())
                .set(Countries.COUNTRIES.POPULATION, country.getPopulation())
                .set(Countries.COUNTRIES.GOVERNMENT_FORM, nameOrNull(country.getGovernmentForm()))
                .returning()
                .fetchOne();

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

Но начнём с начала.

Да, это будет Maven


Я предпочитаю Gradle. Но разработчики JOOQ почему-то уделяют Gradle меньше внимания, предлагая идти за плагинами к сторонним разработчикам. Окей, учебный проект будет на Maven. Но если ты, уважаемый читатель, покопался как следует в глубинах гитхаба и готов явить полностью настроенный проект на Gradle — пиши мне, и ты станешь соавтором этой статьи. Это же касается и H2 (учебный проект будет на PostgreSQL).

Почему у меня не получилось с Gradle и H2
Я полдня потратил на то, чтобы завести генератор сущностей через Gradle. И после очередной неудачной попытки я решил оставить лавры укротителя jooq-generator потомкам. Вторые полдня я пытался сгенерировать сущности с базы на H2, но «маленькая победоносная война» со схемами H2 всё больше виделась не такой уж маленькой и победоносной, поэтому эти лавры, дорогой читатель, так же могут стать твоими.

Необходимые зависимости Maven:

<dependencies>

        <!-- Spring Starters -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jooq</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Database -->
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Helpers -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>${commons.lang3.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!--Test -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <!-- JOOQ Generator -->
        <dependency>
            <groupId>org.jooq</groupId>
            <artifactId>jooq</artifactId>
            <version>${jooq.version}</version>
        </dependency>
        <dependency>
            <groupId>org.jooq</groupId>
            <artifactId>jooq-meta</artifactId>
            <version>${jooq.version}</version>
        </dependency>
        <dependency>
            <groupId>org.jooq</groupId>
            <artifactId>jooq-codegen</artifactId>
            <version>${jooq.version}</version>
        </dependency>

    </dependencies>

У Spring уже есть JOOQ стартер, который самонастраивает DataSource в соответствии с указанными настройками из application.yml. Но для полноценной работы JOOQ этого мало. Необходимо сгенерировать сущности. Да-да, мы не пишем сущности, а генерируем их. Так называемый подход table-first.

Обычная структура приложения в фокусе на работе с базой данных выглядит так:



До сервисного слоя данные путешествуют по приложению в виде плоской модели (DTO). Далее, когда сервису надо поработать с базой, он преобразует DTO в сущность, отправляет в репозиторий, а тот уже сохраняет её в базу. Разработчики JOOQ видят всё иначе:



Как мы видим, в JOOQ всерьёз пересмотрели стандартный паттерн и решили идти своим путём. Отличия существенны:

  1. Преобразование DTO в сущность происходит уже в репозитории.
  2. Сущность как таковую мы не пишем. Её пишет за нас генератор JOOQ.

Уже этого достаточно, чтобы сделать выводы, что JOOQ не так прост. Сложно настраиваемая генерируемая сущность, непонятный маппинг в DTO и прочие сложности отпугнут многих. Но в тех случаях, когда деваться некуда, направленное изучение этих аспектов JOOQ может серьёзно увлечь и занять дни, а то и недели. Уверен, мой пост существенно сэкономит Ваше время.

Мы затронем следующие аспекты работы с JOOQ:

  • Генерация сущностей.
  • Написание запросов CRUD.
  • Оптимизация запросов CRUD.
  • И ещё одна оптимизация запросов CRUD.
  • Мапинг сущностей силами стандартной библиотеки JOOQ.
  • и порассуждаем о том, зачем всё это надо.

Ну, поехали.

Что мы напишем?


Наш проект будет иметь две сущности:

Country:

@Data
public class Country {

    private Long id;
    private String name;
    private GovernmentForm governmentForm;
    private Integer population;

    private List<City> cities;
}

City:

@Data
public class City {

    private Long id;
    private Long countryId;
    private String name;
}

Связь один-ко-многим. Country содержит множество связанных City. City содержит countryId.
Flyway-миграция выглядит так:

create table countries
(
    id              bigserial primary key,
    name            varchar(255),
    government_form varchar(255),
    population      int
);

create table cities
(
    id         bigserial primary key,
    country_id bigint,
    name       varchar(255)
);

Начнём с генерации сущностей


Сущности, как я уже сказал, генерируются отдельно. Есть два способа это сделать.

  • Посредством плагина Maven.
  • В Java-коде.

Генерируем сущности в Maven


Когда происходит сборка проекта, Maven запускает генератор и генерирует сущности. А можно вызвать генератор в любой удобный момент и сгенерировать сущности. Это требуется в те моменты, когда, допустим, поменялась структура базы. Плагин в Maven выглядит так:

            <!-- JOOQ Generator Plugin -->
            <plugin>
                <groupId>org.jooq</groupId>
                <artifactId>jooq-codegen-maven</artifactId>
                <version>${jooq.version}</version>
                <executions>
                    <execution>
                        <phase>generate-sources</phase>
                        <goals>
                            <goal>generate</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <jdbc>  <!-- указываем настройки базы -->
                        <driver>${db.driver}</driver>
                        <url>${db.url}</url>
                        <user>${db.username}</user>
                        <password>${db.password}</password>
                    </jdbc>
                    <generator>
                        <database>
                            <includes>.*</includes>  <!-- включаемые подпакеты и файлы -->
                            <excludes>  <!-- исключаемые подпакеты и файлы -->
                                flyway_schema_history
                            </excludes>
                            <inputSchema>public</inputSchema>  <!-- схема -->
                        </database>
                        <generate>
                            <records>true</records>
                        </generate>
                        <target>
                            <!-- структура пакетов относительно основной директории -->
                            <packageName>ru.xpendence.jooqexample.domain</packageName>
                            <!-- основная директория. Лучше хранить в target. -->
                            <directory>target/generated-sources/jooq</directory>
                        </target>
                    </generator>
                </configuration>
            </plugin>

Если вы всё сделали правильно, обновите Maven и среди плагинов увидите jooq-codegen.



Запустите его. Он сгенерирует вам сущности.



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

Сущности выглядят своеобразно. Вот, например, сигнатура класса CountriesRecord:

public class CountriesRecord extends UpdatableRecordImpl<CountriesRecord> implements Record4<Long, String, String, Integer> {
    //...

Как мы видим, CountriesRecord имплементит Record4, который типизируется 4-мя типами. В таблице countries 4 колонки, потому и Record4. Вот в cities 3 колонки, поэтому Record3. Зачем это было придумано, я не знаю. Всего в библиотеки JOOQ 22 таких рекорда, Record1...Record22. Из чего можно сделать вывод, что возможности JOOQ ограничены обработкой таблиц с максимумом в 22 колонки, но это не так. У меня есть таблицы на несколько десятков колонок, и JOOQ просто сразу имплементит от Record. Ну, такое…

Генерация сущностей JOOQ в Java-коде выглядит следующим образом:

@Component
public class AfterStartupApplicationListener implements ApplicationListener<ContextRefreshedEvent> {

    @Value("${spring.datasource.driver-class-name}")
    private String driver;

    @Value("${spring.datasource.url}")
    private String url;

    @Value("${spring.datasource.username}")
    private String username;

    @Value("${spring.datasource.password}")
    private String password;

    @Value("${jooq.generator.database.name}")
    private String databaseName;

    @Value("${jooq.generator.database.with-includes}")
    private String databaseWithIncludes;

    @Value("${jooq.generator.database.with-input-schema}")
    private String databaseWithInputSchema;

    @Value("${jooq.generator.target.package-name}")
    private String targetPackageName;

    @Value("${jooq.generator.target.directory}")
    private String targetDirectory;

    @SneakyThrows
    @Override
    public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
        new GenerationTool().run(configureGenerator());
    }

    private Configuration configureGenerator() {
        return new Configuration()
                .withJdbc(new Jdbc()
                        .withDriver(driver)
                        .withUrl(url)
                        .withUser(username)
                        .withPassword(password))
                .withGenerator(new Generator()
                        .withDatabase(new Database()
                                .withName(databaseName)
                                .withIncludes(databaseWithIncludes)
                                .withExcludes("")
                                .withInputSchema(databaseWithInputSchema))
                        .withTarget(new Target()
                                .withPackageName(targetPackageName)
                                .withDirectory(targetDirectory)));
    }
}

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

Итак, мы имеем модели, имеем сущности, имеем базу. Пора создавать репозиторий и писать запросы.

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


Все запросы ходят через DslContext. Заавтовайрим DslContext.

@Repository
@RequiredArgsConstructor
public class CountryRepository implements CrudRepository<Country> {

    private final DSLContext dsl;

} 

Репозиторий готов.

Написание запросов CRUD


Insert


Если бы мы пользовались SQL, запрос на добавление ещё одной страны был бы таким:

insert into countries(name, government_form, population)
values ('Габон какой-нибудь', 'UNITARY', 100500) returning *;

В JOOQ запрос максимально приближен к синтаксису SQL:

    public Country insertValues(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)  //insert into countries
                .values(country.getId(), country.getName(), country.getPopulation(), nameOrNull(country.getGovernmentForm()))  //values (? ? ? ?)
                .returning()  //returning
                .fetchOne()  //*
                .into(Country.class);
    }

Как мы видим, ничего сложного. Впрочем, такой запрос нам не подходит, потому что в нашем случае ID генерируется в базе и мы должны это учесть. Если же мы напишем что-то вроде:

.values(null, country.getName(), country.getPopulation(), nameOrNull(country.getGovernmentForm()))

мы получим

org.postgresql.util.PSQLException: ОШИБКА: нулевое значение в столбце "id" нарушает ограничение NOT NULL

Впрочем, если бы мы генерировали ID на стороне приложения, такой запрос бы прокатил. Нам придётся переписать запрос, задав каждому полю конкретное значение. Так тоже можно:

    public Country insert(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)
                .set(Countries.COUNTRIES.NAME, country.getName())
                .set(Countries.COUNTRIES.POPULATION, country.getPopulation())
                .set(Countries.COUNTRIES.GOVERNMENT_FORM, nameOrNull(country.getGovernmentForm()))
                .returning()
                .fetchOne()
                .into(Country.class);
    }

В данном случае, мы вручную сетим объект в поле. Этот вариант вполне рабочий, но есть и получше. Было бы идеально отправлять на сохранение весь объект, как это делается в Spring Data JPA:

repository.save(country);

Можно и так. Для этого нам нужно смапить нашу модель в сущность extends Record и отправить на сохранение:

    public Country insert(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)
                .set(dsl.newRecord(Countries.COUNTRIES, country))  //мапим модель в сущность
                .returning()
                .fetchOptional()
                .orElseThrow(() -> new DataAccessException("Error inserting entity: " + country.getId()))
                .into(Country.class);
    }

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

Как мы заметили, данный запрос возвращает сущность целиком. Вы можете определить, что именно Вам нужно вернуть в методе fetch(). Выглядит это так:

    public Long insertAndReturnId(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)
                .set(dsl.newRecord(Countries.COUNTRIES, country))
                .returning(Countries.COUNTRIES.ID) //в возвращаемом Record только одно значение - id
                .fetchOptional()
                .orElseThrow(() -> new DataAccessException("Error inserting entity: " + country.getId()))
                .get(Countries.COUNTRIES.ID); //достаём id
    }

Немного громоздко, но, опять же, с чем сравнивать.

Напишем остальные методы CRUD.

Update


SQL-запрос:

update countries
set name            = 'Эритрея',
    government_form = 'CONFEDERATE',
    population      = 100500
where id = 1
returning *;

Запрос JOOQ:

    public Country update(Country country) {
        return dsl.update(Countries.COUNTRIES)
                .set(dsl.newRecord(Countries.COUNTRIES, country))
                .where(Countries.COUNTRIES.ID.eq(country.getId()))
                .returning()
                .fetchOptional()
                .orElseThrow(() -> new DataAccessException("Error updating entity: " + country.getId()))
                .into(Country.class);
    }

Select


Запрос в SQL был бы таким:

select *
from countries c
where id = ?;

В JOOQ запрос будет выглядеть соответственно:

    public Country find(Long id) {
        return dsl.selectFrom(Countries.COUNTRIES) //select * from countries
                .where(Countries.COUNTRIES.ID.eq(id))  //where id = ?
                .fetchAny()  //здесь определяем, что мы хотим вернуть
                .into(Country.class);
    }

Delete


Есть запросы, в которых нам нечего возвращать. Такие запросы возвращают количество затронутых строк. В delete нам нечего возвращать, но информация, которую мы получим, всё равно будет нам полезна.

    public Boolean delete(Long id) {
        return dsl.deleteFrom(Countries.COUNTRIES)
                .where(Countries.COUNTRIES.ID.eq(id))
                .execute() == 1;
    }

Мы удаляем одну строку. Значит, SQL запрос вернёт нам что-то вроде:

1 row affected in 5 ms

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

Это была не сказочка. Это была только смазочка. Использование штатного мапера для тонкого мапинга сущности в Record и обратно


«Окей», — скажешь ты, читатель, «а где один-ко-многим, ёмаё? Что-то я не увидел момента, в котором Country прирастает множеством City.»

И будешь прав. Это не Hibernate, здесь придётся вручную. Всего-то надо получить список City, для которых id = country.getId(). Я уже показывал метод into(), который использует штатный маппер для того, чтобы смапить Record в сущность. Но в JOOQ есть дополнительный метод map(), который позволяет делать с Record всё, что мы захотим. Посмотрим на его функциональность и допишем добавление городов:

    public Country find(Long id) {
        return dsl.selectFrom(Countries.COUNTRIES)
                .where(Countries.COUNTRIES.ID.eq(id))
                .fetchAny()
                .map(r -> {
                    Country country = r.into(Country.class);
                    country.setCities(cityRepository.findAll(Cities.CITIES.COUNTRY_ID.eq(country.getId())));
                    return country;
                });
    }

Как мы видим, теперь сначала мы мапим Record в Country, а потом делаем ещё один запрос в cities, получаем все City для данной Country и сетим их в сущность.

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

@RequiredArgsConstructor
@Component
public class CountryRecordMapper implements RecordMapper<CountriesRecord, Country> {

    private final CityRepository cityRepository;

    @Override
    public Country map(CountriesRecord record) {
        Country country = record.into(Country.class);
        country.setCities(cityRepository.findAll(Cities.CITIES.COUNTRY_ID.eq(country.getId())));
        return country;
    }
}

Запрос теперь будет выглядеть так:

    public Country findWithCustomMapper(Long id) {
        return dsl.selectFrom(Countries.COUNTRIES)
                .where(Countries.COUNTRIES.ID.eq(id))
                .fetchAny()
                .map(r -> countryRecordMapper.map((CountriesRecord) r));
    }

Он лаконичнее, и не содержит в себе дополнительной логики.

Окей, мы научились мапить Record в сущность, но как быть с тонкой настройкой мапинга сущности в Record?


Пока у нас есть такая конструкция:

.set(dsl.newRecord(Countries.COUNTRIES, country))

Уже хорошо, но что делать, если нам нужно специфично замапить какое-нибудь поле? Например, у нас там LocalDateTime, а генератор для PostgreSQL по типу timestamp сгенерировал OffsetDateTime. В таком случае, поле просто не смапится и не запишется в базу. Для таких случаев нам потребуется уже другой мапер, который будет делать то же самое, но в обратную сторону.

Да, на каждый mapper у нас найдётся unmapper


Он так и называется. Напишем его наследника.

@Component
@RequiredArgsConstructor
public class CountryRecordUnmapper implements RecordUnmapper<Country, CountriesRecord> {

    private final DSLContext dsl;

    @Override
    public CountriesRecord unmap(Country country) throws MappingException {
        CountriesRecord record = dsl.newRecord(Countries.COUNTRIES, country);
        record.setPopulation(-1);
        return record;
    }
}

Insert с его применением будет выглядеть так:

    public Country insertWithUnmapper(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)
                .set(countryRecordUnmapper.unmap(country))
                .returning()
                .fetchOptional()
                .orElseThrow(() -> new DataAccessException("Error inserting entity: " + country.getId()))
                .into(Country.class);
    }

Как мы видим, тоже вполне.

Выводы и заключения


Лично мне больше нравится Hibernate. Наверное, для 90+% приложений его применение будет более оправдано. Но если ты, читатель, хочешь контролировать каждый запрос, тебе больше подойдёт JOOQ или другая похожая библиотека.

Как всегда, выкладываю учебный проект. Он лежит тут.
Tags:
Hubs:
Total votes 14: ↑3 and ↓11-8
Comments25

Articles