Shell 项目模板案例

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_ROOTmain.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 项目即使达到数千行也仍然容易维护。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容