Net-Base Lehti

08.05.2026

Delphi RTTI kartoitukseen ilman taikuutta: attribuuttipohjainen, virheenkorjattava ja legacy-yhteensopiva

Pragmaattinen mapping-malli, jossa Delphi RTTI: attribuutit konventioiden sijaan, hallitut konversiot, selkeät virheilmoitukset ja debug-tila, joka käytännössä auttaa tuotannossa. Mukana lähdekoodiesimerkkejä Dataset- tai Record–objekti-mappingiin ilman piilotettua magiaa.

08.05.2026

Joka ylläpitää kasvaneita liiketoimintaohjelmistoja Delphi tietää jännitteen: toisaalta halutaan rakenteellisia domääniobjekteja ja selkeät kerrokset, toisaalta on Datasets, Variants, CSV-tuonnit, rajapintapayloadit tai REST-API, jotka pitää „jollain tavalla“ mappata objekteihin. Tässä kohdassa päädytään helposti Delphi RTTI für Mapping ohne Magie: eli mapping per Reflection (RTTI = Run-Time Type Information, tyyppitiedot ajonaikaisesti), mutta siten, että se pysyy jäljitettävänä, hyvin debugattavana eikä perustu salakavalasti konventioihin tai nimileikkeihin.

Keskeinen huomio: „taikuus“ syntyy yleensä ei niinkään RTTI:stä sinänsä, vaan implisiittisistä säännöistä. Kun mapping-säännöt ovat eksplisiittisesti attribuuteissa, konversiot keskitettyjä ja virheet nimeävät selkeän syyn, RTTI muuttuu työkaluksi eikä yllätykseksi.

Miksi RTTI-mapping Delphi:ssa usein pettää

RTTI-pohjainen mapping epäonnistuu reaalisissa järjestelmissä harvoin idean vuoksi, usein sen reunaehtojen takia:

  • Legacy-Datenformen: Null/Empty/0 eivät erotu selkeästi, kenttätyypit vaihtuvat, Strings sisältävät „N/A“.
  • Schleichende Konventionen: „Feld heißt wie Property“ toimii siihen asti, kunnes tulee alias, join tai refaktoroitu Property-nimi.
  • Schwer zu debuggen: Kun mapper „yksinkertaisesti ei aseta mitään“, myöhemmin puuttuu syy. Tuotantoympäristössä se on erityisen haitallista.
  • Performance-Mythen: RTTI leimataan yleisesti „hitaaksi“, vaikka usein ongelma on puuttuva välimuisti.

Kestävä lähestymistapa pitäisi siksi (1) sisältää eksplisiittiset mapping-metatiedot, (2) käsitellä konversio ja null-semanttiikka selkeästi, (3) tuottaa virhe- ja debug-tulosteita sekä (4) välimuistittaa RTTI-tiedot.

Delphi RTTI für Mapping ohne Magie: Designprinzipien

Seuraava malli on tarkoituksella „tylsä“ parhaassa merkityksessä: säännöt ovat näkyvissä, sivuvaikutukset rajattuja, ja sen voi tuoda olemassa oleviin moduuleihin vaiheittain.

  • Attribute statt Namenskonvention: Propertylle asetetaan attribuutti, joka nimeää lähdesarakkeen.
  • Opt-in: Vain merkityt Properties asetetaan. Ei yllätyksiä „kaikki julkistetut Properties“ -periaatteesta.
  • Konvertierung an einer Stelle: Variant/String/Integer/Boolean/Enum/Nullable muunnetaan keskitetysti.
  • Debug-Mode: Valinnaisesti kirjataan, mitkä kentät asetettiin/ohitettiin – syy mukaan lukien.
  • RTTI-Caching: Kalleimmat osat (Propertyliste, Attributeauswertung) valmistellaan tyyppikohtaisesti.

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

Katkelma mapittaa yhden rivin (esim. BDE-ablousungin natatiivilla kytkennällä via TDataSet) objektiin. Sen sijaan, että sidomme mapperin tiukasti TField:iin, käytämme pientä Reader-rajapintaa. Tämä on käytännössä arvokasta, koska sama logiikka toimii myöhemmin myös JSON:lle, INI:lle, CSV:lle tai API-responseille.

Delphi
unit RttiMapping;

interface

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

