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

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

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

## はじめに

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

この記事で紹介するディレクトリ構造は [FHS - Filesystem Hierarchy Standard](https://refspecs.linuxfoundation.org/fhs.shtml) に準拠しており、`/usr` または `/usr/local` ディレクトリ以下にインストールしやすい形にしています。

## 1. 共通ライブラリ

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

```
project
├── bin
│    ├── prog1
│    └── prog2
└── lib/project
      └── common.sh
```

```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](https://www.gnu.org/software/gettext/manual/html_node/sh.html#sh) です。同様に Python では `gettext.py`、Ruby では `gettext.rb` という名前が使用されているようです。

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

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

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

まず `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
```

```sh:libexec/project/prog
#!/bin/sh
set -eu
BASEDIR="$(cd -- "$(dirname -- "$0")/.." && pwd)"
LIBDIR="$BASEDIR/lib/project"
. "$LIBDIR/${0##*/}.sh"
```

```sh:lib/project/prog1.sh
. "$LIBDIR/common.sh"
func # common.sh で定義されている
```

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

## 3. サブコマンドにする

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

```
project
├── bin
│    └── prog
└── lib/project
      ├── cmd1.sh
      ├── cmd2.sh
      └── common.sh
```

```sh:bin/prog
#!/bin/sh
set -eu
BASEDIR="$(cd -- "$(dirname -- "$0")/.." && pwd)"
LIBDIR="$BASEDIR/lib/project"
. "$LIBDIR/$1.sh"
```

```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
```

```sh: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"
```

```sh:libexec/project/prog-cmd1
#!/bin/sh
set -eu # 再度初期化処理が必要
. "$LIBDIR/common.sh"
func # common.sh で定義されている
```


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

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

```console
$ 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` コマンドです。

```sh: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](https://github.com/ko1nksm/readlinkf) を作成しています。GNU版の `readlink -f` と互換性があり入れ替えて使うことが出来ます。シェル関数としてソースコードの形で提供しているので必要な部分をコピーして使用してください。ライセンスは CC0 にしているのでどのようなプロジェクトにも使うことが出来ます。CC0 は権利の放棄であり著作権表記なども不要です。

例

```sh: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 回に分けて行う方法です。例えば次のようなオプションを解析することを考えます。

```sh
prog --global cmd1 --option arg1 arg2
```

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

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

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

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

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

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

この機能の詳細については GNU getopt の [SCANNING MODES](https://man7.org/linux/man-pages/man1/getopt.1.html) を参照してください。

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

## さいごに

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