Shell02-脚本进阶与健壮性
Shell 脚本进阶与健壮性
目录
- 这一篇解决什么问题
- 数组与关联数组
- 字符串处理:切片、替换、正则匹配
- 参数校验:让脚本更稳健
- set 安全开关:set -euo pipefail
- trap:退出时的清理与信号处理
- 错误处理:让失败可控
- 日志输出:可读、可排查
- 调试技巧:bash -x 与 set -x
- 一个综合的健壮脚本骨架
- 总结与练习
- 参考资料
0. 这一篇解决什么问题
第 1 篇讲的是语法——变量、条件、循环、函数,那是写脚本的「零件」。但光有零件还不够:一个脚本写完,常常一跑就翻车——参数传错了它继续跑、文件不存在了它继续跑、按了 Ctrl+C 留下满地的临时文件、出错了也不知道卡在哪一步。
这一篇就是解决这个问题的。目标是让脚本具备四种「扛得住」的能力:
- 稳健:参数传错、文件不存在、命令失败时,不会稀里糊涂往下跑;
- 可排查:出错时能明确知道哪一步、为什么失败;
- 自清理:脚本被打断(Ctrl+C)或被 kill 时,能清理临时文件;
- 可维护:日志有格式、函数职责清晰。
下面每章解决一类问题。
一、数组与关联数组
1.1 普通数组:按下标存储
第 1 篇讲的变量是「一个名字对应一个值」。但很多时候你需要「一个名字对应一组值」——比如一组服务器的 IP、一组要处理的文件名。这就是数组:一组按数字下标存储的值。
# 定义数组
fruits=("apple" "banana" "orange")
# 访问单个元素(下标从 0 开始)
echo "${fruits[0]}" # apple
echo "${fruits[2]}" # orange
# 追加元素
fruits+=("grape")
# 数组长度
echo "${#fruits[@]}" # 4
# 所有元素
echo "${fruits[@]}" # apple banana orange grape
# 遍历
for f in "${fruits[@]}"; do
echo "水果: $f"
done
几个要点:数组下标从 0 开始,所以第 1 个元素是 ${fruits[0]};用 () 定义,多个值用空格隔开;${#fruits[@]} 里的 # 是「取长度」的意思,@ 表示「所有元素」。遍历时用 "${fruits[@]}"(加引号),这样每个元素都被当作独立词,即使元素本身含空格也不会被拆错。
1.2 数组相关语法速查
| 写法 | 含义 |
|---|---|
${arr[0]} |
第 1 个元素(下标 0) |
${arr[@]} |
所有元素(每个独立词) |
${#arr[@]} |
元素个数 |
${#arr[0]} |
第 1 个元素的长度 |
arr+=(x) |
追加元素 |
unset arr[1] |
删除下标 1 的元素 |
数组下标从 0 开始。用
"${arr[@]}"(加引号)遍历,每个元素会被当作独立词,含空格也不出错。
1.3 常见应用:批量处理
数组最实用的场景是「先把要处理的东西收集起来,再统一处理」:
# 收集所有要处理的文件
files=()
for f in /etc/*.conf; do
files+=("$f")
done
echo "共 ${#files[@]} 个配置文件"
这里先用 files=() 建一个空数组,再把每个配置文件追加进去。收集完可以用 ${#files[@]} 拿到总数,也能遍历逐个处理——比起「处理一个输出一个」的直通写法,先收集再统一处理更灵活,比如可以「先检查全部存在,再统一打包」。
1.4 关联数组:按键存储(bash 4+)
普通数组用数字下标,而关联数组用字符串键来存取,类似其他语言里的字典(Map)。它解决的是「按名字查值」的需求——比如按配置项名字存配置值。
# 先声明(关联数组必须 declare -A)
declare -A config
config[host]="db.example.com"
config[port]="3306"
config[user]="app"
# 访问
echo "${config[host]}" # db.example.com
# 遍历所有键
for key in "${!config[@]}"; do
echo "$key = ${config[$key]}"
done
两个关键点:
- 关联数组必须先
declare -A声明,这是和普通数组最大的区别——不声明就用,会被当成普通数组而出错; - 遍历时
${!config[@]}列出所有键(注意那个感叹号!,它表示「取键名」而不是「取长度」),拿到键之后再用${config[$key]}取对应的值。
关联数组适合「按名字查值」的场景,如配置文件解析、状态映射。RHEL/Rocky 的 bash 版本(4+)都支持。
二、字符串处理:切片、替换、正则匹配
字符串是脚本里最常见的处理对象(日志行、路径、配置值都是字符串)。bash 内置了一套相当强大的字符串处理语法,全都在 ${...} 里完成,不需要调用外部命令。
2.1 字符串长度
str="Hello, Shell"
echo "${#str}" # 12(字符个数)
${#str} 返回字符串的字符个数(这里 12 个字符)。
2.2 子串提取(切片)
从字符串里截取一段,语法是 ${str:起始:长度}:
str="Hello, Shell"
echo "${str:0:5}" # Hello(从下标0取5个)
echo "${str:7}" # Shell(从下标7到最后)
echo "${str: -5}" # Shell(最后5个,注意空格)
${str:0:5}:从下标 0 开始取 5 个字符;${str:7}:从下标 7 一直取到结尾(下标从 0 数起,"Hello," 占了 7 个字符,所以从 7 开始就是 "Shell");${str: -5}:取最后 5 个字符。注意-5前面要留一个空格,否则 bash 会把:-5误当成默认值语法。
2.3 删除前缀/后缀
这是处理文件名和路径最常用的技巧。% 删后缀,# 删前缀:
file="config.yaml"
echo "${file%.*}" # config(删除最短后缀)
echo "${file%%.*}" # config(删除最长后缀)
echo "${file#*.}" # yaml(删除最短前缀)
echo "${file##*.}" # yaml(删除最长前缀)
规则:
%删后缀(从右边删),#删前缀(从左边删);- 单个
%/#删最短匹配,双个%%/##删最长匹配; - 对
config.yaml这种只有一个点的文件,两者结果一样;但遇到a.b.c这种多点的,%只删到第一个点(得a.b),%%删到最后一个点(得a)。
这个技巧在「取文件名、取扩展名」时特别有用——比如根据扩展名判断文件类型。
2.4 替换子串
${str/旧/新} 是替换语法:
str="apple banana apple"
echo "${str/apple/pear}" # pear banana apple(只替换第一个)
echo "${str//apple/pear}" # pear banana pear(替换全部)
echo "${str/#apple/pear}" # 替换开头匹配的
echo "${str/%apple/pear}" # 替换结尾匹配的
- 单斜杠
/:只替换第一个匹配; - 双斜杠
//:替换全部匹配; /#限定只替换开头的匹配,/%限定只替换结尾的匹配。
2.5 大小写转换
str="hello"
echo "${str^^}" # HELLO(全大写)
echo "${str,,}" # hello(全小写)
echo "${str^}" # Hello(首字母大写)
^ 是转大写、, 是转小写;双写 ^^/,, 是全部转换,单写 ^/, 只转换首字母。
2.6 正则匹配:=~
这是脚本里校验用户输入的核心手段。第 1 篇讲过 [[ ]],它的增强功能之一就是用 =~ 做正则匹配:
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,}$ ]]; then
echo "邮箱格式正确"
else
echo "邮箱格式错误"
fi
# 捕获匹配的分组(BASH_REMATCH)
if [[ "$line" =~ ([0-9]+)\.[0-9]+\.([0-9]+) ]]; then
echo "匹配到: ${BASH_REMATCH[0]} 组1: ${BASH_REMATCH[1]}"
fi
=~的右侧是正则表达式,不要加引号(加了引号会被当成字面字符串去匹配);- 正则本身用的是 ERE(扩展正则),所以
+、(、)这些直接就是元字符; - 匹配成功时,整个匹配结果存在
${BASH_REMATCH[0]},各捕获分组存在${BASH_REMATCH[1]}、${BASH_REMATCH[2]}...里。这在「从一行文本里提取特定内容」时非常好用。
注意:上面邮箱正则里用
\\.表示字面点号(点号在正则里是「任意字符」,要匹配真正的点必须转义)。
三、参数校验:让脚本更稳健
一个脚本能不能扛住,第一步就取决于它怎么对待用户给的参数。健壮的脚本在开头就把所有前置条件检查完,不符合就直接报错退出,而不是稀里糊涂往下跑。这就是**尽早失败(fail fast)**原则。
3.1 检查参数个数
最基础的校验:用户有没有传够参数。
if [ $# -lt 2 ]; then
echo "错误: 至少需要 2 个参数" >&2
echo "用法: $0 <输入文件> <输出文件>" >&2
exit 1
fi
$# 是参数个数,-lt 2 是「小于 2」。注意错误信息都加了 >&2 输出到标准错误(stderr),并且 exit 1 用非 0 退出码表示失败——这样脚本的「正常输出」和「错误信息」分离,调用方(比如别的脚本、定时任务)才能正确判断成功还是失败。
3.2 检查文件存在
参数传对了,还要检查它指向的文件真的存在:
input="$1"
if [ ! -f "$input" ]; then
echo "错误: 文件不存在: $input" >&2
exit 1
fi
[ ! -f "$input" ] 是「如果它不是一个普通文件」。注意变量都加了双引号——如果用户传的参数本身含空格,不加引号会被拆成多个词,导致判断错误甚至误伤文件。
3.3 检查命令存在(command -v)
脚本里要用的外部命令(curl、jq、tar...),系统里可能没装。逐个检查:
for cmd in curl jq tar; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "错误: 缺少命令 $cmd" >&2
exit 1
fi
done
command -v curl 如果 curl 存在会输出它的路径并返回 0,不存在则返回非 0。>/dev/null 2>&1 是把它的输出全部丢弃(我们只关心它返回 0 还是非 0)。用 ! 取反,就是「如果命令不存在」就报错退出。脚本开头把所有依赖检查一遍,可以避免「跑到一半才发现某个命令没装」。
3.4 检查是否 root
有些脚本(比如改系统配置、装软件)必须用 root 运行:
if [ "$EUID" -ne 0 ]; then
echo "错误: 请用 root 运行" >&2
exit 1
fi
$EUID 是当前用户的 UID(用户 ID),root 用户的 UID 是 0。所以 $EUID -ne 0 就是「当前不是 root」。
参数校验的原则:尽早失败(fail fast)——在脚本开头就把所有前置条件检查完,不符合就报错退出,避免跑到一半才发现问题。
四、set 安全开关:set -euo pipefail
这是生产脚本最重要的三行,通常写在脚本开头(shebang 之后):
#!/bin/bash
set -euo pipefail
它其实是三个开关 -e、-u、-o pipefail 合在一起,分别解决一类「脚本默认行为很危险」的问题。逐个拆解:
4.1 set -e:出错立即退出
默认情况下,脚本里某条命令失败(返回非 0 退出码),脚本还会继续往下跑——这在很多场景下是灾难,因为后续命令可能基于一个错误的结果继续执行。set -e 让脚本在任何命令返回非 0 退出码时立即退出:
set -e
cp a.txt /some/dir/ # 如果这里失败,脚本立即退出,不会继续
echo "这行不会执行到"
如果 cp 失败(比如目标目录不存在),脚本直接终止,不会执行下面那句 echo。
注意:
set -e不会在条件判断(if、while、&& 左侧)里生效。因为那些场景本来就是「故意检查退出码」——if grep ...里 grep 返回 1(没匹配)是正常情况,不该让脚本退出。bash 设计上知道这些是「被当作条件使用的命令」,所以会豁免它们。
4.2 set -u:使用未定义变量报错
默认情况下,引用一个未定义的变量,bash 会静默地把它当成空字符串,不报任何错:
set -u
echo "$undefined_var" # 报错:unbound variable,脚本退出
set -u 让引用未定义变量直接报错退出。这能及早发现拼写错误——比如你本来写的是 $logfile,结果手滑写成 $logfilee,默认情况下它会静默变成空串,脚本照跑不误,最后却得到一个空文件名。开了 -u,这种错误当场就暴露出来。
4.3 set -o pipefail:管道中任一步失败都算失败
管道的退出码,默认只取最后一条命令的。比如 cat file | grep xxx,如果 grep 没匹配到(返回 1),整条管道的退出码就是 1;但如果前面 cat 失败了、后面 grep 成功了,管道退出码反而是 0——前面命令的失败被掩盖了。
pipefail 让管道中任何一条命令失败,整个管道就算失败(退出码取最右一个非 0 的):
set -o pipefail
# 若 grep 没匹配到(退出码1),整条管道退出码就是非0,set -e 会触发
cat /etc/passwd | grep "nosuchuser"
这样配合 set -e,管道里任何一环出错都会让脚本停下来,而不是被悄悄吞掉。
4.4 什么时候用、什么时候慎用
- 推荐:几乎所有生产脚本都该
set -euo pipefail,它是「健壮脚本」的默认配置; - 注意:如果你故意要「某条命令失败但不退出」,要显式处理,比如:
command_that_may_fail || true # 允许失败但不退出 # 或 if command_that_may_fail; then # 在 if 里就不会触发 set -e ... fi|| true让命令失败时「兜底为成功」,if则利用「条件判断豁免 set -e」的特性。两种都是显式声明「这里的失败我接受」。
五、trap:退出时的清理与信号处理
5.1 什么是 trap
脚本运行中可能会被各种方式打断:正常跑完、中途报错退出、用户按 Ctrl+C、被 kill 命令杀掉。很多脚本会在中途创建临时文件、临时目录,如果被打断时来不及清理,就会残留垃圾文件。
trap 的作用,就是让脚本在收到信号或退出时执行你指定的清理代码。最常用的用途:脚本退出时清理临时文件。
5.2 退出时清理临时文件
#!/bin/bash
set -euo pipefail
# 创建临时目录
tmpdir=$(mktemp -d)
# 注册:脚本退出(正常或异常)时删除临时目录
trap 'rm -rf "$tmpdir"' EXIT
# ... 业务逻辑,即使中途出错退出,也会触发 EXIT 清理 ...
trap '要执行的命令' EXIT 的意思是:「当脚本退出时,执行这条命令」。这里注册了删除临时目录的命令,所以无论脚本是正常跑完、还是中途报错退出,都会自动清理 $tmpdir。
5.3 处理中断信号(Ctrl+C / kill)
除了 EXIT(退出),还能针对具体信号做处理。中断时你常常想「先优雅地收尾再退出」:
cleanup() {
echo "收到中断,清理中..."
rm -rf "$tmpdir"
}
trap cleanup INT TERM EXIT
# 业务逻辑
这里把清理动作封装成 cleanup 函数,然后注册给三个信号:
INT:Ctrl+C 发送的中断信号;TERM:kill 默认发送的终止信号;EXIT:脚本任何方式退出(正常或异常)都会触发。
所以无论是用户按 Ctrl+C、还是系统 kill 脚本、还是脚本自己正常退出,都会先执行 cleanup 再结束。
5.4 trap 的典型用途总结
| 信号 | 触发时机 | 常见处理 |
|---|---|---|
EXIT |
脚本退出(任何方式) | 清理临时文件、记录日志 |
INT |
Ctrl+C | 优雅退出、清理 |
TERM |
kill 命令 | 同上 |
ERR |
任意命令失败(配合 set -e) | 记录出错位置 |
trap EXIT 是「无论如何都要清理」的兜底,强烈推荐在会创建临时资源的脚本里使用。
六、错误处理:让失败可控
set -e 已经让脚本「出错就停」,但停在哪、为什么停,还得靠错误处理来回答。
6.1 显式检查退出码
有些关键步骤,你不只想「停」,还想「停下来之前给个明确的提示」。这时可以不依赖 set -e,而是显式检查退出码:
if ! tar czf backup.tar.gz /data; then
echo "错误: 备份失败" >&2
exit 1
fi
echo "备份成功"
! tar ... 取反:如果 tar 失败(返回非 0),! 让整个条件为真,进入 if 分支报错退出。这样失败时会有明确的「备份失败」提示,而不是脚本静默终止。
6.2 封装一个「报错并退出」的函数
如果每个报错点都写一遍「echo + exit」,会非常啰嗦。更规范的做法是封装一个 die(死了就退出)函数:
die() {
echo "错误: $1" >&2
exit "${2:-1}"
}
# 用法
[ -f "$input" ] || die "输入文件不存在: $input"
[ "$EUID" -eq 0 ] || die "需要 root 权限"
die 做两件事:把错误信息输出到 stderr,然后用 ${2:-1} 指定的退出码退出(不指定就默认 1)。[ -f ... ] || die "..." 的意思是「条件不成立就 die」,一行就完成了「检查 + 报错 + 退出」。die 函数统一了报错格式和退出码,是生产脚本的常用模式。
6.3 把错误输出到 stderr
错误信息要写到**标准错误(stderr)**而不是标准输出(stdout),用 >&2:
echo "正常日志" # stdout
echo "错误信息" >&2 # stderr
为什么要分?因为 stdout 和 stderr 是两条独立的通道。如果脚本被 ./script.sh > log.txt 重定向,stdout 会进 log.txt,而 stderr 仍然直接显示在终端上。这样脚本的「正常输出」和「错误输出」分离,便于管道和日志系统区分处理——错误不会被正常的重定向悄悄吞掉。
七、日志输出:可读、可排查
7.1 为什么要格式化日志
一个脚本出了问题,第一件事就是看日志。如果日志乱七八糟(没有时间、没有来源),排查会非常痛苦。生产脚本的日志要能被回答四个问题:谁、什么时候、做了什么、结果如何。统一带时间戳的日志函数是基础:
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
log "开始备份"
log "备份完成"
log 函数把当前时间戳加在每条日志前面,输出形如 [2024-01-15 10:00:00] 开始备份。这样翻日志时能清楚地看到每一步发生在什么时间、按什么顺序。
7.2 带级别的日志函数
更进一步,给日志加上级别(INFO/WARN/ERROR),方便快速过滤出重要的:
INFO=0
WARN=1
ERROR=2
log() {
local level="$1"
shift
echo "[$(date '+%F %T')] [$level] $*"
}
log INFO "开始处理"
log WARN "磁盘空间不足 20%"
log ERROR "处理失败,跳过"
这里 log 第一个参数是级别,shift 把它从参数列表里移除,剩下的 $* 就是真正的日志内容。输出形如 [2024-01-15 10:00:00] [WARN] 磁盘空间不足 20%。排查时只要 grep WARN、grep ERROR 就能快速定位问题行。
7.3 同时写终端和日志文件(tee)
有时候你想让日志同时显示在终端、又写进文件(这样终端能实时看,文件能留档)。用进程替换 >\(...\) 配合 tee:
exec > >(tee -a script.log) 2>&1
# 此后脚本所有输出同时进终端和 script.log
exec 把这行之后的整个脚本输出重定向到 tee,而 tee 会同时把内容写到文件并继续输出到终端。这样脚本里所有 echo 都会「既上终端、又进文件」。
7.4 把日志重定向到文件
如果只需要写文件、不需要看终端,更简单:
# 运行时重定向
./script.sh > script.log 2>&1
# 或脚本内部整体重定向
exec >> script.log 2>&1
第一种是运行时在命令行重定向;第二种是在脚本内部用 exec >> ... 2>&1 把脚本所有后续输出(stdout 和 stderr)都追加到日志文件。注意 >> 是追加(不清空已有内容),2>&1 是把 stderr 也并到同一个文件。
八、调试技巧:bash -x 与 set -x
脚本写出来不跑、或跑错了,怎么排查?bash 自带一套调试手段。
8.1 bash -x:跟踪每一条命令
bash -x script.sh
用 -x 运行,bash 会打印每一条即将执行的命令(及其展开后的值),前面带 + 号。这样你能清楚地看到:脚本实际在执行什么、变量到底展开成了什么。比如 echo "$name" 会显示成 + echo Tom,一眼就能看出 $name 展开成了 Tom——如果展开结果不对,问题立刻现形。
8.2 脚本内局部开启/关闭
不用 -x 参数,也能在脚本内部某段代码前后临时开关跟踪:
set -x # 开启跟踪
# ...要调试的代码...
set +x # 关闭跟踪
这样只跟踪中间那段代码,前后正常执行,输出不会太嘈杂。
8.3 bash -n:只检查语法,不执行
有时候只是想确认「语法有没有写错」,不想真的跑:
bash -n script.sh # 语法正确则无输出,有语法错误会提示
-n(no execute)只做语法检查,不执行任何命令。语法正确就没输出,有语法错误会报错。改完脚本先 bash -n 检查语法,再 bash -x 调试,是标准流程。
8.4 打印变量值排查
最朴素的调试:在关键点把变量值打印出来:
echo "DEBUG: input=$input output=$output" >&2
在关键步骤前后打印变量值(输出到 stderr 以免污染 stdout 的正常结果),配合 bash -x 就能定位是「哪个变量、哪一步」出了问题。
九、一个综合的健壮脚本骨架
把前面所有技巧整合成一个可复用的脚本骨架,当作你写新脚本的起点:
#!/bin/bash
set -euo pipefail
# ---------- 日志函数 ----------
log() {
echo "[$(date '+%F %T')] $*"
}
die() {
echo "[$(date '+%F %T')] 错误: $*" >&2
exit 1
}
# ---------- 参数校验 ----------
if [ $# -lt 2 ]; then
die "用法: $0 <输入文件> <输出目录>"
fi
input="$1"
output="$2"
[ -f "$input" ] || die "输入文件不存在: $input"
[ -d "$output" ] || die "输出目录不存在: $output"
# ---------- 临时资源与清理 ----------
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
# ---------- 业务逻辑 ----------
log "开始处理 $input"
cp "$input" "$tmpdir/"
if tar czf "$output/$(basename "$input").tar.gz" -C "$tmpdir" .; then
log "备份完成: $output/$(basename "$input").tar.gz"
else
die "备份失败"
fi
这个骨架把「日志、报错、校验、临时清理、业务」分层:开头 set -euo pipefail 打开安全开关;log/die 统一日志和报错;开头校验参数和文件;trap EXIT 保证临时目录一定被清理。任何一步失败都能明确报错并自动清理临时目录。新脚本从这里改起,比从零写安全得多。
十、总结与练习
10.1 本篇核心速查
- 数组:
arr=(a b c),${arr[0]}、${#arr[@]}、arr+=(x); - 关联数组:
declare -A+ 字符串键,${!arr[@]}列键; - 字符串:
${#str}长度、${str:0:5}切片、%/#删前后缀、//替换、=~正则; - 校验:$# 参数个数、-f 文件、command -v 命令、EUID 判断 root;
- set:
set -euo pipefail出错退出、未定义报错、管道严格; - trap:EXIT 清理临时文件、INT/TERM 处理中断;
- 错误处理:die 函数统一报错、
>&2错误走 stderr; - 日志:统一 log 函数带时间戳,tee 同时写终端和文件;
- 调试:bash -n 查语法、bash -x 跟踪、set -x/+x 局部开关。
10.2 动手练习
- 写脚本用关联数组保存几台服务器的 IP,用 for 遍历打印;
- 写一个校验邮箱格式的脚本(用 =~);
- 给一个脚本加上 set -euo pipefail,观察出错时行为;
- 写一个创建临时文件并用 trap EXIT 清理的脚本;
- 用 die 函数和参数校验,重写一个你手头的脚本。
参考资料
- GNU Bash 手册(参数展开、数组、trap):https://www.gnu.org/software/bash/manual/
- 菜鸟教程 - Shell 数组:https://www.runoob.com/linux/linux-shell-array.html
- Bash Hackers Wiki - trap:https://wiki.bash-hackers.org/commands/builtin/trap
