复盘与重构:我把之前的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 -epgrep没找到进程返回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列表ips192.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 DAYS7 KEEP3 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 $((KEEP1)): 跳过前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脚本你还有哪些觉得“拧巴”的地方或者有哪些“一直这么写不知道对不对”的习惯评论区聊聊我们一起迭代。