Process: Spouštění externích programů

Třída Nette\Utils\Process umožňuje z PHP spouštět externí programy: posílat jim vstup, číst jejich výstup a reagovat na to, jak skončily. Je to přívětivý obal nad PHP funkcí proc_open(), který hlásí chyby vyhazováním výjimek místo vracení false.

Instalace:

composer require nette/utils

Všechny příklady předpokládají vytvořený alias:

use Nette\Utils\Process;

Nejjednodušší použití

Chcete spustit program a přečíst, co vypsal? Stačí tohle:

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

První argument je program, který se má spustit, druhý je seznam jeho argumentů: totéž, co byste napsali na příkazové řádce, jen rozdělené do pole. Metoda getStdOutput() počká, až program doběhne, a vrátí vše, co zapsal na svůj standardní výstup.

To je celá myšlenka: spustíte proces a pak se ho ptáte: běží ještě, co vypsal, jak skončil. Zbytek této stránky probírá tyto otázky jednu po druhé.

Spuštění procesu

Proces lze spustit dvěma způsoby a stojí za to ten rozdíl pochopit.

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

Spustí jeden konkrétní program se seznamem argumentů. Argumenty se programu předají přímo, takže nikdy nemusíte escapovat mezery, uvozovky ani jiné speciální znaky. A protože nevstupuje do hry žádný shell, nehrozí shell injection. Tohle je bezpečná volba, zvlášť když část příkazu pochází z uživatelského vstupu:

$file = $_GET['file']; // could be anything, even '; rm -rf /'
$process = Process::runExecutable('wc', ['-l', $file]); // perfectly safe

Pokud neuvedete plnou cestu, program se hledá v systémové proměnné PATH. Pro spuštění PHP skriptu se hodí konstanta 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

Spustí příkaz zadaný řetězcem přes systémový shell (/bin/sh na Linuxu a macOS, cmd.exe na Windows). Tím získáte možnosti shellu: roury |, přesměrování >, expanzi proměnných, řetězení příkazů přes && a podobně:

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

Protože ale shell celý řetězec parsuje, nikdy nesestavujte řetězec pro runCommand() z nedůvěryhodného vstupu, což je klasická bezpečnostní díra. Když si nejste jisti, použijte raději runExecutable().

Při tolika parametrech je předávejte jako pojmenované argumenty, např. Process::runExecutable('git', ['pull'], timeout: 30). Pole $options se předává přímo do proc_open() pro pokročilé potřeby, jako třeba bypass_shell na Windows.

Proces běží na pozadí

Po spuštění proces běží souběžně s vaším PHP skriptem: runExecutable() i runCommand() se vrátí okamžitě a nečekají, až proces doběhne. Vy rozhodujete, kdy (a zda) na něj počkáte:

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

// ... do other work here while npm runs ...

$process->wait(); // now block until it's done

V praxi voláte wait() přímo jen zřídka, protože getStdOutput(), getExitCode(), isSuccess() i ensureSuccess() na proces počkají automaticky, než vám dají odpověď. wait() zavolejte explicitně tehdy, když mu chcete předat callback.

isRunning(): bool

Vrací true, dokud proces běží, a false, jakmile doběhl nebo byl ukončen. Hodí se, když chcete mezitím dělat něco jiného:

while ($process->isRunning()) {
	// do something else for a while
	usleep(100_000); // 100 ms
}

Jak skončil?

Každý dokončený proces má návratový kód: konvenčně 0 znamená úspěch a jakékoli jiné číslo nějaký druh selhání (co přesně, záleží na programu).

getExitCode(): int

Vrátí návratový kód; pokud je potřeba, nejprve počká, až proces doběhne:

$code = Process::runExecutable('git', ['pull'])->getExitCode(); // e.g. 0

isSuccess(): bool

Zkratka pro „rovná se návratový kód 0?":

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

ensureSuccess(): void

Často chcete prostě jen to, aby program uspěl, a jinak hlasitě selhat. ensureSuccess() počká na proces a vyhodí Nette\Utils\ProcessFailedException, pokud návratový kód není 0:

Process::runExecutable('git', ['pull'])->ensureSuccess();
// execution continues only if git succeeded

Čtení výstupu

Proces má dva oddělené výstupní proudy: standardní výstup (běžné výsledky) a chybový výstup (kam programy obvykle hlásí problémy a diagnostiku). Nette Utils tyto dva drží odděleně a ve výchozím nastavení oba zachytává do paměti, takže si je můžete přečíst, kdykoli budete chtít.

getStdOutput(): string

Počká, až proces doběhne, a vrátí vše, co zapsal na standardní výstup:

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

getStdError(): string

Totéž, ale pro chybový výstup:

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

Pokud výstupní proud přesměrujete jinam (do souboru, do resource nebo na false), není v paměti co vracet a příslušný getter vyhodí Nette\InvalidStateException.

consumeStdOutput(): string

Někdy chcete vidět výstup tak, jak přichází, bez čekání na konec procesu, třeba abyste zobrazili průběh. Každé volání vrátí kus standardního výstupu, který se objevil od předchozího volání:

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

while ($process->isRunning()) {
	echo $process->consumeStdOutput(); // prints whatever is new
	usleep(100_000); // 100 ms
}
echo $process->consumeStdOutput(); // the final piece, produced just before it ended

Volání uvnitř poslední iterace smyčky už vrátí i to, co proces vypsal těsně před koncem, takže to volání za smyčkou je jen pojistka. Pro chybový výstup existuje obdobné consumeStdError().

Sledování výstupu naživo

