I. Pourquoi écrire un code d'accès aux données quand nous avons des ORM ?▲
Vous rappelez-vous des modèles (pattern) d'accès aux données ? Il semble qu'il y ait une énorme attention ces jours-ci autour de LINQ, ADO.Net Entity Framework et d'autres outils ORM-like qui visent à rendre les programmeurs plus efficaces en leur libérant tout le temps qu'ils utilisaient pour programmer l'accès aux données. Il y a certainement des améliorations de la productivité lors de l'utilisation de ces outils, mais l'application résultante est-elle suffisamment performante ? Dans la plupart des cas où nous avons une ligne de demande standard de l'entreprise dont les principales préoccupations sont la fonctionnalité et le workflow, la réponse est « assez bonne » (on parle de la performance). Les outils de ce genre s'adaptent parfaitement pour ces applications. Toutefois, si vous disposez d'une application à volume élevé où la performance est la préoccupation principale, ces outils peuvent ne pas être le bon choix. Le code LINQ et ADO.Net généré est nettement plus lent que du code ADO.Net bien écrit. Selon un billet sur le blog de l'équipe ADO.net intitulé ADO.NET Entity Framework Performance Comparison, ADO.Net Entity Framework peut être de 50 % à 300 % plus lent que ADO.Net utilisant les ordinaux et SqlDataReaders.
Donc, mon avis est que les ORM sont parfaits pour la plupart des applications, mais lorsque la performance est un facteur important il est préférable de réaliser votre propre DAL. Ce document fera la démonstration de quelques-uns des modèles que j'utilise pour la couche d'accès aux données qui permettent un développement rapide et des performances « rapides comme l'éclair ».
II. Utilisez les DTO, pas un ensemble de données ou un DataTable▲
Tout d'abord, quel conteneur allons-nous utiliser pour transmettre des données à partir de notre DAL aux autres couches de notre application ? Les réponses habituelles que je reçois sont, soit des DataTables/des DataSets, soit des objets métier complets. Je n'aime pas non plus ces derniers. Les DataSets et DataTables viennent avec des zones mémoire importantes et ils ne contiennent pas de données fortement typées. Les objets métier contiennent des données fortement typées, mais ils contiennent généralement beaucoup de logique métier supplémentaire dont je n'ai pas besoin, et ils peuvent même contenir une logique de persistance. Je ne veux vraiment pas de tout cela. Je veux le conteneur le plus léger et le plus simple possible qui me donnera des données fortement typées, et ce conteneur est un objet de transfert de données (DTO). Les DTO sont des classes simples qui ne contiennent que des propriétés. Ils n'ont pas de méthode réelle, seulement des mutateurs et des accesseurs pour leurs données. Voici un diagramme de classe d'un PersonDTO ainsi que les classes de DTOBase et CommonBase qui sont dans sa chaîne d'héritage.
PersonDTO contient toutes les données nécessaires à une entité de personnes dans mon application.
Voici comment je construis généralement un DTO. Tout d'abord, les DTO sont conçus pour se déplacer entre des couches de l'application. Donc, ils n'ont pas leur place dans la DAL. Je les mets dans un projet/Assembly distinct nommé Common. Puis je crée une référence à Common dans ma DAL, BLL, interface utilisateur Web, et dans tout autre projet dans mon application.
Maintenant, nous pouvons commencer à créer des classes dans Common. La première classe que nous devons créer est CommonBase. Le seul but de CommonBase est de contenir les propriétés statiques qui définissent les valeurs null. Nos DTO vont contenir à la fois des données de type valeur et de type référence. Et surtout les types valeur ont toujours une valeur et ne sont jamais null. Dans le cas contraire, la vérification d'une valeur null peut représenter un défi dans les couches supérieures de l'application. Pour compliquer encore les choses, certains développeurs vont utiliser String.Empty ou "" pour représenter une valeur null pour une chaîne. D'autres vont utiliser null (string est de type référence après tout). Pour éviter toute cette confusion, je tiens à définir les valeurs null réelles pour chaque type dans mon Assembly Common. De cette façon, nous avons une valeur prédéfinie que nous pouvons utiliser pour le contrôle de null et le réglage de null pour toute l'application. Voici le code pour la classe CommonBase.
public
class
CommonBase
{
// Les valeurs nulles standards de configuration
public
static
DateTime DateTime_NullValue =
DateTime.
MinValue;
public
static
Guid Guid_NullValue =
Guid.
Empty;
public
static
int
Int_NullValue =
int
.
MinValue;
public
static
float
Float_NullValue =
float
.
MinValue;
public
static
decimal
Decimal_NullValue =
decimal
.
MinValue;
public
static
string
String_NullValue =
null
;
}
La classe suivante est DTOBase. Cette classe de base encapsule une fonctionnalité commune pour mes DTO. À l'heure actuelle, la seule chose que j'ai placée dans DTOBase est un indicateur IsNew qui peut être utilisé pour indiquer si un DTO contient des données nouvellement créées (par opposition à des données qui ont été tirées de la base de données).
public
abstract
class
DTOBase:
CommonBase
{
public
bool
IsNew {
get
;
set
;
}
}
Maintenant, je peux créer ma classe PersonDTO. PersonDTO contient juste un ensemble de propriétés qui représentent les données d'un dossier d'une personne, et un constructeur qui initialise chaque propriété à la valeur null de son type.
public
class
PersonDTO :
DTOBase
{
public
Guid PersonGuid {
get
;
set
;
}
public
int
PersonId {
get
;
set
;
}
public
DateTime UtcCreated {
get
;
set
;
}
public
DateTime UtcModified {
get
;
set
;
}
public
string
Password {
get
;
set
;
}
public
string
Name {
get
;
set
;
}
public
string
Nickname {
get
;
set
;
}
public
string
PhoneMobile {
get
;
set
;
}
public
string
PhoneHome {
get
;
set
;
}
public
string
Email {
get
;
set
;
}
public
string
ImAddress {
get
;
set
;
}
public
int
ImType {
get
;
set
;
}
public
int
TimeZoneId {
get
;
set
;
}
public
int
LanguageId {
get
;
set
;
}
public
string
City {
get
;
set
;
}
public
string
State {
get
;
set
;
}
public
int
ZipCode {
get
;
set
;
}
// Constructeur
// Pas de paramètre, et toutes les propriétés sont initialisées à leur valeur nulle tel que défini dans CommonBase.
public
PersonDTO
(
)
{
PersonGuid =
Guid_NullValue;
PersonId =
Int_NullValue;
UtcCreated =
DateTime_NullValue;
UtcModified =
DateTime_NullValue;
Name =
String_NullValue;
Nickname =
String_NullValue;
PhoneMobile =
String_NullValue;
PhoneHome =
String_NullValue;
Email =
String_NullValue;
ImAddress =
String_NullValue;
ImType =
Int_NullValue;
TimeZoneId =
Int_NullValue;
LanguageId =
Int_NullValue;
City =
String_NullValue;
State =
String_NullValue;
ZipCode =
Int_NullValue;
IsNew =
true
;
}
}
III. Comment la DAL devrait envoyer des données à d'autres couches ? ▲
Lorsque vous construisez le code de votre Framework, c'est une bonne idée d'arrêter périodiquement et de bien réfléchir à la façon dont le code que vous écrivez va être utilisé. La technique la plus utile que j'utilise est d'arrêter, et de visualiser ce à quoi le code doit ressembler. Nous avons déjà décidé que nous utilisons les DTO pour contenir des données. Prenons un moment pour réfléchir à ce que nous voulons que notre code BLL ressemble et quelles fonctionnalités il faudra pour notre DAL. Dans ma BLL, je vais probablement avoir un PersonRepository. Dans ce référentiel, je vais avoir des méthodes qui voudront obtenir un objet PersonDTO individuel et des listes génériques de PersonDTO provenant de la DAL. J'aurai probablement un seul objet DAL qui fournira des méthodes pour obtenir ces DTO. Je tiens donc à créer une classe PersonDb dans ma DAL qui va me permettre d'écrire du code BLL qui ressemble à ceci :
PersonDb db =
new
DAL.
PersonDb;
PersonDTO dto =
db.
GetPersonByPersonGuid
(
personGuid);
PersonDTO dto =
db.
GetPersonByEmail
(
email);
List<
PersonDTO>
people =
db.
GetPersonList
(
);
Avec cet objectif à l'esprit, je vais créer une classe PersonDb dans ma DAL. PersonDb va fournir des méthodes qui vont, soit renvoyer un seul PersonDTO, soit une liste de PersonDTO (List<PersonDTO>).
IV. L'architecture DAL▲
Nous allons avoir une classe DALBase qui encapsule toute notre logique fonctionnelle comme la création de connexions, des commandes TSQL, des procédures stockées, et des paramètres. La classe DALBase contiendra également des méthodes pour obtenir nos deux principaux types de retour, DTO et la liste de DTO, à partir d'un SqlDataReader. Pour agir comme un guichet unique pour l'ensemble de nos méthodes d'accès aux données, pour obtenir et définir les données de personnes, nous allons créer une classe PersonDB. PersonDB héritera de DALBase et contiendra l'ensemble de nos méthodes qui retournent ou enregistrent des données de personnes comme GetPersonByEmail(), GetPersonById() et SavePerson().
Nous aurons également besoin de trouver un endroit où mettre la logique pour la lecture de nos données de personnes avec un SqlDataReader ouvert et les mettre dans un PersonDTO. Il s'agit de trouver le nombre ordinal d'un champ de données, de vérifier pour voir s'il est null, et si ce n'est pas le cas, de stocker la valeur de la donnée dans le DTO. C'est vraiment une logique de parsing et nous allons donc la mettre dans une classe DTOParser_Person séparée. Actuellement, nous regardons seulement les classes pour PersonDTO, mais nous aurons besoin d'avoir un parser (analyseur/répartiteur) différent pour chaque type DTO que la DAL pourra retourner (PersonDTO, CompanyDTO, UserDTO, etc.). Nous allons utiliser une classe DTOParser abstraite pour définir l'interface pour tous les DTOParsers et encapsuler les fonctionnalités répétées. Enfin, nous allons créer une classe DTOParserFactory statique qui retourne une instance de DTOParser appropriée pour tout type de DTO que nous lui passerons. Donc, si nous avons besoin de parser un PersonDTO sur un reader, nous l'appelons tout simplement
DTOParser parser =
DTOParserFactory.
GetParser
(
typeof
(
PersonDTO))
et nous aurons une instance de la classe DTOParser_Person. Voici à quoi nos classes de la DAL vont ressembler.
V. PersonDb▲
Employant encore une fois le principe de penser d'abord à propos de la façon dont nous voulons utiliser notre code, puis d'écrire le code pour atteindre cet objectif, nous allons d'abord écrire notre classe PersonDb, puis nous écrirons notre classe DALBase. La classe PersonDb devra utiliser des méthodes de DALBase pour des opérations comme la création d'objets SqlCommand ou l'obtention d'une liste de PersonDTOs. Une fois que nous voyons comment nous voulons utiliser ces fonctionnalités dans PersonDb, nous aurons une meilleure idée de la façon dont nous voulons travailler avec DALBase.
D'abord, nous allons écrire la méthode GetPersonByPersonGuid(). Extraire des données à partir d'une base de données, puis remplir un DTO avec celles-ci et enfin renvoyer ce DTO ne prend que peu de code. Mais si on y réfléchit bien, la plus grande partie de ce code est dupliquée pour chaque méthode d'accès aux données que nous écrivons. Si nous extrayons les seules choses qui changent pour chaque méthode, nous obtenons la liste suivante :
-nous allons utiliser des procédures stockées du côté de SQL Server, la première chose que nous devons faire est d'obtenir un objet SqlCommand pour la procédure stockée nommée ;
-ensuite, nous aurons besoin d'ajouter des paramètres et de définir leur valeur ;
-la dernière chose que nous devons faire est de lancer la commande et récupérer le type d'ensemble de données souhaité (soit un DTO ou une Liste <DTO>) rempli avec les données.
Ce sont les seuls éléments qui changent vraiment. Quelle procédure stockée sproc nous appelons, quels sont les paramètres que nous devons ajouter, et quel est le type de retour. Nous allons donc écrire des méthodes prédéfinies dans la classe DALBase qui nous permettront d'effectuer chacune de ces tâches avec une seule ligne de code. Le code de GetPersonByPersonGuid()résultant ressemblera à ceci :
SqlCommand command =
GetDbSprocCommand
(
"Person_GetByPersonGuid"
);
command.
Parameters.
Add
(
CreateParameter
(
"@PersonGuid"
,
PersonGuid));
return
GetSingleDTO<
PersonDTO>(
command);
Si nous avons besoin d'une méthode GetPersonByEmail(), nous pouvons utiliser le code ci-dessus avec des modifications mineures. Les éléments qui changent sont juste le nom de la procédure stockée sproc et le paramètre. Le code modifié est :
SqlCommand command =
GetDbSprocCommand
(
"Person_GetByEmail"
);
command.
Parameters.
Add
(
CreateParameter
(
"@Email"
,
Email));
return
GetSingleDTO<
PersonDTO>(
command);
Ensuite, si nous avons besoin d'une méthode qui retourne tous les enregistrements de personnes GetAll(), nous pouvons aussi le faire facilement. Cette fois, le nom de la procédure stockée sproc, les paramètres (cette fois, il n'y en a pas besoin), et le type de retour changent tous.
SqlCommand command =
GetDbSprocCommand
(
"Person_GetAll"
);
return
GetDTOList<
PersonDTO>(
command);
Donc, avec quelques méthodes prédéfinies, nous pouvons mettre en place une classe PersonDb simple et facile à maintenir.
Si vous regardiez attentivement, vous avez remarqué quelles sont les exigences pour la classe DALBase qui ont émergé lors de l'écriture du code de la classe PersonDb. Tout d'abord, nous voulons utiliser les méthodes GetSingleDTO() et GetDTOList(), mais nous devons être en mesure de leur dire de retourner des types spécifiques de DTO, comme PersonDTO. C'est pourquoi celles-ci doivent être des méthodes génériques qui prennent le DTO comme type de paramètre, comme GetSingleDTO<PersonDTO>().
Deuxièmement, nous avons utilisé la même méthode CreateParameter() pour créer un paramètre de chaîne et un paramètre Guid. Nous aurons donc à faire un peu de polymorphisme et écrire des méthodes surchargées pour CreateParameter() pour chaque type de paramètre que nous voulons créer.
Nous allons entrer dans les détails la prochaine fois lorsque nous finirons notre DAL en codant la classe DALBase, les classes de DTOParser, et la classe DTOParserFactory. C'est là que nous verrons le vrai gain de performance. Pour l'accès aux données, nous allons utiliser les ordinaux (données atomiques) pour extraire des données à partir du reader de la manière la plus efficace possible et ensuite utiliser le SqlDataReader et des méthodes (Get) fortement typées pour faire les vérifications null et écrire les valeurs de données dans notre DTO, le tout sans caster la valeur dans l'objet. Pour l'instant, voici la classe PersonDb complète avec une méthode SavePerson() qui prend comme seul paramètre un PersonDTO.
public
class
PersonDb:
DALBase
{
// GetPersonByPersonGuid
public
static
PersonDTO GetPersonByPersonGuid
(
Guid PersonGuid)
{
SqlCommand command =
GetDbSprocCommand
(
"Person_GetByPersonGuid"
);
command.
Parameters.
Add
(
CreateParameter
(
"@PersonGuid"
,
PersonGuid));
return
GetSingleDTO<
PersonDTO>(
ref
command);
}
// GetPersonByEmail
public
static
PersonDTO GetPersonByEmail
(
string
email)
{
SqlCommand command =
GetDbSprocCommand
(
"Person_GetByEmail"
);
command.
Parameters.
Add
(
CreateParameter
(
"@Email"
,
email,
100
));
return
GetSingleDTO<
PersonDTO>(
ref
command);
}
// GetAll
public
static
List<
PersonDTO>
GetAll
(
)
{
SqlCommand command =
GetDbSprocCommand
(
"Person_GetAll"
);
return
GetDTOList<
PersonDTO>(
ref
command);
}
// SavePerson
public
static
void
SavePerson
(
ref
PersonDTO person)
{
// La sproc gérera l'insertion et la mise à jour.
// Nous avons juste besoin de retourner le GUID approprié de la personne.
// S'il s'agit d'une nouvelle personne, alors nous retournons le NewPersonGuid.
// S'il s'agit d'une mise à jour, nous renvoyons juste le PersonGuid.
bool
isNewRecord =
false
;
if
(
person.
PersonGuid.
Equals
(
Common.
DTOBase.
Guid_NullValue)) {
isNewRecord=
true
;}
// Crée la commande et les paramètres.
// Lors de la création des paramètres nous n'avons pas besoin de vérifier les valeurs null.
// La méthode CreateParameter va gérer cela pour nous et créer les paramètres null pour tous les membres DTO qui correspondent à DTOBase.NullValue dans le type de données du membre.
SqlCommand command =
GetDbSprocCommand
(
"Person_Save"
);
command.
Parameters.
Add
(
CreateParameter
(
"@PersonGuid"
,
person.
PersonGuid));
command.
Parameters.
Add
(
CreateParameter
(
"@Password"
,
person.
Password,
20
));
command.
Parameters.
Add
(
CreateParameter
(
"@Name"
,
person.
Name,
100
));
command.
Parameters.
Add
(
CreateParameter
(
"@Nickname"
,
person.
Nickname,
50
));
command.
Parameters.
Add
(
CreateParameter
(
"@PhoneMobile"
,
person.
PhoneMobile,
25
));
command.
Parameters.
Add
(
CreateParameter
(
"@PhoneHome"
,
person.
PhoneHome,
25
));
command.
Parameters.
Add
(
CreateParameter
(
"@Email"
,
person.
Email,
100
));
command.
Parameters.
Add
(
CreateParameter
(
"@ImAddress"
,
person.
ImAddress,
50
));
command.
Parameters.
Add
(
CreateParameter
(
"@ImType"
,
person.
ImType));
command.
Parameters.
Add
(
CreateParameter
(
"@TimeZoneId"
,
person.
TimeZoneId));
command.
Parameters.
Add
(
CreateParameter
(
"@LanguageId"
,
person.
LanguageId));
SqlParameter paramIsDuplicateEmail =
CreateOutputParameter
(
"@IsDuplicateEmail"
,
SqlDbType.
Bit);
command.
Parameters.
Add
(
paramIsDuplicateEmail);
SqlParameter paramNewPersonGuid =
CreateOutputParameter
(
"@NewPersonGuid"
,
SqlDbType.
UniqueIdentifier);
command.
Parameters.
Add
(
paramNewPersonGuid);
// Exécute la commande.
command.
Connection.
Open
(
);
command.
ExecuteNonQuery
(
);
command.
Connection.
Close
(
);
// Vérifie si c'est un email dupliqué.
if
((
bool
)paramIsDuplicateEmail.
Value) {
throw
new
Common.
Exceptions.
DuplicateEmailException
(
);}
// S'il s'agit d'un nouvel enregistrement, attribut un nouveau Guid à l'objet.
if
(
isNewRecord) {
person.
PersonGuid =
(
Guid)paramNewPersonGuid.
Value;}
}
}
VI. Remerciements▲
Je remercie M. Lacovara de m'avoir permis de traduire sa série d'articles « High Performance Data Access Layer Architecture ».
Gaëtan Wauthy et Kropernic pour la relecture et la validation technique, ainsi qu'une première relecture orthographique.
Claude Leloup pour la relecture orthographique.
VII. Source▲
Traduction de l'article de M. Rudy Lacovara — High Performance Data Access Layer Architecture Part 1.