Net-Base מגזין

08.05.2026

Delphi RTTI למיפוי ללא קסמים: מבוסס מאפיינים, ניתן לניפוי שגיאות ותואם למערכות ישנות

מתווה מיפוי פרגמטי עם Delphi RTTI: תכונות במקום קונבנציות, המרות מבוקרות, טקסטי שגיאה ברורים ומצב דיבוג שמסייע בפועל בזמן הריצה. כולל קטעי קוד מקור עבור מיפוי Dataset או Record לאובייקט, ללא קסמים נסתרים.

08.05.2026

מי שמפעיל תוכנת עסק שהתפתחה ב־ Delphi מכיר את שדה המתח: מצד אחד רוצים אובייקטים תחומיים מובנים ושכבות ברורות, ומצד שני יש Datasets, Variants, ייבוא CSV, payloads של Schnittstellen או API של REST שצריכים „באיזשהו אופן“ למיפה לאובייקטים. כאן נתקלים במהירות ב־ Delphi RTTI für Mapping ohne Magie: כלומר מיפוי באמצעות Reflection (RTTI = Run-Time Type Information, מידע טיפוס בזמן ריצה), אך בצורה שניתנת למעקב, טובה לניפוי שגיאות ולא נשענת בסתר על קונבנציות או משחקי שמות.

הנקודה המרכזית: „קסם“ בדרך כלל לא נובע מ־RTTI עצמו, אלא מכללים מוחבאים. כאשר חוקי המיפוי כתובים במפורש ב־Attributes, המרות מרוכזות ושגיאות מצביעות על סיבה ברורה, RTTI הופך לכלי במקום להיות הפתעה.

מדוע מיפוי RTTI ב־Delphi נוטה להיכשל לעתים

מיפוי מבוסס RTTI נכשל במערכות אמתיות לעתים רחוקות בגלל הרעיון עצמו, ובדרך כלל בגלל תנאי שוליים:

  • פורמטי נתונים ישנים: Null/Empty/0 אינם מופרדים בבירור, סוגי שדות משתנים, מחרוזות מכילות „N/A“.
  • קונבנציות חמקמקות: „העמודה קרויה כמו ה־Property“ עובד עד האליאס הראשון, ה־join או שם Property שעבר ריפקטורינג.
  • קשה לניפוי שגיאות: אם ממפה (Mapper) „פשוט לא מגדיר כלום“, מאוחר יותר חסרה הסיבה. בביצוע זה מסוכן.
  • מיתוסי ביצועים: RTTI מתוייג באופן גורף כ“איטי“, אף שלרוב היעדר מטמון הוא הבעיה.

לכן גישה ברת־קיימא צריכה (1) לכלול מטא־נתוני מיפוי מפורשים, (2) לטפל בהמרות ובסמנטיקת Null בצורה ברורה, (3) לספק שגיאות ופלטי ניפוי שגיאות, ו־(4) להטמין במטמון מידע RTTI.

Delphi RTTI für Mapping ohne Magie: עקרונות עיצוב

התבנית הבאה מכוונת במודע להיות „משעממת“ במשמעות הטובה: הכללים גלויים לעין, תופעות הלוואי מוגבלות, וניתן להחדיר אותה בהדרגה למודולים קיימים.

  • Attributes statt Namenskonvention: ל־Property ניתנת תכונה (Attribute) שמציינת את עמודת המקור.
  • Opt-in: רק Properties שסומנו יוגדרו. אין הפתעות כתוצאה מ“כל ה־publizierten Properties“.
  • Konvertierung an einer Stelle: Variant/String/Integer/Boolean/Enum/Nullable ממופים בצורה מרכזית.
  • Debug-Mode: אופציונלית מתועדת אילו שדות הוגדרו/דלגו — כולל הסבר.
  • RTTI-Caching: החלקים היקרים ביותר (רשימת Properties, ניתוח ה־Attributes) מוכנים מראש לכל טיפוס.

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

הסניפט ממפה שורה (למשל מתוך BDE-Ablosung mit nativer Anbindung באמצעות TDataSet) לאובייקט. במקום לקשור את ה־Mapper בחוזקה ל־TField, אנו משתמשים בממשק Reader קטן. זה שימושי בפרקטיקה שכן ניתן später להשתמש באותה לוגיקה גם עבור JSON, INI, CSV או תגובות API.

Delphi
unit RttiMapping;

interface

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

type
  // Explizites Mapping: Property <- Quellname
  MapFromAttribute = class(TCustomAttribute)
  private
    FName: string;
  public
    constructor Create(const AName: string);
    property Name: string read FName;
  end;

  // Kleine Abstraktion: Wert liefern + Existenz/NULL unterscheiden
  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-Konvertierung fehlgeschlagen: "%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-Ordinal außerhalb des Bereichs: %d', [Ord]);
    Exit(Ord);
  end;

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

  // Konvertierung bewusst selektiv: lieber klar scheitern als still "irgendwie".
  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-Mapping nicht implementiert für %s', [AProp.Name]);

    tkClass:
      raise ERttiMappingError.CreateFmt('Class-Property Mapping nicht implementiert für %s', [AProp.Name]);
  else
    raise ERttiMappingError.CreateFmt('TypeKind nicht unterstützt (%s) für %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('Quelle fehlt: "%s" für Property %s',
          [M.SourceName, M.Prop.Name]);
      Continue;
    end;

    if AReader.IsNull(M.SourceName) then
    begin
      if moIgnoreNull in AOptions then
        Continue;
      // Ohne Nullable/Optional-Mechanik kann man NULL nicht sinnvoll setzen.
      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('Mapping-Fehler bei %s <- %s: %s',
          [M.Prop.Name, M.SourceName, E.Message]);
    end;
  end;
