In un articolo precedente abbiamo visto come mappare una relazione uno a molti in NHibernate, ma quella mostrata è solamente una delle possibilità. Sebbene in una struttura relazionale ci sia un solo modo per implementare questa tipologia di relazioni, ovvero usando una Foreign Key, nel mondo ad oggetti le possibilità sono maggiori.
Osservando l'esempio accluso all'articolo e confrontandolo con quello del'articolo precedente, si può notare come sia stata rimossa la proprietà Customer
dalla classe Orders
e nel contempo aggiunta la proprietà Orders
nell'oggetto CustomerOne
.
public virtual IList<Order> Orders { get { return orders ?? (orders = new List<Order>()); } private set { orders = value; } } private IList<Order> orders;
Gli aspetti principali da notare sono tre: il primo è che la proprietà è di tipo IList<Order>
, e non invece List<Order>
; grazie a questo piccolo accorgimento NHibernate sarà in grado di iniettare la sua implementazione di IList
per usare il lazy load.
Utilizzare le interfacce e non le classi concrete è comunque una buona abitudine di progettazione, indipendentemente dal fatto che una libreria infrastrutturale come NHibernate lo richieda.
La seconda peculiarità è aver impostato la parte "setter" della proprietà come privata, il chiamante non deve infatti essere in grado di sostituire la collezione con un'altra, ma deve solamente intervenire attraverso i metodi della lista stessa (Add
, Remove
, etc). NHibernate d'altro canto non ha invece problemi ad accedere alla proprietà, perché agendo tramite reflection è in grado di invocare anche proprietà private.
Questo aspetto, che sembra violare l'incapsulamento, è invece utile, perché NHibernate ha la necessità di interagire in maniera più profonda con la classe, dato che ne deve gestire la persistenza. Infine l'ultima particolarità è la proprietà "getter", che utilizza una inizializzazione lazy della proprietà. In questo modo quando l'oggetto viene reidratato da NHibernate la collezione viene fornita dall'esterno, mentre nel caso di un oggetto transiente viene creata una lista valida al primo utilizzo.
Il primo mapping che verrà esaminato per gestire questa relazione è il seguente:
<bag name ="Orders" cascade="all-delete-orphan" table="Orders" lazy="true" > <key column="CustomerId" /> <one-to-many class="Order" /> </bag>
Il tag usato è <bag>
che indica una collezione con ripetizioni, ovvero che può contenere più di una volta lo stesso elemento. L'interfaccia IList<T>
in .NET ha infatti la semantica di un insieme di oggetti con ripetizioni e quindi questo tipo di mapping discende in maniera diretta.
L'attributo cascade
è all-delete-orphan
che serve ad impostare la persistenza by reachability con gli ordini della collezione, effettuando nel contempo anche la cancellazione dei record orfani. In questo modo se si effettua la cancellazione dell'oggetto padre (CustomerOne
) verranno cancellati in maniera automatica anche tutti i suoi ordini, che altrimenti rimarrebbero orfani.
L'attributo lazy
serve invece per indicare che il caricamento degli ordini deve essere fatto in modalità "pigra" ovvero solo quando necessario.
All'interno del tag <bag>
sono presenti altri due tag, il primo, chiamato key
, serve ad indicare la colonna della tabella correlata che implementa la relazione, il secondo, chiamato one-to-many
, indica invece la classe che deve essere usata nella relazione.
Alcuni esempi
Il primo esempio, contenuto nel Listato1, serve a creare un cliente con due ordini correlati, l'aspetto interessante come sempre è osservare il codice sql generato.
NHibernate: INSERT INTO CustomerOne (Name, Surname) VALUES (@p0, @p1); select SCOPE_IDENTITY(); @p0 = 'Gian Maria', @p1 = 'Ricci' NHibernate: INSERT INTO Orders (Date, Total) VALUES (@p0, @p1); select SCOPE_IDENTITY(); @p0 = '6/14/2008 10:18:08 AM', @p1 = '10' NHibernate: INSERT INTO Orders (Date, Total) VALUES (@p0, @p1); select SCOPE_IDENTITY(); @p0 = '6/14/2008 10:18:08 AM', @p1 = '20' NHibernate: UPDATE Orders SET CustomerId = @p0 WHERE id = @p1; @p0 = '5', @p1 = '3' NHibernate: UPDATE Orders SET CustomerId = @p0 WHERE id = @p1; @p0 = '5', @p1 = '4'
In questo caso viene inserito prima l'oggetto padre (CustomerOne
) poi i figli (Orders
), ma inizialmente il valore della foreign-key è pari a NULL
, infine vengono eseguite le query di update
per impostare la relazione padre-figlio tra ordine e cliente.
Questo comportamento obbliga quindi ad avere nel database il campo CustomerId
nullable
e sebbene sembri in un primo momento quantomeno inefficiente è giustificabile in pieno. Nhibernate infatti prima di tutto salva il padre, poi data la persistenza transitiva decide di salvare tutti i figli (ma ancora non ne imposta la relazione), questo è necessario per conoscere gli id
assegnati automaticamente dal database. Una volta che tutti gli oggetti sono persistenti arriva il momento di creare le relazioni con le istruzioni update
.
Nel listato2 viene invece mostrato cosa succede quando un ordine viene rimosso da un cliente, (nella prima parte le tabelle vengono troncate per non avere problemi con dati precedenti), viene nuovamente inserito un cliente con due ordini e poi viene eseguito il codice seguente
CustomerOne gm = session.CreateQuery("select c from CustomerOne c where c.Name = 'Gian Maria'") .UniqueResult(); Order o = gm.Orders[0]; gm.Orders.Remove(o); session.Flush();
Il recupero del cliente viene fatto con una query HQL molto semplice e che produce il sql seguente
NHibernate: select Customeron0_.id as id1_, Customeron0_.Name as Name1_, Customeron0_.Surname as Surname1_ from CustomerOne Customeron0_ where (Customeron0_.Name='Gian Maria' )
Il dato importante è che la selezione viene effettuata dalla sola tabella clienti, grazie al lazy load l'effettivo caricamento dei dati di orders
viene infatti posposto al momento del primo accesso (che avviene nella riga seguente). Basta infatti accedere a qualsiasi proprietà della collection Orders
per scatenare il recupero di tutti gli oggetti correlati; in questo caso il sql generato è il seguente.
SELECT orders0_.CustomerId as CustomerId__1_, orders0_.id as id1_, orders0_.id as id0_0_, orders0_.Date as Date0_0_, orders0_.Total as Total0_0_ FROM Orders orders0_ WHERE orders0_.CustomerId=@p0; @p0 = '1'
Dal quale si può notare come nhibernate non sta facendo altro che recuperare tutti gli ordini della tabella orders correlati al cliente con quel dato Id
. Quando alla fine viene invocato il metodo Flush()
le query generate sono due:
NHibernate: UPDATE Orders SET CustomerId = null WHERE CustomerId = @p0 AND id = @p1; @p0 = '1', @p1 = '1' NHibernate: DELETE FROM Orders WHERE id = @p0; @p0 = '1'
per prima cosa viene impostato a null
la colonna CustomerId
dell'oggetto order
, dato che non è più legato a nessun oggetto Customer
, successivamente, grazie all'attributo cascade="all-delete-orphan"
nhibernate decide di cancellare completamente dal database l'ordine, perché diventato orfano.
L'attributo all-delete-orphan
è particolarmente delicato e va usato con consapevolezza, a titolo di esempio si provi ad eseguire il listato3, dove vengono creati due clienti, e si tenta di spostare il primo ordine da un cliente ad un altro. Il risultato è purtroppo una eccezione di tipo:
ObjectDeletedException: deleted object would be re-saved by cascade
Per comprendere il perché di un tale comportamento bisogna anlizzare passo passo cosa succede dal punto di vista di NHIbernate: avendo rimosso l'ordine dal primo cliente, NHibernate decide che quell'ordine è orfano e va quindi cancellato, ma lo stesso ordine è stato aggiunto alla proprietà Orders
di un altro cliente, per cui l'oggetto dovrebbe essere ora nuovamente salvato.
Questo comportamento è normale, perché non si può pretendere che NHibernate controlli sempre tutti gli oggetti nel contesto per capire se un ordine è orfano o meno, quando un oggetto viene rimosso da una collezione è marcato come deleted
.
Se si ha necessità di spostare gli ordini da un cliente ad un altro è necessario modificare il mapping inserendo un cascade="all"
. Ora il listato3 viene eseguito senza problemi.
NHibernate: SELECT orders0_.CustomerId … WHERE orders0_.CustomerId=@p0; @p0 = '2' NHibernate: SELECT orders0_.CustomerId … WHERE orders0_.CustomerId=@p0; @p0 = '1' NHibernate: UPDATE Orders SET CustomerId = null WHERE CustomerId = @p0 AND id = @p1; @p0 = '2', @p1 = '2' NHibernate: UPDATE Orders SET CustomerId = @p0 WHERE id = @p1; @p0 = '1', @p1 = '2'
La cosa che salta maggiormente all'occhio è che il mapping di tipo <bag>
non è poi cosi efficiente a livello prestazionale. Il problema più grande, è che ogni volta che si accede alla collection Orders
, NHibernate è costretto a caricare in memoria tutti gli elementi, anche nel caso di una semplice aggiunta di un nuovo ordine.
La tabella ordini
inoltre subisce due update
, il primo per togliere la relazione con il primo cliente, il secondo per aggiungere la relazione con il secondo cliente. Naturalmente ora che il delete-orhpan
è stato disabilitato, se si vuole completamente rimuovere un ordine dal database è necessario chiamare un session.delete
esplicito sull'oggetto ordine da eliminare, ma d'altra parte in questo modo si ha un controllo migliore sul ciclo di vita dell'oggetto.
Questo esempio mostra come una relazione di questo tipo tra cliente ed ordine non sia poi cosi ottimale. Le relazioni di tipo bag, infatti, sono adatte in tutti i contesti in cui gli oggetti nella collezione debbono essere logicamente trattati assieme, questo perché ogni volta che si rimuove, aggiunge, o legge un solo elemento della collezione, nhibernate carica dal database tutti gli oggetti correlati. Nel caso in esame è invece accettabile che un ordine possa essere trattato da solo e quindi il mapping più corretto rimane probabilmente quello che dall'ordine va al cliente.
Nel caso in cui si decida di usare comunque il mapping di tipo bag è allora utile conoscere qualche dettaglio in più, che può tornare molto utile per una gestione efficiente della relazione stessa.
Nella seconda parte dell'articolo faremo alcune considerazioni sulle prestazioni di questo tipo di mapping.
Ulteriori considerazioni su mapping di tipo "bag"
Il Listato4 mostra un'operazione all'apparenza banale, caricare un cliente e calcolare il numero di ordini grazie alla proprietà Count
di Orders
.
Purtroppo se si verifica l'SQL generato si può notare che anche in questo caso il caricamento lazy porta in memoria tutti gli ordini del cliente solo per calcolare il loro numero. Questa situazione è particolarmente inefficiente, soprattutto quando gli oggetti ordini hanno molti campi, dato che vengono sprecate risorse per recuperare i dati dal db e ricostruire oggetti che non verranno mai acceduti.
La soluzione in questo caso è utilizzare una funzione particolarmente interessante della sessione che si chiama CreateFilter.
Int64 count = (Int64)session.CreateFilter(gm.Orders, "select count(*)").UniqueResult();
Un filtro non è altro che una istruzione SQL che NHibernate applica ad un oggetto. In questo caso si chiede di applicare un filtro select count(*)
ad una collezione di entità Orders
, che produce la query:
select count(*) as x0_0_ from Orders this where this.CustomerId = @p0; @p0 = '1'
Sicuramente molto più performante, dato che non deve ricreare tutti gli oggetti in memoria; ma i filtri possono fare molto di più. Supponiamo di dover scandire tutti gli ordini di un cliente per verificare una data condizione complessa che non può essere espressa tramite codice SQL.
Questa situazione è frequente nella progettazione con Domain Model, dove alcune condizioni potrebbero essere espresse da una combinazione di oggetti filtro per dare la possibilità di creare criteri di business veramente complessi. Se gli ordini di un cliente sono molti è consigliabile scorrerli con paginazione per non creare un impatto sulla memoria troppo elevato. La paginazione è tra l'altro molto utile se ci si deve arrestare al primo elemento che soddisfa la condizione, perché in questo caso gli elementi successivi non vengono acceduti.
Ecco un possibile esempio:
CustomerOne gm = session.Get<CustomerOne>(custId); IList<Order> page; Int32 pageNum = 0; Int32 pageSize = 10; while ((page = session .CreateFilter(gm.Orders, "") .SetFirstResult((pageNum++ * pageSize)) .SetMaxResults(pageSize) .List<Order>()).Count > 0) { foreach (Order o in page) if (o.Total > 100) { // fai qualche cosa e poi esci perchè hai trovato l'elemento return true; ...
La funzione che permette la paginazione è sempre la CreateFilter
, con una condizione vuota, ad indicare che si vogliono recuperare tutti gli ordini, ma grazie al SetFirstResult
e al SetMaxResult
siamo in grado di recuperare solo alcuni elementi per volta. Questa tecnica è mostrata nel Listato6.
Nel Listato6b invece si mostra come è possibile paginare imponendo anche una condizione sugli elementi cercati:
while ((page = session .CreateFilter(gm.Orders, "select where Total > 100") .SetFirstResult((pageNum++ * pageSize)) .SetMaxResults(pageSize) .List<Order>()).Count > 0)
Se la paginazione risolve il problema di scorrere gli ordini a gruppi, un altro grave problema rimane ancora irrisolto, il problema detto delle N+1 select. Se si esegue il Listato7, in cui semplicemente si recupera un insieme di clienti e poi si accede uno per uno alla loro collection order, si nota come NHibernate generi N+1 richieste al DB: la prima per recuperare tutti i clienti e poi per ogni cliente una ulteriore interrogazione per recuperare gli ordini correlati.
Un miglioramento di prestazioni è possibile modificando leggermente il mapping della collection
<bag name ="Orders" cascade="all" table="Orders" lazy="true" batch-size="5" >
L'attributo batch-size
indica il numero di oggetti da recuperare in una singola select quando si debbono ricreare gli oggetti lazy. Il funzionamento è semplice, quando una collection lazy deve caricare in memoria il suo reale contenuto, la sessione controlla se ci sono altre collection proxy non inizializzate e, se presenti, carica anche i loro dati contemporaneamente.
Con una batch size di 5 le query generate seguono quindi questo pattern
NHibernate: SELECT orders0_.* WHERE orders0_.CustomerId in (@p0, @p1, @p2, @p3, @p4); @p0 = '1', @p1 = '2', @p2 = '3', @p3 = '4', @p4 = '5'
...
NHibernate: SELECT orders0_.* WHERE orders0_.CustomerId in (@p0, @p1, @p2, @p3); @p0 = '6', @p1 = '7', @p2 = '8', @p3 = '9'
Naturalmente NHibernate effettua il precaricamento in maniera sequenziale, per cui questa tecnica è veramente utile quando si prevede di fare una scansione foreach, ma in generale il numero di query necessarie per scandire tutti gli elementi si riduce del fattore usato come batch-size.
Come ultimo esempio viene mostrato come sia possibile controllare le modalità di caricamento degli elementi della collezione tramite l'attributo fetch del tag bag. Ad esempio si può scegliere una strategia di tipo subselect
<bag name ="Orders" cascade="all" table="Orders" lazy="true" fetch="subselect" >
In questo caso il Listato7 viene eseguito generando solamente due query al db, la prima per recuperare il cliente e la seconda per recuperare tutti gli ordini di tutti i clienti.
SELECT orders0_.* WHERE orders0_.CustomerId in (select Customeron0_.id from CustomerOne Customeron0_)
Naturalmente il fetch subselect
funziona anche se si itera nella collection restituita da una query HQL con condizioni. Sempre nel Listato7, dato che sono stati creati 10 clienti con un Id al posto del cognome, si può eseguire questa query per recuperare tutti i clienti che hanno il cognome minore di 3
IList<CustomerOne> allCustomer = session .CreateQuery("from Customer c where c.Surname < 3") .List<CustomerOne>(); foreach (CustomerOne cust in allCustomer) { Console.WriteLine(cust.Orders.Count); }
Con il fetch subselect la query generata per recuperare gli ordini è
SELECT orders0_.* WHERE orders0_.CustomerId in (select Customeron0_.id from CustomerOne Customeron0_ where (Customeron0_.Surname<3 ))
È interessante notare che nhibernate "ricorda" la query originale che ha generato gli oggetti Customer, per cui è in grado di recuperare le informazioni su tutti gli ordini associati quando si accede ad una qualsiasi delle collection lazy.
Infine è possibile impostare il fetch="join"
che esegue una join tra le due tabelle per recuperare tutti i dati con una singola query. Questo tipo di attributo non si usa però quasi mai nel mapping dato che si può impostare direttamente in HQL:
from CustomerOne c join fetch c.Orders
In questo caso però è necessario fare molta attenzione perché nella lista dei risultati troviamo clienti duplicati.
D'altra parte NHibernate, eseguendo la query con join
, trova un numero di righe pari a quelle dei clienti moltiplicate per i rispettivi ordini e quando procede a creare la lista di entità risultante, non fa altro che ricostruire gli oggetti dal recordset restituito, creando cosi per ogni cliente un numero di riferimenti pari al suo numero di ordini. La soluzione a questo tipo di problemi è mostrata nel Listato8 in cui viene applicato un ResultTrasformer di tipo DistinctRootEntity
al resultset della query, il cui effetto è proprio quello di rimuovere eventuali duplicati che possono essere generati da una strategia di fetch di tipo join.
Conclusioni
Il mapping tramite collection è sicuramente più delicato rispetto al più standard link tra padre e figlio. Per quanto riguarda le prestazioni è importante tenere a mente il funzionamento interno di NHibernate e fare quindi uso di tecniche come la paginazione tramite CreateFilter
e l'uso del batch size in maniera intelligente ed essere sempre coscienti del fatto che ogni volta che si accede alla collection tutti gli elementi vengono comunque ricostruiti. Questo tipo di mapping deve quindi essere considerato con attenzione perché non deve essere abusato.
Riferimenti
- [EVANS]: Domain Driven Design: Tackling complexity in the heart of software (2004)
- [Fowler]: Pattern of Enterprise application Architecture (2002)
- [BAUER-KING]: Java Persistence With Hibernate