avagy: már megint a három és négy betűs rövidítések!

Ebben a cikkben megpróbálok utánajárni a Spring adatbázis kezelő moduljainak és a hozzájuk tartozó tranzakciókezelésének az alapoktól: a jó öreg JDBC-től indulva. Több évnyi Spring-ben fejlesztés után is lehetnek még olyan rétegei a témának amik nem teljesen egyértelműek vagy pedig segítik a nagy kép megértését. A cikk már Spring-ben jártasabb fejlesztőknek szól, tehát a Java, a Spring, az adatbázis tranzakciók alapfokú ismerete szükséges. Az elosztott és a reaktív tranzakciókat ebben a cikkben nem tárgyalom.

A cikk nagyrészt az itt található cikkeken alapul, ezeket egészítettem ki és gyúrtam össze egy nagyobb írássá. További hivatkozások a cikk végén és a szövegben. Felhasználtam természetesen a Java hivatalos API dokumentációját is. Az alapoktól indulok és lépésről lépésre jutunk el az adatbázis tranzakciókezelésig, minden absztrakciós réteget tárgyalva.

A cikkben található példakódokat Java 21 alatt teszteltem, a Spring példákat Spring Boot 3.3.2 alatt, MySQL 8-as adatbázist használva.

A jó öreg JDBC

Kezdjük az elején: minden Java alkalmazás, akár Spring akár nem, akár szerveroldali akár nem, közös módon csatlakozik egy SQL adatbázishoz, ez pedig a JDBC API (JDBC = Java Database Connectivity). Az adatbázis bármi lehet: MySQL, SQLite, MS SQL, Oracle vagy bármi más, de a JDBC mindig ott lesz. Ehhez semmiféle JDBC API-t nem is kell telepíteni vagy külső osztálykönyvtárként (library) behúzni a projektünkbe mert a JDBC minden JDK/JRE alapfelszereltsége. Egyetlen dologra van csak pluszban szükségünk: egy JDBC driver ahhoz az adatbázishoz amihez csatlakozni szeretnénk. Ezeket a dolgokat szerintem már minden Java fejlesztő használta, de jó ha itt tisztázzuk még egyszer az alapokat.

A Java 21-ben a Java 9 óta meglévő 4.3-as verziójú JDBC API van, ez visszafelé kompatibilis az összes korábbi verzióval. A JDBC API ebben a két package-ben lakik:

  1. java.sql: az alap API, az adatbázis-csatlakozás a DriverManager osztályon keresztül történik
  2. javax.sql: szerveroldali feldolgozáshoz szükséges API kiegészítések (például a connection pool-ozáshoz és elosztott tranzakciókhoz szükséges dolgok). Itt találjuk a DataSource interfészt ami a java.sql.DriverManager alternatívája. A hivatalos álláspont szerint inkább ezt javasolt használni a DriverManager helyett.

A JDBC drivert általában annak az adatbázisnak a gyártója publikálja amelyik adatbázishoz kapcsolódni szeretnénk. A driverek elég sok mindent csinálnak, kezdve az alkalmazásunktól az adatbázis felé irányuló connection megnyitásától az SQL lekérdezések továbbításán át az eredmények lekérdezéséig. De még olyan fejlett szolgáltatásokat is tudhatnak mint például események fogadása az adatbázistól (Oracle).

Ha Mavent vagy Gradle-t használunk akkor nincs más teendőnk, mint hozzáadni függőségként a JDBC drivert a projektünkhöz. Itt szinte minden JDBC driverhez megtaláljuk a Maven konfigurációt. Ha ezzel megvagyunk akkor már nyithatjuk is a jobbnál jobb kapcsolatokat az adatbázisunk felé:

import java.sql.Connection;

import java.sql.DriverManager;

import java.sql.SQLException;

public class AppWithJdbc {

public static void main(String[] args) throws SQLException {

// A JDBC API-ból használjuk a DriverManager-t

try (Connection conn = DriverManager.getConnection(„jdbc:mysql://localhost/test?serverTimezone=UTC”,

„username”, „password”)) {

// A 0 használatával kiiktatjuk a timeout-ot amikor az isValid ellenőrzést végezzük

boolean isValid = conn.isValid(0);

System.out.println(„Van élő DB szerver kapcsolatunk?: „ + isValid);

// itt pedig már akár futtathatnánk is fincsi SQL lekérdezéseket

}

}

}

A kapcsolat létrehozásának legegyszerűbb módja a DriverManager.getConnection, ami automatikusan megtalálja a korábban a projekthez adott JDBC drivert. A paraméterek egyértelműek, az első, url paraméterről jó tudni, hogy mindig egy „jdbc” prefixszel kezdődik, ezt követi egy kettőspont, majd az adatbázis azonosítója következik (mysql vagy oracle, stb), utána megint kettőspont, majd pedig adatbázis-specifikus connection sztring következik. Ennek formáját az adatbázis dokumentációja kell, hogy tartalmazza, de itt is van egy jó lista róluk.

SQL utasítások futtatásába nem megyek bele mélyebben, a java.sql.PreparedStatement interfészről biztosan hallott már mindenki. Ezt használhatjuk a JDBC API-n belül SQL utasítások futtatására. Csak egy példa:

// A JDBC API-ból használjuk a DriverManager-t

try (Connection conn = DriverManager.getConnection(„jdbc:mysql://localhost/test?serverTimezone=UTC”,

„username„, „password”)) {

// A 0 használatával kiiktatjuk a timeout-ot amikor az isValid ellenőrzést csináljuk

boolean isValid = conn.isValid(0);

System.out.println(„Van élő DB szerver kapcsolatunk?: „ + isValid);

// csinálunk egy selectet, majd lefuttatjuk

// a ?-paraméter és a megfelelő statement setterek használatával védekezünk az SQL injection támadások ellen

PreparedStatement selectStatement = conn.prepareStatement(„select * from products where product_name =  ?”);

selectStatement.setString(1, „hangfal”);

// a futás után egy ResultSet-et kapunk az eredménnyel

ResultSet rs = selectStatement.executeQuery();

// az összes sort kilistázzuk

while (rs.next()) {

String productName = rs.getString(„product_name”);

String description = rs.getString(„description”);

BigDecimal price = rs.getBigDecimal(„price”);

System.out.println(„productName = ” + productName + „, description= „ + description + „, price= „ + price);

}

}

És itt következik egy nagyon fontos dolog: a tranzakciókezelés. Az alap JDBC tranzakciókezelés így néz ki:

Connection connection = DriverManager.getConnection(„jdbc:mysql://localhost/test?serverTimezone=UTC”,

„username”, „password”);// 1

try (connection) {

connection.setAutoCommit(false); // 2

// itt hajthatjuk végre az SQL műveleteinket

connection.commit(); // 3

} catch (SQLException e) {

connection.rollback(); // 4

}

  1. a tranzakciókhoz is kell egy connection. A már ismert módon létrehozunk egyet. Szerveres környezetben az alkalmazásunknak nyilván lesz egy előre konfigurált data soruce-a és a connection-t attól is el tudjuk kérni.
  2. ez az egyetlen módja annak, hogy Java-ban adatbázis tranzakciót indítsunk! Még úgy is, hogy a neve kicsit félrevezető. A .setAutoCommit(true) (ami az alapértelmezett) esetben minden egyes SQL művelet automatikusan becsomagolódik egy saját tranzakcióba, a .setAutoCommit(false) pedig pont az ellenkezőjét csinálja: mi kezeljük a tranzakciókat és nekünk kell meghívnunk a commit-ot és társait. Az autoCommit flag a connection megnyitott ideje alatt végig érvényes, vagyis a beállítást csak egyszer kell meghívnunk.
  3. kommitoljuk a tranzakciónkat
  4. vagy rollback-eljük a változásokat ha kivételt kaptunk

És ez a 4 sor (leegyszerűsítve) az amit a Spring is csinál amikor a @Transactional annotációt használjuk. Persze mire odáig eljutunk még sok mindent meg kell ismerni, de nem számít, hogy végül majd a Spring @Transactional annotációját használjuk, vagy Hibernate-et, jOOQ-t vagy bármilyen más adatbáziskezelő keretrendszert. A végén lényegében mind ugyanezt a dolgot csinálja ahhoz, hogy megnyisson és lezárjon (mondjuk úgy: kezeljen) adatbázis tranzakciókat.

JDBC connection pool-ok

Azt is mindenki tudja, hogy az adatbázis-kapcsolatok megnyitása és lezárása a DriverManager.getConnection használatával eléggé lassú művelet (néhány JDBC driver esetén a setAutoCommit(false) is). Nem célszerű például egy webes alkalmazásban állandóan kapcsolatokat létrehozni majd lezárni minden egyes frontendi kérésnél. Erre jók a connection pool-ok. Egy connection pool kisszámú (például 10) adatbázis kapcsolatot folyamatosan nyitva tart, az alkalmazásunk pedig a pool-tól tud connection-t kérni, majd a használat után visszaadja a pool-nak.

Java-ban az évtizedek során sok connection pool megoldás keletkezett, ezek közül sok régebbit eléggé körülményes használni. Spring Boot környezetben a HikariCP az alapértelmezett ami viszont nagyon jó és az egyik legelterjedtebb. (Oracle adatbázisokhoz az Oracle UCP is egész jó.)

Ha megvan a connection pool akkor ezután a kódban nem mi nyitjuk és zárjuk a kapcsolatot a DriverManager-en keresztül, hanem egy connection pool jön létre. A pool-t a javax.sql.DataSource interfészen keresztül érjük el és attól kérünk adatbázis kapcsolatot.

Kell egy Maven dependency is a HikariCP használatához:

<dependency>

<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.1.0</version>

</dependency>

Az alábbi kód hasonló a DriverManager-t használóhoz, csak éppen itt már a DataSource-t használjuk a DriverManager helyett.

import java.sql.Connection;

import java.sql.SQLException;

import javax.sql.DataSource;

import com.zaxxer.hikari.HikariDataSource;

