第2章 脚本和shell
好的系统管理员都要写脚本。脚本以标准和自动的方式履行系统管理员的繁杂事务,藉此把管理员的时间节省出来,以花在更重要和更有意思的任务上。从某种意义上讲,脚本也是一种低质量的文档,因为它们充当了一种权威提纲,提纲里列出完成特殊任务所需的步骤。
从复杂性来看,系统管理脚本的范围很广,小到一个脚本,简单得只封装几条静态命令,大到一个重要的软件项目,为整个站点管理主机配置和管理性数据。在本书里,我们所感兴趣的主要是系统管理员通常会碰到的较小的日常脚本项目。因此,对于较大项目才需要的支持功能(例如,bug追踪和设计评审),我们不会讲得太多。
系统管理脚本应该注重两点,即编程人员的开发效率和代码的清晰可读性。计算效率不应该成为关注重点,但这不应成为草率行事的借口,而是要认识到,很少需要在意一个脚本是在半秒还是两秒内运行完。优化脚本获得的回报都非常低,甚至对通过cron定期运行的脚本来说也不例外。
长期以来,编写系统管理脚本的标准语言都是shell所定义的语言。在大多数系统上,默认的shell都是bash(即“Bourne again” shell),但是在几种不多的UNIX系统上,也用sh(最初的Bourne shell)和ksh(Korn shell)。shell脚本一般用于轻量级的任务,如自动执行一系列命令,或者把几个过滤器组合起来处理数据。
各种操作系统上都有shell,所以shell脚本的可移植性相当好,除了它们调用的命令之外,要依赖的东西也不多。无论是否选择shell来编写脚本,都会碰到shell:大多数环境都包括对已有sh脚本的强大补充,系统管理员会频频阅读、理解和调整这些脚本。
对于更为复杂高端的脚本来说,建议转而采用一种真正的编程语言来写,像Perl或者Python这样的语言,它们两者都很适合于系统管理工作。这两种语言融入的设计理念比shell领先20年,它们的字处理功能(对于系统管理员来说,价值难以估量)如此强大,sh在它们面前黯然失色。
Perl和Python的主要缺点在于,建立它们的环境要麻烦一点儿,尤其是要用到的第三方库,而库里又包含已经编译好的部件的时候。shell没有模块结构,也没有第三方的库,因此避开了这个特殊的问题。
本章简要介绍了bash、Perl和Python作为脚本编程语言的用法,以及正则表达式这种通用的技术。
2.1 shell的基础知识
UNIX/Linux 系统管理技术手册(第四版)
在我们开始介绍shell的脚本编程之前,让我们先看看shell的一些基本特性和语法。不管读者正在使用的是何种平台,本节都适用于sh大家庭里的所有主流shell(包括bash和ksh,但不包括csh或者tcsh)。尝试一下自己不熟悉的sh形式,做做实验吧!
2.1.1 编辑命令
我们已经注意到一点,太多人都用方向键来编辑命令行。但读者朋友不会在文字编辑器里这么做,对吗?
如果喜欢emacs,那么在编辑命令历史的时候,所有的emacs基本命令都用得上。用到行尾,用到命令行的开头。用一条条回退到最近执行过的命令,重新把它们调出来进行编辑。用增量搜索命令历史找出老命令。
如果喜欢vi,那么用下面的命令就能让shell的命令行编辑进入vi模式:
$ set -o vi
和在vi里一样,编辑操作是有模式的;不过,一开始会进入输入模式。按键离开输入模式,按“i”键重新进入输入模式。在编辑模式下,“w”键向前进一个单词,“fX”在本行里找到下一个X,等等。用 k[译者注:即同时按下键和k键]可以遍历过去输入的命令。想要再次回到emacs编辑模式吗,可使用下面的命令实现
$ set -o emacs
2.1.2 管道和重定向
每个进程都至少有3个信道:“标准输入”(STDIN)、“标准输出”(STDOUT)和“标准出错”(STDERR)。内核给每个进程都设置了这3个信道,所以进程本身不必知道这三个信道通到哪里。举例来说,它们可能连接到一个终端窗口、一条网络连接,或者属于另一个进程的信道。
UNIX有一个统一的I/O模型,在这个模型中,每个信道都以一个整数来命名,它叫做文件描述符。分配给一个信道整数值到底是哪个,通常而言并没有意义,但要保证STDIN、STDOUT和STDERR对应文件描述符0、1和2,所以保险的做法是,用数字来引用这三个信道。在交互式的终端窗口里,STDIN一般读取键盘的输入,而STDOUT和STDERR把它们的输出写到屏幕上。
大多数命令都接受从STDIN来的输入,并且把自己的输出写到STDOUT,而把出错消息写到STDERR。有了这样的约定,用户就能把命令像积木一样串起来,创建出混合管道。
shell将<、>和>>解释成指令,用来把一条命令的输入或者输出重新定向到一个文件。<这个符号把这条命令的STDIN和已有的某个文件的内容联系起来。符号>和>>则重定向STDOUT;>会替换文件的现有内容,而>>则给文件追加内容。例如,下面的命令
$ echo "This is a test message." > /tmp/mymessage
在/tmp/mymessage这个文件里存入一行内容,如果必要,还会创建这个文件。下面的命令把该文件的内容用电子邮件发给用户johndoe。
$ mail -s "Mail test" johndoe < /tmp/mymessage
为了把STDOUT和STDERR都重定向到同一个地方,可以用>&这个符号。仅仅重定向STDERR的话,则用2>。
命令find演示了想要分开处理STDOUT和STDERR的原因,因为它会在两个信道提供输出,特别是以非特权用户身份运行的时候。例如,像下面这条命令
$ find / -name core
通常会导致很多“permission denied”这样的出错消息,从而把真正的结果给淹没在混乱的输出里了。要消除所有出错消息,可以用这条命令:
$ find / -name core 2> /dev/null
在这个版本的命令里,只有真正匹配的结果(该用户对父目录有读权的地方)才会出现在终端窗口中。要把匹配路径的清单保存在一个文件里,可以试试下面的命令:
$ find / -name core > /tmp/corefiles 2> /dev/null
这一行命令把匹配的路径发到/tmp/corefile这个文件,丢弃出错消息,向终端窗口什么都不发。
要把一条命令的STDOUT连接到另一条命令的STDIN上,可以用|这个符号,它常叫做管道。下面是一些管道的例子:
$ ps -ef | grep httpd
$ cut -d: -f7 < /etc/passwd | sort -u
第一个例子运行ps产生一份进程清单,由管道送给grep命令选出包含httpd这个词的若干行。grep命令的输出没有重定向,所以匹配的结果都出现在终端窗口里。
第二个例子用cut命令从/etc/passwd文件里把每个用户的shell的路径选出来。接着,列出的shell的路径都通过sort–u进行处理,产生的清单中,路径名不但依次排序,且路径名只出现一次。
要让第二条命令只有在第一条命令成功完成之后才执行,可以用一个&&符号把两条命令隔开。例如:
$ lpr /tmp/t2 && rm /tmp/t2
这条命令当且仅当/tmp/t2成功送入打印队列之后,才会删除/tmp/t2。在这里,lpr命令产生的退出码为0的话,就算它执行成功,所以,如果读者已经习惯了其他编程语言中的“短路”计算,而这里用一个表示“逻辑与”的符号,那么就可能造成混乱。不要想得太多;仅仅把它当做一个shell的习惯用法就行了。
相反,||这个符号表明,只有前一条命令执行不成功(产生了一个非零的退出码)时,才执行后面的命令。
在一个脚本里,可以用反斜线把一条命令分成多行来写,从而把出错处理代码和命令管道的其他部分区分开来:
cp --preserve --recursive /etc/* /spare/backup \
|| echo "Did NOT make backup"
要实现相反的效果——将多条命令整合在一行里——可以用分号作为语句分隔符。
2.1.3 变量和引用
变量名在赋值的时候没有标记,但在访问它们的值的时候要在变量名之前加一个$符。例如:
$ etcdir='/etc'
$ echo $etcdir
/etc
不要在等号两边留空白,否则shell会把变量名误认为是命令名。
引用一个变量的时候,可以用花括号把这个变量的名字括起来,让分析程序和阅读代码的人能清楚地知道变量名的起止位置;例如,用${etcdir}代替$etcdir。正常情况下不要求有这对花括号,但是,如果想要在双引号引起来的字符串里扩展变量,它们就会派上用场了。因为人们经常想要在一个变量的内容之后跟着字母或者标点符号。例如:
$ echo "Saved ${rev}th version of mdadm.conf."
Saved 8th version of mdadm.conf.
给shell变量起名字没有标准的命名规范,但如果变量名的所有字母都大写,一般表明该变量是环境变量,或者是从全局配置文件里读取的变量。本地变量则多半是所有字母都小写,而且在变量名的各个部分之间用下划线隔开。变量名区分大小写。
环境变量会被自动导入bash的变量名空间,所以它们可以用标准的语法来设置和读取。命令exportvarname将一个shell变量提升为一个环境变量。用来在用户登录时设置环境变量的那些命令,都应该放在该用户的~/.profile或者~/.bash_profile这两个文件里。而其他像PWD(代表当前工作目录)这样的环境变量都由shell自动维护。
对于用单引号和双引号括起来的字符串而言,shell以相似的方式处理它们,例外之处在于双引号括起来的变量可以进行替换(用*和?这样的文件名匹配元字符做扩展)和变量扩展。例如:
$ mylang="Pennsylvania Dutch"
$ echo "I speak ${mylang}."
I speak Pennsylvania Dutch.
$ echo 'I speak ${mylang}.'
I speak ${mylang}.
左引号也叫做撇号,对它的处理和双引号类似,但是它们还有其他作用,即能够把字符串的内容按一条shell命令来执行,并且用该命令的输出来替换这个字符串。例如:
$ echo "There are `wc -l /etc/passwd` lines in the passwd file."
There are 28 lines in the passwd file.
2.1.4 常见的过滤命令
任何“从STDIN读入数据,向STDOUT输出结果”这样循规蹈矩的命令,都可以当作一个过滤器(也就是说,管道的一个环节)来处理数据。在这一小节,我们简要回顾一些使用较为广泛的过滤器命令(包括上面已经用到过的一些命令),但是这样的过滤器命令实际上是无穷无尽的。过滤器命令多面向“集团作战”,所以有时候它们各自的用处很难单独体现出来。
大多数过滤器命令都接受在命令行上提供的一个或者多个文件名作为输入。只有在一个文件都未指定的时候,它们才从自己的标准输入读取数据。
cut:把行分成域
cut命令从它的输入行中选出若干部分,再打印出来。该命令最常见的用法是提取被限定的若干域,如2.1.2节里的例子所示,但是它也能返回由列边界所限定的若干区段。默认的限定符是,但是可以用-d选项改变这个限定符。-f选项指定输出里包括哪些域。
参考下面介绍uniq命令一节的内容,了解cut用法的例子。
sort:将行排序
sort命令对输入行进行排序。简单吧,不是吗?或许并不简单——到底按每行的哪些部分(即“关键字”)进行排序,以及进行排序的顺序,都可以做精细的调整。表2.1给出了一些比较常见的选项,但要查看手册页才能了解到其他选项。
下面的命令展示出了数值排序和字典排序的不同之处,默认按字典排序。这两条命令都用了-t:和-k3,3两个选项,对/etc/group文件的内容按照由冒号分隔的第三个域(即组ID)进行排序。第一条命令按照数值排序,而第二条命令则按照字母排序。
uniq:重复行只打印一次
$ sort -t: -k3,3 -n /etc/group1
root:x:0: bin:x:1:daemon daemon:x:2:
…
$ sort -t: -k3,3 /etc/group
root:x:0: bin:x:1:daemon users:x:100:
…
uniq: print unique lines
1
uniq命令在思想上和sort -u类似,但它有一些sort不能模拟的选项:-c累计每行出现的次数,-d只显示重复行,而-u只显示不重复的行。uniq命令的输入必须先排好序,因此通常把它放在sort命令之后运行。
例如,下面的命令显示出:有20个用户把/bin/bash作为自己的登录shell,12个用户把/bin/false作为登录的shell(后者要么是伪用户,要么就是账号被禁用的用户)。
$ cut -d: -f7 /etc/passwd | sort | uniq -c
20 /bin/bash
12 /bin/false
wc:统计行数、字数和字符数
统计一个文件里的行数、字数和字符数是另一项常用的操作,wc(表示word count,即字数统计)命令是完成这项操作的一条方便途径。如果不带任何参数运行wc,它会显示全部3种统计结果:
$ wc /etc/passwd
32 77 2003 /etc/passwd
在脚本编程的应用场合里,常给wc命令加上-l、-w或者-c选项,让它只输出一个数。在撇号里最常出现这种形式的命令,这样一来,命令的执行结果就可以被保存起来,或者根据执行结果确定下一步的操作。
tee:把输入复制到两个地方
命令的管道一般都是线性的,但是从中间插入管道里的数据流,然后把一份副本发送到一个文件里,或者送到终端窗口上,也往往会很有帮助。用tee命令就能做到这一点,该命令把自己的标准输入既发送到标准输出,又发送到在命令行上指定的一个文件里。可以把它想成是水管上接的一个三通。
设备/dev/tty是当前终端的同义语。例如:
$ find / -name core | tee /dev/tty | wc -l
该命令把名叫core的文件的路径名,以及找到的core文件的数量都打印出来了。
把tee命令作为一条执行时间很长的命令管道的最后一条命令,这是一种常见的习惯用法,这样一来,管道的输出既可以送到一个文件,又可以送到终端窗口供用户查看。用户可以预先看到一开始的输出结果,从而确保一切按预期执行,然后用户就可以在命令运行的同时不去管它,因为他们知道结果会被保存下来。
head和tail:读取文件的开头或者结尾
管理员会经常碰到一项操作,即查看一个文件开头或者结尾的几行内容。这两条命令默认显示前10行内容,但用户可以带一个命令行参数,指定到底要看多少行内容。
对于交互式的应用场合,head命令已经或多或少被less命令所取代,后者能够给被显示的文件标出页数,但是head命令仍然在脚本里大量使用。
tail也有一个不错的-f选项,对于系统管理员来说,这个选项特别有用。tail -f命令在按要求的行数打印完之后,不是立即退出,而是等着有新行被追加到文件末尾,再随着新行的出现打印新行——对于监视日志文件来说很有用。不过要注意,写文件的那个程序可能会缓冲它的输出。即使从逻辑上讲,新行是按有规律的时间间隔追加的,但它们可能只按1KiB或者4KiB的块来显示2。
键入即可停止监视。
grep:搜索文本
grep命令搜索给它输入的文本,打印出匹配某种模式的行。它的名字源于g/regular-expression/p这条命令,该命令是ed来的(UNIX系统仍然还有这种编辑器),ed是最早版本的UNIX上所带的一种老编辑器。
“正则表达式”是匹配文本的模式,它用一种标准的、能准确刻画的模式匹配语言来编写。尽管在不同的实现上,正则表达式存在轻微的变化,但它们是大多数做模式匹配的程序都要用到的一种通用标准。它之所以叫这么个奇怪的名字,是因为它起源于理论计算研究。我们将在2.3节更详细地讨论正则表达式。
和大多数过滤器一样,grep命令有许多选项,这其中包括:打印匹配行数的-c、匹配时忽略大小写的-i,以及打印不匹配行(而不是匹配行)的-v。另一个有用的选项是-l(L的小写),它让grep只打印匹配文件的名字,而不是匹配的每一行。例如,下面的命令
$ sudo grep -l mdadm /var/log/*
/var/log/auth.log
/var/log/syslog.0
表明mdadm的日志项出现在两个不同的日志文件里。
从传统上看,grep命令是一个相当基础的正则表达式引擎,但是有些版本的grep能够选择其他正则表达式的变体语法。例如,Linux上的grep -p命令选择采用Perl风格的表达式,虽然手册页警告说,它们还处在“实验初级阶段”。如果需要用这类正则表达式的完整功能,那么只能用Perl或者Python语言。