Для большинства специалистов PHP не является языком, который бы всерьёз использовался для написания консольных утилит, и для этого есть много причин. PHP изначально разрабатывался как язык для создания веб-сайтов, но, начиная с PHP 4.3, в 2002-ом году появилась официальная поддержка режима CLI, поэтому он уже давно перестал быть таковым. Разработчики Badoo на протяжении нескольких лет вполне успешно используют множество интерактивных CLI-утилит на PHP.
В данной статье нам хотелось бы поделиться своим опытом работы с CLI-режимом в PHP и дать несколько рекомендаций тем, кто собирается писать скрипты на PHP, при условии, что они будут запускаться в *nix-системе (впрочем, почти всё верно и для Windows).
Рекомендации
Скорость работы
Распространено мнение, что PHP — язык медленный, и таковым он является на самом деле. Для PHP CLI рекомендуется не использовать тяжелые фреймворки и даже просто большие библиотеки на PHP по двум причинам:
- Время работы include/require в CLI-режиме будет всегда включать в себя парсинг и исполнение, т.к. байткод в этом режиме не кэшируется (по крайней мере — по умолчанию), а значит, инициализация займет много времени, даже если из-под веб-сервера всё работает достаточно быстро.
- Пользователи веб-сайтов привыкли ждать некоторое количество времени для загрузки страницы (около 1-ой секунды, а иногда и чуть больше пользователем воспринимается вполне нормально), а вот сказать то же самое про CLI нельзя: даже задержка в 100 мс уже будет ощутимой, а в 1-у секунду и более может раздражать.
Вывод на экран
В CLI- и в веб-режиме вывод на экран значительно отличается. В веб-режиме вывод, как правило, буферизуется, у пользователя нельзя ничего спросить во время исполнения скрипта; отсутствует как класс понятие вывода в поток ошибок. В CLI-режиме, естественно, неприемлем вывод HTML, а также крайне нежелателен вывод длинных строк. В CLI echo по умолчанию вызывает flush() (подробнее можно посмотреть здесь) — это удобно тем, что можно не заботиться о вызове flush() вручную, если, к примеру, вывод перенаправляется в файл.
Также для CLI-скриптов имеет смысл выводить ошибки не в STDOUT (используя echo), а в STDERR: таким образом, даже если вывод программы будет перенаправлен куда-либо еще (например, в /dev/null или grep), пользователь не пропустит текст ошибки в случае ее появления. Это стандартное поведение для большинства «родных» *nix’овых консольных утилит, и STDERR существует именно по причине, описанной выше. В PHP для записи в STDERR можно пользоваться, к примеру, fwrite(STDERR, $message) или error_log($message).
Использование кодов возврата
Код возврата — это число, которое равно 0 в случае успешного выполнения команды и не равно 0 в противном случае. Код возврата, равный 1, часто применяется в случае некритичных ошибок (например, если указаны неправильные аргументы командной строки), а 2 — в случае критичных системных ошибок (например, при ошибке сети или диска). Значения наподобие 127 или 255 обычно используются для каких-либо специальных случаев, которые отражаются отдельно в документации.
По умолчанию при простом завершении PHP-скрипта предполагается, что все команды отработали успешно и возвращается 0. Чтобы выйти с определенным кодом возврата, нужно явно вызвать exit(NUM), где NUM — это и есть код возврата (помним, что он равен 0 в случае успеха и имеет другое значение в случае ошибок).
Чтобы понять, что внешняя команда, исполняемая с помощью exec() или system(), завершилась неуспешно, нужно передавать переменную $return_var в качестве параметров соответствующих функций и проверять значение на равенство нулю.
Внимание! Если вы собираетесь написать exec(‘some_cmd … 2>&1’, $output), чтобы ошибки тоже попали в $output, рекомендуем ознакомиться с причинами разделения STDOUT и STDERR и убрать явное перенаправление потока ошибок в STDOUT (2>&1). Такое перенаправление требуется намного реже, чем может показаться. Единственный случай, когда его использование хоть немного оправдано (в PHP-скрипте) — необходимость распечатать на веб-странице (не в CLI!) результат выполнения команды, включая ошибки, которые произошли (иначе они попадут в лог веб-сервера или вообще уйдут в /dev/null).
«Маскировка» под встроенные команды системы
Хорошая консольная утилита должна себя вести стандартным образом и пользователи могут даже и не знать, что она на PHP. Для этого в *nix-системах предусмотрен механизм, который многим известен по запуску скриптов на Perl/Python/Ruby, но в равной степени применимый и к PHP.
Если добавить в начало PHP-файла, к примеру, #!/usr/bin/env php и перенос строки, дать ему права на исполнение (chmod 755 myscript.php) и убрать расширение .php (последнее не обязательно), то файл можно будет исполнить, как и любой другой исполняемый файл (./myscript). Можно добавить директорию со скриптом в PATH или переместить его в одну из стандартных директорий PATH, например, /usr/local/bin, и тогда скрипт можно будет вызывать простым набором «myscript», как и любые другие системные утилиты.
Обработка аргументов командной строки
Существует соглашение о формате аргументов командной строки, которому следуют большинство встроенных системных утилит, и мы рекомендуем следовать ему и ваших скриптах.
Пишите краткую справку для своего скрипта, если он получил неверное количество аргументов.
Чтобы узнать имя вызываемого скрипта, используйте $argv[0]:
if($argc != 2) { // не забывайте n на конце echo "Usage: ".$argv[0]." <filename>n"; // возвращаем ненулевой код возврата, что свидетельствует об ошибке exit(1); }
Для облегчения обработки флагов можно использовать getopt(). Getopt() — одна из встроенных функций для обработки аргументов командной строки. С другой стороны, нет ничего сложного в том, чтобы обрабатывать часть аргументов вручную, т.к. на PHP это не представляет особого труда. Такой способ может понадобиться, если нужно обработать аргументы в стиле ssh или sudo (sudo -u nobody echo Hello world выполнит echo Hello world из-под пользователя nobody, который указан после флага -u перед командой).
Рекомендации для более сложного уровня
Вызов «правильного» system() для CLI
О реализации system() уже было написано здесь. Речь идет о том, что стандартный system() в PHP является не вызовом system() в С, а оберткой над popen(), соответственно, «портит» STDIN и STDOUT у вызываемого скрипта. Чтобы этого не происходило, нужно пользоваться следующей функцией:
// функция совместима по аргументам с system() в С function cSystem($cmd) { $pp = proc_open($cmd, array(STDIN,STDOUT,STDERR), $pipes); if(!$pp) return 127; return proc_close($pp); }
Работа с файловой системой
К возможному удивлению, мы рекомендуем не писать свои реализации рекурсивного удаления (копирования, перемещения) файлов, а вместо этого использовать встроенные команды mv, rm, cp (под Windows — соответствующие аналоги). Такое не переносимо между Windows/*nix, но зато позволяет избежать некоторых проблем, описанных ниже.
Давайте рассмотрим простой пример реализации рекурсивного удаления директории на PHP:
// неправильный пример! используйте rm -r function recursiveDelete($path) { if(is_file($path)) return unlink($path); $dh = opendir($path); while(false !== ($file = readdir($dh))) { if($file != '.' && $file != '..') recursiveDelete($path.'/'.$file); } closedir($dh); return rmdir($path); }
На первый взгляд всё верно, так? Более того, даже в известных файловых менеджерах на PHP (например, в eXtplorer и в комментариях к документации) удаление папки реализовано именно таким способом. Теперь создадим символическую ссылку на несуществующий файл (ln -s some_test other_test) и попробуем её удалить. Или создадим в папке символическую ссылку на себя, или на корень ФС (рекомендуем не тестировать такой вариант)… Конкретно для recursiveDelete() фикс, конечно же, тривиален, но понятно, что лучше не изобретать велосипед и использовать встроенные команды, пусть и теряя немного в производительности.
Очистка в случае ошибок
Если ваш скрипт делает какие-то операции с файлами (базой данных, сокетами и пр.), то зачастую возникает необходимость корректно завершать работу программы в случае возникновения непредвиденных ошибок: это может быть запись в лог, очистка временных файлов, снятие файловых блокировок и т.д.
В веб-режиме PHP это реализуется с помощью register_shutdown_function(), которая срабатывает даже тогда, когда скрипт завершился с фатальной ошибкой (этот способ, кстати, годится для отлова почти любых ошибок, в том числе ошибок нехватки памяти). В CLI-режиме всё немного сложнее, поскольку пользователь, к примеру, может послать вашему скрипту Ctrl+C, и register_shutdown_function() при этом не сработает.
Но объясняется это просто: PHP по умолчанию вообще не обрабатывает UNIX-сигналы, поэтому получение любого сигнала немедленно вызывает завершение скрипта. Это можно исправить путем добавления declare(ticks=1), в начало файла после <?php и регистрации своих обработчиков интересующих нас сигналов (более подробно здесь):
pcntl_signal(SIGINT, function() { exit(1); }); // Ctrl+C pcntl_signal(SIGTERM, function() { exit(1); }); // killall myscript / kill <PID> pcntl_signal(SIGHUP, function() { exit(1); }); // обрыв связи
Функции для обработки сигналов не обязаны быть одинаковыми для всех. Можно не вызывать exit() внутри обработчика сигнала — тогда выполнение скрипта будет продолжено после того, как сигнал обработан.
Работа с базой данных в нескольких процессах (после fork())
Рекомендация очень простая: следует закрывать все соединения с базой перед тем, как выполнить fork() (в идеале даже открытые файлы с помощью fopen() не должны присутствовать), т.к. выполнение fork() в этих случаях может привести к весьма странным последствиям, а для соединения с базой данных это просто приведет к закрытию соединения после завершения любого из «форкнутых» процессов. В том же руководстве по SQLite прямо сказано, что ресурс, открытый до fork(), нельзя использовать в «форкнутых» процессах, потому что он не поддерживает многопоточный доступ таким способом. В любом случае, pcntl_fork() в PHP просто делает fork() и логирует ошибки, поэтому обращаться с ним нужно столь же осторожно, как и в С.
Использование ncurses для сложной отрисовки на экран
Библиотека ncurses была создана специально для того, чтобы можно было не заботиться об esc-последовательностях для управления положением курсора в терминале и чтобы программа, которая использует, например, цвет, была переносима между системами и терминалами. С другой стороны, даже для таких простых вещей как цветной вывод нужно иметь в виду, что STDOUT не всегда поддерживает цвета. Нам известен один примитивный, но ненадежный, способ узнать без ncurses, поддерживает ли терминал цвет — проверить, является ли STDOUT терминалом (posix_isatty(1)).
Количество выводимого на экран
Большинство стандартных программ почти ничего не выводят на экран, только если их не попросить об этом специально, указав ключ -v (verbose, болтливый). Действительно, не стоит засорять экран без причины. Найти баланс бывает непросто, но есть несколько простых рекомендаций:
- Если операция не займет много времени (меньше 10-ти секунд), не выводите вообще ничего;
- Если вы делаете что-то нетривиальное (например, монтируете временные устройства с использованием sudo), наоборот, сообщите пользователю об этом, чтобы он знал, что делать в случае ошибки;
- Если операция длительная и для неё возможно показывать прогресс выполнения, лучше этот самый прогресс показывать (для этого может пригодиться функция cSystem, указанная выше);
- Если программа может работать как фильтр (например cat, grep, gzip…), проверьте, что в STDOUT попадают только данные, а ошибки, приглашения ко вводу и др. идут в STDERR, чтобы следующие программы в цепочке не получили какой-нибудь ненужный мусор.
Чтобы показывать прогресс выполнения, можно делать так, как это делает git: пользоваться предположением, что у всех терминалов ширина как минимум 80 символов, и печатать строку фиксированной ширины. Если учесть, что символ возврата каретки (r) возвращает курсор в начало строки (и следующий вывод переписывает то, что было в строке до этого), очень легко написать код, который выводит, к примеру, процент выполнения операции от 0 до 100, занимая, при этом, всего одну строку на экране пользователя:
for($i = 0; $i <= 100; $i++) { printf("r%3d%%", $i); sleep(1); } echo "n";
Определение имени пользователя, вызвавшего скрипт
Имя пользователя содержится в переменной окружения USER ($_ENV[‘USER’]), но есть одна загвоздка — этот способ использует переменные окружения, которые могут сообщать неверные данные (пользователь может выполнить скрипт, скажем, как USER=root myscript, и скрипт будет считать, что имя пользователя — root).
Поэтому нужно использовать функции posix:
// getuid() вернет пользователя, который вызывал скрипт, а не эффективный uid – в данном случае нам это и нужно $info = posix_getpwuid(posix_getuid()); $login = $info['name'];
Заключение
В статье мы постарались привести рекомендации, которые не совсем очевидны непосредственно разработчикам PHP, нежели вообще всем программистам, пишущим консольные утилиты. Хотя многое из вышеизложенного можно применить и к другим языкам программирования, и, возможно, некоторые пункты будут полезны и тем, кто не собирается писать на РНР.
Юрий youROCK Насретдинов, разработчик Badoo
Уведомление: Php cli dev null