Pull to refresh
119.45
Rating
Badoo
Big Dating

Написание системных утилит на PHP CLI

Badoo corporate blog
Для большинства специалистов PHP не является языком, который бы всерьёз использовался для написания консольных утилит, и для этого есть много причин. PHP изначально разрабатывался как язык для создания веб-сайтов, но, начиная с PHP 4.3, в 2002-ом году появилась официальная поддержка режима CLI, поэтому он уже давно перестал быть таковым. Разработчики Badoo на протяжении нескольких лет вполне успешно используют множество интерактивных CLI-утилит на PHP.

В данной статье нам хотелось бы поделиться своим опытом работы с CLI-режимом в PHP и дать несколько рекомендаций тем, кто собирается писать скрипты на PHP, при условии, что они будут запускаться в *nix-системе (впрочем, почти всё верно и для Windows).

Рекомендации


Скорость работы

Распространено мнение, что PHP — язык медленный, и таковым он является на самом деле. Для PHP CLI рекомендуется не использовать тяжелые фреймворки и даже просто большие библиотеки на PHP по двум причинам:
  1. Время работы include/require в CLI-режиме будет всегда включать в себя парсинг и исполнение, т.к. байткод в этом режиме не кэшируется (по крайней мере — по умолчанию), а значит, инициализация займет много времени, даже если из-под веб-сервера всё работает достаточно быстро.
  2. Пользователи веб-сайтов привыкли ждать некоторое количество времени для загрузки страницы (около 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, болтливый). Действительно, не стоит засорять экран без причины. Найти баланс бывает непросто, но есть несколько простых рекомендаций:
  1. Если операция не займет много времени (меньше 10-ти секунд), не выводите вообще ничего;
  2. Если вы делаете что-то нетривиальное (например, монтируете временные устройства с использованием sudo), наоборот, сообщите пользователю об этом, чтобы он знал, что делать в случае ошибки;
  3. Если операция длительная и для неё возможно показывать прогресс выполнения, лучше этот самый прогресс показывать (для этого может пригодиться функция cSystem, указанная выше);
  4. Если программа может работать как фильтр (например 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
Tags:badooбадуphp-clisystemposixcliphp
Hubs: Badoo corporate blog
Total votes 96: ↑83 and ↓13+70
Views41K

Popular right now

Information

Founded
2006
Location
Россия
Website
badoo.com
Employees
501–1,000 employees
Registered
Representative
Yuliya Telezhkina

Habr blog