外部プログラムとの連係

世の中にはコマンドラインから利用できる便利なプログラムがたくさんある。 複雑な仕事の一部を既存のプログラムに任せることで, 初期の目的をこなすためのプログラムを手っ取り早く作ることもできる。

ここでは,外部プログラムを起動し,そのプロセスからの標準出力・ 標準エラー出力を受け取ったり,逆にそのプロセスの標準入力へ データを送り込んだりする方法をいくつか示す。

同期呼び出し

一つのプロセスが子プロセスを起動する場合, 親となるプロセスが子プロセスの実行終了を待機してから続く処理を行なう ような形態を同期呼び出し といい,待機せず続く処理を行なう ものを非同期呼び出し という。同期呼び出しの場合は, 子プロセスを起動して, 子プロセスが出力した情報を文字列として利用する場合,そうでない場合を 基準に以下のいずれかを利用すればよい。

子プロセスの出力文字列を利用する場合

コマンド起動をバッククォートで取り込む。
例:

result = `command args`
子プロセスの出力文字列は利用しない場合

system を利用する。
例:

system "command args"

いずれの場合も,起動した子プロセスの標準出力と標準入力は 親プロセスと同じ端末となる。

子プロセスからの出力文字列の受信

起動したプロセスが出力したものを一方的に受け取るだけであれば, 子プロセスの標準出力を読み取れるような起動方法を取ればよい。 その場合は IO.popen を使うのがもっとも簡単である。

Flow of IO.popen

以下のプログラムは cal コマンドを起動し,カレンダー出力を 加工して出力するものである。

popen-cal.rb

#!/usr/bin/env ruby

lineno = 0                      # 行番号を付ける
IO.popen("cal", "r") do |c|
  while line=c.gets
    printf("%d: %s", lineno+=1, line)
  end
end

calコマンドを通常起動すると以下のような結果が出力される。

cal
   February 2014      
 S  M Tu  W Th  F  S  
                   1
 2  3  4  5  6  7  8
 9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28

popen-cal.rb を起動すると,cal コマンドの出力を1行ずつ読み取って行番号を付けたものを出力する。

1:    February 2014
2:  S  M Tu  W Th  F  S
3:                    1
4:  2  3  4  5  6  7  8
5:  9 10 11 12 13 14 15
6: 16 17 18 19 20 21 22
7: 23 24 25 26 27 28
8:

このように,起動したプロセスが標準出力に書き出した文字列を 順次読み取って処理するものは,ファイルの読み取りとほぼ同じ手順で 作成することができる。

子プロセスとの双方向通信

続いて起動したプロセスとデータの双方向のやりとりをする場合の方法を2つ示す。

短いデータのやりとり

IO.popen でのモードを "r+" にすると,起動したプロセスとの間の読み書き両方できるようになる。

co-io Example

子プロセスのやりとりが,

  1. 親プロセスから子プロセスに1行書く
  2. 親プロセスが子プロセスからの出力を読む

という短いデータの1往復の流れなら,

IO.popen(子プロセス, "r+") do |c|
  c.puts データ
  c.close_write
  while line = c.gets
    「lineを利用した処理」
  end
end

のように書く。close_write は,出力のみをクローズする メソッドで,これがない場合実際には puts をしても 子プロセスまでデータが伝わらない。 標準入出力経由のデータは何バイト分かを内部バッファに溜めてから まとめて書き出されるので,データを書く側が送ったつもりでも読み取り側に 伝わらないことが多い。クローズすると,これ以上溜めるべきデータがないと みなされ実際に読み取り側に送られる。

まとまった量のやりとり

以上の書き方は比較的単純であるが,これで済む状況ばかりではない。 「親→子」への出力が複数行に渡る場合や,親と子のやりとりが 何往復にも渡る場合は両プロセスでの入力処理(親となるRubyスクリプトでは gets)が完了するタイミングが合わない。先述のバッファは 有限サイズなので,書き込む側がどんどん書いて, いっこうに読まれないといずれ溢れる。 溢れそうなときは書き込み自体に待ったがかかるため,処理がそこで停止する。 たとえば以下のプログラムの繰り返し指定数値をいろいろ変更して試してみよ。

