上一篇我们讲了错误处理和日志,让脚本从能跑变成靠得住,但靠得住还有一个隐藏前提------脚本得能维护。
如果你写过一个几千行的 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 函数、怎么处理外部依赖、怎么跑集成测试。模块化做完了,没有测试就只能祈祷代码正确,下一篇把祈祷变成验证。