モジュール構成と読み込み|共通ライブラリ化と source

関数・モジュール化

この記事の狙い

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"
}

設計の要点(原則)

  1. エントリは絶対パスで自己解決
    • BASH_SOURCE を辿ってシンボリックリンクにも耐性を持たせる。
  2. ローダで require を一本化
    • 二重読み込み、拡張子有無、依存順序を一箇所で吸収
  3. 各モジュールは“自己ガード+名前空間”
    • __LOADED_xxx で idempotent、関数名は log_* fs_* のように接頭辞で衝突回避
  4. 副作用を限定
    • グローバル変数の書き換え禁止、必要なら**readonly/local。出力はstdout/stderr を使い分け**。

ステップ実装(分解解説)

段階1:レイアウトを固定(bin/lib/scripts)

  • 実行可能ファイルは bin/、再利用コードは lib/、長めのジョブは scripts/
  • 相対パス禁止:全て APP_DIR/LIB_DIR を起点に。

段階2:ローダで「見つけ方」と「一度だけ読み込み」を担保

  • require foo bar順序保証foobar)。
  • ロード済みはガード変数でスキップ。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 configrequire 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_SOURCEcd -P で解決(記事の resolve_script_dir は GNU/BSD どちらでも可)。
  • bash 固有機能(配列、local -n など)を使う場合はシェバンを Bash 固定
  • macOS で Bash 3.x の場合はBash 5 を導入してから利用。

セキュリティと安全設計

  • require の引数は固定の許可リスト[A-Za-z0-9_]+)に限定。ユーザー入力をモジュール名に使わない。
  • すべての source絶対パスで行い、$PWD 依存を排除。
  • モジュールは標準出力にデータ、標準エラーにログの原則を徹底。
  • 一時ファイル・ディレクトリは mktemptrap で必ず掃除。

パフォーマンスの勘所(短く)

  • 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/ を配置して検索順に入れてください。


参考リンク

Bash玄

はじめまして!Bash玄です。

エンジニアとしてシステム運用に携わる中で、手作業の多さに限界を感じ、Bashスクリプトを活用して業務を効率化したのがきっかけで、この道に入りました。「手作業は負け」「スクリプトはシンプルに」をモットーに、誰でも実践できるBashスクリプトの書き方を発信しています。

このサイトでは、Bashの基礎から実践的なスクリプト作成まで、初心者でもわかりやすく解説しています。少しでも「Bashって便利だな」と思ってもらえたら嬉しいです!

# 好きなこと
- シンプルなコードを書くこと
- コマンドラインを快適にカスタマイズすること
- 自動化で時間を生み出すこと

# このサイトを読んでほしい人
- Bashに興味があるけど、何から始めればいいかわからない人
- 定型業務を自動化したい人
- 効率よくターミナルを使いこなしたい人

Bashの世界に一歩踏み出して、一緒に「Bash道」を極めていきましょう!

Bash玄をフォローする