それマグで!

知識はカップより、マグでゆっくり頂きます。 takuya_1stのブログ

習慣に早くから配慮した者は、 おそらく人生の実りも大きい。

phpのproc_open関数でコマンドのstdin/stdout/パイプを使う例を確認しました。

php でプロセス(プログラム実行)

phpでプログラム実行をするなんて邪悪なことを誰がやるんだろうかと。

symfony process でいいじゃないかと思うんですが、駄目なんですね。synfony processだと巨大なSTDIN/STDOUTを扱えないんですね。たとえば、ffmpegにパイプで渡すとか、ssh経由でデータを受け渡すとか出来ないんですね。

既存の解決策が行き詰まるとき、最後の砦になるのが、proc_openでのコマンドの実行です。

proc_openでプログラム実行 exec同等

単純に `command`exec('command') shell_exec と同じことを proc_openでやる。

echo exec('dd if=/dev/urandom bs=1M count=2')

これは、次のソースコードと同等になる。

<?php 
//
$cmd = preg_split('/\s+/','dd if=/dev/urandom bs=1M count=2');
//
$descriptor = [
    0 => STDIN,
    1 => STDOUT,
    2 => STDERR
  ];
//
$process = proc_open($cmd,$descriptor, $pipes);
if( ! is_resource($process) ) {throw new \Exception("実行エラー");}
while(proc_get_status($process)['running']){
  usleep(1000);
}
proc_close($process);

proc_open で開いたプロセスは、何らかの形でwait して待ってあげます。

上記の例では、proc_get_status() の結果が false になるまで wait しています。

wait するのはいくつが方法が考えられます。 たとえば、 proc_get_status($process); で開いた子プロセスの情報が取れるので、exitcodeを見ることも可能です。

wait を工夫すれば、一定時間経過したら強制終了も可能です。

もしやるなら、posix 関数posix_kill で一定時間経過したら KILLするなども出来ます。

proc_open は実質的には fork / exec ( php 関数では pcntl_fork/ pcntl_exec ) ですね。

コマンドからの出力

コマンドを実行して、起動したプロセスからの出力を操作する方法を見ていきます。

標準出力・エラーを捨てる

標準出力とエラーを捨てるときのコードは、こんな感じものがよく書かれますが

<?php 
passthru('ls -l > /dev/null 2> /dev/null');
echo exec('ls -l > /dev/null 2> /dev/null');

これは、proc_openでは次のようにする。

<?php 
$cmd = preg_split('/\s+/','dd if=/dev/urandom bs=1M count=2');
//
$descriptor = [
    0 => STDIN,
    1 => ['file', '/dev/null', 'w'],
    2 => ['file', '/dev/null', 'w']
  ];
//
$process = proc_open($cmd, $descriptor );
if( ! is_resource($process) ) {throw new \Exception("実行エラー");}
while(proc_get_status($process)['running']){
  usleep(1000);
}
proc_close($process);

省略可能

また、$descriptor は指定しないもの以外を省略できる。

使わないディスクリプタは、省略して構わない。

省略するとデフォルト [ STDIN , STDOUT, STDERR ] が適用される。

<?php 
$descriptor = [
    // 0 => STDIN,
    1 => ['file', '/dev/null', 'w'],
    2 => ['file', '/dev/null', 'w']
  ];
//
$process = proc_open($cmd, $descriptor );

標準出力を指定のファイルに

コマンドの出力を別のファイルに接続する。シェルからのコマンド実行だと次のようになる。

<?php
exec(' ls -l > /tmp/out');

これをproc_open 場合は、次のように書くと同様になる。

<?php
//
$cmd = preg_split('/\s+/','ls -alt /tmp/');
//
$fout = fopen('/tmp/out', 'w+');
$descriptor = [
    1 => $fout,
  ];
//
$process = proc_open($cmd, $descriptor );
if( ! is_resource($process) ) {throw new \Exception("実行エラー");}
while(proc_get_status($process)['running']){
  usleep(1000);
}

fseek($fout,0);
$stdout = stream_get_contents($fout);
var_dump($stdout);
//
proc_close($process);

fopen() で開いたファイルに、proc_openしたコマンドのプロセスの標準出力が次々とfwrite されます。

プロセス終了後にファイルのシークは末尾に来ている。

