Pull to refresh

Построение приложений командной строки (CLI)

Reading time 30 min
Views 87K
Данная статья написана под влиянием книги Дэвида Коупленда «Build Awesome Command-Line Application in Ruby» (купить, скачать и изучить дополнительные материалы). Большая её часть будет посвящена проектированию дизайна CLI-приложений вне зависимости от используемого языка. По ходу будут обсуждаться и вещи специфичные для ruby, но не страшно, если вы его не знаете, кода будет не слишком много. Можно считать эту статью довольно подробным обзором вышеупомянутой книги с вкраплениями собственного опыта. Книжку рекомендую!

Для начала я задам вопрос. Если посмотреть на сообщества IT-шников, можно заметить, что несмотря на обилие программ с красивым графическим интерфейсом, приложения командной строки остаются весьма популярны. Почему?
Ответов несколько. Во-первых, это красиво удобно — если вы можете описать задачу командой в командной строке, то её гораздо проще автоматизировать, чем если вам приходится анализировать передвижения мыши и клики на разные пункты меню. Во-вторых, это даёт возможность комбинировать программы невероятным числом способов, чего сложно добиться с помощью графических интерфейсов.
В значительной степени философия Unix базируется на том принципе, что множество маленьких утилит, каждая из которых умеет делать свою конкретную задачу — это лучше, чем одна многофункциональная программа-универсал. И это одна из причин успеха Unix-систем в мире IT-шников.
Наверное, каждый понимает, что обычного пользователя вряд ли удастся сманить от GUI к CLI, давайте сосредоточимся на нас, «компьютерщиках» и конкретизируем наши пожелания к CLI-приложениям.

Общие требования


В двух словах, нам хочется, чтобы пользоваться ими было просто, но эффективно. Дэвид Коупленд прописал исчерпывающий список требований к приложениям, чтобы этого добиться:
  • Easy to use — Оно должно быть простым в использовании и иметь четкую цель. Желательно одну. Тулы, которые как швейцарский нож умеют все, как правило сложны в использовании и никто не знает всех их возможностей. Впрочем, про то, как проектировать многофункциональные приложения, чтобы сделать их проще в использовании и в поддержке, мы тоже поговорим.
    Минимум, который вы можете сделать для упрощения работы с вашей программой — это следование соглашениям о формате опций. Не заставляйте ваших пользователей переучиваться! О том, как принято указывать опции, и как их именовать, я напишу подробно.
  • Helpful — Это означает, что пользователь должен иметь простой доступ к хелпу о том, как что делает приложение, как его запускать, и как его настроить. Желательно, чтобы приложение добавляло свою страничку в man. Кроме того, не помешает интеграция с шеллом на уровне автодополнения команды.
  • Plays well with others — Приложение должно быть способным взаимодействовать с другими приложениями. Это означает модульность приложений, как принято в Unix. Не следует пренебрегать кодами возврата, продуманной работой с потоками ввода-вывода и не только.
  • Has sensible defaults but is configurable — Стандартные сценарии использования должны быть доступны без указания тысячи опций. Нестандартные сценарии не обязаны быть простыми в использовании, но должны быть все-таки доступными. Кроме того, набор опций-по-умолчанию должен быть настраиваемым.
  • Installs painlessly — Легко устанавливается вместе со всеми зависимостями, устанавливает путь к приложению в переменные окружения для более простого запуска. Обновления должны происходить так же легко.
  • Fails gracefully — В случае ошибки в вызове приложения, оно должно сообщить, в чем была ошибка и как её исправить. Кроме того, приложение должно быть non-destructive, т.е. не должно перезаписывать или стирать файлы, если в аргументах допущена ошибка (а идеально, если оно вообще не будет выполнять опасные операции без подтверждения)
  • Gets new features and bug fixes easily — Приложение должно быть поддерживаемым. Дробите приложение на модули и раскидывайте их по разным файлам. Пишите тесты. Пользуйтесь семантическим версионированием. Используйте систему контроля версий.
  • Delights users — Вывод приложения должен быть приятным глазу. К вашему распоряжению цвета, форматирование (например, табличное или html). Также сюда входит интерактивное взаимодействие с пользователем.

Теперь пройдусь по этим пунктам более подробно.

Easy to use


Утилитки и программные пакеты. Что удобнее?

Итак, все приложения можно условно поделить на два типа: утилитки и программные пакеты (в оригинале, Command Suite).

Первый тип — это приложения, которые имеют одну цель, один режим работы. Примеров этому типу программ бесчисленно, почти всех Unix-команды такие: ls, grep, diff,… (рубисты могут вспомнить, например, команду rspec) Удобство этих программ в том, что их возможности проще запомнить и труднее в них запутаться. Кроме того, их проще склеивать в цепочки для последовательной обработки. Тут будет уместной следующая аналогия. Представьте, что вы строите дом, притом дом не типового образца. Гораздо удобнее строить его из кирпичей, а не из монолитных блоков, ведь кое-где вам эти блоки пришлось бы подпиливать, а где-то пришлось бы заделывать стыки камнями. Да и блоки можно только подъемным краном тягать, тогда как кирпичи можно класть руками.

Второй тип программ можно сравнить с швейцарским ножом или кухонным комбайном. Иногда они крайне удобны. Посмотрите на git (в мире руби сразу вспоминаются gem, rails, bundle) — одна программа, а сколько всего умеет. И коммитить/чекаутиться может, и ищет в истории сама, и изменения между файлами считает. Так что grep, diff и прочее в неё встроено, ничего комбинировать с гитом и не надо, сам всё умеет. Если вернуться к аналогии с домом, то у гита есть типовой проект на каждый случай жизни (и попробуй ещё запомни их все).
И всё же, не всем программам стоит быть многофункциональными: всё равно все варианты использования вы не переберете. В подтверждение этого тезиса предлагаю вам представить себе «мультитул», который умеет делать cd, ls, pwd, diff, df и ещё кучу полезных операций одной командой, только опции надо будет слегка менять (например, filesystem change, filesystem show, filesystem where итд). Будете такой пользоваться? Думаю, что выкинете за излишнюю громоздкость. Хотя программные пакеты и бывают чрезвычайно удобными, имеет смысл хорошенько подумать, прежде чем писать своего кентавра с восемью щупальцами.

