よく使うシェルスクリプトの雛形

## はじめに

この記事では、僕がよく使用しているシェルスクリプトの雛形について紹介します。

## コード

```sh
#!/bin/bash

# Description:
# TODO: スクリプトの目的や実行する処理の内容についての説明を記載する。

set -e -u -o pipefail

realpath() {
  [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}"
}

SCRIPT_NAME="$(basename "$(realpath "${BASH_SOURCE:-$0}")")"
SCRIPT_DIR="$(dirname "$(realpath "${BASH_SOURCE:-$0}")")"

# stderr にテキストを出力する echo コマンド
function echo_stderr {
  echo "${@}" 1>&2
}

# エラーと分かるテキストを先頭に付加してメッセージを表示するコマンド
function show_error {
  echo_stderr "[ERROR] $1"
}

# Usage テキストの定義。
# TODO: 実際に必要なパラメータや説明を記載する。
function show_usage_impl {
  local ECHO_COMMAND="$1"
  "$ECHO_COMMAND" "Usage: $SCRIPT_NAME [{-h|--help}] {-f|--flag} [{-a|--flag-with-argument} <ARGUMENT>]"
  "$ECHO_COMMAND" ""
  "$ECHO_COMMAND" "Description: <TODO>"
  "$ECHO_COMMAND" ""
  "$ECHO_COMMAND" "Options:"
  "$ECHO_COMMAND" -e "-h, --help\n\tShow this help."
  "$ECHO_COMMAND" -e "-f, --flag\n\tFlag without arguments."
  "$ECHO_COMMAND" -e "-a, --flag-with-argument [value]\n\tFlag with an argument."
}

function show_usage_stdout {
  show_usage_impl "echo"
}

function show_usage_stderr {
  show_usage_impl "echo_stderr"
}

# 引数なしのとき
# 使用法を表示して正常終了
if [ $# -eq 0 ]; then
  show_usage_stderr
  exit 0
fi

# TODO: 実際にサポートするオプションに合わせて
# オプションの内容を受け取る変数を定義し、
# 解析処理を実装する
FLAG=0
FLAG_WITH_ARG=""

while [ $# -gt 0 ]; do
  case "$1" in
    -h | --help)
      # ヘルプ用のオプションが指定されたときに限り
      # usage テキストを stdout に出力する
      show_usage_stdout
      exit 0
      ;;
    -f | --flag)
      FLAG=1
      shift 1
      ;;
    -a | --flag-with-argument)
      if [[ -z "${2+UNDEF}" ]] || [[ "${2+UNDEF}" =~ ^-+ ]]; then
        show_error "Argument is required for $1"
        show_usage_stderr
        exit 1
      fi
      FLAG_WITH_ARG=$2
      shift 2
      ;;
    -*)
      show_error "illegal option -- '$(echo $1 | sed 's/^-*//')'"
      show_usage_stderr
      exit 1
      ;;
    --)
      shift
      break
      ;;
    *)
      show_error "Internal Error! [$1]"
      show_usage_stderr
      exit 1
      ;;
  esac
done

# TODO: 解析したオプションが期待するものかどうか検証
# if [ "$FLAG_WITH_ARG" != "foobar" ]; then
#   show_error "Invalid flag value"
#   exit 1
# fi

# TODO: ビジネスロジックを実装
# do_something "$FLAG" "$FLAG_WITH_ARG"
```

## 解説

### shebang

まず最初に shebang についてですが、この雛形では bash を使用しています。

```sh
#!/bin/bash
```

bash がない環境のことも考えると、もう少しプリミティブな構文だけを使って単に `/bin/sh` にしたいという要望もありそうですが、自分の場合はそのユースケースに対応する動機がいままであまり強くなかったので、現状では単純に bash で動くように実装してあります。

### エラーハンドリング関連

続いてエラーハンドリング関係の設定を行っています。

```sh
set -e -u -o pipefail
```

それぞれの設定値の意味は以下の通りです。

