Spring Native でリフレクションを使ったメソッドの取得

Java の下記インスタンスに対して、それぞれ getClass().getDeclaredMethods() する処理を Spring Native でネイティブイメージ化するとどうなるのか試してみました。

  • (a) インナークラス
  • (b) レコードクラス
  • (c) ラムダ式

結果として、(a) は特に問題なし、(b) は少し工夫が必要、(c) は今のところ無理そうでした。

今回のソースは こちら

実装

今回は Gradle でビルドし、ビルド・実行には Java 17 を使う事にします。

ビルド定義

ビルド定義ファイルは下記のようになりました。

Spring Native を適用するため org.springframework.experimental.aot プラグインを導入し、bootBuildImage のデフォルト設定を変更しネイティブイメージ化を有効にしています。

build.gradle
plugins {
    id 'org.springframework.boot' version '2.7.0'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
    id 'org.springframework.experimental.aot' version '0.12.0'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

repositories {
    maven { url 'https://repo.spring.io/release' }
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    implementation 'io.projectreactor:reactor-core'
}

tasks.named('bootBuildImage') {
    builder = 'paketobuildpacks/builder:tiny'
    environment = ['BP_NATIVE_IMAGE': 'true']
}
settings.gradle
pluginManagement {
    repositories {
        maven { url 'https://repo.spring.io/release' }
        mavenCentral()
        gradlePluginPortal()
    }
}

rootProject.name = 'sample'

リフレクション処理(メソッドの取得)

(a) ~ (c) のインスタンスに対してそれぞれ getClass().getDeclaredMethods() を実施し、その結果を文字列化して出力しています。

Reactor の Flux を利用している点に関しては、ついでに試してみただけで本件との直接的な関係はありません。

Task.java
・・・
@Component
public class Task implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) throws Exception {
        for (var s : test().toIterable()) {
            System.out.println(s);
        }
    }

    private Flux<String> test() {
        return Flux.push(sink -> {
            showMethods(sink, new Sample1());
            showMethods(sink, new Sample2("sample2", 1));
            // (c) ラムダ式
            UnaryOperator<String> sample3 = s -> "sample3-" + s;
            showMethods(sink, sample3);

            sink.complete();
        });
    }

    private <T> void showMethods(FluxSink<String> sink, T obj) {
        try {
            // メソッドの取得
            var ms = obj.getClass().getDeclaredMethods();

            if (ms.length == 0) {
                sink.next("*** WARN [ " + obj.getClass() + " ]: no methods");
                return;
            }

            for (var m : ms) {
                sink.next("[ " + obj.getClass() + " ]: " + m);
            }
        } catch (Exception ex) {
            sink.error(ex);
        }
    }
    // (a) インナークラス
    private static class Sample1 {
        public String method1(int value) {
            method2();
            return "sample1:" + value;
        }

        private void method2() {}
    }
    // (b) レコードクラス
    private record Sample2(String name, int value) {}
}

ビルドと実行

1. bootRun で実行

まずは、ネイティブイメージ化を行わずに、下記の Application クラスを使って bootRun(実行)してみます。

Application.java
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

結果は以下の通り、(a) ~ (c) のメソッド取得に成功しており問題は無さそうです。

実行結果1
$ gradle bootRun
・・・
> Task :bootRun
2022-06-19 20:13:46.492  INFO 11354 --- [           main] o.s.nativex.NativeListener               : AOT mode disabled

・・・
[ class com.example.Task$Sample1 ]: private void com.example.Task$Sample1.method2()
[ class com.example.Task$Sample1 ]: public java.lang.String com.example.Task$Sample1.method1(int)
[ class com.example.Task$Sample2 ]: public java.lang.String com.example.Task$Sample2.name()
[ class com.example.Task$Sample2 ]: public int com.example.Task$Sample2.value()
[ class com.example.Task$Sample2 ]: public final boolean com.example.Task$Sample2.equals(java.lang.Object)
[ class com.example.Task$Sample2 ]: public final java.lang.String com.example.Task$Sample2.toString()
[ class com.example.Task$Sample2 ]: public final int com.example.Task$Sample2.hashCode()
[ class com.example.Task$$Lambda$354/0x0000000800ddd330 ]: public java.lang.Object com.example.Task$$Lambda$354/0x0000000800ddd330.apply(java.lang.Object)

