《UNIX/Linux 系统管理技术手册(第四版)》——2.2 bash脚本编程

本节书摘来自异步社区《UNIX/Linux 系统管理技术手册(第四版)》一书中的第2章,第2.2节,作者:【美】Evi Nemeth , Garth Snyder , Trent R.Hein , Ben Whaley著,更多章节内容可以访问云栖社区“异步社区”公众号查看

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相反。

screenshot

bash对文件属性取值的那些选项是其出彩之处(还是其/bin/test遗留下来的特性)。bash有大量的测试文件和比较文件的运算符,表2.3列出了其中几个。
screenshot

虽然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来研究这些细微差别。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值