第30章:排除故障

既然我们的脚本变得越来越复杂了,是时候看看当错误产生时,都发生了什么。本章中,我们将学习一些发生在脚本中的常见类型的错误,研究一些可用于跟踪和消除问题的有用技术。

句法错误

一类普通的错误是句法(syntactic)。句法错误涉及 shell 句法的某些元素的错误拼写。shell 在遇到此类错误时会停止执行一个脚本。

在下面的讨论中,我们会用一个脚本来演示常见类型的错误:

#!/bin/bash

# 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

如上,这个脚本成功运行。

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

缺失的引号

我们来编辑脚本,移除第一个 echo 命令的参数中的引号。

#!/bin/bash

# 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

来看下发生了什么:

[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

缺失或意外的令牌

另一个常见的错误是忘了完成一个符合命令,如 ifwhile。如果移除 if 命令中 test 之后的分号,让我们来看下会发生什么:

#!/bin/bash

# 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

结果是:

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

又一次,错误信息指向的错误在发生实际问题所在的后面。发生了什么,这真的很有意思。我们记得,if 接收一个命令列表并评估列表中最后一个命令的退出码。在程序中,我们希望该列表包含在单个命令 [ 之中,即 test 的同义词。[ 命令将其后的内容作为参数列表; 在我们的例子中,有四个参数:$number1=]。在除去分号后,then 被添加到参数列表中,这在语法上是合法的。下一个 echo 命令也是合法的。它会被解释为命令列表中的另一个命令,if 将评估其退出代码。接下来会遇到 else,但是它不合适,因为 shell 会将其识别为保留字(reserved word 对 shell 具有特殊含义的词),而不是命令的名称,这就是错误消息的原因。

不可预料的扩展

在脚本中有可能出现间歇性的错误。有时候脚本运行正常,有时候则会因为一个扩展的结果而报错。我们可以把缺失的分号补全,并将变量 number 的值留空,来演示一下。

#!/bin/bash

# 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

运行变更过的脚本,会导致下列输出:

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

在第二个 echo 命令的输出之后,我们得到这条相当隐秘的错误信息。这个问题在于 test 命令之中的 number 变量的扩展。当下列命令:

[ $number = 1 ]

number 进行的扩展成了空值,结果是:

[  = 1 ]

这是非法的,且生成了该错误。= 操作符是一个二元运算符(它需要符号两边各有一个值),但是第一个值丢失了,所以 test 命令期望一个一元运算符(如 -z)。继续,由于 test 失败(因为这个错误),if 命令接收到了一个非零的退出码,并依此行动,所以第二个 echo 命令就被执行了。

这个问题可以通过在 test 命令的第一个参数两侧加入引号来改正。

[ "$number" = 1 ]

如此,则当扩展发生时,结果是这样的:

[ "" = 1 ]

这就产生了正确的参数数目。除了空字符串,引号可以用来防止一个值被扩展成多个词的字符串,如包含空格的文件名。

注意:总是将变量和命令替换放在双引号内,使其成为一条规则,除非有必要进行分词。

逻辑错误

和句法错误不同,逻辑错误(logical errors) 不会阻止一个脚本运行。脚本会运行,但是其不会产生期望的结果,因为问题在于其逻辑。有无数种可能的逻辑错误,但这里是一些在脚本中会发生的最常见的类型:

  1. 不正确的条件表达式。很容易错误编写 if/then/else 并执行错误的逻辑。有时候逻辑会被颠倒,或者没有写完整。

  2. 「一口气」错误。当编写要用到计数的循环时,可能会忽视循环需要从 0 开始计数,而不是从 1 开始,因为计数会得出正确的结果。此类错误会导致一个循环因计数过多而「跑过终点」或者因太早终止而缺少最后一次迭代。

  3. 意外情况。大多数逻辑错误因为一个程序遭遇到程序员未预料到的数据或条件而发生。如我们所已见,这可以包含意外的扩展,如一个文件名包含了空格,就会扩展为多个命令参数,而非单个文件名。

防御性编程

编程时很重要的一点是要验证一些假设。这意味着要仔细评估程序和用在脚本中命令的退出状态。这里有个基于真实故事的示例。一个不幸的系统管理员写了一个脚本,要在一个重要的服务器上执行一项维护任务。脚本包含了下面两行代码:

cd $dir_name
rm *

只要变量 dir_name 中的目录名称存在的情况下,这两行中没有什么内在的错误。但是如果它不存在呢?在那种情况下,cd 命令运行失败,脚本继续到下一行,并删除了当前工作目录的所有文件。完全不是想要的结果!这倒霉的程序员因为这个设计决策而毁掉了服务器的一个重要部分。

来看一下改进这个设计的几种途径。首先,聪明的办法是用引号保证 dir_name 变量扩展成单个单词,并使得 rm 的执行取决于 cd 命令的成功。

cd "$dir_name" && rm *

这样,如果 cd 命令失败了,就不会执行 rm 命令了。这确实更好了,不过还留有一种可能,在没有设置变量 dir_name 的初值或者该变量为空的情况下,将会导致用户家目录中的文件被删除。这可以通过检查 dir_name 实际上包含的一个以存在目录的名称来规避。

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

通常情况下,对于如上述所发生的情况,最好是加入逻辑来终结脚本并报告错误。

# 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 指示了一个错误,脚本会终止。

当心文件名

这个删除文件的脚本存在另一个问题,它更隐晦,但是非常危险。Unix(和类 Unix 操作系统)在许多人的想法中,在文件名方面具有一个严重的设计缺陷。Unix 对待文件名极度宽容。事实上,只有两个字符不能被用在文件名中。第一个是 /,因为它被用来分隔路径中的元素,第二个是空字符(一个零字节),用来在内部标记字符串的结束。其余任何字符都是合法的,包括空格、制表符、换行符、前置连字符、回车符等等。

特别引起关注的是前置连字符。例如,一个名为 -rf ~ 的文件是完全合法的。考虑一下当这个文件名被传递到 rm 的那个时刻发生了什么。

要防止这嗯问题,我们想要将删除文件的脚本中的 rm 命令从:

rm *

改成:

rm ./*

这就可以预防一个以连字符开始的文件名被解释为一个命令选项。作为一条通常的规则,在通配符(如 *?)之前总是前置 ./,可以预防被命令错误解释。例如,这包含了 *.pdf???.mp3 等。

可移植文件名

要保证一个文件名在多个平台(例如不同类型的电脑和操作系统)之间是可移植的,必须注意限制文件名中包含哪些字符。有一个标准,名为 POSIX 可移植文件名字符集,可用于最大程度地提高文件名在不同系统上正常工作的机会。标准非常简单。其所允许的字符仅仅只有大写字母 A-Z、小写字母 a-z、数字 0-9、句点(.)、连字符(-)和下划线(_)。标准更进一步建议文件名不应以一个连字符起始。

验证输入

好的编程的一条准则是,如果一个程序接收输入,就必须能处理它所接收的任何输入。通常意味着输入必须被小心地显示在屏幕上,保证仅接收能被进一步处理的合法输入。我们在上一章学习 read 命令的时候看过一个示例。一个脚本中包含了下面这个测试,以验证一个菜单选择项:

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

这个测试非常明确。它仅会在用户输入的字符串是从 03 的一个数字时才返回一个为零的退出状态。其它字符则不被接收。有时,编写此类测试颇具挑战性,但是必须付出努力才能生成高质量的脚本。

设计是一个时间函数

当我还是个正在学习工业设计的学生时,一个聪明的教授说过,一个项目的设计量取决于给予设计师的时间。如果给你五分钟,要你设计一个「杀死苍蝇」的设备,你会设计一个苍蝇拍。如果给你五个月,带来的可能就是激光引导的「反苍蝇系统」了。

这一原理也适用于编程。有时候,如果仅供程序员本人一次性使用,可能会做一个「快而烂」的脚本。那种类型的脚本很常见,并且是被快速开发且节省时间的。这种脚本也不需要大量的注释和防御性的检验。另一面,如果一个脚本打算用于生产(production use),即一个脚本将会被反复用于一个重要任务或者被多个用户使用,就需要更仔细的开发了。

测试

在每个软件开发的过程中,测试都是重要的一环,包括脚本的开发。在开源世界中有一种说法,「早发布,常发布」,反映了这个事实。通过早发布常发布,软件得到了更多使用和测试的机会。经验表明,如果在开发环节的早期,更容易找到错误,修正错误的代价也越低。

在第 26 章「自上而下的设计」中,我们看到了存根是如何被用来验证程序流的。从脚本开发的最初阶段,它们就是一个检验工作进展的可贵的技术。

来看之前展示的文件删除错误,并了解如何对它进行编码以方便测试。测试原始代码的片段是危险的,因为其作用是删除文件,不过我们可以修改代码来使得测试更安全。

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

因为错误条件已经输出了有用的信息,我们不需要再做什么了。最重要的变更是将 echo 命令放在 rm 命令之前,以使得命令及其展开的参数能被显示出来,而非实际执行该命令。这一变更使得可以安全地执行代码。在代码碎片的末尾,我们放了一个 exit 命令来结束测试,并防止脚本的其它部分被执行。对测试的需求会因脚本的设计而不同。

我们还包含了一些注释,就像是对测试相关的变更的「记号」。这会有助于在测试完成后找到并移除这些变更。

测试案例

要执行有用的测试,很重要的是,开发并应用良好的测试案例(test cases)。仔细选择输入数据或操作那些反映边角(edge and corner)案例的条件,就可以做到。在我们的代码片段中(很简单的),我们想要知道代码如何在这三个指定条件下执行:

  1. dir_name 包含了一个现有的目录名。

  2. dir_name 包含了一个不存在的目录名。

  3. dir_name 为空值。

通过测试上述每个条件,就达到了好的测试覆盖(test coverage)。

如同设计一样,测试也是一个时间函数。不是每个脚本的功能都需要广泛的测试。确定什么是最重要的,真的很重要。由于如果发生故障,它可能具有潜在的破坏性,因此我们的代码片段在其设计和测试期间都应得到谨慎考虑。

调试

如果测试用脚本揭示了问题,下一步就是调试。「问题」通常意味着脚本,在某些方面,没有按程序员的期待在执行。如果是这样,我们需要仔细的确定脚本实际上做了什么,为什么这么做。查错,有时候涉及到一些侦探工作。

设计良好的脚本会有所帮助。它应该得到防御性地编程,以检测异常条件并提供有用的反馈用户。然而有时候,问题会相当奇怪和意外的,并需要涉及到更多的技术。

找到问题区域

在一些脚本中,特别是长脚本中,将与问题有关的那段脚本分离出来,有时会有所帮助。这不会总是成为实际上的错误,但是分离常常会提供对实际原因的审视。一项技术可以用来分离代码,就是「注释掉」脚本中的一些章节。例如,我们的文件删除片段可以被修改,以确定所移除的章节是否和一个错误有关。

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

在脚本中的一个逻辑段落的每一行的起始位置放置一个注释符号,以防止从该段落开始运行。测试可以被再次执行,以观察移除的代码是否对错误的行为有哪些影响。

追踪

错误通常是由脚本中不可预料的逻辑流事件。即,脚本中某些部分永远都没有被执行、或在错误的顺序或时间被执行。观察实际的程序流,我们使用一种称为跟踪(tracing)的技术。

一种跟踪方法涉及到在脚本中放置信息性消息以显示执行位置。我们可以把信息加到代码片段中。

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 选项和带有 -x 选项的 set 命令实现。使用早先的 trouble 脚本,在第一行中加入 -x 选项,就可以激活对整个文件的跟踪。

#!/bin/bash -x

# 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

当执行时,结果看起来是这样的:

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

随着激活了跟踪功能,我们看到了命令随扩展的应用而执行。为首的加号指示所显示的是跟踪,以区别于常规输出。加号是标记跟踪输出的默认字符。这包含在 shell 变量 PS4(prompt string 4)中。可以调整该变量的内容,使得提示更有用。这里,我们修改了变量的内容,以包含脚本中跟踪所在的当前行号。注意,在实际使用提示符之前,必须使用单引号以防止扩展。

[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

# 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 命令激活跟踪,并带用 +xset 命令关闭跟踪。这个技术可以用来检测有问题脚本的多个部分。

执行期间检查数值

跟踪过程中,显示变量的内容,了解当执行脚本时脚本内部的工作,通常很有用。应用额外的 echo 语句就可以做到这个技巧。

#!/bin/bash

# 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

在这个小示例中,我们简单显示了变量的值,并在额外加上去的行上标记了一段注释,以便随后找到并删除。当观察脚本中循环的行为和算术运算时,该技术特别有用。

总结

本章中,我们仅学习了脚本开发中可能碰到的一些问题。当然,还会有更多的问题。这里学习的技术能找到大多数的常见错误。调试是一种通过经验开发而成的精美艺术,既可以了解如何避免错误(在整个开发过程中不断进行测试),也可以查找错误(有效使用跟踪)。

扩展阅读

Last updated

Was this helpful?