なので、読み出すならfseek($fout,0);をして先頭に持ってくる。

先頭に持ってくるとその場で読み込める。

出力結果を一時ファイルに出す。

出力先を、 php://temp に変更すると、一時ファイルを作成削除を考えずに済むので助かる。

<?php
$fout = fopen('php://temp', 'w+');
$descriptor = [
    1 => $fout,
  ];

php のコードを見ていると、一時ファイルを作成して削除するパターンを非常によく見かけるのだけれど、fopenでfd を持ち回したほうが管理が圧倒的に楽だと思うんです。

php://temp を使う場合の注意点

php://temp には少し癖があり、open するたびに、別のファイルとして扱われる。 必ず、fseek で動かすこと。

## 間違い
$fout = fopen('php://temp', 'w+');
fwrite($fout, 'aaaaaaa');
$out = file_get_contents('php://temp', 'r');
## 正しい
$fout = fopen('php://temp', 'w+');
fwrite($fout, 'aaaaaaa');
fseek($fout, 0);
$out = stream_get_contents($fd);

php://memory and php://temp are not reusable, i.e. after the streams have been closed there is no way to refer to them again.

詳しくは本家のマニュアルを読むこと https://www.php.net/manual/en/wrappers.php.php

標準エラー出力を別のファイルに

エラー出力を、指定したファイルに出す。

ls -alt 2> /tmp/error-log.txt

これと同等のことを proc_open でやると次のようになる。

<?php
//
$cmd = preg_split('/\s+/','ls -alt /tmp/');
//
$ferr = fopen('/tmp/error-log.txt', 'w+');
$descriptor = [
    2 => $ferr,
  ];
//
$process = proc_open($cmd, $descriptor );
if( ! is_resource($process) ) {throw new \Exception("実行エラー");}
while(proc_get_status($process)['running']){
  usleep(1000);
}

//
proc_close($process);

エラー出力と標準出力をそれぞれ別のファイルに出す。

$descriptorをアレコレすると直接ファイルに書き出せる。

<?php
$descriptor = [
    1 => fopen('/tmp/log.txt', 'w+'),
    2 => fopen('/tmp/error-log.txt', 'w+'),
  ];

出力にパイプを使う。

パイプを使う場合は、次のようにしてパイプを指定する。

<?php
$descriptor = [
    1 => ['pipe','w'],
    2 => ['pipe','w'],
  ];
$process = proc_open($cmd, $descriptor, $pipes , '/tmp', []  );

パイプはファイルディスクリプタでプロセス起動後にアクセスができるようになる。

パイプを使う例

pipeを使うと、パイプされているfd から読み込まれる。proc_open() の3th 引数で取得できる。

<?php
//
$cmd = preg_split('/\s+/','ls -alt /tmp/');
//
$descriptor = [
    1 => ['pipe','w'],
  ];
//
$process = proc_open($cmd, $descriptor, $pipes , '/tmp', []  );
if( ! is_resource($process) ) {throw new \Exception("実行エラー");}
while(proc_get_status($process)['running']){
  usleep(1000);
}

$out = stream_get_contents($pipes[1]);
var_dump($out);
//
proc_close($process);

パイプは内部で読み込みと書き込みにfd2つ開けて接続する。

out書き込み用は内部的に使われていて、関数の戻り値にもらえるのは読み込み用 open 後に読み込み用のfd (ファイルディスクリプタ)が返される

パイプ close タイミング

出力用 pipe は、fcloseを明示的に呼ばなくても良い、proc_close() したタイミングで、開いているファイルは close される。

<?php
//fclose( $pipes[1]); //←不要
//fclose( $pipes[2]); //←不要
proc_close($process);

コマンドへの入力

ここまでは、コマンドからの出力を読み込み用に貰ってくる話でした。

ここからは、コマンドへ入力する話です。

標準入力をプロセスにわたす。

実行プログラムの標準入力にファイルを渡すなら、シェルならリダイレクトで書きます。

cat < /tmp/out.txt

入力のリダイレクト、これを proc_open で書くと次のようになる。

<?php
//
$cmd = preg_split('/\s+/','cat');
//
//
$descriptor = [
  0 => fopen('/tmp/out.txt', 'r'),
  ];
