[Go to site: main page, start]

Process: запуск внешних программ

Nette\Utils\Process позволяет запускать внешние программы из PHP: подавать им ввод, читать их вывод и реагировать на то, как они завершились. Это дружелюбная обёртка над proc_open() из PHP, которая сообщает об ошибках исключениями, а не возвратом false.

Установка:

composer require nette/utils

Во всех примерах предполагается, что определён такой псевдоним:

use Nette\Utils\Process;

Самое простое использование

Хотите запустить программу и прочитать, что она вывела? Вот и всё, что нужно:

$process = Process::runExecutable('git', ['log', '-1', '--format=%H']);
echo $process->getStdOutput();

Первый аргумент – запускаемая программа, второй – список её аргументов: то же самое, что вы набрали бы в командной строке, только разбитое на массив. Метод getStdOutput() дожидается завершения программы и возвращает всё, что она записала в стандартный вывод.

В этом и вся идея: вы запускаете процесс, а затем задаёте ему вопросы: работает ли он ещё, что он вывел, как завершился. Остальная часть этой страницы разбирает эти вопросы по очереди.

Запуск процесса

Есть два способа запустить процесс, и разницу между ними стоит понимать.

static runExecutable (string $executable, array $arguments=[], ?array $env=null, array $options=[], mixed $stdin='', mixed $stdout=null, mixed $stderr=null, ?string $directory=null, ?float $timeout=60): Process

Запускает одну конкретную программу со списком аргументов. Аргументы передаются программе напрямую, поэтому вам никогда не приходится экранировать пробелы, кавычки и другие специальные символы. А поскольку оболочка не участвует, нет и риска shell injection. Это безопасный выбор, особенно когда какая-то часть команды берётся из пользовательского ввода:

$file = $_GET['file']; // может быть чем угодно, даже '; rm -rf /'
$process = Process::runExecutable('wc', ['-l', $file]); // совершенно безопасно

Если вы не указываете полный путь, программа ищется в системном PATH. Для запуска PHP-скрипта пригодится константа PHP_BINARY:

$process = Process::runExecutable(PHP_BINARY, ['-v']);

static runCommand (string $command, ?array $env=null, array $options=[], mixed $stdin='', mixed $stdout=null, mixed $stderr=null, ?string $directory=null, ?float $timeout=60): Process

Запускает строку команды через системную оболочку (/bin/sh в Linux и macOS, cmd.exe в Windows). Это даёт вам возможности оболочки: конвейеры |, перенаправления >, подстановку переменных, объединение команд через && и так далее:

$process = Process::runCommand('git log --oneline | head -n 20');

Но поскольку оболочка разбирает всю строку целиком, никогда не собирайте строку для runCommand() из недоверенного ввода – это классическая дыра в безопасности. Если сомневаетесь, используйте runExecutable().

При таком количестве параметров передавайте их как именованные аргументы, например Process::runExecutable('git', ['pull'], timeout: 30). Массив $options передаётся в proc_open() для продвинутых нужд, таких как bypass_shell в Windows.

Процесс работает в фоне

После запуска процесс работает параллельно вашему PHP-скрипту: runExecutable() и runCommand() возвращаются сразу и не ждут его завершения. Вы сами решаете, когда (и нужно ли вообще) ждать:

$process = Process::runExecutable('npm', ['install']);

// ... здесь делаем другую работу, пока npm выполняется ...

$process->wait(); // теперь блокируемся до его завершения

На практике вы редко вызываете wait() сами, потому что getStdOutput(), getExitCode(), isSuccess() и ensureSuccess() дожидаются процесса автоматически, прежде чем дать вам ответ. Вызывайте wait() явно, когда хотите передать в него callback.

isRunning(): bool

Возвращает true, пока процесс ещё выполняется, и false, как только он завершился или был остановлен. Удобно, чтобы тем временем делать другую работу:

while ($process->isRunning()) {
	// какое-то время делаем что-то другое
	usleep(100_000); // 100 мс
}

Как он завершился?

У каждого завершившегося процесса есть код выхода: по соглашению 0 означает успех, а любое другое число – какой-то сбой (что именно, зависит от программы).

getExitCode(): int

Возвращает код выхода, при необходимости сначала дождавшись завершения процесса:

$code = Process::runExecutable('git', ['pull'])->getExitCode(); // например, 0

isSuccess(): bool

Сокращение для вопроса “равен ли код выхода 0?”:

$process = Process::runExecutable('git', ['pull']);
if (!$process->isSuccess()) {
	echo 'git failed: ' . $process->getStdError();
}

ensureSuccess(): void

Часто вам просто нужно, чтобы программа отработала успешно, а иначе громко упала. ensureSuccess() дожидается процесса и выбрасывает Nette\Utils\ProcessFailedException, если код выхода не 0:

Process::runExecutable('git', ['pull'])->ensureSuccess();
// выполнение продолжится только в случае успеха git

Чтение вывода

У процесса два отдельных потока вывода: стандартный вывод (обычные результаты) и стандартный поток ошибок (куда программы обычно сообщают о проблемах и диагностике). Nette Utils держит их раздельно и по умолчанию перехватывает оба в память, чтобы вы могли прочитать их когда угодно.

getStdOutput(): string

Дожидается завершения процесса и возвращает всё, что он записал в стандартный вывод:

$process = Process::runExecutable('date');
echo $process->getStdOutput();

getStdError(): string

То же самое, но для стандартного потока ошибок:

$process = Process::runExecutable('some-tool', ['--do-stuff']);
if (!$process->isSuccess()) {
	throw new RuntimeException('The tool failed: ' . $process->getStdError());
}

Если вы перенаправите поток вывода (в файл, в ресурс или в false), в памяти нечего возвращать, и соответствующий геттер выбросит Nette\InvalidStateException.

consumeStdOutput(): string

Иногда вы хотите видеть вывод по мере его появления, не дожидаясь завершения процесса, например чтобы показывать ход работы. Каждый вызов возвращает ту часть стандартного вывода, которая появилась с предыдущего вызова:

$process = Process::runExecutable('long-running-tool');

while ($process->isRunning()) {
	echo $process->consumeStdOutput(); // выводит всё новое
	usleep(100_000); // 100 мс
}
echo $process->consumeStdOutput(); // последний кусок, появившийся прямо перед завершением

Вызов consumeStdOutput() после цикла важен: процесс мог записать последний вывод во время финального usleep(), после последнего вызова внутри цикла, но до того, как цикл заметил его завершение. (Если же он завершился во время вызова внутри цикла, тот вызов уже вернул всё, и этот вернёт пустую строку.) Для стандартного потока ошибок есть consumeStdError().

Наблюдение за выводом в реальном времени

Вместо опроса через consumeStdOutput() вы можете передать в wait() callback. Он будет вызываться каждый раз, когда появляется новый вывод, что отлично подходит для живого логирования или переброски вывода куда-нибудь:

$process = Process::runExecutable('npm', ['install']);

$process->wait(function (string $stdOut, string $stdErr) {
	echo $stdOut;            // перебрасываем стандартный вывод
	fwrite(STDERR, $stdErr); // и стандартный поток ошибок
});

Callback получает две строки: новые данные стандартного вывода и новые данные стандартного потока ошибок с предыдущего вызова (любая из них может быть пустой). Когда wait() возвращается, процесс завершён, и вы всё ещё можете вызвать getExitCode(), getStdOutput() и остальное.

Отправка ввода

Параметр $stdin определяет, что процесс читает на своём стандартном вводе. Он принимает несколько разных вещей.

Строка становится всем вводом процесса:

$process = Process::runExecutable('wc', ['-c'], stdin: 'hello world');
echo $process->getStdOutput(); // 11

Ресурс, доступный для чтения (открытый файл, поток) копируется на вход:

$file = fopen('data.csv', 'r');
$process = Process::runExecutable('sort', stdin: $file);

null оставляет ввод открытым, чтобы вы могли писать в него постепенно (см. ниже).

По умолчанию используется пустая строка, то есть процесс получает пустой, сразу же закрытый ввод. Это разумное значение по умолчанию: оно не даёт программам, читающим ввод, вечно ждать того, что никогда не придёт.

writeStdInput (string $string)void

Когда вы запускаете процесс с stdin: null, ввод остаётся открытым, и вы наполняете его по частям. Когда закончите, вызовите closeStdInput(). Это сообщает программе, что ввода больше не будет (посылается признак конца файла):

$process = Process::runExecutable('some-repl', stdin: null);
$process->writeStdInput("first command\n");
$process->writeStdInput("second command\n");
$process->closeStdInput();
echo $process->getStdOutput();

Строка или поток, переданные как $stdin, записываются целиком до того, как процесс по-настоящему заработает. Если такой ввод велик и программа выдаёт много вывода, не прочитав сначала ввод, обе стороны могут застрять в ожидании друг друга. В этом (редком) случае используйте stdin: null и writeStdInput(), чтобы чередовать запись с чтением.

Соединение процессов (конвейер)

Вы можете подключить стандартный вывод одного процесса прямо на стандартный ввод другого, ровно как конвейер оболочки |. Достаточно передать Process в качестве $stdin:

$producer = Process::runExecutable('cat', ['big.log']);
$consumer = Process::runExecutable('grep', ['error'], stdin: $producer);

echo $consumer->getStdOutput();

Соединять можно сколько угодно процессов (a | b | c).

Соединение процессов конвейером не поддерживается в Windows (выбрасывается Nette\NotSupportedException). В Windows перехватите вывод первого процесса через getStdOutput() и передайте его следующему строкой.