public class PoolExample {

public static void main(String[] args) throws SQLException {

DataSource dataSource = createDataSource();

try (Connection conn = dataSource.getConnection()) {

boolean isValid = conn.isValid(0);

System.out.println(„Van élő DB szerver kapcsolatunk?: „ + isValid);

// itt pedig már akár futtathatnánk is fincsi SQL lekérdezéseket

}

}

 

private static DataSource createDataSource() {

HikariDataSource ds = new HikariDataSource();

ds.setJdbcUrl(„jdbc:mysql://localhost/test?serverTimezone=UTC”);

ds.setUsername(„username”);

ds.setPassword(„password”);

return ds;

}

}

Látható, hogy itt a DataSource-tól kérjük el a kapcsolatot. Amikor ezt először tesszük meg, akkor a színfalak mögött inicializálja a pool-t, megnyit alapértelmezetten 10 kapcsolatot és az egyiket visszaadja. A connection pool könyvtárak, mint a HikariCP, konfigurációtól függően automatikusan bekapcsolhatják az autocommit flag-et. Ha a createDataSource részben beírjuk, hogy ds.setAutoCommit(true); akkor ugyanúgy kezelnünk kell a tranzakciónkat mint ahogy korábban láttuk, csak az autoCommit flag-et már a HikariCP állítja be a connection-ön. A connection lezárásakor egyébként annyi történik, hogy a HikariCP visszateszi a használható szabad connection-ök közé. Ha épp mind az alapértelmezetten 10 kapcsolatot használjuk akkor újat nem kapunk addig a Hikari-tól míg a futó programunk valamelyik connection-t le nem zárja (vagyis visszaadja a Hikari-nak).

Na most már van adatbázis kapcsolatunk, connection pool-unk és tudunk SQL utasításokat futtatni. Ez viszont még nagyon nyers, alacsony szintű dolog. Java-ban sokkal komfortosabb és fejlesztési szempontból hatékonyabb valami objektum-orientáltsághoz közelebb álló dologgal kezelni a perzisztenciát mint a nyers JDBC hívásokkal és ResultSet-ekkel.

Java SQL könyvtárak

Az alábbiakban bemutatok néhány könnyűsúlyú osztálykönyvtárat amik megpróbálják a nyers ResultSet-es megközelítést emberbarátibbá tenni. Ezek igazából nem is feltétlenül nagy új projektekhez valók, hanem akkor működnek jól, ha már van egy korábbról létező adatbázisunk és ahhoz szeretnénk gyorsan csatlakozni, vagy pedig új projektek esetén akkor ha elsődlegesen az adatbázist terveztük meg és még nem is készültek hozzá Java osztályok. Az első ilyen, a jOOQ ezt nevezi database-first megközelítésnek. Itt nem célom részletekbe menően mindet bemutatni, csak egy áttekintést adni, hogy milyen megoldások léteznek.

jOOQ

Jól karbantartott és népszerű könyvtár. Használata alapvetően három lépésből áll:

  1. a jOOQ kódgenerátorával csatlakozunk az adatbázishoz ami Java osztályokat generál, ezek az adatbázisunk tábláit és oszlopait reprezentálják
  2. JDBC-ben való SQL utasítások írása helyett ezeket a Java osztályokat tudjuk használni SQL lekérdezések létrehozásához „fluent API”-val
  3. ezeket a lekérdezéseket a jOOQ valódi SQL utasításokká fordítja, amiket lefuttat, majd az eredményt visszamap-peli Java kóddá.

Egyszerű példa (a honlapjuk kezdőoldaláról vettem):

// SQL:

SELECT TITLE FROM BOOK WHERE BOOK.PUBLISHED_IN = 2011 ORDER BY BOOK.TITLE

// Ezt ilyen jOOQ módon lehet megírni:

create.select(BOOK.TITLE)
.from(BOOK)
.where(BOOK.PUBLISHED_IN.eq(2011))
.orderBy(BOOK.TITLE)

A jOOQ nem csak SQL utasítások futtatására jó, hanem segít a CRUD (create, update, delete) műveletekben is; az adatbázisrekordok és a Java POJO-k közötti map-peléssel.

MyBatis

Egy másik népszerű és jól karbantartott database-first osztálykönyvtár. Ez az SqlSessionFactory koncepcióra épít (nem összekeverendő a később tárgyalandó Hibernate SessionFactory-val). Amikor létrehoztuk a kis SessionFactory példányunkat, már tudunk vele SQL utasításokat futtatni. Ezeket vagy XML fájlokban vagy pedig annotációkban tudjuk megadni neki.

A MyBatis tud mappelni is adatbázis rekordok és Java objektumok között de automatikusan csak egyszerű esetekben, egyébként nekünk kell bekonfigurálni a mappelést XML fájlokban. Emellett nagyon támogatja az ún. dinamikus SQL képességeket is, ami azt jelenti, hogy XML konfig segítségével összetett és dinamikus SQL sztringeket tudunk létrehozni.

Jdbi

Egy szintén karbantartott kis alacsony szintű könyvtár a JDBC-re építve. Az API-ja két változatban is létezik:
– fluent API
– declarative API

SimpleFlatMapper

Egy kicsit gyengébben dokumentált de jó kis könyvtár ami segít a JDBC ResultSet-ek vagy jOOQ rekordok és POJO-k közötti mappelésben. Mivel ez lényegében csak egy mapper, nagyon sok keretrendszerrel együtt tud működni, akár a jOOQ-val, queryDSL-lel vagy Springgel.

Java ORM keretrendszerek

Ha Java-ban fejlesztünk akkor SQL utasításoknál sokkal kézreállóbbak a Java osztályok. Sok projekt eleve java-first mentalitással készül, vagyis először a Java oldali rész koncepciója készül el és csak utána az adatbázis. És itt jön be a kérdés (amit az angol object-relational mappingnak hív): hogyan feleltessük meg a már létező új Java osztályainkat a még ezt követően létrehozandó adatbázisunknak? Itt jönnek be a képbe a komplex ORM (object-relational mapping) keretrendszerek, mint amilyen a Hibernate vagy más JPA implementáció.

Mi az a JPA?

A JPA a Jakarta Persistence (korábban: Java Persistence) API rövidítése. Ez igazából csak egy specifikáció, vagyis egy szabványt definiál. Ennek kell egy libnek megfelelni, hogy JPA-képes legyen. Az egyik ilyen JPA implementáció a Hibernate de van más is, például az EclipseLink vagy TopLink. Tehát nem kell Hibernate-specifikus vagy EclipseLink-specifikus kódot írni, mert írhatunk JPA-specifikus kódot is. Ezután pedig ha felveszünk valamilyen könyvtárat és konfigurációs fájlt a JPA-t használó projektünkhöz akkor (az elmélet szerint) elérhetjük az adatbázist.

A JPA nevekben van egy kis kavarodás, ezért érdemes felsorolni a verzióit:

  • JPA 1.0: megjelent: 2006
  • JPA 2.0: megjelent: 2009
  • JPA 2.1: megjelent: 2013
  • JPA 2.2: megjelent: 2017
  • Jakarta Persistence 3.0: megjelent: 2020
  • Jakarta Persistence 3.1: megjelent: 2022
  • Jakarta Persistence 3.2: megjelent: 2024. május

A JPA a Java Enterprise Edition (Java EE) keretrendszer része volt, 2017-ben viszont a Oracle úgy döntött, hogy az akkor már 8-as verziónál járó Java EE-t nyílt forrásúvá teszi, és az egész átkerült az Eclipse Foundation alá. Mivel a Java nevet viszont az Oracle megtartotta, át kellett nevezni valamire és a Jakarta EE nevet találták ki (Jakarta egy nagyváros Indonéziában a Jáva-tenger partján ).

A verziózás folytonos maradt, így a Jakarta EE 8 2019-ben jelent meg. Onnantól kezdve tehát nincs új Java EE csak Jakarta EE. Az összes, korábban javax package alatt lévő Java EE API megmaradt, viszont a package-et is átnevezték javax-ről jakarta-ra. Persze azok az API-k amik nem voltak a Java EE része, mint például a JDBC, maradtak javax alatt.

Ezzel a váltással a régi Java EE technológiák nevezéktanában is nagy tisztogatást végeztek, így lett a Java Persistence API-ból Jakarta Persistence (API nélkül). Ugyanakkor még az Eclipse Foundation is néha Java Persistence API-nak hívja, így a JPA rövidítést végülis sikerült megtartani. A Spring keretrendszer egyébként a Spring Boot 2-ről 3-ra váltással cserélte ki a Java EE részeket Jakarta EE-re, így Spring Boot alatt szintén megváltoztak a csomagnevek.

A gyakorlatban a legelterjedtebb JPA implementáció (persistence provider) a Hibernate, sőt a kettő (mint később látni fogjuk) szorosan összefügg, de főleg vállalati környezetben néha más is megtalálható. A legtöbb esetben a többi osztálykönyvtár viszont ma már elhagyatott és nem fejlesztik őket mert elég nagy meló folyamatosan fenntartani a JPA-megfelelőséget.

A JPA implementációkat persistence providernek is hívják. Ennek az az oka, hogy a Java ökoszisztémában amikor egy szabványos API-t implementálnak, akkor ezt egy SPI (Service Provider Interfaces) nevű rendszerrel szokták megtenni. Mindegyik ilyen API implementáció „gyártójának” meg kell adni (angolul: provide) egy adott komponenst, ami valójában egy interfész, ez az implementáló osztályok belépési pontja. Ez a provider szó eredete.

A JPA esetén a kiindulási pont a jakarta.persistence.spi.PersistenceProvider interfész (vegyük észre az spi részt a package névben!). Mindegyik implementációnak tartalmaznia kell az ezt implementáló osztályt. Az implementáció jar-ban (Hibernate esetén a hibernate-core-6.5.2.Final.jar) a /META-INF/services/jakarta.persistence.spi.PersistenceProvider fájl tartalmazza a provider implementáció nevét: org.hibernate.jpa.HibernatePersistenceProvider, ami a javadoc szerint „The best-ever implementation of a JPA PersistenceProvider.”

Ezt meg is találjuk a jar-ban:

public class HibernatePersistenceProvider implements PersistenceProvider

Ha a fejlesztők ennyire biztosak benne, hogy ez a valaha volt legjobb implementáció, akkor lássuk is a Hibernate-et a következő fejezetben.

ORM Hibernate-tel

A Hibernate egy nagyon kiforrott ORM könyvtár aminek első verziója még 2001-ben jelent meg, a jelenlegi stabil verzió pedig már a 6.6-os. Nagyon sok könyv íródott már róla, pár mondatban öszefoglalva a célját: a Hibernate segítségével viszonylag könnyen tudunk adatot mappelni adatbázistáblák és Java osztályok között anélkül, hogy nagyon módosítani kellene a mapping-en futás közben. Emellett lehetővé teszi, hogy legalábbis az alap CRUD műveletekhez ne kelljen semmilyen SQL-t írni de az összetettebb lekérdezésekhez is több lehetőséget biztosít:

  • HQL (Hibernate Query Language): az SQL-hez hasonló deklaratív nyelv
  • Criteria API: a JPA Criteria API-jának támogatásával lekérdezések írásának lehetősége Java-ban, objektumorientált módon

A Hibernate részletes használatára nem térek ki, feltételezem, hogy az olvasó már használta. Az alapokat viszont mégis áttekintem, hogy tisztán lássuk majd, hogyan illeszkedik ez az osztálykönyvtár a Java ökoszisztémába, illetve hogyan illeszkedik a Spring tranzakciókezelésébe.

A Hibernate 6.5 használatához az alábbi függőséget kell a Maven projektünkhöz adni:

<dependency>

<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.5.2.Final</version>

</dependency>

A teljes library innentől kezdve az org.hibernate package-ben elérhető. És persze ne feledkezzünk meg a használt adatbázisunkhoz való JDBC driverről sem, mivel a Hibernate is a JDBC-t használja.

A példánkban legyen egy customer táblánk ahol a vásárlókat tartjuk nyilván:

CREATE TABLE main.`customer` (

`id` bigint NOT NULL AUTO_INCREMENT,
`first_name` varchar(256) NOT NULL,
`last_name` varchar(256) NOT NULL,
`address` varchar(1024) NOT NULL,
PRIMARY KEY (`id`)

);

Ehhez legyen a Java entitásunk az alábbi. A Hibernate egybéként régebben XML konfigurációt alkalmazott, de ezt mára már felváltotta a sokkal kényelmesebb annotáció-alapú konfiguráció. Az alábbiakban már jakarta package-ből származó importokat is látunk, ennek magyarázata a következő fejezetben lesz.

package hu.egalizer.hibernate.domain;

 

import jakarta.persistence.Column;

import jakarta.persistence.Entity;

import jakarta.persistence.GeneratedValue;

import jakarta.persistence.GenerationType;

import jakarta.persistence.Id;

import jakarta.persistence.Table;

 

@Entity

@Table(name = „customer”)

public class Customer {

@Id

@GeneratedValue(strategy = GenerationType.IDENTITY)

private Long id;

@Column(name = „first_name”)

private String firstName;

@Column(name = „last_name”)

private String lastName;

@Column(name = „address”)

private String address;

// konstruktorokat, gettereket és settereket elhagyom a rövidség kedvéért

// …

}

Na most már van adatbázisunk és entitás osztályunk, izzítsuk be a Hibernate-et. A Hibernate belépési pontja lényegében mindenhez az ún. SessionFactory, amit nekünk kell konfigurálni. A SessionFactory inicializálását és felépítését bootstrapping-nek nevezik. A SessionFactory megeszi a mapping annotációkat, egy Hibernate session pedig nem más, mint alapvetően egy adatbáziskapcsolat, vagyis egy wrapper a jó öreg JDBC connection körül! Persze minden földi jóval kiegészítve: ezeket a session-öket tudjuk ezután használni HQL és Criteria lekérdezésekhez is.

A Spring az alábbi kódot megcsinálja helyettünk, tehát ilyet ritkán látunk Spring környezetben, de jó ha tudjuk, hogy csupasz Java-n hogy működik a dolog. Egyébként többféle módja is van a Hibernate konfigurálásának és a bootstrapping-nek, ez csak az egyik.

package hu.egalizer.hibernate;

import static java.lang.System.out;

import org.hibernate.SessionFactory;

import org.hibernate.boot.MetadataSources;

import org.hibernate.boot.registry.StandardServiceRegistry;

import org.hibernate.boot.registry.StandardServiceRegistryBuilder;

import org.hibernate.cfg.AvailableSettings;

import hu.egalizer.hibernate.domain.Customer;

public class AppWithHibernate {

private static SessionFactory sessionFactory;

public static void main(String[] args) {

// Egy SessionFactory-t csak egyszer állítunk be egy alkalmazáshoz

final StandardServiceRegistry registry = new StandardServiceRegistryBuilder()

.applySetting(AvailableSettings.JAKARTA_JDBC_URL, „jdbc:mysql://localhost/test?serverTimezone=UTC”)

.applySetting(AvailableSettings.JAKARTA_JDBC_USER, „username”)

.applySetting(AvailableSettings.JAKARTA_JDBC_PASSWORD, „password„)

.build();

try {

sessionFactory = new MetadataSources(registry)

.addAnnotatedClass(Customer.class)

.buildMetadata()

.buildSessionFactory();

// mentsünk entitásokat

sessionFactory.inTransaction(session -> {

session.persist(new Customer(„Teszt”, „Vevő1”, „Utca házszám 1”));

session.persist(new Customer(„Teszt”, „Vevő2”, „Utca házszám 2”));

});

// olvassunk be entitásokat

sessionFactory.inTransaction(session -> {

session.createSelectionQuery(„from Customer”, Customer.class)

.getResultList()

.forEach(customer -> out.println(customer));

});

} catch (Exception e) {

e.printStackTrace();

} finally {

if (sessionFactory != null) {

sessionFactory.close();

}

StandardServiceRegistryBuilder.destroy(registry);

}

}

}

A SessionFactory-hoz előbb kell egy ServiceRegistry. Ez lehetővé teszi mindenféle service-ek kezelését, amik kellenek a Hibernate-hez. A ServiceRegistry lényegében egy egyszerű dependency injection eszköz ahol a bean-ek mind Service típusúak (egyébként a Java SPI-n alapul). A StandardServiceRegistryBuilder osztállyal csinálunk egy ServiceRegistry-t, a paramétereket itt a kódban adom meg de persze jöhetnének property-ből is. A Hibernate-ben kezdetben mindent XML-ben kellett konfigurálni, ez a visszamenőleges kompatibilitás miatt még mindig működik, de ma már nem használjuk.

Ezután meg kell adnunk az alkalmazásunk domain modelljét és az adatbázis mapping-et (entitások; nálunk a példában csak egy van). Erre való a MetadataSources osztály. Ez létrehoz egy Metadata példányt, abból pedig létrehozzuk a SessionFactory-t. Ezzel már nyithatunk Hibernate session-öket és perzisztálhatunk vagy lekérdezhetünk entitásokat. A SessionFactory egyébként szálbiztos, tehát egy alkalmazáshoz elég belőle egy is.

Az inTransaction metódus a megadott lambdával megnyit egy tranzakciót, elvégzi a műveletet majd kommitolja és lezárja a tranzakciót. De ez csak kényelmi réteg a tranzakciókezelés fölött, ezt mi is megtehetjük ha nyitunk egy session-t (értsd: adatbázis connection-t) a SessionFactory-ból és aztán:

org.hibernate.Transaction tx = null;

Session session = sessionFactory.openSession();

try (session) {

tx = session.getTransaction();

tx.begin();// de a két sor helyett egyben is lehet: tx = session.beginTransaction()

// ide jönnek az entitásainkkal a műveletek. Perzisztálhatunk:

Customer customer = new Customer(„Teszt”, „Vevő1”, „Utca házszám 1”);

session.persist(customer);

// vagy akár szokásos HQL query-t is írhatunk:

List<Customer> result = session

.createSelectionQuery(„select c from Customer c where c.lastName = ‘Vevő1′”, Customer.class)

.getResultList();

// …

tx.commit();

} catch (Exception e) {

if (tx.isActive()) {

tx.rollback();

}

throw e;

}

És ha a mélyére ásunk a Hibernate-nek, akkor pont ugyanazt találjuk amit a JDBC tranzakciókezelésénél láttunk: a Hibernate aktuális verziójában TransactionImpl->JdbcResourceLocalTransactionCoordinatorImpl->AbstractLogicalConnectionImplementor absztrakt osztályban lesz lényegében ugyanaz:

public void begin() {

//…
connection.setAutoCommit( false );
//…

}

public void commit() {

//…
connection.commit();
//…

}

public void rollback() {

//…
connection.rollback();
//…

}

A Hibernate pedig a mélyben szépen lefordítja a HQL lekérdezéseinket, persist-et és minden más Hibernate-specifikus műveletet a használt adatbázisunknak megfelelő SQL kóddá és a már megismert JDBC PreparedStatement-eket és ResultSet-eket használva mappeli az entitásainkat.

Ha pedig a connection pool-ozást is szeretnénk bevonni a játékba, akkor csak annyit kell tenni, hogy a projekthez hozzáadjuk a már ismert connection pool-t (a fentebbi példában a HikariCP), majd a StandardServiceRegistryBuilder-ben még egy sort hozzáadunk a többihez:

.applySetting(AvailableSettings.CONNECTION_PROVIDER, „com.zaxxer.hikari.hibernate.HikariConnectionProvider”)

A Hibernate és a JPA avagy: a JPA és a Hibernate

Már írtam, hogy a JPA lényegében csak egy specifikáció és elméletben lehetővé teszi, hogy ne foglalkozzunk azzal, milyen persistence provider könyvtárat használunk a projektünkben. A gyakorlatban viszont a Hibernate messze a legnépszerűbb JPA implementáció. Sőt, a JPA-ban nagyon sok mindent a Hibernate „inspirált”, hogy úgy mondjam…

A HQL JPA-bani megfelelője például a JPQL csak kicsit kevesebb lehetőséggel. Egy valid JPQL lekérdezés mindig valid HQL lekérdezés is, de ez fordítva már nem igaz. A JPA specifikációs folyamat mindig kicsit el van maradva a létező rendszerek mögött.

ORM JPA-val

A JPA API a jakarta.persistence package alatt található. Ez Mavenben az alábbi dependency alatt van:

<dependency>

<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
<version>3.2.0</version>

</dependency>

Ez viszont önmagában még nem működőképes, hiszen nem tartalmaz persistence providert. Ahhoz, hogy használhassuk, egy persistence providert kell behúzni, ami pedig már magával hozza a JPA-t is. A továbbiakban maradok a Hibernate-nél, ezért megint ez kell:

<dependency>

<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.5.2.Final</version>

</dependency>

A JPA-ban minden adatbázisművelethez az EntityManagerFactory és az EntityManager a belépési pont. A SessionFactory-hoz hasonlóan az EntityManagerFactory felépítését is bootstrappingnak hívjuk. A JPA kétféle bootstrapping-et különböztet meg:

  • EE: valamilyen Jakarta EE konténeren belül, ez esetben az EntityManagerFactory-t a konténer fogja felépíteni. Ez esetben az EntityManager container-managed lesz és a konténer felelőssége a tranzakciókezelés is.
  • SE: Java SE környezetben az alkalmazás építi fel az EntityManagerFactory-t. Ez esetben az EntityManager application-managed lesz, az alkalmazás felelőssége az EntityManager lezárása is.

Lássuk az SE bootstrapping-et. Ehhez kell egy persistence.xml a resources/META_INF könyvtárba. Ezt fogja használni a HibernatePersistenceProvider az EntityManagerFactoryBuilder felépítéséhez:

<persistence xmlns=http://java.sun.com/xml/ns/persistence

xmlns:xsi=http://www.w3.org/2001/XMLSchema-instance

xsi:schemaLocation=http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd

version=„2.0”>

<persistence-unit name=hu.egalizer.jpa”>

<description>Persistence unit teszt</description>

<class>hu.egalizer.jpa.Customer</class>

<properties>

<!– Adatbázis kapcsolat beállításai –>

<property name=„jakarta.persistence.jdbc.url”

value=„jdbc:mysql://localhost/test?serverTimezone=UTC” />

<property name=„jakarta.persistence.jdbc.user” value=„username” />

<property name=„jakarta.persistence.jdbc.password” value=„password” />

<!– Ha már létezik az adatbázisunk, akkor ne próbálja meg automatikusan létrehozni a sémát –>

<property

name=„jakarta.persistence.schema-generation.database.action” value=„none” />

<!– Írjon ki minden végrehajtott SQL-t a konzolra –>

<property name=„hibernate.show_sql” value=„true” />

<property name=„hibernate.format_sql” value=„true” />

<property name=„hibernate.highlight_sql” value=„true” />

</properties>

</persistence-unit>

</persistence>

Itt fel kell sorolni a <class> részben az annotált osztályainkat. Lehet XML nélkül is megcsinálni az SE bootstrapping-et, de XML-lel sokkal egyszerűbb. A fenti Customer-es példa ezután JPA API-val így néz ki (az entitás lényegében nem változott):

package hu.egalizer.jpa.domain;

 

import jakarta.persistence.Column;

import jakarta.persistence.Entity;

import jakarta.persistence.GeneratedValue;

import jakarta.persistence.GenerationType;

import jakarta.persistence.Id;

import jakarta.persistence.Table;

 

@Entity

@Table(name = „customer”)

public class Customer {

@Id

@GeneratedValue(strategy = GenerationType.IDENTITY)

private Long id;

@Column(name = „first_name”)

private String firstName;

@Column(name = „last_name”)

private String lastName;

@Column(name = „address”)

private String address;

// konstruktorokat, gettereket és settereket elhagyom a rövidség kedvéért

// …

}

Az entitás annotációk a JPA-ból jönnek, akárcsak a Hibernate-es példa esetén, mivel a Hibernate is ezeket használja. A tranzakciókezelés pedig:

EntityManagerFactory emf = Persistence.createEntityManagerFactory(„hu.egalizer.jpa”);

EntityManager entityManager = emf.createEntityManager();

EntityTransaction tx = null;

try (entityManager) {

tx = entityManager.getTransaction();

tx.begin();

Customer user = new Customer(„Teszt”, „Vevő1”, „Utca házszám 1”);

entityManager.persist(user);

tx.commit();

} catch (Exception e) {

if (tx.isActive()) {

tx.rollback();

}

throw e;

}

Az EntityManagerFactory a SessionFactory-hoz hasonlóan szálbiztos, egy alkalmazáshoz elég egy példány is. Az EntityManager példányok viszont már nem szálbiztosak, vagyis minden szálnak kell egy példány belőle amit a használat után le is kell zárni. Az EntityManager-nek nincs inTransaction metódusa, tehát mindenképpen nekünk kell kezelni a tranzakciót. Egyébként ha jobban megnézzük a fenti kódot, akkor kísértetiesen hasonlít a Hibernate-féle megoldáshoz. Nézzük csak meg a Hibernate forráskódját:

package org.hibernate;
public interface Session extends SharedSessionContract, EntityManager {
// …
}

és

package org.hibernate;
public interface SessionFactory extends EntityManagerFactory, Referenceable, Serializable, java.io.Closeable {
// …
}

Vagyis: egy Hibernate SessionFactory valójában egy JPA EntityManagerFactory és egy Hibernate Session valójában egy JPA EntityManager. Ennyire egyszerű.

Egyébként a neten lehet találni mindenféle kommentet arról, hogy az EntityManager jobb mint a Session. Látható, hogy a Session egy EntityManager. Ez csak annyi eltérés, hogy JPA vagy szimpla Hibernate irányba megyünk-e a fejlesztésnél. Valójában mindkét megoldás jó, a kettő közötti vita inkább csak szőrszálhasogatás. A gyakorlatban általában két lehetőségünk van SE környezetben:

  • vagy a JPA-t használjuk amennyire csak lehet, megszórva némi Hibernate-specifikus dologgal ott ahol a JPA specifikáció épp el van maradva
  • vagy csak a sima Hibernate-et használjuk végig (talán ez az ésszerűbb)

Felmerülhet a kérdés, ha kipróbáljuk az entityManager.persist()-et mindenféle tranzakció indítás nélkül, hogy miért mentődik el sikeresen abban az esetben is az entitásunk de az előbbiek alapján erre már tudható a válasz: a tranzakció indítás (a kódban tx.begin()) lényegében a setAutoCommit(false) műveletet jelenti. Tranzakció nélkül ez nem fut le, így a persist esetén minden JDBC művelet alapértelmezetten külön tranzakcióban fut.

Egy ábrán rakjuk össze az eddigi építőelemeinket:

Spring adatbázis- és tranzakciókezelés

A Spring univerzum óriási ökoszisztéma, ezért nem érdemes egyből a Spring Data-val kezdeni, hanem inkább az alacsony szintű dolgoktól haladni fölfelé.

Spring JDBC Template

A Spring egyik legrégebbi util osztálya a spring-jdbc dependency-ben lévő JdbcTemplate. Már 2001 óta létezik és nem összekeverendő a Spring Data JDBC-vel. Ez alapjában véve egy wrapper a JDBC connection-höz jobb ResultSet és connection kezeléssel, jobb hibakezeléssel és integrációval a Spring tranzakciókezelő keretrendszeréhez.

Az alábbi függőséggel adhatjuk a projektünkhöz:

<dependency>

<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>6.1.11</version>

</dependency>

Három fő osztályból áll:

  • org.springframework.jdbc.core.JdbcTemplate
  • org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  • org.springframework.jdbc.core.simple.JdbcClient

Nézzünk néhány példát a használatra a teljesség igénye nélkül:

DataSource ds = createDataSource();

JdbcTemplate jdbcTemplate = new JdbcTemplate(ds);

// egyszerű SQL utasítások futtatása

jdbcTemplate.execute(„delete from customer”);

// tömeges futtatást is tudunk csinálni. A ? helyettesítés is működik

List<Object[]> batchParamList = List.of(new Object[] { „firstname1„, „lastname1”, „address1” },

new Object[] { „firstname2”, „lastname2”, „address2” });

jdbcTemplate.batchUpdate(„INSERT INTO customer(first_name, last_name, address) VALUES (?,?,?)”, batchParamList);

// lekérdezés

jdbcTemplate.query(„SELECT id, first_name, last_name, address FROM customer WHERE first_name = ?”,

(rs, rowNum) -> new Customer(rs.getLong(„id”), rs.getString(„first_name”),

rs.getString(„last_name”), rs.getString(„address”)), „firstname2”)

.forEach(customer -> log.info(customer.toString()));

NamedParameterJdbcTemplate namedTemplate = new NamedParameterJdbcTemplate(ds);

SqlParameterSource namedParameters = new MapSqlParameterSource().addValue(„address”, „address1”);

String result = namedTemplate.queryForObject(„SELECT first_name FROM customer WHERE address = :address”,

namedParameters, String.class);

A sima JDBC-hez képest itt nem kell SQLException-öket elkapni sem, mivel ezeket a Spring RuntimeException-ökké alakítja. A NamedParameterJdbcTemplate azt is lehetővé teszi, hogy az SQL sztringben paraméterekre név alapján hivatkozzunk kérdőjel helyett. A kódból egyébként sejthető, hogy a JdbcTemplate valójában csak egy wrapper a JDBC api körül. A fenti kódban nincs külön tranzakciókezelés, ha a DataSource alap beállításokkal van példányosítva, akkor tudjuk, hogy setAutoCommit(true) állapotban van, ehhez a JdbcTemplate nem nyúl, tehát minden művelet külön tranzakcióban fog futni. A későbbiekben látni fogunk eszközt a JdbcTemplate tranzakciókezeléhez is.

A Spring framework 6.1-es verziója óta egy fluent API-stílusú eszköz is rendelkezésünkre áll, a org.springframework.jdbc.core.simple.JdbcClient. Ezzel még kényelmesebben lehet lekérdezéseket összeállítani:

Optional<Integer> result = JdbcClient.create(createDataSource())

.sql(„select count(*) from customer where first_name=:fname”)

.param(„fname”, „firstname1”)

.query(Integer.class)

.optional();

A JdbcTemplate egyébként a konfigurálása után szálbiztos és a Spring azt ajánlja, hogy egy alkalmazáshoz elég egy példányt belőle létrehozni.

Spring Boot környezetben az alábbi dependency még könnyebbé teszi a dolgunkat:

<dependency>

<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>

</dependency>

Ez esetben mindhárom JDBC eszközt egyszerűen injektálhatjuk a service-ünkbe, csak egy plusz application.yml kell a spring.datasource konfigurálásához.

Spring Data

A Spring Data célja, hogy egy jól használható és egységes, általános Spring alapú programozási modellt adjon az adatkezeléshez. Ez egy nagy összefoglaló projekt ami több alprojektet tartalmaz amelyek mind egy adott adatbázis  típus specifikus tulajdonságaihoz illeszkednek, akár relációs akár nem relációs adatbázisról van szó. Lényegében az összes Spring Data modul célja, hogy leegyszerűsítse repository-k, DAO-k és lekérdezések írását. Persze ennél többet is tesznek, de most ennyi elég nekünk.

Nagyon sok modulból áll, ezek megtalálhatók a projekt honlapján. Minden Spring Data modul függ a Spring Data Commons modultól. Ez adja a közös, technológiafüggetlen API-t az összes adatkezeléshez az org.springframework.data csomagban:

  • repository interfészek, amelyek az adatbázis eléréséhez szükséges felületet adják (Repository, CrudRepository, ListCrudRepository, stb)
  • Java osztályok perzisztálásához szükséges absztrakció
  • lekérdezések dinamikus létrehozása metódusnevekből
  • auditálás

Ezen kívül minket most két modul érdekel, mindkettő a commons-t használja. Az első:

  • Spring Data JDBC: 2018 óta létező modul, repository támogatás a JDBC-hez. Ez lényegében egy kényelmi könyvtár a JDBC fölött és lehetővé teszi, hogy repositorykat csináljunk anélkül, hogy behoznánk egy felfújt ORM-et és annak a lehetőségeit (cache-elés, lazy load, stb).

Az org.springframework.data.jdbc package alatt lesz ha megadjuk a következő dependency-t:

<dependency>

<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jdbc</artifactId>
<version>3.3.2</version>

</dependency>

vagy Spring Boot alatt:

<dependency>

<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>

</dependency>

A második:

