gettersetterを通してインスタンス変数にアクセスする既存のフレームワークやライブラリでは、レコードを扱えないが、その対策を紹介する

著者:Frank Kiwy

2020116

※本記事はJava records: Serialization, marshaling, and bean state validationの翻訳記事です。

本記事をPDFでダウンロード


レコードは、Java 14で初めて、プレビュー機能として導入されました。そして最近は、Java 15で第2回プレビューが行われています。そのため、レコード・クラスはまだ正式にJDKの一部となってはおらず、変更される可能性があります。

Javaのレコードについては、Ben Evans氏によるJava Magazineの記事、「Javaにレコードが登場」で紹介されました。レコードについて聞いたことがない方は、レコードの概要を「Java言語アップデート:レコード・クラス」(英語)のドキュメントで確認できます。

手短に言うなら、レコード・クラスの主な目的は、プレーンな、データの集まりを通常のクラスよりも簡潔にモデリングすることです。レコード・クラスでは、一連のフィールドを宣言します。メソッドを宣言しても構いません。すると、コンストラクタとアクセッサ、そしてequalshashCodetoStringの各メソッドが適切な形で自動作成されます。このクラスは単純なデータ・キャリアとして使うことを意図しているので、すべてのフィールドはfinalです。

レコード・クラスの宣言は、名前、ヘッダー(クラスのフィールド一覧が含まれます。このフィールド一覧はコンポーネントと呼ばれます)、本体で構成されます。レコードの宣言の例を次に示します。

record RectangleRecord(double length, double width) {
}

本記事では、レコードのシリアライズとデシリアライズ、マーシャルとアンマーシャル、状態検証に注目します。しかしまずは、JavaのReflection APIを使ってレコードのクラス・メンバーを確認してみることにしましょう。

分析

Javaにレコードが導入されたとき、java.lang.Classに2つの新しいメソッドが追加されました。

  • isRecord()isEnum()とほぼ同じだが、クラスがレコードとして宣言されていた場合はtrueを返す
  • getRecordComponents():レコードのコンポーネントに対応するjava.lang.reflect.RecordComponentオブジェクトの配列を返す

ここでは後者を使って、先ほど宣言したレコード・クラスのコンポーネントを取得してみます。

System.out.println("Record components:");
Arrays.asList(RectangleRecord.class.getRecordComponents())
        .forEach(System.out::println);

出力は次のようになります。

Record components:
double length
double width

おわかりのように、コンポーネントとは、レコードの宣言のヘッダーで指定した変数(型と名前のペア)です。次に、レコードのフィールドを確認してみます。フィールドはコンポーネントから導かれます。

System.out.println("Record fields:");
Arrays.asList(RectangleRecord.class.getDeclaredFields())
        .forEach(System.out::println);

次に出力を示します。

Record fields:
private final double record.test.RectangleRecord.length
private final double record.test.RectangleRecord.width

コンパイラで、private修飾子とfinal修飾子を使ってフィールドが生成されていることに注意してください。field accessors とコンストラクタのパラメータも、レコードのコンポーネントから導かれます。たとえば、次のようになります。

System.out.println("Field accessors:");
Arrays.asList(RectangleRecord.class.getDeclaredMethods())
        .filter(m -> Arrays.stream(RectangleRecord.class.getRecordComponents()).map(c -> c.getName()).anyMatch(n -> n.equals(m.getName())))
        .forEach(System.out::println);

System.out.println("Constructor parameters:");
Arrays.asList(RectangleRecord.class.getDeclaredConstructors())
        .forEach(c -> Arrays.asList(c.getParameters())
        .forEach(System.out::println));

出力は次のようになります。

Field accessors:
public double record.test.RectangleRecord.length()
public double record.test.RectangleRecord.width()
Constructor parameters:
double length
double width

field accessorsの名前がgetから始まっていないことに注目してください。そのため、JavaBeansの規則には従っていないことになります。

(編集注:Brian Goetzオンライン・スレッドで、「言語には、後方だけでなく前方も見る責任があります。また、既存のコードのニーズと新しいコードのニーズとのバランスも見なければなりません。ライブラリで不適切なネーミング規則を採用し、その規則を永久に言語に焼き付けてしまったならば、最悪の選択だったでしょう」と述べていました。)

フィールドの内容を設定するメソッドが存在しないことに、驚きはないでしょう。レコードは不変であることが想定されているからです。

レコードのコンポーネントには、コンストラクタやメソッドのパラメータと同じように、アノテーションを付加できます。これを示すために、次のようなシンプルなアノテーションを作成しました。

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
}

