📜 ⬆️ ⬇️

CLI on steroids: Google Guice and JCommander

In this article I want to talk about one of the ways to build CLI applications in Java.
Actually the need for such applications has not disappeared anywhere - for example, in my case it was an application for carrying out functional and load testing of the server part. Of course, there were options in conducting the necessary tests using a set of JUnits, but we were very limited in time and we wanted to get a solution that does not require programming from the testing department. Moreover, the binary protocol by which the client interacted and the server was clearly specified.

Idea


This time I really didn’t want to reinvent the wheel in creating things that are trivial for a CLI application - parsing input lines, highlighting commands and arguments, validating arguments, executing commands, displaying hints, and the like.
It was decided to look for ready-made components.

On Habré not so long ago there was an article on commons-cli . I didn’t like commons-cli with my 'wooden' API, but I found out from the comments to the article about several alternatives, including JCommander .
')
It was he who attracted attention because:



Since the server part was built using the Google Guice framework, it was also advisable to build a CLI client on it, especially since the server with the client had some common dependencies and components.

What came of it


Class diagram:



In the diagram:
- CLISupport - tracks console user input and delegates its parsing to JCommander;
- CLIApplication - starts and stops application components in a specific order;
- Command - abstract class Command, the base for all other commands. The main method is execute;
- CommandXXX - implementation of commands for example;
- JCommanderProvider - implementation of the com.google.inject.Provider interface. Creates an instance of JCommander for external requests (injections);
- CLIConfigurationModule - configurator components in Guice, the implementation of com.google.inject.AbstractModule.

Code examples


CLIConfigurationModule is a Guice configuration module, only it describes all the dependencies and commands available for execution.

public class CLIConfigurationModule extends AbstractModule {

protected void configure() {
AnsiConsole.systemInstall();

bind(PrimaryBusinessLogicService. class ).to(PrimaryBusinessLogicServiceImpl. class ).asEagerSingleton();
bind(SecondaryBusinessLogicService. class ).to(SecondaryBusinessLogicServiceImpl. class ).asEagerSingleton();
bind(CLISupport. class ).asEagerSingleton();

bind(CLIApplication. class ).asEagerSingleton();

bind(JCommander. class ).toProvider(JCommanderProvider. class );

bind(CommandClearScreen. class );
bind(CommandExit. class );
bind(CommandUsage. class );
bind(CommandPrimary. class );
bind(CommandSecondary. class );
}

@Provides
@Inject
public Collection<Command> provideAvailableCommands(Injector injector) {
Collection<Command> commands = new ArrayList <Command>();
commands.add(injector.getInstance(CommandClearScreen. class ));
commands.add(injector.getInstance(CommandExit. class ));
commands.add(injector.getInstance(CommandUsage. class ));
commands.add(injector.getInstance(CommandPrimary. class ));
commands.add(injector.getInstance(CommandSecondary. class ));
return commands;
}
}

* This source code was highlighted with Source Code Highlighter .
public class CLIConfigurationModule extends AbstractModule {

protected void configure() {
AnsiConsole.systemInstall();

bind(PrimaryBusinessLogicService. class ).to(PrimaryBusinessLogicServiceImpl. class ).asEagerSingleton();
bind(SecondaryBusinessLogicService. class ).to(SecondaryBusinessLogicServiceImpl. class ).asEagerSingleton();
bind(CLISupport. class ).asEagerSingleton();

bind(CLIApplication. class ).asEagerSingleton();

bind(JCommander. class ).toProvider(JCommanderProvider. class );

bind(CommandClearScreen. class );
bind(CommandExit. class );
bind(CommandUsage. class );
bind(CommandPrimary. class );
bind(CommandSecondary. class );
}

@Provides
@Inject
public Collection<Command> provideAvailableCommands(Injector injector) {
Collection<Command> commands = new ArrayList <Command>();
commands.add(injector.getInstance(CommandClearScreen. class ));
commands.add(injector.getInstance(CommandExit. class ));
commands.add(injector.getInstance(CommandUsage. class ));
commands.add(injector.getInstance(CommandPrimary. class ));
commands.add(injector.getInstance(CommandSecondary. class ));
return commands;
}
}

