Building native code using java
GraalVM is an exciting new VM released by Oracle that enables creation of polyglot apps. Perhaps one of the most exciting new features is the native-image
tool that compiles your bytecode into a single binary that can be executed on the target runtime without the need of a JVM.
But because it's now packaged as a single binary it means it also does not need to create a JVM in first place, and memory usage and the warmup
period associated with JVM processes are long gone.
Just to give you an idea of what we are talking about, the app we will build today had the following results (java vs native versions)
Java: 0.97s user 0.10s system 131% cpu 0.808 total
Binary: 0.01s user 0.01s system 8% cpu 0.232 total
Command Line apps the missing use case of java
Now, there are a couple of frameworks out there like Quarkus and Micronaut that provides an easy way to migrate server side apps into native ones.
That's all great, but IMHO server side has never been the issue with java, once a JVM is warmed up
and serving thousands of requests, it's very hard to beat it with the node/go versions. It's fast, it is stable, and once you hit a given threshold of users, the memory footprint is not that bad.
CLI apps on the other hand, were always a weak spot for java. No one wants to download a jar file, install (the right version) java, just to execute a few commands.
More often than not our teams need to write a CLI to integrate something, and java/kotlin have never been the choice. This is where go became king, and hopefully now with GraalVM things can change a bit.
So what are we building today?
I wanted to build a CLI to wrap a REST service. A lot of CLIs today work in this fashion: kubectl, gcloud, azure... So it seemed like a good start.
Our CLI app, which we will call f1
, will be a wrapper for the Ergast motor racing API.
Requirements, limitations, where does it hurt?
Converting a java app into a native app can be a painful experience depending on what features of the JVM you use. Specially reflection, read this section on reflection if you want to understand more about it.
I tried my best to avoid reflection based frameworks when writing this article, it gets specially painful when you have to deal with JSON and kotlin data classes.
Dependencies:
- Clikt - Kotlin based CLI parser and framework. No annotations, no reflections, less to worry when building your native app
- Fuel - Kotlin http client library, with no reflections (like retrofit2) and others.
- [Gson] - Google JSON library, I chose to write specific SerDes classes, it wasn't that bad, if you go the reflection route you may need to append classes to the
reflect-config.json
Let's build it
All the code can be found on this github repo.
Start a new gradle kotlin project. I usually go with IntelliJ IDEA's new project wizard.
I rather create a fat jar so make sure your build.gradle
looks something like this:
buildscript {
repositories {
jcenter()
}
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.github.jengelman.gradle.plugins:shadow:4.0.3'
}
}
apply plugin: 'java'
apply plugin: 'kotlin'
apply plugin: 'application'
apply plugin: "com.github.johnrengelman.shadow"
group 'io.igx.kotlin'
version '1.0-SNAPSHOT'
sourceCompatibility = 1.8
mainClassName = "io.igx.kotlin.ApplicationKt"
repositories {
mavenCentral()
jcenter()
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
implementation 'com.github.ajalt:clikt:1.7.0'
implementation 'com.github.kittinunf.fuel:fuel-gson:2.0.1'
implementation 'de.vandermeer:asciitable:0.3.2'
testCompile group: 'junit', name: 'junit', version: '4.12'
}
compileKotlin {
kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
kotlinOptions.jvmTarget = "1.8"
}
Modify your Application.kt
file to have a simple print on it:
fun main(args: Array<String>) {
println("Hello Native")
}
Important : Make sure you have the GraalVM installed, and that $GRAALVM_BIN is the folder where your install binaries are located.
Build your app ./gradlew build
and now generate the native image by invoking the following command
$GRAALVM_BIN/native-image --report-unsupported-elements-at-runtime -jar build/libs/f1-1.0-SNAPSHOT-all.jar f1 --no-server
You can check what each of those flags mean here
After the native-image tool finishes you should have an f1
binary. Just invoke it: ./f1
and you get a nice Hello Native
output.
Creating the commands
What we need now is some commands that we want to translate into REST calls. Clikt we will create a main command F1
and nest two subcommands: Drivers
and Circuits
.
Create a Commands.kt
file and add the following code:
class F1 : CliktCommand() {
override fun run() = Unit
}
class DriverCommands : CliktCommand(name = "drivers") {
val season: Int by option(help = "Season year").int().default(LocalDate.now().year)
override fun run() {
println("Fetching drivers of $season season")
}
}
class CircuitCommands : CliktCommand(name="circuits") {
val season: Int by option(help = "Season year").int().default(LocalDate.now().year)
override fun run() {
println("Fetching circuits of $season season")
}
}
Change your `Application.kt` main method to:
kotlin
fun main(args: Array) {
F1().subcommands(DriverCommands(), CircuitCommands()).main(args)
}
Build the whole thing with `gradle` and run the `native-image` command again.
Try running ./f1
now and you will be greet with some nice default help message from clikt:
Usage: f1 [OPTIONS] COMMAND [ARGS]...
Options:
-h, --help Show this message and exit
Commands:
drivers
circuits
As you can see we have two registered commands, and running help on those:
./f1 drivers --help
Usage: f1 drivers [OPTIONS]
Options:
--season INT Season year
-h, --help Show this message and exit
Ok so we now have our skeleton for our CLI app, let's just add some content to it.
Handling JSON
As I said before, reflection is a bit painful with GraalVM. So I went for a method of writing my own JsonDeserializer
for each of the domain objects of the remote API.
The ergast API has another problem, the way they represent wrapped
json is not really friendly to java parsing (they pretty much mimic their XML representation), so customization for handling those wrappers would be needed anyways.
I've created a Domain.kt
file where I put all the serialization logic:
data class Response(val series: String, val limit: Int, val offset:Int, val total:Int)
data class Driver(val driverId: String, val code: String?, val url: String? = "", val givenName: String, val familyName: String, val dateOfBirth: String?, val nationality: String?)
data class DriverResponse(val response: Response, val season: Int, val drivers: List<Driver>)
data class Location(val lat: Float?, val lon: Float?, val locality: String?, val country: String?)
data class Circuit(val circuitId: String, val url: String?, val name: String, val location: Location)
data class CircuitResponse(val response: Response, val season: Int, val circuits: List<Circuit>)
class CircuitDeserializer : JsonDeserializer<Circuit>{
override fun deserialize(element: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): Circuit {
val json = element as JsonObject
val jsonLocation = json.getAsJsonObject("Location")
val location = Location(jsonLocation.get("lat")?.asFloat, jsonLocation.get("long")?.asFloat, jsonLocation.get("locality")?.asString, jsonLocation.get("country")?.asString)
return Circuit(json.get("circuitId").asString, json.get("url")?.asString, json.get("circuitName").asString, location)
}
}
class DriverDeserializer : JsonDeserializer<Driver> {
override fun deserialize(element: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): Driver {
val json = element as JsonObject
return Driver(json.get("driverId").asString, json.get("code")?.asString, json.get("url")?.asString, json.get("givenName").asString, json.get("familyName").asString, json.get("dateOfBirth")?.asString, json.get("nationality")?.asString)
}
}
class CircuitResponseDeserializer : JsonDeserializer<CircuitResponse> {
override fun deserialize(
element: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?
): CircuitResponse {
val wrapper = element as JsonObject
val json = wrapper.getAsJsonObject("MRData")
val response = json.toResponse()
val table = json.get("CircuitTable").asJsonObject
val season = table.get("season").asInt
val circuitType = object : TypeToken<List<Circuit>>() {}.type
return CircuitResponse(response, season, context!!.deserialize(table.get("Circuits").asJsonArray, circuitType))
}
}
class DriverResponseDeserializer : JsonDeserializer<DriverResponse> {
override fun deserialize(element: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): DriverResponse {
val wrapper = element as JsonObject
val json = wrapper.getAsJsonObject("MRData")
val response = json.toResponse()
val table = json.get("DriverTable").asJsonObject
val season = table.get("season").asInt
val driversType = object : TypeToken<List<Driver>>() {}.type
return DriverResponse(response, season, context!!.deserialize(table.get("Drivers").asJsonArray, driversType))
}
}
fun JsonObject.toResponse() : Response {
return Response(this.get("series").asString, this.get("limit").asInt, this.get("offset").asInt, this.get("total").asInt)
}
And the final version of your Commands.kt
should look like this:
class DriverCommands : CliktCommand(name = "drivers") {
val season: Int by option(help = "Season year").int().default(LocalDate.now().year)
override fun run() {
val gson = GsonBuilder()
.registerTypeAdapter(DriverResponse::class.java, DriverResponseDeserializer())
.registerTypeAdapter(Driver::class.java, DriverDeserializer())
.create()
val responseString = Fuel.get("http://ergast.com/api/f1/$season/drivers.json").responseString().third.get()
val driverResponse = gson.fromJson<DriverResponse>(responseString, DriverResponse::class.java)
val table = AsciiTable()
table.addRule()
table.addRow(null, null, "$season season drivers").setTextAlignment(TextAlignment.CENTER)
table.addRule()
table.addRow("Driver name", "Date birth", "Nationality").setTextAlignment(TextAlignment.CENTER)
table.addRule()
driverResponse.drivers.forEach {driver ->
table.addRow("${driver.givenName} ${driver.familyName}", driver.dateOfBirth, driver.nationality).setTextAlignment(TextAlignment.CENTER)
table.addRule()
}
println(table.render())
}
}
class CircuitCommands : CliktCommand(name="circuits") {
val season: Int by option(help = "Season year").int().default(LocalDate.now().year)
override fun run() {
val gson = GsonBuilder()
.registerTypeAdapter(CircuitResponse::class.java, CircuitResponseDeserializer())
.registerTypeAdapter(Circuit::class.java, CircuitDeserializer())
.create()
val table = AsciiTable()
table.addRule()
table.addRow(null, null, "$season season circuits").setTextAlignment(TextAlignment.CENTER)
table.addRule()
table.addRow("Circuit", "Location", "Country").setTextAlignment(TextAlignment.CENTER)
table.addRule()
val responseString = Fuel.get("http://ergast.com/api/f1/$season/circuits.json").responseString().third.get()
val circuitResponse = gson.fromJson<CircuitResponse>(responseString, CircuitResponse::class.java)
circuitResponse.circuits.forEach {circuit ->
table.addRow(circuit.name, circuit.location.locality, circuit.location.country).setTextAlignment(TextAlignment.CENTER)
table.addRule()
}
println(table.render())
}
}
- First we prepare our
Gson
instance by registering the proper adapters - The
AsciiTable
is just a nice way to present the results - We get the response as string using
Fuel
and then map it to ourResponse
type - Print the table.
Now build the project again using gradle
but this time you need to modify the native-image
command to enable the http
protocol (disabled by default):
$GRAALVM_BIN/native-image --report-unsupported-elements-at-runtime -jar build/libs/f1-1.0-SNAPSHOT-all.jar f1 --enable-url-protocols=http --no-server
After building it try running the drivers
command:
f1 ./f1 drivers
┌──────────────────────────────────────────────────────────────────────────────┐
│ 2019 season drivers │
├──────────────────────────┬─────────────────────────┬─────────────────────────┤
│ Driver name │ Date birth │ Nationality │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Alexander Albon │ 1996-03-23 │ Thai │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Valtteri Bottas │ 1989-08-28 │ Finnish │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Pierre Gasly │ 1996-02-07 │ French │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Antonio Giovinazzi │ 1993-12-14 │ Italian │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Romain Grosjean │ 1986-04-17 │ French │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Lewis Hamilton │ 1985-01-07 │ British │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Nico Hülkenberg │ 1987-08-19 │ German │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Robert Kubica │ 1984-12-07 │ Polish │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Daniil Kvyat │ 1994-04-26 │ Russian │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Charles Leclerc │ 1997-10-16 │ Monegasque │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Kevin Magnussen │ 1992-10-05 │ Danish │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Lando Norris │ 1999-11-13 │ British │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Sergio Pérez │ 1990-01-26 │ Mexican │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Kimi Räikkönen │ 1979-10-17 │ Finnish │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Daniel Ricciardo │ 1989-07-01 │ Australian │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ George Russell │ 1998-02-15 │ British │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Carlos Sainz │ 1994-09-01 │ Spanish │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Lance Stroll │ 1998-10-29 │ Canadian │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Max Verstappen │ 1997-09-30 │ Dutch │
├──────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Sebastian Vettel │ 1987-07-03 │ German │
└──────────────────────────┴─────────────────────────┴─────────────────────────┘
Closing thoughts
I'm truly excited to see that we can now build amazing CLI tooling with Java. I hope this post got you excited too. No need to split your team, or to ask them to learn a new language to write some cool tooling for your internal apis.
Happy coding!
Top comments (3)
It is possible to register the command so instead of running
./f1 drivers
you can dof1 driver
?either add the binary to your path, or copy the binary to a directory already on your path Gilberto.
Cool! I'll give this a try :D