こんにちは、羽山です。
今回は意外と知らないかもしれないbashの「プロセス置換」という機能を紹介します。

UNIXでは「1つのプログラムは1つの機能にとどめ、フィルタとして動作できるようにせよ」という設計思想があります。
その言葉の通り、大抵のコマンドはパイプでつないでフィルタのように動作可能ですが、必ずしも理想的に設計されていない、もしくは仕様上できないコマンドもあります。

例えば、よく使う diff コマンドがこれにあたります。
指定するファイル名を - とすれば片方は標準入力から読み込めますが、もう片方はどうしても引数で指定しなければいけません。

コマンドの実行結果を直接比較できたら便利なのになーっと思ったことはないでしょうか?

実はbashのプロセス置換 <(command) を利用すれば動的なコマンドの実行結果をファイルのようにコマンドに渡すことができます。

プロセス置換でコマンドの実行結果を読み込む

早速試してみましょう。

例えば以下の二つの出力結果を比較してみます。

$ for i in {1,2,3,4}; do echo $i; done
1
2
3
4
$ for i in {1,2,4,5}; do echo $i; done
1
2
4
5

プロセス置換は <(command) という形式なので、以下のようにします。

$ diff -y -W 10 <(for i in {1,2,3,4}; do echo $i; done) <(for i in {1,2,4,5}; do echo $i; done)
1       1
2       2
3   <
4       4
    >   5

うまく比較できました。

では、プロセス置換はどのように動いているのでしょう?
echo の引数に指定してみると動作原理がなんとなく理解できます。

$ echo <(seq 4)
/dev/fd/63

参考までに cat に渡すと以下のようになります。

$ cat <(seq 4)
1
2
3
4

この結果から分かるようにプログラムに渡される引数は /dev/fd/63 という形になります。
/dev/fd/proc/self/fd のシンボリックリンクなので、つまり 63 のファイルディスクリプタを開いた状態でプログラムが開始し、プロセス置換内で指定したコマンドにパイプで接続されているのだろう。ということが推測できます。

例えばプロセス置換を渡されたプログラム側は、C言語の低水準関数を使って read(63, buffer, 100); などとしたら、 open() 関数でファイルディスクリプタを取得しなくても読み込むことができます。ただし毎回 63 である保証はないですし、プロセス置換が必ず渡されるという前提もありません。そこで普通に fopen() 関数などを利用してコマンドラインの引数を開けば、渡されたのがファイルなのか、プロセス置換で bash によって作られたパイプなのかを意識せずに透過的に扱うことができます。

仕組みがなんとなく分かったところで、今度は sort コマンドと組み合わせてみます。
ソート&ユニークの結果を比較したい場合は以下のように、プロセス置換内でさらにパイプを利用します。

$ diff -y -W 10 <(for i in {3,2,4,1,5,1}; do echo $i; done |sort -u) <(for i in {2,4,3,6,1,3}; do echo $i; done |sort -u)
1       1
2       2
3       3
4       4
5   |   6

psコマンドを5秒待って2回実行して比較する場合は以下です。

$ diff -y <(ps ax) <(sleep 5; ps ax)

これはサーバーの調子が何やらおかしい・・・?でも top とかでも異常なプロセスは見つからないし・・・。という感じで漠然とした状態であたりをつける時にたまに使います。短時間のプロセスの変化を観測できるので、プロセスが起動/終了を繰り返していたり、異常動作しているプロセスを発見できることがあります。

プロセス置換&sleep&任意コマンドは短時間の変化を観測したい場合に幅広く応用可能です。

特定のディレクトリのファイルの変化を観測するには以下のようにします。

$ diff -y <(ls -al) <(sleep 5; ls -al)

watch -n 5 -d "ls -al" でも似たようなことはできますが、差分の見やすさは diff に軍配が上がるのと、 watch は情報が流れてしまいますが、プロセス置換を使った diff なら less などにつなげてゆっくりと比較できます。

curl と組み合わせて、ページの更新差分をローカルファイルとサクッと比較する場合は以下のようにします。

