Той, хто експлуатує розвинуте бізнес‑ПЗ в Delphi, знає це напружене поле: з одного боку потрібні структуровані доменні об’єкти й чіткі шари, з іншого — існують Datasets, Variants, імпорти CSV, payload-и інтерфейсів або REST-API, які «якось» треба замапити на об’єкти. Саме тут часто з’являється Delphi RTTI для Mapping ohne Magie: тобто відображення через Reflection (RTTI = Run-Time Type Information, інформація про типи під час виконання), але так, щоб усе було прозоро, добре відлагоджувалося й не залежало потай від конвенцій чи ігри з іменами.
Суть: «магія» зазвичай виникає не через RTTI як таке, а через імпліцитні правила. Якщо ж правила відображення явно вказані в атрибутах, конвертації централізовані, а помилки називають чітку причину, RTTI стає інструментом, а не сюрпризом.
Чому RTTI‑відображення в Delphi часто зривається
Відображення на основі RTTI в реальних системах рідко зазнає краху через ідею — частіше через граничні умови:
- Форми спадщини даних: Null/Empty/0 не чітко розрізняються, типи полів змінюються, рядки містять «N/A».
- Повзучі конвенції: «Поле має ім’я властивості» працює до першого псевдоніма, Join або рефакторингу імені властивості.
- Важко відлагоджувати: якщо маппер «просто нічого не встановлює», пізніше немає причини. В експлуатації це отрута.
- Міфи про продуктивність: RTTI стигматизують як «повільне», хоча зазвичай проблема в відсутності кешування.
Стійкий підхід має тому (1) містити явні метадані відображення, (2) чітко обробляти конвертацію й семантику null, (3) надавати помилки й відладочні виводи та (4) кешувати RTTI‑інформацію.
Delphi RTTI для Mapping ohne Magie: принципи проєктування
Наступний шаблон навмисно «нудний» у кращому сенсі: правила видимі, побічні ефекти обмежені, і його можна поетапно інтегрувати в наявні модулі.
- Атрибути замість конвенції імен: властивості присвоюється атрибут, який називає стовпець джерела.
- Opt‑in: встановлюються лише відмічені властивості. Ніяких несподіванок через «всі публіковані властивості».
- Конвертація в одному місці: Variant/String/Integer/Boolean/Enum/Nullable картуються централізовано.
- Режим налагодження: опційно логуються поля, які встановлено/пропущено — з указанням причини.
- Кешування RTTI: найдорожчі частини (список властивостей, оцінка атрибутів) готуються для кожного типу заздалегідь.
Фрагмент коду: атрибут‑мапінг з RTTI, кешуванням і відладкою
Цей сніпет відображає рядок (наприклад, з BDE-заміна з нативним підключенням через TDataSet) на об’єкт. Замість жорсткого прив’язування маппера до TField ми використовуємо невеликий інтерфейс Reader. На практиці це цінно, оскільки пізніше ту саму логіку можна застосувати до 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: лише Properties з атрибутом
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-ординал поза допустимим діапазоном: %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(‚Відображення для множин (set) не реалізовано для %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-review можна чітко оцінити:
- Кожна мапована Property візуально позначена (Attribut).
- Конвертація централізована, отже послідовна та придатна для тестування.
- Тексти помилок вказують, яка саме Property і яке саме джерело задіяно.
- Режим налагодження у разі сумнівів надає ланцюжок доказів, без необхідності ставити Breakpoints у продуктивному процесі.
Обмеження та типові підводні камені
- NULL-Semantik: Без власної концепції Nullable (наприклад
Nullable<T>або Option-Types) операція «встановити NULL» не є однозначною. У фрагменті NULL за замовчуванням пропускається. Це консервативний підхід і запобігає тихим перезаписам. - TRttiContext-Lebensdauer: Ми створюємо кеш один раз на тип і після цього звільняємо Context. Це звично. Важливо: не створювати нового RTTI-Context для кожного присвоєння поля.
- Threading: Кеш захищено через Monitor. У високо паралельних мапінгах (наприклад REST-Server) слід додатково перевірити, чи варто наперед сформувати кеш при старті (Preload), щоб зменшити конкуренцію за блокування.
- PropertyType Kind:
tkClassтаtkSetнавмисне не реалізовані. Для вкладених об’єктів слід або рекурсивно виконувати мапінг (з чіткою політикою), або свідомо присвоювати вручну. - Locale-Fallen:
varDoubleчерезVarAsTypeвідносно стійкий, але рядки типу «1,23» vs. «1.23» все одно становлять проблему. Якщо ваші джерела повертають рядки, власний парсер (з визначеною Culture) часто кращий.
Variante für FireDAC und TDataSet: Reader-Adapter statt Mapper-Kopplung
У BDE-Ablosung mit nativer Anbindung- або класичних VCL/Win32-застосунках джерелом часто буває TDataSet. Замість прив’язки маппера до TField реалізуйте адаптер, який реалізує інтерфейс IValueReader. Перевага: маппер залишається незалежним від доступу до даних (важливо, якщо ви згодом винесете доступ до даних у сервіси або на 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 Modernisierung у існуючих застосунках).
- Інтерфейсні межі: імпорти CSV/Excel, REST-payloads або «змішані» джерела даних потребують надійної конвертації та зрозумілих повідомлень про помилки.
- Підтримуваність у команді: атрибути роблять правила мапінгу видимими та зручними для перегляду коду, що має велику цінність у великих кодових базах.
Також є чіткі межі застосування:
- Складні графи об’єктів (дочірні колекції, циклічні посилання) не варто «автоматично» мапити. Тут зазвичай стабільнішим є явний код або окремий патерн Assembler/Factory.
- Вузькі місця з високою пропускною здатністю (High-Throughput-Hotpaths) (наприклад, ETL для масивних даних) швидше виграють від мапперів, згенерованих кодом, або від ручно оптимізованого мапінгу, навіть якщо RTTI кешовано.
- Nullable/Optional — це окрема тема. Якщо вам потрібно чітко розрізняти «відсутнє», «NULL» і «значення за замовчуванням», ви повинні виражати це в доменній моделі, а не приховувати в маппері.
Роль в архітектурі та експлуатації
З точки зору архітектури цей маппер — інфраструктурний компонент на межі між репрезентацією даних і доменною моделлю. Він не замінює чисте розшарування, але може його забезпечити: доступ до даних (FireDAC, SQL, Views) може залишатися прагматичним, тоді як домен має залишатися консистентним. У багатошарових системах (часто позначають як Layer-3 архітектура: UI, Domain/Services, Infrastruktur) маппер належить до інфраструктури і використовується сервісами, а не UI-формами.
В експлуатації важливо: не вмикайте moDebug постійно в продуктивних сервісах, а робіть це прицільно. Для важкорепродукованих проблем з даними доцільно мати перемикаємий діагностичний шлях (конфігурація, Feature-Flag). Інакше є ризик великого обсягу логів і побічних ефектів.
Висновок: RTTI — так, але лише з чіткими обмеженнями
Delphi RTTI для відображення без магії працює добре, коли ви використовуєте RTTI як інструмент для декларативних метаданих — а не як запрошення до прихованих евристик. Атрибути як opt-in, централізована конвертація, кеш на тип і зрозумілі тексти помилок переводять питання від «непрозоро» до «готово до експлуатації». Підхід свідомо не універсальний: для вкладених графів, строгої NULL-семантики або максимальної продуктивності вам знадобляться додаткові компоненти. Як міцний міст між Dataset/Legacy-структурами та сучаснішими доменними об’єктами він у багатьох Delphi-базах коду саме той прагматичний крок, який робить модернізацію взагалі можливою.
Якщо в існуючому Delphi-застосунку ви наразі застрягли на межах мапінгу, якості даних або поступовій модернізації, ми можемо це разом акуратно налаштувати й інтегрувати у вашу архітектуру: Зв’язатися.
У фаховому контексті також важливу роль відіграють Delphi Rtti Mapping та Attribute Mapping Delphi, коли інтеграції, потоки даних і подальший розвиток мають працювати узгоджено.