JOOQ — не замена Hibernate. Они решают разные проблемы
Последние год-полтора я натыкаюсь на статьи и доклады (особенно в англоязычном сегменте) о том, что 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 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 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 — не враги. Эти инструменты вполне могут сосуществовать даже в одном продукте, если в этом есть ценность.
Если у вас есть комментарии/замечания по содержанию, буду рад их обсудить. Продуктивного вам дня!
Ссылки
JDBI
Transaction Script
Domain Model
Моя статья — Rich Domain Model with Spring Boot and Hibernate
Repository pattern
Value Object
JPA Embedded
JPA DynamicUpdate
CQRS
Lukas Eder: To DAO or not to DAO