見出し画像

Google Shell Style Guide から学ぶ保守性の高いシェルスクリプトの書き方

こんにちは、くるふとです。
ナビタイムジャパンで、経路探索エンジンや時刻表データのリリースフロー改善を主に担当しています。

今回は Google が提供する Shell Style Guide について紹介します。

Shell Style Guide とは

Shell Style Guide とは、 Google が提供する shell スクリプトについてのスタイルガイドです。

スタイルガイドとは、「開発者間で一貫性のあるコードが書けるよう定められた規約」になります。
Shell Style Guide は shell スクリプトのコーディング時に推奨されるルールや Tips がまとめられているスタイルガイドにあたります。

なぜスタイルガイドが必要なのか

Shell Style Guide の紹介をする前に、そもそもなぜスタイルガイドが必要なのかを説明します。

スタイルガイドの狙いは、開発者間で一貫性のあるコードを書けるようにし、プロダクトの品質を上げようといったものになります。業務の中で、下記のような課題を感じている人におすすめです。

  • bash スクリプトの運用コストが高い

    • バグ修正の難易度が高い

    • 調査、解析に時間がかかる

ここでいう「品質」について、より深く掘り下げてみましょう。

ソフトウェアの品質については、国際規格として「SQuaRE」という名称で定められています。

SQuaRE では、ソフトウェアの品質を「品質モデル」という形で定義しています。

出典: Information-technology Promotion Agency Japan, 「つながる世界のソフトウェア品質ガイド」の発行 ~経営者が知っておくべきソフトウェアの品質・評価と国際規格「SQuaRE」~, https://www.ipa.go.jp/sec/reports/20150609.html

品質モデルはいくつかの要素で構成されており、その中の一つに保守性という要素があります。
保守性はさらに詳細化した要素に分類され、それぞれモジュール性、再利用性、解析性、修正性、試験性で構成されます。
スタイルガイドは、保守性、特に解析性、修正性に寄与するものと言えます。

Shell Style Guide の重要なセクションを紹介

Shell Style Guide では、推奨されるルール、テクニックが多く存在します。
この記事で全てを紹介することはできないので、自分が重要だと思うセクションを 11 件紹介いたします。

1. Which Shell to Use

今日では、多くの種類の shell が業務で使用されます。(bash, fish, zsh 等)
スタイルガイドでは、bash のみが実行ファイルで許可される唯一のシェルスクリプト言語であると記述されています。
スクリプトの先頭にシバンと set によるシェルオプションを定義し、スクリプトを呼び出した際に必ず bash の指定したオプションで実行できるようにしておきます。

#!/bin/bash
set -eu

シバンとは、スクリプトの1行目に #! 始まりで記述するもののことを指します。どのインタプリタでスクリプトを実行するかを定義することができます。 bash スクリプトにおいては、基本的に1行目に #!/bin/bash を定義します。

set は bash が持つコマンドの一つで、シェルの設定を変更するためのコマンドになります。スタイルガイド上では詳しい記述はないですが、上記のサンプルコードのように -eu オプションを定義することが多いです。
-e は errexit オプションです。スクリプト上で実行したコマンドがエラーになった場合、後続の処理を実行せず直ちにシェルを終了させます。

#!/bin/bash
set -e

echo "cat non-existent file."

cat ./non_existent.txt # 存在しないファイルを cat するので失敗

echo "this is not displayed." # スクリプトの途中で失敗するため実行されない

-u は nounset オプションです。未定義の変数を利用しようとした場合、エラーを返します。

#!/bin/bash
set -u

# 変数定義の行をコメントアウト
# MESSAGE="Hello World!!"

echo "${MESSAGE}" # 未定義の変数を利用しているのでエラー

set -eu は多くのケースで実装漏れを防いでくれます。特に理由がなければシバンと併せて記述することをお勧めします。

2. When to use Shell

スタイルガイドでは、どのようなケースで shell を使うべきかが説明されています。

• If you’re mostly calling other utilities and are doing relatively little data manipulation, shell is an acceptable choice for the task.
• If performance matters, use something other than shell.
• If you are writing a script that is more than 100 lines long, or that uses non-straightforward control flow logic, you should rewrite it in a more structured language now. Bear in mind that scripts grow. Rewrite your script early to avoid a more time-consuming rewrite at a later date.
• When assessing the complexity of your code (e.g. to decide whether to switch languages) consider whether the code is easily maintainable by people other than its author.

