Hatena::ブログ(Diary)

(ひ)メモ このページをアンテナに追加 RSSフィード

2013-08-14 (Wed)

シェルスクリプトでハマった件→【募】ステキな回避方法

追記: 解答編を下の方に書きました!!!

追記: お題に不備があったので変更しました><

あとgistに一式置いたので簡単にテストできます!!

git clone https://gist.github.com/6232206.git oreno
cd oreno
./test.sh source_me.sh
...


シェルは GNU bash, version 4.1.5(1)-release です。

こんな source_me.sh と、

case $PATH in
  */oreno/bin*)
    : # do nothing
    ;;
  *)
    . orenorc
    LOADED_ORENO=1; export LOADED_ORENO
    ;;
esac

orenorc ってファイルがあって、

ORENO_HOME="/oreno"; export ORENO_HOME
PATH=$PATH:$ORENO_HOME/bin
 
# 無視してもいい警告
echo "trivial warning" >&2
 
# やばいエラー!
echo "critical error"  >&2

sourceするとこんな感じになる。

$ PATH=/bin:/usr/bin; . source_me.sh; echo $PATH
trivial warning
critical error
/oreno/bin:/bin:/usr/bin

で、critical errorは出力したいけど、trivial warningは無視したいので画面に出したくないとします。(orenorcは深淵な理由でいじれないとします)

ぱっと思いつくのは

case $PATH in
  */oreno/bin*)
    : # do nothing
    ;;
  *)
    . orenorc
    LOADED_ORENO=1; export LOADED_ORENO
    ;;
esac 2>&1 | grep -v trivial

とか

case $PATH in
  */oreno/bin*)
    : # do nothing
    ;;
  *)
    . orenorc  2>&1 | grep -v trivial
    LOADED_ORENO=1; export LOADED_ORENO
    ;;
esac

とかだと思うんですが、これを実行すると、

$ PATH=/bin:/usr/bin; . source_me.sh; echo $PATH
critical error
/bin:/usr/bin

こうなっちゃって、trivial warning は表示されないんだけどPATHがセットされない。


原因は、シェルスクリプト書いてて毎年1回は踏むワナなのでわりとすぐに気がついたんだけど、回避方法できるのにちょっと時間がかかった上にスマートじゃない気がするのでステキな回避法を思いついたかたは教えてください!!!!


解答編

原因は?

原因は、外部コマンドが絡むとサブシェルで実行されるからです。※コメント欄のfumiyasuさんの書き込みもあわせて参照してください。

bashの場合、

  • BASHPID
    • 現在の bash のプロセス ID に展開されます。 bash を再初期化しないサブシェルのような、いくつかの環境においては、 $$ と値が異なります。
  • BASH_SUBSHELL
    • サブシェルやサブシェル環境が作成されるたびに 1 ずつ増えます。 初期値は 0 です。
Man page of BASH

で観測できるのでやってみましょう。

case文全体でgrepしているスクリプト(gistのsource_me-ng1.sh)は、

echo_pid() { echo "$@ $$ $BASHPID $BASH_SUBSHELL"; }

echo_pid 1
case $PATH in
  */oreno/bin*)
    : # do nothing
    ;;
  *)
    echo_pid 2
    . orenorc
    echo_pid 3
    LOADED_ORENO=1; export LOADED_ORENO
    ;;
esac 2>&1 | grep -v trivial
echo_pid 4

こういう結果になります。

PID=27549
1 27549 27549 0
2 27549 27604 1
o 27549 27604 1 # ←これは orenorc の中でのです
3 27549 27604 1
4 27549 27549 0

$$は変わっていませんが、caseの中ではBASHPIDとBASH_SUBSHELLが変化しています。

次に . oreno をgrepしているスクリプト(gistのsource_me-ng2.sh)では、こうなります。

PID=27649
1 27649 27649 0
2 27649 27649 0
o 27649 27704 1
3 27649 27649 0
4 27649 27649 0

source_meの世界では変わっていませんが、orenorcの世界は別プロセスで実行されているのがわかります。

さて、

いただいた回避法の紹介コーナー

いちばんステキだと思ったのは、@ さんのこれ(一部抜粋、完全版はgistのsource_me-ok-satoh_fumiyasu.sh)です。

  *)
    . orenorc 2> >(grep -v trivial >&2)
    LOADED_ORENO=1; export LOADED_ORENO
    ;;

stderrをbash(限定)のプロセス置換に渡しています。自分はこの発想はなかったです!

同じくプロセス置換を使っているのが、@さんのこれ(file-source_me-ok-sechiro-sh)です。

