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
โน๏ธ 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"
}
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!");
}
}
}
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);
}
}
}
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("=====================================");
}
}
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("=====================================");
}
}
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...");
}
}
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 acountry
to localize the request
-
16 days forecast endpoint:
- Requires an API key
- Takes a
city
and acountry
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);
}
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 namedweather.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 keyweather.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
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}
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
โก๏ธ 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
=====================================
โก๏ธ 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%
=====================================
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
โน๏ธ 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 ?
The answer: GraalVM native compilation !