Pur rappresentando un grande passo avanti nelle tecnologie di accesso ai dati, ADO.NET non mette certo al riparo da tutti i possibili errori o da pratiche scorrette nelle quali uno sviluppatore può incorrere. Il buon senso quindi è ancora la regola numero uno da applicare nel momento in cui si analizza e progetta una soluzione di accesso ai dati.
Best Practices
Non è mai male quindi ripetere alcuni concetti e linee guida che possono essere presi come riferimento durante questa fase:
1) Aprire la connessione il più tardi possibile e chiuderla appena eseguito il comando
Le connessioni verso il database server sono un bene primario, ed occorre gestirlo con “parsimonia”. Le tecnologie come il Connection Pooling ci aiutano a realizzare soluzioni dove il mapping 1:1 tra utenti e connessioni non è più valido e il riutilizzo delle risorse è massimizzato.
2) Eseguire comandi che ritornano la necessaria quantità di informazioni
Query come “Select * From Clienti” tipicamente mettono in crisi le risorse del database server (memoria, cpu), il traffico di rete e l’applicazione client. Occorre sempre cercare di limitare le colonne e le righe ritornate specificando la corretta select list e clausola di WHERE sulla base delle informazioni necessarie. È raro infatti che all’applicazione serva avere in locale 100000 record con 25 colonne ciascuno... J
Quando è possibile poi è sempre conveniente utilizzare strumenti quali le Stored Procedure, che sono ottimizzate per avere un migliore riutilizzo del query plan e un minor traffico di rete, oltre ovviamente a rendere più flessibile l’achitettura dell’applicazione. Anche sull’utilizzo delle Stored Procedure torneremo in modo più specifico in un prossimo articolo.
3) Limitare i round-trip inutili
Le chiamate inutili tra client e server generano sempre una quantità di overhead rilevante che, soprattutto quando eseguiti su componenti lato-server, rischiano di far peggiorare le performances di una applicazione. In ADO.NET esistono molti strumenti per ottimizzare il tipo di comunicazione nel caso, ad esempio, servano più o meno informazioni sui metadati di un resultset oppure sulle caratteristiche dello schema del database sottostante. Grazie a questa flessibilità lo sviluppatore ha la possibilità di scegliere, questa volta in modo esplicito rispetto a quanto accadeva con ADO 2.x dove queste modalità erano spesso nascoste dietro nomi di proprietà o paramentri poco comprensibili.
4) Ottimizzare il database
Nessuna tecnologia di accesso ai dati, per quanto ottimizzata e performante, può prescindere da una base dati ottimizzata e correttamente indicizzata. Infatti senza strumenti di ricerca quali gli indici, nessun database server può garantire delle performance accettabili su quantità di dati significative. Oltre agli indici, altre tecniche proprie dei vari database server possono aiutare: in SQL Server , ad esempio, sono tipicamente da evitare strumenti quali i cursori server-side per ottenere applicazioni scalabili, così come scegliere i corretti tipi di dato per memorizzare le informazioni può dare ovvi benefici sulle performance.
Apertura e gestione delle connessioni
Tralasciando per un momento l’utilizzo dei vari strumenti visuali presenti in Visual Studio.NET, il nostro codice di accesso ai dati avrà solitamente un pattern di questo tipo:
// Instanzio un nuovo oggetto Connection – Xxx dipende dal provider XxxConnection cnn = new XxxConnection(“connection string”); try { // Apertura della connessione cnn.Open(); // Eseguo le operazioni sul database... } catch(Exception e) { // gestisco le eventuali eccezioni } finally { // Chiusura della connessione – opzionale se chiamo Dispose cnn.Close(); // Rilascio le risorse utilizzate dalla connessione cnn.Dispose(); }
Un’alternativa a questo blocco di codice potrebbe essere l’utilizzo, solo in C#, della parola chiave using. Quest’ultima garantisce che venga invocato il metodo Dispose() sull’oggetto indicato, e che quindi implicitamente venga anche chiusa la connessione, oltre che rilasciate le risorse da essa allocate. Il codice risultante quindi sarà:
// Instanzio un nuovo oggetto Connection – Xxx dipende dal provider using(XxxConnection cnn = new XxxConnection(“connection string”)) { try { // Apertura della connessione cnn.Open(); // Eseguo le operazioni sul database... } catch(Exception e) { // gestisco l’eccezione } }
Connection String e Pooling
La connessione verso la fonte dati in realtà non sara interrotta ma verrà resa disponibile in un pool, gestito dal Data Provider utilizzato. Nel caso del SQL Server .NET Data Provider, il connection pooling è basato sui servizi offerti da COM+ ed è completamente controllabile attraverso la stringa di connessione con i parametri:
|
Pooling (default true) |
Abilita o disabilita il pooling delle connessioni |
|
Min Pool Size (0) |
Numero minimo di connessioni nel pool |
|
Max Pool Size (100) |
Numero massimo di connessioni nel pool |
|
Connection Lifetime (0) |
Tempo di vita massimo di una connessione |
|
Connection Reset (true) |
Reset del contesto della connessione quando viene estratta dal pool (con SQL 7.0 ‘false’ evita round-trip con il db server) |
|
Enlist (true) |
La connessione viene automaticamente associata ad una transazione se il contesto del thread lo richiede |
Una connessione può essere riciclata in un pool se le sue catatteristiche (nome server, nome database, contesto di sicurezza, contesto di una transazione) è identico alle altre connessioni in quel pool, altrimenti viene creato un nuovo pool con quelle caratteristiche. Per quanto riguarda l’OleDb .NET Data Provider invece, vengono utilizzati i servizi nativi di OleDb che richiedono che il provider utilizzato supporti questa funzionalità. I provider evoluti come quello per SQL Server e Oracle lo supportano automaticamente, mentre per altri occorre verificarne le caratteristiche e può essere necessario gestirne da codice le funzionalità.
Un’altra caratteristica della stringa di connessione è il parametro ‘Persist Security Info’ che specifica se le informazioni riguardanti la sicurezza (tipicamente User Name e Password) devono essere visibili attraverso le proprietà dell’oggetto Connection oppure no.
Dove mettere la Connection String?
Una delle domande più frequenti quando si progetta la parte di accesso ai dati per una applicazione è proprio questa. Le alternative solitamente sono diverse, e con il .NET Framework aumentano:
- File XML di configurazione dell’applicazione
- File .UDL
- Registry
- ConstructorString in COM+
- Global.aspx in ASP.NET
Ogniuna di queste soluzioni ha vantaggi e svantaggi, e può essere la più indicata a seconda che si tratti di una applicazione client, di un componente server o un Web Service piuttosto che una applicazione ASP.NET anche se, il registry sembra ormai una soluzione obsoleta e poco portabile in altre piattaforme. Il file XML di configurazione dell’applicazione sembra la soluzione indicata da Microsoft dato che il .NET Framework mette a disposizione una serie di classi per leggere e utilizzare queste informazioni applicativamente, mentre dal punto di vista dell’amministratore del sistema è presente un wizard, il .NET Framework Configuration Tool (Mscorcfg.msc), per evitare l’editing diretto dei file.
Esecuzione di un comando
Dopo aver aperto la connessione, normalmente si definisce il comando da inviare alla fonte dati. I comandi possono variare per tipo e per valori e/o parametri restituiti dopo l’esecuzione. Il costruttore dell’oggetto Command accetta il testo del comando stesso e un riferimento alla connessione che si vuole utilizzare per l’esecuzione. La proprietà CommandType invece determina il tipo di comando che si vuole definire, e può assumere uno dei valori presenti nell’enum associato:
|
StoredProcedure |
Il testo del comando è il nome della SP. Viene utilizzata la sintassi “EXEC @ret=nome_sp @par1,@par2....” |
|
TableDirect |
Il testo del comando è il nome della tabella. La query diventa “SELECT * FROM table_name”. |
|
Text |
Il testo del comando è la query da inviare alla fonte dati. |
Oltre al tipo di specificato, è possibile differenziare i comandi a seconda delle informazioni che esso ritorna. Queste possono essere:
- uno o più resultset
- un unico valore scalare
- un valore di ritorno
- uno o più parametri di output
e in più il comando può eventualmente accettare uno o più parametri di ingresso.
Attraverso l’oggetto Command, nei vari .NET DataProvider, è possibile gestire tutte queste varianti e utilizzare alcuni metodi che sono ottimizzati per ciascuna situazione. Ecco un esempio di esecuzione di un comando di tipo testo:
// Instanzio un nuovo oggetto Connection using(OleDbConnection cnn = new OleDbConnection(“connection string”)) { try { // Creo l’oggetto Command OleDbCommand cmd = new OleDbCommand("Select CompanyName From Customers Where ID=?", cnn); // Apertura della connessione cnn.Open(); // Definisco il tipo di comando cmd.CommandType = CommandType.Text; // Creo il parametro di Input cmd.Parameters.Add(new OleDbParameter("@Id",OleDbType.VarWChar,255)); cmd.Parameters["@Id"].Direction = ParameterDirection.Input; cmd.Parameters["@Id"].Value = "ALFKI"; // Eseguo il comando string sName = cmd.ExecuteScalar(); } catch(Exception e) { // gestisco l’eccezione } }
La sequenza delle operazioni non si discosta da quello che veniva fatto in ADO 2.x:
- apertura della connessione
- creazione del comando di tipo indicato
- definizione dei parametri
- esecuzione del comando
- recupero dei risultati
- gestione di eventuali errori
ma quello che cambia decisamente è il modo con il quale si accede ai risultati.
A seconda del tipo di risultati ritornati dal comando occorre chiamare i corretti metodi e utilizzare strumenti adeguati. Il metodo ExecuteReader() consente l’esecuzione di un comando che ritorna uno o più resultset permettendo all’applicazione di accedere a quelle informazioni utilizzando un oggetto di tipo DataReader, in modalità read-only e forward-only scorrendo dal primo all’ultimo record. Se il comando invece dovesse ritornare un resultset composto da un unica riga e un’unica colonna (un valore scalare, appunto), in quel caso il metodo ExecuteScalar() è ottimizzato per recuperare quell’unica informazione nel modo più performante possibile, scartando tutti gli eventuali altri record o colonne presenti nel resultset. Nel caso la query non dovesse ritornare nessun resultset, allora il metodo ExecuteNonQuery() risulterebbe la scelta migliore, tenendo presente che questo metodo è ancora in grado di recuperare eventuali parametri di output di una S.P. nel caso questi esistano e siano l’unico valore recuperato. Solo per il SQL Server .NET Data Provider, e in congiunzione con la clausola FOR XML nella sintassi del comando, è possibile utilizzare anche il metodo ExecuteXmlReader() che ritorna un oggetto di tipo XmlReader, permettendo così una navigazione gerarchica delle informazioni e un utilizzo orientato allo scambio di informazioni in formato XML.
Ecco alcuni esempi di utilizzo nelle varie condizioni:
// Instanzio un nuovo oggetto Connection using(SqlConnection cnn = new SqlConnection(“connection string”)) { try { // Creo l’oggetto Command SqlCommand cmd = new SqlCommand("Select * From Customers", cnn); // Apertura della connessione cnn.Open(); // Definisco il tipo di comando cmd.CommandType = CommandType.Text; // Eseguo il comando e ottengo un DataReader SqlDataReader dr = cmd.ExecuteReader(); // mi posiziono sul primo record e inizio a scorrere While(dr.Read()) { // Accedo ai valori attraverso il nome colonna Console.WriteLine(dr[“CustomerId”]); // utilizzo il “typed accessor” ottimizzato Console.WriteLine(dr.GetString(1)); } } catch(Exception e) { // gestisco l’eccezione } }
In questo caso viene restituito un resultset attraverso l’oggetto DataReader che può essere analizzato scorrendolo dal primo all’ultimo in modo read-only. Se il testo del comando fosse stato composto da un batch di comandi nella forma “Select * From Customers;Select * From Orders” il DataReader avrebbe consentito l’accesso al secondo resultset attraverso il metodo NextResult().
// Instanzio un nuovo oggetto Connection using(SqlConnection cnn = new SqlConnection(“connection string”)) { try { // Creo l’oggetto Command SqlCommand cmd = new SqlCommand("spGetWithParams", cnn); // Apertura della connessione cnn.Open(); // Definisco il tipo di comando cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.Add(new SqlParameter("@Id",SqlDbType.NVarChar,255)); cmd.Parameters["@Id"].Direction = ParameterDirection.Input; cmd.Parameters["@Id"].Value = "ALFKI"; cmd.Parameters.Add(new SqlParameter("@Name",SqlDbType.NVarChar,255)); cmd.Parameters["@Name"].Direction = ParameterDirection.Output; // Eseguo il comando cmd.ExecuteNonQuery(); // Accedo ai valori attraverso il parametro di output Console.WriteLine(cmd.Parameters["@Name"].Value); } catch(Exception e) { // gestisco l’eccezione } }
È possibile ovviamente combinare, come in questo esempio, le varie situazioni ed avere un comando che ritorna sia un DataReader contenente il resultset che parametri di input, output e valore di ritorno della Stored Procedure eseguita. In questo caso occorre avere l’avvertenza di chiudere il DataReader per ottenere i valori dei parametri, perchè questi giungono al client solo alla fine dello stream di byte che il server invia al client.
Un’altro tip importante riguarda l’utilizzo dei parametri. Occorre ricordare che:
- OleDbCommand supporta l’utilizzo del ? come placeholder per i parametri presenti nel comando, e quindi occorre dichiare gli oggetti OleDbParam in modo strettamente posizionale.
- SqlCommand NON consente l’utilizzo del ? ma invece richiede una sintassi del tipo “...WHERE CustomerID=@NomeParam” e, durante la dichiarazione dei parametri, non importa la posizione ma il nome del parametro stesso, che deve essere uguale a quello usato nel comando T-SQL / Stored Procedure.
Il DataReader è un componente indicato per tutte quelle situazioni dove vengono richieste caratteristiche di prestazioni e scalabilità nell’accesso ai dati in modalità read-only, ma occorre tenere a mente alcune particolarità:
- Occorre ricordarsi di chiudere il DataReader una volta utilizzato
- un solo record alla volta è presente in un dato istante “in memoria”
- la connessione DEVE rimanere aperta fino a che il DataReader è utilizzato
- un solo DataReader alla volta può essere utilizzato per ogni Connection
- il DataReader consente l’accesso a campi di tipo BLOB
- esistono una serie di metodi chiamati “typed accessor” (GetString(), GetInt(), ecc.) che consentono un accesso più performante ai dati nelle colonne del DataReader nel caso si conosca in ancicipo la struttura dello stesso
- il parametro CommandBehavior.CloseConnection passato all’esecuzione di ExecuteReader() chiude automaticamente la connessione alla chiusura del DataReader
- chiamando il metodo Cancel() dell’oggetto Command è possibile interrompere lo stream di record dal server verso il client nel caso si siano già estratte le informazioni richieste dal DataReader. Utilizzando Close() del DataReader invece i dati verrebbero comunque tutti estratti dal server prima di chiuderlo.
// Instanzio un nuovo oggetto Connection using(SqlConnection cnn = new SqlConnection(“connection string”)) { try { // Creo l’oggetto Command SqlCommand cmd = new SqlCommand("Select * From Customers FOR XML AUTO", cnn); // Apertura della connessione cnn.Open(); // Definisco il tipo di comando cmd.CommandType = CommandType.Text; // Eseguo il comando e ottengo un XmlReader XmlReader xr = cmd.ExecuteXmlReader(); // mi posiziono sul primo nodo e inizio a scorrere While(dr.Read()) { // Accedo ai valori attraverso I vari attributi Console.WriteLine(MessageBox.Show(r.GetAttribute(2).ToString()); } } catch(Exception e) { // gestisco l’eccezione } }
L’oggetto XmlReader invece può essere un sostituto “leggero” del DataSet in quelle situazioni dove è sufficiente estrarre le informazioni da SQL Server e ottenerne la rappresentazione in forma gerarchica, elaborandole poi con gli altri oggetti presenti nella Base Class Library per manipolare informazioni in formato XML. In questo modo si possono evitare trasformazioni dalla forma relazionale (DataSet) a quella gerarchica (XML) estraendo così le informazioni direttamente dalla fonte dati.
Utilizzando l’XmlReader occorre ricordare che:
- è da utilizzare solo insieme alla clausola FOR XML
- ExecuteXmlReader() non supporta CommandBehavior.CloseConnection e la connessione va chiusa esplicitamente
