我们再前几篇所讲的脚本有个隐含假设:所有命令都会成功,所有数据都干净,所有依赖都在。这种假设在你自己的笔记本上跑没问题,但放到生产环境------挂在 crontab 里、写进 CI/CD 流水线、跑在用户的机器上------脚本就必须在出错时知道怎么办。
这就是这一篇要解决的问题:让脚本从能跑变成靠得住。我们聊三件事------退出码、错误处理、日志。它们三个加起来,决定了脚本出问题时的行为是静默失败还是清晰可控。
这一篇跟前几篇风格不太一样,前面我们一直在加能力,这一篇是在约束能力。你会看到很多不要这样做、要这样写的规矩。规矩听上去无聊,但生产事故里 90% 的 shell 脚本问题,根源都是这些规矩没守住。
一、退出码
在聊错误处理之前,必须先把退出码这个基础概念说清楚。它是 shell 进程之间沟通成功与失败的唯一语言,所有错误处理都建立在它之上。
1.1 什么是退出码
每个命令执行完,都会返回一个数字------退出码 (exit code)。0 表示成功,其他任何值表示失败。听起来简单,但这个任何值是重点:shell 不规定具体数字的含义 ,只规定 0 = 成功,其他都是出了点事。
bash
#!/bin/bash
ls /tmp/does_not_exist
echo "退出码:$?"
输出:
bash
ls: cannot access '/tmp/does_not_exist': No such file or directory
退出码:2
$? 是上一条命令的退出码。这是最常用的上一条命令发生了什么的查询方式。注意 $? 是个"瞬时状态"------一旦你执行了其他命令,它就被覆盖了。所以查询 $? 必须紧跟目标命令:
bash
# 错的写法
ls /tmp/not_here
result=$?
echo "$result" # 正确
# 错的写法
result=$?
ls /tmp/not_here
echo "$result" # 永远是 0(echo 自己的退出码)
第二条写法里 $? 拿到的其实是 ls 之前那条命令的退出码,而 echo 的退出码永远是 0。这是 shell 新手最常犯的错之一。
1.2 常见的退出码约定
虽然 shell 不强制规定,但业界有一套约定俗成的语义:
| 退出码 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | 通用错误(很多命令用它表示"出错了") |
| 2 | 命令参数错误(bash builtin 通常用这个) |
| 126 | 命令找到了但无法执行(无执行权限) |
| 127 | 命令找不到(command not found) |
| 128+N | 进程被信号 N 终止(130 = Ctrl+C,SIGINT) |
| 130 | Ctrl+C 退出(128 + 2) |
| 137 | 被 SIGKILL(128 + 9) |
| 143 | 被 SIGTERM(128 + 15),常见于 docker stop |
记住几个常用的就行:1 = 通用错误、2 = 参数错、126 = 不可执行、127 = 命令不存在、130 = Ctrl+C。其他的交给 $? 自带的语义。
1.3 exit 命令
写脚本时,你想让脚本自己返回某个退出码,就用 exit:
bash
#!/bin/bash
if [ ! -f "$1" ]
then
echo "错误:文件 $1 不存在" >&2
exit 1
fi
# 正常处理
process "$1"
exit 0
几个要点:
exit 0通常可以省略------脚本执行完最后一条命令的退出码会自动成为脚本的退出码。但如果想明确表达成功,显式写exit 0更清晰。exit不带参数,等价于exit $?,返回最后一条命令的退出码。- 退出码范围是
0-255,超过 255 会取模 (exit 300实际返回 44)。自定义错误码建议控制在 0-127 之内。
1.4 把错误信息送到对的地方
一个常被忽视的细节:脚本的错误信息应该输出到 stderr 而不是 stdout。两者的区别是:
stdout(标准输出):程序的正常结果,可以重定向到文件或管道传给下一个命令。stderr(标准错误):程序的诊断信息,默认直接显示在终端。
bash
echo "正常输出" # stdout
echo "错误信息" >&2 # stderr
>&2 是个文件描述符重定向,把输出送到文件描述符 2(也就是 stderr)。这么做的好处是:当用户把脚本输出重定向到文件时,错误信息仍然能显示在终端上:
bash
$ ./script.sh > output.log
错误:参数缺失! # 仍然显示在终端
$ cat output.log
正常输出 # 正常输出在文件里
>&2 的写法看着有点怪,但任何诊断信息、错误信息、警告信息都应该用 >&2 。这是 shell 编程的第三铁律------前两条铁律是"加双引号"和"${arr[@]}" 带引号。
二、set 命令
写 shell 脚本的人,多多少少都被静默失败坑过------脚本跑完没报错,但结果不对。set 命令的几个选项就是用来给脚本加防护栏的,强烈建议每个脚本开头都开。
2.1 set -e:遇错就退出
bash
#!/bin/bash
set -e
cd /nonexistent_dir
echo "这行不会执行"
set -e 告诉 shell:任何命令返回非零退出码,就立刻退出脚本(除非在 if/while/&&/|| 这种本就要判断退出码的上下文里)。
这个选项的争议不小。有人说加了 set -e 写起来麻烦,到处要手动处理错误,但对生产脚本来说,set -e 是必须的------它把忘记检查错误的代价降到最低。
2.2 set -u:未定义变量就报错
bash
#!/bin/bash
set -u
echo "$undefined_var"
默认行为是输出空行;开了 -u 之后,访问未定义变量会直接报错退出。这一条比 -e 更值得开,因为它能把拼写错误暴露出来。
bash
#!/bin/bash
set -u
user_nmae="张三" # 注意是 nmae 不是 name
echo "$user_name" # 没开 -u:输出空;开了 -u:报错
不写 -u,这种 typo 会让脚本静默地用空值继续跑,bug 藏得深。
2.3 set -o pipefail:管道失败别被掩盖
这是三个选项里最容易踩坑的一个。看例子:
bash
#!/bin/bash
set -e
ls /nonexistent | sort
echo "管道执行完毕"
没开 pipefail 时,ls 失败了,但管道的退出码取的是 sort 的退出码(0),set -e 检测不到,于是脚本继续执行------但 ls 的错误信息完全被吃掉了。
开了 pipefail:
bash
#!/bin/bash
set -e
set -o pipefail
ls /nonexistent | sort # 立刻报错退出
pipefail 让管道的退出码是所有命令中最后一个非零的。这样一来,管道里任何一步失败都会被捕获。
2.4 三个一起开:标配
生产脚本的标准开头:
bash
#!/bin/bash
set -euo pipefail
这一行浓缩了三个安全网:错误就退出、变量未定义就报错、管道失败别掩盖。几乎所有靠谱的 shell 脚本都会开这一行。
但有几个边界要注意:
第一,set -e 在某些命令之后不生效。 比如 command || true 后面的命令即使失败也不会触发退出,因为 || 已经"消化"了失败。
bash
set -e
false || true
echo "会执行" # 正常
false || echo "失败但被消化"
echo "会执行" # 正常
第二,函数返回值不会被 set -e 触发。 看个反直觉的例子:
bash
#!/bin/bash
set -e
func() {
return 1
}
func # 函数返回 1,但脚本不会退出
echo "这行会执行"
func 的退出码是 1,但 set -e 不把函数最后一条命令作为整体判断。要让函数能触发 set -e,要么在调用时显式判断:
bash
func || { echo "func 失败了" >&2; exit 1; }
要么让函数返回时用 return 显式声明:
perl
func() {
[ "$1" -gt 0 ] || return 1
# ...
}
第三,if 条件里也不生效。 这是设计如此------if 本来就是判断成功失败的,如果 set -e 在这里也生效,if 就没法用了。
2.5 set -x 和 set -v:调试用
bash
#!/bin/bash
set -x
name="张三"
echo "$name"
输出:
bash
+ name='张三'
+ echo '张三'
张三
set -x 在每条命令执行前打印展开后的样子,是调试 shell 脚本的核武器。日常开发时加进去排查问题,发版时去掉。
set -v 比 -x 更原始------它打印脚本的原始行(不展开变量)。一般 -x 就够用。
三、错误处理模式
set -euo pipefail 解决了基础防护问题,但脚本逻辑里总有些"不能简单失败"的场景------比如"找不到文件要不要继续"、"某个步骤失败要不要回滚"。这些就需要显式的错误处理模式。
3.1 || 和 && 短路
shell 提供了 ||(或)和 &&(与)来做"基于上一条命令结果"的逻辑:
bash
mkdir -p /tmp/work || { echo "创建目录失败" >&2; exit 1; }
grep "pattern" file.txt && echo "找到了" || echo "没找到"
cmd1 && cmd2 表示"cmd1 成功才执行 cmd2",cmd1 || cmd2 表示"cmd1 失败才执行 cmd2"。这是 shell 里写"成功/失败分支"最简洁的姿势。
|| 后面跟多条命令时,记得用 { ... } 括起来(注意 { 后面和 } 前面要带空格,结尾要带 ;):
bash
mkdir -p /tmp/work || { echo "创建失败" >&2; exit 1; }
3.2 防御式 vs 快速失败
两种风格在 shell 圈里争论不休:
防御式(出问题别崩溃,继续往下走):
bash
#!/bin/bash
process() {
for file in *.txt
do
if [ -f "$file" ]
then
if ! gzip "$file"
then
echo "压缩 $file 失败,跳过" >&2
continue
fi
fi
done
}
快速失败(出问题立刻退出,别留隐患):
bash
#!/bin/bash
set -e
for file in *.txt
do
[ -f "$file" ] || continue
gzip "$file" # 失败会立刻退出整个脚本
done
哪种更好?取决于场景。批量处理任务("对 100 个文件做 X,能做多少做多少")用防御式;事务性任务("部署代码,要么成功要么回滚")用快速失败。
实操经验:能快速失败就快速失败。很多静默 bug 都是防御式处理吞掉了错误造成的。
3.3 错误处理函数
如果脚本里有多处需要打印错误并退出,抽成一个函数:
bash
#!/bin/bash
set -euo pipefail
# 错误处理
die() {
echo "[ERROR] $*" >&2
exit 1
}
# 使用
[ "$#" -ge 1 ] || die "用法: $0 <参数>"
[ -f "$1" ] || die "文件不存在: $1"
die 是 shell 脚本里约定俗成的错误退出函数名。配合 set -e 使用,每个 || die "..." 都是显式的失败检查。
3.4 清理与回滚
有些操作有副作用(创建临时文件、修改配置、启动进程),失败时要清理:
bash
#!/bin/bash
set -e
work_dir=$(mktemp -d)
trap "rm -rf '$work_dir'" EXIT
cd "$work_dir"
# 处理逻辑
download_file > data.txt
process < data.txt > result.txt
cp result.txt /final/path/
trap ... EXIT 表示"脚本退出时(无论成功失败)执行这段命令"。这是清理临时资源的标准模式,下一节细讲。
更复杂的场景需要事务回滚------记录每一步的副作用,失败时反向撤销:
bash
#!/bin/bash
set -e
# 记录需要回滚的操作
rollback=()
register_rollback() {
rollback+=("$*")
}
rollback_all() {
for cmd in "${rollback[@]}"
do
eval "$cmd" || echo "回滚失败: $cmd" >&2
done
}
trap rollback_all EXIT
# 业务逻辑
create_user "alice"
register_rollback "delete_user 'alice'"
grant_permission "alice" "admin"
register_rollback "revoke_permission 'alice' 'admin'"
echo "用户创建成功"
这套"register_rollback + trap"模式在数据库迁移、配置变更脚本里很常见。
四、trap退出
trap 是 shell 里最被低估的命令之一。它能让你在脚本收到信号或退出时执行指定代码------这正是优雅退出并清理资源的基础。
4.1 基本语法
bash
trap 'commands' SIGNAL...
commands:收到信号时要执行的命令(用单引号包起来,延迟展开)SIGNAL:要捕获的信号名(SIGINT、SIGTERM、EXIT等)
4.2 三个最常用的场景
场景一:捕获 Ctrl+C
bash
#!/bin/bash
trap 'echo "收到 Ctrl+C,正在清理..."; cleanup; exit 130' INT
cleanup() {
rm -f /tmp/workfile
}
# 模拟工作
while true
do
echo "工作中..."
sleep 1
done
按 Ctrl+C 触发 SIGINT,trap 接收到后清理资源并退出。exit 130 是"Ctrl+C 中断"的标准退出码(128 + 2)。
场景二:清理临时文件
bash
#!/bin/bash
set -e
tmpfile=$(mktemp)
trap "rm -f '$tmpfile'" EXIT
# 使用 tmpfile 做事情
echo "重要数据" > "$tmpfile"
# 模拟出错
[ 1 -eq 1 ] || true # 占位
# 无论成功失败,EXIT trap 都会清理
trap ... EXIT 是"无论脚本怎么退出都执行清理"的标准模式。mktemp 配 trap "rm -f" EXIT 是 shell 脚本里最经典的清理模式。
场景三:忽略某些信号
bash
#!/bin/bash
trap '' HUP # 忽略 SIGHUP
trap '' TERM # 忽略 SIGTERM
trap '' INT # 忽略 Ctrl+C
trap '' SIGNAL 是"忽略该信号"------收到信号就当没看见。这种用法在守护进程里偶尔会用,但绝大多数情况下不要忽略 SIGTERM------这是运维同事要杀你进程的标准手段,忽略它会让服务没法正常关停。
4.3 EXIT 陷阱的妙用
EXIT 不是真正的信号,它是 bash 提供的"伪信号"------脚本退出时触发(无论是正常结束、set -e 退出、还是信号终止)。
bash
#!/bin/bash
set -e
start_time=$(date +%s)
trap 'echo "脚本耗时 $(( $(date +%s) - start_time )) 秒"' EXIT
# 业务逻辑
sleep 2
echo "完成"
输出:
完成
脚本耗时 2 秒
EXIT 陷阱配合 set -e 是无论成功失败都执行的清理------临时文件清理、耗时统计、状态上报都能用它。
4.4 陷阱里的命令
trap 的命令是字符串 ,会在收到信号时由 shell 重新解析执行。这意味着单引号和双引号的选择很关键:
bash
#!/bin/bash
file="/tmp/workfile"
# 单引号:延迟展开,trap 触发时才计算
trap 'rm -f "$file"' EXIT # 正确
# 双引号:立即展开,trap 里写死了空字符串
trap "rm -f '$file'" EXIT # 也能用,但陷阱里变量变化不会反映
单引号版的 $file 是在 trap 触发时才展开的,能拿到最新的值;双引号版是脚本加载时就展开。如果 trap 里的命令里需要用变量,用单引号。
4.5 实战:一个"可中断"的批量处理
bash
#!/bin/bash
set -euo pipefail
total=0
done=0
failed=0
cleanup() {
echo ""
echo "===== 中断/退出汇总 =====" >&2
echo "处理: $done / $total, 失败: $failed" >&2
exit 130
}
# Ctrl+C 时调用 cleanup
trap cleanup INT
# 正常退出时打印汇总
trap 'echo "===== 完成 ====="; echo "处理: $done / $total, 失败: $failed"' EXIT
files=(*.log)
total=${#files[@]}
for file in "${files[@]}"
do
if grep -q "ERROR" "$file"
then
if ! gzip "$file"
then
((failed++)) || true
fi
fi
((done++))
echo "进度: $done/$total"
done
注意 ((failed++)) || true------set -e 下,如果 failed 之前是 0,自增后变 1,((...)) 返回 0(因为结果非零,bash 把它当作"成功"),但反过来如果 failed 之前是 1 而自增后是 2,((...)) 返回非零值触发 set -e 退出 。这是个反直觉的 bash 特性,加 || true 保险一下。
五、日志
错误处理解决的是出问题怎么办,日志解决的是出问题怎么查。两者配对才完整。
5.1 为什么要日志
新手常觉得我自己写的脚本,我自己跑,加日志干嘛。但生产脚本的真相是:
- 你可能不在现场(crontab 凌晨跑)
- 失败后没有现场(一次性脚本跑完就退出)
- 排错靠现场(出错那一刻的输入、状态、调用链)
日志是脚本的黑匣子。出问题后没有日志,排查就是猜谜。
5.2 日志的基本要求
一个合格的日志至少要回答这几个问题:
- 什么时候:时间戳(精确到秒或毫秒)
- 什么级别:INFO / WARN / ERROR / DEBUG
- 哪里出的:脚本名、函数名、行号(可选)
- 发生了什么:清晰的一句话描述
最简版:
css
[2024-01-15 10:23:45] [INFO] 开始处理文件 input.log
[2024-01-15 10:23:46] [ERROR] 解析失败:第 12 行格式错误
[2024-01-15 10:23:46] [INFO] 处理完成:成功 95 条,失败 5 条
5.3 写一个日志函数
bash
#!/bin/bash
set -euo pipefail
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
}
# 使用
log INFO "开始处理"
log WARN "配置文件不存在,使用默认值"
log ERROR "数据库连接失败"
log DEBUG "内部变量: $internal_var" # 默认不显示
几点设计要点:
- 日志输出到 stderr (
>&2)------正常结果和数据走 stdout,诊断信息走 stderr,互不干扰。 - 支持日志级别过滤 ------
LOG_LEVEL=DEBUG ./script.sh可以看到所有调试信息,默认 INFO 级别。 - 支持输出到文件 ------
LOG_FILE=/var/log/myscript.log ./script.sh可以保存日志。 tee -a同时输出到文件和 stderr------既能在终端看到,又能保存到文件。
5.4 调试日志
调试日志是排查问题的关键,但生产环境又不想太吵。一个常见做法是用 BASH_SOURCE 和 LINENO 自动加位置信息:
bash
#!/bin/bash
set -euo pipefail
debug() {
[[ "${DEBUG:-0}" == "1" ]] || return 0
local timestamp
timestamp=$(date '+%H:%M:%S.%3N')
echo "[$timestamp] [DEBUG] ${BASH_SOURCE[1]##*/}:${BASH_LINENO[0]} $*" >&2
}
# 使用
debug "进入 process 函数,参数: $1"
process() {
debug "正在处理 $1"
# ...
}
输出(设 DEBUG=1):
less
[10:23:45.123] [DEBUG] script.sh:14 进入 process 函数,参数: input.log
[10:23:45.124] [DEBUG] script.sh:19 正在处理 input.log
BASH_SOURCE[1] 和 BASH_LINENO[0] 是 bash 内部维护的"调用栈",下标 1 表示"调用当前函数的那个文件"和"那一行"。这个技巧在大型脚本里调试非常好用。
5.5 用 logger 命令写系统日志
Linux 自带 logger 命令,能把消息写到 syslog:
bash
#!/bin/bash
log() {
local level=$1
shift
logger -t "myscript" -p "user.$level" "$*"
}
# 使用
log INFO "开始处理"
log ERROR "数据库连接失败"
输出会进 /var/log/syslog(Debian/Ubuntu)或 /var/log/messages(CentOS/RHEL),可以用 journalctl -t myscript 查看。适合长期运行的服务脚本,不适合一次性任务。
5.6 日志轮转
日志写多了会把磁盘撑爆。生产脚本的日志文件需要轮转------定期切割、压缩、清理。
如果用 logger 走 syslog,轮转由系统的 logrotate 负责。如果自己写文件,可以用 logrotate 配置文件:
javascript
# /etc/logrotate.d/myscript
/var/log/myscript.log {
daily
rotate 7
compress
missingok
notifempty
postrotate
# 通知脚本重新打开日志文件
kill -HUP $(cat /var/run/myscript.pid 2>/dev/null) 2>/dev/null || true
endscript
}
或者脚本里自己处理(用 SIGHUP 触发重新打开日志文件)。日常脚本用不到这么复杂,知道有这件事就行。
六、完整实战
这一节我们看下怎么将数据处理脚本加固成生产级,看错误处理和日志是怎么在真实项目里落地的。
原始版本:
bash
#!/bin/bash
declare -A total_amount
declare -A order_count
while IFS=',' read -r order_id product quantity price date
do
[[ "$order_id" == "order_id" ]] && continue
qty=${quantity:-0}
prc=${price:-0}
[[ ! "$qty" =~ ^[0-9]+$ ]] && continue
[[ ! "$prc" =~ ^[0-9]+$ ]] && continue
amount=$((qty * prc))
total_amount[$product]=$(( ${total_amount[$product]:-0} + amount ))
((order_count[$product]++))
done < sales.csv
echo "product,total_amount,order_count,avg_amount"
for product in "${!total_amount[@]}"
do
total=${total_amount[$product]}
count=${order_count[$product]}
avg=$((total / count))
echo "$product,$total,$count,$avg"
done | sort -t',' -k2 -rn
加固后:
bash
#!/bin/bash
#
# analyze_sales.sh - 分析销售数据
# 用法: analyze_sales.sh <输入CSV> [输出CSV]
#
set -euo pipefail
# ============================================================
# 配置
# ============================================================
SCRIPT_NAME=$(basename "$0")
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
}
die() {
log ERROR "$*"
exit 1
}
# ============================================================
# 参数校验
# ============================================================
[ "$#" -ge 1 ] || die "用法: $SCRIPT_NAME <输入CSV> [输出CSV]"
INPUT=$1
OUTPUT=${2:-}
[ -f "$INPUT" ] || die "输入文件不存在: $INPUT"
[ -r "$INPUT" ] || die "输入文件不可读: $INPUT"
log INFO "开始分析 $INPUT"
# ============================================================
# 临时文件
# ============================================================
work_dir=$(mktemp -d)
trap "rm -rf '$work_dir'" EXIT
# ============================================================
# 业务逻辑
# ============================================================
declare -A total_amount
declare -A order_count
declare -A invalid_rows
total_rows=0
skipped_rows=0
while IFS=',' read -r order_id product quantity price date
do
((total_rows++)) || true
# 跳表头
[[ "$order_id" == "order_id" ]] && continue
# 字段校验
if [[ -z "$product" ]]
then
invalid_rows[$total_rows]="缺少 product"
((skipped_rows++)) || true
continue
fi
if [[ ! "$quantity" =~ ^[0-9]+$ ]] || [[ ! "$price" =~ ^[0-9]+$ ]]
then
invalid_rows[$total_rows]="quantity/price 非数字: $quantity/$price"
((skipped_rows++)) || true
continue
fi
# 聚合
amount=$((quantity * price))
total_amount[$product]=$(( ${total_amount[$product]:-0} + amount ))
((order_count[$product]++)) || true
done < "$INPUT"
# ============================================================
# 输出
# ============================================================
output_file="$work_dir/result.csv"
exec 3> "$output_file" # 打开文件描述符 3 指向输出
echo "product,total_amount,order_count,avg_amount" >&3
for product in "${!total_amount[@]}"
do
total=${total_amount[$product]}
count=${order_count[$product]}
avg=$((total / count))
echo "$product,$total,$count,$avg" >&3
done | sort -t',' -k2 -rn
exec 3>&- # 关闭文件描述符
# ============================================================
# 收尾
# ============================================================
if [[ -n "$OUTPUT" ]]
then
cp "$output_file" "$OUTPUT"
log INFO "结果已写入: $OUTPUT"
else
cat "$output_file"
fi
log INFO "处理完成: 总行数 $total_rows, 跳过 $skipped_rows, 成功 ${#total_amount[@]} 个产品"
if [[ ${#invalid_rows[@]} -gt 0 ]]
then
log WARN "${#invalid_rows[@]} 行数据有问题:"
for row in "${!invalid_rows[@]}"
do
log WARN " 第 $row 行: ${invalid_rows[$row]}"
done
fi
对比一下前后两个版本,加固后多了这些能力:
set -euo pipefail:三个安全网全开- 参数校验:缺参数、文件不存在、不可读都直接报错退出
- 日志函数:INFO/WARN/ERROR 三级,可控级别,可选输出到文件
- 临时目录 + trap EXIT:脚本退出时自动清理
- 数据校验 :缺字段、非数字都记录到
invalid_rows,不静默丢弃 - 统计输出:总行数、跳过数、成功产品数、问题行明细
- 退出码规范:成功返回 0,参数错误返回 1
用起来:
bash
# 正常用法:结果输出到 stdout
./analyze_sales.sh sales.csv
# 输出到文件 + 详细日志
LOG_LEVEL=DEBUG LOG_FILE=/var/log/analyze.log ./analyze_sales.sh sales.csv result.csv
对比之下,没加固的版本会怎样?
- 缺参数 →
cat sales.csv把脚本自己读进来当数据,结果错乱 - 文件不存在 → 进入
while read没数据,输出空,crontab 里你就看到一个空邮件 - 数字字段异常 → 直接
continue,问题行被静默吃掉 - 临时文件残留 → 磁盘慢慢被吃满
这就是能跑和靠得住的差距。
七、总结
错误处理和日志是把脚本从玩具变成工具的关键。回顾一下要点:
- 退出码 是 shell 世界的通用语,
$?是查询方式,exit N是返回方式。 set -euo pipefail是脚本标配,但要知道它在 if/while/|| 上下文里不生效- 错误处理模式 :能用
set -e就快速失败,需要"做多少算多少"再考虑防御式 trap处理信号和清理:trap '...' EXIT清理临时资源,trap '...' INT处理 Ctrl+C- 日志 要有时间戳、级别、清晰描述;可调级别、可选输出文件、调试模式用
BASH_SOURCE+BASH_LINENO - 加固脚本 =
set -euo pipefail+ 参数校验 + trap 清理 + 日志 + 退出码规范
这一篇我们把脚本的健壮性补齐了。下一篇顺着工程化这条路继续走------聊一聊 shell 脚本怎么模块化:把常用函数抽到库文件、怎么写可被 source的脚本、怎么处理多脚本项目。脚本从几百行长到几千行的时候,能不能组织好是写得出来和写得下去的区别。
本文示例在 GNU bash 4.3+ 环境下测试通过。
BASH_SOURCE和BASH_LINENO在 bash 3.0+ 就支持。logger命令需要 syslog 服务,容器化部署时建议改用文件日志。date '+%3N'(毫秒)需要 GNU date。