书名:Bash Cookbook中文版
ISBN:978-7-115-52701-1
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
著 [加] 罗恩·布拉什(Ron Brash)
[加] 加内什·奈克(Ganesh Naik)
译 王林生
责任编辑 胡俊英
人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
网址 http://www.ptpress.com.cn
读者服务热线:(010)81055410
反盗版热线:(010)81055315
Copyright ©2018 Packt Publishing. First published in the English language under the title Bash Cookbook. All rights reserved.
本书由英国Packt Publishing公司授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式或任何手段复制和传播。
版权所有,侵权必究
Bash(Bourne-Again SHell)是为GNU计划编写的UNIX shell,现在是绝大多数Linux发行版和macOS X的默认shell。虽然各种操作系统的GUI界面已经非常完善,但是shell编程仍然起着不可忽视的作用。
本书将侧重点放在Ubuntu环境下的Bash用法上,通过8章内容,循序渐进地介绍了Bash脚本的编写技巧。本书涉及Bash相关的基础知识、文本与文件处理、理解并掌控文件系统等内容。
对Linux/UNIX系统管理员和运维人员来讲,shell也是他们每天都要打交道的一款功能强大的工具。因此,深入地了解和熟练地掌握shell编程,是每一个Linux/UNIX/OS X用户的必修功课之一。
王林生,工程硕士,副教授,目前主要从事Linux系统开发、物联网、电子信息技术的教学与研究工作,已在核心期刊发表论文十余篇。本书的翻译工作得到了“2019年度河南省高等职业学校青年骨干教师培养计划”的资助。
罗恩·布拉什(Ron Brash)是一家公司的CTO和联合创始人。他创立的公司是一家成功的技术咨询公司,提供了众多领域的服务,不过主要集中在自由开源软件(Free Open Source Software,FOSS)和Linux方面。7年多来,罗恩一直从事嵌入式系统的研究,为工控系统和数据采集与监控系统(Supervisory Control And Data Acquisition,SCADA)网络提供了安全性和网络连通性,在这些系统之上运行的都是经过优化的嵌入式Linux。罗恩会定期参加FOSS和社区活动,并在恰当的时机反馈意见,除此之外,他还举办小型研讨会,因为他非常乐于分享知识。
加内什·奈克(Ganesh Naik)同时身兼作者、顾问以及嵌入式Android、嵌入式Linux、物联网和ML相关产品开发的企业培训师。他在IT领域拥有20多年的经验和项目成就。曾在印度空间研究组织(Indian Space Research Organization)、英特尔、通用电气、三星、摩托罗拉、槟城技能开发中心(Penang Skills Development Center)以及新加坡和印度的多家公司担任企业培训师。他创办了一家名为Levana Technologies的公司,并从事咨询和培训活动。
法比奥·亚历山德罗·洛卡蒂(Fabio Alessandro Locati)(大家都称其为Fale)是Otelia的一名导演、公众演说家、作家和开源贡献者。他的主要专业领域是Linux、自动化、安全和云技术。法比奥拥有10多年的IT从业经验,其间为包括数十家《财富》500强公司在内的诸多企业提供过大量咨询服务。这使他能够从不同的角度考量技术,并由此形成了对于技术的批判性思维。
在本书中,我们将使用Bash(Bourne Again Shell)编写各种shell脚本,涉及简单的示例和复杂趁手的实用工具或程序。目前,Bash是大多数GNU/Linux发行版采用的默认shell,在Linux终端中可谓是无处不在。它能够应对各种任务,在Linux/UNIX生态系统中如鱼得水。换句话说,熟悉Bash命令行的用户可以自己动手将其安装在几乎所有的Linux系统中,基本上不用做什么改动就能够完成类似的任务。Bash脚本很少依赖其他软件,在极为精简的系统中(最小化安装),用户仍然可以编写出功能强大的脚本,实现任务自动化或协助执行重复性任务。
我们将侧重点完全放在了Ubuntu环境下的Bash用法,前者是一种很常见的Linux发行版,但是书中的脚本应该可以相对轻松地移植到其他发行版。本书并不是专门为 macOS或Windows操作系统所写的,尽管移植到这些操作系统也并非不可能。
本书适用于编写Bash脚本的高级用户或系统管理员,他们希望实现任务自动化或提高自身在命令行上的工作效率。例如,不用去记住执行特定操作的一系列命令,而是将所有这些命令放入专用于该任务的脚本中,同时还能验证输入和格式化输出。利用强大的工具节省时间并减少错误,何乐而不为?
如果你对自动化系统基础设施执行的各种复杂的日常任务(例如,在系统引导时启动脚本或通过cron作业调度任务)感兴趣,本书也是理想之选。
第1章涵盖Linux shell/Bash的基础知识,为本书的其余部分做好准备。
第2章介绍了一些增强技术,提升 Bash 在搜索条目和文本或自动化文件系统操作时的全面性。
第3章将帮助你从多个角度浏览文件:head、tail、less和more,按照名称或扩展名搜索文件,用diff命令比较两个文件,修补文件,创建并高效使用符号链接,爬取文件系统目录,打印目录树等。
第4章是关于如何仿制应用程序功能,例如菜单或守护进程。
第5章介绍了日志、归档、作业/任务管理、网络连通性、使用防火墙(iptables)加固系统、监视目录改动、用户创建。
第6章讲述了使用logger命令创建syslog条目、制作备份、在命令行中创建图形化界面、检查文件的完整性、挂载网络文件系统、检索文件、浏览 Web、捕获网络流量、查找可执行文件依赖关系、加密和解密文件。
第7章讲述了如何使用命令和脚本完成多项任务。你也会从中了解到如何编写Bash脚本来监视某些任务。
第8章将帮助你学习高级脚本技术和如何定制shell。
作为作者,我们希望本书对你有所帮助,能够从中学到多种不同的Bash编程方法。为了发挥它的最大功效,我们鼓励你:
但是,这本书假定你在阅读前具备一定程度的相关知识,这些内容不会再在书中讲述:
为了精通Bash使用技巧,如果你是一名解决问题的老手,具备快速学习能力,我们建议你全力以赴,或是先参阅下列资源:
CodeInText
:表明文本中出现的代码、数据表名、目录名、文件名、文件扩展名、路径、URL、用户输入、Twitter。下面是一个例子:“完整的路径更为具体,而且采用的是硬编码形式;解释器会尝试使用完整路径。例如,/bin/ls或/usr/local/bin/myBinary..”。
代码块形式如下:
#!/bin/bash
AGE=17
if [ ${AGE} -lt 18 ]; then
echo "You must be 18 or older to see this movie"
fi
命令行输入或输出形式如下:
rbrash@moon:~$ history
1002 ls
1003 cd ../
1004 pwd
1005 whoami
1006 history
粗体:表明新的术语、重要的话或屏幕内容。例如,出现在正文中的菜单或对话框中的内容就像这样。
警告或重要的提示。
建议和窍门。
在本书中,你会发现一些频繁出现的标题(准备工作、实战演练、工作原理、补充内容、参考)。
为了清晰地说明如何完成攻略,我们用到了以下小节标题。
这里将说明该攻略的预期,描述事先如何设置软件或需要进行哪些配置。
这里包含的是完成该攻略所需要的操作步骤。
这里通常详细解释了上一节中的来龙去脉。
为了加深读者的理解,这里包含了一些与该攻略相关的额外信息。
这里提供了另外一些有用的链接。
本章的主要目的是向你传授足够的Linux shell/Bash知识,帮助你顺利上手,书中的其余部分已经就绪,就等你去探索了。
在这一章里,我们将涵盖下列主题:
本章将为你提供掌握书中其余章节的攻略所需的基本知识。
首先,我们要打开一个Linux终端(terminal)或shell。根据你偏好的Linux(发行版),这一步有好几种实现方式,但是在Ubuntu中,最简单的方式就是进入Application菜单,从中找到名为Terminal的菜单项。用户可以在终端或shell中输入命令,然后这些命令会在相同的shell中执行。简单地说,在其中显示命令执行结果(如果有的话),然后终端仍保持打开状态,等待继续输入新的命令。只要打开shell,就会出现一个提示符(prompt),类似于下面这样:
rbrash@moon:~$
提示符采用username@YourComputersHostName的格式,后面跟着一个分隔符。在本书中,你会看到很多命令前面的提示符里都带有rbrash,这是作者名字(Ron Brash)的简写,对于读者个人,则对应的是你自己的用户名。
可能还会看到下面这样的提示符:
root@hostname#
$表示普通用户,#表示root。在Linux和UNIX的世界里,root指的是root用户,类似于Windows中的管理员(Administrator)用户。该用户的权限很大,所以在使用拥有root权限的用户时务必小心。例如,root用户可以访问操作系统的所有文件,也可以删除操作系统使用的任何或全部的关键文件,这会使系统故障或崩溃。
当运行终端或Bash shell时,会有一个记录了用户特定参数和命令的配置文件。该配置文件通常名为.bashrc,其中可以包含命令别名、快捷方式、环境变量以及其他用户增强功能(例如提示符颜色),该文件位于~/.bashrc或~/.bash_profile。
~或~/是用户主目录的简写法,等同于/home/yourUser Name/,对于root用户,则是/root。
用户的Bash shell也包含了该用户运行的所有命令的历史记录(位于~/.bash_history),可以通过history命令访问,如下所示:
rbrash@moon:~$history
1002ls
1003cd../
1004pwd
1005whoami
1006history
举例来说,第一个命令可能是使用ls确定目录内容。cd命令用于将目录更改到父目录。pwd命令用于返回工作目录的完整路径(即终端当前所在的位置)。
你可能会在shell中执行的另一个命令是whoami,该命令可以返回当前登录的用户:
rbrash@moon:/$whoami
rbrash
rbrash@moon:/$
既然命令是逐个输入的,我们就可以把这些(或任意)命令放入到一个shell脚本中。从最简单的形式来看,shell脚本类似于下面这样:
#!/bin/bash
#Poundorhashsignsignifiesacomment(alinethatisnotexecuted)
whoami #Commandreturningthecurrentusername
pwd #Commandreturningthecurrentworkingdirectoryonthefilesystem
ls #Commandreturningtheresults(filelisting)ofthecurrent
workingdirectory
echo"Echoone1";echo"Echotwo2"#Noticethesemicolonusedto
delimitmultiplecommandsinthesameexecution.
第一行包含解释器的路径,告诉shell使用哪个解释器解释该脚本。这行始终包含shebang(#!)[1]和解释器路径名,两者之间没有空格。[1]
#!/bin/bash
脚本自身不能执行,需要由用户、其他程序、系统或别的脚本来调用。脚本的执行还需要其自身拥有可执行权限,该权限可以由用户使用chomd命令授予。
要想添加或授予基本的可执行权限,请使用下列命令:
$chmoda+xscript.sh
可以采用下列任意一种方法执行脚本:
$bashscript.sh #iftheuseriscurrentlyinthesamedirectoryasthescript
$bash/path/to/script.sh #Fullpath
如果权限没有问题,shebang 和 Bash 解释器路径也正确,用下面两种方法也能执行script.sh:
$./script.sh #iftheuseriscurrentlyinthesamedirectoryasthescript
$/path/to/script.sh #Fullpath
你可能注意到前面的几个命令片段中有些地方和路径有关。在指定脚本、文件、可执行文件的路径时,可以使用相对路径或完整路径。相对路径实际上告诉了解释器要执行的文件位于当前目录或用户的全局shell变量$PATH所包含的目录之下。例如,系统知道可执行的二进制文件都保存在/usr/bin、/bin、/sbin,首先查找的就是这些地方。完整路径则更具体,也是硬编码的(hardcoded),解释器会尝试直接使用该路径,例如/bin/ls或/usr/local/bin/myBinary。
当你要执行的二进制可执行文件正位于当前目录下,可以使用./script.sh、bash script.sh,甚至直接用完整的路径来运行该文件。显然,每种方法各有利弊。
如果你知道可执行文件在系统中的确切位置,出于潜在的安全性考虑或系统配置的原因无法指望$PATH 变量,那么硬编码或完整路径就能排上用场了。如果要求灵活性,那就用相对路径。例如,程序ABC可以位于/usr/bin或/bin,你可以简单地使用ABC来调用它,而不用写成/pathTo/ABC。
到目前为止,我们描述了基本的Bash脚本长什么样,简要地介绍了几个非常基础,但又必不可少的命令和路径。但是,要想创建脚本,你得有编辑器!在Ubuntu中,有几款默认的编辑器可以用来创建Bash脚本:vi/vim、nano、gedit。除此之外,还有很多其他的文本编辑器或集成开发环境(Integrated Development Editor,IDE)可用,至于究竟用哪一个,那就看个人喜好了。本书中所有的示例和攻略适用于任何文本编辑器。
如果用不了像Eclipse这种流行的全能型编辑器,Emacs或Geany也可以作为资源受限环境下(例如,树莓派)的IDE,同样也很灵活。
当你在终端上通过SSH远程创建或修改脚本的时候,如果会用vi/vim和nano,那就太方便了。vi/vim看起来可能有些古老,但如果你惯用的编辑器没有安装或无法使用的时候,它可是能为你节省大把时间的。
让我们先来用vi的增强版(称为vim)创建一个脚本。如果vim尚未安装,可以使用sudo或root,按照下列命令安装(-y是yes的简写)。
对于Ubuntu或基于Debian的发行版,有以下命令:
$sudoapt-get-yinstallvim
对于CentOS或RHEL,有以下命令:
$sudoyuminstall-yvim
对于Fedora,有以下命令:
$sudodnfinstall-yvim
打开终端,输入下列命令,先看看你当前所在的位置,然后使用vim创建脚本:
$pwd
/home/yourUserName
$vimmy_first_script.sh
终端窗口会变成vim应用的界面(类似于图1-1),接下来就可以编写你的第一个脚本了。同时按<Esc+I>组合键,进入插入模式,此时左下角会有提示文字,同时光标块开始闪烁,如图1-1所示。
图1-1
你可以使用多种快捷键方式在vim中移动,不过控制光标上下左右移动最简单的方法就是用箭头键。将光标移动到第一行开头位置,输入下列内容:
#!/bin/bash
#Echothisismyfirstcomment
echo"Helloworld!ThisismyfirstBashscript!"
echo-n"Iamexecutingthescriptwithuser:"
whoami
echo-n"Iamcurrentlyrunninginthedirectory:"
pwd
exit0
我们已经介绍过了注释的概念和几个基本命令,但还没讲过灵活的 echo 命令。echo命令可用于向终端或文件输出文本,-n选项可以输出不带换行符的文本(换行符的效果和在键盘上按Enter键一样),这使得whoami和pwd命令的输出能够出现在同一行中。
该脚本在退出的时候带有一个为0的状态码,这表示正常退出。等我们随后讲到使用命令退出状态码检查错误的时候,会再谈及这个话题。
当脚本输入完成后,按Esc键,退出插入模式,返回到命令模式并输入:wq。总结一下,也就是使用下列按键,先按Esc键,然后再按<:wq>组合键。这样就可以将文件写入磁盘(w),然后退出(q)并返回到终端。
关于vim的更多信息可以参见其Linux手册。
输入bash my_first_script.sh,执行你的第一个脚本,终端会返回下列输出:
$bashmy_first_script.sh
Helloworld!ThisismyfirstBashscript!
Iamexecutingthescriptwithuser:rbrash
Iamcurrentlyrunninginthedirectory:/home/rbrash
$
恭喜!你已经创建并执行了自己的首个脚本。有了这些技能,你就可以开始创建更为复杂的脚本,简化日常命令行工作并实现其自动化。
思考变量的最佳方法是将其视为值的预留位(placeholder)。变量可以是永久性的(静态),也可以是临时的(动态),它们还有作用域的概念(随后会讲到)。要想使用变量,我们不妨考虑刚写好的那个脚本(my_first_script.sh)。在这个脚本中,我们能够很容易地使用变量来包含静态值,或者在每次运行脚本时,运行命令所创建的动态值。例如,如果你想使用PI的值(3.14),可以在脚本中这样使用变量:
PI=3.14
echo "The value of PI is $PI"
如果将其包含在一个完整的脚本中,则会输出:
The value of Pi is 3.14
注意,为变量设置值(3.14)叫做赋值。我们为变量PI赋值3.14,也可以使用$PI引用PI变量。方法有以下几种:
echo "1. The value of PI is $PI"
echo "2. The value of PI is ${PI}"
echo "3. The value of PI is" $PI
输出如下:
1. The value of PI is 3.14
2. The value of PI is 3.14
3. The value of PI is 3.14
尽管输出是一样的,但实现机制略有不同。在版本1中,我们在双引号中引用PI变量,双引号用于指明字符串(一系列字符)。我们也可以使用单引号,这样产生的是字符串字面量。在版本2中,我们在{}里引用PI变量,这样有助于避免变量名被误解。来看下面的例子:
echo "1. The value of PI is $PIabc" # Since PIabc is not declared, it will be empty string
echo "2. The value of PI is ${PI}" # Still works because we correctly referred to PI
如果使用未声明的变量,则该变量内容为空。
下列命令将数值转换为字符串形式。在例子中,$PI是一个包含数值的变量,但是也可以像下面这样创建PI变量:
PI="3.14" # Notice the double quotes ""
这样一来,该变量中包含的就不再是数值(例如,整数或浮点数),而是一个字符串。
本书不会详细讨论数据类型。这个主题最好还是留给读者们自己探索,因为这是编程语言和计算机中的一个基本概念。
等等!你说数字和字符串有区别?肯定有啊,如果没有做转换(或者一开始没有正确地设置),则会限制变量的用途。例如,3.14不同于3.14(数值)。3.14由4个字符组成:3、.、1、4。如果我们想对字符串形式的PI执行乘法运算,运算或脚本要么运算出错,要么得到的结果毫无意义。
有关类型转换的更多内容,详见第2章。
假如我们想将一个变量的值赋给另一个变量,可以这么做:
VAR_A=10
VAR_B=$VAR_A
VAR_C=${VAR_B}
如果把上面的代码片段放到一个正常的Bash脚本中,各个变量的值都将为10。
打开一个空白文件,添加下列内容:
#!/bin/bash
PI=3.14
VAR_A=10
VAR_B=$VAR_A
VAR_C=${VAR_B}
echo "Let's print 3 variables:"
echo $VAR_A
echo $VAR_B
echo $VAR_C
echo "We know this will break:"
echo "0. The value of PI is $PIabc" # since PIabc is not declared, it will be empty string
echo "And these will work:"
echo "1. The value of PI is $PI"
echo "2. The value of PI is ${PI}"
echo "3. The value of PI is" $PI
echo "And we can make a new string"
STR_A="Bob"
STR_B="Jane"
echo "${STR_A} + ${STR_B} equals Bob + Jane"
STR_C=${STR_A}" + "${STR_B}
echo "${STR_C} is the same as Bob + Jane too!"
echo "${STR_C} + ${PI}"
exit 0
注意变量的命名方式。使用标准化方法命名变量固然不错,但如果要多次用到变量的话,使用STR_A和VAR_B显然描述性不足。以后,我们会使用更具有描述性的名称,例如,VAL_PI表示PI的值,STR_BOBNAME表示Bob名字的字符串描述。在Bash中,大写通常用来描述变量,因为这样子更清晰。
保存文件之后,退回到终端(如果还没打开终端的话,现在打开一个)。为脚本赋予适合的权限之后,再执行脚本,你应该会看到下列输出:
Lets print 3 variables:
10
10
10
We know this will break:
0. The value of PI is
And these will work:
1. The value of PI is 3.14
2. The value of PI is 3.14
3. The value of PI is 3.14
And we can make a new string
Bob + Jane equals Bob + Jane
Bob + Jane is the same as Bob + Jane too!
Bob + Jane + 3.14
首先,我们看到了程序是如何使用这3个变量的:先分别为其赋值,然后输出其内容。其次,我们看到如果变量名和其他字符串拼接在一起的话,解释器会出错(记住这一点)。再次,输出变量PI的值并使用echo将其与字符串拼接。最后,我们进行了多种形式的拼接,包括最后一个版本,它将变量转换成数值,然后追加到字符串之后。
等一下,还有隐藏变量和保留字?当然!有些单词已经被预先保留了,要想改作他用的话,除非正确地将其包含在某种语法构件中(例如,字符串)。全局变量可用于全局上下文,这意味着当前shell中的所有脚本或已打开的终端内都能使用这种变量。在后续章节中,我们会更多地接触到全局变量,现在你只需要知道有一些有用的全局变量可供使用即可,例如$USER、$PWD、$OLDPWD、$PATH。
可以使用env命令查看所有的shell环境变量(下面的输出做了删减):
$ env
XDG_VTNR=7
XDG_SESSION_ID=c2
CLUTTER_IM_MODULE=xim
XDG_GREETER_DATA_DIR=/var/lib/lightdm-data/rbrash
SESSION=ubuntu
SHELL=/bin/bash
TERM=xterm-256color
XDG_MENU_PREFIX=gnome-
VTE_VERSION=4205
QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1
WINDOWID=81788934
UPSTART_SESSION=unix:abstract=/com/ubuntu/upstart-session/1000/1598
GNOME_KEYRING_CONTROL=
GTK_MODULES=gail:atk-bridge:unity-gtk-module
USER=rbrash
....
修改环境变量PATH颇为有用,但也让人抓狂,因为其中包含了可执行文件的路径(例如,/bin或/sbin或/usr/bin)。借助PATH,当你执行某个命令时,不需要指定具体路径就可以直接运行该命令。
好了,我们已经知道预定义变量的存在以及用户或其他脚本可以创建新的全局变量。一定要小心,如果使用的变量大概率会出现名称相似的情况,请将这些变量限制在脚本中。
除了隐藏变量,还有一些单词在脚本或shell中是保留的。例如,if和else这两个词用于为脚本提供条件逻辑。想象一下,如果你创建的命令、变量或函数等也采用了这些保留词会怎么样?脚本可能会出错或执行错误的操作。
如果想避免出现命名冲突(或者名称空间冲突),可以尝试添加不太会重复的前缀或后缀,让变量更多地只由自己的脚本来使用。
下面的列表包含了一些常见的保留字。其中一些可能看上去非常眼熟,它们告诉Bash这些文本具有特殊含义:重定向输出、在后台运行程序,有些保留字在其他编程/脚本语言中也有用到。
if、elif、else、fi
while、do、for、done、continue、break
case、select、time
function
&;、|、>、<、!、=
#、$、(、)、;、{、}、[、]、\
列表的最后一行中包含了多个具有特殊作用的字符。例如,#表示注释。但是,\是一个非常特殊的存在,作为转义字符,它用于转义或阻止shell对这些字符做特殊处理。例如:
$ echo # Comment
$ echo \# Comment
# Comment
在处理字符串和单/双引号的时候,转义字符尤其有用,这些我们会在第2章中看到。
转义字符会对紧紧随其后的字符进行转义。但在处理换行符(\n、\r\n)和空字节(\0)时,并非如此。
在上一节中,我们说过存在一些保留字和多个能够影响Bash操作的特殊字符。最基本、可能也是用途最广的条件逻辑是if和else语句。来看一段示例代码:
#!/bin/bash
AGE=17
if [ ${AGE} -lt 18 ]; then
echo "You must be 18 or older to see this movie"
fi
注意if语句中方括号前后的空格。Bash对于括号语法尤其挑剔。
如果我们使用小于号(<)或-lt对变量AGE进行评估(Bash提供了多种可评估变量的语法构件),需要使用if语句。在if语句中,如果$AGE小于18,就输出消息“You must be 18 or older to see this movie”。否则,就跳过echo语句,继续往下执行。注意,if语句以保留字fi结尾。这可不是错误,而是Bash语法的要求。
我们使用else添加一个分支。如果if语句的条件无法满足,则执行else:
#!/bin/bash
AGE=40
if [ ${AGE} -lt 18 ]
then
echo "You must be 18 or older to see this movie"
else
echo "You may see the movie!"
exit 1
fi
如果AGE设置为整数值40,则无法满足if语句的条件,因此程序将执行else命令块。
如果我们想引入另一个if条件,可以使用elif(else if的缩写):
#!/bin/bash
AGE=21
if [ ${AGE} -lt 18 ]; then
echo "You must be 18 or older to see this movie"
elif [ ${AGE} -eq 21 ]; then
echo "You may see the movie and get popcorn"
else
echo "You may see the movie!"
exit 1
fi
echo "This line might not get executed"
如果AGE被设置为21,那么这段代码则输出:
You may see the movie and get popcorn
This line might not get executed
使用if、elif、else,配合其他评估,我们就可以执行特定的逻辑分支和功能,甚至是退出脚本。下列操作符可以评估数值:
-gt
(大于>)-ge
(大于或等于>=)-lt
(小于<)-le
(小于或等于<=)-eq
(等于)-nq
(不等于)在1.2节中提到过,数值不同于字符串。字符串通常像下面这样评估:
#!/bin/bash
MY_NAME="John"
NAME_1="Bob"
NAME_2="Jane"
NAME_3="Sue"
Name_4="Kate"
if [ "${MY_NAME}" == "Ron" ]; then
echo "Ron is home from vacation"
elif [ "${MY_NAME}" != ${NAME_1}" && "${MY_NAME}" != ${NAME_2}" &&
"${MY_NAME}" == "John" ]; then
echo "John is home after some unnecessary AND logic"
elif [ "${MY_NAME}" == ${NAME_3}" || "${MY_NAME}" == ${NAME_4}" ]; then
echo "Looks like one of the ladies are home"
else
echo "Who is this stranger?"
fi
在上面的代码片段中,对变量MY_NAME进行条件判断之后,在终端输出字符串“John is home after some unnecessary AND logic”。其中的逻辑流程如下:
1.如果MY_NAME等于Ron,那么执行echo "Ron is home from vacation"。
2.否则,如果MY_NAME不等于NAME_1且MY_NAME不等于NAME_2且MY_NAME等于John,那么执行echo "John is home after some unnecessary AND logic"。
3.否则,如果MY_NAME等于NAME_3或MY_NAME等于NAME_4,那么echo "Looks like one of the ladies"。
4.否则,执行echo "Who is this stranger?"。
注意操作符&&、||、==、!=,相关含义和解释如下:
&&
(与)||
(或)==
(相同)!=
(不相同)-n
(字符串长度不为空)-z
(字符串长度为空)
在计算世界里,空(null)意味着未设置或什么都没有。在脚本中可以使用很多不同类型的操作符或测试。
你也可以使用(("$a" > "$b"))或[[ "$a" > "$b" ]]将数值作为字符串评估。注意双括号和双中括号的用法。
如果你觉得单层if语句不够用,希望在其中更多的逻辑,那么可以创建嵌套条件语句。实现方式如下:
#!/bin/bash
USER_AGE=18
AGE_LIMIT=18
NAME="Bob" # Change to your username if you want to execute the nested logic
HAS_NIGHTMARES="true"
if [ "${USER}" == "${NAME}" ]; then
if [ ${USER_AGE} -ge ${AGE_LIMIT} ]; then
if [ "${HAS_NIGHTMARES}" == "true" ]; then
echo "${USER} gets nightmares, and should not see the movie"
fi
fi
else
echo "Who is this?"
fi
除了if和else语句,Bash还提供了case或swith语句和循环结构,可用于简化逻辑,并提高代码的可读性和可持久性。试想一下,创建一个带有多个elif的if语句是什么样子。这种写法会变得非常烦琐!
#!/bin/bash
VAR=10
# Multiple IF statements
if [ $VAR -eq 1 ]; then
echo "$VAR"
elif [ $VAR -eq 2]; then
echo "$VAR"
elif [ $VAR -eq 3]; then
echo "$VAR"
# ...to 10
else
echo "I am not looking to match this value"
fi
在包含大量条件逻辑的if和elif中,每个if和elif都需要在执行特定的代码分支之前进行评估。使用case/switch语句会更快,因为只需要执行首个匹配条件的代码(而且看起来也更漂亮)。
除了if/else语句,你也可以使用case语句来评估变量。注意,esac是反写,用于结束case语句,类似于if语句的fi。
case语句的形式如下:
case $THING_I_AM_TO_EVALUATE in
1) # Condition to evaluate is number l(could be "a" far a string too!)
echo "THING_I_AM_TO_EVALUATE equals 1"
;; # Notice that this is used to close this evaluation
*) # *Signified the catchall(when THING I AM TO EVALUATE does not equal values in the switch)
echo "FALLTHOUGH or default condition"
esac #Close case statement
来看一个实例:
#!/bin/bash
VAR=10 # Edit to 1 or 2 and re-run, after running the script as is.
case $VAR in
1)
echo "1"
;;
2)
echo "2"
;;
*)
echo "What is this var?"
exit 1
esac
你有没有想过要迭代文件列表或动态数组,逐个评估其中的所有内容?或是一直等到某个条件成立?对于这类场景,你可能得使用for循环、do while循环或until循环来充实脚本功能,化繁为简。这3种循环看起来都差不多,但彼此之间还是有着细微的差别。
(1)for循环
如果要针对数组中的每个元素执行多项任务或多条命令,或想对某些元素执行特定的命令,通常会用到for循环。在这个例子中,我们有一个包含3个元素的数组(或列表):file1、file2、file3。for循环会使用echo输出FILES中的每个元素,然后退出脚本:
#!/bin/bash
FILES=( "file1" "file2" "file3" )
for ELEMENT in ${FILES[@]}
do
echo "${ELEMENT}"
done
echo "Echo\'d all the files"
(2)do while循环
作为另一种选择,我们还可以使用do while循环。它和for循环类似,但是更适合于动态条件,例如,当你不知道什么时候会返回某个值或何时条件才能成立。中括号内的条件和if语句中的写法一样:
#!/bin/bash
CTR=1
while [ ${CTR} -lt 9 ]
do
echo "CTR var: ${CTR}"
((CTR++)) # Increment the CTR variable by 1
done
echo "Finished"
(3)until循环
出于完整性的考虑,我们还要再讲一下until循环。这种循环用的并不是很多,基本上和do while循环大同小异。注意,其条件判断和操作与计数器的增加保持一致,直到达到某个值:
#!/bin/bash
CTR=1
until [ ${CTR} -gt 9 ]
do
echo "CTR var: ${CTR}"
((CTR++)) # Increment the CTR variable by 1
done
echo "Finished"
之前我们已经提到过,function是一个保留字,仅用于Bash脚本的某个操作过程,但什么是函数?
为了说明什么是函数,我们先来给函数下一个定义:函数是执行某个任务的自包含代码段。不过,函数执行的任务可能由很多子任务组成。
举例来说,你有一个名为file_creator的函数,负责执行下列任务。
1.检查文件是否存在。
2.如果文件存在,将其截断(truncate)。否则,创建一个新文件。
3.设置正确的权限。
函数也可以接受参数。参数类似于变量,可以在函数外部设置,在函数内部使用。这非常有用,因为我们可以创建执行通用任务的代码段,供其他脚本重用,甚至是用在循环内部。你还可以拥有局部变量,这种变量无法从函数外部访问,只能在函数内部使用。那么,函数应该是什么样子?
#!/bin/bash
function my_function() {
local PARAM_1="$1"
local PARAM_2="$2"
local PARAM_3="$3"
echo "${PARAM_1} ${PARAM_2} ${PARAM_3}"
}
my_function "a" "b" "c"
就像我们在这个简单的脚本中看到的那样,有一个函数使用 function 保留字声明为my_function。函数的内容出现在花括号{}之中并引入了3个概念。
在下一节中,我们将深入探讨一个更实际的例子,该例子应该会更好地阐明这个观点:函数有助于日常工作,能够在适合的地方轻松地重用某些功能。
在这个短小的例子中,我们定义了一个名为create_file的函数,针对FILES数组中的每个文件,都会调用该函数。这个函数先创建文件并修改权限,然后使用ls命令检查文件是否存在:
#!/bin/bash
FILES=( "file1" "file2" "file3" ) # This is a global variable
function create_file() {
local FNAME="${1}" # First parameter
local PERMISSIONS="${2}" # Second parameter
touch "${FNAME}"
chmod "${PERMISSIONS}" "${FNAME}"
ls -l "${FNAME}"
}
for ELEMENT in ${FILES[@]}
do
create_file "${ELEMENT}" "a+x"
done
echo "Created all the files with a function!"
exit 0
除了函数,我们还可以创建多个脚本并将其包含进来,这样就可以利用函数的共享变量了。
假设我们现在有一个包含了大量可协助创建文件的函数库或工具脚本。该脚本本身可用于或重复用于各种脚本化任务。还有另外一个脚本,不过它只做一件事:执行文件系统操作(I/O操作)。因此,现在有两个脚本:
1.io_maker.sh(包含了library.sh并使用了其中的函数);
2.library.sh(包含了各种函数定义,但并不负责这些函数的实际调用)。
io_maker.sh脚本只是简单地导入或包含library.sh脚本,继承所有的全局变量、函数或其他已包含内容。按照这种方式,这些函数就像是io_maker.sh自己拥有的一样,能够直接执行。
先做些准备,创建并打开下列两个文件:
io_maker.sh
library.sh
在library.sh中加入下列内容:
#!/bin/bash
function create_file() {
local FNAME=$1
touch "${FNAME}"
ls "${FNAME}" # If output doesn't return a value - file is missing
}
function delete_file() {
local FNAME=$1
rm "${FNAME}"
ls "${FNAME}" # If output doesn't return a value - file is missing
}
在io_maker.sh中加入下列内容:
#!/bin/bash
source library.sh # You may need to include the path as it is relative
FNAME="my_test_file.txt"
create_file "${FNAME}"
delete_file "${FNAME}"
exit 0
如果运行该脚本,应该会看到如下输出:
$ bash io_maker.sh
my_test_file.txt
ls: cannot access 'my_test_file.txt': No such file or directory
尽管并不明显,但可以看出两个函数都执行了。第1行输出来自ls命令,在create_file()中创建文件后顺利地找到了my_test_file.txt。在第2行,我们看到在删除该文件时,ls返回了错误信息。
遗憾的是,我们目前只能创建并调用函数来执行命令。在下一节中,将会讲解如何检索命令和函数的返回码或字符串。
到目前为止,我们都是使用exit命令退出脚本。对于好奇的读者,可能已经从网上知道了该命令的作用,但是要记住的关键概念是所有脚本、命令或者可执行的二进制文件在退出的时候都带有一个返回码(return code)。返回码是一个数值,范围是0~255,其原因在于它是一个8位(8bit)无符号整数。如果你使用−1,则返回255。
好了,那么返回码有哪些方面的用途?如果你想知道在执行匹配操作的时候是否查找到了匹配项,命令是否成功执行,这时返回码就能派上用场了。让我们在终端中使用ls命令深入研究一个实例:
$ ls ~/this.file.no.exist
ls: cannot access '/home/rbrash/this.file.no.exist': No such file or
directory
$ echo $?
2
$ ls ~/.bashrc
/home/rbrash/.bashrc
$ echo $?
0
注意到返回码没有?在这个例子中,0或2分别代表成功(0)或错误(1和2)。通过检索$?变量可以得到退出码,我们甚至还可以像这样对其进行设置:
$ ls ~/this.file.no.exist
ls: cannot access '/home/rbrash/this.file.no.exist': No such file or
directory
$ TEST=$?
$ echo $TEST
2
从这个例子中,我们现在知道了什么是返回码,如何通过返回码来获得函数、脚本以及命令的执行结果。
打开终端,创建下列Bash脚本:
#!/bin/bash
GLOBAL_RET=255
function my_function_global() {
ls /home/${USER}/.bashrc
GLOBAL_RET=$?
}
function my_function_return() {
ls /home/${USER}/.bashrc
return $?
}
function my_function_str() {
local UNAME=$1
local OUTPUT=""
if [ -e /home/${UNAME}/.bashrc ]; then
OUTPUT='FOUND IT'
else
OUTPUT='NOT FOUND'
fi
echo ${OUTPUT}
}
echo "Current ret: ${GLOBAL_RET}"
my_function_global "${USER}"
echo "Current ret after: ${GLOBAL_RET}"
GLOBAL_RET=255
echo "Current ret: ${GLOBAL_RET}"
my_function_return "${USER}"
GLOBAL_RET=$?
echo "Current ret after: ${GLOBAL_RET}"
#And for gigglas,we can pass back output too!
GLOBAL_RET=""
echo "Current ret: ${GLOBAL_RET}"
GLOBAL_RET=$(my_function_str ${USER})
# You could also use GLOBAL_RET=`my_function_str ${USER}`
# Notice the back ticks "`"
echo "Current ret after: $GLOBAL_RET"
exit 0
该脚本在使用返回码0(记住,ls如果运行成功的话会返回0)退出之前将输出以下内容:
rbrash@moon:~$ bash test.sh
Current ret: 255
/home/rbrash/.bashrc
Current ret after: 0
Current ret: 255
/home/rbrash/.bashrc
Current ret after: 0
Current ret:
Current ret after: FOUND IT
$
在本节中,有3个函数,分别使用了3种方式:
1.my_function_global使用全局变量保存命令的返回码;
2.my_function_return使用了保留字return和一个值(命令返回码);
3.my_function_str使用子shell(一种特殊操作)返回输出(通过echo命令回显)。
对于第3种方式,有多种方法可以从函数中获得字符串,其中包括使用eval关键字。但是,在使用子shell时,如果为了获得输出而多次运行相同的命令,最好留意资源消耗情况。
本节可能是全书最重要的内容之一,因为其中描述了Linux和UNIX的一个基本且强大的特性:管道和输入/输出重定向。管道本身相当简单:命令和脚本可以将其输出传入文件或其他命令之中。在Bash脚本世界中,这一点可能被不少人低估了,因为管道和重定向允许你借助其他命令的特性来增强自身。
让我们用例子来作一番深入了解,其中用到了命令tail和grep。在这个例子中,用户Bob 希望实时查看自己的日志,但是他感兴趣的只是与无线接口相关的日志项。Bob 的无线设备名称可以使用iwconfig命令找出:
$ iwconfig
wlp3s0 IEEE 802.11abgn ESSID:"127.0.0.1-2.4ghz"
Mode:Managed Frequency:2.412 GHz Access Point: 18:D6:C7:FA:26:B1
Bit Rate=144.4 Mb/s Tx-Power=22 dBm
Retry short limit:7 RTS thr:off Fragment thr:off
Power Management:on
Link Quality=58/70 Signal level=-52 dBm
Rx invalid nwid:0 Rx invalid crypt:0 Rx invalid frag:0
Tx excessive retries:0 Invalid misc:90 Missed beacon:0
iwconfig命令现在已经过时了。下列命令也可以给出无线接口的信息:
$ iw dev # This will give list of wireless interfaces
$ iw dev wlp3s0 link # This will give detailed information about particular wireless interface
Bob现在知道了自己的无线网卡的名称(wlp3s0),接下来就可以搜索系统日志了。日志通常位于/var/log/message。tail 命令的-F选项允许持续向终端输出日志,这样Bob就能看到所有的系统日志了。不过,Bob想使用grep过滤日志,只查看带有关键字wlp3s0的那些日志项。
Bob面临两个选择:要么自己在日志文件中查找,要么利用tail和grep获得想要的结果。答案当然是后者(使用管道)!
$ tail -F /var/log/messages | grep wlp3s0
Nov 10 11:57:13 moon kernel: wlp3s0: authenticate with 18:d6:c7:fa:26:b1
Nov 10 11:57:13 moon kernel: wlp3s0: send auth to 18:d6:c7:fa:26:b1 (try 1/3)
Nov 10 11:57:13 moon kernel: wlp3s0: send auth to 18:d6:c7:fa:26:b1 (try 2/3)
...
只要有新日志内容出现,Bob都能够实时观察到,也可以使用<Ctrl+C>组合键停止终端输出。
你可以利用管道将多个命令组合成一个功能强大的组合命令,发挥各个命令的优点。别忘记管道!
管道的用法和灵活性显而易见,但命令的输入和输出的重定向呢?命令可以从3个地方获取信息:
如果我们考虑单个程序,stdin可以是任何可为其提供内容的东西,通常是参数或用户输入。stdout和stderr是输出发往的地方。这两者通常都是终端,但如果你希望将stderr中的错误信息保存在文件中该怎么办?
$ ls /filethatdoesntexist.txt 2> err.txt
$ ls ~/ > stdout.txt
$ ls ~/ > everything.txt 2>&1 # Gets stderr and stdout
$ ls ~/ >> everything.txt 2>&1# Gets stderr and stdout
$ cat err.txt
ls: cannot access '/filethatdoesntexist.txt': No such file or directory
$ cat stdout.txt
.... # A whole bunch of files in your home directory
当我们执行cat err.txt时,可以看到来自stderr的错误信息。如果你只想记录错误信息,而非输出到终端中的其他内容,这种做法很有用。注意,以上代码片段中的关键之处在于>、2>和2>&1的用法。借助箭头,我们可以将输出重定向到任何文件。
注意>和>>之间的差异。>会将删除重定向文件中的所有内容,而>>会将输出追加到重定向文件的原有内容之后。
在将stderr和stdout重定向到相同的文件时,容易出现一种常见的错误。Bash 会先选择输出到文件,然后再复制输出文件的描述符。
# This is correct
ls ~/ > everything.txt 2>&1
# This is erronous
ls ~/ 2>&1> everything.txt
现在,我们已经了解了Bash最强大的特性之一,接下来看一个重定向和管道的例子。
打开shell,使用你喜爱的编辑器创建一个新的Bash脚本:
#!/bin/sh
# Let's run a command and send all of the output to /dev/null
echo "No output?"
ls ~/fakefile.txt > /dev/null 2>&1
# Retrieve output from a piped command
echo "part 1"
HISTORY_TEXT=`cat ~/.bashrc | grep HIST`
echo "${HISTORY_TEXT}"
# Output the results to history.config
echo "part 2"
echo "${HISTORY_TEXT}" > "history.config"
# Re-direct history.config as input to the cat command
cat < history.config
# Append a string to history.config
echo "MY_VAR=1" >> history.config
echo "part 3 - using Tee"
# Neato.txt will contain the same information as the console
ls -la ~/fakefile.txt ~/ 2>&1 | tee neato.txt
首先,ls会产生错误信息,为了不让错误出现在终端,我们将其重定向到Linux中一个名为/dev/null的特殊设备。/dev/null特别有用,因为你可以将任何再也用不着的信息丢弃到此处。我们使用cat命令和grep命令,配合管道来查找特定的文本行,然后通过命令替换将输出保存在变量(HISTORY_TEXT)中。
接着,使用stdout重定向,将HISTORY_TEXT的内容保存在文件history.config。我们重定向cat命令,使用该文件作为其输入。
然后利用>>将字符串追加到history.config文件的末尾。
脚本的最后一条命令中用到了stdout和stderr重定向、管道以及tee命令。tee命令的作用在于把输入保存到其他文件的同时,还能将输入内容显示在终端中(就像我们演示的那样)。
在最基本的层面上,检索程序的输入参数和检索函数参数的方法基本差不多。这两种参数都可以通过相同的方式访问,例如$1(参数1)、$2(参数2)、$3(参数3)等。之前我们已经接触过选项,例如-l、--long-version、-v 10、--verbosity=10。选项实际上是一种在运行时向程序传入参数的用户友好方法。例如:
bash myProgram.sh -v 99 --name=Ron -l Brash
现在,你知道了什么是选项及如何使用选项改进脚本,下一节的内容可以作为以后编写脚本时的模板。
进入shell,在你喜欢的编辑器中创建一个新文件,按照下列要求编写Bash脚本:
除了基本的逻辑,我们可以看到代码还用到了getopts的部分功能。getopts允许在脚本中获取选项内容。除此之外,还有条件逻辑、while循环和case/switch语句。一旦脚本不再只是一个简单的工具或者提供了更多的功能,那么出现越来越多的Bash构件也就变得司空见惯了。
#!/bin/bash
HELP_STR="usage: $0 [-h] [-f] [-l] [--firstname[=]<value>] [-- lastname[=] <value] [--help]"
# Notice hidden variables and other built-in Bash functionality
optspec=":flh-:"
while getopts "$optspec" optchar; do
case "${optchar}" in
-)
case "${OPTARG}" in
firstname)
val="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 ))
FIRSTNAME="${val}"
;;
lastname)
val="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 ))
LASTNAME="${val}"
;;
help)
val="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 ))
;;
*)
if [ "$OPTERR" = 1 ] && [ "${optspec:0:1}" != ":" ]; then
echo "Found an unknown option --${OPTARG}" >&2
fi
;;
esac;;
f)
val="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 ))
FIRSTNAME="${val}"
;;
l)
val="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 ))
LASTNAME="${val}"
;;
h)
echo "${HELP_STR}" >&2
exit 2
;;
*)
if [ "$OPTERR" != 1 ] || [ "${optspec:0:1}" = ":" ]; then
echo "Error parsing short flag: '-${OPTARG}'" >&2
exit 1
fi
;;
esac
done
# Do we have even one argument?
if [ -z "$1" ]; then
echo "${HELP_STR}" >&2
exit 2
fi
# Sanity check for both Firstname and Lastname
if [ -z "${FIRSTNAME}" ] || [ -z "${LASTNAME}" ]; then
echo "Both firstname and lastname are required!"
exit 3
fi
echo "Welcome ${FIRSTNAME} ${LASTNAME}!"
exit 0
执行上面的脚本,能够看到类似于下面的输出:
$ bash flags.sh
usage: flags.sh [-h] [-f] [-l] [--firstname[=]<value>] [--
lastname[=]<value] [--help]
$ bash flags.sh -h
usage: flags.sh [-h] [-f] [-l] [--firstname[=]<value>] [--
lastname[=]<value] [--help]
$ bash flags.sh --fname Bob
Both firstname and lastname are required!
rbrash@moon:~$ bash flags.sh --firstname To -l Mater
Welcome To Mater!
随着我们继续往下进行,你会发现书中用到了很多命令,但并没有做特别详尽的解释。为了避免在整本书中充斥着Linux和实用命令的介绍性内容,有两个非常方便的命令可供参考:man和info。
man(manual)命令的涉及面极其广泛,如果同一条目出现在多种分类中,甚至还会划分成多个小节。要想研究程序或shell命令,只看分类1就足够了。来看一下mount命令的条目:
$ man mount
...
MOUNT(8) System Administration MOUNT(8)
NAME
mount - mount a filesystem
SYNOPSIS
mount [-l|-h|-V]
mount -a [-fFnrsvw] [-t fstype] [-O optlist]
mount [-fnrsvw] [-o options] device|dir
mount [-fnrsvw] [-t fstype] [-o options] device dir
DESCRIPTION
All files accessible in a Unix system are arranged in one big tree, the
file hierarchy, rooted at /. These files can be spread out over several
devices. The mount command serves to attach the filesystem found
on some device to the big file tree. Conversely, the umount(8) command
will detach it again.
...
(Press 'q' to Quit)
$
另外还有info命令,如果你要查看的命令存在对应的info页面,那么该命令会将其显示出来。
在本章中,我们介绍了变量、类型、赋值的概念。另外介绍了一些基本的Bash编程构件,例如for循环、while循环、switch语句。随后,介绍了函数及其用法,还有如何传递函数参数。
在下一章,我们将学习其他一些增强(bolt-on)技能,扩大Bash应用面。
[1] [译注1]:shebang这个词其实是两个字符名称(sharp-bang)的简写。在UNIX的行话里,用sharp或hash(有时候是mesh)来称呼字符“#”,用bang来称呼惊叹号“!”,因而shebang合起来就代表了这两个字符。