出典: Google, Shell Style Guide, https://google.github.io/styleguide/shellguide.html#when-to-use-shell

翻訳すると以下のようになります。

  • もしあなたが他のユーティリティを呼び出して、データ操作が比較的少ない場合、シェルはそのタスクにとって許容し得る選択です。

  • パフォーマンスが問題であるなら、シェル以外を利用してください。

  • もしあなたが100行を超えるスクリプトを書いている場合、あるいは単純ではない制御フローロジックを利用しようとしている場合、構造化された言語で書き直すべきです。スクリプトが大きくなることを念頭においてください。後日より多くの時間をかけて書き直すことを避けるため、あなたのスクリプトを早い段階で書き直してください。

  • あなたのコードの複雑さを評価するとき(例えば言語を別のものに切り替えるかどうかを決めるとき)、コードが作成者以外の人でも容易に保守できるかを検討してください

ざっくりまとめると、「複雑で管理コストの高いコードになり得る場合は shell は使わないでください。そうでない場合 shell は良い選択になり得ます。」といった感じです。
特に、3つ目の内容は重要です。スタイルガイドでは目安として100行という数字を示しています。100行を超えるような shell ができそうな場合は本当に shell で実装しきるべきか、チーム内で考える必要があります。

3. shellcheck

shell の Linter として shellcheck というプロジェクトが存在します。
この Linter は、バグを起こしやすい shell の実装に対し、警告を出してくれます。スタイルガイドでは、すべての shellscript で shellcheck を導入するべきとしています。
shellcheck の具体的な使い方については、当社の note でも過去に紹介しています。よければ参考にしてください。

4. STDOUT vs STDERR

エラーメッセージについてもスタイルガイドで言及されています。
エラーメッセージは stderr (標準エラー出力)に送信されるべきとされています。こうすることにより、通常の状態とエラーが起きた際の状態を容易に区別することができます。
エラーメッセージは下記のように関数化して他のステータス情報(タイムスタンプなど)と一緒に出力するのがスタイルガイド上でお勧めされています。

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

function hoge() {
  …
}

if ! hoge; then
  err "Unable to hoge"
  exit 1
fi

5. Command Substitution

コマンド置換について、 bash では $() と `` という2つの書き方が存在します。基本的には $() を利用してください。
`` はネストさせる際にバックスラッシュを入れてエスケープさせる必要がありますが、 $() はその必要がないため、その分可読性が上がります。

# 良い例
var="$(command "$(command1)")"

# 悪い例
var="`command \`command1\``"

6. Test, [ … ], and [[ … ]]

bash には [ … ] というコマンドが存在します。if 文でよく利用されるコマンドです。
これらは test コマンドと同等の振る舞いをします。条件式を評価し真偽値を返すコマンドになります。下記のサンプルコードのように、 test, [ … ] どちらを利用しても if 文として同じ振る舞いをします。

HOGE=1
FUGA=0

# []: HOGE は FUGA 以上である
if test ${HOGE} -ge ${FUGA}; then
  echo "use test command."
fi

# test: HOGE は FUGA 以上である
if [ ${HOGE} -ge ${FUGA} ]; then
  echo "use [ … ] command."
fi

一方、[[ … ]] も test, [ … ] と同じような振る舞いをしますが、仕様が少し異なります。
[[ … ]] は変数展開時に文字列が分割されるのを防いでくれます。
下記のサンプルコードのように、変数展開時の解釈が [ … ] と [[ … ]] で異なります。

TEXT="Hello World!"

# [ "Hello" "World!" == "Hello World!" ] と解釈されエラーとなる
if [ ${TEXT} == "Hello World!" ]; then
  echo "use [ … ] command."
fi

# [[ "Hello World!" == "Hello World!" ]] と解釈される
if [[ ${TEXT} == "Hello World!" ]]; then
  echo "use [[ … ]] command."
fi

また、[[ … ]] はパス名の展開(ワイルドカードの利用)も防いでくれます。
[ … ] がパス名展開されエラーとなる一方で、 [[ … ]] は正常終了します。

touch hoge.txt fuga.txt
TEXT="*.txt"

# [ fuga.txt hoge.txt == hoge.txt ] と解釈されるためエラーとなる
if [ ${TEXT} == "hoge.txt" ]; then
  echo "use [ … ]."
fi

# [[ *.txt == hoge.txt ]] と解釈される(パス名展開がされない)
if [[ ${TEXT} == "hoge.txt" ]]; then
  echo "use [[ … ]]."
fi

