第三十六章:奇异

在我们旅程的最后一章,我们将看看一些零碎的东西。虽然我们在前面的章节中已经介绍了很多内容,但还有许多bash功能我们还没有介绍。大多数都相当模糊,主要对那些将bash集成到Linux发行版中的人有用。然而,有一些虽然不常用,但对某些编程问题有帮助。我们将在这里介绍它们。

第三十六章:奇异组命令和subshells进程替换使用 eval 构建命令Wordle助手陷阱临时文件异步执行wait 命名管道设置一个命名管道使用命名管道总结

组命令和subshells

bash允许将命令组合在一起。这可以通过两种方式之一完成,要么使用组命令(group command),要么使用子shell(subshell)。我们在【第6章.重定向】中介绍了组命令,正如我们所记得的,组命令使用以下语法:

{ command1; command2; [command3; ...] }

子shell使用类似的语法:

(command1; command2; [command3;...])

组命令用大括号括住其命令,子shell使用小括号。值得注意的是,由于bash实现组命令的方式,大括号必须用空格与命令隔开最后一个命令必须在结束大括号之前用分号或换行符终止

那么,组命令和子shell有什么好处呢?虽然它们有一个重要的区别(我们稍后会讲到),但它们都可以用来管理重定向。让我们考虑一个对多个命令执行重定向的脚本段。

这很简单。三个命令的输出被重定向到一个名为 output.txt 的文件。使用组命令,我们可以编写如下代码:

使用subshell也是类似的:

使用这种技术,我们节省了一些打字时间,但组命令或子shell真正闪耀的地方是管道。在构建命令管道时,将多个命令的结果合并到一个流中通常很有用。组命令和子shell使这变得容易。

在这里,我们组合了三个命令的输出,并将它们通过管道传输到 lpr 的输入中,以生成打印报告。

虽然组命令和子shell看起来很相似,都可以用于组合流进行重定向,但两者之间存在重要区别。组命令在当前shell中执行其所有命令,而子shell(顾名思义)在当前shell的子副本中执行其命令。这意味着环境(包括shell函数、别名和变量等)被复制并提供给shell的新实例。这个环境副本与子进程的正常工作方式不同,因为子shell继承了父进程环境的整个副本,而子进程只继承了父shell的导出变量。当子shell退出时,其环境副本将丢失,因此对子shell环境所做的任何更改(包括变量赋值)也将丢失。

最重要的是,子shell,像子进程一样,与组命令不同,不能改变父shell的环境。让我们演示一下;首先使用组命令:

接下来,我们将对子shell执行相同的步骤:

正如我们所看到的,组命令能够修改当前shell中 foo 的值,而subshell则不能。当我们想要更改目录时,子shell的这一特性非常方便。当我们在子shell中执行 cd 命令时,当前工作目录在子shell的持续时间内会发生变化,但在返回父shell后,当前工作文件夹保持不变。

然而,一般来说,除非脚本需要子shell,否则组命令比子shell更可取。组命令速度更快,所需内存更少。

在下面的脚本中,我们将使用组命令,并查看可以与关联数组结合使用的几种编程技术。这个名为 array-2 的脚本在给定目录名称时,会打印目录中文件的列表以及文件所有者和组所有者的名称。在列表末尾,脚本会打印出属于每个所有者和组的文件数量。在这里,我们看到了当脚本被指定到 /usr/bin 目录时的结果(为简洁起见而压缩):

以下是脚本的列表(带行号):

让我们来看看这个脚本的机制:

进程替换

我们在【第28章.读取键盘输入】中看到了子shell环境问题的一个例子,当时我们发现管道中的读取命令并不像我们直观预期的那样工作。总结一下,如果我们构建一个这样的管道:

REPLY 变量的内容始终为空,因为 read 命令是在子shell中执行的,当子shell终止时,其 REPLY 副本将被销毁。

因为管道中的命令总是在子shell中执行,所以任何分配变量的命令都会遇到这个问题。幸运的是,shell提供了一种称为过程替换(process substiution)的奇特扩展形式,可用于解决这个问题。

进程替换以两种方式表示。

对于产生标准输出的流程,它看起来像这样:

<(list)

或者,对于接收标准输入的流程,它看起来像这样:

>(list)

其中 list 是命令列表。

为了解决 read 的问题,我们可以使用如下的进程替换:

