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

shell

Bash なんて枯れたコマンド言語でスクリプトなんて書きたくねえ!と思う人は多いと思います。私もその一人でした。他言語には見られないシェルスクリプト特有の癖や記法が多くあり、最近の言語に慣れている人ほどつまずくポイントが多いと思います。

しかし、技術が枯れているからこその良さはあります。Bash は macOS や多くの Linux で標準シェルとして採用されており、一度身につけておくと長く使えるお得な技術です🙆

また、書き方のスタイルをいくつか工夫することで、書き味を高級言語っぽく近づけることもできます。イメージで言うとクラスを使わず関数でプログラミングする PHP や Python と近いレベルまでいけます。

Bash は良くも悪くも自由な言語です。この Tips 集が Bash の世界に必要な秩序をもたらすきっかけとなり、シェルスクリプトを書く敷居が少しでも下がればいいなと思っています。

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

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

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

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

#!/usr/bin/env bash
set -Ceuo pipefail

これにより4つのオプションがまとめて設定されます。それぞれの意味は以下のようになります。

set -C :

  • リダイレクトによるファイル上書き事故を予防します。
  • 存在するファイルを > で上書きすることができなくなります。
  • 代わりに >| を使えば上書きができるようになります。

set -e :

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

set -u :

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

set -o pipefail :

  • パイプの途中でエラーが起きた場合もエラーが発生するようになります。
  • 具体的には非0で終了した最後に実行された(右側にある)コマンドがパイプライン全体の終了ステータスになるようになります。
  • 上述の set -e と組み合わせて使うとスクリプトも終了するようになるので便利です。

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

#!/usr/bin/env -Ceuo pipefail

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

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

ロケールによる動作の違いを予防する

数多に提供されているコマンドは、シェル環境のロケール設定に依存して動作が変わるものも多くあります。そのため、シェルスクリプトの冒頭で決まったロケールを設定しておくとスクリプトの可搬性を高められます。

以下のようにすると日本語環境であってもデフォルトロケールの英語環境に戻すことができます。

export LC_ALL=C
export LANG=C

ここで設定している C ロケールは POSIX ロケールとも呼ばれ、全ての POSIX 準拠システムのデフォルトロケールとなっています。

また、デフォルトロケールにすると sort コマンドが高速になるという話は有名です。(参考: tips: sort は LANG=C で4倍速くなる - Qiita

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

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

しかし 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

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

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

また、文字列比較には [[ ]] が便利です。 比較演算子 =~ を使うと正規表現による比較もできます🙆

if [[ $x =~ ^ABC ]]; then
  echo "xはABCから始まる文字列です"
fi 

if [[ $x == "ABC" ]]; then
  echo "xはABCです"
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 に移動することが出来ます。

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

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

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

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

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

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

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

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

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

関数内で変数を定義すると デフォルトではグローバル変数として定義 されます。

ローカル変数として定義するには local hoge=123 のように、前に local を付けます。副作用を少なくするためにもなるべくローカル変数として定義するほうがいいでしょう。

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

readonly GLOBAL_VAR=global

function fn() {
  local local_var=local

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

# 関数を実行
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
  )
}

※ サブシェルには起動コストがあるので、そのまま呼び出すよりは多少遅くなるという点は留意してください。

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

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

function main() {
  # do something
}

main

高級言語の力を借りる

もはや高級言語っぽく書くという域を超えて、部分的に高級言語の力を借りるという手段もあります。

例があまりよくないかもしれませんが、YAML 内のオブジェクトのキー名の一覧を取り出したいときは以下のように書けます。(Array of object の形で定義されている YAML ファイルを想定)

cat hoge.yml | ruby -ryaml -e '
  begin
    yml = YAML::load(STDIN.read)
    exit(10) if (!yml || yml.empty?)
    # 最初の要素のキー名一覧を改行区切りで出力する
    puts yml[0].keys.join("n")
  rescue Psych::SyntaxError => e
    # パースエラー
    exit(11)
  rescue => e
    # その他エラー
    exit(12)
  end
'

無論、これをすると実行環境への依存を増やすことになるため、用法用量を守って(以下略)

言語選択としては Ruby 以外では Perl などであれば大体どこの環境でも入ってると思うのでおすすめです。(参考: コマンドラインツールとしてのperl – Qiita

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

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

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

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

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

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

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

var=123

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

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

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

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

上の記事にあるようにコマンドのあとに && : を付けると、そのコマンドがエラーであってもスクリプトの終了を回避できます。

これを利用して、渡された数値が偶数か否かを判定する関数を作成してみましょう。(コードはもっと簡潔にできますが、説明のために今は冗長に書きます。)

set -e

function is_even() {
  local input=$1

  if (($input % 2 == 0)); then
    # 偶数なので0を返す
    return 0
  else
    # 偶数ではないので非0 (エラー) を返す
    return 1
  fi
}

# && : を付けて関数を実行する
is_even 5 && :

# すると関数がエラーであってもスクリプトは終了せず、
# 終了ステータスをみてエラーハンドリングが可能になる
if [[ $? != 0 ]]; then
  echo "5は偶数ではありません"
fi

0 と 1 の真偽の扱いが他の言語と逆になっていて違和感を覚えるかもしれませんが、ここはシェルのお作法的な振る舞いになります。

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

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

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

自分のためにもヘルプは書く

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

スクリプトを使う人のためでもありますし、今後そのスクリプトをメンテナンスする自分のためでもあります。

スクリプトのパラメータは一箇所で取り出す

コマンドラインオプションのパースは煩雑な処理になりがちなので、スクリプト序盤でまとめてやってしまうといいです。コード例は長くなるので次のサンプルコードを見てください。

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

ここまでの Tips を取り入れた全体像のサンプルテンプレートです。

#!/usr/bin/env bash
set -Ceuo pipefail

# ロケールによる動作の違いを予防するためデフォルトロケールに戻す
export LC_ALL=C
export LANG=C

function usage() {
  echo "This script is ..."
  echo
  echo "Usage:"
  echo "  $(basename $BASH_SOURCE) [OPTIONS] ARG1 ARG2"
  echo
  echo "Options:"
  echo "  --debug"
  echo "    Enable debug mode."
  echo "  --env (production|staging)"
  echo "    Target environment name."
}

# 色付きの echo
function cecho() {
  local color_name=$1; shift
  local color=
  case $color_name in
    red) color=31 ;;
    green) color=32 ;;
    yellow) color=33 ;;
    blue) color=34 ;;
    cyan) color=36 ;;
    *) error_exit "An undefined color was specified." ;;
  esac
  echo -e "\033[${color}m$@\033[m"
}

# エラーメッセージを表示して終了する
function error_exit() {
  {
    cecho red "[ERROR] $1"
    echo
    usage
  } 1>&2
  exit 1
}

# 引数を入れる配列を定義する
declare -a ARGS=()

# 各スクリプトパラメータの初期値を定義する
# ここではグローバル変数の命名にアッパーケースを採用
IS_DEBUG="false"
ENV=

