Кој управува со развиена деловна софтверска апликација во 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).
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 или „мешани“ извори на податоци бараат робусна конверзија и јасни пораки за грешки.
- Одржливост во тимот: Атрибутите ги прават правилата за мапирање видливи и прегледни, што во поголеми кодни бази е од голема вредност.
Постојат и јасни граници на употреба:
- Комплексни објектни графови (дочерни колекции, циклични референци) не треба да ги мапирате „автомагично“. Тука експлицитен код или одделен 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.