JOOQ не є заміною для Hibernate. Вони вирішують різні задачі.

Цю статтю я спочатку написав російською. Тому, якщо ви є носієм мови, ви можете прочитати її за цим посиланням.

Не можете прочитати статтю через paywall? Спробуйте це посилання для друзів.

Минулого року я натрапив на статті та виступи, в яких стверджується, що JOOQ є сучасною та кращою альтернативою Hibernate. Основні аргументи зазвичай виглядають так:

  1. JOOQ дозволяє перевіряти все на етапі компіляції, на відміну від Hibernate!
  2. Hibernate генерує дивні й не завжди оптимальні запити, в той час як з JOOQ все прозоро!
  3. Ентіті Hibernate змінні, а це погано. JOOQ дозволяє робити всі ентіті незмінними (привіт, функціональне програмування)!
  4. JOOQ не використовує жодних "чар" з анотаціями!

Дозвольте сказати одразу, що я вважаю JOOQ чудовою бібліотекою (саме бібліотекою, а не фреймворком на кшталт Hibernate). Вона чудово справляється зі своїм завданням — роботою з SQL у статично типізованому середовищі для того, щоб ловити більшість помилок на етапі компіляції.

Однак, коли я чую аргумент, що час Hibernate вже минув і тепер ми повинні писати все тільки з JOOQ, це звучить для мене так, ніби хтось сказав, що ера реляційних баз даних закінчилася і ми повинні використовувати тільки NoSQL. Чи звучить це смішно? Проте не так давно такі дискусії були цілком серйозними.

Проблема полягає в нерозумінні основних проблем, які вирішують ці два інструменти. У цій статті я спробую прояснити ці питання. Ми розглянемо:

  1. Що таке Transaction Script?
  2. Що таке патерн Domain Model?
  3. Які конкретно проблеми вирішують Hibernate та JOOQ?
  4. Чому один не є заміною іншому, і як вони можуть співіснувати?

pic

Мем обкладинка

Transaction Script

Найпростіший і найінтуїтивно зрозумілий спосіб роботи з базою даних — це патерн Transaction Script. Коротко кажучи, ви організовуєте всю свою бізнес-логіку у вигляді набору SQL-команд, об'єднаних в одну транзакцію. Зазвичай кожен метод у класі представляє бізнес-операцію і обмежується однією транзакцією.

Припустимо, ми розробляємо застосунок, який дозволяє спікерам подавати свої доповіді на конференцію (для простоти ми записуватимемо лише назву доповіді). Дотримуючись патерну Transaction Script, метод подачі доповіді може виглядати ось так (використовуючи JDBI для SQL):

@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);  
 }  
}

У цьому коді:

  1. Ми підраховуємо, скільки доповідей спікер вже подав.
    2.
    Ми перевіряємо, чи перевищена максимальна кількість поданих доповідей.
  2. Якщо все в порядку, створюємо нову доповідь зі статусом SUBMITTED.

Тут є потенційна умова гонки, але для простоти ми не будемо зосереджуватися на цьому.

Переваги цього підходу:

  1. SQL-запити виконуються просто і передбачувано. Їх легко налаштувати для покращення продуктивності за потреби.
  2. Ми отримуємо тільки необхідні дані з бази даних.
  3. За допомогою JOOQ цей код можна написати простіше, лаконічніше і з використанням статичної типізації!

Недоліки:

  1. Неможливо протестувати бізнес-логіку лише за допомогою юніт-тестів. Вам знадобляться інтеграційні тести (і чимало з них).
  2. Якщо домен складний, цей підхід може швидко призвести до спагеті-коду.
  3. Є ризик дублювання коду, що може призвести до несподіваних багів у процесі еволюції системи.

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

Domain Model

Ідея патерну Domain Model полягає в тому, що ми більше не прив'язуємо нашу бізнес-логіку безпосередньо до SQL-запитів. Замість цього ми створюємо об'єкти домену (в контексті Java — класи), які описують поведінку та зберігають дані про сутності домену.

У цій статті ми не будемо обговорювати різницю між анемічними та багатими моделями. Якщо вам цікаво, я написав детальну статтю на цю тему.

Бізнес-сценарії (сервіси) повинні використовувати тільки ці об'єкти та уникати прив'язки до конкретних запитів до бази даних.

