「速通Shell」Shell 脚本模块化

上一篇我们讲了错误处理和日志,让脚本从能跑变成靠得住,但靠得住还有一个隐藏前提------脚本得能维护。

如果你写过一个几千行的 shell 脚本,你大概率经历过这种痛苦:想找一个函数,要按 Ctrl+F 在几百行里翻;想改个公共逻辑,发现它在 5 个不同的地方各写了一遍;想给同事复用,他拷走之后你改了 bug,他那份还是旧的;想加新功能,不知道往哪儿塞......

这就是代码组织的问题。前几篇我们一直在加能力(循环、字符串、数组、错误处理),这一篇换话题------讲怎么把能力组织起来。我们从函数库开始,跨过双重用途脚本,最后到多脚本项目。读完这一篇,你应该能把自己的 shell 脚本从一个大文件变成一个小项目。

一、先理解"模块化"在 shell 里意味着什么

在大多数编程语言里,模块化是语言内置的概念:Python 有 import,Go 有 package,Java 有 import + class 体系。shell 没有这些,在 shell 里,模块只是你人为组织代码的方式,没有强制约束。

这种自由既是优势也是劣势:

  • 优势:没有复杂的依赖管理,不需要构建系统,文件复制就能分发。运维脚本、CI 流水线、容器启动脚本这些场景下,shell 的"无包袱"反而是优点。
  • 劣势:没有命名空间,没有版本管理,没有自动加载。一个变量名写错,可能引用到完全不相关的代码里的同名变量。

所以 shell 的模块化本质上是约定 + 自律:你定一套规则,自己遵守,自己维护。前面几篇介绍的 set -euo pipefail、"${arr[@]}" 加引号等等,都是这类约定。这一篇要讲的是更宏观的约定------代码怎么组织。

二、把常用函数抽到库文件

模块化的第一步是识别重复代码。当你发现某个函数在多个脚本里都有一份,或者某段逻辑改一处就要改五六处,就该抽出来了。

2.1 库文件长什么样

最简单的情况下,库文件就是一个只包含变量和函数定义 的纯 shell 脚本,不执行任何业务逻辑:

bash 复制代码
# lib_log.sh - 日志工具库
# 用法: source lib_log.sh
​
# 防止被重复加载
[[ -n "${_LIB_LOG_LOADED:-}" ]] && return
_LIB_LOG_LOADED=1
​
# 默认配置
LOG_LEVEL=${LOG_LEVEL:-INFO}
LOG_FILE=${LOG_FILE:-}
​
log() {
    local level=$1
    shift
    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M:%S')
​
    case $level in
        DEBUG) [[ "$LOG_LEVEL" =~ DEBUG ]] || return 0 ;;
        INFO)  [[ "$LOG_LEVEL" =~ (DEBUG|INFO) ]] || return 0 ;;
        WARN)  [[ "$LOG_LEVEL" =~ (DEBUG|INFO|WARN) ]] || return 0 ;;
    esac
​
    local msg="[$timestamp] [$level] $*"
    if [[ -n "$LOG_FILE" ]]
    then
        echo "$msg" | tee -a "$LOG_FILE" >&2
    else
        echo "$msg" >&2
    fi
}
​
die() {
    log ERROR "$*"
    exit 1
}

注意开头那段:

lua 复制代码
[[ -n "${_LIB_LOG_LOADED:-}" ]] && return
_LIB_LOG_LOADED=1

这是 shell 库文件的防重入模式------_LIB_LOG_LOADED 是个特殊命名的全局变量(前面加下划线表示"私有"),用来标记这个库是否已经被加载过。如果已加载,直接 return 退出;否则设置标记并继续加载。

return 而不是 exit 是关键------return 只是退出当前 source 上下文,不会终止调用脚本。exit 会把整个脚本都终结掉。

2.2 怎么导入库

source 命令(或者它的简写 .)能把另一个 shell 脚本导入到当前上下文------本质上是在当前 shell 里把那个文件执行一遍:

bash 复制代码
#!/bin/bash
set -euo pipefail
​
# 导入日志库
source "$(dirname "$0")/lib/lib_log.sh"
​
# 现在可以直接用 log 和 die
log INFO "脚本启动"
log INFO "参数个数: $#"

source "$(dirname "$0")/lib/lib_log.sh" 是最常见的库引用方式:

  • $(dirname "$0") 拿到当前脚本所在的目录
  • /lib/lib_log.sh 是库文件的相对路径

