I. Introduction▲
C'est le dernier article d'une série qui décrit une conception que j'utilise pour un accès de haute performance aux données. Dans la partie 1, nous avons couvert l'architecture et la conception globale de PersonDb. Dans la partie 2, nous avons couvert la mise en œuvre de l'implémentation de DALBase.
II. Les classes de DTOParser▲
Nous mettons en œuvre l'architecture ci-dessous.
Donc, au début, nous avons décidé d'utiliser les Data Transfert Objects (DTO) pour déplacer des données entre notre BLL et notre DAL. Nous avons aussi défini que notre DAL n'aurait que deux types de retour de base ; un seul DTO, ou une liste générique de DTO. À ce stade, nous avons écrit notre classe PersonDb qui encapsule nos méthodes d'accès aux données et nous avons écrit notre classe DALBase qui encapsule nos méthodes GetSingleDTO() et GetDTOList() ainsi que des méthodes générales pour d'autres opérations comme obtenir une chaîne de connexion ou créer des paramètres de procédure stockée. Voici le code que nous avons écrit pour GetSingleDTO().
// GetSingleDTO
protected
static
T GetSingleDTO<
T>(
ref
SqlCommand command) where
T :
DTOBase
{
T dto =
null
;
try
{
command.
Connection.
Open
(
);
SqlDataReader reader =
command.
ExecuteReader
(
);
if
(
reader.
HasRows)
{
reader.
Read
(
);
DTOParser parser =
DTOParserFactory.
GetParser
(
typeof
(
T));
parser.
PopulateOrdinals
(
reader);
dto =
(
T)parser.
PopulateDTO
(
reader);
reader.
Close
(
);
}
else
{
// S'il n'y a pas de données, nous renvoyons null.
dto =
null
;
}
}
catch
(
Exception e)
{
// Lève une « friendly exception » qui enveloppe l'exception interne réelle.
throw
new
Exception
(
"Error populating data"
,
e);
}
finally
{
command.
Connection.
Close
(
);
command.
Connection.
Dispose
(
);
}
// Renvoie le DTO, rempli soit avec des données soit avec null.
return
dto;
}
Ainsi, cette méthode encapsule toute notre logique reproductible pour obtenir un seul DTO d'un reader (lecteur), et nous utilisons les génériques .NET pour créer une méthode générale qui peut renvoyer n'importe quel type qui hérite de DTOBase. Toutefois, notez que nous obtenons les détails pour chaque champ de données à partir du reader. Le remplissage des propriétés via les champs correspondants du reader sera confié à un autre objet, le DTOParser.
III. Que sont les ordinaux et pourquoi les utiliser ? ▲
Avant d'entrer dans le DTOParser, prenons une minute pour parler des ordinaux. Un ordinal est juste un indice qui vous indique où un champ de données est situé dans le flux auquel vous accédez via votre SqlDataReader. Disons que vous avez un champ de données nommé « date_created » et que vous ayez besoin d'obtenir la donnée de ce champ via un reader. La plupart des développeurs utiliseraient un code qui ressemble à ceci.
Object field =
reader[
"date_created"
];
DateTime dateCreated =
(
field ==
DBNull.
Value) ?
DateTime.
MinValue :
(
DateTime)field;
La donnée est obtenue à partir du reader par le nom du champ de données. Elle est stockée dans un objet. Nous effectuons une vérification sur DBNull et si la valeur de notre donnée est correcte, nous la castons en DateTime. Il s'agit d'un code assez solide et j'aime le fait que nous faisons toujours une vérification sur NULL mais il y a encore quelques problèmes dans une perspective de performance.
Tout d'abord, nous obtenons notre donnée à partir du reader par le nom « date_created ». Le reader ne sait pas quel est le type spécifique de « date_created ». Il doit aller trouver l'index associé à ce nom et il peut alors utiliser cet index pour accéder à la donnée. Cette valeur d'indice est l'ordinal. Le SqlDataReader peut travailler d'une manière beaucoup plus efficace si nous lui donnons cet ordinal à la place du nom du champ de données pour travailler.
Deuxièmement, nous obtenons une valeur DateTime, mais nous faisons un premier cast sur l'objet. Je préfère ne pas faire de cast puisque je sais que je suis à la recherche de données datetime mais la syntaxe du reader ["field_name"] retourne un objet. De plus j'ai besoin de faire un une vérification sur NULL. Quel autre choix ai-je ? Si j'utilise les ordinaux, la réponse est que le SqlDataReader a une méthode GetDateTime() fortement typée qui a été faite pour ce but précis. SqlDataReader a des méthodes GetXxx() fortement typées pour chaque type de données, ce qui nous permettra d'éviter ce casting pour l'objet. SqlDataReader a également une méthode IsDBNull() que nous pouvons utiliser pour faire notre vérification de DBNull. Le hic, c'est que ces méthodes n'accepteront pas les noms de champs de données, ils vous obligent à utiliser les ordinaux.
Donc, nous allons écrire le même code en supposant que nous savons que l'ordinal pour « date_created » est 4. Le résultat devrait ressembler à ceci.
DateTime dateCreated =
reader.
IsDBNull
(
4
) ?
DateTime.
MinValue :
reader.
GetDateTime
(
4
);
Ce code utilise la méthode la plus efficace possible pour obtenir des données à partir de notre SqlDataReader, et nous échappons à un cast vers l'objet et un cast depuis l'objet (boxing et unboxing). Si nous voulons vraiment optimiser le rendement, c'est le code que nous devons utiliser.
IV. Comment le DTOParser est utilisé ▲
Nous avons abordé ceci dans le précédent article, mais juste pour nous rafraîchir la mémoire, nous allons jeter un coup d'œil à la façon dont notre objet DTOParser est utilisé dans la classe DALBase. Le code ci-dessous est tiré de notre méthode générique DALBase.GetSingeDTO <T>(SqlCommand command). Nous créons un objet de retour de type T, et nous utilisons l'objet command qui a été passé pour obtenir un reader. Si le reader a des lignes, nous appelons Read(). Ensuite, nous utilisons DTOParserFactory pour obtenir un objet parser. La méthode DTOParserFactory.GetParser() prend le type de DTO souhaité comme paramètre et retourne une instance de la classe concrète DTOParser appropriée. À ce moment-là, ce que nous avons à faire est de passer notre reader à notre parser, caster le DTO retourné au type T, et faire un peu de nettoyage.
T dto =
null
;
command.
Connection.
Open
(
);
SqlDataReader reader =
command.
ExecuteReader
(
);
if
(
reader.
HasRows)
{
reader.
Read
(
);
DTOParser parser =
DTOParserFactory.
GetParser
(
typeof
(
T));
parser.
PopulateOrdinals
(
reader);
dto =
(
T)parser.
PopulateDTO
(
reader);
reader.
Close
(
);
}
else
{
// S'il n'y a pas de données, nous renvoyons null.
dto =
null
;
}
V. La classe de base DTOParser ▲
Maintenant, nous pouvons enfin revenir à l'écriture de nos classes DTOParser. Nous allons avoir une classe concrète de DTOParser distincte pour chaque type DTO dans notre application. Il y a deux choses que nous devons faire pour chaque DTOParser concret. Tout d'abord, notre parser a besoin d'une méthode qui prend un SqlDataReader et ensuite il obtient et sauvegarde les ordinaux pour tous nos champs de données. Deuxièmement, le parser a besoin d'une méthode qui prend un reader et renvoie un seul DTO alimenté avec les données du reader de l'enregistrement courant. Nous allons définir l'interface de ces deux méthodes en utilisant une classe de base abstraite de DTOParser. Toutes nos classes de DTOParser concrètes seront désormais héritées de DTOParser et mettront en œuvre ces deux méthodes. Notez que le type de retour pour PopulateDTO est DTOBase, qui est le type de base pour l'ensemble de nos DTO.
abstract
class
DTOParser
{
abstract
public
DTOBase PopulateDTO
(
SqlDataReader reader);
abstract
public
void
PopulateOrdinals
(
SqlDataReader reader);
}
VI. La classe concrète DTOParser_Person ▲
Maintenant, nous pouvons enfin entrer dans notre classe concrète parser pour PersonDTO. DTOParser_Person va encapsuler l'ensemble de notre logique pour obtenir les valeurs des données des champs/colonnes de nos données. La classe doit faire trois choses :
1. Fournir des propriétés pour stocker un ordinal pour chaque champ de données/colonne ;
2. Implémenter la méthode PopulateOrdinals() ;
3. Implémenter la méthode PopulateDTO.
Pour vous rafraîchir la mémoire, voici à quoi ressemble notre classe PersonDTO :
Nous allons donc commencer par créer les propriétés du Ord_DataMemberName pour contenir la valeur ordinale de chacun de nos membres de données PersonDTO. Les propriétés de Ord_DataMemberName sont de type entier. Vous pouvez vous demander pourquoi nous soucier de créer des propriétés pour chaque ordinal, pourquoi ne pas les obtenir à la volée dans notre méthode PopulateDTO() ? La réponse est que nous n'avons pas vraiment besoin de ces propriétés lorsque nous accédons à un seul DTO. Cependant, lorsque nous obtenons une liste de DTO nous voulons être en mesure d'obtenir une instance de notre parser, appeler PopulateOrdinals() une fois, puis appeler PopulateDTO() pour chaque élément de notre liste. Dans cette situation, nous ne remplissons les ordinaux qu'une fois et parce que nous les avons affectés sur des propriétés locales, nous pouvons les utiliser pour chaque appel suivant à PopulateDTO(). La classe DTOParser_Person résultante ressemblera à ceci :
Maintenant, nous devons mettre en œuvre la méthode PopulateOrdinals(). Cette logique est assez simple. Nous prenons une référence à SqlDataReader comme seul paramètre. Le reader dispose d'une méthode GetOrdinal que nous pouvons utiliser pour obtenir la valeur de chaque ordinal par nom de champ/colonne. Nous avons juste besoin de faire cette recherche pour chaque champ/colonne, puis de stocker le résultat dans la propriété Ord_XXX correspondante.
public
override
void
PopulateOrdinals
(
SqlDataReader reader)
{
Ord_PersonGuid =
reader.
GetOrdinal
(
"person_guid"
);
Ord_PersonId =
reader.
GetOrdinal
(
"person_id"
);
Ord_UtcCreated =
reader.
GetOrdinal
(
"utc_created"
);
Ord_UtcModified =
reader.
GetOrdinal
(
"utc_modified"
);
Ord_Password =
reader.
GetOrdinal
(
"password"
);
Ord_Name =
reader.
GetOrdinal
(
"name"
);
Ord_Nickname =
reader.
GetOrdinal
(
"nickname"
);
Ord_PhoneMobile =
reader.
GetOrdinal
(
"phone_mobile"
);
Ord_PhoneHome =
reader.
GetOrdinal
(
"phone_home"
);
Ord_Email =
reader.
GetOrdinal
(
"email"
);
Ord_ImAddress =
reader.
GetOrdinal
(
"im_address"
);
Ord_ImType =
reader.
GetOrdinal
(
"im_type"
);
Ord_TimeZoneId =
reader.
GetOrdinal
(
"time_zone_id"
);
Ord_LanguageId =
reader.
GetOrdinal
(
"language_id"
);
Ord_City =
reader.
GetOrdinal
(
"city"
);
Ord_State =
reader.
GetOrdinal
(
"state_code"
);
Ord_ZipCode =
reader.
GetOrdinal
(
"zip_code"
);
}
La seule autre chose que nous devons faire est de mettre en œuvre la méthode PopulateDTO(). La logique est aussi simple. La première chose que nous faisons est de créer un nouveau PersonDTO. Rappelez-vous que PersonDTO ainsi que tous les autres types de DTO héritent de DTOBase afin que nous puissions les utiliser comme valeur de retour. Aussi, n'oubliez pas que le constructeur de PersonDTO initialise tous les membres de données à leur valeur nulle de leur type (les valeurs nulles sont définies dans la classe CommonBase). Ainsi, chaque membre de données commence par une valeur nulle, ce qui à notre demande signifie « pas affecté ». Cela veut dire que si un champ ne passe pas le contrôle DBNull, nous n'avons rien à faire pour ce membre de données PersonDTO correspondant, car il est déjà initialisé à sa valeur null.
Ainsi, après avoir créé notre objet PersonDTO, nous avons juste besoin de faire une simple boucle pour chaque membre de données (For Each data member). Premièrement, nous allons utiliser l'ordinal correspondant pour nous assurer que la valeur retournée par le reader n'est pas null. Deuxièmement, si cette valeur n'est pas null, nous allons utiliser la méthode getxxx typée du reader pour obtenir la valeur. Une fois que tous les membres de données du PersonDTO ont été renseignés, nous le renverrons. Le code résultant ressemble à ceci :
public
override
DTOBase PopulateDTO
(
SqlDataReader reader)
{
// On suppose que le reader possède des données et est déjà sur la ligne qui contient les données dont nous avons besoin.
// Nous n'avons pas besoin d'appeler read.
// En règle générale, on suppose que chaque champ doit être vérifié null.
// Si un champ est null alors la valeur Nullvalue pour ce champ a déjà été fixée par le constructeur DTO, nous n'avons pas besoin de la changer.
PersonDTO person =
new
PersonDTO
(
);
// PersonGuid
if
(!
reader.
IsDBNull
(
Ord_PersonGuid)) {
person.
PersonGuid =
reader.
GetGuid
(
Ord_PersonGuid);
}
// PersonId
if
(!
reader.
IsDBNull
(
Ord_PersonId)) {
person.
PersonId =
reader.
GetInt32
(
Ord_PersonId);
}
// UtcCreated
if
(!
reader.
IsDBNull
(
Ord_UtcCreated)) {
person.
UtcCreated =
reader.
GetDateTime
(
Ord_UtcCreated);
}
// UtcModified
if
(!
reader.
IsDBNull
(
Ord_UtcModified)) {
person.
UtcModified =
reader.
GetDateTime
(
Ord_UtcModified);
}
// Password
if
(!
reader.
IsDBNull
(
Ord_Password)) {
person.
Password =
reader.
GetString
(
Ord_Password);
}
// Name
if
(!
reader.
IsDBNull
(
Ord_Name)) {
person.
Name =
reader.
GetString
(
Ord_Name);
}
// Nickname
if
(!
reader.
IsDBNull
(
Ord_Nickname)) {
person.
Nickname =
reader.
GetString
(
Ord_Nickname);
}
// PhoneMobile
if
(!
reader.
IsDBNull
(
Ord_PhoneMobile)) {
person.
PhoneMobile =
reader.
GetString
(
Ord_PhoneMobile);
}
// PhoneHome
if
(!
reader.
IsDBNull
(
Ord_PhoneHome)) {
person.
PhoneHome =
reader.
GetString
(
Ord_PhoneHome);
}
// Email
if
(!
reader.
IsDBNull
(
Ord_Email)) {
person.
Email =
reader.
GetString
(
Ord_Email);
}
// ImAddress
if
(!
reader.
IsDBNull
(
Ord_ImAddress)) {
person.
ImAddress =
reader.
GetString
(
Ord_ImAddress);
}
// ImType
if
(!
reader.
IsDBNull
(
Ord_ImType)) {
person.
ImType =
reader.
GetInt32
(
Ord_ImType);
}
// TimeZoneId
if
(!
reader.
IsDBNull
(
Ord_TimeZoneId)) {
person.
TimeZoneId =
reader.
GetInt32
(
Ord_TimeZoneId);
}
// LanguageId
if
(!
reader.
IsDBNull
(
Ord_LanguageId)) {
person.
LanguageId =
reader.
GetInt32
(
Ord_LanguageId);
}
// City
if
(!
reader.
IsDBNull
(
Ord_City)) {
person.
City =
reader.
GetString
(
Ord_City);
}
// State
if
(!
reader.
IsDBNull
(
Ord_State)) {
person.
State =
reader.
GetString
(
Ord_State);
}
// ZipCode
if
(!
reader.
IsDBNull
(
Ord_ZipCode)) {
person.
ZipCode =
reader.
GetInt32
(
Ord_ZipCode);
}
// IsNew
person.
IsNew =
false
;
return
person;
}
VII. Résumé ▲
C'est tout ! Nous avons un DTO ! Nous avons maintenant un framework pour créer, parser et retourner un DTO fortement typé. En raison des optimisations que nous avons prises comme choix de conteneur de données léger, en utilisant SqlDataReader avec les ordinaux et en minimisant les casts, notre DAL sera performante.
En regardant en arrière sur ce code, je me rends compte qu'il y a vraiment pas mal de morceaux. Toutefois, j'ai aussi remarqué que la plupart des morceaux sont très petits et faciles à comprendre. J'essaye d'employer des principes solides, en particulier le principe de la responsabilité unique. En observant une conception DAL comme cela, je pense qu'elle est vraiment rentable en termes de maintenabilité et de lisibilité du code. Quand vous regardez des méthodes comme PopulateOrdinals() ou PopulateDTO(), elles ne font qu'une seule chose. Les noms des méthodes et le code lui-même rendent très évident ce pour quoi ces méthodes sont conçues. Il est facile de voir quel code mettre en œuvre pour différents types de DTO. Je pense que la clarté et l'intelligibilité créées en concevant le code de cette manière vaut bien l'effort supplémentaire qu'il exige.
Voilà pour l'ensemble. La seule chose que je n'ai pas expliquée c'est la classe DTOParserFactory. C'est juste une simple classe de fabrique que j'ai incluse dans le code correspondant ci-dessous ainsi que le code complet de la classe DTOParser_Person.
internal
static
class
DTOParserFactory
{
// GetParser
internal
static
DTOParser GetParser
(
System.
Type DTOType)
{
switch
(
DTOType.
Name)
{
case
"PersonDTO"
:
return
new
DTOParser_Person
(
);
break
;
case
"PostDTO"
:
return
new
DTOParser_Post
(
);
break
;
case
"SiteProfileDTO"
:
return
new
DTOParser_SiteProfile
(
);
break
;
}
// Si nous arrivons ici alors c'est que nous n'avons pas réussi à trouver le type correspondant. Nous levons donc une exception.
throw
new
Exception
(
"Unknown Type"
);
}
}
class
DTOParser_Person :
DTOParser
{
private
int
Ord_PersonGuid;
private
int
Ord_PersonId;
private
int
Ord_UtcCreated;
private
int
Ord_UtcModified;
private
int
Ord_Password;
private
int
Ord_Name;
private
int
Ord_Nickname;
private
int
Ord_PhoneMobile;
private
int
Ord_PhoneHome;
private
int
Ord_Email;
private
int
Ord_ImAddress;
private
int
Ord_ImType;
private
int
Ord_TimeZoneId;
private
int
Ord_LanguageId;
private
int
Ord_City;
private
int
Ord_State;
private
int
Ord_ZipCode;
public
override
void
PopulateOrdinals
(
SqlDataReader reader)
{
Ord_PersonGuid =
reader.
GetOrdinal
(
"person_guid"
);
Ord_PersonId =
reader.
GetOrdinal
(
"person_id"
);
Ord_UtcCreated =
reader.
GetOrdinal
(
"utc_created"
);
Ord_UtcModified =
reader.
GetOrdinal
(
"utc_modified"
);
Ord_Password =
reader.
GetOrdinal
(
"password"
);
Ord_Name =
reader.
GetOrdinal
(
"name"
);
Ord_Nickname =
reader.
GetOrdinal
(
"nickname"
);
Ord_PhoneMobile =
reader.
GetOrdinal
(
"phone_mobile"
);
Ord_PhoneHome =
reader.
GetOrdinal
(
"phone_home"
);
Ord_Email =
reader.
GetOrdinal
(
"email"
);
Ord_ImAddress =
reader.
GetOrdinal
(
"im_address"
);
Ord_ImType =
reader.
GetOrdinal
(
"im_type"
);
Ord_TimeZoneId =
reader.
GetOrdinal
(
"time_zone_id"
);
Ord_LanguageId =
reader.
GetOrdinal
(
"language_id"
);
Ord_City =
reader.
GetOrdinal
(
"city"
);
Ord_State =
reader.
GetOrdinal
(
"state_code"
);
Ord_ZipCode =
reader.
GetOrdinal
(
"zip_code"
);
}
public
override
DTOBase PopulateDTO
(
SqlDataReader reader)
{
// On suppose que le reader possède des données et est déjà sur la ligne qui contient les données dont nous avons besoin.
// Nous n'avons pas besoin d'appeler read.
// En règle générale, on suppose que chaque champ doit être vérifié null.
// Si un champ est null alors la valeur Nullvalue pour ce champ a déjà été fixée par le constructeur DTO, nous n'avons pas besoin de la changer.
PersonDTO person =
new
PersonDTO
(
);
// PersonGuid
if
(!
reader.
IsDBNull
(
Ord_PersonGuid)) {
person.
PersonGuid =
reader.
GetGuid
(
Ord_PersonGuid);
}
// PersonId
if
(!
reader.
IsDBNull
(
Ord_PersonId)) {
person.
PersonId =
reader.
GetInt32
(
Ord_PersonId);
}
// UtcCreated
if
(!
reader.
IsDBNull
(
Ord_UtcCreated)) {
person.
UtcCreated =
reader.
GetDateTime
(
Ord_UtcCreated);
}
// UtcModified
if
(!
reader.
IsDBNull
(
Ord_UtcModified)) {
person.
UtcModified =
reader.
GetDateTime
(
Ord_UtcModified);
}
// Password
if
(!
reader.
IsDBNull
(
Ord_Password)) {
person.
Password =
reader.
GetString
(
Ord_Password);
}
// Name
if
(!
reader.
IsDBNull
(
Ord_Name)) {
person.
Name =
reader.
GetString
(
Ord_Name);
}
// Nickname
if
(!
reader.
IsDBNull
(
Ord_Nickname)) {
person.
Nickname =
reader.
GetString
(
Ord_Nickname);
}
// PhoneMobile
if
(!
reader.
IsDBNull
(
Ord_PhoneMobile)) {
person.
PhoneMobile =
reader.
GetString
(
Ord_PhoneMobile);
}
// PhoneHome
if
(!
reader.
IsDBNull
(
Ord_PhoneHome)) {
person.
PhoneHome =
reader.
GetString
(
Ord_PhoneHome);
}
// Email
if
(!
reader.
IsDBNull
(
Ord_Email)) {
person.
Email =
reader.
GetString
(
Ord_Email);
}
// ImAddress
if
(!
reader.
IsDBNull
(
Ord_ImAddress)) {
person.
ImAddress =
reader.
GetString
(
Ord_ImAddress);
}
// ImType
if
(!
reader.
IsDBNull
(
Ord_ImType)) {
person.
ImType =
reader.
GetInt32
(
Ord_ImType);
}
// TimeZoneId
if
(!
reader.
IsDBNull
(
Ord_TimeZoneId)) {
person.
TimeZoneId =
reader.
GetInt32
(
Ord_TimeZoneId);
}
// LanguageId
if
(!
reader.
IsDBNull
(
Ord_LanguageId)) {
person.
LanguageId =
reader.
GetInt32
(
Ord_LanguageId);
}
// City
if
(!
reader.
IsDBNull
(
Ord_City)) {
person.
City =
reader.
GetString
(
Ord_City);
}
// State
if
(!
reader.
IsDBNull
(
Ord_State)) {
person.
State =
reader.
GetString
(
Ord_State);
}
// ZipCode
if
(!
reader.
IsDBNull
(
Ord_ZipCode)) {
person.
ZipCode =
reader.
GetInt32
(
Ord_ZipCode);
}
// IsNew
person.
IsNew =
false
;
return
person;
}
}
VIII. 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.
IX. Source▲
Traduction de l'article de M. Rudy Lacovara — High Performance Data Access Layer Architecture Part 3