进程替换允许我们将子shell的输出视为普通文件进行重定向。事实上,由于它是一种扩张形式,我们可以检验它的真正价值。

通过使用 echo 查看扩展的结果,我们看到子shell的输出是由一个名为 /dev/fd/63 的文件提供的。

进程替换通常用于包含 read 的循环。下面是一个 read 循环的示例,它处理子shell创建的目录列表的内容:

该循环对目录列表的每一行执行 read 。列表本身在脚本的最后一行生成。此行将流程替换的输出重定向到循环的标准输入。tail 命令包含在进程替换管道中,以消除列表中不需要的第一行。

执行时,脚本会产生如下输出:

使用 eval 构建命令

eval 内置是一个奇怪而神秘的命令。简单地说,它需要一个参数列表,将它们组合成一个字符串,并将其传递给shell执行。所以问题自然变成了这是为了什么?我们为什么要用这个?

在shell脚本中,有些情况下我们需要在运行时动态构造命令, eval 允许我们这样做。让我们做一个实验。虽然我们没有明确地介绍它,但可以将命令放入字符串变量中,然后依靠参数展开将变量展开为命令:

现在让我们看看当我们在命令中包含变量时会发生什么:

这符合我们的预期,但让我们看看当我们为 str 赋值时会发生什么。

结果没有改变。这是有道理的,因为我们在分配 cmd 时已经对 $str 执行了参数扩展。但是,如果我们想稍后扩展变量呢?我们可以将命令括在单引号中,以抑制初始变量赋值过程中的参数扩展。

虽然我们在分配 cmd 时没有得到参数扩展,但在执行 cmd 时也没有得到。这样做的原因是,即使扩展($cmd)导致可以进一步扩展的内容($str),shell也只执行一次扩展。

如果我们使用 eval ,我们会得到额外的扩展级别。

这向我们展示了 eval 的功能。它将参数连接到一个字符串中,对字符串的内容执行波浪号、参数和路径名扩展,然后将字符串传递给当前shell(不创建子shell)执行。

小心 eval eval 命令名声不好。这源于对使用 eval 向shell脚本添加安全漏洞有多容易的担忧。如果提供给 eval 的字符串来自外部源(即用户输入),则必须注意确保字符串中没有未经授权的命令(称为代码注入攻击)。始终验证给定 eval 的字符串的内容。

Wordle助手

在下面的脚本中,我们将使用 eval 来帮助找到单词谜题的可能答案。

对于那些不熟悉这款流行游戏的人来说,维基百科是这样描述的:

Wordle is a web-based word game created and developed by Welsh software engineer Josh Wardle. Players have six attempts to guess a five-letter word, with feedback given for each guess in the form of colored tiles indicating when letters match or occupy the correct position.

Wordle是一款基于网络的文字游戏,由威尔士软件工程师Josh Wardle创建和开发。玩家有六次尝试猜测一个五个字母的单词,每次猜测都会以彩色瓷砖的形式给出反馈,指示字母何时匹配或占据正确的位置。

基本上,当我们猜测时,游戏会告诉我们猜测中的任何字母是否存在于秘密单词中,以及猜测中的字母是否与秘密单词中的位置匹配。因此,每次我们猜测时,我们都会了解更多关于这个秘密单词的信息,直到我们(希望)能够弄清楚它的身份。游戏的目标是以最少的尝试次数猜测单词。

辅助脚本输出一个单词列表,与到目前为止的猜测相匹配。为了做到这一点,给脚本一个简单的正则表达式,告诉脚本哪些字母是已知的,以及它们在秘密单词中的位置。此外,我们还提供了一个已知包含在秘密单词中的字母列表,以及已知不出现在秘密单词里的字母列表。

假设秘密单词是“about”,我们猜测是“bloat”。Wordle会告诉我们“o”和“t”出现在单词中各自的位置,并且单词中还包含未知位置的“a”和“b”,此外,秘密单词不包含“l”。在这种情况下,我们将以这种方式调用脚本:

第一个参数是一个五个字符的正则表达式,其中“o”和“t”位于各自的位置。接下来是秘密单词中存在的已知字符和已知不是秘密单词的字母。脚本响应如下:

表明词典中只有两个单词符合选择标准。

该脚本的工作原理是根据选择标准过滤字典(/usr/share/dict/words),然后显示剩下的内容。为此,我们需要构建一个长的多部分管道,其长度取决于命令行上提供的位置参数的数量。通过这种方式,我们不需要临时文件来保存中间结果。