cat-n.rb

#!/usr/bin/env ruby
# ./cat-n.rb 5000 とすれば5000回。省略時1000回。

repeat = (ARGV[0] || 1000).to_i
IO.popen("cat -n", "r+") do |c|
  0.upto(repeat) {|i| c.puts i}	# cat -n コマンドに指定数値まで
  c.close_write			# 文字列化した整数を送り続ける
  while line=c.gets		# cat -n コマンドからの出力を得てprint
    print line
  end
end

繰り返し数指定を5000と10000で実行した例を示す。

./cat-n.rb 5000
     1  0
     2  1
     3  2
     4  3
(〜〜 中略 〜〜)
  4999  4998
  5000  4999
  5001  5000
./cat-n.rb 10000
(ずっと待っても何も出てこない)

別プロセスとの読み書きを行なう場合は,書き込みと読み込み両方を よどみなく行なう必要がある。そのためには,書き込み用の処理と読み込み用の 処理を並列で動かせばよい。

スレッドを利用し,読み書きそれぞれの処理を並列で動かす例を示す。

cat-n-t.rb

#!/usr/bin/env ruby
# ./cat-n-t.rb 5000 とすれば5000回。省略時1000回。

repeat = (ARGV[0] || 1000).to_i
IO.popen("cat -n", "r+") do |c|
  Thread.new {				# 別スレッドで整数文字列を
    0.upto(repeat) {|i| c.puts i}	# 一気に書き込み,
    c.close_write			# close_write する
  }
  while line=c.gets			# 元スレッドで読み込み
    print line
  end
end

繰り返し数を増やして実行してみる。

./cat-n-t.rb 10000
(〜〜 省略 〜〜)
  9999  9998
 10000  9999
 10001  10000
./cat-n-t.rb 100000
(〜〜 省略 〜〜)
 99999  99998
100000  99999
100001  100000

以上の例は,読み書きをひたすら続けるのみという単純な例だが, 実際に一定の規則に従った書式のやりとりをする場合は,バッファ溢れが起きないよう タイミングを合わせた入出力に注意する。

標準エラー出力を含めたやりとり

IO.popen では,子プロセスからの標準エラー出力を 受け取ることができない。子プロセスの標準エラー出力も受け取りたいときは Ruby固有の Open3 や,C由来の言語に共通の pipe+fork+exec の組み合わせを利用する。

以下に2つの方法を示すが,標準入出力・エラー出力を同時に利用する 外部プログラムの例として以下のシェルスクリプトを利用する。

ioe.sh

#!/bin/sh

echo -n "何ヶ月分?: " 1>&2
read n
if [ "$n" -le 0 ]; then		# 数字以外があるとコケる。要エラーチェック。
  echo "無効な指定です。1以上の数を指定してください。" 1>&2
  exit 1
fi
m=`date +%m`
y=`date +%Y`
while [ "$n" -gt 0 ]; do
  if [ $m -gt 12 ]; then
    m=1; y=`expr $y + 1`	# exprコマンドで変数に1を足す
  fi
  cal $m $y
  m=`expr $m + 1`
  n=`expr $n - 1`
done

このスクリプトは,対話的に操作して指定した月数分のカレンダーを cal コマンドに出力させるものである。入力案内のメッセージは 標準エラー出力に書き出し,値の入力は標準入力から行ない, 結果を(calコマンドが)標準出力に書き出している。 実際に実行した例を示す。

./ioe.sh    # 標準エラー出力は色を変えて示す
何ヶ月分?: 3
   February 2014      
 S  M Tu  W Th  F  S  
                   1
 2  3  4  5  6  7  8
 9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28

     March 2014       
 S  M Tu  W Th  F  S  
                   1
 2  3  4  5  6  7  8
 9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
     April 2014       
 S  M Tu  W Th  F  S  
       1  2  3  4  5
 6  7  8  9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30

