Architecture de couche d'accès aux données (DAL) de hautes performances — Partie 3

DTO : Data Transfert Object (Objet de transfert de données)

Rudy Lacovara

Traducteur

Hervé Taraveau

Cette série de trois articles décrit comment écrire une couche d'accès aux données de hautes performances (DAL).

(Exemple de code en VB.NET)

Partie 3

Étude du traitement du reader, lequel sera passé à un parser qui utilisera les données ordinales ainsi que les méthodes (get) fortement typées pour extraire les données du reader de la manière la plus efficace possible sans caster l'objet et ainsi obtenir le DTO.

Commentez cet article : Commentez

- Titre : Architecture de couche d'accès aux données (DAL) de hautes performances — Partie 3

- Auteur : Rudy Lacovara

- Traducteur : Hervé Taraveau

- Parution : 11 décembre 2013

- Public visé :

- Licence : Copyright ® 2013 Hervé Taraveau. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Les classes de DTOParser

- III. Que sont les ordinaux et pourquoi les utiliser ?

- IV. Comment le DTOParser est utilisé

- V. La classe de base DTOParser

- VI. La classe concrète DTOParser_Person

- VII. Résumé

- VIII. Remerciements

- IX. Source

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

' GetSingleDTO
Protected Shared Function GetSingleDTO(Of T As DTOBase)(ByRef command As SqlCommand) As T
Dim dto As T = Nothing
Try
command.Connection.Open()
Dim reader As SqlDataReader = command.ExecuteReader()
If reader.HasRows Then
reader.Read()
Dim parser As DTOParser = DTOParserFactory.GetParser(GetType(T))
parser.PopulateOrdinals(reader)
dto = DirectCast(parser.PopulateDTO(reader), T)
reader.Close()
Else
' S'il n'y a pas de données, nous renvoyons null.
dto = Nothing
End If
Catch e As Exception
Throw New Exception("Error populating data", e)
Finally
command.Connection.Close()
command.Connection.Dispose()
End Try
' Renvoie le DTO, rempli soit avec des données soit avec null.
Return dto
End Function

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.

Dim field As Object = reader("date_created")
Dim dateCreated As DateTime = If((DBNull.Value.Equals(field)), DateTime.MinValue, DirectCast(field, DateTime))

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.

