2022年09月24日 に更新

シェルスクリプトを高級言語のような書き味に近づける Tips 集

shell

Bash は言わずと知れた歴史あるコマンド言語です。テキストにコマンドの羅列を記述するだけで、手軽にシェルスクリプトとして実行することができます。

シェルスクリプトの実体はシェルコマンドの羅列に過ぎませんが、手続き型プログラミング言語にあるような制御構文も備えています。変数や条件分岐、ループ、関数などです。これらを使えばシェルスクリプトでプログラミングも可能です。

もちろん、現代の一般的なプログラミング言語と比べると機能は限られます。他の言語には見られないシェルスクリプト特有の癖や記法も数多くあり、最近の言語に慣れている人ほど、つまずくポイントが多いです。

しかし、シェルスクリプトだからこその良さもあります。Bash は現在でも多くの OS で標準シェルとして採用されており、普段使っているシェルコマンドを書くだけで動かせる手軽さは何者にも代えがたいです。一度身につけておくと長く使えるお得な技術と言えるでしょう。🙆

シェルスクリプトをただのコマンドの羅列ではなく、一つのプログラミング言語として意識して書くことで、クラスを使わずに関数でプログラミングした Python や PHP などと同等のレベルの書き味まで近づけることができます。

この記事ではシェルスクリプトで堅牢かつ見通しの良いプログラムを書くための Tips を紹介していきます。

※ この記事はシェルスクリプトで無理に頑張ることを推奨するものではありません。頑張らずに書くための工夫を紹介する記事です。作ろうとしているものが大規模になることが想定される場合は始めから Python など他の言語で書くことを検討したほうがいいでしょう。

※ 記事中には Bash の built-in 機能が多分に登場します。POSIX 互換のシェルスクリプトを目指す方にはこの記事の内容は適さないことがあります。

追記: 予想以上に「高級言語」という言葉に対しての反応が多く、釣りタイトルっぽくなってしまったことを反省しています。シェルが人間向きに作られたインターフェースということを考えるとシェルスクリプトも十分高級言語と言えると思います。

Lint ツールを使う

まず最初に ShellCheck などの Linter を入れることをおすすめします。

静的解析によりシェルスクリプトの問題点を分析し、より良い書き方を提案してくれます。様々な罠があることで知られるシェルスクリプトですが、ツールに指摘された問題点を直していくだけでベストプラクティスとされる書き方を反映でき、安心してスクリプトを書くことができるようになります。

詳しくは以下の記事にまとめているので参照してみてください。

便利なシェルオプションを設定する

set コマンドで設定できるシェルオプションには、取りあえず設定しとけレベルで便利なオプションがいくつかあります。

安全なスクリプトを書くという意味でも適切に設定し、その意味を知っておいたほうがいいです。私はシェルスクリプトの書き出しは大体以下のようにしています。

#!/bin/bash
set -euo pipefail

set -euo pipefail とすることで、以下の3つのシェルオプションがまとめて設定されます。

set -e :

  • スクリプトの実行中にエラーが発生すると、そこでスクリプトが終了するようになります。
    • ここでの「エラー」とは実行したコマンドの終了ステータス(exit code)が非0で終了することを指します。
  • コマンド実行の度にエラーハンドリングを書くことを 横着 省略できるようになります。
  • これを設定しておかないと途中でエラーが起きてもそのままスクリプトが突き進んでいくため、予期せぬ事故に繋がることもあり怖いです。
  • 自分で漏れずにエラーハンドリングを書ける自信がなければ設定しておくべきです。

set -u :

  • 未定義の変数を使用すると、そこでスクリプトが終了するようになります。
  • うっかり変数名を typo したときに気づきやすくなります。

set -o pipefail :

  • パイプで繋げたコマンドのいずれかがエラーだった場合もエラーとして扱えるようになります。
  • 具体的には非0で終了した最後に実行されたコマンド(一番右の失敗コマンド)がパイプライン全体の終了ステータスになるようになります。
  • 上述の set -e と組み合わせて使うとスクリプトも終了するようになるので便利です。
# デフォルトでは常に一番右にあるコマンドの終了ステータスになります
(exit 1) | (exit 2) | (exit 0)
echo $? # => 0

# pipefail を設定しておくと一番右側の失敗コマンドの終了ステータスになります
set -o pipefail
(exit 1) | (exit 2) | (exit 0)
echo $? # => 2

また、シェルオプションは以下のように一行目のシバンで設定することも可能です。

#!/bin/bash -euo pipefail

この方がシンプルで一見良さそうに見えますが、この方法で指定したオプションはスクリプトに実行権限を付け直接実行しないと適用されません🙅

例えばデバッグの為に一時的に -x オプションを付けて実行したいからといって bash -x ./hoge.sh などと実行すると -x は適用されますが -euo pipefail に関しては 適用されない ということです。確実に設定したい場合はシバンの直後あたりで set コマンドで設定する方がいいでしょう。

Bash のパワフルな判定式を活用する

シェルスクリプトを書き始めて難関になりがちなのが条件判定(IF文)の書き方です。括弧の書き方による違いだったり、謎のオプションによる判定方法が色々あって訳わからん!となります。

しかし Bash には [[ ]](( )) といった判定式を一般的なプログラミング言語っぽくスマートに書ける強力な拡張機能が備わっています。これらを活用すれば判定式を書くのはそんなに難しくないです🙆

まず Bash 固有の機能を使わずプレーンに if 文を書こうとすると以下のようになります。不等号を使った判定に慣れていると読みにくいと思います。

if [ $x -gt 2 ] && [ $x -le 5 ]; then
  echo "$x は 2 より大きいかつ 5 以下です"
fi

Bash 固有の機能 (( )) を使うと、数値の比較演算子として不等号が使えるようになります🙆

# この括弧の中は例外的に $ を付けなくても変数参照が可能
if (( x > 2 && x <= 5 )); then
  echo "$x は 2 より大きいかつ 5 以下です"
fi

元々シェルスクリプトにはデータ型の概念がなく、全ての変数は文字列です1。数値と呼ばれるものも数値のみで構成される文字列に過ぎません。

(( )) の括弧の中では文字列変数を数値とみなし、様々な算術演算をすることができます。数値の比較以外にも基本的な四則演算 (+, -, *, /)、インクリメント (++)、デクリメント (--) などの演算子が使えるようになります。

また、文字列比較には [[ ]] が便利です。 この括弧の中では様々な言語でおなじみの等価比較演算子 == の他に、正規表現マッチによる比較演算子 =~ も使えます🙆

x='ABC'

# 完全一致
if [[ $x == 'ABC' ]]; then
  echo '$x は ABC です'
fi 

# 正規表現による先頭一致
if [[ $x =~ ^ABC ]]; then
  echo '$x は ABC から始まる文字列です'
fi 

# 正規表現による部分一致
if [[ $x =~ B ]]; then
  echo '$x は B が含まれる文字列です'
fi