|設定値|意味|
|:----|:---|
|`-e`|エラーが起きたときにスクリプトの実行を中断させる|
|`-u`|未使用の変数を参照しようとしたときにエラー扱いにする|
|`-o pipefail`|コマンドをパイプして実行する際に、パイプの左側のコマンドが失敗したときにそれをエラー扱いにする|

もしなにかスクリプトで呼び出している処理が失敗したり、初期化処理が意図せずスキップされたりした状態ままスクリプトの実行が進むと、場合によっては重要なファイルを含むディレクトリを削除するなどの問題引き起こす可能性があります。 [^remove-with-unexpected-path]

[^remove-with-unexpected-path]: 例えばスクリプト中で `rm -rf "/home/my_user/${TARGET_DIR}"` のようなコードでユーザーのホームディレクトリ下のあるディレクトリを削除しようとしたとき、もしもその前に TARGET_DIR に入れるパスを準備する処理が失敗したり TARGET_DIR 変数への代入を忘れたりして TARGET_DIR の中身が空になっていると、意図せずユーザーのホームディレクトリが全削除されてしまいます。

これを防ぐために上記のフラグを設定しています。

### パス設定

このコードでは、スクリプトのパスに関する情報を取得しています。

```sh
realpath() {
  [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}"
}

SCRIPT_NAME="$(basename "$(realpath "${BASH_SOURCE:-$0}")")"
SCRIPT_DIR="$(dirname "$(realpath "${BASH_SOURCE:-$0}")")"
```

最初の realpath() 関数は `$0` で取得したスクリプトのパスをフルパス化する処理を行っています。この仕組については以下の Stack Overflow の回答を参考にしています。

https://stackoverflow.com/a/3572105/1819753 

Linux には realpath コマンドが標準でインストールされているためそれを使用できますが、 macOS 環境ではこれが標準で利用できません。そのため、本物の realpath コマンドに対する最低限の代替であり Linux と macOS で同じように使える仕組みとしてこの関数を定義して使用しています。

その下の箇所では、定義した realpath() 関数を使ってスクリプトファイルの名前やファイルの存在するディレクトリを変数に設定しています。

これによって、スクリプトを呼び出したときのワーキングディレクトリがどこであっても、スクリプトファイルのパスを起点とした位置にあるデータを参照できるようになります。

### メッセージ関連

次の箇所では、画面にメッセージを出力するための仕組みを用意しています。

```sh
# stderr にテキストを出力する echo コマンド
function echo_stderr {
  echo "${@}" 1>&2
}
```

上の箇所では、 stderr に向けてテキストを出力できる echo コマンドの代替となる関数を定義しています。
エラーメッセージやヘルプ関連のテキストは stdout ではなく stderr に表示したいことがあるため、そのようなケースでこの関数を使用します。

その下の箇所では以下のように、エラーメッセージを出力する際に、エラーだと分かりやすくするための文字列を追加してからテキストを出力する関数を定義しています。

```sh
# エラーと分かるテキストを先頭に付加してメッセージを表示するコマンド
function show_error {
  echo_stderr "[ERROR] $1"
}
```

これらの関数を定義したあとで、スクリプトの使い方を表示するための関数を下のように定義しています。

ここでは仮の内容が入っていますが、この雛形を使って実際のスクリプトを実装するときに、この内容を適切なものに更新することになります。

```sh
# Usage テキストの定義。
# TODO: 実際に必要なパラメータや説明を記載する。
function show_usage_impl {
  local ECHO_COMMAND="$1"
  "$ECHO_COMMAND" "Usage: $SCRIPT_NAME [{-h|--help}] {-f|--flag} [{-a|--flag-with-argument} <ARGUMENT>]"
  "$ECHO_COMMAND" ""
  "$ECHO_COMMAND" "Description: <TODO>"
  "$ECHO_COMMAND" ""
  "$ECHO_COMMAND" "Options:"
  "$ECHO_COMMAND" -e "-h, --help\n\tShow this help."
  "$ECHO_COMMAND" -e "-f, --flag\n\tFlag without arguments."
  "$ECHO_COMMAND" -e "-a, --flag-with-argument=[value]\n\tFlag with an argument."
}

function show_usage_stdout {
  show_usage_impl "echo"
}

function show_usage_stderr {
  show_usage_impl "echo_stderr"
}
```