  • Spring Data JPA: 2011 óta létező modul, repository támogatás a JPA-hoz. Lényegében kényelmi könyvtár a JPA/Hibernate-hez. Lehetővé teszi, hogy egyszerű JPA repository-kat írjunk, de továbbra is megvan az elérésünk az ORM teljes eszközkészletéhez. Ez a modul tartalmazza az entitások verziózásához való Envers osztálykönyvtárat is (de azt a spring-data-jpa dependency nem tartalmazza, külön kell importálni).

Az org.springframework.data.jpa package alatt lesz, ha megadjuk a következő dependency-t:

<dependency>

<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jpa</artifactId>
<version>3.3.2</version>

</dependency>

vagy Spring Boot alatt:

<dependency>

<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>

</dependency>

Spring Data JDBC

Alább egy teljesen egyszerű Spring Data JDBC példa. Kell nekünk először is egy entitás és egy repository:

package hu.egalizer.datajdbc.domain;

 

import org.springframework.data.annotation.Id;

import org.springframework.data.relational.core.mapping.Column;

 

/**

* Teljesen egyszerű entitást létrehozni, minimális annotáció használatával.

*/

public class Customer {

@Id

private Long id;

@Column(„FIRST_NAME”)

private String firstName;

@Column(„LAST_NAME”)

private String lastName;

@Column(„ADDRESS”)

private String address;

// konstruktorokat, gettereket és settereket elhagyom a rövidség kedvéért

}

package hu.egalizer.datajdbc.domain;

 

import java.util.List;

import org.springframework.data.jdbc.repository.query.Query;

import org.springframework.data.repository.CrudRepository;

import org.springframework.data.repository.query.Param;

 

//A CrudRepository-ból származtatva meglesznek a szokványos CRUD metódusok

public interface CustomerRepository extends CrudRepository<Customer, Integer> {

// saját lekérdezést is írhatunk, de itt a JPA-val ellentétben sima SQL-t kell írnunk

@Query(„select * from customer c where c.first_name = :fname”)

List<Customer> findByName(@Param(„fname”) String fname);

}

Kell hozzá minimális konfiguráció:

package hu.egalizer.datajdbc;

 

import javax.sql.DataSource;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration;

import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories;

import com.mysql.cj.jdbc.MysqlDataSource;

 

@Configuration

@EnableJdbcRepositories // mindössze ennyi kell Spring Boot alatt a Spring Data JDBC beizzításához

public class AppConfiguration extends AbstractJdbcConfiguration {

// minimális konfiguráció: csak egy datasource kell és már készen is vagyunk

@Bean

public DataSource createDataSource() {

MysqlDataSource ds = new MysqlDataSource();

ds.setUrl(„jdbc:mysql://localhost/test?serverTimezone=UTC”);

ds.setUser(„username”);

ds.setPassword(„password”);

return ds;

}

}

És már játszhatunk is:

package hu.egalizer.datajdbc;

 

import org.slf4j.Logger;

import org.slf4j.LoggerFactory;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.boot.CommandLineRunner;

import org.springframework.boot.SpringApplication;

import org.springframework.boot.autoconfigure.SpringBootApplication;

import hu.egalizer.datajdbc.domain.Customer;

import hu.egalizer.datajdbc.domain.CustomerRepository;

 

@SpringBootApplication

public class AppWithSpringDataJdbc implements CommandLineRunner {

private static final Logger log = LoggerFactory.getLogger(AppWithSpringDataJdbc.class);

public static void main(String args[]) {

SpringApplication.run(AppWithSpringDataJdbc.class, args);

}

 

@Autowired

CustomerRepository repository;

 

@Override

public void run(String… strings) throws Exception {

Customer customer = new Customer(„firstname”, „lastname”, „address”);

repository.save(customer);

log.info(„Találat: {}”, repository.findByName(„firstname”));

}

}

A Spring Data JDBC-vel tudunk a fenti példákon túl akár tranziens mezőket felvenni, entitások közötti kapcsolatokat definiálni, lapozásos lekérdezéseket használni. A CrudRepository minden metódusa alapértelmezetten külön tranzakcióban fut.

Spring Data JPA

A spring-data-jpa dependency opcionális függőségként hozza Hibernate-et, a Spring Boot verziója pedig már kötelezőként. Vagyis a JPA-hoz alapértelmezett persistence providerként megkapjuk a Hibernate-et. Ebben a fejezetben kifejezetten a Spring Data JPA-specifikus dolgokról lesz szó. Maven dependency:

<dependency>

<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>

</dependency>

Hogyan néz ki entitásunk és repository-nk Spring Data JPA esetén? A DataSource létrehozása semmiben nem tér el a Spring Data JDBC-nél látottól tehát azt már nem másolom be. A szokásos Customer entitásunk:

package hu.egalizer.datajpa.domain;

 

import jakarta.persistence.Column;

import jakarta.persistence.Entity;

import jakarta.persistence.GeneratedValue;

import jakarta.persistence.GenerationType;

import jakarta.persistence.Id;

import jakarta.persistence.Table;

 

@Entity

@Table(name = „customer”)

public class Customer {

@Id

@GeneratedValue(strategy = GenerationType.IDENTITY) // ezt majd a MySql megteszi

private Long id;

@Column(name = „first_name”)

private String firstName;

@Column(name = „last_name”)

private String lastName;

@Column(name = „address”)

private String address;

// konstruktorokat, gettereket és settereket elhagyom a rövidség kedvéért

}

Spring Data JPA esetén általában minden entitáshoz létrehozunk egy repositoryt. A Customer entitáshoz tartozó repository:

package hu.egalizer.datajpa.domain;

 

import java.util.List;

import java.util.Optional;

import org.springframework.data.jpa.repository.Query;

import org.springframework.data.repository.Repository;

 

public interface CustomerRepository extends Repository<Customer, Long> {

Customer save(Customer person);

Optional<Customer> findById(long id);

List<Customer> findByFirstNameAndLastName(String firstName, String lastName);

}

Egy saját repository interfészt definiálunk ami a commons-ban lévő org.springframework.data.repository.Repository-ból származik. Típus paraméterként meg kell adni az entitásunkat és az azonosító típusát. Mint írtam, a commons már tudja lekérdezések dinamikus létrehozását metódusnevekből, ezért csak beírjuk a kívánt lekérdezést metódusként, bizonyos szabályok betartásával és kész is vagyunk. A query methods-nak hívott szuper kis funkcionalitásról részletesebben itt lehet olvasni.

Ahhoz hogy ez a repository absztrakció működjön, még kell egy @EnableJpaRepositories Spring konfiguráció is, de ha Spring Boot-ot használunk akkor már ez sem mert az ezt alapból megcsinálja. Egyébként olyan jó ez a Spring Data modul, hogy még Spring konténer sem kell hozzá, anélkül is ki tudjuk használni a szépségeit, a standalone használatról bővebben itt.

A Spring Data JPA kicsit továbbfejleszti a commons-féle repository-t. Ha az org.springframework.data.jpa.repository.JpaRepository-ból származtatjuk a repositorynkat, akkor nagyon sok alap metódust (delete*, find*, és save) nem nekünk kell megírni mert azt a JpaRepository már tartalmazza.

Ha nem szeretnénk a Repository interfészből származtatni akkor a saját repository interfészünket úgy is létrehozhatjuk, hogy a RepositoryDefinition-nel megannotáljuk, ezzel ugyanazt érjük el mintha származtatnánk:

@RepositoryDefinition(domainClass = Customer.class, idClass = Long.class)

Ha a projektünkben sok különféle repository-ban van ugyanaz a metódus akkor csinálhatunk egy saját ős-repository interfészt mindegyikhez, amiben megadjuk a közös metódusokat. Csak annyit kell tennünk, hogy ezt az őst megannotáljuk a NoRepositoryBean annotációval, ebből a Spring tudni fogja, hogy ne csináljon hozzá automatikusan példányt:

package hu.egalizer.datajpa.domain;

 

import java.util.Optional;

import org.springframework.data.repository.NoRepositoryBean;

import org.springframework.data.repository.Repository;

 

@NoRepositoryBean

public interface TestBaseRepository<T, ID> extends Repository<T, ID> {

Customer save(Customer person);

Optional<Customer> findById(long id);

}

 

public interface CustomerRepository extends TestBaseRepository<Customer, Long> {

//…

}

A Spring Data JPA sok különféle repository-t tartalmaz alapból, ezekből származtatva egyre bővebb, specifikus lekérdezéseket alapból is megkapunk, nem nekünk kell megírni (például lapozás). Akár többszörös öröklődéssel is kreálhatunk saját repositoryt ezekből. A normál (tehát nem reactive-os) repository-k származási fája az alábbi:

Minden repository az ős Repository interfészből származik. A Spring Data JPA repository-k alapértelmezetten singleton bean-ek és a Spring indulásakor jönnek létre. A létrejöttük idején lehet változtatni, de ennek tárgyalása kívül esik a cikk keretein. A spring alapértelmezetten abban a package-ben és annak alpackage-eiben keresi az entitásainkat és a repository-kat amely package-ben a konfigurációs osztályunk van. Az @EnableJpaRepositories annotáció basePackages paramétereként megadva a repository keresés package-ét másra is felülírhatjuk. Ugyanezt az entitásokra a konfig osztályunknál az @EntityScan annotáció paraméterezésével lehet megtenni. A repository-k kereséséről a Spring Boot induláskor az alábbi logbejegyzésekkel tudósít (DEBUG log szint esetén):

INFO RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
DEBUG RepositoryConfigurationDelegate : Scanning for JPA repositories in packages hu.egalizer.datajpa.
INFO RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 47 ms. Found 2 JPA repository interfaces.

A projection funkcionalitással lehetőségünk van arra, hogy egy nagyobb entitásból csak bizonyos részeket kérdezzünk le. Kell hozzá egy interfész amibe felvesszük a lekérdezni kívánt mezők gettereit:

package hu.egalizer.datajpa.domain;

public interface NamesOnly {

String getFirstName();

String getLastName();

}

Ezután pedig a repository interfészünkbe már írhatunk hozzá lekérdező metódus szignatúrát:

List<NamesOnly> findByLastName(String lastName);

Akárcsak a Spring Data JDBC-ben, itt is van lehetőség rá, hogy mindezeken túl egyedi lekérdezéseket írjunk, mégpedig immár JPQL nyelven. Ehhez itt is @Query annotációra van szükségünk, csak máshonnan kell importálni:

import org.springframework.data.jpa.repository.Query;

// …

@Query(„select c from Customer c where address = :address”)

List<Customer> customQuery(String address);

A query by example funkcionalitás arra jó, hogy dinamikus lekérdezéseket csináljunk anélkül hogy kézzel kellene lekérdezéseket mezőnevekkel írni.

Ehhez a repository-nkat ki kell egészíteni az org.springframework.data.repository.query.QueryByExampleExecutor interfészből származással, és ezzel már kapunk olyan metódusokat amiket az alábbi módon lehet használni:

//…

 

import org.springframework.data.domain.Example;

import org.springframework.data.domain.ExampleMatcher;

import org.springframework.data.domain.ExampleMatcher.StringMatcher;

 

//…

 

ExampleMatcher matcher = ExampleMatcher.matching().withIgnorePaths(„lastname”).withIncludeNullValues()

.withStringMatcher(StringMatcher.ENDING);

Example<Customer> example = Example.of(exampleCustomer, matcher);

log.info(„Eredmény: {}”, repository.findAll(example));

A Spring Data Commons auditálásra is biztosít lehetőséget, így entitásainkhoz tartozó módosítást és létrehozást lehet nyomon követni. Ehhez négy annotáció tartozik, a nevük alapján szerintem nem igényelnek különösebb magyarázatot. Ezekkel entitásaink mezőit lehet megjelölni: @CreatedBy; @LastModifiedBy; @CreatedDate; @LastModifiedDate.

A használathoz első körben az entitásunkat meg kell jelölni ezzel:

@EntityListeners(AuditingEntityListener.class)

Ezután fel kell venni bele a kívánt mezőket:

@CreatedBy

private String createdBy;

 

@CreatedDate

private Instant createdDate;

Szükség lesz hozzá egy AuditorAware osztályra is, ami megmondja a CreatedBy-nak az aktuális értéket. Ez a példában sztring, de meg lehet oldani azt is, hogy akár másik entitás legyen.

package hu.egalizer.datajpa.domain;

import java.util.Optional;

import org.springframework.data.domain.AuditorAware;

public class SpringSecurityAuditorAware implements AuditorAware<String> {

@Override

public Optional<String> getCurrentAuditor() {

return Optional.ofNullable(„teszt user”);

}

}

És mindezek után még az alábbi konfigurációra is szükség lesz. Ezután a Spring automatikusan tölteni fogja az auditor mezőket nekünk.

package hu.egalizer.datajpa;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

import hu.egalizer.datajpa.domain.SpringSecurityAuditorAware;

@Configuration

@EnableJpaAuditing

public class AppConfiguration {

@Bean

SpringSecurityAuditorAware auditorAware() {

return new SpringSecurityAuditorAware();

}

}

Alapértelmezetten minden, a CrudRepository-ban lévő metódus külön tranzakcióban fut. A Spring tranzakciókezelésről bővebben a következő fejezet fog szólni. Ezek csak a Spring Data JPA legfontosabb funkciói voltak (nem ejtettem szót például a tárolt eljárások hívásának lehetőségéről sem), a teljes funkcionalitás megismeréséért a dokumentációt érdemes lapozni.

Az talán a fentiekből is látható, hogy nincs egyetlen mindenre jó könyvtár, de azért van néhány irányelv amit érdemes megfontolni amikor amellett döntünk hogy melyiket válasszuk:

