Den som driver modnet bedriftsprogramvare i Delphi, kjenner spenningsfeltet: På den ene siden ønsker man strukturerte domenetobjekter og klare lag, på den andre siden finnes det Datasets, Variants, CSV-importer, Schnittstellenpayloads eller en REST-API som „på en eller annen måte“ må mappes til objekter. Nettopp her ender man raskt opp med Delphi RTTI for mapping uten magi: altså mapping via refleksjon (RTTI = Run-Time Type Information, typinformasjon ved kjøretid), men slik at det er etterprøvbart, godt feilsøkbart og ikke hemmelig bundet til konvensjoner eller navnelek.
Kjernen: „magi“ oppstår vanligvis ikke av RTTI i seg selv, men av implisitte regler. Når mapping-reglene derimot står eksplisitt i attributter, konverteringer er sentralisert og feil navngir en klar årsak, blir RTTI et verktøy i stedet for en overraskelse.
Hvorfor RTTI-mapping i Delphi ofte feiler
RTTI-basert mapping mislykkes i reelle systemer sjelden på idéen, men på randbetingelser:
- Eldre dataformer: Null/Empty/0 er ikke klart separert, felttyper endrer seg, strenger inneholder „N/A“.
- Smygende konvensjoner: „Felt heter som Property“ fungerer til det første aliaset, joinen eller det refaktorerte property-navnet.
- Vanskelig å feilsøke: Når en mapper „bare ikke setter noe“, mangler årsaken senere. I drift er det gift.
- Ytelsesmyter: RTTI stemplers ofte som „langsomt“, selv om manglende caching som regel er problemet.
En bærekraftig tilnærming bør derfor (1) ha eksplisitte mapping-metadata, (2) håndtere konvertering og null-semantikk tydelig, (3) gi feil- og debug-utdata, og (4) cache RTTI-informasjon.
Delphi RTTI for mapping uten magi: designprinsipper
Mønsteret nedenfor er bevisst „kjedelig“ i beste forstand: reglene er synlige, bivirkninger begrenset, og det kan rulles inn trinnvis i eksisterende moduler.
- Attributter i stedet for navnekonvensjon: Property får et attributt som navngir kildesøylen.
- Opt-in: Bare merkede Properties settes. Ingen overraskelser fra „alle publiserte Properties“.
- Konvertering på ett sted: Variant/String/Integer/Boolean/Enum/Nullable mappes sentralt.
- Debug-modus: Valgfritt logges hvilke felter som ble satt/oversprunget 6 med grunn.
- RTTI-caching: De dyreste delene (propertyliste, attributtevaluering) forberedes per type.
Kildeutdrag: Attributt-mapping med RTTI, caching og debug
Snutten mapper en rad (f.eks. fra BDE-erstatning med native tilkobling via TDataSet) til et objekt. I stedet for å knytte mapperen fast til TField bruker vi et lite reader-grensesnitt. Det er i praksis verdifullt, fordi samme logikk senere kan brukes for JSON, INI, CSV eller API-responser.
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// Eksplisitt mapping: Property <- kilde-navn
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
// Enkel abstraksjon: levere verdi + skille eksistens/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: Bare properties med attributt
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-konvertering mislyktes: „%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 utenfor område: %d‘, [Ord]);
Exit(Ord);
end;
Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt(‚Enum-navn ukjent: „%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;
// Konvertering bevisst selektiv: heller feile tydelig enn å „på en måte“ feile stille.
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-mapping ikke implementert for %s‘, [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt(‚Class-property-mapping ikke implementert for %s‘, [AProp.Name]);
else
raise ERttiMappingError.CreateFmt(‚TypeKind ikke støttet (%s) for %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 eller Target er 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(‚Kilde mangler: „%s“ for property %s‘,
[M.SourceName, M.Prop.Name]);
Continue;
end;
if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Uten nullable/optional-mekanikk kan man ikke sette NULL meningsfullt.
Continue;
end;
V := AReader.GetValue(M.SourceName);
try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format(‚Mappet %s <- %s (%s)‘, [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt(‚Mapping-feil ved %s <- %s: %s‘,
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
Hva dette er nyttig for
Du får et mapping som i kodegjennomganger kan vurderes presist:
- Hver mappet Property er visuelt merket (Attribut).
- Konverteringen er sentralisert, dermed konsistent og testbar.
- Feilmeldinger sier, hvilken Property og hvilken kilde som er berørt.
- En feilsøkingsmodus gir deg ved behov beviskjeden, uten at du trenger breakpoints i produksjonsprosessen.
Forutsetninger og typiske fallgruver
- NULL-semantikk: Uten eget nullable-konsept (f.eks.
Nullable<T>eller Option-Types) er det ikke entydig å sette NULL. I snippetet hoppes NULL over som standard. Dette er konservativt og forhindrer stille overskrivinger. - TRttiContext-levetid: Vi bygger cachen én gang per type og kaster Context etterpå. Det er vanlig. Viktig: Ikke bygg en ny RTTI-Context per felttildeling.
- Threading: Cachen er via Monitor beskyttet. I høyt parallelliserte mappings (f.eks. REST-Server) bør du i tillegg vurdere om du skal bygge cachen ‚varm‘ ved oppstart (Preload) for å redusere lock-contention.
- PropertyType Kind:
tkClassogtkSeter bevisst ikke implementert. For nestede objekter bør du enten mape rekursivt (med klar Policy) eller bevisst tildele manuelt. - Locale-feller:
varDoubleviaVarAsTypeer relativt robust, men strenger som „1,23“ vs. „1.23“ er fortsatt et tema. Hvis kildene dine leverer strenger, er en egen parser (med definert Culture) ofte bedre.
Variant for FireDAC og TDataSet: Reader-Adapter i stedet for mapper-kopling
I BDE-Ablosung mit nativer Anbindung- eller klassiske VCL/Win32-applikasjoner er kilden ofte et TDataSet. I stedet for å binde mapperen til TField, skriver du en adapter som implementerer grensesnittet IValueReader. Fordelen: Mapperen forblir uavhengig av dataadgangen (viktig hvis du senere flytter dataadgangen til tjenester eller en 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;
Slik ser et konkret mapping ut:
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;
Hvor denne tilnærmingen lønner seg – og hvor ikke
Dette mønsteret lønner seg typisk i tre situasjoner:
- Trinnvis modernisering: Dere ønsker å innføre domeneobjekter uten å bygge om dataadgangen fullstendig med én gang (vanlig ved Delphi modernisering i eksisterende applikasjoner).
- Grenseflater: CSV-/Excel-importer, REST-payloads eller „blandede“ datakilder trenger robust konvertering og gode feilmeldinger.
- Vedlikehold i teamet: Attributter gjør mapping-regler synlige og gjennomgåelige, noe som er svært verdifullt i større kodebaser.
Det finnes også klare begrensninger for bruken:
- Komplekse objektgrafer (Child-Collections, sykliske referanser) bør man ikke mappe „automagisk“. Her er eksplisitt kode eller et separat Assembler/Factory-mønster som regel mer stabilt.
- High-Throughput-Hotpaths (f.eks. Massendaten-ETL) drar ofte mer nytte av kodegenererte mapper eller håndoptimalisert mapping, selv når RTTI er cachet.
- Nullable/Optional er et eget tema. Hvis man virkelig må skille mellom „ikke til stede“, „NULL“ og „Default“, bør man uttrykke det i domenemodellen, ikke skjule det i mapperen.
Plassering i arkitektur og drift
Fra et arkitektursynspunkt er denne mapperen en infrastrukturkomponent ved grensen mellom datarepresentasjon og domene. Den erstatter ikke ren lagdeling, men kan gjøre den mulig: Dataadgangen (FireDAC, SQL, Views) kan fortsatt være pragmatisk, mens domenet forblir konsistent. I flerlagsystemer (ofte kalt Layer-3 arkitektur: UI, Domene/Tjenester, Infrastruktur) hører mapperen hjemme i infrastrukturen og brukes av tjenester, ikke av UI-skjemaer.
Operasjonelt viktig: Aktiver ikke moDebug permanent i produksjonstjenester, men målrettet. For vanskelig reproducerbare dataproblemer er det fornuftig å ha en konfigurerbar diagnosevei (konfigurasjon, feature-flag). Ellers risikerer man stort loggvolum og bivirkninger.
Konklusjon: RTTI ja, men bare med klare føringer
Delphi RTTI for mapping uten magi fungerer godt når du bruker RTTI som et verktøy for deklarative metadata – ikke som en invitasjon til tause heuristikker. Attributter som opt-in, sentralisert konvertering, cache per type og forståelige feilmeldinger løfter temaet fra „uklar“ til „driftsklar“. Tilnærmingen er bevisst ikke universell: For innfløkte grafer, streng null-semantikk eller maksimal ytelse trenger du ytterligere komponenter. Som en robust bro mellom dataset-/legacy-strukturer og mer moderne domeneobjekter er den i mange Delphi-kodebaser nettopp det pragmatiske steget som gjør modernisering mulig.
Hvis du i en etablert Delphi-applikasjon sitter fast på mapping-kanter, datakvalitet eller trinnvis modernisering, kan vi sette dette opp ryddig sammen og tilpasse det til arkitekturen din: Kontakt oss.
I faglige sammenhenger spiller også Delphi Rtti Mapping og Attribute Mapping Delphi en viktig rolle når integrasjoner, dataflyter og videreutvikling må fungere sømløst sammen.