Shell脚本进阶实战:参数处理、错误捕获与幂等性设计 1. 从能跑就行到跑得漂亮Shell 脚本的进阶分水岭很多人学 Shell 脚本都经历过这样一个阶段能写个for循环批量改文件名能用if判断文件是否存在就觉得自己会 Shell了。但真到了生产环境里面对日志切割、批量部署、数据清洗这些活儿写出来的脚本要么跑一半崩了要么把线上文件误删了要么在别人机器上根本跑不起来。这个分水岭其实不在于你记住了多少命令而在于你有没有建立起一套脚本工程化的思维习惯。这篇内容就是围绕 Shell 练习题三这个主题展开的。它适合已经能写基础脚本、但想让代码更健壮、更可维护的朋友。我会把练习题里最典型的几类场景拆开讲——参数处理、错误捕获、文本处理三剑客的配合、以及脚本的幂等性设计。每一类我都会先讲为什么这么设计再给可直接抄的代码最后补上我在实际项目里踩过的坑。你不需要死记硬背跟着思路走一遍下次写脚本时自然就会多想一步。Shell 脚本最容易被低估的地方在于它既是胶水语言又是运维的最后一道防线。一个rm -rf写错变量可能就是一场事故。所以练习题的价值不在于做对而在于通过反复练习把防御性编程变成肌肉记忆。下面这几块内容我会尽量还原真实工作场景而不是给你一堆脱离实际的玩具例子。2. 参数与输入处理别让第一个参数就把脚本搞崩2.1 位置参数、getopts与手动解析的取舍Shell 脚本接收参数的方式主要有三种位置参数$1 $2、getopts内建命令、以及while循环手动解析。很多练习题只让你用$1但真实场景里参数往往带选项比如-f 文件名 -n 数量 -v。这时候如果还硬用位置参数脚本会变得极其脆弱——用户换个顺序传参就全乱了。getopts是 POSIX 标准的一部分几乎所有 Shell 都支持这是它最大的优势。它的工作方式是逐个扫描选项把选项字母放进你指定的变量把选项值放进OPTARG。下面是一个典型用法#!/bin/bash while getopts :f:n:vh opt; do case $opt in f) FILE$OPTARG ;; n) COUNT$OPTARG ;; v) VERBOSE1 ;; h) usage; exit 0 ;; \?) echo 无效选项: -$OPTARG 2; exit 1 ;; :) echo 选项 -$OPTARG 需要参数 2; exit 1 ;; esac done shift $((OPTIND - 1))这里有几个细节值得说。getopts字符串开头的冒号:表示静默模式它会区分无效选项和缺少参数两种情况分别用?和:捕获。如果不加这个冒号Shell 会自己往 stderr 打印错误信息你就没法自定义提示了。另外shift $((OPTIND - 1))这一步非常关键它把已经解析过的选项从参数列表里移走剩下的$1 $2就是位置参数这样选项和位置参数可以混用。那什么时候不用getopts当你的选项需要支持长选项比如--file时getopts就无能为力了得手动写while循环配合case。手动解析的代码更长但灵活性最高。我的经验是内部小工具用getopts足够对外发布的脚本如果参数复杂就老老实实手动解析别为了省几行代码牺牲可用性。2.2 参数校验把错误挡在业务逻辑之前参数拿到手之后第一件事不是干活而是校验。我见过太多脚本参数没传就往下跑结果rm -rf $DIR/里$DIR是空的变成了rm -rf /。这种事故的根源就是缺少校验。校验分三层。第一层是必填项是否存在用${VAR:?错误信息}这种参数扩展最简洁: ${FILE:?必须通过 -f 指定文件} : ${COUNT:?必须通过 -n 指定数量}这行的意思是如果FILE未设置或为空就打印错误信息并退出退出码非零。比手写if [ -z $FILE ]短得多而且意图明确。第二层是格式是否合法。比如COUNT必须是正整数可以用正则或者case判断if ! [[ $COUNT ~ ^[1-9][0-9]*$ ]]; then echo 数量必须是正整数当前值: $COUNT 2 exit 1 fi第三层是业务约束。比如文件必须存在、目录必须可写、目标不能是根目录。这里有个我踩过的坑[ -f $FILE ]在$FILE为空时行为不确定某些 Shell 下会报错。所以校验顺序永远是先判空、再判存在、最后判类型。提示所有错误信息都往 stderr 输出2这样脚本的输出可以被干净地重定向不会污染正常结果。这是脚本能被其他程序调用的前提。2.3 交互式输入与超时控制有些练习题会让你写提示用户输入的脚本。read命令是标准做法但默认的read会一直阻塞等待如果脚本被自动化调用就会卡死。加个超时参数-t能解决大部分问题if ! read -t 10 -p 请输入目标目录: TARGET; then echo 输入超时使用默认目录 /tmp 2 TARGET/tmp firead -t 10表示 10 秒内没输入就返回非零。配合if !就能实现超时走默认值的逻辑。另外-s参数可以隐藏输入用于密码-r参数禁止反斜杠转义处理路径时必加否则\t会被吃掉。这几个参数组合起来交互脚本的健壮性会上一个台阶。3. 错误处理与退出码脚本的安全气囊3.1set -e的诱惑与陷阱几乎每篇 Shell 教程都会告诉你脚本开头加set -e命令失败就自动退出。这话对但不全对。set -e的规则相当微妙很多情况下它并不会如你预期地触发退出。比如命令在if条件里、在或||链里、在!取反里set -e都会失效。还有管道cmd1 | cmd2默认只看最后一个命令的退出码前面失败了也不管。这些例外让set -e变得不可靠——你以为它兜底了其实漏洞百出。我的建议是set -e可以用但必须配合set -o pipefail和set -u。pipefail让管道中任意一个命令失败就返回失败-u让引用未定义变量时报错退出。三个一起上set -euo pipefail即便如此关键步骤还是要显式判断退出码不能全指望set -e。尤其是那些失败也没关系的命令要主动用|| true放行否则脚本会莫名其妙中断。3.2 用trap做清理和兜底脚本运行过程中可能创建临时文件、加锁、改配置如果中途失败退出这些现场就留在那里了。trap命令可以在脚本收到信号或退出时执行清理逻辑这是专业脚本的标配。TMPFILE$(mktemp) cleanup() { rm -f $TMPFILE echo 已清理临时文件 2 } trap cleanup EXIT INT TERMEXIT表示无论正常结束还是出错退出都执行INT和TERM分别对应 CtrlC 和 kill 信号。把清理逻辑集中在一个函数里用trap挂上去代码干净且不会遗漏。这里有个细节trap里的清理命令如果失败可能会覆盖原来的退出码。所以清理函数里最好用|| true兜底或者手动保存$?再恢复。我一般会在函数开头local exit_code$?结尾exit $exit_code确保退出码不被污染。3.3 退出码的语义化设计退出码不只是0 成功、非 0 失败。在复杂脚本里不同的失败原因用不同的退出码调用方就能精准判断。比如退出码含义0成功1通用错误2参数错误3文件不存在4权限不足5依赖命令缺失这套约定不是强制的但团队内部统一之后排错效率会明显提升。调用方可以写if [ $? -eq 3 ]; then ...做针对性处理而不是笼统地失败了。注意退出码范围是 0-255超过会取模。所以别用 256 这种值也别用 126、127、128 这些被 Shell 保留的码分别表示不可执行、命令未找到、被信号终止。4. 文本处理三剑客grep、sed、awk的配合套路4.1 先想清楚取什么再选工具文本处理是 Shell 练习题的重头戏。很多人拿到需求就上手写awk结果越写越复杂。我的习惯是先问自己三个问题我要的是行还是字段是筛选还是替换是单次处理还是统计聚合要筛选行、按模式匹配用grep。要替换、删除、插入行内内容用sed。要按列处理、做统计、格式化输出用awk。这三者不是互斥的实际脚本里经常串起来用。比如从日志里找出错误行提取时间戳和错误码统计每个错误码出现次数就是grep筛行、awk取字段并聚合的典型组合。4.2grep的常用姿势与性能考量grep最基础的用法是grep pattern file但实战里用得更多的是这些参数组合grep -rn --include*.log ERROR /var/log/-r递归目录-n显示行号--include限定文件类型。这个组合在排查问题时极其高效。如果模式是固定字符串而非正则加-F能显著提速因为跳过了正则引擎。大文件搜索时-F配合--line-buffered还能让结果实时输出。反向匹配用-v计数用-c只输出匹配部分用-o。-o特别有用比如从一行里提取所有 IP 地址grep -oE ([0-9]{1,3}\.){3}[0-9]{1,3} access.log这里-E启用扩展正则避免大量反斜杠转义。记住一个原则能用-E就别用基本正则可读性差太多。4.3sed的替换边界与原地修改sed最常用的是替换sed s/old/new/g。但新手最容易踩的坑是分隔符冲突——如果old或new里本身有斜杠比如路径就得换分隔符sed s|/old/path|/new/path|g file用|代替/作为分隔符可读性立刻提升。另一个坑是原地修改。sed -i直接改文件但不同系统比如某些 Unix 和 Linux 发行版对-i后是否要跟参数要求不同。可移植的写法是先输出到临时文件再替换sed s/foo/bar/g input tmp mv tmp input这样虽然多一步但跨平台稳定。如果确定只在特定环境跑sed -i.bak还能顺便留个备份出问题能回滚。sed的地址范围也值得掌握。sed -n 10,20p打印 10 到 20 行sed /start/,/end/d删除两个模式之间的内容。这些在处理配置文件、日志片段时非常实用。4.4awk的字段思维与聚合能力awk的核心是按字段处理。默认以空白分割$1是第一列$NF是最后一列NF是列数。这个模型让awk在处理结构化文本时如鱼得水。一个经典需求统计某列的总和、平均值、最大值。awk几行就能搞定awk {sum$3; if($3max) max$3} END {print 总和:, sum, 最大:, max, 平均:, sum/NR} data.txtNR是当前行号END块在处理完所有行后执行。这种逐行累加、最后输出的模式是awk最强大的地方。awk还支持关联数组做分组统计特别方便awk {count[$1]} END {for(k in count) print k, count[k]} access.log这行统计第一列每个值出现的次数。如果日志第一列是 IP那就是每个 IP 的访问次数。配合sort -rn -k2就能排出 Top N。提示awk的-F指定分隔符处理 CSV 时用-F,。但 CSV 如果有引号包裹的逗号awk就力不从心了这时候得上专门的 CSV 解析工具别硬扛。5. 幂等性与并发安全脚本重复执行不出事5.1 什么是幂等为什么它重要幂等的意思是同一个脚本执行一次和执行多次结果一样。听起来简单但很多脚本做不到。比如往配置文件追加一行跑两次就重复了创建目录如果不判断是否存在第二次就报错。在自动化流程里脚本可能因为重试、定时任务、人工误操作而重复执行不幂等的脚本就是定时炸弹。实现幂等的核心思路是先检查、再操作。创建目录用mkdir -p它天然幂等。追加配置前先grep -q判断是否已存在if ! grep -q ^export PATH.*/opt/bin ~/.bashrc; then echo export PATH$PATH:/opt/bin ~/.bashrc fi删除操作要格外小心rm -f比rm幂等文件不存在也不报错但rm -rf的破坏力也更大用之前一定要确认变量非空。5.2 文件锁防止脚本自己和自己打架如果脚本可能被并发调用比如定时任务没跑完下一次又启动了就需要加锁。最简单的方案是用flockexec 200/var/lock/myscript.lock flock -n 200 || { echo 另一个实例正在运行 2; exit 1; }exec 200打开一个文件描述符flock -n尝试非阻塞加锁失败就退出。脚本结束时文件描述符自动关闭锁也就释放了。这个方案比手动创建.pid文件再判断可靠得多因为进程崩溃时锁会自动释放不会留下死锁。如果系统没有flock可以用mkdir的原子性模拟mkdir成功说明拿到锁失败说明已被占用。但这种方式在进程异常退出时锁不会自动清理需要额外处理不如flock省心。5.3 临时文件的安全创建脚本里经常需要临时文件做中转。用mktemp而不是自己拼路径原因有两个一是mktemp保证文件名唯一避免并发冲突二是它创建的权限是 600只有当前用户可读写防止敏感数据泄露。TMPDIR$(mktemp -d) TMPFILE$(mktemp)mktemp -d创建临时目录适合需要多个临时文件的场景。记得配合前面说的trap在退出时清理否则/tmp会被塞满。注意不要用$$进程号拼临时文件名因为进程号会复用而且可预测存在被恶意利用的风险。mktemp是唯一推荐的做法。6. 实战拆解三个典型练习题的重构过程6.1 批量重命名从能跑到可回滚练习题常见需求把目录下所有.txt文件重命名为.bak。新手写法for f in *.txt; do mv $f ${f%.txt}.bak; done这行能跑但有几个问题。第一如果目录下没有.txt文件*.txt不会被展开循环会执行一次mv报错。第二如果目标文件已存在mv会直接覆盖没有提示。第三没有日志出错了不知道改了哪些。重构后的版本#!/bin/bash set -euo pipefail shopt -s nullglob # 没有匹配时展开为空循环不执行 for f in *.txt; do target${f%.txt}.bak if [ -e $target ]; then echo 跳过: $target 已存在 2 continue fi mv -- $f $target echo 重命名: $f - $target doneshopt -s nullglob解决了空匹配问题[ -e ]判断避免覆盖mv --防止文件名以-开头被当成选项。这几处改动让脚本从玩具变成了工具。6.2 日志切割按日期归档并清理旧文件需求把当前日志按日期重命名归档删除 7 天前的归档。这个需求涉及日期计算、文件操作、条件判断是综合练习的好素材。#!/bin/bash set -euo pipefail LOG_DIR/var/log/myapp ARCHIVE_DIR$LOG_DIR/archive KEEP_DAYS7 mkdir -p $ARCHIVE_DIR today$(date %Y%m%d) if [ -f $LOG_DIR/app.log ]; then mv $LOG_DIR/app.log $ARCHIVE_DIR/app-$today.log echo 已归档: app-$today.log fi find $ARCHIVE_DIR -name app-*.log -mtime $KEEP_DAYS -delete echo 已清理 $KEEP_DAYS 天前的归档这里date %Y%m%d生成日期后缀find -mtime 7找出修改时间超过 7 天的文件-delete直接删除。注意-mtime 7的语义是修改时间在 7 天之前不是7 天前那一天这个边界容易搞错。如果不想用-delete怕误删可以先-print确认再改成-exec rm {} \;。生产环境里我习惯先 dry-run 一遍确认输出符合预期再真删。6.3 配置检查用脚本做前置校验部署前检查配置文件是否符合规范是运维脚本的常见任务。比如检查某个配置文件里必须包含某些键、不能包含某些值。这类脚本的关键是逐项检查、汇总结果、明确退出码。#!/bin/bash set -uo pipefail CONF$1 errors0 check_contains() { if ! grep -q ^$1 $CONF; then echo 缺少配置项: $1 2 ((errors)) fi } check_contains listen_port check_contains log_level check_contains data_dir if [ $errors -gt 0 ]; then echo 配置检查失败共 $errors 项问题 2 exit 2 fi echo 配置检查通过注意这里没有用set -e因为grep找不到时返回非零是预期行为用set -e会直接退出。改成手动累加错误数最后统一判断。这种收集所有问题再报告的模式比遇到第一个错就退出对用户更友好——一次就能看到所有问题不用反复试。7. 调试与可维护性让脚本经得起时间考验7.1set -x与日志分级脚本出问题时最直接的调试手段是bash -x script.sh它会打印每条执行的命令。但在生产环境不能一直开着-x输出太吵。更好的做法是在脚本里做日志分级log() { local level$1; shift echo [$(date %Y-%m-%d %H:%M:%S)] [$level] $* 2 } log INFO 开始处理 log ERROR 文件不存在需要调试时把日志级别调成 DEBUG输出更多细节正常运行时只输出 INFO 以上。这样既保留了排查能力又不会污染正常输出。7.2 函数化与单一职责脚本超过 50 行就该考虑拆函数了。每个函数只做一件事名字能说清它干什么。比如parse_args、validate_input、process_file、cleanup。函数化的好处不只是可读性更重要的是可测试——你可以单独 source 脚本然后调用某个函数验证。函数里用local声明变量避免污染全局。返回值用return0-255需要返回字符串就用echo配合命令替换。这些约定能让脚本结构清晰很多。7.3 注释写为什么而不是是什么# 循环遍历文件这种注释毫无价值代码本身已经说明了。有价值的注释是解释为什么这么做为什么这里要加|| true为什么这个判断顺序不能变为什么用了某个不常见的参数。这些信息是代码本身无法表达的也是几个月后的你最需要看到的。我在实际项目里有个习惯每个脚本头部写一段用途、用法、依赖、作者、修改记录的注释块。看起来繁琐但当脚本被交接给别人时这段注释能省下大量沟通成本。8. 一些让我印象深刻的踩坑记录说几个我在实际写 Shell 时真真切切踩过的坑都是练习题里不会考、但工作中一定会遇到的。第一个坑是变量未加引号。rm -rf $DIR/*在$DIR为空时变成rm -rf /*这个教训太深刻了。从那以后我养成了习惯所有变量引用都加双引号$DIR、$FILE一个不漏。set -u也能帮忙但引号是最后一道防线。第二个坑是管道里的变量丢失。cat file | while read line; do count$((count1)); done; echo $count输出永远是 0因为管道会创建子 Shell循环里的变量修改不会影响父 Shell。解决办法是用进程替换while read line; do ...; done file或者用shopt -s lastpipe仅部分 Shell 支持。第三个坑是[ ]和[[ ]]混用。[ ]是 POSIX 标准[[ ]]是 Bash 扩展。[[ ]]支持正则、不需要引号、逻辑运算符更自然但不可移植。如果脚本声明了#!/bin/bash就大胆用[[ ]]如果要兼容sh就老老实实用[ ]并注意引号。第四个坑是命令替换的换行问题。files$(ls)会把换行变成空格文件名带空格就完蛋。正确做法是用数组mapfile -t files (ls)或者用find -print0配合xargs -0。处理文件名时永远假设它可能包含空格、换行、特殊字符。这些坑的共同点是它们不会让脚本报错而是让脚本悄悄地做错事。这比直接报错危险得多。所以写 Shell 脚本防御性思维比技术本身更重要。9. 练习题之外把脚本当代码来管理最后聊一个容易被忽略的点Shell 脚本也是代码应该纳入版本管理、代码审查、自动化测试的流程。我见过太多团队把脚本散落在各个服务器上改了就改了没有记录出了问题无从追溯。我的做法是所有脚本进 Git 仓库按功能分目录提交前跑一遍shellcheck它能揪出大量潜在问题未加引号的变量、无用的cat、错误的判断条件关键脚本写几个测试用例用bats这类测试框架跑一遍。这些投入在脚本数量少的时候看不出价值一旦脚本多了、依赖复杂了就是救命稻草。shellcheck特别值得单独说。它是个静态分析工具能发现[ $var x ]这种未加引号的写法、for f in $(ls)这种错误模式、以及各种微妙的语法陷阱。把它集成到编辑器和 CI 里能避免大量低级错误。我现在的习惯是脚本写完先过shellcheck它报的每一处都认真看哪怕觉得这没问题也想想它为什么这么提示——十有八九是我没考虑到的边界情况。Shell 这门语言入门容易精通难。它的难不在于语法复杂而在于细节太多、陷阱太隐蔽。练习题能帮你熟悉语法但真正的功力是在一次次踩坑、一次次重构中磨出来的。希望上面这些内容能让你在下次写脚本时少踩一个坑多一分从容。