保存方針は必ずRUNTIMEに設定してください。そうしないと、コンパイラでアノテーションが破棄されるので、実行時に存在しなくなります。次に示すのは、コンポーネントにアノテーションを付加するように変更した、レコードの宣言です。

record Rectangle(@MyAnnotation double length, @MyAnnotation double width) {
}

次に、リフレクションを使って、レコードのコンポーネントからこのアノテーションを取得します。以下に例を示します。

System.out.println("Record component annotations:");
Arrays.asList(RectangleRecord.class.getRecordComponents())
        .forEach(c -> Arrays.asList(c.getDeclaredAnnotations())
        .forEach(System.out::println));

次に出力を示します。

Record component annotations:
@record.test.MyAnnotation()
@record.test.MyAnnotation()

期待したとおり、レコードのヘッダーで指定したコンポーネントの両方にアノテーションが存在します。

ただし、レコードの場合、コンポーネントに付加したアノテーションは、コンポーネントから導かれるフィールド、accessors、コンストラクタ・パラメータにも伝播します。コンポーネントから導かれたアーティファクトのアノテーションを出力し、この伝播をざっと確認してみます。

次に示すのが、レコードのフィールドのアノテーションです。

System.out.println("Record field annotations:");
Arrays.asList(RectangleRecord.class.getDeclaredFields())
        .forEach(f -> Arrays.asList(f.getDeclaredAnnotations())
        .forEach(System.out::println));

次に出力を示します。

Record field annotations:
@record.test.MyAnnotation()
@record.test.MyAnnotation()

次に示すのが、フィールドのアクセッサのアノテーションです。

System.out.println("Field accessor annotations:");
Arrays.asList(RectangleRecord.class.getDeclaredMethods())
        .filter(m -> Arrays.stream(RectangleRecord.class.getRecordComponents()).map(c -> c.getName()).anyMatch(n -> n.equals(m.getName())))
        .forEach(m -> Arrays.asList(m.getDeclaredAnnotations())
        .forEach(System.out::println));

次に出力を示します。

Field accessor annotations:
@record.test.MyAnnotation()
@record.test.MyAnnotation()

最後に、レコードのコンストラクタ・パラメータのアノテーションを示します。

System.out.println("Constructor parameter annotations:");
Arrays.asList(RectangleRecord.class.getDeclaredConstructors())
        .forEach(c -> Arrays.asList(c.getParameters())
        .forEach(p -> Arrays.asList(p.getDeclaredAnnotations())
        .forEach(System.out::println)));

次に出力を示します。

Constructor parameter annotations:
@record.test.MyAnnotation()
@record.test.MyAnnotation()

このように、レコードのコンポーネントにアノテーションを付加すると、そこから導かれるアーティファクトに自動的に伝播します。ただし、この動作が常に望まれるとは限りません。たとえば、アノテーションがレコードのフィールドにのみ存在することが必要な場合もあるからです。そのため、アノテーションのターゲットを指定してこの動作を変更できるようになっています。

例として、レコードのフィールドにのみアノテーションが存在することが必要な場合を考えます。その場合、Targetアノテーションを付加し、パラメータElementType.FIELDを指定する必要があります。

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
}

先ほどのコードを再実行すると、出力は次のようになります。

Record component annotations:
Record field annotations:
@record.test.MyAnnotation()
@record.test.MyAnnotation()
Field accessor annotations:
Constructor parameter annotations:

おわかりのように、レコードのフィールドにのみアノテーションが存在するようになります。同じように、アノテーションがアクセッサにのみ(ElementType.METHOD)またはコンストラクタ・パラメータにのみ(ElementType.PARAMETER)存在するように宣言することもできます。また、これら2つのパラメータと、アノテーションをレコードのフィールドのみに付加する先ほどのパラメータ(ElementType.FIELD)を任意に組み合わせることもできます。

ただし、いずれの場合でも、アノテーションはレコードのコンポーネントに付加する必要があることに注意してください。レコードの宣言には、フィールドもaccessorsもコンストラクタ・パラメータもまったく存在しないからです。これらは、コンパイラによって自動生成され、(アノテーションの宣言で指定された、要素の型に基づいて)アノテーションが付加されます。その結果、コンパイル済みのレコード・クラスにのみ存在するようになります。

シリアライズとデシリアライズ

レコードは通常のクラスなので、シリアライズおよびデシリアライズも可能です。シリアライズおよびデシリアライズに必要なのは、レコードのヘッダーにjava.io.Serializableインタフェースを追加することです。次に例を示します。

