Den som driver etablerad affärsprogramvara i Delphi känner till spänningsfältet: Å ena sidan vill man strukturerade domänobjekt och tydliga lager, å andra sidan finns det Datasets, Variants, CSV-importer, gränssnittspayloads eller en REST-API som på något sätt måste mappas till objekt. Här hamnar man snabbt vid Delphi RTTI för mappning utan magi: alltså mappning via reflection (RTTI = Run-Time Type Information, typinformation vid körning), men på ett sätt som är spårbart, lätt att debugga och som inte i hemlighet bygger på konventioner eller namntricks.
Kärnpunkten: „Magi“ uppstår oftast inte av RTTI i sig, utan av implicita regler. Om mappningsregler istället finns uttryckligen i attribut, konverteringar centraliseras och fel anger en tydlig orsak, blir RTTI ett verktyg snarare än en överraskning.
Varför RTTI-mappning i Delphi ofta går fel
RTTI-baserad mappning misslyckas i verkliga system sällan på idén, oftare på randvillkor:
- Legacy-datatyper: Null/Empty/0 är inte tydligt åtskilda, fälttyper växlar, strängar innehåller „N/A“.
- Smygande konventioner: „fältet heter som Property“ fungerar tills det första aliaset, join:en eller det refaktorerade Property-namnet dyker upp.
- Svårt att debugga: Om en mapper „helt enkelt inte sätter något“ saknas orsaken senare. I drift är det förödande.
- Prestandamythos: RTTI stämplas generellt som „långsam“, även om bristande caching oftast är problemet.
En hållbar ansats bör därför (1) ha explicita mappningsmetadata, (2) behandla konvertering och null-semantik tydligt, (3) ge fel- och debugutdata och (4) cacha RTTI-info.
Delphi RTTI för mappning utan magi: designprinciper
Följande mönster är medvetet „tråkigt“ i bästa bemärkelsen: regler är synliga, sidoeffekter begränsade och det kan införas stegvis i befintliga moduler.
- Attribut istället för namngivningskonvention: Property tilldelas ett attribut som anger källkolumnen.
- Opt-in: Endast markerade Properties sätts. Inga överraskningar från „alla publicerade Properties“.
- Konvertering på en plats: Variant/String/Integer/Boolean/Enum/Nullable mappas centralt.
- Debug-läge: Valfritt loggas vilka fält som sattes/hoppades över – med orsak.
- RTTI-caching: De dyraste delarna (propertylista, attribututvärdering) förbereds per typ.
Källkodsexempel: attributmappning med RTTI, caching och debug
Snippetet mappar en rad (t.ex. från BDE-Ablosung mit nativer Anbindung via TDataSet) till ett objekt. Istället för att hårdkoppla mappningen till TField använder vi ett litet reader-gränssnitt. Det är i praktiken värdefullt eftersom du senare kan återanvända samma logik för JSON, INI, CSV eller API-responser.
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// Explicit mappning: Property <- källnamn
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
// Liten abstraktion: leverera värde och särskilja existens/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: Endast properties med 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-konvertering misslyckades: „%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 utanför intervallet: %d‘, [Ord]);
Exit(Ord);
end;
Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt(‚Enum-namn okänt: „%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 medvetet selektiv: hellre ett tydligt fel än att tyst „på något sätt“.
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-mappning är inte implementerad för %s‘, [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt(‚Class-Property-mappning är inte implementerad för %s‘, [AProp.Name]);
else
raise ERttiMappingError.CreateFmt(‚TypeKind stöds inte (%s) för %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 är 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(‚Källa saknas: „%s“ för Property %s‘,
[M.SourceName, M.Prop.Name]);
Continue;
end;
if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Utan nullable/optional-mekanism går det inte att meningsfullt tilldela NULL.
Continue;
end;
V := AReader.GetValue(M.SourceName);
try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format(‚Mapped %s <- %s (%s)‘, [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt(‚Mappningsfel vid %s <- %s: %s‘,
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
Vad det är bra för
Du får en mappning som kan bedömas tydligt i kodgranskningar:
- Varje mappad egenskap är visuellt markerad (attribut).
- Konverteringen är centraliserad, därigenom konsekvent och testbar.
- Feltexter anger vilken egenskap och vilken källa som berörs.
- Ett debug-läge ger dig vid behov beviskedjan, utan att du behöver breakpoints i produktionsprocessen.
Randvillkor och typiska fallgropar
- NULL-Semantik: Utan ett eget nullable-koncept (t.ex.
Nullable<T>eller option-typer) är „sätta NULL“ inte entydigt. I snippeten hoppas NULL över som standard. Det är konservativt och förhindrar tysta överskrivningar. - TRttiContext-Lebensdauer: Vi bygger cachen en gång per typ och slänger Context därefter. Det är vanligt. Viktigt är: bygg inte en ny RTTI-Context för varje fälttilldelning.
- Threading: Cachen är via Monitor skyddad. I högparallella mappningar (t.ex. REST-Server) bör du dessutom överväga att bygga cachen „varm“ vid start (Preload) för att minska lock-contention.
- PropertyType Kind:
tkClassochtkSetär avsiktligt inte implementerade. För inbäddade objekt bör du antingen mappa rekursivt (med en tydlig Policy) eller medvetet tilldela manuellt. - Locale-Fallen:
varDoubleviaVarAsTypeär relativt robust, men strängar som „1,23“ vs. „1.23“ är ändå ett problem. Om dina källor levererar strängar är en egen parser (med definierad kultur) ofta bättre.
Variante für FireDAC und TDataSet: Reader-Adapter statt Mapper-Kopplung
I BDE-Ablosung mit nativer Anbindung- eller klassiska VCL/Win32-applikationer är källan ofta ett TDataSet. Istället för att binda mappern till TField skriver du en adapter som implementerar gränssnittet IValueReader. Fördelen: Mappern förblir oberoende av dataåtkomst (viktigt om du senare lägger ut dataåtkomst i tjänster eller till 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;
Så här ser ett 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;
När metoden är motiverad – och när den inte är det
Detta mönster är typiskt motiverat i tre situationer:
- Stegvis modernisering: Ni vill införa domänobjekt utan att omgående riva upp dataåtkomsten helt (klassiskt vid Delphi modernisering av befintliga applikationer).
- Schnittstellenkanten: CSV-/Excel-importer, REST-payloads eller „blandade“ datakällor behöver robust konvertering och tydliga felmeddelanden.
- Underhållbarhet i teamet: Attribut gör mappningsregler synliga och granskningsbara, vilket är värdefullt i större kodbaser.
Det finns också tydliga begränsningar:
- Komplexa Objektgraphen (Child-Collections, cykliska referenser) bör ni inte „automagiskt“ mappa. Här är explicit kod eller ett separat assembler/factory-mönster oftast stabilare.
- Hotpaths med hög genomströmning (t.ex. storskaliga ETL-processer) gynnas snarare av kodgenererade mapper eller handoptimerad mappning, även om RTTI är cachad.
- Nullable/Optional är ett eget ämne. Om ni verkligen behöver skilja mellan „saknas“, „NULL“ och „default“ bör ni uttrycka det i domänmodellen, inte dölja det i mappern.
Placering i arkitektur och drift
Ur ett arkitekturperspektiv är denna mapper en infrastrukturkomponent i gränslandet mellan datarepresentation och domän. Den ersätter inte en ren lagerindelning, men kan möjliggöra den: Dataåtkomsten (FireDAC, SQL, vyer) kan fortsatt vara pragmatisk, samtidigt som domänen hålls konsekvent. I flerlager-system (oft kallad Layer-3 arkitektur: UI, Domain/Services, Infrastruktur) hör mappern hemma i infrastrukturen och används av tjänster, inte av UI-formulär.
Viktigt i drift: Aktivera inte moDebug permanent i produktiva tjänster, utan använd det selektivt. För svårreproducerade dataproblem är det lämpligt att ha en styrbar diagnosväg (konfiguration, feature-flag). Annars riskerar ni stora loggvolymer och oönskade bieffekter.
Slutsats: RTTI ja, men endast med tydliga riktlinjer
Delphi RTTI för Mapping utan magi fungerar bra när du använder RTTI som ett verktyg för deklarativa metadata – inte som en inbjudan till tysta heuristiker. Attribut som opt-in, centraliserad konvertering, cache per typ och begripliga felmeddelanden tar ämnet från „ogenomskinligt“ till „driftsdugligt“. Metoden är medvetet inte universell: för nästlade grafer, strikt null-semantik eller maximal prestanda behöver du ytterligare komponenter. Som en robust brygga mellan dataset-/legacy-strukturer och mer moderna domänobjekt är den i många Delphi-kodbaser just det pragmatiska steget som gör modernisering möjlig.
Om du i en befintlig Delphi-applikation sitter fast vid Mapping-kanter, datakvalitet eller stegvis modernisering kan vi tillsammans sätta upp det rent och anpassa det till din arkitektur: Kontakta oss.
I det fackmässiga sammanhanget spelar också Delphi Rtti Mapping och Attribute Mapping Delphi en viktig roll när integrationer, dataflöden och vidareutveckling måste samspela tydligt.
Diskutera projekt eller moderniseringsinitiativ med Net-Base.