  • nem számít, melyik mellett döntöttünk, fontos hogy jól ismerjük az adatbázisokat és az SQL-t
  • azt a könyvtárat válasszuk amelyiknek aktív közösségve van, jó dokumentációja és rendszeres kiadásai
  • tanuljuk meg alaposan az adatbázis könyvtárunkat, sajnos ezt az időt nem lehet megspórolni
  • a projektünk akár a sima Hibernate-tel, akár a JPA-ba csomagolt Hibernate-tel is jó lesz
  • szintén jó lesz a jOOQ-val vagy bármilyen fent említett database-first libraryval
  • ezeket még kombinálhatjuk is, például JPA implementáció és Jooq vagy sima JDBC vagy még több kényelem az olyan könyvtárakkal mint a QueryDSL

Az alábbi ábrán összefoglalom a cikkben tárgyalt Spring modulokat és azok függőségeit (kattintásra nagyítható).

Spring Tranzakciókezelés

TransactionTemplate

Az első fejezetben bemutattam, hogyan működik a JDBC tranzakciókezelése. Láttuk, hogy ez azt jelenti, hogyan indítjuk, kommitoljuk vagy rollback-eljük a JDBC tranzakciókat. Sima JDBC esetén erre csak egy módunk van (.setAutoCommit(false) ), de a Spring több megoldást is kínál arra, hogy ugyanezt elérjük.

Az első de ritkábban használt mód a programozható tranzakciókezelés: a TransactionTemplate-en vagy pedig közvetlenül a PlatformTransactionManageren keresztül.

A TransactionTemplate itt lakik (org.springframework.transaction package-ben):

<dependency>

<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>

</dependency>

Az előző ábrán is látható, hogy ha a Spring Data JPA-t vagy a Spring Data JDBC-t használjuk akkor a spring-tx-et is megkapjuk.

Az alábbi példákban legyen egy Orders entitásunk amiben rendeléseket kezelünk és ezeket akarjuk adatbázisban tárolni (ezt már külön nem másolom be, szerintem egyértelműen kikövetkeztethető az előzőekből). A TransactionTemplate megoldás így néz ki:

package hu.egalizer.template.service;

 

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.stereotype.Service;

import org.springframework.transaction.support.TransactionTemplate;

import hu.egalizer.template.domain.Orders;

import jakarta.persistence.EntityManager;

 

@Service

public class OrderService {

@Autowired

private TransactionTemplate template;

@Autowired

private EntityManager entityManager;

 

public Long placeOrder(Orders order) {

Long id = template.execute(status -> {

Long persistedId = null;

// amit itt csinálunk (például az EntityManager-ben) az egy tranzakcióban fog lefutni

// a template-nek van olyan metódusa is aminek nincs visszatérési értéke: executeWithoutResult

return persistedId;

});

return id;

}

}

A sima JDBC példához képest:

  • nem kell foglalkozni az adatbázis kapcsolatok megnyitásával és lezárásával (try-finally), ezt a TransactionTemplate megcsinálja helyettünk, ehelyett transaction callback-eket használunk
  • nem kell SQLException-öket elkapkodni, mivel ezeket a Hibernate runtime exception-ökké konvertálja. Minden, az execute-on belüli lambdában dobott kivétel esetén rollback-elődik a tranzakció.
  • jobb integrációnk van a Spring ökoszisztémával is. A TransactionTemplate belsőleg egy TransactionManager-t használ, az pedig egy datasource-t. Ezeket továbbra is nekünk kell megadnunk a Spring konfigurációban, de aztán többé nem kell aggódnunk miattuk.

Feltűnhet, hogy az EntityManager-t a példában injektáltam a bean-be, holott korábban arról volt szó, hogy az nem szálbiztos. Ennek a magyarázatáról később lesz szó.

Az execute paramétere egy org.springframework.transaction.TransactionStatus ami egy éppen futó tranzakciót reprezentál. Ezzel a tranzakcióról kaphatunk státusz információkat, de akár beállíthatjuk azt is, hogy a tranzakció rollback-elődjön (ha nem akarunk kivételt dobni):

package hu.egalizer.template.service;

 

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.stereotype.Service;

import org.springframework.transaction.support.TransactionTemplate;

import hu.egalizer.template.domain.Orders;

 

@Service

public class OrderService6 {

@Autowired

private TransactionTemplate template;

 

public Long placeOrder(Orders order) {

Long id = template.execute(status -> {

Long result = null;

try {

operation();

} catch (StockNotAvailableException ex) {

status.setRollbackOnly();

}

return result;

});

return id;

}

// …

}

Mivel a TransactionTemplate szálbiztos, akár így is használhatjuk, ha például alapértelmezettől eltérő tranzakció beállításokra van szükségünk:

package hu.egalizer.template.service;

 

import org.springframework.stereotype.Service;

import org.springframework.transaction.PlatformTransactionManager;

import org.springframework.transaction.TransactionDefinition;

import org.springframework.transaction.support.TransactionTemplate;

import hu.egalizer.template.domain.Orders;

 

@Service

public class OrderService {

private final TransactionTemplate template;

 

public OrderService(PlatformTransactionManager transactionManager) {

template = new TransactionTemplate(transactionManager);

// a tranzakció beállításait akár kézzel is megadhatjuk, ahogy az alábbi példák mutatják

template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED);

template.setTimeout(30); 

}

 

public Long placeOrder(Orders order) {

Long id = template.execute(status -> {

Long result = null;

// amit itt csinálunk az egy tranzakcióban fog lefutni

return result;

});

return id;

}

}

Az persze fontos, hogy ha két külön szálnak két eltérő konfigurációjú TransactionTemplate-re van szüksége akkor már két külön példányt kell belőle létrehozni a kívánt beállításokkal. A TransactionTemplate-et használhatjuk például ahhoz, hogy a Spring JDBC Template műveleteinket futtassuk tranzakciókezelten.

A programozható tranzakciókezelés is hasznos dolog de Spring igazi erőssége nem ez, hanem a delkaratív tranzakciókezelés.

@Transactional

Azokban a napokban amikor még az XML konfiguráció volt a norma a Spring projektekben, a tranzakciókat is XML-ekben tudtuk konfigurálni. Néhány régi vállalati projekttől eltekintve ezt a megközelítést már már sehol nem találjuk (ekkben a cikkben sem…) mert leváltotta a sokkal egyszerűbb @Transactional annotáció.

Nézzük, hogyan néz ki általában a modern Spring tranzakciókezelés:

package hu.sac.service;

 

import org.springframework.stereotype.Service;

import org.springframework.transaction.annotation.Transactional;

import hu.sac.dto.Order;

 

@Service

public class OrderService {

@Transactional

public void placeOrder(Orders order) {

// Itt hívhatjuk meg a repository-nk metódusait

}

}

Itt már nincs XML konfiguráció és nem kell bonyolult kód sem. Viszont ezek kellenek hozzá:

  • a Spring konfigurációnkat meg kell annotálni az @EnableTransactionManagement annotációval (a Spring Boot ezt automatikusan megcsinálja)
  • kell egy transaction manager a Spring konfigurációnkba (kivéve ha a spring-boot-starter-data-jpa maven dependency-t használjuk mert azzal automatikusan kapunk egy JpaTransactionManager példányt)

Ezután a Spring már transzparens módon tudja kezelni nekünk a tranzakciókat. Bármely bean publikus metódusa amihez odaírjuk a @Transactional annotációt, már egy adatbázis tranzakción belül fog futni. Ennek azért van néhány buktatója, mint később látni fogjuk.

Észrevehetjük, hogy egy Spring Boot-os Spring Data JPA projektben két @Transactional annotáció is van:

  1. org.springframework.transaction.annotation.Transactional
  2. jakarta.transaction.Transactional

A Spring-es Transactional már a Spring nagyon korai verziójától, kb 2005-től létezik és széles körű lehetőségeket biztosít a tranzakciók menedzselésére. A Jakarta-s Transactional a Java EE 7 specifikációjában jelent meg, 2013 körül és lényegében ugyanarra szolgál de kicsit kevesebb beállítási lehetőséget kínál. Bár a Jakarta-féle Transactional is használható Springben, a legjobb ha Spring projektekben csak a Spring-es változatot használjuk, és csak Jakarta (Java EE) projektekben használjuk a Jakarta-félét.

A @Transactional annotációval a Spring a háttérben ezt az ismerős megoldást csinálja (leegyszerűsítve):

public Long placeOrder(Orders user) {

Long result = null;

Connection connection = dataSource.getConnection(); // 1

try (connection) {

connection.setAutoCommit(false); // 1

// 2: itt futtatjuk az adatbázisműveleteinket

// vagyis azt a kódot amit a @Transactional-os metódusba írtunk

connection.commit(); // 1

} catch (SQLException e) {

connection.rollback(); // 1

}

return result;

}