type
  // Eksplisiittinen mappaus: Property <- lähdenimi
  MapFromAttribute = class(TCustomAttribute)
  private
    FName: string;
  public
    constructor Create(const AName: string);
    property Name: string read FName;
  end;

  // Pieni abstraktio: arvojen tarjoaminen + olemassaolon/NULLin erottelu
  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: vain attribuuteilla varustetut propertyt
        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-muunnos epäonnistui: "%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-ordinali alueen ulkopuolella: %d', [Ord]);
    Exit(Ord);
  end;

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

  // Konversio tietoisesti selektiivinen: mieluummin selkeä epäonnistuminen kuin hiljainen "jollain tavalla".
  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-mappingia ei ole toteutettu %s:lle', [AProp.Name]);

    tkClass:
      raise ERttiMappingError.CreateFmt('Class-property-mapping ei ole toteutettu %s:lle', [AProp.Name]);
  else
    raise ERttiMappingError.CreateFmt('TypeKind ei tuettu (%s) kohteelle %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 tai 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('Lähde puuttuu: "%s" Propertylle %s',
          [M.SourceName, M.Prop.Name]);
      Continue;
    end;

    if AReader.IsNull(M.SourceName) then
    begin
      if moIgnoreNull in AOptions then
        Continue;
      // Ilman Nullable/Optional-mekaniikkaa NULL:ia ei voi järkevästi asettaa.
      Continue;
    end;

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

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

end.

Mihin tästä on hyötyä

Saatte mappingin, jonka voi arvioida selkeästi koodikatselmuksissa:

  • Jokainen mapattu ominaisuus on visuaalisesti merkitty (attribuutti).
  • Muunnos on keskitetty, joten se on yhdenmukainen ja testattavissa.
  • Virheilmoitukset kertovat, mikä ominaisuus ja mikä lähde on kyseessä.
  • Debug-tila antaa tarvittaessa todisteketjun, ilman että tarvitsee asettaa breakpointteja tuotantoprosessiin.

Reunaehdot ja tyypilliset sudenkuopat

  • NULL-semanttiikka: Ilman omaa Nullable-käsitettä (esim. Nullable<T> tai Option-tyypit) NULL:n asettaminen ei ole yksiselitteistä. Katkelmassa NULL jätetään oletuksena huomioimatta. Tämä on konservatiivista ja estää hiljaisia ylikirjoituksia.
  • TRttiContext-elinkaari: Rakennamme välimuistin kerran per tyyppi ja heitämme Contextin pois sen jälkeen. Tämä on tavallista. Tärkeää: älä luo uutta RTTI-Contextia joka kenttäkohtaisessa määrityksessä.
  • Monisäikeisyys: Välimuisti on suojattu Monitorilla. Erittäin rinnakkaisissa mappingeissa (esim. REST-Server) kannattaa lisäksi harkita välimuistin esirakentamista käynnistyksessä (Preload) lukkojen kilpailun vähentämiseksi.
  • PropertyType Kind: tkClass ja tkSet on tarkoituksella jätetty toteuttamatta. Sisäkkäisille objekteille tulisi joko mapata rekursiivisesti (selkeällä käytännöllä) tai tietoisesti asettaa käsin.
  • Aluekohtaiset sudenkuopat: varDouble VarAsType:n kautta on suhteellisen robusti, mutta merkkijonot kuten „1,23“ vs. „1.23“ ovat silti ongelma. Jos lähteenne toimittavat merkkijonoja, oma jäsentäjä (määritellyllä Culture-asetuksella) on usein parempi.

Vaihtoehto für FireDAC ja TDataSet: Reader-adapteri mapper-kytkennän sijaan

