Implementazione di un data provider asincrono

Di Mauro Servienti

In questa pagina

Introduzione Introduzione
Perchè un “Data Provider” Perchè un “Data Provider”
“Entry Point” “Entry Point”
Sostituibile a caldo Sostituibile a caldo
Un mondo asincrono... Un mondo asincrono...
Il Problema... Il Problema...
Il “Token” Il “Token”
Conclusione Conclusione
Risorse Risorse

Introduzione

Quando ci si avvicina per la prima volta allo sviluppo di un’applicazione, di qualsiasi genere essa sia, l’architettura e il design sono pressoché degli sconosciuti e i concetti di n-layer o n-tier sono ben lontani dall’essere compresi o anche solo immaginati. Il risultato dei nostri sforzi sarà quindi un bel monolite che fa sì quello che deve fare, ma che ben presto si rivelerà totalmente inmanutenibile e/o non estendibile se non con uno sforzo non indifferente e con la conseguente necessità di ricompilare e redistribuire l’intera applicazione.
Mano a mano che si affrontano scenari di questo tipo ci si rende conto che è fondamentale cercare di isolare il più possibile le varie parti che costituiscono la nostra applicazione. In fin dei conti applichiamo a macro parti della nostra applicazione un principio che applichiamo tutti i giorni durante lo sviluppo secondo i principi della programmazione Object Oriented: cominciamo cioè ad incapsulare e isolare in “Black Box” alcuni blocchi della nostra struttura al fine di rendere la nostra applicazione il più agnostica possibile. Queste “Black Box” vengono comunemente definite “layer”.
Un esempio di suddivisione in layer in un’applicazione molto semplice potrebbe essere il seguente:

Immagine 1

Il “Presentation Layer” è lo strato applicativo che si occupa della presentazione dei dati all’utente, se questo strato sia un’applicazione Windows, Web o quant’altro è del tutto irrilevante, il “Business Layer” contiene tutto il set di classi che rappresentano il nostro Object Model, mentre il “Data Access Layer” è lo strato che si occupa della gestione dati, la cosidetta CRUD.

Un’osservazione fondamentale da fare a questo punto è quali siano le relazioni che intercorrono tra gli strati che abbiamo appena citato, chi conosce chi e sopratutto perchè.

Il PL conosce certamente sia il BL che il DAL, è fondamentale che conosca entrambi perchè il BL rappresenta i dati che deve trattare e il DAL è il mezzo per trattarli. Il DAL conosce solo il BL ma non ha nessuna nozione del PL, anche in questo caso è di fondamentale importanza che il DAL non conosca lo strato di presentation per 2 buoni motivi: il primo, tecnico, è che si verrebbe a creare un riferimento circolare difficilmente districabile; il secondo è che creerebbe una dipendenza che non ha necessità di esistere: lo strato di accesso ai dati non ha alcuna necessità di sapere per quale motivo e soprattutto in che modo questi vengono trattati.Infine il BL non conosce proprio nessuno, ed in effetti non ha senso che abbia nozione né del DAL, alla business entity non interessa sapere come e da chi viene gestita, né tanto meno del PL, alla stessa stregua la nostra entity non ha nessun bisogno di sapere come e da chi viene presentata. In questo caso si dice che il BL è trasversale.In quest’ottica è quindi lecito asserire che ogni strato conosce quello direttamente sotto di lui e solo quello, e non ha nessuna nozione di chi stia sopra di lui.

Questa semplice architettura ci permette di fare proprio quello che vogliamo, cioè incapsulare porzioni della nostra logica, sia essa di business, CRUD o altro, in black box con cui possiamo dialogare perchè ne conosciamo l’interfaccia pubblica, ignorando quanto succede internamente.

In questo caso si parla di architettura n-layer, dove n rappresenta il numero degli strati in cui la nostra applicazione è suddivisa. Semplificando al massimo il concetto possiamo anche asserire che se questi strati sono distribuiti su più macchine all’interno dello stesso sito o anche geograficamente, si parla allora di applicazione n-tier dove ogni “macchina”, non necessariamente una macchina fisica, rappresenta un tier. Parlando di applicazioni n-tier non ci dobbiamo meravigliare se poi internamente ogni tier è a sua volta un componente n-layer.

Perchè un “Data Provider”

Fatta questa doverosa introduzione, cerchiamo di addentrarci meglio nei meandri del problema introducendo il concetto di Data Provider. Diciamo innanzitutto che un Data Provider non è un Data Access Layer; in alcuni casi potrebbero coincidere, ma concettualmente sono due componenti molto diversi. Il Data Access Layer è colui il quale accede fisicamente ai dati, li legge, li scrive e li cancella; è il collegamento ultimo tra la nostra applicazione e lo storage fisico dei dati. Un Data Provider invece è ad un livello di astrazione leggermente più alto: è colui che espone un’interfaccia per poter accedere ai dati e che si rivolgerà poi a sua volta ad un Data Access Layer per fare il lavoro vero e proprio.

Perchè questa distinzione?

Per il semplice fatto che ci serve un adattatore tra il formato con cui il Data Access Layer ci restituisce i dati e il formato che la nostra applicazione si aspetta, che nel nostro caso è la Business Logic: questo compito di “adapting” è assolto dal Data Provider che conosce il Data Access Layer, e quindi sa in che formato gli vengono restituiti i dati e sa molto bene in che formato fornire i dati al chiamante, la nostra applicazione.Da quello che abbiamo appena visto il fine ultimo del Data Provider è quello di isolare la logica di persistenza dei dati astraendone un’interfaccia che sarà l’unica cosa nota alla nostra applicazione; questo ci garantisce un salto di qualità notevole permettendoci di gestire, a parità di interfaccia, logiche di persistenza anche abissalmente differenti tra loro o anche “distanti” tra loro. In sostanza nulla ci vieta di pensare che un Data Provider faccia da ponte, si occupi cioè dell’adapting, tra un tier, quello dove risiede il PL e un altro tier come ad esempio potrebbe essere quello che eroga i dati, che a sua volta farà l’accesso fisico allo storage vero e proprio.

“Entry Point”

Entrando sempre più nel dettaglio, cerchiamo ora di capire quali siano le strategie migliori per esporre alla nostra applicazione tale interfaccia. E’ necessario trovare un sistema per centralizzare l’accesso al Data Provider, e ci si rende altrettanto rapidamente conto che la soluzione definitiva è esporre il Data Provider sotto forma di Singleton e non come classe statica. Il motivo principe è molto semplice: perderemmo tutti i vantaggi della programmazione ad oggetti venendo meno i principi di ereditarietà e polimorfismo. Vediamo un semplice esempio:

public abstract class MyDataProvider
{
  static MyDataProvider _instance = null;
  public static MyDataProvider GetInstance()
  {
    if( _instance == null )
    {
      _instance = ActivatorHelper.CreateInstance<MyDataProvider>( 
                      Properties.Settings.Default.MyDataProviderType );
    }

    return _instance;
  }

  public abstract MyEntity GetEntityById( String id );
}

La classe, astratta, MyDataProvider rappresenta un ipotetico Data Provider, il cui unico punto d’accesso è il metodo statico GetInstance() che non fa altro che verificare se un’istanza del Data Provider sia già stata creata e, in caso negativo, la crea. L’uso da parte del chiamante è quindi siffatto:

MyDataProvider dp = MyDataProvider.GetInstance();
MyEntity obj = dp.GetEntityById( "SomeValue" );

Ma cosa succede la prima volta che vengono eseguite queste due semplici righe di codice? Come abbiamo già visto, alla prima chiamata al metodo GetInstance() viene verificato se un’istanza del Data Provider esiste già e, in caso negativo, viene creata e restituita al chiamante che potrà poi eseguire la chiamata al metodo GetEntityById( String id ). In realtà notiamo subito che la cosa suona quanto meno strana: abbiamo un’istanza di una classe astratta su cui eseguiamo la chiamata ad un metodo anch’esso astratto...Quello che ci manca è l’implementazione concreta e reale del nostro Data Provider, quell’implementazione che sappia effettivamente fare da adapter tra il Data Access Layer e la nostra applicazione:

public class MyConcreteDataProvider : MyDataProvider
{
  public override MyEntity GetEntityById( String id )
  {
    /*
qui effettuiamo il vero e proprio adpating caricando
i dati dalla sorgente dati e trasformandoli in qualcosa
di noto alla nostra applicazione, in questo caso in un’istanza
della classe MyEntity
    */
  }
}

Il legame tra il Data Provider astratto MyDataProvider e la sua implementazione concreta è garantito da un’informazione in un file di configurazione che leggiamo attraverso il nuovo modello di ApplicationSettings esposto dal .NET Framework 2.0:

Properties.Settings.Default.MyDataProviderType;

MyDataProviderType altro non è che una stringa che rappresenta il nome completo (Full Type Name) del “tipo” che dobbiamo effettivamente istanziare, nella forma “NameSpace.ClassName, AssemblyName[, Evidence]”. Grazie a questa informazione siamo in grado di creare, via Reflection, un’istanza della classe concreta che, derivando dalla classe di base astratta MyDataProvider, potrà essere considerata a tutti gli effetti un’istanza di quel tipo.

Sostituibile a caldo

Quello che salta subito all’occhio è che l’unica parte variabile di tutto il processo appena descritto è il tipo del Data Provider concreto che dobbiamo istanziare e notiamo anche che questa informazione è in un file di configurazione e quindi non è cablata all’interno del nostro codice. Questo dettaglio è di fondamentale importanza perchè ci permette di cambiare il tipo concreto semplicemente scrivendo un nuovo Data Provider che derivi dal nostro MyDataProvider astratto: inserendo l’informazione sul nuovo tipo all’interno del file di configurazione della nostra applicazione, al prossimo riavvio dell’applicazione verrà utilizzato il nuovo Data Provider in maniera totalmente trasparente, senza cioè che l’applicazione si accorga di nulla.

A questo punto qualcuno si potrebbe chiedere quale sia il motivo per realizzare un’architettura così articolata per assolvere ad un task fondamentalmente semplice come il caricamento dei dati da un database...La chiave di tutto sta proprio nella parola database che fino ad ora non era mai stata usata: è concezione comune pensare che dietro ad un’applicazione ci sia sempre un database, ma questo non è sempre vero o non è sempre vero che il database stia esattamente “dietro” l’applicazione.Potremmo avere uno scenario in cui l’applicazione è fruita in una rete locale e lavora nella più classica delle modalità client/server e uno scenario in cui la stessa applicazione è utilizzata da remoto appoggiandosi per il trasporto dei dati ad una connessione “labile” come ad esempio è la connettività 3G. In questo caso sarebbe impensabile e poco sensato scrivere due applicazioni diverse che assolvano allo stesso compito; in questo scenario, l’architettura appena presentata ci permette di trasformare la nostra struttura dal semplice n-layer dello scenario client/sever ad un complesso n-tier dello scenario distribuito, lasciando invariate moltissime parti di codice, con tutti i vantaggi che ne derivano in termini di manutenibilità, estendibilità e di eleganza.

Un mondo asincrono...

Queste ultime considerazioni introducono un’ulteriore necessità: in un mondo distribuito in cui il canale di comunicazione non è garantito, né tanto meno ne sono garantite le performance, diventa critico proprio il controllo e la gestione delle performance. Ritengo che, in questi casi, uno degli elementi chiave del successo sia il feedback all’utente finale; ci sono task che sono inevitabilmente lunghi e/o lenti e non è detto che si possano migliorare: a questo punto la soluzione doverosa è quella di rendere consapevole l’utente di quello che sta succedendo, senza lasciarlo in un limbo in cui non sa se la nostra applicazione stia facendo qualcosa o sia semplicemente bloccata per qualche causa a lui oscura.Il segreto della buona riuscita in questo caso è il Multi-Threading: l’obiettivo è quello di rendere asincrone molte delle operazioni che vengono eseguite in modo da poter gestire n operazioni concorrenti senza che queste vengano accodate.

Perchè quindi non rendere asincrono anche il nostro Data Provider? L’operazione è molto più semplice di quel che si potrebbe pensare:

public void GetEntityByIdAsync( String id )
{
  BackgroundWorker worker = new BackgroundWorker();
  worker.DoWork += new DoWorkEventHandler( OnWorkerDoWork );
  worker.RunWorkerCompleted += new RunWorkerCompletedEventHandler(OnWorkerRunWorkerCompleted );

  worker.RunWorkerAsync( id );
}

void OnWorkerDoWork( object sender, DoWorkEventArgs e )
{
  /*
Il lavoro vero e proprio viene comunque fatto dal metodo
GetEntityById che sarà implementato nel data provider
Concreto garantendoci che venga svolto il corretto adapting
E permettendoci di scrivere la logica per il multi-threading
Una ed una sola volta
    */
  e.Result = this.GetEntityById( ( String )e.Argument );
}

void OnWorkerRunWorkerCompleted( object sender, RunWorkerCompletedEventArgs e )
{
  MyEntity result = e.Result as MyEntity;
  AsyncCompletedEventArgs<MyEntity> args = new AsyncCompletedEventArgs<MyEntity>( 
e.Error, 
e.Cancelled, 
result );

  this.OnGetEntityByIdCompleted( args );
}

public event EventHandler<AsyncCompletedEventArgs<MyEntity>> GetEntityByIdCompleted;
protected virtual void OnGetEntityByIdCompleted( AsyncCompletedEventArgs<MyEntity> args )
{
  if( this.GetEntityByIdCompleted != null )
  {
    this.GetEntityByIdCompleted( this, args );
  }
}

Nello stralcio di codice che riportiamo non facciamo altro che applicare il nuovo pattern per le operazioni asincrone introdotto con il .NET Framework 2.0: abbiamo cioè un metodo GetEntityByIdAsync() che ha la stessa firma del metodo non asincrono e differisce solo per il nome, che lo identifica appunto come asincrono; abbiamo poi un evento GetEntityByIdCompleted che viene scatenato al termine dell’operazione asincrona. Vi sono poi una manciata di metodi privati di supporto che servono per gestire le operazioni asincrone incapsulate dal BackroundWorker. Infine, AsyncCompletedEventArgs<T> altro non è che una classe che estende la classe AyncCompletedEventArgs del .NET Framework con il solo scopo di rendere fortemente tipizzato il valore di ritorno e non averlo come semplice System.Object.

Il Problema...

Il codice riportato funziona egregiamente, ma soffre di un grosso problema. Andiamo con ordine: cosa succede quando un client deve utilizzare il nostro Data Provider in maniera asincrona? Farà qualcosa del tipo:

void LoadEntityAsync()
{
  MyDataProvider dp = MyDataProvider.GetInstance();
  dp.GetEntityByIdCompleted += new EventHandler<AsyncCompletedEventArgs<MyEntity>>(
OnGetEntityByIdCompleted );
  dp.GetEntityByIdAsync( "SomeValue" );
}

void OnGetEntityByIdCompleted( object sender, AsyncCompletedEventArgs<MyEntity> e )
{
  MyEntity obj = e.Result;
}

Semplice e lineare, non c’è che dire, ma profondamente sbagliato. innanzitutto manca una cosa fondamentale: all’interno dell’handler dell’evento non sganciamo il delegato, e alla seconda chiamata il nostro metodo OnGetEntityByIdCompleted( ... ) verrebbe invocato due volte e così via fino allo stallo dell’intero sistema; ma questo, in fin dei conti, è solo un problema di qualità del codice, non certo un problema architetturale.Invece il problema architetturale c’è, anche se a prima vista non si vede; solo durante l’uso dell’applicazione emergerà un singolare comportamento. Ma facciamo un passo indietro: il Data Provider è esposto sotto forma di Singleton, e questo lo rende a tutti gli effetti molto simile, passatemi il paragone, ad una variabile globale, con tutti i pregi che questo porta ma anche con tutti i problemi che ne derivano.

L’evento GetEntityByIdCompleted è uno e soprattutto è sempre lo stesso perchè l’istanza a cui accediamo per sottoscrivere l’evento è sempre la stessa, essendo esposta come Singleton: questo comporta che se sottoscriviamo quell’evento in due punti diversi della nostra applicazione, quest’ultimo verrà gestito in entrambi i punti anche a seguito di una sola chiamata al metodo GetEntityByIdAsync() e questo non è certo il comportamento che desideriamo.

La soluzione naturalmente c’è, ed è decisamente semplice: si tratta di trovare un sistema lineare per tracciare le chiamate al metodo GetEntityByIdAsync() e sapere che l’evento che stiamo per gestire sia effettivamente quello che ci stiamo aspettando in quel preciso momento. Per fare ciò introduciamo un nuovo concetto.

Il “Token”

Innanzitutto diciamo che la tracciabilità in questi casi deve essere fatta dall’esterno, non possiamo in alcun modo delegare questa operazione al Data Provider. Anche qui vediamo prima il codice:

public sealed class Token : System.Object
{
  public Token()
  {
  }
}

public void GetEntityByIdAsync( String id, Token tk )
{
  BackgroundWorker worker = new BackgroundWorker();
  worker.DoWork += new DoWorkEventHandler( OnWorkerDoWork );
  worker.RunWorkerCompleted += new RunWorkerCompletedEventHandler( OnWorkerRunWorkerCompleted );
  worker.RunWorkerAsync( new object[] { id, tk } );
}

void OnWorkerDoWork( object sender, DoWorkEventArgs e )
{
  object[] args = ( object[] )e.Argument;
  MyEntity obj = this.GetEntityById( ( String )args[ 0 ] );
  e.Result = new object[] { obj, args[ 1 ] };
}

void OnWorkerRunWorkerCompleted( object sender, RunWorkerCompletedEventArgs e )
{
  object[] args = ( object[] )e.Result;
  MyEntity result = args[ 0 ] as MyEntity;
  Token tk = args[ 1 ] as Token;
  AsyncCompletedEventArgs<MyEntity> args = new AsyncCompletedEventArgs<MyEntity>( 
e.Error, e.Cancelled, result, tk );
  this.OnGetEntityByIdCompleted( args );
}

Decisamente simile al suo predecessore, ma con alcune importanti variazioni. Introduciamo una nuova classe Token che altro non è che un semplice “segnaposto” da utilizzarsi come sistema di tracciamento, alla stessa stregua dei marker per gli esami clinici o dei sistemi di puntamento in ambito militare. Vediamo poi che è necessario passare un’istanza del Token al metodo GetEntityByIdAsync() e che questa passa, invariata e inalterata, attraverso tutti i passaggi eseguiti dal BackgroundWorker per essere infine restituita al chiamante all’interno degli argomenti dell’evento GetEntityByIdCompleted.Cosa deve fare quindi il chiamante per essere sicuro che la notifica, cioè lo scatenarsi dell’evento GetEntityByIdCompleted, sia effettivamente quella che interessa a lui? Semplicemente questo:

static readonly Token myToken = new Token();
public void LoadEntityAsync()
{
  MyDataProvider dp = MyDataProvider.GetInstance();
  dp.GetEntityByIdCompleted += new EventHandler<AsyncCompletedEventArgs<MyEntity>>(
OnGetEntityByIdCompleted );
  dp.GetEntityByIdAsync( "SomeValue", myToken );
}

void OnGetEntityByIdCompleted( object sender, AsyncCompletedEventArgs<MyEntity> e )
{
  if( e.Token == myToken )
  {
    MyDataProvider dp = MyDataProvider.GetInstance();
    dp.GetEntityByIdCompleted -= new EventHandler<AsyncCompletedEventArgs<MyEntity>>(
OnGetEntityByIdCompleted );

    MyEntity obj = e.Result;
  }
}

Il chiamante passerà al metodo del Data Provider una istanza, per sicurezza statica e soprattutto readonly, e all’interno del gestore dell’evento si limiterà a verificare che il Token associato a quell’evento sia quello che gli abbiamo passato in precedenza, nel qual caso proseguiamo con le operazioni, tra cui lo “sgancio” del gestore al fine di evitare il moltiplicarsi delle chiamate.

Conclusione

Abbiamo visto quanto sia semplice realizzare un sistema per rendere trasparente una determinata operazione, o un set di operazioni, all’interno di un’applicazione.

Abbiamo anche visto che trasformare questo set di operazioni in un set di operazioni asincrone è decisamente semplice soprattutto grazie al supporto che ci viene fornito dalle nuovi classi presenti nel .NET Framework 2.0, come ad esempio il BackgroundWorker. Abbiamo sviscerato alcuni dei problemi che potremmo incontrare durante questo task e abbiamo visto alcune possibili soluzioni.

Potremmo introdurre, se necessario e ove possibile, il supporto per la cancellazione dell’operazione asincrona: per accompiere a questo è necessario però estendere non poco il codice presentato per tenere traccia all’interno del Data Provider di tutte le operazioni asincrone in corso; questo ci obbliga anche a tenere in considerazione, sempre all’interno del Data Provider, gli eventuali problema di concorrenza e di accesso a risorse condivise.

Un’ulteriore evoluzione potrebbe essere il supporto per una vera sostituibilità a caldo del Data Provider, quindi senza neanche la necessità di riavviare l’applicazione: ad esempio, si potrebbe utilizzare un FileSystemWatcher per monitorare il file di configurazione, e qualora ci fosse un cambiamento che comporti la necessità di sostituire il Data Provider, iniziare un’operazione di rimpiazzamento. Qui il compito è tutto tranne che semplice perchè ci troveremmo a dover gestire l’eventualità che all’atto della richiesta di cambio ci siano una serie di operazioni asincrone pendenti che debbano quindi essere smaltite prima di poter effettuare la sostituzione. Decisamente elegante sarebbe realizzare un’infrastruttura che, mutuando il comportamento di ShadowCopy, permetta di smaltire le operazioni in corso e nel frattempo soddisfi le nuove richieste con il nuovo tipo, evitando quindi accomodamenti onerosi da smaltire in seguito.

Risorse

MSDN Library (in inglese):

Per un approfondimento sul nuovo modello delle operazioni asincrone introdotte con il .NET Framework 2.0 e in generale sui nuovi sistemi per gestire l’accesso concorrente all’interfaccia utente:

Per ulteriori esempi di codice:


Mostra: