Joka ylläpitää kasvaneita liiketoimintaohjelmistoja Delphi tietää jännitteen: toisaalta halutaan rakenteellisia domääniobjekteja ja selkeät kerrokset, toisaalta on Datasets, Variants, CSV-tuonnit, rajapintapayloadit tai REST-API, jotka pitää „jollain tavalla“ mappata objekteihin. Tässä kohdassa päädytään helposti Delphi RTTI für Mapping ohne Magie: eli mapping per Reflection (RTTI = Run-Time Type Information, tyyppitiedot ajonaikaisesti), mutta siten, että se pysyy jäljitettävänä, hyvin debugattavana eikä perustu salakavalasti konventioihin tai nimileikkeihin.
Keskeinen huomio: „taikuus“ syntyy yleensä ei niinkään RTTI:stä sinänsä, vaan implisiittisistä säännöistä. Kun mapping-säännöt ovat eksplisiittisesti attribuuteissa, konversiot keskitettyjä ja virheet nimeävät selkeän syyn, RTTI muuttuu työkaluksi eikä yllätykseksi.
Miksi RTTI-mapping Delphi:ssa usein pettää
RTTI-pohjainen mapping epäonnistuu reaalisissa järjestelmissä harvoin idean vuoksi, usein sen reunaehtojen takia:
- Legacy-Datenformen: Null/Empty/0 eivät erotu selkeästi, kenttätyypit vaihtuvat, Strings sisältävät „N/A“.
- Schleichende Konventionen: „Feld heißt wie Property“ toimii siihen asti, kunnes tulee alias, join tai refaktoroitu Property-nimi.
- Schwer zu debuggen: Kun mapper „yksinkertaisesti ei aseta mitään“, myöhemmin puuttuu syy. Tuotantoympäristössä se on erityisen haitallista.
- Performance-Mythen: RTTI leimataan yleisesti „hitaaksi“, vaikka usein ongelma on puuttuva välimuisti.
Kestävä lähestymistapa pitäisi siksi (1) sisältää eksplisiittiset mapping-metatiedot, (2) käsitellä konversio ja null-semanttiikka selkeästi, (3) tuottaa virhe- ja debug-tulosteita sekä (4) välimuistittaa RTTI-tiedot.
Delphi RTTI für Mapping ohne Magie: Designprinzipien
Seuraava malli on tarkoituksella „tylsä“ parhaassa merkityksessä: säännöt ovat näkyvissä, sivuvaikutukset rajattuja, ja sen voi tuoda olemassa oleviin moduuleihin vaiheittain.
- Attribute statt Namenskonvention: Propertylle asetetaan attribuutti, joka nimeää lähdesarakkeen.
- Opt-in: Vain merkityt Properties asetetaan. Ei yllätyksiä „kaikki julkistetut Properties“ -periaatteesta.
- Konvertierung an einer Stelle: Variant/String/Integer/Boolean/Enum/Nullable muunnetaan keskitetysti.
- Debug-Mode: Valinnaisesti kirjataan, mitkä kentät asetettiin/ohitettiin – syy mukaan lukien.
- RTTI-Caching: Kalleimmat osat (Propertyliste, Attributeauswertung) valmistellaan tyyppikohtaisesti.
Source-Schnipsel: Attribut-Mapping mit RTTI, Caching und Debug
Katkelma mapittaa yhden rivin (esim. BDE-ablousungin natatiivilla kytkennällä via TDataSet) objektiin. Sen sijaan, että sidomme mapperin tiukasti TField:iin, käytämme pientä Reader-rajapintaa. Tämä on käytännössä arvokasta, koska sama logiikka toimii myöhemmin myös JSON:lle, INI:lle, CSV:lle tai API-responseille.
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// Eksplisiittinen mappaus: Property <- lähdenimi
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
// Pieni abstraktio: arvojen tarjoaminen + olemassaolon/NULLin erottelu
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: vain attribuuteilla varustetut propertyt
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-muunnos epäonnistui: "%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-ordinali alueen ulkopuolella: %d', [Ord]);
Exit(Ord);
end;
Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt('Enum-nimeä ei tunnistettu: "%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;
// Konversio tietoisesti selektiivinen: mieluummin selkeä epäonnistuminen kuin hiljainen "jollain tavalla".
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-mappingia ei ole toteutettu %s:lle', [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt('Class-property-mapping ei ole toteutettu %s:lle', [AProp.Name]);
else
raise ERttiMappingError.CreateFmt('TypeKind ei tuettu (%s) kohteelle %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 tai 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('Lähde puuttuu: "%s" Propertylle %s',
[M.SourceName, M.Prop.Name]);
Continue;
end;
if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Ilman Nullable/Optional-mekaniikkaa NULL:ia ei voi järkevästi asettaa.
Continue;
end;
V := AReader.GetValue(M.SourceName);
try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format('Mapattu %s <- %s (%s)', [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt('Mappausvirhe kohteessa %s <- %s: %s',
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
Mihin tästä on hyötyä
Saatte mappingin, jonka voi arvioida selkeästi koodikatselmuksissa:
- Jokainen mapattu ominaisuus on visuaalisesti merkitty (attribuutti).
- Muunnos on keskitetty, joten se on yhdenmukainen ja testattavissa.
- Virheilmoitukset kertovat, mikä ominaisuus ja mikä lähde on kyseessä.
- Debug-tila antaa tarvittaessa todisteketjun, ilman että tarvitsee asettaa breakpointteja tuotantoprosessiin.
Reunaehdot ja tyypilliset sudenkuopat
- NULL-semanttiikka: Ilman omaa Nullable-käsitettä (esim.
Nullable<T>tai Option-tyypit) NULL:n asettaminen ei ole yksiselitteistä. Katkelmassa NULL jätetään oletuksena huomioimatta. Tämä on konservatiivista ja estää hiljaisia ylikirjoituksia. - TRttiContext-elinkaari: Rakennamme välimuistin kerran per tyyppi ja heitämme Contextin pois sen jälkeen. Tämä on tavallista. Tärkeää: älä luo uutta RTTI-Contextia joka kenttäkohtaisessa määrityksessä.
- Monisäikeisyys: Välimuisti on suojattu Monitorilla. Erittäin rinnakkaisissa mappingeissa (esim. REST-Server) kannattaa lisäksi harkita välimuistin esirakentamista käynnistyksessä (Preload) lukkojen kilpailun vähentämiseksi.
- PropertyType Kind:
tkClassjatkSeton tarkoituksella jätetty toteuttamatta. Sisäkkäisille objekteille tulisi joko mapata rekursiivisesti (selkeällä käytännöllä) tai tietoisesti asettaa käsin. - Aluekohtaiset sudenkuopat:
varDoubleVarAsType:n kautta on suhteellisen robusti, mutta merkkijonot kuten „1,23“ vs. „1.23“ ovat silti ongelma. Jos lähteenne toimittavat merkkijonoja, oma jäsentäjä (määritellyllä Culture-asetuksella) on usein parempi.
Vaihtoehto für FireDAC ja TDataSet: Reader-adapteri mapper-kytkennän sijaan
Perinteisissä BDE-Ablosung mit nativer Anbindung- tai VCL/Win32-sovelluksissa lähteenä on usein TDataSet. Sen sijaan, että sitoisitte mapperin TField:iin, kirjoittakaa adapteri, joka toteuttaa rajapinnan IValueReader. Etu: mapper pysyy riippumattomana datan saatavasta (tärkeää, jos ulkoistatte datan käsittelyn myöhemmin palveluihin tai 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;
Näin konkreettinen mapping näyttää:
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;
Missä lähestymistapa kannattaa – ja missä ei
Tämä malli kannattaa tyypillisesti kolmessa tilanteessa:
- Vaiheittainen modernisointi: Haluatte ottaa käyttöön domääniobjekteja ilman, että muutatte datan käyttöä heti kokonaan (tyypillistä Delphi modernisointi olemassa olevissa sovelluksissa).
- Rajapintakohdat: CSV-/Excel-tuonnit, REST-payloadit tai „sekoitetut“ tietolähteet tarvitsevat vankkaa konversiota ja selkeitä virheilmoituksia.
- Ylläpidettävyys tiimissä: Attribuutit tekevät mappaussäännöt näkyviksi ja tarkastettaviksi, mikä on suuremmissa koodikannoissa arvokasta.
Käyttörajoituksia on myös selkeästi:
- Monimutkaiset objektigrafit (lapsikokoelmat, sykliset viittaukset) ei kannata mapata „automaagisesti“. Tässä eksplisiittinen koodi tai erillinen assembler-/factory-malli on yleensä vakaampi.
- Korkean läpimenon hot-pathit (esim. massadata-ETL) hyötyvät ennemmin koodigeneroiduista mapparista tai käsin optimoidusta mappauksesta, vaikka RTTI olisikin välimuistissa.
- Nullable/Optional on oma aiheensa. Jos teidän täytyy todellakin erottaa „ei olemassa“, „NULL“ ja „Default“, tulisi se ilmaista domäänimallissa, ei piilottaa mapperiin.
Sijoittuminen arkkitehtuuriin ja operointiin
Arkkitehtuurin näkökulmasta tämä mapper on infrastruktuurikomponentti datan esityksen ja domaanin rajapinnassa. Se ei korvaa selkeää kerroksellisuutta, mutta voi mahdollistaa sen: datan käsittely (FireDAC, SQL, Views) voi olla edelleen pragmaattista, samalla kun domaani pysyy konsistenttina. Monikerroksisissa järjestelmissä (usein kutsuttu Layer-3 arkkitehtuuri: UI, Domain/Services, infrastruktuuri) mapper sijoittuu infrastruktuuriin ja on palveluiden käytettävissä, ei UI-lomakkeiden.
Käytännössä tärkeää: Älkää ottako moDebug:ia pysyvästi käyttöön tuotantopalveluissa, vaan käytä sitä kohdennetusti. Vaikeasti toistuvien datavirheiden diagnosointiin on järkevää tarjota kytkettävä diagnostiikkapolku (konfiguraatio, feature-flag). Muuten uhkana ovat lokimäärien kasvu ja sivuvaikutukset.
Yhteenveto: RTTI kyllä — mutta vain selkeillä ohjausperiaatteilla
Delphi RTTI kartoitukseen ilman taikuutta toimii hyvin, kun käytätte RTTI:tä deklaratiivisten metatietojen työkaluna – ei kutsuna hiljaisiin heuristiikkoihin. Attribuutit opt-in-mekanismilla, keskitetty konversio, tyyppikohtainen välimuisti ja ymmärrettävät virheilmoitukset vievät aiheen „läpinäkymättömästä“ „tuotantokelpoiseksi“. Lähestymistapa ei ole tarkoituksella universaali: syvälle upotettuihin graafeihin, tiukkaan null-semantiiikkaan tai maksimaaliseen suorituskykyyn tarvitaan lisäkomponentteja. Kestävä siltaratkaisu dataset-/legacy-rakenteiden ja modernimpien domääniobjektien välillä on kuitenkin monissa Delphi-koodikannoissa juuri se pragmaattinen askel, joka tekee modernisoinnista ylipäätään mahdollisen.
Jos olet vakiintuneessa Delphi-sovelluksessa jumissa mapping-reunojen, datalaadun tai vaiheittaisen modernisoinnin kanssa, voimme rakentaa sen yhdessä siististi ja sovittaa osaksi arkkitehtuuriasi: Ota yhteyttä.
Asiantuntijaympäristössä näyttelevät myös Delphi Rtti Mapping ja Attribute Mapping Delphi tärkeää roolia, kun integraatioiden, datavirtojen ja jatkokehityksen on toimittava saumattomasti yhdessä.
Keskustele projektista tai modernisointihankkeesta Net-Base kanssa.