Tuesday, May 25, 2010

Silverlight DomainDataSource e ViewModel

Da qualche tempo lavoro su un progetto di medie dimensioni che architetturalmente si tratta di una soluzione che usa SQL Server come base dati, nHibernate + LINQ2nHibernate, RIA Services come esposizione dati e Silverlight come interfaccia. Conto di fare dei post sui problemi che ho incontrato/sto incontrando/incontrerò e discutere le stesse in questo spazio.
Oggi parlo di uno dei problemi che ho incontrato nello sviluppo dell’interfaccia client e nell’adozione del celeberrimo MVVM, del quale John Papa da una buona e semplice spiegazione qui.


Non voglio in questo momento parlare dei vantaggi dell'utilizzo di tale pattern o quali helpers utilizzare (PRISM, MVVMLight...) ma cercherò di focalizzare l'attenzione su alcuni problemi che l'utilizzo dello stesso pone.
In particolare, come visibile dall'immagine sopra (rubata vergognosamente dal post di John Papa), la View non deve vedere nient'altro che il ViewModel, quindi qualunque componente all'interno della View... Può referenziare solo il ViewModel! Fin qui semplicissimo, valga come premessa.
Poniamo che sia voglia creare una semplice View con due griglie relazionate tra loro come master/details, il classico esempio di Order e OrderDetails per intenderci.

A seguire le classi Order e OrderDetail

Codice 1
public Order(int id, string description)
{
_id = id;
_description = description;
_orderDetails = new List<OrderDetail>();
}

[Key]
public int Id
{
get { return _id; }
set { _id = value; }
}

public string Description
{
get { return _description; }
set { _description = value; }
}

[Include]
[Association("OrderDetail","Id","OrderId")]
public IList<OrderDetail> OrderDetails
{
get
{
return _orderDetails;
}
set { _orderDetails = value; }
}
}



Codice 2

public class OrderDetail
{
private string _productName;
private int _id;

[Key]
public int Id
{
get { return _id; }
set { _id = value; }
}
public int OrderId { get; set; }

public OrderDetail(int id, string productName)
{
_id = id;
_productName = productName + "_" + id;
}

public string ProductName
{
get { return _productName; }
set { _productName = value; }
}
}

il ViewModel


Codice 3

public class SampleViewModel : DomainContextBase
{
private IEnumerable<Order> _orders;
private Order _selectedOrder;
private DomainService1 _domainContext;

public ViewModelSimulator(DomainService1 domainContext) : base (domainContext)
{
_domainContext = domainContext;
}

public virtual Order SelectedOrder
{
get { return _selectedOrder; }
set
{
_selectedOrder = value;
NotifyPropertyChanged("SelectedOrder");
}
}

public virtual IEnumerable<Order> Orders
{
get
{
return _domainContext.Orders;
}
}
}





e la parte della view che mostra il binding delle due griglie

Codice 4



<Grid x:Name="LayoutRoot" Background="White">
<sdk:DataGrid AutoGenerateColumns="True" Height="148" HorizontalAlignment="Left" Margin="12,12,0,0" Name="dataGrid1"
VerticalAlignment="Top" Width="376" SelectedItem="{Binding SelectedOrder, Mode=TwoWay}" ItemsSource="{Binding Orders}"/>

<sdk:DataGrid AutoGenerateColumns="True" Height="100" HorizontalAlignment="Left" Margin="12,188,0,0" Name="dataGrid2"
VerticalAlignment="Top" Width="376" ItemsSource="{Binding SelectedOrder.OrderDetails}"/>
</Grid>




Ovviamente si vorrebbe che quando l’utente seleziona un Order dalla griglia principale, la griglia secondaria automaticamente mostri le righe di dettaglio, il famoso “lazy loading”.


Se si potesse utilizzare un DomainDataSource la richiesta sarebbe “automagicamente” risolta, insieme ad altre eventuali necessità quali paging, filtering, sorting eccetera; purtroppo però il DomainDataSource è stato pensato per essere dichiarato direttamente nella view e ha una dipendenza sul DomainContext il che significa violare quanto dettato da MVVM (non esporre alla View nient’altro che il ViewModel). Se al contrario si prova a istanziare il DomainDataSource si perdono alcune caratteristiche, prima fra tutte l’AutoLoad.


Jeff Handley spiega bene la cosa nel suo blog: http://jeffhandley.com/archive/2010/03/21/domaindatasource-viewmodel.aspx.