record RectangleRecord(double length, double width) implements Serializable {
}

レコードをシリアライズするコードは次のようになります。

private static final List<RectangleRecord> SAMPLE_RECORDS = List.of(
        new RectangleRecord(1, 5),
        new RectangleRecord(2, 4),
        new RectangleRecord(3, 3),
        new RectangleRecord(4, 2),
        new RectangleRecord(5, 1)
);

try (
        var fos = new FileOutputStream("C:/Temp/Records.txt");
        var oos = new ObjectOutputStream(fos)) {
    oos.writeObject(SAMPLE_RECORDS);
}

次のコードは、レコードのデシリアライズに使用できます。

try (
        var fis = new FileInputStream("C:/Temp/Records.txt");
        var ois = new ObjectInputStream(fis)) {
    List<RectangleRecord> records = (List<RectangleRecord>) ois.readObject();
    records.forEach(System.out::println);
    assertEquals(SAMPLE_RECORDS, records);
}

次に出力を示します。

RectangleRecord[length=1.0, width=5.0]
RectangleRecord[length=2.0, width=4.0]
RectangleRecord[length=3.0, width=3.0]
RectangleRecord[length=4.0, width=2.0]
RectangleRecord[length=5.0, width=1.0]

しかし、通常のクラスとは大きく異なる点が1つあります。レコードがデシリアライズされるときは、ストリームからデシリアライズされた値が、レコードのコンストラクタを通じてフィールドに設定されます。それとは異なり、通常のクラスでは、最初に引数のないコンストラクタを呼び出してインスタンスが作成された後、ストリームからデシリアライズされた値がリフレクションを通じてフィールドに設定されます。

つまり、レコードはコンストラクタを使ってデシリアライズされます。このような動作になっているため、コンストラクタに不変値を追加し、デシリアライズした値の妥当性を検証することができます。通常のクラスではこのような検証が不可能なので、適切でないデータや危険なデータがデシリアライズされてしまうという一定のリスクが常に存在します。このリスクを過小評価すべきではありません。特に、データが外部ソースに由来する場合は注意が必要です。

import java.io.Serializable;
import java.lang.IllegalArgumentException;
import java.lang.StringBuilder;

public record RectangleRecord(double length, double width) implements Serializable {

    public RectangleRecord {
        StringBuilder builder = new StringBuilder();
        if (length <= 0) {
            builder.append("\nLength must be greater than zero: ").append(length);
        }
        if (width <= 0) {
            builder.append("\nWidth must be greater than zero: ").append(width);
        }
        if (builder.length() > 0) {
            throw new IllegalArgumentException(builder.toString());
        }
    }

}

上記のコードでは、レコードのコンパクトなコンストラクタを使っているので、パラメータを指定したり、レコードのフィールドを明示的に設定したりする必要がないことに注意してください。先ほどシリアライズしたレコードをデシリアライズする場合、インスタンスは必ず有効な状態になるはずです。有効な状態でない場合は、レコードのコンストラクタでIllegalArgumentExceptionがスローされます。

この動作は、1つのレコードについてのみ次のように、シリアライズしたデータが検証ロジックに従わなくなるように変更することで確認できます。RectangleRecord[length=0.0, width=-5.0].

先ほどのデシリアライズを行うコードを実行すると、予想どおりの結果が得られます。

IllegalArgumentException:
java.lang.IllegalArgumentException: 
Length must be greater than zero: 0.0
Width must be greater than zero: -5.0
  at record.test.RectangleRecord.<init>(RectangleRecord.java:18)
  at java.base/java.io.ObjectInputStream.readRecord(ObjectInputStream.java:2320)

通常のクラスで同じプロセスを試しても、クラスのコンストラクタが呼び出されることはないので、例外は発生しないでしょう。オブジェクトはエラーを含むデータでデシリアライズされ、誰も気づくことはないでしょう。

次のRectangleClassをご覧ください。このクラスはRectangleRecordに対応しています。

import java.io.Serializable;
import java.util.Objects;

public class RectangleClass implements Serializable {

    private final double width;
    private final double length;

    public RectangleClass(double width, double length) {
        StringBuilder builder = new StringBuilder();
        if (length <= 0) {
            builder.append("\nLength must be greater than zero: ").append(length);
        }
        if (width <= 0) {
            builder.append("\nWidth must be greater than zero: ").append(width);
        }
        if (builder.length() > 0) {
            throw new IllegalArgumentException(builder.toString());
        }
        this.width = width;
        this.length = length;
    }

    @Override
    public String toString() {
        return "RectangleClass[" + "width=" + width + ", length=" + length + ']';
    }

    @Override
    public int hashCode() {
        return Objects.hash(width, length);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        RectangleClass other = (RectangleClass) obj;
        return Objects.equals(length, other.length) && Objects.equals(width, other.width);
    }

    public double width() {
        return width;
    }

    public double length() {
        return length;
    }

}

RectangleClassのコンストラクタにはRectangleRecordのコンストラクタに存在するものと同じ検証ロジックが含まれていますが、デシリアライズのプロセスで呼び出されることはありません。そのため、無効な状態であるオブジェクトの作成を防ぐことはできません。

マーシャルとアンマーシャル

レコードでも、通常のクラスと同じく、JSON、XML、CSVなどの任意の形式でアンマーシャルおよびマーシャルを行うことができます。既存のライブラリを使ってアンマーシャルまたはマーシャルを行いたい場合、レコードにはgetterおよびsetterメソッドが存在しないので、Field.set(Object obj, Object value)メソッドを使ってクラス・フィールドにアクセスする必要がある点に注意してください。

ただし、いくつかの制限について認識しておく必要があります。JDK 15における、Javaレコードの第2回プレビューでは、Field.set(Object obj, Object value)メソッドでレコードのフィールドにアクセスすることはできなくなっています(JDK 14では可能でした)。

この制限が追加された理由は、ライブラリによるこの種のバックドア的な操作を防ぐことで、レコードの不変性を保証することにあります。しかし、現在のライブラリのほとんどは、まだレコードに対応していません。そのため、そのようなライブラリは、レコードを通常のクラスとして扱い、Field.set(Object obj, Object value)メソッドを使ってフィールドの値を設定しようとします。これは動作しません。

次に示すのは、有名なGsonライブラリを使って、この制限のデモを行う例です。Gsonでは、Field.get(Object obj)メソッドを使ってレコード・データを読み取るので、JSONへのマーシャルは問題なく動作します。

private static final List<RectangleRecord> SAMPLE_RECORDS = List.of(
        new RectangleRecord(1, 5),
        new RectangleRecord(2, 4),
        new RectangleRecord(3, 3),
        new RectangleRecord(4, 2),
        new RectangleRecord(5, 1)
);

try (Writer writer = new FileWriter("C:/Temp/Records.json")) {
    new Gson().toJson(SAMPLE_RECORDS, writer);
}

出力されるファイルを次に示します。

[{"length":1.0,"width":5.0},{"length":2.0,"width":4.0},{"length":3.0,"width":3.0},{"length":4.0,"width":2.0},{"length":5.0,"width":1.0}]

しかし、アンマーシャルのプロセスでは問題が発生します。このときGsonでは、Field.set(Object obj, Object value)メソッドを使ってフィールドの値を設定しようとします。

try (Reader reader = new FileReader("C:/Temp/Records.json")) {
    List<RectangleRecord> records = new Gson().fromJson(reader, new TypeToken<List<RectangleRecord>>(){}.getType());
    records.forEach(System.out::println);
}

出力は次のようになります。

java.lang.IllegalAccessException: Can not set final double field record.test.RectangleRecord.length to java.lang.Double
  at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessException(UnsafeFieldAccessorImpl.java:76)
  at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessException(UnsafeFieldAccessorImpl.java:80)
  at java.base/jdk.internal.reflect.UnsafeQualifiedDoubleFieldAccessorImpl.set(UnsafeQualifiedDoubleFieldAccessorImpl.java:79)
  at java.base/java.lang.reflect.Field.set(Field.java:793)

java.lang.IllegalAccessExceptionがスローされ、RectangleRecord.lengthフィールドへの書込みアクセスが失敗していることに注目してください。つまり、現在のライブラリは、レコードを扱う際にこの制限を考慮するように変更する必要があるということです。

現時点で、レコードのフィールドの値を設定する唯一の方法は、コンストラクタを使うことです。そのため、コンストラクタ引数自体がすべて不変である場合(たとえば、プリミティブ・データタイプを使う場合)は、レコードの状態を変更することが非常に難しくなります。ありがたいことに、この制限はレコードの状態検証における一貫性の保証に役立ちます。この点については、デシリアライズに関する先ほどのセクションで触れました。

現在のところ、JSONなどの形式からレコードをアンマーシャルする必要がある場合は、独自のアンマーシャル機能を書く必要があるでしょう。ほとんどのライブラリでは、レコードが正式なJava機能になるまで、レコードの明示的なマーシャルやアンマーシャルをサポートしないでしょう。