为什么要用 $(dirname "$0") 而不是写死绝对路径?因为脚本可能在任何目录下被调用。你写的是 ./bin/myscript.sh 还是 bash /opt/project/bin/myscript.sh,路径完全不同。用 $0 拿到脚本自己的位置,再拼上相对路径,无论怎么调用都能找到库。

2.3 source 和 . 是等价的

bash 复制代码
source lib_log.sh
. lib_log.sh

两者完全一样。但有几个细节差异:

  • source 是 bash 关键字,比 . 易读,建议优先用。
  • . 在所有 POSIX shell 里都能用,跨平台写脚本时更稳。
  • source 在某些系统上是 bash 内建,/usr/bin/source 不存在------但 bash 内建版本足够用。

2.4 库文件的设计原则

库不是把代码搬过去那么简单,它有一些设计原则:

原则一:库不执行业务逻辑。 库文件应该只定义函数和变量,不应该有做事情的代码(比如 rm /tmp/workfile、curl ...),否则每次 source 都会执行一次。

原则二:所有变量加 local 或特殊前缀。 库文件里的函数应该用 local 声明局部变量;库自己定义的全局变量(如 _LIB_LOG_LOADED)用下划线前缀表示内部使用,避免和用户代码冲突。

原则三:提供清晰的文档注释。 库的开头应该有注释说明用途、依赖、用法:

shell 复制代码
# lib_http.sh - HTTP 请求工具库
# 依赖: curl
# 用法: source lib_http.sh
#
# 提供的函数:
#   http_get <url>            - 发起 GET 请求,输出 body
#   http_post <url> <data>    - 发起 POST 请求,输出 body
#   http_status <url>         - 获取 HTTP 状态码

原则四:依赖外部命令时显式声明。 库函数用到了 curl、jq、awk 这些外部工具时,最好在文件开头用 command -v 检查:

bash 复制代码
# 检查依赖
for cmd in curl jq
do
    command -v "$cmd" >/dev/null 2>&1 || {
        echo "lib_http.sh 依赖 $cmd,请先安装" >&2
        return 1
    }
done

2.5 一个常用的工具库示例

把日志、错误处理、参数解析这些几乎所有脚本都要用的能力抽出来:

bash 复制代码
# lib_common.sh - 通用工具库
# 用法: source lib_common.sh
​
[[ -n "${_LIB_COMMON_LOADED:-}" ]] && return
_LIB_COMMON_LOADED=1
​
# ============================================================
# 日志
# ============================================================
LOG_LEVEL=${LOG_LEVEL:-INFO}
LOG_FILE=${LOG_FILE:-}
​
_log_impl() {
    local level=$1
    shift
    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M:%S')
​
    case $level in
        DEBUG) [[ "$LOG_LEVEL" =~ DEBUG ]] || return 0 ;;
        INFO)  [[ "$LOG_LEVEL" =~ (DEBUG|INFO) ]] || return 0 ;;
        WARN)  [[ "$LOG_LEVEL" =~ (DEBUG|INFO|WARN) ]] || return 0 ;;
    esac
