Native CLI with Picocli and GraalVM
jbebar
Posted on August 20, 2020
Situation
Let's imagine a CLI which takes as parameters option a client name and selects a random beer for this person, example:
./beer ---name "Jean-Michel"
Let's see what the barman found....
Hey Jean-Michel, you should try: Strong IPA
The CLI is written in Kotlin and uses Picocli to generate a standard help menu and to parse command line options. I will not detail Picocli in this post but the build process of an executable.
Why Graal VM?
If we run this CLI using a jar packaging and java command we have two downsides:
- startup time of the JVM, users will not want to wait to order their beer.
- ask the user to install a JVM in the right version
- the command to launch the CLI will look like
java -jar name-of-the-cli.jar
whereas we would expect a simple command like thisname-of-the-cli
.
That's where GraalVM can save us here, it can compile directly java byte code down to binaries for a particular machine (Linux in our example):
- No more JVM to install for our users
- Almost no startup time
- A simple command, as we just have to call the executable file from command line.
GraalVM is a polyglot VM to run many languages (Java, Kotlin among many others) in an optimized way. GraalVM can also be used as a JDK if you develop in Java/Kotlin to compile to Java bytecode.
It comes with the native-image
tool can be installed on top of GraalVM to build natives executables from java bytecode.
For this purpose, GraalVM needs to anticipate any runtime reflections operations done, for instance, by the Picocli library. This special compilation is called ahead of time. Every annotation used at runtime in our code will need to come with it's configuration file. These configurations files can be done manually, but usually frameworks provides tools to generate them. Fortunately, that's the case for Picocli.
Adapt our configuration to generate GraalVM configuration files:
Our only source file is the following, we won't need to adapt it but let's keep it here for our understanding:
package org.jbebar.beer.picocli
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Option
import java.util.concurrent.Callable
@Command(
name = "beer",
mixinStandardHelpOptions = true,
version = ["beer 0.0.1"],
description = ["Chooses a random beer for you."]
)
internal class BeerCallable : Callable<Int> {
@Option(names = ["-n", "--name"], required = false, description = ["Your name."])
var name: String = "John Doe"
@Throws(Exception::class)
override fun call(): Int {
val beers = listOf("Black IPA Beer", "Light Blond", "Strong IPA", "Red Beer", "White Beer")
println("Let's see what the barman found....")
println("Hey $name, you should try: ${beers.shuffled().first()} !")
return 0
}
companion object {
@JvmStatic
fun main(args: Array<String>) {
val exitCode: Int = CommandLine(BeerCallable()).execute(*args)
System.exit(exitCode)
}
}
}
Our initial build.gradle looks like this:
plugins {
kotlin("jvm") version "1.3.72"
}
group = "org.jbebar"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
implementation(kotlin("stdlib-jdk8"))
implementation("info.picocli:picocli:4.5.0")
}
tasks {
compileKotlin {
kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
kotlinOptions.jvmTarget = "1.8"
}
}
To use the configuration files generators for picocli library we need to add these lines to our build.gradle.kts :
-
plugins { kotlin("kapt") version "1.3.72" }
: this will enable annotation processing for kotlin. -
dependencies { kapt("info.picocli:picocli-codegen:4.5.0") }
: Using annotation processing from kotlin during java compilation, it will scan for picocli annotations and generate the corresponding configuration files for graalVM. -
kapt { arguments { arg("project", "${project.group}/${project.name}") } }
: this will give the jar a unique name and prevent shading from other jars in the case of a uber jar.
After adapting our build.gradle and running gradle clean build
we can see the following structure in the generated jar:
If we have a look closer, only one configuration file is filled, the reflect-config.json. It refers to the runtimes annotations used by our code and picocli dependency: the field annotated in their class are detailed in the config.
[
{
"name" : "org.jbebar.beer.picocli.BeerCallable",
"allDeclaredConstructors" : true,
"allPublicConstructors" : true,
"allDeclaredMethods" : true,
"allPublicMethods" : true,
"fields" : [
{ "name" : "name" }
]
},
{
"name" : "picocli.CommandLine$AutoHelpMixin",
"allDeclaredConstructors" : true,
"allPublicConstructors" : true,
"allDeclaredMethods" : true,
"allPublicMethods" : true,
"fields" : [
{ "name" : "helpRequested" },
{ "name" : "versionRequested" }
]
}
]
If we check the rentention policy of these annotations, we see that @Option
has RUNTIME
retention on our field name
of class BeerCallable.
Same for the AutoHelpMixin class.
Install GraalVM
To install GraalVM, I let you refer to the documentation for your platform. GraalVM needs C/C++ compilation tools to build the native-images.
After installing GraalVM you can run:
gu install native-image
Compile to native code
Before compiling to native code, it is good to check our application is running using java
command, gathering all the dependencies and the source jar in the same folder and use the --classpath
or -cp
option like this:
java -cp annotations-13.0.jar:kotlin-stdlib-1.3.72.jar:kotlin-stdlib-common-1.3.72.jar:kotlin-stdlib-jdk7-1.3.72.jar:kotlin-stdlib-jdk8-1.3.72.jar:picocli-4.5.0.jar:beer-picocli-1.0-SNAPSHOT.jar org.jbebar.beer.picocli.BeerCallable
Be careful about putting the source file containing the Main class as last dependency in the list passed to -cp
. The only argument to pass on top of the option is the name of the class containing the main method.
This will give the following output:
Let's see what the barman found....
Hey John Doe, you should try: Red Beer !
Now, to compile we can stay in the same folder containing our dependencies and CLI jar and run:
native-image -cp annotations-13.0.jar:kotlin-stdlib-1.3.72.jar:kotlin-stdlib-common-1.3.72.jar:kotlin-stdlib-jdk7-1.3.72.jar:kotlin-stdlib-jdk8-1.3.72.jar:picocli-4.5.0.jar:beer-picocli-1.0-SNAPSHOT.jar -H:Name=beer org.jbebar.beer.picocli.BeerCallable
where -H:Name=beer
is the name of the generated executable.
Execution
./beer -h
Usage: beer [-hV] [-n=<name>]
Chooses a random beer for you.
-h, --help Show this help message and exit.
-n, --name=<name> Your name.
-V, --version Print version information and exit.
./beer -n "Jean-Michel"
Let's see what the barman found....
Hey Jean-Michel, you should try: Strong IPA !
We can also compare the execution times :
time java -cp annotations-13.0.jar:kotlin-stdlib-1.3.72.jar:kotlin-stdlib-common-1.3.72.jar:kotlin-stdlib-jdk7-1.3.72.jar:kotlin-stdlib-jdk8-1.3.72.jar:picocli-4.5.0.jar:beer-picocli-1.0-SNAPSHOT.jar org.jbebar.beer.picocli.BeerCallable
Let's see what the barman found....
Hey John Doe, you should try: Black IPA Beer !
real 0m0.316s
user 0m0.473s
sys 0m0.060s
With graal VM we can see that the real time is nearly 8 times faster.
time ./beer
Let's see what the barman found....
Hey John Doe, you should try: Black IPA Beer !
real 0m0.044s
user 0m0.006s
sys 0m0.005s
Don't take this "8 times" for granted, of course, it will depend on the type of program you run. But this gives a good idea of how fast it is. The user will not see the difference between this or any system command run from terminal, and that's kind of magical.
To see even more the difference, it could be interesting to run this with programs manipulating larger sets of data.
I hope now our client will not wait to know which beer to drink and take it right out of the fridge.
More seriously, would you now try GraalVM and Picocli combination? Or do you have any other way of writing your CLI apps? Feel free to comment :) and see you soon.
Posted on August 20, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.