15
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

中~大規模シェルスクリプトのためのメンテナンス性の高いディレクトリ構造

Last updated at Posted at 2021-07-11

シェルスクリプトで高い移植性と生産性を両立させるシリーズ

タイトル
第一弾 なぜシェルスクリプトはPOSIXに準拠しても環境依存が激しいのか?
第二弾 高い移植性と生産性を両立するソフトウェアを書くのに必要な知識と考え方
【第三弾】 中〜大規模シェルスクリプトのためのメンテナンス性の高いディレクトリ構造
第四弾 シェルスクリプトの互換性と生産性の問題を解決する高度なプログラミング技術
第五弾 (タイトル未定)

はじめに

シェルスクリプトである程度の規模のプロジェクトを作ろうとした時、メンテナンス性を向上させるために複数のファイルに分割することを考えると思います。しかし具体的なディレクトリ構造を解説した記事はあまりありません。この記事では複数のファイルで構成されるシェルスクリプトプロジェクトのためのディレクトリ構造を解説します。

この記事で紹介するディレクトリ構造は FHS - Filesystem Hierarchy Standard に準拠しており、/usr または /usr/local ディレクトリ以下にインストールしやすい形にしています。

1. 共通ライブラリ

例えばプロジェクト(project)の中に複数のコマンド(prog1prog2)があり、共通処理が common.sh で定義されているものとします。このような場合は次のようなディレクトリ構造にします。(コマンドが一つだけでもメンテナンス性やテスト容易性や再利用のためにライブラリファイルを使うことが良い設計です。)

project
├── bin
│    ├── prog1
│    └── prog2
└── lib/project
      └── common.sh
bin/prog1
#!/bin/sh
set -eu
basedir="$(cd -- "$(dirname -- "$0")/.." && pwd)"
libdir="$basedir/lib/project"

. "$libdir/common.sh"
func # common.sh で定義されている

コマンドが bin ディレクトリ以下に直接配置されているのに対してライブラリが lib/project 以下となっている理由は /usr ディレクトリにインストールしたときに bin ディレクトリのコマンドは PATH を通すことでコマンド名だけで実行できるようにするものに対して、ライブラリは project ディレクトリがないと他のプロジェクトと名前がかぶってしまう可能性があるからです。(もちろんコマンドも既存のコマンドとかぶらない名前にする必要があります。)

補足ですが、実行可能ファイル名は prog1 のように拡張子なしにするのがベストです。なぜならコマンドは必ずしもシェルスクリプトで実装する必要はないからです。もし実装を他の言語に変更した場合にそれだけで拡張子が変わってしまうのはおかしな話です。例えば Debian では which コマンドはシェルスクリプトで実装されていますが macOS では バイナリ形式です。しかしどの言語で実装されているかなんて気にしませんよね?実装を隠蔽するために実行可能ファイルには拡張子をつけません。(もちろん個人的に作ったシェルスクリプトであれば自由につけても構いません。)一方ライブラリに関しては拡張子をつけます。ライブラリは言語ごとに異なるものであるため、どの言語用のライブラリであるかがわかることは重要なことだからです。例えば gettext という多言語化ライブラリがありますが、シェルスクリプト用は gettext.sh です。同様に Python では gettext.py、Ruby では gettext.rb という名前が使用されているようです。

2. 単一のコマンドで受け取る

上記のコードの問題点は prog1prog2 の両方に同じコードが含まれてしまうというところです。行数は少ないのでこれぐらい許容範囲かもしれませんが例えば共通の初期化処理を行いたいなどの理由で共通のコードが増える場合があります。メンテナンス性を上げるために重複コードを減らすのは一般的な考え方です。そこで全てのコマンドを一つのコマンド(下記の libexec/prog)で受け取ってコマンド名で区別することで処理を分岐させるようにします。

ウェブアプリケーションでは単一の index.php で全てのアクセスを受け取りルーティングによってそれぞれのモジュールに処理を分岐させる PoEAA のフロントコントローラーパターンがよく使われます。これはそのシェルスクリプト版実装といったところです。

まず bin 以下のコマンドをシンボリックリンクにして全てのコマンドから共通の libexec/project/prog を呼び出します。そして libexec/project/prog はコマンド名を元に適切なモジュールを呼び出します。ちなみに libexec は内部で使用する補助コマンドを配置するために使われるディレクトリです。

project
├── bin
│    ├── prog1 -> ../libexec/project/prog
│    └── prog2 -> ../libexec/project/prog
├── lib/project
│    ├── prog1.sh
│    ├── prog2.sh
│    └── common.sh
└── libexec/project
      └── prog
libexec/project/prog
#!/bin/sh
set -eu
BASEDIR="$(cd -- "$(dirname -- "$0")/.." && pwd)"
LIBDIR="$BASEDIR/lib/project"
. "$LIBDIR/${0##*/}.sh"
lib/project/prog1.sh
. "$LIBDIR/common.sh"
func # common.sh で定義されている

前のコードと違い BASEDIRLIBDIR を大文字にしているのはファイルをまたがって使用することを想定している変数だからです。