​
    local msg="[$timestamp] [$level] $*"
    if [[ -n "$LOG_FILE" ]]
    then
        echo "$msg" | tee -a "$LOG_FILE" >&2
    else
        echo "$msg" >&2
    fi
}
​
log_info()  { _log_impl INFO  "$@"; }
log_warn()  { _log_impl WARN  "$@"; }
log_error() { _log_impl ERROR "$@"; }
log_debug() { _log_impl DEBUG "$@"; }
​
die() {
    log_error "$*"
    exit 1
}
​
# ============================================================
# 参数校验
# ============================================================
require_file() {
    local file=$1
    [ -f "$file" ] || die "文件不存在: $file"
    [ -r "$file" ] || die "文件不可读: $file"
}
​
require_cmd() {
    local cmd=$1
    command -v "$cmd" >/dev/null 2>&1 || die "命令未安装: $cmd"
}
​
# ============================================================
# 工具函数
# ============================================================
trim() {
    local var="$*"
    var="${var#"${var%%[![:space:]]*}"}"
    var="${var%"${var##*[![:space:]]}"}"
    echo "$var"
}
​
# 询问用户确认
confirm() {
    local prompt=${1:-"是否继续?"}
    local answer
    read -r -p "$prompt [y/N] " answer
    [[ "$answer" =~ ^[Yy]$ ]]
}
​
# 获取脚本所在目录的绝对路径
script_dir() {
    local src=${BASH_SOURCE[1]}
    local dir
    dir=$(cd "$(dirname "$src")" && pwd)
    echo "$dir"
}

这个库可以拼装到任何脚本里,主脚本立刻就有了日志、错误处理、参数校验、确认提示等能力。

三、可被 source 的脚本(双重用途)

库文件是最基础的模块化单元,但很多时候我们想写一个既能自己跑,又能被其他脚本调用的脚本,这就是双重用途脚本。

3.1 什么是双重用途

shell 复制代码
$ ./deploy.sh production            # 直接执行:部署到生产环境
$ source deploy.sh                  # 被其他脚本导入:复用部署逻辑

同一个脚本,根据调用方式不同,行为不同:

  • 直接执行(./deploy.sh):跑完整的业务流程
  • 被 source(source deploy.sh):只加载函数定义,不执行

这种模式在工具脚本里非常常见。

3.2 怎么区分两种调用方式

bash 提供了一个内置变量 BASH_SOURCE,记录了每个 source 调用的文件名。BASH_SOURCE[0] 是当前脚本的文件名,当且仅当当前脚本是直接执行时,它才等于 $0。

bash 复制代码
#!/bin/bash
# myscript.sh
​
echo "BASH_SOURCE[0] = ${BASH_SOURCE[0]}"
echo "$0              = $0"
echo "是否直接执行: $([[ "${BASH_SOURCE[0]}" = "$0" ]] && echo "是" || echo "否")"

直接执行:

bash 复制代码
$ ./myscript.sh
BASH_SOURCE[0] = ./myscript.sh
$0              = ./myscript.sh
是否直接执行: 是

被 source:

ini 复制代码
$ source myscript.sh
BASH_SOURCE[0] = myscript.sh
$0              = bash        # 或者调用方的脚本名
是否直接执行: 否

看出来区别了:$0 在 source 模式下是调用方的脚本名,而 BASH_SOURCE[0] 始终是当前脚本自己。利用这个差异就能判断调用方式。

3.3 双重用途脚本的标准结构

bash 复制代码
#!/bin/bash
# deploy.sh - 部署脚本(可直接执行,也可被 source)
​
set -euo pipefail
​
# ============================================================
# 公共函数定义(可被 source)
# ============================================================
deploy_to() {
    local env=$1
    log_info "开始部署到 $env"
    # ... 部署逻辑
    log_info "部署完成"
}
​
rollback() {
    local env=$1
    log_info "回滚 $env"
    # ... 回滚逻辑
    log_info "回滚完成"
}
​
# ============================================================
# 主逻辑(仅在直接执行时运行)
# ============================================================
main() {
    # 参数解析
    local env=${1:-}
​
    if [[ -z "$env" ]]
    then
        echo "用法: $0 <环境>" >&2
        exit 1
    fi
​
    # 加载依赖库
    source "$(dirname "${BASH_SOURCE[0]}")/lib/lib_common.sh"
​
    # 执行业务
    deploy_to "$env"
}
​
# 判断调用方式
if [[ "${BASH_SOURCE[0]}" = "$0" ]]
then
    # 直接执行 - 调用 main
    main "$@"
fi
​
# 被 source 时不执行 main,只加载函数

关键代码:

bash 复制代码
if [[ "${BASH_SOURCE[0]}" = "$0" ]]
then
    main "$@"
fi

直接执行时 BASH_SOURCE[0] = $0,进入 if 分支调用 main。被 source 时 BASH_SOURCE[0] 还是脚本自己,但 $0 是调用方,条件不成立,main 不执行------但 main 之前的函数定义都已经被加载到当前 shell 里了,调用方可以直接用。

3.4 几个细节

细节一:库加载放在哪? 上面的例子把 source lib_common.sh 放在 main 函数里。这意味着被 source 时不会加载 lib_common.sh------只有直接执行才会。

如果想让双重用途都加载库,可以把它提到文件最前面(在函数定义之前),但要注意库文件也要支持被 source(不能有副作用)。

细节二:main 函数 + if 判断 vs 直接写主逻辑。 用 main 函数 + 末尾 if 调用的好处是:

  • 主逻辑被包成函数,可以被复用
  • 末端的 if 判断是"双重用途"的标准模式
  • 调试时可以直接 main 测试

直接写主逻辑(不用 main 函数)也行,但失去了这些好处。

细节三:参数处理的位置。 上面的例子把参数解析放在 main 里,这是有意的------被 source 时不应该对参数有任何要求。脚本被 source 时它的参数是调用方的,不归它管。

细节四:用 ${BASH_SOURCE[0]} 还是 $0 拿自己的路径? 这是一个常见的困惑。看场景:

  • 拿当前脚本文件的路径,用 ${BASH_SOURCE[0]}(在双重用途下永远正确)
  • 拿用户调用命令的第一个字段,用 $0(直接执行时是脚本路径,source 时是调用方)

四、多脚本项目

当脚本数量增长到十几个甚至几十个,光靠一个 main 脚本 + 几个库就不够了。我们需要一个目录结构来组织它们。

4.1 一个合理的项目结构

python 复制代码
myproject/
├── bin/                  # 可执行入口
│   ├── myproject         # 主入口
│   └── myproject-admin   # 管理员入口
├── lib/                  # 库文件
│   ├── lib_common.sh
│   ├── lib_log.sh
│   ├── lib_config.sh
│   └── lib_db.sh
├── src/                  # 业务逻辑
│   ├── deploy.sh
│   ├── backup.sh
│   └── monitor.sh
├── conf/                 # 配置文件
│   ├── myproject.conf
│   └── myproject.env
├── tests/                # 测试
│   ├── test_common.sh
│   └── test_deploy.sh
├── docs/                 # 文档
└── README.md

这是一个常见的 shell 项目布局。bin/ 是入口,lib/ 是通用工具,src/ 是具体业务,conf/ 是配置,tests/ 是测试。每个目录职责单一,新人上手时知道代码在哪、配置在哪、入口在哪。

4.2 路径处理

项目目录结构一旦确定,脚本就要解决一个问题:怎么可靠地找到库和配置文件,无论脚本被从哪里调用。

bash 复制代码
#!/bin/bash
# bin/myproject
​
set -euo pipefail
​
# ============================================================
# 路径解析
# ============================================================
# 当前脚本的真实路径
SCRIPT_PATH="${BASH_SOURCE[0]}"
# 当前脚本所在目录
SCRIPT_DIR=$(cd "$(dirname "$SCRIPT_PATH")" && pwd)
# 项目根目录
PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
​
# ============================================================
# 库加载
# ============================================================
source "$PROJECT_ROOT/lib/lib_common.sh"
source "$PROJECT_ROOT/lib/lib_log.sh"
source "$PROJECT_ROOT/lib/lib_config.sh"
​
# ============================================================
# 业务逻辑
# ============================================================
main() {
    log_info "项目根: $PROJECT_ROOT"
​
    # 加载配置
    load_config "$PROJECT_ROOT/conf/myproject.conf"
​
    # 执行业务
    run_command "$@"
}
​
main "$@"

cd "$(dirname "$SCRIPT_PATH")" && pwd 这个写法是 shell 里的"取绝对路径"标准姿势:

  • dirname 拿到目录
  • cd 切过去
  • pwd 拿到绝对路径
  • 整个 $(...) 替换成绝对路径字符串

为什么不用 realpath 命令?因为它不是所有系统都有(macOS 要装 coreutils)。上面这个写法是纯 bash 的,跨平台。

PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) 拿到项目根(这里假设 bin/ 在项目根的下一级)。这种"硬编码目录层级"的假设在小型项目里是 OK 的,大型项目可以考虑用 git rev-parse --show-toplevel 之类的方法动态确定根。