让我们复习一下脚本的有趣之处。

陷阱

在【第10章.进程】中,我们看到了程序如何对信号做出响应。我们也可以将此功能添加到我们的脚本中。虽然我们到目前为止编写的脚本不需要这种功能(因为它们的执行时间很短,也不创建临时文件),但更大、更复杂的脚本可能会受益于信号处理例程。

当我们设计一个庞大而复杂的脚本时,重要的是要考虑如果用户在脚本运行时注销或关闭计算机会发生什么。当发生此类事件时,将向所有受影响的进程发送信号。反过来,代表这些进程的程序可以执行操作,以确保程序的正确有序终止。例如,我们编写了一个脚本,在执行过程中创建了一个临时文件。为了良好的设计,我们会让脚本在完成工作后删除文件。如果收到指示程序将提前终止的信号,让脚本删除文件也是明智之举。

bash为此提供了一种机制,称为 trap (陷阱)。陷阱是通过名为 trap 的内置命令来实现的。 trap 使用以下语法:

trap argument signal [signal...]

其中 argument 是一个将被读取并视为命令的字符串,signal 是触发执行解释命令的信号的规范。

这里有一个简单的例子:

此脚本定义了一个陷阱,该陷阱将在脚本运行时每次收到 SIGINTSIGTERM 信号时执行 echo 命令。当用户试图按 Ctrl-c 停止脚本时,程序的执行看起来像这样:

正如我们所看到的,每次用户试图中断程序时,都会打印消息。

构造一个字符串来形成一个有用的命令序列可能会很尴尬,因此通常的做法是指定一个shell函数作为命令。在这个例子中,为每个要处理的信号指定了一个单独的shell函数:

此脚本具有两个 trap 命令,每个信号一个。反过来,每个陷阱都指定了一个在接收到特定信号时要执行的shell函数。请注意,每个信号处理功能中都包含 exit 命令。如果没有 exit ,脚本将在完成函数后继续。

当用户在执行此脚本期间按 Ctrl-c 时,结果如下:

除了所有程序都可以使用的信号外, trap 内置还支持几个内部和bash特定的信号。特别感兴趣的是 EXITERR 。正如人们所料,当脚本终止时, EXIT 陷阱会被激活。每当命令(除某些例外)以非零退出状态退出时, ERR 陷阱就会激活。这就像我们在【第30章】中看到的 set -e 选项的一个更有用的版本。下面我们有一个简短的 EXITERR 陷阱演示脚本。请注意第5行的故意错误:

此脚本定义陷阱,然后尝试显示一行文本,但 echo 命令拼写错误。脚本继续下一行,等待用户输入。最后,它显示用户输入的任何内容。

当我们运行此脚本时,我们得到以下内容:

我们得到的第一件事是shell中关于我们拼写错误的命令的错误消息。接下来是来自 ERR 陷阱的消息,显示它已被激活。接下来会出现 read 提示,如果我们按 ENTER 键,将输出一个空行,EXIT 陷阱消息将宣布程序已结束。

临时文件

脚本中包含信号处理程序的一个原因是删除脚本可能创建的临时文件(用于在执行过程中保存中间结果)。命名临时文件是一门艺术。传统上,类Unix系统上的程序在 /tmp 目录中创建临时文件,这是一个用于此类文件的共享目录。然而,由于目录是共享的,这带来了一定的安全问题,特别是对于以超级用户权限运行的程序。除了为暴露给系统所有用户的文件设置适当权限的明显步骤外,为临时文件赋予不可预测的文件名也很重要。这避免了被称为临时竞争攻击(temp race attack)的漏洞。创建不可预测(但仍然具有描述性)名称的一种方法是这样做:

tempfile=/tmp/$(basename $0).$$.$RANDOM

这将创建一个文件名,由程序名称、进程ID(PID)和随机整数组成。但是请注意, $RANDOM shell变量只返回1-32767范围内的值,从计算机的角度来看,这不是一个很大的范围,因此该变量的单个实例不足以克服确定的攻击者。

更好的方法是使用 mktemp 程序(不要与 mktemp 标准库函数混淆)来命名和创建临时文件。 mktemp 程序接受模板作为用于构建文件名的参数。模板应包含一系列“X”字符,这些字符由相应数量的随机字母和数字替换。“X”字符序列越长,随机字符序列就越长。以下是一个示例: tempfile=$(mktemp /tmp/foobar.$.XXXXXXXXXXX)

