Bash は言わずと知れた歴史あるコマンド言語です。テキストにコマンドの羅列を記述するだけで、手軽にシェルスクリプトとして実行することができます。
シェルスクリプトの実体はシェルコマンドの羅列に過ぎませんが、手続き型プログラミング言語にある基本的な制御構文も備えています。変数や条件分岐、ループ、関数などです。これらを使えばシェルスクリプトでプログラミングも可能です。
もちろん、現代の一般的なプログラミング言語と比べると機能は限られます。他言語には見られないシェルスクリプト特有の癖や記法も多くあり、最近の言語に慣れている人ほど、つまずくポイントが多いです。
しかし、シェルスクリプトだからこその良さもあります。Bash は現在でも macOS や多くの Linux ディストリビューションで標準シェルとして採用されており、普段使っているシェルコマンドを書くだけで動かせる圧倒的手軽さは魅力的です。一度身につけておくと長く使えるお得な技術です🙆
また、書き方のスタイルを工夫することで、書き味を一般的な高級言語に近づけることもできます。具体的にはクラスを使わずに関数でプログラミングした Python や PHP などと近いレベルまではいけます。
この記事ではシェルスクリプトで堅牢かつ見通しのいいプログラムを書くための Tips を紹介していきます。
※ この記事はシェルスクリプトで頑張ることを推奨するものではありません。頑張らずに書くための工夫を紹介する記事です。作ろうとしているものが大規模になることが想定される場合は始めから Python など他の言語で書くことを検討したほうがいいでしょう。
※ 記事中には Bash の built-in 機能が多分に登場します。POSIX 互換のシェルスクリプトを目指す方にはこの記事の内容は適さないことがあります。
追記1: 予想以上に「高級言語」という言葉に対しての反応が多く、釣りタイトルっぽくなってしまったことを反省しています。シェルが人間向きに作られたインターフェースということを考えるとシェルスクリプトも十分高級言語と言えると思います。
追記2: Lint ツール ShellCheck についての記述を追加しました。その他、文言などを見直しました。
Lint ツールを使う
まず最初に ShellCheck などの Lint ツールを入れることをおすすめします。
静的解析によりシェルスクリプトの問題点を分析し、より良い書き方を提案してくれます。様々な罠があることで知られるシェルスクリプトですが、ツールに指摘された問題点を直していくだけでベストプラクティスとされる書き方を反映でき、安心してスクリプトを書くことができるようになります。
詳しくは以下の記事にまとめましたので、気になる人は参照してみてください。
便利なシェルオプションを設定する
set
コマンドで設定できるシェルオプションには、取りあえず設定しとけレベルで便利なオプションがいくつかあります。
安全なスクリプトを書くという意味でも適切に設定し、その意味を知っておいたほうがいいです。私はシェルスクリプトの書き出しは大体以下のようにしています。
#!/usr/bin/env bash set -Ceuo pipefail
set -Ceuo pipefail
とすることで、以下の4つのシェルオプションがまとめて設定されます。
set -C
:
- リダイレクトによるファイル上書き事故を予防します。
- 存在するファイルを
>
で上書きすることができなくなります。 - 代わりに
>|
を使えば上書きができるようになります。
set -e
:
- スクリプトの実行中にエラーが発生すると、そこでスクリプトが終了するようになります。
- ここで言う「エラー」とは実行したコマンドの終了ステータス(exit code)に非0の値が返ることを指します。
- コマンド実行の度にエラーハンドリングを書くことを省略 横着 できるようになります。
- これを設定しておかないと途中でエラーが起きてもそのままスクリプトが突き進んでいくため、怖いです。
set -u
:
- 未定義の変数を使用すると、そこでスクリプトが終了するようになります。
- うっかり変数名を typo したときに気づきやすくなります。
set -o pipefail
:
- パイプの途中でエラーが起きた場合もエラーが発生するようになります。
- 具体的には非0で終了した最後に実行された(右側にある)コマンドがパイプライン全体の終了ステータスになるようになります。
- 上述の
set -e
と組み合わせて使うとスクリプトも終了するようになるので便利です。
また、シェルオプションは以下のように一行目のシバンで設定することも可能です。
#!/usr/bin/env bash -Ceuo pipefail
この方がシンプルで一見良さそうに見えますが、この方法で指定したオプションはスクリプトに実行権限を付け直接実行しないと適用されません🙅
例えばデバッグの為に一時的に -x
オプションを付けて実行したいからといって bash -x ./hoge.sh
などと実行すると -x
は適用されますが -Ceuo pipefail
に関しては 適用されない ということです。確実に設定したい場合はシバンの直後あたりで set
コマンドで設定する方がいいでしょう。
Bash のパワフルな判定式を活用する
シェルスクリプトでまず難関となりがちなのが条件判定の書き方です。括弧の書き方による違いだったり、謎のオプションによる判定方法が色々あって訳わからん!となります。
しかし Bash には [[ ]]
や (( ))
といった判定式を高級言語っぽくスマートに書ける強力な拡張機能が備わっています。これらを活用すれば判定式を書くのはそんなに難しくないです🙆
まず Bash 固有の機能を使わずプレーンに if 文を書こうとすると以下のようになります。不等号を使った判定に慣れていると読みにくいと思います。
if [ $x -gt 2 ] && [ $x -le 5 ]; then echo "$x は 2 より大きいかつ 5 以下です" fi
Bash 固有の機能 (( ))
を使うと、数値の比較演算子として不等号が使えるようになります🙆
# この括弧の中は例外的に $ を付けなくても変数参照が可能 if (( x > 2 && x <= 5 )); then echo "$x は 2 より大きいかつ 5 以下です" fi
元々シェルスクリプトにはデータ型の概念がなく、全ての変数は文字列です1。数値と呼ばれるものも数値のみで構成される文字列に過ぎません。
(( ))
の括弧の中では文字列変数を数値とみなし、様々な算術演算をすることができます。数値の比較以外にも基本的な四則演算 (+
, -
, *
, /
)、インクリメント (++
)、デクリメント (--
) などの演算子が使えるようになります。
また、文字列比較には [[ ]]
が便利です。 この括弧の中では様々な言語でおなじみの等価比較演算子 ==
の他に、正規表現マッチによる比較演算子 =~
も使えます🙆
x='ABC' # 完全一致 if [[ $x == 'ABC' ]]; then echo '$x は ABC です' fi # 正規表現による先頭一致 if [[ $x =~ ^ABC ]]; then echo '$x は ABC から始まる文字列です' fi # 正規表現による部分一致 if [[ $x =~ B ]]; then echo '$x は B が含まれる文字列です' fi
他に [ ]
というのがありますが、上述の [[ ]]
が [ ]
を拡張して用意された機能なので、Bash の世界線にいるうちは多機能な [[ ]]
の方を使うといいでしょう。(参考: test と [ と [[ コマンドの違い – 拡張 POSIX シェルスクリプト Advent Calendar 2013 – ダメ出し Blog)
また、コマンドの終了ステータスが 0 か否かを判定したいだけであれば括弧なしで判定できます。
# grep は検索結果が見つかったときだけ 0 を返すコマンド # --quiet は標準出力に何も書き出さないオプション if cat hoge.txt | grep --quiet "Apple"; then echo "hoge.txtにはAppleが含まれた行がある" fi # 条件の反転は ! をつける if ! cat hoge.txt | grep --quiet "Apple"; then echo "hoge.txtにはAppleが含まれた行がない" fi
判定方法は色々とありますが、それぞれに向き不向きがあります。大雑把にまとめると以下のような使い分けになるでしょう。
- 数値判定するとき:
(( ))
- 文字列判定するとき:
[[ ]]
- 終了ステータスを判定するとき: 括弧不要
行が長くなったら適度に改行する
シェルスクリプトはその性質上、一行がとても長くなりやすいです。コマンドラインオプションやパイプラインを駆使して複雑な処理していくことになるからです。
しかし、改行はそこまでに書かれているコマンドを実行するための文法上の目印でもあるので、どこでも自由にできるわけではありません。いくつかのルールに則ればコマンドの途中でも改行することができます。
コマンドラインオプションの途中で改行する
AWS CLI などがその最たる例ですが、とにかくコマンドラインオプションが長くなりがちです。こんな感じです🙅
aws --region ap-northeast-1 cloudformation deploy --template-file ./packaged-template.yaml --stack-name example-stack --capabilities CAPABILITY_IAM --parameter-overrides Environment=development EnableDebugLog=true
長くなったら \
(バックスラッシュ) を行末に書くとコマンドの途中でも自由に改行することができます🙆
aws --region ap-northeast-1 cloudformation deploy \ --template-file ./packaged-template.yaml \ --stack-name example-stack \ --capabilities CAPABILITY_IAM \ --parameter-overrides \ Environment=development \ EnableDebugLog=true
パイプラインの途中で改行する
シェルスクリプトには \
(バックスラッシュ) を書かなくても、行末に書くと改行できる記号がいくつかあります。その一つとして |
(パイプ) があります。
パイプラインは他の人が見たときに呪文のように見えてしまいがちですが、改行することによりパイプの途中でコメントを書く余地も生まれます🙆
cat access.log | # IPアドレスのカラムを取得する awk '{print $5}' | # 100行目以降のみを集計対象とする tail +100 | # IPアドレスごとのアクセス数のランキングを集計する sort | uniq -c | sort -nr
他に行末に書くと改行可能な演算子として &&
, ||
, &
などがあります。
どのディレクトリからスクリプトが起動されても動くようにしておく
シェルスクリプトはすべてカレントディレクトリを基準にコマンドが処理されていきます。そのため今どこのフォルダにいるのか常に意識することになります。
しかし、シェルスクリプトは起動元のカレントディレクトリが引き継がれた状態で開始されます。そのため相対パスで処理するような処理があった場合、起動元のディレクトリ位置によって上手く動かなくなってしまいます🙅
スクリプトの序盤で決まったディレクトリに移動するようにしておけばこの問題は回避できます🙆
以下のようにすると /path/to/hoge.sh
に置かれているスクリプトの場合 /path/to
に移動することが出来ます。
# このスクリプト自身が置かれているディレクトリに移動する cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
位置パラメータは意味のある命名の変数に取り出す
関数の引数は位置パラメータと呼ばれる連番の変数 ($1
, $2
…) から参照することが出来ますが、位置パラメータをローカル変数に取り出しておくだけで関数内のコードの可読性が向上します🙆
function do_something() { # まず最初に引数を意味のある命名の変数に取り出す local target_dir action target_dir=$1 action=$2 } do_something "./dir" "hoge"
スクリプトの後始末はハンドラーで行う
trap
を使うとスクリプト終了時に実行したいコマンドを登録しておくことができます。後始末はハンドラー関数の方に任せることでメインロジックを簡潔にできる場合があります🙆
# スクリプト終了時に呼び出される関数 function on_exit() { local exit_code=$1 # 冪等性を担保した書き方にしておくと安心です。 # (以下は一時ファイルがあっても無くても問題のない書き方) rm -f "tmp_file" exit $exit_code } ## ハンドラー関数を登録する trap 'on_exit $?' EXIT
関数内の変数をローカル変数にする
関数内で変数を定義すると デフォルトではグローバル変数として定義 されます。副作用を少なくするためにもなるべくローカル変数として定義するほうがいいでしょう。
ローカル変数として定義するには local hoge=123
のように、前に local
を付けます。しかし、代入に失敗する可能性のある場合は 安全性の観点でローカル変数の宣言と代入は分けて書くことをおすすめ します。local は一つの組み込みコマンドなので、もし代入に失敗してもコマンド全体では成功扱いになってしまうからです🙅
function fn() { # 問題のある例: local hoge=$(false) # $? で直前に実行したコマンドの終了ステータスを参照できる echo $? # => エラーが握りつぶされ 0 が返る! # 問題のない例: local hoge2 hoge2=$(false) echo $? # => 正しく 1 が返る }
グローバル変数との見分けが付きにくい場合、命名ルールによって分けるのも手です。私はグローバル変数はアッパーケース、ローカル変数はスネークケースといった命名にしています。
readonly GLOBAL_VAR="グローバル変数です" function fn() { local local_var local_var="ローカル変数です" # グローバルスコープの変数は関数内からも参照可能 echo $GLOBAL_VAR # => グローバル変数です } # 関数を実行 fn # ローカル変数は関数外からは参照できない echo $local_var # => 未定義
サブシェルを活用して部分的にスコープを切る
丸括弧 ( )
の中に書いたコマンドはサブシェル上で実行されます。サブシェル内の副作用は親シェルには影響しないという性質を利用すると部分的にスコープを切ることが出来ます。
例えば一時的にカレントディレクトリを移動して何か処理をしたいとしましょう。
# 親シェル上でカレントディレクトリを設定する cd /path/to/base # 親シェル上で適当に変数を定義しておく hoge=123 ( # サブシェル上で一時的にディレクトリを移動する cd tmp pwd # => /path/to/base/tmp # 変数を上書きする hoge=456 ) # サブシェル内の副作用は親シェルには影響しない pwd # => /path/to/base echo $hoge # => 123
無事に副作用をサブシェル内に閉じ込めることができました🙆
面白い使い方として、関数でさえもスコープの中に閉じ込めることができます。
( # サブシェル内で関数を定義 function fn() { echo 123 } # サブシェル内で関数を実行 fn # => 123 ) # 親シェルで関数を実行 fn # => 関数未定義のためエラー
また、関数の中でも同じように ( )
を書くことが出来ます。シェルスクリプトにおける関数はスコープの概念が希薄ですが、サブシェルで関数スコープを強化するような使い方もできます。
function fn() { ( # do something ) }
※ サブシェルには起動コストがあるので、そのまま呼び出すよりは多少遅くなるという点は留意してください。
メインロジックを関数化する
グローバルスコープの汚染を防ぐことができます。また、メインロジックのテストがしやすくなるといったメリットがあります。
function main() { # do something } main
高級言語の力を借りる
もはや高級言語っぽく書くという域を超えて、部分的に他の高級言語の力を借りるという手段もあります。
例があまりよくないかもしれませんが、YAML 内のオブジェクトのキー名の一覧を取り出したいときは以下のように書けます。(Array of object の形で定義されている YAML ファイルを想定)
cat hoge.yml | ruby -ryaml -e ' begin yml = YAML::load(STDIN.read) exit(10) if (!yml || yml.empty?) # 最初の要素のキー名一覧を改行区切りで出力する puts yml[0].keys.join("\n") rescue Psych::SyntaxError => e # パースエラー exit(11) rescue => e # その他エラー exit(12) end '
無論、これをすると実行環境への依存を増やすことになるため、用法用量を守って(以下略)
言語選択としては Ruby 以外では Perl などであれば大体どこの環境でも入ってると思うのでおすすめです。(参考: コマンドラインツールとしてのperl – Qiita)
コマンドなのか構文なのかを意識してみる
シェルスクリプトにはあたかも構文かのように擬態したコマンドが多数存在しています。これを知っておくと文法上の細かいルールの理解が深まります。
例えば条件判定に使用する [
は一つのコマンドです。試しに which [
としてみると /bin/[
と表示され、本当にコマンドであることが分かります。
以下の if 文は 赤文字がコマンド、緑文字がコマンド引数、その他の黒文字がシェル構文ということになります。
if [ $var -eq “abc” ]; then
# do something
fi
つまり [
コマンドの終了ステータスが 0 か否かで then 以降が実行されるかどうか決まるということです。そのため [
の前後には スペースがないと [
をコマンドとして認識できずに エラーとなってしまいます。
逆に変数定義で使う =
はコマンドではありません。=
の前後にスペースを入れると 変数名がコマンドとして認識されてしまう ので、スペースは入れられません。
var=123
このあたりを知っておくと、なぜ文法上でスペース有無のルールがあちこちで違うのか、理解が深まることと思います。
適切にエラーハンドリングする
set -e
しているとエラー時にスクリプトが終了するようになり便利ですが、個別にエラーハンドリングをしたい時は工夫して行う必要が出てきます。
これについては シェルスクリプトでset -eしているときに処理を中断せずエラーを扱う方法 の記事にまとめましたので参考にしてみてください。
また、シェルスクリプトには例外機構はありません。trap
を使うことでジャイアント try-catch のようなことは出来なくもないですが、コマンドや関数レベルのエラーハンドリングについては終了ステータスを見て一つ一つ地道にやっていく必要があります。
シェルスクリプトで難しいことの一つがパイプラインのエラーハンドリングです。しかし各パイプコマンドの終了ステータスを得るためにパイプラインを分解したりする必要はありません。各パイプコマンドの終了ステータスは $PIPESTATUS
の配列変数にまとめて入るので、これを参照することで個別にエラーハンドリングも可能です2。
$ false | false | true $ echo "${PIPESTATUS[0]} ${PIPESTATUS[1]} ${PIPESTATUS[2]}" 1 1 0
自分のためにもヘルプは書く
書きましょう。CLI ツールの仕様が分からないときに --help
などを付けて実行すると Usage が見られるということは、シェルを扱う人たち共通のプロトコルになっています。
スクリプトを使う人のためでもありますし、今後そのスクリプトをメンテナンスする自分のためでもあります。
スクリプトのパラメータは一箇所で取り出す
コマンドラインオプションのパースは煩雑な処理になりがちなので、スクリプト序盤でまとめてやってしまうといいです。コード例は長くなるので次のサンプルコードを見てください。
まとめ (全体像のサンプル)
ここまでの Tips の内容を取り入れた全体像のサンプルテンプレートです。
#!/usr/bin/env bash set -Ceuo pipefail function usage() { echo "This script is ..." echo echo "Usage:" echo " $(basename "${BASH_SOURCE[0]}") [options] [--] arg1 arg2" echo echo "Options:" echo " --debug" echo " Enable debug mode." echo " --env (production|staging)" echo " Target environment name." echo " --help, -h" echo " This help." } # 色付きの echo function cecho() { local color_name color readonly color_name="$1" shift case $color_name in red) color=31 ;; green) color=32 ;; yellow) color=33 ;; blue) color=34 ;; cyan) color=36 ;; *) error_exit "An undefined color was specified." ;; esac printf "\033[${color}m%b\033[m\n" "$*" } # 赤文字でエラーメッセージを表示しつつ終了する function error_exit() { { cecho red "[ERROR] $*" echo usage } 1>&2 exit 1 } # 引数を入れる配列を定義する declare -a ARGS=() # 各スクリプトパラメータの初期値を定義する # ここではグローバル変数の命名にアッパーケースを採用 IS_DEBUG="false" ENV= # コマンドラインオプションをパースする while (($# > 0)); do case "$1" in --help | -h) usage exit 0 ;; --debug) IS_DEBUG="true" shift ;; --env) # 引数が必須のオプションパラメータの例 # 正規表現で --env の次の引数がハイフンから始まる場合もエラーにしておく if (($# < 2)) || [[ $2 =~ ^-+ ]]; then error_exit "Optional arguments are required -- $1" fi ENV=$2 shift 2 ;; --) # ダブルダッシュがあったら以降は引数とみなす shift while (($# > 0)); do ARGS+=("$1") shift done break ;; -*) error_exit "Illegal option -- $1" ;; *) if [[ $1 != "" ]] && [[ ! $1 =~ ^-+ ]]; then ARGS+=("$1") fi shift ;; esac done # 必要に応じて取り出したオプション値や引数を検証する if ((${#ARGS[@]} < 2)); then # 引数の数が不足しているためエラー error_exit "Insufficient number of script arguments." fi if [[ ! $ENV =~ ^(production|staging)$ ]]; then # 環境名が正しくないためエラー error_exit "Invalid argument of --env option -- $ENV" fi # このスクリプト自身のディレクトリに移動する readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" # デバッグモードの場合はパラメータをダンプ if [[ $IS_DEBUG == "true" ]]; then echo "Dump options:" echo " ENV=${ENV}" echo " IS_DEBUG=${IS_DEBUG}" echo echo "Dump arguments:" for ((i = 0; i < ${#ARGS[@]}; i++)); do echo ' $'"$((i + 1))=${ARGS[$i]}" done echo fi function sub_process() { echo "do something" } function debug_process() { echo "do something" } # スクリプト終了時に呼ばれるハンドラー関数 function on_exit() { local exit_code=$1 # スクリプト終了時の後始末を行う場合はここに書く exit "$exit_code" } function main() { # スクリプト終了時のハンドラーを登録 trap 'on_exit $?' EXIT # 煩雑な処理を行う場合はサブ関数を呼び出す(例) sub_process # デバッグモードの場合のみ実行する処理を呼び出す(例) if [[ $IS_DEBUG == "true" ]]; then debug_process fi } # グローバルスコープにはメインロジックをべた書きしない # メイン関数を呼び出しそちらで処理をする main
シェルスクリプトは沼
シェルスクリプトの嫌なところ。書く度によりベターな記法の発見があるため書き方のスタイルが全く安定しないこと。
過去に書いたものをリファクタリングすることを無限に繰り返している気がする。— そわそわ (@ymm1x) 2018年12月6日
長くなりましたが以上です。シェルスクリプトはたのしい。