//
$process = proc_open($cmd, $descriptor, $pipes , '/tmp', []  );
if( ! is_resource($process) ) {throw new \Exception("実行エラー");}
while(proc_get_status($process)['running']){
  usleep(1000);
}
//
proc_close($process);

cat を起動して、stdin にfopen の結果をリダイレクトでつなぐ。

パイプでコマンドに入力を渡す。

入力を渡すのであれば、パイプを経由するのが一般的だと思います。

cat  /tmp/out.txt | cat 

これを proc_openで書くとしたら、pipe を 指定して、そこに書き込んで、fclose する。

<?php
//
$cmd = preg_split('/\s+/','cat');
//
//
$descriptor = [
  0 => ['pipe','r'],
  ];
//
$process = proc_open($cmd, $descriptor, $pipes , '/tmp', []  );
if( ! is_resource($process) ) {throw new \Exception("実行エラー");}

// write to pipe
$f_in = fopen('/tmp/out.txt', 'r');
while(!feof($f_in)){
  fwrite( $pipes[0], fread($f_in, 1024));
}
fclose($pipes[0]);
// waiting
while(proc_get_status($process)['running']){
  usleep(1000);
}
//
proc_close($process);

fclose($pipes[0]); で開けている パイプをcloseしないとプロセスはEOFが来ないので、読み込み待ちでずっとWaitingになるんで注意 0 => ['pipe','r'] で read を指定しているのは、プロセスからみて read ですね。返ってくる $pipeには プロセスへのwrite が戻されるね。

proc_close($process); するとパイプは自動で閉じられる。

一時ファイル作らずにパイプと標準入出力を使う。

php のコードを見ていると、よく見かけるのだけれど tempファイルをバンバン使うんですね。 でも、それってゴミファイルが残ったり管理がめんどくさいんですね。

<?php

$temp1 = tempnam(  sys_get_temp_dir() );
$temp2 = tempnam(  sys_get_temp_dir() );
$str = exec( 'cat '. $temp1 );
file_put_contents($temp2,$str);
pcntl_signal(SIGINT,  "sig_handler");
@unlink($temp1);
@unlink($temp2);

function sig_handler(){
@unlink($temp1);
@unlink($temp2);
}

削除処理が必要になる理由

一時ファイルを使うの用途にはメモリ上限になっちゃう巨大サイズのコマンド実行を扱うとき。

サイズが大きいデータをクラス間で受け渡すときによく使うんですね。だから一時ファイルを適切に削除しないと面倒が増える。

また、巨大なファイルを変数に読み込んでいると、php のメモリ上限などで困るんですよね。

プロセスを開いて流し込んでパイプでつなぎ、プロセス出力をファイルディスクリプタで受け取ればメモリを浪費せずに済む。

これで一時ファイルの管理の地獄にならないで助かる。

プロセスと一時ファイルを使う。

サイズの大きなファイルを扱うときに、変数に取り出したり、一時ファイルを作成して管理せずに直接にファイルディスクリプタを扱えば、管理から開放される。

<?php
function my_command(){
  $descriptor = [
    1 => $fd = fopen('php://temp','w'),
  ];
  
  
  $process = proc_open("cat", $descriptor, $pipes , '/tmp', []  );
  if( ! is_resource($process) ) {throw new \Exception("実行エラー");}
  
  while(proc_get_status($process)['running']) {
    usleep(1000);
  }
  fseek($fd);
  proc_close($process);
  
  return $fd;
  
}

php://temp をつかっておけば、SIG INIT や HUP時に残る一時ファイルはphpがうまいことやってくれる(はず)

proc_open で標準出力・入力を使ってコマンドを起動する

たとえば、ffmpeg を使って ssh 経由で 変換するとか

cat bigfile.ts | ssh ffmpeg -i   pipe:0   ...  pipe:1  | cat -> out.mp4
<?php
//
$cmd = preg_split('/\s+/','ssh server ffmpeg -i pipe:0  -f h265 pipe:1 ');
//
$f_in = fopen('/tmp/out.ts','r');
$f_out = fopen('/tmp/out.mp4','r');
$descriptor = [
  0 => ['pipe','r'],
  1 => ['pipe','w'],
  2 => STDERR,
  ];
//
$process = proc_open($cmd, $descriptor, $pipes , '/tmp', []  );
if( ! is_resource($process) ) {throw new \Exception("実行エラー");}

