Process: spouštění externích programů z PHP konečně bez bolesti

před hodinou od David Grudl  

Spustit z PHP externí program a přečíst, co vypsal? shell_exec('git pull') a je hotovo. Tohle ale stačí jen na ten úplně nejjednodušší případ. Jakmile potřebujete znát návratový kód, vidět chyby odděleně od běžného výstupu, bezpečně předat argument z uživatelského vstupu nebo dát programu timeout, shell_exec() přestane stačit. A proc_open(), který to všechno umí, má API tak nepřívětivé, že se mu většina lidí raději vyhne.

V Nette Utils 4.1.4 přibyla třída Nette\Utils\Process, která to dělá za vás. Spustíte proces a pak se ho ptáte: Běží ještě? Co vypsal? Jak skončil? A ona se postará o roury, čekací smyčky i o rozdíly mezi operačními systémy.

Jak spustit příkaz a získat návratový kód v PHP

Chcete spustit git pull a vědět, jestli prošel?

use Nette\Utils\Process;

Process::runExecutable('git', ['pull'])->ensureSuccess();

Tahle jediná řádka spustí git pull, počká na jeho dokončení a vyhodí ProcessFailedException, pokud návratový kód nebyl nula. Žádné kouzlení s exec() a $retval, žádné is_resource(), žádná smyčka.

A když chcete výstup?

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

echo $process->getStdOutput();  // standardní výstup
echo $process->getStdError();   // chybový výstup, zvlášť
echo $process->getExitCode();   // 0

getStdOutput() počká, až program doběhne, a vrátí všechno, co zapsal na standardní výstup. Chybový výstup dostanete odděleně, což shell_exec() neumí. Návratový kód taky. A na rozdíl od nativních PHP funkcí třída při chybách nevrací false, ale vyhazuje výjimky, takže žádné selhání nepřehlédnete. (Potřebujete programu něco poslat na vstup? Předáte stdin: 'data', nebo null a krmíte ho postupně přes writeStdInput().)

runExecutable vs. runCommand (ochrana před shell injection)

Třída nabízí dva způsoby, jak proces spustit, a ten rozdíl není kosmetický.

runExecutable() spustí konkrétní program se seznamem argumentů. Argumenty se programu předají přímo. Žádný shell se jich ani nedotkne, takže nemusíte nic escapovat a hlavně nehrozí shell injection:

$file = $_GET['file']; // může to být cokoli, klidně '; rm -rf /'
$process = Process::runExecutable('wc', ['-l', $file]); // naprosto bezpečné

runCommand() naproti tomu předá celý řetězec systémovému shellu (/bin/sh, na Windows cmd.exe). Tím získáte roury, přesměrování, expanzi proměnných, zkrátka všechno, co od shellu čekáte:

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

Cena za pohodlí: shell ten řetězec parsuje, takže do něj nikdy nedávejte neošetřený vstup zvenčí. Pravidlo je jednoduché: cokoli, kde figuruje uživatelský vstup, jde přes runExecutable().

Streamování a čtení výstupu procesu v reálném čase

Spouštíte něco, co běží minuty (npm install, composer update, build) a chcete vidět průběh, ne čekat na finální výpis. Předejte wait() callback:

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

$process->wait(function (string $stdOut, string $stdErr) {
	echo $stdOut;
	fwrite(STDERR, $stdErr);
});

Callback se zavolá pokaždé, když přijde nový výstup. Dostane to, co od minulého volání přibylo na standardním a chybovém výstupu. Žádné stream_select(), žádné přepínání rour do neblokujícího režimu, žádné riziko deadlocku, když program vychrlí víc, než se vejde do bufferu roury. O to vše se třída postará.

Nechcete callback? Odebírejte výstup sami metodou consumeStdOutput(), vrátí kus, který přibyl od posledního volání:

while ($process->isRunning()) {
	echo $process->consumeStdOutput();
	usleep(100_000);
}
echo $process->consumeStdOutput(); // doberte zbytek

Zápis velkého výstupu z procesu rovnou do souboru

Ne vždycky chcete výstup v paměti. Velký databázový dump pošlete rovnou do souboru:

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

Parametry stdout i stderr berou název souboru, zapisovatelný resource, false (zahodit) nebo null (zachytit do paměti, výchozí stav). Užitečné, když proces sype gigabajty, ty v RAM mít nechcete.

Jak nastavit timeout pro externí program v PHP

Externí program se může zaseknout. Třída na to má parametr $timeout (ve výchozím stavu 60 sekund):

try {
	Process::runExecutable('slow-tool', timeout: 5.0)->wait();
} catch (Nette\Utils\ProcessTimeoutException $e) {
	echo 'Trvalo to moc dlouho, proces byl ukončen.';
}

Když limit vyprší (kontroluje se, dokud na proces čekáte nebo z něj čtete), proces se zabije a vyletí ProcessTimeoutException. A zabije ho doopravdy: na Unixu signálem SIGKILL, na Windows přes taskkill /F. Tedy bez SIGTERMu, který si umí zatvrzelý proces ignorovat a nechat vás čekat donekonečna v proc_close(). Stejně razantně funguje i metoda terminate(), a proces se ukončí i automaticky, když zanikne jeho objekt Process.

Propojování procesů pomocí rour (pipes) v PHP

Chcete propojit výstup jednoho procesu na vstup druhého, přesně jako rourou | v shellu, ale bez shellu? Předáte Process jako $stdin:

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

echo $consumer->getStdOutput();

A řetězit můžete, kolik chcete: a | b | c. Na Windows tohle jako jediná věc nefunguje, tam výstup prvního procesu zachyťte a předejte dalšímu jako řetězec. Jinak třída chodí na Linuxu, macOS i Windows úplně stejně a rozdíly, o kterých nechcete vědět, řeší pod kapotou.

Process najdete v Nette Utils 4.1.4 (dokumentace). Vyzkoušejte ho a dejte nám vědět. A proc_open()? Toho se už nemusíte dotknout.

David Grudl Programátor, blogger a AI evangelista. Vytvořil Nette Framework používaný statisíci webů. Píše na Uměligence o umělé inteligenci a phpFashion o webovém vývoji. Každý týden moderuje Tech Guys a učí lidi pracovat s ChatGPT a dalšími AI nástroji. Fascinují ho technologie, které mění náš svět, a rád je přibližuje široké veřejnosti.