Net-Base Журнал

08.05.2026

Delphi RTTI для сопоставления без магии: на основе атрибутов, отлаживаемое и совместимое с legacy

Прагматичный шаблон сопоставления с Delphi RTTI: атрибуты вместо соглашений, контролируемые преобразования, чёткие сообщения об ошибках и режим отладки, который действительно помогает в рабочем режиме. С фрагментами исходного кода для сопоставления Dataset или Record с объектами без скрытой магии.

08.05.2026

Кто эксплуатирует унаследованное бизнес‑ПО в 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: tkClass und tkSet sind absichtlich nicht implementiert. Für verschachtelte Objekte sollten Sie entweder rekursiv mappen (mit klarer Policy) oder bewusst per Hand zuweisen.
  • Проблемы локали: varDouble über VarAsType ist 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).

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. Поддерживаемость в команде: атрибуты делают правила маппинга видимыми и подлежащими ревью, что в больших кодовых базах имеет решающее значение.

Ограничения применения также очевидны:

  • Сложные графы объектов (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, когда интеграции, потоки данных и дальнейшее развитие должны работать согласованно.

Обсудить проект или задачу по модернизации с Net-Base.

Поделиться записью

Поделиться этой записью напрямую

LinkedIn, X, XING, Facebook, WhatsApp и E-Mail доступны сразу. Для Instagram мы сразу подготовим ссылку и краткий текст.

Электронная почта

Instagram открывается в новой вкладке. Ссылка и короткий текст предварительно копируются в буфер обмена.