Net-Base списание

08.05.2026

Delphi RTTI за мапирање без магија: базирано на атрибути, лесно за дебагирање и компатибилно со legacy

Прагматичен мапинг-образец со Delphi RTTI: атрибути наместо конвенции, контролирани конверзии, јасни пораки за грешки и дебаг-режим што навистина помага во оперативна средина. Со примероци од изворен код за мапирање од Dataset или Record кон објект без скриена магија.

08.05.2026

Кој управува со развиена деловна софтверска апликација во Delphi знае за таа тензија: од една страна се бараат структурирани доменски објекти и јасни слоеви, а од друга страна има Datasets, Variants, CSV-импорти, payloads на интерфејси или една REST-API, кои „на некој начин“ треба да се мапираат на објекти. Тука често се доаѓа до Delphi RTTI за мапирање без магија: односно мапирање преку Reflection (RTTI = Run-Time Type Information, информации за типови во време на извршување), но така што останува разбирливо, лесно за дебагирање и не зависи тајно од конвенции или игри со имиња.

Клучната поента: „магијата“ најчесто не произлегува од самото RTTI, туку од имплицитни правила. Ако правилата за мапирање се експлицитно зададени како атрибути, конверзиите се централизирани и грешките именуваат јасна причина, RTTI станува алатка, а не изненадување.

Зошто RTTI-мапирањето во Delphi често не успева

RTTI-базираното мапирање во реални системи ретко се сопнува поради идејата, повеќе поради рабните услови:

  • Legacy-формати на податоци: Null/Empty/0 не се добро одвоени, типовите на полиња се менуваат, стринговите содржат „N/A“.
  • Постепено воведувани конвенции: „Полето се вика како Property“ функционира сè додека не дојде првиот алијас, join или префакторирано име на Property.
  • Тешко за дебагирање: Ако маперот „просто не поставува ништо“, подоцна недостасува причината. Во работа тоа е фатално.
  • Митови за перформанс: RTTI паушално се стигматизира како „бавно“, иако вообичаено проблемот е недостигот на кеширање.

Затоа еден одржлив пристап треба да (1) има експлицитни метаподатоци за мапирање, (2) јасно да ги третира конверзиите и семантиката на NULL, (3) да обезбедува грешки и debug-извештаи и (4) да кешира RTTI-информации.

Delphi RTTI за мапирање без магија: принципи на дизајн

Следниот модел е намерно „досаден“ во најдобар смисол: правилата се видливи, несаканите ефекти ограничени, и може да се воведува чекорно во постоечките модули.

  • Атрибути наместо именски конвенции: Property добива атрибут што ја именува изворната колона.
  • Opt-in: Само означените Properties се поставуваат. Никакви изненадувања од „сите публикувани Properties“.
  • Конверзија на едно место: Variant/String/Integer/Boolean/Enum/Nullable се мапираат централизирано.
  • Debug-Mode: Опционално се логира кои полиња се поставени/прескокнати – со причина.
  • RTTI-Caching: Најскапите делови (листата на Properties, евалуација на атрибути) се подготвуваат по тип.

Исечок од код: Атрибутно мапирање со RTTI, кеширање и дебаг

Исечокот мапира еден ред (на пр., од BDE-замена со нативна интеграција via TDataSet) на еден објект. Наместо да го поврземе маперот цврсто за TField, користиме мала Reader-интерфејс. Тоа во пракса е вредно, бидејќи подоцна истата логика може да се искористи и за JSON, INI, CSV или API-одговори.

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;

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 е надвор од опсегот: %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-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 се намерно не имплементирани. За вложени објекти треба или рекурсивно да мапирате (со јасна политика) или свесно да доделувате рачно.
  • Locale-Fallen: varDouble преку VarAsType е релативно робустно, но стрингови како „1,23“ vs. „1.23“ сепак се проблем. Ако вашите извори враќаат стрингови, сопствен парсер (со дефинирана Culture) често е подобар.

Варијанта за FireDAC и TDataSet: Reader-Adapter statt Mapper-Kopplung

