你有没有经历过这样的下午:同一台机器,同一个目录,同样的几条命令,手动敲了三遍之后,手指已经形成了肌肉记忆。我最早开始正经写shell脚本,就是因为实在受不了这种重复劳动——明明可以让机器自己完成的事,为什么要让人一遍一遍地点。后来脚本越写越多,才发现这玩意儿不只是偷懒工具,更是把零散命令沉淀成可复用流程的最好方式。
这篇东西我不打算讲成一本正经的教程,更多是把自己这些年用shell脚本踩过的坑、总结出来的套路,以及几个可以直接拿去改改就用的实战脚本,一起摊开来说。内容覆盖从变量、条件判断、循环这些地基,到文件批处理、adb设备自动化、调试技巧这些实际场景。不管你是刚接触shell脚本入门的新手,还是已经写了几年脚本、偶尔还会被某个小坑绊倒的老手,应该都能在里面翻到点有用的东西。
1. 先说清楚:shell脚本到底是什么
1.1 命令是脚本的最小单元
shell脚本的本质,就是把一条一条的命令按顺序装进一个文件里,交给shell解释器去逐行执行。你在终端里能敲什么,脚本里就能写什么。唯一的不同是,终端里你每次敲完回车就结束了,脚本里还可以加判断、循环、变量,让命令根据不同的条件走不同的分支。
我见过不少人写脚本,其实就是在复制粘贴历史命令。这种做法不能说错,但很容易埋坑。比如你手动执行的时候,某条命令失败了,你可能看一眼输出就继续下一步了;但脚本跑起来没人盯着,一个失败很可能引发后面一串连锁反应。所以脚本思维的第一课,不是学语法,而是养成"每条命令都可能失败"的意识。
从日常场景来看,最能体现脚本价值的就是日志清理。
clean_logs() { find /var/log/myapp -name "*.log" -mtime +7 -delete echo "$(date '+%Y-%m-%d %H:%M:%S') 清理完成" >> /var/log/myapp/clean.log }这种活儿手动做一次没什么,但如果你有几十台服务器,或者需要每周固定执行,写成一个脚本挂上定时任务,节省的时间就是质变了。
1.2 开头的#!/bin/bash到底怎么起作用
每个shell脚本文件的开头几乎都会有一行#!/bin/bash,这行东西叫shebang。它的作用非常直接:告诉操作系统,执行这个文件的时候应该用哪个解释器来跑。
#!后面写/bin/bash,就是用bash;写/bin/sh,就是用sh。很多初学者搞不清sh和bash的区别,简单说,bash是sh的超集,功能更全。在绝大多数Linux发行版上,/bin/sh通常是指向bash或者dash的软链接,但dash为了追求轻量,砍掉了很多bash的便利特性,比如数组、[[ ]]这种高级判断。所以我的建议是,除非有明确的兼容性要求,否则一律写#!/bin/bash。
还有一点要注意:shebang只在两种情况生效——直接./script.sh执行,或者bash script.sh,或者sh script.sh。区别在于,./script.sh要求文件必须有执行权限,你需要先chmod +x script.sh;而bash script.sh不需要执行权限,因为你是显式用bash去读这个文件。我自己写脚本的习惯是,即使临时用bash xxx.sh跑,也照样给文件加执行权限并写好shebang,因为脚本大概率以后会被别的地方调用,提前准备好能少很多麻烦。
1.3 PATH和环境变量:别再让脚本报"command not found"
很多新手第一个劝退时刻,就是明明在终端里能用的命令,写进脚本就报command not found。这里十有八九是PATH的问题。
终端里能用,是因为你登录的时候shell已经加载了.bashrc或者.bash_profile,把一堆路径加进了PATH。但脚本执行的时候,如果用的解释器不是交互式登录shell,它不会加载那些配置文件,PATH里的路径就少了,某些装在自定义目录下的命令自然就找不到了。
解决思路有两种。一种是脚本开头显式设置PATH:
#!/bin/bash export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"另一种是直接用绝对路径调用,比如把python写成/usr/bin/python3。哪种更好没法一概而论。如果脚本会在多台机器上跑,机器间环境差异又比较大,我倾向于在脚本里先探测命令位置,再存成变量,后面所有调用都用变量。
PYTHON=$(command -v python3 || command -v python) echo "使用解释器: $PYTHON"command -v这个命令会告诉你某个命令在PATH里的完整路径,找不到就返回非零。这种写法在环境复杂的场景里特别实用。
2. 变量、判断和循环:脚本的骨架
2.1 变量赋值里那些让你怀疑人生的细节
shell的变量语法非常简单,变量名=值,但越简单的东西越容易出幺蛾子。最常见的一个错就是等号两边加空格:
name = "张三" # 错 name="张三" # 对为什么错?因为shell的规则是,命令名后面跟着参数,中间用空格隔开。name被当成命令名,=和"张三"是传给它的参数,自然就报command not found。这类问题初学阶段几乎人人踩过,好在这种错误报错很直接,一眼就能看出来。
变量的读取要用$符号,写成$name或者${name}。多数情况下两者没有区别,但我强烈建议养成用${name}的习惯。原因有两个:第一,拼接字符串时边界更清晰,${name}_suffix这样的写法你不可能看成别的;第二,后面会讲到一些特殊操作,比如${#name}取长度、${name:-default}设默认值,它们都必须带花括号,统一用花括号风格能避免某些上下文里的解析歧义。
还有单引号和双引号的区别。这是个老生常谈,但很多人依然会记反。简单记法:双引号里的$变量会被展开成变量的值,单引号则原封不动。比如:
name="张三" echo "你好,$name" # 输出:你好,张三 echo '你好,$name' # 输出:你好,$name如果你写过其他编程语言,可以把双引号理解为"会做表达式插值的字符串",单引号就是一个纯字面量。日常脚本里,我几乎全部用双引号,只有需要绝对字面输出时才用单引号。
2.2 if判断里的-n、-z、-f、-d到底是什么
shell里的if判断和主流编程语言很不一样,它本质上是在检查命令的退出码。if后面跟一个命令,命令执行成功(退出码为0)就走then分支。[ ]看着像语法,实际它是一个叫test的外部命令,只是写成了符号形式。
真正需要记住的是一批文件与字符串判断操作符:
| 操作符 | 含义 | 典型用法 |
|---|---|---|
-n | 字符串非空 | [ -n "$var" ] |
-z | 字符串为空 | [ -z "$var" ] |
-f | 是否是普通文件 | [ -f "$path" ] |
-d | 是否是目录 | [ -d "$path" ] |
-e | 路径是否存在 | [ -e "$path" ] |
-s | 文件存在且大小不为0 | [ -s "$file" ] |
热搜词里有人专门查shell脚本if判断的-n,我猜十有八九遇到过这个经典报错:[: unary operator expected。看这段代码:
if [ -n $var ]; then echo "有值" fi如果var是空,那$var展开后什么都没了,整条命令变成[ -n ],这其实是一个参数而不是两个,test命令就会报错。解决办法是给变量加双引号:
if [ -n "$var" ]; then echo "有值" fi加了引号,即使变量为空,[ -n "" ]也始终是两个参数,语法正确。我写过的每一个脚本里几乎都有这种带引号的判断,这个习惯能过滤掉一堆边际问题。
2.3 for循环、while循环与真实场景
循环是脚本里最常用的控制结构,尤其配合文件匹配符*的时候,干起批量活儿来非常顺手。
for file in /var/log/nginx/*.log; do echo "处理日志: $file" done这个循环会把每个匹配到的日志文件路径依次赋给file变量,每次循环执行一次循环体内的命令。需要注意,如果通配符什么都没匹配到,for file in /var/log/nginx/*.log会把字面量字符串/var/log/nginx/*.log作为唯一的一次循环,而不是直接跳过。我建议在循环里先判断文件是否存在:
for file in /var/log/nginx/*.log; do if [ -f "$file" ]; then echo "处理日志: $file" fi done另一种常见写法是C风格的数字循环:
for ((i=1; i<=10; i++)); do echo "第 $i 次" done注意C风格循环的变量i前面不加$,这是它和普通for循环比较大的差异。
while read则是我处理文件内容的首选方式:
while IFS= read -r line; do echo "读取到: $line" done < /etc/hostsIFS=表示不在读行内容时切分字段,-r防止反斜杠被转义。处理包含空格的路径、带特殊字符的文本时,这个写法是最稳的。
2.4 位置参数$@、$#和shift命令
脚本可以接收外部传入参数,比如./script.sh param1 param2,这些参数在脚本里对应$1、$2。$#是参数个数,$@是所有参数拼成一个列表,$*也是所有参数,但会被当成一个整体字符串,两者在处理细节上有差异,多数情况用$@。
shift命令的作用是把参数列表整体左移一位,原本的$2变成$1,$1被丢弃。它在需要逐个消费参数的场景里很好用:
while [ $# -gt 0 ]; do case "$1" in --verbose) echo "开启详细输出" ;; --file) shift FILE="$1" ;; *) echo "未知参数: $1" exit 1 ;; esac shift done这段代码放在脚本开头,就能解析出类似--file xxx这样的命令行选项。shift配合case写出来的参数解析器虽然简陋,但绝大多数普通脚本都够用,也不用额外引入getopt库。
3. 文件操作和文本批处理:让脚本干重活
3.1 用shell批量重命名文件的正确姿势
Linux下批量重命名是个高频需求。有人专门搜linux用shell重命名文件,说明这类场景确实多。最简单粗暴的姿势就是mv命令配合for循环。
比如我要把所有.txt文件改成.md:
for f in *.txt; do mv "$f" "${f%.txt}.md" done${f%.txt}是变量删除后缀的用法,%从右侧匹配最短删除,把.txt剥掉再拼上.md,就完成了改名。这里的关键是mv那句必须给两个参数都加引号,否则文件名里只要出现空格,命令就会被拆开,轻则改名错位,重则文件丢失。
再举个例子,去掉文件名里的空格:
for f in *" "*; do mv "$f" "${f// /_}" done${f// /_}是变量替换,把所有空格换成下划线。这种操作在清理从Windows拷贝过来的文件时,几乎每次都会用到。
如果你需要按时间戳给文件改名,可以这样:
for f in *.log; do ts=$(date -r "$f" +%Y%m%d_%H%M%S) mv "$f" "${f%.log}_${ts}.log" donedate -r读取文件的修改时间,再格式化成想要的字符串拼进新文件名里。
3.2 find、xargs和管道的黄金组合
find加上xargs是文件批处理的黄金搭档。常见需求是找出几天前的临时文件并删除:
find /tmp -name "*.tmp" -mtime +7 -deletefind自己的-delete参数很省事,但如果你想在删除前预览一下,就先不加-delete,改成管道传给xargs:
find /tmp -name "*.tmp" -mtime +7 | xargs ls -l不过这里有个坑:文件名里有空格时,管道传过去的字符串会被拆成两个参数。遇到这种情况,最好的方案是find的-print0配合xargs -0,这两者约定用空字符而不是换行符分隔文件名,空格就安全了:
find /tmp -name "*.tmp" -mtime +7 -print0 | xargs -0 rm -f我见过很多人在生产环境因为忽略了这个细节,误删了包含空格的文件。文件名里有空格其实很常见,从网上下载的压缩包解压出来偶尔就会碰到几个,所以这个姿势得记住。
3.3 脚本里调用mysql和python的实用写法
shell脚本经常需要和其他程序配合。比如自动化测试或数据修脚本,需要往MySQL里写点数据,很多人第一反应是装个客户端库,其实直接调mysql命令行就够了:
mysql -u root -p'密码没写注释里' -e "SELECT * FROM users LIMIT 5;"但更常见的是执行一个SQL文件:
mysql -u root -p密码 dbname < /path/to/init.sql这种写法适合导入初始数据或执行批量schema变更。运行时我会加--default-character-set=utf8mb4,避免中文乱码,顺带把错误输出重定向到日志文件里,方便排查。
至于和Python等脚本联动,核心在于检查退出码。比如:
python3 /opt/scripts/data_process.py if [ $? -ne 0 ]; then echo "数据处理器失败,需要人工介入" exit 1 fi$?是上一条命令的退出码,0代表成功,非0代表异常。只要调用外部程序,我几乎都会检查退出码。别怕脚本因此变得啰嗦,这种啰嗦在出问题的时候是救命稻草。
4. 实战:设备老化测试全自动执行脚本
4.1 先拆需求:自动化测试脚本到底要做什么
热词里有个"设备老化测试全自动执行脚本",这个场景我写过不少,这里完整展开一次。所谓老化测试,通俗说就是把设备长时间、高强度地跑一些操作,观察它会不会死机、变卡、掉数据。过去全靠测试人员手动点屏幕,点两个小时人都麻了,写个脚本自动跑就成了刚需。
需求拆开其实就三块:
- 准备阶段:把测试脚本推送到设备上,可能要禁掉一些系统应用,避免打扰测试。
- 执行阶段:反复触发某一项或多项操作,比如打开应用、滑动屏幕、杀掉进程再打开。
- 数据收集阶段:记录设备温度、内存占用、各应用的CPU使用率,把数据拉回电脑上留着分析。
准备阶段涉及一个高频命令:adb shell pm uninstall --user 0。很多老设备上你会看到--user 0这个尾缀,其中0代表系统主用户。这个命令的作用是,在不root的情况下彻底卸载当前用户安装的某些预装应用,测试前把可能有弹窗、更新的应用清掉,能避免很多不可控因素。
4.2 完整脚本:一次跑的参考实现
下面的脚本是我简化过的版本,思路是设备解锁后,自动打开某个应用,滑动屏幕然后关掉它,循环N次,每次记录数据。特意保留了注释和日志输出。
#!/bin/bash # 设备老化测试循环执行脚本 # 用法: ./aging_test.sh [循环次数] [设备序列号] COUNT=${1:-100} DEVICE=${2:-} LOG_DIR="/var/log/aging_test" mkdir -p "$LOG_DIR" # 用数组维护要记录的关键指标 METRICS=(temperature memory_cpu app_cpu) log_to_pc() { echo "$(date '+%Y-%m-%d %H:%M:%S') | $*" >> "$LOG_DIR/aging.log" } adb_cmd() { if [ -n "$DEVICE" ]; then adb -s "$DEVICE" "$@" else adb "$@" fi } # 准备阶段:确保屏幕点亮,解锁 adb_cmd shell input keyevent KEYCODE_WAKEUP adb_cmd shell wm dismiss-keyguard for ((i=1; i<=COUNT; i++)); do log_to_pc "第 $i 轮开始" # 启动被测应用(包名按需替换) adb_cmd shell am start -n com.example.app/.MainActivity sleep 3 # 模拟用户滑动操作 adb_cmd shell input swipe 540 1500 540 300 300 sleep 2 adb_cmd shell input tap 500 900 sleep 1 # 采集设备温度与内存信息 TEMP=$(adb_cmd shell dumpsys battery | grep temperature | awk '{print $2}') MEM=$(adb_cmd shell cat /proc/meminfo | grep MemFree | awk '{print $2}') log_to_pc "温度: $TEMP, 可用内存: $MEM" # 结束应用,进入下一轮 adb_cmd shell am force-stop com.example.app sleep 2 done log_to_pc "全部 $COUNT 轮测试执行完毕"4.3 脚本关键点逐段拆解
这段脚本有几个地方值得详细说。
文件开头用LOG_DIR定义了日志目录,用mkdir -p创建。不管脚本在什么状态下被调用,目录肯定存在。紧接着用数组METRICS存指标名,虽然这个版本还没实际使用它,但提前定义好一个"我关心哪些数据"的清单,后面扩展时直接往数组里加项目就行,不用改动主逻辑。
adb_cmd函数是这段脚本最重要的一层封装。它接收任意数量参数,前面自动拼接adb -s $DEVICE。如果只测一台设备,DEVICE留空也没关系;如果多台设备同时插在电脑上,adb就会要求你显式指定序列号,这个封装让所有命令都能走同一个入口,避免每次命令都要判断"要不要加-s参数"。
循环体内的am start、input swipe、input tap、am force-stop,都是在通过adb模拟用户操作。am start -n后面跟的是包名加Activity名,force-stop则是强杀进程。你直接拿这套框架改包名,就能适配绝大多数App的压力测试场景。
数据采集部分用的是dumpsys battery和/proc/meminfo,这是两台设备上只要Android系统还活着就一定能读到的信息。awk '{print $2}'从输出中抽取温度和内存数值。
一个比较隐蔽的细节是adb_cmd shell dumpsys battery | grep temperature——这里adb_cmd函数的输出通过管道传给了grep,但函数内部的$@参数拼接并不会被管道拆分,因为命令拼接发生在函数内部。如果你是在脚本里直接把adb shell ...整个丢进管道,也没有问题,adb命令的stdout正常输出才会被grep到。反而是很多新手会在函数体里写return某个值,以为可以像编程语言一样把返回值赋给变量,这就要提醒一下:shell函数里随便写return只影响退出码,不会输出值。想要"函数返回字符串",就把内容通过echo打出来,然后用$(func)捕获,如果你看我这版脚本的TEMP和MEM写法,就是在用这个逻辑。
4.4 多台设备并行跑:for循环加adb devices
只有一台设备,上面的脚本已经够用。但要同时对三台设备跑老化测试,就得先拿到设备序列号列表,再逐个启动脚本。
# 获取所有已连接设备的序列号 mapfile -t DEVICES < <(adb devices | awk 'NR>1 && $2=="device" {print $1}') for dev in "${DEVICES[@]}"; do echo "开始为设备 $dev 启动老化测试" ./aging_test.sh 20 "$dev" & done wait echo "所有设备进程已结束"这里有两个功力点。第一个是< <(...)这种进程替换,它的作用是,把括号里命令的输出作为文件输入给mapfile读取,每一行存成数组的一个元素。第二个是循环体最后那个&,它让脚本在后台运行不占用当前终端;wait等待所有后台进程结束。
用这种方式扩展,三台设备和三十台设备的成本差不了多少。当然,真到了几十台的规模,建议还是引入专门的设备管理平台,但脚本层面这种"for循环+后台任务"的思路,小规模场景已经非常够用。
5. shell脚本常见坑与排查技巧
5.1 动不动就"unexpected EOF"或"command not found"?先检查换行符
我遇到过最隐蔽的坑,就是从Windows机器上写好脚本,用U盘拷到Linux服务器,一执行就报诡异的语法错误。罪魁祸首是Windows记事本默认使用CRLF(回车加换行)作为行结束符,而Linux只认LF(换行)。
这就导致bash读到每一行的末尾时,会看到一个多出来的\r字符,经常出现在if或者函数定义的结束位置,于是报各种莫名其妙的unexpected token和command not found。解决方式很简单,把脚本重新转成Unix换行:
sed -i 's/\r$//' script.shdos2unix script.sh也可以。我自己的习惯是,脚本一律在Linux环境下编辑,使用Vim或者VS Code Remote,从源头规避这个坑。如果你必须处理Windows传过来的脚本,那么每次执行前先转一遍换行符,别偷懒。顺带说一句,缩进的时候建议全部用空格,统一4个或者2个都行,千万别一会儿空格一会儿Tab,虽然bash对缩进本身不敏感,但视觉排查时很容易被这种不一致误导。
5.2 通配符不是正则,别搞混
很多人刚接触shell的时候,最喜欢用*去匹配字符,然后发现*好像并不能代表"任意字符"的所有情况。原因是,在shell文件名匹配场景里,*只匹配文件名的一部分;而在grep、sed这些工具支持的所谓正则表达式里,*表示"前一个字符出现0次或多次"。
举一个直观的对比。如果你想要找出文件里所有以"log"开头的行,写成:
grep '^log' server.log这里^是正则的"行开头"锚点。但如果你在shell里直接写:
ls ^log*shell虽然很宽容,不会报错,但它的匹配逻辑会把^当成普通字符的一部分,这其实不是你想表达的语义。真正用到正则的地方比如grep、awk、sed,就需要按正则语法写;用到文件名匹配的地方比如ls、for file in,它接受的是glob模式。
判断自己到底在写哪种模式,就一句话:这个模式是由谁来解释的。由shell解释,就是glob;由grep/sed/awk解释,就是正则。搞清这一点,能避免非常多看似"玄学"的问题。
5.3 管道引发的子shell变量丢失
看这段经典代码:
echo "hello world" | read first second echo "$first $second" # 输出两个空行很多人以为read会读取管道输入并赋值给两个变量,结果执行完first和second还是空的。原因就是,管道右边的命令是在一个子shell里执行的,子shell里的变量赋值不会影响到父shell。
解决这个问题,主流有三种方式。
方式一是用进程替换,让read直接在父shell环境里运行:
read first second < <(echo "hello world") echo "$first $second"方式二是用heredoc配合重定向:
read first second <<< "hello world" echo "$first $second"方式三是老老实实把需要的数据先算好存进文件,再用$(cat <file)读取。不过最省心的还是前两种。凡是涉及"管道右边要赋值给变量并且后面还要用",我都默认使用进程替换,避开子shell这个坑。
5.4 cd在脚本里的作用域:这个坑让多少人白熬一宿
如果你在终端里执行cd /tmp,当前目录会切换过去;但如果是在脚本里执行cd /tmp,脚本结束后你的终端目录不会有任何变化。这是因为脚本本身就是在一个子shell进程中执行的,子shell里的cd只影响子shell自己。
这个特性的坑在于,脚本中途执行cd之后,后续命令都是在那个目录下运行的,一旦执行到某个地方因为某条命令失败而提前退出,脚本不会自动帮你回到原始目录。如果在脚本最后还有一个删除操作,极其容易辩错路径,删掉不该删的东西。
我的习惯是脚本开头先记录初始目录:
ORIG_DIR=$(pwd)任何可能改变目录的地方,都用子shell包裹:
( cd /path/to/target && ./run.sh )( )子shell里执行cd,退出括号就自动回到原目录。或者用pushd和popd成对出现。写脚本时养成"目录变化必须明确开始和结束"的意识,能少掉很多因为路径漂移引发的低级事故。
6. 调试三板斧:让脚本出问题时不再抓瞎
6.1 bash -n和bash -x,一个查语法,一个看过程
脚本写完后第一件事,不是直接执行,而是先做语法检查:
bash -n script.sh这个命令只检查语法不执行,如果有拼写错误、括号不配对,它会直接报出来。语法检查通过后,再想看清楚脚本到底按什么顺序执行了哪些命令,用:
bash -x script.sh执行时,每条被执行到的命令都会先打印到屏幕上,前面带一个+号。比如for循环展开后每一次迭代实际上执行了什么命令、变量的值是什么,全部一目了然。这个方法在我排查复杂的嵌套函数时几乎是救命的。
如果脚本实在太长,全程bash -x输出量太大,也可以只在可疑区域局部打开跟踪:
set -x # 这里是怀疑出问题的代码段 set +x6.2 set -eu让脚本更早暴露问题
set -e的意思是,一旦某条命令返回非零退出码,脚本立刻终止,不再往后执行。没有这个设置,脚本可能会在错误的基础上继续跑很多步,最后的输出让人根本看不懂哪里出了问题。
不过set -e也有它的"脾气"。如果命令出现在if条件、while条件、until条件里,或者放在&&、||的左边,它即使失败了,也不会触发退出。因为shell认为"这个命令的失败可能正是条件判断想要的结果"。很多人在脚本里写了set -e,又用cmd | grep xxx这种管道命令,就发现set -e有时候灵有时候不灵,其实是这个原因。
再配合set -u,它让脚本在引用未定义变量时直接报错退出,而不是默默把空字符串当成值继续跑。未定义变量很多时候意味着拼写错误或环境变量没设置好,早暴露才有机会早止损。我习惯在脚本开头写:
set -euo pipefailpipefail的意思是,管道命令的退出码取所有命令里最大的那个失败码,而不是最后一个。比如adb shell xxx | grep error,如果没有pipefail,grep失败了你根本不知道,因为管道的退出码只看grep。这个组合我几乎每个脚本都用,属于习惯性配置了。
6.3 日志输出和$?的正确使用习惯
排查脚本问题时,日志是我第一依赖。我很少依赖终端里那几行输出,而是所有关键动作都写进日志文件,带上时间戳。
log_info() { echo "$(date '+%Y-%m-%d %H:%M:%S') [INFO] $*" >> "$LOG_FILE" } log_error() { echo "$(date '+%Y-%m-%d %H:%M:%S') [ERROR] $*" >> "$LOG_FILE" }引用$?也有讲究。$?只保存上一条命令的退出码,一旦你又执行了别的命令,它就变了。所以在需要先保存退出码的场景,要立刻赋值:
rsync -av "$SRC" "$DST" rsync_code=$? if [ $rsync_code -ne 0 ]; then log_error "同步失败,rsync退出码为 $rsync_code" else log_info "同步完成" fi如果中间插了一行echo "同步完成"再用$?,拿到的就是echo的退出码了,等于白查。
另外一个非常容易忽略的细节,是脚本里执行外部脚本或者函数时,一定要关注它的退出码。bash -c、source、./another.sh,它们本质上都是命令,都有可能失败。如果脚本A调用脚本B,B失败了A却继续走,那最终的结果往往非常诡异。这句话是我调过很多次踩出来的经验:外部程序的退出码,是脚本之间协作最重要的暗号。
写到最后想起一开始的场景——那个手动敲命令敲到手指麻木的下午。后来我写出的第一个脚本只有七行,作用是自动备份某个目录到压缩包并清理三天前的旧备份。就是这七行东西,让我第一次感受到把"人做的事"交给"机器做的事"有多爽。往后几乎每个项目里,我都会先花半小时把手动流程捋一遍,写成脚本,再考虑下一步。
这份经验里最想让你带走的一点是:shell脚本学起来绝对不难,但真正让你和别人拉开差距的,不是你背了多少语法,而是你在写每一行命令之前,有没有把"失败会怎样"这种问题想清楚。逃过那些坑,脚本越写越顺手;踩了那些坑,才明白坑在哪。下一篇如果有机会,我会专门讲讲和定时任务、日志轮转相关的一些shell陷阱,那个领域里也有不少有意思的东西。
不过在下一篇之前,你可以先把今天这份脚本拿到手边的机器上跑一遍,改一改参数,试试set -x调试的感觉。相信我,动手比看十篇教程都管用。