Кстати, если вы написали десяток утилит, а потом поняли, что хотите, чтобы это был программный пакет, то это не так уж и сложно поправить. Достаточно написать обертку, которая будет маршрутизировать команды. Вы, возможно не знаете, но git состоит из десятков утилиток типа git-commit, git-show, git-hash-object, git-cat-file, git-update-index итд, которым передает управление команда git, основываясь на типе команды и опциях (разумеется, за одной командой может стоять целая цепочка из вызовов утилит). Так что даже крупные проекты начинать лучше с набора небольших программ, которые вы в дальнейшем будете комбинировать. Их проще писать, отлаживать, поддерживать и использовать.

Синтаксис аргументов командной строки или «в этом доме есть свои традиции».

Начну с терминологии. Когда вы запускаете приложение командной строки, вы указываете какой-то набор параметров. Они делятся на следующие типы: опции и аргументы. Дэвид Коупленд дополнительно делит опции на два подтипа: флаги и свитчи.
Схематично можно изобразить это следующим образом executable [options] <arguments>. Думаю, что все и так в курсе, но на всякий случай поясню, что параметры в квадратных скобках опциональны, а в угловых — обязательны.

Аргументы (или позиционные аргументы) — это параметры, которые необходимо указать для работы программы. Их порядок сторого определен.
Опции — это не обязательные параметры. Они могут быть указаны в любом порядке.
Опции типа свитч — это булевы опции, программа проверяет их наличие или отсутствие. Пример: --ignore-case (или, иногда, --no-ignore-case).
Опции типа флаг — это опции с параметром. Этот параметр может иметь значение по-умолчанию. Например, grep -C ... и grep -C 2 ... в моей версии утилиты grep эквивалентны.
И аргументы, и опции могут иметь значения по-умолчанию, но могут и не иметь.

В качестве примера, в команде grep --ignore-case -r -С 4 "some string" /tmp аргументами являются "some string" /tmp, а опциями --ignore-case -r -С 4. При этом --ignore-case и -r — это свитчи, а -C 4 — это флаг.

Соглашения по использованию опций

Опции могут быть указаны как в короткой -i, так и в длинной --ignore-case форме. В принципе, ничто не мешает вам развлекаться с форматом опций как угодно, ведь опции командной строки вы можете перехватить в скрипте напрямую. Но лучше все же придерживаться выработанных Unix-сообществом правил, так как они отточены временем, и большинство людей привыкло к ним. Кроме того, существуют готовые библиотеки, позволяющие удобно работать с этими опциями
Эти правила таковы:
  • Длинная опция (--long-option) начинается с двух дефисов. В названии опции не может быть пробелов, зато одиночные дефисы вполне допустимы.
  • Короткая опция (-l) состоит из одной буквы (как правило, регистр имеет значение) и предшествующего ей дефиса — одного. Удобно, когда короткая опция — это первая буква длинного аналога, так проще запомнить её значение.
  • Несколько коротких опций можно объединить вместе следующим образом: ls -a -l эквивалентно ls -al. После одиночного дефиса может идти сколько угодно опций без аргументов
  • Если у короткой опции есть параметр, обычно он может идти как сразу после опции, так и отделяться от неё пробелом. -C4 или -C 4. Я не случайно сказал «обычно». Так, например, стандартная руби-библиотека optparse обрабатывает эти два случая одинаково. Утилита ls обрабатывает сходные опции также одинаково. А вот утилита grep, например, считает, что -C отдельно, а 4 — отдельно. Может быть, это баг. Я не мог не упомянуть, что исключения бывают, но пользователи вряд ли будут вам благодарны, если ваша программа станет ещё одним исключением.
    Я не знаю, как обычно поступают в случае, когда короткая опция имеет нечисловой параметр со значением по-умолчанию (-c [param]), ведь -cxyz можно трактовать и как -с xyz, и как -c -x -y -z. Пользователю лучше всегда писать пробел в случае наличия у опции необязательного параметра. Программисту лучше заранее подумать о том, как минимизировать проблемы связанные со слитным написанием опций.
  • В случае длинной опции обычно допускается использование пробела перед параметром, но желательно использовать знак равенства. ls --width=10 или ls --width 10
    Без разделительного символа после длинной опции параметр не указывают (сами посудите, какая путаница получится, особенно если параметр не числовой).
  • Каждая опция может иметь как короткую форму, так и длинную. А может быть и так, что есть только короткая или только длинная. Впрочем, наличие длинной формы для каждой из опций крайне желательно.
  • Для булевых опций можно указать опциональный префикс no-, например: --[no-]pager. Опция --pager задает разделение на страницы, --no-pager указывает, что разделения на страницы быть не должен, а отсутствие опции сохраняет значение по-умолчанию. Это особенно важно в случае, когда опции по-умолчанию конфигурируемы. Без префикса, например, невозможно было бы отменить значение опции заданной по-умолчанию.


Почему желательно всегда иметь длинный вариант опции

Длинная форма опций, как правило, используется в написании скриптов, использующих ваше приложение. Длинные опции следует делать самодокументирующимися. Представьте, что сисадмин заглядывает в cron и видит там запуск задачи бэкапа БД с непонятными опциями -zsf -m 100. Если он не автор скрипта, то ему придется залезать в хелп, чтобы понять, что имеется в виду. Согласитесь, набор опций --scheme --max-size 100 --gzip --force скажет ему гораздо больше и не заставит его тратить лишнее время.
Кроме того, не рекомендуется делать короткие опции для редко используемых опций. Во-первых, букв в алфавите не так уж много, чтобы тратить их на все подряд опции. Во-вторых, и это даже важнее, отсутствие короткой опции подсказывает пользователю, что эта опция второстепенна или даже нежелательна при нормальной работе, и что не надо использовать её бездумно, просто потому что можно.
Итак, часто используемые опции имеют и короткую форму, и длинную, а редко используемые — только длинную.

Различия в параметрах командной строки для однофункциональных и многофункциональных приложений

Эти два типа приложений имеют немного разный порядок аргументов при вызове.
Для первого типа приложений вызов обычно выглядит так: executable [options] <arguments>
Для программных пакетов формат несколько сложнее: executable [global options] <command> [command options] <arguments>
Приведу пример из книги: git --no-pager push -v origin_master Здесь --no-pager — это опция, которая может быть применена к любой команде git'а, а -v — это опция специфичная для команды push. Следует отметить, что глобальные опции и опции специфичные для команды могут иметь одинаковые имена. Но в таком случае для их обработки подойдут не все средства, так например стандартная руби-библиотека OptionParser не справится с этой задачей, так как не различает в каком месте месте встретилась опция. Если вы делаете программный пакет, воспользуйтесь лучше библиотекой GLI.

