Accès Générique aux Données avec ADO.NET et le Data Access Application Block
                         par Fabrice Marguerie

Introduction

Pour accéder aux données depuis vos programmes .NET, le framework fournit une API objet efficace : ADO.NET. Nous ne reviendrons pas sur le support de pilotes natifs ou des pilotes ODBC, qui offrent de nombreux avantages tels que les  performances ou la compatibilité. Nous ne reviendrons pas non plus sur les gains apportés par les DataSets, sujet plus ouvert à discussion en raisons des limitations qu'ils imposent dans une conception orientée objet.

Non non, rien de tout cela ici. Nous reviendrons plutôt sur une des limites d'ADO.NET : le manque de généricité. Comme tout logiciel, ADO.NET a quelques défauts de conception. Je vous laisse vous référer à l'article de Sami Jaber pour une bonne introduction au sujet du manque de généricité au sein d'ADO.NET. 

Vous avez lu l'article de Sami ? Bien, je vais donc pouvoir vous présenter le contenu du présent article. Il se découpe en deux parties :

Connexions génériques

Le problème

Je ne vais pas reprendre l'explication faite par Sami, mais pour faire simple : tout code d'accès aux données en ADO.NET est lié à un moteur de base de données spécifique (et ce aussi simple soit le code écrit). Cela est dû à l'utilisation de classes spécifiques à chaque moteur de base de données.  

La solution : le design pattern Factory

ADO.NET contient partiellement la solution grâce à l'ensemble d'interfaces suivant : IDbConnection, IDbTransaction, IDbCommand, IDataReader, IDbDataAdapter, IDataParameter.
Il suffit d'aller un peu plus loin en mettant en oeuvre le design pattern Factory (fabriques de classes) pour obtenir une solution entièrement générique.

Ce que je vous propose est de voir ensemble la mise en oeuvre d'une bibliothèque de classe nommée Masterline.Data mettant en oeuvre ce principe. Cette bibliothèque couvre tous les aspects de la création de connexions et l'écriture de code d'accès aux données générique. Nous verrons quelques exemples de mise en oeuvre que vous pourrez vous-même reproduire à l'aide des sources livrées à la fin de cette article.

Monsieur Provider et madame DataSource

Je vais maintenant introduire deux notions : les Providers, et les DataSources.

Un provider ("fournisseur", notion plus ou moins équivalente aux drivers (pilotes)) permet à ADO.NET de dialoguer avec un moteur de base de données.

Il existe trois types de providers en ADO.NET :

Architecture d'ADO.NET

Architecture d'ADO.NET

 

Parmi les providers natifs existants, on peut citer le support de :

Une DataSource représente une source de données.

En ADO.NET, on indique à un provider à quelle base de données on souhaite s'adresser à l'aide de chaînes de connexion (connection strings, dans la langue de Bill Gates).

Exemple de chaîne de connection :  "Provider=sqloledb;Data Source=MonServer;Initial Catalog=MaBase;User Id=MonUtilisateur;Password=MonMDP;"

A noter que malheureusement, chaque provider a un format de chaîne de connexion différent ! Pour vous y retrouver vous pouvez consulter http://www.connectionstrings.com/.

Nous appellerons DataSource, la combinaison d'un Provider et d'une chaîne de connection. Ainsi, tout ce dont nous avons besoin pour accéder à une base de données c'est d'une DataSource.

Mise en oeuvre : création de connexions

On peut définir des Providers ou des DataSources par deux moyens : par code ou en utilisant un fichier de configuration. Nous allons voir une série de façons de créer des connexions à l'aide de ces objets Provider et DataSource.

Note : Certains providers sont prédéfinis par la bibliothèque Masterline.Data : ODBC, OLE DB, SQL Server et ODP.NET.

Création d'un Provider par code :

const string ConnectionString = "Server=(local);User ID=sa;Password=;"+
    database=Northwind;Persist Security Info=true";

Provider  provider;

provider = new Provider("System.Data, "+
    "Version=1.0.3300.0, Culture=neutral, PublicKeyToken=b77a5c561934e089",
  "System.Data.SqlClient.SqlConnection",
  "System.Data.SqlClient.SqlDataAdapter",
  "System.Data.SqlClient.SqlCommandBuilder");
