Développer un DataSet en mémoire
Optimisation des accès Base de données, IIère Partie
Par
Franck SORIANO (Pages perso)
Cet article présente en détail le fonctionnement de la classe TDataSet. Il explique notamment
comment dériver la classe TDataSet pour réaliser un dataset en mémoire : la classe TMemoryDataSet.
Les articles suivants utiliseront ce dataset pour s'interfacer avec OLEDB et OCI.
I. Introduction
Télécharger les sources de l'article
II. Organisation générale du TDataSet
II-A. Gestion des buffers
II-B. Ouverture/Fermeture du curseur
II-C. Navigation
II-D. Modification des données
III. La classe TMemoryDataSet
III-A. Organisation des données en mémoire
III-B. Gestion des buffers
III-B-1. Définition des Champs du TMemoryDataSet
III-B-2. Gestion des champs LOB
III-B-3. Organisation d'un buffer
III-B-4. Implémentation des buffers
III-B-4-a. GetRecordSize
III-B-4-b. AllocRecordBuffer
III-B-4-c. FreeRecordBuffer
III-B-4-d. InternalInitRecord
III-B-4-e. Accès direct aux champs
III-C. Ouverture/Fermeture
III-C-1. InternalOpen
III-C-2. InternalClose
III-C-3. IsCursorOpen
III-C-4. InternalInitFieldDefs
III-D. Navigation
III-D-1. GetRecord
III-D-2. GetBookmarkFlag/SetBookmarkFlag
III-D-3. GetBookmarkData/SetBookmarkData
III-D-4. InternalGotoBookmark
III-D-5. BookmarkValid
III-D-6. InternalSetToRecord
III-D-7. InternalFirst
III-D-8. InternalLast
III-D-9. GetRecNo/SetRecNo
III-E. Lecture/Ecriture des champs
III-E-1. GetFieldData
III-E-2. SetFieldData
III-F. Gestion des LOB
III-F-1. TMemoryBlobStream
III-F-2. CreateBlobStream
III-G. Modification des données
III-G-1. GetCanModify
III-G-2. InternalEdit
III-G-3. InternalCancel
III-G-4. InternalPost
III-G-5. InternalDelete
Conclusion
IV. Quelques cas d'utilisations
IV-A. Scénario 1 : Ouverture/Fermeture du DataSet
IV-B. Scenario 2 : Affichage et Navigation avec une DBGrid
IV-B-1. First
IV-B-2. Last
IV-B-3. Prior
IV-B-4. Next
IV-B-5. SetRecNo
V. Références
I. Introduction
Un DataSet en mémoire. Encore un ! Ce type de composant fait légion sur le net.
Même Delphi intègre le TClientDataSet en standard qui peut faire office de DataSet en mémoire.
Alors pourquoi vouloir en développer un de plus ?
Tout simplement pour trois raisons majeures :
- Malgré tout les composants existants, je n'en ai jamais trouvé un seul qui ait le niveau de
performances que j'en attends. Comme je l'ai expliqué dans le précédent article, le TClientDataSet est
très lourd, avec une architecture complexe. On ne dispose d'aucun moyen permettant de le charger rapidement.
- Le TDataSet est au cœur de toute application base de données en Delphi. Si on veut écrire des applications
performantes, il est important de comprendre les mécanismes mis en œuvre à l'intérieur du TDataSet.
De cette manière, lorsqu'on écrit une ligne de code, on appréhende mieu ses conséquences.
Ecrire un DataSet en mémoire est un très bon prétexte pour étudier le fonctionnement de ce composant.
- Enfin, on aura besoin de surcharger un TDataSet dans les prochains articles afin de s'interfacer
avec OLEDB.
Nous allons voir les principes de bases permettant d'implémenter un TDataSet complet.
Il sera cependant bridé aux fonctionnalités essentielles :
- Navigation et Curseur Bidirectionnelle.
- Accès direct à une ligne
- Les champs LOB (Blob, Memo,…) seront gérés.
- Les champs calculés ne seront pas gérés.
- Les indexes et toutes les méthodes associées ne seront pas gérées.
- Les filtres seront partiellement gérés. On ne pourra filtrer le dataset qu'avec l'événement OnFilterRecord.
Cependant, si les indexes et les filtres ne seront pas totalement gérés, je donnerai quelques pistes pour ceux
qui voudraient le faire eux même.
Télécharger les sources de l'article
Vous pouvez télécharger l'article complet au format docx
ici.
L'ensemble des codes sources utilisés pour cet article compile avec Turbo Delphi Explorer.
II. Organisation générale du TDataSet
Tous les composants d'accès aux données permettant de lire un jeu de données dérivent d'un ancêtre commun :
La classe TDataSet.
Cette dernière définit un certain nombre d'opérations et de mécanismes communs à tous les composants d'accès aux données.
Cependant elle ne s'occupe pas de la gestion de la source de données elle-même.
Elle ne peut pas être instanciée directement car bon nombre de méthodes essentielles
sont abstraites et doivent être implémentées dans les descendants.
L'écriture d'un Dataset personnalisé consiste en fait à implémenter ces méthodes abstraites.
Elles peuvent être classées en plusieurs catégories :
II-A. Gestion des buffers
La classe TDataSet maintient un cache permettant de conserver en mémoire un certain
nombre de lignes sans être obligé de déplacer constamment le curseur provenant du SGBD.
Ce cache est composé d'un ensemble de buffers. Chaque buffer permet de stocker une ligne complète de données.
La classe TDataSet décide elle-même du nombre de buffers à gérer. En principe, il s'agit plus
ou moins du nombre de lignes visibles dans les composant TDBGrid liés.
Ainsi, les DBGrids bénéficient d'un mécanisme spécial permettant de lire rapidement les
données sans être obligé de toucher au curseur de la base de données.
Ces buffers servent également en modification. Ils permettent de stocker temporairement les valeurs
de la ligne en cours de modification, avant que cette dernière ne soit envoyée au SGBD.
Ils permettent également de mémoriser les valeurs de la ligne avant modification pour pouvoir les annuler si besoin.
Un buffer correspond à une ligne complète. Il contient les valeurs de tous les champs de la ligne.
Tous les buffers ont la même taille. Ceci pose un problème pour les champs de taille variable.
En effet, en principe ils doivent être stockés à l'intérieur du buffer. Comme ce dernier a une
taille fixe, on est obligé de prévoir des buffers suffisamment grands pour stocker des champs de
taille maximale. Ainsi si on définit un champ varchar(4000) dans la base de données,
le champ occupera bel et bien 4000 octets une fois chargé à l'intérieur du DataSet.
Et ce quel que soit la taille réelle du champ, même si ce dernier est à NULL !
Les allocations et destructions de buffers sont gérées automatiquement par la classe TDataSet.
A notre niveau, on doit simplement calculer la taille d'un buffer et fournir une méthode
d'allocation et de libération de la mémoire.
II-B. Ouverture/Fermeture du curseur
Le principe du DataSet c'est d'encapsuler un curseur sur une source de données.
Bien évidemment, la classe TDataSet ne peut pas créer ce curseur elle-même.
On doit donc définir des méthodes pour créer ce curseur.
On remarquera au passage, que c'est généralement au moment où on ouvre
le curseur base de données qu'on peut connaître la structure du jeu de résultat.
C'est donc à ce moment qu'on peut connaître les champs qui font partis du DataSet,
calculer la taille des buffers et créer les composants TField.
II-C. Navigation
La classe TDataSet définit les fonctions de navigation de base.
C'est-à-dire : First, Next, Prior et Last. Mais toutes ces opérations
peuvent ne pas être disponibles en fonction du type de dataset. Sur un dataset unidirectionnel,
on peut uniquement faire un Next.
Lorsqu'on définit un dataset personnalisé, il faut implémenter ces méthodes pour se déplacer sur
la source de données et charger les buffers avec les données des lignes correspondantes.
Les fonctions de navigation et de recherche plus évoluées (Locate, FindKey, GotoKey...)
ne sont pas gérées par la classe TDataSet. C'est aux descendants de les définir et de les implémenter
si nécessaire.
En revanche, la gestion des bookmarks fait partie du TDataSet et ses derniers doivent être
implémentés dans les classes dérivées (sauf pour les dataset unidirectionnel).
II-D. Modification des données
La classe TDataSet gère automatiquement les méthodes Insert,
Append, Edit, Cancel et Post. Elle assure la
gestion des buffers et maintient la définition de l'état du dataset.
En revanche, les classes dérivées doivent s'occuper de répercuter ces modifications
sur la source de données si elles sont validées.
De plus, la classe TDataSet ne définit pas le mode de stockage
des champs à l'intérieur du buffer. Chaque descendant est libre d'organiser
la structure interne des buffers comme bon lui semble. Aussi, lorsque les
TField essaient de lire/écrire la valeur d'un champ dans le buffer,
il faut implémenter les méthodes d'extraction/écriture de cette valeur.
Si on implémente toutes les méthodes dans chacune de ces catégories,
on obtient un DataSet bidirectionnel, complètement opérationnel et
pouvant être affiché dans une grille.
Si on connecte ce dataset à un tableau en mémoire en guise de source de données,
on obtient un dataset en mémoire.
C'est ce que nous allons mettre en pratique immédiatement en développant
la classe TMemoryDataSet.
III. La classe TMemoryDataSet
III-A. Organisation des données en mémoire
Le TMemoryDataSet va devoir stocker les lignes qui seront lues par le DataSet.
Pour cela on définit simplement un tableau de buffers. Par contre,
par la suite il faudra que les lignes occupent des espaces mémoires contigus.
C'est pourquoi ces dernières seront regroupées par Pages.
Chaque page correspondra à un bloc mémoire pouvant contenir un certain nombre de lignes
(propriété PageSize).
Ainsi, les allocations mémoires s'effectueront page par page et non pas ligne par ligne.
On évitera ainsi de faire de l'allocation mémoire à outrance.
Lorsqu'une page est pleine, on en allouera une deuxième, puis une troisième...
Enfin, on va maintenir une liste des pages.
III-B. Gestion des buffers
III-B-1. Définition des Champs du TMemoryDataSet
La classe TDataSet prévoit qu'on utilise une collection TFieldDefs pour décrire les
champs du DataSet. Inutile de réinventer la roue, c'est ce que nous allons faire.
En principe, on définit les TFieldDef pour indiquer les champs que l'on souhaite gérer
dans le DataSet. Cette définition doit être effectuée avant son ouverture.
Au moment de l'ouverture du TMemoryDataSet, ce dernier créera automatiquement les TField
nécessaires pour accéder aux données.
Avec les composants base de données de Delphi, il est possible de définir les TField en design,
alors que le dataset est fermé. Cela permet de configurer les paramètres d'affichage des TField depuis l'IDE.
On peut alors les réordonner, définir les DisplayFormat, DisplayLabel...
Cependant, je ne conseille pas cette façon de travailler. En effet, si elle permet de développer
rapidement une maquette fonctionnelle, cette approche pose ensuite de gros problèmes de maintenance.
Si on ajoute un champ dans la base, il faut repasser sur tout les datasets qui ont été ainsi configurés,
sinon les nouveaux champs ne sont pas visibles. Si un champ change de taille, on obtient une erreur à
l'ouverture du DataSet...
C'est pourquoi, la classe TMemoryDataSet ignorera purement et simplement les TField créés
en design. Ils seront systématiquement détruit et recréer à l'ouverture du DataSet.
III-B-2. Gestion des champs LOB
Les champs LOB (Memo, Binaire,…) posent un problème particulier. Ils peuvent avoir une taille importante,
voir très importante. Leur taille n'est pas limitée, tandis qu'au contraire les buffers des lignes ont
une taille fixe.
Cette simple particularité interdit purement et simplement de les stocker directement dans les buffers.
On va devoir les stocker séparément. Une technique simple consiste à ne stocker à l'intérieur
du buffer qu'un pointeur sur un autre bloc mémoire qui lui contient réellement les données du
LOB.
Avec cette technique, il faut faire attention à ne pas créer de fuite mémoire. Pour s'en assurer,
on va travailler légèrement différemment. Les champs LOB alloués sont stockés dans une liste indépendante
à l'intérieur de TMemoryDataSet. Dans le buffer d'une ligne, au lieu de stocker directement le
pointeur sur le contenu du LOB, on va stocker le numéro de LOB dans la liste des LOB.
De cette façon, on conserve toujours les références sur les LOB qui ont été alloués.
Au pire, la destruction du TMemoryDataSet libérera les LOBs oubliés.
III-B-3. Organisation d'un buffer
La première chose à faire est de définir la structure d'un buffer. Ce dernier doit pouvoir
contenir les données de tous les champs du DataSet.
Sur le principe, il va donc s'agir de la juxtaposition des valeurs de chaque champ.
Cependant, la valeur d'un champ n'est pas suffisante. Pour chaque champ on doit également
savoir si le champ est à NULL ou s'il possède une valeur. Pour les champs de taille
variable, il faut également qu'on sache qu'elle est la longueur courante du champ.
Aussi, pour chaque champ, nous allons définir la structure suivante :
PFieldData = ^TFieldData;
TFieldData = packed record
NullStatus : integer;
LengthValue : integer;
Data : array[0..0] of byte;
end;
|
On utilise un record et non pas une classe car ces structures vont ensuite être alignées
à l'intérieur de chaque buffer. Si on avait définit une classe, Delphi n'aurait pas stocké la
structure à l'intérieur du buffer, mais un pointeur sur l'instance.
J'ai utilisé un integer pour indiquer si un champ est à NULL. Ca peut paraître comme un
gaspillage mémoire (et ça en est bien un) car un simple boolean aurait pu faire l'affaire.
On aurait également pu optimiser la mémoire en définissant un tableau de bits à l'intérieur du
buffer.
Cependant si toutes ces solutions optimisent la mémoire, elles n'optimisent pas les performances.
A terme, on utilisera la classe TMemoryDataSet pour charger les données retournées par OLEDB.
Hors pour obtenir les meilleures performances avec OLEDB, il vaut mieux que ces indicateurs soient des
entiers sur 32 bits.
Le champ Data indique simplement un tableau d'octets. Sa taille réelle dépendra du type
du champ stocké.
Outre les données relatives aux champs, un buffer doit également contenir trois informations supplémentaires
nécessaires à la gestion du Dataset :
Les BookmarkFlags : Il s'agit d'une information utilisée par TDataSet pour mémoriser
l'état du buffer par rapport à l'ensemble de données. Elle indique au dataset,
s'il s'agit de la première ligne (bfBOF), de la dernière ligne (bfEOF),
d'une ligne ordinaire (bfCurrent) ou d'une ligne en cours d'insertion (bfInserted).
Identifiant du buffer : Les buffers doivent posséder un identifiant unique.
Il peut s'agir d'un simple entier. Cet identifiant permet au dataset de positionner
la source de données sur un buffer en particulier.
Bookmark : Le bookmark est un identifiant indiquant précisément
la ligne sur laquelle on se trouve. C'est cette valeur qui est retournée
lorsqu'on fait un GetBookmark. Lorsqu'on veut effectuer un GotoBookmark,
il faut rechercher la ligne qui possède le bookmark voulu.
Cette information est un peu redondante avec l'identifiant du buffer.
C'est pourquoi il est fréquent d'utiliser le même identifiant comme identifiant de buffer
et valeur de bookmark.
Ainsi la structure d'un buffer est la suivante :
TLineBuffer = packed record
BkFlags : TBookmarkFlag;
Bookmark : cardinal;
Data : array[0..0] of byte;
end;
|
Data est en réalité en tableau de structures TFieldData.
Cependant on ne peut pas le déclarer comme tel car chaque élément possède une taille variable.
III-B-4. Implémentation des buffers
III-B-4-a. GetRecordSize
La méthode GetRecordSize doit retourner la taille d'un buffer en octets. Cette dernière
dépend du mode d'organisation choisit pour les buffers, mais aussi des champs présents
dans le dataset.
La taille d'un buffer peut donc être calculée au moment de l'ouverture du dataset et
mémorisée jusqu'à sa fermeture :
procedure TMemoryDataSet.CalcRecordSize;
var
i : integer;
currentOffset : cardinal;
begin
SetLength(FFieldInfo, FieldDefs.Count);
currentOffset := sizeof(TLineBuffer) - 1;
for i := 0 to FieldDefs.Count -1 do
begin
FFieldInfo[i].Offset := currentOffset;
FFieldInfo[i].Size := GetFieldSize(FieldDefs[i]);
inc(currentOffset, sizeof(TFieldData) - 1 + FFieldInfo[i].Size);
end;
FRecordSize := currentOffset;
end;
|
FFieldInfo est un tableau mémorisant les informations nécessaires pour accéder
directement à un champ à l'intérieur d'un buffer. Il est déclaré de la façon suivante :
FFieldInfo : array of TFieldInfo;
|
Avec :
TFieldInfo = record
Offset : cardinal;
Size : cardinal;
end;
|
La taille du buffer est calculée et mémorisée dans FRecordSize.
Ainsi GetRecordSize n'a qu'à renvoyer cette valeur :
function TMemoryDataSet.GetRecordSize: word;
begin
result := FRecordSize;
end;
|
Pour effectuer le calcul, on fait appel à une méthode GetFieldSize.
Cette dernière est chargée de calculer l'espace nécessaire pour stocker un champ en fonction
de sa déclaration et du mode de stockage choisit :
function TMemoryDataSet.GetFieldSize(FldDef: TFieldDef): cardinal;
begin
case FldDef.DataType of
ftString, ftFixedChar:
result := FldDef.Size;
ftSmallint, ftWord:
result := 2;
ftAutoInc, ftInteger:
result := 4;
ftBoolean:
result := 2;
ftFloat:
result := sizeof(double);
ftCurrency:
result := sizeof(currency);
ftBCD, ftFMTBcd :
result := sizeof(double);
ftDateTime, ftDate, ftTime :
result := sizeof(TDateTime);
ftWideMemo, ftOraBlob, ftOraClob, ftBlob, ftBytes, ftVarBytes,
ftMemo, ftGraphic, ftFmtMemo:
result := 4;
ftFixedWideChar, ftWideString:
result := FldDef.Size*2;
ftLargeint:
result := sizeof(int64);
ftVariant:
result := sizeof(variant);
ftGuid:
result := sizeof(TGUID);
else raise MemoryDataSetNotSupported.Create(FldDef.Name, FldDef.DataType);
end;
end;
|
III-B-4-b. AllocRecordBuffer
Cette méthode doit être implémentée pour allouer un nouveau buffer.
On peut se contenter d'effectuer une simple allocation mémoire.
On initialise également les champs à l'intérieur du buffer à la valeur NULL :
function TMemoryDataSet.AllocRecordBuffer: PChar;
begin
SQLLogger.Trace(EVENT_TRACE_TYPE_INFO, 'AllocRecordBuffer',
TRACE_LEVEL_VERBOSE);
GetMem(Result, GetRecordSize);
InternalInitRecord(Result);
end;
|
III-B-4-c. FreeRecordBuffer
FreeRecordBuffer est le symétrique de AllocRecordBuffer. Cette méthode doit
être implémentée pour libérer les buffers alloués avec AllocRecordBuffer.
Comme nos buffers sont de simples allocations mémoire, il suffit de faire le free
correspondant :
procedure TMemoryDataSet.FreeRecordBuffer(var Buffer: PChar);
begin
SQLLogger.Trace(EVENT_TRACE_TYPE_INFO, 'FreeRecordBuffer',
TRACE_LEVEL_VERBOSE);
FreeMem(Buffer);
end;
|
III-B-4-d. InternalInitRecord
La méthode InternalInitRecord peut être surchargée pour initialiser un buffer avec
une valeur spécifique. La méthode de base dans TDataSet ne fait rien.
Nous la surchargeons pour forcer tous les champs à NULL dans les nouveaux buffers :
procedure TMemoryDataSet.InternalInitRecord(Buffer: PChar);
var i : integer;
begin
SQLLogger.Trace(EVENT_TRACE_TYPE_INFO, 'InternalInitRecord',
TRACE_LEVEL_VERBOSE);
for i := 0 to high(FFieldInfo) do
begin
ClearField(Buffer, FFieldInfo[i].Offset);
end;
end;
|
ClearField est une procédure utilitaire inline pour forcer le statut d'un champ
à NULL.
III-B-4-e. Accès direct aux champs
Par la suite, nous aurons constamment besoin d'accéder aux champs individuellement.
Comme ces derniers sont alignés à l'intérieur d'un buffer, nous avons besoin d'une série de
fonctions permettant d'accéder directement à la structure d'un champ.
Ces dernières doivent être particulièrement efficaces.
De plus elles sont très simples.
Aussi pour avoir les meilleures performances, nous allons les définir inline :
function GetPFieldData(Buffer : PChar; Offset : cardinal) : PFieldData; inline;
begin
result := PFieldData(Buffer + Offset);
end;
function IsNull(Field : PFieldData) : boolean; inline; overload;
begin
result := Field.NullStatus < 0;
end;
function IsNull(Buffer : PChar; Offset : cardinal) : boolean; inline; overload;
begin
result := IsNull(GetPFieldData(Buffer, Offset));
end;
procedure ClearField(Buffer : PChar; Offset : cardinal); inline;
begin
GetPFieldData(Buffer, Offset).NullStatus := DBNULL_VALUE;
end;
|
III-C. Ouverture/Fermeture
Voyons à présent les méthodes à implémenter pour ouvrir le DataSet.
III-C-1. InternalOpen
InternalOpen est appelée par TDataSet lorsque l'utilisateur veut ouvrir
le dataset. Cette méthode doit initialiser le curseur sur la source de données
et créer les TField. C'est le moment pour effectuer toutes les initialisations
du DataSet :
procedure TMemoryDataSet.InternalOpen;
var
currentTime : int64;
begin
SQLLogger.TraceBegin(EVENT_TRACE_TYPE_START, 'InternalOpen', currentTime,
TRACE_LEVEL_VERBOSE);
try
FCursor := -1;
CalcRecordSize;
if DefaultFields
then begin
DestroyFields;
CreateFields;
end;
BindFields(true);
FIsCursorOpen := true;
finally
SQLLogger.TraceEnd(EVENT_TRACE_TYPE_END, 'InternalOpen', currentTime,
TRACE_LEVEL_VERBOSE);
end;
end;
|
III-C-2. InternalClose
Lors de la fermeture du DataSet, on a juste besoin de détruire les éléments qui
ont été créés au moment de l'ouverture :
procedure TMemoryDataSet.InternalClose;
var
currentTime : int64;
begin
SQLLogger.TraceBegin(EVENT_TRACE_TYPE_START, 'InternalClose', currentTime,
TRACE_LEVEL_VERBOSE);
try
BindFields(False);
if DefaultFields
then DestroyFields;
FIsCursorOpen := false;
finally
SQLLogger.TraceEnd(EVENT_TRACE_TYPE_END, 'InternalClose', currentTime,
TRACE_LEVEL_VERBOSE);
end;
end;
|
III-C-3. IsCursorOpen
Cette fonction est utilisée par certaines méthodes du TDataSet pour savoir si le
curseur de la source de données a déjà été ouvert. Il suffit de retourner la valeur
mémorisée dans FIsCursorOpen.
function TMemoryDataSet.IsCursorOpen: Boolean;
begin
result := FIsCursorOpen;
end;
|
III-C-4. InternalInitFieldDefs
Cette méthode est appelée par TDataSet lorsque la classe de base veut récupérer la
définition des champs TFieldDefs de la table, sans procéder pour autant à son ouverture.
Comme dans l'implémentation du TMemoryDataSet les TFieldDef doivent être créés manuellement
en premier lieu, on n'a pas besoin de faire quoi que ce soit dans cette méthode.
On va simplement se contenter de l'instrumenter pour qu'on puisse
par la suite étudier le fonctionnement du TDataSet.
procedure TMemoryDataSet.InternalInitFieldDefs;
begin
SQLLogger.Trace(EVENT_TRACE_TYPE_INFO, 'InternalInitFieldDefs',
TRACE_LEVEL_VERBOSE);
end;
|
III-D. Navigation
III-D-1. GetRecord
On arrive à présent au cœur du DataSet. La méthode GetRecord est certainement
la plus importante et doit faire l'objet de la plus grande attention.
Elle joue un double rôle : Elle procède à la lecture des buffers sur la source de données et
elle effectue la plupart des fonctions de navigation :
function TMemoryDataSet.GetRecord(Buffer: PCha
|