среда, 20 апреля 2011 г.

JLine: Интерактивная Java Console в стиле Quake

Каждому программисту в жизни приходилось писать небольшие консольные утилитки. Обычно это выглядит как бесконечный цикл чтения строки, парсинга её и выполнение. Но визуально это всегда выглядит не очень, да и пользоваться трудно. Но мне как-то пришлось писать такую консольную утилитку, с которой должны работать нормальные люди :) поэтому задумался (после соответсвующего пинка :))) над её усовершенствованием в стиле Quake, т.е. сделать такую консольку более интерактивной. Конечно же писать самостоятельно в 2011 году наверно глупо. Немного поискав, остановился на наилучшем, на мой взгляд, варианте: JLine. Перечислю те вещи, за которые она мне больше всего приглянулась:

  • История команд, по которой можно легко перемещаться с помощью стрелочек.
  • Редактирование строки. В стандартном java console приложении невозможно отредактировать символ внутри уже набранной строки, приходится удалять весь хвост.
  • Автодополнение команд с помощью tab. Легко настраиваемое, но при этом с хорошим стандартным набором

В этом посте я набросаю простенькую консоль, поддерживающую команды, которые можно легко будет добавлять (для этого задействуем Google Guice).

Для начала объявим базовый интерфейс для всех команд. Главный метод execute, который что-то делает :) Остальные методы возвращают метаданные, не очень красиво выглядит, возможно через аннотации было бы лучше сделать, но я пока не придумал как.


public interface Command {

    String getName();

    String getDescription();

    String execute(final String[] args) throws InterruptedException;

    String[] getArgs();

    Completor[] getArgCompletors();

}

Буду останавливаться только на главных моментах (где остальной код можно будет увидеть см. в конце поста).

Первые два пункта jline поддерживает практически без нашего участия. А вот с автодополнением придёться ему помочь. Как можно заметить каждая команда обладает методом getArgCompletors. Он возвращает массив jline интерфейсов Completor. Это достаточно простой интерфейс


public interface Completor {
   int complete(String buffer, int cursor, List candidates);
}

Тут всё просто. Передаётся строка, позиция курсора. В список candidates метод должен поместить возможные варианты автодополнения. В составе библиотеки идут несколько стандартных реализаций.

  • SimpleCompletor хранит список строк и предлагает варианты из этого списка.
  • FileNameCompletor динамически подсказывает имена доступных файлов и папок.
  • NullCompletor ничего не подсказывает :).
  • ArgumentCompletor композитный Completor. Хороший пример несколько идущих аргументов подряд с разделителем. Для каждого из таких аргументов можно задать свой Completor.

Есть и другие, но это те, что будут использованы в моём приложении. К сожалению конкретного того, что мне надо в списке не оказалось. Мне нужна по сути усовершенствованная версия ArgumentCompletor. Каждая строка содержит имя команды, а вслед за ней через пробел идут аргументы. Очень похоже на ArgumentCompletor. Но в зависимости от первой команды, дальше для аргументов могут быть разные дополнители. Поэтому я написал свой Completor. Он очень простой. В начале подбирается команда с помощью SimpleCompletor. И когда команда известна, подбирается для неё свой ArgumentCompletor из тех, что предоставляет интерфейс команды.


public class CommandsCompletor implements Completor {

    private final ArgumentCompletor.ArgumentDelimiter delim;

    private final Completor commandCompletor;
    private final Map argumentsCompletors;

    @Inject
    public CommandsCompletor(final Map commands) {
        delim = new ArgumentCompletor.WhitespaceArgumentDelimiter();

        String[] commandNames = new String[commands.size()];
        commandNames = commands.keySet().toArray(commandNames);
        commandCompletor = new SimpleCompletor(commandNames);
        argumentsCompletors = new HashMap();
        for (Map.Entry commandEntry : commands.entrySet()) {
            String commandName = commandEntry.getKey();
            Command command = commandEntry.getValue();
            Completor[] baseArgumentsCompletors = command.getArgCompletors();
            int baseLength = baseArgumentsCompletors == null ? 0 :
                            baseArgumentsCompletors.length;
            Completor[] commandArgumentCompletors = 
                           new Completor[baseLength + 2];

            commandArgumentCompletors[0] = commandCompletor;
            if (baseLength > 0) {
                arraycopy(baseArgumentsCompletors, 0, 
                              commandArgumentCompletors, 1, baseLength);
            }
            commandArgumentCompletors[baseLength + 1] = new NullCompletor();

            Completor argumentCompletor = 
                         new ArgumentCompletor(commandArgumentCompletors);
            argumentsCompletors.put(commandName, argumentCompletor);
        }
    }

    @Override
    public int complete(final String buffer, final int cursor, 
                             final List candidates) {
        ArgumentCompletor.ArgumentList list = delim.delimit(buffer, cursor);
        int argIndex = list.getCursorArgumentIndex();

        if (argIndex < 0) {
            return -1;
        }

        if (argIndex > 0) {
            String commandName = list.getArguments()[0];
            if (!argumentsCompletors.containsKey(commandName)) {
                return -1;
            }
            return argumentsCompletors.get(commandName).
                     complete(buffer, cursor, candidates);
        } else {
            return commandCompletor.complete(buffer, cursor, candidates);
        }
    }

}

