Command Line Interface в PHP
До этого момента мы с Вами знали, что PHP работает на сервере. Клиент обращается к серверу по протоколу HTTP с каким-либо запросом, запрос на сервере обрабатывается и формируется ответ. После этого клиенту снова по протоколу HTTP в ответе отдаётся сформированный ответ. Однако, если взять какой-нибудь более-менее продвинутый сайт, то мы увидим, что есть задачи, которые не решаются стандартным клиент-серверным путем. Например: поздравлять пользователей с днём рождения и дарить им скидку на какой-нибудь продукт. Для того, чтобы это сделать, нам придется обновлять раз в день php-скрипт в браузере, чтобы он выбирал пользователей, у которых сегодня ДР, затем создавал для них скидки, и отправлял им сообщения по почте. Согласитесь, неудобно это делать вручную и в браузере. Для таких случаев в PHP предусмотрен Command Line Interface (CLI) – интерфейс командной строки.
CLI позволяет запускать программы на PHP не через привычную нам клиент-серверную архитектуру, а как простые программы в командной строке. Давайте создадим простейший скрипт, чтобы показать, как это работает. Создаём новую папку bin в корне проекта, а в ней файл – cli.php.
А теперь запускаем консоль из OpenServer:
Переходим в папку с нашим проектом, выполнив:
И пишем следующую команду:
Написали простейшее консольное приложение! Уже неплохо. Но что если мы захотим сложить 2 числа, которые нужно передать скрипту? Как Вы понимаете, сделать это с помощью GET- или POST- запросов уже не получится. Так как же быть?
Аргументы консольного приложения
На помощь нам приходят аргументы, которые мы можем передать в скрипт, указав их после имени скрипта в командной строке. Вот так:
А для того, чтобы получить к ним доступ из php-скрипта используется магическая переменная $argv. Она представляет собой массив, в котором нулевой элемент – это путь до скрипта, а все последующие – это его аргументы в консоли.
Давайте теперь запустим наш скрипт с параметрами:
Как видим, наши аргументы попали в этот массив. Давайте напишем простейший скрипт, который будет складывать все переданные ему аргументы.
Запустим его, и убедимся, что все работает:
И он действительно работает: 3 + 4 + 5 = 12.
А что если мы хотим передавать аргументы с именами? Вроде такого:
И затем в коде получать их в коде по их именам? Для этого нам следует написать простейший парсер, который будет находить вот такие именованные параметры и их значения. Пишем.
Отлично, теперь мы можем обращаться к элементам массива params, чтобы выяснить, были ли нам переданы какие-то аргументы или нет.
CLI и ООП
Мы с вами изучили некоторые основы работы с CLI. Давайте теперь перенесем эти знания на объектно-ориентированный подход и научимся работать через интерфейс командной строки с объектами.
Для этого нам понадобится создать отдельную директорию под «команды». Команды – так мы будем называть наши специальные классы, которые будут выполнять какой-то код через запуск из командной строки. Создаем новую директорию: src/MyProject/Cli.
И теперь создадим наш первый класс, который будет заниматься тем, что считает сумму переданных в него аргументов: -a и -b.
params = $params; $this->checkParams(); > public function execute() < echo $this->getParam('a') + $this->getParam('b'); > private function checkParams() < $this->ensureParamExists('a'); $this->ensureParamExists('b'); > private function getParam(string $paramName) < return $this->params[$paramName] ?? null; > private function ensureParamExists(string $paramName) < if (!isset($this->params[$paramName])) < throw new CliException('Param with name "' . $paramName . '" is not set!'); >> >
В конструкторе класса мы принимаем список параметров, сохраняем их, а затем вызываем метод checkParams(), который проверяет наличие обязательных параметров для этого скрипта. В нём просто поочередно вызывается метод для проверки в массиве нужных ключей. Если их нет – метод кинет исключение. И, наконец, есть метод execute(), который содержит бизнес-логику. В нем используется метод getParam(), который вернет параметр (при его наличии), либо вернет null (при его отсутствии).
И также создаём исключение, специально для ошибок, возникающих при работе с CLI.
Теперь давайте снова вернемся в нашу точку входа для консольных приложений cli.php. Этот файл можно назвать фронт-контроллером для консольных команд, он как index.php в случае с клиент-серверным подходом будет создавать другие объекты и запускать весь процесс.
Дополним этот код так, чтобы он создавал экземпляр нужного класса и передавал ему аргументы.
); // Составляем полное имя класса, добавив нэймспейс $className = '\\MyProject\\Cli\\' . array_shift($argv); if (!class_exists($className)) < throw new \MyProject\Exceptions\CliException('Class "' . $className . '" not found'); >// Подготавливаем список аргументов $params = []; foreach ($argv as $argument) < preg_match('/^-(.+)=(.+)$/', $argument, $matches); if (!empty($matches)) < $paramName = $matches[1]; $paramValue = $matches[2]; $params[$paramName] = $paramValue; >> // Создаём экземпляр класса, передав параметры и вызываем метод execute() $class = new $className($params); $class->execute(); > catch (\MyProject\Exceptions\CliException $e) < echo 'Error: ' . $e->getMessage(); >
Теперь мы можем запустить наш скрипт с помощью вот такой команды:
Если мы захотим создать еще один класс, в котором мы будем вычитать из аргумента a аргумент b, то нам нужно будет продублировать довольно большой объем кода. Но ведь если присмотреться – большую часть кода из класса Summator можно вынести в отдельный класс и использовать его повторно.
Давайте создадим абстрактный класс, который будет заниматься тем, что будет сохранять переданные в него параметры и запускать метод для их проверки.
params = $params; $this->checkParams(); > abstract public function execute(); abstract protected function checkParams(); protected function getParam(string $paramName) < return $this->params[$paramName] ?? null; > protected function ensureParamExists(string $paramName) < if (!isset($this->params[$paramName])) < throw new CliException('Param with name "' . $paramName . '" is not set!'); >> >
Теперь нам в классе Summator достаточно отнаследоваться от этого класса и он значительно упростится:
ensureParamExists('a'); $this->ensureParamExists('b'); > public function execute() < echo $this->getParam('a') + $this->getParam('b'); > >
Запустим скрипт снова и убедимся, что все успешно отработало:
Давайте создадим по аналогии скрипт, который будет вычитать из аргумента x аргумент y.
ensureParamExists('x'); $this->ensureParamExists('y'); > public function execute() < echo $this->getParam('x') - $this->getParam('y'); > >
А теперь давайте попробуем не указать один из аргументов – получим ошибку.
Вот таким вот нехитрым образом мы с вами научились создавать простейшие программы для запуска в консоли на PHP. А в следующем уроке мы с вами научимся запускать эти команды по расписанию.
Консольные команды на PHP
У многих, равно как и у меня, периодически возникает потребность в реализации каких-то небольших задач. Например распарсить сайт/API и сохранить данные в xml/json/csv, произвести какие-либо расчеты/пересчеты, перегнать данные из одного формата в другой, собрать статистику и т.д. и т.п. Замечу, что речь о задачах не связанных с текущими проектами.
Собирать тяжелый фреймворк ради удобных фич, лень, а реализовывать в рамках кода текущих проектов как-то не эстетично. Поэтому для экономии своего времени приходится создавать скрипт, копипастить в него куски кода из предыдущих наработок, подключать разнообразные библиотеки и запускать скрипт из консоли. При этом часто требуется некоторая интерактивность работы скрипта: обработка опций/аргументов, а то и диалоговое взаимодействие. Здесь главное чтобы не было настроения, которое хорошо описывается выражением «Аппетит приходит во время еды», тогда вообще не понятно к чему приведет работа над простой задачкой =)
В такие моменты я вспоминал удобную симфоническую консоль, к которой успел привыкнуть работая с проектами на
Symfony 2. Не в обиду другим консолям (zend, yii, django, ror etc), все хороши, просто так сложилось.
Когда в очередной раз потребовалось что-то распарсить, я опять вспомнил про консоль Symfony (Console Component) и тот факт, что это независимый компонент все больше подтолкнул меня к мысли использовать ее возможности.
- symfony/console — сама консоль
- symfony/finder — для поиска и подключения к приложению наших комманд
- suncat/symfony-console-extra — несколько плюшек для того чтобы это все работало
С помощью Сomposer-а создаем новый проект:
$ composer create-project suncat/console-commands ./cmd
У меня Composer установлен глобально, поэтому он всегда доступен. Если вы им еще не пользуетесь, то для проверки примера его необходимо установить.
После скачивания приложения и всех зависимостей переходим в созданную директорию:
$ cd cmd # для примера, при создании проекта задайте имя директории на свое усмотрение
app/ console # консоль src/ # автозагрузка psr-0 Command/ # классы ваших команд vendor/ # сторонние библиотеки
Если видим справочную информацию и список доступных команд значит все ок. Выглядит это так:
Теперь создадим шаблон класса команды, которую мы планируем использовать для реализации задачи:
Please enter the name of the command class: NewsInternetCommand
Generated new command class to "./cmd/src/Command/NewsInternetCommand.php"
Собственно все, команда готова, она появилась в списке доступных команд:
Но пока она не делает того что нужно (здесь можно открыть созданный класс в IDE или любимом редакторе и написать код команды).
Так как для нашего примера необходимо получать внешний контент и нам нравится ООП, поставим еще одну библиотеку:
$ composer require kriswallsmith/buzz 0.9
Buzz — легкий HTTP клиент на PHP5.3. Будем использовать его для выполнения запросов к сервису новостей.
Создадим отдельный класс — YandexRSSNewsParser, который будет предоставлять классу команде подготовленный контент:
// ./src/Parser/YandexRSSNewsParser.php namespace Parser; use Buzz\Client\FileGetContents; use Buzz\Message\Request; use Buzz\Message\Response; use DOMDocument; use DOMXPath; class YandexRSSNewsParser < private $method; private $host; /** * Construct */ public function __construct() < $this->method = 'GET'; $this->host = 'http://news.yandex.ru'; > /** * Get news * * @param $resource * * @return mixed */ public function getNews($resource) < // content $xml = $this->getData($resource); if (false === $xml) < return array(); >$doc = new DOMDocument(); @$doc->loadXML($xml); $xpath = new DOMXpath($doc); // items $items = $xpath->query('.//item'); $news = array(); foreach ($items as $item) < $news[] = array( 'datetime' =>$xpath->evaluate("./pubDate", $item)->item(0)->nodeValue, 'title' => $xpath->evaluate("./title", $item)->item(0)->nodeValue ); > return $news; > /** * Get data * * @return mixed */ protected function getData($resource) < $request = new Request($this->method, $resource, $this->host); $response = new Response(); $client = new FileGetContents(); // processing get data $attempt = 0; do < if ($attempt) < sleep($attempt); >try < $client->send($request, $response); > catch (\Exception $e) < continue; >> while (false === ($response instanceof Response) && ++$attempt < 5); if (false === ($response instanceof Response) || false === $response->isOk()) < return false; >$data = $response->getContent(); return $data; > >
И отредактируем класс команды, для вывода в консоль заголовков последних новостей, рубрики «Интернет»:
// ./src/Command/NewsInternetCommand.php namespace Command; use Parser\YandexRSSNewsParser; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; /** * NewsInternetCommand */ class NewsInternetCommand extends Command < /** * Configuration of command */ protected function configure() < $this ->setName("news:internet") ->setDescription("Command for parsing internet news") ; > /** * Execute command * * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output */ protected function execute(InputInterface $input, OutputInterface $output) < $parser = new YandexRSSNewsParser(); $output->writeln("Start parsing \n")); // News $news = $parser->getNews('/internet.rss'); foreach ($news as $item) < $output->writeln(sprintf("[%s] %s ", $item['datetime'], $item['title'])); > $output->writeln("\nDone! ")); > >
Результат:
Получился очень простой, а за счет symfony/console и composer-а гибкий и удобный инструмент для организации консольных команд на PHP.