Process: uruchamianie zewnętrznych programów
Nette\Utils\Process pozwala uruchamiać
z PHP zewnętrzne programy: podawać im wejście, czytać ich wynik i reagować na to, jak się zakończyły. To przyjazny
wrapper wokół proc_open() z PHP, który zamiast zwracać false, zgłasza przy błędach wyjątki.
Instalacja:
composer require nette/utils
Wszystkie przykłady zakładają, że zdefiniowany jest następujący alias:
use Nette\Utils\Process;
Najprostsze użycie
Chcesz uruchomić program i odczytać, co wypisał? Wystarczy tyle:
$process = Process::runExecutable('git', ['log', '-1', '--format=%H']);
echo $process->getStdOutput();
Pierwszym argumentem jest program do uruchomienia, drugim lista jego argumentów: dokładnie to, co wpisałbyś w wierszu
poleceń, tylko rozbite na tablicę. Metoda getStdOutput() czeka na zakończenie programu i zwraca wszystko, co
zapisał na standardowe wyjście.
Na tym polega cała idea: uruchamiasz proces, a potem zadajesz mu pytania: czy jeszcze działa, co wypisał, jak się zakończył. Reszta tej strony omawia te pytania po kolei.
Uruchamianie procesu
Proces można uruchomić na dwa sposoby i warto zrozumieć, czym się różnią.
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
Uruchamia jeden konkretny program z listą argumentów. Argumenty przekazywane są programowi bezpośrednio, więc nigdy nie musisz escapować spacji, cudzysłowów ani innych znaków specjalnych. A ponieważ nie bierze w tym udziału powłoka, nie ma ryzyka shell injection. To bezpieczny wybór, zwłaszcza gdy jakakolwiek część polecenia pochodzi od użytkownika:
$file = $_GET['file']; // może być czymkolwiek, nawet '; rm -rf /'
$process = Process::runExecutable('wc', ['-l', $file]); // całkowicie bezpieczne
Jeśli nie podasz pełnej ścieżki, program wyszukiwany jest w systemowej zmiennej PATH. Do uruchomienia skryptu
PHP przydaje się stała 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
Uruchamia string z poleceniem przez powłokę systemową (/bin/sh na Linuksie i macOS,
cmd.exe w Windows). Daje Ci to możliwości powłoki: potoki |, przekierowania >,
rozwijanie zmiennych, łączenie poleceń przez && itd.:
$process = Process::runCommand('git log --oneline | head -n 20');
Ponieważ jednak powłoka parsuje cały string, nigdy nie buduj stringa dla runCommand() z niezaufanych
danych, bo to klasyczna luka bezpieczeństwa. W razie wątpliwości użyj runExecutable().
Przy takiej liczbie parametrów przekazuj je jako argumenty nazwane, np.
Process::runExecutable('git', ['pull'], timeout: 30). Tablica $options przekazywana jest do
proc_open() na potrzeby zaawansowane, takie jak bypass_shell w Windows.
Proces działa w tle
Po uruchomieniu proces działa równolegle z Twoim skryptem PHP: runExecutable() i
runCommand() wracają natychmiast i nie czekają na jego zakończenie. Ty decydujesz, kiedy (i czy w ogóle)
poczekać:
$process = Process::runExecutable('npm', ['install']);
// ... rób tutaj inne rzeczy, podczas gdy npm pracuje ...
$process->wait(); // teraz blokuj, dopóki nie skończy
W praktyce rzadko wywołujesz wait() sam, bo getStdOutput(), getExitCode(),
isSuccess() i ensureSuccess() same czekają na proces, zanim dadzą Ci odpowiedź. Wywołaj
wait() jawnie, gdy chcesz przekazać mu callback.
isRunning(): bool
Zwraca true, dopóki proces działa, a false, gdy się zakończył albo został zabity. Przydaje się
do wykonywania w międzyczasie innej pracy:
while ($process->isRunning()) {
// przez chwilę rób coś innego
usleep(100_000); // 100 ms
}
Jak się zakończył?
Każdy zakończony proces ma kod wyjścia: umownie 0 oznacza sukces, a każda inna liczba jakiegoś
rodzaju niepowodzenie (co dokładnie, zależy od programu).
getExitCode(): int
Zwraca kod wyjścia, w razie potrzeby najpierw czekając na zakończenie procesu:
$code = Process::runExecutable('git', ['pull'])->getExitCode(); // np. 0
isSuccess(): bool
Skrót dla “czy kod wyjścia był równy 0?”:
$process = Process::runExecutable('git', ['pull']);
if (!$process->isSuccess()) {
echo 'git failed: ' . $process->getStdError();
}
ensureSuccess(): void
Często po prostu chcesz, aby program się powiódł, a w przeciwnym razie żeby głośno zawiódł.
ensureSuccess() czeka na proces i zgłasza Nette\Utils\ProcessFailedException, jeśli kod wyjścia nie
jest 0:
Process::runExecutable('git', ['pull'])->ensureSuccess();
// wykonanie trwa dalej tylko wtedy, gdy git się powiódł
Odczyt wyniku
Proces ma dwa osobne strumienie wyjściowe: standardowe wyjście (zwykłe wyniki) i standardowe wyjście błędów (gdzie programy zwykle zgłaszają problemy i diagnostykę). Nette Utils trzyma je osobno i domyślnie przechwytuje oba do pamięci, abyś mógł je odczytać, kiedy zechcesz.
getStdOutput(): string
Czeka na zakończenie procesu i zwraca wszystko, co zapisał na standardowe wyjście:
$process = Process::runExecutable('date');
echo $process->getStdOutput();
getStdError(): string
To samo, ale dla standardowego wyjścia błędów:
$process = Process::runExecutable('some-tool', ['--do-stuff']);
if (!$process->isSuccess()) {
throw new RuntimeException('The tool failed: ' . $process->getStdError());
}
Jeśli przekierujesz strumień wyjściowy (do pliku, zasobu
albo false), w pamięci nie ma nic do zwrócenia i odpowiadający getter zgłasza
Nette\InvalidStateException.
consumeStdOutput(): string
Czasem chcesz widzieć wynik w miarę, jak napływa, bez czekania na zakończenie procesu, na przykład aby pokazywać postęp. Każde wywołanie zwraca porcję standardowego wyjścia, która pojawiła się od poprzedniego wywołania:
$process = Process::runExecutable('long-running-tool');
while ($process->isRunning()) {
echo $process->consumeStdOutput(); // wypisuje to, co nowe
usleep(100_000); // 100 ms
}
echo $process->consumeStdOutput(); // ostatni kawałek, wyprodukowany tuż przed zakończeniem
consumeStdOutput() po pętli ma znaczenie: proces mógł zapisać swój ostatni wynik podczas końcowego
usleep(), po ostatnim wywołaniu w pętli, ale zanim pętla zauważyła, że proces się zakończył. (Jeśli
zakończył się podczas wywołania w pętli, to wywołanie zwróciło już wszystko, a to zwróci pusty string.) Dla
standardowego wyjścia błędów istnieje też consumeStdError().
Śledzenie wyniku na żywo
Zamiast odpytywać przez consumeStdOutput(), możesz przekazać wait() callback. Będzie wywoływany
za każdym razem, gdy pojawi się nowy wynik, co świetnie nadaje się do logowania na żywo albo przekazywania wyniku gdzie
indziej:
$process = Process::runExecutable('npm', ['install']);
$process->wait(function (string $stdOut, string $stdErr) {
echo $stdOut; // przekazujemy standardowe wyjście
fwrite(STDERR, $stdErr); // i standardowe wyjście błędów
});
Callback otrzymuje dwa stringi: nowe dane ze standardowego wyjścia i nowe dane ze standardowego wyjścia błędów od
poprzedniego wywołania (każdy może być pusty). Gdy wait() wróci, proces jest zakończony i nadal możesz
wywołać getExitCode(), getStdOutput() i resztę.
Wysyłanie wejścia
Parametr $stdin określa, co proces czyta na standardowym wejściu. Przyjmuje kilka różnych rzeczy.
String staje się całym wejściem procesu:
$process = Process::runExecutable('wc', ['-c'], stdin: 'hello world');
echo $process->getStdOutput(); // 11
Zasób do odczytu (otwarty plik, strumień) jest kopiowany na wejście:
$file = fopen('data.csv', 'r');
$process = Process::runExecutable('sort', stdin: $file);
null pozostawia wejście otwarte, dzięki czemu możesz zapisywać do niego stopniowo (zobacz niżej).
Domyślną wartością jest pusty string, co oznacza, że proces dostaje puste, natychmiast zamknięte wejście. To rozsądne domyślne zachowanie: powstrzymuje programy czytające wejście przed wiecznym czekaniem na coś, co nigdy nie nadejdzie.
writeStdInput (string $string): void
Gdy uruchomisz proces z stdin: null, wejście pozostaje otwarte i podajesz je kawałek po kawałku. Po
skończeniu wywołaj closeStdInput(). Mówi to programowi, że więcej wejścia nie będzie (wysyła znak końca
pliku):
$process = Process::runExecutable('some-repl', stdin: null);
$process->writeStdInput("first command\n");
$process->writeStdInput("second command\n");
$process->closeStdInput();
echo $process->getStdOutput();
String albo strumień przekazany jako $stdin zapisywany jest naraz, zanim proces naprawdę ruszy.
Jeśli to wejście jest duże i program produkuje dużo wyniku, nie czytając najpierw swojego wejścia, obie strony mogą
utknąć, czekając na siebie nawzajem. W tym (rzadkim) przypadku użyj stdin: null i writeStdInput(),
aby przeplatać zapis z odczytem.
Łączenie procesów (potoki)
Standardowe wyjście jednego procesu możesz podłączyć wprost do standardowego wejścia innego, dokładnie jak potok
powłoki |. Wystarczy przekazać Process jako $stdin:
$producer = Process::runExecutable('cat', ['big.log']);
$consumer = Process::runExecutable('grep', ['error'], stdin: $producer);
echo $consumer->getStdOutput();
Możesz łączyć dowolnie wiele procesów (a | b | c).
Łączenie procesów potokami nie jest obsługiwane w Windows (zgłasza
Nette\NotSupportedException). W Windows przechwyć wynik pierwszego procesu przez getStdOutput()
i przekaż go następnemu jako string.
Przekierowanie wyniku gdzie indziej
Domyślnie standardowe wyjście i standardowe wyjście błędów przechwytywane są do pamięci. Parametry
$stdout i $stderr pozwalają wysłać je zamiast tego gdzie indziej.
Nazwa pliku wysyła wynik do tego pliku:
Process::runExecutable('mysqldump', ['mydb'], stdout: 'backup.sql')
->ensureSuccess();
Zasób do zapisu wysyła wynik do tego strumienia. Musi być oparty na prawdziwym pliku (nie na
php://memory i podobnych):
$log = fopen('build.log', 'a');
Process::runExecutable('make', stdout: $log, stderr: $log);
false całkowicie odrzuca wynik (trafia do /dev/null albo NUL w Windows):
Process::runExecutable('noisy-tool', stderr: false);
Przekierowanie utrzymuje też zużycie pamięci na niskim poziomie: przechwytywanie do pamięci jest wygodne, ale proces wypisujący gigabajty zużyłby gigabajty RAM-u, więc taki wynik zapisuj do pliku.
Zmienne środowiskowe
Parametr $env ustawia zmienne środowiskowe, które zobaczy proces. Zostaw go jako null (wartość
domyślna), aby odziedziczyć środowisko bieżącego procesu, albo przekaż tablicę, aby ustawić je samodzielnie:
// bieżące środowisko plus jedna dodatkowa zmienna
$process = Process::runExecutable('printenv', ['MY_VAR'], env: ['MY_VAR' => '123'] + getenv());
// całkowicie puste środowisko
$process = Process::runExecutable('some-tool', env: []);
Katalog roboczy
Parametr $directory ustawia katalog, w którym proces startuje (domyślnie jest to katalog bieżący):
$process = Process::runExecutable('git', ['status'], directory: '/path/to/repo');
Limit czasu
Parametr $timeout (w sekundach, domyślnie 60) ogranicza, jak długo będziesz czekać na proces.
Jeśli limit zostanie osiągnięty, gdy czekasz na proces albo czytasz jego wynik, proces zostaje zabity i zgłaszany jest
Nette\Utils\ProcessTimeoutException. Przekaż null, aby usunąć limit:
$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 sprawdzany jest tylko wtedy, gdy jesteś wewnątrz wait(), getExitCode(), getterów wyniku albo
consume*(). Proces, który uruchomisz i na który nigdy nie zaczekasz, nie zostanie przez limit zabity.
Zatrzymywanie procesu
terminate(): void
Natychmiast zabija proces, jeśli jeszcze działa; nie robi nic, jeśli już się zakończył:
$process = Process::runExecutable('server');
// ...
$process->terminate();
Proces jest też automatycznie zabijany, gdy jego obiekt Process zostanie zniszczony (na przykład wyjdzie poza
zasięg), zanim się zakończy. Jeśli nie o to Ci chodzi, odłącz proces od obiektu:
detach(): void
Odłącza proces od obiektu: działa dalej w tle i nie jest już zabijany przy niszczeniu obiektu. Tak uruchamia się demona albo zadanie w tle, które przeżywa nawet sam skrypt PHP:
$process = Process::runExecutable('worker', stdout: 'worker.log', stderr: false);
$process->detach();
// proces działa dalej nawet po zniszczeniu $process
Ponieważ po odłączeniu nikt nie czytałby wyniku, nie może on być przechwytywany do pamięci: przekieruj go do pliku, zasobu albo false, w przeciwnym razie
detach() zgłosi Nette\InvalidStateException. Standardowe wejście i potoki wyjściowe są przy
odłączaniu zamykane.
Zmienia się tylko zachowanie destruktora. wait() i getExitCode() nadal czekają na zakończenie
procesu (a $timeout nadal obowiązuje i po przekroczeniu zabija proces), a terminate() nadal go
kończy.
W systemach POSIX odłączony proces, który zakończy się, gdy Twój skrypt jeszcze działa, pojawia się na liście procesów jako zombie aż do zakończenia skryptu. Jest to nieszkodliwe i znika samo.
getPid(): ?int
Zwraca identyfikator procesu w systemie operacyjnym (PID), dopóki proces działa, albo null, gdy się
zakończył:
$pid = $process->getPid();
Gdy coś pójdzie nie tak
Błędy zawsze zgłaszane są przez wyjątek, nigdy przez wartość zwracaną:
Nette\Utils\ProcessFailedException |
procesu nie dało się uruchomić albo wywołano ensureSuccess(), a kod wyjścia nie był 0 |
Nette\Utils\ProcessTimeoutException |
przekroczono limit $timeout |
Nette\InvalidArgumentException |
jako $stdin, $stdout albo $stderr przekazano nieprawidłową wartość |
Nette\IOException |
nie dało się otworzyć pliku podanego jako $stdout albo $stderr |
Nette\InvalidStateException |
odczyt wyniku, który nie był przechwytywany, zapis do już zamkniętego STDIN albo odłączenie procesu, którego wynik przechwytywany jest do pamięci |
Nette\NotSupportedException |
próba połączenia procesów potokiem w Windows |
ProcessFailedException i ProcessTimeoutException rozszerzają RuntimeException
z PHP.