Bash Scriptの作法

# はじめに
チーム開発でシェルスクリプト（特にBash Script)を書く際に守るべきルールをまとめます。
シェルスクリプトのコードレビューをする際等にお役立てください。

# スクリプト名
`-`区切りの小文字英数字（いわゆるkebab-case）をつける。拡張子は`.sh`.

```
OK: deploy-server.sh
NG: deploy_server.sh
```

これは特に理由があるわけではないので`_`区切りでもよいし、チーム内に`zsh`や`fish`等の他Shell愛好家がいる場合は拡張子は`.bash`でも良い。どちらにせよチーム内では統一しておかないと混乱の元となる。

# 改行コード
LFを使用する。CRLFだと動作しないので書く際に間違うことはないが、Windows上のgitのautocrlf設定によってはご丁寧にCRLFでチェックアウトされてしまう。

避けるためには`git config --global core.autocrlf input`とするか、`.gitattributes`をレポジトリにコミットしておく。

```text:.gitattributes
*.sh text eol=lf
```

# Shebang

```!bash
#!/bin/bash
```
間違っても`#!/bin/sh`としてはいけない。

https://sechiro.hatenablog.com/entry/20120806/1344267619

# おまじない
2行目にはこれを書く

```!bash
set -euxo pipefail
```

それぞれの意味は以下の通り

- `-e`: エラーが発生したときにスクリプトを中断する。
   - 途中でエラーにしたくない場合は`set +e`で一時的に解除するか`||`で繋げる 
- `-u`: 未定義変数をエラーにする
- `-x`: 実行したコマンドを出力する
- `-o pipefail`: パイプで結合したコマンドの途中でエラーが発生したときもスクリプトを中断する

# ディレクトリの移動
3行目には原則これを書く

```bash
cd "$(dirname "$0")"
```

こうすることでスクリプト呼び出し時の作業ディレクトリに依存せず実行できる。
ただし、スクリプトが作業ディレクトリに依存する場合は変数に退避しておく。

```bash
WORKDIR=$(pwd)
cd "$(dirname "$0")"
```


# 変数宣言

## 変数宣言の右辺は`""`あるいは`$()`で囲う

```bash
MESSAGE="Hello World!"
NOW=$(date)
```

右辺にスペースが含まれない場合は`""`で囲わなくても問題ないのだが、
後の変更によってスペースが含まれるとバグる可能性があるため、常に囲っておいた方が安全。

```bash
X="hello date" # Xには"hello date"が格納される
Y=hello date   # 一時的な環境変数Yに"hello"を格納してdateコマンドを実行する。（実行後の`Y`は未定義）
```

## グローバル変数には`_`区切りの大文字英数字を使う

```bash
BRANCH_NAME="topic-foo"
```

## 関数内の変数あるいはループ変数は`_`区切りの小文字英数字を使う

```bash
LOG_FILENAME="script.log"
function hoge() {
  local item=$1
  echo "hoge is called with item = ${item}" >> "${LOG_FILENAME}"
}

for csv in *.csv; do
  hoge "${csv}"
done
```

# 変数展開
原則`${}`で囲う。例外は`$0` `$1`　`$@`　`$?`等の特殊変数。

```bash
RESULT="success"
LOG_DIR="/var/log/hoge"
ARG="$1"
echo "Result is ${RESULT}" | tee "${LOG_DIR}/result.log"
```

`${}`で囲うことで、どこまでが変数名なのか明確になる。また、変数の加工を行う場合の記法と統一感がある。

変数展開は様々な機能がある。仕様を確認すべし。

https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html

よく使うものを抜粋しておく

## 未指定またはNULLの場合にデフォルト値で埋める

```bash
GRADLE_HOME="${GRADLE_HOME:-~/.gradle}"
```

## 未指定またはNULLの場合にエラーメッセージを表示する

```bash
GITLAB_TOKEN="${GITLAB_TOKEN:?Please specify personal access token}"
```

## 文字列置換、除去
- `${var#pattern}`で`pattern`にマッチするprefixを除去
- `${var%pattern}`で`pattern`にマッチするsuffixを除去
- `%`,`#`はShortest-match, `%%`, `##`はLongest-match
- `${var/pattern/string}`で`pattern`にマッチした部分を`string`に置換