* This source code was highlighted with Source Code Highlighter .
public class CLIConfigurationModule extends AbstractModule {

protected void configure() {
AnsiConsole.systemInstall();

bind(PrimaryBusinessLogicService. class ).to(PrimaryBusinessLogicServiceImpl. class ).asEagerSingleton();
bind(SecondaryBusinessLogicService. class ).to(SecondaryBusinessLogicServiceImpl. class ).asEagerSingleton();
bind(CLISupport. class ).asEagerSingleton();

bind(CLIApplication. class ).asEagerSingleton();

bind(JCommander. class ).toProvider(JCommanderProvider. class );

bind(CommandClearScreen. class );
bind(CommandExit. class );
bind(CommandUsage. class );
bind(CommandPrimary. class );
bind(CommandSecondary. class );
}

@Provides
@Inject
public Collection<Command> provideAvailableCommands(Injector injector) {
Collection<Command> commands = new ArrayList <Command>();
commands.add(injector.getInstance(CommandClearScreen. class ));
commands.add(injector.getInstance(CommandExit. class ));
commands.add(injector.getInstance(CommandUsage. class ));
commands.add(injector.getInstance(CommandPrimary. class ));
commands.add(injector.getInstance(CommandSecondary. class ));
return commands;
}
}

* This source code was highlighted with Source Code Highlighter .



JCommanderProvider - creates JCommander instances when they are injected / received. Gets the command collection annotated by Provides in the CLIConfigurationModule class. The main feature is that before parsing the next team, it is necessary to create a new initialized JCommander instance, since after parsing, it maintains the state and it can damage and does so with subsequent parsing. That is why you can not declare JCommander as Singleton / asEagerSingleton.

public class JCommanderProvider implements Provider<JCommander> {

@Inject
private Collection<Command> commands;

/**
* Constructs the new JCommander instance with all commands.
*
* @return
*/
public JCommander get () {
JCommander commander = new JCommander();
for (Command command : commands) {
addCommand(commander, command);
}
return commander;
}

private void addCommand(JCommander commander, Command command) {
commander.addCommand(command.getCommandName(), command, command.getAliases());
}
}

* This source code was highlighted with Source Code Highlighter .
public class JCommanderProvider implements Provider<JCommander> {

@Inject
private Collection<Command> commands;

/**
* Constructs the new JCommander instance with all commands.
*
* @return
*/
public JCommander get () {
JCommander commander = new JCommander();
for (Command command : commands) {
addCommand(commander, command);
}
return commander;
}

private void addCommand(JCommander commander, Command command) {
commander.addCommand(command.getCommandName(), command, command.getAliases());
}
}

* This source code was highlighted with Source Code Highlighter .


Command is the base class for all commands. The main method is execute. The name of the command is indicated in the annotation javax.inject.Named - I considered this a rather elegant solution, since the dependency on javax.inject appears when using Guice and logically this annotation is very appropriate. In addition to the main name, it is possible to define an array of aliases (aliases) - for example, the 'exit' command can have aliases 'q' and 'x'. Further, the getCommandName and getAliases methods are used when registering a command with JCommander (see the addCommand method in JCommanderProvider).

public abstract class Command {

private static final String [] NO_ALIASES = new String []{};

protected Logger logger;
private String commandName;

protected Command() {
logger = LoggerFactory.getLogger(getClass());
commandName = getClass().getAnnotation(Named. class ). value ();
}

public String [] getAliases() {
return NO_ALIASES;
}

public final String getCommandName() {
return commandName;
}

public abstract void execute() throws ExecutionException;
}

* This source code was highlighted with Source Code Highlighter .
public abstract class Command {

private static final String [] NO_ALIASES = new String []{};

protected Logger logger;
private String commandName;

protected Command() {
logger = LoggerFactory.getLogger(getClass());
commandName = getClass().getAnnotation(Named. class ). value ();
}

public String [] getAliases() {
return NO_ALIASES;
}

public final String getCommandName() {
return commandName;
}

public abstract void execute() throws ExecutionException;
}

* This source code was highlighted with Source Code Highlighter .
public abstract class Command {

private static final String [] NO_ALIASES = new String []{};

protected Logger logger;
private String commandName;

protected Command() {
logger = LoggerFactory.getLogger(getClass());
commandName = getClass().getAnnotation(Named. class ). value ();
}

public String [] getAliases() {
return NO_ALIASES;
}

public final String getCommandName() {
return commandName;
}

public abstract void execute() throws ExecutionException;
}

* This source code was highlighted with Source Code Highlighter .



