Глава 143 · Сопровождение терминальных интеграций
Expect: диалог с терминальной программой и наследуемый API
Полный раздел PECL Expect: две функции, все настройки и константы, PTY, локальный диалог и точная граница поддержки PHP.
Зачем нужен диалог с процессом через PTY
У старой локальной складской программы нет API: она печатает «SKU?», ждёт введённый артикул и отвечает «STOCK 7». Простая команда exec с готовой строкой не описывает весь такой диалог. Expect даёт программе псевдотерминал (PTY) и позволяет ждать определённый текст перед отправкой следующего ответа. Польза — автоматизация известного интерактивного протокола там, где действительно нет удобного программного интерфейса.
- PHP открыл PTY и запустил свой stock-prompt.php
- Дочерний процесс напечатал SKU?
- expect_expectl узнал приглашение
- PHP отправил A42 и перевод строки
- Дочерний процесс напечатал STOCK 7
- PHP извлёк 7 и завершил поток
PTY — пара концов виртуального терминала. Дочерняя программа воспринимает свою сторону как терминал; родитель читает вывод и отправляет ввод. Это отличается от HTTP запроса и обычного файла: возможны эхо ввода, переводы строк CRLF, управляющие последовательности и приглашение без завершающего . «Ждать две секунды через sleep» не означает дождаться приглашения; нужен условный переход по фактическому выводу.
<?php
declare(strict_types=1);
$output = 'SKU? ';
$state = $output === 'SKU? ' ? 'send_sku' : 'unexpected';
if ($state === 'send_sku') echo "Следующий шаг: отправить A42 и перевод строки\n";
Ожидаемый результат
Следующий шаг: отправить A42 и перевод строки
Это маленькая модель выбора следующего действия, а не эмуляция Expect. В настоящем PTY текст приходит частями, и совпадение ищет библиотека. Подготовка: функции, ветвления, потоки.
Требования, установка и честная граница PHP8
Статус версии. Полный раздел сохраняет API из официального Manual, включая наследуемые расширения. На проверенной странице PECL последний опубликованный stable Expect — 0.4.0 от 13 января 2020 года; его metadata задаёт PHP не выше 7.99.99. Поэтому наличие страницы в Manual PHP8 не доказывает готовность этой версии расширения для PHP8.5.11. Здесь не предлагается понижать рабочий сайт до PHP7 и не заявляется, что пакет проверен на PHP8. Примеры служат для чтения/сопровождения существующих совместимых установок и изолированного стенда.
Расширение не входит в штатный PHP. Официальные требования: libexpect не ниже 5.43.0 и Tcl; при сборке нужны соответствующие development headers и совпадающие версии phpize/php-config/PHP ABI. Историческая configure опция — --with-expect[=DIR]. Установленный бинарник expect или libexpect.so сам по себе не добавляет PHP функцию expect_popen. PHP модуль загружается в ini конкретного SAPI. Официальная инструкция упоминает php_expect.dll, но готовый Windows DLL пакет там недоступен: это не рабочий шаг «включите отсутствующий файл».
<?php
declare(strict_types=1);
echo 'Модуль: ', extension_loaded('expect') ? 'есть' : 'нет', "\n";
echo 'Функция: ', function_exists('expect_popen') ? 'есть' : 'нет', "\n";
echo 'Wrapper: ', in_array('expect', stream_get_wrappers(), true) ? 'есть' : 'нет', "\n";
Ожидаемый результат
Три строки с фактической доступностью модуля, функции и wrapper.
Можно выполнить как диагностику в CLI и отдельно в используемом SAPI. php --ini и php -m помогают проверить CLI; они не описывают автоматически конфигурацию PHP-FPM. Пример не запускает процесс. Контекст: изолированный Unix стенд с совместимой исторической сборкой PECL Expect и libexpect. Здесь проверен только PHP синтаксис; PTY и PHP8 совместимость не заявляются.
expect_popen возвращает stream resource. Он передаётся в expect_expectl, fwrite и fclose; у этой книги нет новых классов PHP8 вместо него. Для проекта на современном PHP сначала ищите API, batch/JSON режим нужной программы, затем управляемый запуск через функции запуска процессов. Если интерфейс действительно только терминальный, выбирайте поддерживаемую реализацию PTY/Expect как отдельный проверяемый компонент, с ограниченным временем и правами.
Все настройки: время, буфер и журналирование
| Директива, INI_ALL | Поведение |
|---|---|
| expect.timeout | В секундах; default10. -1 отключает timeout, 0 возвращает управление немедленно. Ограничивает ожидание expect_expectl, не весь жизненный цикл процесса. |
| expect.loguser | Default1: вывод дочернего процесса идёт в stdout. Терминальное эхо может включать введённые значения. |
| expect.logfile | Default пусто; иначе вывод пишется в файл независимо от loguser. Файл может создаться; в release0.4.0 открыт на добавление. |
| expect.match_max | Размер буфера сопоставления. В Manual упомянут базовый libexpect размер2000 байт, но source релиза0.4.0 регистрирует default5000. Проверяйте ini_get своей сборки и задавайте обоснованный лимит явно. |
В учебном диалоге ставим timeout2, loguser0, logfile пусто. Это предотвращает смешивание терминального вывода с нашим отчётом. Обработка timeout обязательна даже при ограничении в настройке. Для долгого job добавьте отдельный общий deadline и политику остановки дочернего процесса: новый expect вызов снова может ждать свой timeout. loguser0 не отключает logfile; не записывайте пароли/токены в терминальный transcript.
<?php
declare(strict_types=1);
if (!extension_loaded('expect')) throw new RuntimeException('Модуль не загружен');
foreach (['expect.timeout' => '2', 'expect.loguser' => '0', 'expect.logfile' => '', 'expect.match_max' => '4096'] as $key => $value) {
if (ini_set($key, $value) === false) throw new RuntimeException('Не изменена настройка ' . $key);
}
echo "Ожидание ограничено, transcript отключён\n";
Ожидаемый результат
Ожидание ограничено, transcript отключён
Проверяется результат изменения. В библиотеке сохраняйте предыдущие значения и восстанавливайте их в finally, чтобы настройки одного диалога не влияли на другой. Контекст: изолированный Unix стенд с совместимой исторической сборкой PECL Expect и libexpect. Здесь проверен только PHP синтаксис; PTY и PHP8 совместимость не заявляются.
Полностью представленный локальный собеседник
Для примеров создаём только собственную учебную программу. Она не авторизуется на сервере, не меняет файлы и не выполняет присланные команды. Через три секунды без ввода она завершится с NO_INPUT, чтобы ошибочный тест не удерживал процесс бесконечно. Сам Expect тоже ограничен timeout2.
<?php
fwrite(STDOUT, 'SKU? ');
fflush(STDOUT);
$read = [STDIN]; $write = []; $except = [];
$ready = stream_select($read, $write, $except, 3);
if ($ready !== 1) { fwrite(STDOUT, "NO_INPUT\n"); exit(2); }
$line = fgets(STDIN);
if ($line === false) { fwrite(STDOUT, "NO_INPUT\n"); exit(2); }
$sku = trim($line);
fwrite(STDOUT, $sku === 'A42' ? "STOCK 7\n" : "UNKNOWN\n");
Ожидаемый результат
После ввода A42: STOCK 7; другой артикул: UNKNOWN; без ввода до трёх секунд: NO_INPUT.
Программа выводит приглашение без и явно fflush. stream_select ожидает доступность входа; fgets получает строку после Enter. Этот файл приложен ко всем примерам диалога автоматически. Контекст: изолированный Unix стенд с совместимой исторической сборкой PECL Expect и libexpect. Здесь проверен только PHP синтаксис; PTY и PHP8 совместимость не заявляются.
Команда строится из фиксированного PHP_BINARY и известного пути к этому файлу. На Unix каждый аргумент экранируется escapeshellarg, потому что expect_popen выполняет строку через Bourne shell. Не подставляйте целую команду из $_GET/$_POST. Даже корректное quoting не заменяет ограничение разрешённых действий: пользовательский выбор должен отображаться на заранее известную команду, а не на произвольный исполняемый файл.
Шаблоны, метки, выходные группы и все константы
Один вызов ожидания получает список вариантов. Каждый вариант — массив из шаблона, возвращаемой метки и необязательного вида шаблона. Массив cases делайте плотным списком 0,1,2…; если отфильтровали, примените array_values. Используйте уникальные строковые метки, которые не спутать с числовыми кодами EOF/timeout. Успех совпадения не означает успех всей бизнес-операции: «SKU?» только разрешает отправить артикул.
| Все шесть констант | Назначение |
|---|---|
EXP_GLOB | Шаблон glob; default: * — последовательность, ? — один символ, [] — класс. Это не PCRE. |
EXP_EXACT | Буквальное совпадение строки; * и ? не становятся шаблонами. Удобно для SKU?. |
EXP_REGEXP | Регулярное выражение libexpect, без PHP delimiters /.../. Не обещает все PCRE возможности. |
EXP_EOF | Конец потока до нужного совпадения. Это событие чтения, не проверенный exit code дочерней программы. |
EXP_TIMEOUT | За время expect.timeout нужное совпадение не найдено. |
EXP_FULLBUFFER | Буфер заполнен без нужного совпадения; нужно проверить протокол/границы/лимит, не бесконечно наращивать память. |
Случаи завершения проверяются явно и сравниваются строго. Не пишите if ($result): метка может быть 0 или пустой строкой, а отрицательный служебный код тоже truthy. Перекрывающиеся шаблоны делают поведение хрупким; держите набор подходящим текущему состоянию. match[0] — совпавший фрагмент, не весь transcript; группы regex находятся в match[1]…match[9] с ограничением libexpect.
expect://: файловая оболочка вокруг процесса
Альтернативный вход — fopen("expect://command", "r"). Это всё равно запуск команды через PTY, а не чтение удалённого URL. Wrapper не включён без расширения и не ограничивается allow_url_fopen. Поэтому выключение одной ini директивы не делает непроверенный путь пользователя безопасным.
| Возможность wrapper по официальной таблице | Поддержка |
|---|---|
| Чтение / запись / добавление | Да. Запись — ввод процессу, не сохранение файла. |
| Simultaneous reading/writing как файловый режим | В таблице отмечено No; не выводите из этого отсутствие диалога: официальный способ выполняет ожидание и fwrite последовательно через PTY. Не предполагается семантика произвольного r+ файла. |
| stat/unlink/rename/mkdir/rmdir | Нет. |
| allow_url_fopen | Не является ограничителем expect://. |
<?php
declare(strict_types=1);
if (!in_array('expect', stream_get_wrappers(), true)) throw new RuntimeException('Нет wrapper');
ini_set('expect.loguser', '0');
ini_set('expect.timeout', '2');
$stream = fopen("expect://printf 'READY\\n'", 'r');
if ($stream === false) throw new RuntimeException('Не открыт PTY');
try {
$state = expect_expectl($stream, [['READY', 'ready', EXP_EXACT]]);
if ($state !== 'ready') throw new RuntimeException('Не получен READY');
echo "Учебная программа готова\n";
} finally { fclose($stream); }
Ожидаемый результат
Учебная программа готова
printf — фиксированная локальная Bourne shell команда с немедленным завершением. Даже строка с expect:// может исполнять код; не допускайте произвольные схемы в параметрах импорта файла. Контекст: изолированный Unix стенд с совместимой исторической сборкой PECL Expect и libexpect. Здесь проверен только PHP синтаксис; PTY и PHP8 совместимость не заявляются.
Ошибки диалога, безопасность и современные альтернативы
Для старого SSH/SCP диалога меняются приглашения, локаль, баннер, prompt, host verification и права пользователя сервера. Надёжный код моделирует ограниченное число переходов, обрабатывает отказ аутентификации/неизвестное состояние и прекращает действие. Нельзя автоматически отвечать yes на неизвестный SSH host key ради прохождения примера: доверие проверяется заранее. Для передачи файлов предпочтителен программный SFTP/SSH клиент с проверкой сервера; для новой утилиты — аргументы или JSON stdin вместо автоматизации меню.
| Симптом | Проверка и решение |
|---|---|
| Работает в терминале, зависает из веба | Другой пользователь, HOME, known_hosts, PATH, ini, доступ к PTY; новое приглашение не входит в cases. Не отключать timeout. |
| EOF до ожидаемого ответа | Процесс мог отказаться/не запуститься/завершиться; не объявлять бизнес-успех только по EOF. |
| stdout испортил JSON ответ сайта | loguser и терминальное эхо; libexpect пишет в stdout, ob_start не гарантирует перехват чужого низкоуровневого вывода. |
| FULLBUFFER | Вывод не соответствует ожидаемому протоколу, слишком широкий/неверный шаблон или обоснованно длинный ответ; задайте предел и политику отказа. |
| Undefined function | Наличие системного expect не означает PHP module; проверить ABI и SAPI ini. |
| Процесс остался после timeout | Timeout ожидания не является kill. fclose может ждать завершения; нужен общий supervisor/deadline и политика остановки своих дочерних процессов. |
Стоимость определяется блокирующим ожиданием, числом переходов, объёмом вывода и сложностью шаблонов. Не держите HTTP worker минутами ради интерактивного меню. Для фоновой задачи храните job ID/состояние отдельно и запускайте управляемый процесс под минимальными правами. Для больших выгрузок терминальный интерфейс особенно неудобен: структурированный API обычно легче ограничить по объёму, протестировать и обработать после отказа.
Развить пример и выбрать подходящий интерфейс
Задание 1: отправьте B17 вместо A42. Решение: обрабатывайте UNKNOWN отдельной меткой, а не как timeout. Задание 2: замените приглашение на «Артикул?». Решение: обновить согласованный протокол или точный шаблон; ожидание старого SKU? будет ошибкой. Задание 3: дочерний процесс выводит STOCK 12. Решение: EXP_REGEXP извлечёт группу 12; проверка бизнес-диапазона остаётся в приложении. Задание 4: прекратите ввод. Решение: EXP_TIMEOUT и NO_INPUT означают разные события родителя и ребёнка; общего успешного результата нет.
Полный официальный раздел включён, но исторический пакет не становится современным от переписывания примера. Для нового проекта сначала оцените exec/proc_open и передачу данных другой программе, HTTP API и sockets. Для чтения терминального кода пригодятся состояния и ветвления, различие синтаксисов шаблонов и владение потоками.
Функции и методы этой темы
Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.
expect_popenЗапустить управляемый терминальный диалог через Bourne shell.
expect_popen(string $command): resource|falseЗапустить управляемый терминальный диалог через Bourne shell.
API относится к версии PECL и требованиям среды. Сначала разберите учебный диалог SKU? → A42 → STOCK 7. Скобки вызова передают параметры; возвращённая метка выбирает следующий шаг программы, а не исполняет его автоматически.
| Договор | Значение |
|---|---|
| command | Строка shell команды. Исполняемый файл/действие фиксируются приложением; отдельные аргументы экранируются для Unix shell. |
| Результат | PTY stream resource либо false при отказе открытия. Успешный stream не доказывает успешный exit команды. |
| Операции | Ожидание expect_expectl, ввод fwrite, освобождение fclose; seek не предполагается. |
| Граница | Самостоятельный deadline/остановка дочернего процесса и версия расширения требуют отдельного договора. |
- Фиксированный PHP_BINARY + stock-prompt.php
- popen → PTY stream
- wait SKU?
- write A42 и перевод строки
- wait STOCK7 → результат
Утилита запрашивает данные через терминал. expect_popen запускает указанную строку через Bourne shell и возвращает PTY stream для ввода и общего терминального вывода. Это не обычный однонаправленный popen: можно дождаться приглашения и ответить. stdout/stderr здесь не являются двумя независимыми PHP pipes; если вам нужно разделить каналы, выбирайте подходящий process API.
<?php
declare(strict_types=1);
ini_set('expect.loguser', '0');
ini_set('expect.timeout', '2');
$stream = expect_popen("printf 'READY\\n'");
if ($stream === false) throw new RuntimeException('Не создан PTY');
try {
$state = expect_expectl($stream, [['READY', 'ready', EXP_EXACT]]);
if ($state !== 'ready') throw new RuntimeException('Нет ожидаемого вывода');
echo "READY получен\n";
} finally { fclose($stream); }
Ожидаемый результат
READY получен
Переменная $stream — ресурс связи, не текст результата. expect_expectl читает его и выбирает метку ready. Команда фиксирована, завершается сама, sleep не нужен. Контекст: изолированный Unix стенд с совместимой исторической сборкой PECL Expect и libexpect. Здесь проверен только PHP синтаксис; PTY и PHP8 совместимость не заявляются.
<?php
declare(strict_types=1);
if (!extension_loaded('expect')) throw new RuntimeException('Нужна совместимая сборка Expect');
ini_set('expect.timeout', '2');
ini_set('expect.loguser', '0');
ini_set('expect.logfile', '');
$command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/stock-prompt.php');
$stream = expect_popen($command);
if ($stream === false) throw new RuntimeException('Не создан PTY');
try {
$prompt = expect_expectl($stream, [['SKU? ', 'prompt', EXP_EXACT]]);
if ($prompt !== 'prompt') throw new RuntimeException('Не получено приглашение');
$input = "A42\n";
if (fwrite($stream, $input) !== strlen($input)) throw new RuntimeException('Ввод отправлен не целиком');
$result = expect_expectl($stream, [['STOCK 7', 'stock', EXP_EXACT], ['UNKNOWN', 'unknown', EXP_EXACT]]);
if ($result === 'stock') echo "A42: осталось 7\n";
elseif ($result === 'unknown') echo "Артикул неизвестен\n";
else throw new RuntimeException('Диалог не завершён ожидаемым ответом');
} finally { fclose($stream); }
Ожидаемый результат
A42: осталось 7
Передача завершает ввод строки. Если приглашение не найдено, код не отправляет артикул вслепую. Дочерняя учебная программа имеет собственный предел ожидания; это существенно, потому что fclose не является универсальным принудительным kill. Контекст: изолированный Unix стенд с совместимой исторической сборкой PECL Expect и libexpect. Здесь проверен только PHP синтаксис; PTY и PHP8 совместимость не заявляются.
Связь: expect_expectl.
expect_expectlЖдать нужный текст и получить метку события либо код завершения ожидания.
expect_expectl(resource $expect, array $cases, array &$match = []): mixedЖдать нужный текст и получить метку события либо код завершения ожидания.
API относится к версии PECL и требованиям среды. Сначала разберите учебный диалог SKU? → A42 → STOCK 7. Скобки вызова передают параметры; возвращённая метка выбирает следующий шаг программы, а не исполняет его автоматически.
| Договор | Значение |
|---|---|
| expect | PTY stream, открытый Expect. Обычный HTTP/file ресурс не заменяет его. |
| cases | Плотный список индексных массивов [pattern, returnValue, optionalType]. |
| match | Необязательная выходная переменная; заполняется совпадением/группами. Сбрасывать до новой операции. |
| Результат | Associated value; иначе EXP_EOF/EXP_TIMEOUT/EXP_FULLBUFFER. Не проверять одним if(result). |
| Ожидание | expect.timeout ограничивает отдельный вызов; не останавливает весь дочерний процесс. |
- Получены cases для текущего шага
- Вывод копится до совпадения/отказа
- STOCK7 даёт label stock и match[1]=7
- PHP if выбирает следующее действие
Это ожидание одного из событий диалога, а не выполнение команды. Вы передаёте поток и варианты того, что ожидаете сейчас. Функция возвращает выбранную вами метку; ваш if/switch после неё отправляет ответ или прекращает операцию. В краткой странице Manual return указан int, но cases допускает mixed, и source0.4.0 возвращает associated value без принудительного int: строковые метки работают по этому договору.
<?php
declare(strict_types=1);
if (!extension_loaded('expect')) throw new RuntimeException('Нужна совместимая сборка Expect');
ini_set('expect.timeout', '2');
ini_set('expect.loguser', '0');
ini_set('expect.logfile', '');
$command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/stock-prompt.php');
$stream = expect_popen($command);
if ($stream === false) throw new RuntimeException('Не создан PTY');
try {
if (expect_expectl($stream, [['SKU? ', 'prompt', EXP_EXACT]]) !== 'prompt') throw new RuntimeException('Нет prompt');
if (fwrite($stream, "A42\n") !== 4) throw new RuntimeException('Короткий ввод');
$matches = [];
$state = expect_expectl($stream, [
['STOCK ([0-9]+)', 'stock', EXP_REGEXP],
['UNKNOWN', 'unknown', EXP_EXACT]
], $matches);
if ($state !== 'stock' || !isset($matches[1])) throw new RuntimeException('Нет значения остатка');
echo 'Совпало: ', $matches[0], "\n";
echo 'Остаток: ', $matches[1], "\n";
} finally { fclose($stream); }
Ожидаемый результат
Совпало: STOCK 7
Остаток: 7
Шаблон без /.../: это libexpect regex, не preg_match. Скобки выделили цифры в группу1. Match массив передан по ссылке и заполняется библиотекой; перед каждым независимым вызовом его сбрасывают, чтобы не прочитать старое совпадение после отказа. Контекст: изолированный Unix стенд с совместимой исторической сборкой PECL Expect и libexpect. Здесь проверен только PHP синтаксис; PTY и PHP8 совместимость не заявляются.
<?php
declare(strict_types=1);
ini_set('expect.loguser', '0');
ini_set('expect.timeout', '2');
$stream = expect_popen("printf 'UNKNOWN\\n'");
if ($stream === false) throw new RuntimeException('Не создан PTY');
try {
$matches = [];
$state = expect_expectl($stream, [['STOCK *', 'stock', EXP_GLOB]], $matches);
if ($state === 'stock') echo "Найден ответ по шаблону\n";
elseif ($state === EXP_EOF) echo "Программа завершила вывод без STOCK\n";
elseif ($state === EXP_TIMEOUT) echo "Ответ не пришёл вовремя\n";
elseif ($state === EXP_FULLBUFFER) echo "Вывод превысил буфер без совпадения\n";
else throw new RuntimeException('Непредусмотренное состояние Expect');
} finally { fclose($stream); }
Ожидаемый результат
Программа завершила вывод без STOCK
Эта короткая программа выводит UNKNOWN и сразу завершается, поэтому ожидается EOF, не timeout. Все остальные отказы всё равно перечислены. EOF не содержит exit code и не подтверждает правильность результата. Контекст: изолированный Unix стенд с совместимой исторической сборкой PECL Expect и libexpect. Здесь проверен только PHP синтаксис; PTY и PHP8 совместимость не заявляются.
| Элемент одного case | Синтаксис и смысл |
|---|---|
| 0: pattern | Обязательная строка; например SKU? либо STOCK ([0-9]+). |
| 1: value | Обязательная возвращаемая метка; желательно уникальная строка. |
| 2: type | Необязательно: EXP_GLOB default, EXP_EXACT или EXP_REGEXP. |
| Третий аргумент функции: &match | Выходной массив. [0] совпавший текст, [1]…[9] группы. До PECL0.2.1 возвращалась строка, не массив групп. |
Ошибочный cases формат в историческом расширении может привести к PHP ошибке, а не удобному TypeError современного API: не создавайте шаблоны и type из непроверенного пользовательского ввода. FULLBUFFER означает отсутствие совпадения при заполненном буфере, а не любое временное отсутствие текста. Совпадение могло потребить часть вывода; чтение тем же stream через fgets конкурирует с Expect за байты. Не вставляйте вторую независимую читалку «для логов»: используйте согласованное журналирование без секретов.
Связь: expect_popen.