Для многих не секрет, что Ruby используется не только для web-разработки - этот язык также отлично пригодится для написания простеньких скриптов, упрощающих повседневные рутинные задачи. При этом самый естественный способ взаимодействия со скриптами - через командную строку, он же CLI (Command-Line Interface).
В стандартной библиотеке Ruby есть модуль OptionParser, здорово облегчающий работу с парсингом аргументов, пришедших из командной строки - но в этой статье речь пойдет о библиотеке thor, которую создал небезызвестный Yehuda Katz. Помимо обработки аргументов, thor предоставляет методы для более удобного взаимодействия с пользователем во время сессии работы самой утилиты.
thor заявлен как замена rake, и в этой стезе, надо сказать, он пока еще не преуспел - но зато гем здорово помогает при разработке собственных CLI-утилит, а также отлично справляется с генерацией файлов на основе шаблонов (именно на основе thor работают все генераторы в rails, начиная с версии 3.0).
Задача
Сначала я хотел написать очередную обзорную статью с небольшими примерами кода, но потом решил, что все-таки интереснее говорить о чем-то конкретном; поэтому по ходу статьи будем создавать простенькую CLI-утилитку, выводящую некоторую нужную информацию - непрочитанные письма в gmail-ящике, заголовки 5 последних новостей с ленты.ру и 5 последних коммитов в рельсы с гитхаба. А назовем ее workhorse (рабочая лошадка).
Для начала создадим скелет нашей утилиты. Структура будет стандартной - в bin/ лежат запускаемые скрипты (в нашем случае один), в lib/ библиотечные файлы, стандартный Gemfile и файл конфигурации config.yml в корне.
В данном случае я позволю себе не писать спеки, так как это не является темой статьи, и к тому же при работе с внешними API спеки, написанные с применением традиционных стабов, получаются очень уж громоздкими.
Для работы с gmail-ящиком потребуются логин и пароль. Чтобы не вводить их каждый раз, сложим их в config.yml в корне проекта, но позволим пользователю передавать их параметрами, если вдруг потребуется проверить другой ящик. Аналогично поступим и с гитхабом - в yaml-е напишем название репозитория rails/rails, но будем ждать параметр из командной строки.
Во-первых нам нужен сам thor для создания command-line интерфейса; отвечающий за эту задачу класс будет наследоваться от класса Thor. Далее, для работы с GMail возьмем гем gmail; новости с ленты.ру будем брать через их RSS, а для парсинга HTML и XML данных я обычно использую nokogiri от tenderlove; ну а с Github API я выбрал библиотеку octokit (она не единственная в своем роде - но поскольку я столкнулся с задачей впервые, просто взял самый популярный по watcher-ам гем):
# для CLIgem'thor'# для сбора почтыgem'gmail'# для парсинга RSS-ки ленты.руgem'nokogiri'# для работы с Github APIgem'octokit'
Основной функционал
Для такой простой задачи вполне хватит одного модуля, выполняющего всю работу - каждый метод будет производить какие-нибудь действия. Назовем этот класс Workhorse и положим в lib/workhorse.rb.
require'gmail'require'octokit'require'open-uri'require'nokogiri'moduleWorkhorseclass<<self# конфиг, считываемый из config.yml и кэшируемый в @configdefconfigfile_path=File.expand_path('../../config.yml',__FILE__)@config||=YAML.load_file(file_path)end# получение писем из ящика на GMaildefgmail(mailbox=nil,password=nil)# если адрес и пароль не переданы, они берутся из конфигаmailbox||=config['gmail']['mailbox']password||=config['gmail']['password']begin# создаем соединение с GMailgmail=Gmail.connect(mailbox,password)ifgmail.logged_in?# если авторизация прошла успешно, берем письмаgmail.inbox.emails(:unread).mapdo|email|from=email.from.first{sender:"#{from.mailbox}@#{from.host}",date:Time.parse(email.date),subject:Mail::Encodings.value_decode(email.subject)}endelse# если авторизоваться не удалось, бросаем исключениеraiseArgumentError,'Email and/or password were incorrect.'endensure# в обязательном порядке делаем логаутgmail.logoutendend# получение 5 последних коммитов в определенный репозиторий на Githubdefgithub(repo=nil)# если название репозитория не передано, оно берется из конфигаrepo||=config['github']['repo']Octokit.commits(repo).first(5).mapdo|commit|# fuck yeahcommit=commit.commitcommitter=commit.committer{committer:committer.name,date:Time.parse(committer.date),message:commit.message.split("\n").first# нам достаточно заголовка сообщения}endend# получение заголовков 5 последних новостей с lenta.rudeflenta_ru# получаем XML-документpage=open('http://lenta.ru/rss/')# и отправляем его в nokogiridoc=Nokogiri::XML(page)# каждая нода, найденная по //channel/item - отдельная новостьdoc.xpath('//channel/item').first(5).mapdo|item|{title:item.xpath('.//title').first.content,date:Time.parse(item.xpath('.//pubDate').first.content)}endendendend
Теперь остается только набросать command-line интерфейс для модуля, чтобы им можно было пользоваться даже без знания Ruby.
Для этого мы создаем свой класс, наследующийся от класса Thor, и создаем таски. Таск в thor представляет из себя обычный метод, перед которым указывается его описание с помощью метода desc (он принимает 2 параметра - название метода строкой или символом, и строку с описанием). В конце мы вызываем метод .start нашего класса.
#!/usr/bin/env ruby$LOAD_PATH<<File.expand_path('../../lib',__FILE__)require'workhorse'require'thor'classWorkhorseCLI<Thor# по умолчанию выполняется таск summarydefault_task:summary# описание таска - выводится и в общем списке тасков, и в хелпе по данному таскуdesc:gmail,'Get unread emails from GMail'# строковые параметры mailbox и password с описаниемmethod_option:mailbox,type::string,desc:'A mailbox address to fetch from'method_option:password,type::string,desc:'A password for that mailbox'# описания параметров выводятся в хелпе по таскуdefgmailputs# say - синтаксический сахар для puts с поддержкой разных цветовsay'Unread emails:',:green# опции, переданные в таск из командной строки, доступны в хэше optionsprint_resultsWorkhorse.gmail(options[:mailbox],options[:password])enddesc:github,'Get 5 latest commits from a repo on github'method_option:repo,type::string,desc:'A repository name'defgithubputssay'Last commits:',:greenprint_resultsWorkhorse.github(options[:repo])enddesc:lenta,'Get 5 latest news titles from lenta.ru'deflentaputssay'Last 5 news on lenta.ru:',:greenprint_resultsWorkhorse.lenta_ruenddesc:summary,'Get all info at once, options for other tasks can also be passed here'# в method_option также можно задавать алиасы, добавим ключ -q как синоним --quietmethod_option:quiet,type::boolean,aliases:'-q',desc:'Do not ask any questions, output everything'defsummary# метод yes? выводит пользователю вопрос, и возвращает# true при положительном ответе и, соответственно, false при отрицательном# (кстати, метод no? действует наоборот)ifoptions[:quiet]||yes?('Want emails?')# метод invoke запускает указанный таск, при этом ему также будут доступны опции, переданные в текущий таскinvoke:gmailendifoptions[:quiet]||yes?('Want commits?')invoke:githubendifoptions[:quiet]||yes?('Want news?')invoke:lentaendendprivate# вывод отформатированной таблицы в STDOUTdefprint_results(data)rows=data.mapdo|hash|row=[]# выводим все даты в удобочитаемом форматеrow<<hash.delete(:date).strftime('%d.%m.%Y %H:%M:%S')row+hash.valuesend# метод print_table принимает массив из массивов, и выводит его в виде отформатированной таблицыprint_tablerowsendend# запускаем обработку входных параметровWorkhorseCLI.start
Не забываем сделать файл исполняемым: chmod +x bin/workhorse
Смотрим, что у нас получилось. Для начала проверим страницы хелпа с информацией о наших тасках и их параметрах:
1234567891011121314151617
$ bin/workhorse --help
Tasks:
workhorse github # Get 5 latest commits from a repo on github workhorse gmail # Get unread emails from GMail workhorse help[TASK]# Describe available tasks or one specific task workhorse lenta # Get 5 latest news titles from lenta.ru workhorse summary # Get all info at once$ bin/workhorse help gmail
Usage:
workhorse gmail
Options:
[--mailbox=MAILBOX]# A mailbox address to fetch from[--password=PASSWORD]# A password for that mailboxGet unread emails from GMail
Как видим, все выводится в достаточно удобочитаемом виде. Теперь попробуем запустить какой-нибудь таск:
123456789101112
$ bin/workhorse gmail
Unread emails:
19.02.2012 04:52:09 ror2ru@googlegroups.com Краткая сводка ror2ru@googlegroups.com - Сообщения: 11 в Темы: 4
$ bin/workhorse github --repo=sinatra/sinatra
Last commits:
11.02.2012 01:11:12 Konstantin Haase Merge pull request #464 from boucher/master11.02.2012 01:06:51 Ross Boucher Plus symbols in the URL should be converted to spaces when considered as param values.
02.02.2012 01:45:34 Konstantin Haase Merge pull request #455 from ohhgabriel/readme-es02.02.2012 01:44:29 Konstantin Haase Merge pull request #461 from chanks/indifferent-params-in-arrays02.02.2012 01:32:07 Chris Hanks Params processing shouldn't error on arrays of objects other than hashes.
(здесь этого не видно, но заголовки ‘Unread emails:’ и ‘Last commits:’ подсвечиваются зеленым)
Ну и, наконец, проверим, как работает наш основной и дефолтный таск - summary. Он запускает все остальные таски, спрашивая у пользователя перед каждым, нужно ли это делать (а чтобы умерить его разговорчивость, можно передать опцию --quiet или ее синоним -q).
1234567891011121314151617181920212223242526272829
$ bin/workhorse help summary
Usage:
workhorse summary
Options:
-q, [--quiet]# Do not ask any questions, output everythingGet all info at once, options for other tasks can also be passed here
$ bin/workhorse
Want emails? y
Unread emails:
19.02.2012 04:52:09 ror2ru@googlegroups.com Краткая сводка ror2ru@googlegroups.com - Сообщения: 11 в Темы: 4
Want commits? y
Last commits:
18.02.2012 21:44:42 Vijay Dev Merge pull request #5085 from simi/patch-118.02.2012 21:29:48 Josef Šimánek Update activerecord/CHANGELOG.md
18.02.2012 20:32:34 Vijay Dev Merge branch 'master' of github.com:lifo/docrails
18.02.2012 20:28:33 Vijay Dev fix some typos [ci skip]18.02.2012 15:58:52 Mike Gunderloy Documenting the :inverse_of option for associations
Want news? y
Last 5 news on lenta.ru:
19.02.2012 05:56:25 Из севшего на мель танкера "Каракумнефть" начали откачивать топливо
19.02.2012 03:30:14 Виталий Кличко выиграл бой у Дерека Чисоры
19.02.2012 02:54:13 ГУВД Москвы сосчитало участников автопробега за Путина
19.02.2012 06:42:43 Пожар на севере Москвы потушен
19.02.2012 04:59:57 Бывший муж Уитни Хьюстон воздержался от скандала на панихиде
При вызове таском других тасков все параметры из командной строки доступны всем участвующим таскам, поэтому при вызове summary можно указывать опции для гитхаба и gmail-а:
123456789101112131415161718192021
$ bin/workhorse --mailbox=my.email@gmail.com --password=secret --repo=7even/vkontakte_api
Want emails? y
Unread emails:
19.02.2012 04:52:09 ror2ru@googlegroups.com Краткая сводка ror2ru@googlegroups.com - Сообщения: 11 в Темы: 4
Want commits? y
Last commits:
01.02.2012 01:53:51 Vsevolod Romashov Travis CI integration
01.02.2012 01:45:29 Vsevolod Romashov readme formatting
31.01.2012 07:07:32 Vsevolod Romashov other links fixed
31.01.2012 07:02:11 Vsevolod Romashov readme links fixed
31.01.2012 06:57:11 Vsevolod Romashov release 0.1
Want news? y
Last 5 news on lenta.ru:
19.02.2012 05:56:25 Из севшего на мель танкера "Каракумнефть" начали откачивать топливо
19.02.2012 03:30:14 Виталий Кличко выиграл бой у Дерека Чисоры
19.02.2012 02:54:13 ГУВД Москвы сосчитало участников автопробега за Путина
19.02.2012 06:42:43 Пожар на севере Москвы потушен
19.02.2012 04:59:57 Бывший муж Уитни Хьюстон воздержался от скандала на панихиде
В заключение
В статье была описана лишь часть возможностей thor - гем также имеет множество возможностей по генерации файлов по шаблонам и копированию их, что делает этот инструмент незаменимым для рельсовых генераторов и аналогичных задач.
Например, есть метод inject_into_class, вставляющий текст в файл сразу после определения класса (т.е. сразу после строки class MyClass); insert_into_file, вставляющий текст в файл после строки, найденной по переданному регулярному выражению; get, создающий файл по указанному пути с содержимым, полученным по указанному урлу итд.
Thor::Group - альтернативный вариант работы с тасками (здесь всегда выполняются все таски, и параметры не именуются при передаче скрипту; именно этот класс лежит в основе рельсовых генераторов)