GraalVM Native Build Tools, for Maven

3
0
Send lab feedback

GraalVM Native Build Tools, for Maven

Introduction

This lab takes you step by step through the process of building a cloud native Java application with GraalVM Native Image , using Maven. It is aimed at developers with a knowledge of Java.

GraalVM Native Image technology compiles Java code ahead-of-time into a self-contained executable file. Only the code that is required at run time by the application gets added into the executable file.

An executable file produced by Native Image has several important advantages, in that it:

  • Uses a fraction of the resources required by the JVM, so is cheaper to run
  • Starts in milliseconds
  • Delivers peak performance immediately, with no warmup
  • Can be packaged into a lightweight container image for faster and more efficient deployment
  • Presents a reduced attack surface (more on this in future labs)

Many of the leading microservice frameworks support ahead-of-time compilation with GraalVM Native Image, including Micronaut, Spring, Helidon, and Quarkus.

In addition, there are Maven and Gradle plugins for Native Image so you can easily build, test, and run Java applications as executable files.

Note: Oracle Cloud Infrastructure (OCI) provides GraalVM Enterprise at no additional cost.

Estimated lab time: 45 minutes

Lab Objectives

In this lab you will:

  • Use the GraalVM Native Build Tools for Maven to build, test, and run a demo application.
  • Use the plugin to run unit tests on your native executable.
  • Use the plugin to create the reflection configuration for your native executable.

STEP 1: Connecting to a Remote Host and Check the Development Environment

Your development environment is provided by a remote host: an OCI Compute Instance with Oracle Linux 8, 4 cores, and 32GB of memory. The Luna Labs desktop environment will display before the remote host is ready: this can take up to two minutes.

Visual Studio Code (VS Code) will launch automatically and connect to your remote development environment.

  1. A VS Code window will open and automatically connect to the VM instance that has been provisioned for you. Click Continue to accept the machine fingerprint.

    VS Code Accept

    If you don't click Continue, VS Code will present you with a dialogue box, shown below. Click Retry--VS Code will ask you to accept the machine fingerprint. Then click Continue.

    VS Code Retry Connection

    Issues With Connecting to the Remote Development Environment

    If you encounter any other issues in which VS Code fails to connect to the remote development environment that are not covered above, try the following:

    • Close VS Code
    • Double-click the "Luna-Lab.html" icon on your desktop
    • Copy the "Configure Script" from the Resources tab and paste it into the Luna Desktop Terminal again
    • Repeat the above instructions to connect to the Remote Development environment

You are done! Congratulations, you are now successfully connected to a remote host in Oracle Cloud!

The script will open VS Code--connected to your remote host—-with the source code for the lab opened.

Next, open a Terminal within VS Code. The Terminal enables you to interact with the remote host. A terminal can be opened in VS Code via the menu: Terminal > New Terminal, as shown below.

VS Code Terminal

You will use this Terminal to perform the steps described in the lab.

Note on the Development Environment

You will use GraalVM Enterprise 22 as the Java environment for this lab. GraalVM is a high performance JDK distribution from Oracle built on the trusted and secure Oracle Java SE.

Your development environment comes preconfigured with GraalVM and the Native Image tooling required for this lab.

You can easily check that by running these commands in your Terminal:

java -version

native-image --version

STEP 2: Building, Testing, and Running the Demo Application

In this lab you will create a small, but hopefully interesting, demo application. The application makes use of reflection, so it is a good example to showcase a number of features of the GraalVM Native Build Tools for Maven. This application is also used in the Luna Lab on GraalVM Native Image and Reflection , so if you have already completed that lab, please feel free to skip over the description that follows of how the code works.

The Closed World Assumption and Using Reflection with Native Image

When you use the native-image tool (that comes with GraalVM) to create a native executable from an application, it relies on being able to discover, at build time, everything that can be referenced within your application code. This is what is known as the "closed world" assumption. That is, everything that needs to be included in the output native executable must be known when it is built. Anything that is not found by static analysis, or not explicitly specified in the configuration supplied to the native-image tool, will not be included in the executable file.

For more information on the use of configuration files, see Build a Native Executable with Reflection