このように Usage テキストを定義したあとで、プログラムを引数なしで起動したときの処理を下のように実装しています。

これについてはスクリプトの仕様次第ですが、この雛形ではとりあえず使い方を表示してプログラムを正常終了させるように実装してあります。

```sh
# 引数なしのとき
# 使用法を表示して正常終了
if [ $# -eq 0 ]; then
  show_usage_stderr
  exit 0
fi
```

### オプションの解析

この雛形では、スクリプトにオプションを渡せるようにしてあります。オプションの渡し方は次の形式をサポートしています。

* 短い形式と長い形式
   * 短い形式の例: -h
   * 長い形式の例: --help
* 引数なしと引数付き
   * 引数なしの例: -h
   * 引数付きの例: `-i ~/Desktop/myfile.txt`
       * オプションと引数を `-i=~/Desktop/myfile.txt` のように `=` で繋ぐ形式はサポートしていないので、必ず空白を開ける必要があります。

このオプションの解析を行っているのが下のコードです。[^without-getopt]

[^without-getopt]: オプションの解析処理を実装する方法は getopt コマンドを使うものが一般的ですが、 getopt コマンドは macOS にはデフォルトでインストールされていなくて導入の手間が掛かる問題があります。そのためこの雛形では getopt を使わずに、そのまま macOS 上でも動作できるように実装してあります。

```sh
# TODO: 実際にサポートするオプションに合わせて
# オプションの内容を受け取る変数を定義し、
# 解析処理を実装する
FLAG=0
FLAG_WITH_ARG=""

while [ $# -gt 0 ]; do
  case "$1" in
    -h | --help)
      # ヘルプ用のオプションが指定されたときに限り、
      # usage テキストを stdout に出力する
      show_usage_stdout
      exit 0
      ;;
    -f | --flag)
      FLAG=1
      shift 1
      ;;
    -a | --flag-with-argument)
      if [[ -z "${2+UNDEF}" ]] || [[ "${2+UNDEF}" =~ ^-+ ]]; then
        show_error "Argument is required for $1"
        show_usage
        exit 1
      fi
      FLAG_WITH_ARG=$2
      shift 2
      ;;
    -*)
      show_error "illegal option -- '$(echo $1 | sed 's/^-*//')'"
      show_usage
      exit 1
      ;;
    --)
      shift
      break
      ;;
    *)
      show_error "Internal Error! [$1]"
      show_usage
      exit 1
      ;;
  esac
done
```

スクリプトでサポートするオプションを増やしたい場合は、 Usage テキストとこの解析処理の箇所をそれぞれ更新することになります。

解析処理中の `${2+UNDEF}` という箇所は、 `set -u` を設定した状態で未定義変数にアクセスしてもエラーとして扱われないようにしているものになります。引数付きのオプションに渡されるべき引数が渡されていない場合は、オプション解析処理の途中で $2 に値が設定されない状態となります。これがそのまま未定義変数のエラーとして検知されてしまうとオプションの解析処理上のエラーとして検知できずに不便なため、この対処を導入してあります。[^avoid-set-u]

[^avoid-set-u]:参考: https://qiita.com/laikuaut/items/4bc07eabce56ee30812d
 
### オプションの検証とビジネスロジックの実装

渡されたオプションの解析が完了したら、その中身を検証して、それも成功したらその後で実際のビジネスロジックを実装していくことになります。

```sh
# TODO: 解析したオプションが期待するものかどうか検証
# if [ "$FLAG_WITH_ARG" != "foobar" ]; then
#   show_error "Invalid flag value"
#   exit 1
# fi

# TODO: ビジネスロジックを実装
# do_something "$FLAG" "$FLAG_WITH_ARG"
```

## まとめ

以上、自分がよく使用しているシェルスクリプトの雛形の紹介でした。
もし不備があったりより良い方法があったりしたらコメント欄で指摘いただけると嬉しいです。