歴史的に成長してきた業務系ソフトウェアを Delphi 上で運用している現場は、この緊張関係をよく知っています。ひとつには構造化されたドメインオブジェクトと明確なレイヤを求める一方で、データセット、Variant 型、CSV インポート、インターフェースのペイロード、あるいは REST-API のように、いずれも何らかの形でオブジェクトにマッピングしなければならない要素が存在します。まさにここでしばしばDelphi RTTI für Mapping ohne Magieの話に行き着きます。つまりリフレクションによるマッピング(RTTI = Run-Time Type Information、実行時の型情報)ですが、追跡可能でデバッグしやすく、慣習や命名上の細工に密かに依存しない方法で行う、ということです。
要点はこうです。いわゆる「マジック」は多くの場合 RTTI 自体ではなく、暗黙のルールによって生じます。マッピングルールを属性として明示化し、変換を中央集約し、エラーが明確な原因を示すようにすれば、RTTI は驚きではなくツールになります。
なぜ RTTI マッピングが Delphi でしばしば破綻するのか
RTTI ベースのマッピングは、実環境においてアイデアそのものではなく次のような周辺条件で失敗することが多いです:
- レガシーなデータ形式: Null/Empty/0 が明確に区別されておらず、フィールド型が変わる、文字列に「N/A」が含まれる。
- 徐々に広がる慣習: 「フィールド名がプロパティ名と同じ」が、最初のエイリアス、JOIN、あるいはリファクタリングされたプロパティ名で破綻する。
- デバッグが難しい: マッパーが「単に何も設定しない」場合、後で原因が不明になる。運用環境では致命的になることがある。
- パフォーマンスの誤解: RTTI が一律に「遅い」と評価されがちだが、多くの場合問題はキャッシュの欠如にある。
したがって実用的なアプローチは、(1) 明示的なマッピングメタデータを持ち、(2) 変換とヌルセマンティクスを明確に扱い、(3) エラーとデバッグ出力を提供し、(4) RTTI 情報をキャッシュすることを備えるべきです。
Delphi RTTI für Mapping ohne Magie: 設計原則
以下のパターンは良い意味で意図的に「退屈」です。ルールが可視化され、副作用が限定され、既存のモジュールに段階的に導入できます。
- 命名規約ではなく属性: プロパティにソース列名を指定する属性を与える。
- オプトイン: マークされたプロパティのみが設定される。「公開されているすべてのプロパティ」による予期しない副作用を避ける。
- 変換は単一箇所で: Variant/String/Integer/Boolean/Enum/Nullable を中央でマッピングする。
- デバッグモード: 任意で、どのフィールドが設定/スキップされたかを理由とともに記録する。
- RTTI キャッシュ: 最もコストのかかる部分(プロパティ一覧、属性評価)を型ごとに事前に準備する。
ソース断片: RTTI、キャッシュ、デバッグを用いた属性マッピング
このスニペットは、1 行(例: BDE の置換(ネイティブ接続) からの TDataSet 経由のデータ)をオブジェクトへマップする例を示します。Mapper を TField に固定せず、小さなリーダーインターフェースを用いる点が重要です。実務では、この設計により同じロジックを後で JSON、INI、CSV、あるいは API レスポンスへも再利用できます。
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// 明示的なマッピング: Property <- ソース名
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
// 小さな抽象化: 値を提供し、存在/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;
// オプトイン: 属性のあるプロパティのみ
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 の変換に失敗しました: "%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 の順序値が範囲外です: %d', [Ord]);
Exit(Ord);
end;
Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt('列挙名が不明です: "%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;
// 変換は意図的に限定的: 曖昧に「なんとかする」よりも、明確に失敗させる方が望ましい。
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 マッピングは %s に対して実装されていません', [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt('クラスプロパティのマッピングは %s に対して実装されていません', [AProp.Name]);
else
raise ERttiMappingError.CreateFmt('TypeKind はサポートされていません (%s) - %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 または Target が 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('ソースが欠落しています: "%s" - プロパティ %s',
[M.SourceName, M.Prop.Name]);
Continue;
end;
if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Nullable/Optional 機構がないと 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('マッピングエラー: %s <- %s: %s',
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
利点
コードレビューで明確に評価できるマッピングを得られます:
- マッピングされた各プロパティは視覚的に識別されます(属性)。
- 変換処理が集中管理されるため、一貫性がありテスト可能です。
- エラーメッセージは、どのプロパティとどのソースが影響を受けているかを示します。
- デバッグモードは、必要に応じて証拠となるトレースを提供し、本番処理でブレークポイントを設定する必要をなくします。
前提条件と典型的な落とし穴
- NULL-Semantik: 独自のNullable概念(例えば
Nullable<T>やオプション型)がない場合、「NULLを設定する」ことは明確ではありません。スニペットではNULLはデフォルトでスキップされます。これは保守的な挙動であり、意図しない上書きを防ぎます。 - TRttiContext-Lebensdauer: 型ごとに一度だけキャッシュを構築し、その後Contextを破棄します。これは一般的なやり方です。重要なのは:フィールド割り当てごとに新しいRTTI-Contextを構築しないことです。
- Threading: キャッシュはMonitorで保護されています。高並列なマッピング(例:REST-Server)では、ロック競合を減らすために起動時にキャッシュを「ウォーム」に構築(Preload)するかを検討してください。
- PropertyType Kind:
tkClassとtkSetは意図的に実装していません。ネストしたオブジェクトについては、明確なポリシーを持って再帰的にマッピングするか、意図的に手動で割り当ててください。 - Locale-Fallen:
varDoubleをVarAsType経由で扱うのは比較的堅牢ですが、文字列「1,23」と「1.23」のような表現は問題になります。ソースが文字列を返す場合は、定義されたCultureを持つ専用パーサーを用意した方が良いことが多いです。
Variante für FireDAC und TDataSet: Reader-Adapter statt Mapper-Kopplung
In FireDACや従来のVCL/Win32アプリケーションでは、ソースはしばしばTDataSetです。MapperをTFieldに結びつける代わりに、IValueReaderインターフェースを満たすアダプタを作成します。利点は:Mapperがデータアクセスから独立することで(後でデータアクセスをサービスや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;
これにより、具体的なマッピングは次のようになります:
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;
このアプローチが有効な場面 — そして無効な場面
このパターンは典型的に次の3つの状況で有効です:
- 段階的な近代化: ドメインオブジェクトを導入したいが、データアクセスを直ちに全面的に作り替えたくない場合(既存アプリケーションにおける Delphi 近代化 に典型的です)。
- インターフェース境界: CSV-/Excelインポート、REST-ペイロード、または「混合」データソースは堅牢な変換と明確なエラーメッセージを必要とします。
- チームでの保守性: 属性はマッピング規則を可視化しレビュー可能にするため、より大きなコードベースでは非常に有用です。
適用の限界も明確です:
- 複雑なオブジェクトグラフ(子コレクション、循環参照)は、いわゆる「自動マッピング」に頼るべきではありません。ここでは明示的なコードか、別個のアセンブラ/ファクトリパターンを用いる方が一般に安定します。
- High-Throughput-Hotpaths(例:大量データのETL)は、RTTIがキャッシュされていても、コード生成されたマッパーや手作業で最適化したマッピングの方が有利です。
- Nullable/Optionalは別個の問題です。実際に「存在しない」「NULL」「デフォルト」を区別する必要がある場合は、その差異をマッパーに隠すのではなくドメインモデルで明確に表現すべきです。
アーキテクチャと運用での位置づけ
アーキテクチャの観点から、このマッパーはデータ表現とドメインの境界に位置するインフラストラクチャコンポーネントです。これはきれいなレイヤ分けを置き換えるものではありませんが、その実現を助けます。データアクセス(BDE-Ablosung mit nativer Anbindung、SQL、Views)は引き続き実用的なままでよく、ドメインの一貫性が保たれます。多層システム(しばしば Layer-3 アーキテクチャ と呼ばれる:UI、Domain/Services、インフラ)では、マッパーはインフラ側に属し、UIフォームではなくサービスから利用されるべきです。
運用上の重要点:moDebug を本番サービスで常時有効にしないこと。ターゲットを絞って有効化してください。再現の難しいデータ問題に備えて、(設定やフィーチャーフラグによる)切替可能な診断経路を用意するのが有効です。さもないとログ量の増加や副作用が発生します。
結論:RTTIは有効だが、明確なガイドラインとともに
Delphi マジックに頼らないRTTIによるマッピングは、RTTIを宣言的メタデータのためのツールとして利用する場合に有効です — 暗黙のヒューリスティックへの招待ではありません。Attributeをオプトインとし、変換を集中管理し、型ごとのキャッシュと理解しやすいエラーメッセージを用意することで、この課題は「不透明」から「運用可能」へと変わります。このアプローチは意図的に万能ではありません:入れ子になったグラフ、厳格なNULLセマンティクス、あるいは最大限のパフォーマンスが要求される場合には、さらに別の構成要素が必要です。しかし、Dataset/Legacy-Strukturenとよりモダンなドメインオブジェクトを結ぶ堅牢な橋渡しとして、多くのDelphiコードベースにおいて、モダナイゼーションを初めて可能にする実務的な一歩となります。
成長したDelphiアプリケーションでマッピングの境界、データ品質、あるいは段階的なモダナイゼーションで行き詰まっている場合、私たちがそれを共にきちんと構築し、貴社のアーキテクチャに組み込むことができます:お問い合わせください。
業務領域では、統合、データフロー、機能の継続的な発展が整合して動作する必要がある場合、DelphiのRttiマッピングおよびAttributeマッピングDelphiも重要な役割を果たします。