Звісно, на практиці ми можемо мати змішаний підхід, де взаємодія з об'єктами домену та прямі запити до бази даних використовуються для виконання вимог до продуктивності. Тут ми обговорюємо класичний підхід до реалізації 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;  
 }  

 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  
 }  
}

Тут клас Speaker містить бізнес-логіку для подачі доповіді. Взаємодія з базою даних абстрагована, що дозволяє моделі домену зосередитися на бізнес-правилах.

Припустимо, є такий інтерфейс репозиторію:

public interface SpeakerRepository {  
 Speaker findById(Long id);  
 void save(Speaker speaker);  
}

Тоді SpeakerService можна реалізувати ось так:

@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());  
 }  
}

Переваги моделі домену:

1.
1. Об'єкти домену повністю відокремлені від деталей реалізації (тобто, бази даних). Це робить їх легкими для тестування за допомогою звичайних юніт-тестів.
2. Бізнес-логіка централізована в об'єктах домену. Це значно знижує ризик розповсюдження логіки по всій програмі, на відміну від підходу Transaction Script.
3. За бажанням об'єкти домену можна зробити повністю незмінними, що підвищує безпеку при роботі з ними (ви можете передавати їх до будь-якого методу, не турбуючись про випадкові зміни).
4. Поля в об'єктах домену можна замінити на об'єкти значень (Value Objects), що не лише покращує читабельність, але й забезпечує коректність полів під час їх присвоєння (ви не можете створити об'єкт значення з некоректним вмістом).

Коротше кажучи, є чимало переваг. Однак існує одна важлива проблема. Цікаво, що в книгах про Domain-Driven Design, які часто пропагують патерн Domain Model, ця проблема або зовсім не згадується, або згадується тільки коротко.

Проблема полягає в тому, як зберігати об'єкти домену в базі даних і потім зчитувати їх знову? Іншими словами, як реалізувати репозиторій?

Нині відповідь очевидна. Просто використовуйте Hibernate (або ще краще, Spring Data JPA) і заощаджуйте собі час. Але уявімо, що ми живемо в світі, де ORM-фреймворки ще не були винайдені. Як би ми вирішили цю проблему?

Ручне мапування

Для реалізації SpeakerRepository я також використовую 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 -> {  
 // Складна логіка перевірки:  
 // 1. Чи вже є спікер  
 // 2. Генерація UPDATE/INSERT/DELETE запитів  
 // 3. Можлива реалізація оптимістичного блокування  
 // 4. і т.д.  
 });  
 }  
}

Підхід простий. Для кожного репозиторію ми пишемо окрему реалізацію, яка працює з базою даних, використовуючи будь-яку SQL-бібліотеку (таку як JOOQ або JDBI).

На перший погляд (і можливо навіть на другий) цей підхід може здаватися досить хорошим. Ось що ми маємо:

  1. Код залишається дуже прозорим, як у підході Transaction Script.
  2. Більше немає проблем з тестуванням бізнес-логіки тільки через інтеграційні тести. Вони потрібні тільки для реалізацій репозиторіїв (і, можливо, кількох E2E сценаріїв).
  3. Мапування коду прямо перед нами. Жодної магії з Hibernate. Знайшли баг? Знайшли потрібний рядок і виправили.

Потреба в Hibernate

Зробимо все більш цікаво в реальному світі, де можна зіткнутися з такими ситуаціями:

  1. Об'єкти домену можуть потребувати підтримки успадкування.
  2. Група полів може бути об'єднана в окремий об'єкт значення (Embedded в JPA/Hibernate).
  3. Деякі поля не повинні завантажуватися щоразу при отриманні об'єкта домену, а лише при зверненні, для покращення продуктивності (lazy loading).
  4. Можуть бути складні зв'язки між об'єктами (один до багатьох, багато до багатьох і т.д.).
    5.
    Вам потрібно включити лише ті поля, які змінилися в операторі UPDATE, тому що інші поля рідко змінюються, і немає сенсу відправляти їх через мережу (DynamicUpdate анотація).

До того ж, вам потрібно буде підтримувати код мапування, оскільки ваша бізнес-логіка та об'єкти домену еволюціонують.

Якщо ви спробуєте обробити кожну з цих проблем самостійно, ви зрештою опинитесь (сюрприз!) пишучи свій фреймворк на кшталт Hibernate — або, швидше за все, набагато простішу його версію.

Цілі JOOQ та Hibernate

