1. Мікроскоп для курсу 🔬
Підготовчі дні на технічних курсах мають погану репутацію. І часто цілком заслужено: пів дня йде на встановлення, налаштування та боротьбу із середовищем, а навчальної цінності майже немає. У Hibernate deep-dive ми спеціально намагаємося цього уникнути. Тому сьогоднішня мета — не просто «запустити проєкт», а підняти діагностичний стенд, на якому будь-який майбутній ORM-сценарій можна буде побачити одразу в кількох площинах: як код, як міграції, як SQL, як bind values і як статистику Hibernate. Це вже зовсім інший рівень користі.
Чому це так важливо? Тому що без спостережуваності deep-dive дуже швидко скочується у ворожіння. Ви бачите дивну поведінку, припускаєте причину, змінюєте анотацію, знову сподіваєтеся на краще — і далі живете в режимі шаманства. Нормальний інженерний курс має вчити протилежного: спершу побудувати середовище, у якому поведінку видно, і лише потім робити висновки.
Ось тут і з’являється перший справді сильний момент цього курсу. Ми не завершуємо рівень фразою «ну, у вас тепер є проєкт». Ми завершуємо його стендом, у якому один і той самий виклик видно і як бізнес-операцію, і як SQL, і як параметри, і як лічильники ORM. Це дуже практична точка старту.
2. PostgreSQL через Docker Compose 🐘
Локальну базу в нашому курсі піднімаємо через Docker Compose. Це не декоративне ускладнення і не спроба «зробити все по-дорослому». Це найпростіший спосіб зробити середовище відтворюваним. Якщо один студент працює з локальним PostgreSQL у системі, другий — із контейнером, третій — з H2 «поки для зручності», а четвертий узагалі не впевнений, до якої бази підключився, то далі ми будемо обговорювати не Hibernate, а чужі середовища.
Мінімальний docker-compose.yml для курсу може виглядати так:
services:
postgres: # основний сервіс із PostgreSQL
image: postgres:18 # фіксуємо версію, щоб середовище було відтворюваним
environment:
POSTGRES_DB: commerce # імʼя БД, яка створиться під час старту контейнера
POSTGRES_USER: commerce # користувач БД
POSTGRES_PASSWORD: commerce # пароль користувача (для курсу — просте поєднання)
ports:
- "5432:5432" # виведення порту: зовні localhost:5432 -> всередину контейнера :5432
volumes:
- commerce_pgdata:/var/lib/postgresql/data # зберігаємо дані поза контейнером, щоб вони не зникали
volumes:
commerce_pgdata: # іменований volume для даних PostgreSQL
Тут майже все гранично зрозуміло. Контейнер створює базу commerce, користувача commerce і пароль commerce. Порт виводиться назовні на стандартний 5432, щоб Spring Boot-застосунок міг підключатися до localhost:5432. Том потрібен, щоб дані не зникали під час кожного повторного створення контейнера. Це зручно в звичайній локальній роботі й добре поєднується з ідеєю навчального стенда.
Запуск — звичайний:
docker compose up -d # підняти сервіси у фоновому режимі (detached)
docker compose ps # перевірити, що контейнер справді запущений і має статус Up
Якщо хочеться переконатися, що контейнер справді живий, а не просто існує уявно, можна подивитися логи:
docker compose logs -f postgres # дивитися логи конкретного сервісу postgres у режимі follow
Хороше локальне базове середовище має бути нудним. У ньому немає неочікуваних портів, немає окремого ручного налаштування, немає окремої інструкції на кожен ноутбук. І це саме те, що нам потрібно. Чим стабільніше й передбачуваніше середовище, тим цікавішою стає поведінка ORM.
3. Базова конфігурація й профілі: local, sql-trace, stats
Наступний крок — розділити режими запуску так, щоб середовище не перетворювалося на один гігантський YAML-файл із вічним шумом. У проєкті є три основні профілі: local, sql-trace і stats. local відповідає за звичайний локальний запуск і підключення до PostgreSQL. sql-trace — за видимість SQL і bind values. stats — за вмикання Hibernate statistics. Це дуже просте розділення, але воно одразу робить стенд керованим. 📌
Базові правила курсу зручно тримати в application.yml:
spring:
application:
name: commerce-persistence-lab # імʼя застосунку (для логів, метрик та ідентифікації)
jpa:
open-in-view: false # не тримаємо persistence context відкритим до шару view
hibernate:
ddl-auto: none # схему НЕ генерує Hibernate, лише Flyway
flyway:
enabled: true # міграції ввімкнено як базовий механізм керування схемою
Тут важливі три рядки. open-in-view: false одразу фіксує, що робота з БД має бути всередині зрозумілих меж бекенда, а не витікати далі. ddl-auto: none означає, що Hibernate не керує схемою сам. Схема живе лише через Flyway. І flyway.enabled: true підкреслює, що міграції — не додаткова опція, а частина базового режиму проєкту.
Профіль local зазвичай зберігає підключення до бази:
spring:
config:
activate:
on-profile: local # цей блок застосовується лише за активного профілю local
datasource:
url: jdbc:postgresql://localhost:5432/commerce # підключаємося до PostgreSQL із Docker Compose
username: commerce # має збігатися з POSTGRES_USER
password: commerce # має збігатися з POSTGRES_PASSWORD
Профіль sql-trace вмикає видимість SQL і параметрів:
spring:
config:
activate:
on-profile: sql-trace # вмикаємо цей блок лише тоді, коли хочемо бачити SQL і параметри
logging:
level:
org.hibernate.SQL: debug # друкуємо SQL, який генерує Hibernate
org.hibernate.orm.jdbc.bind: trace # друкуємо bind values (що саме підставилося в '?')
А профіль stats вмикає збирання статистики:
spring:
config:
activate:
on-profile: stats # вмикаємо статистику лише за потреби, щоб не шуміти постійно
jpa:
properties:
hibernate.generate_statistics: true # Hibernate починає збирати Statistics (лічильники)
Такий розподіл дуже корисний методично. Ви можете запускати застосунок просто в local, коли вам потрібен тихий режим. І можете додавати sql-trace,stats, коли починаєте розбирати поведінку. Тобто шум вмикається усвідомлено, а не висить фоном постійно. Це доросла звичка, і вона чудово переноситься в реальну розробку.
4. Flyway: схема має бути видимою
У deep-dive курсі з Hibernate схема бази — це не технічна тінь від Java-класів. Це повноцінна частина системи. Якщо ви не контролюєте, які таблиці, обмеження й індекси реально існують у PostgreSQL, то згенерований SQL стає важко інтерпретувати. Саме тому проєкт принципово живе на Flyway, а не на ddl-auto=update.
Нехай стартова міграція виглядає дуже просто:
-- V1__baseline_catalog.sql
create table product (
id bigint primary key, -- явний PK (важливо для ORM і для читабельності моделі)
sku varchar(64) not null unique, -- бізнес-ідентифікатор, робимо NOT NULL + UNIQUE
name varchar(255) not null -- імʼя продукту, теж NOT NULL для простого baseline
);
-- стартові дані: щоб із першого запуску було що читати з бази
insert into product(id, sku, name)
values (1, 'SKU-0001', 'Coffee mug');
Цього вже достатньо, щоб побачити, як застосунок застосовує схему й отримує стартові дані. Після старту ви можете перевірити, що Flyway справді спрацював, не лише за логами Spring Boot-застосунку, а й напряму в базі. Це хороший анти-магічний жест. Наприклад:
docker compose exec postgres psql -U commerce -d commerce \ # зайти в контейнер і виконати команду psql
-c "select installed_rank, version, description, success from flyway_schema_history order by installed_rank;" # перевірити історію міграцій
Якщо таблиця flyway_schema_history існує й показує успішно застосовані міграції, значить схема живе саме так, як задумано. А щоб перевірити наявність стартових даних, можна зробити ще один чесний запит:
docker compose exec postgres psql -U commerce -d commerce \
-c "select count(*) from product;" # перевіряємо, що стартові дані справді вставилися
Цей момент здається дрібним налаштуванням, але насправді він будує довіру до курсу. Ви не просто сподіваєтеся, що схема піднялася. Ви бачите, як і чим вона піднялася. А отже, далі згенерований SQL можна читати на міцному фундаменті, а не на здогадках.
5. Hibernate statistics і smoke-сервіс
Тепер потрібно додати два невеликі технічні шматки, які дадуть нам реальну спостережуваність. Перший — доступ до Statistics через поточний SessionFactory. Другий — простий smoke-сервіс, який виконує пару операцій читання й друкує коротке зведення. Це як перший прогін лабораторії: ми не робимо нічого складного, але одразу бачимо, що діагностика справді працює.
Конфігурація для Statistics може виглядати так:
@Configuration
class HibernateStatisticsConfig {
@Bean
Statistics hibernateStatistics(EntityManagerFactory emf) {
// unwrap потрібен, щоб дістатися до «нативного» SessionFactory Hibernate з JPA-обгортки
return emf.unwrap(SessionFactory.class).getStatistics(); // беремо єдиний обʼєкт Statistics
}
}
Далі маленький сервіс для очищення й друку лічильників:
@Service
class HibernateStatsPrinter {
private final Statistics stats; // сюди інжектиться Statistics, якщо активний профіль stats
HibernateStatsPrinter(Statistics stats) {
this.stats = stats;
}
public void clear() {
stats.clear(); // скидаємо лічильники, щоб рахувати лише поточний сценарій
}
public void print() {
// Prepared statements: скільки JDBC statements підготував Hibernate
System.out.println("Кількість підготовлених SQL statements = " + stats.getPrepareStatementCount());
// Entity loads: скільки entity реально завантажилося (корисно для пошуку N+1 і зайвих читань)
System.out.println("Кількість завантажених сутностей = " + stats.getEntityLoadCount());
// Flushes: скільки разів відбувався flush (важливо розуміти, коли Hibernate пише в БД)
System.out.println("Flushes = " + stats.getFlushCount());
}
}
А тепер — сам smoke-сценарій. Важливо, щоб це була нормальна backend-операція, а не випадковий static helper. Тому нехай він живе як звичайний сервіс:
@Service
class StartupSmokeService {
private final ProductRepository products; // Spring Data репозиторій для доступу до product
private final HibernateStatsPrinter statsPrinter; // друк статистики після сценарію
StartupSmokeService(ProductRepository products, HibernateStatsPrinter statsPrinter) {
this.products = products;
this.statsPrinter = statsPrinter;
}
@Transactional(readOnly = true) // важливий момент: читаємо в транзакції (базовий режим для JPA)
public void run() {
statsPrinter.clear(); // починаємо з чистого аркуша
long count = products.count(); // швидкий запит, щоб побачити SQL і підготовку statement
System.out.println("Кількість продуктів у БД = " + count);
// вибірка за PK: тут якраз зʼявиться bind parameter (id = ?)
products.findById(1L).ifPresent(product ->
System.out.println("Завантажений SKU продукту = " + product.getSku())
);
statsPrinter.print(); // виводимо лічильники Hibernate саме по цьому сценарію
}
}
Щоб усе це справді виконалося на старті, зручно підвісити CommandLineRunner:
@Configuration
class SmokeStartupConfig {
@Bean
CommandLineRunner smokeRunner(StartupSmokeService smokeService) {
// CommandLineRunner запускається одразу після старту контексту Spring Boot
return args -> smokeService.run(); // викликаємо наш smoke-сценарій
}
}
Тут майже немає бізнес-логіки, і це нормально. Мета не в тому, щоб «зробити фічу». Мета в тому, щоб довести: проєкт реально стартує, реально застосовує міграції, реально звертається до PostgreSQL, реально пише SQL у логи й реально віддає статистику по цій операції. Це і є перша робоча перемога сьогоднішнього дня.
6. Що ви маєте побачити в логах і консолі 👀
Тепер запускаємо застосунок у повному діагностичному режимі:
./gradlew bootRun --args="--spring.profiles.active=local,sql-trace,stats" # активуємо профілі: база + SQL/bind + statistics
Якщо ви запускаєте його з IDE, сенс залишається тим самим: мають бути активні профілі local, sql-trace, stats. Важливо не стільки, звідки ви запускаєте, скільки щоб запуск був відтворюваним і відповідав базовому рівню курсу.
Після старту ви маєте побачити приблизно таку послідовність подій. Спершу піднімається DataSource. Потім Flyway повідомляє про застосовані міграції. Далі спрацьовує smoke-сервіс. І ось тут з’являються головні сигнали:
# Нижче — приклад ключових рядків (вони можуть відрізнятися форматуванням, але зміст той самий)
DEBUG org.hibernate.SQL - select count(p1_0.id) from product p1_0
DEBUG org.hibernate.SQL - select p1_0.id,p1_0.name,p1_0.sku from product p1_0 where p1_0.id=?
TRACE org.hibernate.orm.jdbc.bind - binding parameter (1:BIGINT) <- [1]
Кількість продуктів у БД = 1
Завантажений sku продукту = SKU-0001
Кількість підготовлених SQL statements = 2
Кількість завантажених сутностей = 1
Flushes = 0
Що тут важливо побачити на власні очі? По-перше, SQL справді видно. По-друге, bind value справді видно. Тобто ? у запиті не лишається абстракцією — ви бачите конкретний параметр 1. По-третє, statistics справді рахують роботу Hibernate, а не існують десь у теорії. По-четверте, smoke-сервіс підтверджує, що операція справді дійшла до бази й прочитала дані.
Ось він, справді сильний момент сьогоднішнього дня: один крихітний use case уже видно одразу в кількох площинах. У вас є Java-код. У вас є міграція, яка створила таблицю й стартові дані. У вас є SQL. У вас є bind parameters. У вас є лічильники Hibernate. Це саме той тип практичного результату, який потрібен deep-dive курсу. Не «застосунок якось запустився», а «у мене є робочий діагностичний стенд». 🔬
7. Чек-лист робочого діагностичного стенда
Щоб цей результат можна було переносити далі по курсу, корисно зафіксувати короткий чек-лист. Якщо колись по дорозі ви перестанете бачити один із цих сигналів, це вже привід не аналізувати Hibernate далі, а спершу відновити сам стенд.
| Що перевірити | Ознака, що все гаразд |
|---|---|
| PostgreSQL піднято | docker compose ps показує запущений контейнер postgres |
| Застосунок підключився до правильної бази | DataSource вказує на jdbc:postgresql://localhost:5432/commerce |
| Схему створено через Flyway | є таблиця flyway_schema_history, міграції позначено як успішні |
| ORM не змінює схему сам | ddl-auto=none |
| Контекст не розтягнуто штучно | open-in-view=false |
| SQL видно | активний профіль sql-trace, логи org.hibernate.SQL з’являються |
| Параметри видно | активний логер org.hibernate.orm.jdbc.bind |
| Statistics рахує | профіль stats активний, лічильники виводяться |
| Smoke-сценарій справді сходив у БД | є count() / findById() і зрозумілий вивід у консолі |
Це і є перший відчутний результат дня. Його можна використовувати не лише сьогодні. Він стане в пригоді щоразу, коли в курсі знадобиться зрозуміти, чи справді ви спостерігаєте поведінку ORM, а не просто дивитеся на красивий Java-код без зворотного зв’язку від бази.
8. Типові помилки й міст до наступного кроку 🚧
Помилка №1: зупинитися на факті старту застосунку.
Boot-застосунок може стартувати, а корисного сигналу від шару даних ви при цьому так і не отримаєте. Без smoke-сценарію й логів ви все ще працюєте майже наосліп.
Помилка №2: увімкнути лише SQL, але не bind values.
Текст запиту сам по собі корисний, але дуже часто половина сенсу сидить саме в реальних параметрах. Без них діагностика швидко стає неповною.
Помилка №3: залишити ddl-auto=update заради зручності.
Це миттєво підриває довіру до схеми. А курс, який вчить пов’язувати Java і SQL, не може дозволити собі плаваючу структуру БД.
Помилка №4: тримати всі логи ввімкненими постійно й перестати їх читати.
Профілі потрібні саме для того, щоб спостережуваність вмикалася усвідомлено й не перетворювалася на білий шум.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