Un aspetto che ho avuto modo di discutere con dei colleghi in questi giorni riguarda l' impatto che l' "execution overhead" di LINQ2SQL ( = valutazione dell' expression tree e generazione dello statement SQL ) apporta in un progetto reale.
Supponiamo di avere un sito ASP.NET che invoca la stessa query LINQ2SQL centinaia di migliaia o addirittura milioni di volte nell'arco di 24 ore. In casi come questo il rischio è che l'execution overhead impegni pesantemente le risorse di CPU, precludendo il caricamento di una pagina in tempi ragionevoli. A supporto di tale problematica, segnalo questo interessante post in cui viene raccontata una particolare vicenda risolta a colpi di ANTS, che fa riflettere su quanto non sia poi così immediato riuscire a scovare i frammenti di codice che fanno da collo di bottiglia all'interno di applicazioni enterprise che sfruttano tecnologie come LINQ2SQL.
In generale, per abbattere i costi computazionali di execution overhead nelle situazioni in cui si necessita del riutilizzo massivo della stessa query LINQ2SQL, possiamo ricorrere alle compiled queries: tutto ciò di cui abbiamo bisogno è utilizzare il metodo Compile della classe CompiledQuery.
In questo post vediamo come passare da una classica espressione LINQ2SQL "non-compiled" alla relativa versione "compiled".
Partiamo da un metodo che utilizza un' espressione LINQ2SQL non-compiled:
public Customer GetCustomer(NorthwindDataContext dataContext, string customerID)
{
IQueryable<Customer> customer = from c in dataContext.Customers
where c.CustomerID == customerID
select c;
return customer.SingleOrDefault();
}
Per ottenere la relativa versione compilata dovremmo scrivere qualcosa del tipo...
public static readonly Func<NorthwindDataContext,string,IQueryable<Customer>> Compiled_GetCustomerByID =
CompiledQuery.Compile<NorthwindDataContext,string,IQueryable<Customer>>(
(dataContext,customerID) => from c in dataContext.Customers
where c.CustomerID == customerID
select c);
La sintassi non è delle più leggibili e di certo la conoscenza del functional programming (es. F#) può aiutare molto. Di fatto stiamo essenzialmente creando una funzione che restituisce una funzione invece che un valore.
Inoltre vorrei segnalare un paio di note importanti:
- definendo il delegate Compiled_GetCustomerByID come static e readonly abbiamo la garanzia che la compilazione avverrà una sola volta per AppDomain e che il risultato di tale compilazione rimarrà in cache per l'intero ciclo di vita dell'applicazione. Questo aspetto è fondamentale dato che stiamo cercando proprio di eliminare i tempi di compilazione di una query LINQ2SQL.
- una limitazione del delegate Func è sul numero di parametri: non possiamo passare in argomento più di 4 parametri. Per ovviare a tale problema avremmo bisogno di inglobare i nostri parametri in una classe o una struct da passare come argomento alla compiled query.
Tornando al nostro metodo GetCustomer iniziale, esso sarà modificato in
public Customer GetCustomer(NorthwindDataContext dataContext, string customerID)
{
IQueryable<Customer> customer = Compiled_GetCustomerByID(dataContext, customerID);
return customer.SingleOrDefault();
}
A questo punto si pone una questione cruciale: poiché una compiled query definita staticamente può avere a che fare con diverse istanze di uno stesso DataContext, dobbiamo assicurarci che essi siano compatibili con il DataContext elaborato al momento della prima invocazione ( compilazione ) della compiled query. Prendiamo come esempio l'uso di DataLoadOptions . Se il nostro codice fosse il seguente...
CustomerProvider customerProvider1 = new CustomerProvider();
NorthwindDataContext dataContext1 = new NorthwindDataContext();
DataLoadOptions dataLoadOptions1 = new DataLoadOptions();
dataLoadOptions1.LoadWith<Customer>(c => c.Orders);
dataContext1.LoadOptions = dataLoadOptions1;
Customer c1 = customerProvider1.GetCustomer(dataContext1, "ANTON"); // OK!!!
// ... Poi da un'altra parte dell'applicazione
CustomerProvider customerProvider2 = new CustomerProvider();
NorthwindDataContext dataContext2 = new NorthwindDataContext();
DataLoadOptions dataLoadOptions2 = new DataLoadOptions();
dataLoadOptions2.LoadWith<Customer>(c => c.Orders);
dataContext2.LoadOptions = dataLoadOptions2; // Stiamo passando una nuova istanza di DataLoadOptions!
Customer c2 = customerProvider2.GetCustomer(dataContext2, "ANATR"); // ERRORE!!!
...in corrispondenza dell'ultima riga riceveremmo una bella eccezione "System.NotSupportedException: Compiled queries across DataContexts with different LoadOptions not supported." Se invece valorizzassimo la proprietà LoadOptions di datacontext2 con l'istanza di DataLoadOptions assegnata al dataContext1, ovvero...
dataContext2.LoadOptions = dataLoadOptions1;
...tutto funzionerebbe correttamente.
Il problema sta semplicemente nel fatto che a partire dalla prima invocazione, una compiled query "ricorda" l'istanza di DataLoadOptions in pasto al DataContext. Quindi nelle successive invocazioni la compiled query si aspetta esattamente la stessa istanza specificata nella prima chiamata. Di conseguenza, quando utilizziamo le compiled query, non possiamo gestire le DataLoadOptions localmente. Al contrario, la soluzione sta nel creare un DataLoadOptions statico. Immaginiamo che cosa significherebbe scriversi un metodo per ogni singola combinazione di DataLoadOptions... sarebbe un inferno!
Per questo il modo probabilmente più elegante sta nello sfruttare ancora una volta le potenzialità del functional programming creando un delegate statico come il seguente:
public static readonly DataLoadOptions WithCustomerLoadOrders = (new Func<DataLoadOptions>(() =>
{
DataLoadOptions dataLoadOptions = new DataLoadOptions();
dataLoadOptions.LoadWith<Customer>(c => c.Orders);
return dataLoadOptions;
}))();
In questo modo il codice precedente diverrebbe:
CustomerProvider customerProvider1 = new CustomerProvider();
NorthwindDataContext dataContext1 = new NorthwindDataContext();
dataContext1.LoadOptions = CustomerProvider.WithCustomerLoadOrders;
Customer c1 = customerProvider1.GetCustomer(dataContext1, "ANTON"); // OK!!!
// ... Poi da un'altra parte dell'applicazione
CustomerProvider customerProvider2 = new CustomerProvider();
NorthwindDataContext dataContext2 = new NorthwindDataContext();
dataContext2.LoadOptions = CustomerProvider.WithCustomerLoadOrders;
Customer c2 = customerProvider2.GetCustomer(dataContext2, "ANATR"); // OK!!!
Abbiamo visto come la conversione di una query LINQ2SQL da una versione "non-compiled" ad una "compiled" richieda un certo sforzo e costituisca un' operazione non sempre immediata. Ad ogni modo, il guadagno che otteniamo in termini di performance potrebbe essere determinante soprattutto in quegli scenari in cui una determinata espressione LINQ2SQL viene riutilizzata molte volte nello stesso AppDomain.
Per approfondire meglio la questione nonché avere una prova dei numeri che segnano la differenza di performance tra query compilate e non, consiglio assolutamente la lettura di questo post di Rico Mariani.