CLI applications with Micronaut and Picocli

Olivier Revial - Jun 1 '20 - - Dev Community

In a previous article we introduced Micronaut framework with a simple hello-world application. Today we will see how we can easily create powerful CLI applications using Micronaut and Picocli. In a future article we will see how we can compile our app down to a native binary using Micronaut and GraalVM !

A great use case for Micronaut is to use it with the wonderful Picocli library to build very simple yet very powerful Command Line Interface (CLI) applications. Picocli๏ธ โค๏ธ is an awesome library that allows creating rich command line applications for the JVM, and we can use Micronaut to enrich our application with auto-generated web clients, auto-configuration and such.

So what exactly are we talking about here ? ๐Ÿค”

The exercice today will be to develop a simple weather CLI application that will connect to a distant weather API to give us current weather and forecast for a given location.

We will use Picocli๏ธ features to easily respond to CLI commands and we will use Micronaut to interact with the distant weather API, with Micronaut http-client library. Of course for this simple example it could make more sense to use Java 11 native HTTP client but for the sake of this exercise, let's stick with Micronaut ๐Ÿš€. It will allow us to see how Micronaut works in this context (and of course it could be extended with richer features such as auto-configuration) and this simple CLI application will also be a good playground for the native compilation coming in the next article ๐Ÿ˜‰

In this exercice we will use Weatherbit.io API to add two commands to our CLI application:

  • weather command will return the current weather for a given location (city and country)
  • forecast command will return a forecast for the nehugxt 1 to 16 days for a given location (city and country)

๐Ÿ‘ฉโ€๐Ÿ’ป๐Ÿ‘จโ€๐Ÿ’ป Code sample for this article can be found in this Github repository.

Generate a CLI Micronaut application

As Micronaut applications are quite simple we could create ours from scratch, but as explained in our Micronaut 101 article we will instead use Micronaut mn command to generate our app skeleton, mostly to pre-configure our build with Picocli dependency.

โžก๏ธ Run the following command to generate the application:

> mn create-cli-app weather-cli --features http-client
Enter fullscreen mode Exit fullscreen mode

โ„น๏ธ We told Micronaut to create a CLI application, thus we avoid the use of an HTTP server (useless in our case).

โžก๏ธ Let's now have a look at generated build file build.gradle:

dependencies {
    annotationProcessor platform("io.micronaut:micronaut-bom:$micronautVersion")
    annotationProcessor "io.micronaut.configuration:micronaut-picocli"
    annotationProcessor "io.micronaut:micronaut-inject-java"
    annotationProcessor "io.micronaut:micronaut-validation"
    implementation platform("io.micronaut:micronaut-bom:$micronautVersion")
    implementation "io.micronaut:micronaut-runtime"
    implementation "info.picocli:picocli"
    implementation "io.micronaut.configuration:micronaut-picocli"
    implementation "io.micronaut:micronaut-inject"
    implementation "io.micronaut:micronaut-validation"
    implementation "io.micronaut:micronaut-http-client"
    runtimeOnly "ch.qos.logback:logback-classic:1.2.3"
    testAnnotationProcessor platform("io.micronaut:micronaut-bom:$micronautVersion")
    testAnnotationProcessor "io.micronaut:micronaut-inject-java"
    testImplementation platform("io.micronaut:micronaut-bom:$micronautVersion")
    testImplementation "org.junit.jupiter:junit-jupiter-api"
    testImplementation "io.micronaut.test:micronaut-test-junit5"
    testImplementation "io.micronaut:micronaut-inject-java"
    testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine"
}
Enter fullscreen mode Exit fullscreen mode

Amongst other things we can see that Micronaut added a few dependencies, including Micronaut core dependencies (java-inject, validation, runtime, etc.) along with Picocli dependency.

Add Picocli commands and Micronaut HTTP client

โžก๏ธ First let's look at WeatherCliCommand, the default main class that was generated for us:

@Command(name = "weather-cli", description = "...",
        mixinStandardHelpOptions = true)
public class WeatherCliCommand implements Runnable {

    @Option(names = {"-v", "--verbose"}, description = "...")
    boolean verbose;

    public static void main(String[] args) throws Exception {
        PicocliRunner.run(WeatherCliCommand.class, args);
    }

    public void run() {
        // business logic here
        if (verbose) {
            System.out.println("Hi!");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If you run the application now you should see a very basic CLI application that works but doesn't do much (except printing "Hi!" if called with the -v option). We will now add the weather and forecast subcommands to add behavior.

Working with Picocli commands

As both subcommands will need to take similar city and country inputs, let's factorize this.

โžก๏ธ Create a LocalizedCommand class that simply declares a city and a country, and validates these inputs:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import picocli.CommandLine;
import picocli.CommandLine.Option;
import picocli.CommandLine.Spec;

public abstract class LocalizedCommand implements Runnable {
    private static final Logger LOG = LoggerFactory.getLogger(ForecastSubcommand.class);

    @Spec
    protected CommandLine.Model.CommandSpec spec;

    private static final String DEFAULT_CITY = "paris";
    private static final String DEFAULT_COUNTRY_CODE = "fr";

    protected String city;
    protected String country;

    @Option(names = {"--country"},
        description = "the 2-letters country code",
        defaultValue = DEFAULT_COUNTRY_CODE,
        showDefaultValue = ALWAYS)
    public void setCountry(String country) {
        if (country.length() >= 2) {
            this.country = country.substring(0, 2).toLowerCase();
        } else {
            throw new CommandLine.ParameterException(
                spec.commandLine(),
                "Country parameter must be a 2-letters code");
        }
    }

    @Option(names = {"--city"},
        description = "the city name",
        defaultValue = DEFAULT_CITY,
        showDefaultValue = ALWAYS)
    public void setCity(String city) {
        this.city = city.toLowerCase();
    }

    @Override
    public void run() {
        LOG.info("Asking forecast for city {} and country {}", city, country);
        if (DEFAULT_CITY.equals(city)) {
            LOG.warn("Using default city {}...", city);
        }
        if (DEFAULT_COUNTRY_CODE.equals(country)) {
            LOG.warn("Using default country {}...", country);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Perfect, we now have the base parameters ready. Time to create real subcommands !

โžก๏ธ First, let's create the weather subcommand that simply returns the result of the Weather API call:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import picocli.CommandLine.Command;
import weather.cli.api.WeatherAPIClient;
import weather.cli.api.WeatherResponse;

@Command(name = "weather", description = "gives weather for a given location")
public class CurrentWeatherSubcommand extends LocalizedCommand {
    private static final Logger LOG = LoggerFactory.getLogger(CurrentWeatherSubcommand.class);

    @Inject
    private WeatherAPIClient weatherAPIClient;

    @Override
    public void run() {
        super.run();

        WeatherResponse weather = weatherAPIClient.weather(city, country);
        LOG.info("=====================================");
        LOG.info("Current weather in {}: \n{}", city, weather.getData().get(0).toString());
        LOG.info("=====================================");
    }
}
Enter fullscreen mode Exit fullscreen mode

Of course this will not compile as we don't have the API client interface yet, nor the models. You can create the interface now if you want but we will see this interface in a minute. API models can be found in source repository.

โžก๏ธ We can now create the forecast command:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import weather.cli.api.ForecastObservation;
import weather.cli.api.ForecastResponse;
import weather.cli.api.WeatherAPIClient;

import javax.inject.Inject;

import static java.util.Comparator.comparing;
import static picocli.CommandLine.Help.Visibility.ALWAYS;

@Command(name = "forecast", description = "gives forecast for given city")
public class ForecastSubcommand extends LocalizedCommand {
    private static final Logger LOG = LoggerFactory.getLogger(ForecastSubcommand.class);

    @Inject
    private WeatherAPIClient weatherAPIClient;

    private int nbDays;

    @Option(names = {"-d", "--days"},
        description = "the number of forecast days to fetch (between 1 and 16)",
        defaultValue = "1",
        showDefaultValue = ALWAYS)
    public void setNbDays(int nbDays) {
        if (nbDays < 1 || nbDays > 16) {
            throw new CommandLine.ParameterException(
                spec.commandLine(),
                "Forecast must be between 1 and 16 days");
        }
        this.nbDays = nbDays;
    }

    @Override
    public void run() {
        super.run();

        ForecastResponse forecast = weatherAPIClient.forecast(city, country, nbDays);
        LOG.info("=====================================");
        forecast.getData().stream()
            .sorted(comparing(ForecastObservation::getForecastDate))
            .forEach(forecastObservation -> LOG.info("Forecast in {} on day {}: \n{}",
                city, forecastObservation.getForecastDate(),
                forecastObservation.toString()));
        LOG.info("=====================================");
    }
}
Enter fullscreen mode Exit fullscreen mode

This class is similar to the weather subcommand except that it takes (and validates) an extra --days parameter that corresponds to the number of days we want the forecast for, and loop on the API response to display forecast of each coming day.

โžก๏ธ Great, we now have our two subcommands, let's add them to the "main" command, we can now open WeatherCliCommand:

import io.micronaut.configuration.picocli.PicocliRunner;
import picocli.CommandLine.Command;
import weather.cli.commands.CurrentWeatherSubcommand;
import weather.cli.commands.ForecastSubcommand;

@Command(subcommands = {
    CurrentWeatherSubcommand.class,
    ForecastSubcommand.class
})
public class WeatherCliCommand implements Runnable {

    public static void main(String[] args) throws Exception {
        PicocliRunner.run(WeatherCliCommand.class, args);
    }

    public void run() {
        System.out.println("Welcome to weather app...");
    }
}
Enter fullscreen mode Exit fullscreen mode

Working with Micronaut HTTP Client

Creating an HTTP client with Micronaut is dead-simple. Before creating it, let's look at Weatherbit API endpoints:

  • Current weather endpoint:
    • Requires an API key
    • Takes a city and a country to localize the request
  • 16 days forecast endpoint:
    • Requires an API key
    • Takes a city and a country to localize the request
    • Takes an optional days parameter to specify the number of days to include in the forecast (defaults to 16)

โžก๏ธ Alright, time to create the WeatherAPIClient:

import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.QueryValue;
import io.micronaut.http.client.annotation.Client;

@Client("${weather.api.url}")
public interface WeatherAPIClient {
    @Get("/current?key=${weather.api.key}")
    WeatherResponse weather(@QueryValue String city,
                   @QueryValue String country);

    @Get("/forecast/daily?key=${weather.api.key}")
    ForecastResponse forecast(@QueryValue String city,
                    @QueryValue String country,
                    @QueryValue(defaultValue = "1") int days);
}
Enter fullscreen mode Exit fullscreen mode

What can we see here ?

  • We just need an interface. No need to write a client implementation, Micronaut will inject an implementation for us when needed.
  • @Client annotation declares a Micronaut HTTP client, and we pass it a configuration key named weather.api.url as the server base URL. Micronaut will resolve this configuration at runtime.
  • @Get annotation declares a HTTP Get endpoint and we tell it to add a query parameter from the configuration key weather.api.key. We pass it by configuration to avoid putting our API key directly in the code (so you don't steal my API key ๐Ÿ”’๐Ÿ‘ฎ)
  • Both methods declare @QueryValue parameters that will be added to the URL by Micronaut at runtime.
  • Both methods returns a simple POJO response that will be automatically converted from the API json response by Micronaut with the help of Jackson ObjectMapper.

As said above, we added a query parameter named weather.api.key that we want to be filled in our configuration.

โžก๏ธ Let's open the application.yml file in main/resources and modify it so it matches the following content:

micronaut:
  application:
    name: weather-cli

weather:
  api:
    url: https://api.weatherbit.io/v2.0/
    key: invalid-api-key
Enter fullscreen mode Exit fullscreen mode

But right now you might think that I'm crazy to put my configuration key in a configuration file that is going to be versioned in my public Github repository... well, I won't put my real key. To avoid this we could tell Micronaut that this key uses an environment variable by defining our property as such :

weather.api.key: ${WEATHER_API_KEY:invalid-api-key}
Enter fullscreen mode Exit fullscreen mode

In fact we don't need this extra definition as Micronaut uses "Property Value Binding", which means that WEATHER_API_KEY environment variable value will automatically be assigned and override our property key weather.api.key.

Running our application

โžก๏ธ Alright, time to run our application and generate a uber-jar:

> ./gradlew assemble
Enter fullscreen mode Exit fullscreen mode

โžก๏ธ We can now run it and ask for current weather in Montreal:

# First export WEATHER_API_KEY (replace with your own key)
> export WEATHER_API_KEY=WEATHER-BIT-IO-GENERATED-API-KEY

# Run the app and ask for Montreal weather
> java -jar build/libs/weather-cli-0.1-all.jar weather --country CA --city montreal

Established active environments: [cli]
Asking weather for city montreal and country ca
=====================================
Current weather in montreal:
  - temperature: 8.3ยฐC
  - wind speed: 3.6 km/h
  - cloud coverage: 100.0%
  - precipitation: 6.0 mm/h
=====================================
Enter fullscreen mode Exit fullscreen mode

โžก๏ธ Let's now ask for Paris 3-days forecast:

# Ask for Paris 3-days forecast
> java -jar build/libs/weather-cli-0.1-all.jar forecast --city Paris --days 3

Established active environments: [cli]
Asking forecast for city paris and country fr
Using default city paris...
Using default country fr...
=====================================
Forecast in paris on day 2020-04-30:
  - average temperature: 13.4ยฐC
  - min temperature: 11.6ยฐC
  - max temperature: 14.9ยฐC
  - wind speed: 17.962884000000003 km/h
  - probability of precipitation: 75.0%
  - average cloud coverage: 86.0%
Forecast in paris on day 2020-05-01:
  - average temperature: 13.1ยฐC
  - min temperature: 10.0ยฐC
  - max temperature: 17.7ยฐC
  - wind speed: 13.19382 km/h
  - probability of precipitation: 75.0%
  - average cloud coverage: 74.0%
Forecast in paris on day 2020-05-02:
  - average temperature: 12.5ยฐC
  - min temperature: 9.1ยฐC
  - max temperature: 16.9ยฐC
  - wind speed: 10.229436 km/h
  - probability of precipitation: 0.0%
  - average cloud coverage: 60.0%
=====================================
Enter fullscreen mode Exit fullscreen mode

Great, everything works as expected ๐Ÿ˜Š

๐Ÿ‘ Congrats, you have now successfully created a CLI application using Micronaut and Picocli !

A note on performance...

Let's time the execution time of the help command (we don't time our subcommands because they use network time to talk to the API and this would make any comparison flawed) to have a better idea of the startup time:

> time "java -jar build/libs/weather-cli-0.1-all.jar --help"

Benchmark #1: java -jar build/libs/weather-cli-0.1-all.jar --help
  Time (mean ยฑ ฯƒ):      2.279 s ยฑ  0.172 s    [User: 2.764 s, System: 0.216 s]
  Range (min โ€ฆ max):    2.075 s โ€ฆ  2.732 s    10 runs
Enter fullscreen mode Exit fullscreen mode

โ„น๏ธ I used hyperfine here instead of time to run a benchmark on the help command

We have a mean answer time of approximately 2 seconds. Although it can be satisfying for a JVM app, it's not great for a CLI application.

So how do we improve that ?

spoiler alert

The answer: GraalVM native compilation !

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .