Как стать автором
Обновить

CLI на стероидах: Google Guice и JCommander

Время на прочтение10 мин
Количество просмотров6.5K
В этой статье я хочу рассказать об одном из способов построения 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, только в нем описаны все зависимости и доступные к исполнению команды.

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.

Результаты работы выглядят примерно так:


Надеюсь статья окажется кому-то полезной.
Всем спасибо за внимание!
Теги:
Хабы:
Всего голосов 31: ↑30 и ↓1+29
Комментарии7

Публикации