さらに、 [[ … ]] は正規表現をサポートしています。
正規表現を用いた if 文を記述可能です。

HOGE="qwertyuiop"

if [[ ${HOGE} =~ ^[a-z]{1,}$ ]]; then
  echo "use [[ … ]]."
fi

# [ … ] では利用不可のため、エラー
if [ ${HOGE} =~ ^[a-z]{1,}$ ]; then
  echo "use [ … ]."
fi

以上のように、 [ … ] と比べ [[ … ]] は高機能でバグ混入の可能性も減らせるので、スタイルガイドでは [[ … ]] の利用が推奨されます。

7. Function Names

関数名は小文字とアンダースコアで構成されるようにします。
また、:: 区切りでパッケージ名を定義することもあります。(ただし、 bash の function はパッケージのサポートがされている訳ではなく、あくまで命名規則としてパッケージ名を持たせているだけです)
関数名と () は間のスペースを空けずに記述します。

function my_func() {
  …
}

function mypackage::my_func() {
  …
}

8. Variable Names

変数名も関数名同様に小文字とアンダースコアで構成されます。
また、ループの変数名は配列の変数名の単数形として命名するべきとされています。

items=( … )

for item in "${items[@]}"; do
  something_with "${item}"
done

9. Constants and Environment Variable Names

定数と環境変数名は大文字とアンダースコアで定義します。また、これらはファイルの先頭で宣言する必要があります。

定数の場合は declare -r で定義します。読み取り専用の変数となり、変数に値を代入しようとするとエラーを返却してくれるようになります。readonly と記述することも可能です。
定数かつ環境変数として宣言したい場合は declare -xr で宣言します。
declare コマンドは変数を宣言するためのコマンドです。オプションにより変数の属性を変更することができます。
-r オプションは宣言した変数を定数にするオプションです。readonly と同義です。
-x オプションは宣言した変数を環境変数をするオプションです。 export と同義のものになります。

# 定数
declare -r PATH_TO_FILES='/some/path'
readonly PATH_TO_FILES='/some/path'

# 定数かつ環境変数
declare -xr ORACLE_SID='PROD'

また、定数の中には getopts のように最初の設定で一定となるものも存在します。このような場合は条件に基づいて値を代入したあと、読み取り専用に定義するよう実装します。

OPT_FLAG='false'
while getopts 'f' flag; do
  case "${flag}" in
  f) OPT_FLAG='true' ;;
  esac
done
readonly OPT_FLAG

10. Use Local Variables

bash では関数固有の変数を local で宣言することができます。
こうすることによってグローバルな名前空間への干渉や、関数外で重要になり得る変数を誤って定義してしまうリスクを回避することができます。

local 変数に代入する値がコマンド置換によって提供される場合、変数の宣言と代入処理は異なるステートメント(≒異なる行)で定義する必要があります。
変数の宣言とコマンド置換を同じステートメントで実行すると、コマンド置換の終了コードを無視して local コマンドの終了コードを返してしまいます。
これは常に 0 を返すので、エラーハンドリングで意図した形で実装できなくなるます。

# 良い例: local 変数の宣言とコマンド置換を別の行で定義する
function good_func() {
  local my_var
  my_var="$(my_func)"
  (($? == 0)) || return

  …
}

# 悪い例: local 変数の宣言とコマンド置換を同じ行で定義する
function bad_func() {
  # ↓の行の $? は常に 0 を返します。そのため、 $? != 0 の時の処理が実行されません
  local my_var="$(my_func)"
  (($? == 0)) || return

  …
}

11. main

複数の関数を含む長さのスクリプトには、main 関数を定義する必要があります。
プログラムの開始であることがわかるよう、スクリプトの最下部の関数として main 関数を配置します。これによって、コードベースの残りの部分との一貫性が提供されるだけでなく、より多くの local 変数を定義することができます。
ファイルの最後のコメント行に main 関数の呼び出しを記述します。

function func1() {
  …
}

function func2() {
  …
}

function func3() {
  …
}

function main() {
  func1
  func2
  func3
}

main "$@"

ただし、 main 関数は線形フローである短いスクリプトの場合はやりすぎとなるため、必ず必要という訳ではありません。

最後に

今回紹介したスタイルガイドのセクションは実際の業務でも活かしやすいと思います。 shell スクリプトのコーディングをするチーム等での導入をお勧めいたします。
また、今回は全てのセクションを紹介できなかったので、興味がある方はそれらについても調べてみることをお勧めします。