Net-Base Ajakiri

08.05.2026

Delphi RTTI kaardistamiseks ilma võluta: atribuutidel põhinev, silumisvõimeline ja pärandiga ühilduv

Pragmaatiline mappimismuster koos Delphi RTTI: atribuudid konventsioonide asemel, kontrollitud teisendused, selged veateated ja silumisrežiim, mis tootmiskeskkonnas tõeliselt aitab. Koos lähtekoodinäidetega Dataset- või Record-ilt objektile mappimiseks ilma peidetud maagia.

08.05.2026

Kes haldab kasvanud ärirakendust Delphi, tunneb seda pingevälja: ühel pool soovitakse struktureeritud domeeniobjekte ja selgeid kihte, teisel pool on Datasets, Variants, CSV-impordid, liidese payload’id või REST-API, mis tuleb „mingil moel“ objektidele kaardistada. Just siin jõutakse kiiresti Delphi RTTI kaardistamiseni ilma maagita: ehk kaardistamine peegelduse abil (RTTI = Run-Time Type Information, tüübiinfo käituse ajal), kuid nii, et see jääb jälgitavaks, hästi debugitavaks ega sõltu salaja konventsioonidest või nimenippidest.

Oluline punkt: „maagia“ ei teki tavaliselt RTTI-st enesest, vaid implitsiitsetest reeglitest. Kui kaardistamisreeglid on seevastu selgelt atribuutides kirjas, teisendused tsentraliseeritud ja vead osutavad konkreetsele põhjusile, muutub RTTI tööriistaks, mitte ootamatuseks.

Miks RTTI-põhine kaardistamine Delphi puhul sageli ebaõnnestub

RTTI-põhine kaardistamine ei hääbu reaalses süsteemis tavaliselt idee tõttu, vaid ääretingimuste pärast:

  • Legacy-andmete vormid: Null/Empty/0 ei ole selgelt eristatud, väljatüübid muutuvad, stringid sisaldavad „N/A“.
  • Võõravad konventsioonid: „Väli kannab sama nime mis Property“ töötab kuni esimese alias’i, join’i või refaktoreeritud Property-nimeni.
  • Raskesti debugitav: Kui mapper „lihtsalt midagi ei sea“, puudub hiljem põhjus. Tootmises on see laastav.
  • Jõudlusmüüdid: RTTI tembeldatakse üldiselt kui „aeglane“, kuigi sageli on probleemiks vahemällu salvestuse puudumine.

Hoonekindel lähenemine peaks seetõttu (1) sisaldama eksplitsiitseid kaardistamise metainfosid, (2) selgelt käsitlema teisendusi ja null-semantikat, (3) tooma välja vead ja debug-väljundid ning (4) vahemällu salvestama RTTI-infot.

Delphi RTTI kaardistamine ilma maagita: disainiprintsiibid

Järgmine muster on teadlikult „igav“ parimas tähenduses: reeglid on nähtavad, kõrvalmõjud piiratud ja seda saab samm-sammult olemasolevatesse moodulitesse integreerida.

  • Atribuudid, mitte nimenõuded: Property-le lisatakse atribuut, mis nimetab lähtekolonni.
  • Opt-in: Ainult märgitud Properties seatakse. Ei üllatusi „kõigi avaldatud Properties’idega“.
  • Teisendus ühes kohas: Variant/String/Integer/Boolean/Enum/Nullable kaardistatakse tsentraalselt.
  • Debug-režiim: Valikuline logimine, milliseid välju seatakse või jäetakse vahele – koos põhjendusega.
  • RTTI-vahemälu: Kõige kulukamad osad (omaduste nimekiri, atribuutide hindamine) valmistatakse ette per-tüübi alusel.

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

Koodilõik kaardistab rea (nt BDE-asendamine natiivse ühendusega via TDataSet) objektile. Selle asemel, et mapperit tugevalt TField-iga siduda, kasutame väikest Reader-liidest. See on praktikas väärtuslik, sest hiljem saab sama loogikat kasutada ka JSON-i, INI, CSV või API-vastuste puhul.

Delphi
unit RttiMapping;

interface

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