Несколько замечаний.

  • Я использую WhitespaceArgumentDelimiter, чтобы раздробить строку на аргументы. И если курсор показывает на первый аргумент, значит мы дополняем имя команды и для этого используем простой SimpleCompletor со списком всех команд.
  • Первым дополнителем во всех ArgumentCompletor передаётся наш дополнитель для команды. Это сделано для упрощения кода, так как строка для построения ему передаётся в том виде как она пришла к нам, поэтому в качестве аргумента должно быть имя команды.
  • ArgumentCompletor обладает интересным поведением. Дополнителей, переданных ему может быть меньше чем аргументов в строке, тогда он для каждого последующего использует последний в списке Completor. Мы последним передаём NullCompletor, чтобы больше ничего не дополнялось.
  • Не обращайте внимание на аннотацию Inject, это от Guice и об этом будет позже.

А теперь сделаем нашу консольку с бесконечным циклом чтения новых команд. Чтобы использовать все плюшки описанные выше, необходимо использовать ConsoleReader, который позволяет прочитать строку со всеми плюшками.


public class Console {

    private final ConsoleReader reader;

    private final Executor executor;
    private final String helpCommandName;

    @Inject
    public Console(final Executor executor, final Completor commandsCompletor) throws IllegalStateException {
        this.executor = executor;
        try {
            this.reader = new ConsoleReader();
        } catch (IOException e) {
            throw new IllegalStateException("Couldn't create console reader: " + e.getMessage(), e);
        }
        reader.setBellEnabled(false);
        reader.addCompletor(commandsCompletor);
        this.helpCommandName = "help";
    }

    public void run() throws IOException {
        String line;
        while ((line = reader.readLine(">>> ")) != null) {
            try {
                String result;
                try {
                    result = executor.execute(line);
                } catch (InterruptedException e) {
                    out.println("exit");
                    break;
                }
                if (isNotBlank(result)) {
                    out.println(result);
                }
            } catch (UnsupportedCommand e) {
                err.println("Unsupported command: " + e.getCommandName());
                try {
                    out.println(executor.execute(helpCommandName));
                } catch (InterruptedException e1) {
                    err.println("help command was interrupted");
                }
            }
        }
    }
}

Надеюсь достаточно просто. Есть правда Executor, если интересно можете посмотреть его код, но ничего там интересного нету. Он просто парсит строку и выбирает команду для выполнения и запускает её.

Теперь поговорим о расширяемости. Всюду в конструкторах натыканы @Inject. Это для Guice, чтобы он при создании этих объектов передавал из своего контейнера соответсвующие реализации ожидаемых интерфейсов. Например в консоль мы передаём интерфейс Completor, а не его реализацию. Но что более интересно это Map commands. За его формирование будет отвечать так же Guice, но нам надо будет ему помочь. Для этого я буду использовать MapBinder. Что мне всегда нравилось в Guice, это отсутствие xml. Поэтому можно сделать отличный абстрактный модуль, который описывает все зависимости.


public abstract class AbstractConsoleModule extends AbstractModule {

    @Override
    protected final void configure() {
        List commands = new ArrayList();
        MapBinder commandsBinder = MapBinder.newMapBinder(binder(), String.class, Command.class);

        ExitCommand exitCommand = new ExitCommand();
        commandsBinder.addBinding("exit").toInstance(exitCommand);
        HelpCommand helpCommand = new HelpCommand();
        commandsBinder.addBinding("help").toInstance(helpCommand);

        Command[] futureCommands = getCommands();
        for (Command command : futureCommands) {
            commandsBinder.addBinding(command.getName()).toInstance(command);
        }
        commands.add(exitCommand);
        commands.add(helpCommand);
        commands.addAll(asList(futureCommands));
        helpCommand.init(commands);

        bind(Completor.class).to(CommandsCompletor.class);
    }

    protected abstract Command[] getCommands();

}

Т.о. если нужны новые команды достаточно их вернуть в getCommands и с помощью MapBinder они попадут во все классы ожидающие список команд, как например CommandsCompletor или Executor. Так же вместо Completor передастся наша реализация.

Есть две стандартные команды:

  • help выводить справку обо всех доступных командах
  • exit завершает работу

Но чтобы это происходило динамически, имя класса реализующего AbstractConsoleModule можно передать первым аргументом приложения. Вот такой метод main получился.


    public static void main(final String[] args) {
        AbstractConsoleModule consoleModule;
        if (args == null || args.length == 0) {
            consoleModule = new ConsoleModule();
        } else {
            consoleModule = createModule(args[0]);
        }
        Injector injector = Guice.createInjector(consoleModule);
        Console console = injector.getInstance(Console.class);
        try {
            console.run();
        } catch (IOException e) {
            err.println(e.getMessage());
        }
    }

createModule очень забавный метод, который с помощью reflection строит объект реализации AbstractConsoleModule по имени. А интересен он тем, что размер полезного кода раза в три меньше чем череда catch'ей :)