Во BDE-Ablosung mit nativer Anbindung- или класични VCL/Win32 апликации изворот често е TDataSet. Наместо да го поврзувате Mapper-от со TField, напишете адаптер кој го имплементира интерфејсот IValueReader. Предноста: Mapper-от останува независен од пристапот до податоци (важно ако подоцна пристапот до податоци го преместите во сервиси или на REST-Server).

Delphi
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;

Со тоа, едно конкретно мапирање изгледа вака:

Delphi
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;

Каде се исплати пристапот – и каде не

Овој шаблон типично е применлив во три ситуации:

  1. Постепена модернизација: Сакате да воведете доменски објекти без да го реорганизирате веднаш целосно пристапот до податоци (класично при Delphi модернизација во постојни апликации).
  2. Интерфејсни граници: CSV-/Excel-увези, REST-Payloads или „мешани“ извори на податоци бараат робусна конверзија и јасни пораки за грешки.
  3. Одржливост во тимот: Атрибутите ги прават правилата за мапирање видливи и прегледни, што во поголеми кодни бази е од голема вредност.

Постојат и јасни граници на употреба:

  • Комплексни објектни графови (дочерни колекции, циклични референци) не треба да ги мапирате „автомагично“. Тука експлицитен код или одделен Assembler/Factory-модул обично е постабилен.
  • High-Throughput-Hotpaths (на пр. Massendaten-ETL) имаат повеќе корист од код-генерирани мапери или рачно оптимизирано мапирање, дури и ако RTTI е кеширано.
  • Nullable/Optional е посебна тема. Ако навистина треба да разликувате помеѓу „не постои“, „NULL“ и „Default“, тоа треба да го изразите во доменскиот модел, а не да го криете во Mapper-от.

Позиционирање во архитектурата и оперативноста

Од архитектонска перспектива, овој Mapper е инфраструктурна компонента на границата помеѓу репрезентацијата на податоци и доменот. Тој не ги заменува чистите слоеви, но може да им овозможи: Пристапот до податоци (FireDAC, SQL, Views) може да остане прагматичен, додека доменот останува конзистентен. Во повеќеслојни системи (често наречена Layer-3 архитектура: UI, Domain/Services, инфраструктура) Mapper-от припаѓа на инфраструктурата и се користи од Services, а не од UI-формулари.

Во оперативен поглед важно: не активирајте moDebug трајно во продуктивни сервиси, туку селективно. За тешко репродуцирачки проблеми со податоци е корисно да имате вклучлив дијагностички пат (конфигурација, Feature-Flag). Инаку ризикувате зголемено лог-волумен и несакани последици.

Заклучок: RTTI да, но само со јасни водилки

Delphi RTTI за мапирање без магија дејствува добро кога RTTI го користите како алатка за декларативни метаподатоци — не како покана за скриени хеуристики. Атрибути како opt-in, централизирана конверзија, кеш по тип и разбирливи текстови за грешки го преведуваат прашањето од „непрегледно“ во „работоспособно“. Пристапот намерно не е универзален: за вградени графови, строга семантика на null или максимална изведба ќе ви требаат дополнителни компоненти. Како робусен мост помеѓу Dataset/Legacy-структури и посовремени доменски објекти, тој во многу Delphi-кодни бази е токму тој прагматичен чекор што ја прави модернизацијата воопшто можна.

Ако во една постоечка Delphi-апликација сте заглавени со гранични случаи при мапирање, квалитетот на податоците или постепената модернизација, можеме тоа заедно прецизно да го поставиме и да го вметнеме во вашата архитектура: Контактирајте не.

Во стручниот контекст, Delphi Rtti Mapping и Attribute Mapping Delphi исто така имаат значајна улога кога интеграциите, протокот на податоци и натамошниот развој мора да се координираат прецизно.

Разговарајте за проект или за намера за модернизација со Net-Base.

Сподели објава

Споделете го овој пост директно.

LinkedIn, X, XING, Facebook, WhatsApp и е-пошта се веднаш достапни. За Instagram директно подготвуваме линк и краток текст.

Е-пошта

Instagram се отвора во нов таб. Линкот и краткиот текст претходно се копираат во меѓуспремникот.