シェルスクリプトの処理境界が鮮明になる「名前付きブロック記法」なるものを考えてみた

シェルスクリプトは長くなると処理の境界が不鮮明になりがち。
コメントで処理の境界を表現する工夫はよく見かけるが、もっと良い方法はないか考えてみた。
:コマンド、&&演算子、複合コマンド(){}を組み合わせて書くと、処理の境界線がはっきりする。

bash:[Before]コメントで処理の境界を表現したよくあるパタン
# [Before]コメントで処理の境界を表現したよくあるパタン
# Install libraries
command
command
command

# Build app
command
command

# Deploy app to server
command
command
bash:[After]考えてみた方法で改善してみたパタン
# [After]考えてみた方法で改善してみたパタン
: "Install libraries" && {
    command
    command
    command
}

: "Build app" && {
    command
    command
}


: "Deploy app to server" && {
    command
    command
}

シェルスクリプトが長くなるとよく起こる問題

シェルスクリプトは作業を自動化するのに便利なツールですが、記述するタスクが増えるにつれて可読性が下がってしまいます。それでも読みやすさを維持するために、様々な工夫をされているのではないでしょうか。

コメントも工夫の1つです。処理の境界ごとに宣言的なコメントを書いている人も多いとおもいます。たとえば、リリースを自動化するスクリプトなら、おおまかに次のような構成になっているのではないでしょうか?

bash
# Install libraries
command
command
command

# Run tests
command

# Build app
command
command

# Deploy app to server
command
command

# Notify result to Slack channel
command

コメントの部分は一定の粒度の処理を宣言していて、コマンドの部分はその具体的な実装になっているパターンです。実装の部分は、if分岐があったりと実際はもっと複雑なコードになっていると思います。

シェルスクリプトが長くなってくると、どのコマンドからどのコマンドまでがどの処理なのか一見分かりにくくなる問題が出てきます。1

名前付きブロック記法

Bashで処理の境界線をはっきり表現できる名前付きブロック記法2というものを考えてみました。

処理の境界線を表現する方法として真っ先に思いついたのが、{}を使って処理のまとまりを複合コマンドとして表現する方法です。例えば次のように:

bash
#!/bin/bash

# Install libraries
{
    command
    command
    command
}

# Run tests
{
    command
}

# Build app
{
    command
    command
}

# Deploy app to server
{
    command
    command
}

# Notify result to Slack channel
{
    command
}

これだけでも処理がグルーピングされ、だいぶ分かりやすくなります。しかし、これだけだと、コメントとブロックの関係性があまり表現できてない感じがします。そこで、:コマンドと&&で説明文と処理を強く関連付けした形に表現しなおしてみます。

bash
#!/bin/bash

: "Install libraries" && {
    command
    command
    command
}

: "Run tests" && {
    command
}

: "Build app" && {
    command
    command
}

: "Deploy app to server" && {
    command
    command
}

: "Notify result to Slack channel" && {
    command
}

いかがでしょうか?処理の説明と処理の内容が、コメントと比べてなんとなく関連しているように見えませんか?また、シンタクスハイライトで説明文がコメントよりも目立っていて、見出し感も出ていますね。

処理の境界をコメントで表現する方法と、名前付きブロック記法を一般化すると次のようになります。

bash:コメント記法
# 処理名
処理...
bash:名前付きブロック記法
: "処理名" && {
  処理...
}

名前付きブロック記法の6つのメリット

コメントで処理の境界線を表現するのと比べて、名前付きブロック記法には次のメリットがあると思いました。

  1. 処理の境界線がはっきりする
  2. ネストできる
  3. Bash標準の機能で書ける
  4. デバッグしやすい
  5. エディタで折りたためる
  6. 一時的に処理を無効にするのも容易

メリット1 処理の境界線がはっきりする

ブレースで処理がグルーピングされ、インデントもつくため、コードを眺めたときに処理の境界を把握しやすくなります。

メリット2 ネストできる