```bash
FILE="src/main/java/Example.java"
BASENAME=${FILE##*/}         # Example.java
DIRNAME=${FILE%/*}           # src/main/java
TESTDIR=${DIRNAME/main/test} # src/test/java
```

ただし、上記の例のような場合は変数置換ではなく、`basename`や`dirname`コマンドの方が可読性が高い。

# 関数

## 関数定義
以下の記法を用いる。

```bash
function 関数名() {
  処理
}
```

`function`キーワードがついていた方が関数であることが明確になる

## 関数名
関数名は`_`区切りの小文字英数字を用いる

```bash
function login_api() {
  ...
}
```

## 関数内のローカル変数は`local`をつける

```bash
function hoge() {
  local item="$1/item"
  ...
}
```

`local`をつけることで関数呼び出しによって意図せず変数が書き変わるのを防ぐことができる。

# コマンド呼び出し

## 置換の発生する引数は必ず`""`で囲む
**置換の発生する引数は必ず`""`で囲む**
**置換の発生する引数は必ず`""`で囲む**

例外はない。どんなに無害そうな変数でも囲んでおいた方が安全。

```bash
diff "test-${ID}.out" "test-${ID}.ans"
sudo chown "$(whoami)" /tmp/workdir
```

## 長いコマンドは`\`で改行を入れる

エディタの推奨幅（80文字-120文字)で表示しきれないコマンドは必ず改行すること。

```bash
curl -X POST "${API_ENDPOINT}/users/${USER_ID}/status" \
  -F "status=I'm so happy" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" 
```

## コマンド展開
`$(...)`記法を用いる。バッククオート`` `...` ``はネストが難しいので使わない。
**コマンドの引数にするときには`""`で囲うことを忘れてはならない。**

```bash
cd "$(dirname "$0")"
```

# 制御構文

## ロジックをシンプルに保つ
シェルスクリプトは基本的に一直線のプログラムであることが望ましい。
if文やループがネストするような複雑な制御構造が必要な処理はシェルスクリプトではなくPython等のスクリプト言語を用いて記述すべき。

## if文
条件の後にセミコロンを入れる記法で書く。（これはただの好み）

```bash
if 条件; then
  処理1
else
  処理2
fi
```

## while文
ほとんどの場合、入力を１行ごとに処理する場合に使う

```bash
入力生成 | while read line; do
  処理
done
```

### 追記
@Hogeyama さんから上記の書き方には処理部分で行なった変数の変更が外に伝播されないという問題を指摘いただきました。　[コメント](https://qiita.com/autotaker1984/items/bc758fcf368c1a167353#comment-37b3acac6932b3b08c65)

処理部分で変数の書き換えを行う場合は以下のようにプロセス置換を用いて渡す必要があります。

```bash
while read line; do
  処理
done < <(入力生成）
```

## for文
以下の記法を用いる。

```bash
for 変数 in 範囲; do
  処理
done
```

`範囲`はglob（`*.txt`等）か　brace expansion (`{1..5}`等）。コマンド展開（`$(...)`)の場合はwhile文で書くことを検討する。

```bash
# Discouraged (ファイルパスにスペースが含まれるとバグる）
for file in $(find . -name '*.log'); done
  ...
done

# Recommended
find . -name '*.log' | while read line; do
  ...
done
```

# スクリプトの長さ
シェルスクリプトは単体テストが難しい。
長いスクリプトを書いてしまうと一回のテストに時間がかかり、生産性が低くなる。
目安としては**100行**以内に収めるべきであり、長くても**200行**を超えたら分割しなければならない。

# ログ
シェルスクリプトの実行ログが長くなりすぎるとトラブルシューティングが難しくなる。
目安として標準出力・標準エラーは1000行以下となるようにすべき。

長すぎるログは別ファイルに書き出すようにする。

```bash
# Bad
unzip awscliv2.zip

# Good
unzip awscliv2.zip | tee unzip_awscliv2.log | tail -n 10
```

# コマンドライン引数の渡し方
シェルスクリプトでコマンドラインパーサを実装して豊富なオプションを受け付けるのは多くの場合、労力の無駄である。
コマンドラインオプションの代わりに、環境変数で受け渡したが方がシンプルで可読性も高い。

```bash
# Discouraged
./doit.sh --token abc123
# Recommended
TOKEN="abc123" ./doit.sh
```

# おわりに
これらのルールは筆者の経験に基づくルールです。
異論反論がありましたらコメントの方に指摘してもらえると助かります。