Il DomainDataSource è a quanto ne so l’unico strumento “già pronto” per fare lazy load dal client ma ponendo di non voler legare View e ViewModel diventa inutilizzabile a tale scopo.

Per risolvere questo ho pensato di mettere un proxy tra il binding e il ViewModel in modo da intercettare le chiamate “Get” che il binding esegue per ottenere i dati e invocare (via Reflection) i metodi opportuni che carichino questi dati.


Scrivere manualmente un proxy per ogni ViewModel lo ritengo davvero oneroso e credo sarebbe più semplice scrivere la logica di loading direttamente nel ViewModel, ma un DynamicProxy sarebbe perfetto. Ho cercato in internet e scaricato CastleProject DynamicProxy, da più parti considerato validissimo e soprattutto uno dei pochi se non l’unico che fornisca meccanismi di intercettazioni delle chiamate anche su Silverlight.



Ho impostato il DataContext della View sul proxy e scritto un interceptor che mediante reflection individua le chiamate da effettuare sul DomainService. Il pezzo di codice che segue si trova nella parte di codice della View, il che non è certamente una buona pratica, ma questo è solo un post “didattico” più una prova concettuale che altro.



Codice 5

ProxyGenerator _generator = new ProxyGenerator();
SampleViewModelInterceptor _interceptor = new SampleViewModelInterceptor ();
DomainService1 dmnSvc = new DomainService1();

var proxy = _generator.CreateClassProxy(typeof(SampleViewModel), new object[]{dmnSvc}, _interceptor);

this.DataContext = proxy;



Il cuore di tutto è l’implementazione di IInterceptor, interfaccia di Castle qui a seguire:


Codice 6

public class ViewModelSimulatorInterceptor : IInterceptor
{
private static bool isSelf = false;
private List<MethodInfo> _methodsCall = new List<MethodInfo>();

public void Intercept(IInvocation invocation)
{
if (!isSelf)
PreInvocation(invocation);
invocation.Proceed();
}

private void PreInvocation(IInvocation invocation)
{
if (!invocation.Method.Name.StartsWith("get_"))
return;
var propertyInfo = invocation.Proxy.GetType().GetProperty(invocation.Method.Name.Replace("get_", ""));

if (propertyInfo == null)
return;

Attribute[] customAttributes = Attribute.GetCustomAttributes(propertyInfo)
.Where(x => x is AutoLoadAttribute || x is LazyLoadAttribute).ToArray();
if (customAttributes.Count() == 0)
return;


var wdc = invocation.Proxy;
var _svc = ((DomainContextBase)wdc).DomainContext;

//Auto Load
//Generic Load Method (Asynchronous Call To DomainServices)
var loadMethod = GetMethod(x => x.Name == "Load" && !x.ContainsGenericParameters, invocation);

if (customAttributes.Count(x => x is AutoLoadAttribute) > 0)
{
//Get EntityQuery GetQuery Method
var entityQueryCallMethod = GetMethod(x => x.Name.EndsWith("Query")
&& x.ReturnType.GetGenericArguments().Contains(propertyInfo.PropertyType.GetGenericArguments()[0])
&& x.GetParameters().Count() == 0, invocation);
var entityQuery = entityQueryCallMethod.Invoke(_svc, null);
loadMethod.Invoke(_svc, new object[] { entityQuery, LoadBehavior.MergeIntoCurrent, null, null });
}
else //LazyLoadAttribute
{
var dependencyProps = propertyInfo.PropertyType.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(
x => x.PropertyType.IsGenericType
&& Attribute.GetCustomAttribute(x, typeof(AssociationAttribute)) != null
);

var associationAttributes = propertyInfo.PropertyType.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(x => x.PropertyType.IsClass)
.SelectMany(Attribute.GetCustomAttributes)
.Where(z => z is AssociationAttribute).Cast<AssociationAttribute>();


foreach (var item in dependencyProps)
{
var associationAtt= (AssociationAttribute)Attribute.GetCustomAttribute(item, typeof(AssociationAttribute));

object[] keys = new object[associationAtt.ThisKeyMembers.Count()];

isSelf = true;
var itemValue = propertyInfo.GetValue(invocation.Proxy, null);
isSelf = false;
if (itemValue == null)
continue;
int i = 0;

foreach (string thisKeyMember in associationAtt.ThisKeyMembers)
{
keys[i] = propertyInfo.PropertyType.GetProperty(thisKeyMember).GetValue(itemValue, null);
i++;
}

var queryMethod = GetMethod(x => x.Name.EndsWith("Query")
&& x.ReturnType.GetGenericArguments().Contains(item.PropertyType.GetGenericArguments()[0])
&& x.GetParameters().Count() == 1, invocation);

var entityQuery = queryMethod.Invoke(_svc, keys);

loadMethod.Invoke(_svc, new object[] { entityQuery, LoadBehavior.MergeIntoCurrent, null, null });

}
}
}

private MethodInfo GetMethod(Func<MethodInfo, bool> predicate, IInvocation invocation)
{
var returnValue = _methodsCall.FirstOrDefault(predicate);
if (returnValue == null)
{
var wdc = invocation.Proxy;
var _svc = ((DomainContextBase)wdc).DomainContext;

returnValue = _svc.GetType().GetMethods(BindingFlags.Instance | BindingFlags.Public).FirstOrDefault(predicate);
if (returnValue != null)
_methodsCall.Add(returnValue);
}
return returnValue;
}
}





