Kto prevádzkuje existujúci obchodný softvér v Delphi, pozná toto napätie: na jednej strane chcete štruktúrované doménové objekty a jasné vrstvy, na druhej strane sú tu Datasets, Variants, CSV-importy, payloady rozhraní alebo REST-API, ktoré sa „nejako“ musia namapovať na objekty. Práve tu rýchlo narazíte na Delphi RTTI für Mapping ohne Magie: teda mapovanie pomocou Reflection (RTTI = Run-Time Type Information, typové informácie za behu), ale tak, aby to zostalo zrozumiteľné, dobre ladiť a aby sa nespoliehalo potajomky na konvencie alebo hru s názvami.
Jadro veci: „mágia“ vzniká väčšinou nie samotným RTTI, ale implicitnými pravidlami. Ak sú pravidlá mapovania explicitne uvedené v atribútoch, konverzie sú centralizované a chyby uvádzajú jasnú príčinu, stane sa RTTI nástrojom namiesto prekvapenia.
Prečo RTTI-mapovanie v Delphi často zlyhá
Mapovanie založené na RTTI v reálnych systémoch zriedka zlyháva kvôli myšlienke, skôr kvôli okrajovým podmienkam:
- Legacy formáty dát: Null/Empty/0 nie sú jasne oddelené, typy polí sa menia, reťazce obsahujú „N/A“.
- Postupné konvencie: „Pole sa volá ako Property“ funguje až do prvého aliasu, joinu alebo refaktorizovaného názvu Property.
- Ťažké na ladiť: Keď mapper „jednoducho nič nenastaví“, neskôr chýba dôvod. V prevádzke je to jed.
- Mýty o výkonnosti: RTTI je všeobecne označované za „pomalé“, hoci problémom býva zvyčajne chýbajúce cachovanie.
Udržateľný prístup by mal preto (1) mať explicitné metadáta mapovania, (2) jasne riešiť konverziu a nulovú sémantiku, (3) poskytovať chybové a debug výstupy a (4) cachovať RTTI-informácie.
Delphi RTTI pre mapovanie bez mágie: návrhové princípy
Nasledujúci vzor je zámerne „nudný“ v tom najlepšom zmysle: pravidlá sú viditeľné, vedľajšie účinky obmedzené a dá sa ho postupne zaviesť do existujúcich modulov.
- Atribúty namiesto menných konvencií: Property dostane atribút, ktorý pomenováva zdrojový stĺpec.
- Opt-in: Nastavujú sa len označené Properties. Žiadne prekvapenia z „všetkých zverejnených Properties“.
- Konverzia na jednom mieste: Variant/String/Integer/Boolean/Enum/Nullable sa mapujú centrálne.
- Debug režim: Voliteľne sa protokoluje, ktoré polia boli nastavené/preskočené – s uvedením dôvodu.
- Cachovanie RTTI: Najnákladnejšie časti (zoznam Properties, vyhodnocovanie atribútov) sa pripravujú pre každý typ.
Ukážka zdrojového kódu: Mapovanie atribútov s RTTI, cachovaním a debugom
Úryvok mapuje riadok (napr. z BDE-náhrada s natívnym prepojením cez TDataSet) na objekt. Namiesto pevného viazania mappera na TField používame malé rozhranie pre čítanie. To je v praxi cenné, pretože neskôr môžete tú istú logiku použiť aj pre JSON, INI, CSV alebo API-odpovede.
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// Explicitné mapovanie: vlastnosť <- názov zdroja
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
// Malá abstrakcia: poskytnúť hodnotu + rozlíšiť existenciu/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: len vlastnosti s atribútom
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('Konverzia na boolean zlyhala: "%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 mimo rozsahu: %d', [Ord]);
Exit(Ord);
end;
Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt('Názov enumu neznámy: "%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;
// Konverzia zámerne selektívna: radšej jasne zlyhať než ticho "nejako".
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('Mapovanie typu set nie je implementované pre %s', [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt('Mapovanie Class-Property nie je implementované pre %s', [AProp.Name]);
else
raise ERttiMappingError.CreateFmt('TypeKind nie je podporovaný (%s) pre %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 alebo Target je 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('Zdroj chýba: "%s" pre vlastnosť %s',
[M.SourceName, M.Prop.Name]);
Continue;
end;
if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Bez mechaniky Nullable/Optional nie je možné rozumne nastaviť NULL.
Continue;
end;
V := AReader.GetValue(M.SourceName);
try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format('Namapované %s <- %s (%s)', [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt('Chyba mapovania pri %s <- %s: %s',
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
Na čo to slúži
Dostanete mapovanie, ktoré sa dá v Code-Reviewoch spoľahlivo posúdiť:
- Každá mapovaná Property je vizuálne označená (atribút).
- Konverzia je centralizovaná, v dôsledku čoho konzistentná a testovateľná.
- Chybové hlásenia uvádzajú, ktorá Property a ktorý zdroj sú postihnuté.
- Debugovací režim vám v prípade potreby poskytne reťazec dôkazov, bez nutnosti nastavovať breakpoints v produkčnom procese.
Predpoklady a typické úskalia
- NULL-Semantik: Bez vlastného Nullable-konceptu (napr.
Nullable<T>alebo Option-Types) nie je „NULL nastaviť“ jednoznačné. V ukážke sa NULL štandardne preskočí. To je konzervatívne a zabraňuje tichým prepísaniam. - TRttiContext-Lebensdauer: Cache vytvárame raz pre typ a Context potom zahodíme. To je bežné. Dôležité je: netvorte nový RTTI-Context pri každom priradení poľa.
- Threading: Cache je chránený cez Monitor. Pri vysoko paralelných mapovaniach (napr. REST-Server) by ste mali zvážiť, či cache už pri štarte „warm“ nevytvoriť (preload), aby ste znížili lock-contention.
- PropertyType Kind:
tkClassatkSetsú zámerne neimplementované. Pre vnorené objekty mapujte rekurzívne (s jasnou politikou) alebo priraďte vedome manuálne. - Úskalia lokality (Locale):
varDoublecezVarAsTypeje relatívne robustné, ale reťazce ako „1,23″ vs. „1.23″ sú stále problém. Ak vaše zdroje vracajú stringy, je často lepší vlastný parser (s definovanou Culture).
Varianta pre FireDAC a TDataSet: Reader-Adapter namiesto prepojenia s mapperom
V BDE-Ablosung mit nativer Anbindung- alebo klasických VCL/Win32 aplikáciách je zdroj často TDataSet. Namiesto viazania Mapperu na TField napíšte adaptér, ktorý implementuje rozhranie IValueReader. Výhoda: Mapper zostáva nezávislý od prístupu k dátam (dôležité, ak prístup k dátam neskôr presuniete do služieb alebo na 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;
Konkrétne mapovanie potom vyzerá takto:
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;
Kde sa prístup oplatí — a kde nie
Tento vzor sa obvykle oplatí v troch situáciách:
- Kroková modernizácia: Chcete zaviesť doménové objekty, bez toho aby ste okamžite kompletne prerábali prístup k dátam (klasicky pri Delphi modernizácia v existujúcich aplikáciách).
- Spojnicové body: CSV-/Excel-importy, REST-payloady alebo „zmiešané“ zdroje dát potrebujú robustnú konverziu a kvalitné chybové hlásenia.
- Udržiavateľnosť v tíme: Atribúty robia pravidlá mapovania viditeľnými a prehľadnými pri revízii, čo je v rozsiahlych kódových bárach veľmi cenné.
Existujú aj jasné hranice použitia:
- Komplexné grafy objektov (podriadené kolekcie, cyklické referencie) by ste nemali „automagicky“ mapovať. Tu je explicitný kód alebo oddelený Assembler/Factory-vzor zvyčajne stabilnejší.
- Hotpaths s vysokou priepustnosťou (napr. ETL pre hromadné dáta) majú skôr úžitok z kódom generovaných mapperov alebo ručne optimalizovaného mapovania, aj keď je RTTI cachované.
- Nullable/Optional je samostatná téma. Ak potrebujete skutočne rozlíšiť medzi „neprítomné“, „NULL“ a „predvolená hodnota“, mali by ste to vyjadriť v doménovom modeli, nie skrývať v mapperi.
Zaradenie do architektúry a prevádzky
Z architektonického hľadiska je tento mapper infraštruktúrnou komponentou na rozhraní medzi reprezentáciou dát a doménou. Nenahrádza čisté vrstvenie, môže ho však umožniť: Prístup k dátam (FireDAC, SQL, Views) môže zostať pragmatický, zatiaľ čo doména zostáva konzistentná. Vo viacvrstvových systémoch (často označovaných ako Layer-3 architektúra: UI, Domain/Services, Infrastruktur) patrí mapper do infraštruktúry a používajú ho služby, nie UI formuláre.
Prevádzková dôležitosť: Neaktivujte moDebug trvale v produkčných službách, ale cielene. Pre ťažko reprodukovateľné dátové problémy je vhodné mať prepínateľnú diagnostickú cestu (konfigurácia, Feature-Flag). Inak hrozí objem logov a nežiaduce vedľajšie účinky.
Záver: RTTI áno, ale len s jasnými vodiacimi zásadami
Delphi RTTI pre mapovanie bez mágie funguje dobre, keď používate RTTI ako nástroj pre deklaratívne metadáta – nie ako pozvánku k tichým heuristikám. Atribúty ako opt-in, centralizovaná konverzia, cache pre typ a zrozumiteľné chybové hlásenia posúvajú tému z „neprehľadnej“ na „prevádzkyschopnú“. Prístup nie je úmyselne univerzálny: pre vnorené grafy, prísnu nulovú sémantiku alebo maximálny výkon potrebujete ďalšie komponenty. Ako robustný most medzi dataset/legacy štruktúrami a modernejšími doménovými objektami je však v mnohých Delphi kódbázach práve ten pragmatický krok, ktorý modernizáciu vôbec umožní.
Ak v existujúcej Delphi aplikácii práve narazíte na hranách mapovania, problémy s kvalitou dát alebo pri postupnej modernizácii, môžeme to spoločne čisto nastaviť a zapracovať do vašej architektúry: Kontaktujte nás.
V odbornom kontexte zohrávajú dôležitú úlohu aj Delphi Rtti Mapping a Attribute Mapping Delphi, keď musia integrácie, dátové toky a ďalší vývoj hladko spolupracovať.