CommandPrimary is an example of a real command with business logic service calls. What is specified in the @Parameters annotation will be used when forming the description for this command (see CommandUsage command).

@Parameters(commandDescription = "Execute the logic of primary service" )
@Named( "do-primary" )
public class CommandPrimary extends Command {

@Parameter(names = { "-verbose" , "-v" }, description = "Verbose mode" )
protected boolean verbose;

@Parameter(names = { "-id" }, description = "Entity ID" , required = true )
protected String id;

@Parameter(names = { "-count" , "-c" }, validateWith = PositiveInteger. class , description = "Entities count" , required = true )
protected long count;

private PrimaryBusinessLogicService primaryBusinessLogicService;

@Inject
public CommandPrimary(PrimaryBusinessLogicService primaryBusinessLogicService) {
this .primaryBusinessLogicService = primaryBusinessLogicService;
}

@Override
public String [] getAliases() {
return new String []{ "dp" , "primary" };
}

@Override
public void execute() throws ExecutionException {
try {
if (verbose) {
logger.info( String .format( "Executing primary business logic with parameters: [count=%d, id=%s]" , count, id));
}

primaryBusinessLogicService.executePrimaryBusinessLogic(count, id);
} catch (ServiceException e) {
throw new ExecutionException(e);
}
}
}

* This source code was highlighted with Source Code Highlighter .
@Parameters(commandDescription = "Execute the logic of primary service" )
@Named( "do-primary" )
public class CommandPrimary extends Command {

@Parameter(names = { "-verbose" , "-v" }, description = "Verbose mode" )
protected boolean verbose;

@Parameter(names = { "-id" }, description = "Entity ID" , required = true )
protected String id;

@Parameter(names = { "-count" , "-c" }, validateWith = PositiveInteger. class , description = "Entities count" , required = true )
protected long count;

private PrimaryBusinessLogicService primaryBusinessLogicService;

@Inject
public CommandPrimary(PrimaryBusinessLogicService primaryBusinessLogicService) {
this .primaryBusinessLogicService = primaryBusinessLogicService;
}

@Override
public String [] getAliases() {
return new String []{ "dp" , "primary" };
}

@Override
public void execute() throws ExecutionException {
try {
if (verbose) {
logger.info( String .format( "Executing primary business logic with parameters: [count=%d, id=%s]" , count, id));
}

primaryBusinessLogicService.executePrimaryBusinessLogic(count, id);
} catch (ServiceException e) {
throw new ExecutionException(e);
}
}
}

* This source code was highlighted with Source Code Highlighter .
@Parameters(commandDescription = "Execute the logic of primary service" )
@Named( "do-primary" )
public class CommandPrimary extends Command {

@Parameter(names = { "-verbose" , "-v" }, description = "Verbose mode" )
protected boolean verbose;

@Parameter(names = { "-id" }, description = "Entity ID" , required = true )
protected String id;

@Parameter(names = { "-count" , "-c" }, validateWith = PositiveInteger. class , description = "Entities count" , required = true )
protected long count;

private PrimaryBusinessLogicService primaryBusinessLogicService;

@Inject
public CommandPrimary(PrimaryBusinessLogicService primaryBusinessLogicService) {
this .primaryBusinessLogicService = primaryBusinessLogicService;
}

@Override
public String [] getAliases() {
return new String []{ "dp" , "primary" };
}

@Override
public void execute() throws ExecutionException {
try {
if (verbose) {
logger.info( String .format( "Executing primary business logic with parameters: [count=%d, id=%s]" , count, id));
}

primaryBusinessLogicService.executePrimaryBusinessLogic(count, id);
} catch (ServiceException e) {
throw new ExecutionException(e);
}
}
}

* This source code was highlighted with Source Code Highlighter .



Dependencies in the project
< properties >
< version.jcommander > 1.18 </ version.jcommander >
< version.jansi > 1.6 </ version.jansi >
< version.commons-io > 2.0.1 </ version.commons-io >
< version.jline > 0.9.94 </ version.jline >
< version.guice > 3.0 </ version.guice >
< version.logback > 0.9.29 </ version.logback >
< version.slf4j > 1.6.2 </ version.slf4j >
< version.commons-lang > 3.0.1 </ version.commons-lang >