Before you continue, review the build/run workflow for applications that are built using GraalVM Native Image:

  1. Compile your Java source code into Java bytecode classes, using javac or mvn package
  2. Use the native-image tool to compile those Java bytecode classes into a native executable
  3. Run the native executable

Before going further, take a quick recap of what happens during step two before using the GraalVM Native Build Tools for Maven to integrate this step into your Maven workflow.

The native-image tool analyses your Java application to determine which classes are reachable. But, for classes that the native-image build tool can't determine are required, but may be required at runtime (as in the demo application below), then you need to add configuration to detail this - Build a Native Executable with Reflection .

The demo application requires configuration, and the following steps illustrate how the GraalVM Native Build Tools for Maven can help generate the configuration for you.

STEP 3: An Application that uses Reflection

Imagine you have the following class, DemoApplication.java (a copy of this can be found in the directory, lab/src/main/java/com/example/demo):

package com.example.demo;

import java.lang.reflect.Method;

class StringReverser {
    static String reverse(String input) {
        return new StringBuilder(input).reverse().toString();
    }
}

class StringCapitalizer {
    static String capitalize(String input) {
        return input.toUpperCase();
    }
}

public class DemoApplication {
    public static void main(String[] args) throws ReflectiveOperationException, IllegalArgumentException {
        DemoApplication demo = new DemoApplication();
        System.out.println(demo.doSomething(args));
    }

    public DemoApplication() {
    }

    public String doSomething(String[] args) throws ReflectiveOperationException, IllegalArgumentException {
        if (args == null || args.length != 3) {
            //
            throw new IllegalArgumentException("Usage : Class Method InputString");
        }
        String className = args[0];
        String methodName = args[1];
        String input = args[2];

        Class<?> clazz = Class.forName(className);
        Method method = clazz.getDeclaredMethod(methodName, String.class);
        String result = (String)method.invoke(null, input);
        return result;
    }
}

The above code will reflectively load one of the classes, StringReverser or StringCapitalizer, and use their methods to transform a String argument.

There are also unit tests (that cover the various test cases) in src/test/java/com/example/demo/DemoApplicationTests.java. To help you understand how the application works, take a look at the unit tests defined in this file. These unit tests will be important later when you generate the extra configuration needed to build a working native executable.

In your Terminal, run the following command. This will test the demo application by running the unit tests:

mvn test

You should see something similar to the following, which informs you that five unit tests ran successfully:

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.example.demo.DemoApplicationTests
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.053 s - in com.example.demo.DemoApplicationTests
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

Build a JAR file of the application using the following command:

mvn package

This creates a JAR file in the target directory. Take a look:

ls -l target

You should see the following:

-rw-rw-r--. 1 opc opc 7417 Sep 14 16:47 demo-0.0.1-SNAPSHOT-jar-with-dependencies.jar
-rw-rw-r--. 1 opc opc 7379 Sep 14 16:47 demo-0.0.1-SNAPSHOT.jar

Run the "fat" JAR file, which also contains a file named META-INF/MANIFEST.MF to define the main class, as follows:

java -jar ./target/demo-0.0.1-SNAPSHOT-jar-with-dependencies.jar com.example.demo.StringReverser reverse Java

It should produce the following output:

avaJ

If that worked as expected, the key thing to notice with this demo application is that it relies on reflection:

Class<?> clazz = Class.forName(className);
Method method = clazz.getDeclaredMethod(methodName, String.class);
String result = (String)method.invoke(null, input);

Now that you understand the application, look at how the Native Build Tools for Maven enable building native binaries using Maven.

STEP 4 Introducing the GraalVM Native Build Tools for Maven

You will use a Maven profile (for more information, see Maven profiles ) to separate the building of the native executable from the standard building and packaging of your Java application.

Open the file named

pom.xml
https://luna.oracle.com/api/v1/labs/e5af592b-3365-45ce-b964-6fd409e5c76f/gitlab/tutorial/lab/pom.xml
and review it. Find the profile with the ID native. The profile is included below:

<profiles>
    <profile>
        <id>native</id>
        <build>
            <plugins>
                ...
                <plugin>
                    <groupId>org.graalvm.buildtools</groupId>
                    <artifactId>native-maven-plugin</artifactId>
                    <version>${native.maven.plugin.version}</version>
                    <!-- Enables Junit Test Support -->
                    <extensions>true</extensions>
                    <executions>
                        <!-- Binds to the package phase - causes the native executable to be created
                            when you run, mvn -Pnative package -->
                        <execution>
                            <id>build-native</id>
                            <goals>
                                <goal>build</goal>
                            </goals>
                            <phase>package</phase>
                        </execution>
                        <!-- Binds to the test phase - causes the JUnit tests to be run as native code
                            when you run, mvn -Pnative test -->
                        <execution>
                            <id>test-native</id>
                            <goals>
                                <goal>test</goal>
                            </goals>
                            <phase>test</phase>
                        </execution>
                    </executions>
                    <!-- This section is used to configure the native image build -->
                    <configuration>
                        <!-- Tracing Agent Configuration -->
                        <agent>
                            <!-- shared options -->
                            <options>
                                <option>experimental-class-loader-support</option>
                            </options>
                            <!-- test options -->
                            <options name="test">
                                <!-- Uses an access filter when running the Tracing Agent -->
                                <option>access-filter-file=${basedir}/src/test/resources/access-filter.json</option>
                            </options>
                        </agent>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </profile>
</profiles>

The important things to note here are:

  1. The GraalVM Native Build Tools plugin is contained within a profile with ID, native, which means that the plugin won't be run unless you activate the profile.
  2. You must enable the extensions to the plugin with <extensions>true</extensions> to enable the tracing agent and JUnit support.
  3. You can configure the native image build steps in the configuration section.
  4. You can configure how Tracing Agent in the agent section.
  5. You must define an access-filter within the configuration for use by the Tracing Agent.

Note : Using the GraalVM Native Build Tools to Generate Tracing Configuration

Because the application uses reflection, you have to tell the native-image tool about this, so that it knows to include the classes accessed by reflection in the output native executable. Recall that GraalVM Native Image uses a "closed world" assumption, as discussed earlier. If you make use of reflection, which happens at runtime, you need to supply configuration files to the native-image build tool that detail this. Typically these are generated using the Tracing Agent .

As the demo application makes use of reflection you can make use of the tracing agent to generate this configuration. There are two ways to achieve this:

  1. Run the unit tests and use the GraalVM Native Build Tools to inject the tracing agent into the JVM. This is described in the next section.
  2. Run your application and use the GraalVM Native Build Tools to inject the Tracing Agent into the JVM. For more information on how to do this, see the documentation .

Note : If you use the Tracing Agent to generate this configuration, it is important that you exercise all of the paths in your code. This is one reason it is essential to have a good unit test suite.

Copying Generated Tracing Agent Configuration to Your Source Tree

You may have noticed that the pom.xml file includes another plugin in the "native" profile section. It is worth discussing what this is and how it is used. When you run the Tracing Agent on your unit tests, which you will do in the next section, the configuration files are generated in a location within the target directory, target/native/agent-output/test. To ensure the native-image tool picks these up when you build a native executable, they need to be relocated to the location that the tool expects them to be in, that is: src/main/resources/META-INF/native-image.

The maven-resources-plugin automates this task, so that the configuration files automatically get copied into the source tree when they are available. The following configuration is required to achieve this:

<!--
    Copy over any tracing agent config generated when you run the tracing agent against the tests
-->
<plugin>
    <artifactId>maven-resources-plugin</artifactId>
    <version>3.0.2</version>
    <executions>
        <execution>
            <id>copy-agent-config</id>
            <phase>generate-sources</phase>
            <goals>
                <goal>copy-resources</goal>
            </goals>
            <configuration>
                <!-- The tracing agent config needs to be placed here to be picked up
                        by the native-image tool -->
                <outputDirectory>src/main/resources/META-INF/native-image</outputDirectory>
                <resources>
                    <resource>
                        <!-- The location that the native build tools will write the tracing agent
                                config out to -->
                        <directory>${basedir}/target/native/agent-output/test</directory>
                    </resource>
                </resources>
            </configuration>
        </execution>
    </executions>
