Symfony: The Process Component

Согласно официальной документации: Process Component позволяет выполнять команды в под-процессах.

Начнём с самого просто — с установки. Если по какой-то причине у вас нету этой компоненты, установить её можно очень просто через уже нами любимый composer:

composer require symfony/process

Либо склонировать репозиторий с git’а. Если же вы устанавливаете компонент за пределами Symfony Application то вам стоит заглянуть сюда. Но мы не будем отвлекаться на всякое. Нас очень интересуют процессы и давайте же перейдём непосредственно к ним!

Использование.

Класс Process исполняет команду в подпроцессе, учитывая особенности различных операционных систем и экранирует аргументы в целях безопасности. Может заменять такие функции как: exec, passthru, shell_exec и system. Простой пример использования:

use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
$process = new Process(array('ls', '-lsa'));
$process->run();
// Выполнится после завершения команды
if (!$process->isSuccessful()) {
    throw new ProcessFailedException($process);
}
echo $process->getOutput();

Этот кусок кода создаёт процесс и запускает его. В этом процессе выполняется команда «ls -lsa«. После завершения проверяется успешно ли завершился процесс, и, если нет, выкидывается исключение. Выводится через команду echo на экран стандартный вывод этого процесса.

Обратите внимание, что передавать команды процессу можно в виде массива, а сам компонент уже расставит пробелы. Вы скажете «Так себе достижение!», но при использовании большой команды это может сыграть только на руку.))

// СТРОКА! ОБЫЧНАЯ! СКУЧНАЯ!!!
$builder = new Process('ls -lsa');
// Так же, но с массивом
$builder = new Process(array('ls', '-lsa'));
// Массив может состоять из любых аргументов/ключей и неограниченного количества
$builder = new Process(array('ls', '-l', '-s', '-a'));

Во всех процессах выше выполняется одна и та же команда. Стоит отметить, что array(‘ls’, ‘-lsa’) и array(‘ls’, ‘-l’, ‘-s’, ‘-a’) эквивалентны только в силу особенностей ОС («ls -lsa» и «ls -l -s -a» — интерпретируются, как эквивалентные).

А теперь ближе к методам. Метод getOutput() возвращает всё со стандартного вывода команды, а метод getErrorOutput() всё содержимое вывода ошибки. Так же есть методы getIncrementalOutput() и getIncrementalErrorOutput(), которые возвращают изменения стандартного потока вывода и стандартного потока ошибки с момента последнего вызова.

Если вы затеяли уборки в доме и решили прибраться ещё в потоках вывода и ошибки, то вам пригодятся clearOutput() и clearErrorOutput(), которые очищают стандартный поток вывода и стандартный поток ошибки соответственно.

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

$process = new Process(array('ls', '-lsa'));
$process->start();
foreach ($process as $type => $data) {
    if ($process::OUT === $type) {
        echo "\nПрочитали с stdout: ".$data;
    } else { // Как будто бы $process::ERR === $type
        echo "\nПрочитали с stderr: ".$data;
    }
}

Здесь стоит сделать отступление. Стоит поговорить об итерируемых объектах в PHP. Это одна из самых обширных тем — поэтому мы попытаемся её объяснить, как можно более кратко.

Массивы в PHP

Начнём с массивов. Массивы в PHP ассоциативные — это значит, что в массиве хранится набор пар «ключ, значение». В качестве ключа используется обычно число или строка. Значение может быть любым объектом. Пример:

$arr = ['foo' => 'bar', 'baz' => 42, 'arr' => [1, 2, 3]];

Ключ так же называется «индексом» — в PHP это равнозначные термины. Для массивов определён широкий набор операций:

// Вставка в массив
$arr['new'] = 'some value';
// Вставка с автоматической генерацией индекса
// По умолчанию, это число 0, если оно не занято
$arr[] = 'another value';
// Доступ к элементу по ключу
echo $arr['foo'];
echo $arr[$bar];
// Удаление элемента по индексу
unset($arr['foo']);
// "Распаковка" массива
[$foo, $bar, $baz] = $arr;

Под «распаковкой массива» подразумевается запись в переменные из левой части выражения ($foo, $bar, $baz) значений из массива. По сути выше обозначенная запись распаковки массива эквивалентна набору таких выражений:

// "Распаковка" массива
[$foo, $bar, $baz] = $arr;
// Развёрнутая запись распаковки
$foo = $arr[0];
$bar = $arr[1];
$baz = $arr[2];