他に [ ] というのがありますが、上述の [[ ]][ ] を拡張して用意された機能なので、Bash の世界線にいるうちは多機能な [[ ]] の方を使うといいでしょう。(参考: test と [ と [[ コマンドの違い – 拡張 POSIX シェルスクリプト Advent Calendar 2013 – ダメ出し Blog

また、コマンドの終了ステータスが 0 か否かを判定したいだけであれば括弧なしで判定できます。

# grep は検索結果が見つかったときだけ 0 を返すコマンド
# --quiet は標準出力に何も書き出さないオプション
if cat hoge.txt | grep --quiet "Apple"; then
  echo "hoge.txtにはAppleが含まれた行がある"
fi

# 条件の反転は ! をつける
if ! cat hoge.txt | grep --quiet "Apple"; then
  echo "hoge.txtにはAppleが含まれた行がない"
fi

判定方法は色々とありますが、それぞれに向き不向きがあります。大雑把にまとめると以下のような使い分けになるでしょう。

  • 数値判定するとき: (( ))
  • 文字列判定するとき: [[ ]]
  • 終了ステータスを判定するとき: 括弧不要

行が長くなったら適度に改行する

シェルスクリプトはその性質上、一行がとても長くなりやすいです。コマンドラインオプションやパイプラインを駆使して複雑な処理していくことになるからです。

しかし、改行はそこまでに書かれているコマンドを実行するための文法上の目印でもあるので、どこでも自由にできるわけではありません。いくつかのルールに則ればコマンドの途中でも改行することができます。

コマンドラインオプションの途中で改行する

AWS CLI などがその最たる例ですが、とにかくコマンドラインオプションが長くなりがちです。こんな感じです🙅

aws --region ap-northeast-1 cloudformation deploy --template-file ./packaged-template.yaml --stack-name example-stack --capabilities CAPABILITY_IAM --parameter-overrides Environment=development EnableDebugLog=true

長くなったら \ (バックスラッシュ) を行末に書くとコマンドの途中でも自由に改行することができます🙆

aws --region ap-northeast-1 cloudformation deploy \
  --template-file ./packaged-template.yaml \
  --stack-name example-stack \
  --capabilities CAPABILITY_IAM \
  --parameter-overrides \
    Environment=development \
    EnableDebugLog=true

パイプラインの途中で改行する

シェルスクリプトには \ (バックスラッシュ) を書かなくても、行末に書くと改行できる記号がいくつかあります。その一つとして | (パイプ) があります。

パイプラインは他の人が見たときに呪文のように見えてしまいがちですが、改行することによりパイプの途中でコメントを書く余地も生まれます🙆

cat access.log |
  # IPアドレスのカラムを取得する
  awk '{print $5}' |
  # 100行目以降のみを集計対象とする
  tail +100 |
  # IPアドレスごとのアクセス数のランキングを集計する
  sort | uniq -c | sort -nr

他に行末に書くと改行可能な演算子として && , || , & などがあります。

どのディレクトリからスクリプトが起動されても動くようにしておく

シェルスクリプトはすべてカレントディレクトリを基準にコマンドが処理されていきます。そのため今どこのフォルダにいるのか常に意識することになります。

しかし、シェルスクリプトは起動元のカレントディレクトリが引き継がれた状態で開始されます。そのため相対パスで処理するような処理があった場合、起動元のディレクトリ位置によって上手く動かなくなってしまいます🙅

スクリプトの序盤で決まったディレクトリに移動するようにしておけばこの問題は回避できます🙆

以下のようにすると /path/to/hoge.sh に置かれているスクリプトの場合 /path/to に移動することが出来ます。

# このスクリプト自身が置かれているディレクトリに移動する
THIS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$THIS_SCRIPT_DIR"

位置パラメータは意味のある命名の変数に取り出す

関数の引数は位置パラメータと呼ばれる連番の変数 ($1, $2…) から参照することが出来ますが、位置パラメータをローカル変数に取り出しておくだけで関数内のコードの可読性が向上します🙆

function do_something() {
  # まず最初に引数を意味のある命名の変数に取り出す
  local target_dir action
  target_dir=$1
  action=$2
}

do_something "./dir" "hoge"

スクリプトの後始末はハンドラーで行う

trap を使うとスクリプト終了時に実行したいコマンドを登録しておくことができます。後始末はハンドラー関数の方に任せることでメインロジックを簡潔にできる場合があります🙆

# スクリプト終了時に呼び出される関数
function on_exit() {
  local exit_code=$1
  # 冪等性を担保した書き方にしておくと安心です。
  # (以下は一時ファイルがあっても無くても問題のない書き方)
  rm -f "tmp_file"
  exit $exit_code
}

## ハンドラー関数を登録する
trap 'on_exit $?' EXIT

関数内の変数をローカル変数にする

関数内で変数を定義すると デフォルトではグローバル変数として定義 されます。副作用を少なくするためにもなるべくローカル変数として定義するほうがいいでしょう。

ローカル変数として定義するには local hoge=123 のように、前に local を付けます。しかし、代入に失敗する可能性のある場合は 安全性の観点でローカル変数の宣言と代入は分けて書くことをおすすめ します。local は一つの組み込みコマンドなので、もし代入に失敗してもコマンド全体では成功扱いになってしまうからです🙅

function fn() {
  # 問題のある例:
  local hoge=$(false)
  # $? で直前に実行したコマンドの終了ステータスを参照できる
  echo $? # => エラーが握りつぶされ 0 が返る!

  # 問題のない例:
  local hoge2
  hoge2=$(false)
  echo $? # => 正しく 1 が返る
}

グローバル変数との見分けが付きにくい場合、命名ルールによって分けるのも手です。私はグローバル変数はアッパーケース、ローカル変数はスネークケースといった命名にしています。

readonly GLOBAL_VAR="グローバル変数です"

function fn() {
  local local_var
  local_var="ローカル変数です"

  # グローバルスコープの変数は関数内からも参照可能
  echo $GLOBAL_VAR # => グローバル変数です
}

# 関数を実行
fn

# ローカル変数は関数外からは参照できない
echo $local_var # => 未定義

サブシェルを活用して部分的にスコープを切る

丸括弧 ( ) の中に書いたコマンドはサブシェル上で実行されます。サブシェル内の副作用は親シェルには影響しないという性質を利用すると部分的にスコープを切ることが出来ます。

例えば一時的にカレントディレクトリを移動して何か処理をしたいとしましょう。

# 親シェル上でカレントディレクトリを設定する
cd /path/to/base
# 親シェル上で適当に変数を定義しておく
hoge=123

(
  # サブシェル上で一時的にディレクトリを移動する
  cd tmp
  pwd # => /path/to/base/tmp
  # 変数を上書きする
  hoge=456
)

# サブシェル内の副作用は親シェルには影響しない
pwd        # => /path/to/base
echo $hoge # => 123

無事に副作用をサブシェル内に閉じ込めることができました🙆

面白い使い方として、関数でさえもスコープの中に閉じ込めることができます。

(
  # サブシェル内で関数を定義
  function fn() {
    echo 123
  }

  # サブシェル内で関数を実行
  fn # => 123
)

# 親シェルで関数を実行
fn # => 関数未定義のためエラー

また、関数の中でも同じように ( ) を書くことが出来ます。シェルスクリプトにおける関数はスコープの概念が希薄ですが、サブシェルで関数スコープを強化するような使い方もできます。

function fn() {
  (
    # do something
  )
}

※ サブシェルには起動コストがあるので、そのまま呼び出すよりは多少遅くなるという点は留意してください。
※ サブシェルの中では exit の挙動が変わる点に気をつけてください。サブシェルの中で exit をしてもそのサブシェルが終了されるだけで、スクリプトは終了されません。

オプション引数やスイッチ引数よりも環境変数を活用する

シェルスクリプトを作っていると動作を切り替えるためのオプション引数を作りたくなることがあると思います。例えば強制的に処理するための --force オプションだったり、あるいは何らかの値を受け取る --date 20220515 のようなオプションです。

普段様々なコマンドに慣れ親しんでいる人にとってこういったオプション引数を作りたいと思うのは自然な考えではあるのですが、シェルスクリプトにおいては複雑なオプション引数をパースする処理を書くのは難易度が高く面倒 なので、あまりおすすめできません。シェルスクリプトに慣れてない人が複雑な引数パースの処理を見るときっと何かの暗号のように見えるでしょう。

オプション引数を作らずとも環境変数を活用すれば同じことがもっと簡単に実現できます。面倒なパース処理を書く必要もありません。

--date--force のようなオプション引数を環境変数を活用する形に置き換えると、スクリプトの起動方法は次のように変わります。

# before
$ any_script.sh --date 20220515 --force

# after
$ DATE=20220515 FORCE=true any_script.sh

環境変数で指定した日付をスクリプトの中で使いたい場合、$DATE とするだけで参照できます。

また、位置パラメータ ($1, $2…) で指定するようなものも環境変数で受け渡すようことで、自然と名前付きの変数で取り扱えるようになり可読性が向上します。引数が複雑化してきた場合は環境変数の活用を検討すると良いでしょう。

メインロジックを関数化する

グローバルスコープの汚染を防ぐことができます。また、メインロジックのテストがしやすくなるといったメリットがあります。

function main() {
  # do something
}

main

コマンドなのか構文なのかを意識してみる

シェルスクリプトにはあたかも構文かのように擬態したコマンドが多数存在しています。これを知っておくと文法上の細かいルールの理解が深まります。

例えば条件判定に使用する [ は一つのコマンドです。試しに which [ としてみると /bin/[ と表示され、本当にコマンドであることが分かります。

以下の if 文は 赤文字がコマンド緑文字がコマンド引数、その他の黒文字がシェル構文ということになります。

  if [ $var -eq “abc” ]; then
    # do something
  fi

つまり [ コマンドの終了ステータスが 0 か否かで then 以降が実行されるかどうか決まるということです。そのため [ の前後には スペースがないと [ をコマンドとして認識できずに エラーとなってしまいます。

逆に変数定義で使う = はコマンドではありません。= の前後にスペースを入れると 変数名がコマンドとして認識されてしまう ので、スペースは入れられません。

var=123

このあたりを知っておくと、なぜ文法上でスペース有無のルールがあちこちで違うのか、理解が深まることと思います。

適切にエラーハンドリングする

set -e しているとエラー時にスクリプトが終了するようになり便利ですが、個別にエラーハンドリングをしたい時は工夫して行う必要が出てきます。

これについては シェルスクリプトでset -eしているときに処理を中断せずエラーを扱う方法 の記事にまとめましたので参考にしてみてください。

また、シェルスクリプトには例外機構はありません。trap を使うことでジャイアント try-catch のようなことは出来なくもないですが、コマンドや関数レベルのエラーハンドリングについては終了ステータスを見て一つ一つ地道にやっていく必要があります。

シェルスクリプトで難しいことの一つがパイプラインのエラーハンドリングです。しかし各パイプコマンドの終了ステータスを得るためにパイプラインを分解したりする必要はありません。各パイプコマンドの終了ステータスは $PIPESTATUS の配列変数にまとめて入るので、これを参照することで個別にエラーハンドリングも可能です2

$ false | false | true
$ echo "${PIPESTATUS[0]} ${PIPESTATUS[1]} ${PIPESTATUS[2]}"
1 1 0

自分のためにもドキュメントやヘルプは書く

書きましょう。

CLI ツールの仕様が分からないときに --help を付けて実行すると Usage が見られるということは、シェルを扱う人たち共通のプロトコルになっています。

とは言えシェルスクリプトに慣れてないと --help オプションを作って引数をパースする処理を書くこと自体が難儀だと思うので、スクリプトの上部にコメントでそのスクリプトの概要や使い方を書いておくだけでも十分でしょう。

何らかの形で使い方の情報を残しておくのはスクリプトを使う人の為でもありますし、今後そのスクリプトをメンテナンスする自身の為でもあります。

まとめ (全体像のサンプル)

ここまでの Tips の内容を取り入れた全体像のサンプルスクリプトです。
スクリプトの入り口は一番下にある main 関数なので、そこから上に読んでいくと読みやすいと思います。

#!/bin/bash

# 動画を CDN にアップロードし公開するスクリプトです。
#
# Usage:
#   MOVIE_PATH="/path/to/movie.mp4" \
#   COMPRESS_LEVEL="5" \
#   upload_movie_to_cdn.sh
#
# Parameters (environment variable):
# - MOVIE_PATH: アップロードする動画のパスを指定する
# - COMPRESS_LEVEL: 動画の圧縮レベルを指定する

# スクリプトを安全に実行するためのおまじない
set -euo pipefail

# このスクリプト自身のディレクトリに移動する
# 実行時のカレントディレクトリに依存せずスクリプトを使えるようにする為
THIS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$THIS_SCRIPT_DIR"

#######################################
# Utility functions
#######################################

# 色付きの echo
function colored_echo() {
  local color_name color
  color_name="$1"
  shift
  case $color_name in
    red) color=31 ;;
    green) color=32 ;;
    yellow) color=33 ;;
    *) error_exit "An undefined color was specified." ;;
  esac
  printf "\033[${color}m%b\033[m\n" "$*"
}

# 赤文字でエラーメッセージを表示してエラー終了する
function error_exit() {
  local message=$1
  colored_echo red "[ERROR] ${message}"
  exit 1
}

#######################################
# App functions
#######################################

# スクリプト終了時に呼ばれるハンドラー関数
function on_exit() {
  local exit_code=$1
  # 一時ファイルの後始末などを行う場合はここに書く
  exit "$exit_code"
}

# 入力パラメータのバリデーション(例)
function validate_parameters() {
  if ! [[ -e $MOVIE_PATH ]]; then
    error_exit "MOVIE_PATH に指定されたファイルが見つかりません。"
  fi

  if ((COMPRESS_LEVEL >= 1 && COMPRESS_LEVEL <= 9)); then
    error_exit "COMPRESS_LEVEL は 1 〜 9 の数値を指定する必要があります。"
  fi
}

# 使用するリポジトリを初期化する
function prepare_repository() {
  : "do something"
}

# 動画を圧縮する
function compress_movie() {
  : "do something"
}

# 動画を CDN にアップロードする(例)
function upload_movie_to_cdn() {
  local movie_published_url
  movie_published_url=$(any_upload_command "$MOVIE_PATH")

  # 通常のプログラミング言語における戻り値は標準出力に書き出し、
  # それを呼び出し側でコマンド置換 (Command Substitution) で受け取ることで実現する
  echo "$movie_published_url"
}

# Slack などに完了を通知する
function notify_to_chat() {
  : "do something"
}

function main() {
  local movie_published_url

  # スクリプト終了時のハンドラーを登録
  trap 'on_exit $?' EXIT

  validate_parameters

  # サブ関数を呼び出してスクリプトの本来の目的の処理を行う
  # 一つの関数は大きくしすぎず、小さな関数を順番に呼び出して目的の処理を遂行していく
  # 関数呼び出しの羅列を見るだけでこのスクリプトが何をするか想像できるような命名にする
  prepare_repository
  compress_movie
  movie_published_url=$(upload_image_to_cdn)
  notify_to_chat "$movie_published_url"
}

# グローバルスコープにはメインロジックをべた書きしない
# メイン関数を呼び出しそちらで処理をする
main

シェルスクリプトは沼

長くなりましたが以上です。シェルスクリプトはたのしい。

改訂履歴

この記事を2018年に書いてから長いこと経ち、個人的にシェルスクリプトを書き続ける中で考えが変わってきたところもあるので、記事の内容はたまにアップデートしています。

2020/5:

  • Lint ツール ShellCheck についての記述を追加しました。
  • 「ロケールによる動作の違いを予防する」の項目を削除しました。
    • LC_ALL=C による副作用も理解して使う必要があり、気軽に推奨できるものではないと考えた為。

2022/5:

  • 「スクリプトのパラメータは一箇所で取り出す」の項目を削除し、代わりに「オプション引数やスイッチ引数よりも環境変数を活用する」の項目を追加しました。
    • シェルスクリプトで複雑なオプションや引数のパース処理を書くのはコストが高く、書かずに済むならそれに越したことはないと考えるようになった為。
  • set -C を推奨する記述を削除しました。
    • 個人的にここ数年シェルスクリプトを書いてきた中で特段このオプションに救われた経験が無かった為。
  • まとめの最終的なスクリプト例をイメージしやすいものに差し替えました。
  • 「高級言語の力を借りる」の項目を削除しました。

脚注

  1. Bash では配列型の他、連想配列型も提供されています。連想配列型が追加されたのは Bash 4.2 からです。
  2. set -e による errexit オプションは無効化しておく必要があります。

3 thoughts on “シェルスクリプトを高級言語のような書き味に近づける Tips 集

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

計算問題(認証) *