Qui exploite un logiciel métier établi en Delphi connaît le dilemme : d’une part on souhaite des objets de domaine structurés et des couches claires, d’autre part il existe des Datasets, des Variants, des imports CSV, des payloads d’interface ou une API REST qui doivent « d’une manière ou d’une autre » être mappés sur des objets. C’est précisément là qu’intervient rapidement Delphi RTTI pour le mapping sans magie : c’est‑à‑dire du mapping par réflexion (RTTI = Run-Time Type Information, informations de type à l’exécution), mais de façon traçable, bien débogable et sans dépendre sournoisement de conventions ou de jeux de noms.
Le point clé : la « magie » n’est généralement pas causée par la RTTI elle‑même, mais par des règles implicites. Si les règles de mapping sont explicites dans des attributs, si les conversions sont centralisées et si les erreurs indiquent une cause claire, la RTTI devient un outil plutôt qu’une surprise.
Pourquoi le mapping RTTI dans Delphi échoue souvent
Le mapping basé sur la RTTI échoue rarement sur le principe dans les systèmes réels, mais plutôt à cause de contraintes périphériques :
- Formes de données héritées : Null/Empty/0 ne sont pas clairement distingués, les types de champs changent, les chaînes contiennent « N/A ».
- Conventions qui glissent : « le champ s’appelle comme la propriété » fonctionne jusqu’au premier alias, jointure ou renommage/refactoring de la propriété.
- Difficile à déboguer : si un mapper « ne met simplement rien », la cause est ensuite manquante. En production, c’est toxique.
- Mythes de performance : la RTTI est étiquetée à tort « lente », alors que le manque de mise en cache est généralement le vrai problème.
Une approche viable devrait donc (1) disposer de métadonnées de mapping explicites, (2) traiter clairement la conversion et la sémantique des valeurs nulles, (3) produire des erreurs et des sorties de debug, et (4) mettre en cache les informations RTTI.
Delphi RTTI pour le mapping sans magie : principes de conception
Le modèle suivant est volontairement « ennuyeux » au sens positif : les règles sont visibles, les effets secondaires limités, et il peut être intégré progressivement dans des modules existants.
- Attributs plutôt que conventions de nom : la propriété reçoit un attribut qui nomme la colonne source.
- Opt-in : seules les propriétés marquées sont définies. Pas de surprises dues à « toutes les propriétés publiées ».
- Conversion en un seul endroit : Variant/String/Integer/Boolean/Enum/Nullable sont mappés de façon centralisée.
- Mode debug : en option, on journalise quels champs ont été définis/sautés — avec la raison.
- Mise en cache RTTI : les parties les plus coûteuses (liste des propriétés, évaluation des attributs) sont préparées par type.
Extrait de code : mapping par attributs avec RTTI, mise en cache et debug
Le snippet mappe une ligne (par ex. issue de BDE-remplacement avec connexion native via TDataSet) sur un objet. Plutôt que d’attacher le mapper de manière fixe à TField, nous utilisons une petite interface Reader. En pratique, cela vaut la peine car vous pourrez réutiliser la même logique ultérieurement pour JSON, INI, CSV ou des API-Responses.
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// Mapping explicite : propriété <- nom_source
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
// Petite abstraction : fournir une valeur et distinguer existence/NULL
IValueReader = interface
['{7D1E5864-7D3A-4D30-BD1C-0A94F7E6C0EF}']
function HasValue(const AName: string): Boolean;
function IsNull(const AName: string): Boolean;
function GetValue(const AName: string): Variant;
end;
TRttiMapOptions = set of (moDebug, moIgnoreMissing, moIgnoreNull);
ERttiMappingError = class(Exception);
TRttiMapper = class
private
type
TPropMap = record
Prop: TRttiProperty;
SourceName: string;
end;
TTypeCache = class
Props: TArray<TPropMap>;
end;
private
class var FCache: TObjectDictionary<PTypeInfo, TTypeCache>;
class var FCacheLock: TObject;
class function GetOrBuildCache(ATypeInfo: PTypeInfo): TTypeCache; static;
class function FindMapFromAttr(const AProp: TRttiProperty): string; static;
class procedure SetPropertyValue(const AInstance: TObject; const AProp: TRttiProperty;
const AValue: Variant); static;
class function VariantToBoolean(const V: Variant): Boolean; static;
class function VariantToEnumOrdinal(AEnumType: TRttiType; const V: Variant): Integer; static;
public
class constructor Create;
class destructor Destroy;
class procedure MapToObject(const AReader: IValueReader; const ATarget: TObject;
const AOptions: TRttiMapOptions = [moIgnoreMissing]); static;
end;
implementation
{ MapFromAttribute }
constructor MapFromAttribute.Create(const AName: string);
begin
inherited Create;
FName := AName;
end;
{ TRttiMapper }
class constructor TRttiMapper.Create;
begin
FCache := TObjectDictionary<PTypeInfo, TTypeCache>.Create([doOwnsValues]);
FCacheLock := TObject.Create;
end;
class destructor TRttiMapper.Destroy;
begin
FCache.Free;
FCacheLock.Free;
end;
class function TRttiMapper.FindMapFromAttr(const AProp: TRttiProperty): string;
var
Attr: TCustomAttribute;
begin
Result := '';
for Attr in AProp.GetAttributes do
if Attr is MapFromAttribute then
Exit(MapFromAttribute(Attr).Name);
end;
class function TRttiMapper.GetOrBuildCache(ATypeInfo: PTypeInfo): TTypeCache;
var
Ctx: TRttiContext;
RType: TRttiType;
P: TRttiProperty;
L: TList<TPropMap>;
Src: string;
M: TPropMap;
begin
TMonitor.Enter(FCacheLock);
try
if FCache.TryGetValue(ATypeInfo, Result) then
Exit;
Result := TTypeCache.Create;
Ctx := TRttiContext.Create;
RType := Ctx.GetType(ATypeInfo);
L := TList<TPropMap>.Create;
try
for P in RType.GetProperties do
begin
if not P.IsWritable then
Continue;
// Opt-in : uniquement les propriétés avec un attribut
Src := FindMapFromAttr(P);
if Src = '' then
Continue;
M.Prop := P;
M.SourceName := Src;
L.Add(M);
end;
Result.Props := L.ToArray;
finally
L.Free;
end;
FCache.Add(ATypeInfo, Result);
finally
TMonitor.Exit(FCacheLock);
end;
end;
class function TRttiMapper.VariantToBoolean(const V: Variant): Boolean;
var
S: string;
begin
if VarIsBool(V) then
Exit(V);
if VarIsNumeric(V) then
Exit(V <> 0);
S := Trim(VarToStr(V)).ToLower;
if (S = '1') or (S = 'true') or (S = 't') or (S = 'y') or (S = 'yes') then
Exit(True);
if (S = '0') or (S = 'false') or (S = 'f') or (S = 'n') or (S = 'no') then
Exit(False);
raise ERttiMappingError.CreateFmt('Conversion booléenne échouée : "%s"', [VarToStr(V)]);
end;
class function TRttiMapper.VariantToEnumOrdinal(AEnumType: TRttiType; const V: Variant): Integer;
var
Ord: Integer;
Name: string;
begin
if VarIsNumeric(V) then
begin
Ord := Integer(V);
if (Ord < GetTypeData(AEnumType.Handle)^.MinValue) or
(Ord > GetTypeData(AEnumType.Handle)^.MaxValue) then
raise ERttiMappingError.CreateFmt('Ordinal d''énumération hors plage : %d', [Ord]);
Exit(Ord);
end;
Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt('Nom d''énumération inconnu : "%s"', [Name]);
Result := Ord;
end;
class procedure TRttiMapper.SetPropertyValue(const AInstance: TObject;
const AProp: TRttiProperty; const AValue: Variant);
var
V: TValue;
T: TRttiType;
Ord: Integer;
begin
T := AProp.PropertyType;
// Conversion volontairement sélective : mieux échouer clairement que de fonctionner silencieusement de façon douteuse.
case T.TypeKind of
tkUString, tkString, tkLString, tkWString:
V := TValue.From<string>(VarToStr(AValue));
tkInteger, tkInt64:
V := TValue.From<Int64>(VarAsType(AValue, varInt64));
tkFloat:
V := TValue.From<Double>(VarAsType(AValue, varDouble));
tkEnumeration:
begin
if T.Handle = TypeInfo(Boolean) then
V := TValue.From<Boolean>(VariantToBoolean(AValue))
else
begin
Ord := VariantToEnumOrdinal(T, AValue);
V := TValue.FromOrdinal(T.Handle, Ord);
end;
end;
tkSet:
raise ERttiMappingError.CreateFmt('Mappage de set non implémenté pour %s', [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt('Mappage de propriété de classe non implémenté pour %s', [AProp.Name]);
else
raise ERttiMappingError.CreateFmt('TypeKind non pris en charge (%s) pour %s',
[GetEnumName(TypeInfo(TTypeKind), Ord(T.TypeKind)), AProp.Name]);
end;
AProp.SetValue(AInstance, V);
end;
class procedure TRttiMapper.MapToObject(const AReader: IValueReader;
const ATarget: TObject; const AOptions: TRttiMapOptions);
var
Cache: TTypeCache;
M: TPropMap;
V: Variant;
Msg: string;
begin
if (ATarget = nil) or (AReader = nil) then
raise ERttiMappingError.Create('MapToObject : Reader ou Target est nil');
Cache := GetOrBuildCache(ATarget.ClassInfo);
for M in Cache.Props do
begin
if not AReader.HasValue(M.SourceName) then
begin
if not (moIgnoreMissing in AOptions) then
raise ERttiMappingError.CreateFmt('Source manquante : "%s" pour la propriété %s',
[M.SourceName, M.Prop.Name]);
Continue;
end;
if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Sans mécanisme Nullable/Optional, il n'est pas possible d'assigner NULL de façon pertinente.
Continue;
end;
V := AReader.GetValue(M.SourceName);
try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format('Affectation %s <- %s (%s)', [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt('Erreur de mappage pour %s <- %s: %s',
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
Utilité
Vous obtenez un mapping qui peut être évalué proprement lors des revues de code :
- Chaque propriété mappée est marquée visuellement (attribut).
- La conversion est centralisée, donc cohérente et testable.
- Les messages d’erreur indiquent quelle propriété et quelle source sont concernées.
- Un mode débogage vous fournit, le cas échéant, la chaîne de preuve, sans que vous ayez besoin de points d’arrêt dans le processus en production.
Contraintes et pièges typiques
- NULL-Semantik: Sans concept Nullable propre (p. ex.
Nullable<T>ou Option-Types) l’assignation de NULL n’est pas univoque. Dans l’extrait, NULL est ignoré par défaut. C’est conservateur et évite les écrasements silencieux. - TRttiContext-Lebensdauer: Nous construisons le cache une fois par type et jetons ensuite le Context. C’est courant. Important : ne pas créer un nouveau RTTI-Context pour chaque affectation de champ.
- Threading: Le cache est protégé via Monitor. Dans des mappings hautement parallèles (p. ex. REST-Server) vous devriez aussi envisager de précharger le cache au démarrage (preload) pour réduire la contention des verrous.
- PropertyType Kind:
tkClassettkSetne sont intentionnellement pas implémentés. Pour les objets imbriqués, vous devriez soit mapper de façon récursive (avec une politique claire), soit assigner manuellement. - Locale-Fallen:
varDoubleviaVarAsTypeest relativement robuste, mais les chaînes comme «1,23» vs. «1.23» restent problématiques. Si vos sources renvoient des chaînes, un parseur dédié (avec Culture définie) est souvent préférable.
Variante pour FireDAC et TDataSet: adaptateur Reader plutôt que couplage du Mapper
Dans les applications BDE-Ablosung mit nativer Anbindung ou VCL/Win32 classiques, la source est souvent un TDataSet. Au lieu de lier le mapper à TField, écrivez un adaptateur qui implémente l’interface IValueReader. L’avantage : le mapper reste indépendant de l’accès aux données (important si vous externalisez ultérieurement l’accès aux données vers des services ou vers un REST-Server).
uses Data.DB, System.Variants, RttiMapping;
type
TDataSetValueReader = class(TInterfacedObject, IValueReader)
private
FDS: TDataSet;
public
constructor Create(ADS: TDataSet);
function HasValue(const AName: string): Boolean;
function IsNull(const AName: string): Boolean;
function GetValue(const AName: string): Variant;
end;
constructor TDataSetValueReader.Create(ADS: TDataSet);
begin
inherited Create;
FDS := ADS;
end;
function TDataSetValueReader.HasValue(const AName: string): Boolean;
begin
Result := (FDS <> nil) and (FDS.FindField(AName) <> nil);
end;
function TDataSetValueReader.IsNull(const AName: string): Boolean;
var
F: TField;
begin
F := FDS.FindField(AName);
Result := (F = nil) or F.IsNull;
end;
function TDataSetValueReader.GetValue(const AName: string): Variant;
begin
Result := FDS.FieldByName(AName).Value;
end;
Voici à quoi ressemble un mapping concret :
type
TOrderRow = class
private
FId: Int64;
FCustomerNo: string;
FIsClosed: Boolean;
public
[MapFrom('order_id')]
property Id: Int64 read FId write FId;
[MapFrom('customer_no')]
property CustomerNo: string read FCustomerNo write FCustomerNo;
[MapFrom('is_closed')]
property IsClosed: Boolean read FIsClosed write FIsClosed;
end;
// ...
var
Row: TOrderRow;
Reader: IValueReader;
begin
Row := TOrderRow.Create;
try
Reader := TDataSetValueReader.Create(MyQuery);
TRttiMapper.MapToObject(Reader, Row, [moIgnoreMissing, moDebug, moIgnoreNull]);
finally
Row.Free;
end;
end;
Où l’approche est pertinente — et où elle ne l’est pas
Ce modèle est généralement pertinent dans trois situations :
- Mise à niveau progressive : Vous souhaitez introduire des objets de domaine sans refondre immédiatement l’accès aux données (classique lors de la Delphi modernisation des applications existantes).
- Points d’interface : les imports CSV/Excel, les REST-payloads ou des sources de données « mixtes » nécessitent une conversion robuste et des messages d’erreur clairs.
- Maintenabilité en équipe : les attributs rendent les règles de mapping visibles et vérifiables en revue de code, ce qui a une grande valeur dans des bases de code importantes.
Des limites d’utilisation existent également, clairement :
- Graphes d’objets complexes (Child-Collections, références cycliques) ne devraient pas être mappés « automagiquement ». Ici, du code explicite ou un motif séparé d’assembler/factory est en général plus stable.
- High-Throughput-Hotpaths (p. ex. ETL de masse) bénéficient plutôt de mappers générés par code ou d’un mapping optimisé manuellement, même si le RTTI est mis en cache.
- Nullable/Optional est un sujet à part. Si vous devez réellement distinguer entre « non présent », « NULL » et « par défaut », exprimez-le dans le modèle de domaine, ne le cachez pas dans le mapper.
Intégration dans l’architecture et l’exploitation
Du point de vue architectural, ce mapper est un composant d’infrastructure situé à la frontière entre la représentation des données et le domaine. Il ne remplace pas une séparation claire en couches, mais peut la faciliter : l’accès aux données (FireDAC, SQL, Views) peut rester pragmatique, tandis que le domaine reste cohérent. Dans les systèmes multicouches (souvent appelés Layer-3 architecture : UI, Domain/Services, Infrastruktur), le mapper relève de l’infrastructure et est utilisé par les services, pas par les formulaires UI.
En exploitation, point important : n’activez pas moDebug en permanence dans les services en production, mais de façon ciblée. Pour des problèmes de données difficiles à reproduire, il est judicieux de disposer d’un chemin de diagnostic commutable (configuration, feature flag). Sinon, vous vous exposez à un volume de logs élevé et à des effets secondaires.
Conclusion : RTTI oui, mais seulement avec des garde-fous clairs
Delphi RTTI pour le mapping sans magie fonctionne bien lorsque vous utilisez RTTI comme outil de métadonnées déclaratives — pas comme une invitation à des heuristiques implicites. Les attributs en opt-in, une conversion centralisée, un cache par type et des messages d’erreur lisibles font passer le sujet de « opaque » à « exploitable en production ». L’approche n’est volontairement pas universelle : pour des graphes imbriqués, une sémantique stricte des valeurs nulles ou des performances maximales, il faut d’autres composants. En revanche, comme pont robuste entre des jeux de données/structures héritées et des objets de domaine plus modernes, elle représente dans de nombreuses bases de code Delphi le pas pragmatique qui rend la modernisation possible.
Si, dans une application Delphi existante, vous êtes bloqué sur les points de mapping, la qualité des données ou une modernisation progressive, nous pouvons mettre cela en place proprement et l’intégrer à votre architecture : contactez-nous.
Dans le contexte métier, Delphi Rtti Mapping et Attribute Mapping Delphi jouent également un rôle important lorsque les intégrations, les flux de données et l’évolution doivent s’articuler proprement.
Discuter d’un projet ou d’une initiative de modernisation avec Net-Base.