VSCode 上でシェルスクリプトのまずい書き方を検出してみよう (with ShellCheck on Docker)

UNIX/Linux での作業の効率化に欠かせないシェルスクリプトですが、手軽に書ける反面、様々な罠があることでも知られています。

そんな罠にはまらないよう、シェルスクリプトの解析ツール「ShellCheck」を使ってチェックしましょう、というお話です。

ShellCheck とは?

静的解析によりシェルスクリプトの問題点を分析し、より良い書き方を提案してくれる CLI の Linter ツールです。

構文の問題点やバグだけでなく、推奨されない書き方も教えてくれます。

シェルスクリプトの初心者・上級者に関わらず、入れておくだけで安心してスクリプトを書けるようになります。

ShellCheck がチェックしてくれること

ShellCheck がチェックしてくれる内容は とても多岐に渡ります。例えば…

  • クォーティング処理に関する指摘:
    • ダブルクォーテーションで囲わずに変数展開している場合など。
  • 条件判定の書き方に関する指摘:
    • 意図しない判定ミスが起きそうな書き方など。
  • 初心者がやりがちなミスの指摘:
    • 変数定義のイコールの前後にスペースがある場合など。
  • 危険な書き方の指摘:
    • 破壊的な rm 実行など。
  • ポータビリティ(実行環境に依存しない書き方)に関する指摘:
    • 一行目のシバンに #!/bin/sh と書いてるのに bash 依存のコマンドを書いている場合など。

これだけでも心強いと思いますが、上記はほんの一例です。

ShellCheck を入れてみよう

この記事では Visual Studio CodeShellCheck 連携プラグイン により ShellCheck を呼び出し、コーディングの際にリアルタイムにチェックできる環境の作り方を紹介します。

尚、ShellCheck の導入は Docker イメージとして提供されているものを使います。そのため事前に Docker のインストール が必要です。

なぜ ShellCheck を直接インストールせずに Docker イメージを使うの?

ShellCheck は brew や yum や apt-get などのパッケージ管理ツールでシステムに直接インストールすることもできます。

しかし、環境によってビルドにめちゃくちゃ時間がかかります。macOS では1時間ほど要することもあるようです。Docker イメージならビルドなしですぐ使えます。

もし時間がかかってもシステムに直接インストールしたい場合は 公式のインストール方法 を参照してみてください。

VSCode に ShellCheck を導入する手順

1. ShellCheck の Docker イメージを取得する

docker pull koalaman/shellcheck:v0.7.1

これだけでコマンドラインで ShellCheck を使えるようになります。

例えば、/path/to/myscript.sh に置かれているスクリプトをチェックしたい場合は以下のように実行します。

cd /path/to
docker run --rm -v "$PWD:/mnt" koalaman/shellcheck:v0.7.1 myscript.sh

2. VSCode から実行するためのキッカースクリプトを作る

VSCode 上で shellcheck を使うには docker run コマンドなしで実行できるようにする必要があります。

そのため、公式で説明されているように以下のようなキッカースクリプトを用意しておきます。

#!/bin/bash

exec docker run --rm -i -v "$PWD:/mnt:ro" \
  koalaman/shellcheck:v0.7.1 "$@"

作成したスクリプトは chmod +x コマンドで実行権限のパーミッションを付与しておきます。スクリプトはどこに置いてもいいですが、ファイルパスは控えておいてください。

3. VSCode に拡張機能をインストールする

次に VSCode に ShellCheck を動かすための拡張機能「vscode-shellcheck」をインストールします。

インストールできたら VSCode の設定を開き、shellcheck.executablePath の設定に先ほど作成したキッカースクリプトのパスをフルパスで指定します。(システムに ShellCheck をインストールした場合はこの設定は不要です。)

VSCode 上の設定例

以上で導入完了です。

適当にシェルスクリプトを書いてリアルタイムにチェックされるか確認してみる

下の図は適当にシェルスクリプトを書いてみたときの様子です。

ShellCheck により問題点が指摘されている様子

何やら4行目に波下線が出ています。これは検出された問題のあるコードの箇所を示しています。ウィンドウ下部の PROBLEMS の欄に、具体的な問題の内容が表示されています。

Double quote to prevent globbing and word splitting. shellcheck(SC2086)

一般的に変数を使うときは二重引用符 “” で囲うべき とされているので、その指摘となります。(囲わないと意図しない単語分割やグロブ展開が起きる危険性があります。)

メッセージの末尾にある SC2086 というのは ShellCheck の各チェック事項に対して割り当てられたコード番号で、このコード番号で調べると問題の詳細を知ることができます。例えば SC2086 の Wiki ページ を見ると直し方のコード例と共に、なぜこの書き方が問題なのか、解説を確認することができます。めちゃ親切。

まとめ

今回は二重引用符で囲うべきという指摘がされる例を挙げましたが、この他にも様々なことをチェックしてくれます。実際に ShellCheck を入れて書いてみるとその安心感が分かるかと思います。

ShellCheck が指摘してくれた問題点を直していくだけでベストプラクティスとされる書き方を反映したスクリプトを書くことができますし、上達にも繋がるのでおすすめです。

関連記事

シェルスクリプトで暗記カードを作った話

突然ですが Jan. や Feb. といった月名の略称を見たときにそれが何月かスムーズに思い出せますか?

思い出せる人はとてもえらいです。ちなみに私はさっき Jun. という文字を見て何月か思い出すことが出来ずショックを受けました。June ってなんだか6月感が薄いと思うんですよね。どうしようもない話ですみません…。

何とかするためシェルスクリプトでサクッと暗記マシーン(?)を作ることにしました。

動いてるところ

ランダムに問題を出題し、答えるだけのシンプルなツールです。

simple-tester-sample

CTRL-C キーなどでスクリプトを終了するまで無限に出題され続けるスパルタ仕様になっています。何度かやって無事に覚えることができました🙆

ソースコード

なるべくシェル芸感は薄めて、前に書いた シェルスクリプトを高級言語のような書き味に近づける Tips 集 の内容を取り入れた実装にしています。

#!/usr/bin/env bash
set -Ceuo pipefail

# 出題する問題集を定義
# スペース区切りで問題とその回答を記述しておく
readonly QUESTIONS=$(
  cat <<FIN
Jan 1
Feb 2
Mar 3
Apr 4
May 5
Jun 6
Jul 7
Aug 8
Sep 9
Oct 10
Nov 11
Dec 12
FIN
)

# 色付きの 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 ;;
    *) color=30 ;;
  esac
  printf "\033[${color}m%b\033[m\n" "$*"
}

# 標準入力で渡された全行の中から
# ランダムに一行を取り出すフィルタ関数
function rand_line() {
  cat - | perl -MList::Util=shuffle -E 'say shuffle(<STDIN>);'
}

# 標準入力で渡された一問を出題する関数
function ask_question() {
  # 区切り文字を半角スペースでスプリットし、
  # $question と $answer に代入する
  IFS=" " read -r question answer

  # 出題文を表示する
  echo "[Q] $question"

  # 回答の入力待ちをする
  read -rp "[A] " input </dev/tty

  # 入力された回答を答え合わせ
  if [[ $input == "$answer" ]]; then
    cecho green "正解"
  else
    cecho red "不正解  (正解は「${answer}」でした)"
  fi
  echo
}

# ランダムに一問を出題する関数
function ask_any_question() {
  # 問題集を出力する
  echo "$QUESTIONS" |
    # 全問題の中からランダムに一問を取得する
    rand_line |
    # 取得した一問を出題する
    ask_question
}

function main() {
  echo "表示される月の略称が何月か回答してください!"
  echo

  # 出題を繰り返すメインループ
  while true; do
    ask_any_question
  done
}

main

回答の入力待ちは無くしてランダムに出題と回答表示を繰り返すだけのほうが暗記カードっぽくなるかもしれませんね。

月名に限らず、問題集の定義を書き換えれば英単語の単語カードなどでも使えるとおもいます。出来れば問題データは別ファイルに定義した方がいいですが今回は省略したいとおもいます。

シェルスクリプトを高級言語のような書き味に近づける Tips 集

shell

Bash は言わずと知れた歴史あるコマンド言語です。テキストにコマンドの羅列を記述するだけで、手軽にシェルスクリプトとして実行することができます。

シェルスクリプトの実体はシェルコマンドの羅列に過ぎませんが、手続き型プログラミング言語にあるような制御構文も備えています。変数や条件分岐、ループ、関数などです。これらを使えばシェルスクリプトでプログラミングも可能です。

もちろん、現代の一般的なプログラミング言語と比べると機能は限られます。他の言語には見られないシェルスクリプト特有の癖や記法も数多くあり、最近の言語に慣れている人ほど、つまずくポイントが多いです。

しかし、シェルスクリプトだからこその良さもあります。Bash は現在でも多くの OS で標準シェルとして採用されており、普段使っているシェルコマンドを書くだけで動かせる手軽さは何者にも代えがたいです。一度身につけておくと長く使えるお得な技術と言えるでしょう。🙆

シェルスクリプトをただのコマンドの羅列ではなく、一つのプログラミング言語として意識して書くことで、クラスを使わずに関数でプログラミングした Python や PHP などと同等のレベルの書き味まで近づけることができます。

この記事ではシェルスクリプトで堅牢かつ見通しの良いプログラムを書くための Tips を紹介していきます。

※ この記事はシェルスクリプトで無理に頑張ることを推奨するものではありません。頑張らずに書くための工夫を紹介する記事です。作ろうとしているものが大規模になることが想定される場合は始めから Python など他の言語で書くことを検討したほうがいいでしょう。

※ 記事中には Bash の built-in 機能が多分に登場します。POSIX 互換のシェルスクリプトを目指す方にはこの記事の内容は適さないことがあります。

追記1: 予想以上に「高級言語」という言葉に対しての反応が多く、釣りタイトルっぽくなってしまったことを反省しています。シェルが人間向きに作られたインターフェースということを考えるとシェルスクリプトも十分高級言語と言えると思います。

Lint ツールを使う

まず最初に ShellCheck などの Linter を入れることをおすすめします。

静的解析によりシェルスクリプトの問題点を分析し、より良い書き方を提案してくれます。様々な罠があることで知られるシェルスクリプトですが、ツールに指摘された問題点を直していくだけでベストプラクティスとされる書き方を反映でき、安心してスクリプトを書くことができるようになります。

詳しくは以下の記事にまとめているので参照してみてください。

便利なシェルオプションを設定する

set コマンドで設定できるシェルオプションには、取りあえず設定しとけレベルで便利なオプションがいくつかあります。

安全なスクリプトを書くという意味でも適切に設定し、その意味を知っておいたほうがいいです。私はシェルスクリプトの書き出しは大体以下のようにしています。

#!/bin/bash
set -euo pipefail

set -euo pipefail とすることで、以下の3つのシェルオプションがまとめて設定されます。

set -e :

  • スクリプトの実行中にエラーが発生すると、そこでスクリプトが終了するようになります。
    • ここでの「エラー」とは実行したコマンドの終了ステータス(exit code)に非0の値が返ることを指します。
  • コマンド実行の度にエラーハンドリングを書くことを 横着 省略できるようになります。
  • これを設定しておかないと途中でエラーが起きてもそのままスクリプトが突き進んでいくため、予期せぬ事故に繋がることもあり怖いです。
  • 自分で漏れずにエラーハンドリングを書ける自信がなければ設定しておくべきです。

set -u :

  • 未定義の変数を使用すると、そこでスクリプトが終了するようになります。
  • うっかり変数名を typo したときに気づきやすくなります。

set -o pipefail :

  • パイプの途中でエラーが起きた場合もエラーが発生するようになります。
  • 具体的には非0で終了した最後に実行された(右側にある)コマンドがパイプライン全体の終了ステータスになるようになります。
  • 上述の set -e と組み合わせて使うとスクリプトも終了するようになるので便利です。

また、シェルオプションは以下のように一行目のシバンで設定することも可能です。

#!/bin/bash -euo pipefail

この方がシンプルで一見良さそうに見えますが、この方法で指定したオプションはスクリプトに実行権限を付け直接実行しないと適用されません🙅