type
  // Eksplitsiitne mappimine: Property <- allika nimi
  MapFromAttribute = class(TCustomAttribute)
  private
    FName: string;
  public
    constructor Create(const AName: string);
    property Name: string read FName;
  end;

  // Väike abstraktsioon: väärtuse tagastamine + olemasolu/NULL-i eristamine
  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-konverteerimine ebaõnnestus: "%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-ordinaal väljaspool vahemikku: %d', [Ord]);
    Exit(Ord);
  end;

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

  // Konverteerimine on teadlikult selektiivne: parem ebaõnnestuda selgelt kui vaikides 'mingil moel'.
  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-mappimine ei ole implementeeritud %s jaoks', [AProp.Name]);

    tkClass:
      raise ERttiMappingError.CreateFmt('Class-Property mappimine ei ole implementeeritud %s jaoks', [AProp.Name]);
  else
    raise ERttiMappingError.CreateFmt('TypeKind ei ole toetatud (%s) jaoks %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 või Target on 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('Allikas puudub: "%s" jaoks Property %s',
          [M.SourceName, M.Prop.Name]);
      Continue;
    end;

    if AReader.IsNull(M.SourceName) then
    begin
      if moIgnoreNull in AOptions then
        Continue;
      // Ilma Nullable/Optional-mehhanismita ei saa NULL-i mõistlikult määrata.
      Continue;
    end;

    V := AReader.GetValue(M.SourceName);

    try
      SetPropertyValue(ATarget, M.Prop, V);
      if moDebug in AOptions then
      begin
        Msg := Format('Kaardistatud %s <- %s (%s)', [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
        OutputDebugString(PChar(Msg));
      end;
    except
      on E: Exception do
        raise ERttiMappingError.CreateFmt('Mappimise viga %s <- %s: %s',
          [M.Prop.Name, M.SourceName, E.Message]);
    end;
  end;
end;

end.

Milleks see vajalik on

Saate mappingu, mida saab koodikontrollides selgelt hinnata:

  • Iga kaardistatud Property on visuaalselt märgitud (Attribut).
  • Konverteerimine on tsentraalne, mistõttu järjepidev ja testitav.
  • Veateated näitavad, milline Property ja milline allikas on mõjutatud.
  • Debug-režiim annab vajadusel tõendusketi, ilma et peaksite tootmisprotsessis breakpoints’i kasutama.

Piirtingimused ja tüüpilised komistuskohad

  • NULL-semantika: Ilma enda Nullable-kontseptsioonita (nt Nullable<T> või Option-Types) ei ole „NULL määramine“ üheselt mõistetav. Näites jäetakse NULL vaikimisi vahele. See on konservatiivne ja väldib vaikseid ülekirjutusi.
  • TRttiContext-Lebensdauer: Me ehitame cache’i üks kord tüübi kohta ja viskame Contexti seejärel ära. See on tavapärane. Oluline: ära ehita iga välja määramise jaoks uut RTTI-Context’i.
  • Threading: Cache on Monitori abil kaitstud. Kõrge paralleelsuse korral mappingutes (nt REST-Server) tasub lisaks kaaluda vahemälu „soojalt“ ülesehitamist käivitamisel (Preload), et vähendada lock-contentionit.
  • PropertyType Kind: tkClass ja tkSet on sihilikult mitte-implementeeritud. Sügavamalt pesastatud objektide puhul kaaluge kas rekursiivset mapimist (selge poliitikaga) või teadlikku käsitsi määramist.
  • Locale-Fallen: varDouble üle VarAsType on suhteliselt robustne, kuid stringid nagu „1,23“ vs. „1.23“ võivad siiski probleeme tekitada. Kui teie allikad annavad stringe, on sageli parem kasutada oma parserit (määratud Culture’iga).

Variant für FireDAC ja TDataSet: Reader-Adapter statt Mapper-Kopplung

In BDE-Ablosung mit nativer Anbindung- või klassikalistes VCL/Win32-rakendustes on allikas sageli TDataSet. Selle asemel, et siduda mapperiga TField, kirjutage adapter, mis implementeerib liidese IValueReader. Eelis: mapper jääb andmejuurdepääsust sõltumatuks (oluline, kui te viite andmejuurdepääsu hiljem teenustesse või 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;

Nii näeb konkreetne mapping välja:

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;

Kus see lähenemine tasub end ära — ja kus mitte

Seda mustrit tasub tavaliselt kasutada kolmes olukorras:

  1. Järk-järguline moderniseerimine: Soovite domeeniobjekte kasutusele võtta, ilma et peaksite andmejuurdepääsu kohe täielikult ümber tegema (tüüpiline juhtum: Delphi moderniseerimine olemasolevates rakendustes).
  2. Liideste ääred: CSV-/Excel-impordid, REST-payloadid või „segu“ andmeallikad vajavad robustset konverteerimist ja selgeid veateateid.
  3. Meeskonna hooldatavus: Atribuudid muudavad mappimise reeglid nähtavaks ja läbivaatatavaks, mis suuremates koodibaasides on väga väärtuslik.

Ka kasutuspiirid on selged:

  • Komplekssed objektigraafid (Child-Collections, tsüklilised viited) ei peaks te neid „automagiliselt“ mappima. Selle jaoks on selge, eksplicitne kood või eraldiseisev Assembler/Factory-Muster tavaliselt stabiilsem.
  • Suurläbilaskevõimega hotpathid (nt massandmete-ETL) saavad pigem kasu koodigeneraatoriga genereeritud mapperitest või käsitsi optimeeritud mappimisest, isegi kui RTTI on vahemällu salvestatud.
  • Nullable/Optional on omaette teema. Kui peate tõesti eristama „puudub“, „NULL“ ja „vaikeväärtus“, tuleks see väljendada domeenimudelisse, mitte peita mapperisse.

Paigutus arhitektuuri ja käituse konteksti

Arhitektuurivaatenurgast on see mapper infrastruktuuri komponent andmete esituse ja domeeni piiril. See ei asenda puhast kihistamist, kuid võib selle võimaldada: andmejuurdepääs (FireDAC, SQL, Views) võib jääda pragmaatiliseks, samal ajal kui domeen püsib järjepidevana. Mitmekihilistes süsteemides (sageli nimetatakse Layer-3 arhitektuur: UI, domeen/teenused, infrastruktuur) kuulub mapper infrastruktuuri ja seda kasutavad teenused, mitte UI-vormid.

Käituse seisukohalt oluline: ärge lülitage moDebug püsivalt sisse tootmisteenustes, vaid ainult sihipäraselt. Raskesti reprodutseeritavate andmeprobleemide korral on mõistlik omada lülitatavat diagnostikakanalit (konfiguratsioon, Feature-Flag). Vastasel juhul ähvardavad logimaht ja kõrvalmõjud.

Järeldus: RTTI jah, kuid ainult selgete juhtpõhimõtetega

Delphi RTTI kaardistamiseks ilma võluta toimib hästi, kui käsitlete RTTI-d deklaratiivsete metaandmete tööriistana — mitte kutseina varjatud heuristikatele. Attribuudid opt-inina, tsentraliseeritud teisendused, tüübi-põhine vahemälu ja arusaadavad veateated viivad teema „läbipaistmatu“ juurest „töökorda“. Lähenemisviis ei pretendeeri universaalsusele: pesastatud graafide, rangete nullsemantika või maksimaalse jõudluse puhul on vajalikud täiendavad komponendid. Kuid kui robustne sild andmekogumite/pärandstruktuuride ja kaasaegsemate domeeniobjektide vahel, on see paljudes Delphi-koodibaasides just see pragmaatiline samm, mis moderniseerimise üldse võimalikuks teeb.

Kui teie kasvanud Delphi-rakenduses olete praegu takerdunud kaardistamise kitsaskohtade, andmekvaliteedi või samm-sammulise moderniseerimise taha, saame seda koos korrektselt üles ehitada ja teie arhitektuuri integreerida: võtke ühendust.

Eraldases kontekstis omavad ka Delphi RTTI-kaardistamine ja atribuutide kaardistamine Delphi olulist rolli, kui integratsioonid, andmevood ja edasine arendus peavad puhtalt koos toimima.

Arutage projekti või moderniseerimisettevõtmist koos Net-Base.

Jaga postitust

Jaga seda postitust otse

LinkedIn, X, XING, Facebook, WhatsApp ja e-post on kohe saadaval. Instagrami jaoks valmistame kohe lingi ja lühiteksti ette.

e-post

Instagram avatakse uues vahekaardis. Link ja lühitekst kopeeritakse eelnevalt lõikepuhvrisse.