Перенаправление вывода

По умолчанию стандартный вывод и стандартный поток ошибок перехватываются в память. Параметры $stdout и $stderr позволяют отправить их куда-нибудь ещё.

Имя файла отправляет вывод в этот файл:

Process::runExecutable('mysqldump', ['mydb'], stdout: 'backup.sql')
	->ensureSuccess();

Ресурс, доступный для записи отправляет вывод в этот поток. Он должен опираться на настоящий файл (не php://memory и подобное):

$log = fopen('build.log', 'a');
Process::runExecutable('make', stdout: $log, stderr: $log);

false полностью отбрасывает вывод (он уходит в /dev/null или в NUL в Windows):

Process::runExecutable('noisy-tool', stderr: false);

Перенаправление к тому же снижает расход памяти: перехват в память удобен, но процесс, выводящий гигабайты, займёт гигабайты оперативной памяти, поэтому такой вывод пишите в файл.

Переменные окружения

Параметр $env задаёт переменные окружения, которые увидит процесс. Оставьте его равным null (значение по умолчанию), чтобы унаследовать окружение текущего процесса, или передайте массив, чтобы задать их самостоятельно:

// текущее окружение плюс одна дополнительная переменная
$process = Process::runExecutable('printenv', ['MY_VAR'], env: ['MY_VAR' => '123'] + getenv());

// полностью пустое окружение
$process = Process::runExecutable('some-tool', env: []);

Рабочий каталог

Параметр $directory задаёт каталог, в котором запускается процесс (по умолчанию это текущий):

$process = Process::runExecutable('git', ['status'], directory: '/path/to/repo');

Ограничение времени

Параметр $timeout (в секундах, по умолчанию 60) ограничивает, сколько вы будете ждать процесс. Если предел достигнут, пока вы ждёте его или читаете его вывод, процесс уничтожается и выбрасывается Nette\Utils\ProcessTimeoutException. Передайте null, чтобы снять ограничение:

$process = Process::runExecutable('slow-tool', timeout: 5.0);
try {
	$process->wait();
} catch (Nette\Utils\ProcessTimeoutException $e) {
	echo 'The tool took too long and was terminated.';
}

Предел проверяется только тогда, когда вы находитесь внутри wait(), getExitCode(), геттеров вывода или consume*(). Процесс, который вы запустили и никогда не ждали, им не уничтожается.

Остановка процесса

terminate(): void

Немедленно уничтожает процесс, если тот ещё выполняется; ничего не делает, если он уже завершился:

$process = Process::runExecutable('server');
// ...
$process->terminate();

Процесс также автоматически уничтожается, когда его объект Process разрушается (например, выходит из области видимости) до его завершения. Если вам это не нужно, отсоедините процесс от объекта:

detach(): void

Отсоединяет процесс от объекта: он продолжает работать в фоне и больше не уничтожается при разрушении объекта. Именно так запускается демон или фоновая задача, которая переживает даже сам PHP-скрипт:

$process = Process::runExecutable('worker', stdout: 'worker.log', stderr: false);
$process->detach();
// процесс продолжает работать даже после разрушения $process

Поскольку после отсоединения вывод никто читать не будет, его нельзя перехватывать в память: перенаправьте его в файл, в ресурс или в false, иначе detach() выбросит Nette\InvalidStateException. Стандартный ввод и каналы вывода при отсоединении закрываются.

Меняется только поведение деструктора. wait() и getExitCode() по-прежнему дожидаются завершения процесса (и $timeout по-прежнему действует и уничтожает его при превышении), а terminate() по-прежнему его завершает.

В POSIX-системах отсоединённый процесс, завершившийся, пока ваш скрипт ещё работает, отображается в списке процессов как зомби до окончания скрипта. Это безвредно и проходит само собой.

getPid(): ?int

Возвращает идентификатор процесса операционной системы (PID), пока процесс выполняется, или null после его завершения:

$pid = $process->getPid();

Когда что-то пошло не так

Об ошибках всегда сообщается выбрасыванием исключения, никогда возвращаемым значением:

Nette\Utils\ProcessFailedException процесс не удалось запустить либо был вызван ensureSuccess(), а код выхода оказался не 0
Nette\Utils\ProcessTimeoutException превышен предел $timeout
Nette\InvalidArgumentException в $stdin, $stdout или $stderr передано некорректное значение
Nette\IOException файл, заданный в $stdout или $stderr, не удалось открыть
Nette\InvalidStateException чтение вывода, который не перехватывался, запись в уже закрытый STDIN или отсоединение процесса, вывод которого перехватывается в память
Nette\NotSupportedException попытка соединить процессы конвейером в Windows

ProcessFailedException и ProcessTimeoutException расширяют RuntimeException из PHP.

версия: 4.x