这将创建一个临时文件,并将其名称分配给变量 tempfile 。模板中的“X”字符被随机字母和数字替换,因此最终文件名(在本例中,还包括用于获取PID的特殊参数$$的扩展值)可能如下:

/tmp/foobar.6593.UOZuvM6654

对于由普通用户执行的脚本,明智的做法是避免使用 /tmp 目录,并在用户的主目录中为临时文件创建一个目录,使用如下代码行:

[[ -d $HOME/tmp ]] || mkdir $HOME/tmp

异步执行

有时需要同时执行多个任务。我们已经看到,所有现代操作系统即使不是多用户操作系统,也至少是多任务操作系统。脚本可以被构造为以多任务方式运行。

通常,这涉及启动一个脚本,然后在父脚本继续运行的同时,启动一个或多个子脚本来执行额外的任务。然而,当一系列脚本以这种方式运行时,可能会出现保持父级和子级协调的问题。也就是说,如果父或子依赖于另一个脚本,并且一个脚本必须等待另一个完成任务才能完成自己的任务,该怎么办?

bash有一个内置命令来帮助管理这样的异步执行。wait 命令使父脚本暂停,直到指定的进程(即子脚本)完成。

wait

我们将首先演示 wait 命令。为此,我们需要两个脚本。

首先是父脚本:

第二个是子脚本:

在这个例子中,我们看到子脚本很简单。真正的动作是由家长执行的。在父脚本中,子脚本被启动并置于后台。通过为pid变量分配 $! shell参数的值来记录子脚本的进程ID,该参数将始终包含放入后台的最后一个作业的进程ID。

父脚本继续执行,然后使用子进程的PID执行等待命令。这会导致父脚本暂停,直到子脚本退出,此时父脚本结束。

执行时,父脚本和子脚本会产生以下输出:

命名管道

在大多数类Unix系统中,可以创建一种称为 named pipe (命名管道)的特殊类型的文件。命名管道用于在两个进程之间创建连接,可以像其他类型的文件一样使用。它们不太受欢迎,但很好了解。

有一种称为 client-server(CS,客户端-服务器)的通用编程架构,它可以使用命名管道等通信方法,以及网络连接等其他类型的进程间通信(interprocess communication)。

当然,最广泛使用的客户端-服务器系统类型是与web服务器通信的web浏览器。web浏览器充当客户端,向服务器发出请求,服务器用网页响应浏览器。【即所谓BS,browser-server】

命名管道的行为类似于文件,但实际上形成先进先出(FIFO)缓冲区。与普通(未命名)管道一样,数据从一端进入,从另一端出来。使用命名管道,可以设置如下内容:

process1 > named_pipe

和:

process2 < named_pipe

它的行为如下:

process1 | process2

设置一个命名管道

首先,我们必须创建一个命名管道。这是使用 mkfifo 命令完成的。

在这里,我们使用 mkfifo 创建一个名为 pipe1 的命名管道。使用 ls ,我们检查文件,发现 attributes 字段中的第一个字母是“p”,表示它是一个命名管道。

使用命名管道

为了演示命名管道的工作原理,我们需要两个终端窗口(或者两个虚拟控制台)。在第一个终端中,我们输入一个简单的命令,并将其输出重定向到指定的管道。

按下 Enter 键后,命令将显示为挂起。这是因为还没有任何东西从管道的另一端接收数据。当这种情况发生时,据说管道堵塞了。一旦我们将一个进程附加到另一端,并且它开始从管道读取输入,这个条件就会清除。使用第二个终端窗口,我们输入以下命令:

从第一个终端窗口生成的目录列表作为 cat 命令的输出出现在第二个终端中。一旦第一个终端中的 ls 命令不再被阻止,它就会成功完成。

总结

好了,我们的旅程到此结束。现在唯一要做的就是练习,练习,练习。虽然我们在徒步旅行中走了很多路,但还有更多值得探索的地方。有成千上万的命令行程序等待被发现和享受。开始在 /usr/bin 中挖掘,看看!

对于仍然渴望更多内容的读者,请查看我的后续书籍《Linux命令行冒险》(Adventures with the Linux Command Line),该书可在LinuxCommand.org上免费下载。