Ко год управља развијеним пословним софтвером у Delphi, познаје тај напет баланс: с једне стране желите структуриране објекте домена и јасне слојеве, а с друге постоје Datasets, Variants, CSV-увози, payload-ови интерфејса или REST-API који се „како-тако“ морају мапирати на објекте. Тачно ту се често заврши код Delphi RTTI für Mapping ohne Magie: односно мапирање преко Reflection-а (RTTI = Run-Time Type Information, типске информације у време извођења), али тако да буде свакољиво, лако за дебаговање и да се не ослања криомице на конвенције или игре са именима.
Кључна ствар: „магија“ обично не настаје самим RTTI-јем, већ имплицитним правилима. Када су правила мапирања експлицитно наведена у атрибутима, конверзије централизоване, а грешке именују јасан узрок, RTTI постаје алат, а не изненађење.
Зашто RTTI-мапирање у Delphi често пропада
Мапирање засновано на RTTI-ју у реалним системима ретко не успева због идеје, већ због граница у практичној примени:
- Legacy-Datenformen: Null/Empty/0 нису јасно раздвојени, типови поља се мењају, низови садрже „N/A“.
- Schleichende Konventionen: „Поље се зове као Property“ ради до првог алијаса, join-a или рефакторисаног имена својства.
- Schwer zu debuggen: Када мапер „једноставно ништа не подеси“, касније недостаје узрок. У раду је то погубно.
- Performance-Mythen: RTTI се генерално означава као „споро“, иако је најчешћи проблем непостојеће кеширање.
Одржив приступ би зато требало да (1) има експлицитне метаподатке за мапирање, (2) јасно третира конверзије и семантику нула-вредности, (3) испоручује грешке и дебаг-извештаје и (4) кешира RTTI-информације.
Delphi RTTI für Mapping ohne Magie: Designprinzipien
Следећи образац је намерно „досадан“ у најбољем смислу: правила су видљива, нежељени ефекти ограничени и може се постепено увести у постојеће модуле.
- Attribute statt Namenskonvention: Својство добија атрибут који именује изворну колону.
- Opt-in: Постављају се само означена својства. Нема изненађења услед „свих објављених својстава“.
- Konvertierung an einer Stelle: Variant/String/Integer/Boolean/Enum/Nullable се мапирају централизовано.
- Debug-Mode: Опционо се бележи која су поља постављена/прескочена — уз навођење разлога.
- RTTI-Caching: Најскупљи делови (листe својстава, процена атрибута) припремају се по типу и кеширају.
Source-Schnipsel: Attribut-Mapping mit RTTI, Caching und Debug
Снипет мапира један ред (нпр. из BDE-Ablosung mit nativer Anbindung преко TDataSet) на објекат. Уместо да везујемо мапер директно за TField, користимо малу Reader-срштину. То је у пракси вредно јер исту логику касније можете користити и за JSON, INI, CSV или API-Responses.
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// Експлицитно мапирање: својство <- име извора
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
// Мала апстракција: достављање вредности + разлика између постојања/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;
n 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: само својства са атрибутом
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 вредности није успела: „%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 изван опсега: %d‘, [Ord]);
Exit(Ord);
end;
Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt(‚Име енума непознато: „%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;
// Конверзија намерно селективна: боље јасно подбаци него тихо „на неки начин“.
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(‚Мапирање сетова није имплементирано за %s‘, [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt(‚Мапирање својстава класе није имплементирано за %s‘, [AProp.Name]);
else
raise ERttiMappingError.CreateFmt(‚TypeKind није подржан (%s) за %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 или Target је 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(‚Извор недостаје: „%s“ за својство %s‘,
[M.SourceName, M.Prop.Name]);
Continue;
end;
if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Без Nullable/Optional механике се NULL не може смислено поставити.
Continue;
end;
V := AReader.GetValue(M.SourceName);
try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format(‚Мапирано %s <- %s (%s)‘, [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt(‚Грешка при мапирању за %s <- %s: %s‘,
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
Чему то служи
Добијате мапирање које се у Code-Reviews јасно може проценити:
- Сва мапирана својства су визуелно означена (атрибут).
- Конверзија је централизована, што обезбеђује конзистентност и могућност тестирања.
- Поруке о грешкама наводе, које својство и који извор су погођени.
- Дебаг-модус вам у случају сумње пружа ланац доказа, без потребе за Breakpoints у продуктивном процесу.
Услови и типичне замке
- NULL-Semantik: Без сопственог Nullable-концепта (нпр.
Nullable<T>или Option-Types) постављање NULL није једнозначно. У примеру се NULL по подразумеваној вредности прескаче. То је конзервативно и спречава тихо преписивање. - TRttiContext-Lebensdauer: Градимо кеш једном по типу и затим ослобађамо Context. То је уобичајено. Важно: не правите нови RTTI-Context за сваку доделу поља.
- Threading: Кеш је заштићен преко Monitor-а. У високо-паралелним мапирањима (нпр. REST-Server) требало би додатно проверити да ли ћете кеш већ при старту „топло“ изградити (Preload) како бисте смањили Lock-Contention.
- PropertyType Kind:
tkClassиtkSetсу намerno неимплементирани. За угнеждене објекте требало би или рекурзивно мапирати (са јасном политиком) или свесно доделити ручно. - Locale-Fallen:
varDoubleпрекоVarAsTypeје релативно робустан, али стрингови као „1,23“ против „1.23“ и даље представљају проблем. Ако ваши извори достављају стрингове, често је боље имати сопствени парсер (са дефинисаном Culture).
Варијанта за FireDAC и TDataSet: Reader-Adapter уместо повезивања мапера
У BDE-Ablosung mit nativer Anbindung- или класичним VCL/Win32 апликацијама извор је често TDataSet. Уместо да везујете Mapper за TField, напишите адаптер који реализује интерфејс IValueReader. Предност: Mapper остаје независан од приступа подацима (важно ако касније измештате приступ подацима у сервисе или на 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;
Овако изгледа конкретно мапирање:
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;
Где се приступ исплати – а где не
Овај шаблон се обично исплати у три ситуације:
- Постепена модернизација: Желите да уведете доменске објекте без потпуне реконструкције приступа подацима одједном (класично при Delphi модернизацији у постојећим апликацијама).
- Гранични интерфејси: CSV-/Excel-импорти, REST-payloads или „мешовити“ извори података захтевају робусну конверзију и јасне поруке о грешкама.
- Одрживост у тиму: Атрибути чине правила мапирања видљивим и проверљивим, што у већим базама кода има велику вредност.
Постоје и јасне границе примене:
- Комплексни графови објеката (подколекције, цикличне референце) не би требало „аутоматски“ мапирати. Овде је експлицитни код или одвојени асемблер/factory образац обично стабилније решење.
- High-Throughput-Hotpaths (нпр. ETL за масовне податке) боље имају користи од код-генерисаних мапера или ручно оптимизованог мапирања, чак и ако је RTTI кеширан.
- Nullable/Optional је посебна тема. Ако заиста требате разликовати „непостојеће“, „NULL“ и „подразумевано“, то треба исказати у доменском моделу, а не скривати у маперу.
Уклањање у архитектуру и оперативу
Из перспективе архитектуре, овај мапер је инфраструктурна компонента на граници између репрезентације података и домене. Он не замењује чисто слојевање, али може то омогућити: приступ подацима (FireDAC, SQL, Views) може остати прагматичан, док домена остаје конзистентна. У вишеслојним системима (често називано Layer-3 архитектура: UI, Domain/Services, инфраструктура) мапер припада инфраструктури и користе га сервиси, не UI-формулари.
Оперативно важно: Не активирајте moDebug трајно у продуктивним сервисима, већ циљано. За тешко репродуковиве проблеме са подацима корисно је имати укључив дијагностички пут (конфигурација, feature-flag). У супротном претња су велики обими логова и нуспојаве.
Закључак: RTTI да, али само уз јасне смернице
Delphi RTTI за мапирање без магије добро функционише када RTTI користите као алат за декларативне метаподатке – а не као позив на тихе хеуристике. Атрибути као опција (opt-in), централизована конверзија, кеш по типу и јасни текстови о грешкама пребацују тему од „непрозирно“ до „погодно за рад у производњи“. Приступ је свесно неуниверзалан: за угнеждене графове, строгу семантику нуле или максималне перформансе потребни су додатни елементи. Као робустан мост између Dataset/Legacy-структура и модернијих доменских објеката, он је у многим Delphi код-базама управо прагматичан корак који модернизацију уопште чини могућом.
Ако у развијеној Delphi апликацији тренутно залазите у проблеме са мапирањем, квалитетом података или постепеном модернизацијом, можемо то заједно темељно поставити и уклопити у вашу архитектуру: Контактирајте нас.
У стручном окружењу Delphi RTTI мапирање и мапирање атрибута Delphi такође играју важну улогу када интеграције, токови података и даљи развој морају да се прецизно уклопе.
Разговарајте о пројекту или плану модернизације са Net-Base.