Net-Base Magazyn

08.05.2026

Delphi RTTI dla mapowania bez magii: oparte na atrybutach, przystosowane do debugowania i zgodne z systemami legacy

Pragmatyczny wzorzec mapowania z Delphi RTTI: atrybuty zamiast konwencji, kontrolowane konwersje, czytelne komunikaty o błędach i tryb debugowania, który naprawdę pomaga w środowisku produkcyjnym. Z fragmentami kodu źródłowego do mapowania Dataset lub Record na obiekt bez ukrytej magii.

08.05.2026

Każdy, kto eksploatuje istniejące oprogramowanie biznesowe w Delphi, zna to pole napięć: z jednej strony oczekuje się ustrukturyzowanych obiektów domenowych i wyraźnych warstw, z drugiej występują Datasets, Variants, importy CSV, payloady interfejsów lub API REST, które „jakoś” trzeba zmapować na obiekty. Właśnie tutaj szybko dochodzi się do Delphi RTTI für Mapping ohne Magie: czyli mapowania przez Reflection (RTTI = Run-Time Type Information, informacje o typie w czasie wykonywania), ale w taki sposób, żeby było zrozumiałe, dobrze debugowalne i nie polegało ukradkiem na konwencjach czy gierkach z nazwami.

Kluczowa uwaga: „magia” zwykle nie wynika z samego RTTI, lecz z niejawnych reguł. Jeśli reguły mapowania są natomiast jawnie zapisane w atrybutach, konwersje są scentralizowane, a błędy wskazują klarowną przyczynę, RTTI staje się narzędziem, a nie zaskoczeniem.

Dlaczego RTTI-mapping w Delphi często zawodzi

Mapowanie oparte na RTTI rzadko zawodzi na poziomie pomysłu — częściej przyczyną są warunki brzegowe:

  • Formaty danych legacy: Null/Empty/0 nie są wyraźnie rozróżnione, typy pól się zmieniają, łańcuchy zawierają „N/A”.
  • Wkradające się konwencje: „Pole ma tę samą nazwę co właściwość” działa do pierwszego aliasu, joinu lub przebudowanej nazwy właściwości.
  • Trudne do debugowania: Gdy mapper „po prostu nic nie ustawia”, później brakuje informacji o przyczynie. W środowisku produkcyjnym to zabójcze.
  • Mity o wydajności: RTTI jest bezrefleksyjnie etykietowane jako „powolne”, podczas gdy zwykle problemem jest brak cachowania.

Solidne podejście powinno zatem (1) mieć jawne metadane mapowania, (2) jednoznacznie obsługiwać konwersje i semantykę null, (3) dostarczać błędy i informacje debugujące oraz (4) cachować informacje RTTI.

Delphi RTTI für Mapping ohne Magie: Zasady projektowe

Poniższy wzorzec jest świadomie „nudny” w najlepszym sensie: reguły są widoczne, skutki uboczne ograniczone, i można go etapami wprowadzać do istniejących modułów.

  • Atrybuty zamiast konwencji nazewniczych: Właściwość otrzymuje atrybut określający kolumnę źródłową.
  • Opt-in: Ustawiane są tylko oznaczone właściwości. Brak niespodzianek wynikających z „wszystkich publicznych właściwości”.
  • Konwersje w jednym miejscu: Variant/String/Integer/Boolean/Enum/Nullable są mapowane centralnie.
  • Tryb debugowania: Opcjonalnie rejestruje się, które pola zostały ustawione/pominięte – wraz z powodem.
  • RTTI-Caching: Najdroższe operacje (lista właściwości, analiza atrybutów) są przygotowywane per typ.

Source-Schnipsel: Attribut-Mapping mit RTTI, Caching und Debug

Ten fragment odwzorowuje jeden wiersz (np. z BDE-Ablosung mit nativer Anbindung via TDataSet) na obiekt. Zamiast ściśle wiązać mapper z TField, używamy małego interfejsu Reader. W praktyce jest to wartościowe, ponieważ później tę samą logikę można zastosować także do JSON, INI, CSV czy odpowiedzi API.

Delphi
unit RttiMapping;

interface

uses
  System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
  System.Variants;

