TL;DR
お試しで実行するだけでも外部のリソースへ不具合に繋がる変化をもたらしかねないシェルスクリプトの理解や保守は地味に神経と時間を使う作業だったりします。そんなときに便利なのが、お手軽に dry-run を可能にする関数です。これを使ってみたという小ネタです。
背景
プロジェクトチームに途中から合流して、既に開発中のコードベースを理解しながら動かしたり、直したり、拡張したりしないといけなくなることがしばしばあります。そんなときにハマりがちなのがプロジェクトチームによって自作された Bash スクリプトだったりします。
マニュアルもヘルプもなく、スクリプト自体がドキュメントだ的なノリで作られたシェルスクリプトは、ときに数百行もあればハマれます。スクリプトを読めば大体の流れは理解できますが、結局うまく動かないのはコマンドに渡している引数とかシェル変数の値が想定とは違うことが原因なので、地道にトレースするしかありません。
ただ、いつでも対象の Bash スクリプトで set +x して実行して、変数の値を追いかけられるかというとそうではありません。
- 誤動作でファイルやその中身のデータを容易に破壊できる
- データに型がなく、変数はあちこちで上書きできる(何でも文字列)
- ファイル操作をよくする(だってシェルだもの)
- 実行時間は外部コマンド・スクリプトに依存する
- 大きなデータ処理を含んでいたり
- 何かをビルドしていたり
そんなときに冒頭で紹介した run() 関数を使うと簡単に Bash スクリプトを dry-run できます。ありがたや。
function run() {
if [ "${DRY_RUN:-}" == "" ]; then
"$@"
else
echo -n "[DRY RUN]"
printf ' "%s"' "$@"
echo
fi
}
:-が秀逸。
dry-run 用関数の便利さ
run() 関数の仕組みは元ネタの記事で説明されているので、ここでは積極的に使ってみたときに感じた便利さを紹介します。積極的に使うので、スクリプト内の全てのコマンドに付けることが前提です。
読みやすい
if [ -n "$DEBUG" ]; then とかでデバッグ用のコードを埋め込まずに済むのでスクリプトの読みやすさを維持できます。
ツボが分かる
dry-run なので当然何の変化も起こしません。なので、File not found 的なエラーとかが出てきます。これによってスクリプトのどこでどのファイルが必要とされているかが分かります。そして、そのファイルはそのエラーよりも前段で生成される(はず・べき)であることが分かります。
使い回せる
dry_run.sh とかで run() 関数を定義しておけば、使いたい Bash スクリプトの冒頭で source dry_run.sh とするだけで使えます。
#!/bin/bash
source dry_run.sh
run func "$FOO" "$BAR"
取り込める
実行時のオーバーヘッドも小さいので邪魔になりません。むしろ、dry-run 機能としてそのままスクリプトに取り込めます。元ネタ記事では実行時に DRYRUN=1 foo.sh という風に dry-run のフラグをセットする方法を紹介していますが、スクリプトのオプションにする手もあります。
while getopts 'd' flag; do
case "$flag" in
d)
DRY_RUN="enabled";;
esac
done
shift $((OPTIND - 1))
$ foo.sh -d
正味を測れる
grep とかで run の行を抜き出すと、dry-run で実行しなくてもそのスクリプトでやっている正味の処理やその量(行数)が分かります。スクリプトの行数とかとも一緒に眺めると臭いを察知できるかもしれないです。
$ grep run foo.sh
$ grep run foo.sh | wc -l
きれいになった
なんだかんだで、スクリプトを読み解きながらリファクタリングしてきれいになりました。読み解きやすくなり、リファクタリング作業で壊しにくくなりました。
使用上の注意
銀の弾丸ってのはない訳で。
終了コード
通常実行時は対象のコマンドの終了コードがそのまま得られます(ここまでは便利さ)。しかし、dry-run 時は対象のコマンドの終了コードを変えてしまいます。スクリプト内に終了コードを用いた制御構造がある場合は挙動が変わります。
#!/bin/bash
exit 100
#!/bin/bash
set -u
source dry_run.sh
while getopts 'd' flag; do
case "$flag" in
d)
DRY_RUN="enabled";;
esac
done
shift $((OPTIND - 1))
run ./exit_100.sh
echo $?
$ ./main.sh
100
$ ./main.sh -d
[DRY RUN] "./exit_100.sh"
0
環境変数つきコマンド
これはイカンです。
run BAR=bar foo.sh
こうする。
BAR=bar run foo.sh
どうしても run が行頭に来てほしいときはこう。
BAR=bar \
run foo.sh
&& と || と &
これは無理。
run echo foo && echo bar &
[DRY RUN] "echo" "foo"
bar
こうすると、まぁなんとか。
run echo foo && run echo bar &
[DRY RUN] "echo" "foo"
[DRY RUN] "echo" "bar"
これはもう本当に無理。
run false || echo bar &
[DRY RUN] "false"
上述の通り、dry-run によって終了コードを 0 にしてしまうので || の右辺には絶対に行かない。
run false || run echo bar &
[DRY RUN] "false"
run() も関数なので、それよりも上の存在には勝てません。
さいごに
たまたま背景のような状況になったので、関数を用いた dry-run の仕組みの使い勝手を調べるついでに書きました。完璧ではないですがお手軽で効果は高いので、多くのシーンでこいつに助けられそうです。