Който поддържа развита бизнес софтуерна система в Delphi, познава това напрежение: от една страна искате структурирани домейн-обекти и ясни слоеве, от друга — има Datasets, Variants, CSV импорти, payloads от интерфейси или REST-API, които „по някакъв начин“ трябва да бъдат мапнати към обекти. Точно тук често се стига до Delphi RTTI за мапване без магия: тоест мапване чрез Reflection (RTTI = Run-Time Type Information, информация за типовете по време на изпълнение), но така че да е проследимо, добре дебъгваемо и да не зависи потайно от конвенции или игрички с имена.
Основният момент: „магията“ обикновено не възниква от самото RTTI, а от имплицитни правила. Ако правилата за мапване са експлицитни като атрибути, конвертиранията са централно организирани и грешките посочват ясна причина, RTTI се превръща в инструмент, а не в изненада.
Защо RTTI-мапването в Delphi често се проваля
RTTI-базираното мапване в реални системи рядко се проваля заради идеята, а по-скоро заради гранични условия:
- Наследени формати на данни: Null/Empty/0 не са ясно разграничени, типовете на полетата се променят, низовете съдържат „N/A“.
- Постепенни конвенции: „полето се казва като Property“ работи до първия Alias, Join или рефакториранo име на Property.
- Трудно за дебъгване: Ако един мапър „просто нищо не задава“, по-късно липсва причината. В продукция това е сериозен проблем.
- Митове за производителността: RTTI се заклеймява като „бавен“, въпреки че в повечето случаи липсата на кеширане е причината.
Поради това работещият подход трябва да (1) има експлицитни метаданни за мапване, (2) третира ясно конвертирането и семантиката на null, (3) логва грешки и дебъг информация и (4) кешира RTTI-данните.
Delphi RTTI за мапване без магия: принципи на проектиране
Следният модел е умишлено „скучен“ в най-добрия смисъл: правилата са видими, страничните ефекти ограничени и може да се интегрира постепенно в съществуващи модули.
- Атрибути вместо именни конвенции: Property получава атрибут, който именува източната колона.
- Opt-in: Само маркираните Properties се задават. Никакви изненади от „всички публикувани Properties“.
- Конвертиране на едно място: Variant/String/Integer/Boolean/Enum/Nullable се мапват централно.
- Debug-режим: По избор се логва кои полета са зададени/пропуснати — със съответната причина.
- RTTI кеширане: Най-скъпите части (списък на Properties, оценка на атрибутите) се подготвят за всеки тип.
Примерен код: атрибутно мапване с RTTI, кеширане и дебъг
Отрязъкът картографира един ред (напр. от BDE-замяна с нативна връзка чрез TDataSet) към обект. Вместо да свързваме мапъра твърдо към TField, използваме малък интерфейс за четене. Това е ценно на практика, защото по-късно можете да използвате същата логика и за JSON, INI, CSV или API-отговори.
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// Явно картографиране: Property <- име на източника
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;
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(‚Грешка при конвертиране в булева стойност: „%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(‚Неизвестно име на enum: „%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(‚Mapиране на Set не е имплементирано за %s‘, [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt(‚Mapиране на class-свойство не е имплементирано за %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-семантика: Без собствена концепция за Nullable (напр.
Nullable<T>или option-типове) задаването на „NULL“ не е еднозначно. В примера NULL по подразбиране се пропуска. Това е консервативен подход и предотвратява тихи презаписвания. - Живот на TRttiContext: Изграждаме кеша веднъж за всеки тип и след това изхвърляме контекста. Това е общоприето. Важно: не създавайте нов RTTI-Context за всяко присвояване на поле.
- Многопоточност: Кешът е защитен чрез Monitor. При високо-паралелни мапинги (напр. REST-Server) трябва допълнително да прецените дали да изградите кеша още при стартиране (Preload), за да намалите lock-contention.
- PropertyType Kind:
tkClassиtkSetса умишлено неимплементирани. За вложени обекти трябва или да мапвате рекурсивно (с ясна политика), или да присвоявате целенасочено ръчно. - Локални капани:
varDoubleчрезVarAsTypeе сравнително устойчив, но низове като „1,23“ срещу „1.23“ все още могат да създадат проблем. Ако вашите източници подават низове, собствен парсер (с дефинирана Culture) често е по-подходящ.
Вариант за FireDAC и TDataSet: Reader-адаптер вместо обвързване на Mapper-а
В 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 или „смесени“ източници на данни изискват надеждна конверсия и ясни съобщения за грешки.
- Поддръжка в екипа: Атрибутите правят правилата за мапиране видими и лесни за преглед, което в по-големи кодови бази е изключително ценно.
Има и ясни граници за приложение:
- Сложни обектни графи (Child-Collections, циклични референции) не бива да мапирате „автомагически“. Тук експлицитен код или отделен Assembler/Factory-шаблон обикновено е по-стабилен.
- Критични пътеки с висок пропускателен капацитет (например Massendaten-ETL) печелят повече от кодогенерирани мапъри или ръчно оптимизирано мапиране, дори когато RTTI е кеширан.
- Nullable/Optional е отделна тема. Ако наистина трябва да разграничавате „липсва“, „NULL“ и „стойност по подразбиране“, изразете това в домейн модела, а не го скривайте в мапъра.
Интегриране в архитектурата и експлоатацията
От архитектурна перспектива този мапър е инфраструктурен компонент на границата между представянето на данни и домейна. Той не замества ясното разграничение на слоевете, но може да го улесни: Достъпът до данни (FireDAC, SQL, Views) може да остане прагматичен, докато домейнът остава консистентен. В многослойни системи (често обозначавани като Layer-3 архитектура: UI, Domain/Services, инфраструктура) мапърът принадлежи към инфраструктурата и се използва от Services, а не от UI-формуляри.
Оперативно важно: Не активирайте moDebug постоянно в продуктивни Services, а само целенасочено. За трудно възпроизведими проблеми с данни е разумно да имате превключваем диагностичен път (конфигурация, Feature-Flag). В противен случай съществува риск от голям обем логове и странични ефекти.
Заключение: RTTI — да, но само с ясни насоки
Delphi RTTI за мапиране без магия работи добре, когато използвате RTTI като инструмент за декларативни метаданни – не като покана за скрити евристики. Атрибути като opt-in, централизирана конверсия, кеш на тип и разбираеми съобщения за грешки превръщат темата от „непрозрачна“ в „оперативна“. Подходът съзнателно не е универсален: за вложени графи, строга null-семантика или максимална производителност ще ви трябват допълнителни компоненти. Като здрава връзка между Dataset/legacy структури и по-съвременни домейн обекти обаче в много Delphi кодови бази той е точно прагматичната стъпка, която прави модернизацията възможна.
Ако в съществуващо Delphi приложение се сблъсквате с гранични случаи при мапиране, с качество на данните или с поетапна модернизация, можем да го настроим заедно чисто и да го интегрираме във вашата архитектура: Свържете се с нас.
Във функционалния контекст Delphi Rtti Mapping и Attribute Mapping Delphi също играят важна роля, когато интеграциите, потокът от данни и бъдещото развитие трябва да взаимодействат безпроблемно.