レコードは、正式な機能になるまで、変更される可能性があります。レコードのフィールドへのアクセスはJDK 15で制限され、リフレクションでフィールドを変更することはできなくなっています。JDK 14(レコードの第1回プレビュー)では、この方法でのフィールド変更が可能でした。誰もがJDK 16を待ちわびる中で、この動作変更を軽視すべきではありません。特にライブラリの設計者は注目すべきです。

Bean検証

レコードはJavaBeans標準に準拠していないので、Bean検証仕様(JSR 303とも呼ばれます)の対象にはなり得ないと考える方もいらっしゃるでしょう。これが正しいのは一部だけです。レコードにはgetterやsetterがないので、レコードの状態をgetterやsetterを通じて検証することはできません。しかし、レコードの状態は、コンストラクタ・パラメータやフィールドを通じて何の問題もなく検証できます。

Bean Validation APIでは、Javaアノテーションを使用して制約の表現と検証を行う方法が定義されています。こういったアノテーションは再利用できるので、コードの重複回避に役立ちます。その結果、より簡潔でエラーが起こりにくいコードになります。レコードのコンポーネントに制約のアノテーションを付加することにより、制約の検証を強制して、レコードが常に有効な状態であることを保証することができます。レコードは不変なので、制約を検証する必要があるのは、レコードのインスタンスを作成するときの1回だけです。制約違反がなければ、作成されたインスタンスは常に不変性を満たします。

次の例で、レコードの状態を検証する方法を示します。この検証を行うために、Bean検証のリファレンス実装であるHibernate Validatorを使います。

まずは、お気に入りのビルド・ツールの助けを借りて、必要な依存性を追加します。

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.1.5.Final</version>
</dependency>
<dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>javax.el</artifactId>
    <version>3.0.0</version>
</dependency>

制約違反メッセージの動的な式を評価するために、Hibernate Validatorには式言語の実装も必要になる点に注意してください。

続いて、@javax.validation.constraints.Positiveアノテーションを使って、RectangleRecordにいくつかの検証制約を追加します。このアノテーションでは、要素が厳密に正であるか(値0は無効と見なされます)をチェックします。

import javax.validation.constraints.Positive;

public record RectangleRecord(
    @Positive(message = "Length is ${validatedValue} but must be greater than zero.") double length,
    @Positive(message = "Width is ${validatedValue} but must be greater than zero.") double width
) {}

レコードの状態を検証できるようにするには、javax.validation.Validatorのインスタンスが必要です。しかし、Validatorインスタンスを取得するには、最初にValidatorFactoryを作成する必要があります。たとえば、次のようにします。

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();

これで、レコード・インスタンスの状態を検証できます。具体的には、次のようにします。

RectangleRecord rectangle = new RectangleRecord(0, -5);
Set<ConstraintViolation<RectangleRecord>> constraintViolations = validator.validate(rectangle);
constraintViolations.stream().map(ConstraintViolation::getMessage).forEach(System.out::println);

出力は次のようになります。

Length is 0.0 but must be greater than zero.
Width is -5.0 but must be greater than zero.

この例は、レコード・クラスも通常のクラスと同じようにBean Validation APIを使って検証できることを示しています。ただし、レコードはJavaBeansの規則に従っていないので、たとえば、getterやsetterを使ってその状態を検証することはできません。

上記のように検証するよりも、オブジェクトを構築するプロセスでその状態が有効であるかどうかをチェックし、誤ったデータを持つインスタンスが作成されるのを防ぐ方が望ましいのではないでしょうか。これは、レコードのコンストラクタ自体で制約検証ロジックを呼び出すことにより可能です。

前述の検証コードをすべてのレコードのコンストラクタに追加しなくてもよいように、インタフェースを使って実装してみましょう。レコードはfinalなので、他のレコード・クラスを拡張してそのメソッドを継承することはできません。しかし、インタフェースでdefaultメソッドを宣言して、同様の動作を実現することができます。次に例を示します。

import java.lang.reflect.Constructor;
import java.util.Set;
import java.util.stream.Collectors;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.Validator;

public interface Validatable {

    default void validate(Object... args) {
        Validator validator = ValidatorProvider.getValidator();
        Constructor constructor = getClass().getDeclaredConstructors()[0];
        Set<ConstraintViolation<?>> violations = validator.forExecutables()
                .validateConstructorParameters(constructor, args);
        if (!violations.isEmpty()) {
            String message = violations.stream()
                    .map(ConstraintViolation::getMessage)
                    .collect(Collectors.joining(System.lineSeparator()));
            throw new ConstraintViolationException(message, violations);
        }
    }

}

