Shell脚本——故障诊断

1、语法错误

一类常见错误和语法有关,即语法错误。语法错误包括输错了某些Shell语法元素。如果碰到此类错误,Shell会停止执行脚本。

在后文中,我们将使用下面的脚本来演示常见的错误类型:

#!/bin/bash

# 错误:演示常见错误的脚本
number=1
if [ $number = 1 ]; then
      echo "Number is equal to 1."
else
      echo "Number is not equal to 1."
fi

该脚本执行顺利:

[me@linuxbox ~]$ trouble
Number is equal to 1.

1.1、缺少引号

让我们来编辑脚本,找到第一个echo命令的参数,将其结尾的引号去掉:

#!/bin/bash

# 错误:演示常见错误的脚本

number=1

if [ $number = 1 ]; then
      echo "Number is equal to 1.
else
      echo "Number is not equal to 1."
fi

这会导致下列情况:

[me@linuxbox ~]$ trouble
/home/me/bin/trouble: line 10: unexpected EOF while looking for matching '"'
/home/me/bin/trouble: line 13: syntax error: unexpected end of file

脚本产生了两处错误。值得注意的是,错误消息中所报告的行号并非缺少引号的位置,而是脚本中相当靠后的地方。如果我们从缺少引号处接着往下看,就知道是怎么回事了。Bash会继续寻找闭合引号,不找到誓不罢休。它的确找到了,就是紧挨着第二个echo命令的那个引号。然后,Bash可就“摸不着头脑”了,它发现接下来的if命令的语法不对,原因在于fi语句此时出现在了一个被引用(但未闭合)的字符串内部。

对于代码量较大的脚本,这种错误相当难以查找。使用带有语法高亮特性的编辑器会有所帮助。在多数情况下,这种编辑器会用与其他Shell语法元素明显有别的方式显示引用字符串。如果安装了完整版本的Vim,可以通过下列命令启用语法高亮:

:syntax on

1.2、缺少词法单元

另一种常见错误是没有把符号或命令(如if或while)写完整。让我们来看一看如果去掉if命令中的test命令之后的分号会出现什么情况:

#!/bin/bash

# 错误:演示常见错误的脚本

number=1

if [ $number = 1 ] then
      echo "Number is equal to 1."
else
      echo "Number is not equal to 1."
fi

结果如下:

[me@linuxbox ~]$ trouble
/home/me/bin/trouble: line 9: syntax error near unexpected token 'else'
/home/me/bin/trouble: line 9: 'else'

