Kdor upravlja obstoječo poslovno programsko opremo v Delphi, pozna napetostno polje: po eni strani želiš strukturirane domenske objekte in jasne plasti, po drugi strani pa so tu Datasets, Variants, CSV-uvozi, payloadi vmesnikov ali ena REST-API, ki se »nekako« morajo mapirati na objekte. Ravno tu hitro pridemo do Delphi RTTI für Mapping ohne Magie: torej mapiranje z Reflection (RTTI = Run-Time Type Information, informacije o tipu med izvajanjem), vendar tako, da ostane sledljivo, dobro razhroščevalno in ne temelji prikrito na konvencijah ali igranju z imeni.
Ključna točka: »magija« običajno ne nastane zaradi RTTI samega, temveč zaradi implicitnih pravil. Če so pravila mapiranja eksplicitno v atributih, so pretvorbe centralizirane in napake navedejo jasen vzrok, RTTI postane orodje namesto presenečenja.
Zakaj RTTI-mapiranje v Delphi pogosto ne uspe
RTTI-podprto mapiranje v realnih sistemih redko spodleti zaradi ideje, temveč zaradi omejitev:
- Legacy podatkovne oblike: Null/Empty/0 niso jasno ločene, tipi polj se spreminjajo, nizi vsebujejo »N/A«.
- Tiho uvedene konvencije: »Polje ima isto ime kot Property« deluje do prvega aliasa, joina ali refaktoriranega imena Property.
- Težko za razhroščevanje: Če mapper »preprosto ničesar ne nastavi«, kasneje manjka vzrok. V produkcijskem okolju je to usodno.
- Miti o zmogljivosti: RTTI je pogosto odpisan kot »počasen«, čeprav je običajno pomanjkanje predpomnjenja tisto, kar povzroča težave.
Vzdržen pristop bi zato moral (1) imeti eksplicitne metapodatke mapiranja, (2) jasno obravnavati pretvorbo in semantiko null-vrednosti, (3) zagotavljati napake in izpise za razhroščevanje ter (4) predpomniti RTTI-informacije.
Delphi RTTI za mapiranje brez magije: zasnovna načela
Naslednji vzorec je namenoma »dolgočasen« v najboljšem pomenu: pravila so vidna, stranski učinki omejeni in ga je mogoče postopoma vpeljati v obstoječe module.
- Atributi namesto imenskih konvencij: Property dobi atribut, ki navede izvorni stolpec.
- Opt-in: Nastavijo se samo označene Properties. Brez presenečenj zaradi »vseh objavljenih Properties«.
- Pretvorba na enem mestu: Variant/String/Integer/Boolean/Enum/Nullable se centralno mapirajo.
- Debug-Modus: Po želji se beleži, katera polja so bila nastavljena/preskočena – z razlago.
- RTTI-predpomnjenje: Najdražji deli (seznam Properties, evalvacija atributov) se pripravijo za vsak tip.
Izsek kode: mapiranje preko atributov z RTTI, predpomnjenjem in razhroščevanjem
Ta izsek preslika eno vrstico (npr. iz BDE-zamenjava z nativno povezavo prek TDataSet) na objekt. Namesto da mapper tesno povežemo z TField, uporabimo majhen reader-vmesnik. To je v praksi koristno, ker jo boste kasneje lahko uporabili tudi za JSON, INI, CSV ali API-odzive.
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// Eksplizitno mapiranje: lastnost <- ime vira
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
// Majhna abstrakcija: vrne vrednost in loči obstoj/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: samo lastnosti z atributom
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(‚Pretvorba v Boolean ni uspela: „%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 izven območja: %d‘, [Ord]);
Exit(Ord);
end;
Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt(‚Ime enum-a ni znano: „%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;
// Pretvorba je namerno selektivna: raje jasno zataji kot tiho „nekako“.
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(‚Mapiranje tipa Set ni implementirano za %s‘, [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt(‚Mapiranje lastnosti razreda ni implementirano za %s‘, [AProp.Name]);
else
raise ERttiMappingError.CreateFmt(‚TypeKind ni podprt (%s) za %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 ali 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(‚Vir manjka: „%s“ za lastnost %s‘,
[M.SourceName, M.Prop.Name]);
Continue;
end;
if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Brez mehanike Nullable/Optional ni smiselno nastavljati NULL.
Continue;
end;
V := AReader.GetValue(M.SourceName);
try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format(‚Mapirano %s <- %s (%s)‘, [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt(‚Napaka mapiranja pri %s <- %s: %s‘,
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
Zakaj je to koristno
Dobite mapiranje, ki ga je v pregledih kode (Code-Reviews) mogoče jasno ovrednotiti:
- Vsaka preslikana Property je vizualno označena (atribut).
- Konverzija je centralizirana, zato je dosledna in preverljiva s testi.
- Sporočila o napakah povedo, katera Property in kateri vir sta prizadeta.
- Način za odpravljanje napak vam v primeru dvoma prikaže verigo dokazov, brez potrebe po Breakpoints v produkcijskem procesu.
Robni pogoji in tipične pasti
- NULL-Semantik: Brez lastnega Nullable-koncepta (npr.
Nullable<T>ali Option-Types) ni enoznačno, kaj pomeni „NULL setzen“. V snippetu se NULL privzeto preskoči. To je konservativno in preprečuje tiho prepisovanje. - TRttiContext-Lebensdauer: Predpomnilnik zgradimo enkrat na tip in nato kontekst zavržemo. To je običajno. Pomembno: ne ustvarjajte novega RTTI-konteksta za vsako dodelitev polja.
- Threading: Predpomnilnik je zaščiten z Monitorjem. Pri visoko vzporednih mapiranjih (npr. REST-strežnik) bi morali dodatno preveriti, ali predpomnilnik že ob zagonu „ogrejete“ (Preload), da zmanjšate konkurenco pri zaklepanju.
- PropertyType Kind:
tkClassintkSetsta namensko nerealizirana. Za vdelane objekte jih bodisi rekurzivno mapirajte (z jasno politiko) ali pa jih zavestno dodelite ročno. - Locale-Fallen:
varDoubleprekoVarAsTypeje razmeroma robustno, vendar so nizi, kot so „1,23“ proti „1.23“, še vedno problem. Če vaši viri vračajo nize, je pogosto bolje uporabiti lasten parser (s definirano kulturo).
Varianta za FireDAC in TDataSet: Reader-Adapter namesto povezave Mapperja
V aplikacijah BDE-Ablosung mit nativer Anbindung ali klasičnih VCL/Win32 je vir pogosto TDataSet. Namesto da mapper vežete na TField, napišite adapter, ki implementira vmesnik IValueReader. Prednost: mapper ostane neodvisen od dostopa do podatkov (pomembno, če boste kasneje dostop do podatkov preselili v storitve ali na REST-strežnik).
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;
Tako izgleda konkretno mapiranje:
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;
Kje se pristop izplača – in kje ne
Ta vzorec se običajno izplača v treh situacijah:
- Postopna modernizacija: Želite uvesti domenske objekte, ne da bi takoj popolnoma prenovili dostop do podatkov (tipično pri Delphi modernizaciji v obstoječih aplikacijah).
- Robne točke vmesnikov: CSV-/Excel-uvozi, REST-payloadi ali »mešani« viri podatkov zahtevajo robustno konverzijo in jasna sporočila o napakah.
- Vzdržnost v ekipi: Atributi naredijo pravila mapiranja vidna in pregledna, kar je v večjih kodnih bazah izjemno dragoceno.
Obstajajo tudi jasne omejitve uporabe:
- Kompleksni objektni grafi (Child-Collections, ciklične reference) se ne smejo »automagično« mapirati. Tukaj je eksplicitna koda ali ločen assembler/factory vzorec običajno bolj stabilen.
- Hotpathi z visokim pretokom (npr. ETL za množične podatke) prej pridobijo z uporabo iz kode generiranih mapperjev ali ročno optimiziranega mapiranja, tudi če je RTTI predpomnjen.
- Nullable/Optional je ločeno vprašanje. Če morate res razlikovati med »ni prisotno«, »NULL« in »privzeto«, naj bo to izraženo v domenskem modelu, ne skrito v mapperju.
Položaj v arhitekturi in obratovanju
Z arhitekturnega vidika je ta mapper infrastrukturna komponenta na meji med predstavitvijo podatkov in domeno. Ne nadomešča čiste plastične razporeditve, lahko pa jo omogoča: dostop do podatkov (FireDAC, SQL, Views) sme ostati pragmatičen, medtem ko domena ostane konsistentna. V večplastnih sistemih (pogosto imenovanih Layer-3 arhitektura: UI, domena/storitve, infrastruktura) pripada mapper infrastrukturi in ga uporabljajo storitve, ne UI-obrazci.
Za obratovanje pomembno: Ne puščajte moDebug trajno vključenega v produkcijskih servisih, ampak ga vklapljajte selektivno. Pri težko reproducirnih težavah s podatki je smiselno imeti preklopno diagnostično pot (konfiguracija, Feature-Flag). V nasprotnem primeru grozi velik obseg logov in neželeni stranski učinki.
Zaključek: RTTI da, vendar le z jasnimi smernicami
Delphi RTTI za mapiranje brez magije deluje dobro, ko RTTI uporabite kot orodje za deklarativne metapodatke – ne kot povabilo k tihim heuristikam. Atributi kot opt-in, centralizirana pretvorba, predpomnilnik na tip in razumljiva besedila o napakah premaknejo temo iz „nepregledno“ v „operativno uporabno“. Pristop je zavestno neuniverzalen: za gnezdene grafe, strogo ničelno semantiko ali maksimalno zmogljivost potrebujete dodatne komponente. Kot robusten most med strukturami dataset/legacy in modernejšimi domenskimi objekti pa je v mnogih Delphi-kodbazah prav ta pragmatičen korak, ki modernizacijo sploh omogoči.
Če se v obstoječi Delphi-aplikaciji trenutno zataknete pri mejah mapiranja, kakovosti podatkov ali postopni modernizaciji, lahko to skupaj dosledno vzpostavimo in prilagodimo vaši arhitekturi: Vzpostavite stik.
V strokovnem okolju imata tudi Delphi RTTI-mapiranje in mapiranje atributov Delphi pomembno vlogo, kadar morajo integracije, podatkovni tokovi in nadaljnji razvoj tesno usklajeno delovati.