シェルスクリプトはWebアプリケーションの開発において必須スキルというわけではないのかもしれませんが、ビルドやデプロイのスクリプトを書くときに結構役立ったりします。ただ、たまにしか書かないこともありなかなか入門レベルから上達せず、適切なスクリプトが書けているか不安になることがあります。

そんなときに頼りにしているのがGoogle製のShell Style Guide(以下「ガイド」)です。とりあえず最低限のお作法としてこれに従いつつ、要所要所をアレンジして使っています。

今回は中でも特に気をつけている部分をピックアップしてチェック表代わりにしてみようと思います。

どのshellを使うか

原則bashを使う。shebangは#!/bin/bashとする

特段な理由がなければbashを利用するようにします。日頃使っているコマンドの中には、実はPOSIX準拠ではなくbash等で拡張されたものも存在します。そういったことを意識せずに済むようにbashでの実行を前提としています。

私の場合、用意したスクリプトを手元で実行することもあればCIから実行することもあります。よく使うCiecleCIは、仮想環境のOSがubuntuなのでデフォルトのshellがdashです。shebangを#!/bin/shとしていると、手元で動いたスクリプトがCI上では動かない…なんてことになり兼ねないので、bashでの実行を原則としています。

いつshellを使うか

小さなツール・ユーティリティとして使う

拡張子

  • 直接実行可能なものは拡張子をつけない
  • ライブラリとしてのスクリプトは拡張子必須

直接実行する場面では「どの言語で書かれているか」を意識する必要がないためです。逆にライブラリとして利用する場面では、実装言語を意識する必要がありますよね。「直接実行可能か」をひと目で判断しやすくするためにも拡張子の有無に気を配るようにしています。

エラーメッセージ

全てのエラーメッセージはSTDERR(標準エラー出力)へ書き出す

通常の状態とエラー状態を識別しやすくするためですね。

err() {
  echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $@" >&2
}

if ! do_something; then
  err "Unable to do_something"
  exit "${E_DID_NOTHING}"
fi

リダイレクト>とファイルディスクリプタ&2を使ってSTDERRに出力するサンプルコードです。

フォーマット

  • インデントはスペース2つ
  • 1行は80文字まで
  • パイプラインが3つ以上続くときは改行する
  • ループ文やif文では、同じ行に; do; thenを書く
  • case文では、改行して;;を書く

特にこだわりはないのでガイドに従います。

変数展開

"$var"より${var}を使う

ガイドには「既存の実装に揃えることを優先すること」とも記載されていますが、既存の実装がないのでこのルールに従っています。

コマンド置換

`command`より$(command)を使う

$(command)は入れ子にできる利点があります。

# This is preferred:
var="$(command "$(command1)")"

# This is not:
var="`command \`command1\``"

test, [, [[

[[ ... ]]を利用する

[[ ... ]]はPOSIX準拠ではないので注意が必要ですが、testコマンド等に比べて機能が拡張されています。とくに変数展開に関する問題が減るので、原則利用するようにしています。

empty check

極力-z, -nを利用する

空文字列のチェックなどのempty checkをするときは-z(zero)や-n(non-zero)を利用します。

if [[ -n "${my_var}" ]]; then
  do_something
fi

ファイル名のワイルドカード

ワイルドカードを利用するときは*ではなく./*とする

ワイルドカードを利用するときはpathを明示します。ミスを防ぐことと可読性を高めることが目的だと思います。

whileとパイプライン

パイプでwhileにつなぐ代わりに、forループまたはプロセス置換を使う

whileを使うと暗黙的にsubshell(子プロセス)が生成されます。その結果、子プロセスから親プロセスの変数にアクセスできなかったり、何か問題が起きたときに追跡しにくくなったりします。

以下のコードは、subshellから親の変数にアクセスできない例です。

last_line='NULL'
your_command | while read line; do
  last_line="${line}" # 親で定義したlast_lineへアクセスできない
done

echo "${last_line}"   # そのため親のlast_lineは更新されておらず、'NULL'が出力される

forループでの代替例です。

total=0
# Only do this if there are no spaces in return values.
for value in $(your_command); do
  total+="${value}"
done

次にプロセス置換の例です。プロセス置換もsubshellを使いますが、whileと違い明示的であるため幾分か良いです。ちなみにプロセス置換はPOSIX準拠ではありませんのでご注意を。

total=0
last_file=
while read count filename; do
  total+="${count}"
  last_file="${filename}"
done < <(your_command | uniq -c)

命名規則

  • 変数やファイル名はスネークケース
  • 定数や環境変数は大文字のスネークケース
  • 定数や環境変数はファイル冒頭で定義し、readonlyまたはdeclareする
  • 関数内で使う変数はlocalをつける

定数や環境変数などのグローバル変数はファイルの冒頭で定義します。グローバル変数は広い範囲で利用され得るためバリデーションを実装し、定数の場合はreadonlyとします。

zip_version="$(dpkg --status zip | grep Version: | cut -d ' ' -f 2)"
if [[ -z "${zip_version}" ]]; then
  error_message
else
  readonly zip_version
fi

関数

  • スネークケースで書く
  • パッケージの関数は::で区切る
# Single function
my_func() {
  ...
}

# Part of a package
mypackage::my_func() {
  ...
}

関数はグローバル変数の下に定義し、呼び出し箇所と宣言箇所を混ぜないようにします。

おわりに

以上になります。
間違っている箇所やもっといい案がありましたらお気軽に@aloerina_までご連絡ください。