using (IDbConnection connection = ConnectionFactory.CreateConnection(
    provider, ConnectionString))
{
  connection.Open();
  MessageBox.Show("Connection state: "+connection.State.ToString());
}

Création d'une DataSource par code :

const string ConnectionString = "Server=(local);User ID=sa;Password=;"+
    database=Northwind;Persist Security Info=true";

DataSource  dataSource;
Provider    provider;

provider = new Provider("System.Data, "+
    "Version=1.0.3300.0, Culture=neutral, PublicKeyToken=b77a5c561934e089",
  "System.Data.SqlClient.SqlConnection",
  "System.Data.SqlClient.SqlDataAdapter",
  "System.Data.SqlClient.SqlCommandBuilder");
dataSource = new DataSource(provider, ConnectionString);
using (IDbConnection connection =
    ConnectionFactory.CreateConnectionToDataSource(dataSource))
{
  connection.Open();
  MessageBox.Show("Connection state: "+connection.State.ToString());
}

Utilisation d'un Provider prédéfini :

const string ConnectionString = "Server=(local);User ID=sa;Password=;"+
    "database=Northwind;Persist Security Info=true";

using (IDbConnection connection = ConnectionFactory.CreateConnection(
          DataAccessConfig.Providers[DataAccessConfig.DEFAULTPROVIDER_SqlServer],
          ConnectionString))
{
  connection.Open();
  MessageBox.Show("Connection state: "+connection.State.ToString());
}

L'utilisation d'un fichier de configuration .NET permet de définir des Providers et des DataSources à l'extérieur du code.

Utilisation d'une DataSource définie dans le fichier de configuration :

using (IDbConnection connection =
    ConnectionFactory.CreateConnectionToDataSource("DataSource2"))
{
  connection.Open();
  MessageBox.Show("Connection state: "+connection.State.ToString());
}

Si vous n'utilisez qu'un moteur de base de données ou qu'une source de données, vous pouvez définir un Provider par défaut ou une DataSource par défaut. Cela simplifie le code.

Utilisation d'une DataSource par défaut :

using (IDbConnection connection = ConnectionFactory.CreateConnection())
{
  connection.Open();
  MessageBox.Show("Connection state: "+connection.State.ToString());
}

Voici le fichier de configuration utilisé pour ces exemples :

Fichier app.config

<?xml version="1.0" encoding="utf-8" ?>
<
configuration>

<configSections>

<section name="Masterline.DataAccess.Providers" type="Masterline.Data.DataProvidersSectionHandler, Masterline.Data" />
<
section name="Masterline.DataAccess.DataSources" type="Masterline.Data.DataSourcesSectionHandler, Masterline.Data" />

</configSections>

<
Masterline.DataAccess.Providers 

     defaultProvider="Masterline.Default.SqlServer">

<Provider

name="Provider1"
assemblyName
="Oracle.DataAccess"
connectionClassName="Oracle.DataAccess.Client.OracleConnection"
adapterClassName="Oracle.DataAccess.Client.OracleDataAdapter" />

<Masterline.DataAccess.Providers>
<
Masterline.DataAccess.DataSources 

     defaultDataSource="DataSource2">

<DataSource

name="DataSource1"
providerName="Provider1"
connectionString="User Id=tiger;Password=scott;Data Source=MaBase;" />

<DataSource

name="DataSource2"
providerName="Masterline.Default.SqlServer"
connectionString="Server=(local);User ID=sa;Password=;database=Northwind;Persist Security Info=true">

</DataSource>

</Masterline.DataAccess.DataSources>

</configuration>

Après avoir ouvert une connexion, ont peut travailler avec des requêtes et des DataAdapters :

Utilisation d'un DataAdapter

using (IDbConnection connection = ConnectionFactory.CreateConnection())
{
  IDbDataAdapter adapter;
  DataSet        dataSet;

  connection.Open();
  
  adapter = DataAdapterFactory.CreateAdapter(DataAccessConfig.DefaultProvider,
    "SELECT * FROM Customers",
    connection);

  dataSet = new DataSet();
  adapter.Fill(dataSet);
  dataGrid1.SetDataBinding(dataSet, "Table");
}

