オブジェクトのハイドレーション(Hydration)は、実際に計測してみるまで、解決済みの問題のように思えるものです。データベースから行を取得し、配列をオブジェクトにマッピングして、次の処理へ進む。私たちの多くは、それを「配管作業」のように扱っています。目立たず、地味で、十分に高速であると決めつけているのです。しかしある日、バッチジョブやキューワーカーをプロファイリングした際、驚くほど多くのCPU時間がマッパーに消えていることに気づくことがあります。その気づきこそが、HydraTypeを生み出すきっかけとなりました。HydraTypeは、「クラスは、そのプロパティが使用しない機能のためにコストを支払うべきではない」という、一つの頑固なルールに基づいて構築されたハイドレーターです。
もしフィールドが変換を必要としない単なる文字列であれば、生成されるコードは人間が手書きしたかのように見えるべきです。直接代入。ループも、パイプラインも、リフレクションもありません。
ハイドレーションの真のコスト
一見すると、['name' => 'Alice', 'age' => 30] を User オブジェクトに変換するのは些細なことに思えます。問題は、柔軟性を求めたときに始まります。ほとんどの汎用ハイドレーターは、実行時にクラスを検査するためにリフレクションに依存しています。メタデータマップを構築し、型強制(type coercion)を調整し、ネストされたオブジェクトグラフを解決します。また、彼らはパイプラインを多用します。たとえそのシーケンスが空であっても、すべての値はミューテーター(mutator)、アサーション(assertion)、トランスフォーマー(transformer)の一連の処理を通り抜けます。ループ構造そのものがオーバーヘッドとなります。
数十件のレコードを取得する程度では、決して気づかないでしょう。しかし、現代のPHPアプリケーションは、日常的に数千のキューメッセージを処理したり、膨大なCSVセットをインポートしたり、Elasticsearchから深い結果セットをハイドレーションしたりします。そのようなシナリオでは、フィールドごとにわずか数マイクロ秒を消費するマッパーであっても、深刻なボトルネックとなります。8つのフィールドを持つ1,000個のオブジェクトがあれば、そのコストは無視できない負担となって跳ね返ってきます。
ゼロ・オーバーヘッドの哲学
HydraTypeの核心となる考え方は「機能の分離(feature isolation)」です。型変換、ネストされたオブジェクトのハイドレーション、アサーションはすべてサポートされていますが、それらはどれも必須ではありません。もしプロパティに配列からオブジェクトへの単純なコピーしか必要ないのであれば、生成されるライター(writer)にはまさにそれだけが含まれます。プレーンなスカラーフィールドが、必要のないバリデーターの後ろで待機することを強いるような、共有パイプラインは存在しません。
これは、言うほど簡単なことではありません。多くのライブラリは、コードベースを小さく均一に保つために、すべてのフィールドに対して単一の実行パスを共有します。HydraTypeはそれとは逆のアプローチを取ります。ターゲットとなるDTOごとに独自のPHPクラスを生成し、その特定のクラスが必要とする操作のみを出力します。複雑さは「オプトイン(選択制)」であり、すべてのプロパティに対する一律の課税ではありません。
なぜ高速なのか
HydraTypeを、汎用的なリフレクションベースのツールや、他のコード生成アプローチから分かつ4つの具体的な選択があります。
1. コードは一度生成し、使い続ける
HydraTypeはターゲットクラスを一度だけ検査し、その正確な形状に合わせてカスタマイズされた専用のPHPクラスを書き出します。もしDTOに public string $name、int $status、そして private DateTime $createdAt が含まれている場合、生成されたライターはこれらすべてを事前に把握しています。実行時にリフレクションを繰り返したり、文字列を解析したり、遅延してメタデータを構築したりすることはありません。OPCacheが生成されたファイルをコンパイルしてしまえば、そのハイドレーターは手書きのPHPと区別がつかないものになります。
2. 空のパイプラインを排除する
ほとんどのハイドレーターは、実行時のパイプラインを中心に処理を構成しています。彼らはミューテーター、バリデーター、トランスフォーマーといったコールバック(callable)の配列を保持し、すべての値に対して反復処理を行います。たとえそれらの配列が空であっても、foreach や array_reduce は実行されます。HydraTypeはこれを完全に排除します。コード生成時に、プロパティが実際に何を必要としているかをチェックします。操作リストが空であれば、$object->name = $data['name']; のような単純な代入文を出力します。ジェネレーターがすでに「思考」を終えているため、実行時に反復処理が行われることはありません。
3. クロージャのスコープを利用してリフレクションによる書き込みを回避する
プライベートプロパティは、外部マッパーにとって典型的な悩みの種です。通常、回避策として ReflectionProperty::setValue() が使われますが、このメソッドはプロパティへの直接アクセスと比較して重いペナルティを伴います。HydraTypeは Closure::bind を使用することでこれを回避します。生成されたライターにはターゲットクラスにスコープされたクロージャが含まれており、これによりリフレクションを使わずに private や protected プロパティに直接アクセスできます。バインディングはコンストラクション時に一度だけ行われ、その後、クロージャはネイティブに近い速度で実行されます。
4. ライターを再利用する
一部のライブラリは、ハイドレーションするオブジェクトごとに新しいストラテジーや
PHP 8.2では、その差は歴然としています:
- HydraType: 267.9 ns
- Ocramius GeneratedHydrator: 307.9 ns
- Symfony PropertyNormalizer: 8,499.0 ns
- Valinor: 10,755.2 ns
Symfony PropertyNormalizerとValinorは強力なツールですが、汎用的なものです。PropertyNormalizerは、フォーマット検出、ノーマライザー、および深いオブジェクトグラフを処理するシリアライザーコンポーネントの一部です。Valinorは型ツリーと型強制(coercion)に対して厳格です。その強力さには代償が伴います。このテストでは、これらはHydraTypeよりもおよそ30倍から40倍遅い結果となりました。すでにコード生成を利用しているOcramius GeneratedHydratorでさえ、パイプラインやプロパティアクセスに関するアーキテクチャの違いにより、わずかに後塵を拝しています。
文脈が重要です。単一のデータベースクエリやHTTPのラウンドトリップは、これらの数値をはるかに凌駕します。クエリの後に3つのオブジェクトをハイドレートするだけであれば、ナノ秒を追い求めるのはエネルギーの無駄です。その差が意味を持つのは、スケールした時です。5万件のレコードをハイドレートするバッチプロセスや、キャッシュ配列からオブジェクトを再構築するキューコンシューマーでは、267ナノ秒と10マイクロ秒の差を実感することになります。フィールドと行数にわたってこれを掛け合わせると、より高速なマッパーを使用することで、ビジネスロジックを変更することなく、ジョブの時間を数秒短縮できます。
真の教訓
ここでの教訓は、すべてのプロジェクトにカスタムハイドレーターが必要だということではありません。アーキテクチャの選択が積み重なっていく(複利的に影響する)ということです。HydraTypeは、要求されたものだけを含むコードを生成することで、プレーンなプロパティの高速性を維持しつつ、複雑なプロパティのためのエスケープハッチも提供しています。型変換やネストされたオブジェクトは必要な時に利用できますが、オブジェクト上のすべてのスカラー文字列に対して、それらがパフォーマンスのコストを強いることはありません。
ツールを切り替える前に、プロファイリングを行ってください。もし実環境のトレースでハイドレーションが目立たないのであれば、修正すべき点はありません。しかし、汎用的なマッパーを通じて大規模なデータセットを流し込み、CPUの負荷が高まっているのを目にしているなら、プレーンなPHPの代入が依然として存在することを思い出してください。時として、最も高速なコードとは、単に自分自身で書いたであろうコードであり、一度生成されたらあとは忘れてしまってよいものなのです。
