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.
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:
tkClassitkSetsą celowo niezaimplementowane. Dla zagnieżdżonych obiektów należy albo mapować rekurencyjnie (z jasną polityką) albo przypisywać świadomie ręcznie. - Locale-Fallen:
varDoubleprzezVarAsTypejest 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).
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:
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:
- 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).
- 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.
- 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.