</plugin>

An alternative solution would be to tell the native-image that it should look into the directory named /target/native/agent-output/test for any tracing configuration. This can be achieved using the -H:ConfigurationFileDirectories option. At the end of this workshop you will see how to pass extra parameters, such as this, to native-image using the GraalVM Native Build Tools for Maven plugin.

STEP 5 Using the GraalVM Native Build Tools for Maven to Run the Tracing Agent

Having seen the details of how the plugin works, now use the tooling to generate the Tracing Agent configuration.

From your Terminal, run the following command. It will run your unit tests while at the same time enabling the Tracing Agent and generating the Tracing Agent configuration for your application:

mvn -Pnative -DskipNativeTests=true -DskipNativeBuild=true -Dagent=true test

This will run your unit tests, but activate the profile that contains the GraalVM Native Build Tools for Maven plugin, this is what the the -Pnative option specifies. The command includes three additional options:

  • -DskipNativeTests=true : The GraalVM Native Build Tools for Maven can build a native executable from your unit tests and then run that, in order to check that your unit tests work for the natively compiled code. By setting this option to true, these tests are not run. (This option is used again later in the lab.)
  • -DskipNativeBuild=true : This option stops the plugin from building a native executable of the demo application. (Likewise, this option is used again later in the lab.)
  • -Dagent=true : This causes the Tracing Agent to be "injected" into the application as it runs the unit tests.

View the newly created Tracing Agent configuration.

ls -l target/native/agent-output/test

You should see something similar to:

total 24
drwxrwxr-x. 2 opc opc    6 Sep 15 14:07 agent-extracted-predefined-classes
-rw-rw-r--. 1 opc opc  538 Sep 15 21:03 jni-config.json
-rw-rw-r--. 1 opc opc   64 Sep 15 21:03 predefined-classes-config.json
-rw-rw-r--. 1 opc opc    3 Sep 15 21:03 proxy-config.json
-rw-rw-r--. 1 opc opc 1147 Sep 15 21:03 reflect-config.json
-rw-rw-r--. 1 opc opc  375 Sep 15 21:03 resource-config.json
-rw-rw-r--. 1 opc opc   51 Sep 15 21:03 serialization-config.json

Now, take a moment to look at the contents of the reflect-config.json file:

[
{
  "name":"com.example.demo.DemoApplicationTests",
  "allDeclaredFields":true,
  "allDeclaredClasses":true,
  "queryAllDeclaredMethods":true,
  "queryAllPublicMethods":true,
  "queryAllDeclaredConstructors":true,
  "methods":[
    {"name":"<init>","parameterTypes":[] }, 
    {"name":"testCapitalise","parameterTypes":[] }, 
    {"name":"testNoParams","parameterTypes":[] }, 
    {"name":"testNonExistantClass","parameterTypes":[] }, 
    {"name":"testNonExistantMethod","parameterTypes":[] }, 
    {"name":"testReverse","parameterTypes":[] }
  ]
},
{
  "name":"com.example.demo.StringCapitalizer",
  "methods":[{"name":"capitalize","parameterTypes":["java.lang.String"] }]
},
{
  "name":"com.example.demo.StringReverser",
  "methods":[{"name":"reverse","parameterTypes":["java.lang.String"] }]
},
/* Some parts excluded for brevity */
]

Notes on Using an Access Filter

When you use the Tracing Agent to generate the configuration files from running the unit tests, you need to create an access filter to ensure that the agent excludes certain classes. Take a look at the file that the application uses, lab/src/test/resources/access-filter.json. For more information, see Agent Advanced Usage .

You can see the that the reflect-config.json file contains the names of the classes that the demo application loads via reflection.

STEP 6 Running the Unit Tests as Native Code

The GraalVM Native Image Build Tools for Maven also have the ability to compile your unit tests into a native executable. This is useful as it give you the confidence that your code will run as expected as a native executable.

You can enable this behavior using the <extensions>true</extensions> element of the GraalVM Native Build Tools for Maven plugin configuration. In Step 5 you overrode this behavior by using the -DskipNativeTests=true command-line option that disables building a native executable of the tests.