// write to pipe
while(!feof($f_in)){
  fwrite( $pipes[0], fread($f_in, 1024));
}
// fclose($pipes[0]);
// waiting
while(proc_get_status($process)['running']){
  usleep(1000);
}

while(!feof($pipes[1])){
  fwrite( $f_out, fread( $pipes[1], 1024));
}

//
proc_close($process);
//
fseek($f_out,0);
var_dump(fstat($f_out));

proc_close($process); するとパイプは自動で閉じられるので、パイプに残った内容を別のファイルに取り出す必要がありますね。

パイプでプロセスをつなぐ。

ここまで、わかれば、proc_open でプロセスをつないで、パイプパイプでプログラムを複数つなぐことができる。

<?php

/// プロセスA
$descriptor_a = [
  0 => $fin = fopen('/tmp/out.txt','r'),
  1 => ['pipe','w'],
  2 => STDERR,
];


$process_a = proc_open("cat", $descriptor_a, $pipes_a , '/tmp', []  );
if( ! is_resource($process_a) ) {throw new \Exception("実行エラー");}

/// プロセスB
$descriptor_b = [
  0 => $pipes_a[1],
  1 => ['pipe','w'],
  2 => STDERR,
];

$process_b = proc_open("cat", $descriptor_b, $pipes_b , '/tmp', []  );
if( ! is_resource($process_a) ) {throw new \Exception("実行エラー");}

// プロセス待ち
while(proc_get_status($process_a)['running']){
  usleep(1000);
}
while(proc_get_status($process_b)['running']){
  usleep(1000);
}
// 結果を取得
while(!feof($pipes_b[1])){
  fwrite( STDOUT, fread( $pipes_b[1], 1024));
}


//close
proc_close($process_a);
proc_close($process_b);

このようにすれば、シェル経由をせずともプロセスを複数つないで実行することができる。

注意点 : 2020-03-10 追加

proc_open を使っていてわかったのですが 、php の['pipe'] は、すぐに詰まります。 phpが proc_open のpipe を内部的にどうしてるのかわからないのですが。

pipe 経由で 数MBの出力をバッファリングすると、すぐにエラーになって固まります。ほんとうにこれphpのバグだと思う。

まとめ

proc_open は 標準出力・入力を指定して扱える。

デフォルト指定

$descriptor = [
  STDIN
  STDOUT,
  STDERR,
  ];

リダイレクト

入力をリダイレクト

$descriptor = [
  0 => fopen("/path/to", 'r')
  ];

出力をリダイレクト

$descriptor = [
  1 => fopen("/path/to", 'w')
  ];

エラーをリダイレクト

$descriptor = [
  2 => fopen("/path/to", 'w')
  ];

パイプ

出力をパイプする

$descriptor = [
  1 => ['pipe','w']
  ];
proc_open( $cmd, $descriptor, $pipes);
stream_get_content($pipes[1]);

入力をパイプする。

$descriptor = [
  0 => ['pipe','r']
  ];
proc_open( $cmd, $descriptor, $pipes);
fwrite($pipes[0], "aaaaaa");
fclose($pipes[0]);

一時ファイル php:/temp を使う

一時ファイルをコマンドの入力に渡す。

$fd_in = fopen('php://temp', 'w+');
fwrite($fd_in, "aaaaaa");
fseek($fd, 0);
$descriptor = [
  0 => $fd
  ];
proc_open( $cmd, $descriptor);

出力を一時ファイルに取り出す

$fd_in = fopen('php://temp', 'w+');
$descriptor = [
  1 => $fd
  ];
proc_open( $cmd, $descriptor);
fseek($fd, 0);

プロセスA→ プロセスB

プロセスをパイプでつなぐ

/// プロセスA
$descriptor_a = [
  0 => $fin = fopen('/tmp/out.txt','r'),
  1 => ['pipe','w'],
  2 => STDERR,
];
$process_a = proc_open("cat", $descriptor_a, $pipes_a , '/tmp', []  );

/// プロセスB
$descriptor_b = [
  0 => $pipes_a[1], // Aの結果をBへ
  1 => ['pipe','w'],
  2 => STDERR,
];
$process_b = proc_open("cat", $descriptor_b, $pipes_b , '/tmp', []  );

参考資料

おすすめ Linux(UNIX)のコマンドの実行とパイプの考え方が一番良くわかった本。