2. ネイティブイメージの実行(@TypeHint なし)

次に、先ほどの Application クラスをそのまま使って、bootBuildImage でネイティブイメージ化した後、docker run で実行してみます。

実行結果2
$ gradle bootBuildImage
・・・

$ docker run --rm sample:0.0.1-SNAPSHOT
2022-06-19 20:34:32.294  INFO 1 --- [           main] o.s.nativex.NativeListener               : AOT mode enabled

・・・
*** WARN [ class com.example.Task$Sample1 ]: no methods
*** WARN [ class com.example.Task$Sample2 ]: no methods
*** WARN [ class com.example.Task$$Lambda$0ea7163e8bdc44f50e7627d382ac77b02e97e33f ]: no methods

bootRun の結果とは以下のような違いが出ました。

  • (x) メソッドを一切取得できていない(リフレクションが機能していない)
  • (y) ラムダ式のクラス名が bootRun 時と違う

まず、Spring Native が使用している GraalVM では、リフレクションを許可するクラスを事前に指定する必要があり、そうしないとリフレクションが機能せず (x) のような結果になるようです。

Spring Native では @TypeHint アノテーションでリフレクションの許可を指定できるようになっています。※

 ※ org.springframework.experimental.aot プラグインでは、
 リフレクションを許可するための設定はビルド時に以下のファイルへ出力されるようになっており、
 このファイルで Spring Native のデフォルト設定や @TypeHint の結果を確認できます。

 build/generated/resources/aotMain/META-INF/native-image/org.springframework.aot/spring-aot/reflect-config.json

次に、ラムダ式はネイティブイメージ化の際に GraalVM の内部で特殊な処理を施しているようで(org.graalvm.compiler.java.LambdaUtils)、これが (y) の原因になっていると思われます。

そうすると、@TypeHint アノテーションを処理する段階でそのクラスは存在せず、通常の方法でリフレクションを許可するのは無理そうな気がします。

色々と試したり調べてみましたが、ラムダ式でリフレクションを許可する方法がどうしても分からなかったので、今回は断念しました。

3. ネイティブイメージの実行(@TypeHint あり)

@TypeHint の types(型は Class<?>[])や typeNames(型は String[])で型を指定し、access でリフレクションのどの操作を許可するかを指定できます。

今回は private なクラスを用いているため、typeNames を使いました。

Application.java
@TypeHint(typeNames = "com.example.Task$Sample1", access = QUERY_DECLARED_METHODS)
@TypeHint(typeNames = "com.example.Task$Sample2", access = { QUERY_DECLARED_METHODS, DECLARED_METHODS })
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

レコードクラス(Sample2)の方だけ QUERY_DECLARED_METHODSDECLARED_METHODS の 2つを指定していますが、これは下記ビルドエラーを回避するためにこうする必要がありました。(QUERY_DECLARED_METHODS だけだとビルドエラーが発生)

@TypeHint(typeNames = "com.example.Task$Sample2", access = QUERY_DECLARED_METHODS) とした場合のエラー例
 [creator]     Fatal error: com.oracle.svm.core.util.VMError$HostedError: com.oracle.svm.core.util.VMError$HostedError: New Method or Constructor found as reachable after static analysis: public int com.example.Task$Sample2.value()

これをネイティブイメージ化して実行した結果は以下の通りです。

とりあえず、インナークラスとレコードクラスのメソッドを取得できるようになりました。

実行結果3
$ gradle bootBuildImage
・・・

$ docker run --rm sample:0.0.1-SNAPSHOT
・・・
[ class com.example.Task$Sample1 ]: public java.lang.String com.example.Task$Sample1.method1(int)
[ class com.example.Task$Sample1 ]: private void com.example.Task$Sample1.method2()
[ class com.example.Task$Sample2 ]: public java.lang.String com.example.Task$Sample2.name()
[ class com.example.Task$Sample2 ]: public int com.example.Task$Sample2.value()
[ class com.example.Task$Sample2 ]: public final boolean com.example.Task$Sample2.equals(java.lang.Object)
[ class com.example.Task$Sample2 ]: public final java.lang.String com.example.Task$Sample2.toString()
[ class com.example.Task$Sample2 ]: public final int com.example.Task$Sample2.hashCode()
*** WARN [ class com.example.Task$$Lambda$0ea7163e8bdc44f50e7627d382ac77b02e97e33f ]: no methods