超越你的第一个 Shell 脚本
这就是你的第一个 shell 脚本
lpr weekly.report Mail boss < weekly.report cp weekly.report /floppy/report.txt rm weekly.report
你发现自己一遍又一遍地重复相同的几个命令:打印你的每周报告,邮寄一份给老板,将报告复制到软盘上,然后删除原始文件。因此,当有人向你展示你可以将这些命令放入一个文本文件(例如“dealwithit”)中,使用 chmod +x dealwithit 将该文件标记为可执行文件,然后只需键入其文件名即可运行它时,这节省了大量时间。
但是你想了解更多。你编写的这个脚本不是很健壮;如果在错误的目录中运行它,你会收到一连串难看的错误消息。而且这个脚本也不是很灵活——如果你想打印、邮寄、备份和删除其他文件,你将不得不创建另一个版本的脚本。最后,如果有人问你编写的是哪种 shell 脚本——Bourne?Korn?C shell?——你说不出来。那么请继续阅读。
最后一个问题:这是哪种 shell 脚本?实际上,上面的脚本非常通用。它只使用了所有 shell 都通用的功能。你很幸运。随着你的 shell 脚本变得更加复杂,你需要在开头放置一个指令,告诉操作系统这是哪种 shell 脚本。
#!/bin/bash
#! 应该是文件的前两个字符,其余部分应该是你希望此脚本运行的 shell 程序的完整路径名。细心的读者会注意到,这一行看起来像注释,而且,由于它以 # 字符开头,因此在语法上确实是注释。它也很神奇。
当操作系统尝试将文件作为程序运行时,它会读取文件的前几个字节(它的“魔数”)来了解它是什么类型的文件。字节模式 #! 表示这是一个 shell 脚本,并且接下来的几个字节,直到换行符,构成了操作系统应该真正运行的二进制文件的名称,并将此脚本文件提供给它。
有偏执狂的程序员会确保在 #! 行上的可执行文件名后不放置空格。你是有偏执狂,不是吗?很好。另外,请注意,运行 shell 脚本需要先读取它;这就是为什么你必须同时具有读取和执行权限才能运行 shell 脚本文件,而你只需要执行权限即可运行二进制文件。
在本文中,我们将重点介绍为 Bourne shell 及其后代编写程序。Bourne shell 脚本不仅可以在 Bourne shell 下完美运行,还可以在 Korn shell 下完美运行,Korn shell 添加了各种功能以提高效率和易用性。Korn shell 本身有两个后代:POSIX 标准 shell,它与 Korn shell 几乎相同;以及 Linux 用户的最爱 Bourne Again shell。Bourne Again shell (“bash”) 主要从 C shell 的后代中添加了交互式功能,Bill Joy 试图引入一种使用类似 C 的控制结构的 shell。真是个好主意!由于一些充分的理由,大多数 shell 编程都遵循了 Bourne shell 谱系树的一侧。但是人们喜欢 C shell 的交互式功能,这就是为什么它们也被纳入 Bourne Again shell 的原因。
让我换句话说:不要编写 C shell 脚本。如果你愿意,可以继续交互式地使用 C shell 或其后代 tcsh;你的作者就是这样做的。但是要学习并使用 Bourne/Korn/bash shells 进行脚本编写。
这就是我们现在的 shell 脚本
#!/bin/bash lpr weekly.report Mail boss < weekly.report cp weekly.report /floppy/report.txt rm weekly.report
如果我们在错误的目录中运行此脚本,或者如果我们不小心将文件命名为“weekly.report”以外的其他名称,则会发生以下情况
lpr: weekly.report: No such file or directory ./dealwithit: weekly.report: No such file or directory cp: weekly.report: No such file or directory rm: weekly.report: No such file or directory
如果我们在文件权限错误时运行脚本,我们会收到一堆“Permission denied”消息。呸。我们是否可以在程序开始时进行检查,以便在出现问题时可以避免所有这些难看的消息?当然可以,使用(惊喜!)if。
我们想到,如果 cat weekly.report 可以工作,那么我们脚本想要做的大部分事情也可以工作。shell 的 if 语句的工作方式正如这个想法所暗示的那样:你给 if 语句一个要尝试的命令,如果该命令成功运行,它也会为你运行其他命令。你还可以指定一些命令,以便在第一个命令(称为“控制命令”)失败时运行。让我们试一试
#!/bin/bash if cat weekly.report then lpr weekly.report Mail boss < weekly.report cp weekly.report /floppy/report.txt rm weekly.report else echo I see no weekly.report file here. fi
缩进不是强制性的,但确实使你的 shell 脚本更易于阅读。你可以将控制命令与 if 关键字本身放在同一行。
这个新版本在出现错误时效果很好。我们只收到一条“No such file or directory”消息,比之前的四条有所改进,然后出现我们有用的个性化错误消息。但是当脚本工作时,它并不是那么好:现在我们将 weekly.report 的内容作为初步信息转储到屏幕上。毕竟,这就是 cat 所做的。它就不能安静点吗?
你可能对 Unix 世界中重定向输入和输出有所了解:> 字符将命令的输出发送到文件,< 字符安排命令从文件中获取输入,就像我们的 Mail 命令一样。因此,如果我们只能将 cat 命令的输出发送到垃圾箱而不是发送到文件...等等!也许某个地方有一个垃圾箱文件。有:/dev/null。发送到 /dev/null 的任何输出都会从计算机的后部滴落出去。因此,让我们将 cat 命令更改为
cat weekly.report >/dev/null
因为你是有偏执狂的,你可能想知道将输出发送到垃圾箱是否会影响此命令是否成功或失败。由于 /dev/null 始终存在并且任何人都可以写入,因此它不会失败。
现在我们的脚本安静多了。但是当 cat 失败时,我们仍然会看到
cat: weekly.report: No such file or directory
错误消息。为什么这个也没有进入垃圾箱?因为错误消息与输出分开流动,即使它们通常共享一个共同的目的地:屏幕。我们重定向了标准输出,但没有说明错误。要重定向错误,我们可以
cat weekly.report >/dev/null 2>/dev/null
正如 > 表示“将输出发送到此处”一样,2> 表示“将错误发送到此处”。实际上,> 实际上只是 1> 的同义词。另一种更简洁的表达上述命令的方式是
cat weekly.report >/dev/null 2>&1
咒语 2>&1 的意思是“将错误(输出流编号 2)发送到普通输出(输出流编号 1)要去的地方。” 顺便说一句,这种 2> 的技巧仅在 Bourne shell 及其后代中有效。C shell 使将错误与输出分开变得很麻烦,这是人们避免在其中编程的原因之一。
你可能会对自己说:“这个 cat 技巧很有趣,但是有没有办法我可以只给出一个真或假表达式?例如,文件存在并且可读,或者不存在?” 是的,你可以。有一个命令的全部工作就是根据你给出的表达式是真还是假来成功或失败:test。顺便说一句,这就是为什么你名为“test”的测试程序永远不起作用的原因。这是我们的程序,重写为使用 test
#!/bin/bash if test -r weekly.report then lpr weekly.report Mail boss < weekly.report cp weekly.report /floppy/report.txt rm weekly.report else echo I see no weekly.report file here. fi
test 命令的 -r 运算符的意思是,“此文件是否存在,我可以读取它吗?” 无论 test 命令成功还是失败,它都是静默的,因此无需将任何内容发送到 /dev/null。
Test 还有另一种语法:你可以使用 [ 字符代替单词 test,只要你在行尾有 ] 即可。请务必在任何其他字符与 [ 和 ] 字符之间放置一个空格!我们现在可以使我们的 if 看起来像这样
if [ -r weekly.report ]
嘿,现在 这 看起来像一个程序了!即使我们正在使用方括号,这仍然是 test 命令。test 可以为你做很多其他事情;请参阅其手册页以获取完整列表。例如,我们似乎记得,让你删除文件的不是你是否可以读取它,而是它所在的目录是否为你提供了写入权限。因此,我们可以像这样重写我们的脚本
#!/bin/bash if [ ! -r weekly.report ] then echo I see no weekly.report file here. exit 1 fi if [ ! -w . ] then echo I will not be able to delete echo weekly.report for you, so I give up. exit 2 fi # Real work omitted...
现在每个 test 都有一个 ! 字符,这意味着“非”。因此,如果 weekly.report 不可读,则第一个 test 成功,如果当前目录(“.”)不可写,则第二个 test 成功。在每种情况下,脚本都会打印错误消息并退出。请注意,每次提供给 exit 的数字都不同。这就是 Unix 命令(包括 if 本身!)判断其他命令是否成功的方式:如果它们以任何非零退出代码退出,则它们不成功。除了“发生了一些不好的事情”之外,每个非零数字(最多 255)的含义取决于你。但是 0 始终表示成功。
如果这在你看来是倒退的,那就给自己一块饼干。它 确实 是倒退的。但是有一个很好的设计原因,并且它是通用的 Unix 命令约定,所以要习惯它。
另请注意,我们的实际工作不再有 if 包裹着它。只有在未检测到任何错误条件时,我们的脚本才会到达那里。因此,我们可以假设所有这些错误条件实际上都不存在!真正的 shell 脚本会无情地利用此属性,通常以屏幕上的测试开始,然后再进行任何实际工作。
现在我们已经使我们的脚本更加健壮,让我们致力于使其更通用。大多数 Unix 命令都可以从其命令行中获取一个参数,告诉它们要做什么;为什么我们的脚本不能?因为它的整个代码中都散布着“weekly.report”,这就是原因。我们需要将 weekly.report 替换为表示“命令行上的东西”的东西。认识一下 $1。
#!/bin/bash if [ ! -r $1 ] then echo I see no $1 file here. exit 1 fi if [ ! -w . ] then echo I will not be able to delete $1 for you. echo So I give up. exit 2 fi lpr $1 Mail boss < $1 # and so forth... exit 0
$1 表示命令行上的第一个参数。是的,$2 表示第二个,$3 表示第三个,依此类推。$0 是什么?命令本身的名称。因此,我们可以更改我们的错误消息,使其看起来像这样
echo $0: I see no $1 file here.
有没有注意到 Unix 错误消息会自我介绍?就是这样。
不幸的是,现在我们的程序面临一个新的威胁:如果用户忘记在命令行上放置参数怎么办?那么 $1 中应该包含的正确内容将是空。我们可能会回到我们的一连串错误消息,因为很多命令(例如 rm)会在你命令行上什么都不放时向你抱怨。在此程序的情况下,情况甚至更糟,因为 $1 第一次用作 test -r 的参数,如果你要求它测试 -r 什么都没有,test 会给你一个语法错误。如果你在 lpr 的命令行上什么都不放,lpr 会做什么?试试看!但要做好准备;你可能会最终得到一团糟。
幸运的是,test 可以提供帮助。让我们将其作为我们程序中的 第一个 测试,紧跟在 #!/bin/bash 之后
if [ -z "$1" ] then echo $0: usage: $0 filename exit 3 fi
现在,如果用户在命令行上什么都不放,我们将打印一条用法消息并退出。-z 运算符的意思是“这是一个空字符串吗?”。请注意 $1 周围的双引号:它们在这里是强制性的。如果省略它们,test 会在我们尝试检测的情况下给出错误消息。引号保护存储在 $1 中的空内容免于引起语法错误。
这个 if 子句出现在许多 shell 脚本的最顶部。除其他好处外,它还使我们不必在程序的后面用引号括起 $1,因为如果 $1 为空,我们将在开始时退出。实际上,仍然需要引号的唯一情况是 $1 可能包含对 shell 具有特殊含义的字符,例如空格或问号。文件名通常不会包含这些字符。
如果我们希望我们的脚本能够接受可变数量的参数怎么办?毕竟,大多数 Unix 命令都可以。一种方法很明确:我们可以只剪切并粘贴我们 shell 脚本中的所有内容,这样我们就会有一堆处理 $1 的命令,然后是一堆处理 $2 的命令,依此类推。听起来是个好主意吗?不是吗?对你来说很好;这是一个糟糕的主意。
首先,我们可以处理的参数数量会有一个固定的上限,这取决于我们何时厌倦了剪切、粘贴和编辑我们的脚本。其次,任何时候你拥有相同代码的多个副本,你都会有一个质量问题等待发生。你会忘记在所有必要的许多地方进行更改或修复错误。第三,我们经常在命令行上将通配符(例如 *)传递给 Unix 命令。这些通配符在命令运行之前会扩展为文件名列表!因此,很容易获得一个命令行,其参数数量超过某个任意的低限制。
也许我们可以使用某种算术技巧来遍历我们的参数,例如 $i 或其他东西。这也不起作用。表达式 $i 的意思是“名为 i 的变量的内容”,而不是“命令行上的第 i 个东西”。此外,并非所有 shell 都允许你引用 $9 之后的命令行单词,而那些允许你引用的 shell 会让你使用 ${10}、${11} 等等。
那么我们该怎么办?这样
while [ ! -z "$1" ] do # do stuff to $1 shift done
以下是我们如何阅读该脚本:“当 $1 中有内容时,我们处理它。在我们完成处理后,我们立即执行 shift 命令,该命令将 $2 的内容移动到 $1,将 $3 的内容移动到 $2,依此类推,无论有多少命令行参数。然后我们返回并再次执行所有操作。当 $1 中没有任何内容时,我们就知道我们已经完成了。”
这种技术使我们能够编写一个可以处理任意数量参数的脚本,同时一次只处理 $1。所以现在我们的脚本看起来像这样
#!/bin/bash while [ ! -z "$1" ] do # do stuff to $1 if [ ! -r $1 ] then echo $0: I see no $1 file here. exit 1 fi # omitted test... lpr $1 Mail boss < $1 # and so forth... shift done exit 0
请注意,我们将 if 嵌套在 while 内部。我们可以随意这样做。另请注意,此程序会在发现错误时立即退出。如果你希望它继续处理下一个参数而不是崩溃,只需将 exit 替换为
shift continue
continue 命令只是表示“现在回到循环的顶部,然后再次尝试控制命令。” 思考题:为什么我们必须在 continue 之前放置一个 shift?
这是一个潜在的问题:我们已经使某人可以轻松地在位于不同目录中的文件上使用此程序。但是我们只测试当前目录的可写性。相反,我们应该这样做
if [ ! -w `dirname $1` ] then echo $0: I will not be able to delete $1 for you. # ...
dirname 命令根据文件名路径名打印出文件所在的目录。如果你给 dirname 一个不以目录开头的文件名,它将打印“.”——当前目录。那些反引号呢?与所有其他类型的引号不同,它们并不意味着“这实际上是一整块,忽略空格。” 相反,反引号(也称为“重音符”)的意思是“在运行整个命令行之前,先运行反引号内的命令。捕获所有反引号命令的输出,并假装那是出现在较大命令行上的内容,而不是反引号中的垃圾。” 换句话说,我们将命令的输出替换为另一个命令行。
这就是我们 shell 脚本的最终版本
#!/bin/bash while [ ! -z "$1" ] do if [ ! -r $1 ] then echo $0: I see no $1 file here. shift continue fi if [ ! -w `dirname $1` ] then echo $0: I will not be able to delete $1 for you. shift continue fi lpr $1 Mail boss < $1 cp $1 /floppy/`basename $1` rm $1 shift done exit 0
给读者的练习:`basename $1` 做什么?
现在,你只需要了解另外两种技术就可以满足你绝大多数的脚本编写需求。首先,假设你真的需要计数。我们如何做相当于 C 语言 for 循环的事情?这是传统的 Bourne shell 方法
i=0 upperlim=10 while [ $i -lt $upperlim ] do # mess with $i i=`expr $i + 1` done
请注意,我们没有使用 for 关键字。for 用于完全不同的东西。相反,在这里我们将变量 i 初始化为 0,然后我们进入并保持在循环中,只要 i 中的值小于 10。(Fortran 程序员会将 -lt 识别为小于运算符;猜猜为什么在这种情况下不使用 >。)相当神秘的一行
i=`expr $i + 1`
调用 expr 命令,该命令评估算术表达式。我们使用反引号将 expr 的输出塞回 i 中。
丑陋,不是吗?而且速度也不是特别快,因为我们每次想将 1 添加到 i 时都运行一个命令。shell 就不能自己进行算术运算吗?如果 shell 是 Bourne shell,则不能。但是 Korn shell 可以
((i=i+1))
如果它有效,并且你不需要可移植性,请使用该语法。bash shell 使用类似的东西
i=$(($i+1))
它更具可移植性(甚至在 Korn shell 中也有效),因为它是由 POSIX 指定的,但仍然不适用于某些非 POSIX bourne shell。
那么 for 做什么?它允许你遍历项目列表,依次将变量分配给列表的每个元素。这是一个简单的例子
for a in Larry Moe Curly do echo $a done
这将打印
Larry Moe Curly
不那么简单的是,我们可以使用它来处理我们想要对变量中的每个单词执行某些操作的情况
mylist="apple banana cheese rutabaga" for w in $mylist do # mess with $w done
或对于与 shell 通配符模式匹配的每个文件
for f in /docs/reports/*.txt do pr -h $f $f | lpr done
或对于命令输出中的每个单词
for a in `cat people.txt` do banner $a done
这是你如何使用 for 来模拟你熟悉和喜爱的 C 语言 for 循环
for i in 0 1 2 3 4 5 6 7 8 9 10 11 do # mess with $i done
当然,使用这种语法很难有一个可变上限,这就是为什么我们通常使用上面显示的 while 循环。
恭喜!你现在已经看到了在绝大多数实用 shell 脚本中起作用的东西。前进并节省时间!
Brian Rice (rice@kcomputing.com) 是 K Computing 的技术人员,K Computing 是一家全国性的 Unix 和互联网培训公司。