例えばデバッグの為に一時的に -x オプションを付けて実行したいからといって bash -x ./hoge.sh などと実行すると -x は適用されますが -euo pipefail に関しては 適用されない ということです。確実に設定したい場合はシバンの直後あたりで set コマンドで設定する方がいいでしょう。

Bash のパワフルな判定式を活用する

シェルスクリプトを書き始めて難関になりがちなのが条件判定(IF文)の書き方です。括弧の書き方による違いだったり、謎のオプションによる判定方法が色々あって訳わからん!となります。

しかし 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 に移動することが出来ます。

# このスクリプト自身が置かれているディレクトリに移動する
THIS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$THIS_SCRIPT_DIR"

位置パラメータは意味のある命名の変数に取り出す

関数の引数は位置パラメータと呼ばれる連番の変数 ($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
  )
}

※ サブシェルには起動コストがあるので、そのまま呼び出すよりは多少遅くなるという点は留意してください。
※ サブシェルの中では exit の挙動が変わる点に気をつけてください。サブシェルの中で exit をしてもそのサブシェルが終了されるだけで、スクリプトは終了されません。

オプション引数やスイッチ引数よりも環境変数を活用する

シェルスクリプトを作っていると動作を切り替えるためのオプション引数を作りたくなることがあると思います。例えば強制的に処理するための --force オプションだったり、あるいは何らかの値を受け取る --date 20220515 のようなオプションです。

普段様々なコマンドに慣れ親しんでいる人にとってこういったオプション引数を作りたいと思うのは自然な考えではあるのですが、シェルスクリプトにおいては複雑なオプション引数をパースする処理を書くのは難易度が高く面倒 なので、あまりおすすめできません。シェルスクリプトに慣れてない人が複雑な引数パースの処理を見るときっと何かの暗号のように見えるでしょう。

オプション引数を作らずとも環境変数を活用すれば同じことがもっと簡単に実現できます。面倒なパース処理を書く必要もありません。

--date--force のようなオプション引数を環境変数を活用する形に置き換えると、スクリプトの起動方法は次のように変わります。

# before
$ any_script.sh --date 20220515 --force

# after
$ DATE=20220515 FORCE=true any_script.sh

環境変数で指定した日付をスクリプトの中で使いたい場合、$DATE とするだけで参照できます。

また、位置パラメータ ($1, $2…) で指定するようなものも環境変数で受け渡すようことで、自然と名前付きの変数で取り扱えるようになり可読性が向上します。引数が複雑化してきた場合は環境変数の活用を検討すると良いでしょう。

メインロジックを関数化する

グローバルスコープの汚染を防ぐことができます。また、メインロジックのテストがしやすくなるといったメリットがあります。

function main() {
  # do something
}

main

コマンドなのか構文なのかを意識してみる

シェルスクリプトにはあたかも構文かのように擬態したコマンドが多数存在しています。これを知っておくと文法上の細かいルールの理解が深まります。

例えば条件判定に使用する [ は一つのコマンドです。試しに 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 が見られるということは、シェルを扱う人たち共通のプロトコルになっています。

とは言えシェルスクリプトに慣れてないと --help オプションを作って引数をパースする処理を書くこと自体が難儀だと思うので、スクリプトの上部にコメントでそのスクリプトの概要や使い方を書いておくだけでも十分でしょう。

何らかの形で使い方の情報を残しておくのはスクリプトを使う人の為でもありますし、今後そのスクリプトをメンテナンスする自身の為でもあります。

まとめ (全体像のサンプル)

ここまでの Tips の内容を取り入れた全体像のサンプルスクリプトです。
スクリプトの入り口は一番下にある main 関数なので、そこから上に読んでいくと読みやすいと思います。

#!/bin/bash

# 動画を CDN にアップロードし公開するスクリプトです。
#
# Usage:
#   MOVIE_PATH="/path/to/movie.mp4" \
#   COMPRESS_LEVEL="5" \
#   upload_movie_to_cdn.sh
#
# Parameters (environment variable):
# - MOVIE_PATH: アップロードする動画のパスを指定する
# - COMPRESS_LEVEL: 動画の圧縮レベルを指定する

# スクリプトを安全に実行するためのおまじない
set -euo pipefail

# このスクリプト自身のディレクトリに移動する
# 実行時のカレントディレクトリに依存せずスクリプトを使えるようにする為
THIS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$THIS_SCRIPT_DIR"

#######################################
# Utility functions
#######################################

# 色付きの echo
function colored_echo() {
  local color_name color
  color_name="$1"
  shift
  case $color_name in
    red) color=31 ;;
    green) color=32 ;;
    yellow) color=33 ;;
    *) error_exit "An undefined color was specified." ;;
  esac
  printf "\033[${color}m%b\033[m\n" "$*"
}

