NHibernate è un ORM, ovvero un sistema che ci permette di pensare alle informazioni memorizzate su database come a oggetti da gestire senza pensare al tipo di motore relazionale né alle interrogazioni.
Abbiamo già introdotto NHibernate in passato ed abbiamo già visto alcune realizzazioni con ASP.NET.
In questo articolo aggiungiamo alcuni elementi fondamentali sia alla trattazione, sia agli esempi. Parleremo di di come gestire il mapping delle relazioni e di come questo aspetto non possa prescindere da precise scelte che coinvolgono l'architettura del sistema.
Creare una relazione
Negli articoli già citati ci siamo occupati di come mappare semplici entità, ora osserviamo come come gestire le relazioni tra oggetti. Per semplicità riprendiamo l'esempio già proposto e aggiungiamo un nuovo oggetto chiamato ordine
, in relazione con l'oggetto CustomerOne
.
Nella notazione del Class Designer di Visual Studio, abbiamo definito, nella classe Order
, una proprietà chiamata Customer
che è di tipo CustomerOne
.
La differenza con un normale dataset "strongly typed" è chiara, in quel caso avremmo inserito una semplice proprietà CustomerId
, di tipo Int32
, che conterrebbe l'id del cliente associato.
Ecco come si presenta il mapping di un ordine.
<class name="Order" table="Orders" lazy="false"> <id name="id" unsaved-value="0" access="field" type="System.Int32"> <generator class="native" /> </id> <property name="Date" column="Date" type="System.DateTime"/> <many-to-one name="Customer" class="CustomerOne" column="CustomerId" not-found="exception" not-null="true" /> </class>
La particolarità è che la proprietà Customer
viene mappata come many-to-one
, ma d'altra parte questa non è una sorpresa, perché la relazione tra CustomerOne
e Order
è di tipo "molti a uno" in cui l'ordine è nella parte "molti".
Questa associazione, come una normale proprietà, possiede un set di attributi che permettono di specificarne il funzionamento. L'attributo class
serve ad indicare a NHibernate il tipo di oggetto usato nella relazione e tramite column
si indica la colonna usata per memorizzare la foreign-key.
NHibernate controlla se la classe usata per la relazione (ovvero CustomerOne
) ha un id compatibile con il campo del db per vedere se la relazione è possibile. In questo caso CustomerOne
ha una chiave di tipo Int32
, la colonna CustomerId
è intera per cui il mapping è compatibile con la struttura di database. Gli attributi not-found
e not-null
servono invece per specificare rispettivamente come comportarsi nel caso di una foreign-key orfana (id che non è presente in customer) e se possono esistere ordini orfani con la foreign-key pari a null
.
Se si esegue Listato1
dell'esempio accluso, con il quale si inserisce semplicemente un ordine nel database, viene generata un'eccezione
Listato 1. Inserimento di un ordine
using (ISession session = NHSessionManager.GetSession() ) { CustomerOne gianmaria = new CustomerOne("Gian Maria", "Ricci"); Order order1 = new Order(DateTime.Now, gianmaria); session.Save(order1); session.Flush(); }
Messaggio di errore
failed: not-null property references a null or transient value: Domain.Entities.Order.Customer
Nel momento in cui l'oggetto order1
deve essere reso persistente grazie al metodo Save()
, Nhibernate prepara la INSERT
per la tabella orders
(per il calcolo dell'id identity autogenerato dal DBMS), e per conoscere il valore del campo CustomerId
, che rappresenta la foreign-key con la tabella Customer
, esamina l'oggetto customer associato all'ordine.
A questo punto si verifica il problema, perché l'oggetto customer
è ancora in stato transiente, dato che non è stato mai salvato con NHibernate. Questa situazione presenta due anomalie, in primo luogo NHibernate non conosce l'id dell'oggetto customer e non può quindi sapere cosa inserire nella colonna della foreign-key, in secondo luogo non è lecito salvare un entità quando esistono relazioni con altre entità che sono in stato transiente.
Le soluzioni possono essere due, la prima è chiamare Session.Save()
anche sull'oggetto customer
prima di salvare l'oggetto ordine
, effettuandone quindi il passaggio nello stato persistente, la seconda è modificare il mapping di order
in questo modo:
<many-to-one name="Customer" class="CustomerOne" column="CustomerId" not-found="exception" not-null="true" cascade="save-update"/>
L'unico cambiamento è l'attributo cascade, che indica a NHibernate di percorrere la relazione propagando la persistenza agli oggetti correlati. Questa caratteristica nel mondo degli ORM e della programmazione con Domain Model si chiama: persistence by reachability, ovvero persistenza per raggiungimento. In pratica le operazioni che cambiano lo stato di persistenza di un oggetto vengono propagate percorrendo il grafo degli oggetti. In questo modo è possibile salvare un oggetto ed essere certi che anche tutti gli oggetti correlati vengano salvati in maniera automatica
Questa caratteristica è particolarmente importante se si utilizza la segmentazione in Aggregati [Evans].
Caricamento e Strategie di fetching
Vediamo ora cosa succede quando si recupera un ordine dal database. Supponendo che sia stato eseguito il Listato1
, nel database esiste l'ordine con Id=1
, che può essere recuperato con il metodo Get()
della sessione.
Listato 2. Recuperare l'ordine dal DB
using (ISession session = NHSessionManager.GetSession()) { Order o = session.Get<Order>(1); Console.WriteLine("Order Id {0} customer name {1}", o.Id, o.Customer.Name); }
La vera potenza di NHibernate si può capire dal fatto che, nonostante si sia chiesto il caricamento dell'oggetto ordine con id=1
, si può accedere tranquillamente alla proprietà Customer
e leggere i dati dell'oggetto correlato. La query che è stata generata è la seguente
NHibernate: SELECT order0_.id as id1_1_, order0_.Date as Date1_1_, order0_.CustomerId as CustomerId1_1_, customeron1_.id as id0_0_, customeron1_.Name as Name0_0_, customeron1_.Surname as Surname0_0_ FROM Orders order0_ INNER JOIN CustomerOne customeron1_ on order0_.CustomerId=customeron1_.id WHERE order0_.id=@p0; @p0 = '1'
Come si può osservare NHibernate effettua una query con join per recuperare i dati del cliente assieme a quelli dell'ordine. Questo comportamento però solleva un dubbio legittimo: Cosa accade se l'oggetto ordine ha molte relazioni? Cosa accade se l'oggetto Customer
ha a sua volta altre relazioni? In pratica richiedendo un oggetto si porta in memoria tutto il grafo di oggetti da esso raggiungibile.
Chiaramente questa soluzione non è ottimale e per questa ragione NHibernate prevede modalità differenti per gestire il caricamento degli oggetti correlati.
Vedremo nella seconda parte dell'articolo come ottimizzare le query utilizzando metodologie come la modalità lazy.
Nella prima parte dell'articolo abbiamo osservato come, creare relazioni nel nostro modello, possa tradursi in un'inefficienza delle query. Vediamo ora quali strumenti NHibernate ci mette a disposizione per ottimizzare il colloquio col DBMS.
La modalità Lazy
Per prima cosa è necessario modificare la classe CustomerOne
ed il suo mapping. La prima operazione da fare è mettere a true l'attributo lazy
.
<class name="CustomerOne" table="CustomerOne" lazy="true" ...
In questo modo si indica a NHibernate che l'entità può essere usata in modalità lazy (Pigra). Se si esegue nuovamente il Listato2
viene però generato un errore, dovuto al fatto che la classe CustomerOne non può essere usata in modalità lazy perché non ha tutte le proprietà ed i metodi pubblici dichiarati come virtuali.
Per comprendere la ragione di questa richiesta è doveroso capire come viene implementato il caricamento lazy.
Il termine "Lazy" indica un'operazione che viene effettuata solo quando strettamente necessaria. Nel caso di "lazy load" (caricamento pigro), si effettua la query per recuperare i dati solamente quando si accede ad una proprietà e non prima.
Chiaramente NHibernate deve avere un modo per intercettare quando si utilizza per la prima volta la proprietà di un oggetto e per questa ragione, al momento del caricamento dell'oggetto Order
, il sistema non assegna un vero oggetto CustomerOne
alla sua proprietà Customer
, ma un istanza di una classe creata dinamicamente, che eredita da CustomerOne
ed implementa il pattern proxy.
Data una classe X
, un suo proxy non è altro che un'istanza di una classe Y
che eredita da X
e che al suo interno mantiene una istanza di X
a cui delega tutte le chiamate fatte dall'esterno. Grazie a questo pattern è possibile associare in maniera trasparente funzionalità aggiuntive ad un oggetto esistente.
NHibernate, all'atto del caricamento dell'oggetto Order
, effettua una SELECT
sulla sola tabella order
, da questa tabella recupera l'id dell'oggetto customer
correlato (dalla colonna con la foreign-key), istanza un proxy di CustomerOne
e gli assegna l'id trovato. Questo proxy internamente ha una variabile di tipo CustomerOne
pari a null
, ogni getter
di ogni proprietà virtuale ha al suo interno un controllo che verifica appunto se questa variabile è null
ed in caso affermativo la istanzia, previo caricamento dei dati dalla tabella Customer
.
Il risultato è che i dati vengono caricati dal database solo quando si accede la prima volta ad una proprietà dell'oggetto proxy. (Se si è interessati ai dettagli di implementazione delle tecniche di caricamento lazy si può consultare il POEAA [Fowler])
Se l'oggetto Customer
non avesse tutte le proprietà pubbliche virtuali, non si potrebbe creare l'oggetto proxy
per ereditarietà dato che non si potrebbe intercettare con il metodo getter
l'accesso alle proprietà stesse.
Non rimane altro da fare che rendere tutte le proprietà di CustomerOne
virtuali ed eseguire Listato 2.
Se si osserva il codice SQL generato si nota che non viene più eseguita un'unica query con JOIN
, ma due query SELECT
, una sulla tabella orders
ed una su CustomerOne
. A questo punto è fondamentale usare il debugger ed eseguire una per una le istruzioni del Listato3
.
Listato 3. Eseguire il Fetch di un ordine
Order o = session.Get<Order>(1); Console.WriteLine(o.Customer.GetType().Name); Console.WriteLine("Order Id {0} customer name {1}", o.Id, o.Customer.Name);
Quando viene eseguita la prima istruzione viene effettuata la prima SELECT
per recuperare i dati dalla sola tabella order
. Quando si esegue la seconda istruzione si può osservare che la proprietà Customer
dell'oggetto order
contiene un oggetto di tipo:
CProxyTypeDomain_EntitiesCustomerOneEntities_NHibernate_ProxyINHibernateProxy1
Come detto precedentemente, per implementare il lazy load, NHibernate ha inserito un proxy al posto del vero oggetto Customer
, ma dato che questo proxy eredita da Customer
dal punto di vista dell'utilizzatore non cambia nulla. Quando si esegue la terza istruzione viene finalmente effettuata la SELECT
per la tabella Customer
e da questo momento in poi i dati sono in memoria.
Questa tecnica, conosciuta come Transparent Lazy Load, è una delle strategie di fetch possibile e probabilmente la più utile. Grazie ad essa si può senza problemi navigare un grafo di oggetti in maniera completamente naturale, lasciando a NHIbernate il compito di caricare i dati quando necessario.
Eager Fetch
Naturalmente il Lazy Load non è la panacea di tutti i mali, infatti in questo modo l'utilizzo del database è talvolta particolarmente inefficiente; esaminiamo quindi cosa succede nel Listato4
.
Listato 4. Una query effettuata con NHibernate
IList<Order> orders = session.CreateQuery("from Order").List<Order>(); foreach(Order o in orders) Console.WriteLine("Order Id {0} customer name {1}", o.Id, o.Customer.Name);
In questo esempio abbiamo usato una query HQL, uno dei metodi che offre NHibernate per effettuare query sul Domain Model. La convenienza di usare HQL è che ha una sintassi molto simile a SQL, ma si usano i nomi di classi e proprietà, in questo modo si è completamente scollegati dallo schema del database, che è invece espresso solamente tramite i mapping.
Nel listato la query from Order
non fa altro che selezionare tutti gli oggetti Order
presenti nel db. Se si controlla il codice SQL generato si può vedere che il numero di query SELECT
effettuate è N+1
, dove N
è il numero di oggetti orders
. Questo comportamento è naturale, la prima query infatti recupera tutti gli ordini, poi ogni volta che si accede alla proprietà Customer
di un ordine viene effettuata una ulteriore query per recuperare i dati di quel particolare Customer
, d'altra parte è proprio questa la caratteristica del caricamento lazy.
Se si vuole essere più precisi bisogna osservare che, grazie alla Identity Map, una volta che un oggetto proxy è stato caricato, NHibernate utilizzerà sempre quello e non ne genererà altri, questo significa che in realtà il numero N
indicato non è pari al numero degli ordini, ma è piuttosto il numero di clienti distinti per gli ordini caricati.
In questa situazione il programmatore sa che le operazioni da effettuare prevedono l'accesso alla proprietà Customer
per tutti gli ordini, per cui la strategia di Lazy Load è controproducente in termini di prestazioni, dato che viene eseguito un numero elevato di interrogazioni al database. Anche in questo caso NHibernate ha la soluzione, basta cambiare la query in questo modo
"from Order o inner join fetch o.Customer"
Grazie alla clausola inner join fetch
si chiede a NHibernate di recuperare tutti i dati con una join
, proprio come se il lazy load fosse stato disattivato. Questa strategia di fetch, in cui si recuperano tutti i dati con una singola interrogazione al db, viene chiamata Eager Load, ovvero caricamento anticipato, ed è utile in tutti quei casi in cui si sa già in anticipo come verrà usato il grafo di oggetti.
Nel Listato 5 si può notare che grazie all'eager load non vengono nemmeno usati gli oggetti proxy. Dato che Nhibernate ha già tutti i dati necessari dalla tabella Customer
è preferibile infatti creare un vero oggetto invece di sacrificare le performance con l'uso di proxy.
Da quanto detto emerge quindi che è conveniente che gli oggetti siano tutti mappati come lazy, in questo modo si può poi scegliere al momento della query se usare un caricamento lazy oppure eager per i vari rami del grafo di oggetti che si vuole usare.
Creazione di proxy espliciti
L'oggetto NHibernate.ISession
presenta due metodi distinti per recuperare entità dal loro id: Get()
e Load()
. Il metodo Get()
, utilizzato fino ad ora, recupera l'oggetto dal database e restituisce null
se non esiste nessun record con l'id specificato, il metodo Load()
ritorna invece un proxy senza eseguire nessuna query; in questo caso se non è presente un recrod con l'id specificato viene generata eccezione al momento del primo accesso ad una proprietà.
Il metodo Load()
permette quindi la creazione di un proxy ed è utile per creare associazioni; supponiamo di voler associare il cliente con id 12
ad un ordine, in questo caso utilizzando la chiamata Session.Load<CustomerOne>(12)
si crea un proxy che può essere assegnato alla proprietà Customer
dell'ordine in questione. Il vantaggio di questo approccio è che il proxy permette di impostare la relazione senza dover veramente caricare l'oggetto dal database.
Conclusioni
NHibernate mostra la sua vera flessibilità quando entrano in gioco le relazioni tra oggetti. Grazie alla possibilità di usare differenti strategie di fetching, è infatti possibile gestire la persistenza di un grafo complesso di oggetti in maniera semplice, ma senza sacrificare le prestazioni.
Il lazy load in particolare è molto utile perché permette di navigare il grafo caricando gli oggetti solo al momento del primo utilizzo. L'eager fetch al contrario è utile quando si sa in anticipo che si utilizzeranno certe relazioni del grafo ed è quindi conveniente portare subito tutti gli oggetti in memoria.
Riferimenti
- [EVANS]: Domain Driven Design: Tackling complexity in the heart of software (2004)
- [Fowler]: Pattern of Enterprise application Architecture (2002)