Open3の利用

Open3を利用すると子プロセスの標準出力,標準エラー出力,標準入力 と接続したファイル記述子を生成して,それらに対して入出力を 行なうことが可能となる。Open3は,Open3::popen3 メソッドに起動するコマンド 行と処理ブロックを渡す。ブロックでは標準入力,標準出力, 標準エラー出力のファイルハンドルを引数として受けて処理を進める。

require 'open3'
Open::popen3("コマンド") do |in, out, err|
  〜〜 対話入力処理 〜〜
end

これを用いて ioe.sh を子プロセスで実行するプログラムを以下に示す。

ioe-open3.rb

#!/usr/bin/env ruby
# -*- coding: utf-8 -*-
require 'open3'
prog = "./ioe.sh"

STDERR.print "指定した月数分だけカレンダーを出力します。
何ヶ月分出しますか: "
n = gets.to_i                   # 英字だけなどは0になる。
Open3.popen3(prog) do |i, o, e|
  Thread.new {                  # 子プロセスの標準入力
    i.puts n.to_s               # つまり自分(親)からの出力
    i.close
  }
  Thread.new {
    while line=e.gets		# 子プロセスの標準エラー出力
      # ここでは特に処理しない
    end
  }
  while line=o.gets             # 子プロセスの標準出力
    print line
  end
end

pipe+fork+execの利用

親プロセスと子プロセスの間の入出力を直接作成して制御する 流れを示す。Ruby以外の言語で,C由来のものはほぼこれと同じ 手順で子プロセスとの双方向通信ができるので覚えておくとよい。

パイプは,プロセス間で通信を行なうための機構で Ruby では IO.pipe で作成する。IO.pipe を呼ぶと,2つのファイル記述子を要素に持つ配列が返される。 最初の(第0)要素は読み込み用(入力端), 次の(第1)要素は書き込み用(出力端)に利用する。 出力端に書き出したデータは,同じものが同じ順で入力端から読み出せる。 簡単な例で効果を示す。

pipe.rb

#!/usr/bin/env ruby
# -*- coding: utf-8 -*-

repeat = (ARGV[0] || 5).to_i	# ./pipe.rb 10 とすると10回,省略時5回
io = IO.pipe
io[1].puts "あいうえお "*repeat	# 文字列を指定した回数繰り返したものを書き出す
io[1].flush
print io[0].gets

image of pipe

この例では,パイプに対してデータを書き込んで, すぐにそのパイプから読み込んでいる。まったく同じものが読み取れる。 flush は,書き出したデータがバッファに溜められている 場合に強制的に書き出すメソッドである。 パイプは,forkした先のプロセスでも共有され,通常は他のプロセスとの データの授受に利用する。標準入出力,標準エラー出力を介して 子プロセスとのやりとりを行なうプログラムの流れは以下のようになる。

  1. 標準入力用,標準出力用,標準エラー出力用の3つのパイプを作成する。
  2. 標準出力をフラッシュする(1と順不同)
  3. forkする

既存のファイルハンドラを置き換えるには reopen メソッドを用いる。 このような流れをプログラム化したのが ioe-pfe.rb である。

#!/usr/bin/env ruby
# -*- coding: utf-8 -*-
prog = "./ioe.sh"

STDERR.print "指定した月数分だけカレンダーを出力します。
何ヶ月分出しますか: "
n = gets.to_i                   # 英字だけなどは0になる。

i = IO.pipe                     # 子プロセスの標準入力とのパイプ
o = IO.pipe                     # 子プロセスの標準出力とのパイプ
e = IO.pipe                     # 子プロセスの標準エラー出力とのパイプ
STDOUT.flush

if pid=fork
  # ここは親プロセス
  o[1].close                    # (あ)パイプその2出力端
  e[1].close                    # (あ)パイプその3出力端
  i[1].puts n.to_s		# (い)
  i[1].close                    # 書き出しが終了したのでクローズ
  i[0].close                    # 以下,使わないのでクローズ
  while line=o[0].gets		# (う)ioe.shからの出力を読み取る
    print ":"+line		# コロンを付けて出力
  end