JOOQ вирішує проблему відсутності статичної типізації при написанні SQL-запитів. Це допомагає зменшити кількість помилок на етапі компіляції. Завдяки генерації коду безпосередньо з схеми бази даних, будь-які оновлення в схемі відразу покажуть, де потрібно виправити код (він просто не зкомпілюється).

Hibernate вирішує проблему мапування об'єктів домену на реляційну базу даних і навпаки (зчитування даних з бази даних і мапування їх в об'єкти домену).

Тому не має сенсу сперечатися, що Hibernate гірший, а JOOQ кращий. Ці інструменти призначені для різних цілей. Якщо ваш додаток побудований навколо парадигми Transaction Script, то JOOQ безсумнівно є ідеальним вибором. Але якщо ви хочете використовувати патерн Domain Model і уникнути Hibernate, вам доведеться мати справу з усіма радощами ручного мапування в реалізаціях репозиторіїв. Звісно, якщо ваш роботодавець платить вам за створення ще одного вбивці Hibernate, то тут без питань. Але найімовірніше, вони очікують, що ви зосередитесь на бізнес-логіці, а не на коді інфраструктури для мапування об'єктів до бази даних.

До речі, я вважаю, що комбінація Hibernate та JOOQ добре працює для CQRS. У вас є додаток (або логічна частина його), який виконує команди, як-от операції CREATE/UPDATE/DELETE — саме тут Hibernate підходить ідеально. З іншого боку, у вас є сервіс запитів, що зчитує дані. Тут JOOQ просто блискучий. Він значно полегшує створення складних запитів та їх оптимізацію порівняно з Hibernate.

Що з DAO в JOOQ?

Це правда. JOOQ дозволяє генерувати DAO, які містять стандартні запити для отримання сутностей з бази даних. Ви навіть можете розширити ці DAO своїми методами. Крім того, JOOQ буде генерувати сутності, які можна заповнити за допомогою сеттерів, подібно до Hibernate, і передати їх в методи вставки або оновлення в DAO. Хіба це не схоже на Spring Data?

Для простих випадків це дійсно може працювати. Однак це не дуже відрізняється від ручної реалізації репозиторію. Проблеми схожі:

  1. Сутності не матимуть жодних зв'язків: ні ManyToOne, ні OneToMany. Тільки стовпці бази даних, що ускладнює написання бізнес-логіки.
  2. Сутності генеруються окремо. Ви не можете організувати їх в ієрархію успадкування.
  3. Той факт, що сутності генеруються разом з DAO, означає, що ви не можете змінювати їх на свій розсуд. Наприклад, замінити поле на об'єкт значення, додати зв'язок до іншої сутності або згрупувати поля в Embeddable буде неможливо, оскільки повторна генерація сутностей перезапише ваші зміни. Так, ви можете налаштувати генератор так, щоб сутності створювалися трохи по-іншому, але варіанти налаштування обмежені (і не такі зручні, як написання коду вручну).

Отже, якщо ви хочете побудувати складну модель домену, вам доведеться робити це вручну. Без Hibernate вся відповідальність за мапування ляже на вас. Звісно, використання JOOQ приємніше, ніж JDBI, але процес все одно буде трудомістким.

Навіть Lukas Eder, творець JOOQ, згадує у своєму блозі що DAO були додані до бібліотеки, оскільки це популярний патерн, а не тому, що він обов'язково рекомендує їх використовувати.

Висновки

Дякую за те, що прочитали статтю. Я великий прихильник Hibernate і вважаю його відмінним фреймворком.
Однак я розумію, що деяким може здатися, що JOOQ зручніший. Головна мета моєї статті — це те, що Hibernate і JOOQ не є суперниками. Ці інструменти можуть співіснувати навіть в одному продукті, якщо вони приносять цінність.

Якщо у вас є коментарі чи відгуки щодо змісту, буду радий їх обговорити. Бажаю продуктивного дня!

Ресурси

  1. JDBI
  2. Transaction Script
  3. Domain Model
  4. Моя стаття — Багатий доменний модель з Spring Boot та Hibernate
  5. Патерн репозиторію
  6. Об'єкт значення
  7. JPA Embedded
  8. JPA DynamicUpdate
  9. CQRS
  10. Lukas Eder: DAO чи не DAO

Перекладено з: JOOQ Is Not a Replacement for Hibernate. They Solve Different Problems

Leave a Reply

Your email address will not be published. Required fields are marked *