# 赤文字でエラーメッセージを表示してエラー終了する
function error_exit() {
  local message=$1
  colored_echo red "[ERROR] ${message}"
  exit 1
}

#######################################
# App functions
#######################################

# スクリプト終了時に呼ばれるハンドラー関数
function on_exit() {
  local exit_code=$1
  # 一時ファイルの後始末などを行う場合はここに書く
  exit "$exit_code"
}

# 入力パラメータのバリデーション(例)
function validate_parameters() {
  if ! [[ -e $MOVIE_PATH ]]; then
    error_exit "MOVIE_PATH に指定されたファイルが見つかりません。"
  fi

  if ((COMPRESS_LEVEL >= 1 && COMPRESS_LEVEL <= 9)); then
    error_exit "COMPRESS_LEVEL は 1 〜 9 の数値を指定する必要があります。"
  fi
}

# 使用するリポジトリを初期化する
function prepare_repository() {
  : "do something"
}

# 動画を圧縮する
function compress_movie() {
  : "do something"
}

# 動画を CDN にアップロードする(例)
function upload_movie_to_cdn() {
  local movie_published_url
  movie_published_url=$(any_upload_command "$MOVIE_PATH")

  # 通常のプログラミング言語における戻り値は標準出力に書き出し、
  # それを呼び出し側でコマンド置換 (Command Substitution) で受け取ることで実現する
  echo "$movie_published_url"
}

# Slack などに完了を通知する
function notify_to_chat() {
  : "do something"
}

function main() {
  local movie_published_url

  # スクリプト終了時のハンドラーを登録
  trap 'on_exit $?' EXIT

  validate_parameters

  # サブ関数を呼び出してスクリプトの本来の目的の処理を行う
  # 一つの関数は大きくしすぎず、小さな関数を順番に呼び出して目的の処理を遂行していく
  # 関数呼び出しの羅列を見るだけでこのスクリプトが何をするか想像できるような命名にする
  prepare_repository
  compress_movie
  movie_published_url=$(upload_image_to_cdn)
  notify_to_chat "$movie_published_url"
}

# グローバルスコープにはメインロジックをべた書きしない
# メイン関数を呼び出しそちらで処理をする
main

シェルスクリプトは沼

長くなりましたが以上です。シェルスクリプトはたのしい。

改訂履歴

この記事を2018年に書いてから長いこと経ち、個人的にシェルスクリプトを書き続ける中で考えが変わってきたところもあるので、記事の内容はたまにアップデートしています。

2020/5:

  • Lint ツール ShellCheck についての記述を追加しました。
  • 「ロケールによる動作の違いを予防する」の項目を削除しました。
    • LC_ALL=C による副作用もあるので、理解して使う必要があり、気軽に推奨できるものではないと考えた為。

2022/5:

  • 「スクリプトのパラメータは一箇所で取り出す」の項目を削除しました。
  • 「オプション引数やスイッチ引数よりも環境変数を活用する」の項目を追加しました。
    • シェルスクリプトで複雑なオプション引数のパース処理を書くことはハードルが高く、書かずに済むならそのほうが良いと考えるようになった為。
  • set -C を推奨する記述を削除しました。
    • 個人的にここ数年シェルスクリプトを書いてきた中でこのオプションに救われたという経験が無く、リダイレクトの仕方がトリッキーになる弊害もゼロではなく、脳死で有効にするほどのオプションではないと思った為。
  • まとめの最終的なスクリプト例をイメージしやすいものに差し替えました。
    • オプション引数のパースを頑張っていた部分は丸ごとオミットしてます。
  • 「高級言語の力を借りる」の項目を削除しました。
    • やりすぎ感もあり有効な Tips であると思えなくなった為。