Perinteisissä BDE-Ablosung mit nativer Anbindung- tai VCL/Win32-sovelluksissa lähteenä on usein TDataSet. Sen sijaan, että sitoisitte mapperin TField:iin, kirjoittakaa adapteri, joka toteuttaa rajapinnan IValueReader. Etu: mapper pysyy riippumattomana datan saatavasta (tärkeää, jos ulkoistatte datan käsittelyn myöhemmin palveluihin tai 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;

Näin konkreettinen mapping näyttää:

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;

Missä lähestymistapa kannattaa – ja missä ei

Tämä malli kannattaa tyypillisesti kolmessa tilanteessa:

  1. Vaiheittainen modernisointi: Haluatte ottaa käyttöön domääniobjekteja ilman, että muutatte datan käyttöä heti kokonaan (tyypillistä Delphi modernisointi olemassa olevissa sovelluksissa).
  2. Rajapintakohdat: CSV-/Excel-tuonnit, REST-payloadit tai „sekoitetut“ tietolähteet tarvitsevat vankkaa konversiota ja selkeitä virheilmoituksia.
  3. Ylläpidettävyys tiimissä: Attribuutit tekevät mappaussäännöt näkyviksi ja tarkastettaviksi, mikä on suuremmissa koodikannoissa arvokasta.

Käyttörajoituksia on myös selkeästi:

  • Monimutkaiset objektigrafit (lapsikokoelmat, sykliset viittaukset) ei kannata mapata „automaagisesti“. Tässä eksplisiittinen koodi tai erillinen assembler-/factory-malli on yleensä vakaampi.
  • Korkean läpimenon hot-pathit (esim. massadata-ETL) hyötyvät ennemmin koodigeneroiduista mapparista tai käsin optimoidusta mappauksesta, vaikka RTTI olisikin välimuistissa.
  • Nullable/Optional on oma aiheensa. Jos teidän täytyy todellakin erottaa „ei olemassa“, „NULL“ ja „Default“, tulisi se ilmaista domäänimallissa, ei piilottaa mapperiin.

Sijoittuminen arkkitehtuuriin ja operointiin

Arkkitehtuurin näkökulmasta tämä mapper on infrastruktuurikomponentti datan esityksen ja domaanin rajapinnassa. Se ei korvaa selkeää kerroksellisuutta, mutta voi mahdollistaa sen: datan käsittely (FireDAC, SQL, Views) voi olla edelleen pragmaattista, samalla kun domaani pysyy konsistenttina. Monikerroksisissa järjestelmissä (usein kutsuttu Layer-3 arkkitehtuuri: UI, Domain/Services, infrastruktuuri) mapper sijoittuu infrastruktuuriin ja on palveluiden käytettävissä, ei UI-lomakkeiden.

Käytännössä tärkeää: Älkää ottako moDebug:ia pysyvästi käyttöön tuotantopalveluissa, vaan käytä sitä kohdennetusti. Vaikeasti toistuvien datavirheiden diagnosointiin on järkevää tarjota kytkettävä diagnostiikkapolku (konfiguraatio, feature-flag). Muuten uhkana ovat lokimäärien kasvu ja sivuvaikutukset.

Yhteenveto: RTTI kyllä — mutta vain selkeillä ohjausperiaatteilla

Delphi RTTI kartoitukseen ilman taikuutta toimii hyvin, kun käytätte RTTI:tä deklaratiivisten metatietojen työkaluna – ei kutsuna hiljaisiin heuristiikkoihin. Attribuutit opt-in-mekanismilla, keskitetty konversio, tyyppikohtainen välimuisti ja ymmärrettävät virheilmoitukset vievät aiheen „läpinäkymättömästä“ „tuotantokelpoiseksi“. Lähestymistapa ei ole tarkoituksella universaali: syvälle upotettuihin graafeihin, tiukkaan null-semantiiikkaan tai maksimaaliseen suorituskykyyn tarvitaan lisäkomponentteja. Kestävä siltaratkaisu dataset-/legacy-rakenteiden ja modernimpien domääniobjektien välillä on kuitenkin monissa Delphi-koodikannoissa juuri se pragmaattinen askel, joka tekee modernisoinnista ylipäätään mahdollisen.

Jos olet vakiintuneessa Delphi-sovelluksessa jumissa mapping-reunojen, datalaadun tai vaiheittaisen modernisoinnin kanssa, voimme rakentaa sen yhdessä siististi ja sovittaa osaksi arkkitehtuuriasi: Ota yhteyttä.

Asiantuntijaympäristössä näyttelevät myös Delphi Rtti Mapping ja Attribute Mapping Delphi tärkeää roolia, kun integraatioiden, datavirtojen ja jatkokehityksen on toimittava saumattomasti yhdessä.

Keskustele projektista tai modernisointihankkeesta Net-Base kanssa.

Jaa artikkeli

Jaa tämä viesti suoraan

LinkedIn, X, XING, Facebook, WhatsApp ja sähköposti ovat heti käytettävissä. Instagramia varten valmistelemme linkin ja lyhyen tekstin.

Sähköposti

Instagram avautuu uuteen välilehteen. Linkki ja lyhyt teksti kopioidaan ensin leikepöydälle.