Dim dateCreated As DateTime = If(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.

Dim dto As T = Nothing
command.Connection.Open()
Dim reader As SqlDataReader = command.ExecuteReader()
If reader.HasRows Then
reader.Read()
Dim parser As DTOParser = DTOParserFactory.GetParser(GetType(T))
parser.PopulateOrdinals(reader)
dto = DirectCast(parser.PopulateDTO(reader), T)
reader.Close()
Else
' S'il n'y a pas de données, nous renvoyons null.
dto = Nothing
End If

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.

La classe abstraite DTOParser

MustInherit Class DTOParser
Public MustOverride Sub PopulateOrdinals(ByVal reader As SqlDataReader)
Public MustOverride Function PopulateDTO(ByVal reader As SqlDataReader) As DTOBase
End Class

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 :

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 :

Classe DTOParser_Person

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.

PopulateOrdinals

Public Overrides Sub PopulateOrdinals(ByVal reader As SqlDataReader)
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")
End Sub

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 :

PopulateDTO

Public Overrides Function PopulateDTO(ByVal reader As SqlDataReader) As DTOBase
' 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.
Dim person As New PersonDTO()

' PersonGuid
If Not reader.IsDBNull(Ord_PersonGuid) Then person.PersonGuid = reader.GetGuid(Ord_PersonGuid)
' PersonId
If Not reader.IsDBNull(Ord_PersonId) Then person.PersonId = reader.GetInt32(Ord_PersonId)
' UtcCreated
If Not reader.IsDBNull(Ord_UtcCreated) Then person.UtcCreated = reader.GetDateTime(Ord_UtcCreated)
' UtcModified
If Not reader.IsDBNull(Ord_UtcModified) Then person.UtcModified = reader.GetDateTime(Ord_UtcModified)
' Password
If Not reader.IsDBNull(Ord_Password) Then person.Password = reader.GetString(Ord_Password)
' Name
If Not reader.IsDBNull(Ord_Name) Then person.Name = reader.GetString(Ord_Name)
' Nickname
If Not reader.IsDBNull(Ord_Nickname) Then person.Nickname = reader.GetString(Ord_Nickname)
' PhoneMobile
If Not reader.IsDBNull(Ord_PhoneMobile) Then person.PhoneMobile = reader.GetString(Ord_PhoneMobile)
' PhoneHome
If Not reader.IsDBNull(Ord_PhoneHome) Then person.PhoneHome = reader.GetString(Ord_PhoneHome)
' Email
If Not reader.IsDBNull(Ord_Email) Then person.Email = reader.GetString(Ord_Email)
' ImAddress
If Not reader.IsDBNull(Ord_ImAddress) Then person.ImAddress = reader.GetString(Ord_ImAddress)
' ImType
If Not reader.IsDBNull(Ord_ImType) Then person.ImType = reader.GetInt32(Ord_ImType)
' TimeZoneId
If Not reader.IsDBNull(Ord_TimeZoneId) Then person.TimeZoneId = reader.GetInt32(Ord_TimeZoneId)
' LanguageId
If Not reader.IsDBNull(Ord_LanguageId) Then person.LanguageId = reader.GetInt32(Ord_LanguageId)
' City
If Not reader.IsDBNull(Ord_City) Then person.City = reader.GetString(Ord_City)
' State
If Not reader.IsDBNull(Ord_State) Then person.State = reader.GetString(Ord_State)
' ZipCode
If Not reader.IsDBNull(Ord_ZipCode) Then person.ZipCode = reader.GetInt32(Ord_ZipCode)
' IsNew
person.IsNew = False

Return person
End Function

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.

Friend NotInheritable Class DTOParserFactory

Private Sub New()
End Sub

' GetParser
Friend Shared Function GetParser(ByVal DTOType As System.Type) As DTOParser
Select Case DTOType.Name
Case "PersonDTO"
Return New DTOParser_Person()
Case "PostDTO"
'Return New DTOParser_Post()
Case "SiteProfileDTO"
'Return New DTOParser_SiteProfile()
End Select
' 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")
End Function

End Class

Class DTOParser_Person
Inherits DTOParser

Private Ord_PersonGuid As Integer
Private Ord_PersonId As Integer
Private Ord_UtcCreated As Integer
Private Ord_UtcModified As Integer
Private Ord_Password As Integer
Private Ord_Name As Integer
Private Ord_Nickname As Integer
Private Ord_PhoneMobile As Integer
Private Ord_PhoneHome As Integer
Private Ord_Email As Integer
Private Ord_ImAddress As Integer
Private Ord_ImType As Integer
Private Ord_TimeZoneId As Integer
Private Ord_LanguageId As Integer
Private Ord_City As Integer
Private Ord_State As Integer
Private Ord_ZipCode As Integer

Public Overrides Sub PopulateOrdinals(ByVal reader As SqlDataReader)
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")
End Sub

Public Overrides Function PopulateDTO(ByVal reader As SqlDataReader) As DTOBase
' 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.
Dim person As New PersonDTO()

' PersonGuid
If Not reader.IsDBNull(Ord_PersonGuid) Then person.PersonGuid = reader.GetGuid(Ord_PersonGuid)
' PersonId
If Not reader.IsDBNull(Ord_PersonId) Then person.PersonId = reader.GetInt32(Ord_PersonId)
' UtcCreated
If Not reader.IsDBNull(Ord_UtcCreated) Then person.UtcCreated = reader.GetDateTime(Ord_UtcCreated)
' UtcModified
If Not reader.IsDBNull(Ord_UtcModified) Then person.UtcModified = reader.GetDateTime(Ord_UtcModified)
' Password
If Not reader.IsDBNull(Ord_Password) Then person.Password = reader.GetString(Ord_Password)
' Name
If Not reader.IsDBNull(Ord_Name) Then person.Name = reader.GetString(Ord_Name)
' Nickname
If Not reader.IsDBNull(Ord_Nickname) Then person.Nickname = reader.GetString(Ord_Nickname)
' PhoneMobile
If Not reader.IsDBNull(Ord_PhoneMobile) Then person.PhoneMobile = reader.GetString(Ord_PhoneMobile)
' PhoneHome
If Not reader.IsDBNull(Ord_PhoneHome) Then person.PhoneHome = reader.GetString(Ord_PhoneHome)
' Email
If Not reader.IsDBNull(Ord_Email) Then person.Email = reader.GetString(Ord_Email)
' ImAddress
If Not reader.IsDBNull(Ord_ImAddress) Then person.ImAddress = reader.GetString(Ord_ImAddress)
' ImType
If Not reader.IsDBNull(Ord_ImType) Then person.ImType = reader.GetInt32(Ord_ImType)
' TimeZoneId
If Not reader.IsDBNull(Ord_TimeZoneId) Then person.TimeZoneId = reader.GetInt32(Ord_TimeZoneId)
' LanguageId
If Not reader.IsDBNull(Ord_LanguageId) Then person.LanguageId = reader.GetInt32(Ord_LanguageId)
' City
If Not reader.IsDBNull(Ord_City) Then person.City = reader.GetString(Ord_City)
' State
If Not reader.IsDBNull(Ord_State) Then person.State = reader.GetString(Ord_State)
' ZipCode
If Not reader.IsDBNull(Ord_ZipCode) Then person.ZipCode = reader.GetInt32(Ord_ZipCode)
' IsNew
person.IsNew = False

Return person
End Function

End Class

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

Partie 1

Partie 2

Partie 3 — [haut]