次のクラスでは、必要になるValidatorインスタンスが提供されます。

import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;

public class ValidatorProvider {

    private static final Validator VALIDATOR;

    static {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        VALIDATOR = factory.getValidator();
    }

    public static Validator getValidator() {
        return VALIDATOR;
    }

}

以上で、レコードのコンストラクタでインタフェースのvalidateメソッドを呼び出すために必要なものがすべてそろいました。この呼出しを行うためには、明示的なコンストラクタを指定する必要があります。指定したコンストラクタで、validateメソッドを呼び出すことができます。

import javax.validation.constraints.Positive;

public record RectangleRecord(double length, double width) implements Validatable {

    public RectangleRecord (
            @Positive(message = "Length is ${validatedValue} but must be greater than zero.") double length,
            @Positive(message = "Width is ${validatedValue} but must be greater than zero.") double width
        ) {
        validate(length, width);
        this.length = length;
        this.width = width;
    }

}

明示的なコンストラクタを作成する場合は、レコードのコンポーネントではなく、コンストラクタ・パラメータにアノテーションを付加する必要がある点に注意してください。先ほど確認したように、コンポーネントに付加したアノテーションは、コンポーネントから導かれるフィールド、アクセッサ、コンストラクタ・パラメータにも伝播します。コンストラクタ・パラメータについて言えば、明示的なコンストラクタが作成されていない場合にのみ伝播します。

それでは、無効な長さと幅を使ってRectangleRecordインスタンスを作成してみましょう。

RectangleRecord rectangle = new RectangleRecord(0, -5);

出力は次のようになります。

javax.validation.ConstraintViolationException: 
Length is 0.0 but must be greater than zero.
Width is -5.0 but must be greater than zero.
  at record.test.Validatable.validate(Validatable.java:21)
  at record.test.RectangleRecord.<init>(RectangleRecord.java:11)

インスタンス作成時に(レコードのコンストラクタで)検証ロジックが呼び出されるので、無効なデータを持つオブジェクトが作成されるのを防止できます。最初に示したBean検証の例では、あらかじめ無効な状態のオブジェクトを作成してからでないと、検証を行うことができませんでした。しかし、避けたいのは、まさにその無効な状態のレコードを作成することです。

ただし、カノニカル・コンストラクタを明示的に作成することは、すべてのコンストラクタ・パラメータを明示的に指定し、すべてのレコードのフィールド値を手動で設定しなければならないということでもあります。しかしこれもまた、レコードを使うことで避けようとしている、大変な煩雑さではないでしょうか。次のセクションでは、明示的なコンストラクタ宣言を省略しつつ、インスタンス化のプロセスで引き続きレコードのデータが検証されるようにする方法を紹介します。

Byte Buddy

Byte Buddyは、Javaアプリケーションの実行時に、コンパイラ不要で、Javaクラスを作成したり変更したりするためのライブラリです。JDKに含まれるコード生成ユーティリティ(Java Instrumentation APIなど)とは異なり、Byte Buddyを使うと任意のクラスを作成できるだけでなく、ランタイム・プロキシを作成するインタフェースを実装する必要もありません。

さらに、便利なAPIが提供されています。このAPIにより、Javaエージェントを使用して手動でクラスを変更したり、ビルド時にクラスを変更したりすることができます。このライブラリを使うと、既存のクラスの操作、オンデマンドによる新しいクラスの作成、メソッド呼出しのインターセプトなどを行うことができます。Byte Buddyを使用するために、Javaのバイトコードやクラスのファイル形式を理解している必要はありません。ただし、必要に応じて、カスタムのバイトコードを定義できます。

APIは他の動作の妨げにならないように設計されたため、Byte Buddyがコードの操作を行っても、クラス・ファイルには何の痕跡も残りません。クラスパスにByte Buddyがなくても生成されたクラスが動作するのはそのためです。

Byte Buddyは軽量ライブラリで、依存するのはASM Javaバイトコード・パーサー・ライブラリのビジターAPIだけです。よって、実行時のパフォーマンスも優れています。

ここで行いたいのは、ビルド時のコード操作です。Byte Buddyライブラリに含まれている専用のMavenプラグインを使うと、簡単にこの操作を実現できます。

ご存じの方も多いと思いますが、Mavenのビルド・ライフサイクルは複数のフェーズで構成されています。そのフェーズの1つに、compileフェーズと呼ばれるものがあります。Byte Buddyでは、このフェーズの後、指示に従ってJavaバイトコードの挿入や変更を行います。そのため、実行時に、パフォーマンスに影響する可能性のあるコード操作が行われることはありません。