Now try building natively compiled versions of your unit tests and run them.

From your Terminal, run the following command (remember to remove the option -DskipNativeTests=true):

mvn -Pnative -DskipNativeBuild=true -Dagent=true test

This will do the following:

  1. Compile your code, if needed.
  2. Inject the Tracing Agent and then run your Unit Tests on the JVM (not native).
  3. Compile a native executable that will run the unit tests, to which it will pass in the newly created Tracing Configuration.
  4. Run the native executable version of your tests.

You should see the following output:

Test run finished after 3 ms
[         2 containers found      ]
[         0 containers skipped    ]
[         2 containers started    ]
[         0 containers aborted    ]
[         2 containers successful ]
[         0 containers failed     ]
[         5 tests found           ]
[         0 tests skipped         ]
[         5 tests started         ]
[         0 tests aborted         ]
[         5 tests successful      ]
[         0 tests failed          ]

[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

And, if you look within the target directory you should see the native executable that was created that runs the tests, native-tests. You can even run this - this is a native executable of your unit tests:

./target/native-tests

STEP 7 Building the Native Executable

So far you have seen that the -Dagent=true command-line option injects the Tracing Agent into your unit tests. You have also seen that you can generate a native executable of the unit tests, which you can run independently without using Maven. Now it is time to build a native executable of your application itself! This time run the same command as before, but remove the option that switched off the build of the native executable of the application, -DskipNativeBuild=true.

Run the following from your Terminal (remember to remove the option -DskipNativeBuild=true):

mvn -Pnative -Dagent=true package

This builds your native executable--which can be found in the target directory. The default name for the generated native executable is the name of the artifactID defined in the Maven pom.xml file. Now run the executable to confirm that it works:

./target/demo com.example.demo.StringReverser reverse hello
olleh

It's important to remember that you needed to copy (in the _pom/xml file) the generated Tracing Agent configuration files, without which the native executable would have built, but wouldn't have been able to run. Copying the configuration generated from running the unit tests may not always be the correct solution, but it is a good starting point.

STEP 8 Passing Parameters to The Native Build Tool

In this last section, the lab demonstrates a few examples of how you can pass options to the native-image tool while using the GraalVM Native Build Tools for Maven plugin.

Take another look at the Maven pom.xml file. Below is a snippet that is commented out within the configuration for the plugin:

<!--
<imageName>DeMo</imageName>
<buildArgs>
    <buildArg>-Ob</buildArg>
    <buildArg>-H:+ReportExceptionStackTraces</buildArg>
</buildArgs>
-->

This contains a number of elements that enable you to pass extra configuration to the native-image build tool during the build of the native executable. Take a look at each of them in turn.

Firstly, you can specify the name of the output native executable file, as shown in this snippet:

<imageName>DeMo</imageName>

This will create a native executable file named, DeMo. Use this element to override the default naming of the file.

If you want to pass additional arguments to native image, use in the configuration of the plugin. Any of the native-image command-line options can be passed in using this mechanism. For example:

<buildArgs>
    <buildArg>-Ob</buildArg>
</buildArgs>

The above fragment passes in one additional option, -Ob. This option enables quick build mode for GraalVM Native Image--however, you can pass in any of the recognised options in this manner. For a full list of the available options available with GraalVM Native Image, run the following command:

native-image --expert-options-all

Now uncomment the section you just looked at in the pom.xml file and then rebuild the application. First edit the file and remove the comments, then from your Terminal, run the following command:

mvn -Pnative -Dagent=true clean package

This should build, just as before. Take a look inside the target directory:

ls -l target

You can see that the native executable file has been created with its new name:

-rwxrwxr-x. 1 opc opc 11278976 Sep 16 16:19 DeMo

Conclusion

In this lab, you have tried out several GraalVM Native Image features:

  1. How to generate a fast native executable from a Java command line application
  2. How to use Maven to build a native executable
  3. How to use the tracing agent to automate the process of tracking and registering reflection calls

Write efficient, more secure, and instantly scalable cloud native Java applications with GraalVM Native Image!

NOTE: There is a gradle equivalent of GraalVM Native Build Tools for Maven!

Learn More

SSR