< version.maven-compiler-plugin > 2.3.2 </ version.maven-compiler-plugin >
< version.maven-jar-plugin > 2.3.2 </ version.maven-jar-plugin >
< version.maven-surefire-plugin > 2.9 </ version.maven-surefire-plugin >
< version.onejar-maven-plugin > 1.4.4 </ version.onejar-maven-plugin >
< version.maven-assembly-plugin > 2.2.1 </ version.maven-assembly-plugin >
</ properties >

< dependencies >

<!-- Logging -->

< dependency >
< groupId > ch.qos.logback </ groupId >
< artifactId > logback-classic </ artifactId >
< version > ${version.logback} </ version >
</ dependency >

< dependency >
< groupId > ch.qos.logback </ groupId >
< artifactId > logback-core </ artifactId >
< version > ${version.logback} </ version >
</ dependency >

< dependency >
< groupId > org.slf4j </ groupId >
< artifactId > slf4j-api </ artifactId >
< version > ${version.slf4j} </ version >
</ dependency >

<!-- Google Stuff -->

< dependency >
< groupId > com.google.inject </ groupId >
< artifactId > guice </ artifactId >
< version > ${version.guice} </ version >
</ dependency >

<!--External Stuff-->

< dependency >
< groupId > commons-io </ groupId >
< artifactId > commons-io </ artifactId >
< version > ${version.commons-io} </ version >
</ dependency >

< dependency >
< groupId > org.fusesource.jansi </ groupId >
< artifactId > jansi </ artifactId >
< version > ${version.jansi} </ version >
</ dependency >

< dependency >
< groupId > com.beust </ groupId >
< artifactId > jcommander </ artifactId >
< version > ${version.jcommander} </ version >
</ dependency >

< dependency >
< groupId > jline </ groupId >
< artifactId > jline </ artifactId >
< version > ${version.jline} </ version >
</ dependency >

< dependency >
< groupId > org.apache.commons </ groupId >
< artifactId > commons-lang3 </ artifactId >
< version > ${version.commons-lang} </ version >
</ dependency >
</ dependencies >

* This source code was highlighted with Source Code Highlighter .
< properties >
< version.jcommander > 1.18 </ version.jcommander >
< version.jansi > 1.6 </ version.jansi >
< version.commons-io > 2.0.1 </ version.commons-io >
< version.jline > 0.9.94 </ version.jline >
< version.guice > 3.0 </ version.guice >
< version.logback > 0.9.29 </ version.logback >
< version.slf4j > 1.6.2 </ version.slf4j >
< version.commons-lang > 3.0.1 </ version.commons-lang >

< version.maven-compiler-plugin > 2.3.2 </ version.maven-compiler-plugin >
< version.maven-jar-plugin > 2.3.2 </ version.maven-jar-plugin >
< version.maven-surefire-plugin > 2.9 </ version.maven-surefire-plugin >
< version.onejar-maven-plugin > 1.4.4 </ version.onejar-maven-plugin >
< version.maven-assembly-plugin > 2.2.1 </ version.maven-assembly-plugin >
</ properties >

< dependencies >

<!-- Logging -->

< dependency >
< groupId > ch.qos.logback </ groupId >
< artifactId > logback-classic </ artifactId >
< version > ${version.logback} </ version >
</ dependency >

< dependency >
< groupId > ch.qos.logback </ groupId >
< artifactId > logback-core </ artifactId >
< version > ${version.logback} </ version >
</ dependency >

< dependency >
< groupId > org.slf4j </ groupId >
< artifactId > slf4j-api </ artifactId >
< version > ${version.slf4j} </ version >
</ dependency >

<!-- Google Stuff -->

< dependency >
< groupId > com.google.inject </ groupId >
< artifactId > guice </ artifactId >
< version > ${version.guice} </ version >
</ dependency >

<!--External Stuff-->

< dependency >
< groupId > commons-io </ groupId >
< artifactId > commons-io </ artifactId >
< version > ${version.commons-io} </ version >
</ dependency >

< dependency >
< groupId > org.fusesource.jansi </ groupId >
< artifactId > jansi </ artifactId >
< version > ${version.jansi} </ version >
</ dependency >

< dependency >
< groupId > com.beust </ groupId >
< artifactId > jcommander </ artifactId >
< version > ${version.jcommander} </ version >
</ dependency >

< dependency >
< groupId > jline </ groupId >
< artifactId > jline </ artifactId >
< version > ${version.jline} </ version >
</ dependency >

