ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Shell脚本实战指南:从基础语法到自动化测试与避坑技巧

Shell脚本实战指南:从基础语法到自动化测试与避坑技巧 你有没有经历过这样的下午同一台机器同一个目录同样的几条命令手动敲了三遍之后手指已经形成了肌肉记忆。我最早开始正经写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 使用解释器: $PYTHONcommand -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 ((i1; i10; 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文件改成.mdfor 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改成管道传给xargsfind /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-setutf8mb4避免中文乱码顺带把错误输出重定向到日志文件里方便排查。至于和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 ((i1; iCOUNT; 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 NR1 $2device {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如果没有pipefailgrep失败了你根本不知道因为管道的退出码只看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调用脚本BB失败了A却继续走那最终的结果往往非常诡异。这句话是我调过很多次踩出来的经验外部程序的退出码是脚本之间协作最重要的暗号。写到最后想起一开始的场景——那个手动敲命令敲到手指麻木的下午。后来我写出的第一个脚本只有七行作用是自动备份某个目录到压缩包并清理三天前的旧备份。就是这七行东西让我第一次感受到把人做的事交给机器做的事有多爽。往后几乎每个项目里我都会先花半小时把手动流程捋一遍写成脚本再考虑下一步。这份经验里最想让你带走的一点是shell脚本学起来绝对不难但真正让你和别人拉开差距的不是你背了多少语法而是你在写每一行命令之前有没有把失败会怎样这种问题想清楚。逃过那些坑脚本越写越顺手踩了那些坑才明白坑在哪。下一篇如果有机会我会专门讲讲和定时任务、日志轮转相关的一些shell陷阱那个领域里也有不少有意思的东西。不过在下一篇之前你可以先把今天这份脚本拿到手边的机器上跑一遍改一改参数试试set -x调试的感觉。相信我动手比看十篇教程都管用。
返回列表