На всякий случай напомню читателю, что технически программы на вход получают не строку параметров, а уже разделенный на элементы массив параметров. В ruby этот массив называется ARGV. Именно с ним мы и работаем, напрямую или при помощи специальных библиотек. Надо отметить, что разбит он на элементы не по пробелам (иначе не могло бы быть, например, имен файлов с пробелами), у shell'а чуть более сложные правила. Там участвует и экранировка символов, и использование кавычек для группировки. Если вам потребуется экранировать, склеивать или разрезать строки параметров в массивы и обратно — посмотрите на стандартную библиотеку shellwords. Она как раз состоит из трех методов для этих целей: String#shellsplit, String#shellescape и Array#shelljoin.

В сообществе ruby большая часть приложений командной строки использует либо OptionParser, либо библиотеки на его основе. В разделе про конфигурацию — когда мы узнаем чуть больше — я приведу пример кода, сделанного написанного с помощью OptionParser.

Helpful


Представьте, что вы впервые видите программу awesome_program, которую вы собираетесь использовать. Будучи опытным пользователем, вы наверняка наберете awesome_program --help в надежде увидеть порядок аргументов, набор опций и примеры использования. Когда вы выпускаете свою программу, помните, что пользователь, который впервые её увидел, наверняка первым делом сделает то же самое, поэтому пусть у вас будут ключи -h, --help наготове. Если вы используете библиотеку типа OptionParser, в строку подсказки у вас автоматически будет внесено перечисление всех опций, которые программа распознает с теми описаниями, которые вы дадите.

Помимо строки подсказки имеет смысл написать расширенную справку в man. Впрочем, насколько я знаю, rubygems автоматически не устанавливает странички в man. Однако существует гем gem-man, который позволяет показывать странички man-документации для установленных в системе гемов.
Для того, чтобы создать man-документацию необходимо создать файл в непростом формате nroff. Для упрощения задачи воспользуйтесь библиотекой-конвертером ronn, которая позволяет писать странички документации в более простом формате. Когда всё будет готово, вы можете воспользоваться командой gem man awesome_gem — и увидеть строку помощи. Кроме того, вы можете прописать alias gem='gem man -s'. При этом команда man заменяется командой gem man, что позволяет искать man-ом помощь по гемам. По тем запросам, которые gem-man обработать не смог, происходит автоматическое перенаправление на соответствующую страничку обычного man-а.
Если соберетесь делать свои man-подсказки, загляните в книгу, там этому уделяется значительно больше внимания.

Чтобы ещё больше облегчить пользователю запуск команды, можно сделать автодополнение команды на уровне шелла (работает не во всех шеллах). Это позволит по нажатию на кнопку tab автоматически дополнять названия команд, имена файлов итд. У пользователя будет меньше шансов сделать орфографическую ошибку, и он потратит намного меньше времени на написание команды.

Для того, чтобы сделать tab-completion и хранить историю команд внутри программы (в интерактивном режиме в программах типа irb) достаточно воспользоваться встроенной в руби библиотекой readline. Она позволяет автоматически сохранять историю всех введенных команд — за счет использования команды Readline.readline вместо gets.

Tab-completion делается следующим образом: методу Readline.completion_proc=(block) передается блок, возвращающий массив возможных дополнений по строке уже введенного текста, вот и вся задача. Например:
Readline.completion_proc = proc { |input|
  allowed_commands.grep /^#{input}/
}


Если вам нужен tab-completion не на уровне уже запущенной программы, а на уровне шелла, то это несколько сложнее. Вам придется повозиться с файлом .bashrc.
Во-первых, добавьте к нему строчку complete -F get_my_app_completions my_app
Теперь каждый раз, когда вы набираете my_app(пробел)[какой-то текст], а затем клавишу tab — будет вызываться функция get_my_app_completions. Эта функция должна вернуть возможные варианты автодополнения в переменную COMPREPLY, которой шелл воспользуется, чтобы предоставить пользователю возможные варианты дополнения. В файле .bashrc надо определить эту функцию. Приведу пример из книги для приложения todo:
function get_todo_completions()
{
  if [ -z $2 ] ; then
    # получаем список команд
    COMPREPLY=(`todo help -c`)
  else
    # получаем список возможных аргументов для команды, указанной в $2
    COMPREPLY=(`todo help -c $2`)
  fi
}
complete -F get_todo_completions todo

Теперь в программе надо реализовать следующее поведение (оставим это как легкое упражнение):
1) если вручную набрать в командной строке todo help -c, вы должны увидеть список команд приложения: list, add, complete, help (каждое на своей строке)
2) если набрать todo help -c complete — вы должны увидеть список всех дел, которые начаты, но еще не завершены (тех, к которым можно применить команду complete).

help -c [...] — это служебная команда, её наличие можно не афишировать в краткой справке. Предполагается, что её будет использовать не пользователь, а тот скрипт в .bashrc.
В этом скрипте функция спрашивает у самого приложения, что можно подставить, исходя из того, что уже набрано (этот передается после опции help -с). Программа отслеживает ситуацию, когда приложению указывают такой специальный набор параметров, и выводит список всех вариантов в стандартный вывод (как вы уже видели), откуда они направляются прямиком в переменную шелл-скрипта COMPREPLY.
Автор в книге пользуется собственной библиотекой GLI, которая автоматически отслеживает такой набор опций. Вы легко можете реализовать эту возможность и без помощи GLI. Как вы видите, здесь нет никакой магии.

Plays well with others


Не будем обсуждать вопрос о необходимости удобного взаимодействия программ, каждый, кто работал в Unix может сам оценить, насколько это важно. Главный вопрос — как добиться этого?

Коды возврата