else
  # ここは子プロセス
  STDIN.reopen(i[0])            # (イ)STDINの置き換え
  STDOUT.reopen(o[1])           # (ロ)STDOUTの置き換え
  STDERR.reopen(e[1])           # (ハ)STDERRの置き換え
  i[1].close			# (ニ)パイプその1の出力端
  o[0].close			# (ニ)パイプその2の入力端
  e[0].close			# (ニ)パイプその3の入力端
  exec(prog)
end

このプログラムでは,独自の入力プロンプト文字列を出して 値を入力し,それを起動した子プロセスの標準入力に流し込み, 最後に子プロセスの標準出力を読み取り,それを加工した結果を 最終出力している。その実行例を示す。

./ioe-pfe.rb
指定した月数分だけカレンダーを出力します。
何ヶ月分出しますか: 2
:   February 2014      
: S  M Tu  W Th  F  S  
:                   1
: 2  3  4  5  6  7  8
: 9 10 11 12 13 14 15
:16 17 18 19 20 21 22
:23 24 25 26 27 28
:
:     March 2014       
: S  M Tu  W Th  F  S  
:                   1
: 2  3  4  5  6  7  8
: 9 10 11 12 13 14 15
:16 17 18 19 20 21 22
:23 24 25 26 27 28 29
:30 31

子プロセス制御プログラム

フロントエンドプロセッサ

対話的プログラムを作る場合,ユーザインタフェースを複数用意しておけば, それだけ多くの人の要望に応えられる可能性が上がる。そのためには,実際に ユーザとの対話を行なう部分と,その指示で計算機資源を制御する部分を 独立したものとして作成する。制御部分は(最低)1つ作成しておき, ユーザインタフェースは簡素なものにしておく。そのまま利用させてもよいが, 対話機能を受け持つ別のプログラムから制御プログラムを操作するように設計すると 構造を単純化でき,見通しのよいシステムとなる。

たとえば,mpg123 は, MP3ファイルを音楽信号にデコードし,音源デバイスに送り込む 音楽再生プログラムであるが,このプログラム自身はコマンドラインに渡された ファイルを順次再生する機能だけを有する。いっぽう, 複数のファイルを表示してユーザに再生したいファイルを選択させたりなどの きめこまやかな対話処理を行なうプログラムを作り,そこでmpg123プログラムを 子プロセスとして利用することで, 利用者からは単一の音楽再生ソフトウェアに見えるものが完成する。

このように,実処理は裏で呼ぶプログラムに任せ,ユーザとの対話処理に 特化したプログラムのことをフロントエンドプロセッサ という(逆に裏で実処理を行なうものをバックエンドプロセッサという)。

mpg123プログラムは,他のプログラムの子プロセスとして利用しやすいよう 設計されている。mpg123プログラムを起動すると,標準エラー出力に現在 再生中の曲の総フレーム数,再生中フレーム番号が出される。また mpg123 プロセスにINTシグナルを送ると再生中の曲を停めて次の曲の再生に移る。

mpg123プログラム出力の解析

ここで、子プロセスとして起動し制御する対象とするmpg123プログラムが 標準エラー出力に吐き出す情報を簡単に紹介しておく。実際にmpg123プログラム を起動できる場合はそれも見た方がよい。

以下の出力例は、mpg123プログラムに2つのMP3ファイルを渡し再生させ, 1曲目の途中で C-c をタイプしてINTシグナルを送ったときの ものである。

mpg123 -v music1.mp3 music2.mp3
High Performance MPEG 1.0/2.0/2.5 Audio Player for Layers 1, 2 and 3
        version 1.13.1; written and copyright by Michael Hipp and others
        free software (LGPL/GPL) without any warranty but with best wishes
Decoder: SSE

