Linux 命令行脚本编程技巧(三)

原文:annas-archive.org/md5/eb3c60aab42b7a5286616e4543d3f6f6

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章: Shell 脚本简介

我们已经来到了定义 Unix(或 Linux)知名特性之一的部分——它的脚本编写。当谈到所谓的Unix 哲学时,不仅能使用命令行提供的工具,而且还能创建自己的工具,这是一个令人惊叹的能力,利用那些做一件事做得非常好的 Shell 工具。

脚本编写正是如此——能够创建简单(或复杂)的工具,本质上是执行特定任务的一组命令。在一切开始之前,我们需要澄清一件事——有些人将编程与脚本区分开。严格来说,所有脚本编写都是编程,但并非所有编程都是脚本编写。我们谈论的是遵循相同前提、逻辑和思维方式的学科,但同时,两者之间也有着重大区别。说到脚本编写,实际上我们是在创建文件,这些文件在运行时会被解释,这意味着 Shell(或其他解释器)会逐行读取文件并执行命令。还有另一种方式,那就是创建文本文件,在运行前进行编译。通常,这种方式比解释执行更快,但同时需要一些额外的步骤,并且不如脚本灵活。

我们不会浪费时间讨论与编译应用程序相关的内容;本书将严格处理脚本。

本章将介绍以下内容:

  • 编写你的第一个 Bash Shell 脚本

  • 序列化基本命令 – 从简单到复杂

  • 操作 Shell 脚本的输入、输出和错误

  • Shell 脚本的基本规范

技术要求

本章的内容将在 Linux 机器上进行演示。我们使用与其他章节相同的设置:

  • 安装了 Linux 的虚拟机,任意发行版(在我们的案例中,将使用Ubuntu 20.04

  • Bash – 每个主流发行版的默认 Shell

本章及所有涉及脚本的章节中的脚本,应该能在任何使用 Bash 的发行版上运行。脚本的强大之处就在于这种兼容性;如果机器运行 Linux,几乎可以运行任何脚本,唯一的问题来自脚本本身对服务器的要求。

编写你的第一个 Bash Shell 脚本

在我们编写一个简单的Hello World! Shell 脚本之前,先快速了解一下 Shell 本身以及它在普通 Linux 机器上的作用。最简单的描述方式是,Shell 是用户(我们)与内核(操作系统中负责一切的部分)之间的连接。我们之前已经讨论过这个问题,但在这里我们需要澄清一些要点,以便更容易解释某些概念。

Shell 是一个应用程序,通常显示一个提示符,并查找并运行我们给它的任何命令。这称为交互式 shell,是在 Linux 中使用最广泛的工作方式。这就是所有命令行界面CLI)的内容 - 拥有一个界面,使我们能够执行我们需要的命令:

https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_9.1_B16269.jpg

图 9.1 – 一个典型的 root shell

然而,shell 的另一种操作模式称为非交互模式。这涵盖了 shell 在不根据我们从命令行输入的命令行为基础,而是逐行读取文件(我们的脚本)并执行命令时的所有实例。显然,我们无法直接与命令进行交互,因此该模式恰当地称为非交互式。

请记住,在执行脚本时,如果需要(并且计划),我们可以与其交互;名称仅指与 Shell 的直接交互或无 CLI 可用。同时,这种交互限制意味着我们可以在任何需要的时候,尽快地看到我们的脚本运行。结合 Linux 系统中我们随时可以使用的各种工具,我们拥有一个极其强大的功能,可以帮助我们完成任务。

准备就绪

让我们快速运行几个命令,了解一下我们当前的 shell:

demo@ubuntu:~$ ps -p $$
    PID TTY          TIME CMD
   5329 pts/0    00:00:00 bash
demo@ubuntu:~$ echo $SHELL
/bin/bash

这里发生了什么?我们使用的第一个命令是 ps,它为我们提供了当前正在运行的 shell 的信息,或者更精确地说,负责我们发出的命令当前执行的 shell。使用 $$ 作为进程号,我们要求 ps 命令提供我们当前 shell 分配的进程号。我们在这里进行了一个小技巧 - $$ 是 Bash 的内部变量,它给我们提供了一个进程的运行 PID。

我们使用的另一个命令是 echo,并使用了其 $SHELL 变量,该变量会自动解析为用户当前的 shell。

但是,这两个命令之间存在很大的区别。根据具体情况,它们可以给我们完全不同的结果,因为它们指的是完全不同的事物。让我们来解释一下 - 每个用户都有他们分配的 shell,在用户登录时将被执行。echo 命令的结果将会给你这个信息,而 shell 本身则定义在 /etc/passwd 文件中,描述特定用户的那一行。因此,该命令的输出基本上会提供你的默认 shell 名称。

同时,每个用户可以作为命令运行系统上任何可用的 shell,并且自动将该 shell 作为他们的当前 shell。这意味着这个 shell 将处理用户在命令行中输入的任何内容。这很重要,因为你的脚本可以使用与你应该使用的不同 shell 来自命令行运行,基于 /etc/passwd 文件中的信息。

你的 shell 不一定非得是 bash。你也可以选择系统中可用的任何 shell,或者甚至可以安装当前系统中不可用但作为软件包提供的 shell。

有鉴于此,谈到脚本编写时,即便你使用的是其他 shell,bash 仍然是首选的 shell,因为 bash 能在大多数甚至所有 Linux 机器上运行。

现在,让我们来聊一聊用于脚本编写的编辑器。

本书中涉及脚本编写的章节,我们将使用 vimvi;然而,脚本示例将以文本形式显示,而不带有任何颜色。我们已经在另一个章节中讲解了很多编辑器。由于文本编辑器的话题往往引发较大争议,而我们对此较为务实,我们的建议是使用对你有用的编辑器。

Vim、JOE、nano、vi、Emacs、gedit、Sublime Text、Atom、Notepadqq、Visual Studio Code 等都是可用的编辑器,但选择哪个完全取决于你。对于简单的脚本,任何编辑器都能工作,通常你会选择系统上已有的编辑器,只因为你需要对脚本做一个小改动。

当你在自己的机器上开发脚本时,可能会选择更复杂的工具,因为它能让工作变得更轻松。Vim 就是一个很好的例子,因为它为 bash 提供了语法高亮和格式化功能。高级编辑器会为你提供更多功能,但我们的观点是你不应该过度依赖那些花里胡哨的功能,因为这会让你依赖某个应用,而这个应用可能并不总是能使用:

https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_9.2_B16269.jpg

图 9.2 – 在 Vim 中打开的脚本 – 注意颜色高亮和缩进

最终,你将会使用两个编辑器,一个用于开发你的脚本,另一个用于你部署脚本的服务器。记住,你在部署脚本的系统上不可避免地需要进行调试,因此要做好准备,某些你平常使用的工具可能无法使用。不要过于依赖它们。

如何实现……

让我们创建第一个脚本,看看到底是什么让我们如此关注 shell:

#!/bin/bash
# Hello world script, V1.0 2021/8/1
echo "Hello World!

首先,脚本到底做了什么?它仅仅是将 Hello World! 输出到标准输出并退出。虽然脚本本身有三行,但其中只有一行是实际执行操作的,另外两行则有不同的用途。

我们要做的是首先解析前两行的含义,然后关注 echo 命令。

请注意,我们在脚本中不计算空行,尽管编辑器可能会计算。对于脚本,空行对解释器来说没有意义,因此我们使用空行使脚本更具可读性,但在讨论脚本时会忽略它们——Bash 在执行脚本时也是这样做的。

作为一个规则,如果我们在脚本中使用#字符,解释器将把该字符之后的内容视为注释(在同一行)。我们的前两行是注释,但脚本的第一行是特殊的——它定义了执行脚本命令的 shell,同时也是一行注释。这个序列被称为shebang。我们需要对此进行解释。

在 Linux 下,脚本编写并不局限于使用bash或任何其他 Bash 兼容的 shell。在你的脚本中,实际上可以使用任何你想要的脚本语言。除了 Bash,它还可以是 Python 或 Perl——你可以使用系统上可用的任何语言,只要你知道如何编写该语言的脚本。

通常,脚本是由解释器执行的。解释器基本上是一个能够理解文件内容的应用程序,然后逐行执行文件中的命令。我们提到的所有解释器(Python、Bash 和 Perl)都使用简单的文本文件作为输入,因此需要一种方法来告诉系统该文件中是什么类型的脚本,从而让系统知道如何执行它。

这可以通过两种不同的方式来实现——一种方式是通过使用正确的解释器直接调用脚本,例如以下方式:

demo@ubuntu:~/scripting$ bash helloworld.sh 
Hello World!

这只是确保我们为脚本使用了正确的解释器;它不会让系统或其他用户更容易理解我们的脚本。

现在,考虑另一种做法。让我们使脚本可执行,然后直接运行它:

demo@ubuntu:~/scripting$ chmod u+x helloworld.sh 
demo@ubuntu:~/scripting$ ./helloworld.sh 
Hello World!

这两者之间的区别微妙但重要,尽管最终结果是相同的,因为我们运行的是相同的脚本。

在第一个示例中,我们明确告诉系统使用特定的解释器来运行脚本。在第二个示例中,我们告诉系统使用它需要的解释器来运行脚本,这时脚本的第一行发挥了至关重要的作用。当前的 shell 会拿到第一行(shebang),并尝试找到该行指向的解释器。如果找到了,系统将使用这个解释器来运行文件中的其余内容。最终结果很简单——如果我们遵循约定并将解释器放在第一行的注释中,系统就能运行我们的脚本,即使我们没有明确提到它。

如果第一行是解释器的名称以外的内容,我们的脚本只有在明确使用解释器名称调用时才能运行——如果我们直接运行它,系统将抛出错误。

它是如何工作的……

Linux 不使用扩展名来标识文件,因此脚本可以有任何符合文件系统规定的名称;扩展名不一定是.sh。这意味着,为了让我们的脚本能够普遍运行,我们需要考虑第一行的正确格式。

脚本的下一行是我们的注释,它标识了脚本的名称和版本。当然,任何脚本都可以没有这样的注释,或者一般来说,没有任何注释,但在脚本编写中,注释非常重要。我们稍后在本章中会更加关注注释。

第三行实际上是在工作的那一行,它简单地将分配给脚本的字符串显示到标准输出。标准输入、输出和错误处理是我们稍后也会稍微涉及的内容。

这是我们的第一个脚本。在这一章的这部分中,我们解释了很多内容,重点是除了执行脚本任务的实际命令之外的一切,但我们必须处理很多其他事情。

还有更多内容…

在接下来的几章中,我们将会大量处理脚本,但我们有链接可以让您开始:

序列化基本命令 - 从简单到复杂

脚本只不过是按特定顺序执行的命令列表。在其最基本的结构中,顺序完全是线性的,没有任何决策、循环或条件分支。

命令按照从上到下,从行首到行尾的顺序执行。即使听起来很简单并且不是很有用,但通过这种方式创建脚本也可以有其用处,因为它使我们能够快速运行一组预定义的命令,而不是从命令行重复输入它们。换句话说,有些问题需要超过一行命令,但不复杂到需要复杂的逻辑。这并不是贬低复杂的 Bash 脚本逻辑,因为 IT 中有许多自动化任务可以通过使用 Bash 脚本来实现。

让我们现在想象一个简单的任务,比如我们将用作经常性示例的任务。我们将创建一个简单的备份脚本。我们的任务如下:

  1. /opt/backup下创建一个以今天日期命名的目录。

  2. 将所有文件从/root文件夹复制到此目录。

  3. root用户发送一封空邮件,只说备份已完成。

  4. /root文件夹中名为donebackups.lst的文件添加一行,其中包含今天的日期。

准备就绪

在我们开始之前——免责声明。这是一个简单的脚本,由于多种原因,它的意义并不大。最重要的一点是,它忽略了运行环境的上下文。我们需要先快速讨论这些问题,然后我们会编写一个能解决这个任务的脚本。

我们所说的上下文是什么意思?无论我们选择以何种方式运行脚本,脚本都是由用户在所谓的用户空间中运行的,它们有一些定义其环境的因素,我们通常称之为上下文。

上下文是运行脚本的整个环境,并且提出了以下问题:

  • 哪个用户在运行脚本?

  • 这个脚本有什么权限?

  • 脚本是作为工具从命令行运行,还是作为后台任务运行?

  • 脚本是从哪个目录运行的?

除了这些,通常还有一些其他可能对运行脚本相关的因素,我们将在本书的后续部分讨论这些问题。

现在,我们需要明确的是,上下文极其重要,我们的脚本绝不应当以任何形式或方式理所当然地假设任何元素。如果我们希望脚本正确运行,我们应该不做任何假设,而应该检查所有我们期望处于某种状态的内容。

一个能够检查并判断运行它的环境中可能出现的问题的脚本,要求具备我们尚未讨论的两个方面——控制脚本流程和与系统交互。现在,很明显我们仍然不知道如何做到这一点。

无法在脚本中测试某些内容意味着,在创建这个特定脚本时,我们将假设很多事情。要小心——这通常是导致问题的第一个原因。

如果在我们输入第一个字母之前没有仔细思考,通常是所有问题的根源;脚本很少是如此简单,能够在没有提前规划的情况下创建。

我们现在讨论这些问题的主要原因是希望在创建脚本时让你保持正确的思维方式。

如何做到这一点……

那么,如何创建脚本呢?在开始之前,你应该做以下几件事:

  • 定义你的任务。

  • 研究你将要使用的命令。

  • 检查权限以及成功执行各个命令所需的条件。

  • 在将命令用于脚本之前,先单独尝试这些命令。

想想你假设的一些事情:

  • 如果你正在读写某些文件,你是否期望这些文件已经存在,还是你需要创建它们?

  • 如果你正在引用某个文件或目录,它是否存在,并且你是否拥有正确的权限?

  • 你是否使用了一些需要提前安装或配置的命令?

  • 你是通过绝对路径还是相对路径引用文件?

这只是通常被称为健全性检查的冰山一角,在这种情况下,健全性指的是脚本运行时的状态。健全状态是指一切正常。当脚本偏离这一状态,或者出现错误或问题导致脚本行为异常时,那就是问题所在。这就是为什么我们需要提前思考的原因。也正因如此,进行健全性检查的代码可能比仅仅执行基础功能的常规代码更费力。

但是相信我们——这种类型的检查不仅有助于保持环境的正常运行,还能帮助你在处理复杂任务时保持理智。

现在,我们勇敢地忽略了所有这些,专注于基础知识。对于我们的备份脚本,我们假设/root/opt目录存在,并且对运行脚本的任何用户都是可访问的。在我们这个特定的案例中,这意味着只有超级用户运行脚本时,脚本才会有效,因为该用户需要能够访问/root下的文件。

此外,我们假设某种类型的电子邮件系统存在,并且在本地计算机上运行。我们还假设在最后一步提到的日志文件可以被我们的脚本写入。

重要说明

在运行脚本时,你会做出很多这样的假设,如果任何一个假设不正确,脚本就会以某种方式失败。作为脚本创建者,你的主要任务就是防止这种情况发生。

我们将使用什么命令?

我们的第一项任务是创建一个名称中包含今天日期的目录。在这里,我们假设这个目录不存在,并且无论如何都要创建它。这与我们稍后使用的逻辑有显著偏差——像这样的命令通常会检查目录是否存在,且命令本身是否成功。如果任何条件不true,脚本应该优雅地失败,或者创建它所需要的目录。

在你说,等等——我已经会这样做了;我知道如何在一个命令行中进行测试之前,让我们回过头来再谈一谈脚本是如何运行的。

在此时,我们尝试创建一些没有控制脚本流程的逻辑,这样命令就能逐行运行。我们本可以尝试在每行中进行一些检查,但由于我们没有控制整个脚本的流程,这可能比根本不做检查还要危险。

解释器会运行所有命令,无论如何,即使我们检查是否存在问题并发现了它们,我们最终还是会执行脚本中的所有命令。如果出现问题,正确的做法是控制脚本的行为,而不是控制单个命令的行为。不管脚本任务是多么简单或复杂,你都应该始终考虑上述背景以及你的脚本对它的影响。如果出现故障,脚本需要决定——这个故障是可以处理的吗?还是需要中止整个脚本的执行?

如果你在脚本中途中止执行,是否有需要在脚本结束之前做的事情?通常,当某些事情迫使你中止任务时,你会有某种方式通知系统和用户发生了问题。有时候,这可能还不够——你的脚本需要自行清理。

这可能仅仅是删除一些文件的问题,也可能是需要恢复你对系统所做的某个更改,甚至是恢复数百个更改。每次失败都应该评估它的严重性,以及它如何影响系统和脚本在该系统上创建的状态。

它是如何工作的……

在创建了可能是世界上最大的免责声明,解释我们为什么脚本如此简单之后,咱们开始动手工作吧。

一步步来,我们该如何解决这个问题呢?

创建目录很简单;我们将避免使用 Bash shell 扩展,而是使用date系统命令。这里我们有点作弊,因为我们引用了系统环境,但这个任务没有它就根本无法完成,我们也没有依赖 Bash 本身的内置功能。请注意,这也展示了在脚本中通常有多种方法可以完成同一件事,唯一的区别是你的创造力。

第一个命令大概是这样的:

root@ubuntu:/home/demo/# mkdir /opt/backup/backup$ (date \
+%m%d%Y)

请注意,我们正在以root用户身份运行这个操作。我们来快速检查一下发生了什么:

root@ubuntu:/home/demo/scripting# ls /opt/backup/
backup08202021

我们可以看到我们的目录已经创建好了。现在,让我们处理复制操作:

root@ubuntu:/home/demo/scripting# ls /opt/backup/backup08202021/
root@ubuntu:/home/demo/scripting# touch /root/testfile
root@ubuntu:/home/demo/scripting# cp /root/* /opt/backup/backup'date +%m%d%Y'
cp: -r not specified; omitting directory '/root/snap'
root@ubuntu:/home/demo/scripting# ls /root
snap  testfile
root@ubuntu:/home/demo/scripting# ls /opt/backup/backup08202021/
testfile

我们成功创建了一个测试文件并将其复制到我们的目录。请注意,我们引用目标目录的方式——由于我们不知道什么时候脚本会运行,我们无法知道当前需要复制到哪个目录。

为了避免需要读取和解析目录的麻烦,我们简单地重新创建目录名称,就像我们创建目录时一样。这里可能会有一个错误——如果在某种奇怪的情况下,脚本恰好在午夜时运行,那么创建目录的部分可能会在午夜之前执行,而我们用来复制文件的部分则可能会在午夜之后执行。这会导致错误,因为名称将不匹配。发生这种情况的几率很小,我们也不会为此做计划。

在一个大型脚本中,如果不正确处理这类问题,将会导致严重的问题。

现在,让我们处理邮件功能:

root@ubuntu:/home/demo# mail -s "Backup done!" root@localhost
Command 'mail' not found, but can be installed with:
apt install mailutils
root@ubuntu:/home/demo/scripting# apt install mailutils
Reading package lists... Done
Building dependency tree……………………………       

这里的错误很重要。我们这样做是为了展示测试命令的重要性。在这种情况下,我们尝试发送邮件,这让我们意识到我们期望使用的命令并不是默认安装的。

要运行此脚本,实际上我们需要安装mail命令。在配置了邮件服务的服务器上,这个命令会存在,但在普通工作站上则不会。由于我们的备份脚本应该在任何服务器上工作,我们需要解决这个问题。

盲目地使用包管理器安装软件包通常是安全的;系统将安装该软件包或者如果已安装则更新它。

现在,我们将再次尝试该命令,但这次又将失败:

root@ubuntu:/# mail -s "Backup was done!" root@localhost
Cc: 
Null message body; hope that's ok
root@ubuntu:/# man mail
You have mail in /var/mail/root
root@ubuntu:/# mail -s "Backup was done!" root@localhost < /dev/null
mail: Null message body; hope that's ok

我们实际上没有失败,但当我们调用第一个命令时,它要求我们输入一些数据。它要求我们输入Cc地址,并且我们必须按下Ctrl + D来完成邮件正文。

这也是在脚本中使用命令之前进行测试的另一个理由。

在意识到我们需要做一些事情使这个命令无需人工干预运行并阅读手册后,我们发现只需将/dev/null重定向到命令中即可。

现在,对于我们需要执行的最后一个命令,我们实施备份的实际报告:

root@ubuntu:/home/demo# date +%m%d%Y >> /root/donebackups.lst

记住,我们需要向文件追加内容。另外,我们希望直接使用绝对路径引用文件;毕竟,在运行此脚本时,我们不知道会在哪里。

好的,我们已经尝试和测试了所有的命令。我们的脚本实际上是什么样子?并不复杂:

