この記事の狙い
Bash スクリプトを**モジュール(再利用可能な小さなライブラリ)**に分割し、確実に読み込むためのレイアウトと仕組みを示します。source の落とし穴(相対パス、二重読み込み、依存順序、名前衝突)を避け、安全・移植性・見通しを両立させます。
前提と対象
- Bash 4+(macOS で Bash 3.x の場合は Homebrew 等で 5 系を推奨)
set -Eeuo pipefail前提の安全設計- 小規模 CLI~日次バッチ規模を想定(モノレポにも拡張可能)
TL;DR(最小実装・コピペ可)
ディレクトリ構成(最小)
repo/
├─ bin/
│ └─ app # エントリポイント
├─ lib/
│ ├─ loader.sh # モジュールローダ(必須)
│ ├─ log.sh # ログユーティリティ
│ └─ fs.sh # ファイル系ユーティリティ
└─ scripts/
└─ job.sh # 実処理本体(任意)
bin/app(エントリ) — “どこから呼んでも動く”
#!/usr/bin/env bash
set -Eeuo pipefail
# エントリの絶対パス(シンボリックリンク経由でもOK)
resolve_script_dir() {
local src="${BASH_SOURCE[0]}"
while [ -h "$src" ]; do
local dir; dir="$(cd -P -- "$(dirname -- "$src")" && pwd)"
src="$(readlink -- "$src")"
[[ $src != /* ]] && src="$dir/$src"
done
cd -P -- "$(dirname -- "$src")" && pwd
}
APP_DIR="$(resolve_script_dir)"
LIB_DIR="$APP_DIR/../lib"
# ローダを読み込み、必要モジュールを要求
# shellcheck source=../lib/loader.sh
. "$LIB_DIR/loader.sh"
require log fs # log.sh と fs.sh をロード
log_info "start"
tmp="$(fs_mktmpdir)"
log_info "tmp=$tmp"
# …処理…
lib/loader.sh(ローダ) — 相対パス/二重読み込み/順序を解決
# lib/loader.sh
# shellcheck shell=bash
set -Eeuo pipefail
# ロード済みガード
: "${_LOADER_LOADED:=0}"; [ "$_LOADER_LOADED" -eq 1 ] && return 0; _LOADER_LOADED=1
# 呼び出し元の lib ディレクトリを解決(bin/app から相対)
_loader_resolve_libdir() {
local src="${BASH_SOURCE[0]}"
local dir; dir="$(cd -P -- "$(dirname -- "$src")" && pwd)"
printf '%s\n' "$dir"
}
_LOADER_LIBDIR="$(_loader_resolve_libdir)"
# 依存モジュールを読み込む(拡張子省略可 / 二重読み込み防止)
require() {
local name file guard
for name in "$@"; do
file="$name"
[[ "$file" == *.sh ]] || file="$name.sh"
guard="__LOADED_${file//[^A-Za-z0-9_]/_}"
# すでにロード済みならスキップ
[[ "${!guard-}" == 1 ]] && continue
# shellcheck source=lib/*.sh
. "$_LOADER_LIBDIR/$file"
printf -v "$guard" '%s' 1
done
}
lib/log.sh(名前空間+ガード)
# lib/log.sh
# shellcheck shell=bash
[ "${__LOADED_log_sh-}" = 1 ] && return 0; __LOADED_log_sh=1
set -Eeuo pipefail
_log_ts() { printf '%(%Y-%m-%dT%H:%M:%S%z)T' -1; }
log_info() { printf '%s INFO %s\n' "$(_log_ts)" "$*" >&2; }
log_warn() { printf '%s WARN %s\n' "$(_log_ts)" "$*" >&2; }
log_err() { printf '%s ERROR %s\n' "$(_log_ts)" "$*" >&2; }
lib/fs.sh(副作用は最小に)
# lib/fs.sh
[ "${__LOADED_fs_sh-}" = 1 ] && return 0; __LOADED_fs_sh=1
set -Eeuo pipefail
fs_mktmpdir() {
local d; d="$(mktemp -d)" || { printf 'mktemp failed\n' >&2; return 2; }
printf '%s\n' "$d"
}
設計の要点(原則)
- エントリは絶対パスで自己解決
BASH_SOURCEを辿ってシンボリックリンクにも耐性を持たせる。
- ローダで
requireを一本化- 二重読み込み、拡張子有無、依存順序を一箇所で吸収。
- 各モジュールは“自己ガード+名前空間”
__LOADED_xxxで idempotent、関数名はlog_*fs_*のように接頭辞で衝突回避。
- 副作用を限定
- グローバル変数の書き換え禁止、必要なら**
readonly/local。出力はstdout/stderr を使い分け**。
- グローバル変数の書き換え禁止、必要なら**
ステップ実装(分解解説)
段階1:レイアウトを固定(bin/lib/scripts)
- 実行可能ファイルは
bin/、再利用コードはlib/、長めのジョブはscripts/。 - 相対パス禁止:全て
APP_DIR/LIB_DIRを起点に。
段階2:ローダで「見つけ方」と「一度だけ読み込み」を担保
require foo barで 順序保証(foo→bar)。- ロード済みはガード変数でスキップ。
set -u下でも-"デフォルトを使う。
段階3:モジュール側の規約
- 先頭 2 行でガード+
set -Eeuo pipefail。 - 外向け API は 接頭辞でまとめ、内部関数は先頭
_。 - 標準エラーにログ、標準出力に値。戻り値は
returnで。
段階4:設定の分離(オプション)
config.shを用意し、ローカル上書きに対応:# lib/config.sh(デフォルト) [ "${__LOADED_config_sh-}" = 1 ] && return 0; __LOADED_config_sh=1 set -Eeuo pipefail : "${APP_LOG_LEVEL:=info}" # ~/.config/yourapp/config.sh があれば後から読み込む(任意) [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/yourapp/config.sh" ] && . "${XDG_CONFIG_HOME:-$HOME/.config}/yourapp/config.sh"- エントリで
require config→require log fsの順に。
失敗しやすい点(アンチパターン)
source ./lib/foo.shをあちこちで直書き → 相対パス地獄/順序依存
→ ローダのrequireに一本化。- 同名関数の衝突 → 別ディレクトリの
utils.shを同時に読むと上書き
→ 接頭辞で名前空間、またはファイル名と一致させる(log_*は log.sh だけが定義)。 - 二重読み込みで再定義 →
set -u下で未定義参照が爆発
→ ガード変数で idempotent。 cdの副作用 → 呼び出し元の CWD を壊す
→cdは subshell(...)かpushd/popd、またはパスを明示して開く。
“コピペ可”テストブロック(最小)
#!/usr/bin/env bash
set -Eeuo pipefail
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$APP_DIR/../lib"
. "$LIB_DIR/loader.sh"
require log fs
# 1) log_* が使える
out="$( { log_info "hello"; } 2>&1 )"
[[ "$out" =~ INFO ]] || { echo "log fail"; exit 1; }
# 2) fs_mktmpdir がディレクトリを返す
tmp="$(fs_mktmpdir)"
[[ -d "$tmp" ]] || { echo "fs fail"; exit 1; }
rm -rf "$tmp"
# 3) 二重requireでも多重定義しない
require log fs log fs
echo "PASS"
運用設計(実務)
- 依存方向:
lib/は下位→上位へ依存しない(循環禁止)。logのような低層ほど依存は少なく。 - バージョニング:モジュール先頭に
LIB_FOO_VERSION="1.2.0"を置き、エントリで最低バージョンをチェック:require log [[ "${LIB_LOG_VERSION:-0}" == 1.* ]] || { echo "log lib mismatch" >&2; exit 3; } - ドキュメント化:各モジュール冒頭に提供 API 一覧をコメントで列挙。
- 配布:
lib/を丸ごと vendor として他プロジェクトへコピー可能に(外部依存を避ける)。
互換性と移植性
- GNU readlink 依存を避け、
BASH_SOURCEとcd -Pで解決(記事のresolve_script_dirは GNU/BSD どちらでも可)。 bash固有機能(配列、local -nなど)を使う場合はシェバンを Bash 固定。- macOS で Bash 3.x の場合はBash 5 を導入してから利用。
セキュリティと安全設計
requireの引数は固定の許可リスト([A-Za-z0-9_]+)に限定。ユーザー入力をモジュール名に使わない。- すべての
sourceは絶対パスで行い、$PWD依存を排除。 - モジュールは標準出力にデータ、標準エラーにログの原則を徹底。
- 一時ファイル・ディレクトリは
mktemp+trapで必ず掃除。
パフォーマンスの勘所(短く)
requireのオーバーヘッドは微小。初回ロードのみ。- 大量 CLI を起動するジョブではロギングの行数が支配的になりやすい。必要に応じて
APP_LOG_LEVELで抑制。
参考実装(拡張版・コピペ可)
依存解決と検索パス(上級)
lib/ 以外(例:/usr/local/share/yourapp/lib)も検索したい場合:
# loader.sh(拡張)
_LOADER_PATHS=(
"$(_loader_resolve_libdir)"
"/usr/local/share/yourapp/lib"
)
_require_one() {
local file="$1" p
for p in "${_LOADER_PATHS[@]}"; do
if [ -f "$p/$file" ]; then
# shellcheck source=lib/*.sh
. "$p/$file"; return 0
fi
done
printf 'require: not found: %s (paths: %s)\n' "$file" "${_LOADER_PATHS[*]}" >&2
return 127
}
require() {
local name file guard
for name in "$@"; do
file="${name%.sh}.sh"
guard="__LOADED_${file//[^A-Za-z0-9_]/_}"
[[ "${!guard-}" == 1 ]] && continue
_require_one "$file"
printf -v "$guard" '%s' 1
done
}
よくある質問(Q&A)
Q. set -u でローダが落ちます。
A. ガード参照は -" デフォルトを使いましょう(${var-})。記事の実装は - を使って安全化しています。
Q. 相互依存したモジュールは?
A. 原則禁止。どうしても必要なら、共通下位モジュール(例:core.sh)に分離し、両者から参照。
Q. プロジェクト外からも使いたい。
A. インストールパスを _LOADER_PATHS に追加するか、/usr/local/share/... に lib/ を配置して検索順に入れてください。
