Ultimamente mi sono trovato a sviluppare un’applicazione con la necessità di sincronizzare i dati locali con un’istanza di SQL Server 2008. Generalmente parlando, le applicazioni OCA (occasionally connected application) permettono di utilizzare un’applicazione client che fa uso di dati memorizzati in una base di dati locale che periodicamente è sincronizzata con un database centrale disposto su un server. Il processo di sincronizzazione non è mai stato un task banale (almeno nel caso bidirezionale), è penso che ognuno di noi, almeno una volta nella vita da sviluppatore si sia imbattuto in questo tipo di operazione. Fino a qualche tempo fa, spesso si creavano soluzioni ad hoc, funzionanti, ma magari non performanti o comunque a basso riuso. Per fortuna (tra l’altro da diverso tempo ) abbiamo a disposizione il Microsoft Sync Framework (che personalmente, fino ad oggi non ho mai avuto occasione di utilizzare in modo proficuo), una piattaforma che fornisce agli sviluppatori gli strumenti necessari per aggiungere funzionalità di sincronizzazione ad applicazioni, servizi e dispositivi. Nello specifico del post, ci concentreremo sul funzionamento relativo alla sincronizzazione di database, ma il Microsoft Sync Framework mette a disposizione i Synchronization Services per File Systems ed i Synchronization Services per FeedSync. La versione presente in Visual Studio 2010 è la 2.1, ma è possibile scaricare la versione 4.0 CTP , costruita sulla versione 2.1, con supporto al protocollo “OData + Sync” per lo sviluppo più facile di applicazioni OCA su qualsiasi piattaforma capace di eseguire il caching dei dati oltre alla sincronizzazione di dati memorizzati su SQL Server o SQL Azure.
Durante il processo di sincronizzazione l’aspetto più complesso è sicuramente la tracciatura dei cambiamenti (ovvero i relativi INSERT, UPDATE e DELETE eseguiti sul database Client e\o Server), altrimenti, ogni volta che l’utente si collega al database centrale per la sincronizzazione dei dati deve necessariamente eseguire il download di tutte le informazioni (in situazioni di connessioni lente o non affidabili potrebbe essere un problema di costi e\o tempi). Un metodo naive per eseguire il tracking dei cambiamenti è l’utilizzo di trigger o l’utilizzo di campi rowversion aggiunte alle tabelle soggette a sincronizzazione. Per la cancellazione dei record il discorso è leggermente diverso in quanto è necessario memorizzare le informazioni in una tabella separata (tombstone table) . Ovviamente, questa “metodologia” comporta alcuni svantaggi:
- - E’ necessario introdurre modifiche allo schema della base di dati
- - I trigger comportano problemi di performance
- - Scrittura della logica per rowversion e cancellazione delle righe
Se utilizziamo SQL Server 2008 come database centrale, possiamo evitare i problemi precedentemente esposti in quanto in questa versione è stata introdotto il “SQL Server 2008 Change Tracking”, un meccanismo il cui utilizzo si “riduce” a marcare le tabelle da monitorare, tracciando automaticamente le varie INSERT, UPDATE e DELETE, così da fornire gli opportuni cambiamenti ai client che richiedono di sincronizzarsi rispetto all’ultima operazione di sincronizzazione. Utilizzando il Sync Framework abbiamo questo supporto in modo nativo ed integrato in Visual Studio 2010 senza apportare modifiche allo schema del database, senza trigger e senza dover scrivere codice SQL di logica (maggiori dettagli su MSDN).
Proviamo a chiarire i concetti esposti con un esempio. Prendiamo in considerazione un database con un schema del tipo in figura:
Che descrive un semplicissimo database di un possibile e-commerce (Cliente, Acquisto e Prodotto). Il nostro scopo è sincronizzare i dati tra un client (una piccola applicazione stand-alone di esempio) che utilizza una database locale di SQL Server Compact 3.5 (od eventualmente 4.0 con le apposite modifiche) ed il database centrale presente in un’istanza di SQL Server 2008.
Creiamo un nuovo progetto Console o Windows Form (nel nostro caso) di Visual Studio 2010 a cui andiamo ad aggiungere il necessario per fornire l’accesso alla sincronizzazione dei dati. Dopo la creazione del progetto, aggiungiamo un nuovo item (dalla sezione Data) “Local Database Cache”:
A questo punto verrà visualizzata una nuova finestra nella quale andremo a specificare i parametri di connessione lato server e lato client:
Se stiamo utilizzando una connessione verso un database di SQL Server 2008, il Check “User SQL Server change Tracking” dovrebbe essere abilitato, e spuntando la voce, abbiamo la possibilità di sfruttare il Change Tracking integrato. Ancora, possiamo scegliere se l’operazione di sincronizzazione avverrà all’interno di un’unica transazione (se vogliamo che tutti o nessuno dei dati siano scaricati) oppure (secondo dello scenario) se vogliamo scaricare\caricare ad ogni sincronizzazione quanti più dati possibili. Restando nella finestra, sono presenti delle liste a discesa che permettono di scegliere, all’interno della nostra soluzione di Visual Studio il progetto lato client ed il progetto lato server. Nel nostro caso lasciamo la voce selezionata di Default, e quindi consideriamo il progetto precedentemente creato. E’ arrivato il momento di scegliere le tabelle che faranno parte della nostra cache di dati locale: l’operazione è molto semplice in quanto è sufficiente utilizzare il bottone con la dicitura Add in fondo a sinistra della ListBox presente:
Nella finestra “Configure Tables for Offline Use” è possibile selezionare le tabelle che vogliamo sincronizzare con i nostri client. Per ognuna di essa possiamo scegliere se ogni volta vogliamo scaricare l’interno contenuto della tabella (come durante la prima sincronizzazione) o se ogni successiva sincronizzazione sial di tipo incrementale. Nel caso stessimo utilizzando una versione diversa da SQL Server 2008 (ad esempio SQL Server 2005) come database centrale, non avendo a disposizione il supporto nativo al Change Tracking avremmo dovuto specificare delle Stored Procedure ad-hoc nei campi Compare update using, Compare insert using e Move deleted items to a supporto della logica di sincronizzazione:
A questo punto il gioco è quasi finito, in quanto premendo su OK, al progetto verranno aggiunti tutti i file e riferimenti necessari al funzionamento. Piccola nota: nella schermata precedente premendo il link Show Code Example viene visualizzata una finestra di dialogo con del codice di esempio (molto comodo se si utilizza il Sync Framework per la prima volta) per effettuare la sincronizzazione dei dati:
A questo punto viene avviata la prima sincronizzazione con la creazione del file “.sdf” di SQL Server Compact (ricordiamo per default nella versione 3.5). Dopo qualche secondo è visualizzata una nuova schermata in cui possiamo scegliere se utilizzare un DataSet o un file EDM (Entity Data Model) per il nostro Object Model as Data Model. Partiamo con un semplice DataSet ( ):
Nelle schermate che si susseguono andiamo a configurare il nostro DataSet tipizzato con tutte le tabelle che servono al funzionamento della nostra applicazione client. Notiamo che oltre alle tabelle originali troviamo tre tabelle __syncArticles, _syncSubscriptions e __syncTransactioncs necessarie per le operazioni di sincronizzazione, ma possiamo tranquillamente ignorarle. Alla fine del Wizard, il nostro DataSet dovrebbe assomigliare a qualcosa di questo tipo:
Bene. Supponendo di aver aggiunto alla nostra soluzione una Windows Form contenente una griglia e due bottoni (Synchronize e Close) come nella figura seguente:
Nell’handler dell’evento click del bottone Synchronize possiamo scrivere del codice tipo:
LocalCacheSyncAgent syncAgent = new LocalCacheSyncAgent();
syncAgent.Product.SyncDirection = Microsoft.Synchronization.Data.SyncDirection.Bidirectional;
Microsoft.Synchronization.Data.SyncStatistics syncStats = syncAgent.Synchronize();
////Server -> Client
StringBuilder sb = new StringBuilder();
sb.AppendLine(string.Format("Download Changes: {0}", syncStats.DownloadChangesApplied));
sb.AppendLine(string.Format("Download Failed: {0}", syncStats.DownloadChangesFailed));
sb.AppendLine(string.Format("Upload Changes: {0}", syncStats.UploadChangesApplied));
sb.AppendLine(string.Format("Upload Failed: {0}", syncStats.UploadChangesFailed));
sb.AppendLine(string.Format("Start Time: {0}", syncStats.SyncStartTime));
sb.AppendLine(string.Format("Complete Time: {0}", syncStats.SyncCompleteTime));
MessageBox.Show(sb.ToString());
Dove come prima operazione creiamo un’istanza di LocalCacheSyncAgent, ovvero l’engine che si occuperà di effettuare materialmente il lavoro di sincronizzazione. Impostiamo tramite la proprietà SyncDirection una sincronizzazione di tipo Bidirezionale per la tabella Product. Altre possibili opzioni sono:
- - Download, sincronizzazione unidirezionale Server –> Client
- - Upload, sincronizzazione unidirezionale Client –> Server
- - Snapshot, durante la sincronizzazione viene scaricato un insieme di dati, aggiornato completamente ad ogni sincronizzazione.
A questo punto non resta che invocare il metodo Synchronize del nostro “Agent” che restituisce un’istanza di SyncStatistics contenente le informazioni sull’esito della sincronizzazione, che “raccogliamo” mediante uno StringBuilder e mostriamo all’utente al termine delle operazioni con una semplice MessageBox:
Sopra è mostrato il risultato della prima sincronizzazione. Aggiungendo una riga in Product nella tabella locale (client), e sincronizzando nuovamente dovremmo ottenere un risultato di questo tipo:
Dove chiaramente si evince come sia stato eseguito l’upload di dati verso il server. Ok, ma ovviamente non tutte le tabelle escono col buco, di conseguenza bisogna prepararsi ad ogni evenienza, ovvero alla gestione di possibili conflitti tra dati presenti su Client e Server durante la sincronizzazione. Modifichiamo la parte iniziale del codice precedente in questo modo:
LocalCacheSyncAgent syncAgent = new LocalCacheSyncAgent();
syncAgent.Product.SyncDirection = Microsoft.Synchronization.Data.SyncDirection.Bidirectional;
////-----
LocalCacheClientSyncProvider localProvider = syncAgent.LocalProvider as LocalCacheClientSyncProvider;
LocalCacheServerSyncProvider serverProvider = syncAgent.RemoteProvider as LocalCacheServerSyncProvider;
localProvider.ApplyChangeFailed += new EventHandler<Microsoft.Synchronization.Data.ApplyChangeFailedEventArgs>(localProvider_ApplyChangeFailed);
serverProvider.ApplyChangeFailed += new EventHandler<Microsoft.Synchronization.Data.ApplyChangeFailedEventArgs>(serverProvider_ApplyChangeFailed);
////-----
Microsoft.Synchronization.Data.SyncStatistics syncStats = syncAgent.Synchronize();
...
Dove attraverso istanze di LocalCacheClientSyncProvider e LocalCacheServerSyncProvicer ci sottoscriviamo agli eventi per la gestione dei conflitti, ApplyChangedFailed:
if(e.Conflict.ConflictType == Microsoft.Synchronization.Data.ConflictType.ClientInsertServerInsert){
DataTable clientChange = e.Conflict.ClientChange;
DataTable serverChange = e.Conflict.ServerChange;
FrmConflicts frmConflicts = new FrmConflicts();
frmConflicts.SetDataSource(clientChange, serverChange);
frmConflicts.ShowDialog(this);
e.Action = Microsoft.Synchronization.Data.ApplyAction.RetryWithForceWrite;
}
if (e.Conflict.ConflictType == Microsoft.Synchronization.Data.ConflictType.ErrorsOccurred)
{
MessageBox.Show(e.Conflict.ErrorMessage);
e.Action = Microsoft.Synchronization.Data.ApplyAction.Continue;
}
Nel codice intercettiamo due dei possibili conflitti che possono verificarsi: nel caso di conflitto dovuto ad Insert su Client e Server (ad esempio su client e server viene aggiunto uno stesso prodotto), mostriamo all’utente tramite una seconda Windows Form (FrmConflicts) il dati che creano il problema , e dopo forziamo la scrittura (causando di fatto una duplicazione dei valori all’interno della tabella Product, ovviamente possono\devono essere considerate altre strategie scelte eventualmente dall’utente). Nel caso di conflitto dovuto ad errore non predicibile mostriamo (in modo molto brutale ) un MessageBox contenente informazioni di errore (in un’applicazione reale questo dovrebbe essere evitato in quanto potrebbero essere visualizzate informazioni sensibili per la sicurezza del sistema) e tentiamo di continuare la sincronizzazione dei dati.
Se invece di utilizzare un DataSet volessimo utilizzare Entity Framework, il gioco è semplice, basta utilizzare il Wizard dell’Entity Data Model per costruire un modello da un database già presente (il file .sdf) . Nello specifico caso, avremmo qualcosa di questo tipo:
Le operazioni di sincronizzazione a questo punto non sono più un problema (o almeno si spera ).