# コマンドラインオプションをパースする
while (($# > 0)); do
  case "$1" in
    -h | --help)
      usage
      exit 0
      ;;
    --debug)
      IS_DEBUG="true"; shift
      ;;
    --env)
      if (($# < 2)) || [[ $2 =~ ^-+ ]]; then
        # --env は引数が必須
        error_exit "Optional arguments are required -- $1"
      fi
      ENV=$2; shift 2
      ;;
    --)
      # ダブルダッシュがあったら以降は引数とみなす
      shift
      while (($# > 0)); do
        ARGS+=("$1"); shift
      done
      break
      ;;
    -*)
      error_exit "Illegal option -- '$(echo $1 | perl -pe 's/^-*//')'"
      ;;
    *)
      if [[ $1 != "" ]] && [[ ! $1 =~ ^-+ ]]; then
        ARGS+=("$1");
      fi
      shift
      ;;
  esac
done

# 必要に応じて取り出したオプション値や引数を検証する
if ((${#ARGS[@]} < 2)); then
  # 引数の数が不足しているためエラー
  error_exit "Insufficient number of script arguments."
fi

if [[ ! $ENV =~ ^(production|staging)$ ]]; then
  # 環境名が正しくないためエラー
  error_exit "Invalid argument of --env option -- $ENV"
fi

# このスクリプト自身のディレクトリに移動する
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"

# デバッグモードの場合はパラメータをダンプ
if [[ $IS_DEBUG == "true" ]]; then
  echo "Dump options:"
  echo "  ENV=${ENV}"
  echo "  IS_DEBUG=${IS_DEBUG}"
  echo
  echo "Dump arguments:"
  for ((i = 0; i < ${#ARGS[@]}; i++)); do
    echo '  $'"$(($i + 1))=${ARGS[$i]}"
  done
  echo
fi

function sub_process() {
  echo "do something"
}

function debug_process() {
  echo "do something"
}

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

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

  # 煩雑な処理を行う場合はサブ関数を呼び出す(例)
  sub_process

  # デバッグモードの場合のみ実行する処理を呼び出す(例)
  if [[ $IS_DEBUG == "true" ]]; then
    debug_process
  fi
}

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

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

東京都が配布する防災ブックの PDF を一括ダウンロードする

東京都が配布する「防災ブック」とは

東京都は発災時の対処法や日頃の備えについてわかりやすくまとめた防災ブック「東京防災」を配布しています。都民でなくとも役立つ防災情報も多くまとめられています。

本は以下の内容で構成されています。

(1) 大震災シミュレーション(地震発生の瞬間から避難、復興までをシミュレート)
(2) 今やろう 防災アクション(今すぐできる災害の備え)
(3) そのほかの災害と対策(地震以外の東京に潜む様々な災害と対策の解説)
(4) もしもマニュアル(災害発生時に役立つ知恵や工夫をイラスト付きで解説)
(5) 知っておきたい災害知識(災害に関する知識のまとめ)
(6) 漫画 TOKYO“X”DAY(かわぐちかいじ氏オリジナル漫画)

ダウンロードしておこうと思った発端のツイート

2018年8月〜9月にかけて近畿地方を中心に25年ぶりの「非常に強い」勢力の台風の上陸と、北海道全域が停電する事態になった最大震度7の地震の発生と非常に大きな災害が続きました。

そんな中でこんなツイートがありました。停電中に「東京防災」を一家に一冊おいておけばいいワンということを前に誰かが言っていたのを思い出したという内容です。これをみてダウンロードしとこうと思いました。

防災ブックの配布ページ

「防災ブック」は以下のサイトからダウンロードできます。

防災ブックの一括ダウンロード

もしインターネットが使えなくなっても見られるように全てダウンロードしておくといいと思います。ただし PDF ファイルが26個に分割された形で配布されており ダウンロードするのが面倒です。

どこかに ZIP とかでまとめてくれている人いるかもしれませんが、自分は以下のシェルコマンドで一括ダウンロードしました。良ければお試しください。

# ダウンロード用のディレクトリ作成して移動
mkdir 東京防災 && cd $_

# 配布ページのソースから PDF URL を抽出しダウンロード
curl -sL "http://www.metro.tokyo.jp/INET/OSHIRASE/2015/08/20p8l300.htm" |
  perl -nlE 's/.*?(?:(?i)href)="([^"]+)".*?(?:$|(?=(?i)href))/$1\n/xg and say' |
  perl -nlE "say if /^(http|https):.*pdf$/" |
  xargs -n1 wget

(なるべく環境に依存せず使えるよう grep ではなく perl で抽出した。最近ちょっとしたワンライナーとかもなるべく perl で書くようにしている。)

Kindle ストアでも無料配布されてます。