Во-первых, используйте коды возврата. В случае успешного завершения программы — 0, в случае ошибки — различные ненулевые коды возврата. Это важно, потому что шелл-скрипты могут определить, нормально ли отработала программа, запросив статус последней завершенной программы из переменной $?. Чтобы вернуть статус возврата, в ruby используется метод exit(exit_status).
Если разным ошибкам назначать разные коды возврата, то есть шанс, что другая программа, использующая вашу, сможет принять решение о том, можно ли устранить проблему, и стоит ли вообще на неё обращать внимание. Снаружи программы лучше видно, страшно ли, что программа «упала» или нет. Различные коды возврата — это как различные классы исключений, может у вас ошибка в том, что оперативной памяти нет, а может всего лишь сеть пропала на секунду и стоит попробовать ещё разок. Некоторые программы могут упасть одновременно от нескольких ошибок. Если у вас есть необходимость сообщить сразу о нескольких проблемах — воспользуйтесь битовыми масками. В сети есть рекомендации о том, какие коды возврата принято использовать для каких ошибок, об этом можно почитать на сайтах GNU (весьма общо) и FreeBSD (очень конкретно). Если не хочется заморачиваться кодами ошибки, сделайте хотя бы минимальное усилие — верните хоть какое-нибудь ненулевое значение в случае ошибки. Иначе другие программы даже не смогут узнать, нормально ли отработала ваша программа.

Кстати, вы можете запустить программу не только из шелл-скрипта, но и из ruby-скрипта. Есть несколько вариантов сделать это, например Kernel.system или IO.popen Подробнее про них почитайте в документации. Если вы вызываете другую программу с помощью system, то можете узнать её код возврата в аналогичной shell-у переменной $?.

Потоки ввод-вывода и поток ошибок. Пайпы

Главный способ взаимодействия программ, запускаемых из командной строки — пайпы (pipes). Пайп — это способ перенаправить вывод одной программы на вход другой. Обозначается пайп вертикальной чертой |. Например, в команде ls | sort, первая часть — ls не выводит ничего на экран, а вместо этого перенаправляет свой вывод на вход программе сортировки. А программа sort забирает текст из входного потока построчно и уже осортированный список выводит на экран. С одной стороны, ls и сама могла бы отсортировать файлы, но она для этого не предназначена. В то же время sort нужна именно для этой цели и имеет множество опций. Например, вы можете отсортировать строки не совсем лексиграфически, если названия начинаются с числа (иначе порядок будет следующим: 1.jpg, 10.jpg, 100.jpg, 2.jpg, ...). Или отсортировать в обратном порядке. Кроме того, с помощью специальных программ (типа awk, sed) перед сортировкой строки можно подправить (например, стереть префиксы). Следует отметить, что пайп может быть составлен из произвольного числа программ. Так ls | sort -n | tail выведет последний десяток строк списка файлов, отсортированного по номеру.

Подумайте о том, для чего в вашей программе могут понадобиться потоки ввода и вывода. Что вы пишете в поток вывода, а что — в поток ошибок. Разберемся сначала со вторым вопросом: чем отличаются потоки вывода(stdout) и ошибок(stderr)? Тем, что один поток идет в пайп, а другой — нет. Поток stderr используют для вывода не только ошибок, но и для вывода любой информации о процессе работы, такой как стадия выполнения программы, отладочная информация итп. Эта информация не должна попасть на вход другой программы, она нужна лишь для удобства пользователя. Поток stdout используется для всей остальной информации, такой как результат работы программы.

Необходимо подумать о формате вывода, поскольку с выводом вашей программы предстоит работать не только человеку, но и машине. Имеет смысл сделать опцию --format=<plain|csv|pretty|html|table|...>. При указании human-readable формата (pretty/html/table), вы можете выводить информацию так, как приятно глазу, не думая об удобстве парсинга вывода. Когда вы указываете machine-readable формат (plain/csv), вам совершенно не важно, красиво выглядит результат или нет — главное, чтобы его легко было распарсить. Для удобства парсинга используйте tab-delimited values или comma-separated values(csv), или формат одна строка — одно значение, или другой формат, который вам больше подходит. О том, как сделать максимально приятный для человека вывод, мы ещё поговорим в пункте Delight Users.
Указывать формат вывода при каждом запуске программы пользователю не захочется, а выбрать один на все случаи жизни тоже не всегда возможно. Есть трюк, который поможет автоматически определить, предназначен ли вывод для глаз человека или машины, а именно метод IO#tty?. Вызов $stdout.tty? скажет вам, направлен ли ваш вывод в терминал или нет. Если нет, значит вывод вашей программы направлен в пайп или перенаправлен в файл (следующим образом: ls > output.txt). Для вывода, направленного в терминал, и для вывода в перенаправленный поток можно выбрать разные варианты форматирования по-умолчанию: options[:format] = $stderr.tty? ? 'table' : 'csv'
А если вы захотите, например, вывести в файл результат в формате вывода, предназначенном для человека, просто укажите формат явно.

Теперь поговорим о входном потоке. Какие данные должна принимать программа из входного потока? Это, конечно, зависит от специфики программы. Давайте подумаем, какие данные могут прийти во входной поток? Очевидный ответ — те, которые есть на выходе у другой программы. Например, у меня есть программа, которая конвертирует файл матрицы в другой формат и записывает в файл с другим расширением. Имеет ли смысл принимать матрицы из входного потока? На мой взгляд, не имеет: каким образом и зачем эта матрица попадет во входной поток? Гораздо удобнее будет принимать пачку имен файлов, чтобы сразу обработать множество файлов. Это те данные, которые может выдать, например, команда ls.
Можно поспорить с этой точкой зрения, ведь можно написать скрипт, который будет перебирать список файлов и запускать программу несколько раз. Но тогда вы лишаетесь возможности сделать это прямо из командной строки одним перенаправлением ввода-вывода, вам придется писать цикл. Кроме того, некоторые программы долго стартуют и быстро работают, поэтому на сотню матриц вы можете потратить не одну секунду, а сто одну (увы, это вполне реальный — и даже оптимистичный — масштаб времени при использовании множественных запусков скрипта из гема на Windows-системе). Но, в любом случае, выбор за вами. Делайте то, что целесообразно и не забудьте описать это в мануале.
Кстати, пытается ли другая программа передать данные на вход вашего скрипта можно, вызвав уже знакомый нам метод $stdin.tty?

Сигналы