$ diff index.html <(curl -s http://techblog.raccoon.ne.jp/)

ただこの場合は動的コマンドの実行結果が片方だけなので、 curl の実行結果を標準入力から読み込む以下の方法でも同様の比較が可能です。

$ curl -s http://techblog.raccoon.ne.jp/ |diff -y index.html -

プロセス置換でコマンドの実行結果を書き込む

プロセス置換での読み込みは比較的認知度が高いような気がしますが、実は書き込みも可能なことは意外と知らない方も多いのではないでしょうか?

たまにありますよね、標準出力に実行結果を出したいのにファイル出力しかできないコマンド。
Linuxの標準コマンドではあまり見かけませんが、最近「あってよかったプロセス置換」となったのは wvText というワードファイルをテキストに変換するコマンドでした。

Ubuntu16.04の環境では sudo apt install wv などでインストールできますが、このコマンドが

$ wvText --help
Usage: /usr/bin/wvText <word document> <text output file>

という潔さ。

わざわざ一時ファイルを作るのはイヤだったので、書き込み方向のプロセス置換機能を使いました。

$ wvText file.doc >(cat)

書き込みのプロセス置換も読み込みと同様で、コマンド側(ここではwvText)には接続済みパイプのファイルディスクリプタのパスに変換されて渡されます。
コマンド側は特に意識せずに普通に開いて書き込むと、透過的にパイプを経由して指定されたプログラムの標準入力に渡されます。

上記の例では、wordファイルをテキスト変換したものが cat に渡されて、そのまま cat が標準出力であるtty(つまり画面)に出力します。

ファイル書き込みを標準出力に向けることができたので、さらにコマンド置換 $(command) を利用すると変数に代入することもできます。

$ content=$(wvText file.doc >(cat))

もちろんパイプでつなげることもできます。

$ wvText file.doc >(cat) |less

一時ファイルを作らなくてすむので楽ちんですね。

teeと組み合わせる

tee は標準入力から読み込んだ内容をファイルに書きながら標準出力にも同じ内容を表示してくれるコマンドです。つまりファイルへの書き込みを強制されるコマンドとも言えます。

この tee コマンドとプロセス置換を組み合わせると、いろいろな操作ができます。

ダミーデータとして、以下のコマンドで出力される1~1000の連番を利用します。

$ seq 1000

画面では普通に表示しながら、ファイル出力は gzip で圧縮したいことはありませんか?
その場合は、以下のように tee に渡すファイルをプロセス置換として、 gzip でフィルタすればOKです。

$ seq 1000 |tee >(gzip -c > gzipped_file.gz)

条件に適合した行だけ gzip で圧縮しながら保存したい場合は以下です。

$ seq 1000 |tee >(grep 3 |gzip -c > grep_3_gzipped_file.gz)

この例では、3が入るとアホになる・・・、ではなく、3が入る数値だけにフィルタしてgzip圧縮してます。

リダイレクト > とプロセス置換

リダイレクト > に指定するファイルにも、プロセス置換を利用できます。
これを使えば、リダイレクトなのにパイプと同様の動きをすることができます。

$ for i in {3,2,4,1,5,1}; do echo $i; done |sort -u
$ for i in {3,2,4,1,5,1}; do echo $i; done > >(sort -u)

上記の2つのコマンドはほぼ同じ結果になります。

> でファイルにリダイレクトしますが、そのリダイレクト先がプロセス置換になっているので、 sort -ufor .. echo の結果が渡されます。これはつまりパイプとほぼ同一の動きです。

しかし「ほぼ」と書いたのには理由があります。
リダイレクト&プロセス置換はタイミングによっては bash のプロンプトが表示されてから、1 2 3 ... と画面表示されてしまうことがあります。

実行結果を比べてみましょう。

パイプでソート

ubuntu@ubuntu-xenial:~$ for i in {3,2,4,1,5,1}; do echo $i; done |sort -u
1
2
3
4
5
ubuntu@ubuntu-xenial:~$

リダイレクト&プロセス置換でソート

ubuntu@ubuntu-xenial:~$ for i in {3,2,4,1,5,1}; do echo $i; done > >(sort -u)
ubuntu@ubuntu-xenial:~$ 1
2
3
4
5

リダイレクト&プロセス置換でソートの方は違和感のある表示になっています。

これは for .. echo プロセスが終了したタイミングで bash がプロンプトを表示してしまい、プロセス置換で起動した sort の終了を待ってくれないことが原因です。
パイプの場合は sort の終了まで待ってくれますが、プロセス置換はそういったケアをしてくれないのです。

問題を解決するにはプロセス置換のプロセスが直接画面出力するのではなく、パイプ経由で画面に出力させるようにします。

ubuntu@ubuntu-xenial:~$ for i in {3,2,4,1,5,1}; do echo $i; done > >(sort -u) |cat
1
2
3
4
5
ubuntu@ubuntu-xenial:~$

正しい表示になりました!

こう書けばプロセス置換の標準出力はパイプで cat につながり、 cat の終了までは bash がプロンプト表示を待ってくれるので問題は解消します。

結局パイプを使ってるじゃん!というツッコミが入りそうですね。
リダイレクトにもプロセス置換を使えます。というのがここでの主題なので、ご勘弁を・・・。

しかし、リダイレクトのプロセス置換って何の役に立つの?という疑問が出てくると思いますので、いくつか便利なケースを紹介します。

標準エラー出力にパイプをつなぎたい

普通にパイプを指定すると、標準出力がパイプに接続されます。
そこで、標準エラー出力もパイプにつなぎたい場合は以下のようにします。

$ command1 2>&1 |command2

これで標準出力と標準エラー出力を両方ともをパイプに接続できます。

では標準エラー出力だけをパイプにつなぎたいなら?

$ command1 2>&1 > /dev/null |command2

とすれば望むことはできます。
なぜこれで標準エラー出力だけパイプにつながるのかというと、パイプとリダイレクトが出てくる場合、まずはパイプから解釈します。この場合、 標準出力=command2へのパイプ となります。次にリダイレクトの左側から解釈します。標準エラー出力に標準出力をコピーしているので、 標準出力=標準エラー出力=command2へのパイプ となります。このままだと標準出力もパイプにつながったままですが、次に標準出力を /dev/null にリダイレクトしています。結果として 標準エラー出力=command2へのパイプ だけが残ることになります。

しかし書き方が直感的ではないですし、標準出力は捨ててしまっています。

そんな場合は以下のようにプロセス置換を利用すればスマートに処理できます。

$ command1 2> >(command2)

これなら標準出力はまた別の処理をすることも可能です。

execと組み合わせて使う

画面の出力内容をログに残したいことってありませんか?
ありますよね?

あ、えと。 script コマンドのことはいったん忘れてください。

はい、プロセス置換でできます。

$ exec > >(tee console.log)

exec コマンドで bash プロセス自体の出力をプロセス置換にしてしまいます。
これで標準出力が tee コマンドでフィルタされるようになり、すべてロギングされます。

実行後に bash プロセスのファイルディスクリプタを確認すると、1=標準出力が以下のように pipe:[25968] となっていることを確認できました。

ubuntu@ubuntu-xenial:~$ ls -al /dev/fd/
total 0
dr-x------ 2 ubuntu ubuntu  0 Jun 27 14:05 .
dr-xr-xr-x 9 ubuntu ubuntu  0 Jun 27 14:05 ..
lrwx------ 1 ubuntu ubuntu 64 Jun 27 14:05 0 -> /dev/pts/0
l-wx------ 1 ubuntu ubuntu 64 Jun 27 14:05 1 -> pipe:[25968]
l-wx------ 1 ubuntu ubuntu 64 Jun 27 14:05 2 -> /dev/pts/0
lr-x------ 1 ubuntu ubuntu 64 Jun 27 14:05 3 -> /proc/4167/fd

上記の結果は正確には ls プロセスのファイルディスクリプタですが、標準出力は親プロセスのコピーなので bash プロセスと同一です。

ロギングを停止するには以下のように tty につなぎ直します。

$ exec > $(tty)

ただファイルに記録するだけでなく、ついでに行番号を付与して記録したいなら以下のようにします。

$ exec > >(tee >(nl > console.log))

bash プロセスの標準出力をプロセス置換で tee に流しつつ、さらに tee が書き込むファイルをプロセス置換で nl コマンド経由で書き込んでいます。

ただしこの方法は汎用的に利用可能ではあるものの、標準出力が tty ではなくなるので一部のコマンドは正しく動作しなくなります。
例えばロギングした状態で vim を起動すると標準出力がターミナルでないという警告が出ます。
ls などのように標準出力が tty かどうかで色をつけるかどうか処理分けしているようなコマンドは、標準出力がパイプにつながっていると判断するので色がつかなくなります。ただログファイルに制御文字が入らないという意味ではメリットでもあります。

そして、これを言ってしまうと元も子もありませんが、 script コマンドでも同じことをできる上に、副作用が少なかったりします。

ただし、 script コマンドだけでは、出力結果を加工をできないので、そこでもプロセス置換を利用してみましょう。

前述の例と同様に行番号を付与して記録したいなら以下のようにします。

$ script >(nl > console.log)

script コマンドなら Ctrl-D で記録を終了できるので、その点でも楽です。

execでファイルディスクリプタの操作

exec の便利な活用方法を紹介したので、次はさらに掘り下げてファイルディスクリプタの操作をしてみます。
execbash プロセス自体の操作ができるので、あらかじめパイプをつないでおいて、任意のタイミングで操作することできます。

まずは連番を読み込んでみましょう。

$ exec {fd}< <(seq 1000)

{fd}< という指定方法は見慣れない方もいるかもしれません。
空いているファイルディスクリプタが分かっているなら、固定で 10< などのように記述してもいいのですが、固定の数値だとすでに使われている可能性があります。
そこで、 {fd}< とすると自動的に空いているファイルディスクリプタを割り当てて、 $fd にその数値を入れてくれます。

echo $fd で割り当たったファイルディスクリプタを確認できます。

さて、あとは好きなタイミングで以下のコマンドを実行します。

$ read i <&${fd}

すると、実行するたびに $i に連番が入ります。
read コマンドは標準入力から1行読み込んで、指定した変数に値を入れてくれますが、 <&${fd} とすることで、 ${fd} のファイルディスクリプタを標準入力にコピーします。
つまり先ほど exec で開いた seq 1000 から read コマンドが1行読み込んでくれます。

試しに3回実行してみたら以下のようになりました。

$ read i <&${fd}
$ echo $i
1
$ read i <&${fd}
$ echo $i
2
$ read i <&${fd}
$ echo $i
3

exec で開いたファイルディスクリプタは bash プロセスが終了するタイミングで自動的にクローズされますが、逆に言うと bash プロセスが生存している間は生き続けてしまいます。利用しなくなったら明示的にクローズしましょう。

# {fd}をクローズ
$ exec {fd}>&-

次は書き込みで使ってみましょう。
タイムスタンプを自動で付与しながらロギングしたいとします。以下のように perl へのパイプを開いておきます。

$ exec {fd}> >(perl -MPOSIX -nle 'print strftime("%Y/%m/%d %H:%M:%S: ", localtime(time)) . $_;' > logging.log)

{fd}> で書き込みのファイルディスクリプタを開いています。
その先はプロセス置換になっていて、 perl のワンライナーに渡します。
そして、 perl の処理内でタイムスタンプを付与して print して、最終的に > logging.log にリダイレクトしています。

あとは好きなタイミングで

$ echo aaaa >&${fd}
$ echo bbbb >&${fd}

などのように書き込みます。
もう解説は必要ないと思いますが、 ${fd} のファイルディスクリプタを標準出力にコピーしているので、つまり echo の出力先が先ほど開いた perl の標準入力につながります。

一通りロギングが終了したら以下のようにクローズします。

$ exec {fd}>&-

では、 logging.log の中身を見てみましょう。

$ cat logging.log
2018/06/29 09:36:53: aaaa
2018/06/29 09:36:55: bbbb

想定通り、タイムスタンプ付きでロギングされました。

まとめ

今回はひたすらプロセス置換の話でした。
bash は様々な機能を持ってるので、意外と知らない機能がもりもり出てきます。
では、皆さんもはっぴーLinuxライフを!