3. サブコマンドにする

もう一つの解決方法は prog1prog2 のような個別のスクリプトにするのではなくサブコマンドとして実装する方法です。サブコマンドを利用すると受け取るスクリプトが一つになるので前項のフロントコントローラーパターンを使わなくても自然な形で実装することが出来ます。一つのプロジェクトで多数のコマンドを提供する場合、既存のコマンドとかぶらないようにするのが大変で覚えにくいコマンドが多数出来てしまうことになるので、個別のコマンドではなくサブコマンドを使って実装することを検討した方が良いでしょう。

project
├── bin
│    └── prog
└── lib/project
      ├── cmd1.sh
      ├── cmd2.sh
      └── common.sh
bin/prog
#!/bin/sh
set -eu
BASEDIR="$(cd -- "$(dirname -- "$0")/.." && pwd)"
LIBDIR="$BASEDIR/lib/project"
. "$LIBDIR/$1.sh"
lib/project/cmd1.sh
. "$LIBDIR/common.sh"
func # common.sh で定義されている

4. サブコマンドを補助コマンドにする

サブコマンドを補助コマンドにする理由はサブコマンドの独立性を上げるためです。それにより拡張性やメンテナンス性が上がります。デメリットはサブコマンドが独立したプログラムになるためシェルに関する初期化処理(シェルオプションの設定など)を prog にまとめることができなくなることです。prog では環境変数に関する初期化処理とサブコマンドまでのオプションの解析処理だけを行うようにします。

またサブコマンドを補助コマンドにすることでシェルスクリプト以外で記述することも可能となります。プラグインのような仕組みを追加すればユーザーが独自のサブコマンドを定義することも可能になります。例えば git や docker はそのような仕組みを備えています。補助コマンドには prog-cmd1 のようにサブコマンド名にコマンド名をプリフィックスとしてつけた名前がよく使われます。

project
├── bin
│    └── prog
├── lib/project
│    └── common.sh
└── libexec/project
      ├── prog-cmd1
      └── prog-cmd2
bin/prog
#!/bin/sh
set -eu
export BASEDIR="$(cd -- "$(dirname -- "$0")/.." && pwd)"
export LIBDIR="$BASEDIR/lib/project"
export PATH="$BASEDIR/libexec/project:$PATH"
exec "prog-$1"
libexec/project/prog-cmd1
#!/bin/sh
set -eu # 再度初期化処理が必要
. "$LIBDIR/common.sh"
func # common.sh で定義されている

BASEDIR 等の変数を export しているのは補助コマンドから参照できるようにするためです。また libexecPATH に追加することで内部で補助コマンド名だけで実行できるようにしています。補助コマンドの呼び出し時に exec を使用しているのは、補助コマンドの処理が終わった後に戻ってくる必要がないからです。もし終了処理を行うなどの理由で戻る必要がある場合は exec を削除してください。

余談ですが実は . コマンドは PATH が通ったディレクトリからも読み込むことが出来るので lib/projet を PATH に追加しておくと . command.sh だけでライブラリファイルも読み込むことが出来ます。例えば gettext.shこのような使い方を想定しており、Ubuntu では /usr/bin/gettext.sh というパスにインストールされます。ちなみに gettext.sh. を使わずに直接実行すると . コマンドで読み込めという使い方が出力されます。

$ gettext.sh
GNU gettext shell script function library version 0.21
Usage: . gettext.sh

応用 シンボリックリンクから実行可能にする

実は今までのコードには少し問題がありコマンドをシンボリックリンク経由で実行した場合にうまく動きません。/usr/usr/local ディレクトリにインストールしたり、プロジェクトディレクトリの bin を 環境変数 PATH に追加すれば問題なく動作するのですがシンボリックリンクからも実行できると便利です。

以下は /opt/project 以下にインストールして $HOME/bin/prog に作成したシンボリックリンクから起動する場合の例です。シンボリックリンク経由だと動かない理由は以下の例で $HOME/bin/prog を実行すると、シンボリックリンクファイルを基準にしてパスを参照するので、存在しない $HOME/lib/common.sh を読み込もうとしてしまうからです。

$HOME
└── bin
      └── prog -> /opt/project/bin/prog

/opt/project
├── bin
│    └── prog
└── lib/project
      └── common.sh

readlink -f を使う

この問題を解決するには prog が実際にどのディレクトリにあるのかシンボリックリンクの実体のパスを取得する必要があります。そのためによく使われるのが readlink コマンドです。

