Appunti avanzati di Hibernate

I parte: La gestione degli oggetti persistentidi e

Utilizzare Hibernate in maniera corretta è una pratica molto importante al fine di non incorrere nei tipici errori che possono portare a rallentamenti o peggio ancora a dati non corrispondenti al modello di business come ci si aspetterebbe. In questa serie cercheremo di affrontare i tipici antipattern e le più comuni soluzioni.

Una nuova serie

Qualche tempo addietro, nell'ambito di una tipica chiacchierata di lavoro fra colleghi della stessa azienda di consulenza, ci siamo confrontati sul livello di adozione delle tecnologie più recenti nella media dei clienti/progetti presso i quali svolgiamo regolarmente le nostre attività di formazione e consulenza. Discutendo dei vari layer tecnologici e delle relativi strumenti e framework siamo finiti a parlare di Hibernate. Entrambi concordavamo come fosse ormai considerato uno standard de facto e che la bontà del prodotto lo avesse ormai imposto come strumento di riferimento per la gestione della persistenza in Java. Tutti e due però ci siamo trovati praticamente in perfetto accordo relativamente al fatto che spesso capita di imbattersi in progetti in cui il noto ORM viene utilizzato in maniera impropria, o che di sovente si incappa in errori tipici, e sul fatto piuttosto lampante che spesso le domande che ci vengono poste appartengono a una ristretta cerchia di argomenti.

Scambiandoci pareri su alcuni casi tipici, si andava sempre più o meno a cascare sui classici temi: la gestione dei salvataggi in cascata, il ciclo di vita degli oggetti, le relazioni inverse, la gestione delle transazioni e i livelli di isolamento. Un po' per raccogliere le idee su come migliorare il corso su Hibernate, un po' per capire se c'era il tempo e lo spazio per scriverci degli articoli, abbiamo iniziato a raccogliere degli appunti in maniera vagamente strutturata e ci siamo resi conto in breve tempo che avremmo potuto organizzare tanto materiale per pubblicare articoli per tutto il prossimo anno.

Abbiamo quindi deciso di formalizzare meglio la scaletta e abbiamo deciso di impostare il lavoro in modo da proporre ai lettori del materiale cercasse di rispondere almeno ad alcune delle più frequenti e importanti domande.

Quella che parte questo mese è quindi una serie di articoli dedicati a Hibernate in cui tralasceremo i concetti base del framework (cosa è, come funziona, come si configura un semplice schema di persistenza) cercando di concentrare l'attenzione su quei temi più avanzati che spesso inducono in errore il programmatore, che danno vita a design errati, che possono portare a malfunzionamenti o semplicemente che possono impattare in maniera negativa sulle prestazioni. La parola "avanzato" è spesso un'arma a doppio taglio da cui il bravo articolista si tiene coscienziosamente alla larga perche‘ può dar vita ad aspettative da parte dei lettori che poi rischiano di essere disattese. Il titolo corretto di questa serie poteva essere "Come affrontare in maniera corretta alcune delle più comuni casistiche della programmazione del motore di persistenza Hibernate. Come evitare gli errori più frequenti e come realizzare applicazioni Hibernate senza incappare negli errori più frequenti". Ovviamente un titolo così lungo non avrebbe permesso una facile visualizzazione nel browser, per cui abbiamo optato per un più sintetico e meno pretenzioso "Appunti avanzati di Hibernate". La speranza è che quanto andremo qui a proporre possa essere realmente utile per i nostri lettori e che dopo aver letto questi articoli, sia pù facile capire quando si usa il metodo flush(), a cosa serva il "reverse=true", e quale sia il corretto modo di tenere un oggetto in sincrono con il DB.

I temi trattati

