1. Mikroskop dla kursu 🔬
Dni przygotowawcze w kursach technicznych mają złą reputację. Często zasłużoną: pół dnia ucieka na instalację, ustawienia i walkę ze środowiskiem, a wartości edukacyjnej prawie brak. W Hibernate deep-dive staramy się tego unikać. Dlatego dzisiejszy cel to nie tylko „odpalić projekt", ale zbudować stend diagnostyczny, na którym każdy przyszły scenariusz ORM będzie widoczny od razu w kilku wymiarach: jako kod, migracje, SQL, bind values i statystyki Hibernate. To już zupełnie inny poziom korzyści.
Dlaczego to tak ważne? Bo bez obserwowalności deep-dive bardzo szybko zamienia się w zgadywanie. Widzisz dziwne zachowanie, zakładasz przyczynę, zmieniasz adnotację, znowu masz nadzieję na lepsze — i żyjesz w trybie szamaństwa. Normalny kurs inżynierski powinien uczyć odwrotności: najpierw zbudować środowisko, gdzie zachowanie jest widoczne, a dopiero potem wyciągać wnioski.
Tu pojawia się pierwszy mocny moment tego kursu. Nie kończymy poziomu słowami „no, masz teraz projekt". Kończymy go stendem, w którym to samo wywołanie jest naprawdę widoczne i jako operacja biznesowa, i jako SQL, i jako parametry, i jako liczniki ORM. To bardzo praktyczny punkt startowy.
2. PostgreSQL przez Docker Compose 🐘
Lokalna baza w naszym kursie jest podnoszona przez Docker Compose. To nie jest dekoracyjne komplikowanie i nie „żeby wyglądało jak dorosła praca". To najprostszy sposób na odtwarzalne środowisko. Jeśli jeden student pracuje z lokalnym PostgreSQL w systemie, drugi z kontenerem, trzeci z H2 „na razie dla wygody", a czwarty nie jest nawet pewien, do której bazy się podłączył, to dalej nie dyskutujemy o Hibernate, lecz o obcych środowiskach.
Minimalny docker-compose.yml dla kursu może wyglądać tak:
services:
postgres: # główny serwis z PostgreSQL
image: postgres:18 # ustalamy wersję, żeby środowisko było odtwarzalne
environment:
POSTGRES_DB: commerce # nazwa bazy, która zostanie utworzona przy starcie kontenera
POSTGRES_USER: commerce # użytkownik bazy
POSTGRES_PASSWORD: commerce # hasło użytkownika (dla kursu — prosta kombinacja)
ports:
- "5432:5432" # mapowanie portu: na zewnątrz localhost:5432 -> wewnątrz kontenera :5432
volumes:
- commerce_pgdata:/var/lib/postgresql/data # przechowujemy dane poza kontenerem (persist)
volumes:
commerce_pgdata: # nazwany volume dla danych PostgreSQL
Tu niemal wszystko jest przejrzyste. Kontener tworzy bazę commerce, użytkownika commerce i hasło commerce. Port jest mapowany na zewnątrz na standardowy 5432, żeby aplikacja Boot mogła się podłączać do localhost:5432. Volume jest potrzebny, żeby dane nie znikały przy każdym odtworzeniu kontenera. To wygodne w zwykłej lokalnej pracy i dobrze łączy się z ideą stend'u edukacyjnego.
Uruchomienie — standardowe:
docker compose up -d # podnieść serwisy w tle (detached)
docker compose ps # sprawdzić, że kontener naprawdę jest uruchomiony i w statusie Up
Jeśli chcesz upewnić się, że kontener rzeczywiście żyje, a nie tylko istnieje w wyobraźni, możesz obejrzeć logi:
docker compose logs -f postgres # obserwować logi konkretnego serwisu postgres w trybie follow
Dobry lokalny baseline jest nudny. Nie ma w nim niespodziewanych portów, nie ma „specjalnego ręcznego setup", nie ma osobnej instrukcji na każdy laptop. I to jest dokładnie to, czego potrzebujemy. Im środowisko nudniejsze i stabilniejsze, tym ciekawsze jest obserwowane zachowanie ORM.
3. Podstawowa konfiguracja i profile: local, sql-trace, stats
Kolejny krok to podzielenie trybów uruchomienia tak, żeby środowisko nie zamieniło się w jeden gigantyczny plik YAML z wiecznym szumem. W projekcie są trzy główne profile: local, sql-trace i stats. local odpowiada za zwykłe lokalne uruchomienie i połączenie z PostgreSQL. sql-trace — za widoczność SQL i bind values. stats — za włączenie Hibernate statistics. To bardzo proste podział, ale od razu czyni stend zarządzalnym. 📌
Podstawowe reguły kursu wygodnie trzymać w application.yml:
spring:
application:
name: commerce-persistence-lab # nazwa aplikacji (dla logów/metryk/identyfikacji)
jpa:
open-in-view: false # nie trzymamy persistence context otwartego "do widoku"
hibernate:
ddl-auto: none # schemą NIE generuje Hibernate, tylko Flyway
flyway:
enabled: true # migracje włączone jako podstawowy mechanizm zarządzania schematem
Tu ważne są trzy linijki. open-in-view: false od razu ustala, że praca z bazą powinna żyć wewnątrz zrozumiałych granic backendowych, a nie wyciekać „gdzieś dalej". ddl-auto: none oznacza, że Hibernate nie zarządza schematem samodzielnie. Schemat żyje tylko przez Flyway. I flyway.enabled: true podkreśla, że migracje to nie opcja dodatkowa, lecz część podstawowego trybu projektu.
Profil local zazwyczaj przechowuje połączenie z bazą:
spring:
config:
activate:
on-profile: local # ten blok stosuje się tylko przy aktywnym profilu local
datasource:
url: jdbc:postgresql://localhost:5432/commerce # łączymy się z postgres z docker compose
username: commerce # powinno zgadzać się z POSTGRES_USER
password: commerce # powinno zgadzać się z POSTGRES_PASSWORD
Profil sql-trace włącza widoczność SQL i parametrów:
spring:
config:
activate:
on-profile: sql-trace # włączamy ten blok tylko gdy chcemy widzieć SQL/parametry
logging:
level:
org.hibernate.SQL: debug # wypisujemy SQL generowany przez Hibernate
org.hibernate.orm.jdbc.bind: trace # wypisujemy bind values (co dokładnie podstawiło się w '?')
A profil stats włącza zbieranie statystyk:
spring:
config:
activate:
on-profile: stats # włączamy statystyki tylko gdy potrzebne (nie szumimy zawsze)
jpa:
properties:
hibernate.generate_statistics: true # Hibernate zaczyna zbierać Statistics (liczniki)
Takie rozwiązanie jest bardzo przydatne metodycznie. Możesz uruchamiać aplikację po prostu w local, gdy potrzebujesz cichego trybu. I możesz dodawać sql-trace,stats, gdy zaczynasz analizować zachowanie. Szum włącza się świadomie, a nie wisi w tle zawsze. To dojrzały nawyk, który świetnie przenosi się na realną pracę programistyczną.
4. Flyway: schemat powinien być widoczny
W kursie deep-dive o Hibernate schemat bazy to nie techniczna cień klas Java. To pełnoprawna część systemu. Jeśli nie kontrolujesz, jakie tabele, ograniczenia i indeksy rzeczywiście istnieją w PostgreSQL, to generated SQL jest trudny do interpretacji. Właśnie dlatego projekt zasadniczo żyje na Flyway, a nie na ddl-auto=update.
Niech startowa migracja wygląda bardzo prosto:
-- V1__baseline_catalog.sql
create table product (
id bigint primary key, -- jawny PK (ważny dla ORM i czytelności modelu)
sku varchar(64) not null unique, -- identyfikator biznesowy, NOT NULL + UNIQUE
name varchar(255) not null -- nazwa produktu, też NOT NULL dla prostego baseline
);
-- dane seed: żeby już od pierwszego uruchomienia było co czytać z bazy
insert into product(id, sku, name)
values (1, 'SKU-0001', 'Coffee mug');
To już wystarczy, żeby zobaczyć, jak aplikacja stosuje schemat i dostaje dane seed. Po starcie możesz sprawdzić, że Flyway rzeczywiście zadziałał, nie tylko po logach aplikacji Boot, ale bezpośrednio w bazie. To dobry gest anty-magiczny. Na przykład:
docker compose exec postgres psql -U commerce -d commerce \ # wejść do kontenera i wykonać polecenie psql
-c "select installed_rank, version, description, success from flyway_schema_history order by installed_rank;" # sprawdzić historię migracji
Jeśli tabela flyway_schema_history istnieje i pokazuje pomyślnie zastosowane migracje, to znaczy, że schemat żyje dokładnie tak, jak zaplanowano. A żeby sprawdzić obecność danych startowych, można zrobić jeszcze jedno uczciwe zapytanie:
docker compose exec postgres psql -U commerce -d commerce \
-c "select count(*) from product;" # sprawdzamy, że dane seed zostały wstawione
Ten moment wydaje się „drobnym ustawieniem", ale w rzeczywistości buduje zaufanie do kursu. Nie masz nadziei, że schemat wstał. Widzisz, jak i czym wstał. A to znaczy, że generated SQL możesz czytać na solidnym fundamencie, a nie na domysłach.
5. Hibernate statistics i serwis smoke
Teraz trzeba dodać dwa małe kawałki techniczne, które dadzą nam realną obserwowalność. Pierwszy — dostęp do Statistics przez bieżący SessionFactory. Drugi — prosty serwis smoke, który wykonuje kilka operacji odczytu i wypisuje minimalny raport. To jak pierwsze uruchomienie laboratorium: nie robimy nic skomplikowanego, ale od razu widzimy, że diagnostyka naprawdę działa.
Konfiguracja dla Statistics może wyglądać tak:
@Configuration
class HibernateStatisticsConfig {
@Bean
Statistics hibernateStatistics(EntityManagerFactory emf) {
// unwrap potrzebny, żeby dostać się do "natywnego" SessionFactory Hibernate z opakowania JPA
return emf.unwrap(SessionFactory.class).getStatistics(); // pobieramy jedyny obiekt Statistics
}
}
Dalej mały serwis do czyszczenia i wypisywania liczników:
@Service
class HibernateStatsPrinter {
private final Statistics stats; // tu jest wstrzykiwane Statistics, jeśli aktywny profil stats
HibernateStatsPrinter(Statistics stats) {
this.stats = stats;
}
public void clear() {
stats.clear(); // zerujemy liczniki, żeby liczyć tylko bieżący scenariusz
}
public void print() {
// Prepared statements: ile wyrażeń JDBC przygotował Hibernate
System.out.println("Prepared statements = " + stats.getPrepareStatementCount());
// Entity loads: ile encji naprawdę się załadowało (przydatne do szukania N+1 i zbędnych odczytów)
System.out.println("Entity loads = " + stats.getEntityLoadCount());
// Flushes: ile razy nastąpił flush (ważne, żeby rozumieć, kiedy Hibernate pisze do bazy)
System.out.println("Flushes = " + stats.getFlushCount());
}
}
A teraz — sam scenariusz smoke. Ważne, żeby to była normalna operacja backendowa, a nie przypadkowy static helper. Niech więc żyje jako zwykły serwis:
@Service
class StartupSmokeService {
private final ProductRepository products; // repozytorium Spring Data do dostępu do product
private final HibernateStatsPrinter statsPrinter; // wypisywanie statystyk po scenariuszu
StartupSmokeService(ProductRepository products, HibernateStatsPrinter statsPrinter) {
this.products = products;
this.statsPrinter = statsPrinter;
}
@Transactional(readOnly = true) // ważny moment: czytamy w transakcji (baseline dla JPA)
public void run() {
statsPrinter.clear(); // zaczynamy od "czystej tablicy"
long count = products.count(); // szybkie zapytanie, żeby zobaczyć SQL i przygotowanie wyrażenia
System.out.println("Products in DB = " + count);
// pobieranie po PK: tu właśnie pojawi się parametr bind (id = ?)
products.findById(1L).ifPresent(product ->
System.out.println("Loaded product sku = " + product.getSku())
);
statsPrinter.print(); // wypisujemy liczniki Hibernate dokładnie dla tego scenariusza
}
}
Żeby to wszystko naprawdę wykonało się przy starcie, wygodnie podpiąć CommandLineRunner:
@Configuration
class SmokeStartupConfig {
@Bean
CommandLineRunner smokeRunner(StartupSmokeService smokeService) {
// CommandLineRunner uruchamia się zaraz po starcie kontekstu Spring Boot
return args -> smokeService.run(); // wywołujemy nasz scenariusz smoke
}
}
Tu prawie nie ma logiki biznesowej i to normalne. Cel nie w tym, żeby „zrobić feature". Cel w tym, żeby udowodnić: projekt naprawdę startuje, naprawdę stosuje migracje, naprawdę komunikuje się z PostgreSQL, naprawdę pisze SQL do logu i naprawdę oddaje statystyki dla tej operacji. To właśnie pierwsza robocza wygrana dzisiejszego dnia.
6. Co powinieneś zobaczyć w logach i konsoli 👀
Teraz uruchamiamy aplikację w pełnym trybie diagnostycznym:
./gradlew bootRun --args="--spring.profiles.active=local,sql-trace,stats" # aktywujemy profile: baza + SQL/bind + statystyki
Jeśli uruchamiasz z IDE, sens pozostaje ten sam: muszą być aktywne profile local, sql-trace, stats. Ważne nie tyle skąd uruchamiasz, ile żeby uruchomienie było odtwarzalne i zgodne z baseline kursu.
Po starcie powinieneś zobaczyć mniej więcej taką sekwencję zdarzeń. Najpierw podnosi się datasource. Potem Flyway informuje o zastosowanych migracjach. Następnie odpala się serwis smoke. I właśnie tu pojawiają się główne sygnały:
# Poniżej — przykład kluczowych linii (mogą się różnić formatowaniem, ale sens ten sam)
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]
Products in DB = 1
Loaded product sku = SKU-0001
Prepared statements = 2
Entity loads = 1
Flushes = 0
Co tu ważne zobaczyć wzrokiem? Po pierwsze, SQL rzeczywiście jest widoczny. Po drugie, bind value jest widoczna. Czyli ? w zapytaniu nie pozostaje abstrakcją — widzisz konkretny parametr 1. Po trzecie, statystyki rzeczywiście liczą pracę Hibernate, a nie istnieją „gdzieś w teorii". Po czwarte, serwis smoke potwierdza, że operacja naprawdę doszła do bazy i odczytała dane.
Oto mocny moment dzisiejszego dnia: jeden maleńki use case jest już widoczny od razu w kilku wymiarach. Masz kod Java. Masz migrację, która stworzyła tabelę i seed. Masz SQL. Masz bind parameters. Masz liczniki Hibernate. To jest dokładnie ten rodzaj praktycznego wyniku, który jest potrzebny kursowi deep-dive. Nie „aplikacja jakoś wystartowała", ale „mam działający stend diagnostyczny". 🔬
7. Lista kontrolna działającego stend'u diagnostycznego
Żeby ten wynik można było przenosić dalej przez kurs, warto ustalić krótką listę kontrolną. Jeśli kiedyś po drodze przestaniesz widzieć jeden z tych sygnałów, to już powód, by nie analizować Hibernate dalej, lecz najpierw przywrócić sam stend.
| Co sprawdzić | Znak, że wszystko w porządku |
|---|---|
| PostgreSQL jest podniesiony | docker compose ps pokazuje żywy kontener postgres |
| Aplikacja podłączyła się do właściwej bazy | datasource wskazuje na jdbc:postgresql://localhost:5432/commerce |
| Schemat stworzony przez Flyway | istnieje tabela flyway_schema_history, migracje oznaczone jako pomyślne |
| ORM nie zmienia schematu sam | ddl-auto=none |
| Kontekst nie jest sztucznie rozciągnięty | open-in-view=false |
| SQL jest widoczny | aktywny profil sql-trace, logi org.hibernate.SQL się pojawiają |
| Parametry są widoczne | aktywny logger org.hibernate.orm.jdbc.bind |
| Statistics liczy | profil stats aktywny, liczniki są wypisywane |
| Scenariusz smoke naprawdę trafił do bazy | jest count() / findById() i zrozumiały wydruk w konsoli |
To właśnie jest pierwszy namacalny wynik dnia. Można go używać nie tylko dziś. Przyda się za każdym razem, gdy w kursie trzeba będzie zrozumieć, czy rzeczywiście obserwujesz zachowanie ORM, czy tylko patrzysz na piękny kod Java bez informacji zwrotnej z bazy.
8. Typowe błędy i most do następnego kroku 🚧
Błąd nr 1: zatrzymanie się na fakcie startu aplikacji.
Aplikacja Boot może wystartować, a użytecznego sygnału z data-layer i tak nie dostaniesz. Bez scenariusza smoke i logów wciąż pracujesz prawie w ciemno.
Błąd nr 2: włączenie tylko SQL, ale nie bind values.
Tekst zapytania sam w sobie jest przydatny, ale bardzo często połowa sensu siedzi właśnie w realnych parametrach. Bez nich diagnostyka szybko staje się niepełna.
Błąd nr 3: pozostawienie ddl-auto=update dla wygody.
To natychmiast podważa zaufanie do schematu. A kurs, który uczy łączenia Javy z SQL, nie może sobie pozwolić na płynną strukturę bazy.
Błąd nr 4: trzymanie wszystkich logów włączonych zawsze i przestanie ich czytać.
Profile są właśnie po to, żeby obserwowalność włączała się świadomie i nie zamieniała się w biały szum.
GO TO FULL VERSION