1. Pré-requis
Avant toute chose, et même si certaines notions techniques seront expliquées, la compréhension de cet
article nécessite quelque connaissances préalables.
C'est pourquoi avant de débuter, je vous conseille les lectures suivantes si vous n'êtes pas familier de ces technologies :
2. Notions techniques abordées
Pour commencer, passons en revue les principales notions techniques que nous aborderons dans ces pages.
2.1 Les classes abstraites
Une classe abstraite sert de base à une hiérarchie d'objets partageant des méthodes communes mais dont l'implémentation réelle peut varier.
Une classe est dite abstraite si elle ne fournit pas d'implémentation pour certaines de ses méthodes dites "méthodes abstraites".
Etant incomplète, une classe abstraite ne peut être instanciée; il appartient donc aux classes dérivées de définir le code de chacune des
méthodes abstraites. On parle alors de classes concrètes; ces dernières pouvant être instanciées.
Voici un exemple "shématique" d'une classe abstraite.
public abstract class myAbstractClass
{
protected myAbstractClass() {}
protected abstract void DoSomething();
public void DoCommunStuff()
{
}
}
Et une classe concrète héritant de cette dernière.
public class myConcretClass : myAbstractClass
{
public myConcretClass() : base() {}
protected override void DoSomething()
{
}
}
La classe myConcretClass doit absolument définir du code pour la méthode
abstraite de myAbstractClass. Si vous ne le faites pas, le compilateur refusera votre code.
2.2 Les interfaces
Une interface peut être vue comme une classe abstraite "pure", c'est à dire une classe dont tous les attributs sont abstraits.
Comme une classe abstraite, une interface n'est pas instanciable, mais doit être implémentée par une classe.
Une interface est en fait un contrat qu'une classe s'engage à respecter, car à la différence d'une classe abstraite, une interface ne peut
contenir aucun code, et l'ensemble de ses attributs doit être redéfinit dans la classe qui l'implémente.
A noter que même si la synthaxe de C# peut prêter à confusion, une classe n'hérite pas d'une interface, elle
l'implémente. La différence est importante, car comme nous ne somme pas dans un processus d'héritage, une classe peut
implémenter plusieurs interfaces.
Imaginons deux interfaces IMovable et IDeletable
public interface IMovable
{
public void Move();
}
public interface IDeletable
{
public void Delete();
}
Et maintenant prennons une classe qui implémente ces deux interfaces :
public class myClass: IMovable, IDeletable
{
public void Move()
{
}
public void Delete()
{
}
public void MyMethod()
{
}
}
Notre classe est désormais à la fois du type myClass, IMovable et IDeletable. Ainsi, les trois déclarations suivantes sont correctes :
myClass classe = new myClass()
IMovable classe = new myClass()
IDeletable classe = new myClass()
2.3 Les fabriques
Une fabrique est une classe dont l'utilité découle directement de l'utilisation des interfaces et des classes abstraites.
Pour résumer, une fabrique renvoit une interface ou une classe abstraite au code client tout en implémentant un objet concret de
manière totalement transparente.
Nous en verrons un exemple au chapitre 4.2
3. A quoi sert un composant d'accès aux données ?
Un composant d'accès aux données (DAC) à principalement pour but de répondre à deux problématiques :
- La dépendance à une base de données spécifique.
- Les répétitions de code au sein d'une solution.
3.1 La dépendance à une base de données spécifique
L'architecture actuelle d'ADO.NET (1.1) est ainsi faite que le développeur peut très rapidement se retrouver lié à une source de donnée
spécifique.
Par exemple, si vous travaillez avec MSSQL Server comme base de données, vous allez tout naturellement utiliser au sein de votre projet
les classes à disposition dans l'espace de nom System.Data.SqlClient :
- SqlConnection
- SqlCommand
- SqlDataReader
- etc...
Tous les forums et newsgroups auxquels vous vous êtes adressé vous ont conseillé d'utiliser ces classes car elles sont optimisées pour SQL Server, et c'est
également une recommandation officielle de Microsoft.
Seulement voilà, retournement marketing ou facétie d'un chef de projet sadique, on vous annonce que votre logiciel qui tourne comme une horloge
sur sa base de données SQL Server, va devoir désormais fonctionner avec Oracle. Combien de temps va-t-il falloir pour migrer ? Une semaine ? Un mois ?
Avec un DAC correctement conçu, cela prendrait entre quelques secondes et quelques minutes...
3.2 Les répétions de code
Dans une application de gestion, l'accès aux données occupe une place prépondérante. De ce fait, factoriser toute la logique d'accès
dans un composant unique représente un très gros gain.
Imaginons la méthode suivante dont le but est d'interroger la base de données sur le nombre de lignes
présentes dans une table et de renvoyer le résultat :
public int GetTotal()
{
string connectionString;
int total;
connectionString = System.Configuration.ConfigurationSettings.AppSettings(DSN);
using (SqlConnection Connection = new SqlConnection(connectionString))
{
SqlCommand Command = Connection.CreateCommand();
Command.CommandText = "SELECT COUNT(*) FROM MATABLE";
Connection.Open();
total = System.Convert.ToInt32(Command.ExecuteScalar());
Connection.Close();
}
return total;
}
Difficile de faire plus simple en terme d'accès aux données...Nous n'utilisons ni DataSet, ni DataReader.
La commande est simple, sans paramètres; nous n'utilisons pas de transaction et nous ne nous donnons même pas la peine
de gérer les éventuelles erreurs...Et pourtant, cette méthode atteint allègrement une dizaine de lignes.
Des méthodes telle que celle-ci (ou nettement plus longues et complexes), se retrouvent par dizaines, voir par centaines dans
une application de gestion.
Si l'on décompose les étapes de cette fonction, l'on peut rapidement se rendre compte que certaines opérations sont "variables", et d'autres
communes à toute méthode d'accès aux données que l'on écrira :
- Récupérer la chaîne de connexion.
- Déclarer et obtenir un objet SqlConnection.
- Déclarer et obtenir un objet SqlCommand.
- Fermer la connexion.
Ce qui va changer, c'est la commande SQL que vous allez vouloir exécuter, la manière de l'exécuter (ExecuteReader, ExecuteScalar, etc...), et
l'objet retourné par la méthode.
En factorisant ce qui peut l'être dans un DAC, il devient possible
d'obtenir exactement le même résultat en une ou deux lignes :
public int GetTotal()
{
string sql = "SELECT COUNT(*) FROM MATABLE";
return System.Convert.ToInt32(DataProviderFactory.GetInstance().ExecuteScalar(sql));
}
Mais comment en est-on arrivé là ? C'est ce que nous allons voir.
4. Rendre le code indépendant
4.1 Utilisation des interfaces
Ce problème de dépendance à une source de données provient du fait que le client (votre application) manipule des objets concrets et
spécifiques.
La solution consiste donc à élever le niveau d'abstraction et à manipuler des interfaces en lieu et place de ces objets.
Observons les déclarations respectives de OleDbConnection et de SqlConnection :
public sealed class OleDbConnection : Component, ICloneable, IDbConnection, IDisposable
{
}
public sealed class SqlConnection : Component, IDbConnection, IDisposable, ICloneable
{
}
Vues sous cet angles, les deux classes en question paraissent extrêment similaires. Elle héritent toutes deux de Component, et
implémentent les interfaces IDisposable, ICloneable et IDbConnection.
L'interface qui nous intéresse est bien entendu IDbConnection car c'est elle qui contient les déclarations des méthodes
dont nous avons besoin.
Comme nous l'avons vu plus haut, une classe implémentant une interface est manipulable par le type de cette dernière.
Ainsi, il est tout à fait possible de modifier notre code exemple de la manière suivante :
public int GetTotal()
{
...
IDbConnection Connection = new SqlConnection(connectionString);
IDbCommand Command = Connection.CreateCommand();
...
}
Seulement voilà, il faut bien instancier notre objet à un moment ou à un autre, et comme il n'est évidemment pas possible de faire
public int GetTotal()
{
...
IDbConnection Connection = new IDbConnection(connectionString);
...
}
la dépendance à SqlConnection existe toujours...et c'est là qu'entre en jeu la fabrique.
4.2 La Fabrique
Résumons-nous : nous avons besoin de manipuler une interface IDbConnection, mais nous avons bien entendu besoin
d'un objet implémentant cette interface. Or, nous ne pouvons pas nous permettre d'instancier un tel objet directement
si notre but est d'être indépendant de la source de données...
La solution à ce problème consiste à confier cette instantiation à une classe fabrique dont la mission sera de créer un objet
spécifique en fonction d'un paramètre et de retourner une interface au client.
Voici à quoi cette classe pourrait ressembler :
using System;
using System.Data;
namespace nx.DataAccess
{
public sealed class DBObjectFactoy
{
private DBObjectFactoy() {}
public static IDbConnection GetConnection(string name)
{
IDbConnection connection;
switch (name.ToUpper())
{
case "OLEDB":
connection = new OleDbConnection();
break;
case "SQLSERVER":
connection = new SqlConnection();
break;
default:
connection = new OleDbConnection();
break;
}
return connection;
}
}
}
Pour l'exemple, j'ai créé la méthode GetConnection() en allant au plus simple. Il est évident qu'il serait peut être plus sûr de lever
une exception plutôt que de renvoyer arbitrairement un objet OleDbConnection dans le cas où le nom passé n'est pas prévu dans la liste.
D'autre part, il serait bien entendu possible de s'assurer un choix correct en utilisant un enum.
Dès lors, en utilisant cet objet, nous pouvons transformer notre code exemple de la manière suivante :
public int GetTotal()
{
string connectionString;
string databaseType;
int total;
connectionString = System.Configuration.ConfigurationSettings.AppSettings(DSN);
databaseType = System.Configuration.ConfigurationSettings.AppSettings(DATABASE);
using (IDbConnection Connection = DBObjectFactoy.GetConnection(databaseType))
{
IDbCommand Command = Connection.CreateCommand();
Command.CommandText = "SELECT COUNT(*) FROM MATABLE";
Connection.Open();
total = System.Convert.ToInt32(Command.ExecuteScalar());
Connection.Close();
}
return total;
}
Et voilà notre premier but atteint. Il est désormais possible de changer de base de données cible en modifiant
simplement le fichier de configuration de votre application.
4.3 Seconde approche : un DataProvider
Maintenant que nous pouvons obtenir une IDbConnection grâce à notre classe DBObjectFactoy, il serait
facile de la complèter pour qu'elle puisse aussi fournir une IDbDataAdapter et une IDbDataParameter.
Pour ce faire, il suffirait d'ajouter les méthodes GetAdapter() et GetParameter() utilisant le même principe que
GetConnection(). Cette solution, bien que correcte d'un point de vu fonctionnel, n'est pas idéale en terme d'architecture.
Dans ce cas précis, il n'y a que 3 méthodes, mais que se passerait-il s'il y en avait 30 et que l'on veuille ajouter une base de donnée ?
Est-ce que vous allez parcourir les 30 méthodes pour ajouter une clause "mySQL" dans chaque switch () ?
Pour faciliter la maintenance de notre composant, autant essayer de limiter au strict minimum les switch () et autre if...
De plus, n'oublions pas que notre but n'est pas seulement d'être indépendant de la base de données, mais aussi de pouvoir écrire quelques méthodes
qui soulageront notre code client.
Nous allons donc modifier quelque peu notre composant et lui adjoindre un nouvel objet abstrait que nous appellerons un DataProvider.
L'idée est simple : construire un DataProvider concret par base de données que nous souhaitons supporter et faire en sorte que notre classe
fabrique renvoit un provider abstrait au client.
Voici les objets dont nous allons avoir besoin :
- Une interface IDataProvider.
- Une classe abstraite DataProvider qui implémente IDataProvider.
- Une classe DataProviderSqlClient pour SQL Server, héritant de DataProvider.
- Une classe DataProviderOleDb, héritant elle aussi de DataProvider.
Commençons par construire l'interface.
using System;
using System.Data;
namespace nx.DataAccess
{
public interface IDataProvider
{
public IDbConnection GetConnection(string connectionString);
public IDbDataAdapter GetAdapter(IDbCommand command);
}
}
Puis créons notre classe abstraite.
using System;
using System.Data;
namespace nx.DataAccess
{
public abstract class DataProvider : IDataProvider
{
protected DataProvider() {}
protected abstract IDbConnection CreateConnection(string connectionString);
protected abstract IDbDataAdapter CreateAdapter(IDbCommand command);
public IDbConnection GetConnection(string connectionString)
{
return CreateConnection(connectionString);
}
public IDbDataAdapter GetAdapter(IDbCommand command)
{
return CreateAdapter(command);
}
}
}
Maintenant, construisons le premier DataProvider spécifique; celui pour SQL Server.
using System;
using System.Data;
namespace nx.DataAccess.ConcreteProviders
{
public abstract class DataProviderSqlClient : DataProvider
{
internal DataProviderSqlClient() : base() {}
protected override IDbConnection CreateConnection(string connectionString)
{ return new SqlConnection(connectionString); }
protected override IDbDataAdapter CreateAdapter(IDbCommand command)
{ return new SqlDataAdapter((SqlCommand)Command); }
}
}
et celui pour OleDb, sur le même principe.
using System;
using System.Data;
namespace nx.DataAccess.ConcreteProviders
{
public abstract class DataProviderOleDb : DataProvider
{
internal DataProviderOleDb() : base() {}
protected override IDbConnection CreateConnection(string connectionString)
{ return new OleDbConnection(connectionString); }
protected override IDbDataAdapter CreateAdapter(IDbCommand command)
{ return new OleDbDataAdapter((OleDbCommand)Command); }
}
}
Il ne nous reste plus qu'à effacer sans pitié notre fabrique existante DBObjectFactoy et à
en créer une nouvelle que nous nommerons DataProviderFactory.
Cette nouvelle fabrique est basée sur le même principe que la précédente : la création d'un objet concret, dépendant
d'un paramètre qui lui est passé, est caché au client qui ne reçoit qu'un objet abstrait ou une interface.
using System;
using System.Data;
namespace nx.DataAccess
{
public sealed class DataProviderFactoy
{
private DataProviderFactory() {}
public static DataProvider GetInstance()
{
return this.GetInstance("default");
}
public static DataProvider GetInstance(string name)
{
DataProvider Instance;
switch (name.ToUpper())
{
case "OLEDB":
Instance = new ConcreteProviders.DataProviderOleDb();
break;
case "SQLSERVER":
Instance = new ConcreteProviders.DataProviderSqlClient();
break;
case "DEFAULT":
Instance = new ConcreteProviders.DataProviderSqlClient();
break;
default:
Instance = new ConcreteProviders.DataProviderOleDb();
break;
}
return Instance;
}
}
}
Ainsi, dans votre code client vous pouvez désormais obtenir une IDbConnection de la manière suivante :
IDbConnection Connection = DataProviderFactory.GetInstance().GetConnection(connectionString);
si vous utilisez SQL Server par défaut, et de cette manière :
IDbConnection Connection = DataProviderFactory.GetInstance("OLEDB").GetConnection(connectionString);
si au sein de votre projet se trouve une méthode qui accède à une autre base de données.
5. Factoriser les méthodes d'accès aux données
Comme nous l'avons vu, il est possible de factoriser la majeure partie de notre méthode d'exemple.
Voici donc trois fonctions qui couvrent une partie des besoins que l'on peut rencontrer.
5.1 ExecuteNonQuery()
Le code complet de la méthode :
public int ExecuteNonQuery(string query)
{
System.Data.IDbConnection Connection;
System.Data.IDbCommand Command;
int ExecuteResult;
using ( Connection = this.CreateConnection() )
{
using ( Command = Connection.CreateCommand() )
{
Command.CommandText = query;
Connection.Open();
ExecuteResult= Command.ExecuteNonQuery();
Connection.Close();
}
return ExecuteResult;
}
}
et la façon de l'appeller depuis le client :
public int SaveData(string query)
{
return DataProviderFactory.GetInstance().ExecuteNonQuery(query):
}
5.2 ExecuteScalar()
La méthode :
public object ExecuteScalar(string query)
{
System.Data.IDbConnection Connection;
System.Data.IDbCommand Command;
object ExecuteResult;
using( Connection = this.CreateConnection() )
{
using ( Command = Connection.CreateCommand )
{
Command.CommandText = query;
Connection.Open();
ExecuteResult= Command.ExecuteScalar();
Connection.Close();
}
return ExecuteResult;
}
}
L'appel depuis le client :
public object GetData(string query)
{
return DataProviderFactory.GetInstance().ExecuteScalar(query):
}
5.3 GetDataSet()
La méthode :
public DataSet ExecuteGetDataSet(string query)
{
System.Data.IDbConnection Connection;
System.Data.IDbCommand Command;
System.Data.IDataAdapter Adapter;
System.Data.DataSet Ds = new DataSet();
using ( Connection = this.CreateConnection() )
{
using ( Command = Connection.CreateCommand )
{
Command.CommandText = query;
// Connection.Open(); is omitted !!
// Adapter will handle Connection.Open and Close by itself.
Adapter = this.GetDataAdapter(Command);
Adapter.Fill(Ds);
}
}
return Ds;
}
L'appel depuis le client :
public DataSet GetData(string query)
{
return DataProviderFactory.GetInstance().ExecuteGetDataSet(query):
}
5.4 La cas du DataReader
Les trois méthodes que nous venons de voir possèdent l'avantage de pouvoir gérer le processus de bout en bout. Le code client n'a absolument
pas à se soucier d'ouvrir la connexion et de la refermer, le DAC prend tout en charge.
Si l'on désire écrire un méthode similaire capable de renvoyer un IDataReader, le problème devient légèrement différent à cause de la liaison forte
qui existe entre les objets IDataReader et IDbConnection. La responsabilité de fermer le "reader", et donc de libérer la connexion
ne peut pas être assumé (du moins pas de manière simple entrant dans le cadre de cet article) par le DAC, c'est au code client de s'en charger et la factorisation perd
une partie de son intérêt dans ce cas.
6. Notes complémentaire
Les utilisateurs ayant un minimum d'expérience se rendront rapidement compte que l'utilisation des interfaces ne
règle pas toujours la dépendance à une base de donnée.
Il subsiste effectivement un problème que nous n'avons pas abordé jusqu'à maintenant : le code SQL.
Il est complètement inutile d'essayer de rendre son code indépendant si celui-ci comporte des instructions SQL spécifiques
à une base de données en particulier. C'est pour cette raison que de nombreux architectes recommandent formellement de ne pas inclure
de telles instructions dans votre code, mais d'utiliser absolument les procédures stockées.
Nous verrons dans un prochain article comment modifier ce composant et nos fonctions d'aide afin de prendre en charge
les procédures stockées et les paramètres.