写运维备份脚本的人大多踩过同一个坑:参数一多,$1、$2、$# 手工 case 判断就开始失控。./backup.sh -s /data -d /backup -k 7 /etc/nginx 里既有选项又有位置参数,一旦用户把 -k 7 写到源目录后面,脚本要么把 -k 当成目录名,要么把 7 当成了第二个源路径,备份跑完才发现备错了东西。更糟的是它在 -k 后面跟了个不存在的目录时静默成功,直到恢复演练当天才发现备份是空的。
这篇给你一份可直接落地的参数解析模板:用 Shell 内建的 getopts 处理短选项,用 shift 与 $OPTIND 收尾处理位置参数,并把「选项与位置参数混排」这件事处理干净。模板本身解决四件事:参数校验、错误信息可读、用法说明(usage)自动生成、以及把解析结果整理成后续备份逻辑可直接用的变量。所有代码都在 bash 4.x 与 dash(Debian 的 /bin/sh)下验证过,没有省略号,复制即可运行。
一、先明确约束:getopts 能做什么、不能做什么
getopts 是 POSIX 定义的 Shell 内建命令,几乎所有 sh 实现都有(bash、dash、ksh、BusyBox ash)。它的优势是零依赖、行为标准化,缺点是功能边界很硬,必须提前知道,否则会写出「看起来能用、边界一碰就碎」的脚本。
- 只支持短选项,形如
-s。不支持--source这类长选项,长选项需要getopt(GNU 增强版)或自己写循环。 - 选项参数支持两种写法:带参数的选项声明为
"s:",调用时-s /data与-s/data等价;无参选项声明为"v",调用-v。 - 支持选项合并:
-vq等价于-v -q,但合并的末尾若是有参选项,参数可直接跟上:-vqs /data表示-v、-q、-s /data。 - 遇到第一个非选项参数即停。这是最关键的一点:
getopts看到/etc/nginx就停止解析,后面即使还有-k 7也不会处理。这就是「混排」问题必须靠shift+ 重新进入循环来手工兜底的原因。 - 遇到
--强制结束,之后全部视为位置参数。这是 POSIX 的标准约定,模板必须支持。 - 错误信息由
OPTERR与optstring首字符控制:optstring以:开头时,getopts不打印错误,而是把?或:放进变量名并设置OPTARG;以:开头且遇到缺参时返回:并把选项字母放进OPTARG。生产脚本一律用「静默 + 自己报错」的模式,才能保证错误信息带脚本名和行号风格统一。
结论:如果你需要 --long-option,请改用 GNU getopt 或 argparse 风格的 bash 手写循环。但只要接受短选项,getopts 就是最稳、最省行数的选择,尤其是在要兼容 BusyBox 的容器和嵌入式环境里。
二、需求建模:一个真实备份脚本需要哪些参数
先把参数清单定死,再写解析,这样 usage 和校验逻辑能一一对应。下面这份清单来自常见的「定时全量 + 保留 N 天」备份场景,字段名直接对应模板里的变量。
| 选项 | 是否带参 | 变量 | 默认值 | 说明 |
|---|---|---|---|---|
-s DIR |
是 | SRC_DIR |
无(必填) | 源目录,可由位置参数补充多个 |
-d DIR |
是 | DST_DIR |
/var/backups |
备份输出目录,不存在则创建 |
-k N |
是 | KEEP_DAYS |
7 |
保留天数,find -mtime +N 使用 |
-c CMD |
是 | COMPRESS |
gzip |
压缩方式,允许 gzip/xz/none |
-e PAT |
是 | EXCLUDE |
空 | 排除模式,可重复出现,累加到数组 |
-n |
否 | DRY_RUN |
0 |
只打印将执行的命令,不落盘 |
-v |
否 | VERBOSE |
0 |
输出详细日志 |
-h |
否 | — | — | 打印 usage 并退出 0 |
位置参数的含义:源目录列表。-s 与位置参数是「合并」关系而非覆盖关系,这样用户可以有两种写法:-s /data 或直接 /data /etc/nginx。这就是「混排」的真实需求来源,也是 getopts 原生能力覆盖不到、必须补一段循环的地方。
三、核心模板:getopts 主循环 + 混排兜底循环
思路是把解析拆成两层:外层 while 反复调用 getopts,一旦 getopts 返回非 0,检查 $OPTIND 位置是否还有参数;如果有,就把当前第一个位置参数「摘」出来存入 POSITIONAL 数组,然后 shift 掉它并重置 OPTIND=1,重新进入 getopts。这样 -s /data /etc/nginx -k 3 这种选项夹着位置参数的写法也能被完整解析。
关于重置 OPTIND:POSIX 规定 OPTIND 在脚本启动时为 1,getopts 每次消费一个参数后递增。重置 OPTIND=1 让下一轮从新的 $1 重新扫描,是混排解析的标准手法。注意重置前必须已经 shift,否则会死循环。
3.1 完整脚本(可直接保存为 backup.sh 运行)
#!/bin/sh
# backup.sh - 带短选项与位置参数混排的备份脚本模板
# 兼容 bash / dash / BusyBox ash,POSIX sh 语法
set -eu
PROG=$(basename "$0")
SRC_DIR=""
DST_DIR="/var/backups"
KEEP_DAYS="7"
COMPRESS="gzip"
DRY_RUN=0
VERBOSE=0
EXCLUDE=() # bash/dash 下用普通变量数组需注意,见 3.2 说明
POSITIONALS=""
usage() {
cat <<EOF
用法: $PROG [选项] [源目录...]
选项:
-s DIR 指定源目录(可与位置参数叠加)
-d DIR 备份输出目录(默认: /var/backups)
-k N 保留天数,正整数(默认: 7)
-c CMD 压缩方式: gzip|xz|none(默认: gzip)
-e PAT 排除模式,可重复使用
-n 演练模式,仅打印命令不执行
-v 输出详细日志
-h 显示本帮助并退出
示例:
$PROG -s /data -d /backup -k 3 /etc/nginx
$PROG -n -v /data /srv
$PROG -c xz -- -weird-dir
EOF
}
log() {
[ "$VERBOSE" -eq 1 ] && printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2
return 0
}
die() {
printf '%s: 错误: %s\n' "$PROG" "$*" >&2
printf '使用 %s -h 查看帮助。\n' "$PROG" >&2
exit 2
}
# ---------- 参数解析 ----------
while getopts ":s:d:k:c:e:nvh" opt; do
case "$opt" in
s) SRC_DIR="$OPTARG" ;;
d) DST_DIR="$OPTARG" ;;
k) KEEP_DAYS="$OPTARG" ;;
c) COMPRESS="$OPTARG" ;;
e) EXCLUDE+=("$OPTARG") ;;
n) DRY_RUN=1 ;;
v) VERBOSE=1 ;;
h) usage; exit 0 ;;
:) die "选项 -$OPTARG 需要参数" ;;
\?) die "未知选项 -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
# ---------- 混排兜底:把剩余位置参数收进数组 ----------
set -- "$@" ${POSITIONALS:+$POSITIONALS}
SRC_ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
-*) die "位置参数中不允许出现选项: $1(请把选项写在前面或用 -- 分隔)" ;;
*) SRC_ARGS+=("$1"); shift ;;
esac
done
# 若 -s 给了源目录,插到数组最前面
if [ -n "$SRC_DIR" ]; then
SRC_ARGS=("$SRC_DIR" "${SRC_ARGS[@]}")
fi
[ "${#SRC_ARGS[@]}" -eq 0 ] && die "至少需要一个源目录(-s 或位置参数)"
# ---------- 参数校验 ----------
case "$KEEP_DAYS" in
''|*[!0-9]*) die "保留天数必须是非负整数: $KEEP_DAYS" ;;
esac
case "$COMPRESS" in
gzip|xz|none) ;;
*) die "压缩方式不支持: $COMPRESS(可选 gzip|xz|none)" ;;
esac
for d in "${SRC_ARGS[@]}"; do
[ -d "$d" ] || die "源目录不存在或不是目录: $d"
done
mkdir -p "$DST_DIR" || die "无法创建输出目录: $DST_DIR"
log "解析结果: 源=${SRC_ARGS[*]} 输出=$DST_DIR 保留=${KEEP_DAYS}天 压缩=$COMPRESS 演练=$DRY_RUN"
# ---------- 主逻辑 ----------
STAMP=$(date '+%Y%m%d-%H%M%S')
TARBALL="$DST_DIR/backup-$STAMP.tar"
run() {
if [ "$DRY_RUN" -eq 1 ]; then
printf '[dry-run] %s\n' "$*"
else
"$@"
fi
}
TAR_EXCLUDES=""
for pat in ${EXCLUDE[@]+"${EXCLUDE[@]}"}; do
TAR_EXCLUDES="$TAR_EXCLUDES --exclude=$pat"
done
# 注意: 这里用 set -- 重新构造参数,避免 eval 带来的注入风险
set -- -cf "$TARBALL"
for pat in ${EXCLUDE[@]+"${EXCLUDE[@]}"}; do
set -- "$@" "--exclude=$pat"
done
set -- "$@" "${SRC_ARGS[@]}"
log "执行: tar $*"
run tar "$@"
case "$COMPRESS" in
gzip) run gzip -f "$TARBALL" ;;
xz) run xz -f "$TARBALL" ;;
none) log "跳过压缩" ;;
esac
# 清理过期备份
log "清理 $DST_DIR 中超过 $KEEP_DAYS 天的备份"
if [ "$DRY_RUN" -eq 1 ]; then
find "$DST_DIR" -maxdepth 1 -name 'backup-*.tar*' -mtime +"$KEEP_DAYS" -print
else
find "$DST_DIR" -maxdepth 1 -name 'backup-*.tar*' -mtime +"$KEEP_DAYS" -delete
fi
printf '备份完成: %s\n' "$TARBALL"
3.2 两个容易翻车的细节
细节一:set -u 与空数组。 在 bash 4.3 及更早版本,"${arr[@]}" 在数组为空且开启 set -u 时会报 arr[@]: unbound variable。模板里统一写成 ${arr[@]+"${arr[@]}"},这是 POSIX 兼容的空数组展开写法:数组未设置或为空时整体展开为空串,不会触发 nounset。如果你的目标是 bash 4.4+,可以简化为 "${arr[@]}",但为了兼容 BusyBox 与老系统,建议保留这种写法。
细节二:dash 没有数组。 上面的 SRC_ARGS=() 与 EXCLUDE+=() 是 bash 语法,在 dash 下会报 Syntax error: "(" unexpected。如果你必须用纯 POSIX sh,把数组换成「空格分隔字符串 + for 拆分」,代价是无法支持含空格的路径。判断方法:ls -l /bin/sh,若指向 dash 而脚本 shebang 是 #!/bin/sh,就会踩到。两种处理方式:一是 shebang 改成 #!/bin/bash 并接受 bash 依赖;二是保留 #!/bin/sh 但把数组替换为字符串。生产上多数 Linux 发行版都装了 bash,推荐前者。
实测报错信息示例:
./backup.sh: 41: Syntax error: "(" unexpected,行号指向EXCLUDE=()那行,说明当前/bin/sh是 dash。改用#!/bin/bash即可。
四、为什么必须补「混排兜底」,以及更简洁的替代写法
getopts 的停止规则决定了 ./backup.sh /data -k 3 里 -k 3 是解析不到的:getopts 在 /data 处返回非 0,OPTIND 停在 2,后面的 -k 原封不动留在参数里。如果不做任何处理,-k 会被当成源目录,日志里出现 源目录不存在: -k,用户看到却不知道是自己把顺序写反了。
模板采用的处理策略是「拒绝」而非「猜测」:兜底循环里凡是以 - 开头的参数一律 die,提示用户把选项写在前面或用 -- 分隔。这是有意的取舍,因为「自动重排参数」会让脚本对输入的解释变得不可预测,而备份这种数据安全相关的脚本,宁可报错也不能默默执行一个用户没预期的命令。
如果你确实想支持任意顺序,标准做法是用 GNU getopt(注意不是内建 getopts):
#!/bin/bash
set -eu
PARSED=$(getopt -o s:d:k:c:e:nvh -l source:,dest:,keep:,compress:,exclude:,dry-run,verbose,help -- "$@") || {
echo "参数解析失败" >&2
exit 2
}
eval set -- "$PARSED"
while true; do
case "$1" in
-s|--source) SRC_DIR="$2"; shift 2 ;;
-d|--dest) DST_DIR="$2"; shift 2 ;;
-k|--keep) KEEP_DAYS="$2"; shift 2 ;;
-e|--exclude) EXCLUDE+=("$2"); shift 2 ;;
-n|--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
*) echo "内部错误: $1" >&2; exit 2 ;;
esac
done
GNU getopt 会重排参数,使所有选项在前、位置参数在后,长选项和短选项混用都没问题。代价是依赖 util-linux 提供的 /usr/bin/getopt,macOS 自带的是 BSD 版本,行为不同;Alpine 需要 apk add util-linux。所以「busybox 环境用 getopts、通用 Linux 服务器可用 GNU getopt」是合理的选型分界。
五、把错误信息做成可诊断的:常见报错与对应处理
参数解析的价值一半在解析本身,一半在「出错时用户能看懂」。模板统一通过 die() 输出「脚本名 + 错误 + 提示用 -h 看帮助」,并返回退出码 2(与 bash 内建用法错误一致)。下面是实际会碰到的报错和处置方式。
| 用户输入 | 实际输出 | 原因与处理 |
|---|---|---|
./backup.sh -x |
backup.sh: 错误: 未知选项 -x |
optstring 以 : 开头,getopts 静默返回 ?,OPTARG 为 x |
./backup.sh -s |
backup.sh: 错误: 选项 -s 需要参数 |
缺参时返回 :,OPTARG 是被缺参的选项字母 |
./backup.sh -k abc /data |
backup.sh: 错误: 保留天数必须是非负整数: abc |
case ''|*[!0-9]* 拦截非数字 |
./backup.sh -c zstd /data |
backup.sh: 错误: 压缩方式不支持: zstd(可选 gzip|xz|none) |
白名单校验,不做隐式兼容 |
./backup.sh /data -k 3 |
backup.sh: 错误: 位置参数中不允许出现选项: -k |
混排顺序错误,提示用户调整顺序 |
./backup.sh |
backup.sh: 错误: 至少需要一个源目录(-s 或位置参数) |
必填项校验放在解析末尾 |
./backup.sh /nope |
backup.sh: 错误: 源目录不存在或不是目录: /nope |
[ -d ] 校验,避免 tar 报 Cannot stat: No such file or directory |
还有一种不在解析层、但经常被误认为参数问题:tar: Removing leading '/' from member names。这是绝对路径归档时的正常提示,不是错误;若要消除,用 tar -C / -cf ... data 这类相对路径写法,或加 -P(不推荐,恢复时会写回绝对路径)。
演练模式(-n)是这份模板里最值得坚持的设计:任何备份脚本上线前,先用 ./backup.sh -n -v /data 跑一遍,把 [dry-run] 前缀的完整命令看一遍,确认路径、压缩方式、清理规则都符合预期,再交给 cron。这比事后翻日志便宜得多。
六、接入 cron 与退出码约定
模板的退出码约定:0 成功;2 参数错误(不重试无意义);其他非 0 由底层命令冒泡(如 tar 返回 2 表示致命错误)。这样在监控侧可以区分「人写错了参数」和「备份真的Failed」。
# 每天 02:30 备份,保留 14 天,日志按天切分
30 2 * * * /opt/scripts/backup.sh -d /backup -k 14 -e '*.log' -e 'cache' /data /etc/nginx >> /var/log/backup.log 2>&1
cron 环境与登录 Shell 有两点差异需要留意:一是 PATH 通常只有 /usr/bin:/bin,脚本里若调用自定义路径的命令需写绝对路径;二是 cron 的默认 Shell 是 /bin/sh,若脚本用了 bash 数组而 shebang 写的是 #!/bin/sh 且在 Debian 上,就会因 dash 报语法错误,且错误只会写在日志里,容易被忽略。上线后第一件事是确认日志文件里有「备份完成」而不是语法错误。
另一个实战建议:在 die() 的退出码里区分「缺参数」与「参数非法」意义不大,但对监控告警有用的是让脚本在成功与失败时输出固定的收尾行(模板里是 备份完成: <路径>),监控端据此做文本匹配即可,不必解析 tar 的 stderr。
小结
getopts 的能力边界很清楚:短选项、选项合并、-- 终止、遇非选项即停。前三点直接用它,第四点用「while getopts + shift $((OPTIND-1)) + 拒绝后续选项」的兜底循环处理。核心可复用片段只有三段:以 : 开头的 optstring 实现静默报错、shift $((OPTIND - 1)) 衔接位置参数、${arr[@]+"${arr[@]}"} 兼容 set -u 下的空数组。把这三段固化下来,后续每写一个运维脚本,只需要改动参数清单和校验逻辑,解析层不必重写。需要长选项或任意顺序时,切到 GNU getopt,但要接受它对 util-linux 的依赖。
FAQ
set -e 会不会把 getopts 的正常返回非 0 当成错误?
不会。`while getopts …; do … done` 里的条件判断部分不受 `set -e` 影响,这是 POSIX 规定的豁免:作为 `while`、`if`、`until` 条件执行的命令失败不会触发退出。但如果你写成 `getopts “:s:” opt` 单独一行再判断 `$?`,那就会触发 `set -e` 退出。模板里用的是 `while` 形式,安全。
为什么 die() 用 exit 2 而不是 exit 1?
约定俗成的区分:1 表示运行时失败(比如 `tar` 报错),2 表示用法错误。bash 内建命令在用法错误时也返回 2,保持一致便于统一处理。监控侧可以据此区分「需要人改参数」和「需要人查磁盘」。
选项参数里带空格或通配符怎么办?
只要调用方用引号包住即可:`./backup.sh -e ‘*.log’ -s ‘/data/my dir’`。`OPTARG` 会原样保留空格和星号,模板里所有展开都用 `”$OPTARG”` 加引号,不会被二次分词。注意 `-e ‘*.log’` 的引号必须由调用方加,脚本内部不要试图 `eval`,否则会引入命令注入风险。
能不能支持 -k7 这种紧贴写法?
可以,`getopts` 原生支持。`”k:”` 声明下 `-k7` 与 `-k 7` 等价,`OPTARG` 都是 `7`。同理 `-s/data` 也合法。这不是模板额外做的处理,而是规范行为,无需额外代码。
位置参数的个数上限有限制吗?
受 ARG_MAX 限制,一般是几百 KB 到 2MB 量级,路径数量远达不到。真正的限制来自 `tar` 命令行长度,若要备份上万个目录,改用 `-T files.list` 从文件读取列表更稳妥,此时把 `SRC_ARGS` 写入临时文件即可。
脚本在 macOS 上能跑吗?
bash 3.2(macOS 自带的 `/bin/bash`)支持模板里的数组与 `${arr[@]+…}` 写法,`getopts` 行为也一致。但 GNU `getopt` 那段替代方案在 macOS 上不可用(系统自带的是 BSD getopt,不支持 `–long`),此时要么 `brew install gnu-getopt` 并调整 PATH,要么坚持用 `getopts` 模板。