Наконец, заслуживает упоминания ещё один способ, посредством которого программы могут общаться (не беря в расчет сокеты и всё с ними связанное) — сигналы. Пусть у вас есть долгоиграющий процесс, например, веб-сервер, и вам необходимо попросить его прочитать новую конфгурацию, не перезагружаясь. Вы можете послать ему сигнал (обычно SIGHUP), а он, перехватив его, сделает то, о чем его просят. Или повесить обработчик на сигнал SIGINT, который будет аккуратно завершать работу программы по нажатию Ctrl+C. Всё это достигается методом Signal.trap. Этот метод принимает в качестве аргументов имя сигнала и блок, который выполняется, когда программе приходит указанный сигнал. Работает это (как и перенаправление потоков, кстати) во всех POSIX-системах, т.е. и в Unix, и в Windows. Возможно, однако, что набор поддержживаемых сигналов в Windows будет меньше, чем в Unix, так что если вы добиваетесь кросс-платформенности приложения, сигналы — место, подлежащее тщательному тестированию.
Вот — пример того, как сделать так, чтобы по нажатию Ctrl+C программа сначала подчищала за собой недоделанные файлы, а уже затем закрывалась, вернув код ошибки:
Signal.trap("SIGINT") do
  FileUtils.rm output_file
  exit 1
end



Has sensible defaults but is configurable


Повторю, стандартные сценарии использования должны быть доступны без указания тысячи опций. Нестандартные сценарии не обязаны быть простыми в использовании, но должны быть все-таки доступными. Кроме того, набор опций-по-умолчанию должен быть настраиваемым.

Про стандартные сценарии всё понятно. Необходимо продумать, для чего программа будет использоваться и выбрать самые популярные параметры — параметрами по-умолчанию.
У всех пользователей потребности немного разные, так что подумайте о как можно большем числе вариантов использования вашего скрипта. Если ваше приложение будет выполнять одну задачу, но будет гибко настраиваться (в разумных пределах), пользователи скажут вам спасибо. Нестандартные сценарии должны быть, если они имеют применение. Не страшно, если для их выполнения придется указать множество опций — пользователь два раза подумает, для того ли предназначен ваш скрипт. Напомню о рекомендации делать редкие опции длинными и не предоставлять короткой версии для таких опций (--use-nonstandard-mode вместо -u).

На последнем пункте остановлюсь подробнее. Что значит «набор опций-по-умолчанию должен быть настраиваемым»? Представьте, что вашей программой пользуются множество людей и делают это часто. К примеру, вы написали утилиту для бэкапа БД. Ваш сисадмин пользуется утилитой каждый день и использует набор опций по-умолчанию (например, --no-scheme --gzip), просто набирая db_backup my_db.
Но кроме админа программой пользуются ваши коллеги разработчики, у которых схема БД меняется каждый день. И они каждый день вынуждены писать db_backup --scheme my_db, им нельзя забыть этот ключик. Вы, возможно, будете правы, если скажете, что сисадминские настройки важнее и будут настройками по-умолчанию… но в действительности там будут ещё и опции --login, --password, --host, --force, и такой набор параметров уже сложно воспроизвести без ошибок даже сисадмину, у которого остальные настройки идут по-умолчанию. Не заставляйте ни сисадмина, ни программиста каждый раз вводить все эти параметры и думать о настройках, ведь можно сделать значения по-умолчанию конфигурируемыми.

Для этого служат файлы вида ~/.myapp.rc. В файле нет никакой магии, это лишь соглашение. Каждый пользователь в своём домашнем каталоге может создать файл с предпочтительными для него настройками по-умолчанию. В домашнем каталоге — чтобы разные пользователи могли задавать разные умолчания. Точка в начале файла — чтобы сделать его скрытым. Расширение .rc — дань традиции.
Что должно храниться в этом файле? Просто перечисление тех опций, которые отличаются от стандартных значений по умолчанию. Для этого конфигурационного файла крайне удобно использовать формат YAML. Приведу пример:
---
:gzip: false
:force: true
:user: "Bob"
:password: "Secr3t!"


Рассмотрим, как эти опции загрузить.
require 'yaml'
require 'optparse'

# опции по-умолчанию
options = { 
  :gzip => true,
  :force => false
}

# путь к файлу конфигурации 
# (при тестировании его можно будет подменить, используя переменную окружения HOME)
CONFIG_FILE = File.join(ENV['HOME'],'.db_backup.rc.yaml')

if File.exists? CONFIG_FILE
  # загружаем опции из файла
  config_options = YAML.load_file(CONFIG_FILE)
  # и обновляем хэш опций по-умолчанию значениями из загруженного файла
  options.merge!(config_options)
end
    
# а теперь обрабатываем аргументы командной строки
# этот парсер не допускает никаких типов опций, кроме перечисленных явно
option_parser = OptionParser.new do |opts|
  # заголовок в строке подсказки. 
  #__FILE__ указан как имя скрипта, чтобы при переименовании скрипта подсказка автоматически менялась
  opts.banner = "Usage: #{__FILE__} [options] <db_name>"

  # встретив опцию -u или --username мы выполняем блок, передавая ему значение параметра
  opts.on("-u USER", "--username", "Database username, in first.last format") do |user|
    options[:user] = user
  end

  # обратите внимание на третий аргумент, он используется для строки подсказки
  opts.on("-p PASSWORD", "--password", "Database password") do |password|
    options[:password] = password
  end

  # здесь у опции нет параметра, так что это булева опция. Она либо есть, либо её нет. 
  # Наличие опции --gzip вызывает блок и устанавливает значение true.
  # В отсутствие опции блок не вызывается, так что остается значение по-умолчанию
  # Чтобы задать опции значение false необходимо передать скрипту опцию --no-gzip
  opts.on("--[no-]gzip", "Compress or not the backup file") do |gzip|
    options[:gzip] = gzip
  end
end
    
# В следующей строке из массива вычленяются опции, описанные в объекте option_parser.
# В результате в ARGV остаются только позиционные аргументы, 
#   а переданные опции при обработке заполняют хэш options 
#   (это явно прописано в блоках обработчиков)
option_parser.parse!(ARGV)

# считываем позиционные аргументы
db_name = ARGV.shift


Если у вас стоит задача настройки программного пакета — это делается таким же конфигурационным файлом. Только в хэше на этот раз должны быть как глобальные опции, так и опции каждой команды — во вложенном хэше.
---
:filename: ~/.todo.txt
:url: http://jira.example.com
:username: davec
:password: S3cr3tP@ss
:commands:
  :new:
    :f: true
    :group: Analytics Database
  :list:
    :format: pretty
  :done: {}


