В этой статье я хочу рассказать об одном из способов построения CLI-приложений на Java.
Собственно сама потребность в таких приложениях никуда не делась — так, например, в моем случае это было приложение для проведения функционального и нагрузочного тестирования серверной части. Конечно же были варианты в проведении необходимых тестов при помощи набора JUnit-ов, но мы были сильно ограничены во времени и хотелось получить решение, не требующее программирования со стороны отдела тестирования. Тем более, что бинарный протокол по которому взаимодействовали клиент и сервер был четко специфицирован.
В этот раз мне сильно не хотелось изобретать велосипед в создании тривиальных для CLI-приложения вещей — парсинг строки ввода, выделение команды и аргументов, валидация аргументов, исполнение команды, вывод подсказок и тому подобное.
Было принято решение поискать готовые компоненты.
На Хабре не так давно была статья о commons-cli. Сам commons-cli мне не понравился своим 'деревянным' API, однако из комментариев к самой статье я узнал про несколько альтернатив в числе которых был JCommander.
Привлек внимание именно он, поскольку:
Поскольку серверная часть была построена с применением фреймворка Google Guice, то и CLI-клиента было целесообразно построить на нем же, тем более, что у сервера с клиентом были некоторые общие зависимости и компоненты.
Диаграмма классов:
На диаграмме:
— CLISupport — отслеживает консольный ввод пользователя и делегирует его разбор JCommander-у;
— CLIApplication — производит запуск и останов компонент приложения в определенном порядке;
— Command — абстрактный класс Команда, база для всех остальных команд. Основной метод — execute;
— CommandXXX — реализации команд для примера;
— JCommanderProvider — реализация интерфейса com.google.inject.Provider. Создает экземпляр JCommander при внешних запросах (инжекциях);
— CLIConfigurationModule — конфигуратор компонент в Guice, реализация com.google.inject.AbstractModule.
CLIConfigurationModule — конфигурационный модуль Guice, только в нем описаны все зависимости и доступные к исполнению команды.
Собственно сама потребность в таких приложениях никуда не делась — так, например, в моем случае это было приложение для проведения функционального и нагрузочного тестирования серверной части. Конечно же были варианты в проведении необходимых тестов при помощи набора JUnit-ов, но мы были сильно ограничены во времени и хотелось получить решение, не требующее программирования со стороны отдела тестирования. Тем более, что бинарный протокол по которому взаимодействовали клиент и сервер был четко специфицирован.
Идея
В этот раз мне сильно не хотелось изобретать велосипед в создании тривиальных для CLI-приложения вещей — парсинг строки ввода, выделение команды и аргументов, валидация аргументов, исполнение команды, вывод подсказок и тому подобное.
Было принято решение поискать готовые компоненты.
На Хабре не так давно была статья о commons-cli. Сам commons-cli мне не понравился своим 'деревянным' API, однако из комментариев к самой статье я узнал про несколько альтернатив в числе которых был JCommander.
Привлек внимание именно он, поскольку:
- Естественная поддержка паттерна Команда;
- Удобный способ определения параметров вызова, основанный на аннотациях;
- Автоматическое формирование помощи по доступным командам;
- Неплохая документация;
- Бесплатно.
Поскольку серверная часть была построена с применением фреймворка Google Guice, то и CLI-клиента было целесообразно построить на нем же, тем более, что у сервера с клиентом были некоторые общие зависимости и компоненты.
Что из этого получилось
Диаграмма классов:
На диаграмме:
— CLISupport — отслеживает консольный ввод пользователя и делегирует его разбор JCommander-у;
— CLIApplication — производит запуск и останов компонент приложения в определенном порядке;
— Command — абстрактный класс Команда, база для всех остальных команд. Основной метод — execute;
— CommandXXX — реализации команд для примера;
— JCommanderProvider — реализация интерфейса com.google.inject.Provider. Создает экземпляр JCommander при внешних запросах (инжекциях);
— CLIConfigurationModule — конфигуратор компонент в Guice, реализация com.google.inject.AbstractModule.
Примеры кода
CLIConfigurationModule — конфигурационный модуль Guice, только в нем описаны все зависимости и доступные к исполнению команды.
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 — создает экземпляры JCommander при их инжекции/получении экземпляра. Получает коллекцию команд объявленную аннотацией Provides в классе CLIConfigurationModule. Основная особенность в том, что перед разбором очередной команды необходимо создавать новый проинициализированный экземпляр JCommander, т.к. после разбора он сохраняет состояние и оно может повредить и делает это при последующем разборе. Именно поэтому нельзя объявлять JCommander как 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.
Command — базовый класс для всех команд. Основной метод — execute. Название команды указывается в аннотации javax.inject.Named — я посчитал это достаточно изящным решением, поскольку зависимость на javax.inject появляется при использовании Guice и логически эта аннотация очень подходит по смыслу. Помимо основного названия допустимо определение массива псевдонимов (алиасов) — например на команду 'exit' могут быть псевдонимы 'q' и 'x'. В дальнейшем методы getCommandName и getAliases используются при регистрации команды в JCommander (см. метод addCommand в 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.
CommandPrimary — пример реальной команды с вызовами сервиса бизнес-логики. То, что указано в аннотации @Parameters будет использовано при формировании описания по этой команде (см. команду CommandUsage).
@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.
Зависимости в проекте
<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.
Пояснения по библиотекам
jansi — сайт библиотеки. Для олдскул любителей псевдографики, было желание разнообразить вывод в консоль и добавить немного радости в работу тестеров. Кое-что было сделано — цветной вывод и прощальная фраза 'Good bye!' при выходе белым цветом. Исключительно от появившегося в конце проекта небольшого количества свободного времени.
logback — сайт библиотеки. Использование конкретно этой реализации журналирования не обязательно, однако стоит отметить основные положительные качества logback — высокая производительность, перечитывание настроек 'на лету', конфигурирование посредством JMX, поддержка параметризации и include-ов и многое многое другое. Вообще Logback заслуживает отдельной статьи.
jline — сайт библиотеки. Решает проблему работоспособности навигации по ранее введенным командам клавишами Вверх/Вниз (см. далее), плюс функциональность автозавершения команд (попробуйте ввести, например 'do-p' и нажать клавишу Tab). В идеале можно реализовать авто-завершение не только команд, но и их аргументов, причем с привязкой к контексту.
Возникшие проблемы
Под ОС Linux пользователи отмечали некорректную работу стрелок Вверх/Вниз — вместо перехода по списку отработанных ранее команд выводились непонятные псевдопоследовательности. Эта проблема привела к использованию библиотеки jline.
В остальном все работает четко и слаженно.
Исходный код
Весь исходный код к статье доступен здесь.
Для сборки приложения потребуется установленный Maven версии 2.x, после этого — 'mvn package'.
В результате сборки будет сделан архив jcommander-guice-sample-XXX-client.tar.gz. Его следует распаковать и запустить соответствующий Вашей ОС shell-скрипт — run.sh или run.bat.
Результаты работы выглядят примерно так:
Надеюсь статья окажется кому-то полезной.
Всем спасибо за внимание!