复盘与重构:我把之前的Shell脚本指南,推翻重写了
一、 纠偏:为什么我不建议你无脑set -e?
上篇我把set -euo pipefail吹上了天,说这是“标配”。这其实是不负责任的。
在复杂的生产脚本中,set -e是一把双刃剑。
问题出在哪?
set -e会在任何命令返回非0时立刻退出。但在Shell逻辑里,“返回非0”并不总是代表“失败”。
比如这个场景:
# 检查某个进程是否存在,不存在就启动它 pgrep -x "nginx" > /dev/null if [ $? -ne 0 ]; then systemctl start nginx fi如果用了set -e,pgrep没找到进程返回1,脚本直接就退出了,if语句根本没机会执行。你不得不写成这样:
pgrep -x "nginx" > /dev/null || true # 强行让这一行返回0 if [ $? -ne 0 ]; then systemctl start nginx fi满屏的|| true会让脚本变得极其丑陋且难以阅读。
企业级修正方案:
局部屏蔽:只在关键逻辑块开启严格模式。
set +e # 关闭严格模式 pgrep -x "nginx" result=$? set -e # 重新开启 if [ $result -ne 0 ]; then systemctl start nginx fi更优雅的写法(推荐):利用逻辑运算符,根本不用
set -e。# 如果pgrep失败(返回非0),则执行后面的启动命令 pgrep -x "nginx" > /dev/null || systemctl start nginx||的意思是:如果左边失败,执行右边。这比set -e更符合直觉,也更安全。
结论:不要把set -e当成保险丝,要把显式的错误判断(如if语句、逻辑运算符)当成你的驾驶技术。
二、 重构:那个“自动回滚”的脚本太重了
上篇我举了一个带trap和回滚逻辑的部署脚本,很多读者反馈:“太复杂了,看不懂,也不敢用。”
确实,那是高级运维玩的,不适合日常脚本。对于90%的日常自动化任务,我们只需要做到“失败即停止,不破坏现场”就够了,不需要自动回滚。
简化版的企业级部署脚本:
#!/usr/bin/env bash APP_DIR="/opt/myapp" TIMESTAMP=$(date +%Y%m%d_%H%M%S) BACKUP_DIR="${APP_DIR}_bak_${TIMESTAMP}" echo "==> 开始部署 [${TIMESTAMP}]" # 1. 防御性检查:目录必须存在 if [ ! -d "$APP_DIR" ]; then echo "Error: 应用目录不存在,请检查环境。" exit 1 fi # 2. 备份(仅当目录非空时) if [ "$(ls -A $APP_DIR)" ]; then echo "==> 备份当前版本至 ${BACKUP_DIR}" cp -a "$APP_DIR" "$BACKUP_DIR" else echo "==> 目录为空,跳过备份" fi # 3. 核心逻辑:拉取代码 echo "==> 更新代码..." if ! git -C "$APP_DIR" pull origin main; then echo "Error: Git拉取失败,请检查网络或权限。" echo "Info: 备份文件位于 ${BACKUP_DIR},请手动恢复。" exit 1 fi # 4. 重启服务 echo "==> 重启服务..." if ! systemctl restart myapp; then echo "Error: 服务重启失败!" echo "Info: 请检查 journalctl -u myapp。备份文件位于 ${BACKUP_DIR}。" exit 1 fi echo "==> 部署成功!"改进点:
去掉了晦涩的
trap和ROLLBACK变量。每个关键步骤(
git pull,systemctl restart)后都紧跟着显式的错误判断(if ! ...)。失败后,打印清晰的错误原因和恢复指引(告诉人在哪找备份),而不是盲目地自动回滚(自动回滚可能掩盖更深的错误)。
三、 进阶:数组与映射,告别混乱的字符串拼接
上篇讲了for循环,但没讲数组。在Shell里处理列表数据,数组比字符串拼接靠谱一万倍。
错误示范(处理IP列表):
ips="192.168.1.1 192.168.1.2 192.168.1.3" for ip in $ips; do ping -c 1 $ip done如果IP里带了空格或者其他特殊字符,这就崩了。
正确姿势(使用数组):
ips=("192.168.1.1" "192.168.1.2" "192.168.1.3") for ip in "${ips[@]}"; do ping -c 1 "$ip" done"${ips[@]}"会将数组中的每个元素作为一个独立的字符串传递,完美保留了参数边界。
关联数组(模拟字典/Map):
如果你用的是 Bash 4.0+(现在基本都是),可以用关联数组处理键值对,这在处理配置文件时非常有用。
declare -A config config["port"]=8080 config["user"]="admin" echo "端口是: ${config["port"]}" echo "用户是: ${config["user"]}"四、 实战:写一个“人话”版的日志清理脚本
上篇提到了日志清理,但没有给出完美的实现。这是一个非常常见的需求,也是最容易出事故的脚本。
需求:清理/var/log/myapp下超过7天的.log文件,但保留最近3个文件(无论是否过期)。
#!/usr/bin/env bash LOG_DIR="/var/log/myapp" DAYS=7 KEEP=3 echo "==> 清理 ${LOG_DIR} 中超过 ${DAYS} 天的日志..." # 1. 检查目录是否存在 if [ ! -d "$LOG_DIR" ]; then echo "Error: 日志目录不存在。" exit 1 fi # 2. 统计文件总数 file_count=$(find "$LOG_DIR" -maxdepth 1 -name "*.log" -type f | wc -l) # 3. 如果文件数少于等于保留数,只做过期清理,不干涉数量 if [ "$file_count" -le "$KEEP" ]; then echo "==> 文件数(${file_count})小于等于保留数(${KEEP}),仅清理过期文件。" find "$LOG_DIR" -maxdepth 1 -name "*.log" -type f -mtime +"$DAYS" -delete else # 4. 文件数较多,先按时间排序,跳过最新的KEEP个,再删过期的 echo "==> 文件数(${file_count})大于保留数(${KEEP}),执行清理策略。" # ls -t: 按时间排序,-r: 反转(最旧的在前) # tail -n +$((KEEP+1)): 跳过前KEEP个(最新的) ls -t "$LOG_DIR"/*.log | tail -n +$((KEEP + 1)) | while read -r file; do # 再次检查文件是否过期,双重保险 if [ -f "$file" ] && [ "$(find "$file" -mtime +"$DAYS" -print)" ]; then echo "删除: $file" rm -f "$file" fi done fi echo "==> 清理完成。"这个脚本的亮点:
逻辑严密:考虑了文件数量保护和过期时间保护的双重逻辑。
安全删除:使用
while read循环处理文件名,避免了空格问题。信息透明:每一步都有
echo输出,你知道它在干什么。
五、 总结:Shell脚本的“及格线”
经过这次复盘,我认为一个合格的、能上生产环境的Shell脚本,不需要多么花哨的技巧,只需要守住这几条底线:
拒绝魔法:不要用
set -e来掩盖逻辑缺陷,用if和||来处理错误。防御性编程:凡是外部输入(参数、文件、命令返回值),一律做校验。
人话输出:脚本出错时,告诉人是哪里错了,备份在哪,怎么恢复,而不是只返回一个非0代码。
数据结构化:处理列表用数组,处理键值用关联数组,别玩字符串拼接。
KISS原则:Keep It Simple, Stupid。能写10行的逻辑,别写50行。自动回滚这种事,交给专业的配置管理工具(如Ansible)去做,Shell脚本做好执行者和报告者。
Shell脚本是胶水,不是水泥。它的目的是把简单的逻辑粘合起来,而不是构建复杂的系统。理解了这一点,你的脚本水平就真正入门了。
关于Shell脚本,你还有哪些觉得“拧巴”的地方?或者有哪些“一直这么写,不知道对不对”的习惯?评论区聊聊,我们一起迭代。
