1. 项目概述:一个极简的Shell脚本管理框架
在Linux运维、自动化部署或者日常的系统管理工作中,我们经常需要编写大量的Shell脚本。从简单的文件备份、日志清理,到复杂的服务启停、集群状态检查,脚本无处不在。然而,随着脚本数量的增多和功能的复杂化,一系列问题也随之而来:脚本散落在各处难以管理、公共函数需要重复复制粘贴、参数解析和日志输出每个脚本都要重写一遍、缺乏统一的错误处理和优雅退出机制。最终,我们可能陷入一个“脚本泥潭”——维护成本高,可读性差,新人接手困难。
Mantic.sh的出现,正是为了解决这些痛点。它不是一个庞大的、侵入式的框架,而是一个极简、模块化、约定优于配置的Shell脚本管理方案。你可以把它理解为一个“脚本脚手架”或“脚本工具箱”。它的核心思想是:将脚本的通用功能(如日志、配置、子命令、帮助信息)抽象成可复用的模块,让开发者专注于业务逻辑本身,而不是重复造轮子。通过一套简单的目录结构和命名约定,Mantic.sh能帮你将一堆零散的脚本,组织成一个结构清晰、易于维护和扩展的“脚本项目”。
简单来说,如果你经常需要写超过50行的Shell脚本,或者手头有超过5个功能相关的脚本,那么引入Mantic.sh来管理它们,将会极大地提升你的工作效率和代码质量。它适合系统管理员、DevOps工程师、以及任何希望自己的Shell脚本更专业、更健壮的开发者。
2. 核心设计哲学与项目结构解析
2.1 为什么是“极简”和“约定优于配置”?
在Shell脚本领域,我们见过一些功能强大的框架,但它们往往学习曲线陡峭,需要记忆大量的API和特殊的语法。Mantic.sh反其道而行之,它的设计哲学非常明确:
- 零学习成本的核心:框架本身的核心逻辑极其精简,大部分功能通过“包含”(source)标准Shell脚本库文件和遵循目录约定来实现。你不需要学习一门新的“方言”,你写的依然是纯正的Bash脚本。
- 目录即约定:项目的组织结构本身就定义了脚本的加载顺序和模块的归属。例如,将公共函数放在
lib/目录下,将子命令脚本放在cmd/目录下,框架会自动发现并处理它们。这种约定减少了复杂的配置文件。 - 单一入口点:整个脚本项目通过一个主脚本来调用,这个主脚本负责初始化环境、解析参数、路由到正确的子命令。这提供了统一的用户体验和错误处理入口。
这种设计带来的好处是显而易见的:降低心智负担,提升开发速度,强制形成良好的项目结构。你不需要在写业务逻辑前,先花半天时间搭建框架。
2.2 标准项目结构解剖
一个典型的Mantic.sh项目目录结构如下所示。这个结构是框架发挥作用的基石:
my-awesome-scripts/ ├── mantic # 主入口脚本,名称可自定义(如 `cli`) ├── VERSION # 项目版本文件 ├── .env.example # 环境变量示例文件 ├── config/ # 配置文件目录 │ └── default.conf ├── lib/ # 核心库目录 │ ├── init.sh # 初始化脚本(加载配置、设置全局变量) │ ├── log.sh # 日志记录模块 │ ├── utils.sh # 通用工具函数库 │ └── ... # 其他自定义库文件 ├── cmd/ # 子命令实现目录 │ ├── backup.sh # 子命令:`./mantic backup` │ ├── deploy.sh # 子命令:`./mantic deploy` │ └── status.sh # 子命令:`./mantic status` └── tasks/ # 可选的独立任务脚本目录 └── cleanup-old-files.sh各目录和文件的核心职责:
mantic(主脚本):这是项目的门面。它通常很薄,主要工作是引导(Bootstrap):检查环境、加载lib/init.sh,然后根据用户输入的第一个参数(如backup),去cmd/目录下寻找并执行对应的脚本(cmd/backup.sh)。lib/目录:这是框架的“心脏”。init.sh是总控,它按顺序source其他库文件(log.sh,utils.sh),确保所有公共函数和全局变量在任何子命令执行前就已就绪。你可以在这里添加自己的库,比如network.sh封装curl/wget操作,db.sh封装数据库连接。cmd/目录:这是业务的“大脑”。每个文件代表一个子命令。文件本身就是一个可执行的Shell脚本,但它会继承主脚本初始化好的环境(库函数、配置)。子命令脚本专注于实现具体的业务逻辑。config/目录:存放配置文件。init.sh通常会加载这里的配置,将其转换为环境变量或全局变量,供所有模块使用。支持多环境(如dev.conf,prod.conf)是常见的扩展。tasks/目录:这是一个可选但很有用的约定。有些脚本可能不适合作为暴露给用户的子命令(比如需要cron定时执行的、内部调用的复杂任务),可以放在这里。它们同样可以通过source项目根目录的初始化脚本来复用所有库。
注意:
VERSION文件的存在,使得在脚本中通过cat VERSION获取当前版本号变得非常简单,便于实现--version参数。.env.example则是一个最佳实践,用于说明项目运行所需的环境变量,用户复制它为.env并填入实际值,由init.sh加载。
3. 核心模块深度剖析与实现
3.1 初始化引擎:lib/init.sh
init.sh是整个框架的粘合剂,它的执行顺序和健壮性至关重要。一个健壮的init.sh通常包含以下步骤:
#!/usr/bin/env bash # 文件:lib/init.sh # 1. 设置严格模式,立即捕获错误 set -euo pipefail # 2. 计算并设置项目根目录绝对路径 # 使用 `dirname` 和 `readlink` 组合可以正确处理符号链接 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" export PROJECT_ROOT # 3. 加载默认配置 CONFIG_FILE="${PROJECT_ROOT}/config/default.conf" if [[ -f "$CONFIG_FILE" ]]; then # 安全地source配置文件,避免配置中的错误影响框架 # 这里假设配置文件是简单的 KEY=VALUE 格式 while IFS='=' read -r key value; do # 跳过注释和空行 [[ "$key" =~ ^[[:space:]]*# ]] && continue [[ -z "$key" ]] && continue # 移除可能的引号并导出为环境变量 export "$key"="${value//\"/}" done < "$CONFIG_FILE" else echo "[WARN] Config file not found: $CONFIG_FILE" >&2 fi # 4. 加载环境变量文件(可选,优先级高于默认配置) ENV_FILE="${PROJECT_ROOT}/.env" if [[ -f "$ENV_FILE" ]]; then set -a # 自动导出后续所有变量 # 同样需要安全source,注意 `.env` 可能包含复杂语法 # 一个更安全的做法是使用 `dotenv` 库或类似方法,这里简化处理 source "$ENV_FILE" >/dev/null 2>&1 || { echo "[ERROR] Failed to load .env file" >&2 exit 1 } set +a fi # 5. 按顺序加载核心库 # 顺序很重要:基础工具 -> 日志 -> 其他依赖工具 for lib in utils.sh log.sh; do lib_path="${SCRIPT_DIR}/${lib}" if [[ -f "$lib_path" ]]; then source "$lib_path" else echo "[ERROR] Required library not found: $lib_path" >&2 exit 1 fi done # 6. 信号捕获,实现优雅退出 trap 'cleanup_on_exit' EXIT INT TERM cleanup_on_exit() { local exit_code=$? log_info "Script is cleaning up before exit (Code: $exit_code)" # 在这里执行必要的清理工作,如删除临时文件、关闭网络连接等 # 例如:[[ -n "${TEMP_DIR:-}" && -d "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR" exit $exit_code }关键点解析:
set -euo pipefail:这是编写健壮Shell脚本的黄金法则。-e让脚本在任意命令失败时退出;-u遇到未定义变量时报错;-o pipefail确保管道中任意环节失败,整个管道都视为失败。- 路径计算:使用
$(cd ... && pwd)是获取绝对路径最可靠的方式,能正确处理符号链接和空格。 - 配置加载顺序:通常
.env的优先级高于default.conf,因为.env常用于覆盖默认配置(如开发环境的数据库地址)。 - 信号捕获:
trap命令允许你在脚本被强制中断(Ctrl+C)或收到终止信号时,执行清理函数,避免留下僵尸进程或临时文件。
3.2 日志模块:lib/log.sh
一个功能完善的日志模块是脚本可观测性的基础。Mantic.sh的日志模块应该支持不同级别、颜色输出(可选)、以及输出到文件。
#!/usr/bin/env bash # 文件:lib/log.sh # 定义日志级别 readonly LOG_LEVEL_DEBUG=10 readonly LOG_LEVEL_INFO=20 readonly LOG_LEVEL_WARN=30 readonly LOG_LEVEL_ERROR=40 # 默认日志级别,可通过环境变量 LOG_LEVEL 覆盖 LOG_LEVEL="${LOG_LEVEL:-$LOG_LEVEL_INFO}" # 是否启用颜色(默认自动检测终端) if [[ -t 1 ]]; then readonly COLOR_ENABLED=true readonly COLOR_RESET="\033[0m" readonly COLOR_RED="\033[1;31m" readonly COLOR_GREEN="\033[1;32m" readonly COLOR_YELLOW="\033[1;33m" readonly COLOR_BLUE="\033[1;34m" else readonly COLOR_ENABLED=false fi # 日志函数 _log() { local level="$1" local level_name="$2" local color="$3" shift 3 local message="$*" local timestamp timestamp=$(date '+%Y-%m-%d %H:%M:%S') # 判断是否应该输出该级别日志 if [[ $level -ge $LOG_LEVEL ]]; then if $COLOR_ENABLED && [[ -n "$color" ]]; then echo -e "${color}[${timestamp}] [${level_name}] ${message}${COLOR_RESET}" >&2 else echo "[${timestamp}] [${level_name}] ${message}" >&2 fi # 可选:同时输出到日志文件 if [[ -n "${LOG_FILE:-}" ]]; then echo "[${timestamp}] [${level_name}] ${message}" >> "$LOG_FILE" fi fi } # 对外暴露的日志函数 log_debug() { _log $LOG_LEVEL_DEBUG "DEBUG" "" "$@"; } log_info() { _log $LOG_LEVEL_INFO "INFO" "$COLOR_BLUE" "$@"; } log_warn() { _log $LOG_LEVEL_WARN "WARN" "$COLOR_YELLOW" "$@"; } log_error() { _log $LOG_LEVEL_ERROR "ERROR" "$COLOR_RED" "$@"; } # 一个特殊的成功输出函数(非严格意义上的日志) log_success() { if $COLOR_ENABLED; then echo -e "${COLOR_GREEN}✓ $*${COLOR_RESET}" else echo "✓ $*" fi }实操心得:
- 日志级别动态控制:通过环境变量
LOG_LEVEL(可设置为 DEBUG, INFO, WARN, ERROR),可以在运行时控制日志的详细程度。生产环境设为WARN,调试时设为DEBUG。 - 颜色处理:使用
[[ -t 1 ]]检测标准输出是否连接到终端,避免在重定向到文件或管道时输出颜色控制字符(这会导致文件内容混乱)。 - 输出到文件:
LOG_FILE环境变量可以指定一个文件路径,日志会同时输出到终端和该文件,非常适合后台任务。 - 所有日志输出到 stderr:这是一个好习惯。这样,你的脚本的正常输出(如生成的数据、报告)可以重定向到文件(
>),而日志信息依然能在终端看到,或者被分开处理(2>)。
3.3 子命令路由与参数解析
主脚本mantic的核心任务就是路由。它需要解析用户输入,找到对应的子命令脚本并执行。同时,基本的参数解析(如--help,--version)也在这里处理。
#!/usr/bin/env bash # 文件:./mantic (项目根目录) set -euo pipefail # 导入初始化脚本 source "$(dirname "$0")/lib/init.sh" # 定义常量 readonly SCRIPT_NAME="$(basename "$0")" readonly COMMANDS_DIR="${PROJECT_ROOT}/cmd" # 显示帮助信息 show_help() { cat << EOF Usage: ${SCRIPT_NAME} <command> [options] [args] A collection of awesome system management scripts. Available commands: EOF # 动态列出 cmd/ 目录下所有 .sh 文件作为命令 for cmd in "${COMMANDS_DIR}"/*.sh; do if [[ -f "$cmd" ]]; then cmd_name="$(basename "$cmd" .sh)" # 尝试从脚本中提取一行描述(约定:第二行以 # Description: 开头) description="$(sed -n '2s/^# Description: //p' "$cmd" 2>/dev/null || echo 'No description')" printf " %-15s %s\n" "$cmd_name" "$description" fi done cat << EOF Global options: -h, --help Show this help message -v, --version Show version information Run '${SCRIPT_NAME} <command> --help' for more information on a specific command. EOF } # 显示版本 show_version() { if [[ -f "${PROJECT_ROOT}/VERSION" ]]; then cat "${PROJECT_ROOT}/VERSION" else echo "Version not specified" fi } # 主逻辑 main() { local command="" local args=() # 简单参数预处理 while [[ $# -gt 0 ]]; do case "$1" in -h|--help) show_help exit 0 ;; -v|--version) show_version exit 0 ;; -*) log_error "Unknown global option: $1" show_help exit 1 ;; *) # 第一个非选项参数视为命令 if [[ -z "$command" ]]; then command="$1" else args+=("$1") fi ;; esac shift done # 如果没有提供命令,显示帮助 if [[ -z "$command" ]]; then show_help exit 1 fi # 查找并执行命令 local command_script="${COMMANDS_DIR}/${command}.sh" if [[ ! -f "$command_script" ]]; then log_error "Unknown command: '$command'" log_info "Available commands:" for cmd in "${COMMANDS_DIR}"/*.sh; do [[ -f "$cmd" ]] && log_info " - $(basename "$cmd" .sh)" done exit 1 fi # 执行子命令,并将剩余参数传递给它 log_debug "Executing command script: $command_script with args: ${args[*]}" source "$command_script" "${args[@]}" } # 捕获脚本退出,确保日志刷新等(已在init.sh的trap中处理) main "$@"设计亮点:
- 动态命令发现:
show_help函数会遍历cmd/目录,自动列出所有可用的子命令,无需手动维护命令列表。 - 简洁的路由:主脚本只处理全局参数(
--help,--version),第一个非选项参数被识别为命令名,其余所有参数都原封不动地传递给子命令脚本。这使得子命令可以自由实现自己的复杂参数解析逻辑(例如使用getopts)。 - 子命令自治:每个
cmd/*.sh脚本都是独立的、可执行的。它们可以有自己的--help输出,可以自由地解析$@。这种松散耦合使得框架非常灵活。
4. 实战:构建一个完整的自动化备份命令
现在,让我们运用Mantic.sh框架,从头构建一个实用的backup子命令。这个命令将演示如何组织代码、使用日志库、解析参数、以及实现具体的业务逻辑。
4.1 子命令脚本结构:cmd/backup.sh
#!/usr/bin/env bash # 文件:cmd/backup.sh # Description: Backup specified directories or databases to a remote server. # 注意:这个文件会被主脚本 source,所以已经继承了 init.sh 设置的环境和所有库函数。 # 子命令自身的帮助信息 backup_usage() { cat << EOF Usage: $(basename "$0") backup [options] <source...> Backup files or databases to a configured remote destination. Arguments: source One or more local directories or database identifiers to backup. Options: -c, --config <file> Use a specific backup configuration file. -d, --destination <dir> Override the default remote destination path. -n, --dry-run Perform a trial run without making any changes. -q, --quiet Suppress non-error output. -h, --help Show this help message. Examples: $(basename "$0") backup /home/user/docs /etc/nginx $(basename "$0") backup --dry-run --destination /mnt/backup/serverA /var/www EOF } # 解析子命令的参数 parse_backup_args() { local sources=() local config_file="" local destination="" local dry_run=false local quiet=false # 使用 getopts 进行健壮的参数解析 # 注意:这里使用局部变量,避免污染全局命名空间 while [[ $# -gt 0 ]]; do case "$1" in -c|--config) if [[ -z "${2:-}" ]]; then log_error "Option '$1' requires an argument." backup_usage exit 1 fi config_file="$2" shift 2 ;; -d|--destination) if [[ -z "${2:-}" ]]; then log_error "Option '$1' requires an argument." backup_usage exit 1 fi destination="$2" shift 2 ;; -n|--dry-run) dry_run=true shift ;; -q|--quiet) quiet=true shift ;; -h|--help) backup_usage exit 0 ;; --) # 选项结束符 shift sources+=("$@") break ;; -*) log_error "Unknown option: $1" backup_usage exit 1 ;; *) sources+=("$1") shift ;; esac done # 验证必要参数 if [[ ${#sources[@]} -eq 0 ]]; then log_error "No backup sources specified." backup_usage exit 1 fi # 返回解析后的参数(通过全局变量或关联数组,这里使用全局变量简化示例) # 在实际项目中,可以考虑使用关联数组来返回多个值。 BACKUP_SOURCES=("${sources[@]}") BACKUP_CONFIG="${config_file:-${BACKUP_DEFAULT_CONFIG:-${PROJECT_ROOT}/config/backup.conf}}" BACKUP_DESTINATION="${destination:-${BACKUP_DEFAULT_DESTINATION:-/backup}}" DRY_RUN="$dry_run" QUIET="$quiet" } # 加载备份配置 load_backup_config() { local config_file="$1" if [[ ! -f "$config_file" ]]; then log_warn "Backup config file not found: $config_file. Using defaults." return 1 fi log_debug "Loading backup config from: $config_file" # 安全地source配置文件,可以在这里定义一些默认变量 source "$config_file" || { log_error "Failed to load config file: $config_file" exit 1 } } # 执行备份的核心逻辑 perform_backup() { local source="$1" local destination="$2" local timestamp timestamp=$(date '+%Y%m%d_%H%M%S') local backup_name="backup_$(basename "$source")_${timestamp}.tar.gz" local full_dest_path="${destination}/${backup_name}" log_info "Starting backup of: $source" # 检查源是否存在 if [[ ! -e "$source" ]]; then log_error "Source does not exist: $source" return 1 fi # Dry-run 模式只显示将要执行的操作 if [[ "$DRY_RUN" == true ]]; then log_info "[DRY RUN] Would create backup: $full_dest_path from $source" return 0 fi # 实际备份操作:使用 tar 进行压缩归档 # 使用 rsync 到远程服务器是更常见的场景,这里用本地tar示例 local tar_cmd=(tar -czf "$full_dest_path" -C "$(dirname "$source")" "$(basename "$source")") log_debug "Executing: ${tar_cmd[*]}" if "${tar_cmd[@]}" 2>/dev/null; then log_success "Backup created successfully: $full_dest_path" # 可选:计算并记录文件大小、校验和 local size size=$(du -h "$full_dest_path" | cut -f1) log_info "Backup size: $size" else log_error "Failed to create backup of: $source" return 1 fi } # 子命令的主函数 backup_main() { log_info "=== Backup Procedure Started ===" # 1. 解析参数 parse_backup_args "$@" # 2. 加载配置 load_backup_config "$BACKUP_CONFIG" # 3. 验证目标目录 if [[ "$DRY_RUN" != true ]] && [[ ! -d "$BACKUP_DESTINATION" ]]; then log_warn "Destination directory does not exist, attempting to create: $BACKUP_DESTINATION" mkdir -p "$BACKUP_DESTINATION" || { log_error "Failed to create destination directory." exit 1 } fi # 4. 遍历所有源并执行备份 local exit_code=0 for source in "${BACKUP_SOURCES[@]}"; do if ! perform_backup "$source" "$BACKUP_DESTINATION"; then log_error "Backup for source '$source' encountered errors." exit_code=1 # 是否继续备份其他源?这里选择继续。 fi done if [[ $exit_code -eq 0 ]]; then log_success "=== All backup tasks completed successfully ===" else log_error "=== Backup procedure completed with errors ===" fi exit $exit_code } # 脚本入口:只有当这个文件被直接执行时,才运行测试逻辑。 # 当被主脚本 source 时,下面的判断为 false,不会执行。 if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then echo "This script is designed to be sourced by the main mantic script." >&2 echo "Please run './mantic backup' instead." >&2 exit 1 fi # 当被主脚本 source 后,主脚本会调用 backup_main "$@",所以这里不需要主动调用。4.2 配套配置文件示例:config/backup.conf
# 文件:config/backup.conf # 备份模块默认配置 # 默认远程备份服务器(示例,实际可能用 rsync over SSH) # BACKUP_REMOTE_HOST="backup-server.example.com" # BACKUP_REMOTE_USER="backupuser" # BACKUP_REMOTE_PATH="/mnt/backup-storage" # 本地备份默认目录(会被命令行参数覆盖) BACKUP_DEFAULT_DESTINATION="/var/backups/$(hostname -s)" # 备份保留策略(保留最近多少天的备份) BACKUP_RETENTION_DAYS=30 # 是否启用加密(需要安装 gpg) # BACKUP_ENCRYPT=false # BACKUP_GPG_RECIPIENT="backup@example.com" # 需要排除的文件模式,空格分隔 BACKUP_EXCLUDE_PATTERNS="*.tmp *.log *.cache"4.3 如何使用这个备份命令
假设你的项目已经按照上述结构搭建好,并且主脚本mantic具有可执行权限 (chmod +x mantic)。
# 1. 查看帮助 ./mantic backup --help # 2. 执行一个简单的备份(备份两个目录到默认位置) ./mantic backup /home/user/Documents /etc/nginx # 3. 干跑测试,看看会做什么而不实际执行 ./mantic backup --dry-run --destination /mnt/external_drive /var/www # 4. 使用自定义配置文件 ./mantic backup --config ./config/prod-backup.conf /opt/app/data执行流程回溯:
- 用户输入
./mantic backup /home/user --dry-run。 - 主脚本
mantic识别命令为backup,剩余参数为['/home/user', '--dry-run']。 - 主脚本找到
cmd/backup.sh并source它,同时将剩余参数传递给backup_main函数(通过main函数最后的source "$command_script" "${args[@]}",这相当于在backup.sh的上下文中执行了backup_main "${args[@]}")。 backup.sh中的backup_main函数被调用,参数为['/home/user', '--dry-run']。parse_backup_args解析参数,设置BACKUP_SOURCES=('/home/user'),DRY_RUN=true。- 加载配置,验证路径,进入
perform_backup循环。 - 由于
DRY_RUN=true,perform_backup函数只打印日志而不执行tar命令。 - 脚本退出,返回状态码。
5. 高级技巧、常见问题与排查指南
5.1 性能优化与最佳实践
- 减少子Shell调用:在循环中避免使用
$(command),特别是在处理大量文件时。可以考虑使用while read循环或for循环直接处理。# 不佳 for file in $(find . -name "*.txt"); do ... done # 更佳 (处理带空格的文件名也安全) find . -name "*.txt" -print0 | while IFS= read -r -d '' file; do ... done - 使用数组传递参数:当命令参数可能包含空格或特殊字符时,使用数组来构建命令是最安全的方式(如上面
tar_cmd的例子)。 - 缓存命令路径:如果你频繁调用外部命令(如
jq,aws),可以将其路径缓存到变量中,避免重复进行which查找。readonly JQ_BIN="$(command -v jq)" || { log_error "jq not found"; exit 1; } - 脚本执行超时控制:对于可能长时间运行或挂起的任务,使用
timeout命令。if timeout 300s some_long_running_command; then log_success "Command completed." else exit_code=$? if [[ $exit_code -eq 124 ]]; then log_error "Command timed out after 300s." else log_error "Command failed with code: $exit_code." fi fi
5.2 安全性考量
- 谨慎
source:init.sh和主脚本会source很多文件。务必确保这些文件(尤其是配置文件如.env)的来源可信,并且其中不包含恶意命令。 - 验证用户输入:在子命令中,对所有来自外部的参数(如文件名、路径)进行严格的验证,防止命令注入。
# 危险! local user_input=$1 rm -rf /some/path/$user_input # 如果 user_input 是 "../../etc/passwd",后果严重。 # 更安全:使用参数替换或白名单验证 local safe_input="${1//[^a-zA-Z0-9._-]/}" # 移除非允许字符 # 或检查路径是否在允许的范围内 - 最小权限原则:考虑是否需要以 root 权限运行整个脚本。或许只有特定子命令需要
sudo,可以使用sudo精细控制。
5.3 常见问题排查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
执行./mantic报错source: not found | 脚本在非Bash Shell(如dash,Ubuntu的默认/bin/sh)中运行。 | 1. 确保脚本第一行是#!/usr/bin/env bash。2. 使用 bash ./mantic显式调用。 |
| 子命令脚本中的函数未定义 | 子命令脚本没有被source,而是被作为独立脚本执行了。 | 确保子命令脚本是通过主脚本调用的。检查子命令脚本末尾的if [[ "${BASH_SOURCE[0]}" == "${0}" ]]保护块。 |
| 日志没有输出颜色 | 脚本输出被重定向到了文件或管道。 | lib/log.sh中已通过[[ -t 1 ]]自动检测并禁用颜色。这是正常行为。如果需要文件中也带颜色(通常不需要),可以设置强制颜色变量。 |
| 配置文件中的变量未生效 | 1. 配置文件语法错误。 2. 变量名冲突或被覆盖。 3. 配置文件未被加载。 | 1. 在init.sh的source配置后,添加set | grep YOUR_VAR调试。2. 检查配置文件中是否有语法错误(如未闭合的引号)。 3. 确认配置文件路径正确,且 init.sh成功读取。 |
set -e导致脚本意外退出 | 某些命令返回非零状态是正常的(如grep没找到匹配)。 | 对于预期可能失败的命令,使用 ` |
在函数中exit导致整个Shell退出 | 脚本被source后,exit会退出当前Shell会话。 | 在子命令脚本或库函数中,使用return来退出函数,并将错误码传递给调用者,由主流程决定是否exit。或者,确保脚本是通过子Shell方式执行的。 |
5.4 扩展框架:添加插件机制
当你的脚本库越来越庞大,你可能希望某些功能模块是可插拔的。一个简单的插件机制可以这样实现:
- 创建
plugins/目录。 - 在
lib/init.sh末尾添加插件加载逻辑:# 加载插件 PLUGINS_DIR="${PROJECT_ROOT}/plugins" if [[ -d "$PLUGINS_DIR" ]]; then for plugin in "${PLUGINS_DIR}"/*.sh; do if [[ -f "$plugin" ]]; then log_debug "Loading plugin: $(basename "$plugin")" source "$plugin" fi done fi - 插件示例
plugins/notify-slack.sh:# 提供一个函数,供其他脚本调用 notify_slack() { local message="$1" local webhook_url="${SLACK_WEBHOOK_URL:-}" if [[ -z "$webhook_url" ]]; then log_warn "SLACK_WEBHOOK_URL not set, cannot send notification." return 1 fi curl -X POST -H 'Content-type: application/json' \ --data "{\"text\":\"$message\"}" \ "$webhook_url" >/dev/null 2>&1 && log_info "Slack notification sent." || log_error "Failed to send Slack notification." } - 在备份命令成功后调用插件:
# 在 cmd/backup.sh 的 perform_backup 成功部分 if "${tar_cmd[@]}" 2>/dev/null; then log_success "Backup created successfully: $full_dest_path" # 调用插件(如果存在) if command -v notify_slack &>/dev/null; then notify_slack "Backup succeeded: $full_dest_path" fi fi
通过这种方式,Mantic.sh从一个简单的脚本管理器,进化成了一个支持生态扩展的轻量级自动化平台。你可以根据需要添加监控插件、数据库备份插件、云存储上传插件等等,所有功能都通过清晰的目录结构和约定有机地整合在一起。