ruby の popen に関して。
ruby からIO.popenでプロセスを呼び出すときに、「〇〇」が取れない。というブログがいっぱい見つかって、そうじゃないだろ。という気分になったなったのでメモを残すことにする。
そして、Qiitaなどの記事を丸コピした「いかがでしたでしょうか」も溢れているので、検索でちょっと困った。
私は、プロセス起動を頻繁にやっているので、IO.popen を使っているが、本当にわかりにくいので、面倒だけどいまさらだけど、まとめておく。
IO.popen でプロセス起動
IO.popen はプロセスを起動して結果を取得できる。
Rubyは新しく「プロセス」を起動する。文字列で指定された場合は、シェル経由で、配列で指定された場合は子プロセスで起動する。(ただし、文字列で渡してた場合でも単純なコマンドは子プロセスで起動する。リダイレクトやパイプが含まれるコマンド文字列はシェル経由になる)
プロセスを起動するときは、「STDOUTとSTDIN」を指定して起動する。
IO.popenは プロセスとIOをひらく
IOクラスにあることを忘れないで覚えておく。
IOクラスは、出入力を扱う。popenでプロセスと通信経路を開く。
IO(クラス)--->-->in-->子プロセス--out--> IO(クラス)
少しわかりにくいのがIOクラス
です。入力も出力もまとめてIO
クラスで扱う。入出力をまとめたのがIOクラス。
子プロセスの実行結果を取得する
io = IO.popen(['echo','Hello IO.popen'] ,'r' ) pp io.read io.close
実行結果
"Hello IO.popen\n"
子プロセスに入力する
子プロセスの標準入力に書き込む場合
io = IO.popen(['cat', '-'] ,'r+' ) io.puts "Hello IO.popen" io.close_write pp io.read io.close
実行結果
"Hello IO.popen\n"
子プロセスへの出入力について。
ここまでの「結果を取得」「プロセスへ入力」をよく見比べてみる。
## 結果を読み込むとき io = IO.popen(['echo','Hello IO.popen'] ,'r' ) ## プロセスにデータを渡し、結果を読み込む io = IO.popen(['cat', '-'] ,'r+' )
繰り返しになるが、IO.popenはIOクラスである。READもWRITEもどちらも可能です。それがIOクラスである。
IO.popenは ファイルのopen(オープン)と同じ
IO.popenでやってることは、簡単に言えばプロセスへの出入力
を開いている。
ファイルの場合をもいだしてみると、ファイルを開くとき open はファイルへの出入力を開いている。
これらは同じことである。同じこととして扱える。プロセスはファイルみたいなものである。
ruby でファイルを開く例
io = open( '/dev/null', 'r+' ) io.puts "Hello" io.close
ruby のIOでプロセスを開く例
io = IO.popen( 'cat -', 'r+' ) io.puts "Hello" io.close
これらのコード例を比較する。コードを見ればわかると思うが、ほぼ同じである。
ファイルへの書込読込と、プロセスへの出入力はほぼ同じように扱える。
終了コードの取得
IO.popen で開いたプロセスの終了コードを取るには。$?
を使う。
ただし、$?
は子プロセスが終了しないと取れない。当たり前だが、終了しないと$?
は出現しない。プロセスが終了したら$?
が現れる。
では、いつ終了するのか?
それは、子プロセスへの通信経路として確保しているIOインスタンスをclose
したときです。ソースコードでIO#close
したときです。
IO(クラス)--->-->in-->子プロセス--out--> IO(クラス)
プロセスと、RubyのIOは接続しています。なのでruby側で子プロセスのoutに接続されたIOをCloseしない限り取得できません。
実行例
io = IO.popen( 'cat -', 'r+' ) io.puts "Hello" pp $? io.close pp $?
実行結果
$ bundle exec bin/sample.rb nil #<Process::Status: pid 6574 exit 0>
ファイルなら、ファイルをcloseしない限りファイルは開いています。
同じように、closeしない限り popenしたプロセスは開いています。生きています。
子プロセスとRubyの間にパイプがある。
子プロセスとruby の間に接続が確立しているので、システム(Linux)が待っていてくれます。ruby 側がCloseして、子プロセスの出力結果を捨てるまで、読み込み可能なデータとしてOSのシステムコールが出力結果を持っててくれています。rubyからプロセスへの書き込みパイプが開いてるときも同じようにruby側の書き込みを待ってていてくれます。そのためにプロセスは終了しません。
popen引数でオートクローズ
read/write の両方がopenして起動するが、オプション指定で起動時にcloseしています。
'r' の場合は、書き込みの用のパイプは null になって、close状態で起動しています。
'w' の場合は、読み込み用のパイプは null になって、close状態で起動しています。
読み込み専用で、子プロセスを開く
io = IO.popen( 'echo hello', 'r' ) pp io.read pp $? io.close pp $?
"hello\n" nil #<Process::Status: pid 6637 exit 0>
読み込み専用に書き込むと。。。
io = IO.popen( 'echo hello', 'r' ) io.puts "Hello"
子プロセスが起動するときに、システムコールを使うが、そのシステムコールに読み込み用のパイプだけを渡しているので書き込みができない。
IOError: not opened for writing bin/sample.rb:34:in `write' bin/sample.rb:34:in `puts' bin/sample.rb:34:in `<top (required)>'
書き込み専用で子プロセスを開く(暗黙の出力)
では、書き込み専用にプロセスを開いた場合はどうでしょうか。
この場合は、読み込みをしていないのでcat の結果は無くなりそうです。
io = IO.popen( 'cat -', 'w' ) io.puts "Hello" pp $? io.close pp $?
実行結果
あれ、、、catの結果が出てきます。
bundle exec bin/sample.rb Hello nil #<Process::Status: pid 6679 exit 0> takuya@DESKTOP-2ALDRO3:/mnt/c/Users/takuya/Desktop/zip-checker$
暗黙の接続 $stdout
popen
で開いたプロセスの出力先を指定しない場合、rubyの標準出力である $stdout
へ接続されています。指定しない場合は$stdoutです。
書き込み専用にプロセスを開く。
それでは、暗黙の$stdout
接続をしたくないとき、次のようなコマンドを実行することを思いつくかもしれません。
popen('cat - > /dev/null' )
でも、ちょっとまってください。これはシェル呼び出しでsh コマンドが自分を/dev/null
につないでいます。ruby 側にありません。
そこで、ruby の popen はIOをつないでいることを思い出してみましょう。
IOをつないでいるのですから、/dev/null
を開くのもruby側でやってみましょう。
io = IO.popen( 'cat -', 'w', :out=>open('/dev/null','w') ) io.puts "Hello" pp $? io.close pp $?
実行結果です。
nil #<Process::Status: pid 6699 exit 0>
どうでしょうか、popen
の扱いが少し見えてきた気がしませんか?
暗黙の接続 $stderr
実は、子プロセスのエラー出力も暗黙の了解として、$stderr
に接続されています。
ここが、本当にわかりにくい。
暗黙的に popen
のエラー出力をは rubyの$stderr
に接続されています。ここに気づくか気づかないかで、大きく変わってくる。
この暗黙のIO接続に気づくために、先に$stdout
のお話をしました。
エラー出力を捨てる。
子プロセスのエラー出力をを捨ててみましょう。
これも、コマンドでシェル経由にして、次のようなコードが思いつくかもしれません。
popen('ls /no/exits 2> /dev/null' )
でも、これではシェル呼び出しです。
先程のopenを使ったものを応用してみると次のようになります。
io = IO.popen( 'ls /no/exists', 'r', :err=>open('/dev/null','w') ) pp io.read pp $? io.close pp $?
ここまでのまとめ
プロセスの起動には、IN/OUTを指定する必要がある。
-->-->(in)子プロセス(out)---->
IO.popen('cmd')
すると、IOクラスに接続される。
IOクラス--->(in)子プロセス(out)---> IOクラス
IO.popen('cmd','r')
すると、IOクラスに接続される。
ただし、入力はclosedされた状態で接続される。
IO(closed)--->(in)子プロセス(out)--->IOクラス
IO.popen( 'w' )
すると、$stdoutに接続される。
IOクラス--->-->(in)子プロセス(out)--->$stdout
おなじことが、エラー出力にも起きている。
エラー出力を特に指定しない限り、$stderr
に接続される
IO(クラス)--->─(in)子プロセス(out)─>$stdout └-──err───> $stderr
ターミナルで起動時との比較
popenは、ターミナルからコマンドを起動したときと同様にプロセスを起動するのだが。popenを使っているとよくわからなくなる。
ターミナルから起動した場合、STDOUT/STDIN/STDERRはどこへ行くのを考える。popenやファイルとよく似ているため、理解の補助になるかもしれない。
ターミナルから echo を起動したとき
$ echo hello world
この場合、パイプは次のようになる。
- STDIN 使わないので即Close
- STDOUT ターミナルの出力へ
- STDERR ターミナルの出力へ
echo を起動したとき、echo の結果は、ターミナルの出力と接続されている。
ターミナル --> in -> echo -> out -> ターミナル
ターミナル(sshなど)はttyでIOを扱うので、こんな感じかな。
tty --> in -> echo -> out -> tty
popenの場合、これが、rubyになるだけである。
ruby--> in -> echo -> out -> ruby($stdout or IO)
もう、書きませんが、stderr についても同じである。
ruby--> in -> echo -> err -> ruby($stderr)
接続先のはOS(ファイルディスクリプタ)である。
ここで出てきた接続先はファイルディスクリプタです。
プロセスを起動したとき、プロセスを管理しているのはOSです。OSの管理下の接続先のみプロセスは接続(リダイレクト)が可能です。
IOの接続先はOSの管理するファイルディスクリプタなので、IO.popenが接続できるのは、OSの管理下にあるものです。
だから、OS管理下にないRuby内部の文字列(StringIO)などには接続できません
io = IO.popen( 'ls /no/exists', 'r', :err=>StringIO.new('','w') )
ArgumentError: wrong exec redirect action bin/sample.rb:33:in `popen' bin/sample.rb:33:in `<top (required)>'
次の例では、エラー出力を /dev/null
に接続していますが、これは/dev/nullを開いた、ファイルディスクリプタに接続しているわけです。ここもいくつかrubyが呼び出したときに、OSとの暗黙的な接続が動いてます。このあたりはC言語知らないと、少し辛いかもしれません。
io = IO.popen( 'ls /no/exists', 'r', :err=>open('/dev/null','w') )
簡単に言えば、popenで接続ができるモノは、プロセスから見える範囲にあるモノに限られます。
IO.pipeでつなぐ。
popenは子プロセスが扱える接続先にしか接続できません。それは「ファイル・STDOUT・STDERR」であり、そしてパイプ
です。
ここまで分かれば、システムコール(OS)管理下にまかせておけば、何でも接続できるとわかる。
その接続先として任意に作れるのがOSの「パイプ」です。OSにパイプを作成してもらうのは、ruby の、IO.pipe
が対応している。このメソッドで、OSにパイプを作ってもらうシステムコールを呼び出すことができる。
pp IO.pipe pp IO.pipe
pipeすると、StringIOとは違い、システムコールによるOS管理下にあるパイプがもらえる。
$ bundle exec bin/sample.rb [#<IO:fd 9>, #<IO:fd 10>] [#<IO:fd 11>, #<IO:fd 12>]
fd はファイルディスクリプタを示し、OSの管理するものである。番号は管理番号で、プロセスごとに割り当てられる。
StrngIOは接続できないが「パイプ」なら、popenのエラー出力や標準出力と接続できるのである。IO.pipe
の結果は、ファイルディスクリプタであり、open()
と同じようにOSファイルとつながっているわけです。IOは奥が深いですね。
子プロセスの出力先を変える
pooen された子プロセスに、IN/OUTがあるとわかった。それらはパイプであり、OSが管理するファイルディスクリプタである。
これらが、よくわかったところで、その出力先を変えてみようと思う。
rubyからファイルディスクリプタを作るには IO.pipe
である。
io_r,io_w = IO.pipe io = IO.popen(['ls', '/a'] , :err => io_w ) pp io.read io.close pp $?
これを、実行すると、エラー出力は消えてしまう。
bundle exec bin/sample.rb "" #<Process::Status: pid 6850 exit 2>
消えたけど、捨てられたわけはなく、別PIPEに送信されている。
io_r,io_w = IO.pipe io = IO.popen(['ls', '/a'] , :err => io_w ) pp io.read io_w.close pp io_r.read io.close pp $?
closeが増えて見にくいです。
IO.pipeの結果は、それぞれ(r,w) 専用になっている。それぞれつながっているので、 w側をloseしないと、r側で読めないのが面倒ですね。
popenしたIOはclose前に読み込み、一方で、pipeはcloseしないと読めないんでですね。ややこしくなってきた。*1
ファイルとプロセスでの違い
IO.popen
は、プロセスを開くとき、プロセス特有の制限を受ける。プロセスはシステムコールで起動する。そのため、プロセスにわたたせるのは、パイプだけである。
エラー出力を取りたいために、知ることが多すぎる。と思います。
popenでは標準エラー出力を取れないと書かれちゃうのも仕方が無いのかもしれません。
でもちゃんとIO使えば取れることがわかりました。良かったね。
まとめ
popenでエラー出力をとる
IO.pipeする。
popenで終了コード(exit status)をとる。
IO.popenをcloseする。
サンプル
err_r,err_w = IO.pipe() io = IO.popen(['ls','/', '/a'] , :err => err_w ) ## err_w.close pp err_r.read ## pp io.read io.close pp $?
出力先を変えるだけなら
io = IO.popen(['ls','/', '/a'] , :err => '/dev/null' )
もっと詳しく知りたいとき
次の本を読んでください。
関連資料
https://qiita.com/riocampos/items/3d3f540722f91589e00a
https://riocampos-tech.hatenablog.com/entry/20151225/how_to_capture_stderr_by_io_popen_method
https://qiita.com/tyabe/items/56c9fa81ca89088c5627
*1: 正確には、IO#read はEOFまで読み込みを試みるが、#close しないかぎりEOFが来ないので、EOFまちになって止まる。IO#read以外ならclose前でも読める。