< dependency >
< groupId > org.apache.commons </ groupId >
< artifactId > commons-lang3 </ artifactId >
< version > ${version.commons-lang} </ version >
</ dependency >
</ dependencies >

* This source code was highlighted with Source Code Highlighter .
< properties >
< version.jcommander > 1.18 </ version.jcommander >
< version.jansi > 1.6 </ version.jansi >
< version.commons-io > 2.0.1 </ version.commons-io >
< version.jline > 0.9.94 </ version.jline >
< version.guice > 3.0 </ version.guice >
< version.logback > 0.9.29 </ version.logback >
< version.slf4j > 1.6.2 </ version.slf4j >
< version.commons-lang > 3.0.1 </ version.commons-lang >

< version.maven-compiler-plugin > 2.3.2 </ version.maven-compiler-plugin >
< version.maven-jar-plugin > 2.3.2 </ version.maven-jar-plugin >
< version.maven-surefire-plugin > 2.9 </ version.maven-surefire-plugin >
< version.onejar-maven-plugin > 1.4.4 </ version.onejar-maven-plugin >
< version.maven-assembly-plugin > 2.2.1 </ version.maven-assembly-plugin >
</ properties >

< dependencies >

<!-- Logging -->

< dependency >
< groupId > ch.qos.logback </ groupId >
< artifactId > logback-classic </ artifactId >
< version > ${version.logback} </ version >
</ dependency >

< dependency >
< groupId > ch.qos.logback </ groupId >
< artifactId > logback-core </ artifactId >
< version > ${version.logback} </ version >
</ dependency >

< dependency >
< groupId > org.slf4j </ groupId >
< artifactId > slf4j-api </ artifactId >
< version > ${version.slf4j} </ version >
</ dependency >

<!-- Google Stuff -->

< dependency >
< groupId > com.google.inject </ groupId >
< artifactId > guice </ artifactId >
< version > ${version.guice} </ version >
</ dependency >

<!--External Stuff-->

< dependency >
< groupId > commons-io </ groupId >
< artifactId > commons-io </ artifactId >
< version > ${version.commons-io} </ version >
</ dependency >

< dependency >
< groupId > org.fusesource.jansi </ groupId >
< artifactId > jansi </ artifactId >
< version > ${version.jansi} </ version >
</ dependency >

< dependency >
< groupId > com.beust </ groupId >
< artifactId > jcommander </ artifactId >
< version > ${version.jcommander} </ version >
</ dependency >

< dependency >
< groupId > jline </ groupId >
< artifactId > jline </ artifactId >
< version > ${version.jline} </ version >
</ dependency >

< dependency >
< groupId > org.apache.commons </ groupId >
< artifactId > commons-lang3 </ artifactId >
< version > ${version.commons-lang} </ version >
</ dependency >
</ dependencies >

* This source code was highlighted with Source Code Highlighter .



Explanation of libraries


jansi - library site . For old school fans of pseudographics, there was a desire to diversify the output into the console and add a little joy to the work of testers. Something has been done - a color conclusion and a farewell phrase 'Good bye!' when exiting in white. Exclusively from the small amount of free time that appeared at the end of the project.
logback - library site . Using this particular implementation of logging is not necessary, however, it is worth noting the main positive qualities of logback - high performance, rereading settings on the fly, configuration via JMX, support for parameterization and include, and much more. In general, Logback deserves a separate article.
jline - library site . Solves the problem of navigating the previously entered commands with the Up / Down keys (see below), plus the command completion functionality (try typing, for example, 'do-p' and pressing the Tab key). Ideally, you can implement auto-completion of not only commands, but also their arguments, and with reference to the context.

Problems encountered


Under Linux, users noted the incorrect operation of the Up / Down arrows - instead of navigating through the list of previously executed commands, incomprehensible pseudo-sequences were output. This problem led to the use of the jline library.
Otherwise, everything works well and smoothly.

Source


All source code for the article is available here .
To build the application, you will need Maven version 2.x installed, after that - 'mvn package'.
As a result of the assembly, the jcommander-guice-sample-XXX-client.tar.gz archive will be made. It should be unpacked and run the appropriate shell script for your OS - run.sh or run.bat.

The results of the work look like this:


I hope the article will be useful to someone.
Thank you all for your attention!

Source: https://habr.com/ru/post/128781/


All Articles