Кто эксплуатирует унаследованное бизнес‑ПО в Delphi, знает это напряженное поле: с одной стороны требуются структурированные доменные объекты и четкие слои, с другой — есть Datasets, Variants, CSV‑импорты, payload’ы интерфейсов или REST‑API, которые «как‑то» нужно сопоставить с объектами. Именно здесь быстро появляется Delphi RTTI для сопоставления без магии: то есть сопоставление через reflection (RTTI = Run-Time Type Information, информация о типах во время выполнения), но таким образом, чтобы оно оставалось прослеживаемым, хорошо отлаживалось и не зависело тайно от соглашений или игры с именами.
Суть: «магия» обычно возникает не из‑за самого RTTI, а из‑за неявных правил. Если правила сопоставления явно заданы в атрибутах, конвертации централизованы, а ошибки указывают на понятную причину, RTTI становится инструментом, а не сюрпризом.
Почему RTTI-сопоставление в Delphi часто дает сбой
RTTI‑основанное сопоставление в реальных системах редко проваливается из‑за идеи; обычно проблема в пограничных условиях:
- Устаревшие формы данных: Null/Empty/0 не разделяются четко, типы полей меняются, строки содержат «N/A».
- Незаметно внедряемые соглашения: «Поле называется как Property» работает до первого алиаса, JOIN или рефакторинга имени свойства.
- Сложно отлаживать: когда маппер «просто ничего не устанавливает», позже отсутствует причина. В эксплуатации это критично.
- Мифы о производительности: RTTI по умолчанию маркируют как «медленное», хотя чаще всего проблема — отсутствие кэша.
Поэтому жизнеспособный подход должен (1) иметь явные метаданные сопоставления, (2) ясно обрабатывать конвертацию и семантику null, (3) выдавать ошибки и отладочные сообщения и (4) кэшировать RTTI‑информацию.
Delphi RTTI для сопоставления без магии: принципы проектирования
Следующий шаблон намеренно «скучен» в лучшем смысле: правила видимы, побочные эффекты ограничены, и его можно поэтапно внедрять в существующие модули.
- Атрибуты вместо соглашений об именах: у свойства есть атрибут, который указывает исходный столбец.
- Opt-in: устанавливаются только отмеченные свойства. Никаких сюрпризов из‑за «всех публичных свойств».
- Конвертация в одном месте: Variant/String/Integer/Boolean/Enum/Nullable сопоставляются централизованно.
- Режим отладки: опционно ведется лог того, какие поля установлены/пропущены — с указанием причины.
- Кэширование RTTI: самые затратные части (список свойств, обработка атрибутов) подготавливаются для каждого типа.
Фрагмент исходного кода: атрибутное сопоставление с RTTI, кэшированием и отладкой
Фрагмент отображает одну строку (например, из BDE-Ablosung mit nativer Anbindung 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: Nur Properties mit Attribut
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(‚Порядковое значение перечисления вне допустимого диапазона: %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(‚Сопоставление 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 oder Target ist 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(‚Mapped %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:
- Каждое сопоставленное свойство визуально помечено (атрибут).
- Преобразование выполняется централизованно, что обеспечивает его согласованность и тестируемость.
- Тексты ошибок указывают, какое свойство и какой источник затронуты.
- Режим отладки при необходимости предоставляет цепочку доказательств, без необходимости ставить точки останова в рабочем процессе.
Ограничения и типичные подводные камни
- NULL-семантика: Без собственного Nullable-концепта (z. B.
Nullable<T>oder Option-Types) установка NULL не является однозначной. В сниппете NULL по умолчанию пропускается. Это консервативно и предотвращает неявные перезаписи. - Жизненный цикл TRttiContext: Мы строим кэш один раз для каждого типа и затем освобождаем Context. Это обычная практика. Важно: не создавать новый RTTI-Context при каждой операции присвоения поля.
- Параллельность: Кэш защищён через Monitor. В условиях высокопараллельных маппингов (z. B. REST-Server) стоит дополнительно рассмотреть предварительное заполнение кэша при старте (Preload), чтобы снизить конкуренцию за блокировки.
- PropertyType Kind:
tkClassundtkSetsind absichtlich nicht implementiert. Für verschachtelte Objekte sollten Sie entweder rekursiv mappen (mit klarer Policy) oder bewusst per Hand zuweisen. - Проблемы локали:
varDoubleüberVarAsTypeist relativ robust, aber Strings wie „1,23“ vs. „1.23“ sind trotzdem ein Thema. Wenn Ihre Quellen Strings liefern, ist ein eigener Parser (mit definierter Culture) oft besser.
Вариант для FireDAC и TDataSet: Reader-Adapter statt Mapper-Kopplung
В BDE-Ablosung mit nativer Anbindung- oder klassischen VCL/Win32-Anwendungen ist die Quelle häufig ein TDataSet. Statt den Mapper an TField zu binden, schreiben Sie einen Adapter, der das Interface IValueReader erfüllt. Der Vorteil: Der Mapper bleibt unabhängig vom Datenzugriff (wichtig, wenn Sie Datenzugriff später in Services oder einen REST-Server auslagern).
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.
- Участки с высокой пропускной способностью (например, ETL массовых данных) выигрывают от кодогенерированных мапперов или ручной оптимизации маппинга, даже если RTTI кэшируется.
- Nullable/Optional — это отдельная тема. Если вам действительно нужно различать «отсутствует», «NULL» и «значение по умолчанию», выражайте это в доменной модели, а не скрывайте в маппере.
Роль в архитектуре и эксплуатации
С архитектурной точки зрения этот маппер — компонент инфраструктуры на границе между представлением данных и доменом. Он не заменяет корректную слоистую архитектуру, но может её поддерживать: доступ к данным (FireDAC, SQL, Views) может оставаться прагматичным, в то время как модель домена остаётся согласованной. В многослойных системах (часто называемых Layer-3 архитектура: UI, Domain/Services, инфраструктура) маппер относится к инфраструктуре и используется сервисами, а не UI-формами.
С точки зрения эксплуатации важно: не включайте moDebug постоянно в продуктивных сервисах, используйте его выборочно. Для трудно воспроизводимых проблем с данными имеет смысл иметь переключаемый диагностический путь (конфигурация, Feature-Flag). В противном случае возрастёт объём логов и возможны побочные эффекты.
Вывод: RTTI — да, но только при наличии чётких ограничений
Delphi RTTI для сопоставления без магии работает хорошо, когда вы используете RTTI как инструмент для декларативных метаданных — не как приглашение к неявным эвристикам. Атрибуты как opt-in, централизованная конвертация, кэш на тип и понятные тексты ошибок переводят тему из «непрозрачной» в «готовую к эксплуатации». Подход сознательно не универсален: для вложенных графов, строгой NULL-семантики или максимальной производительности потребуются дополнительные компоненты. В качестве надёжного моста между структурами Dataset/Legacy и более современными доменными объектами он, однако, во многих Delphi-кодовых базах именно тот прагматичный шаг, который делает модернизацию возможной.
Если в наработанном Delphi-приложении вы застряли на стыках сопоставления, качестве данных или при поэтапной модернизации, мы можем совместно аккуратно это настроить и вписать в вашу архитектуру: Свяжитесь с нами.
В профессиональном контексте также важную роль играют Delphi Rtti Mapping и Attribute Mapping Delphi, когда интеграции, потоки данных и дальнейшее развитие должны работать согласованно.