Будет ошибка, если элементы с этими индексами не определены, даже если в массиве больше 3-ёх элементов. Все методы для работы с массивами можно посмотреть в официальной документации.

Но это была преамбула, нас интересует процесс итерации. Начнём так же с массивов, а потом вернёмся к объектам. Итерация — процесс обхода любого объекта. Самый простой пример процесса итерации это, конечно же, совместный цикл, реализованный оператором foreach:

foreach ($arr as $key=>$val) {
  echo $key . '=>' . $val;
  echo "\n";
}

Этот кусок кода выведет все пары «ключ=>значение» из массива $arr.

Но как же PHP понимает — какой элемент массива взять на конкретном шаге цикла? Какой взять следующим? И когда остановиться?

Для ответа на этот вопрос следует знать о существовании так называемого «внутреннего указателя», существующего в каждом массиве. Этот невидимый указатель указывает на «текущий» элемент и умеет сдвигаться на шаг вперед — на следующий элемент или снова сбрасываться на первый элемент. Этот указатель и называется итератором. Строго говоря, итератор — это объект, который позволяет обходить контейнеры(или просто классы), не раскрывая их внутреннюю структуру. В PHP существует специальный интерфейс Iterator состоящий из пяти методов:

// Метод должен вернуть значение текущего элемента
public function current();
// Метод должен вернуть ключ текущего элемента
public function key();
// Метод должен сдвинуть "указатель" на следующий элемент
public function next(): void;
// Метод должен поставить "указатель" на первый элемент
public function rewind(): void;
// Метод должен проверять - не вышел ли указатель за границы?
public function valid(): bool

Ваш класс должен реализовать эти методы и тогда вы получите возможность итерировать объекты этого класса с помощью цикла foreach в соответствии с реализованным алгоритмом.

Вернёмся к Process

Process внутри использует PHP iterator для получения вывода. Изменить поведение этого итератора можно через метод getIterator():

$process = new Process(array('ls', '-lsa'));
$process->start();
$iterator = $process->getIterator($process::ITER_SKIP_ERR | $process::ITER_KEEP_OUTPUT);
foreach ($iterator as $data) {
    echo $data."\n";
}

Такая запись заблокирует вывод ошибки ($process::ITER_SKIP_ERR) и будет выводить только результаты со стандартного вывода ($process::ITER_KEEP_OUTPUT).

Метод mustRun() делает то же, что и метод run() за исключением исключения (Ха!) ProcessFailedException, которое генерируется, если процесс завершается с ненулевым кодом:

use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
$process = new Process(array('ls', '-lsa'));
try {
    $process->mustRun();
    echo $process->getOutput();
} catch (ProcessFailedException $exception) {
    echo $exception->getMessage();
}

Метод run() не генерирует исключений, связанных с некорректным завершением процессов. Проще говоря, mustRun() стоит использовать, когда необходимо корректно завершить процесс.

Ещё один плюс использование массивов взамен простых строк:

// На Unix подобных системах (Linux, macOS)
$process = new Process('echo "$MESSAGE"');
// На Windows
$process = new Process('echo "!MESSAGE!"');

Как мы видим, строки, передаваемые в процессы, отличаются. И это не удобно — нужно затачивать свой код под определённую ОС или плодить код, чтобы угодить все операционным системам. Но выход есть:

// Работает везде! Нет, не смей это запускать на DOS!
$process->run(null, array('MESSAGE' => 'Something to output'));

Получение результатов в режиме реального времени.

При длительной работе процесса есть возможность получать ответ в реальном времени. Для этого нужно передать в run() анонимную функцию:

use Symfony\Component\Process\Process;
$process = new Process(array('ls', '-lsa'));
$process->run(function ($type, $buffer) {
    if (Process::ERR === $type) {
        echo 'ERR > '.$buffer;
    } else {
        echo 'OUT > '.$buffer;
    }
});

Функция, которая передаётся в качестве обратного вызова, будет срабатывать каждый раз, как будет генерироваться выход (сообщение или ошибка). В данном случае, на экран будет выводить как полученный результат работы процесса, так и ошибки, возникшие во время работы.

Выполнение процессов асинхронно.

Чтобы запустить процесс асинхронно нужно всего лишь вызвать метод start(). Метод isRunning() показывает завершил ли процесс свою работу. И уже знакомый нам метод getOutput() для возврата результата:

$process = new Process(array('ls', '-lsa'));
$process->start();
while ($process->isRunning()) {
    // Ждём завершения процесса
}
echo $process->getOutput();

Так же можно запустить асинхронно и заняться своими делами:

$process = new Process(array('ls', '-lsa'));
$process->start();
// Делаем то, что не требует завершения процесса
$process->wait();
// Тут уже процесс завершился, можно продолжить

Метод wait() блокирует — это означает, что работа кода будет остановлена на этой функции и возобновится только тогда, когда процесс закончит свою работу.

Если родительский процесс завершится раньше, чем дочерний процесс закончит свою работу — дочерний процесс будет убит. Да, не смотря на асинхронность. Да, это именно так работает. С точки зрения Process Component выполнение асинхронного процесса — это не тоже самое, что запустить процесс, который может пережить родительский процесс. Запуск процесса асинхронно(с использованием этого компонента) означает создание дочернего процесса, который будет выполняться параллельно родительскому процессу, но закончит свою жизнь не позже родительского. Если вы хотите чтобы ваш процесс полностью прожил цикл запроса/ответа, вы можете воспользоваться событием kernel.terminate и запустить свою команду синхронно внутри этого события. имейте в виду, что kernel.terminate вызывается только в том случае, если вы используете PHP-FPM. Но это не рекомендуется использовать новичкам. Ведь, если вы создадите процесс, который не доступен для любого нового запроса до завершения под-процесса, вы очень быстро можете заблокировать пул FPM, если не будете осторожны.

Метод wait() так же может принимать на вход один необязательный аргумент — callback-функцию: 

$process = new Process(array('ls', '-lsa'));
$process->start();
$process->wait(function ($type, $buffer) {
    if (Process::ERR === $type) {
        echo 'ERR > '.$buffer;
    } else {
        echo 'OUT > '.$buffer;
    }
});

В этом месте callback функция работает аналогично примеру выше.

Что ж, это было занимательно! Долгих дней и приятных ночей! До встречи во второй части.)

Подключение к стандартному вводу процесса

Перед запуском процесса вы можете указать его стандартный ввод (грубо говоря, это место, откуда процесс получает информацию), используя либо метод setInput(), либо 4-й аргумент конструктора. Предоставляемый вход может представлять собой строку, ресурс потока(имеется в виду PHP’шные потоки) или Traversable object (обходимый или итерируемый объект с использованием foreach), с последними мы немного разбирались в предыдущей статье — это может быть любой массив и др.:

$process = new Process('cat');
$process->setInput('foobar');
$process->run();

После запуска процесса метод setInput перестаёт быть доступен. Чтобы записать на стандартный вход под-процесса во время его запуска, компонент предоставляет класс InputStream:

$input = new InputStream();
$input->write('foo');
$process = new Process(array('cat'));
$process->setInput($input);
$process->start();
// ... что-то делаем (можем прочитать результат процесса)
$input->write('bar');
$input->close();
$process->wait();
// выведет "foobar"
echo $process->getOutput();

Метод write() может дописывать информацию в поток после его запуска. write() может принимать в качестве аргументов то же самое, что и метод setInput(). Как показано в примере выше, вам нужно явно вызвать метод close(), когда вы закончите запись стандартного ввода под-процесса — проще говоря, после write(..) должен идти close(), а то чуда не произойдёт.

Использование потоков PHP в качестве стандартного ввода процесса

Вот тут то мы и рассмотрим как поток подавать на вход — ввод процесса также можно определить с помощью потоков PHP:

$stream = fopen('php://temporary', 'w+');
$process = new Process(array('cat'));
$process->setInput($stream);
$process->start();
fwrite($stream, 'foo');
// ... что-то делаем (можем прочитать результат процесса)
// $process->getOutput() тут вернёт "foo"
fwrite($stream, 'bar');
fclose($stream);
$process->wait();
// выведет "foobar"
echo $process->getOutput();

Обратите внимание на то, что мы «подкидываем» свежие данные в поток $stream тогда, когда процесс уже запущен. Так же тут используется fopen(…), с помощью этого метода мы можем открывать файл и перезаписывать его, но в этом случае мы редактируем(точнее модифицируем) поток. 

Прекращение процесса

