写Shell脚本的人基本都绕不开条件测试语句。不管你是想在脚本里判断文件存不存在还是检查用户输入是不是合法又或者根据系统负载决定要不要告警最终都得落到“测试一个条件成不成立”这个动作上。很多初学者抄别人的脚本时能看到if [ -f /etc/passwd ]; then这种写法换个场景就不知道怎么修了问题往往就出在没理解条件测试和分支结构的底层配合逻辑。这篇文章就结合我日常写脚本的实践经验把条件测试语句的用法、常见误区以及 if、case 分支结构的组织方式从头到尾梳理一遍。适合刚接触 Shell 的入门者也适合写了不少脚本但总被“测试表达式报错”折磨的开发者。内容会尽量带上可以复制的示例而不是空谈概念。1. 条件测试语句的前置认知脚本中的“判断题”1.1 为什么Shell脚本需要条件测试脚本编程和普通编程一样核心是“根据不同的输入或状态执行不同的逻辑”。没有条件测试Shell 脚本只能是一条道走到黑从上到下把命令跑完中间没法判断文件是否存在、没法对用户输入做校验、没法根据返回值选择下一步。在真实场景里这种“顺序执行”根本不够用。比如部署脚本要检查 Java 环境有没有装好没有条件测试你只能让脚本直接跑然后等报错有了条件测试就能在跑之前先探路给出更友好的提示。又比如定时备份脚本要判断磁盘空间是否充足空间不够就直接发告警而不再执行复制避免把磁盘写满。这种“先判断、再行动”的思路就是条件测试的本质。再说直白一点条件测试在 Shell 脚本里的作用就是制造“岔路口”。分支结构是路口的指示牌测试表达式是路口的传感器。两者得配合起来脚本才能变成真正的自动化程序而不是一串无脑执行的命令清单。1.2 三种常见的测试写法test、[ ]、[[ ]]Shell 里测试条件通常有三种写法test 表达式、[ 表达式 ]、以及[[ 表达式 ]]。前两种本质上是一个命令。test命令有个别名形式就是[所以[的右边和末尾都必须有空格因为[本身是个命令参数要分隔开。新手最常见的错误就是写if [$1 a]少了空格Shell 会把[$1整体当成命令名然后直接报 “command not found”。第三种[[ ]]是 bash、zsh 等更高阶 Shell 的内置关键字不是外部命令。它功能更强支持正则表达式匹配、支持和||直接写进方括号里而且对变量赋值形式的宽容度更高变量没加引号通常也不会报错。但代价是[[ ]]不是 POSIX 标准语法在sh环境下可能没有这个关键字。三种写法的对比我用下表做个速览写法标准支持逻辑运算符变量引号要求适用场景test $x aPOSIX-a、-o或外部建议加引号通用 sh 脚本[ $x a ]POSIX-a、-o或外部建议加引号通用 sh 脚本[[ $x a ]]bash 等、||、!直接支持可以不加bash 脚本中推荐偏方我的建议很简单如果写的是以#!/bin/bash开头的脚本直接优先用[[ ]]省心省事如果写的是要兼容/bin/sh的脚本别偷懒老老实实用[ ]并且把变量都加上双引号。这不是“二选一”的问题而是“看环境做决定”的问题。2. 字符串、数值与文件测试覆盖九成判断需求2.1 字符串比较的细节与坑字符串比较常用的操作符有、!、-z、-n。其中-z表示“长度为零”-n表示“长度非零”。这几个操作符看起来简单实际用起来坑很多。第一个坑是等号前后必须加空格。if [ $a b ]是判断if [ $a b]或if [ $ab ]都会被当成单个参数处理结果要么报错要么判断结果错误。第二个坑是变量一定要加双引号。在[ ]写法中如果变量是空字符串不加引号展开后[ b ]这种表达式就会少一个参数直接报 “unary operator expected”。加了引号变成[ b ]参数数量正常判断结果也正确。在[[ ]]中字符串比较还有一个隐藏能力右侧可以用通配符。比如if [[ $name root* ]]; then echo 用户以 root 开头 fi这种写法在[ ]里是不支持的。$name如果是 rootroot*模式会匹配成功。这在处理一堆相似前缀的输入时非常方便。还要注意字符串比较不要用、直接比较字典序尤其在[ ]里会被识别成重定向符号导致生成一个奇怪的文件。真要比较字符串排序建议用[[ $a $b ]]或者用排序工具配合判断。2.2 数值比较与整数运算的区别数值判断的常用操作符是-eq、-ne、-lt、-le、-gt、-ge分别对应等于、不等于、小于、小于等于、大于、大于等于。初学者容易踩的坑是直接用和判断数字大小比如if [ $NUM 5 ]; then这条命令在语法上没有立刻报错但被当成输出重定向实际上是判断$NUM是否为空然后重定向到名为5的文件。这种 bug 非常隐蔽脚本跑完不知不觉多出一个文件。所以在[ ]中必须写成-gt等操作符。在 bash 环境中除了用[[ $NUM -gt 5 ]]还可以用双括号(( ))做算术判断if (( NUM 5 )); then(( ))里变量名可以不加$并且支持、-、*、/、%这些运算适合做复杂数值判断。比如判断一个数是否在区间内if (( n 10 n 20 )); then echo n 在 10 到 20 之间 fi唯一要注意的是-eq和(( ))都只支持整数不支持小数。如果需要比较浮点数就别硬用 Shell 的数值操作符了直接用 awk 来处理更省心。比如awk -v v1$value -v v2$threshold BEGIN { exit !(v1 v2) }这句能从 awk 的退出码拿到比较结果配合 if 用完全没压力。2.3 文件属性测试-f、-d、-x、-s文件测试是 Shell 脚本里的高频操作。日常用得最多的操作符如下操作符判断目标示例-e文件或目录是否存在[ -e /etc/hosts ]-f是否为普通文件[ -f /etc/hosts ]-d是否为目录[ -d /tmp ]-r当前用户是否可读[ -r /etc/shadow ]-w当前用户是否可写[ -w /tmp ]-x当前用户是否可执行[ -x /usr/bin/curl ]-s文件是否存在且非空[ -s /var/log/messages ]-L是否为符号链接[ -L /usr/bin/python ]-nt文件是否比另一个新[ file1 -nt file2 ]-ot文件是否比另一个旧[ file1 -ot file2 ]文件测试最常见的问题有两个。第一被测试的路径变量可能为空。比如从配置里读了一个路径变量结果配置没写变量为空[ -f $path ]就会变成[ -f ]返回假后面逻辑直接跑偏。第二-s这个操作符的含义是“非空文件”不是“文件大小不为零字节”那么直观很多人以为是“文件大小可读”实际上它经常用来判断日志文件是否真的写了内容。还有一个技巧判断文件是否存在时-e不如-f常用。因为很多时候你不只想知道“有没有”还想知道“是不是普通文件”。比如目录也存在用-e会通过但用-f可以排除掉目录。脚本里执行某个程序前最好用-x判断可执行权限否则直接调用可能撞上 “Permission denied”。3. 分支结构的落地姿势if、elif、else与case3.1 if语句的语法骨架与缩进习惯if 语句的完整骨架是if 条件测试; then 分支命令1 elif 条件测试; then 分支命令2 else 默认分支 fi注意两个语法细节。第一if和条件测试之间必须有空格。第二then前面需要分号因为then本质是与前面测试命令分隔开的另一个保留字。很多人写成if [ $a b ] thenShell 会把] then当成命令解析然后报语法错误。如果不想写分号可以这样if [ $a b ] then 分支命令 fi这种写成两行的格式在多行命令的脚本里可读性更好但要注意缩进。缩进不是语法要求但绝对不能省。我见过不少脚本判断逻辑没写错就因为缩进乱七八糟最后连作者自己都分不清哪个分支对应哪个条件。建议固定使用两个空格或四个空格缩进全脚本保持一致。if 分支里面如果再套 if就是嵌套结构。嵌套超过三层以后可读性急剧下降。我的经验是与其多层嵌套不如用“快速返回”或“提前退出”减少层级。比如if [ ! -f $conf_file ]; then echo 配置文件不存在退出 exit 1 fi这种守门员式的写法把异常情况在入口处直接挡掉能让主流程保持平铺避免嵌套过深。3.2 case语句处理多分支匹配case 适合做“多选一”的分支判断。语法如下case $action in start) echo 启动服务 ;; stop) echo 停止服务 ;; restart) echo 重启服务 ;; *) echo 未知操作 exit 1 ;; esac需要注意的三点第一case 匹配的单词或变量要加双引号避免空变量引起的语法问题。第二每个分支最后需要两个分号;;漏掉的话Shell 会继续往下跑导致多个分支都执行。第三*是通配符分支相当于默认分支最好放在最后。case 的模式匹配非常灵活除了*还支持?、[...]这些通配符。比如case $flag in --*) echo 长选项 ;; -?) echo 短选项 ;; esac这个例子能对脚本参数进行分类。case 和 if 的取舍很简单当你要判断的变量值就那么几种枚举情况比如 start/stop/restart用 case 比一堆 elif 清晰得多当条件里有逻辑组合、数值比较、文件测试混在一起时用 if 更自然。case 不能直接判断文件是否存在它只能匹配字符串。3.3 分层判断的小案例部署脚本中的环境检测光讲语法没意思写一个部署场景的模拟脚本把 if 和条件测试组合起来看效果。假设要部署一个 Java 服务脚本需要先检查 JAVA_HOME 是否配置、配置文件目录是否存在、磁盘剩余空间是否够用。#!/bin/bash JAVA_HOME_DIR${JAVA_HOME:-/usr/local/java} if [ ! -d $JAVA_HOME_DIR ]; then echo Java 环境目录不存在: $JAVA_HOME_DIR exit 1 fi if [ ! -f $JAVA_HOME_DIR/bin/java ]; then echo 未找到 java 可执行文件 exit 1 fi APP_CONF_DIR/opt/myapp/conf if [ -d $APP_CONF_DIR ]; then echo 配置目录已存在保留现有配置 else echo 配置目录不存在自动创建 mkdir -p $APP_CONF_DIR if [ $? -ne 0 ]; then echo 创建配置目录失败 exit 1 fi fi AVAIL_KB$(df -P /opt | awk NR2 {print $4}) if [ $AVAIL_KB -lt 1048576 ]; then echo 磁盘剩余空间不足 1GB停止部署 exit 1 fi echo 环境检查通过开始部署...这个脚本里出现了多个条件测试-d、-f、$? -ne 0、数值比较。这里$?是上一条命令的退出码0代表成功非零代表失败。用if [ $? -ne 0 ]判断 mkdir 是否成功是 Shell 脚本里的经典操作。不过要注意$?用过一次之后就会被新的命令覆盖想要保存它的值必须在第一时间放进变量里否则再执行一条 echo 就变成 echo 的退出码了。4. 测试与分支的实战场景从判断到决策4.1 用条件测试做参数校验脚本接收外部参数时第一道防线就是校验参数数量和内容。没有参数校验的脚本用户少传一个参数后面所有命令可能都会因为“空值”而跑出莫名其妙的结果。所以参数校验几乎是每个正式脚本的标配。#!/bin/bash if [ $# -lt 2 ]; then echo 用法: $0 用户名 操作 exit 1 fi user$1 action$2 case $action in create|add) echo 创建用户 $user ;; delete|remove) echo 删除用户 $user ;; *) echo 不支持的操作: $action exit 1 ;; esac这里$#是参数个数。[ $# -lt 2 ]判断参数是否少于两个。注意变量$user和$action需要先赋值再使用避免后面写得很长时还要瞪大眼睛分辨$1和$2谁是谁。给参数起一个好读的变量名是脚本可维护性的关键。参数校验还有一个容易被忽略的点参数的“空值”和“未传参”不完全一样。比如用户传了user$#依然是 2但变量内容是空的。这种情况下就需要再补一个判断if [ -z $user ]; then echo 用户名不能为空 exit 1 fi-z专门测空字符串和$#的检测互为补充。两个判断合起来才算把入口守住。4.2 组合条件与逻辑运算符的优先级真实脚本里很少只判断一个条件大多是“文件存在且可写”或“用户输入为 root 或管理员”这种组合条件。逻辑运算符包括且、||或、!取反。在[ ]里组合条件是靠-a、-o实现的比如if [ -d $DIR -a -w $DIR ]; then但-a、-o容易与环境变量展开混淆而且它俩的优先级不是特别直观现在主流建议是不要用改用两个括号外连接if [ -d $DIR ] [ -w $DIR ]; then这样写每个测试都是独立的语义清晰避免了[ $a -o $b ]里的歧义。在[[ ]]里则可以直接写if [[ -d $DIR -w $DIR ]]; then优先级方面!最高高于||。比如if [[ $cond1 $cond2 || $cond3 ]]; then等价于$cond1 $cond2整体作为一个结果再和$cond3做“或”。如果你想要“$cond1 ( $cond2 || $cond3 )”这种组合必须在[[ ]]里加括号if [[ $cond1 ( $cond2 || $cond3 ) ]]; then注意在[ ]里加括号必须转义写起来很痛苦所以我通常会把括号分组的需求直接丢给[[ ]]或者拆成多个 if 嵌套保证别人读代码时不用费劲推断优先级。还有一个心法复杂条件宁可写成两步也不要写成一长串。比如dir_ok0 if [ -d $DIR ]; then if [ -w $DIR ]; then dir_ok1 fi fi这样每个条件都清清楚楚逻辑一目了然。写脚本的首要目标不是让别人叫我“大神”而是过两个月自己还能看懂。4.3 一个完整的示例脚本服务健康检查把前面的知识点拼起来下面模拟一个 HTTP 服务的健康检查脚本。它会请求服务的一个健康检查接口如果返回码不是 200就重试重试多次仍失败就尝试重启服务超过重启次数就发告警。#!/bin/bash URLhttp://127.0.0.1:8080/healthz RETRY_LIMIT3 RESTART_LIMIT2 check_health() { local code code$(curl -s -o /dev/null -w %{http_code} --connect-timeout 5 $URL) if [ $code 200 ]; then return 0 else return 1 fi } retry0 while [ $retry -lt $RETRY_LIMIT ]; do retry$((retry 1)) if check_health; then echo 服务健康检查通过 exit 0 fi echo 第 $retry 次检查失败等待后重试 sleep 3 done echo 健康检查连续失败尝试重启服务 restart_count0 while [ $restart_count -lt $RESTART_LIMIT ]; do restart_count$((restart_count 1)) systemctl restart myapp 2/dev/null sleep 5 if check_health; then echo 服务重启后恢复 exit 0 fi done echo 服务重启多次仍未恢复需要人工介入 exit 1这里面最有意思的是if check_health; then。条件测试不一定要用方括号函数本身的退出码也能作为 if 的判断条件。因为check_health成功时返回 0失败时返回 1天然满足 if 的期待。这一点掌握之后很多脚本能简化不少。循环里的$((retry 1))是算术展开用来计数。整个脚本把条件测试、函数、循环、分支融合在一起是典型的运维监控脚本模式。5. 常见问题与排查技巧实录5.1 [ ] 与 [[ ]] 的差异化场景写了这么久脚本我的结论是两个都重要但要分场景。如果你要发布的脚本需要在多种 Unix 环境里跑比如某些精简版容器镜像里只有sh就必须用[ ]还要老老实实给变量加引号。如果你确定运行环境是 bash比如 CI 流水线里明确指定了 bash 执行器那就用[[ ]]代码更简洁不容易出空变量问题。有一个例外是[[ ]]对字符串模式的匹配支持很诱人很多人为了使用加通配符或正则~会刻意切到 bash 运行。这是合理的。比如判断 IP 地址格式if [[ $ip ~ ^[0-9]\.[0-9]\.[0-9]\.[0-9]$ ]]; then echo IP 格式合法 fi这种正则匹配逻辑在[ ]里是做不到的只能借助grep外部命令再判断退出码。所以遇到模式匹配需求直接选 bash 环境更现实。5.2 空格、引号与变量为空导致的坑我排过的脚本问题里排名第一的原因就是空格和引号。总结成一句话[、]、运算符、变量之间通通要有空格变量内容可能为空的场景通通要加双引号。写一个典型错误现场name if [ $name root ]; then echo 是 root fi这段代码执行时$name展开为空实际变成[ root ]Shell 会报错。改成[ $name root ]之后实际变成[ root ]两个参数都在判断正常返回假不会报错。这就是加引号的价值。还有一种坑在 case 模式里忘了加双引号。比如case $action in如果$action为空case部分语法仍然能过但匹配行为可能不符合预期。建议写成case $action in这不算强制要求但能减少空值带来的边界烦恼。养成这两个习惯测试变量加引号、case 匹配变量加引号。5.3 调试三板斧set -x、echo标记、返回码查看条件测试出问题不要靠眼睛干瞪直接用调试手段。第一个利器是set -x。脚本开头加上它后执行时每条命令、每个变量展开后的真实样子都会打印到终端。很多“诡异”的报错比如命令找不到、参数数目不对带上set -x一眼就能看到变量展开了什么。第二个方法是加 echo 标记。在关键分支入口输出一条提示比如echo 进入 else 分支跑一遍就能确认脚本走的是哪条路。简单粗暴但非常有效。第三个方法是学会看$?。在测试语句后紧跟着输出退出码[ -f /etc/hosts ] echo 退出码: $?退出码为 0 说明判断为真为 1 表示假为 2 或者其他数字多半是表达式写错了。把这三个工具组合起来排查速度会快很多。调试完记得去掉set -x和多余的 echo别把调试输出留在正式脚本里。5.4 常见问题速查表现象常见原因解决方法command not found: [hello[和变量之间缺空格写成[ $a hello ]unary operator expected变量为空且未加引号变量加上双引号]后面出现多余字符then前缺少分号写成if 条件; then生成奇怪的文件被当成重定向数值比较用-gt字符串比较用[[ ]]case 分支全部执行分支结尾少写;;每个分支末尾补;;表达式似乎正确但结果不对变量内容包含隐藏空格用set -x看展开后的真实内容在 sh 环境报[[: not found[[不是 POSIX 标准sh 环境改用[ ]mkdir 失败但脚本继续跑没检查$?mkdir 后判断退出码并处理这个表不是全部但覆盖了新手到中级用户遇到的大部分情况。把它当成小抄收藏起来遇到问题先对照一遍。我个人写了几年脚本后最大的体会是条件测试写不好往往不是语法记不住而是没想清楚判断的主体是“变量值”还是“状态”。“变量值”用字符串比较“文件状态”用文件操作符“命令结果”用退出码想清楚之后再选语法写出来的脚本基本不会乱。最后再分享一个小技巧在复杂脚本开头加上set -u只要碰到没定义过的变量脚本立刻报错退出。这个设置对抓空变量导致的判断 bug 特别有用配合set -x一起用排查效率更高。