4.3 子命令模式

类似 git、docker 这种工具------一个入口脚本,根据第一个参数分发到不同的子命令:

shell 复制代码
$ myproject deploy production
$ myproject backup all
$ myproject status
$ myproject --help

实现起来不复杂:

bash 复制代码
#!/bin/bash
# bin/myproject
​
set -euo pipefail
​
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
​
source "$PROJECT_ROOT/lib/lib_common.sh"
​
# 加载子命令
load_command() {
    local cmd=$1
    local cmd_file="$PROJECT_ROOT/src/${cmd}.sh"
​
    if [[ ! -f "$cmd_file" ]]
    then
        die "未知命令: $cmd(可用的命令: deploy, backup, status)"
    fi
​
    source "$cmd_file"
}
​
main() {
    # 无参数显示帮助
    if [[ $# -eq 0 ]]
    then
        show_help
        exit 0
    fi
​
    local cmd=$1
    shift
​
    # 全局选项
    case $cmd in
        -h|--help|help)
            show_help
            exit 0
            ;;
        --version|-v)
            echo "myproject 1.0.0"
            exit 0
            ;;
    esac
​
    # 加载并执行子命令
    load_command "$cmd"
​
    # 子命令文件应该定义 main_<cmd> 函数
    if declare -F "main_${cmd}" >/dev/null
    then
        "main_${cmd}" "$@"
    else
        die "子命令 $cmd 没有定义 main_${cmd} 函数"
    fi
}
​
show_help() {
    cat <<EOF
myproject - 项目管理工具
​
用法: myproject <命令> [选项]
​
可用命令:
  deploy     部署应用
  backup     备份数据
  status     查看状态
​
通用选项:
  -h, --help     显示帮助
  -v, --version  显示版本
​
示例:
  myproject deploy production
  myproject backup all
EOF
}
​
main "$@"

然后 src/deploy.sh 写业务:

bash 复制代码
#!/bin/bash
# src/deploy.sh - deploy 子命令
​
# 防止直接执行
[[ "${BASH_SOURCE[0]}" = "$0" ]] && {
    echo "请通过 myproject deploy 调用" >&2
    exit 1
}
​
main_deploy() {
    local env=${1:-}
    [[ -z "$env" ]] && die "用法: myproject deploy <环境>"
​
    log_info "开始部署到 $env"
    # ... 实际部署逻辑
    log_info "部署完成"
}

这套"入口脚本 + 子命令文件"的模式有几个好处:

  • 每个子命令一个文件,代码不会挤在一起
  • 新增子命令不用改主入口
  • 子命令文件可以被独立测试
  • 用户体验好(一个二进制对外)

4.4 配置管理

项目大了之后,配置(数据库地址、API 密钥、路径等)必须从代码里抽出来。最简单的方案是 key=value 文件:

ini 复制代码
# conf/myproject.conf
DB_HOST=localhost
DB_PORT=3306
DB_USER=admin
DB_PASSWORD=secret123
API_URL=https://api.example.com
LOG_LEVEL=INFO
LOG_FILE=/var/log/myproject.log

加载函数:

bash 复制代码
# lib/lib_config.sh
[[ -n "${_LIB_CONFIG_LOADED:-}" ]] && return
_LIB_CONFIG_LOADED=1
​
load_config() {
    local file=$1
    [[ -f "$file" ]] || die "配置文件不存在: $file"
​
    while IFS='=' read -r key value
    do
        # 跳过空行和注释
        [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
        # 去掉 key 两端空白
        key=$(echo "$key" | xargs)
        # 去掉 value 两端空白和引号
        value="${value#"${value%%[![:space:]]*}"}"
        value="${value%"${value##*[![:space:]]}"}"
        value="${value%"}"
        value="${value#"}"
        # 导出变量
        export "$key=$value"
    done < "$file"
}

使用:

bash 复制代码
load_config "$PROJECT_ROOT/conf/myproject.conf"
log_info "DB host: $DB_HOST"

这套机制简单但够用。生产项目也可以用 YAML、TOML、JSON,但要付出"依赖 yq/jq"的代价。日常 shell 项目,key=value 配置文件是最务实的选择。

五、模块化的高级技巧

前面三节覆盖了 80% 的项目组织场景。剩下 20% 是一些高级技巧,知道就能让项目更专业。

5.1 库的搜索路径

库多了之后,每个脚本开头都 source "$PROJECT_ROOT/lib/lib_xxx.sh" 会很啰嗦。可以让lib 目录自动发现:

bash 复制代码
# lib_path.sh - 库路径管理
[[ -n "${_LIB_PATH_LOADED:-}" ]] && return
_LIB_PATH_LOADED=1
​
# 库搜索路径(按优先级)
LIB_PATHS=(
    "$PROJECT_ROOT/lib"
    "$HOME/.myproject/lib"
    "/usr/local/lib/myproject"
)
​
# 在搜索路径中查找库
find_lib() {
    local lib_name=$1
    for path in "${LIB_PATHS[@]}"
    do
        if [[ -f "$path/$lib_name" ]]
        then
            echo "$path/$lib_name"
            return 0
        fi
    done
    return 1
}
​
# 加载库(自动从搜索路径查找)
load_lib() {
    local lib_name=$1
    local lib_path
    lib_path=$(find_lib "$lib_name") || die "找不到库: $lib_name"
    source "$lib_path"
}

使用:

复制代码
load_lib lib_log.sh
load_lib lib_config.sh
load_lib lib_db.sh

这种方式在大型项目里很有用------第三方库可以放在用户家目录或系统目录里,项目本身的库优先级最高。

5.2 条件加载

有些库只在特定场景下需要(比如调试库、生产环境不需要)。可以用条件加载:

lua 复制代码
# 默认不加载调试库
[[ "${DEBUG:-0}" == "1" ]] && load_lib lib_debug.sh
​
# 加载可选的特性库
[[ -n "${MYPROJECT_USE_DB:-}" ]] && load_lib lib_db.sh

DEBUG 是个环境变量,调试时 DEBUG=1 ./myproject 即可启用。

5.3 版本管理

库文件多了之后,版本管理是必须考虑的事。简单的做法是文件名带版本号:

bash 复制代码
lib/
├── lib_log_v1.sh
├── lib_log_v2.sh
└── lib_log.sh -> lib_log_v2.sh   # 软链接指向当前版本

或者用环境变量选择版本:

bash 复制代码
# 在 lib_path.sh 里
case "${LIB_VERSION:-latest}" in
    v1) LIB_PATHS=("$PROJECT_ROOT/lib/v1" "${LIB_PATHS[@]}") ;;
    v2|"") LIB_PATHS=("$PROJECT_ROOT/lib/v2" "${LIB_PATHS[@]}") ;;
esac

这种版本目录模式在多版本兼容的场景下很有用。日常小项目用不到------但一旦你的库被多个项目共享,版本管理就变得重要。

5.4 库的依赖管理

库之间可能有依赖关系------比如 lib_db.sh 依赖 lib_log.sh。最简单的方式是被依赖的库在文件开头先 source:

bash 复制代码
# lib_db.sh
[[ -n "${_LIB_DB_LOADED:-}" ]] && return
_LIB_DB_LOADED=1
​
# 先加载依赖
source "$(dirname "${BASH_SOURCE[0]}")/lib_log.sh"
​
# 再定义自己的函数
db_connect() {
    log_info "连接数据库: $DB_HOST:$DB_PORT"
    # ...
}

$(dirname "${BASH_SOURCE[0]}") 在 source 时拿到的是被 source 的文件自己的目录,所以无论从哪个地方调用,库都能正确找到它的依赖。

5.5 函数命名空间

shell 没有命名空间,所有函数都在全局作用域。两个项目用同名函数会冲突。常见做法是给函数加前缀:

javascript 复制代码
# myproject 的函数都加 myp_ 前缀
myp_log_info() { ... }
myp_log_warn() { ... }
myp_db_connect() { ... }
​
# 另一个项目用其他前缀
other_log_info() { ... }

或者按"模块"加前缀:

javascript 复制代码
# lib_log.sh 提供 log_*
log_info() { ... }
log_warn() { ... }
​
# lib_db.sh 提供 db_*
db_connect() { ... }
db_query() { ... }

我们前面 lib_common.sh 的设计就是这种"按模块分前缀"的------log_info、die、require_file、confirm,每个函数名都自带语义,不会跟用户代码冲突。

六、完整实战:搭建一个迷你项目

我们把前面讲的所有东西串起来,搭一个迷你项目。这个项目演示一个部署工具------支持多环境部署、配置管理、日志、可被复用。

6.1 项目结构

css 复制代码
deploy-tool/
├── bin/
│   └── deploy
├── lib/
│   ├── lib_common.sh
│   └── lib_log.sh
├── src/
│   ├── cmd_deploy.sh
│   ├── cmd_rollback.sh
│   └── cmd_status.sh
├── conf/
│   ├── dev.conf
│   └── production.conf
└── README.md

6.2 库文件:lib_log.sh

bash 复制代码
# lib_log.sh - 日志库
[[ -n "${_LIB_LOG_LOADED:-}" ]] && return
_LIB_LOG_LOADED=1
​
LOG_LEVEL=${LOG_LEVEL:-INFO}
LOG_FILE=${LOG_FILE:-}
​
_log() {
    local level=$1; shift
    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M:%S')
​
    case $level in
        DEBUG) [[ "$LOG_LEVEL" =~ DEBUG ]] || return 0 ;;
        INFO)  [[ "$LOG_LEVEL" =~ (DEBUG|INFO) ]] || return 0 ;;
        WARN)  [[ "$LOG_LEVEL" =~ (DEBUG|INFO|WARN) ]] || return 0 ;;
    esac
​
    local msg="[$timestamp] [$level] $SCRIPT_NAME: $*"
    if [[ -n "$LOG_FILE" ]]
    then
        echo "$msg" | tee -a "$LOG_FILE" >&2
    else
        echo "$msg" >&2
    fi
}
​
log_info()  { _log INFO  "$@"; }
log_warn()  { _log WARN  "$@"; }
log_error() { _log ERROR "$@"; }
log_debug() { _log DEBUG "$@"; }

6.3 库文件:lib_common.sh

bash 复制代码
# lib_common.sh - 通用工具
[[ -n "${_LIB_COMMON_LOADED:-}" ]] && return
_LIB_COMMON_LOADED=1
​
# 依赖 lib_log.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib_log.sh"
​
die() {
    log_error "$*"
    exit 1
}
​
require_file() {
    local file=$1
    [ -f "$file" ] || die "文件不存在: $file"
    [ -r "$file" ] || die "文件不可读: $file"
}
​
require_cmd() {
    local cmd=$1
    command -v "$cmd" >/dev/null 2>&1 || die "命令未安装: $cmd"
}
​
confirm() {
    local prompt=${1:-"是否继续?"}
    local answer
    read -r -p "$prompt [y/N] " answer
    [[ "$answer" =~ ^[Yy]$ ]]
}
​
# 加载配置
load_config() {
    local file=$1
    require_file "$file"
​
    while IFS='=' read -r key value
    do
        [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
        key="${key// /}"
        value="${value#"${value%%[![:space:]]*}"}"
        value="${value%"${value##*[![:space:]]}"}"
        value="${value%"}"
        value="${value#"}"
        export "$key=$value"
    done < "$file"
}

6.4 入口:bin/deploy

bash 复制代码
#!/bin/bash
# bin/deploy - 部署工具入口
​
set -euo pipefail
​
# ============================================================
# 路径解析
# ============================================================
SCRIPT_PATH="${BASH_SOURCE[0]}"
SCRIPT_DIR=$(cd "$(dirname "$SCRIPT_PATH")" && pwd)
PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
SCRIPT_NAME=$(basename "$SCRIPT_PATH")
​
# ============================================================
# 加载库
# ============================================================
source "$PROJECT_ROOT/lib/lib_common.sh"
​
# ============================================================
# 子命令分发
# ============================================================
COMMANDS=(deploy rollback status)
​
show_help() {
    cat <<EOF
$SCRIPT_NAME - 部署工具
​
用法: $SCRIPT_NAME <命令> [选项]
​
可用命令:
  deploy     部署应用
  rollback   回滚部署
  status     查看部署状态
​
环境变量:
  LOG_LEVEL  日志级别 (DEBUG|INFO|WARN|ERROR),默认 INFO
  LOG_FILE   日志输出文件,默认 stderr
EOF
}
​
main() {
    if [[ $# -eq 0 ]]
    then
        show_help
        exit 0
    fi
​
    local cmd=$1
    shift
​
    case $cmd in
        -h|--help|help)
            show_help
            exit 0
            ;;
        --version|-v)
            echo "$SCRIPT_NAME 1.0.0"
            exit 0
            ;;
    esac
​
    # 检查命令是否支持
    local valid=0
    for c in "${COMMANDS[@]}"
    do
        [[ "$c" == "$cmd" ]] && valid=1
    done
    [[ $valid -eq 1 ]] || die "未知命令: $cmd(可用的命令: ${COMMANDS[*]})"
​
    # 加载子命令文件
    local cmd_file="$PROJECT_ROOT/src/cmd_${cmd}.sh"
    if [[ ! -f "$cmd_file" ]]
    then
        die "子命令文件不存在: $cmd_file"
    fi
    source "$cmd_file"
​
    # 调用子命令的 main 函数
    if declare -F "cmd_${cmd}_main" >/dev/null
    then
        "cmd_${cmd}_main" "$@"
    else
        die "子命令 $cmd 没有定义 cmd_${cmd}_main 函数"
    fi
}
​
main "$@"

6.5 子命令:src/cmd_deploy.sh

bash 复制代码
#!/bin/bash
# src/cmd_deploy.sh - deploy 子命令
​
# 防止直接执行
if [[ "${BASH_SOURCE[0]}" = "$0" ]]
then
    echo "请通过 $SCRIPT_NAME deploy 调用" >&2
    exit 1