  1. szokványos JDBC kapcsolat nyitás és zárás. Ezt a Spring transactional annotációja automatikusan megcsinálja nekünk.
  2. ez a mi saját kódunk, amiben elmentjük az ordert egy repository-n keresztül vagy bárhogy máshogy.

Tehát a Spring annotáció sem csinál semmi különlegeset, csak a már ismert JDBC-n keresztüli tranzakciókezelést. De hogy szúrja be a Spring a metódusunkba a tranzakciókezelés kódját? Nos a Spring nem tudja átírni a Java osztályokat, hogy beillessze a tranzakciókezeléses kódot. (Hacsak nem használunk olyan spéci technikákat mint például a bytecode weaving, de ezzel most ne foglalkozzunk).

A placeOrder() metódus valóban csak azt csinálja, ami benne szerepel (például orderRepository.persist(order)), az futás közben nem fog megváltozni. Viszont a Spring egy IoC container. Ez azt jelenti, hogy ő példányosítja nekünk az OrderService-t és biztosítja, hogy ez a service injektálva legyen bármilyen másik bean-be aminek arra szüksége van. Amikor egy @Transactional annotációt rakunk egy bean-be, akkor a Spring egy kis trükköt csinál. Nem csak példányosítja az OrderService-t, hanem egyúttal csinál ahhoz egy ún. transactional proxy-t is. Ezt egy proxy-through-subclassing nevű eljárással teszi meg a Cglib könyvtár segítségével. Van más módja is a proxik létrehozásának, de most maradjunk ennél. Lássuk ezt a proxy működést ezen az ábrán:

Vagyis a proxy nyitja meg és zárja le az adatbázis connection-öket/tranzakciókat és az hívja meg a valódi OrderService-t, amit mi írtunk. Más bean-ek mint például a mi OrderRestResource-unk, sose fogja tudni, hogy valójában egy proxyval beszél és nem a valódi osztállyal.

Tekintsük a következő kódot:

package hu.sac.config;

 

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import org.springframework.transaction.annotation.EnableTransactionManagement;

import hu.sac.service.OrderService;

 

@Configuration

@EnableTransactionManagement

public class TransactionConfig {

@Bean

public OrderService orderService() {

return new OrderService();

}

}

Tegyük fel, hogy az OrderService osztály vagy annak legalább egy metódusa meg van annotálva a @Transactional annotációval. Ez esetben vajon milyen OrderService példány adódik itt vissza? Hát bizony egy, a mi osztályunkhoz készült Cglib proxy példány, ami meg tud nyitni és le tud zárni adatbázis tranzakciókat. Se mi, se más bean-ek még csak észre se fogják venni, hogy ez nem a mi OrderService-ünk, hanem egy proxy ami becsomagolja a mi osztályunkat.

A proxy kezeli tehát a tranzakciókat, viszont a tranzakció állapotváltozásait (open, commit, close) nem maga a proxy végzi, hanem egy transaction managernek nevezett komponenst használ erre. A Spring-ben minden transaction manager őse a org.springframework.transaction.TransactionManager, ami egy üres interfész, ebből származik a reactive-os illetve a „hagyományos” transaction manager, ez utóbbit az org.springframework.transaction.PlatformTransactionManager reprezentálja. (Akárcsak a Transactional annotációból, a TransactionManager-ből is létezik Jakarta-s változat.) A PlatformTransactionManager-ben találjuk meg a getTransaction, commit és rollback metódusok szignatúráját. Ezt a tranzakció kezelési módszert (vagyis ami a PlatformTransactionManager-ben van) a Spring tranzakciós stratégiának hívja. A getTransaction metódus egy TransactionDefinition paramétert vár, abban adhatjuk meg a tranzakció tulajdonságait (propagation, readonly, timeout, isolation) és egy TransactionStatus objektumot ad vissza, ami lehet egy teljesen új tranzakció de lehet egy már korábban elindított is. A PlatformTransactionManager az indított tranzakciót az annotált metódusból hívott minden adatelérési műveletre kitrejeszti, kivéve a metódusból indított új szálakat.

A transaction managernek tudnia kell, hogy milyen perzisztencia kezeléssel dolgozik, ennek megfelelően létezik belőle többféle implementáció is:

  • DataSourceTransactionManager: JDBC DataSource-okhoz használható transaction manager
  • HibernateTransactionManager: Hibernate SessionFactory-khoz használható transaction manager, azonban Hibernate 6-tól kezdve Spring alatt nem ezt kell használni, hanem ezt:
  • JpaTransactionManager: EntityManagerFactory-hoz használható transaction manager
  • JtaTransactionManager: JTA providerekhez használatos transaction manager, például elosztott tranzakciók kezeléséhez Jakarta EE környezetben

Egy transaction manager konfigurálása Spring-ben csupán az alábbi lépésből áll, ezt a Spring Boot szintén alapértelmezetten megcsinálja nekünk:

@Bean

public DataSource dataSource() {

// kell egy datasource ahol a transaction manager menedzselheti a tranzakciókat:

return new MysqlDataSource();

}

@Bean

public PlatformTransactionManager txManager() {

return new DataSourceTransactionManager(dataSource());

}

A PlatformTransactionManager-t egy absztrakt AbstractPlatformTransactionManager nevű osztály implementálja, ami a tranzakciók kezelésénak vázát adja meg, ebből származnak a fentebb említett konkrét megvalósítások. Ez absztrakt metódusokat definiál az adott konkrét transaction manager implementációhoz. A számunkra ezekből most lényegesek:

  • doGetTransaction(): ennek kell egy tranzakció indításához szükséges objektumot összeállítani, vagyis az indításhoz szükséges dolgokat összegyűjteni. Ebbe kerül például a data source vagy JPA esetén az EntityManagerFactory. Ha már van futó tranzakció, az is ebbe kerül, de konkrétan ez a metódus nem foglalkozik tranzakció indításával.
  • doBegin(): az AbstractPlatformTransactionManager kezeli a különböző propagációs szintekezet is, majd ezeknek megfelelően hívja meg a doBegin absztrakt metódust. Ez a tranzakció indítása. Az adott implementációk ezt valósítják meg.
  • doCommit(): értelemszerű
  • doRollback(): értelemszerű

A doBegin, doCommit és doRollback nagyjából így néz ki:

public class DataSourceTransactionManager extends AbstractPlatformTransactionManager {

private DataSource dataSource;

// …

 

@Override

protected void doBegin(Object transaction, TransactionDefinition definition) {

Connection con = obtainDataSource().getConnection();

// …

con.setAutoCommit(false);

// …

}

 

@Override

protected void doCommit(DefaultTransactionStatus status) {

Connection con = status.getTransaction().getConnectionHolder().getConnection();

// …

con.commit();

// …

}

 

@Override

protected void doRollback(DefaultTransactionStatus status) {

Connection con = status.getTransaction().getConnectionHolder().getConnection();

// …

con.rollback();

// …

}

}

Ismerős ez a kód? Pedig ezt a Spring forráskódjából másoltam ki, csak leegyszerűsítettem. A DataSourceTransactionManager pontosan ugyanazt a megoldást használja amit már láttunk a JDBC fejezetben (a JpaTransactionManager is, csak az ennél jóval körülményesebben). A fentebbi ábránk bővebben kifejtve tehát az alábbi módon néz ki:

Vagyis:

  1. ha a Spring @Transactional annotációt talál egy bean-en akkor csinál a bean-hez egy proxy-t
  2. a proxy egy transaction manager-t használ, azt kéri meg, hogy nyissa meg és zárja le a tranzakciókat és connection-öket
  3. a transaction manager egyszerűen csak azt csinálja amit mi is csináltunk a JDBC fejezetben: kezel egy jó öreg JDBC connection-t.

A proxy technika teszi lehetővé azt is, hogy bean-be injektáljuk az egyébként nem szálbiztos EntityManager-t. Itt ugyanis a Spring egy SharedEntityManagerCreator típusú proxy-t hoz létre ami biztosítja, hogy egy EntityManager-t csak egy szál fog használni. Ezzel a proxy-val tranzakció nélkül már nem is futtathatjuk a persist-et (kivételt kapunk).

Transaction propagation

Tekintsük a következő két osztályt:

package hu.egalizer.service;

 

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.stereotype.Service;

import org.springframework.transaction.annotation.Transactional;

import hu.egalizer.template.domain.Orders;

import jakarta.persistence.EntityManager;

 

@Service

public class OrderService {

@Autowired

private ProductService productService;

 

@Transactional

public void placeOrder(Orders order) {

productService.modifyStock(order);

// egyéb műveletek

return;

}

}

package hu.egalizer.service;

 

import org.springframework.stereotype.Service;

import org.springframework.transaction.annotation.Propagation;

import org.springframework.transaction.annotation.Transactional;

import hu.egalizer.template.domain.Orders;

 

@Service

public class ProductService {

@Transactional

public void modifyStock(Orders order) {

// készletmódosítás

return;

}

}

Az OrderService-ben van egy transactional placeOrder metódus ami meghív egy másik transactional metódust a ProductService-ben. Üzletileg ennek az egésznek valójában egy tranzakciónak kellene lennie (getConnection() – setAutoCommit(false) – commit()). A Spring ezt fizikai tranzakciónak hívja. A service-eknél viszont két logikai tranzakció jön létre: egyik az OrderService-ben, egy másik meg a ProductService-ben. A Spring tudja, hogy mindkét @Transactional metódusnak a háttérben ugyanazt az adatbázis tranzakciót kell használnia.

A ProductService-ben módosítsuk az annotációt:

@Transactional(propagation = Propagation.REQUIRES_NEW)

A propagation-t REQUIRES_NEW-ra változtatva megmondjuk a Springnek, hogy a modifyStock-nak saját tranzakcióban kell futnia, ami független a többi, már létező tranzakciótól. Vagyis a kódunk két fizikai connection-t kér és két tranzakciót nyit az adatbázishoz. Tehát a két logikai tranzakció (placeOrder()/modifyStock()) most már két külön fizikai tranzakcióra képződik le. A fizikai tranzakció tehát valódi JDBC tranzakció, a logikai tranzakció pedig potenciálisan egymásba ágyazott @Transactional annotált metódusok.

Számos propagation szint létezik amiket a @Transactional annotációban meg tudunk adni:

  • REQUIRED (alapértelmezett): nekem kell tranzakció, ezért vagy nyiss egy újat vagy pedig használd a már megnyitottat, ha van -> getConnection(). setAutoCommit(false). commit()
  • SUPPORTS: nem igazán érdekel, hogy van-e már nyitva tranzakció vagy nincs, mindkét esettel együtt tudok élni -> nincs dolgunk a JDBC-vel
  • MANDATORY: nem fogok magamnak tranzakciót nyitni de panaszkodni fogok ha valaki más nem nyitott már egyet -> nincs dolgunk a JDBC-vel
  • REQUIRES_NEW: mindenképpen saját tranzakciót akarok -> getConnection(). setAutoCommit(false). commit().
  • NOT_SUPPORTED: nem szeretem a tranzakciókat, sőt a már futó tranzakciókat is megpróbálom felfüggeszteni -> nincs dolgunk a JDBC-vel
  • NEVER: panaszkodni fogok ha valaki más már elindított egy tranzakciót -> nincs dolgunk a JDBC-vel
  • NESTED: JDBC save pointot akarok (ennek tárgyalása kívül esik a cikk keretein) -> getConnection(). setAutoCommit(false). commit().

Látható, hogy a legtöbb propagation típusnak nincs köze JDBC-hez, inkább arra vonatkozik hogyan szervezzük meg a vezérlési folyamatot Springben.

Transaction isolation

Mi történik ha ezt a paramétert konfiguráljuk?

@Transactional(isolation = Isolation.REPEATABLE_READ)

Ebből egyszerűen ez lesz:

connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);

Az adatbázis izolációs szintek összetett témakör, ennek a cikknek nem témája, ezzel csak azt akartam bemutatni, hogy ez a paraméter is egyszerűen egy JDBC connection beállítás lesz. Egyébként amikor az izolációs szintet egy tranzakción belül változtatjuk meg, akkor annak is utána kell nézni, hogy a használt JDBC driver és adatbázis esetén milyen esetek támogatottak és melyek nem.

Transaction readonly

Akárcsak az isolation paraméter, a readOnly is annyit csinál, hogy:

@Transactional(readOnly = true)

ebből egyszerűen ez lesz:

connection.setReadOnly(true);

Aztán, hogy ezt az aktuálisan használt adatbázisunk és JDBC driverünk használja-e valamire vagy egyszerűen figyelmen kívül hagyja, az már a használt adatbázistól függ. Emellett a readonly flag beállítása esetén a Spring némi optimalizációt is végez a persistence provider-ben, például Hibernate esetén a flush mode-ot NEVER-re állítja, így a Hibernate kihagyja a dirty check lépéseket. Ez nagy méretű, összetett objektumokon jelentős teljesítménynövekedést is eredményezhet.

Közkeletű @Transactional hibák, amiket mindenki elkövet

Metódushívás

Tekintsük a következő kódot:

package hu.egalizer.service;

 

import org.springframework.stereotype.Service;

import org.springframework.transaction.annotation.Propagation;

import org.springframework.transaction.annotation.Transactional;

import hu.egalizer.template.domain.Orders;

 

@Service

public class OrderService {

@Transactional

public void placeOrder(Orders order) {

modifyStock(order);

// egyéb műveletek

return;

}

 

@Transactional(propagation = Propagation.REQUIRES_NEW)

public void modifyStock(Orders order) {

// …

}

}

Van egy OrderService osztályunk egy transactional placeOrder metódussal ami meghívja a modifyStock metódust ami szintén transactional és REQUIRES_NEW. Mennyi fizikai tranzakció lesz nyitva amikor valaki meghívja az placeOrder-t? A válasz: nem kettő hanem egy. Emlékezzünk a proxy hívásos folyamatra: a Spring létrehozza az OrderService proxyt, de amikor már az OrderService osztályon belül vagyunk és másik OrderService metódust hívunk meg, akkor abban a folyamatban már nem lesz proxy. Tehát nem jön létre új tranzakció. Vannak persze trükkök (például a self-injection, vagy a már ismerős TransactionTemplate) amivel meg lehet kerülni ezt, de a lényeg, hogy mindig tartsuk észben a proxy tranzakciós határokat. (Ez egyébként más annotációkra is igaz, mint például a @Cacheable.)

Kivételek

Rollback alapértelmezett esetben a @Transactional metódusoknál csak nem ellenőrzött kivételek esetén fut le, vagyis amik Error vagy RuntimeException osztályból származnak. (Az ellenőrzött-nem ellenőrzött kivételekről bővebben a Java 8 cikkemben írtam). Egyéb esetben a commit fog lefutni, ami okozhat meglepetéseket. Legyen például egy saját üzleti kivételünk:

public class StockNotAvailableException extends Exception {

}

Ha a placeOrder metódusban mondjuk így írjuk az order mentését:

@Transactional

public void placeOrder(Orders order) throws StockNotAvailableException {

entityManager.persist(order);

if (checkStock()) {

throw new StockNotAvailableException();

}

}

Ha a kivételt a placeOrder hívásának helyén akarjuk elkapni, akkor a kivétel dobása esetén – mivel a StockNotAvailableException ellenőrzött – a megelőző persist kommitolódni fog! Ha azt szeretnénk, hogy saját ellenőrzött üzleti kivételeink esetén is rollback történjen, azt az annotációban külön jelezni kell:

@Transactional(rollbackFor = StockNotAvailableException.class)

De mi van, ha olyan hiba lép fel aminél SQLException kivétel dobódik? Az ugyanis szintén ellenőrzött. Például az entitásunkban túl hosszú sztringet állítunk be egy olyan mezőnek ahol az nincs ellenőrizve, viszont az adatbázis táblánkban már van mezőhossz korlát megadva. Ekkor SQLException-t fog dobni a JDBC driver a persist hívásakor, mégis a tranzakcióban minden korábbi műveletünk is rollbackelődni fog. Ez azért van mert a Hibernate az SQLException-t egy saját RuntimeException-be csomagolja. Innentől kezdve az már szintén rollback-elődni fog.

Hosszú ideig fogott Connection

Tegyük fel hogy összekeverünk két különböző típusú I/O hívást egy metódusban:

@Transactional

public void placeOrder(Orders order) {

saveOrder(order);// adatbázis

processPayment(order);// API hívás

modifyStock(order);// adatbázis

}

Itt van két adatbázisműveletünk és egy valószínűleg költséges API hívásunk. Első látásra van értelme az egész metódust transactional-nak jelölni, hiszen azt akarjuk, hogy az egész egy tranzakcióban fusson le. Viszont ha a külső API hívásunk a vártnál hosszabb ideig tart akkor nagyon hamar kifogyhatunk az adatbázis connection-ökből. Már láttuk, hogy mi történik a háttérben, tehát ez esetben:

  1. kapunk egy connection-t a pool-ból
  2. az első adatbázis hívás után meghívunk egy külső API-t, közben fogjuk a megkapott connection-t
  3. miután az API hívás lefutott, elvégezzük a további adatbázisműveleteket

Ha a külső API hívás nagyon lelassul akkor a metódus beragad egy darabig, fogja a connection-t, míg vár a válaszra. Képzeljük el hogy ez a metódus élesben valami miatt egyszercsak tömegesen meghívódik. Nagyon hamar az összes connection várakozni fog egy API hívás miatt, így kifogyunk a szabad adatbázis connection-ökből. Mindezt egy lassú háttérrendszer miatt! Az adatbázisműveleteket más típusú I/O hívásokkal keverni nagyon nem jó ötlet egy tranzakción belül. Ilyen esetekben vagy szervezzük ki a nem oda való kódot a tranzakció belsejéből vagy pedig ha ez ésszerűen nem megoldható akkor használjuk a már megismert TransactionTemplate-et az adatbázisműveletek szeparálására az annotáció helyett.

Spring tranzakciókezelés és Hibernate

Az OrderService Hibernate-es verziója így nézne ki:

@Autowired

private SessionFactory sessionFactory;

 

public void placeOrder(Orders order) {

Session session = sessionFactory.openSession();

session.beginTransaction();

session.persist(order);

session.getTransaction().commit();

session.close();

}

Ez egy sima SessionFactory, ahol manuálisan kezeljük a session-öket (vagyis adatbázis kapcsolatokat) és a tranzakciókat. Így viszont a Hibernate és a Spring tranzakciókezelés semmit nem tud egymásról. Mi valami ilyet szeretnénk:

@Transactional

public void placeOrder(Orders order) {

sessionFactory.getCurrentSession().persist(order);

}

Ehhez csak annyit kell tenni, hogy a konfigurációnkban kicseréljük a DataSourceTransactionManager-t JpaTransactionManager-re (Hibernate 6 óta a korábban használt HibernateTransactionManager már nem támogatott). Mivel a Hibernate-et használjuk JPA persistence provider-ként, a JPA transaction managere biztosítja, hogy a tranzakciók a Hibernate-en, vagyis a SessionFactory-n keresztül fognak történni és ezt a tranzakciót fogja kezelni a @Transactional annotáció. A Spring Boot ezt a konfigurációt is automatikusan megcsinálja.

Az alábbi ábrán leegyszerűsítve látható a folyamat, ami lényegében megegyezik a DataSourceTransactionManager-es megoldással, csak a transaction manager implementáció van kicserélve, mást nem kell tenni. Ez az előnye a Spring tranzakció absztrakciójának. A JPA-specifikus dolgok kerülnek a JpaTransactionManager-be. Ott már találunk egy EntityManagerFactory-t ami legyártja a szükséges EntityManager-eket, és a konkrét folyamat nyilván bonyolultabb, mint a DataSourceTransactionManager esetén, de az most nekünk lényegtelen, a folyamat mélyén mindig a JDBC-t találjuk.

Ha Spring Boot projektünket TRACE log szinttel indítjuk, akkor a logban az alábbi sorok tájékoztatnak minket a már megismert folyamatokról:

// a Spring konténer elindul és létrejön a proxy az OrderService-ünkhöz
AnnotationAwareAspectJAutoProxyCreator: Creating implicit proxy for bean ‘orderService’ with 0 common interceptors and 1 specific interceptors


// létrejön a transactional metódus
AnnotationTransactionAttributeSource: Adding transactional method ‘hu.egalizer.template.service.OrderService.placeOrder’ with attribute: PROPAGATION_REQUIRED,ISOLATION_DEFAULT


// a proxy placeOrder metódusát meghívják
JpaTransactionManager: Creating new transaction with name [hu.egalizer.template.service.OrderService.placeOrder]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT


// EntityManager példányként egy Hibernate Session-t nyit a JPA
JpaTransactionManager: Opened new EntityManager [SessionImpl(799306600<open>)] for JPA transaction


// JDBC tranzakció lesz belőle
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@5e9ea380]


// a tranzakció kommitolódik és lezárul
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(799306600<open>)]
JpaTransactionManager: Closing JPA EntityManager [SessionImpl(799306600<open>)] after transaction

Spring tranzakciókezelés és JPA repository-k

A repository interfészeinkből a már ismert módszerrel, vagyis proxy-k létrehozásával lesz Spring bean. Ezeket a proxykat a Spring induláskor hozza létre, miután a scan fázisban megkereste, milyen repository-kat definiáltunk. A létrehozást egy ún. repository factory végzi. Spring Data JPA repository-k esetén:

  • org.springframework.data.jpa.repository.support.JpaRepositoryFactory

Spring Data JDBC repository-k esetén pedig:

  • org.springframework.data.jdbc.repository.support.JdbcRepositoryFactory

A Spring Data JPA fejezetben látott repository-származási fában volt két elem, amelyeknél feltűnhetett, hogy az a kettő nem interfész hanem osztály:

  • org.springframework.data.jdbc.repository.support.SimpleJdbcRepository
  • org.springframework.data.jpa.repository.support.SimpleJpaRepository

A factory ezeket az osztályokat használja alapként, ezek tartalmazzák a származás alapján megkapott összes metódus alap implementációját, a factory ezeket bővíti ki a saját interfészünk definíciója alapján. Ha megnézzük ezeknek az alap implementációknak a kódját, akkor látjuk hogy az osztály már eleve readonly transactional annotációval van jelölve, minden módosító metódus pedig külön readonly nélküli transactional annotációval. Tehát bármely öröklött metódus tranzakcióban fog futni. A korábban írtak alapján ha már létező fizikai tranzakcióval hívtuk meg a metódust akkor abban fog futni, ha nem akkor létrejön egy új tranzakció. Minden olyan metódus amit nem örököltünk, hanem mi definiáltunk (ezek mindig lekérdező metódusok) nem hoz létre tranzakciót, vagyis ha már létezik fizikai tranzakció akkor azon belül fut, de ha nem, akkor nem hoz létre. (Vagyis setAutoCommit(true) állapotban fut, bár ez lekérdező metódusok esetén nem katasztrófa.) Egyébként a saját repository interfészünk metódusait is megjelölhetjük @Transactional annotációval, vagy akár az öröklötteket is felülírhatjuk (például ha a traznakció beállításain változtatni szeretnénk).

Minden, a cikkben tárgyalt témáról önmagában lehetne még külön egy ilyen méretű ismertetőt írni, de valahol meg kell húzni a határt, én itt húztam meg. Remélem, mindenkinek hasznos volt a cikk, aki időt szánt az elolvasására.