Есть и другие способы записи данных в конфигурационный файл, YAML — просто один из самых легко читаемых. Так или иначе, настройка через конфигурационные файлы — широко используемая техника. Так настраиваются, например, утилиты gem и rspec, а также git.

Installs painlessly


Даже очень хорошее приложение никто не будет использовать, если процесс его установки слишком сложен. К счастью, в мире руби есть rubygems — менеджер пакетов, который позволяет устанавливать и обновлять программы в одну строчку:
gem install/update gemname

Гем — это пакет, который содержит исходные коды, а также информацию о номере версии и авторе, описание пакета, а также набор зависимостей (какие версии каких гемов используются вашей библиотекой или вашим приложением). По-умолчанию все гемы публикуются на сервере rubygems.org. Благодаря этому при установке гемов вам не надо искать, откуда скачать пакет, он находится на сервере общем для всех (разумеется, можно отдельно настроить корпоративный сервер гемов, чтобы не отдавать свои гемы в посторонние руки) и программа gem автоматически использует его для выкачивания гемов.
Когда вы выполняете команду gem install, менеджер пакетов ищет на сервере rubygems пакет с заданным названием и узнает, какие другие гемы (и каких определенных версий) ему требуются для работы. Затем он выкачивает все необходимые пакеты и устанавливает их, компилирует нативный код, делает возможным запуск файлов, указанных как исполняемые (это могут быть и руби-скрипты, не только бинарники). В системе один гем может стоять в любом числе версий, каждая версия может иметь свой набор версий зависимостей и не вызывать конфликтов. Например, если у вас стоит rails 2.3.8 и rails 3.2, каждый из них будет обращаться к своей версии activesupport-а, той, с которой он согласован.

Про то, как создавать гемы я писать не буду, об этом на хабре уже была отличная статья, если вы ещё не умеете этого делать — прямо сейчас оторвитесь от моей статьи и уделите полчаса своего времени этому вопросу. Это очень просто, очень удобно и жизненно необходимо, если вы собираетесь заниматься ruby и дальше.
Когда вы будете создавать свой гем, вам предстоит указывать номер версии. Есть весьма последовательное соглашение под названием «семантическое версионирование». Формат версий состоит из трех чисел: Major.Minor.Patch. Младшее число — патч-левел отвечает только за багфиксы. Среднее число меняется при изменениях API, являющихся обратно-совместимыми. И старшее число меняется при внесении изменений, рушащих обратную совместимость. Заметьте, не цифра, а число, так что номер версии легко может быть таким: 1.13.2.
Семантическое версионирование полезно тем, что в зависимостях можно указывать не точную версию гема, а версию с точностью до патч-левела или до minor-версии. Таким образом вы получаете возможность, ничего не делая с вашим собственным пакетом, получать исправления, устраняющие баги в пакетах зависимостей. Но в то же время вы имеете возможность запретить версиям зависимостей измениться слишком сильно, чтобы не получить с очередным апдейтом изменения API, несовместимые с вашим пакетом.

Теперь — пара слов про исполняемые файлы. Все файлы, которые вы поместили в папку bin своего гема, считаются исполняемыми (если для создания гема вы используете bundler в конфигурации по-умолчанию). При установке пакета в папке .../ruby/bin создается что-то вроде ссылок на эти файлы (на самом деле, создается специальный руби-скрипт, который знает, где искать исполняемый файл). Фокус в том, что эта папка при установке ruby попадает в переменную окружения PATH — и таким образом становится одним из мест поиска исполняемых файлов. Таким образом все исполняемые файлы из гема становятся доступными из любого места системы.
Под Windows процесс, как я понял, чуть сложнее — вокруг этого файла создается ещё и bat-обертка, которая передает управление самому скрипту. Впрочем, от программиста и от пользователя все эти детали скрыты.

Что должно быть в исполняемом файле?
Во-первых, хотя это и руби-скрипт, не нужно ему указывать указывать расширение .rb. Ни на Unix, ни на Windows. Это только будет сбивать пользователя, а для удачного запуска скрипта это совершенно необязательно.
Во-вторых, в первой строчке должно быть написано: #!/usr/bin/env ruby
Обратите внимание на то, что в этой строке путь не /usr/bin/ruby. Использование env позволяет обнаружить руби, даже если он расположен в другой папке, что просто необходимо при установленном rvm.
В-третьих, всю логику скрипта лучше вынести в отдельный файл, например lib/my_exec.rb, а из исполняемого файла получить её с помощью require. Подробнее прочитать об этом можно в статье про изготовление гемов, которую я упоминал выше. В итоге исполняемый файл выглядит так:
#!/usr/bin/env ruby
require 'rubygems' # не нужно для ruby 1.9 и выше
require 'your-gem'
require 'your-gem/my_exec'


Какие неприятности вас ждут, если вы решите не собирать свой гем-пакет? Ну, кроме очевидной лишней головной боли по контролю зависимостей, вас ждет ещё один неприятный сюрприз. Представьте, что вы написали скрипт и пишете в командной строке my_app value1 value2. Какой список аргументов вы ожидаете? Вероятно, ['value1', 'value2']. Что в действительности вы имеете? В Unix всё будет, как вы и ожидаете. А запуская скрипт в Windows вы имеете пустой список аргументов, т.к. my_app не воспринимается в Windows, как программа, которая может получить аргументы (вместо неё аргументы обычно получает программа ruby.exe). Для того, чтобы скрипту передать аргументы, необходимо запускать скрипт, приписав в начале слово ruby. Т.е. каждый запуск программы будет выглядеть так: ruby my_app value1 value2, а если вы забудете слово ruby, то имеете шансы даже не понять, почему ничего не работает.

Помните, я упоминал, что rubygems создает bat-обертку? Это нужно потому что bat-файл аргументы командной строки понимает и может передать их скрипту, вызвав скрипт надлежащим образом. Таким образом rubygems решает эту проблему. Но есть и ложка дёгтя: такой каскад вызовов существенно замедляет старт приложения. Иногда программа Hello World запускается пять секунд. После того как приложение запущено, всё работает с нормальной скоростью, но процесс запуска приложений неприятно долгий (насколько я понимаю, Windows-процессы вообще более тяжеловесны, чем Unix-процессы). Это может раздражать, когда вы после каждого изменения кода запускаете, например, rspec. Или каждые пять минут тратите пять секунд дожидаясь реакции git-а (который тоже страдает от этой проблемы, хоть и написан не на ruby). Но это цена, которую приходиться платить за совместимость программ с Windows, по-другому — никак.


