Are you searching for a Command Line Interface (CLI) for your Spring Boot application because you do not need a fancy web interface? Spring Shell might be the answer to your question. In this blog, you will learn the basics of creating such a CLI. Enjoy!
1. Introduction
Not every application needs a fancy responsive web GUI. Sometimes, a basic terminal interface suffices. Spring offers Spring Shell for this purpose. Just like all Spring projects, Spring Shell relieves you from the plumbing code in order that you can focus on the actual logic you need to implement. Some Built-In commands like help, clear, exit, etc. are available out-of-the-box, as is tab completion. You can create commands with mandatory and optional arguments and you are able to add validation of the arguments just like you would do with a regular Spring Boot application. On top of that, you can create an executable using GraalVM!
At the moment of writing, two annotation models are supported. The legacy annotation model (relates to @ShellMethod and @ShellOption) and the new annotation model. In this blog, the new annotation model will be used. Do note that a lot of information on the web is using the legacy annotation model.
In case you need more inspiration or examples, you can check out the Spring Shell git repository.
Sources used in this blog are available at GitHub.
2. Prerequisites
Prerequisites for reading this blog are:
- Basic Java knowledge;
- Basic Maven knowledge;
- Basic Spring Boot knowledge.
3. My First Shell App
Navigate to the Spring Initializr and add the Spring Shell dependency. Take a look at the pom and you will see that the following dependencies are added.
<dependencies>
<dependency>
<groupId>org.springframework.shell</groupId>
<artifactId>spring-shell-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.shell</groupId>
<artifactId>spring-shell-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.shell</groupId>
<artifactId>spring-shell-dependencies</artifactId>
<version>${spring-shell.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Create a HelloWorld class with one command hello-world. Some things to notice:
- The class needs to be annotated with
@Command. - The methods which represent commands need to be annotated with
@Commandas well. - By default, Spring Shell will use the method name for the command name. You can also provide a
commandparameter in the@Commandannotation to specify the name of the command. Do note that when two commands with the same name exists, you will not get an error. Instead, the first command will be known, and the second one will not be available as a command. - A short description of a command can be added by means of the
descriptionparameter. It should start with a capital letter and end with a period, as best practice.
@Command
public class HelloWorld {
@Command(description = "This command will say hello.")
public String helloWorld() {
return "Hello World!";
}
}
Now you need to tell Spring that commands need to be activated. You can do so in the MySpringShellPlanetApplication class. You can:
- add an
@EnableCommandannotation and specify which classes need to be scanned (commented out in the code snippet); - add a
@CommandScanannotation in order that Spring will scan all classes.
@SpringBootApplication
//@EnableCommand(HelloWorld.class)
@CommandScan
public class MySpringShellPlanetApplication {
public static void main(String[] args) {
SpringApplication.run(MySpringShellPlanetApplication.class, args);
}
}
Spring Shell defaults to a non-interactive mode, if you want to enable the interactive mode, you can do so by adding the following property to the application.properties.
spring.shell.interactive.enabled=true
However, when building the application with Maven, you will need to skip the tests by adding -DskipTests, otherwise the tests will wait for interaction and your build will not end. In the remainder of this blog, interactive mode is not enabled.
Build the application.
$ mvn clean verify
Run the application with the hello-world command.
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar hello-world
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.3.5)
2024-11-10T09:48:04.050+01:00 INFO 10688 --- [MySpringShellPlanet] [ main] c.m.m.MySpringShellPlanetApplication : Starting MySpringShellPlanetApplication v0.0.1-SNAPSHOT using Java 21 with PID 10688 (/home/<project directory>/myspringshellplanet/target/myspringshellplanet-0.0.1-SNAPSHOT.jar started by <user> in /home/<project directory>/myspringshellplanet)
2024-11-10T09:48:04.053+01:00 INFO 10688 --- [MySpringShellPlanet] [ main] c.m.m.MySpringShellPlanetApplication : No active profile set, falling back to 1 default profile: "default"
2024-11-10T09:48:04.761+01:00 INFO 10688 --- [MySpringShellPlanet] [ main] c.m.m.MySpringShellPlanetApplication : Started MySpringShellPlanetApplication in 0.952 seconds (process running for 1.215)
Hello World!
Great, the hello message is printed. But also a Spring Boot banner and logging.
Disable the banner and the console logging in the application.properties.
spring.main.banner-mode=off
logging.pattern.console=
Build and run the application again. The output is now much more like a shell application.
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar hello-world
Hello World!
4. Built-In Commands
Spring Shell offers some built-in commands which can be viewed by executing the help command. You notice also the description of the hello-world command which was added before.
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar help
AVAILABLE COMMANDS
Built-In Commands
help: Display help about available commands
history: Display or save the history of previously run commands
version: Show version info
script: Read and execute commands from a file.
Default
hello-world: This command will say hello.
The history command shows you the last commands you executed.
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar history
[help, version, exit, help, exit]
The version command shows build and git info if those exist. Add the build-info goal to the spring-boot-maven-plugin in the pom.
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>build-info</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
...
</build>
Build the application and show the version info.
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar version
Build Version: 0.0.1-SNAPSHOT
5. Group Commands
In the help output, you can see that the hello-world command is located under a Default group. Spring Shell allows you to group commands yourself by adding a group parameter.
@Command(group = "Group Commands")
public class GroupCommands {
@Command()
public String helloWorldGroup() {
return "Hello World Group!";
}
}
Build the application, show the help and execute the hello-world-group command.
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar help
AVAILABLE COMMANDS
Built-In Commands
help: Display help about available commands
history: Display or save the history of previously run commands
version: Show version info
script: Read and execute commands from a file.
Default
hello-world: This command will say hello.
Group Commands
hello-world-group:
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar hello-world-group
Hello World Group!
6. Options
In the previous examples, a simple text was returned. But often you will need to pass arguments to the commands. So, instead of just returning Hello World, you pass a name argument.
@Command(group = "Options")
public class Options {
@Command
public String helloName(String name) {
return "Hello " + name + "!";
}
}
Build the application and run the hello-name command with and without an argument.
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar hello-name Gunter
Hello Gunter!
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar hello-name
Missing mandatory option '--name'
When the argument is missing, a nice message is shown that a mandatory option is missing. If an argument is not mandatory, you indicate this with the @Option annotation. You can add a default value as parameter to @Option (hello-name-default command) or you can build some logic to ask the user to fill in the missing argument (hello-name-option command).
@Command
public String helloNameDefault(@Option(defaultValue = "World") String name) {
return "Hello " + name + "!";
}
@Command
public String helloNameOption(@Option String name) {
if (name == null) {
return askForName();
}
return "Hello " + name + "!";
}
private String askForName() {
System.out.print("Please enter a name: ");
return helloNameOption(System.console().readLine());
}
Build the application and execute both commands.
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar hello-name-default
Hello World!
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar hello-name-option
Please enter a name: Gunter
Hello Gunter!
7. Validation
When arguments are passed, you want to validate the input before you start to process them. You just use the Jakarta Bean Validation API as you are used to when using Spring Boot. In the next example, it is checked whether the name is between a minimum and maximum number of characters.
@Command(group = "Validation")
public class Validation {
@Command
public String validateName(@Size(min = 2, max = 40, message = "Name must be between 2 and 40 characters long") String name) {
return "Hello " + name + "!";
}
}
Build the application and run the validate-name command.
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar validate-name G
The following constraints were not met:
ConstraintViolationImpl{interpolatedMessage='Name must be between 2 and 40 characters long', propertyPath=validateName.name, rootBeanClass=class com.mydeveloperplanet.myspringshellplanet.examples.Validation, messageTemplate='Name must be between 2 and 40 characters long'}
This works, but this is not a very user-friendly message. In order to handle this properly, you need to create a CustomExceptionResolver which implements the CommandExceptionHandler. Do note that you also need to add the @Order annotation, otherwise your custom resolver will not be processed.
@Order(0)
public class CustomExceptionResolver implements CommandExceptionResolver {
@Override
public CommandHandlingResult resolve(Exception ex) {
if (ex instanceof ParameterValidationException pve) {
return handleConstraintViolation((pve));
}
return null; // Let other exception handlers deal with other types of exceptions
}
private CommandHandlingResult handleConstraintViolation(ParameterValidationException pve) {
StringBuilder errorMessage = new StringBuilder("Validation error(s):\n");
for (ConstraintViolation<?> violation : pve.getConstraintViolations()) {
errorMessage.append("- ")
.append(violation.getMessage())
.append("\n");
}
return CommandHandlingResult.of(errorMessage.toString());
}
}
Additionally, you register the CustomExceptionResolver as a Bean in MySpringShellPlanetApplication.
@Bean
CustomExceptionResolver customExceptionResolver() {
return new CustomExceptionResolver();
}
Build the application and run the validate-name command. This time, a nice error message is shown.
$ java -jar target/myspringshellplanet-0.0.1-SNAPSHOT.jar validate-name G
Validation error(s):
- Name must be between 2 and 40 characters long
8. Build Executable
Up till now, you executed the shell application as a Java program. But it would be nicer if it was an executable. This can be done by means of GraalVM.
Add the following snippet to the pom.
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<metadataRepository>
<enabled>true</enabled>
</metadataRepository>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
Install GraalVM for Java 21. This can easily be done by using SDKMan. Enable the GraalVM JDK.
$ sdk install java 21.0.2-graalce
$ sdk use java 21.0.2-graalce
Build the GraalVM image.
$ mvn native:compile -Pnative
...
Produced artifacts:
/home/<project directory>/myspringshellplanet/target/myspringshellplanet (executable)
========================================================================================================================
Finished generating 'myspringshellplanet' in 1m 26s.
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 01:33 min
[INFO] Finished at: 2024-11-10T14:42:28+01:00
[INFO] ------------------------------------------------------------------------
Try the executable. Works like a charm and it is super fast!
$ ./target/myspringshellplanet hello-name Gunter
Hello Gunter!
9. Conclusion
Spring Shell offers a very convenient way for creating a CLI. And yes, you can code this yourself in pure Java, but you get so much nice features out-of-the-box with Spring Shell that it saves you a lot of time.
Discover more from
Subscribe to get the latest posts sent to your email.
