2.2 bash脚本编程
UNIX/Linux 系统管理技术手册(第四版)
bash特别适合编写简单的脚本,用来自动执行那些以往在命令行输入的操作。在命令行用的技巧也能用在bash的脚本里,反之亦然,这让用户在bash上投入的学习时间获得了最大的回报。不过,一旦bash脚本超过了100行,或者需要的特性bash没有,那么就要换到Perl或者Python上了。
bash脚本的注释以一个井号(#)开头,并且注释一直延续到行尾。和命令行中一样,可以把逻辑上的一行分成多个物理上的多行来写,每行末尾用反斜线消除换行符(newline)。还可以用分号分隔语句的办法,在一行里书写多条语句。
bash脚本可以只包含一系列的命令行,此外其他什么都没有。例如,下面的helloworld脚本就只有一条echo命令。
!/bin/bash
echo "Hello, world!"
第一行叫做“#!”语句,它声明这个文本文件是一个脚本,要由/bin/bash来解释。内核在决定如何执行这个文件的时候,要先找这个语句。从派生出来执行这个脚本的shell的角度来看,“#!”行只是一个注释行。如果bash不在这行指定的位置那里,那么就需要调整这行的内容。
要让这个文件做好能运行的准备,只要设置它的可执行位即可(参考6.5.5节)。
$ chmod +x helloworld
$ ./helloworld3
Hello, world!
1
还可以把shell当做解释程序直接调用:
$ bash helloworld
Hello, world!
$ source helloworld
Hello, world!
第一条命令在一个shell的新实例中运行helloworld脚本,第二条命令让当前的登录shell读取并执行这个文件的内容。当这个脚本用来设置环境变量,或者只对当前的shell做定制的时候,就采用后一种选择。在脚本编程中,这种形式常用来加入一个配置文件的内容,该文件里面写的是对一系列bash变量进行赋值2。
如果是Windows用户,那么可能已经习惯于这样的做法,即由文件的扩展名标明该文件的类型,以及是否可以执行。但在UNIX和Linux上,要由文件的权限位来指定一个文件是否可以执行,如果可执行,那么由谁可以执行。如果愿意,可以给自己的bash脚本加.sh后缀,提醒用户它们是什么文件,但在运行该命令的时候,就必须得输入.sh,因为UNIX不会对扩展名做特殊处理。参考6.5.1节了解有关权限位的更多知识。
2.2.1 从命令到脚本
在我们开始介绍bash的脚本编程特性之前,先讲一下方法。大多数人写bash脚本的时候,都按照和他们写Perl或者Python脚本一样的方式:用一个文本编辑器来写。不过,把常规的shell命令行当做一种交互式的脚本开发环境,考虑这样用的话,效果会更高。
例如,假定在一个目录层次结构中,散布着很多日志文件,它们的名字后缀为.log和.LOG,现在想把它们都改为大写的形式。首先,让我们看看是否能找到所有这样的文件。
$ find . -name ' *log '
.do-not-touch/important.log admin.com-log/
foo.log genius/spew.log leather_flog
…
哦,看起来我们要在搜索模式中包括点号(.),而且还要排除目录。键入重新找回这条find命令,然后对它进行修改。
$ find . -type f -name ' *.log '
.do-not-touch/important.log foo.log
genius/spew.log
…
好了,这次看上去结果更好了。不过,.do-not-touch目录看上去挺危险的;我们或许不应该让它出来捣乱。
$ find . -type f -name ' *.log ' | grep -v .do-not-touch
foo.log genius/spew.log
…
好了,正好剩下需要的文件清单。让我们生成一些新的名字。
$ find . -type f -name ' *.log ' | grep -v .do-not-touch | while read fname
> do
> echo mv $fname ${fname/.log/.LOG/}
> done
mv foo.log foo.LOG
mv genius/spew.log genius/spew.LOG
…
好,那几条命令就是我们想要的命令,把它们运行起来就可以执行改名操作。那么在现实中,我们该怎么做呢?我们可以把这条命令重新找回来,编辑一下把echo去掉,让bash执行mv命令,而不仅仅是打印mv命令。不过,用管道把这些命令都送到另一个shell的实例,这样更不容易出错,而且需要对前面命令做的编辑也更少。
当键入的时候,我们会发现bash考虑得很精心,它把这个小小的脚本变成了一行。对于这个紧凑的命令行,我们只要加一个管道,把输出送给bash -x就行了。
$ find . -type f -name ' *.log ' | grep -v .do-not-touch | while read fname; do echo mv $fname ${fname/.log/.LOG/}; done | bash -x
+ mv foo.log foo.LOG
+ mv genius/spew.log genius/spew.LOG
…
给bash加了-x选项后,它在执行每条命令之前,会先打印这条命令。
我们现在已经完成了实际的改名工作,但是仍然想把整个脚本保存下来,以便可以再次使用它。bash的内置命令fc和非常像,但它不是让上次的命令重新出现在命令行,而是把该命令送到用户选择的编辑器里。再加一个“#!”行和用法说明之后,把这个文件写到一个可以执行的地方(或许是~/bin,或者/usr/local/bin),让这个文件可执行,于是就得到了最终的脚本。
上述方法总结如下:
按一个管道的方式开发脚本(或者脚本的组成部分),一次开发一步,完全都在命令行上做;
把输出送到标准输出,检查并确保结果正确;
每开发一步,用shell的history命令重新找回命令管道,用shell的编辑功能调整它们;
在得到正确输出之前,都不实际执行任何操作,所以如果命令不正确,也不需要撤销什么操作;
一旦得到正确的输出,就真正执行命令,并核对命令能按预期要求工作;
用fc命令捕获工作结果,整理后保存下来。
在上面的例子里,我们打印出数行命令,然后用管道把它们送入一个子shell去执行。这一技术并不一定行得通,但它经常还是有帮助的。另一种做法是,可以把输出重定向到一个文件,得到这个结果。无论怎样,都要预先看到正确的结果,才做任何可能有破坏性的操作。
2.2.2 输入和输出
echo命令虽然原始,但易于使用。要想对输出做更多的控制,就需要使用printf命令。因为采用printf的话,必须显式地在必要的地方加换行符(用“\n”),所以它用起来稍有不便,不过它也能让用户使用制表符,而且能让输出里的数字有更好的格式。比较下面两条命令的输出。
$ echo "\taa\tbb\tcc\n"
\taa\tbb\tcc\n
$ printf "\taa\tbb\tcc\n"
aa bb cc
有些系统带有操作系统级的echo和printf命令,通常分别位于/bin和/usr/bin目录下。虽然这两条命令和shell的内置命令都很相似,但是它们的细节还是稍有不同,特别是printf,差别更大一些。对此,要么坚持采用bash的语法,要么用完整路径名调用外部的printf命令。
用read命令可以提示输入。下面是一个例子:
!/bin/bash
echo -n "Enter your name: " read user_name
if [ -n "$user_name" ]; then echo "Hello $user_name!" exit 0
else
fi
echo "You did not tell me your name!" exit 1
echo命令的-n选项消除了通常的换行符,但也可以在这里用printf命令。我们简要介绍一下if语句的语法,它的作用在这里很明显。if语句里的-n判断其字符串参数是否为空,不为空的话则返回真(true)。下面是这个脚本运行后的结果:
$ sh readexample Enter your name: Ron Hello Ron!
2.2.3 命令行参数和函数
给一个脚本的命令行参数可以成为变量,这些变量的名字是数字。$1是第一个命令行参数,$2是第二个,以此类推。$0是调用该脚本所采用的名字。这个名字可以是像../bin/example.sh这样的奇怪名字,所以它的取值并不固定。
变量$#是提供给脚本的命令行参数的个数,变量$*里保存有全部参数。这两个变量都不包括或者算上$0。
如果调用的脚本不带参数,或者参数不正确,那么该脚本应该打印一段用法说明,提醒用户怎样使用它。下面这个脚本的例子接受两个参数,验证这两个参数都是目录,然后显示它们。如果参数无效,那么这个脚本会打印一则用法说明,并且用一个非零的返回码退出。如果调用这个脚本的程序检查该返回码,那么它就会知道这个脚本没有正确执行。
!/bin/bash
function show_usage {
echo "Usage: $0 source_dir dest_dir" exit 1
}
Main program starts here if [ $# -ne 2 ]; then
show_usage
else # There are two arguments if [ -d $1 ]; then
source_dir=$1
else
fi
echo 'Invalid source directory' show_usage
if [ -d $2 ]; then dest_dir=$2
else
fi fi
echo 'Invalid destination directory' show_usage
printf "Source directory is ${source_dir}\n" printf "Destination directory is ${dest_dir}\n"
我们创建了一个单独的show_usage函数,用它打印用法说明。如果这个脚本以后又做了更新,能够接受更多的参数,那么只要在一个地方修改用法说明就行了3。
$ mkdir aaa bbb
$ sh showusage aaa bbb Source directory is aaa Destination directory is bbb
$ sh showusage foo bar
Invalid source directory
Usage: showusage source_dir dest_dir
bash函数的参数就按命令行参数那样处理。第一个参数变成$1,以此类推。正如上面的例子所示,$0是这个脚本的名字。
要让上面的例子更健壮一点儿,我们可以编写show_usage函数,让它接受一个出错码作为参数。对于执行不成功的每一种不同类型,返回一个定义好的出错码。下面的代码片段给出了该函数的样子。
function show_usage {
echo "Usage: $0 source_dir dest_dir" if [ $# -eq 0 ]; then
exit 99 # Exit with arbitrary nonzero return code
else
fi
}
exit $1
下面这个版本的函数,其参数可有可无。在一个函数内部,$#表明传入了多少个参数。如果没有提供更确定的出错码,那么这个脚本就返回代码99。但是如果给这个函数一个确定的出错码值,就会让脚本在打印用法说明之后以那个出错码退出,例如:
show_usage 5
(shell变量$?是上次执行的命令退出的状态,而且无论该命令是在一个脚本内部使用,还是在命令行上使用。)
在bash里,函数和命令之间很类似。用户可以在自己的~/.bash_profile文件里定义自己的函数,然后在命令行上使用它们,就好像它们是命令一样。例如,如果站点里统一将网络端口7988用于SSH协议(“不公开,即安全”的一种形式),就可以在~/.bash_profile文件里定义
function ssh {
/usr/bin/ssh -p 7988 $*
}
以保证ssh总是带选项-p7988来运行。和许多shell一样,bash也有一种别名机制,能更加简洁地再现上面这个限制端口的例子,不过采用函数的方法更通用,功能也更强。忘掉别名,采用函数吧。
2.2.4 变量的作用域
在脚本里的变量是全局变量,但是函数可以用local声明语句,创建自己的局部变量。考虑下面的代码:
#!/bin/bash
function localizer {
echo "==> In function localizer, a starts as ' $a' " local a
echo "==> After local declaration, a is ' $a' " a="localizer version"
echo "==> Leaving localizer, a is ' $a' "
}
a="test"
echo "Before calling localizer, a is ' $a' " localizer
echo "After calling localizer, a is ' $a' "
下面的日志显示在localizer函数内,局部变量$a屏蔽了全局变量$a。在localizer内,在碰到local声明了局部变量$a之前,全局变量$a都可见;local实际上是一条命令,它从执行的那个地方开始,创建局部变量。
$ sh scopetest.sh
Before calling localizer, a is 'test'
==> In function localizer, a starts as 'test'
==> After local declaration, a is ' '
==> Leaving localizer, a is 'localizer version' After calling localizer, a is 'test'
2.2.5 控制流程
我们在本章里已经见过几种if-then和if-then-else语句的形式;它们的功能在其名字中得以体现。一条if语句的结束标识是fi。要把几条if语句串起来,可以用elif这个关键字,它的意思是“else if”。例如:
if [ $base -eq 1 ] && [ $dm -eq 1 ]; then installDMBase
elif [ $base -ne 1 ] && [ $dm -eq 1 ]; then installBase
elif [ $base -eq 1 ] && [ $dm -ne 1 ]; then installDM
else
fi
echo '==> Installing nothing'
用[]做比较的奇特语法,以及整数比较运算符的名字(例如,-eq),都看上去像是命令行选项,它们二者都是从原来Bourne shell的/bin/test命令延续下来的。方括号实际上是调用test的一种快捷方式,而不是if语句的语法要求4。
表2.2给出了bash的数值和字符串比较运算符。bash比较数值采用文字运算符,而比较字符串采用符号运算符,这正好和Perl相反。
bash对文件属性取值的那些选项是其出彩之处(还是其/bin/test遗留下来的特性)。bash有大量的测试文件和比较文件的运算符,表2.3列出了其中几个。
虽然elif的形式能用,但是为了清楚起见,用case语句做选择是更好的方法。case的语法如下面的这个函数例程所示,该函数集中给一个脚本写日志。特别值得注意的是,每一选择条件之后有个右括号,而在条件符合时每个要执行的语句块之后有两个分号。case语句以esac结尾。
The log level is set in the global variable LOG_LEVEL. The choices
are, from most to least severe, Error, Warning, Info, and Debug.
function logMsg { message_level=$1 message_itself=$2
if [ $message_level -le $LOG_LEVEL ]; then case $message_level in
0) message_level_text="Error" ;;
1) message_level_text="Warning" ;;
2) message_level_text="Info" ;;
3) message_level_text="Debug" ;;
*) message_level_text="Other"
esac
echo "${message_level_text}: $message_itself" fi
}
这个函数演示了许多系统管理应用经常采取的“日志级别”方案。脚本的代码产生详尽程度不同的日志消息,但是只有那些在全局设定的阈值$LOG_LEVEL之内的消息才被真正记录到日志里,或者采取相应的行动。为了阐明每则消息的重要性,在消息文字之前用一个标签说明其关联的日志级别。
2.2.6 循环
bash的for…in结构可以让它很容易对一组值或者文件执行若干操作,尤其是和文件名通配功能(对诸如和?这样的模式匹配字符进行扩展,形成文件名或者文件名的列表)联合起来使用的时候。在下面这个for循环里,其中的.sh模式会返回当前目录下能够匹配的文件名列表。for语句则逐一遍历这个列表,接着把每个文件名赋值给变量$script。
#!/bin/bash
suffix=BACKUP--`date +%Y%m%d-%H%M`
for script in *.sh; do newname=”$script.$suffix”
echo "Copying $script to $newname..." cp $script $newname
done
输出结果如下:
$ sh forexample
Copying rhel.sh to rhel.sh.BACKUP--20091210-1708... Copying sles.sh to sles.sh.BACKUP--20091210-1708...
…
在这里的上下文关系中,对文件名做扩展并没有什么玄妙之处;它的做法就和在命令行上一模一样。也就是说,先扩展,然后再由解释器处理已经扩展过的这一行5。也可以静态地输入文件名,就像下面这行一样。
for script in rhel.sh sles.sh; do
实际上,任何以空白分隔的对象列表,包括一个变量的内容,都可以充当for …in语句的目标体。
bash也有从传统编程语言看来更为熟悉的for循环,在这种for循环里,可以指定起始、增量和终止子句。例如:
for (( i=0 ; i < $CPU_COUNT ; i++ )); do
CPU_LIST="$CPU_LIST $i" done
接下来的例子演示了bash的while循环,这种循环也能用于处理命令行参数,以及读取一个文件里的各行。
#!/bin/bash exec 0<$1
counter=1
while read line; do
echo "$counter: $line"
$((counter++))
done
下面是输出结果:
ubuntu$ sh whileexample /etc/passwd
1: root:x:0:0:Superuser:/root:/bin/bash
2: bin:x:1:1:bin:/bin:/bin/bash
3: daemon:x:2:2:Daemon:/sbin:/bin/bash
…
这个脚本片段有两个有趣的功能。exec语句重新定义了该脚本的标准输入,变成由第一个命令行参数指定的任何文件6。这个文件必须要有,否则脚本就会出错。
在while子句里的read语句实际上是shell的内置命令,但它的作用就和一条外部命令一样。外部命令也可以放在while子句里;在这种形式下,当外部命令返回一个非零的退出状态时,就会结束while循环。
表达式$((counter++))实际上是个丑小鸭。$((…))这样的写法要求强制进行数值计算。它还可以利用$来标记变量名。++是人们在C和其他语言中熟悉的后置递增运算符。它返回它前面的那个变量的值,但返回之后还要把这个变量的值再加1。
$((…))的技巧在双引号里也起作用,所以可以把整个循环体紧凑地写到一行里。
while read line; do
echo "$((counter++)): $line" done
2.2.7 数组和算术运算
复杂的数据结构和计算不是bash的特长。但它的确至少提供了数组和算术运算。
所有bash变量的值都是字符串,所以bash在赋值的时候并不区分数字1和字符串“1”。不同之处在于如何使用变量。下面几行代码展示出了其中的差异:
#!/bin/bash
a=1 b=$((2))
c=$a+$b d=$(($a+$b))
echo "$a + $b = $c \t(plus sign as string literal)"
echo "$a + $b = $d \t(plus sign as arithmetic addition)"
This script produces the output
这个脚本产生的输出如下:
1 + 2 = 1+2 (plus sign as string literal)
1 + 2 = 3 (plus sign as arithmetic addition)
注意给$c赋值的语句,其中的加号(+)连字符串的连接运行符都不是。它仅仅就是一个字符而已。那行代码等价于
c="$a+$b"
为了强制进行数值计算,要把这个表达式放在$((…))里面,就像上面给$d赋值那样。但即便如此,也不会让$d获得一个数值;它的值仍然保存为字符串“3”。
bash通常能够混合使用算术、逻辑和关系运算符;参考手册页了解详情。
bash中的数组有点儿怪,所以不常用到它们。然而,如果需要它们也依然可以用。数组用括号括起来,数组元素之间用空白隔开。数组元素中的空白要用引号引起来。
ample=(aa 'bb cc' dd)
单个数组元素用${array name [subscript]}来访问。下标从0开始。下标和@指整个数组,${#array name[]}和${#array name[@]}这两种特殊形式表示数组里元素的个数。不要把它们和似乎更合乎逻辑的${#array name}搞混了;后者实际上是数组第一个元素的长度(等价于${#array name[0]})。
读者可能会以为$example[1]是指数组的第二个元素,这一点无可争议,但bash对这个字符串的分析结果却是:$example(即$example[0]的简洁引用形式)加上一个字符串[1]。在访问数组变量的时候,一定要带花括号——这一点无一例外。
下面是一个快速脚本,它演示了bash中数组管理的一些功能和缺陷:
#!/bin/bash
example=(aa 'bb cc' dd)
example[3]=ee
echo "example[@] = $\{example[@]}"
echo "example array contains $\{#example[@]} elements"
for elt in "$\{example[@]}"; do echo " Element = $elt"
done
这个脚本的输出如下:
$ sh arrays
example[@] = aa bb cc dd ee example array contains 4 elements
Element = aa Element = bb cc Element = dd Element = ee
这个例子似乎很直观易懂,但只是因为我们已经把这个脚本构造得循规蹈矩了。人们一不小心就会犯错误。例如,用下面这一句替换for语句那行代码:
for elt in $\{example[@]}; do
(在数组表达式外面没有用引号引起来)也能行,但它却不是输出4个数组元素,而是5个:aa、bb、cc、dd和ee。
这背后的问题是,因为所有bash变量实质上仍然是字符串,所以数组的表象充其量还是不确定的。字符串什么时候分割成数字元素,怎样分割成数组元素,都有很多细微变化。读者可以使用Perl或者Python,或是用谷歌搜索Mendel Cooper的Advanced Bash-Scripting Guide来研究这些细微差别。