最初に、Byte Buddyライブラリに必要な依存性を追加します。

<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy</artifactId>
    <version>1.10.14</version>
</dependency>

次のXMLで、ビルド・ライフサイクルにByte Buddy Mavenプラグインを追加します。

<plugin>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy-maven-plugin</artifactId>
    <version>1.10.14</version>
    <executions>
        <execution>
            <goals>
                <goal>transform</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <transformations>
            <transformation>
                <plugin>
                    record.test.RecordValidationPlugin
                </plugin>
            </transformation>
        </transformations>
    </configuration>
</plugin>

Byte Buddy Mavenプラグインで、net.bytebuddy.build.Pluginインタフェースを実装したRecordValidationPluginというカスタム・クラスを使用します。次に例を示します。

import java.io.IOException;
import javax.validation.Constraint;

import static net.bytebuddy.matcher.ElementMatchers.hasAnnotation;
import static net.bytebuddy.matcher.ElementMatchers.annotationType;

import net.bytebuddy.build.Plugin;
import net.bytebuddy.description.method.MethodDescription;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.dynamic.ClassFileLocator;
import net.bytebuddy.dynamic.DynamicType.Builder;
import net.bytebuddy.dynamic.scaffold.TypeValidation;
import net.bytebuddy.implementation.MethodDelegation;
import net.bytebuddy.implementation.SuperMethodCall;

public class RecordValidationPlugin implements Plugin {

    @Override
    public boolean matches(TypeDescription target) {
        return target.isRecord() && target.getDeclaredMethods()
                .stream()
                .anyMatch(m -> m.isConstructor() && hasConstrainedParameters(m));
    }

    @Override
    public Builder<?> apply(Builder<?> builder, TypeDescription typeDescription, ClassFileLocator classFileLocator) {
        try {
            builder = new ByteBuddy().with(TypeValidation.DISABLED).rebase(Class.forName(typeDescription.getName()));
        } catch (ClassNotFoundException ex) {
            throw new RuntimeException(ex);
        }
        return builder.constructor(this::hasConstrainedParameters)
                .intercept(SuperMethodCall.INSTANCE.andThen(MethodDelegation.to(RecordValidationInterceptor.class)));
    }

    private boolean hasConstrainedParameters(MethodDescription m) {
        return m.getParameters()
                .asDefined()
                .stream()
                .anyMatch(p -> !p.getDeclaredAnnotations()
                .asTypeList()
                .filter(hasAnnotation(annotationType(Constraint.class)))
                .isEmpty());
    }

    @Override
    public void close() throws IOException {
    }

}

このインタフェースには、matchesapplycloseという3つのメソッドがあります。最後のメソッドを実装する必要はありません。

Byte Buddyでは、最初のメソッドを使って、コードを変更したいすべてのクラスを探します。今回必要なのは、制約付きパラメータ(Bean検証アノテーション)があるコンストラクタを持つレコード・クラスだけです。そこで役立つのが、新しいメソッドClass.isRecord()です。

2つ目のメソッドでは、compileフェーズで生成されたバイトコードに変更が適用されます。このメソッドにより、前述したような、制約付きパラメータがあるレコード・コンストラクタに、RecordValidationInterceptorというカスタム・クラスに対するメソッド呼出しが追加されます。

さらに、次に示すカスタムのBuilderインスタンスを使う必要がある点に注意してください。Javaレコードはまだプレビュー機能なので、型検証を無効化する必要があるためです。

builder = new ByteBuddy().with(TypeValidation.DISABLED).rebase(Class.forName(typeDescription.getName()));

次に示すのが、RecordValidationInterceptorのコードです。

import java.lang.reflect.Constructor;
import java.util.Set;
import java.util.stream.Collectors;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import net.bytebuddy.implementation.bind.annotation.AllArguments;
import net.bytebuddy.implementation.bind.annotation.Origin;

public class RecordValidationInterceptor {

    private static final Validator VALIDATOR;

    static {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        VALIDATOR = factory.getValidator();
    }

    public static <T> void validate(@Origin Constructor<T> constructor, @AllArguments Object[] args) {
        Set<ConstraintViolation<T>> violations = VALIDATOR.forExecutables()
                .validateConstructorParameters(constructor, args);
        if (!violations.isEmpty()) {
            String message = violations.stream()
                    .map(ConstraintViolation::getMessage)
                    .collect(Collectors.joining(System.lineSeparator()));
            throw new ConstraintViolationException(message, violations);
        }
    }

}