Al momento i temi che abbiamo identificato e che cercheremo di dissezionare a partire da questo numero e nei prossimi mesi, sono i seguenti (anche se in corso d'opera non sono improbabili modifiche, aggiunte o semplici riduzioni).

La gestione del ciclo degli stati degli oggetti persistenti

Stato degli oggetti mappati in Hibernate e diagramma di transizione; l'importanza dell'ovveride dei metodi equals, dei vari metodi di variazione stato e di forzatura della persistenza (flush). Tutti pensano che un save o saveOrUpdate generino automaticamente una INSERT/UPDATE mentre in realtà cambiano solo lo stato dell'oggetto da new/detached in persistence. Di questo parliamo già in questo articolo

I metodi di callback che portano a variazione di stato degli oggetti

Trattazione sui principali metodi forniti dalla Sessione Hibernate (o EntityManager) che portano a variazione di stato: quali sono i diversi comportamenti strutturali.

Tecniche di mapping

Come si implementano tutti i mapping non banali: da chiavi allineate, nested, componenti, etc...

Strumenti per il mapping reverse engeeneering

Gestione delle regole di mapping nei toolset e gestione delle regole semantiche di mapping automatico tra POJO e tabelle e viceversa.

Fare query in Hibernate

Introduzione alle tecniche di ricerca in Hibernate: HQL, CRITERIA e Query By EXAMPLE e le varie problematiche di Lazing e Fetching.

Ancora sulle query

Gli Hibernate filter e trattazione sui campi calcolati. Le query batch e il controllo delle query di insert, delete e update in modo esplicito. Chiamate a stored procedures. Mapping controllato di query su oggetti Hibernate: esempio di come realizzare query "massive" quando la mole di dati non permette l'uso di delle normali tecniche HQL.

Introduzione alla gestione delle transazioni: livelli di isolamento

Come implementare i vari livelli di isolamento grazie all'utilizzo degli attributi transazionali di vario tipo. Il versionamento e l'optimistic locking (argomento super gettonato); il pessimistic locking e il confronto con la versione precedente.

Ancora sulla gestione delle transazioni: strategie di transazione

Sessione Hibernate e trattazione delle strategie di trasazione (JDBC, JTA e CMT EJB, SPRING): come gestire esplicitamente le transazioni (Programmatic Transaction model) e come invece sia possibile "legarle" dichiarativamente a un Container Managed Transactions (CMT) e di conseguenza alla tecnologia EJB3 o Spring. Integrare la transazionalità degli EJB (Required, RequiresNew etc.) con la Sessione Hibernate.

Hibernate Validator

Utilizzare gli strumenti di validazione per eseguire logica di controllo sul layer di ORM.

Interceptors e Lifecycle

Una trattazione relativa agli interceptor/eventi forniti da Hibernate. Presentazione di esempi di utilizzo.

Gli stati degli oggetti persistenti e la loro gestione

Dopo aver illustrato come si svolgerà presumibilmente il nostro itinerario, partiamo per la prima tappa analizzando il diagramma di figura 1, che rappresenta efficacemente l'argomento degli stati degli oggetti in Hibernate

 

 

Figura 1 - Il diagramma di stato di un oggetto Hibernate: i vari stati e i passaggi causati dai corrispondenti eventi.

In questo diagramma di stato sono raffigurati i possibili passaggi di stato che un oggetto persistent può assumere durante il suo normale ciclo di vita. Per quanto semplice possa essere tale diagramma è probabilmente uno dei più importanti che il programmatore Hibernate dovrebbe tenere sempre a mente. Gli stati che un oggetto può assumere sono essenzialmente quattro.

Transient

Transient è lo stato in cui si trovano gli oggetti che vengono istanziati per mezzo di una comune operazione di new: tali oggetti non sono associati a una sessione Hibernate (Persistence Context), non hanno una rappresentazione persistente all'interno del database di riferimento e non possiedono quindi nessun identificativo univoco. La mancanza di una chiave è una caratteristica fondamentale di questo stato ed è la principale differenza che sussiste fra oggetti transienti e oggetti disconnessi (detached). Un'istanza transient può essere distruttua dal garbage collector se questa non viene più referenziata e ovviamente oggetti che hanno solo riferimenti ad oggetti transient sono a loro volta transient.

Gli oggetti in stato transient (o volatile) vengono considerati da Hibernate e dalla JPA non transazionali; di conseguenza, ogni modifica apportata allo stato interno di un oggetto di questa tipologia non viene considerata dal persistence context (nessun spporto per commit o roll-back delle modifiche che semplicemente verranno perse).

Persistent

Persistent è lo stato in cui si trova un oggetto candidato a essere reso persistente sul database. Parliamo di "candidatura" in quanto Hibernate applica il pattern "trasparent transaction-level write-behind" (TTW) ossia cerca di ritardare il più possibile tutte le operazioni di sincronizzazione (richieste in una transazione o "unit of work") necessarie per allineare lo stato interno degli oggetti con i dati contenuti nel database; l'obiettivo è quello di ottimizzare e minimizzare le query generate e diminuire la probabilità di lock necessari per garantire lo stato di consistenza all'interno di una transazione. Come da diagramma degli stati, gli oggetti nello stato transient o detached passano nello stato persistent tramite i metodi forniti dalla Sessione Hibernate (o EntityManager in caso di JPA), tramite associazione diretta con un oggetto già in uno stato persistent oppure perche' derivati (navigando le relazioni associative fra oggetti) da una istanza già persistent. Quando un oggetto nello stato transient passa in uno stato persistent può accadere che sia violato il pattern TTW: l'associazione a una chiave primaria generata automaticamente (ad esempio tramite l'esecuzione di una sequence del database) richiede infatti l'esecuzione di una operazione di inserimento anticipate. Nei successivi articoli vedremo che ci sono anche altre operazioni dichiarative o automatiche che necessitano di accessi anticipati al database come ad esempio la gestione del versionamento. Per ogni singola modifica eseguita su un oggetto che si trova nello stato persistent Hibernate etichetta tale oggetto come "dirty" ossia inconsistente rispetto alla sua rappresentazione all'interno del database: in questo stato l'oggetto dovrà essere riallineato tramite una operazione di scrittura entro la chiusura della sessione Hibernate ad essa associato; vedremo più avanti come Hibernate determina un oggetto persistent "dirty".

Un oggetto persistent è sempre associato al persistent context ( di conseguenza a una unit of work e una transazione) e può essere considerato a tutti gli effetti una copia in memoria di un dato presente nel database. Sono gestiti dal framework tramite un meccanismo di cache. Come questa sincronizzazione viene mantenuta e come la cache sia gestita è uno degli argomenti di Hibernate più complessi e grandi da affrontare. Torneremo più volte su questi argomenti.

Importante notare che, strettamente legato al concetto di chiave primaria, c'è quello di comparazione degli oggetti persistenti. In questo stato un oggetto infatti è legato alla sessione e Hibernate garantisce quindi che ci sia un unica istanza: ogni operazione di creazione duplicata viene bloccata se si tenta di salvare un oggetto avente lo stesso identificativo (chiave naturale business keys) di un oggetto già presente in sessione. Per ovviare a questa limitazione è possibile forzare la disconnessione dell'oggetto dal context (ossia trasformato in una semplice istanza in memoria) tramite varie operazioni o eventi, la più evidente delle quali è l'invocazione del metodo evict().

In tale contesto assume particolare importanza l'implementazione (più precisamente l'override) dei metodi equals() e hashcode(), al fine di evitare errori nella identificazione delle varie istanze degli oggetti (specialmente in presenza di chiavi surrogate quando le regole di business richiedono d'identificare un record non solo tramite la chiave numerica progressiva).

Removed

In ogni momento si può sempre cancellare una istanza di un oggetto in vari modi. Ad esempio si può utilizzare una esplicita operazione di rimozione tramite il persistence manager. Un oggetto diviene disponibile per la cancellazione se si rimuovono tutti i reference, funzionalità tipica introdotta da Hibernate detta "orphan deletion for entities".

Un oggetto si trova nello stato di removed se una qualche operazione di cancellazione è stata eseguita nella relativa unit of work, ma l'oggetto è ancora associato al persistence context fino a che l'unità di lavoro non verrà terminata. In altre parole l'invocazione del metodo Session.delete(Object) mette l'oggetto in uno stato di cancellabilità, che non è mai immediata: la delete non viene eseguita se non a fine sessione.

La modifica di una della proprietà dell'oggetto sempre all'interno dello scope di lavoro lo riporta in una stato di persistent e dirty e quindi viene eseguito solo UPDATE e non più una DELETE. L'oggetto, la cui corrispondente riga sul database viene eliminata, entra uno stato di detached (a fine sessione) e rimane "vivo" fino a quando non viene eliminato dal garbage collector; di conseguenza se questo viene riagganciato con una operazione di save() a una nuova sessione, questa porta alla generazione di una INSERT.

Detached

Detached è uno stato particolare, che corrisponde alla condizione che vede l'oggetto non più sincronizzato con il database, ma ancora utilizzabile a tutti gli effetti come una variabile in memoria. Questo stato è utile perche‘ consente di rimuovere l'eventuale lock applicativo sul database con un innegabile risparmio di risorse rendendo al contempo il sistema più agile.

Un oggetto è detached (detto anche disconnesso) se lo si è ottenuto durante una ricerca o una crezione eseguite all'interno di una transazione, ma la transazione è ormai conclusa. Si possono eseguire ulteriori operazioni sull'oggetto stesso, ma nessuna sarà resa persistente.

Spesso si rischia di fare confusione con lo stato transient dato che i due stati corrispondono a una condizione in cui l'oggetto non è sincronizzato con il database; la differenza è però molto semplice dato che un oggetto disconnesso di fatto contiene dei dati che corrispondono a dati presenti nel database, anche se ovviamente potrebbero non essere più corrispondenti con la versione in memoria. Naturalmente, come detto in precedenza, un oggetto transient può essere considerato detached al momento in cui viene assegnato programmaticamente il valore dell'identificativo.

Vedremo negli articoli che seguiranno che determinate operazioni eseguite sugli oggetti hanno effetto solo se eseguite su gli oggetti in stato detached (esempio: gestione "forzata" del versionamento) e come invece sia facile cadere in errore se si eseguono altri tipi di manipolazione su oggetti detached invece che transient (esempio: accesso a proprietà di oggetti associati ma "caricati" in lazy)

Passaggio di stato

Il passaggio di stato fra queste quattro condizioni è causato dalla invocazione dei vari metodi di manipolazione degli oggetti stessi. Di fatto questo meccanismo (ossia quando e come un oggetto passa di stato) rappresenta il primo punto essenziale della nostra trattazione: se si guarda quali sono gli eventi che causano un passaggio di stato si noterà che si tratta nella maggior parte dei casi di metodi che hanno un significato familiare e che proprio per questo hanno spesso condotto a errate interpretazioni. Si prenda ad esempio il metodo save(): come molti pensano, e come il nome facilmente lascia credere, tale metodo permette di salvare un oggetto, ossia di causare la scrittura da parte dell'ORM di una riga o più nella tabelle del database. In linea di massima l'ipotesi è esatta, anche se non del tutto precisa: il risultato finale è certamente quello che prima o poi (il "poi" è dovuto allo strato di cache, di cui si parlerà più avanti) un dato verrà scritto nel database; quello che è inesatto è pensare che il metodo serve per salvare l'oggetto. Da un punto di vista più rigoroso, infatti con save() non chiediamo ad Hibernate di salvare l'oggetto, ma di eseguirne un passaggio di stato, da transient a persistent. Se l'oggetto si trova nello stato persistente, allora è compito di Hibernate di mantenere allineate le modifiche fatte nell'oggetto in memoria con il database. In base a questo semplice concetto, appare chiaro quello che è un errore piuttosto comune: spesso, infatti, si trovano nel codice inutili chiamate in cascata a save() perchè si pensa che ad ogni modifica dello stato dell'oggetto debba essere salvata. In questo caso ogni chiamata (inutile) di save() ha solo la conseguenza di cambiare (inutilmente) lo stato dell'oggetto da persistent a persistent. Lo stesso discorso vale quando si associa (ad esempio in una gerarchia master-detail) un oggetto A nello stato detached o transient a un oggetto B nello stato persistent (p.e.: B.setA(A)) portando "automaticamente" A nello stato persistent senza la necessità di invocare alcun metodo di save fornito dalla Sessione Hibernate; in questo caso ogni invocazione di save() sull'oggetto A è assolutamente inutile. Quando A diventa dirty a fronte di una possibile modifica al suo stato interno, Hibernate si prende carico di allinearlo con la rappresentazione nel database.

Gestione della sincronizzazione tramite gli snapshots

Ogni volta che un oggetto entra nello stato di persistent, viene creato, in modo assolutamente trasparente, uno snapshot con i valori congelati al momento in cui entra nello stato persistence, snapshot che verrà utilizzato da Hibernate per verificare se l'oggetto in memoria differisce dalla corrispondente versione persistita nel database. Tramite i valori dello snapshot, Hibernate riesce a determinare ad esempio quali proprietà devono essere inserite in una possibile query di update nel caso in cui venga attivato il dynamic-update (caso in cui sono aggiornati solamente gli attributi realmente modificati dell'oggetto, tecnica utile se l'oggetto o la tabella hanno molti campi). L'utilizzo di snapshot in memoria è utile anche per implementare tecniche di optimistic locking: in questo caso si utilizza una" where condition" su i campi modificati fissando i "vecchi valori"; una modifica da parte di un'altra transazione dei valori "congelati" e riportati nella query di Update porta a una condizione di errore. Si parlerà debitamente di questo aspetto quando affronteremo l'argomento della gestione dei lock ottimistici.

La gestione degli snapshot può comportare un "Out of Memory" se gli oggetti precaricati in sessione sono numerosi o molto ingombranti (definizione sicuramente poco scientifica) perchè la quantità di memoria viene inevitabilmente raddoppiata.

Per risolvere questo problema si possono seguire varie strade.

  • La più prudente è di tenere la dimensione del persistence context al minimo possibile: non di rado capita di trovare in sessione oggetti in stato di persistent per puro caso e senza una reale necessità di business. Conviene quindi fare molta attenzione alle query, caricando in memoria solo gli oggetti strettamente necessari.
  • Per gli oggetti disconnessi non viene eseguito nessun controllo sullo stato di dirty; per questo, appena possibile, è consigliabile sganciare gli oggetti dal contesto utilizzando il metodo evict(), o tramite una chiamata a clear().
  • È infine possibile dichiarare gli oggetti "Read Only" ed eliminare di conseguenza la creazione di snapshot; il metodo session.setReadOnly(object, true) consente questa impostazione. Una successiva invocazione del metodo session.setReadOnly(object, false) di fatto riabilita il dirty checking per quella particolare istanza. Ovviamente queste operazioni non alterano lo stato dell'oggetto e rientrano sotto la definizione di flushing del persistence context.

 

Flush del persistence context

La gestione della sessione Hibernate segue una politica che implementa il cosiddetto write-behind: ogni modifica effettuata su un oggetto non viene immediatamente propagata al database ma tenuta in memoria per il più tempo possibile.

Questa strategia, perseguita essenzialmente per massimizzare le prestazioni, è spesso nota tramite la definizione del pattern introdotto precedentemente "trasparent transaction-level write-behind" (TTW), tecnica che permette di ottimizzare le query di inserimento e sopratutto quelle di modifica. Si pensi ad esempio al caso in cui siano eseguite differenti set (magari relativi ad attributi differenti): in questo caso il sistema dovrebbe generare un UPDATE mentre alla chiusura della unit of work (o alla chiamata di una flush) verrebbe generata un'unica query.

L'operazione di sincronizzazione con il motore di persistenza viene detta flushing (letteralmente svuotamento del buffer), operazione che viene eseguita nei seguenti casi:

  • Al completamento di una transazione quando viene eseguita una commit.
  • Prima che una query sia eseguita (per ovviare all'insorgere di letture sporche: parleremo di questo aspetto dettagliatamente quando si parlerà di transazioni e di livello di isolamento).
  • Quando viene esplicitamente invocato il metodo session.flush()

Il flush al termine di una unit of work (ossia quando concettualmente una sessione deve terminare oppure quando una transazione deve essere sottoposta a operazione di commit) garantisce che i dati siano sincronizzati e viene eseguito automatico quando una unità di lavoro necessita di rendere le modifiche persistenti.

Il flush non è detto che sia eseguito automaticamente solo al termine della transazione, dato che Hibernate è in grado di dedurre se una modifica in memoria possa impattare sulle successive operazioni di interrogazione (e qundi se sia necessario un flush ulteriore prima della chiusura della transazine o della sessione). Tale comportamento può essere configurato e forzato tramite il metodo session.setFlushMode(): il valore (o comportamento) di default è dato dal valore FlushMode.AUTO che causa il comportamento appena descritto.

Se invece si opta per FlushMode.COMMIT, ogni operazione di flush sarà effettuata solo al momento della commit della transazione o quando verrà invocato il metodo Session.flush() manualmente.

Questa configurazione, se da un lato porta a una riduzione degli accessi al database, può portare alla presenza di dati "sporchi" in memoria e deve essere usata con attenzione. In tal senso ancora più forte è la scelta della modalità FlushMode.MANUAL che riduce le operazioni di flush al solo caso in cui sia invocato il metodo Session.flush().

Erroneamente e frequentemente il ricorso al metodo flush viene visto come una misteriosa panacea per risolvere errori o per riportare sotto controllo determinati comportamenti incomprensibili del codice scritto. Purtroppo, se si ottengono letture sporche o non si riesce a rendere persistenti i dati (specie sulle relazioni in cascata), non è per la mancanza di un "magico" flush() al punto giusto. Non di rado si assiste alla presenza ingiustificata di chiamate a flush() in dispiegamento sparso e casuale all'interno del codice: ricordiamo che, se si lavora con flush in modalità automatica (che vista la scarsa conoscenza della possibilità di modificarne il comportamento base è anche la modalità usata più frequentemente) la sincronizzazione dei dati fra cache in memoria e database è sempre garantita.

Compito del programmatore è eventualmente capire se sia possibile migliorare le performance semplificando il lavoro dell'ORM dato che per ogni query il sistema deve verificare se i dati sono coerenti.

Un caso tipico è quello in cui sono eseguite ripetutamente e alternativamente operazioni di lettura e di modifica: le molte operazioni di check-flush che sono eseguite dal motore di persitenza comportano un ovvio decadimento delle prestazioni. In questo caso meglio passare alla modalità FlushMode.COMMIT con un controllo manuale delle flush.

Quindi in buona sostanza, giusto per tranquillizzare il programmatore compulsivo del flush, nella maggior parte dei casi un uso sconsiderato di tale routine ha esclusivamente effetti (negativi) sulle prestazioni. Se i dati nel database non sono come ce li aspettiamo è presumibile che gli errori siano altrove.

Conclusioni

In questo prima puntata della serie abbiamo iniziato a parlare di alcuni semplici concetti che sono legati a un corretto utilizzo di Hibernate. Riteniamo che per molti lettori le considerazioni qui riportate potranno apparire ovvie, ma era doveroso partire da una ampia ed esaustiva trattazione degli stati di un oggetto persistente e degli eventi che ne decretano il passaggio da uno stato all'altro. Ogni ulteriore considerazione (che seguirà nelle prossime puntate) deve necessariamente partire da quanto qui proposto e dal fondamentale diagramma riportato in figura 1.

Riferimenti

[JPH] C. Bauer - G.King, "Java persistence with Hibernate", Manning

Condividi

Pubblicato nel numero
139 aprile 2009
Cristian Faraoni è nato a Forlì (FC) nel 1970 ed è laureato in Scienze dell‘Informazione all‘Università degli studi di Bologna. A livello professionale, si occupa di sviluppo, analisi e progettazione del software dal 1997. Attualmente lavora per Imola Informatica S.P.A. svolgendo principalmente attività di consulenza.
Giovanni Puliti lavora come consulente nel settore dell’IT da oltre 20 anni. Nel 1996, insieme ad altri collaboratori crea MokaByte, la prima rivista italiana web dedicata a Java. Da allora ha svolto attività di formazione e consulenza su tecnologie JavaEE. Autore di numerosi articoli pubblicate sia su MokaByte.it che su…
Articoli nella stessa serie
Ti potrebbe interessare anche