Místo pollování přes consumeStdOutput() můžete metodě wait() předat callback. Ten se zavolá pokaždé, když se objeví nový výstup, což se hodí pro průběžné logování nebo přeposílání výstupu jinam:

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

$process->wait(function (string $stdOut, string $stdErr) {
	echo $stdOut;            // forward standard output
	fwrite(STDERR, $stdErr); // and standard error
});

Callback dostane dva řetězce: nová data standardního výstupu a nová data chybového výstupu od předchozího volání (kterýkoli může být prázdný). Když se wait() vrátí, proces je dokončený a stále můžete volat getExitCode(), getStdOutput() a další.

Posílání vstupu

Parametr $stdin říká, co proces čte na svém standardním vstupu. Přijímá několik různých věcí.

Řetězec se stane celým vstupem procesu:

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

Čitelný resource (otevřený soubor, stream) se do vstupu zkopíruje:

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

null ponechá vstup otevřený, takže do něj můžete zapisovat postupně (viz níže).

Výchozí hodnotou je prázdný řetězec, což znamená, že proces dostane prázdný, okamžitě uzavřený vstup. To je rozumné výchozí chování: zabrání tomu, aby programy, které čtou vstup, navždy visely a čekaly na něco, co nikdy nepřijde.

writeStdInput (string $string)void

Když proces spustíte s stdin: null, vstup zůstane otevřený a vy ho plníte po částech. Až skončíte, zavolejte closeStdInput(). Tím programu řeknete, že už žádný vstup nepřijde (pošle se konec souboru, EOF):

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

Řetězec nebo stream předaný jako $stdin se zapíše celý najednou, ještě než se proces pořádně rozběhne. Pokud je tento vstup velký a zároveň program produkuje hodně výstupu, aniž by si nejdřív přečetl vstup, mohou obě strany uváznout v čekání na sebe navzájem. V tom (vzácném) případě použijte stdin: null a writeStdInput(), abyste prokládali zápis čtením.

Řetězení procesů (piping)

Standardní výstup jednoho procesu můžete napojit přímo na standardní vstup druhého, přesně jako rourou | v shellu. Stačí jako $stdin předat Process:

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

echo $consumer->getStdOutput();

Zřetězit můžete libovolný počet procesů (a | b | c).

Řetězení procesů není podporováno na Windows (vyhodí Nette\NotSupportedException). Na Windows zachyťte výstup prvního procesu pomocí getStdOutput() a předejte ho dalšímu jako řetězec.

Přesměrování výstupu jinam

Ve výchozím nastavení se standardní i chybový výstup zachytávají do paměti. Parametry $stdout a $stderr umožňují poslat je místo toho jinam.

Název souboru pošle výstup do tohoto souboru:

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

Zapisovatelný resource pošle výstup do tohoto streamu. Musí být podložený skutečným souborem (ne php://memory apod.):

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

false výstup úplně zahodí (jde do /dev/null, resp. NUL na Windows):

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

Přesměrování zároveň drží spotřebu paměti nízko: zachytávání do paměti je pohodlné, ale proces, který vypíše gigabajty, by spotřeboval gigabajty RAM, takže takový výstup zapisujte do souboru.

Proměnné prostředí

Parametr $env nastavuje proměnné prostředí, které proces uvidí. Ponechte null (výchozí), aby zdědil prostředí aktuálního procesu, nebo předejte pole a nastavte si je sami:

// the current environment plus one extra variable
$process = Process::runExecutable('printenv', ['MY_VAR'], env: ['MY_VAR' => '123'] + getenv());

// a completely empty environment
$process = Process::runExecutable('some-tool', env: []);

Pracovní adresář

Parametr $directory nastavuje adresář, ve kterém proces startuje (výchozí je ten aktuální):

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

Časový limit

Parametr $timeout (v sekundách, výchozí 60) omezuje, jak dlouho budete na proces čekat. Pokud se limit překročí během toho, co na proces čekáte nebo čtete jeho výstup, proces se zabije a vyhodí se Nette\Utils\ProcessTimeoutException. Předáním null limit zrušíte:

$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.';
}

Limit se kontroluje jen tehdy, když jste uvnitř wait(), getExitCode(), getterů výstupu nebo consume*(). Proces, který spustíte a pak na něj nikdy nečekáte, jím zabit není.

Ukončení procesu

terminate(): void

Okamžitě zabije proces, pokud ještě běží; pokud už doběhl, neudělá nic:

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

Proces se také ukončí automaticky, když je jeho objekt Process zničen (například opustí platnost) a ještě nedoběhl.

getPid(): ?int

Vrátí ID procesu (PID) operačního systému, dokud proces běží, nebo null, jakmile doběhl:

$pid = $process->getPid();

Když se něco pokazí

Chyby se vždy hlásí vyhozením výjimky, nikdy návratovou hodnotou:

Nette\Utils\ProcessFailedException proces se nepodařilo spustit, nebo byla zavolána ensureSuccess() a návratový kód nebyl 0
Nette\Utils\ProcessTimeoutException byl překročen limit daný parametrem $timeout
Nette\InvalidArgumentException jako $stdin, $stdout nebo $stderr byla předána neplatná hodnota
Nette\IOException soubor zadaný jako $stdout nebo $stderr se nepodařilo otevřít
Nette\InvalidStateException čtení výstupu, který nebyl zachytáván, nebo zápis do STDIN, který je už zavřený
Nette\NotSupportedException o řetězení procesů se pokusilo na Windows

ProcessFailedException a ProcessTimeoutException dědí z PHP RuntimeException.

verze: 4.0