Wer gewachsene Business-Software in Delphi betreibt, kennt das Spannungsfeld: Einerseits will man strukturierte Domänenobjekte und klare Schichten, andererseits gibt es Datasets, Variants, CSV-Importe, Schnittstellenpayloads oder eine REST-API, die „irgendwie“ auf Objekte gemappt werden müssen. Genau hier landet man schnell bei Delphi RTTI für Mapping ohne Magie: also Mapping per Reflection (RTTI = Run-Time Type Information, Typinformationen zur Laufzeit), aber so, dass es nachvollziehbar bleibt, gut debugbar ist und nicht heimlich an Konventionen oder Namensspielchen hängt.
Der Kernpunkt: „Magie“ entsteht meist nicht durch RTTI an sich, sondern durch implizite Regeln. Wenn Mapping-Regeln dagegen explizit in Attributen stehen, Konvertierungen zentralisiert sind und Fehler eine klare Ursache benennen, wird RTTI zu einem Werkzeug statt zu einer Überraschung.
Warum RTTI-Mapping in Delphi oft kippt
RTTI-basiertes Mapping scheitert in realen Systemen selten an der Idee, sondern an Randbedingungen:
- Legacy-Datenformen: Null/Empty/0 sind nicht sauber getrennt, Feldtypen wechseln, Strings enthalten „N/A“.
- Schleichende Konventionen: „Feld heißt wie Property“ funktioniert bis zum ersten Alias, Join oder refaktorierten Property-Namen.
- Schwer zu debuggen: Wenn ein Mapper „einfach nichts setzt“, fehlt später die Ursache. Im Betrieb ist das Gift.
- Performance-Mythen: RTTI wird pauschal als „langsam“ abgestempelt, obwohl meist fehlendes Caching das Problem ist.
Ein tragfähiger Ansatz sollte deshalb (1) explizite Mapping-Metadaten haben, (2) Konvertierung und Null-Semantik klar behandeln, (3) Fehler und Debug-Ausgaben liefern und (4) RTTI-Infos cachen.
Delphi RTTI für Mapping ohne Magie: Designprinzipien
Das folgende Muster ist bewusst „langweilig“ im besten Sinn: Regeln sind sichtbar, Nebenwirkungen begrenzt, und man kann es schrittweise in bestehende Module ziehen.
- Attribute statt Namenskonvention: Property bekommt ein Attribut, das die Quellspalte benennt.
- Opt-in: Nur markierte Properties werden gesetzt. Keine Überraschungen durch „alle publizierten Properties“.
- Konvertierung an einer Stelle: Variant/String/Integer/Boolean/Enum/Nullable werden zentral gemappt.
- Debug-Mode: Optional wird protokolliert, welche Felder gesetzt/übersprungen wurden – mit Grund.
- RTTI-Caching: Die teuersten Teile (Propertyliste, Attributeauswertung) werden pro Typ vorbereitet.
Source-Schnipsel: Attribut-Mapping mit RTTI, Caching und Debug
Das Snippet bildet eine Zeile (z. B. aus BDE-Ablosung mit nativer Anbindung via TDataSet) auf ein Objekt ab. Statt den Mapper fest an TField zu koppeln, verwenden wir eine kleine Reader-Schnittstelle. Das ist in der Praxis wertvoll, weil Sie später dieselbe Logik auch für JSON, INI, CSV oder API-Responses verwenden können.
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// Explizites Mapping: Property <- Quellname
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
// Kleine Abstraktion: Wert liefern + Existenz/NULL unterscheiden
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: Nur Properties mit 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('Boolean-Konvertierung fehlgeschlagen: "%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('Enum-Ordinal außerhalb des Bereichs: %d', [Ord]);
Exit(Ord);
end;
Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt('Enum-Name unbekannt: "%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;
// Konvertierung bewusst selektiv: lieber klar scheitern als still "irgendwie".
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('Set-Mapping nicht implementiert für %s', [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt('Class-Property Mapping nicht implementiert für %s', [AProp.Name]);
else
raise ERttiMappingError.CreateFmt('TypeKind nicht unterstützt (%s) für %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 oder Target ist 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('Quelle fehlt: "%s" für Property %s',
[M.SourceName, M.Prop.Name]);
Continue;
end;
if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Ohne Nullable/Optional-Mechanik kann man NULL nicht sinnvoll setzen.
Continue;
end;
V := AReader.GetValue(M.SourceName);
try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format('Mapped %s <- %s (%s)', [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt('Mapping-Fehler bei %s <- %s: %s',
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
Wozu das gut ist
Sie bekommen ein Mapping, das sich in Code-Reviews sauber beurteilen lässt:
- Jede gemappte Property ist optisch markiert (Attribut).
- Die Konvertierung ist zentral, dadurch konsistent und testbar.
- Fehlertexte sagen, welche Property und welche Quelle betroffen ist.
- Ein Debug-Modus gibt Ihnen im Zweifel die Beweiskette, ohne dass Sie Breakpoints im Produktivprozess brauchen.
Randbedingungen und typische Stolperfallen
- NULL-Semantik: Ohne eigenes Nullable-Konzept (z. B.
Nullable<T>oder Option-Types) ist „NULL setzen“ nicht eindeutig. Im Snippet wird NULL standardmäßig übersprungen. Das ist konservativ und verhindert stille Überschreibungen. - TRttiContext-Lebensdauer: Wir bauen den Cache einmal pro Typ und werfen den Context danach weg. Das ist üblich. Wichtig ist: Nicht pro Feldzuweisung neuen RTTI-Context bauen.
- Threading: Der Cache ist via Monitor geschützt. In hochparallelen Mappings (z. B. REST-Server) sollten Sie zusätzlich prüfen, ob Sie den Cache schon beim Start „warm“ bauen (Preload), um Lock-Contention zu reduzieren.
- PropertyType Kind:
tkClassundtkSetsind absichtlich nicht implementiert. Für verschachtelte Objekte sollten Sie entweder rekursiv mappen (mit klarer Policy) oder bewusst per Hand zuweisen. - Locale-Fallen:
varDoubleüberVarAsTypeist relativ robust, aber Strings wie „1,23“ vs. „1.23“ sind trotzdem ein Thema. Wenn Ihre Quellen Strings liefern, ist ein eigener Parser (mit definierter Culture) oft besser.
Variante für FireDAC und TDataSet: Reader-Adapter statt Mapper-Kopplung
In BDE-Ablosung mit nativer Anbindung- oder klassischen VCL/Win32-Anwendungen ist die Quelle häufig ein TDataSet. Statt den Mapper an TField zu binden, schreiben Sie einen Adapter, der das Interface IValueReader erfüllt. Der Vorteil: Der Mapper bleibt unabhängig vom Datenzugriff (wichtig, wenn Sie Datenzugriff später in Services oder einen REST-Server auslagern).
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;
Damit sieht ein konkretes Mapping so aus:
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;
Wo sich der Ansatz lohnt – und wo nicht
Dieses Muster lohnt sich typischerweise in drei Situationen:
- Schrittweise Modernisierung: Sie wollen Domänenobjekte einführen, ohne den Datenzugriff sofort komplett umzubauen (klassisch bei Delphi Modernisierung in Bestandsanwendungen).
- Schnittstellenkanten: CSV-/Excel-Importe, REST-Payloads oder „gemischte“ Datenquellen brauchen robuste Konvertierung und gute Fehlermeldungen.
- Wartbarkeit im Team: Attribute machen Mapping-Regeln sichtbar und reviewbar, was in größeren Codebasen Gold wert ist.
Einsatzgrenzen gibt es ebenfalls klar:
- Komplexe Objektgraphen (Child-Collections, zyklische Referenzen) sollten Sie nicht „automagisch“ mappen. Hier ist expliziter Code oder ein getrenntes Assembler/Factory-Muster meist stabiler.
- High-Throughput-Hotpaths (z. B. Massendaten-ETL) profitieren eher von codegenerierten Mappern oder handoptimiertem Mapping, selbst wenn RTTI gecacht ist.
- Nullable/Optional ist ein eigenes Thema. Wenn Sie wirklich zwischen „nicht vorhanden“, „NULL“ und „Default“ unterscheiden müssen, sollten Sie das im Domänenmodell ausdrücken, nicht im Mapper verstecken.
Einordnung in Architektur und Betrieb
Aus Architekturperspektive ist dieser Mapper eine Infrastruktur-Komponente an der Grenze zwischen Datenrepräsentation und Domäne. Er ersetzt keine saubere Schichtung, kann sie aber ermöglichen: Der Datenzugriff (FireDAC, SQL, Views) darf weiterhin pragmatisch sein, während die Domäne konsistent bleibt. In mehrschichtigen Systemen (oft als Layer-3 Architektur bezeichnet: UI, Domain/Services, Infrastruktur) gehört der Mapper in die Infrastruktur und wird von Services genutzt, nicht von UI-Formularen.
Betrieblich wichtig: Aktivieren Sie moDebug nicht dauerhaft in produktiven Services, sondern gezielt. Für schwer reproduzierbare Datenprobleme ist es sinnvoll, einen schaltbaren Diagnosepfad zu haben (Konfiguration, Feature-Flag). Sonst drohen Log-Volumen und Nebenwirkungen.
Fazit: RTTI ja, aber nur mit klaren Leitplanken
Delphi RTTI für Mapping ohne Magie funktioniert dann gut, wenn Sie RTTI als Werkzeug für deklarative Metadaten nutzen – nicht als Einladung zu stillen Heuristiken. Attribute als Opt-in, zentralisierte Konvertierung, Cache pro Typ und verständliche Fehlertexte bringen das Thema von „undurchsichtig“ zu „betriebsfähig“. Der Ansatz ist bewusst nicht universell: Für verschachtelte Graphen, strikte Null-Semantik oder maximale Performance brauchen Sie weitere Bausteine. Als robuste Brücke zwischen Dataset/Legacy-Strukturen und moderneren Domänenobjekten ist er aber in vielen Delphi-Codebasen genau der pragmatische Schritt, der Modernisierung überhaupt erst möglich macht.
Wenn Sie in einer gewachsenen Delphi-Anwendung gerade an Mapping-Kanten, Datenqualität oder schrittweiser Modernisierung hängen, können wir das gemeinsam sauber aufsetzen und in Ihre Architektur einpassen: Kontakt aufnehmen.
Im fachlichen Umfeld spielen auch Delphi Rtti Mapping und Attribute Mapping Delphi eine wichtige Rolle, wenn Integrationen, Datenflüsse und Weiterentwicklung sauber zusammenspielen müssen.
Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.