Coming Up for Air

A Quarkus Command Line Application

Most people know Quarkus as a great way to build fast, scalable microservices. What many may not be aware of, however, is that Quarkus can also be used to build command line applications as well. In this post, we’ll take a look at how we can leverage the Quarkus ecosystem we already know to build a command line utility quickly and easily.

The command line application we’ll build actually already exists. The GitHub user techpavan has a utility, mvn-repo-cleaner, that can be used to clean up old, unused artifacts from the local Maven repository. It’s a great utility, but it hasn’t been updated in at least a couple of years. Using this as a practical target, then, let’s see what Quarkus can offer.

Quarkus' CLI support is based on Picocli. Using Quarkus' project generation site, we can bootstrap our app:

  • Specify the groupId: com.steeplesoft

  • Specify the artifact name: mvn-repo-cleaner

  • Select the build tool: Maven

  • Search for pico and select the library

  • Select No for Starter Code

  • Click Generate your application

Since we’re migrating/porting an existing CLI utility, we already have a nice, practical command to implement, which means all we need to do is copy the existing classes in techpavan’s upstream project: ArgData, CleanM2, and FileInfo. These classes require some dependencies we haven’t declared yet, so let’s add those now:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.12.0</version>
</dependency>
<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.11.0</version>
</dependency>
<dependency>
    <groupId>org.apache.maven</groupId>
    <artifactId>maven-artifact</artifactId>
    <version>3.8.2</version>
</dependency>

The first class we need to update is CleanM2. This class will be the entry point into our utility. Since the upstream project is based on JCommander, our first step is to remove the related imports. We also need to annotate the class so Picocli knows we’re defining a new command in this class:

@CommandLine.Command
public class CleanM2 implements Runnable {
// ...

We’ve also added an implements Runnable, as Picocli requires that each command be Runnable. The next step is to modify the class' public void main, making it implement the run() method from the interface:

-    public static void main(String[] args) {
+    @Override
+    public void run() {

We now have our Picocli command, but it could use some parameters, which are defined currently in ArgData. Like CleanM2, this class heavily uses JCommander APIs. Fortunately, the APIs are pretty similar, so it’s mostly a matter of changing the imports

-    @Parameter(names = {"--path", "-p"}, description = "Path to m2 directory, if using a custom path.")
-    private String m2Path;
+    @CommandLine.Option(names = {"--path", "-p"},
+            description = "Path to m2 directory, if using a custom path.")
+    protected String m2Path;

and then updating the annotations on the parameters. You can actually update them all at once with a simple search and replace: @Parameter@CommandLine.Option.

To make these options available to the command, we have two options: copy/move each of these properties to CleanM2 or have CleanM2 extend ArgData. I chose to go with the second option to reduce the number of changes from the upstream project:

@CommandLine.Command
public class CleanM2 extends ArgData implements Runnable {

CleanM2 will still have quite a few compilation errors related to the parameter properties. I won’t go over those here, as they’re pretty simple to update (you can also "cheat" by looking at the GitHub repo for this project.) I’ve also taken the opportunity to remove all of the static modifiers. That’s more of a personal preference than a technical requirement, so feel free not to do the same if that’s your preference.

With those changes done, we’re ready to build and run this:

$ mvn clean package
...
$ ls -alh target/
total 20K
drwxrwxr-x  1 jdlee jdlee 226 Oct 10 20:44 .
drwx--x--x. 1 jdlee jdlee 244 Oct 10 20:44 ..
drwxrwxr-x  1 jdlee jdlee  72 Oct 10 20:44 classes
drwxrwxr-x  1 jdlee jdlee  22 Oct 10 20:44 generated-sources
drwxrwxr-x  1 jdlee jdlee  28 Oct 10 20:44 maven-archiver
drwxrwxr-x  1 jdlee jdlee  42 Oct 10 20:44 maven-status
-rw-rw-r--  1 jdlee jdlee 16K Oct 10 20:44 mvn-repo-cleaner-1.0.0-SNAPSHOT.jar
drwxrwxr-x  1 jdlee jdlee 112 Oct 10 20:44 quarkus-app
-rw-rw-r--  1 jdlee jdlee 117 Oct 10 20:44 quarkus-artifact.properties

As things stand out of the box, to run our command, you would do something like this:

$ java -jar target/quarkus-app/quarkus-run.jar --help
...

While that works, shipping that is not ideal. We can fix that by changing how Quarkus packages the app. The default for Quarkus is the "fast jar" format, which we see above in target/quarkus-app. What we’d like is a fat/uber jar, where our utility and all of its dependencies are in one archive. To do that with Quarkus, we need to configure the build using src/main/resources/application.properties:

quarkus.package.type=uber-jar

Now, if we rebuild:

$ mvn clean package
...
$ ls -alh target/
total 4.0M
drwxrwxr-x  1 jdlee jdlee  286 Oct 10 20:53 .
drwx--x--x. 1 jdlee jdlee  244 Oct 10 20:53 ..
drwxrwxr-x  1 jdlee jdlee   72 Oct 10 20:53 classes
drwxrwxr-x  1 jdlee jdlee   22 Oct 10 20:53 generated-sources
drwxrwxr-x  1 jdlee jdlee   28 Oct 10 20:53 maven-archiver
drwxrwxr-x  1 jdlee jdlee   42 Oct 10 20:53 maven-status
-rw-rw-r--  1 jdlee jdlee  16K Oct 10 20:53 mvn-repo-cleaner-1.0.0-SNAPSHOT.jar.original
-rw-rw-r--  1 jdlee jdlee 4.0M Oct 10 20:53 mvn-repo-cleaner-1.0.0-SNAPSHOT-runner.jar
-rw-rw-r--  1 jdlee jdlee  122 Oct 10 20:53 quarkus-artifact.properties

We now have a 4M archive, mvn-repo-cleaner-1.0.0-SNAPSHOT-runner.jar, which we can run with:

$ java -jar target/mvn-repo-cleaner-1.0.0-SNAPSHOT-runner.jar --help
...

That gives us a single file to ship around to users' machines, which is definitely an improvement. It still requires a JVM, though. While that’s not necessarily an issue for this command, which is clearly targeted at developers, your command line utility may not be. This is where Quarkus starts to shine: we’re going to build a native image, which Quarkus makes super easy, barely an inconvenience. To do so, though, we will need GraalVM installed, which I’ve done using sdkman!.

$ sdk install java 21.2.0.r16-grl
$ sdk use java 21.2.0.r16-grl
$ gu install native-image
$ mvn -Pnative package
...
[INFO] [io.quarkus.deployment.pkg.steps.NativeImageBuildRunner] /home/jdlee/.sdkman/candidates/java/21.2.0.r16-grl/bin/native-image -J-Djava.util.logging.manager=org.jboss.logmanager.LogManager -J-Duser.language=en -J-Duser.country=US -J-Dfile.encoding=UTF-8 -H:InitialCollectionPolicy=com.oracle.svm.core.genscavenge.CollectionPolicy\$BySpaceAndTime -H:+JNI -H:+AllowFoldMethods -H:FallbackThreshold=0 -H:+ReportExceptionStackTraces -H:-AddAllCharsets -H:EnableURLProtocols=http -H:NativeLinkerOption=-no-pie -H:-UseServiceLoaderFeature -H:+StackTrace -H:-ParseOnce mvn-repo-cleaner-1.0.0-SNAPSHOT-runner -jar mvn-repo-cleaner-1.0.0-SNAPSHOT-runner.jar
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]    classlist:     888.08 ms,  0.96 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]        (cap):     461.21 ms,  0.96 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]        setup:   1,674.56 ms,  0.96 GB
21:04:16,716 INFO  [org.jbo.threads] JBoss Threads version 3.4.2.Final
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]     (clinit):     266.48 ms,  3.22 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]   (typeflow):   7,152.50 ms,  3.22 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]    (objects):   9,486.04 ms,  3.22 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]   (features):     518.46 ms,  3.22 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]     analysis:  18,004.64 ms,  3.22 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]     universe:     758.33 ms,  3.22 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]      (parse):   1,882.04 ms,  3.22 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]     (inline):   2,659.86 ms,  5.29 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]    (compile):  14,088.48 ms,  5.54 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]      compile:  19,952.01 ms,  5.54 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]        image:   2,717.06 ms,  5.54 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]        write:     321.33 ms,  5.54 GB
[mvn-repo-cleaner-1.0.0-SNAPSHOT-runner:20610]      [total]:  44,515.50 ms,  5.54 GB
# Printing build artifacts to: /home/jdlee/src/personal/mvn-repo-cleaner/target/mvn-repo-cleaner-1.0.0-SNAPSHOT-native-image-source-jar/mvn-repo-cleaner-1.0.0-SNAPSHOT-runner.build_artifacts.txt
[INFO] [io.quarkus.deployment.pkg.steps.NativeImageBuildRunner] objcopy --strip-debug mvn-repo-cleaner-1.0.0-SNAPSHOT-runner
[INFO] [io.quarkus.deployment.QuarkusAugmentor] Quarkus augmentation completed in 45883ms
$ ls -alh target/
total 38M
drwxrwxr-x  1 jdlee jdlee  604 Oct 10 21:04 .
drwx--x--x. 1 jdlee jdlee  244 Oct 10 21:02 ..
drwxrwxr-x  1 jdlee jdlee   72 Oct 10 20:56 classes
drwxrwxr-x  1 jdlee jdlee   22 Oct 10 20:56 generated-sources
drwxrwxr-x  1 jdlee jdlee   28 Oct 10 20:56 maven-archiver
drwxrwxr-x  1 jdlee jdlee   42 Oct 10 20:56 maven-status
-rw-rw-r--  1 jdlee jdlee  16K Oct 10 20:59 mvn-repo-cleaner-1.0.0-SNAPSHOT.jar
-rw-rw-r--  1 jdlee jdlee  16K Oct 10 20:56 mvn-repo-cleaner-1.0.0-SNAPSHOT.jar.original
drwxrwxr-x  1 jdlee jdlee  206 Oct 10 21:04 mvn-repo-cleaner-1.0.0-SNAPSHOT-native-image-source-jar
-rwxrwxr-x  1 jdlee jdlee  34M Oct 10 21:04 mvn-repo-cleaner-1.0.0-SNAPSHOT-runner
-rw-rw-r--  1 jdlee jdlee 4.0M Oct 10 20:56 mvn-repo-cleaner-1.0.0-SNAPSHOT-runner.jar
drwxrwxr-x  1 jdlee jdlee  112 Oct 10 21:04 quarkus-app
-rw-rw-r--  1 jdlee jdlee  293 Oct 10 21:04 quarkus-artifact.properties

That gives us a 34M file, mvn-repo-cleaner-1.0.0-SNAPSHOT-runner, with an amazing startup:

$ time java -jar target/mvn-repo-cleaner-1.0.0-SNAPSHOT-runner.jar --help
...
real    0m0.787s
user    0m1.130s
sys     0m0.101s
$ time ./target/mvn-repo-cleaner-1.0.0-SNAPSHOT-runner --help
...
real    0m0.034s
user    0m0.020s
sys     0m0.015s

From 787 thousandths of a second to 34 thousandths of a second. That’s pretty impressive!

With that, we have a complete command line utility based on Quarkus (with a big tip o' the hat to techpavan for doing the hard work of making the original utility). Hopefully, this will serve as a nice example to follow if and when you write your own utility.

You can find the complete source here. Use it in good health. :)

tags: Java Quarkus CLI

Quotes

Sample quote

Quote source