Fails gracefully


Тут всё совсем просто.
Ваш скрипт упал. Выведите в stderr сообщение об ошибке, предложите возможные варианты решения (например, подскажите, какой аргумент пропущен или какие опции конфликтуют). Если надо, выведите справочную строку с описанием использования. И уж точно программа не должна пытаться выполнить какие-либо действия, если аргументов для их выполнения не хватает.
Ваш скрипт записывает что-то в файлы или, может, стирает файлы? Если да, убедитесь, что он не перезаписывает существующие файлы. Если файл существует, скрипт должен сказать об этом и попросить подтверждение модификации файла, либо предложить использовать ключ --force. Ну и пусть программа выведет сообщение о том, какой файл он перезаписала или удалила.
Пользователь указал странные опции? Попросите его подтвердить, что он имел ввиду ровно это. Представьте себе команду rm -rf * .log Этот случайный пробел будет вам очень дорого стоить, так что переспросите пользователя, если есть подозрения, что программа может вести себя излишне деструктивно.


Gets new features and bug fixes easily


Поддержка кода — слишком широкое понятие, чтобы его полноценно описать. В двух словах, делайте приложение модульным, разбивайте на отдельные файлы. Если необходимо, разбивайте на несколько отдельных гемов. Для того, чтобы устранять баги и не допускать новых, необходимо писать тесты. И вот тут могут быть проблемы…
Дело в том, что тестирование обычно предполагает изолированность тестов, а при работе с файловой системой (что часто является целью утилит) этого добиться непросто. Вам может понадобиться создавать, удалять и перезаписывать файлы, а также восстанавливать состояние файловой системы после каждого теста. Это всё может быть крайне неприятным и долгим делом (работа с HDD — вообще не быстрое дело). Есть по-меньшей мере два решения проблемы.
Первое решение — гем aruba, предоставляющий специфичные сценарии Cucumber для тестирования CLI. Сценарии для наполнения файлов содержимым, для очистки, для проверки существования файла, а также сценарии для запуска приложения с определенными аргументами, проверки кода возврата, содержимого потоков ввода-вывода итп.
Второе решение (которое лично мне нравится значительно больше) — обычные TestUnit или rspec-тесты вместе с гемом fakefs. Этот гем подменяет классы, работающие с файловой системой, и создают виртуальную файловую систему в оперативной памяти — со своей структурой папок, своими файлами с тем содержимым, которое вы пожелаете туда занести. Никаких mock-ов создавать не надо, весь класс File, Dir (и сопричастные) превращаются на время в один большой фэйк, так что код программы вообще не нужно менять, чтобы протестировать поведение. Никаких следов в файловой системе после работы не остаётся. Красота! Мы включаем режим фэйковой файловой системы, загружаем (не запускаем приложение, используя Kernel.system, а именно загружаем) скрипт из нашего файла lib/my_exec и проверяем результаты.

Как, не используя aruba, проверить, что программа выводит на экран? Для этого надо подменить потоки stdout и stderr на объекты класса StringIO. Тогда после работы программы можно будет проверить содержимое этих «потоков». Вы можете использовать готовое решение: out, err = MiniTest::Assertions.capture_io{ ... } из стандартной библиотеки тестирования, а можете сами попробовать написать код для перехвата содержимого потоков, он совсем не сложен.
Важное замечание! В руби есть две переменные для потоков ввода-вывода: константа STDOUT и глобальная переменная $stdout. Когда вы будете подменять потоки с помощью StringIO, пользуйтесь глобальной переменной, не пытайтесь поменять константу. Не то страшно, что вы увидите warning, но то, что это не даст ожидаемого эффекта. Вероятно, команды типа puts по-умолчанию завязаны именно на глобальную переменную, а константа лишь ссылается на неё.
Конечно, это замечание не касается случаев явного указания потока (STDOUT.puts 'smth'), и это, кстати, повод вообще не использовать константы STDOUT, STDIN и STDERR.

Есть ещё один момент. Мы ведь хотим, чтобы код тестирования был как можно больше приближен к реальности. Скрипт получает эти переменные в виде массива ARGV. А мы передаем аргументы в приложение строкой, а не массивом, так ведь? Перед нами встает вопрос: как из строки аргументов получить массив. Вспомните про упоминавшуюся выше библиотеку shellwords и разбейте строку на элементы методом String#shellsplit.
Теперь, когда у нас есть массив аргументов командной строки, мы можем, например, заменить содержимое ARGV этим массивом: ARGV.replace(new_array).
Но лучше будет подменить массив, передаваемый методу OptionParser#parse!. Вместо ARGV ему достаточно передать наш новый массив OptionParser#parse!(new_array), и он будет вычленять опции из него, а не из ARGV. Не забудьте, что позициональные аргументы надо будет выделять тоже из нового массива — после вычленения опций.

Отдельный вопрос — как тестировать корректную работу с файлами конфигурации. Автор советует сделать возможность переопределять настройки этих файлов через переменные окружения. Я лично считаю это неэлегантным и оставлю любопытному читателю возможность залезть в книгу и самостоятельно посмотреть связанные с этим рекомендации.

Delights users


Немного замечаний про красивости в формате вывода.

Таблицы

Предположим, что ваша утилита выводит список самых популярных блогеров: имя, количество постов, комментариев, френдов. Напрашивается очевидный формат вывода: нарисовать табличку. Решение столь же прямолинейное — воспользуйтесь гемом terminal-table.

Цвета

Ещё один вид красивостей вы встретите, если воспользуетесь одной из утилит для сравнения файлов. Вы увидите строчки с плюсиками и минусиками для добавленных и удаленных строк. Но кроме того эти строчки для удобства раскрашены в различные цвета: красный/зеленый. Раскрашивание цветов в консоли выполняется добавлением специальных эскейп-последовательностей в местах смены цвета. Два популярных решения — гемы rainbow и term-ansicolor. Используются они тоже весьма прямолинейно, почитайте их мануалы. Надо отметить, что — увы — не все терминалы нормально поддерживают работу с цветами. Стандартный Windows-терминал для некоторых программ вместо цветных строк выдает цифры кодирующие эти цвета, а для других работает корректно. Так что проверьте работу гемов в разных терминалах, прежде чем начинать использовать их в коде.