错误消息这次指向的又是一个出现在实际问题之后的错误。这里的情况着实值得注意。回想一下,if接受命令列表,评估列表中最后一个命令的退出状态。在我们的程序中,这个命令列表原本应该只包含单个命令[​(test的同义词)​;​[命令之后是4个参数:$number、1、=、​]​。如果去掉了分号,单词then也变成了参数,这在语法上没毛病。后续的echo命令也是合法的。它会被解释成命令列表中的另一个命令,if将评估其退出状态。接下来碰到的是else,它出现在此处是不合适的,因为Shell将其识别为保留字(对Shell具有特殊含义的单词)​,而非命令名称,这正是导致错误消息的原因。

1.3、出乎意料的扩展

脚本可能会间歇性地出现错误。有时候脚本执行正常,有时候又会因为扩展结果而出错。补上之前去掉的分号,将number的值修改为空,现在让我们来演示一下这种现象:

#!/bin/bash

# 错误:演示常见错误的脚本

number=

if [ $number = 1 ]; then
      echo "Number is equal to 1."
else
      echo "Number is not equal to 1."
fi

执行修改后的脚本,产生下列执行结果:

[me@linuxbox ~]$ trouble
/home/me/bin/trouble: line 7: [: =: unary operator expected
Number is not equal to 1.

我们得到了一条让人摸不着头脑的错误消息,接着是第二个echo命令的输出结果。问题在于test命令内的number变量的扩展。当执行下列命令时:

[ $number = 1 ]

$number经过扩展后,结果为空,test命令就变成了:

[ = 1 ]

这种形式显然不合法,因而产生了错误。=是一个二元操作符(要求操作符两侧都要有值)​,但现在少了第一个值,所以test命令只能寄望于一元操作符(例如,-z)​。由于test执行失败,if命令得到的是非0退出状态,因此执行了第二个echo命令。

给test命令的第一个参数加上双引号就能解决这个问题:

[ "$number" = 1 ]

经过扩展后,结果如下:

[ "" = 1 ]

这样一来,参数数量就没错了。除了空串,双引号还可用于某个值会被扩展成多单词字符串(multiword string)的情况,因为文件名中是可以包含空格的。

注意
始终坚持将变量和命令替换放入双引号中,除非需要单词分割。把这句话作为一条规则记住。

2、逻辑错误

和语法错误不同,逻辑错误不会妨碍脚本执行。脚本照样可以执行,但因为逻辑有问题,无法产生理想的结果。可能出现的逻辑错误数不胜数,不过常见的包括以下几种。

  • 条件表达错误。if/then/else表达式很容易写错,从而造成逻辑错误,错误表达的逻辑与正确表达的逻辑南辕北辙,或者压根没表达完整。
  • “差一”​(off by one)错误。在使用计数器的循环中,可能会忽略循环计数需要从0而不是从1开始,才能在正确的点完成计数。这种错误要么导致循环超出终点(计数过多)​,要么导致少了最后一次迭代(循环提前结束)​。
  • 非预期情况。大多数逻辑错误是由于程序遇到了程序员没预想到的数据或情况引起的。非预期扩展也包括在内,例如包含空格符的文件名被扩展成了多个命令参数,而非单个文件名。

2.1、防御式编程

核实编程时的各种假设很重要,这意味要仔细评估程序的退出状态以及脚本中用到的命令。来讲一个真实的故事吧。有位系统管理员写了一个脚本,负责一台重要的服务器的维护任务。这个脚本包含了这样两行代码:

cd $dir_name
rm *

只要变量dir_name中包含的目录存在,以上两行代码就没有什么本质性的错误。但是,如果目录不存在呢?在这种情形下,cd命令执行失败,脚本接着执行下一行,删除当前工作目录中的所有文件。这可完全不是想要的结果!由于这种设计,这位系统管理员销毁了服务器中一部分重要文件。

来看几个改进措施。首先,将变量dir_name标注起来,确保其只扩展成一个单词,同时仅在cd命令执行成功时才执行rm命令。

cd "$dir_name" && rm *

按照这种方法,如果cd命令执行失败,rm命令并不会执行。脚本有所改进,但仍存在变量dir_name不存在或为空的可能性,这会导致用户主目录内的文件全都被删除。

可以通过检查dir_name是否包含已有目录的名称来解决这个问题。

[[ -d "$dir_name" ]] && cd "$dir_name" && rm *

通常要加入终止脚本的逻辑,并在发生上述情况时报告错误:

# 删除目录$dir_name中的文件
if [[ ! -d "$dir_name" ]]; then
      echo "No such directory: '$dir_name'" >&2
      exit 1
fi
if ! cd "$dir_name"; then
      echo "Cannot cd to '$dir_name'" >&2
      exit 1
fi
if ! rm *; then
      echo "File deletion failed. Check results" >&2
      exit 1
fi

其中,我们既检查名称对应的目录是否存在,也检查cd命令是否执行成功。如果有任意一项失败,就向标准错误发送描述性的错误消息并终止脚本,返回指示故障的退出状态。

2.2、小心文件名

文件检测脚本还存在另一个更为晦涩,但也非常危险的问题。在很多人看来,UNIX(以及类UNIX系统)在文件名方面有一处严重的设计缺陷。那就是对文件名的组成过于宽容。事实上,只有两个字符不能出现在文件名中。一个是/,因为该字符用于分隔路径名中的各个部分;另一个是空字符,该字符用于在内部标示字符串结束。除此之外的所有字符都是合法的,其中包括空格符、制表符、换行符、前导连字符(leading hyphens)​、回车符等。

尤其是前导连字符。例如,把文件命名为-rf完全没有任何问题。想想如果将该文件名作为参数传给rm会有什么后果。

为了防范这个问题,我们要把文件检测脚本中的rm命令由:

rm *

修改成:

rm ./*

这就避免了以连字符开头的文件名被误解为命令选项。作为通用规则,始终坚持在通配符(如和?​)之前加上./,以免命令误解,例如.pdf和???.mp3。

2.3、核实输入

一个良好的编程习惯是,如果程序需要接受输入,那么必须能够应对所有的输入内容。这通常意味着一定要仔细核实输入,保证只对有效输入做进一步处理。在第29章讲解read命令时,我们曾经见到过类似的例子。脚本中包含了下列测试来核实菜单选项:

[[ $REPLY =~ ^[0-3]$ ]]

测试非常具体。如果用户输入的字符串是在0~3范围内的数字,则返回退出状态0值。其他输入概不接受。这种测试有时候写起来有难度,但要想得到高质量的脚本,努力是必不可少的。

3、测试

测试是包括脚本在内的所有软件开发中的重要步骤。开源世界有种说法:早发布,勤发布。按照这种做法,软件就能够得到更多的使用和测试。经验表明,在开发周期的早期阶段更容易找出Bug,这时修复Bug的代价也更低。

在前面中,我们看到了如何使用桩代码核实程序流程。在脚本开发的最早阶段,桩代码是检查工作进展的重要技术手段。

回顾一下前文的文件删除问题,看一看怎样增强代码的易测试性。因为原始代码的目的是删除文件,所以直接进行测试比较危险。不过我们可以修改代码,确保测试安全:

if [[ -d $dir_name ]]; then
      if cd $dir_name; then
            echo rm * # 易测试性
      else
            echo "cannot cd to '$dir_name'" >&2
            exit 1
      fi
else
      echo "no such directory: '$dir_name'" >&2
      exit 1
fi
exit # 易测试性

因为错误分支已经输出了有帮助的消息,我们就不需要再添加什么了。最重要的改动是在rm命令之前放置了echo命令,使rm命令及其扩展后的命令参数得以显示出来,而不是执行删除操作。在代码片段的末尾,我们添加了exit命令来结束测试,避免执行脚本的其他部分。是否需要这么做视脚本设计而定。

我们还要添加一些注释,标记出与测试相关的改动。测试结束后,注释有助于查找和删除做出的改动。

测试用例

要想执行有效的测试,重要的是开发和应用高质量的测试用例。这要求仔细选择能反映出边角情况(edge andcorner case)的输入数据和操作条件。对于我们的代码片段(算不上复杂)​,需要知道其在以下3种特定条件下的执行情况。

  • dir_name包含的是已存在的目录名。
  • dir_name包含的是不存在的目录名。
  • dir_name为空。

通过测试每种条件,可实现良好的测试覆盖面。

就像设计一样,测试也得花时间打磨。不是所有的脚本特性都得测试。确定哪些是重要的才是关键。因为有问题的 代码可能会造成毁灭性的后果,所以不管是代码设计还是测试都需要仔细斟酌。

4、调试

如果测试发现脚本存在问题,那么下一步就是调试。​“问题”​,通常在某种程度上意味着脚本并没有按照程序员的预期执行。如果是这样的话,就需要仔细查明脚本究竟做了什么及其原因。查找Bug有时需要大量时间来推敲。

设计优良的脚本本身能够提供一些帮助。脚本应该采用防御式编程,以检测异常,同时向用户反馈有用的信息。但有时候碰到的问题特别奇怪,完全出乎意料,这时就得用到更多的技术。

4.1、查找问题区域

在有些脚本中,尤其是那些长脚本,有时候将其中与问题相关的部分隔离出来还是有必要的。这部分未必就是问题所在,但是往往能帮助我们发现实际原因。一种隔离代码的技术是“注释掉”​(commenting out)一部分脚本。例如,我们可以修改文件删除代码片段,确定去掉的部分是否与错误有关:

if [[ -d $dir_name ]]; then
      if cd $dir_name; then
            rm *
      else
            echo "cannot cd to '$dir_name'" >&2
            exit 1
      fi
# else
#      echo "no such directory: '$dir_name'" >&2
#      exit 1
fi

通过将注释符放置在脚本逻辑片段内的各行之前,就可以阻止这部分代码被执行。再次执行测试,看一看去掉的这部分代码对Bug有没有什么影响。

4.2、跟踪

Bug还经常表现为脚本中异常的逻辑流程。也就是说,部分脚本要么没执行,要么以错误的顺序或在错误的时刻执行了。跟踪是一种用于查看程序实际执行流程的技术。

一种跟踪技术是通过在脚本中添加能够显示执行位置的提示信息。我们可以把这些信息加入代码片段:

echo "preparing to delete files" >&2
if [[ -d $dir_name ]]; then
      if cd $dir_name; then
echo "deleting files" >&2
            rm *
      else
            echo "cannot cd to '$dir_name'" >&2
            exit 1
      fi
else
      echo "no such directory: '$dir_name'" >&2
      exit 1
fi
echo "file deletion complete" >&2

我们将信息发送至标准错误,以便与正常输出结果区分开。另外,我们也没有对包含消息的代码行进行缩进,这样在需要删除这些行的时候,更容易查找。

该脚本执行时,就可以看到已经执行了文件删除操作:

[me@linuxbox ~]$ deletion-script
preparing to delete files
deleting files
file deletion complete
[me@linuxbox ~]$

bash还通过-x选项和set命令的-x选项提供了另一种跟踪技术。我们在前文的trouble脚本的第一行加入-x选项,激活整个脚本的跟踪功能:

#!/bin/bash -x

# 错误:演示常见错误的脚本

number=1

if [ $number = 1 ]; then
      echo "Number is equal to 1."
else
      echo "Number is not equal to 1."
fi

该脚本的执行结果如下:

[me@linuxbox ~]$ trouble
+ number=1
+ '[' 1 = 1 ']'
+ echo 'Number is equal to 1.'
Number is equal to 1.

启用跟踪后,我们可以查看命令经过扩展之后的执行情况。行首的加号表示此行是跟踪显示的结果,以区别于一般的输出结果。加号是用于跟踪输出的默认字符,是由Shell变量PS4(Prompt String 4)设定的。可以改变PS4变量的值,增强提示符的实用性。我们对变量PS4做出修改,在其中加入当前所跟踪代码的行号。注意,单引号用于将扩展推迟到真正使用提示符的时候:

[me@linuxbox ~]$ export PS4='$LINENO + '
[me@linuxbox ~]$ trouble
5 + number=1
7 + '[' 1 = 1 ']'
8 + echo 'Number is equal to 1.'
Number is equal to 1.

如果只是想对部分脚本进行跟踪,可以使用带有-x选项的set命令:

#!/bin/bash

# 错误:演示常见错误的脚本

number=1

set -x # 打开跟踪
if [ $number = 1 ]; then
      echo "Number is equal to 1."
else
      echo "Number is not equal to 1."
fi
set +x # 关闭跟踪

我们使用set命令的-x选项启用跟踪,使用+x选项停止跟踪。这项技术可用于检查问题脚本中的多个部分。

4.3、在执行过程中检查值

在跟踪过程中,显示变量的值,以此了解脚本执行时的内部工作状态,往往能派上大用场。这可以通过添加echo语句来实现。

#!/bin/bash

# 错误:演示常见错误的脚本

number=1

echo "number=$number" # 调试
set -x # 打开跟踪
if [ $number = 1 ]; then
      echo "Number is equal to 1."
else
      echo "Number is not equal to 1."
fi
set +x # 关闭跟踪

在这个简单的例子中,我们只显示了变量number的值,并使用注释标记出了额外添加的行,以便于后续的识别和删除。这项技术在观察脚本中的循环和算术运算行为时尤其有用。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值