Il metodo “Intercept” viene chiamato da Castle DynamicProxy al posto di qualunque metodo cui si tenti di chiamare sulla classe target (in questo caso SampleViewModel) e la chiamata invocation.Proceed() scritta in esso, consente di effettuare la chiamata sulla classe target, all’uscita da tale chiamata l’esecuzione è ovviamente ancora all’ interno del metodo “Intercept”. 


Vediamo un po’ più in dettaglio cosa succede quando la classe di binding richiede la proprietà “Order”, da me decorata con un attributo “AutoLoad”.


Come visibile dal codice “5” il datacontext non è direttamente il ViewModel, ma il dynamic proxy di esso, per cui il binding chiamerà il metodo “get_Orders” del proxy che verrà intercettato nel metodo “Intercept” fornendo il relativo MethodInfo nel parametro “invocation”.

Interceptor chiama quindi “PreInvocation” (isSelf è senz’altro false nella prima chiamata) che dopo qualche controllo si appoggia alla reflection per ottenere dall’oggetto proxato (il SampleViewModel) il MethodInfo del metodo “Load[EntityQuery]” del DomainService, il metodo cioè che fornisce un surrogato di un IQueryable sul quale è possibile applicare Where, OrderBy, ThanBy, Skip, Take…


Qui cerco di fare un minimo di ottimizzazione (ma non sono nemmeno certo di quanto sia utile in effetti) mettendo in _methodCalls tutti i riferimenti ai metodi che cerco tramite Reflection, e questo è lo scopo del metodo “GetMethod” che applica il predicato fornito prima sulla “cache” dei methodinfo e solo in caso di assenza in cache interroga direttamente il type, sempre con lo stesso predicato (ma quanto mi piace LINQ ?? )


Ottenuto il methodinfo della query faccio lo stesso per il metodo “Load”, che come parametro vuole l’entityquery ottenuta sopra e invoco quest’ultimo: la “magia” è fatta, da adesso in poi ci pensano le classi del DomainService a effettuare la chiamata (REST) opportuna al DomainService sul server.


Nel caso di SelectedOrder viene fatta più o meno la stessa cosa, cambiano solo i parametri del metodo “Load[EntityQuery]” che vengono recuperati nel ciclo, ma con il meraviglioso debugger di VS2010 vi annoierete meno che a leggere la spiegazione che posso fornire io.




Ovviamente si tratta di un prototipo che ha diversi problemi e diverse assunzioni, ma spero possa essere utile come spunto a chiunque si sia imbattuto in un problema analogo. A proposito dei problemi, per esempio, bisogna asssolutamente evitare le chiamate “multiple” che questo proxy genererebbe ogni volta che si tenta di accedere a una proprietà, vedic SelectedOrder che è bindata sia alla prima che alla seconda griglia => due chiamate get => due chiamate al metodo load sul server, magari mentre le proprietà di SelectedOrder sono già tutte nel DomainContext!! e qui si potrebbe ovviare leggendo l’invocation list dell’evento “PropertyChanged” o ricorrendo al timing… non so bene ancora, comunque se questo diverrà codice di produzione e qualcuno è interessato magari faccio un altro post!


L’intero progetto è scaricabile qui


Un osservazione sul lazy loading: non è affatto una panacea, e soprattutto da un client web come silverlight potrebbe addirittura peggiorare le prestazioni anziché migliorarle, ma… quant’è comodo ? :) 


Thursday, May 13, 2010

...immancabile

primo post!
Un po' come la monetina quando regali un portafogli, no?