Exécution d'une requête

using (IDbConnection connection = ConnectionFactory.CreateConnection())
{
  IDbCommand  command;

  connection.Open();
  
  command = connection.CreateCommand();
  command.CommandText = "SELECT COUNT(*) FROM Customers";
  MessageBox.Show("SELECT COUNT(*) FROM Customers => "+
     command.ExecuteScalar().ToString());
}

Note : Les providers prédéfinis et ceux du fichier de configuration sont initialisés par l'appel Masterline.Data.DataAccessConfig.Initialize();

Note : Il existe une solution similaire : Abstract ADO.NET

Un Data Access Application Block générique

A ce stade, nous avons nos objets permettant de créer des connexions génériques. C'est très bien, mais l'étape suivante (et somme toute la plus importante) est tout simplement la manipulation des données.

Le problème

Microsoft a publié il y a quelques temps de cela deux Microsoft Application Blocks for .NET (petit aparté : d'autres sont à venir prochainement, parmi lesquels UIP; cf. niouzes DNG ici et ) :

Celui qui nous concerne aujourd'hui, c'est bien évidemment le Data Access Application Block (DAAB). Vous trouverez de l'information sur cette brique logicielle dans le nouveau site MSDN patterns & practices.

La classe constituant le DAAB (SqlHelper) permet d'exécuter en une seule ligne de code des requêtes SQL ou des procédures stockées. Je ne vous exposerai pas ici les avantages de cette classe qui peut rendre de grands services, en particulier si vous utilisez des procédures stockées.
Le hic, c'est que le code de cette classe a été spécialement écrit pour SQL Server. Il était par conséquent impossible d'utiliser la même approche pour accéder à un autre moteur de base de donnée.
Microsoft a fourni, plusieurs mois après (et non officiellement !) des implémentations du DAAB pour Oracle (classe OracleHelper) et OLE DB (classe OleDbHelper) dans le cadre de l'application exemple Nile 3.0.
La nécessité de créer des versions spécifiques pour chaque pilote montre bien la principale limitation de l'implémentation actuelle de ADO.NET.

Comme on l'a vu, il y a trois types de providers ADO.NET, plus un nombre grandissant de providers natifs. Cela signifie qu'il faudrait avoir quantité de versions : une pour OLE DB, une pour ODBC, et une par provider natif.

La solution : une classe DBHelper générique

La solution que je vous propose est une classe indépendante de tout pilote spécifique : la classe DBHelper.

Nous verrons comment nous pouvons étendre le concept présenté dans la première partie de l'article, en employant nos fabriques de classes pour adapter le DAAB.

Le code de la classe DBHelper provient de la classe SqlHelper. Ont simplement été supprimées les références à des classes liées au provider SQL Server (telles que SqlConnection, SqlCommand, ...).

Voici un exemple de méthode adaptée :

Code extrait de la classe SqlHelper du DAAB

public static DataSet ExecuteDataset(SqlConnection connection,
    CommandType commandType, string commandText,
    params SqlParameter[] commandParameters)
{
    //create a command and prepare it for execution
    SqlCommand cmd = new SqlCommand();
    PrepareCommand(cmd, connection, (SqlTransaction)null,
        commandType, commandText, commandParameters);
    
    //create the DataAdapter & DataSet
    SqlDataAdapter da = new SqlDataAdapter(cmd);
    DataSet ds = new DataSet();

    //fill the DataSet using default values for DataTable names, etc.
    da.Fill(ds);
    
    // detach the SqlParameters from the command object, so they can be used again.            
    cmd.Parameters.Clear();
    
    //return the dataset
    return ds;                        
}

Code de la classe DBHelper équivalent, fichier DBHelper.cs

public DataSet ExecuteDataset(IDbConnection connection,
    CommandType commandType, string commandText,
    params IDataParameter[] commandParameters)
{
    //create a command and prepare it for execution
    IDbCommand cmd = connection.CreateCommand();
    PrepareCommand(cmd, connection, (IDbTransaction)null,
        commandType, commandText, commandParameters);
    
    //create the DataAdapter & DataSet
    IDbDataAdapter da = CreateAdapter(Provider, cmd);
    DataSet ds = new DataSet();

    //fill the DataSet using default values for DataTable names, etc.
    da.Fill(ds);
    
    // detach the IDataParameters from the command object, so they can be used again.    
    cmd.Parameters.Clear();
    
    //return the dataset
    return ds;
}

Les différences ont été marquées en gras.
Hormis l'emploi des interfaces (IDbConnection, IDataParameter, IDbCommand, IDbTransaction, IDbDataAdapter), on peut noter deux choses :

Code de la méthode DBHelper.CreateAdapter(), fichier DBHelper.cs

private static IDbDataAdapter CreateAdapter(Provider provider, IDbCommand command)
{
    return DataAdapterFactory.CreateAdapter(provider, command);
}

Mise en oeuvre

Ce qu'on peut observer parmi les différences entre les deux versions de cette méthode, c'est que la signature de la méthode ne change pratiquement pas.
La mise en oeuvre de cette classe DBHelper n'est par conséquent pas très différente de la classe SqlHelper initiale.

Voici deux exemples :

Utilisation de la méthode DBHelper.ExecuteDataSet()

DataSet   dataSet;
DBHelper  helper;

helper = new DBHelper(DataAccessConfig.DefaultProvider);
using (IDbConnection connection = ConnectionFactory.CreateConnection())
{
  dataSet = helper.ExecuteDataset(connection, CommandType.Text,
      "SELECT * FROM Customers");
}
dataGrid1.SetDataBinding(dataSet, "Table");

Utilisation de la méthode DBHelper.ExecuteScalar()

int result;

using (IDbConnection connection = ConnectionFactory.CreateConnection())
{
  result = (int) DBHelper.ExecuteScalar(connection, CommandType.Text,
      "SELECT COUNT(*) FROM Customers");
}

MessageBox.Show("SELECT COUNT(*) FROM Customers => "+result.ToString());

Nota Bene

La généricité n'est pas un frein

Bien que nous ayons rendu notre code générique, Il est toujours possible d’utiliser les spécificités des moteurs de base de données et des providers dédiés.

Ainsi, on peut par exemple tirer parti de l'événement InfoMessage proposé par SQL Server et la classe SqlConnection en transtypant un objet de type IDbConnection en SqlConnection :

On peut toujours mieux faire

On peut par exemple imaginer une version statique des méthodes de la classe DBHelper qui permet de ne pas avoir à créer d'instance. Libre à vous d'adapter le code et de partager vos suggestions.

Les sources mis à votre disposition contiennent la bibliothèque de classes ainsi que les exemples présentés dans cet article.

Conclusion

ADO.NET souffre d'erreurs de jeunesse. Nous avons vu qu'il est possible de palier à ces problèmes de conception. Microsoft se rattrapera t'il avec .NET 2 (nom de code Whidbey) avec une solution intégrée ?
On est également en droit de se demander pourquoi Microsoft n'a pas fournit une version du Data Access Application Block générique plutôt qu'une version différente par provider !

Note : Les classes présentées ici font partie du framework .NET de la société Masterline.

Auteur : Fabrice Marguerie

Copyright © Mai 2003


  Qui est Fabrice Marguerie ?

     Fabrice Marguerie est architecte .NET chez Masterline. Fabrice intervient sur des projets au forfait ou en régie aussi bien que sur des missions de conseil. Il a conçu et réalisé le framework .NET de Masterline. Fabrice rédige également un weblog en anglais : http://weblogs.asp.net/fmarguerie/
 
  Sa société : Masterline

      Créée en 1989, Masterline est une SSII qui développe son expertise autour de trois offres : business intelligence, e-business, SAP. Masterline développe ses expertises .NET, Java, objet et UML au sein de centres de compétences dédiés. Masterline est partenaire Microsoft depuis 1994.

 

Ressources

Téléchargez les sources

Bibliothèque de classe + exemples de mise en oeuvre (Sources) : Masterline.Data.zip (27 Ko, Fichier zip contenant la solution VS.NET)