fi
​
cmd_deploy_main() {
    local env=${1:-}
    [[ -z "$env" ]] && die "用法: $SCRIPT_NAME deploy <环境>"
​
    local conf_file="$PROJECT_ROOT/conf/${env}.conf"
    require_file "$conf_file"
​
    log_info "加载配置: $conf_file"
    load_config "$conf_file"
​
    log_info "开始部署到 $env"
    log_info "目标主机: $DEPLOY_HOST"
    log_info "应用版本: $APP_VERSION"
​
    # 确认
    [[ "$env" == "production" ]] && {
        confirm "确认要部署到生产环境?" || die "用户取消"
    }
​
    # 模拟部署步骤
    log_info "[1/3] 备份当前版本"
    sleep 1
    log_info "[2/3] 上传新版本"
    sleep 1
    log_info "[3/3] 重启服务"
    sleep 1
​
    log_info "部署完成"
}

6.6 配置:conf/production.conf

ini 复制代码
DEPLOY_HOST=prod-server-01
DEPLOY_USER=deploy
APP_VERSION=v1.2.3
APP_PORT=8080
BACKUP_DIR=/var/backups/myapp

6.7 使用效果

ini 复制代码
$ ./bin/deploy help
deploy - 部署工具
​
用法: deploy <命令> [选项]
...
​
$ ./bin/deploy deploy production
[2024-01-15 10:23:45] [INFO] deploy: 加载配置: .../conf/production.conf
[2024-01-15 10:23:45] [INFO] deploy: 开始部署到 production
[2024-01-15 10:23:45] [INFO] deploy: 目标主机: prod-server-01
[2024-01-15 10:23:45] [INFO] deploy: 应用版本: v1.2.3
确认要部署到生产环境? [y/N] y
[2024-01-15 10:23:50] [INFO] deploy: [1/3] 备份当前版本
[2024-01-15 10:23:51] [INFO] deploy: [2/3] 上传新版本
[2024-01-15 10:23:52] [INFO] deploy: [3/3] 重启服务
[2024-01-15 10:23:53] [INFO] deploy: 部署完成
​
$ LOG_LEVEL=DEBUG ./bin/deploy status dev
[2024-01-15 10:24:00] [DEBUG] deploy: ...

整个项目几百行,但已经具备了真实生产工具该有的能力:清晰的目录、统一的日志、错误处理、子命令分发、配置管理。这就是 shell 脚本工程化的样子。

七、总结

模块化是把 shell 脚本从一次性工具推向可维护项目的关键。回顾一下要点:

  • 库文件 只定义变量和函数,不执行业务逻辑。所有变量用 local 或下划线前缀,文件开头加防重入
  • 双重用途脚本 用 ${BASH_SOURCE[0]} = $0 判断调用方式:直接执行时调用 main,被 source 时只加载函数
  • 多脚本项目 用 bin/lib/src/conf/tests 五段式目录,路径处理用 cd "$(dirname "$0")" && pwd 跨平台
  • 子命令模式通过"主入口 + src/cmd_xxx.sh"实现,每个子命令独立文件、可独立测试
  • 配置管理用 key=value 文件 + 加载函数,关键变量用关联数组隔离命名空间
  • 库的依赖用"被依赖的库在文件开头 source"实现,注意循环引用和隐藏依赖

这一篇我们解决了代码怎么组织的问题。下一篇顺着工程化这条路继续------聊一聊 shell 脚本的测试:怎么写可测试的代码、怎么用 assert 函数、怎么处理外部依赖、怎么跑集成测试。模块化做完了,没有测试就只能祈祷代码正确,下一篇把祈祷变成验证。

相关推荐
Lsetea5 小时前
OpenSSL s_client退出码为0却证书验证失败:严格校验与Shell管道排查
https·shell·ssl证书·openssl·tls
fox_charon2 天前
Windows 下 CLI 参数的引号陷阱:为什么 --resume 'uuid' 会失败
windows·shell·cmd·cli·引号
吴声子夜歌11 天前
Shell编程实例——编写安全的shell脚本(二)
linux·运维·shell
吴声子夜歌12 天前
Shell编程实例——内务及管理任务(一)
linux·运维·shell
吴声子夜歌12 天前
Shell编程实例——高级脚本编程(一)
linux·运维·网络·shell
吴声子夜歌12 天前
Shell编程实例——bash的配置与自定义(二)
linux·运维·shell
吴声子夜歌13 天前
Shell编程实例——与解析相关的任务(二)
linux·运维·shell
吴声子夜歌13 天前
Shell编程实例——脚本编程的附加特性
linux·运维·shell
云计算练习生13 天前
什么是内核?操作系统内核到底管哪些事
linux·windows·操作系统·内核·shell
吴声子夜歌16 天前
Shell编程实例——bash入门
linux·运维·shell