Kes haldab kasvanud ärirakendust Delphi, tunneb seda pingevälja: ühel pool soovitakse struktureeritud domeeniobjekte ja selgeid kihte, teisel pool on Datasets, Variants, CSV-impordid, liidese payload’id või REST-API, mis tuleb „mingil moel“ objektidele kaardistada. Just siin jõutakse kiiresti Delphi RTTI kaardistamiseni ilma maagita: ehk kaardistamine peegelduse abil (RTTI = Run-Time Type Information, tüübiinfo käituse ajal), kuid nii, et see jääb jälgitavaks, hästi debugitavaks ega sõltu salaja konventsioonidest või nimenippidest.
Oluline punkt: „maagia“ ei teki tavaliselt RTTI-st enesest, vaid implitsiitsetest reeglitest. Kui kaardistamisreeglid on seevastu selgelt atribuutides kirjas, teisendused tsentraliseeritud ja vead osutavad konkreetsele põhjusile, muutub RTTI tööriistaks, mitte ootamatuseks.
Miks RTTI-põhine kaardistamine Delphi puhul sageli ebaõnnestub
RTTI-põhine kaardistamine ei hääbu reaalses süsteemis tavaliselt idee tõttu, vaid ääretingimuste pärast:
- Legacy-andmete vormid: Null/Empty/0 ei ole selgelt eristatud, väljatüübid muutuvad, stringid sisaldavad „N/A“.
- Võõravad konventsioonid: „Väli kannab sama nime mis Property“ töötab kuni esimese alias’i, join’i või refaktoreeritud Property-nimeni.
- Raskesti debugitav: Kui mapper „lihtsalt midagi ei sea“, puudub hiljem põhjus. Tootmises on see laastav.
- Jõudlusmüüdid: RTTI tembeldatakse üldiselt kui „aeglane“, kuigi sageli on probleemiks vahemällu salvestuse puudumine.
Hoonekindel lähenemine peaks seetõttu (1) sisaldama eksplitsiitseid kaardistamise metainfosid, (2) selgelt käsitlema teisendusi ja null-semantikat, (3) tooma välja vead ja debug-väljundid ning (4) vahemällu salvestama RTTI-infot.
Delphi RTTI kaardistamine ilma maagita: disainiprintsiibid
Järgmine muster on teadlikult „igav“ parimas tähenduses: reeglid on nähtavad, kõrvalmõjud piiratud ja seda saab samm-sammult olemasolevatesse moodulitesse integreerida.
- Atribuudid, mitte nimenõuded: Property-le lisatakse atribuut, mis nimetab lähtekolonni.
- Opt-in: Ainult märgitud Properties seatakse. Ei üllatusi „kõigi avaldatud Properties’idega“.
- Teisendus ühes kohas: Variant/String/Integer/Boolean/Enum/Nullable kaardistatakse tsentraalselt.
- Debug-režiim: Valikuline logimine, milliseid välju seatakse või jäetakse vahele – koos põhjendusega.
- RTTI-vahemälu: Kõige kulukamad osad (omaduste nimekiri, atribuutide hindamine) valmistatakse ette per-tüübi alusel.
Source-Schnipsel: Attribut-Mapping mit RTTI, Caching und Debug
Koodilõik kaardistab rea (nt BDE-asendamine natiivse ühendusega via TDataSet) objektile. Selle asemel, et mapperit tugevalt TField-iga siduda, kasutame väikest Reader-liidest. See on praktikas väärtuslik, sest hiljem saab sama loogikat kasutada ka JSON-i, INI, CSV või API-vastuste puhul.
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// Eksplitsiitne mappimine: Property <- allika nimi
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
// Väike abstraktsioon: väärtuse tagastamine + olemasolu/NULL-i eristamine
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-konverteerimine ebaõnnestus: "%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-ordinaal väljaspool vahemikku: %d', [Ord]);
Exit(Ord);
end;
Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt('Enum-i nimi tundmatu: "%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;
// Konverteerimine on teadlikult selektiivne: parem ebaõnnestuda selgelt kui vaikides 'mingil moel'.
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-mappimine ei ole implementeeritud %s jaoks', [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt('Class-Property mappimine ei ole implementeeritud %s jaoks', [AProp.Name]);
else
raise ERttiMappingError.CreateFmt('TypeKind ei ole toetatud (%s) jaoks %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 või Target on 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('Allikas puudub: "%s" jaoks Property %s',
[M.SourceName, M.Prop.Name]);
Continue;
end;
if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Ilma Nullable/Optional-mehhanismita ei saa NULL-i mõistlikult määrata.
Continue;
end;
V := AReader.GetValue(M.SourceName);
try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format('Kaardistatud %s <- %s (%s)', [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt('Mappimise viga %s <- %s: %s',
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
Milleks see vajalik on
Saate mappingu, mida saab koodikontrollides selgelt hinnata:
- Iga kaardistatud Property on visuaalselt märgitud (Attribut).
- Konverteerimine on tsentraalne, mistõttu järjepidev ja testitav.
- Veateated näitavad, milline Property ja milline allikas on mõjutatud.
- Debug-režiim annab vajadusel tõendusketi, ilma et peaksite tootmisprotsessis breakpoints’i kasutama.
Piirtingimused ja tüüpilised komistuskohad
- NULL-semantika: Ilma enda Nullable-kontseptsioonita (nt
Nullable<T>või Option-Types) ei ole „NULL määramine“ üheselt mõistetav. Näites jäetakse NULL vaikimisi vahele. See on konservatiivne ja väldib vaikseid ülekirjutusi. - TRttiContext-Lebensdauer: Me ehitame cache’i üks kord tüübi kohta ja viskame Contexti seejärel ära. See on tavapärane. Oluline: ära ehita iga välja määramise jaoks uut RTTI-Context’i.
- Threading: Cache on Monitori abil kaitstud. Kõrge paralleelsuse korral mappingutes (nt REST-Server) tasub lisaks kaaluda vahemälu „soojalt“ ülesehitamist käivitamisel (Preload), et vähendada lock-contentionit.
- PropertyType Kind:
tkClassjatkSeton sihilikult mitte-implementeeritud. Sügavamalt pesastatud objektide puhul kaaluge kas rekursiivset mapimist (selge poliitikaga) või teadlikku käsitsi määramist. - Locale-Fallen:
varDoubleüleVarAsTypeon suhteliselt robustne, kuid stringid nagu „1,23“ vs. „1.23“ võivad siiski probleeme tekitada. Kui teie allikad annavad stringe, on sageli parem kasutada oma parserit (määratud Culture’iga).
Variant für FireDAC ja TDataSet: Reader-Adapter statt Mapper-Kopplung
In BDE-Ablosung mit nativer Anbindung- või klassikalistes VCL/Win32-rakendustes on allikas sageli TDataSet. Selle asemel, et siduda mapperiga TField, kirjutage adapter, mis implementeerib liidese IValueReader. Eelis: mapper jääb andmejuurdepääsust sõltumatuks (oluline, kui te viite andmejuurdepääsu hiljem teenustesse või 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;
Nii näeb konkreetne mapping välja:
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;
Kus see lähenemine tasub end ära — ja kus mitte
Seda mustrit tasub tavaliselt kasutada kolmes olukorras:
- Järk-järguline moderniseerimine: Soovite domeeniobjekte kasutusele võtta, ilma et peaksite andmejuurdepääsu kohe täielikult ümber tegema (tüüpiline juhtum: Delphi moderniseerimine olemasolevates rakendustes).
- Liideste ääred: CSV-/Excel-impordid, REST-payloadid või „segu“ andmeallikad vajavad robustset konverteerimist ja selgeid veateateid.
- Meeskonna hooldatavus: Atribuudid muudavad mappimise reeglid nähtavaks ja läbivaatatavaks, mis suuremates koodibaasides on väga väärtuslik.
Ka kasutuspiirid on selged:
- Komplekssed objektigraafid (Child-Collections, tsüklilised viited) ei peaks te neid „automagiliselt“ mappima. Selle jaoks on selge, eksplicitne kood või eraldiseisev Assembler/Factory-Muster tavaliselt stabiilsem.
- Suurläbilaskevõimega hotpathid (nt massandmete-ETL) saavad pigem kasu koodigeneraatoriga genereeritud mapperitest või käsitsi optimeeritud mappimisest, isegi kui RTTI on vahemällu salvestatud.
- Nullable/Optional on omaette teema. Kui peate tõesti eristama „puudub“, „NULL“ ja „vaikeväärtus“, tuleks see väljendada domeenimudelisse, mitte peita mapperisse.
Paigutus arhitektuuri ja käituse konteksti
Arhitektuurivaatenurgast on see mapper infrastruktuuri komponent andmete esituse ja domeeni piiril. See ei asenda puhast kihistamist, kuid võib selle võimaldada: andmejuurdepääs (FireDAC, SQL, Views) võib jääda pragmaatiliseks, samal ajal kui domeen püsib järjepidevana. Mitmekihilistes süsteemides (sageli nimetatakse Layer-3 arhitektuur: UI, domeen/teenused, infrastruktuur) kuulub mapper infrastruktuuri ja seda kasutavad teenused, mitte UI-vormid.
Käituse seisukohalt oluline: ärge lülitage moDebug püsivalt sisse tootmisteenustes, vaid ainult sihipäraselt. Raskesti reprodutseeritavate andmeprobleemide korral on mõistlik omada lülitatavat diagnostikakanalit (konfiguratsioon, Feature-Flag). Vastasel juhul ähvardavad logimaht ja kõrvalmõjud.
Järeldus: RTTI jah, kuid ainult selgete juhtpõhimõtetega
Delphi RTTI kaardistamiseks ilma võluta toimib hästi, kui käsitlete RTTI-d deklaratiivsete metaandmete tööriistana — mitte kutseina varjatud heuristikatele. Attribuudid opt-inina, tsentraliseeritud teisendused, tüübi-põhine vahemälu ja arusaadavad veateated viivad teema „läbipaistmatu“ juurest „töökorda“. Lähenemisviis ei pretendeeri universaalsusele: pesastatud graafide, rangete nullsemantika või maksimaalse jõudluse puhul on vajalikud täiendavad komponendid. Kuid kui robustne sild andmekogumite/pärandstruktuuride ja kaasaegsemate domeeniobjektide vahel, on see paljudes Delphi-koodibaasides just see pragmaatiline samm, mis moderniseerimise üldse võimalikuks teeb.
Kui teie kasvanud Delphi-rakenduses olete praegu takerdunud kaardistamise kitsaskohtade, andmekvaliteedi või samm-sammulise moderniseerimise taha, saame seda koos korrektselt üles ehitada ja teie arhitektuuri integreerida: võtke ühendust.
Eraldases kontekstis omavad ka Delphi RTTI-kaardistamine ja atribuutide kaardistamine Delphi olulist rolli, kui integratsioonid, andmevood ja edasine arendus peavad puhtalt koos toimima.
Arutage projekti või moderniseerimisettevõtmist koos Net-Base.