Process : exécution de programmes externes
Nette\Utils\Process vous permet de
lancer des programmes externes depuis PHP : de leur fournir une entrée, de lire leur sortie et de réagir à la façon dont ils
se sont terminés. C'est une enveloppe accueillante autour de proc_open() de PHP, qui signale les erreurs en levant
des exceptions plutôt qu'en retournant false.
Installation :
composer require nette/utils
Tous les exemples supposent que l'alias suivant est défini :
use Nette\Utils\Process;
L'usage le plus simple
Vous voulez lancer un programme et lire ce qu'il a affiché ? Il n'en faut pas plus :
$process = Process::runExecutable('git', ['log', '-1', '--format=%H']);
echo $process->getStdOutput();
Le premier argument est le programme à exécuter, le second la liste de ses arguments : exactement ce que vous taperiez en
ligne de commande, simplement réparti dans un tableau. La méthode getStdOutput() attend la fin du programme et
retourne tout ce qu'il a écrit sur sa sortie standard.
Toute l'idée tient là : vous démarrez un processus, puis vous lui posez des questions : tourne-t-il encore, qu'a-t-il affiché, comment s'est-il terminé ? La suite de cette page passe ces questions en revue.
Démarrer un processus
Il y a deux façons de démarrer un processus, et la différence mérite d'être comprise.
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
Exécute un programme précis avec une liste d'arguments. Les arguments sont transmis directement au programme : vous n'avez donc jamais à échapper les espaces, les guillemets ou d'autres caractères spéciaux. Et comme aucun shell n'intervient, il n'y a aucun risque d'injection de shell. C'est le choix sûr, surtout quand une partie de la commande provient d'une entrée utilisateur :
$file = $_GET['file']; // pourrait être n'importe quoi, même '; rm -rf /'
$process = Process::runExecutable('wc', ['-l', $file]); // parfaitement sûr
Le programme est cherché dans le PATH du système si vous ne donnez pas de chemin complet. Pour exécuter un
script PHP, la constante PHP_BINARY est bien pratique :
$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
Exécute une chaîne de commande via le shell du système (/bin/sh sous Linux et macOS,
cmd.exe sous Windows). Cela vous donne les fonctionnalités du shell : les tubes |, les redirections
>, l'expansion des variables, l'enchaînement de commandes avec &&, etc. :
$process = Process::runCommand('git log --oneline | head -n 20');
Mais comme le shell analyse toute la chaîne, ne construisez jamais une chaîne pour runCommand() à partir
d'une entrée non fiable : c'est une faille de sécurité classique. Dans le doute, utilisez plutôt
runExecutable().
Avec autant de paramètres, passez-les comme arguments nommés, par ex.
Process::runExecutable('git', ['pull'], timeout: 30). Le tableau $options est transmis à
proc_open() pour les besoins avancés, comme bypass_shell sous Windows.
Le processus tourne en arrière-plan
Une fois démarré, le processus tourne en parallèle de votre script PHP : runExecutable() et
runCommand() retournent immédiatement et n'attendent pas sa fin. C'est vous qui décidez quand (et si)
attendre :
$process = Process::runExecutable('npm', ['install']);
// ... faites autre chose ici pendant que npm travaille ...
$process->wait(); // maintenant, bloquez jusqu'à la fin
En pratique, vous appellerez rarement wait() vous-même, car getStdOutput(),
getExitCode(), isSuccess() et ensureSuccess() attendent tous automatiquement le processus
avant de vous répondre. Appelez wait() explicitement quand vous voulez lui passer un callback.
isRunning(): bool
Retourne true tant que le processus tourne, false une fois qu'il s'est terminé ou a été arrêté.
Pratique pour faire autre chose entre-temps :
while ($process->isRunning()) {
// faire autre chose un moment
usleep(100_000); // 100 ms
}
Comment s'est-il terminé ?
Tout processus terminé a un code de sortie : par convention, 0 signifie la réussite et tout autre
nombre un échec quelconque (le sens exact dépend du programme).
getExitCode(): int
Retourne le code de sortie, en attendant d'abord la fin du processus si nécessaire :
$code = Process::runExecutable('git', ['pull'])->getExitCode(); // par ex. 0
isSuccess(): bool
Un raccourci pour “le code de sortie valait-il 0 ?” :
$process = Process::runExecutable('git', ['pull']);
if (!$process->isSuccess()) {
echo 'git failed: ' . $process->getStdError();
}
ensureSuccess(): void
Souvent, vous voulez simplement que le programme réussisse et qu'il échoue bruyamment sinon. ensureSuccess()
attend le processus et lève Nette\Utils\ProcessFailedException si le code de sortie n'est pas 0 :
Process::runExecutable('git', ['pull'])->ensureSuccess();
// l'exécution ne continue que si git a réussi
Lire la sortie
Un processus a deux flux de sortie distincts : la sortie standard (les résultats normaux) et l'erreur standard (là où les programmes signalent d'ordinaire les problèmes et les diagnostics). Nette Utils garde les deux séparés et, par défaut, capture les deux en mémoire pour que vous puissiez les lire quand bon vous semble.
getStdOutput(): string
Attend la fin du processus et retourne tout ce qu'il a écrit sur la sortie standard :
$process = Process::runExecutable('date');
echo $process->getStdOutput();
getStdError(): string
La même chose, mais pour l'erreur standard :
$process = Process::runExecutable('some-tool', ['--do-stuff']);
if (!$process->isSuccess()) {
throw new RuntimeException('The tool failed: ' . $process->getStdError());
}
Si vous redirigez un flux de sortie (vers un fichier, une
ressource ou false), il n'y a rien en mémoire à retourner et le getter correspondant lève
Nette\InvalidStateException.
consumeStdOutput(): string
Vous voulez parfois voir la sortie au fil de son arrivée, sans attendre la fin du processus, par exemple pour afficher une progression. Chaque appel retourne le fragment de sortie standard apparu depuis l'appel précédent :
$process = Process::runExecutable('long-running-tool');
while ($process->isRunning()) {
echo $process->consumeStdOutput(); // affiche ce qui est nouveau
usleep(100_000); // 100 ms
}
echo $process->consumeStdOutput(); // le dernier morceau, produit juste avant la fin
Le consumeStdOutput() placé après la boucle compte : le processus a pu écrire sa dernière sortie pendant
l'ultime usleep(), après le dernier appel dans la boucle mais avant que celle-ci ne remarque sa fin. (S'il s'est
terminé pendant un appel dans la boucle, cet appel a déjà tout retourné et celui-ci retourne une chaîne vide.) Il existe
aussi consumeStdError() pour l'erreur standard.
Suivre la sortie en direct
Au lieu d'interroger la sortie avec consumeStdOutput(), vous pouvez passer un callback à wait(). Il
sera invoqué à chaque fois que de nouvelles données arrivent, ce qui est idéal pour journaliser en direct ou transférer la
sortie ailleurs :
$process = Process::runExecutable('npm', ['install']);
$process->wait(function (string $stdOut, string $stdErr) {
echo $stdOut; // transfère la sortie standard
fwrite(STDERR, $stdErr); // et l'erreur standard
});
Le callback reçoit deux chaînes : les nouvelles données de la sortie standard et celles de l'erreur standard depuis l'appel
précédent (l'une comme l'autre peut être vide). Quand wait() retourne, le processus est terminé et vous pouvez
encore appeler getExitCode(), getStdOutput() et les autres.
Envoyer une entrée
Le paramètre $stdin indique ce que le processus lit sur son entrée standard. Il accepte plusieurs sortes de
valeurs.
Une chaîne devient l'intégralité de l'entrée du processus :
$process = Process::runExecutable('wc', ['-c'], stdin: 'hello world');
echo $process->getStdOutput(); // 11
Une ressource lisible (un fichier ouvert, un flux) est copiée dans l'entrée :
$file = fopen('data.csv', 'r');
$process = Process::runExecutable('sort', stdin: $file);
null garde l'entrée ouverte pour que vous puissiez y écrire progressivement (voir ci-dessous).
La valeur par défaut est la chaîne vide, ce qui signifie que le processus reçoit une entrée vide et immédiatement fermée. C'est le comportement par défaut le plus sensé : il évite que les programmes qui lisent leur entrée restent bloqués à attendre indéfiniment quelque chose qui ne viendra jamais.
writeStdInput (string $string): void
Quand vous démarrez le processus avec stdin: null, l'entrée reste ouverte et vous l'alimentez morceau par
morceau. Appelez closeStdInput() quand vous avez fini. Cela indique au programme qu'il ne recevra plus rien (un
signal de fin de fichier est envoyé) :
$process = Process::runExecutable('some-repl', stdin: null);
$process->writeStdInput("first command\n");
$process->writeStdInput("second command\n");
$process->closeStdInput();
echo $process->getStdOutput();
Une chaîne ou un flux passé comme $stdin est écrit d'un seul coup avant que le processus ne
démarre vraiment. Si cette entrée est volumineuse et que le programme produit beaucoup de sortie sans lire d'abord son
entrée, les deux côtés peuvent se bloquer mutuellement. Dans ce cas (rare), utilisez stdin: null et
writeStdInput() pour entrelacer écriture et lecture.
Enchaîner les processus (tubes)
Vous pouvez connecter la sortie standard d'un processus directement à l'entrée standard d'un autre, exactement comme un tube
shell |. Il suffit de passer un Process comme $stdin :
$producer = Process::runExecutable('cat', ['big.log']);
$consumer = Process::runExecutable('grep', ['error'], stdin: $producer);
echo $consumer->getStdOutput();
Vous pouvez enchaîner autant de processus que vous voulez (a | b | c).
L'enchaînement de processus par tubes n'est pas pris en charge sous Windows (il lève
Nette\NotSupportedException). Sous Windows, capturez la sortie du premier processus avec getStdOutput()
et passez-la au suivant sous forme de chaîne.
Rediriger la sortie ailleurs
Par défaut, la sortie standard et l'erreur standard sont capturées en mémoire. Les paramètres $stdout et
$stderr permettent de les envoyer ailleurs.
Un nom de fichier envoie la sortie dans ce fichier :
Process::runExecutable('mysqldump', ['mydb'], stdout: 'backup.sql')
->ensureSuccess();
Une ressource accessible en écriture envoie la sortie dans ce flux. Elle doit reposer sur un vrai fichier (pas
php://memory et consorts) :
$log = fopen('build.log', 'a');
Process::runExecutable('make', stdout: $log, stderr: $log);
false jette entièrement la sortie (elle part dans /dev/null, ou NUL sous
Windows) :
Process::runExecutable('noisy-tool', stderr: false);
La redirection limite aussi la consommation de mémoire : capturer en mémoire est confortable, mais un processus qui affiche des gigaoctets en consommerait autant de RAM ; écrivez donc ce genre de sortie dans un fichier.
Variables d'environnement
Le paramètre $env définit les variables d'environnement que verra le processus. Laissez-le à null
(valeur par défaut) pour hériter de l'environnement du processus courant, ou passez un tableau pour les définir
vous-même :
// l'environnement courant plus une variable supplémentaire
$process = Process::runExecutable('printenv', ['MY_VAR'], env: ['MY_VAR' => '123'] + getenv());
// un environnement complètement vide
$process = Process::runExecutable('some-tool', env: []);
Répertoire de travail
Le paramètre $directory définit le répertoire dans lequel le processus démarre (par défaut, le répertoire
courant) :
$process = Process::runExecutable('git', ['status'], directory: '/path/to/repo');
Limite de temps
Le paramètre $timeout (en secondes, 60 par défaut) plafonne le temps que vous attendrez le
processus. Si la limite est atteinte alors que vous l'attendez ou que vous lisez sa sortie, le processus est tué et
Nette\Utils\ProcessTimeoutException est levée. Passez null pour supprimer la limite :
$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.';
}
La limite n'est vérifiée que lorsque vous êtes dans wait(), getExitCode(), les getters de sortie
ou consume*(). Un processus que vous démarrez sans jamais l'attendre n'est pas tué par elle.
Arrêter un processus
terminate(): void
Tue immédiatement le processus s'il tourne encore ; ne fait rien s'il s'est déjà terminé :
$process = Process::runExecutable('server');
// ...
$process->terminate();
Un processus est également arrêté automatiquement quand son objet Process est détruit (parce qu'il sort de
portée, par exemple) avant sa fin. Si ce n'est pas ce que vous voulez, détachez le processus de l'objet :
detach(): void
Détache le processus de l'objet : il continue de tourner en arrière-plan et n'est plus arrêté à la destruction de l'objet. C'est ainsi que vous démarrez un démon ou une tâche de fond qui survit même au script PHP :
$process = Process::runExecutable('worker', stdout: 'worker.log', stderr: false);
$process->detach();
// le processus continue de tourner même après la destruction de $process
Comme plus personne ne lirait la sortie après le détachement, elle ne doit pas être capturée en mémoire : redirigez-la vers un fichier, une ressource ou false, faute de quoi
detach() lève Nette\InvalidStateException. L'entrée standard et les tubes de sortie sont fermés lors
du détachement.
Seul le comportement du destructeur change. wait() et getExitCode() attendent toujours la fin du
processus (et $timeout s'applique toujours et le tue en cas de dépassement), et terminate() l'arrête
toujours.
Sur les systèmes POSIX, un processus détaché qui se termine alors que votre script tourne encore apparaît dans la liste des processus comme un zombie jusqu'à la fin du script. C'est sans danger et cela disparaît tout seul.
getPid(): ?int
Retourne l'identifiant de processus du système d'exploitation (PID) tant que le processus tourne, ou null une
fois qu'il s'est terminé :
$pid = $process->getPid();
Quand quelque chose se passe mal
Les erreurs sont toujours signalées en levant une exception, jamais par une valeur de retour :
Nette\Utils\ProcessFailedException |
le processus n'a pas pu être démarré, ou ensureSuccess() a été appelée et le code de sortie n'était
pas 0 |
Nette\Utils\ProcessTimeoutException |
la limite $timeout a été dépassée |
Nette\InvalidArgumentException |
une valeur invalide a été passée comme $stdin, $stdout ou $stderr |
Nette\IOException |
un fichier indiqué comme $stdout ou $stderr n'a pas pu être ouvert |
Nette\InvalidStateException |
lecture d'une sortie non capturée, écriture sur un STDIN déjà fermé, ou détachement d'un processus dont la sortie est capturée en mémoire |
Nette\NotSupportedException |
un enchaînement de processus par tube a été tenté sous Windows |
ProcessFailedException et ProcessTimeoutException étendent la classe RuntimeException
de PHP.