Playing MPEG stream 1 of 2: music1.mp3 ...
MPEG 1.0, Layer: III, Freq: 44100, mode: Joint-Stereo, modext: 2, BPF : 365
Channels: 2, copyright: No, original: Yes, CRC: No, emphasis: 0.
Bitrate: 112 kbit/s Extension value: 0
Frame#    74 [ 6579], Time: 00:01.93 [02:51.85], RVA:   off, Vol: 100(100)^C
[0:01] Decoding of music1.mp3 finished.

Playing MPEG stream 2 of 2: music2.mp3 ...
MPEG 1.0, Layer: III, Freq: 44100, mode: Joint-Stereo, modext: 2, BPF : 365
Channels: 2, copyright: No, original: Yes, CRC: No, emphasis: 0.
Bitrate: 112 kbit/s Extension value: 0
Frame#    53 [ 7493], Time: 00:01.38 [03:15.73], RVA:   off, Vol: 100(100)^C
[0:01] Decoding of music2.mp3 finished.

3段落に分かれている出力のうち,1つ目の段落はコピーライト等の表示, 2つ目の段落は1曲目(music1.mp3)の再生, 3つ目の段落は2曲目(music2.mp3)の再生のときのものである。 着目すべきは下線で示した部分でこれらは,これから再生する音楽ファイル名と, 再生中のフレーム番号を示している。フレーム番号は再生が進むにつれて 数が随時増えていく。mpg123プログラムを子プロセスとして起動したプログラムでは, これらの情報をパターンマッチで検出し,その後の処理に利用していくことになる。

mpg123制御プログラム

パイプを利用して子プロセスの標準エラー出力を読み取り, 標準入力から得たユーザの司令を子プロセスにシグナル送信で伝える プログラムの例を示す。

mpg123.rb

#!/usr/bin/env ruby
# -*- coding: utf-8 -*-

if !ARGV[0]
  STDERR.puts "再生したいファイルを指定してください。\n用法:"
  STDERR.puts "\t#{$0} "
  exit 1
end
frame = nil                     # 生成中フレーム# の保存用
e = IO.pipe                     # mpg123からは標準エラー出力のみ読み取る

pid = fork do                   # fork&exec を行なう。PIDを記憶。
  STDERR.printf("ちわ,子です。%s を演奏します。", ARGV.join(" "))
  STDERR.reopen(e[1])
  e[0].close                    # 入力端は使わないので閉じる
  exec "mpg123", "-v", *ARGV    # *ARGVで配列を展開して渡す
end
# ここから親の処理
e[1].close                      # 親の出力端は使わないので閉じる

def prompt(file)
  STDERR.printf("\n[%s]再生中 - コマンド(nで次の曲,qで終了): ", file)
end

Thread.new do                   # 子プロセス(mpg123)と通信するスレッド
  while not e[0].closed?
    line = ""
    while l = e[0].getc         # 1字ずつ読み、lineに足していく。
      if l != "\r"[0] && l != "\n"[0]   # CRかLF以外なら
        line << l.chr           # lineに追加
      else                      # CRかLFが来たら行(line)の完成
        break                   # ループを抜けてパターンマッチに移る
      end
    end
    if /Frame\#\s*(\d+)/n =~ line
      STDERR.printf "%s ", $1 if $DEBUG
      frame = $1.to_i
    elsif /stream .* (.+) \.\.\./n =~ line
      prompt($1)          # 再生ファイル名パターンが来たらプロンプト出力
    end
  end
end

Thread.new do			# プロセスの終了を監視するスレッド
  Process.wait                  # mpg123終了まで待機
  printf("Stopping at frame %s\n", frame)
  exit 0                        # mpg123が終了したらこのプログラムも終わる。
end

while true
  a = STDIN.gets                # promptが出ているはず
  if a.nil? || /q/ =~ a
    Process.kill(:INT, pid)     # mpg123コマンドに
    Process.kill(:INT, pid)     # 2連続でINTシグナルを送ると終了
    break
  elsif /n/ =~ a
    Process.kill(:INT, pid)     # 1回INTシグナル送信で次の曲へ
  end
end

本日の目次

yuuji@e.koeki-u.ac.jp