コード操作の結果、レコードのコンストラクタからvalidateメソッドが呼び出され、Bean検証インスタンスにConstructorオブジェクトと適切なパラメータ値が渡されます。

このメソッドには、自由に名前を付けることができます。Byte Buddyでは、ライブラリ独自のアノテーション(@Origin@AllArgumentsなど)からメソッドを特定します。

ここで、コンポーネントに検証制約を追加して先ほど宣言したRectangleRecordを使い、プロジェクトをビルドします。次に例を示します。

import javax.validation.constraints.Positive;

public record RectangleRecord(
    @Positive(message = "Length is ${validatedValue} but must be greater than zero.") double length,
    @Positive(message = "Width is ${validatedValue} but must be greater than zero.") double width
) {}

ビルドが完了したら、生成されたバイトコードを確認できます。この確認を行うには、コマンドラインから次のコマンドを実行します(クラス・ファイルの逆アセンブルを行うことができます)。

javap -c RectangleRecord

次に示すのは、コンストラクタのバイトコードのみを抽出したものです。

public record.test.RectangleRecord(double, double);
    Code:
       0: aload_0
       1: dload_1
       2: dload_3
       3: aconst_null
       4: invokespecial #75                 // Method "<init>":(DDLrecord/test/RectangleRecord$auxiliary$Vd34tcl4;)V
       7: getstatic     #79                 // Field cachedValue$RxYQQtAf$d63lk91:Ljava/lang/reflect/Constructor;
      10: iconst_2
      11: anewarray     #81                 // class java/lang/Object
      14: dup
      15: iconst_0
      16: dload_1
      17: invokestatic  #87                 // Method java/lang/Double.valueOf:(D)Ljava/lang/Double;
      20: aastore
      21: dup
      22: iconst_1
      23: dload_3
      24: invokestatic  #87                 // Method java/lang/Double.valueOf:(D)Ljava/lang/Double;
      27: aastore
      28: invokestatic  #93                 // Method csv/to/records/RecordValidationInterceptor.validate:(Ljava/lang/reflect/Constructor;[Ljava/lang/Object;)V
      31: return

return文の直前にある最後の命令に注目してください。ここで、RecordValidationInterceptor.validateメソッドが呼び出されています。

次に、Byte Buddyによって変更されたコードをテストしてみます。

RectangleRecord rectangle = new RectangleRecord(0, -5);

出力は次のようになります。

javax.validation.ConstraintViolationException: 
Length is 0.0 but must be greater than zero.
Width is -5.0 but must be greater than zero.
  at csv.to.records.RecordValidationInterceptor.validate(RecordValidationInterceptor.java:32)
  at record.test.RectangleRecord.<init>(RectangleRecord.java)

おわかりのように、レコードのコンポーネントで通常のBean検証制約を使用しただけで、無効なデータを持つRectangleRecordインスタンスの作成が防止されました。Byte Buddyプラグインを使用すると、Bean検証によってJavaレコードの不変性を強制することができます。

まとめ

Javaレコードの多くの部分は、通常のJavaクラスと同じように振る舞います。しかし、考慮すべき違いもいくつかあります。その1つは、getterやsetterがないことなどが理由で、レコードがJavaBeansの規則に従っていないことです。そのため、レコードでは、既存のフレームワークやライブラリを使ったり、getterやsetterを通してインスタンス変数にアクセスしたりする部分が動作しない可能性があります。

さらに、レコードのフィールドはコンストラクタ経由でのみ設定でき、したがって実質的にfinalである点も通常のクラスとは異なります。そのため、明示的なコンストラクタの中で、対応する検証ロジックまたは制約アノテーションを使うことにより、レコードの状態の検証が容易です。通常のクラスよりも簡潔に宣言できるだけでなく、状態検証が容易であることも、レコードの主なメリットの1つです。

レコードが正式なJava機能になるときを心待ちにしています。それもできればJava 16でそうなることを期待しています。それまでの間は、Java 15での第2回プレビューを楽しむことにします。

さらに詳しく

 

Frank Kiwy

 

ヨーロッパのある政府機関のITセンターに勤務するシニア・ソフトウェア開発者およびプロジェクト・リーダー。Java SE、Java EE、Web技術を専門とする。ソフトウェア・アーキテクチャにも関心を持ち、継続的インテグレーションや継続的デリバリの分野に貢献。現在は欧州連合の共通農業政策(Common Agricultural Policy)に携わり、いくつかのプロジェクトを担当している。プログラミングでは、明確でわかりやすいAPIを持つ優れた設計のソフトウェアを重視している。