Любой асинхронный процесс может быть остановлен в любое время с помощью метода stop(). Этот метод принимает два аргумента: время жизни потока и сигнал, который он пошлёт по завершению в текущий процесс. Время жизни указывается в секундах. Сигналом по-умолчанию, является SIGKILL. Хотите узнать больше об обработке сигналов в компоненте Process, нажмите сюда или прочитайте ниже.

$process = new Process(array('ls', '-lsa'));
$process->start();
// ... что-то делаем
$process->stop(3, SIGINT);

Выполнение PHP кода в изоляции

Если вы хотите изолировать PHP код, используйте PhpProcess:

use Symfony\Component\Process\PhpProcess;
$process = new PhpProcess(<<<EOF
    <?= 'Hello World' ?>
EOF
);
$process->run();

В данном случае под изоляцией кода подразумевается, что вы можете написать код в виде строки внутри своего скрипта и он будет исполнятся внутри процесса.

Timeout процесса

По умолчанию процессы имеют timeout равный 60 секундам, но это можно изменить, передавая другое значение в секундах методу setTimeout():

use Symfony\Component\Process\Process;
$process = new Process(array('ls', '-lsa'));
$process->setTimeout(3600);
$process->run();

Timeout в данном примере 3600 секунд, а это же 60 минут! Или целый час!!!

Если timeout достигнут, генерируется исключение RuntimeException. При выполнении длительных команд, проверка timeout’а ложится на ваши мужественные плечи:

$process->setTimeout(3600);
$process->start();
while ($condition) {
    // ...
    // проверяем, достигли ли мы timeout'а
    $process->checkTimeout();
    usleep(200000);
}

Время ожидания простоя процесса

Тайм-аут — это время с начала работы, а если мы хотим завершить процесс после того, как он начнёт лениться?! В этом нам поможет setIdleTimeout(..), который в свою очередь и отвечает за время простоя процесса. Время так же указывается в секундах.

use Symfony\Component\Process\Process;
$process = new Process(array('something-with-variable-runtime'));
$process->setTimeout(3600);
$process->setIdleTimeout(60);
$process->run();

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

Сигналы процесса

При асинхронном запуске программы вы можете отправлять сигналы POSIX с помощью метода signal():

use Symfony\Component\Process\Process;
$process = new Process(array('find', '/', '-name', 'rabbit'));
$process->start();
// отправит SIGKILL процессу
$process->signal(SIGKILL);

Зачем? Куда? Что? — скажете вы. Это может пригодиться, когда родительский процесс хочет контролировать и слишком опекает дочерний требует более подробную информацию о дочернем процессе и, в зависимости от сигнала, как-то корректирует свою работу.

Pid процесса

Вы можете получить доступ к pid(уникальный идентификатор процесса) запущенного процесса с помощью метода getPid():

use Symfony\Component\Process\Process;
$process = new Process(array('/usr/bin/php', 'worker.php'));
$process->start();
$pid = $process->getPid();

Отключение вывода

Поскольку стандартный вывод и вывод ошибок всегда «пишутся», может быть полезно отключить вывод для сохранения памяти. Используйте disableOutput() и enableOutput()для отключения и включение соответственно:

use Symfony\Component\Process\Process;
$process = new Process(array('/usr/bin/php', 'worker.php'));
$process->disableOutput();
$process->run();

Включать и отключать вывод можно только до запуска процесса. Если вывод выключен, вы не сможете получить доступ к getOutput(), getIncrementalOutput(), getErrorOutput, getIncrementalErrorOutput() или setIdleTimeout(). Тем не менее, callback функции работают замечательно.

Поиск исполняемого двоичного кода PHP

Этот компонент также предоставляет утилиты под названием PhpExecutableFinder, который возвращает абсолютный путь исполняемого двоичного файла PHP, доступного на вашем сервере:

use Symfony\Component\Process\PhpExecutableFinder;
$phpBinaryFinder = new PhpExecutableFinder();
$phpBinaryPath = $phpBinaryFinder->find();
// $phpBinaryPath = '/usr/local/bin/php' (результат может отличаться на вашем компьютере)

Проверка поддержки TTY

Другой утилитой, предоставляемой этим компонентом, является метод, называемый isTtySupported(), который возвращает, поддерживается ли TTY в текущей операционной системе:

use Symfony\Component\Process\Process;
$process = (new Process())->setTty(Process::isTtySupported());

На этом с Symfony Process мы закончили. Теория оказалась достаточно нудная, но это мы исправим чуточку позже.)) Долгих дней и приятных ночей!