One of the hardest things about compiling a Spring Boot application into a GraalVM native image is generating the necessary reflection, resource and proxy configurations needed for the native image runtime.
These files, placed in the src/main/resources/META-INF/native-image
will inform the runtime about all
of the java magic that would be happening in a JVM environment, that isn’t available in the native-image.
When we do a nativeCompile, some native-image metadata is generated by the build process. But it can only see
what information is available at build-time. The recommended way of generating the additional data that isn’t
available at build-time is by using the tracing-agent
. It’s fairly documented here on the official GraalVM site
There is a major downside to this tracing-agent process; lets say we have a native-image application with a dozen api endpoints using spring-web-mvc. When a developer updates the application and adds a new endpoint, the metadata hasn’t necessarily been updated to make the native-image runtime aware of the new reflection metadata. So while the application may compile and run correctly, the endpoint will fail at runtime. An example of this will be shown in this post.
To complete the example, we would simply execute the application using the JVM and the tracing agent, and exercise the endpoint. This will inform the tracing agent of what needs to be added to the reflect-config.json and other files.
The goal of this post is to explain exactly what the reflect-config.json
file is doing and what information the
tracing agent looks to write.
Test Project
To accomplish this, we’re going to create a new project and go through a typical workflow to build and update a native-image application.
We can use Spring Initializr to generate our test project.
Executing a native run will show the application successfully executing.
$ ./gradlew nativeRun
...
> Task :nativeRun
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.2.4)
... INFO 12345 --- [demo] [ main] com.example.demo.DemoApplication : Starting AOT-processed DemoApplication using Java 21.0.2 with PID 12345
... INFO 12345 --- [demo] [ main] com.example.demo.DemoApplication : No active profile set, falling back to 1 default profile: "default"
... INFO 12345 --- [demo] [ main] com.example.demo.DemoApplication : Started DemoApplication in 0.023 seconds (process running for 0.053)
We need to add some reflection to upset the native-image application. Rather than linking to a github repository, this example is short enough that we can just include it here:
In the following example, we’re gonna call the class automatically in a PostConstruct completely through reflection. At build time, the application has no idea what we’re doing.
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@PostConstruct
void test() throws ClassNotFoundException, InvocationTargetException, InstantiationException, IllegalAccessException, NoSuchMethodException {
Class<?> printerClass = Class.forName("com.example.demo.TestPrinter");
// Class<?>.newInstance() is deprecated, so this example eventually won't work
TestPrinter printer = (TestPrinter) printerClass.newInstance();
Method printMethod = printerClass.getDeclaredMethod("print");
printMethod.setAccessible(true);
printMethod.invoke(printer);
}
}
class TestPrinter {
void print() {
System.out.println("Hello World");
}
}
Now that we have some reflection, we’ll execute the same command and see what happens!
$ ./gradlew nativeRun
> Task :nativeRun FAILED
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.2.4)
... INFO 12346 --- [demo] [ main] com.example.demo.DemoApplication : Starting AOT-processed DemoApplication using Java 21.0.2 with PID 12346
... INFO 12346 --- [demo] [ main] com.example.demo.DemoApplication : No active profile set, falling back to 1 default profile: "default"
... WARN 12346 --- [demo] [ main] o.s.c.support.GenericApplicationContext : Exception encountered during context initialization - cancelling refresh attempt:
org.springframework.beans.factory.BeanCreationException:
Error creating bean with name 'demoApplication': com.example.demo.TestPrinter
... ERROR 12346 --- [demo] [ main] o.s.boot.SpringApplication : Application run failed
Reviewing what we know about native-image compilation, this makes perfect sense.
Our specific error that was output in the stacktrace was
Caused by: java.lang.NoSuchMethodException: com.example.demo.TestPrinter.<init>()
Lets first execute this application with the tracing agent and see if we can resolve our runtime issue by making the build aware of this reflection at build-time.
Tracing Agent Execution
First, we’re going to run a build with ./gradlew build
to create our jvm executable.
Next, we’ll execute our tracing agent execution from the jvm application. We don’t need to explicitly run through any workflows so the tracing agent can see the JVM instantiations, since we do this in a post-construct.
java -agentlib:native-image-agent=config-merge-dir=./config -jar build/libs/demo-0.0.1-SNAPSHOT.jar
The output is what you’d expect from the jvm application version, namely “Hello World” was printed to the console via reflection.
However, in our ./config
folder, we now have a handful of native-image metadata files.
Now the tracing agent records everything it can, so even though we’re looking for a single reflection configuration, we ended up with a 49Kb text file with a ton of internal spring reflection registrations. Many of these would be handled at build-time or through reachability metadata. Here’s an example of the type of things the tracing agent picks up additionally:
...
{
"name":"org.springframework.context.annotation.ComponentScan",
"queryAllDeclaredMethods":true
},
{
"name":"org.springframework.context.annotation.ComponentScan$Filter",
"queryAllDeclaredMethods":true
},
{
"name":"org.springframework.context.annotation.Conditional",
"queryAllDeclaredMethods":true
},
{
"name":"org.springframework.context.annotation.Configuration",
"queryAllDeclaredMethods":true
},
...
In most cases, just generating the metadata using the tracing agent is the simplest solution. However, for the sake of understanding what’s happening, we’re going to add the file by hand.
First, we’re going to create the location to inject these files into the build process;
creating the src/main/resources/META-INF/native-image
directory.
Inside, we’ll create the reflect-config.json file.
Breaking Down Reflect-Config
The official GraalVM documentation has examples of all of the available input types for each of the metadata files generated by the tracing agent and used by the native-image build process.
When you need to figure out what to add, using that as a reference will describe the JSON.
Now, lets get into creating this file. Because it is fairly abstract, we’ll build the json as we go and update each piece, rather than display the final file with notes.
It starts with an empty array of json objects:
[{}]
Inside, we need to let the build know our class and it’s path
[{
"name":"com.example.demo.TestPrinter", // Our classname and path
}]
We reflectively create an instance of the object, so we need to let the build know that this class has a constructor.
[{
"name":"com.example.demo.TestPrinter", // Our classname and path
"methods":[{
"name":"<init>", // representation of the constructor
"parameterTypes":[] // no constructor parameters
}]
]}
Now finally, we need to make it aware of the print
method.
[{
"name":"com.example.demo.TestPrinter", // Our classname and path
"methods":[{
"name":"<init>", // representation of the constructor
"parameterTypes":[] // no constructor parameters
},{
"name":"print", // the name of our defined method
"parameterTypes":[] // no method parameters
}]
]}
With just the contents above, we can now rerun our native-image and see if that was enough to inform the build process about the required reflection to execute as we expect!
... INFO 96340 ... com.example.demo.DemoApplication : Starting AOT-processed DemoApplication using Java 21.0.2 with PID 12347
... INFO 96340 ... com.example.demo.DemoApplication : No active profile set, falling back to 1 default profile: "default"
Hello World
... INFO 96340 ... com.example.demo.DemoApplication : Started DemoApplication in 0.025 seconds (process running for 0.052)
Success!!
With the native-image builder made aware of the only required reflection, our app runs perfectly.
Comparing our java execution to our native-image execution shows the order of magnitude increase in startup time, one of the massive benefits of running as a native-image.
Native: Started DemoApplication in 0.025 seconds (process running for 0.052)
JVM: Started DemoApplication in 0.415 seconds (process running for 0.658)
Takeaways
Sometimes when writing spring boot native-image applications, we’ll have a complicated workflow that is hard or impossible to catch with the tracing-agent. In these instances, adding the reflection configuration by hand is a perfectly acceptable way to ensure the application will function correctly at runtime.