Shell 项目模板案例:Main 流程 + Lib 模块 + Config 配置 + Log 日志
本文是一套可以直接复制使用的 Bash 项目模板,适合备份、部署、巡检、数据处理、定时任务和日常运维脚本。
1. 模板解决的问题
小型 Shell 脚本通常把配置、日志、参数解析和业务逻辑全部写在一个文件中。功能增加后,会出现以下问题:
- 主脚本越来越长;
- 不同功能重复编写日志和检查逻辑;
- 配置值散落在代码中;
- 修改一个公共函数需要同步修改多个脚本;
- 无法快速判断主流程执行顺序;
- 很难测试和复用。
本模板将项目拆分成四个部分:
main 流程脚本:负责参数解析、加载依赖和业务编排
lib 功能模块:负责具体功能及公共函数
config 配置:负责保存可变参数
log 统一管理:负责所有运行日志
核心原则:
main 管流程,lib 做功能,config 放变量,log 记状态。
2. 完整项目结构
shell-flow-template/
├── main.sh
├── config/
│ └── app.conf
├── lib/
│ ├── log.sh
│ ├── common.sh
│ ├── check.sh
│ └── backup.sh
├── data/
│ ├── source/
│ └── backup/
└── var/
└── log/
各文件职责:
| 文件 | 职责 |
|---|---|
main.sh |
程序入口、参数解析、配置加载、流程调度 |
config/app.conf |
项目名称、环境、日志、业务路径等配置 |
lib/log.sh |
DEBUG、INFO、WARN、ERROR 统一日志 |
lib/common.sh |
命令检查、目录检查、命令执行、dry-run |
lib/check.sh |
运行环境和配置检查 |
lib/backup.sh |
创建备份、清理旧备份、查询备份 |
data/source/ |
示例待备份数据 |
data/backup/ |
备份文件输出目录 |
var/log/ |
程序日志目录 |
依赖关系:
main.sh
├── config/app.conf
├── lib/log.sh
├── lib/common.sh ──> 使用日志函数
├── lib/check.sh ──> 使用公共检查和日志函数
└── lib/backup.sh ──> 使用命令执行和日志函数
业务模块可以调用公共模块,但公共模块不应该依赖具体业务模块。
3. 创建项目目录
mkdir -p shell-flow-template/{config,lib,data/source,data/backup,var/log}
cd shell-flow-template
touch main.sh
touch config/app.conf
touch lib/log.sh
touch lib/common.sh
touch lib/check.sh
touch lib/backup.sh
chmod +x main.sh
4. Config:统一配置文件
将以下内容保存为 config/app.conf:
APP_NAME="shell-flow-template"
APP_ENV="dev"
# 日志级别:DEBUG、INFO、WARN、ERROR
LOG_LEVEL="INFO"
LOG_TO_FILE="true"
LOG_FILE="${PROJECT_ROOT}/var/log/${APP_NAME}.log"
# 示例备份业务配置
BACKUP_SOURCE="${PROJECT_ROOT}/data/source"
BACKUP_TARGET="${PROJECT_ROOT}/data/backup"
BACKUP_KEEP_DAYS=7
# true 表示只显示变更命令,不真正执行
DRY_RUN="false"
配置文件只做变量赋值,不放复杂流程和函数。
PROJECT_ROOT 由 main.sh 在加载配置前定义,所以配置可以基于项目根目录设置路径。这样无论用户从哪个目录运行脚本,路径都不会错。
配置文件通过 source 加载,因此只能加载本地可信文件,不能加载用户上传或网络下载的未知文件。
5. Log:统一日志模块
将以下内容保存为 lib/log.sh:
#!/usr/bin/env bash
# 防止同一个库被重复加载。
[[ -n "${__LOG_LIB_LOADED:-}" ]] && return 0
readonly __LOG_LIB_LOADED=1
_log_level_value() {
local normalized
normalized="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')"
case "$normalized" in
DEBUG) printf '10' ;;
INFO) printf '20' ;;
WARN) printf '30' ;;
ERROR) printf '40' ;;
*) printf '20' ;;
esac
}
_log() {
local level="$1"
shift
local current_level="${LOG_LEVEL:-INFO}"
local message="$*"
local timestamp caller output
(( $(_log_level_value "$level") >= $(_log_level_value "$current_level") )) \
|| return 0
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
caller="${BASH_SOURCE[2]##*/}:${BASH_LINENO[1]:-0}"
output="${timestamp} [${level}] [${caller}] ${message}"
# 日志写入 stderr,避免污染业务函数的 stdout 返回值。
printf '%s\n' "$output" >&2
if [[ "${LOG_TO_FILE:-false}" == "true" && -n "${LOG_FILE:-}" ]]; then
mkdir -p -- "$(dirname -- "$LOG_FILE")"
printf '%s\n' "$output" >> "$LOG_FILE"
fi
}
log_debug() { _log DEBUG "$@"; }
log_info() { _log INFO "$@"; }
log_warn() { _log WARN "$@"; }
log_error() { _log ERROR "$@"; }
die() {
local message="${1:-程序执行失败}"
local status="${2:-1}"
log_error "$message"
exit "$status"
}
使用示例:
log_debug "开始读取参数"
log_info "备份执行成功"
log_warn "磁盘剩余空间较少"
log_error "备份命令执行失败"
die "配置文件不存在" 78
生成的日志类似:
2026-08-20 10:00:00 [INFO] [backup.sh:18] 开始创建备份
2026-08-20 10:00:01 [ERROR] [common.sh:42] 命令执行失败,退出码:2
日志写到标准错误的原因:
result="$(some_function)"
函数的正常结果通过标准输出返回,日志通过标准错误输出,两者不会混在一起。
6. Lib:公共函数模块
将以下内容保存为 lib/common.sh:
#!/usr/bin/env bash
[[ -n "${__COMMON_LIB_LOADED:-}" ]] && return 0
readonly __COMMON_LIB_LOADED=1
command_exists() {
command -v "$1" >/dev/null 2>&1
}
require_command() {
command_exists "$1" || die "缺少必要命令:$1" 127
}
require_dir() {
[[ -d "$1" ]] || die "目录不存在:$1" 3
}
ensure_dir() {
[[ -d "$1" ]] || mkdir -p -- "$1"
}
is_positive_integer() {
[[ "$1" =~ ^[1-9][0-9]*$ ]]
}
run_cmd() {
(( $# > 0 )) || return 2
local status
log_info "执行命令:$(printf '%q ' "$@")"
if [[ "${DRY_RUN:-false}" == "true" ]]; then
log_info "DRY-RUN:跳过实际执行"
return 0
fi
if "$@"; then
log_info "命令执行成功"
return 0
else
status=$?
log_error "命令执行失败,退出码:${status}"
return "$status"
fi
}
公共函数模块只存放通用能力:
- 判断命令是否存在;
- 检查文件或目录;
- 参数格式校验;
- 创建目录;
- 统一执行外部命令;
- dry-run 控制。
执行外部命令时,应将命令和参数分别传递:
run_cmd cp -- "$source" "$target"
run_cmd rm -f -- "$file"
不要拼接字符串后使用 eval:
# 不推荐
command_text="cp $source $target"
eval "$command_text"
使用 "$@" 可以保留参数边界,正确处理带空格路径,并降低命令注入风险。
7. Lib:环境检查模块
将以下内容保存为 lib/check.sh:
#!/usr/bin/env bash
[[ -n "${__CHECK_LIB_LOADED:-}" ]] && return 0
readonly __CHECK_LIB_LOADED=1
check_environment() {
log_info "开始检查运行环境"
require_command tar
require_command find
require_dir "$BACKUP_SOURCE"
ensure_dir "$BACKUP_TARGET"
[[ -w "$BACKUP_TARGET" ]] \
|| die "备份目录不可写:${BACKUP_TARGET}" 3
is_positive_integer "$BACKUP_KEEP_DAYS" \
|| die "BACKUP_KEEP_DAYS 必须是正整数" 78
log_info "运行环境检查通过"
}
所有主流程都应先检查必要条件,再执行实际变更。检查内容通常包括:
- 必要命令是否安装;
- 配置变量是否为空;
- 输入目录是否存在;
- 输出目录是否可写;
- 数字配置是否合法;
- 网络服务是否可访问;
- 当前用户权限是否满足要求。
8. Lib:备份业务模块
将以下内容保存为 lib/backup.sh:
#!/usr/bin/env bash
[[ -n "${__BACKUP_LIB_LOADED:-}" ]] && return 0
readonly __BACKUP_LIB_LOADED=1
create_backup() {
local timestamp archive_name archive_path
timestamp="$(date '+%Y%m%d_%H%M%S')"
archive_name="backup_${timestamp}.tar.gz"
archive_path="${BACKUP_TARGET}/${archive_name}"
log_info "开始备份:${BACKUP_SOURCE}"
run_cmd tar -czf "$archive_path" \
-C "$(dirname -- "$BACKUP_SOURCE")" \
"$(basename -- "$BACKUP_SOURCE")" \
|| return $?
log_info "备份完成:${archive_path}"
# 正常结果通过 stdout 返回给调用者。
printf '%s\n' "$archive_path"
}
clean_old_backups() {
local file_count=0
local backup_file
log_info "清理 ${BACKUP_KEEP_DAYS} 天前的备份"
while IFS= read -r -d '' backup_file; do
run_cmd rm -f -- "$backup_file" || return $?
((file_count += 1))
done < <(
find "$BACKUP_TARGET" \
-maxdepth 1 \
-type f \
-name 'backup_*.tar.gz' \
-mtime "+${BACKUP_KEEP_DAYS}" \
-print0
)
log_info "旧备份清理完成,共处理 ${file_count} 个文件"
}
list_backups() {
log_info "当前备份文件列表"
find "$BACKUP_TARGET" \
-maxdepth 1 \
-type f \
-name 'backup_*.tar.gz' \
-print \
| sort
}
模块内函数职责清晰:
create_backup 创建一个新备份
clean_old_backups 清理过期备份
list_backups 查询当前备份
find -print0 配合 read -d '',可以正确处理文件名中的空格、制表符和换行符。
9. Main:完整主流程脚本
将以下内容保存为 main.sh:
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
# 根据脚本自身位置确定项目根目录,不依赖用户当前目录。
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
readonly PROJECT_ROOT="$SCRIPT_DIR"
# 加载顺序:日志 → 公共函数 → 业务模块。
source "${PROJECT_ROOT}/lib/log.sh"
source "${PROJECT_ROOT}/lib/common.sh"
source "${PROJECT_ROOT}/lib/check.sh"
source "${PROJECT_ROOT}/lib/backup.sh"
CONFIG_FILE="${PROJECT_ROOT}/config/app.conf"
CLI_DRY_RUN=""
COMMAND=""
usage() {
cat <<'EOF'
用法:
./main.sh [公共选项] <命令>
命令:
run 执行完整流程:环境检查 → 创建备份 → 清理旧备份
check 只检查环境
backup 只创建备份
clean 只清理过期备份
list 列出当前备份
公共选项:
-c, --config <文件> 指定配置文件
--dry-run 只显示变更命令,不实际执行
-h, --help 显示帮助
示例:
./main.sh run
./main.sh --dry-run run
./main.sh --config ./config/prod.conf backup
EOF
}
cleanup() {
local status=$?
if (( status == 0 )); then
log_info "主流程执行结束"
else
log_error "主流程异常结束,退出码:${status}"
fi
return "$status"
}
on_error() {
local status=$?
log_error "未处理错误:行号=$1,命令=$2,退出码=${status}"
return "$status"
}
trap cleanup EXIT
trap 'on_error "$LINENO" "$BASH_COMMAND"' ERR
trap 'log_warn "收到终止信号"; exit 130' INT TERM
while (( $# > 0 )); do
case "$1" in
-c|--config)
(( $# >= 2 )) || die "$1 缺少配置文件参数" 2
CONFIG_FILE="$2"
shift 2
;;
--dry-run)
CLI_DRY_RUN="true"
shift
;;
-h|--help)
usage
exit 0
;;
run|check|backup|clean|list)
[[ -z "$COMMAND" ]] || die "只能指定一个命令" 2
COMMAND="$1"
shift
;;
*)
die "未知参数:$1;请使用 --help 查看说明" 2
;;
esac
done
[[ -n "$COMMAND" ]] || {
usage
exit 2
}
[[ -r "$CONFIG_FILE" ]] || die "配置文件不可读:${CONFIG_FILE}" 78
# 加载可信配置。
source "$CONFIG_FILE"
# 命令行选项覆盖配置文件。
[[ -n "$CLI_DRY_RUN" ]] && DRY_RUN="$CLI_DRY_RUN"
ensure_dir "${PROJECT_ROOT}/var/log"
ensure_dir "$BACKUP_TARGET"
main() {
log_info "应用启动:${APP_NAME},环境:${APP_ENV},命令:${COMMAND}"
case "$COMMAND" in
run)
check_environment
create_backup >/dev/null
clean_old_backups
;;
check)
check_environment
;;
backup)
check_environment
create_backup
;;
clean)
check_environment
clean_old_backups
;;
list)
check_environment
list_backups
;;
esac
}
main
10. 主流程是如何执行的
运行:
./main.sh run
执行顺序:
启动 main.sh
↓
开启严格模式
↓
确定 PROJECT_ROOT
↓
加载 log、common、check、backup
↓
解析命令行参数
↓
加载 config/app.conf
↓
应用命令行配置覆盖
↓
check_environment
↓
create_backup
↓
clean_old_backups
↓
EXIT trap 记录最终状态
严格模式说明
set -Eeuo pipefail
-
-E:函数中触发的错误也可以继承ERR trap; -
-e:未处理的失败会终止脚本; -
-u:使用未定义变量时报错; -
pipefail:管道中任意命令失败,整个管道都视为失败。
严格模式不能代替显式错误处理。需要获得退出码时,仍然应该使用:
if command; then
log_info "成功"
else
status=$?
log_error "失败:${status}"
return "$status"
fi
11. 首次运行
准备测试文件:
mkdir -p data/source data/backup var/log
printf '%s\n' '这是待备份的测试文件' > data/source/example.txt
chmod +x main.sh
检查语法:
bash -n main.sh lib/*.sh config/*.conf
查看帮助:
./main.sh --help
检查运行环境:
./main.sh check
执行完整流程:
./main.sh run
只创建备份:
./main.sh backup
查看备份:
./main.sh list
查看日志:
tail -n 30 var/log/shell-flow-template.log
仅预览变更命令:
./main.sh --dry-run run
12. 配置覆盖规则
模板采用以下优先级:
命令行参数 > 指定配置文件 > 配置文件默认值
例如创建生产配置:
cp config/app.conf config/prod.conf
修改 config/prod.conf:
APP_NAME="backup-production"
APP_ENV="production"
LOG_LEVEL="INFO"
LOG_TO_FILE="true"
LOG_FILE="${PROJECT_ROOT}/var/log/${APP_NAME}.log"
BACKUP_SOURCE="/srv/application/data"
BACKUP_TARGET="/srv/backups/application"
BACKUP_KEEP_DAYS=30
DRY_RUN="false"
执行:
./main.sh --config ./config/prod.conf run
13. 如何增加新的功能模块
假设增加应用部署功能,新建 lib/deploy.sh:
#!/usr/bin/env bash
[[ -n "${__DEPLOY_LIB_LOADED:-}" ]] && return 0
readonly __DEPLOY_LIB_LOADED=1
deploy_application() {
local package_file="$1"
local deploy_dir="$2"
[[ -f "$package_file" ]] || die "部署包不存在:${package_file}" 3
ensure_dir "$deploy_dir"
log_info "开始部署:${package_file}"
run_cmd cp -- "$package_file" "$deploy_dir/"
log_info "部署完成"
}
在 main.sh 中加载:
source "${PROJECT_ROOT}/lib/deploy.sh"
在参数解析中增加 deploy:
run|check|backup|clean|list|deploy)
COMMAND="$1"
shift
;;
在主流程中增加调度:
deploy)
check_environment
deploy_application "$PACKAGE_FILE" "$DEPLOY_DIR"
;;
增加模块时继续遵守:
- 参数解析放在
main.sh; - 可变值放在
config; - 通用能力放在
common.sh; - 具体业务放在独立模块;
- 所有日志使用
log_*; - 所有外部变更命令使用
run_cmd。
14. 实际项目中的推荐规范
14.1 函数内部变量使用 local
create_user() {
local username="$1"
local home_dir="$2"
}
避免模块之间意外覆盖变量。
14.2 所有变量展开尽量加双引号
cp -- "$source" "$target"
这样可以正确处理带空格路径。
14.3 使用 -- 分隔选项和路径
rm -f -- "$file"
mkdir -p -- "$directory"
避免以短横线开头的文件名被误认为命令选项。
14.4 函数正常返回结果,日志不要混入结果
get_backup_path() {
log_info "生成备份路径"
printf '%s\n' "$archive_path"
}
backup_path="$(get_backup_path)"
因为日志写入 stderr,所以变量只会接收到标准输出中的路径。
14.5 库函数尽量使用 return
普通库函数失败时用:
return 1
只有遇到整个程序无法继续的致命问题时,才调用 die 退出。
15. 常见问题
从其他目录执行时找不到配置
不要使用 $PWD 定位项目。模板已经通过 ${BASH_SOURCE[0]} 定位 main.sh 所在目录,因此可以从任意目录执行:
/absolute/path/shell-flow-template/main.sh run
日志文件没有生成
检查配置:
LOG_TO_FILE="true"
LOG_FILE="${PROJECT_ROOT}/var/log/${APP_NAME}.log"
同时确认日志目录可写。
--dry-run 为什么仍然执行了某些代码
dry-run 只会自动跳过经过 run_cmd 调用的命令。所有产生修改的外部命令都应该统一经过 run_cmd。
为什么不使用 eval
eval 会重新解析拼接后的字符串,容易破坏引号和参数边界,并可能造成命令注入。模板使用 "$@" 直接传递命令参数。
为什么每个模块都有 __XXX_LOADED
这是模块加载保护。如果同一文件被多个地方 source,第二次会直接返回,避免重复定义或重复执行初始化代码。
16. 上线前检查清单
-
main.sh只负责参数、配置和流程调度; - 具体业务已经拆到独立
lib模块; - 所有配置集中在
config; - 所有日志统一使用
log_debug/info/warn/error; - 外部变更命令统一使用
run_cmd; - 变量展开使用双引号;
- 文件操作使用
--分隔选项; - 删除操作限制了目标范围;
- 密码和 Token 没有写入代码或日志;
-
bash -n语法检查通过; - ShellCheck 检查通过;
- 从项目目录之外执行测试成功;
- dry-run 模式验证成功;
- 失败时返回非零退出码;
- 日志有定期轮转或清理方案。
17. 总结
这套模板最重要的不是备份功能本身,而是职责划分:
main.sh
负责程序入口、参数解析和流程编排
config/app.conf
负责保存不同环境下会变化的参数
lib/log.sh
负责统一日志格式、日志级别和日志文件
lib/common.sh
负责所有业务都可能使用的公共能力
lib/check.sh、lib/backup.sh
负责独立、明确的具体业务
后续项目扩大时,只需要不断增加业务模块,而不是继续把全部代码堆入 main.sh。保持依赖方向清晰、配置集中、日志统一和命令执行可控,Shell 项目即使达到数千行也仍然容易维护。