现在我们的脚本变得更加复杂,是时候看看当事情出错时会发生什么了。在本章中,我们将研究脚本中出现的一些常见错误,并研究一些可用于跟踪和消除问题的有用技术。
第三十章:排错语法错误缺失的引号丢失或意外的令牌意想不到的扩展逻辑错误防御性编程set -e
、set -u
、set -o PIPEFAIL
ShellCheck是你的朋友注意文件名可移植文件名验证输入设计是时间的函数测试测试用例调试发现问题区域跟踪在执行过程中检查值总结
一类常见的错误是句法错误。语法错误涉及错误键入shell语法的某些元素。当shell遇到此类错误时,它将停止执行脚本。
在以下讨论中,我们将使用此脚本演示常见类型的错误:
xxxxxxxxxx
# trouble: script to demonstrate common errors
number=1
if [ $number = 1 ]; then
echo "Number is equal to 1."
else
echo "Number is not equal to 1."
fi
如上所述,此脚本已成功运行。
xxxxxxxxxx
[me@linuxbox ~]$ trouble
Number is equal to 1.
quote —— 引号
让我们编辑我们的脚本,并从第一个echo命令后的参数中删除尾随引号。
xxxxxxxxxx
# trouble: script to demonstrate common errors
number=1
if [ $number = 1 ]; then
echo "Number is equal to 1.
else
echo "Number is not equal to 1."
fi
事情是这样的:
xxxxxxxxxx
[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
【对于中文环境的Debian,会提示:行10:寻找匹配的`"'时遇到了未预期的EOF】
它会产生两个错误。有趣的是,错误消息报告的行号不是删除缺失引号的位置,而是程序中的稍后位置。如果我们在缺少报价后遵循程序,我们就能明白原因。bash将继续查找结束引号,直到在第二个echo命令之后立即找到一个为止。在那之后,bash变得非常困惑。后续 if
命令的语法已损坏,因为 fi
语句现在位于引号(但打开)字符串中。
在长脚本中,这种错误可能很难找到。使用带有语法高亮显示的编辑器会有所帮助,因为在大多数情况下,它将以与其他类型的shell语法不同的方式显示引号字符串。如果安装了完整版本的 vim
,可以通过输入以下命令启用语法高亮显示:
xxxxxxxxxx
:syntax on
另一个常见的错误是忘记完成复合命令,例如 if
或 while
。让我们看看如果在 if
命令中删除 test
后的分号会发生什么:
xxxxxxxxxx
# trouble: script to demonstrate common errors
number=1
if [ $number = 1 ] then
echo "Number is equal to 1."
else
echo "Number is not equal to 1."
fi
结果是这样的:
xxxxxxxxxx
[me@linuxbox ~]$ trouble
/home/me/bin/trouble: line 9: syntax error near unexpected token `else'
/home/me/bin/trouble: line 9: `else'
同样,错误消息指向比实际问题晚发生的错误。发生的事情真的很有趣。我们记得, if
接受一系列命令,并计算列表中最后一个命令的退出代码。在我们的程序中,我们打算让这个列表由一个命令 [
组成,这是 test
的同义词。 [
命令将后面的内容作为参数列表;在我们的例子中,有四个参数: $number
、 1
、 =
和 ]
。删除分号后,该词将添加到参数列表中,这在语法上是合法的。以下 echo
命令也是合法的。它被解释为 if
将评估退出代码的命令列表中的另一个命令。接下来会遇到 else
,但它不合适,因为shell将其识别为保留字(对shell具有特殊含义的字),而不是命令的名称,这就是错误消息的原因。
脚本中可能会出现间歇性错误。有时脚本会运行良好,而其他时候则会因为扩展的结果而失败。如果我们返回丢失的分号并将 number
的值更改为空变量,我们可以演示。
xxxxxxxxxx
# trouble: script to demonstrate common errors
number=
if [ $number = 1 ]; then
echo "Number is equal to 1."
else
echo "Number is not equal to 1."
fi
运行带有此更改的脚本会产生以下输出:
xxxxxxxxxx
[me@linuxbox ~]$ trouble
/home/me/bin/trouble: line 7: [: =: unary operator expected
Number is not equal to 1.
我们得到这个相当神秘的错误消息,然后是第二个echo命令的输出。问题在于 test
命令中数字变量的扩展。当执行以下命令时:
xxxxxxxxxx
[ $number = 1 ]
在数字为空的情况下进行扩展,结果如下:
xxxxxxxxxx
[ = 1 ]
这是无效的,并且产生了错误。 =
运算符是一个二元运算符(它需要每侧都有一个值),但缺少第一个值,因此 test
命令需要一个一元运算符(如 -z
)。此外,由于 test
失败(由于错误), if
命令接收非零退出代码并相应地采取行动,并执行第二个echo命令。
这个问题可以通过在 test
命令中的第一个参数周围添加引号来纠正。
xxxxxxxxxx
[ "$number" = 1 ]
然后,当扩展发生时,结果将是这样的:
xxxxxxxxxx
[ "" = 1 ]
这产生了正确数量的论点。除了空字符串外,在值可以扩展为多字字符串的情况下,也应该使用引号,就像包含嵌入式空格的文件名一样。
注意:除非需要分词,否则请始终将变量和命令替换括在双引号中。
与语法错误不同,逻辑错误不会阻止脚本运行。脚本将运行,但由于其逻辑问题,它不会产生预期的结果。可能存在无数种逻辑错误,但以下是脚本中最常见的几种:
条件表达式不正确。
很容易对if/then/else进行错误的编码,并执行错误的逻辑。有时,逻辑会被颠倒,或者是不完整的。
“Off by one”错误。
当对使用计数器的循环进行编码时,可能会忽略循环可能要求计数从零开始,而不是从一开始,以便计数在正确的点结束。这类错误要么导致循环因计数过多而“偏离终点”,要么导致循环过早终止一次迭代而错过最后一次迭代。
意想不到的情况。
大多数逻辑错误是由于程序遇到程序员无法预见的数据或情况造成的。正如我们所看到的,这也可能包括意外的扩展,例如包含嵌入式空格的文件名,这些空格可以扩展为多个命令参数,而不是单个文件名。
defensive —— 防御的
在编程时验证假设很重要。这意味着要仔细评估脚本使用的程序和命令的退出状态。这是一个基于真实故事的例子。一位不幸的系统管理员编写了一个脚本,在一台重要服务器上执行维护任务。该脚本包含以下两行代码:
xxxxxxxxxx
cd $dir_name
rm *
只要变量 dir_name
中命名的目录存在,这两行就没有本质上的问题。但如果没有,会发生什么?在这种情况下,cd命令将失败,脚本将继续执行下一行并删除当前工作目录中的文件。根本不是预期的结果!不幸的管理员因为这个设计决定而破坏了服务器的一个重要部分。
让我们看看这种设计可以改进的一些方法。首先,明智的做法是通过引用 dir_name
变量来确保它只扩展为一个单词,并使 rm
的执行取决于 cd
的成功。
xxxxxxxxxx
cd "$dir_name" && rm *
这样,如果 cd
命令失败,就不会执行 rm
命令。这更好,但仍然存在变量 dir_name
未设置或为空的可能性,这将导致用户主目录中的文件被删除。通过检查 dir_name
是否确实包含现有目录的名称,也可以避免这种情况。
xxxxxxxxxx
[[ -d "$dir_name" ]] && cd "$dir_name" && rm *
通常,最好包含终止脚本的逻辑,并在发生如前所示的情况时报告错误。
xxxxxxxxxx
# Delete files in directory $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
命令的成功。如果其中任何一个失败,则会向标准错误发送一条描述性错误消息,脚本将终止,退出状态为 1
,表示失败。
set -e
、set -u
、set -o PIPEFAIL
关于bash,我们注意到的一件事是,当脚本执行而命令失败时(不包括脚本本身的语法错误),脚本将愉快地继续执行下一个命令。这通常是不可取的,POSIX标准和随后的bash试图解决这个问题。bash提供了一个尝试自动处理错误的设置,这意味着启用此设置后,如果任何命令(包括一些必要的异常)返回非零退出状态,脚本将终止。要调用此设置,我们将命令 set -e
放在脚本的开头附近。几个bash编码标准坚持使用此功能以及几个相关设置, set -u
在有未初始化的变量时终止脚本, set -o PIPEFAIL
在管道中的最后一个元素失败时导致脚本终止。
不建议使用这些功能。最好设计适当的错误处理,不要依赖set-e来代替良好的编码实践。
Bash FAQ #105 对此提供了以下意见:
set -e
是试图在shell中添加“自动错误检测”。它的目标是在发生错误时使shell中止,这样你就不必在每个重要命令后加上 || exit 1
。
这个目标并不简单,因为许多命令都有意返回非零。例如,
if [ -d /foo ]; then ...; else ...; fi
显然,当 [ -d /foo]
命令返回非零时(因为目录不存在),我们不想中止——我们的脚本希望在 else
部分处理这个问题。因此,实现者决定制定一系列特殊规则,比如“if测试中的命令是免疫的”,或者“管道中的命令,而不是最后一个,是免疫的。”
这些规则极其复杂,甚至无法捕捉到一些非常简单的案例。更糟糕的是,规则会随着Bash版本的不同而变化,因为Bash试图跟踪这个“特性”的极其狡猾的POSIX定义。当涉及子shell时,情况会变得更糟——行为会根据Bash是否在POSIX模式下调用而变化。
大多数发行版存储库中都有一个名为 shellcheck
的程序,可以执行shell脚本分析,并检测多种错误和糟糕的脚本编写实践。使用它来检查我们脚本的质量是非常值得的。要将其与具有shebang的脚本一起使用,我们只需这样做:
xxxxxxxxxx
shellcheck my_script
ShellCheck将根据shebang自动检测使用哪种shell方言。对于测试不包含shebang的脚本代码,例如函数库,我们可以这样使用ShellCheck:
xxxxxxxxxx
shellcheck -s bash my_library
使用-s选项指定所需的shell方言。有关ShellCheck的更多信息,请访问其网站http://www.shellcheck.net.
此文件删除脚本还有另一个问题,它更模糊,但可能非常危险。许多人认为,Unix(和类Unix操作系统)在文件名方面存在严重的设计缺陷。Unix对它们非常宽容。事实上,只有两个字符不能包含在文件名中。第一个是 /
字符,因为它用于分隔路径名的元素,第二个是空字符(unll character,零字节),它在内部用于标记字符串的末尾。其他一切都是合法的,包括空格、制表符、换行符、前导连字符、回车符等。
特别值得关注的是前导连字符。例如,拥有一个名为 -rf ~
的文件是完全合法的。考虑一下当文件名传递给 rm
时会发生什么。
为了防止这个问题,我们想将文件删除脚本中的 rm
命令更改为:
xxxxxxxxxx
rm *
具体如下:
xxxxxxxxxx
rm ./*
这将防止以连字符开头的文件名被解释为命令选项。作为一般规则,通配符(如 *
和 ?
)前必须加上 ./
以防止命令的误解。这包括 *.pdf
和 ???.mp3
。
为确保文件名在多个平台(即不同类型的计算机和操作系统)之间可移植,必须注意限制文件名中包含的字符。有一个名为POSIX可移植文件名字符集的标准,可用于最大限度地提高文件名在不同系统中工作的可能性。标准很简单。只允许使用大写字母A-Z、小写字母a-z、数字0-9、句点(.)、连字符(-)和下划线(_)。该标准进一步建议文件名不应以连字符开头。
良好编程的一般规则是,如果程序接受输入,它必须能够处理它收到的任何东西。这通常意味着必须仔细筛选输入,以确保只接受有效的输入进行进一步处理。我们在上一章研究 read
命令时看到了一个例子。一个脚本包含以下测试,用于验证菜单选择:
xxxxxxxxxx
[[ $REPLY =~ ^[0-3]$ ]]
这个测试非常具体。只有当用户输入的字符串是零到三范围内的数字时,它才会返回零退出状态。其他任何东西都不会被接受。有时,编写这类测试可能具有挑战性,但要生成高质量的脚本,必须付出努力。
当我还是一名学习工业设计的大学生时,一位聪明的教授说,一个项目的设计量取决于给设计师的时间。如果你有五分钟的时间设计一个“杀死苍蝇”的装置,你就设计了一个苍蝇拍(flyswatter)。如果给你五个月的时间,你可能会想出一个激光制导的“防苍蝇系统”。
同样的原则也适用于编程。有时,如果程序员只使用一次,那么一个“快速而肮脏”的脚本就可以了。这种脚本很常见,应该快速开发,以节省精力。这样的脚本不需要很多注释和防御检查。另一方面,如果一个脚本是用于生产用途的,即一个将反复用于重要任务或由多个用户使用的脚本,那么它需要更仔细的开发。
测试是每种软件开发中的重要一步,包括脚本。开源世界有一句谚语,“尽早发布,经常发布”(release early, release often),反映了这一事实。通过尽早和经常发布,软件可以获得更多的使用和测试机会。经验表明,如果在开发周期的早期发现错误,则更容易发现,修复成本也低得多。
在【第26章.自顶向下设计】中,我们看到了如何使用存根(stubs)来验证程序流。从脚本开发的早期阶段开始,它们就是检查我们工作进度的宝贵技术。
让我们看看前面显示的文件删除问题,看看如何对其进行编码以便于测试。测试原始代码片段是危险的,因为它的目的是删除文件,但我们可以修改代码以使测试安全。
xxxxxxxxxx
if [[ -d $dir_name ]]; then
if cd $dir_name; then
echo rm * # TESTING
else
echo "cannot cd to '$dir_name'" >&2
exit 1
fi
else
echo "no such directory: '$dir_name'" >&2
exit 1
fi
exit # TESTING
由于错误条件已经输出了有用的消息,我们不必添加任何消息。最重要的更改是在 rm
命令之前放置一个 echo
命令,以允许显示命令及其扩展的参数列表,而不是实际执行的命令。此更改允许安全执行代码。在代码片段的末尾,我们放置一个 exit
命令来结束测试,并防止执行脚本的任何其他部分。根据脚本的设计,对此的需求会有所不同。
我们还包括一些评论,作为我们测试相关更改的“标记”。这些可用于在测试完成时帮助查找和删除更改。
为了执行有用的测试,开发和应用良好的测试用例(test cases)非常重要。这是通过仔细选择反映边缘(edge)和角落(corner)情况的输入数据或操作条件来实现的。在我们的代码片段中(很简单),我们想知道代码在三种特定条件下是如何执行的:
dir_name
包含现有目录的名称。dir_name
包含一个不存在的目录的名称。dir_name
为空。通过在每种条件下进行测试,可以实现良好的测试覆盖率(test coverage)。
与设计一样,测试也是时间的函数。并非每个脚本功能都需要进行广泛的测试。这实际上是确定什么是最重要的问题。由于如果发生故障,它可能会具有潜在的破坏性,因此我们的代码片段在设计和测试过程中都值得仔细考虑。
如果测试发现脚本有问题,下一步就是调试。“问题”通常意味着脚本在某种程度上没有达到程序员的期望。如果是这样的话,我们需要仔细确定脚本实际在做什么以及为什么。发现bug有时会涉及大量的侦探工作。
一个精心设计的脚本会尽力提供帮助。它应该进行防御性编程,以检测异常情况并向用户提供有用的反馈。然而,有时问题非常奇怪和出乎意料,需要更复杂的技术。
在某些脚本中,特别是长脚本中,有时隔离与问题相关的脚本区域是有用的。这并不总是实际的错误,但隔离通常会提供对实际原因的见解。一种可用于隔离代码的技术是“注释掉”脚本的部分。例如,可以修改我们的文件删除片段,以确定删除的部分是否与错误有关。
xxxxxxxxxx
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通常是脚本中意外(unexpected)逻辑流的情况。也就是说,脚本的某些部分要么从未执行,要么以错误的顺序或在错误的时间执行。为了查看程序的实际流程,我们使用了一种称为跟踪的技术。
一种跟踪方法涉及将信息性消息放置在显示执行位置的脚本中。我们可以在代码片段中添加消息。
xxxxxxxxxx
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
我们将消息发送到标准错误,以将其与正常输出分开。我们也不会缩进包含消息的行,因此更容易找到何时删除它们。
现在,当执行脚本时,可以看到文件删除已执行。
xxxxxxxxxx
[me@linuxbox ~]$ deletion-script
preparing to delete files
deleting files
file deletion complete
[me@linuxbox ~]$
bash还提供了一种跟踪方法,通过 -x
选项和带有 -x
选项的 set
命令来实现。使用我们之前的 trouble 脚本,我们可以通过在第一行添加 -x
选项来激活整个脚本的跟踪。
xxxxxxxxxx
# trouble: script to demonstrate common errors
number=1
if [ $number = 1 ]; then
echo "Number is equal to 1."
else
echo "Number is not equal to 1."
fi
执行后,结果如下:
xxxxxxxxxx
[me@linuxbox ~]$ trouble
+ number=1
+ '[' 1 = 1 ']'
+ echo 'Number is equal to 1.'
Number is equal to 1.
启用跟踪后,我们可以看到在应用扩展的情况下执行的命令。前导加号表示轨迹的显示,以将其与常规输出线区分开来。加号是跟踪输出的默认字符。它包含在PS4((prompt string,提示字符串4)shell变量中。可以调整此变量的内容,使提示更有用。在这里,我们修改变量的内容,以在执行跟踪的脚本中包含当前行号。请注意,在实际使用提示之前,需要单引号来防止扩展。
xxxxxxxxxx
[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
命令。
xxxxxxxxxx
# trouble: script to demonstrate common errors
number=1
set -x # Turn on tracing
if [ $number = 1 ]; then
echo "Number is equal to 1."
else
echo "Number is not equal to 1."
fi
set +x # Turn off tracing
我们使用带有 -x
选项的 set
命令来激活跟踪,使用 +x
选项来停用跟踪。此技术可用于检查麻烦脚本的多个部分。
与跟踪一起,显示变量的内容以查看脚本执行时的内部工作原理通常很有用。应用额外的echo语句通常会奏效。
xxxxxxxxxx
# trouble: script to demonstrate common errors
number=1
echo "number=$number" # DEBUG
set -x # Turn on tracing
if [ $number = 1 ]; then
echo "Number is equal to 1."
else
echo "Number is not equal to 1."
fi
set +x # Turn off tracing
在这个简单的例子中,我们只是显示变量数字的值,并用注释标记添加的行,以方便以后识别和删除。在观察脚本中的循环和算术行为时,这种技术特别有用。
在本章中,我们只研究了脚本开发过程中可能出现的几个问题。当然,还有更多。这里描述的技术将能够找到最常见的错误。调试是一门通过经验发展起来的艺术,包括知道如何避免错误(在整个开发过程中不断测试)和发现错误(有效使用跟踪)。