Linux-Shell-脚本学习手册-全-

Linux Shell 脚本学习手册(全)

原文:zh.annas-archive.org/md5/77969218787D4338964B84D125FE6927

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Shell 脚本允许我们以链式编程命令,并让系统将它们作为脚本事件执行,就像批处理文件一样。这本书将从 Linux 和 Bash shell 脚本的概述开始,然后迅速深入帮助你设置本地环境,然后介绍用于编写 shell 脚本的工具。接下来的一系列章节将重点帮助你了解 Linux 的内部机制以及 Bash 为用户提供的内容。很快,你将开始沿着命令行进行旅程。你现在将开始编写实际的脚本而不是命令,并将介绍脚本的实际应用。最后一系列章节将深入探讨 shell 脚本中的更高级主题。这些高级主题将把你从简单的脚本带到现实世界中存在的可重用的有价值的程序。最后一章将为你提供一些方便的技巧和窍门,以及包含最有趣的标志和选项的备忘单,涉及最常用的命令。

完成这本书后,你应该对开始自己的 shell 脚本项目感到自信,无论之前的任务看起来多么简单或复杂。我们的目标是教会你如何编写脚本和需要考虑什么,以补充你在日常脚本挑战中可以使用的清晰模式。

这本书是为谁写的

这本书面向新的和现有的 Linux 系统管理员,以及对自动化管理任务感兴趣的 Windows 系统管理员或开发人员。不需要先前的 shell 脚本经验,但如果你有一些经验,这本书将迅速让你成为专家。读者应该对命令行有(非常)基本的理解。

这本书涵盖了什么

第一章,介绍,为你做好了本书的余下部分。在 Linux 和 Bash 的一些背景知识的帮助下,你应该更能够理解为什么以及如何 shell 脚本可以为你提供明显的好处。

第二章,设置本地环境,帮助你准备好本地机器,以便在本书的其余部分中进行示例和练习。你将学会如何在本地机器上使用 VirtualBox 设置 Ubuntu 18.04 Linux 虚拟机。这个虚拟机将用于在本书中编写、运行和调试命令和脚本。

第三章,选择合适的工具,介绍了用于编写 shell 脚本的工具。将描述两种不同类型的工具:IDE 编辑器(Atom,Notepad++)和基于终端的编辑器(vim 和 nano)。你将被鼓励最初在 IDE 中编写脚本,并在基于终端的编辑器中排除故障,以最接近真实世界的使用。

第四章,Linux 文件系统,通过探索前几章创建的虚拟机,介绍了 Linux 文件系统的组织方式。你将通过执行第一个命令行操作(如cdpwdls)来实现这一点。将提供有关不同结构的上下文,以便你在编写脚本时使用这些信息。最重要的是,将解释一切都是文件的概念。

第五章,理解 Linux 权限方案,让你熟悉 Linux 下的权限,再次通过探索虚拟机。诸如sudochmodchown之类的命令将被用来交互式地学习文件和目录权限。在本章中获得的技能将在 shell 脚本中大量使用,因此你必须接触到命令的成功执行以及失败消息。

第六章,文件操作,向您介绍了最相关的文件操作命令,包括这些命令的最常用标志和修饰符。这将通过虚拟机内的命令实现。

第七章,Hello World!,在撰写脚本时,教育您提前思考和养成良好习惯。在本章中,您将编写您的第一个实际的 shell 脚本。

第八章,变量和用户输入,向您介绍了变量和用户输入。您将看到 Bash 如何使用参数,以及参数和参数之间的区别。用户输入将被处理并用于在您的脚本中生成新函数。最后,将澄清和讨论交互式和非交互式脚本之间的区别。

第九章,错误检查和处理,使您熟悉(用户)输入,错误检查和处理。将用户输入引入脚本中很可能会导致更多的错误,除非脚本专门处理用户提交的不正确或意外的输入可能性。您将学习如何最好地处理这个问题。

第十章,正则表达式,使您熟悉常用于 shell 脚本的正则表达式。将介绍这些正则表达式的最常见模式和用法。本章还将介绍sed的基本用法,以补充正则表达式的解释。

第十一章,条件测试和脚本循环,讨论了在 Bash 中使用的不同类型的循环和相关控制结构。

第十二章,在脚本中使用管道和重定向,向您介绍了 Linux 上的重定向。本章将从基本的输入/输出重定向开始,然后继续流重定向和管道。

第十三章,函数,向您介绍了函数。函数将被呈现为代码块,这些代码块以这样的方式组合在一起,以便它们可以被重复使用,通常使用不同的参数,以产生稍微不同的最终结果。您将学会理解重用代码的好处,并相应地规划脚本。

第十四章,调度和日志记录,教您如何安排脚本,并确保这些计划的脚本执行它们旨在执行的任务,方法是使用 crontab 和at命令,以及适当的日志记录。

第十五章,使用 getopts 解析 Bash 脚本参数,帮助您通过添加标志而不是位置参数来改进脚本,从而使脚本更容易使用。

第十六章,Bash 参数替换和扩展,展示了如何通过参数扩展、替换和变量操作来优化先前在早期脚本中使用的模式。

第十七章,Cheat Sheet 中的技巧和技巧,为您提供了一些方便的技巧和技巧,这些技巧和技巧不一定在 Bash 脚本中使用,但在终端上工作时非常方便。对于最常用的命令,将提供包含最有趣的标志和选项的备忘单,以便您在编写脚本时将本章用作参考。

为了充分利用本书

您需要一个 Ubuntu 18.04 Linux 虚拟机来跟随本书。我们将在第二章中指导您设置这一点。只有当您跟随所有代码示例时,您才能真正学会 shell 脚本编写。整本书都是以此为出发点编写的,所以请务必遵循这些建议!

下载示例代码文件

您可以从您在www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便文件直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

一旦文件下载完成,请确保您使用最新版本的解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learn-Linux-Shell-Scripting-Fundamentals-of-Bash-4.4。如果代码有更新,将会在现有的 GitHub 仓库上进行更新。

我们还有其他代码包,来自我们丰富的书籍和视频目录,可在github.com/PacktPublishing/上找到。快去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781788995597_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“让我们试着将/tmp/目录复制到我们的home目录中。”

代码块设置如下:

#!/bin/bash

echo "Hello World!"

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

reader@ubuntu:~/scripts/chapter_10$ grep 'use' grep-file.txt 
We can use this regular file for testing grep.
but in the USA they use color (and realize)!

任何命令行输入或输出都写成如下形式:

reader@ubuntu:~/scripts/chapter_10$ grep 'e.e' character-class.txt 
eee
e2e
e e

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种形式出现在文本中。这是一个例子:“点击安装按钮,然后观看安装过程。”

警告或重要说明会出现在这样的形式中。

提示和技巧会以这种形式出现。

联系我们

我们始终欢迎读者的反馈。

一般反馈:发送电子邮件至feedback@packtpub.com,并在主题中提及书名。如果您对本书的任何方面有疑问,请发送电子邮件至questions@packtpub.com与我们联系。

勘误:尽管我们已经非常注意确保内容的准确性,但错误是难免的。如果您在本书中发现错误,我们将不胜感激。请访问www.packtpub.com/submit-errata,选择您的书,点击勘误提交表格链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何形式的非法副本,我们将不胜感激,如果您能向我们提供位置地址或网站名称。请通过copyright@packtpub.com与我们联系,并附上材料链接。

如果您有兴趣成为作者:如果您在某个专题上有专业知识,并且有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。阅读并使用本书后,为什么不在购买它的网站上留下评论呢?潜在的读者可以看到并使用您的客观意见来做出购买决定,我们在 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问 packtpub.com

免责声明

本书中的信息仅供以合乎道德的方式使用。如果您没有设备所有者的书面许可,请不要使用本书中的任何信息。如果您进行非法行为,您可能会被逮捕并依法起诉。Packt Publishing 不对您滥用本书中的任何信息承担任何责任。本书中的信息必须在获得适当人员的书面授权的测试环境中使用。

第一章:介绍

在我们开始编写 shell 脚本之前,我们需要了解一些关于我们最相关的两个组件的背景:LinuxBash。我们将解释 Linux 和 Bash,探讨这两种技术背后的历史,并讨论它们的当前状态。

本章将涵盖以下主题:

  • 什么是 Linux?

  • 什么是 Bash?

什么是 Linux?

Linux 是一个通用术语,指的是基于 Linux 内核的不同开源操作系统。Linux 内核最初由 Linus Torvalds 于 1991 年创建,并于 1996 年开源。内核是一种设计用于在低级硬件(如处理器、内存和输入/输出设备)与高级软件(如操作系统)之间充当中间层的软件。除了 Linux 内核外,大多数 Linux 操作系统在很大程度上依赖 GNU 项目实用工具;例如,Bash shell 是一个 GNU 程序。因此,一些人将 Linux 操作系统称为 GNU/Linux。GNU 项目,其中 GNU 代表GNU's Not Unix!(一个递归缩写),是一个自由软件集合,其中大部分在大多数 Linux 发行版中都可以找到。这个集合包括许多工具,还有一个名为 GNU HURD 的替代内核(并不像 Linux 内核那样广泛使用)。

为什么我们需要一个内核?由于内核位于硬件和操作系统之间,它提供了与硬件交互的抽象。这就是为什么 Linux 生态系统变得如此庞大:内核可以自由使用,并且它处理了许多类型硬件的低级操作。因此,操作系统的创建者可以花时间为用户制作易于使用、美观的体验,而不必担心用户的图片将如何被写入连接到系统的物理磁盘。

Linux 内核是所谓的类 Unix软件。正如你可能猜到的那样,这意味着它类似于最初由 Ken Thompson 和 Dennis Ritchie 在贝尔实验室于 1971 年至 1973 年间创建的原始 Unix 内核。然而,Linux 内核只是基于Unix 原则,并不与 Unix 系统共享代码。著名的 Unix 系统包括 BSD(FreeBSD、OpenBSD 等)和 macOS。

Linux 操作系统广泛用于两个目的之一:作为桌面或作为服务器。作为桌面,Linux 可以替代更常用的 Microsoft Windows 或 macOS。然而,大多数 Linux 使用是用于服务器领域。据估计,目前约 70%的互联网服务器使用 Unix 或类 Unix 操作系统。下次当你浏览新闻、阅读邮件或在你最喜欢的社交媒体网站上滚动时,请记住,你所看到的页面很有可能是由一个或多个 Linux 服务器处理的。

Linux 有许多发行版或版本。大多数 Linux 操作系统属于发行版家族。发行版家族基于一个共同的祖先,并且通常使用相同的软件包管理工具。其中一个较为知名的 Linux 发行版Ubuntu基于Debian发行版家族。另一个著名的 Linux 发行版Fedora基于Red Hat家族。其他值得注意的发行版家族包括SUSEGentooArch

很多人并不意识到有多少设备在运行 Linux 内核。例如,如今使用最普遍的智能手机操作系统 Android(市场份额约为 85%)使用了修改版的 Linux 内核。许多智能电视、路由器、调制解调器和其他各种嵌入式设备也是如此。如果我们将 Unix 和其他类 Unix 软件包括在内,我们可以肯定地说世界上大多数设备都在运行这些内核!

什么是 Bash?

Linux 系统中最常用的 shell 是Bourne-again shell,或者称为 Bash。Bash shell 基于Bourne shell,也就是sh。但 shell 到底是什么呢?

shell 本质上是一个用户界面。最常见的是指文本界面,也称为命令行界面CLI)。但它被称为shell,是因为它可以被看作是内核周围的外壳;这意味着它不仅适用于 CLI,同样也适用于图形用户界面GUI)。当我们在本书中提到 shell 时,我们指的是 CLI,除非另有说明,我们指的是 Bash shell。

shell 的目的,无论是 CLI 还是 GUI,都是允许用户与系统进行交互。毕竟,一个没有交互功能的系统很难被证明存在的,更不用说难以使用了!在这种情况下,交互意味着许多事情:在键盘上输入会在屏幕上显示字母,移动鼠标会改变屏幕上光标的位置,给出删除文件的命令(无论是使用 CLI 还是 GUI)都会从磁盘中删除字节,等等。

在 Unix 和计算机的早期,没有 GUI 可用,因此所有工作都是通过 CLI 执行的。要连接到运行中的机器上的 shell,通常会使用视频终端:通常这是一个非常简单的监视器,配合键盘使用,通过 RS-232 串行电缆连接。在这个视频终端上输入的命令由运行在 Unix 机器上的 shell 处理。

幸运的是,自第一台计算机以来,事情发生了很大的变化。今天,我们不再使用专用硬件连接到 shell。一个运行在 GUI 中的软件,即终端仿真器,用于与 shell 进行交互。让我们快速看一下通过终端仿真器连接到 Bash shell 的样子:

在前面的屏幕截图中,我们通过安全外壳SSH)协议,使用终端仿真器(GNOME Terminal)连接到了 Linux 虚拟机(我们将在下一章中设置这个),收到了一些有趣的信息:

  • 我们处于 CLI 界面;我们既无法访问鼠标,也不需要鼠标

  • 我们连接到了一个 Ubuntu 机器,但是我们是在另一个操作系统(本例中是 Arch Linux)中运行的

  • 我们在 Ubuntu 18.04 中收到了一条欢迎消息,显示了关于系统的一些常规信息

除了直接使用 Bash shell 与系统进行交互,它还提供了另一个重要的功能:按特定目标顺序执行多个命令,无论是否需要用户交互。这听起来可能很复杂,但实际上非常简单:我们谈论的是Bash 脚本,也就是本书的主题!

总结

在这一章中,您已经了解了 GNU/Linux 操作系统和 Linux 内核,了解了内核的真正含义,以及 Linux 发行版对日常生活产生的巨大影响。您还了解了 shell 是什么,以及最常见的 Linux shell——Bash,既可以用于与 Linux 系统交互,也可以用于编写 shell 脚本。

在下一章中,我们将设置一个本地环境,这将在本书的其余部分中使用,包括示例和练习。

第二章:设置您的本地环境

在上一章中,我们探讨了 Linux 和 Bash 的美妙世界的一些背景知识。由于这是一本实践驱动的书,我们将利用本章来设置一台机器,您可以在其中跟随示例并完成每章末尾的练习。这可以是虚拟机,也可以是物理安装;这取决于您。我们将在本章的第一部分讨论这个问题,然后继续安装 VirtualBox,最后创建一个 Ubuntu 虚拟机。

本章将介绍以下命令:sshexit

本章将涵盖以下主题:

  • 在虚拟机和物理安装之间进行选择

  • 设置 VirtualBox

  • 创建 Ubuntu 虚拟机

技术要求

要完成本章(以及以下章节)中的练习,您需要一台至少拥有 2 GHz CPU 功率、10 GB 硬盘空间和大约 1 GB 可用内存的 PC 或笔记本电脑。在过去 5 年中制造的几乎所有硬件都应该足够。

在虚拟机和物理安装之间进行选择

虚拟机是物理机的仿真。这意味着它在物理机内部运行,而不是直接在硬件上运行。物理机可以直接访问所有硬件,如 CPU、RAM 和其他设备,如鼠标、键盘和显示器。然而,无法在多个物理机之间共享 CPU 或 RAM。虚拟机不能直接访问硬件,而是通过仿真层访问,这意味着资源可以在多个虚拟机之间共享。

因为我们正在讨论 Bash shell 脚本编程,理论上进行何种安装并不重要。只要该安装运行兼容的 Linux 操作系统,并且具有 Bash 4.4 或更高版本,所有练习都应该可以运行。然而,使用虚拟机而不是物理安装有许多优势:

  • 无需删除您当前首选的操作系统,或设置复杂的双启动配置

  • 虚拟机可以进行快照,这允许从关键故障中恢复

  • 您可以在一台机器上运行(许多)不同的操作系统

不幸的是,虚拟机使用也有一些缺点:

  • 因为您在已经运行的操作系统中运行虚拟操作系统,所以与运行裸机安装相比,虚拟化会带来一些开销

  • 由于同时运行多个操作系统,您将需要比裸机安装更多的资源

在我们看来,现代计算机的速度足够快,使得缺点几乎可以忽略不计,而在虚拟机中运行 Linux 所提供的优势非常有帮助。因此,我们将在本章的其余部分中只解释虚拟机设置。如果您对将 Linux 作为物理安装感到足够自信(或者您可能已经在某个地方运行了 Linux!),请随时使用该机器探索本书的其余部分。

您可能在家里有一个树莓派或另一个运行 Linux 的单板计算机,来自以前的项目。虽然这些机器确实运行着 Linux 发行版(Raspbian),但它们可能是在不同的架构上运行的:ARM 而不是 x86。因为这可能会导致意想不到的结果,我们建议本书只使用 x86 设备。

如果您想确保所有示例和练习都能像本书中所示那样工作,请在 VirtualBox 中运行一个 Ubuntu 18.04 LTS 虚拟机,建议的规格为 1 个 CPU、1GB RAM 和 10GB 硬盘:这个设置在本章的其余部分中有描述。即使许多其他类型的部署也应该可以工作,但当练习不起作用时,您可能不希望在发现是由于您的设置引起的之前,花费数小时撞墙。

设置 VirtualBox

要使用虚拟机,我们需要一种称为虚拟化软件的软件。虚拟化软件在主机机器和虚拟机之间管理资源,提供对磁盘的访问,并具有管理所有这些的接口。有两种不同类型的虚拟化软件:类型 1 和类型 2。类型 1 虚拟化软件是所谓的裸机虚拟化软件。这些软件是安装在硬件上而不是像 Linux、macOS 或 Windows 等常规操作系统上的。这些类型的虚拟化软件用于企业服务器、云服务等。在本书中,我们将使用类型 2 虚拟化软件(也称为托管虚拟化软件):这些软件安装在另一个操作系统中,就像一个浏览器一样。

有许多类型 2 的虚拟化软件。在撰写本文时,最受欢迎的选择是 VirtualBox、VMware 工作站播放器,或者 OS 特定的变体,如 Linux 上的 QEMU/KVM,macOS 上的 Parallels Desktop,以及 Windows 上的 Hyper-V。因为我们将在整本书中使用虚拟机,我们不假设主机机器的任何情况:您应该可以舒适地使用您喜欢的任何操作系统。因此,我们选择使用 VirtualBox 作为我们的虚拟化软件,因为它可以在 Linux、macOS 和 Windows 上运行(甚至其他系统!)。此外,VirtualBox 是免费和开源软件,这意味着您可以只需下载并使用它。

目前,VirtualBox 由 Oracle 公司拥有。您可以从www.virtualbox.org/下载 VirtualBox 的安装程序。安装不应该很难;按照安装程序的说明进行操作。

安装类型 2 的虚拟化软件(如 VirtualBox)后,请务必重新启动计算机。虚拟化软件通常需要加载一些内核模块,最简单的方法是通过重新启动实现。

创建 Ubuntu 虚拟机

在本书中,我们使用 Bash 进行脚本编写,这意味着我们的 Linux 安装不需要 GUI。我们选择使用Ubuntu Server 18.04 LTS作为虚拟机操作系统,原因有很多:

  • Ubuntu 被认为是一种适合初学者的 Linux 发行版。

  • 18.04 是一个长期支持LTS)版本,这意味着它将在 2023 年 4 月之前接收更新

  • 因为 Ubuntu 服务器提供了仅 CLI 安装,它对系统资源消耗较小,并且代表了真实服务器的情况。

在撰写本文时,Ubuntu 由 Canonical 公司维护。您可以从www.ubuntu.com/download/server下载 ISO 镜像。现在下载文件,并记住您保存此文件的位置,因为您很快就会需要它。

如果前面的下载链接不再有效,您可以转到您喜欢的搜索引擎,搜索“Ubuntu Server 18.04 ISO 下载”。您应该会找到 Ubuntu 存档的参考,其中将包含所需的 ISO。

在 VirtualBox 中创建虚拟机

首先,我们将开始创建虚拟机来托管我们的 Ubuntu 安装:

  1. 打开 VirtualBox,选择菜单工具栏中的 Machine | New。

  2. 作为参考,在下面的截图中给出了菜单工具栏中的 Machine 条目。为虚拟机选择一个名称(这可以是与服务器名称不同的名称,但出于简单起见,我们喜欢保持相同),将类型设置为 Linux,版本设置为 Ubuntu(64 位)。然后点击下一步:

  1. 在这个屏幕上,我们确定内存设置。对于大多数服务器来说,1024 MB 的 RAM 是一个很好的起点(也是 VirtualBox 为虚拟机推荐的)。如果你有强大的硬件,可以设置为 2048 MB,但 1024 MB 应该也可以。做出你的选择,然后按下一步:

  1. 再次,VirtualBox 推荐的值对我们的需求非常完美。按下“创建”开始创建虚拟硬盘:

  1. 虚拟硬盘可以是许多不同的类型。VirtualBox 默认使用自己的格式VDI,而不是 VMware 使用的VMDK格式(另一个流行的虚拟化提供商)。最后一个选项是VHD(虚拟硬盘),这是一个更通用的格式,可以被多个虚拟化提供商使用。由于我们在本书中将专门使用 VirtualBox,所以保持选择VDI(VirtualBox 磁盘映像)并按下“下一步”:

  1. 在这个屏幕上,我们有两个选项:我们可以立即在物理硬盘上为虚拟硬盘分配全部空间,或者我们可以使用动态分配,它不会保留虚拟磁盘的全部大小,而只保留已使用的部分。

这些选项之间的区别通常在许多虚拟机运行在单个主机上的情况下最相关。创建的磁盘总量大于物理可用的情况,但假设并非所有磁盘都会被充分使用,允许我们在单台机器上放置更多的虚拟机。这被称为过度配置,只有在并非所有磁盘都被填满的情况下才能工作(因为我们永远不能拥有比物理磁盘空间更多的虚拟磁盘空间)。对我们来说,这个区别并不重要,因为我们将只运行一个虚拟机;我们保持默认的动态分配并进入下一个屏幕:

  1. 在这个屏幕上,我们可以做三件事:命名虚拟磁盘文件,选择位置,并指定大小。如果你关心位置(默认为你的home/user目录中的某个位置),你可以按下下面截图中圈出的图标。对于名称,我们喜欢保持与虚拟机名称相同。最后,10GB 的大小对本书中的练习已经足够了。设置好这三个值后,按下“创建”。恭喜,你刚刚创建了你的第一个虚拟机,如下面的截图所示:

  1. 然而,在我们可以在虚拟机上安装 Ubuntu 之前,我们还需要做两件事:将虚拟机指向安装 ISO,并设置网络。选择新创建的虚拟机,点击“设置”。导航到存储部分:

你应该在磁盘图标上看到一个带有“Empty”字样的图标(在前面截图左侧圈出的位置)。选择它并通过点击选择磁盘图标(在右侧圈出的位置)挂载一个 ISO 文件,选择虚拟光盘文件,然后选择你之前下载的 Ubuntu ISO。如果你做对了,你的屏幕应该和前面的截图一样:你不再看到磁盘图标旁边的“Empty”字样,信息部分应该也填写了。

  1. 一旦你验证了这一点,就去到网络部分。

  2. 配置应该默认为 NAT 类型。如果不是,请现在设置为 NAT。NAT 代表网络地址转换。在这种模式下,主机机器充当虚拟机的路由器。最后,我们将设置一些端口转发,以便稍后使用 SSH 工具。点击“端口转发”按钮:

  1. 设置 SSH 规则就像我们所做的那样。这意味着在虚拟机上的端口22被暴露为主机上的端口2222。我们选择端口2222有两个原因:低于 1024 的端口需要 root/administrator 权限,我们可能没有。其次,有可能主机上已经有一个 SSH 进程在监听,这意味着 VirtualBox 将无法使用该端口:

通过这一步,我们已经完成了虚拟机的设置!

在虚拟机上安装 Ubuntu

现在您可以从 VirtualBox 主屏幕启动虚拟机。右键单击该虚拟机,选择启动,然后选择正常启动。如果一切顺利,一个新窗口将弹出,显示虚拟机控制台。过一会儿,您应该在该窗口中看到 Ubuntu 服务器安装屏幕:

  1. 在下一个截图中显示的屏幕上,使用箭头键选择您喜欢的语言(我们使用英语,所以如果您不确定,英语是一个不错的选择),然后按Enter键:

  1. 选择您正在使用的键盘布局。如果不确定,可以使用交互式识别键盘选项来确定哪种布局最适合您。设置正确的布局后,将焦点移动到“完成”然后按Enter键:

  1. 现在我们选择安装类型。因为我们使用的是服务器 ISO,所以我们看不到与 GUI 相关的任何选项。在前面的截图中,选择安装 Ubuntu(其他两个选项都使用 Canonical 的Metal-As-A-ServerMAAS)云服务,这对我们不相关),然后按Enter键:

  1. 您将看到网络连接屏幕。安装程序应默认使用虚拟机创建的默认网络接口上的 DHCP。验证该接口是否已分配 IP,然后按Enter键:

  1. 配置代理屏幕对我们不相关(除非您正在使用代理设置,但在这种情况下,您很可能不需要我们的安装帮助!)。将代理地址留空,然后按Enter键:

  1. 有时手动分区 Linux 磁盘以满足特定需求是有帮助的。在我们的情况下,使用整个磁盘的默认值非常合适,所以按Enter键:

  1. 在选择了使用整个磁盘之后,我们需要指定要使用的磁盘。由于我们在配置虚拟机时只创建了一个磁盘,选择它然后按Enter键。

  2. 现在您将遇到有关执行破坏性操作的警告。因为我们使用的是整个(虚拟!)磁盘,该磁盘上的所有信息都将被删除。我们在创建虚拟机时创建了这个磁盘,因此它不包含任何数据。我们可以安全地执行此操作,所以选择继续然后按Enter键:

  1. 文件系统设置,再次默认值非常适合我们的需求。验证我们至少有 10GB 的硬盘空间(可能会少一点,比如在下面的示例中是 9.997GB:这没问题),然后按Enter键:

  1. Ubuntu 服务器现在应该开始安装到虚拟磁盘。在这一步中,我们将设置服务器名称并创建一个管理用户。我们选择了服务器名称ubuntu,用户名reader和密码password。请注意,这是一个非常弱的密码,我们只会在这台服务器上使用它以简化操作。这是可以接受的,因为服务器只能从我们的主机访问。在配置接受来自互联网的流量的服务器时,永远不要使用这么弱的密码!选择任何您喜欢的内容,只要您能记住它。如果您不确定,我们建议使用ubuntureaderpassword相同的值:

现在,您已经选择了服务器名称并配置了一个管理用户,请按Enter完成安装。

  1. 根据上一个屏幕完成所花费的时间以及主机的速度,Ubuntu 要么仍在安装中,要么已经完成。如果您仍然看到屏幕上的文本在移动,那么安装仍在进行。一旦安装完全完成,您将看到“立即重启”按钮出现。按Enter

  1. 几秒钟后,应该会出现一条消息,指出“请移除安装介质,然后按 Enter”。按照说明操作,如果一切顺利,您应该会看到一个终端登录提示:

通常,VirtualBox 足够智能,会尝试从硬盘而不是 ISO 进行第二次引导。如果在之前的步骤之后,重新启动将您送回安装菜单,则从 VirtualBox 主屏幕关闭虚拟机。右键单击该机器,选择关闭,然后选择关机。在完全关闭电源后,编辑该机器并删除 ISO。这应该强制 VirtualBox 从包含 Ubuntu Server 18.04 LTS 安装的磁盘引导。

  1. 现在是真相的时刻:尝试使用您创建的用户名和密码登录。如果成功,您应该会看到一个类似于以下的屏幕:

给自己一个鼓励:您刚刚创建了一个虚拟机,安装了 Ubuntu Server 18.04 LTS,并通过终端控制台登录。干得好!要退出,请输入exitlogout,然后按Enter

通过 SSH 访问虚拟机

我们已成功连接到 VirtualBox 提供给我们的终端控制台。但是,这个终端连接实际上非常基本:例如,我们无法向上滚动,无法粘贴复制的文本,也没有彩色的语法高亮。幸运的是,我们有一个不错的替代方案:安全外壳SSH)协议。 SSH 用于连接到虚拟机上运行的 shell。通常,这是通过网络完成的:这就是企业维护其 Linux 服务器的方式。在我们的设置中,我们实际上可以在主机机器上使用 SSH,使用我们之前设置的电源转发。

如果您遵循了安装指南,主机上的端口2222应该被重定向到虚拟机上的端口22,这是 SSH 进程运行的端口。从 Linux 或 macOS 主机,我们可以使用以下命令进行连接(根据需要替换用户名或端口号):

$ ssh reader@localhost -p 2222

然而,有很大的可能性您正在运行 Windows。在这种情况下,您可能无法在命令提示符中访问本机 SSH 客户端应用程序。幸运的是,有许多好的(而且免费的!)SSH 客户端。最简单和最知名的客户端是PuTTY。 PuTTY 是在 1999 年创建的,虽然它绝对是一个非常稳定的客户端,但它的年龄开始显现。我们建议一些更新的 SSH 客户端软件,比如MobaXterm。这为您提供了更多的会话管理,更好的图形用户界面,甚至本地命令提示符!

无论您决定使用哪种软件,请确保使用以下值(再次更改端口或用户名,如果您偏离了安装指南):

  • 主机名:localhost

  • 端口:2222

  • 用户名:reader

如果您使用 SSH 连接到虚拟机,可以以无头模式启动它。这样做时,VirtualBox 不会为您创建一个带有终端控制台的新窗口,而是在后台运行虚拟机,您仍然可以通过 SSH 连接(就像在实际的 Linux 服务器上发生的情况)。这个无头启动选项,在右键单击虚拟机并选择启动时,位于早期的正常启动下方。

摘要

在本章中,我们已经开始准备我们的本地机器,以便进行本书的其余部分。我们现在知道虚拟机和物理机之间的区别,以及为什么我们更喜欢在本书的其余部分使用虚拟机。我们已经了解了两种不同类型的 hypervisors。我们已经安装和配置了 VirtualBox,并在其中安装了 Ubuntu 18.04 操作系统的虚拟机。最后,我们已经使用 SSH 连接到正在运行的虚拟机,而不是使用 VirtualBox 终端,这样可以获得更好的可用性和选项。

在本章中引入了以下命令:sshexit

在下一章中,我们将通过查看一些不同的工具来完成设置我们的本地机器,这些工具将帮助我们在 GUI 和虚拟机 CLI 上进行 bash 脚本编写。

问题

  1. 运行虚拟机比裸机安装更可取的原因是什么?

  2. 与裸机安装相比,运行虚拟机的一些缺点是什么?

  3. 类型 1 和类型 2 hypervisor 之间有什么区别?

  4. 在 VirtualBox 上有哪两种方式可以启动虚拟机?

  5. Ubuntu LTS 版本有什么特别之处?

  6. 如果在 Ubuntu 安装后,虚拟机再次引导到 Ubuntu 安装屏幕,我们应该怎么办?

  7. 如果在安装过程中意外重启,最终没有进入 Ubuntu 安装界面(而是看到错误),我们应该怎么办?

  8. 为什么我们为虚拟机设置了 NAT 转发?

进一步阅读

如果您想更深入地了解本章的主题,以下资源可能会有所帮助:

第三章:选择合适的工具

本章将介绍一些在编写 Bash 脚本时会帮助我们的工具。我们将专注于两种类型的工具:基于 GUI 的编辑器(Atom 和 Notepad++)和基于终端的编辑器(Vim 和 nano)。我们将描述这些工具以及如何使用它们,它们的优势和劣势,以及如何同时使用基于 GUI 和基于终端的编辑器以获得最佳结果。

本章将介绍以下命令:vimnanols

本章将涵盖以下主题:

  • 使用图形编辑器进行 shell 脚本编写

  • 使用命令行编辑器进行 shell 脚本编写

  • 在编写 shell 脚本时将图形编辑器与命令行编辑器结合使用

技术要求

在使用 Vim 或 nano 时,您将需要我们在上一章中创建的虚拟机。如果要使用 Notepad++,您将需要 Windows 主机。对于 Atom,主机可以运行 Linux、macOS 或 Windows。

使用图形编辑器进行 shell 脚本编写

自 Unix 和类 Unix 发行版问世以来,工具已经发展了很长一段路。在早期,编写 shell 脚本比今天要困难得多:shell 功能较弱,文本编辑器仅限于命令行,诸如语法高亮和自动补全之类的功能都是不存在的。今天,我们有非常强大的图形用户界面编辑器,可以帮助我们进行脚本编写。为什么我们要等到运行脚本才发现错误,当图形用户界面编辑器可以提前显示错误?今天,使用高级编辑器进行 shell 脚本编写几乎是一种必需品,我们不想离开。

在接下来的页面中,我们将描述两个文本编辑器:Atom 和 Notepad++。两者都是基于 GUI 的,我们可以用它们进行高效的 shell 脚本编写。如果您已经对其中一个有偏好,请选择那个。如果不确定,我们建议使用 Atom。

Atom

我们将首先考虑的图形编辑器是由 GitHub 制作的 Atom。它被描述为“21 世纪可修改的文本编辑器”。在这里,“可修改”意味着虽然 Atom 的默认安装与任何文本编辑器一样完整,但这个应用程序真正闪耀的地方在于它非常可配置和可扩展。任何 GitHub 未集成的功能都可以作为扩展包编写。通过使用这些扩展,您可以使 Atom 安装完全成为您自己的;如果您不喜欢某些东西,就改变它。如果不能直接改变,就找一个可以做到的扩展包。即使没有符合您期望的扩展包,您仍然可以选择创建自己的扩展包!

Atom 的另一个很好的功能是与 Git 和 GitHub 的默认集成。Git 目前是最流行的版本控制系统。版本控制系统在编写代码或脚本时使用。它们确保文件的历史记录被保留,并使多个甚至许多贡献者能够同时在同一文件上工作,而不会因冲突管理而负担过重。GitHub,顾名思义,目前是最重要的面向开源软件的基于 Web 的 Git 提供者。

关于 Atom 的最后一件伟大的事情是,它默认支持许多脚本和编程语言。当我们说“支持”时,我们是指它可以通过文件扩展名识别文件类型,并提供语法高亮(这样编写脚本就更容易了!)。这种功能是通过核心包提供的,它们的工作方式与普通包相同,但从一开始就包含在内。对于我们的目的,核心包language-shellscript将帮助我们进行 shell 脚本编写。

Atom 安装和配置

让我们继续安装 Atom。 只要您运行 Linux、macOS 或 Windows,您可以转到atom.io/并获取安装程序。 运行安装程序,如果需要,可以跟随提示直到 Atom 安装完成。 现在,启动 Atom,您将会看到欢迎屏幕,写作时看起来像下面这样:

一定要查看 Atom 提供的所有屏幕。 当您觉得已经足够探索时,让我们向 Atom 添加一个可以补充我们的 shell 脚本的软件包。 如果您仍然打开欢迎指南屏幕,请从中选择安装软件包。 否则,您可以使用键盘快捷键Ctrl + *,*来打开设置屏幕。 您将在那里看到一个安装选项,它将带您到安装软件包屏幕。 搜索bash,您应该会看到以下软件包:

单击安装按钮并观看安装过程。 安装后可能会提示您重新启动 Atom;一定要这样做。 如果没有提示但看到任何错误,重新启动 Atom 绝对不是一个坏主意。 安装软件包后,您现在在编写 shell 脚本时将具有自动完成功能。 这意味着您可以开始输入,Atom 将尝试预测您想要的内容,方式如下:

在右侧,您可以看到我们开始输入echo shell 命令,前两个字母后,Atom 给出了包含这两个字母的两个选项。 一旦它提出建议,我们可以按Enter,命令就完全插入了。 虽然在这种情况下节省的时间不会太多,但有两个主要原因可以很好地使用它:

  • 如果您不确定命令的确切名称,您可能可以通过自动完成找到它。

  • 一旦您开始编写条件和循环(在本书的第二部分),自动完成将跨越多行,为您节省了输入许多单词和记住所有语法的时间。

最后,让我们看看当您打开一个 Git 项目并且正在处理文件时,Atom 的外观是什么样的:

在 Atom 中工作时,屏幕大部分时间看起来会像这样。 在左侧,您将看到树视图,您可以通过按Ctrl + **来切换其开/关。 树视图包含当前项目中的所有文件(即您打开的目录)。 双击这些文件可以将它们打开,它们将出现在中间:编辑器视图。 这是您将花费大部分时间在其中编写 shell 脚本的地方。 即使当前没有打开文件,编辑器视图也将始终可见。

默认情况下,还有一个最后的视图,Git 视图,位于右侧。 您可以通过按*Ctrl *+*Shift *+ 9来切换此视图。 本书的代码托管在 GitHub 上,您将下载(或者,正如 Git 所称的那样,克隆)一次,而无需在远程服务器上进行编辑。 因此,在本书中不需要 Git 视图,但我们提到它,因为您可能会在其他项目中使用它。

Notepad++

Atom 更接近于集成开发环境IDE)而不是文本编辑器,Notepad++基本上就是其名字的含义:带有一些附加功能的老式记事本。 其中一些附加功能包括能够同时打开多个文件,语法高亮显示和有限的自动完成。 它最初是在 2003 年发布的,只能在 Windows 上运行。

Notepad以其简单性而闻名。如果您熟悉任何一种记事本软件(谁不熟悉?),那么 Notepad应该是立即可识别的。虽然我们建议在本书中使用 Atom,但使用诸如 Notepad++之类的简单解决方案绝对不会让您退步。但是,在商业环境中,您几乎总是会在已存在的版本控制存储库中创建脚本,这就是 Atom 的附加功能真正发挥作用的地方。

如果您想尝试 Notepad++,请从notepad-plus-plus.org/download下载并运行安装程序(请记住,仅当您使用 Windows 时!)。保持默认选项并在安装后运行 Notepad++。您应该会看到以下屏幕:

正如您所看到的,当您打开以.sh结尾的文件时,您将看到语法高亮显示。这是因为.sh扩展名保留用于 shell 脚本文件。在编写脚本时,这可以帮助您很多。缺少引号导致脚本出错的示例将通过基于颜色的语法高亮显示变得非常明显,可能为您节省了很多故障排除时间。

Notepad还有许多其他功能,使其成为一个出色的增强记事本。您可以使用宏执行脚本化任务,可以安装插件以扩展功能,并且还有许多其他独特功能,使 Notepad成为一个吸引人的选择。

使用命令行编辑器

能够使用命令行编辑器是任何与 Linux 一起工作的人迟早都应该学会的技能。对于带有图形用户界面(GUI)的 Linux 安装,这可能会被替换为诸如 Atom 或分发内置的 Notepad 变体之类的 GUI 工具。但是,服务器安装几乎永远不会有 GUI,您将不得不依赖命令行文本编辑器。虽然这可能听起来令人生畏,但实际上并非如此!为了给您一个关于命令行编辑器的简要介绍,我们将介绍大多数 Linux 发行版上都存在的两个最受欢迎的应用程序:VimGNU nano

Vim

我们将讨论的第一个命令行文本编辑器可能是 Linux 中最受欢迎的:Vim。Vim 源自术语Vi Improved,因为它是 Unix 编辑器 Vi 的更新克隆。它由 Bram Moolenaar 创建,并仍在维护,他于 1991 年首次公开发布了 Vim。Vim(或者在非常老的系统上是 Vi)应该存在于您遇到的所有 Unix 或类 Unix 机器上。

Vim 被认为是一种难以学习的工具。这主要是因为它的工作方式与大多数人习惯的文本编辑器非常不同。但是,一旦初始学习曲线结束,许多人同意在 Vim 中可以更快地完成许多操作,而不是在普通文本编辑器(如 Microsoft 的 Notepad++)中。

让我们开始吧!登录到您的虚拟机:

$ ssh reader@localhost -p 2222

登录后,打开 Vim 到一个空文件:

$ vim

您应该会看到大致如下的东西:

Vim 启动一个使用整个终端的新进程(不用担心,一旦退出 Vim,一切都还会在您离开的地方!)。当您启动 Vim 时,您将进入normal模式。Vim 有许多模式,其中正常和insert是最有趣的探索。在正常模式下,您不能像在记事本或 Word 中那样开始输入。由于 Vim 设计为无需鼠标即可使用,因此它需要一种方式来操作文本。一些应用程序决定使用修改器(例如在记事本中按住Shift键),而 Vim 决定使用模式。让我们首先进入插入模式,以便我们可以开始输入一些文本。按I键,您的屏幕应该切换到插入模式:

我们已经在插入模式下输入了一些文本。确保您也这样做,完成后按Esc返回到正常模式:

如果你比较两个屏幕截图,你应该会看到一个很大的区别:在左下角,文本-- INSERT --消失了!当你处于正常模式以外的模式时,那个模式会清晰地显示在那里。如果你什么都看不到,你可以安全地假设你处于正常模式。在正常模式下,我们可以使用箭头键进行导航。我们还可以使用几个按键来操作字符、单词,甚至(多个)行!例如,按下dd,注意到你整行都被删除了。如果你想要恢复它,按u进行撤销。

还有一个挑战:退出 Vim。通常,你可能会想要使用Esc按钮退出程序。如果你对 Linux 有一些了解,你可能甚至知道一个不错的Ctrl + C也可以退出大多数程序。然而,这两种方法对 Vim 都不起作用:Esc只会让你进入正常模式,而Ctrl + C 则不会有任何作用。要退出 Vim,请确保你处于正常模式,并输入以下内容:

:q!

这将退出你当前的文档,而不保存任何内容。如果你想保存并退出,请使用以下命令:

:x filename.txt

这将保存你当前的文档为filename.txt并返回到你的终端。请注意,通常你会使用以下命令在已经存在的文件上启动 Vim:

$ vim filename.txt

在这种情况下,当保存和退出时,你不需要输入文件名;在这种情况下,使用:x就足够了。:x实际上是:wq的缩写。:w操作,用于保存文件,:q用于退出。结合起来,它们用于保存并退出。如果你在编辑过程中想随时保存文件,你可以使用:w来完成这个操作。

Vim 摘要

Vim 有许多命令,受力用户欣赏。现在,记住有两个重要的模式,正常和插入。你可以通过按I从正常模式切换到插入模式,你可以通过按Esc返回到正常模式。在插入模式下,Vim 的行为就像记事本或 Word 一样,但在正常模式下,你可以进行简单的文本操作,例如删除当前选择的整行。如果你想退出 Vim,进入正常模式,然后输入:q!:x,取决于你是否想保存更改。

不要害怕开始使用 Vim。虽然一开始可能会让人望而生畏,但一旦你掌握了它,你就可以更快地在服务器上执行与文件相关的任务。如果你想提前了解,花 30 分钟的时间通过vimtutor。这个命令行工具会让你迅速掌握 Vim 的基本用法!要开始,只需导航到你的虚拟机,输入vimtutor,然后按Enter

.vimrc

.vimrc文件可用于为 Vim 设置一些持久选项。使用这个文件,你可以自定义你的 Vim 体验。有许多定制的可能性:常见的例子包括设置颜色方案,转换制表符和空格,以及设置搜索选项。

要创建一个在启动 Vim 时使用的.vimrc文件,请执行以下操作:

$ cd
$ vim .vimrc

第一个命令将你放在你的home目录中(不用担心,这将在本书的后面更详细地解释)。第二个命令为.vimrc文件启动了一个 Vim 编辑器。不要忘记前面的点,因为这是 Linux 处理隐藏文件的方式(同样,稍后会更详细地介绍)。我们在.vimrc文件中使用以下配置:

set expandtab
set tabstop=2
syntax on
colo peachpuff
set ignorecase
set smartcase
set number

按顺序,通过这个配置实现了以下几件事:

  • set expandtab:将制表符转换为空格。

  • set tabstop=2:每个制表符转换为两个空格。

  • syntax on:打开语法高亮(使用不同的颜色)。

  • colorscheme peachpuff:使用 peachpuff 颜色方案。

  • set ignorecase:在搜索时忽略大小写。

  • set smartcase:在搜索时不忽略一个或多个大写字母的大小写。

  • set number:显示行号。

Vim 速查表

为了让您熟悉一些 Vim 的常用命令,我们提供了一个速查表。通过vimtutor学习后,有了这个速查表,几乎可以保证您能够正确地使用 Vim!

按键直接输入。请注意,按键区分大小写,因此aA不同。您可以按住Shift键输入大写字母,或使用大写锁定键。然而,最实用的方法是使用Shift

按键 效果
Esc 退出插入模式,返回命令模式。
i 在光标当前位置之前进入插入模式。
a 在光标当前位置之后进入插入模式。
I 在当前行的开头进入插入模式。
A 在当前行的末尾进入插入模式。
o 在当前行下方插入新行进入插入模式。
O 在当前行上方插入新行进入插入模式。
dd 删除当前行。
u 撤消上一个插入模式中所做的更改。
Ctrl + r 重做撤消。
yy '复制'当前行。
p 在当前行下方粘贴最后复制的行。
P 在当前行上方粘贴最后复制的行。
H 导航到文件开头。
M 导航到文件中间。
G 导航到文件末尾。
dH 删除直到文件开头的所有行(包括当前行)。
dG 删除直到文件末尾的所有行(包括当前行)。

nano

GNU nano,通常简称为 nano,是另一个默认存在于大多数 Linux 安装中的命令行编辑器。正如名称所示,它是 GNU 项目的一部分,与构成 Linux 发行版的许多其他部分并无不同(请记住,Bash 也是 GNU 项目软件)。Nano 于 1999 年首次发布,旨在取代 Pico 文本编辑器,Pico 是为 Unix 系统创建的简单文本编辑器。

与 Vim 相比,Nano 远不止是一个所见即所得(WYSIWYG)工具。类似于记事本和 Word,nano 不使用不同的模式;它总是准备好开始输入您的文档或脚本。

在您的虚拟机上,打开一个 nano 编辑器屏幕:

$ nano

应该出现类似以下的屏幕:

随意开始输入一些内容。它应该看起来像以下内容:

正如您所看到的,屏幕底部保留用于显示 nano 所称的控制键。虽然一开始可能不太明显,但^Ctrl的简写。如果您想退出,您可以按住Ctrl并按X

您将被提示是否要保存或不保存文件并退出。在这种情况下,我们按Y表示是。如果我们使用文件名启动 nano,保存和退出将立即完成,但因为我们没有使用文件名启动 nano,另一个选择将被呈现给我们:

输入文件名并按Enter。您将回到之前的终端屏幕,在您启动 nano 的目录中。如果一切顺利,您可以使用以下命令查看文件:

$ ls -l

尽管 nano 具有更多高级功能,但对于基本用法,我们已经讨论了最重要的功能。虽然最初使用比 Vim 更容易,但它也没有那么强大。简而言之,nano 很简单,Vim 很强大。

如果您没有任何经验和/或偏好,我们建议您花点时间学习 Vim 并坚持使用它。在花费更多时间学习 Linux 和 Bash 脚本之后,Vim 的高级功能变得不可或缺。但是,如果您无法习惯 Vim,不要害羞地使用 nano:这是一个很好的编辑器,可以在不太麻烦的情况下完成大部分工作!

在编写 shell 脚本时,将图形编辑器与命令行编辑器相结合

为了让您了解我们喜欢如何将 GUI 工具与命令行编辑器结合使用,我们给出了以下示例工作流程。不要担心现在不理解所有步骤;在本书结束时,您应该回到这个示例并准确理解我们在谈论什么。

在编写 shell 脚本时,通常会经历几个阶段:

  1. 收集 shell 脚本的要求。

  2. 设计 shell 脚本。

  3. 编写 shell 脚本。

  4. 测试和调整 shell 脚本。

  5. (可选)将工作的 shell 脚本提交到您的版本控制系统。

阶段 1 和 2 通常在不编写实际代码的情况下完成。您会思考脚本的目的,它如何实现,以及创建脚本会带来什么好处。这些步骤通常涉及研究和寻找最佳实践。当您觉得对为什么、什么以及如何编写 shell 脚本有了很好的想法时,就可以进入第 3 阶段:编写脚本。在这一点上,您会打开您最喜欢的基于 GUI 的编辑器并开始输入。由于 GUI 编辑器具有自动完成、语法高亮和其他内置的生产力功能,您可以高效地编写大部分 shell 脚本代码。在您觉得脚本准备好进行测试之后,您需要离开 GUI:脚本必须在其设计的系统上进行测试。

第 4 阶段开始。您可以使用 Vim 或 nano 将脚本复制并粘贴到服务器上。一旦脚本在服务器上,您就可以运行它。大多数情况下,它实际上不会做您期望它做的一切。小错误很容易犯错,也很容易修复,但是如果要回到 GUI 编辑器更改、保存、传输到服务器并再次运行,这将是一个小麻烦!幸运的是,我们可以使用 Vim 或 nano 在服务器上进行微小更改以修复脚本,并再次尝试。一个丢失的;"将使 shell 脚本无法使用,但可以快速修复(尽管 GUI 编辑器通常会突出显示这样的错误,因此这些错误不太可能出现在服务器上,即使是第一个版本)。

最后,在经过多次迭代后,您的脚本将按预期工作。现在,您必须确保完整且正确的脚本已上传到您的版本控制系统。建议将脚本从 GUI 传输到服务器最后一次,以查看您是否已将服务器上所做的所有更改应用到您的 GUI 会话中。完成后,提交它,您就完成了!

总结

在本章中,我们讨论了四种文本编辑工具,分为两种类型:基于 GUI 的编辑器(Atom 和 Notepad++)和命令行编辑器(Vim 和 GNU nano),然后展示了如何将这些工具结合使用。

Atom 是一个功能强大的文本编辑器,可以按照您的要求进行配置。默认情况下,它支持许多不同的编程语言,包括 shell。它还具有 Git 和 GitHub 集成。我们还简要讨论了 Notepad++。虽然不如 Atom 强大,但它也适用于我们的目的,因为它基本上是一个增强版的记事本,具有所有 shell 脚本的重要功能。

Vim 和 nano 是两种最流行的 Linux 命令行文本编辑器。我们已经了解到,虽然 Vim 非常强大,但比 nano 更难学习。然而,学会如何正确使用 Vim 将加快您在 Linux 系统上的许多操作,并且是一项非常有价值的技能。要了解 Vim 的实际操作,请通过 vimtutor 进行实践性的介绍。Nano 更容易使用,因为它更接近所见即所得的编辑风格,这种风格也在 Microsoft Word 和记事本中找到。

我们以一个 shell 脚本编写的示例结束了本章。我们简要概述了如何在命令行编辑器中使用基于 GUI 的编辑器。

本章介绍了以下命令:vimnanols

问题

  1. 为什么语法高亮是文本编辑器的重要特性?

  2. 我们如何扩展 Atom 已提供的功能?

  3. 编写 shell 脚本时,自动补全有什么好处?

  4. Vim 和 GNU nano 之间的区别如何描述?

  5. Vim 中最有趣的两种模式是哪两种?

  6. 什么是.vimrc文件?

  7. 当我们称 nano 为所见即所得的编辑器时,我们是什么意思?

  8. 为什么我们希望将图形界面编辑器与命令行编辑器结合使用?

进一步阅读

如果您想更深入地了解本章的主题,以下资源可能会很有趣:

第四章:Linux 文件系统

在本章中,我们将花一些时间探索 Linux 文件系统。我们将解释文件系统是什么,以及什么使 Linux 文件系统独特。我们将描述 Linux 文件系统的结构以及在 Linux 下(几乎)一切都是文件。我们将以交互方式进行,让您首次近距离了解一些常见的 Linux 命令,这些命令将在后面的脚本中使用。

本章将介绍以下命令:pwdcddfechotypecatless

本章将涵盖以下主题:

  • Linux 文件系统解释

  • Linux 文件系统的结构

  • 一切都是文件

技术要求

我们将使用在第二章中创建的虚拟机来探索 Linux 文件系统,设置本地环境

如果在连接到虚拟机时遇到问题,请确保 VirtualBox 正在运行,并且虚拟机已启动。虽然有许多可能导致问题的原因,但确保虚拟机监控程序和虚拟机正在运行应始终是故障排除的第一步。

Linux 文件系统解释

本章将介绍 Linux 文件系统的基础知识。由于文件系统很复杂,我们不会深入探讨技术的细节;相反,我们将提供足够相关的信息,以便进行 shell 脚本编写。

什么是文件系统?

文件系统本质上是数据在物理介质上存储和检索的方式(可以是硬盘、固态硬盘,甚至是 RAM)。它是一个软件实现,管理位的写入和再次找到的位置和方式,并可能包括各种增强可靠性、性能和功能的高级功能。

文件系统的概念是抽象的:有许多文件系统实现,令人困惑的是它们经常被称为文件系统。我们发现最容易理解的方法是按照家族对文件系统进行排序,就像 Linux 发行版一样:有 Linux 文件系统、Windows 文件系统、macOS 文件系统以及许多其他文件系统。Windows 文件系统家族从最早的FAT文件系统一直延伸到最新的ReFS,目前最广泛使用的是NTFS

在撰写本文时,Linux 家族中最重要的文件系统实现如下:

  • ext4

  • XFS

  • Btrfs

目前最常用的 Linux 文件系统实现是 ext4。它是 Linux 文件系统扩展文件系统ext)系列的第四个版本。它于 2008 年发布,被认为非常稳定,但并非最先进;可靠性是最重要的考虑因素。

XFS 最著名的用途是在 Red Hat 发行版(Red Hat Enterprise Linux、CentOS 和 Fedora)中。它包含一些比 ext4 更先进的功能,如并行 I/O、更大的文件大小支持和更好地处理大文件。

最后是 Btrfs。这个文件系统实现最初是在 Oracle 设计的,截至 2014 年被认为是稳定的。Btrfs 具有许多先进的功能,这使得它可能比 ext4 和 XFS 更可取;甚至 ext4 的主要开发人员表示,ext4 最终应该被 Btrfs 取代。Btrfs 最有趣的特性是它使用写时复制COW)原则:复制的文件实际上并没有完全写入物理介质,而只是创建了指向相同数据的新指针。只有在复制或原始文件被修改时才会写入新数据。

正如你可能已经猜到的那样,文件系统实现只不过是软件而已。对于 Linux,前面描述的三种实现都存在于所有更新的 Linux 内核中。这意味着只要在操作系统中安装了正确的驱动程序,这些都可以使用。更好的是,所有这些甚至可以同时使用!我们将在本章的后面进一步讨论这一点。

另一个有趣的事情是,虽然 ext4 是 Linux 原生的文件系统,但在驱动程序的帮助下,它也可以在 Windows 下使用。你不会将 ext4 用作 Windows 下的主要驱动器文件系统,但你可以在 Windows 下挂载一个 Linux 格式的 ext4 文件系统并与其中的内容交互。反过来,在 Linux 下挂载 Windows 文件系统也是大多数实现所支持的。虽然我们在这里使用 ext4 作为例子,但 XFS 和 Btrfs 也是一样的。

Linux 文件系统的独特之处是什么?

现在应该清楚的是,实际上并不存在the Linux 文件系统。然而,这些文件系统共享某些特征,使它们成为可行的 Linux 文件系统。

Linux 文件系统遵循文件系统层次结构标准FHS)。这个 FHS 由 Linux 基金会维护,目前已经更新到 3.0 版。与 Linux 生态系统中的许多其他事物一样,它是基于 Unix 的前身:Unix 文件系统标准UFS)。它指定了目录结构及其内容。我们将在本章的下一部分一起探讨这个结构。

由于 Linux 最常用于服务器,Linux 文件系统实现(通常)在文件完整性和灾难恢复方面具有非常先进的功能。这种灾难的一个例子是,当系统在写入一个业务关键文件时遇到停电。如果写入操作存储在内存中并在中途中止,文件将处于不一致的状态。当系统再次启动时,操作系统不再在内存中有写入操作(因为内存在每次重启时都会被清除),只有部分文件会被写入。显然,这是不希望发生的行为,可能会导致问题。由于 COW 的特性,Btrfs 不会出现这个问题。然而,ext4 和 XFS 不是 COW 文件系统。它们以另一种方式处理这个问题:通过日志记录

如前图所示,文件被写入磁盘分为三个步骤:

  1. 文件系统请求从日志中写入磁盘

  2. 日志写入磁盘

  3. 文件写入后,更新日志

如果服务器在步骤 2 和 3 之间崩溃,那么在上电后将再次进行写入,因为日志仍然包含该条目。日志只包含有关操作的一些元数据,而不是整个文件。由于日志包含对磁盘上实际位置(驱动器扇区)的引用,它将覆盖先前写入的内容,即文件的一部分。如果这次成功完成,日志条目将被删除,文件/磁盘的状态得到保证。如果服务器在步骤 1 和 2 之间失败,那么实际的写入磁盘指令从未被给出,给出指令的软件应该考虑到这种可能性。

免责声明:关于日志记录的部分有点过于简化,但是文件系统很复杂,我们想要专注于与 shell 脚本相关的内容。如果你对文件系统在更低层次上的工作原理感兴趣,一定要找一本其他的书来看,因为这确实是一个非常有趣的主题!

Linux 文件系统的结构

虽然还有许多更高级的文件系统功能非常有趣,但我们想要专注于使 Linux 文件系统与众不同的东西:文件系统结构。如果您习惯于 Windows,这可能是两个操作系统之间最令人困惑的区别。如果您来自 macOS,差异仍然明显,但要小得多:这是 macOS 作为 Unix 操作系统的结果,它与类 Unix 的 Linux 结构有明显的相似之处。

从这一点开始,我们将交互式地探索 Linux 文件系统。我们建议您跟随后面的代码示例,因为这会显著增加信息的保留。此外,如果您选择不使用 Ubuntu 18.04 LTS 进行本书学习,您的系统可能与我们使用的系统有所不同。无论如何,启动虚拟机并与我们一起开始探索吧!

树结构

让我们首先通过 SSH 登录到我们的虚拟机:

ssh -p 2222 reader@localhost

在提示处输入密码,然后您应该到达默认的 Ubuntu 18.04 登录横幅,应该看起来类似于以下内容:

reader@localhost's password: 
Welcome to Ubuntu 18.04.1 LTS (GNU/Linux 4.15.0-29-generic x86_64)
<SNIPPED>
  System information as of Sat Jul 28 14:15:19 UTC 2018

  System load:  0.09              Processes:             87
  Usage of /:   45.6% of 9.78GB   Users logged in:       0
  Memory usage: 15%               IP address for enp0s3: 10.0.2.15
  Swap usage:   0%
<SNIPPED>
Last login: Sat Jul 28 14:13:42 2018 from 10.0.2.2
reader@ubuntu:~$

登录时(无论是通过 SSH 还是终端控制台),您将最终进入用户的home目录。您可以始终使用pwd命令来确定您的确切位置。pwd代表print working directory:

reader@ubuntu:~$ pwd
/home/reader

所以,我们最终进入了/home/reader/目录。这是大多数 Linux 发行版的默认设置:/home/$USERNAME/。由于我们创建了主用户reader,这就是我们期望的位置。对于那些来自 Windows 的人来说,这可能看起来非常陌生:驱动器名称(C:D:等)在哪里?为什么我们使用(正斜杠)而不是反斜杠?

Linux 以及 Unix 和其他类 Unix 系统使用树结构。它被称为树,因为它从单个起始点root(位于/)开始。目录从那里嵌套(就像树的分支一样),与其他操作系统并没有太大不同。最后,树结构以被视为树的叶子的文件结束。这可能听起来仍然非常复杂,但实际上相对简单。让我们继续探索,以确保我们完全理解这个结构!在 Linux 下,我们使用cd命令来更改目录。它通过输入cd,后跟我们想要去的文件系统位置作为命令的参数来工作。导航到文件系统根目录:

reader@ubuntu:~$ cd /    
reader@ubuntu:/$

如您所见,似乎没有发生太多事情。但是,您的终端提示中有一个微小的区别:~字符已被/替换。在 Ubuntu 下,默认配置显示文件系统的位置,无需使用pwd命令。提示构建如下:<username>@<hostname>**:**<location>**$**。那么为什么是~呢?简单:波浪符号是用户主目录的简写!如果简写不存在,登录时的提示将是reader@ubuntu:/home/reader$

由于我们已经导航到了文件系统的根目录,让我们看看我们可以在那里找到什么。要列出当前目录的内容,我们使用ls命令:

reader@ubuntu:/$ ls
bin dev home initrd.img.old lib64 media opt root sbin srv sys usr vmlinuz
boot etc initrd.img lib lost+found mnt proc run snap swap.img tmp var vmlinuz.old

如果您使用 SSH,您很可能会看到一些颜色来区分文件和目录(甚至可以看到目录权限的颜色,如果您以不同方式看到tmp;这将在下一章中讨论)。然而,即使有颜色的帮助,这仍然感觉不清晰。让我们通过在ls命令上使用一个选项来清理一下:

reader@ubuntu:/$ ls -l
total 2017372
drwxr-xr-x  2 root root       4096 Jul 28 10:31 bin
drwxr-xr-x  3 root root       4096 Jul 28 10:32 boot
drwxr-xr-x 19 root root       3900 Jul 28 10:31 dev
drwxr-xr-x 90 root root       4096 Jul 28 10:32 etc
drwxr-xr-x  3 root root       4096 Jun 30 18:20 home
lrwxrwxrwx  1 root root         33 Jul 27 11:39 initrd.img -> boot/initrd.img-4.15.0-29-generic
lrwxrwxrwx  1 root root         33 Jul 27 11:39 initrd.img.old -> boot/initrd.img-4.15.0-23-generic
drwxr-xr-x 22 root root       4096 Apr 26 19:09 lib
drwxr-xr-x  2 root root       4096 Apr 26 19:07 lib64
drwx------  2 root root      16384 Jun 30 17:58 lost+found
drwxr-xr-x  2 root root       4096 Apr 26 19:07 media
drwxr-xr-x  2 root root       4096 Apr 26 19:07 mnt
drwxr-xr-x  2 root root       4096 Apr 26 19:07 opt
dr-xr-xr-x 97 root root          0 Jul 28 10:30 proc
drwx------  3 root root       4096 Jul  1 09:40 root
drwxr-xr-x 26 root root        920 Jul 28 14:15 run
drwxr-xr-x  2 root root      12288 Jul 28 10:31 sbin
drwxr-xr-x  4 root root       4096 Jun 30 18:20 snap
drwxr-xr-x  2 root root       4096 Apr 26 19:07 srv
-rw-------  1 root root 2065694720 Jun 30 18:00 swap.img
dr-xr-xr-x 13 root root          0 Jul 28 10:30 sys
drwxrwxrwt  9 root root       4096 Jul 28 14:32 tmp
drwxr-xr-x 10 root root       4096 Apr 26 19:07 usr
drwxr-xr-x 13 root root       4096 Apr 26 19:10 var
lrwxrwxrwx  1 root root         30 Jul 27 11:39 vmlinuz -> boot/vmlinuz-4.15.0-29-generic
lrwxrwxrwx  1 root root         30 Jul 27 11:39 vmlinuz.old -> boot/vmlinuz-4.15.0-23-generic

ls命令的选项-l(连字符小写 l,如long)提供了长列表格式。除其他外,这会打印权限、文件/目录的所有者、文件类型和大小。请记住,权限和所有权将在下一章中讨论,所以现在不需要担心这些。从中最重要的是,每个文件/目录都会单独打印在自己的一行上,该行的第一个字符表示文件类型:d表示目录,-表示普通文件,l表示符号链接(在 Linux 下是快捷方式)。

让我们深入树结构,回到我们的home目录。此时,你有两个选择。你可以使用相对路径(即:相对于当前位置)或全路径相对于当前目录)。让我们都试一下:

reader@ubuntu:/$ cd home
reader@ubuntu:/home$

上述是进入相对目录的示例。我们位于根目录/,然后从那里导航到 home,最终到达/home。我们可以使用全路径从任何地方导航到那里:

reader@ubuntu:/$ cd /home
reader@ubuntu:/home$

你注意到了区别吗?在全路径示例中,cd的参数以斜杠开头,但在相对示例中没有。让我们看看如果你使用这两种类型时会发生什么错误:

reader@ubuntu:/home$ ls
reader
reader@ubuntu:/home$ cd /reader
-bash: cd: /reader: No such file or directory

我们使用ls列出了/home目录的内容。如预期的那样,我们看到(至少)当前用户的主目录reader。然而,当我们尝试使用cd /reader导航到它时,我们得到了臭名昭著的错误No such file or directory。尽管这并不令人意外:实际上并没有一个目录/reader。我们要找的目录是/home/reader,可以使用命令cd /home/reader全路径到达:

reader@ubuntu:/home$ cd home
-bash: cd: home: No such file or directory
reader@ubuntu:/home$

如果我们尝试使用不正确的相对路径,也会出现相同的错误。在上面的示例中,我们当前位于/home目录,然后使用cd home命令。实际上,这会把我们放在/home/home,就像我们在/home目录中使用ls时看到的那样,这个目录并不存在!

在 Linux 中安全地导航的最佳方式是全路径:只要你有正确的目录,它总是有效的,无论你当前位于文件系统的何处。然而,特别是当你深入文件系统时,你需要输入更多。我们始终建议初学者从全路径导航开始,一旦他们熟悉了cdlspwd命令,就可以切换到相对路径。

尽管全路径更安全,但比起相对路径效率要低得多。你看到了我们如何可以深入树结构的分支,但如果你需要向下一级,回到根呢?幸运的是,这并不强迫我们使用全路径。我们可以使用..符号,这意味着向上一级,朝着/

reader@ubuntu:/home$ cd ..
reader@ubuntu:/$

这里需要注意一下术语。虽然我们将文件系统构想为一棵树,但在谈到根目录时,我们将其视为文件系统中的最高点。因此,从/移动到/home时,我们是在向下移动。如果我们使用cd ..命令移回/,我们是在向上移动。虽然我们认为这实际上与树的图像不太匹配(根实际上是最低点),但请记住这个约定!

使用cd ..向上移动会使我们回到文件系统的根目录。此时,你可能会想*如果我在文件系统的最高级别再次这样做,会发生什么?*试一试:

reader@ubuntu:/$ cd ..
reader@ubuntu:/$

对我们来说幸运的是,我们没有收到错误,也没有崩溃的机器;相反,我们只是最终到达(或者,取决于你的看法,停留在)文件系统的根目录。

Linux 新用户经常困惑的一个术语是root。它可以代表以下三种情况之一:

  1. 文件系统中的最低点,在/

  2. 默认的超级用户,名为root

  3. 默认超级用户的主目录,在/root/

通常,读者需要根据上下文来确定指的是这三者中的哪一个。当谈论文件系统的上下文时,可能是:

  1. 如果它似乎是在提到用户,你可以期望它指的是 root 用户

  2. 只有在谈论根用户的主目录或/root/时,你应该考虑

  3. 最常见的是,你会发现 root 指的是 1 或 2!

顶级目录概述

现在我们已经掌握了使用cd移动和使用ls列出目录内容的基础知识,让我们开始探索文件系统的其他部分。让我们从根文件系统下直接的每个目录的概述开始,如 FHS 所指定的:

位置 目的
/bin/ 包含普通用户使用的基本工具(=工具)
/boot/ 包含启动过程中使用的文件:kernelinitramfsbootloader
/dev/ 包含用于访问设备的特殊文件
/etc/ 软件配置文件的默认位置
/home/ 包含普通用户的主目录
/lib/ 包含系统
/lib64/ 包含64位系统
/media/ 可移动设备,如 USB 和 DVD,可以在这里找到
/mnt/ 默认为空,可以用来挂载其他文件系统
/opt/ 可以安装可选软件的目录
/proc/ 存储有关进程的信息的目录
/root/ root用户的主目录
/run/ 包含关于运行时数据的可变数据,每次启动都不同
/sbin/ 包含管理员用户使用的基本系统工具(=工具)
/srv/ 放置服务器服务的数据的目录
/sys/ 包含有关系统的信息,如驱动程序和内核功能
/tmp/ 用于临时文件的目录,通常在重新启动时清除(因为它存储在 RAM 中,而不是在磁盘上)
/usr/ 包含只读用户数据的非必要文件和二进制文件
/var/ 包含变量文件,如日志

虽然每个顶级目录都有重要的功能,但有一些我们将更仔细地检查,因为我们肯定会在我们的 shell 脚本中遇到它们。这些是/bin//sbin//usr//etc//opt//tmp//var/

多个分区呢?

但首先,我们想简要解释一些可能让你感到困惑的事情,特别是如果你来自 Windows 背景,习惯于C:\D:\E:\等形式的多个磁盘/分区。有了前面的目录结构和最高点在/的信息,Linux 如何处理多个磁盘/分区?

答案实际上相当简单。Linux 在树结构中的某个位置挂载文件系统。第一个挂载点位于我们已经介绍过的主分区上:它被挂载在/上!让我们看看在我们检查新的df工具时它是什么样子:

reader@ubuntu:~$ df -hT
Filesystem     Type      Size  Used Avail Use% Mounted on
udev           devtmpfs  464M     0  464M   0% /dev
tmpfs          tmpfs      99M  920K   98M   1% /run
/dev/sda2      ext4      9.8G  4.4G  5.0G  47% /
tmpfs          tmpfs     493M     0  493M   0% /dev/shm
tmpfs          tmpfs     5.0M     0  5.0M   0% /run/lock
tmpfs          tmpfs     493M     0  493M   0% /sys/fs/cgroup
/dev/loop0     squashfs   87M   87M     0 100% /snap/core/4917
/dev/loop1     squashfs   87M   87M     0 100% /snap/core/4486
/dev/loop2     squashfs   87M   87M     0 100% /snap/core/4830
tmpfs          tmpfs      99M     0   99M   0% /run/user/1000

虽然这是df报告文件系统磁盘空间使用情况)的大量输出,但最有趣的是之前突出显示的:类型为ext4(记得吗?)的分区/dev/sda2被挂载在/上。你将在本章后面看到一切都是文件的预览:/dev/sda2被处理为文件,但实际上是对磁盘上的分区的引用(在这种情况下是虚拟磁盘)。我们的 Arch Linux 主机的另一个示例提供了更多信息(如果你没有 Linux 主机,不用担心,我们以后会解释):

[root@caladan ~]# df -hT
Filesystem                          Type      Size  Used Avail Use% Mounted on
dev                                 devtmpfs  7.8G     0  7.8G   0% /dev
run                                 tmpfs     7.8G  1.5M  7.8G   1% /run
/dev/mapper/vg_caladan-lv_arch_root ext4       50G   29G   19G  60% /
tmpfs                               tmpfs     7.8G  287M  7.5G   4% /dev/shm
tmpfs                               tmpfs     7.8G     0  7.8G   0% /sys/fs/cgroup
tmpfs                               tmpfs     7.8G  212K  7.8G   1% /tmp
/dev/sda1                           vfat      550M   97M  453M  18% /boot
tmpfs                               tmpfs     1.6G   16K  1.6G   1% /run/user/120
tmpfs                               tmpfs     1.6G   14M  1.6G   1% /run/user/1000
/dev/sdc1   vfat       15G  552M   14G   4% /run/media/tammert/ARCH_201803
/dev/mapper/vg_caladan-lv_data      btrfs      10G   17M  9.8G   1% /data

你可以看到我有一个ext4文件系统挂载在我的根目录。然而,我还有一个额外的btrfs分区挂载在/data/上,以及一个vfat引导分区(在裸机安装时需要,但在虚拟机上不需要)挂载在/boot/上。最后,还有一个连接着 Arch Linux 安装程序的vfat USB 设备,它被自动挂载在/run/media/下。因此,Linux 不仅可以优雅地处理多个分区或磁盘,甚至不同类型的文件系统也可以在同一树结构下并存!

/bin/、/sbin/和/usr/

让我们回到顶级目录。我们将首先讨论/bin//sbin//usr/,因为它们非常相似。正如概述中所述,所有这些目录都包含了系统的普通用户和管理员使用的二进制文件。让我们看看这些二进制文件在哪里,以及我们的用户会话如何在进程中找到它们。我们将使用echo命令来管理这个过程。它的简短描述只是显示一行文本。让我们看看它是如何工作的:

reader@ubuntu:~$ echo

reader@ubuntu:~$ echo 'Hello'
Hello
reader@ubuntu:~$

如果我们使用echo而没有传递参数,将显示一行空白文本(基本上就像简短描述所承诺的那样!)。如果我们传递文本,将其用单引号括起来,那么该文本将被打印出来。在这种情况下,包含字母、数字或其他字符的文本被称为字符串。因此,我们传递给echo的任何字符串都将在我们的终端中打印出来。虽然这可能看起来不那么有趣,但当你开始考虑变量时,它就变得有趣了。变量是一个值会随时间变化的字符串,正如其名称所暗示的那样。让我们使用echo来打印变量BASH_VERSION的当前值:

reader@ubuntu:~$ echo BASH_VERSION
BASH_VERSION
reader@ubuntu:~$ echo $BASH_VERSION
4.4.19(1)-release
reader@ubuntu:~$

你应该注意到我们没有使用echo BASH_VERSION命令,因为那样会打印出文字BASH_VERSION,而是我们用$符号开始了变量名。在 Bash 中,$表示我们正在使用一个变量(我们将在第八章中进一步解释变量变量插值)。为什么我们告诉你这个?因为我们可以从我们的终端使用的二进制文件是通过使用一个变量找到的,具体来说是PATH变量:

reader@ubuntu:~$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin <SNIPPED>
reader@ubuntu:~$

如你所见,二进制文件需要在/usr/local/sbin//usr/local/bin//usr/sbin//usr/bin//sbin//bin/目录中才能使用(使用当前值的PATH,我们可以更改,但这暂时超出了范围)。这意味着我们到目前为止使用的二进制文件(cdlspwdecho)需要在这些目录中的一个中才能使用,对吗?不幸的是,这就是事情变得稍微复杂的地方。在 Linux 上,我们基本上使用两种类型的二进制文件:在磁盘上找到的(在PATH变量指定的目录中),或者它们可以内置到我们正在使用的 shell 中,然后称为shell 内置。一个很好的例子实际上是我们刚学到的echo命令,它两者都是!我们可以使用type来查看我们正在处理的命令的类型:

reader@ubuntu:~$ type -a echo
echo is a shell builtin
echo is /bin/echo
reader@ubuntu:~$ type -a cd
cd is a shell builtin
reader@ubuntu:~$

如果一个命令既是内置命令又是PATH中的二进制文件,则使用二进制文件。如果它只存在于内置命令中,比如cd,则使用内置命令。一般来说,你使用的大多数命令都是磁盘上的二进制文件,在你的PATH中找到。此外,这些命令中大多数都存在于/usr/bin/目录中(在我们的 Ubuntu 虚拟机上,超过一半的二进制文件都存在于/usr/bin/中!)。

因此,二进制目录的总体目标应该是清楚的:为我们提供执行工作所需的工具。问题仍然存在,为什么有(至少)六个不同的目录,它们为什么分为binsbin?对于问题的最后一部分的答案很容易:bin包含用户使用的常规实用程序,而sbin包含系统管理员使用的实用程序。在最后一类中,可以找到与磁盘维护、网络配置和防火墙等相关的工具。bin目录包含用于文件系统操作(例如创建和删除文件/目录)、存档和列出系统信息等的实用程序。

顶级目录/(s)bin//usr/(s)bin/之间的区别有点模糊。一般来说,基本工具可以在/(s)bin中找到,而系统特定的二进制文件则放在/usr/(s)bin目录中。因此,如果你安装了一个用于运行 Web 服务器的软件包,它将被放置在/usr/bin//usr/sbin/中,因为它是系统特定的。最后,根据我们的经验,/usr/local/(s)bin/目录最常用于手动安装的二进制文件,而不是从软件包管理器中安装。但你可以将它们放在PATH的任一目录中工作;这主要是一种惯例问题。

最后,/usr/包含的不仅仅是二进制文件。其中包括一些库(与/lib//lib64/顶级目录具有相同关系)和一些杂项文件。如果你感兴趣,我们绝对建议使用cdls来查看/usr/目录的其余部分,但最重要的是要记住二进制文件可以在这里找到。

/etc/

接下来是 Linux 文件系统中另一个有趣的顶级目录:/etc/目录。发音为et-c,用于存储系统软件和用户软件的配置文件。让我们看看它包含了什么:

reader@ubuntu:/etc# ls
acpi console-setup ethertypes inputrc logrotate.conf network python3 shadow ucf.conf
...<SNIPPED>:

我们剪掉了前面的输出,只保留了我们系统的顶行。如果你跟着这个例子(你应该这样做!)你会看到超过 150 个文件和目录。我们将使用cat命令打印一个特别有趣的文件:

reader@ubuntu:/etc$ cat fstab 
UUID=376cd784-7c8f-11e8-a415-080027a7d0ea / ext4 defaults 0 0
/swap.img    none    swap    sw    0    0
reader@ubuntu:/etc$

我们在这里看到的是文件系统表,或者fstab文件。它包含了 Linux 在每次启动时挂载文件系统的指令。正如我们在这里看到的,我们通过通用唯一标识符UUID)引用一个分区,并将其挂载在/上,作为根文件系统。它的类型是ext4,使用defaults选项挂载。最后的两个零处理系统启动时的备份和检查。在第二行,我们看到我们正在使用一个文件作为交换空间。交换空间用于在系统没有足够的内存可用时使用,可以通过将其写入磁盘来补偿(但会导致严重的性能损失,因为磁盘比 RAM 慢得多)。

/etc/目录中的另一个有趣的配置文件是passwd文件。虽然听起来像密码,但别担心,密码并没有存储在那里。让我们使用less命令查看内容:

reader@ubuntu:/etc$ less passwd

这将以只读模式在所谓的分页器中打开文件。less使用 Vim 命令,所以你可以通过在键盘上按Q来退出。如果文件比你的屏幕大,你可以使用 Vim 的按键:箭头键或使用JK来上下导航。在less中,屏幕应该看起来像下面这样:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...<SNIPPED>:
sshd:x:110:65534::/run/sshd:/usr/sbin/nologin
reader:x:1000:1004:Learn Linux Shell Scripting:/home/reader:/bin/bash

这个文件包含了系统上所有用户的信息。按顺序,由:分隔的字段表示以下内容:

用户名 密码 用户 ID(UID) 组 ID(GID) 用户真实姓名 主目录 用户默认 shell

尽管这里有一个密码字段,但这是出于传统原因;(哈希!)密码已经移动到/etc/shadow文件中,只有 root 超级用户才能读取。我们将在下一章中讨论 UID 和 GID;其他字段现在应该是清楚的。

这些只是在/etc/目录中找到的配置文件的两个例子(尽管很重要!)。

/opt//tmp//var/

在 Ubuntu 的新安装中,/opt/目录是空的。虽然这又是一个惯例问题,但根据我们的经验,这个目录最常用于安装来自发行版软件包管理器之外的软件。但是,一些使用软件包管理器安装的应用程序确实使用/opt/来存储它们的文件;这完全取决于软件包维护者的偏好。在我们的情况下,我们将使用这个目录来保存我们将要创建的 shell 脚本,因为这些绝对属于可选软件。

/tmp/目录用于临时文件(谁会猜到呢?)。在一些 Linux 发行版中,/tmp/不是根分区的一部分,而是作为单独的tmpfs文件系统挂载的。这种类型的文件系统是在 RAM 中分配的,这意味着/tmp/的内容在重新启动后不会存活。由于我们处理临时文件,这有时不仅是一个很好的功能,而且是特定用途的先决条件。例如,对于桌面 Linux 用户,可以用来保存只在活动会话期间需要的笔记,而无需担心在完成后清理它。

最后,/var/目录稍微复杂一些。让我们来看看:

reader@ubuntu:~$ cd /var/
reader@ubuntu:/var$ ls -l
total 48
drwxr-xr-x  2 root root   4096 Jul 29 10:14 backups
drwxr-xr-x 10 root root   4096 Jul 29 12:31 cache
drwxrwxrwt  2 root root   4096 Jul 28 10:30 crash
drwxr-xr-x 35 root root   4096 Jul 29 12:30 lib
drwxrwsr-x  2 root staff  4096 Apr 24 08:34 local
lrwxrwxrwx  1 root root      9 Apr 26 19:07 lock -> /run/lock
drwxrwxr-x 10 root syslog 4096 Jul 29 12:30 log
drwxrwsr-x  2 root mail   4096 Apr 26 19:07 mail
drwxr-xr-x  2 root root   4096 Apr 26 19:07 opt
lrwxrwxrwx  1 root root      4 Apr 26 19:07 run -> /run
drwxr-xr-x  3 root root   4096 Jun 30 18:20 snap
drwxr-xr-x  4 root root   4096 Apr 26 19:08 spool
drwxrwxrwt  4 root root   4096 Jul 29 15:04 tmp
drwxr-xr-x  3 root root   4096 Jul 29 12:30 www
reader@ubuntu:/var$

正如您所看到的,/var/包含许多子目录和一些符号链接(由->字符表示)。在这种情况下,/var/run/实际上是指向顶级目录/run的快捷方式。/var/中最有趣的子目录(目前)是log/mail/

/var/log/通常用于保存大多数系统和用户进程的日志文件。根据我们的经验,在 Linux 系统上安装的大多数第三方软件都会遵守这个惯例,并将日志文件输出到/var/log/目录,或者在/var/log/中创建一个子目录。让我们看一个使用less的完全限定路径的日志文件的例子:

reader@ubuntu:~$ less /var/log/kern.log

less分页器中,您将遇到类似以下内容的东西:

Jun 30 18:20:32 ubuntu kernel: [    0.000000] Linux version 4.15.0-23-generic (buildd@lgw01-amd64-055) (gcc version 7.3.0 (Ubuntu 7.3.0-16ubuntu3)) #25-Ubuntu SMP Wed May 23 18:02:16 UTC 2018 (Ubuntu 4.15.0-23.25-generic 4.15.18)
Jun 30 18:20:32 ubuntu kernel: [    0.000000] Command line: BOOT_IMAGE=/boot/vmlinuz-4.15.0-23-generic root=UUID=376cd784-7c8f-11e8-a415-080027a7d0ea ro maybe-ubiquity
Jun 30 18:20:32 ubuntu kernel: [    0.000000] KERNEL supported cpus:
Jun 30 18:20:32 ubuntu kernel: [    0.000000]   Intel GenuineIntel
Jun 30 18:20:32 ubuntu kernel: [    0.000000]   AMD AuthenticAMD
...<SNIPPED>:

这个日志文件包含有关内核引导过程的信息。您可以看到对磁盘上实际内核的引用,/boot/vmlinuz-4.15.0-23-generic,以及挂载在根目录的文件系统的 UUID,UUID=376cd784-7c8f-11e8-a415-080027a7d0ea。如果您的系统在启动时出现问题或某些功能似乎无法正常工作,您将检查此文件!

在 Unix 和 Linux 的早期,发送邮件不仅仅是在互联网上使用(当时互联网还处于萌芽阶段),还用于在服务器或同一服务器上的用户之间中继消息。在您的新 Ubuntu 虚拟机上,/var/mail/目录及其符号链接/var/spool/mail/将是空的。但是,一旦我们开始讨论调度和日志记录,我们将看到该目录将用于存储消息。

这就是关于默认 Linux 文件系统中顶级目录的简要描述。我们讨论了我们认为与 shell 脚本相关的最重要的目录。然而,随着时间的推移,您将对所有目录有所了解,并且在 Linux 文件系统中找到任何东西肯定会变得更容易,尽管现在可能听起来很困难。

一切都是文件

在 Linux 下,有一个众所周知的表达:

在 Linux 系统中,一切都是文件;如果某物不是文件,那就是一个进程。

虽然这并不是严格意义上的 100%真实,但至少对于您在 Linux 上遇到的 90%的事情来说是真实的,特别是如果您还不是很高级的话。尽管一般来说,这个规则是成立的,但它还有一些额外的注意事项。尽管 Linux 上的大部分东西都是文件,但有不同的文件类型,确切地说是七种。我们将在接下来的页面上讨论它们。您可能不会使用所有七种;但是,对它们都有基本的了解可以让您更好地理解 Linux,这绝对是一件好事!

不同类型的文件

这七种文件类型如下,用 Linux 用来表示它们的字符:

类型 解释
-: 常规文件 一个包含文本或字节的常规文件
d: 目录 一个目录,可以包含其他目录和常规文件
l: 符号链接 用作快捷方式
s: 套接字 用于通信的通道
c: 特殊文件 主要用于设备处理程序
b: 块设备 表示存储硬件的类型,如磁盘分区
p: 命名管道 用于进程之间进行通信

在这七种文件类型中,您首先会遇到常规文件(-)和目录(d)。接下来,您可能会更多地与符号链接(l)、块设备(b)和特殊文件(c)进行交互。很少会使用最后两种:套接字(s)和命名管道(p)。

/dev/ 中遇到最常见的文件类型是一个不错的地方。让我们使用 ls 命令来查看它包含了什么:

reader@ubuntu:/dev$ ls -l /dev/
total 0
crw-r--r-- 1 root root     10, 235 Jul 29 15:04 autofs
drwxr-xr-x 2 root root         280 Jul 29 15:04 block
drwxr-xr-x 2 root root          80 Jul 29 15:04 bsg
crw-rw---- 1 root disk     10, 234 Jul 29 15:04 btrfs-control
drwxr-xr-x 3 root root          60 Jul 29 15:04 bus
lrwxrwxrwx 1 root root           3 Jul 29 15:04 cdrom -> sr0
drwxr-xr-x 2 root root        3500 Jul 29 15:04 char
crw------- 1 root root      5,   1 Jul 29 15:04 console
lrwxrwxrwx 1 root root          11 Jul 29 15:04 core -> /proc/kcore
...<SNIPPED>:
brw-rw---- 1 root disk      8,   0 Jul 29 15:04 sda
brw-rw---- 1 root disk      8,   1 Jul 29 15:04 sda1
brw-rw---- 1 root disk      8,   2 Jul 29 15:04 sda2
crw-rw---- 1 root cdrom    21,   0 Jul 29 15:04 sg0
crw-rw---- 1 root disk     21,   1 Jul 29 15:04 sg1
drwxrwxrwt 2 root root          40 Jul 29 15:04 shm
crw------- 1 root root     10, 231 Jul 29 15:04 snapshot
drwxr-xr-x 3 root root         180 Jul 29 15:04 snd
brw-rw---- 1 root cdrom    11,   0 Jul 29 15:04 sr0
lrwxrwxrwx 1 root root          15 Jul 29 15:04 stderr -> /proc/self/fd/2
lrwxrwxrwx 1 root root          15 Jul 29 15:04 stdin -> /proc/self/fd/0
lrwxrwxrwx 1 root root          15 Jul 29 15:04 stdout -> /proc/self/fd/1
crw-rw-rw- 1 root tty       5,   0 Jul 29 17:58 tty
crw--w---- 1 root tty       4,   0 Jul 29 15:04 tty0
crw--w---- 1 root tty       4,   1 Jul 29 15:04 tty1
...<SNIPPED>:
reader@ubuntu:/dev$

正如您从输出中看到的那样,/dev/ 包含了大量文件,其中大多数类型如上所述。具有讽刺意味的是,它不包含最常见的文件类型:常规文件。但是,因为我们一直在与常规文件交互,所以您应该对它们有所了解(否则本书的其余部分肯定会给您一个概念)。

因此,让我们看看除了常规文件之外的任何东西。让我们从最熟悉的开始:目录。任何以 d 开头的行都是一个目录,如果您使用 SSH,它很可能也会以不同的颜色表示。不要低估这种视觉辅助的重要性,因为当您在 Linux 机器上导航时,它会为您节省大量时间。记住,您可以使用相对路径或绝对路径(始终从文件系统的根目录开始)来进入目录,其中相对路径使用 cd 命令。

接下来,您将看到以 b 开头的文件。这些文件用于表示 设备,最常见的用途是磁盘设备或分区。在大多数 Linux 发行版中,磁盘通常被称为 /dev/sda/dev/sdb 等。这些磁盘上的分区用数字表示:/dev/sda1/dev/sda2 等。正如您在前面的输出中所看到的,我们的系统只有一个磁盘(只有 /dev/sda)。但是该磁盘有两个分区:/dev/sda1/dev/sda2。再次尝试使用 df -hT 命令,您会注意到 /dev/sda2 被挂载为根文件系统(除非您的虚拟机配置不同,否则可能是 /dev/sda1 或甚至 /dev/sda3)。

在 Linux 上经常使用符号链接。在前面的输出中查找条目 cdrom,您会看到它以 l 开头。术语 cdrom 具有上下文意义:它指的是 CD(或更可能是在新系统中,DVD)驱动器。但是,它链接到处理交互的实际块设备 /dev/sr0,它以 b 开头表示块设备。使用符号链接可以轻松找到您需要的项目(磁盘驱动器),同时仍然保留 Linux 配置,调用设备处理程序 sr0

最后,您应该看到一个名为tty的文件的长列表。这些文件的开头标有c,表示特殊文件。为了简单起见,您应该将tty视为连接到 Linux 服务器的终端。这些是 Linux 用来允许用户与系统进行交互的一种虚拟设备。许多虚拟和物理设备在它们出现在 Linux 文件系统上时使用特殊文件处理程序。

本章向您介绍了许多命令。也许您已经厌倦了一切都要手动输入,也许没有。无论如何,我们有一些好消息:Bash 有一个叫做自动完成的功能。我们不想过早介绍它以避免混淆,但在使用 Linux 系统时,它被广泛使用,如果我们不解释它,我们就会欺骗您。

实际上很简单:如果在命令的第一部分(如cdls)后按下Tab键,如果只有一个选择,它将完成您的命令,或者如果再次按下Tab,它将向您呈现一个选项列表。转到/,输入cd,然后按两次Tab键,看看它是如何工作的。进入/home/目录并在输入cd后按一次Tab键将使其自动完成,只有一个目录,节省时间!

摘要

在本章中,我们介绍了 Linux 文件系统的概述。我们首先简要介绍了一般文件系统,然后解释了 Linux 文件系统的独特之处。讨论了 Ext4、XFS 和 Btrfs 文件系统实现,以及这些文件系统的日志记录功能。接下来,解释了 Linux 遵循的 FHS,然后详细介绍了 Linux 文件系统的更重要部分。这是通过探索构成 Linux 文件系统的树结构的部分来完成的。我们解释了可以在树的某个地方挂载不同的文件系统。最后,我们解释了在 Linux 上(几乎)一切都被处理为文件,并讨论了使用的不同文件类型。

本章介绍了以下命令:pwdcddfechotypecatless。作为提示,解释了 Bash 自动完成功能。

问题

  1. 什么是文件系统?

  2. 哪些 Linux 特定的文件系统最常见?

  3. 真或假:Linux 上可以同时使用多个文件系统实现?

  4. 大多数 Linux 文件系统实现中存在的日志记录功能是什么?

  5. 根文件系统挂载在树的哪个位置?

  6. PATH变量用于什么?

  7. 根据 FHS,配置文件存储在哪个顶级目录中?

  8. 进程日志通常保存在哪里?

  9. Linux 有多少种文件类型?

  10. Bash 自动完成功能是如何工作的?

进一步阅读

如果您想更深入地了解本章的主题,可以参考以下资源:

第五章:理解 Linux 权限方案

在本章中,我们将探讨 Linux 权限方案是如何实现的。文件和目录的读取、写入和执行权限将被讨论,我们将看到它们如何不同地影响文件和目录。我们将看到多个用户如何使用组一起工作,以及一些文件和目录也对其他人可用。

本章将介绍以下命令:idtouchchmodumaskchownchgrpsudouseraddgroupaddusermodmkdirsu

本章将涵盖以下主题:

  • 读取,写入和执行

  • 用户,组和其他人

  • 与多个用户一起工作

  • 高级权限

技术要求

我们将使用我们在第二章中创建的虚拟机来探索 Linux 权限方案,设置您的本地环境。在本章中,我们将向该系统添加新用户,但目前只有作为第一个用户(具有管理或root权限)的访问权限就足够了。

读取,写入和执行

在上一章中,我们讨论了 Linux 文件系统以及 Linux 实现“一切皆文件”哲学的不同类型。然而,我们没有看文件的权限。你可能已经猜到,在一个多用户系统,比如 Linux 服务器中,用户可以访问其他用户拥有的文件并不是一个特别好的主意。隐私在哪里呢?

Linux 权限方案实际上是 Linux 体验的核心,就我们而言。就像在 Linux 中(几乎)一切都被处理为文件一样,所有这些文件都有一组不同的权限。在上一章中探索文件系统时,我们限制了自己只能查看所有人或当前登录用户可见的文件。然而,有许多文件只能被root用户查看或写入:通常,这些是一些敏感文件,比如/etc/shadow(其中包含所有用户的哈希密码),或者在启动系统时使用的文件,比如/etc/fstab(确定哪些文件系统在启动时被挂载)。如果每个人都可以编辑这些文件,很快就会导致系统无法启动!

RWX

Linux 下的文件权限由三个属性处理:读取写入执行,或者 RWX。虽然还有其他权限(我们将在本章后面讨论一些),但大多数权限交互将由这三个属性处理。尽管这些名称似乎不言自明,但它们在(普通)文件和目录方面的行为是不同的。以下表格应该说明这一点:

允许用户使用任何支持此操作的命令查看文件的内容,比如vimnanolesscat等。

权限 对普通文件 对目录
读取 允许用户使用ls命令列出目录的内容。这甚至会列出用户没有其他权限的目录中的文件!
写入 允许用户对文件进行更改。 允许用户替换或删除目录中的文件,即使用户对该文件没有直接权限。但这不包括对目录中所有文件的读取权限!
执行 允许用户执行文件。当文件是应该被执行的东西,比如二进制文件或脚本时,这是相关的;否则,这个属性什么也不做。 允许用户使用 cd 进入目录。这是与内容列表不同的权限,但它们几乎总是一起使用;能够列出而不能进入(反之亦然)大多数情况下是无效的配置。

这个概述应该为这三种不同的权限提供了一个基础。请仔细看一看,看看你是否完全理解了那里呈现的内容。

现在,情况将变得更加复杂。虽然文件和目录上的这些权限显示了用户可以做什么和不能做什么,但 Linux 如何处理多个用户?Linux 如何跟踪文件的所有权,文件如何被多个用户共享?

用户、组和其他

在 Linux 下,每个文件都由一个用户和一个组拥有。每个用户都有一个标识号,用户 IDUID)。组也是一样:它由一个组 IDGID)解析。每个用户有一个 UID 和一个主要GID;然而,用户可以是多个组的成员。在这种情况下,用户将有一个或多个附加 GID。您可以通过在 Ubuntu 机器上运行id命令来自己看到这一点:

reader@ubuntu:~$ id
uid=1000(reader) gid=1004(reader) groups=1004(reader),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),108(lxd),1000(lpadmin),1001(sambashare),1002(debian-tor),1003(libvirtd)
reader@ubuntu:~$

在前面的输出中,我们可以看到以下内容:

  • reader用户的uid1000;Linux 通常从1000开始对普通用户进行编号

  • gid1004,对应于reader组;默认情况下,Linux 会创建一个与用户同名的组(除非明确告知不要创建)

  • 其他组包括 admsudo 和其他

这意味着什么?当前登录的用户具有uid1000,主要gid1004,以及一些附加组,这确保它具有其他特权。例如,在 Ubuntu 下,cdrom组允许用户访问光驱。sudo组允许用户执行管理命令,adm组允许用户读取管理文件。

虽然我们通常按名称引用用户和组,但这只是 Linux 为我们提供的 UID 和 GID 的表示。在系统级别上,只有 UID 和 GID 对权限很重要。这使得例如,有两个具有相同用户名但不同 UID 的用户成为可能:这些用户的权限将不同。反之亦然也是可能的:两个不同用户名但相同 UID 的用户,这将导致两个用户的权限在 UID 级别上至少是相同的。然而,这两种情况都非常令人困惑,不应该使用!正如我们将在后面看到的那样,使用组来共享权限是共享文件和目录的最佳解决方案。

另一件需要记住的事情是,UID 和 GID 是本地的。所以,如果我在机器 A 上有一个名为 bob 的用户,UID 为 1000,在机器 B 上 UID 为 1000 映射到用户 alice,将 bob 的文件从机器 A 传输到机器 B 将导致文件在系统 B 上被 alice 拥有!

之前解释的 RWX 权限与我们现在讨论的用户和组有关。实质上,每个文件(或目录,这只是一种不同类型的文件)都有以下属性:

  • 文件由一个用户拥有,该用户具有(部分)RWX 权限

  • 文件也由一个拥有,再次,有(部分)RWX 权限

  • 文件最终对其他人具有 RWX 权限,这意味着所有不共享该组的不同用户

要确定用户是否可以读取、写入或执行文件或目录,我们需要查看以下属性(不一定按照这个顺序):

  • 用户是否是文件的所有者?所有者有什么 RWX 权限?

  • 用户是否是拥有文件的组的一部分?为组设置了什么 RWX 权限?

  • 文件在其他属性上有足够的权限吗?

在变得太抽象之前,让我们看一些简单的例子。在您的虚拟机上,按照以下命令操作:

reader@ubuntu:~$ pwd
/home/reader
reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
reader@ubuntu:~$ touch testfile
reader@ubuntu:~$

首先,我们确保我们在reader用户的home目录中。如果不是,我们可以使用cd /home/reader命令或者只输入cd(没有参数时,cd默认为用户的home目录!)。我们继续使用ls -l以长格式列出目录的内容,其中显示了一个文件:nanofile.txt,来自第二章,设置您的本地环境(如果您没有跟随那里并且没有该文件,不用担心;我们稍后将创建和操作文件)。我们使用一个新命令touch来创建一个空文件。我们指定给touch的参数被解释为文件名,当我们再次列出文件时可以看到。

reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
-rw-rw-r-- 1 reader reader  0 Aug  4 13:44 testfile
reader@ubuntu:~$

您将看到权限后面跟着两个名称:用户名和组名(按顺序!)。对于我们的testfile,用户readerreader组的成员都可以读取和写入文件,但不能执行(在x的位置上,有一个-,表示没有该权限)。所有其他用户,例如既不是读者也不是读者组的成员(在这种情况下,实际上是所有其他用户),由于其他人的权限,只能读取文件。这也在下表中描述:

文件类型 (第一个字符) 用户权限(第 2 到 4 个字符) 组权限(第 5 到 7 个字符) 其他权限(第 8 到 10 个字符) 用户所有权 组所有权
-(普通文件) rw-,读和写,无执行 rw-,读和写,无执行 r--,只读 读者 读者

如果一个文件对每个人都有完全权限,它会是这样的:-rwxrwxrwx。对于所有者和组都有所有权限,但其他人没有任何权限的文件,它将是-rwxrwx---。对于用户和组都有所有权限,但其他人没有任何权限的目录,表示为drwxrwx---

让我们看另一个例子:

reader@ubuntu:~$ ls -l /
<SNIPPED>
dr-xr-xr-x 98 root root          0 Aug  4 10:49 proc
drwx------  3 root root       4096 Jul  1 09:40 root 

drwxr-xr-x 25 root root        900 Aug  4 10:51 run
<SNIPPED>
reader@ubuntu:~$

系统超级用户的home目录是/root/。我们可以从行的第一个字符看到它是一个d,表示目录。它对所有者root具有 RWX(最后一次:读取、写入、执行)权限,对组(也是root)没有权限,对其他人(由---表示)也没有权限。这些权限只能意味着一件事:只有用户 root **可以进入或操作此目录!**让我们看看我们的假设是否正确。请记住,进入目录需要x权限,而列出目录内容需要r权限。我们既不是root用户也不在 root 组,因此我们既不能做任何一件事。在这种情况下,将应用其他人的权限,即---

reader@ubuntu:~$ cd /root/
-bash: cd: /root/: Permission denied
reader@ubuntu:~$ ls /root/
ls: cannot open directory '/root/': Permission denied
reader@ubuntu:~$

操作文件权限和所有权

阅读本章的第一部分后,您应该对 Linux 文件权限有了相当好的理解,以及如何在用户、组和其他级别上使用读取、写入和执行来确保文件的暴露正好符合要求。然而,直到这一点,我们一直在处理静态权限。在管理 Linux 系统时,您很可能会花费大量时间调整和解决权限问题。在本书的这一部分中,我们将探讨可以用来操作文件权限的命令。

chmod,umask

让我们回到我们的testfile。它具有以下权限:-rw-rw----。用户和组可读/写,其他人可读。虽然这些权限对大多数文件来说可能是合适的,但对于所有文件来说肯定不是一个很好的选择。私人文件呢?您可能不希望这些文件被所有人读取,甚至可能不希望被组成员读取。

在 Linux 中,更改文件或目录的权限的命令是chmod,我们喜欢将其读作change file mode。chmod有两种操作模式:符号模式和数字/八进制模式。我们将首先解释符号模式(更容易理解),然后再转到八进制模式(更快速使用)。

我们还没有介绍的是查看命令手册的命令。该命令就是man,后面跟着你想要查看手册的命令。在这种情况下,man chmod会将我们放入chmod手册分页器中,它使用与你学习 Vim 相同的导航控件。记住,退出是通过输入:q来完成的。在这种情况下,只需输入q就足够了。现在看一下chmod手册,至少读一下description标题;这将使接下来的解释更清晰。

符号模式使用了我们之前看到的 UGOA 字母的 RWX 构造。这可能看起来很新,但实际上并不是!Users、Groups、Others 和All 用于表示我们正在更改的权限。

要添加权限,我们告诉chmod我们(用户、组、其他或所有)为谁做这个操作,然后是我们想要添加的权限。例如,chmod u+x <filename>将为用户添加执行权限。类似地,使用chmod删除权限的方法如下:chmod g-rwx <filename>。请注意,我们使用+号来添加权限,使用-号来删除权限。如果我们没有指定用户、组、其他或所有,所有将默认使用。让我们在我们的 Ubuntu 机器上试一下:

reader@ubuntu:~$ cd
reader@ubuntu:~$ pwd
/home/reader
reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
-rw-rw-r-- 1 reader reader  0 Aug  4 13:44 testfile
reader@ubuntu:~$ chmod u+x testfile 
reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
-rwxrw-r-- 1 reader reader  0 Aug  4 13:44 testfile
reader@ubuntu:~$ chmod g-rwx testfile 
reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
-rwx---r-- 1 reader reader  0 Aug  4 13:44 testfile
reader@ubuntu:~$ chmod -r testfile 
reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
--wx------ 1 reader reader  0 Aug  4 13:44 testfile
reader@ubuntu:~$

首先,我们为testfile添加了用户的执行权限。接下来,我们从组中移除了读取、写入和执行权限,结果是-rwx---r--。在这种情况下,组成员仍然可以读取文件,然而,因为每个人仍然可以读取文件。可以说这不是隐私的完美权限。最后,我们在-r之前没有指定任何内容,这实际上移除了用户、组和其他的读取权限,导致文件最终变成了--wx------

能够写和执行一个你无法阅读的文件有点奇怪。让我们来修复它,看看八进制权限是如何工作的!我们可以使用chmodverbose选项,通过使用-v标志来打印更多信息:

reader@ubuntu:~$ chmod -v u+rwx testfile
mode of 'testfile' changed from 0300 (-wx------) to 0700 (rwx------)
reader@ubuntu:~$

正如你所看到的,我们现在从chmod得到了输出!具体来说,我们可以看到八进制模式。在我们改变文件之前,模式是0300,在为用户添加读取权限后,它跳到了0700。这些数字代表什么?

这一切都与权限的二进制实现有关。对于所有三个级别(用户、组、其他),在结合读取、写入和执行时,有 8 种不同的可能权限,如下所示:

符号 八进制
--- 0
--x 1
-w- 2
-wx 3
r-- 4
r-x 5
rw- 6
rwx 7

基本上,八进制值在 0 和 7 之间,总共有 8 个值。这就是为什么它被称为八进制:来自于拉丁语/希腊语中 8 的表示,octo。读取权限被赋予值 4,写入权限被赋予值 2,执行权限被赋予值 1。

通过使用这个系统,0 到 7 的值总是可以唯一地与 RWX 值相关联。RWX 是4+2+1 = 7,RX 是4+1 = 5,依此类推。

现在我们知道了八进制表示是如何工作的,我们可以使用它们来使用chmod修改文件权限。让我们在一个命令中为用户、组和其他给予测试文件完全权限(RWX 或 7):

reader@ubuntu:~$ chmod -v 0777 testfile 
mode of 'testfile' changed from 0700 (rwx------) to 0777 (rwxrwxrwx)
reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
-rwxrwxrwx 1 reader reader  0 Aug  4 13:44 testfile
reader@ubuntu:~$

在这种情况下,chmod接受四个数字作为参数。第一个数字涉及到一种特殊类型的权限,称为粘滞位;我们不会讨论这个,但我们已经在Further reading部分中包含了相关材料,供感兴趣的人参考。在这些示例中,它总是设置为0,因此没有设置特殊位。第二个数字映射到用户权限,第三个映射到组权限,第四个,不出所料,映射到其他权限。

如果我们想要使用符号表示法来做到这一点,我们可以使用chmod a+rwx命令。那么,为什么八进制比我们之前说的更快呢?让我们看看如果我们想要为每个级别设置不同的权限会发生什么,例如-rwxr-xr--。如果我们想要用符号表示法来做到这一点,我们需要使用三个命令或一个链接的命令(chmod的另一个功能):

reader@ubuntu:~$ chmod 0000 testfile 
reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
---------- 1 reader reader  0 Aug  4 13:44 testfile
reader@ubuntu:~$ chmod u+rwx,g+rx,o+r testfile 
reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
-rwxr-xr-- 1 reader reader  0 Aug  4 13:44 testfile
reader@ubuntu:~$

chmod u+rwx,g+rx,o+r testfile命令中可以看出,事情变得有点复杂。然而,使用八进制表示法,命令要简单得多:

reader@ubuntu:~$ chmod 0000 testfile 
reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
---------- 1 reader reader  0 Aug  4 13:44 testfile
reader@ubuntu:~$ chmod 0754 testfile 
reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
-rwxr-xr-- 1 reader reader  0 Aug  4 13:44 testfile
reader@ubuntu:~$

基本上,主要区别在于使用命令式表示法(添加或删除权限)与声明式表示法(将其设置为这些值)。根据我们的经验,声明式几乎总是更好/更安全的选择。使用命令式,我们需要首先检查当前的权限并对其进行变异;而使用声明式,我们可以在单个命令中指定我们想要的内容。

现在可能很明显了,但我们更喜欢使用八进制表示法。除了从更短、更简单的命令中受益,这些命令是以声明方式处理的,另一个好处是大多数您在网上找到的示例也使用八进制表示法。要完全理解这些示例,您至少需要了解八进制。而且,如果无论如何都需要理解它们,那么在日常生活中使用它们是最好的!

早些时候,当我们使用touch命令时,我们最终得到了一个文件,该文件可以被用户和组读取和写入,并且对其他人是可读的。这些似乎是默认权限,但它们是从哪里来的?我们如何操纵它们?让我们来认识一下umask

reader@ubuntu:~$ umask
0002
reader@ubuntu:~$

umask会话用于确定新创建的文件和目录的文件权限。对于文件,执行以下操作:取文件的最大八进制值0666,然后减去umask(在本例中为0002),这给我们0664。这意味着新创建的文件是-rw-rwr--,这正是我们在testfile中看到的。你可能会问,为什么我们使用0666而不是0777?这是 Linux 提供的一种保护措施;如果我们使用0777,大多数文件将被创建为可执行文件。可执行文件可能是危险的,因此设计决策是文件只有在明确设置为可执行时才能执行。因此,根据当前的实现,没有意外创建可执行文件这样的事情。对于目录,使用正常的八进制值0777,这意味着目录以0775-rwxrwxr-x权限创建。我们可以通过使用mkdir命令创建一个新目录来验证这一点:

reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
-rwxr-xr-- 1 reader reader  0 Aug  4 13:44 testfile
reader@ubuntu:~$ umask
0002
reader@ubuntu:~$ mkdir testdir
reader@ubuntu:~$ ls -l
total 8
-rw-rw-r-- 1 reader reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader 4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader    0 Aug  4 13:44 testfile
reader@ubuntu:~$

因为目录的执行权限要少得多(记住,它用于确定您是否可以进入目录),所以这个实现与文件不同。

关于umask,我们还有一个技巧要展示。在特定情况下,我们想要自己确定文件和目录的默认值。我们也可以使用umask命令来做到这一点:

reader@ubuntu:~$ umask
0002
reader@ubuntu:~$ umask 0007
reader@ubuntu:~$ umask
0007
reader@ubuntu:~$ touch umaskfile
reader@ubuntu:~$ mkdir umaskdir
reader@ubuntu:~$ ls -l
total 12
-rw-rw-r-- 1 reader reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader 4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader    0 Aug  4 13:44 testfile
drwxrwx--- 2 reader reader 4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader reader    0 Aug  4 16:18 umaskfile
reader@ubuntu:~$

在上面的例子中,您可以看到运行umask命令而不带参数会打印当前的 umask。如果以有效的 umask 值作为参数运行它,将会改变 umask 为该值,然后在创建新文件和目录时使用。将上述输出中的umaskfileumaskdir与之前的testfiletestdir进行比较。如果我们想要默认创建私有文件,这将非常有用!

sudo、chown 和 chgrp

到目前为止,我们已经看到了如何操纵文件和目录的(基本)权限。然而,我们还没有处理更改文件的所有者或组。总是必须按照创建时的用户和组来工作有点不切实际。对于 Linux,我们可以使用两个工具来更改所有者和组:change owner(chown)和change groupchgrp)。然而,有一件非常重要的事情要注意:这些命令只能由具有 root 权限的人执行(通常是root用户)。因此,在我们向你介绍chownchgrp之前,让我们先看看sudo

sudo

sudo命令最初是为superuser do命名的,正如其名字所暗示的,它给了你一个机会以 root 超级用户的身份执行操作。sudo命令使用/etc/sudoers文件来确定用户是否被允许提升到超级用户权限。让我们看看它是如何工作的!

reader@ubuntu:~$ cat /etc/sudoers
cat: /etc/sudoers: Permission denied
reader@ubuntu:~$ ls -l /etc/sudoers
-r--r----- 1 root root 755 Jan 18  2018 /etc/sudoers
reader@ubuntu:~$ sudo cat /etc/sudoers
[sudo] password for reader: 
#
# This file MUST be edited with the 'visudo' command as root.
#
# Please consider adding local content in /etc/sudoers.d/ instead of
# directly modifying this file.
#
# See the man page for details on how to write a sudoers file.
#
Defaults    env_reset
Defaults    mail_badpass
Defaults  secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"
<SNIPPED>
# User privilege specification
root    ALL=(ALL:ALL) ALL

# Members of the admin group may gain root privileges
%admin ALL=(ALL) ALL

# Allow members of group sudo to execute any command
%sudo    ALL=(ALL:ALL) ALL
<SNIPPED>
reader@ubuntu:~$

我们首先尝试以普通用户的身份查看/etc/sudoers的内容。当这给我们一个Permission denied错误时,我们查看文件的权限。从-r--r----- 1 root root这一行,很明显只有root用户或root组的成员才能读取该文件。为了提升到 root 权限,我们使用sudo命令我们想要运行的命令前面,即cat /etc/sudoers。为了验证,Linux 会始终要求用户输入密码。默认情况下,这个密码会被保存在内存中大约 5 分钟,所以如果你最近输入过密码,你就不必每次都输入密码。

输入密码后,/etc/sudoers文件就会被打印出来!看来sudo确实给了我们超级用户权限。这是如何工作的也可以通过/etc/sudoers文件来解释。# Allow members of group sudo to execute any command这一行是一个注释(因为它以#开头;稍后会详细介绍),告诉我们下面的行给了sudo组的所有用户任何命令的权限。在 Ubuntu 上,默认创建的用户被认为是管理员,并且是这个组的成员。使用id命令来验证这一点:

reader@ubuntu:~$ id
uid=1000(reader) gid=1004(reader) groups=1004(reader),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),108(lxd),1000(lpadmin),1001(sambashare),1002(debian-tor),1003(libvirtd)
reader@ubuntu:~$

sudo命令还有另一个很好的用途:切换到root用户!为此,使用--login标志,或者它的简写,-i

reader@ubuntu:~$ sudo -i
[sudo] password for reader: 
root@ubuntu:~#

在提示符中,你会看到用户名已经从reader变成了root。此外,你的提示符中的最后一个字符现在是#而不是$。这也用于表示当前的提升权限。你可以使用内置的exit shell 退出这个提升的位置:

root@ubuntu:~# exit
logout
reader@ubuntu:~$

记住,root用户是系统的超级用户,可以做任何事情。而且,我们真的是指任何事情!与其他操作系统不同,如果你告诉 Linux 删除根文件系统和其下的一切,它会乐意遵从(直到它破坏了太多以至于无法正常工作为止)。也不要指望有Are you sure?的提示。对于sudo命令或者 root 提示中的任何东西都要非常小心。

chown,chgrp

经过一小段sudo的绕道之后,我们可以回到文件权限:我们如何改变文件的所有权?让我们从使用chgrp来改变组开始。语法如下:chgrp <groupname> <filename>

reader@ubuntu:~$ ls -l
total 12
-rw-rw-r-- 1 reader reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader 4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader    0 Aug  4 13:44 testfile
drwxrwx--- 2 reader reader 4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader reader    0 Aug  4 16:18 umaskfile
reader@ubuntu:~$ chgrp games umaskfile 
chgrp: changing group of 'umaskfile': Operation not permitted
reader@ubuntu:~$ sudo chgrp games umaskfile 
reader@ubuntu:~$ ls -l
total 12
-rw-rw-r-- 1 reader reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader 4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader    0 Aug  4 13:44 testfile
drwxrwx--- 2 reader reader 4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games     0 Aug  4 16:18 umaskfile
reader@ubuntu:~$

首先,我们使用ls列出内容。接下来,我们尝试使用chgrpumaskfile文件的组更改为 games。然而,由于这是一个特权操作,我们没有以sudo开头启动命令,所以它失败了,显示Operation not permitted错误消息。接下来,我们使用正确的sudo chgrp games umaskfile命令,这通常是 Linux 中的一个好迹象。我们再次列出文件,确保情况是这样,我们可以看到umaskfile的组已经更改为games

让我们做同样的事情,但现在是为了用户,使用chown命令。语法与chgrp相同:chown <username> <filename>

reader@ubuntu:~$ sudo chown pollinate umaskfile 
reader@ubuntu:~$ ls -l
total 12
-rw-rw-r-- 1 reader    reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader    reader 4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader    reader    0 Aug  4 13:44 testfile
drwxrwx--- 2 reader    reader 4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 pollinate games     0 Aug  4 16:18 umaskfile
reader@ubuntu:~$

正如我们所看到的,我们现在已经将文件所有权从reader:reader更改为pollinate:games。然而,有一个小技巧非常方便,我们想立刻向您展示!您实际上可以使用chown通过以下语法更改用户和组:chown <username>:<groupname> <filename>。让我们看看这是否可以将umaskfile恢复到其原始所有权:

reader@ubuntu:~$ ls -l
total 12
-rw-rw-r-- 1 reader    reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader    reader 4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader    reader    0 Aug  4 13:44 testfile
drwxrwx--- 2 reader    reader 4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 pollinate games     0 Aug  4 16:18 umaskfile
reader@ubuntu:~$ sudo chown reader:reader umaskfile 
reader@ubuntu:~$ ls -l
total 12
-rw-rw-r-- 1 reader reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader 4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader    0 Aug  4 13:44 testfile
drwxrwx--- 2 reader reader 4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader reader    0 Aug  4 16:18 umaskfile
reader@ubuntu:~$

在前面的示例中,我们使用了随机用户和组。如果要查看系统上存在哪些组,请检查/etc/group文件。对于用户,相同的信息可以在/etc/passwd中找到。

与多个用户一起工作

正如我们之前所说,Linux 本质上是一个多用户系统,特别是在 Linux 服务器的情况下,这些系统通常不是由单个用户,而是经常是(大型)团队管理。服务器上的每个用户都有自己的权限集。例如,想象一下,一个服务器需要开发、运营和安全三个部门。开发和运营都有自己的东西,但也需要共享其他一些东西。安全部门需要能够查看所有内容,以确保符合安全准则和规定。我们如何安排这样的结构?让我们实现它!

首先,我们需要创建一些用户。对于每个部门,我们将创建一个单一用户,但由于我们将确保组级别的权限,因此对于每个部门中的 5、10 或 100 个用户来说,这也同样有效。我们可以使用useradd命令创建用户。在其基本形式中,我们可以只使用useradd <username>,Linux 将通过默认值处理其余部分。显然,与 Linux 中的几乎所有内容一样,这是高度可定制的;有关更多信息,请查看 man 页面(man useradd)。

chownchgrp一样,useradd(以及后来的usermod)是一个特权命令,我们将使用sudo来执行:

reader@ubuntu:~$ useradd dev-user1
useradd: Permission denied.
useradd: cannot lock /etc/passwd; try again later.
reader@ubuntu:~$ sudo useradd dev-user1
[sudo] password for reader: 
reader@ubuntu:~$ sudo useradd ops-user1
reader@ubuntu:~$ sudo useradd sec-user1
reader@ubuntu:~$ id dev-user1
uid=1001(dev-user1) gid=1005(dev-user1) groups=1005(dev-user1)
reader@ubuntu:~$ id ops-user1 
uid=1002(ops-user1) gid=1006(ops-user1) groups=1006(ops-user1)
reader@ubuntu:~$ id sec-user1 
uid=1003(sec-user1) gid=1007(sec-user1) groups=1007(sec-user1)
reader@ubuntu:~$

最后提醒一下,我们向您展示了当您忘记sudo时会发生什么。虽然错误消息在技术上是完全正确的(您需要 root 权限才能编辑/etc/passwd,其中存储了用户信息),但可能不太明显命令失败的原因,特别是因为误导性的稍后重试!错误。

然而,使用sudo,我们可以添加三个用户:dev-user1ops-user1sec-user1。当我们按顺序检查这些用户时,我们可以看到他们的uid每次增加一个。我们还可以看到与用户同名的组被创建,并且这是用户的唯一组成员。组也有它们的gid,每次下一个用户都会增加一个。

因此,现在我们已经有了用户,但我们需要共享组。为此,我们有一个类似的命令(在名称和操作上都相同):groupadd。查看groupadd的 man 页面,并添加三个对应于我们部门的组:

reader@ubuntu:~$ sudo groupadd development
reader@ubuntu:~$ sudo groupadd operations
reader@ubuntu:~$ sudo groupadd security
reader@ubuntu:~$

要查看系统上已有哪些组,可以查看/etc/group文件(例如使用lesscat)。一旦满意,我们现在已经有了用户和组。但是我们如何使用户成为组的成员?输入usermod(表示user modify)。设置用户的主要组的语法如下:usermod -g <groupname> <username>

reader@ubuntu:~$ sudo usermod -g development dev-user1 
reader@ubuntu:~$ sudo usermod -g operations ops-user1 
reader@ubuntu:~$ sudo usermod -g security sec-user1 
reader@ubuntu:~$ id dev-user1 
uid=1001(dev-user1) gid=1008(development) groups=1008(development)
reader@ubuntu:~$ id ops-user1 
uid=1002(ops-user1) gid=1009(operations) groups=1009(operations)
reader@ubuntu:~$ id sec-user1 
uid=1003(sec-user1) gid=1010(security) groups=1010(security)
reader@ubuntu:~$

我们现在已经实现的更接近我们的目标,但我们还没有到达那里。到目前为止,我们只确保多个开发人员可以通过所有在开发组中的文件共享文件。但是开发和运营之间的共享文件夹呢?安全性如何监视所有内容?让我们创建一些具有正确组的目录(使用mkdir,表示make directory),看看我们能走多远:

reader@ubuntu:~$ sudo mkdir /data
[sudo] password for reader:
reader@ubuntu:~$ cd /data
reader@ubuntu:/data$ sudo mkdir dev-files
reader@ubuntu:/data$ sudo mkdir ops-files
reader@ubuntu:/data$ sudo mkdir devops-files
reader@ubuntu:/data$ ls -l
total 12
drwxr-xr-x 2 root root 4096 Aug 11 10:03 dev-files
drwxr-xr-x 2 root root 4096 Aug 11 10:04 devops-files
drwxr-xr-x 2 root root 4096 Aug 11 10:04 ops-files
reader@ubuntu:/data$ sudo chgrp development dev-files/
reader@ubuntu:/data$ sudo chgrp operations ops-files/
reader@ubuntu:/data$ sudo chmod 0770 dev-files/
reader@ubuntu:/data$ sudo chmod 0770 ops-files/
reader@ubuntu:/data$ ls -l
total 12
drwxrwx--- 2 root development 4096 Aug 11 10:03 dev-files
drwxr-xr-x 2 root root        4096 Aug 11 10:04 devops-files
drwxrwx--- 2 root operations  4096 Aug 11 10:04 ops-files
reader@ubuntu:/data

我们现在有以下结构:一个/data/顶级目录,其中包含dev-filesops-files目录,分别由developmentoperations组拥有。现在,让我们满足安全部门可以进入这两个目录并管理文件的要求!除了使用usermod来更改主要组,我们还可以将用户追加到额外的组中。在这种情况下,语法是usermod -a -G <groupnames> <username>。让我们将sec-user1添加到developmentoperations组中:

reader@ubuntu:/data$ id sec-user1
uid=1003(sec-user1) gid=1010(security) groups=1010(security)
reader@ubuntu:/data$ sudo usermod -a -G development,operations sec-user1 
reader@ubuntu:/data$ id sec-user1
uid=1003(sec-user1) gid=1010(security) groups=1010(security),1008(development),1009(operations)
reader@ubuntu:/data$

安全部门的用户现在是所有新组的成员:安全、开发和运维。由于/data/dev-files//data/ops-files/都没有其他人的权限,我们当前的用户不应该能够进入其中任何一个,但sec-user1应该可以。让我们看看这是否正确:

reader@ubuntu:/data$ sudo su - sec-user1
No directory, logging in with HOME=/
$ cd /data/
$ ls -l
total 12
drwxrwx--- 2 root development 4096 Aug 11 10:03 dev-files
drwxr-xr-x 2 root root        4096 Aug 11 10:04 devops-files
drwxrwx--- 2 root operations  4096 Aug 11 10:04 ops-files
$ cd dev-files
$ pwd
/data/dev-files
$ touch security-file
$ ls -l
total 0
-rw-r--r-- 1 sec-user1 security 0 Aug 11 10:16 security-file
$ exit
reader@ubuntu:/data$

如果您跟着这个例子,您应该会发现我们引入了一个新命令:su。它是switch user 的缩写,它允许我们在用户之间切换。如果您在前面加上sudo,您可以切换到一个用户,而不需要该用户的密码,只要您有这些权限。否则,您将需要输入密码(在这种情况下很难,因为我们还没有为用户设置密码)。您可能已经注意到,新用户的 shell 是不同的。这是因为我们还没有加载任何配置(这是为默认用户自动完成的)。不过,不用担心——它仍然是一个完全功能的 shell!我们的测试成功了:我们能够进入dev-files目录,即使我们不是开发人员。我们甚至能够创建一个文件。如果您愿意,可以验证在ops-files目录中也是可能的。

最后,让我们创建一个新组devops,我们将使用它来在开发人员和运维之间共享文件。创建组后,我们将像将sec-user1添加到developmentoperations组一样,将dev-user1ops-user1添加到这个组中:

reader@ubuntu:/data$ sudo groupadd devops
reader@ubuntu:/data$ sudo usermod -a -G devops dev-user1 
reader@ubuntu:/data$ sudo usermod -a -G devops ops-user1 
reader@ubuntu:/data$ id dev-user1 
uid=1001(dev-user1) gid=1008(development) groups=1008(development),1011(devops)
reader@ubuntu:/data$ id ops-user1 
uid=1002(ops-user1) gid=1009(operations) groups=1009(operations),1011(devops)
reader@ubuntu:/data$ ls -l
total 12
drwxrwx--- 2 root development 4096 Aug 11 10:16 dev-files
drwxr-xr-x 2 root root        4096 Aug 11 10:04 devops-files
drwxrwx--- 2 root operations  4096 Aug 11 10:04 ops-files
reader@ubuntu:/data$ sudo chown root:devops devops-files/
reader@ubuntu:/data$ sudo chmod 0770 devops-files/
reader@ubuntu:/data$ ls -l
total 12
drwxrwx---  2 root development 4096 Aug 11 10:16 dev-files/
drwxrwx---  2 root devops      4096 Aug 11 10:04 devops-files/
drwxrwx---  2 root operations  4096 Aug 11 10:04 ops-files/
reader@ubuntu:/data$

我们现在有一个共享目录,/data/devops-files/dev-user1ops-user1都可以进入并创建文件。

作为练习,可以执行以下任何一项:

  • sec-user1添加到devops组,以便它也可以审计共享文件

  • 验证dev-user1ops-user1是否可以在共享目录中写入文件

  • 了解为什么dev-user1ops-user1只能读取devops目录中的对方文件,但不能编辑它们(提示:本章的下一节高级权限将告诉您如何使用 SGID 解决这个问题)

高级权限

这涵盖了 Linux 的基本权限。然而,还有一些高级主题,我们想指出,但我们不会详细讨论它们。有关这些主题的更多信息,请查看本章末尾的进一步阅读部分。我们已经包括了文件属性、特殊文件权限和访问控制列表的参考。

文件属性

文件也可以具有以不同于我们目前所见的权限表达的属性。一个例子是使文件不可变(一个花哨的词,意思是它不能被更改)。不可变文件仍然具有正常的所有权和组以及 RWX 权限,但它不允许用户更改它,即使它包含可写权限。另一个特点是该文件不能被重命名。

其他文件属性包括不可删除仅追加压缩。有关文件属性的更多信息,请查看lsattrchattr命令的 man 页面(man lsattrman chattr)。

特殊文件权限

正如您可能已经在八进制表示部分注意到的那样,我们总是以零开头(0775,0640 等)。如果我们不使用它,为什么要包括零?该位置保留用于特殊文件权限:SUID、SGID 和粘滞位。它们具有类似的八进制表示法(其中 SUID 为 4,SGID 为 2,粘滞位为 1),并且以以下方式使用:

文件 目录
SUID 文件以所有者的权限执行,无论哪个用户执行它。 什么也不做。
SGID 文件以组的权限执行,无论哪个用户执行它。 在此目录中创建的文件获得与目录相同的组。
粘滞位 什么也不做。 用户只能删除他们在这个目录中的文件。查看/tmp/目录以了解其最著名的用途。

访问控制列表(ACL)

ACL 是增加 UGO/RWX 系统灵活性的一种方式。使用setfaclset file acl)和getfaclget file acl),您可以为文件和目录设置额外的权限。因此,例如,使用 ACL,您可以说,虽然/root/目录通常只能由root用户访问,但也可以被reader用户读取。另一种实现这一点的方法是将reader用户添加到root组中,这也给了reader用户系统上的许多其他特权(任何对 root 组有权限的东西都已经授予了 reader 用户!)。尽管根据我们的经验,ACL 在实践中并不经常使用,但对于边缘情况,它们可能是复杂解决方案和简单解决方案之间的区别。

总结

在本章中,我们已经了解了 Linux 权限方案。我们已经学到了权限安排的两个主要轴:文件权限和文件所有权。对于文件权限,每个文件都有对执行权限的允许(或不允许)。这些权限的工作方式对文件和目录有所不同。权限是通过所有权应用的:文件始终由用户和组拥有。除了用户之外,还有其他人的文件权限,称为其他所有权。如果用户是文件的所有者或文件组的成员,那么这些权限对用户是可用的。否则,其他人需要有权限才能与文件交互。

接下来,我们学习了如何操纵文件权限和所有权。通过使用chmodumask,我们能够以所需的方式获取文件权限。使用sudochownchgrp,我们操纵了文件的所有者和组。对于sudoroot用户的使用给出了警告,因为两者都可以在很少的努力下使 Linux 系统无法操作。

我们继续以一个与多个用户一起工作的示例。我们使用useradd添加了三个额外的用户到系统,并使用usermod为他们分配了正确的组。我们看到这些用户可以成为相同组的成员,并以这种方式共享对文件的访问。

最后,我们简要介绍了 Linux 下高级权限的一些基础知识。进一步阅读部分包含了这些主题的更多信息。

本章介绍了以下命令:idtouchchmodumaskchownchgrpsudouseraddgroupaddusermodmkdirsu

问题

  1. Linux 文件使用了哪三种权限?

  2. 为 Linux 文件定义了哪三种所有权类型?

  3. 哪个命令用于更改文件的权限?

  4. 什么机制控制了新创建文件的默认权限?

  5. 以下符号权限如何用八进制描述:rwxrw-r--

  6. 以下八进制权限如何用符号描述:0644

  7. 哪个命令允许我们获得超级用户权限?

  8. 我们可以使用哪些命令来更改文件的所有权?

  9. 我们如何安排多个用户共享文件访问?

  10. Linux 有哪些类型的高级权限?

进一步阅读

如果您想深入了解本章主题,以下资源可能会很有趣:

第六章:文件操作

本章专门讨论文件操作。就像一切都是文件系统一样,文件操作被认为是与 Linux 工作中最重要的方面之一。我们将首先探讨常见的文件操作,比如创建、复制和删除文件。接着我们会介绍一些关于存档的内容,这是在命令行工作时的另一个重要工具。本章的最后部分将致力于在文件系统中查找文件,这是 shell 脚本工具包中的另一个重要技能。

本章将介绍以下命令:cprmmvlntarlocatefind

本章将涵盖以下主题:

  • 常见的文件操作

  • 存档

  • 查找文件

技术要求

我们将使用我们在第二章中创建的虚拟机进行文件操作。此时不需要更多资源。

常见的文件操作

到目前为止,我们主要介绍了与 Linux 文件系统导航相关的命令。在早期的章节中,我们已经看到我们可以使用mkdirtouch分别创建目录和空文件。如果我们想给文件一些有意义的(文本)内容,我们使用vimnano。然而,我们还没有讨论过删除文件或目录,或者复制、重命名或创建快捷方式。让我们从复制文件开始。

复制

实质上,在 Linux 上复制文件非常简单:使用cp命令,后面跟着要复制的文件名和要复制到的文件名。它看起来像这样:

reader@ubuntu:~$ ls -l
total 12
-rw-rw-r-- 1 reader reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader 4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader    0 Aug  4 13:44 testfile
drwxrwx--- 2 reader reader 4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games     0 Aug  4 16:18 umaskfile
reader@ubuntu:~$ cp testfile testfilecopy
reader@ubuntu:~$ ls -l
total 12
-rw-rw-r-- 1 reader reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader 4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader    0 Aug  4 13:44 testfile
-rwxr-xr-- 1 reader reader    0 Aug 18 14:00 testfilecopy
drwxrwx--- 2 reader reader 4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games     0 Aug  4 16:18 umaskfile
reader@ubuntu:~$

正如你所看到的,在这个例子中,我们复制了一个(空的)文件,它已经是我们拥有的,而我们在相同的目录中。这可能会引发一些问题,比如:

  • 我们是否总是需要在源文件和目标文件的相同目录中?

  • 文件的权限呢?

  • 我们是否也可以使用cp复制目录?

正如你所期望的,在 Linux 下,cp命令也是非常多才多艺的。我们确实可以复制不属于我们的文件;我们不需要在与文件相同的目录中,我们也可以复制目录!让我们尝试一些这些事情:

reader@ubuntu:~$ cd /var/log/
reader@ubuntu:/var/log$ ls -l
total 3688
<SNIPPED>
drwxr-xr-x  2 root      root               4096 Apr 17 20:22 dist-upgrade
-rw-r--r--  1 root      root             550975 Aug 18 13:35 dpkg.log
-rw-r--r--  1 root      root              32160 Aug 11 10:15 faillog
<SNIPPED>
-rw-------  1 root      root              64320 Aug 11 10:15 tallylog
<SNIPPED>
reader@ubuntu:/var/log$ cp dpkg.log /home/reader/
reader@ubuntu:/var/log$ ls -l /home/reader/
total 552
-rw-r--r-- 1 reader reader 550975 Aug 18 14:20 dpkg.log
-rw-rw-r-- 1 reader reader     69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader   4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader      0 Aug  4 13:44 testfile
-rwxr-xr-- 1 reader reader      0 Aug 18 14:00 testfilecopy
drwxrwx--- 2 reader reader   4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games       0 Aug  4 16:18 umaskfile
reader@ubuntu:/var/log$ cp tallylog /home/reader/
cp: cannot open 'tallylog' for reading: Permission denied
reader@ubuntu:/var/log$

那么,发生了什么?我们使用cd命令将目录更改为/var/log/。我们使用带有选项的ls列出了那里的文件。我们复制了一个相对路径的文件,我们能够读取它,但它是由root:root拥有的,复制到了完全限定的/home/reader/目录。当我们使用完全限定路径列出/home/reader/时,我们看到复制的文件现在由reader:reader拥有。当我们尝试对tallylog文件做同样的操作时,我们得到了错误cannot open 'tallylog' for reading: Permission denied。这并不意外,因为我们对该文件没有任何读取权限,所以复制会很困难。

这应该回答了三个问题中的两个。但是对于目录呢?让我们尝试将/tmp/目录复制到我们的home目录中:

reader@ubuntu:/var/log$ cd
reader@ubuntu:~$ cp /tmp/ .
cp: -r not specified; omitting directory '/tmp/'
reader@ubuntu:~$ ls -l
total 552
-rw-r--r-- 1 reader reader 550975 Aug 18 14:20 dpkg.log
-rw-rw-r-- 1 reader reader     69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader   4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader      0 Aug  4 13:44 testfile
-rwxr-xr-- 1 reader reader      0 Aug 18 14:00 testfilecopy
drwxrwx--- 2 reader reader   4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games       0 Aug  4 16:18 umaskfile
reader@ubuntu:~$ cp -r /tmp/ .
cp: cannot access '/tmp/systemd-private-72bcf47b69464914b021b421d5999bbe-systemd-timesyncd.service-LeF05x': Permission denied
cp: cannot access '/tmp/systemd-private-72bcf47b69464914b021b421d5999bbe-systemd-resolved.service-ApdzhW': Permission denied
reader@ubuntu:~$ ls -l
total 556
-rw-r--r-- 1 reader reader 550975 Aug 18 14:20 dpkg.log
-rw-rw-r-- 1 reader reader     69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader   4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader      0 Aug  4 13:44 testfile
-rwxr-xr-- 1 reader reader      0 Aug 18 14:00 testfilecopy
drwxrwxr-t 9 reader reader   4096 Aug 18 14:38 tmp
drwxrwx--- 2 reader reader   4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games       0 Aug  4 16:18 umaskfile
reader@ubuntu:~$

对于这样一个简单的练习,实际上发生了很多事情!首先,我们使用cd命令返回到我们的home目录,而不带任何参数;这本身就是一个很巧妙的小技巧。接下来,我们尝试将整个/tmp/目录复制到.(你应该记得,.当前目录的缩写)。然而,这次失败了,出现了错误-r not specified; omitting directory '/tmp/'。我们列出目录来检查,确实,似乎什么都没发生。当我们添加了错误指定的-r并重新尝试命令时,出现了一些Permission denied的错误。这并不意外,因为并非所有/tmp/目录中的文件都对我们可读。尽管我们得到了错误,但当我们现在检查我们的home目录的内容时,我们可以看到tmp目录在那里!因此,使用-r选项(它是--recursive的缩写)允许我们复制目录和其中的所有内容。

删除

在将一些文件和目录复制到我们的home目录之后(这是一个安全的选择,因为我们确信可以在那里写入!),我们留下了一些混乱。让我们使用rm命令来删除一些重复的项目:

reader@ubuntu:~$ ls -l
total 556
-rw-r--r-- 1 reader reader 550975 Aug 18 14:20 dpkg.log
-rw-rw-r-- 1 reader reader     69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader   4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader      0 Aug  4 13:44 testfile
-rwxr-xr-- 1 reader reader      0 Aug 18 14:00 testfilecopy
drwxrwxr-t 9 reader reader   4096 Aug 18 14:38 tmp
drwxrwx--- 2 reader reader   4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games       0 Aug  4 16:18 umaskfile
reader@ubuntu:~$ rm testfilecopy
reader@ubuntu:~$ rm tmp/
rm: cannot remove 'tmp/': Is a directory
reader@ubuntu:~$ rm -r tmp/
reader@ubuntu:~$ ls -l
total 552
-rw-r--r-- 1 reader reader 550975 Aug 18 14:20 dpkg.log
-rw-rw-r-- 1 reader reader     69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader   4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader      0 Aug  4 13:44 testfile
drwxrwx--- 2 reader reader   4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games       0 Aug  4 16:18 umaskfile
reader@ubuntu:~$

使用rm后跟文件名将删除它。您可能会注意到,这里没有“您确定吗?”的提示。实际上,可以通过使用-i标志来启用此功能,但默认情况下不是这样。请注意,rm还允许您使用通配符,例如*(匹配所有内容),这将删除所有匹配的文件(并且可以被用户删除)。简而言之,这是一种非常快速丢失文件的好方法!但是,当我们尝试使用rm命令和目录名称时,它会给出错误cannot remove 'tmp/': Is a directory。这与cp命令非常相似,幸运的是,解决方法也是一样的:添加-r进行递归删除!同样,这是一种丢失文件的好方法;一个命令就可以让您删除整个home目录及其中的所有内容,而不需要任何警告。请把这当作是您的警告!特别是在与-f标志结合使用时,它是--force的缩写,这将确保rm永远不会提示并立即开始删除。

重命名、移动和链接

有时,我们不仅想要创建或删除文件,还可能需要重命名文件。奇怪的是,Linux 没有任何听起来像重命名的东西;但是,mv命令(用于move)确实实现了我们想要的功能。与cp命令类似,它接受源文件和目标文件作为参数,并且看起来像这样:

reader@ubuntu:~$ ls -l
total 552
-rw-r--r-- 1 reader reader 550975 Aug 18 14:20 dpkg.log
-rw-rw-r-- 1 reader reader     69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader   4096 Aug  4 16:16 testdir
-rwxr-xr-- 1 reader reader      0 Aug  4 13:44 testfile
drwxrwx--- 2 reader reader   4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games       0 Aug  4 16:18 umaskfile
reader@ubuntu:~$ mv testfile renamedtestfile
reader@ubuntu:~$ mv testdir/ renamedtestdir
reader@ubuntu:~$ ls -l
total 552
-rw-r--r-- 1 reader reader 550975 Aug 18 14:20 dpkg.log
-rw-rw-r-- 1 reader reader     69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader   4096 Aug  4 16:16 renamedtestdir
-rwxr-xr-- 1 reader reader      0 Aug  4 13:44 renamedtestfile
drwxrwx--- 2 reader reader   4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games       0 Aug  4 16:18 umaskfile
reader@ubuntu:~$

正如您所看到的,mv命令非常简单易用。它甚至适用于目录,无需像cprm那样需要-r这样的特殊选项。但是,当我们引入通配符时,它会变得更加复杂,但现在不用担心。我们在前面的代码中使用的命令是相对的,但它们也可以完全限定或混合使用。

有时,您可能需要将文件从一个目录移动到另一个目录。如果您仔细考虑,这实际上是对完全限定文件名的重命名!没有触及任何数据,但您只是想在其他地方访问文件。因此,使用mv umaskfile umaskdir/umaskfile移动到umaskdir/

reader@ubuntu:~$ ls -l
total 16
-rw-r--r-- 1 reader reader 550975 Aug 18 14:20 dpkg.log
-rw-rw-r-- 1 reader reader     69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader   4096 Aug  4 16:16 renamedtestdir
-rwxr-xr-- 1 reader reader      0 Aug  4 13:44 renamedtestfile
drwxrwx--- 2 reader reader   4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games       0 Aug  4 16:18 umaskfile
reader@ubuntu:~$ mv umaskfile umaskdir/
reader@ubuntu:~$ ls -l
total 16
-rw-r--r-- 1 reader reader 550975 Aug 18 14:20 dpkg.log
-rw-rw-r-- 1 reader reader     69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader   4096 Aug  4 16:16 renamedtestdir
-rwxr-xr-- 1 reader reader      0 Aug  4 13:44 renamedtestfile
drwxrwx--- 2 reader reader   4096 Aug 19 10:37 umaskdir
reader@ubuntu:~$ ls -l umaskdir/
total 0
-rw-rw---- 1 reader games 0 Aug  4 16:18 umaskfile
reader@ubuntu:~$

最后,我们有ln命令,它代表linking。这是 Linux 创建文件之间链接的方式,最接近 Windows 使用的快捷方式。有两种类型的链接:符号链接(也称为软链接)和硬链接。区别在于文件系统的工作原理:符号链接指向文件名(或目录名),而硬链接链接到存储文件或目录内容的inode。对于脚本编写,如果您使用链接,您可能正在使用符号链接,因此让我们看看这些符号链接的操作:

reader@ubuntu:~$ ls -l
total 552
-rw-r--r-- 1 reader reader 550975 Aug 18 14:20 dpkg.log
-rw-rw-r-- 1 reader reader     69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader   4096 Aug  4 16:16 renamedtestdir
-rwxr-xr-- 1 reader reader      0 Aug  4 13:44 renamedtestfile
drwxrwx--- 2 reader reader   4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games       0 Aug  4 16:18 umaskfile
reader@ubuntu:~$ ln -s /var/log/auth.log 
reader@ubuntu:~$ ln -s /var/log/auth.log link-to-auth.log
reader@ubuntu:~$ ln -s /tmp/
reader@ubuntu:~$ ln -s /tmp/ link-to-tmp
reader@ubuntu:~$ ls -l
total 552
lrwxrwxrwx 1 reader reader     17 Aug 18 15:07 auth.log -> /var/log/auth.log
-rw-r--r-- 1 reader reader 550975 Aug 18 14:20 dpkg.log
lrwxrwxrwx 1 reader reader     17 Aug 18 15:08 link-to-auth.log -> /var/log/auth.log
lrwxrwxrwx 1 reader reader      5 Aug 18 15:08 link-to-tmp -> /tmp/
-rw-rw-r-- 1 reader reader     69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader   4096 Aug  4 16:16 renamedtestdir
-rwxr-xr-- 1 reader reader      0 Aug  4 13:44 renamedtestfile
lrwxrwxrwx 1 reader reader      5 Aug 18 15:08 tmp -> /tmp/
drwxrwx--- 2 reader reader   4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games       0 Aug  4 16:18 umaskfile
reader@ubuntu:~$

我们使用ln -s(这是--symbolic的缩写)创建了两种类型的符号链接:首先是到/var/log/auth.log文件,然后是到/tmp/目录。我们看到了两种不同的使用ln -s的方式:如果没有第二个参数,它将创建与我们要链接的内容相同名称的链接;否则,我们可以将我们自己的名称作为第二个参数(如link-to-auth.loglink-to-tmp/链接所示)。现在,我们可以通过与/home/reader/auth.log/home/reader/link-to-auth.log交互来读取/var/log/auth.log的内容。如果我们想要导航到/tmp/,我们现在可以使用/home/reader/tmp//home/reader/link-to-tmp/cd结合使用。虽然这个例子在日常工作中并不特别有用(除非输入/var/log/auth.log而不是auth.log可以为您节省大量时间),但链接可以防止重复复制文件,同时保持易于访问。

链接(以及 Linux 文件系统一般)中的一个重要概念是inode。每个文件(无论类型如何,包括目录)都有一个 inode,它描述了该文件的属性和磁盘块位置。在这个上下文中,属性包括所有权和权限,以及最后的更改、访问和修改时间戳。在链接中,软链接有它们自己的 inode,而硬链接指的是相同的 inode。

在继续本章的下一部分之前,使用rm清理四个链接和复制的dpk.log文件。如果你不确定如何做到这一点,请查看rm的 man 页面。一个小提示:删除符号链接就像rm <name-of-link>一样简单!

存档

现在我们对 Linux 中的常见文件操作有了一定的了解,我们将继续进行存档操作。虽然听起来可能很花哨,但存档简单地指的是创建存档。你们大多数人熟悉的一个例子是创建 ZIP 文件,这是一个存档。ZIP 并不是特定于 Windows 的;它是一种存档文件格式,在 Windows、Linux、macOS 等不同的实现中都有。

正如你所期望的,有许多存档文件格式。在 Linux 上,最常用的是tarball,它是通过使用tar命令创建的(这个术语来源于tape archive)。以.tar结尾的 tarball 文件是未压缩的。在实践中,tarball 几乎总是使用 Gzip 进行压缩,Gzip 代表GNU zip。这可以直接使用tar命令(最常见)或之后使用gzip命令(不太常见,但也可以用于压缩除 tarball 以外的文件)。由于tar是一个复杂的命令,我们将更详细地探讨最常用的标志(描述取自tar手册页):

-c--create 创建一个新的存档。参数提供要存档的文件的名称。除非给出--no-recursion选项,否则将递归存档目录。
-x--extract--get 从存档中提取文件。参数是可选的。给定时,它们指定要提取的存档成员的名称。
-t--list 列出存档的内容。参数是可选的。给定时,它们指定要列出的成员的名称。
-v--verbose 详细列出处理的文件。
-f--file=ARCHIVE 使用存档文件或设备 ARCHIVE。
-z--gzip--gunzip--ungzip 通过 Gzip 过滤存档。
-C--directory=DIR 在执行任何操作之前切换到 DIR。这个选项是有顺序的,也就是说,它影响后面的所有选项。

tar命令在指定这些选项的方式上非常灵活。我们可以逐个呈现它们,一起呈现,带有或不带有连字符,或者使用长选项或短选项。这意味着创建存档的以下方式都是正确的,都可以工作:

  • tar czvf <archive name> <file1> <file2>

  • tar -czvf <archive name> <file1> <file2>

  • tar -c -z -v -f <archive name> <file1> <file2>

  • tar --create --gzip --verbose --file=<archive name> <file1> <file2>

虽然这看起来很有帮助,但也可能令人困惑。我们的建议是:选择一个格式并坚持使用它。在本书中,我们将使用最简短的形式,因此这是所有没有连字符的短选项。让我们使用这种形式来创建我们的第一个存档!

reader@ubuntu:~$ ls -l
total 12
-rw-rw-r-- 1 reader reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader 4096 Aug  4 16:16 renamedtestdir
-rwxr-xr-- 1 reader reader    0 Aug  4 13:44 renamedtestfile
drwxrwx--- 2 reader reader 4096 Aug  4 16:18 umaskdir
reader@ubuntu:~$ tar czvf my-first-archive.tar.gz \
nanofile.txt renamedtestfile
nanofile.txt
renamedtestfile
reader@ubuntu:~$ ls -l
total 16
-rw-rw-r-- 1 reader reader  267 Aug 19 10:29 my-first-archive.tar.gz
-rw-rw-r-- 1 reader reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader 4096 Aug  4 16:16 renamedtestdir
-rwxr-xr-- 1 reader reader    0 Aug  4 13:44 renamedtestfile
drwxrwx--- 2 reader reader 4096 Aug  4 16:18 umaskdir
-rw-rw---- 1 reader games     0 Aug  4 16:18 umaskfile
reader@ubuntu:~$

使用这个命令,我们verbosely created 了一个名为my-first-archive.tar.gz的 gzipped file,其中包含了文件nanofile.txt umaskfilerenamedtestfile

在这个例子中,我们只存档了文件。实际上,通常很好的存档整个目录。语法完全相同,只是你会给出一个目录名,整个目录将被存档(在-z选项的情况下也会被压缩)。当你解压存档了一个目录的 tarball 时,整个目录将被再次提取,而不仅仅是内容。

现在,让我们看看解压它是否能还原我们的文件!我们将 gzipped tarball 移动到renamedtestdir,并使用tar xzvf命令在那里解压它:

reader@ubuntu:~$ ls -l
total 16
-rw-rw-r-- 1 reader reader  226 Aug 19 10:40 my-first-archive.tar.gz
-rw-rw-r-- 1 reader reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader 4096 Aug  4 16:16 renamedtestdir
-rwxr-xr-- 1 reader reader    0 Aug  4 13:44 renamedtestfile
drwxrwx--- 2 reader reader 4096 Aug 19 10:37 umaskdir
reader@ubuntu:~$ mv my-first-archive.tar.gz renamedtestdir/
reader@ubuntu:~$ cd renamedtestdir/
reader@ubuntu:~/renamedtestdir$ ls -l
total 4
-rw-rw-r-- 1 reader reader 226 Aug 19 10:40 my-first-archive.tar.gz
reader@ubuntu:~/renamedtestdir$ tar xzvf my-first-archive.tar.gz 
nanofile.txt
renamedtestfile
reader@ubuntu:~/renamedtestdir$ ls -l
total 8
-rw-rw-r-- 1 reader reader 226 Aug 19 10:40 my-first-archive.tar.gz
-rw-rw-r-- 1 reader reader  69 Jul 14 13:18 nanofile.txt
-rwxr-xr-- 1 reader reader   0 Aug  4 13:44 renamedtestfile
reader@ubuntu:~/renamedtestdir$

正如我们所看到的,我们在renamedtestdir中找回了我们的文件!实际上,我们从未删除原始文件,所以这些是副本。在你开始提取和清理所有东西之前,你可能想知道 tarball 里面有什么。这可以通过使用-t选项而不是-x来实现:

reader@ubuntu:~/renamedtestdir$ tar tzvf my-first-archive.tar.gz 
-rw-rw-r-- reader/reader 69 2018-08-19 11:54 nanofile.txt
-rw-rw-r-- reader/reader  0 2018-08-19 11:54 renamedtestfile
reader@ubuntu:~/renamedtestdir$

tar广泛使用的最后一个有趣选项是-C--directory选项。这个命令确保我们在提取之前不必移动存档。让我们使用它将/home/reader/renamedtestdir/my-first-archive.tar.gz从我们的home目录提取到/home/reader/umaskdir/中:

reader@ubuntu:~/renamedtestdir$ cd
reader@ubuntu:~$ tar xzvf renamedtestdir/my-first-archive.tar.gz -C umaskdir/
nanofile.txt
renamedtestfile
reader@ubuntu:~$ ls -l umaskdir/
total 4
-rw-rw-r-- 1 reader reader 69 Jul 14 13:18 nanofile.txt
-rwxr-xr-- 1 reader reader  0 Aug  4 13:44 renamedtestfile
-rw-rw---- 1 reader games   0 Aug  4 16:18 umaskfile
reader@ubuntu:~$

通过在存档名称后指定-C和目录参数,我们确保tar将 gzipped tarball 的内容提取到指定的目录中。

这涵盖了tar命令的最重要方面。然而,还有一件小事要做:清理!我们在home目录下搞得一团糟,而且那里没有任何真正有用的文件。以下是一个实际示例,展示了带有rm -r命令的通配符有多危险:

reader@ubuntu:~$ ls -l
total 12
-rw-rw-r-- 1 reader reader   69 Jul 14 13:18 nanofile.txt
drwxrwxr-x 2 reader reader 4096 Aug 19 10:42 renamedtestdir
-rwxr-xr-- 1 reader reader    0 Aug  4 13:44 renamedtestfile
drwxrwx--- 2 reader reader 4096 Aug 19 10:47 umaskdir
reader@ubuntu:~$ rm -r *
reader@ubuntu:~$ ls -l
total 0
reader@ubuntu:~$

一个简单的命令,没有警告,所有文件,包括更多文件的目录,都消失了!如果你在想:不,Linux 也没有回收站。这些文件已经消失了;只有高级硬盘恢复技术可能还能够恢复这些文件。

确保你执行了前面的命令,以了解rm有多具有破坏性。然而,在你执行之前,请确保你在你的home目录下,并且不要意外地有任何你不想删除的文件。如果你遵循我们的示例,这不应该是问题,但如果你做了其他事情,请确保你知道自己在做什么!

查找文件

在学习了常见的文件操作和存档之后,还有一个在文件操作中至关重要的技能我们还没有涉及:查找文件。你知道如何复制或存档文件是非常好的,但如果你找不到你想要操作的文件,你将很难完成你的任务。幸运的是,有专门用于在 Linux 文件系统中查找和定位文件的工具。简单来说,这些工具就是findlocatefind命令更复杂,但更强大,而locate命令在你确切知道你要找的东西时更容易使用。首先,我们将向你展示如何使用locate,然后再介绍find更广泛的功能。

定位

在 locate 的 man 页面上,描述再合适不过了:“locate - 按名称查找文件”。locate命令默认安装在您的 Ubuntu 机器上,基本功能就是使用locate <filename>这么简单。让我们看看它是如何工作的:

reader@ubuntu:~$ locate fstab
/etc/fstab
/lib/systemd/system-generators/systemd-fstab-generator
/sbin/fstab-decode
/usr/share/doc/mount/examples/fstab
/usr/share/doc/mount/examples/mount.fstab
/usr/share/doc/util-linux/examples/fstab
/usr/share/doc/util-linux/examples/fstab.example2
/usr/share/man/man5/fstab.5.gz
/usr/share/man/man8/fstab-decode.8.gz
/usr/share/man/man8/systemd-fstab-generator.8.gz
/usr/share/vim/vim80/syntax/fstab.vim
reader@ubuntu:~$

在前面的例子中,我们搜索了文件名fstab。我们可能记得我们需要编辑这个文件来进行文件系统更改,但我们不确定在哪里可以找到它。locate向我们展示了磁盘上包含fstab的所有位置。如你所见,它不必是一个精确的匹配;包含fstab字符串的所有内容都将被打印出来。

你可能已经注意到locate命令几乎立即完成。这是因为它使用一个定期更新的数据库来存储所有文件,而不是在运行时遍历整个文件系统。因此,信息并不总是准确的,因为更改不会实时同步到数据库中。为了确保你使用的是文件系统的最新状态与数据库进行交互,请确保在运行locate之前运行sudo updatedb(需要 root 权限)。这也是在系统上首次运行locate之前所需的,否则就没有数据库可供查询!

Locate 有一些选项,但根据我们的经验,只有当你知道确切的文件名(或文件名的一部分)时才会使用它。对于其他搜索,最好默认使用find命令。

find

find 是一个非常强大但复杂的命令。你可以使用find做以下任何一件事情:

  • 按文件名搜索

  • 按权限搜索(用户和组)

  • 按所有权搜索

  • 按文件类型搜索

  • 按文件大小搜索

  • 按时间戳搜索(创建时间,最后修改时间,最后访问时间)

  • 仅在特定目录中搜索

解释find命令的所有功能需要一整章的篇幅。我们只会描述最常见的用法。真正的教训在于了解find的高级功能;如果你需要查找具有特定属性的文件,一定要首先考虑使用find命令,并查看man file页面,看看是否可以利用find进行搜索(剧透:几乎总是这样!)。

让我们从 find 的基本用法开始:find <位置> <选项和参数>。如果没有任何选项和参数,find 将打印出位置内找到的每个文件:

reader@ubuntu:~$ find /home/reader/
/home/reader/
/home/reader/.gnupg
/home/reader/.gnupg/private-keys-v1.d
/home/reader/.bash_logout
/home/reader/.sudo_as_admin_successful
/home/reader/.profile
/home/reader/.bashrc
/home/reader/.viminfo
/home/reader/.lesshst
/home/reader/.local
/home/reader/.local/share
/home/reader/.local/share/nano
/home/reader/.cache
/home/reader/.cache/motd.legal-displayed
/home/reader/.bash_history
reader@ubuntu:~$

你可能以为你的home目录是空的。实际上,它包含了相当多的隐藏文件或目录(以点开头),这些文件被find找到了。现在,让我们使用-name选项应用一个过滤器:

reader@ubuntu:~$ find /home/reader/ -name bash
reader@ubuntu:~$ find /home/reader/ -name *bash*
/home/reader/.bash_logout
/home/reader/.bashrc
/home/reader/.bash_history
reader@ubuntu:~$ find /home/reader/ -name .bashrc
/home/reader/.bashrc
reader@ubuntu:~$

与你可能期望的相反,findlocate在部分匹配文件方面的工作方式不同。除非在-name参数的参数周围添加通配符,否则它只会匹配完整的文件名,而不是部分匹配的文件。这绝对是需要记住的事情。那么,仅查找文件而不是目录呢?为此,我们可以使用-type选项和d参数表示目录,或者使用f表示文件:

reader@ubuntu:~$ find /home/reader/ -type d
/home/reader/
/home/reader/.gnupg
/home/reader/.gnupg/private-keys-v1.d
/home/reader/.local
/home/reader/.local/share
/home/reader/.local/share/nano
/home/reader/.cache
reader@ubuntu:~$ find /home/reader/ -type f
/home/reader/.bash_logout
/home/reader/.sudo_as_admin_successful
/home/reader/.profile
/home/reader/.bashrc
/home/reader/.viminfo
/home/reader/.lesshst
/home/reader/.cache/motd.legal-displayed
/home/reader/.bash_history
reader@ubuntu:~$

第一个结果显示了/home/reader/内的所有目录(包括/home/reader/!),而第二个结果打印了所有文件。你可以看到,没有重叠,因为在 Linux 下,文件总是只有一种类型。我们还可以组合多个选项,比如-name-type

reader@ubuntu:~$ find /home/reader/ -name *cache* -type f
reader@ubuntu:~$ find /home/reader/ -name *cache* -type d
/home/reader/.cache
reader@ubuntu:~$

我们首先在/home/reader/中寻找包含字符串 cache 的文件find命令没有打印任何内容,这意味着我们没有找到任何东西。然而,如果我们寻找包含 cache 字符串的目录,我们会看到/home/reader/.cache/目录。

最后一个例子,让我们看看如何使用find来区分不同大小的文件。为此,我们将使用touch创建一个空文件,使用vim(或nano)创建一个非空文件:

reader@ubuntu:~$ ls -l
total 0
reader@ubuntu:~$ touch emptyfile
reader@ubuntu:~$ vim textfile.txt
reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader  0 Aug 19 11:54 emptyfile
-rw-rw-r-- 1 reader reader 23 Aug 19 11:54 textfile.txt
reader@ubuntu:~

从屏幕上的023可以看出,emptyfile包含 0 字节,而textfile.txt包含 23 字节(这不是巧合,它包含了 23 个字符的句子)。让我们看看如何使用find命令找到这两个文件:

reader@ubuntu:~$ find /home/reader/ -size 0c
/home/reader/.sudo_as_admin_successful
/home/reader/.cache/motd.legal-displayed
/home/reader/emptyfile
reader@ubuntu:~$ find /home/reader/ -size 23c
/home/reader/textfile.txt
reader@ubuntu:~$

为此,我们使用了-size选项。我们给出了我们要查找的数字,后面跟着一个表示我们正在处理的范围的字母。c用于字节,k用于千字节,M用于兆字节,依此类推。您可以在手册页上找到这些值。正如结果所示,有三个文件的大小正好为 0 字节:我们的emptyfile就是其中之一。有一个文件的大小正好为 23 字节:我们的textfile.txt。您可能会想:23 字节,那非常具体!我们怎么知道文件的确切字节数呢?好吧,您不会知道。find的创建者还实现了大于小于的结构,我们可以使用它们来提供更多的灵活性:

reader@ubuntu:~$ find /home/reader/ -size +10c
/home/reader/
/home/reader/.gnupg
/home/reader/.gnupg/private-keys-v1.d
/home/reader/.bash_logout
/home/reader/.profile
/home/reader/.bashrc
/home/reader/.viminfo
/home/reader/.lesshst
/home/reader/textfile.txt
/home/reader/.local
/home/reader/.local/share
/home/reader/.local/share/nano
/home/reader/.cache
/home/reader/.bash_history
reader@ubuntu:~$ find /home/reader/ -size +10c -size -30c
/home/reader/textfile.txt
reader@ubuntu:~$

假设我们正在寻找一个至少大于 10 字节的文件。我们在参数上使用+选项,它只打印大于 10 字节的文件。然而,我们仍然看到了太多的文件。现在,我们希望文件也小于 30 字节。我们添加另一个-size选项,这次指定-30c,意味着文件将小于 30 字节。并且,毫不意外,我们找到了我们的 23 字节的testfile.txt

所有前述选项以及更多选项都可以组合在一起,形成一个非常强大的搜索查询。您是否正在寻找一个文件,它至少有 100 KB,但不超过10 MB,在/var/中的任何位置,在上周创建,并且对您是可读的?只需在find中组合选项,您肯定会在短时间内找到该文件!

总结

本章描述了 Linux 中的文件操作。我们从常见的文件操作开始。我们解释了如何在 Linux 中使用cp复制文件,以及如何使用mv移动或重命名文件。接下来,我们讨论了如何使用rm删除文件和目录,以及如何使用ln -s命令在 Linux 下创建符号链接。

在本章的第二部分中,我们讨论了归档。虽然有许多不同的工具可以进行归档,但我们专注于 Linux 中最常用的工具:tar。我们向您展示了如何在当前工作目录和文件系统的其他位置创建和提取归档。我们描述了tar可以归档文件和整个目录,并且我们可以使用-t选项在不实际提取它的情况下查看 tarball 中的内容。

我们以使用filelocate查找文件结束了本章。我们解释了locate是一个在特定情况下有用的简单命令,而find是一个更复杂但非常强大的命令,可以为掌握它的人带来巨大的好处。

本章介绍了以下命令:cprmmvlntarlocatefind

问题

  1. 我们在 Linux 中使用哪个命令来复制文件?

  2. 移动和重命名文件之间有什么区别?

  3. 用于在 Linux 下删除文件的rm命令为什么可能很危险?

  4. 硬链接和符号(软)链接之间有什么区别?

  5. tar的三种最重要的操作模式是什么?

  6. tar用于选择输出目录的选项是什么?

  7. 在搜索文件名时,locatefind之间最大的区别是什么?

  8. find有多少个选项可以组合使用?

进一步阅读

以下资源可能会很有趣,如果您想深入了解本章的主题:

第七章:Hello World!

在本章中,我们将终于开始编写 shell 脚本。在编写和运行我们自己的Hello World!脚本之后,我们将研究一些适用于所有未来脚本的最佳实践。我们将使用许多技术来提高脚本的可读性,并在可能的情况下遵循 KISS 原则(保持简单,愚蠢)。

本章将介绍以下命令:headtailwget

本章将涵盖以下主题:

  • 第一步

  • 可读性

  • KISS

技术要求

我们将直接在虚拟机上创建我们的 shell 脚本;我们暂时不会使用 Atom/Notepad++。

本章的所有脚本都可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Linux-Shell-Scripting-Fundamentals-of-Bash-4.4/tree/master/Chapter07

第一步

在获取有关 Linux 的一些背景信息,准备我们的系统,并了解 Linux 脚本编写的重要概念之后,我们终于到达了我们将编写实际 shell 脚本的地步!

总之,shell 脚本只不过是多个 Bash 命令的顺序排列。脚本通常用于自动化重复的任务。它们可以交互式或非交互式地运行(即带有或不带有用户输入),并且可以与他人共享。让我们创建我们的Hello World脚本!我们将在我们的home目录中创建一个文件夹,用于存储每个章节的所有脚本:

reader@ubuntu:~$ ls -l
total 4
-rw-rw-r-- 1 reader reader  0 Aug 19 11:54 emptyfile
-rw-rw-r-- 1 reader reader 23 Aug 19 11:54 textfile.txt
reader@ubuntu:~$ mkdir scripts
reader@ubuntu:~$ cd scripts/
reader@ubuntu:~/scripts$ mkdir chapter_07
reader@ubuntu:~/scripts$ cd chapter_07/
reader@ubuntu:~/scripts/chapter_07$ vim hello-world.sh

接下来,在vim屏幕中,输入以下文本(注意我们在两行之间使用了空行):

#!/bin/bash

echo "Hello World!"

正如我们之前解释的,echo命令将文本打印到终端。让我们使用bash命令运行脚本:

reader@ubuntu:~/scripts/chapter_07$ bash hello-world.sh
Hello World!
reader@ubuntu:~/scripts/chapter_07

恭喜,你现在是一个 shell 脚本编写者!也许还不是一个非常优秀或全面的编写者,但无论如何都是一个 shell 脚本编写者。

请记住,如果vim还没有完全满足你的需求,你可以随时退回到nano。或者,更好的是,再次运行vimtutor并刷新那些vim操作!

shebang

你可能想知道第一行是什么意思。第二行(或者第三行,如果你算上空行的话)应该很清楚,但第一行是新的。它被称为shebang,有时也被称为sha-banghashbangpound-bang和/或hash-pling。它的功能非常简单:它告诉系统使用哪个二进制文件来执行脚本。它的格式始终是#!<binary path>。对于我们的目的,我们将始终使用#!/bin/bash shebang,但对于 Perl 或 Python 脚本,分别是#!/usr/bin/perl#!/usr/bin/python3。乍一看,这似乎是不必要的。我们创建了名为hello-world.sh的脚本,而 Perl 或 Python 脚本将使用hello-world.plhello-world.py。那么,为什么我们需要 shebang 呢?

对于 Python,它允许我们轻松区分 Python 2 和 Python 3。通常情况下,人们会期望尽快切换到编程语言的新版本,但对于 Python 来说,这似乎需要付出更多的努力,这就是为什么今天我们会看到 Python 2 和 Python 3 同时在使用中的原因。

Bash 脚本不以.bash结尾,而是以.sh结尾,这是shell的一般缩写。因此,除非我们为 Bash 指定 shebang,否则我们将以正常的 shell 执行结束。虽然对于一些脚本来说这没问题(hello-world.sh脚本将正常工作),但当我们使用 Bash 的高级功能时,就会遇到问题。

运行脚本

如果您真的留心观察,您会注意到我们执行了一个没有可执行权限的脚本,使用了bash命令。如果我们已经指定了如何运行它,为什么还需要 shebang 呢?在这种情况下,我们不需要 shebang。但是,我们需要确切地知道它是哪种类型的脚本,并找到系统上正确的二进制文件来运行它,这可能有点麻烦,特别是当您有很多脚本时。幸运的是,我们有更好的方法来运行这些脚本:使用可执行权限。让我们看看如何通过设置可执行权限来运行我们的hello-world.sh脚本:

reader@ubuntu:~/scripts/chapter_07$ ls -l
total 4
-rw-rw-r-- 1 reader reader 33 Aug 26 12:08 hello-world.sh
reader@ubuntu:~/scripts/chapter_07$ ./hello-world.sh
-bash: ./hello-world.sh: Permission denied
reader@ubuntu:~/scripts/chapter_07$ chmod +x hello-world.sh 
reader@ubuntu:~/scripts/chapter_07$ ./hello-world.sh
Hello World! reader@ubuntu:~/scripts/chapter_07$ /home/reader/scripts/chapter_07/hello-world.sh Hello World!
reader@ubuntu:~/scripts/chapter_07$ ls -l
total 4
-rwxrwxr-x 1 reader reader 33 Aug 26 12:08 hello-world.sh
reader@ubuntu:~/scripts/chapter_07$

我们可以通过运行完全限定或在相同目录中使用./来执行脚本(或任何文件,只要对于该文件来说有意义)。只要设置了可执行权限,我们就需要前缀./。这是因为安全性的原因:通常当我们执行一个命令时,PATH变量会被探索以找到该命令。现在想象一下,有人在您的主目录中放置了一个恶意的名为ls的二进制文件。如果没有./规则,运行ls命令将导致运行该二进制文件,而不是/bin/ls(它在您的PATH上)。

因为我们只是使用./hello-world.sh来运行脚本,所以现在我们需要再次使用 shebang。否则,Linux 会默认使用/bin/sh,这不是我们在Bash脚本书中想要的,对吧?

可读性

在编写 shell 脚本时,您应该始终确保代码尽可能易读。当您正在创建脚本时,所有逻辑、命令和脚本流程对您来说可能是显而易见的,但如果您一段时间后再看脚本,这就不再是显而易见的了。更糟糕的是,您很可能会与其他人一起编写脚本;这些人在编写脚本时从未考虑过您的考虑(反之亦然)。我们如何在脚本中促进更好的可读性呢?注释和冗长是我们实现这一目标的两种方式。

注释

任何优秀的软件工程师都会告诉您,在代码中放置相关注释会提高代码的质量。注释只不过是一些解释您在做什么的文本,前面加上一个特殊字符,以确保您编写代码的语言不会解释这些文本。对于 Bash 来说,这个字符是井号 #(目前更为人所熟知的是在#HashTags 中的使用)。在阅读其他来源时,它也可能被称为井号哈希。其他注释字符的例子包括//(Java,C++),--(SQL),以及<!-- comment here -->(HTML,XML)。#字符也被用作 Python 和 Perl 的注释。

注释可以放在行的开头,以确保整行不被解释,或者放在行的其他位置。在这种情况下,直到#之前的所有内容都将被处理。让我们看一个修订后的Hello World脚本中这两种情况的例子。

#!/bin/bash

# Print the text to the Terminal.
echo "Hello World!"

或者,我们可以使用以下语法:

#!/bin/bash

echo "Hello World!" # Print the text to the Terminal.

一般来说,我们更喜欢将注释放在命令的上面单独的一行。然而,一旦我们引入循环、重定向和其他高级结构,内联注释可以确保比整行注释更好的可读性。然而,最重要的是:任何相关的注释总比没有注释更好,无论是整行还是内联。按照惯例,我们总是更喜欢保持注释非常简短(一到三个单词)或者使用带有适当标点的完整句子。在需要简短句子会显得过于夸张的情况下,使用一些关键词;否则,选择完整句子。我们保证这将使您的脚本看起来更加专业。

脚本头

在我们的脚本编写中,我们总是在脚本开头包含一个标题。虽然这对于脚本的功能来说并不是必需的,但当其他人使用您的脚本时(或者再次,当您使用其他人的脚本时),它可以帮助很大。标题可以包括您认为需要的任何信息,但通常我们总是从以下字段开始:

  • 作者

  • 版本

  • 日期

  • 描述

  • 用法

通过使用注释实现简单的标题,我们可以让偶然发现脚本的人了解脚本是何时编写的,由谁编写的(如果他们有问题的话)。此外,简单的描述为脚本设定了一个目标,使用信息确保首次使用脚本时不会出现试错。让我们创建hello-world.sh脚本的副本,将其命名为hello-world-improved.sh,并实现标题和功能的注释:

reader@ubuntu:~/scripts/chapter_07$ ls -l
total 4
-rwxrwxr-x 1 reader reader 33 Aug 26 12:08 hello-world.sh
reader@ubuntu:~/scripts/chapter_07$ cp hello-world.sh hello-world-improved.sh
reader@ubuntu:~/scripts/chapter_07$ vi hello-world-improved.sh

确保脚本看起来像下面这样,但一定要输入当前日期您自己的名字

#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-08-26
# Description: Our first script!
# Usage: ./hello-world-improved.sh
#####################################

# Print the text to the Terminal.
echo "Hello World!"

现在,看起来不错吧?唯一可能突出的是,我们现在有一个包含任何功能的 12 行脚本。在这种情况下,的确,这似乎有点过分。然而,我们正在努力学习良好的实践。一旦脚本变得更加复杂,我们用于 shebang 和标题的这 10 行将不会有任何影响,但可用性显著提高。顺便说一下,我们正在引入一个新的head命令。

reader@ubuntu:~/scripts/chapter_07$ head hello-world-improved.sh
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0

# Date: 2018-08-26
# Description: Our first script!
# Usage: ./hello-world-improved.sh
#####################################

reader@ubuntu:~/scripts/chapter_07$

head命令类似于cat,但它不会打印整个文件;默认情况下,它只打印前 10 行。巧合的是,这恰好与我们创建的标题长度相同。因此,任何想要使用您的脚本的人(老实说,在 6 个月后也是任何人)只需使用head打印标题,并获取开始使用脚本所需的所有信息。

在引入head的同时,如果我们不介绍tail也是不负责任的。正如名称可能暗示的那样,head打印文件的顶部,而tail打印文件的末尾。虽然这对我们的脚本标题没有帮助,但在查看错误或警告的日志文件时非常有用:

reader@ubuntu:~/scripts/chapter_07$ tail /var/log/auth.log
Aug 26 14:45:28 ubuntu systemd-logind[804]: Watching system buttons on /dev/input/event1 (Sleep Button)
Aug 26 14:45:28 ubuntu systemd-logind[804]: Watching system buttons on /dev/input/event2 (AT Translated Set 2 keyboard)
Aug 26 14:45:28 ubuntu sshd[860]: Server listening on 0.0.0.0 port 22.
Aug 26 14:45:28 ubuntu sshd[860]: Server listening on :: port 22.
Aug 26 15:00:02 ubuntu sshd[1079]: Accepted password for reader from 10.0.2.2 port 51752 ssh2
Aug 26 15:00:02 ubuntu sshd[1079]: pam_unix(sshd:session): session opened for user reader by (uid=0)
Aug 26 15:00:02 ubuntu systemd: pam_unix(systemd-user:session): session opened for user reader by (uid=0)
Aug 26 15:00:02 ubuntu systemd-logind[804]: New session 1 of user reader.
Aug 26 15:17:01 ubuntu CRON[1272]: pam_unix(cron:session): session opened for user root by (uid=0)
Aug 26 15:17:01 ubuntu CRON[1272]: pam_unix(cron:session): session closed for user root
reader@ubuntu:~/scripts/chapter_07$

冗长

回到我们如何改善脚本的可读性。虽然注释是改善我们对脚本理解的好方法,但如果脚本中的命令使用许多晦涩的标志和选项,我们需要在注释中使用许多词语来解释一切。而且,正如您可能期望的那样,如果我们需要五行注释来解释我们的命令,那么可读性会降低而不是提高!冗长是在不要太多但也不要太少的解释之间取得平衡。例如,您可能不必向任何人解释您是否以及为什么使用ls命令,因为那是非常基本的。然而,tar命令可能相当复杂,因此简短地解释您要实现的目标可能是值得的。

在这种情况下,我们想讨论三种类型的冗长。它们分别是:

  • 注释的冗长

  • 命令的冗长

  • 命令输出的冗长

注释的冗长

冗长的问题在于很难给出明确的规则。几乎总是非常依赖于上下文。因此,虽然我们可以说,确实,我们不必评论echols,但情况并非总是如此。假设我们使用ls命令的输出来迭代一些文件;也许我们想在注释中提到这一点?或者甚至这种情况对我们的预期读者来说是如此清晰,以至于对整个循环进行简短的评论就足够了?

答案是,非常不令人满意,这取决于情况。如果您不确定,通常最好还是包括注释,但您可能希望保持更加简洁。例如,您可以选择使用 ls 构建迭代列表,而不是此 ls 实例列出所有文件,然后我们可以用它来进行脚本的其余部分的迭代。这在很大程度上是一种实践技能,所以一定要至少开始练习:随着您编写更多的 shell 脚本,您肯定会变得更好。

命令的冗长

命令输出的冗长是一个有趣的问题。在之前的章节中,您已经了解了许多命令,有时还有相应的标志和选项,可以改变该命令的功能。大多数选项都有短语法和长语法,可以实现相同的功能。以下是一个例子:

reader@ubuntu:~$ ls -R
.:
emptyfile  scripts  textfile.txt
./scripts:
chapter_07
./scripts/chapter_07:
hello-world-improved.sh  hello-world.sh
reader@ubuntu:~$ ls --recursive
.:
emptyfile  scripts  textfile.txt
./scripts:
chapter_07
./scripts/chapter_07:
hello-world-improved.sh  hello-world.sh
reader@ubuntu:~$

我们使用ls递归打印我们的主目录中的文件。我们首先使用简写选项-R,然后使用长--recursive变体。从输出中可以看出,命令完全相同,即使-R更短且输入更快。但是,--recursive选项更冗长,因为它比-R给出了更好的提示,说明我们在做什么。那么,何时使用哪个?简短的答案是:在日常工作中使用简写选项,在编写脚本时使用长选项。虽然这对大多数情况都适用,但这并不是一个绝对可靠的规则。有些简写命令使用得非常普遍,以至于使用长选项可能会更令读者困惑,尽管听起来有些违反直觉。例如,在使用 SELinux 或 AppArmor 时,ls-Z命令会打印安全上下文。这个的长选项是--context,但是这个选项没有-Z选项那么出名(根据我们的经验)。在这种情况下,使用简写会更好。

然而,我们已经看到了一个复杂的命令,但是当我们使用长选项时,它会更加可读:tar。让我们看看创建存档的两种方法:

reader@ubuntu:~/scripts/chapter_07$ ls -l
total 8
-rwxrwxr-x 1 reader reader 277 Aug 26 15:13 hello-world-improved.sh
-rwxrwxr-x 1 reader reader  33 Aug 26 12:08 hello-world.sh
reader@ubuntu:~/scripts/chapter_07$ tar czvf hello-world.tar.gz hello-world.sh
hello-world.sh
reader@ubuntu:~/scripts/chapter_07$ tar --create --gzip --verbose --file hello-world-improved.tar.gz hello-world-improved.sh
hello-world-improved.sh
reader@ubuntu:~/scripts/chapter_07$ ls -l
total 16
-rwxrwxr-x 1 reader reader 277 Aug 26 15:13 hello-world-improved.sh
-rw-rw-r-- 1 reader reader 283 Aug 26 16:28 hello-world-improved.tar.gz
-rwxrwxr-x 1 reader reader  33 Aug 26 12:08 hello-world.sh
-rw-rw-r-- 1 reader reader 317 Aug 26 16:26 hello-world.tar.gz
reader@ubuntu:~/scripts/chapter_07$

第一个命令tar czvf只使用了简写。这样的命令非常适合作为完整的行注释或内联注释:

#!/bin/bash
<SNIPPED>
# Verbosely create a gzipped tarball.
tar czvf hello-world.tar.gz hello-world.sh

或者,您可以使用以下内容:

#!/bin/bash
<SNIPPED>
# Verbosely create a gzipped tarball.
tar czvf hello-world.tar.gz hello-world.sh

tar --create --gzip --verbose --file 命令本身已经足够冗长,不需要注释,因为适当的注释实际上与长选项所表达的意思相同!

简写用于节省时间。对于日常任务来说,这是与系统交互的好方法。但是,在 shell 脚本中,清晰和冗长更为重要。使用长选项是一个更好的主意,因为使用这些选项时可以避免额外的注释。然而,一些命令使用得非常频繁,以至于长标志实际上可能更加令人困惑;在这里要根据您的最佳判断,并从经验中学习。

命令输出的冗长

最后,当运行 shell 脚本时,您将看到脚本中命令的输出(除非您想使用重定向来删除该输出,这将在第十二章中解释,在脚本中使用管道和重定向)。一些命令默认是冗长的。这些命令的很好的例子是lsecho命令:它们的整个功能就是在屏幕上打印一些东西。

如果我们回到tar命令,我们可以问自己是否需要看到正在存档的所有文件。如果脚本中的逻辑是正确的,我们可以假设正在存档正确的文件,并且这些文件的列表只会使脚本的其余输出变得混乱。默认情况下,tar不会打印任何内容;到目前为止,我们一直使用-v/--verbose选项。但是,对于脚本来说,这通常是不可取的行为,因此我们可以安全地省略此选项(除非我们有充分的理由不这样做)。

大多数命令默认具有适当的冗长性。ls的输出是打印的,但tar默认是隐藏的。对于大多数命令,可以通过使用--verbose--quiet选项(或相应的简写,通常是-v-q)来反转冗长性。wget就是一个很好的例子:这个命令用于从互联网上获取文件。默认情况下,它会输出大量关于连接、主机名解析、下载进度和下载目的地的信息。然而,很多时候,所有这些东西都不是很有趣!在这种情况下,我们使用wget--quiet选项,因为对于这种情况来说,这是命令的适当冗长性

在编写 shell 脚本时,始终考虑所使用命令的冗长性。如果不够,查看 man 页面以找到增加冗长性的方法。如果太多,同样查看 man 页面以找到更安静的选项。我们遇到的大多数命令都有一个或两个选项,有时在不同的级别(-q-qq甚至更安静的操作!)。

保持简单,愚蠢(KISS)

KISS 原则是处理 shell 脚本的一个很好的方法。虽然它可能显得有点严厉,但给出它的精神是重要的:它应该被视为很好的建议。Python 之禅中还给出了更多的建议,这是 Python 的设计原则:

  • 简单胜于复杂

  • 复杂比复杂好

  • 可读性很重要

Python 之禅中还有大约 17 个方面,但这三个对于 Bash 脚本编写也是最相关的。最后一个,'可读性很重要',现在应该是显而易见的。然而,前两个,'*简单胜于复杂'*和'*复杂胜于复杂'*与 KISS 原则密切相关。保持简单是一个很好的目标,但如果不可能,复杂的解决方案总是比复杂的解决方案更好(没有人喜欢复杂的脚本!)。

在编写脚本时,有一些事情你可以记住:

  • 如果你正在构思的解决方案似乎变得非常复杂,请做以下任一事情:

  • 研究你的问题;也许有另一个工具可以代替你现在使用的工具。

  • 看看是否可以将事情分成离散的步骤,这样它会变得更复杂但不那么复杂。

  • 问问自己是否需要一行代码完成所有操作,或者是否可能将命令拆分成多行以增加可读性。在使用管道或其他形式的重定向时,如第十二章中更详细地解释的那样,在脚本中使用管道和重定向,这是需要牢记的事情。

  • 如果它起作用,那可能不是一个坏解决方案。但是,请确保解决方案不要简单,因为边缘情况可能会在以后造成麻烦。

总结

我们从创建和运行我们的第一个 shell 脚本开始了这一章。学习一门新的软件语言时,几乎是强制性的,我们在终端上打印了 Hello World!接着,我们解释了 shebang:脚本的第一行,它是对 Linux 系统的一条指令,告诉它在运行脚本时应该使用哪个解释器。对于 Bash 脚本,约定是文件名以.sh 结尾,带有#!/bin/bash的 shebang。

我们解释了可以运行脚本的多种方式。我们可以从解释器开始,并将脚本名称作为参数传递(例如:bash hello-world.sh)。在这种情况下,shebang 是不需要的,因为我们在命令行上指定了解释器。然而,通常情况下,我们通过设置可执行权限并直接调用文件来运行脚本;在这种情况下,shebang 用于确定使用哪个解释器。因为你无法确定用户将如何运行你的脚本,包含 shebang 应该被视为强制性的。

为了提高我们脚本的质量,我们描述了如何提高我们 shell 脚本的可读性。我们解释了何时以及如何在我们的脚本中使用注释,以及如何使用注释创建一个我们可以通过使用head命令轻松查看的脚本头。我们还简要介绍了与head密切相关的tail命令。除了注释,我们还解释了冗长性的概念。

冗长性可以在多个级别找到:注释的冗长性,命令的冗长性和命令输出的冗长性。我们认为,在脚本中使用命令的长选项几乎总是比使用简写更好的主意,因为它增加了可读性,并且可以防止需要额外的注释,尽管我们已经确定,太多的注释几乎总是比没有注释更好。

我们以简要描述 KISS 原则结束了本章,我们将其与 Python 中的一些设计原则联系起来。读者应该意识到,如果有一个简单的解决方案,它往往是最好的。如果简单的解决方案不可行,应优先选择复杂的解决方案而不是复杂的解决方案。

本章介绍了以下命令:headtailwget

问题

  1. 按照惯例,当我们学习一门新的编程或脚本语言时,我们首先要做什么?

  2. Bash 的 shebang 是什么?

  3. 为什么需要 shebang?

  4. 我们可以以哪三种方式运行脚本?

  5. 为什么我们在创建 shell 脚本时要如此强调可读性?

  6. 为什么我们使用注释?

  7. 为什么我们建议为您编写的所有 shell 脚本包括脚本头?

  8. 我们讨论了哪三种冗长性类型?

  9. KISS 原则是什么?

进一步阅读

如果您想更深入地了解本章主题,以下资源可能会有趣:

第八章:变量和用户输入

在本章中,我们将首先描述变量是什么,以及我们为什么需要它们。我们将解释变量和常量之间的区别。接下来,我们将提供一些关于变量命名的可能性,并介绍一些关于命名约定的最佳实践。最后,我们将讨论用户输入以及如何正确处理它:无论是使用位置参数还是交互式脚本。我们将以介绍if-then结构和退出代码结束本章,我们将使用它们来结合位置参数和交互提示。

本章将介绍以下命令:readtestif

本章将涵盖以下主题:

  • 什么是变量?

  • 变量命名

  • 处理用户输入

  • 交互式与非交互式脚本

技术要求

除了具有来自前几章的文件的 Ubuntu 虚拟机外,不需要其他资源。

本章的所有脚本都可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Linux-Shell-Scripting-Fundamentals-of-Bash-4.4/tree/master/Chapter08。对于 name-improved.sh 脚本,只能在网上找到最终版本。在执行脚本之前,请务必验证头部中的脚本版本。

什么是变量?

变量是许多(如果不是所有)编程和脚本语言中使用的标准构建块。变量允许我们存储信息,以便稍后可以引用和使用它,通常是多次。例如,我们可以使用textvariable变量来存储句子This text is contained in the variable。在这种情况下,textvariable的变量名称被称为键,变量的内容(文本)被称为值,构成了变量的键值对。

在我们的程序中,当我们需要文本时,我们总是引用textvariable变量。现在可能有点抽象,但我们相信在本章的其余部分看到示例之后,变量的用处将变得清晰起来。

实际上,我们已经看到了 Bash 变量的使用。还记得在第四章 Linux 文件系统中,我们看过BASH_VERSIONPATH变量。让我们看看如何在 shell 脚本中使用变量。我们将使用我们的hello-world-improved.sh脚本,而不是直接使用Hello world文本,我们将首先将其放入一个变量中并引用它:

reader@ubuntu:~/scripts/chapter_08$ cp ../chapter_07/hello-world-improved.sh hello-world-variable.sh
reader@ubuntu:~/scripts/chapter_08$ ls -l
total 4
-rwxrwxr-x 1 reader reader 277 Sep  1 10:35 hello-world-variable.sh
reader@ubuntu:~/scripts/chapter_08$ vim hello-world-variable.sh

首先,我们将hello-world-improved.sh脚本从chapter_07目录复制到新创建的chapter_08目录中,并命名为hello-world-variable.sh。然后,我们使用vim进行编辑。给它以下内容:

#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-01
# Description: Our first script using variables!
# Usage: ./hello-world-variable.sh
#####################################

hello_text="Hello World!"

# Print the text to the terminal.
echo ${hello_text}

reader@ubuntu:~/scripts/chapter_08$ ./hello-world-variable.sh 
Hello World!
reader@ubuntu:~/scripts/chapter_08$

恭喜,您刚刚在脚本中使用了您的第一个变量!正如您所看到的,您可以通过在${...}语法中包装其名称来使用变量的内容。从技术上讲,只需在名称前面放置$就足够了(例如,echo $hello_text)。但是,在那种情况下,很难区分变量名称何时结束以及程序的其余部分开始——例如,如果您在句子中间使用变量(或者更好的是,在单词中间!)。如果使用${..},那么变量名称在}处结束是清晰的。

在运行时,我们定义的变量将被实际内容替换,而不是变量名称:这个过程称为变量插值,并且在所有脚本/编程语言中都会使用。我们永远不会在脚本中看到或直接使用变量的值,因为在大多数情况下,值取决于运行时配置。

您还将看到我们编辑了头部中的信息。虽然很容易忘记,但如果头部不包含正确的信息,就会降低可读性。请务必确保您的头部是最新的!

如果我们进一步解剖这个脚本,你会看到hello_text变量是标题之后的第一行功能性代码。我们称这个为给变量赋值。在一些编程/脚本语言中,你首先必须在分配之前声明一个变量(大多数情况下,这些语言有简写形式,你可以一次性声明和分配)。

声明的需要来自于一些语言是静态类型的事实(变量类型——例如字符串或整数——应该在分配值之前声明,并且编译器将检查你是否正确地进行了赋值——例如不将字符串赋值给整数类型的变量),而其他语言是动态类型的。对于动态类型的语言,语言只是假定变量的类型是从分配给它的内容中得到的。如果它被分配了一个数字,它将是一个整数;如果它被分配了文本,它将是一个字符串,依此类推。

基本上,变量可以被赋值一个值,声明初始化。尽管从技术上讲,这些是不同的事情,但你经常会看到这些术语被互换使用。不要太过纠结于此;最重要的是记住你正在创建变量及其内容

Bash 并没有真正遵循任何一种方法。Bash 的简单变量(不包括数组,我们稍后会解释)始终被视为字符串,除非操作明确指定我们应该进行算术运算。看一下下面的脚本和结果(我们为了简洁起见省略了标题):

reader@ubuntu:~/scripts/chapter_08$ vim hello-int.sh 
reader@ubuntu:~/scripts/chapter_08$ cat hello-int.sh 
#/bin/bash

# Assign a number to the variable.
hello_int=1

echo ${hello_int} + 1
reader@ubuntu:~/scripts/chapter_08$ bash hello-int.sh 
1 + 1

你可能期望我们打印出数字 2。然而,正如所述,Bash 认为一切都是字符串;它只是打印出变量的值,然后是空格、加号、另一个空格和数字 1。如果我们想要进行实际的算术运算,我们需要一种专门的语法,以便 Bash 知道它正在处理数字:

reader@ubuntu:~/scripts/chapter_08$ vim hello-int.sh 
reader@ubuntu:~/scripts/chapter_08$ cat hello-int.sh 
#/bin/bash

# Assign a number to the variable.
hello_int=1

echo $(( ${hello_int} + 1 ))

reader@ubuntu:~/scripts/chapter_08$ bash hello-int.sh 
2

通过在$((...))中包含variable + 1,我们告诉 Bash 将其作为算术表达式进行评估。

我们为什么需要变量?

希望你现在明白了如何使用变量。然而,你可能还没有完全理解为什么我们会想要需要使用变量。这可能只是为了小小的回报而额外工作,对吧?考虑下一个例子:

reader@ubuntu:~/scripts/chapter_08$ vim name.sh 
reader@ubuntu:~/scripts/chapter_08$ cat name.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-01
# Description: Script to show why we need variables.
# Usage: ./name.sh
#####################################

# Assign the name to a variable.
name="Sebastiaan"

# Print the story.
echo "There once was a guy named ${name}. ${name} enjoyed Linux and Bash so much that he wrote a book about it! ${name} really hopes everyone enjoys his book."

reader@ubuntu:~/scripts/chapter_08$ bash name.sh 
There once was a guy named Sebastiaan. Sebastiaan enjoyed Linux and Bash so much that he wrote a book about it! Sebastiaan really hopes everyone enjoys his book.
reader@ubuntu:~/scripts/chapter_08$

正如你所看到的,我们不止一次使用了name变量,而是三次。如果我们没有这个变量,而我们需要编辑这个名字,我们就需要在文本中搜索每个使用了这个名字的地方。

此外,如果我们在某个地方拼写错误,写成Sebastian而不是Sebastiaan(如果你感兴趣,这种情况经常发生),那么阅读文本和编辑文本都需要更多的努力。此外,这只是一个简单的例子:通常,变量会被多次使用(至少比三次多得多)。

此外,变量通常用于存储程序的状态。对于 Bash 脚本,你可以想象创建一个临时目录,在其中执行一些操作。我们可以将这个临时目录的位置存储在一个变量中,任何需要在临时目录中进行的操作都将使用这个变量来找到位置。程序完成后,临时目录应该被清理,变量也将不再需要。对于每次运行程序,临时目录的名称将不同,因此变量的内容也将不同,或者可变

变量的另一个优点是它们有一个名称。因此,如果我们创建一个描述性的名称,我们可以使应用程序更容易阅读和使用。我们已经确定可读性对于 shell 脚本来说总是必不可少的,而使用适当命名的变量可以帮助我们实现这一点。

变量还是常量?

到目前为止的例子中,我们实际上使用的是常量作为变量。变量这个术语意味着它可以改变,而我们的例子总是在脚本开始时分配一个变量,并在整个过程中使用它。虽然这有其优点(如前面所述,为了一致性或更容易编辑),但它还没有充分利用变量的全部功能。

常量是变量,但是一种特殊类型。简单来说,常量是在脚本开始时定义的变量,不受用户输入的影响,在执行过程中不改变值

在本章后面,当我们讨论处理用户输入时,我们将看到真正的变量。在那里,变量的内容由脚本的调用者提供,这意味着脚本的输出每次调用时都会不同,或者多样化。在本书后面,当我们描述条件测试时,我们甚至会根据脚本本身的逻辑在脚本执行过程中改变变量的值。

变量命名

接下来是命名的问题。你可能已经注意到到目前为止我们看到的变量有些什么:Bash 变量PATHBASH_VERSION都是完全大写的,但在我们的例子中,我们使用小写,用下划线分隔单词(hello_text)。考虑以下例子:

#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-08
# Description: Showing off different styles of variable naming.
# Usage: ./variable-naming.sh
#####################################

# Assign the variables.
name="Sebastiaan"
home_type="house"
LOCATION="Utrecht"
_partner_name="Sanne"
animalTypes="gecko and hamster"

# Print the story.
echo "${name} lives in a ${home_type} in ${LOCATION}, together with ${_partner_name} and their two pets: a ${animalTypes}."

如果我们运行这个,我们会得到一个不错的小故事:

reader@ubuntu:~/scripts/chapter_08$ bash variable-naming.sh 
Sebastiaan lives in a house in Utrecht, together with Sanne and their two pets: a gecko and hamster.

所以,我们的变量运行得很好!从技术上讲,我们在这个例子中所做的一切都是正确的。然而,它们看起来很混乱。我们使用了四种不同的命名约定:用下划线分隔的小写、大写、_ 小写,最后是驼峰命名法。虽然这些在技术上是有效的,但要记住可读性很重要:最好选择一种命名变量的方式,并坚持下去。

正如你所期望的,对此有很多不同的意见(可能和制表符与空格的辩论一样多!)。显然,我们也有自己的意见,我们想要分享:对于普通变量,使用用下划线分隔的小写,对于常量使用大写。从现在开始,你将在所有后续脚本中看到这种做法。

前面的例子会是这样的:

reader@ubuntu:~/scripts/chapter_08$ cp variable-naming.sh variable-naming-proper.sh
reader@ubuntu:~/scripts/chapter_08$ vim variable-naming-proper.sh
vim variable-naming-proper.sh
reader@ubuntu:~/scripts/chapter_08$ cat variable-naming-proper.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-08
# Description: Showing off uniform variable name styling.
# Usage: ./variable-naming-proper.sh
#####################################

NAME="Sebastiaan"
HOME_TYPE="house"
LOCATION="Utrecht"
PARTNER_NAME="Sanne"
ANIMAL_TYPES="gecko and hamster"

# Print the story.
echo "${NAME} lives in a ${HOME_TYPE} in ${LOCATION}, together with ${PARTNER_NAME} and their two pets: a ${ANIMAL_TYPES}."

我们希望你同意这看起来好多了。在本章后面,当我们介绍用户输入时,我们将使用普通变量,而不是到目前为止一直在使用的常量。

无论你在命名变量时决定了什么,最终只有一件事情真正重要:一致性。无论你喜欢小写、驼峰命名法还是大写,它对脚本本身没有影响(除了某些可读性的利弊,如前所述)。然而,同时使用多种命名约定会极大地混淆事情。一定要确保明智地选择一个约定,然后坚持下去!

为了保持清洁,我们通常避免使用大写变量,除了常量。这样做的主要原因是(几乎)Bash 中的所有环境变量都是用大写字母写的。如果你在脚本中使用大写变量,有一件重要的事情要记住:确保你选择的名称不会与预先存在的 Bash 变量发生冲突。这些包括PATHUSERLANGSHELLHOME等等。如果你在脚本中使用相同的名称,可能会得到一些意想不到的行为。

最好避免这些冲突,并为你的变量选择唯一的名称。例如,你可以选择SCRIPT_PATH变量,而不是PATH

处理用户输入

到目前为止,我们一直在处理非常静态的脚本。虽然为每个人准备一个可打印的故事很有趣,但它几乎不能算作一个功能性的 shell 脚本。至少,你不会经常使用它!因此,我们想要介绍 shell 脚本中非常重要的一个概念:用户输入

基本输入

在非常基本的层面上,调用脚本后在命令行上输入的所有内容都可以作为输入使用。然而,这取决于脚本如何使用它!例如,考虑以下情况:

reader@ubuntu:~/scripts/chapter_08$ ls
hello-int.sh hello-world-variable.sh name.sh variable-naming-proper.sh variable-naming.sh
reader@ubuntu:~/scripts/chapter_08$ bash name.sh 
There once was a guy named Sebastiaan. Sebastiaan enjoyed Linux and Bash so much that he wrote a book about it! Sebastiaan really hopes everyone enjoys his book.
reader@ubuntu:~/scripts/chapter_08$ bash name.sh Sanne
There once was a guy named Sebastiaan. Sebastiaan enjoyed Linux and Bash so much that he wrote a book about it! Sebastiaan really hopes everyone enjoys his book

当我们第一次调用name.sh时,我们使用了最初预期的功能。第二次调用时,我们提供了额外的参数:Sanne。然而,因为脚本根本不解析用户输入,我们看到的输出完全相同。

让我们修改name.sh脚本,以便在调用脚本时实际使用我们指定的额外输入:

reader@ubuntu:~/scripts/chapter_08$ cp name.sh name-improved.sh
reader@ubuntu:~/scripts/chapter_08$ vim name-improved.sh
reader@ubuntu:~/scripts/chapter_08$ cat name-improved.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-08
# Description: Script to show why we need variables; now with user input!
# Usage: ./name-improved.sh <name>
#####################################

# Assign the name to a variable.
name=${1}

# Print the story.
echo "There once was a guy named ${name}. ${name} enjoyed Linux and Bash so much that he wrote a book about it! ${name} really hopes everyone enjoys his book."

reader@ubuntu:~/scripts/chapter_08$ bash name-improved.sh Sanne
There once was a guy named Sanne. Sanne enjoyed Linux and Bash so much that he wrote a book about it! Sanne really hopes everyone enjoys his book.

现在看起来好多了!脚本现在接受用户输入;具体来说,是人的名字。它通过使用$1构造来实现这一点:这是第一个位置参数。我们称这些参数为位置参数,因为位置很重要:第一个参数将始终被写入$1,第二个参数将被写入$2,依此类推。我们无法交换它们。只有当我们开始考虑使我们的脚本与标志兼容时,我们才会获得更多的灵活性。如果我们向脚本提供更多的参数,我们可以使用$3$4等来获取它们。

你可以提供的参数数量是有限制的。然而,这个限制足够高,以至于你永远不必真正担心它。如果你达到了这一点,你的脚本将变得非常笨重,以至于没有人会使用它!

你可能想将一个句子作为一个参数传递给一个 Bash 脚本。在这种情况下,如果你希望将其解释为单个位置参数,你需要用单引号或双引号将整个句子括起来。如果不这样做,Bash 将认为句子中的每个空格是参数之间的分隔符;传递句子This Is Cool将导致脚本有三个参数:This、Is 和 Cool。

请注意,我们再次更新了标题,包括Usage下的新输入。然而,从功能上讲,脚本并不是那么好;我们用男性代词来指代一个女性名字!让我们快速修复一下,看看如果我们现在省略用户输入会发生什么:

reader@ubuntu:~/scripts/chapter_08$ vim name-improved.sh 
reader@ubuntu:~/scripts/chapter_08$ tail name-improved.sh 
# Date: 2018-09-08
# Description: Script to show why we need variables; now with user input!
# Usage: ./name-improved.sh
#####################################

# Assign the name to a variable.
name=${1}

# Print the story.
echo "There once was a person named ${name}. ${name} enjoyed Linux and Bash so much that he/she wrote a book about it! ${name} really hopes everyone enjoys his/her book."

reader@ubuntu:~/scripts/chapter_08$ bash name-improved.sh 
There once was a person named .  enjoyed Linux and Bash so much that he/she wrote a book about it!  really hopes everyone enjoys his/her book.

因此,我们已经使文本更加中性化。然而,当我们在没有提供名字作为参数的情况下调用脚本时,我们搞砸了输出。在下一章中,我们将更深入地讨论错误检查和输入验证,但现在请记住,如果变量缺失/为空,Bash不会提供错误;你完全有责任处理这个问题。我们将在下一章中进一步讨论这个问题,因为这是 Shell 脚本中的另一个非常重要的主题。

参数和参数

我们需要退一步,讨论一些术语——参数和参数。这并不是非常复杂,但可能有点令人困惑,有时会被错误使用。

基本上,参数是你传递给脚本的东西。在脚本中定义的内容被视为参数。看看下面的例子,看看它是如何工作的:

reader@ubuntu:~/scripts/chapter_08$ vim arguments-parameters.sh
reader@ubuntu:~/scripts/chapter_08$ cat arguments-parameters.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-08
# Description: Explaining the difference between argument and parameter.
# Usage: ./arguments-parameters.sh <argument1> <argument2>
#####################################

parameter_1=${1}
parameter_2=${2}

# Print the passed arguments:
echo "This is the first parameter, passed as an argument: ${parameter_1}"
echo "This is the second parameter, also passed as an argument: ${parameter_2}"

reader@ubuntu:~/scripts/chapter_08$ bash arguments-parameters.sh 'first-arg' 'second-argument'
This is the first parameter, passed as an argument: first-arg
This is the second parameter, also passed as an argument: second-argument

我们在脚本中以这种方式使用的变量称为参数,但在传递给脚本时被称为参数。在我们的name-improved.sh脚本中,参数是name变量。这是静态的,与脚本版本绑定。然而,参数每次脚本运行时都不同:可以是Sebastiaan,也可以是Sanne,或者其他任何名字。

记住,当我们谈论参数时,你可以将其视为运行时参数;每次运行都可能不同的东西。如果我们谈论脚本的参数,我们指的是脚本期望的静态信息(通常由运行时参数提供,或者脚本中的一些逻辑提供)。

交互式与非交互式脚本

到目前为止,我们创建的脚本使用了用户输入,但实际上并不能称之为交互式。一旦脚本启动,无论是否有参数传递给参数,脚本都会运行并完成。

但是,如果我们不想使用一长串参数,而是提示用户提供所需的信息呢?

输入read命令。read的基本用法是查看来自命令行的输入,并将其存储在REPLY变量中。自己试一试:

reader@ubuntu:~$ read
This is a random sentence!
reader@ubuntu:~$ echo $REPLY
This is a random sentence!
reader@ubuntu:~$

在启动read命令后,您的终端将换行并允许您输入任何内容。一旦您按下Enter(或者实际上,直到 Bash 遇到换行键),输入将保存到REPLY变量中。然后,您可以 echo 此变量以验证它是否实际存储了您的文本。

read有一些有趣的标志,使其在 shell 脚本中更易用。我们可以使用-p标志和一个参数(用引号括起来的要显示的文本)来向用户显示提示,并且我们可以将要存储响应的变量的名称作为最后一个参数提供:

reader@ubuntu:~$ read -p "What day is it? "
What day is it? Sunday
reader@ubuntu:~$ echo ${REPLY}
Sunday
reader@ubuntu:~$ read -p "What day is it? " day_of_week
What day is it? Sunday
reader@ubuntu:~$ echo ${day_of_week}
Sunday

在上一个示例中,我们首先使用了read -p,而没有指定要保存响应的变量。在这种情况下,read的默认行为将其放在REPLY变量中。一行后,我们用day_of_week结束了read命令。在这种情况下,完整的响应保存在一个名为此名称的变量中,如紧随其后的echo ${day_of_week}中所示。

现在让我们在实际脚本中使用read。我们将首先使用read创建脚本,然后使用到目前为止使用的位置参数:

reader@ubuntu:~/scripts/chapter_08$ vim interactive.sh
reader@ubuntu:~/scripts/chapter_08$ cat interactive.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-09
# Description: Show of the capabilities of an interactive script.
# Usage: ./interactive.sh
#####################################

# Prompt the user for information.
read -p "Name a fictional character: " character_name
read -p "Name an actual location: " location
read -p "What's your favorite food? " food

# Compose the story.
echo "Recently, ${character_name} was seen in ${location} eating ${food}!

reader@ubuntu:~/scripts/chapter_08$ bash interactive.sh
Name a fictional character: Donald Duck
Name an actual location: London
What's your favorite food? pizza
Recently, Donald Duck was seen in London eating pizza!

这样做得相当不错。用户只需调用脚本,而无需查看如何使用它,并且进一步提示提供信息。现在,让我们复制和编辑此脚本,并使用位置参数提供信息:

reader@ubuntu:~/scripts/chapter_08$ cp interactive.sh interactive-arguments.sh
reader@ubuntu:~/scripts/chapter_08$ vim interactive-arguments.sh 
reader@ubuntu:~/scripts/chapter_08$ cat interactive-arguments.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-09
# Description: Show of the capabilities of an interactive script, 
# using positional arguments.
# Usage: ./interactive-arguments.sh <fictional character name> 
# <actual location name> <your favorite food>
#####################################

# Initialize the variables from passed arguments.
character_name=${1}
location=${2}
food=${3}

# Compose the story.
echo "Recently, ${character_name} was seen in ${location} eating ${food}!"

reader@ubuntu:~/scripts/chapter_08$ bash interactive-arguments.sh "Mickey Mouse" "Paris" "a hamburger"
Recently, Mickey Mouse was seen in Paris eating a hamburger!

首先,我们将interactive.sh脚本复制到interactive-arguments.sh。我们编辑了此脚本,不再使用read,而是从传递给脚本的参数中获取值。我们编辑了标题,使用新名称和新用法,并通过提供另一组参数来运行它。再次,我们看到了一个不错的小故事。

因此,您可能会想知道,何时应该使用哪种方法?两种方法最终都得到了相同的结果。但就我们而言,这两个脚本都不够可读或简单易用。请查看以下表格,了解每种方法的优缺点:

优点 缺点
读取
  • 用户无需了解要提供的参数;他们只需运行脚本,并提示提供所需的任何信息

  • 不可能忘记提供信息

|

  • 如果要多次重复运行脚本,则需要每次输入响应

  • 无法以非交互方式运行;例如,在计划任务中

|

参数
  • 可以轻松重复

  • 也可以以非交互方式运行

|

  • 用户需要在尝试运行脚本之前了解要提供的参数

  • 很容易忘记提供所需的部分信息

|

基本上,一种方法的优点是另一种方法的缺点,反之亦然。似乎我们无法通过使用任一方法来取胜。那么,我们如何创建一个健壮的交互式脚本,也可以以非交互方式运行呢?

结合位置参数和 read

通过结合两种方法,当然可以!在我们开始执行脚本的实际功能之前,我们需要验证是否已提供了所有必要的信息。如果没有,我们可以提示用户提供缺失的信息。

我们将稍微提前查看第十一章,条件测试和脚本循环,并解释if-then逻辑的基本用法。我们将结合test命令,该命令可用于检查变量是否包含值或为空。如果是这种情况,那么我们可以使用read提示用户提供缺失的信息。

在本质上,if-then逻辑只不过是说if <某事>,then 做 <某事>。在我们的例子中,if角色名的变量为空,then使用read提示输入这个信息。我们将在我们的脚本中为所有三个参数执行此操作。

因为我们提供的参数是位置参数,我们不能只提供第一个和第三个参数;脚本会将其解释为第一个和第二个参数,第三个参数缺失。根据我们目前的知识,我们受到了这个限制。在第十五章中,使用 getopts 解析 Bash 脚本参数,我们将探讨如何使用标志提供信息。在这种情况下,我们可以分别提供所有信息,而不必担心顺序。然而,现在我们只能接受这种限制!

在我们解释test命令之前,我们需要回顾一下退出代码。基本上,每个运行并退出的程序都会返回一个代码给最初启动它的父进程。通常,如果一个进程完成并且执行成功,它会以代码 0退出。如果程序的执行不成功,它会以任何其他代码退出;然而,这通常是代码 1。虽然有关于退出代码的约定,通常你会遇到 0 表示良好退出,1 表示不良退出。

当我们使用test命令时,它也会生成符合指南的退出代码:如果测试成功,我们会看到退出代码 0。如果不成功,我们会看到另一个代码(可能是 1)。你可以使用echo $?命令查看上一个命令的退出代码。

让我们来看一个例子:

reader@ubuntu:~/scripts/chapter_08$ cd
reader@ubuntu:~$ ls -l
total 8
-rw-rw-r-- 1 reader reader    0 Aug 19 11:54 emptyfile
drwxrwxr-x 4 reader reader 4096 Sep  1 09:51 scripts
-rwxrwxr-x 1 reader reader   23 Aug 19 11:54 textfile.txt
reader@ubuntu:~$ mkdir scripts
mkdir: cannot create directory ‘scripts’: File exists
reader@ubuntu:~$ echo $?
1
reader@ubuntu:~$ mkdir testdir
reader@ubuntu:~$ echo $?
0
reader@ubuntu:~$ rmdir testdir/
reader@ubuntu:~$ echo $?
0
reader@ubuntu:~$ rmdir scripts/
rmdir: failed to remove 'scripts/': Directory not empty
reader@ubuntu:~$ echo $?
1

在上一个例子中发生了很多事情。首先,我们试图创建一个已经存在的目录。由于在同一位置不能有两个同名目录,所以mkdir命令失败了。当我们使用$?打印退出代码时,返回了1

接下来,我们成功创建了一个新目录testdir。在执行该命令后,我们打印了退出代码,看到了成功的数字:0。成功删除空的testdir后,我们再次看到了退出代码0。当我们尝试使用rmdir删除非空的scripts目录(这是不允许的)时,我们收到了一个错误消息,并看到退出代码再次是1

让我们回到test。我们需要做的是验证一个变量是否为空。如果是,我们希望启动一个read提示,让用户输入。首先我们将在${PATH}变量上尝试这个(它永远不会为空),然后在empty_variable上尝试(它确实为空)。要测试一个变量是否为空,我们使用test -z <变量名>

reader@ubuntu:~$ test -z ${PATH}
reader@ubuntu:~$ echo $?
1
reader@ubuntu:~$ test -z ${empty_variable}
reader@ubuntu:~$ echo $?
0

虽然这乍看起来似乎是错误的,但想一想。我们正在测试一个变量是否为空。由于$PATH不为空,测试失败并产生了退出代码 1。对于${empty_variable}(我们从未创建过),我们确信它确实为空,退出代码 0 证实了这一点。

如果我们想要将 Bash 的iftest结合起来,我们需要知道if期望一个以退出代码 0 结束的测试。因此,如果测试成功,我们可以做一些事情。这与我们的例子完全吻合,因为我们正在测试空变量。如果你想测试另一种情况,你需要测试一个非零长度的变量,这是test-n标志。

让我们先看一下if语法。实质上,它看起来像这样:if <退出代码 0>; then <做某事>; fi。你可以选择将其放在多行上,但在一行上使用;也会终止它。让我们看看我们是否可以为我们的需求进行操作:

reader@ubuntu:~$ if test -z ${PATH}; then read -p "Type something: " PATH; fi
reader@ubuntu:~$ if test -z ${empty_variable}; then read -p "Type something: " empty_variable; fi
Type something: Yay!
reader@ubuntu:~$ echo ${empty_variable} 
Yay!
reader@ubuntu:~$ if test -z ${empty_variable}; then read -p "Type something: " empty_variable; fi
reader@ubuntu:~

首先,我们在PATH变量上使用了我们构建的if-then子句。由于它不是空的,我们不希望出现提示:幸好我们没有得到!我们使用了相同的结构,但现在是使用empty_variable。看哪,由于test -z返回了退出码 0,所以if-then子句的then部分被执行,并提示我们输入一个值。在输入值之后,我们可以将其输出。再次运行if-then子句不会给我们read提示,因为此时变量empty_variable不再为空!

最后,让我们将这种if-then逻辑融入到我们的new interactive-ultimate.sh脚本中:

reader@ubuntu:~/scripts/chapter_08$ cp interactive.sh interactive-ultimate.sh
reader@ubuntu:~/scripts/chapter_08$ vim interactive-ultimate.sh 
reader@ubuntu:~/scripts/chapter_08$ cat interactive-ultimate.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-09
# Description: Show the best of both worlds!
# Usage: ./interactive-ultimate.sh [fictional-character-name] [actual-
# location] [favorite-food]
#####################################

# Grab arguments.
character_name=$1
location=$2
food=$3

# Prompt the user for information, if it was not passed as arguments.
if test -z ${character_name}; then read -p "Name a fictional character: " character_name; fi
if test -z ${location}; then read -p "Name an actual location: " location; fi
if test -z ${food}; then read -p "What's your favorite food? " food; fi

# Compose the story.
echo "Recently, ${character_name} was seen in ${location} eating ${food}!"

reader@ubuntu:~/scripts/chapter_08$ bash interactive-ultimate.sh 
"Goofy"

Name an actual location: Barcelona
What's your favorite food? a hotdog
Recently, Goofy was seen in Barcelona eating a hotdog!

成功!我们被提示输入locationfood,但character_name成功地从我们传递的参数中解析出来。我们创建了一个脚本,可以完全交互使用,而无需提供参数,但也可以使用参数进行非交互操作。

虽然这个脚本很有信息量,但效率并不是很高。最好是将test直接与传递的参数($1$2$3)结合起来,这样我们只需要一行。在本书的后面,我们将开始使用这样的优化,但现在更重要的是将事情写得详细一些,这样您就可以更容易地理解它们!

总结

在本章开始时,我们解释了什么是变量:它是一个标准的构建块,允许我们存储信息,以便以后引用。我们更喜欢使用变量有很多原因:我们可以存储一个值一次并多次引用它,如果需要更改值,我们只需更改一次,新值将在所有地方使用。

我们解释了常量是一种特殊类型的变量:它只在脚本开始时定义一次,不受用户输入的影响,在脚本执行过程中不会改变。

我们继续讨论了一些关于变量命名的注意事项。我们演示了 Bash 在变量命名方面非常灵活:它允许许多不同风格的变量命名。但是,我们解释了如果在同一个脚本或多个脚本之间使用多种不同的命名约定,可读性会受到影响。最好的方法是选择一种变量命名方式,并坚持下去。我们建议使用大写字母表示常量,使用小写字母和下划线分隔其他变量。这将减少本地变量和环境变量之间冲突的机会。

接下来,我们探讨了用户输入以及如何处理它。我们赋予我们脚本的用户改变脚本结果的能力,这几乎是大多数现实生活中功能脚本的必备功能。我们描述了两种不同的用户交互方法:使用位置参数的基本输入,以及使用read构造的交互式输入。

我们在本章结束时简要介绍了 if-then 逻辑和test命令。我们使用这些概念创建了一种处理用户输入的强大方式,将位置参数与read提示结合起来处理缺少的信息,同时介绍了单独使用每种方法的利弊。这样创建了一个脚本,可以根据使用情况进行交互和非交互操作。

本章介绍了以下命令:readtestif

问题

  1. 什么是变量?

  2. 我们为什么需要变量?

  3. 什么是常量?

  4. 为什么变量的命名约定特别重要?

  5. 什么是位置参数?

  6. 参数和参数之间有什么区别?

  7. 我们如何使脚本交互式?

  8. 我们如何创建一个既可以进行非交互操作又可以进行交互操作的脚本?

进一步阅读

如果您想更深入地了解本章主题,以下资源可能会很有趣:

第九章:错误检查和处理

在本章中,我们将描述如何检查错误并优雅地处理它们。我们将首先解释退出状态的概念,然后进行一些使用test命令的功能检查。之后,我们将开始使用test命令的简写表示法。本章的下一部分专门讨论错误处理:我们将使用if-then-exitif-then-else来处理简单的错误。在本章的最后部分,我们将介绍一些可以防止错误发生的方法,因为预防胜于治疗。

本章将介绍以下命令:mktemptruefalse

本章将涵盖以下主题:

  • 错误检查

  • 错误处理

  • 错误预防

技术要求

本章只需要 Ubuntu 虚拟机。如果您从未更新过您的机器,现在可能是一个好时机!sudo apt update && sudo apt upgrade -y命令会完全升级您的机器上的所有工具。如果您选择这样做,请确保重新启动您的机器,以加载升级后的内核。在 Ubuntu 上,如果存在/var/log/reboot-required文件,您可以确定需要重新启动。

本章的所有脚本都可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Linux-Shell-Scripting-Fundamentals-of-Bash-4.4/tree/master/Chapter09

错误检查

在上一章中,我们花了一些时间解释了如何在脚本中捕获和使用用户输入。虽然这使得我们的脚本更加动态,从而更加实用,但我们也引入了一个新概念:人为错误。假设您正在编写一个脚本,您希望向用户提出一个是/否问题。您可能期望一个合理的用户使用以下任何一个作为答案:

  • y

  • n

  • YES

虽然 Bash 允许我们检查我们能想到的所有值,但有时用户仍然可以通过提供您不希望的输入来破坏脚本。例如,用户用他们的母语回答是/否问题:jasinei,或者其他无数的可能性。实际上,您会发现您永远无法考虑到用户提供的每种可能的输入。鉴于事实如此,最好的解决方案是处理最常见的预期输入,并用通用错误消息捕获所有其他输入,告诉用户如何正确提供答案。我们将在本章后面看到如何做到这一点,但首先,我们将开始查看如何甚至确定是否发生了错误,通过检查命令的退出状态

退出状态

退出状态,通常也称为退出代码返回代码,是 Bash 向其父进程通信进程成功或不成功终止的方式。在 Bash 中,所有进程都是从调用它们的 shell 中fork出来的。以下图解释了这一点:

当命令运行时,例如前面图中的ps -f,当前 shell 被复制(包括环境变量!),命令在副本中运行,称为fork。命令/进程完成后,它终止 fork 并将退出状态返回给最初从中 fork 出来的 shell(在交互会话的情况下,将是您的用户会话)。在那时,您可以通过查看退出代码来确定进程是否成功执行。如前一章所述,退出代码为 0 被认为是 OK,而所有其他代码应被视为 NOT OK。由于 fork 被终止,我们需要返回代码,否则我们将无法将状态传递回我们的会话!

因为我们已经在上一章的交互式会话中看到了如何获取退出状态(提示:我们查看了$?变量的内容!),让我们看看如何在脚本中做同样的事情:

reader@ubuntu:~/scripts/chapter_09$ vim return-code.sh
reader@ubuntu:~/scripts/chapter_09$ cat return-code.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-29
# Description: Teaches us how to grab a return code.
# Usage: ./return-code.sh
#####################################

# Run a command that should always work:
mktemp
mktemp_rc=$?

# Run a command that should always fail:
mkdir /home/
mkdir_rc=$?

echo "mktemp returned ${mktemp_rc}, while mkdir returned ${mkdir_rc}!"

reader@ubuntu:~/scripts/chapter_09$ bash return-code.sh 
/tmp/tmp.DbxKK1s4aV
mkdir: cannot create directory ‘/home’: File exists
mktemp returned 0, while mkdir returned 1!

通过脚本,我们从 shebang 和 header 开始。由于在此脚本中我们不使用用户输入,因此用法只是脚本名称。我们运行的第一个命令是mktemp。这个命令用于创建一个具有随机名称的临时文件,如果我们需要在磁盘上有一个临时数据的地方,这可能会很有用。或者,如果我们向mktemp提供了-d标志,我们将创建一个具有随机名称的临时目录。因为随机名称足够长,并且我们应该始终在/tmp/中有写权限,我们期望mktemp命令几乎总是成功的,因此返回退出状态为 0。我们通过在命令直接完成后运行变量赋值来将返回代码保存到mktemp_rc变量中。这就是返回代码的最大弱点所在:我们只能在命令完成后直接使用它们。如果我们在之后做任何其他事情,返回代码将被设置为该操作,覆盖先前的退出状态!

接下来,我们运行一个我们期望总是失败的命令:mkdir /home/。我们期望它失败的原因是因为在我们的系统上(以及几乎每个 Linux 系统上),/home/目录已经存在。在这种情况下,它无法再次创建,这就是为什么该命令以退出状态 1 失败。同样,在mkdir命令之后,我们将退出状态保存到mkdir_rc变量中。

最后,我们需要检查我们的假设是否正确。使用echo,我们打印两个变量的值以及一些文本,以便知道我们在哪里打印了哪个值。这里还有一件事要注意:我们在包含变量的句子中使用了双引号。如果我们使用单引号,变量将不会被展开(Bash 术语是用变量的值替换变量名)。或者,我们可以完全省略引号,echo也会按预期执行,但是当我们开始使用重定向时,这可能会开始出现问题,这就是为什么我们认为在处理包含变量的字符串时始终使用双引号是一个好习惯。

功能检查

现在,我们知道如何检查进程的退出状态以确定它是否成功。然而,这并不是我们验证命令成功/失败的唯一方法。对于我们运行的大多数命令,我们还可以执行功能检查以查看我们是否成功。在上一个脚本中,我们尝试创建/home/目录。但是,如果我们更关心/home/目录的存在,而不是进程的退出状态呢?

以下脚本显示了我们如何对系统状态执行功能检查

reader@ubuntu:~/scripts/chapter_09$ vim functional-check.sh
reader@ubuntu:~/scripts/chapter_09$ cat functional-check.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-29
# Description: Introduces functional checks.
# Usage: ./functional-check.sh
#####################################

# Create a directory.
mkdir /tmp/temp_dir
mkdir_rc=$?

# Use test to check if the directory was created.
test -d /tmp/temp_dir
test_rc=$?

# Check out the return codes:
echo "mkdir resulted in ${mkdir_rc}, test resulted in ${test_rc}."

reader@ubuntu:~/scripts/chapter_09$ bash functional-check.sh 
mkdir resulted in 0, test resulted in 0.
reader@ubuntu:~/scripts/chapter_09$ bash functional-check.sh 
mkdir: cannot create directory ‘/tmp/temp_dir’: File exists
mkdir resulted in 1, test resulted in 0.

我们从通常的管道开始前面的脚本。接下来,我们想用mkdir创建一个目录。我们获取退出状态并将其存储在一个变量中。接下来,我们使用test命令(我们在上一章中简要探讨过)来验证/tmp/temp_dir/是否是一个目录(因此,如果它被创建了某个时间)。然后,我们用echo打印返回代码,方式与我们在 return-code.sh 中做的一样。

接下来,我们运行脚本两次。这里发生了一些有趣的事情。第一次运行脚本时,文件系统上不存在/tmp/temp_dir/目录,因此被创建。因此,mkdir命令的退出代码为 0。由于它成功创建了,test -d也成功,并像预期的那样给我们返回了退出状态 0。

现在,在脚本的第二次运行中,mkdir命令并没有成功完成。这是预期的,因为脚本的第一次运行已经创建了该目录。由于我们没有在两次运行之间删除它,mkdir的第二次运行是不成功的。然而,test -d仍然可以正常运行:目录存在,即使它并没有在脚本的那次运行中创建。

在创建脚本时,请确保仔细考虑如何检查错误。有时,返回代码是你需要的:当你需要确保命令已成功运行时就是这种情况。然而,有时功能性检查可能更合适。当最终结果很重要时(例如,目录必须存在),但造成所需状态的原因并不那么重要时,这通常是情况。

测试简写

test命令是我们 shell 脚本工具中最重要的命令之一。因为 shell 脚本经常很脆弱,特别是涉及用户输入时,我们希望尽可能使其健壮。虽然解释test命令的每个方面需要一整章,但以下是test可以做的事情:

  • 检查文件是否存在

  • 检查目录是否存在

  • 检查变量是否不为空

  • 检查两个变量是否具有相同的值

  • 检查 FILE1 是否比 FILE2 旧

  • 检查 INTEGER1 是否大于 INTEGER2

等等等等——这应该至少让你对可以用test检查的事情有所印象。在进一步阅读部分,我们包含了有关测试的广泛来源。确保看一看,因为它肯定会帮助你进行 shell 脚本编写冒险!

对于大多数脚本和编程语言,没有test命令这样的东西。显然,在这些语言中测试同样重要,但与 Bash 不同的是,测试通常直接与if-then-else逻辑集成在一起(我们将在本章的下一部分讨论)。幸运的是,Bash 有一个test命令的简写,这使它与其他语言的语法更接近:[[[

看一下以下代码,以更好地了解我们如何用这种简写替换test命令:

reader@ubuntu:~/scripts/chapter_09$ vim test-shorthand.sh
reader@ubuntu:~/scripts/chapter_09$ cat test-shorthand.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-29
# Description: Write faster tests with the shorthand!
# Usage: ./test-shorthand.sh
#####################################

# Test if the /tmp/ directory exists using the full command:
test -d /tmp/
test_rc=$?

# Test if the /tmp/ directory exists using the simple shorthand:
[ -d /tmp/ ]
simple_rc=$?

# Test if the /tmp/ directory exists using the extended shorthand:
[[ -d /tmp/ ]]
extended_rc=$?

# Print the results.
echo "The return codes are: ${test_rc}, ${simple_rc}, ${extended_rc}."

reader@ubuntu:~/scripts/chapter_09$ bash test-shorthand.sh 
The return codes are: 0, 0, 0.

正如你所看到的,在我们介绍的test语法之后,我们开始进行管道操作。接下来,我们用[替换了 test 这个词,并以]结束了这一行。这是 Bash 与其他脚本/编程语言共有的部分。请注意,与大多数语言不同,Bash 要求在[之后和]之前有空格!最后,我们使用了扩展的简写语法,以[[开头,以]]结尾。当我们打印返回代码时,它们都返回0,这意味着所有测试都成功了,即使使用了不同的语法。

[ ]和[[ ]]之间的区别很小,但可能非常重要。简单地说,[ ]的简写语法在变量或路径中包含空格时可能会引入问题。在这种情况下,测试会将空格视为分隔符,这意味着字符串hello there变成了两个参数而不是一个(hello + there)。还有其他区别,但最终我们的建议非常简单:使用[[ ]]的扩展简写语法。有关更多信息,请参阅测试部分的进一步阅读

变量复习

作为一个小小的奖励,我们对test-shorthand.sh脚本进行了轻微改进。在上一章中,我们解释了,如果我们在脚本中多次使用相同的值,最好将其作为变量。如果变量的值在脚本执行过程中不会改变,并且不受用户输入的影响,我们使用一个常量。看看我们如何在之前的脚本中加入这个:

reader@ubuntu:~/scripts/chapter_09$ cp test-shorthand.sh test-shorthand-variable.sh
reader@ubuntu:~/scripts/chapter_09$ vim test-shorthand-variable.sh 
reader@ubuntu:~/scripts/chapter_09$ cat test-shorthand-variable.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-29
# Description: Write faster tests with the shorthand, now even better 
# with a CONSTANT!
# Usage: ./test-shorthand-variable.sh
#####################################

DIRECTORY=/tmp/

# Test if the /tmp/ directory exists using the full command:
test -d ${DIRECTORY}
test_rc=$?

# Test if the /tmp/ directory exists using the simple shorthand:
[ -d ${DIRECTORY} ]
simple_rc=$?

# Test if the /tmp/ directory exists using the extended shorthand:
[[ -d ${DIRECTORY} ]]
extended_rc=$?

# Print the results.
echo "The return codes are: ${test_rc}, ${simple_rc}, ${extended_rc}."

reader@ubuntu:~/scripts/chapter_09$ bash test-shorthand-variable.sh 
The return codes are: 0, 0, 0.

虽然最终结果是相同的,但如果我们想要更改它,这个脚本更加健壮。此外,它向我们展示了我们可以在test简写中使用变量,这些变量将自动被 Bash 展开。

Bash 调试

我们还有一个更聪明的方法来证明值是否被正确展开:使用 Bash 脚本带有调试日志运行。看一下以下执行:

reader@ubuntu:~/scripts/chapter_09$ bash -x test-shorthand-variable.sh 
+ DIRECTORY=/tmp/
+ test -d /tmp/
+ test_rc=0
+ '[' -d /tmp/ ']'
+ simple_rc=0
+ [[ -d /tmp/ ]]
+ extended_rc=0
+ echo 'The return codes are: 0, 0, 0.'
The return codes are: 0, 0, 0.

如果您将此与实际脚本进行比较,您将看到脚本文本test -d ${DIRECTORY}在运行时解析为test -d /tmp/。这是因为我们没有运行bash test-shorthand-variable.sh,而是运行bash -x test-shorthand-variable.sh。在这种情况下,-x标志告诉 Bash打印命令及其参数在执行时——这是一个非常方便的事情,如果您曾经编写脚本并不确定为什么脚本没有按照您的期望执行!

错误处理

到目前为止,我们已经看到了如何检查错误。然而,除了检查错误之外,还有一个同样重要的方面:处理错误。我们将首先结合我们以前的iftest的经验来处理错误,然后介绍更智能的处理错误的方法!

if-then-exit

正如您可能还记得的,Bash 使用的if-then结构对(几乎)所有编程语言都是通用的。在其基本形式中,想法是您测试一个条件(IF),如果该条件为真,则执行某些操作(THEN)。

这是一个非常基本的例子:如果name的长度大于或等于 2 个字符,则echo "hello ${name}"。在这种情况下,我们假设一个名字至少要有 2 个字符。如果不是,输入是无效的,我们不会给它一个“hello”。

在下面的脚本if-then-exit.sh中,我们将看到我们的目标是使用cat打印文件的内容。然而,在这之前,我们检查文件是否存在,如果不存在,我们将退出脚本,并向调用者显示指定出了什么问题的消息:

reader@ubuntu:~/scripts/chapter_09$ vim if-then-exit.sh 
reader@ubuntu:~/scripts/chapter_09$ cat if-then-exit.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-30
# Description: Use the if-then-exit construct.
# Usage: ./if-then-exit.sh
#####################################

FILE=/tmp/random_file.txt

# Check if the file exists.
if [[ ! -f ${FILE} ]]; then 
  echo "File does not exist, stopping the script!"
  exit 1
fi

# Print the file content.
cat ${FILE}

reader@ubuntu:~/scripts/chapter_09$ bash -x if-then-exit.sh
+ FILE=/tmp/random_file.txt
+ [[ ! -f /tmp/random_file.txt ]]
+ echo 'File does not exist, stopping the script!'
File does not exist, stopping the script!
+ exit 1

到目前为止,这个脚本应该是清楚的。我们使用了测试的扩展简写语法,就像我们在本书的其余部分中所做的那样。-f标志在test的 man 页面中被描述为文件存在且是一个常规文件。然而,在这里我们遇到了一个小问题:我们想要打印文件(使用cat),但只有在文件存在时才这样做;否则,我们想要使用echo打印消息。在本章后面,当我们介绍if-then-else时,我们将看到如何使用正测试来实现这一点。不过,目前我们希望测试在我们检查的文件不是一个现有文件时给我们一个 TRUE。在这种情况下,从语义上讲,我们正在做以下事情:如果文件不存在,则打印一条消息并退出。Bash 中的测试语法没有一个标志可以做到这一点。幸运的是,我们可以使用一个强大的构造:感叹号,!,它可以对测试进行否定/反转!

这些示例如下:

  • if [[-f /tmp/file]]; then 做某事 → 如果文件/tmp/file 存在,则执行做某事

  • if [[!-f /tmp/file]]; then 做某事 → 如果文件/tmp/file 不存在,则执行做某事

  • if [[-n \({variable}]]; then *做某事* -> 如果变量\)不为空,则执行做某事

  • if [[!-n \({variable}]]; then *做某事* -> 如果变量\)为空,则执行做某事(因此,双重否定意味着只有在变量实际为空时才执行 do-something)

  • if [[-z \({variable}]]; then *做某事* -> 如果变量\)为空,则执行做某事

  • if [[!-z \({variable}]]; then *做某事* -> 如果变量\)为空,则执行做某事

正如你应该知道的那样,最后四个例子是重叠的。这是因为标志-n(非零)和-z(零)已经是彼此的对立面。由于我们可以用!否定测试,这意味着-z等于! -n,而! -z-n相同。在这种情况下,使用-n或!-z都无关紧要。我们建议您在使用另一个标志的否定之前,先使用特定的标志。

让我们回到我们的脚本。当我们使用否定的文件存在测试发现文件不存在时,我们向调用者打印了有用的消息并退出了脚本。在这种情况下,我们从未达到cat命令,但由于文件根本不存在,cat永远不会成功。如果我们让执行继续到那一点,我们将收到cat的错误消息。对于cat来说,这条消息并不比我们自己的消息更糟糕,但对于其他一些命令来说,错误消息绝对不总是像我们希望的那样清晰;在这种情况下,我们自己的检查并附上清晰的消息并不是一件坏事!

这里有另一个例子,我们在其中使用 if 和 test 来查看我们将在变量中捕获的状态代码:

reader@ubuntu:~/scripts/chapter_09$ vim if-then-exit-rc.sh
reader@ubuntu:~/scripts/chapter_09$ cat if-then-exit-rc.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-30
# Description: Use return codes to stop script flow.
# Usage: ./if-then-exit-rc.sh
#####################################

# Create a new top-level directory.
mkdir /temporary_dir
mkdir_rc=$?

# Test if the directory was created successfully.
if [[ ${mkdir_rc} -ne 0 ]]; then
  echo "mkdir did not successfully complete, stop script execution!"
  exit 1
fi

# Create a new file in our temporary directory.
touch /temporary_dir/tempfile.txt

reader@ubuntu:~/scripts/chapter_09$ bash if-then-exit-rc.sh
mkdir: cannot create directory ‘/temporary_dir’: Permission denied
mkdir did not successfully complete, stop script execution!

在脚本的第一个功能部分中,我们试图创建顶层目录/temporary_dir/。由于只有 root 用户拥有这些特权,而我们既不是以 root 用户身份运行,也没有使用sudo,所以mkdir失败了。当我们在mkdir_rc变量中捕获退出状态时,我们不知道确切的值(如果需要,我们可以打印它),但我们知道一件事:它不是0,这个值是保留用于成功执行的。因此,我们有两个选择:我们可以检查退出状态是否不等于 0,或者状态代码是否等于 1(这实际上是mkdir在这种情况下向父 shell 报告的)。我们通常更喜欢检查成功的缺席,而不是检查特定类型的失败(如不同的返回代码,如 1、113、127、255 等)。如果我们只在退出代码为 1 时停止,那么我们将在所有不得到 1 的情况下继续脚本:这有希望是 0,但我们不能确定。总的来说,任何不成功的事情都需要停止脚本!

对于这种情况,检查返回代码是否不是0,我们使用整数(记住,数字的一个花哨的词)比较。如果我们检查man test,我们可以看到-ne标志被描述为INTEGER1 -ne INTEGER2:INTEGER1 不等于 INTEGER2。因此,对于我们的逻辑,这意味着,如果在变量中捕获的返回代码不等于0,命令就没有成功执行,我们应该停止。记住,我们也可以使用-eq等于)标志,并用!否定它以达到相同的效果。

在当前形式中,脚本比严格需要的要长一点。我们首先将返回代码存储在一个变量中,然后再比较该变量。我们还可以直接在if-test结构中使用退出状态,就像这样:

reader@ubuntu:~/scripts/chapter_09$ cp if-then-exit-rc.sh if-then-exit-rc-improved.sh
reader@ubuntu:~/scripts/chapter_09$ vim if-then-exit-rc-improved.sh
reader@ubuntu:~/scripts/chapter_09$ cat if-then-exit-rc-improved.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-30
# Description: Use return codes to stop script flow.
# Usage: ./if-then-exit-rc-improved.sh
#####################################

# Create a new top-level directory.
mkdir /temporary_dir

# Test if the directory was created successfully.
if [[ $? -ne 0 ]]; then
  echo "mkdir did not successfully complete, stop script execution!"
  exit 1
fi

# Create a new file in our temporary directory.
touch /temporary_dir/tempfile.txt

reader@ubuntu:~/scripts/chapter_09$ bash if-then-exit-rc-improved.sh 
mkdir: cannot create directory ‘/temporary_dir’: Permission denied
mkdir did not successfully complete, stop script execution!

虽然这节省了一行(变量赋值),但也节省了一个不必要的变量。你可以看到我们将测试改为比较 0 和$?。我们知道无论如何我们都想检查执行,所以我们也可以立即这样做。如果以后需要再做,我们仍然需要将其保存在一个变量中,因为记住:退出状态只在运行命令后直接可用。在那之后,它已经被后续命令的退出状态覆盖了。

if-then-else

到目前为止,你应该已经对if-then逻辑有了一些了解。然而,你可能觉得还缺少了一些东西。如果是这样,你是对的!一个if-then结构没有 ELSE 语句是不完整的。if-then-else结构允许我们指定如果 if 子句中的测试为真时应该发生什么。从语义上讲,它可以被翻译为:

如果条件,那么做某事,否则(其他情况)做其他事情

我们可以通过拿我们之前的一个脚本if-then-exit.sh来很容易地说明这一点,并优化脚本的流程和代码:

reader@ubuntu:~/scripts/chapter_09$ cp if-then-exit.sh if-then-else.sh
reader@ubuntu:~/scripts/chapter_09$ vim if-then-else.sh 
reader@ubuntu:~/scripts/chapter_09$ cat if-then-else.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-30
# Description: Use the if-then-else construct.
# Usage: ./if-then-else.sh
#####################################

FILE=/tmp/random_file.txt

# Check if the file exists.
if [[ ! -f ${FILE} ]]; then 
  echo "File does not exist, stopping the script!"
  exit 1
else
  cat ${FILE} # Print the file content.
fi

reader@ubuntu:~/scripts/chapter_09$ bash if-then-else.sh 
File does not exist, stopping the script!
reader@ubuntu:~/scripts/chapter_09$ touch /tmp/random_file.txt
reader@ubuntu:~/scripts/chapter_09$ bash -x if-then-else.sh 
+ FILE=/tmp/random_file.txt
+ [[ ! -f /tmp/random_file.txt ]]
+ cat /tmp/random_file.txt

现在,这开始看起来像是一些东西!我们将cat命令移到了if-then-else逻辑块中。现在,它感觉(而且确实是!)像一个单一的命令:如果文件不存在,则打印错误消息并退出,否则打印其内容。不过,我们在错误情况下使用了 then 块有点奇怪;按照惯例,then 块是为成功条件保留的。我们可以通过交换 then 和 else 块来使我们的脚本更加直观;但是,我们还需要反转我们的测试条件。让我们来看一下:

reader@ubuntu:~/scripts/chapter_09$ cp if-then-else.sh if-then-else-proper.sh
reader@ubuntu:~/scripts/chapter_09$ vim if-then-else-proper.sh 
reader@ubuntu:~/scripts/chapter_09$ cat if-then-else-proper.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-30
# Description: Use the if-then-else construct, now properly.
# Usage: ./if-then-else-proper.sh file-name
#####################################

file_name=$1

# Check if the file exists.
if [[ -f ${file_name} ]]; then 
  cat ${file_name} # Print the file content.
else
  echo "File does not exist, stopping the script!"
  exit 1
fi

reader@ubuntu:~/scripts/chapter_09$ bash -x if-then-else-proper.sh /home/reader/textfile.txt 
+ FILE=/home/reader/textfile.txt
+ [[ -f /home/reader/textfile.txt ]]
+ cat /home/reader/textfile.txt
Hi, this is some text.

我们在这个脚本中所做的更改如下:

  • 我们用用户输入变量file_name替换了硬编码的 FILE 常量

  • 我们去掉了test的!反转

  • 我们交换了 then 和 else 执行块

现在,脚本首先检查文件是否存在,如果存在,则打印其内容(成功场景)。如果文件不存在,脚本将打印错误消息并以退出代码 1 退出(失败场景)。在实践中,else通常用于失败场景,then用于成功场景。但这并不是铁律,可能会有所不同,根据您可用的测试类型。如果您正在编写脚本,并且希望使用 else 块来处理成功场景,那就尽管去做:只要您确定这是您情况下的正确选择,绝对没有什么可耻的!

您可能已经注意到,在if-then-else块中,我们在 then 或 else 中执行的命令之前始终有两个空格。在脚本/编程中,这称为缩进。在 Bash 中,它只有一个功能:提高可读性。通过用两个空格缩进这些命令,我们知道它们是 then-else 逻辑的一部分。同样,很容易看到then在哪里结束,else在哪里开始。请注意,在某些语言中,特别是 Python,空白是编程语言语法的一部分,不能省略!

到目前为止,我们只使用if-then-else逻辑来检测错误,然后退出1。然而,在某些情况下,thenelse都可以用来实现脚本的目标,而不是其中一个用于错误处理。看一下以下脚本:

reader@ubuntu:~/scripts/chapter_09$ vim empty-file.sh 
reader@ubuntu:~/scripts/chapter_09$ cat empty-file.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-02
# Description: Make sure the file given as an argument is empty.
# Usage: ./empty-file.sh <file-name>
#####################################

# Grab the first argument.
file_name=$1

# If the file exists, overwrite it with the always empty file 
# /dev/null; otherwise, touch it.
if [[ -f ${file_name} ]]; then
  cp /dev/null ${file_name}
else
  touch ${file_name}
fi

# Check if either the cp or touch worked correctly.
if [[ $? -ne 0 ]]; then
  echo "Something went wrong, please check ${file_name}!"
  exit 1
else
  echo "Succes, file ${file_name} is now empty."
fi

reader@ubuntu:~/scripts/chapter_09$ bash -x empty-file.sh /tmp/emptyfile
+ file_name=/tmp/emptyfile
+ [[ -f /tmp/emptyfile ]]
+ touch /tmp/emptyfile
+ [[ 0 -ne 0 ]]
+ echo 'Succes, file /tmp/emptyfile is now empty.'
Succes, file /tmp/emptyfile is now empty.
reader@ubuntu:~/scripts/chapter_09$ bash -x empty-file.sh /tmp/emptyfile
+ file_name=/tmp/emptyfile
+ [[ -f /tmp/emptyfile ]]
+ cp /dev/null /tmp/emptyfile
+ [[ 0 -ne 0 ]]
+ echo 'Succes, file /tmp/emptyfile is now empty.'
Succes, file /tmp/emptyfile is now empty.

我们使用此脚本来确保文件存在且为空。基本上,有两种情况:文件存在(可能不为空)或不存在。在我们的if测试中,我们检查文件是否存在。如果存在,我们通过将/dev/null(始终为空)复制到用户给定的位置来用空文件替换它。否则,如果文件不存在,我们只需使用touch创建它。

正如您在脚本执行中所看到的,第一次运行此脚本时,文件不存在,并且使用touch创建。在直接之后的脚本运行中,文件存在(因为它是在第一次运行中创建的)。这次,我们可以看到cp被使用。因为我们想确保这些操作中的任何一个是否成功,我们包含了额外的if块,用于处理退出状态检查,就像我们以前看到的那样。

简写语法

到目前为止,我们已经看到了使用 if 块来查看我们之前的命令是否成功运行的一些用法。虽然功能很棒,但在每个可能发生错误的命令之后使用 5-7 行真的会增加脚本的总长度!更大的问题将是可读性:如果一半的脚本是错误检查,可能很难找到代码的底部。幸运的是,我们可以在命令之后直接检查错误的方法。我们可以使用 || 命令来实现这一点,这是逻辑 OR 的 Bash 版本。它的对应物 && 是逻辑 AND 的实现。为了说明这一点,我们将介绍两个新命令:truefalse。如果您查看各自的 man 页面,您将找到可能得到的最清晰的答案:

  • true:不执行任何操作,成功

  • false:不执行任何操作,不成功

以下脚本说明了我们如何使用 || 和 && 来创建逻辑应用程序流。如果逻辑运算符是陌生的领域,请先查看 进一步阅读 部分下的 逻辑运算符 链接:

reader@ubuntu:~/scripts/chapter_09$ vim true-false.sh 
reader@ubuntu:~/scripts/chapter_09$ cat true-false.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-02
# Description: Shows the logical AND and OR (&& and ||).
# Usage: ./true-false.sh
#####################################

# Check out how an exit status of 0 affects the logical operators:
true && echo "We get here because the first part is true!"
true || echo "We never see this because the first part is true :("

# Check out how an exit status of 1 affects the logical operators:
false && echo "Since we only continue after && with an exit status of 0, this is never printed."
false || echo "Because we only continue after || with a return code that is not 0, we see this!"

reader@ubuntu:~/scripts/chapter_09$ bash -x true-false.sh 
+ true
+ echo 'We get here because the first part is true!'
We get here because the first part is true!
+ true
+ false
+ false
+ echo 'Because we only continue after || with a return code that is not 0, we see this!'
Because we only continue after || with a return code that is not 0, we see this!

正如我们所预期的,只有在前一个命令返回退出代码 0 时,才会执行 && 之后的代码,而只有在退出代码 不是 0 时(通常是 1)才会执行 || 之后的代码。如果您仔细观察,您实际上可以在脚本的调试中看到这种情况发生。您可以看到 true 被执行了两次,以及 false。然而,我们实际上看到的第一个 echo 是在第一个 true 之后,而我们看到的第二个 echo 是在第二个 false 之后!我们已经在前面的代码中突出显示了这一点,以方便您查看。

现在,我们如何使用这个来处理错误呢?错误将给出一个不为 0 的退出状态,因此这与 false 命令是可比的。在我们的例子中,逻辑运算符 || 后面的代码在 false 之后被打印出来。这是有道理的,因为 falseecho 应该成功。在这种情况下,由于 false(默认)失败,echo 被执行。在下面的简单示例中,我们将向您展示如何在脚本中使用 || 运算符:

reader@ubuntu:~/scripts/chapter_09$ vim logical-or.sh
reader@ubuntu:~/scripts/chapter_09$ cat logical-or.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-02
# Description: Use the logical OR for error handling.
# Usage: ./logical-or.sh
#####################################

# This command will surely fail because we don't have the permissions needed:
cat /etc/shadow || exit 123

reader@ubuntu:~/scripts/chapter_09$ cat /etc/shadow
cat: /etc/shadow: Permission denied
reader@ubuntu:~/scripts/chapter_09$ echo $?
1
reader@ubuntu:~/scripts/chapter_09$ bash logical-or.sh 
cat: /etc/shadow: Permission denied
reader@ubuntu:~/scripts/chapter_09$ echo $?
123

我们尝试 cat 一个我们没有权限的文件(这是一件好事,因为 /etc/shadow 包含系统上所有用户的哈希密码)。当我们正常执行此操作时,我们会收到 1 的退出状态,就像我们的手动 cat 中所看到的那样。但是,在我们的脚本中,我们使用 exit 123。如果我们的逻辑运算符起作用,我们将不会以默认的 1 退出,而是以退出状态 123。当我们调用脚本时,我们会收到相同的 Permission denied 错误,但是这次当我们打印返回代码时,我们会看到预期的 123

如果您真的想要确认,只有在第一部分失败时才会执行 || 后面的代码,请使用 sudo 运行脚本。在这种情况下,您将看到 /etc/shadow 的内容,因为 root 具有这些权限,退出代码将是 0,而不是之前的 1 和 123。

同样,如果您只想在完全确定第一个命令已成功完成时执行代码,也可以使用 &&。要以非常优雅的方式处理潜在错误,最好在 || 之后结合使用 echoexit。在接下来的示例中,您将在接下来的几页中看到如何实现这一点!我们将在本书的其余部分中使用处理错误的方式,所以现在不要担心语法 - 在本书结束之前,您将遇到它很多次。

错误预防

到目前为止,您应该已经牢固掌握了我们如何处理(用户输入)错误。显然,这里的上下文是一切:根据情况,一些错误以不同的方式处理。本章中还有一个更重要的主题,那就是 错误预防。虽然知道如何处理错误是一回事,但如果我们能在脚本执行过程中完全避免错误,那就更好了。

检查参数

正如我们在上一章中指出的,当处理传递给脚本的位置参数时,有一些非常重要的事情。其中之一是空格,它表示参数之间的边界。如果我们需要向脚本传递包含空格的参数,我们需要将该参数用单引号或双引号括起来,否则它将被解释为多个参数。位置参数的另一个重要方面是确切地获得正确数量的参数:既不要太少,也绝对不要太多。

通过在使用位置参数的脚本中以检查传递的参数数量开始,我们可以验证用户是否正确调用了脚本。否则,我们可以指导用户如何正确调用它!以下示例向您展示了我们如何做到这一点:

reader@ubuntu:~/scripts/chapter_09$ vim file-create.sh 
reader@ubuntu:~/scripts/chapter_09$ cat file-create.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-01
# Description: Create a file with contents with this script.
# Usage: ./file-create.sh <directory_name> <file_name> <file_content>
#####################################

# We need exactly three arguments, check how many have been passed to 
# the script.
if [[ $# -ne 3 ]]; then
  echo "Incorrect usage!"
  echo "Usage: $0 <directory_name> <file_name> <file_content>"
  exit 1
fi
# Arguments are correct, lets continue.

# Save the arguments into variables.
directory_name=$1
file_name=$2
file_content=$3

# Create the absolute path for the file.
absolute_file_path=${directory_name}/${file_name}

# Check if the directory exists; otherwise, try to create it.
if [[ ! -d ${directory_name} ]]; then
  mkdir ${directory_name} || { echo "Cannot create directory, exiting script!"; exit 1; }
fi

# Try to create the file, if it does not exist.
if [[ ! -f ${absolute_file_path} ]]; then
  touch ${absolute_file_path} || { echo "Cannot create file, exiting script!"; exit 1; }
fi

# File has been created, echo the content to it.
echo ${file_content} > ${absolute_file_path}

reader@ubuntu:~/scripts/chapter_09$ bash -x file-create.sh /tmp/directory/ newfile "Hello this is my file"
+ [[ 3 -ne 3 ]]
+ directory_name=/tmp/directory/
+ file_name=newfile
+ file_content='Hello this is my file'
+ absolute_file_path=/tmp/directory//newfile
+ [[ ! -d /tmp/directory/ ]]
+ mkdir /tmp/directory/
+ [[ ! -f /tmp/directory//newfile ]]
+ touch /tmp/directory//newfile
+ echo Hello this is my file
reader@ubuntu:~/scripts/chapter_09$ cat /tmp/directory/newfile 
Hello this is my file

为了正确说明这个原则和我们之前看到的一些其他原则,我们创建了一个相当大而复杂的脚本(与您之前看到的相比)。为了更容易理解这一点,我们将它分成几部分,并依次讨论每一部分。我们将从头部开始:

#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-01
# Description: Create a file with contents with this script.
# Usage: ./file-create.sh <directory_name> <file_name> <file_content>
#####################################
...

现在,shebang 和大多数字段应该感觉很自然。然而,在指定位置参数时,我们喜欢在**<>中将它们括起来,如果它们是必需的**,则在**[]中将它们括起来,如果它们是可选的**(例如,如果它们有默认值,我们将在本章末尾看到)。这是脚本编写中的常见模式,您最好遵循它!脚本的下一部分是实际检查参数数量的部分:

...
# We need exactly three arguments, check how many have been passed to the script.
if [[ $# -ne 3 ]]; then
  echo "Incorrect usage!"
  echo "Usage: $0 <directory_name> <file_name> <file_content>"
  exit 1
fi
# Arguments are correct, lets continue.
...

这一部分的魔力来自$#的组合。类似于$?退出状态构造,$#解析为传递给脚本的参数数量。因为这是一个整数,我们可以使用test-ne-eq标志将其与我们需要的参数数量进行比较:三个。任何不是三个的都不适用于这个脚本,这就是为什么我们以这种方式构建检查。如果测试结果为正(这意味着负结果!),我们执行then-logic,告诉用户他们错误地调用了脚本。为了防止再次发生这种情况,还传递了使用脚本的正确方法。我们在这里使用了另一个技巧,即$0 符号。这解析为脚本名称,这就是为什么在错误调用的情况下,脚本名称会很好地打印在实际预期参数旁边,就像这样:

reader@ubuntu:~/scripts/chapter_09$ bash file-create.sh 1 2 3 4 5
Incorrect usage!
Usage: file-create.sh <directory_name> <file_name> <file_content>

由于这个检查和对用户的提示,我们预期用户只会错误地调用此脚本一次。因为我们还没有开始处理脚本的功能,所以我们不会出现脚本中一半的任务已经完成的情况,即使我们在脚本开始时就知道它永远不会完成,因为缺少脚本需要的信息。让我们继续下一部分脚本:

...
# Save the arguments into variables.
directory_name=$1
file_name=$2
file_content=$3

# Create the absolute path for the file.
absolute_file_path=${directory_name}/${file_name}
...

作为回顾,我们可以看到我们将位置用户输入分配给一个我们选择的变量名,以表示它所保存的内容。因为我们需要多次使用最终文件的绝对路径,我们根据用户输入结合两个变量来形成文件的绝对路径。脚本的下一部分包含实际功能:

...
# Check if the directory exists; otherwise, try to create it.
if [[ ! -d ${directory_name} ]]; then
  mkdir ${directory_name} || { echo "Cannot create directory, exiting script!"; exit 1; }
fi

# Try to create the file, if it does not exist.
if [[ ! -f ${absolute_file_path} ]]; then
  touch ${absolute_file_path} || { echo "Cannot create file, exiting script!"; exit 1; }
fi

# File has been created, echo the content to it.
echo ${file_content} > ${absolute_file_path}

对于文件和目录,我们进行类似的检查:我们检查目录/文件是否已经存在,或者我们是否需要创建它。通过使用echoexit的||简写,我们检查mkdirtouch是否返回退出状态 0。请记住,如果它们返回除 0 以外的任何值,则||之后和花括号内的所有内容都将被执行,这种情况下会退出脚本!

最后一部分包含了将回显重定向到文件的操作。简单地说,echo 的输出被重定向到一个文件中。重定向将在第十二章中深入讨论,“在脚本中使用管道和重定向”。现在,接受我们用于${file_content}的文本将被写入文件中(您可以自行检查)。

管理绝对路径和相对路径

我们还没有讨论的一个问题是:使用绝对路径和相对路径运行脚本。这可能看起来像是一个微不足道的差异,但实际上并非如此。大多数你运行的命令,无论是直接交互还是从你调用的脚本中运行,都使用你的当前工作目录作为它们的当前工作目录。你可能期望脚本中的命令默认为脚本所在的目录,但由于脚本只是你当前 shell 的一个分支(正如本章开头所解释的那样),它也继承了当前工作目录。我们可以通过创建一个复制文件到相对路径的脚本来最好地说明这一点:

reader@ubuntu:~/scripts/chapter_09$ vim log-copy.sh 
reader@ubuntu:~/scripts/chapter_09$ cat log-copy.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-02
# Description: Copy dpkg.log to a local directory.
# Usage: ./log-copy.sh
#####################################

# Create the directory in which we'll store the file.
if [[ ! -d dpkg ]]; then
  mkdir dpkg || { echo "Cannot create the directory, stopping script."; exit 1; }
fi

# Copy the log file to our new directory.
cp /var/log/dpkg.log dpkg || { echo "Cannot copy dpkg.log to the new directory."; exit 1; }

reader@ubuntu:~/scripts/chapter_09$ ls -l dpkg
ls: cannot access 'dpkg': No such file or directory
reader@ubuntu:~/scripts/chapter_09$ bash log-copy.sh 
reader@ubuntu:~/scripts/chapter_09$ ls -l dpkg
total 632
-rw-r--r-- 1 reader reader 643245 Oct  2 19:39 dpkg.log
reader@ubuntu:~/scripts/chapter_09$ cd /tmp
reader@ubuntu:/tmp$ ls -l dpkg
ls: cannot access 'dpkg': No such file or directory
reader@ubuntu:/tmp$ bash /home/reader/scripts/chapter_09/log-copy.sh 
reader@ubuntu:/tmp$ ls -l dpkg
total 632
-rw-r--r-- 1 reader reader 643245 Oct  2 19:39 dpkg.log

脚本本身非常简单——检查目录是否存在,否则创建它。您可以使用我们的简写错误处理来检查mkdir的错误。接下来,将一个已知文件(/var/log/dpkg.log)复制到dpkg目录中。第一次运行时,我们与脚本位于同一目录。我们可以看到在那里创建了dpkg目录,并且文件被复制到其中。然后,我们将当前工作目录移动到/tmp/,并再次运行脚本,这次使用绝对路径而不是第一次调用的相对路径。现在,我们可以看到dpkg目录被创建在/tmp/dpkg/下!这并不是非常意外的,但我们如何可以“避免”这种情况呢?脚本开头的一行代码就可以解决这个问题:

reader@ubuntu:~/scripts/chapter_09$ cp log-copy.sh log-copy-improved.sh
reader@ubuntu:~/scripts/chapter_09$ vim log-copy-improved.sh 
reader@ubuntu:~/scripts/chapter_09$ cat log-copy-improved.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-02
# Description: Copy dpkg.log to a local directory.
# Usage: ./log-copy-improved.sh
#####################################

# Change directory to the script location.
cd $(dirname $0)

# Create the directory in which we'll store the file.
if [[ ! -d dpkg ]]; then
  mkdir dpkg || { echo "Cannot create the directory, stopping script."; exit 1; }
fi

# Copy the log file to our new directory.
cp /var/log/dpkg.log dpkg || { echo "Cannot copy dpkg.log to the new directory."; exit 1; }

reader@ubuntu:~/scripts/chapter_09$ cd /tmp/
reader@ubuntu:/tmp$ rm -rf /tmp/dpkg/
reader@ubuntu:/tmp$ rm -rf /home/reader/scripts/chapter_09/dpkg/
reader@ubuntu:/tmp$ bash -x /home/reader/scripts/chapter_09/log-copy-improved.sh 
++ dirname /home/reader/scripts/chapter_09/log-copy-improved.sh
+ cd /home/reader/scripts/chapter_09
+ [[ ! -d dpkg ]]
+ mkdir dpkg
+ cp /var/log/dpkg.log dpkg
reader@ubuntu:/tmp$ ls -l dpkg
ls: cannot access 'dpkg': No such file or directory

正如代码执行所示,现在我们可以相对于脚本位置执行所有操作。这是通过一点点 Bash 魔法和dirname命令实现的。这个命令也很简单:它从我们传递的任何内容中打印目录名,这里是$0。你可能记得,$0 解析为脚本名称,因为它被调用。从/tmp/,这是绝对路径;如果我们从另一个目录调用它,它可能是一个相对路径。如果我们在与脚本相同的目录中,dirname,$0 将结果为.,这意味着我们cd到当前目录。这并不是真正需要的,但它也不会造成任何伤害。这似乎是一个小小的代价,换来了一个更加健壮的脚本,现在我们可以从任何地方调用它!

现在,我们不会详细讨论$(...)语法。我们将在第十二章中进一步讨论这个问题,“在脚本中使用管道和重定向”。在这一点上,记住这使我们能够在一行中获取一个值,然后将其传递给cd

处理 y/n

在本章的开始,我们向您提出了一个思考的问题:通过陈述是或否来要求用户同意或不同意某事。正如我们讨论过的,有许多可能的答案可以期待用户给出。实际上,用户可以以五种方式给出“是”的答案:y、Y、yes、YES 和 Yes。

对于“否”也是一样。让我们看看如何在不使用任何技巧的情况下进行检查:

reader@ubuntu:~/scripts/chapter_09$ vim yes-no.sh 
reader@ubuntu:~/scripts/chapter_09$ cat yes-no.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-01
# Description: Dealing with yes/no answers.
# Usage: ./yes-no.sh
#####################################

read -p "Do you like this question? " reply_variable

# See if the user responded positively.
if [[ ${reply_variable} = 'y' || ${reply_variable} = 'Y' || ${reply_variable} = 'yes' || ${reply_variable} = 'YES' || ${reply_variable} = 'Yes' ]]; then
  echo "Great, I worked really hard on it!"
  exit 0
fi

# Maybe the user responded negatively?
if [[ ${reply_variable} = 'n' || ${reply_variable} = 'N' || ${reply_variable} = 'no' || ${reply_variable} = 'NO' || ${reply_variable} = 'No' ]]; then
  echo "You did not? But I worked so hard on it!"
  exit 0
fi

# If we get here, the user did not give a proper response.
echo "Please use yes/no!"
exit 1

reader@ubuntu:~/scripts/chapter_09$ bash yes-no.sh 
Do you like this question? Yes
Great, I worked really hard on it!
reader@ubuntu:~/scripts/chapter_09$ bash yes-no.sh 
Do you like this question? n
You did not? But I worked so hard on it!
reader@ubuntu:~/scripts/chapter_09$ bash yes-no.sh 
Do you like this question? maybe 
Please use yes/no!

虽然这样做是有效的,但并不是一个非常可行的解决方案。更糟糕的是,如果用户在尝试输入“是”时碰巧开启了大写锁定键,我们最终会得到“yES”!我们需要包括这种情况吗?答案当然是否定的。Bash 有一个称为参数扩展的巧妙功能。我们将在第十六章中更深入地解释这一点,“Bash 参数替换和扩展”,但现在,我们可以给你一个它能做什么的预览:

reader@ubuntu:~/scripts/chapter_09$ cp yes-no.sh yes-no-optimized.sh
reader@ubuntu:~/scripts/chapter_09$ vim yes-no-optimized.sh 
reader@ubuntu:~/scripts/chapter_09$ cat yes-no-optimized.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-01
# Description: Dealing with yes/no answers, smarter this time!
# Usage: ./yes-no-optimized.sh
#####################################

read -p "Do you like this question? " reply_variable

# See if the user responded positively.
if [[ ${reply_variable,,} = 'y' || ${reply_variable,,} = 'yes' ]]; then
  echo "Great, I worked really hard on it!"
  exit 0
fi

# Maybe the user responded negatively?
if [[ ${reply_variable^^} = 'N' || ${reply_variable^^} = 'NO' ]]; then
  echo "You did not? But I worked so hard on it!"
  exit 0
fi

# If we get here, the user did not give a proper response.
echo "Please use yes/no!"
exit 1

reader@ubuntu:~/scripts/chapter_09$ bash yes-no-optimized.sh 
Do you like this question? YES
Great, I worked really hard on it!
reader@ubuntu:~/scripts/chapter_09$ bash yes-no-optimized.sh 
Do you like this question? no
You did not? But I worked so hard on it!

现在,我们不再对每个答案进行五次检查,而是只使用两次:一个用于完整单词(是/否),一个用于简短的单字母答案(y/n)。但是,当我们只指定了yes时,答案YES是如何工作的呢?这个问题的解决方案在于我们在变量内部包含的,,和^^。因此,我们使用了${reply_variable,,}和${reply_variable^^},而不是$。在,,,的情况下,变量首先解析为其值,然后转换为所有小写字母。因此,所有三个答案——YES, Yes 和 yes——都可以与yes进行比较,因为 Bash 会将它们扩展为这样。你可能猜到^^的作用是什么:它将字符串的内容转换为大写,这就是为什么我们可以将其与 NO 进行比较,即使我们给出的答案是 no。

始终试图站在用户的角度。他们正在处理许多不同的工具和命令。在这些情况下,处理不同方式的是/否写法的逻辑已经被整合。这甚至可以让最友好的系统管理员有点懒惰,并训练他们选择单字母答案。但你也不想惩罚那些真正听你话的系统管理员!因此,要点是以友好的方式处理最合理的答案。

摘要

在本章中,我们讨论了 Bash 脚本中错误的许多方面。首先描述了错误检查。首先,我们解释了退出状态是命令用来传达其执行是否被视为成功或失败的一种方式。介绍了test命令及其简写[[...]]符号。该命令允许我们在脚本中执行功能性检查。其中的示例包括比较字符串和整数,以及检查文件或目录是否被创建和可访问/可写。我们对变量进行了快速复习,然后简要介绍了使用调试标志-x运行脚本。

本章的第二部分涉及错误处理。我们描述了(非官方的)if-then-exit结构,我们用它来检查命令执行并在失败时退出。在随后的示例中,我们看到当我们想要检查它们时,我们并不总是需要将返回码写入变量中;我们可以直接在测试用例中使用$?。接着,我们预览了如何使用if-then-else逻辑更好地处理错误。我们通过介绍了错误处理的简写语法来结束本章的第二部分,这将在本书的其余部分中继续使用。

在本章的第三部分和最后一部分中,我们解释了错误预防。我们学习了如何检查参数是否正确,以及在调用脚本时如何避免绝对路径和相对路径的问题。在本章的最后部分,我们回答了一开始提出的问题:我们如何最好地处理用户的是/否输入?通过使用一些简单的 Bash 参数扩展(这将在本书的最后一章中进一步解释),我们能够简单地为我们的脚本的用户提供多种回答方式。

本章介绍了以下命令:mktemptruefalse

问题

  1. 我们为什么需要退出状态?

  2. 退出状态、退出码和返回码之间有什么区别?

  3. 我们在 test 中使用哪个标志来测试以下内容?

  • 现有的目录

  • 可写文件

  • 现有的符号链接

  1. test -d /tmp/的首选简写语法是什么?

  2. 如何在 Bash 会话中打印调试信息?

  3. 我们如何检查变量是否有内容?

  4. 抓取返回码的 Bash 格式是什么?

  5. ||&&中,哪个是逻辑与,哪个是逻辑或?

  6. 抓取参数数量的 Bash 格式是什么?

  7. 我们如何确保用户从任何工作目录调用脚本都没有关系?

  8. 在处理用户输入时,Bash 参数扩展如何帮助我们?

进一步阅读

如果您想深入了解本章主题,以下资源可能会很有趣:

第十章:正则表达式

本章介绍了正则表达式以及我们可以用来利用其功能的主要命令。我们将首先了解正则表达式背后的理论,然后深入到使用grepsed的正则表达式的实际示例中。

我们还将解释通配符及其在命令行上的使用方式。

本章将介绍以下命令:grepsetegrepsed

本章将涵盖以下主题:

  • 什么是正则表达式?

  • 通配符

  • 使用egrepsed的正则表达式

技术要求

本章的所有脚本都可以在 GitHub 上找到:github.com/tammert/learn-linux-shell-scripting/tree/master/chapter_10。除此之外,Ubuntu 虚拟机仍然是我们在本章中测试和运行脚本的方式。

介绍正则表达式

您可能以前听说过正则表达式regex这个术语。对于许多人来说,正则表达式似乎非常复杂,通常是从互联网或教科书中摘取的,而没有完全掌握它的作用。

虽然这对于完成一项任务来说是可以的,但是比普通系统管理员更好地理解正则表达式可以让你在创建脚本和在终端上工作时脱颖而出。

一个精心设计的正则表达式可以帮助您保持脚本简短、简单,并且能够适应未来的变化。

什么是正则表达式?

实质上,正则表达式是一段文本,它作为其他文本的搜索模式。正则表达式使得很容易地说,例如,我想选择所有包含五个字符的单词的行,或者查找所有以.log结尾的文件。

一个示例可能有助于您的理解。首先,我们需要一个可以用来探索正则表达式的命令。在 Linux 中与正则表达式一起使用的最著名的命令是grep

grep是一个缩写,意思是global regular expression print。您可以看到,这似乎是解释这个概念的一个很好的候选者!

grep

我们将按以下方式立即深入:

reader@ubuntu:~/scripts/chapter_10$ vim grep-file.txt
reader@ubuntu:~/scripts/chapter_10$ cat grep-file.txt 
We can use this regular file for testing grep.
Regular expressions are pretty cool
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ grep 'cool' grep-file.txt 
Regular expressions are pretty cool
reader@ubuntu:~/scripts/chapter_10$ cat grep-file.txt | grep 'USA'
but in the USA they use color (and realize)!

首先,让我们探索grep的基本功能,然后再深入到正则表达式。grep的功能非常简单,如man grep中所述:打印匹配模式的行

在前面的示例中,我们创建了一个包含一些句子的文件。其中一些以大写字母开头;它们大多以不同的方式结束;它们使用一些相似但不完全相同的单词。这些特征以及更多特征将在后续示例中使用。

首先,我们使用grep来匹配一个单词(默认情况下搜索区分大小写),并打印出来。grep有两种操作模式:

  • grep <pattern> <file>

  • grep <pattern>(需要以管道或|的形式输入)

第一种操作模式允许您指定一个文件名,从中您想要指定需要打印的行,如果它们匹配您指定的模式。grep 'cool' grep-file.txt命令就是一个例子。

还有另一种使用grep的方式:在流中。流是指在传输中到达您的终端的东西,但在移动过程中可以被更改。在这种情况下,对文件的cat通常会将所有行打印到您的终端上。

然而,通过管道符号(|),我们将cat的输出重定向到grep;在这种情况下,我们只需要指定要匹配的模式。任何不匹配的行将被丢弃,并且不会显示在您的终端上。

正如您所看到的,完整的语法是cat grep-file.txt | grep 'USA'

管道是一种重定向形式,我们将在第十二章中进一步讨论,在脚本中使用管道和重定向。现在要记住的是,通过使用管道,cat输出被用作grep输入,方式与文件名被用作输入相同。在讨论grep时,我们(暂时)将使用首先解释的不使用重定向的方法。

因为单词coolUSA只在一行中找到,所以grep的两个实例都只打印那一行。但是如果一个单词在多行中找到,grep会按照它们遇到的顺序(通常是从上到下)打印它们:

reader@ubuntu:~/scripts/chapter_10$ grep 'use' grep-file.txt 
We can use this regular file for testing grep.
but in the USA they use color (and realize)!

使用grep,可以指定我们希望搜索是不区分大小写的,而不是默认的区分大小写的方法。例如,这是在日志文件中查找错误的一个很好的方法。一些程序使用单词error,其他使用ERROR,我们甚至偶尔会遇到Error。通过向grep提供-i标志,所有这些结果都可以返回:

reader@ubuntu:~/scripts/chapter_10$ grep 'regular' grep-file.txt 
We can use this regular file for testing grep.
reader@ubuntu:~/scripts/chapter_10$ grep -i 'regular' grep-file.txt 
We can use this regular file for testing grep.
Regular expressions are pretty cool

通过提供-i,我们现在看到了regularRegular都已经匹配,并且它们的行已经被打印出来。

贪婪性

默认情况下,正则表达式被认为是贪婪的。这可能看起来是一个奇怪的术语来描述一个技术概念,但它确实非常合适。为了说明为什么正则表达式被认为是贪婪的,看看这个例子:

reader@ubuntu:~/scripts/chapter_10$ grep 'in' grep-file.txt 
We can use this regular file for testing grep.
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!
reader@ubuntu:~/scripts/chapter_10$ grep 'the' grep-file.txt 
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!

正如你所看到的,grep默认情况下不会寻找完整的单词。它查看文件中的字符,如果一个字符串匹配搜索(不管它们之前或之后是什么),那么该行就会被打印出来。

在第一个例子中,in匹配了正常的单词in,但也匹配了 testing。在第二个例子中,两行都有两个匹配项,thethey。

如果你只想返回整个单词,请确保在grep搜索模式中包含空格:

reader@ubuntu:~/scripts/chapter_10$ grep ' in ' grep-file.txt 
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!
reader@ubuntu:~/scripts/chapter_10$ grep ' the ' grep-file.txt 
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!

正如你所看到的,现在对' in '的搜索并没有返回包含单词testing的行,因为字符in没有被空格包围。

正则表达式只是一个特定搜索模式的定义,它在个别脚本/编程语言中的实现方式是不同的。我们在 Bash 中使用的正则表达式与 Perl 或 Java 中使用的不同。在一些语言中,贪婪性可以被调整甚至关闭,但是grepsed下的正则表达式总是贪婪的。这并不是一个问题,只是在定义搜索模式时需要考虑的事情。

字符匹配

我们现在知道了如何搜索整个单词,即使我们对大写和小写不是很确定。

我们还看到,(大多数)Linux 应用程序下的正则表达式是贪婪的,因此我们需要确保通过指定空格和字符锚点来正确处理这一点,我们将很快解释。

在这两种情况下,我们知道我们在寻找什么。但是如果我们真的不知道我们在寻找什么,或者可能只知道一部分呢?这个困境的答案是字符匹配。

在正则表达式中,有两个字符可以用作其他字符的替代品:

  • .(点)匹配任何一个字符(除了换行符)

  • *(星号)匹配前面字符的任意重复次数(甚至零次)

一个例子将有助于理解这一点:

reader@ubuntu:~/scripts/chapter_10$ vim character-class.txt 
reader@ubuntu:~/scripts/chapter_10$ cat character-class.txt 
eee
e2e
e e
aaa
a2a
a a
aabb
reader@ubuntu:~/scripts/chapter_10$ grep 'e.e' character-class.txt 
eee
e2e
e e
reader@ubuntu:~/scripts/chapter_10$ grep 'aaa*' character-class.txt 
aaa
aabb
reader@ubuntu:~/scripts/chapter_10$ grep 'aab*' character-class.txt 
aaa
aabb

在那里发生了很多事情,其中一些可能会感觉非常违反直觉。我们将逐一讨论它们,并详细说明发生了什么:

reader@ubuntu:~/scripts/chapter_10$ grep 'e.e' character-class.txt 
eee
e2e
e e

在这个例子中,我们使用点来替代任何字符。正如我们所看到的,这包括字母(eee)和数字(e2e)。但是,它也匹配了最后一行上两个 e 之间的空格字符。

这里是另一个例子:

reader@ubuntu:~/scripts/chapter_10$ grep 'aaa*' character-class.txt 
aaa
aabb

当我们使用*替代时,我们正在寻找零个或多个前面的字符。在搜索模式aaa*中,这意味着以下字符串是有效的:

  • aa

  • aaa

  • aaaa

  • aaaaa

...等等。在第一个结果之后的一切都应该是清楚的,为什么aa也匹配aaa*呢?因为零或更多中的零!在这种情况下,如果最后的a是零,我们只剩下aa

在最后一个例子中发生了同样的事情:

reader@ubuntu:~/scripts/chapter_10$ grep 'aab*' character-class.txt 
aaa
aabb

模式aab*匹配aaa 中的 aa,因为b*可以是零,这使得模式最终变成aa。当然,它也匹配一个或多个 b(aabb完全匹配)。

当你对你要找的东西只有一个大概的想法时,这些通配符就非常有用。然而,有时你会对你需要的东西有更具体的想法。

在这种情况下,我们可以使用括号[...]来缩小我们的替换范围到某个字符集。以下示例应该让你对如何使用这个有一个很好的想法:

reader@ubuntu:~/scripts/chapter_10$ grep 'f.r' grep-file.txt 
We can use this regular file for testing grep.
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ grep 'f[ao]r' grep-file.txt 
We can use this regular file for testing grep.
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ grep 'f[abcdefghijklmnopqrstuvwxyz]r' grep-file.txt 
We can use this regular file for testing grep.
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ grep 'f[az]r' grep-file.txt 
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ grep 'f[a-z]r' grep-file.txt 
We can use this regular file for testing grep.
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ grep 'f[a-k]r' grep-file.txt 
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ grep 'f[k-q]r' grep-file.txt 
We can use this regular file for testing grep

首先,我们演示使用.(点)来替换任何字符。在这种情况下,模式f.r匹配forfar

接下来,我们在f[ao]r中使用括号表示法,以表明我们将接受一个在fr之间的单个字符,它在ao的字符集中。不出所料,这又返回了farfor

如果我们用f[az]r模式来做这个,我们只能匹配farfzr。由于字符串fzr不在我们的文本文件中(显然也不是一个单词),我们只看到打印出far的那一行。

接下来,假设你想匹配一个字母,但不是一个数字。如果你使用.(点)进行搜索,就像第一个例子中那样,这将返回字母和数字。因此,你也会得到,例如,f2r作为匹配(如果它在文件中的话,实际上并不是)。

如果你使用括号表示法,你可以使用以下表示法:f[abcdefghijklmnopqrstuvwxyz]r。这匹配fr之间的任何字母 a-z。然而,在键盘上输入这个并不好(相信我)。

幸运的是,POSIX 正则表达式的创建者引入了一个简写:[a-z],就像前面的例子中所示的那样。我们也可以使用字母表的一个子集,如:f[a-k]r。由于字母o不在 a 和 k 之间,它不匹配for

最后,一个例子证明了这是一个强大而实用的模式:

reader@ubuntu:~/scripts/chapter_10$ grep reali[sz]e grep-file.txt 
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!

希望这一切仍然是有意义的。在转向行锚之前,我们将进一步结合表示法。

在前面的例子中,你看到我们可以使用括号表示法来处理美式英语和英式英语之间的一些差异。然而,这只有在拼写的差异是一个字母时才有效,比如 realise/realize。

在颜色/colour 的情况下,有一个额外的字母我们需要处理。这听起来像是一个零或更多的情况,不是吗?

reader@ubuntu:~/scripts/chapter_10$ grep 'colo[u]*r' grep-file.txt 
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!

通过使用模式colo[u]*r,我们搜索包含以colo开头的单词的行,可能包含任意数量的u,并以r结尾。由于colorcolour都适用于这个模式,两行都被打印出来。

你可能会想要使用点字符和零或更多的*表示法。然而,仔细看看在这种情况下会发生什么:

reader@ubuntu:~/scripts/chapter_10$ grep 'colo.*r' grep-file.txt 
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!

再次,两行都匹配。但是,由于第二行中包含另一个r,所以字符串color (and r被匹配,以及colourcolor

这是一个典型的例子,正则表达式模式对我们的目的来说太贪婪了。虽然我们不能告诉它变得不那么贪婪,但grep中有一个选项,让我们只寻找匹配的单词。

表示法-w评估空格和行尾/行首,以便只找到完整的单词。用法如下:

reader@ubuntu:~/scripts/chapter_10$ grep -w 'colo.*r' grep-file.txt 
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!

现在,只有单词colourcolor被匹配。之前,我们在单词周围放置了空格以促进这种行为,但由于单词colour在行尾,它后面没有空格。

自己尝试一下,看看为什么用colo.*r搜索模式括起来不起作用,但使用-w选项却起作用。

一些正则表达式的实现有{3}表示法,用来补充*表示法。在这种表示法中,你可以精确指定模式应该出现多少次。搜索模式[a-z]{3}将匹配所有恰好三个字符的小写字符串。在 Linux 中,这只能用扩展的正则表达式来实现,我们将在本章后面看到。

行锚

我们已经简要提到了行锚。根据我们目前为止提出的解释,我们只能在一行中搜索单词;我们还不能设置对单词在行中的位置的期望。为此,我们使用行锚。

在正则表达式中,^(插入符)字符表示行的开头,$(美元)表示行的结尾。我们可以在搜索模式中使用这些,例如,在以下情况下:

  • 查找单词 error,但只在行的开头:^error

  • 查找以句点结尾的行:\.$

  • 查找空行:^$

第一个用法,查找行的开头,应该是很清楚的。下面的例子使用了grep -i(记住,这允许我们不区分大小写地搜索),展示了我们如何使用这个来按行位置进行过滤:

reader@ubuntu:~/scripts/chapter_10$ grep -i 'regular' grep-file.txt 
We can use this regular file for testing grep.
Regular expressions are pretty cool
reader@ubuntu:~/scripts/chapter_10$ grep -i '^regular' grep-file.txt 
Regular expressions are pretty cool

在第一个搜索模式regular中,我们返回了两行。这并不意外,因为这两行都包含单词regular(尽管大小写不同)。

现在,为了只选择以单词Regular开头的行,我们使用插入符字符^来形成模式^regular。这只返回单词在该行的第一个位置的行。(请注意,如果我们没有选择在grep上包括-i,我们可以使用[Rr]egular代替。)

下一个例子,我们查找以句点结尾的行,会有点棘手。你会记得,在正则表达式中,句点被认为是一个特殊字符;它是任何其他一个字符的替代。如果我们正常使用它,我们会看到文件中的所有行都返回(因为所有行都以任何一个字符结尾)。

要实际搜索文本中的句点,我们需要转义句点,即用反斜杠前缀它;这告诉正则表达式引擎不要将句点解释为特殊字符,而是搜索它:

reader@ubuntu:~/scripts/chapter_10$ grep '.$' grep-file.txt 
We can use this regular file for testing grep.
Regular expressions are pretty cool
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ grep '\.$' grep-file.txt 
We can use this regular file for testing grep.
Also, New Zealand is pretty far away.

由于\用于转义特殊字符,你可能会遇到在文本中寻找反斜杠的情况。在这种情况下,你可以使用反斜杠来转义反斜杠的特殊功能!在这种情况下,你的模式将是\\,它与\字符串匹配。

在这个例子中,我们遇到了另一个问题。到目前为止,我们总是用单引号引用所有模式。然而,并不总是需要这样!例如,grep cool grep-file.txtgrep 'cool' grep-file.txt 一样有效。

那么,我们为什么要这样做呢?提示:尝试前面的例子,使用点行结束,不用引号。然后记住,在 Bash 中,美元符号也用于表示变量。如果我们引用它,Bash 将不会扩展$,这将返回问题结果。

我们将在第十六章中讨论 Bash 扩展,Bash 参数替换和扩展

最后,我们介绍了^$模式。这搜索一个行的开头,紧接着一个行的结尾。只有一种情况会发生这种情况:一个空行。

为了说明为什么你想要找到空行,让我们看一个新的grep标志:-v。这个标志是--invert-match的缩写,这应该给出一个关于它实际上做什么的好提示:它打印不匹配的行,而不是匹配的行。

通过使用grep -v '^$' <文件名>,你可以打印一个没有空行的文件。在一个随机的配置文件上试一试:

reader@ubuntu:/etc$ cat /etc/ssh/ssh_config 

# This is the ssh client system-wide configuration file.  See
# ssh_config(5) for more information.  This file provides defaults for
# users, and the values can be changed in per-user configuration files
# or on the command line.

# Configuration data is parsed as follows:
<SNIPPED>
reader@ubuntu:/etc$ grep -v '^$' /etc/ssh/ssh_config 
# This is the ssh client system-wide configuration file.  See
# ssh_config(5) for more information.  This file provides defaults for
# users, and the values can be changed in per-user configuration files
# or on the command line.
# Configuration data is parsed as follows:
<SNIPPED>

正如你所看到的,/etc/ssh/ssh_config 文件以一个空行开头。然后,在注释块之间,还有另一行空行。通过使用 grep -v '^$',这些空行被移除了。虽然这是一个不错的练习,但这并没有真正为我们节省多少行。

然而,有一个搜索模式是广泛使用且非常强大的:过滤配置文件中的注释。这个操作可以快速概述实际配置了什么,并省略所有注释(尽管注释本身也有其价值,但在你只想看到配置选项时可能会妨碍)。

为了做到这一点,我们将行首的插入符号与井号结合起来,表示注释:

reader@ubuntu:/etc$ grep -v '^#' /etc/ssh/ssh_config 

Host *
    SendEnv LANG LC_*
    HashKnownHosts yes
    GSSAPIAuthentication yes

这仍然打印所有空行,但不再打印注释。在这个特定的文件中,共有 51 行,只有四行包含实际的配置指令!所有其他行要么是空的,要么包含注释。很酷,对吧?

使用 grep,也可以同时使用多个模式。通过使用这种方法,可以结合过滤空行和注释行,快速概述配置选项。使用 -e 选项定义多个模式。在这种情况下,完整的命令是 grep -v -e '^$' -e '^#' /etc/ssh/ssh_config。试试看!

字符类

我们现在已经看到了许多如何使用正则表达式的示例。虽然大多数事情都很直观,但我们也看到,如果我们想要过滤大写和小写字符串,我们要么必须为 grep 指定 -i 选项,要么将搜索模式从 [a-z] 更改为 [a-zA-z]。对于数字,我们需要使用 [0-9]

有些人可能觉得这样工作很好,但其他人可能不同意。在这种情况下,可以使用另一种可用的表示法:[[:pattern:]]

下一个例子同时使用了这种新的双括号表示法和旧的单括号表示法:

reader@ubuntu:~/scripts/chapter_10$ grep [[:digit:]] character-class.txt 
e2e
a2a
reader@ubuntu:~/scripts/chapter_10$ grep [0-9] character-class.txt 
e2e
a2a

正如你所看到的,这两种模式都导致相同的行:包含数字的行。同样的方法也适用于大写字符:

reader@ubuntu:~/scripts/chapter_10$ grep [[:upper:]] grep-file.txt 
We can use this regular file for testing grep.
Regular expressions are pretty cool
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ grep [A-Z] grep-file.txt 
We can use this regular file for testing grep.
Regular expressions are pretty cool
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!
Also, New Zealand is pretty far away.

最终,使用哪种表示法是个人偏好的问题。不过,双括号表示法有一点值得一提:它更接近其他脚本/编程语言的实现。例如,大多数正则表达式实现使用 \w(单词)来选择字母,使用 \d(数字)来搜索数字。在 \w 的情况下,大写变体直观地是 \W

为了方便起见,这里是一个包含最常见的 POSIX 双括号字符类的表格:

表示法 描述 单括号等效
[[:alnum:]] 匹配小写字母、大写字母或数字 [a-z A-Z 0-9]
[[:alpha:]] 匹配小写字母和大写字母 [a-z A-Z]
[[:digit:]] 匹配数字 [0-9]
[[:lower:]] 匹配小写字母 [a-z]
[[:upper:]] 匹配大写字母 [A-Z]
[[:blank:]] 匹配空格和制表符 [ \t]

我们更喜欢使用双括号表示法,因为它更好地映射到其他正则表达式实现。在脚本中可以自由选择使用任何一种!但是,一如既往:确保你选择一种,并坚持使用它;不遵循标准会导致令人困惑的杂乱脚本。本书中的其余示例将使用双括号表示法。

通配符

我们现在已经掌握了正则表达式的基础知识。在 Linux 上,还有一个与正则表达式密切相关的主题:通配符。即使你可能没有意识到,你在本书中已经看到了通配符的示例。

更好的是,实际上你已经有很大的机会在实践中使用了通配符模式。如果在命令行上工作时,你曾经使用通配符字符 *,那么你已经在使用通配符!

什么是通配符?

简单地说,glob 模式描述了将通配符字符注入文件路径操作。所以,当你执行cp * /tmp/时,你将当前工作目录中的所有文件(不包括目录!)复制到/tmp/目录中。

*扩展到工作目录中的所有常规文件,然后所有这些文件都被复制到/tmp/中。

这是一个简单的例子:

reader@ubuntu:~/scripts/chapter_10$ ls -l
total 8
-rw-rw-r-- 1 reader reader  29 Oct 14 10:29 character-class.txt
-rw-rw-r-- 1 reader reader 219 Oct  8 19:22 grep-file.txt
reader@ubuntu:~/scripts/chapter_10$ cp * /tmp/
reader@ubuntu:~/scripts/chapter_10$ ls -l /tmp/
total 20
-rw-rw-r-- 1 reader reader   29 Oct 14 16:35 character-class.txt
-rw-rw-r-- 1 reader reader  219 Oct 14 16:35 grep-file.txt
<SNIPPED>

我们使用*来选择它们两个。相同的 glob 模式也可以用于rm

reader@ubuntu:/tmp$ ls -l
total 16
-rw-rw-r-- 1 reader reader   29 Oct 14 16:37 character-class.txt
-rw-rw-r-- 1 reader reader  219 Oct 14 16:37 grep-file.txt
drwx------ 3 root root 4096 Oct 14 09:22 systemd-private-c34c8acb350...
drwx------ 3 root root 4096 Oct 14 09:22 systemd-private-c34c8acb350...
reader@ubuntu:/tmp$ rm *
rm: cannot remove 'systemd-private-c34c8acb350...': Is a directory
rm: cannot remove 'systemd-private-c34c8acb350...': Is a directory
reader@ubuntu:/tmp$ ls -l
total 8
drwx------ 3 root root 4096 Oct 14 09:22 systemd-private-c34c8acb350...
drwx------ 3 root root 4096 Oct 14 09:22 systemd-private-c34c8acb350...

默认情况下,rm只会删除文件而不是目录(正如你从前面的例子中的错误中看到的)。正如第六章所述,文件操作,添加-r将递归地删除目录。

再次,请考虑这样做的破坏性:没有警告,你可能会删除当前树位置内的每个文件(当然,如果你有权限的话)。前面的例子展示了* glob 模式有多么强大:它会扩展到它能找到的每个文件,无论类型如何。

与正则表达式的相似之处

正如所述,glob 命令实现了与正则表达式类似的效果。不过也有一些区别。例如,正则表达式中的*字符代表前一个字符的零次或多次出现。对于 globbing 来说,它是一个通配符,代表任何字符,更类似于正则表达式的.*表示。

与正则表达式一样,glob 模式可以由普通字符和特殊字符组合而成。看一个例子,其中ls与不同的参数/ globbing 模式一起使用:

reader@ubuntu:~/scripts/chapter_09$ ls -l
total 68
-rw-rw-r-- 1 reader reader  682 Oct  2 18:31 empty-file.sh
-rw-rw-r-- 1 reader reader 1183 Oct  1 19:06 file-create.sh
-rw-rw-r-- 1 reader reader  467 Sep 29 19:43 functional-check.sh
<SNIPPED>
reader@ubuntu:~/scripts/chapter_09$ ls -l *
-rw-rw-r-- 1 reader reader  682 Oct  2 18:31 empty-file.sh
-rw-rw-r-- 1 reader reader 1183 Oct  1 19:06 file-create.sh
-rw-rw-r-- 1 reader reader  467 Sep 29 19:43 functional-check.sh
<SNIPPED>
reader@ubuntu:~/scripts/chapter_09$ ls -l if-then-exit.sh 
-rw-rw-r-- 1 reader reader 416 Sep 30 18:51 if-then-exit.sh
reader@ubuntu:~/scripts/chapter_09$ ls -l if-*.sh
-rw-rw-r-- 1 reader reader 448 Sep 30 20:10 if-then-else-proper.sh
-rw-rw-r-- 1 reader reader 422 Sep 30 19:56 if-then-else.sh
-rw-rw-r-- 1 reader reader 535 Sep 30 19:44 if-then-exit-rc-improved.sh
-rw-rw-r-- 1 reader reader 556 Sep 30 19:18 if-then-exit-rc.sh
-rw-rw-r-- 1 reader reader 416 Sep 30 18:51 if-then-exit.sh

在上一章的scripts目录中,我们首先运行了一个普通的ls -l。如你所知,这会打印出目录中的所有文件。现在,如果我们使用ls -l *,我们会得到完全相同的结果。看起来,鉴于缺少参数,ls会为我们注入一个通配符 glob。

接下来,我们使用ls的替代模式,其中我们将文件名作为参数。在这种情况下,因为每个目录的文件名是唯一的,我们只会看到返回的单行。

但是,如果我们想要所有以if-开头的scripts(以.sh结尾)呢?我们使用if-*.sh的 globbing 模式。在这个模式中,*通配符被扩展为匹配,正如man glob所说,任何字符串,包括空字符串

更多的 globbing

在 Linux 中,globbing 非常常见。如果你正在处理一个处理文件的命令(根据一切皆为文件原则,大多数命令都是如此),那么你很有可能可以使用 globbing。为了让你对此有所了解,考虑以下例子:

reader@ubuntu:~/scripts/chapter_10$ cat *
eee
e2e
e e
aaa
a2a
a a
aabb
We can use this regular file for testing grep.
Regular expressions are pretty cool
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!
Also, New Zealand is pretty far away.

cat命令与通配符 glob 模式结合使用,打印出当前工作目录中所有文件的内容。在这种情况下,由于所有文件都是 ASCII 文本,这并不是真正的问题。正如你所看到的,文件都是紧挨在一起打印出来的;它们之间甚至没有空行。

如果你cat一个二进制文件,你的屏幕会看起来像这样:

reader@ubuntu:~/scripts/chapter_10$ cat /bin/chvt 
@H!@8    @@@�888�� �� �  H 88 8 �TTTDDP�td\\\llQ�tdR�td�� � /lib64/ld-linux-x86-64.so.2GNUGNU��H������)�!�@`��a*�K��9���X' Q��/9'~���C J

最糟糕的情况是二进制文件包含某个字符序列,这会对你的 Bash shell 进行临时更改,使其无法使用(是的,这种情况我们遇到过很多次)。这里的教训应该很简单:在使用 glob 时要小心!

到目前为止,我们看到的其他命令可以处理 globbing 模式的命令包括chmodchownmvtargrep等等。现在可能最有趣的是grep。我们已经在单个文件上使用了正则表达式与grep,但我们也可以使用 glob 来选择文件。

让我们来看一个最荒谬的grep与 globbing 的例子:在everything中找到anything

reader@ubuntu:~/scripts/chapter_10$ grep .* *
grep: ..: Is a directory
character-class.txt:eee
character-class.txt:e2e
character-class.txt:e e
character-class.txt:aaa
character-class.txt:a2a
character-class.txt:a a
character-class.txt:aabb
grep-file.txt:We can use this regular file for testing grep.
grep-file.txt:Regular expressions are pretty cool
grep-file.txt:Did you ever realise that in the UK they say colour,
grep-file.txt:but in the USA they use color (and realize)!
grep-file.txt:Also, New Zealand is pretty far away.

在这里,我们使用了正则表达式.*的搜索模式(任何东西,零次或多次)与*的 glob 模式(任何文件)。正如你所期望的那样,这应该匹配每个文件的每一行。

当我们以这种方式使用grep时,它的功能基本上与之前的cat *相同。但是,当grep用于多个文件时,输出会包括文件名(这样您就知道找到该行的位置)。

请注意:globbing 模式总是与文件相关,而正则表达式是用于文件内部,用于实际内容。由于语法相似,您可能不会对此感到太困惑,但如果您曾经遇到过模式不按您的预期工作的情况,那么花点时间考虑一下您是在进行 globbing 还是正则表达式会很有帮助!

高级 globbing

基本的 globbing 主要是使用通配符,有时与部分文件名结合使用。然而,正如正则表达式允许我们替换单个字符一样,glob 也可以。

正则表达式通过点来实现这一点;在 globbing 模式中,问号被使用:

reader@ubuntu:~/scripts/chapter_09$ ls -l if-then-*
-rw-rw-r-- 1 reader reader 448 Sep 30 20:10 if-then-else-proper.sh
-rw-rw-r-- 1 reader reader 422 Sep 30 19:56 if-then-else.sh
-rw-rw-r-- 1 reader reader 535 Sep 30 19:44 if-then-exit-rc-improved.sh
-rw-rw-r-- 1 reader reader 556 Sep 30 19:18 if-then-exit-rc.sh
-rw-rw-r-- 1 reader reader 416 Sep 30 18:51 if-then-exit.sh
reader@ubuntu:~/scripts/chapter_09$ ls -l if-then-e???.sh
-rw-rw-r-- 1 reader reader 422 Sep 30 19:56 if-then-else.sh
-rw-rw-r-- 1 reader reader 416 Sep 30 18:51 if-then-exit.sh

现在,globbing 模式if-then-e???.sh应该不言自明了。在?出现的地方,任何字符(字母、数字、特殊字符)都是有效的替代。

在前面的例子中,所有三个问号都被字母替换。正如您可能已经推断出的那样,正则表达式.字符与 globbing 模式?字符具有相同的功能:它有效地代表一个字符。

最后,我们用于正则表达式的单括号表示法也可以用于 globbing。一个快速的例子展示了我们如何在cat中使用它:

reader@ubuntu:/tmp$ echo ping > ping # Write the word ping to the file ping.
reader@ubuntu:/tmp$ echo pong > pong # Write the word pong to the file pong.
reader@ubuntu:/tmp$ ls -l
total 16
-rw-rw-r-- 1 reader reader    5 Oct 14 17:17 ping
-rw-rw-r-- 1 reader reader    5 Oct 14 17:17 pong
reader@ubuntu:/tmp$ cat p[io]ng
ping
pong
reader@ubuntu:/tmp$ cat p[a-z]ng
ping
pong

禁用 globbing 和其他选项

尽管 globbing 功能强大,但这也是它危险的原因。因此,您可能希望采取激烈措施并关闭 globbing。虽然这是可能的,但我们并没有在实践中看到过。但是,对于一些工作或脚本,关闭 globbing 可能是一个很好的保障。

使用set命令,我们可以像 man 页面所述那样更改 shell 选项的值。在这种情况下,使用-f将关闭 globbing,正如我们在尝试重复之前的例子时所看到的:

reader@ubuntu:/tmp$ cat p?ng
ping
pong
reader@ubuntu:/tmp$ set -f
reader@ubuntu:/tmp$ cat p?ng
cat: 'p?ng': No such file or directory
reader@ubuntu:/tmp$ set +f
reader@ubuntu:/tmp$ cat p?ng
ping
pong

通过在前缀加上减号(-)来关闭选项,通过在前缀加上加号(+)来打开选项。您可能还记得,这不是您第一次使用这个功能。当我们调试 Bash 脚本时,我们开始的不是bash,而是bash -x

在这种情况下,Bash 子 shell 在调用脚本之前执行了set -x命令。如果您在当前终端中使用set -x,您的命令将开始看起来像这样:

reader@ubuntu:/tmp$ cat p?ng
ping
pong
reader@ubuntu:/tmp$ set -x
reader@ubuntu:/tmp$ cat p?ng
+ cat ping pong
ping
pong
reader@ubuntu:/tmp$ set +x
+ set +x
reader@ubuntu:/tmp$ cat p?ng
ping
pong

请注意,我们现在可以看到 globbing 模式是如何解析的:从cat p?ngcat ping pong。尽量记住这个功能;如果您曾经因为不知道脚本为什么不按照您的意愿执行而抓狂,一个简单的set -x可能会产生很大的不同!如果不行,您总是可以通过set +x恢复正常行为,就像例子中所示的那样。

set有许多有趣的标志,可以让您的生活更轻松。要查看您的 Bash 版本中set的功能概述,请使用help set命令。因为set是一个 shell 内置命令(您可以用type set来验证),所以不幸的是,查找man set的 man 页面是行不通的。

使用 egrep 和 sed 的正则表达式

我们现在已经讨论了正则表达式和 globbing。正如我们所看到的,它们非常相似,但仍然有一些需要注意的区别。在我们的正则表达式示例中,以及一些 globbing 示例中,我们已经看到了grep的用法。

在这部分中,我们将介绍另一个命令,它与正则表达式结合使用时非常方便:sed(不要与set混淆)。我们将从一些用于grep的高级用法开始。

高级 grep

我们已经讨论了一些用于更改grep默认行为的流行选项:--ignore-case-i)、--invert-match-v)和--word-regexp-w)。作为提醒,这是它们的作用:

  • -i允许我们进行不区分大小写的搜索

  • -v只打印匹配的行,而不是匹配的行

  • -w只匹配由空格和/或行锚和/或标点符号包围的完整单词

还有三个其他选项我们想和你分享。第一个新选项,--only-matching-o)只打印匹配的单词。如果你的搜索模式不包含任何正则表达式,这可能是一个相当无聊的选项,就像在这个例子中所看到的:

reader@ubuntu:~/scripts/chapter_10$ grep -o 'cool' grep-file.txt 
cool

它确实如你所期望的那样:它打印了你要找的单词。然而,除非你只是想确认这一点,否则可能并不那么有趣。

现在,如果我们在使用一个更有趣的搜索模式(包含正则表达式)时做同样的事情,这个选项就更有意义了:

reader@ubuntu:~/scripts/chapter_10$ grep -o 'f.r' grep-file.txt 
for
far

在这个(简化的!)例子中,你实际上得到了新的信息:你搜索模式中的任何单词都会被打印出来。虽然对于这样一个短的单词在这样一个小的文件中来说可能并不那么令人印象深刻,但想象一下在一个更大的文件中使用一个更复杂的搜索模式!

这带来了另一个问题:grep非常。由于 Boyer-Moore 算法,grep可以在非常大的文件(100 MB+)中进行非常快速的搜索。

第二个额外选项,--count-c),不返回任何行。但是,它会返回一个数字:搜索模式匹配的行数。一个众所周知的例子是查看包安装的日志文件时:

reader@ubuntu:/var/log$ grep 'status installed' dpkg.log
2018-04-26 19:07:29 status installed base-passwd:amd64 3.5.44
2018-04-26 19:07:29 status installed base-files:amd64 10.1ubuntu2
2018-04-26 19:07:30 status installed dpkg:amd64 1.19.0.5ubuntu2
<SNIPPED>
2018-06-30 17:59:37 status installed linux-headers-4.15.0-23:all 4.15.0-23.25
2018-06-30 17:59:37 status installed iucode-tool:amd64 2.3.1-1
2018-06-30 17:59:37 status installed man-db:amd64 2.8.3-2
<SNIPPED>
2018-07-01 09:31:15 status installed distro-info-data:all 0.37ubuntu0.1
2018-07-01 09:31:17 status installed libcurl3-gnutls:amd64 7.58.0-2ubuntu3.1
2018-07-01 09:31:17 status installed libc-bin:amd64 2.27-3ubuntu1

在这个常规的grep中,我们看到显示了哪个包在哪个日期安装的日志行。但是,如果我们只想知道某个日期安装了多少个包呢?--count来帮忙!

reader@ubuntu:/var/log$ grep 'status installed' dpkg.log | grep '2018-08-26'
2018-08-26 11:16:16 status installed base-files:amd64 10.1ubuntu2.2
2018-08-26 11:16:16 status installed install-info:amd64 6.5.0.dfsg.1-2
2018-08-26 11:16:16 status installed plymouth-theme-ubuntu-text:amd64 0.9.3-1ubuntu7
<SNIPPED>
reader@ubuntu:/var/log$ grep 'status installed' dpkg.log | grep -c '2018-08-26'
40

我们将这个grep操作分为两个阶段。第一个grep 'status installed'过滤掉所有与成功安装相关的行,跳过中间步骤,比如unpackedhalf-configured

我们在管道后面使用grep的替代形式(我们将在第十二章中进一步讨论,在脚本中使用管道和重定向)来匹配另一个搜索模式到已经过滤的数据。第二个grep '2018-08-26'用于按日期过滤。

现在,如果没有-c选项,我们会看到 40 行。如果我们对包感兴趣,这可能是一个不错的选择,但否则,只打印数字比手动计算行数要好。

或者,我们可以将其写成一个单独的 grep 搜索模式,使用正则表达式。自己试一试:grep '2018-08-26 .* status installed' dpkg.log(确保用你运行更新/安装的某一天替换日期)。

最后一个选项非常有趣,特别是对于脚本编写,就是--quiet-q)选项。想象一种情况,你想知道文件中是否存在某个搜索模式。如果找到了搜索模式,就删除文件。如果没有找到搜索模式,就将其添加到文件中。

你知道,你可以使用一个很好的if-then-else结构来完成这个任务。但是,如果你使用普通的grep,当你运行脚本时,你会在终端上看到文本被打印出来。

这并不是一个很大的问题,但是一旦你的脚本变得足够大和复杂,大量的输出到屏幕会使脚本难以使用。为此,我们有--quiet选项。看看这个示例脚本,看看你会如何做到这一点:

reader@ubuntu:~/scripts/chapter_10$ vim grep-then-else.sh 
reader@ubuntu:~/scripts/chapter_10$ cat grep-then-else.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-16
# Description: Use grep exit status to make decisions about file manipulation.
# Usage: ./grep-then-else.sh
#####################################

FILE_NAME=/tmp/grep-then-else.txt

# Touch the file; creates it if it does not exist.
touch ${FILE_NAME}

# Check the file for the keyword.
grep -q 'keyword' ${FILE_NAME}
grep_rc=$?

# If the file contains the keyword, remove the file. Otherwise, write 
# the keyword to the file.
if [[ ${grep_rc} -eq 0 ]]; then
  rm ${FILE_NAME}  
else
  echo 'keyword' >> ${FILE_NAME}
fi

reader@ubuntu:~/scripts/chapter_10$ bash -x grep-then-else.sh 
+ FILE_NAME=/tmp/grep-then-else.txt
+ touch /tmp/grep-then-else.txt
+ grep --quiet keyword /tmp/grep-then-else.txt
+ grep_rc='1'
+ [[ '1' -eq 0 ]]
+ echo keyword
reader@ubuntu:~/scripts/chapter_10$ bash -x grep-then-else.sh 
+ FILE_NAME=/tmp/grep-then-else.txt
+ touch /tmp/grep-then-else.txt
+ grep -q keyword /tmp/grep-then-else.txt
+ grep_rc=0
+ [[ 0 -eq 0 ]]
+ rm /tmp/grep-then-else.txt

正如你所看到的,关键在于退出状态。如果grep找到一个或多个搜索模式的匹配,就会返回退出代码 0。如果grep没有找到任何内容,返回代码将是 1。

你可以在命令行上自己看到这一点:

reader@ubuntu:/var/log$ grep -q 'glgjegeg' dpkg.log
reader@ubuntu:/var/log$ echo $?
1
reader@ubuntu:/var/log$ grep -q 'installed' dpkg.log 
reader@ubuntu:/var/log$ echo $?
0

grep-then-else.sh中,我们抑制了grep的所有输出。但是,我们仍然可以实现我们想要的效果:脚本的每次运行在thenelse条件之间变化,正如我们的bash -x调试输出清楚地显示的那样。

没有--quiet,脚本的非调试输出将如下所示:

reader@ubuntu:/tmp$ bash grep-then-else.sh 
reader@ubuntu:/tmp$ bash grep-then-else.sh 
keyword
reader@ubuntu:/tmp$ bash grep-then-else.sh 
reader@ubuntu:/tmp$ bash grep-then-else.sh 
keyword

它实际上并没有为脚本添加任何东西,是吗?更好的是,很多命令都有--quiet-q或等效选项。

在编写脚本时,始终考虑命令的输出是否相关。如果不相关,并且可以使用退出状态,这几乎总是会使输出体验更清晰。

介绍egrep

到目前为止,我们已经看到grep与各种选项一起使用,这些选项改变了它的行为。有一个最后重要的选项我们想要和你分享:--extended-regexp (-E)。正如man grep页面所述,这意味着将 PATTERN 解释为扩展正则表达式

与 Linux 中找到的默认正则表达式相比,扩展正则表达式具有更接近其他脚本/编程语言中的正则表达式的搜索模式(如果你已经有这方面的经验)。

具体来说,在使用扩展正则表达式而不是默认正则表达式时,以下构造是可用的:

? 匹配前一个字符的重复零次或多次
+ 匹配前一个字符的重复一次或多次
匹配前一个字符的重复恰好 n 次
{n,m} 匹配前一个字符的重复介于 n 和 m 次之间
{,n} 匹配前一个字符的重复n 次或更少次
{n,} 匹配前一个字符的重复n 次或更多次
(xx|yy) 交替字符,允许我们在搜索模式中找到 xx yy(对于具有多个字符的模式非常有用,否则,[xy]表示法就足够了)

正如你可能已经看到的,grep的 man 页面包含了一个关于正则表达式和搜索模式的专门部分,你可能会发现它作为一个快速参考非常方便。

现在,在我们开始使用新的 ERE 搜索模式之前,我们将介绍一个命令:egrep。如果你试图找出它的作用,你可能会从which egrep开始,结果是/bin/egrep。这可能会让你认为它是一个独立的二进制文件,而不是你现在已经使用了很多的grep

然而,最终,egrep只不过是一个小小的包装脚本:

reader@ubuntu:~/scripts/chapter_10$ cat /bin/egrep
#!/bin/sh
exec grep -E "$@"

你可以看到,这只是一个 shell 脚本,但没有通常的.sh扩展名。它使用exec命令来用新的进程映像替换当前进程映像

你可能还记得,通常情况下,命令是在当前环境的一个分支中执行的。在这种情况下,因为我们使用这个脚本来包装(这就是为什么它被称为包装脚本)grep -E作为egrep,所以替换它而不是再次分支是有意义的。

"$@"构造也是新的:它是一个数组(如果你对这个术语不熟悉,可以想象为一个有序列表)的参数。在这种情况下,它基本上将egrep接收到的所有参数传递到grep -E中。

因此,如果完整的命令是egrep -w [[:digit:]] grep-file.txt,它将被包装并最终作为grep -E -w [[:digit:]] grep-file.txt执行。

实际上,使用egrepgrep -E并不重要。我们更喜欢使用egrep,这样我们就可以确定我们正在处理扩展的正则表达式(因为在我们的经验中,扩展功能经常被使用)。但是,对于简单的搜索模式,不需要使用 ERE。

我们建议你找到自己的系统,决定何时使用每个命令。

现在我们来看一些扩展正则表达式搜索模式的例子:

reader@ubuntu:~/scripts/chapter_10$ egrep -w '[[:lower:]]{5}' grep-file.txt 
but in the USA they use color (and realize)!
reader@ubuntu:~/scripts/chapter_10$ egrep -w '[[:lower:]]{7}' grep-file.txt 
We can use this regular file for testing grep.
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!
reader@ubuntu:~/scripts/chapter_10$ egrep -w '[[:alpha:]]{7}' grep-file.txt 
We can use this regular file for testing grep.
Regular expressions are pretty cool
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!
Also, New Zealand is pretty far away.

第一个命令egrep -w [[:lower:]]{5} grep-file.txt,显示了所有恰好五个字符长的单词,使用小写字母。不要忘记这里需要-w选项,因为否则,任何五个字母连续在一起也会匹配,忽略单词边界(在这种情况下,pretty 中的prett也会匹配)。结果只有一个五个字母的单词:color。

接下来,我们对七个字母的单词做同样的操作。我们现在得到了更多的结果。然而,因为我们只使用小写字母,我们错过了两个也是七个字母长的单词:Regular 和 Zealand。我们通过使用[[:alpha:]]而不是[[:lower:]]来修复这个问题。(我们也可以使用-i选项使所有内容不区分大小写—egrep -iw [[:lower:]]{7} grep-file.txt

虽然这在功能上是可以接受的,但请再考虑一下。在这种情况下,你将搜索由七个小写字母组成的不区分大小写单词。这实际上没有任何意义。在这种情况下,我们总是选择逻辑而不是功能,这意味着将[[:lower:]]改为[[:alpha:]],而不是使用-i选项。

所以我们知道了如何搜索特定长度的单词(或行,如果省略了-w选项)。现在我们来搜索比最小长度或最大长度更长或更短的单词。

这里有一个例子:

reader@ubuntu:~/scripts/chapter_10$ egrep -w '[[:lower:]]{5,}' grep-file.txt
We can use this regular file for testing grep.
Regular expressions are pretty cool
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ egrep -w '[[:alpha:]]{,3}' grep-file.txt
We can use this regular file for testing grep.
Regular expressions are pretty cool
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ egrep '.{40,}' grep-file.txt
We can use this regular file for testing grep.
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!

这个例子演示了边界语法。第一个命令,egrep -w '[[:lower:]]{5,}' grep-file.txt,寻找了至少五个字母的小写单词。如果你将这些结果与之前寻找确切五个字母长的单词的例子进行比较,你现在会发现更长的单词也被匹配到了。

接下来,我们反转边界条件:我们只想匹配三个字母或更少的单词。我们看到所有两个和三个字母的单词都被匹配到了(因为我们从[[:lower:]]切换到了[[:alpha:]],UK 和行首大写字母也被匹配到了)。

在最后一个例子中,egrep '.{40,}' grep-file.txt,我们去掉了-w,所以我们匹配整行。我们匹配任何字符(由点表示),并且我们希望一行至少有 40 个字符(由{40,}表示)。在这种情况下,只有五行中的三行被匹配到了(因为其他两行较短)。

引用对于搜索模式非常重要。如果你在模式中不使用引号,特别是在使用{和}等特殊字符时,你将需要用反斜杠对它们进行转义。这可能会导致令人困惑的情况,你会盯着屏幕想知道为什么你的搜索模式不起作用,甚至会报错。只要记住:如果你始终对搜索模式使用单引号,你就会更有可能避免这些令人沮丧的情况。

我们想要展示的扩展正则表达式的最后一个概念是alternation。这使用了管道语法(不要与用于重定向的管道混淆,这将在第十二章中进一步讨论,在脚本中使用管道和重定向)来传达匹配 xxx 或 yyy的含义。

一个例子应该能说明问题:

reader@ubuntu:~/scripts/chapter_10$ egrep 'f(a|o)r' grep-file.txt 
We can use this regular file for testing grep.
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ egrep 'f[ao]r' grep-file.txt
We can use this regular file for testing grep.
Also, New Zealand is pretty far away.
reader@ubuntu:~/scripts/chapter_10$ egrep '(USA|UK)' grep-file.txt 
Did you ever realise that in the UK they say colour,
but in the USA they use color (and realize)!

在只有一个字母差异的情况下,我们可以选择使用扩展的 alternation 语法,或者之前讨论过的括号语法。我们建议使用最简单的语法来实现目标,这种情况下就是括号语法。

然而,一旦我们要寻找超过一个字符差异的模式,使用括号语法就变得非常复杂。在这种情况下,扩展的 alternation 语法是清晰而简洁的,特别是因为|||在大多数脚本/编程逻辑中代表OR构造。对于这个例子,这就像是说:我想要找到包含单词 USA 或单词 UK 的行。

因为这种语法与语义视图相对应得很好,它感觉直观且易懂,这是我们在脚本中应该始终努力的事情!

流编辑器 sed

由于我们现在对正则表达式、搜索模式和(扩展)grep非常熟悉,是时候转向 GNU/Linux 领域中最强大的工具之一了:sed。这个术语是stream editor 的缩写,它确实做到了它所暗示的:编辑流。

在这种情况下,流可以是很多东西,但通常是文本。这个文本可以在文件中找到,但也可以从另一个进程中流式传输,比如cat grep-file.txt | sed ...。在这个例子中,cat命令的输出(等同于grep-file.txt的内容)作为sed命令的输入。

我们将在我们的示例中查看就地文件编辑和流编辑。

流编辑

首先,我们将看一下使用sed进行实际流编辑。流编辑允许我们做一些很酷的事情:例如,我们可以更改文本中的一些单词。我们还可以删除我们不关心的某些行(例如,不包含单词 ERROR 的所有内容)。

我们将从一个简单的例子开始,搜索并替换一行中的一个单词:

reader@ubuntu:~/scripts/chapter_10$ echo "What a wicked sentence"
What a wicked sentence
reader@ubuntu:~/scripts/chapter_10$ echo "What a wicked sentence" | sed 's/wicked/stupid/'
What a stupid sentence

就像这样,sed将我的积极句子转变成了不太积极的东西。sed使用的模式(在sed术语中,这只是称为script)是s/wicked/stupid/s代表搜索替换,script的第一个单词被第二个单词替换。

观察一下对于具有多个匹配项的多行会发生什么:

reader@ubuntu:~/scripts/chapter_10$ vim search.txt
reader@ubuntu:~/scripts/chapter_10$ cat search.txt 
How much wood would a woodchuck chuck
if a woodchuck could chuck wood?
reader@ubuntu:~/scripts/chapter_10$ cat search.txt | sed 's/wood/stone/'
How much stone would a woodchuck chuck
if a stonechuck could chuck wood?

从这个例子中,我们可以学到两件事:

  • 默认情况下,sed只会替换每行中每个单词的第一个实例。

  • sed不仅匹配整个单词,还匹配部分单词。

如果我们想要替换每行中的所有实例怎么办?这称为全局搜索替换,语法只有非常轻微的不同:

reader@ubuntu:~/scripts/chapter_10$ cat search.txt | sed 's/wood/stone/g'
How much stone would a stonechuck chuck
if a stonechuck could chuck stone?

通过在sed script的末尾添加g,我们现在全局替换所有实例,而不仅仅是每行的第一个实例。

另一种可能性是,您可能只想在第一行上进行搜索替换。您可以使用head -1仅选择该行,然后将其发送到sed,但这意味着您需要在后面添加其他行。

我们可以通过在sed脚本前面放置行号来选择要编辑的行,如下所示:

reader@ubuntu:~/scripts/chapter_10$ cat search.txt | sed '1s/wood/stone/'
How much stone would a woodchuck chuck
if a woodchuck could chuck wood?
reader@ubuntu:~/scripts/chapter_10$ cat search.txt | sed '1s/wood/stone/g'
How much stone would a stonechuck chuck
if a woodchuck could chuck wood?
reader@ubuntu:~/scripts/chapter_10$ cat search.txt | sed '1,2s/wood/stone/g'
How much stone would a stonechuck chuck
if a stonechuck could chuck stone?

第一个脚本,'1s/wood/stone/',指示sed将第一行中的第一个wood实例替换为stone。下一个脚本,'1s/wood/stone/g',告诉sedwood的所有实例替换为stone,但只在第一行上。最后一个脚本,'1,2s/wood/stone/g',使sed替换所有行(包括!)中(和包括!)12之间的所有wood实例。

就地编辑

虽然在将文件发送到sed之前cat文件并不是那么大的问题,幸运的是,我们实际上不需要这样做。sed的用法如下:sed [OPTION] {script-only-if-no-other-script} [input-file]。正如您在最后看到的那样,还有一个选项[input-file]

让我们拿之前的一个例子,然后去掉cat

reader@ubuntu:~/scripts/chapter_10$ sed 's/wood/stone/g' search.txt 
How much stone would a stonechuck chuck
if a stonechuck could chuck stone?
reader@ubuntu:~/scripts/chapter_10$ cat search.txt 
How much wood would a woodchuck chuck
if a woodchuck could chuck wood?

如您所见,通过使用可选的[input-file]参数,sed根据脚本处理文件中的所有行。默认情况下,sed会打印它处理的所有内容。在某些情况下,这会导致行被打印两次,即当使用sedprint函数时(我们稍后会看到)。

这个例子展示的另一个非常重要的事情是:这种语法不会编辑原始文件;只有打印到STDOUT的内容会发生变化。有时,您可能希望编辑文件本身——对于这些情况,sed--in-place-i)选项。

确保您理解这会对磁盘上的文件进行不可逆转的更改。而且,就像 Linux 中的大多数事情一样,没有撤销按钮或回收站!

让我们看看如何使用sed -i来持久更改文件(当然,在我们备份之后):

reader@ubuntu:~/scripts/chapter_10$ cat search.txt 
How much wood would a woodchuck chuck
if a woodchuck could chuck wood?
reader@ubuntu:~/scripts/chapter_10$ cp search.txt search.txt.bak
reader@ubuntu:~/scripts/chapter_10$ sed -i 's/wood/stone/g' search.txt
reader@ubuntu:~/scripts/chapter_10$ cat search.txt
How much stone would a stonechuck chuck
if a stonechuck could chuck stone?

这一次,不是将处理后的文本打印到屏幕上,而是sed悄悄地更改了磁盘上的文件。由于这种破坏性的本质,我们事先创建了一个备份。但是,sed--in-place选项也可以提供这种功能,方法是添加文件后缀:

reader@ubuntu:~/scripts/chapter_10$ ls
character-class.txt  error.txt  grep-file.txt  grep-then-else.sh  search.txt  search.txt.bak
reader@ubuntu:~/scripts/chapter_10$ mv search.txt.bak search.txt
reader@ubuntu:~/scripts/chapter_10$ cat search.txt 
How much wood would a woodchuck chuck
if a woodchuck could chuck wood?
reader@ubuntu:~/scripts/chapter_10$ sed -i'.bak' 's/wood/stone/g' search.txt
reader@ubuntu:~/scripts/chapter_10$ cat search.txt
How much stone would a stonechuck chuck
if a stonechuck could chuck stone?
reader@ubuntu:~/scripts/chapter_10$ cat search.txt.bak 
How much wood would a woodchuck chuck
if a woodchuck could chuck wood?

sed的语法有点吝啬。如果在-i'.bak'之间加上一个空格,您将会得到奇怪的错误(这通常对于选项带有参数的命令来说是正常的)。在这种情况下,因为脚本定义紧随其后,sed很难区分文件后缀和脚本字符串。

只要记住,如果您想使用这个,您需要小心这个语法!

行操作

虽然sed的单词操作功能很棒,但它也允许我们操作整行。例如,我们可以按行号删除某些行:

reader@ubuntu:~/scripts/chapter_10$ echo -e "Hi,\nthis is \nPatrick"
Hi,
this is 
Patrick
reader@ubuntu:~/scripts/chapter_10$ echo -e "Hi,\nthis is \nPatrick" | sed 'd'
reader@ubuntu:~/scripts/chapter_10$ echo -e "Hi,\nthis is \nPatrick" | sed '1d'
this is 
Patrick

通过使用echo -e结合换行符(\n),我们可以创建多行语句。-eman echo页面上解释为启用反斜杠转义的解释。通过将这个多行输出传递给sed,我们可以使用删除功能,这是一个简单地使用字符d的脚本。

如果我们在行号前加上一个前缀,例如1d,则删除第一行。如果不这样做,所有行都将被删除,这对我们来说没有输出。

另一个,通常更有趣的可能性是删除包含某个单词的行:

reader@ubuntu:~/scripts/chapter_10$ echo -e "Hi,\nthis is \nPatrick" | sed '/Patrick/d'
Hi,
this is 
reader@ubuntu:~/scripts/chapter_10$ echo -e "Hi,\nthis is \nPatrick" | sed '/patrick/d'
Hi,
this is 
Patrick

与我们使用脚本进行单词匹配的sed搜索替换功能一样,如果存在某个单词,我们也可以删除整行。从前面的例子中可以看到,这是区分大小写的。幸运的是,如果我们想以不区分大小写的方式进行操作,总是有解决办法。在grep中,这将是-i标志,但对于sed-i已经保留给了--in-place功能。

那我们该怎么做呢?当然是使用我们的老朋友正则表达式!请参阅以下示例:

reader@ubuntu:~/scripts/chapter_10$ echo -e "Hi,\nthis is \nPatrick" | sed '/[Pp]atrick/d'
Hi,
this is
reader@ubuntu:~/scripts/chapter_10$ echo -e "Hi,\nthis is \nPatrick" | sed '/.atrick/d'
Hi,
this is

虽然它不像grep提供的功能那样优雅,但在大多数情况下它确实完成了工作。它至少应该让您意识到,使用正则表达式与sed使整个过程更加灵活和更加强大。

与大多数事物一样,增加了灵活性和功能,也增加了复杂性。但是,我们希望通过这对正则表达式和sed的简要介绍,两者的组合不会感到难以管理的复杂。

与从文件或流中删除行不同,您可能更适合只显示一些文件。但是,这里有一个小问题:默认情况下,sed会打印它处理的所有行。如果您给sed指令打印一行(使用p脚本*),它将打印该行两次——一次是匹配脚本,另一次是默认打印。

这看起来有点像这样:

reader@ubuntu:~/scripts/chapter_10$ cat error.txt 
Process started.
Running normally.
ERROR: TCP socket broken.
ERROR: Cannot connect to database.
Exiting process.
reader@ubuntu:~/scripts/chapter_10$ sed '/ERROR/p' error.txt 
Process started.
Running normally.
ERROR: TCP socket broken.
ERROR: TCP socket broken.
ERROR: Cannot connect to database.
ERROR: Cannot connect to database.
Exiting process.

打印和删除脚本的语法类似:'/word/d''/word/p'。要抑制sed的默认行为,即打印所有行,添加-n(也称为--quiet--silent):

reader@ubuntu:~/scripts/chapter_10$ sed -n '/ERROR/p' error.txt 
ERROR: TCP socket broken.
ERROR: Cannot connect to database.

您可能已经发现,使用sed脚本打印和删除行与grepgrep -v具有相同的功能。在大多数情况下,您可以选择使用哪种。但是,一些高级功能,例如删除匹配的行,但仅从文件的前 10 行中删除,只能使用sed完成。作为一个经验法则,任何可以使用单个语句使用grep实现的功能都应该使用grep来处理;否则,转而使用sed

有一个sed的最后一个用例我们想要强调:您有一个文件或流,您需要删除的不是整行,而只是这些行中的一些单词。使用grep,这是(很容易地)无法实现的。然而,sed有一种非常简单的方法来做到这一点。

搜索和替换与仅仅删除一个单词有什么不同?只是替换模式!

请参阅以下示例:

reader@ubuntu:~/scripts/chapter_10$ cat search.txt
How much stone would a stonechuck chuck
if a stonechuck could chuck stone?
reader@ubuntu:~/scripts/chapter_10$ sed 's/stone//g' search.txt
How much  would a chuck chuck
if a chuck could chuck ?

通过将单词 stone 替换为nothing(因为这正是在sed脚本中第二个和第三个反斜杠之间存在的内容),我们完全删除了单词 stone。然而,在这个例子中,你可以看到一个常见的问题,你肯定会遇到:删除单词后会有额外的空格。

这带我们来到了sed的另一个技巧,可以帮助你解决这个问题:

reader@ubuntu:~/scripts/chapter_10$ sed -e 's/stone //g' -e 's/stone//g' search.txt
How much would a chuck chuck
if a chuck could chuck ?

通过提供-e,后跟一个sed脚本,你可以让sed在你的流上运行多个脚本(按顺序!)。默认情况下,sed期望至少有一个脚本,这就是为什么如果你只处理一个脚本,你不需要提供-e。对于比这更多的脚本,你需要在每个脚本之前添加一个-e

最后的话

正则表达式很。在 Linux 上更难的是,正则表达式已经由不同的程序(具有不同的维护者和不同的观点)略有不同地实现。

更糟糕的是,一些正则表达式的特性被一些程序隐藏为扩展的正则表达式,而在其他程序中被认为是默认的。在过去的几年里,这些程序的维护者似乎已经朝着更全局的 POSIX 标准迈进,用于正则正则表达式和扩展正则表达式,但直到今天,仍然存在一些差异。

我们对处理这个问题有一些建议:试一试。也许你不记得星号在 globbing 中代表什么,与正则表达式不同,或者问号为什么会有不同的作用。也许你会忘记用-E来“激活”扩展语法,你的扩展搜索模式会返回奇怪的错误。

你肯定会忘记引用搜索模式一次,如果它包含像点或$这样的字符(由 Bash 解释),你的命令会崩溃,通常会有一个不太清晰的错误消息。

只要知道我们都犯过这些错误,只有经验才能让这变得更容易。事实上,在写这一章时,几乎没有一个命令像我们在脑海中想象的那样立即起作用!你并不孤单,你不应该因此感到难过。继续努力,直到成功,并且直到你明白为什么第一次没有成功。

总结

本章解释了正则表达式,以及在 Linux 下使用它们的两个常见工具:grepsed

我们首先解释了正则表达式是与文本结合使用的搜索模式,用于查找匹配项。这些搜索模式允许我们在文本中进行非常灵活的搜索,其中文本的内容在运行时不一定已知。

搜索模式允许我们,例如,仅查找单词而不是数字,查找行首或行尾的单词,或查找空行。搜索模式包括通配符,可以表示某个字符或字符类的一个或多个。

我们介绍了grep命令,以展示我们如何在 Bash 中使用正则表达式的基本功能。

本章的第二部分涉及 globbing。Globbing 用作文件名和路径的通配符机制。它与正则表达式有相似之处,但也有一些关键的区别。Globbing 可以与大多数处理文件的命令一起使用(而且,由于 Linux 下的大多数东西都可以被视为文件,这意味着几乎所有命令都支持某种形式的 globbing)。

本章的后半部分描述了如何使用egrepsed的正则表达式。egrepgrep -E的简单包装器,允许我们使用扩展语法进行正则表达式,我们讨论了一些常用的高级grep功能。

与默认的正则表达式相比,扩展的正则表达式允许我们指定某些模式的长度以及它们重复的次数,同时还允许我们使用交替。

本章的最后部分描述了sed,流编辑器。sed是一个复杂但非常强大的命令,可以让我们做比grep更令人兴奋的事情。

本章介绍了以下命令:grepsetegrepsed

问题

  1. 什么是搜索模式?

  2. 为什么正则表达式被认为是贪婪的?

  3. 在搜索模式中,哪个字符被认为是除换行符外的任意一个字符的通配符?

  4. 在 Linux 正则表达式搜索模式中,星号如何使用?

  5. 什么是行锚点?

  6. 列举三种字符类型。

  7. 什么是 globbing?

  8. 在 Bash 下,扩展正则表达式语法可以实现哪些普通正则表达式无法实现的功能?

  9. 在决定使用grep还是sed时,有什么好的经验法则?

  10. 为什么 Linux/Bash 上的正则表达式如此困难?

进一步阅读

如果您想更深入地了解本章主题,以下资源可能会很有趣:

第十一章:条件测试和脚本循环

本章将以对if-then-else的小结开始,然后介绍if-then-else条件的高级用法。我们将介绍whilefor的脚本循环,并展示如何使用exitbreakcontinue来控制这些循环。

本章将介绍以下命令:elifhelpwhilesleepforbasenamebreakcontinue

本章将涵盖以下主题:

  • 高级if-then-else

  • while 循环

  • for 循环

  • loop 控制

技术要求

本章的所有脚本都可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Linux-Shell-Scripting-Fundamentals-of-Bash-4.4/tree/master/Chapter11。所有其他工具仍然有效,无论是在您的主机上还是在您的 Ubuntu 虚拟机上。对于 break-x.sh,for-globbing.sh,square-number.sh,while-interactive.sh 脚本,只能在网上找到最终版本。在执行脚本之前,请务必验证头部中的脚本版本。

高级 if-then-else

本章致力于条件测试和脚本循环的所有内容,这两个概念经常交织在一起。我们已经在第九章中看到了if-then-else循环,错误检查和处理,它侧重于错误检查和处理。在继续介绍高级概念之前,我们将对我们描述的关于if-then-else的事情进行小结。

对 if-then-else 的小结

If-then-else 逻辑几乎完全符合其名称的含义:如果 某事是这样的那么 做某事否则 做其他事情。在实践中,这可能是如果 磁盘已满那么 删除一些文件否则 报告磁盘空间看起来很好。在脚本中,这可能看起来像这样:

reader@ubuntu:~/scripts/chapter_09$ cat if-then-else-proper.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-09-30
# Description: Use the if-then-else construct, now properly.
# Usage: ./if-then-else-proper.sh file-name
#####################################

file_name=$1

# Check if the file exists.
if [[ -f ${file_name} ]]; then 
  cat ${file_name} # Print the file content.
else
  echo "File does not exist, stopping the script!"
  exit 1
fi

如果文件存在,我们打印内容。否则(也就是说,如果文件不存在),我们以错误消息的形式给用户反馈,然后以1的退出状态退出脚本。请记住,任何不为 0 的退出代码都表示脚本失败

在测试中使用正则表达式

在介绍了if-then-else之后的一章中,我们学到了关于正则表达式的一切。然而,那一章大部分是理论性的,只包含了一个脚本!现在,正如你可能意识到的那样,正则表达式主要是支持构造,应该与其他脚本工具一起使用。在我们描述的测试情况下,我们可以在[[...]]块中同时使用 globbing 和正则表达式!让我们更深入地看一下这一点,如下所示:

reader@ubuntu:~/scripts/chapter_11$ vim square-number.sh 
reader@ubuntu:~/scripts/chapter_11$ cat square-number.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-26
# Description: Return the square of the input number.
# Usage: ./square-number.sh <number>
#####################################

INPUT_NUMBER=$1

# Check the number of arguments received.
if [[ $# -ne 1 ]]; then
 echo "Incorrect usage, wrong number of arguments."
 echo "Usage: $0 <number>"
 exit 1
fi

# Check to see if the input is a number.
if [[ ! ${INPUT_NUMBER} =~ [[:digit:]] ]]; then 
 echo "Incorrect usage, wrong type of argument."
 echo "Usage: $0 <number>"
 exit 1
fi

# Multiple the input number with itself and return this to the user.
echo $((${INPUT_NUMBER} * ${INPUT_NUMBER}))

我们首先检查用户是否提供了正确数量的参数(这是我们应该始终做的)。接下来,我们在测试[[..]]块中使用=~运算符。这允许我们使用正则表达式进行评估。在这种情况下,它简单地允许我们验证用户输入是否为数字,而不是其他任何东西。

现在,如果我们调用这个脚本,我们会看到以下内容:

reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh
Incorrect usage, wrong number of arguments.
Usage: square-number.sh <number>
reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh 3 2
Incorrect usage, wrong number of arguments.
Usage: square-number.sh <number>
reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh a
Incorrect usage, wrong type of argument.
Usage: square-number.sh <number>
reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh 3
9
reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh 11
121

我们可以看到我们的两个输入检查都有效。如果我们调用这个脚本而不是只有一个参数($# -ne 1),它会失败。这对于02个参数都是正确的。接下来,如果我们用一个字母而不是一个数字来调用脚本,我们会到达第二个检查和随之而来的错误消息:错误的参数类型。最后,为了证明脚本确实做到了我们想要的,我们将尝试使用单个数字:3119121的返回值是这些数字的平方,所以看起来我们实现了我们的目标!

然而,并不是一切都如表面所示。这是使用正则表达式时的一个常见陷阱,如下面的代码所示:

reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh a3
0
reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh 3a
square-number.sh: line 28: 3a: value too great for base (error token is "3a")

这是怎么发生的?我们检查了用户输入是否是一个数字,不是吗?实际上,与你可能认为的相反,我们实际上检查了用户输入是否“与数字匹配”。简单来说,如果输入包含一个数字,检查就会成功。我们真正想要检查的是输入是否是一个数字“从头到尾”。也许这听起来很熟悉,但它绝对有锚定行的味道!以下代码应用了这一点:

reader@ubuntu:~/scripts/chapter_11$ vim square-number.sh
reader@ubuntu:~/scripts/chapter_11$ head -5 square-number.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.1.0
reader@ubuntu:~/scripts/chapter_11$ grep 'digit' square-number.sh 
if [[ ! ${INPUT_NUMBER} =~ ^[[:digit:]]$ ]]; then

我们做了两个改变:我们匹配的搜索模式不再只是[[:digit:]],而是^[[:digit:]]$,并且我们更新了版本号(直到现在我们还没有做太多)。因为我们现在将数字锚定到行的开头和结尾,我们不能再在随机位置插入字母。用错误的输入运行脚本来验证这一点:

reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh a3
Incorrect usage, wrong type of argument.
Usage: square-number-improved.sh <number>
reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh 3a
Incorrect usage, wrong type of argument.
Usage: square-number-improved.sh <number>
reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh 3a3
Incorrect usage, wrong type of argument.
Usage: square-number-improved.sh <number>
reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh 9
81

我很想告诉你,我们现在完全安全了。但是,不幸的是,就像正则表达式经常出现的那样,事情并不那么简单。脚本现在对单个数字(0-9)运行得很好,但是如果你尝试使用双位数,它会出现“错误的参数类型”(试一下!)。我们需要做最后的调整来确保它完全符合我们的要求:我们需要确保数字也接受多个连续的数字。正则表达式中的“一个或多个”构造是+号,我们可以将其附加到[[:digit:]]上:

reader@ubuntu:~/scripts/chapter_11$ vim square-number.sh 
reader@ubuntu:~/scripts/chapter_11$ head -5 square-number.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.2.0
reader@ubuntu:~/scripts/chapter_11$ grep 'digit' square-number.sh 
if [[ ! ${INPUT_NUMBER} =~ ^[[:digit:]]+$ ]]; then 
reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh 15
225
reader@ubuntu:~/scripts/chapter_11$ bash square-number.sh 1x5
Incorrect usage, wrong type of argument.
Usage: square-number-improved.sh <number>

我们改变了模式,提高了版本号,并用不同的输入运行了脚本。最终的模式^[[:digit:]]+$可以解读为“从行首到行尾的一个或多个数字”,在这种情况下意味着“一个数字,没有其他东西”!

这里的教训是你确实需要彻底测试你的正则表达式。正如你现在所知道的,搜索模式是贪婪的,一旦有一点匹配,它就认为结果是成功的。就像前面的例子中所看到的那样,这并不够具体。实现(和学习!)的唯一方法是尝试破坏你自己的脚本。尝试错误的输入,奇怪的输入,非常具体的输入等等。除非你尝试很多次,否则你不能确定它会可能工作。

你可以在测试语法中使用所有正则表达式搜索模式。我们不会详细介绍其他例子,但应该考虑的有:

  • 变量应该以/开头(用于完全限定的路径)

  • 变量不能包含空格(使用[[:blank:]]搜索模式)

  • 变量应该只包含小写字母(可以通过^[[:lower:]]+$模式实现)

  • 变量应该包含一个带有扩展名的文件名(可以匹配[[:alnum:]]\.[[:alpha:]]

elif 条件

在我们到目前为止看到的情况中,只需要检查一个if 条件。但是正如你所期望的那样,有时候有多个你想要检查的事情,每个事情都有自己的后续动作(then block)。你可以通过使用两个完整的if-then-else语句来解决这个问题,但至少你会有一个重复的else block。更糟糕的是,如果你有三个或更多的条件要检查,你将会有越来越多的重复代码!幸运的是,我们可以通过使用elif命令来解决这个问题,它是if-then-else逻辑的一部分。你可能已经猜到,elifelse-if的缩写。它允许我们做如下的事情:

如果条件 1,那么执行事情 1,否则如果条件 2,那么执行事情 2,否则执行最终的事情

你可以在初始的if命令之后链接尽可能多的elif命令,但有一件重要的事情需要考虑:一旦任何条件为真,只有该then语句会被执行;其他所有语句都会被跳过。

如果你在考虑多个条件可以为真,并且它们的then语句应该被执行,你需要使用多个if-then-else块。让我们看一个简单的例子,首先检查用户给出的参数是否是一个文件。如果是,我们使用cat打印文件。如果不是这种情况,我们检查它是否是一个目录。如果是这种情况,我们使用ls列出目录。如果也不是这种情况,我们将打印一个错误消息并以非零退出状态退出。看看以下命令:

reader@ubuntu:~/scripts/chapter_11$ vim print-or-list.sh 
reader@ubuntu:~/scripts/chapter_11$ cat print-or-list.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-26
# Description: Prints or lists the given path, depending on type.
# Usage: ./print-or-list.sh <file or directory path>
#####################################

# Since we're dealing with paths, set current working directory.
cd $(dirname $0)

# Input validation.
if [[ $# -ne 1 ]]; then
  echo "Incorrect usage!"
  echo "Usage: $0 <file or directory path>"
  exit 1
fi

input_path=$1

if [[ -f ${input_path} ]]; then
  echo "File found, showing content:"
  cat ${input_path} || { echo "Cannot print file, exiting script!"; exit 1; }
elif [[ -d ${input_path} ]]; then
  echo "Directory found, listing:"
  ls -l ${input_path} || { echo "Cannot list directory, exiting script!"; exit 1; }
else
  echo "Path is neither a file nor a directory, exiting script."
  exit 1
fi

如你所见,当我们处理用户输入的文件时,我们需要额外的净化。我们确保在脚本中设置当前工作目录为cd $(dirname $0),并且我们假设每个命令都可能失败,因此我们使用||构造来处理这些失败,就像第九章中所解释的那样,错误检查和处理。让我们尝试看看我们是否可以找到这个逻辑可能走的大部分路径:

reader@ubuntu:~/scripts/chapter_11$ bash print-or-list.sh 
Incorrect usage!
Usage: print-or-list.sh <file or directory path>
reader@ubuntu:~/scripts/chapter_11$ bash print-or-list.sh /etc/passwd
File found, showing content:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
<SNIPPED>
reader@ubuntu:~/scripts/chapter_11$ bash print-or-list.sh /etc/shadow
File found, showing content:
cat: /etc/shadow: Permission denied
Cannot print file, exiting script!
reader@ubuntu:~/scripts/chapter_11$ bash print-or-list.sh /tmp/
Directory found, listing:
total 8
drwx------ 3 root root 4096 Oct 26 08:26 systemd-private-4f8c34d02849461cb20d3bfdaa984c85...
drwx------ 3 root root 4096 Oct 26 08:26 systemd-private-4f8c34d02849461cb20d3bfdaa984c85...
reader@ubuntu:~/scripts/chapter_11$ bash print-or-list.sh /root/
Directory found, listing:
ls: cannot open directory '/root/': Permission denied
Cannot list directory, exiting script!
reader@ubuntu:~/scripts/chapter_11$ bash print-or-list.sh /dev/zero
Path is neither a file nor a directory, exiting script.

按顺序,我们已经看到了我们脚本的以下场景:

  1. 无参数使用不正确错误

  2. /etc/passwd 文件参数:文件内容已打印

  3. 非可读文件/etc/shadow 上的文件参数:无法打印文件错误

  4. /tmp/上的目录参数:目录列表已打印

  5. 非可列出目录/root/上的目录参数:无法列出目录错误

  6. 特殊文件(块设备)参数/dev/zero:路径既不是文件也不是目录错误

这六种输入场景代表了我们的脚本可能采取的所有可能路径。虽然你可能认为对于(看似简单的)脚本的所有错误处理有点过分,但这些参数应该验证了为什么我们实际上需要所有这些错误处理。

虽然elif极大地增强了if-then-else语句的可能性,但太多的if-elif-elif-elif-.......-then-else将使你的脚本变得非常难以阅读。还有另一种构造(超出了本书的范围),叫做case。这处理许多不同的、独特的条件。在本章末尾的进一步阅读部分查看关于case的良好资源!

嵌套

另一个非常有趣的概念是嵌套。实质上,嵌套非常简单:就是在外部if-then-elsethenelse中放置另一个if-then-else语句。这使我们能够首先确定文件是否可读,然后确定文件的类型。通过使用嵌套的if-then-else语句,我们可以以不再需要||构造的方式重写先前的代码:

reader@ubuntu:~/scripts/chapter_11$ vim nested-print-or-list.sh 
reader@ubuntu:~/scripts/chapter_11$ cat nested-print-or-list.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-26
# Description: Prints or lists the given path, depending on type.
# Usage: ./nested-print-or-list.sh <file or directory path>
#####################################

# Since we're dealing with paths, set current working directory.
cd $(dirname $0)

# Input validation.
if [[ $# -ne 1 ]]; then
  echo "Incorrect usage!"
  echo "Usage: $0 <file or directory path>"
  exit 1
fi

input_path=$1

# First, check if we can read the file.
if [[ -r ${input_path} ]]; then
  # We can read the file, now we determine what type it is.
  if [[ -f ${input_path} ]]; then
    echo "File found, showing content:"
    cat ${input_path} 
  elif [[ -d ${input_path} ]]; then
    echo "Directory found, listing:"
    ls -l ${input_path} 
  else
    echo "Path is neither a file nor a directory, exiting script."
    exit 1
  fi
else
  # We cannot read the file, print an error.
  echo "Cannot read the file/directory, exiting script."
  exit 1
fi

尝试使用与前一个示例相同的输入运行上述脚本。在这种情况下,错误场景中的输出会更加友好,因为现在我们控制了这些(而不是默认输出cat: /etc/shadow: Permission denied,例如)。但从功能上讲,什么也没有改变!我们认为,这个使用嵌套的脚本比之前的例子更可读,因为我们现在自己处理错误场景,而不是依赖系统命令来为我们处理。

我们之前讨论过缩进,但在我们看来,像这样的脚本才是它真正发挥作用的地方。通过缩进内部的if-then-else语句,更清楚地表明第二个else属于外部的if-then-else语句。如果你使用多层缩进(因为理论上你可以嵌套多次),这确实有助于所有参与脚本编写的人遵循这个逻辑。

嵌套不仅仅适用于if-then-else。我们将在本章后面介绍的两个循环forwhile也可以嵌套。而且,更实用的是,你可以将它们嵌套在其他所有循环中(从技术角度来看;当然,从逻辑角度来看也应该是有意义的!)。当我们解释whilefor时,你会看到这样的例子。

获取帮助

到现在为止,你可能害怕自己永远记不住所有这些。虽然我们确信随着时间的推移,通过足够的练习,你肯定会记住,但我们理解当你经验不足时,这是很多东西要消化的。为了让这更容易些,除了man页面之外还有另一个有用的命令。你可能已经发现(并且在尝试时失败了),man ifman [[都不起作用。如果你用type iftype [[检查这些命令,你会发现它们实际上不是命令而是shell 关键字。对于大多数 shell 内置和 shell 关键字,你可以使用help命令打印一些关于它们的信息以及如何使用它们!使用help就像help ifhelp [[help while等一样简单。对于if-then-else语句,只有help if有效:

reader@ubuntu:~/scripts/chapter_11$ help if
if: if COMMANDS; then COMMANDS; [ elif COMMANDS; then COMMANDS; ]... [ else COMMANDS; ] fi
    Execute commands based on conditional.

    The 'if COMMANDS' list is executed. If its exit status is zero,
     then the 'then COMMANDS' list is executed.  Otherwise, each 
     'elif COMMANDS' list is executed in turn, and if its 
     exit status is zero, the corresponding
    'then COMMANDS' list is executed and the if command completes.  Otherwise,
    the 'else COMMANDS' list is executed, if present. 
    The exit status of the entire construct is the 
     exit status of the last command executed, or zero
    if no condition tested true.

    Exit Status:
    Returns the status of the last command executed.

因此,总的来说,有三种方法可以让 Linux 为你打印一些有用的信息:

  • 使用man命令的 man 页面

  • 使用help命令获取帮助信息

  • 命令本地帮助打印(通常作为flag -h--help-help

根据命令的类型(二进制命令或 shell 内置/关键字),你将使用manhelp--help标志。记住,通过检查你正在处理的命令的类型(这样你就可以更加有根据地猜测首先尝试哪种帮助方法),使用type -a <command>

while循环

现在我们已经搞定了if-then-else的复习和高级用法,是时候讨论第一个脚本循环了:while。看一下下面的定义,在if-then-else之后应该看起来很熟悉:

当条件为真时执行的事情

ifwhile之间最大的区别是,while会执行动作多次,只要指定的条件仍然为真。因为通常不需要无休止地循环,动作将定期改变与条件相关的某些东西。这基本上意味着do中的动作最终会导致while条件变为 false 而不是 true。让我们看一个简单的例子:

reader@ubuntu:~/scripts/chapter_11$ vim while-simple.sh 
reader@ubuntu:~/scripts/chapter_11$ cat while-simple.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-27
# Description: Example of a while loop.
# Usage: ./while-simple.sh 
#####################################

# Infinite while loop.
while true; do
  echo "Hello!"
  sleep 1 # Wait for 1 second.
done

这个例子是while的最基本形式:一个无休止的循环(因为条件只是true),它打印一条消息,然后休眠一秒。这个新命令sleep经常在循环(whilefor)中使用,等待指定的时间。在这种情况下,我们运行sleep 1,它在返回循环顶部并再次打印Hello!之前等待一秒。一定要尝试一下,并注意它永远不会停止(Ctrl + C会杀死进程,因为它是交互式的)。

现在我们将创建一个在特定时间结束的脚本。为此,我们将在while循环之外定义一个变量,我们将使用它作为计数器。这个计数器将在每次while循环运行时递增,直到达到条件中定义的阈值。看一下:

reader@ubuntu:~/scripts/chapter_11$ vim while-counter.sh 
reader@ubuntu:~/scripts/chapter_11$ cat while-counter.sh
cat while-counter.sh
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-27
# Description: Example of a while loop with a counter.
# Usage: ./while-counter.sh 
#####################################

# Define the counter outside of the loop so we don't reset it for 
# every run in the loop.
counter=0

# This loop runs 10 times.
while [[ ${counter} -lt 10 ]]; do
  counter=$((counter+1)) # Increment the counter by 1.
  echo "Hello! This is loop number ${counter}."
  sleep 1 
done

# After the while-loop finishes, print a goodbye message.
echo "All done, thanks for tuning in!"

由于我们添加了注释,这个脚本应该是不言自明的。counter被添加到while循环之外,否则每次循环运行都会以counter=0开始,这会重置进度。只要计数器小于 10,我们就会继续运行循环。经过 10 次运行后,情况就不再是这样了,而是继续执行脚本中的下一条指令,即打印再见消息。继续运行这个脚本。编辑 sleep 后面的数字(提示:它也接受小于一秒的值),或者完全删除 sleep。

until 循环

while有一个孪生兄弟:untiluntil循环与while做的事情完全相同,唯一的区别是:只有在条件为false时循环才会运行。一旦条件变为true,循环就不再运行。我们将对上一个脚本进行一些小修改,看看until是如何工作的:

reader@ubuntu:~/scripts/chapter_11$ cp while-counter.sh until-counter.sh
reader@ubuntu:~/scripts/chapter_11$ vim until-counter.sh 
reader@ubuntu:~/scripts/chapter_11$ cat until-counter.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-27
# Description: Example of an until loop with a counter.
# Usage: ./until-counter.sh 
#####################################

# Define the counter outside of the loop so we don't reset it for 
# every run in the loop.
counter=0

# This loop runs 10 times.
until [[ ${counter} -gt 9 ]]; do
  counter=$((counter+1)) # Increment the counter by 1.
  echo "Hello! This is loop number ${counter}."
  sleep 1
done

# After the while-loop finishes, print a goodbye message.
echo "All done, thanks for tuning in!"

如你所见,对这个脚本的更改非常小(但重要,尽管如此)。我们用until替换了while,用-gt替换了-lt,用9替换了10。现在,它读作当计数器大于 9 时运行循环,而不是只要计数器小于 10 时运行循环。因为我们使用了小于和大于,我们必须改变数字,否则我们将会遇到著名的off-by-one错误(在这种情况下,这意味着我们将循环 11 次,如果我们没有将10改为9;试试看!)。

实际上,whileuntil循环是完全相同的。你会经常使用while循环而不是until循环:因为你可以简单地否定条件,while循环总是有效的。然而,有时,until循环可能更合理。无论如何,使用最容易理解的那个!如果有疑问,只使用while几乎永远不会错,只要你得到了正确的条件。

创建一个交互式 while 循环

实际上,你不会经常使用while循环。在大多数情况下,for循环更好(正如我们将在本章后面看到的)。然而,有一种情况while循环非常适用:处理用户输入。如果你使用while true结构,并在其中嵌套 if-then-else 块,你可以不断地向用户询问输入,直到得到你要找的答案。下面的例子是一个简单的谜语,应该能澄清问题:

reader@ubuntu:~/scripts/chapter_11$ vim while-interactive.sh 
reader@ubuntu:~/scripts/chapter_11$ cat while-interactive.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-27
# Description: A simple riddle in a while loop.
# Usage: ./while-interactive.sh
#####################################

# Infinite loop, only exits on correct answer.
while true; do
  read -p "I have keys but no locks. I have a space but no room. You can enter, but can’t go outside. What am I? " answer
  if [[ ${answer} =~ [Kk]eyboard ]]; then # Use regular expression so 'a keyboard' or 'Keyboard' is also a valid answer.
    echo "Correct, congratulations!"
    exit 0 # Exit the script.
  else
    # Print an error message and go back into the loop.
    echo "Incorrect, please try again."
  fi
done

reader@ubuntu:~/scripts/chapter_11$ bash while-interactive.sh 
I have keys but no locks. I have a space but no room. You can enter, but can’t go outside. What am I? mouse
Incorrect, please try again.
I have keys but no locks. I have a space but no room. You can enter, but can’t go outside. What am I? screen
Incorrect, please try again.
I have keys but no locks. I have a space but no room. You can enter, but can’t go outside. What am I? keyboard
Correct, congratulations!
reader@ubuntu:~/scripts/chapter_11$

在这个脚本中,我们使用read -p来询问用户一个问题,并将回答存储在answer变量中。然后我们使用嵌套的 if-then-else 块来检查用户是否给出了正确的答案。我们使用一个简单的正则表达式 if 条件,${answer} =~ [Kk]eyboard,这给用户在大写字母和也许单词a前面有一点灵活性。对于每个不正确的答案,else语句打印一个错误,循环重新开始read -p。如果答案是正确的,then块被执行,以exit 0表示脚本的结束。只要没有给出正确的答案,循环将永远继续。

你可能会看到这个脚本有一个问题。如果我们想在while循环之后做任何事情,我们需要在不退出脚本的情况下中断它。我们将看到如何使用——等待它——break关键字来实现这一点!但首先,我们将看看for循环。

for 循环

for循环可以被认为是 Bash 脚本中更强大的循环。在实践中,forwhile是可以互换的,但for有更好的简写语法。这意味着在for中编写循环通常需要比等效的while循环少得多的代码。

for循环有两种不同的语法:C 风格的语法和regular Bash 语法。我们首先看一下 Bash 语法:

FOR value IN list-of-values DO thing-with-value DONE

for循环允许我们迭代一个事物列表。每次循环将使用列表中的不同项目,按顺序。这个非常简单的例子应该说明这种行为:

reader@ubuntu:~/scripts/chapter_11$ vim for-simple.sh
reader@ubuntu:~/scripts/chapter_11$ cat for-simple.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-27
# Description: Simple for syntax.
# Usage: ./for-simple.sh
#####################################

# Create a 'list'.
words="house dog telephone dog"

# Iterate over the list and process the values.
for word in ${words}; do
  echo "The word is: ${word}"
done

reader@ubuntu:~/scripts/chapter_11$ bash for-simple.sh 
The word is: house
The word is: dog
The word is: telephone
The word is: dog

如你所见,for接受一个列表(在这种情况下,是由空格分隔的字符串),对于它找到的每个值,它执行echo操作。我们添加了一些额外的文本,这样你就可以看到它实际上进入循环四次,而不仅仅是打印带有额外换行符的列表。这里要注意的主要事情是,在 echo 中我们使用${word}变量,我们将其定义为for定义中的第二个单词。这意味着对于for循环的每次运行,${word}变量的值是不同的(这非常符合使用变量的意图,具有variable内容!)。你可以给它取任何名字,但我们更喜欢给出语义逻辑的名称;因为我们称列表为words,列表中的一个项目将是一个word

如果你想用while做同样的事情,事情会变得更加复杂。通过使用计数器和cut这样的命令(它允许你剪切字符串的不同部分),这是完全可能的,但由于for循环以这种简单的方式完成,为什么要麻烦呢?

我们可以使用的第二种与 for 一起使用的语法对于那些有其他脚本编程语言经验的人来说更加熟悉。这种 C 风格的语法使用一个计数器,直到某个点递增,与我们在看while时看到的示例类似。其语法如下:

FOR ((counter=0; counter<=10; counter++)); DO something DONE

看起来很相似对吧?看看这个示例脚本:

reader@ubuntu:~/scripts/chapter_11$ vim for-counter.sh 
reader@ubuntu:~/scripts/chapter_11$ cat for-counter.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-27
# Description: Example of a for loop in C-style syntax.
# Usage: ./for-counter.sh 
#####################################

# This loop runs 10 times.
for ((counter=1; counter<=10; counter++)); do
  echo "Hello! This is loop number ${counter}."
  sleep 1
done

# After the for-loop finishes, print a goodbye message.
echo "All done, thanks for tuning in!"

reader@ubuntu:~/scripts/chapter_11$ bash for-counter.sh 
Hello! This is loop number 1.
Hello! This is loop number 2.
Hello! This is loop number 3.
Hello! This is loop number 4.
Hello! This is loop number 5.
Hello! This is loop number 6.
Hello! This is loop number 7.
Hello! This is loop number 8.
Hello! This is loop number 9.
Hello! This is loop number 10.
All done, thanks for tuning in!

由于 off-by-one 错误的性质,我们必须使用稍微不同的数字。由于计数器在循环结束时递增,我们需要从 1 开始而不是从 0 开始(或者我们可以在 while 循环中做同样的事情)。在 C 风格的语法中,**<=**表示小于或等于,++表示递增 1。因此,我们有一个计数器,从 1 开始,一直持续到达 10,并且每次循环运行时递增 1。我们发现这个for循环比等效的 while 循环更可取;它需要更少的代码,在其他脚本/编程语言中更常见。

更好的是,还有一种方法可以遍历数字范围(就像我们之前对 1-10 做的那样),也可以使用 for 循环 Bash 语法。因为数字范围只是一个数字列表,所以我们可以使用几乎与我们在第一个示例中对单词列表进行迭代的相同语法。看看下面的代码:

reader@ubuntu:~/scripts/chapter_11$ vim for-number-list.sh
reader@ubuntu:~/scripts/chapter_11$ cat for-number-list.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-27
# Description: Example of a for loop with a number range.
# Usage: ./for-number-list.sh
#####################################

# This loop runs 10 times.
for counter in {1..10}; do
  echo "Hello! This is loop number ${counter}."
  sleep 1
done

# After the for-loop finishes, print a goodbye message.
echo "All done, thanks for tuning in!"

reader@ubuntu:~/scripts/chapter_11$ bash for-number-list.sh 
Hello! This is loop number 1.
Hello! This is loop number 2.
Hello! This is loop number 3.
Hello! This is loop number 4.
Hello! This is loop number 5.
Hello! This is loop number 6.
Hello! This is loop number 7.
Hello! This is loop number 8.
Hello! This is loop number 9.
Hello! This is loop number 10.
All done, thanks for tuning in!

因此,<list>中的<variable>语法适用于{1..10}的列表。这称为大括号扩展,并在 Bash 版本 4 中添加。大括号扩展的语法非常简单:

{<starting value>..<ending value>}

大括号扩展可以以许多方式使用,但打印数字或字符列表是最为人熟知的:

reader@ubuntu:~/scripts/chapter_11$ echo {1..5}
1 2 3 4 5
reader@ubuntu:~/scripts/chapter_11$ echo {a..f}
a b c d e f

大括号扩展{1..5}返回字符串1 2 3 4 5,这是一个以空格分隔的值列表,因此可以在 Bash 风格的for循环中使用!另外,{a..f}打印字符串a b c d e f。范围实际上是由 ASCII 十六进制代码确定的;这也允许我们做以下操作:

reader@ubuntu:~/scripts/chapter_11$ echo {A..z}
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z [  ] ^ _ ` a b c d e f g h i j k l m n o p q r s t u v w x y z

你可能会觉得奇怪,因为你会看到一些特殊字符在中间打印,但这些字符是大写和小写拉丁字母字符之间的。请注意,这种语法与使用${variable}获取变量值非常相似(但这是参数扩展,而不是大括号扩展)。

大括号扩展还有另一个有趣的功能:它允许我们定义增量!简而言之,这允许我们告诉 Bash 每次递增时要跳过多少步。其语法如下:

{<starting value>..<ending value>..<increment>}

默认情况下,增量值为 1。如果这是期望的功能,我们可以省略增量值,就像我们之前看到的那样。但是,如果我们设置了它,我们将看到以下内容:

reader@ubuntu:~/scripts/chapter_11$ echo {1..100..10}
1 11 21 31 41 51 61 71 81 91
reader@ubuntu:~/scripts/chapter_11$ echo {0..100..10}
0 10 20 30 40 50 60 70 80 90 100

现在,增量是以 10 的步长进行的。正如你在前面的示例中看到的,<ending value>被认为是包含的。这意味着低于或等于的值将被打印,但其他值不会。在前面示例中的第一个大括号扩展中,{1..100..10},下一个值将是 101;因为这不是低于或等于 100,该值不会被打印,扩展被终止。

最后,因为我们承诺了我们可以用while做的任何事情,我们也可以用for做,我们想通过展示如何使用for创建无限循环来结束本章的部分。这是选择while而不是for的最常见原因,因为for的语法有点奇怪:

eader@ubuntu:~/scripts/chapter_11$ vim for-infinite.sh 
reader@ubuntu:~/scripts/chapter_11$ cat for-infinite.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-27
# Description: Example of an infinite for loop.
# Usage: ./for-infinite.sh 
#####################################

# Infinite for loop.
for ((;;)); do
  echo "Hello!"
  sleep 1 # Wait for 1 second.
done

reader@ubuntu:~/scripts/chapter_11$ bash for-infinite.sh 
Hello!
Hello!
Hello!
^C

我们使用 C 风格的语法,但省略了计数器的初始化、比较和递增。因此,它的读法如下:

for ((;;)); do

这最终变成了((;;));,只有将它放在正常语法的上下文中才有意义,就像我们在前面的例子中所做的那样。我们也可以省略增量或与相同效果的比较,但那样会增加更多的代码。通常情况下,更短更好,因为它会更清晰。

尝试复制无限的for循环,但只是通过省略for子句中的一个值。如果你成功了,你将更接近理解为什么你现在让它无休止地运行。如果你需要一点点提示,也许你想要在循环中打印counter的值,这样你就可以看到发生了什么。当然,你也可以用bash -x来运行它!

通配符和 for 循环

现在,让我们看一些更实际的例子。在 Linux 上,你将会处理大部分事情都与文件有关(还记得为什么吗?)。想象一下,你有一堆日志文件放在服务器上,你想对它们执行一些操作。如果只是一个命令执行一个动作,你可以很可能使用通配符模式和命令(比如grep -i 'error' *.log)。然而,想象一种情况,你想收集包含某个短语的日志文件,或者只想要这些文件的行。在这种情况下,使用通配符模式结合for循环将允许我们对许多文件执行许多命令,我们可以动态地找到它们!让我们试试看。因为这个脚本将结合我们迄今为止所学的许多课程,我们将从简单开始,逐渐扩展它:

reader@ubuntu:~/scripts/chapter_11$ vim for-globbing.sh 
reader@ubuntu:~/scripts/chapter_11$ cat for-globbing.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-27
# Description: Combining globbing patterns in a for loop.
# Usage: ./for-globbing.sh 
#####################################

# Create a list of log files.   
for file in $(ls /var/log/*.log); do
  echo ${file}
done

reader@ubuntu:~/scripts/chapter_11$ bash for-globbing.sh 
/var/log/alternatives.log
/var/log/auth.log
/var/log/bootstrap.log
/var/log/cloud-init.log
/var/log/cloud-init-output.log
/var/log/dpkg.log
/var/log/kern.log

通过使用$(ls /var/log/*.log)构造,我们可以创建一个在/var/log/目录中找到的所有以.log结尾的文件的列表。如果你手动运行ls /var/log/*.log命令,你会注意到格式与我们在 Bash 风格的 for 语法中使用时所见到的其他格式相同:单词,以空格分隔。因此,我们现在可以操作我们找到的所有文件!让我们看看如果我们尝试在这些文件中进行 grep 会发生什么:

reader@ubuntu:~/scripts/chapter_11$ cat for-globbing.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.1.0
# Date: 2018-10-27
# Description: Combining globbing patterns in a for loop.
# Usage: ./for-globbing.sh 
#####################################

# Create a list of log files.   
for file in $(ls /var/log/*.log); do
  echo "File: ${file}"
  grep -i 'error' ${file}
done

自从我们改变了脚本的内容,我们已经将版本从v1.0.0提升到v1.1.0。如果你现在运行这个脚本,你会发现一些文件在 grep 上返回了正匹配,而其他一些没有:

reader@ubuntu:~/scripts/chapter_11$ bash for-globbing.sh 
File: /var/log/alternatives.log
File: /var/log/auth.log
File: /var/log/bootstrap.log
Selecting previously unselected package libgpg-error0:amd64.
Preparing to unpack .../libgpg-error0_1.27-6_amd64.deb ...
Unpacking libgpg-error0:amd64 (1.27-6) ...
Setting up libgpg-error0:amd64 (1.27-6) ...
File: /var/log/cloud-init.log
File: /var/log/cloud-init-output.log
File: /var/log/dpkg.log
2018-04-26 19:07:33 install libgpg-error0:amd64 <none> 1.27-6
2018-04-26 19:07:33 status half-installed libgpg-error0:amd64 1.27-6
2018-04-26 19:07:33 status unpacked libgpg-error0:amd64 1.27-6
<SNIPPED>
File: /var/log/kern.log
Jun 30 18:20:32 ubuntu kernel: [    0.652108] RAS: Correctable Errors collector initialized.
Jul  1 09:31:07 ubuntu kernel: [    0.656995] RAS: Correctable Errors collector initialized.
Jul  1 09:42:00 ubuntu kernel: [    0.680300] RAS: Correctable Errors collector initialized.

太好了,现在我们用一个复杂的 for 循环实现了与直接使用grep相同的事情!现在,让我们充分利用它,在我们确定它们包含单词error之后,对文件做些什么:

reader@ubuntu:~/scripts/chapter_11$ vim for-globbing.sh 
reader@ubuntu:~/scripts/chapter_11$ cat for-globbing.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.2.0
# Date: 2018-10-27
# Description: Combining globbing patterns in a for loop.
# Usage: ./for-globbing.sh 
#####################################

# Create a directory to store log files with errors.
ERROR_DIRECTORY='/tmp/error_logfiles/'
mkdir -p ${ERROR_DIRECTORY}

# Create a list of log files. 
for file in $(ls /var/log/*.log); do
 grep --quiet -i 'error' ${file}

 # Check the return code for grep; if it is 0, file contains errors.
 if [[ $? -eq 0 ]]; then
 echo "${file} contains error(s), copying it to archive."
 cp ${file} ${ERROR_DIRECTORY} # Archive the file to another directory.
 fi

done

reader@ubuntu:~/scripts/chapter_11$ bash for-globbing.sh 
/var/log/bootstrap.log contains error(s), copying it to archive.
/var/log/dpkg.log contains error(s), copying it to archive.
/var/log/kern.log contains error(s), copying it to archive.

下一个版本,v1.2.0,执行了一个安静的grep(没有输出,因为我们只想要在找到东西时得到退出状态为 0)。在grep之后,我们使用了一个嵌套的if-then来将文件复制到我们在脚本开头定义的存档目录中。当我们现在运行脚本时,我们可以看到在上一个版本的脚本中生成输出的相同文件,但现在它复制整个文件。此时,for循环证明了它的价值:我们现在对使用通配符模式找到的单个文件执行多个操作。让我们再进一步,从存档文件中删除所有不包含错误的行:

reader@ubuntu:~/scripts/chapter_11$ vim for-globbing.sh 
reader@ubuntu:~/scripts/chapter_11$ cat for-globbing.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.3.0
# Date: 2018-10-27
# Description: Combining globbing patterns in a for loop.
# Usage: ./for-globbing.sh 
#####################################

# Create a directory to store log files with errors.
ERROR_DIRECTORY='/tmp/error_logfiles/'
mkdir -p ${ERROR_DIRECTORY}

# Create a list of log files.   
for file in $(ls /var/log/*.log); do
  grep --quiet -i 'error' ${file}

  # Check the return code for grep; if it is 0, file contains errors.
  if [[ $? -eq 0 ]]; then
    echo "${file} contains error(s), copying it to archive ${ERROR_DIRECTORY}."
    cp ${file} ${ERROR_DIRECTORY} # Archive the file to another directory.

    # Create the new file location variable with the directory and basename of the file.
    file_new_location="${ERROR_DIRECTORY}$(basename ${file})"
    # In-place edit, only print lines matching 'error' or 'Error'.
    sed --quiet --in-place '/[Ee]rror/p' ${file_new_location} 
  fi

done

版本 v1.3.0!为了使它稍微可读一些,我们没有在cpmkdir命令上包含错误检查。然而,由于这个脚本的性质(在/tmp/中创建一个子目录并将文件复制到那里),那里出现问题的机会非常小。我们添加了两个新的有趣的东西:一个名为file_new_location的新变量,带有新位置的文件名和sed,它确保只有错误行保留在存档文件中。

首先,让我们考虑file_new_location=${ERROR_DIRECTORY}$(basename ${file})。我们正在将两个字符串拼接在一起:首先是存档目录,然后是处理文件的基本名称basename命令会剥离文件的完全限定路径,只保留路径末端的文件名。如果我们要查看 Bash 将采取的步骤来解析这个新变量,它可能看起来是这样的:

  • file_new_location=${ERROR_DIRECTORY}$(basename ${file})

-> 解析${file}

  • file_new_location=${ERROR_DIRECTORY}$(basename /var/log/bootstrap.log)

-> 解析$(basename /var/log/bootstrap.log)

  • file_new_location=${ERROR_DIRECTORY}bootstrap.log

-> 解析${ERROR_DIRECTORY}

  • file_new_location=/tmp/error_logfiles/bootstrap.log

-> 完成,变量的最终值!

完成这些工作后,我们现在可以在这个新文件上运行sedsed --quiet --in-place '/[Ee]rror/p' ${file_new_location}命令简单地用与正则表达式搜索模式[Ee]rror匹配的所有行替换文件的内容,这几乎就是我们最初使用 grep 搜索的内容。请记住,我们需要--quiet,因为默认情况下,sed会打印所有行。如果我们省略这一点,我们最终会得到文件中的所有行,但所有的错误文件都会被复制:一次来自sed的非静音输出,一次来自搜索模式匹配。然而,通过激活--quiet,sed只打印匹配的行并将其写入文件。让我们实际操作一下,验证结果:

reader@ubuntu:~/scripts/chapter_11$ bash for-globbing.sh 
/var/log/bootstrap.log contains error(s), copying it to archive /tmp/error_logfiles/.
/var/log/dpkg.log contains error(s), copying it to archive /tmp/error_logfiles/.
/var/log/kern.log contains error(s), copying it to archive /tmp/error_logfiles/.
reader@ubuntu:~/scripts/chapter_11$ ls /tmp/error_logfiles/
bootstrap.log  dpkg.log  kern.log
reader@ubuntu:~/scripts/chapter_11$ head -3 /tmp/error_logfiles/*
==> /tmp/error_logfiles/bootstrap.log <==
Selecting previously unselected package libgpg-error0:amd64.
Preparing to unpack .../libgpg-error0_1.27-6_amd64.deb ...
Unpacking libgpg-error0:amd64 (1.27-6) ...

==> /tmp/error_logfiles/dpkg.log <==
2018-04-26 19:07:33 install libgpg-error0:amd64 <none> 1.27-6
2018-04-26 19:07:33 status half-installed libgpg-error0:amd64 1.27-6
2018-04-26 19:07:33 status unpacked libgpg-error0:amd64 1.27-6

==> /tmp/error_logfiles/kern.log <==
Jun 30 18:20:32 ubuntu kernel: [    0.652108] RAS: Correctable Errors collector initialized.
Jul  1 09:31:07 ubuntu kernel: [    0.656995] RAS: Correctable Errors collector initialized.
Jul  1 09:42:00 ubuntu kernel: [    0.680300] RAS: Correctable Errors collector initialized.

正如你所看到的,每个文件顶部的三行都包含errorError字符串。实际上,所有这些文件中的所有行都包含这两个字符串中的一个;请务必在您自己的系统上验证这一点,因为内容肯定会有所不同。

现在我们完成了这个示例,如果你愿意接受挑战,我们为读者提供了一些挑战:

  • 使这个脚本接受输入。这可以是存档目录、路径通配符、搜索模式,甚至是这三者的组合!

  • 通过为可能失败的命令添加异常处理,使这个脚本更加健壮。

  • 通过使用sed '/xxx/d'语法来颠倒这个脚本的功能(提示:你可能需要重定向来实现这一点)。

虽然这个示例应该说明了很多东西,但我们意识到仅仅搜索error这个词实际上并不只返回错误。实际上,我们看到的大部分返回的内容都与一个已安装的软件包liberror有关!在实践中,你可能会处理在错误方面具有预定义结构的日志文件。在这种情况下,更容易确定一个只记录真正错误的搜索模式。

循环控制

在这一点上,你应该对使用whilefor循环感到满意。关于循环,还有一个更重要的话题需要讨论:循环控制。循环控制是一个通用术语,用于控制循环的任何操作!然而,如果我们想要发挥循环的全部威力,有两个关键字是必须的:breakcontinue。我们将从break开始。

打破循环

对于一些脚本逻辑,有必要跳出循环。你可以想象,在你的某个脚本中,你正在等待某件事完成。一旦发生,你就想做点什么。在while true循环中等待并定期检查可能是一个选择,但是如果你回想一下while-interactive.sh脚本,我们在谜底得到成功答案时退出了。在退出时,我们不能运行任何超出while循环之外的命令!这就是break发挥作用的地方。它允许我们退出循环,但继续脚本。首先,让我们更新while-interactive.sh以利用这个循环控制关键字:

reader@ubuntu:~/scripts/chapter_11$ vim while-interactive.sh 
reader@ubuntu:~/scripts/chapter_11$ cat while-interactive.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.1.0
# Date: 2018-10-28
# Description: A simple riddle in a while loop.
# Usage: ./while-interactive.sh
#####################################

# Infinite loop, only exits on correct answer.
while true; do
  read -p "I have keys but no locks. I have a space but no room. You can enter, but can’t go outside. What am I? " answer
  if [[ ${answer} =~ [Kk]eyboard ]]; then # Use regular expression so 'a keyboard' or 'Keyboard' is also a valid answer.
    echo "Correct, congratulations!"
    break # Exit the while loop.
  else
    # Print an error message and go back into the loop.
    echo "Incorrect, please try again."
  fi
done

# This will run after the break in the while loop.
echo "Now we can continue after the while loop is done, awesome!"

我们做了三个更改:

  • 采用了更高的版本号

  • exit 0替换为break

  • 在 while 循环后添加一个简单的echo

当我们仍然使用exit 0时,最终的echo将永远不会运行(但不要相信我们,一定要自己验证一下!)。现在,用break运行它并观察:

reader@ubuntu:~/scripts/chapter_11$ bash while-interactive.sh 
I have keys but no locks. I have a space but no room. You can enter, but can’t go outside. What am I? keyboard
Correct, congratulations!
Now we can continue after the while loop is done, awesome!

这就是,在一个中断的while循环之后的代码执行。通常,在一个无限循环之后,肯定有其他需要执行的代码,这就是做到的方式。

我们不仅可以在while循环中使用break,而且在for循环中当然也可以。下面的例子显示了我们如何在for循环中使用break

reader@ubuntu:~/scripts/chapter_11$ vim for-loop-control.sh
reader@ubuntu:~/scripts/chapter_11$ cat for-loop-control.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-28
# Description: Loop control in a for loop.
# Usage: ./for-loop-control.sh
#####################################

# Generate a random number from 1-10.
random_number=$(( ( RANDOM % 10 )  + 1 ))

# Iterate over all possible random numbers.
for number in {1..10}; do

  if [[ ${number} -eq ${random_number} ]]; then
    echo "Random number found: ${number}."
    break # As soon as we have found the number, stop.
  fi

  # If we get here the number did not match.
  echo "Number does not match: ${number}."
done
echo "Number has been found, all done."

在此脚本功能的顶部,确定一个 1 到 10 之间的随机数(不用担心语法)。接下来,我们遍历 1 到 10 的数字,对于每个数字,我们将检查它是否等于随机生成的数字。如果是,我们打印一个成功的消息并且我们中断循环。否则,我们将跳出if-then块并打印失败消息。如果我们没有包括中断语句,输出将如下所示:

reader@ubuntu:~/scripts/chapter_11$ bash for-loop-control.sh 
Number does not match: 1.
Number does not match: 2.
Number does not match: 3.
Random number found: 4.
Number does not match: 4.
Number does not match: 5.
Number does not match: 6.
Number does not match: 7.
Number does not match: 8.
Number does not match: 9.
Number does not match: 10.
Number has been found, all done.

我们不仅看到数字被打印为匹配和不匹配(这当然是一个逻辑错误),而且当我们确定那些数字不会匹配时,脚本还会继续检查所有其他数字。现在,如果我们使用 exit 而不是 break,最终的语句将永远不会被打印:

reader@ubuntu:~/scripts/chapter_11$ bash for-loop-control.sh 
Number does not match: 1.
Number does not match: 2.
Number does not match: 3.
Number does not match: 4.
Number does not match: 5.
Number does not match: 6.
Random number found: 7.

只有使用break,我们才会得到我们需要的确切数量的输出;既不多也不少。你可能已经看到,我们也可以为Number does not match:消息使用else子句。但是,没有什么会阻止程序。所以即使随机数第一次就被找到(最终会发生的),它仍然会比较列表中的所有值,直到达到该列表的末尾。

这不仅是浪费时间和资源,而且想象一下,如果随机数在 1 到 1,000,000 之间!只要记住:如果你完成了循环,跳出它。

继续关键字

和 Bash(以及生活)中的大多数事情一样,break有一个对应的continue关键字。如果你使用 continue,你是告诉循环停止当前循环,但继续下一次运行。所以,不是停止整个循环,你只是停止当前迭代。让我们看看另一个例子是否能澄清这一点:

reader@ubuntu:~/scripts/chapter_11$ vim for-continue.sh
reader@ubuntu:~/scripts/chapter_11$ cat for-continue.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-28
# Description: For syntax with a continue.
# Usage: ./for-continue.sh
#####################################

# Look at numbers 1-20, in steps of 2.
for number in {1..20..2}; do
  if [[ $((${number}%5)) -eq 0 ]]; then
    continue # Unlucky number, skip this!
  fi

  # Show the user which number we've processed.
  echo "Looking at number: ${number}."

done

在这个例子中,所有可以被 5 整除的数字都被认为是不幸的,不应该被处理。这是通过[[ $((${number}%5)) -eq 0 ]]条件实现的:

  • [[ \(((\)%5)) -eq 0 ]] → 测试语法

  • [[ \(((**\)%5))** -eq 0 ]] → 算术语法

  • [[ \(((**\)%5**)) -eq 0 ]] → 变量number的模 5

如果数字通过了这个测试(因此可以被 5 整除,比如 5、10、15、20 等),将执行continue。当这发生时,循环的下一个迭代将运行(并且echo不会被执行!),当运行这个脚本时可以看到:

reader@ubuntu:~/scripts/chapter_11$ bash for-continue.sh 
Looking at number: 1.
Looking at number: 3.
Looking at number: 7.
Looking at number: 9.
Looking at number: 11.
Looking at number: 13.
Looking at number: 17.
Looking at number: 19.

如列表所示,数字51015被处理,但我们在echo中看不到它们。我们还可以看到之后的一切,这在使用break时是不会发生的。使用bash -x验证这是否真的发生了(警告:大量输出!),并检查如果你用break或甚至exit替换continue会发生什么。

循环控制和嵌套

在本章的最后部分,我们想向您展示如何使用循环控制来影响嵌套循环。breakcontinue都将带有一个额外的参数:指定要中断的循环。默认情况下,如果省略了此参数,就假定为1。因此,break命令等同于break 1continue 1等同于continue。正如之前所述,我们理论上可以将我们的循环嵌套得很深;你可能会比你的现代系统的技术能力更早地遇到逻辑问题!我们将看一个简单的例子,向我们展示如何使用break 2不仅可以跳出for循环,还可以跳出外部的while循环:

reader@ubuntu:~/scripts/chapter_11$ vim break-x.sh 
reader@ubuntu:~/scripts/chapter_11$ cat break-x.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-10-28
# Description: Breaking out of nested loops.
# Usage: ./break-x.sh
#####################################

while true; do
  echo "This is the outer loop."
  sleep 1

  for iteration in {1..3}; do
    echo "This is inner loop ${iteration}."
    sleep 1
  done
done
echo "This is the end of the script, thanks for playing!"

这个脚本的第一个版本不包含break。当我们运行它时,我们永远看不到最终的消息,而且我们得到一个无休止的重复模式:

reader@ubuntu:~/scripts/chapter_11$ bash break-x.sh 
This is the outer loop.
This is inner loop 1.
This is inner loop 2.
This is inner loop 3.
This is the outer loop.
This is inner loop 1.
^C

现在,让我们在迭代达到2时中断内部循环:

reader@ubuntu:~/scripts/chapter_11$ vim break-x.sh 
reader@ubuntu:~/scripts/chapter_11$ cat break-x.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.1.0
# Date: 2018-10-28
# Description: Breaking out of nested loops.
# Usage: ./break-x.sh
#####################################
<SNIPPED>
  for iteration in {1..3}; do
    echo "This is inner loop ${iteration}."
    if [[ ${iteration} -eq 2 ]]; then
      break 1
    fi
    sleep 1
  done
<SNIPPED>

现在运行脚本时,我们仍然得到无限循环,但在三次迭代之后,我们缩短了内部 for 循环:

reader@ubuntu:~/scripts/chapter_11$ bash break-x.sh 
This is the outer loop.
This is inner loop 1.
This is inner loop 2.
This is the outer loop.
This is inner loop 1.
^C

现在,让我们使用break 2命令指示内部循环跳出外部循环:

reader@ubuntu:~/scripts/chapter_11$ vim break-x.sh 
reader@ubuntu:~/scripts/chapter_11$ cat break-x.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.2.0
# Date: 2018-10-28
# Description: Breaking out of nested loops.
# Usage: ./break-x.sh
#####################################
<SNIPPED>
    if [[ ${iteration} -eq 2 ]]; then
      break 2 # Break out of the outer while-true loop.
    fi
<SNIPPED>

看,内部循环成功地跳出了外部循环:

reader@ubuntu:~/scripts/chapter_11$ bash break-x.sh 
This is the outer loop.
This is inner loop 1.
This is inner loop 2.
This is the end of the script, thanks for playing!

我们可以完全控制我们的循环,即使我们需要嵌套尽可能多的循环来满足我们的脚本需求。相同的理论也适用于continue。在这个例子中,如果我们使用continue 2而不是break 2,我们仍然会得到一个无限循环(因为while true永远不会结束)。然而,如果您的其他循环也是for或非无限的while循环(根据我们的经验,这更常见,但不适合作为一个很好的简单例子),continue 2可以让您执行恰好符合情况需求的逻辑。

总结

本章是关于条件测试和脚本循环的。由于我们已经讨论了if-then-else语句,所以在展示条件测试工具包的更高级用法之前,我们回顾了这些信息。这些高级信息包括在条件测试场景中使用正则表达式,我们在上一章中学习过,以实现更灵活的测试。我们还向您展示了如何使用elifelse if的缩写)来顺序测试多个条件。我们解释了如何嵌套多个if-then-else语句以创建高级逻辑。

在本章的第二部分,我们介绍了while循环。我们向您展示了如何使用它来创建一个永远运行的脚本,或者如何使用条件来在满足某些条件时停止循环。我们介绍了until关键字,它与while具有相同的功能,但允许进行负检查,而不是while的正检查。我们通过向您展示如何在一个无休止的while循环中创建一个交互式脚本来结束对while的解释(使用我们的老朋友read)。

while之后,我们介绍了更强大的for循环。这个循环可以做与while相同的事情,但通常更短的语法允许我们编写更少的代码(以及更可读的代码,这仍然是脚本编写中非常重要的一个方面!)。我们向您展示了for如何遍历列表,以及如何使用大括号扩展来创建数字列表。我们通过给出一个实际的例子,结合for和文件通配模式,来结束我们对for循环的讨论,以便我们可以动态查找、获取和处理文件。

我们通过解释循环控制来结束了本章,Bash 中使用breakcontinue关键字来实现。这些关键字允许我们从循环中跳出(甚至从嵌套循环中,直到我们需要的外部循环),并且还允许我们停止循环的当前迭代,继续到下一个迭代。

本章介绍了以下命令/关键字:elifhelpwhilesleepforbasenamebreakcontinue

问题

  1. if-then-else)语句是如何结束的?

  2. 我们如何在条件评估中使用正则表达式搜索模式?

  3. 我们为什么需要elif关键字?

  4. 什么是嵌套

  5. 我们如何获取有关如何使用 shell 内置命令和关键字的信息?

  6. while的相反关键字是什么?

  7. 为什么我们会选择for循环而不是while循环?

  8. 大括号扩展是什么,我们可以在哪些字符上使用它?

  9. 哪两个关键字允许我们对循环有更精细的控制?

  10. 如果我们嵌套循环,我们如何使用循环控制来影响内部循环的外部循环?

进一步阅读

如果您想更深入地了解本章的主题,以下资源可能会很有趣:

第十二章:在脚本中使用管道和重定向

在本章中,我们将解释 Bash 的一个非常重要的方面:重定向。我们将从描述不同类型的输入和输出重定向开始,以及它们如何与 Linux 文件描述符相关联。在涵盖了重定向的基础知识之后,我们将继续介绍一些高级用法。

接下来是管道,这是 Shell 脚本中广泛使用的一个概念。我们将介绍一些管道的实际示例。最后,我们将展示here documents的工作原理,这也有一些很好的用途。

本章将介绍以下命令:diffgccfallocatetrchpasswdteebc

本章将涵盖以下主题:

  • 输入/输出重定向

  • 管道

  • Here documents

技术要求

本章的所有脚本都可以在 GitHub 上找到,链接如下:github.com/tammert/learn-linux-shell-scripting/tree/master/chapter_12。对于所有其他练习,你的 Ubuntu 18.04 虚拟机仍然是你最好的朋友。

输入/输出重定向

在本章中,我们将详细讨论 Linux 中的重定向。

简而言之,重定向几乎完全就像字面意思一样:将某物重定向到其他某物。例如,我们已经看到我们可以使用一个命令的输出作为下一个命令的输入,使用管道。在 Linux 中,管道是使用|符号实现的。

然而,这可能会引发一个问题:Linux 如何处理输入和输出?我们将从一些关于文件描述符的理论开始我们的重定向之旅,这是使所有重定向成为可能的原因!

文件描述符

你可能已经厌倦了听到这一点,但它仍然是真的:在 Linux 中,一切都是一个文件。我们已经看到一个文件是一个文件,一个目录是一个文件,甚至硬盘也是文件;但现在,我们将再进一步:你用于输入的键盘也是一个文件!

与此相辅相成的是,你的终端,命令使用它作为输出,猜猜看:就是一个文件。

你可以在 Linux 文件系统树中找到这些文件,就像大多数特殊文件一样。让我们检查一下我们的虚拟机:

reader@ubuntu:~$ cd /dev/fd/
reader@ubuntu:/dev/fd$ ls -l
total 0
lrwx------ 1 reader reader 64 Nov  5 18:54 0 -> /dev/pts/0
lrwx------ 1 reader reader 64 Nov  5 18:54 1 -> /dev/pts/0
lrwx------ 1 reader reader 64 Nov  5 18:54 2 -> /dev/pts/0
lrwx------ 1 reader reader 64 Nov  5 18:54 255 -> /dev/pts/0

在这里找到的四个文件中,有三个很重要:/dev/fd/0/dev/fd/1/dev/fd/2

从这段文字的标题中,你可能会怀疑fd代表file descriptor。这些文件描述符在内部用于将用户的输入和输出与终端绑定在一起。你实际上可以看到文件描述符是如何做到这一点的:它们被符号链接到/dev/pts/0

在这种情况下,pts代表伪终端从属,这是对 SSH 连接的定义。看看当我们从三个不同的位置查看/dev/fd时会发生什么:

# SSH connection 1
reader@ubuntu:~/scripts/chapter_12$ ls -l /dev/fd/
total 0
lrwx------ 1 reader reader 64 Nov  5 19:06 0 -> /dev/pts/0
lrwx------ 1 reader reader 64 Nov  5 19:06 1 -> /dev/pts/0
lrwx------ 1 reader reader 64 Nov  5 19:06 2 -> /dev/pts/0

# SSH connection 2
reader@ubuntu:/dev/fd$ ls -l
total 0
lrwx------ 1 reader reader 64 Nov  5 18:54 0 -> /dev/pts/1
lrwx------ 1 reader reader 64 Nov  5 18:54 1 -> /dev/pts/1
lrwx------ 1 reader reader 64 Nov  5 18:54 2 -> /dev/pts/1

# Virtual machine terminal
reader@ubuntu:/dev/fd$ ls -l
total 0
lrwx------ 1 reader reader 64 Nov  5 19:08 0 -> /dev/tty/1
lrwx------ 1 reader reader 64 Nov  5 19:08 1 -> /dev/tty/1
lrwx------ 1 reader reader 64 Nov  5 19:08 2 -> /dev/tty/1

每个连接都有自己的/dev/挂载(存储在内存中的udev类型),这就是为什么我们看不到一个连接的输出进入另一个连接的原因。

现在,我们一直在谈论输入和输出。但是,正如你无疑所见,前面的例子中分配了三个文件描述符。在 Linux(或类 Unix 系统)中,默认通过文件描述符公开的三个默认

  • 标准输入stdin默认绑定到/dev/fd/0

  • 标准输出stdout默认绑定到/dev/fd/1

  • 标准错误stderr默认绑定到/dev/fd/2

就这三个流而言,stdinstdout应该相当直接:输入和输出。然而,正如你可能已经推断出的那样,输出实际上被分成正常输出和错误输出。正常输出被发送到stdout文件描述符,而错误输出通常被发送到stderr

由于这两者都是符号链接到终端,所以无论如何你都会在那里看到它们。然而,正如我们将在本章后面看到的,一旦我们开始重定向,这种差异就变得重要起来。

你可能会看到一些其他文件描述符,比如第一个示例中的 255。除了在终端提供输入和输出时使用它们,文件描述符还在 Linux 打开文件系统中的文件时使用。文件描述符的这种其他用途超出了本书的范围;然而,我们在进一步阅读部分包含了一个链接,供感兴趣的读者参考。

在正常交互中,你在终端中输入的文本被写入到/dev/fd/0上的stdin,一个命令可以读取。使用该输入,命令通常会执行某些操作(否则,我们就不需要这个命令!)并将输出写入stdoutstderr。然后终端会读取这些输出并显示给你。简而言之:

  • 一个终端 写入 stdin读取 stdoutstderr

  • 一个命令 stdin 读取写入 stdoutstderr

除了 Linux 内部使用的文件描述符之外,还有一些保留用于创建真正高级脚本的文件描述符;这些是 3 到 9。任何其他的可能被系统使用,但这些保证可以自由使用。正如所述,这是非常高级的,不太经常使用,我们不会详细介绍。然而,我们找到了一些可能有趣的进一步阅读材料,这些材料包含在本章的末尾。

重定向输出

现在输入、输出和文件描述符的理论应该是清楚的,我们将看到如何在命令行和脚本冒险中使用这些技术。

事实上,在没有使用重定向的情况下编写 shell 脚本是相当困难的;在本章之前的书中,我们实际上已经使用了几次重定向,因为我们当时真的需要它来完成我们的工作(例如第八章中的file-create.sh变量和用户输入)。

现在,让我们先来体验一下重定向!

stdout

大多数命令的输出将是标准输出,写入/dev/fd/1上的stdout。通过使用>符号,我们可以使用以下语法重定向输出:

command > output-file

重定向将始终指向一个文件(然而,正如我们所知,不是所有文件都是相等的,因此在常规示例之后,我们将向您展示一些 Bash 魔法,涉及非常规文件)。如果文件不存在,它将被创建。如果存在,它将被覆盖

在其最简单的形式中,通常会打印到终端的所有内容都可以重定向到文件:

reader@ubuntu:~/scripts/chapter_12$ ls -l /var/log/dpkg.log 
-rw-r--r-- 1 root root 737150 Nov  5 18:49 /var/log/dpkg.log
reader@ubuntu:~/scripts/chapter_12$ cat /var/log/dpkg.log > redirected-file.log
reader@ubuntu:~/scripts/chapter_12$ ls -l
total 724
-rw-rw-r-- 1 reader reader 737150 Nov  5 19:45 redirected-file.log

如你所知,cat将整个文件内容打印到你的终端。实际上,它实际上将整个内容发送到stdout,它绑定到/dev/fd/1,它绑定到你的终端;这就是为什么你看到它。

现在,如果我们将文件的内容重定向回另一个文件,我们实际上已经做出了很大的努力...复制一个文件!从文件大小可以看出,实际上是相同的文件。如果你不确定,你可以使用diff命令来查看文件是否相同:

reader@ubuntu:~/scripts/chapter_12$ diff /var/log/dpkg.log redirected-file.log 
reader@ubuntu:~/scripts/chapter_12$ echo $?
0

如果diff没有返回任何输出,并且它的退出代码为0,则文件没有差异。

回到重定向示例。我们使用>将输出重定向到文件。实际上,>1>的简写。你可能会认出这个1:它指的是文件描述符/dev/fd/1。正如我们将在处理stderr时看到的,它位于/dev/fd/2上,我们将使用2>而不是1>>

首先,让我们构建一个简单的脚本来进一步说明这一点:

reader@ubuntu:~/scripts/chapter_12$ vim redirect-to-file.sh 
reader@ubuntu:~/scripts/chapter_12$ cat redirect-to-file.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-05
# Description: Redirect user input to file.
# Usage: ./redirect-to-file.sh
#####################################

# Capture the users' input.
read -p "Type anything you like: " user_input

# Save the users' input to a file.
echo ${user_input} > redirect-to-file.txt

现在,当我们运行这个脚本时,read会提示我们输入一些文本。这将保存在user_input变量中。然后,我们将使用echouser_input变量的内容发送到stdout。但是,它不会通过/dev/fd/1到达终端上的/dev/pts/0,而是重定向到redirect-to-file.txt文件中。

总的来说,它看起来像这样:

reader@ubuntu:~/scripts/chapter_12$ bash redirect-to-file.sh 
Type anything you like: I like dogs! And cats. Maybe a gecko?
reader@ubuntu:~/scripts/chapter_12$ ls -l
total 732
-rw-rw-r-- 1 reader reader 737150 Nov  5 19:45 redirected-file.log
-rw-rw-r-- 1 reader reader    383 Nov  5 19:58 redirect-to-file.sh
-rw-rw-r-- 1 reader reader     38 Nov  5 19:58 redirect-to-file.txt
reader@ubuntu:~/scripts/chapter_12$ cat redirect-to-file.txt
I like dogs! And cats. Maybe a gecko?

现在,这个脚本按照预期工作。然而,如果我们再次运行它,我们会看到这个脚本可能出现的两个问题:

reader@ubuntu:~/scripts$ bash chapter_12/redirect-to-file.sh
Type anything you like: Hello
reader@ubuntu:~/scripts$ ls -l
<SNIPPED>
drwxrwxr-x 2 reader reader 4096 Nov  5 19:58 chapter_12
-rw-rw-r-- 1 reader reader    6 Nov  5 20:02 redirect-to-file.txt
reader@ubuntu:~/scripts$ bash chapter_12/redirect-to-file.sh
Type anything you like: Bye
reader@ubuntu:~/scripts$ ls -l
<SNIPPED>
drwxrwxr-x 2 reader reader 4096 Nov  5 19:58 chapter_12
-rw-rw-r-- 1 reader reader    4 Nov  5 20:02 redirect-to-file.txt

第一件出错的事情,正如我们之前警告过的,是相对路径可能会搞乱文件的写入位置。

你可能已经设想到文件是在脚本旁边创建的;只有当你的当前工作目录在脚本所在的目录中时,才会发生这种情况。因为我们是从树的较低位置调用它,所以输出被写入那里(因为那是当前工作目录)。

另一个问题是,每次我们输入内容时,都会删除文件的旧内容!在我们输入Hello后,我们看到文件有六个字节(每个字符一个字节,加上一个换行符),在我们输入Bye后,我们现在看到文件只有四个字节(三个字符加上换行符)。

这可能是期望的行为,但更多时候,如果输出追加到文件中,而不是替换它,会更好。

让我们在脚本的新版本中解决这两个问题:

reader@ubuntu:~/scripts$ vim chapter_12/redirect-to-file.sh 
reader@ubuntu:~/scripts$ cat chapter_12/redirect-to-file.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.1.0
# Date: 2018-11-05
# Description: Redirect user input to file.
# Usage: ./redirect-to-file.sh
#####################################

# Since we're dealing with paths, set current working directory.
cd $(dirname $0)

# Capture the users' input.
read -p "Type anything you like: " user_input

# Save the users' input to a file. > for overwrite, >> for append.
echo ${user_input} >> redirect-to-file.txt

现在,如果我们运行它(无论在哪里),我们会看到新的文本被追加到第一句话,“我喜欢狗!还有猫。也许是壁虎?”在/home/reader/chapter_12/redirect-to-file.txt文件中:

reader@ubuntu:~/scripts$ cd /tmp/
reader@ubuntu:/tmp$ cat /home/reader/scripts/chapter_12/redirect-to-file.txt 
I like dogs! And cats. Maybe a gecko?
reader@ubuntu:/tmp$ bash /home/reader/scripts/chapter_12/redirect-to-file.sh
Type anything you like: Definitely a gecko, those things are awesome!
reader@ubuntu:/tmp$ cat /home/reader/scripts/chapter_12/redirect-to-file.txt 
I like dogs! And cats. Maybe a gecko?
Definitely a gecko, those things are awesome!

所以,cd $(dirname $0) 帮助我们处理相对路径,>> 而不是 > 确保追加而不是覆盖。正如你所期望的那样,>> 再次代表 1>>,当我们开始稍后重定向 stderr 流时,我们会看到这一点。

不久前,我们向你承诺了一些 Bash 魔法。虽然不完全是魔法,但可能会让你的头有点疼:

reader@ubuntu:~/scripts/chapter_12$ cat redirect-to-file.txt 
I like dogs! And cats. Maybe a gecko?
Definitely a gecko, those things are awesome!
reader@ubuntu:~/scripts/chapter_12$ cat redirect-to-file.txt > /dev/pts/0
I like dogs! And cats. Maybe a gecko?
Definitely a gecko, those things are awesome!
reader@ubuntu:~/scripts/chapter_12$ cat redirect-to-file.txt > /dev/fd/1
I like dogs! And cats. Maybe a gecko?
Definitely a gecko, those things are awesome!
reader@ubuntu:~/scripts/chapter_12$ cat redirect-to-file.txt > /dev/fd/2
I like dogs! And cats. Maybe a gecko?
Definitely a gecko, those things are awesome!

所以,我们成功地使用cat四次打印了我们的文件。你可能会想,我们也可以用for来做,但是这个教训不是我们打印消息的次数,而是我们是如何做到的!

首先,我们只是使用了cat;没有什么特别的。接下来,我们将cat与将stdout重定向到/dev/pts/0,也就是我们的终端,结合使用。同样,消息被打印出来。

第三和第四次,我们将cat的重定向stdout发送到/dev/fd/1/dev/fd/2。由于这些是符号链接到/dev/pts/0,这也不奇怪,这些也最终出现在我们的终端上。

那么,我们如何实际区分 stdoutstderr 呢?

stderr

如果你对前面的例子感到困惑,那可能是因为你误解了stderr消息的流向(我们不怪你,我们自己也搞混了!)。虽然我们将cat命令的输出发送到/dev/fd/2,但我们使用了>,这会发送stdout而不是stderr

因此,在我们的示例中,我们滥用了 stderr 文件描述符来打印到终端;这是不好的做法。我们保证不会再这样做了。那么,我们如何实际处理 stderr 消息呢?

reader@ubuntu:/tmp$ cat /root/
cat: /root/: Permission denied
reader@ubuntu:/tmp$ cat /root/ 1> error-file
cat: /root/: Permission denied
reader@ubuntu:/tmp$ ls -l
-rw-rw-r-- 1 reader reader    0 Nov  5 20:35 error-file
reader@ubuntu:/tmp$ cat /root/ 2> error-file
reader@ubuntu:/tmp$ ls -l
-rw-rw-r-- 1 reader reader   31 Nov  5 20:35 error-file
reader@ubuntu:/tmp$ cat error-file 
cat: /root/: Permission denied

这种交互应该说明一些事情。首先,当cat /root/抛出Permission denied错误时,它将其发送到stderr而不是stdout。我们可以看到这一点,因为当我们执行相同的命令,但尝试用1> error-file重定向标准 输出时,我们仍然在终端上看到输出,并且我们还看到error-file是空的。

当我们使用2> error-file时,它重定向stderr而不是常规的stdout,我们不再在终端上看到错误消息。

更好的是,我们现在看到error-file有 31 个字节的内容,当我们用cat打印它时,我们再次看到了我们重定向的错误消息!如前所述,并且与1>>的精神一样,如果你想追加而不是覆盖stderr流到一个文件,使用2>>

现在,因为很难找到一个命令来在同一个命令中打印stdoutstderr,我们将创建我们自己的命令:一个非常简单的 C 程序,它打印两行文本,一行到stdout,一行到stderr

作为对编程和编译的预览,请看这个(如果你不完全理解这个,不要担心):

reader@ubuntu:~/scripts/chapter_12$ vim stderr.c 
reader@ubuntu:~/scripts/chapter_12$ cat stderr.c 
#include <stdio.h>
int main()
{
  // Print messages to stdout and stderr.
  fprintf(stdout, "This is sent to stdout.\n");
  fprintf(stderr, "This is sent to stderr.\n");
  return 0;
}

reader@ubuntu:~/scripts/chapter_12$ gcc stderr.c -o stderr
reader@ubuntu:~/scripts/chapter_12$ ls -l
total 744
-rw-rw-r-- 1 reader reader 737150 Nov  5 19:45 redirected-file.log
-rw-rw-r-- 1 reader reader    501 Nov  5 20:09 redirect-to-file.sh
-rw-rw-r-- 1 reader reader     84 Nov  5 20:13 redirect-to-file.txt
-rwxrwxr-x 1 reader reader   8392 Nov  5 20:46 stderr
-rw-rw-r-- 1 reader reader    185 Nov  5 20:46 stderr.c

gcc stderr.c -o stderr命令将在stderr.c中找到的源代码编译为二进制文件stderr

gcc是 GNU 编译器集合,并不总是默认安装的。如果你想跟着这个例子并且收到关于找不到gcc的错误,请使用sudo apt install gcc -y来安装它。

如果我们运行我们的程序,我们会得到两行输出。因为这不是一个 Bash 脚本,我们不能用bash stderr来执行它。我们需要用chmod使二进制文件可执行,并用./stderr来运行它:

reader@ubuntu:~/scripts/chapter_12$ bash stderr
stderr: stderr: cannot execute binary file
reader@ubuntu:~/scripts/chapter_12$ chmod +x stderr
reader@ubuntu:~/scripts/chapter_12$ ./stderr 
This is sent to stdout.
This is sent to stderr.

现在,让我们看看当我们开始重定向部分输出时会发生什么:

reader@ubuntu:~/scripts/chapter_12$ ./stderr > /tmp/stdout
This is sent to stderr.
reader@ubuntu:~/scripts/chapter_12$ cat /tmp/stdout 
This is sent to stdout.

因为我们只重定向了stdout(最后提醒:>等于1>)到完全限定的文件/tmp/stdoutstderr消息仍然被打印到终端上。

另一种方式会得到类似的结果:

reader@ubuntu:~/scripts/chapter_12$ ./stderr 2> /tmp/stderr
This is sent to stdout.
reader@ubuntu:~/scripts/chapter_12$ cat /tmp/stderr 
This is sent to stderr.

现在,当我们只使用2> /tmp/stderr来重定向stderr时,我们会看到stdout消息出现在我们的终端上,而stderr被正确地重定向到/tmp/stderr文件中。

我相信你现在正在问自己这个问题:我们如何重定向所有输出,包括stdoutstderr,到一个文件?如果这是一本关于 Bash 3.x 的书,我们将会有一个困难的对话。这个对话将包括我们将stderr重定向到stdout,之后我们可以使用>将所有输出(因为我们已经将stderr重定向到stdout)发送到一个单独的文件。

尽管这是逻辑上的做法,将stderr重定向到stdout实际上是在命令的末尾。命令最终变成这样:./stderr > /tmp/output 2>&1。并不是太复杂,但足够难以一次记住(你可以相信我们)。

幸运的是,在 Bash 4.x 中,我们有一个新的重定向命令可供我们使用,可以以更易理解的方式完成相同的事情:&>

重定向所有输出

在大多数情况下,发送到stderr而不是stdout的输出将包含明显表明你正在处理错误的单词。这将包括诸如permission deniedcannot execute binary filesyntax error near unexpected token等示例。

因此,通常并不真的需要将输出分成stdoutstderr(但显然,有时会是很好的功能)。在这些情况下,Bash 4.x 的新增功能允许我们用单个命令重定向stdoutstderr是完美的。这种重定向,你可以使用&>语法,与我们之前看到的例子没有不同。

让我们回顾一下我们之前的例子,看看这是如何让我们的生活变得更容易的:

reader@ubuntu:~/scripts/chapter_12$ ./stderr
This is sent to stdout.
This is sent to stderr.
reader@ubuntu:~/scripts/chapter_12$ ./stderr &> /tmp/output
reader@ubuntu:~/scripts/chapter_12$ cat /tmp/output
This is sent to stderr.
This is sent to stdout.

太棒了!有了这个语法,我们就不再需要担心不同的输出流。当你使用新命令时,这是特别实用的;在这种情况下,你可能会错过一些有趣的错误消息,因为stderr流没有被保存。

冒昧地说一下,将stdoutstderr都追加到文件的语法再次是额外的>&>>

继续尝试之前的例子。我们不会在这里打印它,因为现在应该很明显这是如何工作的。

不确定是重定向所有输出,还是只重定向stdoutstderr?我们的建议:从将两者重定向到同一个文件开始。如果在您的用例中这会产生太多噪音(掩盖错误或正常日志消息),您可以决定将它们中的任何一个重定向到文件,并在终端中打印另一个。实际上,stderr消息通常需要stdout消息提供的上下文来理解错误,因此最好将它们方便地放在同一个文件中!

特殊的输出重定向

尽管发送所有输出通常是一件好事,但您经常会发现自己要做的另一件事是将错误(您期望在某些命令上出现)重定向到一个特殊的设备:/dev/null

null类型透露了功能:它介于垃圾桶和黑洞之间。

/dev/null

实际上,所有发送(实际上是写入)到/dev/null的数据都将被丢弃,但仍会生成一个写操作成功的返回给调用命令。在这种情况下,那将是重定向。

这很重要,因为当重定向无法成功完成时会发生什么:

reader@ubuntu:~/scripts/chapter_12$ ./stderr &> /root/file
-bash: /root/file: Permission denied
reader@ubuntu:~/scripts/chapter_12$ echo $?
1

这个操作失败了(因为reader用户显然无法在root超级用户的主目录中写入)。

看看当我们尝试使用/dev/null做同样的事情时会发生什么:

reader@ubuntu:~/scripts/chapter_12$ ./stderr &> /dev/null 
reader@ubuntu:~/scripts/chapter_12$ echo $?
0
reader@ubuntu:~/scripts/chapter_12$ cat /dev/null 
reader@ubuntu:~/scripts/chapter_12$

就是这样。所有的输出都消失了(因为&>重定向了stdoutstderr),但命令仍然报告了期望的退出状态0。当我们确保数据已经消失时,我们使用cat /dev/null,结果什么也没有。

我们将向您展示一个实际示例,您在脚本中经常会使用到:

reader@ubuntu:~/scripts/chapter_12$ vim find.sh 
reader@ubuntu:~/scripts/chapter_12$ cat find.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-06
# Description: Find a file.
# Usage: ./find.sh <file-name>
#####################################

# Check for the current number of arguments.
if [[ $# -ne 1 ]]; then
  echo "Wrong number of arguments!"
  echo "Usage: $0 <file-name>"
  exit 1
fi

# Name of the file to search for.
file_name=$1

# Redirect all errors to /dev/null, so they don't clutter the terminal.
find / -name "${file_name}" 2> /dev/null

这个脚本只包含我们之前介绍过的结构,除了对stderr进行/dev/null重定向。虽然这个find.sh脚本实际上只是find命令的一个简单包装器,但它确实有很大的区别。

看看当我们使用find查找文件find.sh时会发生什么(因为为什么不呢!):

reader@ubuntu:~/scripts/chapter_12$ find / -name find.sh
find: ‘/etc/ssl/private’: Permission denied
find: ‘/etc/polkit-1/localauthority’: Permission denied
<SNIPPED>
find: ‘/sys/fs/pstore’: Permission denied
find: ‘/sys/fs/fuse/connections/48’: Permission denied
/home/reader/scripts/chapter_12/find.sh
find: ‘/data/devops-files’: Permission denied
find: ‘/data/dev-files’: Permission denied
<SNIPPED>

我们删掉了大约 95%的输出,因为您可能会同意,五页的Permission denied错误没有多少价值。因为我们是以普通用户身份运行find,所以我们无法访问系统的许多部分。这些错误反映了这一点。

我们确实找到了我们的脚本,正如之前所强调的,但在遇到它之前可能需要滚动几分钟。这正是我们所说的错误输出淹没相关输出的情况。

现在,让我们用我们的包装脚本寻找同一个文件:

reader@ubuntu:~/scripts/chapter_12$ bash find.sh find.sh
/home/reader/scripts/chapter_12/find.sh

我们走了!相同的结果,但没有那些让我们困惑的烦人错误。由于Permission denied错误被发送到stderr流,我们在find命令之后使用2> /dev/null 删除了它们。

这实际上带我们到另一个观点:您也可以使用重定向来使命令静音。我们已经看到许多命令都包含--quiet-q标志。但是,有些命令,比如find,却没有这个标志。

您可能会认为find有这个标志会很奇怪(为什么要搜索文件,当你不想知道它在哪里时,对吧?),但可能有其他命令的退出代码提供了足够的信息,但没有--quiet标志;这些都是将所有内容重定向到/dev/null的绝佳候选者。

所有的命令都是不同的。虽然现在大多数命令都有一个可用的--quiet标志,但总会有一些情况不适用。也许--quiet标志只静音stdout而不是stderr,或者它只减少输出。无论如何,当您真的对输出不感兴趣(只对退出状态感兴趣)时,了解将所有输出重定向到/dev/null是一件非常好的事情!

/dev/zero

我们可以使用的另一个特殊设备是/dev/zero。当我们将输出重定向到/dev/zero时,它与/dev/null完全相同:数据消失。但是,在实践中,/dev/null最常用于此目的。

那么,为什么有这个特殊的设备呢?因为/dev/zero也可以用来读取空字节。在所有可能的 256 个字节中,空字节是第一个:十六进制00。空字节通常用于表示命令的终止,例如。

现在,我们还可以使用这些空字节来为磁盘分配字节:

reader@ubuntu:/tmp$ ls -l
-rw-rw-r-- 1 reader reader   48 Nov  6 19:26 output
reader@ubuntu:/tmp$ head -c 1024 /dev/zero > allocated-file
reader@ubuntu:/tmp$ ls -l
-rw-rw-r-- 1 reader reader 1024 Nov  6 20:09 allocated-file
-rw-rw-r-- 1 reader reader   48 Nov  6 19:26 output
reader@ubuntu:/tmp$ cat allocated-file 
reader@ubuntu:/tmp$ 

通过使用head -c 1024,我们指定要从/dev/zero中获取前 1024 个字符。因为/dev/zero只提供空字节,这些字节都将是相同的,但我们确切知道会有1024个。

我们使用stdout重定向将它们重定向到文件,然后我们看到一个大小为 1024 字节的文件(多么令人惊讶)。现在,如果我们cat这个文件,我们什么也看不到!同样,这不应该是一个惊喜,因为空字节就是这样:空的,无效的,空的。终端无法表示它们,因此它不会显示。

如果您在脚本中需要执行此操作,还有另一个选项:fallocate

reader@ubuntu:/tmp$ fallocate --length 1024 fallocated-file
reader@ubuntu:/tmp$ ls -l
-rw-rw-r-- 1 reader reader 1024 Nov  6 20:09 allocated-file
-rw-rw-r-- 1 reader reader 1024 Nov  6 20:13 fallocated-file
-rw-rw-r-- 1 reader reader   48 Nov  6 19:26 output
reader@ubuntu:/tmp$ cat fallocated-file 
reader@ubuntu:/tmp$ 

从前面的输出中可以看出,这个命令与我们已经通过/dev/zero读取和重定向实现的功能完全相同(如果fallocate实际上是从/dev/zero读取的一个花哨的包装器,我们不会感到惊讶,但我们不能确定)。

输入重定向

另外两个著名的特殊设备/dev/random/dev/urandom最好与输入重定向一起讨论。

输入通常来自您的键盘,通过终端传递给命令。最简单的例子是read命令:它从stdin读取,直到遇到换行符(按下Enter键时),然后将输入保存到REPLY变量(或者如果您提供了该参数,则保存到任何自定义变量)。它看起来有点像这样:

reader@ubuntu:~$ read -p "Type something: " answer
Type something: Something
reader@ubuntu:~$ echo ${answer}
something

简单。现在,假设我们以非交互方式运行此命令,这意味着我们无法使用键盘和终端提供信息(对于read来说不是真正的用例,但这是一个很好的例子)。

在这种情况下,我们可以使用输入重定向(stdin)来提供read的输入。这是通过<字符实现的,它是<0的简写。记住stdin文件描述符是/dev/fd/0?这不是巧合。

让我们通过将stdin重定向到文件而不是终端,以非交互方式使用read

reader@ubuntu:/tmp$ echo "Something else" > answer-file
reader@ubuntu:/tmp$ read -p "Type something: " new_answer < answer-file
reader@ubuntu:/tmp$ echo ${new_answer}
Something else

为了表明我们没有作弊并重复使用${answer}变量中已经存储的答案,我们已经将read中的回复重命名为${new_answer}

现在,在命令的末尾,我们将stdinanswer-file文件重定向,我们首先使用echo + stdout重定向创建了这个文件。这就像在命令之后添加< answer-file一样简单。

这种重定向使read从文件中读取,直到遇到换行符(这恰好是echo总是以字符串结尾的地方)。

现在基本的输入重定向应该是清楚的了,让我们回到我们的特殊设备:/dev/random/dev/urandom。这两个特殊文件是伪随机数生成器,这是一个复杂的词,用于生成几乎随机的数据。

在这些特殊设备的情况下,它们从设备驱动程序、鼠标移动和其他大部分是随机的东西中收集(一个类似随机性的复杂词)。

/dev/random/dev/urandom之间有一个细微的区别:当系统中的熵不足时,/dev/random停止生成随机输出,而/dev/urandom则继续生成。

如果您真的需要完全的熵,/dev/random可能是更好的选择(老实说,在这种情况下,您可能会采取其他措施),但通常情况下,在您的脚本中,/dev/urandom是更好的选择,因为阻塞可能会导致不可思议的等待时间。这来自第一手经验,可能非常不方便!

对于我们的示例,我们只会展示/dev/urandom/dev/random的输出类似。

在实践中,/dev/urandom随机地产生字节。虽然有些字节在可打印的 ASCII 字符范围内(1-9,a-z,A-Z),但其他字节用于空格(0x20)或换行符(0x0A)。

您可以通过使用head -1/dev/urandom中抓取'第一行'来查看随机性。由于一行以换行符结尾,命令head -1 /dev/urandom将打印直到第一个换行符之前的所有内容:这可能是少量或大量字符:

reader@ubuntu:/tmp$ head -1 /dev/urandom 
~d=G1���RB�Ҫ��"@
                F��OJ2�%�=�8�#,�t�7���M���s��Oѵ�w��k�qݙ����W��E�h��Q"x8��l�d��P�,�.:�m�[Lb/A�J�ő�M�o�v��
                                                                                                        �
reader@ubuntu:/tmp$ head -1 /dev/urandom 
��o�u���'��+�)T�M���K�K����Y��G�g".!{R^d8L��s5c*�.đ�

我们第一次运行时打印了更多的字符(并非所有字符都可读)比第二次运行时;这可以直接与生成的字节的随机性联系起来。第二次我们运行head -1 /dev/urandom时,我们比第一次迭代更快地遇到了换行字节 0x0A。

生成密码

现在,您可能会想知道随机字符可能有什么用。一个主要的例子是生成密码。长而随机的密码总是很好;它们抵抗暴力破解攻击,无法被猜测,并且如果不重复使用,非常安全。而且坦率地说,使用您自己的 Linux 系统的熵来生成随机密码有多酷呢?

更好的是,我们可以使用来自/dev/urandom的输入重定向来完成这个任务,再加上tr命令。一个简单的脚本看起来是这样的:

reader@ubuntu:~/scripts/chapter_12$ vim password-generator.sh 
reader@ubuntu:~/scripts/chapter_12$ cat password-generator.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-06
# Description: Generate a password.
# Usage: ./password-generator.sh <length>
#####################################

# Check for the current number of arguments.
if [[ $# -ne 1 ]]; then
  echo "Wrong number of arguments!"
  echo "Usage: $0 <length>"
  exit 1
fi

# Verify the length argument.
if [[ ! $1 =~ ^[[:digit:]]+$ ]]; then
  echo "Please enter a length (number)."
  exit 1
fi

password_length=$1

# tr grabs readable characters from input, deletes the rest.
# Input for tr comes from /dev/urandom, via input redirection.
# echo makes sure a newline is printed.
tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c ${password_length}
echo

标题和输入检查,甚至包括使用正则表达式检查数字的检查,现在应该是清楚的。

接下来,我们使用tr命令从/dev/urandom重定向输入,以获取我们的 a-z、A-Z 和 0-9 字符集中的可读字符。这些被管道head(本章后面将更多地介绍管道),这会导致前x个字符被打印给用户(如脚本参数中指定的那样)。

为了确保终端格式正确,我们在没有参数的情况下添加了一个快速的echo;这只是打印一个换行符。就像这样,我们建立了我们自己的私人安全离线密码生成器。甚至使用输入重定向!

高级重定向

我们现在已经看到了输入和输出重定向,以及两者的一些实际用途。但是,我们还没有结合这两种重定向形式,这是完全可能的!

您可能不会经常使用这个功能;大多数命令接受输入作为参数,并经常提供一个标志,允许您指定要输出到的文件。但知识就是力量,如果您遇到一个没有这些参数的命令,您知道您可以自己解决这个问题。

在命令行上尝试以下操作,并尝试理解为什么会得到您看到的结果:

reader@ubuntu:~/scripts/chapter_12$ cat stderr.c 
#include <stdio.h>
int main()
{
  // Print messages to stdout and stderr.
  fprintf(stdout, "This is sent to stdout.\n");
  fprintf(stderr, "This is sent to stderr.\n");
  return 0;
}

reader@ubuntu:~/scripts/chapter_12$ grep 'stderr' < stderr.c 
  // Print messages to stdout and stderr.
  fprintf(stderr, "This is sent to stderr.\n");
reader@ubuntu:~/scripts/chapter_12$ grep 'stderr' < stderr.c > /tmp/grep-file
reader@ubuntu:~/scripts/chapter_12$ cat /tmp/grep-file 
  // Print messages to stdout and stderr.
  fprintf(stderr, "This is sent to stderr.\n");

正如您所看到的,我们可以在同一行上使用<>来重定向输入和输出。首先,我们在grep 'stderr' < stderr.c命令中使用了输入重定向的grep(这在技术上也是grep 'stderr' stderr.c所做的)。我们在终端中看到了输出。

接下来,我们在该命令的后面添加了> /tmp/grep-file,这意味着我们将把我们的stdout重定向到/tmp/grep-file文件。我们不再在终端中看到输出,但当我们cat文件时,我们会得到它,所以它成功地写入了文件。

由于我们现在处于本章的高级部分,我们将演示输入重定向放在哪里实际上并不重要:

reader@ubuntu:~/scripts/chapter_12$ < stderr.c grep 'stdout' > /tmp/grep-file-stdout
reader@ubuntu:~/scripts/chapter_12$ cat /tmp/grep-file-stdout 
 // Print messages to stdout and stderr.
 fprintf(stdout, "This is sent to stdout.\n");

在这里,我们在命令的开头指定了输入重定向。对我们来说,当考虑流程时,这似乎是更合乎逻辑的方法,但这会导致实际命令(grep)出现在命令的大致中间,这会破坏可读性。

这在实践中基本上是一个无用的观点,因为我们发现很少有用于输入和输出重定向;即使在这个例子中,我们也只需将命令写成grep 'stdout' stderr.c > /tmp/grep-file-stdout,混乱的构造就消失了。

但真正理解输入和输出的运作方式,以及一些命令如何为你做一些繁重的工作,是值得你花时间去理解的!这些正是你在更复杂的脚本中会遇到的问题,充分理解这一点将为你节省大量的故障排除时间。

重定向重定向

我们已经给你一个重定向重定向过程的概览。最著名的例子是在 Bash 4.x 之前大多数情况下使用的,即将stderr流重定向到stdout流。通过这样做,你可以只用>语法重定向所有输出。

你可以这样实现:

reader@ubuntu:/tmp$ cat /etc/shadow
cat: /etc/shadow: Permission denied
reader@ubuntu:/tmp$ cat /etc/shadow > shadow
cat: /etc/shadow: Permission denied
reader@ubuntu:/tmp$ cat shadow 
#Still empty, since stderr wasn't redirected to the file.
reader@ubuntu:/tmp$ cat /etc/shadow > shadow 2>&1 
#Redirect fd2 to fd1 (stderr to stdout).
reader@ubuntu:/tmp$ cat shadow 
cat: /etc/shadow: Permission denied

记住,你不再需要在 Bash 4.x 中使用这种语法,但是如果你想要使用自定义的文件描述符作为输入/输出流,这将是有用的知识。通过以2>&1结束命令,我们将所有stderr输出(2>)写入stdout描述符(&1)。

我们也可以反过来做:

reader@ubuntu:/tmp$ head -1 /etc/passwd
root:x:0:0:root:/root:/bin/bash
reader@ubuntu:/tmp$ head -1 /etc/passwd 2> passwd
root:x:0:0:root:/root:/bin/bash
reader@ubuntu:/tmp$ cat passwd
#Still empty, since stdout wasn't redirected to the file.
reader@ubuntu:/tmp$ head -1 /etc/passwd 2> passwd 1>&2
#Redirect fd1 to fd2 (stdout to stderr).
reader@ubuntu:/tmp$ cat passwd 
root:x:0:0:root:/root:/bin/bash

所以现在,我们将stderr流重定向到passwd文件。然而,head -1 /etc/passwd命令只提供了一个stdout流;我们看到它被打印到终端而不是文件中。

当我们使用1>&2(也可以写成>&2)时,我们将stdout重定向到stderr。现在它被写入文件,我们可以在那里使用cat命令!

记住,这是高级信息,主要用于你的理论理解以及当你开始使用自定义文件描述符时。对于所有其他输出重定向,还是安全地使用我们之前讨论过的&>语法。

命令替换

虽然在 Linux 意义上并不严格属于重定向,但在我们看来,命令替换是一种功能性重定向的形式:你使用一个命令的输出作为另一个命令的参数。如果我们需要使用输出作为下一个命令的输入,我们会使用管道(正如我们将在几页后看到的),但有时我们只需要将输出放在我们命令中的一个非常特定的位置。

这就是命令替换的用途。我们已经在一些脚本中看到了命令替换:cd $(dirname $0)。简单地说,这做的事情类似于cddirname $0的结果。

dirname $0返回脚本所在的目录(因为$0是脚本的完全限定路径),所以当我们在脚本中使用它时,我们将确保所有操作都相对于脚本所在的目录进行。

如果没有命令替换,我们需要在再次使用它之前将输出存储在某个地方:

dirname $0 > directory-file
cd < directory-file
rm directory-file

虽然这有时会起作用,但这里有一些陷阱:

  • 你需要在你有写权限的地方写一个文件

  • cd之后你需要清理文件

  • 你需要确保文件不会与其他脚本冲突

长话短说,这远非理想的解决方案,最好避免使用。而且由于 Bash 提供了命令替换,使用它并没有真正的缺点。正如我们所见,cd $(dirname $0)中的命令替换为我们处理了这个问题,而不需要我们跟踪文件或变量或任何其他复杂的构造。

命令替换实际上在 Bash 脚本中经常使用。看看以下的例子,我们在其中使用命令替换来实例化和填充一个变量:

reader@ubuntu:~/scripts/chapter_12$ vim simple-password-generator.sh 
reader@ubuntu:~/scripts/chapter_12$ cat simple-password-generator.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-10
# Description: Use command substitution with a variable.
# Usage: ./simple-password-generator.sh
#####################################

# Write a random string to a variable using command substitution.
random_password=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 20)

echo "Your random password is: ${random_password}"

reader@ubuntu:~/scripts/chapter_12$ bash simple-password-generator.sh 
Your random password is: T3noJ3Udf8a2eQbqPiad
reader@ubuntu:~/scripts/chapter_12$ bash simple-password-generator.sh 
Your random password is: wu3zpsrusT5zyvbTxJSn

在这个例子中,我们重用了我们之前的password-generator.sh脚本中的逻辑。这一次,我们不给用户提供输入长度的选项;我们保持简单,假设长度为 20(至少在 2018 年,这是一个相当好的密码长度)。

我们使用命令替换将结果(随机密码)写入一个变量,然后将其echo给用户。

实际上我们可以在一行中完成这个操作:

reader@ubuntu:~/scripts/chapter_12$ echo "Your random password is: $(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 20)"
Your random password is: REzCOa11pA2846fvxsa

然而,正如我们现在已经讨论了很多次,可读性很重要(仍然!)。我们认为在实际使用之前首先将其写入具有描述性名称的变量,可以增加脚本的可读性。

此外,如果我们想要多次使用相同的随机值,我们无论如何都需要一个变量。因此,在这种情况下,脚本中的额外冗长帮助我们并且是可取的。

$(..)的前身是使用反引号,即`字符(在英语国际键盘上的1旁边)。$(cd dirname $0)以前写为`cd dirname $0`。虽然这与新的(更好的)$(..)语法做的事情相同,有两件事经常与反斜线有关:单词拆分和换行。这些都是由空白引起的问题。使用新的语法要容易得多,而且不必担心这样的事情!

进程替换

与命令替换紧密相关的是进程替换。语法如下:

<(command)

它的工作原理与命令替换非常相似,但不是将命令的输出作为字符串发送到某个地方,而是可以将输出作为文件引用。这意味着一些命令,它们不期望字符串,而是期望文件引用,也可以使用动态输入。

虽然太高级了,无法详细讨论,但这里有一个简单的例子,应该能传达要点:

reader@ubuntu:~/scripts/chapter_12$ diff <(ls /tmp/) <(ls /home/)
1,11c1
< directory-file
< grep-file
< grep-file-stdout
< passwd
< shadow
---
> reader

diff命令通常比较两个文件并打印它们的差异。现在,我们使用进程替换,让diff比较ls /tmp/ls /home/的结果,使用<(ls /tmp/)语法。

管道

最后,我们一直期待的管道终于来了。这些近乎神奇的结构在 Linux/Bash 中使用得如此频繁,以至于每个人都应该了解它们。任何比单个命令更复杂的东西几乎总是使用管道来达到解决方案。

现在揭晓大秘密:管道实际上只是将一个命令的stdout连接到另一个命令的stdin

等等,什么?!

绑定 stdout 到 stdin

是的,就是这样。现在你知道了输入和输出重定向的所有知识,这可能有点令人失望。然而,仅仅因为概念简单,并不意味着管道不是极其强大且被广泛使用的。

让我们看一个例子,展示我们如何用管道替换输入/输出重定向:

reader@ubuntu:/tmp$ echo 'Fly into the distance' > file
reader@ubuntu:/tmp$ grep 'distance' < file
Fly into the distance reader@ubuntu:/tmp$ echo 'Fly into the distance' | grep 'distance'Fly into the distance 

对于正常的重定向,我们首先将一些文本写入文件(使用输出重定向),然后将其用作grep的输入。接下来,我们做完全相同的功能性事情,但没有文件作为中间步骤。

基本上,管道语法如下:

command-with-output | command-using-input

你可以在一行中使用多个管道,并且可以使用任何管道和输入/输出重定向的组合,只要它有意义。

通常,当你使用超过两个管道/重定向时,你可以通过额外的行来提高可读性,也许使用命令替换将中间结果写入变量。但是,从技术上讲,你可以让它变得尽可能复杂;只是要注意不要让它变得过于复杂

如前所述,管道将stdout绑定到stdin。你可能已经想到即将出现的问题:stderr!看看这个例子,它展示了输出分为stdoutstderr是如何影响管道的:

reader@ubuntu:~/scripts/chapter_12$ cat /etc/shadow | grep 'denied'
cat: /etc/shadow: Permission denied
reader@ubuntu:~/scripts/chapter_12$ cat /etc/shadow | grep 'denied' > /tmp/empty-file
cat: /etc/shadow: Permission denied #Printed to stderr on terminal.
reader@ubuntu:~/scripts/chapter_12$ cat /etc/shadow | grep 'denied' 2> /tmp/error-file
cat: /etc/shadow: Permission denied #Printed to stderr on terminal.
reader@ubuntu:~/scripts/chapter_12$ cat /tmp/empty-file
reader@ubuntu:~/scripts/chapter_12$ cat /tmp/error-file

起初,这个例子可能会让你感到困惑。让我们一步一步地来弄清楚它。

首先,cat /etc/shadow | grep 'denied'。我们尝试在cat /etc/shadowstdout中查找单词denied。我们实际上并没有找到它,但我们还是在终端上看到了它的打印。为什么?因为尽管stdout被管道传输到grep,但stderr直接发送到我们的终端(并且通过grep)。

如果你通过 SSH 连接到 Ubuntu 18.04,默认情况下,当 grep 成功时,你应该会看到颜色高亮;在这个例子中,你不会遇到这种情况。

下一个命令,cat /etc/shadow | grep 'denied' > /tmp/empty-file,将 grepstdout 重定向到一个文件。由于 grep 没有处理错误消息,文件保持空。

即使我们尝试在最后重定向 stderr,正如在 cat /etc/shadow | grep 'denied' 2> /tmp/error-file 命令中所见,我们仍然不会在文件中得到任何输出。这是因为重定向是顺序的:输出重定向仅适用于 grep,而不适用于 cat

现在,正如输出重定向有一种方法可以重定向 stdoutstderr,管道也有一种方法使用 |& 语法。再次看一下相同的示例,现在使用正确的重定向:

reader@ubuntu:~/scripts/chapter_12$ cat /etc/shadow |& grep 'denied'
cat: /etc/shadow: Permission denied
reader@ubuntu:~/scripts/chapter_12$ cat /etc/shadow |& grep 'denied' > /tmp/error-file
reader@ubuntu:~/scripts/chapter_12$ cat /tmp/error-file 
cat: /etc/shadow: Permission denied
reader@ubuntu:~/scripts/chapter_12$ cat /etc/shadow |& grep 'denied' 2> /tmp/error-file
cat: /etc/shadow: Permission denied
reader@ubuntu:~/scripts/chapter_12$ cat /tmp/error-file

对于第一个命令,如果你启用了颜色语法,你会看到单词 denied 是加粗并着色的(在我们的例子中,是红色)。这意味着现在我们使用 |&grep 确实成功地处理了输出。

接下来,当我们使用 grepstdout 进行重定向时,我们看到我们成功地将输出写入文件。如果我们尝试使用 2> 进行重定向,我们看到它在终端中再次打印,但没有在文件中。这是因为重定向的顺序性质:一旦 grep 成功处理了输入(来自 stderr),grep 就将此输出到 stdout

grep 实际上并不知道输入最初是来自 stderr 流;在它看来,这只是一个需要处理的 stdin。由于对于 grep 来说,成功的处理结果会输出到 stdout,所以最终我们在那里找到它!

如果我们想要安全,并且不需要区分 stdoutstderr 的功能,最安全的方法是像这样使用命令:cat /etc/shadow |& grep 'denied' &> /tmp/file。由于管道和输出重定向都处理 stdoutstderr,我们总能确保所有输出都在我们想要的地方。

实际示例

由于管道的理论现在应该相对简单(当我们讨论输入和输出重定向时,我们已经解决了大部分问题),我们将展示一系列实际示例,这些示例真正展示了管道的强大功能。

记住,管道只对那些接受来自 stdin 输入的命令有效;并非所有命令都如此。如果你将某些内容管道传输到一个完全忽略该输入的命令,你可能会对结果感到失望。

既然我们已经介绍了管道,我们将在本书的其余部分更自由地使用它们。虽然这些示例将展示一些使用管道的方法,但本书的其余部分将包含更多!

又一个密码生成器

因此,我们已经创建了两个密码生成器。既然三是一个神奇的数字,而且这是一个展示管道链的绝佳示例,我们将再创建一个(最后一个,我保证):

reader@ubuntu:~/scripts/chapter_12$ vim piped-passwords.sh
reader@ubuntu:~/scripts/chapter_12$ cat piped-passwords.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-10
# Description: Generate a password, using only pipes.
# Usage: ./piped-passwords.sh
#####################################

password=$(head /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c20)

echo "Your random password is: ${password}"

首先,我们从/dev/urandom获取前 10 行(head的默认行为)。我们将其发送到tr,它将其修剪为我们想要的字符集(因为它也输出不可读的字符)。然后,当我们有一个可用的字符集时,我们再次使用head从中获取前 20 个字符。

如果您只运行head /dev/urandom | tr -dc 'a-zA-Z0-9'几次,您会看到长度不同;这是因为换行字节的随机性。通过从/dev/urandom获取 10 行,没有足够的可读字符来创建 20 个字符的密码的可能性非常小。

(挑战读者:创建一个循环脚本,足够长时间地执行此操作以遇到此情况!)

这个例子说明了几个问题。首先,我们通常可以用几个巧妙的管道实现很多我们想做的事情。其次,多次使用同一个命令并不罕见。顺便说一下,我们也可以选择tail -c20作为链中的最后一个命令,但这与整个命令有很好的对称性!

最后,我们看到了三个不同的密码生成器,实际上它们做的是同样的事情。正如在 Bash 中一样,有很多方法可以实现相同的目标;由你来决定哪一个最适用。就我们而言,可读性和性能应该是这个决定中的两个主要因素。

在脚本中设置密码

您可能想要编写脚本的另一项任务是为本地用户设置密码。虽然从安全角度来看,这并不总是好的做法(尤其是对于个人用户帐户),但它用于功能性帐户(对应于软件的用户,例如运行httpd进程的 Apache 用户)。

这些用户中的大多数不需要密码,但有时他们需要。在这种情况下,我们可以使用带有chpasswd命令的管道来设置他们的密码:

reader@ubuntu:~/scripts/chapter_12$ vim password-setter.sh 
reader@ubuntu:~/scripts/chapter_12$ cat password-setter.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-10
# Description: Set a password using chpasswd.
# Usage: ./password-setter.sh
#####################################

NEW_USER_NAME=bob

# Verify this script is run with root privileges.
if [[ $(id -u) -ne 0 ]]; then
  echo "Please run as root or with sudo!"
  exit 1
fi

# We only need exit status, send all output to /dev/null.
id ${NEW_USER_NAME} &> /dev/null

# Check if we need to create the user.
if [[ $? -ne 0 ]]; then
  # User does not exist, create the user.
  useradd -m ${NEW_USER_NAME}
fi

# Set the password for the user.
echo "${NEW_USER_NAME}:password" | chpasswd

在运行此脚本之前,请记住,这会在您的系统上添加一个用户,其密码非常简单(糟糕)。我们为这个脚本更新了输入消毒:我们使用命令替换来检查脚本是否以 root 权限运行。因为id -u返回用户的数字 ID,对于 root 用户或 sudo 权限,它应该是 0,我们可以使用-ne 0进行比较。

如果我们运行脚本并且用户不存在,我们会在设置该用户的密码之前创建该用户。这是通过将username:password发送到chpasswdstdin,通过管道实现的。请注意,我们使用了-ne 0两次,但用于非常不同的事情:第一次用于比较用户 ID,第二次用于退出状态。

你可能能想到对这个脚本进行多种改进。例如,能够指定用户名和密码而不是这些硬编码的占位值可能是个好主意。此外,在chpasswd命令之后进行健全性检查绝对是个好主意。在当前版本中,脚本没有给用户任何反馈;这是非常糟糕的做法。

看看你是否能解决这些问题,并确保记住,用户提供的任何输入都应该进行彻底检查!如果你真的想挑战自己,可以在一个for循环中为多个用户执行此操作,方法是从文件中获取输入。

需要注意的是,当进程运行时,系统上的任何用户都可以看到它。这通常不是什么大问题,但如果你直接将用户名和密码作为参数提供给脚本,那么这些信息也会对所有人可见。尽管这种情况通常只会持续很短的时间,但它们仍然会暴露。在处理如密码等敏感问题时,始终要牢记安全性。

tee

一个看似为与管道协同工作而创建的命令是tee。手册页上的描述应该能说明大部分情况:

tee - 从标准输入读取并写入到标准输出和文件

因此,本质上,通过管道将某些内容发送到teestdin,允许我们将输出同时保存到终端和文件中。

这在使用交互式命令时通常最有用;它允许你实时跟踪输出,同时也将其写入(日志)文件以便稍后审查。系统更新提供了一个很好的tee使用案例:

sudo apt upgrade -y | tee /tmp/upgrade.log

我们可以通过将所有输出发送到tee,包括stderr,使其变得更好:

sudo apt upgrade -y |& tee /tmp/upgrade.log

输出将看起来像这样:

reader@ubuntu:~/scripts/chapter_12$ sudo apt upgrade -y |& tee /tmp/upgrade.log
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
Reading package lists...
<SNIPPED>
0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
reader@ubuntu:~/scripts/chapter_12$ cat /tmp/upgrade.log 
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
Reading package lists...
<SNIPPED>
0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.

终端输出和日志文件的第一行是一个发送到stderrWARNING;如果你使用的是|而不是|&,那么它就不会被写入日志文件,只会在屏幕上显示。如果你按照建议使用|&,你会发现屏幕上的输出和文件内容是完全匹配的。

默认情况下,tee会覆盖目标文件。与所有重定向形式一样,tee也有一种方式可以追加而不是覆盖:使用--append-a)标志。根据我们的经验,这通常是一个明智的选择,与|&没有太大不同。

尽管tee是命令行工具库中的一个强大工具,它在脚本编写中同样有其用武之地。一旦你的脚本变得更加复杂,你可能希望将部分输出保存到文件中以便稍后审查。然而,为了保持用户对脚本状态的更新,将一些信息打印到终端也可能是个好主意。如果这两种情况重叠,你就需要使用tee来完成任务!

此处文档

本章我们将介绍的最后一个概念是here document。here document,也称为 heredocs,用于向某些命令提供输入,与 stdin 重定向略有不同。值得注意的是,它是向命令提供多行输入的一种简单方法。它使用以下语法:

cat << EOF
input
more input
the last input
EOF

如果你在终端中运行这个,你会看到以下内容:

reader@ubuntu:~/scripts/chapter_12$ cat << EOF
> input
> more input
> the last input
> EOF
input
more input
the last input

<< 语法让 Bash 知道你想要使用一个 heredoc。紧接着,你提供了一个分隔标识符。这可能看起来很复杂,但实际上意味着你提供了一个字符串,该字符串将终止输入。因此,在我们的例子中,我们提供了常用的 EOF(代表结束文件结束)。

现在,如果 heredoc 在输入中遇到与分隔标识符完全匹配的行,它将停止接收进一步的输入。这里有一个更接近的例子来说明这一点:

reader@ubuntu:~/scripts/chapter_12$ cat << end-of-file
> The delimiting identifier is end-of-file
> But it only stops when end-of-file is the only thing on the line
> end-of-file does not work, since it has text after it
> end-of-file
The delimiting identifier is end-of-file
But it only stops when end-of-file is the only thing on the line
end-of-file does not work, since it has text behind it

虽然使用 cat 说明了这一点,但它并不是一个非常实用的例子。然而,wall 命令是。wall 允许你向连接到服务器的每个人广播消息,到他们的终端。当与 heredoc 结合使用时,它看起来有点像这样:

reader@ubuntu:~/scripts/chapter_12$ wall << EOF
> Hi guys, we're rebooting soon, please save your work!
> It would be a shame if you lost valuable time...
> EOF

Broadcast message from reader@ubuntu (pts/0) (Sat Nov 10 16:21:15 2018):

Hi guys, we're rebooting soon, please save your work!
It would be a shame if you lost valuable time...

在这种情况下,我们收到自己的广播。但是,如果你使用你的用户多次连接,你也会在那里看到广播。

尝试使用终端控制台连接和 SSH 连接同时进行;如果你亲眼看到它,你会更好地理解它。

Heredocs 和变量

使用 heredocs 时经常出现的混淆来源是使用变量。默认情况下,变量在 heredoc 中被解析,如下例所示:

reader@ubuntu:~/scripts/chapter_12$ cat << EOF
> Hi, this is $USER!
> EOF
Hi, this is reader!

然而,这可能并不总是理想的功能。你可能想使用它来写入一个文件,其中变量应该在以后解析。

在这种情况下,我们可以引用分隔标识符 EOF 以防止变量被替换:

reader@ubuntu:~/scripts/chapter_12$ cat << 'EOF'
> Hi, this is $USER!
> EOF
Hi, this is $USER!

使用 heredocs 进行脚本输入

由于 heredocs 允许我们简单地将以换行符分隔的输入传递给命令,我们可以使用它以非交互方式运行交互式脚本!我们在实践中使用了这一点,例如,在只能以交互方式运行的数据库安装脚本上。但是,一旦你知道问题的顺序和你想要提供的输入,你就可以使用 heredoc 将此输入提供给该交互式脚本。

更好的是,我们已经创建了一个使用交互式输入的脚本,/home/reader/scripts/chapter_11/while-interactive.sh,我们可以用它来展示这个功能:

reader@ubuntu:/tmp$ head /home/reader/scripts/chapter_11/while-interactive.sh
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.1.0
# Date: 2018-10-28
# Description: A simple riddle in a while loop.
# Usage: ./while-interactive.sh
#####################################

reader@ubuntu:/tmp$ bash /home/reader/scripts/chapter_11/while-interactive.sh << EOF
a mouse  #Try 1.
the sun  #Try 2.
keyboard #Try 3.
EOF

Incorrect, please try again. #Try 1.
Incorrect, please try again. #Try 2.
Correct, congratulations!    #Try 3.
Now we can continue after the while loop is done, awesome!

我们知道脚本会一直运行,直到得到正确答案,即 keyboardKeyboard。我们使用 heredoc 按顺序发送三个答案给脚本:a mousethe sun,最后是 keyboard。我们可以很容易地将输出与输入对应起来。

为了更详细地了解,可以运行带有 heredoc 输入的脚本,并使用 bash -x,这将明确显示谜题有三次尝试。

你可能想在嵌套函数(将在下一章解释)或循环内部使用此处文档。在这两种情况下,你都应该已经使用缩进来提高可读性。然而,这会影响你的 heredoc,因为空白被认为是输入的一部分。如果你发现自己处于这种情况,heredocs 有一个额外的选项:使用<<-而不是<<。当提供额外的-时,所有制表符都会被忽略。这允许你用制表符缩进 heredoc 结构,这既保持了可读性又保持了功能。

此处字符串

本章我们最后要讨论的是此处字符串。它与此处文档非常相似(因此得名),但它处理的是单个字符串,而不是文档(谁会想到呢!)。

这种使用<<<语法的结构,可以用来向可能通常只接受来自stdin或文件输入的命令提供文本输入。一个很好的例子是bc,它是一个简单的计算器(属于 GNU 项目的一部分)。

通常,你以两种方式使用它:通过管道将输入发送到stdin,或者通过指向bc到一个文件:

reader@ubuntu:/tmp$ echo "2^8" | bc
256

reader@ubuntu:/tmp$ echo "4*4" > math
reader@ubuntu:/tmp$ bc math
bc 1.07.1
Copyright 1991-1994, 1997, 1998, 2000, 2004, 2006, 2008, 2012-2017 Free Software Foundation, Inc.
This is free software with ABSOLUTELY NO WARRANTY.
For details type `warranty'. 
16
^C
(interrupt) use quit to exit.
quit

当与stdin一起使用时,bc返回计算结果。当与文件一起使用时,bc打开一个交互式会话,我们需要手动关闭它,方法是输入quit。这两种方式似乎对于我们想要实现的目标来说有点过于繁琐。

让我们看看此处字符串是如何解决这个问题的:

reader@ubuntu:/tmp$ bc <<< 2^8
256

就是这样。只是一个简单的输入字符串(发送到命令的stdin),我们得到了与使用管道的echo相同的功能。但是,现在只是一个命令,而不是一个链。简单但有效,正是我们喜欢的方式!

总结

这一章几乎解释了关于 Linux 上重定向的所有知识。我们从对重定向的一般描述开始,以及如何使用文件描述符来促进重定向。我们了解到文件描述符 0、1 和 2 分别用于stdinstdoutstderr

然后我们熟悉了重定向的语法。这包括>2>&><,以及它们的追加语法,>>2>>&>><<

我们讨论了一些特殊的 Linux 设备,/dev/null/dev/zero/dev/urandom。我们展示了如何使用这些设备来删除输出、生成空字节和生成随机数据的示例。在高级重定向部分,我们展示了我们可以将stdout绑定到stderr,反之亦然。

此外,我们了解了命令替换进程替换,它允许我们在另一个命令的参数中使用命令的结果,或者作为文件。

接下来是管道。管道是简单但非常强大的 Bash 结构,用于将一个命令的stdout(可能还有stderr)连接到另一个命令的stdin。这使我们能够链接命令,通过尽可能多的命令来进一步操作数据流。

我们还介绍了tee,它允许我们将流发送到我们的终端和一个文件,这种结构通常用于日志文件。

最后,我们解释了文档字符串。这些概念允许我们将多行和单行输入直接从终端发送到其他命令的stdin,否则需要echocat

本章介绍了以下命令:diffgccfallocatetrchpasswdteebc

问题

  1. 文件描述符是什么?

  2. 术语stdinstdoutstderr是什么意思?

  3. stdinstdoutstderr如何映射到默认文件描述符?

  4. >1>2>之间的输出重定向有什么区别?

  5. >>>之间有什么区别?

  6. 如何同时重定向stdoutstderr

  7. 哪些特殊设备可以用作输出的黑洞?

  8. 管道在重定向方面有什么作用?

  9. 我们如何将输出发送到终端和日志文件?

  10. here string 的典型用例是什么?

进一步阅读

第十三章:函数

在本章中,我们将解释 Bash 脚本的一个非常实用的概念:函数。我们将展示它们是什么,我们如何使用它们,以及为什么我们想要使用它们。

在介绍了函数的基础知识之后,我们将进一步探讨函数如何具有自己的输入和输出。

将描述函数库的概念,并且我们将开始构建自己的个人函数库,其中包含各种实用函数。

本章将介绍以下命令:topfreedeclarecaserevreturn

本章将涵盖以下主题:

  • 函数解释

  • 使用参数增强函数

  • 函数库

技术要求

本章的所有脚本都可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Linux-Shell-Scripting-Fundamentals-of-Bash-4.4/tree/master/Chapter13。除了您的 Ubuntu Linux 虚拟机外,在本章的示例中不需要其他资源。对于 argument-checker.sh、functions-and-variables.sh、library-redirect-to-file.sh 脚本,只能在网上找到最终版本。在执行脚本之前,请务必验证头部中的脚本版本。

函数解释

在本章中,我们将讨论函数以及这些如何增强你的脚本。函数的理论并不太复杂:函数是一组命令,可以被多次调用(执行),而无需再次编写整组命令。一如既往,一个好的例子胜过千言万语,所以让我们立即用我们最喜欢的例子之一来深入研究:打印“Hello world!”。

Hello world!

我们现在知道,相对容易让单词“Hello world!”出现在我们的终端上。简单的echo "Hello world!"就可以做到。然而,如果我们想要多次这样做,我们该怎么做呢?你可以建议使用任何一种循环,这确实可以让我们多次打印。然而,该循环还需要一些额外的代码和提前规划。正如你将注意到的,实际上循环非常适合迭代项目,但并不完全适合以可预测的方式重用代码。让我们看看我们如何使用函数来代替这样做:

reader@ubuntu:~/scripts/chapter_13$ vim hello-world-function.sh
reader@ubuntu:~/scripts/chapter_13$ cat hello-world-function.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-11
# Description: Prints "Hello world!" using a function.
# Usage: ./hello-world-function.sh
#####################################

# Define the function before we call it.
hello_world() {
  echo "Hello world!"
}

# Call the function we defined earlier:
hello_world

reader@ubuntu:~/scripts/chapter_13$ bash hello-world-function.sh 
Hello world!

正如你所看到的,我们首先定义了函数,这只不过是写下应该在函数被调用时执行的命令。在脚本的末尾,你可以看到我们通过输入函数名来执行函数,就像执行任何其他命令一样。重要的是要注意,只有在之前定义了函数的情况下,你才能调用函数。这意味着整个函数定义需要在脚本中的调用之前。现在,我们将把所有函数放在脚本中的第一项。在本章的后面,我们将向你展示如何更有效地使用它。

在上一个例子中,你看到的是 Bash 中函数定义的两种可能语法中的第一种。如果我们只提取函数,语法如下:

function_name() {
   indented-commands
   further-indented-commands-as-needed
 }

第二种可能的语法,我们不太喜欢,是这样的:

function function_name {
   indented-commands
   further-indented-commands-as-needed
 }

两种语法的区别在于函数名之前没有function一词,或者在函数名后没有()。我们更喜欢第一种语法,它使用()符号,因为它更接近其他脚本/编程语言的符号,并且对大多数人来说应该更容易识别。而且,作为额外的奖励,它比第二种符号更短、更简单。正如你所期望的,我们将在本书的其余部分继续使用第一种符号;第二种符号是为了完整性而呈现的(如果你在研究脚本时在网上遇到它,了解它总是方便的!)。

记住,我们使用缩进来向脚本的读者传达命令嵌套的信息。在这种情况下,由于函数中的所有命令只有在调用函数时才运行,我们用两个空格缩进它们,这样就清楚地表明我们在函数内部。

更复杂

函数可以有尽可能多的命令。在我们简单的例子中,我们只添加了一个echo,然后只调用了一次。虽然这对于抽象来说很好,但并不真正需要创建一个函数(尚未)。让我们看一个更复杂的例子,这将让您更好地了解为什么在函数中抽象命令是一个好主意:

reader@ubuntu:~/scripts/chapter_13$ vim complex-function.sh 
reader@ubuntu:~/scripts/chapter_13$ cat complex-function.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-11
# Description: A more complex function that shows why functions exist.
# Usage: ./complex-function.sh
#####################################

# Used to print some current data on the system.
print_system_status() {
  date # Print the current datetime.
  echo "CPU in use: $(top -bn1 | grep Cpu | awk '{print $2}')"
  echo "Memory in use: $(free -h | grep Mem | awk '{print $3}')"
  echo "Disk space available on /: $(df -k / | grep / | awk '{print $4}')" 
  echo # Extra newline for readability.
}

# Print the system status a few times.
for ((i=0; i<5; i++)); do
  print_system_status
  sleep 5
done

现在我们在谈论!这个函数有五个命令,其中三个包括使用链式管道的命令替换。现在,我们的脚本开始变得复杂而强大。正如您所看到的,我们使用()符号来定义函数。然后我们在 C 风格的for循环中调用这个函数,这会导致脚本在每次系统状态之间暂停五秒钟后打印系统状态五次(由于sleep,我们在第十一章中看到过,条件测试和脚本循环)。当您运行这个脚本时,它应该看起来像这样:

reader@ubuntu:~/scripts/chapter_13$ bash complex-function.sh 
Sun Nov 11 13:40:17 UTC 2018
CPU in use: 0.1
Memory in use: 85M
Disk space available on /: 4679156

Sun Nov 11 13:40:22 UTC 2018
CPU in use: 0.2
Memory in use: 84M
Disk space available on /: 4679156

除了日期之外,其他输出发生显着变化的可能性很小,除非您有其他进程在运行。然而,函数的目的应该是清楚的:以透明的方式定义和抽象一组功能。

虽然不是本章的主题,但我们在这里使用了一些新命令。topfree命令通常用于检查系统的性能,并且可以在没有任何参数的情况下使用(top打开全屏,您可以使用Ctrl C退出)。在本章的进一步阅读部分,您可以找到有关 Linux 中这些(和其他)性能监控工具的更多信息。我们还在那里包括了awk的入门知识。

使用函数有许多优点;其中包括但不限于以下内容:

  • 易于重用代码

  • 允许代码共享(例如通过库)

  • 将混乱的代码抽象为简单的函数调用

函数中的一个重要事项是命名。函数名应尽可能简洁,但仍需要告诉用户它的作用。例如,如果您将一个函数命名为function1这样的非描述性名称,任何人怎么知道它的作用呢?将其与我们在示例中看到的名称进行比较:print_system_status。虽然也许不完美(什么是系统状态?),但至少指引我们朝着正确的方向(如果您同意 CPU、内存和磁盘使用率被认为是系统状态的一部分的话)。也许函数的一个更好的名称是print_cpu_mem_disk。这取决于您的决定!确保在做出这个选择时考虑目标受众是谁;这通常会产生最大的影响。

虽然在函数命名中描述性非常重要,但遵守命名约定也同样重要。当我们处理变量命名时,我们已经在第八章中提出了同样的考虑。重申一下:最重要的规则是保持一致。如果您想要我们对函数命名约定的建议,那就坚持我们为变量制定的规则:小写,用下划线分隔。这就是我们在之前的例子中使用的方式,也是我们将在本书的其余部分继续展示的方式。

变量作用域

虽然函数很棒,但我们之前学到的一些东西在函数的范围内需要重新考虑,尤其是变量。我们知道变量存储的信息可以在脚本的多个地方多次访问或改变。然而,我们还没有学到的是变量总是有一个作用域。默认情况下,变量的作用域是全局的,这意味着它们可以在脚本的任何地方使用。随着函数的引入,还有一个新的作用域:局部。局部变量在函数内部定义,并随着函数调用而存在和消失。让我们看看这个过程:

reader@ubuntu:~/scripts/chapter_13$ vim functions-and-variables.sh
reader@ubuntu:~/scripts/chapter_13$ cat functions-and-variables.sh 
#!/bin/bash
#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-11
# Description: Show different variable scopes.
# Usage: ./functions-and-variables.sh <input>
#####################################

# Check if the user supplied at least one argument.
if [[ $# -eq 0 ]]; then
  echo "Missing an argument!"
  echo "Usage: $0 <input>"
  exit 1
fi

# Assign the input to a variable.
input_variable=$1
# Create a CONSTANT, which never changes.
CONSTANT_VARIABLE="constant"

# Define the function.
hello_variable() {
  echo "This is the input variable: ${input_variable}"
  echo "This is the constant: ${CONSTANT_VARIABLE}"
}

# Call the function.
hello_variable
reader@ubuntu:~/scripts/chapter_13$ bash functions-and-variables.sh teststring
This is the input variable: teststring
This is the constant: constant

到目前为止,一切都很好。我们可以在函数中使用我们的全局常量。这并不令人惊讶,因为它不是轻易被称为全局变量;它可以在脚本的任何地方使用。现在,让我们看看当我们在函数中添加一些额外的变量时会发生什么:

#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.1.0
# Date: 2018-11-11
# Description: Show different variable scopes.
# Usage: ./functions-and-variables.sh <input>
#####################################
<SNIPPED>
# Define the function.
hello_variable() {
 FUNCTION_VARIABLE="function variable text!"
  echo "This is the input variable: ${input_variable}"
  echo "This is the constant: ${CONSTANT_VARIABLE}"
 echo "This is the function variable: ${FUNCTION_VARIABLE}"
}

# Call the function.
hello_variable

# Try to call the function variable outside the function.
echo "Function variable outside function: ${FUNCTION_VARIABLE}"

你认为现在会发生什么?试一试:

reader@ubuntu:~/scripts/chapter_13$ bash functions-and-variables.sh input
This is the input variable: input
This is the constant: constant
This is the function variable: function variable text!
Function variable outside function: function variable text!

与你可能怀疑的相反,我们在函数内部定义的变量实际上仍然是一个全局变量(对于欺骗你感到抱歉!)。如果我们想要使用局部作用域变量,我们需要添加内置的 local shell:

#!/bin/bash
#####################################
# Author: Sebastiaan Tammer
# Version: v1.2.0
# Date: 2018-11-11
# Description: Show different variable scopes.
# Usage: ./functions-and-variables.sh <input>
#####################################
<SNIPPED>
# Define the function.
hello_variable() {
 local FUNCTION_VARIABLE="function variable text!"
  echo "This is the input variable: ${input_variable}"
  echo "This is the constant: ${CONSTANT_VARIABLE}"
  echo "This is the function variable: ${FUNCTION_VARIABLE}"
}
<SNIPPED>

现在,如果我们这次执行它,我们实际上会看到脚本在最后一个命令上表现不佳:

reader@ubuntu:~/scripts/chapter_13$ bash functions-and-variables.sh more-input
This is the input variable: more-input
This is the constant: constant
This is the function variable: function variable text!
Function variable outside function: 

由于局部添加,我们现在只能在函数内部使用变量及其内容。因此,当我们调用hello_variable函数时,我们看到变量的内容,但当我们尝试在函数外部打印它时,在echo "Function variable outside function: ${FUNCTION_VARIABLE}"中,我们看到它是空的。这是预期的和理想的行为。实际上,你可以做的,有时确实很方便,是这样的:

#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.3.0
# Date: 2018-11-11
# Description: Show different variable scopes.
# Usage: ./functions-and-variables.sh <input>
#####################################
<SNIPPED>
# Define the function.
hello_variable() {
 local CONSTANT_VARIABLE="maybe not so constant?"
  echo "This is the input variable: ${input_variable}"
  echo "This is the constant: ${CONSTANT_VARIABLE}"
}

# Call the function.
hello_variable

# Try to call the function variable outside the function.
echo "Function variable outside function: ${CONSTANT_VARIABLE}"

现在,我们已经定义了一个与我们已经初始化的全局作用域变量同名的局部作用域变量!你可能已经对接下来会发生什么有所想法,但一定要运行脚本并理解为什么会发生这种情况:

reader@ubuntu:~/scripts/chapter_13$ bash functions-and-variables.sh last-input
This is the input variable: last-input
This is the constant: maybe not so constant?
Function variable outside function: constant

所以,当我们在函数中使用CONSTANT_VARIABLE变量(记住,常量仍然被认为是变量,尽管是特殊的变量)时,它打印了局部作用域变量的值:也许不那么常量?。当在函数外,在脚本的主体部分,我们再次打印变量的值时,我们得到了最初定义的值:constant

你可能很难想象这种情况的用例。虽然我们同意你可能不经常使用这个,但它确实有它的用处。例如,想象一个复杂的脚本,其中一个全局变量被多个函数和命令顺序使用。现在,你可能会遇到这样一种情况,你需要变量的值,但稍微修改一下才能在函数中正确使用它。你还知道后续的函数/命令需要原始值。现在,你可以将内容复制到一个新变量中并使用它,但是通过在函数内部覆盖变量,你让读者/用户更清楚地知道你有一个目的;这是一个经过深思熟虑的决定,你知道你需要这个例外仅仅是为了那个函数。使用局部作用域变量(最好还加上注释,像往常一样)将确保可读性!

变量可以通过使用内置的declare shell 设置为只读。如果你查看帮助,使用help declare,你会看到它被描述为“设置变量值和属性”。通过用declare -r CONSTANT=VALUE替换CONSTANT=VALUE,可以创建一个只读变量,比如常量。如果你这样做,你就不能再(临时)用本地实例覆盖变量;Bash 会给你一个错误。实际上,就我们遇到的情况而言,declare命令并没有被使用得太多,但它除了只读声明之外还可以有其他有用的用途,所以一定要看一看!

实际例子

在本章的下一部分介绍函数参数之前,我们将首先看一个不需要参数的函数的实际示例。我们将回到我们之前创建的脚本,并查看是否有一些功能可以抽象为一个函数。剧透警告:有一个很棒的功能,涉及到一点叫做错误处理的东西!

错误处理

在第九章中,错误检查和处理,我们创建了以下结构:command || { echo "Something went wrong."; exit 1; }。正如你(希望)记得的那样,||语法意味着只有在左侧命令的退出状态不是0时,右侧的所有内容才会被执行。虽然这种设置运行良好,但并没有增加可读性。如果我们能将错误处理抽象为一个函数,并调用该函数,那将会更好!让我们就这样做:

reader@ubuntu:~/scripts/chapter_13$ vim error-functions.sh
reader@ubuntu:~/scripts/chapter_13$ cat error-functions.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-11
# Description: Functions to handle errors.
# Usage: ./error-functions.sh
#####################################

# Define a function that handles minor errors.
handle_minor_error() {
 echo "A minor error has occured, please check the output."
}

# Define a function that handles fatal errors.
handle_fatal_error() {
 echo "A critical error has occured, stopping script."
 exit 1
}

# Minor failures.
ls -l /tmp/ || handle_minor_error
ls -l /root/ || handle_minor_error 

# Fatal failures.
cat /etc/shadow || handle_fatal_error
cat /etc/passwd || handle_fatal_error

这个脚本定义了两个函数:handle_minor_errorhandle_fatal_error。对于轻微的错误,我们会打印一条消息,但脚本的执行不会停止。然而,致命错误被认为是如此严重,以至于脚本的流程预计会被中断;在这种情况下,继续执行脚本是没有用的,所以我们会确保函数停止它。通过使用这些函数与||结构,我们不需要在函数内部检查退出码;我们只有在退出码不是0时才会进入函数,所以我们已经知道我们处于错误的情况中。在执行这个脚本之前,花点时间反思一下我们通过这些函数改进了多少可读性。当你完成后,用调试输出运行这个脚本,这样你就可以跟踪整个流程。

reader@ubuntu:~/scripts/chapter_13$ bash -x error-functions.sh 
+ ls -l /tmp/
total 8
drwx------ 3 root root 4096 Nov 11 11:07 systemd-private-869037dc...
drwx------ 3 root root 4096 Nov 11 11:07 systemd-private-869037dc...
+ ls -l /root/
ls: cannot open directory '/root/': Permission denied
+ handle_minor_error
+ echo 'A minor error has occured, please check the output.'
A minor error has occured, please check the output.
+ cat /etc/shadow
cat: /etc/shadow: Permission denied
+ handle_fatal_error
+ echo 'A critical error has occured, stopping script.'
A critical error has occured, stopping script.
+ exit 1

正如你所看到的,第一个命令ls -l /tmp/成功了,我们看到了它的输出;我们没有进入handle_minor_error函数。下一个命令,我们确实希望它失败,它的确失败了。我们看到现在我们进入了函数,并且我们在那里指定的错误消息被打印出来。但是,由于这只是一个轻微的错误,我们继续执行脚本。然而,当我们到达cat /etc/shadow时,我们认为这是一个重要的组件,我们遇到了一个Permission denied的消息,导致脚本执行handle_fatal_error。因为这个函数有一个exit 1,脚本被终止,第四个命令就不会被执行。这应该说明另一个观点:一个exit,即使在函数内部,也是全局的,会终止脚本(不仅仅是函数)。如果你希望看到这个脚本成功,用sudo bash error-functions.sh来运行它。你会看到两个错误函数都没有被执行。

用参数增强函数

正如脚本可以接受参数的形式输入一样,函数也可以。实际上,大多数函数都会使用参数。静态函数,比如之前的错误处理示例,不如它们的参数化对应函数强大或灵活。

丰富多彩

在下一个示例中,我们将创建一个脚本,允许我们以几种不同的颜色打印文本到我们的终端。它基于一个具有两个参数的函数来实现:stringcolor。看一下以下命令:

reader@ubuntu:~/scripts/chapter_13$ vim colorful.sh 
reader@ubuntu:~/scripts/chapter_13$ cat colorful.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-17
# Description: Some printed text, now with colors!
# Usage: ./colorful.sh
#####################################

print_colored() {
  # Check if the function was called with the correct arguments.
  if [[ $# -ne 2 ]]; then
    echo "print_colored needs two arguments, exiting."
    exit 1
  fi

  # Grab both arguments.
  local string=$1
  local color=$2

  # Use a case-statement to determine the color code.
  case ${color} in
  red)
    local color_code="\e[31m";;
  blue)
    local color_code="\e[34m";;
  green)
    local color_code="\e[32m";;
  *)
    local color_code="\e[39m";; # Wrong color, use default.
  esac

  # Perform the echo, and reset color to default with [39m.
  echo -e ${color_code}${string}"\e[39m"
}

# Print the text in different colors.
print_colored "Hello world!" "red"
print_colored "Hello world!" "blue"
print_colored "Hello world!" "green"
print_colored "Hello world!" "magenta"

这个脚本中发生了很多事情。为了帮助你理解,我们将逐步地逐个部分地进行讲解,从函数定义的第一部分开始:

print_colored() {
  # Check if the function was called with the correct arguments.
  if [[ $# -ne 2 ]]; then
    echo "print_colored needs two arguments, exiting."
    exit 1
  fi

  # Grab both arguments.
  local string=$1
  local color=$2

在函数体内部,我们首先检查参数的数量。语法与我们通常对整个脚本传递的参数进行检查的方式相同,这可能会有所帮助,也可能会有些困惑。一个要意识到的好事是,$#结构适用于其所在的范围;如果它在主脚本中使用,它会检查那里传递的参数。如果像这里一样在函数内部使用,它会检查传递给函数的参数数量。对于$1$2等也是一样:如果在函数内部使用,它们指的是传递给函数的有序参数,而不是一般脚本中的参数。当我们获取参数时,我们将它们写入本地变量;在这个简单的脚本中,我们不一定需要这样做,但是在本地范围内使用变量时,将变量标记为本地总是一个好习惯。您可能会想象,在更大、更复杂的脚本中,许多函数使用可能会意外地被称为相同的东西(在这种情况下,string是一个非常常见的词)。通过将它们标记为本地,您不仅提高了可读性,还防止了由具有相同名称的变量引起的错误;总的来说,这是一个非常好的主意。让我们回到脚本的下一部分,即case语句:

  # Use a case-statement to determine the color code.
  case ${color} in
  red)
    color_code="\e31m";;
  blue)
    color_code="\e[34m";;
  green)
    color_code="\e[32m";;
  *)
    color_code="\e[39m";; # Wrong color, use default.
  esac

现在是介绍case的绝佳时机。case语句基本上是一个非常长的if-then-elif-then-elif-then...链。变量的选项越多,链条就会变得越长。使用case,您只需说对于${variable}中的特定值,执行<某些操作>。在我们的例子中,这意味着如果${color}变量是red,我们将设置另一个color_code变量为\e[31m(稍后会详细介绍)。如果它是blue,我们将执行其他操作,对于green也是一样。最后,我们将定义一个通配符;未指定的变量值将通过这里,作为一种通用的构造。如果指定的颜色是一些不兼容的东西,比如dog,我们将只设置默认颜色。另一种选择是中断脚本,这对于错误的颜色有点反应过度。要终止case,您将使用esac关键字(这是case的反义词),类似于if被其反义词fi终止的方式。

现在,让我们来谈谈终端上的颜色的技术方面。虽然我们学到的大多数东西都是关于 Bash 或 Linux 特定的,但打印颜色实际上是由您的终端仿真器定义的。我们正在使用的颜色代码非常标准,应该被您的终端解释为不要字面打印这个字符,而是改变颜色<颜色>。终端看到一个转义序列\e,后面跟着一个颜色代码[31m,并且知道您正在指示它打印一个与之前定义的颜色不同的颜色(通常是该终端仿真器的默认设置,除非您自己更改了颜色方案)。您可以使用转义序列做更多的事情(当然,只要您的终端仿真器支持),比如创建粗体文本、闪烁文本,以及为文本设置另一个背景颜色。现在,请记住*\e[31m 序列不会被打印,而是被解释*。对于case中的通配符,您不想显式设置颜色,而是向终端发出信号,以使用默认颜色打印。这意味着对于每个兼容的终端仿真器,文本都以用户选择的颜色(或默认分配的颜色)打印。

现在是脚本的最后部分:

  # Perform the echo, and reset color to default with [39m.
  echo -e ${color_code}${string}"\e[39m"
}

# Print the text in different colors.
print_colored "Hello world!" "red"
print_colored "Hello world!" "blue"
print_colored "Hello world!" "green"
print_colored "Hello world!" "magenta"

print_colored函数的最后一部分实际上打印了有颜色的文本。它通过使用带有-e标志的老式echo来实现这一点。man echo显示-e启用反斜杠转义的解释。如果您不指定此选项,您的输出将只是类似于\e[31mHello world!\e[39m。在这种情况下需要知道的一件好事是,一旦您的终端遇到颜色代码转义序列,随后的所有文本都将以该颜色打印!因此,我们用"\e[39m"结束 echo,将所有后续文本的颜色重置为默认值。

最后,我们多次调用函数,第一个参数相同,但第二个参数(颜色)不同。如果您运行脚本,输出应该类似于这样:

![

在前面的截图中,我的颜色方案设置为绿底黑字,这就是为什么最后的Hello world!是鲜绿色的原因。您可以看到它与bash colorful.sh的颜色相同,这应该足以让您确信[39m颜色代码实际上是默认值。

返回值

有些功能遵循处理器原型:它们接受输入,对其进行处理,然后将结果返回给调用者。这是经典功能的一部分:根据输入,生成不同的输出。我们将通过一个示例来展示这一点,该示例将用户指定的输入反转为脚本。通常使用rev命令来完成这个功能(实际上我们的函数也将使用rev来实现),但我们将创建一个包装函数,增加一些额外的功能:

reader@ubuntu:~/scripts/chapter_13$ vim reverser.sh 
reader@ubuntu:~/scripts/chapter_13$ cat reverser.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-17
# Description: Reverse the input for the user.
# Usage: ./reverser.sh <input-to-be-reversed>
#####################################

# Check if the user supplied one argument.
if [[ $# -ne 1 ]]; then
  echo "Incorrect number of arguments!"
  echo "Usage: $0 <input-to-be-reversed>"
  exit 1
fi

# Capture the user input in a variable.
user_input="_${1}_" # Add _ for readability.

# Define the reverser function.
reverser() {
  # Check if input is correctly passed.
  if [[ $# -ne 1 ]]; then
    echo "Supply one argument to reverser()!" && exit 1
  fi

  # Return the reversed input to stdout (default for rev).
  rev <<< ${1}
}

# Capture the function output via command substitution.
reversed_input=$(reverser ${user_input})

# Show the reversed input to the user.
echo "Your reversed input is: ${reversed_input}"

由于这又是一个更长、更复杂的脚本,我们将逐步查看它,以确保您完全理解。我们甚至在其中加入了一个小惊喜,证明了我们之前的说法之一,但我们稍后再谈。我们将跳过标题和输入检查,转而捕获变量:

# Capture the user input in a variable.
user_input="_${1}_" # Add _ for readability.

在以前的大多数示例中,我们总是直接将输入映射到变量。但是,这一次我们要表明您实际上也可以添加一些额外的文本。在这种情况下,我们通过用户输入并在其前后添加下划线。如果用户输入rain,那么变量实际上将包含_rain_。这将在后面证明有洞察力。现在,对于函数定义,我们使用以下代码:

# Define the reverser function.
reverser() {
  # Check if input is correctly passed.
  if [[ $# -ne 1 ]]; then
    echo "Supply one argument to reverser()!" && exit 1
  fi

  # Return the reversed input to stdout (default for rev).
  rev <<< ${1}
}

reverser函数需要一个参数:要反转的输入。与往常一样,我们首先检查输入是否正确,然后再执行任何操作。接下来,我们使用rev来反转输入。但是,rev通常期望从文件或stdin中获取输入,而不是作为参数的变量。因为我们不想添加额外的 echo 和管道,所以我们使用这里字符串(如第十二章中所述,在脚本中使用管道和重定向),它允许我们直接使用变量内容作为stdin。由于rev已经将结果输出到stdout,所以在那一点上我们不需要提供任何东西,比如 echo。

我们告诉过您我们将证明之前的说法,这在这种情况下与前面的片段中的$1有关。如果函数中的$1与脚本的第一个参数相关,而不是函数的第一个参数,那么我们在编写user_input变量时添加的下划线就不会出现。对于脚本,$1可能等于rain,而对于函数,$1等于_rain_。当您运行脚本时,您肯定会看到下划线,这意味着每个函数实际上都有自己的一组参数!

将所有内容绑在一起的是脚本的最后一部分:

# Capture the function output via command substitution.
reversed_input=$(reverser ${user_input})

# Show the reversed input to the user.
echo "Your reversed input is: ${reversed_input}"

由于reverser函数将反转的输入发送到stdout,我们将使用命令替换来将其捕获到一个变量中。最后,我们打印一些澄清文本和反转的输入给用户看。结果将如下所示:

reader@ubuntu:~/scripts/chapter_13$ bash reverser.sh rain
Your reversed input is: _niar_

下划线和所有,我们得到了rain的反转:_nair_。不错!

为了避免太多复杂性,我们将这个脚本的最后部分分成两行。但是,一旦你对命令替换感到舒适,你可以省去中间变量,并直接在 echo 中使用命令替换,就像这样:echo "Your reversed input is: $(reverser ${user_input})"。然而,我们建议不要让它变得比这更复杂,因为那将开始影响可读性。

函数库

当你到达书的这一部分时,你会看到超过 50 个示例脚本。这些脚本中有许多共享组件:输入检查、错误处理和设置当前工作目录在多个脚本中都被使用过。这段代码实际上并没有真正改变;也许注释或回显略有不同,但实际上只是重复的代码。再加上在脚本顶部定义函数的问题(或者至少在开始使用它们之前),你的可维护性就开始受到影响。幸运的是,我们有一个很好的解决方案:创建你自己的函数库!

函数库的想法是你定义的函数在不同的脚本之间是共享的。这些是可重复使用的通用函数,不太关心特定脚本的工作。当你创建一个新脚本时,你会在头部之后包含来自库的函数定义。库只是另一个 shell 脚本:但它只用于定义函数,所以它从不调用任何东西。如果你运行它,最终结果将与运行一个空脚本的结果相同。在我们看如何包含它之前,我们将首先创建我们自己的函数库。

创建函数库时只有一个真正的考虑:放在哪里。你希望它在你的文件系统中只出现一次,最好是在一个可预测的位置。就个人而言,我们更喜欢/opt/目录。然而,默认情况下/opt/只对root用户可写。在多用户系统中,把它放在那里可能不是一个坏主意,由root拥有并被所有人可读,但由于这是一个单用户情况,我们将直接把它放在我们的主目录下。让我们从那里开始建立我们的函数库:

reader@ubuntu:~$ vim bash-function-library.sh 
reader@ubuntu:~$ cat bash-function-library.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-17
# Description: Bash function library.
# Usage: source ~/bash-function-library.sh
#####################################

# Check if the number of arguments supplied is exactly correct.
check_arguments() {
  # We need at least one argument.
  if [[ $# -lt 1 ]]; then
    echo "Less than 1 argument received, exiting."
    exit 1
  fi  

  # Deal with arguments
  expected_arguments=$1
  shift 1 # Removes the first argument.

  if [[ ${expected_arguments} -ne $# ]]; then
    return 1 # Return exit status 1.
  fi
}

因为这是一个通用函数,我们需要首先提供我们期望的参数数量,然后是实际的参数。在保存期望的参数数量后,我们使用shift将所有参数向左移动一个位置:$2变成$1$3变成$2$1被完全移除。这样做,只有要检查的参数数量保留下来,期望的数量安全地存储在一个变量中。然后我们比较这两个值,如果它们不相同,我们返回退出码1return类似于exit,但它不会停止脚本执行:如果我们想要这样做,调用函数的脚本应该处理这个问题。

要在另一个脚本中使用这个库函数,我们需要包含它。在 Bash 中,这称为sourcing。使用source命令来实现:

source <file-name>

语法很简单。一旦你source一个文件,它的所有内容都将被处理。在我们的库的情况下,当我们只定义函数时,不会执行任何内容,但我们将拥有这些函数。如果你source一个包含实际命令的文件,比如echocatmkdir,这些命令将被执行。就像往常一样,一个例子胜过千言万语,所以让我们看看如何使用source来包含库函数:

reader@ubuntu:~/scripts/chapter_13$ vim argument-checker.sh
reader@ubuntu:~/scripts/chapter_13$ cat argument-checker.sh
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-17
# Description: Validates the check_arguments library function
# Usage: ./argument-checker.sh
#####################################

source ~/bash-function-library.sh

check_arguments 3 "one" "two" "three" # Correct.
check_arguments 2 "one" "two" "three" # Incorrect.
check_arguments 1 "one two three" # Correct.

很简单对吧?我们使用完全合格的路径(是的,即使~是简写,这仍然是完全合格的!)来包含文件,并继续使用在其他脚本中定义的函数。如果你以调试模式运行它,你会看到函数按我们的期望工作:

reader@ubuntu:~/scripts/chapter_13$ bash -x argument-checker.sh 
+ source /home/reader/bash-function-library.sh
+ check_arguments 3 one two three
+ [[ 4 -lt 1 ]]
+ expected_arguments=3
+ shift 1
+ [[ 3 -ne 3 ]]
+ check_arguments 2 one two three
+ [[ 4 -lt 1 ]]
+ expected_arguments=2
+ shift 1
+ [[ 2 -ne 3 ]]
+ return 1
+ check_arguments 1 'one two three'
+ [[ 2 -lt 1 ]]
+ expected_arguments=1
+ shift 1
+ [[ 1 -ne 1 ]]

第一个和第三个函数调用预期是正确的,而第二个应该失败。因为我们在函数中使用了return而不是exit,所以即使第二个函数调用返回了1的退出状态,脚本仍会继续执行。正如调试输出所示,第二次调用函数时,执行了2 不等于 3的评估并成功,导致了return 1。对于其他调用,参数是正确的,返回了默认的0返回代码(输出中没有显示,但这确实发生了;如果你想自己验证,可以添加echo $?)。

现在,要在实际脚本中使用这个,我们需要将用户给我们的所有参数传递给我们的函数。这可以使用$@语法来完成:其中$#对应于参数的数量,$@简单地打印出所有参数。我们将更新argument-checker.sh来检查脚本的参数:

reader@ubuntu:~/scripts/chapter_13$ vim argument-checker.sh 
reader@ubuntu:~/scripts/chapter_13$ cat argument-checker.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.1.0
# Date: 2018-11-17
# Description: Validates the check_arguments library function
# Usage: ./argument-checker.sh <argument1> <argument2>
#####################################

source ~/bash-function-library.sh

# Check user input. 
# Use double quotes around $@ to prevent word splitting.
check_arguments 2 "$@"
echo $?

我们传递了预期数量的参数2,以及脚本接收到的所有参数$@给我们的函数。用一些不同的输入运行它,看看会发生什么:

reader@ubuntu:~/scripts/chapter_13$ bash argument-checker.sh 
1
reader@ubuntu:~/scripts/chapter_13$ bash argument-checker.sh 1
1
reader@ubuntu:~/scripts/chapter_13$ bash argument-checker.sh 1 2
0
reader@ubuntu:~/scripts/chapter_13$ bash argument-checker.sh "1 2"
1
reader@ubuntu:~/scripts/chapter_13$ bash argument-checker.sh "1 2" 3
0

太棒了,一切似乎都在正常工作!最有趣的尝试可能是最后两个,因为它们展示了单词分割经常引起的问题。默认情况下,Bash 会将每个空白字符解释为分隔符。在第四个例子中,我们传递了"1 2"字符串,实际上由于引号的存在是一个单独的参数。如果我们没有在$@周围使用双引号,就会发生这种情况:

reader@ubuntu:~/scripts/chapter_13$ tail -3 argument-checker.sh 
check_arguments 2 $@
echo $?

reader@ubuntu:~/scripts/chapter_13$ bash argument-checker.sh "1 2"
0

在这个例子中,Bash 将参数传递给函数时没有保留引号。函数将会接收到"1""2",而不是"1 2"。要时刻注意这一点!

现在,我们可以使用预定义的函数来检查参数的数量是否正确。然而,目前我们并没有使用我们的返回代码做任何事情。我们将对我们的argument-checker.sh脚本进行最后一次调整,如果参数的数量不正确,将停止脚本执行:

reader@ubuntu:~/scripts/chapter_13$ vim argument-checker.sh 
reader@ubuntu:~/scripts/chapter_13$ cat argument-checker.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.2.0
# Date: 2018-11-17
# Description: Validates the check_arguments library function
# Usage: ./argument-checker.sh <argument1> <argument2>
#####################################

source ~/bash-function-library.sh

# Check user input. 
# Use double quotes around $@ to prevent word splitting.
check_arguments 2 "$@" || \
{ echo "Incorrect usage! Usage: $0 <argument1> <argument2>"; exit 1; }

# Arguments are correct, print them.
echo "Your arguments are: $1 and $2"

由于本书的页面宽度,我们使用\check_arguments一行分成两行:这表示 Bash 会继续到下一行。如果你喜欢,你可以省略这一点,让整个命令在一行上。如果我们现在运行脚本,将会看到期望的脚本执行:

reader@ubuntu:~/scripts/chapter_13$ bash argument-checker.sh 
Incorrect usage! Usage: argument-checker.sh <argument1> <argument2>
reader@ubuntu:~/scripts/chapter_13$ bash argument-checker.sh dog cat
Your arguments are: dog and cat
reader@ubuntu:~/scripts/chapter_13$ bash argument-checker.sh dog cat mouse
Incorrect usage! Usage: argument-checker.sh <argument1> <argument2>

恭喜,我们已经开始创建一个函数库,并成功在我们的一个脚本中使用它!

对于source有一个有点令人困惑的简写语法:一个点(.)。如果我们想在我们的脚本中使用这个简写,只需. ~/bash-function-library.sh。然而,我们并不是这种语法的铁杆支持者:source命令既不长也不复杂,而单个.如果你忘记在它后面加上空格(这很难看到!)就很容易被忽略或误用。我们的建议是:如果你在某个地方遇到这个简写,请知道它的存在,但在编写脚本时使用完整的内置source

更多实际例子

我们将在本章的最后一部分扩展您的函数库,使用来自早期脚本的常用操作。我们将从早期章节中复制一个脚本,并使用我们的函数库来替换功能,然后可以使用我们的库中的函数来处理。

当前工作目录

我们自己的私有函数库中第一个候选是正确设置当前工作目录。这是一个非常简单的函数,所以我们将它添加进去,不做太多解释:

reader@ubuntu:~/scripts/chapter_13$ vim ~/bash-function-library.sh 
reader@ubuntu:~/scripts/chapter_13$ cat ~/bash-function-library.sh 
#!/bin/bash
#####################################
# Author: Sebastiaan Tammer
# Version: v1.1.0
# Date: 2018-11-17
# Description: Bash function library.
# Usage: source ~/bash-function-library.sh
#####################################
<SNIPPED>
# Set the current working directory to the script location.
set_cwd() {
  cd $(dirname $0)
}

因为函数库是一个潜在频繁更新的东西,正确更新头部信息非常重要。最好(并且在企业环境中最有可能)将新版本的函数库提交到版本控制系统。在头部使用正确的语义版本将帮助您保持一个干净的历史记录。特别是,如果您将其与 Chef.io、Puppet 和 Ansible 等配置管理工具结合使用,您将清楚地了解您已经更改和部署到何处。

现在,我们将使用我们的库包含和函数调用更新上一章的脚本redirect-to-file.sh。最终结果应该是以下内容:

reader@ubuntu:~/scripts/chapter_13$ cp ../chapter_12/redirect-to-file.sh library-redirect-to-file.sh
reader@ubuntu:~/scripts/chapter_13$ vim library-redirect-to-file.sh 
reader@ubuntu:~/scripts/chapter_13$ cat library-redirect-to-file.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-17
# Description: Redirect user input to file.
# Usage: ./library-redirect-to-file.sh
#####################################

# Load our Bash function library.
source ~/bash-function-library.sh

# Since we're dealing with paths, set current working directory.
set_cwd

# Capture the users' input.
read -p "Type anything you like: " user_input

# Save the users' input to a file. > for overwrite, >> for append.
echo ${user_input} >> redirect-to-file.txt

为了教学目的,我们已将文件复制到当前章节的目录中;通常情况下,我们只需更新原始文件。我们只添加了对函数库的包含,并用我们的set_cwd函数调用替换了神奇的cd $(dirname $0)。让我们从脚本所在的位置运行它,看看目录是否正确设置:

reader@ubuntu:/tmp$ bash ~/scripts/chapter_13/library-redirect-to-file.sh
Type anything you like: I like ice cream, I guess
reader@ubuntu:/tmp$ ls -l
drwx------ 3 root root 4096 Nov 17 11:20 systemd-private-af82e37c...
drwx------ 3 root root 4096 Nov 17 11:20 systemd-private-af82e37c...
reader@ubuntu:/tmp$ cd ~/scripts/chapter_13
reader@ubuntu:~/scripts/chapter_13$ ls -l
<SNIPPED>
-rw-rw-r-- 1 reader reader 567 Nov 17 19:32 library-redirect-to-file.sh
-rw-rw-r-- 1 reader reader 26 Nov 17 19:35 redirect-to-file.txt
-rw-rw-r-- 1 reader reader 933 Nov 17 15:18 reverser.sh
reader@ubuntu:~/scripts/chapter_13$ cat redirect-to-file.txt 
I like ice cream, I guess

因此,即使我们使用了$0语法(你记得的,打印脚本的完全限定路径),我们在这里看到它指的是library-redirect-to-file.sh的路径,而不是你可能合理假设的bash-function-library.sh脚本的位置。这应该证实了我们的解释,即只有函数定义被包含,当函数在运行时被调用时,它们会采用包含它们的脚本的环境。

类型检查

我们在许多脚本中做的事情是检查参数。我们用一个函数开始了我们的库,允许检查用户输入的参数数量。我们经常对用户输入执行的另一个操作是验证输入类型。例如,如果我们的脚本需要一个数字,我们希望用户实际输入一个数字,而不是一个单词(或一个写出来的数字,比如'eleven')。你可能记得大致的语法,但我敢肯定,如果你现在需要它,你会浏览我们的旧脚本找到它。这不是理想的库函数候选吗?我们创建并彻底测试我们的函数一次,然后我们可以放心地只是源和使用它!让我们创建一个检查传递参数是否实际上是整数的函数:

reader@ubuntu:~/scripts/chapter_13$ vim ~/bash-function-library.sh
reader@ubuntu:~/scripts/chapter_13$ cat ~/bash-function-library.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.2.0
# Date: 2018-11-17
# Description: Bash function library.
# Usage: source ~/bash-function-library.sh
#####################################
<SNIPPED>

# Checks if the argument is an integer.
check_integer() {
  # Input validation.
  if [[ $# -ne 1 ]]; then
    echo "Need exactly one argument, exiting."
    exit 1 # No validation done, exit script.
  fi

  # Check if the input is an integer.
  if [[ $1 =~ ^[[:digit:]]+$ ]]; then
    return 0 # Is an integer.
  else
    return 1 # Is not an integer.
  fi
}

因为我们正在处理一个库函数,为了可读性,我们可以多说一点。在常规脚本中过多的冗长将降低可读性,但是一旦有人查看函数库以便理解,你可以假设他们会喜欢一些更冗长的脚本。毕竟,当我们在脚本中调用函数时,我们只会看到check_integer ${variable}

接下来是函数。我们首先检查是否收到了单个参数。如果没有收到,我们退出而不是返回。为什么我们要这样做呢?调用的脚本不应该困惑于1的返回代码意味着什么;如果它可以意味着我们没有检查任何东西,但也意味着检查本身失败了,我们会在不希望出现歧义的地方带来歧义。所以简单地说,返回总是告诉调用者有关传递参数的信息,如果脚本调用函数错误,它将看到完整的脚本退出并显示错误消息。

接下来,我们使用在第十章中构建的正则表达式,正则表达式,来检查参数是否实际上是整数。如果是,我们返回0。如果不是,我们将进入else块并返回1。为了向阅读库的人强调这一点,我们包括了# 是整数# 不是整数的注释。为什么不让它对他们更容易呢?记住,你并不总是为别人写代码,但如果你在一年后看自己的代码,你肯定也会觉得自己像别人(相信我们吧!)。

我们将从我们早期的脚本中进行另一个搜索替换。来自上一章的一个合适的脚本,password-generator.sh,将很好地完成这个目的。将其复制到一个新文件中,加载函数库并替换参数检查(是的,两个!):

reader@ubuntu:~/scripts/chapter_13$ vim library-password-generator.sh 
reader@ubuntu:~/scripts/chapter_13$ cat library-password-generator.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-17
# Description: Generate a password.
# Usage: ./library-password-generator.sh <length>
#####################################

# Load our Bash function library.
source ~/bash-function-library.sh

# Check for the correct number of arguments.
check_arguments 1 "$@" || \
{ echo "Incorrect usage! Usage: $0 <length>"; exit 1; }

# Verify the length argument.
check_integer $1 || { echo "Argument must be an integer!"; exit 1; }

# tr grabs readable characters from input, deletes the rest.
# Input for tr comes from /dev/urandom, via input redirection.
# echo makes sure a newline is printed.
tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c $1
echo

我们用我们的库函数替换了参数检查和整数检查。我们还删除了变量声明,并直接在脚本的功能部分使用了$1;这并不总是最好的做法。然而,当输入只使用一次时,首先将其存储在命名变量中会创建一些额外开销,我们可以跳过。即使有所有的空格和注释,我们仍然通过使用函数调用将脚本行数从 31 减少到 26。当我们调用我们的新改进的脚本时,我们看到以下内容:

reader@ubuntu:~/scripts/chapter_13$ bash library-password-generator.sh
Incorrect usage! Usage: library-password-generator.sh <length>
reader@ubuntu:~/scripts/chapter_13$ bash library-password-generator.sh 10
50BCuB835l
reader@ubuntu:~/scripts/chapter_13$ bash library-password-generator.sh 10 20
Incorrect usage! Usage: library-password-generator.sh <length>
reader@ubuntu:~/scripts/chapter_13$ bash library-password-generator.sh bob
Argument must be an integer!

很好,我们的检查按预期工作。看起来也好多了,不是吗?

是-否检查

在完成本章之前,我们将展示另一个检查。在本书的中间,在第九章中,错误检查和处理,我们介绍了一个处理用户可能提供的'yes'或'no'的脚本。但是,正如我们在那里解释的那样,用户也可能使用'y'或'n',甚至可能在其中的某个地方使用大写字母。通过秘密使用一点 Bash 扩展,你将在第十六章中得到适当解释,我们能够对用户输入进行相对清晰的检查。让我们把这个东西放到我们的库中!

reader@ubuntu:~/scripts/chapter_13$ vim ~/bash-function-library.sh 
reader@ubuntu:~/scripts/chapter_13$ cat ~/bash-function-library.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.3.0
# Date: 2018-11-17
# Description: Bash function library.
# Usage: source ~/bash-function-library.sh
#####################################
<SNIPPED>

# Checks if the user answered yes or no.
check_yes_no() {
  # Input validation.
  if [[ $# -ne 1 ]]; then
    echo "Need exactly one argument, exiting."
    exit 1 # No validation done, exit script.
  fi

  # Return 0 for yes, 1 for no, exit 2 for neither.
  if [[ ${1,,} = 'y' || ${1,,} = 'yes' ]]; then
    return 0
  elif [[ ${1,,} = 'n' || ${1,,} = 'no' ]]; then
    return 1
  else
    echo "Neither yes or no, exiting."
    exit 2
  fi
}

通过这个例子,我们为你准备了一些稍微高级的脚本。现在我们不再有二进制返回,而是有四种可能的结果:

  • 函数错误调用:exit 1

  • 函数找到了 yes:return 0

  • 函数找到了 no:return 1

  • 函数找不到:exit 2

有了我们的新库函数,我们将把yes-no-optimized.sh脚本和复杂逻辑替换为(几乎)单个函数调用:

reader@ubuntu:~/scripts/chapter_13$ cp ../chapter_09/yes-no-optimized.sh library-yes-no.sh
reader@ubuntu:~/scripts/chapter_13$ vim library-yes-no.sh
reader@ubuntu:~/scripts/chapter_13$ cat library-yes-no.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-11-17
# Description: Doing yes-no questions from our library.
# Usage: ./library-yes-no.sh
#####################################

# Load our Bash function library.
source ~/bash-function-library.sh

read -p "Do you like this question? " reply_variable

check_yes_no ${reply_variable} && \
echo "Great, I worked really hard on it!" || \
echo "You did not? But I worked so hard on it!"

花一分钟看一下前面的脚本。起初可能有点混乱,但请记住&&||的作用。由于我们应用了一些智能排序,我们可以使用&&||来实现我们的结果。可以这样看待它:

  1. 如果check_yes_no返回退出状态 0(找到yes时),则执行&&后面的命令。由于它回显了成功,而echo的退出代码为 0,因此下一个||后的失败echo不会被执行。

  2. 如果check_yes_no返回退出状态 1(找到no时),则&&后面的命令不会被执行。然而,它会继续执行直到达到||,由于返回代码仍然不是 0,它会继续到失败的回显。

  3. 如果check_yes_no在缺少参数或缺少 yes/no 时退出,则&&||后面的命令都不会被执行(因为脚本被给予exit而不是return,所以代码执行立即停止)。

相当聪明对吧?然而,我们必须承认,这与我们教给你的大多数关于可读性的东西有点相悖。把这看作是一个使用&&||链接的教学练习。如果你想要自己实现是-否检查,可能最好创建专门的check_yes()check_no()函数。无论如何,让我们看看我们改进的脚本是否像我们希望的那样工作:

reader@ubuntu:~/scripts/chapter_13$ bash library-yes-no.sh 
Do you like this question? Yes
Great, I worked really hard on it!
reader@ubuntu:~/scripts/chapter_13$ bash library-yes-no.sh 
Do you like this question? n
You did not? But I worked so hard on it!
reader@ubuntu:~/scripts/chapter_13$ bash library-yes-no.sh 
Do you like this question? MAYBE 
Neither yes or no, exiting.
reader@ubuntu:~/scripts/chapter_13$ bash library-yes-no.sh 
Do you like this question?
Need exactly one argument, exiting.

我们定义的所有场景都能正常工作。非常成功!

通常,你不希望过多地混合退出和返回代码。此外,使用返回代码传达除了通过或失败之外的任何内容也是相当不常见的。然而,由于你可以返回 256 个不同的代码(从 0 到 255),这至少在设计上是可能的。我们的是非示例是一个很好的候选,可以展示如何使用它。然而,作为一个一般的建议,最好是以通过/失败的方式使用它,因为目前你把知道不同返回代码的负担放在了调用者身上。这至少不总是一个公平的要求。

我们想以一个小练习结束本章。在本章中,在我们引入函数库之前,我们已经创建了一些函数:两个用于错误处理,一个用于彩色打印,一个用于文本反转。你的练习很简单:获取这些函数并将它们添加到你的个人函数库中。请记住以下几点:

  • 这些函数是否足够详细,可以直接包含在库中,还是需要更多的内容?

  • 我们可以直接调用函数并处理输出,还是最好进行编辑?

  • 返回和退出是否已经正确实现,还是需要调整以作为通用库函数工作?

这里没有对错之分,只是需要考虑的事情。祝你好运!

总结

在本章中,我们介绍了 Bash 函数。函数是可以定义一次,然后被多次调用的通用命令链。函数是可重用的,并且可以在多个脚本之间共享。

引入了变量作用域。到目前为止,我们看到的变量始终是全局作用域:它们对整个脚本都是可用的。然而,引入函数后,我们遇到了局部作用域的变量。这些变量只能在函数内部访问,并且用local关键字标记。

我们了解到函数可以有自己独立的参数集,可以在调用函数时作为参数传递。我们证明了这些参数实际上与传递给脚本的全局参数不同(当然,除非所有参数都通过函数传递)。我们举了一个例子,关于如何使用stdout从函数返回输出,我们可以通过将函数调用封装在命令替换中来捕获它。

在本章的下半部分,我们把注意力转向创建一个函数库:一个独立的脚本,没有实际命令,可以被包含(通过source命令)在另一个脚本中。一旦库在另一个脚本中被引用,库中定义的所有函数就可以被脚本使用。我们在本章的剩余部分展示了如何做到这一点,同时用一些实用的实用函数扩展了我们的函数库。

我们以一个练习结束了本章,以确保本章中定义的所有函数都包含在他们自己的个人函数库中。

本章介绍了以下命令:topfreedeclarecaserevreturn

问题

  1. 我们可以用哪两种方式定义一个函数?

  2. 函数的一些优点是什么?

  3. 全局作用域变量和局部作用域变量之间有什么区别?

  4. 我们如何给变量设置值和属性?

  5. 函数如何使用传递给它的参数?

  6. 我们如何从函数中返回一个值?

  7. source命令是做什么的?

  8. 为什么我们想要创建一个函数库?

进一步阅读

第十四章:调度和日志记录

在本章中,我们将教您调度和记录脚本结果的基础知识。我们将首先解释如何使用atcron来调度命令和脚本。在本章的第二部分,我们将描述如何记录脚本的结果。我们可以使用 Linux 的本地邮件功能和重定向来实现此目的。

本章将介绍以下命令:atwallatqatrmsendmailcrontabalias

本章将涵盖以下主题:

  • 使用atcron进行调度

  • 记录脚本结果

技术要求

本章的所有脚本都可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Linux-Shell-Scripting-Fundamentals-of-Bash-4.4/tree/master/Chapter14。其余的示例和练习应该在您的 Ubuntu 虚拟机上执行。

使用 at 和 cron 进行调度

到目前为止,我们已经学习了 shell 脚本世界中的许多内容:变量、条件、循环、重定向,甚至函数。在本章中,我们将解释另一个与 shell 脚本密切相关的重要概念:调度。

简而言之,调度是确保您的命令或脚本在特定时间运行,而无需您每次都亲自启动它们。经典示例可以在清理日志中找到;通常,旧日志不再有用并且占用太多空间。例如,您可以使用清理脚本解决此问题,该脚本会删除 45 天前的日志。但是,这样的脚本可能应该每天运行一次。在工作日,这可能不是最大的问题,但在周末登录并不好玩。实际上,我们甚至不应该考虑这一点,因为调度允许我们定义脚本应该在何时多久运行!

在 Linux 调度中,最常用的工具是atcron。我们将首先描述使用at进行调度的原则,然后再继续使用更强大(因此更广泛使用)的cron

at

at命令主要用于临时调度。at的语法非常接近我们的自然语言。通过以下示例最容易解释:

reader@ubuntu:~/scripts/chapter_14$ date
Sat Nov 24 11:50:12 UTC 2018
reader@ubuntu:~/scripts/chapter_14$ at 11:51
warning: commands will be executed using /bin/sh
at> wall "Hello readers!"
at> <EOT>
job 6 at Sat Nov 24 11:51:00 2018
reader@ubuntu:~/scripts/chapter_14$ date
Sat Nov 24 11:50:31 UTC 2018

Broadcast message from reader@ubuntu (somewhere) (Sat Nov 24 11:51:00 2018):

Hello readers!

reader@ubuntu:~/scripts/chapter_14$ date
Sat Nov 24 11:51:02 UTC 2018

实质上,您在告诉系统:在<时间戳>,执行某些操作。当您输入at 11:51命令时,您将进入一个交互式提示符,允许您输入要执行的命令。之后,您可以使用Ctrl + D退出提示符;如果您使用Ctrl + C,作业将不会被保存!作为参考,在这里我们使用一个简单的命令wall,它允许您向当时登录到服务器的所有人广播消息。

时间语法

当您使用at时,可以绝对指定时间,就像我们在上一个示例中所做的那样,也可以相对指定。相对指定的示例可能是5 分钟后24 小时后。这通常比检查当前时间,将所需的间隔添加到其中,并将其传递给at更容易。这可以使用以下语法:

reader@ubuntu:~/scripts/chapter_14$ at now + 1 min
warning: commands will be executed using /bin/sh
at> touch /tmp/at-file
at> <EOT>
job 10 at Sun Nov 25 10:16:00 2018
reader@ubuntu:~/scripts/chapter_14$ date
Sun Nov 25 10:15:20 UTC 2018

您总是需要指定相对于哪个时间要添加分钟、小时或天。幸运的是,我们可以使用 now 作为当前时间的关键字。请注意,处理分钟时,at将始终四舍五入到最近的整分钟。除分钟外,以下内容也是有效的(如man at中所述):

  • 小时

您甚至可以创建更复杂的解决方案,例如3 天后的下午 4 点。但是,我们认为cron更适合这类情况。就at而言,最佳用途似乎是在接近的时间运行一次性作业。

at 队列

一旦您开始安排作业,您就会发现自己处于这样一种情况:您要么搞砸了时间,要么搞砸了作业内容。对于某些作业,您可以添加一个新的作业,让其他作业失败。但是,肯定有一些情况下,原始作业将对您的系统造成严重破坏。在这种情况下,删除错误的作业将是一个好主意。幸运的是,at的创建者预见到了这个问题(可能也经历过!)并创建了这个功能。atq命令(at queue的缩写)显示当前在队列中的作业。使用atrm(我们想不需要解释这个),您可以按编号删除作业。让我们看一个队列中有多个作业的示例,并删除其中一个:

reader@ubuntu:~/scripts/chapter_14$ vim wall.txt
reader@ubuntu:~/scripts/chapter_14$ cat wall.txt 
wall "Hello!"
reader@ubuntu:~/scripts/chapter_14$ at now + 5 min -f wall.txt 
warning: commands will be executed using /bin/sh
job 12 at Sun Nov 25 10:35:00 2018
reader@ubuntu:~/scripts/chapter_14$ at now + 10 min -f wall.txt 
warning: commands will be executed using /bin/sh
job 13 at Sun Nov 25 10:40:00 2018
reader@ubuntu:~/scripts/chapter_14$ at now + 4 min -f wall.txt 
warning: commands will be executed using /bin/sh
job 14 at Sun Nov 25 10:34:00 2018
reader@ubuntu:~/scripts/chapter_14$ atq
12    Sun Nov 25 10:35:00 2018 a reader
13    Sun Nov 25 10:40:00 2018 a reader
14    Sun Nov 25 10:34:00 2018 a reader
reader@ubuntu:~/scripts/chapter_14$ atrm 13
reader@ubuntu:~/scripts/chapter_14$ atq
12    Sun Nov 25 10:35:00 2018 a reader
14    Sun Nov 25 10:34:00 2018 a reader

正如您所看到的,我们为at使用了一个新的标志:-f。这允许我们运行在文件中定义的命令,而不必使用交互式 shell。这个文件以.txt 结尾(为了清晰起见,不需要扩展名),其中包含要执行的命令。我们使用这个文件来安排三个作业:5 分钟后,10 分钟后和 4 分钟后。在这样做之后,我们使用atq来查看当前队列:所有三个作业,编号为 12、13 和 14。此时,我们意识到我们只想让作业在 4 和 5 分钟后运行,而不是在 10 分钟后运行。现在我们可以使用atrm通过简单地将该数字添加到命令中来删除作业编号 13。然后我们再次查看队列时,只剩下作业 12 和 14。几分钟后,前两个 Hello!消息被打印到我们的屏幕上。如果我们等待完整的 10 分钟,我们将看到...什么也没有,因为我们已成功删除了我们的作业:

Broadcast message from reader@ubuntu (somewhere) (Sun Nov 25 10:34:00 2018):

Hello!

Broadcast message from reader@ubuntu (somewhere) (Sun Nov 25 10:35:00 2018):

Hello!

reader@ubuntu:~/scripts/chapter_14$ date
Sun Nov 25 10:42:07 UTC 2018

不要使用atqatrmat也有我们可以用于这些功能的标志。对于atq,这是at -llist)。atrm甚至有两个可能的替代方案:at -ddelete)和at -rremove)。无论您使用支持命令还是标志,底层都将执行相同的操作。使用对您来说最容易记住的方式!

at 输出

正如您可能已经注意到的,到目前为止,我们只使用了不依赖于 stdout 的命令(有点狡猾,我们知道)。但是,一旦您考虑到这一点,这就会带来一个真正的问题。通常,当我们处理命令和脚本时,我们使用 stdout/stderr 来了解我们的操作结果。交互提示也是如此:我们使用键盘通过 stdin 提供输入。现在我们正在安排非交互作业,情况将会有所不同。首先,我们不能再使用诸如read之类的交互式结构。脚本将因为没有可用的 stdin 而简单地失败。但是,同样地,也没有可用的 stdout,因此我们甚至看不到脚本失败!还是有吗?

at的 manpage 中的某个地方,您可以找到以下文本:

“用户将收到他的命令的标准错误和标准输出的邮件(如果有的话)。邮件将使用命令/usr/sbin/sendmail 发送。如果 at 是从 su(1) shell 执行的,则登录 shell 的所有者将收到邮件。”

似乎at的创建者也考虑到了这个问题。但是,如果您对 Linux 没有太多经验(但!),您可能会对前文中的邮件部分感到困惑。如果您在想邮票的那种,您就离谱了。但是,如果您想到电子邮件,您就接近了一些。

不详细介绍(这显然超出了本书的范围),Linux 有一个本地的邮件存储箱,允许您在本地系统内发送电子邮件。如果您将其配置为上游服务器,实际上也可以发送实际的电子邮件,但现在,请记住 Linux 系统上的内部电子邮件是可用的。有了这个邮件存储箱,电子邮件(也许不足为奇)是文件系统上的文件。这些文件可以在/var/spool/mail 找到,这实际上是/var/mail 的符号链接。如果您跟随安装 Ubuntu 18.04 机器的过程,这些目录将是空的。这很容易解释:默认情况下,sendmail未安装。当它未安装时,您安排一个具有 stdout 的作业时,会发生这种情况:

reader@ubuntu:/var/mail$ which sendmail # No output, so not installed.
reader@ubuntu:/var/mail$ at now + 1 min
warning: commands will be executed using /bin/sh
at> echo "Where will this go?" 
at> <EOT>
job 15 at Sun Nov 25 11:12:00 2018
reader@ubuntu:/var/mail$ date
Sun Nov 25 11:13:02 UTC 2018
reader@ubuntu:/var/mail$ ls -al
total 8
drwxrwsr-x  2 root mail 4096 Apr 26  2018 .
drwxr-xr-x 14 root root 4096 Jul 29 12:30 ..

是的,确实什么都不会发生。现在,如果我们安装sendmail并再次尝试,我们应该会看到不同的结果:

reader@ubuntu:/var/mail$ sudo apt install sendmail -y
[sudo] password for reader: 
Reading package lists... Done
<SNIPPED>
Setting up sendmail (8.15.2-10) ...
<SNIPPED>
reader@ubuntu:/var/mail$ which sendmail
/usr/sbin/sendmail
reader@ubuntu:/var/mail$ at now + 1 min
warning: commands will be executed using /bin/sh
at> echo "Where will this go?"
at> <EOT>
job 16 at Sun Nov 25 11:17:00 2018
reader@ubuntu:/var/mail$ date
Sun Nov 25 11:17:09 UTC 2018
You have new mail in /var/mail/reader

邮件,只给你!如果我们检查/var/mail/,我们将看到只有一个包含我们输出的文件:

reader@ubuntu:/var/mail$ ls -l
total 4
-rw-rw---- 1 reader mail 1341 Nov 25 11:18 reader
reader@ubuntu:/var/mail$ cat reader 
From reader@ubuntu.home.lan Sun Nov 25 11:17:00 2018
Return-Path: <reader@ubuntu.home.lan>
Received: from ubuntu.home.lan (localhost.localdomain [127.0.0.1])
  by ubuntu.home.lan (8.15.2/8.15.2/Debian-10) with ESMTP id wAPBH0Ix003531
  for <reader@ubuntu.home.lan>; Sun, 25 Nov 2018 11:17:00 GMT
Received: (from reader@localhost)
  by ubuntu.home.lan (8.15.2/8.15.2/Submit) id wAPBH0tK003528
  for reader; Sun, 25 Nov 2018 11:17:00 GMT
Date: Sun, 25 Nov 2018 11:17:00 GMT
From: Learn Linux Shell Scripting <reader@ubuntu.home.lan>
Message-Id: <201811251117.wAPBH0tK003528@ubuntu.home.lan>
Subject: Output from your job 16
To: reader@ubuntu.home.lan

Where will this go?

它甚至看起来像一个真正的电子邮件,有一个日期:、主题:、收件人:和发件人:(等等)。如果我们安排更多的作业,我们将看到新的邮件附加到这个单个文件中。Linux 有一些简单的基于文本的邮件客户端,允许您将这个单个文件视为多个电子邮件(mutt就是一个例子);但是,我们不需要这些来实现我们的目的。

在处理系统通知时需要注意的一件事,比如您有新邮件时,它并不总是会推送到您的终端(而其他一些通知,比如wall,会)。这些消息会在下次更新终端时打印出来;这通常在您输入新命令时(或者只是一个空的Enter)时完成。如果您正在处理这些示例并等待输出,请随时按Enter几次,看看是否会有什么出现!

尽管获取我们作业的输出有时很棒,但往往会非常烦人,因为许多进程可能会发送本地邮件给您。通常情况下,这将导致您不查看邮件,甚至主动抑制命令的输出,以便您不再收到更多的邮件。在本章后面,介绍了cron之后,我们将花一些时间描述如何正确处理输出。作为一个小预览,这意味着我们不会依赖这种内置的能力,而是会使用重定向将我们需要的输出写入我们知道的地方。

cron

现在,通过at进行调度的基础知识已经讨论过了,让我们来看看 Linux 上真正强大的调度工具:croncron的名称源自希腊词chronos,意思是时间,它是一个作业调度程序,由两个主要组件组成:cron 守护进程(有时称为crond)和crontab。cron 守护进程是运行预定作业的后台进程。这些作业是使用 crontab 进行预定的,它只是文件系统上的一个文件,通常使用同名命令crontab进行编辑。我们将首先看一下crontab命令和语法。

crontab

Linux 系统上的每个用户都可以有自己的 crontab。还有一个系统范围的 crontab(不要与可以在 root 用户下运行的 crontab 混淆!),用于周期性任务;我们稍后会在本章中介绍这些。现在,我们将首先探索 crontab 的语法,并为我们的读者用户创建我们的第一个 crontab。

crontab 的语法

虽然语法可能一开始看起来令人困惑,但实际上并不难理解,而且非常灵活:

<时间戳>命令

哇,这太容易了!如果真是这样的话,那是的。然而,我们上面描述的<时间戳>实际上由五个不同的字段组成,这些字段组成了运行作业多次的组合周期。实际上,时间戳的定义如下(按顺序):

  1. 一小时中的分钟

  2. 一天中的小时

  3. 一个月中的日期

  4. 月份

  5. 星期几

在任何这些值中,我们可以用一个通配符替换一个数字,这表示所有值。看一下下表,了解一下我们如何组合这五个字段来精确表示时间:

** Crontab     语法** ** 语义含义**
15 16 * * * 每天 16:15。
30 * * * * 每小时一次,xx:30(因为每小时都有效,所以通配符)。
* 20 * * * 每天 60 次,从 20:00 到 20:59(小时固定,分钟有通配符)。
10 10 1 * * 每个月 1 日的 10:10。
00 21 * * 1 每周一次,周一 21:00(1-7 代表周一到周日,周日也是 0)。
59 23 31 12 * 新年前夜,12 月 31 日 23:59。
01 00 1 1 3 在 1 月 1 日 00:01,但仅当那天是星期三时(这将在 2020 年发生)。

你可能会对这种语法感到有些困惑。因为我们许多人通常写时间为 18:30,颠倒分钟和小时似乎有点不合常理。然而,这就是事实(相信我们,你很快就会习惯 crontab 格式)。现在,这种语法还有一些高级技巧:

  • 8-16(连字符允许多个值,因此00 8-16 * * *表示从 08:00 到 16:00 的每个整点)。

  • /5 允许每 5 个单位*(最常用于第一个位置,每 5 分钟一次)。小时的值*/6 也很有用,每天四次。

  • 00,30 表示两个值,比如每小时的 30 分钟或半小时(也可以写成*/30)。

在我们深入理论之前,让我们使用crontab命令为我们的用户创建一个简单的第一个 crontab。crontab命令有三个最常用的有趣标志:-l用于列出,-e用于编辑,-r用于删除。让我们使用这三个命令创建(和删除)我们的第一个 crontab:

reader@ubuntu:~$ crontab -l
no crontab for reader
reader@ubuntu:~$ crontab -e
no crontab for reader - using an empty one

Select an editor.  To change later, run 'select-editor'.
  1\. /bin/nano        <---- easiest
  2\. /usr/bin/vim.basic
  3\. /usr/bin/vim.tiny
  4\. /bin/ed

Choose 1-4 [1]: 2
crontab: installing new crontab
reader@ubuntu:~$ crontab -l
# m h  dom mon dow   command
* * * * * wall "Crontab rules!"

Broadcast message from reader@ubuntu (somewhere) (Sun Nov 25 16:25:01 2018):

Crontab rules!

reader@ubuntu:~$ crontab -r
reader@ubuntu:~$ crontab -l
no crontab for reader

正如你所看到的,我们首先列出当前的 crontab 使用crontab -l命令。由于我们没有,我们看到消息没有读者的 crontab(没有什么意外的)。接下来,当我们使用crontab -e开始编辑 crontab 时,我们会得到一个选择:我们想使用哪个编辑器?像往常一样,选择最适合你的。我们有足够的经验使用vim,所以我们更喜欢它而不是nano。我们只需要为每个用户做一次,因为 Linux 会保存我们的偏好(查看~/.selected_editor 文件)。最后,我们会看到一个文本编辑器屏幕,在我们的 Ubuntu 机器上,上面填满了有关 crontab 的小教程。由于所有这些行都以#开头,都被视为注释,不会影响执行。通常情况下,我们会删除除了语法提示之外的所有内容:m h dom mon dow command。你可能会忘记这个语法几次,这就是为什么这个小提示在你需要快速编辑时非常有帮助的原因,尤其是如果你有一段时间没有与 crontab 交互了。

我们使用最简单的时间语法创建一个 crontab:在所有五个位置上都使用通配符。简单地说,这意味着指定的命令每分钟运行一次。保存并退出后,我们最多等待一分钟,然后我们就会看到wall "Crontab rules!";命令的结果,这是我们自己用户的广播,对系统上的所有用户可见。因为这种构造会严重干扰系统,我们使用crontab -r在单次广播后删除 crontab。或者,我们也可以删除那一行或将其注释掉。

一个 crontab 可以有很多条目。每个条目都必须放在自己的一行上,有自己的时间语法。这允许用户安排许多不同的作业,以不同的频率。因此,crontab -r并不经常使用,而且本身相当破坏性。我们建议您始终使用crontab -e来确保您不会意外删除整个作业计划,而只是您想要删除的部分。

如上所述,所有的 crontab 都保存在文件系统中的文件中。你可以在/var/spool/cron/crontabs/目录中找到它们。这个目录只有 root 用户才能访问;如果所有用户都能看到彼此的作业计划,那将会有一些很大的隐私问题。然而,如果你使用sudo成为 root 用户,你会看到以下内容:

reader@ubuntu:~$ sudo -i
[sudo] password for reader: 
root@ubuntu:~# cd /var/spool/cron/crontabs/
root@ubuntu:/var/spool/cron/crontabs# ls -l
total 4
-rw------- 1 reader crontab 1090 Nov 25 16:51 reader

如果我们打开这个文件(vimlesscat,无论你喜欢哪个),我们会看到与读者用户的crontab -e显示的内容相同。然而,作为一个一般规则,总是使用可用的工具来编辑这样的文件!这样做的主要附加好处是,这些工具不允许你保存不正确的格式。如果我们手动编辑 crontab 文件并弄错了时间语法,整个 crontab 将不再工作。如果你用crontab -e做同样的事情,你会看到一个错误,crontab 将不会被保存,如下所示:

reader@ubuntu:~$ crontab -e
crontab: installing new crontab
"/tmp/crontab.ABXIt7/crontab":23: bad day-of-week
errors in crontab file, can't install.
Do you want to retry the same edit? (y/n)

在前面的例子中,我们输入了一行* * * * true。从错误中可以看出,cron 期望一个数字或通配符,但它找到了命令true(你可能还记得,这是一个简单返回退出码 0 的命令)。它向用户显示错误,并拒绝保存新的编辑,这意味着所有以前的计划任务都是安全的,将继续运行,即使我们这次搞砸了。

crontab 的时间语法允许几乎任何你能想到的组合。然而,有时你并不真的关心一个确切的时间,而更感兴趣的是确保某些东西每小时、每天、每周,甚至每月运行。Cron 为此提供了一些特殊的时间语法:而不是通常插入的五个值,你可以告诉 crontab@hourly@daily@weekly@monthly

记录脚本结果

按计划运行脚本是自动化重复任务的一种很好的方式。然而,在这样做时有一个很大的考虑因素:日志记录。通常,当你运行一个命令时,输出会直接显示给你。如果有什么问题,你就在键盘后面调查问题。然而,一旦我们开始使用cron(甚至at),我们就再也看不到命令的直接输出了。我们只能在登录后检查结果,如果我们没有做安排,我们只能寻找脚本的结果(例如,清理后的日志文件)。我们需要的是脚本的日志记录,这样我们就有一个简单的方法定期验证我们的脚本是否成功运行。

Crontab 环境变量

在我们的 crontab 中,我们可以定义环境变量,这些变量将被我们的命令和脚本使用。crontab 的这个功能经常被使用,但大多数情况下只用于三个环境变量:PATH、SHELL 和 MAILTO。我们将看看这些变量的用例/必要性。

路径

通常,当你登录到 Linux 系统时,你会得到一个登录 shell。登录 shell 是一个完全交互的 shell,为你做了一些很酷的事情:它设置了 PS1 变量(决定了你的提示符的外观),正确设置了你的 PATH 等等。现在,你可能会想象,除了登录 shell 还有其他东西。从技术上讲,有两个维度构成了四种不同类型的 shell:

登录 非登录
交互式 交互式登录 shell 交互式非登录 shell
非交互式 非交互式登录 shell 非交互式非登录 shell

大多数情况下,你会使用交互式登录 shell,比如通过(SSH)连接或直接通过终端控制台。另一个经常遇到的 shell 是非交互式非登录 shell,这是在通过atcron运行命令时使用的。其他两种也是可能的,但我们不会详细讨论你何时会得到这些。

所以,现在你知道我们在atcron中得到了不同类型的 shell,我们相信你想知道区别是什么(也就是说,你为什么关心这个问题?)。有一些文件在 Bash 中设置你的配置文件。其中一些在这里列出:

  • /etc/profile

  • /etc/bash.bashrc

  • ~/.profile

  • ~/.bashrc

前两个位于/etc/中,是系统范围的文件,因此对所有用户都是相同的。后两个位于你的主目录中,是个人的;这些可以被编辑,例如,添加你想使用的别名。alias命令用于为带有标志的命令创建一个简写。在 Ubuntu 18.04 上,默认情况下,~/.bashrc 文件包含一行alias ll='ls -alF',这意味着你可以输入ll,而执行ls -alF

不详细介绍(并且过于简化了很多),交互式登录 shell 读取和解析所有这些文件,而非交互式非登录 shell 不会(有关更深入的信息,请参见进一步阅读部分)。一如既往,一幅图值千言,所以让我们自己来看看区别:

reader@ubuntu:~$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
reader@ubuntu:~$ echo $PS1
\[\e]0;\u@\h: \w\a\]${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$
reader@ubuntu:~$ echo $0
-bash
reader@ubuntu:~$ at now
warning: commands will be executed using /bin/sh
at> echo $PATH
at> echo $PS1
at> echo $0
at> <EOT>
job 19 at Sat Dec  1 10:36:00 2018
You have mail in /var/mail/reader
reader@ubuntu:~$ tail -5 /var/mail/reader 
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
$
sh

正如我们在这里看到的,普通(SSH)shell 和at执行的命令之间的值是不同的。这对 PS1 和 shell 本身都是如此(我们可以通过$0 找到)。然而,对于at,PATH 与交互式登录会话的 PATH 相同。现在,看看如果我们在 crontab 中这样做会发生什么:

reader@ubuntu:~$ crontab -e
crontab: installing new crontab
reader@ubuntu:~$ crontab -l
# m h  dom mon dow   command
* * * * * echo $PATH; echo $PS1; echo $0
You have mail in /var/mail/reader
reader@ubuntu:~$ tail -4 /var/mail/reader 
/usr/bin:/bin
$
/bin/sh
reader@ubuntu:~$ crontab -r # So we don't keep doing this every minute!

首先,PS1 等于at看到的内容。由于 PS1 控制 shell 的外观,这只对交互式会话有趣;atcron都是非交互式的。如果我们继续看PATH,我们会看到一个非常不同的故事:当在cron中运行时,我们得到的是/usr/bin:/bin,而不是/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin!简单地说,这意味着对于所有在/bin/和/usr/bin/之外的命令,我们需要使用完全限定的文件名。这甚至体现在$0 的差异(sh 与/bin/sh)。虽然这并不是严格必要的(因为/bin/实际上是 PATH 的一部分),但在与cron相关的任何事情上看到完全限定的路径仍然是很典型的。

现在,我们有两种选择来处理这个问题,如果我们想要防止诸如sudo: command not found之类的错误。我们可以确保对所有命令始终使用完全限定的路径(实际上,这样做肯定会失败几次),或者我们可以确保为 crontab 设置一个 PATH。第一种选择会给我们所有与cron相关的事情带来更多的额外工作。第二种选择实际上是确保我们消除这个问题的一个非常简单的方法。我们只需在 crontab 的顶部包含一个PATH=...,所有由 crontab 执行的事情都使用那个 PATH。试一下以下内容:

reader@ubuntu:~$ crontab -e
no crontab for reader - using an empty one
crontab: installing new crontab
reader@ubuntu:~$ crontab -l
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
# m h  dom mon dow   command
* * * * * echo $PATH
reader@ubuntu:~$
You have new mail in /var/mail/reader
reader@ubuntu:~$ crontab -r
reader@ubuntu:~$ tail -2 /var/mail/reader 
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin

很简单。如果你想亲自验证这一点,你可以保持默认的 PATH 并从/sbin/运行一些东西(比如blkid命令,它显示你的磁盘/分区的信息)。由于这不在 PATH 上,如果你不使用完全限定的方式运行它,你会遇到错误/bin/sh: 1: blkid: not found in your local mail。选择任何你通常可以运行的命令并尝试一下!

通过简单地添加到 crontab 中,你可以节省大量的时间和精力来排除错误。就像调度中的所有事情一样,你通常需要等待至少几分钟才能运行每个脚本尝试,这使得故障排除成为一种耗时的实践。请自己一个忙,确保在 crontab 的第一行包含一个相关的 PATH。

SHELL

从我们看到的PATH的输出中,应该很清楚,atcron默认使用/bin/sh。你可能很幸运,有一个/bin/sh 默认为 Bash 的发行版,但这并不一定是这样,尤其是如果你跟着我们的 Ubuntu 18.04 安装走的话!在这种情况下,如果我们检查/bin/sh,我们会看到完全不同的东西:

reader@ubuntu:~$ ls -l /bin/sh
lrwxrwxrwx 1 root root 4 Apr 26  2018 /bin/sh -> dash

Dash 是Debian Almquist shell,它是最近 Debian 系统(你可能记得 Ubuntu 属于 Debian 发行系列)上的默认系统 shell。虽然 Dash 是一个很棒的 shell,有它自己的一套优点和缺点,但这本书是为 Bash 编写的。所以,对于我们的用例来说,让cron默认使用 Dash shell 并不实际,因为这将不允许我们使用酷炫的 Bash 4.x 功能,比如高级重定向、某些扩展等。幸运的是,当我们运行我们的命令时,我们可以很容易地设置cron应该使用的 shell:我们使用 SHELL 环境变量。设置这个非常简单:

reader@ubuntu:~$ crontab -e
crontab: installing new crontab
reader@ubuntu:~$ crontab -l
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
# m h  dom mon dow   command
* * * * * echo $0
reader@ubuntu:~$
You have mail in /var/mail/reader
reader@ubuntu:~$ tail -3 /var/mail/reader
/bin/bash
reader@ubuntu:~/scripts/chapter_14$ crontab -r

只需简单地添加 SHELL 环境变量,我们确保了不会因为某些 Bash 功能不起作用而感到困惑。预防这些问题总是一个好主意,而不是希望你能迅速发现它们,特别是如果你仍在掌握 shell 脚本。

MAILTO

现在我们已经确定我们可以在 crontab 中使用环境变量,通过检查 PATH 和 SHELL,让我们看看另一个非常重要的变量 MAILTO。从名称上可以猜到,这个变量控制邮件发送的位置。你可能记得,当命令有 stdout 时(几乎所有命令都有),邮件会被发送。这意味着对于 crontab 执行的每个命令,你可能会收到一封本地邮件。你可能会怀疑,这很快就会变得很烦人。我们可以在我们放置在 crontab 中的所有命令后面加上一个不错的&> /dev/null(记住,&>是 Bash 特有的,对于默认的 Dash shell 不起作用)。然而,这意味着我们根本不会有任何输出,无论是邮件还是其他。除了这个问题,我们还需要将它添加到所有我们的行中;这并不是一个真正实用的、可行的解决方案。在接下来的几页中,我们将讨论如何将输出重定向到我们想要的地方。然而,在达到这一点之前,我们需要能够操纵默认的邮件。

一个选择是要么不安装或卸载sendmail。这对于你们中的一些人可能是一个很好的解决方案,但对于其他人来说,他们有另一个需要在系统上安装sendmail,所以它不能被移除。那么呢?我们可以像使用PATH一样使用 MAILTO 变量;我们在 crontab 的开头设置它,邮件将被正确重定向。如果我们清空这个变量,通过将它赋值为空字符串"",则不会发送邮件。这看起来像这样:

reader@ubuntu:~$ crontab -e
no crontab for reader - using an empty one
crontab: installing new crontab
reader@ubuntu:~$ crontab -l
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
MAILTO=""
# m h dom mon dow command
* * * * * echo "So, I guess we'll never see this :("

到目前为止,我们已经经常使用tail命令,但实际上它有一个很棒的小标志--follow-f),它允许我们查看文件是否有新行被写入。这通常用于tail a logfile,但在这种情况下,它允许我们通过 tailing /var/mail/reader 文件来查看是否收到邮件。

reader@ubuntu:~$ tail -f /var/mail/reader 
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
X-Cron-Env: <SHELL=/bin/sh>
X-Cron-Env: <HOME=/home/reader>
X-Cron-Env: <PATH=/usr/bin:/bin>
X-Cron-Env: <LOGNAME=reader>

/bin/bash: 1: blkid: not found

如果一切都按我们的预期进行,这将是你看到的唯一的东西。由于 MAILTO 变量被声明为空字符串""cron知道不发送邮件。使用Ctrl + C退出tail -f(但记住这个命令),现在你可以放心了,因为你已经阻止了自己被 crontab 垃圾邮件轰炸!

使用重定向进行日志记录

虽然邮件垃圾邮件已经消除,但现在你发现自己根本没有任何输出,这绝对也不是一件好事。幸运的是,我们在第十二章中学到了有关重定向的一切,在脚本中使用管道和重定向*。就像我们可以在脚本中使用重定向在命令行中*使用一样,我们可以在 crontab 中使用相同的结构。管道和 stdout/stderr 的顺序规则也适用,所以我们可以链接任何我们想要的命令。然而,在我们展示这个之前,我们将展示 crontab 的另一个很酷的功能:从文件实例化一个 crontab!

reader@ubuntu:~/scripts/chapter_14$ vim base-crontab
reader@ubuntu:~/scripts/chapter_14$ cat base-crontab 
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
MAILTO=""
# m h  dom mon dow   command
reader@ubuntu:~/scripts/chapter_14$ crontab base-crontab
reader@ubuntu:~/scripts/chapter_14$ crontab -l
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
MAILTO=""
# m h  dom mon dow   command

首先,我们创建 base-crontab 文件,其中包含我们的 Bash SHELL、我们修剪了一点的 PATH、MAILTO 变量和我们的语法头。接下来,我们使用crontab base-crontab命令。简单地说,这将用文件中的内容替换当前的 crontab。这意味着我们现在可以将 crontab 作为一个文件来管理;这包括对版本控制系统和其他备份解决方案的支持。更好的是,使用crontab <filename>命令时,语法检查是完整的。如果文件不是正确的 crontab 格式,你会看到错误“crontab 文件中的错误,无法安装”。如果你想将当前的 crontab 保存到一个文件中,crontab -l > filename命令会为你解决问题。

既然这样,我们将给出一些由 crontab 运行的命令的重定向示例。我们将始终从一个文件实例化,这样你就可以在 GitHub 页面上轻松找到这些材料:

reader@ubuntu:~/scripts/chapter_14$ cp base-crontab date-redirection-crontab
reader@ubuntu:~/scripts/chapter_14$ vim date-redirection-crontab 
reader@ubuntu:~/scripts/chapter_14$ cat date-redirection-crontab 
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
MAILTO=""
# m h  dom mon dow   command
* * * * * date &>> /tmp/date-file
reader@ubuntu:~/scripts/chapter_14$ crontab date-redirection-crontab 
reader@ubuntu:~/scripts/chapter_14$ tail -f /tmp/date-file
Sat Dec 1 15:01:01 UTC 2018
Sat Dec 1 15:02:01 UTC 2018
Sat Dec 1 15:03:01 UTC 2018
^C
reader@ubuntu:~/scripts/chapter_14$ crontab -r

现在,这很容易。只要我们的 SHELL、PATH 和 MAILTO 设置正确,我们就避免了在使用 crontab 进行调度时通常会遇到的很多问题。

我们还没有运行一个脚本来使用 crontab。到目前为止,只运行了单个命令。但是,脚本也可以很好地运行。我们将使用上一章的脚本 reverser.sh,它将显示我们也可以通过 crontab 向脚本提供参数。此外,它将显示我们刚学到的重定向对脚本输出同样有效:

reader@ubuntu:~/scripts/chapter_14$ cp base-crontab reverser-crontab
reader@ubuntu:~/scripts/chapter_14$ vim reverser-crontab 
reader@ubuntu:~/scripts/chapter_14$ cat reverser-crontab 
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
MAILTO=""
# m h dom mon dow command
* * * * * /home/reader/scripts/chapter_13/reverser.sh 'crontab' &>> /tmp/reverser.log
reader@ubuntu:~/scripts/chapter_14$ crontab reverser-crontab 
reader@ubuntu:~/scripts/chapter_14$ cat /tmp/reverser.log
/bin/bash: /home/reader/scripts/chapter_13/reverser.sh: Permission denied
reader@ubuntu:~/scripts/chapter_14$ crontab -r

哎呀!尽管我们做了仔细的准备,但我们还是搞砸了。幸运的是,我们创建的输出文件(因为它是一个日志文件,所以扩展名为.log)也有 stderr 重定向(因为我们的 Bash 4.x &>>语法),我们看到了错误。在这种情况下,经典的错误“权限被拒绝”简单地意味着我们试图执行一个非可执行文件:

reader@ubuntu:~/scripts/chapter_14$ ls -l /home/reader/scripts/chapter_13/reverser.sh 
-rw-rw-r-- 1 reader reader 933 Nov 17 15:18 /home/reader/scripts/chapter_13/reverser.sh

所以,我们需要修复这个问题。我们可以做两件事:

  • 使用(例如)chmod 755 reverser.sh使文件可执行。

  • 将 crontab 从reverser.sh更改为bash reverser.sh

在这种情况下,没有真正好坏之分。一方面,标记需要执行的文件为可执行文件总是一个好主意;这向看到系统的人表明你是有意这样做的。另一方面,如果在 crontab 中添加额外的bash命令可以避免这类问题,那又有什么坏处呢?

在我们看来,使文件可执行并在 crontab 中省略bash命令略有优势。这样可以保持 crontab 的清洁(并且根据经验,如果处理不当,crontab 很容易变得混乱,所以这是一个非常大的优点),并向查看脚本的其他人表明由于权限问题应该执行它。让我们在我们的机器上应用这个修复:

reader@ubuntu:~/scripts/chapter_14$ chmod 755 ../chapter_13/reverser.sh
reader@ubuntu:~/scripts/chapter_14$ crontab reverser-crontab
reader@ubuntu:~/scripts/chapter_14$ tail -f /tmp/reverser.log
/bin/bash: /home/reader/scripts/chapter_13/reverser.sh: Permission denied
Your reversed input is: _batnorc_
^C
reader@ubuntu:~/scripts/chapter_14$ crontab -r

好了,好多了。我们在 crontab 中运行的完整命令是/home/reader/scripts/chapter_13/reverser.sh 'crontab' &>> /tmp/reverser.log,其中包括单词 crontab 作为脚本的第一个参数。输出 batnorc 确实是反转后的单词。看来我们可以通过 crontab 正确传递参数!虽然这个例子说明了这一点,但可能并不足以说明这可能是重要的。但是,如果你想象一个通用脚本,通常会使用不同的参数多次,那么它也可以在 crontab 中以不同的参数出现(可能在多行上,也许有不同的计划)。确实非常有用!

如果您需要快速查看 crontab 的情况,您当然会查看man crontab。但是,我们还没有告诉您的是,有些命令实际上有多个 man 页面!默认情况下,man crontabman <first-manpage> crontab的简写。在该页面上,您将看到这样的句子:“SEE ALSO crontab(5), cron(8)”。通过向man 5 crontab提供此数字,您将看到一个不同的页面,其中本章的许多概念(语法、环境变量和示例)都很容易访问。

最终的日志记录考虑

您可能考虑让您的脚本自行处理其日志记录。虽然这当然是可能的(尽管有点复杂且不太可读),但我们坚信调用者有责任处理日志记录。如果您发现一个脚本自行处理其日志记录,您可能会遇到以下一些问题:

  • 多个用户以不同的间隔运行相同的脚本,将输出到单个日志文件

  • 日志文件需要具有健壮的用户权限,以确保正确的暴露

  • 临时和定期运行都将出现在日志文件中

简而言之,将日志记录的责任委托给脚本本身是在自找麻烦。对于临时命令,您可以在终端中获得输出。如果您需要它用于其他任何目的,您可以随时将其复制并粘贴到其他地方,或者重定向它。更有可能的是使用管道运行脚本到tee,因此输出同时显示在您的终端上保存到文件中。对于从cron进行的定期运行,您需要在创建计划时考虑重定向。在这种情况下,特别是如果您使用 Bash 4.x 的&>>构造,您将始终看到所有输出(stdout 和 stderr)都附加到您指定的文件中。在这种情况下,几乎没有错过任何输出的风险。记住:tee和重定向是您的朋友,当正确使用时,它们是任何脚本调度的重要补充!

如果您希望您的 cron 日志记录机制变得非常花哨,您可以设置sendmail(或其他软件,如postfix)作为实际的邮件传输代理(这超出了本书的范围,但请查看进一步阅读部分!)。如果正确配置,您可以在 crontab 中将 MAILTO 变量设置为实际的电子邮件地址(也许是yourname@company.com),并在您的常规电子邮件邮箱中接收来自定期作业的报告。这最适用于不经常运行的重要脚本;否则,您将只会收到大量令人讨厌的电子邮件。

关于冗长的说明

重要的是要意识到,就像直接在命令行上一样,只有输出(stdout/stderr)被记录。默认情况下,大多数成功运行的命令没有任何输出;其中包括cprmtouch等。如果您希望在脚本中进行信息记录,您有责任在适当的位置添加输出。最简单的方法是偶尔使用echo。使日志文件对用户产生信心的最简单方法是在脚本的最后一个命令中使用echo "一切顺利,退出脚本。"。只要您在脚本中正确处理了所有潜在的错误,您可以安全地说一旦达到最后一个命令,执行就已成功,您可以通知用户。如果不这样做,日志文件可能会保持空白,这可能有点可怕;它是空白的,因为一切都成功了还是因为脚本甚至没有运行?这不是您想冒险的事情,尤其是当一个简单的echo可以帮您省去所有这些麻烦。

摘要

我们通过展示新的at命令开始了本章,并解释了如何使用at来安排脚本。我们描述了at的时间戳语法以及它包含了所有计划作业的队列。我们解释了at主要用于临时安排的命令和脚本,然后继续介绍了更强大的cron调度程序。

cron守护程序负责系统上大多数计划任务,它是一个非常强大和灵活的调度程序,通常通过所谓的 crontab 来使用。这是一个用户绑定的文件,其中包含了关于cron何时以及如何运行命令和脚本的指令。我们介绍了在 crontab 中使用的时间戳语法。

本章的第二部分涉及记录我们的计划命令和脚本。当在命令行上交互运行命令时,不需要专门的记录,但计划的命令不是交互式的,因此需要额外的机制。计划命令的输出可以使用sendmail进程发送到本地文件,也可以使用我们之前概述的重定向可能性将其重定向到日志文件中。

我们在本章结束时对日志记录进行了一些最终考虑:始终由调用者负责安排日志记录,并且脚本作者有责任确保脚本足够详细以便非交互式地使用。

本章介绍了以下命令:atwallatqatrmsendmailcrontabalias

问题

  1. 什么是调度?

  2. 我们所说的临时调度是什么意思?

  3. 使用at运行的命令的输出通常会去哪里?

  4. cron守护程序的调度最常见的实现方式是什么?

  5. 哪些命令允许您编辑个人的 crontab?

  6. 在 crontab 时间戳语法中有哪五个字段?

  7. crontab 的三个最重要的环境变量是哪些?

  8. 我们如何检查我们使用cron计划的脚本或命令的输出?

  9. 如果我们计划的脚本没有足够的输出让我们有效地使用日志文件,我们应该如何解决这个问题?

进一步阅读

如果您想更深入地了解本章的主题,以下资源可能会很有趣:

第十五章:使用 getopts 解析 Bash 脚本参数

在本章中,我们将讨论向脚本传递参数的不同方法,特别关注标志。我们将首先回顾位置参数,然后继续讨论作为标志传递的参数。之后,我们将讨论如何使用 getopts shell 内建在你自己的脚本中使用标志。

本章将介绍以下命令:getoptsshift

本章将涵盖以下主题:

  • 位置参数与标志

  • getopts shell 内建

技术要求

本章的所有脚本都可以在 GitHub 上找到,链接如下:github.com/PacktPublishing/Learn-Linux-Shell-Scripting-Fundamentals-of-Bash-4.4/tree/master/Chapter15。在你的 Ubuntu Linux 虚拟机上跟着示例进行—不需要其他资源。对于 single-flag.sh 脚本,只能在网上找到最终版本。在执行脚本之前,请务必验证头部中的脚本版本。

位置参数与标志

我们将从一个简短的位置参数回顾开始本章。你可能还记得来自第八章的变量和用户输入,我们可以使用位置参数来向我们的脚本传递参数。

简单来说,使用以下语法:

bash script.sh argument1 argument2 ...

在上述(虚构的)script.sh 中,我们可以通过查看参数的位置来获取用户提供的值:$1 是第一个参数,$2 是第二个参数,依此类推。记住 $0 是一个特殊的参数,它与脚本的名称有关:在这种情况下,是 script.sh

这种方法相对简单,但也容易出错。当你编写这个脚本时,你需要对用户提供的输入进行广泛的检查;他们是否提供了足够的参数,但不要太多?或者,也许一些参数是可选的,所以可能有一些组合是可能的?所有这些事情都需要考虑,如果可能的话,需要处理。

除了脚本作者(你!),脚本调用者也有负担。在他们能够成功调用你的脚本之前,他们需要知道如何传递所需的信息。对于我们的脚本,我们应用了两种旨在减轻用户负担的做法:

  • 我们的脚本头包含一个 Usage: 字段

  • 当我们的脚本被错误调用时,我们会打印一个错误消息,带有一个与头部类似/相等的使用提示

然而,这种方法容易出错,而且并不总是很用户友好。不过,还有另一个选择:选项,更常被称为标志

在命令行上使用标志

也许你还没有意识到,但你在命令行上使用的大多数命令都是使用位置参数和标志的组合。Linux 中最基本的命令 cd 使用了一个位置参数:你想要移动到的目录。

实际上它确实有两个标志,你也可以使用:-L-P。这些标志的目的是小众的,不值得在这里解释。几乎所有命令都同时使用标志和位置参数。

那么,我们什么时候使用哪个?作为一个经验法则,标志通常用于修改器,而位置参数用于目标。目标很简单:你想要用命令操作的东西。在 ls 的情况下,这意味着位置参数是应该被列出(操作)的文件或目录。

对于ls -l /tmp/命令,/tmp/是目标,-l是用来修改ls行为的标志。默认情况下,ls列出所有文件,不包括所有者、权限、大小等额外信息。如果我们想要修改ls的行为,我们添加一个或多个标志:-l告诉ls使用长列表格式,这样每个文件都会单独打印在自己的行上,并打印有关文件的额外信息。

请注意,在ls /tmp/ls -l /tmp/之间,目标没有改变,但输出却改变了,因为我们用标志修改了它!

有些标志甚至更特殊:它们需要自己的位置参数!因此,我们不仅可以使用标志来修改命令,而且标志本身还有多个选项来修改命令的行为。

一个很好的例子是find命令:默认情况下,它会在目录中查找所有文件,如下所示:

reader@ubuntu:~/scripts/chapter_14$ find
.
./reverser-crontab
./wall.txt
./base-crontab
./date-redirection-crontab

或者,我们可以使用find与位置参数一起使用,以便不在当前工作目录中搜索,而是在其他地方搜索,如下所示:

reader@ubuntu:~/scripts/chapter_14$ find ../chapter_10
../chapter_10
../chapter_10/error.txt
../chapter_10/grep-file.txt
../chapter_10/search.txt
../chapter_10/character-class.txt
../chapter_10/grep-then-else.sh

现在,find还允许我们使用-type标志只打印特定类型的文件。但是仅使用-type标志,我们还没有指定要打印的文件类型。通过在标志之后直接指定文件类型(这里关键是顺序),我们告诉标志要查找什么。它看起来像下面这样:

reader@ubuntu:/$ find /boot/ -type d
/boot/
/boot/grub
/boot/grub/i386-pc
/boot/grub/fonts
/boot/grub/locale

在这里,我们在/boot/目录中寻找了一种d(目录)类型。-type标志的其他参数包括f(文件)、l(符号链接)和b(块设备)。

像这样的事情会发生,如果你没有做对的话:

reader@ubuntu:/$ find -type d /boot/
find: paths must precede expression: '/boot/'
find: possible unquoted pattern after predicate '-type'?

不幸的是,不是所有的命令都是平等的。有些对用户更宽容,尽力理解输入的内容。其他则更加严格:它们会运行任何传递的内容,即使它没有任何功能上的意义。请务必确保您正确使用命令及其修改器!

前面的例子使用了与我们将学习如何在getopts中使用标志的方式不同。这些例子只是用来说明脚本参数、标志和带参数的标志的概念。这些实现是在没有使用getopts的情况下编写的,因此不完全对应我们以后要做的事情。

内置的 getopts shell

现在真正的乐趣开始了!在本章的第二部分中,我们将解释getopts shell 内置。getopts命令用于在脚本的开头获取您以标志形式提供的选项。它有一个非常特定的语法,一开始可能会让人感到困惑,但是,一旦我们完全了解了它,你应该就不会觉得太复杂了。

不过,在我们深入讨论之前,我们需要讨论两件事:

  • getoptsgetopt之间的区别

  • 短选项与长选项

如前所述,getopts是一个shell 内置。它在常规的 Bourne shell(sh)和 Bash 中都可用。它始于 1986 年左右,作为getopt的替代品,后者在 1980 年前后创建。

getopts相比,getopt不是内置于 shell 中的:它是一个独立的程序,已经移植到许多不同的 Unix 和类 Unix 发行版。getoptsgetopt之间的主要区别如下:

  • getopt不能很好地处理空标志参数;getopts可以

  • getopts包含在 Bourne shell 和 Bash 中;getopt需要单独安装

  • getopt允许解析长选项(--help而不是-h);getopts不允许

  • getopts有更简单的语法;getopt更复杂(主要是因为它是一个外部程序,而不是内置的)。

一般来说,大多数情况下,使用getopts更可取(除非你真的想要长选项)。由于getopts是 Bash 内置的,我们也会使用它,特别是因为我们不需要长选项。

您在终端上使用的大多数命令都有短选项(在终端上交互工作时几乎总是使用,以节省时间)和长选项(更具描述性,更适合创建更易读的脚本)。根据我们的经验,短选项更常见,而且使用正确时更容易识别。

以下列表显示了最常见的短标志,对大多数命令起着相同的作用:

  • -h:打印命令的帮助/用法

  • -v:使命令详细

  • -q:使命令安静

  • -f :将文件传递给命令

  • -r:递归执行操作

  • -d:以调试模式运行命令

不要假设所有命令都解析短标志,如前所述。尽管对大多数命令来说是这样,但并非所有命令都遵循这些趋势。这里打印的内容是根据个人经验发现的,应始终在运行对您新的命令之前进行验证。也就是说,运行一个没有参数/标志或带有-h的命令,至少 90%的时间会打印正确的用法供您欣赏。

尽管长选项对我们的getopts脚本可用会很好,但是长选项永远不能替代编写可读性脚本和为使用您的脚本的用户创建良好提示。我们认为这比拥有长选项更重要!此外,getopts的语法比可比的getopt要干净得多,遵循 KISS 原则仍然是我们的目标之一。

getopts 语法

我们不想在这一章中再花费更多时间而不看到实际的代码,我们将直接展示一个非常简单的getopts脚本示例。当然,我们会逐步引导您,以便您有机会理解它。

我们正在创建的脚本只做了一些简单的事情:如果找到-v标志,它会打印一个详细消息,告诉我们它找到了该标志。如果没有找到任何标志,它将不打印任何内容。如果找到任何其他标志,它将为用户打印错误。简单吧?

让我们来看一下:

reader@ubuntu:~/scripts/chapter_15$ vim single-flag.sh
reader@ubuntu:~/scripts/chapter_15$ cat !$
cat single-flag.sh
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-12-08
# Description: Shows the basic getopts syntax.
# Usage: ./single-flag.sh [flags]
#####################################

# Parse the flags in a while loop.
# After the last flag, getopts returns false which ends the loop.
optstring=":v"
while getopts ${optstring} options; do
  case ${options} in
    v)
      echo "-v was found!"
      ;;
    ?)
      echo "Invalid option: -${OPTARG}."
      exit 1
      ;; 
  esac
done

如果我们运行这个脚本,我们会看到以下情况发生:

reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh # No flag, do nothing.
reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh -p 
Invalid option: -p. # Wrong flag, print an error.
reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh -v 
-v was found! # Correct flag, print the message.

因此,我们的脚本至少按预期工作!但是为什么它会这样工作呢?让我们来看看。我们将跳过标题,因为现在应该非常清楚。我们将从包含getopts命令和optstringwhile行开始:

# Parse the flags in a while loop.
# After the last flag, getopts returns false which ends the loop.
optstring=":v"
while getopts ${optstring} options; do

optstring,很可能是options string的缩写,告诉getopts应该期望哪些选项。在这种情况下,我们只期望v。然而,我们以一个冒号(:)开始optstring,这是optstring的一个特殊字符,它将getopts设置为静默错误报告模式。

由于我们更喜欢自己处理错误情况,我们将始终以冒号开头。但是,随时可以尝试删除冒号看看会发生什么。

之后,getopts的语法非常简单,如下所示:

getopts optstring name [arg]

我们可以看到命令,后面跟着optstring(我们将其抽象为一个单独的变量以提高可读性),最后是我们将存储解析结果的变量的名称。

getopts的最后一个可选方面允许我们传递我们自己的一组参数,而不是默认为传递给脚本的所有内容($0 到$9)。我们在练习中不需要/使用这个,但这绝对是好事。与往常一样,因为这是一个 shell 内置命令,您可以通过执行help getopts来找到有关它的信息。

我们将此命令放在while循环中,以便它遍历我们传递给脚本的所有参数。如果getopts没有更多参数要解析,它将返回除0之外的退出状态,这将导致while循环退出。

然而,在循环中,我们将进入case语句。如你所知,case语句基本上是更好的语法,用于更长的if-elif-elif-elif-else语句。在我们的示例脚本中,它看起来像这样:

  case ${options} in
    v)
      echo "-v was found!"
      ;;
    ?)
      echo "Invalid option: -${OPTARG}."
      exit 1
      ;;
  esac
done

注意case语句以esac(case 反写)结束。对于我们定义的所有标志(目前只有-v),我们有一段代码块,只有对该标志才会执行。

当我们查看${options}变量时(因为我们在getopts命令中为name指定了它),我们还会发现?通配符。我们将它放在case语句的末尾,作为捕获错误的手段。如果它触发了?)代码块,我们向getopts提供了一个无法理解的标志。在这种情况下,我们打印一个错误并退出脚本。

最后一行的done结束了while循环,并表示我们所有的标志都应该已经处理完毕。

可能看起来有点多余,既有optstring又有所有可能选项的case。目前确实是这样,但在本章稍后的部分,我们将向您展示optstring用于指定除了字母之外的其他内容;到那时,optstring为什么在这里应该是清楚的。现在不要太担心它,只需在两个位置输入标志即可。

多个标志

幸运的是,我们不必满足于只有一个标志:我们可以定义许多标志(直到字母用完为止!)。

我们将创建一个新的脚本,向读者打印一条消息。如果没有指定标志,我们将打印默认消息。如果遇到-b标志或-g标志,我们将根据标志打印不同的消息。我们还将包括-h标志的说明,遇到时将打印帮助信息。

满足这些要求的脚本可能如下所示:

reader@ubuntu:~/scripts/chapter_15$ vim hey.sh 
reader@ubuntu:~/scripts/chapter_15$ cat hey.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-12-14
# Description: Getopts with multiple flags.
# Usage: ./hey.sh [flags]
#####################################

# Abstract the help as a function, so it does not clutter our script.
print_help() {
  echo "Usage: $0 [flags]"
  echo "Flags:"
  echo "-h for help."
  echo "-b for male greeting."
  echo "-g for female greeting."
}

# Parse the flags.
optstring=":bgh"
while getopts ${optstring} options; do
  case ${options} in
    b)
      gender="boy"
      ;;
    g)
      gender="girl"
      ;;
    h)
      print_help
      exit 0 # Stop script, but consider it a success.
      ;;
    ?)
      echo "Invalid option: -${OPTARG}."
      exit 1
      ;; 
  esac
done

# If $gender is n (nonzero), print specific greeting.
# Otherwise, print a neutral greeting.
if [[ -n ${gender} ]]; then
  echo "Hey ${gender}!"
else
  echo "Hey there!"
fi

在这一点上,这个脚本对你来说应该是可读的,尤其是包含的注释。从头开始,我们从标题开始,然后是print_help()函数,当遇到-h标志时打印我们的帮助信息(正如我们在几行后看到的那样)。

接下来是optstring,它仍然以冒号开头,以便关闭getopts的冗长错误(因为我们将自己处理这些错误)。在optstring中,我们将要处理的三个标志,即-b-g-h,定义为一个字符串:bgh

对于每个标志,我们在case语句中都有一个条目:对于b)g)gender变量分别设置为boygirl。对于h),在调用exit 0之前,调用了我们定义的函数。(想想为什么我们要这样做!如果不确定,可以在不使用 exit 的情况下运行脚本。)

我们总是通过?)语法处理未知标志来结束getopts块。

继续,当我们的case语句以esac结束时,我们进入实际的功能。我们检查gender变量是否已定义:如果是,我们打印一个包含根据标志设置的值的消息。如果没有设置(即如果未指定-b-g),我们打印一个省略性别的通用问候。

这也是为什么我们在找到-h后会exit 0:否则帮助信息和问候语都会显示给用户(这很奇怪,因为用户只是要求使用-h查看帮助页面)。

让我们看看我们的脚本是如何运行的:

reader@ubuntu:~/scripts/chapter_15$ bash hey.sh -h
Usage: hey.sh [flags]
Flags:
-h for help.
-b for male greeting.
-g for female greeting.
reader@ubuntu:~/scripts/chapter_15$ bash hey.sh
Hey there!
reader@ubuntu:~/scripts/chapter_15$ bash hey.sh -b
Hey boy!
reader@ubuntu:~/scripts/chapter_15$ bash hey.sh -g
Hey girl!

到目前为止,一切都很顺利!如果我们使用-h调用它,将看到打印的多行帮助信息。默认情况下,每个echo都以换行符结束,因此我们的五个echo将打印在五行上。我们可以使用单个echo\n字符,但这样更易读。

如果我们在没有标志的情况下运行脚本,将看到通用的问候语。使用-b-g运行它将给出特定性别的问候语。是不是很容易?

实际上是这样的!但是,情况即将变得更加复杂。正如我们之前解释过的,用户往往是相当不可预测的,可能会使用太多的标志,或者多次使用相同的标志。

让我们看看我们的脚本对此做出了怎样的反应:

reader@ubuntu:~/scripts/chapter_15$ bash hey.sh -h -b
Usage: hey.sh [flags]
Flags:
-h for help.
-b for male greeting.
-g for female greeting.
reader@ubuntu:~/scripts/chapter_15$ bash hey.sh -b -h
Usage: hey.sh [flags]
Flags:
-h for help.
-b for male greeting.
-g for female greeting.
reader@ubuntu:~/scripts/chapter_15$ bash hey.sh -b -h -g
Usage: hey.sh [flags]
Flags:
-h for help.
-b for male greeting.
-g for female greeting.

因此,只要指定了多少个标志,只要脚本遇到-h标志,它就会打印帮助消息并退出(由于exit 0)。为了您的理解,在调试模式下使用bash -x运行前面的命令,以查看它们实际上是不同的,即使用户看不到这一点(提示:检查gender=boygender=girl的赋值)。

这带我们来一个重要的观点:*标志是按用户提供的顺序解析的!*为了进一步说明这一点,让我们看另一个用户搞乱标志的例子:

reader@ubuntu:~/scripts/chapter_15$ bash hey.sh -g -b
Hey boy!
reader@ubuntu:~/scripts/chapter_15$ bash hey.sh -b -g
Hey girl!

当用户同时提供-b-g标志时,系统会执行性别的两个变量赋值。然而,似乎最终的标志才是赢家,尽管我们刚刚说过标志是按顺序解析的!为什么会这样呢?

一如既往,一个不错的bash -x让我们对这种情况有了一个很好的了解:

reader@ubuntu:~/scripts/chapter_15$ bash -x hey.sh -b -g
+ optstring=:bgh
+ getopts :bgh options
+ case ${options} in
+ gender=boy
+ getopts :bgh options
+ case ${options} in
+ gender=girl
+ getopts :bgh options
+ [[ -n girl ]]
+ echo 'Hey girl!'
Hey girl!

最初,gender变量被赋予boy的值。然而,当解析下一个标志时,变量的值被覆盖为一个新值,girl。由于-g标志是最后一个,gender变量最终变成girl,因此打印出来的就是这个值。

正如您将在本章的下一部分中看到的,可以向标志提供参数。不过,对于没有参数的标志,有一个非常酷的功能,许多命令都在使用:标志链接。听起来可能很复杂,但实际上非常简单:如果有多个标志,可以将它们全部放在一个破折号后面。

对于我们的脚本,情况是这样的:

reader@ubuntu:~/scripts/chapter_15$ bash -x hey.sh -bgh
+ optstring=:bgh
+ getopts :bgh options
+ case ${options} in
+ gender=boy
+ getopts :bgh options
+ case ${options} in
+ gender=girl
+ getopts :bgh options
+ case ${options} in
+ print_help
<SNIPPED>

我们将所有标志都指定为一组:而不是-b -g -h,我们使用了-bgh。正如我们之前得出的结论,标志是按顺序处理的,这在我们连接的例子中仍然是这样(正如调试指令清楚地显示的那样)。这与ls -al并没有太大的不同。再次强调,这仅在标志没有参数时才有效。

带参数的标志

optstring中,冒号除了关闭冗长的错误日志记录之外还有另一个意义:当放在一个字母后面时,它向getopts发出信号,表示期望一个选项参数

如果我们回顾一下我们的第一个例子,optstring只是:v。如果我们希望-v标志接受一个参数,我们会在v后面放一个冒号,这将导致以下optstring:v:。然后我们可以使用一个我们之前见过的特殊变量OPTARG来获取那个

我们将对我们的single-flag.sh脚本进行修改,以向您展示它是如何工作的:

reader@ubuntu:~/scripts/chapter_15$ vim single-flag.sh 
reader@ubuntu:~/scripts/chapter_15$ cat single-flag.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.1.0
# Date: 2018-12-14
# Description: Shows the basic getopts syntax.
# Usage: ./single-flag.sh [flags]
#####################################

# Parse the flags in a while loop.
# After the last flag, getopts returns false which ends the loop.
optstring=":v:"
while getopts ${optstring} options; do
  case ${options} in
    v)
      echo "-v was found!"
      echo "-v option argument is: ${OPTARG}."
      ;;
    ?)
      echo "Invalid option: -${OPTARG}."
      exit 1
      ;; 
  esac
done

已更改的行已经为您突出显示。通过在optstring中添加一个冒号,并在v)块中使用OPTARG变量,我们现在看到了运行脚本时的以下行为:

reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh 
reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh -v Hello
-v was found!
-v option argument is: Hello.
reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh -vHello
-v was found!
-v option argument is: Hello.

正如您所看到的,只要我们提供标志和标志参数,我们的脚本就可以正常工作。我们甚至不需要在标志和标志参数之间加上空格;由于getopts知道期望一个参数,它可以处理空格或无空格。我们始终建议在任何情况下都包括空格,以确保可读性,但从技术上讲并不需要。

这也证明了为什么我们需要一个单独的optstringcase语句是一样的,但是getopts现在期望一个参数,如果创建者省略了optstring,我们就无法做到这一点。

就像所有看起来太好以至于不真实的事情一样,这就是其中之一。如果用户对你的脚本友好,它可以正常工作,但如果他/她不友好,可能会发生以下情况:

reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh -v
Invalid option: -v.
reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh -v ''
-v was found!
-v option argument is: 

现在我们已经告诉getopts期望-v标志的参数,如果没有参数,它实际上将无法正确识别该标志。但是,空参数,如第二个脚本调用中的'',是可以的。 (从技术上讲是可以的,因为没有用户会这样做。)

幸运的是,有一个解决方案——:)块,如下所示:

reader@ubuntu:~/scripts/chapter_15$ vim single-flag.sh 
reader@ubuntu:~/scripts/chapter_15$ cat single-flag.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.2.0
# Date: 2018-12-14
# Description: Shows the basic getopts syntax.
# Usage: ./single-flag.sh [flags]
#####################################

# Parse the flags in a while loop.
# After the last flag, getopts returns false which ends the loop.
optstring=":v:"
while getopts ${optstring} options; do
  case ${options} in
    v)
      echo "-v was found!"
      echo "-v option argument is: ${OPTARG}."
      ;;
 :)
 echo "-${OPTARG} requires an argument."
 exit 1
 ;;
    ?)
      echo "Invalid option: -${OPTARG}."
      exit 1
      ;; 
  esac
done

可能有点令人困惑,错误的标志和缺少的选项参数都解析为OPTARG。不要把这种情况弄得比必要的更复杂,这一切取决于case语句块在那一刻包含?)还是:)。对于?)块,所有未被识别的内容(整个标志)都被视为选项参数,而:)块只有在optstring包含带参数选项的正确指令时才触发。

现在一切都应该按预期工作:

reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh
reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh -v
-v requires an argument.
reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh -v Hi
-v was found!
-v option argument is: Hi.
reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh -x Hi
Invalid option: -x.
reader@ubuntu:~/scripts/chapter_15$ bash single-flag.sh -x -v Hi
Invalid option: -x.

再次,由于标志的顺序处理,由于?)块中的exit 1,最终调用永远不会到达-v标志。但是,所有其他情况现在都得到了正确解决。不错!

getopts实际处理涉及多次传递和使用shift。这对于本章来说有点太技术性了,但对于你们中间感兴趣的人来说,进一步阅读部分包括了这个机制的非常深入的解释,你可以在空闲时阅读。

将标志与位置参数结合使用

可以将位置参数(在本章之前我们一直使用的方式)与选项和选项参数结合使用。在这种情况下,有一些事情需要考虑:

  • 默认情况下,Bash 将识别标志(如-f)作为位置参数

  • 就像标志和标志参数有一个顺序一样,标志和位置参数也有一个顺序

处理getopts和位置参数时,*标志和标志选项应始终在位置参数之前提供!*这是因为我们希望在到达位置参数之前解析和处理所有标志和标志参数。这对于脚本和命令行工具来说是一个相当典型的情况,但这仍然是我们必须考虑的事情。

前面的所有观点最好通过一个例子来说明,我们将创建一个简单的脚本,作为常见文件操作的包装器。有了这个脚本file-tool.sh,我们将能够做以下事情:

  • 列出文件(默认行为)

  • 删除文件(使用-d选项)

  • 清空文件(使用-e选项)

  • 重命名文件(使用-m选项,其中包括另一个文件名)

  • 调用帮助函数(使用-h

看一下脚本:

reader@ubuntu:~/scripts/chapter_15$ vim file-tool.sh 
reader@ubuntu:~/scripts/chapter_15$ cat file-tool.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-12-14
# Description: A tool which allows us to manipulate files.
# Usage: ./file-tool.sh [flags] <file-name>
#####################################

print_help() {
  echo "Usage: $0 [flags] <file-name>"
  echo "Flags:"
  echo "No flags for file listing."
  echo "-d to delete the file."
  echo "-e to empty the file."
  echo "-m <new-file-name> to rename the file."
  echo "-h for help."
}

command="ls -l" # Default command, can be overridden.

optstring=":dem:h" # The m option contains an option argument.
while getopts ${optstring} options; do
  case ${options} in
    d)
      command="rm -f";;
    e)
      command="cp /dev/null";;
    m)
      new_filename=${OPTARG}; command="mv";;
    h)
      print_help; exit 0;;
    :)
      echo "-${OPTARG} requires an argument."; exit 1;;
    ?)
      echo "Invalid option: -${OPTARG}." exit 1;; 
  esac
done

# Remove the parsed flags from the arguments array with shift.
shift $(( ${OPTIND} - 1 )) # -1 so the file-name is not shifted away.

filename=$1

# Make sure the user supplied a writable file to manipulate.
if [[ $# -ne 1 || ! -w ${filename} ]]; then
  echo "Supply a writable file to manipulate! Exiting script."
  exit 1 
fi

# Everything should be fine, execute the operation.
if [[ -n ${new_filename} ]]; then # Only set for -m.
  ${command} ${filename} $(dirname ${filename})/${new_filename}
else # Everything besides -m.
  ${command} ${filename}
fi

这是一个大的例子,不是吗?我们通过将多行压缩成单行(在case语句中)稍微缩短了一点,但它仍然不是一个短脚本。虽然一开始可能看起来令人生畏,但我们相信通过你到目前为止的接触和脚本中的注释,这对你来说应该是可以理解的。如果现在还不完全理解,不要担心——我们现在将解释所有新的有趣的行。

我们跳过了标题,print_help()函数和ls -l的默认命令。第一个有趣的部分将是optstring,它现在包含有和没有选项参数的选项:

optstring=":dem:h" # The m option contains an option argument.

当我们到达m)块时,我们将选项参数保存在new_filename变量中以供以后使用。

当我们完成getoptscase语句后,我们遇到了一个我们之前简要见过的命令:shift。这个命令允许我们移动我们的位置参数:如果我们执行shift 2,参数$4变成了$2,参数$3变成了$1,旧的$1$2被移除了。

处理标志后面的位置参数时,所有标志和标志参数也被视为位置参数。在这种情况下,如果我们将脚本称为file-tool.sh -m newfile /tmp/oldfile,Bash 将解释如下:

  • $1:被解释为-m

  • $2:被解释为一个新文件

  • $3:被解释为/tmp/oldfile

幸运的是,getopts将它处理过的选项(和选项参数)保存在一个变量中:$OPTIND(来自options index)。更准确地说,在解析了一个选项之后,它将$OPTIND设置为下一个可能的选项或选项参数:它从 1 开始,在找到传递给脚本的第一个非选项参数时结束。

在我们的示例中,一旦getopts到达我们的位置参数/tmp/oldfile$OPTIND变量将为3。由于我们只需要将该点之前的所有内容shift掉,我们从$OPTIND中减去 1,如下所示:

shift $(( ${OPTIND} - 1 )) # -1 so the file-name is not shifted away.

记住,$(( ... ))是算术的简写;得到的数字用于shift命令。脚本的其余部分非常简单:我们将进行一些检查,以确保我们只剩下一个位置参数(我们想要操作的文件的文件名),以及我们是否对该文件具有写权限。

接下来,根据我们选择的操作,我们将为mv执行一个复杂的操作,或者为其他所有操作执行一个简单的操作。对于重命名命令,我们将使用一些命令替换来确定原始文件名的目录名称,然后我们将在重命名中重用它。

如果我们像应该做的那样进行了测试,脚本应该符合我们设定的所有要求。我们鼓励你尝试一下。

更好的是,看看你是否能想出一个我们没有考虑到的情况,破坏了脚本的功能。如果你找到了什么(剧透警告:我们知道有一些缺点!),试着自己修复它们。

正如你可能开始意识到的那样,我们正在进入一个非常难以为每个用户输入加固脚本的领域。例如,在最后一个例子中,如果我们提供了-m选项但省略了内容,我们提供的文件名将被视为选项参数。在这种情况下,我们的脚本将shift掉文件名并抱怨它没有。虽然这个脚本应该用于教育目的,但我们不会相信它用于我们的工作场所脚本。最好不要将getopts与位置参数混合使用,因为这样可以避免我们在这里面对的许多复杂性。只需让用户提供文件名作为另一个选项参数(-f,任何人?),你会更加快乐!

总结

本章以回顾 Bash 中如何使用位置参数开始。我们继续向您展示了到目前为止我们介绍的大多数命令行工具(以及我们没有介绍的那些)如何使用标志,通常作为脚本功能的修饰符,而位置参数则用于指示命令的目标

然后,我们介绍了一种让读者在自己的脚本中结合选项和选项参数的方法:使用getopts shell 内置。我们从讨论传统程序getopt和较新的内置getopts之间的区别开始,然后我们在本章的其余部分重点讨论了getopts

由于getopts只允许我们使用短选项(而getopt和其他一些命令行工具也使用长选项,用双破折号表示),我们向您展示了由于识别常见的短选项(如-h-v等)而不是问题。

我们用几个例子正确介绍了getopts的语法。我们展示了如何使用带有和不带有标志参数的标志,以及我们如何需要一个optstring来向getopts发出信号,表明哪些选项有参数(以及期望哪些选项)。

我们通过聪明地使用shift命令来处理选项和选项参数与位置参数的组合,结束了这一章节。

本章介绍了以下命令:getoptsshift

问题

  1. 为什么标志经常被用作修饰符,而位置参数被用作目标?

  2. 为什么我们在while循环中运行getopts

  3. 为什么我们在case语句中需要?)

  4. 为什么我们(有时)在case语句中需要:)

  5. 如果我们无论如何都要解析所有选项,为什么还需要一个单独的optstring

  6. 为什么我们在使用shift时需要从OPTIND变量中减去 1?

  7. 将选项与位置参数混合使用是个好主意吗?

进一步阅读

请参考以下链接,了解本章主题的更多信息:

第十六章:Bash 参数替换和扩展

本章专门介绍了 Bash 的一个特殊功能:参数扩展。参数扩展允许我们对变量进行许多有趣的操作,我们将进行广泛的介绍。

我们将首先讨论变量的默认值、输入检查和变量长度。在本章的第二部分,我们将更仔细地看一下我们如何操作变量。这包括替换和删除文本中的模式,修改变量的大小写,并使用子字符串。

本章将介绍以下命令:exportdirname

本章将涵盖以下主题:

  • 参数扩展

  • 变量操作

技术要求

本章的所有脚本都可以在 GitHub 上找到,链接如下:github.com/PacktPublishing/Learn-Linux-Shell-Scripting-Fundamentals-of-Bash-4.4/tree/master/Chapter16。对于这最后一个常规章节,你的 Ubuntu 虚拟机应该能再次帮助你度过难关。

参数扩展

在倒数第二章中,最后一章是技巧和窍门,我们将讨论 Bash 的一个非常酷的功能:参数扩展

我们将首先对术语进行一些说明。首先,在 Bash 中被认为是参数扩展的东西不仅仅涉及到脚本提供的参数/参数:我们将在本章讨论的所有特殊操作都适用于 Bash 变量。在官方 Bash 手册页(man bash)中,所有这些都被称为参数。

对于脚本的位置参数,甚至带参数的选项,这是有意义的。然而,一旦我们进入由脚本创建者定义的常量领域,常量/变量和参数之间的区别就有点模糊了。这并不重要;只要记住,当你在man page中看到参数这个词时,它可能是指一般的变量。

其次,人们对术语参数扩展参数替换有些困惑,在互联网上这两个术语经常被交替使用。在官方文档中,替换这个词只用在命令替换进程替换中。

命令替换是我们讨论过的:它是$(...)的语法。进程替换非常高级,还没有描述过:如果你遇到<(...)的语法,那就是在处理进程替换。我们在本章的进一步阅读部分包括了一篇关于进程替换的文章,所以一定要看一下。

我们认为混淆的根源在于参数替换,也就是在运行时用变量名替换其值,只被认为是 Bash 中更大的参数扩展的一小部分。这就是为什么你会看到一些文章或来源将参数扩展的所有伟大功能(默认值、大小写操作和模式删除等)称为参数替换。

再次强调,这些术语经常被互换使用,人们(可能)谈论的是同一件事。如果你自己有任何疑问,我们建议在任何一台机器上打开 Bash 的man page,并坚持使用官方的称呼:参数扩展

参数替换-回顾

虽然在这一点上可能并不是必要的,但我们想快速回顾一下参数替换,以便将其放在参数扩展的更大背景中。

正如我们在介绍中所述,并且你在整本书中都看到了,参数替换只是在运行时用变量的值替换变量。在命令行中,这看起来有点像下面这样:

reader@ubuntu:~/scripts/chapter_16$ export word=Script
reader@ubuntu:~/scripts/chapter_16$ echo ${word}
Script
reader@ubuntu:~/scripts/chapter_16$ echo "You're reading: Learn Linux Shell ${word}ing"
You're reading: Learn Linux Shell Scripting
reader@ubuntu:~/scripts/chapter_16$ echo "You're reading: Learn Linux Shell $wording"
You're reading: Learn Linux Shell 

通常在回顾中你不会学到任何新东西,但因为我们只是为了背景,我们设法在这里偷偷加入了一些新东西:export命令。export是一个 shell 内置命令(可以用type -a export找到),我们可以使用help export来了解它(这是获取所有 shell 内置命令信息的方法)。

当设置变量值时,我们并不总是需要使用export:在这种情况下,我们也可以只使用word=Script。通常情况下,当我们设置一个变量时,它只在当前的 shell 中可用。在我们的 shell 的分支中运行的任何进程都不会将环境的这一部分与它们一起分叉:它们无法看到我们为变量分配的值。

虽然这并不总是必要的,但你可能会在网上寻找答案时遇到export的使用,所以了解它是很好的!

其余的示例应该不言自明。我们为一个变量赋值,并在运行时使用参数替换(在这种情况下,使用echo)来替换变量名为实际值。

作为提醒,我们将向你展示为什么我们建议始终在变量周围包含花括号:这样可以确保 Bash 知道变量的名称从何处开始和结束。在最后的echo中,我们可能会忘记这样做,我们会发现变量被错误解析,文本打印不正确。虽然并非所有脚本都需要,但我们认为这样做看起来更好,是一个你应该始终遵循的良好实践。

就我们而言,只有我们在这里涵盖的内容属于参数替换。本章中的所有其他特性都是参数扩展,我们将相应地引用它们!

默认值

接下来是参数扩展!正如我们所暗示的,Bash 允许我们直接对变量进行许多酷炫的操作。我们将从看似简单的示例开始,为变量定义默认值。

在处理用户输入时,这样做会让你和脚本用户的生活都变得更加轻松:只要有一个合理的默认值,我们就可以确保使用它,而不是在用户没有提供我们想要的信息时抛出错误。

我们将重用我们最早的一个脚本,interactive.sh,来自第八章,变量和用户输入。这是一个非常简单的脚本,没有验证用户输入,因此容易出现各种问题。让我们更新一下,并包括我们的参数的新默认值,如下所示:

reader@ubuntu:~/scripts/chapter_16$ cp ../chapter_08/interactive-arguments.sh default-interactive-arguments.sh
reader@ubuntu:~/scripts/chapter_16$ vim default-interactive-arguments.sh 
reader@ubuntu:~/scripts/chapter_16$ cat default-interactive-arguments.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-12-16
# Description: Interactive script with default variables.
# Usage: ./interactive-arguments.sh <name> <location> <food>
#####################################

# Initialize the variables from passed arguments.
character_name=${1:-Sebastiaan}
location=${2:-Utrecht}
food=${3:-frikandellen}

# Compose the story.
echo "Recently, ${character_name} was seen in ${location} eating ${food}!"

我们现在不再仅仅使用$1$2$3来获取用户输入,而是使用man bash中定义的更复杂的语法,如下所示:

$

使用默认值。 如果参数未设置或为空,将替换为 word 的扩展。否则,将替换为参数的值。

同样,你应该在这个上下文中将参数读作变量(即使在用户提供时,它实际上是参数的一个参数,但它也很可能是一个常量)。使用这种语法,如果变量未设置或为空(空字符串),则在破折号后面提供的值(在man页面中称为word)将被插入。

我们已经为所有三个参数做了这个,所以让我们看看这在实践中是如何工作的:

reader@ubuntu:~/scripts/chapter_16$ bash default-interactive-arguments.sh 
Recently, Sebastiaan was seen in Utrecht eating frikandellen!
reader@ubuntu:~/scripts/chapter_16$ bash default-interactive-arguments.sh '' Amsterdam ''
Recently, Sebastiaan was seen in Amsterdam eating frikandellen!

如果我们没有向脚本提供任何值,所有默认值都会被插入。如果我们提供了三个参数,其中两个只是空字符串(''),我们可以看到 Bash 仍然会为我们替换空字符串的默认值。然而,实际的字符串Amsterdam被正确输入到文本中,而不是Utrecht

以这种方式处理空字符串通常是期望的行为,你也可以编写你的脚本以允许空字符串作为变量的默认值。具体如下:

reader@ubuntu:~/scripts/chapter_16$ cat /tmp/default-interactive-arguments.sh 
<SNIPPED>
character_name=${1-Sebastiaan}
location=${2-Utrecht}
food=${3-frikandellen}
<SNIPPED>

reader@ubuntu:~/scripts/chapter_16$ bash /tmp/default-interactive-arguments.sh '' Amsterdam
Recently,  was seen in Amsterdam eating frikandellen!

在这里,我们创建了一个临时副本来说明这个功能。当您从默认声明中删除冒号(${1-word}而不是${1:-word})时,它不再为空字符串插入默认值。但是,对于根本没有设置的值,它会插入默认值,当我们使用'' Amsterdam而不是'' Amsterdam ''调用它时可以看到。

根据我们的经验,在大多数情况下,默认值应忽略空字符串,因此man page中呈现的语法更可取。不过,如果您有一个特殊情况,现在您已经意识到了这种可能性!

对于您的一些脚本,您可能会发现仅替换默认值是不够的:您可能更愿意将变量设置为可以更细致评估的值。这也是可能的,使用参数扩展,如下所示:

$

分配默认值。如果参数未设置或为空,则将单词的扩展分配给参数。然后替换参数的值。不能以这种方式分配位置参数和特殊参数。

我们从未见过需要使用此功能,特别是因为它与位置参数不兼容(因此,我们只在这里提到它,不详细介绍)。但是,与所有事物一样,了解参数扩展在这个领域提供的可能性是很好的。

输入检查

与使用参数扩展设置默认值密切相关,我们还可以使用参数扩展来显示如果变量为空或为空则显示错误。到目前为止,我们通过在脚本中实现 if-then 逻辑来实现这一点。虽然这是一个很好且灵活的解决方案,但有点冗长,特别是如果您只对用户提供参数感兴趣的话。

让我们创建我们之前示例的新版本:这个版本不提供默认值,但会在缺少位置参数时提醒用户。

我们将使用以下语法:

${parameter:?word}

如果参数为空或未设置,则将单词的扩展(或者如果单词不存在,则写入相应的消息)写入标准错误和 shell,如果不是交互式的,则退出。否则,替换参数的值。

当我们在脚本中使用这个时,它可能看起来像这样:

reader@ubuntu:~/scripts/chapter_16$ cp default-interactive-arguments.sh check-arguments.sh
reader@ubuntu:~/scripts/chapter_16$ vim check-arguments.sh eader@ubuntu:~/scripts/chapter_16$ cat check-arguments.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-12-16
# Description: Script with parameter expansion input checking.
# Usage: ./check-arguments.sh <name> <location> <food>
#####################################

# Initialize the variables from passed arguments.
character_name=${1:?Name not supplied!}
location=${2:?Location not supplied!}
food=${3:?Food not supplied!}

# Compose the story.
echo "Recently, ${character_name} was seen in ${location} eating ${food}!"

再次注意冒号。与前面的示例中冒号的工作方式相同,它还会强制此参数扩展将空字符串视为 null/未设置值。

当我们运行这个脚本时,我们会看到以下内容:

reader@ubuntu:~/scripts/chapter_16$ bash check-arguments.sh 
check-arguments.sh: line 12: 1: Name not supplied!
reader@ubuntu:~/scripts/chapter_16$ bash check-arguments.sh Sanne
check-arguments.sh: line 13: 2: Location not supplied!
reader@ubuntu:~/scripts/chapter_16$ bash check-arguments.sh Sanne Alkmaar
check-arguments.sh: line 14: 3: Food not supplied!
reader@ubuntu:~/scripts/chapter_16$ bash check-arguments.sh Sanne Alkmaar gnocchi
Recently, Sanne was seen in Alkmaar eating gnocchi!
reader@ubuntu:~/scripts/chapter_16$ bash check-arguments.sh Sanne Alkmaar ''
check-arguments.sh: line 14: 3: Food not supplied!

虽然这样做效果很好,但看起来并不是那么好,对吧?打印了脚本名称和行号,这对于脚本的用户来说似乎是太多深入的信息。

您可以决定您是否认为这些是可以接受的反馈消息给您的用户;就个人而言,我们认为一个好的 if-then 通常更好,但是在简洁的脚本方面,这是无法超越的。

还有另一个与此密切相关的参数扩展:${parameter:+word}。这允许您仅在参数不为空时使用word。根据我们的经验,这并不常见,但对于您的脚本需求可能会有用;在man bash中查找Use Alternate Value以获取更多信息。

参数长度

到目前为止,我们在书中进行了很多检查。然而,我们没有进行的一个是所提供参数的长度。在这一点上,您可能不会感到惊讶的是我们如何实现这一点:当然是通过参数扩展。语法也非常简单:

$

参数长度。替换参数值的字符数。如果参数是*或@,则替换的值是位置参数的数量。

所以,我们将使用${#variable}而不是${variable}来打印,后者会在运行时替换值,而前者会给我们一个数字:值中的字符数。这可能有点棘手,因为空格等内容也可以被视为字符。

看看下面的例子:

reader@ubuntu:~/scripts/chapter_16$ variable="hello"
reader@ubuntu:~/scripts/chapter_16$ echo ${#variable}
5
reader@ubuntu:~/scripts/chapter_16$ variable="hello there"
reader@ubuntu:~/scripts/chapter_16$ echo ${#variable}
11

正如你所看到的,单词hello被识别为五个字符;到目前为止一切顺利。当我们看看句子hello there时,我们可以看到两个分别有五个字母的单词。虽然你可能期望参数扩展返回10,但实际上它返回的是11。由于单词之间用空格分隔,你不应感到惊讶:这个空格是第 11 个字符。

如果我们回顾一下man bash页面上的语法定义,我们会看到以下有趣的细节:

如果参数是*或@,则替换的值是位置参数的数量。

还记得我们在本书的其余部分中使用$#来确定传递给脚本的参数数量吗?这实际上就是 Bash 参数扩展的工作,因为${#*}等于$#!

为了加深这些观点,让我们创建一个快速脚本,处理三个字母的首字母缩略词(我们个人最喜欢的缩略词类型)。目前,这个脚本的功能将仅限于验证和打印用户输入,但当我们到达本章的末尾时,我们将稍作修改,使其更加酷炫:

reader@ubuntu:~/scripts/chapter_16$ vim acronyms.sh 
reader@ubuntu:~/scripts/chapter_16$ cat acronyms.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-12-16
# Description: Verify argument length.
# Usage: ./acronyms.sh <three-letter-acronym>
#####################################

# Use full syntax for passed arguments check.
if [[ ${#*} -ne 1 ]]; then
  echo "Incorrect number of arguments!"
  echo "Usage: $0 <three-letter-acronym>"
  exit 1
fi

acronym=$1 # No need to default anything because of the check above.

# Check acronym length using parameter expansion.
if [[ ${#acronym} -ne 3 ]]; then
  echo "Acronym should be exactly three letters!"
  exit 2
fi

# All checks passed, we should be good.
echo "Your chosen three letter acronym is: ${acronym}. Nice!"

在这个脚本中,我们做了两件有趣的事情:我们使用了${#*}的完整语法来确定传递给我们脚本的参数数量,并使用${#acronym}检查了首字母缩略词的长度。因为我们使用了两种不同的检查,所以我们使用了两种不同的退出代码:对于错误的参数数量,我们使用exit 1,对于不正确的首字母缩略词长度,我们使用exit 2

在更大、更复杂的脚本中,使用不同的退出代码可能会节省大量的故障排除时间,因此我们在这里提供了相关信息。

如果我们现在用不同的不正确和正确的输入运行我们的脚本,我们可以看到它按计划运行。

reader@ubuntu:~/scripts/chapter_16$ bash acronyms.sh 
Incorrect number of arguments!
Usage: acronyms.sh <three-letter-acronym>
reader@ubuntu:~/scripts/chapter_16$ bash acronyms.sh SQL
Your chosen three letter acronym is: SQL. Nice!
reader@ubuntu:~/scripts/chapter_16$ bash acronyms.sh SQL DBA
Incorrect number of arguments!
Usage: acronyms.sh <three-letter-acronym>
reader@ubuntu:~/scripts/chapter_16$ bash acronyms.sh TARDIS
Acronym should be exactly three letters

没有参数,太多参数,参数长度不正确:我们已经准备好处理用户可能抛给我们的一切。一如既往,永远不要指望用户会按照你的期望去做,只需确保你的脚本只有在输入正确时才会执行!

变量操作

Bash 中的参数扩展不仅涉及默认值、输入检查和参数长度,它实际上还允许我们在使用变量之前操纵这些变量。在本章的第二部分中,我们将探讨参数扩展中处理变量操作(我们的术语;就 Bash 而言,这些只是普通的参数扩展)的能力。

我们将以模式替换开始,这是我们在第十章中对sed的解释后应该熟悉的内容。

模式替换

简而言之,模式替换允许我们用其他东西替换模式(谁会想到呢!)。这就是我们之前用sed已经能做的事情:

reader@ubuntu:~/scripts/chapter_16$ echo "Hi"
Hi
reader@ubuntu:~/scripts/chapter_16$ echo "Hi" | sed 's/Hi/Bye/'
Bye

最初,我们的echo包含单词Hi。然后我们通过sed进行管道传输,在其中查找模式 Hi,我们将用Bye 替换它。sed指令前面的s表示我们正在搜索和替换。

看吧,当sed解析完流之后,我们的屏幕上就会出现Bye

如果我们想在使用变量时做同样的事情,我们有两个选择:要么像之前一样通过sed解析它,要么转而使用我们的新朋友进行另一次很棒的参数扩展:

${parameter/pattern/string}

模式替换。 模式会扩展成与路径名扩展中一样的模式。参数会被扩展,模式与其值的最长匹配将被替换为字符串。如果模式以/开头,则所有模式的匹配都将被替换为字符串。

因此,对于${sentence}变量,我们可以用${sentence/pattern/string}替换模式的第一个实例,或者用${sentence//pattern/string}替换所有实例(注意额外的斜杠)。

在命令行上,它可能看起来像这样:

reader@ubuntu:~$ sentence="How much wood would a woodchuck chuck if a woodchuck could chuck wood?"
reader@ubuntu:~$ echo ${sentence}
How much wood would a woodchuck chuck if a woodchuck could chuck wood?
reader@ubuntu:~$ echo ${sentence/wood/stone}
How much stone would a woodchuck chuck if a woodchuck could chuck wood?
reader@ubuntu:~$ echo ${sentence//wood/stone}
How much stone would a stonechuck chuck if a stonechuck could chuck stone reader@ubuntu:~$ echo ${sentence}
How much wood would a woodchuck chuck if a woodchuck could chuck wood?

再次强调,这是非常直观和简单的。

一个重要的事实是,这种参数扩展实际上并不编辑变量的值:它只影响当前的替换。如果您想对变量进行永久操作,您需要再次将结果写入变量,如下所示:

reader@ubuntu:~$ sentence_mutated=${sentence//wood/stone}
reader@ubuntu:~$ echo ${sentence_mutated}
How much stone would a stonechuck chuck if a stonechuck could chuck stone?

或者,如果您希望在变异后保留变量名称,可以将变异值一次性赋回变量,如下所示:

reader@ubuntu:~$ sentence=${sentence//wood/stone}
reader@ubuntu:~$ echo ${sentence}
How much stone would a stonechuck chuck if a stonechuck could chuck stone?

想象在脚本中使用这种语法应该不难。举个简单的例子,我们创建了一个小型交互式测验,在其中,如果用户给出了错误答案,我们将帮助他们:

reader@ubuntu:~/scripts/chapter_16$ vim forbidden-word.sh
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-12-16
# Description: Blocks the use of the forbidden word!
# Usage: ./forbidden-word.sh
#####################################

read -p "What is your favorite shell? " answer

echo "Great choice, my favorite shell is also ${answer/zsh/bash}!"

reader@ubuntu:~/scripts/chapter_16$ bash forbidden-word.sh 
What is your favorite shell? bash
Great choice, my favorite shell is also bash!
reader@ubuntu:~/scripts/chapter_16$ bash forbidden-word.sh 
What is your favorite shell? zsh
Great choice, my favorite shell is also bash!

在这个脚本中,如果用户暂时困惑并且没有给出想要的答案,我们将简单地用正确答案bash替换他们的错误答案(zsh)。

开玩笑的时候到此为止,其他 shell(如zshksh,甚至较新的 fish)都有自己独特的卖点和优势,使一些用户更喜欢它们而不是 Bash 进行日常工作。这显然很好,也是使用 Linux 的心态的一部分:您有自由选择您喜欢的软件!

然而,当涉及到脚本时,我们(显然)认为 Bash 仍然是 shell 之王,即使只是因为它已经成为大多数发行版的事实标准 shell。这在可移植性和互操作性方面非常有帮助,这些特性通常对脚本有益。

模式删除

与模式替换紧密相关的一个主题是模式删除。让我们面对现实,模式删除基本上就是用空白替换模式。

如果模式删除与模式替换具有完全相同的功能,我们就不需要它。但是,模式删除有一些很酷的技巧,使用模式替换可能会很困难,甚至不可能做到。

模式删除有两个选项:删除匹配模式的前缀后缀。简单来说,它允许您从开头或结尾删除内容。它还有一个选项,可以在找到第一个匹配模式后停止,或者一直持续到最后。

没有一个好的例子,这可能有点太抽象(对我们来说,第一次遇到这种情况时肯定是这样)。然而,这里有一个很好的例子:这一切都与文件有关:

reader@ubuntu:/tmp$ touch file.txt
reader@ubuntu:/tmp$ file=/tmp/file.txt
reader@ubuntu:/tmp$ echo ${file}
/tmp/file.txt

我们创建了一个包含对文件的引用的变量。如果我们想要目录,或者不带目录的文件,我们可以使用basenamedirname,如下所示:

reader@ubuntu:/tmp$ basename ${file}
file.txt
reader@ubuntu:/tmp$ dirname ${file}
/tmp

我们也可以通过参数扩展来实现这一点。前缀和后缀删除的语法如下:

${parameter#word}

${parameter##word}

删除匹配前缀模式。 \({parameter%word}\){parameter%%word} 删除匹配后缀模式。

对于我们的${file}变量,我们可以使用参数扩展来删除所有目录,只保留文件名,如下所示:

reader@ubuntu:/tmp$ echo ${file#/}
tmp/file.txt
reader@ubuntu:/tmp$ echo ${file#*/}
tmp/file.txt
reader@ubuntu:/tmp$ echo ${file##/}
tmp/file.txt
reader@ubuntu:/tmp$ echo ${file##*/}
file.txt

第一条和第二条命令之间的区别很小:我们使用了可以匹配任何内容零次或多次的星号通配符。在这种情况下,由于变量的值以斜杠开头,它不匹配。然而,一旦我们到达第三个命令,我们就看到了需要包括它:我们需要匹配我们想要删除的所有内容

在这种情况下,*/模式匹配/tmp/,而/模式仅匹配第一个正斜杠(正如第三个命令的结果清楚显示的那样)。

值得记住的是,在这种情况下,我们仅仅是使用参数扩展来替换basename命令的功能。然而,如果我们不是在处理文件引用,而是(例如)下划线分隔的文件,我们就无法用basename来实现这一点,参数扩展就会派上用场!

既然我们已经看到了前缀的用法,让我们来看看后缀。功能是一样的,但是不是从值的开头解析,而是先从值的末尾开始。例如,我们可以使用这个功能从文件中删除扩展名:

reader@ubuntu:/tmp$ file=file.txt
reader@ubuntu:/tmp$ echo ${file%.*}
file

这使我们能够获取文件名,不包括扩展名。如果你的脚本中有一些逻辑可以应用到文件的这一部分,这可能是可取的。根据我们的经验,这比你想象的要常见!

例如,你可能想象一下备份文件名中有一个日期,你想将其与今天的日期进行比较,以确保备份成功。一点点的参数扩展就可以让你得到你想要的格式,这样日期的比较就变得微不足道了。

就像我们能够替换basename命令一样,我们也可以使用后缀模式删除来找到dirname,如下所示:

reader@ubuntu:/tmp$ file=/tmp/file.txt
reader@ubuntu:/tmp$ echo ${file%/*}
/tmp

再次强调,这些示例主要用于教育目的。有许多情况下这可能会有用;由于这些情况非常多样化,很难给出一个对每个人都有趣的例子。

然而,我们介绍的关于备份的情况可能对你有用。作为一个基本的脚本,它看起来会是这样的:

reader@ubuntu:~/scripts/chapter_16$ vim check-backup.sh
reader@ubuntu:~/scripts/chapter_16$ cat check-backup.sh 
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-12-16
# Description: Check if daily backup has succeeded.
# Usage: ./check-backup.sh <file>
#####################################

# Format the date: yyyymmdd.
DATE_FORMAT=$(date +%Y%m%d)

# Use basename to remove directory, expansion to remove extension.
file=$(basename ${1%%.*}) # Double %% so .tar.gz works too.

if [[ ${file} == "backup-${DATE_FORMAT}" ]]; then
  echo "Backup with todays date found, all good."
  exit 0 # Successful.
else
  echo "No backup with todays date found, please double check!"
  exit 1 # Unsuccessful.
fi

reader@ubuntu:~/scripts/chapter_16$ touch /tmp/backup-20181215.tar.gz
reader@ubuntu:~/scripts/chapter_16$ touch /tmp/backup-20181216.tar.gz
reader@ubuntu:~/scripts/chapter_16$ bash -x check-backup.sh /tmp/backup-20181216.tar.gz 
++ date +%Y%m%d
+ DATE_FORMAT=20181216
++ basename /tmp/backup-20181216
+ file=backup-20181216
+ [[ backup-20181216 == backup-20181216 ]]
+ echo 'Backup with todays date found, all good.'
Backup with todays date found, all good.
+ exit 0
reader@ubuntu:~/scripts/chapter_16$ bash check-backup.sh /tmp/backup-20181215.tar.gz 
No backup with todays date found, please double check!

为了说明这一点,我们正在创建虚拟备份文件。在实际情况下,你更有可能在目录中挑选最新的文件(例如使用ls -ltr /backups/ | awk '{print $9}' | tail -1)并将其与当前日期进行比较。

与 Bash 脚本中的大多数事物一样,还有其他方法可以完成这个日期检查。你可以说我们可以保留文件变量中的扩展名,并使用解析日期的正则表达式:这样也可以,工作量几乎相同。

这个例子(以及整本书)的要点应该是使用对你和你的组织有用的东西,只要你以稳固的方式构建它,并为每个人添加必要的注释,让大家都能理解你做了什么!

大小写修改

接下来是另一个参数扩展,我们已经简要看到了:大小写修改。在这种情况下,大小写是指小写和大写字母。

在我们最初在第九章中创建的yes-no-optimized.sh脚本中,错误检查和处理,我们有以下指令:

reader@ubuntu:~/scripts/chapter_09$ cat yes-no-optimized.sh 
<SNIPPED>
read -p "Do you like this question? " reply_variable

# See if the user responded positively.
if [[ ${reply_variable,,} = 'y' || ${reply_variable,,} = 'yes' ]]; then
  echo "Great, I worked really hard on it!"
  exit 0
fi

# Maybe the user responded negatively?
if [[ ${reply_variable^^} = 'N' || ${reply_variable^^} = 'NO' ]]; then
  echo "You did not? But I worked so hard on it!"
  exit 0
fi

正如你所期望的那样,在变量的花括号中找到的,,^^是我们所讨论的参数扩展。

man bash中所述的语法如下:

${parameter^pattern}

${parameter^^pattern}

${parameter,pattern}

${parameter,,pattern}

大小写修改。 这个扩展修改参数中字母字符的大小写。模式被扩展以产生一个与路径名扩展中一样的模式。参数的扩展值中的每个字符都与模式进行匹配,如果匹配模式,则其大小写被转换。模式不应尝试匹配多于一个字符。

在我们的第一个脚本中,我们没有使用模式。当不使用模式时,暗示着模式是通配符(在这种情况下是?),这意味着一切都匹配。

快速的命令行示例可以清楚地说明如何进行大小写修改。首先,让我们看看如何将变量转换为大写:

reader@ubuntu:~/scripts/chapter_16$ string=yes
reader@ubuntu:~/scripts/chapter_16$ echo ${string}
yes
reader@ubuntu:~/scripts/chapter_16$ echo ${string^}
Yes
reader@ubuntu:~/scripts/chapter_16$ echo ${string^^}
YES

如果我们使用单个插入符(^),我们可以看到我们变量值的第一个字母将变成大写。如果我们使用双插入符,^^,我们现在有了全部大写的值。

以类似的方式,逗号也可以用于小写:

reader@ubuntu:~/scripts/chapter_16$ STRING=YES
reader@ubuntu:~/scripts/chapter_16$ echo ${STRING}
YES
reader@ubuntu:~/scripts/chapter_16$ echo ${STRING,}
yES
reader@ubuntu:~/scripts/chapter_16$ echo ${STRING,,}
yes

因为我们可以选择将整个值大写或小写,所以现在我们可以更容易地将用户输入与预定义值进行比较。无论用户输入YESYes还是yes,我们都可以通过单个检查来验证所有这些情况:${input,,} == 'yes'

这可以减少用户的头疼,而一个快乐的用户正是我们想要的(记住,你经常是你自己脚本的用户,你也应该快乐!)。

现在,关于模式,就像man page指定的那样。根据我们的个人经验,我们还没有使用过这个选项,但它是强大和灵活的,所以多解释一点也没有坏处。

基本上,只有在模式匹配时才会执行大小写修改。这可能有点棘手,但你可以看到它是如何工作的:

reader@ubuntu:~/scripts/chapter_16$ animal=salamander
reader@ubuntu:~/scripts/chapter_16$ echo ${animal^a}
salamander
reader@ubuntu:~/scripts/chapter_16$ echo ${animal^^a}
sAlAmAnder
reader@ubuntu:~/scripts/chapter_16$ echo ${animal^^ae}
salamander
reader@ubuntu:~/scripts/chapter_16$ echo ${animal^^[ae]}
sAlAmAndEr

我们运行的第一个命令${animal^a},只有在匹配模式a时才会将第一个字母大写。由于第一个字母实际上是s,整个单词被打印为小写。

对于下一个命令${animal^^a}所有匹配的字母都会被大写。因此,单词salamander中的所有三个a实例都会变成大写。

在第三个命令中,我们尝试向模式添加一个额外的字母。由于这不是正确的做法,参数扩展(可能)试图找到一个单个字母来匹配模式中的两个字母。剧透警告:这是不可能的。一旦我们将一些正则表达式专业知识融入其中,我们就可以做我们想做的事情:通过使用[ae],我们指定ae都是大小写修改操作的有效目标。

最后,返回的动物现在是sAlAmAndEr,所有元音字母都使用自定义模式和大小写修改参数扩展为大写!

作为一个小小的奖励,我们想分享一个甚至在man bash页面上都没有的大小写修改!它也不是那么复杂。如果你用波浪号~替换逗号,或插入符^,你将得到一个大小写反转。正如你可能期望的那样,单个波浪号只会作用于第一个字母(如果匹配模式的话),而双波浪号将匹配模式的所有实例(如果没有指定模式并且使用默认的?)。

看一下:

reader@ubuntu:~/scripts/chapter_16$ name=Sebastiaan
reader@ubuntu:~/scripts/chapter_16$ echo ${name}
Sebastiaan
reader@ubuntu:~/scripts/chapter_16$ echo ${name~}
sebastiaan
reader@ubuntu:~/scripts/chapter_16$ echo ${name~~}
sEBASTIAAN reader@ubuntu:~/scripts/chapter_16$ echo ${name~~a}
SebAstiAAn

这应该足够解释大小写修改,因为所有的语法都是相似和可预测的。

现在你知道如何将变量转换为小写、大写,甚至反转大小写,你应该能够以任何你喜欢的方式改变它们,特别是如果你加入一个模式,这个参数扩展提供了许多可能性!

子字符串扩展

关于参数扩展,只剩下一个主题:子字符串扩展。虽然你可能听说过子字符串,但它也可能是一个非常复杂的术语。

幸运的是,这实际上是非常非常简单的。如果我们拿一个字符串,比如今天是一个伟大的一天,那么这个句子的任何部分,只要顺序正确但不是完整的句子,都可以被视为完整字符串的子字符串。例如:

  • 今天是

  • 一个伟大的一天

  • day is a gre

  • 今天是一个伟大的一天

  • o

  • (<- 这里有一个空格,你只是看不到它)

从这些例子中可以看出,我们并不关注句子的语义意义,而只是关注字符:任意数量的字符按正确的顺序可以被视为子字符串。这包括整个句子减去一个字母,但也包括单个字母,甚至是单个空格字符。

因此,让我们最后一次看一下这个参数扩展的语法:

$

$

子字符串扩展。 从偏移量指定的字符开始,将参数值的长度扩展到长度个字符。

基本上,我们指定了子字符串应该从哪里开始,以及应该有多长(以字符为单位)。与大多数计算机一样,第一个字符将被视为0(而不是任何非技术人员可能期望的1)。如果我们省略长度,我们将得到偏移量之后的所有内容;如果我们指定了长度,我们将得到确切数量的字符。

让我们看看这对我们的句子会怎么样:

reader@ubuntu:~/scripts/chapter_16$ sentence="Today is a great day"
reader@ubuntu:~/scripts/chapter_16$ echo ${sentence}
Today is a great day
reader@ubuntu:~/scripts/chapter_16$ echo ${sentence:0:5}
Today
reader@ubuntu:~/scripts/chapter_16$ echo ${sentence:1:6}
oday is
reader@ubuntu:~/scripts/chapter_16$ echo ${sentence:11}
great day

在我们的命令行示例中,我们首先创建包含先前给定文本的${sentence}变量。首先,我们完全echo它,然后我们使用${sentence:0:5}只打印前五个字符(记住,字符串从 0 开始!)。

接下来,我们打印从第二个字符开始的前六个字符(由:1:6表示)。在最后一个命令中,echo ${sentence:11}显示我们也可以在不指定长度的情况下使用子字符串扩展。在这种情况下,Bash 将简单地打印从偏移量到变量值结束的所有内容。

我们想以前面承诺的方式结束本章:我们的三个字母缩写脚本。现在我们知道如何轻松地从用户输入中提取单独的字母,创建一个咒语会很有趣!

让我们修改脚本:

reader@ubuntu:~/scripts/chapter_16$ cp acronyms.sh acronym-chant.sh
reader@ubuntu:~/scripts/chapter_16$ vim acronym-chant.sh
reader@ubuntu:~/scripts/chapter_16$ cat acronym-chant.sh
#!/bin/bash

#####################################
# Author: Sebastiaan Tammer
# Version: v1.0.0
# Date: 2018-12-16
# Description: Verify argument length, with a chant!
# Usage: ./acronym-chant.sh <three-letter-acronym>
#####################################
<SNIPPED>

# Split the string into three letters using substring expansion.
first_letter=${acronym:0:1}
second_letter=${acronym:1:1}
third_letter=${acronym:2:1}

# Print our chant.
echo "Give me the ${first_letter^}!"
echo "Give me the ${second_letter^}!"
echo "Give me the ${third_letter^}!"

echo "What does that make? ${acronym^^}!"

我们还加入了一些大小写修改以确保万无一失。在我们使用子字符串扩展拆分字母之后,我们无法确定用户呈现给我们的大小写。由于这是一首咒语,我们假设大写不是一个坏主意,我们将所有内容都转换为大写。

对于单个字母,一个插入符就足够了。对于完整的首字母缩写,我们使用双插入符,以便所有三个字符都是大写。使用${acronym:0:1}${acronym:1:1}${acronym:2:1}的子字符串扩展,我们能够获得单个字母(因为长度总是 1,但偏移量不同)。

为了重要的可读性,我们将这些字母分配给它们自己的变量,然后再使用它们。我们也可以直接在echo中使用${acronym:0:1},但由于这个脚本不太长,我们选择了更冗长的额外变量选项,其中名称透露了我们通过子字符串扩展实现的目标。

最后,让我们运行这个最后的脚本,享受我们的个人咒语:

reader@ubuntu:~/scripts/chapter_16$ bash acronym-chant.sh Sql
Give me the S!
Give me the Q!
Give me the L!
What does that make? SQL!
reader@ubuntu:~/scripts/chapter_16$ bash acronym-chant.sh dba
Give me the D!
Give me the B!
Give me the A!
What does that make? DBA!
reader@ubuntu:~/scripts/chapter_16$ bash acronym-chant.sh USA
Give me the U!
Give me the S!
Give me the A!
What does that make? USA!

大小写混合,小写,大写,都无所谓:无论用户输入什么,只要是三个字符,我们的咒语就能正常工作。好东西!谁知道子字符串扩展可以如此方便呢?

一个非常高级的参数扩展功能是所谓的参数转换。它的语法${parameter@operator}允许对参数执行一些复杂的操作。要了解这可以做什么,转到man bash并查找参数转换。你可能永远不需要它,但功能确实很酷,所以绝对值得一看!

总结

在本章中,我们讨论了 Bash 中的参数扩展。我们首先回顾了我们如何在本书的大部分内容中使用参数替换,以及参数替换只是 Bash 参数扩展的一小部分。

我们继续向你展示如何使用参数扩展来包括变量的默认值,以防用户没有提供自己的值。这个功能还允许我们在输入缺失时向用户呈现错误消息,尽管不是最干净的方式。

我们通过展示如何使用这个来确定变量值的长度来结束了参数扩展的介绍,并且我们向你展示了我们在书中已经广泛使用了这个形式的$#语法。

我们在“变量操作”标题下继续描述参数扩展的功能。这包括“模式替换”的功能,它允许我们用另一个字符串替换变量值的一部分(“模式”)。在非常相似的功能中,“模式删除”允许我们删除与模式匹配的部分值。

接下来,我们向您展示了如何将字符从小写转换为大写,反之亦然。这个功能在本书的早期已经提到,但现在我们已经更深入地解释了它。

我们以“子字符串扩展”结束了本章,它允许我们从“偏移量”和/或指定的“长度”中获取变量的部分。

本章介绍了以下命令:exportdirname

问题

  1. 什么是参数替换?

  2. 我们如何为已定义的变量包含默认值?

  3. 我们如何使用参数扩展来处理缺失的参数值?

  4. ${#*}是什么意思?

  5. 在谈论参数扩展时,模式替换是如何工作的?

  6. 模式删除与模式替换有什么关系?

  7. 我们可以执行哪些类型的大小写修改?

  8. 我们可以使用哪两种方法从变量的值中获取子字符串?

进一步阅读

有关本章主题的更多信息,请参考以下链接:

第十七章:速查表中的技巧和技巧

在这最后一章中,我们收集了一些提示和技巧,以帮助您在脚本编写的旅程中。首先,我们将涉及一些重要但在早期章节中没有直接提到的主题。然后,我们将向您展示一些命令行的实用快捷方式,这应该有助于您在使用终端时提高速度。最后,我们将以一张我们在本书中讨论过的最重要的交互式命令的速查表结束。

本章将介绍以下命令:historyclear

本章将涵盖以下主题:

  • 一般的提示和技巧

  • 命令行快捷方式

  • 交互式命令速查表

技术要求

由于本章主要是关于提示,所以没有像我们在早期章节中看到的脚本。要真正了解这些技巧的感觉,您应该自己尝试一下。作为最后的告别,您的 Ubuntu 虚拟机可以在这最后一次为您提供帮助!

一般的提示和技巧

在本章的第一部分中,我们将描述一些我们无法在书的其他部分中恰当放置的事物。除了第一个主题数组之外,historyalias在脚本编写的上下文中并不真正使用,因此我们选择在这里介绍它们。但首先是数组!

数组

如果您来自开发背景或曾涉足编程,您可能已经遇到过数组这个术语。如果我们需要用一句话来解释数组,它会是这样的:数组允许我们存储相同类型的数据集合。为了让这个概念不那么抽象,我们将向您展示如何在 Bash 中创建一个字符串数组

reader@ubuntu:~$ array=("This" "is" "an" "array")
reader@ubuntu:~$ echo ${array[0]}
This
reader@ubuntu:~$ echo ${array[1]}
is
reader@ubuntu:~$ echo ${array[2]}
an
reader@ubuntu:~$ echo ${array[3]}
array

在这个字符串数组中,我们放置了四个元素:

  • 一个

  • 数组

如果我们想要打印数组中第一个位置的字符串,我们需要使用echo ${array[0]}语法来指定我们想要的零位置。请记住,正如在 IT 中常见的那样,列表中的第一项通常在 0 位置找到。现在,看看如果我们尝试获取第四个位置,因此第五个值(不存在)会发生什么:

reader@ubuntu:~$ echo ${array[4]}
 # <- Nothing is printed here.
reader@ubuntu:~$ echo $?
0
reader@ubuntu:~$ echo ${array[*]}
This is an array

奇怪的是,即使我们要求获取数组中不存在的位置的值,Bash 也不认为这是一个错误。如果在某些编程语言中(如 Java)中执行相同操作,你会看到类似**ArrayIndexOutOfBoundsException**的错误。如你所见,在0的退出状态之后,如果我们想要打印数组中的所有值,我们使用星号(作为通配符)。

在我们的脚本示例中,为了使其更简单一些,当我们需要创建一个列表时,我们使用了空格分隔的字符串(参考脚本**for-simple.sh**,来自第十一章,条件测试和脚本循环)。根据我们的经验,对于大多数情况来说,这通常更容易使用并且足够强大。然而,如果对于您的脚本挑战来说似乎不是这种情况,请记住 Bash 中存在数组这样的东西,也许这对您有用。

历史命令

Bash 中一个非常强大和酷的命令是history。简而言之,默认情况下,Bash 会存储您输入的所有命令的历史记录。这些保存在一定的阈值内,对于我们的 Ubuntu 18.04 安装来说,内存中保存了 1,000 个命令,磁盘上保存了 2,000 个命令。每次您干净地退出/注销终端时,Bash 都会将内存中的命令历史记录写入磁盘,同时考虑这两个限制。

在我们深入之前,让我们来看看**reader**用户的个人历史记录:

reader@ubuntu:~$ history
 1013  date
 1014  at 11:49 << wall "Hi"
 1015  at 11:49 <<< wall "Hi"
 1016  echo 'wall "Hi"' | at 11:49
<SNIPPED>
 1998  array=("This" "is" "an" "array")
 1999  echo ${array[0]}
 2000  echo ${array[1]}
 2001  echo ${array[2]}
 2002  echo ${array[3]}
 2003  echo ${array[4]}
 2004  echo ${array[*]}

尽管我们的历史非常有趣,但在这里完全打印出来并不那么有趣。通常,如果在实践中使用这个命令,它也很容易变成信息的过载。我们建议您以以下方式使用history命令:

  • history | less

  • history | grep sed

如果将其传输到less,您将得到一个漂亮的分页器,可以轻松滚动并使用搜索功能。当您使用**q**退出时,您将回到整洁的终端。如果您正在寻找特定命令(例如sed),您还可以通过grep命令过滤history的输出。如果这仍然太粗糙,考虑在grep后面添加| less,再次使用分页器。

历史记录的配置可以在一些环境变量中找到,这些环境变量通常在您的**~/.bashrc**文件中设置:

reader@ubuntu:~$ cat .bashrc
<SNIPPED>
# for setting history length see HISTSIZE and HISTFILESIZE in bash(1)
HISTSIZE=1000
HISTFILESIZE=2000
<SNIPPED>

在这里,您可以看到我们已经宣布的两个默认值(如果需要,可以进行编辑!)。对于其他命令,man bash会告诉您以下内容:

  • HISTCONTROL

  • HISTFILE

  • HISTTIMEFORMAT

一定要快速阅读一下。不要低估history命令的便利性;您肯定会几乎记得以前如何使用命令,如果您记得足够多,可以使用history找出您做了什么,以便再次执行。

创建您自己的别名

Bash 允许您为命令创建自己的别名。我们已经在第十四章 调度和日志中介绍过这一点,但对于日常任务来说,值得进一步探索一下。语法非常简单:

alias name=value

在这个语法中,alias是命令,name是您在终端上调用alias时的名称,value是您调用alias时实际调用的内容。对于交互式工作,这可能看起来像下面这样:

reader@ubuntu:~$ alias message='echo "Hello world!"'
reader@ubuntu:~$ message
Hello world!

我们创建了别名message,当调用时实际上执行echo "Hello world!"。对于一些经验丰富的人来说,您无疑已经使用了"command" ll一段时间了。您可能(或可能不)记得,这是一个常见的默认alias。我们可以使用-p标志打印当前设置的别名:

reader@ubuntu:~$ alias -p
<SNIPPED>
alias grep='grep --color=auto'
alias l='ls -CF'
alias la='ls -A'
alias ll='ls -alF'
alias ls='ls --color=auto'
alias message='echo "Hello world!"'

如您所见,默认情况下我们设置了一些别名,我们刚刚创建的别名也在其中。更有趣的是,我们可以使用alias覆盖一个命令,比如上面的ls。在本书的示例中,我们使用ls的所有时间,实际上都在执行ls --color=autogrep也是如此。ll别名快速允许我们使用ls的常见、几乎必要的标志。但是,您应该意识到这些别名是特定于发行版的。例如,看看我 Arch Linux 主机上的ll别名:

[tammert@caladan ~]$ alias -p
alias ll='ls -lh'
<SNIPPED>

这与我们的 Ubuntu 机器不同。至少,这引出了一个问题:这些默认别名是在哪里设置的?如果您记得我们在第十四章 调度和日志中关于**/etc/profile****/etc/bash.bashrc****~/.profile****~/.bashrc**的解释,我们知道这些文件是最有可能的候选者。根据经验,您可以期望大多数别名在**~/.bashrc**文件中:

reader@ubuntu:~$ cat .bashrc
<SNIPPED>
# some more ls aliases
alias ll='ls -alF'
alias la='ls -A'
alias l='ls -CF'
<SNIPPED>

如果您经常使用某些命令或者想要默认包含某些标志,可以编辑您的**~/.bashrc**文件,并添加尽可能多的alias命令。.bashrc文件中的任何命令都会在您登录时运行。如果要使别名在整个系统范围内可用,最好将alias命令包含在**/etc/profile****/etc/bash.bashrc**文件中。否则,您将不得不编辑所有用户(当前和未来)的个人.bashrc文件(这是低效的,因此您甚至不应该考虑这一点)。

命令行快捷方式

除了本章第一部分中命令的便利之外,还有另一种节省时间的方法,这不一定需要在 shell 脚本的上下文中讨论,但它仍然是一个很大的优势,我们觉得如果不与您分享,会感到很遗憾:命令行快捷方式。

感叹号的乐趣

感叹号通常用于强调文本,但在 Bash 下它们实际上是一个shell关键字:

reader@ubuntu:~$ type -a !
! is a shell keyword

虽然术语“shell 关键字”并不能真正告诉我们它的作用,但感叹号可以实现多种功能。我们已经看到其中一个:如果我们想要否定一个test,我们可以在检查中使用感叹号。如果您想在终端上验证这一点,请尝试以下操作,使用truefalse

reader@ubuntu:~$ true
reader@ubuntu:~$ echo $?
0
reader@ubuntu:~$ ! true
reader@ubuntu:~$ echo $?
1

正如您所看到的,感叹号可以颠倒退出状态:true 变为 false,false 变为 true。感叹号的另一个很酷的功能是,双感叹号将在命令行中用完整的上一个命令替换,如下所示:

reader@ubuntu:~$ echo "Hello world!"
Hello world!
reader@ubuntu:~$ !!
echo "Hello world!"
Hello world!

为了确保您清楚地知道您正在重复什么,该命令将与命令的输出一起打印到 stdout。而且,我们还可以通过使用数字和冒号与感叹号相结合来选择要重复的命令的哪一部分。与往常一样,0保留给第一个参数,1保留给第二个参数,依此类推。这方面的一个很好的例子如下:

reader@ubuntu:/tmp$ touch file
reader@ubuntu:/tmp$ cp file new_file # cp=0, file=1, new_file=2
reader@ubuntu:/tmp$ ls -l !:1 # Substituted as file.
ls -l file
-rw-r--r-- 1 reader reader 0 Dec 22 19:11 file
reader@ubuntu:/tmp$ echo !:1
echo -l
-l

前面的例子显示,我们使用**!:1**来替换上一个命令的第二个单词。请注意,如果我们对ls -l file命令重复此操作,第二个单词实际上是ls命令的-l标志,因此不要假设只有完整的命令被解析;这是一个简单的空格分隔的索引。

在我们看来,感叹号的一个杀手功能是!$构造。这是相同类型的替换,正如您可能从vim**$**的工作方式猜到的那样,它会替换上一个命令的最后一个单词。虽然这可能看起来不是那么重要,但看看上一个命令的最后一个单词有多少次是可以重用的:

reader@ubuntu:/tmp$ mkdir newdir
reader@ubuntu:/tmp$ cd !$
cd newdir reader@ubuntu:/tmp/newdir

或者,当复制要编辑的文件时:

reader@ubuntu:/tmp$ cp file new_file 
reader@ubuntu:/tmp$ vim !$
vim new_file

一旦您开始在实践中使用它,您会发现这个技巧几乎可以适用于许多命令,它几乎立即就会为您节省时间。在这些示例中,名称很短,但是如果我们谈论长路径名,我们要么必须将手从键盘上拿开,用鼠标复制/粘贴,要么重新输入所有内容。当一个简单的**!$**就能解决问题时,您为什么要这样做呢?

同样,这可以迅速成为一个救命稻草,有一个极好的例子可以说明何时使用**!!**。看看以下每个人都遇到过或迟早会遇到的情况:

reader@ubuntu:~$ cat /etc/shadow
cat: /etc/shadow: Permission denied
reader@ubuntu:~$ sudo !!
sudo cat /etc/shadow
[sudo] password for reader: 
root:*:17647:0:99999:7:::
daemon:*:17647:0:99999:7:::
bin:*:17647:0:99999:7:::
<SNIPPED>

当您忘记在命令前添加sudo(因为它是特权命令或操作特权文件)时,您可以选择:

  • 再次输入整个命令

  • 使用鼠标复制并粘贴命令

  • 使用上箭头,然后按 Home 键,输入sudo

  • 或者只需键入sudo !!

很明显哪个是最短和最容易的,因此我们更倾向于使用它。要意识到,这种简单性也意味着责任:如果您尝试删除不应删除的文件,并且在没有充分考虑的情况下迅速使用sudo !!,您的系统可能会立即消失。警告仍然存在:在以**root**sudo身份交互时,运行命令之前一定要三思。

从历史记录中运行命令

我们发现与感叹号相关的最值得注意的最后一件事是与历史记录的交互。就像您在几页前学到的那样,历史记录保存了您的命令。使用感叹号,您可以快速从历史记录中运行命令:可以通过提供命令的编号(例如!100)或输入命令的一部分(例如:!ls)来运行。根据我们的经验,这些功能并没有像我们即将解释的反向搜索那样经常使用,但了解这个功能仍然是很好的。

让我们看看这在实践中是什么样子:

reader@ubuntu:~$ history | grep 100
 1100  date
 2033  history | grep 100
reader@ubuntu:~$ !1100
date
Sat Dec 22 19:27:55 UTC 2018
reader@ubuntu:~$ !ls
ls -al
total 152
drwxr-xr-x  7 reader reader  4096 Dec 22 19:20 .
drwxr-xr-x  3 root   root    4096 Nov 10 14:35 ..
-rw-rw-r--  1 reader reader  1530 Nov 17 20:47 bash-function-library.sh
<SNIPPED>

通过提供数字,!1100再次运行了date命令。你应该意识到,一旦历史记录达到最大值,它将会改变。今天等于!1100的命令可能下周会完全不同。实际上,这被认为是一种冒险的举动,通常最好避免,因为你不会得到确认:你看到正在执行的内容,当它正在运行时(或者可能是在你看到你运行的内容时已经完成)。只有在检查历史记录后,你才能确定,而在这种情况下,你并没有节省任何时间,只是使用了额外的时间。

然而,有趣的是,基于命令本身重复一个命令,比如!ls显示的。这仍然有些冒险,特别是如果与rm等破坏性命令结合使用,但如果你确定最后一个与感叹号查询匹配的命令是什么,你应该相对安全(特别是对于catls等非破坏性命令)。再次,在你开始将这种做法融入到你的日常生活之前,一定要确保继续阅读,直到我们解释了反向搜索。在那时,我们期望/希望这些对你来说更有趣,然后你可以把这里的信息存档为好知识

键盘快捷键

我们要讨论的下一个快捷方式类别是键盘快捷键。与之前的命令和 shell 关键字相比,这些只是修改命令行上的事物的键盘组合。我们要讨论的组合都是通过使用CTRL键作为修饰符来工作的:你按住CTRL键,然后按下另一个键,例如t。我们将像在本书的其余部分一样描述这个为CTRL+t。说到**CTRL+t**,这实际上是我们想要讨论的第一个快捷键!当你打错字时,你可以使用CTRL+t

reader@ubuntu:~$ head /etc/passdw
# Last two letters are swapped, press CTRL+t to swap them:
reader@ubuntu:~$ head /etc/passwd

由于终端被修改,很难准确地表示这些页面。我们在行之间包含了一条注释,以显示我们做了什么以及我们做了什么改变。然而,在你的终端中,你只会看到一行。试一试吧。通过按下CTRL+t,你可以随意交换最后两个字符。请注意,它也考虑了空格:如果你已经按下了空格键,你将会交换空格和最后一个字母,就像这样:

reader@ubuntu:~$ sl 
# CTRL+t
reader@ubuntu:~$ s l

如果你开始使用这个快捷键,你很快就会意识到交换两个字母比你最初期望的要常见得多。与 Bash 中的大多数事物一样,这个功能之所以存在是因为人们使用它,所以如果这对你来说发生得太频繁,你不需要为自己感到难过!至少有了这个快捷键,你可以快速地减轻错误。

接下来是**CTRL+l**快捷键(小写的L),实际上是一个命令的快捷键:clear。clear 的功能几乎和命令的名字一样简单:clear - 清除终端屏幕(来自man clear)。这实际上是一个我们在每个终端会话中广泛使用的快捷键(以及命令)。一旦你到达终端仿真器屏幕的底部,上面有很多混乱,你可能会注意到这不像你开始时的空终端那样好用(我们的个人意见,也许你也有同感)。如果你想清理这些,你可以使用CTRL+l快捷键,或者简单地输入clear命令。当你清除终端时,输出并没有消失:你可以随时向上滚动(通常通过鼠标滚轮或SHIFT+page-up)来查看被清除的内容。但至少你的光标在一个干净的屏幕顶部!

还有一个exit命令的快捷键,**CTRL+d**。这不仅适用于退出 SSH 会话,还适用于许多其他交互提示:一个很好的例子是at(实际上,你需要使用CTRL+d来退出at提示,因为exit将被解释为一个要运行的命令!)。正如你所知,**CTRL+c**发送一个取消到正在运行的命令(在 Linux 下有许多取消/终止的强度,技术上是一个 SIGINT),所以一定不要混淆CTRL+dCTRL+c

关于导航,有两个基于 CTRL 的快捷键通常比它们的替代方案更容易到达:**CTRL+e****CTRL+a****CTRL+e**将光标移动到行的末尾,类似于 END 键的功能。正如你所期望的,**CTRL+a**则相反:它作为 HOME 键的替代功能。特别是对于那些熟练使用触摸打字的人来说,这些快捷键比将右手移开主键行找到END/HOME键更快。

从终端复制和粘贴

在基于 GUI 的系统中,常见的事情是剪切和粘贴文本。你会选择文本,通常用鼠标,然后要么使用右键复制和粘贴,或者希望你已经找到了老式的**CTRL+c****CTRL+v**(对于 Windows,macOS 的 Command 键)。正如我们之前解释过并在两段前提醒过你的,Linux 下的CTRL+c绝对不是复制,而是取消。同样,CTRL+v也很可能不会粘贴文本。那么,在 Linux 下,我们如何复制和粘贴呢?

首先,如果你正在使用 SSH 和 GUI 桌面内的终端仿真器,你可以使用右键来完成这个操作(或者,如果你感觉非常高级,按下中键通常也默认为粘贴!)。你可以从互联网上的某个地方选择文本,例如,复制它,并用任一按钮粘贴到你的终端仿真器中。然而,我们总是努力优化我们的流程,一旦你需要抓住鼠标,你就会浪费宝贵的时间。对于你已经复制的文本,(对于大多数终端仿真器!)有一个快捷键可以粘贴:**SHIFT+insert**。只是让你知道,这个粘贴快捷键不仅限于 Linux 或大多数终端仿真器:它似乎是相当通用的,在 Windows 和带有 GUI 的 Linux 上也可以工作。就我们个人而言,我们几乎完全用SHIFT+insert替代了CTRL+v来满足我们的粘贴需求。

显然,如果我们可以以这种方式粘贴,那么也一定有一种类似的复制方式。这非常类似:复制可以用**CTRL+insert**来完成。同样,这不仅限于 Linux 或终端:在 Windows 上也可以很好地工作。对于我们这些在 Linux 和 Windows 上工作的人来说,用CTRL+insertSHIFT+insert替换CTRL+cCTRL+v确保我们无论在哪种环境下都能正确地复制和粘贴。就我们个人而言,我们在家里使用 Linux,但在工作中使用 Windows,这意味着我们的时间大约 50/50 地花在操作系统上:相信我们,总是能够正常工作的快捷键非常好!

现在,上面的方法仍然有点依赖于鼠标。大多数情况下(根据你的工作,可能超过 95%),这是成立的,但有时你可能根本没有鼠标(例如,当直接连接到数据中心的服务器的终端时)。幸运的是,Bash 中有三个快捷键可以让我们在命令行上直接剪切和粘贴:

  • **CTRL+w**:剪切光标前的单词

  • **CTRL+u**:剪切光标前的整行

  • **CTRL+y**:粘贴所有被剪切的内容(使用上面的两个命令,而不是一般的操作系统剪贴板!)

除了能够剪切和粘贴,CTRL+w也非常适合从命令行中删除一个完整的单词。看下面的例子:

reader@ubuntu:~$ sudo cat /etc/passwd # Wrong file, we meant /etc/shadow!
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
<SNIPPED>
# Up-arrow
reader@ubuntu:~$ sudo cat /etc/passwd
# CTRL+w
reader@ubuntu:~$ sudo cat # Ready to type /etc/shadow here.

经常发生的一件事是给命令提供一个不正确的最终参数。如果你想快速修改这个问题,只需简单地按一下向上箭头,然后按CTRL+w,就会将上一个命令减去最终参数的部分重新放回终端。现在,你只需要给它正确的参数再次运行。或者,你也可以:

  • 重新输入整个命令

  • 使用鼠标滚动、复制和粘贴

  • 向上箭头后跟一些退格键

根据我们的经验,双击键总是比所有其他可能的解决方案更快。只有最后一个参数是单个字符时,使用向上箭头退格键才会同样快,这有点牵强。

现在,在前面的例子中,我们实际上并不只是删除最终参数,我们实际上是剪切它。当你剪切一个参数时,它会给你重新粘贴的能力。正如所述,这是一个特定于 Bash 的剪贴板,它不与系统剪贴板绑定;虽然你可能认为粘贴总是用SHIFT+insert完成,但在这种情况下,我们使用CTRL+y来操作 Bash 特定的剪贴板。最好的例子是使用**CTRL+u**来剪切整行:

root@ubuntu:~# systemctl restart network-online.target # Did not press ENTER yet.
# Forgot to edit a file before restart, CTRL+u to cut the whole line.
root@ubuntu:~# vim /etc/sysctl.conf # Make the change.
# CTRL+y: paste the cut line again.
root@ubuntu:~# systemctl restart network-online.target # Now we press ENTER.

对我们来说,这是一个典型的情况,我们比自己提前了一步。我们已经输入了一个需要执行的命令,但在按下ENTER之前,我们意识到我们忘记了在我们当前的命令成功之前需要做一些事情。在这种情况下,我们使用**CTRL+u**来剪切整个命令,继续进行先决条件命令,当我们准备好时再次粘贴该行使用**CTRL+y**。再次强调,你可能认为这不会发生在你身上,但你可能会惊讶地发现你会经常遇到这种精确的模式。

反向搜索

就键盘快捷键而言,我们认为我们已经为最后留下了最好的。在我们迄今介绍的所有节省时间的方法中,这绝对是我们认为最酷的:反向搜索

反向搜索允许你浏览历史记录,并在执行的命令中搜索字符串。你可以将其视为类似于history | grep cat,但更加交互和更快。要进入反向搜索提示,使用键**CTRL+r**

reader@ubuntu:~$ # CTRL+r
(reverse-i-search)'': # Start typing now.
(reverse-i-search)'cat': cat /var/log/dpkg.log # Press CTRL+r again for next match.
(reverse-i-search)'cat': sudo cat /etc/shadow # When match is found, press ENTER to execute.
reader@ubuntu:~$ sudo cat /etc/shadow
root:*:17647:0:99999:7:::
daemon:*:17647:0:99999:7:::
bin:*:17647:0:99999:7:::
<SNIPPED>

请尝试一下。很难将这些交互式提示记录下来,所以我们希望上面的注释能很好地说明反向搜索的工作原理。你可以一直反向搜索到历史记录的开头。如果在那时,你再次按下CTRL+r,你会看到类似以下的内容:

(failed reverse-i-search)'cat': cat base-crontab.txt

这向你表明没有更多的匹配项供反向搜索查找。在这一点上,或者在你认为花费的时间太长之前,你可以随时按下CTRL+c来停止反向搜索。

!ls语法相比,反向搜索不会从行的开头开始查找关键词:

reverse-i-search)'ls': cat grep-then-else.sh

这意味着它更强大(它只是匹配命令中的任何位置)并且更复杂(它不仅匹配命令)。然而,如果你对此很聪明,你只想要命令,你总是可以巧妙地使用空格来确保不会发生像上面的例子那样的情况:

(reverse-i-search)'ls ': ls -al /tmp/new # Note the whitespace after ls.

虽然我们很乐意更多地谈论反向搜索,但你真正学会它的唯一方法是开始使用它。放心,如果你熟练地使用它(并且知道何时停止搜索,直接输入你要找的命令),你一定会以你高效的终端工作给同行留下深刻印象!

交互式命令的速查表

我们将以一个简单的交互命令备忘单结束这本书。熟练掌握 Bash 是一个练习的问题。然而,多年来,我们发现自己偶然发现了使用命令的新方法,或者我们不知道的标志,这使我们的生活变得更加轻松。即使在写这本书的过程中,我们也遇到了以前不知道的东西,这些东西非常有帮助。在写命令和结构的过程中,您比在日常业务中使用它们时更仔细地查看手册页面和资源。

请充分利用这些备忘单,因为它们不仅包括基本的语法,还包括我们认为很重要的标志和提示(我们希望我们在职业生涯的早期就发现了它们)!

这些备忘单不包括诸如 find/locate、重定向、测试和循环之类的内容:这些内容在它们各自的章节中已经得到了充分的描述(希望如此)。

导航

这些命令用于导航。

cd

描述 更改 shell 工作目录。
语法 cd [dir]
实际用途
  • cd:导航到主目录(如在 HOME 中指定)。

  • cd -:导航回上一个目录(保存在 OLDPWD 中)。

|

ls

描述 列出目录内容。
语法 ls [选项]... [文件]...
实际用途
  • ls -a:不要忽略以点(.和..)开头的条目。

  • ls -l:使用长列表格式。

  • ls -h:与-l和/或-s一起,打印人类可读的大小(例如,1K 234M 2G)。

  • ls -R:递归列出子目录。

  • ls -S:按文件大小排序,从大到小。

  • ls -t:按修改时间排序,最新的排在前面。

  • ls -ltu:按访问时间排序并显示。

  • ls -Z:打印每个文件的安全上下文。

|

pwd

描述 打印当前/工作目录的名称。
语法 pwd [选项]...

文件操作

这些命令用于文件操作。

描述 连接文件并打印到标准输出。
语法 cat [选项]... [文件]...
实际用途
  • 猫-:没有文件,或者文件是-,读取标准输入。

  • cat -n:对所有输出行编号。

|

less

描述 使用分页器逐屏查看文本。
语法 less [选项]... [文件]...
实际用途
  • less -S:截断长行。行不换行,但可以用左右箭头键看到。

  • less -N:显示行号。

|

touch

描述 更改文件时间戳和/或创建空文件。
语法 touch [选项]... 文件...
实际用途
  • touch <不存在的文件>:创建一个空文件。

|

mkdir

描述 创建目录。
语法 mkdir [选项]... 目录...
实际用途
  • mkdir -m750 <dirname>:创建具有指定八进制权限的目录。

  • mkdir -Z:将每个创建的目录的 SELinux 安全上下文设置为默认类型。

|

cp

描述 复制文件和目录。
语法 cp [选项]... 源... 目录
实际用途
  • cp -a:归档模式,保留所有权限、链接、属性等。

  • cp -i:覆盖前提示(覆盖以前的-n选项)。

  • cp -rcp -R:递归复制目录。

  • cp -u:仅在源文件比目标文件新或目标文件丢失时复制。

|

rm

描述 删除文件或目录。
语法 rm [选项]... [文件]...
实际用途
  • rm -f:忽略不存在的文件和参数,不要提示。

  • rm -i:每次删除前提示。

  • rm -I(大写 i):在删除三个以上的文件或递归删除时提示一次;比-i 少侵入,同时仍然提供对大多数错误的保护。

  • rm -rrm -R:递归删除目录及其内容。

|

mv

描述 移动(重命名)文件。
语法 mv [选项]... 源... 目录
实际用途
  • mv -f: 在覆盖之前不提示。

  • mv -n: 不覆盖现有文件。

  • mv -u: 仅在源文件新于目标文件或目标文件丢失时移动。

|

ln

描述 在文件之间创建链接。默认为硬链接。
语法 ln [OPTION]... [-T] TARGET LINK_NAME
实际用途
  • ln -s: 创建符号链接而不是硬链接。

  • ln -i: 提示是否删除目标。

|

head

描述 输出文件的第一部分。
语法 head [OPTION]... [FILE]...
实际用途
  • head: 将每个文件的前 10 行打印到标准输出。

  • head -n20head -20: 打印前 NUM 行而不是前 10 行。

  • head -c20: 打印每个文件的前 NUM 个字节。

  • head -q: 永远不打印给出文件名的标题。

|

tail

tail命令与head具有相同的选项,但是从文件末尾而不是开头看到。

描述 输出文件的最后部分。
语法 tail [OPTION]... [FILE]...

权限和所有权

这些命令用于权限和所有权操作。

chmod

描述 更改文件模式位。可以指定为 rwx 或八进制模式。
语法 chmod [OPTION]... OCTAL-MODE FILE...
实际用途
  • chmod -c: 像 verbose,但仅在更改时报告。

  • chmod -R: 递归更改文件和目录。

  • chmod --reference=RFILE: 从参考文件复制模式。

|

umask

描述 设置文件模式创建掩码。由于这是掩码,因此与正常的八进制模式相反。
语法 umask [octal-mask]

chown

描述 更改文件所有者和组。仅在具有 root 权限时可执行。
语法 chown [OPTION]... [OWNER][:[GROUP]] FILE...
实际用途
  • chown user: <file>: 更改所有权为用户和他们的默认组。

  • chown -c: 像 verbose,但仅在更改时报告。

  • chown --reference=RFILE: 从参考文件复制所有权。

  • chown -R: 递归操作文件和目录。

|

chgrp

描述 更改组所有权。
语法 chgrp [OPTION]... GROUP FILE...
实际用途
  • chgrp -c: 像 verbose,但仅在更改时报告。

  • chgrp --reference=RFILE: 从参考文件复制组所有权。

  • chgrp -R: 递归操作文件和目录。

|

sudo

描述 以另一个用户的身份执行命令。
语法 sudo [OPTION]...
实际用途
  • sudo -i: 成为根用户。

  • sudo -l: 列出调用用户允许(和禁止)的命令。

  • sudo -u <user> <command>: 以指定的身份运行

  • sudo -u <user> -i: 以指定的登录。

|

su

描述 更改用户 ID 或成为超级用户。
语法 su [options] [username]
实际用途
  • sudo su -: 切换到 root 用户。需要 sudo,可以选择使用自己的密码。

  • su - <user>: 切换到。需要的密码输入。

|

useradd

描述 创建新用户或更新默认新用户信息。
语法 useradd [options] LOGIN
实际用途
  • useradd -m: 如果不存在,则创建用户的主目录。

  • useradd -s <shell>: 用户登录 shell 的名称。

  • useradd -u <uid>: 用户 ID 的数值。

  • useradd -g <group>: 用户初始登录组的组名或编号。

|

groupadd

描述 创建新组。
语法 groupadd [options] group
实际用途
  • groupadd -g <gid>: 组 ID 的数值。

  • groupadd -r: 创建系统组。这些组的 GID(通常)低于用户。

|

usermod

描述 修改用户帐户。
语法 usermod [options] LOGIN
实际用途
  • usermod -g <group> <user>: 将的主要组更改为

  • usermod -aG <group> <user>:将添加到中。对于用户来说,这将是一个附加组。

  • usermod -s <shell> <user>:为设置登录 shell。

  • usermod -md <homedir> <user>:将的主目录移动到

|

摘要

我们以一般提示和技巧开始了这一最终章节。本章的这部分涉及数组、history命令以及使用alias为您喜欢的命令及其标志设置别名。

我们继续讲解键盘快捷键。我们首先讨论了感叹号的用途以及在 Bash 中它们的多功能性:它用于否定退出代码,替换先前命令的部分,甚至通过匹配行号或行内容从历史记录中运行命令。之后,我们展示了一些有趣的 Bash 键盘快捷键,可以帮助我们节省一些常见操作和使用模式的时间(例如拼写错误和忘记的中间命令)。我们将最好的键盘快捷键留到最后:反向搜索。这些快捷键允许您交互式地浏览您的个人历史记录,找到再次执行的正确命令。

我们在本章和本书的结尾处提供了一个命令速查表,其中包含了我们在本书中介绍的大部分命令的基本语法,以及我们喜欢的标志和命令的组合。

本章介绍了以下命令:historyclear

最后的话

如果您已经成功阅读到这里:感谢您阅读我们的书。我们希望您享受阅读它的过程,就像我们创作它一样。继续脚本编写和学习:熟能生巧!

第十八章:评估

第二章

  1. 运行虚拟机相对于裸金属安装有哪些优点?
  • 虚拟机可以在当前首选操作系统内运行,而不是替换它或设置复杂的双引导解决方案。

  • 虚拟机可以进行快照,这意味着整个机器的状态被保留并可以恢复。

  • 许多不同的操作系统可以同时在一台机器上运行。

  1. 运行虚拟机与裸金属安装相比有哪些缺点?
  • 虚拟化会带来一些开销。

  • 与运行裸金属安装相比,将始终使用更多资源(CPU/RAM/磁盘)。

  1. Type-1 和 Type-2 hypervisor 之间有什么区别?

Type-1 hypervisors 直接安装在物理机器上(例如 VMWare vSphere,KVM,Xen),而 Type-2 hypervisors 安装在已运行的操作系统中(例如 VirtualBox,VMWare Workstation Player)。

  1. 我们可以用哪两种方式在 VirtualBox 上启动虚拟机?
  • 通常,它会打开一个新窗口,其中包含终端控制台(或 GUI,如果安装了桌面环境)。

  • 无头模式,将虚拟机作为服务器运行,没有 GUI。

  1. Ubuntu LTS 版本有什么特别之处?

LTS 代表长期支持。Ubuntu LTS 版本保证更新五年,而常规 Ubuntu 版本只有九个月。

  1. 如果在 Ubuntu 安装后,虚拟机再次引导到 Ubuntu 安装屏幕,我们应该怎么办?

我们应该检查虚拟硬盘是否比光驱在引导顺序中更高,或者卸载光盘驱动器上的 ISO,以便只有虚拟硬盘是有效的引导目标。

  1. 如果在安装过程中意外重启,并且最终没有进入 Ubuntu 安装界面(而是看到错误),我们应该怎么办?我们应该确保光盘驱动器在引导顺序中高于虚拟硬盘,并且需要确保 ISO 已挂载到光盘驱动器上。

  2. 我们为什么要为虚拟机设置 NAT 转发?

因此,我们不仅限于使用终端控制台,而是可以使用更丰富的 SSH 工具,如 PuTTY 或 MobaXterm。

第三章

  1. 为什么语法高亮是文本编辑器的重要特性?它通过使用颜色来轻松发现语法错误。

  2. 我们如何扩展 Atom 已提供的功能?我们可以安装额外的包,甚至编写自己的包。

  3. 编写 shell 脚本时,自动完成的好处是什么?

  • 它减少了输入,特别是对于多行结构。

  • 这样更容易找到命令。

  1. 我们如何描述 Vim 和 GNU nano 之间的区别?Nano 简单,Vim 强大。

  2. Vim 中最有趣的两种模式是哪两种?普通模式和插入模式。

  3. .vimrc 文件是什么?它用于配置 Vim 的持久选项,如颜色方案和如何处理制表符。

  4. 当我们称 nano 为 WYSIWYG 编辑器时,我们是什么意思?

WYSIWYG 代表 What You See Is What You Get,这意味着你可以从光标处开始输入。

  1. 为什么我们希望将 GUI 编辑器与命令行编辑器结合使用?因为在 GUI 编辑器中编写更容易,但在命令行编辑器中进行故障排除更容易。

第四章

  1. 文件系统是什么?

数据在物理介质上的存储和检索方式的软件实现。

  1. 哪些 Linux 特定的文件系统最常见?
  • ext4

  • XFS

  • Btrfs

  1. 在 Linux 上可以同时使用多个文件系统实现,是真是假?

正确;根文件系统始终是单一类型,但文件系统树的不同部分可以用于挂载其他文件系统类型。

  1. 大多数 Linux 文件系统实现中存在的日志记录功能是什么?

日志记录是一种机制,可以确保对磁盘的写入不会在中途失败。它极大地提高了文件系统的可靠性。

  1. 根文件系统挂载在树的哪个位置?

在最高点,在/.上。

  1. PATH 变量用于什么?

它用于确定可以使用哪个目录中的二进制文件。您可以使用命令'echo $PATH'检查 PATH 变量的内容。

  1. 根据文件系统层次结构标准,配置文件存储在哪个顶级目录中?

/etc/中。

  1. 进程日志通常保存在哪里?

/var/log/中。

  1. Linux 有多少种文件类型?

7

  1. Bash 自动完成功能是如何工作的?

对于支持自动完成功能的命令,您可以使用 TAB 一次来获取正确的参数(如果只有一个可能性),或者使用 TAB 两次来获取可能参数的列表。

第五章

  1. Linux 文件使用哪三种权限?
  • 执行

  1. Linux 文件定义了哪三种所有权类型?
  • 用户

  • 其他

  1. 用于更改文件权限的命令是什么?

chmod

  1. 控制新创建文件的默认权限的机制是什么?

umask

  1. 以下符号权限如何用八进制描述: rwxrw-r--

0764. 前三位(用户)为 rwx 的 7,第二组三位(组)为rw-的 6,最后三位(其他)为r--的 4。

  1. 以下八进制权限如何用符号描述: 0644

rw-r--r--。第一个 6 是读写,然后是两个 4,只是读取。

  1. 哪个命令允许我们获得超级用户权限?

sudo

  1. 我们可以使用哪些命令来更改文件的所有权?
  • chown

  • chgrp

  1. 我们如何安排多个用户共享对文件的访问? 确保他们共享组成员资格,并创建一个只允许这些组成员的目录。

  2. Linux 有哪些高级权限类型?

  • 文件属性

  • 特殊文件权限

  • 访问控制列表

第六章

  1. 我们在 Linux 中用哪个命令复制文件?

cp

  1. 移动和重命名文件之间有什么区别?

从技术上讲,没有区别。从功能上讲,移动更改了文件所在的目录,而重命名保持了文件在同一目录中。在 Linux 中,这两者都由mv命令处理。

  1. 为什么 rm 命令,用于在 Linux 下删除文件,可能很危险?
  • 它可以用于递归删除目录和其中的所有内容

  • 它不会(默认情况下)出现“您确定吗?”提示

  • 它允许您使用通配符删除文件

  1. 硬链接和符号(软)链接之间有什么区别?

硬链接指的是文件系统上的数据,而符号链接指的是文件(反过来又指向文件系统上的数据)。

  1. tar的三种最重要的操作模式是什么?
  • 归档模式

  • 提取模式

  • 打印模式

  1. tar用于选择输出目录的选项是什么?

-C

  1. 在搜索文件名时,locatefind之间最大的区别是什么?

Locate 默认允许部分命名匹配,而 find 需要指定通配符,如果需要部分匹配。

  1. find的多少个选项可以组合?

搜索需要的数量!这正是使find如此强大的原因。

第七章

  1. 当我们学习新的编程或脚本语言时,按照惯例,我们首先做什么?

我们打印字符串“Hello World”。

  1. Bash 的 shebang 是什么?

#!/bin/bash

  1. 为什么需要 shebang?

如果我们在不指定应该使用哪个程序的情况下运行脚本,shebang 将允许 Linux 使用正确的程序。

  1. 我们可以以哪三种方式运行脚本?
  • 通过使用我们想要运行的程序:bash script.sh

  • 通过设置可执行权限并在脚本名之前加上./:./script.sh

    • 通过设置可执行权限并使用完全限定的文件路径:/tmp/script.sh
  1. 创建 shell 脚本时为什么要如此强调可读性?
  • 如果使用脚本的人能够轻松理解脚本的功能,那么使用脚本会更容易

  • 如果除了您自己之外的其他人需要编辑脚本(经过几个月后,您自己也可以考虑自己是“其他人”!),如果脚本简单易懂,将会极大地帮助

  1. 为什么我们要使用注释?

因此,我们可以在脚本中解释可能仅通过查看命令不明显的事情。此外,它还允许我们提供一些设计原理,如果有助于澄清脚本。

  1. 为什么我们建议为您编写的所有 shell 脚本包括脚本头?

如果为脚本提供了一些关于作者、年龄和描述的信息。当脚本不能按预期工作或需要修改时,这有助于为脚本提供上下文。

  1. 我们讨论了哪三种冗长?
  • 注释的冗长

  • 命令的冗长

  • 命令输出的冗长

  1. KISS 原则是什么?

KISS,即“保持简单,愚蠢”,是一种设计建议,它帮助我们记住我们应该保持简单,因为这通常会增加可用性和可读性,而且大多数时候也是最好的解决方案。

第八章

  1. 什么是变量?

变量是编程语言的基本构建块,用于存储可以在应用程序中多次引用的运行时值。

  1. 我们为什么需要变量?

变量非常适合存储您需要多次使用的信息。在这种情况下,如果需要更改信息,这是一个单独的操作(对于常量而言)。对于真实变量,它允许我们在程序中引用运行时信息。

最后,适当的变量命名使我们能够为我们的脚本提供额外的上下文,增加可读性。

  1. 什么是常量?

常量是一种特殊类型的变量,因为它的值是固定的,并且在整个脚本中使用。正常变量在执行过程中经常发生多次变化。

  1. 为什么对变量来说命名约定尤为重要?

Bash 允许我们几乎可以给变量取任何名字。因为这可能会变得混乱(这绝不是一件好事!),所以选择一个命名约定并坚持下去很重要:这增加了脚本的一致性和连贯性。

  1. 什么是位置参数?

当您调用 Bash 脚本时,在bash scriptname.sh命令之后传递的任何其他文本都可以在脚本中访问,因为这些文本被视为脚本的参数。没有用引号括起来的每个单词都被视为单个参数:多个单词的参数应该用引号括起来!

  1. 参数和参数之间有什么区别?

参数用于填充脚本的参数。参数是脚本逻辑中使用的静态变量名称,而参数是用作参数的运行时值

  1. 我们如何使脚本交互?

通过使用read命令。我们可以将用户提供的值存储在我们选择的变量中,否则我们可以使用默认的$REPLY 变量。

  1. 我们如何创建一个既可以非交互式又可以交互式使用的脚本?

通过结合(可选)位置参数和read命令。为了验证在开始脚本逻辑之前我们是否拥有所有需要的信息,我们使用if-then结构与test命令来查看我们的所有变量是否都被填充。

第九章

  1. 为什么我们需要退出状态?

因此,命令可以以简单的方式向其调用者发出成功或失败的信号。

  1. 退出状态、退出码和返回码之间有什么区别?

退出码和返回码指的是同一件事。退出状态是一个概念,由退出/返回码实现。

  1. 我们使用哪个标志来测试 test 命令以测试:
  • 现有目录

-d

  • 可写文件

-w

  • 现有符号链接

-h(或-L)

  1. test -d /tmp/的首选简写语法是什么?

[[ -d /tmp/ ]]。请注意,[[之后和]]之前的空格是强制性的,否则命令将失败!

  1. 如何在 Bash 会话中打印调试信息?

设置-x 标志,可以在 shell 中使用set -x,也可以在调用脚本时使用bash -x

  1. 我们如何检查变量是否有内容?
  • if [[ -n $ ]] 检查变量是否非零

  • if [[ ! -z $ ]] 检查变量是否不为零

  1. 抓取返回代码的 Bash 格式是什么?

$?。

  1. ||和&&中,哪个是逻辑 AND,哪个是 OR?

||是 OR,&&是 AND。

  1. 抓取参数数量的 Bash 格式是什么?

$#。

  1. 如何确保用户从任何工作目录调用脚本都无关紧要?

通过在脚本开头提供cd $(dirname $0)

  1. Bash 参数扩展在处理用户输入时如何帮助我们?

它允许我们删除大写字母,这样我们就可以更容易地与预期值进行比较。

第十章

  1. 什么是搜索模式?

一种正则表达式语法,允许我们找到具有指定特征的文本片段,例如长度,内容和位置。

  1. 为什么正则表达式被认为是贪婪的?

大多数正则表达式试图找到尽可能多的与搜索模式匹配的数据。这包括空格和其他标点符号,这对人类来说是逻辑分隔,但对机器来说不一定是。

  1. 在搜索模式中,哪个字符被认为是除换行符外的任意一个字符的通配符?

点(.)

  1. 在 Linux 正则表达式搜索模式中,星号如何使用?

与另一个字符结合使用,以形成重复字符。示例搜索模式:sped 将匹配 spd,sped,speed,speeeeeeeeed 等。

  1. 什么是行锚?

用于表示行开头和行结尾的特殊字符。^表示行开头,$表示行结尾。

  1. 列举三种字符类型。

这些都是正确的:

  • 字母数字

  • 字母表

  • 小写

  • 大写

  • 数字

  • 空格

  1. 什么是 Globbing?

当你在与文件或文件路径交互时,在命令行上使用*或?来完成 Globbing。Globbing 允许我们轻松操作(移动,复制,删除等)与 Globbing 模式匹配的文件。

  1. 扩展正则表达式语法中可能的,而在 Bash 下的普通正则表达式中不可能的是什么?
  • 一个或多个重复字符

  • 精确数量的重复字符

  • 重复字符范围

  • 具有多个字符的交替

  1. 在使用grepsed时,有什么好的经验法则?

如果你的目标可以通过单个grep语句实现,选择简单。如果不能以这种方式实现,选择更强大的语法sed

  1. 为什么 Linux/Bash 上的正则表达式如此困难?

有许多相似的不同实现。正则表达式及其困难本身,这种混乱并没有帮助。只有实践和经验才能解决这个问题!

第十一章

  1. if-then(-else)语句如何结束?

使用 if 的反向:fi

  1. 如何在条件评估中使用正则表达式搜索模式?

通过使用=~比较符号。例如:[[ ${var} =~ [[:digit:]] ]]

  1. 我们为什么需要elif关键字?

如果我们想要顺序测试多个条件,我们可以使用 else if (elif)。

  1. 什么是嵌套

在另一个 if-then-else 语句或循环中使用 if-then-else 语句或循环。

  1. 如何获取有关如何使用 shell 内置和关键字的信息?

通过使用命令help,然后是我们想要了解信息的内置或关键字。例如:help [[

  1. while的相反关键字是什么?

until。while 循环运行直到条件不再true,until 循环运行直到条件不再false

  1. 为什么我们会选择 for 循环而不是 while 循环?

for更强大,并且具有许多方便的简写语法,使用while可能会很难或难以阅读。

  1. 大括号扩展是什么,我们可以在哪些字符上使用它?

大括号扩展允许我们编写非常简短的代码,根据 ASCII 字符生成基于空格分隔的列表。例如:{1..10}打印 1 到 10 之间的数字,中间有空格。我们还可以用它来表示大写或小写字母,或 ASCII 字符集中的任何范围。

  1. 哪两个关键字允许我们对循环有更精细的控制?

breakcontinuebreak停止当前循环,而continue跳到循环中的下一个迭代。

  1. 如果我们嵌套循环,如何使用循环控制来影响内部循环中的外部循环?

通过在breakcontinue关键字后添加大于 1 的数字。例如:break 2退出内部和一个外部循环。

第十二章

  1. 文件描述符是什么?

Linux 用作输入/输出接口的文件或设备的句柄。

  1. 术语 stdin、stdout 和 stderr 的含义是什么?
  • stdin,标准输入。用于命令的输入。

  • stdout,标准输出。用于命令的正常输出。

  • stderr,标准错误。用于命令的错误输出。

  1. stdin、stdout 和 stderr 如何映射到默认文件描述符?

stdin 绑定到 fd0,stdout 绑定到 fd1,stderr 绑定到 fd2。

  1. >1>2>之间的输出重定向有什么区别?

>1>是相等的,用于重定向 stdout。2>用于重定向 stderr。

  1. >>>之间有什么区别?

>将覆盖文件,如果文件已经有内容,而>>将追加到文件。

  1. 如何同时重定向 stdout 和 stderr?
  • 通过使用&>(和&>>

  • 通过将 stderr 绑定到 stdout,使用2>&1

  • 通过使用|&进行管道传输

  1. 哪些特殊设备可以用作输出的黑洞?

/dev/null 和/dev/zero。

  1. 管道在重定向方面有什么作用?

它将命令的 stdout/stderr 绑定到另一个命令的 stdin。

  1. 我们如何将输出发送到终端和日志文件?

通过使用tee命令进行管道传输,最好使用|&,这样 stdout 和 stderr 都会被转发。

  1. here string 的典型用例是什么?

如果我们想直接向命令的 stdin 提供输入,我们可以使用 here string。bc就是一个很好的例子。

第十三章

  1. 我们可以以哪两种方式定义函数?
  • 名称(){

}

  • 函数名称{

}

  1. 函数的一些优点是什么?
  • 易于重用的代码

  • 促进代码共享

  • 抽象复杂的代码

  1. 全局作用域变量和局部作用域变量之间有什么区别?

在函数内部作用域的变量只在函数内部有效,全局作用域的变量可以在整个脚本中使用(甚至在函数中)。

  1. 我们如何在变量上设置值和属性?

通过使用declare命令。

  1. 函数如何使用传递给它的参数?

脚本可以使用$1、\(#、\)@等方式来执行命令。

  1. 我们如何从函数中返回一个值?

通过将其输出到 stdout。调用函数的命令应该知道如何捕获输出,使用命令替换。

  1. source命令是做什么的?

它在当前 shell 中执行文件中的命令。如果被引用的文件只包含函数定义,那么这些函数将被加载以供以后使用(但仍然只能在当前 shell 中使用)。

  1. 为什么我们想要创建一个函数库?

许多实用函数,如参数检查、错误处理和颜色设置,从不改变,有时可能很难弄清楚。如果我们正确地做一次,我们就可以使用库中预定义的函数,而不需要从旧脚本中复制代码。

第十四章

  1. 什么是调度?

调度允许我们定义脚本应该在何时以及如何运行,而无需用户在那时进行交互。

  1. 我们所说的临时调度是什么意思?

临时调度,通常我们在 Linux 上使用at进行的调度,是指不定期重复的调度,而是通常在固定时间进行一次性作业。

  1. 使用at运行的命令的输出通常会去哪里?

默认情况下,at尝试使用sendmail向拥有队列/作业的用户发送本地邮件。如果未安装 sendmail,则输出将消失。

  1. cron守护程序的调度最常见的实现方式是什么?

作为用户绑定的 crontab。

  1. 哪些命令允许您编辑您的个人 crontab?

命令crontab -e。此外,您可以使用crontab -l列出当前的 crontab,并使用crontab -r删除当前的 crontab。

  1. crontab 时间戳语法中存在哪五个字段?

  2. 分钟

  3. 小时

  4. 月份中的日期

  5. 年份中的月份

  6. 星期中的日期

  7. 哪三个环境变量对于 crontab 最重要?

  8. 路径

  9. 外壳

  10. MAILTO

  11. 我们如何检查我们使用cron计划的脚本或命令的输出?

我们可以在 crontab 中使用重定向将输出写入文件,或者我们可以使用 Linux 本地邮件功能将输出发送给我们。大多数情况下,将输出重定向到日志文件是最佳选择。

  1. 如果我们计划的脚本没有足够的输出让我们有效地使用日志文件,我们应该如何解决这个问题?

在脚本中的多个位置使用 echo 命令,向读者发出执行正在按预期进行的消息。例如:'第 1 步成功完成,继续进行。'和'脚本执行成功,退出。'。

第十五章

  1. 为什么标志通常被用作修饰符,而位置参数被用作目标

标志通常修改行为:它可以使脚本更加详细或更加简洁,或者将输出写入其他位置。通常,命令会操作一个文件,然后该文件被视为命令实际尝试实现的主要目标

  1. 为什么我们在while循环中运行getopts

所有标志都按顺序解析,当getopts无法再找到新标志时,它将返回一个不同于 0 的退出代码,这将在恰当的时刻退出while循环。

  1. 为什么我们在 case 语句中需要一个?)?我们不能指望用户始终正确使用所有标志。?)匹配我们未指定的任何标志,然后我们可以用它来通知用户使用不正确。

  2. 为什么我们(有时)需要在 case 语句中使用:)?当 optstring 指定一个选项的参数,但用户没有给出时,可以使用:)。这允许您通知用户缺少的信息(在这一点上,您很可能会中止脚本)。

  3. 如果我们最终解析所有选项,为什么我们需要一个单独的 optstring?

因为 optstring 将告诉getopts哪些选项有参数,哪些没有。

  1. 为什么我们在使用shift时需要从 OPTIND 变量中减去 1?OPTIND 变量始终指向下一个可能的索引,这意味着它始终比找到的最终标志提前 1。因为我们只需要移除标志(它们被视为位置参数!),我们需要确保在移除之前将 OPTIND 减 1。

  2. 将选项与位置参数混合使用是个好主意吗?

由于处理选项和位置参数的复杂性增加,通常最好将操作的目标指定为-f标志的标志参数;-f 几乎被普遍接受为文件引用,这将始终被视为大多数操作的逻辑目标。

第十六章

  1. 参数替换是什么?不过是变量名称在运行时与其值的实时替换。

  2. 我们如何为定义的变量包含默认值?

使用$语法,其中variable是名称,value是默认值。只有在值为空或空('')时才会使用此值。

  1. 我们如何使用参数扩展来处理缺少的参数值?虽然通常会使用if [[ -z ${variable} ]]; then,但参数扩展允许您使用以下语法生成错误消息并exit 1:${1:?未提供名称!}

  2. \({#*}是什么意思?它与\)#相同,我们用它来确定传递给我们的 shell 脚本的参数数量。一般的$语法允许我们获取name变量的值的长度。

  3. 在谈论参数扩展时,模式替换是如何工作的?模式替换允许我们获取变量的值并稍微修改它,通过搜索/替换模式

  4. 模式去除模式替换有什么关系?

删除模式就相当于用空白替换模式。使用模式删除时,我们可以从文本的开头(前缀)和末尾(后缀)进行搜索,这样更加灵活。在处理文件路径时,模式删除非常有用。

  1. 我们可以执行哪些类型的大小写修改?
  • 小写

  • 大写

  • 反转大小写

  1. 我们可以用哪两种方法从变量值中获取子字符串?我们需要一个偏移量,或一个长度,或者两者的组合(最常见)。
posted @ 2024-05-16 19:07  绝不原创的飞龙  阅读(12)  评论(0编辑  收藏  举报