原文はこちら。
The original article was written by Julia Boes (OpenJDK developer on Java Platform Group, Oracle) and Chris Hegarty (Networking Lead of the Java Platform Group, Oracle).
https://inside.java/2021/04/06/record-serialization-in-practise/
TL;DR シリアライズフレームワークがRecordクラスをサポートする仕組みについて学ぶ
Record Classes and Serialization
シリアライズとは、オブジェクトの状態を抽出し、それを永続的なフォーマットに変換し、そこから同等のオブジェクトを構築するプロセスです。RecordクラスがとうとうJava 16で最終化されました。これは、意味的に制約のあるクラスで、その設計はシリアライズの要求に自然に適合します。
JEP 395: Records
https://openjdk.java.net/jeps/395
通常のJavaクラスでは、拡張可能な動作や変更可能な状態を自由にモデル化できるため、シリアライズは非常に早く複雑になります。対照的に、Recordはシンプルなままです。Recordは不変の状態を宣言し、その状態を初期化してアクセスするためのAPIを提供する単純なデータキャリアです。以下はPointというRecordクラスの宣言の例です。
record Point (int x, int y) { }
このように、この言語では、Recordクラスを宣言するための簡潔な構文が用意されており、Record構成要素をRecordヘッダで宣言します。Recordヘッダで宣言されたRecord構成要素のリストがRecord記述子を形成します。Record記述子とは、状態を記述し、簡潔なAPIを駆動するものです。Recordクラスには、Record記述子のパラメータリストと一致する標準コンストラクタがあり、これは状態の初期化に使用されます。Recordの状態(Recordクラスのインスタンス)は、そのコンポーネントの値を使って取得でき、各コンポーネントは同じ名前のアクセサを使ってアクセスできます。
この設計から、Java Object Serializationが使用する複雑でないRecordシリアライゼーション・プロトコルが生まれ、それは2つのプロパティに基づいています。
Java Object Serialization Specification
https://docs.oracle.com/en/java/javase/16/docs/specs/serialization/index.html
- Recordの状態のコンポーネントのみに基づいてRecordのシリアライズを実施する。
- Recordのデシリアライズでは、標準コンストラクタのみを使用する。
Recordはその状態のみで構成されており、そこからカスタマイズせずにシリアライズ形式をモデル化します。シリアライズの際には、シリアライズ形式に変換される前にアクセサを使用して状態を読み取ります。デシリアライズ時には、状態の説明と同じパラメータを持つ標準コンストラクタを呼び出します。これがRecordを作成する唯一の方法です。
通常のJavaクラスの場合はもっと複雑で、シリアライズフレームワークはこの複雑さを処理するためにある種の裏技を使いたがります。例えば、シリアライズ時によく行われるのは、プライベートフィールドをスクレイプすることです。これは、Java言語のアクセス制御チェックが抑制されている場合にのみ機能します。デシリアライズ時には、新たにデシリアライズされたオブジェクトのプライベートな状態を設定するために、通常、Core Reflectionを使用します。JDK 15以降、Recordオブジェクトでは、十分な特権を持つコードであっても、この設定はできなくなりました(詳細については、以下のIssueを参照してください)。代わりに、Recordクラスをインスタンス化する唯一の方法が、その標準コンストラクタを呼び出すことです。
JDK-8247517: Final fields in records are not reflectively modifiable
https://bugs.openjdk.java.net/browse/JDK-8247517
この新しい強制力に関わらず、侵入的なリフレクティブ・アクセスやミューテーション(mutation、転変)は本当に不要です。Recordクラスは、その状態を公開することで、シリアライズに必要なすべてのAPIポイントを提供しており、しっかり規定されたメソッドを使って再構築の手段を提供しており、これを使わないのはもったいないことです。Record固有のシリアライズプロトコルの採用は、シリアライズフレームワークをより安全に、よりメンテナンスしやすく、より使いやすくするチャンスです。そこで、既存のフレームワークが適切にRecordをサポートできるようにするための取り組みを始めました。
Serialization Frameworks Supporting Records
3個の人気のあるJavaベースのシリアライズフレームワーク(Jackson、Kryo、XStream)と一緒にやることにしました。
Framework | Jackson | Kryo | XStream |
---|---|---|---|
Serialization format | JSON | binary | XML |
Build JDK | 8 | 8, 11 | 8 |
Jacksonは、私たちが最初に関わったプロジェクトで、実際、2020年6月に連絡を取ったときには、すでにコントリビューターがRecordのサポートに取り組んでいました。その2ヶ月後にコードが統合されるまで、レビューコメントや提案をお手伝いしました。同じ頃、Kryoではユーザーがissueを作成したことで関わりが始まり、結果としてプルリクエスト(PR)に至りました。XStreamについては、Issueが作成され、その直後にPRが作成されました。両PRは2021年3月に無事統合されました。
Jackson
https://github.com/FasterXML/jackson
https://github.com/FasterXML/jackson-future-ideas/issues/46
https://github.com/FasterXML/jackson-databind/pull/2714
Kryo
https://github.com/EsotericSoftware/kryo
https://github.com/EsotericSoftware/kryo/issues/735
https://github.com/EsotericSoftware/kryo/pull/766
XStream
https://github.com/x-stream/xstream
https://github.com/x-stream/xstream/issues/210
https://github.com/x-stream/xstream/pull/220
各プロジェクトが独自のアーキテクチャや慣習がありますが、Recordサポートにあたって共通の要素がありますのでここでご紹介します。
The Common Recipe
Recordをサポートするための基本的な考え方は、3つのフレームワークで共通しています。レコードに特化したSerializer/Deserializerを実装し、それを既存のプロジェクトに統合する、というものです。以下のURLで紹介するいくつかのユーティリティー・メソッドの助けを借りれば、かなり簡単に実装を完了できます。
Record Serialization Util
https://github.com/FrauBoes/record-s11n-util
実装をより詳しく見ると、重要な点はJDKのバージョン互換性です。問題のフレームワークは通常、サポートしている最も古いJDKでコンパイルされるため、実行時にはコンパイル時のバージョンと同等以上のJDKを使用できます。RecordはJava 14で初めてプレビュー機能として導入されたため、Java 14以後に静的に依存しないようにするには、Recordの存在を実行時に判断する必要があります。このため、Java 14ではClass::isRecord
というメソッドが追加されました。これはクラスがRecordクラスの場合はtrueを、そうでない場合はfalseを返します。このメソッドが存在しない場合、JavaランタイムはRecordをサポートしません。さらに、別のメソッドと新しい型が導入されました。Class::getRecordComponents
は、このRecordクラスのRecordコンポーネントを表すjava.lang.reflect.RecordComponent
オブジェクトの配列を返します。RecordComponent
は、Recordコンポーネントに関する情報や、Recordコンポーネントへの動的なアクセスを提供します。特にその名前と型(RecordComponent::getName
、RecordComponent::getType
)を提供します。
これらのいくつかのプリミティブは、Recordのシリアライズを実装するための重要な要素です。例えば、デシリアライズ時のRecordクラスのインスタンス化は次のようになります。
Class<?>[] paramTypes = Arrays.stream(cls.getRecordComponents())
.map(RecordComponent::getType)
.toArray(Class<?>[]::new);
MethodHandle MH_canonicalConstructor =
LOOKUP.findConstructor(cls, methodType(void.class, paramTypes))
.asType(methodType(Object.class, paramTypes));
MH_canonicalConstructor.invokeWithArguments(args);
このコードサンプルでは、リフレクティブな呼び出しにMethodHandles
を使用しています(サンプルコードのinvokeパッケージを参照)。
InvokeUtils.java
https://github.com/FrauBoes/record-s11n-util/tree/main/src/invoke
これは実装上の問題で、Core Reflectionでも同じことができます(サンプルコードのreflectパッケージを参照)。
ReflectUtils.java
https://github.com/FrauBoes/record-s11n-util/tree/main/src/reflect
とはいえ、java.lang.invoke
のメソッドハンドルAPIは、メソッドの検索、適応、結合、呼び出し、フィールドの設定など、低レベルの興味深い操作を提供しており、賢く使えば効率を向上できます。
これらのツールがあれば、シリアライズの実際の仕組みは簡単です。シリアライズ時には、Recordコンポーネントを取得し、シリアライズ形式に変換されます。デシリアライズ時には、オブジェクト作成のためにRecordクラスの標準コンストラクタに渡される前に、値を読み取ります。シリアライズ形式はフレームワークのシリアライズフォーマットと規約に依存します。Kryoの場合、シリアライズされたデータのコンシューマがデータの形を想定しているので、シリアライズ形式は構成要素の値だけに煮詰めることができます。その結果、非常にコンパクトなシリアライズ形式になります。XStreamの場合、シリアライズ形式には、コンポーネントの名前と値、そしてそれぞれのクラス名が含まれています。データの形状はあまり効率化されておらず、名前が値と一致するため、クラスの形状を反映する必要はありません。一般的に、クラスの形状がシリアライズ形式で捉えられれば捉えられるほど、デシリアライズプロセスはより柔軟になります。一方、よりコンパクトなシリアライズ形式は、ストレージとメモリの効率性をもたらすことができるデシリアライズ時の特定の仮定に依存します。
もう1つの興味深い点は、シリアライズ形式におけるRecordコンポーネントの表現の順序です。特定の順序を適用するアプローチもありますが、当然の選択は、Record宣言のコンポーネント順序です。Class::getRecordComponents
が返す配列はこの順序に従っており、標準コンストラクタのパラメータリストも同様です。この順序に従えば、値をストリームに順次書き込んだり、ストリームから読み込んだりして、標準コンストラクタに直接渡すことができます。
しかし、時間の経過とともにRecordクラスが進化した場合はどうでしょうか。例えばRecordクラスの構成要素の順序を変更する、といった場合です。シリアライズの際、コンポーネントの順序はもはや確実ではなく、上記のような静的なアプローチは十分ではないでしょう。このようなRecordクラスの進化をサポートするためには、シリアライズ形式の柔軟性を高める必要があります。より正確には、実装が何らかのマッチングまたはソートアルゴリズムを提供して、ストリームの値を標準コンストラクタのパラメータリストに正しくマッピングできるようにしなければなりません。ここでの1つのオプションは、シリアライズ形式のRecordコンポーネントを名前で辞書的に並べ替えることです(Kryoのソリューションで行われています)。Recordコンポーネントのシリアライズ形式での順序付けは、Record型のバージョニングをサポートするだけでなく、通常のクラスに沿ったものでもあります。これにより、将来的に通常のクラスからRecordクラスへの移行が容易になるというメリットがあります。
実装からテストに移ると、ここでもJava 14以上への依存性を処理する必要があります。テストコードは、古いJDKバージョンに依存するテストとは別にテストを保存する必要があり、Java 14以後が存在する場合にのみコンパイルして実行できます。3つのフレームワークでビルドおよび依存関係管理ツールとしてMavenが使用されており、そのビルドプロファイルを使用して、検出されたJDKバージョンに基づいて条件付きでテストをコンパイルできます。既存のビルドプロセスによっては、この設定は少し難しいかもしれませんが、特定のプラグインは、ソースコードとテストコードのコンパイルと実行の設定に役立ちます。Java 14および15で作業する場合、プレビュー機能を有効にするには特定のフラグが必要です(具体的には--enable-preview
です)。Java 16では、Recordは最終化されたので、このフラグはもう必要ありません。
In Conclusion
JavaのRecordはシリアライズフレームワークに付加価値を追加できます。Recordのサポート追加に成功した3個のシリアライズフレームワークとそれらの共通する3個のレシピをご紹介しました。これを使うと、フレームワークの開発者の如何を問わずRecordのサポートは難しくありません。ぜひお試しください。