複合コマンドはネストすることができるため、サブ処理をネストで表現することができます。

bash
: "setup database" && {
    : "create database" && {
        command
        command
    }
    : "migrate database schema" && {
        command
        command
    }
    : "store dummy data" && {
        command
        command
    }
}

メリット3 Bash標準の機能で書ける

記法の構成要素については後述しますが、使用するコードはすべてBashの標準機能です。必要な外部ライブラリなしに記述できるのが特徴です。

メリット4 デバッグしやすい

Bashは-xオプションで実行コマンドを出力することができます。
コメントは-xオプションでデバッグすることはできませんが、:はコマンドの一種なので、-xオプションで処理名を表示することができデバッグがはかどります。

bash
#!/bin/bash

set -x # デバッグオプション

: "Install libraries" && {
    command
    command
    command
}

: "Run tests" && {
    command
}

: "Build app" && {
    command
    command
}

: "Deploy app to server" && {
    command
    command
}

: "Notify result to Slack channel" && {
    command
}
text:出力結果
+ : 'Install libraries'
+ command
+ command
+ command
+ : 'Run tests'
+ command
+ : 'Build app'
+ command
+ command
+ : 'Deploy app to server'
+ command
+ command
+ : 'Notify result to Slack channel'
+ command

メリット5 エディタで折りたためる

エディタで折りたたみ機能を使う人にとっては1つのメリットになるかと思います。

Atomエディタでの折りたたみ

メリット6 一時的に処理を無効にするのも容易

処理を一時的に無効にしたい場合は、&&||に置き換えるだけで対応できます。

bash
#!/bin/bash -eux

: "Install libraries" || {
    command
    command
    command
}

名前付きブロック記法の構成要素の説明

名前付きブロック記法の「:{}って何だろう?」と疑問を持たれた方もいると思うので、構成要素についても簡単に説明しておきます。

:コマンド

:コマンドはBashでは何もしないコマンドです。そして、常にsuccessで終わります。

bash
: "do nothing"

コメントとの違いは実行されるコマンドという点です。なので、サブシェルや変数を扱うこともできます。

bash
#!/bin/bash
set -x
: "current directory is $PWD"
text:出力結果
+ : 'current directory is /Users/suin/Desktop'

名前付きブロック記法では、:コマンドを説明文として活用しています。

&&演算子

&&演算子は前のコマンドが成功した場合、次のコマンドを実行する演算子です。

bash
#!/bin/bash
true && echo 1  # 1は出力される
false && echo 2 # 2は出力されない

:コマンドは常にsuccessで終わるので、&&演算子以降のコマンドは必ず実行されるというわけです。

複合コマンド{}

Bashでは{}()で複数のコマンドを1つのコマンドとして複合することができます。

bash
#!/bin/bash

{
    echo 1
    echo 2
    echo 3
} > /tmp/output

cat /tmp/output
text:実行結果
1
2
3

{}は現在のシェルで、()はサブシェルで実行される違いがあります。

bash
#!/bin/bash

foo=1
(
    foo=2
    echo $foo # 2が出力される。サブシェル内なので親シェルのfooは書き換えない
)
echo $foo # 1が出力される

{
    foo=2 # 現行シェルで実行されるのでfooが書き換わる
}
echo $foo # 2

まとめ

  • シェルスクリプトは長くなると処理の境界が不鮮明になりがち。
  • コメントで処理の境界を表現する工夫はよく見かけるが、もっと良い方法はないか考えてみた。
  • :コマンド、&&演算子、複合コマンド(){}を組み合わせて書くと、処理の境界線がはっきりする。

  1. シェルスクリプトがあまりにも長い場合は、関数やファイルを分割することで、処理を分ける方法も考えられます。ここでは、1ファイル内での処理の境界線を明確にする方法のみを考えるため、関数やファイル分割する方法については割愛します。 

  2. この呼称は僕が考えたもので、もしかしたら既に流通している呼称があるかもしれません。ご存知の方はコメントいただければ幸いです。