type
  // Jawne mapowanie: właściwość <- nazwa źródła
  MapFromAttribute = class(TCustomAttribute)
  private
    FName: string;
  public
    constructor Create(const AName: string);
    property Name: string read FName;
  end;

  // Mała abstrakcja: zwraca wartość i rozróżnia istnienie/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: tylko właściwości z atrybutem
        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('Konwersja na Boolean nie powiodła się: "%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('Ordinal wyliczenia poza zakresem: %d', [Ord]);
    Exit(Ord);
  end;

  Name := VarToStr(V);
  Ord := GetEnumValue(AEnumType.Handle, Name);
  if Ord < 0 then
    raise ERttiMappingError.CreateFmt('Nazwa wyliczenia nieznana: "%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;

  // Konwersja celowo selektywna: lepiej jawnie zakończyć błędem niż cicho "jakoś".
  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('Mapowanie typu set niezaimplementowane dla %s', [AProp.Name]);

    tkClass:
      raise ERttiMappingError.CreateFmt('Mapowanie właściwości klasy niezaimplementowane dla %s', [AProp.Name]);
  else
    raise ERttiMappingError.CreateFmt('TypeKind nieobsługiwany (%s) dla %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 lub Target jest 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('Brak źródła: "%s" dla właściwości %s',
          [M.SourceName, M.Prop.Name]);
      Continue;
    end;

    if AReader.IsNull(M.SourceName) then
    begin
      if moIgnoreNull in AOptions then
        Continue;
      // Bez mechaniki Nullable/Optional nie da się sensownie przypisać 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('Błąd mapowania przy %s <- %s: %s',
          [M.Prop.Name, M.SourceName, E.Message]);
    end;
  end;
end;

end.

Do czego to służy

Otrzymują Państwo mapowanie, które można rzetelnie ocenić w code-review:

  • Każda zmapowana właściwość jest wizualnie oznaczona (atrybut).
  • Konwersja jest scentralizowana, dzięki czemu spójna i testowalna.
  • Komunikaty o błędach wskazują, która właściwość i które źródło są dotknięte.
  • Tryb debugowania zapewnia, w razie wątpliwości, łańcuch dowodowy bez konieczności stosowania breakpointów w procesie produkcyjnym.

Ograniczenia i typowe pułapki

  • NULL-Semantik: Bez własnego konceptu Nullable (np. Nullable<T> lub typów opcyjnych) ustawianie „NULL” nie jest jednoznaczne. W snippecie NULL jest domyślnie pomijany. To konserwatywne podejście zapobiega cichym nadpisaniom.
  • TRttiContext-Lebensdauer: Budujemy cache raz na typ i wyrzucamy danach Context. To jest powszechne. Ważne: Nie tworzyć nowego RTTI-Context dla każdej przypisania pola.
  • Threading: Cache jest chroniony za pomocą Monitor. W mapowaniach o wysokiej równoległości (np. REST-Server) należy dodatkowo rozważyć wstępne zbudowanie cache przy starcie (preload), aby zmniejszyć rywalizację o blokady.
  • PropertyType Kind: tkClass i tkSet są celowo niezaimplementowane. Dla zagnieżdżonych obiektów należy albo mapować rekurencyjnie (z jasną polityką) albo przypisywać świadomie ręcznie.
  • Locale-Fallen: varDouble przez VarAsType jest względnie odporne, ale ciągi takie jak „1,23” vs. „1.23” nadal stanowią problem. Jeśli Państwa źródła dostarczają stringi, własny parser (z określoną Culture) jest często lepszy.

Variante für FireDAC und TDataSet: Reader-Adapter statt Mapper-Kopplung

W aplikacjach BDE-Ablosung mit nativer Anbindung lub klasycznych VCL/Win32 źródłem często jest TDataSet. Zamiast wiązać Mapper z TField, napisz adapter spełniający interfejs IValueReader. Zaletą: Mapper pozostaje niezależny od dostępu do danych (ważne, jeśli przeniosą Państwo dostęp do danych później do usług lub na 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;

W rezultacie konkretne mapowanie wygląda następująco:

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;

Gdzie podejście ma sens — a gdzie nie

Ten wzorzec zazwyczaj ma sens w trzech sytuacjach:

  1. Stopniowa modernizacja: Chcą Państwo wprowadzić obiekty domenowe, nie przebudowując od razu całego dostępu do danych (typowe przy Delphi Modernizacja w aplikacjach istniejących).
  2. Punkty styku interfejsów: importy CSV/Excel, REST-payloady lub „mieszane“ źródła danych wymagają solidnej konwersji i czytelnych komunikatów o błędach.
  3. Utrzymanie w zespole: Atrybuty czynią reguły mapowania widocznymi i przeglądalnymi, co w większych bazach kodu jest bardzo cenne.

Istnieją też wyraźne granice zastosowania:

  • Złożone grafy obiektów (kolekcje potomne, cykliczne referencje) nie powinny być mapowane „automagicznie“. W takich przypadkach zwykle stabilniejszy jest jawny kod lub oddzielny wzorzec assembler/factory.
  • Ścieżki o dużej przepustowości (np. ETL masowych danych) lepiej korzystają z mapperów generowanych kodowo lub ręcznie zoptymalizowanego mapowania, nawet jeśli RTTI jest zbuforowane.
  • Nullable/Optional to odrębny temat. Jeśli naprawdę musicie rozróżniać „nieobecne“, „NULL“ i „wartość domyślną“, powinniście to wyrazić w modelu domenowym, a nie ukrywać w mapperze.

Miejsce w architekturze i eksploatacji

Z perspektywy architektury ten mapper jest komponentem infrastruktury na granicy między reprezentacją danych a domeną. Nie zastępuje czystego podziału na warstwy, ale może go umożliwić: dostęp do danych (FireDAC, SQL, widoki) może pozostać pragmatyczny, podczas gdy domena pozostaje spójna. W systemach wielowarstwowych (często określanych jako Layer-3 architektura: UI, domena/usługi, infrastruktura) mapper należy umieścić w infrastrukturze i powinien być używany przez serwisy, nie przez formularze UI.

W kontekście eksploatacji ważne: w środowisku produkcyjnym nie należy na stałe włączać moDebug, tylko używać go selektywnie. Dla trudno odtwarzalnych problemów z danymi warto mieć przełączalny kanał diagnostyczny (konfiguracja, feature-flag). W przeciwnym razie grozi duże wolumeny logów i skutki uboczne.

Wniosek: RTTI tak, ale tylko z jasnymi wytycznymi

Delphi RTTI do mapowania bez magii sprawdza się wtedy, gdy wykorzystują Państwo RTTI jako narzędzie do deklaratywnych metadanych – a nie jako zaproszenie do ukrytych heurystyk. Atrybuty jako opt-in, scentralizowana konwersja, cache na typ oraz zrozumiałe komunikaty o błędach przesuwają zagadnienie z „nieprzejrzysty” do „gotowy do eksploatacji”. Podejście celowo nie jest uniwersalne: dla zagnieżdżonych grafów, ścisłej semantyki null lub maksymalnej wydajności potrzebne są dodatkowe elementy. Jako solidny most pomiędzy strukturami Dataset/Legacy a nowocześniejszymi obiektami domenowymi jest ono jednak w wielu Delphi bazach kodu tym pragmatycznym krokiem, który w ogóle umożliwia modernizację.

Jeżeli w rozwiniętej aplikacji Delphi utknęli Państwo na styku mapowania, jakości danych lub stopniowej modernizacji, możemy to wspólnie uporządkować i dopasować do Państwa architektury: prosimy o kontakt.

W kontekście merytorycznym odgrywają również istotną rolę Delphi Rtti Mapping i Attribute Mapping Delphi, gdy integracje, przepływy danych i dalszy rozwój muszą ze sobą spójnie współgrać.

Omówić projekt lub przedsięwzięcie modernizacyjne z Net-Base.

Udostępnij wpis

Udostępnij ten wpis bezpośrednio

LinkedIn, X, XING, Facebook, WhatsApp i e‑mail są natychmiast dostępne. Dla Instagrama przygotowujemy od razu link i krótki tekst.

E-mail

Instagram otwiera się w nowej karcie. Link i krótki tekst są wcześniej kopiowane do schowka.