DEV Community

Vinicius Carvalho
Vinicius Carvalho

Posted on

Building a native CLI with Kotlin and GraalVM

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

Enter fullscreen mode Exit fullscreen mode

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"
}

Enter fullscreen mode Exit fullscreen mode

Modify your Application.kt file to have a simple print on it:

fun main(args: Array<String>) {
    println("Hello Native")
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:


Enter fullscreen mode Exit fullscreen mode


kotlin
fun main(args: Array) {
F1().subcommands(DriverCommands(), CircuitCommands()).main(args)
}


Build the whole thing with `gradle` and run the `native-image` command again.


Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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())
    }

}

Enter fullscreen mode Exit fullscreen mode
  1. First we prepare our Gson instance by registering the proper adapters
  2. The AsciiTable is just a nice way to present the results
  3. We get the response as string using Fuel and then map it to our Response type
  4. 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
Enter fullscreen mode Exit fullscreen mode

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          │
└──────────────────────────┴─────────────────────────┴─────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
redeemefy profile image
redeemefy

It is possible to register the command so instead of running ./f1 drivers you can do f1 driver?

Collapse
 
jangroot profile image
JanGroot

either add the binary to your path, or copy the binary to a directory already on your path Gilberto.

Collapse
 
jangroot profile image
JanGroot

Cool! I'll give this a try :D