LINQ, lazy loading e architettura

di Cristian Civera, in .NET,

Con l'avvento di LINQ l'accesso ai dati è decisamente cambiato. Con un timido LINQ to SQL e un po' più maturo LINQ to Entities abbiamo potuto interrogare le informazioni sfruttando ORM, oggetti, tipizzazione, compilazione delle query e tutti i benefici che ormai sappiamo.

Anche LINQ con il tempo ha dovuto trovare il suo posto e adattare/adattarsi hai layer e ai pattern che usiamo da tempo e, a mio avviso, si è trovato un ottimo compromesso che permetta di conciliare astrazione e la facilità di LINQ. Mi riferisco alla tipica separazione in layer che solitamente si fa tra repository, servizi di dominio e servizi applicativi che ha trovato alleato l'utilizzo dell'interfaccia IQueryable per essere restituito dal repository e permette ai vari strati di aggiungere pezzi alla query per poter essere poi eseguita quando effettivamente la consumiamo.

Il nostro MarcoDes ha inserito questa tecnica in Model Virtual Casting, e si dimostra molto comoda ed efficace se utilizzata con LINQ to Entities. Non voglio però parlare di questo dal punto di vista architetturale ma affrontare un problema tecnologico che ho dovuto affrontare. Quando infatti utilizziamo IQueryable ci ritroviamo con la problematica principale di dover indicare quali navigation property caricare all'interno delle entità che si stanno materializzando e, scartando l'ipotesi del lazy loading per motivi di controllo ed efficienza sulle query, si procede con l'indicare sul repository le proprietà da includere. Anche questo è implementato in Model Virtual Casting e l'abbiamo rappresentato con un'interfaccia simile a questa.

public interface IReadOnlyRepository<T>
{
  IQueryable<T> GetAll();
  IReadOnlyRepository<T> Include(string path);
}

Esiste una variante che al posto della stringa richiede un Expression per rendere tutto tipizzato, ma in sostanza il discorso non cambia. Quando da un servizio utilizziamo il repository di fatto faremo:

IQueryable<Product> list = new ProductRepository().Include("Orders").GetAll();

In questo modo, quando sfogliamo list otteniamo popolata la classe Product con i rispettivi ordini attraverso la collezione esposta dalla proprietà Orders. Poiché l'implementazione del repository è basta su EF 40, la chiamata ad Include si traduce semplicemente alla chiamata alla funzione omonima di ObjectQuery<T> di LINQ to Entities.

Il problema di questo è approccio è che qualora decidiamo di restituire la variabile list, facendola passare ad esempio dallo strato dei servizi a quello applicativo per effettuare un binding, perdiamo il riferimento al repository e di conseguenza la possibilità di aggiungere Include anche su strati successivi. Se gli strati che si passano IQueryable e alterano la query non fanno altro che agire solo sull'expression tree, non è più probabile che sia l'utilizzatore finale a sapere quali proprietà si debba materializzare? A mio modo di vedere la risposta è sì e lo dimostra anche il fatto che spesso si utilizzano proezioni per ottenere un sottoinsieme dei membri disponibili su una classe.

Per permettere quindi di effettuare gli Include su tutti gli strati ho pensato dapprima a la creazione di un'inferfaccia IMultiQueryable<T< che ereditando da IQueryable permetta sia di effettuare query ma anche di effettuare Include. Questa soluzione l'ho adottata con un cliente e si è dimostrata sempre funzionante portando solo con se la scomodità di fare il cast in certe situazioni su IMultiQueryable. Ho dunque pensato ad una soluzione alternativa che praticamente consiste nel creare un extension method personalizzato per rappresentare l'include:

public static IQueryable<T> Include<T>(this IQueryable<T> source, params Expression<Func<T, Object>>[] properties)
{
  MethodInfo method = ((MethodInfo)MethodBase.GetCurrentMethod()).MakeGenericMethod(new Type[] { typeof(T) });
  var arguments = new Expression[] {
    source.Expression,
    Expression.NewArrayInit(typeof(Expression<Func<T, Object>>), properties.Select(p => Expression.Quote(p))),
  };

  return source.Provider.CreateQuery<T>(Expression.Call(null, method, arguments));
}

In modo simile a Where, OrderBy ecc, non faccio altro che creare un'espressione che chiama il metodo stesso Include. Questo mi permette di eliminare tale metodo dall'interfaccia di repository e di scrivere, ovunque sono, il seguente codice:

