Net-Base Magazine

08.05.2026

Delphi RTTI pour le mapping sans magie : basé sur des attributs, débogable et compatible avec les systèmes hérités

Un modèle de mapping pragmatique avec Delphi RTTI : attributs plutôt que des conventions, conversions contrôlées, messages d'erreur clairs et un mode debug qui aide réellement en exploitation. Avec des extraits de code pour le mapping de jeu de données ou d'enregistrements vers des objets, sans magie cachée.

08.05.2026

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.

Delphi
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: tkClass et tkSet ne 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: varDouble via VarAsType est 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).

Delphi
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 :

Delphi
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 :

  1. 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).
  2. 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.
  3. 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.

Partager l'article

Partager directement cette publication

LinkedIn, X, XING, Facebook, WhatsApp et e-mail sont immédiatement disponibles. Pour Instagram, nous préparons directement le lien et un court texte.

Courriel

Instagram s'ouvre dans un nouvel onglet. Le lien et le court texte sont préalablement copiés dans le presse-papiers.