#!/bin/bash
mkdir /opt/backup/backup'date +%m%d%Y'
cp /root/* /opt/backup/backup'date +%m%d%Y'
mail -s "Backup was done!" root@localhost < /dev/null
date +%m%d%Y >> /root/donebackups.lst

现在,让我们运行它。

root@ubuntu:/home/demo/scripting# bash backupexample.sh
cp: -r not specified; omitting directory '/root/snap'
mail: Null message body; hope that's ok

有几件事情需要我们的注意。首先,请注意,我们有一些脚本输出是我们没有预料到的。这是正常的,直接是我们在测试命令时看到的结果 — 一些命令抛出了错误。我们看到这个错误的原因将在本章的下一部分进行解释。我们需要注意的另一件事是,除了错误之外,我们没有其他输出。我们唯一能判断我们的脚本是否成功的方法将是让脚本本身报告 — 我们需要检查脚本中提到的邮件和文件以确认一切是否正确。这提示我们还需要另一件事情 — 日志记录。我们将在后续章节中处理日志和调试问题。

现在,让我们稍微详细介绍一下您的脚本如何与环境通信。

还有更多……

操作 shell 脚本的输入、输出和错误

没有什么比 Linux 中标准输入和标准输出的概念更具实用性了。

自从 Unix 起,系统上安装的不同应用程序和工具之间的互操作性一直是每个脚本、工具和应用程序必须遵循的主要前提之一。

简单来说,如果你在系统上编写任何工具,你可以依赖三个独立的通信渠道与外部环境进行交互。基于 ANSI C 输入/输出流的概念,称为标准输出标准输入,在 shell 中运行的所有程序可以通过三种方式进行通信——它可以从标准输入接收输入,它可以将结果和信息输出到标准输出,并且它可以将错误报告到专门为此任务标记的另一个输出,称为错误输出

将这个概念与每个工具应该输出仅包含文本信息并具有最小格式化的思想结合起来,并且应该准备好在需要时接受文本输入,你就得到了一个简单但极其强大且可移植的框架。

准备工作

当我们创建脚本时,我们将经常使用这些概念,以各种不同的方式。在这之前,我们需要确保理解实际存在且可用的输入和输出,以及在编写脚本时它们的常见使用方式。之后,我们将处理一些建议以及如何遵循在用户交互方面已确立的最佳实践。

甚至在此之前,我们需要定义一些概念。标准输入、输出和错误只是被称为文件描述符的某些特例。为了简化一些内容,我们不会花太多时间讨论文件描述符到底是什么;就本章而言,我们可以将其视为引用已打开文件的一种方式。

由于在 Linux 中一切都被视为文件,因此我们实际上只是为可以写入、读取或在某些情况下既能读又能写的内容分配一个数字,具体取决于上下文。显然,读取和写入的选项取决于实际设备的引用是什么。

默认情况下,你的脚本会打开与三个文件的通信。这些文件将用于处理标准输入,标准输入将与键盘连接;你的脚本将从键盘接收信息,除非你将其更改为其他东西,例如另一个文件或其他脚本或应用程序的输出。

标准输出默认设置为控制台或你运行脚本的屏幕。在某些情况下,我们还会将物理连接到服务器的屏幕称为控制台,但这不是我们现在要处理的内容。我们提到这一点是为了避免不必要的混淆。

我们无法从屏幕读取或向键盘写入,这就是为什么我们通常称它们为控制台,这是一种常见的名称,或多或少地描述了键盘和屏幕。这里还有很多内容可以学习,但现在我们就先停在这里。

如何操作…

为了更好地解释这两件事,你可以做一个简单的操作——运行一个没有任何参数的cat命令。当像这样执行时,任何命令,包括cat,都会接受标准输入并将结果输出到标准输出。在这个特定的例子中,cat会一行一行地处理,因为它在输出信息之前会等待一个行分隔符。

实际上,这意味着cat会逐行回显你输入的内容,直到你使用Ctrl + D发送一个特殊字符,称为传输结束EOT),这告诉系统你决定结束输入。

这将结束应用程序的执行。在截图中,看起来我们输入了每一行两次;实际上,一行是我们的输入,另一行是命令的输出:

https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_9.3_B16269.jpg

图 9.3 – cat – 演示标准输入和输出的最简单命令

还有标准错误,它也默认为屏幕,但它是一个独立的数据流;如果我们将某些内容输出到标准错误,它会以与标准输出完全相同的方式显示,但如果需要的话,可以进行重定向。

之所以有两个单独的流来处理输出,很简单——我们通常希望将某些数据作为脚本的结果,但我们不希望错误也成为其中的一部分。在这种情况下,我们可以将数据重定向到某个文件,将错误重定向到屏幕,或者甚至重定向到另一个文件,然后再处理它们。

现在,记得我们之前提到标准输入、输出和错误是文件描述符的特殊实例吗?Bash 实际上可以同时打开九个文件描述符,所以在编写脚本时我们可以做更多的事情。然而,这种做法很少使用,因为几乎所有的操作都可以通过使用默认的文件描述符完成。现在,记住以下几点:

  • 标准输入是文件描述符号0

  • 标准输出是文件描述符号1

  • 标准错误是文件描述符号2

为什么这些数字很重要?通过在命令行和脚本中使用一些特殊字符,如果我们只知道这三个数字,我们可以做很多事情。首先,如何停止脚本在屏幕上显示某些内容,如何将它输出到文件中?只需简单地使用 > 字符。

有时,你会看到命令行中包含 1> 而不是单纯的 >。这与使用单个 > 字符完全相同,但有时写成这样是为了确保你明白你正在重定向标准输出。

你可能对这种重定向形式很熟悉,因为这是你在处理命令行时学到的第一件事之一。需要注意的一点是,我们可以通过两种不同的方式将输出重定向到文件中,这取决于如果文件已经存在,我们希望对其做什么。

通过使用 > filename,我们将把脚本输出的内容重定向到名为 filename 的文件中。如果该文件不存在,它将被创建,如果文件已经存在,它将被覆盖

通过使用一个额外的括号,>> filename 重定向在处理已存在文件时的方式会有所不同。如果我们使用这个符号进行重定向,我们将会追加数据到一个已存在的文件中;数据将被添加到文件的末尾。

提到 1> 后,我们需要处理更常见的 2> 符号,它表示标准错误。当脚本中出现错误时,它会将错误输出。通常,如果你只把脚本输出重定向到文件,你会注意到,如果没有提到 2>,只有错误会出现在屏幕上,而其他所有内容都会写入文件。

如果我们确实希望将错误的结果输出到特定的文件中,可以通过使用 2> errorfilename 来实现,脚本将把错误写入名为 errorfilename 的文件中。

还有一种可能性是我们希望将所有内容输出到同一个文件中,且有两种方式可以实现。一种是分别在一条命令行中执行两个重定向,使用相同的文件名进行重定向。这样做的好处是当我们尝试理解输出的去向时,它比较容易阅读。

主要的缺点是,这种重定向可能是处理脚本时最常用的,尤其是在我们让脚本在无人值守的情况下运行时,这使得它在大多数环境中更难阅读。当然,有一个简单的解决办法——我们可以通过使用 &> filename 来使用单一重定向代替两个分开的重定向。在 Bash 环境中,这意味着我们希望将标准错误和输出都重定向到同一个文件:

demo@ubuntu:~/scripting$ bash helloworld.sh 1> outputfile \
2>errorfile
demo@ubuntu:~/scripting$ bash helloworld.sh &>outputfile

请注意,这个技巧仅在将输出和错误都重定向到同一个文件时有效;如果输出文件不同,我们需要明确地分别指定它们。

当我们开始讨论输出时,我们提到过输出不仅仅限于三个预定义的类型,处理它们的方式是合乎逻辑的。如果我们决定将某些输出重定向到文件描述符 5,在命令行中的处理方式将是直接重定向 5> 文件名。这不是每天都会见到的情况,但如果你需要创建多个日志文件或从同一个脚本输出不同的内容到不同的目标,這將是非常有用的。这种方法很少使用,因为直接从脚本中处理重定向要简单得多,而且通过在脚本中使用变量,任何调试你脚本的人都将更加容易。

到此为止,我们处理的是来自外部的重定向。现在是时候转向如何在日常工作中使用它了。

我们使用重定向的主要目的是记录信息。有几种方法可以实现这一点。一种是简单地在脚本中使用 echo 命令,然后对整个脚本进行重定向——例如,我们可以创建一个简单的脚本,只打印四行文本:

#!/usr/bin/bash
echo "First line of text!"
echo "Second line of text!"
echo "Third line of text!"
echo "Fourth line of text!"

让我们将其命名为 simpleecho.sh 并通过 Bash 运行它:

demo@ubuntu:~/scripting$ bash simpleecho.sh 
First line of text!
Second line of text!
Third line of text!
Fourth line of text!
demo@ubuntu:~/scripting$

现在,我们将它重定向到一个文件:

demo@ubuntu:~/scripting$ bash simpleecho.sh > testfile
demo@ubuntu:~/scripting$ cat testfile 
First line of text!
Second line of text!
Third line of text!
Fourth line of text!

好的,我们可以看到文件现在包含了 echo 命令的输出。为了演示错误是如何工作的,我们将在脚本中插入一个故意的错误:

#!/usr/bin/bash
echo "First line of text!"
echo "Second line of text!"
echo "Third line of text!"
bad_command
echo "Fourth line of text!"

现在,我们将再次执行相同的操作,首先启动脚本,然后进行重定向,看看发生了什么:

demo@ubuntu:~/scripting$ bash simpleecho.sh
First line of text!
Second line of text!
Third line of text!
simpleecho.sh: line 5: bad_command: command not found
Fourth line of text!
demo@ubuntu:~/scripting$ bash simpleecho.sh > testfile
simpleecho.sh: line 5: bad_command: command not found
demo@ubuntu:~/scripting$ bash simpleecho.sh &> testfile
demo@ubuntu:~/scripting$ cat testfile 
First line of text!
Second line of text!
Third line of text!
simpleecho.sh: line 5: bad_command: command not found
Fourth line of text!

这里要记住的主要一点是,错误输出始终与标准输出分开,因此除非我们特意重定向它们,否则不会在文件中看到错误。

到目前为止,事情都很简单,因为我们的脚本只使用了标准输出。通常,与用户的沟通并不像这那么简单,因为我们希望脚本既能在屏幕上提供一些信息,又能输出到一个特定的日志文件。当处理无人值守脚本时,情况类似;能够将脚本输出重定向到特定文件是很方便的,但更常见的做法是让脚本自动使用一个特定的日志文件,无需用户或管理员在执行脚本时进行任何重定向。

实现这一点的过程非常简单——我们可以在命令级别使用重定向,将输出重定向到文件。这里唯一需要记住的一点是,重定向到文件仅限于单个命令;如果你重定向任何内容,文件将在命令完成后立即关闭。这一点非常重要,主要因为你通常需要将内容附加到文件中;如果你忘记这样做,文件将会被新数据覆盖,作为日志文件就失去了作用。由于日志通常用于跟踪脚本或服务的多次执行,你几乎总是会将数据附加到文件中。

它是如何工作的…

现在让我们扩展我们的初始脚本,增加一点日志记录。我们要做的是编写单独的日志,其中包含脚本运行时执行的操作信息。我们将此信息写入脚本调用目录中的日志文件。这意味着我们的脚本随时可以使用三个单独的输出通道;除了标准输出和标准错误之外,我们还使用我们的日志文件。日志文件与标准输出之间的主要区别在于我们的日志是硬编码的,没有办法将其重定向到另一个文件。当然,存在此问题的解决方案,但我们不会花太多时间在上面;我们已经说过可以使用其他文件描述符之一,并将输出映射到它,稍后将输出转发到任何所需的流。这很少使用,因为在运行脚本时需要额外注意:

#!/usr/bin/bash
echo "We are adding four lines of text!" >> simplelog.txt
echo "First line of text!"
echo "Second line of text!"
echo "Third line of text!"
echo "Fourth line of text!"
echo "Exiting, end of script!" >> simplelog.txt

这种方法为我们提供了额外的灵活性,因为我们无需转发标准输出即可获得日志;我们的脚本已经做到了这一点。这意味着我们可以从命令行或作为无人看管的任务启动脚本,并在日志中获得相同的结果。当然,我们始终可以使用重定向来确保每个输出都被写入和保存。

还有更多…

Shell 脚本卫生

评论不仅仅是你可以做的事情;它本身就是一种艺术。在本章的这一部分,我们将处理评论,以便在编写脚本时使您的生活更轻松,但是在此处提供的建议和最佳实践在我们能想到的任何编程语言中都可以轻松使用。真正理解如何以有用的方式进行评论是您需要学习的事情,因为它将帮助任何在您完成编写后要使用您的脚本的人。

那么,评论是什么?可能描述它们的最简单方式是说它们是关于脚本预期执行的文档、脚本如何工作以及谁创建了脚本的文档,并且它们提供了有关脚本的技术细节的更多信息,例如创建时间。

评论是您应该自动想要做的事情。没有人是完美的,也没有人有完美的记忆。评论的作用是帮助您记住在某些脚本内部所做的事情,并向任何其他人提供关于脚本如何工作以及如果他们需要更改脚本中的任何内容时需要知道的不同事项的指导。

另一个重要的点是,注释并不等同于提供文档。有时候,人们可能会说他们不需要文档,因为他们在代码中已经有了注释,但这是完全错误的。除非你在讨论只有大约 10 行代码的脚本,否则注释会帮助你理解脚本的功能,而不必查阅整个文档,这可以节省大量时间。

准备工作

现在,让我们谈谈不同类型的注释。在编写代码时,总会涉及对各个过程或脚本部分的注释、预期的输入和输出、数据类型以及数据的注释。

在 Bash 中,注释通常以#符号开头。Bash 不识别多行注释,不像一些其他编程语言那样。这意味着我们需要注意,每一行包含注释的行都以#开头。某种程度上的例外是脚本中的第一行,它包含将运行脚本的解释器,但解释器在那行之后继续工作,所以我们可以说每一行以#开头的行实际上都是注释。Shell 会忽略注释内的所有内容,或者更准确地说,它会完全忽略包含注释的行。因此,请理解注释是为您和其他将要处理您的脚本的人编写的。尽量使它们易于理解、准确,并避免重复可以从命令本身推导出的内容。例如,如果您有一个命令在回显某些内容,请尽量不要说,“好的,这个命令将回显…”你要输出给用户的任何文本,而是尽量解释为什么。在注释带有许多变量的晦涩输出时,这尤其有用。

您可以并且应该在脚本中每个代码块的前面写注释,但也应该在脚本的开头和结尾写注释。

如何做到…

让我们从脚本的开始说起。第一个注释应该是什么?首先应该是解释器的名称,然后通常会提供有关脚本本身的信息。通常,脚本应该以一个注释开始,提供有关谁编写了它、何时编写的以及它是否属于负责脚本本身的项目的信息。

此部分还应该说明技术细节,如许可分发、对保证的限制以及谁有权使用脚本,谁没有。

完成了头部之后,我们还应该处理脚本的参数和运行方式,以及在输入方面的预期结果。如果输入有特殊要求,比如预期类型、参数数量或者在运行脚本之前需要存在或运行的先决条件,这些都应该在脚本的开头某处声明。

现在,我们来讲解函数。我们稍后会讨论函数的概念,但我们需要先讨论如何对函数进行注释,因为这也适用于任何其他的代码块。原因在于,函数本身是模块化的,并且以独立代码块的形式书写。

在函数内部共享某些内容时,给我们提供了注释的机会。我们应该利用这部分注释来描述函数或模块的作用,哪些变量会被修改或需要,函数将接收哪些参数,函数将执行什么操作,以及函数的输出是什么。如果我们处理的是某种非标准输出 —— 比如说,记录日志到单独的文件 —— 我们应该在函数头部说明。我们还应该注明函数输出的所有返回代码,尤其是在它改变脚本的退出状态时。

有一些有用的方式可以通过注释来创建提醒,提醒自己或他人脚本中仍需要完成的任务,这些被称为待办注释。它们通常以大写字母写成 —— TODO

我们还应注意,存在一种叫做heredoc表示法的方式,它有时用于我们需要创建大块注释时。这种表示法通过特定的 Shell 重定向方式来提供注释块的头部和尾部,而不使用常见的符号。我们将为你提供一个该表示法的示例,因为你在分析其他人脚本时会遇到它,但我们在自己的脚本中不会使用它。主要原因是它往往使脚本的可读性下降。

例如,这是一种完全有效的注释创建方式:

#!/bin/bash
echo "Comment block starts after this!"
<<COMMENTBLOCK
    Comment line
    Another comment line
    Third one
COMMENTBLOCK
echo "This is going to get executed"

那么,我们到底注释什么呢?

让我们从一些一般性的事情开始:

  • 明确标明脚本的编写者以及创建时间。

  • 给脚本版本化 —— 如果有任何更改,更新版本号,以便你可以跟踪不同计算机上使用的脚本。

  • 解释代码中任何复杂的部分 —— 诸如正则表达式、调用外部资源、以及任何引用脚本之外的内容都应该加上注释。

  • 对单独的代码块进行注释。

  • 清晰标注你注释掉并保留在脚本中的旧代码部分。

我们会稍微讲一下所有这些要点。

清晰地标明脚本的作者和创建日期至关重要。你的脚本可能会被其他人维护。打开一个有几百行代码的脚本时,最糟糕的情况就是不知道出问题时该找谁。有些人认为不签署脚本可以避免被其他管理员不断打扰,但这种想法是错误的。你写了这个脚本,为它感到自豪吧。

在提及作者后,请始终注意脚本的创建时间。这有助于人们优先考虑可能的更改,尤其是一些脚本中可能使用的外部资源。此外,请写出最后一次更改的时间,因为这对于所有维护脚本的人(包括您在内)都是相关信息。

在提及更改后,学习版本控制。版本控制是一种跟踪脚本中不同更改的方式,并确保您知道在任何给定时刻使用的版本。版本控制本身是一个简单的概念,使用一种方案来跟踪您的脚本的进展及所做的更改。

这可以通过几种方式来完成,因为目前没有官方标准来写下版本,尽管很多人倾向于使用语义化版本控制(https://semver.org/)。通常,版本号或多或少严格地遵循源代码的变化或特定版本创建时的时间。这两种方案都有其优点,但在撰写脚本时,我们认为跟踪更改是一个更好的主意,因为我们几乎无法从日期版本中推断出什么。

在我们承诺任何版本方案之前,我们将快速浏览一些示例。我们处理不同软件版本的方式直接与我们所处理的软件类型及版本之间的更改数量有关。

通用应用程序通常坚持使用一个正常的版本方案,其结构使用两个数字表示应用程序的主要和次要版本。例如,我们可以有 App v1.0,然后是 App v1.1,然后是 App v2.0,依此类推。第一个数字表示对应用程序进行的主要更改;第二个数字通常表示次要更改或错误修复。这实际上是今天市场上大型应用程序的通行做法。

在我们的脚本中,我们将使用相同的方案,但我们将实施语义化版本控制,因此版本将是1.0.03.2.4。第三个数字表示小的变更,并在变更数较少但变更显著时才有意义。请注意,有些应用程序将此方法推向极端,因此您将不可避免地遇到诸如版本2.1.2.1-33.PL2之类的情况。在处理脚本时,这只会使您的工作变得更加复杂,所以请不要这样做。

处理版本的另一种方式是参考时间,就像现在大多数操作系统所做的那样。例如,有 Ubuntu 20.04 和 20.10,分别代表 2020 年 4 月和 10 月发布的版本。这样做的原因是变更的数量巨大。每次发生变更时发布整个操作系统的新版本几乎是不可能的;你需要几乎每隔几小时发布一个新版本。

还有一种顺序编号方案,通常与我们提到的两种方法之一配合使用。微软使用这种版本控制风格,主要版本发布如Windows 10,更新版本发布如 20.04 或 21H1,代表发布的时间,然后使用构建版本来表示操作系统的小改动。

所有这些版本控制方案都有其优缺点,但无论你选择哪种,我们只有一个建议——坚持使用它。不要混合不同的版本控制方案,因为这会让人感到困惑。

说到版本控制,我们还应该谈谈变更追踪。当创建新版本的脚本时,大多数情况下你会对脚本本身进行很多更改。这些更改可能是修复 bug,或者使代码更快、更可靠。某些更改需要以其他方式记录,而不是单纯增加版本号。这样做很重要,因为它能帮助你记住对脚本做了什么。可以通过几种方式来实现。一种方法是将所有更改记录在一个单独的文件中(通常我们会使用ChangeLog文件来记录)。这样,你的注释和脚本本身会更加清晰易读,但你也需要关注这个新文件的更新。这样做还可以让其他人更容易阅读代码,因为每次新版本的发布都会更新它。另一种方法是直接在脚本中列出所有的更改。这种方法的好处是你可以快速查看哪些地方发生了变化,但这样脚本就会多出一些额外的文本,需要你跳过。还有一种版本是在修改所在的代码行之前记录更改,这样更改和代码本身紧密关联。

让我们看看这些在实践中是如何运作的:

#!/bin/bash
# V1.2 by Author, under GPLV2 licence
# V1.0 - Hello world script, V1.0 1/8/2021
# V1.1 - Added changes to comments on 2/8/2021 
# V1.2 - Added more changes to comments 3/8/2021 
echo "Hello World!"

我们将在这里暂停,因为接下来章节会详细介绍这些内容,并且在学习过程中逐步深入。

还有更多内容……

第十章:使用循环

在上一章中,我们开始处理脚本编写,并且学习了很多关于脚本如何工作以及它们是如何结构化的内容。然而,我们忽略了脚本中的一个重要主题——影响脚本执行时命令执行顺序。这里有一些内容需要我们讲解,因为我们有多种方式可以影响脚本中接下来要执行的命令。

我们将从一个概念开始,这个概念叫做 迭代器 或更常见的 循环。日常任务中有很多事情需要重复执行,通常每次迭代只改变其中的一个小部分。这就是循环发挥作用的地方。

在本章中,我们将介绍以下配方:

  • for 循环

  • breakcontinue

  • while 循环

  • test-if 循环

  • case 循环

  • 使用 andornot 进行逻辑循环

for 循环

当我们谈论循环时,我们通常会根据变量值变化的执行位置来区分。for 循环在这方面属于那一类,在每次迭代之前设置变量,并保持其值直到下一次迭代运行。我们将通过使用 for 循环来执行的最常见任务是使用循环遍历一组事物,通常是数字或名称。

准备工作

在开始介绍不同的 for 循环使用方式之前,我们需要先讲解它的抽象形式:

for item in [LIST]
Do
  [COMMANDS]
done

我们这里有什么?首先需要注意的是,我们有一些保留关键字,这些关键字使得 Bash 明白我们要使用 for 循环。在这个特定的例子中,item 实际上是变量的名称,它将持有列表中每次循环迭代的一个值。in 这个词是一个关键字,进一步帮助我们理解我们将使用一组值,这组值目前我们称之为列表,尽管它也可以是其他东西。

在列表之后,有一个块,定义了我们每次执行循环时打算运行的命令。目前,我们将把这个块当作一个整体来处理,里面包含的命令会一个接一个地执行,直到没有中断。稍后在本章中,我们将介绍一些条件分支,这将使我们能够覆盖更多可能的工作流解决方案,但目前为止,块是不可中断的。

可能会让你感到惊讶的是,for 循环通常是直接从命令行中使用的,甚至比在脚本中使用的次数还要多。原因很简单——有很多任务我们可以通过使用简单的 for 循环来完成,反而通过创建脚本来让它们变得复杂是应该避免的。以这种形式写的 for 循环看起来与我们第一次示例中展示的有些不同,主要区别在于当我们用一行代码编写循环时,关键字之间使用了分号分隔。

如何操作…

让我们从一个简单的例子开始。我们将遍历一个服务器列表,并在每次循环迭代中输出其中一个。注意,shell 会从提示符获取我们的命令,并在执行前将其重复一遍:

root@cli1:~# for name in srv1 srv2 srv3 ;do echo $name; \
done; 
Srv1
Srv2
srv3

在测试循环时,使用echo作为占位符命令是很常见的。我们将在示例中多次使用这种调试风格。echo作为命令在这种情况下可能是最有用的,因为它不会改变任何东西,同时使我们能够看到实际的输出结果。

在创建对象列表时,我们不需要使用任何特殊字符来分隔各个条目;bash 会将空格作为分隔符,只要我们用空格分隔值,bash就能理解我们的意图。稍后,我们会展示如何更改列表中分隔值的字符,但空格在几乎所有情况下都能正常工作。

我们在迭代中使用的列表可以明确定义,但更多时候,我们需要在运行循环时创建它,无论是在命令行还是脚本中。

这方面的典型例子是对目录中的一组文件运行循环。实现这个的方法是使用 shell 扩展。这意味着让 shell 运行一个命令,然后将其输出作为for循环的列表。我们可以通过反引号(`)指定命令,或者使用bash$(命令)语法。两者的结果相同——命令运行后输出会被传递给列表。

我们的示例将是一个循环,它遍历当前目录,并对每个文件运行file命令,向我们提供关于该文件实际内容的信息。我们仍然处于命令行环境:

root@cli1:~# for name in `ls`; do file $name; done;
donebackups.lst: ASCII text
snap: directory
testfile: empty

现在,让我们处理一些更有趣的内容。通常,我们需要在循环中使用数字,无论是为了计数还是创建其他对象。几乎所有编程语言都有某种类型的循环来实现这一点。Bash 在这方面有些例外,因为它能通过几种不同的方法来实现这一功能。其中一种方法是使用echo命令并加一点 shell 扩展来完成此任务。

如果你不熟悉这个,当给echo传递一个由大括号格式化的数字作为参数时,它将输出你指定区间中的所有数字:

demo@cli1:~/scripting$ echo {0..9}
0 1 2 3 4 5 6 7 8 9

要在循环中使用它,我们只需要做与前一个示例中相同的技巧:

root@cli1:~# for number in `echo {0..9}`; do echo $number; \
done; 
for number in `echo {0..9}`; do echo $number; done; 
0
1
2
3
4
5
6
7
8
9

我们并不局限于使用固定的步长;如果我们仅提到一个区间后面跟着一个数字,那么这个数字将被视为步长值。步长值本质上是指在每次循环迭代中,变量将增加的数值。

我们将尝试一个使用20倍数的简单循环:

root@cli1:~# for number in `echo {0..100..20}`; do echo \
$number; done; 
for number in `echo {0..100..20}`; do echo $number; done; 
0
20
40
60
80
100

我们可以像在命令行中那样结合使用 shell 扩展,为我们的循环创建不同的值。例如,为了为三组服务器创建服务器名称,每组包含六个服务器,我们可以使用一个简单的单行循环:

root@cli1:~# for name  in srv{l,w,m}-{1..6}; do echo $name; \
done;
srvl-1
srvl-2
srvl-3
srvl-4
srvl-5
srvl-6
srvw-1
srvw-2
srvw-3
srvw-4
srvw-5
srvw-6
srvm-1
srvm-2
srvm-3
srvm-4
srvm-5
srvm-6

当然,循环可以嵌套,只需将内层循环放入外层循环的 do-done 块中。在这个特定的示例中,我们使用 shell 扩展在两个循环中遍历一个值列表:

root@cli1:~# for name  in {user1,user2,user3,user4}; do \
for server in {srv1,srv2,srv3,srv4}; do echo "Trying to ssh \
$name@$server"; done;done; 
Trying to ssh user1@srv1
Trying to ssh user1@srv2
Trying to ssh user1@srv3
Trying to ssh user1@srv4
Trying to ssh user2@srv1
Trying to ssh user2@srv2
Trying to ssh user2@srv3
Trying to ssh user2@srv4
Trying to ssh user3@srv1
Trying to ssh user3@srv2
Trying to ssh user3@srv3
Trying to ssh user3@srv4
Trying to ssh user4@srv1
Trying to ssh user4@srv2
Trying to ssh user4@srv3
Trying to ssh user4@srv4

它是如何工作的……

现在是时候慢慢地从命令行过渡到如何在脚本中使用这些循环了。这里最大的区别是,for 循环在脚本中格式化后要比命令行更容易阅读。

对于我们的第一个例子,我们将提到另一种在循环中创建数字集合的方法,所谓的 C 风格循环。正如名字所示,这种循环的语法来自 C 语言。每个循环有三个单独的值。其中前两个是强制性的;第三个不是。第一个值称为 初始化值起始值。它给我们提供了变量在第一次循环迭代中的值。这里需要注意的一点是,我们需要显式地分配初始值,这与 常规 for 循环中通常使用的风格有显著不同。

这个循环变体中的第二个值是 测试条件,有时也叫 边界条件。它表示在我们完成循环之前,循环迭代器所能达到的最后一个有效值,或者更简单地说,就是当我们按递增顺序计数时的最大值。

第三个值可以省略;它将默认为 1。如果我们使用它,这将是循环将使用的默认步长或增量。

从理论上讲,这个 C 风格的 for 循环将是这样的:

for ((INITIALIZATION; TEST; STEP))
Do
  [COMMANDS]
done

实际上,它有更复杂的语法,但对于所有有 C 编程经验的人来说,它看起来非常熟悉,正如名字所示:

for ((i = 0 ; i <= 100 ; i=i+20)); do
  echo "Counter: $i"
done

在我们继续之前,让我们看一个我们已经使用过的循环示例,并以脚本中的格式呈现:

#!/usr/bin/bash
# for loop test script 1 
for name  in {user1,user2,user3,user4}; do
        for server in {srv1,srv2,srv3,srv4}; do
                echo "Trying to ssh $name@$server"
        Done
done

正如我们所看到的,唯一的实际区别是格式化和省略分号,这是因为我们不需要在一行中解析整个脚本。

另请参见

为了理解循环,你可能需要一些例子。从这些链接开始:

break 和 continue

到目前为止,我们在脚本中实际上并没有做任何条件分支。我们做的所有事情都是线性的,即使是循环也是如此。我们的脚本能够逐行执行命令,从第一行开始,如果有循环,它会一直运行,直到满足我们在循环开始时定义的条件。这意味着我们的循环有一个固定的、预定的迭代次数。有时候,或者更准确地说,通常我们需要做一些事情来打破这种想法。

准备就绪

假设有这样一个例子——你有一个循环,它必须迭代若干次,除非满足某个条件。我们说过,循环的迭代次数是在循环开始时就固定的,所以显然我们需要一种方法来提前结束循环。

这就是为什么我们有一个叫做 break 的命令。顾名思义,这个命令通过跳出它所在的命令块来打破循环,并结束循环,无论循环的定义中使用了什么条件。之所以这么重要,是因为它能帮助我们控制循环,处理任何可能要求我们在循环中不完成任务的状态。还需要注意的是,break 命令不仅限于 for 循环;它可以在任何其他代码块中使用,等到我们学习如何将脚本结构化为块时,这会变得更加有用。

如何实现…

开始时使用一个例子总是比较容易,但在这个特定的案例中,我们将从这个命令如何工作的整体视角开始。我们将使用抽象命令代替实际命令,以帮助你理解这个循环的结构。之后,我们会创建一些实际的例子:

for I in 1 2 3 4 5
do
#main part of the loop, will execute each time loop is started
  command1      
  command2
#condition to meet if we need to break the loop
  if (break-condition)
  then
#Leave the loop
      break          
  fi
#This command will execute if the condition is not met
  statements3              
done
command4

这里发生了什么?for 循环本身是一个正常的循环,它使用 12345 作为值来执行。command1command2 会按照预期至少执行一次,因为它们是循环开始后的第一个命令。

if 语句是事情变得有趣的地方。我们将会更多地讨论 if 语句,但我们需要在这里提到它们的最基本形式。在这里,我们有一个叫做终止条件的东西。它可以是任何可以解析为逻辑值的东西。这意味着我们的条件结果必须是 truefalse。如果结果为 false,则条件未满足,循环将继续执行 command3,并回到循环的开始,给变量赋予下一个值。

我们更感兴趣的是,如果break条件为真时会发生什么。这意味着我们已经满足了条件,并需要运行后续的代码块。这里有一个简单的break语句,它没有参数。接下来发生的事情是,脚本会立即退出循环并执行command4以及其后的所有内容。重要的是,command3在这种情况下不会被执行,循环也不会重复,无论循环变量的值是什么。

还有一个叫做continue的语句也很有用,虽然它不像break那样使用频繁。Continue也以某种方式跳出循环,但不是永久性的。一旦在循环中使用continue,程序的流程将立即跳转到循环块的开始,而不执行剩余的语句。

它是如何工作的…

讲完抽象结构后,现在是时候创建一个例子了。

假设我们在使用for循环计数,但我们希望一旦变量的值为4时就跳出循环。当然,我们也可以通过简单地将5指定为我们计数的上限来实现这一点,但我们需要展示循环的工作原理,因此我们将使用break语句跳出循环:

#!/usr/bin/bash
# testing the break command
for number  in 1 2 3 4 5
do 
echo running command1, number is $number
echo running command2, number is $number
if [ $number -eq 4 ]
        Then
                echo breaking out of loop, number is $number
                Break
fi
echo running command3, number is $number
done

是时候拆解我们的脚本了,但我们在拆解之前先运行一次:

demo@cli1:~/scripting$ bash forbreak.sh 
running command1, number is 1
running command2, number is 1
running command3, number is 1
running command1, number is 2
running command2, number is 2
running command3, number is 2
running command1, number is 3
running command2, number is 3
running command3, number is 3
running command1, number is 4
running command2, number is 4
breaking out of loop, number is 4

我们的示例脚本看起来非常像我们的抽象示例,但我们用了实际的echo命令来模拟应该发生的事情。我们需要讨论的最重要部分是if命令;其他部分和我们在食谱第一部分中所说的相同。

我们提到过,为了让break语句有意义,我们需要一个条件。在这个特定的例子中,我们使用了带有test条件的if语句;基本上,我们在告诉bash去比较两个值,看看它们是否相等。在bash中,有两种方式可以做到这一点——一种是使用我们习惯的=操作符,另一种是使用-eqequals操作符。这两者的区别在于,=用于比较字符串,而-eq用于比较整数。我们将在后续的食谱中详细讲解它们,因为它们在脚本中非常重要。

现在,让我们看看continue命令是如何工作的。我们将稍微修改一下脚本,使其在变量值为3时跳过第三个命令:

#!/usr/bin/bash
# testing the continue command
for number  in 1 2 3 4 5
do
echo running command1, number is $number
echo running command2, number is $number
if [ $number -eq 3 ]
        Then
                echo skipping over a statement, number is \
$number
                Continue
fi
echo running command3, number is $number
done

我们所做的只是简单地修改了if语句;我们更改了条件,使其检查变量值是否等于3,然后创建了一个命令块,当条件满足时跳过循环的其余部分。运行它很简单:

demo@cli1:~/scripting$ bash forcontinue.sh
running command1, number is 1
running command2, number is 1
running command3, number is 1
running command1, number is 2
running command2, number is 2
running command3, number is 2
running command1, number is 3
running command2, number is 3
skipping over a statement, number is 3
running command1, number is 4
running command2, number is 4
running command3, number is 4
running command1, number is 5
running command2, number is 5
running command3, number is 5

这里唯一需要注意的是我们完成了所有迭代;唯一跳过的事情是脚本中第三个命令的执行。还需要注意的是,循环中的 continue 命令会跳过当前循环到达其末尾并返回到循环的开始,而 break 语句则会跳过整个循环并不再重复执行。

另见

中断命令流一开始可能是个问题。更多信息请参考以下链接:

while 循环

到目前为止,我们处理的循环都是固定次数的迭代。原因很简单——如果你使用 for 循环,你需要指定循环要运行的值,或者指定变量在循环中将拥有的值。

这种循环方法的问题在于,有时你无法提前知道需要多少次迭代才能完成某个操作。这时,while 循环就派上用场了。

准备开始

你需要了解的最重要的事情是,while 循环在循环开始时进行测试。这意味着我们需要构建我们的脚本,使其在某些条件为真时运行 while。这也意味着我们可以创建一个永远不会执行的循环;如果我们创建一个 while 循环,其条件未满足,bash 将完全不执行它。这具有很多优点,因为它使我们可以灵活地根据需要多次使用循环,而不必考虑边界,并且我们仍然可以在条件未满足时使用 break 退出循环。

如何实现……

while 循环看起来比标准的 for 循环更简单;我们有一个必须满足的条件和一个将要执行的命令块。如果条件不满足,命令将不会执行,bash 会跳过该块,继续执行 done 语句后面的内容,该语句用于终止该块:

while [ condition ]; do commands; done

条件,在这个例子中,与之前提到的逻辑条件相同。还有一种使用 while 循环的方法,通过拥有一个称为 control-command 的命令,它会执行并直接提供信息以启动循环。我们将经常使用这种方法,因为它使我们能够,例如,逐行读取文件,而无需预先指定文件的行数:

while control-command; do COMMANDS; done

它是如何工作的……

和往常一样,我们将提供一些示例。首先,我们将重复使用 for 循环已经完成的任务。我们的目标是循环直到值达到 4,然后结束循环。请注意,值可以是字符串,而不一定是数字:

#!/bin/bash
x=0
while [ $x -le 4 ]
do
  echo number is $number
  x=$(( $x + 1 ))
done

有几个小点需要强调。第一个是我们使用的条件。在我们的for循环中,我们比较了值是否为4,然后使用break跳出循环。在这种情况下,我们不能这么做;如果检查x变量的值是否为4,循环将永远不会运行,因为初始值是1

while循环中,我们需要检查相反的条件——我们希望循环一直运行,直到值变为4,因此条件必须在所有情况下都为真,除了当我们的变量恰好是4时。

幸运的是,正是这个while关键字帮助我们创建条件。

我们提到过,除了条件之外,我们还可以使用命令。一个你将经常使用的典型例子是读取文件。我们可以使用for循环来实现这一点,但这会不必要地复杂。for循环需要在开始之前知道迭代的次数。为了使用for循环解决这个问题,我们需要在开始循环之前先统计文件中的行数,而这既复杂又慢,因为它需要我们打开文件两次——第一次是统计行数,第二次是在循环中读取。

一个更简单的方法是使用while循环。我们只需在命令有输出时运行循环——在本例中,就是在从文件中读取内容时。只要命令失败,循环就结束:

#!/bin/bash
FILE=testfile.txt
# read testfile and display it one line at a time
while read line
do
     # just write out the line prefixed by >
     echo "> $line"
done < $FILE

你会注意到,在这些脚本中有一些我们还没有看到的内容。第一个是变量的使用。当我们处理for语句时,某种程度上我们已经做过这件事了,但在这里你可以看到变量是如何声明的,以及它是如何被使用的。稍后我们会详细讨论这个问题。另一个问题是我们是如何实际读取文件的。read命令没有参数,它是用来处理标准输入的。既然我们知道如何重定向输入输出,我们就可以将文件中的内容重定向为read命令的输入。这就是为什么我们在脚本的最后一行使用了重定向。它看起来可能有些不自然,但这是做这件事的正确方式。

有时,我们需要使用一个永不结束的循环,即所谓的无限循环。它看起来与直觉相悖,但这种循环在脚本中非常常见,当我们需要不断运行脚本,却不知道需要多少次迭代时。我们有时甚至希望脚本不断运行,并在发生某些事件时使用break语句来停止它。无限的while循环很简单;只需将:作为条件:

#!/bin/bash
while :
do
     echo "infinite loops [ hit CTRL+C to stop]"
done

另见

测试-如果循环

严格来说,当谈到循环时,我们通常将它们分为for循环和while循环。还有一些其他结构,我们有时也称之为循环,尽管它们更像是命令块的结构。这些结构有时被称为decision循环或decision块,但出于传统原因,它们通常被称为test-if循环、case循环或logical循环。

其背后的主要思想是,任何决策性部分的代码实际上都会将代码分支到包含命令块的不同路径中。由于分支和决策是你在脚本中最常做的事情之一,我们将向你展示一些最常用的结构,这些结构或多或少会出现在你编写的任何脚本中。

准备工作

对于这个教程,最重要的是理解,对于任何条件分支,或者说任何你在代码中放入的条件,你将使用逻辑表达式。简单来说,逻辑表达式就是可以为真或为假的语句。

例如,考虑以下语句:

  • something.txt 文件存在。

  • 数字2大于数字0

  • somedir 目录存在且 Joe 用户可以读取。

  • unreadable.txt 文件任何用户都无法读取。

这里的每个语句都是可以为真或为假的。最重要的是,这里没有其他逻辑状态可以定义在任何语句上。另一个需要注意的点是,这里每个语句都指向一个特定的对象,比如文件、目录或数字,并且给我们该对象的某些属性或状态。

牢记这一点,我们将介绍 Shell 测试的概念,然后使用它来帮助我们编写脚本。

如何实现…

我们已经引入了使用conditionif语句,以便将代码分支到其中一个已评估的代码块中。这个条件必须得到满足,这意味着它需要被解析为truefalse语句。然后,if命令会决定哪一部分代码将被执行。

这种评估也叫做测试,并且在 shell 中有两种方法可以执行测试。bash shell 有一个叫做test的命令,有时会在脚本中使用。这个命令接受一个表达式并对其进行评估,以查看结果是 true 还是 false。命令的结果不会在输出中打印出来,而是将其退出状态分配给相应的值。

退出状态是每个命令在完成后会设置的一个值,我们可以从命令行内部或从脚本中检查它。这个状态通常用来查看是否执行特定命令时发生了错误,或者传递一些信息,比如测试表达式的逻辑值。

为了测试退出状态,我们可以使用一个简单的echo命令。让我们通过一些示例,使用简单的表达式和test命令来演示。

第一个例子使用echo命令输出test命令的退出状态。在所有例子中,0表示true1表示false

demo@cli1:~/$ test "1"="0" ; echo $?
0

那么,为什么我们得到了1=0是对的结果呢?我们故意犯了一个语法错误,目的是向你展示脚本中最常见的错误。所有命令通常都会使用非常严格的语法,而test命令也不例外。这个命令的问题在于它不会显示错误;相反,它会将我们的表达式当作一个单一的参数来处理,然后认为它是true

我们可以通过使用一个完全无意义的参数来检查这一点,比如一个单词:

demo@cli1:~/$ test whatever ; echo $?
0

如你所见,结果在逻辑上是对的,即使它在实际中没有任何意义。实际上,test命令需要空格来理解表达式的各个部分是运算符还是操作数。我们之前例子的正确写法应该是这样的:

demo@cli1:~/$ test "1" = "0" ; echo $?
1

这是我们预期的结果。为了检查,我们将尝试评估另一个表达式:

demo@cli1:~/$ test "0" = "0" ; echo $?
0

所以,这个是对的。完全符合我们的预期。我们使用引号的原因是我们实际上不是在评估数字,而是在比较字符串。如果我们去掉引号会怎么样?

demo@cli1:~/$ test 0 = 0 ; echo $?
0

这也没问题;只是为了检查,我们将重新尝试一个应该是假的表达式:

demo@cli1:~/$ test 0 = 1 ; echo $?
1

结果也完全是我们预期的样子。现在让我们尝试别的东西。我们曾说过,比较数字和字符串之间是有区别的。一个数字的值是固定的,无论它前面有多少个零:

demo@cli1:~/$ test 01 = 1 ; echo $?
1

我们的命令现在表示这两个不相等。为什么?因为字符串不相等。Bash使用不同的运算符来比较字符串和数字,而由于我们为字符串使用了1,所以这两个值不相同。即使使用引号也是如此,下面是如何处理引号的演示:

demo@cli1:~/$ test "01" = "1" ; echo $?
1 

我们应该使用的整数比较运算符是-eq;它会理解我们在比较数字,并根据此进行比较:

demo@cli1:~/$ test "01" -eq "1" ; echo $?
0

无论我们是否使用引号,结果应该是一样的:

demo@cli1:~/$ test 01 -eq 1 ; echo $?
0

对于最后一个例子,我们将看看当我们把运算符反过来使用并尝试使用整数比较来比较字符串时会发生什么:

demo@cli1:~/scripting$ test 0a -eq  0a ; echo $?
bash: test: 0a: integer expression expected
2

这个结果意味着什么?首先,我们的测试尝试评估条件,并意识到比较有误,因为它不能比较字符串和整数,或者更准确地说,一个整数不能包含字母。我们在输出中得到了错误,因此命令以2状态退出,这表示出现错误。结果在逻辑上没有意义,所以结果既不是0也不是1

下一步我们需要做的是将所学内容应用到实际脚本中,但在此之前,我们需要再解决一个问题。创建测试的方式有两种。一种是显式使用test命令,另一种是使用方括号([ ])。虽然在需要根据某些条件在命令行运行某些命令时我们会经常使用test,但在使用if语句时,我们大多数时候会使用方括号,因为它们更易于书写,并且在浏览脚本时看起来更整洁。为了确保理解,下面是我们使用的一个表达式,以不同的方式写出。请注意方括号内的空格;方括号和我们使用的表达式之间需要有一个空格:

demo@cli1:~/$ [ 01 -eq 1 ] ; echo $?
0

它是如何工作的……

我们将编写一个小脚本来测试文件是否存在于脚本运行的目录中。为此,我们需要讨论一些我们可以使用的其他运算符。

如果你查看man页面中的test命令或bash手册,你会看到有许多不同的测试,我们可以根据想要检查的内容来选择;我们最常用的测试可能如下(直接取自test(1)的手册页):

  • -d文件:文件存在并且是一个目录。

  • -e文件:文件存在。

  • -f文件:文件存在并且是常规文件。

  • -r文件:文件存在且具有读取权限。

  • -s文件:文件存在并且大小大于零。

  • -w文件:文件存在并且具有写入权限。

  • -x文件:文件存在并且具有执行(或搜索)权限。

让我们使用这个来创建一个脚本:

#!/usr/bin/bash
# testing if a file exists
if [ -f testfile.txt ]
      then
           echo testfile.txt exists in the current directory
      else 
           echo File does not exist in the current directory! 
fi

这里最重要的内容可能是学习else语句的结构和用法。在if语句中,我们定义了两个代码块或部分——一个叫做then,另一个叫做else。它们的作用如其名;如果我们在语句中使用的条件为真,那么then代码块将会被执行。如果条件不成立,则会执行else代码块。这两个代码块是互斥的;它们中只会有一个被执行。

现在,我们要处理一个有时会让你感到困惑的话题。我们已经提到,脚本有一个它运行的上下文。除了其他事项之外,你每次运行脚本时需要知道两件事——它是从哪里运行的,以及是哪个用户运行了脚本。

这两条信息至关重要,因为它们定义了我们如何引用需要的文件,以及从脚本中可以获得哪些权限。

我们接下来的任务是创建一个脚本,展示如何处理所有这些问题。我们将测试脚本是否可以读取和写入root目录,以及该目录是否存在。我们对这个目录的引用将是相对的,因此我们假设脚本是从/目录运行的,尽管通常情况下并非如此。然后,我们将尝试在不同的目录和不同的用户下运行脚本,并比较结果:

#!/usr/bin/bash
# testing permissions and paths 
if [ -d root ]
     then
           echo root directory exists!
     else 
           echo root directory does NOT exist! 
fi
if [ -r root ]
        then
                echo Script can read from the directory!
        else
                echo Script can NOT read from the directory!    
fi
if [ -w root ]
        then
                echo Script can write to the directory!
        else
                echo Script can not write to the directory!    
fi

如你所见,我们基本上在测试三种不同的条件。首先,我们试图检查目录是否存在,其次是检查脚本是否具有读写权限。

首先,我们将在脚本创建的目录中作为当前用户尝试运行它。然后,我们将转到/目录并从那里运行它:

demo@cli1:~/scripting$ bash testif2.sh 
root directory does NOT exists!
Script can NOT read from the directory!
Script can not write to the directory!
demo@cli1:~/scripting$ cd /
demo@cli1:/$ bash home/demo/scripting/testif2.sh 
root directory exists!
Script can NOT read from the directory!
Script can not write to the directory!

这些信息告诉我们什么?第一次运行时,由于我们在脚本中使用了相对路径,所以无法找到该目录。这使得脚本运行时的目录变得非常重要。

我们学到的另一个东西是如何进行检查。我们可以独立检查文件或目录是否存在,以及当前用户对特定文件拥有的不同权限。我们将通过在root用户下使用sudo命令来运行脚本来演示这一点:

demo@cli1:~/scripting$ cd /
demo@cli1:/$ sudo bash home/demo/scripting/testif2.sh 
[sudo] password for demo: 
root directory exists!
Script can read from the directory!
Script can write to the directory!

一旦我们改变了上下文,就可以看到同一个脚本不仅能够看到该目录存在,而且还拥有完全的使用权限。

现在,我们将完全修改我们的脚本,演示如何将检查嵌套在一起。我们的脚本将再次测试root目录是否在当前目录中,但这次,只有在目录存在时,脚本才会检查是否具有读写权限。毕竟,检查一个不存在的目录是否可以读取是没有意义的:

#!/usr/bin/bash
# testing permissions and paths 
if [ -d root ]
        then
                echo root directory exists!
                if [ -r root ]
                      then
                        echo Script can read from the \
directory!
                      else
                        echo Script can NOT read from the \
directory!
                fi
                if [ -w root ]
                      then
                        echo Script can write to the directory!
                      else
                        echo Script can not write to the \
directory!
                fi
        else
                echo root directory does NOT exists!
fi

现在,我们将在两个目录中运行它,以查看我们的脚本是否有效;主要的区别应该在于输出。同时,当你有这样的嵌套结构时,始终保持缩进的一致性非常重要。这意味着你应该始终确保同一块中的命令缩进一致,这样可以立刻清楚地知道每个命令属于哪个部分:

demo@cli1:~/scripting$ bash testif3.sh
root directory does NOT exists!
demo@cli1:~/scripting$ cd /
demo@cli1:/$ bash home/demo/scripting/testif3.sh 
root directory exists!
Script can NOT read from the directory!
Script can not write to the directory!

我们现在已经看到如何在bash中使用不同的测试和条件。接下来的话题与此类似——case语句或case循环。

另见

case 循环

到目前为止,我们已经处理了一些基本命令,这些命令允许我们在编写脚本时完成必要的操作,比如循环、分支、跳出和继续程序流程。case 循环,本食谱的主题,并不是严格必要的,因为其背后的逻辑可以通过多层嵌套的 if 命令来实现。我们之所以提到这个,纯粹是因为 case 是我们在脚本中会频繁使用的东西,而使用 if 语句的替代方案不仅难以编写和阅读,而且调试起来也很复杂。

准备工作

可以简单地说,case 循环或 case 语句只是另一种编写多个 if then else 测试的方式。case 并不能代替普通的 if 语句,但有一个常见的情况,在这种情况下,case 语句能让我们的生活变得更加简单,脚本也更容易调试和理解。但在我们深入讨论之前,我们需要先了解一些关于变量和分支的知识。一旦我们开始使用 if 语句,就会迅速意识到它们大致可以用两种不同的方式。第一种是大家在想到 if 语句时最常考虑的方式——我们有一个变量,并将它与另一个变量或一个值进行比较。这是很常见的,也是脚本中经常使用的。稍微不那么常见的是,当我们需要将一个变量与一组值进行比较时。这通常出现在我们需要将事物分类或根据用户输入执行某个代码块时。

用户输入可能是使用 case 语句最常见的原因。在脚本中,当我们开始使用参数时,通常会用到它。我们的脚本需要根据用户在运行脚本时选择的参数重新配置内容。稍后当我们开始处理传递参数到脚本时,我们将专门使用 case 语句来执行相应的命令。

用户菜单是另一个通过使用 case 语句解决的问题;广义来说,每次用户需要对一个问题作出多项选择时,都可以通过 case 语句来处理。

如何实现…

解释 case 语句的最佳方法是通过一个例子。假设一个用户启动了一个脚本,他们有四个选项可以选择脚本执行的操作。现在,我们还没有准备好处理用户输入的方式,所以我们暂且假设有一个变量 $1,它包含了以下四个值中的一个——copydeletemovehelp。我们的脚本需要根据用户的输入执行相应的代码部分。事实上,这就是如何处理参数的方法,不过我们稍后会讨论这一点。

我们的第一个版本将使用 if – then – elif 循环:

#!/usr/bin/bash
# $1 contains either copy, delete, move or help
if [ $1 = "copy" ]
        then
                echo you chose to copy!
        elif  [ $1 = "delete" ]
                then
                        echo you chose to delete!
        elif  [ $1 = "move" ]
                then
                        echo you chose to move!  
        elif  [ $1 = "help" ]
                then
                        echo you chose help!  
else    
                echo please make a choice!
fi

这个方法有效,但有两个问题。一个是如果没有提供参数,它会抛出错误,因为这意味着我们在比较一个值和一个没有值的变量。另一个问题是,即使我们特别注意使用正确的缩进,这段代码也很难阅读。我们将使用case语句重新编写:

#!/usr/bin/bash
# $1 contains either copy, delete, move or help
case $1 in 
      copy)      echo you chose to copy! ;;
      delete) echo you chose to delete! ;;
      move)   echo you chose to move! ;; 
      help)   echo you chose help!  ;;
      *)    echo please make a choice!
esac

你首先会注意到的是,这看起来非常简单和清晰。除了更容易编写之外,如果需要的话,代码也更容易阅读和调试。只需注意两件简单的事——语句块的结束由esac定义,这是case反过来拼写的,类似于if语句通过fi来结束。另一个是你必须使用;;来终止一行,因为这是在case循环中用于分隔选项的符号。

在匹配值时,你还可以使用有限的正则表达式;这也是为什么使用* glob来表示零个或多个字符

它是如何工作的……

现在我们已经了解了更多关于脚本编写的内容,我们将编写一个简单的脚本,在一个目录中搜索一个字符串并告诉我们发生了什么。我们不关心文本在哪里;我们只想知道我们在运行脚本的目录中是否有使用过该文本。

在开始之前,我们需要了解以下内容:

  • $1 将保存一个字符串值,这个值是我们要搜索的文本。

  • $? 保存了刚刚在脚本中完成的命令的exit值。

  • grep作为命令返回0(如果找到内容),1(如果没有找到),或2(如果发生错误)。

  • 有一个特殊的设备/dev/null,如果我们需要消除一些输出,可以使用它。

多亏了case语句,这成了一项简单的任务:

#!/usr/bin/bash
# $1 contains string we are searching for
grep $1 * &> /dev/null
case $? In
      0)    echo Something was found! ;;
      1)       echo Nothing was found! ;;
      2)        echo grep reported an error! ;;
esac

对于最后一个脚本,我们将使用case来结合本章中另一个测试目录的脚本,并将其放入一个更大的脚本中。我们将创建一个接受命令和文件名作为参数的脚本。命令将是checkcopydeletehelp。如果我们指定了copydelete,脚本将检查是否有权限执行该任务,然后执行它通常会调用的echo命令。

如果我们指定check,脚本将检查给定文件的权限:

#!/usr/bin/bash
# $1 contains either check, copy, delete or help
#script expects two arguments: a command and a file name
case $1 in 
      copy) 
       echo you chose to copy! 
       if [ -r $2 ]
      then
      echo Script can read the file use cp $2 ~ to copy to \
your home Directory!
      else
      echo Script can NOT read the file!    
      fi
          ;;
      delete) 
       echo you chose to delete! 
           if [ -w $2 ]
             then
             echo Script can write the file, use rm $2 to \
remove it!
             else
             echo Script can NOT read the file!    
           fi
           ;;
      check)
       if [ -f $2 ]
              then
                 echo File $2 exists!
                  if [ -r $2 ]
                        then
                                echo Script can read $2!
                           else
                                echo Script can NOT read $2!
                      fi
                      if [ -w $2 ]
                          then
                                 echo Script can write to $2!
                          else
                                 echo Script can not write to $2!
                      fi
            else
                      echo File $2 does NOT exist!
           fi  ;;
      help)         
      echo you chose help, please choose from check, copy or \
delete!  ;;
      *)   echo please make a choice, available are copy \
check delete and help!
esac

我们在这里所做的就是将到目前为止做过的所有事情结合成一个实际执行的脚本。我们之前没有提到的唯一一件事是脚本中的第二个参数$2。在这种情况下,我们使用它来获取需要运行命令的文件名。以下是从命令行运行时的效果:

demo@cli1:~/scripting$ bash testcas4.sh check testfile.txt
File testfile.txt exists!
Script can read testfile.txt!
Script can write to testfile.txt!
demo@cli1:~/scripting$ bash testcas4.sh check testfile.tx
File testfile.tx does NOT exist!

另见

当你在脚本中使用case时,你会很快发现很多示例是从不同网站复制粘贴的。以下链接是两个很好的资源:

  • https://tldp.org/LDP/Bash-Beginners-Guide/html/sect_07_03.html

  • https://www.shellhacks.com/case-statement-bash-example/

使用 ANDORNOT 进行逻辑循环

在计算机中,逻辑是无法逃避的。我们已经处理了一些可以用来评估条件的操作,但在 bash 中还有很多其他操作可以进行。

在这个教程中,我们将介绍一些不同的逻辑运算符,它们有助于我们在脚本编写中解决问题。首先,我们将讨论在命令行上可以完成的操作,然后再将其应用到脚本中。

准备工作

首先,让我们快速讨论一下逻辑运算符。到目前为止,我们提到了值为 true 或 false 的表达式。我们还提到了一些 bash 中内置的各种不同表达式,因为它们提供了在命令行上日常工作中至关重要的功能。现在是时候谈谈逻辑运算符,它们帮助我们将表达式组合起来,创建复杂的解决方案。我们将从常见的运算符开始:

  • &&(逻辑 AND

  • ||(逻辑 OR

这些运算符的有趣之处在于它们可以直接在命令行上使用。在 bash 中,命令行有四种执行命令的方式。其中一种是逐行运行命令。这是我们在交互模式下工作的常见方式。

如何实现……

有时,我们需要(或希望)在一行上运行多个命令。这通常通过使用 ; 来分隔命令,例如:

demo@cli1:~/scripting$ pwd ; ls
/home/demo/scripting
backupexample.sh  errorfile  forbreak.sh  forcontinue.sh  forloop1.sh  helloworld.sh  helloworldv1.sh  outputfile  readfile.sh  testcas1.sh

如我们所见,这与单独运行每个命令完全相同,但 shell 会按顺序执行它们。当我们使用 test 命令测试不同的表达式时,我们已经用到了这一点。我们需要检查该命令的退出状态,因此在运行测试后我们总是直接使用 echo

然而,有时我们可以使用一些逻辑来创建快捷方式。这时,逻辑运算符发挥了作用。剩下的两种运行多个命令的方法,使用它们不仅运行命令,还可以根据条件运行命令。

假设我们想在进行某些测试后执行一个命令——例如,我们想要打开一个文件,但仅当该文件确实存在时。我们可以在这里写一个 if 语句,但将事情复杂化完全没有意义。这时我们可以使用逻辑 AND

demo@cli1:~/scripting$ [ -f outputfile ] && cat outputfile 
Hello World!
demo@cli1:~/scripting$ [ -f idonotexist ] && cat outputfile
demo@cli1:~/scripting$

通常,在命令之间使用 && 告诉 bash 只有在左侧命令成功时,才运行右侧的命令。在我们的示例中,这意味着我们在目录中有一个名为 output 的文件。在左侧,我们快速测试文件是否存在。一旦测试成功,我们运行 cat 来输出文件内容。

在第二个示例中,我们故意使用了错误的文件名,而 cat 命令没有被执行,因为文件不存在。

另一个我们可以使用的逻辑运算符是逻辑 OR。使用的运算符是 ||,与之前一样。这个运算符指示 bash 只有在左侧命令失败时,才运行右侧的命令:

demo@cli1:~/scripting$ [ -f idonotexist ] || cat outputfile 
Hello World!
demo@cli1:~/scripting$ [ -f outputfile ] || cat outputfile
demo@cli1:~/scripting$

这是与前一个示例完全相反的情况。我们的cat命令只有在测试失败时才会执行。像这样的结构有时会在脚本中使用,以创建故障保护机制或快速运行诸如更新之类的任务。

好的一点是,这使我们能够根据测试的结果立即执行某些操作:

demo@cli1:~/$[ -f outputfile ] && echo exists || echo not \
exists
Exists
demo@cli1:~/$[ -f idonotexist ] && echo exists || echo exists \
not
exists not

这些运算符也存在于测试表达式中,允许我们创建不同的条件,否则需要多个if语句。

它是如何工作的…

测试条件现在应该对你来说完全熟悉了。我们将尝试结合几个条件来解释不同运算符的作用。例如,如果我们想快速检查一个文件是否存在并且可读,我们可以通过测试它是否可读,或者明确地将这两者结合成一个语句来实现:

demo@cli1:~/$ [ -f outputfile ] &&  [ -r outputfile ] ; echo \
$?
0

这些测试在处理字符串和数字时最为有用。例如,我们可以尝试在脚本中检查一个数字是否在一个区间内,如下所示:

#!/usr/bin/bash
# testing if a number is in an interval
if [ $1 -gt 1 ]
      then
          if [ $1 -lt 10 ] 
                 then 
                  echo Number is between 1 and 10
          else 
                echo Number is not between 1 and 10
                fi
     else 
            echo number is not between 1 and 10! 
fi

我们将要运行这个脚本,但在我们开始之前,可以看到它看起来很复杂,比应有的复杂。问题不仅仅在于我们必须使用两个if语句来确保处理外部区间的两个部分;即使这个脚本只有几行,它也需要大量的解释。它能正常工作吗?是的,正如我们在这里看到的:

demo@cli1:~/scripting$ bash testmultiple.sh 42
Number is not between 1 and 10
demo@cli1:~/scripting$ bash testmultiple.sh 2
Number is between 1 and 10
demo@cli1:~/scripting$ bash testmultiple.sh -1
number is not between 1 and 10!

现在,我们将使用逻辑运算符来优化我们的脚本:

#!/usr/bin/bash
# testing if a number is in an interval
if [[ $1 -gt 1  &&  $1 -lt 10 ]]
        then
                        echo Number is between 1 and 10
        else
                        echo Number is not between 1 and 10
fi

我们在这里使用双括号是因为我们必须这么做。有多种方式可以实现相同的目标,还有一些旧版本的语法,但处理多个表达式时,最好使用双括号。

另见

处理逻辑运算符部分之所以复杂,是因为它们种类繁多。你可以在这里找到更多信息:

第十一章:与变量一起工作

if 语句。

本章我们将涵盖以下内容:

  • 使用 shell 变量

  • 在 shell 脚本中使用变量

  • Shell 中的引号

  • 对变量执行操作

  • 通过外部命令使用变量

我们将涵盖你需要了解的有关变量的最重要内容,但和几乎所有其他内容一样,本章内容需要你进行实践。

技术要求

你可以使用的机器与前几章脚本编写中的机器相同——基本上,任何能够运行 bash 的机器都能使用。在我们的案例中,我们使用的是安装了 Linux 和 Ubuntu 20.10 的虚拟机VM)。

所以,启动你的虚拟机,咱们开始吧!

使用 shell 变量

变量是你可能已经理解的概念,即使只是从概念上理解。我们这里说的不是编程;我们的日常生活中充满了变量。基本上,变量是能够存储一个值的东西,且在我们需要它时可以为我们提供这个值。

准备工作

用日常语言来说,我们可以说像开车这样的活动充满了变量。这意味着,天气温度、环境光照、路面质量等许多因素在你行驶过程中都会发生变化。尽管它们不断变化,但重要的是,在任何给定的时刻,我们能够看到天气的实际、温度的实际值、光照的强度,以及道路的情况或结构。

这就是我们所说的变量以及查找变量的方式。

一旦我们确认了天气的实际情况,它就不再是变量了,因为它已经有了实际的值。当我们谈论编程时,变量的工作方式也是一样的。我们所做的是给一个空间起个名字,然后用它来存储某个值。在我们的代码中,我们引用这个空间来存储和读取其中的值。根据编程语言的不同,这个空间可以存储不同的内容,但现在我们只把变量看作是能够存储值的东西。

bash 中,变量比许多其他语言中的变量简单得多,基本上它们可以存储两种不同类型的值。一个是字符串,它可以是任何数字和字母的组合,也可以包括特殊字符。

另一个类型是数字,之所以这两种变量之间存在区别,是因为在处理字符串和数字时,一些运算符和操作是不同的。

如何做到这一点…

当你开始使用变量时,有两件事是你需要学习的。

首先,你需要了解如何为变量赋值。通常称为赋值变量或实例化变量。一个变量有一个名称和一个值。在bash中,当我们想创建一个变量时,我们只需选择一个名称并给它赋值。之后,我们的 shell 知道这是一个变量,并且会跟踪我们赋给它的值。在我们赋值之前,变量是不存在的,任何对它的引用都是无效的。

那么,如何为变量选择一个名称呢?

每个变量都有一个名称,用于在脚本或 shell 环境中引用该变量。名称的选择完全由你决定。名称应该是你容易记住的,并且不会与其他变量混淆。通常,一个好的选择是能够标识变量用途的名称,或者是一个完全抽象的名称,暗示该变量的含义。

在为变量命名时,你应该始终避免使用关键词,特别是那些在bash中已经有特定含义的关键词。例如,我们不能使用continue作为变量名,因为这是一个命令的名称。这将不可避免地产生错误,因为 shell 会对该变量产生混淆,无法知道该怎么处理它。

我们提到了环境变量。在交互式 shell 中,有许多变量用于存储有关你环境的信息。这些信息描述了不同应用程序所需的各种内容——例如用户名、你的 shell 等等。

让我们做几个简单的例子。我们按之前提到的方式给变量赋值,通过为名称赋一个值。在我们的例子中,我们将把一个value字符串值赋给名为VAR1的变量:

demo@cli1:~$ VAR1=value

很简单吧。现在,让我们读取刚才创建的变量:

demo@cli1:~$ echo $VAR1
value

正如我们所看到的,为了读取变量,我们需要在变量名之前加上$字符。此外,变量名在创建时使用的大小写需要一致,因为变量名是区分大小写的。

如果我们不这样做,我们将无法从echo命令中获得任何有用的值,但要非常注意,这两个例子都没有给出错误:

demo@cli1:~$ echo var1
Var1
demo@cli1:~$ echo $var1
demo@cli1:~$ echo VAR1
VAR1

我们故意犯这些错误是为了强调几个小点。当使用echo命令时,我们告诉它显示一个字符串。如果字符串包含变量名,它必须加上前缀;否则,echo命令将直接输出字符串内容,而不显示变量的值。

如我们所说,变量名区分大小写,但如果我们犯了错误,系统不会显示任何错误——我们只是会得到一个空行。这个行为是可以更改的,稍后当我们在脚本中使用变量时会处理这个问题。

现在让我们做点别的——我们将尝试在脚本中使用我们的变量。记住,我们在 shell 中分配了一个变量,但现在,我们将在脚本中引用它。

这个脚本将是最简单的:创建一个文件,命名为 referencing.sh,并输入以下代码:

#!/bin/bash
#referencing variable VAR1
echo $VAR1

运行它时会发生什么?让我们看一下:

demo@cli1:~$ bash referencing.sh
demo@cli1:~$ echo $VAR1
value

我们发现了一个问题。当我们从命令行读取变量时,一切正常,但这个变量在我们的脚本中不存在。问题的原因并不像看起来那么简单。我们之前提到过上下文和环境变量。每个变量都存在于当前环境中,并且不会被任何命令隐式继承。当我们启动一个脚本时,实际上是在创建一个新的环境和新的上下文,该上下文继承所有标记为可继承的变量。由于我们只是给变量赋了一个值,而没有做其他操作,因此该变量只会对我们的 shell 可见,而对从 shell 启动的任何命令或脚本不可见。

为了解决这个问题,我们需要 导出 变量。导出意味着标记我们的变量,告诉环境我们希望变量的值对作为其子进程运行的命令和脚本可用。为此,我们需要使用一个叫做 export 的命令。语法再简单不过了:

demo@cli1:~$ export VAR1
demo@cli1:~$ bash referencing.sh
value
demo@cli1:~$

如我们所见,我们的脚本现在知道变量的值,并且该值是从 bash shell 继承而来的。

如果我们只输入 export,我们将看到所有已导出的变量列表,这些变量可以供我们的脚本使用:

https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_11.1_B16269.jpg

图 11.1 – 每个用户都有不同的导出变量

注意一件重要的事情:每行都以 declare -x 命令开头,后面跟着变量名和值。这指向了另一个非常有用的命令:declare

当我们创建一个变量并给它赋值时,我们只使用了 bash 中处理变量的一个部分。记得我们是如何导出变量的吗?变量有一些属性,这些属性是关于变量应如何行为的额外信息。将变量导出是其中一个属性,但我们还可以将变量设置为只读,改变变量名称的大小写,甚至改变变量所持有信息的类型。要做到这一点,我们使用 declare

它是如何工作的……

唯一剩下的就是给你提供更多关于环境变量的信息。

环境可能非常庞大,这取决于你的系统及其配置。它包含很多内容,并且因系统而异,因为环境中的变量及其值依赖于在特定系统上安装的不同程序和选项。例如,如果你使用的是 bash 之外的 shell,你可能会有特定于该 shell 的不同变量。如果你使用 declare -penv

这两者的区别非常重要。declare 语句是 bash 的内建命令。它会读取环境中所有的变量并显示出来。而 env 则是一个应用程序。它会运行,创建自己的环境来运行,然后显示该环境中的所有变量:

https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_11.2_B16269.jpg

Figure 11.2 – 可以通过至少两种方式检查环境,但我们通常使用 env 命令

我们将提到一些最重要的内容:

  • USER—保存当前用户的用户名。如果你需要检查脚本是以哪个用户身份运行的,这一点至关重要。这个命令的替代方法是运行 whoami 命令。

  • PWD—保存当前目录的绝对路径。这对任何脚本来说都很重要,因为它可以帮助你找出脚本是从哪个运行目录调用的。这个命令的替代方法是 pwd

  • LOGNAME—提供与 USER 相同的信息,特别是当前登录用户的用户名,因此得名。

  • SHELL—包含当前用户登录 shell 的完整路径。这与正在运行的 shell 不同;我们可以运行任何 shell 并从中工作,而此变量返回的是我们的登录 shell 设置的路径。这个值来自 /etc/passwd 文件。

  • SHLVL—当你最初运行 shell 时,你处于环境中的第一层。这意味着没有其他东西在你的 shell 之上运行,或者更准确地说,是你的系统直接启动了你的 shell。随着你的工作进行,你可以运行其他的 shell、脚本,甚至是在 shell 中再启动 shell。每次你在 shell 内部运行一个 shell 时,SHLVL 就会增加。这在尝试找出你的脚本是从另一个 shell 中运行的还是直接由系统启动时非常有用。

  • PATHPATH 包含了一个目录列表,shell 在尝试查找你执行的任何命令时会搜索这些目录。由于 Linux 上几乎所有东西都是命令,这个信息非常重要——如果某个路径不在 PATH 变量中,它将不会被搜索,且只有在你直接引用时,才能执行该路径下的命令。这在你不想每次都直接引用命令,或者你有某些理由更倾向于使用某个目录中的命令时非常有用。

在继续下一个食谱之前,还有另一种列出变量的方法,那就是不带任何参数使用 set

https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_11.3_B16269.jpg

Figure 11.3 – set 不仅可以显示变量,还能够配置 shell

当然,由于在任何给定时刻都有很多活动的变量,使用某种过滤方式要更好:

https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_11.4_B16269.jpg

图 11.4 – 查找事物的唯一快速方法是使用 grep

参见

我们将为你提供一个起点,因为这个主题非常庞大:

在 shell 脚本中使用变量

变量有时看起来很简单——它们的作用是让你在代码中放入一个不断变化的值。问题在于,在这种简单性中,有几件事情你需要知道关于变量的实际位置——它存在于一个叫做上下文的地方。我们将在本章中讨论这一点。

准备就绪

当我们谈论脚本时,情况与我们在交互式环境中工作时略有不同。当你使用交互式 shell 时,你能使用的每个环境变量也能在脚本中使用。然而,有一件事你必须始终记住。正如我们之前所说,脚本是在某个特定的上下文中运行的。这个上下文是由运行脚本的用户定义的。在前一章中,我们让你确保拥有执行脚本中所需任务的适当权限。

在这个配方中,我们将确保你理解,这同样适用于变量。除非我们在脚本中显式设置变量,否则我们需要确保从环境中获得的变量是我们期望的。而且,很多时候,我们会先检查变量是否存在,因为它可能没有从 shell 导出,因此对我们不可见。

还有一类特殊的变量,它们在脚本运行的瞬间被设置,并包含一些对成功运行脚本至关重要的信息。

我们要做的就是从脚本如何与 shell 使用变量交互开始。

如何操作……

一如既往地,我们从简单的开始。首先,我们要做的是我们能做的最基础的事情——Hello World,但使用变量:

#!/bin/bash
# define a variable
STRING="Hello World!"
# output the variable
echo $STRING

这基本上是我们之前提到过的内容,只不过是在脚本中。我们创建了一个变量,给它赋了一个值,然后使用这个值输出文本。

现在,让我们尝试做一些更有用的事情。在编写脚本时,我们需要计算或以某种方式准备一些东西,以便在脚本的不同部分使用它们。变量是一个很好的方式,可以清晰地做到这一点,以便它们可以在代码中重用。

例如,我们可以创建一个包含今天日期的字符串。然后,我们可以使用变量,而不是每次都运行适当的命令,以重复创建指定格式的日期:

#!/bin/bash
# we are using variable TodaysDate to store date
TodaysDate=$(date +%Y%m%d)
# now lets create an archive that will have todays date in \
the name. 
tar cfz Backup-$TodaysDate.tgz .

在我们运行这个之后,输出将会很有趣:

demo@cli1:~/variables$ bash varinname.sh 
tar: .: file changed as we read it
demo@cli1:~/variables$ ls 
Backup-20210920.tgz  varinname.sh

我们可以看到文件已正确创建,日期看起来也正常。我们没有预料到的是错误。错误的原因很简单——tar首先创建一个输出文件,然后读取它必须归档的目录。如果归档文件是在它要归档的目录中创建的,那么tar命令会尝试对归档文件本身运行,从而产生这个错误。在这种情况下这是正常的,但应尽量避免这种归档循环。解决方案是将归档文件保存到我们要归档目录之外的地方。

现在进入有趣的部分——向脚本传递参数。到目前为止,我们创建的脚本完全不关心它们的运行环境。我们需要改变这一点,因为我们不仅需要能够向脚本传递信息,还需要让脚本报告发生了什么。

任何脚本,不论它是如何执行的,都可以有参数。这是如此常见,以至于我们通常不会特意去考虑它。参数基本上是执行脚本时,脚本名称后面的字符串。

这正是脚本中参数的工作方式——shell 会将启动脚本时命令行中的内容传递给脚本,并通过一个以数字为名称的变量传递它。下面是一个例子:

#!/bin/bash
# we are going to read first three parameters
# and just echo them
echo $1 $2 $3
# we will also use $# to echo number of arguments
echo Number of arguments passed: $#

现在,来看一下我们如何以几种不同的方式运行它:

demo@cli1:~/variables$ bash parameters.sh 
Number of arguments passed: 0

如果我们不给它任何参数,它也能正常工作,就像我们给它传递三个预期的参数时一样:

demo@cli1:~/variables$ bash parameters.sh one two 3
one two 3
Number of arguments passed: 3

但让我们尝试使用超过三个参数:

demo@cli1:~/variables$ bash parameters.sh one two 3 four
one two 3
Number of arguments passed: 4
demo@cli1:~/variables$ bash parameters.sh one two 3 four five
one two 3
Number of arguments passed: 5

我们在这里看到一个问题。保存参数值的变量是位置性的,我们必须正确地引用参数行中的所有内容。解决方法是读取arguments变量的数量,然后创建一个循环来读取这些参数。

你可能会想:那$0 呢? 程序员通常从零开始计数,而不是从一开始,这里也不例外——有一个叫做$0的变量,它包含了脚本本身的名称。这对于脚本编写来说非常方便。我们创建了一个名为parameters1.sh的脚本并运行它:

#!/bin/bash
# reading the script name
# and just echo
echo $0

如我们所见,这个脚本可以说是极其简单的。但在这种简单中,隐藏着一个巧妙的技巧:

demo@cli1:~/variables$ bash parameters1.sh
parameters1.sh
demo@cli1:~/variables$ cd ..
demo@cli1:~$ bash variables/parameters1.sh 
Variables/parameters1.sh
demo@cli1:~$ bash /home/demo/variables/parameters1.sh 
/home/demo/variables/parameters1.sh
demo@cli1:~$

我们要表达的重点是,变量保存的值不仅包含脚本的名称,还包含用于运行脚本的完整路径。如果我们是从crontab或其他脚本运行的,这可以用来确定脚本是如何被运行的。

接下来,我们需要学习一个新的概念——shift语句。

有两种方法可以解析脚本的参数——一种是使用一个循环,循环运行$#次,这意味着我们将对脚本的每个参数运行一次。这是一种完全有效的方法,但也有另一种更优雅的方式来处理这个问题。shift是一个内建语句,它使你可以一次解析一个参数,而不需要知道参数的总数。

它是如何工作的……

一旦你理解它的作用,移位的方式就完全是直观的。让我们引用一下help页面的内容:

demo@cli1:~/variables$ help shift
shift: shift [n]
    Shift positional parameters.
    Rename the positional parameters $N+1,$N+2 ... to $1,$2 ...  If N is
    not given, it is assumed to be 1.
        Exit Status:
    Returns success unless N is negative or greater than $#.

基本上,我们只需要读取$1参数,然后调用shift。该命令将删除这个参数并将所有参数向左移位,使下一个变成$1,依此类推。

这使我们可以做以下事情:

#!/bin/bash
while [ "$1" != "" ]; do
    case $1 in
        -n | --name )
            shift
            echo Parameter is Name: $1
        ;;
        -s | --surname )
            shift
            echo Parameter is Surname: $1,
       ;;
        -h | --help )    echo usage is -n or -s followed by a \
string
            exit
        ;;
        * )              echo usage is -n or -s followed by a \
string
            exit 1
    esac
    shift
done

我们需要在这里解释一些事情。我们使用shift而不是for循环的原因是我们正在解析可以是不同选项的参数。我们的脚本有三个可能的开关:-n,可以写作—name-s,也可以用作-surname,以及-h—help。在前两个参数之后,我们的脚本期望有一个字符串。如果没有使用任何参数,或者我们选择-h,我们的脚本将写出一个关于使用参数的小提示。

如果你尝试在for循环中做这个,你会遇到问题——我们需要读取选项,将其存储到某个地方,然后在下一个循环中读取option参数,然后再次循环,尝试判断接下来的内容是选项还是参数。

通过使用shift,事情变得简单得多——我们读取一个参数,如果找到任何选项,我们就移位;然后参数就存储在$1中,我们可以打印并使用它们。

如果我们没有找到选项,我们就忽略变量中的内容。

另见

使用参数的话题非常复杂,几乎在每个脚本中都需要。所以,针对这个问题有一些开源的解决方案,比如这些:

Shell 中的引号

引号是我们理所当然认为的东西,不仅在 Linux 中,在许多其他应用程序中也是如此。在这个教程中,我们将讨论引号是如何工作的,应该使用哪些引号,以及如何确保你引用的脚本部分按预期行为运行。

准备工作

在 Linux 中,使用引号非常重要,不仅仅是在 shell 脚本中,也是在任何其他使用文本的应用程序中。在这种情况下,引号的行为与数学表达式中的括号几乎一样——它们提供了一种改变表达式评估方式的方式。几乎所有的命令行工具都使用空格作为分隔符,告诉工具一个字符串在哪里结束,另一个字符串从哪里开始。当你尝试使用名称中有空格的文件或目录时,你可能遇到过这个问题。通常,我们通过使用转义字符(\)来解决这个问题,但如果我们使用引号,它就变得更易于阅读。

这并不是我们使用引号的唯一原因,因此我们现在要更加关注它们。

首先,我们必须定义可以使用的不同引号符号,并概述它们的含义:

  • 双引号:""""

用于引用字符串并防止 shell 将空格当作分隔符。这个引用风格会使用像 $`\! 等 shell 扩展字符,且不会引用它们,而是按通常的方式替换它们。你会一直使用这种引用风格。

  • 单引号:'

它们的行为几乎与双引号完全相同,但有一个重要的区别。单引号中的所有内容都会被原样处理,且不会被以任何方式更改。即使使用了特殊字符,这也不会产生影响——它们将作为字符串的一部分使用。

  • 反引号:"`"

反引号有时被视为引号,且常常与单引号混淆。注意,这是一个完全不同的字符——在标准的美国(US)键盘上,你可以在数字键1键左边的键上找到它,它位于最左边。区别在于字符的倾斜角度,因此“反引号”这个名称意味着它与引号字符的方向不同。在 shell 中,它用于运行命令——或者更准确地说,用于运行命令并将其输出替换在其位置上。

即使反引号严格来说不是引号,在大多数学习资料中你可能会看到它们被提到作为引号。这要么是因为它们看起来像引号,要么是因为它们是最可能在任何文本编辑器中自动变成引号的字符。

如何操作……

为了理解引号的使用,我们将做几个脚本示例,从一个简单的 if 语句开始,只是提醒你它长什么样。我们将创建一个名为 quotes1.sh 的文件,并使用以下代码:

#!/bin/bash
directory="scripting"
# does the directory exist? 
If [ -d $directory ]; then
             echo "Directory $directory exists!"
else 
              echo "Directory $directory does not exist!"
fi

一旦我们运行它,结果如我们所预期:

demo@cli1:~/variables$ bash quotes1.sh 
Directory scripting does not exist!

现在,让我们在 quotes1.sh 中做一个小改动并将其保存为 quotes2.sh

#!/bin/bash
directory='scripting'
# does the directory exist? 
if [ -d $directory ]; then
             echo 'Directory $directory exists!'
else 
             echo 'Directory $directory does not exist!'
fi

在这种情况下,当我们运行命令时,结果会完全不同。由于我们使用了单引号,Shell 不会显示我们的变量,而是会显示我们实际的变量名及其前缀:

demo@cli1:~/variables$ bash quotes2.sh 
Directory $directory does not exist!

还有一个特殊的情况需要提及,那就是当我们在单引号内使用双引号,或者反过来。当双引号位于外部时,它们会否定单引号,因此我们会看到通常的变量扩展。这时,创建一个名为 undeterdouble.sh 的文件,并将以下代码输入其中:

#!/bin/bash
directory='scripting'
# does the directory exist? 
echo "'Directory $directory is undetermined since we have no \
logic in this script'"

当我们运行它时,得到的是:

demo@cli1:~/variables$ bash undeterdouble.sh 
'Directory 'scripting' is undetermined since we have no logic in this script'

注意,Shell 插入了另一对引号,以将变量值和字符串的其余部分分开。

如果我们把它反过来,那么所有内容都会被引用,因为单引号的作用就是这样:

#!/bin/bash
directory='scripting'
# does the directory exist? 
echo '"Directory $directory is undetermined since we have no \
logic in this script"'

注意,字符串中没有额外的引号:

demo@cli1:~/variables$ bash undetersingle.sh 
"Directory $directory is undetermined since we have no logic in this script"

它是如何工作的……

Shell 需要知道何时扩展变量,何时不扩展。空格在脚本中也是一个大问题——大多数时候,你的脚本会因为将字符串拆分成由空格分隔的单词而完全错过某些部分。

单引号和双引号各有其用途,但你大多数时候会使用双引号。原因是,你通常会有一个包含空格的字符串,但其中也包含不同的变量。使用双引号时,你的变量会被展开,同时保留文本内容。

另见

关于单引号和双引号,资源并不多,因为它们是直接明了的:

对变量执行操作

变量非常有用,因为它们可以存储我们能想到的任何值。通常,我们不仅仅需要在变量中存储一个值。在这个教程中,我们将处理许多关于如何操作变量的不同内容,有时修改它,有时完全替换它。

准备工作

为了能够修改变量,你需要理解一个简单的概念。bash 不能直接修改变量本身;我们稍后会提到这一点,但如果你需要修改变量中的某些内容,你必须重新赋值。

如何做到这一点…

变量可以做很多事情。有时,我们想了解它包含了什么;有时,我们需要修改其中的内容,以便以后使用;或者,我们可能只是想知道该变量是否有值。

在这个教程中,我们将大量使用命令行,因为它使得解释事物变得更加容易。

在我们开始之前,我们要介绍一个我们尚未提到的东西:数组。

数组是一个变量,它包含由空格分隔的多个字符串。你可以说它本身是一个字符串,但出于灵活性的考虑,bash 可以单独访问数组的不同部分,同时将所有值保存在一个变量中。

我们将定义一个包含四个字符串的数组。定义变量的方式是使用括号,并将字符串放入其中:

demo@cli1:~/variables$ TestArray=(first second third fourth)

现在,我们可以看到数组中有多少个元素。这时事情会变得有些奇怪。记得我们曾说过,bash 中的计数是从零开始的吗?

demo@cli1:~/variables$ echo ${#TestArray[@]}
4

我们看到得到了正确的信息——我们的数组确实有四个元素。我们得到这个结果的方法是使用大括号和一些特殊字符。我们的表达式以 $ { 开头,告诉 bash 我们要操作一个数组。然后是 # 符号,表示我们期待得到某个计数,无论是长度还是元素数量。接着,我们有数组的名称,后面跟着方括号和方括号中的 @ 符号。在 shell 语法中,这告诉 bash 我们想要数组中的所有元素。

用通俗易懂的英文来说,这个命令的意思是:显示 TestArray 数组中有多少个元素。

但要小心——在语法方面,事情是极其敏感的。例如,如果你省略了[@]部分,这仍然是一个完全有效的命令,但它会给你完全不同的信息:

demo@cli1:~/variables$ echo ${#TestArray}
5

我们得到的数字实际上是数组中第一个字符串的长度,而不是数组本身的长度。这是因为如果我们只使用数组名,我们将只获得第一个字符串作为结果:

demo@cli1:~/variables$ echo ${TestArray}
first

为了避免这种情况,我们应该始终使用方括号并在其中放入数字。这是引用数组中字符串位置的正确方式。请记住,第一个字符串的索引是0

demo@cli1:~/variables$ echo ${TestArray[2]}
third
demo@cli1:~/variables$ echo ${TestArray[0]}
first
demo@cli1:~/variables$ echo ${TestArray[1]}
second
demo@cli1:~/variables$ echo ${TestArray[@]}
first second third fourth

现在我们已经看到如何引用数组及其部分内容,让我们来看看如何检查一个变量是否存在以及如何检查其长度。我们已经知道如何做——我们只需要使用${#variablename}来让 shell 输出长度:

demo@cli1:~/variables$ TestVar="Very Long Variable Contains \
Lots Of Characters"
demo@cli1:~/variables$ echo $TestVar 
Very Long Variable Contains Lots Of Characters
demo@cli1:~/variables$ echo ${#TestVar} 
46

如我们所见,由于我们在引号中放入了一个字符串,我们的变量包含了字符串中的所有空格和字符。然后长度会被正确计算。

那么,如何通过查看变量的长度来检查它是否存在呢?

demo@cli1:~/variables$ echo $VariableThatDoesNotExist
demo@cli1:~/variables$ echo ${#VariableThatDoesNotExist}
0

在这个特定的例子中,长度是0。如果你不习惯这种计算方式,你可能会期望得到一个无效的数字,而不是 shell 报告变量未定义,但bash的做法是不同的。

接下来,我们可以做的是变量的替换。一项非常有用的功能是能够检查一个变量是否有值,如果没有值,就用另一个值替代它。换句话说,在使用一个变量之前,始终确保它有值,因为默认情况下,bash会在变量未定义时返回空结果。以下是一个例子:

demo@cli1:~/variables$ echo ${TEST:-empty}
empty
demo@cli1:~/variables$ echo $TEST
demo@cli1:~/variables$ TEST=full
demo@cli1:~/variables$ echo $TEST
full
demo@cli1:~/variables$ echo ${TEST:-empty}
full

我们在这里做的是测试TEST变量是否有值。如果没有,我们将输出empty字符串。一旦我们的变量被设置,输出将恢复为变量的值。

它是如何工作的……

到目前为止,我们提到的内容只是整个变量的简单替换。更常见的是需要修改变量内部的内容。这可以通过使用特殊的语法来实现。我们可以从变量中提取字符串。这不会改变变量本身;相反,如果我们以后需要这个字符串做某些事情,我们需要将其保存在另一个变量中。我们将使用的语法如下:

${VAR:OFFSET:LENGTH}

VAR是变量名。OFFSETLENGTH是不言自明的——它们基本上意味着从这个精确位置开始提取这么多字符。解释这个功能的最简单方式是给你几个示例:

demo@cli1:~/variables$ echo $TestVar 
Very Long Variable Contains Lots Of Characters
demo@cli1:~/variables$ echo ${TestVar:5:4}
Long
demo@cli1:~/variables$ echo ${TestVar:5:13}
Long Variable
demo@cli1:~/variables$ echo ${TestVar:5}
Long Variable Containg Lots Of Characters
demo@cli1:~/variables$ echo ${TestVar:5:}
demo@cli1:~/variables$ echo ${TestVar:5:-4}
Long Variable ContainsLots Of Charac
demo@cli1:~/variables$ echo ${TestVar:5:-10}
Long Variable Contains Lots Of

请注意,我们也可以使用负数。如果我们这样做,我们将从给定的偏移位置提取字符串的一部分,直到最后的X个字符,X是我们使用的负数。

我们想向你展示的最后一件事是替换变量中的模式。为此,我们使用以下语法:

${VAR/PATTERN/STRING}

与我们讨论提取变量的部分时一样,所做的改变并不是修改变量本身,而只是修改了输出:

demo@cli1:~/variables$ echo ${TestVar/Variable/String}
Very Long String Contains Lots Of Characters
demo@cli1:~/variables$ echo $TestVar 
Very Long Variable Contains Lots Of Characters

另见

变量操作包含更多的可能性。请在此查看它们:

通过外部命令获取变量

有时,在编写脚本时,你需要运行某个命令,并将其输出用于脚本中的其他操作。一种复杂的做法是使用重定向。我们说它是复杂的,因为一旦你使用了重定向,就不能再用它做其他事情了。你可以重定向到不同的文件描述符,但这样会使事情变得更加复杂。

准备就绪

你很快就会发现,区分与 Shell 命令和函数相关的不同内容是很困难的。原因在于有一些基本规则会以不同的方式重复出现。我们将在本书中几次提到它们,不是因为我们喜欢冗余,而是因为你需要完全理解这些规则,才能编写出好的脚本。

这就是为什么 Shell 扩展存在的原因,它有两种方式可以将其应用到我们的任务中。

如何实现…

对此我们可以使用两种语法。一种是将命令及其所有参数用反引号括起来,像这样:command。另一种是使用$(command)。这两种方式得到的结果相同——无论命令的输出是什么,它都会被转换为一组字符串,并代替原始命令使用:

demo@cli1:~/variables$ ls
Backup-20210920.tgz  parameters.sh  quotes2.sh        undetersingle.sh
parameters1.sh       quotes1.sh     undeterdouble.sh  varinname.sh
demo@cli1:~/variables$ echo $(ls)
Backup-20210920.tgz parameters1.sh parameters.sh quotes1.sh quotes2.sh undeterdouble.sh undetersingle.sh varinname.sh
demo@cli1:~/variables$ echo `ls`
Backup-20210920.tgz parameters1.sh parameters.sh quotes1.sh quotes2.sh undeterdouble.sh undetersingle.sh varinname.sh

这只是为了向你展示这种扩展是如何运作的。仅使用一个echo命令是没有意义的;我们将尝试用更复杂的方式:

#!/usr/bin/bash
# testing extension on list of files 
for name  in $(ls) ;            do 
             for exten in .pdf .txt; do 
                          echo "Trying $name$exten"
     done
done

我们所做的是从当前目录获取文件列表,然后使用这个列表尝试不同的扩展名。这种处理文件的方式是你在脚本中最常用的。这样迭代时,可能会有文件或文件中的行:

demo@cli1:~/variables$ bash forexpand.sh 
Trying Backup-20210920.tgz.pdf
Trying Backup-20210920.tgz.txt
Trying forexpand.sh.pdf
Trying forexpand.sh.txt
Trying parameters1.sh.pdf
Trying parameters1.sh.txt
Trying parameters.sh.pdf
Trying parameters.sh.txt
Trying quotes1.sh.pdf
Trying quotes1.sh.txt
Trying quotes2.sh.pdf
Trying quotes2.sh.txt
Trying undeterdouble.sh.pdf
Trying undeterdouble.sh.txt
Trying undetersingle.sh.pdf
Trying undetersingle.sh.txt
Trying varinname.sh.pdf
Trying varinname.sh.txt

这个 Shell 功能很强大,但也有其局限性,主要的限制是括号内命令的输出必须是干净的。这里的“干净”指的是输出必须仅包含可以直接用作参数的信息。考虑到在我们的脚本中做出这个微小的修改:

demo@cli1:~/variables$ cat forexpand.sh 
#!/usr/bin/bash
# testing extension on list of files 
for name  in $(ls -l) ;         do 
               for exten in .pdf .txt; do 
                          echo "Trying $name$exten"
               done
done

我们通过添加-l选项更改了ls命令的两个字符,使其以长格式输出。如果我们现在运行它,得到的结果完全不符合预期:

demo@cli1:~/variables$ bash forexpand.sh 
Trying total.pdf
Trying total.txt
Trying 36.pdf
Trying 36.txt
Trying -rw-rw-r--.pdf
Trying -rw-rw-r--.txt
Trying 1.pdf
Trying 1.txt
Trying demo.pdf
Trying demo.txt
Trying demo.pdf
Trying demo.txt
Trying 494.pdf
Trying 494.txt

我们在这里停止了输出。

它是如何工作的…

从一个命令获取信息的方式可能是整个bash脚本中最简单的理解方式。Shell 所做的是执行命令,获取其输出,然后表现得就像它是一个使用空格作为分隔符的长字符串列表。

这也是为什么我们必须特别注意应用程序的输出是什么原因。Shell 无法理解我们希望从中得到什么;它只是解析它所看到的内容,并将空格当作分隔符。接下来会发生什么完全取决于你——你将这个表达式嵌入的命令可能会完全不同地处理最终结果。

另见

第十二章:使用参数和函数

每当我们尝试用任何编程语言编写任何类型的应用程序或脚本时,我们应该始终尽量使我们的代码模块化,并且容易维护。在创建脚本的这一方面,帮助我们很多的概念是函数

本章将涵盖以下食谱:

  • 在 shell 脚本中使用自定义函数

  • 将参数传递给函数

  • 局部和全局变量

  • 处理函数的返回值

  • 将外部函数加载到 shell 脚本中

  • 通过函数实现常用过程

技术要求

对于这些食谱,我们将使用一台 Linux 机器。我们可以使用任何cli1虚拟机,因为它是最方便使用的,因为它仅是命令行界面CLI)机器。所以,总的来说,我们需要以下内容:

  • 安装了 Linux 的虚拟机——任何发行版(在我们的案例中,将使用Ubuntu 20.02)。

  • 花点时间消化使用 VI(m)编辑器的复杂性。Nano 更简单,因此它会更容易学习。

所以,启动你的虚拟机,开始吧!

在 shell 脚本中使用自定义函数

到目前为止,我们所做的只是创建非常简单的脚本,最多只有几个命令。这将是你大多数脚本的样子,因为通过脚本解决的许多问题都是简单地消除重复的任务。在本章中,我们将通过函数来创建脚本中的代码模块。它们的主要目的是避免脚本中重复的代码块,进一步简化脚本本身。

准备工作

说到函数,Bash 有点奇怪。你可能从其他语言中了解的函数,在bash中看起来相似,但又完全不同。我们将从如何定义函数开始。为了让事情从一开始就变得复杂,bash使用了两种非常相似的表示方法,一种看起来更像是你在其他语言中会看到的,另一种则更符合bash的语法规则。

在我们提到它们之前,请记住,函数的定义在功能或其他方面没有任何区别——我们可以使用它们中的任何一个,结果是完全相同的。

第一个定义的语法看起来像是你在任何编程语言中都会看到的。没有关键字——我们只是指定函数的名称,后跟两个普通括号,然后在大括号中定义构成函数的命令块。

然而,bash和几乎所有其他编程语言之间有一个很大的区别。通常,在任何语言中,括号用于将参数传递给函数。而在bash中,它们始终是空的——它们唯一的作用是定义一个函数。参数是通过完全不同的方式传递的:

function_name () {
<commands>
}

定义函数的另一种方式更符合bash的常规方式。这里有一个保留字function;因此,为了定义一个函数,我们只需这样做:

function function_name {
<commands>
}

这个版本更可能提醒你,参数是以不同的方式提供的,但这可能是两者之间唯一的区别。

函数必须在使用之前定义。这是完全合乎逻辑的,因为 Shell 是逐行执行每条命令的,要理解一条命令,必须先定义它是内部命令、外部命令还是函数。与其他一些编程语言不同,参数和返回值不会预先定义——或者更准确地说,根本不定义。

如何做到这一点……

和往常一样,我们将从一个hello world脚本开始,但会做一些小小的改变。我们将在一个函数内使用echo命令,并且脚本的主要部分将运行这个函数。我们还将创建一个函数的替代版本,旨在展示两种定义函数的方式是等效的。

在这个脚本中有几点需要注意——当我们定义函数时,并没有唯一正确的方式;两种方式都可以使用,但它们的工作方式不同。我们倾向于使用明确包含function关键字的格式,因为这样可以立即引起注意,表明这是一种函数定义,但这只是我们的个人偏好——你可以使用任何你喜欢的格式:

#!/bin/bash
# Hello World done by a function
function HelloWorld {
    echo Hello World!
}
HelloWorld_alternate () {
    echo Hello World!
}
#now we call the functions
HelloWorld
HelloWorld_alternate

当我们运行脚本时,我们可以看到两个函数的表现完全相同:

demo@cli1:~/scripting$ bash functions.sh 
Hello World!
Hello World!

现在,我们将创建一个更有意义的示例。许多脚本都需要你将内容输出到屏幕或文件中。输出的某些部分会反复出现——这是函数特别适合的任务:

#!/bin/bash
function PrintHeader {
    echo -----------------------
    echo Header of some sort
    echo -----------------------
}
echo In order to show how this looks like
echo we are going to print a header
PrintHeader
echo And once again
PrintHeader
echo That was it.
demo@cli1:~/scripting$ bash function.sh 
In order to show how this looks like
we are going to print a header
-----------------------
Header of some sort
-----------------------
And once again
-----------------------
Header of some sort
-----------------------
That was it.

我们的函数所做的工作是为输出创建一个头部。当我们学习如何向函数传递参数时,我们将经常使用这个技巧,特别是当我们需要将格式化的文本输出到日志中,或者当我们有一大块文本,并且需要填入几个变量时。

它是如何工作的……

函数是bash中重复执行的代码块,每当我们在脚本中引用它们时,它们就会被重新执行。它们的主要目的是使脚本更易读、更易调试。使用函数的另一个原因是:避免代码错误。如果我们需要在脚本的不同部分重用某些代码,我们可以直接复制和粘贴,但这可能会引入错误,导致脚本出现 bug。

另见

向函数传递参数

我们通过展示一个简单的脚本来开始演示函数的样子,这是我们能创建的最简单的脚本。我们仍然没有定义如何函数进行交互,也不知道如何给函数传递参数或参数并获得返回结果。在这个配方中,我们将解决这个问题。

准备工作

既然我们提到了参数,我们需要稍微谈一下它们。bash在函数中的参数处理与在脚本本身中处理参数相同——参数在函数块内部变成局部变量。为了返回一个值,我们几乎是采用与处理整个脚本时相同的方式——我们仅仅从函数块中返回一个值,然后在主脚本体内读取它。

记得我们说过你可以引用最初调用脚本时传递给脚本的参数吗?我们使用了名为$1$2$3等的变量来获取命令行中的第一个、第二个、第三个等参数吗?在函数中也是如此。此时,我们使用与引用传递给函数的参数时相同的变量名。

如何实现…

为了向一个简单的函数发送两个参数并显示它们,我们可以使用类似下面的方式:

#!/bin/bash
#passing arguments to a function
function output {
     echo Parameters you passed are $1 and $2
}
output First Second

当我们尝试运行这个脚本时,参数会按我们预期的顺序逐个传递,然后我们的函数会输出它们:

demo@cli1:~/scripting$ bash functionarg.sh 
Parameters you passed are First and Second

你可能会想知道我们的脚本如何处理传递给脚本的参数,与我们传递给函数的参数相比有何不同。简短的回答是,名为$1等的变量具有函数局部的值,并且由我们传递给函数的参数定义。在函数代码块外部,这些变量的值则是传递给脚本的参数。详细的答案将在下一个配方中说明,这涉及局部变量和全局变量的概念。使用参数其实就是声明局部变量的一种特殊情况;我们传递的参数会在函数内变成局部变量:

#!/bin/bash
#passing arguments to a function
function output {
    echo Parameters you passed are $1 and $2
}
#we are going to take input arguments of the script itself and #reverse them
output $2 $1

我们更改参数顺序的原因是为了展示参数传递给函数的顺序,并确保我们不会在函数中使用传递给脚本的参数,因为它们的名字相同。这个脚本将从命令行获取两个参数,交换它们的顺序,然后将它们作为参数传递给我们的函数。函数将简单地输出它们:

demo@cli1:~/scripting$ bash functionarg2.sh First Second
Parameters you passed are Second and First

这里发生的事情也正如我们所预期的那样。接下来,我们将检查一个可能让一些人感到困惑的事情。函数是否知道一些参数被传递给了脚本,还是这些参数严格是局部的?为了检查这一点,我们将忽略脚本命令行中的内容,并向函数传递一对硬编码的字符串。如果bash像我们预期的那样工作,我们的脚本将输出这些硬编码的值。如果命名为$1$2的变量被设置为命令行中的值,并且这些值在函数内仍然存在,我们应该能在echo语句中看到这些值。我们将创建一个functionarg3.sh文件,包含以下代码:

#!/bin/bash
#passing arguments to a function
function output {
    echo Parameters you passed are $1 and $2
}
#we are going to ignore input parameters
output Hardcoded Variables

现在,我们将运行它并检查发生了什么:

demo@cli1:~/scripting$ bash functionarg3sh First Second
Parameters you passed are Hardcoded and Variables

我们可以看到我们的假设是正确的,传递给函数的参数总是优先于其他内容。

我们接下来要做的是展示如何使用函数处理简单的操作。关于可以对变量执行的操作,我们在本书的其他部分已有涉及,但在这里,我们将使用一个之前没有用过的例子。我们将简单地将命令行中的两个参数相加。

为了做到这一点,我们将从命令行将参数传递给函数,然后使用echo输出计算结果。用于获取结果的函数部分也非常有趣,因为它提醒我们,必须显式使用一个函数来加两个数字。否则,如果我们尝试将变量相加,最终会得到一个字符串——像这样:

demo@cli1:~/scripting$ a=1
demo@cli1:~/scripting$ b=2
demo@cli1:~/scripting$ echo $a+$b
1+2
demo@cli1:~/scripting$ echo $(($a+$b))
3

这是最终版本,已被纳入我们的脚本中:

#!/bin/bash
#Doing some maths
function simplemath {
add=$(($1+$2))
echo $add is the result of addition
}
#we are going to take input arguments and pass them all the way
simplemath $1 $2

请注意,在这个例子中,我们在函数内部使用一个新变量来加数字,然后将该变量的值作为结果输出。这比直接在输出中进行操作要好得多——使用这些临时变量的代码总是比试图查找和理解嵌入到输出字符串中的变量更容易阅读和理解。

它是如何工作的……

接下来我们想展示的是一个很有趣的小功能,这个功能在大多数编程语言中并不常见。由于bash在函数中处理参数的方式与处理脚本中的参数相同,并且使用相同的逻辑将这些参数转化为函数内部的变量,因此我们实际上可以在不预先定义参数数量的情况下向函数传递多个参数。当然,我们的函数需要能够理解类似这样的东西。

参见

本地变量和全局变量

当在脚本中声明任何变量时——或者更广泛地说,任何地方——对于该变量,一个至关重要的属性就是它的作用域。作用域指的是变量值被声明的地方。作用域非常重要,因为如果我们不理解它是如何工作的,就可能在某些情况下得到意外的结果。

准备开始

定义变量的全局作用域是bash的默认行为,不需要我们与之交互。所有定义的变量都是全局变量;它们的值在整个脚本中都是相同的。如果我们通过重新赋值来改变变量的值(记住,对值的操作不会改变值本身),那么这个值会全局变化,旧值将被丢失。

在声明变量时,我们还可以做另一件事,那就是将其声明为局部变量。简单来说,这意味着我们明确告诉bash,我们将在代码的某个有限部分使用这个变量,并且它需要只在这里保存值,而不是在整个脚本中作为全局变量存在。

为什么要声明一个局部变量?有几个原因,其中最重要的是确保我们不会更改任何全局变量的值。如果一个变量与全局变量同名并在局部作用域中声明,bash将创建该变量的另一个实例,并会分别跟踪全局值和局部值。

全局变量和局部变量以及它们是如何工作的,最好的解释方法就是使用一个示例。

如何实现……

我们将使用的脚本展示如何工作的例子,几乎可以在互联网上的每一个示例中找到,或者在任何涉及该主题的书籍中都会出现。这个示例的思路是创建一个全局变量,然后在函数中创建一个与全局变量同名的局部变量。全局变量的值应该与局部变量的值不同,当我们显示这个值时,应该能够看到根据我们引用的是全局变量还是局部变量,值会有所不同:

#!/bin/bash
# First we define global variable
# Value of this variable should be visible in the entire script
VAR1="Global variable"
Function func {
# Now we define local variable with the same name
# as the global one. 
local VAR1="Local variable"
#we then output the value inside the function
echo Inside the function variable has the value of: $VAR1 \
}
echo In the main script before function is executed variable \
has the value of: $VAR1
echo Now calling the function
func
# Value of the global variable shouldn't change
echo returned from function
echo In the main script after function is executed value is: \
$VAR1

如果我们执行这个脚本,我们将看到变量是如何交互的:

demo@cli1:~/scripting$ bash funcglobal.sh
In the main script before function is executed variable has the value of: Global variable
Now calling the function
Inside the function variable has the value of: Local variable
returned from function
In the main script after function is executed value is: Global variable

这是完全预期的——如果存在同名的全局变量和局部变量,局部变量将只在其定义的块中有自己的值;否则,将使用全局值。

我们说过像这样的脚本是常见的示例,但如果我们只定义局部变量会发生什么呢?bash与大多数其他语言不同,因为默认情况下,如果我们错误地引用了未定义的变量,它不会显示错误信息。在调试脚本时,这可能是一个大问题,因为未定义的变量和没有值的已定义变量在我们尝试引用它们时,乍一看它们是完全一样的。

为了展示这一点,我们将对脚本做一个小修改,只需删除第一个变量定义。这将导致我们的全局值变为未定义——只有局部值才有实际的值:

#!/bin/bash
# We are not defining the value for our variable in the global #block
function func {
# Now we define local variable that is not defined globally
# as the global one. 
local VAR1="Local variable"
#we then output the value inside the function
echo Inside the function variable has the value of: $VAR1
}
echo In the main script before function is executed undefined \
variable has the value of: $VAR1
echo Now calling the function
func
# Value of the global variable shouldn't change
echo returned from function
echo In the main script after function is executed undefined \
value is actually: $VAR1 

在任何严格的编程语言中,类似的做法都会产生错误。但在bash中,情况有所不同:

demo@cli1:~/scripting$ bash funcglobal1.sh 
In the main script before function is executed undefined variable has the value of:
Now calling the function
Inside the function variable has the value of: Local variable
returned from function
In the main script after function is executed undefined value is actually:

我们可以看到,脚本并没有报错,而是忽略了变量的值,并用“空值”替换它。正如我们所提到的,虽然我们预期会出现这种行为,但要注意,这可能会导致一些意想不到的后果。另一个在脚本中重要的点是局部值。我们可以看到,局部变量只存在于其定义的代码块中;定义它并不会创建一个全局变量,而且一旦函数或代码块执行完毕,局部值将会丢失。

它是如何工作的……

在脚本中使用全局变量还有一个好处——在函数之间传递值。变量的这一特性是非常有用的,但同时也取决于你个人的编程风格。以这种方式使用全局变量很简单——你只需要在脚本开始时声明一个变量,然后在需要时更改其值。通常,你会在执行特定函数之前赋值,然后在函数执行完毕后读取同一个变量。这样,你的函数只需改变变量,就能给你期望的值。

然而,在这种看似合理的使用全局变量方式中,存在一个大问题。由于你无法确定函数是否按预期执行,并且是否已经到达需要改变变量值的阶段,你根本无法知道值本身是否符合预期。如果函数因为某些原因失败,变量将保持你传给它的值,导致可能出现错误的情况。

我们想说的是,以这种方式使用全局变量应该避免,尽管你可以这样做——正确的方式是通过使用参数并通过一个我们将在下一个示例中讨论的机制来返回值,来处理函数和传递值。

参见

处理函数返回值

我们提到过,可以使用全局变量将值传递给脚本中的函数,并返回结果。这是最糟糕的做法。如果我们需要将某个值传递给函数,使用参数才是正确的方式。我们仍然面临的问题是,如何在函数执行完后获取结果。我们将在本章中解决这个问题。

准备工作

如果没有别的,bash在其使用的语法上是逻辑一致的。之所以提到这一点,是因为当函数返回一个值时,它们使用的机制和脚本返回变量时使用的机制完全相同——即return命令。通过使用这个命令,函数在被调用时可以返回一个值,但这个值的范围只能在0255之间。也有可能设置一个全局变量来返回函数值——例如,如果我们需要返回一个字符串——但尽量避免这样做,因为这会产生难以调试的代码。当你在互联网上浏览函数return语句时,你也可能会遇到一种第三种解决方案,这种方案使用了一种叫做引用传递nameref的技术。这是一个更复杂的解决方案,你需要了解它,但我们故意在这个例子中避免它,因为它只在bash的最新版本(4.3及以上)中有效,这会破坏我们脚本的兼容性和可用性。

如何做到这一点…

我们将向你展示两种返回函数值的方法,从我们认为错误的方法开始。之所以展示一个错误的解决方案,是因为你在不同的脚本中(尤其是从互联网上下载的脚本)经常会遇到这种情况,如果你不了解这种方法,可能一开始会有点困惑,因为变量通常是在函数内部首次定义的,在函数第一次调用之前并不存在:

#!/bin/bash
#Doing some string adding inside a function and returning #values
#function takes two strings and returns them concatenated 
function concatenate {
RESULT=$1$2
}
# calling the function with hardcoded strings
concatenate "First " "and second"
echo $RESULT

我们所做的就是将两个字符串传递给一个函数,函数返回它们连接后的结果。当然,这样做很傻——我们完全可以仅仅通过函数中使用的表达式来完成这个任务。这个例子如此基础,甚至没有使用任何运算符。

我们返回值的方式很重要。通过仅仅赋予一个新值,并因此创建了一个名为RESULT的全局变量,我们得到了我们的字符串,并能够使用echo将其写入屏幕。为什么这会是个问题?

我们已经解释过这个问题了。我们在这里所做的事情是危险的,因为我们无法知道函数是否完成了它必须做的事情。我们唯一拥有的就是一个名为RESULT的变量,它可能包含我们期望的值。在这个简单的例子中,我们可以检查结果,但那样会违背有一个专用函数的目的。为了稍微减少不确定性,我们可以做一个小技巧。

请考虑对脚本做出这样的修改:

#!/bin/bash
#Doing some string adding inside a function and returning \
values
#function takes two strings and returns them concatenated 
function concatenate {
RESULT=$1$2
}
concatenate "First " "and second"
[ $? -eq 0 ] && echo $RESULT || echo Function did not finish!

我们所做的是创建一个条件输出。条件本身的格式你现在应该已经熟悉——我们使用逻辑函数来打印函数的结果,或者打印出函数没有正确执行的消息。提醒一下,当我们介绍逻辑运算符时,我们脚本在最后一行做的事情是检查一个名为$?的变量的值。如果变量值等于0,我们打印函数的结果。如果值不是零,我们输出错误信息,因为我们知道函数的命令块内部某处出了问题。

我们之所以能做到这一点很简单——我们已经提到过,函数与脚本之间的通信方式与脚本与操作系统其他部分的通信方式相同。这包括传递参数以及能够使用return语句返回值,还意味着bash在函数结束时会设置一个名为?的变量。当我们用它来理解脚本发生了什么(我们已经解释过),如果我们检查这个变量并且它的值为0,这意味着函数正确执行完毕,或者至少最后一条命令正确执行完毕。

这是一个简单的解决方案,针对我们本不应该一开始就创建的问题;只要可能,我们应该使用return来获取我们的值。以下是一个示例:

#!/bin/bash
#simple adding of two numbers
#function takes two numbers and returns result of addition
function simpleadd {
    local RESULT=$(($1+$2))
    return $RESULT
}
#we are going to hardcode two numbers
simpleadd 4 5 
echo $?

如果我们确保数字在0255的范围内,这是一种更好的方式。我们输出函数的结果,操作就像引用正确的变量一样简单。我们还可以检查函数执行后的变量值是否为0,这意味着函数正常工作,然后再输出结果。

另一个你应该知道的事情是,函数可以使用exit命令。通过使用它,你是在告诉bash立即停止函数正在执行的操作并退出函数命令块。在这种情况下,将返回的值是执行exit命令之前最后一条命令的错误级别。

这是一个示例:

#!/bin/bash
#exiting from a function before function finishes
function never {
echo This function has two statements, one will never be \
printed. 
exit
echo This is the message that will never print
}
#here we run the function
never

将要打印的是输出的第一行;由于我们使用了exit语句,第二部分输出将永远不会执行:

demo@cli1:~/scripting$ bash funcreturn3.sh 
This function has two statements, one will never be printed.

它是如何工作的…

所有这一切存在的主要原因是为了让你更紧密地控制函数以及更一般来说,脚本中命令执行的顺序。bash在处理这个话题时非常基础,这也正是它的多功能性所在。为了使用函数,你只需要了解脚本中参数的工作方式——所有的变量名和背后的逻辑在应用于函数时是相同的。

另请参阅

将外部函数加载到 shell 脚本中

当你需要创建更复杂的 shell 脚本时,常见的问题之一就是如何将其他代码包含到脚本中。一旦你开始编写脚本,你通常会创建一些常用的函数——比如打开与服务器的连接,执行一些操作,或者其他类似的操作。

有时候,为了避免每次脚本被调用时都需要输入变量,你的脚本必须使用许多由用户预先定义的变量,这些变量需要在脚本运行前就设置好。

当然,解决这两个问题的方法可以是将相关代码直接复制并粘贴到脚本中,并让用户在运行脚本之前编辑它。我们不应该这样做的原因是,每次复制和粘贴内容时,我们都会创建代码的新版本。如果我们在代码中发现错误,就需要在所有重复使用这段代码的脚本中进行修复。幸运的是,解决这个问题有一个更好的方法,那就是将脚本拆分成不同的文件,然后在需要时引用它们。

准备工作

这个方案将在两种不一定互斥的场景中非常有用。我们已经简要提到过这两种场景。

第一个方案是使用外部函数。通常,在创建脚本时,所有内容都会放在一个文件中。所有函数、定义、变量和命令都会集中在一个地方。如果我们只是在创建专门完成特定任务的脚本,这种做法通常完全没问题。

更常见的是,我们需要解决一些之前在其他解决方案中已经处理过的问题。在这种情况下,我们通常已经有一些可以被认为是解决方案一部分的现成函数。

在复杂的脚本解决方案中,你可能会使用一些通用的功能,比如菜单、界面、页眉、页脚、日志等,这些内容在你创建的每个脚本中都是完全相同的。

另一个非常常见的问题是需要用户进行设置的配置。大型脚本可能会包含服务器名称、端口、文件名、用户以及脚本运行所需的其他许多信息。你可以将这些信息作为命令行参数传递,但这种做法看起来不太好,而且会使脚本容易出错,因为用户每次运行脚本时都必须手动输入大量内容。

在这种情况下,一种常见做法是将所有内容作为变量放在一个文件中,然后让用户在脚本的安装过程中编辑此文件。当然,你也可以将所有内容与脚本本身放在一起,但这几乎肯定会导致某些用户更改他们不应该更改的内容。

一如既往,解决方案是存在的。

如何操作……

bash具有内置的功能,可以将不同的文件包含到脚本中。这个思想很简单——有一个主脚本文件,它作为脚本本身执行。在该文件中,有一些命令告诉bash包含不同的文件和脚本。

和其他事情一样,尽管这是一件非常简单的事情,但你需要了解一些事情。我们首先要使用的命令是source。在我们解释所有内容之前,我们将创建两个脚本。第一个是用户将要运行的脚本,它看起来是这样的。将文件命名为main.sh

#!/bin/bash
#first we are going to output some environment variables and #define a few of our own
echo Shell level before we include $SHLVL
echo PWD value before include $PWD
TESTVAR='main'
echo Shell level after include $SHLVL
echo PWD value after include $PWD
echo Variable value after include $TESTVAR

我们将运行它,只是为了看看脚本的行为:

demo@cli1:~/includes$ bash main.sh 
Shell level before include 2
PWD value before include /home/demo/includes
Shell level after include 2
PWD value after include /home/demo/includes
Variable value after include main

结果正如我们预期的那样——我们当前的目录与我们运行脚本时所在的目录相同,并且$SHLVL2,因为我们在与命令行(lvl1)不同的独立 shell (lvl2) 中运行了脚本。我们的变量定义为main,并且它没有发生变化。

现在,我们将创建第二个脚本并命名为auxscript.sh

echo Inside included file Shell level is $SHLVL
echo Inside included PWD is $PWD
echo Before we changed it variable had a value of: $TESTVAR
TESTVAR='AUX'
echo After we changed it variable has a value of: $TESTVAR

这里最大的问题是我们没有在脚本开始时使用通常的#!/bin/bash标记。这是故意的,因为这个文件是为了被包含在其他脚本中,而不是独立运行的。

之后,我们做的事情大致与主脚本中一样,输出一些文本和数值,并且操作变量。

我们更改变量的原因是为了展示在文件的包含部分实际发生了什么,以及它是如何与主脚本主体交互的。

现在,我们将更改main.sh脚本并只添加一行:

#!/bin/bash
#first we are going to output some environment variables and #define a few of our own
echo Shell level before we include $SHLVL
echo PWD value before include $PWD
TESTVAR='main'
source auxscript.sh
echo Shell level after include $SHLVL
echo PWD value after include $PWD
echo Variable value after include $TESTVAR

现在最主要的事情是再次运行main.sh脚本:

demo@cli1:~/includes$ bash main.sh 
Shell level before include 2
PWD value before include /home/demo/includes
Inside included file Shell level is 2
Inside included PWD is /home/demo/includes
Before we changed it variable had a value of: main
After we changed it variable has a value of: AUX
Shell level after include 2
PWD value after include /home/demo/includes
Variable value after include AUX

这里发生了一些有趣的事情。我们可以看到,环境变量没有发生变化,但测试变量却发生了变化。

我们将解释这一点,但在此之前,我们要做一件事——我们将使用另一个命令,而不是source。很多刚接触脚本的人往往会将我们刚刚展示的source命令与执行脚本混淆。毕竟,我们是在脚本中包含一个脚本,所以这些东西看起来很相似。我们将尝试在我们的例子中实现这一点。

我们将在主脚本中更改一行,但我们的aux脚本保持不变。我们可以用多种方式做到这一点,但我们故意选择了运行bash并显式地运行第二个脚本。原因很简单——其他方法要求我们的脚本设置执行位(这是我们没有做的),或者依赖于类似于直接运行exec命令的较不易理解的版本:

#!/bin/bash
#first we are going to output some environment variables and #define a few of our own
echo Shell level before include $SHLVL
echo PWD value before include $PWD
TESTVAR='main'
bash auxscript.sh
echo Shell level after include $SHLVL
echo PWD value after include $PWD
echo Variable value after include $TESTVAR

我们唯一改变的就是,我们没有包含脚本——我们在执行它:

demo@cli1:~/includes$ bash mainexec.sh 
Shell level before include 2
PWD value before include /home/demo/includes
Inside included file Shell level is 3
Inside included PWD is /home/demo/includes
Before we changed it variable had a value of:
After we changed it variable has a value of: AUX
Shell level after include 2
PWD value after include /home/demo/includes
Variable value after include main

然而,我们可以看到,这个小小的变化在脚本的工作方式上产生了巨大的不同。

它是如何工作的…

我们做的最后一个例子需要很多解释,我们需要从bash的工作方式开始。

使用source命令告诉bash去查找一个文件并在我们引用该文件的地方使用它的内容。bash做的事情很简单——它只是用我们指向的整个文件替换这一行。所有的行都会被插入,然后像我们将整个文件复制粘贴到原始脚本中一样执行。

这就是为什么在我们的第一个示例中什么都没有改变的原因。我们的脚本从主文件开始运行,继续从辅助文件运行命令,然后返回主文件执行接下来的命令。

当我们将source替换为bash时,创建了一个完全不同的场景。通过在脚本中使用bash命令,我们告诉 shell 启动另一个实例并执行我们引用的脚本。这意味着会创建整个环境,除非我们明确指定在新环境中需要一些变量,否则这些变量不会被导出。

这也是我们的$SHLVL变量增加的原因——因为我们在脚本中调用了另一个 shell,shell 级别必须提高。

我们的测试变量消失了,因为我们没有导出它,因此它在被设置之前没有值,而且由于我们的环境仅仅是为了运行这几行代码而创建的,因此当我们调用的脚本结束时,同样的变量也消失了。

记住,执行脚本和引用脚本是完全不同的事情,当你不确定时,思考你到底想做什么。如果你想像常规命令一样执行脚本中的某个内容,使用bashexec。如果你需要从另一个脚本复制粘贴代码,使用source

在完成本教程之前,我们还需要提到函数。包含函数的方式与包含任何其他脚本部分完全相同,有一个重要的区别。为了让代码正常工作,你必须在脚本的开头或在尝试使用这些函数之前包含函数。如果没有这样做,结果会是和根本没有定义函数一样,导致错误。

另见

通过函数实现常用程序

到目前为止,我们已经创建了很多不同且非常简单的脚本,这些脚本或多或少使用了echo和一些命令,仅仅是为了展示bash中某个特定功能的工作原理。在本篇教程中,我们将给你一些关于如何使用我们迄今为止学到的内容的想法。

准备工作

我们将创建一个小脚本,展示如何轻松自动化任何系统中的日常任务。这里的重点不是展示每个可能的任务,而是教你如何解决最常见的问题。

如何做……

在我们开始编写脚本之前,我们需要回到之前的教程,回顾如何开始编写脚本。我们讨论的是当我们创建和运行这个脚本时,我们所做的前提条件和假设。

每个脚本都会有其特定的前提条件。这些通常是脚本运行所需的东西清单——可能是需要不同的包,或者是需要满足某些条件才能使脚本正常工作,例如一个正常工作的数据库或正常工作的 Web 服务器。

对于这个脚本,我们假设你已经安装了一个叫做curl的包,并且你已经连接到了互联网。

现在,对于我们所依赖的假设,这个脚本包含了一些会影响系统用户和组的命令。这意味着,为了使脚本的这一部分正常工作,我们绝对需要脚本由root用户或拥有管理员权限的其他用户来运行。

该脚本还假设了很多关于用户的信息,并且只检查我们是否有足够的参数,而不是检查提供的参数质量。这意味着用户可以给脚本提供一个数字而不是字符串,脚本会把它当作有效的参数。我们将在开始解析脚本时解释如何处理这个问题。

作为负责编写脚本的人,你的工作之一是要清楚这些前提条件,并确保处理好这些条件。有两种方法可以做到这一点——第一种是以某种形式在文档中说明你的脚本期望的条件,这个文档会跟随脚本一起发布。

你还可以做的另一件事(我们强烈推荐这样做)是检查你能想到的每一种可能的条件,如果出现问题,要么打印错误信息并停止脚本,要么,如果你知道问题所在,尝试修复它。

你可以在脚本中解决的一些问题示例是管理员权限——你的脚本可以测试是否能够运行,如果权限不足,它会要求用户提升权限。你还可以测试系统中是否存在特定的包,如果你看到某个非标准命令失败,可以进行检查。

最终,如何解决脚本中的问题将取决于你和你的技能水平,但在你做任何事之前,记住,编写脚本时你需要测试一切。

现在,这是实际的脚本:

#!/bin/bash
#shell script that automates common tasks
function rsyn {
rsync -avzh $1 $2 
}
function usage {
echo In order to use this script you can:
echo "$0 copy <source> <destination> to copy files from source \
to destination"
echo "$0 newuser <name> to createuser with the username \
<username>"
echo "$0 group <username> <group> to add user to a group"
echo "$0 weather to check local weather"
echo "$0 weather <city> to check weather in some city on earth"
echo "$0 help for this help"
}
if [ "$1" != "" ] 
            Then
    case $1 in
         help)
            Usage
            Exit
            ;;
        copy)
                 if [ "$2" != "" && "$3" != "" ]
                 then 
            rsyn $2 $3
          fi
            ;;

              group)
            if [ "$2" != "" && "$3" != "" ]
                  then 
                       usermod -a -G $3 $2
            fi
                                        ;;
              newuser) 
                  if [ "$2" != "" ]
                  then
                               useradd $2
                          fi
                          ;;
               weather)
                  if [ "$2" != "" ]
                          then 
                                curl wttr.in/$2
                          else 
                                curl wttr.in
                  fi
                  ;;
               *)
            echo "ERROR: unknown parameter $1\""
            usage
            exit 1
            ;;
    esac
                 else
            Usage
fi

它是如何工作的……

这个脚本需要一些解释,我们故意没有对其进行注释,原因有两个。一个是注释会使得脚本变得过长,导致需要打印太多页,另一个是为了能够在这次解释中逐块讲解,而不会因为短小的注释打断你的思路。话虽如此,记住,脚本中一定要加上注释!

所以,我们的脚本从一个函数开始。考虑到这个函数只有一行,你可能会惊讶于我们决定把它拆成一个函数,但我们是有目的的。

一些命令,如rsynctar,例如,有一长串常用的开关。在创建脚本时,有时将其中一些命令放入函数中,这样就可以在不记住每个开关的情况下调用该函数。对于需要许多预设参数的命令,这种方法也适用。将所有这些参数放入一个函数中,然后只用最基本的参数调用该函数。

我们将usage(用法)放入函数中,它是一个帮助用户运行脚本的文本块,提供足够的信息,让他们无需其他帮助。

如果可能,请为您的脚本编写更详细的帮助页面。你甚至可以创建一个阅读帮助页面以获取更多信息

在这个函数中,我们使用了$0位置参数来输出脚本的名称。当你在提供脚本使用示例时,使用这种方式来为用户提供帮助。避免硬编码脚本名称,因为你不知道用户是否更改了脚本的文件名,而硬编码的名称可能会让他们完全困惑。

此外,如果你在文本中使用任何特殊字符,请使用引号;否则,可能会遇到错误,或者更糟糕的是,出现完全无法解释的错误。

脚本的下一部分处理每个单独的命令。在创建像这样的命令行工具时,事先决定你是要创建一个使用命令(如这个命令)、开关(如-h—something)还是某种简单文本界面的工具。这些方法各有优缺点,但从本质上讲,我们选择的格式通常用于可以一次执行多个任务的脚本。开关允许你为任务引入多个参数,而用户界面UIs)则面向没有经验的用户。此外,请记住,您的脚本可能会在其他脚本中使用,因此要避免使用会阻碍这一过程的界面。

case语句中,我们检查了几个事项。首先,我们测试第一个参数是否是有效命令。然后,我们检查给定命令是否有足够的参数,以确保可以无误运行。即便如此,我们仍然没有进行足够的参数有效性测试。在阅读时,尝试添加一些其他的合理性检查,比如参数是否有效用户是否输入了包含空格的有效参数并将其分成了多个字符串,等等。

我们不会详细讨论单个命令;我们只会提到那个看起来完全不合适的命令。我们当然在说的是weather命令,它为你提供所在城市的天气报告:

https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_12.1_B16269.jpg

图 12.1 – wttr.in 是许多有趣的在线服务之一

互联网充满了有用的服务,而wttr.in绝对是其中之一。如果你访问wttr.in或者运行curl wttr.in,系统会给你一个关于你所在城市的天气报告。这里面有一些深奥的技术——系统会根据你的互联网协议IP)地址来尝试猜测你的位置,甚至在进行这个猜测的过程中,几乎会立即提供一个相当准确的天气预报。

我们故意选择这个例子来展示——如果你在wttr.in链接后添加一个城市名,系统会显示该城市的天气,甚至会尝试猜测准确的城市名称。像这样的在线服务有不少,可以通过命令行访问,使用其中一些可以让你以最不寻常的方式扩展你的脚本。

在这个过程的最后,注意我们正在检查脚本以三种不同方式调用时可能出现的不同错误。始终尝试预测这类错误。

另见

如果你在命令行中做任何操作,下面的网页是必看的:

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值