IQueryable<Product> list = GetProductsFromRespository();
list = list.Include(p => p.Orders).Take(10).Include(p => p.Details);

Possiamo quindi combinare Include come qualsiasi altro extension method, su qualsiasi layer, basta che sia un IQueryable. E' chiaro però che così facendo quando LINQ to Entities dovrà convertire l'expression tree in SQL, non conoscerà il nostro metodo personalizzato e lancerà un'eccezione. Aggirare il problema però è più semplice di quanto si pensi, perché basta fornire un intermediario all'oggetto iniziale della query.

Mettendoci a livello del repository possiamo infatti non restituire direttamente l'ObjectQuery restituito dall'ObjectContext di EF, ma restituire un wrapper da noi creato che implementa IQueryable.

public IQueryable<Product> GetAll()
{
  NorthwindContext c = new NorthwindContext();
  return new ObjectMultiQuery<T>(c.Products);
}

Implementare IQueryable se si deve fare solo da intermediari è piuttosto semplice, perché la nostra unica intenzione è porci in mezzo prima di eseguire la query su database. Per farlo dobbiamo implementare IEnumerable.GetEnumerator e IQueryProvider.Execute, alterare l'espression tree creato e passarlo al motore di LINQ to Entities per eseguirla.

public IEnumerator<T> GetEnumerator()
{
  // Trasformo l'espressione per LINQ to Entities
  IncludeVisitor compiler = new IncludeVisitor();
  Expression newExpression = compiler.Visit(this.Expression);

  // Creo la nuova query utilizzando ObjectQuery<T>
  IEnumerable<T> result = this.original.Provider.CreateQuery<T>(newExpression) as IEnumerable<T>;

  // Ottengo il risultato
  return result.GetEnumerator();
}

Il campo original è l'oggetto originale EF ottenuto con c.Products nel codice precedente e rappresenta l'engine di EF. La classe IncludeVisitor invece ha il compito di trasformare l'espressione, cioè tradurre le chiamate all'extension method Include, al metodo ObjectQuery.Include. Ci viene in aiuto la classe ExpressionVisitor dalla quale possiamo ereditare e sovrascrivere il metodo VisitMethodCall per cambiare l'espressione di chiamata. Riporto il codice con i commenti così da capire meglio come funziona:

class IncludeVisitor : ExpressionVisitor
{
  protected override Expression VisitMethodCall(MethodCallExpression node)
  {
    if (node.Method.DeclaringType == typeof(Extensions) && node.Method.Name == "Include")
    {
      PropertiesVisitor visitor = new PropertiesVisitor();

      // ObjectQuery<T>
      Type objectQueryType = typeof(ObjectQuery<>).MakeGenericType(node.Arguments[0].Type.GetGenericArguments());
      // Visito il context così conto anch'esso come parametro
      // Effettuo un convert per essere sicuro che sia un ObjectQuery<T>
      Expression exp = Expression.Convert(this.Visit(node.Arguments[0]), objectQueryType);

      // Trovo il metodo include specifico di ObjectQuery<T>
      MethodInfo includeMethod = objectQueryType.GetMethod("Include", BindingFlags.Public | BindingFlags.Instance);

      // Chiama il metodo Include per tutte le espressioni passate
      foreach (var property in ((NewArrayExpression)node.Arguments[1]).Expressions)
      {
        visitor.FindProperties(property);

        // Concateno le proprietà trovate con il punto
        exp = Expression.Call(exp, includeMethod, Expression.Constant(String.Join(".", visitor.PropertyNames)));
      }

      return exp;
    }
    return base.VisitMethodCall(node);
  }
}

In questo modo l'expression tree originale cambia così:

// Da
Convert(value(System.Data.Objects.ObjectSet`1[Product])).MergeAs(AppendOnly).Include(new [] {c => c.Orders}).Take(10)
// A
Convert(Convert(value(System.Data.Objects.ObjectSet`1[Product])).MergeAs(AppendOnly)).Include("Orders").Take(10)

Trovo questa soluzione veloce ed efficace ed è soprattutto approvata da MarcoDes :-D. Trovate l'esempio nelle demo di Codemotion 2011 svoltosi a Roma pochi giorni fa. Enjoy!

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese