Introduzione
Da quando mi occupo di Web Service, sviluppati in particolare con il Framework .NET, ho sempre sottolineato ai miei clienti il fatto che bisogna pensare a SOAP come ad una tecnologia per integrare differenti piattaforme e non solo come a uno strumento da utilizzare da server .NET a client .NET. Ci sono però casi in cui risulta assolutamente comodo sfruttare la versatilità dei Web Service per fornire a Smart Client .NET dei DataSet popolati lato server, permettendo ai client di aggiornarli e rispedirli al server affinché esegua un aggiornamento batch.
Questo tipo di attività richiede però alcuni accorgimenti non solo legati allo sviluppo del Web Service in quanto tale, ma anche al corretto utilizzo di ADO.NET e all’aggiornamento batch dei DataSet tramite DataAdapter.
In questo articolo lavoreremo con una tabella clienti molto semplice, ospitata da un database SQL Server 2000. Il server sarà un Web Service ASP.NET, mentre il client sarà un’applicazione Windows Forms in grado di aggiungere, cancellare e modificare i dati relativi ai clienti. Sempre il client sarà poi in grado di persistere i dati su disco per un successivo utilizzo, magari anche dopo un riavvio del PC.
Lo scheletro del servizio
Abbiamo bisogno di realizzare un Web Service che esponga almeno un paio di WebMethod: uno per fornire i dati al client e uno per avere indietro i dati e aggiornare il database. I Web Service ASP.NET sono in grado di gestire tranquillamente i DataSet sia come input che come output dei loro WebMethod. Il DataSet internamente ragiona in XML e può rappresentare il suo stato sia sfruttando XmlSerializer (il motore di serializzazione dei Web Service ASP.NET) che i Formatter (i serializzatori del motore di serializzazione runtime, usato anche da .NET Remoting).
Sarà quindi sufficiente predisporre una coppia di WebMethod come i seguenti:
[WebMethod]
public DataSet ListCustomers()
{
// Prepara e restituisce il DataSet
}
[WebMethod]
public DataSet UpdateCustomers(DataSet ds)
{
// Riceve il DataSet, aggiorna il DB e
// restituisce i dati dopo l'aggiornamento
}
Non è scopo di questo articolo approfondire come muoversi per popolare il DataSet quindi sono date per assodate queste informazioni, comunque reperibili in numerosi libri di testo e articoli. Sarà invece fondamentale approfondire, in uno dei paragrafi successivi, come gestire l’aggiornamento batch e risolvere eventuali conflitti.
È importante osservare bene il formato XML del DataSet ottenuto dal WebMethod di selezione dei record. Possiamo farlo con l’ausilio di un tracer come quello fornito con il Microsoft SOAP Toolkit 3.0 o semplicemente invocando dal browser, tramite l’apposita pagina di test ottenuta richiedendo il file ASMX, il WebMethod di lista dei record.
<?xml version="1.0" encoding="utf-8"?> <DataSet xmlns="http://schemas.paolo.com/CustomersManager"> <xs:schema id="NewDataSet" xmlns="" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> <xs:element name="NewDataSet" msdata:IsDataSet="true" msdata:Locale="it-IT"> <xs:complexType> <xs:choice maxOccurs="unbounded"> <xs:element name="tabCustomers"> <xs:complexType> <!-- Struttura XSD della tabella omessa per motivi di spazio --> </xs:complexType> </xs:element> </xs:choice> </xs:complexType> <xs:unique name="Constraint1" msdata:PrimaryKey="true"> <xs:selector xpath=".//tabCustomers" /> <xs:field xpath="idCustomer" /> </xs:unique> </xs:element> </xs:schema> <diffgr:diffgram xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1"> <NewDataSet xmlns=""> <tabCustomers diffgr:id="tabCustomers1" msdata:rowOrder="0"> <idCustomer>AA001</idCustomer> <CustomerFullName>Prova</CustomerFullName> <CustomerEMail>prova</CustomerEMail> <SysTimestamp>AAAAAAAACKY=</SysTimestamp> </tabCustomers> <!-- Lista di record tabOrders e tabCustomers --> </NewDataSet> </diffgr:diffgram> </DataSet>
Si tratta di un XML particolare che contiene lo schema XSD (XML Schema Definition) della struttura dati rappresentata dal DataSet nonchè il suo contenuto, sotto forma di DiffGram. Cosa è un DiffGram? Si tratta di una grammatica XML elaborata da Microsoft per descrivere lo stato di un DataSet tenendo conto di eventuali modifiche apportate ai dati in esso contenuti. In pratica tramite questa grammatica XML è possibile rappresentare il contenuto informativo attuale di un DataSet, ma anche se e come sono state modificate, aggiunte o cancellate delle righe al suo interno, rispetto al momento in cui è stato popolato leggendo i dati originali dal database. Un DiffGram infatti contiene sia lo stato originale delle righe, al momento della richiesta al database server, che lo stato attuale, dopo aventuali modifiche. Il tipo di formato XML appena visto è proprio quello che si ottiene salvando lo stato di un DataSet con un XmlSerializer.
Il client
Il client dovrà avere una Web Reference al servizio e dovrà innanzitutto richiedere i dati al server usando il WebMethod ListCustomers:
private void downloadData_Click(object sender, System.EventArgs e)
{
CustomersManager ws = new CustomersManager();
try
{
dsCustomers = ws.ListCustomers();
if (dsCustomers != null) bindGridCustomers();
}
catch (System.Web.Services.Protocols.SoapException exSoap)
{
MessageBox.Show(exSoap.Message);
}
catch (System.Net.WebException exWeb)
{
MessageBox.Show(exWeb.Message);
}
}
Nelle applicazioni Windows Forms consiglio sempre di utilizzare un paradigma di accesso ai Web Service in asincrono ([Begin/End]WebMethod) per non rendere la chiamata al servizio bloccante per il client. In questo esempio per semplicità le attività saranno svolte in sincrono. Il metodo di upload dei dati è sicuramente quello più interessante:
private void uploadData_Click(object sender, System.EventArgs e)
{
CustomersManager ws = new CustomersManager();
if (dsCustomers.HasChanges())
{
try
{
DataSet dsResult = ws.UpdateCustomers(dsCustomers.GetChanges());
dsCustomers.Merge(dsResult, false);
dsCustomers.AcceptChanges();
}
catch (System.Web.Services.Protocols.SoapException exSoap)
{
MessageBox.Show(exSoap.Message);
}
catch (System.Data.DataException exData)
{
MessageBox.Show(exData.Message);
}
catch (System.Net.WebException exWeb)
{
MessageBox.Show(exWeb.Message);
}
}
}
Come si vede verifichiamo innanzitutto che l’utente finale abbia modificato i dati all’interno del DataSet, altrimenti non avrebbe senso disturbare il server per dirgli che non è cambiato nulla! Per svolgere questa verifica utilizziamo il metodo HasChanges esposto dalla classe DataSet. Esso risponderà true solo nel caso in cui vi siano dei record aggiunti/cancellati/modificati nel DataSet. Invochiamo quindi il metodo UpdateCustomers del Web Service passandogli solamente i record oggetto di modifica, sfruttando ancora una volta un apposito metodo della classe DataSet. Si tratta di GetChanges, che ci permette di ottenere tutte le righe modificate o i gruppi di righe modificate secondo un certo criterio definito dai valori dell’enumerazione DataRowState.
In questo modo tra client e server viaggeranno solo le righe modificate e non caricheremo la rete e il server di inutile lavoro. Il WebMethod di aggiornamento batch ci restituirà un DataSet che non sarà altro che l’insieme di righe da noi fornitegli, eventualmente risincronizzate con i dati realmente presenti sul database server. Appoggiandoci al metodo Merge della classe DataSet possiamo “fondere” il nostro DataSet presente sul client con quello nuovo, restituito dal server. Inoltre il Web Service sarà in grado di fornirci la lista degli eventuali conflitti o errori di aggiornamento, in modo tale che sia possibile comunicarli visivamente all’utente finale.
L’aggiornamento batch sul server
Ed eccoci al punto chiave dell’applicazione: l’aggiornamento batch dei dati. I DataSet, quando contengono dati provenienti da database, sono popolati grazie all’aiuto di classi che implementano IDbDataAdapter, per esempio nel nostro caso SqlDataAdapter. Queste classi fungono da mediatori sia in fase di caricamento dei dati nel DataSet che poi in fase di update del database. I DataAdapter sono dei contenitori di quattro oggetti Command: SelectCommand, DeleteCommand, InsertCommand e UpdateCommand. Come si capisce facilmente dai loro nomi, servono per configurare le attività da svolgere a seconda che si vogliano selezionare, cancellare, inserire o aggiornare righe. Un approccio abbastanza diffuso è quello di utilizzare un oggetto SqlCommandBuilder per farci scrivere le istruzioni SQL, ma molto spesso queste istruzioni sono scarsamente ottimizzate e altrettanto poco personalizzabili. Preferisco decisamente preparare delle stored procedure in SQL Server e configurare manualmente i vari oggetti Command del DataAdapter per usarle. Rimane comunque da gestire il problema degli accessi concorrenti ai dati. Cosa accade se due client contemporaneamente scaricano la lista dei clienti dal Web Service, cambiano l’email di uno stesso cliente, con due valori tra loro differenti e diversi da quello originale, e poi si ripresentano al server per l’aggiornamento batch? Chi vince?! Ma soprattutto come facciamo ad accorgerci che ciò sta accadendo?
Innanzitutto dobbiamo decidere se applicare una politica che premia l’ultimo arrivato ovvero il primo. Di solito nel nostro mondo si tende a premiare i primi primi. Premiare gli ultimi sarebbe per altro anche troppo facile, basterebbe forzare un UPDATE sui dati, senza preoccuparci di vedere se qualcuno li ha cambiati da quando li avevamo letti noi l’ultima volta. Per accorgerci invece che qualcuno ci ha "battuto sul tempo" possiamo applicare diverse tecniche:
• Confrontare tutte le colonne del record corrente: dal momento che il DataSet, tramite il DiffGram, mantiene sia i valori attuali che quelli originali, possiamo confrontare tutti i valori originali delle colonne con quelli attualmente presenti nel database e, solo se tutti coincidono, confermare i nostri valori correnti. Si tratta del comportamento predefinito dei CommandBuilder ed è abbastanza oneroso.
• Utilizzare una colonna di tipo timestamp: in SQL Server possiamo definire delle colonne che cambiano il loro contenuto ad ogni intervento di modifica sulla riga a cui appartengono, comportandosi come un "timbro" di ultima modifica. Se salviamo il timestamp dei record alla lettura dal database e li confrontiamo al ritorno con i dati modificati, semplicemente verificando questa colonna possiamo scoprire se qualcuno ha cambiato i dati mentre eravamo disconnessi.
Personalmente preferisco la seconda soluzione ed è quella che implemento generalmente. Per esempio nel nostro caso, partendo dalla seguente tabella tabCustomers:
CREATE TABLE [dbo].[tabCustomers] ( [idCustomer] [udt_IdCustomer] NOT NULL , [CustomerFullname] [udt_CustomerFullName] NOT NULL , [CustomerEMail] [udt_CustomerEMail] NOT NULL , [SysGuid] [udt_SysGuid] NOT NULL , [SysTimestamp] [timestamp] NOT NULL ) GO la sua stored procedure di aggiornamento sarà: T-SQL CREATE PROCEDURE spUpdateCustomer ( @idCustomerOriginal udt_IdCustomer, @idCustomer udt_IdCustomer, @CustomerFullName udt_CustomerFullName, @CustomerEMail udt_CustomerEMail, @SysTimestamp timestamp ) AS UPDATE tabCustomers SET idCustomer = @idCustomer, CustomerFullName = @CustomerFullName, CustomerEmail = @CustomerEmail WHERE idCustomer = @idCustomerOriginal AND tsequal(SysTimestamp, @SysTimestamp) SELECT idCustomer, CustomerFullName, CustomerEMail, SysTimestamp FROM tabCustomers WHERE idCustomer = @idCustomer GO
Per verificare se il timestamp è cambiato utilizziamo l’apposita funzione tsequal del T-SQL, che solleverà un’eccezione nel caso in cui i valori non corrispondano. Se invece i valori dei timestamp sono uguali l’aggiornamento sarà confermato.
E cosa c’entra la SELECT, dopo l’istruzione di UPDATE? Si tratta di un accorgimento che ci permette di avere la sincronizzazione del record nel DataSet già in fase di aggiornamento. Gli oggetti Command, quando sono invocati da un DataAdapter, possono utilizzare i parametri di output e/o la prima riga restituita dall’istruzione SQL che eseguono per rinfrescare il singolo record in fase di aggiornamento. Determiniamo questo comportamento valorizzando la proprietà UpdatedRowSource del Command.
In questo modo, subito dopo aver aggiornato il record, rileggiamo dalla SELECT i nuovi valori delle sue colonne e sincronizziamo il DataSet con il database server, senza richiedere nuovamente tutti i record, ma rinfrescando solo quelli che sono cambiati. Il codice che si occupa di eseguire l’aggiornamento con il DataAdapter, nella sua parte fondamentale è il seguente:
[WebMethod]
public DataSet UpdateCustomers(DataSet ds)
{
SqlConnection cn = new SqlConnection(
ConfigurationSettings.AppSettings["SqlConnectionString"]);
// Configuro le Stored Procedure di aggiornamento dei Customer
SqlDataAdapter daCustomers = new SqlDataAdapter("spListCustomers", cn);
daCustomers.InsertCommand = new SqlCommand("spAddCustomer", cn);
// ... omissis ...
daCustomers.DeleteCommand = new SqlCommand("spDeleteCustomer", cn);
// ... omissis ...
daCustomers.UpdateCommand = new SqlCommand("spUpdateCustomer", cn);
// ... omissis ...
daCustomers.UpdateCommand.UpdatedRowSource =
UpdateRowSource.FirstReturnedRecord;
using (cn)
{
// Comunico al DataAdapter di continuare in caso di errore
daCustomers.ContinueUpdateOnError = true;
// Aggangio l’evento RowUpdated
daCustomers.RowUpdated += new
SqlRowUpdatedEventHandler(da_RowUpdated);
// Aggiorno il database
daCustomers.Update(ds.Tables["tabCustomers"]);
// Sgangio l’evento RowUpdated
daCustomers.RowUpdated -= new
SqlRowUpdatedEventHandler(da_RowUpdated);
}
// Restituisco il DataSet risincronizzato al client
return(ds);
}
private void da_RowUpdated(object sender, SqlRowUpdatedEventArgs e)
{
if (e.Status == UpdateStatus.ErrorsOccurred)
{
// Prevedere la riga seguente solo con .NET 1.0
e.Row.RowError = e.Errors.Message;
e.Status = UpdateStatus.SkipCurrentRow;
}
}
Come si vede intercettiamo l’evento RowUpdated del DataAdapter per renderci conto di quando si verificano degli errori di aggiornamento, valutando la proprietà Status del SqlRowUpdatedEventArgs. Nel caso di errori saltiamo la riga incriminata, assegnandole un errore e continuando comunque ad aggiornare le righe successive.
Questa gestione dell’aggiornamento batch paga fino a quando ci basta aggiornare e risincronizzare le righe che il client stesso ha modificato, eventualmente intercettando sovrapposizioni con il lavoro altrui sulle stesse. Se però vogliamo sincronizzare il DataSet sul client con tutti i dati che abbiamo sul server, dovremo per forza restituire non solo la porzione di DataSet oggetto dell’aggiornamento ma l’intero resultset, per essere sicuri che il client lavori sempre con la copia dei dati più recente possibile. In questo secondo caso il codice di aggiornamento sarà uguale al precedente, salvo il fatto che nell’ultima riga restituiremo un DataSet nuovo e popolato per intero (attenzione che “per intero” non vuol dire che se ho 1 milione di record li devo passare tutti al client, significa invece rinfrescare la pagina o la vista filtrata corrente ... senza abusare della rete!!!). Inoltre non avrà più senso prevedere la risincronizzazione delle singole righe nelle stored procedure di aggiornamento.
Salvare e caricare il DataSet su disco
L’ultima parte di articolo la dedichiamo a valutare come sia possibile salvare e poi rileggere il contenuto di un DataSet utilizzato da un client, per esempio per poter spegnere un PC con installato uno Smart Client .NET ed essere in grado di ritrovare i dati alla successiva accensione. Per ottenere questo risultato ci coviene non scrivere a mano il contenuto del DataSet, utilizzando magari i vari metodi WriteXml e WriteXmlSchema della classe DataSet, ma predisporre un’istanza di XmlSerializer e utilizzarla per leggere e scrivere i nostri DataSet.
private void saveDataLocally_Click(object sender, System.EventArgs e)
{
XmlSerializer serXml = new XmlSerializer(typeof(DataSet));
using(FileStream fs = new FileStream("dsCustomers.xml",
FileMode.Create, FileAccess.Write, FileShare.None))
{
serXml.Serialize(fs, dsCustomers);
}
}
private void loadDataLocally_Click(object sender, System.EventArgs e)
{
XmlSerializer serXml = new XmlSerializer(typeof(DataSet));
using(FileStream fs = new FileStream("dsCustomers.xml",
FileMode.Open, FileAccess.Read, FileShare.None))
{
dsCustomers = (DataSet)serXml.Deserialize(fs);
}
bindGridCustomers();
}
L’uso di XmlSerializer è semplice: basta crearne un’istanza, sulla base del tipo .NET che vogliamo serializzare, per noi sarà il DataSet, quindi invocarne il metodo Serialize o Deserialize con gli opportuni oggetti in ingresso o in uscita. Il fatto di utilizzare un XmlSerializer ci permette di avere all’interno del file dsCustomers.xml la sua rappresentazione sotto forma di DiffGram, quindi tra un fase di salvataggio ed una di caricamento non perderemo la traccia delle modifiche operate sui record.
Conclusioni
Abbiamo visto come sia possibile creare dei Web Service che svolgano la funzione di intermediari tra dei client .NET e un database, altrimenti non raggiungibile direttamente dagli stessi. Il punto chiave di questa tecnica di lavoro è l’implementazione di una corretta politica di gestione e risoluzione dei conflitti di accesso concorrente. Come abbiamo visto non esiste la soluzione, ma dobbiamo valutare a seconda dei casi e degli obiettivi quale delle possibili soluzioni si addice alle nostre esigenze particolari.
