Последние год-полтора я натыкаюсь на статьи и доклады (особенно в англоязычном сегменте) о том, что JOOQ – это современная и более крутая альтернатива Hibernate. Тезисы звучат примерно так:
JOOQ позволяет вам все проверить в compile time, а Hibernate – нет!
В Hibernate генерируются странные и не всегда оптимальные запросы, а в JOOQ все прозрачно!
В Hibernate сущности мутабельные и это плохо. А JOOQ позволяет все entity объявить неизменяемыми (привет ФП)!
В JOOQ нет никакой «магии» с аннотациями!
Скажу сразу, что я считаю JOOQ отличной библиотекой (именно библиотекой, а не фреймворком, в отличие от Hibernate). Он прекрасно справляется со своей задачей – работой с SQL в режиме статической типизации, чтобы отловить большинство ошибок на этапе компиляции.
Но когда я слышу аргумент, что время Hibernate прошло и пора все писать на JOOQ, для меня это звучит примерно так же, как то, что время реляционных БД прошло и теперь нужно использовать только NoSQL. Звучит смешно? Но по меркам истории буквально вчера такие разговоры велись вполне серьезно.
Я думаю, дело кроется в непонимании корневых проблем, которые решают эти два инструмента. Этой статьей я хочу ответить на эти вопросы. Мы с вами рассмотрим:
Что такое Transaction Script?
Что такое Domain Model?
Какие именно проблемы решают Hibernate и JOOQ?
Почему один не является заменой другого и как они могут сосуществовать?
Transaction Script
Самый простой и интуитивно понятный способ работы с БД – паттерн Transaction Script. Если кратко, вы организуете всю вашу бизнес-логику в виде набора SQL-команд, которые объединяете в одну транзакцию. Чаще всего каждый метод в классе обозначает какую-то бизнес-операцию, и он же ограничен одной транзакцией.
Допустим, мы разрабатываем приложение, которое позволяет спикерам отправить свой доклад на конференцию (для простоты фиксировать будем только название доклада). Если мы следуем паттерну Transaction Script, то метод отправки доклада на рассмотрение может выглядеть так (в качестве библиотеки для SQL я использую JDBI):
@Service
@RequiredArgsConstructor
public class TalkService {
private final Jdbi jdbi;
public TalkSubmittedResult submitTalk(Long speakerId, String title) {
var talkId = jdbi.inTransaction(handle -> {
// считаем количество принятых докладов у спикера
var acceptedTalksCount =
handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'ACCEPTED'")
.bind("id", speakerId)
.mapTo(Long.class)
.one();
// Проверяем, является ли спикер опытным
var experienced = acceptedTalksCount >= 10;
// Определяем максимальное допустимое количество докладов
var maxSubmittedTalksCount = experienced ? 5 : 3;
var submittedTalksCount =
handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'SUBMITTED'")
.bind("id", speakerId)
.mapTo(Long.class)
.one();
// Если превышено максимальное число докладов на рассмотрении, кидаем исключение
if (submittedTalksCount >= maxSubmittedTalksCount) {
throw new CannotSubmitTalkException("Submitted talks count is maximum: " + maxSubmittedTalksCount);
}
return handle.createUpdate(
"""
INSERT INTO talk (speaker_id, status, title)
VALUES (:id, 'SUBMITTED', :title)
"""
).bind("id", speakerId)
.bind("title", title)
.executeAndReturnGeneratedKeys("id")
.mapTo(Long.class)
.one();
});
return new TalkSubmittedResult(talkId);
}
}
Здесь происходит следующее:
Считаем, сколько спикер уже подал докладов.
Определяем, не превышено ли максимальное допустимое количество докладов на рассмотрении.
Если все ок, создаем новый доклад в статусе
SUBMITTED
.
Здесь возможна гонка данных, но для простоты повествования мы не будем заострять внимание на реализации pessimistic или optimistic locking.
Плюсы у такого подхода следующие:
SQL, который выполнится, максимально понятен и предсказуем. Его легко подтюнить, чтобы улучшить performance при необходимости.
Мы можем вытаскивать из БД лишь нужные данные.
Благодаря JOOQ, этот код можно написать проще, короче, да еще и со статической типизацией!
Минусы же такие:
Невозможно проверить бизнес-логику с помощью unit-тестов. Обязательно нужны интеграционные (и довольно много).
Если домен сложный, подобный подход быстро приведет к спагетти-коду.
Риск дублирования кода, который может привести к неожиданным багам при дальнейшей эволюции системы.
Такой подход валиден и логичен, если в вашем сервисе очень простая логика, которая также в будущем не предполагает усложнения. Но часто домены бывают крупнее. Поэтому нам нужна альтернатива.
Доменная модель
Идея паттерна Domain Model состоит в том, что мы больше не завязываем нашу бизнес-логику напрямую на SQL-команды. Вместо этого, мы создаем доменные объекты (в контексте Java, классы), которые описывают поведение и хранят данные о доменных сущностях.
В этой статье мы не будем говорить о разнице анемичной и богатой моделях. Если интересно, я писал об этом отдельную большую статью.
Бизнес-сценарии (сервисы) должны использовать только эти объекты и не завязываться на конкретные запросы в БД.
Ясное дело, что в реальности у нас может быть микс из взаимодействия с доменными объектами и прямыми запросами в БД с целью соблюдения требований по performance. Здесь мы говорим о хрестоматийном подходе при внедрении Domain Model, когда инкапсуляция и изоляция не нарушаются.
Например, если речь идет о сущностях Speaker
и Talk
, как было ранее, доменные объекты могут выглядеть так:
@AllArgsConstructor
public class Speaker {
private Long id;
private String firstName;
private String lastName;
private List<Talk> talks;
public Talk submitTalk(String title) {
boolean experienced = countTalksByStatus(Status.ACCEPTED) >= 10;
int maxSubmittedTalksCount = experienced ? 3 : 5;
if (countTalksByStatus(Status.SUBMITTED) >= maxSubmittedTalksCount) {
throw new CannotSubmitTalkException(
"Submitted talks count is maximum: " + maxSubmittedTalksCount);
}
Talk talk = Talk.newTalk(this, Status.SUBMITTED, title);
talks.add(talk);
return talk;
}
public void acceptTalk(int talkNumber) {
Talk talk = talkByNumber(talkNumber);
talk.setStatus(status -> {
if (!status.equals(Status.SUBMITTED)) {
throw new CannotAcceptTalkException("");
}
return Status.ACCEPTED;
});
}
public void rejectTalk(int talkNumber) {
Talk talk = talkByNumber(talkNumber);
talk.setStatus(status -> {
if (!status.equals(Status.SUBMITTED)) {
throw new CannotRejectTalkException("");
}
return Status.REJECTED;
});
}
private Talk talkByNumber(int number) {
return talks.stream().filter(t -> Objects.equals(t.getNumber(), number)).findFirst()
.orElseThrow();
}
private long countTalksByStatus(Talk.Status status) {
return talks.stream().filter(t -> t.getStatus().equals(status)).count();
}
}
@AllArgsConstructor
public class Talk {
private Long id;
private Speaker speaker;
private Status status;
private String title;
private int talkNumber;
void setStatus(Function<Status, Status> fnStatus) {
this.status = fnStatus.apply(this.status);
}
public enum Status {
SUBMITTED, ACCEPTED, REJECTED
}
}
Чтобы откуда-то получать и куда-то сохранять доменные объекты, используется репозиторий. Это интерфейс, который позволяет получить агрегат, и сохранить его после выполнения над ним каких-то действий. При этом сама реализация репозитория нас не волнует, мы используем лишь его интерфейс.
Допустим у нас такой репозиторий:
public interface SpeakerRepository {
Speaker findById(Long id);
void save(Speaker speaker);
}
Тогда сервис по выполнению операции submitTalk
будет выглядеть так:
@Service
@RequiredArgsConstructor
public class SpeakerService {
private final SpeakerRepository repo;
public TalkSubmittedResult submitTalk(Long speakerId, String title) {
Speaker speaker = repo.findById(speakerId);
Talk talk = speaker.submitTalk(title);
repo.save(speaker);
return new TalkSubmittedResult(talk.getId());
}
}
Плюсы доменной модели такие:
Доменные объекты полностью отвязаны от деталей реализации (то есть от БД). Значит, их легко протестировать обычными unit-тестами.
Бизнес-логика сконцентрирована в одном месте, в доменных объектах. Гораздо меньший риск расползания логики по приложению, в отличие от Transaction Script.
При желании доменные объекты можно объявить полностью иммутабельными, что увеличит безопасность при работе с ними (можно передавать их в любой метод и не боятся, что он случайно изменит их содержимое).
Поля в доменных объектах можно заменить на Value Objects, что не только повысит читаемость, но и позволит гарантировать валидность полей на этапе их присвоения (нельзя создать Value Object с невалидным контентом).
Короче говоря, одни сплошные плюсы. Тем не менее, есть одна важная проблема. Особенно интересно, что в книгах по Domain Driven Design, в которых зачастую и продвигается Domain Model, это проблема либо вообще не упоминается, либо ее касаются вскольз.
Звучит она так: а как доменные объекты записать в БД, а потом прочитать обратно? Иначе говоря, как написать реализацию для репозитория?
Естественно, сейчас ответ очевиден. Возьми Hibernate (а еще лучше, Spring Data JPA) и не мучайся. Но давайте представим, что мы попали в мир, где ORM фреймворков не придумали. Как бы мы решили эту проблему?
Маппинг в БД и обратно руками
Для работы с БД я также возьму библиотеку JDBI:
@AllArgsConstructor
@Repository
public class JdbiSpeakerRepository implements SpeakerRepository {
private final Jdbi jdbi;
@Override
public Speaker findById(Long id) {
return jdbi.inTransaction(handle -> {
return handle.select("SELECT * FROM speaker s LEFT JOIN talk t ON t.speaker_id = s.id WHERE id = :id")
.bind("id", speakerId)
.mapTo(Speaker.class) // для простоты опустим логику маппинга
.execute();
});
}
@Override
public void save(Speaker speaker) {
jdbi.inTransaction(handle -> {
// сложная логика по проверке того, есть ли speaker,
// генерации update/insert, optimistic locking,
// обновлению удалению Talk внутри speaker и т д
});
}
}
Подход простой и прямой. Для каждого репозитория пишем отдельную реализацию, которая работает с БД через любую удобную библиотеку (например, тот же JOOQ или JDBI).
На первый взгляд (а может быть, даже на второй), такое решение может показаться хорошим. Судите сами:
По-прежнему высокая степень прозрачности кода, как в Transaction Script.
Больше нет проблем с тестированием бизнес-логики исключительно integration тестами. Они нужны только для реализаций репозиториев (и возможно пару сценариев E2E).
Код маппинга прямо перед нашими глазами. Никакой Hibernate-овской магии. Увидел баг? Нашел нужную строчку и поправил.
Необходимость Hibernate
Но все становится намного интереснее в реальном мире. Ведь у вас вполне могут быть такие сценарии:
Доменные объекты могут наследоваться.
Совокупность полей может объединяться в отдельный Value Object (Embedded в JPA/Hibernate).
Некоторые поля не нужно загружать каждый раз при получении доменного объекта, а лишь при обращении к ним с целью улучшить performance (lazy loading).
Между объектами могут быть сложные связи (one-to-many, many-to-many и так далее).
Нам нужно добавлять в
UPDATE
лишь те поля, которые мы в действительности поменяли, потому что остальные меняются редко и нам нет смысла гонять их по сети (аннотация DynamicUpdate).
Да и банально сам код по маппингу тоже придется поддерживать вместе с эволюцией бизнес-логики (и доменных объектов, следовательно).
Если вы попробуете самостоятельно закрывать каждый из пунктов, то придете к тому, что (сюрприз!) напишите свой Hibernate (а скорее всего, его сильно урезанную версию).
Цели JOOQ и Hibernate
JOOQ решает проблему отсутствия статической типизации при написании SQL-запросов. Это позволяет снизить количество ошибок еще на этапе компиляции. А благодаря кодогенерации прямо из схемы БД, при ее обновлении мы сразу увидим, где код нужно поправить (он просто не будет компилироваться).
Hibernate решает проблему маппинга доменных объектов в реляционную БД и наоборот (чтение данных из БД и их маппинг на реляционные объекты).
Поэтому нет смысла рассуждать о том, что Hibernate хуже, или JOOQ лучше. Эти инструменты нужны для разных задач. Если ваше приложение написано в парадигме Transaction Script, то JOOQ, безусловно, будет идеальным выбором. Но если вы хотите использовать паттерн Domain Model, но в то же время гнушаетесь Hibernate, то вам придется познать радость самостоятельного маппинга в кастомных реализациях репозиториев. Конечно, если работадель платит вам за то, что вы пишите ему yet another Hibernate killer, вопросов нет. Но скорее всего, ему нужна от вас в первую очередь бизнес-логика, а не инфраструктурный код с маппингом объектов в БД и обратно.
Кстати, я считаю, что связка Hibernate + JOOQ отлично подходит к CQRS. У вас есть приложение (или его логическая часть), которое выполняет команды, то есть CREATE/UPDATE/DELETE
операции. Здесь Hibernate будет очень кстати. С другой стороны, у вас есть query-сервис, который хочет читать данные. Тут JOOQ пригодится. С ним будет гораздо проще строить сложные запросы и оптимизировать их точечно, чем с Hibernate.
А как насчет DAO в JOOQ?
Это правда. JOOQ позволяет сгенерировать DAO, который будет содержать типовые запросы по поиску сущности в БД. Можно даже его отнаследовать и дополнить своими методами. Более того, JOOQ сгенерирует сущность, которую можно наполнить данными через сеттеры, подобно Hibernate, и передать в методы insert/update в DAO. Чем вам не Spring Data?
В простых кейсах это и правда будет работать. Но вот только это мало отличается от того, когда мы писали реализацию репозитория вручную. Проблемы похожие:
В сущностях не будет никаких связей: ни ManyToOne, ни OneToMany. Только колонки из БД. Это сильно затрудняет написание бизнес-логики.
Сущности генерируются по отдельности. Нельзя объединить их в иерархию наследования.
Тот факт, что сущности генерируются вместе с DAO, означает, что вы не можете их менять по своему усмотрению. Например, заменить поле на Value Object, добавить связь на другую сущность, объединить поля в Embeddable и т д. Потому что повторная генерация сотрет ваши труды. Да, вы можете настроить генератор, чтобы он создавал сущность немного иначе, но возможности кастомизации тоже не безграничны (и далеко не так удобны, как написать код самому).
Так что здесь, если вы хотите построить сложную доменную модель, вам придется писать ее самостоятельно. А без Hibernate вопрос маппинга ляжет целиком на ваши плечи. Конечно, с JOOQ его писать приятнее, чем с JDBI, но процесс все равно будет трудоемким.
Даже сам Lukas Eder, создатель JOOQ, пишет в блоге, что DAO были добавлены в библиотеку, потому что это популярный паттерн, а вовсе не потому, что он всем советует его использовать.
Заключение
Спасибо, что дочитали статью. Я большой поклонник Hibernate и считаю его отличным фреймворком. Но я не исключаю, что кому-то может быть JOOQ удобнее. Главная мысль моей статьи в том, что Hibernate и JOOQ – не враги. Эти инструменты вполне могут сосуществовать даже в одном продукте, если в этом есть ценность.
Если у вас есть комментарии/замечания по содержанию, буду рад их обсудить. Продуктивного вам дня!
Ссылки
Комментарии (8)
arvgord
10.01.2025 05:53Соглашусь с автором что для каждой конкретной цели есть свой инструмент. Единственный момент - возможно вы упустили или не указали в статье, что у Hibernate также есть возможность проверки кода во время компиляции. Для этого нужно использовать Criteria API и библиотеку генерации метамоделей классов hibernate-jpamodelgen.
Приведу примеры кода с использованием метамодели и Criteria API.
Репозиторий:
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; public interface SpeakerRepository extends JpaRepository<Speaker, Long>, JpaSpecificationExecutor<Speaker> { }
Сервис с использованием метамодели Speaker:
import lombok.RequiredArgsConstructor; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import java.util.List; @Service @RequiredArgsConstructor public class SpeakerService { private final SpeakerRepository speakerRepository; public List<Speaker> findByLastName(String lastName) { return speakerRepository.findAll(hasLastName(lastName)); } private Specification<Speaker> hasLastName(String lastName) { // Используется сгенерированная метамодель Speaker_.java return (root, query, cb) -> cb.equal(root.get(Speaker_.lastName), lastName); } }
Метамодель
Speaker_.java
генерируется во время компиляции и её можно использовать для проверки корректности составленных запросов (типы данных, наименование полей).kirekov Автор
10.01.2025 05:53Да, вы правы, что jpametamodelgen помогает в построении валидных запросов с помощью Criteria API. Тем не менее, функциональность не такая продвинутая как у JOOQ. Последний может проверить, допустим, что вы используете поле именно из правильной таблицы, проверить типы данных в соответствии с колонками и т д. Jpamodelgen просто сгенерит константы с названиями атрибутов из сущностей. Это, конечно, лучше чем просто использовать строки в коде. Но JOOQ здесь явно ушел дальше)
kalempir
10.01.2025 05:53То что вы описали, а именно бизнес логика в доменных объектах можно сделать и с помощью JOOQ судя по тому по примеру как пишутся запросы на нем. Я лично не пользовался этой библиотекой, но пользуюсь кажется почти аналогом skunk на скале. А там это отлично делается, причем изначально код получается максимально оптимизированным. А сама техника не смешивать бизнес логику с запросами в базу данных или любыми другими эффектами вроде логично и особенно практикуется в функциональном программировании. Бизнес логика должна получать данные и возвращать результат, ничего более, и это очень облегчит тестирование. Более подробно можете поискать "Moving IO to the edges of your app: Functional Core, Imperative Shell"
AlexunKo
10.01.2025 05:53Лет 10 назад использовал жука на проекте, подбирая сам архитектуру. Был как ребенок доволен. И вот что скажу спустя годы. Далеко не факт что вам нужен этот инструмент. Его суть в красоте выражения SQL запросов на Java и возможности жонглировать частями запросов контролируемо (типы). А можно, наоборот, хотеть принципиально не смешивать эти консёрны, имея аккуратный SQL отдельно. Это может быть гибче и проще (нет доп. слоя "магии", доп. API для изучения).
panzerfaust
Если вы пишете тесты не ради галочки и покрытия, а в целях повышения качества, то вам все равно не обойтись без интеграционных тестов БД со всеми ее фокусами.
Хибер и альтернативные решения как-то принципиально защищены от спагетти-кода при росте сложности? На каких примерах вы замеряли "быстроту" сползания джука в спагетти-код?
Хибер и альтернативные решения как-то принципиально защищены от риска дублирования кода и неожиданных багов?
Итого 2 ваших "минуса" джука на самом деле являются особенностями разработки ПО в целом. Соответственно, все дальнейшие рассуждения идут из сомнительного тезиса.
ExTalosDx
не соглашусь т.к отдельно тестировать бд и бизнес логику это одно, намного проще, а вот тестировать бизнес логику и бд совместно это абсолютно разные вещи. Я уже устал править тесты на 20 и более ассертов просто потому что 'иначе не получится', а нафига вы сильную связность создали.
panzerfaust
Случай, когда вы прям вынуждены тестировать вместе бизнес-логику и БД - это вопрос к вашей архитектуре, а не к джуку или другому фреймворку. Здесь у автора тоже имеет место избиение чучела. Хоть на хибере, хоть на голом JDBC можно наваять очень труднотестируемые вещи.