bin/prog
#!/bin/sh
set -eu
self=$(readlink -f "$0")
libdir=${self%/*/*}/lib/project
. "$libdir/common.sh"
func # common.sh で定義されている

シンボリックリンクのシンボリックリンクのシンボリックリンク・・・とネストする可能性があるので再帰したシンボリックリンクを全て解決する -f オプションが必要です。

readlinkf を使う

しかしこの readlink コマンドは移植性がありません。POSIX でも規定されておらず -f オプションがサポートされてない環境(macOS 等)や readlink コマンドそのものがインストールされていない環境があります。そういう環境に対応する場合に readlink -f 相当の処理を POSIX 準拠のコマンドだけで実装した readlinkf を作成しています。GNU版の readlink -f と互換性があり入れ替えて使うことが出来ます。シェル関数としてソースコードの形で提供しているので必要な部分をコピーして使用してください。ライセンスは CC0 にしているのでどのようなプロジェクトにも使うことが出来ます。CC0 は権利の放棄であり著作権表記なども不要です。

bin/prog
#!/bin/sh
set -eu

readlinkf() {
  [ "${1:-}" ] || return 1
  max_symlinks=40
  CDPATH='' # to avoid changing to an unexpected directory

  target=$1
  [ -e "${target%/}" ] || target=${1%"${1##*[!/]}"} # trim trailing slashes
  [ -d "${target:-/}" ] && target="$target/"

  cd -P . 2>/dev/null || return 1
  while [ "$max_symlinks" -ge 0 ] && max_symlinks=$((max_symlinks - 1)); do
    if [ ! "$target" = "${target%/*}" ]; then
      case $target in
        /*) cd -P "${target%/*}/" 2>/dev/null || break ;;
        *) cd -P "./${target%/*}" 2>/dev/null || break ;;
      esac
      target=${target##*/}
    fi

    if [ ! -L "$target" ]; then
      target="${PWD%/}${target:+/}${target}"
      printf '%s\n' "${target:-/}"
      return 0
    fi

    # `ls -dl` format: "%s %u %s %s %u %s %s -> %s\n",
    #   <file mode>, <number of links>, <owner name>, <group name>,
    #   <size>, <date and time>, <pathname of link>, <contents of link>
    # https://pubs.opengroup.org/onlinepubs/9699919799/utilities/ls.html
    link=$(ls -dl -- "$target" 2>/dev/null) || break
    target=${link#*" $target -> "}
  done
  return 1
}

self=$(readlinkf "$0")
libdir=${self%/*/*}/lib/project
. "$libdir/common.sh"
func # common.sh で定義されている

応用 サブコマンドのオプション解析

サブコマンドを作る場合オプションの解析に工夫が必要です。具体的な解析コードは長くなるので省略しますが、おすすめの方法はオプション解析を 2 回に分けて行う方法です。例えば次のようなオプションを解析することを考えます。

prog --global cmd1 --option arg1 arg2

サブコマンド(cmd1)を境にして左側がグローバルオプション、右側がサブコマンドのオプションと引数です。この時 prog で解析するのは cmd1 が登場する所までです。残りの引数は prog-cmd1 にそのまま渡して prog-cmd1 側で解析を行います。prog で解析したオプションの結果は環境変数に代入しておき prog-cmd1 に引き継ぎます。これを応用すればオプション解析を 3 回行うことでサブサブコマンドに対応することも出来ます。

prog --global cmd1           --option arg1 arg2
# prog で解析するのは↑ここまで    ↑残りは prog-cmd1 で解析する

この処理を行うためにはオプション解析をサブコマンドが見つかった所で打ち切る必要があります。getopts や 独自のオプション解析コードであればサブコマンドが登場したときに引数解析のループを打ち切るのは簡単ですが GNU 版の getopt を使う場合は注意が必要です。GNU 版の getopt はデフォルトではオプションではない引数の後にオプションを書いても解析されてしまうからです。引数の後にオプションが指定できるようになるため一般には便利な機能なのですが、サブコマンドを使う場合は、サブコマンドの後にあるオプションまで解析されてしまうためエラーになってしまいます。

getopt -o 'g' -l global -- "$@"
# ↑ で -g --global cmd1 arg1 arg2 --option という引数を解析すると
# getopt: unrecognized option `--option' というエラーが出てしまう

これを回避するにはショートオプションの文字列の前に + を追加します。

getopt -o '+g' -l global -- -g --global cmd1 arg1 arg2 --option
# -g --global -- 'cmd1' 'arg1' 'arg2' '--option'

この機能の詳細については GNU getopt の SCANNING MODES を参照してください。

しかしながら getoptは移植性がなく getopts はロングオプションに対応しておらず、独自のオプション解析コードを書くのも大変なので getoptions を使うのをお勧めしますgetoptions は既存の問題をすべて解決しており、サブコマンドにも対応しています。

さいごに

シェルスクリプトで中規模以上のソフトウェアを作る例はあまりないからか、このような情報が少ないのでまとめてみました。シェルスクリプトではコードが大きくなっても一つのファイルにそのまま書いてしまいがちになる人が多いように思えます。そんな事をするとメンテナンス性は大きく下がってしまいます。シェルスクリプトでも他の言語と考え方は同じです。大きくなったら小さなファイルに分ける。それだけです。小さいままであればメンテナンス性が下がることはありません。シェルスクリプトが大きくなってメンテナンスが大変になるのはシェルスクリプト自体の問題ではなく正しいプログラミング手法を知らないからです。

15
9
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?