精通-Linux-脚本编程第二版-全-
精通 Linux 脚本编程第二版(全)
原文:
annas-archive.org/md5/a2687dc6a1efb4e0c673996d9ed14a91译者:飞龙
序言
首先,你将了解 Linux shell 及其选择 bash shell 的原因。然后,你将学习如何编写一个简单的 bash 脚本,以及如何使用 Linux 编辑器编辑你的 bash 脚本。
接下来,你将学习如何定义变量以及变量的可见性。之后,你将学习如何将命令执行输出存储到变量中,这称为命令替换。此外,你将学习如何使用 bash 选项和 Visual Studio Code 调试代码。你将学习如何通过接受用户输入使你的 bash 脚本具有交互性。然后,你将学习如何读取用户传递给脚本的选项及其值。接下来,你将学习如何编写条件语句,如if语句,以及如何使用case语句。之后,你将学习如何使用 vim 和 Visual Studio Code 创建代码片段。对于重复的任务,你将学习如何编写 for 循环,如何遍历简单的值,以及如何遍历目录内容。此外,你将学习如何编写嵌套循环。与此同时,你还将学习如何编写 while 循环和 until 循环。接着,我们将进入函数部分,函数是可重用的代码块。你将学习如何编写函数以及如何使用它们。之后,你将接触到 Linux 中最强大的工具之一——流编辑器(Stream Editor)。由于我们仍在讨论文本处理,我们将介绍 AWK,这是 Linux 中最优秀的文本处理工具之一。
之后,你将通过编写更好的正则表达式来提升你的文本处理技能。最后,你将学习 Python,作为 bash 脚本的替代方案。
本书适合谁阅读
本书面向系统管理员和开发人员,帮助他们编写更好的 shell 脚本以自动化工作。有编程经验者优先。如果你没有任何 shell 脚本背景也没关系,本书将从头开始讨论所有内容。
本书内容
第一章,使用 Bash 脚本的是什么和为什么,将介绍 Linux shell,如何编写你的第一个 shell 脚本,如何准备编辑器,如何调试你的 shell 脚本,以及一些基础的 bash 编程,如声明变量、变量作用域和命令替换。
第二章,创建交互式脚本,介绍了如何使用read命令读取用户输入,如何向脚本传递选项,如何控制输入文本的可见性,以及如何限制输入字符的数量。
第三章,附加条件,将介绍if语句、case语句以及其他测试命令,如else和elif。
第四章,创建代码片段,介绍如何使用编辑器(如 vim 和 Visual Studio Code)创建和使用代码片段。
第五章,替代语法,将讨论如何使用[进行高级测试,以及如何执行算术运算。
[第六章,使用循环迭代,将教你如何使用for循环、while循环和until循环来遍历简单值和复杂值。
第七章,通过函数创建构建模块,将介绍函数,并解释如何创建函数、列出内建函数、将参数传递给函数,以及编写递归函数。
第八章,介绍流编辑器,将介绍 sed 工具的基础知识,如何操作文件,如添加、替换、删除和转换文本。
第九章,自动化 Apache 虚拟主机,包含一个 sed 的实用示例,并解释如何使用 sed 自动创建虚拟主机。
第十章,AWK 基础,将讨论 AWK 及如何使用它来过滤文件内容。同时,我们也将讨论一些 AWK 编程基础。
第十一章,正则表达式,介绍正则表达式、它们的引擎以及如何与 sed 和 AWK 结合使用,以增强你的脚本。
第十二章,使用 AWK 总结日志,将展示如何使用 AWK 处理httpd.conf Apache 日志文件,并提取有用的、格式化良好的数据。
第十三章,使用 AWK 改进 lastlog,将向你展示如何使用 AWK 通过过滤和处理 lastlog 输出,利用 lastlog 命令生成漂亮的报告。
第十四章,使用 Python 作为 Bash 脚本替代,将讨论 Python 编程语言基础,并解释如何编写一些 Python 脚本作为 Bash 脚本的替代。
为了最大限度地从本书中获益
我假设你有一些编程背景。即使没有编程背景,本书也会从基础开始。
你应该了解一些 Linux 基础知识,如基本命令ls、cd和which。
下载示例代码文件
你可以从你的账户在 www.packtpub.com 下载本书的示例代码文件。如果你是在其他地方购买本书,你可以访问 www.packtpub.com/support 并注册,直接将文件通过邮件发送给你。
你可以通过以下步骤下载代码文件:
-
登录或注册 www.packtpub.com。
-
选择“支持”标签。
-
点击“代码下载和勘误”。
-
在搜索框中输入书名,并按照屏幕上的指示操作。
下载文件后,请确保使用以下最新版本的解压工具解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,地址为 github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition。如果代码有更新,它将会在现有的 GitHub 仓库中更新。
我们的丰富书籍和视频目录中也提供了其他代码包,您可以访问 github.com/PacktPublishing/。快去看看吧!
下载彩色图像
我们还提供了一个 PDF 文件,里面有本书中使用的截图/图表的彩色图像。您可以从 www.packtpub.com/sites/default/files/downloads/MasteringLinuxShellScriptingSecondEdition_ColorImages.pdf 下载。
使用的约定
本书中使用了多种文本约定。
CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。示例:“编辑您的脚本,使其像下面的完整代码块一样运行,示例:$HOME/bin/hello2.sh”
一段代码如下所示:
if [ $file_compression = "L" ] ; then
tar_opt=$tar_l
elif [ $file_compression = "M" ]; then
tar_opt=$tar_m
else
tar_opt=$tar_h
fi
任何命令行输入或输出如下所示:
$ type ls
ls is aliased to 'ls --color=auto'
粗体:表示一个新术语、一个重要的词汇或您在屏幕上看到的词汇。例如,菜单或对话框中的词汇以这种方式出现在文本中。示例:“另一个非常有用的功能可以在 'Preferences | Plugins' 标签页中找到”
警告或重要提示通常以这种形式出现。
小贴士和技巧通常以这种形式出现。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书名。如果您有关于本书的任何问题,请通过questions@packtpub.com联系我们。
勘误:尽管我们已尽最大努力确保内容的准确性,但错误还是可能发生。如果您在本书中发现错误,我们将非常感激您向我们报告。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击 “Errata Submission Form” 链接并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何非法复制版本,我们将非常感激您提供该位置地址或网站名称。请通过copyright@packtpub.com联系我们,并附上该材料的链接。
如果您有兴趣成为作者:如果您在某个领域拥有专业知识,并且有兴趣撰写或为书籍贡献内容,请访问 authors.packtpub.com。
评论
请留下评论。在阅读并使用本书后,为什么不在您购买书籍的网站上留下评论呢?潜在的读者可以看到并利用您的公正意见做出购买决策,我们在 Packt 也能了解您对我们产品的看法,而我们的作者也可以看到您对他们书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问 packtpub.com。
第一章:使用 Bash 脚本的目的与原因
欢迎来到 bash 脚本的介绍与原因。在本章中,你将了解 Linux 中的各种 shell 类型,以及为什么我们选择了 bash。你将学习什么是 bash,如何编写你的第一个 bash 脚本,并且如何运行它。同时,你将看到如何配置 Linux 编辑器,如 vim 和 nano,以便编写代码。
就像任何其他脚本语言一样,变量是编码的基本单元。你将学习如何声明变量,如整数、字符串和数组。此外,你将学习如何导出这些变量,并扩展它们在运行进程之外的作用范围。
最后,你将学会如何使用 Visual Studio Code 进行可视化调试你的代码。
本章将涵盖以下主题:
-
Linux shell 的类型
-
什么是 bash 脚本?
-
bash 命令层次结构
-
准备文本编辑器以进行脚本编写
-
创建和执行脚本
-
声明变量
-
变量作用域
-
命令替换
-
调试你的脚本
技术要求
你需要一台正在运行的 Linux 计算机。使用哪个发行版都无所谓,因为如今所有的 Linux 发行版都预装了 bash shell。
下载并安装 Visual Studio Code,这是 Microsoft 提供的免费软件。你可以在此下载:code.visualstudio.com/。
你可以使用 VS Code 作为编辑器,而不是 vim 和 nano;这取决于你。
我们更倾向于使用 VS Code,因为它具有许多功能,如代码自动补全、调试等。
安装bashdb,这是 bash 调试插件所需的包。如果你使用的是基于 Red Hat 的发行版,可以按照以下方式安装:
$ sudo yum install bashdb
如果你使用的是基于 Debian 的发行版,可以按以下方式安装:
$ sudo apt-get install bashdb
从 marketplace.visualstudio.com/items?itemName=rogalmic.bash-debug 安装 VS Code 的插件,名为 bash debug。这个插件将用于调试 bash 脚本。
本章的源代码可以在此下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter01
Linux shell 的类型
正如你所知道的,Linux 由一些主要部分组成,比如内核、shell 和 GUI 界面(如 Gnome、KDE 等)。
Shell 将你的命令翻译并发送到系统。大多数 Linux 发行版都预装了多种 shell。
每个 shell 都有自己的特点,其中一些在今天的开发者中非常受欢迎。以下是一些受欢迎的 shell 类型:
-
Sh shell:这就是所谓的 Bourne shell,最初由 Stephen Bourne 在 70 年代的 AT&T 实验室开发。这个 shell 提供了许多功能。
-
Bash shell:也叫做 Bourne Again Shell,这是非常流行的,并且与 sh 脚本兼容,因此你可以在不修改的情况下运行 sh 脚本。我们将在本书中使用这个 shell。
-
Ksh shell:也叫做 Korn shell,它与 sh 和 bash 兼容。Ksh 在 Bourne shell 的基础上提供了一些增强功能。
-
Csh 和 tcsh:Linux 是使用 C 语言构建的,这推动了伯克利大学的开发人员开发了一种 C 风格的 shell,其语法与 C 语言类似。Tcsh 对 csh 进行了少量增强。
现在我们知道了 shell 的类型,并且知道我们将使用 bash,那么什么是 bash 脚本呢?
什么是 bash 脚本?
bash 脚本的基本思路是执行多个命令,以自动化特定的任务。
正如你所知道的,你可以通过使用分号(;)分隔多个命令,从 shell 中运行多个命令:
ls ; pwd
前一行是一个迷你 bash 脚本。
第一个命令运行后,紧接着是第二个命令的结果。
你在 bash 脚本中输入的每一个关键字实际上都是一个 Linux 二进制程序(程序),即使是if语句,或else或while循环。所有这些都是 Linux 可执行文件。
你可以说,shell 是将这些命令连接在一起的“胶水”。
bash 命令层级
当你在 bash shell 中工作,并且坐在命令提示符前,急切地等待输入命令时,你很可能会觉得这只是输入命令并按下Enter键的简单问题。你应该知道这并非如此,因为事情从来都不会像我们想象的那么简单。
命令类型
例如,如果我们输入并执行ls列出文件,合理的想法是我们正在运行该命令。这是可能的,但我们经常会运行一个别名。别名存在于内存中,是对命令或带选项命令的快捷方式;这些别名在我们检查文件之前就会被使用。bash 的内建type命令可以在这里帮助我们。type命令会显示在命令行中输入的单词对应的命令类型。命令类型如下所示:
-
别名
-
函数
-
Shell 内建命令
-
关键字
-
文件
这个列表也代表了它们被搜索的顺序。正如我们所看到的,直到最后才会搜索可执行文件ls。
以下命令展示了type的简单用法:
$ type ls
ls is aliased to 'ls --color=auto'
我们可以进一步扩展,显示给定命令的所有匹配项:
$ type -a ls
ls is aliased to 'ls --color=auto'
ls is /bin/ls
如果我们只需要输出结果,可以使用-t选项。当我们需要从脚本内部测试命令类型并且只需要返回类型时,这个选项非常有用。它排除了任何多余的信息,从而使我们更容易阅读。考虑以下命令和输出:
$ type -t ls
alias
输出清晰简洁,正是计算机或脚本所需要的。
内建的type命令也可以用来识别 shell 关键字,如if和case。以下命令展示了type在多个参数和类型中的使用:
$ type ls quote pwd do id
命令的输出在以下屏幕截图中显示:

你也可以看到,当我们在使用type时遇到函数,函数定义会被打印出来。
命令路径
Linux 只有在提供了程序的完整路径或相对路径时,才会在PATH环境变量中查找可执行文件。一般情况下,当前目录不会被搜索,除非它被包含在PATH中。我们可以通过将当前目录添加到PATH变量中,从而使当前目录包含在PATH内。这在以下命令示例中有所展示:
$ export PATH=$PATH:.
这将当前目录添加到PATH变量的值中;PATH中的每个项目用冒号分隔。现在你的PATH已经更新,包含了当前工作目录,每次你更改目录时,脚本都可以轻松执行。一般来说,将脚本组织到一个有结构的目录层级中可能是个不错的主意。考虑在你的主目录下创建一个名为bin的子目录,并将脚本放入其中。将$HOME/bin添加到PATH变量中将使你能够通过名称而不需要文件路径找到这些脚本。
以下命令行列表将只会在目录不存在时创建该目录:
$ test -d $HOME/bin || mkdir $HOME/bin
虽然前面的命令行列表严格来说不是必需的,但它展示了 bash 脚本编程不仅仅限于实际的脚本文件,我们可以直接在命令行中使用条件语句和其他语法。从我们的角度来看,无论你是否有bin目录,前面的命令都会生效。使用$HOME变量确保命令在不考虑当前文件系统上下文的情况下也能正常工作。
在本书中,我们会将脚本添加到$HOME/bin目录,这样它们就可以在任何工作目录下执行。
为脚本编程准备文本编辑器
在本书中,我们将使用 Linux Mint,并且包括脚本的创建和编辑。当然,你可以选择任何你希望的脚本编辑方式,可能会更喜欢使用图形化编辑器,所以我们会在 gedit 中展示一些设置。我们将在本章中稍作偏离,进入 Red Hat 系统,以展示 gedit 的屏幕截图。
此外,我们还将使用 Visual Studio Code 作为现代 GUI 编辑器来编辑和调试我们的脚本。
为了让命令行编辑器更加易用,我们可以启用选项,并通过隐藏的配置文件保持这些选项。Gedit 和其他 GUI 编辑器及其菜单也会提供类似的功能。
配置 vim
编辑命令行通常是必须的,它是开发者日常工作的一部分。在编辑器中设置一些常用选项,使得工作更加高效,为我们提供了所需的可靠性和一致性,这有点像脚本编程本身。我们将会在 vi 或 vim 编辑器文件$HOME/.vimrc中设置一些有用的选项。
我们设置的选项详细信息如下:
-
set showmode:确保我们看到何时处于插入模式 -
set nohlsearch:不突出显示我们搜索过的单词 -
set autoindent:我们经常缩进代码;这使我们可以在每次换行时返回到上一个缩进级别,而不是从新的一行开始。 -
set tabstop=4:将制表符设置为四个空格 -
set expandtab:将制表符转换为空格,这在文件移到其他系统时很有用 -
syntax on:注意,这不使用set命令,而是用于启用语法高亮
当这些选项设置好后,$HOME/.vimrc 文件应类似于以下内容:
set showmode
set nohlsearch
set autoindent
set tabstop=4
set expandtab
syntax on
配置 nano
nano 文本编辑器越来越重要,它是许多系统的默认编辑器。就个人而言,我不喜欢它的导航功能,或者说缺乏导航功能。它可以像 vim 一样进行自定义。这一次,我们将编辑 $HOME/.nanorc 文件。你编辑后的文件应类似于以下内容:
set autoindent
set tabsize 4
include /usr/share/nano/sh.nanorc
最后一行启用 shell 脚本的语法高亮。
配置 gedit
图形编辑器,例如 gedit,可以通过首选项菜单进行配置,并且操作非常简单。
启用将制表符间距设置为 4 个空格并将制表符转换为空格,可以通过首选项 | 编辑器标签完成,具体如以下截图所示:

你可以从你在 www.packtpub.com 的账户中下载示例代码文件,适用于你购买的所有 Packt Publishing 图书。如果你是在其他地方购买此书,你可以访问 www.packtpub.com/support 并注册,将文件直接通过电子邮件发送给你。
另一个非常有用的功能可以在首选项 | 插件标签中找到。在这里,我们可以启用 Snippets 插件,用于插入代码示例。以下截图展示了这一功能:

在本书的剩余部分,我们将使用命令行和 vim 进行操作;你可以随意使用最适合你的编辑器。我们现在已经为创建良好的脚本奠定了基础,尽管在 bash 脚本中空格、制表符和空白字符并不重要,但布局清晰且间距一致的文件更易于阅读。稍后我们将学习 Python 时,你会意识到在一些编程语言中,空白字符对语言有意义,因此最好从一开始就养成良好的习惯。
创建和执行脚本
配置好编辑器后,我们现在可以迅速开始创建和执行脚本。如果你是带着一定经验来阅读这本书的,我们会提醒你我们将从基础开始,但也会涉及位置参数的内容;你可以按自己的节奏继续阅读。
你好,世界!
如你所知,几乎每个脚本都从 Hello World 开始,我们也不会让你失望。我们将首先创建一个新的脚本,$HOME/bin/hello1.sh。该文件的内容应如下所示:

我们希望你没有为此感到太困难;毕竟只有三行。我们鼓励你在阅读时运行这些示例,真正通过动手实践帮助你掌握信息。
-
#!/bin/bash:通常这是脚本的第一行,称为 shebang。Shebang 以注释开始,但系统仍然会使用这一行。shell 脚本中的注释以#符号开始。Shebang 告诉系统的解释器执行这个脚本。我们使用 bash 来编写 shell 脚本,必要时也可以使用 PHP 或 Perl 来编写其他脚本。如果我们没有添加这一行,命令将在当前 shell 中运行;如果我们启动另一个 shell,可能会导致问题。 -
echo "Hello World":echo命令将在内置的 shell 中执行,并用于写入标准输出STDOUT;默认情况下输出到屏幕。要打印的信息被双引号括起来,稍后会详细讨论引号。 -
exit 0:exit命令是内置的 shell 命令,用于退出或离开脚本。exit代码作为整数参数传递。任何不为0的值都表示脚本执行时出现了某种错误。
执行脚本
即便脚本保存在我们的 PATH 环境变量中,它仍然无法作为独立脚本执行。我们需要为文件分配并执行相应的权限。对于一个简单的测试,我们可以直接用 bash 运行文件。以下命令演示了如何执行:
$ bash $HOME/bin/hello1.sh
我们应该能看到 Hello World 文本显示在屏幕上。这并不是一个长期的解决方案,因为我们需要将脚本放在 $HOME/bin 目录中,特别是为了方便在任何位置运行脚本而无需输入完整路径。我们需要为该文件添加执行权限,代码如下所示:
$ chmod +x $HOME/bin/hello1.sh
现在我们应该能够简单地运行脚本,如下图所示:

检查退出状态
这个脚本很简单,但我们仍然需要了解如何利用脚本和其他应用程序的退出码。我们之前生成的命令行列表,在创建 $HOME/bin 目录时,就是一个很好的例子,说明了如何使用退出码:
$ command1 || command 2
在前面的示例中,只有在 command1 以某种方式失败时,才会执行 command2。具体来说,如果 command1 退出状态码不是 0,则会执行 command2。
同样,在以下示例中,只有在 command1 成功并返回 0 的退出码时,才会执行 command2:
$ command1 && command2
要显式地读取脚本的退出代码,我们可以查看$?变量,如下例所示:
$ hello1.sh
$ echo $?
预期输出是0,因为这是我们在文件的最后一行添加的内容,几乎没有其他情况能导致无法到达该行。
确保名称唯一
我们现在可以创建并执行一个简单的脚本,但需要稍微考虑一下名称。在这种情况下,hello1.sh就足够好,而且不太可能与系统中的其他内容冲突。我们应该避免使用可能与现有别名、函数、关键字和构建命令发生冲突的名称,也要避免使用已经在系统中使用的程序名称。
给文件添加sh后缀并不能保证名称是唯一的,但在 Linux 中,我们不使用文件扩展名,后缀是文件名的一部分。这有助于为脚本提供唯一的身份标识。此外,编辑器还使用后缀帮助你识别文件并进行语法高亮。如果你记得,我们特意将语法高亮文件sh.nanorc添加到 nano 文本编辑器中。这些文件都与后缀和随之而来的语言相关。
回到本章中的命令层级,我们可以使用type来确定文件hello.sh的位置和类型:
$ type hello1.sh #To determine the type and path
$ type -a hello1.sh #To print all commands found if the name is NOT unique
$ type -t hello1.sh ~To print the simple type of the command
这些命令和输出可以在以下截图中看到:

Hello Dolly!
脚本中可能需要的不仅仅是一个简单的固定消息。静态消息内容确实有其用途,但通过在脚本中加入一些灵活性,我们可以使这个脚本变得更加有用。
在本章中,我们将查看可以提供给脚本的位置参数或参数,在下一章中,我们将学习如何使脚本具有交互性,并在运行时提示用户输入。
使用参数运行脚本
我们可以使用参数运行脚本;毕竟,这是一个自由的世界,Linux 鼓励你根据自己的需要使用代码。然而,如果脚本没有使用这些参数,那么它们会被默默地忽略。以下命令展示了脚本在使用一个参数时的运行:
$ hello1.sh fred
脚本仍然可以运行,并且不会产生错误。输出也不会改变,依然会打印Hello World:
| 参数标识符 | 描述 |
|---|---|
$0 |
脚本本身的名称,通常用于使用说明中。 |
$1 |
一个位置参数,是传递给脚本的第一个参数。 |
${10} |
当需要两个或更多数字来表示参数位置时使用。大括号用来将变量名与其他内容区分开来。期望为单一值数字。 |
$# |
参数计数,当我们需要设置正确脚本执行所需的参数数量时,这个参数特别有用。 |
$* |
指代所有参数。 |
为了让脚本利用该参数,我们可以稍微修改其内容。首先,复制脚本,添加执行权限,然后编辑新的hello2.sh:
$ cp $HOME/bin/hello1.sh $HOME/bin/hello2.sh
$ chmod +x $HOME/bin/hello2.sh
我们需要编辑hello2.sh文件,以利用命令行传递的参数。以下截图展示了命令行参数的最简单使用方式,现在我们可以有一个自定义消息:

现在运行脚本;我们可以提供如下所示的参数:
$ hello2.sh fred
输出现在应该显示Hello fred。如果我们不提供任何参数,则变量将为空,并且只会打印Hello。你可以参考以下截图查看执行参数和输出:

如果我们调整脚本使用$*,所有参数都会打印。我们会看到Hello,然后是所有提供的参数列表。编辑脚本并将echo行替换如下:
echo "Hello $*"
这将使用以下参数执行脚本:
$ hello2.sh fred wilma betty barney
这将导致如下截图中的输出:

如果我们想打印Hello <name>,并且每个名字都在单独的行上,我们需要稍等一下,直到讲解循环结构。for循环是实现这一目标的好方法。
正确使用引号的重要性
到目前为止,我们已经使用了简单的双引号机制,将我们想用echo的字符串包裹起来。
在第一个脚本中,使用单引号或双引号并不重要。echo "Hello World"与echo 'Hello World'是完全一样的。
但在第二个脚本中情况并非如此,因此理解 bash 中可用的引用机制非常重要。
如我们所见,使用双引号echo "Hello $1"会输出Hello fred或任何提供的值。而如果我们在echo 'Hello $1'中使用单引号,则屏幕上打印的输出将是Hello $1;也就是说,我们看到的是变量名,而不是它的值。
引号的作用是保护特殊字符,例如两个单词之间的空格;这两种引号都保护了空格,防止它被误解。空格通常被 shell 视为默认字段,且由 shell 分隔。换句话说,所有字符都被 shell 读取为字面量,没有特殊含义。这会导致$符号打印其字面格式,而不是允许 bash 扩展其值。由于被单引号保护,bash shell 无法扩展变量的值。
这就是双引号帮助我们的地方。双引号会保护所有字符,除了$,从而允许 bash 扩展存储的值。
如果我们需要在引号字符串中使用字面量的$符号,并且需要展开变量,可以使用双引号,但要用反斜杠(\)转义所需的$符号。例如,echo "$USER earns \$4"如果当前用户是 Fred,将会打印Fred earns $4。
在命令行中尝试以下示例,使用所有引号机制。根据需要自由调整你的小时费率:
$ echo "$USER earns $4"
$ echo '$USER earns $4'
$ echo "$USER earns \$4"
输出如下面的截图所示:

打印脚本名称
$0变量表示脚本名称,这通常用于用法说明中。由于我们尚未讨论条件语句,我们将显示脚本名称,而不是显示的名称。
编辑你的脚本,使其读起来像以下完整的$HOME/bin/hello2.sh代码块:
#!/bin/bash
echo "You are using $0"
echo "Hello $*"
exit 0
命令的输出如下面的截图所示:

如果我们不想打印路径,只希望显示脚本的名称,可以使用basename命令,它从路径中提取文件名。调整脚本,使第二行现在如下所示:
echo "You are using $(basename $0)"
$(....)语法用于评估内部命令的输出。我们首先运行basename $0,并将结果传递给一个无名变量,由$表示。
新的输出将如下面的截图所示:

使用反引号也可以实现相同的结果;虽然这种方式较难阅读,但我们提到它是因为你可能需要理解并修改由其他人编写的脚本。$(....)语法的替代方式如下面的示例所示:
echo "You are using 'basename $0'"
请注意,使用的字符是反引号,而不是单引号。在英国和美国的键盘上,它们位于左上角,紧挨数字 1 键。
声明变量
就像在任何编程语言中一样,你可以在 bash 脚本中声明变量。那么,这些变量是什么,使用它们有什么好处呢?
好吧,变量就像是一个占位符,用来存储一些值,以便稍后在代码中使用。
你可以在脚本中声明两种类型的变量:
-
用户定义的变量
-
环境变量
用户定义的变量
要声明一个变量,只需输入你想要的名称,并通过等号(=)设置其值。
查看这个示例:
#!/bin/bash
name="Mokhtar"
age=35
total=16.5
echo $name #prints Mokhtar
echo $age #prints 35
echo $total #prints 16.5
如你所见,要打印变量的值,你应该在变量前面加上美元符号($)。
注意,变量名和等号之间,或者等号和值之间没有空格。
如果你忘记了,在变量名和等号之间输入了空格,shell 会把这个变量当作命令来处理,而由于没有这样的命令,它会显示错误。
以下所有示例都是错误的声明:
# Don't declare variables like this:
name = "Mokhtar"
age =35
total= 16.5
另一种有用的用户定义变量类型是数组。一个数组可以存储多个值。所以,如果你有数十个值想要使用,应该使用数组,而不是将脚本填满多个变量。
要声明一个数组,只需将其元素放在括号中,像这样:
#!/bin/bash
myarr=(one two three four five)
要访问特定的数组元素,你可以像这样指定其索引:
#!/bin/bash
myarr=(one two three four five)
echo ${myarr[1]} #prints two which is the second element
索引是从零开始的。
要打印数组元素,你可以使用星号,像这样:
#!/bin/bash
myarr=(one two three four five)
echo ${myarr[*]}
要从数组中删除特定元素,你可以使用unset命令:
#!/bin/bash
myarr=(one two three four five)
unset myarr[1] #This will remove the second element
unset myarr #This will remove all elements
环境变量
到目前为止,我们使用了未定义的变量,例如$BASH_VERSION、$HOME、$PATH和$USER。你可能会想,既然我们没有声明这些变量,它们是从哪里来的?
这些变量是由 shell 为你定义的,用于你的使用,它们被称为环境变量。
环境变量有很多。如果你想列出它们,可以使用printenv命令。
此外,你还可以通过将其指定给printenv命令来打印特定的环境变量:
$ printenv HOME
我们可以在 bash 脚本中使用这些变量。
请注意,所有环境变量都使用大写字母书写,所以你可以将自己的变量声明为小写字母,以便轻松区分它们与环境变量。虽然这不是强制性的,但建议这么做。
变量作用域
一旦你声明了变量,它将可以在整个 bash 脚本中使用,毫无问题。
假设这种情况:你将代码分成两个文件,并且你将从其中一个文件执行另一个文件,像这样:
# The first script
#!/bin/bash
name="Mokhtar"
./script2.sh # This will run the second script
第二个脚本是这样的:
# The script2.sh script
#!/bin/bash
echo $name
假设你想在第二个脚本中使用name变量。如果你尝试打印它,什么也不会显示;这是因为变量的作用域仅限于创建它的进程。
要使用name变量,你可以使用export命令将其导出。
所以,我们的代码将是这样的:
# The first script
#!/bin/bash
name="Mokhtar"
export name # The variable will be accessible to other processes
./script2.sh
现在,如果你运行第一个脚本,它将打印来自第一个脚本文件的名称。
请记住,第二个进程或script2.sh仅复制变量,并不会触及原始变量。
为了证明这一点,尝试从第二个脚本中更改该变量,并尝试从第一个脚本中访问该变量的值:
# The first script
#!/bin/bash
name="Mokhtar"
export name
./script2.sh
echo $name
第二个脚本将是这样的:
# The first script
#!/bin/bash
name="Another name"
echo $name
如果你运行第一个脚本,它将打印来自第二个脚本的修改后的name,然后它将打印第一个脚本中的原始name。因此,原始变量保持不变。
命令替换
到目前为止,我们已经看到如何声明变量。这些变量可以存储整数、字符串、数组或浮动值,正如我们所看到的,但这还不是全部。
命令替换意味着将命令执行的输出存储在一个变量中。
如你所知,pwd命令打印当前工作目录。所以,我们将看看如何将其值存储在一个变量中。
有两种方法可以执行命令替换:
-
使用反引号字符(
') -
使用美元符号格式,如:
$()
使用第一种方法,我们只需将命令放在两个反引号之间:
#!/bin/bash
cur_dir='pwd'
echo $cur_dir
第二种方法写法如下:
#!/bin/bash
cur_dir=$(pwd)
echo $cur_dir
来自命令的输出可以进一步处理,并根据该输出采取相应的行动。
调试你的脚本
由于到目前为止我们看到的脚本非常简单,几乎没有什么会出错或需要调试的地方。随着脚本的增长,包含了带有条件语句的决策路径时,我们可能需要使用一定程度的调试来更好地分析脚本的执行过程。
Bash 提供了两个选项:-v 和 -x。
如果我们想查看脚本的详细输出和逐行评估脚本的详细信息,可以使用 -v 选项。这可以在 shebang 中使用,但通常直接用 bash 运行脚本更为方便:
$ bash -v $HOME/bin/hello2.sh fred
这在这个例子中尤其有用,因为我们可以看到每个嵌入的 basename 命令元素是如何被处理的。第一步是移除引号,然后是括号。看看以下输出:

-x 选项用于显示命令在执行时的过程,它更常用。它有助于了解脚本选择了哪个决策分支。接下来的例子展示了这一点:
$ bash -x $HOME/bin/hello2.sh fred
我们再次看到 basename 首先被求值,但我们看不到运行该命令时涉及的更详细的步骤。接下来的截图捕捉了命令和输出:

之前的方法可能对初学者或那些有编程背景、习惯通过视觉调试代码的人来说较为困难。
调试 Shell 脚本的另一种现代方式是使用 Visual Studio Code。
有一个叫做 bash debug 的插件,可以让你像调试其他编程语言一样,视觉化地调试 Bash 脚本。
你可以逐步进入、逐步跳过、添加观察点,以及做你知道的所有常规调试操作。
安装插件后,从文件菜单打开你的 shell-scripts 文件夹。然后你可以通过按 Ctrl + Shift + P 并输入以下内容来配置调试过程:
Debug:open launch.json
这将打开一个空文件;在其中输入以下配置:
{
"version": "0.2.0",
"configurations": [
{
"name": "Packt Bash-Debug",
"type": "bashdb",
"request": "launch",
"scriptPath": "${command:SelectScriptName}",
"commandLineArguments": "",
"linux": {
"bashPath": "bash"
},
"osx": {
"bashPath": "bash"
}
}
]
}
这将创建一个名为 Packt Bash-Debug 的调试配置:

现在插入一个断点并按 F5,或从调试菜单开始调试;它会显示你所有 .sh 文件的列表:

选择你要调试的部分,并在任意一行设置断点进行测试,如下图所示:

你可以在逐步调试代码行时添加观察点来查看变量的值:

请注意,你的脚本 必须 以 Bash shebang 开头,#!/bin/bash。
现在你可以享受可视化调试方法的乐趣了。祝你编码愉快!
总结
本章到此结束,你无疑已经觉得这对你有帮助。特别是对于那些刚刚开始学习 bash 脚本的人,本章将为你建立一个坚实的基础,帮助你更好地积累知识。
我们首先确保 bash 是安全的,不会受到嵌入函数引发的 shell-shock 影响。确保 bash 安全后,我们考虑了执行层次结构,在命令执行之前,会先检查别名、函数等;了解这一点有助于我们规划一个良好的命名结构,并找到脚本的位置。
然后我们继续介绍了不同类型的 Linux shell,并且了解了什么是 bash 脚本。
很快,我们开始编写简单的静态内容脚本,但也看到使用参数如何轻松增加灵活性。我们可以通过$?变量读取脚本的退出码,还可以使用||和&&创建命令行列表,这取决于前一个命令是否成功或失败。
然后我们学习了如何声明变量以及如何使用环境变量。我们确定了变量的作用域,并了解了如何将它们导出到其他进程中。
我们还学习了如何将命令的输出存储到变量中,这个过程叫做命令替换。
最后,我们通过使用 bash 选项和 VS Code 调试脚本来结束本章。对于简单的脚本来说,这其实不是必须的,但当脚本复杂度增加时,这会变得非常有用。
在下一章中,我们将创建交互式脚本,在脚本执行期间读取用户的输入。
问题
- 以下代码有什么问题?我们该如何修复它?
#!/bin/bash
var1 ="Welcome to bash scripting ..."
echo $var1
- 以下代码的结果是什么?
#!/bin/bash
arr1=(Saturday Sunday Monday Tuesday Wednesday)
echo ${arr1[3]}
- 以下代码有什么问题?我们该如何修复它?
#!/bin/bash
files = 'ls -la'
echo $files
- 以下代码中的 b 和 c 变量的值是什么?
#!/bin/bash
a=15
b=20
c=a
b=c
进一步阅读
请参阅以下内容,进一步阅读与本章相关的内容:
第二章:创建交互式脚本
在第一章,Bash 脚本的是什么以及为什么要使用,我们学习了如何创建脚本并使用其中的一些基本元素。这些包括可选参数,我们可以在脚本执行时传递给脚本。在本章中,我们将通过使用 shell 的内置read命令来扩展这一点,以允许交互式脚本。交互式脚本是在脚本执行过程中提示输入信息的脚本。
在本章中,我们将讨论以下主题:
-
使用选项的
echo -
使用
read的基本脚本 -
脚本注释
-
使用
read提示增强读取脚本 -
限制输入字符的数量
-
控制输入文本的可见性
-
传递选项
-
读取选项值
-
尝试遵循标准
-
使用简单脚本增强学习
技术要求
本章的源代码可以从这里下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter02
使用带选项的echo
到目前为止,在本书中我们已经看到echo命令非常有用,并且将被用于许多我们的脚本中,如果不是所有脚本的话。在运行echo命令时,除非我们声明文件的完整路径,否则将使用内置命令。我们可以通过以下命令来测试这一点:
$ which echo
要获取内置命令的帮助,我们可以使用man bash并搜索echo;然而,echo命令与内部命令是相同的,因此我们建议在大多数情况下使用man echo来显示命令选项。
到目前为止,我们看到的echo基本用法将产生文本输出和新行。这通常是期望的响应,因此我们无需担心下一个提示会附加到回显文本的末尾。新行将脚本输出与下一个 shell 提示分开。如果我们没有提供任何文本字符串进行打印,echo将只打印新行到STDOUT。我们可以通过以下命令在命令行中直接测试这一点。我们不需要在脚本中运行echo或实际上任何其他命令。要从命令行运行echo,我们只需输入如下命令:
$ echo
输出将显示命令与后续提示之间的明显新行。我们可以在下面的截图中看到这一点:

如果我们想抑制换行符,特别是在提示用户时非常有用,我们可以通过以下两种方式借助echo来实现:
$ echo -n "Which directory do you want to use? "
$ echo -e "Which directory do you want to use? \c"
结果将会抑制换行符。在初始示例中,使用了-n选项来抑制换行符。第二个示例使用了更通用的-e选项,它允许在文本字符串中添加转义序列。为了在同一行继续,我们使用\c作为转义序列。
作为脚本的最终部分,或者在命令行中运行时,这看起来并不好,因为命令提示符会跟随显示。以下截图展示了这一点:

使用 read 的基本脚本
当作为一个提示用户输入的脚本的一部分时,抑制换行正是我们想要的效果。我们将从将现有的 hello2.sh 脚本复制到 hello3.sh 开始,并构建一个交互式脚本。最初,我们将使用 echo 作为提示机制,但随着脚本逐渐增强,我们将直接通过 shell 内置的 read 命令生成提示:
$ cp $HOME/bin/hello2.sh $HOME/bin/hello3.sh
$ chmod +x $HOME/bin/hello3.sh
编辑 $HOME/bin/hello3.sh 脚本,使其内容如下:
#!/bin/bash
echo -n "Hello $(basename $0)! May I ask your name: "
read
echo "Hello $REPLY"
exit 0
在执行脚本时,我们将会看到问候语并提示输入用户输入的内容。这是通过 echo 语句中的 $REPLY 变量回显的。由于我们尚未为 read 内置命令提供变量名,因此使用默认的 $REPLY 变量。脚本的执行和输出如下图所示。请花些时间在你的系统上实践这个脚本。

这一小步让我们走了很远,这样的脚本有很多用途;我们都曾使用过在安装过程中提示选项和目录的安装脚本。我们接受它仍然非常简单,但随着我们深入这一章节,我们将逐步接近一些更有用的脚本。
脚本注释
我们应该始终在脚本的早期加入注释。脚本注释以 # 符号开始。# 符号后面的内容是注释,脚本不会对其进行求值。Shebang #!/bin/bash 主要是一个注释,因此不会被 shell 求值。运行脚本的 shell 会读取整个 shebang,这样它就知道应该将脚本交给哪个命令解释器。注释可以位于行的开头,也可以在行中间。Shell 脚本没有多行注释的概念。
如果你还不熟悉注释,请注意它们是添加到脚本中的,用来描述谁编写了脚本,脚本何时编写和最后更新,以及脚本的作用。它们是脚本的元数据。
以下是脚本中注释的示例:
#!/bin/bash
# Welcome to bash scripting
# Author: Mokhtar
# Date: 1/5/2018
良好的实践是注释代码,并添加解释代码作用和原因的注释。这将帮助你和你的同事在日后编辑脚本时理解它。
通过 read 提示增强脚本
我们已经看到如何使用内置的 read 来填充变量。到目前为止,我们使用 echo 来产生提示,但这可以通过 -p 选项传递给 read 本身。read 命令将会抑制额外的换行符,从而在一定程度上减少了行数和复杂性。
我们可以直接在命令行上测试这一点。尝试输入以下命令来查看 read 的实际效果:
$ read -p "Enter your name: " name
我们使用带有-p选项的read命令。选项后面的参数是提示符中出现的文本。通常,我们会确保文本的末尾有一个空格,以便清楚地看到我们输入的内容。这里提供的最后一个参数是我们想要填充的变量,我们简单地称之为 name。变量也是区分大小写的。即使我们没有提供最后一个参数,我们仍然可以存储用户的响应,但这时它会存储在REPLY变量中。
当我们返回一个变量的值时,我们使用$,但在写入时不使用。简单来说,当读取一个变量时,我们使用$VAR,而设置一个变量时,我们写作VAR=value。
使用-p选项的read命令的语法如下:
read -p <prompt> <variable name>
我们可以编辑脚本,使其看起来类似于hello3.sh中的以下摘录:
#!/bin/bash
read -p "May I ask your name: " name
echo "Hello $name"
exit 0
read提示符不能在消息字符串中执行命令,如我们之前使用的那些命令。
限制输入的字符数
在我们至今使用的脚本中并未需要此功能,但我们可能需要要求用户按任意键继续。目前,我们的设置方式是,在按下Enter键之前,变量并不会被填充。用户必须按Enter键才能继续。如果我们使用-n选项后跟一个整数,我们可以指定在继续之前接受的字符数;在这种情况下,我们将设置为1。请看以下代码摘录:
#!/bin/bash
read -p "May I ask your name: " name
echo "Hello $name"
read -n1 -p "Press any key to exit"
echo
exit 0
现在,脚本在显示名字后将暂停,直到我们按下任意键;我们可以按任何键继续,因为我们只接受一个按键输入,而之前我们必须保持默认行为,因为我们无法知道输入的名字有多长。我们必须等用户按下Enter键。
我们在这里添加了一个额外的 echo 命令,以确保脚本结束前会输出一个新行。这确保了 shell 提示符会在新的一行开始。
控制输入文本的可见性
即使我们将输入限制为单个字符,我们仍然能看到屏幕上的文本。同样地,如果我们输入名字,在按下Enter键之前我们可以看到输入的文本。在这种情况下,它只是显得不整洁,但如果我们输入的是敏感数据,如 PIN 码或密码,我们应该隐藏文本。我们可以使用静默选项,或者-s,来实现这一点。脚本中的简单编辑将完成这一设置:
#!/bin/bash
read -p "May I ask your name: " name
echo "Hello $name"
read -sn1 -p "Press any key to exit"
echo
exit 0
现在,当我们按键继续时,文本将不会显示在屏幕上。我们可以在以下截图中看到脚本的行为:

传递选项
到目前为止,我们在第一章中学习了如何从用户那里读取参数。此外,你还可以传递选项。那么,什么是选项?它们与参数有何不同?
选项是带有单一破折号的字符。
看这个例子:
$ ./script1.sh -a
-a 是一个选项。你可以在脚本中检查用户是否输入了这个选项;如果是,你的脚本可以根据这个选项执行某些操作。
你可以传递多个选项:
$ ./script1.sh -a -b -c
要打印这些选项,可以使用 $1、$2 和 $3 变量:
#!/bin/bash
echo $1
echo $2
echo $3

我们应该检查这些选项,但由于我们还没有讨论条件语句,所以暂时保持简单。
选项可以像这样传递值:
$ ./script1.sh -a -b 20 -c
这里,-b 选项被传递,并且它的值是 20。

如你所见,变量 $3=20,这是传入的值。
这可能不符合你的要求。你需要 $2=-b 和 $3=-c。
我们将使用一些条件语句来确保这些选项正确。
#!/bin/bash
while [ -n "$1" ]
do
case "$1" in
-a) echo "-a option used" ;;
-b) echo "-b option used" ;;
-c) echo "-c option used" ;;
*) echo "Option $1 not an option" ;;
esac
shift
done
如果你不了解 while 循环,没关系;我们将在接下来的章节详细讨论条件语句。
shift 命令将选项向左移动一步。
所以,如果我们有三个选项或参数,并且使用 shift 命令:
-
$3变成了$2 -
$2变成了$1 -
$1被丢弃
这就像是在使用 while 循环遍历选项时,向前移动的一个动作。
所以,在第一次循环中,$1 会是第一个选项。选项移动后,$1 将是第二个选项,以此类推。
如果你尝试前面的代码,你会注意到它仍然没有正确识别选项的值。别担心,解决方案马上就来,稍等一下。
传递带有选项的参数
要同时传递参数和选项,你必须用双破折号分隔它们,像这样:
$ ./script1.sh -a -b -c -- p1 p2 p3
使用前面提到的技术,我们可以遍历选项直到遇到双破折号,然后我们将遍历参数:
#!/bin/bash
while [ -n "$1" ]
do
case "$1" in
-a) echo "-a option found" ;;
-b) echo "-b option found";;
-c) echo "-c option found" ;;
--) shift
break ;;
*) echo "Option $1 not an option";;
esac
shift
done
#iteration over options is finished here.
#iteration over parameters started.
num=1
for param in $@
do
echo "#$num: $param"
num=$(( $num + 1 ))
done
现在,如果我们将其与参数和选项一起运行,我们应该能看到选项列表和另一个参数列表:
$ ./script1.sh -a -b -c -- p1 p2 p3

