Writing CLIs with Spring Boot and JCommander
Wednesday, July 15, 2020 |I was recently asked to convert a Spring Boot-based "CLI" to a real CLI utility. It was actually just a normal Spring Boot application with REST endpoints that we’d hit with curl. Pretty ugly. After a few frustrating hours, I finally settled on a solution that seems to work pretty well for us. It uses Spring Boot, of course, as that’s our library of choice, plus JCommander for the argument handling. This is a pared-down example of how the application is structured. And because I care about of each you deeply, I’ll present it in Java AND Kotlin. :)
For those of you in a hurry, you can get the complete code in my GitHub repo. Everyone else, feel free to read along.
Setting up Maven
The first step will be setting up your Maven POM (If you’re using Gradle, I’m sorry. I’m already doing two languages. You can figure that part out on your own. :). We can start with this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.1.RELEASE</version>
</parent>
<properties>
<maven.compiler.target>11</maven.compiler.target>
<maven.compiler.source>11</maven.compiler.source>
</properties>
<groupId>com.steeplesoft.spring-cli-app</groupId>
<artifactId>spring-cli-app-master</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>spring-cli-app-java</module>
<module>spring-cli-app-kotlin</module>
</modules>
<dependencies>
<dependency>
<groupId>com.beust</groupId>
<artifactId>jcommander</artifactId>
<version>1.78</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
</plugin>
</plugins>
</build>
</project>
While the project I was modifying had a lot of existing Spring beans, including some JPA entities and repositories, this
simple project does not, and I didn’t feel the added complexity helped any, so I went with simple. To bring in the Spring
dependencies, then, I declared a dependency on org.springframework.boot:spring-boot-starter
. If you are using, say,
JPA, then feel free use org.springframework.boot:spring-boot-starter-data-jpa
or whatever else may be appropriate.
Note also the declaration of a parent. It does a lot of work for you, so don’t skip it. :)
For Java, there’s no additional POM work required. In my multi-module setup, the POM is very basic and mostly just refers
to the POM above as its parent. The Kotlin POM is a bit more involved, but it’s just configuring the kotlin-maven-plugin
.
If you need help with that, check out the official docs.
With Maven configured, we’re ready to start writing our commands. Let’s start with writing the application’s entry point.
Spring Boot, while most people probably think of it solely as a REST microservice framework, does actually come with
built-in support for command line utilities via the CommandLineRunner
interface. Our @SpringBootApplication
starts
out looking like this:
1
2
3
4
5
6
7
8
9
10
@SpringBootApplication
public class SpringBootCliApplication implements CommandLineRunner {
@Override
public void run(String... args) {
}
public static void main(String[] args) {
SpringApplication.run(SpringBootCliApplication.class, args);
}
}
1
2
3
4
5
6
7
8
9
@SpringBootApplication
class SpringBootCliApplication(val commands: List<Command>) : CommandLineRunner {
override fun run(vararg args: String?) {
}
}
fun main(args: Array<String>) {
runApplication<SpringBootCliApplication>(*args)
}
You can compile and run that now, but it’s going to be awfully boring.
Adding commands
When we start defining commands, which we’re going to do right now, we’re immediately hit with two concerns:
-
How do we define them? and
-
How do we find them?
Defining Commands
JCommander lets us define commands using simple classes, so we’ll create a very simple command, ExampleCommand
, that
takes one parameter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Parameters(commandNames = ExampleCommand.COMMAND_NAME,
commandDescription = "Example command")
public class ExampleCommand implements Command {
public static final String COMMAND_NAME = "example";
@Parameter(names = "--example", description = "Example parameter")
private String example;
@Override
public String commandName() {
return COMMAND_NAME;
}
@Override
public void run() {
System.out.println("You ran the command " + COMMAND_NAME + " with the parameter --example set to " + example);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Parameters(commandNames = [ExampleCommand.COMMAND_NAME], commandDescription = "Example command")
class ExampleCommand : Command {
@Parameter(names = ["--example"], description = "Example parameter")
private var example: String? = null
override fun commandName(): String {
return COMMAND_NAME
}
override fun run() {
println("You ran the command $COMMAND_NAME with the parameter --example set to $example")
}
companion object {
const val COMMAND_NAME = "example"
}
}
You’ll see a bit of extra ceremony in this (the public static final String
) than is strictly necessary, but you will
see why in a moment. The first thing of important to note is the @Parameters
annotation on the class. I’m not a JCommander
expert, but I get the sense that the reason we’re using that annotation rather than, say, the not-real @Command
annotation
is that we’re technically building one "command", and just defining here a sub-command, or a parameter, if you will, that
refines what actions an invocation will perform. Total guess there, but that’s certainly the annotation
you need.
At any rate, inside the class, we define an actual parameter we want to support, --example
. It’s an optional String
.
You can define as many options as you want, and JCommander has very robust support for just about anything you would want
to do, it seems.
Finally, we have a run
method (or function for all you Kotlin folks!) that does the real work. That’s not a JCommander
requirement, but is something I built into the solution I’m showing here. Before we take a look at that, let’s find out
how to find the commands. We do that by leaning on Spring.
Finding Commands
Since we’re suing Spring, we’re going to let Spring do as much of the work as we can. This is especially helpful if you’re
injecting repositories or other Spring beans. The integration is very natural: we simply annotate the class with
@Component
:
1
2
3
4
5
6
@Component
@Parameters(commandNames = ExampleCommand.COMMAND_NAME,
commandDescription = "Example command")
public class ExampleCommand {
// ...
}
1
2
3
4
5
@Component
@Parameters(commandNames = [ExampleCommand.COMMAND_NAME], commandDescription = "Example command")
class ExampleCommand : Command {
// ...
}
When the Spring ApplicationContext
starts up, our command is found and registered in Spring’s metadata. All we have to
do now is ask for it:
1
2
3
4
5
6
7
8
9
10
11
@SpringBootApplication
public class SpringBootCliApplication implements CommandLineRunner {
@Autowired
private List<Command> commands;
@Override
public void run(String... args) {
// ...
}
// ...
}
1
2
3
4
5
6
@SpringBootApplication
class SpringBootCliApplication(val commands: List<Command>) : CommandLineRunner {
override fun run(vararg args: String?) {
// ...
}
}
When our Application
starts, Spring injects a list of any Command
objects it finds. But what is that?
1
2
3
4
public interface Command {
String commandName();
void run();
}
1
2
3
4
interface Command {
fun commandName() : String
fun run()
}
It’s a very simple interface
that provides a way to find out what it represents, and then to do the work. Armed with
that, we can now build our JCommander objects:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public void run(String... args) {
JCommander.Builder builder = JCommander.newBuilder() // 1
.programName("spring-boot-cli");
commands.forEach((c) -> builder.addCommand(c)); // 2
JCommander jc = builder.build();
jc.parse(args); // 3
Optional<Command> command = commands.stream() // 4
.filter(c -> c.commandName().equals(jc.getParsedCommand()))
.findFirst();
if (command.isPresent()) { // 5
command.get().run();
} else {
jc.usage(); // 6
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
override fun run(vararg args: String?) {
val builder = JCommander.newBuilder() // 1
.programName("spring-boot-cli")
commands.forEach { // 2
builder.addCommand(it.commandName(), it)
}
val jc = builder.build();
jc.parse(*args) // 3
val command = commands // 4
.firstOrNull { it.commandName() == jc.parsedCommand }
if (command != null) { // 5
command.run()
} else {
jc.usage() // 6
}
}
This isn’t terribly complex, but let’s step through it:
-
We create a
JCommander.Builder
instance, and start by giving our command a name,spring-boot-cli
. -
We iterate through the injected list of
Command
instances, callingbuilder.addCommand()
to register it with JCommander. -
Once we’ve finished configuring and building our JCommander instance, we need to parse the command line arguments
-
Now we need to find the command the user requested. We do that by iterating over our list of commands again, comparing
Command.commandName()
with the value returned byjc.getParsedCommand()
. We’ll either get aCommand
instance, or an emptyOptional
-
If we have found a
Command
, we call itsrun
method/function. JCommander takes care of injecting the command line options/parameters that have been defined, so by the time control entersrun()
, we’re ready to do our work. -
On the other hand, if no
Command
is found, we ask JCommander to print a usage message, which it generates for us using the@Parameter
and@Parameters
annotations.
Running the commands
We should be ready to build and run these now:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ mvn install
...
$ java -jar spring-cli-app-java/target/spring-cli-app-java-1.0-SNAPSHOT.jar
Usage: spring-boot-cli [command] [command options]
Commands:
example Example command
Usage: example [options]
Options:
--example
Example parameter
$ java -jar spring-cli-app-kotlin/target/spring-cli-app-kotlin-1.0-SNAPSHOT.jar
Usage: spring-boot-cli [command] [command options]
Commands:
example Example command
Usage: example [options]
Options:
--example
Example parameter
They look remarkable similar, don’t they? :)
Here’s an example with setting a parameter:
1
2
$ java -jar spring-cli-app-kotlin/target/spring-cli-app-kotlin-1.0-SNAPSHOT.jar example --example 'This is a Spring Boot cli!'
You ran the command example with the parameter --example set to This is a Spring Boot cli!
Adding more commands
Remember how I kinda made a big deal about finding commands and injecting lists? With this setup, it’s super easy. Barely an inconvience:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
@Parameters(commandNames = Example2Command.COMMAND_NAME,
commandDescription = "Example command #2")
public class Example2Command implements Command {
public static final String COMMAND_NAME = "something-else";
@Override
public String commandName() {
return COMMAND_NAME;
}
@Override
public void run() {
System.out.println("You ran something else!");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
@Parameters(commandNames = [Example2Command.COMMAND_NAME], commandDescription = "Example command #2")
class Example2Command : Command {
override fun commandName(): String {
return COMMAND_NAME
}
override fun run() {
println("You ran something else!")
}
companion object {
const val COMMAND_NAME = "something-else"
}
}
Making only that change, if we repackage our utility, and rerun the usage request, we get:
1
2
3
4
5
6
7
8
9
10
11
$java -jar spring-cli-app-java/target/spring-cli-app-java-1.0-SNAPSHOT.jar
Usage: spring-boot-cli [command] [command options]
Commands:
example Example command
Usage: example [options]
Options:
--example
Example parameter
something-else Example command #2
Usage: something-else
The Kotlin version looks exactly the same. Trust me. :)
One final note. Spring Boot can be pretty chatty in the logs/console, so I add this to my application.properties
:
1
2
spring.main.banner-mode=off
logging.level.root=ERROR
Voila!
That’s it. Any real CLI utility will obviously do more, but that should get you the plumbing you need. Just @Autowire
any Spring Beans you need, and you’re off to the races!