Каждому программисту в жизни приходилось писать небольшие консольные утилитки. Обычно это выглядит как бесконечный цикл чтения строки, парсинга её и выполнение. Но визуально это всегда выглядит не очень, да и пользоваться трудно. Но мне как-то пришлось писать такую консольную утилитку, с которой должны работать нормальные люди :) поэтому задумался (после соответсвующего пинка :))) над её усовершенствованием в стиле 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
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, чтобы не потерялись, да и смотреть исходники удобнее, качать непонятные архивы не надо и код говорит больше чем слова :)
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. А консольку как в кваке можно тильдой вызывать?
Виктор, огромное спасибо за отличный комментарий!
ОтветитьУдалить1. Ага, давно прикрутил, больше всего нравится древовидная структура.
2. Согласен, сегодня попробую переснять. К сожалению не спец в этом =/
3. Оки, спасибо. Буду стараться лучше комментировать.
4. Я смотрю ты там вообще активно форкаешся :)
5. Да с дизайном мучался. Расцветку кода специально брал тёмную под тёмный дизайн, так что не поменяю, не проси :) К сожалению у блогера все стандартные темы имеют фиксированную ширину, что в случае java кода усложняет форматирование. Ты прав, Жава такая жава :) В дальнейшем у меня планы писать о более интересных и красивых языках. Например clojure, думаю ты оценишь скобочки =D
6. Спасибо! :) С нативными языками у меня ещё остались проблемы, но я работаю над этим :)
7. Жава такая жава (c) :)
8. Таки это сразу чисто консольное приложение было. Наверно можно сделать и отдельный swing компонент.
Ещё раз спасибо Витя за этот отличный комментарий!
Вить, оцени новое видео. Уменьшил размер окна и увеличил шрифт.
ОтветитьУдалить2. Новое видео ок. В принципе, всё можно рассмотреть даже во встроенном в пост окошке (хоть и трудновато - может, окошко можно раскукожить слегка?). А на ютубе же всё ништяк видно даже без фулскрина.
ОтветитьУдалить7. А жава что, не умеет выводить цветные буковки на консоль?
8. Так а что мешает всплывающую консольку в консольном приложении?
2. Готово :)
ОтветитьУдалить7. Хм.. хороший вопрос. Позже почитаю и попробую, если что отпишусь отдельным постом.
8. Ничего, кроме времени :) Может так же позже попробую.
Не понял, а чем csh не нравится?
ОтветитьУдалитьЛадно, опустим тот момент, что это должно работать ещё и на windows.
ОтветитьУдалить1. Эти команды написаны на Java, так как делают очень специфичные вещи, например общаются с сервером по JMX. (наверно лучше было бы на каком-нибудь скриптовом JVM языке написать, но это уже мелочь)
2. Я не в курсах про csh, но это вроде просто скриптовый язык. Он умеет читать разве ввод пользователя? Он сможет коплитить по табику? И историю моих команд (т.е. не csh каких-то, а моих сложных, которые из себя представляют в этом случае наверно несколько команд) подсказывать?
Какие предложения? Я просто в никсах не селен, так что можно просто ткнуть носом в похожий как я написал manual, только для csh. Чтобы я мог описать какие-то свои команды, хоть на самом же csh, так уж и быть, а он мне потом автокомплит их аргументов делал и историю?