end;

end.

למה זה נחוץ

תקבלו מיפוי שניתן להערכה בצורה ברורה בביקורות קוד:

  • כל Property שממופה מסומנת באופן חזותי (Attribut).
  • ההמרה מרוכזת, ולכן עקבית וניתנת לבדיקות.
  • הודעות שגיאה מציינות איזו Property ואיזו מקור מושפעים.
  • מצב Debug יספק במקרה של ספק את שרשרת הראיות, מבלי שתצטרכו Breakpoints בתהליך הייצור.

תנאים מקדמיים ומכשולים טיפוסיים

  • NULL-Semantik: ללא קונספט Nullable משלו (למשל Nullable<T> או Option-Types) לא ברור באופן חד-ערכי מה פירוש „הגדרת NULL“. בסניפט ה-NULL מדולגת כברירת מחדל. זה שמרני ומונע החלפה שקטה של ערכים.
  • TRttiContext-Lebensdauer: אנו בונים את המטמון פעם אחת לכל טיפוס ומשליכים את ה-Context לאחר מכן. זה מקובל. חשוב: לא ליצור RTTI-Context חדש עבור כל שיוך שדה.
  • Threading: המטמון מוגן באמצעות Monitor. במיפויים בעלי מקביליות גבוהה (למשל REST-Server) עליכם לשקול גם לבנות את המטמון כבר בעת ההפעלה (Preload) כדי להפחית Lock-Contention.
  • 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.
  • Locale-Fallen: 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.

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

In 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. יכולת תחזוקה בצוות: Attributes הופכים את כללי המיפוי לנראים ולניתנים לבדיקה, מה שערכם רב בבסיסי קוד מקיפים.

גם יש גבולות ברורים לשימוש:

  • גרפים של אובייקטים מורכבים (Child-Collections, הפניות מחזוריות) לא כדאי למפות „אוטומגית“. כאן קוד מפורש או תבנית Assembler/Factory נפרדת לרוב יציבה יותר.
  • מסלולי ביצוע בעלי קצב גבוה (למשל Massendaten-ETL) מפיקים תועלת יותר ממיפויים שנוצרו בקוד או ממיפוי מותאם-ידנית, גם אם RTTI במטמון.
  • Nullable/Optional הוא נושא נפרד. אם אתם צריכים באמת להבחין בין „לא קיים“, „NULL“ ו“Default“, עליכם לבטא זאת במודל הדומיין, לא להסתיר זאת ב-Mapper.

מיקום בתוך הארכיטקטורה והתפעול

מנקודת מבט ארכיטקטונית, ה-Mapper הזה הוא רכיב תשתיתי על הגבול בין ייצוג הנתונים והדומיין. הוא לא מחליף שיבוץ שכבות נקי, אך יכול לאפשר אותו: גישת הנתונים (FireDAC, SQL, Views) עדיין יכולה להיות פרגמטית, בעוד שהדומיין נשאר עקבי. במערכות רב-שכבתיות (לעיתים מתואר כ-Layer-3 ארכיטקטורה: UI, Domain/Services, Infrastruktur) ה-Mapper שייך לתשתית ומשמש על ידי Services, לא על ידי טפסי UI.

ברמה התפעולית חשוב: אל תפעילו את moDebug באופן קבוע בשירותים פרודוקטיביים, אלא באופן ממוקד. עבור בעיות נתונים שקשה לשכפל כדאי לספק מסלול דיאגנוזה שניתן להדליקו (קונפיגורציה, Feature-Flag). אחרת יש סיכון לנפחי לוג גבוהים ותופעות לוואי.

מסקנה: RTTI — כן, אך רק עם קווי הגנה ברורים

Delphi RTTI למיפוי ללא קסמים עובד היטב כאשר אתם משתמשים ב-RTTI ככלי למטא-דאטה דקלרטיבית — ולא כהזמנה להסתמכות על היוריסטיקות נסתרות. מאפייני Opt-in, המרה מרכזית, מטמון לכל טיפוס וטקסטי שגיאה ברורים מעבירים את הגישה מ“לא שקופה“ ל“מוכנה לתפעול“. הגישה איננה אוניברסלית במתכוון: לגרפים מקוננים, לסמנטיקת null מחמירה או לביצועים מקסימליים תזדקקו לרכיבים נוספים. כגשר יציב בין מבני Dataset/Legacy לבין אובייקטי דומיין מודרניים יותר, הוא בבסיסי קוד רבים של Delphi הצעד הפרגמטי המדויק שהופך מודרניזציה לאפשרית.

אם אתם ביישום Delphi שהתפתח במשך הזמן נתקעים כרגע בממשקי מיפוי, באיכות נתונים או במודרניזציה הדרגתית — נוכל להקים זאת יחד באופן מסודר ולהתאים אותו לארכיטקטורה שלכם: צרו קשר.

בהקשר המקצועי גם Delphi מיפוי RTTI ומיפוי מאפיינים Delphi ממלאים תפקיד חשוב כאשר אינטגרציות, זרימות נתונים והמשך פיתוח חייבים להשתלב בצורה מסודרת.

לדון בפרויקט או ביוזמת מודרניזציה עם Net-Base.

שתף פוסט

לשתף את הפוסט הזה ישירות

LinkedIn, X, XING, Facebook, WhatsApp ודוא"ל זמינים מיידית. עבור Instagram אנו מכינים קישור וטקסט קצר באופן מיידי.

דוא״ל

אינסטגרם נפתח בכרטיסייה חדשה. הקישור וטקסט קצר מועתקים מראש ללוח.