Technológiák: JPA 2.0, EclipseLink 2.3.2, Hibernate 4.1.2
Gyakorta találkozunk JPA használatakor a lazy loading fogalmával (vagy más néven deferred loading, lazy
fetching, on-demand fetching, just-in-time reading, indirection, stb.), azonban használata koránt sem olyan triviális, mint amilyennek tűnik. A JSR 317: Java Persistence API, Version 2.0 specifikáció is viszonylag szűkszavúan nyilatkozik róla. Annyit ír, hogy @Basic, @OneToOne, @OneToMany, @ManyToOne, @ManyToMany és az @ElementCollection annotációknál használatos, és ez egy
Miért is érdemes lazy loadingot alkalmazni? Alapvetően teljesítménynövelés céljából. Először meg kell különböztetni a két esetet, mikor egy entitás egy mezőjére, vagy egy kapcsolatra tesszük rá. Az előbbi akkor lehet hasznos, amennyiben egy entitás nem minden mezőjét akarjuk mindig betölteni, mert vagy túl sok van neki, vagy túl nagyok. Utóbbi esetre egy példa egy hallgatói nyilvántartásban a hallgatóhoz tartozhat egy fénykép is, de ez nagy, blob-ban tároljuk, és egy listázó képernyőn nem akarjuk megjeleníteni, csak a részletek képernyőn. Ezzel vigyázni kell, mert ha nem megfelelően használjuk, azaz nincs nagy mennyiségű mező vagy nem nagy méretű, akkor többet ártunk, mint használunk. Ugyanis az újbóli adatbázishoz fordulás erőforrás igényesebb lehet, mint pár plusz adat lekérése. Kapcsolódó entitások esetében példa, ha egy listázó képernyőn nem akarjuk a számlához tartozó tételeket megjeleníteni, kivéve a részletek képernyőn, ahol szükséges őket felsorolni. Háttérben ez úgy történik, hogy egy proxy osztály kerül generálásra (EclipseLink és Hibernate esetében eltérően), és nem tölti be a lekérdezés esetén a megfelelő mezőt vagy kapcsolatot, és így kerül példányosításra a proxy objektum. Amikor a proxy objektumtól mégis elkérésre kerül a nem betöltött mező vagy osztály, akkor próbálkozik a persistence provider a betöltéssel.
Postomban a lazy loading viselkedését vizsgálom a két legelterjedtebb provider, az EclipseLink és a Hibernate esetében. A Hibernate EclipseLink átállás post részletesen leírja, hogyan lehet átállni Hibernate-ről EclipseLink-re, valamint ebben leírom, mi az a weaving, Java agent és instrumentation, így ezekre itt nem térek ki részletesebben.
Példaként már egy előző postomban bemutatott esetet használok, kicsit módosítva.
A példaprogram letölthető a GitHub-ról. A példaprogram a mvn jetty:run parancs kiadásával már futtatható is. A példaprogram jó példa arra is, hogyan lehet az EclipseLink-et vagy a Hibernate-et Spring-gel integrálni. Az alkalmazásba beágyazott HyperSQL adatbázis-kezelő biztosítja a perzisztenciát. A böngészőbe a http://localhost:8080 címet írva bejön egy oldal (/employees.html), melyet a EmployeeController.listEmployees metódusa szolgál ki, mely a DefaultEmployeeService.listEmployees metódusát hívja. Ez az EntityManager.createNamedQuery-t hívja egy select-tel. Ez adja vissza a List<Employee> struktúrát, melyet a JSP oldal megjelenít. Az alkalmazott nevére kattintva annak részletes adatai jelennek meg, az EmployeeController.findEmployeeById metódusa hívja a EmployeeService.findEmployeeById metódusát, mely a EntityManager.find metódusát hívja, ami azonosító alapján tölti be az adott entitást. Két employee rekord szerepel az adatbázisban, mindkettő 2-2 telefonszámmal (, ahogy az init.sql-ben látható). Az Employee esetén egyrészt a cv mezőt vizsgáljuk, másrészt a List<Phone> kapcsolatot. A service-ben szereplő metódusok hívásakor jön létre a persistence context, majd annak lefutásakor az lezárásra kerül. Az entitások a persistence context lezárásakor detached állapotba kerülnek. A kódot bőséges naplózással láttam el, valamint bekapcsoltam, hogy a persistence provider írja ki a lefuttatott sql parancsokat. Így jól lehet látni, hogy milyen sql-eket ad ki, mikor még él a persistence context, és milyeneket mikor már lezárásra került.
EclipseLink
Az EclipseLink-et úgy konfiguráltam, hogy static weaving legyen, azaz fordítási időben instrumentálja a class fájlokat. Ehhez a pom.xml-ben a eclipselink-staticweave-maven-plugin-t használtam. Valamint a persistence.xml-ben a eclipselink.weaving property értékét static-ra kellett állítani. Build közben a következőket írja ki a weavinggel kapcsolatban:
[EL Finest]: 2012-04-22 01:41:28.453--ServerSession(3980107)-- Thread(Thread[main,5,main])--Begin predeploying Persistence Unit lazyPersistenceUnit; session lazyPersistenceUnit; state Initial; factoryCount 0 [EL Finest]: 2012-04-22 01:41:29.062--ServerSession(3980107)-- Thread(Thread[main,5,main])--Begin weaver class transformer processing class [jtechlog/lazy/service/Phone]. [EL Finest]: 2012-04-22 01:41:29.078--ServerSession(3980107)-- Thread(Thread[main,5,main])--End weaver class transformer processing class [jtechlog/lazy/service/Phone].
Alapértelmezetten az EclipseLink cache be van kapcsolva, ezért a persistence.xml-ben kikapcsoltam azért, hogy követni lehessen, hogy milyen SQL utasításokat ad ki.
<property name="eclipselink.cache.shared.default" value="false"/>
Első körben azt az alapértelmezett esetet vizsgáltam, mikor nem tettem semmilyen annotációt a CV mezőre. A listázó képernyőn a következőt kaptuk:
SELECT ID, CV, EMP_NAME FROM EMPLOYEE -- Persistence context lezárása SELECT ID, PHONE_NUMBER, PHONE_TYPE, EMPLOYEE_ID FROM PHONE WHERE (EMPLOYEE_ID = ?) SELECT ID, PHONE_NUMBER, PHONE_TYPE, EMPLOYEE_ID FROM PHONE WHERE (EMPLOYEE_ID = ?)
Az adott alkalmazott kilistázásakor hasonlóképp működik.
SELECT ID, CV, EMP_NAME FROM EMPLOYEE WHERE (ID = ?) -- Persistence context lezárása SELECT ID, PHONE_NUMBER, PHONE_TYPE, EMPLOYEE_ID FROM PHONE WHERE (EMPLOYEE_ID = ?)
Először vizsgáljuk csak a cv mezőt. Azt kaptuk, amit vártunk, minden mezőt lekérdezett mindkét képernyőn. Aztán a CV mezőre rátettem a @Lob annotációt, a helyzet változatlan volt. Amennyiben rátettem a @Basic(fetch = FetchType.LAZY) annotációt a cv mezőre, máris megváltozott a helyzet (, függetlenül attól, hogy a @Lob rajta volt-e vagy sem).
SELECT ID, EMP_NAME FROM EMPLOYEE -- Persistence context lezárása SELECT ID, CV, EMP_NAME FROM EMPLOYEE WHERE (ID = ?) SELECT ID, PHONE_NUMBER, PHONE_TYPE, EMPLOYEE_ID FROM PHONE WHERE (EMPLOYEE_ID = ?) SELECT ID, EMP_NAME FROM EMPLOYEE WHERE (ID = ?) SELECT ID, CV, EMP_NAME FROM EMPLOYEE WHERE (ID = ?) SELECT ID, PHONE_NUMBER, PHONE_TYPE, EMPLOYEE_ID FROM PHONE WHERE (EMPLOYEE_ID = ?) SELECT ID, EMP_NAME FROM EMPLOYEE WHERE (ID = ?)
Valamint az egy entitást bemutató képernyőn:
SELECT ID, EMP_NAME FROM EMPLOYEE WHERE (ID = ?) -- Persistence context lezárása SELECT ID, CV, EMP_NAME FROM EMPLOYEE WHERE (ID = ?) SELECT ID, PHONE_NUMBER, PHONE_TYPE, EMPLOYEE_ID FROM PHONE WHERE (EMPLOYEE_ID = ?) SELECT ID, EMP_NAME FROM EMPLOYEE WHERE (ID = ?)
Az addig rendben van, hogy az első select-ben ezek után nem kérdezte le a cv mezőt. Láthatjuk, hogy a persistence context lezárása után lekérdezi immár az alkalmazotthoz tartozó összes mezőt, köztük a CV-t is. Ez az EclipseLink egy speciális viselkedése, hogy a persistence context lezárása után is képes sql utasításokat kiadni, betöltve ezzel a lazy-vel definiált mezőket. Az azonban számomra rejtély, hogy miért kell még egy select-et kiadni az employee táblán.
Most pedig nézzük a kapcsolatot. Látható, hogy a @OneToMany-n alapértelmezetten a fetch type lazy, ugyanis a persistence context lezárása után kéri le a persistence provider a telefonszámokat. Ráadásul alkalmazottanként egy külön select-ben. Itt is látható az EclipseLink különleges viselkedése, hogy a persistence context lezárása után is le tudja kérdezni a még be nem töltött entitásokat. A szabvány amúgy nem írja le, hogy ilyenkor mi legyen. A persistence provider megpróbálhatja feloldani a nem betöltött kapcsolatokat, mint ahogy az EclipseLink is teszi, kivételt dobhat, ahogy a Hibernate, mint azt később látni fogjuk, vagy inicializálatlanul hagyja az adott mezőket. A @OneToMany, @ManyToMany és @ElementCollection esetén az alapértelmezett fetch type lazy, a @Basic, @OneToOne és @ManyToOne esetén eager.
A következő lépésben beállítottam a @OneToMany annotációnál a fetch paramétert FetchType.EAGER értéket. Az eredmény mind a két esetben ugyanaz volt, mint az előbb, azzal a különbséggel, hogy a kapcsolódó telefonszámokat nem a persistence context lezárása után kérte el, hanem még előtte. Azaz ugyanúgy több select-et futtatott, de mindet a persistence context lezárása előtt, azaz mielőtt átadta volna a vezérlést a view-nak.
Azon, hogy ne több select-et futtasson le, egyféleképpen tudtam segíteni, és kizárólag a lekérdezés esetén. Átírtam a következőre:
select distinct e from Employee e join fetch e.phones
A distinct használata kötelező, mert ha nem tettem ki, akkor egy alkalmazottat annyiszor hozott le, amennyi telefonszáma volt. A háttérben ugyanis itt egy join van, a következő lekérdezésnek megfelelő select-et futtatja le a persistence provider:
select distinct e, p from Employee e join e.phones p
Viszont a p eredmény értékét egyszerűen nem használja fel, hanem eldobja. A distinct használata tehát szükséges ilyenkor, ekkor az alkalmazottak listázásakor a következő select futott le:
SELECT DISTINCT t1.ID, t1.CV, t1.EMP_NAME, t0.ID, t0.PHONE_NUMBER, t0.PHONE_TYPE, t0.EMPLOYEE_ID FROM PHONE t0, EMPLOYEE t1 WHERE (t0.EMPLOYEE_ID = t1.ID)
Különösen érdekes lehet még a lazy loaddal bejelölt, és nem betöltött entitások kezelése cascade merge esetén. Ennek tesztelésére készítettem egy oldalt, mely betölti az alkalmazottat, telefonszámokat lazy fetch-re állítottam, és megjelenít egy űrlapot. Az Employee példányt session-be tettem (EditEmployeeController), majd a detach-elt példányt az űrlap postjakor módosítottam, majd merge-öltem vissza (EmployeeService.mergeEmployee()). Minden hibátlanul működött, a módosítás megtörtént, a telefonszámok érintetlenül maradtak.
Aztán kipróbáltam azt is, hogy a post után még hozzáadtam egy telefonszámot is. Az EclipseLink betöltötte a telefonszámokat, elvégezte az update-et az employee táblán, majd történt egy insert a phone táblába.
employee.addPhone(new Phone("home", "123"));
Van egy metódus arra, hogy megvizsgáljuk, hogy az adott kapcsolatok, melyek lazyvel vannak jelölve, betöltésre kerültek-e. A következőképpen használhatjuk:
Persistence.getPersistenceUtil().isLoaded(
em.find(Employee.class, 42), "phoneNumbers");
Hibernate
Ugyanazzal az alapfelállással indulok, mint az EclipseLink esetében, azaz nincs plusz annotáció, és a @OneToMany annotációnál sincs plusz paraméter. Az előző cikkem óta a Hibernate 3.3.0.CR2-ben a bájtkód módosítását a CGLib helyett a javassist végzi (, amiről szintén írtam korábban) , ugyanis az előbbi nagyon inaktív projekt lett, így leváltották. Mindkét oldalt betöltve a következő hibaüzenetet kapom:
org.hibernate.LazyInitializationException: failed to lazily initialize a
collection of role: jtechlog.lazy.service.Employee.phones, no session or
session was closed
at org.hibernate.collection.internal.AbstractPersistentCollection.
throwLazyInitializationException(AbstractPersistentCollection.java:393)
...
Ahogy említettem, csak az EclipseLink sajátossága az, hogy lazy loadinggal megjelölt kapcsolatban részt vevő entitásokat a persistence context lezárása után (azaz detach állapotban lévő entitásokat is) be tud tölteni. A Hibernate ezt nem tolerálja, amennyiben nem betöltött mezőre történik hivatkozás, és a persistence context nincs nyitva, ezt a hibaüzenetet kapom. A Hibernate-tel való ismerkedéskor ez a leggyakoribb hiba.
A probléma megoldására többfajta megoldás létezik:
- Eager fetch-t állítunk be a @OneToMany annotáció paraméterében.
- Ha entitást akarunk a view rétegnek levinni, akkor megtehetjük azt is, hogy amíg a persistence context aktív, meghívjuk a megfelelő getter metódust.
- A fentivel ekvivalens, ha a view rétegnek nem entitást adunk, hanem egy un. data transfer object-et. Ezt még addig feltöltöm (gyakorlatilag az előző esethez hasonlóan a gettereket hívom), míg a persistence context aktív, így nincs probléma. Ez több programozási feladattal jár, a DTO-k plusz réteget jelentenek.
- Külön lekérdezést alkalmazunk fetch join-nal, vagy un. projection query-t, vagy akár JDBC-vel is operálhatunk. Ez utóbbi még tervezési minta is Fast Lane Reader, mikor az ORM réteget kikerüljük, tipikusan teljesítménynövelés miatt, és alacsonyabb technológiával dolgozunk.
- Az eddigi megoldások azt célozták meg, hogy legyenek betöltve a megfelelő objektumok. A következők azt célozzák meg, hogy ne kelljen detach-elni. Amíg lehet, nyitva hagyjuk a persistence context-et, ha kell, a view réteg lefutása után zárjuk csak le.
- Vagy alkalmazás által vezérelt tranzakciókezelést vagy persistence context-et, vagy extended persistence context-et használunk. Ugyanis alapértelmezetten egy persistence context a tranzakció lezárásáig tart. Programból mi is vezérelhetjük a tranzakciókat. Vagy az EntityManager létrehozását EntityManagerFactory-n keresztül (createEntityManager metódus), és lezárását (a close metódusával). Vagy stateful session bean (EJB) esetén használhatunk extended persistence context-et, ez nem csak egy tranzakciónyi, hanem a bean élettartamáig élhet, programozottan lehet lezárni.
Amennyiben a @OneToMany annotáció paraméterében eager fetch-t állítunk be, az alkalmazottak listázása ugyanúgy történik, mint az EclipseLink esetén, azaz három query, először az alkalmazottak, majd alkalmazottanként a telefonszámok lekérdezése.
Azonban a Hibernate az EntityManager.find metódus használatakor már okosabb, mint az EclipseLink, egy left outer joint is tartalmazó lekérdezést futtat:
select employee0_.id as id0_1_, employee0_.cv as cv0_1_, employee0_.EMP_NAME as EMP3_0_1_, phones1_.employee_id as employee4_0_3_, phones1_.id as id3_, phones1_.id as id1_0_, phones1_.employee_id as employee4_1_0_, phones1_.PHONE_NUMBER as PHONE2_1_0_, phones1_.PHONE_TYPE as PHONE3_1_0_ from Employee employee0_ left outer join Phone phones1_ on employee0_.id=phones1_.employee_id where employee0_.id=?
Amennyiben a getter metódusokkal akarjuk az entitást inicializálni, vigyázzunk, nem mindegy, hogy mit hívunk meg, mert pl. a getPhones(), getPhones().get(1).getId() nem megfelelő, sőt a getPhones().size() sem, hanem pl. érdemes az első elem nem id mezőjét lekérni. Ez ezért van így, mert a Hibernate úgy módosítja a bájtkódot, hogy a lista létrejön, abban az Employee objektumok is, de csupán az id mezőjük van feltöltve, a többi getter hívás indukálja a további betöltéseket. Ez számomra egy kicsit visszás, mert a kódot olyan technikai részekkel gyarapítjuk, mely üzleti szempontból teljesen lényegtelen, ráadásul az alsóbb rétegnek kell ilyenkor a felsőbb réteg igényeiről tudnia, hogy a visszaadott adat hogy kerül megjelenítésre.
A join fetch-es lekérdezés ugyanúgy működik itt is, mint az EclipseLink esetén, itt a legenerált SQL kicsit másképp néz ki, a Hibernate az inner join kulcsszót használja.
select distinct employee0_.id as id0_0_, phones1_.id as id1_1_, employee0_.cv as cv0_0_, employee0_.EMP_NAME as EMP3_0_0_, phones1_.employee_id as employee4_1_1_, phones1_.PHONE_NUMBER as PHONE2_1_1_, phones1_.PHONE_TYPE as PHONE3_1_1_, phones1_.employee_id as employee4_0_0__, phones1_.id as id0__ from Employee employee0_ inner join Phone phones1_ on employee0_.id=phones1_.employee_id
Persze a ebben az esetben az findEmployeeById metódust is át kellett írni, hogy ne az EntityManager.find metódusát használja, hanem szintén fetch join-os lekérdezést:
return em.createNamedQuery("findEmployeeById", Employee.class)
.setParameter("id", id).getSingleResult();
Valamint a NamedQuery:
@NamedQuery(name = "findEmployeeById", query = "select distinct e from Employee e join fetch e.phones where e.id = :id")
Megoldás lehet az is, hogy ha a persistence context tranzakciónyi, akkor a view rétegben kell a tranzakciót nyitni és zárni. Ezt nevezik transactional view-nak. Ennek továbbfejlesztése, hogy nem a tranzakciót nyitjuk a view-ban, hanem az entity manager-t, ennek neve az "Open EntityManager in View" minta használata, és kb. ugyanazon előnyökkel és hátrányokkal rendelkezik. Ehhez a következőt kell hozzáadni a web.xml-hez:
<filter>
<filter-name>openEntityManagerInViewFilter</filter-name>
<filter-class>org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>openEntityManagerInViewFilter</filter-name>
<url-pattern>*.html</url-pattern>
</filter-mapping>
Itt az történik, hogy a http kérés beérkeztekor a filter nyit EntityManager-t, azaz persistence context-et, és az adott szálhoz rendeli. A tranzakció kezdésekor a szolgáltatásrétegben a szálhoz rendelt EntityManager-hez kapcsolódik. Lefut a flush, majd a commit a szolgáltatás végén, ha minden rendben ment. Majd meghívásra kerül a view. Ennek visszaadása után zárja le csak a filter az EntityManager-t. Bár egyszerű és gyors megoldásnak tűnik, én nagy terhelésű rendszer esetén nem javasolnám a használatát. Egyrészt többen írják, hogy lassabb, bár saját mérési eredményeim nincsenek. Valamint ahhoz tartom magam, hogy az ilyen jellegű dolgokat minimális ideig tartsunk nyitva. Egyszerű, párfelhasználós, vagy pilot projekt esetén viszont jól jöhet.
OpenEntityManagerInViewFilter helyett használhatnánk OpenEntityManagerInViewInterceptor-t is, mely az application contextben jön létre, így hozzáfér az abban lévő bean-ekhez.
Itt még figyeljük meg a lekérdezéseket:
select employee0_.id as id0_, employee0_.cv as cv0_, employee0_.EMP_NAME as EMP3_0_ from Employee employee0_ -- Persistence context lezárása select phones0_.employee_id as employee4_0_1_, phones0_.id as id1_, phones0_.id as id1_0_, phones0_.employee_id as employee4_1_0_, phones0_.PHONE_NUMBER as PHONE2_1_0_, phones0_.PHONE_TYPE as PHONE3_1_0_ from Phone phones0_ where phones0_.employee_id=? select phones0_.employee_id as employee4_0_1_, phones0_.id as id1_, phones0_.id as id1_0_, phones0_.employee_id as employee4_1_0_, phones0_.PHONE_NUMBER as PHONE2_1_0_, phones0_.PHONE_TYPE as PHONE3_1_0_ from Phone phones0_ where phones0_.employee_id=?
Gyakorlatilag ugyanaz, mint az EclipseLink esetén, azzal a különbséggel, hogy a Hibernate mindenütt alias-okat használ.
Az alkalmazás által gyártott EntityManager használatát nem javaslom, hiszen nagyon oda kell figyelni, bonyolítja a kódot, plusz munkával jár, és az újrafelhasználhatóságot is megnehezíti. Az extended persistence context szintén nem befutó nálam, hiszen egyrész Spring-ezek, másrészt EJB esetén is kerülöm a stateful session bean-ek használatát.
Vizsgáljuk meg most a cv mező működését. Ahogy várható, mindig beolvassa. Azonban az már meglepő, hogy a @Lob, vagy a @Basic(fetch = FetchType.LAZY) vagy mindkét annotáció hatására sem változik semmi, azaz mindig berántja. Erről már írtam, ennek az az oka, hogy ez csak akkor működik, ha az osztályok instrumentálva vannak. A példa projektben a pom.xml-ben a maven-antrun-plugin futtat egy org.hibernate.tool.instrument.javassist.InstrumentTask-ot. Amennyiben ezt megtesszük, a következő kerül a konzolra.
main: [instrument] starting instrumentation [instrument] processing class : jtechlog.lazy.service.Employee; file = D:\projects\jtechlog\jtechlog-lazy\target\classes\ jtechlog\lazy\service\Employee.class [instrument] processing class : jtechlog.lazy.service.Phone; file = D:\projects\jtechlog\jtechlog-lazy\target\classes\ jtechlog\lazy\service\Phone.class
A class állományok is nagyobbak lesznek, és belenézve ilyeneket látunk: org/hibernate/bytecode/internal/javassist/FieldHandler. Azért jó ezt tudni, mert pl. engem a fejlesztőeszköz tévesztett meg azzal, hogy saját maga felülvágta a Maven által generált class állományokat, amik azonban még nem voltak instrumentálva. Innentől lazy mező esetén pont úgy működött, mint ahogyan az elvárható volt, és ugyanúgy figyelni kell arra, hogy ne kapjunk LazyInitializationException-t.
Merge esetén úgy működött, ahogy várható volt. Amennyiben csak az Employee példányon módosítottunk, és a detached objektumot merge-öltem vissza, sikerült, módosult az alkalmazott, és megmaradtak a telefonszámok. Amennyiben azonban a telefonszámokhoz új telefonszámot akartam felvenni, ugyanúgy kaptam a LazyInitializationException-t. Azaz ugyanúgy nekünk kell gondoskodnunk a betöltésről, ahogy azt Hibernate esetén megszokhattuk.
Összegzés
Bár nem tűnik túl bonyolult témakörnek, a lazy loading kezelése okozott számomra meglepetéseket. Az EclipseLink kicsit jobban elrejti ezt a dolgot, ezért csak ezt a tulajdonságát figyelembe véve jobb választás lehet. Azt azonban mindenképp javaslom, hogy ne hagyatkozzunk az elképzeléseinkre, hogy hogyan történhet a betöltés, hanem mindig állítsuk be azt, hogy a persistence provider írja ki az általa lefuttatott SQL lekérdezéseket, és minden képernyőnél elemezzük azokat, hogy nincs-e felesleges lekérdezés, vagy nem-e lehet úgy megfogalmazni a JPA lekérdezést, hogy több SQL lekérdezés helyett csak egy fusson.
Egy másik postban már említett Apress kiadó Pro JPA 2 Mastering the Java Persistence API könyve elég részletesen ír a lazy loading fogalmáról, de az implementációkat az sem hasonlítja össze.
