Глава 60 · Практика CLI и интеграций
PHP запускает Python, Node.js и другие программы: отчёт, JSON и проверка ошибок
Передайте заказы внешнему обработчику и получите проверенный отчёт. exec и proc_open, настоящий дочерний процесс, stdin/stdout/stderr, Python/Node workers и границы безопасного запуска.
PHP поручает Python подготовить список на выдачу
Три потока связывают PHP с обработчиком отчёта
PHP передаёт две строки заказов отдельной программе. Результат и диагностика возвращаются разными каналами.
- 0 · stdin — вход
PHP → worker: JSON с количеством и ценой. Программа читает данные, а не shell-команду от пользователя.
- 1 · stdout — результат
Worker → PHP: JSON с rows=2 и totalMinor=9000. Отладочный echo здесь испортит формат ответа.
- 2 · stderr — диагностика
Worker → PHP: processed=2 или код ошибки. Этот текст не склеивается с JSON результата.
В примере proc_open подключает временные файлы к этим потокам. Отдельный exit code проверяют до JSON; stderr бывает и при успехе.
В мастерской уже есть маленькая Python-программа: она читает CSV с инструментами и печатает список для сотрудника. Нужно вызвать её из PHP и показать весь отчёт. Результат нашего запуска: «Дрель: 2», «Пила: 1», «Итого к выдаче: 3». Это полезная граница между языками: Python выполняет свою задачу, PHP решает, как представить результат.
Процесс — отдельно запущенная программа. Вызов обычной PHP-функции использует тот же процесс; запуск Python создаёт другой, со своими переменными. Их общие договорённости здесь — файл orders.csv, stdout (обычный вывод), stderr (диагностика) и целый код завершения. Переменная Python не превращается сама в переменную PHP.
Распакуйте три файла из примера: example.php, report.py и orders.csv. Установите Python 3. В PowerShell задайте $env:PHPMAN_PYTHON = 'C:\путь\к\python.exe', затем php example.php. В bash: export PHPMAN_PYTHON=/полный/путь/python3. Подставьте существующий путь вашей установки. PHP CLI должен разрешать exec. Python не обязан находиться в PATH, и архив не устанавливает интерпретатор.
- orders.csv: Дрель,2 и Пила,1
- PHP запускает Python 3 report.py orders.csv
- Python складывает 2 + 1 и печатает три строки
- exec заполняет $lines, возвращает $last, записывает $exitCode
- PHP показывает весь список и проверяет успешное завершение
Полный файл orders.csv
title,quantity
Дрель,2
Пила,1
Полный файл report.py
import csv
import sys
with open(sys.argv[1], encoding="utf-8", newline="") as source:
total = 0
for row in csv.DictReader(source):
quantity = int(row["quantity"])
if quantity < 1:
print("invalid_quantity", file=sys.stderr)
sys.exit(4)
total += quantity
print(f'{row["title"]}: {quantity}')
print(f"Итого к выдаче: {total}")
В report.py встроенный csv.DictReader читает строки по заголовкам title и quantity; sys.argv[1] — путь, переданный PHP первым аргументом после имени скрипта. print пишет stdout. При количестве меньше 1 программа пишет invalid_quantity в stderr и sys.exit(4) завершает её с кодом 4. Здесь worker рассчитан на наш маленький доверенный CSV; произвольные импорты требуют собственного договора проверки.
В PHP getenv читает настройку пути из окружения, is_file проверяет существование. escapeshellarg оформляет один доверенный аргумент команды с учётом платформы; точка соединяет части. Параметр Python -X utf8 делает кодировку stdout предсказуемой. Все пути заданы нами, данные посетителя в строку команды не входят.
$lines = [] создаёт пустой массив строк: exec дополняет уже существующий массив. $exitCode = -1 — начальное диагностическое значение. $last = exec($command, $lines, $exitCode) имеет три результата: возвращаемая последняя строка, все строки через второй аргумент и код через третий. Знак & в месте вызова не ставят. implode соединяет строки массива переводом строки.
<?php
$python = getenv('PHPMAN_PYTHON');
if ($python === false || !is_file($python)) {
exit("Задайте PHPMAN_PYTHON: абсолютный путь к Python 3.\n");
}
$command = escapeshellarg($python) . ' -X utf8 '
. escapeshellarg(__DIR__ . '/report.py') . ' '
. escapeshellarg(__DIR__ . '/orders.csv');
$lines = [];
$exitCode = -1;
$last = exec($command, $lines, $exitCode);
if ($last === false || $exitCode !== 0) {
exit("Отчёт не построен. Проверьте диагностику программы.\n");
}
echo "Весь отчёт:\n", implode("\n", $lines), "\n";
echo 'Последняя строка отдельно: ', $last, "\n";
echo 'Код завершения: ', $exitCode, "\n";
Ожидаемый результат
Весь отчёт:
Дрель: 2
Пила: 1
Итого к выдаче: 3
Последняя строка отдельно: Итого к выдаче: 3
Код завершения: 0
Точный вывод при установленном Python 3 и PHPMAN_PYTHON указан выше. Для универсального CLI-проверяющего этот пример имеет режим проверки синтаксиса, потому что Python — отдельная зависимость. В отчёте web-practice-revision проверен настоящий Python3-запуск с фиксированным окружением; результат не выдаётся за запуск на любой машине.
Если написать только echo exec(...), вы увидите лишь «Итого к выдаче: 3». Именно потеря промежуточных строк была проблемой прочитанного вопроса PHP show return from python script. Возвращаемое значение не равно всему stdout; строки $lines не сохраняют завершающие пробельные символы. Для точных байтов и отдельных потоков выберем proc_open.
На Windows shell-экранирование имеет ограничения для символов вроде %, ! и кавычек; используйте контролируемую папку с простыми именами для первого exec-опыта. Для произвольных путей и аргументов предпочтителен следующий argv-массив. PHP_BINARY под веб-сервером также не следует считать автоматически Python или нужным CLI-интерпретатором.
Отдельно получить stderr и остановить выдачу при ошибке
Изменим требование: если в CSV отрицательное количество, выдачу выполнять нельзя, даже если программа успела напечатать первую строку. Сценарий входа теперь «Дрель,2», «Пила,-1». Python напечатает «Дрель: 2», затем сообщит invalid_quantity и завершится с кодом 4. PHP должен показать отказ и сохранить смысл диагностики.
proc_open принимает команду массивом: каждый элемент — отдельный аргумент, shell не нужен и escapeshellarg применять к элементам нельзя. 0, 1, 2 обозначают stdin, stdout и stderr ребёнка. Нулевой поток создаём как pipe для чтения ребёнком; родитель закрывает свою сторону, потому что в этом опыте ввод идёт из файла. Два временных потока tmpfile раздельно собирают вывод и закрываются в finally.
proc_close ждёт завершения и возвращает код. Затем rewind переводит временный поток к началу, stream_get_contents читает накопленные байты. Такой подход избегает взаимной блокировки двух непрочитанных pipe в этом маленьком опыте. Он не даёт квоту диска или время выполнения для произвольной программы.
<?php
$python = getenv('PHPMAN_PYTHON');
if ($python === false || !is_file($python)) {
exit("Задайте PHPMAN_PYTHON: абсолютный путь к Python 3.\n");
}
$stdout = tmpfile();
$stderr = tmpfile();
if ($stdout === false || $stderr === false) {
throw new RuntimeException('Не удалось открыть временные потоки.');
}
try {
$command = [$python, '-X', 'utf8', __DIR__ . '/report.py', __DIR__ . '/orders.csv'];
$descriptors = [0 => ['pipe', 'r'], 1 => $stdout, 2 => $stderr];
$process = proc_open($command, $descriptors, $pipes, __DIR__);
if ($process === false) {
throw new RuntimeException('Не удалось запустить Python.');
}
fclose($pipes[0]);
$exitCode = proc_close($process);
rewind($stdout);
rewind($stderr);
$report = stream_get_contents($stdout);
$diagnostic = stream_get_contents($stderr);
if ($report === false || $diagnostic === false) {
throw new RuntimeException('Не удалось прочитать результат Python.');
}
echo 'Код завершения: ', $exitCode, "\n";
if ($exitCode !== 0) {
echo "Выдача остановлена.\n";
echo 'Диагностика: ', trim($diagnostic), "\n";
echo "Успели проверить:\n", $report;
} else {
echo "Список на выдачу:\n", $report;
}
} finally {
fclose($stdout);
fclose($stderr);
}
Ожидаемый результат
Код завершения: 4
Выдача остановлена.
Диагностика: invalid_quantity
Успели проверить:
Дрель: 2
Запуск тот же: PHPMAN_PYTHON задаётся до php example.php. В архиве отрицательная строка намеренно делает результат неуспешным. Исправьте -1 на 1: код станет 0, появится «Список на выдачу:» и полный итог из трёх строк. Встроенный verifier выполняет оба варианта на реальном Python.
Код 0 обычно означает успех, но сам по себе не доказывает правильность данных. Для машинного JSON-ответа ниже проверяем ещё JSON и его схему. Непустой stderr иногда содержит обычную диагностику при коде 0, поэтому договор программы важнее правила «любой stderr — провал». Ненулевой код тоже не вызывает PHP-исключение автоматически.
Сервис не должен принимать имя программы или команду от посетителя. Исполняемый файл, рабочий каталог и окружение задаются проверенной серверной конфигурацией. Запуск работает с правами PHP-процесса; PATH и права могут отличаться от вашего терминала. Для долгих задач нужны очередь, внешний таймаут, ограничение объёма вывода и остановка дерева процессов; set_time_limit не является переносимой заменой такого контроля.
Упражнение: верните правильный CSV и добавьте «Отвёртка,4» — итог станет 7. Затем пустое/нечисловое quantity даст исключение Python с ненулевым кодом: PHP обязан остановиться, а техническую трассировку в веб-интерфейсе следует заменить понятным сообщением и записать в закрытый журнал. Следующая ступень — передать проверенные данные через stdin в JSON и применить тот же договор к Node.js.
Сначала результат: сайт получил готовый отчёт
Менеджер нажал «Построить отчёт». Сайт на PHP уже знает заказы, а нужный обработчик написан на Python. Переписывать работающий расчёт необязательно: PHP может передать данные отдельной программе и забрать результат. В наших примерах две строки заказов превращаются в «Строк: 2; сумма: 9000 коп.». Это 90 рублей, а не 9000 рублей: деньги в договоре передаются целым числом в минимальных единицах.
Знакомая короткая операция выглядит так; ниже есть её полный запускаемый вариант:
$result = runWorker($command, $orders);
$report = readReport($result);
echo $report['totalMinor']; // 9000- PHP проверил и закодировал заказы в UTF-8 JSON
- Дочерняя программа прочитала stdin и посчитала отчёт
- JSON результата пришёл в stdout; диагностика — отдельно в stderr
- PHP проверил код завершения, JSON и поля ответа
Почему здесь PHP запускает другой PHP? Первый опыт должен работать без Python, Node и библиотек: берём CLI-интерпретатор, которым уже запущен пример. Это настоящий второй процесс с собственными переменными и памятью. Затем меняем только команду на Python/Node, сохраняя формат сообщений.
Скачиваемые примеры содержат example.php и соседние worker/runner-файлы. Распакуйте весь ZIP и запускайте example.php через PHP CLI. В веб-окружении PHP_BINARY не следует автоматически считать путём к CLI; исполняемый файл задают в проверенной конфигурации. Код на сервере выполняется от учётной записи приложения, а не от вашего пользователя терминала.
exec: две строки результата и ошибка после частичного вывода
Небольшой доверенный CLI-инструмент уже печатает строковый итог? exec подходит для простого запуска и проверки завершения. Перед каждым вызовом заводите пустой массив вывода: существующий массив будет дополнен. Возвращаемая строка — последняя, а не весь отчёт. Строки в массиве лишаются завершающих пробельных символов; для точной передачи байтов выберите другой интерфейс.
<?php
declare(strict_types=1);
// Запускайте example.php через PHP CLI; worker.php лежит рядом из того же ZIP.
$command = escapeshellarg(PHP_BINARY)
. ' -n -d memory_limit=32M '
. escapeshellarg(__DIR__ . '/worker.php');
$lines = [];
$exitCode = -1;
$lastLine = exec($command, $lines, $exitCode);
if ($lastLine === false || $exitCode !== 0) {
throw new RuntimeException('Отчёт не построен');
}
echo 'Последняя строка: ', $lastLine, "\n";
echo 'Все строки: ', implode(' | ', $lines), "\n";
echo 'Код завершения: ', $exitCode, "\n";
Ожидаемый результат
Последняя строка: totalMinor=9000
Все строки: rows=2 | totalMinor=9000
Код завершения: 0
Реально запускается второй PHP-процесс. Его stdout содержит две строки; возвращаемое значение exec содержит только вторую. worker.php включён в архив. Это небольшой доверенный CLI-отчёт, а не механизм запуска команд из запроса посетителя.
Посмотреть файл worker.php — отчёт из примера
<?php
declare(strict_types=1);
$orders = [
['quantity' => 2, 'unitMinor' => 1500],
['quantity' => 1, 'unitMinor' => 6000],
];
$total = 0;
foreach ($orders as $order) {
$total += $order['quantity'] * $order['unitMinor'];
}
echo 'rows=', count($orders), "\n";
echo 'totalMinor=', $total, "\n";
Успешно полученный stdout ещё не означает успех операции. Проверяющая программа могла сообщить, сколько строк она рассмотрела, затем обнаружить проблему. Ненулевой exit не становится исключением PHP автоматически:
<?php
declare(strict_types=1);
$directory = sys_get_temp_dir() . '/phpman-exec-' . bin2hex(random_bytes(8));
if (!mkdir($directory, 0700)) {
throw new RuntimeException('Не создан каталог диагностики');
}
$errorPath = $directory . '/stderr.txt';
try {
$command = escapeshellarg(PHP_BINARY)
. ' -n -d memory_limit=32M '
. escapeshellarg(__DIR__ . '/worker.php')
. ' 2>' . escapeshellarg($errorPath);
$lines = [];
$exitCode = -1;
$lastLine = exec($command, $lines, $exitCode);
if ($lastLine === false) {
throw new RuntimeException('Не удалось выполнить команду');
}
$diagnostic = file_get_contents($errorPath, false, null, 0, 257);
if ($diagnostic === false || strlen($diagnostic) > 256) {
throw new RuntimeException('Некорректный объём диагностики');
}
if ($exitCode !== 0) {
echo 'Импорт отклонён: exit ', $exitCode, "\n";
echo 'stdout: ', implode(' | ', $lines), "\n";
echo 'stderr: ', trim($diagnostic), "\n";
} else {
echo "Проверка разрешила продолжить импорт\n";
}
} finally {
if (is_file($errorPath)) {
unlink($errorPath);
}
rmdir($directory);
}
Ожидаемый результат
Импорт отклонён: exit 4
stdout: checked=2
stderr: invalid_quantity
Дочерняя программа напечатала checked=2, но обнаружила отрицательное количество и вернула 4. Родитель не продолжает импорт. Диагностика направлена в новый файл собственного временного каталога; finally удаляет только этот файл и каталог. До перенаправления родитель не держит файл открытым — это существенно для Windows. Показанная команда полностью определена кодом. Сам fixture короткий; ограничение чтения не ограничивает потенциальную запись произвольного процесса на диск.
Посмотреть файл worker.php — проверка импорта из примера
<?php
declare(strict_types=1);
// Два результата предварительной проверки не означают успешный импорт.
$quantities = [2, -1];
echo 'checked=', count($quantities), "\n";
foreach ($quantities as $quantity) {
if ($quantity < 1) {
fwrite(STDERR, "invalid_quantity\n");
exit(4);
}
}
echo "ready\n";
proc_open: массив аргументов и договор JSON через stdin/stdout
Для отчёта удобнее передать структуру данных целиком, а не собирать shell-строку из полей заказа. proc_open принимает argv-массив без shell. В его элементы не добавляют escapeshellarg и ручные кавычки. Путь программы задаёт серверная конфигурация; данные заказа уходят в stdin.
В runner три дескриптора: 0 — вход программы, 1 — результат, 2 — диагностика. Мы используем три настоящих временных файла, чтобы короткий учебный обмен не зависел от порядка чтения нескольких заполненных pipes. После записи вход перематывается в начало, после завершения читаются выходы. Все файлы закрываются в finally.
<?php
declare(strict_types=1);
require __DIR__ . '/runner.php';
$orders = [
'label' => 'Отчёт по заказам',
'rows' => [
['quantity' => 2, 'unitMinor' => 1500],
['quantity' => 1, 'unitMinor' => 6000],
],
];
// В массиве аргументов нет shell, кавычек вокруг путей и escapeshellarg.
$command = [PHP_BINARY, '-n', '-d', 'memory_limit=32M', __DIR__ . '/worker.php'];
$result = runWorker($command, $orders);
$report = readReport($result);
echo $report['label'], "\n";
echo 'Строк: ', $report['rows'], '; сумма: ', $report['totalMinor'], ' коп.', "\n";
echo 'Диагностика: ', trim($result['stderr']), "\n";
Ожидаемый результат
Отчёт по заказам
Строк: 2; сумма: 9000 коп.
Диагностика: processed=2
Разверните runner.php и worker.php ниже или распакуйте пример целиком. PHP запускает настоящий отдельный процесс, передаёт UTF-8 JSON через stdin и получает JSON через stdout. stderr здесь содержит обычную диагностику, хотя код завершения равен нулю. После exit code родитель проверяет JSON и поля ответа. Worker с фиксированными малыми лимитами нужен для опыта; это не универсальный защищённый запускатель произвольных программ.
Посмотреть файл runner.php из примера
<?php
declare(strict_types=1);
/** Синхронный учебный обмен. $command задаёт только доверенная конфигурация. */
function runWorker(array $command, array $payload): array
{
$input = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) . "\n";
if (strlen($input) > 4096) {
throw new LengthException('Слишком большой учебный запрос');
}
$streams = [];
try {
for ($index = 0; $index < 3; $index++) {
$stream = tmpfile();
if ($stream === false) {
throw new RuntimeException('Не создан временный файл');
}
$streams[] = $stream;
}
$offset = 0;
while ($offset < strlen($input)) {
$written = fwrite($streams[0], substr($input, $offset));
if ($written === false || $written === 0) {
throw new RuntimeException('Не записан запрос');
}
$offset += $written;
}
if (!rewind($streams[0])) {
throw new RuntimeException('Не перемотан запрос');
}
// 0 = stdin ребёнка, 1 = stdout, 2 = stderr. Здесь это файлы, не pipes.
$process = proc_open($command, $streams, $pipes, __DIR__, null, ['bypass_shell' => true]);
if (!is_resource($process)) {
throw new RuntimeException('Не создан дочерний процесс');
}
// Ожидание завершения, а не timeout. proc_get_status здесь не нужен.
$exitCode = proc_close($process);
$captured = [];
foreach ([1, 2] as $index) {
if (!rewind($streams[$index])) {
throw new RuntimeException('Не перемотан ответ');
}
$text = stream_get_contents($streams[$index], 4097);
if ($text === false || strlen($text) > 4096) {
throw new LengthException('Слишком большой учебный ответ');
}
$captured[$index] = $text;
}
return ['exitCode' => $exitCode, 'stdout' => $captured[1], 'stderr' => $captured[2]];
} finally {
foreach ($streams as $stream) {
fclose($stream);
}
}
}
function readReport(array $result): array
{
if ($result['exitCode'] !== 0) {
throw new RuntimeException('Worker завершился с ошибкой');
}
$report = json_decode($result['stdout'], true, 8, JSON_THROW_ON_ERROR);
if (!is_array($report) || ($report['version'] ?? null) !== 1
|| !is_int($report['rows'] ?? null) || $report['rows'] < 0 || $report['rows'] > 20
|| !is_int($report['totalMinor'] ?? null) || $report['totalMinor'] < 0 || $report['totalMinor'] > 2000000000
|| !is_string($report['label'] ?? null) || strlen($report['label']) > 256) {
throw new UnexpectedValueException('JSON не соответствует договору отчёта');
}
return $report;
}
Посмотреть файл worker.php из примера
<?php
declare(strict_types=1);
try {
$raw = stream_get_contents(STDIN, 4097);
if ($raw === false || strlen($raw) > 4096) {
throw new InvalidArgumentException('input_too_large');
}
// Сохраняем различие JSON-объекта и JSON-массива: rows обязан быть массивом.
$request = json_decode($raw, false, 8, JSON_THROW_ON_ERROR);
if (!$request instanceof stdClass || !is_string($request->label ?? null)
|| strlen($request->label) > 256 || !is_array($request->rows ?? null)
|| count($request->rows) > 20) {
throw new InvalidArgumentException('invalid_request');
}
$total = 0;
foreach ($request->rows as $row) {
if (!$row instanceof stdClass || !is_int($row->quantity ?? null)
|| $row->quantity < 1 || $row->quantity > 100
|| !is_int($row->unitMinor ?? null) || $row->unitMinor < 0 || $row->unitMinor > 1000000) {
throw new InvalidArgumentException('invalid_row');
}
$total += $row->quantity * $row->unitMinor;
}
$report = ['version' => 1, 'rows' => count($request->rows), 'totalMinor' => $total, 'label' => $request->label];
fwrite(STDOUT, json_encode($report, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) . "\n");
fwrite(STDERR, 'processed=' . count($request->rows) . "\n");
} catch (JsonException | InvalidArgumentException $error) {
// Стабильный технический код без входных данных и секретов.
fwrite(STDERR, "invalid_request\n");
exit(3);
}
| Часть договора | Что проверяем | Какую проблему замечаем |
|---|---|---|
| Вход | До 4096 байт; UTF-8 JSON; не больше 20 строк | Слишком большая или повреждённая партия |
| Количество и цена | Целые числа в явно заданных пределах | Отрицательное количество, строка вместо числа, чрезмерное значение |
| Завершение | Exit code 0 | Программа отклонила вход или сломалась |
| Результат | JSON; version=1; rows, totalMinor и label нужных типов | Новая несовместимая версия worker или чужой вывод |
| Диагностика | Отдельный stderr | Журнал не испортил JSON в stdout |
STDOUT предназначен для одного машинного ответа. Отладочное echo перед JSON испортит договор. STDERR может содержать предупреждение или техническую статистику даже при успехе — решение принимают по согласованному exit code и результату, а не просто по наличию stderr.
Импорт не должен продолжаться после ошибки worker
<?php
declare(strict_types=1);
require __DIR__ . '/runner.php';
$command = [PHP_BINARY, '-n', '-d', 'memory_limit=32M', __DIR__ . '/worker.php'];
$badOrder = ['label' => 'Проверка импорта', 'rows' => [['quantity' => -1, 'unitMinor' => 100]]];
$result = runWorker($command, $badOrder);
if ($result['exitCode'] !== 0) {
echo 'Импорт не начат: exit ', $result['exitCode'], '; ', trim($result['stderr']), "\n";
}
// Локальные отрицательные фикстуры: exit 0 ещё не доказывает верный контракт.
foreach (['broken-json.php', 'wrong-schema.php'] as $fixture) {
$result = runWorker([PHP_BINARY, '-n', __DIR__ . '/' . $fixture], []);
try {
readReport($result);
throw new LogicException('Повреждённый ответ ошибочно принят');
} catch (JsonException) {
echo "Повреждённый JSON отклонён\n";
} catch (UnexpectedValueException) {
echo "Неверная схема JSON отклонена\n";
}
}
Ожидаемый результат
Импорт не начат: exit 3; invalid_request
Повреждённый JSON отклонён
Неверная схема JSON отклонена
Это предварительная проверка партии, она ничего не записывает в базу. Отрицательное количество даёт ненулевой exit. Ещё два настоящих дочерних процесса изображают ошибочные версии worker: один печатает не JSON, другой отдаёт неверный тип totalMinor. Родитель отвергает оба ответа, несмотря на exit 0. Названия программ заданы закрытым списком в исходнике, не параметром HTTP.
Здесь показаны три разных отказа: неверные данные заказа, неразбираемый JSON и формально правильный JSON с неверным типом поля. На настоящем сайте сохранение в базу начинается только после успешной проверки всех обязательных условий. Разбор JSON не заменяет проверку схемы.
JSON переносит данные между процессами; PHP-объект не превращается автоматически в объект Python. Договорите имена полей, единицы, пределы и версию. У Python bool является подклассом int — поэтому в приложенном worker используется точная проверка type(... ) is int. У JavaScript есть предел точности чисел; наши ограниченные целые остаются в безопасном диапазоне. Не подменяйте большой денежный идентификатор округлённым Number.
Системную диагностику храните с ID задачи и удалением секретов; посетителю покажите понятный статус без путей сервера и командной строки. Схемы данных и исключения связаны с вложенными JSON-данными и обработкой ошибок.
Python, Node.js или скомпилированный C: меняется программа, сохраняется договор
| Где это оправдано | Что делает PHP | Что делает отдельная программа |
|---|---|---|
| У команды уже есть проверенный Python-отчёт | Проверяет права, собирает вход, получает результат | Выполняет существующий расчёт или библиотечную обработку |
| В проекте есть Node.js-обработчик документов | Передаёт ограниченное задание | Применяет имеющийся JS-код и возвращает итог |
| Поставщик дал CLI на C/C++ для конвертации | Вызывает установленный бинарный файл с фиксированной операцией | Конвертирует данные по документированному протоколу |
| Нужно только сложить две цены | Оставляет вычисление внутри PHP | Отдельный процесс здесь добавляет лишние расходы |
require __DIR__ . '/runner.php';
// Укажите абсолютный путь из доверенной конфигурации своего сервера.
$pythonBinary = '/absolute/path/to/python3';
$orders = [
'label' => 'Отчёт по заказам',
'rows' => [
['quantity' => 2, 'unitMinor' => 1500],
['quantity' => 1, 'unitMinor' => 6000],
],
];
$result = runWorker([$pythonBinary, __DIR__ . '/worker.py'], $orders);
$report = readReport($result);
echo $report['label'], ': ', $report['totalMinor'], ' коп.', "\n";
Контекстный PHP-фрагмент: сначала установите Python, задайте существующий абсолютный путь и распакуйте runner.php с worker.py. Здесь нет предположения, что Python доступен по имени из PATH. Код worker.py действительно приложен; общий ZIP не устанавливает интерпретаторы или сторонние библиотеки.
Посмотреть файл worker.py из примера
import json
import sys
try:
raw = sys.stdin.buffer.read(4097)
if len(raw) > 4096:
raise ValueError("input_too_large")
request = json.loads(raw.decode("utf-8"))
if (type(request) is not dict or type(request.get("label")) is not str
or len(request["label"].encode("utf-8")) > 256
or type(request.get("rows")) is not list or len(request["rows"]) > 20):
raise ValueError("invalid_request")
total = 0
for row in request["rows"]:
if (type(row) is not dict or type(row.get("quantity")) is not int
or not 1 <= row["quantity"] <= 100
or type(row.get("unitMinor")) is not int
or not 0 <= row["unitMinor"] <= 1_000_000):
raise ValueError("invalid_row")
total += row["quantity"] * row["unitMinor"]
report = {"version": 1, "rows": len(request["rows"]), "totalMinor": total, "label": request["label"]}
sys.stdout.buffer.write((json.dumps(report, ensure_ascii=False) + "\n").encode("utf-8"))
sys.stderr.write("processed=" + str(len(request["rows"])) + "\n")
except (ValueError, UnicodeError, RecursionError):
sys.stderr.write("invalid_request\n")
sys.exit(3)
require __DIR__ . '/runner.php';
// Это настройка окружения, а не значение из $_GET или $_POST.
$nodeBinary = '/absolute/path/to/node';
$orders = [
'label' => 'Отчёт по заказам',
'rows' => [
['quantity' => 2, 'unitMinor' => 1500],
['quantity' => 1, 'unitMinor' => 6000],
],
];
$result = runWorker([$nodeBinary, __DIR__ . '/worker.cjs'], $orders);
$report = readReport($result);
echo $report['label'], ': ', $report['totalMinor'], ' коп.', "\n";
Контекстный PHP-фрагмент для установленного Node.js. worker.cjs использует встроенные модули, поэтому npm-пакеты здесь не нужны. Путь к исполняемому файлу зависит от машины. Результат должен пройти ту же PHP-проверку readReport, что и у PHP/Python worker.
Посмотреть файл worker.cjs из примера
const fs = require('node:fs');
try {
const buffer = Buffer.alloc(4097);
let length = 0;
while (length < buffer.length) {
const count = fs.readSync(0, buffer, length, buffer.length - length, null);
if (count === 0) break;
length += count;
}
if (length > 4096) throw new Error('input_too_large');
const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, length));
const request = JSON.parse(text);
if (request === null || Array.isArray(request) || typeof request !== 'object'
|| typeof request.label !== 'string' || Buffer.byteLength(request.label, 'utf8') > 256
|| !Array.isArray(request.rows) || request.rows.length > 20) throw new Error('invalid_request');
let total = 0;
for (const row of request.rows) {
if (row === null || Array.isArray(row) || typeof row !== 'object'
|| !Number.isInteger(row.quantity) || row.quantity < 1 || row.quantity > 100
|| !Number.isInteger(row.unitMinor) || row.unitMinor < 0 || row.unitMinor > 1000000) {
throw new Error('invalid_row');
}
total += row.quantity * row.unitMinor;
}
process.stdout.write(JSON.stringify({ version: 1, rows: request.rows.length, totalMinor: total, label: request.label }) + '\n');
process.stderr.write('processed=' + request.rows.length + '\n');
} catch {
process.stderr.write('invalid_request\n');
process.exitCode = 3;
}
Эти workers намеренно считают тот же маленький отчёт: вы можете проверить границу процессов, не устанавливая pandas или другие пакеты. В настоящем проекте внутри worker используется нужная библиотека. Для уже скомпилированного CLI на C команда может быть массивом [$reportBinary, "--json"], если программа действительно поддерживает этот флаг и договор stdin/stdout. Сам по себе C не предоставляет JSON-протокол — его должен реализовать автор CLI.
В Windows не переносите POSIX-кавычки из примера команды в cmd.exe. exec использует shell; escapeshellarg на Windows заменяет %, ! и двойные кавычки пробелами, поэтому не обещает сохранить произвольные данные или пути. Его многобайтовое поведение зависит и от locale. Для JSON здесь выбран stdin. Массив proc_open обходит shell, но Windows-разбор аргументов предполагает совместимость программы с правилами VC runtime.
Время, память, зависание и очередь: где заканчивается учебный запускатель
Создание процесса, запуск интерпретатора и преобразование JSON имеют стоимость. Измеряйте весь путь на данных своей задачи. Запускать по одному процессу на каждую строку большого импорта обычно дороже, чем передать ограниченную партию. Большие задачи выносят в очередь: HTTP создаёт задание, отдельный worker выполняет его, страница показывает статус и позже отдаёт результат.
- Запрос пользователя → проверка прав → запись задания
- Фоновый worker получает ID задания и ограниченный вход
- Выполняет обработку с контролем окружения
- Сохраняет результат/ошибку и статус
- Страница узнаёт статус и даёт скачать готовый отчёт
| Ограничение | Что есть в примере | Что требуется при эксплуатации |
|---|---|---|
| Размер сообщения | Вход ≤4096 байт; чтение выхода максимум 4097 | Ограничить загрузки, партии и общий объём результата |
| Время | Доверенные маленькие workers; proc_close ждёт завершения | Отдельный проверенный deadline и остановка дерева процессов средствами запуска/ОС |
| Память | Для дочернего PHP явно передан memory_limit=32M | Лимиты Python/Node/C и общий бюджет процессов на уровне ОС/контейнера/службы |
| Диск | Собственные временные файлы очищаются через finally | Квоты/объём, приватный каталог, уборка после аварийного завершения |
| Параллелизм | Один короткий вызов | Очередь, число workers, idempotency и политика повторов |
set_time_limit не заменяет контроль внешнего процесса: учёт ожиданий отличается между ОС, а прекращение PHP-запроса не является доказательством остановки всех потомков. В Unix внешние ожидания обычно не входят в этот счётчик, в Windows учитывается реальное время. Проверяйте политику службы, которая запускает worker.
Для коротких примеров мы вызываем proc_close один раз и не опрашиваем статус. Если добавите мониторинг, учитывайте версию: до PHP 8.3 вызов proc_get_status мог привести к последующему -1 от proc_close; с 8.3 exit code кэшируется. -1 не считайте успешным нулём. Если применяются pipes вместо файлов, чтение только stdout при заполненном stderr способно заблокировать обе стороны; нужен корректный одновременный обмен.
Если хостинг запретил exec/proc_open, не пытайтесь обходить ограничение другими shell-функциями. Выберите поддерживаемый worker/очередь или отдельный сервис с авторизацией. Для интерфейса задания пригодятся границы приложения, развёртывание и измерение производительности.
Функции и методы этой темы
Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.
execПолучить текстовый отчёт внешней программы и отдельно проверить её код завершения.
exec(string $command, array &$output = null, int &$result_code = null): string|falseПолучить текстовый отчёт внешней программы и отдельно проверить её код завершения.
Здесь PHP обращается к операционной системе и запускает отдельную программу. Это не вызов PHP-функции внутри того же процесса. Знак $ начинает имя переменной, = сохраняет значение, а имя со скобками вызывает функцию. В скобках аргументы разделены запятыми; точка . склеивает строки. echo выводит текст, "\n" переводит строку. var_dump показывает также тип значения. В примерах PHP_BINARY — путь к текущему PHP CLI, __DIR__ — каталог самого примера.
Проверка === false отличает отказ от пустой строки и нуля. if выполняет блок при выполнении условия. throw new RuntimeException прекращает обычный путь при неожиданном отказе; try/finally гарантирует завершающие действия. Файлы worker.php ниже — собственные короткие дочерние программы: раскройте их содержимое и сохраните рядом с example.php либо скачайте весь пример. Пошаговая практическая глава о PHP, Python и Node.js.
Квадратные скобки [] создают массив, а запись 0 => значение связывает ключ с данными. foreach ($items as $item) по очереди берёт элементы; для коротких проверок ниже их заранее всего два или три. catch принимает ожидаемое исключение, чтобы показать понятный результат отказа. Запись условие ? A : B выбирает одно из двух значений. Массивы, условия и циклы и исключения разобраны отдельно от процесса.
| Часть вызова | Значение и границы |
|---|---|
| command | Полная командная строка для shell, включая доверенный исполняемый файл и отдельно сформированные аргументы. Не команда из формы посетителя. Windows использует cmd.exe; для argv без shell нужен proc_open с массивом. |
| output | Массив, заполняемый по ссылке строками stdout; завершающие пробельные символы удаляются. Старые элементы сохраняются: функция дописывает в массив. Начинайте с [] для нового отчёта. |
| result_code | Код завершения, записываемый в переменную. Обычно 0 — успех; точное значение устанавливает договор внешней программы. |
| Возврат | Последняя строка stdout, либо false при невозможности запуска. Пустой stdout не означает неуспех; частичный stdout не означает успех. |
| Отказ | E_WARNING при невозможности выполнить команду; пустая строка/NUL — ValueError с PHP 8.0. Ошибки самой программы обычно проявляются ненулевым exit, а не исключением PHP. |
- worker пишет rows=2 и total=9000
- output получает обе строки
- return получает только total=9000
- result_code=0 подтверждает обычное завершение
Файл worker.php
<?php
echo "rows=2\n";
echo "total=9000\n";
<?php
declare(strict_types=1);
$command = escapeshellarg(PHP_BINARY) . ' -n ' . escapeshellarg(__DIR__ . '/worker.php');
$lines = [];
$last = exec($command, $lines, $exitCode);
echo $last, "\n";
echo implode(' | ', $lines), "\n";
echo $exitCode, "\n";
Ожидаемый результат
total=9000
rows=2 | total=9000
0
Знак & виден в сигнатуре, но не пишется в месте вызова: PHP сам заполняет lines и exitCode. Для рабочего решения отдельно обработайте last===false и ненулевой код.
Файл worker.php
<?php echo "ready
"; exit(4);<?php
declare(strict_types=1);
$command = escapeshellarg(PHP_BINARY) . ' -n ' . escapeshellarg(__DIR__ . '/worker.php');
$lines = ['previous'];
$last = exec($command, $lines, $exitCode);
echo json_encode($lines), "\n";
echo '[' . $last . "]\n";
echo $exitCode, "\n";
Ожидаемый результат
["previous","ready"]
[ready]
4
Пробелы в конце ready удалены. Строка есть, но exit=4 означает отказ по договору этого worker. Массив previous остался: данные нового задания следует отделять.
Файл worker.php
<?php exit(0);<?php
declare(strict_types=1);
$command = escapeshellarg(PHP_BINARY) . ' -n ' . escapeshellarg(__DIR__ . '/worker.php');
$lines = [];
$last = exec($command, $lines, $exitCode);
var_dump($last, $lines, $exitCode);
Ожидаемый результат
string(0) ""
array(0) {
}
int(0)
Булева проверка if (!$last) неправильно назвала бы этот результат ошибкой.
<?php
declare(strict_types=1);
foreach (['', "bad\0command"] as $command) {
try { exec($command); }
catch (ValueError $error) { echo "Invalid command\n"; }
}
Ожидаемый результат
Invalid command
Invalid command
Это проверка входной строки, а не запуск неизвестной программы. Нулевой байт нельзя молча удалить, изменив смысл исходных данных. PHP 8+ сообщает ValueError.
exec ждёт программу и держит собранные строки в памяти. Для большого вывода выбирайте потоковую обработку либо ограниченный файл с квотами. Добавление & не превращает PHP-запрос в надёжную очередь: у фоновой программы должны быть перенаправлены потоки и управляться срок жизни.
Следующий шаг: proc_open; shell_exec; system; passthru.
Сценарий 1 — готовый строковый отчёт. Дочерняя программа печатает rows=2 и totalMinor=9000. Массив $lines содержит обе строки, $last — только вторую.
$lines = [];
$last = exec($trustedCommand, $lines, $exitCode);
if ($last === false || $exitCode !== 0) { /* отказ */ }Это краткая иллюстрация, не самостоятельная команда. Полный запускаемый отчёт и worker.
Сценарий 2 — проверка импорта. stdout содержит checked=2, но exit=4 и stderr=invalid_quantity запрещают продолжать. В втором примере exec stderr сохраняется отдельно; не принимайте частичный stdout за успех.
Стоимость и границы. Вызов ждёт программу и создаёт процесс. Для JSON, отдельных потоков и argv-массива рассмотрите proc_open. Для внешних аргументов shell нужен escapeshellarg по одному аргументу, но безопаснее не передавать пользовательские данные в командную строку. Нюансы Windows разобраны в разделе о программах.
proc_openНастроить вход, выход и окружение отдельной программы; получить дескриптор для управления её жизнью.
proc_open(array|string $command, array $descriptor_spec, array &$pipes, ?string $cwd = null, ?array $env_vars = null, ?array $options = null): resource|falseНастроить вход, выход и окружение отдельной программы; получить дескриптор для управления её жизнью.
Здесь PHP обращается к операционной системе и запускает отдельную программу. Это не вызов PHP-функции внутри того же процесса. Знак $ начинает имя переменной, = сохраняет значение, а имя со скобками вызывает функцию. В скобках аргументы разделены запятыми; точка . склеивает строки. echo выводит текст, "\n" переводит строку. var_dump показывает также тип значения. В примерах PHP_BINARY — путь к текущему PHP CLI, __DIR__ — каталог самого примера.
Проверка === false отличает отказ от пустой строки и нуля. if выполняет блок при выполнении условия. throw new RuntimeException прекращает обычный путь при неожиданном отказе; try/finally гарантирует завершающие действия. Файлы worker.php ниже — собственные короткие дочерние программы: раскройте их содержимое и сохраните рядом с example.php либо скачайте весь пример. Пошаговая практическая глава о PHP, Python и Node.js.
Квадратные скобки [] создают массив, а запись 0 => значение связывает ключ с данными. foreach ($items as $item) по очереди берёт элементы; для коротких проверок ниже их заранее всего два или три. catch принимает ожидаемое исключение, чтобы показать понятный результат отказа. Запись условие ? A : B выбирает одно из двух значений. Массивы, условия и циклы и исключения разобраны отдельно от процесса.
| Часть вызова | Значение и границы |
|---|---|
| command | С PHP 7.4 можно передать argv-массив: [программа, аргумент, ...] запускается без shell. Кавычки/escapeshellarg в элементах массива не нужны. Строковая команда проходит через shell; Windows bypass_shell отключает cmd.exe. Если имя программы без разделителя пути, поиск идёт по PATH; задавайте проверенный абсолютный путь. |
| descriptor_spec | Ключи 0/1/2 означают stdin/stdout/stderr дочерней программы. ["pipe","r"] отдаёт ребёнку читающий конец, а PHP — пишущий; ["pipe","w"] наоборот. ["file", путь, режим] связывает поток с файлом. Можно передать уже открытый stream с реальным файловым дескриптором. |
| pipes | Выходной массив: PHP записывает сюда свои концы созданных pipe. Знак & есть в объявлении, а при вызове просто $pipes. При файловых дескрипторах соответствующих pipe-элементов нет. |
| cwd | Абсолютный начальный каталог дочерней программы; null наследует текущий CWD PHP. Это не автоматически каталог скрипта. |
| env_vars | null наследует окружение; массив задаёт окружение ребёнка. Не считайте его автоматическим дополнением ко всем текущим переменным. PATH и другие необходимые значения задают сознательно; секреты не печатают в диагностике. |
| options | Дополнительные платформенные настройки перечислены ниже. Windows argv предполагает разбор командной строки, совместимый с VC runtime; это не универсальное правило для любой программы. |
| Возврат / отказ | Ресурс процесса, либо false при отказе запуска. Созданный процесс ещё не означает успешное выполнение. С PHP 8.3 пустой argv-массив или пустое имя программы — ValueError. Нулевые байты и неверные дескрипторы также отвергаются, иногда с предупреждением/исключением. |
- PHP передаёт shop в stdin worker
- worker читает строку и пишет SHOP в stdout
- stderr отдельно предназначен диагностике
- PHP закрывает stdin, читает результат, ждёт exit
Массив [0 => ...] связывает номер канала с настройкой. fwrite пишет в поток; fclose закрывает его. Закрытие stdin сообщает ребёнку: больше данных не будет. stream_get_contents читает до конца; он может ждать. В коротком опыте worker не пишет stderr, а вход и ответ заранее малы. Это не алгоритм одновременного обмена произвольными объёмами.
Файл worker.php
<?php $text=stream_get_contents(STDIN); echo strtoupper($text);<?php
declare(strict_types=1);
$process = proc_open(
[PHP_BINARY, '-n', __DIR__ . '/worker.php'],
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'],
2 => ['file', __DIR__ . '/error.txt', 'w']],
$pipes,
__DIR__
);
if ($process === false) { throw new RuntimeException('Cannot start worker'); }
fwrite($pipes[0], "shop");
fclose($pipes[0]);
$text = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$exitCode = proc_close($process);
echo $text, "\n", $exitCode, "\n";
Ожидаемый результат
SHOP
0
Код самого ребёнка включён ниже/в загрузку. Он ждёт EOF от stdin, затем печатает ровно четыре байта. Если забыть fclose(stdin), оба участника могут ждать друг друга.
Файл input.txt
Файл worker.php
<?php echo '{"total":9000}'; fwrite(STDERR,"processed=2\n");<?php
declare(strict_types=1);
$process = proc_open(
[PHP_BINARY, '-n', __DIR__ . '/worker.php'],
[0 => ['file', __DIR__ . '/input.txt', 'r'],
1 => ['file', __DIR__ . '/output.txt', 'w'],
2 => ['file', __DIR__ . '/error.txt', 'w']],
$pipes,
__DIR__
);
if ($process === false) { throw new RuntimeException('Cannot start worker'); }
$exitCode = proc_close($process);
echo file_get_contents(__DIR__ . '/output.txt'), "\n";
echo trim(file_get_contents(__DIR__ . '/error.txt')), "\n";
echo $exitCode, "\n";
Ожидаемый результат
{"total":9000}
processed=2
0
Файловые потоки не заполняют ограниченный pipe-буфер. В реальном сервисе каталог задания должен быть собственным закрытым каталогом, а объём записи — ограничен. Здесь все файлы принадлежат изолированному примеру.
Файл input.txt
Файл worker.php
<?php echo getenv("REPORT_MODE");<?php
declare(strict_types=1);
$process = proc_open(
[PHP_BINARY, '-n', __DIR__ . '/worker.php'],
[0 => ['file', __DIR__ . '/input.txt', 'r'],
1 => ['pipe', 'w'], 2 => ['file', __DIR__ . '/error.txt', 'w']],
$pipes, __DIR__, ['REPORT_MODE' => 'preview']
);
if ($process === false) { throw new RuntimeException('Cannot start worker'); }
echo stream_get_contents($pipes[1]), "\n";
fclose($pipes[1]);
echo proc_close($process), "\n";
Ожидаемый результат
preview
0
Программа задана абсолютным PHP_BINARY, поэтому этот опыт не зависит от PATH. Не передавайте таким способом целиком пользовательский массив GET/POST.
<?php
declare(strict_types=1);
try { proc_open([], [], $pipes); }
catch (ValueError $error) { echo "Empty argv rejected\n"; }
Ожидаемый результат
Empty argv rejected
PHP 8.3+. Создание resource подтверждает запуск, но отсутствие resource в этом случае объясняется некорректным договором самого вызова.
| Windows option | Что меняет |
|---|---|
| suppress_errors | Скрывает системные сообщения этого вызова; не делает неуспешную программу успешной. Не используйте для сокрытия неизвестной причины отказа. |
| bypass_shell | Обходит cmd.exe при строковой команде. argv-массив уже запускается без shell. |
| blocking_pipes | Принудительно блокирующие pipe. Это не автоматическая асинхронная обработка двух выходов. |
| create_process_group (7.4+) | Создаёт группу для обработки CTRL events дочерним процессом. Не даёт автоматического управления всем деревом задач. |
| create_new_console (7.4.4+) | Создаёт новую консоль. Для серверного задания/учебного фонового worker здесь не включается. |
Дескрипторы выше 2 могут быть полезны POSIX-программам для дополнительного канала. Windows передаёт наследуемые handles, но стандартного доступа по тем же номерам файловых дескрипторов у ребёнка нет. Существуют специальные платформенные варианты; для переносимого контракта начните с 0/1/2. В отличие от popen, proc_open позволяет управлять обоими направлениями и stderr. Подробнее: ожидание и взаимная блокировка.
Следующий шаг: proc_close; proc_get_status; proc_terminate; exec.
Сценарий 1 — отчёт по заказам. PHP передаёт JSON через stdin, получает 9000 коп. через stdout и processed=2 через stderr. Полный пример с runner и worker.
$command = [PHP_BINARY, "-n", __DIR__ . "/worker.php"];
$result = runWorker($command, $orders);
$report = readReport($result);Это краткий фрагмент с helper из полного примера. Массив аргументов передаётся без shell-экранирования.
Сценарий 2 — предварительная проверка импорта. Ненулевой exit запрещает продолжение; exit 0 с плохим JSON или неверным типом totalMinor тоже отклоняется. Три реально воспроизводимых отказа.
Границы. Resource от proc_open подтверждает создание процесса, а не успех работы. proc_close ждёт завершения. В примерах временные файлы отделяют потоки, но не вводят timeout или квоту записи. Для простой доверенной текстовой команды есть exec; для длительной задачи — очередь и управляемый запуск worker.
Источники и обсуждения
- PHP: exec — последняя строка, массив строк и код завершения
- PHP: proc_open — массив аргументов и дескрипторы
- PHP: proc_close — ожидание и код завершения
- PHP: proc_get_status — версии и cached
- PHP: escapeshellarg — особенности Windows и locale
- PHP: set_time_limit — какие ожидания учитываются
- PHP: function.exec
- PHP: function.proc-open
- PHP: function.proc-close
- PHP: function.escapeshellarg