И для примера был реализован такой модуль с двумя командами.

  • HelloCommand с одним аргументом и выводит результирующую строку. В качестве дополнение подсказывает слово world ;)
  • CutCommand показывает содержимое выбранного файла и использует FileNameCompletor.


public class ConsoleModule extends AbstractConsoleModule {

    @Override
    protected Command[] getCommands() {
        return new Command[]{new HelloCommand(), new CutCommand()};
    }

}

Скриншоты как-то глупо атачить, поэтому снял видео =D

И как обещал. Решил выкладывать примеры в проект blog-examples на GitHub, чтобы не потерялись, да и смотреть исходники удобнее, качать непонятные архивы не надо и код говорит больше чем слова :)

7 комментариев:

  1. 1. Discuss ок. Не знаю, правда, как у него с разметкой каментов. Но возможность RSS-подписки - это щастье.

    2. Видео - вырвиглазный адъ. Снимая видео, можно сделать окошко поменьше. У меня на фулскрине на 360p (а это максимум) ничего не рассмотришь.

    3. Из текста не понял, откуда интерфейсы Completor и Command. Сначала подумал, что это оба твои интерфейсы. Потом - что оба jline-овские. Посмотрел в код, увидел, что первый - из jline, второй - твой. Комментируй как-нить в тексте, или оставляй импорты/пекеджи в кодярнике.

    4. Гитхаб - респектос.

    5. Расцветка кода - мрачная. Бложе и так ЧОРНЫЙ, а ещё и сочетание серых цветов с джавовскими строчками, еле влезающими в ширину колонки, делают меня плакать:

    Injector injector = Guice.createInjector(consoleModule);
    Console console = injector.getInstance(Console.class);

    В двух строчках я вижу четыре слова injector и четыре слова console. Жава такая жава :)

    6. Язык почти ок. Не споткнулся ни разу об ошибки. Запятых нету, конечно, но это ерунда - запятые не нужны. Ну и по стилю лингвоэстеты наверняка сказали б что-нить дерзкое, но это тоже пофиг. Главное, чтобы человек был хороший^w^w^w статья была интересная и полезная. А она такая, ящитаю.

    7. Тема разноцветности консоли не раскрыта.

    8. А консольку как в кваке можно тильдой вызывать?

    ОтветитьУдалить
  2. Виктор, огромное спасибо за отличный комментарий!
    1. Ага, давно прикрутил, больше всего нравится древовидная структура.
    2. Согласен, сегодня попробую переснять. К сожалению не спец в этом =/
    3. Оки, спасибо. Буду стараться лучше комментировать.
    4. Я смотрю ты там вообще активно форкаешся :)
    5. Да с дизайном мучался. Расцветку кода специально брал тёмную под тёмный дизайн, так что не поменяю, не проси :) К сожалению у блогера все стандартные темы имеют фиксированную ширину, что в случае java кода усложняет форматирование. Ты прав, Жава такая жава :) В дальнейшем у меня планы писать о более интересных и красивых языках. Например clojure, думаю ты оценишь скобочки =D
    6. Спасибо! :) С нативными языками у меня ещё остались проблемы, но я работаю над этим :)
    7. Жава такая жава (c) :)
    8. Таки это сразу чисто консольное приложение было. Наверно можно сделать и отдельный swing компонент.

    Ещё раз спасибо Витя за этот отличный комментарий!

    ОтветитьУдалить
  3. Вить, оцени новое видео. Уменьшил размер окна и увеличил шрифт.

    ОтветитьУдалить
  4. 2. Новое видео ок. В принципе, всё можно рассмотреть даже во встроенном в пост окошке (хоть и трудновато - может, окошко можно раскукожить слегка?). А на ютубе же всё ништяк видно даже без фулскрина.

    7. А жава что, не умеет выводить цветные буковки на консоль?

    8. Так а что мешает всплывающую консольку в консольном приложении?

    ОтветитьУдалить
  5. 2. Готово :)

    7. Хм.. хороший вопрос. Позже почитаю и попробую, если что отпишусь отдельным постом.

    8. Ничего, кроме времени :) Может так же позже попробую.

    ОтветитьУдалить
  6. Не понял, а чем csh не нравится?

    ОтветитьУдалить
  7. Ладно, опустим тот момент, что это должно работать ещё и на windows.

    1. Эти команды написаны на Java, так как делают очень специфичные вещи, например общаются с сервером по JMX. (наверно лучше было бы на каком-нибудь скриптовом JVM языке написать, но это уже мелочь)

    2. Я не в курсах про csh, но это вроде просто скриптовый язык. Он умеет читать разве ввод пользователя? Он сможет коплитить по табику? И историю моих команд (т.е. не csh каких-то, а моих сложных, которые из себя представляют в этом случае наверно несколько команд) подсказывать?

    Какие предложения? Я просто в никсах не селен, так что можно просто ткнуть носом в похожий как я написал manual, только для csh. Чтобы я мог описать какие-то свои команды, хоть на самом же csh, так уж и быть, а он мне потом автокомплит их аргументов делал и историю?

    ОтветитьУдалить