From magazine topic to project implementation
Relevant service and technical pages for this post
Der typische Moment, in dem Delphi RTTI für Mapping ohne Magie plötzlich interessant wird, ist kein Architektur-Workshop – sondern eine Fehlermeldung aus dem Betrieb: „Seit dem View-Update sind bei einigen Kunden die Beträge 0, und niemand weiß warum.“ In der Datenbank war es nur ein Alias-Wechsel (sum_total statt total_sum), im Code gab es eine Konvention („Feldname = Propertyname“) und der Mapper hat stillschweigend nichts gesetzt. Kein Crash, kein Logeintrag, nur falsche Domänenzustände. Genau diese Art Fehler ist teurer als ein sauberer, expliziter Mapper.
RTTI (Run-Time Type Information, Typinformationen zur Laufzeit) ist dafür ein brauchbares Werkzeug – aber nur, wenn Sie die Regeln sichtbar machen, Konvertierungen zentralisieren und Debugbarkeit einbauen. Der Rest dieses Beitrags ist eine Abwägungskarte: Was gewinnt man, was kostet es, und wie sieht ein Mapping-Plan aus, der in Legacy-Situationen nicht sofort kippt.
Trade-off-Karte: RTTI-Mapping ohne Magie vs. Alternativen
| Ansatz | Stärken | Typische Kippstellen | Wann ich ihn nehme |
|---|---|---|---|
| Handgeschriebenes Mapping | Maximal kontrollierbar, schnell, gut zu profilieren | Viel Boilerplate, Drift bei vielen DTOs | Hotpaths, kritische ETL-Strecken, kleine Modelle |
| Konventionen (Name=Name) | Schnell gestartet, wenig Code | Alias/Join/Refactoring bricht still; Debug schwierig | Nur für Prototypen oder streng kontrollierte Schemas |
| ORM (vollautomatisch) | Komfort, Change Tracking, Query-Abstraktion | Magie, Nebenwirkungen, schwer zu begrenzen | Wenn ORM bewusst eingeführt und betrieben wird |
| RTTI + Attribute (Opt-in) + Mapping-Plan | Explizite Regeln, weniger Boilerplate, gute Diagnose | Konvertierungsregeln müssen klar sein; RTTI-Caching nötig | Gewachsene Business-Software, Schnittstellenkanten, Modernisierung |
Die Leitentscheidung ist: Ein Mapper darf nie „still korrekt aussehen“. Wenn er nicht mappen kann, muss er entweder klar fehlschlagen oder mindestens erklärbar protokollieren. Sonst produzieren Sie Datenfehler, die erst Wochen später auffallen.
Delphi RTTI für Mapping ohne Magie: Prinzipien, die den Betrieb überleben
1) Opt-in per Attribut statt impliziter Konvention
„Magie“ entsteht meist durch implizite Regeln. Der Kerntrick ist banal: Nur Properties mit Attribut werden gemappt. Damit ist im Review sichtbar, was überhaupt Teil des Mappings ist, und Umbenennungen treffen Sie nicht zufällig.
2) Mapping-Plan bauen, dann ausführen (statt RTTI pro Datensatz)
RTTI ist nicht per se langsam, aber wiederholte Metadaten-Auswertung kostet. Delphi dokumentiert selbst, dass RTTI über System.Rtti zur Laufzeit Metadaten bereitstellt; nutzen Sie das als Bauphase und cachen Sie das Ergebnis pro Klasse. (Einordnung: RTTI in Delphi ist die Standard-Reflection-API über TRttiContext, TRttiType, TRttiProperty.) Siehe Delphi-Doku zu RTTI in Delphi.
3) Konvertierung ist Infrastruktur, nicht „mal eben VarAsType“
Variant-Konvertierung wirkt bequem, aber sie verschleiert Null-Semantik, Locale-Probleme und abweichende Boolean-/Enum-Codierungen. Deshalb: ein zentraler Konverter, der bewusst nur unterstützte Typen annimmt und ansonsten mit klarer Fehlermeldung abbricht. Das ist weniger „flexibel“, aber wesentlich leichter zu debuggen.
Source-Schnipsel: Mapping-Plan mit RTTI, Cache, Reader-Adapter und Debug
Das folgende Snippet ist absichtlich „unaufgeregt“: ein Attribut, ein Reader-Interface, ein Mapper mit Typ-Cache. Wichtiger als das reine Mapping ist die Fehlerführung: Wenn etwas nicht passt, steht im Fehlertext welche Property, welcher Quellname und welcher Zieltyp betroffen war.
unit NbRttiMap;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
IValueReader = interface
['{E7A76C4E-5E3E-4F2D-8F7B-5F92C2F6A6D3}']
function HasValue(const AName: string): Boolean; // Feld vorhanden?
function IsNull(const AName: string): Boolean; // Feld ist NULL?
function GetValue(const AName: string): Variant; // Wert (Variant)
end;
TRttiMapOptions = set of (moDebug, moIgnoreMissing, moFailOnNull);
ERttiMapError = class(Exception);
TRttiMapper = class
private
type
TPropMap = record
Prop: TRttiProperty;
SourceName: string;
end;
TTypeCache = class
public
Props: TArray<TPropMap>;
end;
private
class var FCache: TObjectDictionary<PTypeInfo, TTypeCache>;
class var FLock: TObject;
class function BuildCache(ATypeInfo: PTypeInfo): TTypeCache; static;
class function GetCache(ATypeInfo: PTypeInfo): TTypeCache; static;
class function MapNameOf(const P: TRttiProperty): string; static;
class function VariantToBoolean(const V: Variant): Boolean; static;
class function VariantToEnumOrdinal(AEnumType: TRttiType; const V: Variant): Integer; static;
class procedure SetPropValue(const Target: TObject; const P: TRttiProperty; const V: Variant); static;
public
class constructor Create;
class destructor Destroy;
class procedure MapToObject(const Reader: IValueReader; const Target: TObject;
const Options: TRttiMapOptions = [moIgnoreMissing]); static;
end;
implementation
constructor MapFromAttribute.Create(const AName: string);
begin
inherited Create;
FName := AName;
end;
class constructor TRttiMapper.Create;
begin
FCache := TObjectDictionary<PTypeInfo, TTypeCache>.Create([doOwnsValues]);
FLock := TObject.Create;
end;
class destructor TRttiMapper.Destroy;
begin
FCache.Free;
FLock.Free;
end;
class function TRttiMapper.MapNameOf(const P: TRttiProperty): string;
var
A: TCustomAttribute;
begin
Result := '';
for A in P.GetAttributes do
if A is MapFromAttribute then
Exit(MapFromAttribute(A).Name);
end;
class function TRttiMapper.BuildCache(ATypeInfo: PTypeInfo): TTypeCache;
var
Ctx: TRttiContext;
T: TRttiType;
P: TRttiProperty;
L: TList<TPropMap>;
M: TPropMap;
Src: string;
begin
Result := TTypeCache.Create;
Ctx := TRttiContext.Create;
T := Ctx.GetType(ATypeInfo);
L := TList<TPropMap>.Create;
try
for P in T.GetProperties do
begin
if not P.IsWritable then
Continue;
Src := MapNameOf(P);
if Src = '' then
Continue; // Opt-in
M.Prop := P;
M.SourceName := Src;
L.Add(M);
end;
Result.Props := L.ToArray;
finally
L.Free;
end;
end;
class function TRttiMapper.GetCache(ATypeInfo: PTypeInfo): TTypeCache;
begin
TMonitor.Enter(FLock);
try
if not FCache.TryGetValue(ATypeInfo, Result) then
begin
Result := BuildCache(ATypeInfo);
FCache.Add(ATypeInfo, Result);
end;
finally
TMonitor.Exit(FLock);
end;
end;
class function TRttiMapper.VariantToBoolean(const V: Variant): Boolean;
var
S: string;
begin
if VarIsBool(V) then
Exit(Boolean(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 ERttiMapError.CreateFmt('Boolean-Konvertierung fehlgeschlagen: "%s"', [VarToStr(V)]);
end;
class function TRttiMapper.VariantToEnumOrdinal(AEnumType: TRttiType; const V: Variant): Integer;
var
OrdV: Integer;
Name: string;
TD: PTypeData;
begin
TD := GetTypeData(AEnumType.Handle);
if VarIsNumeric(V) then
begin
OrdV := Integer(V);
if (OrdV < TD^.MinValue) or (OrdV > TD^.MaxValue) then
raise ERttiMapError.CreateFmt('Enum-Ordinal außerhalb Bereich: %d', [OrdV]);
Exit(OrdV);
end;
Name := VarToStr(V);
OrdV := GetEnumValue(AEnumType.Handle, Name);
if OrdV < 0 then
raise ERttiMapError.CreateFmt('Enum-Name unbekannt: "%s"', [Name]);
Result := OrdV;
end;
class procedure TRttiMapper.SetPropValue(const Target: TObject; const P: TRttiProperty; const V: Variant);
var
T: TRttiType;
TV: TValue;
OrdV: Integer;
begin
T := P.PropertyType;
case T.TypeKind of
tkUString, tkString, tkLString, tkWString:
TV := TValue.From<string>(VarToStr(V));
tkInteger:
TV := TValue.From<Integer>(VarAsType(V, varInteger));
tkInt64:
TV := TValue.From<Int64>(VarAsType(V, varInt64));
tkFloat:
TV := TValue.From<Double>(VarAsType(V, varDouble));
tkEnumeration:
begin
if T.Handle = TypeInfo(Boolean) then
TV := TValue.From<Boolean>(VariantToBoolean(V))
else
begin
OrdV := VariantToEnumOrdinal(T, V);
TV := TValue.FromOrdinal(T.Handle, OrdV);
end;
end;
else
raise ERttiMapError.CreateFmt('Nicht unterstützter Zieltyp (%s) für Property %s',
[GetEnumName(TypeInfo(TTypeKind), Ord(T.TypeKind)), P.Name]);
end;
P.SetValue(Target, TV);
end;
class procedure TRttiMapper.MapToObject(const Reader: IValueReader; const Target: TObject;
const Options: TRttiMapOptions);
var
C: TTypeCache;
M: TPropMap;
V: Variant;
Info: string;
begin
if (Reader = nil) or (Target = nil) then
raise ERttiMapError.Create('MapToObject: Reader oder Target ist nil');
C := GetCache(Target.ClassInfo);
for M in C.Props do
begin
if not Reader.HasValue(M.SourceName) then
begin
if not (moIgnoreMissing in Options) then
raise ERttiMapError.CreateFmt('Quelle fehlt: "%s" für Property %s', [M.SourceName, M.Prop.Name]);
Continue;
end;
if Reader.IsNull(M.SourceName) then
begin
if moFailOnNull in Options then
raise ERttiMapError.CreateFmt('Quelle ist NULL: "%s" für Property %s', [M.SourceName, M.Prop.Name]);
Continue;
end;
V := Reader.GetValue(M.SourceName);
try
SetPropValue(Target, M.Prop, V);
if moDebug in Options then
begin
Info := Format('Mapped %s <- %s (VariantType=%d)', [M.Prop.Name, M.SourceName, VarType(V)]);
OutputDebugString(PChar(Info));
end;
except
on E: Exception do
raise ERttiMapError.CreateFmt('Mapping-Fehler %s <- %s: %s', [M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
Zweck und Randbedingungen
- Explizit: Ohne
[MapFrom('...')]wird nichts gesetzt. - Cache: Pro Klasse wird ein Mapping-Plan gebaut und wiederverwendet. Ohne Cache wird RTTI schnell zum Hotspot.
- Fehlertexte: Die Exception nennt Quelle und Ziel, damit man im Betrieb überhaupt eine Chance hat.
Stolperfallen (bewusst nicht „wegabstrahiert“)
- NULL-Semantik: Das Snippet überspringt NULL standardmäßig. Wenn NULL als Zustand wichtig ist, müssen Sie im Modell nachziehen (Nullable/Optional oder Flags).
- Locale:
VarAsType(..., varDouble)ist robust für echte numerische Variants, aber nicht für kulturabhängige Zahlenstrings. Wenn Ihre Quellen Strings liefern, definieren Sie einen Parser mit fester Kultur. - Komplexe Typen: Klassen-Properties, Records, Sets, Collections sind absichtlich nicht automatisch gemappt. Dort kippt „Automatik“ erfahrungsgemäß zuerst.
Adapter für Dataset/FireDAC: entkoppeln statt verheiraten
In VCL- und Service-Code ist die Quelle oft TDataSet (BDE-Ablosung mit nativer Anbindung oder klassisch). Statt den Mapper direkt von TField abhängig zu machen, schreiben Sie einen Reader-Adapter. So können Sie später denselben Mapper auch für JSON/CSV verwenden. (Einordnung: BDE-Ablosung mit nativer Anbindung ist die Datenzugriffsbibliothek von RAD Studio; TDataSet ist die Basisklasse des Dataset-Modells in Delphi.)
unit NbDataSetReader;
interface
uses
Data.DB, System.Variants, NbRttiMap;
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;
implementation
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;
end.
So wird es verwendet
type
TOrderState = (osOpen, osClosed);
TOrderRow = class
private
FId: Int64;
FCustomerNo: string;
FIsClosed: Boolean;
FState: TOrderState;
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;
[MapFrom('state')]
property State: TOrderState read FState write FState; // z. B. 'osClosed' oder 1
end;
var
Row: TOrderRow;
Reader: IValueReader;
begin
Row := TOrderRow.Create;
try
Reader := TDataSetValueReader.Create(MyQuery);
TRttiMapper.MapToObject(Reader, Row, [moIgnoreMissing, moDebug]);
finally
Row.Free;
end;
end;
Vier Betriebsmodi als Entscheidungspfad (damit es nicht kippt)
- Entwicklung:
moDebugan,moIgnoreMissingaus. Ziel: Schema-Probleme früh knallen lassen. - Test/Staging: Debug selektiv an, Missing/NULL je nach Vertrag. Ziel: driftende Datenquellen sichtbar machen.
- Produktion (stabil): Debug aus, Missing nach Vertrag. Ziel: keine Logflut, aber klare Exceptions bei echten Vertragsbrüchen.
- Produktion (Diagnosefenster): Debug kurzfristig an (Feature-Flag), begrenzt auf einen Vorgang/Correlation-ID. Ziel: reproduzierbare Beweiskette ohne Remote-Debugging.
Ein praxisnaher Anker: Wenn Sie ohnehin an Mapping-Kanten arbeiten, lohnt sich oft der Blick auf robuste Dataset-zu-Objekt-Strategien mit Legacy-Randfällen, z. B. bei Alias-Spalten und optionalen Spaltenlisten. Dazu passt inhaltlich auch Dataset-zu-Objekt Mapping für ungewöhnliche Altstrukturen.
Genau eine redaktionelle Einschätzung
Wenn ich mich zwischen „schnell per Konvention mappen“ und „RTTI mit Opt-in und Diagnose“ entscheiden muss, bevorzuge ich in gewachsenen Codebasen fast immer die zweite Variante – nicht wegen Eleganz, sondern weil stilles Nicht-Mappen im Betrieb zu den unangenehmsten Fehlerklassen gehört: fachlich falsche Daten ohne technischen Alarm.
Grenzen, Risiken und Varianten
Performance und Lock-Contention
Der Cache ist per Monitor geschützt. In hochparallelen Services kann das beim ersten Zugriff pro Typ kurz contention erzeugen. Variante: Cache beim Start „warm“ bauen (alle relevanten Typen einmal anfassen) oder den Cache-Aufbau getrennt synchronisieren.
Thread-Sicherheit von TRttiContext
Hier wird TRttiContext nur in BuildCache lokal verwendet. Das reduziert Risiko und hält den Kontext aus Hotpaths raus. Grundsätzlich gilt: RTTI-Objekte sollten nicht quer durch Threads „wild“ herumgereicht werden; bauen Sie Pläne und arbeiten Sie mit stabilen Referenzen.
Nullable/Optional sauber modellieren
Wenn Sie zwischen „NULL“ und „Default“ unterscheiden müssen (z. B. 0 ist fachlich relevant), reicht „NULL überspringen“ nicht. Dann braucht es ein Domänenmodell, das Abwesenheit ausdrückt (Nullable/Option) oder getrennte Felder/Flags. Der Mapper sollte diese Entscheidung nicht verstecken.
Erweiterung: Custom-Konverter pro Property
Ein häufig sinnvoller Ausbau ist ein zusätzliches Attribut wie [MapWith(TMyConverter)], um einzelne Randfälle (z. B. Datum als ISO-String, Betrag als „1,23“) explizit zu lösen, ohne die globale Konvertierung zu verwässern. Wichtig: Converter müssen deterministisch sein und klare Fehlermeldungen liefern.
Fazit: RTTI ist nicht das Problem – implizite Regeln sind es
Delphi RTTI für Mapping ohne Magie lohnt sich, wenn Sie Mapping als Vertrag zwischen Datenquelle und Domänenmodell behandeln: Opt-in über Attribute, ein gecachter Mapping-Plan, kontrollierte Konvertierung und ein Debug-Pfad, der im Betrieb wirklich hilft. Es ist kein Ersatz für saubere Domänenmodellierung und auch kein Freifahrtschein für komplexe Objektgraphen. Aber als robuste Brücke in der Legacy-Modernisierung und an Schnittstellenkanten ist der Ansatz oft genau die Art „wenig Magie“, die Sie langfristig schneller macht.
Wenn Sie so ein Mapping-Muster in eine gewachsene Delphi-Anwendung einpassen, inklusive Null-Semantik, Diagnosepfad und Architekturgrenzen, können Sie das mit uns konkret besprechen: .
Für dieses Thema sind auch Attribute Mapping Delphi und Dataset Zu Objekt Mapping Delphi wichtig. Der Beitrag ordnet diese Aspekte verständlich ein und zeigt, worauf es im Alltag ankommt.
Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.
Next step
When the topic becomes an actual project, architecture, existing systems and operations should be considered together from the outset.
We support not only with individual issues, but also when source snippets, legacy topics, or portal ideas are to be turned into a robust enterprise project.
- Current state, target state and technical risks are assessed jointly.
- REST, data access, portals and rollout are not deferred to a later stage as secondary consequences.
- You can see early on which path is economically and operationally viable.