如你所见,双破折号后传递的任何内容都会被视为参数。
读取选项值
我们已经看到了如何识别选项和参数,但我们仍然需要一种正确读取选项值的方法。
你可能需要为特定选项传递一个值。如何读取这个值呢?
我们将在迭代过程中检查 $2 变量,针对我们期望有值的选项。
看一下以下代码:
#!/bin/bash
while [ -n "$1" ]
do
case "$1" in
-a) echo "-a option passed";;
-b) param="$2"
echo "-b option passed, with value $param"
shift ;;
-c) echo "-c option passed";;
--) shift
break ;;
*) echo "Option $1 not an option";;
esac
shift
done
num=1
for param in "$@"
do
echo "#$num: $param"
num=$(( $num + 1 ))
done

现在看起来不错;你的脚本已经能够识别选项和第二个选项的传递值。
有一个内建选项可以让用户获取选项,即使用 getopt 函数。
不幸的是,getopt 不支持多字符选项。
有一个非内建程序叫做 getopt,它支持多于一个字符的选项,但再次强调,macOS X 版本不支持长选项。
无论如何,如果你想了解更多关于 getopt 的使用方法,可以参考本章后面的进一步阅读资源。
尝试保持标准
你可以使用来自 GitHub 的 bash 脚本,并且你可能会注意到,遵循了一种标准的选项方案。虽然这不是强制性的,但这是推荐的做法。
这些是一些常用的选项:
-
-a: 列出所有项目 -
-c: 获取所有项目的数量 -
-d: 输出目录 -
-e: 展开项目 -
-f: 指定文件 -
-h: 显示帮助页面 -
-i: 忽略字符大小写 -
-l: 列出文本 -
-o: 将输出发送到文件 -
-q: 保持静默模式;不询问用户 -
-r: 递归处理某些内容 -
-s: 使用隐身模式 -
-v: 使用详细模式 -
-x: 指定可执行文件 -
-y: 无需提示直接接受
使用简单脚本增强学习
我们的脚本仍然有些简单,我们还没有学习如何使用条件语句来测试正确的输入,但让我们来看一些我们可以构建的具有某些功能的简单脚本。
使用脚本备份
现在我们已经创建了一些脚本,可能希望将它们备份到其他位置。如果我们创建一个提示脚本,便可以选择我们想要备份的位置和文件类型。
以下是你第一次练习时可以使用的脚本。创建该脚本并命名为$HOME/backup.sh:
#!/bin/bash
# Author: @theurbanpenguin
# Web: www.theurbapenguin.com
# Script to prompt to back up files and location
# The files will be search on from the user's home
# directory and can only be backed up to a directory
# within $HOME
# Last Edited: July 4 2015
read -p "Which file types do you want to backup " file_suffix
read -p "Which directory do you want to backup to " dir_name
# The next lines creates the directory if it does not exist
test -d $HOME/$dir_name || mkdir -m 700 $HOME/$dir_name
# The find command will copy files the match the
# search criteria ie .sh . The -path, -prune and -o
# options are to exclude the backdirectory from the
# backup.
find $HOME -path $HOME/$dir_name -prune -o \
-name "*$file_suffix" -exec cp {} $HOME/$dir_name/ \;
exit 0
你会看到该文件有注释;虽然是黑白显示,但可读性稍有困难。如果你有本书的电子版,你应该能看到以下截图中的彩色内容:

在脚本运行时,你可以选择.sh作为备份的文件类型,backup作为备份目录。脚本执行过程如下截图所示,并列出了目录:

现在你可以看到,我们可以开始使用简单的脚本创建有意义的脚本;尽管我们强烈建议,如果这个脚本用于个人以外的用途,应该添加用户输入的错误检查。随着我们继续深入书籍内容,我们将涉及到这一点。
连接到服务器
让我们来看一些可以用来连接到服务器的实用脚本。首先,我们将查看 ping 命令,接下来的脚本则会提示输入 SSH 凭证。
版本 1 – ping
这是我们都能做的事情,因为不需要特殊服务。这将简化ping命令,尤其对于那些可能不熟悉该命令细节的控制台用户。该命令会对服务器进行三次 ping,而不是通常的无限次。如果服务器正常,则没有输出;如果服务器失败,则显示server dead。请创建以下脚本并命名为$HOME/bin/ping_server.sh:
#!/bin/bash
# Author: @theurbanpenguin
# Web: www.theurbapenguin.com
# Script to ping a server
# Last Edited: July 4 2015
read -p "Which server should be pinged " server_addr
ping -c3 $server_addr 2>1 > /dev/null || echo "Server Dead"
以下截图展示了成功和失败的输出:

版本 2 – SSH
通常 SSH 已经在服务器上安装并运行,所以如果你的系统运行 SSH 或你可以访问 SSH 服务器,你可能能够运行此脚本。在这个脚本中,我们提示输入服务器地址和用户名,并将它们传递给 SSH 客户端。将以下脚本创建为 $HOME/bin/connect_server.sh:
#!/bin/bash
# Author: @theurbanpenguin
# Web: www.theurbapenguin.com
# Script to prompt fossh connection
# Last Edited: July 4 2015
read -p "Which server do you want to connect to: " server_name
read -p "Which username do you want to use: " user_name
ssh ${user_name}@$server_name
使用大括号的目的是为了将变量与脚本最后一行中的 @ 符号分隔开。
版本 3 – MySQL/MariaDB
在下一个脚本中,我们将提供有关数据库连接的详细信息,以及要执行的 SQL 查询。如果你的系统上有 MariaDB 或 MySQL 数据库服务器,或者你能够连接到一个数据库服务器,你将能够运行此脚本。为了演示,我们将使用 Linux Mint 18.3 和 MariaDB 版本 10;然而,这应该适用于任何 MySQL 或 MariaDB 服务器,从 5 版本起。该脚本收集用户和密码信息以及要执行的 SQL 命令。将以下脚本创建为 $HOME/bin/run_mysql.sh:
#!/bin/bash
# Author: @theurbanpenguin
# Web: www.theurbapenguin.com
# Script to prompt for MYSQL user password and command
# Last Edited: July 4 2015
read -p "MySQL User: " user_name
read -sp "MySQL Password: " mysql_pwd
echo
read -p "MySQL Command: " mysql_cmd
read -p "MySQL Database: " mysql_db
mysql -u"$user_name" -p$mysql_pwd $mysql_db -Be"$mysql_cmd"
在这个脚本中,我们可以看到,在使用 -s 选项将 MySQL 密码输入到 read 命令时,我们抑制了密码的显示。同样,我们直接使用 echo 以确保下一个提示符从新的一行开始。
脚本输入如下截图所示:

现在我们可以轻松看到密码抑制功能的工作效果,以及向 MySQL 命令添加选项的简便性。
读取文件
read 命令不仅用于从用户读取输入;你还可以使用 read 命令读取文件以供进一步处理。
#!/bin/bash
while read line
do
echo $line
done < yourfile.txt
我们将文件内容重定向到 while 命令,使用 read 命令逐行读取内容。
最后,我们使用 echo 命令打印该行。
总结
为自己感到骄傲,因为你现在已经获得了 我会读 的 shell 脚本徽章。我们已经将脚本开发得具有互动性,并在脚本执行过程中提示用户输入。这些提示可以简化用户在命令行上的操作。通过这种方式,用户不需要记住命令行选项,也不需要存储最终出现在命令行历史记录中的密码。在使用密码时,我们可以简单地使用 -sp 选项将值存储。
此外,我们还学习了如何传递带有和不带有值的选项,并正确识别值。我们看到了如何同时传递选项和参数,感谢双破折号。
在下一章中,我们将花时间研究 bash 中的条件语句。
问题
- 以下代码中有多少个注释?
#!/bin/bash
# Welcome to shell scripting
# Author: Mokhtar
- 如果我们有以下代码:
#!/bin/bash
echo $1
echo $2
echo $3
然后我们使用这些选项运行脚本:
$ ./script1.sh -a -b50 -c
运行此代码的结果是什么?
- 查看以下代码:
#!/bin/bash
shift
echo $#
如果我们使用这些选项运行它:
$ ./script1.sh Mokhtar -n -a 35 -p
-
-
结果是什么?
-
被丢弃的参数是什么?
-
进一步阅读
请参见以下内容,以获取与本章相关的更多阅读材料:
第三章:附加条件
现在你可以使用read命令让脚本更加互动,并且你已经知道如何读取参数和选项来简化输入。
我们可以说,我们现在进入了脚本的细节部分。这些是通过条件语句在脚本中编写的细节,用于测试某个语句是否应该执行。现在我们准备为脚本添加一些智能,这样脚本会变得更健壮、更易用、更可靠。条件语句可以通过简单的命令行列表(AND或OR命令)来编写,或者更常见的是在传统的if语句中编写。
本章将涵盖以下主题:
-
使用命令行列表的简单决策路径
-
使用列表验证用户输入
-
使用测试 Shell 内建命令
-
使用
if创建条件语句 -
使用
else扩展if -
使用
test命令与if命令 -
使用
elif添加更多条件 -
使用 case 语句
-
使用
grep的配方前端
技术要求
本章的源代码可以从此处下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter03
使用命令行列表的简单决策路径
我们在第一章《Bash 脚本的是什么以及为什么要使用它》和第二章《创建交互式脚本》中都使用了命令行列表(||和&&)。列表是我们可以创建的最简单的条件语句之一,因此我们认为在这里彻底解释之前,在前面的示例中使用它们是合适的。
命令行列表是两个或更多通过AND或OR符号连接的语句:
-
&&:AND -
||:OR
当两个语句通过AND符号连接时,第二个命令只有在第一个命令成功时才会执行。而使用OR符号时,第二个命令只有在第一个命令失败时才会执行。
命令是否成功执行的决定是通过读取应用程序的退出代码来判断的。零表示应用程序成功完成,非零表示失败。我们可以通过读取系统变量$?来测试应用程序的成功或失败。以下是示例:
$ echo $?
如果我们需要确保脚本从用户的主目录运行,我们可以将其构建到脚本的逻辑中。这可以通过命令行进行测试,并且不一定非得在脚本中。考虑以下命令行示例:
$ test $PWD == $HOME || cd $HOME
双竖线(||)表示OR布尔逻辑。这确保第二个语句只有在第一个语句不为真时才会执行。简单来说,如果我们当前不在主目录中,命令执行完毕后我们将会回到主目录。稍后我们会详细讲解test命令。
我们可以将这段代码添加到几乎任何命令中,而不仅仅是test命令。例如,我们可以查询是否有用户已登录到系统,如果有,我们可以使用write命令直接向他们的终端发送消息。与之前类似,我们可以在命令行中测试这个功能,然后再将其添加到脚本中。以下是命令行示例:
$ who | grep pi > /dev/null 2>&1 && write pi < message.txt
请注意,你应该将pi用户替换为你的用户名。
如果我们在脚本中使用它,几乎可以确定我们会将用户名替换为变量。一般来说,如果我们需要多次引用同一个值,那么使用变量是个好主意。在这种情况下,我们正在寻找pi用户。
当我们将命令行列表拆解时,首先使用who命令列出已登录的用户。我们将列表通过管道传递给grep,以便搜索所需的用户名。我们对搜索结果不感兴趣,只关注其是否成功。考虑到这一点,我们将所有输出重定向到/dev/null。双重与号(&&)表示只有在第一个语句返回true时,第二个语句才会执行。如果pi用户已登录,我们会使用write命令向该用户发送消息。以下截图展示了该命令及其输出:

使用列表验证用户输入
在这个脚本中,我们将确保第一个位置参数已经被赋值。我们可以修改在第一章中创建的hello2.sh脚本,使用 Bash 脚本的目的与原理,在显示hello文本之前检查用户输入。
你可以将hello2.sh脚本复制到hello4.sh,或者从头创建一个新脚本。输入的内容不会很多,脚本将被创建为$HOME/bin/hello4.sh,如下所示:

我们可以通过以下命令确保脚本是可执行的:
$ chmod +x $HOME/bin/hello4.sh
然后,我们可以带参数或不带参数地运行这个脚本。test语句会检查$1变量是否为空字节。如果是,那么我们不会看到hello语句;否则,它会打印hello消息。简单来说,如果我们提供了名字,就会看到hello消息。
以下截图展示了当你没有向脚本提供参数时的输出,后面跟着提供的参数:

使用test内建命令
可能是时候我们在脚本的高速公路旁稍作停留,仔细看看test命令了。它既是一个 Shell 内建命令,也是一个独立的文件可执行命令。当然,除非我们指定文件的完整路径,否则必须先调用内建命令。
当test命令没有表达式进行评估时,测试将返回假值。因此,如果我们按照以下命令运行test,即使没有错误输出,退出状态仍然是1:
$ test
test命令将始终返回True或False,或0或1,分别对应真假值。test命令的基本语法如下:
test EXPRESSION
或者,我们可以通过以下命令反转test命令:
test ! EXPRESSION
如果需要包含多个表达式,可以使用AND或OR组合这两个表达式,分别使用-a和-o选项:
test EXPRESSION -a EXPRESSION
test EXPRESSION -o EXPRESSION
我们还可以写成简写形式,使用方括号将表达式括起来,替代test命令,示例如下:
[ EXPRESSION ]
测试字符串
我们可以测试两个字符串的相等性或不等性。例如,测试 root 用户的一种方法是使用以下命令:
test $USER = root
我们也可以使用方括号表示法来写这个命令:
[ $USER = root ]
请注意,每个括号和内部测试条件之间必须留有空格,正如前面所示。
同样,我们也可以通过以下两种方法来测试非 root 账户:
test ! $USER = root
[ ! $USER = root ]
我们还可以测试字符串的零值或非零值。在本章的早期示例中,我们已经看到过这个方法。
要测试一个字符串是否有值,我们可以使用-n选项。我们可以通过检查用户环境中是否存在某个变量,来判断当前的连接是否通过 SSH 建立。我们通过使用test命令和方括号来完成这一检查,以下是两个示例:
test -n $SSH_TTY
[ -n $SSH_TTY ]
如果为真,则表示连接是通过 SSH 建立的;如果为假,则表示连接不是通过 SSH 建立的。
如前所述,测试零字符串值对于判断变量是否已设置非常有用:
test -z $1
或者,更简单的方法是使用以下命令:
[ -z $1 ]
该查询的结果为真意味着没有向脚本提供输入参数。
测试整数
除了测试 bash 脚本的字符串值外,我们还可以测试整数值和整数。测试脚本输入的另一种方式是计算位置参数的数量,并测试该数量是否大于0:
test $# -gt 0
或者使用方括号,如下所示:
[ $# -gt 0 ]
在脚本中,$#变量表示传递给脚本的参数数量。
有很多可以用于数字的测试:
-
number1 -eq number2:检查number1是否等于number2。 -
number1 -ge number2:检查number1是否大于或等于number2。 -
number1 -gt number2:检查number1是否大于number2。 -
number1 -le number2:检查number1是否小于或等于number2。 -
number1 -lt number2:此命令检查number1是否小于number2 -
number1 -ne number2:此命令检查number1是否不等于number2
测试文件类型
在测试值时,我们可以测试文件或文件类型的存在。例如,我们可能只想在文件是符号链接时才删除它。在编译内核时,我们会使用这种方法。/usr/src/linux目录应该是指向最新内核源代码的符号链接。如果我们在编译新内核之前下载了更新版本,我们需要删除现有的链接并创建一个新链接。为了防止有人创建了/usr/src/linux目录,我们可以在删除之前测试它是否存在链接:
# [ -h /usr/src/linux ] &&rm /usr/src/linux
-h选项用于测试文件是否有链接。其他选项包括以下内容:
-
-d:此命令显示该文件是否为目录 -
-e:此命令显示文件是否以任何形式存在 -
-x:此命令显示文件是否可执行 -
-f:此命令显示文件是否为常规文件 -
-r:此命令显示文件是否可读 -
-p:此命令显示文件是否为命名管道 -
-b:此命令显示文件是否为块设备 -
file1 -nt file2:此命令检查file1是否比file2更新 -
file1 -ot file2:此命令检查file1是否比file2更旧 -
-O file:此命令检查当前登录用户是否为该文件的所有者 -
-c:此命令显示文件是否为字符设备
还存在更多选项,因此根据需要深入阅读主要页面。我们将在整本书中使用不同的选项,从而为您提供实用且有用的示例。
使用if语句创建条件语句
正如我们所看到的,通过使用命令行列表,构建简单的条件是可能的。这些条件语句可以有或没有测试。随着任务复杂性的增加,使用if语句创建语句变得更加简单。这肯定会提高脚本的可读性和逻辑布局。从某种程度上说,它也符合我们思考和表达的方式;if在我们的口语中就像在 bash 脚本中一样,都是一种语义。
尽管在脚本中会占用多于一行,但通过使用if语句,我们可以实现更多功能,并使脚本更具可读性。话虽如此,让我们来看看如何创建if条件。以下是一个使用if语句的脚本示例:
#!/bin/bash
# Welcome script to display a message to users on login
# Author: @theurbanpenguin
# Date: 1/1/1971
if [ $# -lt 1 ] ; then
echo "Usage: $0 <name>"
exit 1
fi
echo "Hello $1"
exit 0
if语句中的代码仅在条件为真时运行,if语句块的结束由fi表示——if的倒写。在vim中的颜色编码有助于提高可读性,您将在下面的截图中看到:

在脚本中,我们可以轻松添加多个语句,当条件为true时运行。在我们的案例中,这包括退出脚本并显示错误提示,以及包含usage语句以帮助用户。这样可以确保我们只有在提供了被欢迎人的名字时才显示hello消息。
我们可以通过以下截图查看脚本执行的情况,分别是在有和没有参数的情况下:

以下伪代码展示了if条件语句的语法:
if condition; then
statement 1
statement 2
fi
缩进代码不是必须的,但它有助于提高可读性,并且强烈推荐。将then语句与if语句写在同一行也有助于提高代码的可读性,并且需要使用分号将if与then分隔开。
使用else扩展if
当脚本需要继续执行,无论if条件结果如何时,通常需要处理评估的两个条件,即true和false时的操作。这时可以使用else关键字。这样可以在条件为真时执行一个代码块,在条件为假时执行另一个代码块。以下是该伪代码:
if condition; then
statement
else
statement
fi
如果我们考虑扩展之前创建的hello5.sh脚本,完全可以确保无论参数是否存在,都能正确执行。我们可以将其重新创建为hello6.sh,如下所示:
#!/bin/bash
# Welcome script to display a message to users
# Author: @theurbanpenguin
# Date: 1/1/1971
if [ $# -lt 1 ] ; then
read -p "Enter a name: "
name=$REPLY
else
name=$1
fi
echo "Hello $name"
exit 0
现在脚本设置了一个命名变量,这有助于提高可读性,我们可以将正确的值从输入参数或read提示中赋给$name;无论哪种方式,脚本都能很好地工作并开始成型。
使用if命令测试
你已经看到如何使用test命令或简写的[ ]。这个测试返回零(true)或非零(false)。
你将看到如何使用if命令检查返回结果。
检查字符串
你可以将if命令与test命令结合使用,检查字符串是否符合特定条件:
-
if [$string1 = $string2]:检查string1是否与string2相同 -
if [$string1 != $string2]:检查string1是否与string2不同 -
if [$string1 \< $string2]:检查string1是否小于string2 -
if [$string1 \> $string2]:检查string1是否大于string2
小于号和大于号应该用反斜杠转义,以防显示警告。
-
if [-n $string1]:检查string1是否长度大于零 -
if [-z $string1]:检查string1的长度是否为零
让我们看一些例子来解释if语句是如何工作的:
#!/bin/bash
if [ "mokhtar" = "Mokhtar" ]
then
echo "Strings are identical"
else
echo "Strings are not identical"
fi

这个if语句检查字符串是否相同;由于字符串不相同,因为其中一个包含了大写字母,它们被判定为不相同。
注意方括号与变量之间的空格;如果没有这个空格,某些情况下会显示警告。
不等于运算符(!=)的作用相同。此外,你可以对if语句取反,它也会按照相同的方式工作,如下所示:
if ! [ "mokhtar" = "Mokhtar" ]
小于号和大于号运算符检查第一个字符串是否从 ASCII 顺序上大于或小于第二个字符串:
#!/bin/bash
if [ "mokhtar" \> "Mokhtar" ]
then
echo "String1 is greater than string2"
else
echo "String1 is less than the string2"
fi

在 ASCII 顺序中,小写字母的值高于大写字母。
如果你使用sort命令对文件进行排序或者类似的操作,发现排序顺序与test命令的排序方向相反,别搞混淆。这是因为sort命令使用了系统设置的数字顺序,而这个顺序与 ASCII 顺序正好相反。
要检查字符串长度,你可以使用-n测试:
#!/bin/bash
if [ -n "mokhtar" ]
then
echo "String length is greater than zero"
else
echo "String is zero length"
fi

要检查长度是否为零,你可以使用-z测试:
#!/bin/bash
if [ -z "mokhtar" ]
then
echo "String length is zero"
else
echo "String length is not zero"
fi

我们在测试字符串时使用了引号,即使我们的字符串中没有空格。
如果你有一个包含空格的字符串,你必须使用引号。
检查文件和目录
同样,你可以使用if语句检查文件和目录。
我们来看一个例子:
#!/bin/bash
mydir=/home/mydir
if [ -d $mydir ]
then
echo "Directory $mydir exists."
else
echo "Directory $mydir not found."
fi
我们使用了-d测试来检查路径是否为目录。
其他的测试方法都一样。
检查数字
同样,我们也可以使用test和if命令检查数字。
#!/bin/bash
if [ 12 -gt 10 ]
then
echo "number1 is greater than number2"
else
echo "number1 is less than number2"
fi

正如预期的,12大于10。
所有其他的数字测试方式相同。
组合测试
你可以将多个测试结合起来,使用一个if语句进行检查。
这可以通过使用AND(&&)和OR(||)命令来完成:
#!/bin/bash
mydir=/home/mydir
name="mokhtar"
if [ -d $mydir ] && [ -n $name ]; then
echo "The name is not zero length and the directory exists."
else
echo "One of the tests failed."
fi

if语句执行了两个检查,它检查目录是否存在,并且名称的长度是否为零。
这两个测试必须成功(返回零),才能执行下一个echo命令。
如果其中一个测试失败,if语句会进入else子句。
与OR(||)命令不同,如果任何一个测试返回成功(零),if语句就会成功。
#!/bin/bash
mydir=/home/mydir
name="mokhtar"
if [ -d $mydir ] || [ -n $name ]; then
echo "One of tests or both successes"
else
echo "Both failed"
fi

很明显,如果其中一个测试返回真,if语句会对所有组合的测试返回真。
使用 elif 的更多条件
当我们需要更高控制度时,可以使用elif关键字。与else不同,elif每次都需要一个附加的条件进行测试。通过这种方式,我们可以根据不同的情况做出不同的处理。我们可以根据需要添加任意多个elif条件。以下是一些伪代码示例:
if condition; then
statement
elif condition; then
statement
else
statement
fi
exit 0
脚本可以通过提供简化的选择来帮助操作员完成更复杂的代码部分。尽管脚本逐渐变得更加复杂以满足需求,但对于操作员来说,执行变得大大简化。我们的工作是让用户能够通过命令行轻松运行更复杂的操作,在创建脚本时,通常这需要向脚本中添加更多的复杂性;然而,我们将收获脚本化应用的可靠性。
使用 elif 创建 backup2.sh
我们可以重新审视之前创建的脚本,用于运行先前的备份。这个脚本$HOME/bin/backup.sh会提示用户选择文件类型和存储备份的目录。备份使用的工具是find和cp。
有了这些新学到的知识,我们现在可以允许脚本使用tar命令进行备份,并根据操作员选择的压缩级别进行压缩。无需选择文件类型,因为整个主目录会被备份,但会排除备份目录本身。
操作员可以根据三个字母H、M和L选择压缩级别。选择将影响传递给tar命令的选项以及创建的备份文件。高压缩使用bzip2,中等压缩使用gzip,低压缩则创建未压缩的tar归档。该逻辑存在于后续的扩展if语句中:
if [ $file_compression = "L" ] ; then
tar_opt=$tar_l
elif [ $file_compression = "M" ]; then
tar_opt=$tar_m
else
tar_opt=$tar_h
fi
根据用户的选择,我们可以为tar命令配置正确的选项。由于我们有三个条件需要评估,if、elif和else语句是合适的。要查看变量如何配置,我们可以参考以下脚本片段:
tar_l="-cvf $backup_dir/b.tar --exclude $backup_dir $HOME"
tar_m="-czvf $backup_dir/b.tar.gz --exclude $backup_dir $HOME"
tar_h="-cjvf $backup_dir/b.tar.bzip2 --exclude $backup_dir $HOME"
完整的脚本可以创建为$HOME/bin/backup2.sh,并应包含以下代码:
#!/bin/bash
# Author: @theurbanpenguin
# Web: www.theurbapenguin.com
read -p "Choose H, M or L compression " file_compression
read -p "Which directory do you want to backup to " dir_name
# The next lines creates the directory if it does not exist
test -d $HOME/$dir_name || mkdir -m 700 $HOME/$dir_name
backup_dir=$HOME/$dir_name
tar_l="-cvf $backup_dir/b.tar --exclude $backup_dir $HOME"
tar_m="-czvf $backup_dir/b.tar.gz --exclude $backup_dir $HOME"
tar_h="-cjvf $backup_dir/b.tar.bzip2 --exclude $backup_dir $HOME"
if [ $file_compression = "L" ] ; then
tar_opt=$tar_l
elif [ $file_compression = "M" ]; then
tar_opt=$tar_m
else
tar_opt=$tar_h
fi
tar $tar_opt
exit 0
当我们执行脚本时,需要选择大写的H、M或L,因为在脚本中就是这样进行选择的。以下截图显示了初始脚本执行的情况,其中已经选择了M:

使用 case 语句
与其使用多个elif语句,case语句在对单一表达式进行评估时,可能提供一种更简便的机制。
case语句的基本布局如下所示,使用伪代码:
case expression in
case1)
statement1
statement2
;;
case2)
statement1
statement2
;;
*)
statement1
;;
esac
我们看到的语句布局与其他语言中的switch语句相似。在 bash 中,我们可以使用case语句来测试简单的值,例如字符串或整数。case语句可以处理广泛的字母范围,例如[a-f]或从a到f,但它不能轻松处理如[1-20]这样的整数范围。
case语句首先会扩展表达式,然后尝试逐个与每个项目进行匹配。当找到匹配项时,所有语句都会执行,直到遇到;;,这表示该匹配的代码结束。如果没有匹配项,将匹配case语句中的else部分,即由*表示的项。这必须是列表中的最后一项。
请参考以下脚本grade.sh,它用于评估成绩:
#!/bin/bash
#Script to evaluate grades
#Usage: grade.sh stduent grade
#Author: @likegeeks
#Date: 1/1/1971
if [ ! $# -eq 2 ] ; then
echo "You must provide <student> <grade>"
exit 2
fi
case ${2^^} in #Parameter expansion is used to capitalize input
[A-C]) echo "$1 is a star pupil"
;;
[D]) echo "$1 needs to try a little harder!"
;;
[E-F]) echo "$1 could do a lot better next year"
;;
*) echo "Grade could not be evaluated for $1 $2"
;;
esac
脚本首先使用if语句检查是否确实向脚本提供了两个参数。如果没有提供,脚本将以错误状态退出:
if [ ! $# -eq2 ] ; then
echo "You must provide <student><grade>
exit 2
fi
然后我们使用参数扩展 $2 变量的值,通过 ^^ 来将输入转换为大写。这代表我们提供的等级。由于我们在将输入转换为大写,因此我们首先尝试与字母 A 到 C 进行匹配。
我们对其他提供的等级 E 到 F 进行了类似的测试。
以下截图显示了不同等级下脚本执行的情况:

配方 – 使用 grep 构建前端
作为本章的结尾,我们将把一些我们学到的功能组合在一起,编写一个脚本,提示操作员输入文件名、搜索字符串和要使用 grep 命令执行的操作。我们将创建脚本文件 $HOME/bin/search.sh,并且不要忘记将其设为可执行:
#!/bin/bash
#Author: @theurbanpenguin
usage="Usage: search.sh file string operation"
if [ ! $# -eq3 ] ; then
echo "$usage"
exit 2
fi
[ ! -f $1 ] && exit 3
case $3 in
[cC])
mesg="Counting the matches in $1 of $2"
opt="-c"
;;
[pP])
mesg="Print the matches of $2 in $1"
opt=""
;;
[dD])
mesg="Printing all lines but those matching $3 from $1"
opt="-v"
;;
*) echo "Could not evaluate $1 $2 $3";;
esac
echo $mesg
grep $opt $2 $1
我们通过以下代码开始检查是否正好有三个输入参数:
if [ ! $# -eq3 ] ; then
echo "$usage"
exit 2
fi
下一个检查使用命令行列表,如果文件参数不是常规文件,则退出脚本,使用 test -f:
[ ! -f $1 ]&& exit 3
case 语句允许进行三种操作:
-
统计匹配的行
-
打印匹配的行
-
打印除匹配行之外的所有行
以下截图显示了在 /etc/ntp.conf 文件中搜索以字符串 server 开头的行。在这个例子中,我们选择了 count 选项:

概述
编写脚本时,最重要且耗时的任务之一就是构建所有的条件语句,使脚本既可用又稳健。通常会提到一个 80/20 法则:20%的时间用于编写主要脚本,而 80%的时间用于确保脚本中所有可能的情况都得到正确处理。这就是我们所说的脚本的过程完整性,我们尝试仔细而准确地涵盖每一种场景。
我们首先查看了一个简单的命令行列表测试。如果需要的操作比较简单,那么这些命令提供了很好的功能,并且容易添加。在需要更多复杂性时,我们会加入 if 语句。
使用 if 语句时,我们可以根据需要通过 else 和 elif 关键字扩展它们。别忘了 elif 关键字需要有自己的条件来进行评估。
我们看到了如何使用 if 语句与 test 命令一起检查字符串、文件和数字。
最后,我们看到了如何使用 case 语句来评估一个需要单一表达式的情况。
在下一章中,我们将探讨阅读已经准备好的代码片段的重要性。我们将创建一个示例 if 语句,可以将其保存为代码片段,在编辑时加载到脚本中。
问题
- 以下代码的结果是什么:
True还是False?
if [ "LikeGeeks" \> "likegeeks" ]
then
echo "True"
else
echo "False"
fi
- 以下哪个脚本是正确的?
#!/bin/bash
if ! [ "mokhtar" = "Mokhtar" ]
then
echo "Strings are not identical"
else
echo "Strings are identical"
fi
或
#!/bin/bash
if [ "mokhtar" != "Mokhtar" ]
then
echo "Strings are not identical"
else
echo "Strings are identical"
fi
- 在以下示例中,可以使用多少个命令作为操作符来返回
True?
#!/bin/bash
if [ 20 ?? 15 ]
then
echo "True"
else
echo "False"
fi
- 以下代码的结果是什么?
#!/bin/bash
mydir=/home/mydir
name="mokhtar"
if [ -d $mydir ] || [ -n $name ]; then
echo "True"
else
echo "False"
fi
深入阅读
请参阅以下内容,以获取更多与本章相关的阅读材料:
第四章:创建代码片段
现在我们可以编写条件测试来做出决策。当你的编程速度加快后,你将需要保存一些代码片段以供以后使用,那么在编写脚本时,如何节省时间和精力呢?
如果你喜欢使用命令行,同时又喜欢图形化集成开发环境(IDEs)的某些功能,那么本章可能会为你揭示一些新思路。我们可以通过 vi 或 vim 文本编辑器在命令行中创建常用脚本元素的快捷方式。
在本章中,我们将涵盖以下主题:
-
缩写
-
使用代码片段
-
使用 VS Code 创建代码片段
技术要求
本章的源代码可以从这里下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter04
缩写
我们已经短暂进入过 ~/.vimrc 文件,现在我们将重新访问此文件,查看缩写或 abbr 控制。此文件作为 vim 文本编辑器的运行控制机制,通常会安装在你的 Linux 发行版中。较旧的发行版或 Unix 变种可能使用原始的 vi 文本编辑器,并且会使用 ~/.exrc 文件。如果你不确定自己使用的 vi 版本及正确的运行控制文件,可以简单地输入 vi 命令。如果打开的是一个空白页面,那就是 vi;但如果打开的是一个带有 vim 启动画面的新空白文档,那么你正在使用改进版的 vim 或 vi。
缩写允许用较短的字符串替代较长的字符串。这些缩写可以在 vim 会话的最后一行模式中设置,但通常是在控制文件中设置的。Shebang 可以通过一个缩写轻松表示,如下所示:
abbr _sh #!/bin/bash
缩写的基本语法如下命令所示:
abbr <shortcut><string>
使用这个缩写,我们只需要在编辑模式下输入 _sh。按下 Enter 键后,shebang 的完整文本将被打印出来。实际上,在 abbr 代码后按下任何键都会展开快捷方式,而不仅仅是按 Enter 键。像这样的简单元素可以大大增强使用 vim 作为文本编辑器的体验。以下截图显示了更新后的 ~/.vimrc 文件:

我们不限于单一的缩写代码,可以添加更多的 abbr 条目,例如支持 Perl 脚本的 shebang 行:
abbr _pl #!/usr/bin/perl
下划线的使用不是必须的,但目的是保持快捷代码的唯一性,避免输入错误。我们也不限于单行,尽管缩写通常在单行中使用。考虑以下 if 语句的缩写:
abbr _if if [-z $1];then<CR>echo "> $0 <name><CR>exit 2<CR>fi
尽管这样可以工作,但if语句的格式将不完美,且多行缩写远非理想。这时,我们可以考虑使用预先准备好的代码片段。
使用代码片段
我们所说的代码片段指的是可以读取到当前脚本中的预备代码。使用 vim 特别容易,在编辑时可以读取其他文本文件的内容:
ESC
:r <path-and-filename>
例如,如果我们需要读取位于$HOME/snippets中的名为if的文件内容,我们将在 vim 中使用以下快捷键:
ESC
:r $HOME/snippets/if
该文件的内容将在当前光标位置下方读取到当前文档中。这样,我们可以根据需要使代码片段变得复杂,并保持正确的缩进,以帮助提高可读性和一致性。
所以,我们的职责是始终在我们的home目录中创建一个片段目录:
$ mkdir -m 700 $HOME/snippets
不需要共享目录,因此在创建时将模式设置为700或设置为用户私有是一种良好的做法。
在创建代码片段时,您可以选择使用伪代码或真实示例。我的偏好是使用经过编辑的真实示例,以反映接收脚本的要求。一个简单的if片段的内容如下:
if [ -z $1 ] ; then
echo "Usage: $0 <name>"
exit 2
fi
这为我们提供了创建带有实际示例的if语句的布局。在这种情况下,我们检查$1是否未设置,并在退出脚本之前向用户发送错误。关键是保持片段简洁,以限制所需的更改,同时确保易于理解并根据需要扩展。
为终端带来颜色
如果我们要向用户和执行脚本的操作员显示文本消息,我们可以提供颜色帮助解释消息。使用红色表示错误,绿色表示成功,可以更容易地为我们的脚本添加功能。并非所有 Linux 终端,但绝大多数都支持颜色。内置命令echo在与-e选项一起使用时,可以向用户显示颜色。
要显示红色文本,我们可以使用echo命令,如下所示:
$ echo -e "\03331mError\033[0m"
以下截图显示了代码和输出:

我们可以通过颜色编码的输出轻松识别脚本的成功与失败;绿色的Hello fred表示我们提供了参数,而红色的Usage语句表示我们没有提供所需的名称。
使用 VS Code 创建代码片段
对于那些喜欢图形化 IDE 的人,你可以将 VS Code 用作 shell 脚本的编辑器。我们在第一章中将它作为调试器使用,《Bash 脚本的作用与意义》。现在我们将展示它作为编辑器的能力。
你可以按如下方式在 VS Code 中创建自己的代码片段。
转到 文件 | 偏好设置 | 用户代码片段。
然后开始输入shell,这将打开shellscript.json文件。
该文件有两个括号,准备好在其中输入你的代码片段:

要创建一个代码片段,请在文件的括号中输入以下内容:
"Print a welcome message": {
"prefix": "welcome",
"body": [
"echo 'Welcome to shell scripting!' "
],
"description": "Print welcome message"
}

你可以使用以下模板,并根据需要进行修改。
尝试使用不同于 shell 脚本关键字的前缀,以避免混淆。
当你打开任何 .sh 文件并开始输入welcome时,自动完成将显示我们刚刚创建的代码片段:

你可以使用任何前缀;在我们的例子中,我们使用了welcome,这样自动完成时就会以它为开头。
你可以在代码片段的主体中添加多行内容:
"Print to a welcome message": {
"prefix": "welcome",
"body": [
"echo 'Welcome to shell scripting!' ",
"echo 'This is a second message'"
],
"description": "Print welcome message"
}
你可以在代码片段主体中使用占位符来简化代码编辑。
占位符写作如下:
$1, $2, etc,
修改之前的代码片段并添加一个占位符,如下所示:
"Print a welcome message": {
"prefix": "welcome",
"body": [
"echo 'Welcome to shell scripting! $1' "
],
"description": "Print welcome message"
}
当你开始输入welcome并选择代码片段后,你会注意到光标会停在占位符的精确位置,等待你的输入。
如果你忘记在这些可编辑的位置输入内容,可以使用选择项:
"Print to a welcome message": {
"prefix": "welcome",
"body": [
"echo 'Welcome to shell scripting! ${1|first,second,third|}' "
],
"description": "Print welcome message"
}
选择此代码片段并按Enter后,你应该会看到光标停留在等待输入的位置,并带有你的选择:

这非常有帮助!
此外,你可以为占位符添加默认值,这样如果你按下 Tab,该值就会被写入:
"Print a welcome message": {
"prefix": "welcome",
"body": [
"echo 'Welcome to shell scripting! ${1:book}' "
],
"description": "Print welcome message"
}
总结
对于任何管理员来说,脚本重用总是追求高效的关键。使用 vim 在命令行中编辑脚本可以非常快速且高效,我们可以通过使用缩写来节省输入。这些最好在用户个人的 .vimrc 文件中设置,并通过 abbr 控制定义。除了缩写之外,我们还可以看到使用代码片段的意义。这些是预先准备好的代码块,可以读取到当前脚本中。
此外,我们还看到了在命令行中使用颜色的价值,脚本会提供反馈。在初次使用时,这些颜色代码并不友好,但我们可以通过使用变量来简化这个过程。我们创建了包含颜色代码的变量并将其保存到文件中,通过使用 source 命令,这些变量将可以在当前环境中使用。
最后,我们看到了如何使用 VS Code 创建代码片段,以及如何添加占位符以简化代码编辑。
在下一章中,我们将探讨其他机制,帮助我们简化整数和变量的使用,编写测试表达式。
问题
- 以下代码创建了一个打印一行的片段。如何在片段中添加选择项?
"Hello message": {
"prefix": "hello",
"body": [
"echo 'Hello $1' "
],
"description": "Hello message"
}
- 你应该使用哪个命令,使你的代码片段在 shell 中可用?
深入阅读
请参阅以下内容,进一步阅读与本章相关的内容:
第五章:替代语法
到目前为止,在脚本编写过程中,我们已经看到可以使用test命令来判断条件状态。我们已经进一步了解,除了test命令,还可以使用单方括号。在这里,我们将回顾test命令,并更详细地了解单方括号。通过进一步了解方括号,我们将转向更高级的变量或参数管理,提供默认值并理解引用问题。
最后,我们将看到在如 bash、Korn 和 Zsh 等高级 shell 中,我们可以使用双括号!利用双圆括号和双方括号可以简化整体语法,并标准化数学符号的使用。
本章将涵盖以下主题:
-
回顾
test命令 -
提供参数默认值
-
有疑问时 – 引用!
-
使用
[[进行高级测试 -
使用
((进行算术运算
技术要求
本章的源代码可以从这里下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter05
回顾 test 命令
到目前为止,我们使用内建的test命令来驱动条件语句。通过与test结合使用其他选项,我们可以查看返回的值,以判断文件系统中文件的状态。如果不带任何选项运行test命令,将返回假输出:
$ test
测试文件
通常,我们可以使用test来检查与文件相关的条件。例如,要测试文件是否存在,可以使用-e选项。以下命令将测试/etc/hosts文件是否存在:
test -e /etc/hosts
我们可以再次运行test命令,但这次要检查文件不仅存在,而且是常规文件,而不是具有某种特殊用途的文件。特定的文件类型可能是目录、管道和链接等。常规文件的选项是-f:
$ test -f /etc/hosts
添加逻辑
如果我们需要在脚本中打开文件,我们会测试该文件是否是常规文件,并且已设置可读权限。为了使用test实现这一点,我们还可以包含-a选项,将多个条件通过AND连接在一起。在以下示例命令中,我们将使用-r条件来检查文件是否可读:
$ test -f /etc/hosts -a -r /etc/hosts
同样,使用-o选项可以在表达式中对两个条件进行OR操作。
前所未见的方括号
作为test命令的替代方法,我们可以使用单方括号来实现相同的条件测试。重复之前的test条件,并省略命令本身。我们将按照以下命令进行重写:
$ [ -f /etc/hosts -a -r /etc/hosts ]
很多时候,即使是经验丰富的管理员,我们也习惯了某些语言元素,并接受它们原本的样子。我觉得很多 Linux 管理员会很惊讶地发现 `` 是一个既是 shell 内建命令又是独立文件的命令。使用 type 命令,我们可以验证这一点:
$ type -a [
我们可以在以下屏幕截图中看到此命令的输出,确认其存在:
![
内置的 [ 命令模仿了 test 命令,但它需要一个闭括号。
现在我们对 [ 命令有了更多了解,它既出现在 bash 中,也出现在早期的 Bourne shell 中,我们现在可以继续添加一些命令行列表语法。除了命令行列表,我们可以在以下命令示例中看到所需的功能:
$ FILE=/etc/hosts
$ [ -f $FILE -a -r $FILE ] && cat $FILE
设置了 FILE 参数变量后,我们可以测试它是否为常规文件,并且在尝试列出文件内容之前,用户是否可读。这样,脚本变得更加健壮,而不需要复杂的脚本逻辑。我们可以在以下屏幕截图中看到代码的使用:

这种缩写非常常见,且容易识别。如果缩写没有提高可读性,我们应该始终小心使用它们。我们编写脚本的目标应是写出清晰且易于理解的代码,避免使用那些并没有增加可读性的简写。
提供参数默认值
在 bash 参数中,有一些命名的内存空间允许我们访问存储的值。参数有两种类型:
-
变量
-
特殊参数
变量
我们在第一章中已经描述了什么是变量以及如何定义它们,使用 Bash 编写脚本的背景和目的。
只是为了提醒你,你可以通过使用等号并且没有空格来定义一个变量,例如:
#!/bin/bash
myvar=15
myvar2="welcome"
所以这里没有什么新内容。
特殊参数
特殊参数是第二类参数,由 shell 本身管理,并以只读形式呈现。我们以前在诸如 $0 的参数中遇到过这些,但让我们来看一下另一个 $-。我们可以扩展这些参数,通过使用 echo 命令来理解它们的用法:
$ echo "My shell is $0 and the shell options are: $-"
从我添加的注释文本中,我们可以理解$-选项表示配置的 shell 选项。可以通过 set -o 命令显示这些选项,但可以使用 $- 通过程序化方式读取。
我们可以在以下屏幕截图中看到这一点:

此处设置的选项如下:
-
h:这是 hashall 的缩写;它允许通过PATH参数查找程序 -
i:这表示这是一个交互式 shell -
m:这是 monitor 的缩写;它允许使用bg和fg命令将命令从后台调入或调出 -
B:这允许使用大括号扩展或mkdirdir{1,2},其中我们创建dir1和dir2 -
H:允许历史命令扩展,例如使用!501来重复历史命令。
设置默认值
使用 test 命令或括号,我们可以为变量提供默认值,包括命令行参数。以我们之前处理的 hello4.sh 脚本为例,我们可以修改它,并在 name 参数为零字节时进行设置:
#!/bin/bash
name=$1
[ -z $name ] && name="Anonymous"
echo "Hello $name"
exit 0
这段代码是可行的,但如何编写默认值的代码是我们自己的选择。我们也可以直接将默认值赋给参数。考虑以下命令,其中直接进行默认值赋值:
name=${1-"Anonymous"}
在 bash 中,这称为 参数替换,可以写成以下伪代码:
${parameter-default}
无论何时,若变量(parameter)未声明且值为空,将使用默认值。如果该参数已明确声明为空值,我们将使用 :- 语法,如下例所示:
parameter=
${parameter:-default}
通过编辑脚本,我们可以创建 hello8.sh,利用 bash 参数替换来提供默认值:
#!/bin/bash
#Use parameter substitution to provide default value
name=${1-"Anonymous"}
echo "Hello $name"
exit 0
下面的截图展示了此脚本及其输出,包括有无提供值的情况:

hello8.sh 脚本提供了我们所需的功能,逻辑直接嵌入到参数赋值中。现在,逻辑和赋值已经合并为脚本中的一行代码,这是保持脚本简洁并维护可读性的重要一步。
若有疑问 —— 请加引号!
确定变量是一种参数类型后,我们应始终牢记这一点,特别是在阅读手册和 HOWTO 时。文档中通常提到参数,并且在提及时,包括了变量以及 bash 特殊参数,如 $1 等。基于此,我们将探讨为何在命令行或脚本中使用参数时最好加上引号。现在学习这一点,可以帮助我们避免以后遇到的许多麻烦,尤其是在我们开始研究循环时。
首先,我们应该使用的正确术语是 参数扩展 来读取变量的值。对你我而言,这就是读取变量,但对 bash 来说,这样描述太简单。正确的术语,如参数扩展,减少了含义上的歧义,但同时增加了复杂性。在以下示例中,第一行命令将 fred 的值赋给 name 参数。第二行命令使用参数扩展打印存储在内存中的值。$ 符号用于实现参数扩展:
$ name=fred
$ echo "The value is: $name"
在这个例子中,我们使用了双引号来允许echo打印出单一字符串,因为我们使用了空格。如果不使用引号,echo可能会将其视为多个参数,空格是大多数 shell(包括 bash)中的默认字段分隔符。通常,当我们没有想到使用引号时,我们并不会直接看到空格。考虑我们之前使用过的命令行代码片段:
$ FILE=/etc/hosts
$ [ -f $FILE -a -r $FILE ] && cat $FILE
尽管这样做成功了,但我们可能有些幸运,特别是当我们从一个我们没有自己创建的文件列表中填充FILE参数时。可以想象,一个文件名中可能包含空格。现在让我们使用另一个文件重新执行这个命令。考虑以下命令:
$ FILE="my file"
$ [ -f $FILE -a -r $FILE ] && cat $FILE
即使从结构上来看,代码没有任何变化,它现在失败了。这是因为我们向``命令提供了太多的参数。即使我们使用test命令,失败的结果也是一样的。
即使我们已经正确引用了文件名分配给FILE参数,但当参数被扩展时,我们并没有保护空格。我们可以看到代码失败,如下截图所示:
![
我们可以看到,这样的代码对于我们的脚本来说并不合适。唉,我们曾经认为坚固的代码现在四分五裂,就像“泰坦尼克号”一样,我们的代码已经沉没。
然而,一个简单的解决方法是,在不特别不需要时,恢复引用参数扩展。我们只需简单修改代码,就能使这艘船变得不可沉:
$ FILE="my file"
$ [ -f "$FILE" -a -r "$FILE" ] && cat "$FILE"
现在我们可以骄傲地站在白星航线的码头上,看到“泰坦尼克号 II”在以下代码示例中被启动,截图如下:

这些微小的引号所带来的效果真是令人惊讶,有时甚至有些难以置信。在扩展变量时,我们绝不应该忽视引号。为了确保我们能强调这一点,我们可以通过另一个更简单的例子来说明这个现象。假设我们现在只想删除文件。在第一个例子中,我们没有使用引号:
$ rm $FILE
这段代码将失败,因为参数扩展将导致以下被视为命令:
$ rm my file
代码会失败,因为它无法找到my文件或file文件。更糟糕的是,如果任何文件名被意外解析,我们可能会删除错误的文件。然而,引用参数扩展会拯救我们,正如我们在第二个例子中看到的那样:
$ rm "$FILE"
这是正确扩展到所需命令的结果,我们将在以下命令示例中进行说明:
$ rm "my file"
我确实希望这些例子能展示出在扩展参数时需要小心,并让你意识到潜在的陷阱。
使用[[进行的高级测试
使用双括号[[ condition ]]允许我们进行更高级的条件测试,但它与 Bourne shell 不兼容。双括号最初作为一个定义的关键字出现在 KornShell 中,并且在 bash 和 Zsh 中也可用。与单括号不同,这不是一个命令,而是一个关键字。使用type命令可以确认这一点:
$ type [[
空白字符
[[不是命令这一事实在空白字符处理方面具有重要意义。作为一个关键字,[[会在 bash 扩展其参数之前解析它们。因此,单个参数将始终作为单个参数表示。尽管这违背了最佳实践,但[[可以缓解与参数值中空白字符相关的一些问题。重新考虑我们之前测试的条件,我们在使用[[时可以省略引号,如下例所示:
$ echo "The File Contents">"my file"
$ FILE="my file"
$ [[ -f $FILE && -r $FILE ]] && cat "$FILE"
使用cat时我们仍然需要引用参数,如你所见,我们可以在双括号内使用引号,但它们变得可选。注意,我们还可以使用更传统的&&和||分别表示-a和-o。
其他高级功能
这些是我们可以通过双括号包含的额外功能。即使使用它们可能失去可移植性,但也有一些强大的功能可以克服这一损失。记住,如果我们只使用 bash,那么我们可以使用双括号,但无法在 Bourne shell 中运行脚本。我们在接下来的章节中将讨论的高级功能包括模式匹配和正则表达式。
模式匹配
使用双括号,我们不仅可以匹配字符串,还可以使用模式匹配。例如,我们可能需要专门处理 Perl 脚本,即以.pl结尾的文件。我们可以通过将模式作为匹配项轻松地在条件中实现这一点,如下例所示:
$ [[ $FILE = *.pl ]] && cp"$FILE" scripts/
正则表达式
我们将在第十一章中深入探讨正则表达式,正则表达式,但现在先简要了解一下。
我们可以使用正则表达式重写最后一个示例:
$ [[ $FILE =~ \.pl$ ]] && cp "$FILE" scripts/
由于单个点或句号在正则表达式中有特殊含义,我们需要使用\对其进行转义。
以下截图展示了正则表达式匹配在一个名为my.pl的文件和另一个名为my.apl的文件中的工作情况。匹配正确地显示了以.pl结尾的文件:

正则表达式脚本
使用正则表达式进行条件测试的另一个简单示范是暴露美式和英式拼写的color,分别为color和colour。我们可以提示用户是否想要脚本输出颜色或单色,但同时兼容这两种拼写。脚本中执行此操作的代码如下:
if [[ $REPLY =~ colou?r ]] ; then
正则表达式通过使u变为可选来适应两种拼写形式的color:u?。此外,我们可以通过设置 shell 选项来禁用大小写敏感,使其能够匹配COLOR和color:
shopt -s nocasematch
这个选项可以通过以下命令在脚本末尾再次禁用:
shopt -u nocasematch
当我们使用命名的变量参数$GREEN和$RESET时,会影响输出的颜色。绿色将仅在我们源自颜色定义文件的地方显示。这在选择颜色显示时被设置。选择单色将确保变量参数为空,并且没有影响。
完整的脚本显示在以下截图中:

使用((进行的算术运算
当使用 bash 和一些其他高级 shell 时,我们可以利用(( ))符号简化脚本中的数学运算。
简单的数学运算
bash 中的双括号构造允许算术扩展。以最简单的格式使用它时,我们可以轻松进行整数运算。这可以替代let内建命令。以下示例展示了使用let命令和双括号来实现相同的结果:
$ a=(( 2 + 3 ))
$ let a=2+3
在这两种情况下,a参数被赋值为2 + 3的和。如果你想在脚本中编写它,必须在括号前加上美元符号:
#!/bin/bash
echo $(( 2 + 3 ))
参数操作
对我们在脚本编写中可能更有用的是可以使用双括号来实现的 C 风格参数操作。我们可以经常用它在循环中递增计数器,并限制循环的迭代次数。考虑以下命令:
$ COUNT=1
$ (( COUNT++ ))
echo $COUNT
在这个例子中,我们首先将COUNT设置为1,然后用++操作符递增它。当在最后一行回显时,参数的值将是2。我们可以在以下截图中看到结果:

我们可以使用以下语法以详细形式实现相同的结果:
$ COUNT=1
$ (( COUNT=COUNT+1 ))
echo $COUNT
当然,这允许对COUNT参数进行任何增量,而不仅仅是单一单位的增加。同样,我们可以使用--操作符进行倒计时,如下例所示:
$ COUNT=10
$ (( COUNT-- ))
echo $COUNT
我们从10开始,通过双括号将值减少1。
请注意,我们在括号内不使用$来展开参数。它们用于参数操作,因此我们不需要显式地展开参数。
标准算术测试
我们可以从这些双括号中获得的另一个优势是用于测试。我们无需使用-gt来表示“大于”,可以直接使用>。我们可以在以下代码中演示这一点:
$(( COUNT > 1 )) && echo "Count is greater than 1"
以下截图演示了这一点:

正是这种标准化的操作,无论是在 C 风格的操作还是测试中,使得双圆括号变得如此对我们有用。这种用法不仅适用于命令行,也适用于脚本。在我们研究循环结构时,我们将广泛使用这个功能。
总结
在本章中,我真心希望我们向你介绍了许多新的、有趣的选择。这是一个范围广泛的领域,我们从回顾 test 的使用开始,发现 [ 实际上是一个命令,而不是语法结构。它作为命令的主要影响体现在空格上,我们还看到了引用变量的必要性。
虽然我们常常称变量为“变量”,但我们也已经看到它们的正确名称,特别是在文档中,是参数。读取一个变量是参数扩展。理解参数扩展有助于我们理解 [[ 关键字的使用。双中括号并不是命令,它们不会扩展参数。这意味着即使变量中包含空格,我们也不需要引用它们。此外,使用双中括号时,我们可以进行高级测试,如模式匹配或正则表达式。
最后,我们通过使用双圆括号符号,了解了算术扩展和参数操作。它带来的最大特点是能够轻松地增减计数器。
在下一章中,我们将讨论 bash 中的循环结构,并将运用本章学到的一些新技能。
问题
-
如何在 shell 脚本中从 25 中减去 8?
-
以下代码有什么问题?如何修复?
$ rm my file
- 以下代码有什么问题?
#!/bin/bash
a=(( 8 + 4 ))
echo $a
进一步阅读
请参阅以下内容以进一步阅读与本章相关的资料:
第六章:使用循环进行迭代
现在我们可以执行算术运算和测试,我们的脚本有了更多的控制能力。有时,你会发现需要反复执行一些任务,比如遍历日志文件条目并执行某些操作,或者连续运行一段代码。我们是忙碌的人,不会愿意重复执行同一任务一百次或更多次;循环是我们的好帮手。
循环结构是脚本的命脉。这些循环是强大的引擎,可以多次迭代,可靠地重复相同的任务。试想一下,在一个 CSV 文件中,有 100,000 行文本需要检查其中的错误条目。一旦开发出脚本,脚本就可以轻松且准确地完成此任务,但如果是人工操作,可靠性和准确性将迅速失败。
那么,让我们通过本章中的以下主题来看如何节省时间和精力:
-
for循环 -
高级
for循环 -
内部字段分隔符(IFS)
-
统计目录和文件
-
C 风格的 for 循环
-
嵌套循环
-
重定向循环输出
-
while循环和until循环 -
从文件中读取输入
-
创建操作员菜单
技术要求
本章的源代码可以从这里下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter06
for 循环
所有的循环控制都很简单,我们将从for循环开始。for是 bash 中的一个关键字,在它的工作方式上,类似于if。我们可以使用命令类型来验证这一点,如下面的示例所示:
$ type for for is a shell keyword
作为保留的 shell 关键字,我们可以在脚本中或直接在命令行中使用for循环。通过这种方式,我们可以在脚本内外都使用循环,优化命令行的使用。下面是一个简单的for循环示例代码:
# for u in bob joe ; do
useradd $u
echo '$u:Password1' | chpasswd #pipe the created user to chpasswd
passwd -e $u
done
useradd命令用于创建用户,chpasswd命令用于批量更新密码。
在for循环中,我们从右侧的列表读取,填充左侧的变量参数;在这种情况下,我们将从包含bob和joe的列表中读取,并将每个项目插入变量u中,一次一个项目。这样,只要列表中有待处理的项目,循环就会继续执行,直到列表耗尽。
对我们来说,实际执行这个循环意味着我们将执行以下操作:
-
创建用户
bob -
为
bob设置密码 -
使
bob用户的密码过期,以便在第一次登录时需要重置密码
然后我们回到循环,重复为用户joe执行相同的过程。
我们可以在下图中查看前面的示例。在通过sudo -i获得 root 权限后,我们继续运行循环并创建用户:

在for循环中读取的列表可以动态生成,也可以像之前的例子那样静态生成。要创建动态列表,我们可以使用各种通配符技巧来填充列表。例如,要处理目录中的所有文件,我们可以使用*,如以下示例所示:
for f in * ; do
stat "$f"
done
当生成列表时,例如通过文件通配符,我们应当引用变量参数的扩展。如果不加引号,可能会包含空格,导致命令失败。这就是我们在stat命令中看到的情况。
在接下来的示例中,我们筛选出以ba*开头的文件名。然后使用stat命令打印 inode 元数据。代码和输出如下截图所示:

这个列表也可以通过另一个命令的输出或命令管道生成。例如,如果我们需要打印所有已登录用户的当前工作目录,我们可以尝试类似下面的操作:
$ for user in $(who | cut -f1 -d" ") ; do
lsof -u "$user" -a -c bash | grep cwd
done
在之前的示例中,我们可以看到,参数名称的选择由我们决定;我们不限于单个字符,在这个例子中我们可以使用$user。通过使用小写字母,我们就不会覆盖系统变量$USER。以下截图展示了循环及其输出:

lsof命令将列出打开的文件;我们可以依次搜索每个用户打开的文件,并使用bash命令作为当前工作目录。
在我们迄今为止创建的脚本中,我们可以创建一个新的脚本,名为hello9.sh。如果我们将$HOME/bin/hello2.sh脚本复制到新脚本中,我们可以编辑它以使用for循环:
#!/bin/bash
echo "You are using $(basename $0)"
for n in $*
do
echo "Hello $n"
done
exit 0
该循环用于遍历每个命令行参数,并逐个问候每个用户。当我们执行脚本时,我们可以看到现在可以为每个用户显示Hello消息。以下截图展示了这一点:

尽管我们在这里看到的内容仍然相对简单,但现在我们应该对脚本和循环的应用有所了解。这个脚本的参数可以是我们已经使用过的用户名,或者其他任何内容。如果我们坚持使用用户名,那么创建用户账户并设置密码将变得非常简单,就像我们之前看到的那样。
高级for循环
在之前的示例中,我们使用for循环遍历简单值,每个值中不包含空格。
如你所知,如果值中包含空格,你应当使用双引号:
#!/bin/bash
for var in one "This is two" "Now three" "We'll check four"
do
echo "Value: $var"
done

如你所见,每个值都按照预期打印出来,这得益于双引号的使用。
这个示例包含了一行中的值,我们引用了这些值,因为它们包含空格和逗号。如果值位于多行中,比如在文件中,又该如何处理呢?
如果我们要迭代的值之间的分隔符不是空格,而是其他符号,比如逗号或分号,怎么办?
这时就需要用到 IFS。
IFS
默认情况下,IFS 变量的值是(空格、换行符或制表符)之一。
假设你有一个像下面这样的文件,并且想要遍历它的行:
Hello, this is a test
This is the second line
And this is the last line
让我们编写一个 for 循环来遍历这些行:
#!/bin/bash
file="file1.txt"
for var in $(cat $file)
do
echo " $var"
done
如果你检查结果,会发现它是我们不需要的:

由于 Shell 找到的第一个分隔符是空格,所以 Shell 将每个单词当作一个字段,但我们需要每一行作为一个字段打印。
在这里,我们需要将 IFS 变量改为换行符。
让我们修改脚本,正确遍历每一行:
#!/bin/bash
file="file1.txt"
IFS=$'\n' #Here we change the default IFS to be a newline
for var in $(cat $file)
do
echo " $var"
done

我们将 IFS 变量改为换行符,它按预期工作了。
看看前一节中 IFS 定义里的美元符号,IFS=$"\n"。默认情况下,bash 不会解释像 \r、\n 和 \t 这样的转义字符。因此,在我们的例子中,它会被当作 n 字符对待,所以要解释转义字符,你必须在前面加上美元符号($),这样它才能正常工作。
但是,如果你的 IFS 是一个普通字符,你根本不需要使用美元符号($)。
统计目录和文件
我们可以使用简单的 for 循环遍历文件夹内容,并用 if 语句检查路径是目录还是文件:
#!/bin/bash
for path in /home/likegeeks/*
do
if [ -d "$path" ]
then
echo "$path is a directory"
elif [ -f "$path" ]
then
echo "$path is a file"
fi
done

这是一个相当简单的脚本。我们遍历目录内容,然后使用 if 语句检查路径是目录还是文件。最后,我们在每个路径旁边打印它是文件还是目录。
我们对路径变量使用了引号,因为文件路径中可能包含空格。
C 风格的 for 循环
如果你有 C 语言背景,你会很高兴地知道,你可以用 C 风格编写 for 循环。这个功能是从 KornShell 中借用来的。Shell for 循环可以像这样编写:
for (v= 0; v < 5; v++)
{
printf(Value is %d\n", v);
}
对于 C 开发者来说,在 for 循环中使用这种语法是很容易的。
看看这个例子:
#!/bin/bash
for (( v=1; v <= 10; v++ ))
do
echo "value is $v"
done
选择权在你,for 循环有多种语法样式可供选择。
嵌套循环
嵌套循环意味着循环中有循环。看看下面的例子:
#!/bin/bash
for (( v1 = 1; v1 <= 3; v1++ ))
do
echo "First loop $v1:"
for (( v2 = 1; v2 <= 3; v2++ ))
do
echo " Second loop: $v2"
done
done

第一个循环首先执行,然后是第二个循环,这个过程重复三次。
重定向循环输出
你可以使用 done 命令将循环输出重定向到文件:
#!/bin/bash
for (( v1 = 1; v1 <= 5; v1++ ))
do
echo "$v1"
done > file
如果没有文件,它将被创建,并用循环输出填充。
这种重定向对于不需要将循环输出显示在屏幕上,而是将其保存到文件中的情况非常有用。
控制循环
进入我们的循环后,我们可能需要提前退出循环,或者排除某些项目不进行处理。如果我们只想处理列表中的目录,而不是所有类型的文件,那么为了实现这一点,我们有循环控制关键字,比如break和continue。
break关键字用于退出循环,不再处理任何条目,而continue关键字则用于停止处理当前循环条目,并继续处理下一个条目。
假设我们只想处理目录,我们可以在循环中实现一个测试,并确定文件类型:
$ for f in * ; do
[ -d "$f" ] || continue
chmod 3777 "$f"
done
在循环内,我们希望设置权限,包括 SGID 和 sticky 位,但只对目录进行操作。*搜索会返回所有文件;循环中的第一个语句会确保我们只处理目录。如果当前循环的测试失败且该目标不是目录,continue关键字将跳过该项并处理下一个循环列表中的条目。如果test返回true并且我们正在处理一个目录,那么我们将继续执行后续语句并执行chmod命令。
如果我们需要运行循环,直到找到一个目录并退出循环,我们可以调整代码,以便遍历每个文件。如果文件是目录,我们就使用break关键字退出循环:
$ for f in * ; do
[ -d "$f" ] && break
done
echo "We have found a directory $f"
在以下截图中,我们可以看到代码的运行效果:

通过使用相同的主题,我们可以使用以下代码打印出列出的每个目录:
for f in * ; do
[ -d "$f" ] || continue
dir_name="$dir_name $f"
done
echo "$dir_name"
我们可以通过仅在循环内处理目录来实现结果。我们只使用if测试来处理常规文件。在这个例子中,我们将目录名称附加到dir_name变量中。退出循环后,我们打印出完整的目录列表。我们可以在以下截图中看到这一点:

使用这些示例和你自己的想法,你现在应该能够看到如何使用continue和break关键字来控制循环。
while 循环和 until 循环
当使用for循环时,我们会遍历一个列表;这个列表可以是我们自己创建的,也可以是动态生成的。使用while或until循环时,我们会基于条件是否变为true或false来决定是否继续循环。
while循环会在条件为真时循环,而until循环则会在条件为假时循环。以下命令将从 10 倒数到零,每次循环打印变量的值,然后将该值减一:
$ COUNT=10
$ while (( COUNT >= 0 )) ; do
echo -e "$COUNT \c"
(( COUNT-- ))
done ; echo
我们可以在以下截图中看到该命令的输出,从而确认倒计时到零:

这里使用的\c转义序列可以抑制echo命令通常使用的换行符。这样,我们就可以让倒计时保持在单行输出中。我相信你会同意,这是一个不错的效果。
使用until循环也可以实现该循环的功能;只需要快速调整逻辑,因为我们希望循环直到条件为真。通常来说,选择使用哪个循环取决于个人习惯以及逻辑如何最好地工作。以下示例展示了使用until循环编写的循环:
$ COUNT=10
$ until (( COUNT < 0 )) ; do
echo -e "$COUNT \c"
(( COUNT-- ))
done ; echo
从文件读取输入
现在,看起来这些循环不仅仅能做倒计时。我们可能希望从文本文件中读取数据并处理每一行。本书中之前提到过的 shell 内建read命令可以逐行读取文件。这样,我们就可以使用循环逐行处理文件内容。
为了演示这些功能,我们将使用一个包含服务器地址的文件。这些地址可以是主机名或 IP 地址。在以下示例中,我们将使用谷歌 DNS 服务器的 IP 地址。以下命令显示了servers.txt文件的内容:
$ cat servers.txt
8.8.8.8
8.8.4.4
使用while循环中的read命令,我们可以只要文件中还有更多行,就继续循环。我们在done关键字后直接指定输入文件。对于从文件中读取的每一行,我们可以使用ping命令测试服务器是否在线,如果服务器有响应,就将其添加到可用服务器列表中。该列表将在循环结束时打印出来。在下面的示例中,我们可以看到,我们开始将本书中涵盖的脚本元素逐步加入到脚本中:
$ while read server ; do
ping -c1 $server && servers_up="$servers_up $server"
done < servers.txt
echo "The following servers are up: $servers_up"
我们可以通过以下截图验证操作,截图中捕获了输出:

使用这种循环,我们可以开始构建极具实用性的脚本,处理从命令行或其他脚本输入的信息。我们可以轻松地用$1替换我们读取的文件名,它代表传递给脚本的一个位置参数。让我们回到ping_server.sh脚本,并调整它以接受输入参数。我们可以将脚本复制到新的$HOME/bin/ping_server_from_file.sh文件中。在脚本中,我们首先测试输入参数是否为文件。然后,我们创建一个输出文件,文件名中包含日期。当我们进入循环时,我们将可用的服务器添加到该文件中,并在脚本末尾列出该文件:
#!/bin/bash
# Author: @theurbanpenguin
# Web: www.theurbapenguin.com
# Script to ping servers from file
# Last Edited: August 2015
if [ ! -f"$1 ] ; then
echo "The input to $0 should be a filename"
exit 1
fi
echo "The following servers are up on $(date +%x)"> server.out
done
while read server
do
ping -c1 "$server"&& echo "Server up: $server">> server.out
done
cat server.out
现在我们可以通过以下方式执行脚本:
$ ping_server_from_file.sh servers.txt
脚本执行后的输出应与以下截图类似:

创建操作员菜单
我们可以为需要有限功能的 Linux 操作员提供菜单,这些操作员不想学习命令行的详细内容。我们可以使用他们的登录脚本为他们启动一个菜单。该菜单将提供一个命令选择列表,供用户选择。菜单会循环,直到用户选择退出菜单。我们可以创建一个新的$HOME/bin/menu.sh脚本;菜单循环的基础将是如下所示:
while true
do
......
done
我们在这里创建的循环是无限的。true命令总是返回真,并且会不断循环;然而,我们可以提供一个循环控制机制,允许用户离开菜单。为了开始构建菜单的结构,我们将在循环内回显一些文本,询问用户选择的命令。在每次加载菜单之前,我们将清屏,执行所需命令后会显示一个额外的读取提示。
这允许用户在屏幕被清除并重新加载菜单之前,先查看命令的输出。此时,脚本的代码将如下所示:
#!/bin/bash
# Author: @theurbanpenguin
# Web: www.theurbapenguin.com
# Sample menu
# Last Edited: August 2015
while true
do
clear
echo "Choose an item: a,b or c"
echo "a: Backup"
echo "b: Display Calendar"
echo "c: Exit"
read -sn1
read -n1 -p "Press any key to continue"
done
如果你在此阶段执行脚本,将没有机制可以退出脚本。我们尚未为菜单选择项添加任何代码;但是,你可以使用Ctrl + C键进行测试并退出。
在这个阶段,菜单应该类似于以下截图中的输出:

为了构建菜单选择背后的代码,我们将实现一个case语句。它将被添加在两个read命令之间,如下所示:
read -sn1
case "$REPLY" in
a) tar -czvf $HOME/backup.tgz ${HOME}/bin;;
b) cal;;
c) exit 0;;
esac
read -n1 -p "Press any key to continue"
我们可以看到我们添加到case语句中的三个选项:a、b和c:
-
选项
a:这将运行tar命令,备份脚本 -
选项
b:这将运行cal命令,显示当前月份 -
选项
c:这将退出脚本
为了确保用户在退出登录脚本时已注销,我们将运行以下内容:
exec menu.sh
exec命令用于确保在menu.sh文件完成后退出 shell。这样,用户就不需要接触到 Linux shell。完整的脚本如下所示:

总结
我们在本章中已经开始取得进展。我们已将许多之前使用过的元素结合成了一个有机且功能性的脚本。尽管本章的重点是循环,我们也使用了命令行列表、if语句、case语句和算术运算。
我们通过描述循环是脚本的主力军来开启本章,并且已经通过for、while和until循环展示了这一点。for循环用于遍历列表中的元素。列表可以是静态的也可以是动态的;我们着重展示了如何通过文件通配符或命令扩展简单地创建动态列表。
此外,我们还学习了如何迭代复杂值以及如何设置 IFS 以正确地迭代字段。
我们学习了如何编写嵌套循环以及如何将循环输出重定向到文件。
while和until循环是通过条件控制的。while循环将在提供的条件为真时循环执行。until循环将在提供的条件为真时停止,或者条件为假时才会继续执行。continue和break是循环中特有的关键字,通过它们以及exit,我们可以控制循环流程。
在下一章中,我们将学习如何使用函数来模块化脚本。
问题
- 以下脚本将在屏幕上打印多少行?
#!/bin/bash
for (( v1 = 12; v1 <= 34; v1++ ))
do
echo "$v1"
done > output
- 以下脚本将在屏幕上打印多少行?
#!/bin/bash
for (( v=8; v <= 12; v++ ))
do
if [ $v -ge 12 ]
then
break
fi
echo "$v"
done
- 以下脚本有什么问题?你如何修复它?
#!/bin/bash
for (( v=1, v <= 10, v++ ))
do
echo "value is $v"
done
- 以下脚本将在屏幕上打印多少行?
#!/bin/bash
count=10
while (( count >= 0 )) ; do
echo $count
done
$((count--))
exit 0
进一步阅读
请参阅以下内容,了解与本章相关的进一步阅读:
第七章:使用函数创建构建模块
在本章中,我们将深入探索函数的精彩世界。我们可以将它们看作是构建强大且灵活脚本的模块化构建块。通过创建函数,我们可以将代码封装在一个与其他脚本隔离的构建块中。专注于改进单一函数要比尝试改进整个脚本更容易。如果没有函数,往往很难专注于问题区域,而且代码会被重复多次,这意味着更新需要在多个位置进行。函数是作为代码块或脚本内脚本的命名元素,它们能够解决更复杂代码中存在的许多问题。
在本章中,我们将涵盖以下主题:
-
引入函数
-
向函数传递参数
-
变量作用域
-
从函数返回值
-
递归函数
-
在菜单中使用函数
技术要求
本章的源代码可以从这里下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter07
引入函数
函数是存在于内存中的命名元素代码块。这些元素可以在 shell 环境中创建,也可以在脚本执行中创建。当在命令行输入命令时,首先会检查别名,接着会检查是否有匹配的函数名。要显示在 shell 环境中存在的函数,可以使用以下命令:
$ declare -F
输出结果会根据你使用的发行版和你创建的函数数量而有所不同。在我的 Linux Mint 中,部分输出如下所示:

使用小的-f选项,你可以显示函数及其相关定义。然而,如果我们只想查看单个函数定义,可以使用type命令:
$ type quote
上面的代码示例会显示quote函数的代码块,如果它存在于你的 shell 中。我们可以在下面的截图中看到这个命令的输出:

bash 中的quote函数会在提供的输入参数周围插入单引号。例如,我们可以展开USER变量并将值作为字符串字面量显示;如下面的截图所示。截图捕捉了命令和输出:

大多数代码可以通过伪代码来表示,伪代码展示了示例布局。函数也不例外,创建函数的代码如下所示:
function-name() {
<code to execute>
}
此外,还有另一种定义函数的方法,如下所示:
function <function-name> {
<code to execute>
}
keyword函数已被弃用,以符合可移植操作系统接口(POSIX)规范,但仍有一些开发人员在使用它。
请注意,使用keyword函数时不需要(),但如果您在没有keyword函数的情况下定义该函数,则必须使用()。
该函数没有使用do和done块,这在我们之前的循环中有用。大括号的目的是定义代码块的边界。
显示聚合系统信息的简单函数如下所示。这可以在命令行中创建,并将驻留在您的 shell 中。此函数不会在登录后保持有效,并将在关闭 shell 或取消设置函数时丢失。为了让该函数保持有效,我们需要将其添加到用户帐户的登录脚本中。示例代码如下:
$ show_system() {
echo "The uptime is:"
uptime
echo
echo "CPU Detail"
lscpu
echo
echo "User list"
who
}
我们可以像前面提到的实例一样使用type命令打印函数的详细信息,截图如下所示:

执行该函数时,我们只需输入show_system,然后我们将看到来自三条命令uptime、lscpu和who的静态文本和输出。当然,这是一个非常简单的函数,但我们可以通过允许在运行时传递参数来增加更多的功能。
向函数传递参数
在本章前面,我们将函数称为脚本中的脚本,我们将继续保持这个类比。就像脚本可以有输入参数一样,我们可以创建接受参数的函数,使其操作不那么静态。在我们编写脚本之前,可以先看看命令行中有用的函数。
我最讨厌的一件事就是配置文件中的注释过多,尤其是在已有文档详细说明可用选项的情况下。
GNU's Not Unix(GNU)Linux sed 命令可以轻松编辑文件并删除注释行和空行。我们在这里介绍了流编辑器sed,但我们将在接下来的章节中更详细地了解它。
执行原地编辑的sed命令行如下所示:
$ sed -i.bak '/^\s*#/d;/^$/d' <filename>
我们可以通过逐个拆解命令行来运行我们的取证分析。让我们深入看看:
-
sed -i.bak:这会编辑文件并创建一个扩展名为.bak的备份文件。原始文件将作为<filename>.bak可访问。 -
/^:此插入符号(^)表示编辑以插入符号后面的内容开头的行。因此,插入符号匹配行的开始部分。 -
\s*:这表示任意数量的空白字符,包括没有空格或制表符。 -
#/:这是一个普通的#符号。因此,完整的表达式^\s*#意味着我们在寻找以注释或空格和注释开头的行。 -
d:这是删除操作,用于移除匹配的行。 -
;/^$/d:分号用于分隔表达式,第二个表达式类似于第一个,不过这次我们准备删除空行。
要将其转化为函数,我们只需要想一个好的名称。我喜欢将动词融入函数名,这有助于保持独特性并明确函数的目的。我们将按如下方式创建clean_file函数:
$ function clean_file {
sed -i.bak '/^\s*#/d;/^$/d' "$1"
}
在脚本中,我们使用位置参数来接受命令行参数。我们可以用$1替代之前硬编码的文件名。在函数中,我们会对这个变量加引号,以防文件名中包含空格。为了测试clean_file函数,我们将复制一个系统文件并操作这个副本。这样,我们可以确保不会对任何系统文件造成损害。我们可以向所有读者保证,在本书制作过程中,任何系统文件都没有受到伤害。以下是我们需要遵循的详细步骤来测试新函数:
-
按照描述创建
clean_file函数 -
使用
cd命令(不带参数)进入你的home目录 -
将时间配置文件复制到你的
home目录cp /etc/ntp.conf $HOME -
使用以下命令计算文件中的行数:
wc -l $HOME/ntp.conf -
现在使用
clean_file $HOME/ntp.conf删除注释行和空行 -
现在使用
wc -l $HOME/ntp.conf重新计算行数 -
此外,检查我们创建的原文件备份的行数:
wc -l $HOME/ntp.conf.bak
命令的顺序如以下截图所示:

我们可以通过在执行函数时提供的参数来将函数的注意力指向所需的文件。如果我们需要保存这个函数,那么应该将其添加到登录脚本中。然而,如果我们想在脚本中进行测试,可以创建以下文件来执行此操作,并练习我们学到的其他元素。需要注意的是,函数应该始终在脚本开始时创建,因为它们需要在调用时被加载到内存中。可以理解为你的函数需要在启动前被解锁并加载。
我们将创建一个新的 Shell 脚本$HOME/bin/clean.sh,并像往常一样设置执行权限。脚本的代码如下:
#!/bin/bash
# Script will prompt for filename
# then remove commented and blank lines
is_file() {
if [ ! -f "$1" ] ; then
echo "$1 does not seem to be a file"
exit 2
fi
}
clean_file() {
is_file "$1"
BEFORE=$(wc -l "$1")
echo "The file $1 starts with $BEFORE"
sed -i.bak '/^\s*#/d;/^$/d' "$1"
AFTER=$(wc -l "$1")
echo "The file $1 is now $AFTER"
}
read -p "Enter a file to clean: "
clean_file "$REPLY"
exit 1
我们在脚本中提供了两个函数。第一个,is_file,只是简单地测试确保我们输入的文件名是一个常规文件。然后我们声明clean_file函数,并增加了一些功能,显示操作前后文件的行数。我们还可以看到,函数是可以嵌套的,我们在clean_file中调用了is_file。
如果没有函数定义,我们在文件末尾只有三行代码,正如之前代码块中的示例代码那样,已保存为$HOME/bin/clean.sh。我们首先提示输入文件名,然后运行clean_file函数,后者又调用了is_file函数。这里主代码的简洁性很重要,复杂性在于函数,因为每个函数都可以作为独立单元进行处理。
我们现在可以测试脚本操作,首先使用一个错误的文件名,正如我们在以下截图中看到的:

既然我们已经看到使用错误文件的操作,现在可以尝试使用一个实际文件!我们可以使用之前处理过的同一个系统文件。首先,我们需要将文件恢复到原始状态:
$ cd $HOME
$ rm $HOME/ntp.conf
$ mv ntp.conf.bak ntp.conf
现在文件已经准备好,我们可以从$HOME目录执行脚本,如下图所示:

传递数组
并非你传递的所有值都是单一值;你可能需要将数组传递给函数。我们来看看如何将数组作为参数传递:
#!/bin/bash
myfunc() {
arr=$@
echo "The array from inside the function: ${arr[*]}"
}
test_arr=(1 2 3)
echo "The original array is: ${test_arr[*]}"
myfunc ${test_arr[*]}

从结果中可以看出,使用的数组是按原样从函数返回的。
注意,我们使用了$@来获取函数中的数组。如果你使用$1,它只会返回第一个数组元素:
#!/bin/bash
myfunc() {
arr=$1
echo "The array from inside the function: ${arr[*]}"
}
my_arr=(5 10 15)
echo "The original array: ${my_arr[*]}"
myfunc ${my_arr[*]}

因为我们使用了$1,它只返回第一个数组元素。
变量作用域
默认情况下,你在函数内部声明的任何变量都是全局变量。这意味着这个变量可以在函数内外都使用,没有问题。
看看这个例子:
#!/bin/bash
myvar=10
myfunc() {
myvar=50
}
myfunc
echo $myvar
如果你运行这个脚本,它将返回50,这是在函数内部更改的值。
如果你想声明一个只在函数内有效的变量呢?这就是所谓的局部变量。
你可以使用local命令来声明局部变量,像这样:
myfunc() {
local myvar=10
}
为了确保变量仅在函数内使用,我们来看一下以下例子:
#!/bin/bash
myvar=30
myfunc() {
local myvar=10
}
myfunc
echo $myvar
如果你运行这个脚本,它将打印出30,这意味着局部变量的版本与全局变量的版本不同。
从函数返回值
每当我们有在函数内部打印到屏幕上的语句时,我们可以看到它们的结果。然而,很多时候我们希望函数在脚本中填充一个变量,而不显示任何内容。在这种情况下,我们在函数中使用return。这在获取用户输入时尤其重要。我们可能希望将输入的内容转换为已知的格式,以便更轻松地进行条件测试。将代码嵌入函数中使得它可以在脚本中多次使用。
以下代码展示了如何通过创建to_lower函数来实现这一点:
to_lower ()
{
input="$1"
output=$( echo $input | tr [A-Z] [a-z])
return $output
}
逐步查看代码后,我们可以开始理解这个函数的操作:
-
input="$1":这主要是为了方便,我们将第一个输入参数分配给名为 input 的变量。 -
output=$( echo $input | tr [A-Z] [a-z]):这是函数的主要引擎,负责将大写字母转换为小写字母。我们将输入通过管道传递给tr命令来进行大小写转换。 -
return $output:这是我们创建返回值的方式。
这个函数的一个使用场景是在一个读取用户输入并简化测试的脚本中,看看他们是否选择了Q或q。这一点可以在以下代码片段中看到:
to_lower ()
{
input="$1"
output=$( echo $input | tr [A-Z] [a-z])
return $output
}
while true
do
read -p "Enter c to continue or q to exit: "
$REPLY=$(to_lower "$REPLY")
if [ $REPLY = "q" ] ; then
break
fi
done
echo "Finished"
递归函数
递归函数是一个从内部调用自身的函数。当你需要从函数内部再次调用该函数时,这个函数非常有用。最著名的例子就是计算阶乘。
要计算 4 的阶乘,你需要将数字乘以递减的数字。你可以这样做:
4! = 4*3*2*1
!符号表示阶乘。
让我们编写一个递归函数,计算任何给定数字的阶乘:
#!/bin/bash
calc_factorial() {
if [ $1 -eq 1 ]
then
echo 1
else
local var=$(( $1 - 1 ))
local res=$(calc_factorial $var)
echo $(( $res * $1 ))
fi
}
read -p "Enter a number: " val
factorial=$(calc_factorial $val)
echo "The factorial of $val is: $factorial"

首先,我们定义一个名为calc_factorial的函数,在其中我们检查数字是否等于 1,如果是,函数将返回 1,因为 1 的阶乘等于 1。
然后我们将数字减 1,并从函数内部调用该函数,这将再次调用该函数。
这个过程将继续进行,直到它达到 1,然后函数将退出。
在菜单中使用函数
在第六章中,使用循环迭代,我们创建了menu.sh文件。菜单是使用函数的绝佳目标,因为case语句通过单行条目非常简单地保持,而复杂性仍然可以存储在每个函数中。我们应该考虑为每个菜单项创建一个函数。如果我们将之前的$HOME/bin/menu.sh复制到$HOME/bin/menu2.sh,我们可以改善功能。新菜单应如下所示:
#!/bin/bash
# Author: @likegeeks
# Web: likegeeks.com
# Sample menu with functions
# Last Edited: April 2018
to_lower() {
input="$1"
output=$( echo $input | tr [A-Z] [a-z])
return $output
}
do_backup() {
tar -czvf $HOME/backup.tgz ${HOME}/bin
}
show_cal() {
if [ -x /usr/bin/ncal ] ; then
command="/usr/bin/ncal -w"
else
command="/usr/bin/cal"
fi
$command
}
while true
do
clear
echo "Choose an item: a, b or c"
echo "a: Backup"
echo "b: Display Calendar"
echo "c: Exit"
read -sn1
REPLY=$(to_lower "$REPLY")
case "$REPLY" in
a) do_backup;;
b) show_cal;;
c) exit 0;;
esac
read -n1 -p "Press any key to continue"
done
如我们所见,我们仍然保持了case语句的简洁性;然而,我们可以通过函数来增加脚本的复杂性。例如,当选择日历的b选项时,我们现在会检查ncal命令是否可用。如果可用,我们使用ncal并使用-w选项来打印周数。我们可以在以下截图中看到这一点,其中我们选择了显示日历并安装了ncal:

我们也不需要担心Caps Lock键,因为to_lower函数会将我们的选择转换为小写字母。随着时间的推移,向函数中添加更多元素将变得非常简单,因为我们只影响该单一函数。
总结
我们在编写脚本方面仍在快速进步。希望这些想法能对你有所帮助,并且你发现代码示例有用。函数对于脚本的易维护性和最终功能非常重要。脚本越容易维护,你就越有可能随着时间的推移进行改进。我们可以在命令行或脚本中定义函数,但它们必须在使用之前包含在脚本中。
函数本身在脚本运行时被加载到内存中,但只要脚本是通过 fork 方式运行而不是通过 source 方式运行,它们将在脚本执行完毕后从内存中释放。我们在本章中简要介绍了sed,在下一章我们将更深入地探讨流编辑器(sed)的使用。sed命令非常强大,我们可以在脚本中充分利用它。
问题
- 以下代码打印的值是什么?
#!/bin/bash
myfunc() {
arr=$1
echo "The array: ${arr[*]}"
}
my_arr=(1 2 3)
myfunc ${my_arr[*]}
- 以下代码的输出是什么?
#!/bin/bash
myvar=50
myfunc() {
myvar=100
}
echo $myvar
myfunc
- 以下代码有什么问题?你如何修复它?
clean_file {
is_file "$1"
BEFORE=$(wc -l "$1")
echo "The file $1 starts with $BEFORE"
sed -i.bak '/^\s*#/d;/^$/d' "$1"
AFTER=$(wc -l "$1")
echo "The file $1 is now $AFTER"
}
- 以下代码有什么问题?你如何修复它?
#!/bin/bash
myfunc() {
arr=$@
echo "The array from inside the function: ${arr[*]}"
}
test_arr=(1 2 3)
echo "The origianl array is: ${test_arr[*]}"
myfunc (${test_arr[*]})
进一步阅读
请参阅以下内容,获取与本章相关的进一步阅读材料:
第八章:介绍流编辑器
在上一章中,我们看到可以利用sed在脚本中编辑文件。sed命令是流编辑器(sed),它逐行打开文件来搜索或编辑文件内容。历史上,这一命令源于 Unix 系统,那时系统可能没有足够的内存来打开非常大的文件。使用sed进行编辑是必须的。即使在今天,我们仍然会使用sed来修改和显示包含数百或数千条条目的文件数据。它比人工尝试做同样的事更简单、更容易、更可靠。最重要的是,正如我们所见,我们可以在脚本中使用sed来自动编辑文件,不需要人工干预。
我们将从查看grep并在文件中搜索文本开始。grep命令中的re是正则表达式(regular expression)的缩写。尽管我们在本章中不涉及脚本编写,但我们将介绍一些非常重要的工具,这些工具可以与脚本一起使用。在下一章中,我们将看到sed在脚本中的实际应用。
但此刻我们有足够的内容可以处理,我们将在本章中讨论以下主题:
-
使用
grep显示文本 -
理解
sed的基础知识 -
其他
sed命令 -
多个
sed命令
技术要求
本章的源代码可以在这里下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter08
使用 grep 显示文本
我们将从学习grep命令开始。这将帮助我们掌握一些基本的文本搜索概念,然后再深入学习更复杂的正则表达式和使用sed编辑文件。
全局正则表达式打印(grep),或者我们更常称之为grep命令,是一个命令行工具,用于在全局范围内(跨文件中的所有行)搜索并将结果打印到STDOUT。搜索字符串是一个正则表达式。
grep命令是如此常用的工具,它有许多简单的示例和我们每天可以使用它的多种场合。在接下来的部分,我们包含了一些简单且实用的示例,并附有解释。
显示接口上接收到的数据
在这个示例中,我们将仅打印从eth0接口接收到的数据。
这是我的主要网络连接接口。如果你不确定你的接口名称,可以使用ifconfig -a命令来显示所有接口,并选择系统中正确的接口名称。如果找不到ifconfig,尝试输入完整路径/sbin/ifconfig。
只使用ifconfig eth0命令,就可以将大量数据打印到屏幕上。为了只显示接收到的包,我们可以隔离包含RX packets(RX代表接收)的行。这就是grep派上用场的地方:
$ ifconfig eth0 | grep "RX packets"
使用管道符或竖线,我们可以将ifconfig命令的输出发送到grep命令的输入。在这个例子中,grep正在搜索一个非常简单的字符串RX packets。搜索字符串是区分大小写的,因此我们需要确保正确,或者使用grep的-i选项使搜索不区分大小写,如下例所示:
$ ifconfig eth0 | grep -i "rx packets"
不区分大小写的搜索在查找配置文件中的选项时尤其有用,因为这些选项通常包含大小写混合的情况。
我们可以在以下截图中看到初始命令的结果,确认我们已经成功提取了仅有的单行输出,如下所示:

显示用户帐户数据
Linux 中的本地用户帐户数据库是/etc/passwd文件,所有用户帐户都可以读取此文件。如果我们想查找包含我们自己数据的那一行,我们可以在搜索中使用我们自己的登录名,或者使用参数扩展和$USER变量。我们可以在下面的命令示例中看到这一点:
$ grep "$USER" /etc/passwd
在这个示例中,grep的输入来自/etc/passwd文件,我们搜索的是$USER变量的值。同样,在这个例子中,它是一个简单的文本,但它依然是正则表达式,只是没有任何操作符。
为了完整性,我们在下面的截图中包含了输出:

我们可以通过这种类型的查询作为条件,在脚本中稍微扩展它。我们可以用它来检查一个用户帐户是否存在,然后再尝试创建一个新帐户。为了保持脚本尽可能简单,并确保不需要管理员权限,创建帐户的命令行示例将仅显示提示符和条件测试:
$ bash
$ read -p "Enter a user name: "
$ if (grep "$REPLY" /etc/passwd > /dev/null) ; then
> echo "The user $REPLY exists"
> exit 1
> fi
grep搜索现在使用了由read填充的$REPLY变量。如果我输入pi,系统将显示一条消息,并且我们将退出,因为我的用户帐户也叫pi。无需显示grep的结果;我们只需要寻找一个返回值,是真还是假。为了确保我们不会看到任何不必要的输出,如果用户在文件中,我们将grep的输出重定向到特殊设备文件/dev/null。
如果你想从命令行运行此操作,首先应该启动一个新的 bash shell。你可以通过简单地输入bash来做到这一点。这样,当exit命令执行时,它不会让你登出,而是关闭新打开的 shell。我们可以在以下截图中看到这一过程以及指定现有用户时的结果:

列出系统中的 CPU 数量
另一个非常有用的功能是,grep可以计算匹配的行数,而不显示它们。我们可以用它来计算系统上有多少个 CPU 或 CPU 核心。每个核心或 CPU 都会在/proc/cpuinfo文件中列出一个名称。然后,我们可以搜索name文本并计算输出;在下面的示例中展示了使用的-c选项:
$ grep -c name /proc/cpuinfo
我的 CPU 有四个核心,如以下输出所示:

如果我们在另一个只有单核心的 PC Model B 上使用相同的代码,我们将看到以下输出:

我们可以再次在脚本中利用这个方法,确保在运行 CPU 密集型任务之前有足够的核心可用。要在命令行中测试此功能,我们可以使用以下代码,在只有一个核心的 PC 上执行:
$ bash
$ CPU_CORES=$(grep -c name /proc/cpuinfo)
$ if (( CPU_CORES < 4 )) ; then
> echo "A minimum of 4 cores are required"
> exit 1
> fi
我们在开始时只运行bash,以确保我们不会因为exit命令而退出系统。如果这是一个脚本中的操作,那么就不需要此步骤,因为我们会退出脚本,而不是退出 shell 会话。
在 Model B(单核心)的系统上运行此脚本时,我们可以看到脚本的结果,以及一个指示我们没有所需核心数的提示:

如果你需要在多个脚本中运行此检查,你可以在共享脚本中创建一个函数,并在需要检查的脚本中源化包含共享函数的脚本:
function check_cores {
[ -z $1 ] && REQ_CORES=2
CPU_CORES=$(grep -c name /proc/cpuinfo)
if (( CPU_CORES < REQ_CORES )) ; then
echo "A minimum of $REQ_CORES cores are required"
exit 1
fi
}
如果参数传递给函数,则将其作为所需的核心数;否则,我们将默认值设置为2。如果我们在 Model B PC 的 shell 中定义这个函数,并使用type命令显示其详细信息,我们应该能看到如下截图所示:

如果我们在单核心系统上运行此脚本,并指定只需要一个核心,我们会看到在满足要求时没有输出。如果我们没有指定要求,它将默认为2个核心,并且我们将无法满足要求,最后会退出 shell。
我们可以看到,当传递参数1运行函数时的输出,以及没有传递参数时的输出,见下图:

我们可以看到,即使是grep的基本用法在脚本中也非常有用,并且我们如何利用学到的内容开始创建可用的模块来添加到我们的脚本中。
解析 CSV 文件
接下来,我们将看看如何创建一个脚本来解析或格式化 CSV 文件。文件的格式化将会在输出中添加新行、制表符和颜色,使其更加易读。然后,我们可以使用grep来显示 CSV 文件中的单个条目。这里的实际应用是基于 CSV 文件的目录系统。
CSV 文件
CSV 文件,或者说逗号分隔值列表,将来自当前目录中的名为 tools 的文件。这个文件是我们销售的产品目录。文件内容如下所示:
drill,99,5
hammer,10,50
brush,5,100
lamp,25,30
screwdriver,5,23
table-saw,1099,3
这只是一个简单的演示,因此我们不期望有太多数据,但目录中的每个项目包括以下内容:
-
名称
-
价格
-
库存单位
我们可以看到我们有一台售价 99 美元的钻孔机,并且库存有五个。如果我们用 cat 列出文件,显示效果并不友好;然而,我们可以编写一个脚本,以更吸引人的方式显示数据。我们可以创建一个名为 $HOME/bin/parsecsv.sh 的新脚本:
#!/bin/bash
OLDIFS="$IFS"
IFS=","
while read product price quantity
do
echo -e "\331;33m$product \
========================\033[0m\n\
Price : \t $price \n\
Quantity : \t $quantity \n"
done <"$1"
IFS=$OLDIFS
让我们一起来分析一下这个文件,看看其中的相关元素:
| 元素 | 含义 |
|---|---|
OLDIFS="$IFS" |
IFS 变量存储文件分隔符,通常是空格字符。我们可以保存旧的 IFS,以便在脚本结束时恢复它,确保脚本完成后环境保持不变,无论脚本如何运行。 |
IFS="," |
我们将分隔符设置为逗号,以便与 CSV 文件匹配。 |
while read product price quantity |
我们进入一个 while 循环,用来填充我们需要的三个变量:product、price 和 quantity。while 循环会逐行读取输入文件,并填充每个变量。 |
echo ... |
echo 命令将产品名称显示为蓝色,并在下方加上双下划线。其他变量会打印在新行上,并缩进显示。 |
done <"$1" |
这里我们读取输入文件,该文件作为参数传递给脚本。 |
脚本如下所示:

我们现在开始意识到,在命令行中我们有很大的能力来格式化文件,使其更易读,纯文本文件也不一定非得是单纯的文本。
隔离目录条目
如果我们需要搜索某个条目,那么我们需要的不仅仅是单行内容。这个条目有三行。因此,如果我们要查找锤子,我们需要去到锤子的那一行以及接下来的两行。我们可以使用 grep 的 -A 选项来实现这一点,-A 是 "after" 的缩写。我们需要显示匹配的行以及后面两行。这可以通过以下代码实现:
$ parsecsv.sh tool | grep -A2 hammer
下面的截图展示了这一点:

理解 sed 的基础
在构建了一些基础之后,我们现在可以开始查看一些 sed 的操作。大多数 Linux 系统都会提供这些命令,并且它们是核心命令。
我们将直接深入一些简单的例子:
$ sed 'p' /etc/passwd
p操作符将打印匹配的模式。在这种情况下,我们没有指定模式,因此将匹配所有内容。在不抑制STDOUT的情况下打印匹配的行将会重复行。此操作的结果是将passwd文件中的所有行打印两次。为了仅打印修改后的行,我们使用-n选项:
$ sed -n 'p' /etc/passwd
太棒了!!我们刚刚重新发明了cat命令。现在我们可以专门处理一系列行:
$ sed -n '1,3 p ' /etc/passwd
现在我们已经重新发明了head命令,但我们也可以在正则表达式模式中指定范围来重现grep命令:
$ sed -n '/^root/ p' /etc/passwd
我们可以在以下截图中看到这个演示:

注意,插入符号(^)表示行的开头,这意味着行必须以单词root开头。别担心,我们将在单独的章节中解释所有这些正则表达式字符。
替换命令
我们已经看到了用于打印模式空间的p命令。实际上,p是substitute命令s的一个标志。
substitute命令的写法如下:
$ sed s/pattern/replacement/flags
有三个常用的标志与substitute命令一起使用:
-
p: 打印原始内容 -
g: 对所有出现进行全局替换 -
w: 文件名:将结果发送到文件
现在我们将看一下substitute命令或s。使用这个命令,我们可以将一个字符串替换为另一个字符串。同样,默认情况下,我们将输出发送到STDOUT而不编辑文件。
要更换用户pi的默认 shell,可以使用以下命令:
sed -n ' /^pi/ s/bash/sh/p ' /etc/passwd
我们继续使用p命令打印匹配的模式,并使用-n选项来抑制STDOUT。我们搜索以pi开头的行,这代表用户名。然后我们使用s命令来替换这些匹配的行中的文本。这需要两个参数:第一个是要搜索的文本,第二个表示用来替换原始文本的文本。在这种情况下,我们查找bash并将其替换为sh。这很简单并且有效,但长期来看可能不是很可靠。我们可以在以下截图中看到输出:

我们必须强调,目前我们并没有编辑文件,只是将其显示在屏幕上。原始的passwd文件保持不变,我们可以以标准用户身份运行此命令。在前面的示例中,我提到搜索可能不够可靠,因为我们搜索的字符串是bash。这个字符串很短,也许在其他匹配的行中也可以找到它。可能某人的姓氏可能是Tabash,其中包含字符串bash。我们可以扩展搜索范围,查找/bin/bash并将其替换为/bin/sh。但是,这引入了另一个问题:默认的分隔符是斜杠,所以我们必须转义每个用于搜索和替换字符串中的斜杠,具体如下:
sed -n ' /^pi/ s/\/bin\/bash/\/usr\/bin\/sh/p ' /etc/passwd
这是一个选项,但它并不整洁。更好的解决方案是了解我们使用的第一个定界符定义了定界符。换句话说,您可以使用任何字符作为定界符。在这种情况下,使用@符号可能是一个好主意,因为它既不出现在搜索字符串中,也不出现在替换字符串中:
sed -n ' /^pi/ s@/bin/bash@/usr/bin/sh@p ' /etc/passwd
我们现在有了更可靠的搜索和更易读的命令行,这是一个好事。我们只替换了每行的第一个/bin/bash为/bin/sh。如果需要替换第一个以上的出现,我们在末尾添加g命令,表示全局替换:
sed -n ' /^pi/ s@bash@sh@pg ' /etc/passwd
在我们的案例中,这是不必要的,但知道这一点是很有帮助的。
全局替换
假设我们有以下示例文件:
Hello, sed is a powerful editing tool. I love working with sed
If you master sed, you will be a professional one
让我们尝试在这个文件上使用sed:
$ sed 's/sed/Linux sed/' myfile
在这里,我们使用sed将单词sed替换为Linux sed:

如果仔细检查结果,您会注意到sed只修改了每行的第一个词。
如果您想替换所有出现,这可能不是您想要的效果。
这里是g标志。
让我们再次使用它并查看结果:
$ sed 's/sed/Linux sed/g' myfile

现在所有的出现都已被修改。
您可以使用w标志将这些修改应用到文件中:
$ sed 's/sed/Linux sed/w outputfile' myfile
同样,您可以限制同一行中出现的次数,因此我们可以仅修改每行的前两个出现,如下所示:
$ sed 's/sed/Linux sed/2' myfile
所以,如果有第三次出现,它将被忽略。
限制替换
我们看到g标志如何修改同一行中的所有出现,这适用于整个文件的所有行。
如果我们想限制编辑到特定行?或者特定的行范围呢?
我们可以像这样指定结束行或行范围:
$ sed '2s/old text/new text/' myfile
上述命令只会修改文件的第二行。以下命令将仅修改第三行到第五行:
$ sed '3,5s/old text/new text/' myfile
以下命令将从第二行修改到文件末尾:
$ sed '2,$s/old text/new text/' myfile
编辑文件
使用w标志,我们可以将编辑内容写入文件,但如果我们想直接编辑文件本身怎么办?我们可以使用-i选项。我们需要有权限操作该文件,但可以先复制文件来操作,这样我们就不会损害任何系统文件,也不需要额外的权限。
我们可以将passwd文件复制到本地:
$ cp /etc/passwd "$HOME"
$ cd
我们用cd命令来确保我们在home目录下工作,并且在本地passwd文件中操作。
-i选项用于执行就地更新。当编辑文件时,我们不需要-n选项或p命令。因此,命令变得像以下示例一样简单:
$ sed -i ' /^pi/ s@/bin/bash@/bin/sh/ ' $HOME/passwd
该命令不会输出任何内容,但文件现在会反映出更改。以下截图展示了命令的使用:

在进行更改之前,我们应该通过在-i选项后直接添加一个字符串(不留空格)来备份。这在以下示例中展示:
$ sed -i.bak ' /^pi/ s@/bin/bash@/bin/sh/ ' $HOME/passwd
如果我们想要查看这个,我们可以反转搜索并替换字符串:
$ sed -i.bak ' /^pi/ s@/bin/sh@/bin/bash/ ' $HOME/passwd
这将使本地的 passwd 文件与之前相同,并且我们将有一个带有先前更改集的 passwd.bak。这样,如果需要,我们就有回滚选项。
其他 sed 命令
sed 提供了许多命令,可以轻松插入、更改、删除和转换文本。让我们看看如何使用这些命令与 sed 的一些示例。
删除命令
您可以使用 delete 命令 d 从流中删除行或一系列行。以下命令将删除流中的第三行:
$ sed '3d' myfile
以下命令将从流中删除第三到第五行:
$ sed '3,5d' myfile
此命令将删除从第四行到文件末尾的内容:
$ sed '4,$d' myfile
请注意,删除仅发生在流中,而不是实际文件。因此,如果要从实际文件中删除,可以使用 -i 选项:
$ sed -i '2d' myfile #Permenantly delete the second line from the file
插入和追加命令
insert,i 和 append,a 命令以几乎相同的方式工作,只有轻微的差异。
insert 命令在指定行或模式前插入指定文本。
append 命令在指定行或模式后插入指定文本。
让我们看几个例子。
我们的示例 02 文件将会是这样:
First line
Second line
Third line
Fourth line
要插入一行,您需要使用 insert 命令 i 如下:
$ sed '2i\inserted text' myfile
要添加一行,您需要使用如下的 append 命令 a:
$ sed '2a\inserted text' myfile
查看结果并检查插入行的位置:

更改命令
我们看到如何使用 substitute 命令 s 替换出现次数。那么 change 命令是什么,它又有什么不同?
change 命令,c,用于更改整行。
要更改一行,您可以使用 change 命令如下:
$ sed '2c\modified the second line' myfile

我们用新行替换了第二行。
转换命令
transform 命令用于将任何字母或数字替换为另一个,例如大写字母或将数字转换为不同的数字。
它的工作原理类似于 tr 命令。
您可以像这样使用它:
$ sed 'y/abc/ABC/' myfile

转换适用于整个流,不能限制。
多个 sed 命令
在所有先前的例子中,我们只对我们的流应用了一个 sed 命令。那么如何运行多个 sed 命令呢?
您可以通过使用 -e 选项,并用分号分隔命令来做到这一点,如下所示:
$ sed -e 's/First/XFirst/; s/Second/XSecond/' myfile

此外,您可以将每个命令输入到单独的行中,结果将相同:
$ sed -e '
> s/First/XFirst/
> s/Second/XSecond/' myfile
sed 命令提供了很大的灵活性;如果使用得当,您将获得很多的权力。
概要
这是另一个你已经牢牢掌握的章节,希望它对你真的有用。虽然我们希望专注于使用sed,但我们首先介绍了grep在脚本内部和外部的强大功能。尽管我们仅仅触及了sed的表面,我们将在下一个章节中扩展这些内容,深入讲解我们所学的知识。
此外,我们学习了如何替换文本、如何限制和全局化替换操作,以及如何使用-i选项保存编辑流。
我们学习了如何使用sed插入、追加、删除和转换文本。
最后,我们学习了如何使用-e选项运行多个sed命令。
在下一个章节中,我们将学习如何自动化 Apache 虚拟主机,如何自动创建新的虚拟主机,以及其他一些很酷的操作。这些操作的核心工具将是sed和sed脚本。
问题
- 假设你有一个包含以下内容的文件:
Hello, sed is a powerful editing tool. I love working with sed
If you master sed, you will be a professional one
并假设你使用了以下命令:
$ sed 's/Sed/Linux sed/g' myfile
将有多少行会被替换?
- 假设你有和前一个问题中相同的文件,并且你使用了以下命令:
$ sed '2d' myfile
将有多少行会从文件中删除?
- 在以下示例中,插入的行位于哪个位置?
$ sed '3a\Example text' myfile
- 假设你有一个和之前相同的示例文件,并且你执行了以下命令:
$ sed '2i\inserted text/w outputfile' myfile
将有多少行被保存到输出文件中?
深入阅读
请参阅以下内容,获取与本章相关的进一步阅读材料:
第九章:自动化 Apache 虚拟主机
现在,我们已经了解了一些 流编辑器(sed)的内容,可以将这些知识付诸实践。在第八章,介绍流编辑器,我们熟悉了sed的一些功能;然而,这只是编辑器所包含功能的一小部分。在本章中,我们将进一步练习sed,并接触一些工具的实际用途,尤其是在使用 bash 脚本时。
在本次旅程中,我们将使用sed帮助我们自动化创建基于名称的 Apache 虚拟主机。Apache 主机是我们展示过的sed的实际应用,但更重要的是,我们将使用sed在主配置文件中搜索选定的行。然后,我们会取消注释这些行并将其保存为模板。创建好模板后,我们将基于它创建新的配置文件。我们在 Apache 上展示的概念可以应用于许多不同的情况。
我们将发现,在 shell 脚本中使用sed将使我们能够轻松地从主配置中提取模板数据,并根据虚拟主机的需求进行调整。通过这种方式,我们将能够扩展对sed和 shell 脚本的理解。本章中,我们将涵盖以下主题:
-
基于名称的 Apache 虚拟主机
-
自动化虚拟主机创建
技术要求
你将需要以下内容:
-
CentOS 7.x 机器
-
已安装 Apache 2.4.x web 服务器
你可以按照以下步骤安装 Apache:
$ sudo yum install httpd
然后你可以启动 web 服务器:
$ systemctl start httpd
你可以通过以下命令检查服务的状态,确保它已经在运行:
$ systemctl status httpd
本章的源代码可以从这里下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter09
基于名称的 Apache 虚拟主机
在本次演示中,我们将使用来自 CentOS 7.x 主机的 Apache 2.4 HTTPD 服务器中的 httpd.conf 文件。坦率地说,我们更关心的是配置文件本身,因为它是由 Red Hat 或 CentOS 提供的,而不是我们将做的实际配置更改。该文件可以从本章的代码包中下载。我们的目的是学习如何从系统提供的文件中提取数据并创建一个模板。我们可以将这种方法应用于 Apache 配置文件或任何其他文本数据文件。我们关注的是方法论,而不是实际的结果。
为了理解我们要做的事情,我们必须首先查看 /etc/httpd/conf/httpd.conf 文件,这在 CentOS、Red Hat Enterprise Linux 或 Scientific Linux 中是相同的。以下截图显示了我们感兴趣的文件中的虚拟主机部分:

从这些行中可以看出,它们是注释的,而且这全部是一个单体的 httpd.conf 文件的一部分。在创建虚拟主机时,我们通常会为每个潜在的虚拟主机提供单独的配置。我们需要能够从主文件中提取这些数据,并同时去除注释。然后,我们可以将这些未注释的数据保存为模板。
使用此模板,我们将创建新的配置文件,代表我们需要在同一实例的 Apache 上运行的不同命名 hosts。这使得我们可以在单个服务器上托管 sales.example.com 和 marketing.example.com。销售和营销将拥有各自独立的配置和网站,互不干扰。此外,使用我们创建的模板,还可以轻松地添加我们需要的额外站点。主 Web 服务器的任务是读取传入的 HTTP 头请求,并根据使用的域名将其定向到正确的站点。
我们的第一个任务将是提取出 VirtualHost 开闭标签之间的内容,去除注释,并将其保存为模板。这只需要做一次,并且不会成为我们创建虚拟主机的主要脚本的一部分。
创建虚拟主机模板
由于我们不会测试所创建的虚拟主机,我们将复制一份 httpd.conf 文件,并在 home 目录下本地处理它。在开发脚本时,这是一个良好的实践,以避免影响正在使用的配置。我正在使用的 httpd.conf 文件应能够与脚本中的其他资源一起从发布者处下载。或者,你也可以从已安装 Apache 的 CentOS 主机复制它。确保将 httpd.conf 文件复制到你的 home 目录,并在 home 目录中进行操作。
第一步骤
创建模板的第一步是隔离出我们需要的行。在我们的例子中,这将是我们在前面截图中看到的示例虚拟主机定义中的行。这包括 VirtualHost 的开闭标签以及其中的所有内容。我们可以使用行号来实现这一点;然而,这可能不可靠,因为我们需要假设文件中没有任何更改,以保证行号一致。为了完整起见,我们将在介绍更可靠的机制之前先展示这一方法。
首先,我们会回顾一下如何使用 sed 打印整个文件。这很重要,因为在下一步中,我们将过滤显示,并只显示我们需要的行:
$ sed -n ' p ' httpd.conf
-n 选项用于抑制标准输出,引用中的 sed 命令是 p;它用于显示模式匹配。由于我们在这里没有过滤任何内容,因此匹配的模式就是整个文件。如果我们要使用行号进行过滤,可以通过 sed 容易地添加行号,如以下命令所示:
$ sed = httpd.conf
从以下截图中,我们可以看到,在此系统中,我们只需要处理355到361行;不过,我再次强调,这些行号可能会因文件不同而有所变化:

隔离行
要显示这些包含在标签中的行,我们可以为sed添加一个行范围。这可以通过将这些行号添加到sed命令中来轻松实现,如下所示:
$ sed -n '355,361 p ' httpd.conf
使用指定的行范围后,我们能够轻松地隔离出所需的行,现在显示的仅是虚拟主机定义的相关行。我们可以在以下截图中看到这一点,截图展示了命令和输出结果:

我们在硬编码行号时遇到的问题是,失去了灵活性。这些行号与此文件相关,可能仅与此文件相关。我们总是需要检查与我们正在处理的文件相关的正确行号。如果这些行不方便地位于文件的末尾,我们可能需要向后滚动才能找到正确的行号,这会是一个问题。为了解决这些问题,我们可以直接实现对开闭标签的搜索,而不是使用行号:
$ sed -n '/^#<VirtualHost/,/^#<\/VirtualHost/p' httpd.conf
我们不再使用起始和结束的行号,而是使用更可靠的起始正则表达式和结束正则表达式。起始正则表达式用于查找以#<VirtualHost开头的行。结束正则表达式则用来查找结束标签。然而,我们需要用转义字符保护/VirtualHost。通过查看正则表达式的结尾部分,我们可以看到它转换为查找以#\/VirtualHost开头的行,其中斜杠被转义。
如果你还记得在第八章《流编辑器介绍》中提到的内容,我们通过使用插入符号(^)来指定以特定字符开头的行。
通过查看以下截图,我们现在可以更加可靠地隔离所需的行,而无需知道行号。这对于编辑过的文件尤为重要,因为它们的行号会有所不同:

sed 脚本文件
隔离行只是第一步!我们还需要取消注释这些行,然后将结果保存为模板。尽管我们可以将这些操作写成一个单独的sed命令,但我们可以看到这将会非常冗长,不易阅读和编辑。幸运的是,sed命令确实提供了从输入文件读取命令的选项,这个文件通常称为脚本。我们使用-f选项和sed一起,指定我们想要作为控制的文件。
我们已经看到,能够从文件中提取出正确的行。因此,脚本的第一行配置了我们将要处理的行。我们使用大括号{}来定义一个代码块,这个代码块会立即在选定的行之后启动。
代码块是我们希望在给定选择上运行的一个或多个命令。
在我们的案例中,第一个命令将删除注释,第二个命令将把模式空间写入到新文件中。sed脚本应该如下所示:
/^#<VirtualHost/,/^#<\/VirtualHost/ {
s/^#//
w template.txt
}
我们可以将此文件保存为$HOME/vh.sed。
在第一行中,我们选择了要处理的行,正如我们之前所见,然后用左大括号开启了代码块。在第二行中,我们使用了substitute命令(s)。它会查找以注释或#开头的行。我们将注释替换为空字符串。中间和末尾的斜杠之间没有任何字符或空格。在英语中,我们是在取消注释这行,但对代码而言,这只是将#替换为空字符串。代码的最后一行使用w命令将其保存到template.txt文件中。为了帮助你理解,我们附上了vh.sed文件的截图:

我们现在可以看到我们的所有努力取得了成果,确保我们处在与httpd.conf和vh.sed文件相同的目录中,并执行以下命令:
$ sed -nf vh.sed httpd.conf
我们现在已经在工作目录中创建了template.txt文件。这是从httpd.conf文件中提取出的未注释文本。简而言之,我们在毫秒级的时间内从超过 350 行的文本中提取了七行正确的内容,去除了注释,并将结果保存为一个新文件。template.txt文件如下面的截图所示:

现在我们有了一个模板文件,可以开始使用它来创建虚拟主机定义。即使我们看到的是 Apache 的配置,去除文本注释或删除选定行的第一个字符的相同思路也可以应用到许多情境中,因此请将其作为sed功能的一个例子。
自动化虚拟主机创建
创建了模板后,我们现在可以利用它来创建虚拟主机配置。简单来说,我们需要将dummy-host.example.com的 URL 替换为sales.example.com或marketing.example.com的 URL。当然,我们还必须创建DocumentRoot目录,也就是存放网页的目录,并且添加一些基本内容。当我们使用脚本执行整个过程时,什么都不会被遗漏,每次编辑都会很准确。脚本的基础如下:
#!/bin/bash
WEBDIR=/www/docs
CONFDIR=/etc/httpd/conf.d
TEMPLATE=$HOME/template.txt
[ -d $CONFDIR ] || mkdir -p $CONFDIR
sed s/dummy-host.example.com/$1/ $TEMPLATE > $CONFDIR/$1.conf
mkdir -p $WEBDIR/$1
echo "New site for $1" > $WEBDIR/$1/index.html
我们可以忽略第一行的 shebang;我们现在应该已经知道了。我们可以从脚本的第二行开始解释:
| 行号 | 含义 |
|---|---|
WEBDIR=/www/docs/ |
我们初始化WEBDIR变量,并将其存储在指向存放不同网站的目录路径中。 |
CONFDIR=/etc/httpd/conf.d |
我们初始化CONFDIR变量,用于存储新创建的虚拟主机配置文件。 |
TEMPLATE=$HOME/template.txt |
我们初始化将用于模板的变量。这应指向我们的模板文件路径。 |
[ -d $CONFDIR ] || mkdir -p "$CONFDIR" |
在一个工作的EL6主机上,该目录将存在并包含在主配置中。如果我们将其作为纯粹的测试运行,则可以创建一个目录来证明我们可以在目标目录中创建正确的配置。 |
sed s/dummy-host.example.com/$1/ $TEMPLATE >$CONFDIR/$1.conf |
sed命令作为脚本中的引擎,执行搜索和替换操作。使用sed中的替换命令,我们搜索虚拟文本并将其替换为传递给脚本的参数。 |
mkdir -p $WEBDIR/$1 |
在这里,我们创建正确的子目录来存放新虚拟主机的网站。 |
echo "New site for $1" > $WEBDIR/$1/index.html |
在这最后一步,我们为网站创建一个基础的占位页面。 |
我们可以将此脚本创建为$HOME/bin/vhost.sh。别忘了添加执行权限。这在以下截图中有示例:

要创建销售虚拟主机和网页,我们可以按照以下示例运行脚本。我们将直接以 root 用户身份运行脚本。或者,您也可以选择在脚本中使用sudo命令:
# vhost.sh sales.example.com
我们现在可以看到,使用精心设计的脚本,我们可以多么轻松地创建虚拟主机。虚拟主机的配置文件将创建在/etc/httpd/conf.d/目录中,文件名将为sales.example.com.conf。该文件将类似于以下截图所示:

网站内容必须已经创建在/www/docs/sales.example.com目录中。这将是一个简单的占位页面,证明我们可以通过脚本实现这一点。使用以下命令,我们可以列出网站内容或我们用来存放每个站点的基础目录:
$ ls -R /www/docs
-R选项允许递归列出。我们使用/www/docs目录纯粹是因为它在我们提取的原始虚拟主机定义中已设置。如果在实际环境中工作,您可能更愿意使用/var/www或类似目录,而不是在文件系统的根目录下创建新目录。编辑我们创建的模板非常简单,您也可以在模板创建时使用sed来实现。
创建网站时提示输入数据
我们现在可以使用脚本来创建虚拟主机和内容,但除了虚拟主机名之外,我们没有允许任何自定义。当然,这是很重要的。毕竟,正是这个虚拟主机名在配置文件中使用,并且在设置网站目录和配置文件名时也会用到。
我们有可能允许在虚拟主机创建过程中指定额外的选项。我们将使用 sed 根据需要插入数据。sed 命令中的 i 用于在选择前插入数据,a 用于在选择后追加数据。
对于我们的示例,我们将添加一个主机限制,只允许本地网络访问该网站。我们更关心的是将数据插入文件,而不是我们对特定的 HTTP 配置文件所做的事情。在脚本中,我们将添加 read 提示并在配置中插入一个 Directory 块。
为了尝试解释我们想要做的事情,在执行脚本时,我们应该看到类似以下内容的输出。您可以从文本中看到,我们正在为营销网站创建它,并添加了访问限制,指定谁可以访问该网站:

如您所见,我们可以问两个问题,但如果需要,可以添加更多问题以支持自定义,目的是确保额外的自定义像脚本创建一样准确可靠。您也可以选择通过示例答案来详细阐述问题,以便用户了解网络地址应该如何格式化。
为了帮助脚本创建,我们将原始的 vhost.sh 复制到 vhost2.sh。我们可以整理脚本中的一些项目,以便更容易扩展,然后添加额外的提示。新脚本将类似于以下代码:
#!/bin/bash
WEBDIR=/www/docs/$1
CONFDIR=/etc/httpd/conf.d
CONFFILE=$CONFDIR/$1.conf
TEMPLATE=$HOME/template.txt
[ -d $CONFDIR ] || mkdir -p $CONFDIR
sed s/dummy-host.example.com/$1/ $TEMPLATE > $CONFFILE
mkdir -p $WEBDIR
echo "New site for $1" > $WEBDIR/index.html
read -p "Do you want to restrict access to this site? y/n "
[ ${REPLY^^} = 'n' ] && exit 0
read -p "Which network should we restrict access to: " NETWORK
sed -i "/<\/VirtualHost>/i <Directory $WEBDIR >\
\n Order allow,deny\
\n Allow from 127.0.0.1\
\n Allow from $NETWORK\
\n</Directory>" $CONFFILE
请注意,我们在脚本中没有运行太多的检查。这是为了保持我们对所添加元素的关注,而不是一个健壮的脚本。在您自己的环境中,一旦脚本按预期运行,您可能需要实施更多的检查以确保脚本的可靠性。
如您所见,我们有更多的行。WEBDIR 变量已被调整为包含目录的完整路径,类似地,我们添加了一个新的变量 CONFFILE,以便直接引用该文件。如果第一个提示的答案是 n 且用户不希望进行额外的自定义,脚本将退出。如果用户回答除了 n 以外的任何内容,脚本将继续并提示网络授权访问。然后,我们可以使用 sed 来编辑现有配置并插入新的 directory 块。默认情况下,它会拒绝访问,但允许来自 localhost 和 NETWORK 变量的访问。在代码中,我们将 localhost 称为 127.0.0.1。
为了简化代码并更好地理解,伪代码将如下所示:
$ sed -i "/SearchText/i NewText <filename>
这里的 SearchText 代表我们希望插入文本的文件行之前的行。同时,NewText 代表将插入到 SearchText 之前的新行或多行。紧随 SearchText 之后的 i 命令表明我们正在插入文本。使用 a 命令追加文本则意味着我们添加的文本将会在 SearchText 后插入。
我们可以看到 marketing.example.com 的结果配置文件,正如我们通过以下截图所创建的,并在其中添加了额外的 Directory 块:

我们可以看到,我们在关闭的 VirtualHost 标签上方添加了新的代码块。在脚本中,这就是我们使用的 SearchText。我们添加的 Directory 块替换了伪代码中的 NewText。当我们查看它时,似乎更为复杂,因为我们已经用 \n 嵌入了新行,并使用行继续字符 \ 格式化文件,以便更易于阅读。再次强调,一旦脚本创建完成,这个编辑是简单且精确的。
为了完整性,我们包括了以下 vhost2.sh 脚本的截图:

总结
在本章中,我们已经看到如何将 sed 扩展为一些非常酷的脚本,这些脚本让我们能够从文件中提取数据,取消注释选定的行并写入新的配置。不仅如此,我们还看到了如何使用 sed 编写插入新行到现有文件的脚本。我认为,sed 很快会成为你的得力助手,我们已经创建了一些强大的脚本来支持学习体验。
你可能已经知道,sed 有个更大的兄弟——awk。在下一章中,我们将看到如何使用 awk 从文件中提取数据。
问题
-
如何从 Apache 配置文件中打印第
50行? -
如何使用
sed将 Apache 默认端口80更改为8080?
进一步阅读
请参阅以下内容,进一步阅读本章相关内容:
第十章:AWK 基础
流编辑器并不是唯一的家族成员,它还有一个“大哥”——AWK。在本章中,我们将学习 AWK 的基础知识,并探索 AWK 编程语言的强大功能。我们将了解为什么我们需要和喜爱 AWK,以及如何在接下来的两章中实际使用 AWK 之前,先利用一些基本特性。在学习过程中,我们将涵盖以下主题:
-
AWK 背后的历史
-
显示和过滤文件中的内容
-
AWK 变量
-
条件语句
-
格式化输出
-
进一步筛选,按 UID 显示用户
-
AWK 控制文件
技术要求
本章的源代码可以在此下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter10
AWK 背后的历史
awk 命令是 UNIX 和 Linux 中常用的命令套件之一。UNIX awk 命令最早由贝尔实验室在 1970 年代开发,命名来自主要作者的姓氏:阿尔弗雷德·阿霍(Alfred Aho)、彼得·温伯格(Peter Weinberger)和布赖恩·柯宁汉(Brian Kernighan)。awk 命令使得可以使用 AWK 编程语言,该语言设计用于处理文本流中的数据。
AWK 有许多实现版本:
-
gawk:也称为 GNU AWK,是 AWK 的免费版本,被许多开发者使用;我们将在本书中使用它。
-
mawk:另一种由迈克·布伦南(Mike Brennan)开发的实现。这个实现只包含了少数几个 gawk 特性;它的设计侧重于速度和性能。
-
tawk:也称为 Thompson AWK,是一个可以在 Solaris、DOS 和 Windows 上运行的实现版本。
-
BWK awk:也称为 nawk,OpenBSD 和 macOS 使用的是这一版本。
请注意,本书中我们使用的 awk 解释器是 gawk,但它有一个名为 awk 的符号链接。因此,awk 和 gawk 是相同的命令。
你可以通过列出 awk 二进制文件,查看它指向的路径来确保这一点:

为了演示 awk 提供的编程语言,我们应该创建一个 Hello World 程序。我们知道这是所有语言的必备程序:
$ awk 'BEGIN { print "Hello World!" }'
我们不仅可以看到这段代码会打印出常见的 hello 消息,还可以使用 BEGIN 块生成头信息。稍后我们将看到,
你可以通过 END 代码块生成总结信息,从而允许一个主
代码块。
我们可以在下图中看到这条基本命令的输出:

显示和过滤文件中的内容
现在,当然我们都希望能够打印的不仅仅是Hello World。awk命令可以用来从文件中筛选内容,如果需要的话,还可以筛选非常大的文件。我们应该从打印完整的文件开始,然后再进行筛选。通过这种方式,我们可以熟悉命令的语法。稍后,我们将看到如何将这些控制信息添加到awk文件中,以简化命令行。使用以下命令,我们将打印/etc/passwd文件中的所有行:
$ awk ' { print } ' /etc/passwd
这相当于在print语句中使用$0变量:
$ awk ' { print $0 }' /etc/passwd
AWK 为我们提供了一些现成的变量来提取数据,例如:
-
$0表示整行内容 -
$1表示第一个字段 -
$2表示第二个字段 -
$3表示第三个字段,依此类推
然而,我们需要指定在这个文件中使用的字段分隔符是冒号,因为它是/etc/passwd文件中的字段分隔符。awk的默认分隔符是空格、任意数量的空格、制表符或换行符。指定输入分隔符有两种方式;这些方式将在以下示例中展示。
第一个示例简单易用。-F选项非常有效,特别是当我们不需要任何额外的头部信息时:
$ awk -F":" '{ print $1 }' /etc/passwd
我们也可以在BEGIN块中做这件事;当我们希望使用BEGIN块来显示头部信息时,这非常有用:
$ awk ' BEGIN { FS=":" } { print $1 } ' /etc/passwd
我们可以从前面的示例中清楚地看到,我们命名了BEGIN块,所有代码都被大括号括起来。主块没有名字,它被大括号括起来。
在看到BEGIN块和主代码块之后,我们现在来看一下END代码块。这个块通常用于显示汇总数据。例如,如果我们想打印passwd文件的总行数,我们可以利用END块。带有BEGIN和END块的代码只会处理一次,而主块会对每一行进行处理。以下示例在我们迄今编写的代码上添加了总行数的计算:
$ awk ' BEGIN { FS=":" } { print $1 } END { print NR } ' /etc/passwd
awk的内部变量NR维护了已处理行的数量。如果我们需要,我们可以为此添加一些附加文本。这可以用来注解汇总数据。我们还可以使用 AWK 语言中的单引号;它们允许我们将代码分散到多行。只要我们打开了单引号,就可以在命令行中添加换行符,直到关闭引号为止。下一示例展示了我们如何扩展汇总信息:
$ awk ' BEGIN { FS=":" }
> { print $1 }
> END { print "Total:",NR } ' /etc/passwd
如果我们不希望在这里结束我们的 AWK 体验,我们可以轻松地显示每行的行号计数,以及最终的总计。以下示例展示了这一点:
$ awk ' BEGIN { FS=":" }
> { print NR,$1 }
> END { print "Total:",NR } ' /etc/passwd
以下截图捕捉了这个命令并显示了部分输出:

在第一个包含 BEGIN 的示例中,我们看到没有理由不能单独使用 END 代码块而不依赖主代码块。如果我们需要模拟 wc -l 命令,可以使用以下 awk 语句:
$ awk ' END { print NR }' /etc/passwd
输出将是文件中的行数。以下截图展示了 awk 命令和 wc 命令如何一起用于统计 /etc/passwd 文件中的行数:

如我们所见,输出结果与 28 行一致,代码运行成功。
另一个我们可以练习的特性是只处理选定的行。例如,如果我们只想打印前五行,我们将使用以下语句:
$ awk ' NR < 6 ' /etc/passwd
如果我们想打印第 8 行到第 12 行,我们可以使用以下代码:
$ awk ' NR==8,NR==12 ' /etc/passwd
我们还可以使用正则表达式来匹配行中的文本。看看下面的示例,我们查找以 bash 结尾的行:
$ awk ' /bash$/ ' /etc/passwd
示例及其输出如下所示:

所以如果你想使用正则表达式模式,应该使用两个斜杠并将模式写在其中,/bash$/。
AWK 变量
我们看到了如何使用数据字段,例如 $1 和 $2。此外,我们还看到了 NR 字段,它保存了处理的行号,但 AWK 提供了更多内置变量,能够进一步简化工作。
-
FIELDWIDTHS:指定字段宽度 -
RS:指定记录分隔符 -
FS:指定字段分隔符 -
OFS:指定输出分隔符,默认为空格 -
ORS:指定输出分隔符 -
FILENAME:保存正在处理的文件名 -
NF:保存正在处理的行 -
FNR:保存当前处理的记录 -
IGNORECASE:忽略字符大小写
这些变量在很多情况下都能为你提供很大的帮助。假设我们有以下文件:
John Doe
15 back street
(123) 455-3584
Mokhtar Ebrahim
10 Museum street
(456) 352-3541
我们可以说,我们有两个记录,分别对应两个人,每个记录包含三个字段。假设我们需要打印名字和电话号码。那么,如何让 AWK 正确处理它们呢?
在这个例子中,字段是通过换行符(\n)分隔的,记录是通过空行分隔的。
所以,如果我们将 FS 设置为(\n)并将 RS 设置为空文本,字段将会正确识别:
$ awk 'BEGIN{FS="\n"; RS=""} {print $1,$3}' myfile

结果看起来有效且合适。
以同样的方式,你可以使用 OFS 和 ORS 来生成输出报告:
$ awk 'BEGIN{FS="\n"; RS=""; OFS="*"} {print $1,$3}' myfile

你可以使用任何符合需求的文本。
我们知道 NR 保存的是当前处理行的编号,而 FNR 从定义上看是相同的,但让我们通过以下示例来看看它们之间的区别:
假设我们有以下文件:
Welcome to AWK programming
This is a test line
And this is one more
我们使用 AWK 来处理这个文件:
$ awk 'BEGIN{FS="\n"}{print $1,"FNR="FNR}' myfile myfile

这里我们只是为了测试目的,处理了两次文件,以查看 FNR 变量的值。
如你所见,每个处理周期中的值从 1 开始。
让我们看看是否以相同的方式使用了 NR 变量:
$ awk 'BEGIN {FS="\n"} {print $1,"FNR="FNR,"NR="NR} END{print "Total lines: ",NR}' myfile myfile

NR 变量在整个处理过程中保持其值,而 FNR 则从 1 开始。
用户定义的变量
你可以像其他编程语言一样,在 AWK 编程中定义自己的变量。
你可以使用任何文本来定义变量,但变量名不能以数字开头:
$ awk '
BEGIN{
var="Welcome to AWk programming"
print var
}'

你可以定义任何类型的变量并以相同的方式使用它。
你可以像这样定义数字:
$ awk '
BEGIN{
var1=2
var2=3
var3=var1+var2
print var3
}'

或者像这样进行字符串拼接:
$ awk '
BEGIN{
str1="Welcome "
str2=" To shell scripting"
str3=str1 str2
print str3
}'

如你所见,AWK 是一种强大的脚本语言。
条件语句
AWK 支持条件语句,如 if 和 while 循环。
if 命令
假设你有以下文件:
50
30
80
70
20
90
现在,让我们过滤这些值:
$ awk '{if ($1 > 50) print $1}' myfile

if 语句检查每个值,如果大于 50,则打印该值。
你可以像这样使用 else 语句:
$ awk '{
if ($1 > 50)
{
x = $1 * 2
print x
} else
{
x = $1 * 3
print x
}}' myfile

如果你不使用大括号 {} 将语句括起来,可以在同一行中使用分号输入语句:
$ awk '{if ($1 > 50) print $1 * 2; else print $1 * 3}' myfile
注意,你可以将此代码保存到文件中,并使用 -f 选项将其分配给 awk 命令,稍后我们将在本章中看到这一点。
while 循环
AWK 会处理你文件的每一行,但如果你想遍历每一行的字段该怎么办?
当使用 AWK 时,你可以通过 while 循环遍历字段。
假设我们有以下文件:
321 524 124
174 185 254
195 273 345
现在,让我们使用 while 循环来遍历字段。
$ awk '{
total = 0
i = 1
while (i < 4)
{
total += $i
i++
}
mean = total / 3
print "Mean value:",mean
}' myfile

while 循环遍历字段,我们计算每一行的平均值并打印出来。
for 循环
你可以像这样使用 for 循环来遍历 AWK 中的值:
$ awk '{
total = 0
for (var = 1; var < 4; var++)
{
total += $var
}
mean = total / 3
print "Mean value:",mean
}' myfile

我们这次使用 for 循环实现了相同的结果。
格式化输出
到目前为止,我们一直忠实于使用 print 命令,因为我们对输出的要求较少。如果我们想要打印用户名、UID 和默认 shell 等内容,我们需要稍微格式化输出。在这种情况下,我们可以将输出组织成整齐的列。如果没有格式化,所使用的命令将类似于以下示例,其中我们使用逗号分隔我们要打印的字段:
$ awk ' BEGIN { FS=":" } { print $1,$3,$7 } ' /etc/passwd
我们在这里使用 BEGIN 块,因为稍后我们可以利用它来打印列标题。
为了更好地理解问题,请看一下下面的截图,展示了不均匀的列宽:

输出中的问题是,列没有对齐,因为用户名的长度不一致。为了解决这个问题,我们可以使用printf函数,在其中指定列的宽度。awk语句的语法将类似于以下示例:
$ awk ' BEGIN { FS=":" }
> { printf "%10s %4d %17s\n",$1,$3,$7 } ' /etc/passwd
printf格式化包含在双引号内。我们还需要通过\n来添加换行符。printf函数不会自动添加换行,而print函数会。我们打印了三个字段;第一个接受字符串值,并设置为宽度10个字符。中间字段最多接受 4 个数字,最后是默认的 shell 字段,允许最多17个字符串字符。
以下截图展示了如何改进输出:

我们可以通过添加头部信息进一步增强这一点。虽然此时代码开始显得杂乱无章,但稍后我们将看到如何通过 AWK 控制文件解决这个问题。以下示例展示了头部信息是如何添加到Begin块中的。分号用于分隔BEGIN块中的两个语句:
$ awk 'BEGIN {FS=":" ;printf "%10s %4s %17s\n","Name","UID","Shell" }
> { printf "%10s %4d %17s\n",$1,$3,$7 } ' /etc/passwd
在以下截图中,我们可以看到这如何进一步改进输出:

在前一章中,我们看到如何通过在 shell 中使用颜色来增强输出。我们还可以通过在 AWK 中添加自定义函数来使用颜色。在接下来的代码示例中,你将看到 AWK 允许我们定义自己的函数,以便于进行更复杂的操作并隔离代码。我们将修改之前的代码以在头部添加绿色输出:
$ awk 'function green(s) {
> printf "\0331;32m" s "\033[0m\n"
> }
> BEGIN {FS=":";
green(" Name: UID: Shell:") }
> { printf "%10s %4d %17s\n",$1,$3,$7 } ' /etc/passwd
在awk中创建函数使我们可以在需要的地方添加颜色,在这种情况下是绿色文本。创建定义其他颜色的函数也非常简单。以下截图中包含了代码和输出:

关于正则表达式的一件非常重要的事情是,它们是区分大小写的:
$ echo "Welcome to shell scripting" | awk '/shell/{print $0}'
$ echo "Welcome to SHELL scripting" | awk '/shell/{print $0}'

假设你想匹配以下任意字符:
.*[]^${}\+?|()
你必须使用反斜杠对它们进行转义,因为这些字符是正则表达式引擎的特殊字符。
现在你已经知道如何定义 BRE 模式。接下来,我们来使用常见的 BRE 字符。
锚字符
锚字符用于匹配行的开头或结尾。这里有两个锚字符:插入符号(^)和美元符号($)。
插入符号字符用于匹配行的开头:
$ echo "Welcome to shell scripting" | awk '/^Welcome/{print $0}'
$ echo "SHELL scripting" | awk '/^Welcome/{print $0}'
$ echo "Welcome to shell scripting" | sed -n '/^Welcome/p'

所以,插入符号字符用于检查指定的文本是否位于行的开头。
如果你想查找插入符号字符作为普通字符,如果使用 AWK,你应该用反斜杠对其进行转义。
但是,如果你使用sed,你就不需要转义它:
$ echo "Welcome ^ is a test" | awk '/\^/{print $0}'
$ echo "Welcome ^ to shell scripting" | sed -n '/^/p'

要匹配文本的结尾,你可以使用美元符号字符($):
$ echo "Welcome to shell scripting" | awk '/scripting$/{print $0}'
$ echo "Welcome to shell scripting" | sed -n '/scripting$/p'

你可以在同一个模式中同时使用插入符号(^)和美元符号($)来指定文本。
你可以使用这些字符来执行有用的操作,例如查找空行并将其删除:
$ awk '!/^$/{print $0}' myfile
感叹号(!)被称为否定字符,它会否定它后面的内容。
该模式搜索^$,其中插入符号(^)表示行的开头,美元符号($)表示行的结尾,这意味着搜索开头和结尾之间没有内容的行,即空行。然后,我们用感叹号(!)来取反,得到非空行。
让我们将其应用于以下文件:
Lorem Ipsum is simply dummy text .
Lorem Ipsum has been the industry's standard dummy.
It has survived not only five centuries
It is a long established fact that a reader will be distracted.
现在,让我们来看一下魔法:
$ awk '!/^$/{print $0}' myfile

打印的行不包含空行。
点字符
点字符匹配除了换行符(\n)以外的任何字符。我们可以将其应用于以下文件:
Welcome to shell scripting.
I love shell scripting.
shell scripting is awesome.
假设我们使用以下命令:
$ awk '/.sh/{print $0}' myfile
$ sed -n '/.sh/p' myfile
该模式匹配任何包含sh以及其之前的任何文本的行:

如你所见,它只匹配前两行,因为第三行以sh开头,所以第三行没有匹配。
字符类
我们已经看到如何使用点字符匹配任何字符。如果你想只匹配特定的一组字符呢?
你可以将你想匹配的字符放在方括号[]中进行匹配,这就是字符类。
让我们以以下文件为例:
I love bash scripting.
I hope it works without a crash.
Or I'll smash it.
让我们看看字符类是如何工作的:
$ awk '/[mbr]ash/{print $0}' myfile
$ sed -n '/[mbr]ash/p' myfile

字符类[mbr]匹配任何一个包含字符,并且后面跟着“ash”,因此它匹配这三行。
你可以在有用的操作中使用它,例如匹配大写或小写字符:
$ echo "Welcome to shell scripting" | awk '/^[Ww]elcome/{print $0}'
$ echo "welcome to shell scripting" | awk '/^[Ww]elcome/{print $0}'
字符类使用插入符号字符来取反,如下所示:
$ awk '/[^br]ash/{print $0}' myfile

在这里,我们匹配任何包含“ash”并且既不以b也不以r开头的行。
记住,在方括号外使用插入符号字符(^)表示行的开头。
使用字符类,你可以指定你想要的字符。如果你有一个长范围的字符怎么办?
字符范围
你可以在方括号中指定一组字符范围,如下所示:
[a-d]
这意味着从 a 到 d 的字符范围,所以 a、b、c 和 d 都包含在内。
让我们使用之前的示例文件:
$ awk '/[a-m]ash/{print $0}' myfile
$ sed -n '/[a-m]ash/p' myfile

从 a 到 m 的字符范围被选择。第三行包含 r 在灰烬之前,这不在我们的范围内,因此只有第二行不匹配。
你也可以使用数字范围:
$ awk '/[0-9]/'
这个模式意味着从 0 到 9 的字符会被匹配。
你可以在同一个括号中写多个范围:
$ awk '/[d-hm-z]ash/{print $0}' myfile $ sed -n '/[d-hm-z]ash/p' myfile

在这个模式中,从 d 到 h 和从 m 到 z 被选中,因为第一行包含 b 在灰烬之前,所以只有第一行不匹配。
你可以使用范围选择所有大写和小写字母,如下所示:
$ awk '/[a-zA-Z]/'
特殊字符类
我们看到了如何使用字符类来匹配一组字符,然后我们看到了如何使用字符范围来匹配一组字符。
实际上,ERE 引擎提供了现成的类,用于匹配一些常见的字符集,如下所示:
[[:alpha:]] |
匹配任何字母字符 |
|---|---|
[[:upper:]] |
只匹配 A–Z 大写字母 |
[[:lower:]] |
只匹配 a–z 小写字母 |
[[:alnum:]] |
匹配 0–9、A–Z 或 a–z |
[[:blank:]] |
只匹配空格或 Tab |
[[:space:]] |
匹配任何空白字符:空格、Tab、CR |
[[:digit:]] |
匹配从 0 到 9 的数字 |
[[:print:]] |
匹配任何可打印字符 |
[[:punct:]] |
匹配任何标点字符 |
所以,如果你想匹配大写字母,可以使用 [[:upper:]],它将与字符范围 [A-Z] 完全一样有效。
让我们对以下示例文件进行测试:
checking special character classes.
This LINE contains upper case.
ALSO this one.
我们将匹配大写字母,看看它是如何工作的:
$ awk '/[[:upper:]]/{print $0}' myfile $ sed -n '/[[:upper:]]/p' myfile

大写字母特殊类使得匹配任何包含大写字母的行变得更加容易。
星号
星号用于匹配字符或字符类的存在零次或多次。
当你在查找一个有多种变体或拼写错误的词时,这会很有用:
$ echo "Checking colors" | awk '/colou*rs/{print $0}' $ echo "Checking colours" | awk '/colou*rs/{print $0}'

如果字符 u 根本不存在,或者存在,这都将匹配该模式。
我们可以通过将星号与点字符一起使用,来匹配任意数量的字符。
让我们看看如何在以下示例文件中使用它们:
This is a sample line
And this is another one
This is one more
Finally, the last line is this
让我们编写一个匹配包含单词 this 和其后所有内容的模式:
$ awk '/this.*/{print $0}' myfile $ sed -n '/ this.*/p' myfile

第四行包含单词 this,但第一行和第三行包含大写字母 T,因此不匹配。
第二行包含了单词和其后的文本,而第四行包含了单词和后面没有任何内容,在这两种情况下,星号匹配零个或多个实例。
你可以将星号与字符类一起使用,匹配字符类中任意字符出现一次或没有出现。
$ echo "toot" | awk '/t[aeor]*t/{print $0}' $ echo "tent" | awk '/t[aeor]*t/{print $0}' $ echo "tart" | awk '/t[aeor]*t/{print $0}'

第一行包含字符 o 两次,因此它匹配。
第二行包含 n 字符,它不在字符类中,所以没有匹配。
第三行包含字符 a 和 r,每个字符出现一次,并且它们都在字符类中,因此该行也匹配该模式。
定义 ERE 模式
我们已经看到如何轻松定义 BRE 模式。现在,我们将看到一些更强大的 ERE 模式。
除了 BRE 模式外,ERE 引擎还理解以下模式:
-
问号
-
加号
-
花括号
-
管道字符
-
表达式分组
默认情况下,AWK 支持 ERE 模式,而 sed 需要 -r 来理解这些模式。
问号
问号匹配前一个字符或字符类出现零次或一次:
$ echo "tt" | awk '/to?t/{print $0}' $ echo "tot" | awk '/to?t/{print $0}' $ echo "toot" | awk '/to?t/{print $0}' $ echo "tt" | sed -r -n '/to?t/p' $ echo "tot" | sed -r -n '/to?t/p' $ echo "toot" | sed -r -n '/to?t/p'

在前两个示例中,字符 o 出现零次或一次,而在第三个示例中,它出现了两次,这不符合模式。
以相同的方式,你可以将问号与字符类一起使用:
$ echo "tt" | awk '/t[oa]?t/{print $0}' $ echo "tot" | awk '/t[oa]?t/{print $0}' $ echo "toot" | awk '/t[oa]?t/{print $0}' $ echo "tt" | sed -r -n '/t[oa]?t/p' $ echo "tot" | sed -r -n '/t[oa]?t/p' $ echo "toot" | sed -r -n '/t[oa]?t/p'

第三个示例只没有匹配,因为它包含了两次 o 字符。
注意,当将问号与字符类一起使用时,文本中不需要包含字符类中的所有字符;只要有一个字符匹配即可通过模式。
加号
加号匹配前一个字符或字符类出现一次或多次,因此它至少必须出现一次:
$ echo "tt" | awk '/to+t/{print $0}' $ echo "tot" | awk '/to+t/{print $0}' $ echo "toot" | awk '/to+t/{print $0}' $ echo "tt" | sed -r -n '/to+t/p' $ echo "tot" | sed -r -n '/to+t/p' $ echo "toot" | sed -r -n '/to+t/p'

第一个示例没有 o 字符,这就是为什么它是唯一没有匹配的示例。
同样,我们可以将加号与字符类一起使用:
$ echo "tt" | awk '/t[oa]+t/{print $0}' $ echo "tot" | awk '/t[oa]+t/{print $0}' $ echo "toot" | awk '/t[oa]+t/{print $0} $ echo "tt" | sed -r -n '/t[oa]+t/p' $ echo "tot" | sed -r -n '/t[oa]+t/p' $ echo "toot" | sed -r -n '/t[oa]+t/p'

第一个示例只没有匹配,因为它完全没有 o 字符。
花括号
花括号定义了前一个字符或字符类出现的次数:
$ echo "tt" | awk '/to{1}t/{print $0}' $ echo "tot" | awk '/to{1}t/{print $0}' $ echo "toot" | awk '/to{1}t/{print $0}' $ echo "tt" | sed -r -n '/to{1}t/p' $ echo "tot" | sed -r -n '/to{1}t/p' $ echo "toot" | sed -r -n '/to{1}t/p'

第三个示例没有任何匹配,因为 o 字符出现了两次。那么,如果你想指定一个更灵活的数量呢?
你可以在花括号内指定一个范围:
$ echo "toot" | awk '/to{1,2}t/{print $0}' $ echo "toot" | sed -r -n '/to{1,2}t/p'

在这里,我们匹配 o 字符,如果它出现一次或两次。
同样,你可以将花括号与字符类一起使用:
$ echo "tt" | awk '/t[oa]{1}t/{print $0}' $ echo "tot" | awk '/t[oa]{1}t/{print $0}' $ echo "toot" | awk '/t[oa]{1}t/{print $0}' $ echo "tt" | sed -r -n '/t[oa]{1}t/p' $ echo "tot" | sed -r -n '/t[oa]{1}t/p' $ echo "toot" | sed -r -n '/t[oa]{1}t/p'

正如预期的那样,如果 [oa] 中的任何字符出现一次,模式就会匹配。
管道字符
管道字符 (|) 告诉正则表达式引擎匹配传入的任何字符串。因此,如果其中之一存在,模式就会匹配。这就像是传入字符串之间的逻辑 OR:
$ echo "welcome to shell scripting" | awk '/Linux|bash|shell/{print $0}' $ echo "welcome to bash scripting" | awk '/Linux|bash|shell/{print $0}' $ echo "welcome to Linux scripting" | awk '/Linux|bash|shell/{print $0}' $ echo "welcome to shell scripting" | sed -r -n '/Linux|bash|shell/p' $ echo "welcome to bash scripting" | sed -r -n '/Linux|bash|shell/p' $ echo "welcome to Linux scripting" | sed -r -n '/Linux|bash|shell/p'

所有之前的示例都有匹配,因为每个示例中都有这三个单词中的任意一个。
管道符与单词之间没有空格。
表达式分组
你可以使用括号()来分组字符或单词,使它们在正则表达式引擎看来是一个整体:
$ echo "welcome to shell scripting" | awk '/(shell scripting)/{print $0}' $ echo "welcome to bash scripting" | awk '/(shell scripting)/{print $0}' $ echo "welcome to shell scripting" | sed -r -n '/(shell scripting)/p' $ echo "welcome to bash scripting" | sed -r -n '/(shell scripting)/p'

由于shell scripting字符串被括号分组,它将被当作一个整体处理。
所以,如果整个句子不存在,模式将会失败。
你可能已经意识到,你可以像这样不使用括号也能实现相同的效果:
$ echo "welcome to shell scripting" | sed -r -n '/shell scripting/p'
那么,使用括号或表达式分组有什么好处呢?请查看以下示例,了解其中的区别。
你可以将任何 ERE 字符与分组括号一起使用:
$ echo "welcome to shell scripting" | awk '/(bash scripting)?/{print $0}' $ echo "welcome to shell scripting" | awk '/(bash scripting)+/{print $0}' $ echo "welcome to shell scripting" | sed -r -n '/(bash scripting)?/p' $ echo "welcome to shell scripting" | sed -r -n '/(bash scripting)+/p'

在第一个示例中,我们使用问号查找整个句子bash scripting,查找零次或一次,因为整个句子不存在,所以模式成功匹配。
如果没有表达式分组,你将无法得到相同的结果。
使用 grep
如果我们要深入讨论grep,一本书也不够。grep支持许多引擎,包括 BRE 和 ERE。它支持的引擎有Perl 兼容正则表达式(PCRE)。
grep是一个非常强大的工具,几乎所有系统管理员每天都会使用它。我们只是想通过类似于 sed 和 AWK 的方式,启发大家如何使用 BRE 和 ERE 模式。
grep工具默认理解 BRE 模式,如果你想使用 ERE 模式,你需要使用-E选项。
让我们使用以下示例文件并应用一个 BRE 模式:
Welcome to shell scripting.
love shell scripting.
shell scripting is awesome.
让我们测试一个 BRE 模式:
$ grep '.sh' myfile

结果以红色高亮显示。
让我们测试一个 ERE 模式:
$ grep -E 'to+' myfile

其他所有 ERE 字符也可以以相同的方式使用。
总结
在本章中,我们介绍了正则表达式以及正则表达式引擎 BRE 和 ERE。我们学习了如何为它们定义模式。
我们学习了如何为 sed、AWK 和grep编写这些模式。
此外,我们还看到了特殊字符类如何使匹配字符集变得更加容易。
我们看到了如何使用强大的 ERE 模式以及如何对表达式进行分组。
最后,我们看到了如何使用grep工具以及如何定义 BRE 和 ERE 模式。
在接下来的两章中,我们将看到一些 AWK 的实际示例。
问题
- 假设你有以下文件:
Welcome to shell scripting.
I love shell scripting.
shell scripting is awesome.
假设你运行以下命令:
$ awk '/awesome$/{print $0}' myfile
输出将打印多少行?
- 如果我们对前面的文件使用以下命令,会打印多少行?
$ awk '/scripting\..*/{print $0}' myfile
- 如果我们对前面的示例文件使用以下命令,会打印多少行?
$ awk '/^[Ww]?/{print $0}' myfile
- 以下命令的输出是什么?
$ echo "welcome to shell scripting" | sed -n '/Linux|bash|shell/p'
进一步阅读
请参见以下内容,了解与本章相关的进一步阅读资料:
第十二章:使用 AWK 汇总日志
在上一章中,我们讨论了正则表达式,并且看到了如何利用它们来增强 sed 和 AWK。本章中,我们将讨论一些使用 AWK 的实际示例。
AWK 的一个强项是从日志文件中过滤数据。这些日志文件可能有很多行,可能达到 250,000 行或更多。我曾处理过超过一百万行的数据。AWK 可以快速高效地处理这些行。作为例子,我们将处理一个包含 30,000 行的 Web 服务器访问日志,以展示高效且写得很好的 AWK 代码的效果。在本章中,我们将看到不同的日志文件,并回顾一些我们可以使用 awk 命令和 AWK 编程语言来帮助报告和管理服务的技术。在本章中,我们将涵盖以下主题:
-
HTTPD 日志文件格式
-
显示来自 Web 日志的数据
-
显示排名最高的客户端 IP 地址
-
显示浏览器数据
-
处理电子邮件日志
技术要求
本章的源代码可以从这里下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter12
HTTPD 日志文件格式
在处理任何文件时,首要任务是熟悉文件的模式。简单来说,我们需要了解每个字段所表示的内容以及用什么来分隔字段。我们将处理 Apache HTTPD Web 服务器的访问日志文件。日志文件的位置可以通过 httpd.conf 文件进行控制。在基于 Debian 的系统中,默认的日志文件位置是 /var/log/apache2/access.log;其他系统可能会使用 httpd 目录来替代 apache2。
log 文件已经包含在代码包中,所以你可以直接下载并使用它。
使用 tail 命令,我们可以显示 log 文件的结尾。虽然说实话,使用 cat 命令也能很好地完成这项任务,因为这个文件只有几行:
$ tail /var/log/apache2/access.log
命令的输出和文件内容如下所示:

输出内容确实会稍微换行,但我们仍然能感受到日志的布局。我们还可以看到,尽管我们感觉只访问了一个网页,但实际上我们访问了两个项目:index.html 和 ubuntu-logo.png。我们也未能成功访问 favicon.ico 文件。可以看到,文件是以空格分隔的。每个字段的含义如下表所示:
| 字段 | 用途 |
|---|---|
| 1 | 客户端 IP 地址。 |
| 2 | 按 RFC 1413 和 identd 客户端定义的客户端身份。如果未启用 IdentityCheck,则不会读取此字段。如果未读取,则该值将是一个连字符。 |
| 3 | 用户认证的用户 ID(如果启用)。如果未启用认证,值将为连字符。 |
| 4 | 请求的日期和时间,格式为日/月/年:时:分:秒 偏移量。 |
| 5 | 实际请求和方法。 |
| 6 | 返回状态代码,如200或404。 |
| 7 | 文件大小(以字节为单位)。 |
尽管这些字段由 Apache 定义,但我们仍然需要小心。时间、日期和时区是一个字段,并在方括号内定义;但是,在该数据和时区之间还有额外的空格。为了确保在需要时打印完整的时间字段,我们需要打印$4和$5。以下命令示范了这一点:
$ awk ' { print $4,$5 } ' /var/log/apache2/access.log
我们可以在以下截图中查看命令及其产生的输出:

显示来自 Web 日志的数据
我们已经预览了如何使用 AWK 查看 Apache web 服务器的日志文件;然而,接下来我们将转到我们的演示文件,该文件包含更多和更为多样的内容。
按日期选择条目
既然我们已经了解了如何显示日期,或许我们应该看看如何仅打印一天的条目。为此,我们可以在awk中使用匹配操作符。这由波浪线(~)表示,或者你也可以叫它波浪线。如果我们只需要日期元素,就没有必要同时使用日期和时区字段。以下命令展示了如何打印 2014 年 9 月 10 日的条目:
$ awk ' ( $4 ~ /10\/Sep\/2014/ ) ' access.log
为了完整性,以下截图展示了该命令和部分输出:

圆括号包围了我们正在查找的行范围,我们省略了主块,这样可以确保我们打印范围内完全匹配的行。没有任何限制阻止我们进一步筛选并从匹配的行中打印字段。例如,如果我们只想打印出用于访问 Web 服务器的客户端 IP 地址,我们可以打印字段1。以下命令示范了这一点:
$ awk ' ( $4 ~ /10\/Sep\/2014/ ) { print $1 } ' access.log
如果我们想能够打印特定日期的总访问次数,可以将条目通过管道传输到wc命令。这在下面进行了演示:
$ awk ' ( $4 ~ /10\/Sep\/2014/ ) { print $1 } ' access.log | wc -l
然而,如果我们希望使用awk来执行此任务,这将比启动一个新进程更高效,并且我们可以统计条目。如果我们使用内置变量NR,我们可以打印文件中的整行,而不仅仅是范围内的行。最好在主块中增加我们自己的变量,而不是为每一行匹配范围。可以在END块中实现打印我们使用的count变量。以下命令行作为示例:
$ awk ' ( $4 ~ /10\/Sep\/2014/ ) { print $1; COUNT++ } END { print COUNT }' access.log
wc和内部计数器的计数输出将给我们演示文件的结果16205。如果我们只想计数而不做其他任何操作,我们应该在主块中使用变量递增:
$ awk ' ( $4 ~ /10\/Sep\/2014/ ) { COUNT++ } END { print COUNT }' access.log
我们可以在以下输出中看到这一点:

汇总404错误
请求页面的状态码显示在日志的字段9中。404状态码表示服务器上的页面未找到错误。我相信我们在某个阶段都在浏览器中见过这种情况。这可能表示你的网站中某个链接配置错误,或者浏览器正在寻找一个图标图片以便在标签浏览器中显示该页面。你还可以通过查找请求标准页面来识别可能对你的网站构成威胁的行为,这些页面可能会提供有关 PHP 驱动网站(如 WordPress)的额外信息。
首先,我们可以仅仅打印出请求的状态:
$ awk '{ print $9 } ' access.log
现在我们可以稍微扩展一下代码以及我们自己,只打印出404错误:
$ awk ' ( $9 ~ /404/ ) { print $9 } ' access.log
我们可以进一步扩展,通过打印出状态码和正在访问的页面。这需要我们打印字段9和字段7。简而言之,这将如下所示:
$ awk ' ( $9 ~ /404/ ) { print $9, $7 } ' access.log
这些失败访问的页面会有重复的情况。为了汇总这些记录,我们可以使用命令管道结合sort和uniq命令来实现:
$ awk ' ( $9 ~ /404/ ) { print $9, $7 } ' access.log | sort -u
使用uniq命令时,数据必须先进行排序;因此,我们使用sort命令准备数据。
汇总 HTTP 访问码
现在是时候离开纯命令行,开始处理 AWK 控制文件了。正如我们所知,当所需结果集的复杂性增加时,awk代码的复杂性也随之增加。我们将在当前目录下创建一个status.awk文件。该文件应与以下文件相似:
{ record[$9]++ }
END {
for (r in record)
print r, " has occurred ", record[r], " times." }
首先,我们将简化主代码块,这非常简单且简洁。这是一种简单的方法,用来计算每个状态码的唯一出现次数。我们不使用简单的变量,而是将其传入一个数组。在这种情况下,数组叫做记录。数组是一个多值变量,数组中的槽位称为键。因此,我们将会有一组存储在数组中的变量。例如,我们期望看到record[200]和record[404]的条目。我们将每个键填充上它们的出现次数。每当我们找到一个404代码时,我们就增加存储在关联键中的计数:
{ record[$9]++ }
在END块中,我们使用for循环创建汇总信息,打印出数组中的每个键值对:
END {
for (r in record)
print r, " has occurred ", record[r], " times." }
要运行此命令,相关的命令行将类似于以下内容:
$ awk -f status.awk access.log
为了查看命令和输出,我们包含了以下截图:

我们可以进一步关注 404 错误。当然,你可以选择任何状态码。从结果中我们看到 4382 404 状态码。为了总结这些 404 代码,我们将把 status.awk 复制到一个名为 404.awk 的新文件中。我们可以编辑 404.awk,添加一个 if 语句,使其只处理 404 代码。该文件应该类似于以下代码:
{ if ( $9 == "404" )
record[$9,$7]++ }
END {
for (r in record)
print r, " has occurred ", record[r], " times." }
如果我们使用以下命令执行代码:
$ awk -f 404.awk access.log
输出将类似于以下截图:

资源请求
你可以使用 AWK 检查特定页面或资源的请求次数:
$ awk '{print $7}' access.log | sort | uniq -c | sort -rn
上面的命令将按请求资源的次数从高到低排序:

这些资源可能是图片、文本文件或 CSS 文件。
如果你想查看请求的 PHP 文件,可以使用 grep 只获取 PHP 文件:
$ awk ' ($7 ~ /php/) {print $7}' access.log | sort | uniq -c | sort -nr

每个页面旁边都有访问次数。
你可以从 log 文件中获取任何统计数据,提取唯一值并以相同的方式排序。
识别图片热链接
说到资源时,你可能会遇到一个问题,那就是图片热链接。它指的是通过链接到其他服务器上的图片来使用你的图片。这种图片热链接的行为可能会泄露你的带宽。
既然我们在谈论 AWK,接下来我们将看到如何使用 AWK 查找它是如何使用我们的图片的:
$ awk -F\" '($2 ~ /\.(png|jpg|gif)/ && $4 !~ /^https:\/\/www\.yourdomain\.com/){print $4}' access.log | sort | uniq -c | sort
请注意,如果你使用的是 Apache,可以通过一个小的 .htaccess 文件防止图片热链接,方法是检查引荐来源是否不是你的域名:
RewriteEngine on
RewriteCond %{HTTP_REFERER} !^$
RewriteCond %{HTTP_REFERER} !^https://(www\.)yourdomain.com/.*$ [NC]
RewriteRule \.(gif|jpg|jpeg|bmp|png)$ - [F]
显示最高排名的 IP 地址
你现在应该了解一些 awk 的强大功能,以及它本身庞大的语言结构。我们从 30,000 行的文件中提取的数据确实很强大,且容易提取。我们只需将之前使用的字段替换为 $1。这个字段表示客户端 IP 地址。如果我们使用以下代码,我们将能够打印出每个 IP 地址以及它访问 web 服务器的次数:
{ ip[$1]++ }
END {
for (i in ip)
print i, " has accessed the server ", ip[i], " times." }
我们希望能够扩展这个,显示出最高排名的 IP 地址,也就是访问站点最多的地址。工作主要还是在 END 块中,并将与当前最高排名地址进行比较。以下文件可以创建并保存为 ip.awk:
{ ip[$1]++ }
END {
for (i in ip)
if ( max < ip[i] ) {
max = ip[i]
maxnumber = i }
print i, " has accessed ", ip[i], " times." }
我们可以在以下截图中看到命令的输出。部分客户端 IP 地址被遮挡,因为它来自我的公共 web 服务器:

代码的功能来自 END 块。在进入 END 块时,我们运行一个 for 循环。我们遍历 ip 数组中的每个条目。我们使用条件语句 if 来查看当前迭代的值是否大于当前最大值。如果是,那么它就成为新的最大条目。当 loop 循环结束时,我们打印出具有最高值的 IP 地址。
显示浏览器数据
用于访问网站的浏览器信息包含在日志文件的字段 12 中。展示用于访问网站的浏览器列表可能会很有意思。以下代码将帮助你展示按浏览器报告的访问列表:
{ browser[$12]++ }
END {
for ( b in browser )
print b, " has accessed ", browser[b], " times."
}
你可以看到我们如何创建小插件来与这些文件配合使用 awk,并调整字段和数组名称以适应需求。输出如下截图所示:

有趣的是,我们看到 Mozilla 4 和 5 占据了大多数请求客户端。我们看到 Mozilla 4 在这里出现了 1713 次。这里的 Mozilla/5.0 条目由于额外的双引号而格式错误。它稍后以 27,000 次访问出现。
处理邮件日志
我们曾处理过来自 Apache HTTP 网络服务器的日志。事实上,我们可以将相同的理念和方法应用于任何日志文件。我们将查看 Postfix 邮件日志。邮件日志包含了来自 SMTP 服务器的所有活动,然后我们就能看到谁向谁发送了邮件。该日志文件通常位于 /var/log/mail.log。我将在我的 Ubuntu 15.10 服务器上访问这个日志文件,该服务器配置了本地邮件传送。这意味着 SMTP 服务器只监听 127.0.0.1 的本地接口。
日志格式会根据消息类型略有变化。例如,$7 会包含外发消息的 from 日志,而接收消息则会包含 to。
如果我们想列出所有发送到 SMTP 服务器的消息,可以使用以下命令:
$ awk ' ( $7 ~ /^to/ ) ' /var/log/mail.log
由于字符串 to 非常简短,我们可以通过确保字段以 to 开头来为其添加标识,使用 ^ 来实现。命令和输出如下截图所示:

扩展对 to 或 from 的搜索以包含用户名将变得非常简单。我们可以看到邮件的发送或接收格式。使用与 Apache 日志相同的模板,我们可以轻松显示出最高的接收者或发送者。
总结
现在我们已经为文本处理提供了一些强大的工具,可以开始理解 AWK 的强大功能。使用真实数据对于评估我们搜索的性能和准确性特别有用。我们从新安装的 Ubuntu 15.10 Apache 网络服务器上开始使用简单的 Apache 条目,很快就迁移到了来自实际 Web 服务器的更大样本数据。这个包含 30,000 行的数据文件为我们提供了真正的内容,短短时间内,我们就能够生成可信的报告。然后我们返回到 Ubuntu 15.10 服务器,分析 Postfix SMTP 日志。我们可以看到,我们可以非常轻松地将之前使用的技术应用到新的日志文件中。
接下来,我们继续使用 AWK,并了解如何报告 lastlog 数据和扁平 XML 文件。
问题
-
access_log文件中的哪个字段包含 IP 地址? -
用于统计 AWK 处理的行数的命令是什么?
-
如何从 Apache 访问日志文件中获取独立访客的 IP 地址?
-
如何从 Apache 访问日志文件中获取访问量最多的 PHP 页面?
进一步阅读
请参阅以下内容,进一步阅读本章相关的内容:
第十三章:使用 AWK 改进 lastlog
我们已经在第十二章《使用 AWK 汇总日志》中看到了如何从大量纯文本文件中挖掘的数据创建复杂报告。类似地,我们可以使用标准命令行工具的输出创建广泛的报告,比如lastlog工具。lastlog本身可以报告所有用户的最后登录时间。然而,我们通常可能希望过滤lastlog的输出。也许你需要排除那些从未用于登录系统的用户账户。报告root账户也可能不相关,因为该账户可能主要用于sudo,并没有用于常规登录时记录数据。
在本章中,我们将使用lastlog并格式化 XML 数据。由于这是我们研究 AWK 的最后一章,我们将配置记录分隔符。我们已经看过 AWK 中字段分隔符的使用,但我们可以将默认的记录分隔符从换行符更改为更符合我们需求的内容。本章将具体介绍:
-
使用 AWK 范围来排除数据
-
基于字段数量的条件
-
操控 AWK 记录分隔符来报告 XML 数据
技术要求
本章的源代码可以在这里下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter13
使用 AWK 范围来排除数据
到目前为止,在本书中,我们主要关注的是如何在sed或awk中使用范围来包含数据。对于这两个工具,我们可以否定范围,从而排除指定的行。为了更好地解释这一点,我们将使用lastlog命令的输出。它将打印所有用户的登录数据,包括那些从未登录的账户。这些从未登录的账户可能是服务账户,或者是那些至今没有登录系统的新用户。
lastlog 命令
如果我们查看lastlog的输出,在没有任何选项的情况下使用时,我们可以开始理解这个问题。从命令行执行该命令时,作为标准用户执行即可。没有要求必须以 root 账户运行。命令示例如下:
$ lastlog
以下截图显示了部分输出:

即使从这个有限的输出中,我们也能看到由于未登录的账户产生的虚拟噪声,我们得到了杂乱的输出。可以使用 lastlog 的选项在某种程度上缓解这个问题,但它可能无法完全解决。为了演示这一点,我们可以为 lastlog 添加一个选项,仅显示标准用户,并过滤掉其他系统和服务用户。这在你的系统上可能有所不同,但在我使用的 CentOS 6 示例主机上,第一个用户的 UID 为 500。在 CentOS 7 中,标准用户的 UID 从 1000 开始。
如果我们使用 lastlog -u 500-5000 命令,我们将只打印 UID 在此范围内的用户数据。在这个简单的演示系统中,我们只有三个用户账户,因此输出是可以接受的。然而,我们也可以理解,由于这些尚未使用的账户,可能仍然会有一些杂乱。这在以下截图中得到了展示:

除了从 Never logged in 账户打印出来的冗余数据外,我们可能只对 Username 和 Latest 字段感兴趣。这也是支持使用 AWK 作为数据过滤工具的另一个原因。这样,我们可以提供水平和垂直的数据过滤,过滤行和列。
使用 AWK 进行水平行过滤
为了使用 AWK 进行过滤,我们将直接通过管道将 lastlog 的数据传递给 awk。我们将使用一个简单的控制文件,最初提供水平过滤或减少我们看到的行数。首先,命令管道将像下面的命令示例一样简单:
$ lastlog | awk -f lastlog.awk
当然,复杂性已经从命令行中抽象出来,并隐藏在我们使用的控制文件中。最初,控制文件保持简单,内容如下:
!(/Never logged in/ || /^Username/ || /^root/) {
print $0;
}
范围设置如我们之前所见,位于主代码块之前。在括号前加上感叹号会否定或反转所选范围。双竖线作为逻辑 OR。我们不包括包含 Never logged in 的行,也不包括以 Username 开头的行。这会去掉 lastlog 打印出的表头行。最后,我们排除了 root 账户的显示。这启动了我们处理的行,主代码块将打印出这些行。
计算匹配的行数
我们可能还希望计算过滤器返回的行数。例如,使用内部的 NR 变量将显示所有行,而不仅仅是匹配的行;为了能够报告已登录的用户数量,我们必须使用自己的变量。以下代码将保持计数在我们命名为 cnt 的变量中。每次主代码块迭代时,我们使用 C 风格的 ++ 来递增它。
我们可以使用 END 代码块来显示这个变量的最终值:
!(/Never logged in/ || /^Username/ || /^root/) {
cnt++
print $0;
}
END {
print "========================"
print "Total Number of Users Processed: ", cnt
}
从以下代码和输出中,我们可以看到它在我的系统上是如何表现的:

从显示输出中,我们现在可以看到,我们只显示已登录的用户,而在这种情况下,只有单个用户。然而,我们也可以决定进一步抽象数据,并仅显示匹配行中的某些字段。这应该是一个简单的任务,但事实并非如此,因为登录执行的字段数量将不同。
基于字段数量的条件
如果用户直接登录到服务器的物理控制台而不是通过远程或图形伪终端登录,则 lastlog 输出将不显示主机字段。为了演示这一点,我已经直接登录到我的 CentOS 主机上的 tty1 控制台,并避免使用图形界面。来自先前 AWK 控制文件的输出显示,我们现在有用户 tux 和 bob;但是 bob 缺少主机字段,因为他连接到控制台:

尽管本身没有问题,但如果我们想要过滤字段,并且两行的字段编号将因一些行中省略了字段而有所不同,则会有问题。对于 lastlog,对于大多数连接,我们将有 9 个字段,而对于直接连接到服务器控制台的连接,只有 8 个字段。应用程序的目标是打印用户名和日期,但不打印最后登录时间。我们还将在 BEGIN 块中打印我们自己的标题。为了确保我们使用正确的位置,我们将需要使用 NF 内部变量来计算每行的字段数。
对于具有 8 个字段的行,我们希望打印字段 1、4、5 和 8;对于具有额外主机信息的更长行,我们将使用字段 1、5、6 和 9。我们还将使用 printf 以便能够正确对齐列数据。控制文件应如下例所示进行编辑:
BEGIN {
printf "%8s %11s\n","Username","Login date"
print "===================="
}
!(/Never logged in/ || /^Username/ || /^root/) {
cnt++
if ( NF == 8 )
printf "%8s %2s %3s %4s\n", $1,$5,$4,$8
else
printf "%8s %2s %3s %4s\n", $1,$6,$5,$9
}
END {
print "===================="
print "Total Number of Users Processed: ", cnt
}
我们可以在下面的屏幕截图中看到命令及其产生的输出。我们可以看到如何根据我们想要关注的信息创建一个更合适的显示:

如果我们查看输出,我选择显示日期在月份之前,以便不按数字顺序显示字段。当然,这是个人选择,并可根据您希望显示数据的方式进行定制。
我们可以利用我们在 lastlog 控制文件中看到的原理来处理来自任何命令的输出,并且您应该使用您希望从中过滤数据的命令进行实践。
操纵 AWK 记录分隔符以报告 XML 数据
到目前为止,虽然我们一直在使用 AWK 处理单独的行,每一行都代表一个新记录。尽管这通常是我们想要的情况,但在处理标记数据(例如 XML,其中一个单独记录可能跨越多行)时,我们可能需要设置 RS 或记录分隔符内部变量。
Apache 虚拟主机
在第九章,自动化 Apache 虚拟主机,我们使用了Apache 虚拟主机。这使用了标记数据,定义了每个虚拟主机的开始和结束。即使我们更倾向于将每个虚拟主机存储在自己的文件中,它们也可以合并到一个文件中。考虑以下存储可能的虚拟主机定义的文件;它可以存储为virtualhost.conf文件,如下所示:
<VirtualHost *:80>
DocumentRoot /www/example
ServerName www.example.org
# Other directives here
</VirtualHost>
<VirtualHost *:80>
DocumentRoot /www/theurbanpenguin
ServerName www.theurbanpenguin.com
# Other directives here
</VirtualHost>
<VirtualHost *:80>
DocumentRoot /www/packt
ServerName www.packtpub.com
# Other directives here
</VirtualHost>
我们将三个虚拟主机存储在一个文件中。每个记录由一个空行分隔,这意味着我们有两个换行符,它们在逻辑上分隔了每个条目。我们可以通过将RS变量设置为以下方式来向 AWK 解释这一点:RS="\n\n"。设置好后,我们可以打印所需的虚拟主机记录。这将被设置在控制文件的BEGIN代码块中。
我们还需要动态地在命令行中搜索所需的主机配置。我们将其构建到控制文件中。控制文件应类似于以下代码:
BEGIN { RS="\n\n" ; }
$0 ~ search { print }
BEGIN块设置了变量,然后我们进入了范围部分。这个范围设置使得记录($0)与search变量匹配(~)。我们必须在执行awk时设置该变量。以下命令演示了命令行执行,其中控制文件和配置文件位于我们的工作目录中:
$ awk -f vh.awk search=packt virtualhost.conf
我们可以通过查看下面截图中的命令和输出结果,清楚地看到这一点:

XML 目录
我们还可以将这个概念扩展到 XML 文件中,在这些文件中,我们可能不想显示完整的记录,而只是某些特定的字段。考虑以下产品catalog:
<products>
<product>
<name>drill</name>
<price>99</price>
<stock>5</stock>
</product>
<product>
<name>hammer</name>
<price>10</price>
<stock>50</stock>
</product>
<product>
<name>screwdriver</name>
<price>5</price>
<stock>51</stock>
</product>
<product>
<name>table saw</name>
<price>1099.99</price>
<stock>5</stock>
</product>
</products>
从逻辑上讲,每个记录像之前一样以空行分隔。然而,每个字段的细节更加详细,我们需要使用以下分隔符:FS="[><]"。我们将开括号或闭括号定义为字段分隔符。
为了帮助分析,我们可以按照如下方式打印一个单独的记录:
<product><name>top</name><price>9</price><stock>5</stock></product>
每个尖括号是一个字段分隔符,这意味着我们将有一些空字段。我们可以将这一行重写为 CSV 文件:
,product,,name,top,/name,,price,9,/price,,stock,5,/stock,,/product,
我们只需将每个尖括号替换为逗号;这样更容易被我们读取。我们可以看到字段5的内容是top值。
当然,我们不会编辑 XML 文件,我们会保持其 XML 格式。这里的转换只是为了突出显示字段分隔符是如何读取的。
用于从 XML 文件中提取数据的控制文件示例如下:
BEGIN { FS="[><]"; RS="\n\n" ; OFS=""; }
$0 ~ search { print $4 ": " $5, $8 ": " $9, $12 ": " $13 }
在BEGIN代码块中,我们设置了FS和RS变量,正如我们之前所讨论的。我们还将输出字段分隔符(OFS)设置为空格。通过这种方式,当我们打印字段时,会用空格分隔各个值,而不是保留尖括号。范围使用了我们在查看虚拟主机时所使用的相同匹配条件。
如果我们需要在catalog中查找产品钻孔,可以使用以下命令:
$ awk -f catalog.awk search=drill catalog.xml
以下截图详细显示了输出:

我们现在已经能够处理一个相当杂乱的 XML 文件,并从目录中生成可读的报告。AWK 的强大功能再次得到体现,对于我们来说,这是在本书中的最后一次使用。到现在为止,我希望你也能开始定期使用它。
总结
我们有三章使用了 AWK,首先是一些基本用法的介绍,在第十章中,AWK 基础知识,我们熟悉了 AWK。接着,在第十二章中,使用 AWK 汇总日志,以及本章,我们开始构建定制应用程序。
具体来说,在本章中我们展示了如何从标准命令的输出(如lastlog)中创建报告。我们看到可以否定范围,并额外使用OR语句。然后我们构建了一个应用程序,使我们能够查询 XML 数据。
在接下来的两章中,我们将不再使用 Shell 脚本,而是转向使用 Perl 和 Python 脚本,以便比较这些脚本语言并做出合适的选择。
问题
-
我们如何获取那些从未登录过系统的用户?
-
根据前一个问题,如何计算从未登录的用户数量?
-
以下命令将打印多少行?
进一步阅读
请参阅以下内容,进一步阅读与本章相关的资料:
第十四章:使用 Python 作为 Bash 脚本的替代方案
在上一章中,我们看到了一些使用 AWK 的实际示例,并且我们了解了如何处理 lastlog 输出,以生成更好的报告。在本章中,我们将看看 Bash 的另一种脚本替代方案,我们将讨论 Python。Python 是另一种脚本语言,也是我们迄今为止研究的最新语言。与 Bash 相似,Python 是一种解释型语言,并且使用了 shebang。尽管它没有 shell 接口,但我们可以通过一个叫做 REPL 的控制台来访问, 在其中我们可以输入 Python 代码与系统进行交互。在本章中,我们将讨论以下内容:
-
什么是 Python?
-
以 Python 的方式打招呼——Hello World
-
Pythonic 参数
-
显著的空格缩进
-
读取用户输入
-
字符串处理
技术要求
本章的源代码可以在这里下载:
github.com/PacktPublishing/Mastering-Linux-Shell-Scripting-Second-Edition/tree/master/Chapter14
什么是 Python?
Python 是一种面向对象的解释型语言,旨在易于使用并促进快速应用开发。这一目标通过语言中简化的语法来实现。
Python 由荷兰开发者 Guido van Rossum 于 1989 年 12 月底创建。该语言的大多数设计旨在追求清晰和简洁,其中 Zen of Python 的一条主要规则是:
应该有一种,最好只有一种,明显的方式来做这件事。
系统通常同时安装 Python 2 和 Python 3,但所有新的发行版都在切换到 Python 3。我们将使用 Python 3。
由于我们使用的是 Linux Mint,它已经预装了 Python 3。
如果你使用的是其他 Linux 发行版,或者由于某种原因找不到 Python 3,你可以通过以下方式进行安装:
- 在基于 RedHat 的发行版上:
$ sudo yum install python36
- 在基于 Debian 的发行版上:
$ sudo apt-get install python3.6
尽管没有 shell,我们可以使用 REPL 来与 Python 交互——读取、评估、打印和循环。我们可以通过在命令行中输入 python3 或在使用 CentOS 7 时输入 python36 来访问它。你应该会看到类似下面的截图:

我们可以看到,系统为我们提供了 >>> 提示符,这就是 REPL 控制台。我们需要强调的是,这是一种脚本语言,像 Bash 和 Perl 一样,我们通常通过我们创建的文本文件来执行代码。这些文本文件通常会以 .py 作为后缀。
在使用 REPL 时,我们可以通过导入模块独立打印版本。在 Perl 中,我们使用关键字;在 Bash 中,我们使用命令 source;在 Python 中,我们使用 import:
>>>import sys
加载模块后,我们现在可以通过打印版本来探讨 Python 的面向对象特性:
>>> sys.version
我们将导航到命名空间中的 sys 对象,并从该对象调用版本方法。
将这两个命令结合起来,我们应该能看到以下输出:

在结束这部分关于 Python 的描述时,我们应该看看 Python 的 Zen。在 REPL 中,我们可以输入 import this,如以下截图所示:

这远不止是 Python 的 Zen,它实际上是所有编程语言的一个好规则,也是开发者的指南。
最后,要关闭 REPL,我们将在 Linux 中按 Ctrl + D,或在 Windows 中按 Ctrl + Z。
以 Python 方式输出 Hello World
我们在 Python 中编写的代码应该清晰简洁:简洁胜过冗长。我们需要在第一行添加 shebang,然后是 print 语句。print 函数会自动添加换行符,且我们不需要在行尾加分号。我们可以在以下示例中看到编辑后的 $HOME/bin/hello.py 版本:
#!/usr/bin/python3
print("Hello World")
我们仍然需要添加执行权限,但可以像之前一样使用 chmod 来运行代码。以下命令展示了这一点,尽管我们现在应该有些习惯了:
$ chmod u+x $HOME/bin/hello.py
最后,我们可以执行代码来查看我们的问候语。
同样,你可以通过命令行使用 Python 解释器运行文件,如下所示:
$ python3 $HOME/bin/hello.py
或者在某些 Linux 发行版中,你可以这样运行:
$ python36 $HOME/bin/hello.py
再次提醒,至少掌握一门语言能更容易适应其他语言,这一点没有太多新意。
Pythonic 参数
我们现在应该知道我们需要向 Python 传递命令行参数,可以通过 argv 数组来实现。不过,我们更像是 bash;在 Python 中,我们将程序名和其他参数一起放入数组中。
Python 还使用小写而不是大写来表示对象名称:
-
argv数组是sys对象的一部分 -
sys.argv[0]是脚本名 -
sys.argv[1]是传递给脚本的第一个参数 -
sys.argv[2]是第二个提供的参数,依此类推 -
参数计数将始终至少为 1,因此,在检查提供的参数时请记住这一点
提供参数
如果我们创建 $HOME/bin/args.py 文件,我们可以看到这个操作的效果。文件应该按以下方式创建,并设置为可执行:
#!/usr/bin/python3
import sys
print("Hello " + sys.argv[1])
如果我们运行带有提供参数的脚本,应该能看到类似以下截图的内容:

我们的代码仍然相当简洁清晰;不过,你可能已经注意到,我们无法将 print 语句中的引号文本与参数结合起来。我们使用 + 符号来连接或拼接两个字符串。由于没有专门的符号表示变量或其他类型的对象,因此它们不能作为静态文本出现在引号内。
计数参数
如前所述,脚本名称是数组中索引0的第一个参数。因此,如果我们尝试计算参数数量,计数应该至少为 1。换句话说,如果我们没有提供参数,参数数量将为 1。要计算数组中的项数,我们可以使用len()函数。
如果我们编辑脚本并加入一行新代码,我们将看到它的效果,如下所示:
#!/usr/bin/python3
import sys
print("Hello " + sys.argv[1])
print( "length is: " + str(len(sys.argv)) )
按照之前的方式执行代码,我们可以看到我们提供了两个参数——脚本名称和字符串Mokhtar:

如果我们尝试使用一个print语句来打印输出和参数的数量,将会产生错误,因为我们不能将整数与字符串连接。长度值是一个整数,它不能与字符串混合,除非进行转换。因此,我们使用了str函数将整数转换为字符串。以下代码会失败:
#!/usr/bin/python3
import sys
print("Hello " + sys.argv[1] + " " + len(sys.argv))

如果我们尝试运行脚本并省略了传递参数,那么当我们引用索引1时,数组中将会有一个空值。这将导致错误,如下图所示:

当然,我们需要处理这个问题以防止错误;这就是重要空白符的概念。
重要的空白符
Python 与大多数其他语言的一个主要区别是额外的空白符可能具有意义。代码的缩进级别定义了它属于的代码块。到目前为止,我们创建的代码没有缩进超出行的开始位置。这意味着所有代码都在相同的缩进级别,并且属于同一个代码块。与使用大括号或 do 和 done 关键字来定义代码块不同,Python 使用缩进来表示代码块。如果我们使用两个或四个空格,甚至是制表符缩进,那么必须始终保持一致。当我们返回到上一个缩进级别时,我们就回到之前的代码块。
这看起来很复杂,但实际上它非常简单,可以保持代码的简洁和整洁。如果我们编辑arg.py文件以防止不必要的错误,当没有提供参数时,我们可以看到它的实际效果:
#!/usr/bin/python3
import sys
count = len(sys.argv)
if ( count > 1 ):
print("Arguments supplied: " + str(count))
print("Hello " + sys.argv[1])
print("Exiting " + sys.argv[0])
if语句检查参数数量是否大于1。现在为了方便,我们将参数数量存储到一个名为count的变量中。代码块以冒号开始,接着所有后续缩进四个空格的代码都是在条件成立时会执行的部分。
当我们返回到上一级缩进时,我们回到主代码块,并且无论条件的状态如何,都会执行代码。
我们可以在下面的截图中看到这一点,在其中我们可以通过有无参数来执行脚本:

读取用户输入
如果我们希望欢迎消息根据我们的名字来问候我们,无论我们是否向脚本提供参数,我们都可以在脚本运行时添加一个提示来捕获数据。Python 可以简单而轻松地实现这一点。从下面的编辑文件中,我们可以看到如何实现这一点:

我们在脚本中使用了一个新变量,最初在主块中设置为空字符串。我们在这里设置它是为了使该变量对整个脚本和所有代码块都可用:

Python 3 中的 input 函数(或 Python 2 中的 raw_input)用于获取用户输入。我们将输入存储在 name 变量中。如果我们提供了一个参数,在代码的 else 块中我们将使用它,并将 name 变量设置为第一个提供的参数。这个在主块中的 print 语句中使用。
使用 Python 写入文件
为了增加本章的变化,我们现在将看看如何将这些数据打印到文件中。再次使用 Python,这是一个相当简单和容易的方式。我们将首先复制我们现有的 args.py。我们将其复制到 $HOME/bin/file.py。新的 file.py 应该类似于以下屏幕截图,并设置执行权限:

您会注意到我们刚刚改变了最后几行,现在不再使用打印而是打开一个文件。我们还可以看到 Python 更多的面向对象的特性,其中它动态地为对象 log 分配了 write() 和 close() 方法,因为它被视为文件的实例。当我们打开文件时,我们将其用于追加,这意味着如果文件已经存在内容,我们不会覆盖它。如果文件不存在,我们将创建一个新文件。如果使用 w,我们将打开文件进行写入,这可能导致覆盖现有内容,所以请小心。
您可以看到这是一个简单的任务;这就是为什么 Python 在许多应用程序中被广泛使用并且在学校广泛教授的原因。
字符串操作
处理 Python 中的字符串非常简单:您可以轻松地进行搜索、替换、更改字符大小写和执行其他操作:
要搜索字符串,您可以像这样使用 find 方法:
#!/usr/bin/python3
str = "Welcome to Python scripting world"
print(str.find("scripting"))

Python 中的字符串计数也是从零开始的,因此单词 scripting 的位置在 18 处。
您可以使用方括号获取特定子字符串,像这样:
#!/usr/bin/python3
str = "Welcome to Python scripting world"
print(str[:2]) # Get the first 2 letters (zero based)
print(str[2:]) # Start from the second letter
print(str[3:5]) # from the third to fifth letter
print(str[-1]) # -1 means the last letter if you don't know the length

要替换字符串,您可以像这样使用 replace 方法:
#!/usr/bin/python3
str = "Welcome to Python scripting world"
str2 = str.replace("Python", "Shell")
print(str2)

要更改字符大小写,您可以使用 upper() 和 lower() 函数:

如您所见,使用 Python 处理字符串非常简单。作为替代脚本语言,Python 是一个非常棒的选择。
Python 的强大之处在于其可用的库。实际上,几乎所有你能想到的都有成千上万的库。
总结
这标志着我们对 Python 的学习的结束,虽然这确实是一次简短的旅程。我们再次强调,你在学习多种语言时会看到的许多相似之处,以及学习任何编程语言的重要性。你在一种语言中学到的知识,将帮助你理解大多数你遇到的其他语言。
从 Python 的 Zen 中我们学到的内容将帮助我们设计和开发出色的代码。我们可以使用以下 Python 代码打印 Python 的 Zen:
>>>import this
我们可以在 REPL 提示符下输入代码。保持代码简洁且良好排版将有助于提高可读性,并最终帮助代码维护。
我们也看到,Python 喜欢你在代码中显式地指定,且不会隐式地转换数据类型。
最后,我们学习了如何使用 Python 操作字符串。
我们也到了本书的结尾,但希望这只是你编写脚本生涯的开始。祝你好运,谢谢阅读。
问题
- 以下代码将打印多少个字符?
#!/usr/bin/python3
str = "Testing Python.."
print(str[8:])
- 以下代码将打印多少个单词?
#!/usr/bin/python3
print( len(sys.argv) )
Solution: Nothing
- 以下代码将打印多少个单词?
#!/usr/bin/python3
import sys
print("Hello " + sys.argv[-1])
进一步阅读
请参阅以下内容以获取与本章相关的进一步阅读资料:
第十五章:评估
第一章
- 错误出在第二行:变量声明中不应该有空格。
#!/bin/bash
var1="Welcome to bash scripting ..."
echo $var1
-
结果将是
Tuesday,因为数组是从零开始的。 -
这里有两个错误:第一个错误是变量声明中的空格,第二个错误是使用了单引号,而应该使用反引号。
解决方案:
#!/bin/bash files='ls -la' echo $files
- 变量
b的值将是c,而变量c的值将是a。
由于我们在赋值行中没有使用美元符号,变量将会取字符值而不是整数值。
第二章
- 三
这是因为整个 bash shebang 主要是注释,所以有三行注释。
- 选项
-b和它的值之间没有空格,因此它将被视为一个选项。
-a
-b50
-c
- 1
四
这是因为我们传递了五个参数,并且使用了 shift 来丢弃一个参数。
- 2
-n
这是因为它在左侧,而shift命令会从左侧移除参数。
第三章
False
由于小写字符的 ASCII 顺序较高,因此该语句会返回假。
-
两者都是正确的,并且会返回相同的结果,即
字符串不相等。 -
三
我们可以使用以下内容:
-
-ge:大于或等于 -
-gt:大于 -
-ne:不等于
- 真
由于一个测试足以返回真值,因此我们可以确定第二个测试也会返回真值。
第四章
- 我们可以进行以下更改:
"Hello message": {
"prefix": "hello",
"body": [
"echo 'Hello ${1|first,second,third|}' "
],
"description": "Hello message"
}
source命令。
第五章
- 使用
((:
#!/bin/bash
num=$(( 25 - 8 ))
echo $num
- 问题出在文件名中的空格。要修复它,请将文件名用引号括起来:
$ rm "my file"
- 在括号前面没有美元符号:
#!/bin/bash
a=$(( 8 + 4 ))
echo $a
第六章
-
没有行。因为循环输出被重定向到文件中,所以屏幕上不会显示任何内容。
-
四。循环将从
8开始,直到达到12,它会匹配大于或等于的条件,并会中断循环。 -
问题出在
for循环定义中的逗号,应该改为分号。因此,正确的脚本如下:
#!/bin/bash
for (( v=1; v <= 10; v++ ))
do
echo "value is $v"
done
- 由于递减语句在循环外部,计数变量将保持相同的值,即
10。这是一个无限循环,它将永远打印10,要停止它,你需要按Ctrl+C。
第七章
-
由于我们使用了
$1变量,而不是$@,该函数将只返回第一个元素。 -
50。是的,它是一个全局变量,但由于我们在函数调用之前打印了它的值,变量不会受到影响。 -
缺少括号
()或在函数名之前没有添加关键字 function。应该这样写:
clean_file() {
is_file "$1"
BEFORE=$(wc -l "$1")
echo "The file $1 starts with $BEFORE"
sed -i.bak '/^\s*#/d;/^$/d' "$1"
AFTER=$(wc -l "$1")
echo "The file $1 is now $AFTER"
}
- 问题出在函数调用中。我们在函数调用时不应该使用括号
(),括号应该仅用于函数定义中。正确的代码应该是这样的:
#!/bin/bash
myfunc() {
arr=$@
echo "The array from inside the function: ${arr[*]}"
}
test_arr=(1 2 3)
echo "The origianl array is: ${test_arr[*]}"
myfunc ${test_arr[*]}
第八章
-
无。因为你在搜索大写的 Sed,而它是不存在的。
-
无。删除命令
d仅删除流中的行,而不是文件中的行。要删除文件中的行,可以使用-i选项。 -
第四行。因为我们使用了追加命令 a,它将在指定位置之后插入。
-
没有,因为
w标志仅与替换命令s一起使用。
第九章
- 你可以使用以下命令打印第 50 行:
$ sed -n '50 p ' /etc/httpd/conf/httpd.conf
- 你可以使用以下命令将 Apache 的默认端口
80更改为8080:
$ sed -i '0,/Listen [0-9]*/s//Listen 8080/' /etc/httpd/conf/httpd.conf
我们搜索Listen,它定义了 Apache 的默认端口,查找旁边的数字并将其更改为Listen 8080。
第十章
- 什么都没有
你应该使用变量名而不是美元符号来打印它。
- 解决方案:零
因为你应该打印$1而不是$2,其中$1是第一个字段。
-
while循环应该以i值小于4而不是3进行迭代。 -
1
因为唯一的用户 UID 小于1的是 root(UID=0),所以将打印一行。
第十一章
- 0 行
因为awesome后面有一个句点,如果你想打印那一行,可以使用以下命令:
$ awk '/awesome\.$/{print $0}' myfile
- 2 行
因为我们搜索包含scripting单词的行,并且该单词后面跟着句点和任意文本,这个模式只出现在两行中,因为第三行的scripting后面没有句点。
- 3 行
因为我们使用了问号,这意味着字符类不是模式匹配的必要条件。
- 什么都没有
因为我们使用了管道符,它是一个 ERE 字符,并且我们使用了 sed,所以我们必须使用-r选项来启用 sed 的扩展引擎。
第十二章
-
字段 1
-
你可以使用
print NR,或者将输出通过管道传递给wc -l
我们必须使用-l,否则它将计算单词数。
$ awk '{print $1}' access.log | sort | uniq -c
$ awk '{print $7}' access.log | grep 'php' | sort | uniq -c | sort -nr | head -n 1
你应该使用 head -n 1来只获取一页。
第十三章
- 使用
lastlog命令
$ lastlog | awk ' /Never logged/ { print $1}'
- 使用
wc命令
$ lastlog | awk ' /Never logged/ { print $1}' | wc -l
- 零,因为该行以两个星号结尾。
第十四章
-
8
-
由于我们正在使用
sys模块,我们应该先导入它。
所以正确的代码应该是这样的:
#!/usr/bin/python3
import sys
print( len(sys.argv))
- 2


浙公网安备 33010602011771号