exec 2> >(grep -v trivial >&2)
case $PATH in
  */oreno/bin*)
    : # do nothing
    ;;
  *)
    . orenorc
    LOADED_ORENO=1; export LOADED_ORENO
    ;;
esac

同じようにstderrをプロセス置換に渡してるんですが、それをexecでやっています。

常々、execにフィルタが書ければstdout,stderr全部にタイムスタンプつけるのが簡単になるのになぁと思ってたんですが、この手法を使えばできますね。

exec  > >(tai64n | tai64nlocal)
exec 2> >(tai64n | tai64nlocal)

bashのプロセス置換については、sechiro さんがまとめてくださっているのでそちらを参照するのがいいと思います。スバラシス。


で、自分が考えたのはこんなの(source_me-ok-hirose31.sh)です。

tmpf=$(mktemp)
exec 3>&2 2>$tmpf
case $PATH in
  */oreno/bin*)
    : # do nothing
    ;;
  *)
    . orenorc
    LOADED_ORENO=1; export LOADED_ORENO
    ;;
esac
exec 2>&3 3>&-
cat $tmpf | grep -v trivial
rm -f $tmpf

あとで復元するようにfd 2をdupして3を作り、2を一時ファイルに向けます。

case文が終わったら、fd 2を復元(3をdupして2にする)して、3はもう要らないので閉じます。あとは一時ファイルをgrepして消します。

@さんも同じような手法を提案していました。


最後は@さん提案のmkfifoを使う方法です。こんなスクリプト(source_me-ok-m2ym.sh)になるかと思います。

fifo=$(mktemp -u)
mkfifo $fifo
grep -v trivial <$fifo &
exec 3>&2 2>$fifo
rm -f $fifo
case $PATH in
  */oreno/bin*)
    : # do nothing
    ;;
  *)
    . orenorc
    LOADED_ORENO=1; export LOADED_ORENO
    ;;
esac
exec 2>&3 3>&-

自分の環境では exec の前に grep しないと exec がブロックして止まっちゃったので気をつけましょう。

いずれもfd 2をファイルやFIFOに向ける手法です。@ さんはコメント欄でファイル的なものを作らない方法を提案していましたが、残念ながらうまく動きませんでした。ファイル的なものを使わない方が消し忘れがなくてよいので、うまくできる書き方があったら是非教えてください><

回答スクリプトも含め gist (https://gist.github.com/hirose31/6232206) に置きましたので興味ある方はぞうぞ!

最後に、回答くださった皆様、ありがとうございました!!


参考ページ

viverviver 2013/08/15 12:05 こんなのでいけないですかね…?

exec 3<&1 4<&1 # dup stdout to 3 and 4
grep -v ng < 4 & # grep reads from 4
exec > 4 # redirect stdout to 4
. orenorc
exec 1<&3 3<&- 4<&- # restore stdout and closes 3 and 4

sechirosechiro 2013/08/15 13:13 ブコメに書きましたが、せっかくなのでこちらにも。bashでの回答です。
exec 3>&2
exec 2> >(grep -v trivial > &2)
. orenorc
exec 2>&3

fumiyasfumiyas 2013/08/17 15:36 「原因は、外部コマンドが絡むとが入るとサブシェルで実行されるから」は違うと思います。「bash (や POSIX sh) は、コマンドパイプラインの変数値変更はそのコマンドラインローカルになるから」のようです。

$ bash -c 'foo=bar |true; echo $foo'
(何も表示されない。dash 等でも同じ)
$ bash -c 'true |foo=bar; echo $foo'
(同上)

色々やるとわかりますが、上記例はパイプの左辺も右辺も $BASH_SUBSHELL は 0 になります。よって、「サブシェルかどうか」も関係ありません。

ちなみに、ksh, zsh はパイプの最右辺の変数変更は、以後も維持されます。

$ ksh -c 'true |foo=bar; echo $foo'
bar

私はこれが好きなので ksh, zsh をお勧めしたいですね。

はてなユーザーのみコメントできます。はてなへログインもしくは新規登録をおこなってください。

トラックバック - http://d.hatena.ne.jp/hirose31/20130814/1376487935
2003 | 11 | 12 |
2004 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 10 | 11 | 12 |
2005 | 01 | 02 | 03 | 05 | 08 | 09 | 10 | 11 | 12 |
2006 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 |
2007 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 |
2008 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 |
2009 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 |
2010 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 |
2011 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 12 |
2012 | 01 | 02 | 03 | 06 | 08 | 10 | 11 | 12 |
2013 | 01 | 02 | 03 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 |
2014 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 10 |
2015 | 01 | 02 | 07 | 10 |
2016 | 01 | 05 | 10 | 12 |
2017 | 07 |
2018 | 05 |