Дэвид Коупленд напоминает, что почти 10% людей страдают дальтонизмом. Из этого следует, что цвет должен лишь помогать ориентироваться в выводе программы, а не брать на себя функцию единственного канала передачи данных. Если в утилите diff убрать плюсики и минусики, то существенная часть людей потеряет возможность воспользоваться результатами работы. Поэтому в раскрашенном выводе должны быть и цвета, и другие данные, имея которые, цвета перестают быть необходимыми.
Важное замечание! Когда ваша утилита выдает текст в machine-readable формате — желательно, чтобы форматирование цветов было отключено. В противном случае сторона, принимающая входные данные, имеет шанс заработать «несварение желудка» от специальных символов в строке.

Интерактивное общение с пользователем

Ещё одна библиотека предназначена для обеспечения интерактивности. Readline, о котором я уже говорил. Итак, к вашим услугам: запоминание истории пользовательских ответов и автодополнение. Также в rubygems и thor есть специальные модули отвечающие за взаимодействие с пользователем и предоставляющие такие методы, как say и ask.
Какого типа бывают интерактивные приложения? Вспомните irb и rails console. Автор книги приводил ещё один пример: предположим, вам приходит крупный JSON-объект и вы хотите исследовать, что в нем есть. Для этого можно написать интерактивный просмотрщик JSON, который позволяет бродить по иерархии командами cd, ls, а также изменять его командами rm и mknode. Пример приведен исключительно для того, чтобы разбудить ваше воображение. Можете придумать ещё сотню применений интерактивным приложениям.

Берегите нервы пользователя

Представьте себя на месте пользователя, который выкачивает через мобильное соединение большой файл и не знает, сколько уже скачалось. Первые десять минут пользователь ждет, зная, что файл большой. А потом начинает кусать локти: а вдруг коннекта нет? а может программа зависла? а может файл настолько большой, что пользователю в конце месяца мобильный раз и навсегда отключат? В случае, если программа может работать долго, ей не помешало бы вывести строку состояния в stderr или в лог-файл (и не забудьте, что файловые операции буферизуются, так что время от времени делайте flush, а то имеете шансы увидеть результаты в лог-файле только после того, как программа завершит работу).
Будет нелепо отсчитывать каждый процент загрузки на новой строке! Вместо этого давайте будем переписывать одну строку, каждый раз меняя числа. В этом нам поможет спецсимвол \r — возврат каретки. Когда в строке встречается \r, курсор терминала как бы смещается в начало строки и начинает печатать символы поверх старых (осторожно, «хвост» старой строки автоматически не затирается). Будьте однако внимательны, метод puts нам не подойдет, т.к. он автоматически переводит курсор на новую строку. Нам нужен метод print, и вот пример его использования:
(0..100).each do |i| 
  $stderr.print("\r#{i}% done")
  sleep(0.1)
end

Стоит, правда, немного вас предостеречь: в окне терминала потоки stderr и stdout смешаны, так что вы можете начать затирать не те данные, которые нужно.

В заключение не могу не рассказать о нескольких популярных библиотеках.


Если вы хоть немного проработали с руби-проектами, наверняка уже встречали команду rake, а может и команду thor. Это — специализированные библиотеки, позволяющие при помощи специального DSL описать набор утилиток, как набор задачи автоматизации.

Rake — это улучшенный аналог программы make для ruby. Он заглядывает в Rakefile текущего или родительского каталога и ищет там описание задачи. Например, bundler создает для каждого нового гема Rakefile с набором задач: build, install, release, что позволяет инсталлировать и публиковать собственные гемы одной командой. Одной из отличительных фишек rake является система зависимостей между задачами — так rake release сначала выполнит задачу build и только затем release. К сожалению, передавать аргументы в rake то ли нельзя, то ли нетривиально. На хабре, кстати, уже был вводный пост про rake.

Thor — система похожая на rake. Она помимо прочего позволяет «устанавливать» задачи в систему. Подробнее лучше посмотреть в других источниках.
Я упоминаю об этих библиотеках, поскольку они могут облегчить вам жизнь, если вам не нужна никакая сложная обработка опций и аргументов. Простые программные пакеты вполне описываются в терминах этих двух известных библиотек. В частности, в Ruby on Rails они обе используются для задач типа запуска генераторов, миграций, очистки кэша приложения итп.

Большую часть повествования я использовал для разбора опций OptionParser из стандартной библиотеки ruby. Это весьма удобная библиотека, однако некоторые считают её довольно тяжеловесной и пишут обертки. Некоторые обертки концентрируются на том, чтобы упростить задание опций, некоторые — на том, чтобы сделать хэш опций доступным глобально итд. Если вам покажется, что OptionParser вас тормозит, можете подобрать одну из готовых оберток (список можно найти на сайте книги — см. начало статьи) или сделать свою.
Есть у OptionParser и другие недостатки: она не способна отличить глобальные опции от локальных в программном пакете (это разделение мы сами сделали; вообще говоря, оно ниоткуда не следует, кроме успешного применения концепции в некоторых крупных проектах, таких как git). Ещё одна особенность OptionParser-а (это не прописано в спецификации и я полагаю, что это баг) — то что отрицательные числа в аргументах он понимает как опции. Полагаю, что рано или поздно этот баг исправят, но если ваша программа принимает числовые аргументы — будьте осторожны и тщательно тестируйте программу.

Для построения программных пакетов автор книги Дэвид Коупленд сделал весьма неплохой гем GLI. Он различает глобальные опции, опции команды и распознает саму команду. Проект живой и периодически получает обновления.

Кроме того, не могу не упомянуть довольно сырой, но крайне любопытный проект — docopt. Это — библиотека, которая по строке подсказки генерирует парсер опций, тогда как OptionParser и родственные библиотеки делают наоборот. Эта библиотека изначально написана на python и портирована на довольно большое количество языков. Про её возможности можно почитать здесь. Думаю, при должном внимании сообщества, она может превратиться в крайне удобную и мощную библиотеку.

P.S. Помимо описанного мной в статье, рубисту, работающему с приложениями командной строки, есть смысл почитать про специальную переменную ARGF. Если все аргументы вашего скрипта — имена файлов, то ARGF — просто конкатенация содержимого всех файлов.
Tags:
Hubs:
+66
Comments 31
Comments Comments 31

Articles