Bash スクリプトのベストプラクティス

ネット上に散在している Bash スクリプトのベストプラクティスについて、僕なりにまとめたメモです。

VS Code での開発を前提に記載しています。

VS Code Extensions

VS Code を利用するのであれば、以下の Extensions を利用できます。

インデント

「タブ派」と「スペース 2 個派」に分かれるようですが、ヒアドキュメントを書く際にはタブが必須となるため、タブを利用しようと思います。

シェルスクリプトのインデントにはスペースとタブのどちらがよいか?

settings.json

{
  "[shellscript]": {
    "editor.tabSize": 4,
    "editor.insertSpaces": false,
    "editor.defaultFormatter": "foxundermoon.shell-format",
    "editor.formatOnSave": true
  }
}

命名規則

  • ファイル名
    • kebab-case
  • 定数
    • UPPER_SNAKE_CASE
    • readonly 宣言して使う
  • 変数
    • lower_snake_case
    • 関数内では local 宣言して使う
  • 関数
    • lower_snake_case

シバン行

PATH を見て実行させます。

#!/usr/bin/env bash

#!/usr/bin/env searches PATH for bash, and bash is not always in /bin, particularly on non-Linux systems. For example, on my OpenBSD system, it's in /usr/local/bin, since it was installed as an optional package.

If you are absolutely sure bash is in /bin and will always be, there's no harm in putting it directly in your shebang—but I'd recommend against it because scripts and programs all have lives beyond what we initially believe they will have.

Why is #!/usr/bin/env bash superior to #!/bin/bash?

set コマンドのオプション

set -Eeuo pipefail
option description
-E ERR trap を関数内にも継承させる
-e コマンドがゼロ以外で終了した場合、直ちに終了する
-u 未設定の変数をエラーとして扱う
-o pipefail パイプライン内のコマンドがゼロ以外で終了した場合、その失敗コマンドの終了ステータスがパイプライン全体の終了ステータスになる

スクリプトファイルのフルパスを取得して、そこを基点に操作する

SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
readonly SCRIPT_DIR

echo "$SCRIPT_DIR"
ls "$SCRIPT_DIR"
cd "$SCRIPT_DIR/../" && pwd

# output:
#  /full/path/to/script/directory
#  sample.sh
#  /full/path/to/script

usage 関数を作成する

#!/bin/bash
set -Eeuo pipefail

function usage() {
    cat <<-EOF
      Usage: $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-p param_value] arg1 [arg2]

      description.

      Options:
      -h, --help     Print this help
      -v, --verbose  Print script debug info
      -p, --param    Some param description

      Arguments:
      arg1     aaaaa
      arg2     bbbbb
 EOF
}

function parse_args() {
    while :; do
        case ${1:-default} in
        -h | --help) usage && exit ;;
        -v | --verbose) set -x ;;
        -p | --param)
            local param="${2}"
            shift
            ;;
        -?*)
            printf "\033[31m%s\033[m\n\n" "ERROR: unknown option" >&2
            usage && exit 1
            ;;
        *) break ;;
        esac
        shift
    done
    if [[ $# -eq 0 ]]; then
        printf "\033[31m%s\033[m\n\n" "ERROR: missing arguments" >&2
        usage && exit 1
    fi
    readonly PARAM=${param:-}
    readonly ARG1=${1}
    readonly ARG2=${2:-}
}

parse_args "$@"
echo "$ARG1 $ARG2 $PARAM"

実行例1。ヒントの表示。

$ ./sample.sh -h
Usage: sample.sh [-h] [-v] [-p] arg1 arg2

description.

Options:
-h, --help     Print this help
-v, --verbose  Print script debug info
-p, --param    Some param description

Arguments:
arg1     aaaaa
arg2     bbbbb

実行例2。引数の不足。

$ ./sample.sh
ERROR: missing arguments

Usage: sample.sh [-h] [-v] [-p param_value] arg1 [arg2]

description.

Options:
-h, --help     Print this help
-v, --verbose  Print script debug info
-p, --param    Some param description

Arguments:
arg1     aaaaa
arg2     bbbbb

実行例3。デバッグモードでの出力。

$ ./sample.sh -v --param '!!' Hello World
+ shift
+ :
+ case ${1:-default} in
+ local 'param=!!'
+ shift
+ shift
+ :
+ case ${1:-default} in
+ break
+ [[ 2 -eq 0 ]]
+ readonly 'PARAM=!!'
+ PARAM='!!'
+ readonly ARG1=Hello
+ ARG1=Hello
+ readonly ARG2=World
+ ARG2=World
+ echo 'Hello World !!'
Hello World !!

Exit code of traps in Bash

trap コマンドを活用する

以下は trap コマンドを利用して、必ず finally の終了処理を呼ぶ例です。

#!/usr/bin/env bash
set -Eeuox pipefail
trap finally SIGINT SIGTERM ERR EXIT

function finally() {
    trap - SIGINT SIGTERM ERR EXIT # trap の設定をリセットする. リセットしないと、非 EXIT シグナルが呼ばれた時に、この関数が 2 回実行されてしまう
    echo "finally done"
}

function force_error() {
    not_found_command
}

force_error

実行例。

$ ./sample.sh
+ trap finally SIGINT SIGTERM ERR EXIT
+ force_error
+ not_found_command
./sample.sh: line 11: not_found_command: command not found
++ finally
++ trap - SIGINT SIGTERM ERR EXIT
++ echo 'finally done'
finally done
$
$ echo $?
127

スクリプトの終了コードは、trap される前の値を利用してくれる、という話。

Exit code of traps in Bash

main 関数を作成する

function main() {
    parse_args "$@"
    echo "$ARG1 $ARG2 $PARAM"
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi

if 文の部分は、source コマンドで実行された時には実行させないための工夫。

sample.sh

#!/bin/bash

echo "1: ${BASH_SOURCE[0]}"
echo "2: ${0}"

実行結果。

$ ./sample.sh
1: ./sample.sh
2: ./sample.sh
$
$ source ./sample.sh
1: ./sample.sh
2: /bin/bash

まとめ

gist7481b760492267c449c25277fda9045c

参考資料