Linux-脚本编程终极指南-全-
Linux 脚本编程终极指南(全)
原文:
annas-archive.org/md5/ba00d8e91eed140a0762b25e806ea7fe
译者:飞龙
前言
欢迎阅读 终极 Linux Shell 脚本指南!本书非常适合 Linux 初学者和更有经验的 Linux 管理员,它将引导你完成 Shell 脚本的创建过程。我们将从基本的命令行使用开始,并逐步深入每一章节中的更高级概念。你将学会如何编写脚本来自动化重复的管理任务,以及其他许多有趣的功能。全书的重点将主要放在 bash
脚本上。稍后,我们会展示如何使脚本具有可移植性,以便它们可以在无法运行 bash
的旧版 Unix 系统上运行。在关于 Shell 脚本调试和安全性的章节后,我们将以 Z Shell 和 PowerShell 的介绍作为总结。
本书的适用对象
本书适合任何需要掌握 Shell 脚本概念的人。Linux 初学者可以受益,因为它可以帮助他们掌握将涵盖的内容,特别是帮助他们为 CompTIA Linux+/Linux 专业认证考试做准备。更高级的 Linux 管理员也能从中受益,因为本书会展示他们需要掌握的更高级概念,从而编写出真正有用的实用 Shell 脚本。
本书的内容
第一章,入门 Shell,这一章涵盖了 Linux 和类 Unix 系统上常见的操作系统 Shell 的基础知识。读者需要了解这些基本原理,以便理解后续章节中将要介绍的其他原理。
第二章,解释命令,操作系统 Shell 为我们提供五个功能:解释命令、设置变量、启用管道、允许输入/输出重定向以及允许自定义用户的工作环境。在这一章中,我们将探讨 Shell 是如何解释用户命令的。
第三章,理解变量和管道,在这一章中,我们将讨论操作系统 Shell 为我们做的另外两件事,那就是允许我们设置变量并使用命令管道。关于这两个话题要说的并不多,这也是为什么我们将它们合并到同一章节中的原因。
第四章,理解输入/输出重定向,在这一章中,我们将讨论如何将命令的文本输出发送到终端以外的地方,而终端是默认的输出设备。接着我们将讨论如何让命令从键盘以外的地方获取文本输入,而键盘是默认的输入设备。
最后,我们将讨论如何将错误信息发送到终端以外的地方。
第五章,自定义环境,在这一章中,我们将探讨各种 Shell 环境的配置文件。我们将学习如何自定义这些配置文件,并且如何通过命令行设置某些环境选项。
第六章,文本流过滤器 - 第一部分,管理员经常需要编写 shell 脚本,从外部来源获取文本信息,格式化这些信息并生成报告。在本章中,我们将介绍文本流过滤器的概念,这些过滤器能帮助完成这个过程。此外,了解这些文本流过滤器还能帮助你通过某些 Linux 认证考试,比如 LPI/Linux+考试。接下来,我们将向你展示如何使用这些过滤器。
第七章,文本流过滤器 - 第二部分,在本章中,我们将继续探索文本流过滤器。
第八章,基础 shell 脚本构建,在本章中,我们将讲解 shell 脚本的基本结构,并将使用前面章节中的一些文本流过滤器来创建简单的脚本。我们还将介绍一些在所有编程语言中都常见的基本编程结构,并向你展示如何使用它们。
第九章,使用 grep、sed 和正则表达式过滤文本,在本章中,你将学习正则表达式的概念,以及如何将它们与 grep 和 sed 结合使用,来过滤或处理文本。这些技巧不仅可以帮助你查找特定的文本,还能帮助你自动化报告的生成以及同时编辑多个文本文件。
第十章,理解函数,函数是每种编程语言中的重要部分,因为它们使程序员能够在多个程序中或同一个程序的多个地方重复使用一段代码。程序员可以向函数传递参数,让函数对这些参数进行操作,并将结果返回给主程序。
第十一章,执行数学运算,各种操作系统的 shell 都有执行数学运算的方式,无论是从命令行,还是从 shell 脚本中。在本章中,我们将探讨如何使用整数和浮点数运算。
第十二章,使用 here 文档和 expect 自动化脚本,虽然让 shell 脚本从一个独立的文本文件中提取数据很简单,但有时候将数据直接存储在 shell 脚本中更为方便。我们将通过使用“here”文档来实现。在本章中,你将学习如何创建和使用“here”文档。你还将看到如何利用expect
工具来自动化某些脚本。
第十三章,使用 ImageMagick 编写脚本,ImageMagick 是一个文本模式的程序,用于编辑、处理和查看图像文件。在本章中,你将学习如何通过在 shell 脚本中使用 ImageMagick 命令来自动化图像处理。
第十四章,使用 awk–第一部分,这一章介绍了awk
,它是一个能够从文本文件中提取特定文本并自动生成报告和数据库的工具。由于 awk 本身就是一个完整的编程语言,我们在这里不会深入讨论它。相反,我们将提供足够的信息,让您能够编写适用于 Shell 脚本的 awk“一行脚本”。
第十五章,使用 awk–第二部分,这是上一章的延续,在这一章中,我们将讨论使用 awk 脚本编写的更高级的概念。
第十六章,使用 yad、dialog 和 xdialog 创建用户界面,到目前为止,我们只看了从命令行运行的 Shell 脚本。事实上,这也是大多数人使用 Shell 脚本的方式,也是大多数人想到 Shell 脚本时的默认印象。但其实,也可以创建提供用户界面的 Shell 脚本。在这一章中,我们将使用 yad 来创建图形用户界面,使用 dialog 来创建 ncurses 风格的界面。
第十七章,使用 Shell 脚本选项与 getopts,管理员通常需要向 Shell 脚本传递参数和选项。传递参数,即脚本操作的对象,比较容易。要同时传递选项,修改脚本的运行方式,则需要另一种类型的操作符。在这一章中,您将学习如何使用 getopts 向脚本传递选项。
第十八章,为安全专业人员编写 Shell 脚本,在这一章中,您将学习如何创建 Shell 脚本或搜索现有的 Shell 脚本,以帮助安全管理员执行他们的工作。我们还将探讨如何修改或改进现有的 Shell 脚本,以满足安全管理员的特定需求。
第十九章,Shell 脚本的可移植性,大型组织,如大型政府机构或大公司,可能会拥有不同类型的 Linux、Unix 和类 Unix 机器。有时,编写能够自动检测运行的系统类型并为每种类型系统运行相应代码的 Shell 脚本会非常方便。在这一章中,我们将讨论几种增强脚本可移植性的方法。
第二十章,Shell 脚本安全性,脚本错误可能导致脚本无意中暴露敏感数据,或允许某人对系统执行未经授权的操作。在这一章中,我们将讨论如何帮助读者编写尽可能安全的 Shell 脚本。
第二十一章,调试 Shell 脚本,Shell 脚本可能会有 bug,就像任何其他编程语言一样。有时 bug 很容易找到,而有时则不然。在这一章中,我们将探讨一些方法,帮助繁忙的管理员调试那些不能正常工作的 Shell 脚本。
第二十二章,Z Shell 脚本简介,Z Shell(或 zsh)是一种可替代 bash 的 Shell。它的使用方式与 bash 相似,但也有 bash 没有的增强功能。在本章中,我们将探讨这些增强功能,以及一些 bash 无法实现的脚本技巧。
第二十三章,在 Linux 上使用 PowerShell,PowerShell 是微软在 2006 年为 Windows 操作系统开发的。在 2016 年,微软宣布将 PowerShell 开源,并使其可以在 Linux 和 macOS 上使用,同时也支持 Windows。在本章中,我们将探讨 PowerShell 如何对 Linux 管理员有所帮助,如何安装它,以及如何使用它。
要充分利用本书
由于本书从 Linux 和 Unix 命令行使用的基础知识开始,读者实际上只需要对设置 VirtualBox 并安装 Linux、FreeBSD 和 OpenIndiana 虚拟机的过程感到熟悉。
VirtualBox 是一个免费的下载软件,你可以从这里获取: https://www.virtualbox.org/
要运行 VirtualBox,你需要一台支持虚拟化的 CPU。大多数现代 CPU 都具备此能力,除了某些 Intel Core i3 和 Core i5 型号。(这是因为它们缺乏虚拟化所需的硬件加速。)此外,你还需要确保在计算机的 BIOS 中启用了虚拟化。
对于演示,我们将使用 Fedora、Debian、Ubuntu、FreeBSD 和 OpenIndiana 虚拟机。你可以从以下位置下载安装镜像:
-
Fedora: https://fedoraproject.org/
-
Debian: https://www.debian.org/
-
Ubuntu: https://ubuntu.com/
-
FreeBSD: https://www.freebsd.org/
-
OpenIndiana: https://openindiana.org/
在所有情况下,你都需要创建一个具有完全 sudo 权限的普通用户账户。在 Ubuntu 和 OpenIndiana 的安装过程中,这会自动完成。在 Debian 和 Fedora 中,如果你在安装过程中没有设置 root 用户密码,这也会自动完成。
对于 FreeBSD,情况略有不同。这是因为 FreeBSD 安装程序会要求你为 root 用户设置密码,而sudo
不会被安装。因此,以下是安装 FreeBSD 的过程。
-
当你到达安装程序部分,要求你创建自己的用户账户时,你会看到:
-
登录组是
your_username
。将your_username
邀请加入其他组。 -
输入
wheel
以将自己添加到wheel
组。
-
-
安装完成后,使用你在安装过程中创建的密码登录 root 用户账户。
-
通过执行以下命令安装
sudo
包:pkg install sudo
-
配置
sudo
,使得 wheel 组的成员拥有完全的sudo
权限。首先输入以下命令:visudo
-
向下滚动,直到看到这一行:
# %wheel ALL=(ALL:ALL) ALL
-
删除这一行前面的#和空格。
-
保存文件并退出。
-
-
从 root 用户账户注销,然后使用你自己的账户重新登录。
当您需要执行管理命令时,您可以像在任何 Linux 发行版上一样使用sudo
。
接下来,您需要在 FreeBSD 上安装bash
。
由于 bash 在 FreeBSD 上默认未安装,您需要自行安装。以下是操作步骤:
-
使用以下命令安装 bash:
sudo pkg install bash
-
创建一个指向 bash 可执行文件的符号链接,类似这样:
sudo ln -s /usr/local/bin/bash /bin/bash
下载示例代码文件
本书的代码包托管在 GitHub 上,地址为 https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git。我们还提供了来自我们丰富书籍和视频目录的其他代码包,您可以在 https://github.com/PacktPublishing/ 上查看它们。
下载彩色图片
我们还提供了一份 PDF 文件,其中包含本书中使用的截图/图表的彩色图片。您可以在这里下载:packt.link/gbp/9781835463574
。
使用的约定
本书中使用了多种文本约定。
CodeInText
:表示文本中的代码字、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号。例如:“将新功能添加到/etc/bashrc
文件中。”
donnie@opensuse:~> git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
粗体:表示新术语、重要词汇,或您在屏幕上看到的词语。例如,菜单或对话框中的词语在文本中通常以这种方式出现。例如:“首先,让我们看一下处于运行状态或僵尸状态的进程数量。”
警告或重要提示通常以这种方式呈现。
提示和技巧通常以这种方式呈现。
联系我们
我们总是欢迎读者的反馈。
一般反馈:通过电子邮件feedback@packtpub.com
与我们联系,并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请发送邮件至questions@packtpub.com
。
勘误:尽管我们已尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,欢迎向我们报告。请访问 http://www.packtpub.com/submit-errata,点击提交勘误并填写表单。
盗版:如果您在互联网上发现我们作品的任何非法副本,欢迎您提供相关的地址或网站名称。请通过copyright@packtpub.com
与我们联系,并附上相关材料的链接。
如果您有意成为作者:如果您在某个领域拥有专长,并且有意编写或贡献书籍内容,请访问 http://authors.packtpub.com。
留下评论!
感谢您从 Packt 出版购买本书——我们希望您喜欢这本书!您的反馈对我们至关重要,帮助我们改进和成长。完成阅读后,请花一点时间留下Amazon 评论;这只需要一分钟,但对像您这样的读者来说,意义重大。
扫描下面的二维码,获取您选择的免费电子书。
下载这本书的免费 PDF 副本
感谢您购买本书!
您喜欢随时随地阅读,但无法携带印刷版书籍吗?
您的电子书购买是否与您选择的设备不兼容?
不用担心,现在购买每本 Packt 图书时,您都可以免费获得该书的无 DRM PDF 版本。
随时随地、在任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制并粘贴代码到您的应用程序中。
福利不仅仅是这些,您还可以获得独家折扣、新闻通讯以及每日发送到您邮箱的精彩免费内容。
按照这些简单的步骤获取福利:
- 扫描二维码或访问以下链接:
https://packt.link/free-ebook/9781835463574
-
提交您的购买证明。
-
就这样!我们会将您的免费 PDF 和其他福利直接发送到您的电子邮件。
第一章:开始使用 Shell
在我们讨论 Shell 脚本之前,我们需要了解什么是 Shell,以及 Linux、Unix 和类 Unix 操作系统中有哪些不同的 Shell。我们还将讨论一些其他重要的主题,这些都将帮助你进入 Shell 脚本的广阔世界。
本章的主题包括:
-
理解 Shell
-
寻找 Shell 命令的帮助
-
使用文本编辑器
-
理解编译型与解释型编程
-
理解
root
和sudo
权限
如果你准备好了,那就让我们开始这段重要的旅程吧。同时,永远记得在过程中享受乐趣。
理解 Shell
所以,你一定在挠头问:“什么是 Shell,为什么我需要关心它?”那么,Shell 是一个程序,它充当用户和操作系统内核之间的中介。用户将命令输入到 Shell 中,Shell 会将这些命令传递给内核进行处理。处理结果随后通过计算机的 终端(也可以称作 屏幕)展示给用户。Linux 系统上最常见的 Shell 是 bash,但 Z Shell(zsh)近年来也逐渐变得流行。(我将在第二十二章,使用 Z Shell 中解释原因。)你会发现 bash
是大多数 Linux 发行版以及一些类 Unix 发行版(如 OpenIndiana)的默认 Shell,而 zsh
则是 Kali Linux 的默认 Shell。
如果你是 Linux 及其类 Unix 系统的全新用户,可能会想知道什么是 distro。其实,和 Windows 以及 macOS 这类由单一公司控制的专有操作系统不同,Linux 及其类 Unix 系统是主要的开源软件,这意味着任何人都可以拿到源代码并创建自己的实现或 发行版。Red Hat Enterprise Linux、Fedora 和 Ubuntu 是 Linux 发行版的例子,而 OpenIndiana 和 FreeBSD 则是类 Unix 发行版的例子。但我们这些铁杆极客很少说 发行版,我们通常直接说 distro,这是 distribution 的简写。
另外,我区分 Unix 和类 Unix 发行版的原因与上世纪 80 年代的法律原因有关。这涉及到一个相当复杂的问题,我这里不打算深入探讨。简单来说,像 FreeBSD 这样的发行版的创作者并不能将他们的作品称为 Unix,尽管它们在功能上大致等同。但,他们可以说自己的作品是 类 Unix 的。
最新版本的 macOS 也将 zsh
设置为默认的 shell。幸运的是,很多关于 bash
的知识也适用于 zsh
。主要的区别是,zsh
有一些 bash
所没有的酷功能。(我会在第二十二章中详细解释这些内容。)PowerShell 最初仅适用于微软的 Windows 操作系统,但自 2016 年起,PowerShell 也已在 Linux 和 macOS 上可用。PowerShell 是完全不同的工具,但你可能会发现它非常有用,正如我们在第二十三章,Linux 上使用 PowerShell 中将要介绍的那样。
通常会听到人们把 bash
称为 bash shell。但是,bash
是 Bourne Again Shell 的缩写。所以,当你说 bash shell 时,实际上是在说 Bourne Again Shell Shell,这有点奇怪。这就像人们说要去 ATM machine 提取钱一样。他们实际上是在说 Automatic Teller Machine Machine,这也是很尴尬的说法。
而且,别让我开始讲那些讨论 热水器 的人。我是说,如果水已经热了,为什么还要加热呢?
另一方面,如果你觉得还需要说 bash shell,这样别人才能明白你在说什么,我会理解,也不会谴责你。事实上,你可能偶尔会看到我也这么做。
现代操作系统的 shell 最酷的地方在于,它们不仅仅是一个界面工具。它们也是完整的编程环境,拥有与更复杂编程语言(如 Pascal、C 或 Java)相同的许多编程结构。系统管理员可以通过使用 shell 脚本来自动化复杂的重复任务,从而使工作变得更加轻松。
当你登录到一个文本模式的 Linux 或 Unix 服务器时,屏幕上会显示一片黑色和一些文本,类似如下:
图 1.1:文本模式 Debian Linux 机器上的纯 bash
这是简洁的、基本的 shell。安装了桌面环境的机器将通过 终端模拟器 与 shell 进行交互,终端模拟器看起来像这样:
图 1.2:在 OpenIndiana 机器上与 bash 交互的终端模拟器
终端模拟器的名称因桌面环境的不同而有所不同,但它们的功能是相同的。使用终端模拟器的好处是,你可以享受使用滚动条、定制显示界面和命令行复制粘贴等功能。
无论如何,你可以通过输入以下命令查看自己使用的是哪个 shell:
donnie@fedora:~$ echo $SHELL
/bin/bash
donnie@fedora:~$
在这种情况下,你看到的是 bash
。
查找 Shell 命令的帮助
无论你认为自己是多么的专家,仍然会有需要查找一些信息的时候。对于 Linux、Unix 和类 Unix 操作系统来说,有几个选项可以用来查找信息。
理解手册页
手册页,简称man 页,几乎从一开始就内置在类 Unix 操作系统中。要使用 man 页,只需输入man
,后跟您需要查找信息的命令、配置文件或系统组件的名称。例如,您可以这样查找如何使用ls
命令:
man ls
大多数时候,man
命令会在less
分页程序中打开一个 man 页面。(某些 Unix 实现可能使用more
分页程序,但我没有发现最近有这样的实现。)无论哪种方式,您都可以滚动浏览 man 页面,或在页面内进行关键字搜索,以找到您需要的信息。
man 页被分为不同的部分,每一部分对应一个不同的类别。在大多数类 Unix 和 Linux 系统上,有八个主要类别,通常称为部分,它们如下所示:
部分编号 | 目的 |
---|---|
1 | 这一部分包含任何非特权用户可以使用的命令信息。 |
2 | 这一部分包含关于系统调用的信息,主要对软件开发人员有用。 |
3 | 在这一部分,您将找到关于库函数的信息,这些信息主要对软件开发人员有用。 |
4 | 如果您曾经想查找/dev/ 目录中的设备文件的信息,那么这里就是您要查找的地方。此部分还包含有关设备驱动程序的信息。 |
5 | 在这里,您将找到关于系统上各种配置和系统文件的信息。 |
6 | 这是关于游戏和屏幕保护程序的信息。通常这里的信息不多。 |
7 | 这是关于各种杂项信息的部分,这些内容无法整齐地归类到其他部分中。 |
8 | 这里是关于管理命令和系统守护进程的信息。 |
表 1.1:描述 man 页部分
您将在/usr/share/man/
目录中看到包含这些 man 页面文件的子目录。您还可能看到一些名为man0p
、man5p
或man8x
的子目录。这些子目录包含某些特殊用途的 man 页面,在不同的 Linux 发行版中会有所不同。
很多时候,您不需要关心这些部分,因为man
命令会自动为您调用正确的 man 页面。其他时候,您需要注意这些部分,因为许多关键字在多个部分中都能找到。例如,在我写这篇文章时使用的 Fedora 工作站上,printf
有两个 man 页面。您可以通过两种方式来找到它们。首先,您可以使用man -aw
命令,像这样:
[donnie@fedora ~]$ man -aw printf
/usr/share/man/man1/printf.1.gz
/usr/share/man/man3/printf.3.gz
[donnie@fedora ~]$
您还可以使用whatis
命令,像这样:
[donnie@fedora ~]$ whatis printf
printf (1) - format and print data
printf (3) - formatted output conversion
[donnie@fedora ~]$
请注意,whatis
是man -f
的同义词。无论使用哪个命令,您都会得到相同的结果,但我个人更喜欢使用whatis
。
所以,我们在第一部分有一个printf
手册页,这意味着我们有一个名为printf
的普通用户命令。我们在第三部分也看到了一个printf
手册页,这意味着有一个名为printf
的库函数。如果你输入man printf
,你将看到来自第一部分的手册页。你会在手册页的第一行看到类似如下内容:
PRINTF(1) User Commands PRINTF(1)
如果你想查看第三部分的手册页,你需要在命令中指定,如下所示:
man 3 printf
要扩大搜索范围,查找所有包含printf
的手册页,无论是标题还是描述中,甚至是嵌入在其他文本字符串中的,都可以使用apropos
或man -k
,如以下所示:
[donnie@fedora ~]$ apropos printf
asprintf (3) - print to allocated string
BIO_printf (3ossl) - formatted output to a BIO
BIO_snprintf (3ossl) - formatted output to a BIO
BIO_vprintf (3ossl) - formatted output to a BIO
BIO_vsnprintf (3ossl) - formatted output to a BIO
curl_mprintf (3) - formatted output conversion
dprintf (3) - formatted output conversion
tpm2_print (1) - Prints TPM data structures
fprintf (3) - formatted output conversion
fwprintf (3) - formatted wide-character output conversion
printf (1) - format and print data
printf (3) - formatted output conversion
. . .
[donnie@fedora ~]$
再次强调,无论使用哪个命令,都会得到相同的输出,但我个人始终偏好使用apropos
。
大多数时候,你的 Linux 系统会很好地保持手册页索引的更新。然而,有时你需要手动更新,方法如下:
[donnie@fedora ~]$ sudo mandb
[sudo] password for donnie:
Purging old database entries in /usr/share/man...
Processing manual pages under /usr/share/man...
Purging old database entries in /usr/share/man/ca...
Processing manual pages under /usr/share/man/ca...
. . .
. . .
Processing manual pages under /usr/local/share/man...
0 man subdirectories contained newer manual pages.
0 manual pages were added.
0 stray cats were added.
0 old database entries were purged.
[donnie@fedora ~]$
好的,这就是关于手册页系统的内容。接下来我们来谈谈信息系统。
理解信息页面
信息页面系统较新,是由理查德·M·斯托曼(Richard M. Stallman)发明的,作为GNU 项目的一部分。它的独特之处在于每个信息页面都包含可以将你引导到其他信息页面的超链接。例如,要获取关于信息系统的内容,只需输入info info
。这个信息页面包含一个菜单,类似于以下内容:
* Menu:
* Stand-alone Info:: What is Info?
* Invoking Info:: Options you can pass on the command line.
* Cursor Commands:: Commands which move the cursor within a node.
. . .
., . .
* Variables:: How to change the default behavior of Info.
* Colors and Styles:: Customize the colors used by Info.
* Custom Key Bindings:: How to define your own key-to-command bindings.
* Index:: Global index.
每个下划线的项目都是一个超链接,指向另一页面。使用光标键,将光标移到您想查看的超链接上,然后按Enter键。要查看特定命令的帮助页面,比如ls
,只需这样操作:
info ls
如果你需要帮助来浏览信息页面,只需按H
键来调出导航菜单。
好的,这就是关于信息页面的内容。接下来我们来谈谈在线文档。
了解 Linux 文档项目
Linux 文档项目几乎存在了永远,是一个宝贵的资源。最棒的是它的指南部分,在这里你可以找到免费的完整 Linux 和bash
书籍,并可以下载多种格式。这些书籍大多数都很旧,最新的更新是在 2014 年。不过,关于Bash 初学者指南和高级 Bash 脚本编程这两本书,时间久远也无妨。这两本书中的概念是永恒的,数年来并未发生变化。要查看这些书籍,请访问tldp.org/guides.html
。
使用您最喜欢的搜索引擎
如果一切都失败了,只需使用您最喜欢的搜索引擎查找有关脚本编程的一般信息,或某个特定操作系统上的脚本编程。你会找到很多帮助信息,比如博客文章、YouTube 视频和官方文档。许多专注于 Linux 的免费网站提供各种帮助,找到它们非常简单。
接下来,让我们来谈谈文本编辑器。
使用文本编辑器创建 Shell 脚本
要创建你的 shell 脚本,你需要一个专为 Linux 和 Unix 系统设计的文本编辑器。你有很多选择,选择哪一个取决于多个标准:
-
你是在文本模式机器上编辑文件,还是在桌面机器上编辑?
-
你需要哪些功能?
-
你个人的偏好是什么?
文本模式编辑器
文本模式文本编辑器可以在没有图形用户界面的机器上使用。最常见的两种文本模式编辑器是 nano
和 vim
。nano
编辑器几乎在所有 Linux 发行版中都已预装,且使用起来非常简单。使用它,只需键入 nano
,后跟你要编辑或创建的文件名。在屏幕底部,你会看到可用命令的列表。要调用某个命令,按下 CTRL 键,然后按对应的字母键。
使用 nano
的缺点是它没有你可能需要的程序员文本编辑器的全部功能。你可以看到,我在 Fedora 工作站上使用的 nano
实现有语法高亮,但它不会自动格式化代码。
图 1.3:我在 Fedora 工作站上使用的 nano 文本编辑器
请注意,在其他 Linux 发行版上,nano
可能甚至没有语法高亮。
我最喜欢的文本模式编辑器是 vim
,它拥有几乎让所有程序员都满意的功能。它不仅支持语法高亮,还能自动格式化你的代码,并应用适当的缩进,如你所见:
图 1.4:我在 Fedora 工作站上使用的 vim 文本编辑器
实际上,bash
脚本不需要缩进,因为 bash
脚本在没有缩进的情况下也能正常工作。然而,缩进确实让代码更容易为人类阅读,并且有一个能自动应用适当缩进的编辑器是非常方便的。此外,vim
具有强大的搜索和替换功能,允许你分屏操作,从而可以同时处理两个文件,并且可以通过多种插件进行定制。即使它是一个文本模式编辑器,你也可以在通过桌面机器远程登录到服务器时,使用鼠标右键菜单进行复制和粘贴,或者在桌面机器上编辑本地文件时使用该功能。
较老的 vi
文本编辑器通常在大多数 Linux 发行版中默认安装,但 vim
通常没有。某些发行版中,即使没有实际安装 vim
,vim
命令仍然可以工作。这是因为这些发行版中的 vim
命令可能指向的是 vim-minimal
,甚至指向的是旧版的 vi
。无论如何,要在任何基于 Red Hat 的发行版(如 RHEL、Fedora、AlmaLinux 或 Rocky Linux)上安装完整的 vim
,只需执行以下命令:
sudo dnf install vim-enhanced
要在 Debian 或 Ubuntu 上安装 vim
,请执行以下命令:
sudo apt install vim
尽管我很喜欢vim
,我还是得告诉你,一些用户不太愿意使用它,因为他们认为它太难学。这是因为原版的vi
是在计算机的“石器时代”创建的,那时计算机键盘上没有光标键、退格键或删除键。你曾经必须使用的那些老式vi
命令,直到今天的vim
版本仍然得以保留。
所以,大多数你能找到的vim
教程仍然会尝试教你所有那些老式的键盘命令。
图 1.5:这张我的照片拍摄于计算机的“石器时代”,那时计算机键盘上没有光标键、退格键或删除键。
然而,在你将安装在 Linux 和现代类 Unix 发行版(如 FreeBSD 和 OpenIndiana)上的当前版本的vim
中,光标键、退格键和删除键都能像在其他文本编辑器中一样工作。因此,已经不再需要学习那些你以前不得不学习的所有键盘命令了。我的意思是,你仍然需要学会一些基本的键盘命令,但不会像以前那样需要学那么多。
图形用户界面文本编辑器
如果你使用的是桌面计算机,你仍然可以根据需要使用nano
或vim
。但是,如果你更愿意使用图形界面类型的编辑器,也有多种选择。一些简单的文本编辑器,比如gedit
或leafpad
,可能已经安装在你的桌面系统上。还有一些稍微复杂一点的程序员编辑器,如geany
、kwrite
和bluefish
,它们在大多数 Linux 发行版和一些类 Unix 发行版的正常软件仓库中都有提供。你最好的方法是尝试不同的编辑器,看看哪种最适合你。这是启用了语法高亮的kwrite
编辑器的示例:
图 1.6:Kwrite 文本编辑器。
如果你是 Windows 用户,你绝对不想在 Windows 计算机上使用 Windows 文本编辑器(如记事本或写字板)创建或编辑 shell 脚本,然后将脚本转移到 Linux 计算机上。这是因为 Windows 文本编辑器在每一行的末尾插入了一个不可见的回车符。你看不见它们,但你的 Linux shell 能够看到,并且会拒绝执行脚本。话虽如此,你有时可能会遇到别人用 Windows 文本编辑器创建的脚本,这时你需要知道如何修复它们,使它们能够在你的 Linux 或 Unix 机器上运行。这个过程很简单,我们会在第七章,文本流过滤器-第二部分中讲解。
这就是我们对 Linux 文本编辑器概述的全部内容。接下来,让我们讨论编译型和解释型编程语言。
理解编译型与解释型编程
编译编程是指在文本编辑器中编写程序代码,然后使用编译器将文本文件转换为可执行的二进制文件。一旦完成,程序的用户将无法轻易查看程序的源代码。而 解释性编程 是直接从文本文件运行程序,而无需先进行编译。
编译语言,例如 C、C++ 或 Fortran,适合当你需要从程序中获得最大性能时。然而,它们可能相当难学,尤其是当涉及到较低级的功能时,比如处理文件。解释性语言可能无法提供如此高的性能,但它们通常非常灵活,而且通常更容易学习。解释性语言通常也提供更高的操作系统间的便携性。Shell 脚本就属于解释性语言的范畴。
下面是一些你可能会考虑使用解释性语言的原因:
-
当你寻找一个简单的解决方案时。
-
当你需要一个便携的解决方案时。如果你关注便携性问题,你可以编写一个脚本,使其能够在不同的 Linux 发行版以及 Unix/类 Unix 系统上运行。如果你在一个大型公司,且该公司拥有一个混合操作系统的大型网络,这会非常有用。(你甚至可能会遇到一些较大的公司,它们仍在运行一些传统的 Unix 系统,如 AIX、HPUX 或 SUNOS,同时还使用更现代的 Linux、BSD 或 macOS 实现。)
下面是一些你可能会考虑使用编译语言的原因:
-
当任务需要大量使用系统资源时,尤其是在速度至关重要的情况下,这种情况尤为明显。
-
当你进行需要大量数值运算的数学操作时。
-
当你需要复杂的应用程序时。
-
当你的应用程序有许多具有依赖关系的子组件时。
-
当你想要创建专有应用程序,并防止用户查看应用程序源代码时。
当你仔细考虑时,几乎所有的生产力软件、服务器软件、游戏软件或科学软件都属于一个或多个这些类别,这意味着它们应该使用编译语言来构建,以获得最佳性能。
好的,现在我们来谈谈sudo
。
理解 root 和 sudo 权限
本课程中你将做的一些事情需要具有管理员权限。虽然可以方便地登录到 root 命令提示符,但我尽量不鼓励这样做。为了最佳的安全性,并且习惯于企业环境中的操作,最好的选择是使用sudo
。
现代 Linux 发行版允许你在安装操作系统时将自己添加到管理员组中。(在 Red Hat 系统中,这是 wheel
组,在 Debian/Ubuntu 系统中,这是 sudo
组。)要执行需要管理员权限的命令,只需像这样做:
sudo nftables list ruleset
然后,系统会要求你输入你自己的用户账户密码,而不是 root 用户账户的密码。
关于这个话题,我们差不多可以说完了,接下来让我们总结一下并继续下一章节。
总结
在本章中,我为接下来的章节奠定了一些基础。我们了解了操作系统 shell 是什么,以及为什么要使用它。接着,我们探讨了寻找帮助的各种方式,简要回顾了 Linux 文本编辑器,并讨论了编译型和解释型编程语言的区别,还简单提到为何我们要使用 sudo
来执行管理员命令。
在下一章节,我们将开始探讨操作系统 shell 为我们做的各种事情。我会在那时见到你。
问题
-
Linux 系统最广泛使用的 shell 是什么?
-
zsh
-
bash
-
korn
-
csh
-
-
如果你在 Windows 电脑上使用 Windows 文本编辑器(如记事本或 Wordpad)创建一个 Linux shell 脚本,会发生什么?
-
该脚本将在 Linux 机器上正常运行。
-
你的 Windows 机器会因你使用它来创建 Linux 脚本而愤怒关机。
-
该脚本无法在 Linux 机器上运行,因为 Windows 文本编辑器会在每行末尾插入一个不可见的回车符。
-
微软前 CEO 史蒂夫·巴尔默将拜访你,解释为什么 Linux 是一种癌症。
-
-
3. 在哪个章节可以找到管理员命令的 man 页面?
-
1
-
3
-
5
-
6
-
8
-
-
以下哪项陈述是正确的?
-
解释型编程语言适用于需要进行重数学运算的程序。
-
编译型编程语言通常比解释型语言更适合处理大型复杂程序。
-
解释型编程语言的例子包括 C、C++ 和 Fortran。
-
解释型语言和编译型语言在性能上没有差别。
-
-
对还是错:要执行管理员命令,最好直接登录 root 用户账户。
延伸阅读
-
22 款最佳 Linux 编程文本编辑器:
phoenixnap.com/kb/best-linux-text-editors-for-coding
-
Ballmer: “Linux 是一种癌症”:
www.theregister.com/2001/06/02/ballmer_linux_is_a_cancer/
-
微软曾将 Linux 称为癌症,这个说法是个大错误:
www.zdnet.com/article/microsoft-once-called-linux-a-cancer-and-that-was-a-big-mistake/
-
VIM 初学者教程:
linuxconfig.org/vim-tutorial
-
Distrowatch.com:
distrowatch.com/
-
Linux 文档项目:
tldp.org/
-
LinuxQuestions.org:
www.linuxquestions.org/
-
Linux 手册页:
linux.die.net/man/
回答
-
b
-
c
-
e
-
b
-
错误。最好从你自己的用户账户使用
sudo
。
加入我们的 Discord 社区!
与其他用户、Linux 专家以及作者本人一起阅读本书。
提问、为其他读者提供解决方案、通过“问我任何问题”环节与作者交流,以及更多内容。扫描二维码或访问链接加入社区。
第二章:解读命令
为了履行其作为用户与操作系统内核之间接口的职责,Shell 必须执行五个不同的功能。这些功能包括解读命令、设置变量、启用输入/输出重定向、启用管道以及允许定制用户的工作环境。在本章中,我们将探讨bash
和zsh
是如何解读命令的。作为额外的收获,我们将在接下来的几章中讲解的内容也会帮助你准备一些 Linux 认证考试,比如 Linux 专业人员协会(LPI)或 CompTIA Linux+考试。
本章内容包括:
-
理解命令的结构
-
一次执行多个命令
-
递归运行命令
-
理解命令历史
-
转义和引用
为了跟着学习,你可以使用任何你喜欢的 Linux 发行版,只要它运行的是bash
或zsh
。你最好的选择是使用虚拟机,而不是生产工作站,以防不小心删除或更改一些不该更改的内容。
理解命令结构
对于现实生活和你可能参加的任何认证考试来说,了解命令的结构非常重要。命令可以由最多三个部分组成,并且这些部分有一定的顺序。以下是这些部分以及通常排列的顺序:
-
命令本身
-
命令选项
-
命令参数
如果你计划参加 Linux 认证考试,你肯定要记住这个排序规则。不过,稍后我们会看到一些命令并不总是遵循这个规则。
使用命令选项
有两种常见的选项开关:
-
单字母选项:对于大多数命令,单字母选项前面会有一个短横线。大多数情况下,两个或更多单字母选项可以与一个短横线结合使用。
-
全字选项:对于大多数命令,整个单词选项前面会有两个短横线。两个或更多的全字选项必须单独列出,因为它们不能与一对短横线结合使用。
为了向你展示我们的意思,请查看这个实践实验。
实践实验 – 使用命令选项的练习
在本实验中,我们将使用简单的ls
工具。对于这个工具,选项和参数是可选的,因此我们将在实际操作中看到该命令的不同配置。
-
让我们执行裸
ls
命令,以查看当前目录中的文件和目录。[donnie@fedora ~]$ ls 4-2_Building_an_Alpine_Container.bak Public 4-2_Building_an_Alpine_Container.pptx pwsafe.key addresses.txt python_container alma9_default.txt rad-bfgminer alma9_future.txt ramfetch alma_link.txt read.me.first . . . . . . pCloudDrive yad-form.sh Pictures [donnie@fedora ~]$
-
现在,让我们添加一个单字母选项。我们将使用
-l
选项来显示文件和目录及其一些特征。[donnie@fedora ~]$ ls -l total 40257473 -rw-r--r--. 1 donnie donnie 754207 Apr 5 16:13 4-2_Building_an_Alpine_Container.bak -rw-r--r--. 1 donnie donnie 761796 Apr 8 14:49 4-2_Building_an_Alpine_Container.pptx -rw-r--r--. 1 donnie donnie 137 Apr 2 15:05 addresses.txt -rw-r--r--. 1 donnie donnie 1438 Nov 2 2022 alma9_default.txt . . . . . . -rwxr--r--. 1 donnie donnie 263 May 16 15:42 yad-form.sh [donnie@fedora ~]$
-
使用
ls
命令配合-a
选项,可以查看任何隐藏的文件或目录。(隐藏文件或目录的名字以一个句点开始。)[donnie@fedora ~]$ ls -a . .pcloud .. pCloudDrive 4-2_Building_an_Alpine_Container.bak Pictures 4-2_Building_an_Alpine_Container.pptx .pki addresses.txt .podman-desktop alma9_default.txt .profile . . . . . . .mozilla .Xauthority Music .xscreensaver NetRexx .xsession-errors nikto yad-form.sh [donnie@fedora ~]$
-
接下来,让我们结合这两个选项,以便我们可以看到隐藏文件和未隐藏文件以及目录的特点:
[donnie@fedora ~]$ ls -la total 40257561 drwx------. 1 donnie donnie 2820 Jul 25 13:53 . drwxr-xr-x. 1 root root 12 Aug 9 2022 .. -rw-r--r--. 1 donnie donnie 137 Apr 2 15:05 addresses.txt -rw-------. 1 donnie donnie 15804 Jul 24 17:53 .bash_history -rw-r--r--. 1 donnie donnie 18 Jan 19 2022 .bash_logout -rw-r--r--. 1 donnie donnie 194 Apr 3 12:11 .bash_profile -rw-r--r--. 1 donnie donnie 513 Apr 3 12:11 .bashrc . . . . . . -rw-r--r--. 1 donnie donnie 9041 Feb 4 12:57 .xscreensaver -rw-------. 1 donnie donnie 0 Jul 25 13:53 .xsession-errors -rwxr--r--. 1 donnie donnie 263 May 16 15:42 yad-form.sh [donnie@fedora ~]$
在前面的例子中,donnie donnie
部分表示这些文件和目录属于用户donnie
,并与donnie
组关联。在这个例子中,我们使用一个完整的选项--author
,前面有两个破折号,来查看一些额外的信息。让我们将--author
开关和-l
开关一起使用,来查看这些文件的作者:
[donnie@fedora ~]$ ls -l --author
total 40257473
-rw-r--r--. 1 donnie donnie donnie 137 Apr 2 15:05 addresses.txt
-rw-r--r--. 1 donnie donnie donnie 1438 Nov 2 2022 alma9_default.txt
-rw-r--r--. 1 donnie donnie donnie 1297 Nov 2 2022 alma9_future.txt
. . .
. . .
rwxr--r--. 1 donnie donnie donnie 263 May 16 15:42 yad-form.sh
[donnie@fedora ~]$
所以,看起来那个 Donnie 角色也正是最初创建这些文件的人。(哦,那是我,不是吗?)
使用命令参数
参数是命令操作的对象。对于ls
命令,参数将是文件或目录的名称。例如,假设我们只想查看某个文件的详细信息。我们可以这样做:
[donnie@fedora ~]$ ls -l yad-form.sh
-rwxr--r--. 1 donnie donnie 263 May 16 15:42 yad-form.sh
[donnie@fedora ~]$
我们可以使用*
通配符查看某一类型的所有文件的详细信息,如下所示:
[donnie@fedora ~]$ ls -l *.sh
-rwxr--r--. 1 donnie donnie 116 May 16 15:04 root.sh
-rwxr--r--. 1 donnie donnie 263 May 16 15:42 yad-form.sh
[donnie@fedora ~]$
如果你不熟悉通配符的概念,可以把它们看作是执行模式匹配的一种方式。在上面的例子中,*
通配符用于匹配一个或多个字符。正因如此,ls -l *.sh
命令让我们能看到所有以.sh 为文件扩展名的文件。你还可以以其他方式使用这个通配符。例如,要查看所有以字母 w 开头的文件名和目录名,只需执行:
donnie@opensuse:~> ls -ld w*
drwxrwxr-x 1 donnie users 22 Mar 5 2022 windows
-rw-r--r-- 1 donnie users 82180 Dec 7 2019 wingding.ttf
drwxr-xr-x 1 donnie users 138 Mar 11 2023 wownero-x86_64-linux-gnu-v0.11
donnie@opensuse:~>
要了解有关通配符的更多信息,请查阅进一步阅读部分中的参考资料。
在这个例子中,我们查看的是所有以.sh
结尾的文件。
你并不总是仅限于指定一个参数。在这个例子中,我们正在查看三个不同的文件:
[donnie@fedora ~]$ ls -l missing_stuff.txt yad-form.sh Dylan-My_Back_Pages-tab.odt
-rw-r--r--. 1 donnie donnie 29502 Mar 7 18:30 Dylan-My_Back_Pages-tab.odt
-rw-r--r--. 1 donnie donnie 394 Dec 7 2022 missing_stuff.txt
-rwxr--r--. 1 donnie donnie 263 May 16 15:42 yad-form.sh
[donnie@fedora ~]$
使用-ld
选项查看目录的特征,而不查看目录的内容,如下所示:
[donnie@fedora ~]$ ls -ld Downloads/
drwxr-xr-x. 1 donnie donnie 8100 Aug 4 12:37 Downloads/
[donnie@fedora ~]$
尽管你实际上可以改变选项和参数在许多命令中出现的顺序,但这样做并不是好习惯。为了避免混淆,并为你可能参加的任何 Linux 认证考试做好准备,还是遵循我在这里提供的顺序规则。也就是说,首先是命令本身,其次是命令选项,最后是命令参数。
命令结构部分就到这里。接下来,我们来看一下如何一次性执行多个命令。
一次性执行多个命令
无论是在命令行中还是在 shell 脚本中,了解如何将多个命令合并成一个单一命令都非常方便。在本节中,我将演示三种方法:
-
交互式运行命令
-
使用命令序列
-
使用
find
工具
交互式运行命令
这是一种 shell 脚本编程方式,只不过你只是从命令行执行所有命令,而不是实际编写、保存和执行脚本。在这里,你正在创建一个for 循环——循环中的每个命令都在各自的单独行上——来执行三次目录列出操作。
[donnie@fedora ~]$ for var in arg1 arg2 arg3
> do
> echo $var
> ls
> done
. . .
. . .
[donnie@fedora ~]$
在每一行的末尾,你需要按下Enter键。但在你输入done
命令之后,什么也不会发生。for
循环将会执行三次,每次使用列出的三个参数之一。每次执行时,参数的值都会被赋值给var
变量,而echo
命令将打印当前赋值的内容。输出看起来会像这样:
arg1
4-2_Building_an_Alpine_Container.bak Public
4-2_Building_an_Alpine_Container.pptx pwsafe.key
arg2
4-2_Building_an_Alpine_Container.bak Public
4-2_Building_an_Alpine_Container.pptx pwsafe.key
arg3
4-2_Building_an_Alpine_Container.bak Public
4-2_Building_an_Alpine_Container.pptx pwsafe.key
接下来,按下键盘上的上箭头键,你会看到刚刚执行的for
循环。如果你在bash
中尝试这一操作,你会发现各个命令用分号分隔,如下所示:
[donnie@fedora ~]$ for var in arg1 arg2 arg3; do echo $var; ls; done
在zsh
中,按下上箭头键会使命令的各个部分出现在单独的行上,正如你在这里看到的那样:
donnie@opensuse:~> for var in arg1 arg2 arg3
do
echo $var
ls
done
无论哪种方式,当你按下Enter键时,for
循环将再次执行。
如果你对for
循环的工作方式还有点不清楚,别担心。一旦我们开始实际创建 shell 脚本,我们将更详细地讲解它们。
使用命令序列
命令序列是另一种你会发现非常有用的编程结构。在这里,我演示了如何从命令行使用它们,以便你能掌握基本概念。在接下来的章节中,我会向你展示如何在 shell 脚本中使用它们的例子。
使用分号链式执行命令
你还可以使用分号来分隔你想在同一命令行中执行的独立命令。如果你想先cd
到某个目录,再查看它的内容,你可以把每个命令写在单独的行中,或者把它们写在同一行。这种过程被称为命令链式执行,它的样子如下:
[donnie@fedora ~]$ cd /var ; ls
account cache db ftp kerberos local log nis preserve spool yp
adm crash empty games lib lock mail opt run tmp
[donnie@fedora var]$
[donnie@fedora ~]$ cd /far ; ls
bash: cd: /far: No such file or directory
4-2_Building_an_Alpine_Container.bak Public
4-2_Building_an_Alpine_Container.pptx pwsafe.key
addresses.txt python_container
alma9_default.txt rad-bfgminer
. . .
. . .
[donnie@fedora ~]$
第一个命令失败了,因为我尝试进入一个不存在的目录。但是,第二个命令仍然执行成功,它列出了我家目录中的文件。
使用双与符号进行条件命令执行
你也可以指示bash
或zsh
,仅在第一个命令成功完成时才执行第二个命令。只需用&&
而不是分号来分隔命令,如下所示:
[donnie@fedora ~]$ cd /var && ls
account cache db ftp kerberos local log nis preserve spool yp
adm crash empty games lib lock mail opt run tmp
[donnie@fedora var]$
如果第一个命令没有成功执行呢?请注意,第二个命令并不会执行:
[donnie@fedora ~]$ cd /far && ls
bash: cd: /far: No such file or directory
[donnie@fedora ~]$
使用双管道符进行条件命令执行
如果你希望bash
或zsh
仅在第一个命令未成功执行时才执行第二个命令,只需用||
分隔命令。(这是两个管道符号,你可以在和反斜杠同一个键上找到它们。)举个例子,我们再次尝试输入一个小错误的命令来切换目录。
[donnie@fedora ~]$ ce /var || echo "This command didn't work."
bash: ce: command not found
This command didn't work.
[donnie@fedora ~]$
[donnie@fedora ~]$ cd /var || echo "This command didn't work."
[donnie@fedora var]$
作为一个更实际的例子,试着切换到一个目录,如果该目录不存在则创建它,然后在目录成功创建后切换到它。
[donnie@fedora ~]$ cd mydirectory || mkdir mydirectory && cd mydirectory
bash: cd: mydirectory: No such file or directory
[donnie@fedora mydirectory]$
你仍然会看到一个错误信息,提示你尝试访问的目录不存在。但请看看命令提示符,你会看到目录已经被创建,并且你已经进入了该目录。
使用find
工具
我们现在暂时从多命令执行的讨论中休息一下,来介绍一下 find
工具,它真的是所有搜索工具中的酷炫大哥。介绍完之后,我会使用 find
展示更多同时运行多个命令的方法。
另外,我们有必要提到,find
不仅适用于命令行搜索。它在 Shell 脚本中也非常优秀,稍后你会看到。
如果你和我一样年纪大,你可能还记得 Windows XP 搜索助手,每次从 Windows XP 图形搜索工具进行文件搜索时,它会在屏幕上跳跃。它看起来挺可爱,但并没有增强你的搜索能力。而在 Linux 的 find
工具中,你可以根据你能想到的任何标准执行强大的搜索,然后——通过同一个命令行输入——调用另一个工具来处理搜索结果。我不会尝试讨论 find
的所有选项,因为它们太多了。我将简要介绍你可以用 find
做什么,然后让你通过查看其手册页面了解更多选项。(只需在命令行输入 man find
,即可查看所有选项。)
要执行最基本的搜索,你需要指定两个要素:
-
搜索路径:你可以在指定的路径中,或者整个文件系统中进行搜索。由于
find
本身是递归的,搜索会自动扩展到你指定目录下的所有子目录。(当然,你也可以添加命令开关来限制搜索的深度。) -
你要搜索的内容:有很多方法可以指定搜索内容。你可以按特定的文件名进行搜索,并决定是否区分大小写。你还可以使用通配符,或者搜索具有特定特征或特定年龄的文件。或者,你可以结合多个标准进行更为具体的搜索。限制你的唯一因素就是你的想象力。
假设你现在想要在整个文件系统中搜索所有以 .conf
结尾的文件。你需要在要搜索的文件描述前使用 -name
或 -iname
选项。否则,你会得到一个杂乱无章的目录列表,其中包含你搜索的每个目录,且你要找的信息混杂其中。对于区分大小写的搜索,使用 -name
,对于不区分大小写的搜索,使用 -iname
。在这种情况下,我们使用 -iname
,因为我们希望搜索不区分大小写。
我知道,我之前告诉过你,大多数整字选项开关前面都有一对破折号。find
工具是这个规则的例外,因为它的整字选项开关前面只有一个破折号。
同时需要注意,在生产服务器上对整个文件系统进行搜索,尤其是当硬盘非常大的时候,可能会花费很长时间。有时候确实需要这么做,但尽可能最好将搜索限制在特定目录内。
如果在搜索条件中包含通配符字符,您需要将该搜索条件用引号括起来。这样可以防止 shell 将通配符字符解释为模糊的文件引用。例如,若要在当前工作目录及其所有子目录中执行不区分大小写的搜索,查找所有以 .conf
结尾的文件,我会这样操作:
[donnie@fedora ~]$ find -iname '*.conf'
./.cache/containers/short-name-aliases.conf
./.config/lxsession/LXDE/desktop.conf
./.config/pcmanfm/LXDE/desktop-items-0.conf
./.config/pcmanfm/LXDE/pcmanfm.conf
./.config/lxterminal/lxterminal.conf
./.config/Trolltech.conf
. . .
. . .
./tor-browser/Browser/TorBrowser/Data/fontconfig/fonts.conf
./rad-bfgminer/example.conf
./rad-bfgminer/knc-asic/RPi_system/raspi-blacklist.conf
./something.CONF
[donnie@fedora ~]$[donnie@fedora ~]$
通过使用 -iname
选项,我能够找到以 .conf
或 .CONF
结尾的文件。如果我使用的是 -name
选项,那么只会找到以 .conf
结尾的文件。
通常,您会将搜索路径指定为 find
命令的第一个组件。在 Linux 操作系统中包含的 GNU 实现的 find
中,如果省略搜索路径,find
将会默认搜索当前工作目录,就像我们刚才看到的那样。不幸的是,这个技巧对于 Unix/Unix-like 操作系统(如 FreeBSD、macOS 或 OpenIndiana)不适用。在这些操作系统中,您始终需要指定搜索路径。要使 find
搜索当前工作目录,只需使用点号来指定搜索路径。因此,在我的 FreeBSD 虚拟机上,命令如下所示:
donnie@freebsd-1:~ $ find . -iname '*.conf'
./anotherdir/yetanother.conf
./anotherthing.CONF
./something.conf
donnie@freebsd-1:~ $
好吧,我知道了。你可能会问,为什么我在这本本该是 Linux 书籍的内容里提到 FreeBSD、macOS 和 OpenIndiana。其实,是因为有时我们希望创建可以在多个操作系统上运行的 shell 脚本,而不仅仅是 Linux。如果您在这个命令中包含点号,它仍然能在 Linux 机器上运行,同时也能在您的 Unix/Unix-like 机器上运行。
您还可以指定不是当前工作目录的搜索路径。例如,您可以停留在自己的主目录,并像这样在整个文件系统中进行搜索:
[donnie@fedora ~]$ find / -iname '*.conf'
当然,这将比仅搜索一个目录花费更多的时间。而且,您会遇到错误,因为您的普通用户账户没有权限进入所有目录。要搜索整个文件系统中的所有目录,只需在命令前加上 sudo
,如以下所示:
[donnie@fedora ~]$ sudo find / -iname '*.conf'
您可以使用多个搜索条件进行搜索。如果使用空格分隔条件,它就相当于在它们之间放置了一个 and
运算符。在这里,我们将使用 -mtime -7
选项来查找过去七天内修改过的 .conf
文件,并在最后使用 -ls
选项显示文件的详细信息:
[donnie@fedora ~]$ sudo find / -iname '*.conf' -mtime -7 -ls
18 4 -rw-r--r-- 1 root root 328 Jul 24 17:50 /boot/loader/entries/81085aed13d34626859063e7ebf29da5-6.4.4-100.fc37.x86_64.conf
3321176 4 -rw-r--r-- 1 donnie donnie 467 Jul 24 16:14 /home/donnie/.config/pcmanfm/LXDE/pcmanfm.conf
370 4 -rw-r--r-- 1 donnie donnie 3272 Jul 19 16:21 /home/donnie/.config/Trolltech.conf
. . .
. . .
4120762 8 -rw-r--r-- 2 root root 7017 Jul 21 14:43 /var/lib/flatpak/app/com.notepadqq.Notepadqq/x86_64/stable/a049a1963430515aa15d950212fc1f0db7efb703a94ddd1f1d316b38ad12ec72/files/lib/node_modules/npm/node_modules/request/node_modules/http-signature/node_modules/jsprim/node_modules/verror/jsl.node.conf
[donnie@fedora ~]$
要查找修改时间超过七天的 .conf
文件,请将 -7
替换为 +7
,如下所示:
[donnie@fedora ~]$ sudo find / -iname '*.conf' -mtime +7 -ls
你还可以通过创建复合表达式来执行更复杂的搜索。它的工作原理像代数一样,表达式是从左到右评估的,除非你使用括号将某些项分组。但这样做时,有几个小陷阱。
由于括号符号在bash
和zsh
中有特殊含义,你需要在它们前面加上反斜杠,这样bash
和zsh
就不会误解它们的含义。你还需要在括号符号和它们所包含的内容之间留一个空格。
假设我们现在要查找/etc/
目录下所有.conf
文件,这些文件要么在过去七天内被修改,要么在 30 天前就被访问过。我们将使用-atime
开关来设置访问时间条件。or
运算符由-o
表示。
[donnie@fedora ~]$ sudo find /etc -iname '*.conf' \( -mtime -7 -o -atime +30 \)
[sudo] password for donnie:
/etc/UPower/UPower.conf
/etc/X11/xinit/xinput.d/ibus.conf
/etc/X11/xinit/xinput.d/xcompose.conf
/etc/X11/xinit/xinput.d/xim.conf
. . .
. . .
/etc/appstream.conf
/etc/whois.conf
/etc/nfsmount.conf
[donnie@fedora ~]$
/etc/
目录中有几个子目录需要 root 权限才能进入,所以我又使用了sudo
,就像之前那样。在命令末尾添加-ls
选项可以显示文件的时间戳,这可以告诉我每个具体文件满足哪一个搜索条件。
如果你想查找只属于某个特定用户的文件,可以使用-user
开关。添加第二个条件来查找只属于某个特定用户的特定类型的文件。在这里,我正在搜索整个文件系统,寻找所有属于我的.png
图形文件:
[donnie@fedora ~]$ sudo find / -user donnie -iname '*.png'
/home/donnie/.cache/mozilla/firefox/xgwvyw2p.default-release/thumbnails/9aa3453b0b6246665eb573e58a40fe7c.png
/home/donnie/.cache/mozilla/firefox/xgwvyw2p.default-release/thumbnails/96c0e5aa4c2e735c2ead0701d2348dd6.png
. . .
. . .
/home/donnie/rad-bfgminer/vastairent.png
find: '/run/user/1000/doc': Permission denied
find: '/run/user/1000/gvfs': Permission denied
/tmp/.org.chromium.Chromium.IpK3VA/pcloud1_16.png
find: '/tmp/.mount_pcloudWz4ji1': Permission denied
[donnie@fedora ~]$
即使拥有完全的sudo
权限,仍然有一些目录我无法访问。但这没关系。
你可以使用-group
开关来查找属于某个特定组的文件。在这里,我正在查看我自己主目录下,属于nobody
组的文件或目录。
[donnie@fedora ~]$ sudo find -group nobody -ls
3344421 0 drwxr-xr-x 1 nobody nobody 0 Jul 25 18:36 ./share
3344505 0 -rw-r--r-- 1 donnie nobody 0 Jul 25 18:38 ./somefile.txt
[donnie@fedora ~]$
请注意,我在这里仍然使用sudo
,因为即使在我自己的主目录中,也有一些目录在没有它的情况下find
是无法访问的。(这些目录包含有关 Docker 容器的信息。)
相反,你可以使用-nogroup
开关来查找不属于/etc/group
文件中列出的任何组的文件。
[donnie@fedora ~]$ sudo find -nogroup
./.local/share/containers/storage/overlay/994393dc58e7931862558d06e46aa2bb17487044f670f310dffe1d24e4d1eec7/diff/etc/shadow
./.local/share/containers/storage/overlay/ded7a220bb058e28ee3254fbba04ca90b679070424424761a53a043b93b612bf/diff/etc/shadow
./.local/share/containers/storage/overlay/8e012198eea15b2554b07014081c85fec4967a1b9cc4b65bd9a4bce3ae1c0c88/diff/etc/shadow
./.local/share/containers/storage/overlay/7cd52847ad775a5ddc4b58326cf884beee34544296402c6292ed76474c686d39/diff/etc/shadow
[donnie@fedora ~]$
在 Linux/Unix 世界中,系统上的一切都表示为文件。系统的普通用户通常只会遇到常规文件和目录,但对于系统管理员来说,还有许多其他类型的文件会很重要。各种文件类型包括:
-
常规文件: 这些是普通用户通常会访问的文件类型。图形文件、视频文件、数据库文件、电子表格文件、文本文件和可执行文件都是常规文件的例子。
-
目录: 目录作为一种文件类型可能会让人觉得奇怪,但在 Linux 和 Unix 的世界里,事情就是如此。
-
字符设备: 字符设备要么接受,要么提供一个串行数据流。声卡或终端会由字符设备文件表示。你会在
/dev/
目录中找到这些文件。 -
块设备:块设备文件代表可以随机访问的设备。示例包括硬盘、固态硬盘和驱动器分区。你还可以在
/dev/
目录中找到这些文件。 -
命名管道:这些设备接收一个系统进程的输出,并将其作为输入提供给另一个系统进程,从而实现进程间通信。
-
套接字:这些与命名管道相同,只是它们可以在通信流中发送和接收文件描述符。此外,与命名管道不同,套接字允许两个进程之间进行双向数据交换。
-
符号链接:这种类型的文件只是指向一个普通文件或目录。这允许用户从文件系统的多个位置访问文件和目录,或者通过不同的名称访问它们。
你可以通过执行ls -l
命令来判断文件的类型。每个文件输出的第一个字符被称为文件模式字符串。这个文件模式字符串表示文件类型。例如,让我们看看我家目录中的内容:
[donnie@fedora ~]$ ls -l
total 137972
-rw-r--r--. 1 donnie donnie 12111206 Feb 18 13:41 dnf_list.txt
drwxr-xr-x. 15 donnie donnie 4096 Jul 27 16:39 orphaned_files
drwxr-xr-x. 2 donnie donnie 6 Jul 29 16:53 perm_demo
-rw-r--r--. 1 donnie donnie 643 Mar 26 15:53 sample.json
[donnie@fedora ~]$
以-
开头的行表示普通文件,而以d
开头的行表示目录。各种文件类型表示如下:
文件模式字符串 | 文件类型 |
---|---|
- |
普通文件 |
d |
目录 |
c |
字符设备 |
b |
块设备 |
p |
命名管道 |
s |
套接字 |
l |
符号链接 |
表 2.1:文件类型标识符
有时你可能需要定位所有某一类型的文件。你可以通过使用-type
选项来做到这一点,像这样:
[donnie@fedora ~]$ sudo find / -type p -ls
545 0 prw------- 1 root root 0 Jul 31 15:20 /run/initctl
542 0 prw------- 1 root root 0 Jul 31 15:20 /run/dmeventd-client
541 0 prw------- 1 root root 0 Jul 31 15:20 /run/dmeventd-server
6 0 p--------- 1 donnie donnie 0 Jul 31 15:29 /run/user/1000/systemd/inaccessible/fifo
1228 0 prw------- 1 root root 0 Jul 31 15:21 /run/systemd/inhibit/2.ref
1193 0 prw------- 1 root root 0 Jul 31 15:21 /run/systemd/inhibit/1.ref
1324 0 prw------- 1 root root 0 Jul 31 15:29 /run/systemd/sessions/3.ref
1311 0 prw------- 1 root root 0 Jul 31 15:29 /run/systemd/sessions/1.ref
8 0 p--------- 1 root root 0 Jul 31 15:20 /run/systemd/inaccessible/fifo
112 0 prw------- 1 root root 0 Jul 31 15:20 /var/lib/nfs/rpc_pipefs/gssd/clntXX/gssd
[donnie@fedora ~]$
如你所见,我使用-type p
选项来搜索所有命名管道文件。
现在,让我们考虑之前的例子,在这个例子中我们搜索了所有以.conf
文件扩展名结尾的文件:
[donnie@fedora ~]$ sudo find / -iname '*.conf'
这个命令只找到了普通文件,因为它们是系统中唯一拥有.conf
文件扩展名的文件类型。但是,现在假设我们想在/etc/
目录中搜索所有包含conf
文本字符串的子目录。如果我们没有指定文件类型,我们将看到普通文件、符号链接和目录:
[donnie@fedora ~]$ sudo find /etc -name '*conf*' -ls
329486 4 -rw-r--r-- 1 root root 351 Jul 27 07:02 /etc/dnf/plugins/copr.conf
329487 4 -rw-r--r-- 1 root root 30 Jul 27 07:02 /etc/dnf/plugins/debuginfo-install.conf
8480155 4 -rw-r--r-- 1 root root 93 May 18 04:27 /etc/dnf/protected.d/dnf.conf
. . .
25325169 0 lrwxrwxrwx 1 root root 43 Jul 29 18:19 /etc/crypto-policies/back-ends/bind.config -> /usr/share/crypto-policies/DEFAULT/bind.txt
25325172 0 lrwxrwxrwx 1 root root 45 Jul 29 18:19 /etc/crypto-policies/back-ends/gnutls.config -> /usr/share/crypto-policies/DEFAULT/gnutls.txt
. . .
5430579 0 drwxr-xr-x 2 root root 25 Sep 19 2022 /etc/reader.conf.d
8878157 0 drwxr-xr-x 3 root root 27 Dec 8 2022 /etc/pkgconfig
8927250 0 drwxr-xr-x 2 root root 83 Nov 16 2022 /etc/krb5.conf.d
. . .
[donnie@fedora ~]$
我们将使用-type d
选项来进一步缩小范围:
[donnie@fedora ~]$ sudo find /etc -name '*conf*' -type d -ls
17060336 0 drwxr-xr-x 2 root root 41 Dec 8 2022 /etc/fonts/conf.d
25430579 0 drwxr-xr-x 2 root root 25 Sep 19 2022 /etc/reader.conf.d
8878157 0 drwxr-xr-x 3 root root 27 Dec 8 2022 /etc/pkgconfig
8927250 0 drwxr-xr-x 2 root root 83 Nov 16 2022 /etc/krb5.conf.d
25313333 0 drwxr-xr-x 2 root root 6 Feb 1 17:58 /etc/security/pwquality.conf.d
25395980 0 drwxr-xr-x 2 root root 30 Dec 8 2022 /etc/X11/xorg.conf.d
17060487 0 drwxr-xr-x 2 root root 6 Aug 9 2022 /etc/pm/config.d
. . .
. . .
16917753 0 drwxr-xr-x 2 root root 33 Jul 29 18:11 /etc/containers/registries.conf.d
[donnie@fedora ~]$
很棒。现在我们只看到目录,这正是我们想要的。
如我之前所说,find
工具有很多选项可以使用。(输入man find
可以查看所有选项。)
现在,在介绍了find
之后,让我们来看一下如何使用find
通过一个命令执行多个操作。
使用find
执行多个操作
我们接下来的技巧有点复杂。我们将使用find
的-exec
和-ok
选项来使find
对每个找到的文件执行某种操作。首先,find
找到文件。然后,它会触发另一个命令来对文件执行某种操作。它是这样工作的:
-exec
和 -ok
开关告诉 shell 仅当第一个命令产生有效输出时,才执行第二个命令。然后,它将第一个命令(find
)的输出作为第二个命令的参数。这两个开关之间的区别在于,-exec
会在不提示用户的情况下自动对每个文件执行所需操作。而 -ok
开关会在 find
找到每个文件后暂停,询问用户是否继续对该文件执行操作。在这里,我们正在搜索整个文件系统中所有超过 30 天的 .zip
文件,并将它们复制到 /home/donnie/
目录。(注意,我仍然使用 sudo
以便能够访问所有目录。)
[donnie@fedora ~]$ sudo find / \( -mtime +30 -iname '*.zip' \) -exec cp {} /home/donnie \;
cp
命令后的 {}
告诉 bash
或 zsh
,“获取 find
命令的结果,并将其作为参数放到这里”。注意,这个命令序列必须以分号结尾。但是,由于分号对 bash
和 zsh
有特殊含义,你必须在它前面加上反斜杠,以便 bash
和 zsh
能正确解释它。
另外,注意你必须在第一个括号后留一个空格,并在反斜杠前的括号之前再留一个空格。
现在,假设你只想复制找到的一部分文件。只需将 -exec
开关替换为 -ok
开关。它的工作方式与 -exec
相同,但在对文件执行操作之前会询问权限。你必须输入 y 或 n,然后才能继续执行下一个文件。
同样的原理也适用于删除文件。
[donnie@fedora ~]$ sudo find / \( -mtime +30 -iname '*.zip' \) -ok rm {} \;
假设现在 Vicky、Cleopatra、Frank 和 Goldie 都在为某个项目创建图形文件。它们应该将图形文件放入各自主目录下的 graphics
子目录。有时他们会忘记,将文件放入各自的顶级主目录,如下图所示:
图 2.1:这些图形文件中的一些文件放错了位置。
现在,让我们动手实践一下这个操作。
实践实验 – 使用 find 执行其他命令
对于这个实验,使用 Fedora、Debian 或 Ubuntu 虚拟机。(我会提供它们的操作说明。)
假设我们想将每个人的图形文件复制到一个公共的备份目录。
-
首先,像这样创建
/backup
目录:[donnie@fedora ~]$ sudo mkdir /backup
就我们目前的目的而言,暂时保持文件的所有权和权限设置不变。
-
接下来,创建 Vicky、Cleopatra、Frank 和 Goldie 的用户账户,并为每个账户分配密码。在 Fedora 上,命令如下所示:
donnie@fedora:~$ sudo useradd frank donnie@fedora:~$ sudo passwd frank
在 Debian 或 Ubuntu 上,使用交互式的 adduser
命令,该命令会同时创建用户账户并设置密码。命令如下:
donnie@debian12:~$ sudo adduser goldie
-
登录到每个用户的账户,在每个用户的主目录中创建一个
graphics
目录,然后创建一些假的图形文件。以下是执行这些操作的命令:goldie@fedora:~$ touch goldie02.png goldie@fedora:~$ mkdir graphics goldie@fedora:~$ cd graphics/ goldie@fedora:~/graphics$ touch {goldie01.png,goldie03.png,goldie04.png} goldie@fedora:~/graphics$
touch
命令实际上是为程序员设计的,用于一些我这里不打算深入讲解的用途。但它在这种情况下也很有用,尤其是当你只需要创建一些假文件进行测试时。通过将用逗号分隔的文件名列表放入一对大括号中,你可以通过一个命令创建多个文件。为了验证这一点,让我们看一下graphics
目录:
goldie@fedora:~/graphics$ ls -l
total 0
-rw-r--r--. 1 goldie goldie 0 Mar 23 13:27 goldie01.png
-rw-r--r--. 1 goldie goldie 0 Mar 23 13:27 goldie03.png
-rw-r--r--. 1 goldie goldie 0 Mar 23 13:27 goldie04.png
goldie@fedora:~/graphics$
-
在这一步,你需要重新登录到你的用户帐户。你要确保获取所有的图形文件,即使它们在用户的顶级主目录中,也要将它们复制到
/backup/
目录中。你的命令和结果应该像这样:[donnie@fedora ~]$ sudo find /home -name '*.png' -exec cp {} /backup \; [donnie@fedora ~]$ ls -l /backup/ total 0 -rw-r--r--. 1 root root 0 Jul 28 15:40 cleopatra01.png -rw-r--r--. 1 root root 0 Jul 28 15:40 cleopatra02.png -rw-r--r--. 1 root root 0 Jul 28 15:40 cleopatra03.png -rw-r--r--. 1 root root 0 Jul 28 15:40 cleopatra04.png -rw-r--r--. 1 root root 0 Jul 28 15:40 frank01.png -rw-r--r--. 1 root root 0 Jul 28 15:40 frank02.png -rw-r--r--. 1 root root 0 Jul 28 15:40 frank03.png -rw-r--r--. 1 root root 0 Jul 28 15:40 frank04.png -rw-r--r--. 1 root root 0 Jul 28 15:40 goldie01.png -rw-r--r--. 1 root root 0 Jul 28 15:40 goldie02.png -rw-r--r--. 1 root root 0 Jul 28 15:40 goldie03.png -rw-r--r--. 1 root root 0 Jul 28 15:40 goldie04.png -rw-r--r--. 1 root root 0 Jul 28 15:40 vicky01.png -rw-r--r--. 1 root root 0 Jul 28 15:40 vicky02.png -rw-r--r--. 1 root root 0 Jul 28 15:40 vicky03.png -rw-r--r--. 1 root root 0 Jul 28 15:40 vicky04.png [donnie@fedora ~]$
我在这里给你展示的内容只是find
命令能做的事情的冰山一角。要查看你可以指定的所有搜索条件,请打开find
的手册页面并滚动到TESTS部分。
稍后我们将再看一些find
的示例。现在,我们来看看如何创建递归命令。
递归运行命令
我们已经展示过find
工具是递归的。也就是说,它会自动搜索你指定的搜索路径下的子目录,而无需你额外告诉它。 然而,大多数 Linux 命令并不是这样。如果你希望它们递归工作,你必须告诉它们。大多数情况下,这是通过-R
或-r
选项来完成的。(有些命令使用–R
,有些使用–r
。你最终会发现,不同命令在选项开关的使用上并没有很大的统一性。)让我们通过一个实操实验来看看它是如何工作的。
本节中的示例涉及使用数字方式设置文件和目录权限。对于那些不熟悉如何操作的人,我在进一步阅读部分提供了参考。
实操实验 – 使用带递归的命令
在这个实验中,你将使用ls
和chmod
工具的递归选项。让我们深入了解一下。
首先,让我们创建一个带有嵌套子目录的新目录,像这样:
[donnie@fedora ~]$ sudo mkdir -p /perm_demo/level1/level2/level3/level4
[donnie@fedora ~]$
接下来,我们要查看整个目录嵌套的权限设置。所以,让我们这样做:
[donnie@fedora ~]$ ls -l /perm_demo/
total 0
drwxr-xr-x. 3 root root 20 Jul 29 17:09 level1
[donnie@fedora ~]$
哦,这并没有什么帮助,是吧?我们只能看到第一层子目录。
让我们尝试添加-R
选项,看看是否有帮助:
[donnie@fedora ~]$ ls -lR /perm_demo/
/perm_demo/:
total 0
drwxr-xr-x. 3 root root 20 Jul 29 17:09 level1
/perm_demo/level1:
total 0
drwxr-xr-x. 3 root root 20 Jul 29 17:09 level2
/perm_demo/level1/level2:
total 0
drwxr-xr-x. 3 root root 20 Jul 29 17:09 level3
/perm_demo/level1/level2/level3:
total 0
drwxr-xr-x. 2 root root 6 Jul 29 17:09 level4
/perm_demo/level1/level2/level3/level4:
total 0
[donnie@fedora ~]$
这已经好多了,因为我们现在可以看到所有四个嵌套子目录的权限设置。但是,我们发现权限设置并不是我们想要的。根据当前的755
权限设置,我们允许用户拥有读/写/执行权限,而组和其他用户只有读/执行权限。我们真正想要的是让用户和组都拥有读/写/执行权限,而其他人则完全没有访问权限。我们将通过使用chmod
命令将权限设置更改为770
来实现。-R
选项将允许我们更改顶级目录以及所有四个嵌套子目录的设置。
使用以下命令递归地设置正确的权限:
[donnie@fedora ~]$ sudo chmod -R 770 /perm_demo/
[donnie@fedora ~]$
既然你已经为其他用户移除了访问权限,你将需要使用sudo
来查看权限设置:
[donnie@fedora ~]$ sudo ls -lR /perm_demo/
/perm_demo/:
total 0
drwxrwx---. 3 root root 20 Jul 29 17:09 level1
/perm_demo/level1:
total 0
drwxrwx---. 3 root root 20 Jul 29 17:09 level2
/perm_demo/level1/level2:
total 0
drwxrwx---. 3 root root 20 Jul 29 17:09 level3
/perm_demo/level1/level2/level3:
total 0
drwxrwx---. 2 root root 6 Jul 29 17:09 level4
/perm_demo/level1/level2/level3/level4:
total 0
[donnie@fedora ~]$
你会看到权限设置现在是770
,表示我们已经实现了极致的酷炫。
提示
你可能会在某个时刻被要求创建一个自动编译和安装程序的 shell 脚本。在编写这种脚本时,创建嵌套目录并递归地更改它们的权限设置将非常有用。
还有其他一些实用工具也具有递归功能。(在本书的学习过程中,你会遇到其中的一些。)小小的区别是,对于某些工具,递归选项是-r
,而对于其他工具则是-R
。不过,这没关系。如果不确定,可以查阅你需要使用的工具的手册页。
现在我们已经了解了递归,接下来让我们来上一节history
课程。
理解命令历史
每当你使用命令行时,会有一些时候你需要输入某些命令不止一次。如果你刚刚输入了一个既长又复杂的命令,你可能不会太高兴再次输入它。不过不用担心。对于这一点,bash
和zsh
提供了回忆和/或编辑你之前输入的命令的功能。实现这一点有几种方法。
每当你输入命令时,它会存储在内存中,直到你退出 shell 会话。该命令会被添加到由HISTFILE
变量指定的文件中。通常,这在bash
中是.bash_history
文件,在zsh
中是.histfile
文件。你可以在每个用户的主目录中找到这些文件。要验证这一点,你可以使用echo
命令,像这样:
[donnie@fedora ~]$ echo $HISTFILE
/home/donnie/.bash_history
[donnie@fedora ~]$
在zsh
中,你会看到以下内容:
donnie@opensuse:~> echo $HISTFILE
/home/donnie/.histfile
donnie@opensuse:~>
保存在.bash_history
文件或.histfile
文件中的命令数量是由/etc/profile
文件中的HISTSIZE
变量设置的。(bash
和zsh
都参考了这个文件。)你可以使用grep
来搜索该行,而无需打开文件,像这样:
[donnie@fedora ~]$ grep HISTSIZE /etc/profile
HISTSIZE=1000
export PATH USER LOGNAME MAIL HOSTNAME HISTSIZE HISTCONTROL
[donnie@fedora ~]$
你也可以使用echo
来查看这个设置:
[donnie@fedora ~]$ echo $HISTSIZE
1000
[donnie@fedora ~]$
无论如何,我们可以看到系统被设置为将最后 1,000 条用户命令保存在.bash_history
文件中。
大多数情况下,你可能会使用键盘上的上下箭头键来调用先前输入的命令。如果你不断按上箭头键,你将滚动浏览以前的命令列表,从最后输入的命令开始。如果你越过了你想要的命令,可以使用下箭头键返回到它。当你最终找到想要重复的命令时,你可以按Enter键直接输入,或者先编辑再按Enter键。
你也可以用各种方式使用!
来回顾过去的命令。例如,输入!!
将执行你输入的最后一个命令,就像你看到的这样:
[donnie@fedora ~]$ ls -l *.txt
-rw-r--r--. 1 donnie donnie 12111206 Feb 18 13:41 dnf_list.txt
-rw-r--r--. 1 donnie donnie 2356 Jul 29 18:46 md5sumfile.txt
-rw-r--r--. 1 donnie donnie 2356 Jul 29 18:49 newmd5sums.txt
[donnie@fedora ~]$ !!
ls -l *.txt
-rw-r--r--. 1 donnie donnie 12111206 Feb 18 13:41 dnf_list.txt
-rw-r--r--. 1 donnie donnie 2356 Jul 29 18:46 md5sumfile.txt
-rw-r--r--. 1 donnie donnie 2356 Jul 29 18:49 newmd5sums.txt
[donnie@fedora ~]$
使用!
后跟一个文本字符串,来执行最后执行过的以该字符串开头的命令。假设我想重复我做过的最后一个grep
命令,像这样:
[donnie@fedora ~]$ !grep
grep HISTSIZE /etc/profile
HISTSIZE=1000
export PATH USER LOGNAME MAIL HOSTNAME HISTSIZE HISTCONTROL
[donnie@fedora ~]$
使用!?
后跟一个字符串来执行最后执行过的包含该字符串的命令,像这样:
[donnie@fedora ~]$ echo "The fat cat jumped over the skinny dog."
The fat cat jumped over the skinny dog.
[donnie@fedora ~]$ !?skinny
echo "The fat cat jumped over the skinny dog."
The fat cat jumped over the skinny dog.
[donnie@fedora ~]$
现在,最酷的部分来了。首先,我们查看历史列表,像这样:
[donnie@fedora ~]$ history
1 sudo dnf -y upgrade
2 sudo shutdown -r nowj
3 sudo shutdown -r now
4 cd /usr/share
. . .
. . .
478 echo "The fat cat jumped over the skinny dog."
479 clear
[donnie@fedora ~]$
要执行此列表中的命令,请输入!
后跟命令编号。例如,要再次执行echo
命令,请输入!478
,像这样:
[donnie@fedora ~]$ !478
echo "The fat cat jumped over the skinny dog."
The fat cat jumped over the skinny dog.
[donnie@fedora ~]$
在我展示的所有history
技巧中,最后这个对我来说是最有用的。但等一下,这里还有一个你可能会觉得有用的技巧。也就是说,你可以显示命令历史记录,并带有显示每个命令执行时间戳的功能。在bash
中,只需这样做:
donnie@opensuse:~> HISTTIMEFORMAT="%d/%m/%y %T " history
输出将类似于这样:
49 22/03/24 14:02:29 ./start_mining.sh
50 22/03/24 14:02:29 vim start_mining.sh
51 22/03/24 14:02:29 ./start_mining.sh
52 22/03/24 14:02:29 cd
53 22/03/24 14:02:29 cd Downloads/
54 22/03/24 14:02:29 ls
. . .
. . .
1046 23/03/24 12:03:53 clear
1047 23/03/24 12:05:37 HISTTIMEFORMAT="%d/%m/%y %T " history
donnie@opensuse:~>
这里发生的情况是,我们正在配置HISTTIMEFORMAT
环境变量,以显示我们期望的时间戳格式,然后运行history
命令。
在zsh
中,这稍微容易一些,因为zsh
允许我们使用带有-f
选项开关的history
,像这样:
donnie@opensuse:~> zsh
donnie@opensuse:~> history -f
17 3/23/2024 11:58 echo $HISTFILE
18 3/23/2024 11:58 cd /etc
19 3/23/2024 11:58 ls
20 3/23/2024 11:58 less zprofile
. . .
. . .
31 3/23/2024 11:58 echo $HISTFILE
32 3/23/2024 11:58 exit
donnie@opensuse:~>
请注意,在bash
上运行history -f
会给出错误信息,如下所示:
donnie@opensuse:~> history -f
bash: history: -f: invalid option
history: usage: history [-c] [-d offset] [n] or history -anrw [filename] or history -ps arg [arg...]
donnie@opensuse:~>
好的,我们继续下一个话题。
转义和引用
每当你在命令行中输入任何内容或在 Shell 脚本中输入时,你将使用普通的字母数字文本和非字母数字字符的混合。某些字符在 Shell 中具有特殊含义,会导致 Shell 以某种特殊方式执行。有时,你可能希望 Shell 将这些特殊字符视为普通文本,而不是具有魔法般的功能。为了实现这一点,你可以选择转义或引用这些特殊字符。
在 Shell 命令中,Shell 可以解释的字符通常分为两类。这些是:
-
普通字符:
bash
和zsh
会字面解释这些字符。换句话说,它们对 Shell 没有特殊含义。 -
元字符:这些字符对
bash
和zsh
有特殊的含义。可以说,元字符为这些 Shell 提供了某种特殊的指令。
这是一个由空格分隔的元字符列表,可以在 Shell 脚本或 Shell 命令中使用:
``& ; | * ? ' " ` [ ] ( ) $ < > { } # / \ ! ~``
我现在不想解释每个元字符的功能,因为其中许多可以根据命令的上下文执行多个功能。不过,我们已经在前面的示例中看到了一些元字符的使用,接下来我们会在本书的后续内容中看到其余元字符的应用。
转义元字符
在前面的示例中,我们已经看到一些元字符的应用。为了进一步演示,让我们看看不起眼的*
元字符,它可以用作通配符。我们先列出/etc/
目录下所有的.conf
文件,像这样:
[donnie@fedora ~]$ ls /etc/*.conf
/etc/anthy-unicode.conf /etc/libaudit.conf /etc/rsyncd.conf
/etc/appstream.conf /etc/libuser.conf /etc/rsyslog.conf
/etc/asound.conf /etc/locale.conf /etc/sestatus.conf
. . .
. . .
/etc/ld.so.conf /etc/resolv.conf
[donnie@fedora ~]$
你看到我刚刚列出了所有文件名以.conf
扩展名结尾的文件。现在,让我们在*
前面加上一个\
,像这样:
[donnie@fedora ~]$ ls /etc/\*.conf
ls: cannot access '/etc/*.conf': No such file or directory
[donnie@fedora ~]$
在*
前面加上\
导致 Shell 按字面意义解释*
,而不是将其视为元字符。现在,我们不是在查找所有以.conf
结尾的文件,而是在查找名为*.conf
的特定文件。由于没有这样的文件,ls
返回了一个错误信息。
在我们之前的find
示例中,当我们执行复合搜索时,我们必须在每个括号前加上\
,以便 Shell 能正确解释它们。这就是当时的情况:
[donnie@fedora ~]$ sudo find / \( -mtime +30 -iname '*.zip' \)
/home/donnie/Downloads/Roboto_Condensed.zip
/home/donnie/Downloads/Bungee_Spice.zip
. . .
. . .
/home/donnie/dosbox/turboc/SAMPLES/simpwn18/SWTCPPRJ.ZIP
/home/donnie/dosbox/turboc/SAMPLES/simpwn18/SWH.ZIP
/home/donnie/dosbox/turboc/SAMPLES/simpwn18/SIMPWIN.ZIP
/home/donnie/dosbox/turboc/SAMPLES/simpwn18/SWTC.ZIP
/home/donnie/dosbox/turbocplusplus/TC/TC.zip
[donnie@fedora ~]$
现在,让我们试试不加\
字符的情况:
[donnie@fedora ~]$ sudo find / ( -mtime +30 -iname '*.zip' )
bash: syntax error near unexpected token `('
[donnie@fedora ~]$
这次,我收到一个错误信息,因为bash
不理解我正在尝试做什么。
现在,出于好玩,试试在你自己的机器上执行这对命令,并注意输出的差异:
echo I won $300.
echo I won \$300.
我想我应该提到,当你在元字符前加上\
,使得 Shell 按字面意思解释元字符时,这叫做转义元字符。这是你在执行普通 Shell 命令或编写 Shell 脚本时会广泛使用的技术。
好的,让我们看另一种让 Shell 按字面意义解释元字符的方法。
引号
在执行 Shell 命令或编写 Shell 脚本时,你有时可能需要引用文本字符串。这只需要将文本字符串用一对双引号("
)或一对单引号('
)括起来。如果你用一对单引号将文本字符串括起来,Shell 会将引号内的任何元字符解释为正常的字面字符。如果你用一对双引号将文本字符串括起来,Shell 会将大部分元字符(但不是全部)解释为正常的字面字符。为了展示这一点,让我们创建一个名为name
的编程变量,并将其赋值为charlie
,如下所示:
[donnie@fedora ~]$ name=charlie
[donnie@fedora ~]$
接下来,我们将尝试回显name
的值,使用一对单引号:
[donnie@fedora ~]$ echo '$name'
$name
[donnie@fedora ~]$
你看到单引号使得 Shell 将$
解释为字面字符。现在,让我们看看如果我们使用一对双引号会发生什么:
[donnie@fedora ~]$ echo "$name"
charlie
[donnie@fedora ~]$
这一次,我们看到了变量的实际值,因为$
是一个元字符,双引号不会将其视为字面字符。
我们稍后会更深入地讲解编程变量的主题。所以目前,如果你还不完全理解这个概念,也不要太担心。
作为参考,以下是不会被解释为字面字符的元字符完整列表,方法是将它们用双引号括起来:
-
"
-
\
-
`
-
$
为了澄清,列表包括双引号字符、反斜杠、反引号和美元符号。(目前,别担心这些元字符的作用。我们会在适当的时候讲解它们。)
对于所有其他元字符,是否使用双引号或单引号并不重要。我们来看这个使用通配符字符的例子:
[donnie@fedora ~]$ echo '*.txt'
*.txt
[donnie@fedora ~]$ echo "*.txt"
*.txt
[donnie@fedora ~]$
无论哪种方式,结果是相同的。每次,*
都会被当作字面字符处理。要将*
作为实际的元字符使用,只需省略引号,如下所示:
[donnie@fedora ~]$ echo *.txt
addresses.txt alma9_default.txt alma9_future.txt alma_link.txt centos7scan_modified.txt centos7scan.txt dnf_list.txt finances.txt missing_stuff.txt password_for_RHEL_VM.txt rpmfusion.txt somefile.txt temp.txt test.txt text.txt ubuntuscan_modified.txt ubuntuscan.txt withL3.txt withoutL3.txt
[donnie@fedora ~]
好的,转义和引用部分差不多就到此为止,这一章也讲完了。让我们总结一下并继续。
总结
在本章中,我们已经覆盖了一些重要的基础知识。我们首先描述了 shell 命令的结构和组成部分,以及如何通过一个命令执行多个操作。接着,我们看了find
工具以及你可以用它做的有趣事情。然后,我们讨论了如何递归执行命令,最后总结了转义和引用的内容。
在下一章中,我们将讨论变量和管道。我们在那里见。
问题
-
以下哪个元字符集会使第二个命令仅在第一个命令成功执行后运行?
-
||
-
&&
-
|
-
&
-
-
你想运行一个包含
$
元字符的命令,但你希望 shell 将该元字符按字面意思解释。你该怎么做?(选择两项)-
在元字符前加上
/
。 -
将包含元字符的文本字符串用一对单引号括起来。
-
在元字符前加上
\
。 -
将包含元字符的文本字符串用一对双引号括起来。
-
这是不可能的。
-
-
你正在使用
find
在 FreeBSD 系统上搜索文件。要在当前工作目录中搜索,在 FreeBSD 上必须做什么,而在 Linux 上不需要做的?-
使用点符号指定搜索路径。
-
没有区别。这两个系统上的命令执行方式是相同的。
-
在 FreeBSD 系统上无法实现这一点。
-
使用
sudo
。
-
-
你想用一个
mkdir
命令创建一个嵌套的目录结构。你该如何操作?-
使用
-r
选项使mkdir
递归运行。 -
使用
-R
选项使mkdir
递归运行。 -
使用
-P
选项。 -
使用
-p
选项。
-
-
你希望自动对
find
找到的每个文件执行某个操作,而不需要提示。你会使用哪个find
选项来实现?-
-ok
-
-exec
-
--exec
-
--ok
-
进一步阅读
-
Linux 文件权限解释:
www.redhat.com/sysadmin/linux-file-permissions-explained
-
如何使用 Bash 通配符进行全局匹配?:
www.shell-tips.com/bash/wildcards-globbing/#gsc.tab=0
-
如何在 Linux 中递归搜索目录名称:
www.howtogeek.com/devops/how-to-recursively-search-directory-names-in-linux/
-
Linux 中的 Find 命令:
linuxize.com/post/how-to-find-files-in-linux-using-the-command-line/
-
使用 Linux find 命令的 10 种方法:
www.redhat.com/sysadmin/linux-find-command
-
什么是 Linux 元字符?你需要知道的一切:
www.makeuseof.com/what-are-linux-metacharacters/
-
我喜欢在命令行上使用的 6 个 Linux 元字符:
opensource.com/article/22/2/metacharacters-linux
-
如何在 Linux 或 MacOS 终端中使用 Bash 历史记录:
www.howtogeek.com/44997/how-to-use-bash-history-to-improve-your-command-line-productivity/
-
使用 Bash 历史记录 Ctrl+r:
lornajane.net/posts/2011/navigating-bash-history-with-ctrlr
-
如何查找 Linux 中何时执行命令:
ostechnix.com/find-when-a-command-is-executed-in-linux/
-
在 Bash 中转义引号:
linuxsimply.com/bash-scripting-tutorial/quotes/escape-quotes/
答案
-
b
-
b 和 c
-
a
-
d
-
b
加入我们的 Discord 社区!
与其他用户、Linux 专家以及作者本人一起阅读本书。
提问、为其他读者提供解决方案、通过“问我任何问题”环节与作者交流,还有更多。扫描二维码或访问链接加入社区。
第三章:理解变量和管道
在前一章中,您看到了 shell 如何解释用户的命令,并看到了如何编写您自己的命令的各种示例。在本章中,我将告诉您有关变量和管道的内容。
创建变量并为其分配值的能力是任何编程环境的重要组成部分。正如您期望的那样,bash
和zsh
都具备这种能力。在本章的第一部分,我们将介绍有关环境变量和编程变量的基础知识。
在本章的第二部分,我们将介绍如何使用管道。管道非常简单,您可能已经在某个时候使用过它们。因此,我保证会简短而言之地介绍这个内容。(实际上,关于这两个主题,现在还没有太多需要讲的,这就是为什么我将它们合并到一个章节中。)
本章涵盖的主题包括:
-
理解环境变量
-
理解编程变量
-
理解管道
如果您准备好了,让我们开始吧。
理解环境变量
环境变量 控制操作系统 shell 的配置和功能。当您安装 Linux 或类 Unix 操作系统(如 FreeBSD 或 OpenIndiana)时,您会发现在全局和用户级别已经定义了一组默认的环境变量。
要查看环境变量及其设置的列表,请使用env
命令,就像这样:
[donnie@fedora ~]$ env
SHELL=/bin/bash
IMSETTINGS_INTEGRATE_DESKTOP=yes
COLORTERM=truecolor
XDG_CONFIG_DIRS=/etc/xdg/lxsession:/etc/xdg
HISTCONTROL=ignoredups
. . .
. . .
MAIL=/var/spool/mail/donnie
OLDPWD=/etc/profile.d
_=/bin/env
[donnie@fedora ~]$
环境变量的完整列表非常广泛。幸运的是,您不需要记住每个项的作用。您所需了解的大多数变量都是不言自明的。
而不是查看整个列表,您还可以查看特定项的值。只需使用echo
命令,并在变量名之前加上$
符号,就像这样:
[donnie@fedora ~]$ echo $USER
donnie
[donnie@fedora ~]$ echo $PATH
/home/donnie/.local/bin:/home/donnie/bin:/home/donnie/.cargo/bin:/usr/local/bin:/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/sbin
[donnie@fedora ~]$ echo $EDITOR
/usr/bin/nano
[donnie@fedora ~]$
在这里,我们可以看到我(donnie
)是当前登录用户,我的路径设置是什么,以及我的默认编辑器是什么。您可以以同样的方式查看任何其他环境变量的值。
一个重要的注意事项是,所有环境变量的名称始终由全部大写字母组成。操作系统或 shell 中没有任何东西阻止使用小写字母,但不使用它们有一个非常好的理由。变量名称是区分大小写的。最佳实践是仅使用大写字母命名环境变量,对于编程变量名称则可以使用全部小写字母或大小写混合。这将防止您意外地覆盖环境变量的值。(我将在下一节中展示更多相关内容。)
如我之前提到的,环境变量可以在全局和用户级别进行配置。全局级别的变量设置会影响所有 bash
和 zsh
用户。对于 bash
,你会在 /etc/profile
文件、/etc/bashrc
文件以及 /etc/profile.d/
目录中的各种文件中找到这些全局设置。对于 zsh
,你会在 /etc/zprofile
、/etc/zshrc
和 /etc/zshenv
文件中找到这些设置。(请注意,zsh
也会引用与 bash
相同的 /etc/profile
文件。)如果你此时打开其中一个文件,你可能并不理解它们的内容。没关系,因为现在这并不重要。但你会很容易找到环境变量的设置位置,因为变量名都是大写字母。
现在,假设你不喜欢某个特定的设置。举个例子,假设你想自定义命令行提示符以符合自己的喜好。在我的 Fedora 工作站上,我的 bash
提示符是这样的:
[donnie@fedora ~]$
提示符的格式由 PS1
环境变量决定。我们可以像这样查看 PS1
的设置:
[donnie@fedora ~]$ echo $PS1
[\u@\h \W]\$
[donnie@fedora ~]$
这是你刚刚看到的内容的解析:
-
[
: 这是一个字面字符,它是我们在提示符中看到的第一个字符。 -
\u
:这会使当前用户的用户名显示出来。 -
@
:这是另一个字面字符。 -
\h
:这会使机器的主机名的第一个组件显示出来。 -
\W
:这会显示当前工作目录的名称。请注意,大写的 W 不会显示整个路径名。 -
]
:这是另一个字面字符。 -
\$
:这会使所有普通用户看到$
,而根用户看到#
。
不久前,我说过我们可以使用 \
强制 shell 将元字符解释为字面字符。但在这里,我们看到了 \
的另一种用法。当配置 PS1
参数时,\
表示我们将要使用一个宏命令。(可以将宏看作是当你执行某个简单操作时,例如按下特定的键或点击特定的按钮时会运行的命令。)
现在,假设我们想让当前工作目录的整个路径显示出来,同时显示当前的日期和时间。为此,我们将 \W
替换为 \w
,并添加 \d
和 \t
宏,像这样:
[donnie@fedora ~]$ export PS1="[\d \t \u@\h \w]\$"
[Wed Aug 09 18:14:26 donnie@fedora ~]$
请注意,我必须将新参数用一对引号括起来,以便 shell 能正确解释元字符。同时,注意当我执行cd
进入下一级目录时会发生什么:
[Wed Aug 09 18:14:26 donnie@fedora ~]$cd /etc/profile.d/
[Wed Aug 09 18:29:15 donnie@fedora /etc/profile.d]$
将/w
替换为/W
会显示当前工作目录的整个路径。
当你从命令行配置PS1
参数时,新的设置将在你注销机器或关闭终端窗口后消失。为了使设置永久生效,只需编辑位于主目录中的.bashrc
文件。在文件末尾添加export PS1="[\d \t \u@\h \w]$ "
这一行,下一次你登录机器或打开一个新终端窗口时,就会看到新的提示符。
还有很多其他方式可以自定义命令提示符,我没有一一展示。有关更完整的列表,请查看我在进一步阅读部分提供的参考资料。还要注意,我这里只展示了如何使用bash
进行设置,因为zsh
使用不同的命令提示符参数。(在第二十二章,使用 Z Shell中我会详细介绍这部分内容。)
我能读懂你的心思,看到你在想环境变量与 Shell 脚本有什么关系。其实,有时候你需要让脚本执行一个依赖于特定环境变量值的特定操作。例如,假设你只希望脚本在 root 用户下运行,而不是在任何没有特权的用户下运行。由于我们知道 root 用户的用户标识号是0
,我们可以编写代码,允许脚本在UID
变量设置为0
时运行,而当UID
设置为非0
时则阻止脚本运行。
顺便说一句,如果我能读懂你的心思而让你觉得有些不自在,我为此道歉。
这就是我们对环境变量的介绍。现在让我们快速看看编程变量。
理解编程变量
有时候,定义变量在脚本中是必要的。你可以根据需要定义、查看和取消设置这些变量。请注意,尽管系统允许你使用全大写字母来创建编程变量名,但这被认为是一种不太好的做法。最佳实践是始终使用小写字母来命名编程变量,这样你就不会冒险用相同名字覆盖环境变量的值。(当然,覆盖环境变量不会造成长期损害。但是,为什么要冒险覆盖一个你以后可能需要在脚本中使用的环境变量呢?)
为了展示这些内容是如何工作的,让我们从命令行创建一些编程变量,并查看它们的值。首先,我们将创建car
变量,并将其值设置为Ford
,如下面所示:
[donnie@fedora ~]$ car=Ford
[donnie@fedora ~]$ echo $car
Ford
[donnie@fedora ~]$
要查看变量的值,使用echo
,并在变量名之前加上$
,就像我们对环境变量所做的那样。现在,让我们使用bash
命令打开一个子 shell,看看我们是否还能查看到这个car
变量的值,然后退出回到父 shell,如下所示:
[donnie@fedora ~]$ bash
[donnie@fedora ~]$ echo $car
[donnie@fedora ~]$ exit
exit
[donnie@fedora ~]$
这次我们看不到car
的值,因为我们没有导出这个变量。导出变量将允许子 Shell 访问该变量。正如你可能猜到的,我们将使用export
命令来完成这个操作,像这样:
[donnie@fedora ~]$ export car=Ford
[donnie@fedora ~]$ echo $car
Ford
[donnie@fedora ~]$ bash
[donnie@fedora ~]$ echo $car
Ford
[donnie@fedora ~]$ exit
exit
[donnie@fedora ~]$
这次,我们看到car
的值现在在子 Shell 中显示了出来。
我们将在本书的余下部分中使用变量,所以你将学习更多关于如何使用它们的内容。不过目前,快速介绍就足够了。
接下来,让我们做一些管道操作。
理解管道
管道会将一个命令的输出作为另一个命令的输入。它由|
符号表示,这个符号与反斜杠在同一个键上。你通常会从命令行调用一个简单的管道进行各种目的。但你也可以为 Shell 脚本创建非常复杂的多阶段管道。
图 3.1:创建管道。请注意,stdout 是标准输出的简称,而 stdin 是标准输入的简称。
为了看看这如何有用,假设你想查看某个目录中所有文件的列表。但是文件太多,输出会滚动出屏幕,你根本看不到它。你可以通过将ls
命令的输出作为less
命令的输入来解决这个问题。它看起来像这样:
[donnie@fedora ~]$ ls -l | less
ls -l
的列表将会在less
分页器中打开,你可以滚动输出或搜索特定的文本字符串。
现在,假设我只想看到文件名中包含文本字符串alma
的文件。这很简单。我只需将ls -l
的输出管道到grep
中,像这样:
[donnie@fedora ~]$ ls -l | grep alma
-rw-r--r--. 1 donnie donnie 1438 Nov 2 2022 alma9_default.txt
-rw-r--r--. 1 donnie donnie 1297 Nov 2 2022 alma9_future.txt
-rw-r--r--. 1 donnie donnie 81 Jan 11 2023 alma_link.txt
[donnie@fedora ~]$
现在,假设我不想看到文件名,但我确实想知道有多少个文件。我只需再添加一个管道阶段,像这样:
[donnie@fedora ~]$ ls -l | grep alma | wc -l
3
[donnie@fedora ~]$
wc -l
命令计算输出中的行数,在这个例子中,它告诉我们在文件名中包含文本字符串alma
的文件数量。
如果你不熟悉grep
,目前只需理解它是一个可以搜索特定文本字符串或文本模式的工具。它可以在不打开文件的情况下搜索文本文件,或者它可以搜索从另一个命令的输出管道到它中的文本字符串或模式。
好吧,我已经向你展示了一些如何从命令行创建管道的简单例子。现在,我想向你展示一些我希望你永远不要做的事情。这涉及到使用cat
工具。
使用cat
你可以将文本文件的内容输出到屏幕上。这主要用于查看小文件,正如你在这里看到的:
[donnie@fedora ~]$ cat somefile.txt
This is just some file that I created to demonstrate cat.
[donnie@fedora ~]$
如果你使用cat
查看一个大文件,输出将滚动出屏幕,你可能无法查看它。我见过有人仍然使用cat
来转储文件,然后将输出管道到less
,像这样:
[donnie@fedora ~]$ cat files.txt | less
我也看到过有人将 cat
的输出通过管道传递给 grep
来查找文本字符串,像这样:
[donnie@fedora ~]$ cat files.txt | grep darkfi
drwxr-xr-x. 1 donnie donnie 414 Apr 3 12:41 darkfi
[donnie@fedora ~]$
这两个例子都可以工作,但直接使用 less
或 grep
而不通过 cat
,会少输入一些内容,像这样:
[donnie@fedora ~]$ less files.txt
. . .
. . .
[donnie@fedora ~]$ grep darkfi files.txt
drwxr-xr-x. 1 donnie donnie 414 Apr 3 12:41 darkfi
[donnie@fedora ~]$
换句话说,尽管将 cat
的输出通过管道传递给其他工具是可行的,但它比直接使用其他工具效率低,可能会让你的脚本变得不那么高效,也更难阅读。而且,作为一个爱猫的人,每次看到有人用 cat
进行滥用时,我总是感到很烦。
我展示的只是管道操作的冰山一角。Linux 和 Unix 管理员需要创建 shell 脚本的一个主要原因是自动化从文本文件或某些程序输出中提取和格式化信息。这通常需要非常长、复杂的、多阶段的管道,每个阶段都有不同的提取或格式化工具。在你能开始做这些之前,你需要学习如何使用这些工具,你将在 第六章,文本流过滤器 - 第一部分 中开始学习。而在我们进入那部分之前,还需要再介绍一些 bash
和 zsh
的基础功能。
总结
尽管这一章比较简短,但我们已经覆盖了许多重要的信息。我们首先查看了环境变量,展示了如何修改其中一个变量,并解释了在 shell 脚本中为何需要使用环境变量。接着,我们讲解了编程变量,说明了如何创建和导出它们。最后,我们做了一些管道操作。
在下一章,我们将介绍输入/输出重定向的概念。我们下次见。
问题
-
以下哪些元字符允许你查看变量的赋值?
-
*
-
%
-
$
-
^
-
-
以下哪项陈述是正确的?
-
使用全大写或全小写字母来创建编程变量名都是完全正确的。
-
变量名不区分大小写。
-
创建编程变量名时,应始终使用大写字母。
-
创建编程变量名时,应始终使用小写字母。
-
-
如何使子 shell 识别你在父 shell 中创建的变量?
-
在子 shell 中,从父 shell 导入变量。
-
在父 shell 中,创建变量时使用
export
关键字。 -
你不能这样做,因为父 shell 和子 shell 是相互隔离的。
-
子 shell 和父 shell 默认会共享变量。
-
-
你会使用以下哪些元字符将一个命令的输出传递给另一个命令作为输入?
-
$
-
|
-
&
-
%
-
深入阅读
-
如何在 Linux 中更改/设置 bash 自定义提示符(PS1):
www.cyberciti.biz/tips/howto-linux-unix-bash-shell-setup-prompt.html
-
如何计算 Linux 中目录下的文件数?:
www.linuxjournal.com/content/how-count-files-directory-linux
答案
-
c
-
d
-
b
-
b
加入我们的 Discord 社区!
与其他用户、Linux 专家以及作者本人一同阅读本书。
提问、为其他读者提供解决方案、通过“问我任何问题”环节与作者聊天,还有更多内容等你参与。扫描二维码或访问链接加入社区。
留下评论!
感谢您从 Packt 出版社购买本书—我们希望您喜欢它!您的反馈对我们至关重要,能帮助我们改进和成长。阅读完本书后,请花一点时间在 Amazon 上留下评论;这只需一分钟,但对像您一样的读者帮助巨大。
扫描下面的二维码,获得你选择的免费电子书。
第四章:理解输入/输出重定向
在上一章中,我们讨论了如何在 shell 中使用变量和管道。这一次,我们将看看如何将命令的文本输出发送到终端以外的地方,终端是默认的输出设备。然后,我们将看看如何让命令从键盘以外的地方获取文本,键盘是默认的输入设备。最后,我们将讨论如何将错误消息发送到终端以外的地方。
本章的主题包括:
-
理解
stdout
-
理解
stdin
-
理解
stderr
-
理解
tee
好的,让我们开始吧。
输入/输出重定向简介
为了从不同的来源提取信息并进行格式化展示,我们通常需要使用各种实用工具,这些工具统称为文本流过滤器。当你使用文本流过滤工具时,提供输入显然是必要的。查看输出以及在出现问题时查看错误信息同样是必须的。为了这些目的,我们有了stdin
、stdout
和stderr
。
-
stdin
:这是标准输入的缩写。默认情况下,stdin
来自键盘。然而,通过使用管道或重定向符号,你也可以从文件或另一个命令的输出中获取stdin
。 -
stdout
:这是标准输出的缩写。默认情况下,stdout
会发送到你的计算机屏幕。你可以使用管道将stdout
变成另一个命令的stdin
,或者你可以使用重定向符号将stdout
保存为存储设备上的文件。如果你不想看到任何输出,只需使用重定向符号将stdout
发送到所谓的垃圾桶。 -
stderr
:正如你可能猜到的,这是标准错误的缩写。如果命令执行不正确,你将收到一条错误消息。默认情况下,消息会显示在屏幕上。然而,你可以使用管道或重定向符号将stderr
的输出目标改变,就像你可以操作stdout
一样。
我之前告诉过你,在 Linux、Unix 或类似 Unix 的系统(如 FreeBSD 或 OpenIndiana)上,一切都表示为文件。stdin
、stdout
和stderr
在 Linux 系统中由/proc/
文件系统中的文件表示。在/dev/
目录下,有指向这些文件的符号链接,正如我们所看到的:
[donnie@fedora ~]$ cd /dev
[donnie@fedora dev]$ ls -l std*
lrwxrwxrwx. 1 root root 15 Aug 11 13:29 stderr -> /proc/self/fd/2
lrwxrwxrwx. 1 root root 15 Aug 11 13:29 stdin -> /proc/self/fd/0
lrwxrwxrwx. 1 root root 15 Aug 11 13:29 stdout -> /proc/self/fd/1
[donnie@fedora dev]$
请注意,这些文件所在的最低级别子目录是fd
,即文件描述符的缩写。所以,表示stdin
、stdout
和stderr
的文件统称为文件描述符。
我不会深入探讨它是如何工作的细节,因为其实并不需要了解这些。你真正需要知道的唯一内容就是文件描述符的 ID 号,具体如下:
-
0
:这是用于stdin
。 -
1
:这是用于stdout
。 -
2
:这是用于stderr
。
记住这些文件描述符的编号,这些编号在所有 Linux、Unix/类 Unix 系统中都是相同的,这将帮助你理解本章稍后介绍的一些概念。
你可以使用重定向符号做以下事情:
-
将命令的输入(
stdin
)从键盘以外的地方获取。 -
让命令将其输出(
stdout
)发送到计算机屏幕以外的地方。 -
让命令将其错误消息(
stderr
)发送到计算机屏幕以外的地方。
你将会使用几个操作符符号与重定向符号搭配。大部分符号很容易理解,但你可能会发现与 stderr
相关的符号有点令人困惑。不过不用担心,因为我会帮你解答。我们从 stdout
系列的操作符开始,它包括 >
、>|
和 >>
。
理解标准输出(stdout)
假设你想查看某个目录中的文件列表。你不想将 ls
的输出通过管道传输到 less
,而是希望将这个列表保存为文本文件,以便以后打印。以下是它如何工作的图示:
图 4.1:标准输出(stdout)是如何工作的
这是实践中的效果,我正在将 ls
命令的输出发送到 filelist.txt
文件:
[donnie@fedora ~]$ ls > filelist.txt
[donnie@fedora ~]$
如你所见,这相当简单。你几乎可以将任何通常将输出发送到计算机屏幕的命令,改为将其输出发送到文本文件。不过,有一点需要小心。如果你将命令的输出重定向到一个已经存在的文件,它将被覆盖,文件中所有之前的信息将会丢失。防止这种情况发生的方法有三种。
防止文件被覆盖
在本节中,我将展示两种防止覆盖现有文件的方法,分别是:
-
确保同名文件已经不存在,否则你将创建的新文件会覆盖它。
-
设置
noclobber
选项。
让我们来看一下这两种方法。
防止意外覆盖重要文件的第一种方法是最显而易见的。也就是说,在你将输出重定向到文件之前,确保同名的文件不存在。稍后我将展示如何轻松编写 Shell 脚本代码来检查这一点。
第二种方法是为你的 Shell 环境设置 noclobber
选项,如下所示:
[donnie@fedora ~]$ set -o noclobber
[donnie@fedora ~]$
你可以通过命令行或在 Shell 脚本中设置此选项。设置此选项后,如果你尝试使用重定向符号覆盖文件,bash
和 zsh
会发出错误消息,正如你在这里看到的:
[donnie@fedora ~]$ ls -la > filelist.txt
bash: filelist.txt: cannot overwrite existing file
[donnie@fedora ~]$
但是,如果你真的想在设置了此选项的情况下覆盖文件,你可以通过稍微修改重定向命令来实现。只需将操作符 >
替换为 >|
,就像这样:
[donnie@fedora ~]$ ls -la >| filelist.txt
[donnie@fedora ~]$
这次没有错误消息,这意味着我确实覆盖了文件。
请注意,当你设置了 noclobber
选项时,这并不是一个永久设置。它会在你退出 bash
或 zsh
会话后消失。(包括你关闭终端模拟器窗口时。)同时要注意,noclobber
选项不会阻止你通过 mv
或 cp
命令覆盖文件从而丢失文件。它也不会阻止你通过 rm
命令删除文件。
使用文件描述符
我刚才告诉过你,stdout
的文件描述符是数字 1
。如果你真的想,你可以在我展示的任何命令中包括这个文件描述符,看起来像这样:
[donnie@fedora ~]$ ls 1> filelist.txt
[donnie@fedora ~]$
“这有什么好处呢?”你可能会问。好吧,在这种情况下,没有任何好处。去掉 1
,效果也一样好。但在接下来的几页中,我们会讨论 stderr
。那时文件描述符的 ID 号会派上用场。
好的,关于输出的内容我们已经讲够了。现在,让我们来看看一些输入的内容。
理解 stdin
这样会更容易一些,因为只涉及一个运算符符号。以下是图形表示:
图 4.2:stdin 的工作原理
在我们的示例中,我们将简要介绍 tr
工具。(我们将在第七章,文本流过滤器-第二部分中更深入地解释 tr
。目前,简单来说,它是一个用来转换内容的工具。)默认情况下,tr
会从键盘读取 stdin
。
你可以做的一件事是输入一个全小写的文本字符串,然后让 tr
将其转换回全大写。输入 tr [:lower:] [:upper:]
命令后,按下回车键,再输入你的一行文本后再次按下回车。当大写的文本出现时,按下Ctrl-d退出 tr
。它应该看起来像这样:
[donnie@fedora ~]$ tr [:lower:] [:upper:]
i only want to type in all upper-case letters.
I ONLY WANT TO TYPE IN ALL UPPER-CASE LETTERS.
[donnie@fedora ~]$
如果你需要 tr
从文件获取输入,只需添加适当的重定向运算符和文件名,像这样:
[donnie@fedora ~]$ tr [:lower:] [:upper:] < filelist.txt
15827_ZIP.ZIP
2023-08-01_15-23-31.MP4
2023-08-01_16-26-12.MP4
2023-08-02_13-57-37.MP4
21261.ZIP
. . .
. . .
YAD-FORM.SH
ZONEINFO.ZIP
[donnie@fedora ~]$
这不会改变原文件。它只会让文件内容以全大写字母显示在屏幕上。如果你想将这个转换后的输出保存到另一个文件中,只需添加一个 stdout
运算符和一个新文件名,像这样:
[donnie@fedora ~]$ tr [:lower:] [:upper:] < filelist.txt > filelist_2.txt
[donnie@fedora ~]$
当你使用这个技巧时,你总是需要为输出指定一个新文件名。如果你试图使用这个技巧只修改原文件,你最终会得到一个没有任何内容的文件。所以,输入这个命令会是一个糟糕的选择:
[donnie@localhost ~]$ tr [:lower:] [:upper:] < filelist.txt > filelist.txt
[donnie@localhost ~]$
当然,你也可以在这个技巧中使用 >>
运算符将新信息追加到原文件中,像这样:
[donnie@localhost ~]$ tr [:lower:] [:upper:] < testfile.txt >> testfile.txt
[donnie@localhost ~]$
stdin
和 stdout
运算符相对容易理解。stderr
运算符不难,但它的某些方面可能有点棘手。所以,在开始之前,坐下来,深呼吸,放松一下。准备好了吗?好,开始吧。
理解 stderr
用于stderr
的重定向操作符是2>
和2>>
。如果你想知道为什么,那是因为我们几页前看过的文件描述符 ID 号码。stderr
的 ID 号码恰好是2
。一如既往,这里是图形表示:
图 4.3:stderr 的工作原理
如果你运行一个命令,出了问题,它会通过stderr
输出一个错误消息。默认情况下,这条消息将被发送到计算机屏幕。同样默认情况下,stderr
消息与stdout
消息混合在一起。因此,如果你的命令同时输出良好的数据和错误消息,你需要在屏幕上滚动输出消息以区分两者。幸运的是,你可以使用重定向符来改变这种行为。为了展示这是如何工作的,让我们再次看一下我们在第二章,解释命令中讨论过的find
实用程序。
如果你作为普通用户登录到计算机上,并使用find
命令搜索整个文件系统,当find
尝试访问你没有权限访问的目录时,你会收到错误消息。你也会得到良好的输出,但请注意,良好的输出在这个例子中和错误的输出混合在一起:
[donnie@fedora ~]$ find / -name README
find: '/boot/loader/entries': Permission denied
find: '/boot/lost+found': Permission denied
find: '/boot/efi': Permission denied
find: '/boot/grub2': Permission denied
find: '/dev/vboxusb': Permission denied
/home/donnie/.cache/go-build/README
. . .
. . .
/home/donnie/Downloads/lynis/README
/home/donnie/Downloads/lynis/extras/README
/home/donnie/Downloads/lynis/plugins/README
[donnie@fedora ~]$
如果你在这个命令后面加上一个2>
重定向符和一个文件名,你可以将错误消息发送到一个文本文件中,这样你就可以在屏幕上只看到良好的数据。这是它的工作原理:
[donnie@fedora ~]$ find / -name README 2> find_error.txt
/home/donnie/.cache/go-build/README
/home/donnie/.local/share/containers/storage/overlay/994393dc58e7931862558d06e46aa2bb17487044f670f310dffe1d24e4d1eec7/diff/etc/profile.d/README
/home/donnie/.local/share/containers/storage/overlay/63ba8d57fba258ed8ccaec2ef1fd9e3e27e93f7f23d0683bd83687322a68ed29/diff/etc/fonts/conf.d/README
. . .
. . .
/home/donnie/Downloads/lynis/README
/home/donnie/Downloads/lynis/extras/README
/home/donnie/Downloads/lynis/plugins/README
[donnie@fedora ~]$
你可以结合重定向符,将stdout
发送到一个文本文件,将stderr
发送到另一个文本文件,像这样:
[donnie@fedora ~]$ find / -name README > find_results.txt 2> find_error.txt
[donnie@fedora ~]$
如果你不想看到任何错误消息,只需将stderr
发送到/dev/null
设备,有些圈子称之为臭名昭著的位桶。任何发送到那里的东西都永远不会见天日。这是它的外观:
[donnie@fedora ~]$ find / -name README 2> /dev/null
/home/donnie/.cache/go-build/README
/home/donnie/.local/share/containers/storage/overlay/994393dc58e7931862558d06e46aa2bb17487044f670f310dffe1d24e4d1eec7/diff/etc/profile.d/README
/home/donnie/.local/share/containers/storage/overlay/63ba8d57fba258ed8ccaec2ef1fd9e3e27e93f7f23d0683bd83687322a68ed29/diff/etc/fonts/conf.d/README
. . .
. . .
/home/donnie/Downloads/lynis/README
/home/donnie/Downloads/lynis/extras/README
/home/donnie/Downloads/lynis/plugins/README
[donnie@fedora ~]$
如果你想把好的数据发送到位桶,这样你就只会看到错误消息,你可以使用这个命令:
[donnie@fedora ~]$ find / -name README > /dev/null
find: '/boot/loader/entries': Permission denied
find: '/boot/lost+found': Permission denied
find: '/boot/efi': Permission denied
find: '/boot/grub2': Permission denied
. . .
. . .
find: '/var/tmp/systemd-private-075495f99a0e4571a4507a921ef61dab-chronyd.service-vVTD10': Permission denied
find: '/var/tmp/systemd-private-075495f99a0e4571a4507a921ef61dab-ModemManager.service-CG1GeZ': Permission denied
[donnie@fedora ~]$
你也可以使用2>>
操作符将错误消息附加到现有文件中,就像这样:
[donnie@fedora ~]$ cd /far 2> error.txt
[donnie@fedora ~]$ cat error.txt
bash: cd: /far: No such file or directory
[donnie@fedora ~]$ cd /fat 2>> error.txt
[donnie@fedora ~]$ cat error.txt
bash: cd: /far: No such file or directory
bash: cd: /fat: No such file or directory
[donnie@fedora ~]$
到目前为止,一切都很顺利。现在,正如承诺的那样,我们来看一下可能会有点令人困惑的部分。嗯,实际上,它并不那么令人困惑。只是我们要使用一点缩写,需要一点时间来适应。
假设你想要将stdout
和stderr
同时发送到同一个地方。这意味着你必须用两个不同的重定向符输入目的地吗?多亏了这个简写,答案是不用。它是这样工作的。
如果你希望stderr
和stdout
都发送到同一个文本文件中,只需输入你的命令,带有常规的stdout
操作符和目的地。然后,在最后追加2>&1
。如果你需要一种帮助理解这一点的方法,只需记住stderr
是文件描述符2
,而stdout
是文件描述符1
。所以,你可以把它读作,stderr
(ID2
)去与stdout
(ID1
)去同一个地方。
要将find
操作的stderr
和stdout
都发送到同一个文本文件中,你可以输入:
[donnie@fedora ~]$ find / -name README > find_results.txt 2>&1
[donnie@fedora ~]$
可能会有一些情况,你不希望从stderr
或stdout
中获取任何输出。例如,如果你需要在后台运行一个备份任务,你就不希望任何屏幕输出打乱你正在前台编辑的文本文件。(你也不需要将任何输出保存到文本文件。)为此,你可以输入如下内容:
[donnie@fedora ~]$ find \( -iname '*.txt' -mtime -1 \) -exec cp {} /backup/ \; > /dev/null 2>&1
[donnie@fedora ~]$
(注意,我已经设置了/backup/
目录的权限,以便我可以用普通用户权限进行写操作。)
我认为这就是关于stderr
的全部内容了。现在,作为额外的奖励,我会展示如何将输出同时发送到屏幕和文本文件。
理解 tee 命令
tee
命令相当独特,因为它并不是一个普通的重定向器。它是一个工具,可以同时将命令的输出发送到屏幕和文件中。因此,和我们之前使用重定向符号的方式不同,你会通过管道将输入传递给它。
如果你需要同时查看命令的输出并将其保存为文本文件,可以像这样将命令的输出通过tee
工具进行管道传输:
[donnie@fedora ~]$ ps a | tee ps.txt
PID TTY STAT TIME COMMAND
972 tty1 Ss+ 0:00 -bash
1005 pts/0 Ss 0:00 -bash
1076 pts/0 R+ 0:00 ps a
1077 pts/0 S+ 0:00 tee ps.txt
[donnie@fedora ~]$ ls -l ps.txt
-rw-r--r--. 1 donnie donnie 181 Aug 12 17:29 ps.txt
[donnie@fedora ~]$
请注意,你不需要在此命令中使用stdout
操作符(>
)。文本文件的名称作为tee
的参数使用。
如果你用相同的文件名运行另一个命令,第一个创建的文件将被覆盖。(当然,你可以通过设置noclobber
选项来防止这种情况,就像我刚才展示的那样。)如果你想将输出追加到现有文件中,可以使用-a
选项,像这样:
[donnie@fedora ~]$ ps a | tee -a ps.txt
PID TTY STAT TIME COMMAND
972 tty1 Ss+ 0:00 -bash
1005 pts/0 Ss 0:00 -bash
1087 pts/0 R+ 0:00 ps a
1088 pts/0 S+ 0:00 tee -a ps.txt
[donnie@fedora ~]$
tee
命令还有另一个你一定需要了解的用途。只不过有时候,你可能需要创建一个 shell 脚本,自动创建或更新/etc/
目录中的配置文件。看似逻辑上,你应该使用echo
命令,并使用>
或>>
操作符来完成这一操作。但看看当我尝试这么做时发生了什么:
[donnie@fedora ~]$ sudo echo "This is a new setting." > /etc/someconfig.cfg
-bash: /etc/someconfig.cfg: Permission denied
[donnie@fedora ~]$ sudo echo "This is a new setting." >| /etc/someconfig.cfg
-bash: /etc/someconfig.cfg: Permission denied
[donnie@fedora ~]$ sudo echo "This is a new setting." >> /etc/someconfig.cfg
-bash: /etc/someconfig.cfg: Permission denied
[donnie@fedora ~]$
如你所见,shell 不允许我将输出重定向到/etc/
目录中的文件,即使我使用了sudo
权限。(好吧,如果你实际登录 root 用户的 shell,是可以做到的,但假设我们不想这么做。)解决方法是使用tee
,如你所见:
[donnie@fedora ~]$ echo "This is a new setting." | sudo tee /etc/someconfig.cfg
This is a new setting.
[donnie@fedora ~]$ ls -l /etc/someconfig.cfg
-rw-r--r--. 1 root root 23 Aug 12 17:47 /etc/someconfig.cfg
[donnie@fedora ~]$
当在命令行中运行时,我必须在tee
命令前加上sudo
。如果你将像这样的命令放入 shell 脚本中,你就可以省略sudo
,因为你将以sudo
权限运行整个脚本。
现在,尽管tee
听起来很酷,但有一个小小的限制。也就是说,tee
始终将正常输出和错误信息都发送到屏幕,但只会将正常输出发送到指定的文件。
到目前为止,在我解释的过程中,你应该很容易跟上并在你自己的 shell 中操作。现在事情有点复杂了,我们通过进行一个实际的实践实验室来把这些内容结合起来。
实践实验室 – 管道、重定向器和 find 命令
对于本次任务,你将使用管道和重定向器。为了看到这个练习的完整效果,你需要作为普通用户登录,而不是以 root 身份登录。
-
输入以下内容,注意你故意输入了一个不存在的目录名来生成错误信息。
find /far -iname '*'
-
注意输出,然后输入:
find /far -iname '*' 2> error.txt cat error.txt
-
输入以下命令以创建文件列表并查看文件数量:
find / -iname '*.txt' > filelist.txt 2> error_2.txt find / -iname '*.txt' 2> /dev/null | wc -l less filelist.txt less error_2.txt
如果你作为普通用户登录,而不是 root 身份登录,这应该会生成一些错误信息,表示你没有权限查看某些目录。.txt
文件的列表将被写入filelist.txt
文件,错误信息将写入error_2.txt
文件。
-
输入以下命令以搜索 README 文件:
find / -name README > files_and_errors.txt 2>&1 less files_and_errors.txt
这次,命令末尾追加的2>&1
导致错误信息和文件列表都发送到了同一个文件。
-
对于下一个操作,你将把文件列表同时发送到屏幕和文件中。请输入:
find / -name README | tee filelist_2.txt less filelist_2.txt
请注意,文件列表和错误信息都会打印在屏幕上,但只有文件列表会写入文件中。
-
创建一个模拟的备份目录,如下所示:
sudo mkdir /backup sudo chown your_user_name:your_user_name /backup
-
将过去一天内创建的所有.txt 文件复制到
/backup/
目录,并将所有屏幕输出发送到/dev/null
设备:find \( -iname '*.txt' -mtime -1 \) -exec cp {} /backup/ \; > /dev/null 2>&1
-
查看
/backup/
目录中的文件:ls -l /backup
实验结束
好的,本章内容差不多就这些。让我们总结一下并继续前进。
总结
本章我们讨论了输入/输出重定向的概念。其实这是一个简单的概念。它只是意味着我们要么从键盘之外的地方获取输入,要么将输出发送到屏幕之外的地方。我们了解了重定向操作符,如何使用它们,以及使用不当时的一些陷阱。
在下一章中,我们将探讨如何修改你的 shell 环境。到时见。
问题
-
stdin
的文件描述符号是多少?-
0
-
1
-
2
-
3
-
-
以下哪些操作符用于
stdin
?-
>
-
>>
-
<
-
<<
-
-
如果运行此命令,会发生什么?
tr [:lower:] [:upper:] < filelist.txt > filelist.txt
-
filelist.txt
文件将被更新的输出覆盖。 -
tr
的输出将会追加到文件末尾。 -
你将收到一条警告信息。
-
filelist.txt
文件的内容将被清除,最终只会留下一个空文件。
-
-
stdin
的默认设备是什么?-
键盘
-
终端
-
鼠标
-
命名管道
-
-
你会使用哪个操作符将
stderr
和stdout
发送到同一个地方?-
2>1&
-
2>&1
-
2&1
-
2>1
-
进一步阅读
-
在 Bash 中使用重定向操作符的五种方式:
www.redhat.com/sysadmin/redirect-operators-bash
-
如何在 Linux 中使用 shell 重定向和管道操作文件:
www.redhat.com/sysadmin/linux-shell-redirection-pipelining
-
如何重定向 Shell 命令输出:
www.redhat.com/sysadmin/redirect-shell-command-script-output
答案
-
a
-
c
-
d
-
a
-
b
加入我们的 Discord 社区!
与其他用户、Linux 专家以及作者本人一起阅读本书。
提问、为其他读者提供解决方案、通过“问我任何问题”环节与作者交流,等等。扫描二维码或访问链接加入社区。
第五章:自定义环境
在本章中,我们将查看 bash
shell 环境的各种配置文件。我们将看看如何自定义这些配置文件,以及如何从命令行设置某些环境选项。
本章的主题包括:
-
审查环境变量
-
理解配置文件
-
从命令行设置 shell 选项
我现在坚持使用 bash
,但在第二十二章,理解 Z Shell中,我会解释如何设置 zsh
。
如果你已经迫不及待,我们就开始吧。
技术要求
对于本章,你需要一台 Fedora 虚拟机和一台 Debian 虚拟机。我不会提供本章的动手实验。相反,我会邀请你在阅读本章时跟随你的虚拟机操作。
审查环境变量
在第三章,理解变量与管道中,我介绍了环境变量的概念。在本章中,我将进一步展开这个话题。
我们已经看到,环境变量可以帮助自定义和控制你的 shell 环境。以下是一些常见环境变量的表格:
环境变量 | 用途 |
---|---|
USER |
当前登录系统的用户的用户名。 |
UID |
当前登录用户的用户 ID。 |
EUID |
运行某个进程的用户的有效用户 ID。 |
MAIL |
这定义了当前登录用户的邮件队列路径。 |
SHELL |
当前使用的 shell 路径。 |
PWD |
当前工作目录。(PWD 代表“打印工作目录”)。 |
OLDPWD |
上一个工作目录。 |
HOSTNAME |
计算机的主机名。 |
PATH |
一个以冒号分隔的目录列表,系统在你输入可执行程序名称时会搜索这些目录。这个变量是在多个配置文件中构建的,比如 /etc/profile 和用户主目录中的 .bashrc 文件。 |
HOME |
这是当前用户的主目录路径。一些程序会使用这个变量来确定配置文件的位置或决定存储文件的默认位置。 |
PS1 |
主 shell 提示符。 |
PS2 |
次要 shell 提示符。 |
TERM |
这指定了当前的终端类型。在非登录会话和通过远程图形界面终端模拟器的登录会话中,你可能会看到它被设置为 xterm 或某种形式的 xterm ,而在本地控制台的登录会话中则通常为 Linux 。(我将在下一节中解释会话类型。)系统需要知道当前使用的是哪种终端类型,这样它才能知道如何在文本模式程序中移动光标和显示文本效果。 |
DISPLAY |
该变量允许你在同一台计算机上运行多个显示器。如果你只运行一个显示器,你会看到返回值为 :0 。(这意味着当前计算机上的第一个显示器。) |
EDITOR |
该变量设置你希望用于系统管理功能(如 systemctl edit 和 crontab -e )的默认文本编辑器。通常,最佳选择是 nano 或 vim 。 |
表 5.1:更常见的环境变量
这里稍微复杂的地方是,环境变量在不同的 Linux 发行版中并不完全相同。例如,EDITOR
变量在我的 Fedora 工作站上有定义,但在任何 Debian 或 Ubuntu 发行版上都没有使用。相反,Debian 和 Ubuntu 使用另一种机制来设置默认编辑器,我们稍后会讨论。
现在,在我能完全解释 bash
配置文件之前,我需要先解释不同类型的 Shell 会话。
理解 Shell 会话
每次你启动与 Shell 的交互时,你都在创建一个 Shell 会话。Shell 会话可以按照以下方式分类:
-
交互式 Shell: 当你坐在计算机前并在命令行上输入命令时,你正在使用交互式 Shell。
-
非交互式 Shell: 当从 Shell 脚本中调用一个 Shell 会话时,你正在使用非交互式 Shell。
-
登录 Shell: 如果你登录到一台运行在文本模式下且没有图形界面的 Linux 机器,你正在使用登录 Shell。你也可以通过调用 Ctrl-Alt-Function_Key 组合键,在桌面机器上将桌面界面切换到文本模式终端,从而使用登录 Shell。(你可以使用 F1 到 F6 的功能键来完成此操作。)或者,你可以在正常的终端模拟器中调用
bash -l
命令来以登录模式打开一个子bash
会话。启动登录 Shell 会话的最终方式是通过安全外壳(SSH)远程登录到一台机器。无论远程机器是文本模式还是图形用户界面(GUI)模式,你的远程会话都将是登录 Shell 类型的。 -
非登录 Shell: 每次你在桌面 Linux 机器上打开一个终端模拟器时,你正在使用非登录 Shell。
那么,这一切意味着什么呢?嗯,交互式 Shell 和非交互式 Shell 之间的区别比较明显,所以我就不再多说了。但我想指出两种方法,帮助你判断自己是否正在使用登录 Shell 或非登录 Shell。
第一种方法是使用 shopt
命令,像这样:
[donnie@fedora ~]$ shopt login_shell
login_shell off
[donnie@fedora ~]$
shopt
命令可以用来为 bash
会话设置各种配置选项。在这里,我没有使用任何选项开关,而只是查看 login_shell
设置。你可以看到 login_shell
设置为 off
,这意味着我在 Fedora 工作站上使用的是非登录 Shell。而在我的文本模式 Fedora 服务器虚拟机上,shopt
输出是这样的:
[donnie@fedora-server ~]$ shopt login_shell
login_shell on
[donnie@fedora-server ~]$
如你所见,login_shell
参数是 on
。
判断是否处于登录 shell 的第二种方法是使用 echo $0
命令,像这样:
[donnie@fedora ~]$ echo $0
bash
[donnie@fedora ~]$
$0
参数被称为 位置参数。我将在 第八章,基本 Shell 脚本构建 中深入讲解位置参数,所以现在不必担心它们。你现在需要知道的仅仅是,echo $0
命令显示的是当前使用的脚本或可执行文件的名称。
在这种情况下,我们处于一个 bash
会话中,这意味着正在使用 bash
可执行文件。但是,我们怎么知道自己是否使用的是登录 shell 呢?其实,bash
输出前面没有破折号,这意味着我们不在登录 shell 中。为了显示区别,以下是你在文本模式的 Fedora 服务器虚拟机上看到的内容:
[donnie@fedora-server ~]$ echo $0
-bash
[donnie@fedora-server ~]$
-bash
输出表示我在一个登录 shell 中。
即使从远处,我也能读懂你的心思。(是的,我知道这很吓人。)我知道你在想,为什么你需要了解这些不同类型的 Shell 会话。其实,是因为有几个不同的 bash
配置文件。你使用的 shell 会话类型决定了该会话访问哪些配置文件。所以,现在你已经了解了不同类型的 Shell 会话,我们可以来看看这些配置文件。
理解配置文件
如我们已经看到的,你可以通过命令行设置环境变量。但是,任何你以这种方式设置的变量只会在命令行会话期间有效。当你退出系统或关闭终端仿真窗口时,命令行中所做的任何环境更改都会丢失。
如果我们想让这些更改永久生效怎么办?有几个配置文件可以编辑以保存我们的更改。有些是全局的,会影响所有用户,而另一些则只会影响单个用户。稍微复杂的是,这些文件在不同的 Linux 发行版之间有所不同。我们先来看 Fedora 上的 bash
配置文件。之后,我们再看看 Debian。
Fedora 上的 bash 全局配置文件
如我们已经看到的,在 /etc/
目录中有全局配置文件,在每个用户的主目录中有用户配置文件。对于全局配置,我们有这两个文件:
-
/etc/profile
-
/etc/bashrc
/etc/profile
文件为任何打开 bash
登录 shell 会话的用户设置环境,并在用户登录后立即执行。打开它并浏览,你会看到一个相当复杂的 Shell 脚本,执行以下功能:
-
它为 root 用户和所有使用登录 shell 的非 root 用户定义了默认的
PATH
设置。 -
它定义了与每个登录用户相关的各种环境变量。这些变量包括
UID
、EUID
、USER
、LOGNAME
和MAIL
。 -
它设置了机器的
HOSTNAME
。 -
它设置了
HISTSIZE
变量,该变量定义了每个用户的命令历史中将保留多少条过去的命令。 -
完成上述操作后,它会读取
/etc/profile.d/
目录中的各种配置脚本。大多数这些脚本定义了某些系统工具的系统范围行为。还有一个脚本用于设置自动打开文本编辑器的系统工具的默认EDITOR
。
当然,到目前为止,我不指望你完全理解该配置文件中的内容。然而,你在这个文件中看到的正是我们将在本书后续部分覆盖的内容。一旦你完成了所有的学习,你将能更好地理解这个文件究竟在做什么。
另一个全局配置文件是/etc/bashrc
文件,它影响非登录 Shell 会话。它为我们做了几件不同的事情,但就目前的目的而言,只需要知道这是为交互式会话定义PS1
变量的地方,并且它为使用非登录 Shell 的用户定义了PATH
设置。
除了这两个主要文件之外,/etc/profile.d/
目录中还有一些补充的配置文件,如你所见:
[donnie@fedora profile.d]$ ls
bash_completion.sh colorxzgrep.sh gawk.csh sh.local
colorgrep.csh colorzgrep.csh gawk.sh vim-default-editor.csh
colorgrep.sh colorzgrep.sh lang.csh vim-default-editor.sh
colorls.csh csh.local lang.sh which2.csh
colorls.sh debuginfod.csh less.csh which2.sh
colorxzgrep.csh debuginfod.sh less.sh
[donnie@fedora profile.d]$
每个文件包含了针对 Shell 环境的补充配置信息。通常,每个文件为我们做了以下两件事情中的一件:
-
为某些其他命令创建别名。(我们稍后将讨论别名。)
-
定义在
/etc/profile
或/etc/bashrc
文件中尚未定义的环境变量。
作为第二个功能的简单示例,让我们看一下EDITOR
变量的定义。在 Fedora 的服务器版本中,它将位于/etc/profile.d/vim-default-editor.sh
文件中,而在 Fedora 的工作站版本中,它将位于/etc/profile.d/nano-default-editor.sh
文件中。以下是服务器版本的内容:
# Ensure vim is set as EDITOR if it isn't already set
if [ -z "$EDITOR" ]; then
export EDITOR="/usr/bin/vim"
fi
再次说明,我并不期望你现在完全理解这一切。所以目前简单的解释是,如果EDITOR
环境变量尚未设置,那么它将被设置为/usr/bin/vim
。(在 Fedora 的工作站版本中,EDITOR
将设置为/usr/bin/nano
。)
有时候,管理员可能需要更改在/etc/profile
或/etc/bashrc
中定义的默认环境设置。你可以通过编辑/etc/profile
或/etc/bashrc
文件来实现,但这并不推荐。相反,应该将新的设置放入/etc/profile.d/sh.local
文件中。目前,除了一个说明性注释外,该文件没有其他内容,如下所示:
#Add any required envvar overrides to this file, it is sourced from /etc/profile
好的,这就是 Fedora 的全局配置文件的内容。现在让我们来看看用户的配置文件。
Fedora 上的用户配置文件
用户主目录中的 Shell 配置文件被视为隐藏文件,因为它们的文件名以点号开头。要查看这些文件,你需要使用ls
命令的-a
选项,像这样:
[donnie@fedora ~]$ ls -la
total 20
drwx------. 3 donnie donnie 111 Aug 26 16:28 .
drwxr-xr-x. 3 root root 20 Aug 19 18:15 ..
-rw-------. 1 donnie donnie 1926 Aug 26 15:51 .bash_history
-rw-r--r--. 1 donnie donnie 18 Feb 5 2023 .bash_logout
-rw-r--r--. 1 donnie donnie 141 Feb 5 2023 .bash_profile
-rw-r--r--. 1 donnie donnie 492 Feb 5 2023 .bashrc
-rw-------. 1 donnie donnie 37 Aug 26 16:28 .lesshst
drwx------. 2 donnie donnie 48 Aug 21 14:13 .ssh
[donnie@fedora ~]$
只有这三个文件对我们有关系,它们是:
-
.bash_logout
:此文件目前为空,只有一条说明性注释。你放在此文件中的任何命令将会在从交互式登录 Shell 会话退出时执行,或者当在 Shell 脚本的末尾调用exit
函数时执行。除了其他功能外,你可以使用此文件在用户退出 Shell 会话时自动清理临时文件,或者执行用户主目录中文件的自动备份。 -
.bash_profile
:此文件仅用于登录 Shell 会话。因此,如果你在桌面计算机上打开终端模拟器,文件中的任何内容都不会产生影响。如果你查看该文件的内容,你会看到它默认唯一的作用就是读取.bashrc
文件。 -
.bashrc
:这是用户级的主要 bash 配置文件,直接影响非登录 Shell 会话。由于.bash_profile
文件会使.bashrc
文件在登录 Shell 会话中被读取,因此你放入.bashrc
中的任何内容都会影响登录会话和非登录会话。
那么,这在实际操作中是如何运作的呢?假设你需要将/opt/
目录添加到你的工作PATH
中。当前,PATH
看起来是这样的:
[donnie@fedora-server ~]$ echo $PATH
/home/donnie/.local/bin:/home/donnie/bin:/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin
[donnie@fedora-server ~]$
若要添加/opt/
目录,请在文本编辑器中打开.bashrc
文件,找到这一段内容:
if ! [[ "$PATH" =~ "$HOME/.local/bin:$HOME/bin:" ]]
then
PATH="$HOME/.local/bin:$HOME/bin:$PATH"
fi
将其更改为如下所示:
if ! [[ "$PATH" =~ "$HOME/.local/bin:$HOME/bin:" ]]
then
PATH="$HOME/.local/bin:$HOME/bin:$PATH:/opt:"
fi
登出并重新登录。你的PATH
设置现在应该是这样的:
[donnie@fedora-server ~]$ echo $PATH
/home/donnie/.local/bin:/home/donnie/bin:/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/opt:
[donnie@fedora-server ~]$
现在,你可以将可执行脚本或二进制可执行文件放入/opt/
目录,并且无需指定完整路径即可运行它们。
若要永久更改你的命令提示符设置,使其也能显示当前的日期和时间,请将以下这一行添加到.bashrc
文件的末尾:
export PS1="[\d \t \u@\h \w] \$ "
登出并重新登录。你的命令提示符现在应该是这样的:
[Tue Aug 29 17:13:55 donnie@fedora-server ~] $
所以,这一切看起来都很酷,对吧?为了增加更多的酷炫感,让我们继续讨论 Debian 的配置文件。
Debian 上的 bash 全局配置文件
在 Debian 中,情况略有不同。/etc/profile
文件仍然存在,但与 Fedora 上的版本有着根本的不同。你可以在你自己的虚拟机上查看它。你会看到,它所做的只是定义PATH
和PS1
变量,并读取/etc/profile.d/
目录中的任何补充文件。如果会话是一个实际的bash
会话,而不是sh
会话,它还会读取/etc/bash.bashrc
文件。此外,你会看到 Debian 在定义PATH
时使用了一种与 Fedora 方法完全不同的方式。
与 Fedora 使用的/etc/bashrc
文件不同,Debian 使用/etc/bash.bashrc
文件。该文件为我们做了以下几件事:
-
它检查运行
bash
会话的窗口大小,以便在用户输入命令后正确显示行数和列数。 -
它定义了
PS1
变量。 -
如果用户输入
bash
找不到的命令,并且没有安装command-not-found
软件包,它会调用command_not_found_handle
函数。
在 bash.bashrc
文件的顶部,你会看到它会在交互式 shell 会话中被调用,并且如果 profile
文件引用了它,它也会在登录 shell 会话中被调用。我们已经看到 profile
文件确实引用了它,所以我们知道 bash.bashrc
会在登录和非登录的交互式会话中运行。
当一个配置文件从另一个配置文件中读取信息时,我们称第一个文件为源文件,第二个文件为被源文件引用的文件。
与 Fedora 不同,/etc/profile.d/
目录在 Debian 中默认没有什么作用。你在这里唯一能看到的就是 bash_completion.sh
脚本。正如你稍后会看到的那样,Fedora 上在 /etc/profile.d/
目录中定义的别名,在 Debian 中会定义在用户的配置文件里。
Debian 上的用户配置文件
在 Debian 系统的每个用户主目录中,我们都有以下这些 bash 配置文件:
-
.bash_logout
:这与 Fedora 上的.bash_logout
文件相同,不同之处在于它包含一个命令,该命令在注销登录会话时会清屏。 -
.profile
:这个文件替代了 Fedora 上的.bash_profile
,并且具有相同的作用。 -
.bashrc
:它执行与 Fedora 上的.bashrc
文件相同的操作,且更多。它还定义了一些在 Fedora 上全局定义的环境变量,并且定义了一些在 Fedora 的/etc/profile.d/
目录中定义的别名。
正如我之前所说,我并不指望你阅读这些配置文件并完全理解它们的作用。这就是为什么我只展示了一些小片段,而不是详细解释每个文件。在你完全阅读完这本书后,你将对它们有更深入的理解。
我还应该指出,每个 Linux 发行版家庭的 bash
配置文件设置方式都不同。我无法覆盖所有的配置,但你可以根据我在这里讲的内容,大致了解它们的作用。
在 Debian 上设置默认编辑器
哎呀,我差点忘了向你展示如何更改 Debian 的默认编辑器,因为我们不能通过设置环境变量来做到这一点。那么,接下来让我们来看一下。
在 Debian 系统上,默认编辑器是通过一对符号链接定义的,如你所见:
donnie@debian:~$ ls -l /usr/bin/editor
lrwxrwxrwx 1 root root 24 Jan 18 2023 /usr/bin/editor -> /etc/alternatives/editor
donnie@debian:~$ cd /etc/alternatives/
donnie@debian:/etc/alternatives$ ls -l editor
lrwxrwxrwx 1 root root 9 Jan 18 2023 editor -> /bin/nano
donnie@debian:/etc/alternatives$
/usr/bin/
目录中的 editor
符号链接指向 /etc/alternatives/
目录中的 editor
符号链接,而后者又指向位于 /bin/
目录中的 nano
可执行文件。这告诉我们 nano
被设置为默认编辑器。要在 Debian 系统上更改默认编辑器,首先需要确保你想使用的编辑器已安装。然后,使用 update-alternatives
工具,如下所示:
donnie@debian:~$ sudo update-alternatives --config editor
[sudo] password for donnie:
There are 3 choices for the alternative editor (providing /usr/bin/editor).
Selection Path Priority Status
------------------------------------------------------------
* 0 /bin/nano 40 auto mode
1 /bin/nano 40 manual mode
2 /usr/bin/vim.basic 30 manual mode
3 /usr/bin/vim.tiny 15 manual mode
Press <enter> to keep the current choice[*], or type selection number: 2
update-alternatives: using /usr/bin/vim.basic to provide /usr/bin/editor (editor) in manual mode
donnie@debian:~$
我选择了 2
选项来将 vim
设置为默认编辑器。让我们看看这如何改变 /etc/alternatives/
目录中的符号链接:
donnie@debian:~$ ls -l /etc/alternatives/editor
lrwxrwxrwx 1 root root 18 Sep 2 15:26 /etc/alternatives/editor -> /usr/bin/vim.basic
donnie@debian:~$
非常酷。这让我可以使用我最喜欢的编辑器。
好的,现在我们已经查看了配置文件,接下来让我们看看另一种自定义 shell 会话的方法。
从命令行设置 Shell 选项
除了使用环境变量来修改 shell 会话外,你还可以使用 shell 选项。你可以使用 set
命令从命令行或 shell 脚本中设置 shell 选项。你还可以仅从命令行查看已设置的选项。
你可以使用 Fedora 或 Debian 虚拟机进行本节操作。除了涉及手册页的小差异外,其他部分在这两者上都是一样的。
首先,让我们调用不带任何选项或参数的 set
命令,如下所示:
[donnie@fedora-server ~]$ set
BASH=/bin/bash
BASHOPTS=checkwinsize:cmdhist:complete_fullquote:expand_aliases:extglob:extquote:force_fignore:globasciiranges:globskipdots:histappend:interactive_comments:login_shell:patsub_replacement:progcomp:promptvars:sourcepath
. . .
. . .
quote_readline ()
{
local ret;
_quote_readline_by_ref "$1" ret;
printf %s "$ret"
}
[donnie@fedora-server ~]$
你将看到一个完整的环境变量和 shell 函数 列表,它们对于当前的 shell 会话是活动的。(我将在 第十章,理解函数 中详细讲解 shell 函数。)
使用 set -o
命令仅查看活动的 shell 选项列表,像这样:
[donnie@fedora-server ~]$ set -o
allexport off
braceexpand on
emacs on
errexit off
errtrace off
functrace off
hashall on
histexpand on
history on
. . .
. . .
xtrace off
[donnie@fedora-server ~]$
我不会详细讲解这些选项的作用,但我会告诉你如何找出它们的作用。唯一的诀窍是,有一点小技巧。也就是说,并没有专门为 set
命令提供的手册页。当你在 Fedora 系统上执行 man set
时,实际上会打开 bash
的手册页。在 Debian 系统上,你需要执行 man bash
,因为 man set
根本无法使用。原因在于,set
命令是内建在 bash
可执行文件中的,并没有单独的可执行文件。一旦打开了 bash
的手册页,你需要在其中查找 set
命令及其所有选项的说明。现在,我们来看几个更有用的选项以及如何设置它们。
在 第四章,理解输入/输出重定向 中,我告诉过你 noclobber
选项如何帮助防止你不小心覆盖重要的文件。默认情况下,noclobber
是关闭的,如你所见:
[donnie@fedora-server ~]$ set -o | grep noclobber
noclobber off
[donnie@fedora-server ~]$
启用和禁用选项的方式完全违反直觉。你将使用 set -o
命令来启用选项,使用 set +o
命令来禁用选项,这正好与你想的相反。(我不知道为什么有人决定这样做。但不管了,对吧?)假设我们想要启用 noclobber
选项。如下所示:
[donnie@fedora-server ~]$ set -o noclobber
[donnie@fedora-server ~]$ set -o | grep noclobber
noclobber on
[donnie@fedora-server ~]$
现在,让我们把它关闭:
[donnie@fedora-server ~]$ set +o noclobber
[donnie@fedora-server ~]$ set -o | grep noclobber
noclobber off
[donnie@fedora-server ~]$
对于一些选项,例如 noclobber
,你可以使用简写表示法。因此,为了减少输入,在开启或关闭 noclobber
时,只需这样操作:
[donnie@fedora-server ~]$ set -C
[donnie@fedora-server ~]$ set -o | grep noclobber
noclobber on
[donnie@fedora-server ~]$ set +C
[donnie@fedora-server ~]$ set -o | grep noclobber
noclobber off
[donnie@fedora-server ~]$
在这个示例中,-C
替代了 -o noclobber
,+C
替代了 +o noclobber
。
另一个你可能会发现有用的选项是 allexport
选项。为了看看你如何使用它,让我们从命令行设置一个编程变量,然后尝试在子 shell 中使用它。如下所示:
[donnie@fedora-server ~]$ car="1958 Edsel Corsair"
[donnie@fedora-server ~]$ echo $car
1958 Edsel Corsair
[donnie@fedora-server ~]$ bash
[donnie@fedora-server ~]$ echo $car
[donnie@fedora-server ~]$ exit
exit
[donnie@fedora-server ~]$
如你所见,一旦我通过 bash
命令打开子 shell,car
的值就不再对我可用了。解决这个问题的一种方法是,在变量定义前加上 export
,如下所示:
[donnie@fedora-server ~]$ export car="1958 Edsel Corsair"
[donnie@fedora-server ~]$ bash
[donnie@fedora-server ~]$ echo $car
1958 Edsel Corsair
[donnie@fedora-server ~]$ exit
exit
[donnie@fedora-server ~]$
这次,car
的值在子 shell 中对我可用。这个方法的问题是,我必须记得在创建每个变量定义时都要加上 export
。最简单的方法是设置 allexport
选项,这样我创建的每个变量都会自动导出。下面是实现的方式:
[donnie@fedora-server ~]$ set -o allexport
[donnie@fedora-server ~]$ set -o | grep allexport
allexport on
[donnie@fedora-server ~]$ car="1964 Ford Galaxie"
[donnie@fedora-server ~]$ bash
[donnie@fedora-server ~]$ echo $car
1964 Ford Galaxie
[donnie@fedora-server ~]$ exit
exit
[donnie@fedora-server ~]$
这次,car
变量自动导出了,因此它的值在子 shell 中可用。当你完成 allexport
后,可以像这样关闭它:
[donnie@fedora-server ~]$ set +o allexport
[donnie@fedora-server ~]$ set -o | grep allexport
allexport off
[donnie@fedora-server ~]$
allexport
也可以使用 -a
简写表示法,格式如下:
[donnie@fedora-server ~]$ set -a
[donnie@fedora-server ~]$ set -o | grep allexport
allexport on
[donnie@fedora-server ~]$ set +a
[donnie@fedora-server ~]$ set -o | grep allexport
allexport off
[donnie@fedora-server ~]$
现在,我的 shell 选项设置已恢复到默认值。
请记住,您设置的任何选项在退出 shell 会话时都会恢复到默认状态。
好的,关于 shell 选项的内容就到此为止,至少暂时是这样。在 第二十一章,调试 Shell 脚本 中,我会展示一些你可以用它们做的更多酷炫技巧。既然这些内容已经讲完,我们来进入本章的最后部分。
理解别名
如果你看过任何电视犯罪剧,你可能会看到一些犯罪分子使用多个名字。当然,只有一个名字是这个犯罪分子的真实姓名。其他所有名字都是假名,或者叫做别名,犯罪分子用它们来避免被警察找到。在操作系统的 shell 中,别名非常有用,且与犯罪活动毫无关系。事实上,你已经在不知不觉中使用它们了。
把别名当作一个可以替代另一个命令的命令。例如,假设你是那些大多数时间都被困在 Windows 系统里的可怜灵魂之一,偶尔才有机会使用 Linux。
假设每次你登录到 Linux 机器时,你本能地总是输入 Windows 命令,就像你在这里做的那样:
[donnie@fedora-server ~]$ ls
[donnie@fedora-server ~]$ ls -a
. .. .bash_history .bash_logout .bash_profile .bashrc .lesshst .ssh .viminfo
[donnie@fedora-server ~]$ cls
-bash: cls: command not found
[donnie@fedora-server ~]$
是的,你在 Windows 上总是使用的 cls
命令,在 Linux 上似乎不太好用,对吧?不过,有个简单的解决办法。只需创建一个指向 clear
命令的别名,像这样:
[donnie@fedora-server ~]$ alias cls=clear
[donnie@fedora-server ~]$
一旦你这么做了,你就能使用 cls
命令或 clear
命令清屏。请注意,在这种情况下,我不需要将别名定义用引号括起来,因为它不包含空格或特殊符号。如果创建的别名定义包含空格或特殊字符,格式会像这样:
图 5.1:在 Fedora 上使用我的新 lla
别名
很酷,效果不错。
要查看所有激活的别名列表,只需使用不带任何选项或参数的 alias
命令,像这样:
[donnie@fedora-server ~]$ alias
alias cls='clear'
alias egrep='egrep --color=auto'
alias fgrep='fgrep --color=auto'
alias grep='grep --color=auto'
alias l.='ls -d .* --color=auto'
alias ll='ls -l --color=auto'
alias lla='ls -la --color=auto'
alias ls='ls --color=auto'
alias which='(alias; declare -f) | /usr/bin/which --tty-only --read-alias --read-functions --show-tilde --show-dot'
. . .
. . .
alias zgrep='zgrep --color=auto'
[donnie@fedora-server ~]$
正如你所见,它们按字母顺序排列。在 Fedora 上,多个别名已经在 /etc/profile.d/
目录下的一组脚本中全局定义。举个例子,我们来看看 /etc/profile.d/colorls.sh
脚本。大部分脚本比较复杂,由定义与 ls
别名配合使用的颜色方案的命令组成。在脚本的底部,你会找到实际的别名,它们看起来像这样:
alias ll='ls -l --color=auto' 2>/dev/null
alias l.='ls -d .* --color=auto' 2>/dev/null
alias ls='ls --color=auto' 2>/dev/null
在这里,我们看到:
-
ll
别名替换了ls -l --color=auto
命令 -
l.
别名替换了ls -d .* --color=auto
命令 -
ls
别名替换了ls --color=auto
命令。
要查看这些别名是如何工作的,可以在 Fedora 虚拟机的主目录中创建一些文件和目录,像这样:
[donnie@fedora-server ~]$ touch somefile.sh anyfile.txt graphic1.png
[donnie@fedora-server ~]$ mkdir somedir
[donnie@fedora-server ~]$ chmod u+x somefile.sh
[donnie@fedora-server ~]$
当你执行普通的 ls
命令时,你会看到普通文本文件以黑色或白色字母显示,具体取决于你终端的背景颜色是黑色还是白色。目录会以蓝色字母显示,图形文件以品红色字母显示,可执行脚本以绿色字母显示。ll
别名执行的功能与 ls -l
命令相同,同样带有颜色编码,正如你所看到的:
图 5.2:在 Fedora 上使用 ll
别名
最后,l.
别名展示了以点号开头的文件和目录,同样带有颜色编码。它看起来像这样:
图 5.3:在 Fedora 上使用 l.
别名
Debian 开发者的做法与众不同。与其在全局层面定义别名,Debian 的开发者们选择在用户层面定义它们。你可以在每个用户的主目录下找到定义的别名列表,这些别名保存在 .bashrc
文件中。默认情况下,只有一个别名被启用,但你可以通过去掉前面的 #
符号来轻松启用任何或所有其他别名。
在 Fedora 机器上,有一个 which
别名替换了 which
命令。这个别名不仅会告诉你某个命令的可执行文件在哪,它还会告诉你该命令是否有别名。下面是它的效果:
[donnie@fedora-server ~]$ which ls
alias ls='ls --color=auto'
/usr/bin/ls
[donnie@fedora-server ~]$
Debian 中没有 which
别名,因此你不能使用 which
查看是否存在其他命令的别名。以下是 Debian 输出的样子:
donnie@debian:~$ which ls
/usr/bin/ls
donnie@debian:~$
需要注意的是,如果你有一个别名与实际底层命令同名,则别名始终优先。你可以通过两种方式来覆盖这个问题,以便直接调用命令的可执行文件。第一种方式是直接指定可执行文件的完整路径,像这样:
[donnie@fedora-server ~]$ /usr/bin/ls
anyfile.txt somedir somefile.sh
[donnie@fedora-server ~]$
第二种方式是,在命令前加上反斜杠,像这样:
[donnie@fedora-server ~]$ \ls
anyfile.txt somedir somefile.sh
[donnie@fedora-server ~]$
你现在会看到没有颜色编码的 ls
输出。让我们再试一次,用 which
,像这样:
[donnie@fedora-server ~]$ /usr/bin/which ls
/usr/bin/ls
[donnie@fedora-server ~]$
这次,which
只显示可执行文件的位置,忽略了别名。如果需要禁用一个别名,可以使用 unalias
命令,像这样:
[donnie@fedora-server ~]$ which which
alias which='(alias; declare -f) | /usr/bin/which --tty-only --read-alias --read-functions --show-tilde --show-dot'
/usr/bin/which
[donnie@fedora-server ~]$ unalias which
[donnie@fedora-server ~]$ which which
/usr/bin/which
[donnie@fedora-server ~]$
在这种情况下,别名只会暂时禁用,直到你关闭 shell 会话。如果你使用 unalias
禁用任何从命令行创建的别名,该别名将永久禁用。
关于别名的最后一件事是,你在命令行设置的任何别名会在退出 shell 会话时消失。为了在 Debian 或 Fedora 上使别名永久生效,只需将它们放入你家目录下的 .bashrc
文件中。
好的,这几乎是本章的总结了。让我们总结一下,然后继续前进。
总结
在本章中,我们已经为接下来的章节打下了基础。我们解释了不同类型的 shell 会话,然后查看了影响 shell 环境的全局和用户级配置文件。接着,我们介绍了如何设置 shell 选项以及如何创建和使用别名。
现在你已经了解了 shell 操作的基本原理,接下来你可以开始处理更复杂的问题,比如如何使用文本流过滤器。我们将在下一章开始讨论。下次见。
问题
-
以下哪两个命令会开启
noclobber
选项?(选择两个。)-
set +o noclobber
-
set -o noclobber
-
set -C
-
set +C
-
-
在 Fedora 机器上,以下哪一个用户级配置文件只会影响登录 shell 会话?
-
profile
-
.bash_profile
-
.bash.bashrc
-
-
你刚在桌面 Linux 机器上打开了一个终端模拟器。你正在使用哪种类型的 shell 会话?(选择两个。)
-
非交互式
-
登录
-
交互式
-
非登录
-
深入阅读
-
如何列出 Linux 中的环境变量:
www.howtogeek.com/842780/linux-list-environment-variables/
-
Linux Shell 会话的类型:
www.automationdojos.com/types-of-linux-shell-sessions/
-
Linux set 命令及其使用方法(9 个示例):
phoenixnap.com/kb/linux-set
答案
-
b 和 c
-
c
-
c 和 d
加入我们的 Discord 社区!
和其他用户、Linux 专家以及作者本人一起阅读本书。
提出问题,为其他读者提供解决方案,通过“问我任何问题”环节与作者交流,等等。扫描二维码或访问链接加入社区。
第六章:文本流过滤器 - 第一部分
这是介绍文本流过滤器概念的两章中的第一章。我们将讨论它们是什么以及如何从命令行使用它们。在接下来的章节中,我会展示这些过滤工具如何在 Shell 脚本中使用的一些例子。
你应该学习这些实用工具的原因有两个。首先,如果你需要创建能够自动生成不同类型文档(如报告)的脚本,这些工具非常有帮助。第二个原因是,它们会出现在某些 Linux 认证考试中,例如 CompTIA Linux+/Linux 专业人员协会考试。
本章的主题包括:
-
文本流过滤器简介
-
使用
cat
-
使用
tac
-
使用
cut
-
使用
paste
-
使用
join
-
使用
sort
好的,开始吧,怎么样?
技术要求
本章请使用你任何的 Linux 虚拟机,因为这些过滤工具在所有 Linux 系统上都一样有效。或者,如果你正好在主机上运行 Linux 或 macOS,也可以直接使用它,而不需要虚拟机。没有动手实验,所以可以在自己机器上尝试所有命令,边学边操作。
在这一章和下一章中,你将处理大量的文本文件。为了方便起见,我已将文件放入 GitHub 仓库。如果你使用的是 Linux,获取这些文件的最佳方式是通过你发行版的正常包管理器安装git
。
然后,使用以下命令下载文件:
donnie@opensuse:~> git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
然后,cd
进入The-Ultimate-Linux-Shell-Scripting-Guide
目录,这是git
命令创建的,你会找到各章节的子目录。
如果你使用的是 Mac,你需要打开 App Store 并安装Xcode
包才能使用git
。然后,使用我刚才给出的命令来下载文件。
我还包括了如何在 FreeBSD 和 OpenIndiana 上使用一些这些工具的例子。如果你愿意,可以创建一个 FreeBSD 和 OpenIndiana 的虚拟机,但目前还不是必须的。
文本流过滤器简介
作为 Linux 系统管理员或使用 Linux 桌面的办公室工作人员,你可能会有一定数量的文本文件需要处理。你甚至可能需要从这些文件中提取数据,并有效地展示这些数据。
或者,你可能需要从显示 Linux 系统状态的工具中提取数据,或者从自动抓取网络特定信息的脚本中提取数据。我在本节中介绍的文本流过滤器工具可以帮助简化这些任务。一旦你学会了它们,你甚至可能发现,使用这些工具提取和展示数据的速度比使用文本编辑器或文字处理软件还要快。
图 6.1:文本流过滤器的基本概念
除了一个例外,你不会使用这些工具来修改原始文本文件。你将使用它们来查看屏幕上的选定数据,将选定的数据传输到另一个工具,或者使用重定向符将选定的数据创建或附加到另一个文本文件中。
使用 cat
cat
工具与我们的猫咪朋友无关。它用于查看、创建或合并多个文本文件。(实际上,cat
是 catenate 的缩写,意思是“将两者连接起来,端对端”。)
我在介绍中提到过,Mac 用户可以在他们的 Mac 上执行本章的演示。不过现在,我必须加一点警告。因为至少有三个版本的 cat
,它们有不同的选项开关。我将在过程中指出它们的差异。
有时,你需要编写可以跨各种操作系统使用的脚本。所以,了解这些小的细节很重要。
默认情况下,stdin
对于 cat 是键盘,stdout
是计算机屏幕。如果你在命令提示符下输入 cat
,你可以开始输入文本,并且只要按下 Enter,它就会立即回显文本。它会一直这样做,直到你按下 Ctrl-d 结束它。下面是它的样子:
[donnie@fedora ~]$ cat
Hi there!
我还没有按 Enter。看我按下之后会发生什么:
[donnie@fedora ~]$ cat
Hi there!
Hi there!
这是我想要输入的唯一一行,所以我会通过 Ctrl-d 退出 cat
:
[donnie@fedora ~]$ cat
Hi there!
Hi there!
[donnie@fedora ~]$
当然,仅凭这一点并不是特别有用。但你可以将 cat
与 stdout
重定向符一起使用来创建简单的文本文件。当你输入完消息后,再按一次 Enter 以跳到空白行,然后按 Ctrl-d 退出。它应该像这样:
[donnie@fedora ~]$ cat > newtext.txt
Hi there! I'm using cat to create a
text file. I hope that it turns out well.
[donnie@fedora ~]$
一旦你创建了文件,你可以使用 cat
来显示它。不过,它与 less
工具不同,因为 cat
会直接将文件中的所有内容显示到屏幕上,而没有分页。你不需要在 cat
中使用 stdin
重定向符,因为 cat
设计为使用参数而不是 stdin
重定向符。总之,下面是它的样子:
[donnie@fedora ~]$ cat newtext.txt
Hi there! I'm using cat to create a
text file. I hope that it turns out well.
[donnie@fedora ~]$
现在,使用 cat
创建第二个文件,像这样:
[donnie@fedora ~]$ cat > newtext_2.txt
I'm going to catenate this file to the first
file, just to see if it actually works.
[donnie@fedora ~]$
这就是合并操作的部分。再次调用 cat
,但是将两个新文件的名称作为参数传入,像这样:
[donnie@fedora ~]$ cat newtext.txt newtext_2.txt
Hi there! I'm using cat to create a
text file. I hope that it turns out well.
I'm going to catenate this file to the first
file, just to see if it actually works.
[donnie@fedora ~]$
图 6.2:将两个文件的内容导入到第三个文件。
这次,你会看到两个文件会显示得像一个单独的文件一样。
现在,添加一个 stdout
重定向符,通过将前两个文件合并来创建一个新文件,像这样:
[donnie@fedora ~]$ cat newtext.txt newtext_2.txt > newtext_3.txt
[donnie@fedora ~]$ cat newtext_3.txt
Hi there! I'm using cat to create a
text file. I hope that it turns out well.
I'm going to catenate this file to the first
file, just to see if it actually works.
[donnie@fedora ~]$
理解这一点很重要:当你这么做时,你需要创建一个第三个文本文件。这是因为如果你将两个原始文件的输出重定向到其中任何一个原始文件中,你将完全覆盖该目标文件的内容。你剩下的只有第二个文件的内容。实际上,让我给你看一下:
[donnie@fedora ~]$ cat newtext.txt newtext_2.txt > newtext.txt
[donnie@fedora ~]$ cat newtext.txt
I'm going to catenate this file to the first
file, just to see if it actually works.
[donnie@fedora ~]$
如你所见,原来的 newtext.txt
文件中的文本已经不存在了。
cat
有多个显示选项,你可以使用它们。为了查看如何使用它们,创建另一个文本文件。这次,添加一些标签和一大堆空行。它看起来大致如下:
[donnie@fedora ~]$ cat > newtext_4.txt
I'm now adding tabs, spaces, and blank lines, to see if some
of the options will work.
Hopefully, they will.
I hope.
[donnie@fedora ~]$
在of the options will work
行的开头,我插入了一些空格。其他所有行我都通过按一次Tab键开始。
假设我们不需要这么多连续的空行,那也没问题。我只需使用-s
选项将它们压缩掉,像这样:
[donnie@fedora ~]$ cat -s newtext_4.txt
I'm now adding tabs, spaces, and blank lines, to see if some
of the options will work.
Hopefully, they will.
I hope.
[donnie@fedora ~]$
看起来更好,对吧?
在 Linux、FreeBSD 和 macOS 上,你可以通过使用-t
选项看到所有的标签,像这样:
[donnie@fedora ~]$ cat -t newtext_4.txt
^II'm now adding tabs, spaces, and blank lines, to see if some
of the options will work.
^IHopefully, they will.
^II hope.
[donnie@fedora ~]$
你会看到现在所有的标签都显示为^I
字符。
在 OpenIndiana 上,你需要将-t
和-v
选项结合使用才能看到标签字符,因为仅使用-t
选项不会显示任何内容。以下是在 OpenIndiana 上的显示方式:
donnie@openindiana:~$ cat -tv test.txt
Testing, testing.
^IMore testing.
^I^IYet more testing.
Testing yet again,
^Iand again.
^I^I^IDone.
donnie@openindiana:~$
如果你想看到每行的结尾在哪里,请使用-e
选项,像这样:
[donnie@fedora ~]$ cat -e newtext_4.txt
I'm now adding tabs, spaces, and blank lines, to see if some$
of the options will work.$
$
$
$
$
Hopefully, they will.$
$
$
I hope.$
[donnie@fedora ~]$
在 OpenIndiana 上,你需要将-e
和-v
选项结合使用,因为仅使用-e
选项不会显示任何内容。以下是在 OpenIndiana 上的显示方式:
donnie@openindiana:~$ cat -ev test.txt
I'm now adding tabs, spaces, and blank lines, to see if some$
of the options will work.$
$
$
$
$
Hopefully, they will.$
$
$
I hope.$
donnie@openindiana:~$
正如你所看到的,每一行的末尾用$
表示。
仅在 Linux 上,你可以通过使用-A
选项查看标签的位置以及每行的结尾,像这样:
[donnie@fedora ~]$ cat -A newtext_4.txt
^II'm now adding tabs, spaces, and blank lines, to see if some$
of the options will work.$
$
$
$
$
^IHopefully, they will.$
$
$
^II hope.$
[donnie@fedora ~]$
请注意,-A
选项只在 Linux 版本的cat
上有效。它在 OpenIndiana、FreeBSD 或 macOS 上不起作用。
此外,如果脚本的可移植性是一个问题,你可以在 Linux 机器上使用cat -tv
和cat -ev
,尽管在 Linux 上-v
选项并不是必须的。
-b
选项会为所有非空行编号,像这样:
[donnie@fedora ~]$ cat -b newtext_4.txt
1 I'm now adding tabs, spaces, and blank lines, to see if some
2 of the options will work.
3 Hopefully, they will.
4 I hope.
[donnie@fedora ~]$
或者,使用-n
选项让所有行都编号,像这样:
[donnie@fedora ~]$ cat -n newtext_4.txt
1 I'm now adding tabs, spaces, and blank lines, to see if some
2 of the options will work.
3
4
5
6
7 Hopefully, they will.
8
9
10 I hope.
[donnie@fedora ~]$
这就是cat
的内容,接下来我们来看一下tac
。
使用 tac
tac
--cat
的反向拼写--一次按逆序显示一个或多个文件。为了查看它是如何工作的,我们首先创建两个看起来像这样的文本文件:
[donnie@fedora ~]$ cat > testfile_1.txt
one
two
three
four
five
six
[donnie@fedora ~]$ cat > testfile_2.txt
seven
eight
nine
ten
eleven
twelve
[donnie@fedora ~]$
让我们使用cat
同时查看两个文件:
[donnie@fedora ~]$ cat testfile_1.txt testfile_2.txt
one
two
three
four
five
six
seven
eight
nine
ten
eleven
twelve
[donnie@fedora ~]$
你可以使用tac
以逆序查看一个文件:
[donnie@fedora ~]$ tac testfile_1.txt
six
five
four
three
two
one
[donnie@fedora ~]$
或者,按逆序查看两个或更多文件:
[donnie@fedora ~]$ tac testfile_1.txt testfile_2.txt
six
five
four
three
two
one
twelve
eleven
ten
nine
eight
seven
[donnie@fedora ~]$
请注意,第一个文件的内容先显示,然后是第二个文件的内容。
当然,你可以使用stdout
重定向器来创建一个新的文本文件,像这样:
[donnie@fedora ~]$ tac testfile_1.txt testfile_2.txt > testfile_3.txt
[donnie@fedora ~]$
你可能以为tac
会使用和cat
相同的选项,但事实并非如此。cat
的选项都不能用于tac
。
这就是tac
的内容,接下来是cut
。
使用 cut
顾名思义,这个实用程序用于从文本文件中剪切并显示选定的信息。你可以把它当作是从文本文件中提取一个垂直切片并将其发送到你选择的输出。指定你想要开始和结束切片的方式有两种。你可以通过指定开始和结束字符来指定,也可以通过指定字段来指定。
要按字段指定切片,你需要同时使用-d
和-f
选项。-d
选项将指定分隔符,即分隔字段的字符。这样cut
就能知道每个字段的开始和结束位置。-f
选项将指定你想查看的字段。在这个图示中,你可以看到我使用cut
从/etc/passwd
文件中提取了用户名和真实姓名——字段1
和5
。
图 6.3:使用 cut 查看 passwd 文件的第 1 和第 5 字段
由于这个文件中的字段由冒号分隔,我使用了-d:
作为分隔符选项。
另一种指定切片的方法是字符方法。使用这种方法,你可以选择要显示的文本行的开始字符和结束字符。在下图中,你可以看到我将当前目录中的文件列表保存到了filelist.txt
中。
图 6.4:使用 cut 查看前 20 个字符
我决定只查看该文件每行的前 20 个字符,所以我使用了-c
选项,并附加了适当的字符编号。(注意,你可以将命令输入为cut -c1-20 filelist.txt
,也可以输入为cut -c 1-20 filelist.txt
,-c
与第一个数字之间的空格是可选的。)为了进一步说明它是如何工作的,下面让我展示一下切割一个实际的多行文件时的输出:
[donnie@fedora ~]$ cut -c 1-20 filelist.txt
total 2109597
drwxr-xr-x 1 donnie
-rw-r--r-- 1 donnie
-rw-r--r-- 1 donnie
-rw-r--r-- 1 donnie
. . .
. . .
drwxr-xr-x 1 donnie
-rwxr--r-- 1 donnie
-rwxr--r-- 1 donnie
-rwxr--r-- 1 donnie
[donnie@fedora ~]$
你可以看到cut
对文件中的每一行都进行了操作。此外,你还看到第一行完整地打印了出来,因为它的字符数少于 20 个。(当你使用字段方法时,情况也是一样的。)
当然,对于上述两个例子,你可以选择使用stdout
重定向器将提取的信息保存到文本文件。例如,你可以像这样操作:
[donnie@fedora ~]$ cut -d: -f 1,5 /etc/passwd > passwd_cut.txt
[donnie@fedora ~]$ cut -c1-20 myfile.txt > filelist_cut.txt
[donnie@fedora ~]$
和大多数文本流过滤工具一样,你可以将cut
的输出通过管道传递给另一个工具,或者将另一个工具的输出通过管道传递给cut
。在前面的例子中,我其实并不需要将ls -l
的输出保存到文本文件中。我可以直接将ls -l
的输出通过管道传递给cut
,像这样操作:
[donnie@fedora ~]$ ls -l myfile.txt | cut -c1-20
-rw-r--r--. 1 donnie
[donnie@fedora ~]$
添加一个stdout
重定向器,你还可以将其保存到文本文件中,如下所示:
[donnie@fedora ~]$ ls -l myfile.txt | cut -c1-20 > filelist_cut.txt
[donnie@fedora ~]$
这就是cut
的所有内容。现在,让我们来看看如何进行粘贴。
使用 paste
与cat
将两个或更多文件连接在一起不同,paste
是将它们并排连接起来。当你有两个或更多列式数据的文件,并且你想在一个显示中查看所有数据时,这非常有用。请继续创建下图中所示的两个文本文件,然后尝试使用图示中的paste
命令。
图 6.5:使用 paste 将两个文件合并
paste
有两个选项可以使用。使用-s
选项的串联模式,允许你将数据列水平显示。将myfile_1.txt
和myfile_2.txt
与-s
选项一起粘贴,结果如下所示:
[donnie@fedora ~]$ paste -s myfile_1.txt myfile_2.txt
one three five seven nine
two four six eight ten
[donnie@fedora ~]$
-d
开关设置的分隔符选项可以让你更改列之间的分隔方式。如果你没有使用-d
开关,粘贴显示的列将用制表符分隔,就像前面的示例中看到的那样。要将制表符替换为其他字符,只需在-d
开关后面放置所需的替代字符。在这里,我使用一对双引号,中间有一个空格,将每个制表符替换为正常空格:
[donnie@fedora ~]$ paste -d" " myfile_1.txt myfile_2.txt
one two
three four
five six
seven eight
nine ten
[donnie@fedora ~]$
现在,这里有一个非常酷的技巧,你可以用来给你的朋友留下深刻印象。首先,按如下方式创建四个新文本文件:
[donnie@fedora ~]$ cat > number_1.txt
one
1
uno
[donnie@fedora ~]$ cat > number_2.txt
two
2
dos
[donnie@fedora ~]$
[donnie@fedora ~]$ cat > number_3.txt
three
3
tres
[donnie@fedora ~]$ cat > number_4.txt
four
4
cuatro
[donnie@fedora ~]$
现在,将它们全部合并到一起,使用 paste 命令。这一次,在-d
后面的引号中放置+
、-
和=
符号,像这样:
[donnie@fedora ~]$ paste -d"+-=" number_3.txt number_2.txt number_1.txt number_4.txt
three+two-one=four
3+2-1=4
tres+dos-uno=cuatro
[donnie@fedora ~]$
如你所见,三个算术运算符已经被放置在了适当的位置。(哦,顺便说一下,如果你在想,uno、dos、tres 和 cuatro 是西班牙语的 1、2、3 和 4。)
这不仅仅是一个疯狂的派对技巧。我将在第十一章,执行数学运算中展示这个方法的实际应用。
好的,现在我们已经将一些文件粘贴在一起,接下来让我们将它们合并。
使用 join
这里有一个你可以用来合并两个文本文件的实用工具。(与我们之前讨论的其他工具不同,你只能用join
合并两个文本文件,不能超过两个。)要了解它是如何工作的,查看下面的示意图,它展示了关于过去时代著名好莱坞演员的文本文件。然后,创建这些文件,在每个文件的两列之间用制表符分隔。接着,运行示意图中显示的命令。
图 6.6:使用 join 将两个文件合并在一起
如你所见,join
工具将列之间的制表符替换为空格。
请注意,在每个输入文件中,第一个字段是相同的。(在这个例子中,它是每个演员的姓氏。)在输出中,关键字段——第一个字段——只列出一次。每个演员的姓氏——关键字段——从两个输入文件中获取。
这里需要记住的是,你必须确保两个输入文件中有一个字段是相同的。这个字段不一定是第一个字段,正如我们在下一个例子中将展示的那样。
如果你需要,还可以使用-j
开关选择另一个字段。你可以通过下面这两个数字列表看到这一点:
[donnie@fedora ~]$ cat > num_1.txt
1 one
3 three
4 four
7 seven
5 five
[donnie@fedora ~]$ cat > num_2.txt
uno one
tres three
cuatro four
siete seven
cinco five
[donnie@fedora ~]$
现在,让我们将它们合并起来:
[donnie@fedora ~]$ join -j 2 num_1.txt num_2.txt
one 1 uno
three 3 tres
four 4 cuatro
seven 7 siete
five 5 cinco
[donnie@fedora ~]$
在这里,第二个字段在每个输入文件中是相同的,因此我使用了-j 2
选项将该字段指定为关键字段。
你可以将join
的输出通过管道传递给其他工具,以获得定制化的输出。如果你想为输出编号,可以将其通过管道传递给cat
并使用-n
选项,像这样:
[donnie@fedora ~]$ join -j 2 num_1.txt num_2.txt | cat -n
1 one 1 uno
2 three 3 tres
3 four 4 cuatro
4 seven 7 siete
5 five 5 cinco
[donnie@fedora ~]$
现在,很多人都喜欢创建逗号分隔值(csv)格式的电子表格文件,而不是使用电子表格程序的原生格式。这样做的好处是,文件可以在任何文本编辑器中创建,且任何电子表格程序都能读取这些文件。你可以通过使用-t
开关来指定字段是由逗号而不是制表符分隔,从而让join
与这些文件一起使用。要查看如何操作,可以将actorfile_1.txt
和actorfile_2.txt
文件复制为对应的.csv
文件,如下所示:
[donnie@fedora ~]$ cp actorfile_1.txt actorfile_1.csv
[donnie@fedora ~]$ cp actorfile_2.txt actorfile_2.csv
[donnie@fedora ~]$
接下来,编辑这两个.csv
文件,将制表符替换为逗号。第一个文件将如下所示:
[donnie@fedora ~]$ cat actorfile_1.csv
Benny,Jack
Allen,Fred
Ladd,Alan
Hitchcock,Alfred
Robinson,Edgar
[donnie@fedora ~]$
第二个文件将如下所示:
[donnie@fedora ~]$ cat actorfile_2.csv
Benny,comedy
Allen,comedy
Ladd,adventure
Hitchcock,mystery
Robinson,drama
[donnie@fedora ~]$
现在,看看当我尝试正常使用join
时会发生什么:
[donnie@fedora ~]$ join actorfile_1.csv actorfile_2.csv
join: actorfile_1.csv:2: is not sorted: Allen,Fred
join: actorfile_2.csv:4: is not sorted: Hitchcock,mystery
join: input is not in sorted order
[donnie@fedora ~]$
它不起作用,因为join
认为每个文件中只有一个字段。为了使其正常工作,我需要使用-t
开关来指定逗号作为我的字段分隔符,如下所示:
[donnie@fedora ~]$ join -t, actorfile_1.csv actorfile_2.csv
Benny,Jack,comedy
Allen,Fred,comedy
Ladd,Alan,adventure
Hitchcock,Alfred,mystery
Robinson,Edgar,drama
[donnie@fedora ~]$
很酷,看起来好多了。当然,你也可以将输出重定向到一个第三个.csv
文件,然后将其导入到任何电子表格程序中。
join
还有更多的使用方式,你可以在join
的手册页中查看。将join
的强大功能与我们即将介绍的下一个工具的功能结合起来,你将拥有一个易于使用的工具,可以用来创建简单的数据库。
使用 sort
这是一个多功能的工具,你可以单独使用它,也可以与其他工具配合使用。你可以用它来排序一个文件,或者将多个文件一起排序。
有很多选项可以用于不同的目的。你可以选择如何格式化输出,如何对数据进行排序,以及你希望使用哪些字段进行排序。你甚至可以在两个或更多字段上同时进行排序,主排序基于一个字段,次排序基于其他字段。
你可以使用sort
的方式有很多种。正如我之前所说,唯一的限制是你自己的想象力。首先,看看下面的图示。请在你的机器上创建文本文件,然后运行你看到的命令:
图 6.7:排序 actorfile_1.txt 文件
你在上面的图示中看到的是最简单的排序。默认情况下,排序是区分大小写的,并且是对输入文件的每一整行进行排序。
在下一个示例中,我将同时展示两个选项。第一个是-k
选项,它允许你根据特定字段对输入文件进行排序。第二个是-o
选项,它允许你将输出保存到文本文件中。当然,你仍然可以使用重定向器来实现这一点。但与重定向器不同,-o
选项允许你将排序后的输出保存回原始输入文件,而不会清空其内容。
在我展示之前,我将把actorfile_1.txt
文件复制到actorfile_5.txt
文件,以便稍后我可以再次使用actorfile_1.txt
文件:
[donnie@fedora ~]$ cp actorfile_1.txt actorfile_5.txt
[donnie@fedora ~]$
现在,让我们看看它是如何工作的:
[donnie@fedora ~]$ sort -k2 actorfile_5.txt -o actorfile_5.txt
[donnie@fedora ~]$ cat actorfile_5.txt
Ladd Alan
Hitchcock Alfred
Robinson Edgar
Allen Fred
Benny Jack
[donnie@fedora ~]$
如你所见,原始输入文件现在已经按演员的名字排序。
假设你需要一个女演员列表来搭配你的男演员列表,而这个列表大致如下所示:
[donnie@fedora ~]$ cat > actorfile_6.txt
MacLaine Shirley
Booth Shirley
Stanwick Barbara
Allen Gracie
[donnie@fedora ~]$
将actorfile_1.txt
文件和actorfile_6.txt
文件一起排序,如下所示:
[donnie@fedora ~]$ sort actorfile_1.txt actorfile_6.txt
Allen Fred
Allen Gracie
Benny Jack
Booth Shirley
Hitchcock Alfred
Ladd Alan
MacLaine Shirley
Robinson Edgar
Stanwick Barbara
[donnie@fedora ~]$
在屏幕上查看结果后,将结果保存到一个新文件中,如下所示:
[donnie@fedora ~]$ sort actorfile_1.txt actorfile_6.txt -o actorfile_7.txt
[donnie@fedora ~]$
你也可以通过tee
工具将其管道传输,这样你既能在屏幕上看到输出,又能同时将其保存到文件中。
[donnie@fedora ~]$ sort actorfile_1.txt actorfile_6.txt | tee actorfile_8.txt
Allen Fred
Allen Gracie
Benny Jack
Booth Shirley
Hitchcock Alfred
Ladd Alan
MacLaine Shirley
Robinson Edgar
Stanwick Barbara
[donnie@fedora ~]$ ls -l actorfile_8.txt
-rw-r--r--. 1 donnie donnie 130 Sep 6 19:02 actorfile_8.txt
[donnie@fedora ~]$
还有一些其他选项,可以在特殊情况下帮助你。为了演示,创建一个名为fruit.txt
的文件,它将如下所示:
[donnie@fedora ~]$ cat > fruit.txt
Peach
Peach
apricot
peach
Apricot
[donnie@fedora ~]$
你会看到有一些重复条目。让我们看看正常排序会是什么样子:
[donnie@fedora ~]$ sort fruit.txt
apricot
Apricot
peach
Peach
Peach
[donnie@fedora ~]$
由于每种水果只需要一个条目,可以使用-u
选项来去除重复条目,如下所示:
[donnie@fedora ~]$ sort -u fruit.txt
apricot
Apricot
peach
Peach
[donnie@fedora ~]$
这看起来稍微好一些。但是,你真的需要在大写和小写字母中重复显示水果名称吗?如果不需要,那么添加-f
开关,使sort
将所有字母视为大写字母进行排序,如下所示:
[donnie@fedora ~]$ sort -uf fruit.txt
apricot
Peach
[donnie@fedora ~]$
一些旧版 Linux 书籍可能会告诉你,文本文件中包含大小写字母混合的排序字段,除非使用-f
开关,否则不会正确排序。
例如,名字“MacLeod”会排在“Mack”之前,因为大写字母 L 会排在小写字母 k 之前。-f
开关会强制sort
将列表项按正确的字母顺序排序,不论大小写。然而,在更新的 Linux 发行版中,这已不再是问题。-f
开关仍然有其用途,正如你在上面的例子中看到的。但是,那些其他 Linux 书籍中提到的问题已经不复存在。
在下一个示例中,创建一个包含数字的文件。
[donnie@fedora ~]$ cat > numbers.txt
1
5
10
78
78034
10053
[donnie@fedora ~]$
这是你尝试排序列表时得到的结果:
[donnie@fedora ~]$ sort numbers.txt
1
10
10053
5
78
78034
[donnie@fedora ~]$
这可能不是你想要的。为了按正确的数字顺序排序,请使用-n
开关,如下所示:
[donnie@fedora ~]$ sort -n numbers.txt
1
5
10
78
10053
78034
[donnie@fedora ~]$
添加-r
开关,以便按逆序排序文件:
[donnie@fedora ~]$ sort -nr numbers.txt
78034
10053
78
10
5
1
[donnie@fedora ~]$
这次,玩一下,创建一个包含小数和负数的文本文件,以及一些正数:
[donnie@fedora ~]$ cat > numbers_2.txt
-68
57
2.6
1.24
10000
14
1.4
8
80
8.2
.8
-87
[donnie@fedora ~]$
现在,让我们看看在没有任何选项开关的情况下,排序结果如何:
[donnie@fedora ~]$ sort numbers_2.txt
10000
1.24
1.4
14
2.6
57
-68
.8
8
80
8.2
-87
[donnie@fedora ~]$
和之前一样,添加-n
开关以按正确的数字顺序排序列表:
[donnie@fedora ~]$ sort -n numbers_2.txt
-87
-68
.8
1.24
1.4
2.6
8
8.2
14
57
80
10000
[donnie@fedora ~]$
在下一个示例中,让我们创建一个二手汽车的文件列表。我们将列出品牌、型号、年份、里程(以千英里为单位)和售价。(如果你不在美国,可以假装英里实际上是公里。)列表如下所示:
[donnie@fedora ~]$ cat > autos.txt
plymouth satellite 1970 154 600
plymouth fury 1970 73 2500
plymouth breeze 1996 116 4300
chevy malibu 2000 60 3000
ford mustang 1965 45 10000
volvo s80 1998 102 9850
ford thunderbird 2003 15 3500
chevy malibu 1999 50 3500
bmw 325i 1985 115 450
bmw 325i 1985 60 1000
honda accord 2001 30 6000
ford taurus 2004 10 17000
toyota rav4 2002 180 750
chevy impala 1985 85 1550
ford explorer 2003 25 9500
jeep wrangler 2003 54 1600
edsel corsair 1958 47 750
ford galaxie 1964 128 60
[donnie@fedora ~]$
我们将首先进行正常排序,如下所示:
[donnie@fedora ~]$ sort autos.txt
bmw 325i 1985 115 450
bmw 325i 1985 60 1000
chevy impala 1985 85 1550
chevy malibu 1999 50 3500
chevy malibu 2000 60 3000
edsel corsair 1958 47 750
ford explorer 2003 25 9500
ford galaxie 1964 128 60
ford mustang 1965 45 10000
ford taurus 2004 10 17000
ford thunderbird 2003 15 3500
honda accord 2001 30 6000
jeep wrangler 2003 54 1600
plymouth breeze 1996 116 4300
plymouth fury 1970 73 2500
plymouth satellite 1970 154 600
toyota rav4 2002 180 750
volvo s80 1998 102 9850
[donnie@fedora ~]$
默认情况下,排序操作将从文件的第一列开始,然后逐行处理,直到遇到非唯一条目。在这里,我们看到所有内容都按正确的顺序排序,直到年份列。(稍后我们会修复这个问题。)
当某个品牌的汽车超过一辆时,排序将继续到型号列。当某个型号超过一辆时,排序将继续到年份列。即使在这个示例中看起来没有,里程列也会参与排序。这是因为我在几页前展示过的数字排序问题。
为了按数字顺序排序数字而不是机器排序顺序,您必须使用-n
选项。我们可以通过使用-k
选项来指定要排序的单独列,如下所示:
[donnie@fedora ~]$ sort -k1,3 -k4n autos.txt
bmw 325i 1985 60 1000
bmw 325i 1985 115 450
chevy impala 1985 85 1550
chevy malibu 1999 50 3500
chevy malibu 2000 60 3000
edsel corsair 1958 47 750
ford explorer 2003 25 9500
ford galaxie 1964 128 60
ford mustang 1965 45 10000
ford taurus 2004 10 17000
ford thunderbird 2003 15 3500
honda accord 2001 30 6000
jeep wrangler 2003 54 1600
plymouth breeze 1996 116 4300
plymouth fury 1970 73 2500
plymouth satellite 1970 154 600
toyota rav4 2002 180 750
volvo s80 1998 102 9850
[donnie@fedora ~]$
-k1,3
选项表示对第一个到第三个字段进行排序。(换句话说,按品牌、型号和年份字段排序。)-k4n
选项表示还要按第四个(里程)字段以数字顺序排序。
请注意,您可以为单独的字段指定排序选项。还要注意,价格列在此排序中永远不会被考虑,因为直到里程列之前,没有任何行条目是完全相同的。
接下来,让我们先按制造商和价格对汽车列表进行排序。为此,您需要对第一个和第五个字段进行排序。由于我们已经知道需要使用-n
选项来正确排序第五列,因此我们将继续添加它,如下所示:
[donnie@fedora ~]$ sort -k 1 -k5n autos.txt
bmw 325i 1985 115 450
bmw 325i 1985 60 1000
chevy impala 1985 85 1550
chevy malibu 1999 50 3500
chevy malibu 2000 60 3000
edsel corsair 1958 47 750
ford explorer 2003 25 9500
ford galaxie 1964 128 60
ford mustang 1965 45 10000
ford taurus 2004 10 17000
ford thunderbird 2003 15 3500
honda accord 2001 30 6000
jeep wrangler 2003 54 1600
plymouth breeze 1996 116 4300
plymouth fury 1970 73 2500
plymouth satellite 1970 154 600
toyota rav4 2002 180 750
volvo s80 1998 102 9850
[donnie@fedora ~]$
每当您指定两个或更多的-k
选项时,sort
将在多个回合中执行其操作。在第一次回合中,它将按第一个-k
选项指定的字段进行排序。对其余指定字段的排序将在后续回合中执行。
在这里,我们看到sort
忽略了第二个-k
选项。这是因为第一个-k
选项没有为其排序指定结束点。所以,sort
在第一次回合中评估了每一整行,认为不需要第二次回合。
现在,让我们为第一个-k
选项指定一个结束点,看看这是否会有所不同。
[donnie@fedora ~]$ sort -k1,1 -k5n autos.txt
bmw 325i 1985 115 450
bmw 325i 1985 60 1000
chevy impala 1985 85 1550
chevy malibu 2000 60 3000
chevy malibu 1999 50 3500
edsel corsair 1958 47 750
ford galaxie 1964 128 60
ford thunderbird 2003 15 3500
ford explorer 2003 25 9500
ford mustang 1965 45 10000
ford taurus 2004 10 17000
honda accord 2001 30 6000
jeep wrangler 2003 54 1600
plymouth satellite 1970 154 600
plymouth fury 1970 73 2500
plymouth breeze 1996 116 4300
toyota rav4 2002 180 750
volvo s80 1998 102 9850
[donnie@fedora ~]$
-k1,1
选项表示我们希望第一次排序仅按照文件的第一个字段进行排序。通过添加该结束点,我们的排序就按预期工作了。
您不必从第一个字段开始排序。在下一个示例中,我们将首先按第三个字段(年份)排序,然后按第一个字段(品牌)排序。
[donnie@fedora ~]$ sort -k3 -k1 autos.txt
edsel corsair 1958 47 750
ford galaxie 1964 128 60
ford mustang 1965 45 10000
plymouth satellite 1970 154 600
plymouth fury 1970 73 2500
bmw 325i 1985 115 450
bmw 325i 1985 60 1000
chevy impala 1985 85 1550
plymouth breeze 1996 116 4300
volvo s80 1998 102 9850
chevy malibu 1999 50 3500
chevy malibu 2000 60 3000
honda accord 2001 30 6000
toyota rav4 2002 180 750
ford thunderbird 2003 15 3500
ford explorer 2003 25 9500
jeep wrangler 2003 54 1600
ford taurus 2004 10 17000
[donnie@fedora ~]$
再次,您可以看到当没有为第一个排序字段指定结束点时会发生什么。让我们再试一次。
[donnie@fedora ~]$ sort -k3,3 -k1 autos.txt
edsel corsair 1958 47 750
ford galaxie 1964 128 60
ford mustang 1965 45 10000
plymouth fury 1970 73 2500
plymouth satellite 1970 154 600
bmw 325i 1985 115 450
bmw 325i 1985 60 1000
chevy impala 1985 85 1550
plymouth breeze 1996 116 4300
volvo s80 1998 102 9850
chevy malibu 1999 50 3500
chevy malibu 2000 60 3000
honda accord 2001 30 6000
toyota rav4 2002 180 750
ford explorer 2003 25 9500
ford thunderbird 2003 15 3500
jeep wrangler 2003 54 1600
ford taurus 2004 10 17000
[donnie@fedora ~]$
好吧,它还是没有工作。所有内容仍然按年份正确排序,但没有按品牌排序。我们不是轻易放弃的人,所以再试一次。
[donnie@fedora ~]$ sort -b -k3,3 -k1 autos.txt
edsel corsair 1958 47 750
ford galaxie 1964 128 60
ford mustang 1965 45 10000
plymouth fury 1970 73 2500
plymouth satellite 1970 154 600
bmw 325i 1985 115 450
bmw 325i 1985 60 1000
chevy impala 1985 85 1550
plymouth breeze 1996 116 4300
volvo s80 1998 102 9850
chevy malibu 1999 50 3500
chevy malibu 2000 60 3000
honda accord 2001 30 6000
toyota rav4 2002 180 750
ford explorer 2003 25 9500
ford thunderbird 2003 15 3500
jeep wrangler 2003 54 1600
ford taurus 2004 10 17000
[donnie@fedora ~]$
请注意,在五个字段中的四个字段中,文本字符串的长度不同。理论上,较短文本字符串行中的额外空格应该会对我们执行的所有排序操作产生不利影响。然而,由于某种原因,只有最后一次排序操作受到了影响。通过添加-b
选项,我们告诉sort
忽略多余的空格。
好吧,也许你没有一辆古董汽车的收藏。但你可能有其他需要按各种方式排序的项目清单。请记住,你可以使用我在这里展示的技巧来处理任何类型的清单。
好的,这样就足够了,关于古董汽车的内容就到此为止。让我们现在看看如何排序月份列表。首先,让我们创建这个列表,像这样:
[donnie@fedora ~]$ cat > dates.txt
Dec 2023
jan 2023
Oct 2023
Sep 2022
Feb 2022
mar 2023
may 2022
[donnie@fedora ~]$
现在,使用 -M
开关按月份排序列表:
[donnie@fedora ~]$ sort -M dates.txt
jan 2023
Feb 2022
mar 2023
may 2022
Sep 2022
Oct 2023
Dec 2023
[donnie@fedora ~]$
如你所见,-M
开关不区分大小写。
现在,使用你在前面的示例中学到的技巧,先按年份排序,再按月份排序,像这样:
[donnie@fedora ~]$ sort -k2,2 -k1M dates.txt
Feb 2022
may 2022
Sep 2022
jan 2023
mar 2023
Oct 2023
Dec 2023
[donnie@fedora ~]$
最后,正如我承诺的,我现在将向你展示如何将 join
和 sort
一起使用,来大致模拟一个简单的关系数据库程序。让我们首先看一下输入文件,它们是我们之前使用的相同文件。首先,我们有 actorfile_1.txt
:
[donnie@fedora ~]$ cat actorfile_1.txt
Benny Jack
Allen Fred
Ladd Alan
Hitchcock Alfred
Robinson Edgar
[donnie@fedora ~]$
第二个输入文件是 actorfile_2.txt
,内容如下:
[donnie@fedora ~]$ cat actorfile_2.txt
Benny comedy
Allen comedy
Ladd adventure
Hitchcock mystery
Robinson drama
[donnie@fedora ~]$
现在,我们将它们连接起来,并将结果通过 sort
传递。(再一次,注意 join
如何将输入中的制表符转为普通空格。)
[donnie@fedora ~]$ join actorfile_1.txt actorfile_2.txt | sort
Allen Fred comedy
Benny Jack comedy
Hitchcock Alfred mystery
Ladd Alan adventure
Robinson Edgar drama
[donnie@fedora ~]$
好吧,这样看起来不太好,因为 join
无法保持列的正确对齐。让我们通过将输出管道传输到 column -t
命令来解决这个问题,像这样:
donnie@fedora:~$ join actorfile_1.txt actorfile_2.txt | sort | column -t
Allen Fred comedy
Benny Jack comedy
Hitchcock Alfred mystery
Ladd Alan adventure
Robinson Edgar drama
donnie@fedora:~$
我们可以使用已经学到的技巧按类别排序,然后按姓氏排序。
[donnie@fedora ~]$ join actorfile_1.txt actorfile_2.txt | sort -b -k3,3 -k1
Ladd Alan adventure
Allen Fred comedy
Benny Jack comedy
Robinson Edgar drama
Hitchcock Alfred mystery
[donnie@fedora ~]$
如你所见,这个话题有很多内容,但不要因此而气馁。通过一点点练习,你会像专业人士一样进行排序。
好的,这一章差不多就到此为止了。让我们总结一下并继续。
总结
作为一个 Linux 系统管理员、开发者,甚至是一个使用 Linux 的办公室文员,你可能需要从文本文件或系统命令的输出中提取并格式化数据。在本章中,你学习了各种文本流过滤器以及它们如何帮助你完成这些任务。还有很多其他过滤器需要覆盖,我们将在下一章中讨论。我会在那里等你。
问题
-
以下哪个实用程序可以用来将两个或更多文件并排连接在一起?
-
join
-
cat
-
tac
-
paste
-
-
以下哪两个实用程序可以一起使用来创建简单的数据库?(选择两个。)
-
paste
-
sort
-
join
-
cat
-
-
以下哪个命令可以正确保存
cat
操作的输出?-
cat file1.txt file2.txt > file1.txt
-
cat file1.txt file2.txt > file2.txt
-
cat file1.txt file2.txt > file3.txt
-
-
如果你希望
sort
仅按文件的第 1 列进行排序,命令应该是什么样的?-
sort -F1 myfile.txt
-
sort -F1,1 myfile.txt
-
sort -k1 myfile.txt
-
sort -k1,1 myfile.txt
-
-
以下哪个选项开关可以与
cat
一起使用,以去除重复的空白行?-
-d
-
-s
-
-o
-
-u
-
进一步阅读
-
Linux 专业认证考试 101 目标:
www.lpi.org/our-certifications/exam-101-objectives
-
如何在 Linux 上使用 join 命令:
www.howtogeek.com/542677/how-to-use-the-join-command-on-linux/
-
排序命令示例:
linuxhandbook.com/sort-command/
答案
-
d
-
b 和 c
-
c
-
d
-
b
加入我们的 Discord 社区!
与其他用户、Linux 专家以及作者本人一起阅读本书。
提问、为其他读者提供解决方案、通过问我任何问题环节与作者互动,更多内容。扫描二维码或访问链接加入社区。
第七章:文本流过滤器 – 第二部分
在本章中,我们将继续探讨各种文本流过滤器。在学习本章时,我挑战你发挥想象力。不要把这些过滤器工具当作是因为我说了或者你需要通过 Linux 认证考试就必须学的东西,试着去想象每个工具是如何帮助你格式化自己的文本文件和报告的。相信我,你永远不会知道这些工具什么时候会派上用场。
本章将涵盖以下主题:
-
使用
expand
-
使用
unexpand
-
使用
nl
-
使用
head
-
使用
tail
-
同时使用
head
和tail
-
使用
od
-
使用
uniq
-
使用
wc
-
使用
fmt
-
使用
split
-
使用
tr
-
使用
xargs
-
使用
pr
-
从命令行打印
它们并不难掌握,但涉及的内容很多。所以如果你准备好了,我们开始吧。
技术要求
使用任意一个虚拟机,因为在两者上效果是一样的。或者,如果你的主机正在运行 Linux 或 macOS,也可以直接使用主机,而不必使用虚拟机。我不会提供实际的实验室操作,因此你可以在阅读本章节时,随时在自己的机器上操作。同时,和之前一样,你可以从 GitHub 仓库下载所需的文本文件。
使用 expand
有时,你创建的包含列数据的文本文件在某些情况下可能无法正确显示。这可能是因为你用制表符而不是空格分隔列。有时,制表符显示不正确,你需要将它们替换为空格。
为了查看这一点,创建一个expand.txt
文本文件。文件中将包含三列数据,每列之间有两个制表符。文件内容如下:
[donnie@fedora ~]$ cat > expand.txt
one two three
four five six
seven eight nine
[donnie@fedora ~]$
现在,展开文件并查看输出。它应该如下所示:
[donnie@fedora ~]$ expand expand.txt
one two three
four five six
seven eight nine
[donnie@fedora ~]$
既然我还保留着阅读你心思的怪癖,我知道你一定在想,既然扩展后的输出与原文件看起来一样,究竟发生了什么。这时,外观可能会让人产生误导。当你扩展文件时,你在列之间放置的每个制表符都被若干空格所替换。空格的数量因行而异,取决于每个文本字符串中的字符数量。这样,列仍然可以保持对齐。
实际上,expand
会将每个制表符字符替换为八个空格。但当文本字符串长度不一致时,它似乎不会这么做。这是因为expand
会调整插入的空格数,以确保所有列都对齐。如果你的文本文件中列与列之间有多个制表符,且文本长度不一致,除了一个制表符,其他的都会被替换成恰好八个空格。
如果你想验证这一点,可以将扩展后的输出保存到一个新文件中,然后用你喜欢的文本编辑器打开新文件。在我的例子中,我将使用vim
,像这样:
[donnie@fedora ~]$ expand expand.txt > expand_2.txt
[donnie@fedora ~]$ vim expand_2.txt
现在,移动光标到两个列之间的空白区域。左右移动光标时,你会发现它每次只能移动一个空格。关闭此文件,打开原始文件。当你在此文件的列之间移动光标时,你会看到光标移动了一个制表符的长度,而不是一个空格。
expand
有两个可以使用的选项。第一个是 -t
选项。这个选项让你设置想要替换制表符的空格数,而不是使用默认的八个空格。这里,我们想将每个制表符替换为两个空格。
[donnie@fedora ~]$ expand -t2 expand.txt
one two three
four five six
seven eight nine
[donnie@fedora ~]$
由于每个制表符仅替换为两个空格,expand
未能保持列的正确对齐。你可以随意尝试其他 -t
值,看看会发生什么。
使用 -i
开关,你可以指示 expand
只替换每行开头的制表符。每行后续的制表符将保持不变。为了看看它是如何工作的,将 expand_1.txt
文件复制到 expand_1a.txt
文件中,如下所示:
donnie@fedora:~$ cp expand_1.txt expand_1a.txt
donnie@fedora:~$
在文本编辑器中打开 expand_1a.txt 文件,并在第 1 行的开头插入一个制表符,在第 2 行的开头插入两个制表符,在第 3 行的开头插入三个制表符。编辑后的文件应该如下所示:
donnie@fedora:~$ cat expand_1a.txt
one two three
four five six
seven eight nine
donnie@fedora:~$
使用 -i 选项,将此文件展开到 expand_1b.txt 文件中,如下所示:
donnie@fedora:~$ expand -i expand_1a.txt > expand_1b.txt
donnie@fedora:~$
在文本编辑器中打开 expand_1b.txt
文件,并确认只有每行前面的制表符被替换为空格。
猜猜看?这就是我关于 expand
要说的全部内容。现在,让我们反向操作。
使用 unexpand
既然我已经告诉你 expand
做了什么,我还需要告诉你 unexpand
做了什么吗?没错,你猜对了。unexpand
会删除列之间的空格,并将其替换为制表符。不过,有几个小问题。默认情况下,unexpand
仅作用于行首的空格。这正好与 expand
用制表符的方式相反。因此,如果你想将一行中的所有空格都替换为制表符,就需要使用 -a
开关。第二个问题是,默认情况下,unexpand
只有在看到连续的八个空格时才会生效。少于八个连续空格的分组不会被替换为制表符。(你可以通过 -t
开关来更改这种行为,稍后你会看到。)
我将通过使用 -a
选项,来展示如何取消展开我在 expand
部分刚刚创建的 expand_2.txt
文件,如下所示:
[donnie@fedora ~]$ unexpand -a expand_2.txt
one two three
four five six
seven eight nine
[donnie@fedora ~]$
再次,你可能看不出区别。为了看到区别,将结果保存到一个新文件中,如下所示:
[donnie@fedora ~]$ unexpand -a expand_2.txt > unexpand.txt
[donnie@fedora ~]$
在你最喜欢的文本编辑器中打开新文件。现在,当你在列之间移动光标时,你会发现它跳过了一个制表符的长度,而不仅仅是一个空格。换句话说,它现在和我在上一节中展开的原始文件一样。
之所以这样工作,是因为当我使用 expand
创建 expand_2.txt
文件时,列之间的制表符都被替换为八个或更多的空格。
现在,我之前告诉过你,默认情况下,unexpand
只对位于行首的空白字符进行操作。要验证这一点,打开 expand_3.txt
文件,内容将如下所示:
donnie@fedora:~$ cat expand_3.txt
one two three
four five six
seven eight nine
donnie@fedora:~$
这看起来有点乱,因为没有任何对齐,但在这种情况下正是我们想要的。第一行前面有八个空白字符,第二行前面有四个空白字符,第三行前面有两个空白字符。各列之间有不同数量的空白字符。
接下来,通过取消展开 expand_3.txt
文件来创建 unexpand_2.txt
文件,方法如下:
donnie@fedora:~$ unexpand expand_3.txt > unexpand_2.txt
donnie@fedora:~$
打开 unexpand_2.txt
文件,在文本编辑器中验证只有第一行开头的八个空白字符被替换为一个制表符。关闭文件,然后用 -t4
选项重新运行命令,如下所示:
donnie@fedora:~$ unexpand -t4 expand_3.txt > unexpand_2.txt
donnie@fedora:~$
这次,你应该看到第 1 行和第 2 行开头的空白字符已被制表符替代。再试一次,使用 -t2
选项,你会看到所有三行前面的空白字符都被制表符替代。最后,再次运行这些命令,使用 -a
选项来执行空白字符的全局替换。
我必须承认,长期以来,我从未认为自己会使用 expand
或 unexpand
做任何事。但我错了。几年前,一位前客户委托我教授一门 Kali Linux 课程。我使用的书中有一个 shell 脚本,本应该自动从 ifconfig
命令的输出中提取 IP 地址。但脚本并没有正常工作,因为书籍写作完成后,有人更改了 ifconfig
代码,导致输出格式发生了变化。
我不得不修改脚本才能使其正常工作,并且我使用了 expand
或 unexpand
作为修复的一部分。(那是很久以前的事了,所以我不记得具体用了哪个。不过,这不重要。)所以,这表明你永远不知道会发生什么。(是的,我是个诗人。)
好了,关于展开和取消展开的内容就到此为止。接下来让我们做一些编号。
使用 nl
nl
工具用于对文本文件的行进行编号。它很容易使用,且只有少数几个选项需要记住。让我们从创建一个包含十行连续内容的文件开始,如下所示:
[donnie@fedora ~]$ cat > lines.txt
This is line one.
This is line two.
This is line three.
This is line four.
This is line five
This is line six
This is line seven.
This is line eight.
This is line nine.
This is line ten.
[donnie@fedora ~]$
让我们按如下方式对文件中的行进行编号:
[donnie@fedora ~]$ nl lines.txt
1 This is line one.
2 This is line two.
3 This is line three.
4 This is line four.
5 This is line five
6 This is line six
7 This is line seven.
8 This is line eight.
9 This is line nine.
10 This is line ten.
[donnie@fedora ~]$
现在,我们创建另一个与此相同的文件,唯一的区别是我们插入了一些空行。
[donnie@fedora ~]$ cat > lines_2.txt
This is line one.
This is line two.
This is line three.
This is line four.
This is line five.
This is line six.
This is line seven.
This is line eight.
This is line nine.
This is line ten.
[donnie@fedora ~]$
再次,我们将对这个文件进行编号,但不指定任何选项。
[donnie@fedora ~]$ nl lines_2.txt
1 This is line one.
2 This is line two.
3 This is line three.
4 This is line four.
5 This is line five.
6 This is line six.
7 This is line seven.
8 This is line eight.
9 This is line nine.
10 This is line ten.
[donnie@fedora ~]$
如果你没有指定任何选项,只有非空行会被编号。使用 -b
开关和适当的选项可以改变这一行为。通过 a
选项,你可以编号所有的行,包括空白行。(顺便说一下,-b
开关代表正文。换句话说,这个开关设置了 nl
在文件正文中对行进行编号的方式。稍后我会展示这个开关如何发挥作用。)
[donnie@fedora ~]$ nl -ba lines_2.txt
1 This is line one.
2
3 This is line two.
4
5 This is line three.
6 This is line four.
7 This is line five.
8
9
10 This is line six.
11
12 This is line seven.
13 This is line eight.
14 This is line nine.
15
16
17 This is line ten.
[donnie@fedora ~]$
-bt
选项使nl
只对文本文件正文中非空的行进行编号,而-bn
选项则告诉nl
不对文本文件正文中的行进行编号。这可能对你来说有点奇怪,因为第一个选项定义了已经是默认行为的内容,而第二个选项似乎完全违背了使用nl
的目的。稍后我会对此进行澄清。
当你创建文本文件时,可以使用一组特殊的分隔符来定义一个头部、正文和页脚,这些将由nl
使用。
如下图所示,你可以通过在每个部分的开头放置适当的反斜杠和冒号来实现这一点。
图 7.1:文本文件的头部、正文和页脚部分
每个字符出现三次定义了头部,两个字符定义了正文,一个字符定义了页脚。nl
工具允许你以不同的方式对这些部分进行编号。通过创建你自己的三部分文本文件来验证这一点,文件将如下所示:
[donnie@fedora ~]$ cat > line_number_1.txt
\:\:\:
This is the file header.
\:\:
This is the body of the file.
There's not a lot to say, so I'll
close it.
\:
This is the footer.
[donnie@fedora ~]$
当使用nl
而不指定任何选项时,只有正文中非空的行会被编号,像这样:
[donnie@fedora ~]$ nl line_number_1.txt
This is the file header.
1 This is the body of the file.
2 There's not a lot to say, so I'll
3 close it.
This is the footer.
[donnie@fedora ~]$
要对头部和页脚中的行进行编号,你需要使用-h
和-f
开关。这些开关的选项与-b
开关的选项相同。因此,要对头部中的所有行以及正文中非空行进行编号,你可以使用-ha
选项,如下所示:
[donnie@fedora ~]$ nl -ha line_number_1.txt
1 This is the file header.
2
1 This is the body of the file.
2 There's not a lot to say, so I'll
3 close it.
This is the footer.
[donnie@fedora ~]$
要仅对头部和正文中的非空行进行编号,使用-ht
选项,如下所示:
[donnie@fedora ~]$ nl -ht line_number_1.txt
1 This is the file header.
1 This is the body of the file.
2 There's not a lot to say, so I'll
3 close it.
This is the footer.
[donnie@fedora ~]$
接下来,让我们为页脚中的所有行进行编号,如下所示:
[donnie@fedora ~]$ nl -fa line_number_1.txt
This is the file header.
1 This is the body of the file.
2 There's not a lot to say, so I'll
3 close it.
1 This is the footer.
[donnie@fedora ~]$
这次,页脚被编号了,但头部没有。在这两种情况下,正文中的非空行被编号了,尽管我并没有指定让nl
这么做。为了防止nl
在编号页头和页脚的同时编号正文中的行,可以加上-bn
选项,像这样:
[donnie@fedora ~]$ nl -bn -ha -fa line_number_1.txt
1 This is the file header.
2
This is the body of the file.
There's not a lot to say, so I'll
close it.
1 This is the footer.
[donnie@fedora ~]$
你也可以让nl
搜索包含特定文本字符串的行,并只对这些行进行编号。为此,你需要使用p
选项。我们以第一个文本文件为例,称之为lines.txt
,并仅对包含单词seven
的行进行编号。(请注意,p
和你要搜索的字符串之间不能有空格。)它的效果如下所示:
[donnie@fedora ~]$ nl -bpseven lines.txt
This is line one.
This is line two.
This is line three.
This is line four.
This is line five
This is line six
1 This is line seven.
This is line eight.
This is line nine.
This is line ten.
[donnie@fedora ~]$
现在,让我们创建一个稍微更现实的文件。我们甚至会做得更完整,给它加上头部、正文和页脚。
[donnie@fedora ~]$ cat > macgruder.txt
\:\:\:
This is the file outlining the strategy
for the MacGruder Corporation account.
\:\:
It is vitally important to maintain close
working relations with the IT gurus at
the MacGruder Corporation. This account
represents our company's best opportunity
in ages to make a huge sale of servers,
desktop computers, routers, and software
services. We must give full attention
to the needs and wants of the MacGruder
Corporation.
\:
This document contains sensitive
information about the MacGruder
Corporation.
[donnie@fedora ~]$
现在,对正文中包含单词MacGruder
的每一行进行编号,像这样:
[donnie@fedora ~]$ nl -bpMacGruder macgruder.txt
This is the file outlining the strategy
for the MacGruder Corporation account.
It is vitally important to maintain close
working relations with the IT gurus at
1 the MacGruder Corporation. This account
represents our company's best opportunity
in ages to make a huge sale of servers,
desktop computers, routers, and software
services. We must give full attention
2 to the needs and wants of the MacGruder
Corporation.
This document contains sensitive
information about the MacGruder
Corporation.
[donnie@fedora ~]$
如果你指定nl
只对头部和/或页脚中的MacGruder
行进行编号,而没有指定正文部分,nl
仍会按默认方式对正文中所有非空行进行编号,如下所示:
[donnie@fedora ~]$ nl -hpMacGruder -fpMacGruder macgruder.txt
This is the file outlining the strategy
1 for the MacGruder Corporation account.
1 It is vitally important to maintain close
2 working relations with the IT gurus at
3 the MacGruder Corporation. This account
4 represents our company's best opportunity
5 in ages to make a huge sale of servers,
6 desktop computers, routers, and software
7 services. We must give full attention
8 to the needs and wants of the MacGruder
9 Corporation.
This document contains sensitive
1 information about the MacGruder
Corporation.
[donnie@fedora ~]$
包含-bn
选项,仅对头部和/或页脚中的MacGruder
行进行编号,像这样:
[donnie@fedora ~]$ nl -bn -hpMacGruder -fpMacGruder macgruder.txt
This is the file outlining the strategy
1 for the MacGruder Corporation account.
It is vitally important to maintain close
working relations with the IT gurus at
the MacGruder Corporation. This account
represents our company's best opportunity
in ages to make a huge sale of servers,
desktop computers, routers, and software
services. We must give full attention
to the needs and wants of the MacGruder
Corporation.
This document contains sensitive
1 information about the MacGruder
Corporation.
[donnie@fedora ~]$
现在,你可能会想,什么时候你会创建一个带有nl
风格头部和脚本的文本文件呢?答案是——请鼓掌——我也不知道。经过相当广泛的研究,我没有找到任何其他工具会像nl
一样显示这些头部和脚本。事实上,我找到的一些将文本文件转换为其他格式的工具可以插入自己的头部和脚本,但它们会将nl
风格的头部和脚本显示为普通文档的一部分。
然而,nl
在需要向只有正文的文件插入行号时仍然有用。如果你计划参加 Linux 认证考试,你需要了解nl
风格文本文件的头部、正文和脚本概念,因为你可能会看到相关的问题。
nl
的手册页非常简略,所以你应该查看 info 页面,以获取更多关于nl
的信息和选项。要查看 info 页面,可以运行以下命令:
[donnie@fedora ~]$ info nl
这就是nl
的内容。接下来我们继续。
使用 head
如果你只想查看文本文件开头的某些行,可以使用head
命令。为了演示,我将展示一些在我 Fedora 工作站上的文件。如果你自己电脑上没有完全相同的文件,随时可以使用其他文件。
默认情况下,head
会显示文件的前十行。稍后我会向你展示如何更改这一点。
让我们从进入/var/log/
目录开始,然后查看boot.log
文件的前十行,如下所示:
[donnie@fedora ~]$ cd /var/log
[donnie@fedora log]$ sudo head boot.log
[ OK ] Finished logrotate.service - Rotate log files.
[FAILED] Failed to start vmware.ser… starts and stops VMware services.
See 'systemctl status vmware.service' for details.
Starting vmware-USBArbitra…s and stops the USB Arbitrator....
[ OK ] Started rsyslog.service - System Logging Service.
[ OK ] Started chronyd.service - NTP client/server.
[ OK ] Started vmware-USBArbitrat…rts and stops the USB Arbitrator..
Starting livesys-late.serv…ate init script for live image....
[ OK ] Started livesys-late.servi… Late init script for live image..
[ OK ] Started dbus-broker.service - D-Bus System Message Bus.
[donnie@fedora log]$
使用-n
开关可以更改你想查看的行数。要仅查看前五行,可以输入以下命令:
[donnie@fedora log]$ sudo head -n5 boot.log
[ OK ] Finished logrotate.service - Rotate log files.
[FAILED] Failed to start vmware.ser… starts and stops VMware services.
See 'systemctl status vmware.service' for details.
Starting vmware-USBArbitra…s and stops the USB Arbitrator....
[ OK ] Started rsyslog.service - System Logging Service.
[donnie@fedora log]$
在这种情况下,-n
是可选的。你可以通过以下命令得到相同的结果:
[donnie@fedora log]$ sudo head -5 boot.log
数字前面的破折号意味着该数字是一个选项,并不表示数字是负数。但正如你很快会看到的那样,某些命令要求你使用-n
。
你可以通过在命令中包含多个文件名,选择同时查看多个文件的前几行。在这里,我们查看的是boot.log
文件和cron
文件的前五行。
[donnie@fedora log]$ sudo head -n5 boot.log cron
[sudo] password for donnie:
==> boot.log <==
[ OK ] Finished logrotate.service - Rotate log files.
[FAILED] Failed to start vmware.ser… starts and stops VMware services.
See 'systemctl status vmware.service' for details.
Starting vmware-USBArbitra…s and stops the USB Arbitrator....
[ OK ] Started rsyslog.service - System Logging Service.
==> cron <==
Sep 9 17:36:05 fedora crond[2062]: (CRON) INFO (Shutting down)
Sep 11 15:27:58 fedora crond[2038]: (CRON) STARTUP (1.6.1)
Sep 11 15:27:58 fedora crond[2038]: (CRON) INFO (Syslog will be used instead of sendmail.)
Sep 11 15:27:58 fedora crond[2038]: (CRON) INFO (RANDOM_DELAY will be scaled with factor 85% if used.)
Sep 11 15:27:58 fedora crond[2038]: (CRON) INFO (running with inotify support)
[donnie@fedora log]$
使用-q
选项启用静默模式。这样,当你同时查看多个文件的行时,就不会看到文件的头部行。(如果你从脚本中运行head
,这可能会很有用。)
另外,请注意你可以使用一个破折号来组合选项。在这种情况下,如果你想设置要查看的行数,使用-n
是必须的。以下是它的样子:
[donnie@fedora log]$ sudo head -qn5 boot.log cron
[ OK ] Finished logrotate.service - Rotate log files.
[FAILED] Failed to start vmware.ser… starts and stops VMware services.
See 'systemctl status vmware.service' for details.
Starting vmware-USBArbitra…s and stops the USB Arbitrator....
[ OK ] Started rsyslog.service - System Logging Service.
Sep 9 17:36:05 fedora crond[2062]: (CRON) INFO (Shutting down)
Sep 11 15:27:58 fedora crond[2038]: (CRON) STARTUP (1.6.1)
Sep 11 15:27:58 fedora crond[2038]: (CRON) INFO (Syslog will be used instead of sendmail.)
Sep 11 15:27:58 fedora crond[2038]: (CRON) INFO (RANDOM_DELAY will be scaled with factor 85% if used.)
Sep 11 15:27:58 fedora crond[2038]: (CRON) INFO (running with inotify support)
[donnie@fedora log]$
使用-n
选项和负数,可以查看文件中除最后n
行之外的所有内容。如果你想查看boot.log
文件中除了最后 20 行之外的所有行,可以输入以下命令:
[donnie@fedora log]$ sudo head -n-20 boot.log
再次强调,在这种情况下,使用-n
是必须的。
你可以使用 -c
选项查看文件开头的字节数、千字节数或兆字节数。这里,我们查看的是 boot.log
文件的前 30 个字节:
[donnie@fedora log]$ sudo head -c30 boot.log
[ OK ] Finished [donnie@fedora log]$
你会发现 -c
选项有一个小怪癖。由于某些原因,换行命令没有发出,你的新命令提示符会与输出显示在同一行。
现在,我们通过在 2
后面加上一个 k
,来查看 boot.log
文件的前两个千字节,像这样:
[donnie@fedora log]$ sudo head -c2k boot.log
要查看文件的前两个兆字节,你只需在 2
后加上一个 m
,像这样:
[donnie@fedora log]$ sudo head -c2m boot.log
如果你在想为什么使用 c
来表示字节数,那是因为 c 代表 字符。一个字符正好是一个字节。因此,当你告诉 head
或 tail
你想查看多少字节时,实际上是在告诉它你想查看多少个字符。
这就是 head
的所有内容。接下来,让我们回到 tail
。
使用 tail
如你所猜测的,tail
允许你查看文件的最后几行。默认情况下,它会显示最后十行。让我们再看看 boot.log
文件:
[donnie@fedora log]$ sudo tail boot.log
[ OK ] Reached target remote-fs-p…eparation for Remote File Systems.
[ OK ] Reached target remote-fs.target - Remote File Systems.
Starting rpc-statd-notify.…- Notify NFS peers of a restart...
Starting systemd-user-sess…vice - Permit User Sessions...
[ OK ] Started rpc-statd-notify.s…m - Notify NFS peers of a restart.
[ OK ] Finished systemd-user-sess…ervice - Permit User Sessions.
[ OK ] Started atd.service - Deferred execution scheduler.
[ OK ] Started crond.service - Command Scheduler.
Starting plymouth-quit-wai… until boot process finishes up...
Starting plymouth-quit.ser… Terminate Plymouth Boot Screen...
[donnie@fedora log]$
要指定你想查看的行数或字节数,只需使用你为 head
使用的相同选项。不过,head
还有一些没有的选项。例如,如果你在数字前面加上一个 +
,你就可以从某一行开始显示内容。在这里,我决定查看从第 33 行到文件末尾的所有内容。
[donnie@fedora log]$ sudo tail -n+33 boot.log
请注意,-n
参数是必需的。
你可以使用相同的技巧,从文件中的两个千字节开始显示内容,像这样:
[donnie@fedora log]$ sudo tail -c+2k boot.log
如果你在想为什么使用 c
来表示字节数,那是因为 c 代表 字符。一个字符正好是一个字节。因此,当你告诉 head
或 tail
你想查看多少字节时,实际上是在告诉它你想查看多少个字符。
tail
的最后一个选项是 -f
选项,代表 follow(跟踪)。这个选项提供了一个实时更新的日志文件显示。例如,要查看我在 Fedora 机器上的 secure
日志文件的最后十行,并且随着新事件的添加而实时更新显示,我会输入:
[donnie@fedora log]$ sudo tail -f secure
当你完成时,只需按 Ctrl-c 退出。
如果你怀疑系统中可能发生了某些不良活动,这个功能可能会派上用场,因为它允许你不断监控安全事件的发生。或者,如果你需要进行故障排除,可以使用此选项来监控正常的系统日志。
接下来的技巧是一起使用 head
和 tail
。
使用 head
和 tail
一起
你已经看到如何使用 head
从文件的开头查看行,以及如何使用 tail
从文件的末尾查看行。
这些都没问题,但如果你想查看文件中间某个位置的选定行怎么办?这很简单。只需一起使用 head
和 tail
。下面是它的使用方法。
假设你想查看一个有 39 行的文件的第 11 到第 20 行,只需输入:
[donnie@fedora log]$ sudo head -n20 boot.log | tail
你可以使用nl
来证明你确实在查看第 11 到第 20 行,像这样:
[donnie@fedora log]$ sudo nl -ba boot.log | head -n20 | tail
11 [ OK ] Started rtkit-daemon.servi…timeKit Scheduling Policy Service.
12 [ OK ] Started avahi-daemon.service - Avahi mDNS/DNS-SD Stack.
13 [ OK ] Started abrtd.service - ABRT Daemon.
14 [ OK ] Started switcheroo-control… Switcheroo Control Proxy service.
15 [ OK ] Started abrt-journal-core.… ABRT coredumpctl message creator.
16 [ OK ] Started abrt-oops.service - ABRT kernel log watcher.
17 [ OK ] Started abrt-xorg.service - ABRT Xorg log watcher.
18 [ OK ] Started alsa-state.service…nd Card State (restore and store).
19 [ OK ] Reached target sound.target - Sound Card.
20 [ OK ] Started upower.service - Daemon for power management.
[donnie@fedora log]$
除了这个组合,你还可以做很多其他组合。(我再次强调,你的想象力是唯一的限制。)如果你想查看boot.log
的第 10 到第 15 行,输入以下内容:
[donnie@fedora log]$ sudo head -n15 boot.log | tail -n6
同样,你可以使用nl
来证明你正在查看正确的行,像这样:
[donnie@fedora log]$ sudo nl -ba boot.log | head -n15 | tail -n6
10 [ OK ] Started dbus-broker.service - D-Bus System Message Bus.
11 [ OK ] Started rtkit-daemon.servi…timeKit Scheduling Policy Service.
12 [ OK ] Started avahi-daemon.service - Avahi mDNS/DNS-SD Stack.
13 [ OK ] Started abrtd.service - ABRT Daemon.
14 [ OK ] Started switcheroo-control… Switcheroo Control Proxy service.
15 [ OK ] Started abrt-journal-core.… ABRT coredumpctl message creator.
[donnie@fedora log]$
这就是head
和tail
的用法。接下来,让我们通过做一些八进制转储来真正玩得开心。
使用od
八进制转储(od
)工具有很多选项,完全探究它们需要不少页数。但除非你是硬核程序员,否则你可能只会使用其中的少数选项。
如果你参加 Linux 认证考试,你可能会看到一两个关于od
的问题。但是,你可能不会看到任何涉及od
的深度问题。所以现在,我只介绍基础知识。
这个工具的名称有点误导。它默认情况下以八进制字节码的形式显示文件内容。但这并不是它的全部功能。通过使用适当的选项开关,你还可以使用od
以其他几种格式显示文件内容。od
通常用于显示二进制文件的内容,但你也可以用它来显示普通文本文件中的非打印字符。
你可以显示整个文件的内容,或者限制你想要显示的文件内容的数量。如果你选择显示整个文件,你可能希望将输出通过管道传递给less
,或者将输出重定向到一个新的文本文件。我们首先来看一下来自echo
二进制文件的od
输出的一部分:
[donnie@fedora ~]$ od /bin/echo
0000000 042577 043114 000402 000001 000000 000000 000000 000000
0000020 000003 000076 000001 000000 030020 000000 000000 000000
0000040 000100 000000 000000 000000 104450 000000 000000 000000
0000060 000000 000000 000100 000070 000015 000100 000040 000037
0000100 000006 000000 000004 000000 000100 000000 000000 000000
0000120 000100 000000 000000 000000 000100 000000 000000 000000
. . .
. . .
请注意,左侧列从零开始,并且从一行到下一行按 20(八进制)递增。可以将这一列视为地址列。这些地址可以用于标记并稍后找到文件中的某些特定数据。每一行的其余部分表示实际的数据。
要以其他格式查看文件,可以使用-t
开关并选择适当的选项。例如,你可以这样查看echo
二进制文件的十六进制格式:
[donnie@fedora ~]$ od -tx /bin/echo
0000000 464c457f 00010102 00000000 00000000
0000020 003e0003 00000001 00003010 00000000
0000040 00000040 00000000 00008928 00000000
0000060 00000000 00380040 0040000d 001f0020
0000100 00000006 00000004 00000040 00000000
0000120 00000040 00000000 00000040 00000000
. . .
. . .
请注意,第一列保持不变,只有其他列发生了变化。
如果你是普通用户,除了查看文本文件中的非打印字符之外,你可能不需要od
做其他事情。例如,你可能需要查找换行符或回车符。在这种情况下,你可以使用-tc
选项,查找前面带有反斜杠的字符。在我们即将查看的示例中,\n
代表换行符,\r
代表回车符。
我将使用一本名为《如何正确说话和写作》的 Project Gutenberg 电子书文件进行演示。你可以从这里下载:www.gutenberg.org/cache/epub/6409/pg6409.txt
或者,你也可以直接从 Github 仓库获取。无论哪种方式,文件名都将是pg6409.txt
。
现在,让我们看一下文件的前十行,像这样:
[donnie@fedora ~]$ od -tc pg6409.txt | head
0000000 357 273 277 T h e P r o j e c t G
0000020 u t e n b e r g e B o o k o
0000040 f H o w t o S p e a k a
0000060 n d W r i t e C o r r e c t
0000100 l y \r \n \r \n T h i s e
0000120 b o o k i s f o r t h e
0000140 u s e o f a n y o n e a n
0000160 y w h e r e i n t h e U n
0000200 i t e d S t a t e s a n d \r
0000220 \n m o s t o t h e r p a r t
[donnie@fedora ~]$
你会看到文件中有多个\n
和\r
字符。那么,这意味着什么呢?首先,它意味着这个文件是用基于 Windows 的文本编辑器创建的,比如记事本或写字板。出于某种奇怪的原因,Windows 文本编辑器在每一行的末尾都插入了换行符(\n
)和回车符(\r
)。
Unix 和 Linux 文本编辑器只会在每一行末尾插入一个换行符。如果你在 Unix 或 Linux 机器上,只是想读取一个包含回车符的文本文件,一切会正常工作。但如果你想在文本文件中搜索某些内容,回车符可能会导致搜索无法正确执行。
此外,Unix 和 Linux 操作系统如果遇到包含回车符的 shell 脚本或配置文件,无法正确读取它们。
你可以使用-ta
选项来查看不可打印字符的官方 ASCII 名称,像这样:
[donnie@fedora ~]$ od -ta pg6409.txt | head
0000000 o ; ? T h e sp P r o j e c t sp G
0000020 u t e n b e r g sp e B o o k sp o
0000040 f sp H o w sp t o sp S p e a k sp a
0000060 n d sp W r i t e sp C o r r e c t
0000100 l y cr nl sp sp sp sp cr nl T h i s sp e
0000120 b o o k sp i s sp f o r sp t h e sp
0000140 u s e sp o f sp a n y o n e sp a n
0000160 y w h e r e sp i n sp t h e sp U n
0000200 i t e d sp S t a t e s sp a n d cr
0000220 nl m o s t sp o t h e r sp p a r t
[donnie@fedora ~]$
这一次,换行符和回车符由nl
和cr
表示,而不是\n
和\r
。
如果你想从文件的某个位置开始显示,而不是从文件开头,使用-j
选项。例如,如果你想从地址0000640
开始查看,输入:
[donnie@fedora ~]$ od -ta -j0000640 pg6409.txt | head
0000640 e d sp S t a t e s , cr nl y o u sp
0000660 w i l l sp h a v e sp t o sp c h e
0000700 c k sp t h e sp l a w s sp o f sp t
0000720 h e sp c o u n t r y sp w h e r e
0000740 sp y o u sp a r e sp l o c a t e d
0000760 cr nl b e f o r e sp u s i n g sp t
0001000 h i s sp e B o o k . cr nl cr nl T i
0001020 t l e : sp H o w sp t o sp S p e a
0001040 k sp a n d sp W r i t e sp C o r r
0001060 e c t l y cr nl cr nl cr nl A u t h o
[donnie@fedora ~]
如果你只想读取文件的某个部分,可以使用-N
选项。这里,我正在读取我们文本文件的前0000640
字节:
[donnie@fedora ~]$ od -ta -N0000640 pg6409.txt
0000000 o ; ? T h e sp P r o j e c t sp G
0000020 u t e n b e r g sp e B o o k sp o
0000040 f sp H o w sp t o sp S p e a k sp a
. . .
. . .
0000600 sp y o u sp a r e sp n o t sp l o c
0000620 a t e d sp i n sp t h e sp U n i t
0000640
[donnie@fedora ~]$
最后,让我们将这两个选项结合起来。让我们从地址0000400
开始,读取0000640
字节的文件,如下所示:
[donnie@fedora ~]$ od -ta -j0000400 -N0000640 pg6409.txt
0000400 a w a y sp o r sp r e - u s e sp i
0000420 t sp u n d e r sp t h e sp t e r m
0000440 s cr nl o f sp t h e sp P r o j e c
. . .
. . .
0001160 0 0 4 sp [ e B o o k sp # 6 4 0 9
0001200 ] cr nl sp sp sp sp sp sp sp sp sp sp sp sp sp
0001220 sp sp sp M o s t sp r e c e n t l y
0001240
[donnie@fedora ~]$
请记住,地址列中的数字是八进制格式的。这就是为什么你需要在地址选项中使用类似0000640
的数字。如果你使用640
,od
将把它视为十进制数,并给出与预期不同的结果。
donnie@fedora:~$ od -ta -j400 -N640 pg6409.txt
0000620 a t e d sp i n sp t h e sp U n i t
0000640 e d sp S t a t e s , cr nl y o u sp
0000660 w i l l sp h a v e sp t o sp c h e
. . .
. . .
0001740 E sp W A T E R S cr nl cr nl cr nl cr nl
0001760 cr nl T H E sp C H R I S T I A N sp
0002000 H E R A L D cr nl B I B L E sp H O
0002020
donnie@fedora:~$
如你所见,这并不是你想要的。
这里有一个例子,展示如何使用od
。假设你有几个文本文件需要用cat
命令合并,但它们没有正确对齐。为了演示,创建一个文本文件,其中几行的开头有制表符,如下所示:
[donnie@fedora ~]$ cat > alignment_1.txt
This is line one.
This is line two.
This is line three.
This is line four.
This is line five.
[donnie@fedora ~]$
创建一个与之相同的第二个文件,只是去掉了制表符,如下所示:
[donnie@fedora ~]$ cat > alignment_2.txt
This is line one.
This is line two.
This is line three.
This is line four.
This is line five.
[donnie@fedora ~]$
现在,假设你不知道第一个文件中有制表符。此外,假设它是一个如此大的文件,你没有机会浏览它以注意到这些制表符。考虑到这一点,试着将这两个文件通过cat
命令合并,像这样:
[donnie@fedora ~]$ cat alignment_1.txt alignment_2.txt
This is line one.
This is line two.
This is line three.
This is line four.
This is line five.
This is line one.
This is line two.
This is line three.
This is line four.
This is line five.
[donnie@fedora ~]$
现在,你可能有些困惑,想知道为什么文件没有正确对齐。一个快速的办法是通过od
命令并使用-ta
选项,将输出结果传递管道,像这样:
[donnie@fedora ~]$ cat alignment_1.txt alignment_2.txt | od -ta
0000000 T h i s sp i s sp l i n e sp o n e
0000020 . nl T h i s sp i s sp l i n e sp t
0000040 w o . nl ht T h i s sp i s sp l i n
0000060 e sp t h r e e . nl ht ht T h i s sp
0000100 i s sp l i n e sp f o u r . nl ht ht
0000120 ht T h i s sp i s sp l i n e sp f i
0000140 v e . nl T h i s sp i s sp l i n e
0000160 sp o n e . nl T h i s sp i s sp l i
0000200 n e sp t w o . nl T h i s sp i s sp
0000220 l i n e sp t h r e e . nl T h i s
0000240 sp i s sp l i n e sp f o u r . nl T
0000260 h i s sp i s sp l i n e sp f i v e
0000300 . nl
[donnie@fedora ~]$
所有你看到的ht
实例表示硬制表符的存在,这为你提供了关于如何修正对齐问题的线索。你也可以分别使用od
命令查看每个文件,以找出需要编辑的地方。
还有更多od
选项可能对你有用。要查看它们,可以参考od
的手册页或od
的信息页面。
在实际应用中,你可以使用 od
来帮助排查 shell 脚本或 Linux/Unix 配置文件中的问题。记住,如果脚本或配置文件包含任何回车符,操作系统将无法读取它们。此外,近年来 Linux 操作系统开始更多地使用 .yaml 文件。除了回车符问题,.yaml
文件还要求每一行必须按照特定的方式缩进。使用 od
可以帮助确定每一行是否按照正确的制表符或空格数进行缩进。
接下来,让我们看看一些独特的不同之处。
使用 uniq
使用 uniq
工具处理包含连续重复行的文件。它的默认行为是只显示任何重复行中的一行。我们从创建 fruit.txt
文件开始,如下所示:
[donnie@fedora ~]$ cat > fruit.txt
Peach
Peach
peach
apricot
Apricot
peach
Apricot
[donnie@fedora ~]$
使用没有任何选项的 uniq
会得到如下结果:
[donnie@fedora ~]$ uniq fruit.txt
Peach
peach
apricot
Apricot
peach
Apricot
[donnie@fedora ~]$
这去除了那些在大小写上相同的重复行。但你仍然会有一些连续的单词,它们除了大小写不同外是相同的。使用 -i
开关可以让此操作不区分大小写,像这样:
[donnie@fedora ~]$ uniq -i fruit.txt
Peach
apricot
peach
Apricot
[donnie@fedora ~]$
在两个重复的对中,无论哪个单词在上面,它就会保留下来。在 Peach
对中,Peach
大写字母排在上面,因此它保留了下来。在 Apricot
对中,apricot
小写字母排在上面,因此它保留下来。
你可以使用 -c
开关获取有多少连续的重复行。将其与 -i
开关结合,可以使计数不区分大小写。操作方法如下:
[donnie@fedora ~]$ uniq -c fruit.txt
2 Peach
1 peach
1 apricot
1 Apricot
1 peach
1 Apricot
[donnie@fedora ~]$ uniq -ic fruit.txt
3 Peach
2 apricot
1 peach
1 Apricot
[donnie@fedora ~]$
-u
开关允许你仅显示没有重复的行。你还可以将其与 -i
开关结合使用,使其不区分大小写。如下所示:
[donnie@fedora ~]$ uniq -u fruit.txt
peach
apricot
Apricot
peach
Apricot
[donnie@fedora ~]$ uniq -iu fruit.txt
peach
Apricot
[donnie@fedora ~]$
使用 -d
开关可以显示每一行的一个副本,如果该行重复,并且不显示任何不重复的行。同样,你可以将其与 -i
开关结合使用,如下所示:
[donnie@fedora ~]$ uniq -d fruit.txt
Peach
[donnie@fedora ~]$ uniq -id fruit.txt
Peach
apricot
[donnie@fedora ~]$
你还可以让 uniq
对部分行而不是整行进行比较。为了演示这一点,创建一个包含两组语句的文件。其中一些语句仅在第一个词上有所不同,如下所示:
[donnie@fedora ~]$ cat > newfile.txt
The cat is sitting under the table.
Katelyn cat is sitting under the table.
Mr. Gray, the tomcat, is on the couch.
Mr. Gray, the tomcat, is on the couch.
Mister Gray, the tomcat, is on the couch.
[donnie@fedora ~]$
默认的 uniq
操作会得到如下结果:
[donnie@fedora ~]$ uniq newfile.txt
The cat is sitting under the table.
Katelyn cat is sitting under the table.
Mr. Gray, the tomcat, is on the couch.
Mister Gray, the tomcat, is on the couch.
[donnie@fedora ~]$
使用 -f
开关告诉 uniq
在比较时忽略字段。在这种情况下,字段是句子中的一个单词。-f
后面的数字告诉 uniq
忽略多少个字段。现在,让我们让 uniq
忽略第一个单词,如下所示:
[donnie@fedora ~]$ uniq -f1 newfile.txt
The cat is sitting under the table.
Mr. Gray, the tomcat, is on the couch.
[donnie@fedora ~]$
你可以以相同的方式使用 -c
开关,区别在于它会让 uniq
跳过一些字符,而不是跳过字段。如果行中有前导空格,这个功能可能会很有用。
如果你希望 uniq
在行末之前就停止比较,可以使用 -w
开关。-w
后面的数字告诉 uniq
在行的多少字符内进行比较。如果指定了 -c
或 -f
开关,计数将从满足 -c
或 -f
开关之后才开始。
为了演示这一点,再创建一个文本文件,内容如下:
[donnie@fedora ~]$ cat > newfile_2.txt
Mr. Gray is on the couch.
Mr. Gray is on the back of the couch.
Mister Gray is on the back of the couch.
[donnie@fedora ~]$
这次,我们希望比较从每个句子的第二个单词开始,到比较的第 14 个字符结束。以下是具体操作方法:
[donnie@fedora ~]$ uniq -f1 -w14 newfile_2.txt
Mr. Gray is on the couch.
[donnie@fedora ~]$
好的,到目前为止很简单,对吧?
在接下来的演示中,创建一对文件,以便你可以将join
和uniq
一起使用。首先,创建actorfile_9.txt
文件,内容如下所示:
[donnie@fedora ~]$ cat > actorfile_9.txt
Wayne John
Allen Gracie
Allen Fred
Price Vincent
Davis Bette
[donnie@fedora ~]$
接下来,创建actorfile_10.txt
文件,内容如下所示:
[donnie@fedora ~]$ cat > actorfile_10.txt
Wayne drama
Allen comedy
Allen comedy
Price horror
Davis drama
[donnie@fedora ~]$
将这两个文件合并,看看会发生什么:
[donnie@fedora ~]$ join actorfile_9.txt actorfile_10.txt
Wayne John drama
Allen Gracie comedy
Allen Gracie comedy
Allen Fred comedy
Allen Fred comedy
Price Vincent horror
Davis Bette drama
[donnie@fedora ~]$
注意到当关键字段在两行连续的行中相同时,join
会认为它必须在合并输出中列出每一行两次。你可以通过将输出通过uniq
管道处理来解决这个问题,如下所示:
[donnie@fedora ~]$ join actorfile_9.txt actorfile_10.txt | uniq
Wayne John drama
Allen Gracie comedy
Allen Fred comedy
Price Vincent horror
Davis Bette drama
[donnie@fedora ~]$
好的,uniq
部分就到这里。现在我们来做一些计数。
使用 wc
这个易于使用的工具为你提供了一种快速计数文本文件中行数、单词数和/或字节数的方法。因此,如果你正在编写一个只能包含特定单词数量的文档,你不必坐在那里手动数单词,随着文档在屏幕上滚动。直接使用wc
即可。
wc
的默认输出会显示行数、接着是单词数,然后是字节数。最后,它会显示输入文件的名称。以下是输出示例:
[donnie@fedora ~]$ wc actorfile_1.txt
5 10 67 actorfile_1.txt
[donnie@fedora ~]$
所以,actorfile_1.txt
文件包含五行、十个单词和 67 个字节。
如果你指定了多个文件,wc
会分别为每个文件提供信息,并显示所有文件的总数,像这样:
[donnie@fedora ~]$ wc actorfile_1.txt actorfile_2.txt macgruder.txt
5 10 67 actorfile_1.txt
5 10 77 actorfile_2.txt
19 77 510 macgruder.txt
29 97 654 total
[donnie@fedora ~]$
如果你只想查看文件中的行数,可以使用-l
(小写 L)选项。以下是输出示例:
[donnie@fedora ~]$ wc -l actorfile_1.txt
5 actorfile_1.txt
[donnie@fedora ~]$
-w
选项显示文件中的单词数,像这样:
[donnie@fedora ~]$ wc -w actorfile_1.txt
10 actorfile_1.txt
[donnie@fedora ~]$
使用-c
选项只显示字节数,如下所示:
[donnie@fedora ~]$ wc -c actorfile_1.txt
67 actorfile_1.txt
[donnie@fedora ~]$
要查看文件中字符的数量,使用-m
,如下所示:
[donnie@fedora ~]$ wc -m actorfile_1.txt
67 actorfile_1.txt
[donnie@fedora ~]$
理论上,-c
和-m
的输出应该是相同的,如上所示,因为一个字符的大小就是一个字节。然而,对于大型文本文件,输出可能会略有不同,正如你在这里看到的那样:
[donnie@fedora ~]$ wc -c pg6409.txt
282605 pg6409.txt
[donnie@fedora ~]$ wc -m pg6409.txt
282419 pg6409.txt
[donnie@fedora ~]$
我不知道为什么会这样,因为我从未找到任何相关的解释。
最后一个选项是-L
,它显示输入文件中最长行的长度,如下所示:
[donnie@fedora ~]$ wc -L pg6409.txt
87 pg6409.txt
[donnie@fedora ~]$
你可以将wc
与其他工具结合使用。假设你有一个包含许多重复行的文本文件。将wc
与uniq
结合使用,以查看删除空白行会如何影响你从古腾堡计划下载的pg6409.txt
文件的大小,如下所示:
[donnie@fedora ~]$ wc pg6409.txt
6019 45875 282605 pg6409.txt
[donnie@fedora ~]$ uniq pg6409.txt | wc
5785 45875 282137
[donnie@fedora ~]$
你会看到uniq
减少了行数,但单词数保持不变。这告诉我们所有重复的行都是空白行。字节数也减少了,因为即使是空格也算作字符,每个字符占一个字节。
如果你是系统管理员,可以使用 wc
来帮助你进行安全审计。如果你知道系统中应该授权多少用户,你可以定期检查 /etc/passwd
文件中的行数。(记住,每一行代表一个用户。)这样你就能很容易地发现是否有用户未经授权被添加。
好的,我们完成了计数。现在是时候进行一些格式化了。
使用 fmt
fmt
工具的工作原理是尝试使文本文件中所有非空行的长度相同。它的默认行为是将每行的目标长度设置为 75 个字符。不过,如果你的文件过窄或过宽,无法正确显示,你可以更改这个设置。唯一的问题是,它并不适用于所有文件。如果你的文件中有表格、索引或目录,fmt
可能会弄乱它们的格式。(这很讽刺,因为 fmt
代表的是 format 的缩写。)在这种情况下,你需要手动编辑它们,以使它们看起来正确。
我应该指出,为了使这些示例在虚拟机上运行,你需要将虚拟机窗口的宽度设置为比默认宽度更宽。你可以通过打开虚拟机的 视图 菜单,选择 缩放模式 选项来做到这一点。这样你就可以根据需要调整虚拟机窗口的大小。
另一种选择是直接从主机机器的终端远程登录到虚拟机。然后你可以根据需要调整终端窗口的大小。
我们首先来看一下你已经从 Project Gutenberg 网站下载的 pg6409.txt
电子书文件的一个摘录。以下是未格式化的摘录文件的样子:
图 7.2:未格式化文本
我决定这有点宽,所以我将使用 fmt
,它的默认目标设置为每行 75 个字符,如下所示:
图 7.3:使用 fmt 与默认宽度
它看起来还是有点宽。所以,我们使用 -w
开关将宽度缩小到 60 个字符,如下所示:
图 7.4:设置 60 个字符的宽度
再想想,咱们试试将行宽设置为 90 个字符,如下所示:
图 7.5:设置 90 个字符的宽度
-u
开关确保每个单词之间始终有一个空格,每个句子之间有两个空格,效果如下所示:
图 7.6:使用 -u 选项
如果你想让长行变短,但不想让短行变长,可以使用 -s
开关。(注意,你可以使用一个短划线来组合多个开关。)效果如下所示:
[donnie@fedora ~]$ fmt -sw60 excerpt.txt
It is the purpose of this book, as briefly and concisely
as possible, to
direct the reader along a straight course, pointing out
the mistakes he
must avoid and giving him such assistance as will enable
him to reach the
goal of a correct knowledge of the English language. It
is not a Grammar
in any sense, but a guide, a silent signal-post pointing
the way in the
right direction.
[donnie@fedora ~]$
是的,-s
在这个特定的文件中效果不太好,我自己也从来没找到过用它的地方。不过,这并不意味着你永远不会找到用它的情况。
一旦你最终决定喜欢看到的内容,使用重定向器将输出保存到新文件中,像这样:
[donnie@fedora ~]$ fmt -uw80 excerpt.txt > formatted_excerpt.txt
[donnie@fedora ~]$
既然我们已经做够了格式化,现在开始做一些分割吧。
使用 split
你可以使用 split
将一个大的文本文件分割成两个或更多较小的文件。默认情况下,它会将一个大文件分割成每个文件包含 1,000 行的小文件。(当然,最后一个文件可能会更小。)同样,默认情况下,这些新小文件的名称会是 xaa
、xab
、xac
等等。让我们开始看一下你从古腾堡计划下载的公共领域电子书文件的行数,像这样:
[donnie@fedora ~]$ wc -l pg6409.txt
6019 pg6409.txt
[donnie@fedora ~]$
由于该文件有 6,019 行,split
会将它分成六个包含 1,000 行的文件,以及一个仅包含 19 行的文件。它是这样工作的:
[donnie@fedora ~]$ split pg6409.txt
[donnie@fedora ~]$ ls -l x*
-rw-r--r--. 1 donnie donnie 38304 Sep 15 17:31 xaa
-rw-r--r--. 1 donnie donnie 48788 Sep 15 17:31 xab
-rw-r--r--. 1 donnie donnie 42676 Sep 15 17:31 xac
-rw-r--r--. 1 donnie donnie 42179 Sep 15 17:31 xad
-rw-r--r--. 1 donnie donnie 54845 Sep 15 17:31 xae
-rw-r--r--. 1 donnie donnie 55021 Sep 15 17:31 xaf
-rw-r--r--. 1 donnie donnie 792 Sep 15 17:31 xag
[donnie@fedora ~]$
为了验证这个方法,使用 wc
执行每个文件的行数统计:
[donnie@fedora ~]$ wc -l xaa xab xac xad xae xaf xag
1000 xaa
1000 xab
1000 xac
1000 xad
1000 xae
1000 xaf
19 xag
6019 total
[donnie@fedora ~]$
使用 -a
选项来改变新文件名的长度。以下命令将为文件名提供五个字符的后缀。
[donnie@fedora ~]$ split -a5 pg6409.txt
[donnie@fedora ~]$ ls -l xaaa*
-rw-r--r--. 1 donnie donnie 38304 Sep 15 17:37 xaaaaa
-rw-r--r--. 1 donnie donnie 48788 Sep 15 17:37 xaaaab
-rw-r--r--. 1 donnie donnie 42676 Sep 15 17:37 xaaaac
-rw-r--r--. 1 donnie donnie 42179 Sep 15 17:37 xaaaad
-rw-r--r--. 1 donnie donnie 54845 Sep 15 17:37 xaaaae
-rw-r--r--. 1 donnie donnie 55021 Sep 15 17:37 xaaaaf
-rw-r--r--. 1 donnie donnie 792 Sep 15 17:37 xaaaag
[donnie@fedora ~]$
你还可以将文件名的前缀从 x
改为你想要的任何内容。只需将所需的前缀添加到命令的末尾,像这样:
[donnie@fedora ~]$ split -a5 pg6409.txt pg6409.txt
[donnie@fedora ~]$ ls -l pg6409.txt*
-rw-r--r--. 1 donnie donnie 282605 Sep 13 17:39 pg6409.txt
-rw-r--r--. 1 donnie donnie 38304 Sep 15 17:40 pg6409.txtaaaaa
-rw-r--r--. 1 donnie donnie 48788 Sep 15 17:40 pg6409.txtaaaab
-rw-r--r--. 1 donnie donnie 42676 Sep 15 17:40 pg6409.txtaaaac
-rw-r--r--. 1 donnie donnie 42179 Sep 15 17:40 pg6409.txtaaaad
-rw-r--r--. 1 donnie donnie 54845 Sep 15 17:40 pg6409.txtaaaae
-rw-r--r--. 1 donnie donnie 55021 Sep 15 17:40 pg6409.txtaaaaf
-rw-r--r--. 1 donnie donnie 792 Sep 15 17:40 pg6409.txtaaaag
[donnie@fedora ~]$
如果你不想使用字母作为文件名的后缀,使用 -d
选项来使用数字前缀。(再一次,注意你可以将开关合并,只使用一个短横线。)像这样做:
[donnie@fedora ~]$ split -da5 pg6409.txt pg6409.txt
[donnie@fedora ~]$ ls -l pg6409.txt0000*
-rw-r--r--. 1 donnie donnie 38304 Sep 15 17:42 pg6409.txt00000
-rw-r--r--. 1 donnie donnie 48788 Sep 15 17:42 pg6409.txt00001
-rw-r--r--. 1 donnie donnie 42676 Sep 15 17:42 pg6409.txt00002
-rw-r--r--. 1 donnie donnie 42179 Sep 15 17:42 pg6409.txt00003
-rw-r--r--. 1 donnie donnie 54845 Sep 15 17:42 pg6409.txt00004
-rw-r--r--. 1 donnie donnie 55021 Sep 15 17:42 pg6409.txt00005
-rw-r--r--. 1 donnie donnie 792 Sep 15 17:42 pg6409.txt00006
[donnie@fedora ~]$
但是,如果 1,000 行对你的文件来说太长了怎么办?那就使用 -l
选项来将其改为其他值。在这里,我将每个文件设置为 400 行:
[donnie@fedora ~]$ split -da5 -l400 pg6409.txt pg6409.txt
[donnie@fedora ~]$
请注意,每当你使用两个或更多需要数字选项的选项开关时,你必须为每个选项使用单独的短横线。
你可以使用 -b
选项来创建特定字节大小的文件。在这里,我将文件分割成每个 900 字节的小块:
[donnie@fedora ~]$ split -da5 -b900 pg6409.txt pg6409.txt
[donnie@fedora ~]$
在数字选项后面加上 k
或 m
,你就可以指定千字节或兆字节,而不是默认的字节。在这个例子中,我假装回到过去,我必须将文件分成两部分,这样我才能将它存储在两个老式的 180-Kbyte 软盘上:
[donnie@fedora ~]$ split -da5 -b180k pg6409.txt pg6409.txt
[donnie@fedora ~]$ ls -l pg6409.txt000*
-rw-r--r--. 1 donnie donnie 184320 Sep 15 17:55 pg6409.txt00000
-rw-r--r--. 1 donnie donnie 98285 Sep 15 17:55 pg6409.txt00001
[donnie@fedora ~]$
我要结束 split
的介绍了,我要做一个告白,以便卸下我的心灵负担。正如我在前一段中提到的,split
的最初目的是分割那些太大,无法存储在单一老式软盘上的文本文件。
在现代,我们拥有多达几个 TB 容量的硬盘和固态硬盘,以及容量达到几个 GB 的 USB 闪存盘。所以,你可能会发现 split
已经不像以前那样有用了。但如果你打算参加 Linux 认证考试,还是要记住它,因为你很可能会遇到一些关于它的问题。
好了,我们已经做够了分割,现在开始做一些翻译吧。
使用 tr
你可以使用 tr
来处理各种转换任务。(毕竟,tr
代表 translate)。tr
不像从一种语言翻译到另一种语言,而是将一个字符转换为另一个字符,或将一组字符转换为另一组字符,或者将一种字符类别转换为另一种字符类别。你还可以从文件中删除选定的字符或消除重复字符。
与我们到目前为止所查看的其他工具相比,tr
的操作方式有很大的不同。我们到目前为止查看的其他工具可以通过你在命令行中提供的参数来获取输入。因此,使用这些工具时,你不需要使用 stdin
重定向器。tr
工具无法使用参数,因此你必须使用 stdin
重定向器,或者通过管道从另一个命令传递输入。
对于第一个示例,创建文件 translation.txt
,并使用 tr
更改每个字符的出现方式。让文件看起来像这样:
[donnie@fedora ~]$ cat > translation.txt
Let's translate all of the a's into A's.
[donnie@fedora ~]$
现在,让我们像这样进行实际转换:
[donnie@fedora ~]$ tr 'a' 'A' < translation.txt
Let's trAnslAte All of the A's into A's.
[donnie@fedora ~]$
在 tr
后,我将两个文本字符串放在单引号内。第一个字符串表示我要查找并更改的内容,第二个字符串表示我们希望第一个字符串变成什么样。在这个例子中,我希望将所有的小写字母 a 转换为大写字母 A。
你可以通过列出字符或指定字符范围来转换多个字符。在下一个示例中,我选择将 a 转换为 A,将 l 转换为 L。
[donnie@fedora ~]$ cat > translation_2.txt
Let's translate all of the a's into A's
and all of the l's into L's.
[donnie@fedora ~]$
[donnie@fedora ~]$ tr 'al' 'AL' < translation_2.txt
Let's trAnsLAte ALL of the A's into A's
And ALL of the L's into L's.
[donnie@fedora ~]$
使用短横线分隔字符来指定字符范围,例如:
[donnie@fedora ~]$ cat > translation_3.txt
Let's now convert everything from
a through l to A through L.
[donnie@fedora ~]$
[donnie@fedora ~]$ tr 'a-l' 'A-L' < translation_3.txt
LEt's now ConvErt EvErytHInG From
A tHrouGH L to A tHrouGH L.
[donnie@fedora ~]$
你可以指定多个范围,如下所示:
[donnie@fedora ~]$ cat > translation_4.txt
Let's now convert everything from
a through l to A through L, and
everything from u through z to
U through Z.
[donnie@fedora ~]$
[donnie@fedora ~]$ tr 'a-lu-z' 'A-LU-Z' < translation_4.txt
LEt's noW ConVErt EVErYtHInG From
A tHroUGH L to A tHroUGH L, AnD
EVErYtHInG From U tHroUGH Z to
U tHroUGH Z.
[donnie@fedora ~]$
其他类型的转换也是可能的。在这里,我将字母 a 到 e 转换为数字 1 到 5:
[donnie@fedora ~]$ cat > translation_5.txt
Let's now convert a through e to
1 through 5.
Are we ready?
[donnie@fedora ~]$
[donnie@fedora ~]$ tr 'a-e' '1-5' < translation_5.txt
L5t's now 3onv5rt 1 through 5 to
1 through 5.
Ar5 w5 r514y?
[donnie@fedora ~]$
与许多这些工具一样,tr
提供了多种方法来执行某些操作。例如,要将文件中的所有小写字母转换为大写字母,你可以在命令中指定范围‘a-z’和‘A-Z’,如下所示:
[donnie@fedora ~]$ cat > translation_6.txt
Let's now convert all lower-case letters
into upper-case letters.
[donnie@fedora ~]$
[donnie@fedora ~]$ tr 'a-z' 'A-Z' < translation_6.txt
LET'S NOW CONVERT ALL LOWER-CASE LETTERS
INTO UPPER-CASE LETTERS.
[donnie@fedora ~]$
你也可以使用字符类来执行此转换。字符类 由字符类型的名称组成,并置于方括号和冒号字符之间。例如,小写字母的字符类表示为 [:lower:]
,而大写字母的字符类表示为 [:upper:]
。因此,上述命令可以这样输入:
[donnie@fedora ~]$ tr [:lower:] [:upper:] < translation_6.txt
LET'S NOW CONVERT ALL LOWER-CASE LETTERS
INTO UPPER-CASE LETTERS.
[donnie@fedora ~]$
这是其余字符类别的表格。
类别名称 | 含义 |
---|---|
[:alnum:] |
字母和数字。(换句话说,字母数字混合。) |
[:alpha:] |
字母 |
[:blank:] |
空格 |
[:cntrl:] |
控制字符 |
[:digit:] |
数字 |
[:graph:] |
所有可打印字符,但不包括空格 |
[:lower:] |
所有小写字母 |
[:print:] |
所有可打印字符,包括空格 |
[:punct:] |
所有标点符号字符 |
[:space:] |
垂直或水平空白字符 |
[:upper:] |
所有大写字母 |
[:xdigit:] |
十六进制数字 |
表 7.1:字符类
这看起来很简单,对吧?但不幸的是,这里有一些小问题。看看当我尝试将数字转换为十六进制数字时会发生什么:
[donnie@fedora ~]$ tr [:digit:] [:xdigit:] < numbers.txt
tr: when translating, the only character classes that may appear in
string2 are 'upper' and 'lower'
[donnie@fedora ~]$
这个难题的答案在tr
的手册页中,其中提到“在转换时,[:lower:]
和[:upper:]
可以成对使用来指定大小写转换”。当然,它并没有明确说只有[:lower:]
和[:upper:]
可以成对使用,但这是它的含义。然而,你可以在其他类型的tr
操作中使用这些字符类,稍后我们会讨论到。
如果你想从文本流中删除某些字符,使用-d
选项。在这个例子中,我将删除文本中的所有元音字母。
注意,在使用-d
选项时,你只需要指定一个文本字符串或字符类。
下面是这种情况的效果:
[donnie@fedora ~]$ cat > translation_7.txt
I will now show you how to delete
all vowels from a text stream. Are
you really ready for this?
[donnie@fedora ~]$
[donnie@fedora ~]$ tr -d 'aeiouAEIOU' < translation_7.txt
wll nw shw y hw t dlt
ll vwls frm txt strm. r
y rlly rdy fr ths?
[donnie@fedora ~]$
添加-c
选项后,你可以让tr
操作所有内容,除了你指定的部分。在这里,我将删除所有辅音字母、标点符号、空格和换行符。也就是说,我将删除所有不是元音的字符。准备好,你将看到的效果非常神奇:
[donnie@fedora ~]$ cat > translation_8.txt
I'll now show you how to delete
everything except for vowels from
a text stream.
[donnie@fedora ~]$
[donnie@fedora ~]$ tr -dc 'aeiouAEIOU' < translation_8.txt
Iooouooeeeeeieeooeoaeea[donnie@fedora ~]$
当我说这将删除除元音字母以外的所有内容时,我确实是指所有内容,包括不可见的换行符。这也解释了为什么所有元音字母都在同一行,以及为什么命令提示符现在与输出在同一行。
你可以通过指定字符类,而不是字符或字符范围,来做同样的事情。在这个例子中,我正在删除除小写字母之外的所有内容:
[donnie@fedora ~]$ tr -dc [:lower:] < translation_9.txt
histimewellremoveeverythingthatalowercaseletter[donnie@fedora ~]$
这次,让我们去除所有不可打印的控制字符,像这样:
[donnie@fedora ~]$ cat > translation_10.txt
Now, let's remove
all of the
control characters.
[donnie@fedora ~]$
[donnie@fedora ~]$ tr -d [:cntrl:] < translation_10.txt
Now, let's removeall of thecontrol characters.[donnie@fedora ~]$
当然,这个文件中唯一的控制字符是换行符,但没关系。如果你有一个文件,也包含了其他类型的控制字符,而你只想删除所有换行符,只需这样做:
[donnie@fedora ~]$ cat > translation_11.txt
Now,
I just want to
delete the
newline characters.
[donnie@fedora ~]$
[donnie@fedora ~]$ tr -d '\n' < translation_11.txt
Now,I just want todelete the newline characters.[donnie@fedora ~]$
如果你有连续出现的某个字符或字符类,可以使用-s
选项将它们替换为单个出现。下面是这种情况的示例:
[donnie@fedora ~]$ cat > translation_12.txt
Take a look at the poor
yellow dog. The poor little
pooch isn't as peppy as he
used to be.
[donnie@fedora ~]$
[donnie@fedora ~]$ tr -s [:alpha:] < translation_12.txt
Take a lok at the por
yelow dog. The por litle
poch isn't as pepy as he
used to be.
[donnie@fedora ~]$
如果你指定了两个字符串,并使用了-s
选项,tr
会首先将第一个字符串中的字符替换成第二个字符串中的字符。然后,它会挤压出连续出现的字符。如前所述,准备好惊讶吧,你将看到的效果是这样的:
[donnie@fedora ~]$ cat > translation_13.txt
tennessee
[donnie@fedora ~]$
[donnie@fedora ~]$ tr -s 'tnse' 'srne' < translation_13.txt
serene
[donnie@fedora ~]$
所以,我把tennessee变成了serene。(下次派对上可以用这个技巧,给气氛带来一点活力。)
你可以将tr
与其他文本流工具结合使用,像这样:
[donnie@fedora ~]$ sort autos.txt | tr [:lower:] [:upper:] | head
BMW 325I 1985 115 450
BMW 325I 1985 60 1000
CHEVY IMPALA 1985 85 1550
CHEVY MALIBU 1999 50 3500
CHEVY MALIBU 2000 60 3000
EDSEL CORSAIR 1958 47 750
FORD EXPLORER 2003 25 9500
FORD GALAXIE 1964 128 60
FORD MUSTANG 1965 45 10000
FORD TAURUS 2004 10 17000
[donnie@fedora ~]$
这是tr
的一个更实用的例子。假设你得到了一个列式数据的文本文件,需要将其格式化以便打印。但这些列之间都是用一个空格分开的,导致一切都没有正确对齐,就像你在这里看到的:
[donnie@fedora ~]$ cat > spaces.txt
one two
three four
five six
seven eight
nine ten
[donnie@fedora ~]$ cat spaces.txt
one two
three four
five six
seven eight
nine ten
[donnie@fedora ~]$
你不能使用unexpand
来将空格替换为制表符,因为你需要至少两个空格才能让unexpand
起作用。
在这种情况下,你可以使用tr
将空格替换为制表符。(注意,单引号之间有一个空格。所以,是的,你可以引号包括空格。)
[donnie@fedora ~]$ tr ' ' '\t' < spaces.txt
one two
three four
five six
seven eight
nine ten
[donnie@fedora ~]$
即使你在列之间有多个空格,unexpand
仍然可能不是最好的选择。在这里,我在列之间放了两个空格:
[donnie@fedora ~]$ cat > spaces_2.txt
one two
three four
five six
seven eight
nine ten
[donnie@fedora ~]$
我会使用unexpand
和-t2
选项,这样它只需要两个空格就能表示一个制表符,就像这样:
[donnie@fedora ~]$ unexpand -t2 spaces_2.txt
one two
three four
five six
seven eight
nine ten
[donnie@fedora ~]$
这样稍微好一点了,但看起来还是有点凌乱。所以,我会使用tr
将空格替换为制表符,像这样:
[donnie@fedora ~]$ tr ' ' '\t' < spaces_2.txt
one two
three four
five six
seven eight
nine ten
[donnie@fedora ~]$
更好一点吧?但是,由于原来列之间有两个空格,所以现在列之间有两个制表符。我只想要一个制表符在列之间,所以我会使用-s
选项来去除多余的制表符,就像这样:
[donnie@fedora ~]$ tr -s ' ' '\t' < spaces_2.txt
one two
three four
five six
seven eight
nine ten
[donnie@fedora ~]$
对于最后一个例子,考虑一下我之前给你看的 Project Gutenberg 文件。记得它包含回车符,表示它是在 Windows 电脑上创建的。它看起来像这样:
[donnie@fedora ~]$ od -c pg6409.txt | head
0000000 357 273 277 T h e P r o j e c t G
0000020 u t e n b e r g e B o o k o
0000040 f H o w t o S p e a k a
0000060 n d W r i t e C o r r e c t
0000100 l y \r \n \r \n T h i s e
0000120 b o o k i s f o r t h e
0000140 u s e o f a n y o n e a n
0000160 y w h e r e i n t h e U n
0000200 i t e d S t a t e s a n d \r
0000220 \n m o s t o t h e r p a r t
[donnie@fedora ~]$
正如我之前指出的,每个\r
表示一个回车符。现在,我们假设这是一个 Linux 配置文件,需要去除回车符,以便 Linux 能够正确读取它。将输出保存到一个新文件中,像这样:
[donnie@fedora ~]$ tr -d '\r' < pg6409.txt > pg6409_stripped.txt
[donnie@fedora ~]$
成功了吗?我们来看看:
[donnie@fedora ~]$ od -c pg6409_stripped.txt | head
0000000 357 273 277 T h e P r o j e c t G
0000020 u t e n b e r g e B o o k o
0000040 f H o w t o S p e a k a
0000060 n d W r i t e C o r r e c t
0000100 l y \n \n T h i s e b o
0000120 o k i s f o r t h e u s
0000140 e o f a n y o n e a n y w
0000160 h e r e i n t h e U n i t
0000200 e d S t a t e s a n d \n m o
0000220 s t o t h e r p a r t s o
[donnie@fedora ~]$
我没有看到任何回车符,所以它工作得很好。
实际上,大多数人只会使用dos2unix
工具来去除回车符。但如果你打算参加任何 Linux 认证考试,你可能也想知道如何使用tr
来完成这项工作。
我们接下来要看的工具会让你想要像海盗一样说话。
使用 xargs
我写这篇文章的时机恰到好处,因为今天是国际海盗日,而当你正确地发音这个工具的名字时,真的听起来像是海盗会说的那样。所以,现在大家一起用你最好的海盗语气来念一下……
Xaaaaarrrrrgs!
好了,够了,别再胡闹了。我们开始工作吧。
说真的,xargs
是一个非常方便的工具,可以以几种不同的方式使用。不过,由于当前的话题是文本流过滤器,我们暂时只在这个上下文中讨论它。稍后,我会向你展示它的其他用法。
xargs
本身不能独立工作,它总是与其他工具一起使用。它的目的是从一个源获取输出,并将其用作另一个命令的参数。它的工作方式有点像find
命令的-exec
选项,但也有一些不同之处。也就是说,xargs
不仅可以与find
一起使用,还可以与其他工具结合使用,它有更多的选项,且效率更高。我们来看几个例子。
如果你想对多个文件进行排序,你可以在调用sort
时将它们都列出。为了演示这一点,让我们回顾一下我之前让你创建的actorfile_1.txt
文件和actorfile_6.txt
文件:
[donnie@fedora ~]$ cat actorfile_1.txt
Benny Jack
Allen Fred
Ladd Alan
Hitchcock Alfred
Robinson Edgar
[donnie@fedora ~]$ cat actorfile_6.txt
MacLaine Shirley
Booth Shirley
Stanwick Barbara
Allen Gracie
[donnie@fedora ~]$
现在,我们把它们一起排序,就像这样:
[donnie@fedora ~]$ sort actorfile_1.txt actorfile_6.txt
Allen Fred
Allen Gracie
Benny Jack
Booth Shirley
Hitchcock Alfred
Ladd Alan
MacLaine Shirley
Robinson Edgar
Stanwick Barbara
[donnie@fedora ~]$
如果你只有两个文件需要排序,这个方法很好用。但如果你有一堆文件需要排序呢?而且,如果你需要定期更新这些文件并在每次更新后重新排序该怎么办?在sort
命令中列出每个文件很快就会变得繁琐且不方便。为了简化工作,你可以编写一个包含所有文件名的 shell 脚本,或者使用xargs
配合所有文件的列表。由于我们还没有学习如何编写 shell 脚本,让我们先看看xargs
。
首先,创建一个包含你要排序的文件列表的文件,如下所示:
[donnie@fedora ~]$ cat > xargs_sort.txt
actorfile_1.txt actorfile_6.txt
[donnie@fedora ~]$
如你所见,所有文件名可以都放在同一行,之间只用一个空格分开。或者,如果你愿意,你也可以将每个文件名放在单独的一行。无论哪种方式,都能正常工作。
现在,使用xargs
和sort
读取文件列表,然后对实际文件进行排序,如下所示:
[donnie@fedora ~]$ xargs sort < xargs_sort.txt
Allen Fred
Allen Gracie
Benny Jack
Booth Shirley
Hitchcock Alfred
Ladd Alan
MacLaine Shirley
Robinson Edgar
Stanwick Barbara
[donnie@fedora ~]$
如我所说,如果你有很多需要频繁更新并排序的文件,这个方法非常方便。只需建立一个需要排序的文件列表,这样你就不必每次都输入所有文件的名称。
现在,让我们创建一个包含名称列表的文件,并使用xargs
将列表传递给echo
命令,如下所示:
[donnie@fedora ~]$ cat > howdy.txt
Jack,
Jane,
Joe,
and John.
[donnie@fedora ~]$
[donnie@fedora ~]$ xargs echo "Howdy" < howdy.txt
Howdy Jack, Jane, Joe, and John.
[donnie@fedora ~]$
看起来挺酷的吧?(我知道你一定很想在下次聚会上展示这个技巧。)不过,还有更多内容。
你可以使用-i
选项和一组大括号,将参数放置在输出字符串中的任何位置。
请注意,为了确保这个示例能够正常工作,每个名字必须单独占一行输入文件。这是因为-i
也会导致命令对输入文件中的每一行调用一次。
无论如何,下面是它的工作原理:
[donnie@fedora ~]$ cat > xargs_test.txt
Frank
Goldie
Vicky
[donnie@fedora ~]$
[donnie@fedora ~]$ xargs -i echo "Howdy {}. Are you busy for lunch?" < xargs_test.txt
Howdy Frank. Are you busy for lunch?
Howdy Goldie. Are you busy for lunch?
Howdy Vicky. Are you busy for lunch?
[donnie@fedora ~]$
如果我们使用-n num
选项,我们可以告诉xargs
在每num
行输入后执行相关的命令一次。
[donnie@fedora ~]$ cat howdy.txt
Jack,
Jane,
Joe,
and John.
[donnie@fedora ~]$
[donnie@fedora ~]$ xargs -n1 echo "Howdy" < howdy.txt
Howdy Jack,
Howdy Jane,
Howdy Joe,
Howdy and
Howdy John.
[donnie@fedora ~]$
那个方法没有奏效,所以让我们试试-n2
,看看是否有差别。
[donnie@fedora ~]$ xargs -n2 echo "Howdy" < howdy.txt
Howdy Jack, Jane,
Howdy Joe, and
Howdy John.
[donnie@fedora ~]$
看起来好多了。
最后,我们可以使用-l num
选项来确定输出的行数。在这里,我们创建了两行输出。
[donnie@fedora ~]$ cat howdy.txt
Jack,
Jane,
Joe,
and John.
[donnie@fedora ~]$
[donnie@fedora ~]$ xargs -l2 echo "Howdy" < howdy.txt
Howdy Jack, Jane,
Howdy Joe, and John.
[donnie@fedora ~]$
目前就这些。xargs
还有其他用法,我稍后会详细介绍。接下来,让我们为你重要的文本文件做一些最后的润色。
使用 pr
你一直在努力工作,使用各种文本流过滤器从文本文件中提取有意义的数据,并生成格式化报告。现在,是时候通过将工作提交到纸面上来回报你的辛勤付出了。当然,你可以在不使用pr
的情况下打印文件。但是,为了更专业的外观,pr
可以为你提供完美的修饰。
使用pr
,你可以轻松地将文件准备好进行打印,通过将它们分成几页,并添加标题和页码。在这方面,它比使用常规文本编辑器更好。实际上,它几乎像是使用一个小型文字处理器。它还提供了其他文本编辑器无法提供的格式化选项。(稍后你会看到一些例子。)
一旦你使用 pr
完成了最终格式化,可以通过将 pr
的输出通过管道传递给 lpr
实用程序,或通过将文件名作为参数调用 lpr
来直接从命令行打印。(稍后我会告诉你更多关于 lpr
的信息。)你需要知道如何操作,原因有几个。
如果你使用没有安装 GUI 的 Linux 或 Unix 服务器,你需要知道如何从命令行打印,因为文本模式下的文本编辑器不包含打印功能。而且,如果你知道如何从命令行打印,你就能设置批处理作业,一次打印多个文件,并通过设置 cron
作业或 systemd
定时器来自动化打印。这在你需要定期打印日志文件或报告时非常有用。
默认情况下,pr
会将文件拆分为每页 66 行的单倍行距页面。它还会在每页的页眉中放入文件的最后修改日期和时间、文件名以及页码,后面跟着空白行的页脚。然而,使用适当的选项开关,可以更改所有这些设置。让我们先看一个 pr
默认行为的示例。以下是来自公共领域电子书《如何正确说话和写作》中的一段摘录:
图 7.7:使用默认设置的 pr
由于书籍格式问题,我无法向你展示的是,在最后一行文本之后有很多空白行。这是因为 pr
识别到这段简短的摘录并没有填满页面。
你可以使用 -h
选项将文件名替换为你选择的页眉,如下所示:
图 7.8:使用 pr 设置自定义页眉
如果你不需要在输出中插入页眉,但仍然需要使用 pr
的其他功能,可以使用 -t
开关来省略页眉,如下所示:
图 7.9:使用 -t 选项
-d
选项将使输出内容双倍行距:
图 7.10:使用 -d 选项
使用 -o
选项和一个数字来设置左边距。(该数字代表左边距缩进的空格数。)在这里,我设置了一个八个空格的边距:
图 7.11:设置左边距
正如我之前所说,默认的页面长度为 66 行。你可以使用 -l
选项来更改这一设置。若要设置页面长度为 80 行,请输入:
[donnie@fedora ~]$ pr -l80 excerpt_2.txt
默认情况下,pr
通过在每页的末尾插入多个换行符来分隔页面。要改为插入单个分页符,可以使用 -f
选项。如果需要插入行号,则使用 -n
选项。在这里,我将这两个选项合并成一个短横线:
图 7.12:使用分页符并添加行号
要处理一系列页面,只需在第一个页面号前面加一个 +
,并在最后一个页面号前面加上冒号。这里,我选择查看 pg6409.txt
文件的第 10 页到第 12 页:
[donnie@fedora ~]$ pr +10:12 pg6409.txt
2023-09-13 17:39 pg6409.txt Page 10
_Thou_, _He_, _She_, and _It_, with their plurals, _We_, _Ye_ or _You_
and _They_.
. . .
. . .
[donnie@fedora ~]$
如果省略最后一个页面号,你将看到从第一页到文件结尾的所有内容。这里,我查看的是从 103 页到文件结尾的内容:
[donnie@fedora ~]$ pr +103 pg6409.txt
2023-09-13 17:39 pg6409.txt Page 103
1.A. By reading or using any part of this Project Gutenberg™
electronic work, you indicate that you have read, understand, agree to
. . .
. . .
[donnie@fedora ~]$
我把列和合并选项放到最后,因为它们是最难使用的。
列选项允许你将文本输出为多个列。通过使用一个-
符号后跟一个数字来设置它。诀窍是,你首先需要格式化你的文本,使其不至于过宽,无法适应列。以下是如果你尝试将我们的示例文本作为两列页面输出时的结果:
图 7.13:使用两列的第一次尝试
你可以看到这并不太有用,因为有太多内容被省略了。处理这个问题的最简单方法是使用fmt
更改行宽,然后将输出管道传递给pr
,像这样:
图 7.14:结合 fmt 和 pr
当然,你可能需要反复调整行宽,直到达到想要的效果。
你会在合并选项中遇到相同的问题。在这里,我使用-m
开关来并排显示两个文件:
图 7.15:第一次尝试合并两个文件
再次,你会发现这些行太宽,无法在两列中正确显示。处理这个问题的最佳方法是使用fmt
缩短行宽,但将输出保存到两个中间文件中。然后,使用pr
合并这两个新文件。下面是这个过程的样子:
[donnie@fedora ~]$ fmt -w35 excerpt.txt > excerpt_fmt.txt
[donnie@fedora ~]$ fmt -w35 excerpt_2.txt > excerpt_2_fmt.txt
图 7.16:合并两个格式化的文件
一旦你在屏幕上看起来一切正常,你就可以准备将 pr
输出重定向到一个新的文本文件,然后打印它。
从命令行打印
正如我之前提到的几次,确实可以从命令行打印文本文件。(实际上,你还可以从命令行打印 PostScript 文件、.pdf
文件、图像文件以及其他一些类型的文档文件。但现在,我们只关注文本文件的打印。)为此,你的 Linux、Unix 或类 Unix 机器需要安装以下两项内容:
-
常见的 Unix 打印软件 (CUPS):这通常在 Linux 桌面版本中默认安装,但在 Linux 文本模式服务器版本中未安装。
-
打印机的正确驱动程序:CUPS 包括了许多现成的打印机驱动程序。然而,您的特定打印机可能不包括在内。如果是这种情况,您需要从打印机制造商处获取正确的驱动程序,并按照他们的安装说明进行安装。
安装 CUPS 通常很容易,因为它已经包含在大多数 Linux 发行版的仓库中。在任何类型的 Red Hat 风格机器上,如 Fedora、AlmaLinux、Rocky Linux、Oracle Linux 或 RHEL,只需执行:
sudo dnf install cups
在 Debian 或其任何衍生版本中,你需要安装两个软件包。要安装它们,只需执行:
sudo apt install cups cups-bsd
接下来,你需要找出 CUPS 是否包含你的打印机驱动程序。在 Red Hat 类型的系统上,可以使用lpinfo -m
命令,在 Debian 类型的系统上,可以使用sudo lpinfo -m
命令。
如果你想滚动查看整个列表,可以将输出管道传输到less
。或者,如果你想查找特定打印机制造商,可以将输出管道传输到grep
,这看起来像这样:
donnie@debian:~$ sudo lpinfo -m | grep -i brother
donnie@debian:~$
所以,Debian 版的 CUPS 并没有包含任何 Brother 打印机的驱动程序。但是,正如你在这里看到的,我的 Fedora 工作站上的 CUPS 却有相当多的 Brother 打印机驱动程序可供选择:
[donnie@fedora ~]$ lpinfo -m | grep -i brother
gutenprint.5.3://brother-dcp-1200/expert Brother DCP-1200 - CUPS+Gutenprint v5.3.4
gutenprint.5.3://brother-dcp-1200/simple Brother DCP-1200 - CUPS+Gutenprint v5.3.4 Simplified
gutenprint.5.3://brother-dcp-8045d/expert Brother DCP-8045D - CUPS+Gutenprint v5.3.4
gutenprint.5.3://brother-dcp-8045d/simple Brother DCP-8045D - CUPS+Gutenprint v5.3.4 Simplified
. . .
. . .
gutenprint.5.3://brother-mfc-9600/expert Brother MFC-9600 - CUPS+Gutenprint v5.3.4
gutenprint.5.3://brother-mfc-9600/simple Brother MFC-9600 - CUPS+Gutenprint v5.3.4 Simplified
MFC7460DN.ppd Brother MFC7460DN for CUPS
lsb/usr/MFC7460DN.ppd Brother MFC7460DN for CUPS
[donnie@fedora ~]$
唯一的问题是,Fedora 工作站没有为我的 Brother MFC7460DN 打印机提供驱动程序,所以我不得不从 Brother 官网上下载驱动并自行安装。(我安装的驱动程序在这个列表中的最后两个项目。)lpstat -p -d
命令可以显示我的打印机状态,正如你在这里看到的:
[donnie@fedora ~]$ lpstat -p -d
printer MFC7460DN is idle. enabled since Tue 19 Sep 2023 06:17:03 PM EDT
system default destination: MFC7460DN
[donnie@fedora ~]$
很棒。它已经准备好了。
一旦你设置好一切,就可以使用lp
或lpr
进行打印。(这两个是不同的工具,但它们执行的是相同的操作,只是选项开关不同。为了简化,我将只展示lpr
。)要打印到特定的打印机,可以像这样操作:
[donnie@fedora ~]$ lpr -P MFC7460DN somefile.txt
-P
选项将打印作业定向到指定的打印机。如果你不想每次都指定打印机,可以像这样设置默认打印机:
[donnie@localhost ~]$ lpoptions -d MFC7460DN
(注意,这在 Debian 类型的系统上需要sudo
权限。)
一旦你指定了默认打印机,就可以像这样执行打印作业:
[donnie@fedora ~]$ lpr somefile.txt
如果你有多个文件需要打印,不必发出多个lpr
命令。相反,只需将你要打印的文件名放入一个单独的文本文件中,然后使用xargs
读取列表。这样看起来会像这样:
[donnie@fedora ~]$ xargs lpr < print_list.txt
[donnie@fedora ~]$
很简单,对吧?事实上,你可以使用lp
或lpr
命令进行更多打印选项,不过现在我们就先从基础开始。要查看完整的lp
和lpr
打印教程,最好的方法是打开 Linux 桌面机器上的网页浏览器,然后导航到http://localhost:631
。除了教程,你还会看到可以通过网页界面执行某些管理功能。
好的,我想这一章就到这里了。让我们总结一下并继续前进。
总结
在本章中,我向你展示了更多的工具,你可以使用它们将文本文件格式化为你或你的雇主可能需要的样式。最后,在章节结尾,我展示了在文本模式服务器上设置打印机的基本步骤。
这一切的美妙之处在于,您可以创建 shell 脚本,这些脚本会自动从各种来源获取信息,创建格式正确的文本文件,然后打印它们。您甚至可以通过创建 cron
作业或 systemd
定时器来让脚本自动运行。
哦,我还没给你展示如何创建 shell 脚本,对吧?没关系,因为这将是下一章的内容。我们到时候见。
问题
-
以下哪两个命令可以用来检查一个文本文件是否包含回车符?(选择两个。)
-
od -tx filename.txt
-
od -tc filename.txt
-
od -ta filename.txt
-
od -td filename.txt
-
-
您已经创建了一个包含其他文本文件列表的文本文件。现在,您想要对列表中的所有文件进行排序,并将输出保存到一个新文件中。您会使用以下哪个命令?
-
sort sort_list.txt > combined_sorted.txt
-
xargs sort sort_list.txt > combined_sorted.txt
-
xargs sort < sort_list.txt > combined_sorted.txt
-
sort < sort_list.txt > combined_sorted.txt
-
-
以下哪个命令可以为文本文件中的所有行添加行号?
-
nl -a file.txt
-
nl file.txt
-
nl -bn file.txt
-
nl -ba file.txt
-
-
在准备打印文本文件的最后一步中,您最可能使用哪个工具?
-
fmt
-
pr
-
lp
-
lpr
-
-
以下哪个命令可以用来验证您的打印机驱动程序是否已安装在计算机上?
-
lpinfo -m
-
lpstat -p -d
-
lpr -i
-
lp -i
-
进一步阅读
-
nl 命令在 Linux 中的示例:
linuxconfig.org/nl
-
Linux 中的 head 命令(5 个必备示例):
linuxhandbook.com/head-command/
-
使用 od 查看文本格式:
bash-prompt.net/guides/od/
-
wc 命令在 Linux 中的示例:
www.geeksforgeeks.org/wc-command-linux-examples/
-
Linux 中的 tr 命令及示例:
linuxize.com/post/linux-tr-command/
-
如何使用 Linux tr 命令:
www.howtogeek.com/886723/how-to-use-the-linux-tr-command/
-
如何在 Linux 中使用 xargs 命令:
www.howtogeek.com/435164/how-to-use-the-xargs-command-on-linux/
-
我如何使用 Linux fmt 命令格式化文本:
opensource.com/article/22/7/fmt-trivial-text-formatter
-
pr 命令在 Linux 中:
www.geeksforgeeks.org/pr-command-in-linux/
-
精通 Linux “pr” 命令:全面指南:
hopeness.medium.com/master-the-linux-pr-command-a-comprehensive-guide-b166865c933e
答案
-
b 和 c
-
c
-
d
-
b
-
a
加入我们在 Discord 上的社区!
与其他用户、Linux 专家以及作者本人一起阅读本书。
提问、为其他读者提供解决方案、通过“问我任何问题”环节与作者交流,等等。扫描二维码或访问链接加入社区。
第八章:基本 Shell 脚本构建
是的,我知道,你迫不及待想开始编写一些 shell 脚本,但还没有机会做到。所以,在本章中,我们将学习 shell 脚本构建的基础知识。然后,我们将通过一些既实用又有用的 shell 脚本来总结内容。
本章中我介绍的许多技巧适用于任何 shell,但我也会介绍一些可能仅适用于bash
的技巧。所以,为了简化起见,我现在将继续使用bash
。在第二十二章,使用 zsh Shell中,我会向你展示一些特定于zsh
的技巧。在第十九章,Shell 脚本移植性中,我会向你展示一些可以在各种 shell 上工作的技巧。
本章中将涉及的主题包括:
-
理解基本的 shell 脚本构建
-
执行测试
-
理解子 shell
-
理解脚本变量
-
理解数组变量
-
理解变量扩展
-
理解命令替换
-
理解决策和循环
-
理解位置参数
-
理解退出码
-
更多关于 echo 的信息
-
查看一些真实世界的例子
好的,如果你准备好了,我们就开始吧。
技术要求
使用任何已安装bash
的 Linux 发行版。如果你是 Mac 用户,你可能需要使用其中一个 Linux 虚拟机,因为一些脚本使用的命令在 macOS 上无法运行。你可以在本地机器上跟随本章的内容,但也要知道,我将提供一些实际的动手实验。
我还包含了一个使用 FreeBSD 虚拟机的动手练习。创建 FreeBSD 虚拟机并安装sudo
和bash
,就像我在本书的前言中所展示的那样。
另外,正如我在前言中所解释的,你可以通过以下方式从 Github 仓库下载脚本:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
理解基本的 Shell 脚本构建
创建 shell 脚本时,你首先需要做的就是定义你希望使用的 shell 来解释脚本。
你可能有选择某个 shell 而非另一个的特定原因。这是我们将在第十九章,Shell 脚本移植性和第二十二章,使用 Z Shell中讨论的内容。
你将在shebang 行中定义要作为解释器使用的 shell,它是脚本的第一行。它的形式类似于这样:
#!/bin/bash
通常情况下,以#
符号开头的行表示注释,shell 会忽略这些行。而 shebang 行——请不要问我为什么它叫这个名字——是这个规则的例外。除了定义你想使用的特定 shell,比如/bin/bash
或/bin/zsh
,你还可以定义通用的/bin/sh
shell,使得你的脚本更加便于移植,从而能在更广泛的 shell 和操作系统上运行。它是这样显示的:
#!/bin/sh
这种通用的 sh
shell 旨在让你可以在不同的系统上运行你的脚本,这些系统可能会或可能不会安装 bash
。但它也有问题,因为 sh
代表的各种 shell 之间并不完全兼容。下面是它的工作原理:
-
在 FreeBSD 和可能的其他 伯克利软件分发(Berkeley Software Distribution,简称 BSD) 类型的系统中,
sh
可执行文件是旧版 Bourne shell,它是 Bourne Again Shell(bash
)的前身。 -
在 Red Hat 类型的系统中,
sh
是指向bash
可执行文件的符号链接。请注意,bash
可以使用其他 shell 无法使用的某些编程功能。(我将在第十九章,Shell 脚本的可移植性中进一步解释这一点。) -
在 Debian/Ubuntu 类型的系统中,
sh
是指向dash
可执行文件的符号链接。dash
代表Debian Almquist Shell,它是bash
的一种更快、更轻量的实现。 -
在 Alpine Linux 中,
sh
是指向ash
的符号链接,ash
是busybox
可执行文件的一部分,是一个轻量级的 shell。(在 Alpine 中,bash
默认并未安装。) -
在 OpenIndiana 中,这是 Oracle 的 Solaris 操作系统的自由开源软件(Free Open Source Software,简称 FOSS) 版本,
sh
是指向ksh93
shell 的符号链接。这个 shell,也被称为 Korn shell,与bash
有一定但不完全的兼容性。(它是由名为 David Korn 的人创建的,与任何蔬菜无关。) -
在 macOS 中,
sh
是指向bash
的符号链接。(有趣的是,zsh
是 macOS 默认的登录 shell,但bash
仍然是默认安装的,并且可以使用。)
要注意,使用 #!/bin/sh
在你的脚本中可能会有问题。因为不同操作系统上 #!/bin/sh
代表的各种 shell 之间并不完全兼容。举个例子,假设你在一个 Red Hat 机器上创建脚本,在该机器上 sh
指向 bash
。那么,这个脚本在 Debian 或 FreeBSD 机器上可能无法运行,因为在 Debian 上 sh
指向 dash
,在 FreeBSD 上 sh
指向 Bourne shell。由于这个原因,我们现在将专注于 bash
,并使用 #!/bin/bash
作为我们的 shebang 行。如我之前所提到的,我们将在第十九章,Shell 脚本的可移植性中详细讨论这个话题。
一个 shell 脚本可以根据你的需求简单或复杂。它可以只是将一个或多个普通的 Linux/Unix 命令按顺序放在列表中执行。或者,你也可以编写接近高级编程语言(如 C)复杂度的脚本。
让我们先来看一个非常简单的单命令脚本。
#!/bin/bash
rsync -avhe ssh /var/www/html/course/images/ root@192.168.0.22:/var/www/html/course/images/
这是我以前用来将我的 images
目录从一台计算机备份到 Debian 计算机的备份目录的一个简单一行脚本。它使用 rsync
,并带有适当的选项,通过安全 Shell (ssh
) 会话同步这两个目录。(虽然我通常不喜欢允许 root 用户进行 ssh
登录,但在这种情况下它是必须的。显然,我只会在本地网络中进行此操作,绝不会在互联网上进行。)为了适当起见,我将脚本命名为 rsync_with_debian
。在我运行脚本之前,我需要添加可执行权限,方法如下:
chmod u+x rsync_with_debian
在 rsync_with_debian
脚本的第二行,也就是 shebang 行之后,你会看到与我如果没有脚本而直接在命令行输入的命令完全相同。可以看出,通过创建脚本,我大大简化了操作。
为了让所有系统用户都能使用这个脚本,需将它放在 /usr/local/bin/
目录下,这个目录应该在每个人的 PATH
设置中。
在继续之前,让我们通过一个动手实验来巩固你刚刚学到的内容。
实验 – 统计已登录的用户
这个实验将帮助你创建一个 Shell 脚本,显示当前登录的用户数量,然后修改它,使其只列出唯一的用户。(这个脚本使用了你在前两章中学到的一些文本流过滤器。)
-
在你的某一台 Linux 虚拟机上,创建三个额外的用户账户。在你的 Fedora 虚拟机上,按以下方式操作,只需选择你自己的用户名:
sudo useradd vicky sudo passwd vicky
在 Debian 虚拟机上,按如下方式操作:
sudo adduser vicky
-
在虚拟机的本地终端上,获取其 IP 地址,方法如下:
ip a
-
在你的主机上打开四个终端窗口。使用你虚拟机的 IP 地址,从一个窗口登录到自己的账户,然后从其他窗口登录到其他账户。命令如下所示:
ssh vicky@192.168.0.9
-
从你登录的终端窗口中,查看所有当前登录的用户,方法如下:
who
你应该能看到五个用户,因为你的账户会显示一次本地终端登录的信息,并且显示一次远程 ssh
登录的信息。
-
创建
logged-in.sh
脚本,并使其如下所示:#!/bin/bash users="$(who | wc -l)" echo "There are currently $users users logged in."
我使用了 命令替换 的概念,将 who | wc -l
命令的输出赋值给 users
脚本变量。(稍后我会详细讲解命令替换,所以现在不必担心它。)
-
让脚本具有可执行权限,方法如下:
chmod u+x logged-in.sh
-
现在,运行脚本,方法如下:
./logged-in.sh
输出应如下所示:
There are currently 5 users logged in.
这里的问题是,实际上只有四个用户,因为你自己的用户名被计算了两次。所以,让我们来解决这个问题。
-
修改
logged-in.sh
脚本,使其现在看起来如下:#!/bin/bash users="$(who | wc -l)" echo "There are currently $users users logged in." echo uniqusers="$(who | cut -d" " -f1 | sort | uniq | wc -l)" echo "There are currently $uniqusers unique users logged in."
变量uniqusers
是通过所有通过管道传递到彼此之间的命令创建的。cut
操作通过空格(-d" "
)分隔,并且第一个字段(-f1
)是从who
命令输出中切割出来的内容。该输出被传递到sort
,然后再传递到uniq
,这将只将唯一用户的名称传递给wc -l
命令。
-
再次运行脚本,输出应该像这样:
There are currently 5 users logged in. There are currently 4 unique users logged in.
-
对脚本做最后的修改,以列出登录用户的唯一名称。完成的脚本将如下所示:
#!/bin/bash users="$(who | wc -l)" echo "There are currently $users users logged in." echo uniqusers="$(who | cut -d" " -f1 | sort | uniq | wc -l)" echo "There are currently $uniqusers unique users logged in." echo listusers="$(who | cut -d" " -f1 | sort | uniq)" echo "These users are currently logged in: \n$listusers "
-
再次运行脚本,你应该会得到类似这样的输出:
There are currently 5 users logged in. There are currently 4 unique users logged in. These users are currently logged in: cleopatra donnie frank vicky
恭喜!你刚刚创建了你的第一个 Shell 脚本。现在,让我们进行一些测试。
执行测试
当你的脚本需要在做出行动决定之前测试某个条件时,会有一些情况。你可能需要检查某个文件或目录是否存在,文件或目录上是否设置了特定的权限,或者其他各种情况。有三种执行测试的方法,分别是:
-
使用关键字
test
,后跟一个测试条件,并用&&
或||
结构将另一个命令与其连接。 -
将测试条件括在一对方括号中。
-
使用
if...then
结构
让我们首先来看一下test
关键字。
使用test
关键字
对于第一个例子,让我们测试某个目录是否存在,如果不存在则创建它。操作如下:
[donnie@fedora ~]$ test -d graphics || mkdir graphics
[donnie@fedora ~]$ ls -ld graphics/
drwxr-xr-x. 1 donnie donnie 0 Sep 26 15:41 graphics/
[donnie@fedora ~]$
现在,让我们将其放入test_graphics.sh
脚本中:
#!/bin/bash
cd
pwd
test -d graphics || mkdir graphics
cd graphics
pwd
让我们运行这个脚本,看看结果如何:
[donnie@fedora ~]$ ./test_graphics.sh
/home/donnie
/home/donnie/graphics
[donnie@fedora ~]$
正如你可能已经猜到的,-d
操作符代表目录。||
结构会在graphics
目录不存在时执行mkdir
命令。当然,如果目录已经存在,它将不会被重新创建。这是一个很好的安全措施,可以防止你不小心覆盖现有的文件或目录。(稍后我会给你展示一个包含更多test
操作符的表格。)
现在,让我们来看第二种执行测试的方法。
将测试条件括在方括号中
执行测试的第二种方法是将测试条件括在一对方括号中,如下所示:
[ -d graphics ]
首先,注意在第一个括号后和第二个括号前必须有一个空格。这就像test -d
结构一样,测试graphics
目录是否存在。现在,让我们把它放到test_graphics_2.sh
脚本中,如下所示:
#!/bin/bash
cd
pwd
[ -d graphics ] || mkdir graphics
cd graphics
pwd
运行这个脚本将产生与第一个脚本完全相同的输出。现在,让我们来点不同的。修改test_graphics_2.sh
脚本,使其看起来像这样:
#!/bin/bash
cd
pwd
[ ! -d graphics ] && mkdir graphics
cd graphics
pwd
!
是一个否定运算符,它使受影响的运算符做相反的事情。在这种情况下,!
使-d
运算符检查graphics
目录的不存在。为了使其正确工作,我还需要将||
运算符改为&&
运算符。(另外,请注意!
和-d
之间必须有一个空格。)
你还可以测试数值,如下所示:
[ $var -eq 0 ]
你看到我正在调用变量var
的值,并测试它是否等于(-eq
)0。与其在这里使用否定(!
)来判断变量是否不等于 0,不如使用-ne
运算符。我们来看一下test_var.sh
脚本中是如何表现的:
#!/bin/bash
var1=0
var2=1
[ $var1 -eq 0 ] && echo "$var1 is equal to zero."
[ $var2 -ne 0 ] && echo "$var2 is not equal to zero."
现在,让我们来运行一下:
[donnie@fedora ~]$ ./test_var.sh
0 is equal to zero.
1 is not equal to zero.
[donnie@fedora ~]$
执行测试的第三种方式是使用if...then
结构,我们接下来会简要讲解。
使用if...then
结构
使用if...then
结构在你有更复杂的测试条件时非常有用。这里是一个最基本的例子,以test_graphics_3.sh
脚本的形式:
#!/bin/bash
cd
pwd
if [ ! -d graphics ]; then
mkdir graphics
fi
cd graphics
pwd
这个结构以if
语句开始,以fi
语句结束。(这就是if
倒写过来的样子。)在测试条件后加上分号,然后跟上关键字then
。之后,指定你想执行的操作,在这个例子中是mkdir graphics
。尽管在 Shell 脚本中不像在其他语言中那样强制缩进操作块,但缩进确实有助于提高脚本的可读性。
当然,if...then
结构不仅仅是这样,还有更多内容。不过不用担心,因为在理解决策和循环部分,我会给你展示更多内容。在我们到达那里之前,我想先给你展示一些可以用来美化if...then
结构的其他概念。
现在,让我们来看一下你可以进行的其他各种测试。
使用其他类型的测试
你可以进行更多种类的测试,包括文本字符串比较、数值比较、文件或目录是否存在以及它们上面设置了哪些权限。以下是更常用测试及其运算符的图表:
运算符 | 测试 |
---|---|
-b filename |
如果指定的文件名是块设备文件,则为真。 |
-c filename |
如果指定的文件名是字符设备文件,则为真。 |
-d directory_name |
如果指定的目录名称存在,则为真。 |
-e filename |
如果指定的文件名存在任意类型的文件,则为真。 |
-f filename |
如果指定的文件名是常规文件,则为真。 |
-g filename |
如果文件或目录具有 SGID 权限,则为真。 |
-G filename |
如果文件存在且属于有效的组 ID,则为真。 |
-h filename |
如果文件存在且是符号链接,则为真。 |
-k filename |
如果文件或目录存在且设置了粘滞位,则为真。 |
-L filename |
如果文件存在且是符号链接。(这与-h 相同。) |
-p filename |
如果文件存在并且是一个命名管道,则为真 |
-O filename |
如果文件存在并且属于有效用户 ID,则为真 |
-r filename |
如果文件存在并且是可读的,则为真 |
-S filename |
如果文件存在并且是一个套接字,则为真 |
-s filename |
如果文件存在且大小大于零字节,则为真 |
-u filename |
如果文件存在并且设置了 SUID 位,则为真 |
-w filename |
如果文件存在并且是可写的,则为真 |
-x filename |
如果文件存在并且是可执行文件,则为真 |
file1 -nt file2 |
如果 file1 比 file2 更新,则为真 |
file1 -ot file2 |
如果 file1 比 file2 旧,则为真 |
-z string |
如果文本字符串的长度为 0,则为真 |
-n string |
如果文本字符串的长度不为 0,则为真 |
string1 == string2 |
如果两个文本字符串相同,则为真 |
string1 != string2 |
如果两个文本字符串不相同,则为真 |
string1 < string2 |
如果 string1 在字母顺序上排在 string2 前面,则为真 |
string1 > string2 |
如果 string1 在字母顺序上排在 string2 后面,则为真 |
integer1 -eq integer2 |
如果两个整数相等,则为真 |
integer1 -ne integer2 |
如果两个整数不相等,则为真 |
integer1 -lt integer2 |
如果 integer1 小于 integer2 ,则为真 |
integer1 -gt integer2 |
如果 integer1 大于 integer2 ,则为真 |
integer1 -le integer2 |
如果 integer1 小于或等于 integer2 ,则为真 |
integer1 -ge integer2 |
如果 integer1 大于或等于 integer2 ,则为真 |
-o optionname |
如果启用了某个 shell 选项,则为真 |
我知道,这信息量挺大的,不是吗?但没关系。如果你不想记住所有这些内容,只需随时参考这张表格即可。
接下来,让我们来谈谈子 Shell。
理解子 Shell
当你使用 [ $var -ne 0 ]
构造执行测试时,该测试会调用一个 子 Shell。为了防止测试调用子 Shell,使用以下构造:
[[ $var -ne 0 ]]
这可以使你的脚本运行得更高效,这对你的特定脚本可能是一个大问题,也可能不是。
这种 [[. . .]]
类型的构造在执行需要匹配模式与正则表达式的测试时也非常必要。 (在 [. . .]
构造中无法使用正则表达式进行匹配。)
这个 [[. . .]]
构造的缺点是你不能在某些非 bash
的 Shell 中使用它,例如 dash
、ash
或 Bourne
。 (你将在 第十九章,Shell 脚本的可移植性 中看到这一点。)
当然,你现在可能还不知道什么是正则表达式,但没关系。第九章,使用 grep、sed 和正则表达式过滤文本 会详细介绍它们。
无论如何,你总是可以尝试在使用子 Shell 和不使用子 Shell 的情况下运行你的脚本,看看哪种方式对你更有效。
我们现在已经看过了执行测试的三种方法。那么,不妨来做一些实践,进行一次实验室操作?
实验室操作 – 测试条件
对于这一步,从 Github 仓库下载tests-test.sh
脚本。(由于书籍格式的考虑,这个脚本比较长,我无法在这里重现。)在文本编辑器中打开脚本,并检查它是如何构建的。你首先会看到它在检查myfile.txt
文件是否存在,如下所示:
#!/bin/bash
[ -f myfile.txt ] && echo "This file exists." || echo "This file does not exist."
之后,你将看到创建文件的命令(如果文件不存在),如下所示:
echo "We will now create myfile.txt if it does not exist, and make it with only read permissions for $USER."
[ -f myfile.txt ] || touch myfile.txt
接下来,你将看到命令将400
设置为权限设置,这意味着用户有权限读取文件,但没有人有权限写入文件。然后,我们想验证所有写权限是否已被移除。如下所示:
chmod 400 myfile.txt
ls -l myfile.txt
echo
echo "We will now see if myfile.txt is writable."
[ -w myfile.txt ] && echo "This file is writable." || echo "This file is not writable."
在进行了更多权限设置操作并进行了测试后,你将看到这个代码段,它测试一个目录是否存在,如果不存在则创建它:
[ -d somedir ] || echo "somedir does not exist."
[ -d somedir ] || mkdir somedir && echo "somedir has just been created."
ls -ld somedir
最终你将看到一个代码段,测试noclobber
选项,设置它,然后再次进行测试。
审查完脚本后,运行它看看会发生什么。
为了额外积分,重新键入脚本到你自己的脚本文件中。为什么?因为一个小秘密是,如果你自己键入代码,它会帮助你更好地理解这些概念。
对于下一步,创建tests-test_2.sh
脚本,内容如下:
#!/bin/bash
echo "We will now compare text strings."
string1="abcd"
string2="efgh"
[[ $string1 > $string2 ]] && echo "string1 comes after string2 alphabetically." || echo "string1 comes before string2 alphabetically."
echo
echo "We will now compare numbers."
num1=10
num2=9
[[ $num1 -gt $num2 ]] && echo "num1 is greater than num2." || echo "num1 is less than num2."
使脚本文件可执行,然后运行它查看结果。更改变量string1
、string2
、num1
和num2
的值,然后再次运行脚本并查看结果。
实验结束。
接下来,让我们更详细地了解变量。
理解脚本变量
我已经跟你简要介绍了脚本变量,并且你已经看到了它们的使用。但这个故事还有一些内容。
创建和删除变量
正如你已经看到的,有时在脚本中定义变量是必要的,或者更为方便。你也可以从命令行定义、查看和取消设置变量。以下是一个示例:
[donnie@fedora ~]$ car=Ford
[donnie@fedora ~]$ echo $car
Ford
[donnie@fedora ~]$ unset car
[donnie@fedora ~]$ echo $car
[donnie@fedora ~]$
在这里,我定义了变量car
,并将其值设置为Ford
。第一个echo
命令显示分配的值。第二个echo
命令验证我是否成功使用unset
清除了该变量。
理解变量和 Shell 级别
当你在脚本开头放置一个 shebang 行,如#!/bin/bash
或#!/bin/sh
时,每次运行脚本时都会启动一个新的非交互式子 shell。脚本结束后,子 shell 会终止。子 shell 会继承父 shell 导出的任何变量,但父 shell 不会继承子 shell 的任何变量。为了演示这一点,我们在父 shell 中将car
变量设置为Volkswagen
,如下所示:
[donnie@fedora ~]$ export car="Volkswagen"
[donnie@fedora ~]$ echo $car
Volkswagen
[donnie@fedora ~]$
接下来,创建car_demo.sh
脚本,如下所示:
#!/bin/bash
echo \$car is set to $car
export car=Toyota
echo "The $car is very fast."
echo \$car is set to $car
使文件可执行,然后运行脚本。输出应该如下所示:
[donnie@fedora ~]$ ./car_demo.sh
$car is set to Volkswagen
The Toyota is very fast.
$car is set to Toyota
[donnie@fedora ~]$
注意car
的Volkswagen
值是如何从父 shell 继承过来的。这是因为我使用了export
命令,确保这个值对脚本调用的子 shell 可用。让我们再试一次,不过这次我不会导出变量:
[donnie@fedora ~]$ unset car
[donnie@fedora ~]$ car=Studebaker
[donnie@fedora ~]$ ./car_demo.sh
$car is set to
The Toyota is very fast.
$car is set to Toyota
[donnie@fedora ~]$
为了让这工作,我首先需要取消设置car
变量。除了清除car
的值,它还清除了 export。当我这次运行脚本时,脚本无法找到我在父 shell 中设置的car
的值。
那么,这为什么重要呢?问题在于,有时候你可能会编写调用另一个脚本的脚本,而这将有效地打开另一个子 shell。如果你希望变量对子 shell 可用,你必须导出它们。
理解大小写敏感性
变量名称是区分大小写的。所以,名为car
的变量与名为Car
或CAR
的变量是完全不同的。
环境变量的名称是全大写字母,而编程变量的名称最好使用全小写字母或混合大小写字母。令人惊讶的是,Linux 和 Unix 的 shell 并没有强制要求编程变量遵循这个规则。但是,这是最佳实践,因为它有助于防止你不小心覆盖掉重要的环境变量的值。
令人遗憾的是,网上确实有一些 shell 脚本教程,作者让你创建全大写字母的编程变量名。实际上,我刚刚就看到一个这样的教程。在大多数情况下,作者让你创建的变量名不会与任何环境变量冲突。然而在某个地方,作者让你创建USER
变量并为其赋值。当然,USER
已经是一个环境变量的名字,因此将新值赋给它会覆盖它应有的值。这里的教训是,网上有很多优秀的教程,但也有很多提供错误信息的教程。
理解只读变量
你刚刚看到,当你以普通方式声明变量时,可以取消设置它或者给它赋予一个新值。你也可以将一个变量设置为只读,这样就无法重新定义或取消设置该变量。下面是它的工作原理:
[donnie@fedora ~]$ car=Nash
[donnie@fedora ~]$ echo $car
Nash
[donnie@fedora ~]$ readonly car
[donnie@fedora ~]$ unset car
bash: unset: car: cannot unset: readonly variable
[donnie@fedora ~]$ car=Hudson
bash: car: readonly variable
[donnie@fedora ~]$
设置只读属性后,我改变或删除car
的唯一方法就是关闭终端窗口。
好吧,如果你没有 root 用户权限,这是你唯一能删除只读变量的方法。如果你有 root 用户权限,你可以使用GNU bash 调试器(gbd
)来删除它。但这超出了本章的范围。(我们将在第二十一章,调试 Shell 脚本中讨论gbd
。)
好的,如果你只需要定义一些单独的变量,这一切都很棒。但如果你需要一个完整的变量列表呢?那么,这时候数组就派上用场了。接下来我们来看看数组。
理解数组变量
数组允许你将一个列表收集到一个变量中。创建数组变量的简单方法是为其某个索引分配一个值,如下所示:
name[index]=value
这里,name
是数组的名称,index
是数组中项的位置。(请注意,index 必须是数字。)value
是为该数组项设置的值。
数组的编号系统从 0 开始。所以,name[0]
就是数组中的第一个项。要创建一个有索引的数组,使用带有-a
选项的declare
命令,如下所示:
[donnie@fedora ~]$ declare -a myarray
[donnie@fedora ~]$
接下来,我们创建将被插入数组中的列表,如下所示:
[donnie@fedora ~]$ myarray=(item1 item2 item3 )
[donnie@fedora ~]$
你可以查看数组中任意单个项的值,但有一种特殊的方式来做到这一点。它的样子如下:
[donnie@fedora ~]$ echo ${myarray[0]}
item1
[donnie@fedora ~]$ echo ${myarray[1]}
item2
[donnie@fedora ~]$ echo ${myarray[2]}
item3
[donnie@fedora ~]$
注意我如何将myarray[x]
结构用一对大括号包围起来。
要查看数组项的完整列表,可以用*
或@
代替索引数字,如下所示:
[donnie@fedora ~]$ echo ${myarray[*]}
item1 item2 item3
[donnie@fedora ~]$ echo ${myarray[@]}
item1 item2 item3
[donnie@fedora ~]$
要仅仅计算数组项的数量,在数组名称前插入一个#
,像这样:
[donnie@fedora ~]$ echo ${#myarray[@]}
3
[donnie@fedora ~]$ echo ${#myarray[*]}
3
[donnie@fedora ~]$
好的,这就是数组的基础知识。让我们通过一个实际操作实验来做一些更有实用性的东西。
实操实验 – 使用数组
-
要查看数组是如何构建的,创建一个
ip.sh
脚本,内容如下:#!/bin/bash echo "IP Addresses of intruder attempts" declare -a ip ip=( 192.168.3.78 192.168.3.4 192.168.3.9 ) echo "ip[0] is ${ip[0]}, the first item in the list." echo "ip[2] is ${ip[2]}, the third item in the list." echo "*****************************" echo "The most dangerous intruder is ${ip[1]}, which is in ip[1]." echo "*****************************" echo "Here is the entire list of IP addresses in the array." echo ${ip[*]}
-
使文件可执行并运行它。
chmod u+x ip.sh ./ip.sh
-
创建
/opt/scripts/
目录,用于存储你的脚本需要访问的数据文件,像这样:sudo mkdir /opt/scripts
-
在
/opt/scripts/
目录下,创建banned.txt
文件。(请注意,在该目录下,打开文本编辑器时需要使用sudo
。)将以下内容添加到文件中:192.168.0.48 24.190.78.101 38.101.148.126 41.206.45.202 58.0.0.0/8 59.107.0.0/17 59.108.0.0/15 59.110.0.0/15 59.151.0.0/17 59.155.0.0/16 59.172.0.0/15
-
在你自己的主目录下,创建
attackers.sh
脚本,它将根据文本文件中的列表构建一个被禁止的 IP 地址数组。将以下内容添加到文件中:#!/bin/bash badips=$(cat /opt/scripts/banned.txt) declare -a attackers attackers=( $badips ) echo "Here is the complete list: " echo ${attackers[@]} echo echo "Let us now count the items in the list." num_attackers=${#attackers[*]} echo "There are $elements IP addresses in the list." echo echo "attackers[2] is ${attackers[2]}, which is the third address in the list." exit
-
设置可执行权限并运行脚本,如下所示:
chmod u+x attackers.sh ./attackers.sh
-
修改脚本,使得索引为 0、5 和 8 的元素被打印到屏幕上,然后重新运行脚本。(你已经看过如何做了。)
实验结束
关于这个attackers.sh
脚本,需要做一些解释。首先,在第二行,我使用命令替换与cat
命令,将banned.txt
文件的内容赋值给badips
变量。(我知道,我一直给你展示命令替换的例子,但还没有完全解释清楚。不过不用担心,稍后我会解释的。)然而,这仍然不是一个数组。我在declare -a
那行中单独创建了数组。在attackers=
这一行,我引用了badips
变量的值,然后用它构建了attackers
数组。或者,我也可以跳过使用中间变量,直接从cat
命令替换中构建数组,就像我在attackers_2.sh
脚本中做的那样:
#!/bin/bash
declare -a badips
badips=( $(cat /opt/scripts/banned.txt) )
echo "Here is the complete list: "
echo ${badips[@]}
echo
echo "Let us now count the items in the list."
elements=${#badips[*]}
echo "There are $elements IP addresses in the list."
echo
echo "badips[2] is ${badips[2]}, which is the third address in the list."
exit
两种方式都可以,但这种方式稍显简洁。
在实际场景中,你可以添加代码,自动调用一个防火墙规则,阻止banned.txt
文件中的所有地址。但是,这需要使用一些我还没有向你展示的技术。因此,我们稍后再来看这部分内容。
接下来,让我们扩展一些变量。
理解变量扩展
变量扩展,也叫做参数扩展,允许 shell 通过使用被大括号包围并由$
符号前缀的特殊修饰符(${variable}
)来测试或修改变量的值,并将这些值用于脚本中。如果在bash
中这个变量没有被设置,它将扩展为空字符串。最好的方式是先通过几个简单的例子来展示。
为未设置的变量替换值
首先,我会定义一个cat
变量,给它赋值为我 16 岁灰色小猫的名字。然后,我会进行一个测试,看看cat
是否真的有一个已设置的值,像这样:
[donnie@fedora ~]$ cat=Vicky
[donnie@fedora ~]$ echo ${cat-"This cat variable is not set."}
Vicky
[donnie@fedora ~]$
接下来,我会取消设置cat
的值,并再次进行测试。看看会发生什么:
[donnie@fedora ~]$ unset cat
[donnie@fedora ~]$ echo ${cat-"This cat variable is not set."}
This cat variable is not set.
[donnie@fedora ~]$
那么,发生了什么呢?嗯,cat
和"This cat variable is not set."
之间的-
用来测试cat
变量是否有一个已设置的值。如果变量没有已设置的值,那么-
后面的文本字符串就会替代变量的值。然而,替代的值并没有被赋值给变量,正如你在这里看到的:
[donnie@fedora ~]$ echo $cat
[donnie@fedora ~]$
现在,为cat
赋一个空值,再试一次:
donnie@fedora:~$ cat=
donnie@fedora:~$ echo ${cat-"This cat variable is not set."}
donnie@fedora:~$
这次,我们只得到一个空白行作为输出,因为cat
变量已经设置。只是它的值被设置为一个空值。我们再试一次,使用:-
代替-
,像这样:
donnie@fedora:~$ echo ${cat:-"This cat variable is not set."}
This cat variable is not set.
donnie@fedora:~$
这样是可行的,因为在-
前面加上:
会使那些已设置为空值的变量被视为未设置的变量。
好的,这就结束了未设置变量的内容。但有时候,我们可能需要处理那些已经设置了值的变量,正如你接下来将看到的。
为已设置的变量替换值
你可以通过为一个有值的变量替换一个值来反向操作,像这样:
[donnie@fedora ~]$ car="1958 Edsel Corsair"
[donnie@fedora ~]$ echo ${car+"car is set and might or might not be null"}
car is set and might or might not be null
[donnie@fedora ~]$
在这种情况下,+
构造会使以下文本字符串替代变量的已赋值。注意,由于这个文本字符串中没有特殊字符需要转义,引用符号是可选的。不过,最佳实践是仍然使用引号,以确保安全。另外,注意这次替代并没有改变car
变量的实际赋值,正如你在这里看到的:
[donnie@fedora ~]$ echo $car
1958 Edsel Corsair
[donnie@fedora ~]$
正如你刚才看到的,–
操作符和+
操作符会将具有空值的变量视为已设置。如果你想将空值变量视为未设置,则可以使用:+
操作符。如果你创建一个变量并让它保持空值,它看起来会像这样:
[donnie@fedora ~]$ computer=
[donnie@fedora ~]$ echo ${computer:+"computer is set and is not null"}
[donnie@fedora ~]$
你会看到,使用空值时,echo
命令没有任何输出。现在,由于我恰好在使用一台戴尔计算机,让我们将computer
变量的值设置为Dell
,就像你在这里看到的:
[donnie@fedora ~]$ computer=Dell
[donnie@fedora ~]$ echo ${computer:+"computer is set and might or might not be null"}
computer is set and might or might not be null
[donnie@fedora ~]$
正如我之前所提到的,我们刚刚看到的操作符会根据变量是否已经赋值来替换变量的值。但它们不会真正改变变量的值。不过,我们有时可能需要更改变量的值,接下来我们会看到这一点。
为变量赋值
下一个小技巧实际上会使用 =
和 :=
操作符为一个未设置的变量赋值。我们从给 town
变量赋值开始:
donnie@fedora:~$ unset town
donnie@fedora:~$ echo $town
donnie@fedora:~$ echo ${town="Saint Marys"}
Saint Marys
donnie@fedora:~$ echo $town
Saint Marys
donnie@fedora:~$
现在,让我们看看是否能给 town
赋一个不同的值:
donnie@fedora:~$ echo ${town="Kingsland"}
Saint Marys
donnie@fedora:~$ echo $town
Saint Marys
donnie@fedora:~$
如你之前所见,使用没有前导 :
的操作符,会将一个空值变量当作已设置的变量来处理。看看这个例子:
donnie@fedora:~$ unset town
donnie@fedora:~$ town=
donnie@fedora:~$ echo ${town="Saint Marys"}
donnie@fedora:~$
为了展示如何使用 :=
操作符,我们首先创建一个值为空的 armadillo
变量,然后给它分配一个默认值,如下所示:
[donnie@fedora ~]$ armadillo=
[donnie@fedora ~]$ echo ${armadillo:=Artie}
Artie
[donnie@fedora ~]$ echo $armadillo
Artie
[donnie@fedora ~]$
Artie 是我给最近开始在晚上来我后院的犰狳取的临时名字。但我还不知道这只犰狳是男是女,所以不确定 Artie 这个名字是否合适。如果我发现它是只母犰狳,我可能想把名字改成 Annie。所以,我们再试一下前面的练习,不过这次将 armadillo
设置为 Annie
。然后我们看看能否通过变量扩展将它改为 Artie
,像这样:
[donnie@fedora ~]$ armadillo=Annie
[donnie@fedora ~]$ echo ${armadillo:=Artie}
Annie
[donnie@fedora ~]$ echo $armadillo
Annie
[donnie@fedora ~]$
你会看到,由于 armadillo
变量已经赋值为 Annie
,所以 echo ${armadillo:=Artie}
命令没有任何效果,除了显示我已经赋予的值。
现在,如果你不想替换变量的值,而只是想查看一个错误信息,怎么办呢?我们来看看。
显示错误信息
你并不总是希望为未设置的变量执行值替换或赋值。有时候,你可能只想看到一个错误(stderr
)信息,如果变量未设置。可以使用 :?
构造来实现,如下所示:
[donnie@fedora ~]$ dog=
[donnie@fedora ~]$ echo ${dog:?The dog variable is unset or null.}
bash: dog: The dog variable is unset or null.
[donnie@fedora ~]$
让我们再试一次,使用一个叫 Rastus 的狗,它是我小时候奶奶养的那只英国牧羊犬的名字。它看起来像这样:
[donnie@fedora ~]$ dog=Rastus
[donnie@fedora ~]$ echo ${dog:?The dog variable is unset or null.}
Rastus
[donnie@fedora ~]$
我知道,你现在可能觉得这看起来和第一个例子一样,在那个例子中,我用 -
替换了未设置的 cat
变量的值。嗯,你说得对。只是 -
替换的是一个会显示在 stdout
的值,而 :?
替换的是一个会显示在 stderr
的信息。另一个区别是,如果在 shell 脚本中使用 :?
来处理未设置的变量,它会导致脚本退出。
试试这个,创建一个名为 ex.sh
的脚本,内容如下:
#!/bin/bash
var=
: ${var:?var is unset, you big dummy}
echo "I wonder if this will work."
到目前为止,我一直在展示如何使用 echo
来执行变量扩展并显示结果。这个特定的构造允许你只测试变量,而不打印结果,只需要使用 :
替代 echo
。现在,当我运行这个脚本时,你会看到它在最终的 echo
命令执行之前就退出了,如下所示:
[donnie@fedora ~]$ ./ex.sh
./ex.sh: line 3: var: var is unset, you big dummy
[donnie@fedora ~]$
等等!我刚才是不是称自己是个大傻瓜?哦,算了。不过,让我们修改脚本,让 var
有一个已赋值的值,就像在这个 ex_2.sh
脚本中一样:
#!/bin/bash
var=somevalue
: ${var:?"var is unset, you big dummy"}
echo "I wonder if this will work with a value of "$var"."
现在脚本执行完毕,如下所示:
donnie@fedora:~$ ./ex_2.sh
I wonder if this will work with a value of somevalue.
donnie@fedora:~$
所以为了重申,使用:
代替echo
可以防止${var:?"var is unset, you big dummy"}
结构打印出变量的值。我们可以通过将:
改回echo
来改变这一行为,就像在ex_3.sh
中看到的那样:
#!/bin/bash
var=somevalue
echo ${var:?"var is unset, you big dummy"}
echo "I wonder if this will work with a value of "$var"."
现在,让我们看看该更改的结果:
donnie@fedora:~$ ./ex_3.sh
somevalue
I wonder if this will work with a value of somevalue.
donnie@fedora:~$
这次,var
的值确实被打印出来。
正如你刚刚看到的,使用-
和+
操作符时,前面加上:
会导致操作符将一个空值创建的变量视为未设置。省略:
会导致操作符将空值创建的变量视为已设置。
现在,让我们换个方式来看变量偏移量。
使用变量偏移量
我将向你展示的最后一种变量扩展类型涉及仅替换文本字符串的一个子集。这涉及使用变量偏移量,除非你能看到一个例子,否则理解起来有点困难。
当你设置一个变量时,它会有一个固定的大小,或者说是字符数。${variable:offset}
结构使用偏移量或从指定位置开始的字符数。所以,如果偏移量是4
,它将省略前四个字符,并且只会回显第四个字符之后的所有字符。通过使用${variable:offset:length}
结构来添加长度参数,你还可以确定你想要使用的字符数。首先,让我们创建text
变量,其值为MailServer
,如下所示:
[donnie@fedora ~]$ text=MailServer
[donnie@fedora ~]$ echo $text
MailServer
[donnie@fedora ~]$
现在,假设我们只想查看第四个字母之后的文本。使用偏移量,如下所示:
[donnie@fedora ~]$ echo ${text:4}
Server
[donnie@fedora ~]$
很酷,它有效。现在,假设我们想要查看前四个字母。使用偏移量和长度,如下所示:
[donnie@fedora ~]$ echo ${text:0:4}
Mail
[donnie@fedora ~]$
这意味着我们从位置0
开始,并且只查看前四个字母。
你还可以从文本字符串的中间提取文本,例如这样:
[donnie@fedora ~]$ echo ${text:4:5}
Serve
[donnie@fedora ~]$
在这里,我从第四个字符之后开始,提取接下来的五个字符。
对于一些稍微实际点的操作,让我们将location
变量设置为一个美国城市及其州名,以及相关的邮政编码。(对任何不在美国的人来说,那就是邮政编码。)然后,假设我们想提取文本字符串中的邮政编码部分,如下所示:
[donnie@fedora ~]$ location="Saint Marys GA 31558"
[donnie@fedora ~]$ echo "Zip Code: ${location:14}"
Zip Code: 31558
[donnie@fedora ~]$
我可以不通过从文本字符串的开头开始计数来设置偏移量,而是使用负数来提取文本字符串的最后部分。由于邮政编码有五个数字,我可以使用-5
,像这样:
[donnie@fedora ~]$ echo "Zip Code: ${location: -5}"
Zip Code: 31558
[donnie@fedora ~]$
为确保这始终正常工作,务必在:
和-
之间留一个空格。此外,由于城市名称的长度总是会有所不同,如果你需要从整个地点列表中提取邮政编码,这将是一个更好的选择。
关于偏移量的内容就是这些。现在,让我们匹配一些模式。
匹配模式
下一个变量扩展技巧涉及匹配模式。让我们从创建pathname
变量开始,如下所示:
[donnie@fedora ~]$ pathname="/var/lib/yum"
[donnie@fedora ~]$
现在,假设我想去掉这个路径中的最低级目录。我会使用 %
和 *
来做到这一点,如下所示:
[donnie@fedora ~]$ echo ${pathname%/yum*}
/var/lib
[donnie@fedora ~]$
%
告诉 shell 忽略与模式匹配的字符串的最后一部分。在这种情况下,末尾的 *
并不是必须的,因为 yum
恰好在 pathname
的末尾。所以,去掉它也能得到相同的结果。但是,如果你想省略 pathname
的最低两级,你需要使用 *
,这样模式才会正确匹配。这就是我所说的:
[donnie@fedora ~]$ echo ${pathname%/lib}
/var/lib/yum
[donnie@fedora ~]$ echo ${pathname%/lib*}
/var
[donnie@fedora ~]$
你看到没有,缺少 *
时,模式匹配不起作用。加上 ***
后,匹配就正常工作了。所以,即使 *
并非绝对必要,最好还是加上它,确保万无一失。
另一方面,有时你可能只是想提取低级目录的名称。要做到这一点,只需将 %
替换为 #
,如下所示:
[donnie@fedora ~]$ echo ${pathname#/var}
/lib/yum
[donnie@fedora ~]$ echo ${pathname#/var/lib}
/yum
[donnie@fedora ~]$
让我们用一个最终的小技巧来结束这一节。这次,我将匹配一个模式,然后替换其他内容。首先,我会创建一个字符串变量,如下所示:
[donnie@fedora ~]$ string="Hot and Spicy Food"
[donnie@fedora ~]$ echo $string
Hot and Spicy Food
[donnie@fedora ~]$
这样挺好,除非我决定不想在单词之间留空格。所以,我会用 _
字符替换,如下所示:
[donnie@fedora ~]$ echo ${string/[[:space:]]/_}
Hot_and Spicy Food
[donnie@fedora ~]$
结果不是很好,因为它只替换了第一个空格。为了执行全局替换,我需要在 string
后面加一个额外的正斜杠,像这样:
[donnie@fedora ~]$ echo ${string//[[:space:]]/_}
Hot_and_Spicy_Food
[donnie@fedora ~]$
这样看起来好多了。但是,这里到底发生了什么呢?嗯,我们使用了 /pattern_to_be_replaced/
构造来进行替换。你在两个正斜杠之间放置的是你想要替换的内容。你可以指定一个单独的字符、一个字符类,或者其他你想要替换的模式。最后,在最后一个正斜杠和右大括号之间,放置你想替换的字符。
变量展开的内容还有些许更多,但我已经给你展示了最实际的例子。如果你想查看更多内容,可以在 进一步阅读 部分找到相关参考。
好的,现在我们已经替换了一些模式,接下来让我们尝试替换命令。
理解命令替换
在 计数登录用户 和 使用数组 实战实验中,我向你展示了 命令替换 的一些实例,但我还没有完全解释清楚。现在该是时候讲解了。
命令替换是一个非常有用的工具,你会广泛使用它。我是说,真的。你可以用它做一些非常酷的事情。它涉及到获取 shell 命令的输出,然后将其用于另一个命令中,或者将其作为某个变量的值。你将把需要获取输出的命令放在 $( )
构造中。以下是一个非常简单的例子:
[donnie@fedora ~]$ echo "This machine is running kernel version $(uname -r)."
This machine is running kernel version 6.5.5-200.fc38.x86_64.
[donnie@fedora ~]$
你看到 uname -r
命令的输出吗?它显示了当前正在运行的 Linux 内核版本,已经被替换成了命令替换构造。
现在,让我们创建 command_subsitution_1.sh
脚本,并让它看起来像这样:
#!/bin/bash
[[ ! -d Daily_Reports ]] && mkdir Daily_Reports
cd Daily_Reports
datestamp=$(date +%F)
echo "This is the report for $datestamp" > daily_report_$datestamp.txt
这里是具体的分析。在第二行,我正在测试Daily_Reports 目录
是否存在。如果它不存在,我将创建它。在第四行,我使用命令替换来创建datestamp
变量,并将当前日期赋值给它。当前日期由date +%F
命令返回,格式为年-月-日(2023-10-03)。在最后一行,我将包含今天日期的消息输出到一个文件中,该文件名中包含今天的日期。以下是其具体样式:
[donnie@fedora ~]$ ls -l Daily_Reports/
total 4
-rw-r--r--. 1 donnie donnie 34 Oct 3 15:30 daily_report_2023-10-03.txt
[donnie@fedora ~]$ cat Daily_Reports/daily_report_2023-10-03.txt
This is the report for 2023-10-03
[donnie@fedora ~]$
这不酷吗?相信我,如果你需要编写能够自动生成报告的脚本,你会经常做这种事的。
提示
有许多不同的格式化选项可以与date
命令一起使用。要查看它们的全部内容,只需查看date
的手册页面。
但是,我们这里缺少了一个重要的元素。如果今天的报告已经创建了怎么办?你是否想要覆盖它?
不,在这种情况下,我不需要。所以,让我们创建command_substitution_2.sh
脚本,在创建另一个报告之前,先检查今天的报告是否已经存在。这只需要增加一点额外的代码,如下所示:
#!/bin/bash
[[ ! -d Daily_Reports ]] && mkdir Daily_Reports
cd Daily_Reports
datestamp=$(date +%F)
[[ ! -f daily_report_$datestamp.txt ]] && echo "This is the report for $datestamp" > daily_report_$datestamp.txt || echo "This report has already been done today."
那条看起来像三行的命令实际上只是单行命令,它在打印页面上换行了。
现在,准备好惊叹当我运行这个新的修改版脚本时发生了什么:
[donnie@fedora ~]$ ./command_substitution_2.sh
This report has already been done today.
[donnie@fedora ~]$
只是为了好玩,让我们看看一些其他酷炫的示例。
让我们创建一个脚本,叫做am_i_root_1.sh
,它将如下所示:
#!/bin/bash
test $(whoami) != root && echo "You are not the root user."
test $(whoami) == root && echo "You are the root user."
whoami
命令返回运行该命令的用户的用户名。这是我在没有sudo
的情况下运行命令,再在有sudo
的情况下运行时的输出:
[donnie@fedora ~]$ whoami
donnie
[donnie@fedora ~]$ sudo whoami
root
[donnie@fedora ~]$
如你所见,使用sudo
运行whoami
命令显示我当前是 root 用户。第一个命令使用!=
操作符来测试当前用户不是root 用户。第二个命令使用==
操作符来测试当前用户是root 用户。现在,让我们运行脚本看看会发生什么:
[donnie@fedora ~]$ ./am_i_root_1.sh
You are not the root user.
[donnie@fedora ~]$ sudo ./am_i_root_1.sh
[sudo] password for donnie:
You are the root user.
[donnie@fedora ~]$
它有效,这意味着我们已经达到了酷炫的效果。但我们可以通过稍微简化一下流程变得更酷。修改脚本,使其看起来像这样:
#!/bin/bash
test $(whoami) != root && echo "You are not the root user." || echo "You are the root user."
现在,这个脚本中只需要一个命令,而不是两个。但无论如何,输出是相同的。
与其将命令放入$( )
结构中,你也可以将其用一对反引号包围,如下所示:
[donnie@fedora ~]$ datestamp=`date +%F`
[donnie@fedora ~]$ echo $datestamp
2023-10-03
[donnie@fedora ~]$
这样也行,但它是一个已弃用的方法,我不推荐使用。它最大的问题是,如果你的命令中包含任何可能被 Shell 错误解析的特殊字符,你必须确保用反斜杠进行转义。而使用更新的$( )
结构,你就不需要太担心这个问题。我提到这个方法只是因为你可能仍然会看到其他人使用它的脚本。
这就完成了命令替换的部分。接下来,我们需要做出一些决策。
理解决策与循环
到目前为止,我已经向你展示了很多特定于 Shell 脚本的编程技巧和结构。在本节中,我将向你展示一些大多数编程语言都通用的结构。我将首先展示另一种决策方法。
if .. then
结构
虽然&&
和||
决策结构适用于简单脚本,但对于更复杂的脚本,你可能需要使用if .. then
结构,特别是当你需要同时测试多个条件时。对于第一个例子,创建am_i_root_2.sh
脚本,它应该是这样的:
#!/bin/bash
if [ $(id -u) == 0 ]; then
echo "This user is root."
fi
if [ $(id -u) != 0 ]; then
echo "This user is not root."
echo "This user's name is $(id -un)."
fi
请注意,每个决策语句块都是以if
开始并以fi
结束的。(是的,fi
就是if
倒过来拼的。)还需要注意的是,在bash
Shell 脚本中,不像某些其他编程语言那样强制要求缩进,但它确实能使代码更具可读性。
这次我没有使用whoami
命令,而是使用了id
命令,后者提供了比whoami
更多的选项。(详细信息请参见这两个命令的手册页。)至于脚本的其余部分,我不会逐一解释,而是让你自己研究这个脚本,看看它是如何工作的。这样对我来说更容易,对你来说也不那么无聊。而且,我相信你能搞定。
现在,让我们看看当我运行这个脚本时会发生什么:
[donnie@fedora ~]$ ./am_i_root_2.sh
This user is not root.
This user's name is donnie.
[donnie@fedora ~]$
[donnie@fedora ~]$ sudo ./am_i_root_2.sh
This user is root.
[donnie@fedora ~]$
当你需要为同一个决策测试多个条件时,使用一个if .. then .. elif
结构会更合适,而不是使用两个if .. then
结构。这可以使你的代码更加清晰,以便任何阅读它的人都能更容易理解。我们来创建am_i_root_3.sh
脚本来展示这个技巧。它看起来应该是这样的:
#!/bin/bash
if [ $(id -u) == 0 ]; then
echo "This user is root."
elif [ $(id -u) != 0 ]; then
echo "This user is not root."
echo "This user's name is $(id -un)."
fi
这个脚本中的elif
关键字是else if
的缩写。除此之外,一切和之前的脚本几乎没有区别。当你运行它时,你将获得与之前脚本相同的输出。此外,请注意,你可以通过多个elif
语句来测试多个条件。
另外,你也可以使用if .. then .. else
结构。创建am_i_root_4.sh
脚本,它应该是这样的:
#!/bin/bash
if [ $(id -u) == 0 ]; then
echo "This user is root."
else
echo "This user is not root."
echo "This user's name is $(id -un)."
fi
使用else
非常方便,因为它定义了当任何if
或elif
语句中的条件没有得到满足时应该采取的默认动作。例如,看看这个检测机器运行哪个操作系统的脚本:
#!/bin/bash
os=$(uname)
if [[ $os == Linux ]]; then
echo "This machine is running Linux."
elif [[ $os == Darwin ]]; then
echo "This machine is running macOS."
elif [[ $os == FreeBSD ]]; then
echo "This machine is running FreeBSD."
else
echo "I don't know this $os operating system."
fi
你会看到,这个脚本可以检测 Linux、macOS 或 FreeBSD 操作系统。如果机器没有运行这三种操作系统中的任何一种,那么最后的else
语句会显示默认消息。另一个需要注意的事项是,你需要在每个if
或elif
语句的末尾添加分号和then
关键字,但在else
语句后则不需要添加它们。
这是我在 OpenIndiana 机器上运行脚本时的结果:
donnie@openindiana:~$ ./os-test.sh
I don't know this SunOS operating system.
donnie@openindiana:~$
当然,如果我愿意,我可以插入另一个elif
语句来测试 SunOS。
这基本上涵盖了if...then
的内容。现在让我们做一些事情,同时等待其他事情发生。
do...while
构造
这个构造会在某个条件为真时持续执行一组命令。这里有一个例子:
#!/bin/bash
x=10
while [[ $x -gt 0 ]]; do
x=$(expr $x - 1)
echo $x
done
这个while_demo.sh
脚本从给x
变量赋值为10
开始。只要x
的值大于0
,它就会从该值中减去1
,并使用expr $x-1
命令将新值赋给x
,然后回显新值。输出如下所示:
[donnie@fedora ~]$ ./while_demo.sh
9
8
7
6
5
4
3
2
1
0
[donnie@fedora ~]$
请注意,在这个while_demo.sh
脚本中,你可以使用简写的方式在每次循环中将x
的值减去 1。只需将x=$(expr $x - 1)
这一行替换为:
((x--))
这与你可能习惯在 C 或 C++语言程序中看到的构造是一样的。然而,这种构造并不具有可移植性,这意味着它在bash
上能正常工作,但在其他 shell 上却不行。因此,如果你需要编写一个能够在 Bourne shell、dash
或ash
上运行的脚本,你需要避免使用这种构造,而是坚持使用x=$(expr $x - 1)
构造。
你也可以使用while
循环逐行读取文本文件。这是一个非常简单的read_file.sh
脚本,用来读取/etc/passwd
文件:
#!/bin/bash
file=/etc/passwd
while read -r line; do
echo $line
done < "$file"
如你所见,我首先创建了file
变量,并将/etc/passwd
赋值给它。while
语句定义了line
变量,read -r
命令将值赋给line
变量。在每次while
循环时,read -r
命令读取文件的一行,将这一行的内容赋给line
变量,然后将该行内容回显到stdout
。当文件的所有行都读取完毕时,循环终止。最后,你看到我使用了stdin
重定向器,使while
循环读取文件。通常情况下,read
会将长行拆分成短行,并在每一部分的末尾加上反斜杠。-r
选项禁用了这种行为。
你可能会有想要创建一个无限循环的情况,直到你手动停止它为止。(也可能会有某些时候你会不小心创建一个无限循环,但那是另外一个故事。现在,假设你是故意这么做的。)为了演示,创建infinite_loop.sh
脚本,并使其看起来像这样:
#!/bin/bash
while :
do
echo "This loop is infinite."
echo "It will keep going until you stop it."
echo "To stop it, hit Ctrl-c."
sleep 1
done
这是一个相当无用的脚本,除了回显一些信息外没有其他作用。sleep 1
命令在每次循环迭代之间引入了一秒的延迟。这是我运行此脚本时发生的情况:
[donnie@fedora ~]$ ./infinite_loop.sh
This loop is infinite.
It will keep going until you stop it.
To stop it, hit Ctrl-c.
This loop is infinite.
It will keep going until you stop it.
To stop it, hit Ctrl-c.
^C
[donnie@fedora ~]$
我们仍然可以用while..do
做一些其他的技巧,但目前就先到这里。现在让我们看看for..in
。
for..in
构造
for...in
构造将处理一个列表,并对列表中的每个项目执行一条命令。在这个car_demo_2.sh
脚本中,for
语句创建了cars
变量。它的样子如下:
#!/bin/bash
for cars in Edsel Ford Nash Studebaker Packard Hudson
do
echo "$cars"
done
echo "That's all, folks!"
每次循环迭代时,in
关键字从列表中获取一个经典汽车的名称,并将其赋值给 cars
。当循环到达列表末尾时,循环结束。以下是我运行脚本时发生的情况:
[donnie@fedora ~]$ ./car_demo_2.sh
Edsel
Ford
Nash
Studebaker
Packard
Hudson
That's all, folks!
[donnie@fedora ~]$
这很简单,让我们再试试另一个。这次,创建 list_demo.sh
脚本,如下所示:
#!/bin/bash
for filename in *
do
echo "$filename"
done
这个循环只是对当前目录中的文件进行 ls
样式的列出。我使用 *
通配符告诉 for
读取所有文件名,无论有多少文件。在 echo
行中,我需要用一对双引号将 $filename
括起来,以防文件名中包含空格。以下是我运行时的情况:
[donnie@fedora ~]$ ./list_demo.sh
15827_zip.zip
2023-08-01_15-23-31.mp4
2023-08-01_16-26-12.mp4
2023-08-02_13-57-37.mp4
. . .
. . .
xargs_test.txt
yad-form.sh
zoneinfo.zip
[donnie@fedora ~]$
之所以能这样工作,是因为如果你从命令行执行 echo *
,你会看到目录中文件的混乱列表。for..in
循环会导致 echo
将每个文件名列出在单独的一行。
好的,我们刚刚看过 for..in
。现在让我们来看一下 for
。
for
结构
这与 for . . in
结构类似,唯一不同的是它获取列表的方式。使用 for
时,用户在调用脚本时会将列表作为参数传入。让我们创建 car_demo_3.sh
脚本来演示这个:
#!/bin/bash
for cars
do
echo "$cars"
done
cars
变量在 for
行中创建,但没有汽车列表。那么,列表是从哪里来的呢?它来自用户在命令行中调用脚本时输入的参数。这次,我们不用经典的汽车名称,而是使用一组现代汽车名称,如下所示:
[donnie@fedora ~]$ ./car_demo_3.sh Toyota Volkswagen Subaru Honda
Toyota
Volkswagen
Subaru
Honda
[donnie@fedora ~]$
接下来,让我们看看 break
命令。
使用 break
使用 break
命令进一步控制 for..in
和 while..do
循环的操作方式。为了查看其工作原理,创建 break_demo.sh
脚本并使其如下所示:
#!/bin/bash
j=0
while [[ $j -lt 5 ]]
do
echo "This is number: $j"
j=$((j + 1))
if [[ "$j" == '2' ]]; then
echo "We have reached our goal: $j"
break
fi
done
echo "That's all, folks!"
while
行告诉脚本在 j
的值小于 5
时运行。第六行的 j=$((j + 1))
结构是一个数学运算符,每次循环迭代时会将 j
的值加 1
。第七行开始的 if..then
结构定义了当 j
的值等于 2
时应该发生的事情。然后 break
命令终止循环。以下是其表现:
[donnie@fedora ~]$ ./break_demo.sh
This is number: 0
This is number: 1
We have reached our goal: 2
That's all, folks!
[donnie@fedora ~]$
如同我在 while_demo.sh
脚本中所示,你可以用以下内容替换 j=$((j + 1))
结构:
((j++))
然而,((j++))
是特定于 bash
的,可能在其他非 bash
的 shell 中无法使用。
你还可以将其表示为 j=$(expr j + 1)
,这也是便携式的,并且是我在 while_demo.sh
脚本中向你展示的形式。
(我将在 第十一章:执行数学运算 中向你展示更多关于在 shell 脚本中执行数学运算的内容。)
只是为了好玩,从脚本中删除 break
命令并重新运行。现在你应该看到以下内容:
[donnie@fedora ~]$ ./break_demo.sh
This is number: 0
This is number: 1
We have reached our goal: 2
This is number: 2
This is number: 3
This is number: 4
That's all, folks!
[donnie@fedora ~]$
这次,循环会继续执行直到数字 2
之后。
既然我们已经休息一下,接下来让我们继续。
使用 continue
continue
命令还会修改 for..in
和 while..do
循环的操作方式。这次,创建 for_continue.sh
脚本,并使其如下所示:
#!/bin/bash
for cars in Pontiac Oldsmobile Buick Chevrolet Ford Mercury
do
if [[ $cars == Buick || $cars == Mercury ]]; then
continue
fi
echo $cars
done
在for
循环的每次迭代中,都会将一个不同的经典汽车名称赋值给cars
。if..then
部分会判断cars
的值是否为Buick
或Mercury
。if..then
中的continue
命令会导致循环跳过这两个汽车名称,以避免echo
命令列出它们。你还会看到||
结构的另一个用法。它在测试操作中作为or
运算符使用。输出效果如下:
[donnie@fedora ~]$ ./for_continue.sh
Pontiac
Oldsmobile
Chevrolet
Ford
[donnie@fedora ~]$
接下来,让我们尝试使用while..do
循环。像这样创建while_continue.sh
脚本:
#!/bin/bash
j=0
while [[ $j -lt 6 ]]
do
j=$((j + 1))
[[ $j -eq 3 || $j -eq 6 ]] && continue
echo "$j"
done
这次,我们只是想跳过 3 和 6 这两个数字。输出如下:
[donnie@fedora ~]$ ./while_continue.sh
1
2
4
5
[donnie@fedora ~]$
好的,够了。让我们看看until
结构。
until
结构
until
循环会一直执行,直到满足某个条件。你可以用它来做各种事情,比如玩猜谜游戏。通过创建secret_word.sh
脚本来看看它是如何工作的,像这样:
#!/bin/bash
secretword=Donnie
word=
echo "Hi there, $USER!"
echo "Would you like to play a guessing game?"
echo "If so, then enter the correct secret word"
echo "to win a special prize."
echo
echo
until [[ "$word" = "$secretword" ]]
do
echo -n "Enter your guess. "
read word
done
echo "Yay! You win a pat on the back!"
所以,我将secretword
设置为Donnie
。(嘿,那是我!)然后我将word
设置为空值。until
循环会一直执行,直到我输入正确的secretword
值。(在这种情况下,read
会暂停脚本,直到你输入猜测的内容。)它是这样工作的:
[donnie@fedora ~]$ ./secret_word.sh
Hi there, donnie!
Would you like to play a guessing game?
If so, then enter the correct secret word
to win a special prize.
Enter your guess. Vicky
Enter your guess. Cleopatra
Enter your guess. Donnie
Yay! You win a pat on the back!
[donnie@fedora ~]$
酷吧?我是说,这又是一个你可以在下次聚会时表演的小技巧。
好的,接下来让我们继续看看下一个。
case
结构
case
结构提供了一种避免使用if..then..else
结构的方法。它允许用户输入一个文本字符串,然后评估该字符串并提供相应的选项。下面是case
的基本结构:
case $variable in
match_1)
commands_to_execute
;;
match_2)
commands_to_execute
;;
match_3)
commands_to_execute
;;
*) Optional Information
commands_to_execute_for_no_match
;;
esac
case
语句会与多个值进行匹配,直到找到匹配项。当找到匹配项时,命令会被执行,直到遇到双分号(;;
)。然后,esac
行之后的命令会被执行。
如果没有匹配项,则执行*)
和双分号之间的命令。*)
的作用与if...then
结构中的else
相同,都是在没有匹配任何条件时提供默认操作。
仅为好玩,尝试创建term_color.sh
脚本,效果如下:
#!/bin/bash
echo -n "Choose Background Color for Terminal(b-black,g-grey): "
read color
case "$color" in
b)
setterm -background black -foreground white
;;
g)
setterm -background white -foreground black
;;
*)
echo "I do not understand"
;;
esac
exit
这个脚本允许你更改终端的背景颜色。(是的,我知道我将g
选项设置为white
。这是因为当你运行这个脚本并选择g
选项时,背景看起来会比白色更灰一些。)运行脚本的效果如下:
[donnie@fedora ~]$ ./term_color.sh
Choose Background Color for Terminal(b-black,g-grey): g
[donnie@fedora ~]$
在你自己的终端中运行脚本,选择g
选项,你应该会看到命令提示符的背景变灰。(或者,如果你的终端已经设置为白色背景,可以选择b
选项。)要让整个终端背景变灰,只需输入clear
。
为了更有趣,编辑脚本并添加一个新的选项。首先,将顶部的echo
行改成这样:
echo -n "Choose Background Color for Terminal(b-black,g-grey,y-yellow): "
然后,在g
选项后添加y
选项。这个新选项会像这样:
y)
setterm -background yellow -foreground red
;;
要查看一些非常丑陋的内容,请再次运行脚本并选择y
选项。(不过别担心,这个设置不是永久性的。)以下是使用各种选项的效果:
图 8.1:运行 term_color.sh 脚本
好的,你已经看过如何使用for
在调用脚本时传递参数。现在,让我们看看另一种方式。
使用位置参数
当你运行一个 shell 脚本时,你还可以输入命令行参数,这些参数将在脚本中使用。你输入的第一个参数将指定为$1
,第二个参数将指定为$2
,以此类推。($9
是你能达到的最大值。)$0
参数保留给脚本的名称。
要查看这如何工作,创建position_demo.sh
脚本,它将如下所示:
#!/bin/bash
# position_demo
echo "I have a cat, whose name is $1."
echo "I have another cat, whose name is $2."
echo "I have yet another cat, whose name is $3."
echo
echo
echo "The script that I just ran is $0"
要调用脚本,输入三个名字,格式如下:
[donnie@fedora ~]$ ./position_demo.sh Vicky Cleopatra Lionel
I have a cat, whose name is Vicky.
I have another cat, whose name is Cleopatra.
I have yet another cat, whose name is Lionel.
The script that I just ran is ./position_demo.sh
[donnie@fedora ~]$
在输出中,$1
、$2
和$3
变量将被展开为我在命令行中输入的名字。$0
变量将展开为脚本的完整路径和名称。
有三个特殊的定位参数,你可以用来增强脚本功能。以下是列表:
-
$#
:这显示你输入的参数数量。 -
$@
:这会列出你输入的所有参数,每个参数占一行。 -
$*
:这会将你输入的所有参数列在一行上,每个参数之间用空格分隔。
你可以用$#
参数进行错误检查。为了理解我的意思,请重新运行position_demo.sh
脚本,但只输入一个名字作为参数。你应该会看到如下输出:
[donnie@fedora ~]$ ./position_demo.sh Vicky
I have a cat, whose name is Vicky.
I have another cat, whose name is .
I have yet another cat, whose name is .
The script that I just ran is ./position_demo.sh
[donnie@fedora ~]$
如你所见,它并没有警告我没有列出正确数量的名字。让我们稍微修改一下,以处理这个问题。创建position_demo_2.sh
脚本,让它看起来像这样:
#!/bin/bash
# position_demo
if [[ $# -ne 3 ]]; then
echo "This script requires three arguments."
exit 1
fi
echo "I have a cat, whose name is $1."
echo "I have another cat, whose name is $2."
echo "I have yet another cat, whose name is $3."
echo
echo
echo "The script that I just ran is $0"
用三个名字运行这个脚本,你会得到与第一个脚本相同的输出。然后,再用一个名字运行,你应该会看到如下输出:
[donnie@fedora ~]$ vim position_demo_2.sh
[donnie@fedora ~]$ ./position_demo_2.sh Vicky
You entered 1 argument(s).
This script requires two arguments.
[donnie@fedora ~]$
看起来好多了。
为了介绍我们的下一个技巧,请查看date
命令的输出,不指定任何格式选项,像这样:
[donnie@fedora ~]$ date
Fri Oct 6 03:24:39 PM EDT 2023
[donnie@fedora ~]$
你可以看到输出中有七个字段,分别是:
-
星期几
-
月份
-
日期
-
时间
-
上午或下午
-
时区
-
年份
现在,创建position_demo_3.sh
脚本,该脚本将把日期输出的每个字段作为位置参数处理。让它看起来像这样:
#!/bin/bash
set $(date)
echo $*
echo "Day, First Argument: $1"
echo "Month, Second Argument: $2"
echo "Date, Third Argument: $3"
echo "Time, Fourth and Fifth Arguments: $4, $5"
echo "Time Zone, Sixth Argument: $6"
echo "Year, Seventh Argument: $7"
echo "$2 $3, $7"
在第二行,你看到了set
命令的另一种用法,这是你之前没有见过的。你第一次看到set
时,是使用-o
选项来设置 shell 选项。这次,我没有使用任何选项,而是将$(date)
作为参数传递。以下是bash
手册页中关于以这种方式使用set
的说明:
如果没有选项,则以一种可以重复使用的格式显示每个 shell 变量的名称和值,用于设置或重置当前设置的变量。
在这种情况下,set
获取 $(date)
的输出并以一种方式格式化它,使得各个字段可以作为位置参数使用。
在第三行,你可以看到真正的魔法发生的地方。$*
位置参数将 $(date)
的所有字段列在一行上。其余的 echo
命令只是输出一个文本字符串,后面跟上指定字段或字段的值。它的样子是这样的:
[donnie@fedora ~]$ ./position_demo_3.sh
Fri Oct 6 03:46:28 PM EDT 2023
Day, First Argument: Fri
Month, Second Argument: Oct
Date, Third Argument: 6
Time, Fourth and Fifth Arguments: 03:46:28, PM
Time Zone, Sixth Argument: EDT
Year, Seventh Argument: 2023
Oct 6, 2023
[donnie@fedora ~]$
它按预期工作,看起来相当酷。把这个加入到下次聚会时可以尝试的技巧清单里。
我认为关于位置参数的部分已经涵盖了。现在让我们来看看退出码。
理解退出码
你已经看过一些使用 exit
命令的例子,它可以正常终止脚本,或者在发生错误时提前终止。我还没有解释的是关于 退出码 的内容。退出码大体分为两类,它们是:
-
标准 shell 退出码:每个 shell 都有自己定义的退出码集合。(为了简化起见,本章只讨论
bash
退出码。) -
用户定义的退出码:你也可以为不同的目的定义自己的退出码。
让我们首先讨论标准退出码。
标准 Shell 退出码
当程序或脚本成功运行时,它会返回 0
的退出码。否则,退出码将是一个非 0
的数字,范围从 1
到 255
。为了演示,使用 find
搜索 /etc/
目录下的 passwd
文件,像这样:
[donnie@fedora ~]$ find /etc -name passwd
find: '/etc/audit': Permission denied
find: '/etc/cups/ssl': Permission denied
. . .
. . .
/etc/pam.d/passwd
find: '/etc/pki/rsyslog': Permission denied
find: '/etc/polkit-1/localauthority': Permission denied
find: '/etc/polkit-1/rules.d': Permission denied
. . .
. . .
find: '/etc/credstore.encrypted': Permission denied
/etc/passwd
[donnie@fedora ~]$
你看到 find
找到了文件,但我们也有很多 Permission denied
错误,因为有些目录我不能用普通用户权限进入。现在,验证退出码,像这样:
[donnie@fedora ~]$ echo $?
1
[donnie@fedora ~]$
?
是一个特殊变量,返回上一个执行命令的退出码。在这个例子中,退出码是 1
,这告诉我发生了某种错误。具体的错误是 find
无法进入某些目录进行搜索。所以,我们再试一次,使用 sudo
,像这样:
[donnie@fedora ~]$ sudo find /etc -name passwd
[sudo] password for donnie:
/etc/pam.d/passwd
/etc/passwd
[donnie@fedora ~]$ echo $?
0
[donnie@fedora ~]$
这次我得到的退出码是 0
,意味着没有错误发生。
大多数时候,你会看到退出码是 0
或 1
。你可能会看到的完整代码列表包括以下内容:
-
1
一般错误 -
2
错误使用了 shell 内置命令 -
126
无法调用请求的命令 -
127
找不到命令 -
128
退出命令的无效参数 -
128+n
致命错误信号n
-
130
脚本被 Ctrl-c 中断
有可能演示其他的退出码。首先创建 exit.sh
脚本,像这样:
#!/bin/bash
exit n
立刻,你可以看到错误。exit
命令需要一个数值参数,不能使用字母参数。但我们假装看不见这个错误,还是试着运行它。你会看到这样的结果:
[donnie@fedora ~]$ ./exit.sh
./exit.sh: line 2: exit: n: numeric argument required
[donnie@fedora ~]$ echo $?
2
[donnie@fedora ~]$
2
退出码表示我错误使用了 shell 内置命令。
Shell 内建命令只是一个没有独立可执行程序文件的命令,因为它是内置在 bash
可执行程序文件中的。你可能会认为我会得到 128
代码,因为我为 exit
提供了无效的参数,但事实并非如此。(事实上,我真的不确定我需要做什么才能得到 128
代码。但没关系。)要查看完整的 shell 内建命令列表,只需查看 builtins
手册页。
126
代码通常意味着你没有权限执行某个命令。例如,假设我忘记为脚本设置可执行权限,如你所见:
[donnie@fedora ~]$ ls -l somescript.sh
-rw-r--r--. 1 donnie donnie 0 Oct 7 16:26 somescript.sh
[donnie@fedora ~]$
看我尝试运行这个脚本时会发生什么:
[donnie@fedora ~]$ ./somescript.sh
bash: ./somescript.sh: Permission denied
[donnie@fedora ~]$ echo $?
126
[donnie@fedora ~]$
你可以通过尝试执行一个不存在的命令来生成 127
代码,比如这样:
[donnie@fedora ~]$ donnie
bash: donnie: command not found
[donnie@fedora ~]$ echo $?
127
[donnie@fedora ~]$
显然,我的名字不是一个命令。
128+n
代码表示发生了某种致命错误条件。n
是一个额外的数字,它加到 128
上。例如,如果你启动一个命令并在它完成之前按 Ctrl-c 停止它,你会得到 128+2
的代码,也就是 130
。(在这种情况下,2
表示特定的致命条件。)
你可以在 shell 脚本中使用标准退出代码来处理不同的条件。要查看这一点,可以创建如下的 netchk.sh
脚本:
#!/bin/bash
if [[ $# -eq 0 ]]; then
site="google.com"
else
site="$1"
fi
ping -c 2 $site > /dev/null
if [[ $? != 0 ]]; then
echo $(date +%F) . . . Network Failure!
logger "Could not reach $site."
else
echo $(date +%F) . . . Success!
logger "$site is reachable."
fi
这个脚本期望你传递一个主机名、域名或 IP 地址作为参数。在顶部的第一个 if..then
结构中,你会看到,如果你没有输入参数,脚本会默认使用 google.com
作为参数。
否则,它会使用你指定的参数。然后它会尝试 ping 该站点。如果 ping 成功,退出代码将是 0
。否则,它会是其他非 0
的值。
在第二个 if..then
结构中,你会看到,如果退出代码不是 0
,它会回显 Network Failure
消息,并将一条记录写入系统日志文件,在 Fedora 机器上该文件是 /var/log/messages
。否则,它会回显 Success
消息。它看起来是这样的:
[donnie@fedora ~]$ ./netchk.sh
2023-10-07 . . . Success!
[donnie@fedora ~]$ ./netchk.sh www.donnie.com
ping: www.donnie.com: Name or service not known
2023-10-07 . . . Network Failure!
[donnie@fedora ~]$
关于标准退出代码没有太多要说的了。那么,让我们说几句关于用户自定义退出代码的话。
用户自定义退出代码
你可以通过为 exit
指定一个数字参数来指定你自己的退出代码。这在你需要将某个特定的退出代码传递给外部程序时非常有用。Nagios 网络监控工具就是一个很好的例子。
Nagios 是一个可以监控你网络中几乎所有类型设备的工具。它可以监控各种类型的服务器、工作站、路由器、交换机,甚至是打印机。它之所以这么酷,是因为它是模块化的,这意味着它支持插件。如果你需要监控一个特定的设备,并发现没有现成的插件能完成这项工作,你可以自己编写插件。你可以使用多种编程语言来编写插件,其中包括 shell 脚本。
你可以在想要监控的服务器或工作站上安装一个 Nagios 监控代理,并创建一个生成 Nagios 期望看到的退出代码的 Shell 脚本。要了解它是如何工作的,可以查看以下来自更大脚本的代码片段:
#!/bin/bash
os=$(uname)
quantity=$(cut -f3 -d: /etc/passwd | grep -w 0 | wc -l)
if [ $os == Linux ]; then
if [ $quantity -gt 1 ]; then
echo "CRITICAL. There are $quantity accounts with UID 0."
exit 2
else
echo "OKAY. There is only one account with UID 0."
exit 1
fi
这个脚本正在查看/etc/passwd
文件,看看是否有多个用户的 UID 为0
。这很重要,因为 UID 0 赋予用户根用户权限。所以,在任何 Linux 系统中,你绝对不想看到有多个用户账户的 UID 是 0。在if..then
结构中,你可以看到如果脚本发现多个 UID 0 账户,它会生成一个退出代码2
。否则,它会生成一个退出代码1
。
这个退出代码及其对应的echo
命令被传递给 Nagios 监控代理。然后,监控代理将echo
命令的输出传递给 Nagios 服务器,Nagios 服务器将在仪表板上显示该消息。(你将在稍后的查看一些实际的例子部分看到整个脚本。)
这就是退出代码的全部内容。现在,让我们更仔细地看看echo
。
关于 echo 的更多信息
你已经看到了使用echo
的最简单方法,即显示一条信息到屏幕上或将文本发送到文本文件中。你还没有看到的是echo
的各种格式化选项。
如果你使用-n
选项,它会阻止在文本输出的末尾创建新的一行,如下所示:
[donnie@fedora ~]$ echo -n "The fat cat jumped over the skinny dog."
The fat cat jumped over the skinny dog.[donnie@fedora ~]$
使用-e
选项,你将能够使用一些反斜杠选项。例如,要在一行文本中插入垂直制表符,可以使用-e
选项与\v
选项,如下所示:
[donnie@fedora ~]$ echo -e "The fat cat jumped\v over the skinny dog."
The fat cat jumped
over the skinny dog.
[donnie@fedora ~]$
要插入水平制表符,请使用\t
选项,如下所示:
[donnie@fedora ~]$ echo -e "The fat cat jumped\t over the skinny dog."
The fat cat jumped over the skinny dog.
[donnie@fedora ~]$
如果你想在文本中插入反斜杠,只需使用两个连续的反斜杠,如下所示:
[donnie@fedora ~]$ echo -e "The fat cat jumped over the thin\\skinny dog."
The fat cat jumped over the thin\skinny dog.
[donnie@fedora ~]$
你不仅可以回显文本消息。你还可以使用通配符字符显示当前目录中存在的文件列表,如下所示:
[donnie@fedora ~]$ echo *
1 15827_zip.zip 18.csv 2023-08-01_15-23-31.mp4 2023-08-01_16-26-12.mp4 2023-08-02_13-57-37.mp4 2023-10-25_price.txt 21261.zip 4-2_Building_an_Alpine_Container.bak 4-2_Building_an_Alpine_Container.pptx 46523.zip 48986.zip 50645.zip 54586.zip 70604.zip access_log_parse.sh access_log_parse.txt actorfile_10.txt actorfile_11.txt actorfile_1.txt actorfile_2.txt actorfile_4.txt actorfile_5.txt actorfile_6.txt actorfile_7.txt actorfile_8.txt actorfile_9.txt add_fields.awk add-repos.sh addresses.txt alignment_1.txt alignment_2.txt alma9_default.txt alma9_future.txt alma_link.t
. . .
. . .
donnie@fedora:~$
你也可以在文件列表中回显一条信息,如下所示:
[donnie@fedora ~]$ echo -e "These are my files:\n" *
These are my files:
15827_zip.zip 2023-08-01_15-23-31.mp4 2023-08-01_16-26-12.mp4
. . .
. . .
test.txt yad-form.sh zoneinfo.zip
[donnie@fedora ~]$
只要稍加想象,你就能利用这些echo
格式化选项来增强屏幕输出和文本文件的外观。
可惜,尽管这些echo
的格式化选项很酷,但它们在某些非bash
的 Shell 中效果不佳,例如dash
。在第十九章——Shell 脚本的可移植性中,我将展示如何通过使用printf
来替代echo
解决这个问题。
这就是echo
的全部内容。让我们进入现实世界吧。
查看一些实际的例子
在这一部分,我将展示一些实际的、现实中的技巧,你可以利用我们到目前为止介绍的一些技术。实际上,我不仅仅是展示,我还将让你亲自动手,参与一些有趣的实操实验。
实操实验:使用 if..then
这完全是一个现实生活中的例子。几年前,我将这个脚本作为插件添加到 Nagios 网络监控系统中。场景是我们希望确保恶意黑客没有在 Linux 和 FreeBSD 系统的 /etc/passwd
文件中添加一个不明的 UID 0
账户。因为任何在 passwd
文件中设置了 UID 0
的账户都拥有完整的 root 权限,我们不希望任何未经授权的账户拥有 root 权限。
问题在于,Linux 系统上应该只有一个 UID 0
用户账户,而在 FreeBSD 上有两个 UID 0
的账户。(一个 UID 0
的账户名为 toor
,其默认 shell 为 bash
。另一个 UID 0
的账户为 root
,其默认 shell 为 csh
。)因此,我们需要一个可以在这两个操作系统上都能运行的脚本。(请注意,您将会修改 passwd
文件,所以最好在虚拟机上进行此操作,而不是在生产环境中的真实机器上。)
请注意,您将看到的 exit 1
和 exit 2
状态代码是 Nagios 用来表示 正常
或 严重
的预期状态代码。还要注意,如果您想检查其他 UNIX 或类 UNIX 操作系统,可以添加更多的 elif
语句块。(事实上,您会看到我刚刚增加了用于检查 macOS 和 OpenIndiana 的代码。)引言部分讲解完毕,接下来让我们进入操作步骤。
-
不幸的是,脚本太长,无法在书中完整显示。所以,请访问 Github 仓库并下载
UID-0_check.sh
脚本。将其传输到一个 Linux 虚拟机中,在文本编辑器中打开脚本,查看代码。 -
运行脚本查看结果。您应该会看到如下消息:
[donnie@fedora ~]$ ./UID-0_check.sh OKAY. There is only one account with UID 0. [donnie@fedora ~]$
-
警告:再强调一遍,请在虚拟机上进行此操作,而不是在您的生产工作站上。
在 Linux 虚拟机上,使用适合您 Linux 发行版的用户创建命令,创建另一个用户账户。用文本编辑器打开 /etc/passwd
文件,将新用户的 UID 改为 0
。
这个 UID 字段是每行 /etc/passwd
文件中的第三个字段。例如,您可以看到这里 Vicky 的 UID 是 1001:
vicky:x:1001:1001::/home/vicky:/bin/bash
将她的 UID 改为 0
后,行内容将如下所示:
vicky:x:0:1001::/home/vicky:/bin/bash
-
保存文件并再次运行脚本。您应该会看到如下所示的消息:
[donnie@fedora ~]$ ./UID-0_check.sh CRITICAL. There are 2 accounts with UID 0. [donnie@fedora ~]$
-
删除新创建的用户账户。
-
创建一个 FreeBSD 虚拟机,并按照我在 前言 章节中所示安装
sudo
和bash
。将UID-0_check.sh
脚本传输到虚拟机,并重复步骤 3 到 5。此时,您应该看到2 个账户
状态为正常
,3 个账户
状态为严重
。这将使您有机会查看脚本底部的elif [ $os == FreeBSD ]; then
语句块,它能正确检测您运行的操作系统,从而执行正确的代码。
实验结束
实验操作 - 解析 Apache 访问日志
在这个实验中,我将向你展示一个单命令 Shell 脚本能有多强大。然而,构建这个单一命令可能有点棘手,所以我会一步一步地教你如何构建它,确保每一步都能正常运行,再继续下一步。如果你准备好了,我们就开始吧。
-
设置一个带有桥接网络的 Fedora Server 虚拟机。(你需要桥接网络,这样才能从你网络中的其他机器访问这台虚拟机。)
-
安装并激活 Apache 网络服务器,像这样:
sudo dnf install httpd sudo systemctl enable --now httpd
-
打开虚拟机防火墙上的 80 端口,像这样:
sudo firewall-cmd --permanent --add-service=http sudo firewall-cmd --reload
-
从你网络上的尽可能多的机器上,打开一个 Web 浏览器,并导航到虚拟机的 IP 地址。你输入的 URL 应该像这样:
http://192.168.0.10
请注意,你可以通过物理机或网络上其他虚拟机访问此页面。同时,请注意无需设置你自己的网页,因为默认的Fedora Webserver Test Page就能满足需求。
-
查看 Apache 访问日志,像这样:
sudo less /var/log/httpd/access_log
注意每一行都以访问该网站的机器的 IP 地址开头。这里是一个例子:
192.168.0.25 - - [06/Oct/2023:16:44:15 -0400] "GET /poweredby.png HTTP/1.1" 200 5714 "http://192.168.0.10/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0"
-
你会看到源 IP 地址在第一列,并且各个字段通过空格分隔。所以,我们可以使用
cut
命令,通过指定空格作为分隔符来只查看第一列的内容。命令和输出应该像这样:[donnie@fedora-server ~]$ sudo cut -d" " -f1 /var/log/httpd/access_log ::1 192.168.0.16 192.168.0.16 192.168.0.27 . . . . . . 192.168.0.25 192.168.0.25 192.168.0.9 192.168.0.8 192.168.0.8 192.168.0.8 192.168.0.8 [donnie@fedora-server ~]$
除了一个例外,你会看到访问该服务器的机器的 IPv4 地址列表。唯一的例外是列表顶部的 IPv6 地址,它是 Fedora Server 机器的localhost
地址。(除非你从虚拟机内部访问该页面,否则你可能看不到这个 IPv6 地址。)
-
到目前为止,一切顺利。你已经成功地隔离了第一列。现在让我们添加第二部分,这将排序输出,以便
uniq
过滤器在下一步能够正常工作。它看起来是这样的:sudo cut -d" " -f1 /var/log/httpd/access_log | sort
通过不使用-n
选项的sort
,列表不会按正确的数字顺序排序。但在这一步中,这无关紧要。
-
下一步是从输出中去除重复的 IP 地址,并计算每个 IP 地址在源文件中的出现次数。它看起来是这样的:
[donnie@fedora-server ~]$ sudo cut -d" " -f1 /var/log/httpd/access_log | sort | uniq -c 1 ::1 11 192.168.0.16 4 192.168.0.25 4 192.168.0.27 4 192.168.0.8 1 192.168.0.9 [donnie@fedora-server ~]$
-
现在,我们将对每个 IP 地址出现的次数进行反向数字排序,像这样:
[donnie@fedora-server ~]$ sudo cut -d" " -f1 /var/log/httpd/access_log | sort | uniq -c | sort -nr 11 192.168.0.16 4 192.168.0.8 4 192.168.0.27 4 192.168.0.25 1 192.168.0.9 1 ::1 [donnie@fedora-server ~]$
-
现在你已经知道命令正常工作了,创建
ipaddress_count.sh
脚本,并使其看起来像这样:#!/bin/bash cut -d" " -f1 /var/log/httpd/access_log | sort | uniq -c | sort -nr
请注意,你需要使用sudo
来运行这个脚本。
-
最后,让我们给它增添一些亮点。添加一些代码,将结果保存到一个带有时间戳的文本文件中。它应该像这样:
#!/bin/bash timestamp=$(date +%F) echo "These addresses have accessed this webserver as of $timestamp." > ipaddress_list_$timestamp.txt cut -d" " -f1 /var/log/httpd/access_log | sort | uniq -c | sort -nr >> ipaddress_list_$timestamp.txt
当然,也有其他可用的程序能够更全面地解析你的 Web 服务器日志文件。但这个脚本对于快速分析谁在访问你的服务器非常方便。
实验结束
让我们继续到最后一个实验。
动手实验 – 硬盘 Beta 测试
最后的示例涉及到我几年前的一个经历。那时,西部数据的好心人邀请我参与一个新型号硬盘的 Beta 测试。我真正需要做的就是在整个四个月的测试期间保持硬盘运行,然后在测试结束时从硬盘的 BIOS 中收集日志数据。但我做得更进一步,编写了一个 Shell 脚本,自动每天收集硬盘性能数据。和之前一样, 在你的 Fedora Server 虚拟机上进行操作。
-
要收集硬盘性能数据,你需要安装几个软件包,方法如下:
sudo dnf install sysstat smartmontools
-
启动
sysstat
服务并确保它处于活动状态,如下所示:sudo systemctl start sysstat systemctl status sysstat
-
你将使用
sysstat
包中的sar
组件来收集数据。但在几分钟内,sar
数据才会可用。在等待的过程中,可以通过执行系统更新来生成一些硬盘活动,方法如下:sudo dnf -y upgrade
-
查看
sar
的手册页面,并注意不同的sar
选项开关所能收集的数据类型。你将在 Shell 脚本中看到其中一些选项。
这是另一个在书中无法完全复现的脚本。所以,从 Github 仓库中下载hard_drive.sh
脚本。用你的文本编辑器打开并研究它。我已经涵盖了脚本中使用的所有概念,所以你应该能够理解它在做什么。
-
脚本中的最后一个命令是
smartctl
命令,需要sudo
权限。因此,你需要使用sudo
来运行脚本,方法如下:sudo ./hard_drive.sh
请耐心等待,因为这个过程需要几分钟。同时,请注意,你的虚拟机虚拟硬盘并不支持smartmontools
,这意味着你在报告中会看到一些警告信息。
-
脚本运行完成后,请查看
Drive_Reports
目录中生成的报告。 -
随时在你的 Linux 主机上运行此脚本。只要你安装了
sysstat
和smartmontools
包,并且sysstat
服务正在运行,几乎所有 Linux 发行版上都可以运行此脚本。
实验结束
好的,这一章就到这里。让我们总结一下并继续前进。
总结
本章我们覆盖了大量内容,我希望没有让你感到不堪重负。我想做的是为你提供一个全面的概述,介绍你在构建可用的 Shell 脚本时会用到的概念和技术。我们从一些 Shell 脚本特有的技巧开始,然后跟进一些大多数编程语言通用的技巧。
而且,学习 shell 脚本的最酷之处之一是,它比 C、Java 或 Rust 等高级语言更容易学习,但它仍然非常有用。当你学习 shell 脚本时,你也会了解适用于高级语言的构造和概念。因此,如果你打算学习另一种编程语言,先学习 shell 脚本可以帮助你为此做好准备。
但是,即使我们已经讨论了所有这些内容,我们还没有完成。在下一章中,我会介绍几种更多的文本过滤和操作方法。到时见。
问题
-
以下哪个代码片段代表执行命令替换的首选方法?
-
`command`
-
%(command)
-
"command"
-
$(command)
-
-
你需要创建一个名字数组。你会怎么做?
-
set array=names
`names=( Vicky Frank Cleopatra Katelyn )`
-
array=names
`names=( Vicky Frank Cleopatra Katelyn )`
-
array names
`names=( Vicky Frank Cleopatra Katelyn )`
-
declare names
`names=( Vicky Frank Cleopatra Katelyn )`
-
declare -a names
`names=( Vicky Frank Cleopatra Katelyn )`
-
-
如何查看刚刚运行的命令的退出代码?
-
echo $#
-
echo $?
-
echo $$
-
echo $!
-
-
你想创建一个循环,读取一个名字列表,然后将名字回显到另一个文本文件中。但是,你想跳过两个名字。以下哪个命令可以让你的脚本实现这一点?
-
break
-
skip
-
continue
-
stop
-
-
你想比较两个数值以查看它们是否相等。以下哪个运算符会使用?
-
=
-
-eq
-
==
-
-ne
-
进一步阅读
-
什么是 Bash Shebang 及其用法:
www.rosehosting.com/blog/what-is-the-bash-shebang/
-
bash 中参数扩展简介:
opensource.com/article/17/6/bash-parameter-expansion
-
Shell 参数扩展(Bash 参考手册):
www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html
-
if 入门:
tldp.org/LDP/Bash-Beginners-Guide/html/sect_07_01.html
-
Bash while 循环:
linuxize.com/post/bash-while-loop/
-
如何在 Linux 中查找最常用的磁盘空间目录和文件:
www.tecmint.com/find-top-large-directories-and-files-sizes-in-linux/
-
Linux 标准退出状态码:
www.baeldung.com/linux/status-codes
-
如何在 Linux 上使用 sar 命令:
www.howtogeek.com/793513/how-to-use-the-sar-command-on-linux/
答案
-
d
-
e
-
b
-
c
-
b
加入我们的 Discord 社区!
与其他用户、Linux 专家和作者一起阅读本书。
提出问题,为其他读者提供解决方案,通过“问我任何问题”环节与作者聊天,等等。扫描二维码或访问链接加入社区。
第九章:使用grep
、sed
和正则表达式进行文本过滤
到目前为止,我们已经看过了如何使用find
进行文件搜索,以及如何使用文本流过滤工具从文本文件和程序输出流中提取和呈现数据。在本章中,我们将研究另外两个工具,sed
和grep
,它们将使你的搜索和文本操作更加强大。不过,在此之前,你需要了解正则表达式。
本章的内容包括:
-
理解正则表达式
-
理解
sed
-
理解
grep
-
使用 RegEx Buddy
-
使用 RegEx101
-
查看一些实际案例
如果你准备好了,我们就开始吧。
技术要求
使用任何一台 Linux 虚拟机或你的 Linux 主机。
另外,像往常一样,你可以从 Github 获取脚本和文本文件,方法如下:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
理解正则表达式
当你在第六章,文本流过滤器第一部分和第七章,文本流过滤器第二部分中使用文本流过滤器时,你操作的是简单的文本字符串。也就是说,你在处理字母字符、数字和允许你在文本文件中插入制表符、空格和换行符的控制字符。正则表达式(Regular Expressions),有时你会看到它缩写为regexp或regex,可以包括文本字符串,甚至可以是一个文本字符串。它们还可以包括特殊字符,统称为元字符,这些元字符赋予了理解它们的工具强大的功能。事实上,你可以把正则表达式看作一种编程语言,文本字符串是单词,元字符是标点符号。正则表达式不仅能让你搜索简单的文本字符串,它还允许你搜索模式。例如,如果你需要在文件中查找所有电话号码或所有信用卡号码,你可以创建一个正则表达式来查找与这些模式匹配的文本字符串。
我需要澄清一件有些混淆的事情。正则表达式(Regular Expressions),总是以复数形式书写,并且每个单词的首字母都大写,是一种模式匹配语言的名称。它与正则表达式(regular expression)不同,后者是你可以用正则表达式语言构建的一个模式匹配结构。
不过,要理解的是,正则表达式的主题是非常庞大的,关于它的内容甚至可以写成整本书。现在,我会给你一些基础知识,让你理解如何在grep
和sed
中使用它们。让我们首先来看一下正则表达式中你会使用的两类字符。
字面量和元字符
你将会使用两种类型的字符来构造正则表达式。字面字符是任何需要被按字面意思匹配的字符。换句话说,字面字符就是你希望当作普通文本对待的任何东西。元字符则使你能够根据需要修改模式匹配。例如,你可以使用元字符查找发生在行首或行尾的文本模式,而如果模式出现在行中的其他地方则不进行匹配。你还可以使用元字符执行不同类型的通配符模式匹配。
有三种类型的元字符,它们是:
-
位置锚点:这些定义了你希望在文件或行中的哪个位置找到匹配项。
-
字符集:这些定义了你想要匹配的字符范围或特定类型的字面字符。换句话说,如果你想找到一个只包含小写字母的文本字符串,你不必在正则表达式中列出每一个小写字母。相反,只需使用小写字母字符集。
-
修饰符:主要来说,这些元字符允许你定义你希望一次匹配的字面字符或字面字符串的个数。其他修饰符包括或操作符和分组操作符。
位置锚点,如以下表格所示:
位置锚点 | 描述 |
---|---|
^ |
这允许你匹配发生在行首的模式。你总是将其放置在你想要匹配的模式左侧,像这样:^pattern |
` | 位置锚点 |
--- | --- |
^ |
这允许你匹配发生在行首的模式。你总是将其放置在你想要匹配的模式左侧,像这样:^pattern |
| 这允许你匹配发生在行尾的模式。你总是将其放置在你想要匹配的模式右侧,像这样:pattern$ |
|
\<pattern\> |
这一对符号标记了单词边界。例如,如果你要搜索模式and,使用这个元字符将只匹配整个单词and,而不是包含文本字符串and的任何单词。 |
以下表格解释了各种字符集。
字符集示例 | 描述 |
---|---|
[abc]``[a-z]``[:lower:]``[:digit:] |
这应该已经对你来说是熟悉的了。它定义了一个字符集或字符范围,用于在模式中匹配。你还可以使用与文本流过滤器相同的字符类。 |
[^abc]``[^a-z] |
在前面的表格中,我向你展示了如何使用^ 作为位置锚点。但是,当^ 作为字符集的第一个字符时,它充当否定操作符。所以,虽然[abc] 匹配包含 a、b 或 c 的任何内容,[^abc] 则匹配不包含 a、b 或 c 的任何内容。 |
元字符的最后一种类型是修饰符,如表格所示:
基本修饰符 | 扩展修饰符 | 描述 |
---|---|---|
* |
* |
匹配前面单个字符或单字符正则表达式的零次或多次出现的通配符。请注意,这种行为不同于你在正常命令行操作中看到的 * ,例如在 ls -l *.txt 命令中。 |
\? |
? |
匹配前面单个字符或正则表达式零次或一次出现的通配符。 |
\+ |
+ |
必须有匹配项的通配符。匹配前面单个字符或正则表达式的一次或多次出现。 |
\{n\} |
{n} |
匹配前面单个字符或正则表达式的n次出现。 |
\{n,\} |
{n,} |
匹配前面单个字符或正则表达式至少n次出现。 |
\| |
| |
这叫做交替,但可以理解为 or 运算符。 |
\(regex\) |
(regex) |
这表示分组。它匹配括号内的正则表达式,但可以整体进行修改。它也可以用于反向引用。 |
现在不必担心基本修饰符和扩展修饰符之间的区别。我会在我们讨论理解 grep这一部分时讲解这些内容。
正如我之前所说,正则表达式的主题非常庞大和复杂。我没有足够的篇幅提供完整的教程,所以我会在 sed
和 grep
部分提供一些使用各种正则表达式的示例。
你可以通过查看 regex
的手册页面来获得更多关于正则表达式的信息,这个页面位于手册的第七部分。(不过不要指望通过阅读它成为正则表达式专家。)你可以通过以下命令查看相应的手册页面:
man 7 regex
既然我已经向你介绍了正则表达式,现在让我向你介绍一个使用正则表达式的工具。
理解 sed
sed
,即流编辑器的缩写,是一个非常强大的工具,关于它有许多书籍专门进行讲解。这里的目标是帮助你学习基础知识,并激发你今后深入学习的兴趣。
如果你曾经需要对大型文本文件进行错误检查或编辑,你会很欣赏 sed
的强大功能。对于简单的任务,一条命令——即所谓的sed 脚本——可能就足够了。对于更复杂的任务,比如你可能需要对一个或多个文档进行多次修改时,你可以编写较长的程序文件,并通过 sed
执行它们。唯一的限制就是你自己的想象力。但在我们谈论如何用 sed
做酷炫的事情之前,我们需要简要讨论一下 sed
的可移植性问题。
理解 sed
可移植性问题
如果你需要使用非 Linux 操作系统,你将会遇到两种不同的 sed
实现。Linux 操作系统使用 GNU 实现,而 macOS、BSD 发行版和 OpenIndiana 上使用的是 BSD 实现。
BSD 代表 伯克利软件发行版,而 GNU 是一个递归缩写,代表 GNU 不是 Unix。
区别在于,GNU 实现具有 BSD 实现所没有的非常酷的高级功能。因此,我将在本节中向你展示的许多示例——特别是附加(a
)、插入(i
)和更改(c
)命令,以及任何使用正则表达式的命令——在 BSD 实现中无法使用。不过幸运的是,所有这些操作系统都有一个简单的解决办法。只需安装 gsed
包,然后创建指向 gsed
可执行文件的 sed
别名。以下是各操作系统的具体指引。
在 FreeBSD 上安装 gsed
在 FreeBSD 上,像这样安装 gsed
:
donnie@freebsd14:~ $ sudo pkg install gsed
然后,编辑你主目录中的 .shrc
文件,在别名部分添加以下行:
alias sed='/usr/local/bin/gsed'
注销终端,然后重新登录。通过执行以下命令验证别名是否已生效:
donnie@freebsd14:~ $ type sed
sed is an alias for /usr/local/bin/gsed
donnie@freebsd14:~ $
在 macOS 上安装 gsed
在 macOS 上安装 gsed
同样简单,但你首先需要安装 Homebrew 系统。
你可以在以下网站找到有关如何安装 Homebrew 的说明:brew.sh/
然后,执行以下命令安装 gsed
:
macmini@MacMinis-Mac-mini ~ % brew install gsed
将别名添加到你主目录中的 .zprofile
文件,方法是添加以下行:
alias sed='/usr/local/bin/gsed'
关闭终端窗口,然后再打开它。然后,验证别名是否已生效:
macmini@MacMinis-Mac-mini ~ % type sed
sed is an alias for /usr/local/bin/gsed
macmini@MacMinis-Mac-mini ~ %
在 OpenIndiana 上安装 gsed
OpenIndiana 的正常软件库中没有 gsed
。因此,你需要安装一个第三方库来获取它。首先,在你的 OpenIndiana 机器上打开 Firefox 并访问:buildfarm.opencsw.org/opencsw/official/
下载你将看到的 pkgutil.pkg
文件。
通过执行以下命令安装该包:
donnie@openindiana:~$ sudo pkgadd -d ./pkgutil.pkg
这将把 pkgutil
安装到 /opt/csw/bin/
目录,如下所示:
donnie@openindiana:/opt/csw/bin$ ls -l
total 209
-rwxr-xr-x 1 root bin 106397 Nov 12 2013 pkgutil
donnie@openindiana:/opt/csw/bin$
这个目录不在你的 PATH
中,因此你需要修复它。最简单的方法是在 /usr/sbin/
目录中创建一个符号链接,如下所示:
donnie@openindiana:/opt/csw/bin$ sudo ln -s /opt/csw/bin/pkgutil /usr/sbin/pkgutil
Password:
donnie@openindiana:/opt/csw/bin$ which pkgutil
/usr/sbin/pkgutil
donnie@openindiana:/opt/csw/bin$
接下来,安装 gsed
包,方法如下:
donnie@openindiana:~$ sudo pkgutil -i gsed
最后,你准备好创建别名了。在 OpenIndiana 上,你需要在两个不同的文件中创建它。为了在本地机器上打开终端时启用别名,你需要将其添加到主目录中的 .bashrc
文件中,如下所示:
alias sed='/usr/bin/gsed'
为了在通过 ssh
远程登录时启用别名,你需要在主目录中创建 .bash_profile
文件,并将相同的别名行添加到其中。
现在你已经安装了gsed
并在非 Linux 系统上创建了别名,你将能够从命令行运行任何sed
命令。不幸的是,如果你将任何sed
命令放入 shell 脚本中,这将无法工作,因为 shell 脚本不会读取包含别名的.bashrc
、.shrc
、.bash_profile
或.zprofile
文件。现在没关系,因为我稍后会告诉你如何解决这个问题。不过,首先,我需要向你展示如何使用各种sed
命令。
既然已经完成了,让我们来看看如何实际使用sed
。有几个不同的sed
函数是你需要了解的。我们从替换函数开始。
使用 sed 进行替换
在下图中,你可以看到一个典型的sed
替换脚本的结构。
图 9.1:一个典型的 sed 替换脚本
现在,让我们看看一些具体的例子。
示例 1:修改办公室备忘录
让我们从一个简单的办公室备忘录开始。创建一个tom.txt
文件,正如你所看到的那样:
Memo: tom tinker
We regret to inform you that tom
Tinker will no longer be working
with us. Tom tinker is leaving to
embark on his own business venture.
We wish tom tinker well. Good-bye, tom.
cc: tom Tinker
你可以看到文档中存在一些问题。最明显的一点是专有名词并没有总是大写。我们需要一种方法来自动化这些修改过程。
虽然可以一次性完成所有的修改,但首先,我们来看看如何将tom替换为Tom。你可以这样做:
[donnie@fedora ~]$ sed 's/tom/Tom/' tom.txt
Memo: Tom tinker
We regret to inform you that Tom
Tinker will no longer be working
with us. Tom tinker is leaving to
embark on his own business venture.
We wish Tom tinker well. Good-bye, tom.
cc: Tom Tinker
[donnie@fedora ~]$
在单引号中,你首先看到字母s
。它告诉sed
执行替换操作。接下来,你会看到你正在处理的两个模式。第一个是你要替换的模式,第二个是你用作替换的内容。关闭表达式后,列出你想要修改的文本文件。(注意,你不需要stdin
重定向符。)
现在,这个方法在大多数情况下有效,但我们仍然在正文的最后一行有一个小写的tom。这是因为默认情况下,sed
的s
命令只会替换给定行中的第一个匹配项。为了修复这个问题,使用带有全局(g
)选项的s
命令。这样,给定行中的每一个匹配项都会被替换。以下是这样做的效果:
[donnie@fedora ~]$ sed 's/tom/Tom/g' tom.txt
Memo: Tom tinker
We regret to inform you that Tom
Tinker will no longer be working
with us. Tom tinker is leaving to
embark on his own business venture.
We wish Tom tinker well. Good-bye, Tom.
cc: Tom Tinker
[donnie@fedora ~]$
这样效果好多了。但是,我们仍然需要替换小写的姓氏。让我们将两个命令结合起来,一次性替换姓和名,像这样:
[donnie@fedora ~]$ sed 's/tom/Tom/g ; s/tinker/Tinker/g' tom.txt
Memo: Tom Tinker
We regret to inform you that Tom
Tinker will no longer be working
with us. Tom Tinker is leaving to
embark on his own business venture.
We wish Tom Tinker well. Good-bye, Tom.
cc: Tom Tinker
[donnie@fedora ~]$
你只需要创建两个独立的sed
脚本,并用分号将它们组合在一起。你可以将两个脚本放在一对单引号内。
默认情况下,sed
会读取你想修改的文本文件,并将整个修改后的文件发送到stdout
。
如果你只想查看实际被修改的行怎么办?
为此,你需要使用-n
开关作为sed
选项,p
开关作为s
命令选项。首先,我们来看看只使用其中一个开关时会发生什么。以下是如果你只使用-n
而没有p
的结果:
[donnie@fedora ~]$ sed -n 's/tom/Tom/g ; s/tinker/Tinker/g' tom.txt
[donnie@fedora ~]$
如果没有s
命令的p
开关,-n
开关会抑制所有输出,这不是我们想要的效果。因为-n
开关是静默开关,它会取消sed
默认将处理后的文件输出到stdout
的行为。
接下来,让我们尝试只用p
而不加-n
,它会是这样的:
[donnie@fedora ~]$ sed 's/tom/Tom/g ; s/tinker/Tinker/gp' tom.txt
Memo: Tom Tinker
Memo: Tom Tinker
We regret to inform you that Tom
Tinker will no longer be working
with us. Tom Tinker is leaving to
with us. Tom Tinker is leaving to
embark on his own business venture.
We wish Tom Tinker well. Good-bye, Tom.
We wish Tom Tinker well. Good-bye, Tom.
cc: Tom Tinker
[donnie@fedora ~]$
如果没有-n
选项,s
命令的p
选项会导致sed
打印默认输出,此外还会将修改后的行再次打印一次。(实际上,我们在这里看到有两行被打印了三次,因为每行做了多次替换。)这是因为没有静默选项(-n
)时,sed
默认会将处理过的文件打印出来。然后,s
命令的p
选项会导致所有被修改过的行再次打印出来。
现在,为了只查看修改后的行,让我们将选项合并,像这样:
[donnie@fedora ~]$ sed -n 's/tom/Tom/gp ; s/tinker/Tinker/gp' tom.txt
Memo: Tom tinker
Memo: Tom Tinker
We regret to inform you that Tom
with us. Tom Tinker is leaving to
We wish Tom tinker well. Good-bye, Tom.
We wish Tom Tinker well. Good-bye, Tom.
cc: Tom Tinker
[donnie@fedora ~]$
所以这里的教训是,你必须同时使用-n
和p
选项,因为只使用其中一个选项并不能达到你想要的效果。
等等,不过你还没完呢。看来你的老板有点爱开玩笑。所以,他告诉你将最后一个Tinker替换为Stinker。他转身离开时,眨了眨眼,说:“是的,这样一定能让他生气。”这时,地址选项派上了用场。
由于你知道最后一个Tinker是文件最后一行的最后一个单词,你可以通过在地址中放置行尾元字符($
)来指示sed
只替换该出现。它看起来像这样:
[donnie@fedora ~]$ sed 's/tom/Tom/g ; s/tinker/Tinker/g ; $s/Tinker/Stinker/' tom.txt
Memo: Tom Tinker
We regret to inform you that Tom
Tinker will no longer be working
with us. Tom Tinker is leaving to
embark on his own business venture.
We wish Tom Tinker well. Good-bye, Tom.
cc: Tom Stinker
[donnie@fedora ~]$
好的,让我们进入下一个示例。
示例 2:修改好莱坞演员列表
仍然有很多其他方法可以使用sed
进行替换。为了演示,创建一个名为actorsfile_11.txt
的文件,并填入以下好莱坞演员的名单:
Jessel George
Marx Groucho
Cantor Eddie
Allen Fred
Burns George
Wayne John
Price Vincent
Besser Joe
Greenstreet Sidney
Conrad William
你决定将 Groucho Marx 替换为他的兄弟 Zeppo。查找所有以 Marx 开头的行,并且仅在这些行上进行替换,像这样:
[donnie@fedora ~]$ sed '/^Marx/s/Groucho/Zeppo/' actorfile_11.txt
Jessel George
Marx Zeppo
Cantor Eddie
Allen Fred
Burns George
Wayne John
Price Vincent
Besser Joe
Greenstreet Sidney
Conrad William
[donnie@fedora ~]$
在这个例子中,你使用了一个正则表达式作为地址。也就是说,你将字面字符串Marx放在了sed
的默认分隔符(正斜杠)内,然后在它前面加上了^
元字符。这使得替换命令只查找以Marx开头的行中的Marx出现。再看一下这个地址是如何紧接在s
命令之前的。你不需要为s
命令使用全局选项,因为你已经知道每行中给定单词只有一个出现。
你现在决定将 Joe Besser 替换为 Joe DeRita。(这是合理的,因为在现实生活中,《三傻大闹宝莱坞》的确在 Joe Besser 离开照顾生病的妻子后,用 Joe DeRita 替换了 Joe Besser。)这次,你想要查找以Joe结尾的行,并且仅在这些行上进行替换,像这样:
[donnie@fedora ~]$ sed '/Joe$/s/Besser/DeRita/' actorfile_11.txt
Jessel George
Marx Groucho
Cantor Eddie
Allen Fred
Burns George
Wayne John
Price Vincent
DeRita Joe
Greenstreet Sidney
Conrad William
[donnie@fedora ~]$
这一次,你在地址中使用了$
元字符来告诉s
查看行尾。请注意,你必须将这个元字符放在字面字符串的末尾。
好了,演员部分说完了。我们来看一些经典汽车。
示例 3:修改汽车列表
你也可以替换单个字符。创建一个名为cars_2.txt
的文件,内容如下:
[donnie@fedora ~]$ cat > cars_2.txt
edsel
Edsel
Edsel
desoto
desoto
Desoto
Desoto
nash
Nash
nash
Hudson
hudson
hudson
[donnie@fedora ~]$
在这个文件中,你看到需要大写一些列出的汽车名称。现在,我们只关注将Desoto大写。如果你告诉sed
将每个d替换为D,你会得到这个结果:
[donnie@fedora ~]$ sed 's/d/D/' cars_2.txt
eDsel
EDsel
EDsel
Desoto
Desoto
Desoto
Desoto
nash
Nash
nash
HuDson
huDson
huDson
[donnie@fedora ~]$
这不是你想要的。但是,你知道desoto只出现在第 4 行和第 5 行。因此,你可以告诉sed
仅在这两行中替换小写的d,就像这样:
[donnie@fedora ~]$ sed '4,5s/d/D/' cars_2.txt
edsel
Edsel
Edsel
Desoto
Desoto
Desoto
Desoto
nash
Nash
nash
Hudson
hudson
hudson
[donnie@fedora ~]$
请注意,两个行号之间的逗号表示第 4 行到第 5 行,而不是第 4 行和第 5 行。
你可以告诉sed
在行的开头查找desoto,像这样:
[donnie@fedora ~]$ sed '/^desoto/s/d/D/' cars_2.txt
edsel
Edsel
Edsel
Desoto
Desoto
Desoto
Desoto
nash
Nash
nash
Hudson
hudson
hudson
[donnie@fedora ~]$
如果你只想查看更改的行,可以插入-n
和p
开关,如下所示:
[donnie@fedora ~]$ sed -n '/^desoto/s/d/D/p' cars_2.txt
Desoto
Desoto
[donnie@fedora ~]$
有三种方法可以保存你的修改。你可以使用stdout
重定向器创建一个新文件,如下所示:
[donnie@fedora ~]$ sed -n '/^desoto/s/d/D/p' cars_2.txt > cars_cap.txt
[donnie@fedora ~]$
你可以将w
开关与s
命令一起使用,将修改保存到新文件中,如下所示:
[donnie@fedora ~]$ sed '/^desoto/s/d/D/w newcarsfile.txt' cars_2.txt
edsel
Edsel
Edsel
Desoto
Desoto
Desoto
Desoto
nash
Nash
nash
Hudson
hudson
hudson
[donnie@fedora ~]$
这里唯一的诀窍是,尽管w
开关会在屏幕上显示整个修改后的文件内容,但它只会将修改后的行保存到新文件中,如你所见:
[donnie@fedora ~]$ cat newcarsfile.txt
Desoto
Desoto
[donnie@fedora ~]$
保存修改的最终方法是使用-i
开关来修改原始文件,如下所示:
[donnie@fedora ~]$ sed -i '/^desoto/s/d/D/' cars_2.txt
[donnie@fedora ~]$
与使用w
开关时不同,-i
会保存整个修改后的文件,如你所见:
[donnie@fedora ~]$ cat cars_2.txt
edsel
Edsel
Edsel
Desoto
Desoto
Desoto
Desoto
nash
Nash
nash
Hudson
hudson
hudson
[donnie@fedora ~]$
如果你想在修改原始文件之前创建一个备份,只需在-i
开关后添加你想要附加到备份文件名的后缀,如下所示:
[donnie@fedora ~]$ sed -i.bak '/^desoto/s/d/D/' cars_2.txt
[donnie@fedora ~]
请注意,-i
和.bak
之间不能有空格。验证操作是否成功,如下所示:
[donnie@fedora ~]$ ls -l cars_2.txt.bak
-rw-r--r--. 1 donnie donnie 82 Oct 11 13:58 cars_2.txt.bak
[donnie@fedora ~]$
接下来,我们来做一些全字替换。
示例 4:执行全字替换
如果你有一个模式,它可能是其他单词的一部分,但你只想在它是一个完整单词时才执行替换呢?为此,创建一个名为ing.txt
的文件,内容如下:
It began raining just as the
baseball team began their spring
training. Meanwhile, Susan began
singing as she was playing the
piano. Just being alive was great,
and Michael was thinking about how
he was going to begin working on
his new novel. Writing was like
a tonic to him. It was just like
acting out all of his fantasies.
I think I've used ing enough in this
story to illustrate my point about ing.
So, ing away, and then ing some more.
现在,假设你想将单词ing替换为ING。尝试这个命令看看你会得到什么:
[donnie@fedora ~]$ sed 's/ing/ING/g' ing.txt
It began rainING just as the
baseball team began their sprING
trainING. Meanwhile, Susan began
sINGING as she was playING the
piano. Just beING alive was great,
and Michael was thinkING about how
he was goING to begin workING on
his new novel. WritING was like
a tonic to him. It was just like
actING out all of his fantasies.
I think I've used ING enough in this story to illustrate my point about ING. So, ING away, and then ING some more.
你原本只想将整个单词ing替换为ING,但也替换了其他单词中的ing。这时你需要使用词边界,如下所示:
[donnie@fedora ~]$ sed 's/\<ing\>/ING/g' ing.txt
It began raining just as the
baseball team began their spring
training. Meanwhile, Susan began
singing as she was playing the
piano. Just being alive was great,
and Michael was thinking about how
he was going to begin working on
his new novel. Writing was like
a tonic to him. It was just like
acting out all of his fantasies.
I think I've used ING enough in this
story to illustrate my point about ING.
So, ING away, and then ING some more.
你只需要将你想替换的字符串放入\< \>
结构中。
当你查看我几页前展示的元字符表时,你可能会想,“嘿,我发现了另一种方法。 我可以只使用通配符字符”。确实,在这种情况下,它会起作用,如你所见:
[donnie@fedora ~]$ sed 's/ \+ing/ ING/g' ing.txt
It began raining just as the
baseball team began their spring
training. Meanwhile, Susan began
singing as she was playing the
piano. Just being alive was great,
and Michael was thinking about how
he was going to begin working on
his new novel. Writing was like
a tonic to him. It was just like
acting out all of his fantasies.
I think I've used ING enough in this
story to illustrate my point about ING.
So, ING away, and then ING some more.
在这里,你使用了 \+
元字符来替换所有前面有一个或多个空格的 ing。 (请注意,我在 s
命令中的第一个正斜杠和 \+
元字符之间留了一个空格。我还必须在 ING 前面留一个空格,以便在输出中也能留出一个空格。)然而,在这种情况下,这并不是最优雅的解决方案,因为它并不适用于所有情况。为了说明这一点,创建 ing_2.txt
文件,并使其如下所示:
I'm writing yet another story about
the suffix ing. I hope that this
ing story will help illustrate our point.
再次执行前面的命令,你应该得到如下结果:
[donnie@fedora ~]$ sed 's/ \+ing/ ING/g' ing_2.txt
I'm writing yet another story about
the suffix ING. I hope that this
ing story will help illustrate our point.
[donnie@fedora ~]$
最后一行中的 ing 没有被替换,因为它位于行首。因此,前面不能有空格。通配符元字符是很方便的工具,并且确实有它们的合法用途。但在这里,我们发现它们并不是最好的解决方案。
这就是替换的内容。接下来我们进行一些删除操作。
使用 sed 删除
你可以使用 d
命令来执行删除操作。这比替换过程简单,因为你只需要地址和 d
命令。我们将从删除列表中的一些项开始。
示例 1:从列表中删除项
让我们再看一下我们的 cars_2.txt
文件,并尝试删除所有与 Edsel 相关的内容。 (我们想忘记 Edsel 曾经被生产过,正如福特公司自 1960 年以来一直试图做的那样。)首先,作为提醒,原始文件看起来是这样的:
[donnie@fedora ~]$ cat cars_2.txt
edsel
Edsel
Edsel
Desoto
Desoto
Desoto
Desoto
nash
Nash
nash
Hudson
hudson
hudson
[donnie@fedora ~]$
好吧,这并不完全是原创的,因为我忘了之前修改它,使所有的 desoto 都大写了。不过,这不重要。要删除所有与 Edsel 相关的内容,请尝试这样做:
[donnie@fedora ~]$ sed '/edsel/d' cars_2.txt
Edsel
Edsel
Desoto
Desoto
Desoto
Desoto
nash
Nash
nash
Hudson
hudson
hudson
[donnie@fedora ~]$
这个命令只删除了小写的 edsel。但是,我需要一种方法来删除所有与 Edsel 相关的内容。幸运的是,你可以使用正则表达式在地址中指定多个字符,如下所示:
[donnie@fedora ~]$ sed '/[eE]dsel/d' cars_2.txt
Desoto
Desoto
Desoto
Desoto
nash
Nash
nash
Hudson
hudson
hudson
[donnie@fedora ~]$
你只需将 e 和 E 放在括号中,并将其作为地址中的第一个字符。
如果你想删除除了 Edsel 以外的所有内容,可以使用 !
来取反命令。现在,你可能认为应该把取反符号放在你正在查找的地址前面,但实际情况并非如此。相反,你需要将取反符号放在命令字符本身之前。所以,在这种情况下,你应该把感叹号放在 d
前面,如下所示:
[donnie@fedora ~]$ sed '/[eE]dsel/!d' cars_2.txt
edsel
Edsel
Edsel
[donnie@fedora ~]$
你刚刚看到的示例展示了不同的 shell 脚本工具并非始终一致地实现功能。之前,你看到 ^
是正则表达式的取反操作符。在这里,你看到 !
是 sed
命令的取反操作符。你还看到 ^
也可以用来表示行的开头。所以,是的,事情有时候确实会变得有些混乱。
你还可以选择行号范围作为地址。这一次,我们删除所有与 Nash 相关的内容,它们正好出现在第八行到第十行之间,如下所示:
[donnie@fedora ~]$ sed '8,10d' cars_2.txt
edsel
Edsel
Edsel
Desoto
Desoto
Desoto
Desoto
Hudson
hudson
hudson
[donnie@fedora ~]$
在 d
前面加上感叹号,删除所有内容,除了第八行到第十行之间的内容,如下所示:
[donnie@fedora ~]$ sed '8,10!d' cars_2.txt
nash
Nash
nash
[donnie@fedora ~]$
到此为止,我们已经处理完经典车型。接下来,我将展示如果文件中有太多空行该怎么做。
示例 2:删除空行
对于我们最后的删除技巧,下面是如何从文件中删除空行的。只需使用行首元字符(^
)与行尾元字符($
)创建一个地址。首先创建blank_lines.txt
文件,如下所示:
One non-blank line.
Another non-blank line.
Yet another non-blank line.
And one last non-blank line.
现在,执行删除操作,使用^$
作为正则表达式,如下所示:
[donnie@fedora ~]$ sed '/^$/d' blank_lines.txt
One non-blank line.
Another non-blank line.
Yet another non-blank line.
And one last non-blank line.
[donnie@fedora ~]$
请注意,这与我们之前在替换脚本中使用行尾元字符的方式不同。由于我们正在使用两个元字符组合形成正则表达式,因此必须将它们放在两个正斜杠之间。
现在,进行下一个技巧。
使用 sed 进行追加和插入
a
命令在 Linux 实现的sed
中用于在另一行文本后附加一行文本。让我们来看一下它是如何工作的。
示例 1:追加文本行
你可以使用这个功能将一些模型名称添加到汽车列表中。像这样,为 Nash 车型添加Ambassador型号:
[donnie@fedora ~]$ sed '/[nN]ash/aAmbassador' cars_2.txt
edsel
Edsel
. . .
. . .
nash
Ambassador
Nash
Ambassador
nash
Ambassador
Hudson
hudson
hudson
[donnie@fedora ~]$
那很好,但你可以做得更好。首先通过缩进模型名称来改进。方法是添加一个制表符,如下所示:
[donnie@fedora ~]$ sed '/[nN]ash/a\\tAmbassador' cars_2.txt
edsel
Edsel
. . .
. . .
nash
Ambassador
Nash
Ambassador
nash
Ambassador
Hudson
hudson
hudson
[donnie@fedora ~]$
请注意,由于书籍排版的限制,我只能在这里显示部分输出。
制表符的控制字符是\t
。但请注意,你必须添加另一个反斜杠,这样sed
才能正确识别它。
你也可以通过使用行地址来做到这一点。让我们向第六行追加一个型号,如下所示:
[donnie@fedora ~]$ sed '6a\\tFiredome' cars_2.txt
edsel
Edsel
Edsel
Desoto
Desoto
Desoto
Firedome
Desoto
nash
Nash
nash
Hudson
hudson
hudson
但是,这还不能覆盖所有的Desoto车型。让我们向第四到第七行追加型号,如下所示:
[donnie@fedora ~]$ sed '4,7a\\tFiredome' cars_2.txt
edsel
Edsel
Edsel
Desoto
Firedome
Desoto
Firedome
Desoto
Firedome
Desoto
Firedome
nash
. . .
[donnie@fedora ~]$
现在,让我们结合一些操作。首先编辑cars_2.txt
文件,使其重新包含一些小写的desoto。它应该像这样:
edsel
Edsel
Edsel
desoto
desoto
Desoto
desoto
nash
Nash
nash
Hudson
hudson
hudson
但是,你不仅仅限于一次执行一个操作。那么,让我们看看如何将多个操作结合在一起。
示例 2:一次执行多个操作
你将为 Edsel 和 Desoto 车型添加一个新型号,并删除所有不是大写字母开头的车型。注意,你不能将所有内容放在一对引号中,或使用分号来分隔操作。为此,你必须为每个操作单独提供一组引号,并在每个操作前加上-e
开关。因为这些操作是顺序执行的,这意味着第一个操作必须完成后,第二个操作才能开始。下面是它的样子:
[donnie@fedora ~]$ sed -e '6a\\tFiredome' -e '/Edsel/a\\tCorsair' -e '/^[a-z]/d' cars_2.txt
Edsel
Corsair
Edsel
Corsair
Desoto
Firedome
Nash
Hudson
[donnie@fedora ~]$
现在我们已经看过了如何追加文本,让我们来看看如何插入文本。
示例 3:插入文本行
i
命令与此相同,不同之处在于它会在另一行文本之前插入一行文本,如下所示:
[donnie@fedora ~]$ sed -e '/Edsel/i1958' -e '/^[[:lower:]]/d' cars_2.txt
1958
Edsel
1958
Edsel
Desoto
Nash
Hudson
[donnie@fedora ~]$
作为额外的奖励,我还展示了另一种去除所有以小写字母开头的车名的方法。
这就是这个技巧的全部内容。让我们继续下一个技巧。
使用 sed 进行更改
你可以使用 c
命令来更改一串文本。(你也可以使用 s
命令来更改文本,但这个命令更简短。)让我们来看一下如何操作。
示例 1:将 Edsel 替换为 Studebaker
和之前一样,你将使用 cars_2.txt
文件。从将 Edsel 和 edsel 替换为 Studebaker 开始,像这样:
[donnie@fedora ~]$ sed '/[eE]dsel/cStudebaker' cars_2.txt
Studebaker
Studebaker
Studebaker
desoto
desoto
Desoto
desoto
nash
Nash
nash
Hudson
hudson
hudson
[donnie@fedora ~]$
你也可以通过行号来指定要进行的更改。以下是使用 edsel 和 Edsel 行号作为地址时的效果:
[donnie@fedora ~]$ sed '1,3cStudebaker' cars_2.txt
Studebaker
desoto
desoto
Desoto
desoto
nash
Nash
nash
Hudson
hudson
hudson
[donnie@fedora ~]$
你会发现这次的操作与之前有所不同。与其他命令逐行修改不同,这里命令告诉 sed
将一组行整体替换。
我知道这看起来很惊艳,但你还没见识到真正的精彩。看看下一个令人惊叹的技巧吧。
示例 2:更改整行文本
为了更好的示例,创建 cars_4.txt
文件,像这样:
donnie@fedora:~$ cat cars_4.txt
1958 Edsel Corsair
1949 Oldsmobile 88
1959 Edsel Ranger
1960 Edsel Ranger
1958 Edsel Bermuda
1964 Ford Galaxie
1954 Nash Ambassador
donnie@fedora:~$
现在,准备好惊叹吧,当你运行这个 sed
命令时:
donnie@fedora:~$ sed '/[eE]dsel/c1963 Studebaker Avanti' cars_4.txt
1963 Studebaker Avanti
1949 Oldsmobile 88
1963 Studebaker Avanti
1963 Studebaker Avanti
1963 Studebaker Avanti
1964 Ford Galaxie
1954 Nash Ambassador
donnie@fedora:~$
所以现在,你看到了 s
和 c
两个 sed
命令之间的最大区别。c
命令并不是仅仅替换你指定的文本字符串或模式,而是替换包含该文本字符串或模式的整个行。在这个例子中,所有包含文本字符串 Edsel 的整行都被替换为 1963 Studebaker Avanti。很酷吧?
其他杂项 sed
技巧
我们还没结束。这里有几个你可以用 sed
做的很酷的技巧。
示例 1:使用 q 命令
q
命令让 sed
像 head
工具一样工作。它告诉 sed
读取指定数量的行,然后退出。如果你想显示我们的公共领域电子书的前十行,可以输入以下命令:
[donnie@fedora ~]$ sed '10q' pg6409.txt
The Project Gutenberg eBook of How to Speak and Write Correctly
This ebook is for the use of anyone anywhere in the United States and
most other parts of the world at no cost and with almost no restrictions
whatsoever. You may copy it, give it away or re-use it under the terms
of the Project Gutenberg License included with this ebook or online
at www.gutenberg.org. If you are not located in the United States,
you will have to check the laws of the country where you are located
before using this eBook.
[donnie@fedora ~]$
如果你想查看一个大文件中间的某些选定行,你会发现 sed
比 head
/tail
组合更容易使用。以下是查看我们电子书文件中第 1,005 行到第 1,010 行的方法:
[donnie@fedora ~]$ sed -n '1005,1010p' pg6409.txt
Present Perfect
To be loved To have been loved
PARTICIPLES
[donnie@fedora ~]$
请注意,我在这里使用的 p
与 substitute 命令中的 p
选项有所不同。这个 print
命令本身是一个独立的命令。不过,它必须与 sed
的 -n
选项一起使用,才能获得你想要的结果。
示例 2:使用 w 命令
使用 w
命令将选定的文本行写入新文件。你可以像上面对 p
命令那样使用正则表达式作为地址,或者也可以像这样使用行号:
[donnie@fedora ~]$ sed '11,13whudson_cars.txt' cars_2.txt
edsel
Edsel
Edsel
desoto
desoto
Desoto
desoto
nash
Nash
nash
Hudson
hudson
hudson
[donnie@fedora ~]$
如果前面的命令看起来有点混乱,你可以插入一些空格,使其更清晰,像这样:
[donnie@fedora ~]$ sed '11,13 w hudson_cars.txt' cars_2.txt
尽管你看到的是整个文件的内容,但只有选中的内容会保存在你新建的文件中,如你所见:
[donnie@fedora ~]$ cat hudson_cars.txt
Hudson
hudson
hudson
[donnie@fedora ~]$
输出将与之前一样。这展示了sed
中可能让人困惑的一些地方。在sed
脚本的某些位置,空格是可选的。而在其他地方,比如我之前展示如何使用-i
选项和文件名后缀来备份文件时,就不能使用空格。正如我之前所说,关于这些规则,并不总是很一致。
示例 3:使用r
命令
r
命令将读取一个选定的文件,并将其附加到你正在处理的文件中的指定位置。要查看这个效果,创建cars_3.txt
文件,像这样:
Packard
Kaiser
Frazer
现在,把cars_3.txt
文件插入到cars_2.txt
文件的第二行之后,像这样:
[donnie@fedora ~]$ sed '2rcars_3.txt' cars_2.txt
edsel
Edsel
Packard
Kaiser
Frazer
Edsel
desoto
desoto
Desoto
. . .
. . .
hudson
[donnie@fedora ~]$
再次,如果这让你读起来感到困惑,可以使用可选的空格,像这样:
[donnie@fedora ~]$ sed '2 r cars_3.txt' cars_2.txt
无论哪种方式都能同样有效。
使用sed
程序文件
如果你有需要定期执行的复杂任务,比如必须对一个或多个文档进行多次编辑,可以创建程序文件。然后,使用-f
选项调用sed
,从文件中读取脚本。让我们来看几个例子:
示例 1:在文本文件中追加行
为了查看如何工作,创建myfile_3.txt
文件,它看起来像这样:
This here is line one.
And here is the second line.
The third.
Line four.
This is the fifth sentence.
Six
This here is line seven.
Eighth and final.
接下来,创建demo_append.txt
文件,它将作为sed
程序文件。让它看起来像这样:
2a\
I'll place this line after line two.
像这样调用程序文件:
[donnie@fedora ~]$ sed -f demo_append.txt myfile_3.txt
This here is line one.
And here is the second line.
I'll place this line after line two.
The third.
Line four.
This is the fifth sentence.
Six
This here is line seven.
Eighth and final.
[donnie@fedora ~]$
注意我如何将程序文件中的单个脚本拆分成两行。为了实现这一点,我必须在第一行末尾放一个反斜杠,告诉sed
脚本将在下一行继续。稍后我会向你展示为什么这样做很重要。
现在,让我们尝试一个插入一些行的程序文件。这次,我将使用文本字符串作为地址。像这样创建demo_insert.txt
程序文件:
/This/i\
I'll insert this line before all lines that contain "This".
调用程序,你会看到如下结果:
[donnie@fedora ~]$ sed -f demo_insert.txt myfile_3.txt
I'll insert this line before all lines that contain "This".
This here is line one.
And here is the second line.
The third.
Line four.
I'll insert this line before all lines that contain "This".
This is the fifth sentence.
Six
I'll insert this line before all lines that contain "This".
This here is line seven.
Eighth and final.
[donnie@fedora ~]$
示例 2:更改文本文件中的行
现在,让我们把第四到第六行改成其他内容。像这样创建demo_change.txt
文件:
4,6c\
Let's replace lines\
four through six\
to something else.
注意,我再次必须使用反斜杠来表示脚本在下一行继续。运行程序,你会看到如下结果:
[donnie@fedora ~]$ sed -f demo_change.txt myfile_3.txt
This here is line one.
And here is the second line.
The third.
Let's replace lines
four through six
to something else.
This here is line seven.
Eighth and final.
[donnie@fedora ~]$
示例 3:替换文本
接下来,创建demo_sub.txt
程序文件,它将sentence替换为line,并且只将修改过的行发送到stdout
。让它看起来像这样:
s/line/sentence/p
像这样调用程序文件:
[donnie@fedora ~]$ sed -nf demo_sub.txt myfile_3.txt
This here is sentence one.
And here is the second sentence.
This here is sentence seven.
[donnie@fedora ~]$
注意,我必须使用-n
选项来调用sed
程序,因为我在程序文件中使用了p
选项。
再试一次,不过这次使用写入选项,将更改后的输出发送到新文件。像这样创建demo_write.txt
程序文件:
s/line/sentence/w new_myfile.txt
调用程序并查看新文件,像这样:
[donnie@fedora ~]$ sed -nf demo_write.txt myfile_3.txt
[donnie@fedora ~]$ cat new_myfile.txt
This here is sentence one.
And here is the second sentence.
This here is sentence seven.
[donnie@fedora ~]$
示例 4:从一个文件复制行到另一个文件
最后,创建demo_write_2.txt
程序文件,它将把从一个文件中选定的行复制到另一个文件中。让它看起来像这样:
4,7w new_2_myfile.txt
调用它,查看结果,像这样:
[donnie@fedora ~]$ sed -nf demo_write_2.txt myfile_3.txt
[donnie@fedora ~]$ cat new_2_myfile.txt
Line four.
This is the fifth sentence.
Six
This here is line seven.
[donnie@fedora ~]$
这就是简单程序文件的内容。现在让我们稍微复杂一点。
sed
程序文件中的复合脚本
到目前为止,我只向你展示了包含一个 sed
脚本的程序文件。你也可以拥有包含两个或更多脚本的程序文件。首先创建 demo_compound.txt
程序文件,内容如下:
1,3s/Edsel/Packard/
2,4s/Packard/Lasalle/
3d
现在,创建 riding.txt
文件,内容如下:
Let's go for a ride in my Edsel.
Let's go for a ride in my Edsel.
Let's go for a ride in my Edsel.
Let's go for a ride in my Edsel.
按如下方式调用程序文件:
[donnie@fedora ~]$ sed -f demo_compound.txt riding.txt
Let's go for a ride in my Packard.
Let's go for a ride in my Lasalle.
Let's go for a ride in my Edsel.
[donnie@fedora ~]$
这是详细说明:
-
这个程序首先让
sed
在第一到第三行中将 Edsel 替换为 Packard。 -
在第二到第四行,它用
sed
将 Packard 替换为 Lasalle。 -
最后,最后一个脚本删除了第三行。请注意,由于没有脚本延续到下一行,你不必在这里使用反斜杠。
要查看第二个示例,请创建 demo_compound_2.txt
程序文件,如下所示:
2a\
No, I'd rather ride in the Hudson Hornet with the Twin-H Power Six.\
He'd rather ride in the Pierce-Arrow.
3p
按如下方式调用程序:
[donnie@fedora ~]$ sed -f demo_compound_2.txt riding.txt
Let's go for a ride in my Edsel.
Let's go for a ride in my Edsel.
No, I'd rather ride in the Hudson Hornet with the Twin-H Power Six.
He'd rather ride in the Pierce-Arrow.
Let's go for a ride in my Edsel.
Let's go for a ride in my Edsel.
Let's go for a ride in my Edsel.
[donnie@fedora ~]$
这一次,你有一个脚本在第二行之后附加了两行新内容,另一个脚本使第三行重复打印一次。
只是为了好玩,再做一次相同的事情,不过这次删除第二行。创建 demo_compound_3.txt
程序文件,如下所示:
2a\
No, I'd rather ride in the Hudson Hornet with the Twin-H Power six.\
He'd rather ride in the Pierce-Arrow.
2d
按如下方式运行程序:
[donnie@fedora ~]$ sed -f demo_compound_3.txt riding.txt
Let's go for a ride in my Edsel.
No, I'd rather ride in the Hudson Hornet with the Twin-H Power six.
He'd rather ride in the Pierce-Arrow.
Let's go for a ride in my Edsel.
Let's go for a ride in my Edsel.
[donnie@fedora ~]$
即使你删除了第二行,你仍然能够将新行附加到它之后。
让我们用一些更复杂的内容来结束 sed
的讨论。创建 tab.txt
程序文件,如下所示:
2,4s/^./\t&/
8c\
This really is the last line.
1d
当你运行这个程序时,你会感到非常惊讶。以下是你将看到的内容:
[donnie@fedora ~]$ sed -f tab.txt myfile_3.txt
And here is the second line.
The third.
Line four.
This is the fifth sentence.
Six
This here is line seven.
This really is the last line.
[donnie@fedora ~]$
第一个脚本在每个非空行的开头添加一个制表符。(^.
组合意味着查找以非空字符开头的每一行。制表符后的 &
防止 sed
将每行的第一个字符替换为制表符。相反,它只是将制表符插入到第一个字符之前。)第二个脚本更改了第八行的文本。最后,第三个脚本删除了第一行。
在 Shell 脚本中使用 sed
如果你只打算在 Linux 系统上运行你的脚本,使用 sed
编写 Shell 脚本非常简单。例如,你可以像这样编写 sed_test_0.sh
脚本:
#!/bin/bash
sed '/[Ee]dsel/i1958' cars_2.txt
这是我在我的 Fedora 工作站上运行它时的情况:
donnie@fedora:~$ ./sed_test_0.sh
1958
edsel
1958
Edsel
1958
Edsel
desoto
. . .
. . .
Hudson
hudson
hudson
donnie@fedora:~$
但是,这在 FreeBSD、OpenIndiana 或 macOS 的默认 sed
实现中不起作用。事实上,以下是我在 FreeBSD 机器上运行上述脚本时得到的结果:
donnie@freebsd14:~ $ ./sed_test_0.sh
sed: 1: "/[Ee]dsel/i1958": command i expects \ followed by text
donnie@freebsd14:~ $
根据错误信息,我应该能够通过在 1958 前加上反斜杠来使其生效。但我试过了,还是不行。
正如我在前几页的理解 sed 的可移植性问题一节中提到的,大多数非 Linux 系统中自带的sed
实现缺少 Linux 版本的一些功能。具体来说,非 Linux 版本的sed
不支持使用追加(a
)、插入(i
)和更改(c
)命令,也不能处理正则表达式。因此,如果你想在非 Linux 系统上体验sed
的全部功能,你需要安装gsed
,并在你的 shell 配置文件中创建一个别名。第二个问题是你的 shell 脚本无法读取你主目录中的 shell 配置文件,这意味着它们无法找到你在配置文件中创建的sed
别名。这里有一个解决方案,可以在sed_test_1.sh
脚本中修复这个问题:
#!/bin/bash
if [[ $(uname) == "Darwin" || $(uname) == "FreeBSD" || $(uname) == "SunOS" ]]; then
gsed="gsed"
elif [[ $(uname) == "Linux" ]]; then
gsed="sed"
else
echo "I don't know that OS."
fi
$gsed '/[Ee]dsel/i1958' cars_2.txt
所以,我们想在 Linux 机器上调用sed
,但希望在其他所有系统上调用gsed
。为了实现这一点,我使用$(uname)
获取操作系统的名称。在第一个if
语句中,我使用||
构造作为or
运算符。这样,我就能用这一句if
语句测试 Darwin、FreeBSD 或 SunOS。然后,我创建了gsed
变量,并根据需要将gsed
或sed
赋值给它。最后,在下面的$gsed
行中,我调用gsed
变量的值,以调用gsed
或sed
命令。它能工作吗?我们来看一下它在 FreeBSD 上的表现:
donnie@freebsd14:~ $ ./sed_test_1.sh
1958
edsel
1958
Edsel
1958
Edsel
. . .
. . .
hudson
hudson
donnie@freebsd14:~ $
哦,没错。它工作得非常好。我也在 macOS、Linux 和 OpenIndiana 上测试过,它在这些系统上都能正常运行。
我知道,我以前从未向你展示过你可以用命令的变量赋值来调用它。不过,你可以看到,这个小技巧非常方便,你可能会发现它非常有用。
这基本上涵盖了我们关于sed
的讨论。不过,关于sed
还有很多内容需要学习。正如我之前所说,它是一个可以写成整本书的主题。这个部分会为你提供一个很好的起点,足以让你通过入门级 Linux 认证考试中的sed
部分。
接下来,我们来谈谈grep
。
理解 grep
我已经向你展示了一些在系统上搜索文件的高级方法。那么,如果你需要在文件中搜索某些内容呢?如果你不知道自己到底在找什么呢?更糟糕的是,如果你甚至不知道该在哪个文件中查找呢?别担心,grep
可以帮忙。
grep
是全球正则表达式打印(Global Regular Expression Print)的缩写,它是一个强大的命令行工具,几乎在所有 Unix 或 Unix 衍生操作系统中都有提供。(包括 Linux 和 macOS。)Windows 也有可用版本。
你可以让grep
搜索一个文件或多个文件。你还可以将其他工具的输出通过管道传输给grep
,这样你只会看到你想看的信息。而且,grep
支持正则表达式,这让你即使只有一个大概的搜索方向,也能进行搜索。
grep
并不难,但确实需要一些练习。如果你准备好了,我们就开始吧。
使用 grep 进行基本搜索
我已经在第七章,文本流过滤器 第二部分和第八章,基本 Shell 脚本构建中给你展示了一些使用grep
的例子,但没关系。我仍然想从基础开始,并提供如何使用它的解释。我将从你已经创建的文本文件开始演示。
使用grep
最基本的方式是搜索一个文件中的文本字符串,如下所示:
[donnie@fedora ~]$ grep 'Edsel' cars_2.txt
Edsel
Edsel
[donnie@fedora ~]$
在这里,我列出了要查找的文本字符串,然后列出了包含该文本字符串的文件名。我用一对单引号将搜索词包围起来,但在这种情况下其实不需要这样做。只有当搜索词包含 shell 会错误解释的字符时,我才需要加引号。(我通常总是使用单引号,这也是一种习惯。)
默认情况下,grep
是区分大小写的。使用-i
开关可以使搜索不区分大小写,如下所示:
[donnie@fedora ~]$ grep -i 'Edsel' cars_2.txt
edsel
Edsel
Edsel
[donnie@fedora ~]$
如果你需要查看文件中哪些行包含搜索词,可以使用-n
开关,如下所示:
[donnie@fedora ~]$ grep -in 'Edsel' cars_2.txt
1:edsel
2:Edsel
3:Edsel
[donnie@fedora ~]$
你可以使用通配符一次搜索多个文件,如下所示:
[donnie@fedora ~]$ grep -in 'Edsel' cars*
cars_2.txt:1:edsel
cars_2.txt:2:Edsel
cars_2.txt:3:Edsel
cars_2.txt.bak:1:edsel
cars_2.txt.bak:2:Edsel
cars_2.txt.bak:3:Edsel
cars_2.txtbak:1:edsel
cars_2.txtbak:2:Edsel
cars_2.txtbak:3:Edsel
cars.txt:1:edsel
cars.txt:2:Edsel
cars.txt:3:Edsel
[donnie@fedora ~]$
注意,现在文件名已经包含在输出中了。
-v
开关表示反向。使用它可以显示所有不包含指定搜索词的行,如下所示:
[donnie@fedora ~]$ grep -inv 'Edsel' cars*
cars_2.txt:4:desoto
cars_2.txt:5:desoto
cars_2.txt:6:Desoto
cars_2.txt:7:desoto
cars_2.txt:8:nash
cars_2.txt:9:Nash
cars_2.txt:10:nash
. . .
. . .
cars.txt:11:Hudson
cars.txt:12:hudson
cars.txt:13:hudson
[donnie@fedora ~]$
-c
开关显示搜索模式在文件中出现的次数,但不会显示包含搜索模式的行。它看起来是这样的:
[donnie@fedora ~]$ grep -ic 'edsel' cars*
cars_2.txt:3
cars_2.txt.bak:3
cars_2.txtbak:3
cars_3.txt:0
cars_cap.txt:0
cars.txt:3
[donnie@fedora ~]$
在这些例子中,我展示了如何将grep
选项开关与单个-
结合使用。当然,如果你需要的话,你也可以一次只使用一个选项。
好的,这些例子相对简单,你可能会说:“嘿,那什么时候才是重点?”别着急,重点快来了。
更高级的 grep 搜索
这里有一些更酷的 grep 选项。
示例 1:搜索完整单词
默认情况下,grep
不会搜索完整单词。因此,如果你搜索文本字符串and,它会显示包含land、hand、sand和and的行。我们通过在公共领域电子书中搜索文本字符串noun来看看这个效果。(输出很长,所以我只展示一部分。)我会让它变成区分大小写的搜索,如下所示:
[donnie@fedora ~]$ grep 'noun' pg6409.txt
Noun, Adjective, Pronoun, Verb, Adverb, Preposition, Conjunction and
_Gender_ has the same relation to nouns that sex has to individuals, but
_Case_ is the relation one noun bears to another or to a verb or to a
An _Article_ is a word placed before a noun to show whether the latter is
An _Adjective_ is a word which qualifies a noun, that is, which shows
some distinguishing mark or characteristic belonging to the noun.
A _Pronoun_ is a word used for or instead of a noun to keep us from
repeating the same noun too often. Pronouns, like nouns, have case,
number, gender and person. There are three kinds of pronouns, _personal_,
of speech in properly pronouncing them.
. . .
. . .
[donnie@fedora ~]$
这个搜索不仅返回了包含单词noun的行,还返回了包含nouns、pronouns、Pronouns和Pronoun的行。还有一行包含了pronouncing这个词。如果你只想搜索单词noun,且仅仅是noun,那么你需要添加-w
开关,如下所示:
[donnie@fedora ~]$ grep -w 'noun' pg6409.txt
_Case_ is the relation one noun bears to another or to a verb or to a
An _Article_ is a word placed before a noun to show whether the latter is
An _Adjective_ is a word which qualifies a noun, that is, which shows
some distinguishing mark or characteristic belonging to the noun.
A _Pronoun_ is a word used for or instead of a noun to keep us from
repeating the same noun too often. Pronouns, like nouns, have case,
An _Article_ is a word placed before a noun to show whether the noun is
thing but indicates the noun in its widest sense; thus, _a_ man means any
_Number_ is that inflection of the noun by which we indicate whether it
_Gender_ is that inflection by which we signify whether the noun is the
. . .
. . .
[donnie@fedora ~]$
你已经大大缩小了搜索范围。(你仍然会在其中看到一些代词,但只是因为它们和名词在同一行上。)
示例 2:进行不区分大小写的搜索
现在,添加-i
选项来使搜索不区分大小写,像这样:
[donnie@fedora ~]$ grep -iw 'noun' pg6409.txt
Noun, Adjective, Pronoun, Verb, Adverb, Preposition, Conjunction and
Interjection. Of these, the Noun is the most important, as all the others
are more or less dependent upon it. A Noun signifies the name of any
_Case_ is the relation one noun bears to another or to a verb or to a
An _Article_ is a word placed before a noun to show whether the latter is
An _Adjective_ is a word which qualifies a noun, that is, which shows
some distinguishing mark or characteristic belonging to the noun.
A _Pronoun_ is a word used for or instead of a noun to keep us from
repeating the same noun too often. Pronouns, like nouns, have case,
An _Article_ is a word placed before a noun to show whether the noun is
thing but indicates the noun in its widest sense; thus, _a_ man means any
NOUN
. . .
[donnie@fedora ~]$
在搜索模式的左侧添加^
元字符,grep
将只显示那些模式出现在行首的行,像这样:
[donnie@fedora ~]$ grep -iw '^noun' pg6409.txt
Noun, Adjective, Pronoun, Verb, Adverb, Preposition, Conjunction and
NOUN
[donnie@fedora ~]$
仅仅出于好奇,使用-c
选项来计算一下整个单词名词在文档中出现了多少次,像这样:
[donnie@fedora ~]$ grep -iwc 'noun' pg6409.txt
33
[donnie@fedora ~]$
那么,如果我省略-i
选项来使搜索区分大小写,会有什么不同吗?让我们看看:
donnie@fedora:~$ grep -wc 'noun' pg6409.txt
29
donnie@fedora:~$
所以,我们总共有 33 个不区分大小写的名词字符串,以及 29 个区分大小写的。这告诉我,至少有四个名词字符串包含至少一个大写字母。
示例 3:处理回车符
这是一个你可能在 Linux 工作中遇到的实际场景。直到你最终弄明白它,这个问题可能会让你有点烦恼。
你刚刚学会了如何执行不区分大小写的搜索,以查找每一行开头的名词。你发现了一个单独的NOUN。那么,按照这个逻辑,你也应该能够通过执行不区分大小写的搜索,查找到行尾的名词。所以,现在,试试看:
[donnie@fedora ~]$ grep -iw 'noun$' pg6409.txt
[donnie@fedora ~]$
等一下,为什么没有找到任何结果呢?我们知道应该能找到一个。这里有个线索。
几章前,我告诉过你我从 Project Gutenberg 网站下载了这个文件。所以,文件很可能是用 DOS 或 Windows 文本编辑器创建的。
这很重要,因为 DOS 和 Windows 文本编辑器会在文本文件的每一行末尾插入回车符,而 Unix 和 Linux 文本编辑器则只使用换行符。如果grep
在搜索过程中遇到换行符,它会忽略它。然而,它不会忽略回车符。现在,让我们来验证一下我们的理论。我将从向你展示如何使用另一个正则表达式元字符开始。
如果在搜索模式中放置一个点号,这意味着你会接受该位置存在任何单个字符,只要其余模式能够匹配正确。所以,将一个点号放在你的搜索模式的末尾。然后,将输出通过od -c
命令传递,像这样:
[donnie@fedora ~]$ grep -iw 'noun.$' pg6409.txt | od -c
0000000 N O U N \r \n
0000006
[donnie@fedora ~]$
是的,问题来了。\r
是回车控制字符。不过不用担心,只需使用tr
命令加上-d
选项来去掉那些讨厌的家伙。然后将转换后的输出保存到一个新文件中,像这样:
[donnie@fedora ~]$ tr -d '\r' < pg6409.txt > linux_pg6409.txt
[donnie@fedora ~]$
验证回车符已经消失,并且grep
搜索现在可以正确工作,方法是进行不带点号的模式搜索。它应该像这样:
[donnie@fedora ~]$ grep -iw 'noun$' linux_pg6409.txt | od -c
0000000 N O U N \n
0000005
[donnie@fedora ~]$ grep -iw 'noun$' linux_pg6409.txt
NOUN
[donnie@fedora ~]$
现在,当你将搜索输出通过od
命令处理时,你会发现回车符已经消失了。然后,当你尝试原始搜索时,你会得到你应该得到的结果。
不过,你还没完成。如果行尾的 noun 恰好是一个句子的结尾呢?那么,noun 后面会跟着一个句号,而你刚才执行的搜索是无法捕捉到这一点的。让我们回到前面几步,再次使用点号元字符进行搜索,像这样:
[donnie@fedora ~]$ grep -iw 'noun.$' linux_pg6409.txt
some distinguishing mark or characteristic belonging to the noun.
_s_ and these phrases are now idioms of the language. All plural nouns
verb, _summons_, a noun.
[donnie@fedora ~]$
现在换行符已经去掉,这个搜索现在能找到 noun. 和 nouns.,但是,你不想找到 nouns。你只想找到单数形式的 noun。我会通过引入另一个元字符来告诉你怎么做,像这样:
[donnie@fedora ~]$ grep -iw 'noun\.\?$' linux_pg6409.txt
some distinguishing mark or characteristic belonging to the noun.
NOUN
verb, _summons_, a noun.
[donnie@fedora ~]$
通过在点号前放置反斜杠,你将点号元字符变成了一个普通的句点。\?
告诉 grep
尝试找到前面字符的匹配,但如果找不到也没关系。所以,在这里,你告诉 grep
执行一个不区分大小写的搜索,查找行尾的整个单词 noun,无论是否有句号。(你开始明白正则表达式有多有趣了吗?)
好了,你已经看过一些酷东西了,但还没完成。接下来,让我们继续。
使用 grep 进行更高级的搜索
你可以将 grep
与其他工具结合,进行更高级的搜索。下面是如何操作的。
示例 1:审计源代码文件
要演示这个,使用你的 Fedora 服务器或 Debian 虚拟机,这样你就可以安装一个你可能不想在 Linux 工作站上安装的包,如果你正在使用的是工作站的话。在 Fedora 服务器上,安装 cairo-devel
包,像这样:
[donnie@fedora-server ~]$ sudo dnf install cairo-devel
如果你更倾向于使用 Debian 虚拟机,请安装 libghc-cairo-dev
包,像这样:
donnie@debian:~$ sudo apt install libghc-cairo-dev
接下来,查看 /usr/include/cairo/
目录中的源代码文件,查找包含字符串 #include
的行,像这样:
[donnie@fedora-server ~]$ grep -ihr '#include' /usr/include/cairo/* | sort | uniq -c
1 #include "cairo-deprecated.h"
1 #include "cairo-features.h"
9 #include "cairo.h"
1 #include <cairo.h>
1 #include "cairo-version.h"
1 #include <fontconfig/fontconfig.h>
1 #include <ft2build.h>
1 #include FT_FREETYPE_H
2 #include <stdio.h>
1 #include <X11/extensions/Xrender.h>
2 #include <X11/Xlib.h>
1 #include <xcb/render.h>
1 #include <xcb/xcb.h>
[donnie@fedora-server ~]$
如果你不想显示文本字符串所在的文件名,可以使用 -h
开关。然后将这些行通过 sort
排序,再通过 uniq
进行去重。这样,你就会得到排序后的输出,并且所有重复的行都会被删除。
同时,通过在 uniq
命令中使用 -c
开关,我们将得知每一行被重复了多少次。
示例 2:搜索社会保障号码
这是一个使用正则表达式进行搜索的更复杂示例。首先,创建一个 ssn.txt
文件,列出姓名、出生日期和美国社会保障号码,像这样:
Knockwurst, Ronald J. 899-98-1247
Born 28 April 1954
Liverwurst, Alex 988-45-7898
Born 1 Feb 1933
Saurbraten, Alicia 978-98-6987
Born 5 Apr 1956
Hassenfeffer, Gerald 999-87-1258
Born 10 Jan 1961
请注意,这些都是虚构的,因此我们没有侵犯任何人的隐私。此外,我也不知道为什么我选择了德国食物作为姓氏,也许是因为我饿了?
然后,构建一个 grep
命令,只显示包含社会保障号码的行,像这样:
[donnie@fedora ~]$ grep '[0-9]\{3\}-[0-9]\{2\}-[0-9]\{4\}' ssn.txt
Knockwurst, Ronald J. 899-98-1247
Liverwurst, Alex 988-45-7898
Saurbraten, Alicia 978-98-6987
Hassenfeffer, Gerald 999-87-1258
[donnie@fedora ~]$
在这里,每个[0-9]
表示你正在搜索任何数字。被反斜杠和大括号包围的数字表示你要找到特定数量的连续数字。所以,[0-9]\{3\}
结构表示你要寻找三个连续的数字。在这个正则表达式中,包含了三个这样的分组,每个分组之间用连字符分隔。
示例 3:使用 ^ 元字符
在我们继续讨论下一个话题之前,再来看一个例子。这是一个关于^
元字符的例子,取决于它在正则表达式中的位置,它可以有两种不同的含义。请看下面的grep
命令,了解它是如何工作的:
[donnie@fedora ~]$ grep '^[^a-zA-Z]' pg6409.txt | less
在这个命令中,'^[^a-zA-Z]'
正则表达式表示查找所有不以字母开头的行。这是因为在方括号外,^
表示只在行首查找匹配。在方括号内,它则表示显示所有不匹配该词项的内容。
当然,你还会看到很多连续的空行,因为空行不以字母开头。如果你不想看到这些空行,可以将grep
的输出传递给uniq
,像这样:
[donnie@fedora ~]$ grep '^[^a-zA-Z]' pg6409.txt | uniq | less
一定要滚动查看这两个命令的输出,并注意它们之间的差异。
好了,我们继续看接下来能否进一步扩展。
在grep
中使用扩展正则表达式
正如你在几页前的理解正则表达式部分中看到的正则表达式元字符图表,grep
有两种命令语法。我到目前为止展示的就是基本语法。另一种是扩展语法。以下是它们之间的两个区别:
-
基本语法要求在某些元字符前使用反斜杠,而扩展语法则不需要。
-
扩展语法要求你使用
egrep
代替grep
,或者使用带有-E
选项开关的grep
。(需要注意的是,虽然egrep
目前仍然有效,但它已被视为过时,并可能在未来停止工作。所以,你应该始终使用grep -E
。)
好了,这里有一些例子。
示例 1:使用扩展语法进行基本搜索
让我们再看一个之前的例子。这里是使用普通基本语法的命令:
[donnie@fedora ~]$ grep -iw 'noun\.\?$' linux_pg6409.txt
some distinguishing mark or characteristic belonging to the noun.
NOUN
verb, _summons_, a noun.
[donnie@fedora ~]$
下面是使用扩展语法的完全相同命令:
[donnie@fedora ~]$ grep -E -iw 'noun\.?$' linux_pg6409.txt
some distinguishing mark or characteristic belonging to the noun.
NOUN
verb, _summons_, a noun.
[donnie@fedora ~]$
好吧,这里没什么大问题,因为使用扩展语法只消除了一个需要输入的反斜杠。但是,如果你的grep
命令需要使用基本语法来转义很多字符,那么扩展语法可以大大减少输入量。
示例 2:搜索连续的重复单词
下一个例子稍微复杂一些。你正在查看/etc/services
文件,寻找所有包含连续重复单词的行,像这样:
[donnie@fedora ~]$ grep -Ei '\<([a-z]+) +\1\>' /etc/services
http 80/tcp www www-http # WorldWideWeb HTTP
http 80/udp www www-http # HyperText Transfer Protocol
nextstep 178/tcp NeXTStep NextStep # NeXTStep window
nextstep 178/udp NeXTStep NextStep # server
[donnie@fedora ~]$
这里是具体的解析:
-
\<. . .\>
构造表示一个单词边界。这个构造内的模式将仅匹配一个单一的单词。 -
[a-z]
表示我们在寻找一个单一的字母字符。当然,你看到的全是小写字母,这通常意味着它只会匹配小写字母。但是,grep -Ei
命令中的i
使得这次搜索不区分大小写。 -
[a-z]
末尾的+
称为重复字符。这意味着我们希望匹配一个或多个在它前面出现的字符或字符集。在这个例子中,我们希望匹配只包含两个方括号之间字符集的模式。 -
将
[a-z]+
放在一对括号中表示我们希望在评估第二部分之前先评估这一部分的正则表达式。 -
最后,正则表达式第二部分中的
+\1
表示我们希望找到与第一部分描述的模式的第二次匹配。换句话说,我们要查找/etc/services
文件中包含两个连续相同单词的所有行。
现在,通过省略-i
选项,使搜索区分大小写,像这样:
[donnie@fedora ~]$ grep -E '\<([a-z]+) +\1\>' /etc/services
http 80/tcp www www-http # WorldWideWeb HTTP
http 80/udp www www-http # HyperText Transfer Protocol
[donnie@fedora ~]$
示例 3:搜索以特定字母开头的单词
接下来,浏览/etc/
目录,查找所有名称以* p 或 q *开头的文件和目录,像这样:
[donnie@fedora ~]$ ls /etc/ | grep -E '^[pq]'
pam.d
paperspecs
passwd
. . .
. . .
purple
qemu-ga
[donnie@fedora ~]$
再试一次,不过这次查找文件和目录,要求它们的第一个字母是p或q,而且第二个字母也是p,像这样:
[donnie@fedora ~]$ ls /etc/ | grep -E '^[pq]p+'
ppp
[donnie@fedora ~]$
现在,列出/etc/
目录中所有名称以se开头并且可选的第三个字母为r的文件和目录,像这样:
[donnie@fedora ~]$ ls /etc/ | grep -E '^ser?'
security
selinux
services
sestatus.conf
setroubleshoot
[donnie@fedora ~]$
再试一次,但这次要求文件和目录名称的第三个字母是r。(我这次不会告诉你如何做,但我会给你个提示。只需要看看之前的例子。)
在下一个示例中,我将向你展示一些简写。列出/etc/
目录中所有名称中包含非字母数字字符的文件和目录,像这样:
[donnie@fedora ~]$ ls /etc/ | grep -E '\W'
anthy-unicode.conf
appstream.conf
asound.conf
at.deny
bash_completion.d
. . .
. . .
vmware-vix
whois.conf
xattr.conf
yum.repos.d
[donnie@fedora ~]$
你会看到包含点、下划线和连字符等字符的文件和目录名称。任何没有这些非字母数字字符的文件或目录名称将不会显示。这个示例中的\W
只是[^_[:alnum:]]
的简写。^
表示取反,这会导致grep
搜索非字母数字字符。
使用小写的w(\w
)将替代[_[:alnum:]]
,这会导致grep
搜索所有字母数字字符。
示例 4:搜索包含数字的单词
接下来,搜索/etc/
中的所有文件和目录,查找名称中包含数字的文件,像这样:
[donnie@fedora ~]$ ls /etc/ | grep -E '[[:digit:]]'
dbus-1
grub2.cfg
grub2-efi.cfg
ImageMagick-7
. . .
. . .
tpm2-tss
udisks2
X11
[donnie@fedora ~]$
在最后一个示例中,使用替代符号运算符来创建一种“或者/或”的正则表达式,像这样:
[donnie@fedora ~]$ ls /etc/ | grep -E '(cron|yum)'
anacrontab
cron.d
cron.daily
cron.deny
cron.hourly
cron.monthly
crontab
cron.weekly
yum.repos.d
[donnie@fedora ~]$
在(cron|yum)
结构中的|
符号充当了或
运算符,这使得grep
可以搜索包含cron或yum的目录和文件名。(顺便说一下,如果你在 Debian 机器上而不是 Fedora 机器上进行此操作,只需将yum替换为apt。)
这就是扩展grep
的全部内容。让我们将注意力转向固定字符串。
使用固定字符串正则表达式与 grep
无论是fgrep
还是grep -F
,它们都会将你输入的任何模式解释为字面量表达式。因此,如果你在grep -F
或fgrep
后面跟上正则表达式,你实际上是在搜索该正则表达式,而不是解析后的模式。例如,让我们将之前使用的ssn.txt
文件复制到ssn_2.txt
,并添加一行。新文件将如下所示:
Knockwurst, Ronald J. 899-98-1247
Born 28 April 1954
Liverwurst, Alex 988-45-7898
Born 1 Feb 1933
Saurbraten, Alicia 978-98-6987
Born 5 Apr 1956
Hassenfeffer, Gerald 999-87-1258
Born 10 Jan 1961
'[0-9]\{3\}-[0-9]\{2\}-[0-9]\{4\}'
这新增加的最后一行是你用来搜索文件的正则表达式。使用不带选项的grep
,正如你之前看到的,将返回包含社会保障号的行列表,如下所示:
[donnie@fedora ~]$ grep '[0-9]\{3\}-[0-9]\{2\}-[0-9]\{4\}' ssn_2.txt
Knockwurst, Ronald J. 899-98-1247
Liverwurst, Alex 988-45-7898
Saurbraten, Alicia 978-98-6987
Hassenfeffer, Gerald 999-87-1258
[donnie@fedora ~]$
但是,看看如果你使用fgrep
或grep -F
会发生什么:
[donnie@fedora ~]$ grep -F '[0-9]\{3\}-[0-9]\{2\}-[0-9]\{4\}' ssn_2.txt
'[0-9]\{3\}-[0-9]\{2\}-[0-9]\{4\}'
[donnie@fedora ~]$
使用fgrep
或grep -F
时,正则表达式被解释为仅仅是另一个文本字符串。
和egrep
一样,fgrep
被认为是过时的,并且可能会在不久的将来停止工作。因此,你最好的选择是开始习惯使用grep -E
和grep -F
,而不是egrep
和fgrep
。(我提到它们的主要原因是,你可能会在其他教程中看到它们。)
现在你已经看过一些正则表达式的使用,接下来让我们看看如何简化创建它们的过程。
使用正则表达式助手程序
好了,现在你已经看到了这么多示例,你现在可以创建正则表达式来完成任何你需要做的事情。
什么?!不行,你说?
别难过。我已经告诉过你,正则表达式是相当复杂的,并且它是许多书籍的主题。正是在这种情况下,助手程序就派上了用场。让我们看看一些示例。
RegexBuddy 和 RegexMagic
RegexBuddy 和 RegexMagic 是由Just Great Software公司发布的一对助手程序。它们之间的区别在于,RegexBuddy 主要是一个用于构建正则表达式的点击式界面。它有一个预构建的正则表达式库,你可以使用它,但要构建其他任何东西,你仍然需要了解一些正则表达式语言。
RegexMagic 对于正则表达式初学者来说非常棒,因为你只需输入一些示例文本并标记出你想要转换为正则表达式的区域。然后,RegexMagic 会为你生成正则表达式。
RegexBuddy 和 RegexMagic 真的只有两个小缺点。首先,它们都是闭源的商业程序,所以你需要为它们付费。但是,价格相当合理,每个仅需 39.95 美元。(如果你将它们一起购买,还可以享受折扣。)
第二个小缺点是它们仅为 Windows 操作系统编写。然而,你会很高兴地知道,它们都能在 Linux 机器上通过 WINE 良好运行。(事实上,我已经在这台 Fedora 工作站上安装了这两个工具。)RegexBuddy 和 RegexMagic 的网站甚至提供了如何在 WINE 下安装这些程序的指南。
WINE 是一个递归缩写,代表 WINE is Not an Emulator(WINE 不是一个模拟器)。它是一个翻译层,将 Windows 可执行代码转换为 Linux 可执行代码,并且几乎所有的 Linux 发行版都包含了这个工具。
另外,请知道我与 Just Great Software 的员工没有任何财务安排,因此我提到他们的产品并不会得到任何报酬。
要查看这些很酷的产品,只需访问它们的网站。你可以在这里找到 RegexBuddy:www.regexbuddy.com/index.html
你可以在这里找到 RegexMagic:www.regexmagic.com/
现在,让我们来看看一个免费的工具。
Regex101
如果你更倾向于使用免费的工具,可以查看 Regex101。它是基于网页的,因此无需下载或安装。虽然它没有 RegexBuddy 和 RegexMagic 那些丰富的功能,但足够让你开始使用。登录账号是可选的,但登录后会有一些好处。请注意,你不会创建一个新账号,而是通过你的 Google 或 Github 账号进行登录。
你可以在这里查看 Regex101:regex101.com/
我想我们已经覆盖了足够的理论。接下来,让我们进入实际应用。
查看一些实际的例子
我有一些关于如何在实际生活中使用 sed
、grep
和正则表达式的有趣案例研究。继续阅读并享受吧!
一次修改多个文件
如果你有多个文件需要以相同方式修改,可以通过使用文件名中的 *
通配符,让 sed
在一个命令中处理所有文件。几年前,我帮助一个网站维护者将一组基于 PHP 的网站从 CentOS 5 服务器迁移到 CentOS 6 服务器。为了使网站能够兼容更新版的 PHP,她需要在每个 .php
文件中添加一行新代码。这可能会有些麻烦,因为她需要修改大约 2,000 个文件,这比你手动编辑的数量要多。我建议她使用 sed
一次性修改所有文件,她立刻明白我在说什么。(我通过这个建议唤起了她的记忆。)
通过 Apache Webserver 日志搜索跨站脚本攻击
大约 15 年前,我的一位安全工作者朋友给我打电话,急切地求助。他在信用合作社工作的老板交给他一个 USB 存储设备,里面存储着四个 GB——没错,就是四个GB——的压缩 Apache 访问日志,并要求他查看其中是否有跨站脚本攻击的迹象。像我这样一位可怜的安全管理员,在这种情况下该怎么办呢?
跨站脚本攻击,通常简称为XSS,是恶意黑客用来窃取信息或操控网站的一种手段。我知道这个名字可能让人感到困惑,它并没有准确地描述 XSS 攻击的真实作用。更准确的说法应该是将这种攻击称为Javascript 注入攻击,因为这才是它真正的工作方式。
当攻击者发现一个易受此类攻击的网站时,他或她可以使用攻击工具,如 Kali Linux 中的一些工具,来执行攻击。或者至少,现在是这样。15 年前我朋友打电话给我时,仍然可以通过简单的网页浏览器执行 XSS 攻击。攻击者只需要将一些特殊的 Javascript 代码附加到网站 URL 的末尾,如下所示:
https://www.mybigbank.com/<BR SIZE="&{alert('XSS')}">
现在,已经不可能通过网页浏览器执行 XSS 攻击了,因为现代版本的所有网页浏览器现在都会对输入进行过滤。所以,现在 URL 中的 Javascript 代码已经没有任何效果了。
但,我偏题了。
在我朋友打电话给我之后,我搭建了一台装有 Apache Web 服务器的机器,然后从另一台机器对其执行了几次 XSS 攻击。我发现,作为 XSS 攻击结果的日志文件条目,在GET
部分会有一个%
模式,像这样:
192.168.0.252 – - [05/Aug/2009:15:16:42 -0400] "GET /%27%27;!–%22%3CXSS%3E=&{()
} HTTP/1.1″ 404 310 "-" "Mozilla/5.0 (X11; U; Linux x86_64; en-US; rv:1.9.0.12)
Gecko/2009070812 Ubuntu/8.04 (hardy) Firefox/3.0.12″
GET /%27%27
这一部分告诉我们这是来自 XSS 攻击的痕迹。没有任何由正常网站访问生成的日志条目会包含这种模式。
这个模式还有一些变体,我首先需要做的是创建正则表达式来匹配所有这些变体。我创建的正则表达式有GET /%
、GET /.%
和GET /.=%
。(点号是一个通配符,表示脚本在该位置查找任意字符。)接下来,我创建了脚本,使用find
工具来查找并读取所有的 Apache 日志文件。这里是我创建的脚本:
#!/bin/bash
inputpath=$1
output_file=$2
if [ -f $output_file ]; then
echo "This file already exists. Choose another filename."
exit;
fi
find $inputpath -iname '*.gz' -exec zcat {} \; | grep 'GET /%' > $output_file
find $inputpath -iname '*.gz' -exec zcat {} \; | grep 'GET /.%' >> $output_file
find $inputpath -iname '*.gz' -exec zcat {} \; | grep 'GET /.=%' >> $output_file
less $output_file
exit
首先,你会看到我使用了位置参数$1
来指定 USB 存储设备的挂载点,使用位置参数$2
来指定报告文件的文件名。if..then
结构是一个安全特性,用来防止用户覆盖已有文件。调用脚本的命令大概长这样:
./cross-site-search /media/PATRIOT/ ~/cross-site-search-results.txt
Linux 系统的日志文件通常都会被gzip
压缩,文件名会以.gz
结尾。因此,我让find
命令搜索 USB 存储设备中的所有.gz
文件,使用zcat
读取它们,然后将输出传递给grep
。最后,我让报告自动在less
中打开。
幸运的是,我的朋友能够验证这个脚本确实有效。前一个秋天,信用合作社雇佣了一家渗透测试公司进行安全测试,其中 XSS 攻击是测试的一部分。脚本找到了所有渗透测试人员的攻击实例,大家都很高兴。
自动化第三方仓库安装
接下来我要给你展示的另一个真实脚本,很遗憾,你将无法运行。那是因为我是在 2010 年或 2011 年创建的,当时我正在为 Nagios 监控系统编写培训文档和插件。我和客户当时都在使用 CentOS 5 和 CentOS 6,这两个版本现在都已经过时了。
这个场景是一个更大场景的一部分,涉及在 CentOS 服务器上自动下载并编译 Nagios Core 源代码。一个前提是安装 RPMForge 和 EPEL 第三方软件仓库。那时,在 CentOS 上安装第三方仓库还需要配置每个仓库的优先级,以防止一个仓库的包覆盖另一个仓库安装的包。由于我和客户经常需要进行新的 Nagios 安装,我写了一个脚本来帮助自动化这个过程。这个脚本太大,无法在这里重现,所以我邀请你从 Github 下载add-repos.sh
脚本。然而,我会在这里展示一些片段来解释一下。
在脚本的顶部,你会看到这个if..then..else
结构:
if [ $(uname -m) == x86_64 ]; then
rpm -Uvh http://pkgs.repoforge.org/rpmforge-release/rpmforge-release-0.5.3-1.el6.rf.x86_64.rpm
else
rpm -Uvh http://pkgs.repoforge.org/rpmforge-release/rpmforge-release-0.5.3-1.el6.rf.i686.rpm
fi
那时,计算机行业还在从 32 位转向 64 位 CPU。所以,已经废弃的 RPMForge 仓库的维护者为每种架构提供了单独的安装包。(我想完全切换到 64 位,但我的客户坚持仍然支持 32 位。)这个结构会自动检测安装了哪种版本的 CentOS,然后下载并安装适当的 RPMForge 包。
接下来是安装 EPEL 仓库包和设置仓库优先级的那一行。它看起来是这样的:
yum install -y epel-release yum-plugin-priorities
最后一步是使用sed
自动设置每个不同仓库配置文件中各部分的优先级。下面是其中一个文件的片段:
[base]
name=CentOS-$releasever - Base
mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo
=os&infra=$infra
#baseurl=http://mirror.centos.org/centos/$releasever/os/$basearch/
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-6
文件的其他部分分别命名为[updates]
、[extras]
、[centosplus]
和[contrib]
。(要查看整个文件,请从 GitHub 下载CentOS-Base.repo
文件。)其他配置文件也分为不同名称的部分。目标是使用sed
自动将priority=
这一行附加到每个文件中每个部分的[section_name]
行下方。
下面是这些sed
脚本的样子:
sed -i '/\[base\]/apriority=1' /etc/yum.repos.d/CentOS-Base.repo
sed -i '/\[updates\]/apriority=1' /etc/yum.repos.d/CentOS-Base.repo
sed -i '/\[extras\]/apriority=1' /etc/yum.repos.d/CentOS-Base.repo
sed -i '/\[centosplus\]/apriority=2' /etc/yum.repos.d/CentOS-Base.repo
sed -i '/\[contrib\]/apriority=2' /etc/yum.repos.d/CentOS-Base.repo
sed -i '/\[rpmforge\]/apriority=10' /etc/yum.repos.d/rpmforge.repo
sed -i '/\[rpmforge-extras\]/apriority=11' /etc/yum.repos.d/rpmforge.repo
sed -i '/\[rpmforge-testing\]/apriority=11' /etc/yum.repos.d/rpmforge.repo
sed -i '/\[epel\]/apriority=12' /etc/yum.repos.d/epel.repo
sed -i '/\[epel-debuginfo\]/apriority=13' /etc/yum.repos.d/epel.repo
sed -i '/\[epel-source\]/apriority=13' /etc/yum.repos.d/epel.repo
你会看到每个sed
脚本都将适当的优先级设置添加到它们应放置的位置。结果看起来大致是这样的:
[base]
priority=1
name=CentOS-$releasever - Base
. . .
. . .
现在,让我们看看最后一个真实场景。
在.csv 文件中填充空字段
如果你曾经需要处理列数据,你可能会遇到必须使用 逗号分隔值 (.csv
) 文件的情况。顾名思义,这些文件包含多行数据,每行都有相同数量的字段。字段之间都由逗号分隔,如下所示:
donnie@fedora:~$ cat inventory_2.csv
Kitchen spatula,$4.99,Housewares,Sale Over
Raincoat,$36.99,Clothing,On Sale!
Claw hammer,$7.99,Tools,On Sale Next Week
donnie@fedora:~$
.csv
文件的美妙之处在于,你可以使用普通的纯文本编辑器来创建它们。然后,你可以在任何电子表格程序中打开它们,就像这样:
图 9.2:将 .csv 文件作为电子表格打开
有时,你可能会遇到包含空字段的 .csv
文件,如下所示:
donnie@fedora:~$ cat myfile.csv
1,2,3,4,5,6,7
,,,,,,
1,,,4,5,,
,2,3,4,5,,
donnie@fedora:~$
你会看到逗号存在,但字段之间没有值。在电子表格程序中打开该文件时,应该是这样的:
图 9.3:打开一个包含空字段的 .csv 文件
好的,看起来不像完全的灾难,但你可能想要一些看起来更好的东西。因此,你需要找到一种简单的方法,在所有空字段中填充值。你可能想要插入一些占位符值,或者你可能想要插入一些解释字段为何为空的文本。无论哪种方式,sed
都能轻松完成这项工作。
但是,我得坦白说,别人已经为这个问题创建了解决方案,我真的无法在此基础上做出改进。所以,与其自己解释解决方案,不如邀请你访问作者的原始文章,里面包含了解决方案,链接如下:linuxconfig.org/how-to-fill-all-empty-valued-cells-within-a-csv-file-with-sed-and-bash-shell
好的,本章的内容就到这里了。让我们结束这部分,继续前进。
总结
正如我们通常做的那样,在本章中我们覆盖了很多内容。我首先向你解释了正则表达式、正则表达式的基本概念、sed
和 grep
。接着,我展示了如何将正则表达式与 sed
和 grep
配合使用。我还展示了一些很棒的工具,这些工具可以帮助简化正则表达式的创建过程。最后,我展示了一些实际场景,在这些场景中,我在自己的脚本中使用了这些概念。
下一章将讲解函数。我们在那里见。
问题
-
以下哪一项是使用扩展语法的
grep
的首选方法?-
Egrep
-
egrep
-
grep -e
-
grep -E
-
-
以下哪两种字符是正则表达式中使用的两种通用字符?(选择两个。)
-
digits
-
字母
-
字面量
-
数字
-
元字符
-
-
在创建正则表达式时,以下哪一项你会用作
or
运算符?-
or
-
-o
-
-or
-
|
-
?
-
-
正则表达式中有哪三种元字符?(选择三个。)
-
字面量
-
位置锚点
-
数字
-
字符集
-
修饰符
-
-
你会使用
sed
的哪个选项开关来保存更改到源文件中?-
-o
-
-i
-
-s
-
-n
-
进一步阅读
-
正则表达式教程——如何编写正则表达式:
www.geeksforgeeks.org/write-regular-expressions/
-
正则表达式信息:
www.regular-expressions.info/
-
学习正则表达式:初学者指南:
www.sitepoint.com/learn-regex/
-
正则表达式实用指南:
www.freecodecamp.org/news/practical-regex-guide-with-real-life-examples/
-
正则表达式备忘单和快速参考:
quickref.me/regex.html
-
如何在 Linux 上使用 sed 命令:
www.howtogeek.com/666395/how-to-use-the-sed-command-on-linux/
-
如何使用 sed 查找并替换文件中的字符串:
linuxize.com/post/how-to-use-sed-to-find-and-replace-string-in-files/
-
如何在 Linux 上使用 grep 命令:
www.howtogeek.com/496056/how-to-use-the-grep-command-on-linux/
-
如何使用 Linux grep 命令:
opensource.com/article/21/3/grep-cheat-sheet
答案
-
d
-
c 和 e
-
d
-
b, d 和 e
-
b
加入我们的 Discord 社区!
和其他用户、Linux 专家以及作者本人一起阅读本书。
提问、为其他读者提供解决方案、通过“问我任何问题”环节与作者交流,等等。扫描二维码或访问链接加入社区。
留下评论!
感谢您从 Packt Publishing 购买本书——我们希望您喜欢它!您的反馈非常宝贵,帮助我们改进和成长。阅读完本书后,请花几分钟时间在 Amazon 上留下评论;这只需要一分钟,但对像您这样的读者来说,它能带来巨大的影响。
扫描下方二维码,领取您选择的免费电子书。
第十章:理解函数
函数是几乎所有现代编程语言中的重要组成部分。它们只是执行特定任务的代码块。它们的酷点在于,允许程序员重用代码。也就是说,一个代码块可以在程序的不同地方被调用,或者可以被不同的程序从函数库中调用。在本章中,我将向你展示如何在 shell 脚本中使用函数的基础知识。
本章内容包括:
-
函数简介
-
定义函数
-
在 shell 脚本中使用函数
-
创建函数库
-
查看一些现实世界的例子
如果你准备好了,我们就开始吧。
技术要求
使用任何你的 Linux 虚拟机或你的 Linux 主机。
此外,和往常一样,你可以从 Github 上抓取脚本和文本文件,像这样:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
函数简介
正如我上面提到的,函数是执行特定任务的编程代码块。函数之所以这么酷,是因为它们大大减少了你需要输入的代码量。你不必每次执行一个任务时都输入相同的代码,而是可以直接调用函数。这减少了脚本的大小,也使得输入脚本时更不容易出错。
此外,在函数第一次被调用之后,它会驻留在系统内存中,这可以提高脚本的性能。
你可以创建接受主程序传递参数的函数,返回值给主程序的函数,或者执行一些独立任务的函数,这些任务既不涉及传递参数也不涉及返回值。学习创建函数很简单,因为大多数时候你只需使用我已经向你展示过的相同编码技巧。我的意思是,是的,你会需要学习一些新东西,但实际上没有什么难度。
如果你在工作站上运行 Linux,你已经在不自觉中使用了很多功能。要查看系统正在运行的功能,只需运行一个 declare -F
命令,像这样:
[donnie@fedora ~]$ declare -F
declare -f __expand_tilde_by_ref
declare -f __fzf_comprun
declare -f __fzf_defc
. . .
. . .
declare -f gawkpath_prepend
declare -f quote
declare -f quote_readline
[donnie@fedora ~]$
这些功能每次你登录到计算机时都会由 shell 加载到系统内存中,并被构成 bash
生态系统的各种实用工具使用。
运行 declare
命令并加上小写 -f
参数,还可以显示每个函数的代码。你也可以仅列出函数的名称,来只查看该函数的代码,像这样:
[donnie@fedora ~]$ declare -f __expand_tilde_by_ref
__expand_tilde_by_ref ()
{
if [[ ${!1-} == \~* ]]; then
eval $1="$(printf ~%q "${!1#\~}")";
fi
}
[donnie@fedora ~]$
要查看这些函数是在哪里定义的,你需要进入调试模式,然后使用 declare -F
命令,像这样:
[donnie@fedora ~]$ bash --debugger
[donnie@fedora ~]$ declare -F __expand_tilde_by_ref
__expand_tilde_by_ref 1069 /usr/share/bash-completion/bash_completion
[donnie@fedora ~]$
所以,你会看到这个函数是在 /usr/share/bash-completion/
目录下的 bash_completion
函数库文件的第 1069 行定义的。去看一下那个文件,你会看到它里面定义了很多函数。当你完成调试模式后,只需输入 exit
。
你还可以使用 type
命令获取关于函数的信息,像这样:
[donnie@fedora ~]$ type quote
quote is a function
quote ()
{
local quoted=${1//\'/\'\\\'\'};
printf "'%s'" "$quoted"
}
[donnie@fedora ~]$
现在你知道了什么是函数,接下来我们来看看如何定义一些函数。
定义一个函数
你可以在以下地方定义一个函数:
-
在命令行中
-
在一个 shell 脚本中
-
在一个函数库中
-
在一个 shell 配置文件中
从命令行定义函数很有用,特别是当你需要快速测试或尝试新代码时。下面是它的样子:
[donnie@fedora ~]$ howdy() { echo "Howdy, world!" ; echo "How's it going?"; }
[donnie@fedora ~]$ howdy
Howdy, world!
How's it going?
[donnie@fedora ~]$
这个函数的名字是howdy
,()
告诉 shell 这是一个函数。函数的代码包含在一对大括号内。这个函数由两个echo
命令组成,它们通过分号隔开。请注意,你需要在最后一个命令的末尾放置一个分号。你还需要确保在第一个大括号和第一个命令之间有一个空格,以及在最后一个分号和闭合的大括号之间有一个空格。
一些教程可能会告诉你,可以使用function
关键字开始函数,像你在这里看到的那样:
[donnie@fedora ~]$ function howdy() { echo "Howdy, world!" ; echo "How's it going?"; }
[donnie@fedora ~]$ howdy
Howdy, world!
How's it going?
[donnie@fedora ~]$
不过,创建没有function
关键字的函数也可以正常工作,所以实际上没有必要使用它。
请注意,使用function
关键字是一个非常糟糕的主意,因为它不具备可移植性。我的意思是,它在bash
、zsh
和ash
中有效,但在ksh
、dash
或 Bourne Shell 中不起作用。即使可以使用function
关键字,它也没有实际用途。因此,你最好的做法是永远不要使用它。
你在命令行中定义的任何函数都是临时的,一旦关闭终端就会消失。所以你需要将它们保存到文件中,使其持久化。而且,你在命令行中定义的任何函数在bash --debug
会话中是不可见的,因为它们没有被导出到子 shell。
在 shell 脚本、函数库文件和 shell 配置文件中创建函数的方式没有区别。但是,你在结构化函数时有几种不同的方法。
第一个方法是将整个函数放在一行中,可以选择是否使用前面的function
关键字。这和你在命令行中创建函数的方式完全一样。既然你已经见过这个方法了,我就不再多说了。
另一种方法是将函数结构化为多行格式,就像在其他编程语言(如 C 或 Java)中一样。这实际上是最好的选择,因为它使代码更加易读,减少了出错的可能性。一个快速的方法是通过declare -f
查看我刚才在命令行上创建的单行howdy
函数,像这样:
[donnie@fedora ~]$ declare -f howdy
howdy ()
{
echo "Howdy, world!";
echo "How's it going?"
}
[donnie@fedora ~]$
declare -f
命令会自动将我在命令行中输入的函数转换为多行格式。如果我愿意,我可以将它复制粘贴到 shell 脚本文件中。事实上,我决定将它复制到一个新的howdy_func.sh
脚本中。它将是这样的:
#!/bin/bash
howdy ()
{
echo "Howdy, world!";
echo "How's it going?"
}
如前所述,前面的function
关键字是可选的。我选择省略它,因为它没有实际作用,而且不兼容某些非bash
shell。
好的,这一切看起来都不错,但当我运行脚本时看看会发生什么:
[donnie@fedora ~]$ ./howdy_func.sh
[donnie@fedora ~]$
什么都没有发生,这可不好。我会在下一部分展示为什么。
在 shell 脚本中使用函数
你会惊讶于函数如何让你的 shell 脚本变得更加有功能。(是的,我知道,那个真的是个糟糕的双关笑话。)继续阅读,看看如何实现这一点。
创建并调用函数
当你将一个函数放入一个 shell 脚本中时,函数代码不会被执行,直到你调用该函数。让我们修改一下howdy_func.sh
脚本来实现这一点,像这样:
#!/bin/bash
howdy ()
{
echo "Howdy, world!";
echo "How's it going?"
}
howdy
你看到我所做的只是将函数名称放在函数代码块之外。另外,请注意,函数定义必须出现在函数调用之前。否则,shell 将无法找到该函数。无论如何,当我现在运行脚本时,它可以正常工作:
[donnie@fedora ~]$ ./howdy_func.sh
Howdy, world!
How's it going?
[donnie@fedora ~]$
我还应该指出,第一个大括号可以像上面那样单独放在一行,也可以与函数名称放在同一行,像这样:
#!/bin/bash
howdy() {
echo "Howdy, world!"
echo "How's it going?"
}
howdy
我个人更喜欢这种方式,有一个简单的理由。就是我偏爱的文本编辑器是vim
,并且将第一个大括号放在与函数名称同一行会导致vim
激活其自动缩进功能。
重用代码的能力是函数最酷的功能之一。你不必在每个需要执行某项任务的地方都输入相同的代码,只需输入一次,然后在需要的地方调用它。让我们对howdy_func.sh
脚本再做一点修改,像这样:
#!/bin/bash
howdy ()
{
echo "Howdy, world!";
echo "How's it going?"
}
howdy
echo
echo "Let's do some more stuff and then"
echo "we'll call the function again."
echo
howdy
运行修改过的脚本时是这样的:
[donnie@fedora ~]$ ./howdy_func.sh
Howdy, world!
How's it going?
Let's do some more stuff and then
we'll call the function again.
Howdy, world!
How's it going?
[donnie@fedora ~]$
所以,我调用了这个函数两次,它执行了两次。多酷啊?不过,它还会变得更好。
将位置参数传递给函数
你还可以像将位置参数传递给 shell 脚本一样,将位置参数传递给函数。为此,像你在这个greet_func.sh
脚本中看到的那样调用函数并传递你希望作为位置参数的值:
#!/bin/bash
greetings() {
echo "Greetings, $1"
}
greetings Donnie
你看到函数中的$1
位置参数。当我调用该函数时,我传递了Donnie
这个值给函数。下面是我运行脚本时发生的情况:
[donnie@fedora ~]$ ./greet_func.sh
Greetings, Donnie
[donnie@fedora ~]$
你也可以像平常一样将位置参数传递给脚本,然后将它传递给函数,像你在这里看到的那样:
#!/bin/bash
greetings() {
echo "Greetings, $1"
}
greetings $1
这是我运行这个脚本时发生的情况:
[donnie@fedora ~]$ ./greet_func2.sh Frank
Greetings, Frank
[donnie@fedora ~]$
这很好,但它只适用于一个参数。你传入的任何额外参数都不会出现在输出中。你可以通过定义更多的参数来解决这个问题,像这样:
#!/bin/bash
greetings() {
echo "Greetings, $1 $2"
}
greetings $1 $2
如果你知道总是想要传递指定数量的参数,这样也可以。如果你不想限制自己,可以使用$@
作为位置参数。这样,每次运行脚本时都可以传递任意数量的参数。看起来是这样的:
#!/bin/bash
greetings() {
echo "Greetings, $@"
}
greetings $@
让我们看看当我输入四个名称时会发生什么情况:
[donnie@fedora ~]$ ./greet_func4.sh Vicky Cleopatra Frank Charlie
Greetings, Vicky Cleopatra Frank Charlie
[donnie@fedora ~]$
看起来不错。但我还可以稍微修饰一下,就像这样:
[donnie@fedora ~]$ ./greet_func4.sh Vicky, Cleopatra, Frank, "and Charlie"!
Greetings, Vicky, Cleopatra, Frank, and Charlie!
[donnie@fedora ~]$
相当灵巧,是吧?更加灵巧的是,让函数将值传递回主程序。
从函数传递值
好的,这里我需要稍作停顿,并澄清一些其他编程语言的老手可能会遇到的事情。与其他语言不同,Shell 脚本函数不能直接将值返回给它的调用者,通常是程序的主体部分。
好的,在函数中有一个return
命令,但它与其他语言中的return
命令不同。在 Shell 脚本中,你可以通过return
仅传递命令的退出状态。
你可以创建一个返回值的函数,但你必须使用全局变量或局部变量和命令替换的组合来实现。另一个区别是,在其他语言中,变量默认是局部的,你必须指定哪些变量是全局的。在 Shell 脚本中,情况正好相反。Shell 脚本变量默认是全局的,你必须指定哪些变量是局部的。
现在,对于那些不是其他编程语言老手的人,请允许我解释一下局部和全局变量之间的区别。
全局变量可以从脚本的任何地方访问,即使你在函数内定义它。例如,看看这个value1.sh
脚本中的valuepass
函数:
#/bin/bash
valuepass() {
textstring='Donnie is the great BeginLinux Guru'
}
valuepass
echo $textstring
看看我运行脚本时会发生什么:
donnie@debian12:~$ ./value1.sh
Donnie is the great BeginLinux Guru
donnie@debian12:~$
在这个脚本中,你可以看到函数除了定义全局变量textstring
并设置其值外什么也没做。textstring
的值在函数外部是可用的,这意味着调用函数后,我可以回显textstring
的值。
现在,让我们像这样在value2.sh
脚本中将textstring
定义为局部变量:
#/bin/bash
valuepass() {
local textstring='Donnie is the great BeginLinux Guru'
}
valuepass
echo $textstring
看看这次我运行脚本时会发生什么:
donnie@debian12:~$ ./value2.sh
donnie@debian12:~$
这次没有输出,因为局部变量在函数外部是不可访问的。或者更准确地说,局部变量在函数外部是直接不可访问的。有方法可以使函数将局部变量的值传递给外部调用者,我马上就会给你展示。
不过首先,我想回答一个你一定很想问的问题。也就是说,如果我们可以使用全局变量将值传递到函数外部,那为什么还需要本地变量呢?其实,如果你在函数中只使用全局变量,你可能会把脚本弄成一个调试噩梦。因为如果你不小心在函数外创建了一个与函数内全局变量同名的变量,你就会覆盖函数内部变量的值。
通过在函数内部仅使用本地变量,你可以避免这个问题。如果你有一个与函数内的本地变量同名的外部变量,外部变量不会覆盖本地变量的值。为了演示这个,我们来创建一个 value3.sh
脚本,像这样:
#/bin/bash
valuepass() {
textstring='Donnie is the great BeginLinux Guru'
}
valuepass
echo $textstring
textstring='Who is the great BeginLinux Guru?'
echo $textstring
你会看到,我在函数内将 textstring
变量重新设置为全局变量,并且在函数外再次定义了这个变量。现在,当我运行脚本时,会发生以下情况:
donnie@debian12:~$ ./value3.sh
Donnie is the great BeginLinux Guru
Who is the great BeginLinux Guru?
donnie@debian12:~$
第二个 echo $textstring
命令使用了 textstring
的新值,覆盖了之前的值。如果这是你希望发生的情况,那是没问题的,但通常这并不是你想要的结果。
让一个函数通过本地变量传递值的一种方法是通过命令替换结构来调用该函数。为了演示这一点,创建一个 value4.sh
脚本,像这样:
#!/bin/bash
valuepass() {
local textstring='Donnie is the great BeginLinux Guru'
echo "$textstring"
}
result=$(valuepass)
echo $result
你会看到,我创建了外部的 result
变量,并将通过 $(valuepass)
命令替换获得的值赋给了它。
获取函数值的另一种方法是使用 结果变量。这涉及到在调用函数时将一个 变量名 传递到函数中,然后在函数内部给该变量赋值。让我们看一下 value5.sh
脚本,看看它是如何工作的:
#/bin/bash
valuepass() {
local __internalvar=$1
local myresult='Shell scripting is cool!'
eval $__internalvar="'$myresult'"
}
valuepass result
echo $result
valuepass result
命令调用函数,并通过 $1
位置参数将 result
变量的名称传递给函数。不过需要理解的是,result
仍然没有被赋值,所以你传递给函数的只是变量的名称,而没有传递其他任何东西。
到目前为止,你只看到过位置参数用于在调用脚本时传递值。这里,你看到了位置参数也可以用于将值从脚本的主体传递到函数中。
在函数内部,这个变量名由 $1
位置参数表示。这个变量名被赋值给本地变量 __internalvar
。下一行将 Shell scripting is cool!
赋值给本地变量 myresult
。在最后一行,eval
命令将 $myresult
的值设置为 $__internalvar
变量的值。
请记住,__internalvar
变量的值是result
,这是我们传入函数的变量名。同时,请注意,由于这里没有指定local
关键字,这个变量现在是全局变量。这意味着它的值可以返回到脚本的主体部分。
所以现在,$__internalvar
实际上就是result
变量,且它的值是Shell scripting is cool!
。
不过要理解的是,你传入函数的变量名不能与函数内部的局部变量名相同。否则,eval
命令将无法正确设置变量的值。这就是为什么我在局部变量名前加了两个下划线,以确保避免在函数外部有相同名称的变量。(当然,单个下划线也能达到目的,但约定俗成的做法是使用两个下划线。)
接下来,我们来创建一些库。
创建函数库
为了方便地使函数可重用,只需将它们放入函数库中。然后,在你的脚本中,使用source
命令或点命令调用所需的库。如果只有你一个人会使用这些库,可以将它们放在你的主目录中。
如果你希望所有人都能使用它们,可以将它们放在一个中心位置,比如/usr/local/lib/
目录下。
为了简单演示,我在/usr/local/lib/
目录中创建了donnie_library
文件,里面包含了两个简单的函数。它看起来是这样的:
howdy_world() {
echo "Howdy, world!"
echo "How's it going?"
}
howdy_georgia() {
echo "Howdy, Georgia!"
echo "How's it going?"
}
(我住在乔治亚州,这也解释了第二个函数的存在。)
这个文件不需要设置可执行权限,因为它只包含将在可执行脚本中被调用的代码。
接下来,我创建了library_demo1.sh
脚本,它看起来是这样的:
#!/bin/bash
source /usr/local/lib/donnie_library
howdy_world
你看到我使用source
命令引用了donnie_library
文件。然后,我像通常那样调用了howdy_world
函数。运行脚本看起来是这样的:
[donnie@fedora ~]$ ./library_demo1.sh
Howdy, world!
How's it going?
[donnie@fedora ~]$
我还创建了library_demo2.sh
文件,它看起来是这样的:
#!/bin/bash
. /usr/local/lib/donnie_library
howdy_georgia
这次我用点命令替换了source
命令,向你展示它们实际上执行的是相同的操作。
请注意,使用source
关键字适用于ash
、ksh
、zsh
和bash
,但在某些其他 shell(如dash
或 Bourne)上不起作用。如果你希望脚本能够在尽可能多的 shell 上运行,你需要使用点命令(dot),而避免使用source
。
然后,我在库文件中调用了第二个函数。运行这个脚本看起来是这样的:
[donnie@fedora ~]$ ./library_demo2.sh
Howdy, Georgia!
How's it going?
[donnie@fedora ~]$
为了做一个稍微更实用的例子,我们加入一个网络检查函数,使用case..esac
结构。不幸的是,donnie_library
文件现在太大,无法在书中展示,但你可以从 Github 仓库下载它。不过,我还是想指出关于这个case..esac
结构的一点:
network() {
site="$1"
case "$site" in
"")
site="google.com"
;;
*)
site="$1"
;;
esac
. . .
. . .
}
请注意,在第一个条件中,我使用了"")
构造。成对的双引号,中间没有空格,意味着位置参数没有传入任何值。这也意味着google.com
会作为默认的网站名称。
现在,使用这个新函数的library_demo3.sh
脚本如下所示:
#!/bin/bash
. /usr/local/lib/donnie_library
network $1
如果没有提供参数,运行脚本的结果是这样的:
[donnie@fedora ~]$ ./libary_demo3.sh
2023-10-27 . . . Success! google.com is reachable.
[donnie@fedora ~]$
这是当我提供有效网站名称作为参数时的结果:
[donnie@fedora ~]$ ./libary_demo3.sh www.civicsandpolitics.com
2023-10-27 . . . Success! www.civicsandpolitics.com is reachable.
[donnie@fedora ~]$
最后,这是当我提供一个无效的网站名称时得到的结果:
[donnie@fedora ~]$ ./libary_demo3.sh www.donnie.com
ping: www.donnie.com: Name or service not known
2023-10-27 . . . Network Failure for site www.donnie.com!
[donnie@fedora ~]$
我知道,真可惜没有一个网站是以我命名的。(也许某天我得解决这个问题。)
你猜怎么着?这就是我对函数库要说的全部内容。接下来让我们进入现实世界。
看一些真实世界的例子
这里有几个函数实际应用的酷例子,快来看看吧!
检查网络连接
这个函数本质上与我在前面一节中展示的函数相同。唯一的真正不同之处在于,我使用了if..then
结构,而不是case..esac
结构。
此外,我将这个函数放入了实际可执行的脚本中。不过,如果你愿意,也可以轻松将它移到库文件中。(由于书籍排版的限制,我不能在这里展示完整脚本,但你可以从 Github 仓库下载。它是network_func.sh
文件。)总之,下面是我真正希望你看到的重点部分:
#!/bin/bash
network() {
if [[ $# -eq 0 ]]; then
site="google.com"
else
site="$1"
fi
. . .
. . .
}
network
network www.civicsandpolitics.com
network donnie.com
你看,在我定义了network()
函数之后,我调用了它三次。我第一次调用时没有传入位置参数,这样它就会 ping google.com
。接着我又分别用另一个真实网站和一个假的网站作为位置参数调用了它。下面是我运行这个新脚本时得到的结果:
[donnie@fedora ~]$ ./network_func.sh
2023-10-27 . . . Success for google.com!
2023-10-27 . . . Success for www.civicsandpolitics.com!
ping: donnie.com: Name or service not known
2023-10-27 . . . Network Failure for donnie.com!
[donnie@fedora ~]$
那部分没问题。现在让我们查看系统日志文件,看看函数中的logger
工具是否正确记录了日志条目:
[donnie@fedora ~]$ sudo tail /var/log/messages
. . .
. . .
Oct 27 16:14:21 fedora donnie[38134]: google.com is reachable.
Oct 27 16:14:23 fedora donnie[38137]: www.civicsandpolitics.com is reachable.
Oct 27 16:14:23 fedora donnie[38140]: Could not reach donnie.com.
[donnie@fedora ~]$
是的,那部分也正常工作。所以再次证明,我们已经实现了酷炫功能。(当然,你也可以修改脚本,使你能够将自己的位置参数作为参数传入。我就不再赘述这部分内容了。)
请注意,我是在一台 Fedora 机器上测试这个脚本的,它仍然使用旧版的rsyslog
日志文件。如果你在 Debian 上尝试此操作,你需要自己安装rsyslog
。默认情况下,Debian 12 现在只使用journald
日志系统,并且没有安装rsyslog
。
接下来,这是我为自己编写的一些小东西。
使用 CoinGecko API
CoinGecko 是一个很酷的网站,你可以在这里查看几乎所有加密货币的价格和统计信息。如果你只需要查看一个币种,它使用起来相当快速,但如果你需要查看多个币种,就可能会比较耗时。
你可以创建一个自定义的硬币投资组合,这在你总是希望看到相同的硬币列表时很有用。但如果你总是想查看不同的硬币列表,这样做就不太有用了。所以,你可能想简化这个过程。为此,你可以创建一个使用 CoinGecko 应用程序编程接口(API)的 shell 脚本。
要充分利用 CoinGecko API 的所有功能,你需要成为付费客户,这个费用相当高。幸运的是,做这个演示时你不需要这样做。你只需使用 CoinGecko 的公共 API 密钥,它是免费的。为了演示这一点,我们来做一个动手实验。
动手实验 – 创建 coingecko.sh 脚本
首先,查看 CoinGecko 的文档页面(https://www.coingecko.com/api/documentation),并按照以下步骤进行操作:
-
向下滚动页面,你会看到可以使用公共 API 执行的各种功能列表。你可以使用下拉列表中的表单创建你将复制到 shell 脚本中的代码。我已经创建了
coingecko.sh
脚本,它执行其中三个功能,你可以从 Github 仓库下载。在使用之前,你需要确保你的系统上已安装curl
和jq
软件包。无论如何,下面是脚本的工作方式。 -
在
gecko_api
函数的顶部,我将coin
和currency
变量定义为位置参数$1
和$2
。if [ -z $coin ]; then
这一行检查coin
的值是否为零长度。如果是,说明在调用脚本时我没有输入任何参数。因此,脚本会显示一系列关于如何调用脚本的说明。 -
接下来,你会看到这个
elif
结构:elif [ $coin == list ]; then curl -X 'GET' \ "https://api.coingecko.com/api/v3/coins/list?include_platform=true" \ -H 'accept: application/json' | jq | tee coinlist.txt
-
我从 CoinGecko 的文档页面复制了这个
curl
命令,并将其粘贴到脚本中。 -
然后,我将输出管道传递给
jq
工具,将普通的json输出转换为美化后的 json格式。 -
然后,我将输出管道传递给
tee
命令,以便在屏幕上查看输出并将其保存到文本文件中。使用list
参数调用脚本将下载 API 支持的所有硬币列表。下一行的elif
结构与这个elif
结构相同,只是它将显示可以用于定价硬币的对比货币列表。 -
函数最后一行的
else
结构要求你输入一个硬币和一个对比货币作为参数。文档页面上的原始代码包含了我在文档页面表单中输入的硬币和对比货币的实际名称。我将它们分别更改为$coin
和$currency
变量。接着,我需要将围绕 URL 的一对单引号改为一对双引号,以便变量名中的$
能够正确解析。 -
假设你想查看比特币的美元价格。首先,打开你在运行
./coingecko list
命令时创建的coinlist.txt
文件。搜索比特币,你会找到一段类似于这样的内容:{ "id": "bitcoin", "symbol": "btc", "name": "Bitcoin", "platforms": {} },
-
接下来,打开你在运行
./coingecko currencies
命令时创建的currency_list.txt
文件。向下滚动,直到找到你想用来为特定币种定价的基准货币。现在,要查看你想要的币种在所选货币中的价格,输入coinlist.txt
文件中该币种的"id:"
值作为第一个参数,并将期望的基准货币作为第二个参数,像这样:[donnie@fedora ~]$ ./coingecko.sh bitcoin usd % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 157 0 157 0 0 876 0 --:--:-- --:--:-- --:--:-- 872 { "bitcoin": { "usd": 34664, "usd_market_cap": 675835016546.567, "usd_24h_vol": 27952066301.047348, "usd_24h_change": 2.6519150329293883, "last_updated_at": 1698262594 } } [donnie@fedora ~]$
-
所以,看起来当前比特币的价格是 $34664 美元。
你可以通过输入用逗号分隔的列表一次查看多个币种,像这样:
[donnie@fedora ~]$ ./coingecko.sh bitcoin,ethereum,dero usd
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 471 0 471 0 0 3218 0 --:--:-- --:--:-- --:--:-- 3226
{
"bitcoin": {
"usd": 34662,
"usd_market_cap": 675835016546.567,
"usd_24h_vol": 27417000940.815952,
"usd_24h_change": 2.647099155664393,
"last_updated_at": 1698262727
. . .
. . .
[donnie@fedora ~]$
-
如果你是一个交易者,你可能想要查看某个山寨币相对于其他货币的价格,比如比特币的 satoshi。这里,我正在查看 Dero 的比特币 satoshi 价格:
[donnie@fedora ~]$ ./coingecko.sh dero sats % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 161 0 161 0 0 746 0 --:--:-- --:--:-- --:--:-- 748 { "dero": { "sats": 8544.47, "sats_market_cap": 106868665749.11504, "sats_24h_vol": 50996466.4091239, "sats_24h_change": -3.2533636291339687, "last_updated_at": 1698263552 } } [donnie@fedora ~]
-
所以,Dero 的当前 satoshi 价格是 8544.47 sats。(当然,当你查看时,这些价格会有所不同。)
-
输出的前三行是脚本用来下载有关币种信息的 curl 命令。由于某种奇怪的原因,bash 把这个 curl 消息当做错误消息,尽管它实际上并不是错误消息。不过,这其实是好事,因为你可以通过在命令末尾使用标准错误重定向器来避免看到这个消息,就像这样:
donnie@fedora:~$ ./coingecko.sh dero sats 2>/dev/null { "dero": { "sats": 3389.23, "sats_market_cap": 42965410638.71136, "sats_24h_vol": 112468964.13986385, "sats_24h_change": 2.81262920882357, "last_updated_at": 1718482347 } } donnie@fedora:~$
-
哦,我差点忘了脚本中最重要的部分,那就是调用函数的命令:
gecko_api $1 $2
它要求输入两个位置参数,但如果你只输入 list
或 currencies
作为单个参数,它也能正常工作。
-
好的,这很好。但如果你有一个非常长的币种列表,并且每次调用这个脚本时都不想输入这么长的列表怎么办?这个很简单。我将创建一个 mycoins.sh 脚本,它将调用 coingecko.sh 脚本。它的样子如下:
#!/bin/bash ./coingecko.sh bitcoin,dero,ethereum,bitcoin-cash,bitcoin-cash-sv,bitcoin-gold,radiant,monero usd 2>/dev/null
当然,你可以编辑这个脚本,加入你自己喜欢的币种列表。
- 作为此主题的变体,也可以下载
coingecko-case.sh
脚本。它与之前的脚本相同,但我使用了case..esac
结构,而不是if..then
结构。
这就是关于函数的内容,至少目前是这样。不过,如果你在本书的其余部分看到更多内容,也不要太惊讶。
现在,让我们总结一下并继续前进。
总结
函数是几乎所有现代编程语言中的重要组成部分。在本章中,我向你展示了如何构建函数、如何调用函数,以及如何创建函数库,你可以为自己使用或与其他用户共享。
在下一章,我将向你展示如何使用 shell 脚本执行数学运算。到时见。
问题
-
以下哪两种方法可以用来创建函数?(选择两项。)
-
function function_name()
-
function_name()
-
declare -f function_name
-
declare -F function_name
-
-
你想创建一个函数,将变量的值返回给主程序。以下哪项应当使用来实现这一功能?
-
一个全局变量
-
一个局部变量
-
一个
return
语句 -
一个 continue 语句
-
-
你想创建一个任何用户都可以在他或她的脚本中使用的函数。最好的方法是什么?
-
在一个中心位置创建一个函数库文件,并使其可执行。
-
将新函数添加到
/etc/bashrc
文件中。 -
在一个中心位置创建一个函数库文件,但不要使其可执行。
-
D. 在你自己的主目录中创建一个函数库文件。
-
-
下面哪两个命令可以用来在你的 shell 脚本中引用一个函数库?(选择两个)
-
load
-
reference
-
source
-
.
-
-
你想查看加载到内存中的函数列表,以及它们的源代码。你应该使用哪个命令?
-
declare -f
-
declare -F
-
debugger -f
-
debugger -F
-
进一步阅读
-
Bash 函数:
linuxize.com/post/bash-functions/
-
从 Bash 函数返回值:
www.linuxjournal.com/content/return-values-bash-functions
-
Bash 函数及其使用方法 {变量、参数、返回值}:
phoenixnap.com/kb/bash-function
-
编写 shell 脚本 - 第 15 课:错误、信号和陷阱:
linuxcommand.org/lc3_wss0150.php
-
(请注意,本教程的作者使用全大写字母来表示脚本变量名称,我个人不推荐这样做。除此之外,这是一个很好的教程。)
-
Bash 脚本高级指南:
tldp.org/LDP/abs/html/abs-guide.html#FUNCTIONS
答案
-
a 和 b
-
b
-
c
-
c 和 d
-
a
加入我们的 Discord 社区!
与其他用户、Linux 专家和作者本人一起阅读这本书。
提问、为其他读者提供解决方案、通过 "问我任何问题"(Ask Me Anything)环节与作者聊天,等等。扫描二维码或访问链接加入社区。
第十一章:执行数学运算
各种操作系统的 shell 都可以从命令行或 shell 脚本中执行数学运算。在本章中,我们将讨论如何使用整数和浮点数数学进行运算。
本章包括以下内容:
-
使用表达式进行整数运算
-
使用整数变量进行整数运算
-
使用
bc
进行浮点数学运算
如果你准备好了,我们开始吧。
技术要求
你可以使用任何 Linux 虚拟机来进行此操作。而且,和往常一样,你可以通过以下方式下载脚本:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
使用表达式进行整数运算
你可以直接在bash
中进行整数运算,这有时非常方便。但是,bash
没有进行浮点运算的能力。对于浮点运算,你需要使用一个单独的工具,稍后我们会讨论。
如果你尝试使用echo
在命令行上执行数学运算,你会发现它无法正常工作。你得到的结果可能像这样:
[donnie@fedora ~]$ echo 1+1
1+1
[donnie@fedora ~]$ echo 1 + 1
1 + 1
[donnie@fedora ~]$
这是因为echo
将你的数学问题视为普通文本字符串。所以,你需要其他方法来解决数学问题。幸运的是,有几种不同的方法可以做到这一点。
使用expr
命令
expr
命令用于计算表达式。这些表达式可以是普通文本字符串、正则表达式或数学表达式。现在,我将仅讨论如何使用它来计算数学表达式。以下是其基本用法示例:
[donnie@fedora ~]$ expr 1 + 1
2
[donnie@fedora ~]$
注意,你需要在运算符和每个操作数之间留有空格,否则你会得到如下结果:
[donnie@fedora ~]$ expr 1+1
1+1
[donnie@fedora ~]$
如果没有空格,expr
只是将你输入的内容原样回显。
你可以使用expr
与+
、-
、/
、*
和%
运算符来执行加法、减法、除法、乘法或取余操作。(取余操作会显示除法操作后的余数。)使用*
运算符时需要特别注意,因为在 shell 中它会被解释为通配符。因此,在执行乘法时,你需要用\
对*
进行转义,像这样:
[donnie@fedora ~]$ expr 3 \* 3
9
[donnie@fedora ~]$
如果在*
前面没有反斜杠,你将会收到一个错误,错误信息如下:
donnie@fedora:~$ expr 3 * 3
expr: syntax error: unexpected argument '15827_zip.zip'
donnie@fedora:~$
对于更复杂的问题,正常的数学规则适用,如你所见:
[donnie@fedora ~]$ expr 1 + 2 \* 3
7
[donnie@fedora ~]$
数学法则规定,除法和乘法总是优先于减法和加法。所以,你可以看到2 * 3
的运算先于加1
。但是,就像在常规数学中一样,你可以通过在你想先执行的操作周围放置一对括号来改变运算顺序。它看起来是这样的:
[donnie@fedora ~]$ expr \( 1 + 2 \) \* 3
9
[donnie@fedora ~]$
注意我如何使用反斜杠转义每个括号符号,并且在(
和1
之间、2
和\
之间留有空格。
你也可以在expr
中使用变量。为了演示这一点,创建一个名为math1.sh
的脚本,像这样:
#!/bin/bash
val1=$1
val2=$2
echo "val1 + val2 = " ; expr $val1 + $val2
echo
echo "val1 - val2 = " ; expr $val1 - $val2
echo
echo "val1 / val2 = " ; expr $val1 / $val2
echo
echo "val1 * val2 = " ; expr $val1 \* $val2
echo
echo "val1 % val2 = " ; expr $val1 % $val2
使用 88 和 23 作为输入值运行脚本,看起来像这样:
[donnie@fedora ~]$ ./math1.sh 88 23
val1 + val2 =
111
val1 - val2 =
65
val1 / val2 =
3
val1 * val2 =
2024
val1 % val2 =
19
[donnie@fedora ~]$
请记住,expr
只能处理整数。所以,任何涉及小数的结果都会四舍五入到最接近的整数。
当然,你也可以像在这个math2.sh
脚本中一样,使用expr
和命令替换。
#/bin/bash
result1=$(expr 20 \* 8)
result2=$(expr \( 20 + 4 \) / 8)
echo $result1
echo $result2
这就是expr
的全部内容。接下来,让我们再看看echo
。
使用echo
与数学表达式
我知道,我刚刚告诉你不能使用echo
来执行数学运算。嗯,实际上你是可以的,但有一种特殊的方式。你只需要将数学问题放入$(( ))
构造或$[ ]
构造中,像这样:
[donnie@fedora ~]$ echo $((2+2))
4
[donnie@fedora ~]$ echo $[2+2]
4
[donnie@fedora ~]$
两种构造给出的结果是一样的,所以——至少在bash
中——你使用哪种只是个人喜好的问题。
这两个构造的一个非常酷的地方是,你不需要使用$
来回调它们内部任何变量的值。同样,你也不需要用反斜杠来转义*
字符。以下是math3.sh
脚本,展示给你看:
#!/bin/bash
val1=$1
val2=$2
val3=$3
echo "Without grouping: " $((val1+val2*val3))
echo "With grouping: " $(((val1+val2)*val3))
你还可以看到,在第二个echo
命令中,我用一对括号将val1+val2
括起来,以便让加法运算优先于乘法运算。无论如何,以下是我运行脚本时发生的情况:
[donnie@fedora ~]$ ./math3.sh 4 8 3
Without grouping: 28
With grouping: 36
[donnie@fedora ~]$
如果你觉得使用嵌套括号太混乱,你可能想改用方括号构造,像这样:
#!/bin/bash
val1=$1
val2=$2
val3=$3
echo "Without grouping: " $[val1+val2*val3]
echo "With grouping: " $[(val1+val2)*val3]
无论如何,你会得到相同的结果。
不过,这里有一个陷阱。方括号构造在某些其他 shell 中不起作用,比如 FreeBSD 和 OpenIndiana 中的/bin/sh
,以及 Debian 及其衍生版中的/bin/dash
。所以,为了让你的脚本更具可移植性,你需要使用((..))
构造来处理数学问题,尽管它可能会让人有点困惑。
这里是一个更实用的例子,展示了如何使用括号来改变运算的优先级。在这个new_year.sh
脚本中,我在计算距离新年还有多少周。我首先使用date +%j
命令来计算当年的天数。以下是该命令的输出:
[donnie@fedora ~]$ date +%j
304
[donnie@fedora ~]$
我是在 10 月 31 日写的这个,这一天是 2023 年的第 304^(天)。然后,我将这个结果从一年中的天数中减去,并将其除以 7,得到最终的答案。以下是脚本的样子:
#!/bin/bash
echo "There are $[(365-$(date +%j)) / 7 ] weeks left until the New Year."
你可以看到,我使用了方括号构造,以避免有太多嵌套括号带来的混乱。但正如我之前所说,这在某些非bash
的 shell 中是无法使用的。
这是我运行脚本时得到的结果:
[donnie@fedora ~]$ ./new_year.sh
There are 8 weeks left until the New Year.
[donnie@fedora ~]$
当然,你可能正在 2024 年、2028 年或甚至 2032 年阅读这篇文章,那些年份都是闰年,有 366 天,但这没关系。那一天的额外天数对这个特定的数学问题不会产生影响。
好吧,再来一个带有数学表达式的脚本示例?创建一个math4.sh
脚本,像这样:
#!/bin/bash
start=0
limit=10
while [ "$start" -le "$limit" ]; do
echo "$start... "
start=$["$start"+1]
done
这个脚本以 0 作为start
的初始值开始。然后它打印出start
的值,将其增加 1,再打印下一个值。循环会一直进行,直到达到limit
的值。以下是它的运行结果:
[donnie@fedora ~]$ ./math4.sh
0...
1...
2...
3...
4...
5...
6...
7...
8...
9...
10...
[donnie@fedora ~]$
这基本上涵盖了使用数学表达式的内容。现在让我们来看一下使用新类型变量的方法。
使用整数变量进行整数数学运算
你可以使用整数变量来代替数学表达式。
你已经看到了不适用的情况,它看起来是这样的:
[donnie@fedora ~]$ a=1
[donnie@fedora ~]$ b=2
[donnie@fedora ~]$ echo $a + $b
1 + 2
[donnie@fedora ~]$
这是因为默认情况下,变量的值是文本字符串,而不是数字。为了使其正常工作,可以使用declare -i
命令来创建整数变量,像这样:
[donnie@fedora ~]$ declare -i a=1
[donnie@fedora ~]$ declare -i b=2
[donnie@fedora ~]$ declare -i result=a+b
[donnie@fedora ~]$ echo $result
3
[donnie@fedora ~]$
这是math5.sh
脚本中的实现方式:
#!/bin/bash
declare -i val1=$1
declare -i val2=$2
declare -i result1=val1+val2
declare -i result2=val1/val2
declare -i result3=val1*val2
declare -i result4=val1-val2
declare -i result5=val1%val2
echo "Addition: $result1"
echo "Division: $result2"
echo "Multiplication: $result3"
echo "Subtraction: $result4"
echo "Modulus: $result5"
在declare -i
命令中,你不需要在变量名前加上$
来调用它们的值。你也不需要使用命令替换来将数学运算的结果赋值给变量。总之,以下是我运行它时的样子:
[donnie@fedora ~]$ ./math5.sh 38 3
Addition: 41
Division: 12
Multiplication: 114
Subtraction: 35
Modulus: 2
[donnie@fedora ~]$
由于这是整数运算,任何包含小数的结果都会被四舍五入到最近的整数。
有时,整数运算已经足够了。但如果你需要更多呢?那就在下一部分,我们敬请期待。
使用 bc 进行浮点数学运算
你刚才看到的几种从 shell 执行数学运算的方法都有两个限制。首先,这些方法只能处理整数。其次,当使用这些方法时,你只能进行基本的数学运算。幸运的是,bc
工具解决了这两个问题。事实上,要充分利用bc
的特性,你需要成为一名数学专家。(我不属于这个类别,但我还是可以向你展示使用bc
的基本操作。)
你应该会发现bc
已经安装在你的 Linux 或 Unix 系统上,因此你可能不需要再去安装它。
使用bc
有三种方式,分别是:
-
交互模式:你只需打开
bc
,然后在其命令行中输入数学命令。 -
程序文件:在
bc
语言中创建程序,并使用bc
执行它们。 -
将数学问题通过管道传递给 bc:你可以从 shell 命令行或 shell 脚本中执行此操作。
让我们先看看交互模式。
在交互模式下使用 bc
你可以通过在命令行输入bc
来启动bc
的交互模式,像这样:
[donnie@fedora ~]$ bc
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'.
现在,你只需在bc
命令提示符下输入一个数学问题。让我们先从 3 除以 4 开始,如下所示:
3/4
0
你会看到结果是 0。但等等,bc
不应该支持浮点运算吗?当然支持,但你必须使用-l
选项启动它,以加载可选的数学库。所以,我们先输入quit
退出并重新启动,如下所示:
[donnie@fedora ~]$ bc -l
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'.
3/4
.75000000000000000000
如果你不想看到这么多小数位,可以使用scale
命令。假设你只想看到两位小数,可以这样设置:
[donnie@fedora ~]$ bc -l
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'.
scale=2
3/4
.75
现在,让我们做一些更实际的事情,来解决一些几何问题。正如你可能知道的,三角形有三个角,三个角度的总和总是等于 180 度,正如你在这里看到的:
图 11.1:一个三角形,三个 60 度的角
例如,你可以有一个三角形,角度分别为 40 度、50 度和 90 度。然而,有时候你可能只知道两个角度的度数,并且需要找出第三个角度的度数。你可以通过将已知的两个角度的度数相加,并从 180 中减去它们的和来得到第三个角度。下面是在 bc
交互模式下的计算方式:
[donnie@fedora ~]$ bc -l
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'.
a=40
b=50
180-(a+b)
90
这也演示了如何在 bc
中使用变量。这非常方便,因为如果我想在另一组数值上重新运行计算,我只需要输入新的变量赋值。然后,我只需使用键盘上的上箭头键回到公式。
接下来,我们来看一个直径为 25,半径为 12.5 的圆。(单位不重要,可以是英寸、厘米、英里或公里,实际上都没关系。)要计算圆的周长,我们需要将圆的直径乘以 pi
(Π)的值,如下所示:
[donnie@fedora ~]$ bc -l
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'.
pi=3.14159265358979323846
diameter=25
circumference=pi*diameter
print circumference
78.53981633974483096150
要计算圆的面积,使用 Π 乘以半径的平方,如下所示:
radius=12.5
area=pi*(radius²)
print area
490.87385212340519350937
你在这里看到,^
用来表示指数运算,在这个例子中是 2。你还会看到,在 bc
语言中没有 echo
命令。相反,你需要使用 print
。
除了处理浮点数学的功能外,你还会在可选库中找到许多有用的函数。例如,你可以使用 ibase
和 obase
函数将数字从一种数字系统转换为另一种。在这里,你看到我将一个十进制数转换为十六进制数:
[donnie@fedora ~]$ bc -l
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'.
obase=16
10
A
obase=16
行告诉 bc
我希望所有的数字以十六进制格式输出。我不需要使用 ibase
行来指定输入的数字系统,因为它默认是十进制。当我输入 10 作为要转换的数字时,得到的结果是 A,这就是十进制数字 10 的十六进制等价。我还可以将十六进制转换回十进制,像这样:
[donnie@fedora ~]$ bc -l
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'.
ibase=16
A
10
同样,由于十进制是默认值,我不需要为 obase
指定它。(如果我在之前的例子中设置了 obase
为 16 后没有关闭并重新打开 bc
,那我就得指定了。)
这是同时设置 ibase
和 obase
的一个例子:
[donnie@fedora ~]$ bc -l
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'.
ibase=2
obase=16
1011101
10110
在这个例子中,我选择将一个二进制数转换为十六进制。
你还可以将 ibase
和 obase
设置为相同的值,以便在不同的数字系统中进行数学运算。以下是如何进行二进制运算的示例:
[donnie@fedora ~]$ bc -l
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'.
ibase=2
obase=2
101-1
100
101+101
1010
101/101
1.000000000000000000000000000000000000000000000000000000000000000000\
0
是的,我希望在上世纪八十年代学习二进制数学时能有这个,那时候会方便很多。但是要注意,在除法命令中,有太多的尾随零,所以使用\
来将它们继续到下一行。
你可以使用scale=
命令来进行更改,但是在二进制模式下使用它时,可能会得到一些令人惊讶的结果。这里是我的意思:
scale=2
101/101
1.0000000
scale=1
101/101
1.0000
我不知道为什么是这样,但没关系。
提示
请注意,如果你决定将一个十六进制数转换为另一种格式,那么字母 A 到 F 必须以大写形式输入。
bc
库中的其他函数包括:
-
s (x)
: x 的正弦,单位是弧度。 -
c (x)
: x 的余弦,单位是弧度。 -
a (x)
: x 的反正切,单位是弧度。 -
l (x)
: x 的自然对数。 -
e (x)
: 对数 e 的 x 次幂。 -
j (n,x)
: x 的整数阶贝塞尔函数。
例如,假设你需要找到数字 80 的自然对数。只需像这样做:
[donnie@fedora ~]$ bc -l
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'.
l(80)
4.38202663467388161226
好的,我想这应该涵盖了交互模式。现在让我们看看一些bc
程序。
使用 bc 程序文件
使用交互模式的主要问题是,一旦关闭bc
,所有的工作都会消失。使你的工作永久化的一种方法是创建一个程序文件。让我们开始创建geometry1.txt
文件,它看起来像这样:
print "\nGeometry!\n"
print "Let's say that you want to calculate the area of a circle.\n"
print "Enter the radius of the circle: "; radius = read()
pi=3.14159265358979323846
area=pi*(radius²)
print "The area of this circle is: \n"
print area
print "\n"
quit
你已经看到如何进行数学计算了,所以我不会再重复了。但是,我想让你注意到print
命令不会自动在行末插入换行符。因此,在你的print
命令末尾加上\n
序列来手动换行。另外,请注意第 3 行如何使用read()
函数接收用户输入并将其赋值给radius
变量。最后一个命令必须是quit
,否则程序将无法退出。要运行此程序,只需输入bc -l
,然后输入程序文件的名称,就像这样:
[donnie@fedora ~]$ bc -l geometry1.txt
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'.
Geometry!
Let's say that you want to calculate the area of a circle.
Enter the radius of the circle: 20
The area of this circle is:
1256.63706143591729538400
[donnie@fedora ~]$
提示
我在这里有点作弊,从一个已经计算好的网站上复制了 П 的值。如果你愿意自己计算 П 的值,可以用这个公式:
pi=4*a(1)
下一个示例是我从bc
文档页面借来的支票簿平衡程序。它的样子是这样的:
scale=2
print "\nCheck book program\n!"
print " Remember, deposits are negative transactions.\n"
print " Exit by a 0 transaction.\n\n"
print "Initial balance? "; bal = read()
bal /= 1
print "\n"
while (1) {
"current balance = "; bal
"transaction? "; trans = read()
if (trans == 0) break;
bal -= trans
bal /= 1
}
quit
在这里,你可以看到bc
语言具有与普通 Shell 脚本中已经看到的相同的编程结构。(在这种情况下,你看到了一个while
循环。)bc
语言对它们实现有些不同,但没关系。接下来要注意的是scale=2
和bal /= 1
这两行。这两个命令确保程序的输出始终保留小数点后两位,即使你只输入一个没有小数的整数。为了说明我的意思,打开交互模式中的bc
,并输入这些命令:
[donnie@fedora ~]$ bc
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'.
scale=2
bal=1000
print bal
1000
bal /= 1
print bal
1000.00
你看到在我调用bal /= 1
命令之前,print bal
只显示 1000,没有小数点。所以,为什么这样做有效呢?其实就是bal /= 1
命令是表达除以 1 的简写方式。换句话说,它做的和bal=(bal/1)
命令完全一样,只不过少了些输入。在这种情况下,我们将 1000 除以 1,结果还是 1000。但因为我们将 scale 设置为 2,所以任何数学操作的结果打印出来时都会显示两位小数。
程序文件中的下一个需要注意的地方是bal -= trans
这一行。-=
运算符使得余额根据代表财务交易的trans
数值减少。我真不明白程序作者为什么这么做,因为这意味着用户必须输入一个正数来减少余额,输入负数来增加余额。
将这一行改为bal +=
trans 会更有意义。这样,负数代表扣款,正数代表存款,一切都会顺利进行。无论如何,我有点跑题了。
运行程序的效果如下:
[donnie@fedora ~]$ bc checkbook_bc
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'.
Check book program
! Remember, deposits are negative transactions.
Exit by a 0 transaction.
Initial balance? 1000
current balance = 1000.00
transaction?
现在,只需输入你的交易记录,然后输入 0 退出。
你可以用bc
程序文件做更多的事情,但我想你已经掌握了大致的思路。现在,让我们看看如何在普通的 Shell 脚本中使用bc
。
在 Shell 脚本中使用 bc
使用bc
的第三种也是最后一种方式是从普通的 Shell 环境中运行bc
命令。所以,你可以从 Shell 命令行运行bc
命令,或者将它们放入普通的 Shell 脚本中。这里有一个示例:
[donnie@fedora ~]$ pi=$(echo "scale=10; 4*a(1)" | bc -l)
[donnie@fedora ~]$ echo $pi
3.1415926532
[donnie@fedora ~]$
在这里,我使用命令替换将一个值赋给pi
。在命令替换构造中,我将一个bc
风格的数学公式传递给bc
。你首先看到的是我将 scale 设置为 10。4*a(1)
意味着我正在计算 1 的反正切,并将结果乘以 4,这是你可以用来近似pi
(π)值的许多公式之一。(记住,π是一个无理数,这意味着你永远无法得到它的准确值。)
现在,让我们将这些内容放入pi_bc.sh
脚本中,它看起来是这样的:
#!/bin/bash
if [ -z $1 ]; then
echo "Usage:"
echo "./pi_bc.sh scale_value"
else
pi=$(echo "scale=$1; 4*a(1)" | bc -l)
echo $pi
fi
我稍微优化了一下,允许你在调用脚本时指定自己的缩放值。你可以看到,如果不输入缩放值,它会返回一条提示信息,告诉你需要输入。运行脚本的效果如下:
[donnie@fedora ~]$ ./pi_bc.sh 20
3.14159265358979323844
[donnie@fedora ~]$
当然,你可以根据需要输入更大的缩放值。
你也可以使用bc
来创建你自己的函数库。例如,查看我在/usr/local/lib/
目录下创建的这个baseconv.lib
库文件:
h2d() {
h2dnum=$(echo "ibase=16; $input_num" | bc)
}
d2h() {
d2hnum=$(echo "obase=16; $input_num" | bc)
}
o2h() {
o2hnum=$(echo "obase=16; ibase=8; $input_num" | bc)
}
h2o() {
h2onum=$(echo "obase=8; ibase=16; $input_num" | bc)
}
使用这个库的bc-func_demo.sh
脚本太长,无法在此完整展示。但是,你可以从 GitHub 仓库下载它。目前,我只会展示一些片段并提供解释。
脚本的顶部部分看起来是这样的:
#!/bin/bash
. /usr/local/lib/baseconv.lib
until [ "$choice" = "q" ]; do
echo "Choose your desired function from the following list: "
echo "For hex to decimal, press \"1\"."
echo "For decimal to hex, press \"2\"."
echo "For octal to hex, press \"3\"."
echo "For hex to octal, press \"4\"."
echo "To quit, press \"q\"."
你首先看到的是我通过. /usr/local/lib/baseconv.lib
命令引入库文件。接下来,你会看到一个我之前没展示过的结构。until. .do
结构会一直显示菜单,直到你按下q键。也就是说,如果你做出其他选择,系统会提示你输入一个想要转换的数字。转换完成后,菜单会立即重新出现,直到你按下q。接下来是一个case. .esac
结构,它根据你从菜单中选择的任务执行相应的操作。这里是其中的第一部分:
read choice
case $choice in
1) echo "Enter the hex number that you would like to convert. "
read input_num
h2d
echo $h2dnum
echo
echo
;;
运行脚本时会是这样:
[donnie@fedora ~]$ ./bc-func_demo.sh
Choose your desired function from the following list:
For hex to decimal, press "1".
For decimal to hex, press "2".
For octal to hex, press "3".
For hex to octal, press "4".
To quit, press "q".
2
Enter the decimal number that you would like to convert.
10
A
在此之后,超出了我在此处能够展示的范围,菜单会重新出现,等待你的下一个输入。
现在,这种方式确实有效,但它使用全局变量将函数中的值传递回主脚本。我已经告诉过你,这并不是最安全的做法,最好是结合使用局部变量和命令替换。修改后的库文件内容过长,无法在这里完全展示,但这里有一段代码示例:
h2d() {
local h2dnum=$(echo "ibase=16; $input_num" | bc)
echo $h2dnum
}
d2h() {
local d2hnum=$(echo "obase=16; $input_num" | bc)
echo $d2hnum
}
o2h() {
local o2hnum=$(echo "obase=16; ibase=8; $input_num" | bc)
echo $o2hnum
}
你可以从 Github 下载整个baseconv_local.lib
文件,以及使用它的bc-func_local_demo.sh
脚本。该脚本与之前的版本大致相同,除了在case. .esac
结构中的代码,它调用了不同的函数。这里有一段代码示例:
read choice
case $choice in
1) echo "Enter the hex number that you would like to convert. "
read input_num
result=$(h2d)
echo $result
echo
echo
;;
我已经在第十章——理解函数中解释过这个结构,所以在这里不再赘述。
我将向你展示的最后一个例子,使用了你曾以为永远不会用到的许多文本流过滤器之一。这涉及到使用paste
命令来帮助计算各种版本 Windows 操作系统的总市场份额。为了让你理解我的意思,看看这个你可以从 Github 下载的os_combined-ww-monthly-202209-202309-bar.csv
文件:
"OS","Market Share Perc. (Sept 2022 - Oct 2023)"
"Windows 11",17.21
"Windows 10",45.62
"Windows 7",3.5
"Windows 8.1",1.5
"Windows 8",1.3
"Windows XP",0.6
"OS X",17.66
"Unknown",6.55
"Chrome OS",3.15
"Linux",2.89
"Other",0.01
这些市场份额统计数据来自于Statcounter Global Stats网站,针对的是各种桌面操作系统。
好吧,有点像是这样。因为很多年前,当我首次为我教的 Shell 脚本课程创建这个演示时,Statcounter 的人们把 Windows 市场份额按不同版本进行了划分,如你所见。但现在,他们只在这份综合报告中列出 Windows 的总体市场份额,并在单独的 Windows-only 报告中细分 Windows 各版本的市场份额。所以,我不得不稍微修改一下这个文件,以重现以前的报告方式,使得演示能继续进行。(不过,管它怎么做,只要有效就好,对吧?)这个演示的脚本是report_os.sh
,你也可以从 Github 下载。
脚本中的第一个echo
命令会将所有版本的 Windows 市场份额加总,以计算 Windows 的总市场份额。以下是显示方式:
echo "The market share for Windows is $(grep 'Win' $file | cut -d, -f2 | paste -s -d+ | bc)%." > report_for_$(date +%F).txt
所以,在我从所有 Windows 行中提取出第二字段之后,我使用 paste
以串行模式,并使用 +
作为 paste
字段分隔符。我将所有内容通过管道传输给 bc
,然后将输出重定向到一个以今天日期命名的文本文件中。
其余操作系统的 echo
命令更加简单,因为它们不需要进行数学计算。以下是 macOS 的命令:
echo "The market share for macOS is $(grep 'OS X' $file | cut -d, -f2)%." >> report_for_$(date +%F).txt
是的,我知道苹果将他们的操作系统更名为 macOS。但是,Statcounter 的人们仍然将其列为 OS X,所以我需要在脚本中使用这个作为搜索词。无论如何,运行脚本的命令是这样的:
[donnie@fedora ~]$ ./report_os.sh os_combined-ww-monthly-202209-202309-bar.csv
[donnie@fedora ~]$
结果报告文件如下所示:
The market share for Windows is 69.73%.
The market share for macOS is 17.66%.
The market share for Linux is 2.89%.
The market share for Chrome is 3.15%.
The market share for Unknown is 6.55%.
The market share for Others is 0.01%.
当我修改输入文件以列出各种版本的 Windows 时,我确保 Windows 的总市场份额仍然符合预期。所以,是的,当我在 2023 年 10 月写这篇文章时,Windows 的市场份额确实是 69.73%。
顺便提一下,如果你有兴趣查看更多关于操作系统使用的统计信息,请访问 Statcounter Global Stats 网站:gs.statcounter.com/
我认为这已经涵盖了 shell 脚本数学运算的内容。让我们总结一下并继续前进。
总结
在这一章中,我向你展示了几种在 bash
中进行数学运算的方法,并提供了一些技巧,帮助你确保你的数学脚本可以在非 bash
的 shell 中运行。我从执行整数运算的各种方法开始,然后展示了使用 bc
进行浮点数运算的多种方法。正如我之前所说,你需要成为数学专家,才能充分利用 bc
的所有功能。但即使你不是数学专家,依然能做很多事情。而且,网上有很多数学教程可以帮助你。只需使用你喜欢的搜索引擎找到它们。
在下一章中,我将向你展示如何使用 here 文档。到时见。
问题
-
以下哪种方法可以在命令行中执行整数运算?
-
echo 1+1
-
echo 1 + 1
-
echo $(bc 1+1)
-
expr 1+1
-
expr 1 + 1
-
-
你希望确保你的 shell 脚本不仅能在
bash
上运行,还能在非bash
的 shell 上运行。以下哪些命令可以在你的脚本中使用?-
echo $((1+2+3+4))
-
echo $[1+2+3+4]
-
echo $[[1+2+3+4]]
-
echo $(1+2+3+4)
-
-
你想进行浮点数运算。以下哪些命令可以使用?
-
bc
-
bc -f
-
bc -l
-
bc --float
-
-
你需要找到 8 的自然对数。你该如何做?
-
expr log(8)
-
echo [log(8)]
-
使用
l(8)
和bc
-
使用
log(8)
和bc
-
-
以下哪个命令可以用来找到 П 的近似值?
-
pi=$("scale=10; 4*a(1)" | bc -l)
-
pi=$(echo "scale=10; 4*a(1)" | bc -l)
-
pi=$(bc -l "scale=10; 4*a(1)" )
-
pi=$(echo "scale=10; 4*arc(1)" | bc -l)
-
进一步阅读
-
Bash 数学运算(Bash Arithmetic)解析:
phoenixnap.com/kb/bash-math
-
在 Linux 上使用什么是好的命令行计算器:
www.xmodulo.com/command-line-calculator-linux.html
-
数学 LibreTexts:
math.libretexts.org/
-
Statcounter 全球统计:
gs.statcounter.com/
答案
-
e
-
a
-
c
-
c
-
b
加入我们的 Discord 社区!
与其他用户、Linux 专家以及作者本人一同阅读本书。
提问、为其他读者提供解决方案、通过“问我任何问题”环节与作者聊天,等等。扫描二维码或访问链接加入社区。
第十二章:使用 here Documents 和 expect 自动化脚本
那么,究竟here在哪里呢?嗯,我想它就只是我所在的地方,或者从你的角度来看,任何你所在的地方。更大的谜团是,似乎没有人弄明白,为什么这个非常有用的 shell 脚本结构会有如此奇怪的名字。
here document,也可以称为here script或heredoc,其实并不是一个文档,也与任何人的当前位置无关。但是,正如你很快会看到的,它在很多不同的方面非常有用,比如自动化脚本。
我将展示的第二种自动化方法是expect,它是一个具有自己脚本语言的脚本环境。
本章的主题包括:
-
使用 here Documents
-
使用 expect 自动化响应
好的,准备好进行自动化吧!
技术要求
你可以使用任何 Linux 虚拟机来完成本章的内容。此外,像往常一样,你可以通过以下方式从 GitHub 获取脚本和文本文件:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
使用 here Documents
Here document 是你放入脚本中的一块代码,用于执行特定任务。是的,我知道,我之前说过函数也是这么说的。但here documents完全不同。它们自 Unix 早期就存在,并且可以在各种不同的编程语言中使用。
格式说明:Unix 和 Linux 中公认的写法是将“here document”一词全部用小写字母书写。不过,这可能有点让人困惑。所以,为了消除混淆,我将在本书的其余部分将该术语斜体化,像这样:here document。
Here documents 的工作原理是通过提供另一种方式将输入重定向到特定命令。执行此重定向的代码被一对限界字符串括起来,通常长这样:
command << _EOF_
. . .
Code to execute or data to display
. . .
_EOF_
<<
序列是一种特殊类型的重定向符号。它从两个限界字符串之间的代码或文本中获取输入,这些字符串在本例中称为 _EOF_
。传统上,通常使用 _EOF_
或 EOF
作为限界字符串,但实际上你可以使用几乎任何文本字符串,只要它们在代码块的开始和结束位置相同,并且不会与任何变量或函数的名称冲突。事实上,如果你为限界字符串取个描述性的名字,通常有助于提高脚本的可读性。(一些教程中使用token而非limit string,但其实它们是一样的。)
除了使用<<
作为重定向符号外,你还可以使用<<-
,像这样:
command <<- _EOF_
. . .
Code to execute or data to display
. . .
_EOF_
添加破折号可以防止任何前导制表符字符缩进任何显示的文本。(前导空格仍然会缩进文本,但制表符则不会。我稍后会对此进行更详细的解释。)这样,你就可以缩进脚本中的代码,使其更具可读性,而不会导致输出内容被缩进。
以下是你可以使用here documents做的一些事情:
-
从 shell 脚本中显示多行注释。
-
创建一个简单的查找目录。
-
自动化文档的创建,比如网页内容文件或报告。
你的here 文档可以处理静态数据或动态数据。让我们从静态数据开始,因为它们是最简单的。
使用静态数据创建 Here 文档
有时你可能希望当用户调用脚本时,脚本显示一种多行消息。这条消息可以是帮助信息、版权信息或许可信息。你可以通过一系列echo
命令来实现,这看起来像这样:
#!/bin/bash
echo "This software is released under the GNU GPL 3 license."
echo "Anyone who uses this software must respect the terms"
echo "of this license."
echo
echo "GNU GPL 3 is a license that aims to protect software freedom."
这样有效,但如果你需要显示长消息,输入许多单独的 echo 命令会变得有些繁琐。使用here 文档来显示消息会让事情变得更加简单。以下是here-doc1.sh
脚本中显示这种效果的方式:
#!/bin/bash
cat << licensing
This software is released under the GNU GPL 3 license.
Anyone who uses this software must respect the terms
of this license.
GNU GPL 3 is a license that aims to protect software freedom.
licensing
在这种情况下,我使用licensing
作为限制字符串,而不是传统的_EOF_
。这样,我可以一眼看出here 文档在做什么。以下是我运行脚本时发生的情况:
[donnie@fedora ~]$ ./here-doc1.sh
This software is released under the GNU GPL 3 license.
Anyone who uses this software must respect the terms
of this license.
GNU GPL 3 is a license that aims to protect software freedom.
[donnie@fedora ~]$
如你所见,位于两个licensing
字符串之间的文本会被重定向到cat
命令,后者会将文本显示在屏幕上。
有时你需要为任何自由软件或 shell 脚本选择许可证。自由软件许可有很多种,你需要选择最适合你需求的一个。基本上,你可以将各种许可证分为宽松许可和非宽松许可。宽松许可,如 MIT 和 BSD 许可证,允许将被许可的代码嵌入到专有软件中,并且不要求将最终产品的源代码提供给客户。(这就是为什么苹果可以将 FreeBSD 代码包含在其专有的 OS X 和 macOS 操作系统中的原因。)非宽松许可,如各种 GNU 通用公共许可证,禁止将被许可的代码用于专有软件,并且要求软件供应商将最终产品的源代码提供给客户。总之,你可以在这里阅读有关各种自由软件许可的详细信息:opensource.org/licenses/
顺便提一下,MIT 代表麻省理工学院,BSD 代表伯克利软件分发,GNU 是一个递归首字母缩略词,代表“GNU 不是 Unix”。
有时你可能希望将代码缩进到你的here 文档中,以使脚本更具可读性。here-doc1.sh
脚本并不是这样做的,但我们假设它是这样。我们来创建here-doc1-tabs.sh
脚本,看看它是怎么做的:
#!/bin/bash
cat << licensing
This software is released under the GNU GPL 3 license.
Anyone who uses this software must respect the terms
of this license.
GNU GPL 3 is a license that aims to protect software freedom.
licensing
这与here-doc1.sh
相同,只不过我在“这款软件...”行前面放了两个制表符,在“任何人...”和“...此许可证...”行前面放了一个制表符,在“GNU GPL 3...”行前面放了三个空格。看看当我运行这个新脚本时会发生什么:
donnie@fedora:~$ ./here-doc1-tabs.sh
This software is released under the GNU GPL 3 license.
Anyone who uses this software must respect the terms
of this license.
GNU GPL 3 is a license that aims to protect software freedom.
donnie@fedora:~$
现在,假设我们想保留脚本中的制表符和空格,但又不希望制表符出现在输出中。我们需要做的就是将<<
改为<<-
,正如你在here-doc1-tabs-dash.sh
脚本中看到的那样:
#!/bin/bash
cat <<- licensing
This software is released under the GNU GPL 3 license.
Anyone who uses this software must respect the terms
of this license.
GNU GPL 3 is a license that aims to protect software freedom.
licensing
现在,看看我运行它时发生了什么:
donnie@fedora:~$ ./here-doc1-tabs-dash.sh
This software is released under the GNU GPL 3 license.
Anyone who uses this software must respect the terms
of this license.
GNU GPL 3 is a license that aims to protect software freedom.
donnie@fedora:~$
这次,脚本中所有以制表符开头的行现在都会输出与左边缘对齐的文本。然而,以三个空格开头的那一行则输出带有三个空格的文本。
正如我在本节开头段落中提到的,使用<<-
代替<<
可以防止here 文档中的前导制表符出现在脚本的输出中。但它允许任何前导空格显示出来。
重要的是要记住,在结束限制字符串前不能有任何空格。如果我在here-doc1.sh
脚本中的结束licensing
字符串前插入空格,我会得到类似如下的错误信息:
[donnie@fedora ~]$ ./here-doc1.sh
./here-doc1.sh: line 10: warning: here-document at line 3 delimited by end-of-file (wanted `licensing')
This software is released under the GNU GPL 3 license.
Anyone who uses this software must respect the terms
of this license.
GNU GPL 3 is a license that aims to protect software freedom.
licensing
[donnie@fedora ~]$
当然,这不是你想要的。
这个here-doc1.sh
脚本本身不是很有用,因为它唯一的作用就是显示一条信息。所以通常,你会将其余的代码放在here 文档之后。你可以做一个很酷的技巧,就是在here 文档后面立即放置一个sleep
命令和一个clear
命令,这样开头的消息将在主脚本开始执行之前显示指定的秒数。
此外,你并不局限于在脚本开头使用这种here 文档。你可以将它放在脚本的任何地方,只要你需要向用户传达某些信息。
接下来,让我们创建一个简单的查找目录,列出一些电话号码。这里是实现这一功能的here-doc2.sh
脚本:
#!/bin/bash
grep $1 <<directory
lionel 555-1234
maggie 555-2344
katelyn 555-4555
cleopatra 555-4818
vicky 555-1919
charlie 555-2020
frank 555-2190
goldie 555-8340
directory
exit
在这个脚本中,我将一个名称作为位置参数传递给grep
命令。电话目录条目位于两个directory
限制字符串之间。请注意,在这个脚本中,我没有像上一个例子那样在<<
和第一个限制字符串之间放置空格。这只是告诉你,空格是可选的,哪种方式都可以。无论如何,以下是我查找莱昂内尔电话号码时发生的情况:
[donnie@fedora ~]$ ./here-doc2.sh lionel
lionel 555-1234
[donnie@fedora ~]$
如果你的here 文档中包含任何元字符,这些字符会导致 Shell 采取某种动作,你需要用反斜杠将它们转义,正如你在here-doc3.sh
脚本中看到的那样:
#!/bin/bash
grep $1 <<donations
lionel \$5.00
maggie \$2.50
katelyn \$10.00
cleopatra \$7.35
vicky \$20.00
charlie \$3.00
frank \$8.25
goldie \$9.05
donations
exit
用引号包围包含特殊字符的字符串是行不通的,因为here 文档会将所有引号符号视为字面字符。
这种处理特殊字符的方法是可行的,但有更简单的方法。只需在限制字符串前加一个反斜杠,像这样:
#!/bin/bash
grep $1 <<\donations
lionel $5.00
maggie $2.50
katelyn $10.00
cleopatra $7.35
vicky $20.00
charlie $3.00
frank $8.25
goldie $9.05
donations
exit
你也可以用一对单引号将限制字符串包围起来,像这样:
#!/bin/bash
grep $1 <<'donations'
lionel $5.00
maggie $2.50
katelyn $10.00
cleopatra $7.35
vicky $20.00
charlie $3.00
frank $8.25
goldie $9.05
donations
exit
虽然这两种方法都很好用,而且比需要逐个转义每个特殊字符要容易,但如果你需要使用$
来获取变量的值,它们可能会弄乱你的代码。这是因为即使你不希望这样,所有内容都会被当作字面字符串处理。为了看看这是如何工作的,让我们看一下here-doc3-wrong.sh
脚本:
#!/bin/bash
grep $1 <<\donations
lionel $5.00
maggie $2.50
katelyn $10.00
cleopatra $7.35
vicky $20.00
charlie $3.00
frank $8.25
goldie $9.05
$1
donations
exit
你看到我在开头的donations
限制字符串前面放了一个反斜杠,用来转义所有的$
元字符。我还将$1
单独放在一行,在关闭的donations
限制字符串之前。我想要的效果是将$1
位置参数的值打印到输出的末尾。但是,当我运行它时,看看会发生什么:
donnie@fedora:~$ ./here-doc3-wrong.sh lionel
lionel $5.00
donnie@fedora:~$
现在,让我们去掉开头限制字符串前面的反斜杠,并且分别转义所有的$
元字符,除了位置参数中的那个。这段脚本现在应该如下所示:
#!/bin/bash
grep $1 <<donations
lionel \$5.00
maggie \$2.50
katelyn \$10.00
cleopatra \$7.35
vicky \$20.00
charlie \$3.00
frank \$8.25
goldie \$9.05
$1
donations
exit
当我现在运行脚本时,底部的$1
的值应该会打印出来,像这样:
donnie@fedora:~$ ./here-doc3-wrong.sh lionel
lionel $5.00
lionel
donnie@fedora:~$
这里的教训是,尽管我们的某些编程快捷方式非常有用,但它们并不总是适合使用。
这基本涵盖了静态部分。现在,让我们来看动态部分。
使用动态数据创建 here 文档
除了在here 文档中包含正常数据外,你还可以包括你通常会使用的编程结构和命令。然后,只需将输出重定向到动态生成的文档中。以下是here-doc4.sh
脚本,它展示了将输出重定向到.html
文件的一个简单示例:
#!/bin/bash
title="System Information for $HOSTNAME"
current_date=$(date +%F)
cat <<- _system-info_ > sysinfo.html
<title>
$title
</title>
<body>
$(uname -opr) <br>
Updated on: $current_date
</body>
_system-info_
为了证明它有效,只需在网页浏览器中打开结果文件。网页应该看起来像这样:
图 12.1:由here-doc4.sh
脚本生成的网页
当然,我在这里保持简单,创建了一个非常简单的文档,只有一些简单的 HTML 标签来格式化它。但是,如果你擅长 HTML 编码,你可以将文档做得非常精美。
如果你在想,HTML 代表超文本标记语言。很久以前,当公共互联网刚刚问世,我还留着满头的头发时,创建网站的唯一方法就是用 HTML 手工编写代码。(啊,是的,那是个美好的时代。)
你还可以创建其他格式的文档。例如,如果你想创建一个.pdf
文档,有几种方法可以做到。最简单的方法是从你的 Linux 或 FreeBSD 发行版的仓库中安装适当的pandoc
包。(它也可用于 macOS,但你需要从 Pandoc 网站下载安装程序包。)这里的一个小问题是,你还需要安装一个PDF 引擎,而且并非所有发行版都有 PDF 引擎可用。你最好的选择是坚持使用pdflatex 引擎,因为它几乎可以在所有 Linux 发行版上使用,同时也支持 FreeBSD 和 macOS。(遗憾的是,没有适用于 OpenIndiana 的pandoc
或 PDF 引擎包。)在你的 Fedora 虚拟机上,可以使用这个简单的命令来安装pandoc
和 pdflatex 引擎:
[donnie@fedora ~]$ sudo dnf install pandoc-pdf
这个命令也可以在 Red Hat Enterprise Linux 类发行版上安装pandoc
,但你首先需要安装 EPEL 仓库。在 AlmaLinux 和 Rocky Linux 上,像这样安装 EPEL:
[donnie@rocky ~]$ sudo dnf install epel-release
在 Debian 或 Ubuntu 类的发行版上,只需执行:
donnie@ubuntu2204:~$ sudo apt install pandoc texlive-latex-recommended
在 FreeBSD 上执行:
donnie@freebsd-1:~ $ sudo pkg install hs-pandoc texlive-full
请注意,对于 FreeBSD,我假设你已经安装了sudo
包,将自己添加到wheel
组,并配置了visudo
。如果没有,你可以现在以 root 用户登录,以便进行安装。此外,如果你还没有这样做,务必安装bash
包,并在/bin/
目录下创建一个指向bash
的符号链接,方法如下:
donnie@freebsd-1:~ $ sudo ln -s /usr/local/bin/bash /bin/bash
到目前为止,我向你展示的here-doc
脚本将在 FreeBSD 默认使用的/bin/sh
shell 上工作,但我很快会向你展示一个需要实际bash
的脚本。
对于 macOS,你需要从各自的网站下载 Pandoc 和 MacTeX 安装程序。
如果你在使用 OpenIndiana,很遗憾,你会失望的,因为没有可用于该系统的pandoc
或 pdf 引擎包。不过没关系,正如你将在稍后看到的,我已经制作了即将展示的演示脚本,使它依然可以在 OpenIndiana 上运行。
无论如何,安装pandoc
和 pdf 引擎包需要一些时间,因为许多依赖项也需要安装。因此,在等待的时候,你不妨去拿一杯你最喜欢的饮品。
一旦安装了pandoc
,在除了 OpenIndiana 之外的所有系统上,将以下几行添加到你的here-doc4.sh
脚本的末尾:
pandoc -o sysinfo.pdf sysinfo.html
rm sysinfo.html
现在,当你运行here-doc4.sh
脚本时,最终会生成sysinfo.pdf
文件作为结果。你可以在你喜欢的文档查看器中打开该文件,或者添加一行代码让文件自动打印出来。假设你已经安装了适当的打印机驱动程序并设置了默认打印机,你可以在脚本末尾添加以下命令来实现这一功能:
lpr sysinfo.pdf
提示
我已经在第七章,文本流过滤器 - 第二部分中向你展示了如何设置默认打印机并使用lpr
。
我之所以在这个这里文档示例中使用 HTML,仅仅是因为 HTML 非常简单。如果你对其他文档标记语言如 Postscript、Markdown、Troff 或 LaTeX 非常熟悉,可以随意用它们替代 HTML。在所有情况下,如果你需要,你都可以将生成的文件转换为.pdf
文件。另一方面,如果你不需要那么复杂的格式,可以省略所有标记语言标签,直接将输出保存为纯文本文件。然后,使用适当的文本流过滤器,如fmt
和pr
,将文件准备好用于打印。可能性仅受你自己想象力的限制。
现在我们已经介绍了基础内容,让我们来看一些稍微复杂一点的内容。
在此文档中使用函数
对于这一部分,我创建了sysinfo.lib
文件,你可以从 Github 下载。然后,将它复制到你的/usr/local/lib/
目录中。最后,从 Github 下载system-info.sh
脚本。我不能在这里展示库文件或 shell 脚本的完整内容,但我可以给你展示一些代码片段并提供一些解释。
在这个演示中,你将看到我为使这个脚本能够在 Linux、FreeBSD、macOS 和 OpenIndiana 上工作所做的事情。
库中的前两个函数,show_uptime()
和drive_space()
,非常简单。每个函数仅执行一个简单的系统信息命令,然后添加一些 HTML 标签,正如你在这里看到的:
show_uptime() {
echo "<h2>System uptime</h2>"
echo "<pre>"
uptime
echo "</pre>"
}
drive_space() {
echo "<h2>Filesystem space</h2>"
echo "<pre>"
df -P
echo "</pre>"
}
我在drive_space()
函数中使用了df -P
,以便在 macOS 上正确格式化输出。(-P
在 Linux、FreeBSD 或 OpenIndiana 上并不需要,但加上它也没有坏处。)
现在,看看home_space()
函数:
home_space() {
echo "<h2>Home directory space by user</h2>"
echo "<pre>"
echo "Bytes Directory"
if [ $(uname) = SunOS ]; then
du -sh /export/home/* | sort -nr
elif [ $(uname) = Darwin ]; then
du -sh /Users/* | sort -nr
else
du -sh /home/* | sort -nr
fi
echo "</pre>"
}
这个函数使用du
工具报告每个用户的主目录占用的磁盘空间。一个小问题是,OpenIndiana 和 macOS 不像 Linux 和 FreeBSD 那样将用户的主目录放在/home/
目录下。所以,我加入了代码来确定du
应该查看的位置,这取决于操作系统。
接下来是open_files()
和open_files_root()
函数,它们报告了 apache 和 root 用户打开的文件数量。两个函数除了指定的用户不同外是相同的,因此我这里只展示其中一个:
open_files_root() {
echo "<h2>Number of open files for root</h2>"
echo "<pre>"
lsof -u root | wc -l
echo "</pre>"
}
你会发现它非常简单,只是将lsof -u
的输出传递给wc -l
来统计指定用户打开的文件数量。
下一个函数是open_files_users()
,稍微复杂一些。我再次需要考虑主目录位置的差异,正如你在函数前半部分看到的:
open_files_users() {
echo "<h2> Number of open files for normal users</h2>"
if [ $(uname) = SunOS ]; then
cd /export/home
elif [ $(uname) = Darwin ]; then
cd /Users
else
cd /home
fi
函数的第二部分是一个for循环,它读取用户主目录的名称,然后在lsof
命令中使用这些名称。但这里还有一个问题需要解决。如果你的 Linux 机器将/home/
目录挂载到自己的分区上,并且该分区使用ext3
或ext4
文件系统格式化,那么会有一个lost+found
目录,它不是用户的主目录。在 macOS 上,你会看到一个Shared
目录,它也不是用户的主目录。如果你尝试将这两个目录名称之一作为lsof
的参数,你会收到错误。因此,我必须添加一些代码来排除这两个目录名称不被使用,如下所示:
for user in *
do
if [[ $user != "lost+found" ]] && [[ $user != "Shared" ]]; then
echo "There are $(lsof -u $(id -u $user) | wc -l) open files for $user. "
echo "<br>"
fi
done
cd
}
你可以看到,我使用了&&
序列作为and
操作符,这样我就能把两个测试条件放入一个if..then
结构中。我找到的一个参考文献指出,and
(&&
)和or
(||
)操作符在与[[..]]
测试结构一起使用时,比与[..]
测试结构一起使用时效果更好。不过,我测试了这两种方式,对我来说都可以正常工作。我还向你展示了一些我不记得以前展示过的内容。也就是说,你可以将一个命令替换结构嵌套在另一个命令替换结构中,正如你在第一个echo
命令中看到的那样。
最后是system_info()
函数,它在不同操作系统上会有所不同。这是因为在 Linux 和 FreeBSD 中,系统信息保存在/etc/os-release
文件中,而在 OpenIndiana 中,系统信息保存在/etc/release
文件中。在 macOS 上根本没有任何类型的发行版文件,因此我不得不使用其他方法。无论如何,下面是这个函数的前部分,适用于 Linux 和 FreeBSD:
system_info() {
# Find any release files in /etc
if [ -f /etc/os-release ]; then
os=$(grep "PRETTY_NAME" /etc/os-release)
echo "<h2>System release info</h2>"
echo "<pre>"
echo "${os:12}"
echo "<br>"
uname -orp
echo "</pre>"
如果存在/etc/os-release
文件,则os
变量的值将是该文件中的PRETTY_NAME
行。echo "${os:12}"
命令将去掉PRETTY_NAME
部分,这样只留下 Linux 或 FreeBSD 发行版的实际版本名称。然后我使用uname -orp
命令显示我希望每个人看到的系统信息。(我会让你查看uname
的手册页,了解所有选项的作用。)
函数的下一部分是适用于 OpenIndiana 的,如下所示:
elif [ $(uname) = SunOS ]; then
echo "<h2>System release info</h2>"
echo "<pre>"
head -1 /etc/release
echo "<br>"
uname -orp
echo "</pre>"
OpenIndiana 中的/etc/release
文件与 Linux 和 FreeBSD 中的/etc/os-release
文件不同。因此,我使用head -1
命令读取文件的第一行,该行包含了发行版的版本名称。
该函数的最后部分是适用于 OS X/macOS 的,如下所示:
elif [ $(uname) = Darwin ]; then
echo "<h2>System release info</h2>"
echo "<pre>"
sw_vers
echo "<br>"
uname -sprm
echo "</pre>"
else
echo "Unknown operating system"
}
由于没有任何类型的发行版文件,我改用了sw_vers
命令,这个命令仅在 OS X 和 macOS 上可用。该命令的输出如下所示:
Donald-Tevaults-Mac-Pro:~ donnie$ sw_vers
ProductName: Mac OS X
ProductVersion: 10.14.6
BuildVersion: 18G9323
Donald-Tevaults-Mac-Pro:~
是的,那是一个非常旧的 Mac 操作系统版本。但是,这台机器是 2010 年中期型号的 Mac Pro,而这是它可以运行的最新版本。(嗯,它可以运行更近期的版本,但我必须执行一些苹果未授权的非正常操作才能实现。这涉及安装 OpenCore Legacy Patcher,它将修改 Mac 的引导程序,让你能够安装更新的 macOS 版本。不过要注意,可能会发生问题,导致你的 Mac 无法启动。相信我,我知道这是什么感觉。)
我还不得不为uname
使用不同的选项组合,因为 macOS 使用的是不同版本的uname
。(再说一遍,查看你 Mac 上uname
的 man 页面,看看选项开关的作用。)
这就是函数库文件的全部内容。现在,我们需要查看使用这个库的system_info.sh
脚本。以下是顶部部分:
#!/bin/bash
# sysinfo_page - A script to produce an HTML file
. /usr/local/lib/sysinfo.lib
title="System Information for $HOSTNAME"
right_now=$(date +"%x %r %Z")
time_stamp="Updated on $right_now by $USER"
我们在第八章,基本 Shell 脚本构建中讨论过的 shebang 行必须是#!/bin/bash
,因为一些由#!/bin/sh
引用的替代 Shell 与我需要使用的某些编程结构不兼容。因此,如果你在 Alpine Linux 或 FreeBSD 上运行此脚本,你需要安装bash
。
剩下的顶部部分只是读取函数库并设置我将在脚本其余部分使用的变量。所以,这里没有什么难的。
下一部分是here 文档,它插入了正确的 HTML 标签并调用了几个函数,如下所示:
cat <<- _system-info_ > sysinfo.html
<html>
<head>
<title>
. . .
. . .
$(home_space)
$(open_files_root)
$(open_files_users)
</body>
</html>
_system-info_
所以,再次强调,这里没有什么难的。
脚本的最后一部分将.html
输出文件转换为.pdf
文件,前提是已经安装了pandoc
包。如果没有安装,它将保持.html
文件不变。如下所示:
if [[ -f /usr/local/bin/pandoc ]] || [[ -f /usr/bin/pandoc ]]; then
pandoc -o sysinfo.pdf sysinfo.html
rm sysinfo.html
fi
exit
尽管我希望总是有一个.pdf
文件,但在 OpenIndiana 上这是不可能的。正如我之前提到的,pandoc
不在 OpenIndiana 的仓库中。我看到的唯一其他可用于 OpenIndiana 的.pdf
创建工具是groff
,但那涉及使用完全不同的标记语言。
当你运行这个脚本时,你需要使用sudo
权限才能访问其他用户的信息。另外,如果你收到关于脚本无法访问的目录的错误消息,不必惊慌,因为即使使用sudo
,这也会发生。例如,在我的 Fedora 工作站上,脚本无法访问位于我的主目录中的pCloud
目录,因为它是远程驱动器的挂载点。
关于here 文档,我现在就说这么多。但在第二十章,Shell 脚本可移植性中,我会进一步介绍它们,并展示更多让脚本能够跨各种操作系统和 Shell 运行的方法。不过现在,让我们看看接下来会发生什么。
使用 expect 自动化响应
可能会有一些时候,你需要运行一个会多次暂停,提示你输入某些信息的脚本。如果需要多次在多个服务器或工作站上运行这个脚本,这可能会变得有些繁琐。难道不希望能够自动化输入吗?那么,恕我直言,你可以使用expect
来做到这一点。
那么,expect
是什么呢?它是一个编程环境,类似于你在bash
中使用的环境。如果你有一个交互式的 shell 脚本,它期望某些响应,你可以使用expect
自动发送正确的响应。让我们从最简单的例子开始。首先,创建interactive_script.sh
脚本,如下所示:
#!/bin/bash
echo "Hello. What is your name?"
read $reply
echo "What's your favorite operating system?"
read $reply
echo "How many cats to you have?"
read $reply
echo "How many dogs do you have?"
read $reply
当你运行这个脚本时,它会在每个read $reply
命令处暂停,以便你输入响应。当然,这对于这个简单的脚本来说不是什么大问题。但现在,假设你写了一个长且复杂的软件测试脚本,需要软件测试人员不断输入响应。如果你不喜欢痛苦(我怀疑你会喜欢),你可能不想一直坐在工作站前,不停地为这些长时间的测试输入响应。因此,你最好的选择是自动化这个过程,而自动化的最佳方式就是使用expect
。在 Mac 上你应该会发现它已经预装好了,但对于其他操作系统,你需要自行安装它。
通过以下命令验证它是否已安装:
which expect
如果它没有安装,它应该可以在你使用的发行版的仓库中找到,作为expect
包提供。在 FreeBSD 上,你需要额外创建一个符号链接,这样你就不需要修改脚本就能在 FreeBSD 上运行。可以这样做:
donnie@freebsd-1:~ $ sudo ln -s /usr/local/bin/expect /bin/expect
donnie@freebsd-1:~ $
安装完成后,创建interactive_script.exp
伴随脚本。最简单的做法是使用autoexpect
,像这样:
[donnie@fedora ~]$ autoexpect -f interactive_script.exp ./interactive_script.sh
autoexpect started, file is script.exp
Hello. What is your name?
Donnie
What's your favorite operating system?
Linux
How many cats do you have?
1
How many dogs do you have?
0
autoexpect done, file is script.exp
[donnie@fedora ~]$
如你所见,autoexpect
运行原始的interactive_script.sh
脚本,并提示你输入响应。响应将被保存到你通过-f
选项指定的interactive_script.exp
脚本中。注意,如果原始脚本位于你的主目录中,你需要在脚本名之前加上./
,这样autoexpect
才能找到它。由于整个interactive_script.exp
脚本太长,无法显示,我将展示一些片段并做些解释。
首先需要注意的是,expect
脚本有自己的 shebang 行,格式如下:
#!/bin/expect -f
-f
表示响应将从此文件读取。
接下来我想展示的是spawn
命令,它是这样的:
spawn ./interactive_script.sh
当我运行expect
脚本时,它将自动启动原始的 shell 脚本。
接下来,让我们来看一下expect
脚本如何提供响应:
expect -exact "Hello. What is your name?\r
"
send -- "Donnie\r"
expect -exact "Donnie\r
What's your favorite operating system?\r
"
send -- "Linux\r"
expect -exact "Linux\r
How many cats to you have?\r
"
send -- "1\r"
expect -exact "1\r
How many dogs do you have?\r
"
send -- "0\r"
expect
命令复制了原始 shell 脚本中的问题。通过包含 -exact
选项,问题必须与原始问题完全相同。第一个 expect
命令只会询问我的名字,其他所有命令既会返回响应,又会拉取下一个问题。每行末尾的 \r
提供了回车符,使得下一个响应或问题会显示在新的一行。(请注意,这并不是那种在文本文件或配置文件中可能引发麻烦的 Windows 类型回车符。)
expect
脚本的最后一行如下所示:
expect eof
正如你所料,这表示文件结束。
autoexpect
另一个酷的功能是,它会自动为它生成的脚本文件设置可执行权限。因此,你不必自己做这件事。现在,这是我运行这个 expect
脚本时发生的情况:
[donnie@fedora ~]$ ./interactive_script.exp
spawn ./interactive_script.sh
Hello. What is your name?
Donnie
What's your favorite operating system?
Linux
How many cats to you have?
1
How many dogs do you have?
0
[donnie@fedora ~]$
所有的问题都自动得到了回答,我完全没有与脚本进行任何交互。
如此强大的 autoexpect
也有一些缺点。首先,它仅随 Linux 发行版的 expect
软件包提供。在一些奇怪的情况下,它没有提供给 FreeBSD、OpenIndiana 或 macOS。因此,如果你需要为这些操作系统创建 expect
脚本,最好的方法是先在 Linux 机器上创建,再将其传输到非 Linux 机器。其次,如果你用它来处理使用 curl
或 wget
下载软件的软件安装脚本,curl
或 wget
显示下载状态的输出会成为 expect
脚本的一部分。你需要手动从 expect
脚本中删除这些内容,才能确保脚本正常运行。最后,你不能指望 autoexpect
做所有事情。有时,你需要亲自编写 expect
脚本。
使用 expect
的安全隐患
有很多使用 expect
的方法,但在我个人看来,它在软件测试或安装的自动化中最为有用。你会发现大多数其他教程展示了如何自动化 ssh
登录会话或 scp
文件传输,但如果你用 expect
做类似的事情,必须小心。问题是目标机器的密码会以明文格式存储在 expect
脚本中,而这个脚本本身也是明文的。更糟糕的是,一些教程让你像这样访问目标机器的 root 用户帐户。
现在,我的通用规则是在设置服务器时永远不启用 root 用户账户,只要操作系统安装程序给我这个选择。当设置像 FreeBSD 这样的系统时,如果没有这个选择,我喜欢将sudo
安装并配置为我的第一个安装后步骤,然后禁用 root 账户。然而,有时确实需要启用 root 用户账户,并使用scp
将文件传输到 root 用户账户。如果确实需要这样做,那么禁用服务器上的密码认证,并改用基于密钥的认证会更安全。
即便如此,我只在严格控制的本地网络内访问 root 用户账户,而不是通过互联网。说到这些,如果你仍然觉得有必要在 expect 脚本中存储明文ssh
密码,一定要将脚本存储在只有你或你团队的信任成员能访问的地方。(但希望你能找到其他不需要将密码放入expect
脚本中的方法。)
我对expect
唯一要说的就是它是一个庞大的话题,至少有一本完整的书已经写了它。(如果有人感兴趣,我会在进一步阅读部分提供链接。)
好的,让我们结束本章并继续下一章。
总结
在本章中,我向你展示了一些很酷的技巧,帮助你自动化脚本。首先我向你展示了here 文档,以及如何用几种不同的方式使用它们。接着,我向你展示了 expect,它本身就是一种完整的脚本语言。我展示了几种使用 expect 的方法,并讨论了它的一些安全影响。
在下一章,我将向你展示一些用于 ImageMagick 的脚本技巧。我们在那里见。
问题
-
什么是 here 文档?
-
它显示了你当前的位置。
-
它是一个特定目的的代码块。
-
它是一种可执行脚本类型。
-
它是一个静态数据块。
-
-
以下哪项陈述是正确的?
-
here 文档使用它自己的脚本语言。
-
here 文档只能在 bash 脚本中使用。
-
here 文档可以与多种编程和脚本语言一起使用。
-
here 文档只能用于显示注释或其他静态数据。
-
-
你如何定义一个 here 文档?
-
在脚本中加入
#!/
bin/here 作为 shebang 行。 -
用一对双引号包围整个 here 文档。
-
用一对单引号包围整个 here 文档。
-
用一对限制字符串包围整个here 文档。
-
-
以下哪项陈述是正确的?
-
你可以在任何情况下使用
autoexpect
,然后无需编辑即可使用生成的expect
脚本。 -
autoexpect
创建的expect
脚本可能需要手动编辑才能正常工作。 -
你可以在任何操作系统上使用
autoexpect
,包括 Linux、FreeBSD、macOS 和 OpenIndiana。 -
你绝不应该使用
autoexpect
来做任何事情。
-
-
使用
expect
有什么一个影响?-
使用
expect
自动化ssh
登录或scp
传输时,目标服务器的密码会以明文存储在expect
脚本中。 -
没有什么影响。
-
这并不是很高效。
-
创建 expect 脚本太难了。
-
进一步阅读
-
如何在 Linux 中使用“Here Documents”:
www.howtogeek.com/719058/how-to-use-here-documents-in-bash-on-linux/
-
如何在 BASH 中创建 TXT 模板脚本:
www.maketecheasier.com/create-txt-template-scripts-bash/
-
Here Documents:
tldp.org/LDP/abs/html/here-docs.html
-
如何在 Shell 脚本中使用 Heredoc:
www.tecmint.com/use-heredoc-in-shell-scripting/
-
编写 Shell 脚本-第 3 课:Here 脚本:
linuxcommand.org/lc3_wss0030.php
-
如何在 Linux Shell 脚本中使用 Here Document(heredoc):
linuxtldr.com/heredoc/
-
Linux expect 命令示例:
phoenixnap.com/kb/linux-expect
-
使用 expect 命令自动化输入到 Linux 脚本:
www.howtogeek.com/devops/automate-inputs-to-linux-scripts-with-the-expect-command/
-
Linux 中的 Expect 命令与示例:
www.geeksforgeeks.org/expect-command-in-linux-with-examples/
-
Expect 命令及如何像魔法一样自动化 Shell 脚本:
likegeeks.com/expect-command/
-
使用 expect 和 autoexpect 在 Linux 上自动化响应脚本:
www.networkworld.com/article/969513/automating-responses-to-scripts-on-linux-using-expect-and-autoexpect.html
-
探索 Expect:
amzn.to/3MSNqAV
回答
-
b
-
c
-
d
-
b
-
a
加入我们的 Discord 社区!
与其他用户、Linux 专家和作者本人一起阅读这本书。
提问,提供解决方案给其他读者,通过“问我任何问题”环节与作者聊天,等等。扫描二维码或访问链接加入社区。
第十三章:使用 ImageMagick 脚本
ImageMagick 是一款强大的图形处理工具包。您可以使用这些工具执行许多与使用图形界面工具(如 GIMP 和 Adobe Photoshop)相同的任务。但是,ImageMagick 工具是命令行工具,这使得您可以将它们用于脚本中,从而自动化许多任务。这些脚本可以是普通的 Shell 脚本,或者是使用 ImageMagick 脚本环境的脚本。
本章包括以下内容:
-
转换非标准文件名扩展名
-
安装 ImageMagick
-
显示图像
-
查看图像属性
-
调整大小和定制图像
-
批量处理图像文件
-
使用 Fred 的 ImageMagick 脚本
如果您准备好了,就让我们开始吧。
技术要求
在本章中,我使用的是 Fedora 的桌面版,因为它自带最新版的 ImageMagick。而 Debian 12 自带的是旧版,但我在这里介绍的命令和技巧同样适用于 Debian 12。
同样,您可以通过执行以下操作下载本章的脚本:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
转换非标准文件名扩展名
在开始使用 ImageMagick 之前,我需要先解决一个让我比较头痛的问题。那就是 Linux 和 Unix 操作系统是区分大小写的,而其他操作系统则不是。所以在 Linux 和 Unix 上,somegraphic.png
和 somegraphic.PNG
是两个不同的文件,而在 Windows 上,它们表示的是同一个文件。在 Linux 和 Unix 上,更常见的做法是使用小写字母作为文件扩展名。如果您正在使用桌面版的 Linux 或 Unix,您可能会发现,如果文件的扩展名是大写字母,GUI 文件管理器就无法自动在图形文件查看器中打开这些文件。虽然这看似不是什么大问题,但某些 Windows 工具和某些数码相机总是将图形文件的文件名全部使用大写字母。而且,如果您创建一个脚本对整个目录中的图像文件执行批处理操作,某些文件的扩展名是大写字母,某些是小写字母,这会给您带来困扰。所以,您需要将这些文件重命名,以符合 Linux/Unix 的约定。如果您有一个满是文件的目录需要转换,您就需要自动化这个过程。所以,来看看这个实用的 rename_extension.sh
脚本,它可以帮您解决这个问题:
#!/bin/bash
for file in *.JPG; do
mv -- "$file" "${file%.JPG}.jpg"
done
如你所见,这只是一个简单的 for
循环,它会查找当前目录中所有具有 .JPG
文件扩展名的文件。真正的魔力是通过循环中的 mv
命令实现的。(记住,mv
不仅可以用于重命名文件和目录,还可以将它们移动到其他位置。)--
序列标志着 mv
选项列表的结束,并防止了任何以 -
开头的文件名导致的问题。在行尾,你看到一个变量扩展结构,它将所有文件名中的 .JPG
替换为 .jpg
。在这里,我需要提醒一下。
请始终确保你的变量替换结构是使用一对大括号而不是一对圆括号构建的。如果你不小心使用了圆括号而不是大括号,脚本会删除它找到的所有文件,这可能不是你想要的结果。
无论如何,让我们测试一下脚本,看看会发生什么。这是我运行脚本之前图形目录的内容:
[donnie@fedora script_test]$ ls
rename_extension2.sh S1180001.JPG S1340001.JPG S1340003.JPG
rename_extension.sh S1180002.JPG S1340002.JPG
[donnie@fedora script_test]$
现在,让我们运行脚本,然后查看目录内容:
[donnie@fedora script_test]$ ./rename_extension.sh
[donnie@fedora script_test]$ ls
rename_extension2.sh S1180001.jpg S1340001.jpg S1340003.jpg
rename_extension.sh S1180002.jpg S1340002.jpg
[donnie@fedora script_test]$
如果你不想处理变量扩展,或者如果你使用的 shell 不支持它,你可以使用 basename
工具,如你在 rename_extension2.sh
脚本中看到的那样:
#!/bin/bash
for file in *.JPG; do
mv -- $file "$(basename -- "$file" .JPG).jpg"
done
basename
工具通过从文件名中去除目录路径,并在指定时去除文件名扩展名。下面是当我只想去除目录路径时的操作方式:
[donnie@fedora ~]$ basename Pictures/script_test/rename_extension.sh
rename_extension.sh
[donnie@fedora ~]$
这是当我想去除目录路径和文件名扩展名时的操作方式:
[donnie@fedora ~]$ basename Pictures/script_test/rename_extension.sh .sh
rename_extension
[donnie@fedora ~]$
当然,这适用于任何文件名扩展名,如你在这里看到的:
[donnie@fedora ~]$ basename Pictures/script_test/S1340001.JPG .JPG
S1340001
[donnie@fedora ~]$
对我来说,.JPG
和 .MP4
文件是最大的问题,因为我的小型松下摄像机/相机会以这些命名格式保存文件。如果你需要处理来自 Windows 机器的截图,你将处理 .PNG
文件。调整我刚刚展示的任一脚本以将这些文件名扩展名转换为全小写字母是件简单的事。
现在,随着这些前提条件的解决,让我们来看看 ImageMagick。
安装 ImageMagick
你会在 OpenIndiana 和几乎所有的 Linux 发行版的仓库中找到 ImageMagick。你还会在 FreeBSD 及其面向桌面的衍生版(如 GhostBSD 和 MidnightBSD)中找到它。安装过程简单快捷,只有几个小注意事项。
第一个注意事项与 Debian 相关。如你所知,Debian 往往有自己独特的节奏,并且在将最新的软件包更新到仓库中方面相对较慢。
所以,如果你在 Debian 12 或更早版本上安装 ImageMagick,你将得到较旧的 ImageMagick 6,而不是当前的 ImageMagick 7。(如果你切换到 Debian Testing 或 Debian Unstable,你可能会得到版本 7,但我没有确认过,因为我更喜欢使用稳定版本。)
另一个需要注意的事项是,不同的发行版对 ImageMagick 包的命名方式并不一致。例如,Fedora 上的包名是 ImageMagick
,Debian、OpenIndiana 和 Alpine Linux 上是 imagemagick
,FreeBSD 及其桌面衍生版本上是 ImageMagick7
。不过,在所有情况下,只需使用发行版的常规包管理工具进行安装即可。
如果你使用的是 macOS 或 Windows,可以在官方的 ImageMagick 网站上找到如何在这两个系统上安装 ImageMagick 的说明。(你可以在进一步阅读部分找到到 ImageMagick 网站的链接。)
现在你已经安装了 ImageMagick,我们来看看可以用它做些什么。
显示图像
要显示图像,使用 display
命令,像这样:
donnie@fedora script_test]$ display S1180001.jpg
我知道,你可能会想,为什么我要花一整节章节来告诉你这些。别急,因为还有更多内容。
打开图像后,你可以点击鼠标的左右按钮来弹出一个菜单。左键弹出的菜单包含许多图像操作功能,和你在命令行中可以做的操作一样。菜单长这样:
图 13.1:ImageMagick 左键菜单
右键菜单比你看到的要简单得多:
图 13.2:ImageMagick 右键菜单
右键菜单的一个功能是显示图像的信息,显示的内容像这样:
图 13.3:从右键菜单显示图像信息
你可以通过随意点击这些菜单,了解每个功能的作用。虽然这些功能都很酷,但它们并不能帮助我们进行脚本编程。所以,我们仍然需要看看如何从命令行使用 ImageMagick。
查看图像属性
使用 identify
命令查看图像的属性,像这样:
[donnie@fedora script_test]$ identify S1180001.jpg
S1180001.jpg JPEG 3968x2232 3968x2232+0+0 8-bit sRGB 1.90756MiB 0.000u 0:00.000
[donnie@fedora script_test]$
若要查看更多信息,请使用 -verbose
选项,像这样:
[donnie@fedora script_test]$ identify -verbose S1180001.jpg
Image:
Filename: S1180001.jpg
Permissions: rw-r--r--
Format: JPEG (Joint Photographic Experts Group JFIF format)
Mime type: image/jpeg
. . .
. . .
Number pixels: 8.85658M
Pixel cache type: Memory
Pixels per second: 55.2201MP
User time: 0.160u
Elapsed time: 0:01.160
Version: ImageMagick 7.1.1-15 Q16-HDRI x86_64 21298 https://imagemagick.org
[donnie@fedora script_test]$
我们从上面的例子可以看到,这张图片非常大,尺寸为 3968x2232 像素,大小为 1.90756 MiB。我不需要它这么大,所以我们来看一下如何让它更小。
调整和自定义图像大小
假设我想将我的图像调整为 1000x1000 像素。我会这样做:
[donnie@fedora script_test]$ convert -resize 1000x1000 S1180001.jpg S1180001_small.jpg
[donnie@fedora script_test]$
默认情况下,convert
命令会保持图像的原始宽高比。因此,我的缩小后的图像尺寸实际上是 1000x563 像素,像这样:
[donnie@fedora script_test]$ identify S1180001_small.jpg
S1180001_small.jpg JPEG 1000x563 1000x563+0+0 8-bit sRGB 328914B 0.000u 0:00.000
[donnie@fedora script_test]$
你可以通过指定图像的大小占原始大小的百分比,而不是指定像素数,像这样:
[donnie@fedora script_test]$ convert -resize 20% S1180001.jpg S1180001_small2.jpg
[donnie@fedora script_test]$
现在,当我显示图像时,它会适应我的电脑屏幕。长这样:
图 13.4:Goldie,睡在我卧室窗台上
你可以在调整图像大小的同时应用特殊效果。例如,假设我们将 Goldie 的这张照片转为炭笔画,像这样:
[donnie@fedora script_test]$ convert -resize 15% -charcoal 2 S1180001.jpg S1180001_charcoal.jpg
[donnie@fedora script_test]$
-charcoal
选项要求你指定一个数值来确定效果的强度。在这种情况下,我只用了 -charcoal 2
,这给了我我想要的效果。(我一开始使用了 -charcoal 15
,但那样看起来一点也不好。)结果如下所示:
图 13.5:Goldie 带有木炭效果
你可以对图片应用的效果种类繁多,以至于我不可能在这里列出所有内容。要查看完整列表,只需查看 convert
手册页。
关于 ImageMagick 的一个令人惊讶的好消息是,通过查阅 magick
和 ImageMagick 手册页、ImageMagick 网站或各种在线教程,你可以很快学会如何做这些事情。实际上,每次我尝试用类似 GIMP 或 PhotoShop 这样的图形界面程序做类似的事时,总是花费我大量时间才搞明白。
一直让我感到困扰的是,平板和智能手机在自拍模式下拍摄时,图片总是会被反转。所以,假设我拍一张我弹吉他的自拍照。我是右手吉他手,但用智能手机拍的自拍会让我看起来像是左手吉他手。(保罗·麦卡特尼,世界上最著名的左手吉他手,拍的照片看起来会像右手吉他手。)ImageMagick 通过使用 convert
和 -flop
选项可以轻松纠正这个问题,如下所示:
[donnie@fedora script_test]$ convert -flop S1180001_charcoal.jpg S1180001_charcoal_flop.jpg
我目前手头没有自拍照,所以我反转了 Goldie 的图片。结果如下所示:
图 13.6:Goldie 的图片,被反转
如果你曾经犯过把相机倒过来拍照的错误,你也可以使用 -flip
选项来垂直翻转你的图片,如下所示:
donnie@fedora:~/Pictures/script_test$ convert -resize 15% -flip S1180001.jpg S1180001_flip.jpg
donnie@fedora:~/Pictures/script_test$
结果如下所示:
图 13.7:Goldie 被翻转过来了
我将展示的最后一个技巧是如何将一种图片格式转换为另一种格式。只需使用 convert
,不带任何选项,如下所示:
[donnie@fedora script_test]$ convert S1180001_small2.jpg S1180001_small2.png
[donnie@fedora script_test]$ ls -l *.png
-rw-r--r--. 1 donnie donnie 494190 Dec 4 17:19 S1180001_small2.png
[donnie@fedora script_test]$
所以,现在我有一个 .png
文件与 .jpg
文件配合使用。要查看 ImageMagick 能处理的所有图片格式,只需执行:
[donnie@fedora ~]$ identify -list format
你不仅限于处理现有的图片文件。你还可以创建带有各种特效的原创文本图像文件。例如,假设我们创建一个包含我名字的花哨图片文件,如下所示:
[donnie@fedora Pictures]$ convert -size 320x115 xc:lightblue -font Comic-Sans-MS -pointsize 72 -fill Navy -annotate 0x0+12+55 'Donnie' -fill RoyalBlue -annotate 0x130+25+80 'Donnie' font_slewed.jpg
[donnie@fedora Pictures]$
当然,你指定的字体必须已安装在你的系统中。在这种情况下,我使用的是那个人人都爱恨交加的臭名昭著的 Comic Sans 字体。(它是微软的字体,我确实在这台 Fedora 机器上安装了它。我总是会在我的 Linux 机器上安装完整的微软字体套件,这样我就可以和我的出版商以及客户合作。)另外,请注意,字体名称中不能包含空格。将每个空格替换为连字符,就可以了。要理解命令的其余部分,可以在 ImageMagick 的手册页中查找所有选项的解释。总之,下面是我新图像的样子:
图 13.8:我使用 ImageMagick 创建的文本图像文件
很酷,对吧?当你查看官方 ImageMagick 文档中字体效果页面上的大量示例时,它会变得更加酷,你可以在这里找到:imagemagick.org/Usage/fonts/
你可以用 ImageMagick 做的事情远不止这些,但目前这些已经足够了。接下来我们来谈谈在 shell 脚本中使用 ImageMagick。
批量处理图像文件
现在,假设你有一个包含大量图像文件的目录,你需要以相同的方式处理所有这些文件。使用图形用户界面(GUI)程序会非常繁琐,因为你只能一次处理一个文件。而使用 ImageMagick,你只需要写一个简单的脚本,就能为你完成所有工作。例如,看看这个 resize.sh
脚本:
#!/bin/bash
for picture in *.jpg; do
convert -resize 15% "$picture" "${picture%.jpg}_small.jpg"
done
如你所见,它与我之前展示过的 rename_extension.sh
脚本没有太大区别。只需要一个简单的 for
循环和一些变量扩展就足够了。当然,你可以将这个 -resize
命令替换成你想要的任何其他 ImageMagick 命令。
好的,这基本上涵盖了简单的内容。现在让我们继续探讨更复杂的内容。
使用 Fred 的 ImageMagick 脚本
通过我刚才给你展示的简单类型的 ImageMagick 命令,你可以做很多事情。对于简单的项目,你可能不需要更复杂的东西。但是,如果你是专业的图形艺术家,可能需要更多功能。你可以使用很酷的效果来创建一些令人惊艳的图像,但这可能需要使用一组非常复杂的 ImageMagick 命令。幸运的是,有一种作弊的方法,因为已经有人为你完成了这项工作。
Fred 的 ImageMagick 脚本是bash
脚本,结合了非常复杂的 ImageMagick 命令集。截止到我在 2023 年 12 月写这篇文章时,Fred Weinhaus 先生总共有 375 个脚本,你可以从他的网站下载。这些脚本供个人使用时免费,但如果你需要将其用于商业用途,Fred 请求你联系他并安排付款。我甚至无法开始描述所有这些脚本以及它们可以创建的效果。因此,我建议你直接去 Fred 的网站,下载一些脚本来进行自己的学习。你可以在这里找到 Fred 的网站:
www.fmwconcepts.com/imagemagick/
这是网站的样子:
图 13.9: Fred 的 ImageMagick 脚本网站
正如你在这张图中看到的,你可以通过将光标移动到脚本名称上来查看每个脚本的作用描述。
我认为这就是关于 ImageMagick 脚本的入门介绍。让我们总结一下并继续前进。
总结
ImageMagick 是一个非常适合休闲和专业图形艺术家的工具。你可以使用一些非常简单的命令进行简单的图像操作,或者使用更复杂的命令创建炫酷的效果。
在本章中,我首先解释了如何自动化将非标准文件扩展名转换为标准的 Linux/Unix 格式的过程。接着,我解释了如何在各种操作系统上安装 ImageMagick。然后,我向你展示了如何在命令行中显示图片、查看图片属性以及如何调整大小和自定义图片。最后,我向你展示了如何通过学习和尝试 Fred 的 ImageMagick 脚本来“作弊”。
当然,你可以用 ImageMagick 做的事情远比我在这里展示的要多。网络上到处都有 ImageMagick 教程,你可以通过你喜欢的搜索引擎轻松找到它们。更好的是,去 YouTube 搜索 ImageMagick 教程吧。
在下一章中,我将向你展示如何使用awk
进行文本处理。我们在那里见。
问题
-
以下哪项陈述是错误的?
-
使用 ImageMagick 的功能有限,因为它没有图形用户界面。
-
有时候,使用 ImageMagick 应用效果比使用 GUI 类型的程序要简单。
-
你可以通过将 ImageMagick 命令放入 Shell 脚本中来批量处理整个目录的图形文件。
-
ImageMagick 对于简单任务很容易使用,但你也可以使用更复杂的脚本执行更复杂的图像处理操作。
-
-
你可以使用哪两种方法来自动化整个目录图形文件的文件扩展名更改过程?
-
使用命令替换
-
使用变量扩展
-
使用命令扩展
-
使用
basename
工具
-
-
你会使用哪个 ImageMagick 命令来显示图像属性?
-
show
-
identify
-
show_properties
-
properties
-
深入阅读
-
bash--如何更改多个文件的扩展名?:
unix.stackexchange.com/questions/19654/how-do-i-change-the-extension-of-multiple-files
-
ImageMagick.org:
imagemagick.org/
-
开始使用 ImageMagick:
opensource.com/article/17/8/imagemagick
-
入门 ImageMagick:
riptutorial.com/imagemagick
-
使用 ImageMagick 命令行工具操作图像:
www.baeldung.com/linux/imagemagick-edit-images
-
Fred 的 ImageMagick 脚本:
www.fmwconcepts.com/imagemagick/
答案
-
a
-
b 和 d
-
b
加入我们的 Discord 社区!
与其他用户、Linux 专家以及作者本人一起阅读本书。
提出问题,为其他读者提供解决方案,通过“问我任何事”环节与作者聊天,等等。扫描二维码或访问链接加入社区。
第十四章:使用 awk – 第一部分
在本章中,我将向您展示一些关于 awk
的知识。它是一个具有悠久历史的编程环境,追溯到 1970 年代,当时由 Alfred Aho、Peter Weinberger 和 Brian Kernighan 发明,用于早期的 Unix 操作系统。
您可以通过多种方式使用 awk
。它是一个完整的编程语言,因此您可以用它编写非常复杂的独立程序。您还可以创建简单的 awk
命令,既可以从命令行运行,也可以在常规的 shell 脚本中运行。awk
内容丰富,已有专门的书籍介绍它。本章的目标是向您展示如何在常规的 shell 脚本中使用 awk
。
本章中的主题包括:
-
介绍
awk
-
理解模式与操作
-
从文本文件获取输入
-
从命令获取输入
如果你准备好使用 awk
,那我们就开始吧。
介绍 awk
awk
是一个模式扫描和文本处理工具,您可以使用它来自动化生成报告和数据库的过程。凭借其内置的数学功能,您还可以使用它对列状数字数据的文本文件执行电子表格操作。术语 awk
来自其创造者的名字:Aho、Weinberger 和 Kernighan。原始版本现在被称为“旧版 awk
”。更新的实现版本,如 nawk
和 gawk
,具有更多功能,且更易于使用。
您使用的 awk
版本取决于您运行的操作系统。大多数 Linux 操作系统运行的是 gawk
,它是 awk
的 GNU 实现。通常会有一个指向 gawk
可执行文件的 awk
符号链接,就像我在 Fedora 工作站上看到的那样:
[donnie@fedora ~]$ awk --version
GNU Awk 5.1.1, API: 3.1 (GNU MPFR 4.1.1-p1, GNU MP 6.2.1)
Copyright (C) 1989, 1991-2021 Free Software Foundation.
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.
. . .
. . .
[donnie@fedora ~]$ ls -l /bin/awk
lrwxrwxrwx. 1 root root 4 Jan 18 2023 /bin/awk -> gawk
[donnie@fedora ~]$
一个显著的例外是 Alpine Linux,它默认使用内置在 busybox
可执行文件中的轻量级 awk
实现。不过,gawk
和另一种 awk
实现 mawk
都可以从 Alpine 仓库安装。(我找不到 mawk
为什么被称为 mawk
的确切答案。不过,它的作者是 Michael Brennan,所以我猜它代表着“Michael 的 awk
”。)
Unix 和类 Unix 操作系统,如 macOS、OpenIndiana 和各种 BSD 发行版,使用的是 nawk
,即“新 awk
”的简称。您有时会看到它被称为“唯一真正的 awk
”,部分原因是 awk
原作者之一 Brian Kernighan 是它的维护者之一。不过,gawk
可在 FreeBSD 和 OpenIndiana 上安装,而 mawk
则可在 FreeBSD 上安装。
那么,这些不同的 awk
实现之间有什么区别呢?这里有一个简要的概述:
-
busybox
:busybox
中内置的awk
实现非常轻量,非常适合低资源的嵌入式系统。这也是它成为 Alpine Linux(一个也在嵌入式系统中非常流行的系统)默认选择的原因。然而要注意,它可能并不总是具备你在复杂awk
命令或脚本中所需的功能。 -
nawk
:正如我之前提到的,nawk
是大多数 Unix 和类 Unix 系统(如 FreeBSD、OpenIndiana 和 macOS)上的默认选择。但你在这些系统上找到的可执行文件通常是awk
,而不是nawk
。 -
mawk
:这是awk
的一个更快的版本,由 Mike Brennan 创建。 -
gawk
:这个实现具有其他实现所没有的功能。一个重要的增强功能是国际化和本地化能力,这有助于为不同语言和地区创建软件。它还包括 TCP/IP 网络功能和改进的正则表达式处理功能。
除非我另有说明,否则我将向你展示在nawk
和gawk
上都能使用的编码技巧。现在,让我们开始吧。
理解模式和动作
awk
模式只是文本或正则表达式,用于执行操作的依据。数据源可以是普通文本文件,也可以是另一个程序的输出。首先,让我们将/etc/passwd
文件的全部内容输出到屏幕上,如下所示:
[donnie@fedora ~]$ awk '{print $0}' /etc/passwd
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
. . .
. . .
donnie:x:1000:1000:Donald A. Tevault:/home/donnie:/bin/bash
systemd-coredump:x:986:986:systemd Core Dumper:/:/usr/sbin/nologin
systemd-timesync:x:985:985:systemd Time Synchronization:/:/usr/sbin/nologin
clamupdate:x:984:983:Clamav database update user:/var/lib/clamav:/sbin/nologin
setroubleshoot:x:983:979:SELinux troubleshoot server:/var/lib/setroubleshoot:/usr/sbin/nologin
[donnie@fedora ~]$
在这个命令中,{print $0}
部分是操作,它必须被一对单引号包围。$
用于指定要打印的字段。在这个例子中,$0
会打印出所有字段。指定模式将导致只有匹配该模式的行被打印。由于这次没有指定模式,所以我让每一行都被打印。现在,假设我只想显示包含特定用户名的行。我只需添加模式,如下所示:
[donnie@fedora ~]$ awk '/donnie/ {print $0}' /etc/passwd
donnie:x:1000:1000:Donald A. Tevault:/home/donnie:/bin/bash
[donnie@fedora ~]$
模式需要被一对斜杠包围,而这对斜杠需要放在单引号中,这些单引号也同时包围着操作部分。
awk
操作的是被称为records
的信息组。默认情况下,文本文件中的每一行就是一个记录。现在,我们暂时只处理这个。不过,你也可以有多行记录的文件,这需要使用特殊技巧来处理。我将在下一章中展示如何操作。
由于{print $0}
是默认的操作,我可以直接省略这一部分,得到相同的结果,如下所示:
[donnie@fedora ~]$ awk '/donnie/' /etc/passwd
donnie:x:1000:1000:Donald A. Tevault:/home/donnie:/bin/bash
[donnie@fedora ~]$
接下来,我们只打印出这一行的第一个字段。这样做:
[donnie@fedora ~]$ awk '/donnie/ {print $1}' /etc/passwd
donnie:x:1000:1000:Donald
[donnie@fedora ~]$
$1
表示我要打印第 1 个字段。但等等,这样还是不对,因为命令实际上打印了第 1 到第 5 个字段。那是因为awk
的默认字段分隔符是空格。字段 5 包含我的全名,而“Donald”和“A.”之间有一个空格。所以,对awk
来说,这是第二个字段的开始。为了修正这个问题,我会使用-F:
选项,让awk
将冒号识别为字段分隔符,像这样:
[donnie@fedora ~]$ awk -F: '/donnie/ {print $1}' /etc/passwd
donnie
[donnie@fedora ~]$
最后,我得到了我想要的输出。
如果你需要显示多个字段,请在动作中使用逗号分隔的字段标识符列表,像这样:
[donnie@fedora ~]$ awk -F: '/donnie/ {print $1, $7}' /etc/passwd
donnie /bin/bash
[donnie@fedora ~]$
所以,在这里,我显示的是字段 1 和字段 7。(请注意,字段编号之间的逗号是在输出中为字段之间添加空格的原因。如果省略逗号,输出将不会有这个空格。)
你也可以将另一个程序的输出通过管道传递给awk
,像这样:
[donnie@fedora ~]$ cat /etc/passwd | awk -F: '/donnie/ {print $1}'
donnie
[donnie@fedora ~]$
是的,我知道。通常来说,将cat
的输出管道传递给另一个工具不是一种好形式,尤其是在你可以直接使用其他工具的情况下。但现在这样做是为了演示这个概念。
到目前为止,我展示的所有示例也可以使用cat
、grep
、cut
或它们的组合来完成。所以,你可能会疑惑,为什么要使用awk
呢?好吧,稍等一下,你很快就会看到awk
可以做一些其他工具做不到,或者做得不如它的精彩事情。我们将从更深入地了解awk
如何处理来自普通文本文件的输入开始。
从文本文件中获取输入
正如你可能已经猜到的那样,awk
的默认操作方式是逐行读取文件,搜索每行中指定的模式。当它找到包含指定模式的行时,会对该行执行指定的操作。我们将从前一节中展示的passwd
文件示例开始,继续构建。
查找人类用户
/etc/passwd
文件包含了系统上所有用户的列表。我一直觉得很奇怪的是,系统用户账户和普通人类用户账户都混杂在同一个文件中。但是,假设作为管理员职责的一部分,你需要维护每台机器上的普通人类用户列表。做到这一点的一个方法是使用awk
在passwd
文件中搜索对应人类用户的用户 ID 号码(UIDs)。要查找普通用户的 UID 号码,你可以查看大多数 Linux 系统上的/etc/login.defs
文件。这个文件在不同的 Linux 系统上可能会有所不同,所以我只会展示在我的 Fedora 机器上的情况。在第 142 到 145 行,你可以看到以下内容:
# Min/max values for automatic uid selection in useradd(8)
#
UID_MIN 1000
UID_MAX 60000
所以,为了查看我系统上的所有人类用户账户,我会搜索所有passwd
文件中字段 3(即 UID 字段)包含大于或等于 1000 且小于或等于 60000 的行,如下所示:
donnie@fedora:~$ awk -F: '$3 >= 1000 && $3 <= 60000 {print $1, $7}' /etc/passwd
donnie /bin/bash
vicky /bin/bash
frank /bin/bash
goldie /bin/bash
cleopatra /bin/bash
donnie@fedora:~$
你还可以将输出重定向到一个文本文件中,只需在动作结构中放置输出重定向符号,如下所示:
donnie@fedora:~$ awk -F: '$3 >= 1000 && $3 <= 60000 {print $1, $7 > "users.txt"}' /etc/passwd
donnie@fedora:~$ cat users.txt
donnie /bin/bash
vicky /bin/bash
frank /bin/bash
goldie /bin/bash
cleopatra /bin/bash
donnie@fedora:~$
在这两个示例中,请注意我在命令的模式部分使用了&&
作为and
操作符。
提示
如果你计划将此命令用于自己的用途,确保验证你的操作系统正在使用的 UID 号码范围。大多数 Linux 发行版使用 1000 到 60000 的范围,这个范围定义在/etc/login.defs
文件中。非 Linux 操作系统,如 OpenIndiana 和 FreeBSD,则不使用login.defs
文件,我还没能找到关于它们使用的 UID 范围的明确答案。根据我所知道的,你可以查看/etc/passwd
文件,看看第一个人类用户帐户分配的 UID 是什么,并查阅你所在组织的政策手册,看看组织希望为人类用户使用的 UID 号码范围。
当然,每次想使用这个命令时,你不必每次都键入整个长命令。相反,只需将其放入一个普通的 Shell 脚本中,我将其命名为user_list.sh
。它将类似于以下内容:
#!/bin/bash
awk -F: '$3 >= 1000 && $3 <= 60000 {print $1, $7 > "users.txt"}' /etc/passwd
这很好,但如果你需要一个可以轻松导入到电子表格中的逗号分隔值(.csv
)文件呢?好吧,我已经为你准备好了。只需添加一个BEGIN
部分,在其中定义输出的新字段分隔符以及输入字段分隔符,如下所示:
donnie@fedora:~$ awk 'BEGIN {OFS = ","; FS = ":"}; $3 >= 1000 && $3 <= 60000 {print $1,$7 > "users.csv"}' /etc/passwd
donnie@fedora:~$ cat users.csv
donnie,/bin/bash
vicky,/bin/bash
frank,/bin/bash
goldie,/bin/bash
cleopatra,/bin/bash
victor,/bin/bash
valerie,/bin/bash
victoria,/bin/bash
donnie@fedora:~$
这可能看起来有点奇怪,因为当你在awk
动作中定义输入字段分隔符时,你使用了-F
选项。但当你在BEGIN
部分定义输入字段分隔符时,你使用了FS
选项。同样,在BEGIN
部分,你使用OFS
来定义输出字段分隔符。(在awk
术语中,字段分隔符实际上被称为字段分隔符,这也解释了为什么这些BEGIN
选项被称为FS
和OFS
。)
awk
命令或脚本的BEGIN
部分是你可以添加任何初始化代码的地方,这些代码将在awk
开始处理输入文件之前运行。你可以用它来定义字段分隔符,向输出添加标题,或初始化你稍后将使用的全局变量。
这很好,但我还想看到用户的 UID。所以,我只需将第 3 列添加进去,如下所示:
donnie@fedora:~$ awk 'BEGIN {OFS = ","; FS = ":"}; $3 >= 1000 && $3 <= 60000 {print $1,$3,$7 > "users.csv"}' /etc/passwd
donnie@fedora:~$ cat users.csv
donnie,1000,/bin/bash
vicky,1001,/bin/bash
frank,1003,/bin/bash
goldie,1004,/bin/bash
cleopatra,1005,/bin/bash
victor,1006,/bin/bash
valerie,1007,/bin/bash
victoria,1008,/bin/bash
donnie@fedora:~$
现在我已经将我的awk
命令设置为我想要的样子,我将把它放入名为user_list2.sh
的 Shell 脚本中,脚本如下所示:
#!/bin/bash
awk 'BEGIN {OFS = ","; FS = ":"}; $3 >= 1000 && $3 <= 60000 {print $1,$3,$7 > "users.csv"}' /etc/passwd
你可以用其他编程语言编写一个程序来做同样的事情,比如 Python、C 或 Java。但是,程序代码会相当复杂,且更难以正确实现。使用awk
,只需一个简单的一行命令就可以完成。
接下来,让我们看看awk
如何帮助忙碌的 Web 服务器管理员。
解析 Web 服务器访问日志
Web 服务器访问日志包含大量信息,可以帮助网站所有者、web 服务器管理员或网络安全管理员。有很多先进的日志解析工具可以生成详细报告,告诉你发生了什么,如果你需要的话。但有时候,你可能想快速提取一些特定数据,而不花时间运行复杂的工具。有几种方法可以做到这一点,而 awk
是其中最好的之一。
要开始这个场景,你需要一台安装了活动 web 服务器的虚拟机。为了简化操作,我将在我的 Fedora Server 虚拟机上安装 Apache 服务器和 PHP 模块,方法如下:
donnie@fedora:~$ sudo dnf install httpd php
接下来,启用并启动 Apache 服务,方法如下:
donnie@fedora:~$ sudo systemctl enable --now httpd
要从不同的机器访问服务器,你需要在防火墙上打开端口 80,方法如下:
donnie@fedora:~$ sudo firewall-cmd --add-service=http --permanent
donnie@fedora:~$ sudo firewall-cmd --reload
donnie@fedora:~$
最后,在 /var/www/html/
目录下,创建 test.php
文件,并添加以下内容:
<?php echo "<strong><center>This is the awk Test Page</strong></center>";
?>
提醒:为了使其正常工作,请确保将你的虚拟机设置为桥接模式网络,这样网络中的其他机器就能访问它。还要确保用于访问 web 服务器的虚拟机也设置为桥接模式,以便每台机器在访问日志文件中都有自己的 IP 地址。
现在,从尽可能多的其他机器上访问 Fedora Web 服务器测试页面和 test.php
页面。然后,尝试访问一个不存在的页面。你的 URL 应该像这样:
http://192.168.0.11
http://192.168.0.11/test.php
http://192.168.0.11/bogus.html
在 web 服务器虚拟机上,使用 less
打开 var/log/httpd/access_log
文件,方法如下:
donnie@fedora:~$ sudo less /var/log/httpd/access_log
注意文件的结构,以及它如何使用空格作为某些字段的分隔符,而使用双引号作为其他字段的分隔符。例如,以下是我自己访问文件中的一条记录:
192.168.0.17 - - [18/Dec/2023:16:40:03 -0500] "GET /test.php HTTP/1.1" 200 59 "-" "Mozilla/5.0 (X11; SunOS i86pc; rv:120.0) Gecko/20100101 Firefox/120.0"
所以,你会看到这里有些字段被双引号包围,而有些则没有。现在,我们来看一下访问过这台机器的 IP 地址,方法如下:
donnie@fedora:~$ sudo awk '{print $1}' /var/log/httpd/access_log | sort -V | uniq
192.168.0.10
192.168.0.16
192.168.0.17
192.168.0.18
192.168.0.27
192.168.0.37
192.168.0.107
192.168.0.251
192.168.0.252
donnie@fedora:~$
如你所见,我将 awk
输出管道传输到 sort -V
,然后再管道传输到 uniq
。使用 sort
的 -V
选项会按照正确的数字顺序对 IP 地址进行排序。你不能使用 -n
选项,因为默认情况下,sort
会将 IP 地址中的点当作小数点处理。-V
选项会通过执行所谓的自然排序来覆盖这一行为。
这样做是不错的,但我仍然不知道每个 IP 地址访问 web 服务器的次数。我将使用带 -c
选项的 uniq
来查看,这将显示如下内容:
donnie@fedora:~$ sudo awk '{print $1}' /var/log/httpd/access_log | sort -V | uniq -c
6 192.168.0.10
20 192.168.0.16
12 192.168.0.17
2 192.168.0.18
4 192.168.0.27
15 192.168.0.37
6 192.168.0.107
14 192.168.0.251
6 192.168.0.252
donnie@fedora:~$
这样不错,但我真的想创建一个.csv
文件,就像我为我的用户列表做的那样。因为计数字段直到我将输出管道到uniq -c
之后才会创建,所以这次我不能通过在BEGIN
部分定义OFS
参数来做到这一点。所以,我稍微“作弊”一下,安装miller
包,它包含在大多数 Linux 发行版以及 FreeBSD 的仓库中。在 Mac 上,你应该可以通过homebrew
安装它。无论如何,为了创建.csv
文件,我将把来自这个awk
命令的输出管道到mlr
工具,它是miller
包的一部分。看起来是这样的:
donnie@fedora:~$ sudo awk '{print $1}' /var/log/httpd/access_log | sort -V | uniq -c | mlr --p2c cat > ip_list.csv
donnie@fedora:~$ cat ip_list.csv
6,192.168.0.10
27,192.168.0.16
12,192.168.0.17
2,192.168.0.18
4,192.168.0.27
15,192.168.0.37
6,192.168.0.107
14,192.168.0.251
6,192.168.0.252
donnie@fedora:~$
mlr
的--p2c
选项用于将输出转换为.csv
格式。接下来是cat
,这是mlr
的动词。你会看到这个cat
会像它的bash
版本一样,将输出直接显示到屏幕或文件中。
我正在向你展示如何创建.csv
文件,因为出于历史原因,.csv
是最流行的纯文本数据文件格式,而你的雇主或客户可能期望你使用这种格式。然而,正如我们稍后会看到的,这并不总是最佳格式。在某些情况下,你可能会发现最好的选择是忘记.csv
文件,而是将数据保存为制表符分隔值(.tsv
)文件,它们可能像这样:
donnie@fedora:~$ cat inventory.tsv
Kitchen spatula $4.99 Housewares
Raincoat $36.99 Clothing On Sale!
Claw hammer $7.99 Tools
donnie@fedora:~$
你还可以将它们导入你最喜欢的电子表格程序,在某些情况下,它们更容易处理。
如果你需要的是.json
文件而不是.csv
文件,mlr
也能帮你做到。你只需将--p2c
选项替换为--ojson
选项,如下所示:
donnie@fedora:~$ sudo awk '{print $1}' /var/log/httpd/access_log | sort -V | uniq -c | mlr --ojson cat > ip_list.json
donnie@fedora:~$ cat ip_list.json
{ "1": " 6 192.168.0.10" }
{ "1": " 27 192.168.0.16" }
{ "1": " 12 192.168.0.17" }
{ "1": " 2 192.168.0.18" }
{ "1": " 4 192.168.0.27" }
{ "1": " 15 192.168.0.37" }
{ "1": " 6 192.168.0.107" }
{ "1": " 14 192.168.0.251" }
{ "1": " 6 192.168.0.252" }
donnie@fedora:~$
你可以用mlr
做的事情远远超过我在这里展示的内容。要了解更多,查阅mlr
的手册页或 Miller 文档网页。 (你可以在进一步阅读部分找到指向 Miller 网页的链接。)不过,尽管mlr
非常优秀,它确实有一个缺点。也就是,它不能处理包含空格或逗号的字段。所以,你不能在 Apache 访问日志中的每个字段上使用它。
现在,假设我们想查看哪些操作系统和浏览器正在访问这个服务器。包含这些信息的用户代理字符串位于第 6 个字段中,并且被一对双引号包围。所以,我们需要使用双引号作为字段分隔符,如下所示:
donnie@fedora:~$ sudo awk -F\" '{print $6}' /var/log/httpd/access_log | sort | uniq
Lynx/2.8.9rel.1 libwww-FM/2.14 SSL-MM/1.4.1 OpenSSL/1.1.1t-freebsd
Lynx/2.9.0dev.10 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/3.7.1
Lynx/2.9.0dev.12 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/3.7.8
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15
. . .
. . .
Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0
Mozilla/5.0 (X11; Linux x86_64; rv:45.0) Gecko/20100101 Firefox/45.0
Mozilla/5.0 (X11; SunOS i86pc; rv:120.0) Gecko/20100101 Firefox/120.0
Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0
donnie@fedora:~$
你可以看到,我必须用反斜杠转义双引号,这样 shell 就不会错误地解析它。
接下来,让我们查找特定的用户代理。假设我们想知道有多少用户使用的是 Mac。只需添加模式,像这样:
donnie@fedora:~$ sudo awk -F\" '/Mac OS X/ {print $6}' /var/log/httpd/access_log | sort | uniq -c
8 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15
6 Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:109.0) Gecko/20100101 Firefox/115.0
6 Mozilla/5.0 (Macintosh; PPC Mac OS X 10.4; FPR10; rv:45.0) Gecko/20100101 Firefox/45.0 TenFourFox/7450
9 Mozilla/5.0 (Macintosh; U; PPC Mac OS X 10_4_11; en) AppleWebKit/533.19.4 (KHTML, like Gecko) Version/4.1.3 Safari/533.19.4
donnie@fedora:~$
英特尔 Mac 的条目来自我 2010 年的 Mac Pro,所以它们是合理的。但是,输出的第 3 和第 4 行中出现的 PPC 是怎么回事?这也很好解释。只是为了好玩,我启动了我的 21 年历史的 eMac,它配备了老式的摩托罗拉 PowerPC G4 处理器,用它访问了测试 Web 服务器。(我猜测你在自己的网络上永远不会看到这些设备。)
你也可以仅使用纯 awk
获得这些信息,无需将输出传递给其他工具。通过构建一个关联数组,可以实现这一点,格式如下:
donnie@fedora:~$ sudo awk '{count[$1]++}; END {for (ip_address in count) print
ip_address, count[ip_address]}' /var/log/httpd/access_log
192.168.0.251 14
192.168.0.252 6
192.168.0.10 6
192.168.0.16 22
192.168.0.17 12
192.168.0.107 6
192.168.0.18 2
192.168.0.27 4
192.168.0.37 15
donnie@fedora:~$
添加一个 BEGIN
部分,并定义 OFS
,你就可以创建一个 .csv
文件。它是这样的:
donnie@fedora:~$ sudo awk 'BEGIN {OFS = ","}; {count[$1]++}; END {for (ip_address in count) print ip_address, count[ip_address]}' /var/log/httpd/access_log > ip_addresses.csv
donnie@fedora:~$ cat ip_addresses.csv
192.168.0.251,14
192.168.0.252,6
192.168.0.10,6
192.168.0.16,27
192.168.0.17,12
192.168.0.107,6
192.168.0.18,2
192.168.0.27,4
192.168.0.37,15
donnie@fedora:~$
与你之前见过的普通 Shell 脚本数组不同,awk
的关联数组使用文本字符串,而不是数字,作为索引。此外,awk
中的数组是动态定义的,无需在使用前声明它们。所以在这里你会看到 count
数组,它的索引值对应字段 1 的各个值。++
运算符假定初始值为 0,表示每个索引字符串在文件中出现的次数。这个命令的第一部分,以分号结尾,按行处理文件,正如 awk
通常所做的那样。END
关键字标志着代码会在逐行处理完成后运行。在这种情况下,你会看到一个 for
循环,打印出唯一 IP 地址的摘要,并显示每个地址出现的次数。不幸的是,用纯 awk
对输出进行排序并没有简单的方法,但你仍然可以将输出传递给 sort
,像这样:
donnie@fedora:~$ sudo awk 'BEGIN {OFS = ","}; {count[$1]++}; END {for (ip_address in count) print ip_address, count[i]}' /var/log/httpd/access_log | sort -t, -V -k1,1 > ip_addresses.csv
donnie@fedora:~$ cat ip_addresses.csv
192.168.0.10,6
192.168.0.16,27
192.168.0.17,12
192.168.0.18,2
192.168.0.27,4
192.168.0.37,15
192.168.0.107,6
192.168.0.251,14
192.168.0.252,6
donnie@fedora:~$
为了使其生效,我使用 -t,
选项对 sort
进行排序,将逗号定义为字段分隔符。(是的,我知道。如果所有 Linux 和 Unix 工具都使用相同的选项开关来定义字段分隔符,那就好了,但现实情况并非如此。)
使用纯 awk
的另一个区别是,每个 IP 地址的出现次数位于第二列,而不是你之前看到的第一列。这里没问题,但对于其他字段可能不太适用。例如,让我们将命令更改为查看字段 6 中的用户代理:
donnie@fedora:~$ sudo awk -F\" '{count[$6]++}; END { for (i in count) print i, count[i]}' /var/log/httpd/access_log | sort
Lynx/2.8.9rel.1 libwww-FM/2.14 SSL-MM/1.4.1 OpenSSL/1.1.1t-freebsd 2
Lynx/2.9.0dev.10 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/3.7.1 4
Lynx/2.9.0dev.12 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/3.7.8 3
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15 8
. . .
. . .
Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0 6
donnie@fedora:~$
是的,方法有效,但在第二列中显示出现次数使得输出的可读性稍差。
正如我之前提到的,.csv
格式并不总是最适合用作纯文本数据文件。事实上,将字段 6 的输出转化为任何电子表格程序能够正确显示的 .csv
文件几乎是不可能的。因为该字段包含空格和逗号,会导致电子表格程序误认为 .csv
文件中有比实际更多的字段。
所以,最简单的解决方案是用一对双引号将字段 6 中的文本括起来,并将输出保存为 .tsv
文件。然后,当你在电子表格中打开该文件时,将 "
定义为字段分隔符。无论如何,以下是创建 .tsv
文件的命令,未使用关联数组:
donnie@fedora:~$ sudo awk 'BEGIN {FS="\""} {print "\"" $6 "\""}' /var/log/
httpd/access_log | sort | uniq -c | sort -k 1,1 -nr > user_agent.tsv
donnie@fedora:~$
默认情况下,awk
会去掉原始日志文件中字段 6 周围的双引号。所以,为了使这个操作正常工作,我必须将双引号放回最终输出文件中。在操作部分,你会看到我在$6
之前和之后都打印了一个双引号。通过省略你通常会在各个print
元素之间放置的逗号,我确保双引号和文本之间没有空格。然后,我只是像之前所展示的那样,将输出通过管道传递给 sort
、uniq
,然后再次传递给 sort
。下面是文件的样子:
donnie@fedora:~$ cat user_agent.tsv
23 "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
9 "Mozilla/5.0 (X11; SunOS i86pc; rv:120.0) Gecko/20100101 Firefox/120.0"
9 "Mozilla/5.0 (Macintosh; U; PPC Mac OS X 10_4_11; en) AppleWebKit/533.19.4 (KHTML, like Gecko) Version/4.1.3 Safari/533.19.4"
8 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15"
6 "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0"
. . .
. . .
3 "Lynx/2.9.0dev.12 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/3.7.8"
2 "Lynx/2.8.9rel.1 libwww-FM/2.14 SSL-MM/1.4.1 OpenSSL/1.1.1t-freebsd"
donnie@fedora:~$
如果你更喜欢使用关联数组,可以像这样操作:
donnie@fedora:~$ sudo awk -F\" '{count[$6]++}; END { for (ip_address in count) printf "%s \"%s\"\n", count[ip_address], ip_address}' /var/log/httpd/access_log | sort -nr > user_agent.tsv
donnie@fedora:~$ cat user_agent.tsv
23 "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
9 "Mozilla/5.0 (X11; SunOS i86pc; rv:120.0) Gecko/20100101 Firefox/120.0"
9 "Mozilla/5.0 (Macintosh; U; PPC Mac OS X 10_4_11; en) AppleWebKit/533.19.4 (KHTML, like Gecko) Version/4.1.3 Safari/533.19.4"
8 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15"
6 "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0"
. . .
. . .
3 "Lynx/2.9.0dev.12 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/3.7.8"
2 "Lynx/2.8.9rel.1 libwww-FM/2.14 SSL-MM/1.4.1 OpenSSL/1.1.1t-freebsd"
donnie@fedora:~$
注意我如何需要将 printf
的参数用一对双引号括起来。第一个 %s
参数是用于计数字段,第二个 %s
参数是用于用户代理字段。为了在正确的位置添加双引号,我在第二个 %s
前后都加了一个 \"
。使用关联数组方法或非关联数组方法都能得到相同的结果。现在,当我在电子表格程序中打开文件时,我只需将双引号定义为字段分隔符。下面是它在 LibreOffice Calc 中的样子:
图 14.1:在 LibreOffice Calc 中设置字段分隔符
我知道我还没有完全解释 print
和 printf
的区别,但我会在下一章中详细说明。
user_agent.tsv
文件现在应该能够正确显示,第一列是计数,第二列是用户代理字符串。
接下来,让我们统计一下每个 URL 在 Web 服务器上的访问次数。我喜欢将计数作为输出的第一个字段,所以我再次将 awk
的输出通过管道传递给 uniq
和 sort
,如下所示:
donnie@fedora:~$ sudo awk '{print $7}' /var/log/httpd/access_log | sort | uniq -c | sort -k1,1 -n
1 /something.html
1 /test
1 /test.html
1 /test/php
1 /test.php?module=..../
2 /test.php?module=....//....//....//....//....//....//....//....//....//....//proc/self/environ%0000
2 /test.php?page=../../../../../../../../../../../../../../../proc/self/environ%00
11 /icons/poweredby.png
11 /poweredby.png
11 /report.html
12 /favicon.ico
17 /
18 /test.php
donnie@fedora:~$
使用空格作为字段分隔符意味着 URL 字段是字段 7。最后,我再次将输出通过管道传递给 sort
,以便按每个 URL 的点击次数输出列表。但实际上,我希望最受欢迎的 URL 显示在列表的顶部。所以,我只需加上 -r
选项来反向排序,如下所示:
donnie@fedora:~$ sudo awk '{print $7}' /var/log/httpd/access_log | sort | uniq -c | sort -k1,1 -nr
18 /test.php
17 /
12 /favicon.ico
11 /report.html
11 /poweredby.png
11 /icons/poweredby.png
2 /test.php?page=../../../../../../../../../../../../../../../proc/self/environ%00
2 /test.php?module=....//....//....//....//....//....//....//....//....//....//proc/self/environ%0000
1 /test.php?module=..../
1 /test/php
1 /test.html
1 /test
1 /something.html
donnie@fedora:~$
我们看到的一件事是,有人试图对我进行目录遍历攻击,从以 /test.php?page
或 /test.php?module
开头的行中可以看出。我稍后会在 第十八章:安全专家的 Shell 脚本 中给你更多讲解。
我们最后要看的字段是字段 9,它是HTTP 状态码。同样,我们将使用空格作为字段分隔符,如下所示:
donnie@fedora:~$ sudo awk '{print $9}' /var/log/httpd/access_log | sort | uniq -c | sort -k1,1 -nr
53 200
17 404
17 403
2 304
donnie@fedora:~$
下面是这些代码含义的分解:
-
200:
200
代码意味着用户可以正常访问网页,没有任何问题。 -
304:
304
代码意味着当用户重新加载一个页面时,页面没有任何变化,因此不需要重新加载。 -
403:
403
代码意味着有人尝试访问一个用户没有授权的页面。 -
404:
404
代码意味着用户尝试访问一个不存在的页面。
好的,这一切都很好。所以现在,让我们把所有这些放到access_log_parse.sh
脚本中,它将如下所示:
#!/bin/bash
#
timestamp=$(date +%F_%I-%M-%p)
awk 'BEGIN {FS="\""} {print "\"" $6 "\""}' /var/log/httpd/access_log | sort | uniq -c | sort -k 1,1 -nr > user_agent_$timestamp.tsv
awk '{print $1}' /var/log/httpd/access_log | sort -V | uniq -c | sort -k1,1 -nr >> source_IP_addreses_$timestamp.tsv
awk '{print $7}' /var/log/httpd/access_log | sort | uniq -c | sort -k1,1 -nr > URLs_Requested_$timestamp.tsv
awk '{print $9}' /var/log/httpd/access_log | sort | uniq -c | sort -k1,1 -nr > HTTP_Status_Codes_$timestamp.tsv
我决定为每个功能创建单独的.tsv
文件,并且我想在每个文件名中加上时间戳。date
命令的%F
选项以YEAR-MONTH-DATE
格式打印日期,这很好。我本可以使用%T
选项打印时间,但那样会在文件名中加入冒号,这将要求我每次从命令行访问这些文件时都要转义冒号。因此,我改用了%I-%M-%p
组合,以将冒号替换为破折号。(要了解更多格式选项,请参阅date
手册页。)脚本的其余部分包括您已经看到的命令,所以我不会重复任何解释。
当然,您可以使用正则表达式来优化任何这些脚本,以满足您自己的需求和需求。只需使用我在前几章中展示给您的技术,向输出添加标记语言标签,并将输出文件转换为.html
或.pdf
格式。对于我刚刚展示给您的多功能脚本,您可以添加if..then
或case
结构,以允许您选择要运行的特定功能。如果您需要回头查看以了解任何操作方式,也不必感到难过。相信我,我不会因此责怪您。
到目前为止,我们一直在整个文件中解析以找到我们想要查看的内容。不过有时候,您可能只想查看特定行或一系列行的信息。例如,假设您只想查看第 10 行。请按照以下方式操作:
donnie@fedora:~$ sudo awk 'NR == 10' /var/log/httpd/access_log
192.168.0.16 - - [17/Dec/2023:17:31:20 -0500] "GET /test.php HTTP/1.0" 200 59 "-" "Lynx/2.9.0dev.10 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/3.7.1"
donnie@fedora:~$
NR
变量表示记录数。由于access_log
文件中的每条记录仅占一行,这与定义您想要查看的行号相同。要查看一系列行,请执行以下操作:
donnie@fedora:~$ sudo awk 'NR == 10, NR == 15 {print $1}' /var/log/httpd/access_log
192.168.0.16
192.168.0.16
192.168.0.16
192.168.0.107
192.168.0.107
192.168.0.107
donnie@fedora:~$
在这里,我正在查看第 10 到 15 行的第 1 字段。当然,您也可以通过结合tail
、head
和cut
工具来做同样的事情,但这种方式更简单。
在本节中,我们已经看过FS
、OFS
和NR
。我还没告诉你的是,这三个结构都是内置到awk
中的变量。还有许多其他内置变量,但我现在不想一下子就把它们都解释清楚,以免让你感到不知所措。如果您有兴趣了解所有这些内容,只需打开awk
手册页并滚动到内置变量部分即可。
我在这里介绍的日志解析技术可以用于任何类型的日志文件。要设计您的awk
命令,请查看要处理的日志文件,并注意每行的字段布局及其包含的信息。
在我们继续之前,让我们看看一些其他日志解析技术。
使用正则表达式
您可以像使用其他文本处理工具一样,通过正则表达式增强您的awk
体验。
如果需要,可以回顾一下第二章,文本流过滤器 – 第二部分和第九章,使用 grep、sed 和正则表达式过滤文本,复习正则表达式和 POSIX 字符类的概念。
对于我们的第一个例子,假设你需要在 /etc/passwd
文件中查找所有用户名以小写字母 v 开头的用户。只需使用一个简单的正则表达式,像这样:
donnie@fedora:~$ awk '/^v/' /etc/passwd
vicky:x:1001:1001::/home/vicky:/bin/bash
victor:x:1006:1006::/home/victor:/bin/bash
valerie:x:1007:1007::/home/valerie:/bin/bash
victoria:x:1008:1008::/home/victoria:/bin/bash
donnie@fedora:~$
之所以这样有效,是因为用户名字段是每一行的第一个字段。所以,你不需要做任何复杂的操作,仅仅这样就可以了。但是,假设你需要在另一个字段中搜索以特定字符开头的内容。比如说,你需要在 Apache 访问日志文件中查找所有属于 400 范围的 HTTP 状态码。你只需要像这样做:
donnie@fedora:~$ sudo awk '$9 ~ /⁴/{print $9}' /var/log/httpd/access_log | sort -n | uniq -c
17 403
20 404
donnie@fedora:~$
模式中的 $9
表示你在第 9 个字段中查找某个特定模式。~
表示你希望该字段中的内容匹配后面正斜杠之间的内容。在这种情况下,它是在寻找第 9 个字段中以数字 4 开头的内容。在输出中,你会看到我找到了 403 和 404 状态码。如果需要,你还可以将输出保存为 .csv
文件,像这样:
donnie@fedora:~$ sudo awk '$9 ~ /⁴/{print $9}' /var/log/httpd/access_log | sort -n | uniq -c | mlr --p2c cat > status_code.csv
donnie@fedora:~$ cat status_code.csv
17,403
20,404
donnie@fedora:~$
如果你更喜欢的话,你可以通过省略 mlr --p2c cat
部分,将其保存到 .tsv
文件中,像这样:
donnie@fedora-server:~$ sudo awk '$9 ~ /⁴/{print $9}' /var/log/httpd/access_log | sort -n | uniq -c > status_code.tsv
donnie@fedora-server:~$ cat status_code.tsv
6 403
4 404
donnie@fedora-server:~$
要查看所有除了某个模式以外的内容,可以使用 !~
构造,像这样:
donnie@fedora:~$ sudo awk '$9 !~ /⁴/{print $9}' /var/log/httpd/access_log | sort -n | uniq -c
53 200
2 304
donnie@fedora:~$
这样,我们就可以查看所有除 400 状态码以外的内容。
接下来,假设我们想要查看所有使用 Safari 或 Lynx 浏览器的实例。你可以这样做:
donnie@fedora:~$ sudo awk '/Safari|Lynx/' /var/log/httpd/access_log
但是,这样并不行,因为它会包含像这样的条目:
192.168.0.27 - - [17/Dec/2023:17:43:40 -0500] "GET / HTTP/1.1" 403 8474 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
出于某种奇怪的原因,Apache 无法准确识别 Microsoft Edge 浏览器,所以它将其报告为多个不同的可能性,包括 Safari。为了缩小范围,我会排除所有使用 Windows 的用户。(Safari 曾经在 Windows 上可用,但 Windows 版本在 2012 年就停止了。)下面是它的表现方式:
donnie@fedora:~$ sudo awk -F\" '$6 ~/Safari|Lynx/ && $6 !~ /Windows/' /var/log/httpd/access_log
你会发现这里有点奇怪。当你在正则表达式中使用 or
或 and
操作符时,你使用的是单个 |
或单个 &
。而当你在正则表达式外部使用 or
或 and
操作符时,你需要使用 ||
或 &&
。我知道,这有点让人困惑,但就是这样。不管怎样,如果你现在查看输出,你会看到没有来自 Windows 用户的行。
我知道,这只是 awk
和正则表达式的冰山一角。如果你请求得特别恳切,我可能会在下一个部分给你展示一些更多的例子,内容是如何使用 awk
处理来自其他命令的信息。
从命令获取输入
让我们从一个简单的例子开始,获取一些基本的进程信息。在运行 Apache 的 Fedora Server 虚拟机上,搜索所有包含 httpd
模式的 ps aux
输出行,像这样:
donnie@fedora:~$ ps aux | awk '/httpd/ {print $0}'
root 1072 0.0 0.2 19108 10796 ? Ss 14:36 0:01 /usr/sbin/httpd -DFOREGROUND
apache 1111 0.0 0.1 19204 6788 ? S 14:36 0:00 /usr/sbin/httpd -DFOREGROUND
apache 1112 0.0 0.2 2158280 8448 ? Sl 14:36 0:02 /usr/sbin/httpd -DFOREGROUND
apache 1113 0.0 0.2 2420488 8592 ? Sl 14:36 0:03 /usr/sbin/httpd -DFOREGROUND
apache 1114 0.0 0.2 2158280 8448 ? Sl 14:36 0:02 /usr/sbin/httpd -DFOREGROUND
donnie 1908 0.0 0.0 9196 3768 pts/0 S+ 17:13 0:00 awk /httpd/ {print $0}
donnie@fedora:~$
接下来,假设我们想查看所有由 root 用户拥有的进程。也很简单,直接执行以下命令:
donnie@fedora:~$ ps aux | awk '$1 == "root" {print $0}'
root 1 0.0 0.6 74552 27044 ? Ss 14:26 0:05 /usr/lib/systemd/systemd --switched-root --system --deserialize=36 rhgb
root 2 0.0 0.0 0 0 ? S 14:26 0:00 [kthreadd]
root 3 0.0 0.0 0 0 ? S 14:26 0:00 [pool_workqueue_release]
. . .
. . .
root 1688 0.0 0.1 16332 6400 ? S 16:53 0:00 systemd-userwork: waiting...
donnie@fedora:~$
这里没有什么新内容,因为这些 awk
命令和你在上一节中看到的一样。事实上,几乎所有解析日志文件的技巧在这里也同样适用。所以,接下来就不再重复这些内容了。
解析 ps
工具的信息的第一步是查看各个字段的含义。我个人发现 aux
选项组合最为实用,因为它显示了我最需要查看的特定信息。要查看显示字段名的 ps
头部,我会将 ps aux
的输出通过管道传递给 head -1
,像这样:
[donnie@fedora ~]$ ps aux | head -1
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
[donnie@fedora ~]$
这是所有这些字段的含义说明:
-
USER
:拥有每个进程的用户。 -
PID
:每个进程的进程 ID。 -
%CPU
:每个进程消耗的 CPU 资源百分比。 -
RSS
:常驻内存集大小,即每个进程使用的实际物理内存(未交换的内存)。(虽然这里的顺序可能有点乱,但很快你会明白原因。) -
%MEM
:进程的 RSS(常驻内存集)与计算机中安装的物理内存总量的比率。 -
VSZ
:虚拟内存集大小,即每个进程使用的虚拟内存量。(以 1024 字节单位表示。) -
TTY
:控制每个进程的终端。 -
STAT
:此列显示每个进程的状态代码。 -
START
:每个进程的启动时间或日期。 -
TIME
:每个进程的累计 CPU 时间,格式为“[DD-]HH:MM:SS
”。 -
COMMAND
:启动每个进程的命令。
请记住,ps
有许多选项开关,每个都显示不同类型的进程数据。你还可以通过组合这些选项来获取所有你需要的信息,并以你喜欢的格式显示。不过,现在我会坚持使用 ps aux
,主要是因为这是最适合我的选项组合。有关详细信息,请参阅 ps
的手册页。
默认情况下,ps
命令总是显示此头部和你想查看的信息。因此,如果你将 ps
的输出通过管道传递给 awk
命令而不对其进行过滤,你也会看到这个头部。例如,看看那些不是 root 用户拥有的进程,像这样:
donnie@fedora:~$ ps aux | awk '$1 != "root" {print $0}'
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
systemd+ 800 0.1 0.1 16240 7328 ? Ss 14:27 0:13 /usr/lib/systemd/systemd-oomd
systemd+ 801 0.0 0.4 27656 16236 ? Ss 14:27 0:00 /usr/lib/systemd/systemd-resolved
. . .
. . .
donnie 1745 200 0.1 9888 4520 pts/0 R+ 17:10 0:00 ps aux
donnie 746 0.0 0.0 9196 3804 pts/0 S+ 17:10 0:00 awk $1 != "root" {print $0}
donnie@fedora:~$
这样通常是没问题的。但是,如果你需要将信息保存到格式化的文本文件中,头部可能会给你带来麻烦。为了解决这个问题,我们可以使用我在前一节中展示过的 记录数(NR
)变量。这样操作:
donnie@fedora:~$ ps aux | awk 'NR > 1 && $1 != "root" {print $0}'
systemd+ 800 0.1 0.1 16240 7328 ? Ss 14:27 0:15 /usr/lib/systemd/systemd-oomd
systemd+ 801 0.0 0.4 27656 16236 ? Ss 14:27 0:00 /usr/lib/systemd/systemd-resolved
. . .
. . .
donnie 1765 250 0.1 9888 4620 pts/0 R+ 17:26 0:00 ps aux
donnie 1766 0.0 0.0 9196 3780 pts/0 S+ 17:26 0:00 awk NR > 1 && $1 != "root" {print $0}
donnie@fedora:~$
NR > 1
条件意味着我们只想查看第 1 记录之后的记录。换句话说,我们不想看到输出的第一行,在这种情况下那就是 ps
头部。
现在我们知道了每个字段的含义,我们可以创建一些有用的一行命令来提取信息。首先,让我们看看有多少进程处于 运行中 或 僵尸 状态。(僵尸进程是已经死掉的进程,但其父进程尚未完全销毁它们。你可以查看 ps
的手册页来了解更多。)我们知道 STAT
列是第 8 个字段,因此命令将如下所示:
[donnie@fedora ~]$ ps aux | awk '$8 ~ /^[RZ]/ {print}'
donnie 2803 30.5 1.6 4963152 1058324 ? Rl 14:00 70:12 /usr/lib64/firefox/firefox
donnie 4383 1.9 0.4 3157216 307460 ? Rl 14:03 4:18 /usr/lib64/firefox/firefox -contentproc -childID 33 -isForBrowser -prefsLen 31190 -prefMapSize 237466 -jsInitLen 229864 -parentBuildID 20231219113315 -greomni /usr/lib64/firefox/omni.ja -appomni /usr/lib64/firefox/browser/omni.ja -appDir /usr/lib64/firefox/browser {d458c911-d065-4a3c-bdd5-9d06fc1d030d} 2803 true tab
donnie 46283 0.0 0.0 0 0 ? R 17:50 0:00 [Chroot Helper]
donnie 46315 0.0 0.0 224672 3072 pts/0 R+ 17:50 0:00 ps aux
[donnie@fedora ~]$
它看起来有些混乱,因为第二个进程的 COMMAND
字段包含了一个很长的字符串,但这没关系。你仍然可以看到每一行的第 8 字段都以字母 R
开头。(你在系统中很少会看到僵尸进程,所以不用担心这里没有看到它们。而且,不,僵尸进程不会四处寻找大脑来偷。)
w
命令会显示所有已登录系统的用户以及他们正在做什么。它看起来是这样的:
donnie@fedora:~$ w
17:37:44 up 3:11, 4 users, load average: 0.03, 0.06, 0.02
USER TTY LOGIN@ IDLE JCPU PCPU WHAT
donnie tty1 17:31 5:51 0.06s 0.06s -bash
donnie pts/0 15:29 0.00s 0.35s 0.06s w
vicky pts/1 17:32 4:50 0.05s 0.05s -bash
frank pts/2 17:33 3:18 0.05s 0.05s -bash
donnie@fedora:~$
你可以看到,我是通过 tty1
登录的,这是本地终端。我也通过一个 pts
终端远程登录,并且 Vicky 和 Frank 也在其中。(记住,tty
终端表示本地登录,而 pts
终端表示远程登录。)现在,让我们使用 ps
查看远程用户正在运行的进程的更多信息,如下所示:
donnie@fedora:~$ ps aux | awk '$7 ~ "pts"'
donnie 1492 0.0 0.1 8480 5204 pts/0 Ss 15:29 0:00 -bash
vicky 1845 0.0 0.1 8348 4960 pts/1 Ss+ 17:32 0:00 -bash
frank 1892 0.0 0.1 8348 5000 pts/2 Ss+ 17:33 0:00 -bash
donnie 1936 200 0.1 9888 4640 pts/0 R+ 17:37 0:00 ps aux
donnie 1937 0.0 0.0 9064 3908 pts/0 S+ 17:37 0:00 awk $7 ~ "pts"
donnie@fedora:~$
刚才,我向你展示了如果你的 awk
命令没有自动去除头部行时,如何去除它。这次,awk
命令已经去除了它,但我决定还是要看到它。为了解决这个问题,我会这样做:
donnie@fedora:~$ ps aux | awk '$7 ~ "pts" || $1 == "USER"'
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
donnie 1492 0.0 0.1 8480 5204 pts/0 Ss 15:29 0:00 -bash
vicky 1845 0.0 0.1 8348 4960 pts/1 Ss+ 17:32 0:00 -bash
frank 1892 0.0 0.1 8348 5000 pts/2 Ss+ 17:33 0:00 -bash
donnie 2029 400 0.1 9888 4416 pts/0 R+ 17:48 0:00 ps aux
donnie 2030 0.0 0.0 9064 3928 pts/0 S+ 17:48 0:00 awk $7 ~ "pts" || $1 == "USER"
donnie@fedora:~$
请注意,我正在使用两种不同的方法来查找模式。$7 ~ "pts"
部分会查找第 7 字段中包含 pts
文本字符串的所有行。因此,我们看到 pts/0
、pts/1
和 pts/2
都符合这个搜索条件。$1 == "USER"
部分则是在查找一个精确的、完整的单词匹配。为了演示这一点,请查看这个 user.txt
文件:
[donnie@fedora ~]$ cat user.txt
USER donnie
USER1 vicky
USER2 cleopatra
USER3 sylvester
[donnie@fedora ~]
使用 ~
在第 1 字段中查找所有包含 USER
的行,我们得到如下结果:
[donnie@fedora ~]$ awk '$1 ~ "USER" {print $1}' user.txt
USER
USER1
USER2
USER3
[donnie@fedora ~]$
将 ~
替换为 ==
后,我们得到如下结果:
[donnie@fedora ~]$ awk '$1 == "USER" {print $1}' user.txt
USER
[donnie@fedora ~]$
所以你可以看到两者之间有很大的区别。另外,请注意,由于我在搜索一个字面文本字符串,因此在使用 ~
时,我可以将搜索词(USER
)放在一对双引号或一对斜杠内。但在使用 ==
时,你需要使用双引号,因为使用双斜杠什么也不会显示。
话题稍微有些跑题,回到我们的正题。
我现在决定查看所有虚拟内存大小(VSZ
)超过 500,000 字节的进程。VSZ
信息在第 5 字段,因此我的 awk
命令看起来是这样的:
donnie@fedora:~$ ps aux | awk '$5 > 500000 {print $0}'
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
apache 1098 0.0 0.2 2420488 8628 ? Sl 14:28 0:04 /usr/sbin/httpd -DFOREGROUND
apache 1099 0.0 0.2 2223816 8248 ? Sl 14:28 0:03 /usr/sbin/httpd -DFOREGROUND
apache 1100 0.0 0.2 2158280 8232 ? Sl 14:28 0:04 /usr/sbin/httpd -DFOREGROUND
donnie@fedora:~$
但这些信息对我来说其实有些多余。我只是想查看相关的字段,并决定将每个字段单独放在一行上,并加上标签。以下是我的做法:
donnie@fedora:~$ ps aux | awk 'NR > 1 && $5 > 500000 {print "USER:" "\t\t" $1 "\n" "PID:" "\t\t" $2 "\n" "VSZ:" "\t\t" $5 "\n" "COMMAND:" "\t" $11 "\n\n"}'
USER: apache
PID: 1147
VSZ: 2354952
COMMAND: /usr/sbin/httpd
USER: apache
PID: 1148
VSZ: 2158280
COMMAND: /usr/sbin/httpd
USER: apache
PID: 1149
VSZ: 2158280
COMMAND: /usr/sbin/httpd
donnie@fedora:~$
如前所述,NR > 1
防止 ps
打印默认的标题。在动作部分,print
命令在 USER
、PID
、VSZ
及其相应值之间放置一对制表符(\t\t
)。由于 COMMAND
标签较长,因此它和其值之间只需要一个制表符(\t
)。这确保了第二列的所有值整齐对齐。在 USER
、PID
和 VSZ
的值后面的换行符(\n
)使得下一个字段在新的一行上打印。COMMAND
的值后面,我放置了两个换行符(\n\n
),以确保每个记录之间有一个空白行。
接下来,让我们将这个命令转换成一个可以添加到我在第十章—理解函数中展示的 sysinfo.lib
函数库的函数。我们新的 VSZ_info()
函数如下所示:
VSZ_info() {
echo "<h2>Processes with more than 500000 bytes VSZ
size.</h2>"
echo "<pre>"
ps aux | awk 'NR > 1 && $5 > 500000 {print "USER:" "\t\t" $1 "\n" "PID:" "\t\t" $2 "\n" "VSZ:" "\t\t" $5 "\n" "COMMAND:" "\t" $11 "\n\n"}'
echo "</pre>"
}
现在,在我在第十章展示给你看的 system_info.sh
脚本中,你需要将 $(VSZ_info)
添加到脚本调用的函数列表的末尾。现在,当你运行脚本时,你会看到输出文件末尾的 VSZ
信息。(两个文件太长,无法在这里展示,但你可以从 GitHub 下载它们。)
好的,关于在 shell 脚本中使用awk
的基础知识就讲到这里。让我们总结一下并继续。
总结
在本章中,我向你介绍了使用 awk
的神秘技巧。我们从研究 awk
的多个实现方式开始,并了解了基本 awk
命令的构建方式。接着,我们看到了如何使用 awk
处理来自文本文件或其他程序的信息。然后,我们学习了如何在普通的 shell 脚本中运行 awk
命令。最后,我们将一个 awk
命令转化为函数,并将其添加到我们的函数库文件中。
在下一章中,我们将学习如何创建 awk
程序脚本。到时候见。
问题
-
一个
awk
命令有哪两个部分?(选择两个。)-
action
-
expression
-
pattern
-
command
-
E. 顺序
-
-
{print $0}
在awk
命令中做什么?-
它打印你正在运行的脚本的名称,因为
$0
是保存脚本名称的 bash 位置参数。 -
它打印分配给
0
变量的值。 -
它打印记录的所有字段。
-
这是一个无效的命令,不会执行任何操作。
-
-
如果你想对所有包含某个特定文本字符串的行执行精确的全字匹配,以下哪种
awk
操作符适合使用?-
==
-
=
-
eq
-
~
-
-
在哪里定义
FS
和OFS
的值最合适?-
在
awk
命令的 END 部分。 -
不能。它们已经有了预定义的值。
-
在
awk
命令的BEGIN
部分。 -
在
awk
命令的动作部分。
-
-
如何在
awk
中使用正则表达式?-
将正则表达式用一对斜杠括起来。
-
将正则表达式用一对单引号括起来。
-
将正则表达式用一对双引号括起来。
-
不要在正则表达式周围加上任何东西。
-
深入阅读
-
GNU awk 用户指南:
www.gnu.org/software/gawk/manual/gawk.html
-
《Awk 实例》:
developer.ibm.com/tutorials/l-awk1/
-
awklang.org——关于 awk 语言的相关内容网站:
www.awklang.org/
-
awk: One True awk:
github.com/onetrueawk/awk
-
Awk Scripts YouTube 频道:
www.youtube.com/@awkscripts
-
Miller 文档:
miller.readthedocs.io/en/latest/
-
《GAWK 手册 - 有用的“一行代码”》:
web.mit.edu/gnu/doc/html/gawk_7.html
-
如何在 Bash 脚本中使用 awk:
www.cyberciti.biz/faq/bash-scripting-using-awk/
-
《高级 Bash Shell 脚本指南 - Shell 包装器》:
www.linuxtopia.org/online_books/advanced_bash_scripting_guide/wrapper.html
-
Awk:一个 40 年历史语言的力量与前景:
www.fosslife.org/awk-power-and-promise-40-year-old-language
回答
-
a 和 c
-
c
-
a
-
c
-
a
加入我们的 Discord 社区!
与其他用户、Linux 专家及作者本人一起阅读本书。
提问、为其他读者提供解决方案、通过“问我任何问题”环节与作者互动,更多精彩内容尽在其中。扫描二维码或访问链接加入社区。
留下您的评论!
感谢您从 Packt 出版社购买此书——希望您喜欢它!您的反馈对我们非常宝贵,帮助我们改进和成长。完成阅读后,请花一点时间在亚马逊上留下评价;这仅需一分钟,但对像您这样的读者来说意义重大。
扫描下面的二维码,领取您选择的免费电子书。
第十五章:使用 awk —— 第二部分
在本章中,我们将从不同的角度继续讨论 awk
。在上一章中,我向你展示了如何创建可以在普通 shell 脚本中使用的单行 awk
命令。在本章中,我将向你展示如何用 awk
语言编写 awk
脚本。本章的内容包括:
-
基本的
awk
脚本构建 -
使用条件语句
-
使用
while
结构并设置变量 -
使用 for 循环和数组
-
使用浮动点数学和
printf
-
处理多行记录
如果你准备好了,让我们深入探讨。
技术要求
你可以使用 Fedora 或 Debian 虚拟机来执行这个操作。而且,和往常一样,你可以通过以下方式获取脚本:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
基本的 awk 脚本构建
让我们从你能想象的最简单的 awk
脚本开始,我们将其命名为 awk_kernel1.awk
。它看起来像这样:
/kernel/
正如你可能已经猜到的,这个脚本会查看指定的文件,搜索包含文本字符串 kernel
的所有行。你已经知道,如果没有指定操作,{print $0}
是默认操作。因此,这个脚本将打印出包含指定文本字符串的每一行。
在实际的 awk
脚本中,不需要在每个命令前加上 awk
,也不需要像在普通的 shell 脚本中嵌入 awk
命令时那样,用单引号括起来。我没有在这个脚本中加入 shebang 行,因此不需要设置可执行权限。相反,只需像这样调用脚本:
donnie@fedora:~$ sudo awk -f awk_kernel1.awk /var/log/messages
Jan 11 16:17:55 fedora kernel: audit: type=1334 audit(1705007875.578:35): prog-id=60 op=LOAD
Jan 11 16:18:00 fedora kernel: msr: Write to unrecognized MSR 0x17f by mcelog (pid: 856).
Jan 11 16:18:00 fedora kernel: msr: See https://git.kernel.org/pub/scm/linux/kernel/git/tip/tip.git/about for details.
. . .
. . .
Jan 11 17:15:28 fedora kernel: fwupdmgr[1779]: memfd_create() called without MFD_EXEC or MFD_NOEXEC_SEAL set
donnie@fedora:~$
当然,这样也可以。但你不觉得还是更希望有一个独立的、可执行的脚本吗?这其实很简单。只需添加 shebang 行,像这样:
#!/usr/bin/awk -f
/kernel/
然后,使脚本可执行,就像你处理普通的bash
脚本一样。
有两件事我希望你注意到这个 shebang 行。首先,我使用的是 /usr/bin/
而不是 /bin/
作为 awk
可执行文件的路径。这是因为我想让这个脚本具备可移植性,这样它就可以在 Linux、Unix 以及像 FreeBSD 和 macOS 这样的 Unix-like 系统上运行。
你习惯在 shebang 行中看到的 /bin/
路径,实际上是一个遗留物,它来自较早的 Linux 系统。在当前的 Linux 系统中,/bin/
是指向 /usr/bin/
的符号链接。在旧版 Linux 系统中,/bin/
和 /usr/bin/
曾经是两个独立的目录,每个目录都包含各自的程序文件集。现在已经不是这样了。如今,你会发现在所有 Linux 系统中,awk
可执行文件都位于 /usr/bin/
中。
FreeBSD 仍然使用独立的 /bin/
和 /usr/bin/
目录,其中包含不同的程序文件集。但 awk
位于 /usr/bin/
中,并且在 /bin/
中没有它的符号链接。因此,只需使用 #!/usr/bin/awk
,大多数操作系统就能正常运行。
第二件事要注意的是,我仍然需要使用-f
选项来调用awk
,这样awk
才会读取程序文件。如果你省略了-f
,脚本将无法运行。
现在你已经看到了awk
脚本的基本结构,接下来我们来看看一些awk
编程结构。
使用条件语句
你其实已经在使用if
语句了,只是你可能没有意识到。因为你不需要明确声明它们。你在awk_kernel1.awk
脚本中看到的简单/kernel/
命令意味着,如果在某行中找到kernel字符串,就打印该行。然而,awk
还提供了你在其他编程语言中会看到的完整编程结构。例如,我们来创建awk_kernel2.awk
脚本,内容如下:
#!/usr/bin/awk -f
{
if (/kernel/) {
print $0
}
}
这和你在bash
脚本中习惯看到的有所不同,因为在awk
中,不需要使用then
或fi
语句。这是因为awk
使用 C 语言语法来编写编程结构。所以,如果你习惯用 C 语言编程,那就高兴吧!
另外,注意如何需要将模式用一对括号括起来,以及如何必须用一对花括号将整个多行脚本括起来。无论如何,在运行脚本时,只需指定你的日志文件的名称和位置,像这样:
donnie@fedora:~$ sudo ./awk_kernel2.awk /var/log/messages
现在,你可能会想,为什么有人会想输入额外的代码来创建一个完整的if
语句结构,而仅仅输入/kernel/
就能完成任务呢?原因是这样可以创建完整的if. .else
结构,就像在awk_kernel3.awk
脚本中的这个结构:
#!/usr/bin/awk -f
{
if ($5 ~ /kernel/) {
print "Kernel here in Field 5"
}
else if ($5 ~ /systemd/) {
print "Systemd here in Field 5"
}
else {
print "No kernel or systemd in Field 5"
}
}
现在,让我们看看每种类型的消息在日志文件中出现的次数:
donnie@fedora:~$ sudo ./awk_kernel3.awk /var/log/messages | sort | uniq -c
25795 Kernel here in Field 5
38580 No kernel or systemd in Field 5
35506 Systemd here in Field 5
donnie@fedora:~$
很酷,它工作了。
对于我们的最后一个if
技巧,让我们创建awk_kernel4.awk
脚本,如下所示:
#!/usr/bin/awk -f
{
if ($5 ~ /kernel/) {
print "Kernel here in Field 5 on line " NR
}
else if ($5 ~ /systemd/) {
print "Systemd here in Field 5 on line " NR
}
else {
print "No kernel or systemd in Field 5 on line " NR
}
}
记录数(NR
)内建变量会将行号和消息一起打印出来。输出会很多,你可能需要将其管道传送到less
,像这样:
donnie@fedora:~$ sudo ./awk_kernel4.awk /var/log/messages | less
下面是输出的示例:
Kernel here in Field 5 on line 468
Systemd here in Field 5 on line 469
Systemd here in Field 5 on line 470
No kernel or systemd in Field 5 on line 471
No kernel or systemd in Field 5 on line 472
No kernel or systemd in Field 5 on line 473
No kernel or systemd in Field 5 on line 474
No kernel or systemd in Field 5 on line 475
No kernel or systemd in Field 5 on line 476
Systemd here in Field 5 on line 477
好的,我想你已经明白了。除了语法不同外,它其实和在普通bash
脚本中使用if
没有什么不同。所以,接下来我们继续。
使用while
结构并设置变量
在这一部分,我将同时介绍两个新概念。你将看到如何使用while
循环,以及如何使用awk
编程变量。让我们从简单的开始。
求一行中的数字和
在这个场景中,我们有一个包含几行数字的文件。我们希望将每一行的数字相加,并显示每一行的总和。首先,创建输入文件并使其看起来像这样numbers_fields.txt
文件:
38 87 389 3 3432
34 13
38976 38 198378 38 3
3878538 38
38
893 18 3 384 352 3892 10921 10 384
348 35 293
93 1 2
1 2 3 4 5
这看起来是一个相当具有挑战性的任务,因为每一行的字段数量不同。但实际上,这个任务非常简单。下面是执行此任务的add_fields.awk
脚本:
#!/usr/bin/awk -f
{
addend=1
sum=0
while (addend <= NF) {
sum = sum + $addend
addend++
}
print "Line " NR " Sum is " sum
}
我做的第一件事是初始化addend
和sum
变量。addend
变量表示字段号。通过将其初始化为1
,脚本将始终从每行的第一个字段开始。sum
变量被初始化为0
,这是显而易见的。while (addend <= NF)
这一行使得while
循环会一直执行,直到到达行中的最后一个字段。(内建变量NF
保存当前行中的字段数。)在下一行,使用$addend
与列出字段号(例如$1
或$2
)是一样的。因此,正如你所预期的,$addend
返回的是给定字段中包含的值。通过使用变量代替硬编码的字段号,我们可以在下一行使用addend++
命令来进到下一字段。(这种variable++
结构会将变量的值增加 1,和 C 语言中一样。)
如果你对这个有些困惑,请允许我进一步说明。
与普通的 shell 脚本不同,在awk
中,你不需要在变量名前加上$
来引用其值。在awk
中,$
用于引用字段的编号。因此,在awk
中给变量名前加$
,意味着你引用的是分配给该变量的字段号。
在while
循环结束后,脚本会输出其信息,并显示一行中所有数字的总和。然后,它返回脚本的开头,继续执行,直到文件中的所有行都被处理完。输出如下:
donnie@fedora:~$ ./add_fields.awk numbers_fields.txt
Line 1 Sum is 3949
Line 2 Sum is 47
Line 3 Sum is 237433
Line 4 Sum is 3878576
Line 5 Sum is 38
Line 6 Sum is 16857
Line 7 Sum is 676
Line 8 Sum is 96
Line 9 Sum is 15
donnie@fedora:~$
它能正常工作,一切都很顺利。现在,让我们做一些改进。添加一行代码,计算每行数字的平均值并格式化输出。将文件命名为average_fields.awk
,并使其如下所示:
#!/usr/bin/awk -f
{
addend=1
sum=0
while (addend <= NF) {
sum = sum + $addend
addend++
}
print "Line " NR "\n\t" "Sum: " sum "\n\t" "Average: " sum/NF
}
这非常酷,因为我可以在数学运算中使用NF
内建变量。在这种情况下,我只是将每行的总和除以每行的字段数。输出如下:
donnie@fedora:~$ ./average_fields.awk numbers_fields.txt
Line 1
Sum: 3949
Average: 789.8
Line 2
Sum: 47
Average: 23.5
. . .
. . .
Line 9
Sum: 15
Average: 3
donnie@fedora:~$
如果你习惯了 C 语言编程,请理解awk
和 C 在使用变量上的区别。与 C 不同,在awk
中,你不需要在使用变量之前声明它们。然而,有时确实需要在使用之前将它们初始化为某个特定值。此外,与 C 不同的是,awk
中只有一种变量类型。所有awk
变量都是字符串类型,所有awk
数学运算符会自动识别这些变量可能代表的数值。
接下来,让我们看看一些稍微复杂的内容。
查找 CPU 世代
让我们考虑另一种情况。你有一台老旧的服务器,搭载的是 AMD Opteron CPU。你刚刚尝试在其上安装 Red Hat Enterprise Linux 9(RHEL 9),但无法成功安装。问题可能出在哪里?
其实,就是因为你那颗可靠的 Opteron CPU 太旧了,这意味着它缺少了新一代 CPU 的一些功能。自从 AMD 在 2003 年推出第一款 64 位 x86 CPU 以来,AMD 和英特尔一直在为其新型号增加新的功能。2020 年,英特尔、AMD、Red Hat 和 SUSE 的代表们聚集在一起,定义了 x86_64 的四个代次。每个新的代次都包含上一代没有的功能。以下是每一代发布的时间:
-
第一代:2003 年
-
第二代:2009 年
-
第三代:2013 年发布的英特尔,2015 年发布的 AMD
-
第四代:2017 年发布的英特尔,2022 年发布的 AMD
为第一代 x86_64 CPU 创建的软件也可以在后三代 CPU 上正常运行。但是,如果你拥有更新的 CPU,你可以通过使用专门为其优化的软件来提升性能。当然,这也意味着它无法在旧版 CPU 上运行。因此,如果你仍在使用第一代 x86_64 CPU,你将无法运行 RHEL 9 或任何它的克隆版本。(有传言称,RHEL 10 将至少需要第三代 x86_64 CPU,预计将在 2025 年某个时候发布。你可以在进一步阅读部分找到关于这一传闻的链接。)其实这也没关系,因为 Red Hat 的目标客户是那些定期更新设备的大型企业。
这使得几乎不可能有任何设备仍在运行这些第一代机器。对于普通的桌面 Linux 用户来说,这并不会造成太大影响,因为很少有家庭用户会使用 RHEL 或其克隆版本,且大多数非 RHEL 发行版仍然支持旧机器。那么,如何知道你的 CPU 属于哪一代呢?很简单,写一个脚本。
为了了解这一过程的工作原理,可以查看/proc/cpuinfo
文件,并滚动到flags
部分。你看到的内容将取决于你机器中的 x86_64 CPU 属于哪一代。这里是我那台 2012 年款戴尔工作站上的 Intel Xeon CPU 的flags
部分,它是第二代 x86_64 架构:
flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx rdtscp lm constant_tsc rep_good nopl xtopology nonstop_tsc cpuid tsc_known_freq pni pclmulqdq ssse3 cx16 sse4_1 sse4_2 x2apic popcnt aes xsave avx hypervisor lahf_lm pti md_clear flush_l1d
在我的那台 2009 年款的惠普老机器上,它配有一对第一代 Opteron CPU,flags
部分如下所示:
flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm 3dnowext 3dnow constant_tsc rep_good nopl nonstop_tsc cpuid extd_apicid pni monitor cx16 popcnt lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt hw_pstate vmmcall npt lbrv svm_lock nrip_save
你可以看到,我的戴尔电脑上的第二代 CPU 拥有更新的功能,例如sse4_1
、sse4_2
,以及一些旧版 Opteron 所不具备的其他特性。在 Linux 系统中,你可以创建一个bash
脚本或awk
脚本,它会自动解析/proc/cpuinfo
文件,从而确定你的 CPU 属于哪个代次。下面是我从 StackExchange 网站的一篇帖子中借来的x86_64_check.awk
脚本:
#!/usr/bin/awk -f
BEGIN {
while (!/flags/) if (getline < "/proc/cpuinfo" != 1) exit 1
if (/cmov/&&/cx8/&&/fpu/&&/fxsr/&&/mmx/&&/syscall/&&/sse2/) level = 1
if (level == 1 && /cx16/&&/lahf/&&/popcnt/&&/sse4_1/&&/sse4_2/&&/ssse3/) level = 2
if (level == 2 && /avx/&&/avx2/&&/bmi1/&&/bmi2/&&/f16c/&&/fma/&&/abm/&&/movbe/&&/xsave/) level = 3
if (level == 3 && /avx512f/&&/avx512bw/&&/avx512cd/&&/avx512dq/&&/avx512vl/) level = 4
if (level > 0) { print "CPU supports x86-64-v" level; exit level + 1 }
exit 1
}
首先需要注意的是,整个脚本必须放在BEGIN
块中。这是因为我们需要一次性处理整个cpuinfo
文件,而不是像awk
通常那样逐行处理。BEGIN
块帮助我们完成这项任务。
然后,请注意这个脚本使用了不同风格的 if
结构。与我之前给你展示的 C 语言风格不同,作者将每个 if
结构单独放在一行。两种风格都可以正常工作,接下来我留给你决定你喜欢哪一种。总之,这里是它如何工作的详细说明。
-
在前四个
if
语句的末尾,level
变量会被设置为新的值。 -
第一个
if
语句查找第一代的功能,然后将1
赋值给level
。 -
第二个
if
语句验证 CPU 是否包含一级功能,查找第二代 CPU 的另一组功能,然后将2
赋值给level
。 -
该过程会重复执行第三和第四个
if
语句,用于检测第三代或第四代的 CPU。 -
最后,第五个
if
语句验证level
的值是否大于0
,打印出消息,然后以level
加 1 的值作为退出码退出脚本。 -
exit 1
行的作用是,如果脚本因任何原因无法正确运行,则会以退出码1
退出脚本。(第四个if
语句末尾的level + 1
命令防止程序成功运行时返回1
作为退出码。记住,1
通常是表示程序未正确运行的退出码。)
关于 awk
变量的一件有趣的事情是,它们都是字符串变量,这大大简化了编码过程。如果你将值 1
赋给 level
变量,该值会作为字符串存储,而不是整数。但是,当你使用变量进行数学运算时,awk
会自动识别字符串是否是数字,因此一切都会正常工作。另外,与 bash
不同,awk
原生支持浮点数运算。所以,你可以比在 bash
脚本或普通编程语言中更轻松、更快速地进行数学运算。为了演示这一点,让我们在我目前运行的两台工作站上运行这个脚本。这是我那台使用 Opteron 处理器的惠普机器上运行的情况:
donnie@opensuse:~> ./x86-64-level_check.awk
CPU supports x86-64-v1
donnie@opensuse:~> echo $?
2
donnie@opensuse:~>
正如预期的那样,脚本显示这是一个第一代 x86_64 机器。echo $?
命令显示退出码,这是由脚本中的 level + 1
命令生成的。现在,这就是我那台使用 Xeon 处理器的戴尔机器上的情况:
donnie@fedora:~$ ./x86-64-level_check.awk
CPU supports x86-64-v2
donnie@fedora:~$ echo $?
3
donnie@fedora:~$
所以,是的,一切运行正常。
啊,不过等等,我们还没完,因为我还没有解释 while
结构。这个有点复杂,所以我把它留到最后讲。
我已经向你展示过,在 /proc/cpuinfo
文件中,你要查找的字符串在第一字段包含 flags
字符串的段落中。但是,while (!/flags/)
语句让人看起来像是我们没有在寻找 flags
段落。(记住,!
是取反运算符。)要理解发生了什么,查看整个 cpuinfo
文件,可以通过输入 cat /proc/cpuinfo
来实现。你会看到相同的信息会为你系统中的每个 CPU 核心打印一次。例如,我的戴尔工作站配备了一个八核 Xeon 处理器,启用了超线程技术,这意味着我总共有 16 个虚拟 CPU 核心。因此,运行 cat /proc/cpuinfo
会导致相同的 CPU 信息打印 16 次。你还会看到在 flags
段之后会打印出更多的信息段。要理解这个 while
循环的工作原理,可以运行以下单行的 awk
命令:
donnie@fedora:~$ awk 'BEGIN {while (!/flags/) if (getline <"/proc/cpuinfo" == 1) print $0}'
processor : 0
vendor_id : GenuineIntel
. . .
. . .
wp : yes
flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 cx16 xtpr pdcm pcid dca sse4_1 sse4_2 x2apic popcnt tsc_deadline_timer aes xsave avx lahf_lm epb pti ssbd ibrs ibpb stibp tpr_shadow flexpriority ept vpid xsaveopt dtherm ida arat pln pts vnmi md_clear flush_l1d
donnie@fedora:~$
flags
这一段实际上只是一个很长的单行,它会在你的终端和打印页面上换行。因此,awk
将 flags
行视为单个记录。这个命令中的 while (!/flags/)
语句使得 getline
会读取 cpuinfo
文件,直到遇到第一个 flags
字符串为止。这意味着只有第一个 CPU 核心的 CPU 信息会显示出来,任何在第一个 flags
行之后的信息都不会显示。(是的,看起来有点困惑,但仔细想一想就能明白。)
在原始脚本中,while
循环中的 if (getline < "/proc/cpuinfo" != 1) exit 1
做了两件事。首先,它使用 !=1
参数检查 cpuinfo
文件是否存在。如果 !=1
,也就是相当于说 不正确,其结果为 正确 时,脚本会以退出码 1
退出。
如果 !=1
参数的结果为 假,即文件存在,那么 getline
,一个内置的 awk
函数,会读取 cpuinfo
文件。这样,如果你在没有 /proc/cpuinfo
文件的操作系统(如 FreeBSD)上运行此脚本,它会优雅地退出。
现在,有些人可能更喜欢使用 C 风格的语法来让脚本更易读。幸运的是,这很容易做到。改变语法会使脚本变得太长,无法在这里完整展示,但我可以给你看一小段代码:
#!/usr/bin/awk -f
BEGIN {
while (!/flags/) {
if (getline < "/proc/cpuinfo" !=1) {
exit 1
}
}
. . .
. . .
if (level > 0) {
{ print "CPU supports x86-64-v" level; exit level + 1 }
}
exit 1
}
如果你想查看完整的转换脚本,只需从 GitHub 下载 x86-64-level_check2.awk
文件。
我认为我们已经完成了这个脚本。让我们看看如何使用 for
循环和数组。
使用 for
循环和数组
一些语言,如西班牙语和法语,具有阳性和阴性名词的概念。在这个演示中,我们将使用一组英文名词、它们在西班牙语中的对应词以及西班牙语名词的性别标注。
为什么一个法国姓氏的人在创建西班牙语单词列表?嗯,尽管我有法国血统,但我在高中选择学习西班牙语而非法语。所以,我会一些西班牙语,但我不会法语。(我知道,我有点怪。)此外,我也意识到西班牙语单词camiόn的最后一个音节上有一个重音符号。可惜的是,在英语键盘上插入重音符号并不容易,特别是在纯文本文件中,至少不使用awk
脚本时,不会破坏其工作。
首先,创建spanish_words.txt
文件,并使其如下所示:
ENGLISH:SPANISH:GENDER
cat:gato:M
table:mesa:F
bed:cama:F
bus:camion:M
house:casa:F
如你所见,我们使用冒号作为字段分隔符,并用M
或F
来表示单词是阳性还是阴性。第一行是表头,因此我们在处理文件时需要考虑到这一点。
接下来,创建masc-fem.awk
脚本,如下所示:
#!/usr/bin/awk -f
BEGIN {FS=":"}
NR==1 {next}
$3 == "M" {masc[$2]=$1}
$3 == "F" {fem[$2]=$1}
END {
print "\nMasculine Nouns\n----";
for (m in masc)
{print m "--" masc[m]; count++}
print "\nFeminine Nouns\n----";
for (f in fem)
{print f "--" fem[f]; count2++}
print "\nThere are " count " masculine nouns and " count2 " feminine nouns."
}
在BEGIN
部分,我们设置了:
作为字段分隔符。NR == 1 {next}
这一行表示忽略第一行,直接跳到下一行。接下来的两行构建了masc
和fem
数组。任何第三列是M
的行都会被放入masc
数组,而任何第三列是F
的行则会被放入fem
数组。END
部分包含在主体代码运行完成后执行的代码。两个for
循环的工作原理与正常的 Shell 脚本for
循环相同,只是我们现在使用的是 C 语言的语法。第一个循环打印出所有的阳性名词,并使用count
变量统计阳性名词的总数。第二个循环对阴性名词执行相同的操作,只是它使用count2
变量统计阴性名词的数量。运行脚本时,效果如下:
donnie@fedora:~$ ./masc-fem.awk spanish_words.txt
Masculine Nouns
----
gato--cat
camion--bus
Feminine Nouns
----
mesa--table
casa--house
cama--bed
There are 2 masculine nouns and 3 feminine nouns.
donnie@fedora:~$
就这样,完工了。简单吧?
接下来,让我们做一些浮点数学运算。
使用浮点数学和printf
由于某种奇怪的原因,你发现自己正在做一份需要跟踪与天气相关的统计数据的工作。你的老板刚刚发给你一个文本文件,里面包含了一系列温度。有些温度是摄氏温度,有些是华氏温度。你被分配的任务是将华氏温度转换为摄氏温度。你可以通过使用计算器手动转换每个华氏温度来完成这个任务,或者你可以编写一个脚本来自动化这个过程。你决定编写脚本会更容易一些。温度列表保存在temps.txt
文件中,内容如下:
Temperature Celsius_or_Fahrenheit
32 F
212 F
-40 F
-14.7 F
24.6111 C
75.8 F
55.21 F
23.9444 C
29.8 F
104.34 F
98.6 F
23.1 C
前三个温度是用来检查脚本是否正确工作的。我们知道 32 华氏度等于 0 摄氏度,212 华氏度等于 100 摄氏度,最后,-40 华氏度等于-40 摄氏度。如果脚本能正确转换这三个温度,我们可以合理地确信其余的温度也会被正确转换。
确保你没有在最后一行温度数据之后插入空行,否则脚本将在最后一行实际输出数据后插入无意义的信息。
有两种方法可以表达转换公式。这里是其中一种方法:
(($1-32)*5) / 9
这是另一种方法:
($1-32)/1.8
$1
代表保存原始华氏温度的字段。所以,我们首先从华氏值中减去 32。
两种公式都同样有效,因此我们使用哪一种其实并不重要。为了好玩,我会在 fahrenheit_to_celsius.awk
脚本中使用第一种方法,脚本内容如下:
#!/usr/bin/awk -f
NR==1 ; NR>1{print ($2=="F" ? (($1-32)*5) / 9 : $1)"\t\t Celsius"}
如你所见,这只是一个简单的单行代码。NR==1
使得标题被打印出来,而 NR>1
确保转换只在包含实际数据的行上进行。print
操作中 ?
和 :
的组合被称为三元运算符。如果第一个条件 ($2=="F"
) 为真,那么字段 1 的原始值将被 ?
和 :
之间的值替换。在这种情况下,新值是通过执行转换计算得出的。在每行的新温度值后,我们希望打印出一对制表符,后跟“Celsius”一词。运行脚本时的结果如下:
donnie@fedora:~$ ./fahrenheit_to_celsius.awk temps.txt
Temperature Celsius_or_Fahrenheit
0 Celsius
100 Celsius
-40 Celsius
-25.9444 Celsius
24.6111 Celsius
24.3333 Celsius
12.8944 Celsius
23.9444 Celsius
-1.22222 Celsius
40.1889 Celsius
37 Celsius
23.1 Celsius
donnie@fedora:~$
这看起来不太好,因为一些摄氏值比其他的要长,这导致第二列的那些行没有正确对齐。我们将通过使用 printf
替代 print
来修复这个问题。
printf
命令允许你以 print
无法做到的方式定制输出。在 awk
中它的工作方式与 C 中相同,所以再次让 C 程序员们高兴吧。以下是 fahrenheit_to_celsius2.awk
脚本中解决方案的工作方式:
#!/usr/bin/awk -f
NR==1; NR>1{printf("%-11s %s\n",($2=="F" ? (($1-32)*5) / 9 : $1), "Celsius")}
printf
命令中的 %
符号代表格式化指令。让我们来看一下 %-11s
指令,它格式化每行的第一个字段。-
告诉 printf
左对齐输出。(默认情况下,输出是右对齐的。)11s
告诉 printf
为第一个字段分配 11 个空格。如果任何给定行的字符串长度小于 11 个字符,printf
会用足够的空白填充输出,直到达到差距。最后,%s\n
使得 printf
打印出指定的文本字符串作为第二个字段,后跟换行符。(与 print
不同,printf
不会自动在行末添加换行符。)无论如何,输出如下:
donnie@fedora:~$ ./fahrenheit_to_celsius2.awk temps.txt
Temperature Celsius_or_Fahrenheit
0 Celsius
100 Celsius
-40 Celsius
-25.9444 Celsius
24.6111 Celsius
24.3333 Celsius
12.8944 Celsius
23.9444 Celsius
-1.22222 Celsius
40.1889 Celsius
37 Celsius
23.1 Celsius
donnie@fedora:~$
是的,看起来好多了。但是,如果你不需要看到所有的小数位怎么办?很简单,只需要为第一个字段使用不同的格式化指令,正如你在 fahrenheit_to_celsius3.awk
脚本中看到的那样:
#!/usr/bin/awk -f
NR==1; NR>1{printf("%.2f %s\n",($2=="F" ? (($1-32)*5) / 9 : $1), "Celsius")}
这里,我使用 %.2f
将输出格式化为一个浮点数,且小数点后只有两位数字。输出的结果如下:
donnie@fedora:~$ ./fahrenheit_to_celsius3.awk temps.txt
Temperature Celsius_or_Fahrenheit
0.00 Celsius
100.00 Celsius
-40.00 Celsius
-25.94 Celsius
24.61 Celsius
24.33 Celsius
12.89 Celsius
23.94 Celsius
-1.22 Celsius
40.19 Celsius
37.00 Celsius
23.10 Celsius
donnie@fedora:~$
唯一的小问题是,现在第二列对不齐了。你不能将两个指令用于单个字段,所以我们只能接受这样的格式。不过,这也没关系,因为如果你决定将输出重定向到文件中,依然可以将它导入到电子表格程序中。
通过稍微修改公式,你也可以将摄氏温度转换为华氏温度。以下是执行此操作的celsius_to_fahrenheit2.awk
脚本:
#!/usr/bin/awk -f
NR==1; NR>1{printf("%-11s %s\n",($2=="C" ? (($1+32)*9) / 5 : $1),"Fahrenheit")}
这是输出结果:
donnie@fedora:~$ ./celsius_to_fahrenheit2.awk temps.txt
Temperature Celsius_or_Fahrenheit
32 Fahrenheit
212 Fahrenheit
-40 Fahrenheit
-14.7 Fahrenheit
101.9 Fahrenheit
75.8 Fahrenheit
55.21 Fahrenheit
100.7 Fahrenheit
29.8 Fahrenheit
104.34 Fahrenheit
98.6 Fahrenheit
99.18 Fahrenheit
donnie@fedora:~$
接下来,假设我们不关心查看温度列表,只想看到平均温度。那么,以下是实现这一目标的average_temp.awk
脚本:
#!/usr/bin/awk -f
BEGIN{temp_sum=0; total_records=0; print "Calculate the average temperature."}
$2=="F"{temp_sum += ($1-32) / 1.8; total_records += 1;}
$2=="C" {temp_sum += $1; total_records += 1}
END {average_temp = temp_sum/total_records; print "The average temperature is: \n\t " average_temp " Celsius."}
这次,我决定使用从华氏度转换为摄氏度的替代公式。在两个$2==
的行中,我使用了+=
运算符来求和第一个字段中的温度,并递增total_records
变量。以下是输出结果:
donnie@fedora:~$ ./average_temp.awk temps.txt
Calculate the average temperature.
The average temperature is:
18.2421 Celsius.
donnie@fedora:~$
为了验证它是否给出了准确的平均值,可以尝试修改temps.txt
文件中的不同温度值,看看会发生什么。
关于这个话题最后要说的是,awk
提供了全套数学运算符,并且有很多有用的数学函数。你可以通过查阅进一步阅读部分中的链接了解更多信息。
接下来,让我们看看如何处理多行记录。
处理多行记录
到目前为止,我们一直在使用awk
解析每一行都是独立记录的文本文件。不过,有时你可能需要处理一些文件,其中每条记录跨越多行。例如,看看这个inventory.txt
文件:
Kitchen spatula
$4.99
Housewares
Raincoat
$36.99
Clothing
On Sale!
Claw hammer
$7.99
Tools
第一条和第三条记录各由三行组成,第二条记录由四行组成。每条记录由空行分隔。现在,假设我们需要将这些信息导入到电子表格中。由于多行记录的存在,这样做效果不好,所以我们需要找到一种简单的方法将其转换为适合电子表格格式的内容。再次使用awk
来拯救我们!以下是帮助我们完成这一任务的inventory.awk
脚本:
#!/usr/bin/awk -f
BEGIN {
FS="\n"
RS=""
ORS=""
}
{
count=1
while (count<NF) {
print $count "\t"
count++
}
print $NF "\n"
}
BEGIN
块将换行符(\n
)定义为字段分隔符,这意味着每一行都算作记录中的一个独立字段。记录分隔符(RS
)和输出记录分隔符(ORS
)都被定义为空值(""
)。RS
变量将空值解读为空白行,而ORS
变量不会。在这种情况下,ORS
被定义为空值只是为了防止while
循环中的print
命令在每个字段末尾添加换行符。
while
循环中没有什么是你没见过的。它只是使用 count
变量来保存正在处理的字段的编号。这个 count
变量的值在每次迭代 while
循环后都会增加 1。你可能会觉得奇怪,为什么循环条件定义为 count<NF
而不是 count<=NF
。我的意思是,我们难道不想处理每个记录的最后一个字段吗?嗯,确实是的,我们通过 while
循环后的 print $NF "\n"
命令来处理最后一个字段。正如我所说,定义 OFS
为 ""
可以防止 print
命令在每个字段的末尾添加换行符。因此,为了让每条记录在单独的一行上,我们必须为最后一个字段单独使用一个 print
命令,并指定它在行末添加换行符。无论如何,以下是我使用该脚本解析 inventory.txt
文件时的结果:
donnie@fedora:~$ ./inventory.awk inventory.txt
Kitchen spatula $4.99 Housewares
Raincoat $36.99 Clothing On Sale!
Claw hammer $7.99 Tools
donnie@fedora:~$
当然,你可以将输出重定向到 .tsv
文件中,这样你就可以在你喜欢的电子表格程序中打开它。如果你更愿意使用 .csv
文件,只需在 while
循环中的 print $count "\t"
行替换为 print $count ","
。输出将如下所示:
donnie@fedora:~$ ./inventory.awk inventory.txt
Kitchen spatula,$4.99,Housewares
Raincoat,$36.99,Clothing,On Sale!
Claw hammer,$7.99,Tools
donnie@fedora:~$
这个脚本的优点是,无论每条记录中有多少字段都不重要。NF
内建变量跟踪字段的数量,while
循环根据记录的字段数量进行处理。
好的,我想这差不多就结束了我们对 awk
脚本的介绍。所以,让我们总结一下并继续前进。
总结
awk
脚本语言对于需要从纯文本文件中提取有意义数据的人来说极其有用。我从这一章开始时,首先向你展示了一个 awk
脚本的基本结构。接着,我展示了如何使用 if
和 if..else
创建条件命令,如何使用 while
循环,以及如何解析包含多行记录的文本文件。在演示中,我向你展示了各种 awk
脚本,这些脚本执行的任务是你在现实生活中可能遇到的类型。
不幸的是,我无法完全展现 awk
的所有内容。它是一个那种有完整书籍专门讨论的主题,所以我能做的最好就是让你对它产生兴趣。
在下一章中,我们将回到 Shell 脚本的主要主题,介绍几个工具,它们允许你为脚本创建用户界面。我们在那里见。
问题
-
为了最佳的可移植性,你应该在
awk
脚本中使用以下哪一行 shebang?-
#!/bin/awk
-
#!/usr/bin/awk -f
-
#!/bin/awk -f
-
#!/usr/bin/awk
-
-
以下哪项陈述是正确的?
-
所有
awk
编程变量必须在使用之前声明。 -
awk
变量有多种类型,例如整数、浮点数和字符串。 -
所有
awk
编程变量都是字符串类型的变量。 -
在进行数学运算之前,你必须将字符串类型的变量转换为整数或浮点数类型。
-
-
在
awk
脚本中,如何定义逗号为字段分隔符?-
-F=,
-
-F=","
-
FS=,
-
.
FS=","
-
-
你有一个包含多行数字的文本文件,数字之间有空格。每一行的数字数量不同。你想计算每行的数字之和。当你编写一个
awk
脚本来计算这些和时,如何处理每行字段数量不同的问题?-
创建一个变量数组来存储每行的字段数,并使用
for
循环构建该数组。 -
使用内建变量
NF
。 -
使用内建变量
NR
。 -
只有当每一行有相同数量的字段时,才能执行此操作。
-
-
以下哪个
printf
指令可以确保浮点数总是显示四位小数?-
%.4f
-
%4
-
#4.f
-
#4
-
进一步阅读
-
如何检查我的 CPU 是否支持 x86_v2?:
unix.stackexchange.com/questions/631217/how-do-i-check-if-my-cpu-supports-x86-64-v2
-
探索 x86-64-v3 在 Red Hat Enterprise Linux 10 中的应用:
developers.redhat.com/articles/2024/01/02/exploring-x86-64-v3-red-hat-enterprise-linux-10
-
x86_64 级别:
medium.com/@BetterIsHeather/x86-64-levels-944e92cd6d83
-
《awk 命令如何让我成为 10 倍工程师》:
youtu.be/FbSpuZVb164?si=ri9cnjBh1sxM_STz
-
printf 示例:
www.gnu.org/software/gawk/manual/html_node/Printf-Examples.html
-
使用 awk 进行数学运算:
www.networkworld.com/article/942538/doing-math-with-awk.html
-
awk 中的数值函数:
www.gnu.org/software/gawk/manual/html_node/Numeric-Functions.html
答案
-
b
-
c
-
d
-
b
-
a
加入我们的 Discord 社区!
与其他用户、Linux 专家以及作者本人一起阅读这本书。
提问、为其他读者提供解决方案、通过“问我任何问题”环节与作者交流,等等。扫描二维码或访问链接加入社区。
第十六章:使用 yad、dialog 和 xdialog 创建用户界面
我知道,你可能在想 shell 脚本不过是进入终端输入无聊的纯文本命令而已。但如果我告诉你,你可以通过添加用户界面来让你的脚本更炫酷呢?如果我告诉你,你可以为桌面系统添加图形界面,为文本模式的服务器添加非图形界面呢?没错,你完全可以做到,而我会告诉你怎么做。
本章内容包括:
-
使用
yad
创建图形用户界面 -
使用
dialog
和xdialog
创建用户界面
如果你准备好了,我们就开始吧。
技术要求
对于 yad
和 xdialog
,你需要使用桌面版的 Linux。使用哪种发行版并不重要,只要 yad
和 xdialog
包在软件仓库中即可。只需使用你发行版的标准包管理工具来安装它们。(yad
和 xdialog
包也适用于 GhostBSD,它是 FreeBSD 的一个版本,支持 Mate 或 Xfce 桌面环境。如果你在 Mac 上,你需要通过 Homebrew 安装 yad
或 xdialog
。遗憾的是,OpenIndiana 上没有这两个工具,而 yad
在任何 RHEL 9 类的发行版上都不可用。)
对于 dialog
部分,你可以使用桌面或文本模式服务器实现的 Linux。
一如既往,你可以通过运行以下命令来获取脚本:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
好的,让我们从 yad
开始看起。
使用 yad 创建图形用户界面
Yet Another Dialog,简称 yad,是一个非常酷的程序,可以让你为 shell 脚本添加图形化界面。你可以用它做很多事情,我想给你展示几个简单的例子。
yad 基础
在 yad
的手册页中,你会看到一列预定义的组件,可以在你的 yad
脚本中使用。例如,如果你运行 yad --file
,你会打开一个像这样的文件管理器:
图 16.1:yad 文件管理器
就目前而言,这个文件管理器对你没有任何帮助。如果你点击一个文件,然后点击 OK 按钮,管理器会关闭,并且你选择的文件名会打印在命令行上,像这样:
donnie@fedora:~$ yad --file
/home/donnie/win_bentley.pdf
donnie@fedora:~$
为了使它更有用,你需要添加代码,对所选文件执行一些预期的操作。不过,在尝试这么复杂的操作之前,我们先从一些稍微简单的内容开始。
创建数据输入表单
假设你是一个经典汽车收藏爱好者,你需要创建一个简单的数据库来跟踪你庞大的收藏。我们将从测试基本脚本开始,脚本内容如下:
#!/bin/bash
yad --title="Classic Autos" --text="Enter the info about your classic auto:" --form --width=400 --field="Year":NUM --field="Make" --field="Model" --field="Body Style" >> classic_autos.txt
不过,这个有点难读。所以,让我们稍微调整一下 yad-form-auto1.sh
脚本,使其更具可读性,像这样:
#!/bin/bash
yad --title="Classic Autos" --text="Enter the info about your classic auto:" \
--form --width=400 \
--field="Year":NUM \
--field="Make" \
--field="Model" \
--field="Body Style" >> classic_autos.txt
这个yad
命令实际上是一个长命令。为了使其更易读,我将其分成了几行,每个选项定义后都加上了反斜杠。如果你愿意,也可以去掉反斜杠,将整个命令写成一行,就像上面看到的那样,但分行显示要好得多。
在第一行,我同时设置了--title
和--text
定义,这些应该是显而易见的。第二行定义了一个宽度为 400 像素的表单。接下来的定义是数据输入字段。请注意,Year
字段只能接受数字值,如:NUM
参数所示。最后,我将输出重定向到classic_autos.txt
文件中。(我使用>>
操作符,以便可以将多个汽车信息添加到文件中。)这是我运行脚本时的样子:
图 16.2:运行 yad-form-auto1.sh
当我点击OK按钮时,输出将被保存到classic_autos.txt
文件中,内容如下所示:
donnie@fedora:~$ cat classic_autos.txt
1958|Edsel|Corsair|2 door hardtop|
donnie@fedora:~$
默认情况下,yad
使用|
符号作为输出字段分隔符。这是可以的,因为如果你在电子表格程序中打开此文件,你可以设置|
符号作为字段分隔符,它会正常工作。不过,如果你愿意,你也可以更改字段分隔符。例如,如果你想创建.csv
文件,只需在--form
行中添加--separator=","
参数。
这个脚本有一个小问题,每当你点击OK按钮将一个条目创建到文本文件中时,脚本就会退出。每次添加一辆车,你都需要重新启动脚本。让我们通过在yad-form-auto2.sh
脚本中使用while
循环来修复这个问题:
#!/bin/bash
while :
do
yad --title="Classic Autos" --text="Enter the info about your classic auto:" \
--form --width=400 \
--field="Year":NUM \
--field="Make" \
--field="Model" \
--field="Body Style" \
--field="Date Acquired":DT >> classic_autos.txt
done
通过使用:
作为while
条件,我们创建了一个所谓的无限循环。换句话说,除非按下Ctrl-c,否则它将永远不会退出。
在此过程中,我添加了Date Acquired
字段,并使用了:DT
选项。这个选项会在我点击日历图标时弹出一个方便的日历。这里是它的显示样式:
图 16.3:yad-form-auto2.sh 脚本
这样做不错,但仍然不完美。由于使用了:
作为while
条件,结束这个脚本的唯一方法就是Ctrl-c,因为取消按钮不会起作用。要解决这个问题,你需要明白,OK按钮返回 0 作为退出代码,而取消按钮返回 1 作为退出代码。
如果你需要复习退出代码的概念,请参考第八章,基本 Shell 脚本构建。
所以,让我们修改一下yad-form-auto3.sh
脚本中的while
循环:
#!/bin/bash
while [ $? == 0 ]
do
yad --title="Classic Autos" --text="Enter the info about your classic auto:" \
--form --width=400 \
--field="Year":NUM \
--field="Make" \
--field="Model" \
--field="Body Style" \
--field="Date Acquired":DT >> classic_autos.txt
done
$? == 0
参数获取退出代码并验证它是否为 0。这使得脚本在你没有点击取消按钮或窗口右上角的X时一直运行。(我忘记提到,点击X会返回退出代码 252。)
现在,让我们通过最后一次修改来结束这一部分。
创建下拉列表
这次,我们将添加一个下拉列表供选择车身样式,并加入一个自由文本框。以下是yad-form-auto4.sh
脚本,展示了如何实现:
#!/bin/bash
bodystyles=$(echo "2 door hardtop,2 door sedan,4 door hardtop,4 door sedan,station wagon,convertible,pickup truck,other")
while [ $? == 0 ]
do
yad --title="Classic Autos" --text="Enter the info about your classic auto:" \
--form --width=400 --item-separator="," \
--field="Year":NUM \
--field="Make" \
--field="Model" \
--field="Body Style":CBE \
--field="Date Acquired":DT >> classic_autos.txt \
--field="Add any additional notes:":TXT \
"" "" "" "$bodystyles" "Click on the calender icon"
done
bodystyles=
行、--form
行中的--item-separator
参数和"" "" ""
行一起工作,创建了下拉列表。bodystyles=
定义了可用车身样式的列表,样式之间用逗号分隔。这与--item-separator
参数配合使用,确保下拉列表每行只显示一个车身样式,而不是将所有车身样式放在同一行。
--field="Body Style"
行末的:CBE
参数将此字段定义为下拉列表,并在选择其他选项时可以编辑。while
循环中的最后一行告诉yad
在哪里放置bodystyles
变量的值。我们看到Body Style
字段是第四个字段。所以为了表示前三个字段(默认是空的),我们在$bodystyles
变量前放置了三对双引号。行末的最后一项将一行文本放入Date Acquired
字段。最终字段定义是自由文本框,正如通过:TXT
参数看到的。(文本框字段在下拉列表字段之后,这意味着您不需要再添加一对双引号作为占位符。)当我选择车身样式时,它看起来是这样的:
图 16.4:使用下拉列表
当然,您可以将这个脚本适应于几乎任何目的。这包括管理您个人收藏的库存、商店商品或软件许可证。(让您的想象力尽情驰骋。)
这就是本次演示的结束。现在,让我们来管理一些文件吧。
使用 yad 文件管理器
与文件交互的最简单方法是创建一个变量,并通过yad
命令替换结构为其赋值。然后,您可以对该变量的值执行任何操作。我们来看看可以做些什么。
创建文件校验和工具
对于第一个例子,假设您想创建一个包含sha512
校验和的文件列表。以下是实现此功能的yad-file-checksum1.sh
脚本:
#!/bin/bash
filetocheck=$(yad --file)
sha512sum $filetocheck >> file_checksums.txt
点击一个文件会将该文件的名称赋值给filetocheck
变量。然后,我们可以使用该变量的值作为sha512sum
命令的参数。
它的样子如下:
图 16.5:使用 yad-file-checksum1.sh 脚本打开文件
点击确定按钮会创建file_checksum.txt
文件,该文件包含people.txt
文件的 SHA512 校验和,内容如下所示:
donnie@fedora:~$ cat file_checksums.txt
0aa501cc947b22c48c8b2bca0e2a9675fad58440133af8794d8e2b51160f72fa6851c6d76a8554 6938c23ff5bf70b841bd1099ca727b39a59f486348d3dcee50 /home/donnie/people.txt
donnie@fedora:~$
很好,但一如既往地,我们可以让它更精致。在yad-file-checksum2.sh
脚本中,我们将添加一个while
循环,使取消按钮能够工作,并且还可以选择多个文件。同时,我们还会加入一个文件预览功能。它看起来是这样的:
#!/bin/bash
while [ $? == 0 ]
do
filetocheck=$(yad --file --multiple --separator='\n' --add-preview --width=800)
sha512sum $filetocheck >> file_checksums.txt
done
--multiple
和--separator='\n'
选项一起工作。没有--separator
选项时,--multiple
选项会导致所有文件名作为一行显示,文件名之间用|
符号分隔。这会导致sha512sum
工具将这一行视为单个文件名,从而导致错误。在每个文件名后面加上换行符可以解决这个问题。--add-preview
和--width
选项应该不言自明,因此我不再赘述。总之,以下是这个新脚本的工作方式:
图 16.6:带预览和多个文件选项的文件选择器
你可以通过按住Ctrl键选择多个不连续的文件,或者按住Shift键选择多个连续的文件。最终结果将是一个文本文件,包含所有选中文件的 SHA512 校验和。
我必须承认,这个脚本有点怪异,让我感到困惑。当你点击取消按钮或X按钮时,弹窗应该会关闭。但由于某些奇怪的原因,进程并没有结束,这意味着终端永远不会返回到命令提示符。我试过了所有能想到的非自然手段来解决这个问题,但都没有成功。所以,看起来每次运行这个脚本时,完成后你只需Ctrl-c退出即可。
好的,接下来我们继续下一个例子。
创建一个图形界面前端用于 ImageMagick
接下来,我们来看看能否为我们在第十三章,使用 ImageMagick 编写脚本中提到的 ImageMagick 程序创建一个图形界面前端。我们将从yad-image-resize1.sh
脚本开始,脚本内容如下:
#!/bin/bash
imageFile=$(yad --file --title="Select the image file" --width=800)
dialog=$(yad --title "Image Resize" --form --field="Resize parameter" --field="Quality")
size=$(echo $dialog | awk 'BEGIN {FS="|" } { print $1 }')
quality=$(echo $dialog | awk 'BEGIN {FS="|" } { print $2 }')
convert "$imageFile" -resize "$size" -quality "$quality"% "$imageFile"
yad --title="Status" --width=300 --button="OK:0" --text="All done. Yay!"
第一行创建了初始的文件选择窗口。然后,它将所选文件的文件名分配给imageFile
变量。以下是这个初始窗口的样子:
图 16.7:用于调整图像大小的初始文件选择窗口
第二行创建了图像调整大小窗口,你将用它来输入调整大小和质量参数。当你输入所需的参数时,它们会以单行的形式存储在dialog
变量中,两个参数之间用|
符号分隔。
接下来的两行创建了size
和quality
变量,使用存储在dialog
变量中的值。我们需要一种方法来提取每个单独的参数,而awk
为我们提供了一个方便的方法。总之,以下是图像调整大小窗口的样子:
图 16.8:图像调整大小窗口
接下来是convert
行,它执行实际的调整大小操作。你会看到它使用了size
、quality
和imageFile
变量中的值。当我点击确定按钮时,调整大小过程将会发生。
最后一行创建了状态窗口,显示处理过程已完成。默认情况下,yad
窗口有取消和确定按钮。在这种情况下,我们只需要确定按钮,因此我们通过显式创建一个确定按钮来覆盖默认行为,使用--button
选项。以下是该窗口的样子:
图 16.9:状态窗口
在你的目录中查看,你应该能看到图像确实已经被调整大小。
说一句公道话,我借用了这段脚本,它最初出现在 2012 年的一篇Linux Magazine文章中,并对其做了几个小的修改。你可以在进一步阅读部分找到原始文章的链接。
这个脚本的主要缺陷是它会用调整大小后的版本覆盖原始的图形文件。让我们修改它,使得它能够将调整大小后的版本保存为一个新文件,并且使用新的文件名。以下是执行此操作的yad-image-resize2.sh
脚本:
#!/bin/bash
imageFile=$(yad --file --title="Select the image file" --width=800 --add-preview)
newfilename=${imageFile%%.*}
suffix=${imageFile: -4}
dialog=$(yad --title "Image Resize" --form --field="Resize parameter" --field="Quality")
size=$(echo $dialog | awk 'BEGIN {FS="|"} {print $1}')
quality=$(echo $dialog | awk 'BEGIN {FS="|"} {print $2}')
convert -resize "$size" -quality "$quality" "$imageFile" -delete 1 "$newfilename"_resized"$suffix"
yad --title="Status" --width=300 --button="OK:0" --text="All done. Yay!"
为了实现这一点,我创建了两个新变量,并使用变量扩展为它们赋值。newfilename=${imageFile%%.*}
这一行提取了imageFile
文件名的第一部分,直到文件扩展名的点为止。例如,假设imageFile
的值是somegraphic.jpg
。使用变量扩展来去除.jpg
后,将somegraphic
赋值给newfilename
变量。suffix
变量使用了变量扩展的偏移特性。在这种情况下,${imageFile: -4}
构造会提取文件名的最后四个字符。因此,它会将.jpg
赋值给suffix
变量。
接下来的要点是如何修改convert
命令。(顺便提一下,请注意,这个convert
命令相当长,并且在页面上会换行。)我所做的只是将-delete 1 "$newfilename"_resized"$suffix"
部分添加到我之前的命令末尾。你可能已经猜到,在我们当前的示例中,这会创建一个新文件,文件名为somegraphic_resized.jpg
。但是,你可能会对-delete 1
这一部分感到困惑。其实,这只是因为在某些条件下,convert
命令会创建两个输出文件,而不仅仅是一个,正如你在这里看到的:
donnie@fedora:~/Pictures$ ls -l *dup*
-rw-r--r--. 1 donnie donnie 2324036 Feb 11 14:38 S1340003-dup.JPG
-rw-r--r--. 1 donnie donnie 29745 Feb 11 15:57 S1340003-dup_resized-0.JPG
-rw-r--r--. 1 donnie donnie 293424 Feb 11 15:57 S1340003-dup_resized-1.JPG
donnie@fedora:~/Pictures$
在输出文件名参数之前添加-delete 1
参数,解决了这个问题,正如你在这里看到的:
donnie@fedora:~/Pictures$ ls -l *dup*
-rw-r--r--. 1 donnie donnie 2324036 Feb 11 14:38 S1340003-dup.JPG
-rw-r--r--. 1 donnie donnie 29745 Feb 11 16:00 S1340003-dup_resized.JPG
donnie@fedora:~/Pictures$
我将展示的最终修改,我会将其放入yad-image-resize3.sh
,它会使文件管理器只显示特定类型的文件。由于我们处理的是图形文件,所以我们只希望文件管理器显示.JPG
、.jpg
、.PNG
和.png
扩展名的文件。我们可以通过将--file-filter "Graphics files | *.JPG *.jpg *.PNG *.png"
选项添加到脚本顶部的imageFile=
行来实现。新的行现在看起来是这样的:
imageFile=$(yad --file --title="Select the image file" --width=800 --add-preview --file-filter "Graphics files | *.JPG *.jpg *.PNG *.png")
由于这一行相当长,我们可以通过使用反斜杠将其分成多行,以便提高可读性,像这样:
imageFile=$(yad --file --title="Select the image file" \
--width=800 --add-preview \
--file-filter "Graphics files | *.JPG *.jpg *.PNG *.png")
那看起来好多了,对吧?
好的,让我们继续进行最后一个例子。
编程表单按钮
在这个场景中,我们处在一个软件开发者的工作室,运行着一小队由 Fedora Linux 驱动的工作站。我们想创建一个图形界面类型的设置工具,以简化设置新工作站的过程。此外,我们还希望将这个工具存放在 USB 闪存盘中,这样我们可以直接从闪存盘运行它,或者将它复制到每台工作站上。幸运的是,使用yad
很容易做到这一点。下面是完成这项工作的yad-system-tool.sh
脚本:
#!/bin/bash
yad --title "System tools for a Fedora Workstation" --form --columns=3 \
--width=540 --height=190 \
--text="Tools for setting up Fedora or RHEL-type distros" \
--field="<b>Update System</b>":FBTN "dnf -y upgrade" \
--field="<b>Authoring and Publishing</b>":FBTN "dnf -y groupinstall --with-optional 'Authoring and Publishing'" \
--field="<b>Development Tools</b>":FBTN "dnf -y --with-optional groupinstall 'Development Tools'" \
--button=Exit:1
在这个脚本中,你会看到一些你之前没见过的选项。第一行中的--columns=3
选项表示我们想要在每一行中创建三个按钮。第二行中你会看到--height
选项以及--width
选项。在每个--field
行中,你会看到:FBTN
选项,它定义了这些字段为可编程按钮。围绕按钮文本的<b>
和</b>
标签使按钮文本以粗体显示。每个--field
行的最后一部分是点击按钮时将运行的命令。
第一个按钮被编程为使用dnf -y upgrade
命令执行系统更新。-y
使得命令在运行时不会停止并提示用户是否真的要继续。我必须使用-y
选项,因为如果没有它,操作会在提示时中止。接下来的两个按钮每个安装一组选定的软件包。如果你想查看每个组中包含的软件包,只需运行这两个命令:
donnie@fedora:~$ sudo dnf group info "Authoring and Publishing"
donnie@fedora:~$ sudo dnf group info "Development Tools"
由于我们在这个脚本中使用了管理员命令,因此我们需要用sudo
来运行它。下面是它的样子:
图 16.10:带有可编程按钮的 yad 表单
现在,只需点击一个按钮,看看会发生什么。
添加更多的软件安装选项很简单,只需添加更多的--field
行。如果你想查看在 Fedora 上可用的软件包列表,只需执行:
donnie@fedora:~$ sudo dnf group list
我知道,你可能认为这种任务用一组普通的文本模式脚本来完成会更简单。是的,你说得对。所以,只需把这个例子当作一个模板,供你根据自己的需要使用。
此外,我之前没有提到,你需要手动在每个你想用这个脚本管理的系统上安装yad
。
这就是这个脚本的全部内容,让我们继续进行下一个。
关于 yad 的一些最终思考
yad
是一个非常酷的程序,你可以用它做很多非常酷的事情。事实上,一个人可能会写一本关于yad
的书。我现在不能这样做,但希望我已经给你足够的内容来引起你的兴趣。
yad
的主要问题是,无论出于什么原因,它的创建者从未为其编写过任何真正的文档,除了一个非常简略的 man 页面。幸运的是,很多其他人已经接过了这个挑战,创建了自己的文档和教程,这些资源可以在网络和 YouTube 上找到。(我将在 进一步阅读
部分链接到这些资源。)
接下来,让我们将注意力转向 dialog
和 xdialog
,我们也可以使用它们为我们的脚本创建用户界面。
使用 dialog 和 xdialog 创建用户界面
yad
的另一个小问题是,你只能在安装了桌面环境的机器上使用它。但许多 Linux、Unix 和类 Unix 服务器配置为仅使用全文本模式环境,并且不必使用图形桌面。另一个小问题是,即使在桌面操作系统上,它也并不总是可以安装。然而,如果 yad
不可用,而你仍然需要一个 GUI 解决方案,你可能能够使用 xdialog
,它更具普适性。让我们先看看 dialog
,它可以在文本模式的机器上使用。
对话框基础
dialog
曾经是所有 Linux 系统的默认安装,但现在不再是了。然而,它几乎可以在所有 Linux 发行版中安装。所以,你可以通过你发行版的常规包管理器安装它。在大多数 Unix 和类 Unix 系统中,如 OpenIndiana 和 FreeBSD,它仍然是默认安装的。
基本的 dialog
构建块被称为 小部件。每个小部件都有一组参数,你在使用时需要指定。例如,打开 dialog
的 man 页面,滚动到 --msgbox
段落,你将看到如下内容:
--msgbox text height width
所以,这个小部件需要你指定三个参数。(与 yad
不同,指定 dialog
框的尺寸不是可选项。)让我们看看在 dialog-hello.sh 脚本
中是如何实现的:
#!/bin/bash
dialog --title "Dialog message box" \
--msgbox "\n Howdy folks!" 6 25
正如你在 yad
部分之前看到的,我使用了反斜杠将命令分成两行,这样让内容更易读。在 --msgbox
行中,我们可以看到消息文本、由框中的行数定义的高度,以及由一行中可容纳的字符数定义的宽度。
运行脚本的效果如下所示:
图 16.11:运行 dialog-hello.sh 脚本
要关闭窗口,只需按 Enter 键,这会默认激活 OK 按钮。(在桌面机器上,你也可以选择用鼠标点击 OK 按钮。)一旦窗口关闭,运行 clear
命令即可去除终端中的蓝色背景。
默认情况下,dialog
窗口总是出现在终端的中央,蓝色背景不会自动清除,直到你手动清除它。幸运的是,你可以改变这两种行为。下面是我们稍作修改后的脚本,它将窗口放置在终端的左上角:
#!/bin/bash
dialog --title "Dialog message box" \
--begin 2 2 \
--msgbox "\nHowdy folks!" 6 25
clear
这看起来是这样的:
图 16.12:将对话框放置在左上角
--begin
选项接受两个参数。第一个表示框的垂直位置,第二个表示水平位置。例如,通过将该行改为--begin 15 2
,框将出现在左下角。要将其放置到右上角,可以更改为--begin 2 50
。当你按下OK按钮时,clear
命令会清除蓝色背景。
当然,这个脚本本身并不是很有用,但没关系。稍后我会展示一些更有用的概念。首先,请允许我说几句关于xdialog
的话。
xdialog 基础
如果你需要一个 GUI 类型的用户界面,而你的桌面系统没有yad
,那么xdialog
可能是一个不错的替代方案。(我说“可能”,因为yad
和xdialog
都没有在 OpenIndiana 中提供。)它应该在你的 Linux 或类似 Unix 的发行版的标准软件库中,因此只需使用你常用的软件包管理器安装它。我之所以能够在同一节中讨论dialog
和xdialog
,是因为大多数为dialog
编写的代码也可以在xdialog
上运行。
有一件事需要注意,可能会让你困惑。出于某种奇怪的原因,包的名称是xdialog
,全小写字母。但安装后,你需要通过输入Xdialog
来调用该程序,其中X
是大写字母。(我安装后,花了一段时间才弄明白为什么它不起作用。)
在大多数情况下,将dialog
脚本改为以 GUI 类型的程序运行,只需将所有dialog
实例替换为Xdialog
,就像你在这里看到的那样:
#!/bin/bash
Xdialog --title "Dialog message box" \
--begin 2 50 \
--msgbox "\nHowdy folks!" 6 25
clear
在桌面系统上运行该脚本会得到如下效果:
图 16.13:使用 xdialog 运行脚本
首先需要注意的是,xdialog
会忽略--begin
选项,并将对话框放置在终端的中央。还有一个事实是,xdialog
通常要求你创建更大的框,以便你能看到所有内容。因此,让我们在最终的xdialog-hello.sh
脚本中做如下更改:
#!/bin/bash
Xdialog --title "Dialog message box" \
--begin 2 2 \
--msgbox "\nHowdy folks!" 15 50
clear
正如你在这里看到的,这样看起来好多了:
图 16.14:改进后的 xdialog 脚本
使用xdialog
时,脚本末尾的clear
命令不再必要,但留下它不会造成任何问题。事实上,我们将需要它来进行下一个演示。
自动选择dialog
或xdialog
现在,这里有一个非常酷的东西。只需再添加几行代码,你就可以让脚本自动检测它是运行在桌面系统还是文本模式机器上,以及xdialog
是否已安装。以下是xdialog-hello2.sh
脚本,展示它如何工作:
#!/bin/bash
command -v Xdialog
if [[ $? == 0 ]] && [[ -n $DISPLAY ]]; then
diag=Xdialog
else
diag=dialog
fi
$diag --title "Dialog message box" \
--begin 2 2 \
--msgbox "\nHowdy folks!" 15 50
clear
有几种方法可以检测程序是否已安装。在第十二章,通过 here 文档和 expect 自动化脚本中,我在system_info.sh
脚本中向你展示了这种方法:
if [[ -f /usr/local/bin/pandoc ]] || [[ -f /usr/bin/pandoc ]]; then
pandoc -o sysinfo.pdf sysinfo.html
rm sysinfo.html
fi
我们需要的pandoc
可执行文件在 FreeBSD 的/usr/local/bin/
目录中,而在其他系统上则位于/usr/bin/
目录。因此,我设置了这个if..then
结构来检测可执行文件是否位于这两个位置之一。这样有效,但我现在想展示一个更简单的方法。
在xdialog-hello2.sh
脚本中,command -v Xdialog
命令会检测Xdialog
可执行文件是否存在,而不管它在哪个目录中。如果存在,命令将返回退出码 0。如果不存在,命令将返回退出码 1。要查看这个是如何工作的,请在命令行上运行这个命令。如果未检测到Xdialog
可执行文件,效果如下:
donnie@ubuntu2204:~$ command -v Xdialog
donnie@ubuntu2204:~$ echo $?
1
donnie@ubuntu2204:~$
下面是如果检测到它时的效果:
donnie@fedora:~$ command -v Xdialog
donnie@fedora:~$ echo $?
0
donnie@fedora:~$
在下一行,你可以看到一个if..then
结构,它检查两个条件。首先,它检查command
命令的退出码,然后检查是否安装了图形桌面环境。如果DISPLAY
环境变量的值是非零长度,那么说明已安装桌面环境。你可以通过运行echo $DISPLAY
命令来查看这个是如何工作的。这是桌面机器上的显示效果:
donnie@fedora:~$ echo $DISPLAY
:0
donnie@fedora:~$
在文本模式机器上,你将完全没有输出,就像在这个 Ubuntu Server 虚拟机上看到的那样:
donnie@ubuntu2204:~$ echo $DISPLAY
donnie@ubuntu2204:~$
对于我们的目的,我们可以说,在这个文本模式机器上,DISPLAY
的值是零个字符长。
现在,让我们再看看我们的if..then
语句:
if [[ $? == 0 ]] && [[ -n $DISPLAY ]]; then
diag=Xdialog
else
diag=dialog
fi
这意味着,如果command -v Xdialog
命令的退出码为 0,并且DISPLAY
环境变量的值非零长度,那么diag
变量的值将变为Xdialog
。如果Xdialog
可执行文件缺失或DISPLAY
的值不是非零长度,则diag
的值将变为dialog
。更酷的是,这在所有 Linux、Unix 或类 Unix 系统上都能正常工作。我已经在 FreeBSD、GhostBSD、DragonflyBSD、OpenIndiana 以及 Linux 的桌面和文本模式实现上测试过这个脚本。在所有这些系统上,脚本都能正确检测到它应该检测的内容,并正确选择是运行dialog
还是Xdialog
。
这有一件事让我有点困惑。我无意中发现,你检查某些内容的顺序有时很重要。在这个脚本的if..then
结构中,我最初先检查了DISPLAY
的值,然后检查了command
命令的退出码。这样脚本无法正确运行,因为对DISPLAY
值的检查会将退出码设置为 0。当我将检查顺序反过来时,一切开始正常工作。
你可以尝试在各种系统上运行这个脚本,看看会发生什么。
接下来,让我们在已有的基础上添加另一个小部件。
添加小部件
你可以通过添加更多的小部件来增加更多功能。例如,dialog-hello2.sh
脚本,你可以从 GitHub 仓库中下载。由于格式限制,我不能一次性展示整个文件,所以我会分段展示给你。这里是顶部部分:
#!/bin/bash
command -v Xdialog
if [[ $? == 0 ]] && [[ -n $DISPLAY ]]; then
diag=Xdialog
else
diag=dialog
fi
我们在之前的脚本中已经看到过这个内容,我也已经做过解释。所以,接下来我们继续。这里是下一部分:
$diag --title "Dialog message box" \
--begin 2 2 \
--msgbox "\n Hello world!" 20 50
$diag --begin 4 4 --yesno "Do you believe in magic?" 0 0
第一行 $diag
创建了一个初始消息框,位于左上角,位置由 --begin 2 2
选项指定。第二行 $diag
创建了一个带有两个按钮的框,按钮标记为 是 和 否。--begin 4 4
选项将 yesno
框略微放置在初始消息框的下方和右侧。--yesno
后面是我们希望框显示的文本和高度、宽度参数。(将高度和宽度设置为 0 和 0 会使框自动调整大小。)
接下来,我们有一个 case..esac
结构,将命令分配给两个按钮。记住,当点击时,是按钮返回退出代码 0,否按钮返回退出代码 1。按下 Esc 键时,返回退出代码 255。我们可以利用这些退出代码来触发所需的命令,如你在这里看到的:
case $? in
0)
clear
echo "Cool! I'm glad that you do." ;;
1)
clear
echo "I'm sorry that you live such a dull life." ;;
255)
clear
echo "You pressed the ESC key." ;;
esac
好吧,这个脚本的作用就是当你按下按钮时显示一个消息。但它确实展示了这个概念,所以一切都很好。
你已经看过初始消息框,所以我不会再展示它了。相反,我会展示接下来的 yesno
框:
图 16.15:yesno 框
当我点击 是 按钮时,我会看到相应的消息,如你在这里看到的:
图 16.16:点击 是 按钮后
当然,这个脚本在支持 xdialog
的机器上也能同样运行。
接下来,让我们创建一些真正能为我们做有用工作的东西。
创建 SSH 登录界面
假设你有一组 Linux、Unix 或类 Unix 服务器,需要通过 SSH 远程管理。试图跟踪服务器的 IP 地址是件非常混乱的事情,因此你决定简化操作。你决定创建 xdialog-menu-test.sh
来帮助你。这个脚本也太长,不能在这里一次性展示,所以我会将它分段展示。这里是顶部部分:
#!/bin/bash
command -v Xdialog
if [[ $? == 0 ]] && [[ -n $DISPLAY ]]; then
diag=Xdialog
else
diag=dialog
fi
这与之前的脚本相同,所以你已经了解了它。接下来是下一部分:
cmd=($diag --keep-tite --menu "Select options:" 22 76 16)
options=(1 "Remote SSH to Debian miner"
2 "Remote SSH to Fedora miner"
3 "Remote SSH to Fedora Workstation"
4 "Remote SFTP to Fedora Workstation")
choices=$("${cmd[@]}" "${options[@]}" 2>&1 >/dev/tty)
cmd=
行在一对括号内创建一个命令,然后将该命令分配给 cmd
变量数组。这个命令将在脚本的最后一行被调用,构建一个 dialog
或 xdialog
菜单窗口。根据 dialog
手册页,当在桌面机器的图形终端模拟器中运行脚本时,--keep-tite
选项是推荐使用的,因为它可以防止脚本在图形终端和底层 shell 终端之间来回切换。在 --menu
选项之后,我们会看到要显示的文本、菜单框的高度和宽度,然后是一次显示的最大菜单项数。(在这个例子中,用户需要向下滚动才能看到前 16 个菜单项之外的内容。)菜单项将通过标签和项字符串来指定。例如,我们看到的第一个菜单项标签为 1,项为 Remote SSH to Debian miner。
接下来是 options
语句,它将所有菜单项插入到一个数组中。choices=
行使用 2>&1>
重定向操作符将数组的内容输出到终端屏幕 (/dev/tty
) 上。${options[@]}
中的 @
是一种变量扩展方式,因为它允许根据用户选择的菜单项执行相应的操作。@
代表数组项的索引号,该索引对应用户所选的菜单项。
在 第八章,基本 Shell 脚本构建 中,我向你展示了如何用 *
或 @
替代特定的索引号,以显示数组中的所有元素。在 cmd=
行中,$diag --keep-tite --menu "Select options:" 22 76 16
命令中的每个组件都是 cmd
数组的一个独立元素。因此,我们需要在 choices=
行的底部使用 *
或 @
作为 cmd
数组的索引,以便整个命令被调用。对于分配给 options
数组的选项列表也同样如此,稍后你将看到。
现在我们有了一个菜单,接下来需要让它执行一些操作。这将在下一个部分中完成,如下所示:
for choice in $choices
do
case $choice in
1)
ssh donnie@192.168.0.3
;;
2)
ssh donnie@192.168.0.12
;;
3)
ssh donnie@192.168.0.16
;;
4)
sftp donnie@192.168.0.16
;;
esac
done
这不是你没见过的东西。这只是一个正常的 for
循环,执行一个 case..esac
结构。有一点不同的是,choices
变量的值是与所选菜单项对应的数组项的索引号。options
数组中列出的每个选项都由一个数字和一个完整的短语组成,如 1 "Remote SSH to Debian miner"
。在这个例子中,数字 1 代表 options
数组的第 0 个元素,短语中的其余部分代表第 1 至第 5 个元素。为了使选项正确显示在菜单中,你需要用 *
或 @
替代索引号来调用数组。下面是这一操作发生的完整行:
choices=$("${cmd[@]}" "${options[@]}" 2>&1 >/dev/tty)
由于只有一个索引值被赋给 choices
,所以 for
循环在第一次运行后就退出了。
现在一切都构建好了,让我们来看看它是否能正常工作。以下是使用 xdialog
在 GhostBSD 上,Mate 桌面环境下的效果:
图 16.17:在 GhostBSD 上使用 xdialog 的 xdialog-menu-test.sh 脚本
这是同样的脚本,在 Fedora 上使用 dialog
的效果:
图 16.18:在 Fedora 上使用 dialog 的 xdialog-menu-test.sh 脚本
当你选择一个菜单项时,脚本会在终端中打开一个远程登录提示,然后退出。这没问题,因为如果你希望同时连接多个远程服务器,你无论如何都需要打开其他终端。
当然,你可以将这个脚本装饰得更漂亮,使它更加实用。例如,你可以配置 SSH 客户端,使用不同的配置文件或加密密钥来连接不同的服务器会话,并相应地修改菜单中的命令。或者,你可以将其作为其他用途的模板。正如我一直说的,让你的想象力飞翔吧!
好吧,我觉得关于 yad
、dialog
和 xdialog
的部分差不多了。让我们总结一下,继续前进吧。
总结
正如往常一样,我们在这一章中涵盖了很多内容,看到了一些很酷的东西。我们从讨论 yad
开始,了解了如何使用它为 shell 脚本创建图形用户界面。然后,我们看了如何使用 dialog
为文本模式环境创建用户界面,如何使用 xdialog
为图形环境创建用户界面。作为额外的奖励,你还看到了如何创建脚本,使其在支持 xdialog
的机器上运行,在不支持的机器上运行 dialog
。
这些其实并不是你可以用来为脚本创建用户界面的唯一三种工具。其他选择还包括 whiptail
、cdialog
和 zenity
。好消息是,一旦你学会了其中一种,切换到另一种是相对容易的。
现在,你可以用这些工具做比我在这里展示的更多的事情。另一方面,这些工具都不适合创建非常复杂的东西。要做到这一点,你需要学习一种更复杂的编程语言。无论如何,如果你所需的仅仅是一个简单的界面,以便让你自己或你的用户操作更轻松,这些工具完全能够胜任。
在下一章中,我们将探讨如何使用选项开关运行 shell 脚本。我在那儿等你。
问题
-
你想为一个将在各种 Linux、Unix 和类 Unix 操作系统上运行的 shell 脚本创建图形用户界面。以下哪个工具可以提供最佳的可用性?
-
zenity
-
cdialog
-
xdialog
-
yad
-
-
以下关于这个代码片段的哪个说法是正确的?
command -v Xdialog
if [[ -n $DISPLAY ]] && [[ $? == 0 ]]; then
diag=Xdialog
else
diag=dialog
fi
-
这是很好的代码,能够正常工作。
-
它无法工作,因为它使用了错误的方法来检测
Xdialog
可执行文件是否存在。 -
它无法工作,因为无法检测某个可执行文件是否存在。
-
它无法正常工作,因为首先进行
[[ -n $DISPLAY ]]
测试确保$?
总是返回 0 的值。
进一步阅读
-
用 YAD 装饰 Bash 脚本:
www.linux-magazine.com/Online/Blogs/Productivity-Sauce/Dress-Up-Bash-Scripts-with-YAD
-
YAD 指南:
yad-guide.ingk.se/
-
如何在 Linux 上的 Shell 脚本中使用 ncurses 小部件:
linuxconfig.org/how-to-use-ncurses-widgets-in-shell-scripts-on-linux
-
使用 dialog/Xdialog 设计简单的前端:
linuxgazette.net/101/sunil.html
-
向 Shell 脚本中添加对话框:
www.linux-magazine.com/Issues/2019/228/Let-s-Dialog
-
菜单驱动的 Shell 脚本 - 使用对话框工具:
web.archive.org/web/20120318060251/http://www.bashguru.com/2011/01/menu-driven-shell-script-using-dialog.html
-
Xdialog 文档:
web.mit.edu/outland/share/doc/Xdialog-2.1.2/
答案
-
c
-
d
加入我们的 Discord 社区!
与其他用户、Linux 专家以及作者一起阅读本书。
提出问题,提供解决方案,和作者通过“问我任何问题”环节交流,还有更多内容。扫描二维码或访问链接加入社区。
第十七章:使用 Shell 脚本选项与getopts
通常,管理员需要同时向 shell 脚本传递参数和选项。通过向脚本传递位置参数,这一点在前几章中已经讲解过。 但是,如果你需要使用正常的 Linux/Unix 风格的选项开关,并且需要为某些选项使用参数,那么你就需要一个辅助程序。在本章中,我将向你展示如何使用getopts
将选项、参数以及带参数的选项传递给脚本。
本章的主题包括:
-
理解使用
getopts
的必要性 -
理解
getopt
与getopts
的区别 -
使用
getopts
-
查看实际的例子
所以,如果你准备好了,我们就开始吧。
技术要求
你可以使用你的 Fedora 或 Debian 虚拟机来完成本章内容。而且,像往常一样,你可以通过运行以下命令来获取脚本:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
理解getopts
的必要性
在我们做任何事情之前,先复习一下选项和参数之间的区别。
-
选项修改程序或脚本的行为。
-
参数是程序或脚本将作用于的对象。
最简单的方式是使用简单的ls
命令。如果你想查看某个特定文件是否存在,只需像这样使用ls
和文件名作为参数:
donnie@fedora:~$ ls coingecko.sh
coingecko.sh
donnie@fedora:~$
如果你想查看文件的详细信息,你需要添加一个选项,像这样:
donnie@fedora:~$ ls -l coingecko.sh
-rwxr--r--. 1 donnie donnie 842 Jan 12 13:11 coingecko.sh
donnie@fedora:~$
你可以看到-l
选项如何修改ls
命令的行为。
你已经见过如何使用普通的位置参数将选项和参数传递给脚本。很多时候,这种方式足够好了,你不需要其他更多的东西。然而,如果没有某种辅助工具,创建能够正确解析由单个短横线组合的单字母选项,或具有带参数选项的脚本将变得非常笨重。这就是getopts
的作用。你可以在脚本中使用它来简化这一过程。不过,在我们讨论这个之前,我想快速介绍一些可能会让你感到困惑的内容。
理解getopt
与getopts
的区别
getopt
工具已经存在很久了,从 Unix 的早期就有了。它的一个大优点是可以处理长选项。换句话说,除了可以向它传递单字母选项,例如-a
或-b
,你还可以向它传递完整的单词选项,例如--alpha
或--beta
。就这些,这就是它的唯一优点。
它也有一些缺点。getopt
的原始实现无法处理包含空格的参数文本。例如,如果你需要处理一个文件名中包含空格的文件,你无法将该文件名作为参数传递给使用原始getopt
的 shell 脚本。另外,getopt
的语法比getopts
稍微复杂,这使得getopts
的使用相对更简单。
在某个时间点,我不确定是何时,一些随机的 Linux 开发者决定创建一个新的getopt
实现,以修复其中一些缺陷。(这被称为util-linux实现。)这听起来很棒,但它仍然只有在 Linux 上。所有的 Unix 和类 Unix 操作系统仍然拥有原始的、未增强的getopt
。因此,如果您在 Linux 机器上创建一个使用增强getopt
的脚本,您将无法在任何 Unix 或类 Unix 操作系统上运行它,比如 FreeBSD 或 OpenIndiana。
较新的getopts
命令是一个内置的 Shell 命令,没有自己的可执行文件,可以处理包含空格的参数。但是,它只能处理单字母选项,不能处理长选项。最大的优势是它在 Linux、Unix 和类 Unix 操作系统上具有可移植性,无论使用哪种 Shell。因此,如果可移植性是您的目标之一,最好拒绝getopt
,选择getopts
。
因此,我在本章节只讨论getopts
,不再提及老旧的getopt
。
说到这里,让我们深入探讨问题的核心。
使用 getopts
我们将从getopts-demo1.sh
脚本开始,这是世界上最简单的getopts
脚本。它的样子如下:
#!/bin/bash
while getopts ab options; do
case $options in
a) echo "You chose Option A";;
b) echo "You chose Option B";;
esac
done
我们正在使用while
循环将所选选项或选项传递给case
语句。在getopts
命令后面的ab
意味着我们正在定义a
和b
作为可选项。 (这个可选项列表称为optstring。)您可以将任何字母数字字符用作选项。也可以使用某些其他字符,但不建议这样做。由于稍后将变得清晰,绝对不能将?
或:
用作选项。在ab
之后,您会看到options
,这只是我们将在case
结构中使用的变量名称。 (您可以随意命名变量。)无论如何,运行脚本看起来像这样:
donnie@fedora:~$ ./getopts-demo1.sh -a
You chose Option A
donnie@fedora:~$ ./getopts-demo1.sh -b
You chose Option B
donnie@fedora:~$
好吧,大不了,你可能会说。我们可以用普通的位置参数做这种事情。啊,但这是普通位置参数做不到的一件事:
donnie@fedora:~$ ./getopts-demo1.sh -ab
You chose Option A
You chose Option B
donnie@fedora:~$
因此,您看到getopts
如何允许您将选项与单破折号组合,就像您习惯于在普通 Linux 和 Unix 实用程序中做的那样。另一件普通位置参数做不到的事情,至少不容易做到的事情,是有需要自己参数的选项。这里是展示如何做到这一点的getopts-demo2.sh
脚本:
#!/bin/bash
while getopts a:b: options; do
case $options in
a) var1=$OPTARG;;
b) var2=$OPTARG;;
esac
done
echo "Option A is $var1 and Option B is $var2"
当你在 optstring 中为允许的选项后加上冒号时,运行脚本时需要为该选项提供一个参数。每个选项的参数存储在 OPTARG
中,这是一个内建变量,和 getopts
一起使用。(请注意,该变量名只能由大写字母组成。)while
循环会针对每个使用的选项执行一次,这意味着同一个 OPTARG
变量可以用于多个选项参数。为了好玩,我们试着将我的名字作为 -a
选项的参数,将我的姓氏作为 -b
选项的参数。以下是结果:
donnie@fedora:~$ ./getopts-demo2.sh -a Donnie -b Tevault
Option A is Donnie and Option B is Tevault
donnie@fedora:~$
目前,如果你在运行脚本时使用了无效的选项,shell 会返回自己的错误信息,类似这样:
donnie@fedora:~$ ./getopts-demo2.sh -x
./getopts-demo2.sh: illegal option -- x
Option A is and Option B is
donnie@fedora:~$
在这种情况下,./getopts-demo2.sh: illegal option -- x
这一行是由 bash
生成的错误信息。你可以通过在 optstring 开头再加一个冒号来抑制该错误信息。可选地,你可以在 case
结构中添加一个 \?
选项,用来自定义显示无效选择的错误信息,并使用 :
选项来提醒你如果尝试在没有提供所需选项参数的情况下运行脚本。另一个问题是,如果我没有提供所需的选项参数,最终的 echo
命令仍然会执行,而它实际上不应该执行。无论如何,以下是所有修复后的 getopts-demo3.sh
脚本:
#!/bin/bash
while getopts :a:b: options; do
case $options in
a) var1=$OPTARG;;
b) var2=$OPTARG;;
\?) echo "I don't know the $OPTARG option";;
:) echo "Both options require an argument.";;
esac
done
[[ -z $var1 ]] || echo "Option A is $var1"
[[ -z $var2 ]] || echo "Option B is $var2"
当我使用所有必要的参数运行脚本时,结果是这样的:
donnie@fedora:~$ ./getopts-demo3.sh -a Donnie -b Tevault
Option A is Donnie
Option B is Tevault
donnie@fedora:~$
如果我没有使用所有选项,或者在使用选项时没有提供参数,情况是这样的:
donnie@fedora:~$ ./getopts-demo3.sh -a Donnie
Option A is Donnie
donnie@fedora:~$ ./getopts-demo3.sh -a
Both options require an argument.
donnie@fedora:~$ ./getopts-demo3.sh -a Donnie -b
Both options require an argument.
Option A is Donnie
donnie@fedora:~$
到目前为止,我们做的工作对需要参数的选项来说非常完美。在 getopts-demo4.sh
脚本中,我将介绍两个新概念。你将看到如何为某些选项要求参数而不为其他选项要求参数,以及如何使用非选项参数。以下是示例:
#!/bin/bash
while getopts :a:b:c options; do
case $options in
a) var1=$OPTARG;;
b) var2=$OPTARG;;
c) echo "This option doesn't require an argument.";;
\?) echo "I don't know the $OPTARG option";;
:) echo "Both options require an argument.";;
esac
done
[[ -z $var1 ]] || echo "Option A is $var1"
[[ -z $var2 ]] || echo "Option B is $var2"
shift $((OPTIND-1))
[[ -z $1 ]] || ls -l "$1"
:a:b:c
的 optstring,在 c
后没有冒号,表示 -a
和 -b
选项需要参数,但 -c
选项不需要。如果提供了作为非选项参数的文件名,底部的最后一行将对这些文件执行 ls -l
命令。(请注意,[[ -z $@ ]]
结构如果没有非选项参数时将返回退出码 0。如果有非选项参数,则返回退出码 1,这将触发 ls -l
命令。)不过,诀窍在于:
当你运行这个脚本时,首先需要指定选项和选项参数。然后,在命令的最后指定将作为非选项参数的文件名。或者,如果你愿意,也可以只指定一个非选项参数,而不使用任何选项。shift $((OPTIND-1))
这一行是必须的,以便脚本能识别非选项参数。
OPTIND
是另一个内置的变量,用来存储已处理的getopts
选项的数量。理解shift $((OPTIND-1))
命令的最简单方式是把它看作一个清理机制。换句话说,它会清除已经使用的所有getopts
选项,为非选项参数腾出空间。另一种理解方式是它会将位置参数计数重置回$1
。这样,你就可以像平常一样访问非选项参数,如最后一行所示。此外,注意最后一行中的ls -l "$1"
命令。这是一个必须用双引号括住位置参数(在此情况下是$1
)的场景。否则,如果你试图访问文件名中有空格的文件时,会收到错误消息。最后,注意你可以列出多个位置参数,这样你就可以有多个参数,就像你以前做的那样。或者,你也可以用"$@"
替代"$1"
,这样你就可以使用无限数量的非选项参数。
所以,在没有选项,只有一个非选项参数的情况下运行这个脚本,效果如下:
donnie@fedora:~$ ./getopts-demo4.sh "my new file.txt"
-rw-r--r--. 1 donnie donnie 0 Feb 25 13:00 'my new file.txt'
donnie@fedora:~$
如果我去掉ls -l
命令中位置参数周围的双引号,当我尝试访问文件名中包含空格的文件时,就会收到错误。下面是这样做的效果:
donnie@fedora:~$ ./getopts-demo4.sh "my new file.txt"
ls: cannot access 'my': No such file or directory
ls: cannot access 'new': No such file or directory
ls: cannot access 'file.txt': No such file or directory
donnie@fedora:~$
所以,记得使用双引号。
当我告诉你使用getopt
的缺点时,我提到过getopt
的语法比getopts
更复杂。如果我使用getopt
编写这些脚本,我需要在case
结构中的每个项后面添加一个或多个shift
命令,以便脚本能够正确识别选项。而使用getopts
时,这个移动操作是自动完成的。我唯一需要使用shift
的地方就是在最后的shift $((OPTIND-1))
命令中。所以你可以看到,getopts
要简单得多。
此外,如果你想了解更多关于如何在getopts
中使用OPTIND
的内容,我已经在进一步阅读部分中放置了一些参考资料。
事实上,这就是getopts
的基本用法。所以,让我们继续往下看。
查看真实世界的例子
现在我已经展示了如何使用getopts
的理论,是时候通过一些真实世界的例子开始实际操作了。享受吧!
修改版 Coingecko 脚本
在第十章,理解函数中,我展示了一对我为自己编写的很酷的脚本。回顾一下,这些脚本使用 Coingecko API 来自动获取有关加密货币的信息。
coingecko.sh
脚本使用了一个if. .elif. .else
结构,允许我选择要执行的功能。在coingecko-case.sh
脚本中,我使用了case. .esac
结构来实现相同的功能。现在,我展示了这个脚本的第三个版本,它使用了getopts
。
coingecko-getopts.sh
脚本太大,无法在此处完整展示,所以你需要从 GitHub 获取它。我确实需要指出其中的一些要点,所以在这里我只展示相关的代码片段。
在脚本的开头,我添加了gecko_usage()
函数,它看起来是这样的:
gecko_usage() {
echo "Usage:"
echo "To create a list of coins, do:"
echo "./coingecko.sh -l"
echo
echo "To create a list of reference currencies, do:"
echo "./coingecko.sh -c"
echo
echo "To get current coin prices, do:"
echo "./coingecko.sh coin_name(s) currency"
}
接下来,我删除了gecko_api()
函数内的coin=$1
和currency=$2
变量定义,因为我们不再需要它们。
然后,我在现有的case
结构中添加了一个错误检查选项,修改了Usage选项以调用gecko_usage()
函数,然后用一个while
循环将其包裹起来,像这样:
while getopts :hlc options; do
case $options in
h)
gecko_usage
l)
curl -X 'GET' \
"https://api.coingecko.com/api/v3/coins/list?include_platform=true" \
-H 'accept: application/json' | jq | tee coinlist.txt
;;
. . .
. . .
\?) echo "I don't know this $OPTARG option"
;;
esac
done
记得安装jq
包,就像我在第十章中展示的那样。
起初,我也有一个p:
选项来获取当前的币种价格。但它没有工作,因为这个命令需要两个参数,即以逗号分隔的币种列表和参考货币。然而,getopts
只允许每个选项使用一个参数。为了解决这个问题,我将币种价格命令移出了gecko_api()
函数,并将其放在文件的末尾,如下所示:
gecko_api $1
shift $((OPTIND-1))
[[ -n $1 ]] && [[ -n $2 ]] && curl -X 'GET' \
"https://api.coingecko.com/api/v3/simple/price?ids=$1&vs_currencies=$2&include_market_cap=true&include_24hr_vol=true&include_24hr_change=true&include_last_updated_at=true&precision=true" \
-H 'accept: application/json' | jq
调用gecko_api()
函数现在只需要一个位置参数,该参数将是所选的-h
、-l
或-c
选项,或者是组合的-hlc
选项集。如果你想分别使用每个选项,而不是将它们合并,你需要将gecko_api $1
改为gecko_api $1 $2 $3
或gecko_api $@
。
所以,改编原始的 Coingecko 脚本以使用getopts
其实非常简单。但,这样做的优势是什么呢?嗯,在这个情况下,实际上只有一个优势。也就是说,通过使用getopts
选项切换,你现在可以通过仅运行一次脚本来执行多个任务。这是原始脚本做不到的。无论如何,你可以将这个作为模板,适用于你自己在使用getopts
时可能更具优势的脚本。
现在,让我们来看一个很酷的系统监控脚本。
Tecmint 监控脚本
我从 Tecmint 网站借用了这个实用的仅限 Linux 的tecmint_monitor.sh
脚本,并稍微调整了一下,使其在较新的 Linux 系统上运行得更好。作者将其发布在 Apache 2.0 自由软件许可下,所以我能够将修改版上传到 GitHub,方便你使用。(我还在进一步阅读部分提供了原始 Tecmint 文章的链接。)
我不会在这里解释整个脚本,因为作者已经通过在各处插入注释做得非常出色。但是,我会指出其中的一些要点。
首先需要注意的是,作者在使用case
结构时做了一些不同的处理。与将他想要执行的所有代码放入case
选项中不同,他只是使用case
选项来设置iopt
或vopt
变量,具体取决于选择了-i
还是-v
选项。下面是它的实现方式:
while getopts iv name
do
case $name in
i)iopt=1;;
v)vopt=1;;
*)echo "Invalid arg";;
esac
done
可执行代码被放置在一对if...then
结构中。下面是第一个结构:
if [[ ! -z $iopt ]]
then
{
wd=$(pwd)
basename "$(test -L "$0" && readlink "$0" || echo "$0")" > /tmp/scriptname
scriptname=$(echo -e -n $wd/ && cat /tmp/scriptname)
su -c "cp $scriptname /usr/bin/monitor" root && echo "Congratulations! Script Installed, now run monitor Command" || echo "Installation failed"
}
fi
所以,如果iopt
变量的值不是零长度,意味着选择了-i
选项,那么此脚本安装例程将会执行。它并不是一个非常复杂的命令。它所做的只是提示你输入根用户的密码,将techmint-monitor.sh
脚本复制到/usr/bin/
目录,并将其重命名为monitor
。
现在,这里有一个我还没有解决的小问题。因为自从作者在 2016 年创建这个脚本以来,Linux 用户不为根用户账户设置密码已经变得越来越常见。事实上,这是 Ubuntu 发行版的默认行为,很多其他发行版也是可选的。(事实上,我在这台 Fedora 工作站上从未为根用户设置过密码。)因此,su -c
命令在这里不起作用,因为它会在提示时要求你输入根用户的密码。所以,如果你没有为根账户设置密码,在没有使用sudo
的情况下运行脚本时,你将总是遇到身份验证失败的问题。但是,当我使用sudo
运行脚本时,它工作得很好,因此我没有急于更改这一点。
接下来,我们有针对vopt
变量的if...then
结构,它看起来是这样的:
if [[ ! -z $vopt ]]
then
{
#echo -e "tecmint_monitor version 0.1\nDesigned by Tecmint.com\nReleased Under Apache 2.0 License"
echo -e "tecmint_monitor version 0.1\nDesigned by Tecmint.com\nTweaked by Donnie for more modern systems\nReleased Under Apache 2.0 License"
}
fi
这个选项的作用仅仅是显示关于脚本的版本信息。在底部,我添加了Tweaked by Donnie
这一行,以显示我对原脚本进行了修改。(你可以说我在这里做了一些调整,以表明我做了一些修改。)
接下来,我们看到一个长的if...then
结构,它执行实际的工作。它从if [[ $# -eq 0 ]]
开始,这意味着如果没有选择任何选项,那么if...then
结构中的代码将会执行。
在这个长长的if...then
块中包含了获取各种系统信息的命令。我不得不调整检查 DNS 服务器信息的命令,因为原作者让它去寻找resolv.conf
文件中某一特定行的 DNS 服务器 IP 地址。我修改了它,让它去查找任何以nameserver
开头的行,效果如下:
# Check DNS
#nameservers=$(cat /etc/resolv.conf | sed '1 d' | awk '{print $2}')
nameservers=$(cat /etc/resolv.conf | grep -w ^nameserver | awk '{print $2}')
echo -e '\E[32m'"Name Servers :" $tecreset $nameservers
我还对检查磁盘使用情况的命令进行了调整,以使其更加便携。以下是调整后的效果:
# Check Disk Usages
#df -h| grep 'Filesystem\|/dev/sda*' > /tmp/diskusage
df -Ph| grep 'Filesystem\|/dev/sda*' > /tmp/diskusage
echo -e '\E[32m'"Disk Usages :" $tecreset
cat /tmp/diskusage
我在这里所做的只是为df
命令添加了-P
选项,这样df
的输出将始终符合 POSIX 规范。当然,如果需要,你也可以添加更多的驱动器。
最后,在最底部,我们有shift $(($OPTIND -1))
命令。如果你注释掉这一行,脚本实际上也能正常运行,因为我们不需要处理任何非选项参数。不过,许多人认为,为了确保所有选项信息在脚本退出时被清除,最好在使用getopts
的脚本末尾始终加上这一行。
在所有这些示例中,你看到我使用while
循环来实现getopts
,因为这是我个人的偏好。然而,如果你更喜欢使用for
循环,完全可以。选择权在你。
好的,我想我们已经差不多完成了关于getopts
的讨论。让我们总结一下并继续。
总结
在本章中,我们介绍了getopts
,它为我们提供了一种非常酷且简单的方法,来创建识别 Linux 和 Unix 风格选项开关的脚本,这些开关可能会有自己的参数。我们首先回顾了命令选项和参数的概念,然后澄清了关于getopt
和getopts
的一个可能的混淆点。在介绍了如何使用getopts
之后,我们总结了一些实际使用它的脚本示例。
在下一章中,我们将探讨 Shell 脚本如何帮助安全专业人员。我在那里等你。
问题
-
以下哪个陈述是正确的?
-
你应该始终使用
getopt
,因为它比getopts
更容易使用。 -
getopt
可以处理包含空格的参数,而getopts
则不能。 -
getopt
要求你在每个选项后使用一个或多个 shift 命令,但getopts
则不需要。 -
getopts
可以处理长选项,而getopt
则不能。
-
-
在这行
while getopts :a:b:c options; do
中,第一个冒号有什么作用?-
它抑制了来自 Shell 的错误信息。
-
它使得
-a
选项需要一个参数。 -
它什么也不做。
-
冒号使得选项之间被分开。
-
-
你需要创建一个接受选项、带有参数的选项和非选项参数的脚本。你必须在脚本中插入以下哪个命令,才能使非选项参数生效?
-
shift $(($OPTARG -1))
-
shift $(($OPTIND +1))
-
shift $(($OPTIND -1))
-
shift $(($OPTARG +1))
-
进一步阅读
-
小型 getopts 教程:
web.archive.org/web/20190509023321/https://wiki.bash-hackers.org/howto/getopts_tutorial
-
如何轻松处理脚本中的命令行选项和参数?:
mywiki.wooledge.org/BashFAQ/035
-
如何在 Bash 中使用 OPTARG:
linuxsimply.com/bash-scripting-tutorial/functions/script-argument/bash-optarg/
-
一个用于监控 Linux 网络、磁盘使用、系统运行时间、负载平均值和内存使用情况的 Shell 脚本:
www.tecmint.com/linux-server-health-monitoring-script/
注意:以下链接包含有关OPTIND
的额外信息。
-
如何使用 getopts 解析 Linux Shell 脚本选项:
www.howtogeek.com/778410/how-to-use-getopts-to-parse-linux-shell-script-options/
-
在 shell 内建命令 getopts 中,OPTIND 变量如何工作:
stackoverflow.com/questions/14249931/how-does-the-optind-variable-work-in-the-shell-builtin-getopts
-
如何在 Bash 中使用 “getopts”:
linuxsimply.com/bash-scripting-tutorial/functions/script-argument/bash-getopts/
答案
-
c
-
a.
-
c
加入我们在 Discord 上的社区!
与其他用户、Linux 专家以及作者本人一起阅读这本书。
提问、为其他读者提供解决方案、通过“问我任何问题”环节与作者互动等等。扫描二维码或访问链接加入社区。
第十八章:安全专业人士的 Shell 脚本编写
在本章中,我们将做一些不同的事情。与其向你展示新的脚本概念,不如教你如何利用你已经学到的概念来执行一些安全专业可能需要做的工作。
当然,你也可以用更复杂的程序如nmap
来完成许多这些任务。但是,有时这些工具可能对你不可用。在本章中,我将向你展示一些简单的脚本,可以完成一些这样的工作。
本章的主题包括:
-
简单的审计脚本
-
创建简单的防火墙脚本
-
搜索现有的与安全相关的脚本
-
好了,我知道你迫不及待地想要开始了。所以,让我们开始吧。
技术要求
对于本章中的 Linux 演示,我将使用一个 Fedora Server 虚拟机。这是因为这些演示将使用红帽类型的独特功能和实用程序,如 Fedora。但是,如果你愿意,你也可以很容易地将它们适配到其他 Linux 发行版,如 Ubuntu 或 Debian。
我还将在 OpenIndiana 和 FreeBSD 上向你展示一些东西。在你的 FreeBSD 虚拟机上,我假设你已经安装了bash
,并设置了一个具有完整sudo
权限的普通用户帐户,就像我在第十二章,使用 here 文档和 expect 自动化脚本中展示的那样。
正如以往一样,你可以通过运行以下命令从 Github 获取这些脚本:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
简单的审计脚本
如果你习惯使用nmap
,你肯定知道它有多棒。你可以用它来进行许多类型的审计和网络安全任务,如扫描端口或识别远程机器上的操作系统。但是,如果你发现自己处于nmap
不可用的情况下,你也可以通过一些简单的 Shell 脚本完成部分nmap
的工作。让我们从简单的事情开始。
识别操作系统
你可以通过 ping 另一台机器并查看响应中的Time-to-Live(TTL)字段来大致了解它运行的操作系统。这是它的工作原理:
-
64
:如果 ping 响应的 TTL 为 64,则目标机器的操作系统是 Linux、某种 BSD 或 macOS。 -
128
:128 的 TTL 表示目标机器正在运行 Windows。 -
255
:这表示目标机器正在运行 Solaris 或 Solaris 克隆,如 OpenIndiana。
这是普通 ping 命令的输出。(请注意,我使用了-c1
选项,这意味着我只发送一个 ping 数据包。)这显示了 TTL 字段:
donnie@fedora:~$ ping -c1 192.168.0.18
PING 192.168.0.18 (192.168.0.18) 56(84) bytes of data.
64 bytes from 192.168.0.18: icmp_seq=1 ttl=128 time=5.09 ms
--- 192.168.0.18 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 5.086/5.086/5.086/0.000 ms
donnie@fedora:~$
在输出的第二行中,你看到ttl=128
,这表明我刚刚 ping 了一台 Windows 机器。现在,这是os-detect.sh
脚本,它可以自动解释 TTL 字段:
#!/bin/bash
ttl=$(ping -c1 $1 | head -2 | tail -1 | cut -d= -f3 | cut -d" " -f1)
echo "The TTL value is $ttl."
if [[ $ttl == 64 ]]; then
echo "A TTL of $ttl indicates either a Linux, BSD, or macOS operating system."
elif [[ $ttl == 128 ]]; then
echo "A TTL of $ttl indicates a Windows operating system."
elif [[ $ttl == 255 ]] ; then
echo "A TTL of $ttl indicates a Solaris/OpenIndiana operating system."
else
echo "There was no recognized TTL value."
fi
在第二行中,我使用$1
位置参数来表示目标机器的 IP 地址。我还将ping
输出通过管道传送到head
,然后是tail
,以便隔离出第二行输出,其中包含 TTL 字段。接着,我将第二行传递给cut
,使用=
作为字段分隔符来隔离出第三个字段,在这个例子中,Windows 的第三个字段是128 time
。最后,我将这个第三个字段的值传递给cut
,以便仅提取 TTL 数字。这个 TTL 数字将作为ttl
变量的值。
最后,我们有if. .elif. .else
结构来识别目标机器的操作系统。现在,看看这个脚本的运行效果:
donnie@fedora:~$ ./os-detect.sh 192.168.0.18
The TTL value is 128.
A TTL of 128 indicates a Windows operating system.
donnie@fedora:~$ ./os-detect.sh 192.168.0.20
The TTL value is 64.
A TTL of 64 indicates either a Linux, BSD, or macOS operating system.
donnie@fedora:~$ ./os-detect.sh 192.168.0.19
The TTL value is 255.
A TTL of 255 indicates a Solaris/OpenIndiana operating system.
donnie@fedora:~$
记录一下,第一台机器运行的是 Windows 10,第二台机器运行的是 FreeBSD,第三台机器运行的是 OpenIndiana。这样很简单,对吧?不过,稍等一下,我确实需要指出几个注意事项。
首先,显而易见的是,这个脚本无法提供目标机器操作系统的详细信息。事实上,它甚至无法区分 Linux、BSD 或 macOS 操作系统。第二个注意事项是,你只能使用这个脚本扫描本地网络中的机器,因为跨网络边界发送 ping 数据包会改变 TTL 值。第三,你可以在 Linux 或 BSD 机器上运行这个脚本,但不能在 Solaris/OpenIndiana 机器上运行。因为 Solaris 和 OpenIndiana 使用不同实现的ping
工具,这些工具除了显示目标机器是否在线外不会显示任何其他信息。最后,如果目标机器有防火墙配置来阻止 ping 数据包,那么这个脚本根本无法工作。
所以,即使这个脚本可能是一个快速分析你本地网络中机器的有用工具,你仍然需要使用nmap
或其他等效工具来获取更详细的信息,扫描另一个网络上的机器,或者扫描那些阻止 ping 数据包的防火墙机器。例如,使用nmap
的-A
选项可以使nmap
相对准确地检测目标机器的操作系统。下面是当我扫描一台 FreeBSD 14 机器时的结果:
donnie@fedora:~$ sudo nmap -A 192.168.0.20
. . .
. . .
Device type: general purpose
Running: FreeBSD 12.X|13.X
OS CPE: cpe:/o:freebsd:freebsd:12 cpe:/o:freebsd:freebsd:13
OS details: FreeBSD 12.0-RELEASE - 13.0-CURRENT
Network Distance: 1 hop
Service Info: OS: FreeBSD; CPE: cpe:/o:freebsd:freebsd
Okay, that's cool. Let's now scan some ports.
. . .
. . .
donnie@fedora:~$
好吧,你看到了我说的nmap
的操作系统检测是有些准确的意思。扫描结果显示我正在扫描的是 FreeBSD 12 或 FreeBSD 13 机器,尽管它实际上是 FreeBSD 14 机器。但是,FreeBSD 14 还很新,因此很可能它的指纹尚未添加到nmap
数据库中。好的一面是,至少它准确地告诉我们这是某种 FreeBSD 机器,而不是仅仅告诉我们它可能是 FreeBSD、Linux 或 macOS。
另一方面,你可能会发现我们的简单脚本在某些情况下效果更好。例如,看看当我在 OpenIndiana 机器上执行nmap -A
扫描时发生了什么:
donnie@fedora:~$ sudo nmap -A 192.168.0.19
. . .
. . .
No exact OS matches for host (If you know what OS is running on it, see https://nmap.org/submit/ ).
. . .
. . .
donnie@fedora:~$
正如你之前看到的,我们的脚本正确地将这台机器识别为 Solaris 或 OpenIndiana 机器。但是,nmap
完全无法识别它。
一个简单的端口扫描脚本
这是一个很酷的小脚本,你可以用它扫描本地或远程机器上的开放网络端口。如果你是网络扫描的新手,网络端口可以处于三种状态中的任何一种。以下是这些状态的相关定义:
-
open
:开放端口是指有一个关联的网络服务正在运行,并且没有被防火墙阻止的端口。例如,你会期待在运行安全外壳(SSH)服务的服务器上找到 22 端口是开放的,在使用加密连接的 Web 服务器上找到 443 端口是开放的。通过观察远程机器上哪些端口开放,你可以知道该机器上正在运行哪些网络服务。 -
closed
:关闭端口是指没有关联服务在运行,并且没有被防火墙阻止的端口。 -
filtered
:过滤端口是指该端口已被防火墙阻止。
这个脚本通过使用 GNU 版bash
内建的网络功能来工作,这些功能存在于 Linux 和较新版本的 macOS 上。它通过使用 Linux 系统上的/dev/tcp
或/dev/udp
设备来工作。奇怪的是,你不会在/dev/
目录下找到这些设备文件,因为它们是硬编码在bash
可执行文件中的。你可以使用strings
工具来验证这一点,正如你在这里看到的:
donnie@fedora:~$ strings /bin/bash | grep tcp
/dev/tcp/*/*
donnie@fedora:~$ strings /bin/bash | grep udp
/dev/udp/*/*
donnie@fedora:~$
如果你在想,strings
允许你查看嵌入到二进制可执行文件中的文本字符串。另外,要注意,这种网络功能仅在 GNU 版的bash
中内建,这意味着你可以在 Linux 或较新版本的 macOS 上运行这些命令,但不能在其他 Unix/类 Unix 发行版(如 FreeBSD 或 OpenIndiana)上运行。
最简单的说明方式是手动查询一个会提供反馈的端口。在这里,我查询了一个远程网络时间服务器的 13 端口:
donnie@fedora:~$ cat < /dev/tcp/time.nist.gov/13
60372 24-03-03 21:04:44 58 0 0 544.5 UTC(NIST) *
donnie@fedora:~$
你还会收到来自 22 端口(SSH 端口)的反馈,正如你在这里看到的:
donnie@fedora:~$ cat < /dev/tcp/192.168.0.20/22
SSH-2.0-OpenSSH_9.3 FreeBSD-20230719
donnie@fedora:~$
这个过程完成起来稍微需要一些时间,因为目标机器上的身份验证计时器需要一段时间才能超时。
在这两个示例中,请注意我如何使用输入重定向符号(<
)从/dev/tcp
设备获取输入。然后,在/dev/tcp/
部分之后,我放入目标机器的 IP 地址,最后是我想要扫描的端口。
大多数端口不会向你提供任何反馈。但你仍然可以通过命令执行的速度判断端口是否开放。例如,如果你查询 DNS 服务器上的 53 端口,你应该看到命令立即完成执行,就像你在这里看到的 Google DNS 服务器一样:
donnie@fedora:~$ cat < /dev/tcp/8.8.8.8/53
donnie@fedora:~$
所以,我知道 53 端口是开放的。但是,如果我查询一个未开放的端口,比如在这个例子中的 54 端口,命令提示符返回错误信息会非常迟,正如你在这里看到的:
donnie@fedora:~$ cat < /dev/tcp/8.8.8.8/54
bash: connect: Connection timed out
bash: /dev/tcp/8.8.8.8/54: Connection timed out
donnie@fedora:~$
现在,让我们利用这些知识,通过创建bash-portscan1.sh
脚本来实现,像这样:
#!/bin/bash
host=$1
startport=$2
stopport=$3
ping=$(ping -c 1 $host | grep bytes | wc -l)
if [ "$ping" -gt 1 ]; then
echo "$host is up";
else
echo "$host is down. Quitting";
exit
fi
for ((counter=$startport; counter<=$stopport; counter++)); do
(echo >/dev/tcp/$host/$counter) > /dev/null 2>&1 && echo "Port $counter open"
done
这个脚本比较长,我会分成几个部分。这里是上半部分:
#!/bin/bash
host=$1
startport=$2
stopport=$3
要运行这个脚本,你需要指定目标机器的主机名或 IP 地址,以及你想要扫描的端口范围。到目前为止,这很简单,对吧?接下来,我们要验证目标机器是否真的在线,并创建一个变量赋值,方便我们在下一步使用。它看起来是这样的:
ping=$(ping -c 1 $host | grep bytes | wc -l)
如果目标机器在线并且可访问,ping
变量的值将大于 1,通常为 2。如果目标机器无法访问,则值将为 1。要查看这如何工作,可以在命令行中运行此命令,去掉wc -l
部分,如下所示:
donnie@fedora:~$ ping -c1 192.168.0.20 | grep bytes
PING 192.168.0.20 (192.168.0.20) 56(84) bytes of data.
64 bytes from 192.168.0.20: icmp_seq=1 ttl=64 time=0.506 ms
donnie@fedora:~$ ping -c1 192.168.0.200 | grep bytes
PING 192.168.0.200 (192.168.0.200) 56(84) bytes of data.
donnie@fedora:~$
我首先对一个在线的机器进行了 ping 操作,并得到了两行输出。然后,我对一个虚拟机器进行了 ping 操作,只收到了一个输出行。wc -l
命令将计算这些行并将适当的值赋给ping
变量。
接下来,我们有一个if...else
块,它会在目标机器不可用时使脚本退出。它看起来是这样的:
if [ "$ping" -gt 1 ]; then
echo "$host is up";
else
echo "$host is down. Quitting";
exit
fi
注意我必须用一对双引号将$ping
括起来。这是因为ping
的值可能包含空格、非字母数字字符,并且可能包含多行内容。如果没有双引号,bash
将无法正确解析ping
的值。
最后,我们有了执行实际端口扫描的for
循环。它看起来是这样的:
for ((counter=$startport; counter<=$stopport; counter++)); do
(echo >/dev/tcp/$host/$counter) > /dev/null 2>&1 && echo "Port $counter open"
done
这从将startport
变量的值赋给counter
变量开始。只要counter
的值小于或等于stopport
的值,循环就会继续。
现在,这是我运行脚本时的效果:
donnie@fedora:~$ ./bash-portscan1.sh 192.168.0.20 20 22
192.168.0.20 is up
Port 22 open
donnie@fedora:~$ ./bash-portscan1.sh 8.8.8.8 53 53
8.8.8.8 is up
Port 53 open
donnie@fedora:~$
在第一次扫描中,我扫描了一个端口范围,从端口 20 开始,到端口 22 结束。然后,我只扫描了 Google DNS 服务器上的端口 53。
所以你看到,这对于本地或远程目标都能正常工作。
到目前为止,我们只扫描了 TCP 端口。但是,你也可以通过简单的修改来扫描 UDP 端口,就像我在bash-portscan2.sh
脚本中所做的那样。在for
循环中,只需将tcp
改为udp
,它看起来就像这样:
for ((counter=$startport; counter<=$stopport; counter++)); do
(echo >/dev/udp/$host/$counter) > /dev/null 2>&1 && echo "Port $counter open"
done
当然,如果你喜欢的话,你可以让事情看起来更加复杂。例如,你可以将 TCP 和 UDP 扫描功能合并到一个脚本中,并添加一个菜单,允许你选择要执行的操作。甚至,你还可以加上yad
、dialog
或xdialog
界面。(所有这些技巧,我在第十六章,使用 yad、dialog 和 xdialog 创建用户界面中已经向你展示过了。)
现在,你可以使用nmap
进行扫描,从而获取目标的更详细信息,类似这样:
donnie@fedora:~$ sudo nmap -sS 192.168.0.20
Starting Nmap 7.93 ( https://nmap.org ) at 2024-03-03 17:12 EST
Nmap scan report for 192.168.0.20
Host is up (0.00044s latency).
Not shown: 999 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
MAC Address: 08:00:27:45:A4:75 (Oracle VirtualBox virtual NIC)
Nmap done: 1 IP address (1 host up) scanned in 6.01 seconds
donnie@fedora:~$
这种-sS
类型的扫描,称为 SYN 包扫描,需要sudo
权限。然而,你也可以进行-sT
类型的扫描,这种扫描不需要sudo
权限。使用我们自制的脚本扫描开放端口几乎是瞬间完成的。但扫描关闭或被过滤的端口,使用nmap
可能会更快(但并不总是如此)。不过,使用我们脚本的一个潜在优势是,某些类型的nmap
扫描可以通过向目标机器的防火墙添加几条规则来阻止。因此,如果你尝试对某台机器运行nmap
扫描却没有结果,你可以尝试改用脚本。不过,使用脚本扫描开放端口的隐蔽性稍差,因为它会在目标机器的系统日志文件中留下明显的痕迹。例如,下面是使用脚本扫描 AlmaLinux 9 机器上的端口 22 时产生的消息:
Mar 6 13:25:20 localhost sshd[1601]: error: kex_exchange_identification: client sent invalid protocol identifier ""
Mar 6 13:25:20 localhost sshd[1601]: error: send_error: write: Broken pipe
Mar 6 13:25:20 localhost sshd[1601]: banner exchange: Connection from 192.168.0.16 port 38680: invalid format
使用脚本扫描其他开放端口时,会生成类似的消息,而扫描关闭端口则不会生成任何消息。如果你使用脚本扫描一个开放的 web 服务器端口,如端口 80 或端口 443,你将在 web 服务器的access_log
文件中看到类似这样的消息:
192.168.0.16 - - [06/Mar/2024:13:38:49 -0500] "\n" 400 226 "-" "-"
但是,nmap
不会生成这些消息,除非目标机器的防火墙配置为记录来自nmap
扫描的包。因此,两种方法各有优缺点。
在 Red Hat 系列的机器上,如 AlmaLinux、Rocky Linux 和 RHEL,这些消息将出现在/var/log/secure
文件中。在其他发行版中,这些消息可能会出现在/var/log/messages
文件或/var/log/syslog
文件中。一些 Linux 发行版(如 Debian)默认不再创建这些文件。对于这些系统,你需要使用sudo journalctl
命令来查看消息,或者从正常的发行版仓库安装rsyslog
包,以便你能拥有正常的文本模式日志文件。
现在,让我们做一些更复杂的操作。
审计 root 用户账户
由于我是一名安全极客,我一直倡导在设置 Linux 和 Unix 系统时禁用 root 用户账户。在许多现代 Linux 系统上,这很容易做到,因为你可以在系统安装程序中正确配置。事实上,Ubuntu 安装程序甚至不会让你启用 root 用户账户,而是会自动将你创建的账户添加到sudo
组中。(你可以在安装操作系统后启用 root 用户账户,尽管这不是推荐的做法。)对于其他 Linux 发行版,如 Debian、Fedora 或 RHEL 家族的成员,在安装过程中启用 root 用户账户是可选的。在大多数类 Unix 系统(如 FreeBSD 和 OpenIndiana)上,安装程序会为 root 用户账户分配一个密码。在 FreeBSD 上,安装完成后,你需要手动安装sudo
,设置一个普通用户账户来使用它,然后禁用 root 用户账户。在 OpenIndiana 上,你在安装操作系统时创建的普通用户将自动配置为具有完全的sudo
权限,且 root 账户也会被启用。
现在,你想要一种简单的方法来审计你的系统,看看它们的 root 用户是否已启用。我们从设置一个在 Linux 或 OpenIndiana 系统上都能很好工作的脚本开始。
为 Linux 和 OpenIndiana 创建 root 账户审计脚本
我们将从rootlock_1.sh
脚本开始,你可以从 Github 仓库下载该脚本。这是另一个太长无法在这里完整展示的脚本。没关系,因为如果我将其分解成几个部分来解释,会更容易理解。
我首先要做的是初始化一些变量,如下所示:
#!/bin/bash
os=$(uname)
quantity=$(cut -f3 -d: /etc/passwd | grep -w 0 | wc -l)
OpenIndiana 和大多数 Linux 系统默认都安装了bash
,并且两者使用相同的影子文件系统。因此,完全相同的脚本适用于这两者。不过,最终我可能会想对其进行修改,使其可以在一些 BSD 类型的操作系统上运行,因为这些系统的设置方式不同。为此,我将使用uname
命令的输出作为os
变量的值,以确保每个操作系统上总是运行正确的代码。我还想知道有多少个用户账户的 UID 值为 0,因此我将创建quantity
变量来追踪这一点。为了获取该值,我需要使用cut -f3 -d:
命令查看每个passwd
文件条目的第三字段,然后将其通过管道传输给grep -w 0
命令,只查找该字段中仅包含 0 的行。最后,我使用wc -l
命令来计算匹配此标准的行数。
请记住,UID 为 0 的值赋予用户账户完全的根用户权限。在大多数操作系统中,你不应该看到多个 UID 为 0 的用户账户。在某些 BSD 类型的系统中,你会看到两个或三个 UID 0 的账户。其中一个是 root 账户,可能将 csh
或 sh
设置为默认 shell。第二个是 toor 账户,默认 shell 为 sh
。DragonflyBSD 有一个第三个 UID 0 账户,名为 installer。因此,如果你想编写一个适用于所有这些不同操作系统的脚本,你需要编写能够处理每个系统拥有不同数量 UID 0 账户的代码。(稍后我们会在本节中详细讨论这些内容。)
接下来,我创建了 linux_sunos
函数,它包含了大部分工作代码。它是这样的:
linux_sunos() {
if [ $quantity -gt 1 ] ; then
echo "CRITICAL. There are $quantity accounts with UID 0."
else
echo "OKAY. There is only one account with UID 0."
fi
echo
echo
rootlock=$(awk 'BEGIN {FS=":"}; /root/ {print $2}' /etc/shadow | cut -c1)
if [ "$rootlock" == $ ] ; then
echo "CRITICAL!! The root account is not locked out."
else
echo "The root account is locked out, as it should be."
fi
}
顶部部分很简单。它只是一个 if...then...else
结构,如果 /etc/passwd
文件中有多个 UID 0 账户,则会提醒你。
接下来,我使用 awk
命令,将其输出通过管道传输到 cut -c1
,以查找 /etc/shadow
文件中根用户账户的行,并隔离该行第二个字段的第一个字符的值。这个值将被分配给 rootlock
变量。那么,这个字符有什么重要性呢?好吧,事情是这样的。
在 Linux、Unix 及类 Unix 系统中,用户账户的列表保存在 /etc/passwd
文件中。该文件必须始终对所有用户可读,以便用户在登录时可以访问他们的账户信息。许多年前,当我年轻时,仍然满头黑发,用户密码也保存在这个 passwd
文件中。最终,有人意识到将密码保存在一个全局可读的文件中是一个安全问题,并发明了 shadow 文件系统。现在,所有用户密码的哈希值都保存在 Linux 和 Solaris/OpenIndiana 系统的 /etc/shadow
文件中,读取该文件需要根用户权限。(在 BSD 类型的系统中有些不同,稍后我会向你展示。)例如,以下是我自己在 Fedora Server 虚拟机上的用户账户条目:
donnie:$y$j9T$PfB847h88/LNURBaDxBbWdYI$bRXbrMUrTM7JwWifuDfjt6oFl0FFdYEzcwJHF5r/kG5::0:99999:7:::
(请注意,这是一行很长的内容,在打印页面上会换行。同时注意,我改变了几个字符,以防泄露真实的哈希值。)
我希望你注意到这个哈希值的前缀,即 $y$
。前导的 $
表示该账户已启用,而整个 $y$
表示密码是通过 yescrypt 哈希算法加密的。
由于 Fedora 是一个前沿的、稍微实验性的 Linux 发行版,你可以期待它使用一些还未广泛应用于其他 Linux 生态系统中的新技术。这里就是 Fedora 使用 yescrypt 的例子。在大多数现代 Linux 发行版中,你会在密码哈希的前面看到$6$
,这表示它们使用的是 SHA512 哈希算法。虽然 SHA512 哈希很难破解,但 yescrypt 哈希据说更难破解,从而增强了密码安全性。
对于我们当前的目的,使用什么哈希算法并不重要。我们关心的只是前导的 $
,因为它告诉我们该账户已启用。如果该位置的字符不是 $
,则账户已禁用。为了获得最佳安全性,你希望在 /etc/shadow
文件中看到类似以下三行的内容:
root:!::0:99999:7:::
root:*::0:99999:7:::
root:*LK*::0:99999:7:::
现在,以下是你不想看到的情况:
root:$y$j9T$TZqIDctm8w7ESbopARa5f1$RKjMWhZ9zS4KZ5dPvSODo2nAIH4s8GwZTA4TJNnoh3B:19 844:0:99999:7:::
在这台虚拟机上,根账户已启用,如前导符号 $
所示。这将我们带回到脚本中的 linux_sunos
函数。让我们再看看创建 rootlock
变量的那一行:
rootlock=$(awk 'BEGIN {FS=":"}; /root/ {print $2}' /etc/shadow | cut -c1)
如我之前所指出的,这个 awk
命令提取了根用户条目的第二个字段,即密码字段。将 awk
输出通过管道传输到 cut -c1
命令,会提取该字段的第一个字符。该字符的值随后被赋值给 rootlock
变量。
接下来是决定根用户账户是否被锁定的代码:
if [ "$rootlock" == '$' ] ; then
echo "CRITICAL!! The root account is not locked out."
else
echo "The root account is locked out, as it should be."
fi
这表示,如果根用户条目的第二个字段的第一个字符是$
,那么该账户未被锁定。如果第一个字符是其他任何字符,则该账户被锁定,一切正常。
请注意,在这个 if...then...else
结构中,我必须用一对双引号将 $rootlock
包围起来。这是因为在某些 Linux 发行版中,如 Ubuntu,你可能会在根用户的 shadow
文件条目的密码字段中看到一个 *
。如果没有双引号,Shell 会将 *
解释为通配符,这会导致 $rootlock
返回当前工作目录中的文件列表。使用双引号可以确保 $
正常工作,同时强制 Shell 以字面意义解释 *
。
为了确保准确,我还用一对单引号将 $
包围,以确保 Shell 能正确解释它。(实际上,在我测试时,即使没有单引号也能正常工作,但为了安全起见,最好加上。)
现在,在脚本的最后部分,在函数之后,你会看到以下内容:
if [ $os == Linux ] || [ $os == SunOS ] ; then
linux_sunos
else
echo "I don't know this operating system."
fi
os
变量的值应该在 Linux 系统上为 Linux
,在 OpenIndiana 系统上为 SunOS
。无论哪种情况,linux_sunos
函数都会运行。如果 os
的值为其他任何值,用户将看到错误消息。
最后,让我们测试一下这个脚本,看看会发生什么。以下是在我用来编写本文的 Fedora 工作站上的显示效果:
donnie@fedora:~$ sudo ./rootlock_1.sh
[sudo] password for donnie:
OKAY. There is only one account with UID 0.
The root account is locked out, as it should be.
donnie@fedora:~$
非常酷,一切看起来不错。
为了查看启用 root 账户后的样子,我启动了我的 Fedora Server 虚拟机,该虚拟机从未启用过 root 账户。我像这样启用了 root 账户:
donnie@fedora-server:~$ sudo passwd root
Changing password for user root.
New password:
Retype new password:
passwd: all authentication tokens updated successfully.
donnie@fedora-server:~$
很简单,对吧?我只需要为 root 用户账户分配一个密码。现在,让我们运行脚本:
donnie@fedora-server:~$ sudo ./rootlock_1.sh
OKAY. There is only one account with UID 0.
CRITICAL!! The root account is not locked out.
donnie@fedora-server:~$
现在,让我们在 rootlock_2.sh
脚本中添加禁用 root 账户的选项。我们将通过在已经存在的 if...then...else
结构中嵌入另一个结构来做到这一点。它的样子是这样的:
if [ $rootlock == $ ] ; then
echo "CRITICAL!! The root account is not locked out."
echo "Do you want to disable the root account? (y/n)"
read answer
if [ $answer == y ] ; then
passwd -l root
else
exit
fi
else
echo "The root account is locked out, as it should be."
fi
运行修改后的脚本是这样的:
donnie@fedora-server:~$ sudo ./rootlock_2.sh
OKAY. There is only one account with UID 0.
CRITICAL!! The root account is not locked out.
Do you want to disable the root account? (y/n)
y
Locking password for user root.
passwd: Success
donnie@fedora-server:~$
要禁用一个账户,passwd -l
命令会在密码哈希前加上一对感叹号,像这样:
root:!!$y$j9T$ckYOQzoMU0mr9gQkjqz/K0$QDNV0unG1XAfBwViY.7a6JR8VaMpIGObGzXIN0vxGQA:19847:0:99999:7:::
密码哈希仍然存在,但操作系统无法再读取它。这将允许你通过运行以下命令解锁账户:
donnie@fedora-server:~$ sudo passwd -u root
[sudo] password for donnie:
Unlocking password for user root.
passwd: Success
donnie@fedora-server:~$
要删除密码并禁用账户,将 passwd -d root
命令放在 passwd -l root
命令之前,这样构造将变成这样:
if [ $rootlock == $ ] ; then
echo "CRITICAL!! The root account is not locked out."
echo "Do you want to disable the root account? (y/n)"
read answer
if [ $answer == y ] ; then
passwd -d root
passwd -l root
else
exit
fi
else
echo "The root account is locked out, as it should be."
fi
注意,你不能在一个命令中同时使用 -l
和 -d
选项。要使用这两个选项,你需要分别运行两个命令。还需要注意的是,如果你只使用 -d
选项,虽然会删除密码哈希,但账户仍然被视为启用状态。在运行 passwd -d
后,如果再运行 passwd -l
,将既删除密码哈希,又禁用账户。
虽然你可能已经知道这一点,但我还是告诉你。在禁用 root 用户账户之前,一定要确保你是以具有完整 sudo
权限的普通用户身份登录,而不是以 root 用户身份登录。这样,才不会意外地禁用没有其他管理员权限的机器上的 root 账户。
现在重新启用 root 账户的唯一方法就是创建一个新密码,正如你已经看到的那样。
这涵盖了 Linux 和 OpenIndiana。现在让我们看看能否在 FreeBSD 上使其工作。
修改 root 账户审计脚本以在 FreeBSD 上使用
在 BSD 类型操作系统(如 FreeBSD)中,使用的是 /etc/master.passwd
文件,而不是 /etc/shadow
文件。除了 /etc/passwd
文件中的 UID 0 的 root 用户账户外,还有一个 UID 0 的 toor 用户账户。因此,我们需要添加一个 freebsd
功能来处理这些差异。
在 FreeBSD 13 之前,root 用户账户的默认 shell 是 csh
,而 toor 账户的默认 shell 是 sh
。但在 FreeBSD 14 中,这两个 UID 0 的账户默认 shell 都是 sh
。
最简单的做法是添加一个新功能,我将其命名为 freebsd
。(实际上,还能叫什么呢?)你可以在 Github 仓库中的 rootlock_3.sh
脚本中找到这个新功能。让我们将这个新功能分解成几个部分来看它包含了什么。这里是顶部部分:
freebsd() {
if [ $quantity -gt 2 ]
then
echo "CRITICAL. There are $quantity accounts in the passwd file with UID 0."
else
echo "OKAY. There are only two accounts in the passwd file with UID 0."
echo
echo
fi
这与我们刚刚查看的linux_sunos
函数相同,只是现在它检查passwd
文件中超过两个 UID 0 账户。这里是接下来的部分:
rootlock=$(awk 'BEGIN {FS=":"}; $1 ~ /root/ {print $2}' /etc/master.passwd | cut -c1)
if [ "$rootlock" == '$' ] ; then
echo "CRITICAL!! The root account is not locked out."
echo "Do you want to disable the root account? (y/n)"
read answer
if [ $answer == y ] ; then
pw mod user root -w no
fi
else
echo "The root account is locked out, as it should be."
fi
我不得不修改awk
命令,以便它只在一行的第一个字段中找到“root”,就像你在这里看到的那样,使用了$1 ~ /root/
部分:
rootlock=$(awk 'BEGIN {FS=":"}; $1 ~ /root/ {print $2}' /etc/master.passwd | cut -c1)
这是因为,与 Linux 和 OpenIndiana 的shadow
文件不同,FreeBSD 的master.passwd
文件列出了用户的默认主目录。正如你在这里看到的,前三个用户的默认主目录设置为/root/
目录:
root:*:0:0::0:0:Charlie &:/root:/bin/csh
toor:*:0:0::0:0:Bourne-again Superuser:/root:
daemon:*:1:1::0:0:Owner of many system processes:/root:/usr/sbin/nologin
在awk
命令中使用/root/
而不是$1 ~ /root/
导致脚本读取了所有这三行,而不仅仅是用于 root 用户的第一行。这会阻止脚本正确检测 root 用户帐户是否已启用。因为如果脚本在 root 行的字段 2 中看到$
,然后在 toor 和 daemon 行的字段 2 中看到*
,它将把*
作为rootlock
的最终值。因此,即使 root 帐户没有被锁定,脚本也会始终显示 root 帐户被锁定。
我还不得不更改锁定 root 和 toor 帐户的命令,因为 FreeBSD 版本的passwd
没有适当的选项开关来执行此操作。因此,我用以下两个passwd
命令替换了它们:
pw mod user root -w no
这个方便的命令同时删除密码并锁定帐户。
由于 FreeBSD 还有 UID 0 的 toor 帐户,我添加了另一部分来检查它:
toorlock=$(awk 'BEGIN {FS=":"}; /toor/ {print $2}' /etc/master.passwd | cut -c1)
if [ "$toorlock" == '$' ] ; then
echo "CRITICAL!! The toor account is not locked out."
echo "Do you want to disable the toor account? (y/n)"
read answer
if [ $answer == y ] ; then
pw mod user toor -w no
fi
else
echo "The toor account is locked out, as it should be."
fi
}
在master.passwd
文件中只有一行包含单词“toor”,所以我不需要告诉awk
只在第一个字段中查找/toor/
模式。除此之外,它与我刚刚为 root 用户展示的内容相同。(当然,因为这是函数的结尾,我在末尾包含了闭合的大括号。)
最后,我修改了脚本的最后部分,以便它将自动选择要运行的功能:
if [ $os == Linux ] || [ $os == SunOS ] ; then
linux_sunos
elif [ $os == FreeBSD ] ; then
freebsd
else
echo "I don't know this operating system."
fi
你在之前的章节中已经见过这种情况,所以你可能已经知道这里正在发生什么。
再次阅读你的思维。你正在考虑这个脚本有多酷,以及你想在一个混合的 Linux、Unix 和类 Unix 服务器群上使用它。然而问题在于,这个脚本是为bash
编写的,并使用了一些bash
的高级特性,这些特性在许多传统的sh
shell 上不起作用。如果你能在所有的 Unix 和类 Unix 服务器上安装bash
,那就太好了,但这可能不是一个选择。另外,如果你在使用运行轻量级 Linux 的物联网设备,可能也无法在它们上安装bash
。那么,你该怎么办?好吧,稍等,因为我将在第十九章,Shell 脚本可移植性中解释这一切。
我认为这差不多涵盖了rootlock
脚本。在结束本节之前,让我们再看一个审计脚本。
创建一个用户活动监控脚本
在这种情况下,你希望查看其他用户何时登录到系统,以及他们如何使用自己的sudo
权限。为此,让我们创建user_activity_1.sh
脚本。以下是脚本的顶部部分:
#!/bin/bash
if [[ $1 == "" ]] ; then
echo "You must specify a user name."
echo "Usage: sudo ./user_activity_1.sh username "
exit
fi
这告诉你,在调用此脚本时必须提供用户名。如果没有指定用户名,你将看到此消息,并且脚本会退出。接下来是这部分内容:
if [[ -f /var/log/secure ]] ; then
logfile=/var/log/secure
elif [[ -f /var/log/auth.log ]] ; then
logfile=/var/log/auth.log
elif [[ -n $(awk /suse/ /etc/os-release) ]] ; then
logfile=/var/log/messages
else
echo "I don't know this operating system."
exit
fi
大多数 Linux 和一些 Unix/类 Unix 发行版会将用户认证消息存储在/var/log/secure
文件或/var/log/auth.log
文件中。SUSE 和 openSUSE 是这一规则的显著例外,因为它们将这些信息存储在/var/log/messages
文件中。
我假设你正在使用一个安装了rsyslog
或syslog
的发行版。如果你只有journald
,那么这个脚本是无法工作的。
最后,这部分才是实际执行工作的部分。
username=$1
echo "=== User Account Activity ===" > user_activity_for_"$username"_$(date +"%F_%H-%M").txt
# Check user activity in system logs
echo "=== Recent Logins ===" >> user_activity_for_"$username"_$(date +"%F_%H-%M").txt
last | grep $username >> user_activity_for_"$username"_$(date +"%F_%I-%M").txt
# Check sudo command usage
echo "=== Sudo Command Usage ===" >> user_activity_for_"$username"_$(date +"%F_%H-%M").txt
grep sudo "$logfile" | grep $username >> user_activity_for_"$username"_$(date +"%F_%H-%M").txt
好吧,很简单。它只是创建一个包含用户名和时间戳的报告文件,文件名中带有时间戳。last
命令会生成用户登录的记录,grep
命令会在指定的文件中搜索所有包含sudo字符串的行。然后,它将这些输出通过管道传输给grep
,继续搜索包含指定用户名的所有行。
现在,让我们运行脚本,看看 Donnie 这个角色在做什么:
donnie@fedora-server:~$ sudo ./user_activity_1.sh donnie
[sudo] password for donnie:
donnie@fedora-server:~$ ls -l user_activity_*
-rwxr--r--. 1 donnie donnie 921 May 14 18:11 user_activity_1.sh
-rw-r--r--. 1 root root 276170 May 14 18:11 user_activity_for_donnie_2024-05-14_18-11.txt
donnie@fedora-server:~$
非常酷。你看到脚本创建了一个报告文件,文件名中包含了我的用户名和当前的日期与时间。这是你在报告文件中会看到的一部分内容:
=== User Account Activity ===
=== Recent Logins ===
donnie pts/1 192.168.0.16 Tue May 14 15:30 - 16:41 (01:11)
donnie pts/0 192.168.0.16 Tue May 14 15:22 still logged in
donnie tty1 Mon May 13 17:33 - 17:44 (00:11)
donnie pts/0 192.168.0.16 Mon May 13 14:43 - 17:44 (03:01)
=== Sudo Command Usage ===
Dec 8 14:17:32 localhost sudo[993]: donnie : TTY=tty1 ; PWD=/home/donnie ; USER=root ; COMMAND=/usr/bin/dnf install openscap-scanner scap-security-guide
Dec 8 14:17:32 localhost sudo[993]: pam_unix(sudo:session): session opened for user root(uid=0) by donnie(uid=1000)
Jan 30 13:02:43 localhost sudo[955]: donnie : TTY=tty1 ; PWD=/home/donnie ; USER=root ; COMMAND=/usr/bin/dnf -y upgrade
我已经在 Fedora Server、Ubuntu Server、openSUSE、FreeBSD 和 OpenBSD 上测试了这个脚本。(请注意,我在这两个 BSD 发行版上都安装了bash
。)
好的,我想审计脚本差不多就是这样了。让我们看看可以用防火墙脚本做些什么。
创建简单的防火墙脚本
在这种情况下,你需要创建一个文本文件,列出你想要封锁的 IP 地址。然后,你需要创建一个 shell 脚本,读取这些 IP 地址,并创建封锁它们的防火墙规则。你可以通过两种方式来实现这一点。首先是较难的方式,然后是简单的方式。
难的方法是将 IP 地址列表读入一个变量数组,然后创建一个for
循环,为数组中的每个 IP 地址创建一个封锁规则。好吧,这并不是非常困难,但比我们希望的稍微难一点。(在我展示了难的方法后,我会给你展示简单的方法。这样,你会更加感激简单的方法。)
为 Red Hat 发行版创建 IP 地址封锁脚本
Red Hat 类型的发行版,如 Fedora、AlmaLinux、Rocky Linux、Oracle Linux,以及当然还有 Red Hat Enterprise Linux,使用firewalld
作为其防火墙管理工具,并且使用nftables
作为实际的防火墙引擎。根据我所知,唯一默认安装这个设置的非 Red Hat Linux 发行版是 SUSE 和 openSUSE。
firewall-cmd
工具是管理 firewalld
规则、策略和配置的主要方式。为了了解它是如何工作的,让我们做几个动手实验。
动手实验:使用数组和 for 循环创建脚本
在这个实验中,你将创建一个脚本,通过读取 ip-address_blacklist.txt
文件中的 IP 地址列表来构建一个变量数组。然后,你将使用 for
循环为列表中的每个 IP 地址创建一个防火墙规则。
1. 在 Fedora Server 虚拟机上,创建 ip-address_blacklist.txt
文件,每行一个 IP 地址。它应该像这样:
donnie@fedora-server:~$ cat ip-address_blacklist.txt
192.168.0.14
192.168.0.84
192.168.0.7
192.168.0.12
192.168.0.39
donnie@fedora-server:~$
2. 现在,创建 firewall-blacklist_array.sh
脚本,使用一个变量数组和一个 for
循环。它应该像这样:
#!/bin/bash
declare -a badips
badips=( $(cat ip-address_blacklist.txt) )
for ip in ${badips[*]}
do
firewall-cmd --permanent --add-rich-rule="rule family="ipv4" source address="$ip" drop"
done
firewall-cmd --reload
我们在这里做的第一件事是声明并构建 badips
数组,就像我在 第八章 基本 Shell 脚本构建 中展示的那样。这个数组从我们刚刚创建的 ip-address_blacklist.txt
文件中获取其值。在 for
循环中,你会看到 firewall-cmd
命令,它为每个我们加载到 badips
数组中的 IP 地址创建一个防火墙规则。每当你使用 --permanent
选项时,firewall-cmd
命令会将新规则写入相应的配置文件。但它不会将新规则加载到正在运行的防火墙中。最后的 firewall-cmd --reload
命令会加载新规则,使其生效。
3. 在你的 Fedora 虚拟机上运行此脚本。你应该会收到每个 IP 地址的一个成功消息,并在重载命令后收到一个最终的成功消息。它看起来是这样的:
donnie@fedora-server:~$ sudo ./firewall-blacklist_array.sh
success
success
success
success
success
success
donnie@fedora-server:~$
4. 要验证规则是否生效,使用 nft list ruleset
命令,然后向上滚动查看新规则。它看起来是这样的:
donnie@fedora-server:~$ sudo nft list ruleset
. . .
. . .
chain filter_IN_FedoraServer_deny {
ip saddr 192.168.0.14 drop
ip saddr 192.168.0.84 drop
ip saddr 192.168.0.7 drop
ip saddr 192.168.0.12 drop
ip saddr 192.168.0.39 drop
}
. . .
. . .
donnie@fedora-server:~$
5. 要查看规则是否已永久添加,请查看 /etc/firewalld/zones/
目录中的相应配置文件,像这样:
donnie@fedora-server:~$ sudo cat /etc/firewalld/zones/FedoraServer.xml
. . .
. . .
<rule family="ipv4">
<source address="192.168.0.14"/>
<drop/>
</rule>
<rule family="ipv4">
<source address="192.168.0.84"/>
<drop/>
</rule>
. . .
. . .
donnie@fedora-server:~$
请注意,这个配置文件的名称在其他发行版中会有所不同,比如 AlmaLinux、Rocky Linux、Red Hat Enterprise Linux 或 SUSE/openSUSE。
另外,请注意,不同的 Linux 发行版可能配有不同的防火墙管理工具。例如,Ubuntu 配备 简单防火墙 (ufw
) 防火墙管理工具,其他发行版可能仅让你使用简单的 nftables
,而没有管理工具。一旦你知道适用于你特定发行版的防火墙管理命令,修改这个脚本以使其适应就非常简单了。(如果你需要更多了解 Linux 防火墙的内容,可能会想看看我的另一本书,《精通 Linux 安全与加固》,这本书可以在亚马逊以及 Packt 出版社直接购买。)
实验结束。
好了,这还不算太难,对吧?不过,稍等一下。让我们用 xargs
让这个变得更简单。
动手实验:使用 xargs 创建脚本
在第七章,文本流过滤器–第二部分中,我介绍了xargs
工具。我向你展示了在文本流过滤器的上下文中如何使用它的一些例子,并且我还承诺稍后会向你展示更多的使用例子。通过使用xargs
而不是变量数组和for
循环,你可以大大简化这种类型的脚本。像之前一样,我会向你展示如何在 Red Hat 家族系统中进行操作。
1. 创建firewall-blacklist_xargs.sh
脚本,像这样:
#!/bin/bash
xargs -i firewall-cmd --permanent --add-rich-rule="rule family="ipv4" source
address={} drop" < ip-address_blacklist.txt
firewall-cmd --reload
(注意,xargs
这一行是很长的,可能会在打印页面上换行。)
和之前一样,第一个命令是一个普通的firewall-cmd
命令,用于创建阻止规则。在这个例子中,我们在命令前加上了xargs -i
,这样它就会从ip-address_blacklist.txt
文件中逐个读取 IP 地址。在普通的firewall-cmd
命令中,你会在source address=
后面放入一个 IP 地址或 IP 地址范围。但这次我们放置了一对大括号。xargs
工具会使得firewall-cmd
命令针对ip-address_blacklist.txt
文件中找到的每个 IP 地址执行一次。每次执行时,列表中的下一个 IP 地址将被放入大括号内。firewall-cmd
命令中的--permanent
选项会将新的规则保存到正确的规则文件中。使用--permanent
选项时,需要运行firewall-cmd --reload
命令才能使新规则生效。
2. 编辑ip-address_blacklist.txt
文件,添加一些更多的 IP 地址。然后运行脚本。输出应该类似于这样:
donnie@fedora-server:~$ sudo ./firewall-blacklist_xargs.sh
Warning: ALREADY_ENABLED: rule family=ipv4 source address=192.168.0.14 drop
success
Warning: ALREADY_ENABLED: rule family=ipv4 source address=192.168.0.84 drop
success
Warning: ALREADY_ENABLED: rule family=ipv4 source address=192.168.0.7 drop
success
Warning: ALREADY_ENABLED: rule family=ipv4 source address=192.168.0.12 drop
success
Warning: ALREADY_ENABLED: rule family=ipv4 source address=192.168.0.39 drop
success
success
success
success
donnie@fedora-server:~$
3. 验证新规则是否生效:
donnie@fedora-server:~$ sudo nft list ruleset
. . .
. . .
chain filter_IN_FedoraServer_deny {
ip saddr 192.168.0.14 drop
ip saddr 192.168.0.84 drop
ip saddr 192.168.0.7 drop
ip saddr 192.168.0.12 drop
ip saddr 192.168.0.39 drop
ip saddr 212.12.3.12 drop
ip saddr 172.10.0.0/16 drop
}
. . .
. . .
donnie@fedora-server:~$
请注意,在最后一条规则中,我选择阻止整个 IP 地址子网。这个功能如果你需要阻止一个国家访问你的服务器时可能会派上用场。
如果你确实需要阻止一个整个国家的 IP 地址,可以在这里找到各种国家的 IP 地址范围列表:
lite.ip2location.com/ip-address-ranges-by-country
4. 打开/etc/firewalld/zones/FedoraServer.xml
文件,在文本编辑器中删除你刚才创建的规则。
5. 最后,通过以下操作清除正在运行的防火墙中的规则:
donnie@fedora-server:~$ sudo firewall-cmd --reload
success
donnie@fedora-server:~$
实验结束。
现在,这是不是很酷?我的意思是,通过使用xargs
而不是变量数组和for
循环,你大大简化了这个脚本。而且,使用xargs
让你的脚本更加便于移植。因为你几乎可以在任何类型的 Linux、Unix 或类 Unix 的 shell 中使用xargs
。另一方面,虽然你可以在bash
中使用变量数组,但在某些sh
变体中却不能使用。(关于移植性,我会在第十九章,Shell 脚本的移植性中详细讲解。)所以,真的,把xargs
放入我们的工具箱中,算得上是双赢!
接下来,让我们看看是否能节省一些工作。
搜索现有的与安全相关的脚本
计算机编程的一个基本原则是尽可能重用代码。否则,世界上每个程序员都会浪费大量时间试图重新发明那个“轮子”。因此,如果你需要一个脚本而又不知道如何编写,你可以通过使用你最喜欢的搜索引擎或在 Github 上搜索来找到它。
使用搜索引擎的唯一问题是,你可能需要尝试几个不同的搜索词才能找到你需要的东西。例如,我在 DuckDuckGo 上尝试了以下搜索词:
-
用于安全审计的 bash 脚本
-
用于渗透测试员的 bash 脚本
-
用于安全管理员的 bash 脚本
-
用于网络安全的 bash 脚本
需要完全披露的是,大多数这些搜索结果都是针对需要付费的课程和书籍。如果那正是你需要或想要的,那很好。但是,在这些结果中,你也许能找到一些对你当前问题有帮助的“宝藏”,或者为你自己的脚本提供一些不错的想法。
为了获得更简洁和有用的搜索结果,你可以考虑在 Github 上搜索脚本。例如,我搜索了bash 安全脚本:
图 18.1:在 Github 上搜索 bash 安全脚本
你会找到很多来自不同作者的代码库。大多数,甚至是所有这些脚本,都发布在“自由软件许可”下,因此你可以下载并按照自己的需要使用。当然,在将它们投入生产使用之前,你需要先审查和测试这些脚本。它们中的一些在你的系统上可以直接使用,而有些则不行。所以,仅仅因为你能找到别人已经编写的脚本,并不意味着你可以在不具备任何 shell 脚本知识的情况下轻松应对。
好的,我想这大致涵盖了内容。让我们总结一下,然后进入下一章。
总结
在本章中,我没有像通常那样介绍很多新的脚本概念。相反,我向你展示了如何利用你已经掌握的概念来创建可能对注重安全的管理员有用的脚本。你已经看到了如何创建可以执行简单端口扫描或操作系统识别的 shell 脚本。然后,你看到了如何创建审计脚本,这些脚本可以显示 root 用户账户是否启用,普通用户何时登录系统,以及普通用户在使用sudo
权限时做了什么。之后,我展示了一个脚本,它可以读取 IP 地址列表,并自动创建防火墙规则来阻止这些地址。最后,我向你展示了一些如何查找和使用别人已经创建的脚本的技巧。
在下一章中,我们将讨论一些关于 shell 脚本可移植性的内容。我在下一章见。
问题
-
当你对远程机器进行端口扫描并发现某些端口处于关闭状态时,这意味着什么?
-
端口被防火墙阻止。
-
端口未被防火墙阻止,并且与这些端口相关的服务未运行。
-
端口未被防火墙阻止,并且与这些端口相关的服务正在运行。
-
远程机器无法通过您的端口扫描器访问。
-
-
您在 FreeBSD 系统上寻找
/etc/shadow
文件,但找不到它。可能是什么问题?-
FreeBSD 将其账户密码保存在
/etc/passwd
文件中。 -
FreeBSD 将其账户密码保存在
/var/lib/
目录中。 -
FreeBSD 使用
/etc/master.passwd
文件,而不是/etc/shadow
文件。 -
FreeBSD 不需要密码。
-
-
以下哪项陈述是正确的?
-
在您的脚本中使用
xargs
没有任何优势。 -
使用
xargs
可以让您的脚本更易创建、更易阅读,并能提高脚本的可移植性。 -
使用
xarg
使您的脚本更加复杂,且更难理解。
-
深入阅读
-
网络安全入门-Bash 使用简介:
medium.com/@aardvarkinfinity/introduction-to-bash-for-cybersecurity-56792984bcc0
-
Shell 脚本与安全:
www.linuxjournal.com/content/shell-scripting-and-security
-
HackSploit 博客-Bash 脚本:
hackersploit.org/bash-scripting/
-
Bash 是网络安全专家的秘密武器吗?:
cyberinsight.co/is-bash-used-in-cyber-security/
-
安全脚本:在 Linux 上使用 Bash 进行密码自动化的分步指南:
medium.com/@GeorgeBaidooJr/secure-scripting-a-step-by-step-guide-to-password-automation-in-linux-with-bash-12aa6b980acf
答案
-
b
-
c
-
b
加入我们的 Discord 社区!
与其他用户、Linux 专家以及作者本人一起阅读本书。
提问、为其他读者提供解决方案、通过“问我任何问题”环节与作者互动,等等。扫描二维码或访问链接加入社区。
留下您的评论!
感谢您从 Packt 出版社购买本书——我们希望您喜欢它!您的反馈对我们非常宝贵,帮助我们不断改进和成长。阅读完本书后,请花一点时间在亚马逊上留下评价;这只需要一分钟,但对像您这样的读者来说,意义重大。
扫描下面的二维码以接收您选择的免费电子书。
第十九章:Shell 脚本的可移植性
如我们稍后会看到,Linux、Unix 和类 Unix 操作系统有很多不同的 Shell 可用。但到目前为止,我们主要一直在使用bash
。bash
的一个大优点是它已经在大多数 Linux 发行版、macOS 和 OpenIndiana 上预装了。它通常不会在 BSD 类型的发行版上默认安装,但如果需要,你可以自行安装。
bash
的一个大优点是它可以使用不同的脚本结构,这些结构能让脚本编写者的工作变得更轻松。bash
的一个大缺点是许多这些bash
结构在非bash
的 Shell 中并不总是可用。如果你能在所有机器上安装bash
,那这并不是一个大问题,但并不是每次都能做到这一点。(稍后我会解释为什么。)
在这一章中,我将展示一些你可能遇到的bash
替代方案,以及如何让你的 Shell 脚本在这些 Shell 上运行。
本章内容包括:
-
在非 Linux 系统上运行
bash
-
理解 POSIX 合规性
-
理解 Shell 之间的差异
-
理解 bashisms
-
测试脚本的 POSIX 合规性
介绍部分就到这里。现在,让我们深入探讨一下。
技术要求
我在这一章中使用了 Fedora、Debian 和 FreeBSD 虚拟机。如果你们中的任何人使用的是 Mac,也可以尝试在上面运行这些脚本。如果你愿意,像往常一样,你可以从 GitHub 获取脚本:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
在非 Linux 系统上运行 bash
在我们讨论bash
的替代方案之前,让我们先谈谈在非 Linux 操作系统上使用bash
。我的意思是,确保脚本在每个地方都使用相同的 Shell,是让你的 Shell 脚本具备移植性最简单的方式。
正如我在介绍中提到的,bash
已经在大多数基于 Linux 的操作系统中安装好了,也包括 macOS 和 OpenIndiana。如果你想在 BSD 类型的发行版上使用bash
,比如 FreeBSD 或 OpenBSD,你需要自己安装它。我已经在第八章 基本 Shell 脚本构建中展示了如何在 FreeBSD 上安装bash
。为了提醒你,我再展示一遍:
donnie@freebsd14:~ $ sudo pkg install bash
在任何其他 BSD 类型的发行版上安装bash
同样简单,只是它们使用不同的包管理器。下面是各种 BSD 系统的命令表:
BSD 发行版 | 安装 bash 的命令 |
---|---|
FreeBSD | sudo pkg install bash |
OpenBSD | sudo pkg_add bash |
DragonflyBSD | sudo pkg install bash |
NetBSD | sudo pkgin install bash |
表 19.1:在 BSD 发行版上安装 bash 的命令
现在,如果你在这些 BSD 发行版中的任何一个上运行带有#!/bin/bash
的脚本,你会看到一个错误信息,类似下面这样:
donnie@freebsd14:~ $ ./ip.sh
-sh: ./ip.sh: not found
donnie@freebsd14:~ $
这个消息有点误导,因为它给人一种 shell 找不到脚本的印象。实际上,脚本找不到的是bash
。这是因为,与在 Linux 和 OpenIndiana 中看到的不同,bash
在 BSD 发行版的/bin/
目录中并不存在。相反,BSD 发行版将bash
放在/usr/local/bin/
目录中。有几种方法可以解决这个问题,我们接下来会看到。
使用 env 设置 bash 环境
确保你的脚本总是能找到bash
的第一种方法是将脚本中的#!/bin/bash
行替换为:
#!/usr/bin/env bash
这会导致脚本在用户的PATH
环境中查找bash
可执行文件,而不是在特定的硬编码位置查找。
我在第三章,理解变量和管道中向你展示了如何使用env
命令查看在bash
中设置的环境变量。在这种情况下,我使用env
来指定我希望用来解释该脚本的 shell。
这是最简单的方法,但也存在一些潜在问题。
-
如果你为一个已安装多个版本的解释器指定路径,你将无法知道脚本会调用哪个版本。如果机器上安装了多个
bash
版本,你就无法知道#!/usr/bin/env bash
会调用哪个版本。 -
使用
#!/usr/bin/env bash
可能会存在安全问题。正常的bash
可执行文件总是安装在一个只有 root 权限的用户才能添加、删除或修改文件的目录中。但假设恶意黑客获得了你正常用户账户的访问权限。在这种情况下,他或她就不需要获取 root 权限,便可以在你的家目录中植入一个恶意的伪bash
,并更改你的PATH
环境变量,使得伪bash
被调用,而不是实际的bash
。使用硬编码的#!/bin/bash
行可以避免这个问题。 -
使用
#!/usr/bin/env bash
时,你将无法在 shebang 行中调用任何bash
选项。例如,假设你需要排查一个有问题的脚本,并且想以调试模式运行该脚本。使用#!/bin/bash --debug
可以正常工作,但使用#!/usr/bin/env bash --debug
则不行。原因在于,env
命令只能识别一个选项参数,这个选项在此情况下是bash
。(它永远不会看到--debug
选项。)
暂时不用担心--debug
选项的作用。我会在第二十一章,调试 Shell 脚本中向你展示更多关于调试脚本的内容。
尽管使用#!/usr/bin/env bash
看起来是最简单的解决方案,但我更倾向于在可能的情况下避免使用它。所以,让我们看一下另一个稍微更安全和可靠的解决方案。
创建指向 bash 的符号链接
我推荐的解决方案是确保你的脚本在所有 BSD 发行版上都能找到bash
,你只需要在/bin/
目录下创建一个指向bash
可执行文件的符号链接。下面是操作步骤:
donnie@freebsd14:~ $ which bash
/usr/local/bin/bash
donnie@freebsd14:~ $ sudo ln -s /usr/local/bin/bash /bin/bash
Password:
donnie@freebsd14:~ $ which bash
/bin/bash
donnie@freebsd14:~ $
使用ln -s
命令创建符号链接时,你首先需要指定要链接的文件路径。然后,指定你想要创建的链接的路径和名称。注意,在我创建链接之后,which
命令现在能在/bin/
目录下找到bash
,而不是在/usr/local/bin/
目录下。那是因为/bin/
在默认的PATH
设置中排在/usr/local/bin/
之前,正如你在这里看到的:
donnie@freebsd14:~ $ echo $PATH
/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/home/donnie/bin
donnie@freebsd14:~ $
你现在可以在你的 BSD 机器上使用#!/bin/bash
的 shebang 行,就像在 Linux 机器上一样。
好的,如果你能在所有系统上安装bash
,那这都没问题。但如果不能呢?如果你需要编写能够在bash
以及其他 Shell 上运行的脚本呢?为了回答这个问题,我们首先需要了解一下叫做POSIX的东西。
理解 POSIX 合规性
在计算机的远古时代,没有所谓的标准化。为了让事情有意义,我们从 Unix 的出现开始讲述历史。
AT&T 在 1970 年代初期创造了 Unix,但直到 1983 年才被允许将其推向市场。这是由于美国政府在 1956 年对 AT&T 提起的反垄断法律案件。(我不了解这个案件的详细情况,所以请不要问。)但他们被允许将代码许可给其他厂商,以便他们可以销售自己的修改版实现。因此,多个不同的 Unix 实现相继出现,它们之间并不总是完全兼容。这些不同的实现包括伯克利软件发行版(BSD)、微软 Xenix和SunOS。1983 年,AT&T 终于被允许推销自己的System V Unix。更有趣的是,最终出现了多种不同的 Shell,供这些不同的 Unix 系统使用。最初是汤普森 Shell,几年后被 Bourne Shell 所取代。其他 Shell,如 C Shell 和 Korn Shell,紧随其后。最后,在 1990 年代初,Linus Torvalds 创建了 Linux 内核,它是 Unix 内核的干净重写。如今,基于 Linux 的操作系统基本上取代了基于 Unix 的系统,而 Bourne Again Shell(bash
)是 Linux 系统中占主导地位的 Shell。
在 1980 年代,客户开始要求不同 Unix 厂商之间有某种程度的标准化。这最终导致了IEEE 计算机学会在 1988 年创建了POSIX标准。
IEEE代表电气和电子工程师协会。有趣的是,您在他们的网站上看到的仅仅是 IEEE,而没有解释它的全称。
POSIX 代表可移植操作系统接口。
最后,对于在美国以外的你们,AT&T 代表美国电话电报公司。
没错,微软曾经是一个 Unix 厂商。
POSIX 的主要目的是确保所有 Unix 实现中的 shell 和工具以相同的方式工作。例如,如果你知道如何在 Solaris/OpenIndiana 上使用 ps
工具,那么你也知道如何在 FreeBSD 上使用它。问题在于,虽然许多 Unix 实现及其默认 shell 符合 POSIX 标准,但有些仅部分符合。另一方面,大多数基于 Linux 的操作系统只是部分符合 POSIX 标准。这是因为许多 Linux 工具使用一些 Unix/Unix-like 系统没有的选项开关,而 bash
本身有一些 POSIX shell 无法提供的编程特性。例如,你可以在符合 POSIX 标准的 shell 中使用 if [ -f /bin/someprogram ]
这种构造来测试文件是否存在,但你不能使用 if [[ -f /bin/someprogram ]]
。 (稍后我会详细解释这一点。)
POSIX 还定义了任何给定 Unix 实现必须包含哪些编程库。然而,这主要对实际的程序员有用,而非对 shell 脚本编写者。
公平地说,我承认如果你只在 Linux 服务器或工作站上工作,POSIX 合规性可能永远不会成为问题。但假设你正在处理一大批服务器,包含了 Unix、BSD 和 Linux 系统的混合配置。现在,假设这些 Unix 和 BSD 服务器中的一些仍在运行没有 bash
的旧版系统,你可能需要创建 POSIX 合规的 shell 脚本,以便在整个服务器群集上运行而不需要修改。此外,物联网(IoT)设备通常是资源非常有限的设备,可能无法运行 bash
。相反,它们会使用更轻量级的工具,如 ash
或 dash
。一般来说,这些轻量级的非 bash
shell 会符合 POSIX 标准,且无法运行使用 bash
特有功能的脚本。
现在你已经对 POSIX 有了较好的理解,让我们来谈谈不同 shell 之间的差异。
理解 Shell 之间的差异
除了定义你想使用的特定 shell,例如 /bin/bash
或 /bin/zsh
,你还可以定义通用的 /bin/sh
shell,使你的脚本更具可移植性,如下所示:
#!/bin/sh
这个通用的 sh
shell 允许你在可能安装也可能没有安装 bash
的不同系统上运行脚本。但问题是,很多年前,sh
总是指 Bourne shell。如今,sh
在一些操作系统中仍然是 Bourne shell,但在其他操作系统中则完全不同。以下是它的工作原理:
-
在大多数 BSD 类型的系统上,如 FreeBSD 和 OpenBSD,
sh
是传统的 Bourne shell。根据sh
的手册页,它仅支持符合 POSIX 标准的功能,以及一些 BSD 扩展。 -
在 Red Hat 类型的系统上,
sh
是一个符号链接,指向bash
可执行文件。 -
在 Debian/Ubuntu 类型的系统上,
sh
是指向dash
可执行文件的符号链接。dash
代表Debian Almquist Shell,它是 Bourne shell 的一个更快、更轻量级的实现。 -
在 Alpine Linux 上,
sh
是一个指向ash
的符号链接,ash
是一个轻量级的 shell,内置在busybox
可执行文件中。(在 Alpine Linux 上,默认情况下并未安装bash
。) -
在 OpenIndiana 上,OpenIndiana 是 Oracle Solaris 操作系统的一个自由开源软件分支,
sh
是指向ksh93
的符号链接。这个 shell,也叫 Korn shell,在某种程度上与bash
兼容。(它是由名为 David Korn 的人创建的,和任何蔬菜无关。)不过,OpenIndiana 的默认登录 shell 是bash
。 -
在 macOS 上,
sh
指向bash
可执行文件。有趣的是,zsh
现在是 macOS 的默认用户登录 shell,但bash
仍然安装并可以使用。
使用#!/bin/sh
只在你小心地使脚本具有移植性时有效。如果你机器上的sh
指向的是其他 shell,而不是bash
,那么对于需要 bash
特有高级功能的脚本,它将无法工作。所以,如果你使用sh
,请务必在不同的系统上测试,以确保一切如预期那样运行。
不过,在进入测试部分之前,让我们先了解一下 bashism 的概念,以及如何避免它们。
理解 Bash 特性
bashism指的是任何特定于bash
的功能,而这些功能在其他 shell 中无法使用。让我们看几个例子。
使用可移植的测试
对于我们的第一个例子,试着在你的 Fedora 虚拟机上运行这个命令:
donnie@fedora:~$ [[ -x /bin/ls ]] && echo "This file is installed.";
This file is installed.
donnie@fedora:~$
这里,我正在测试/bin/
目录中是否存在ls
可执行文件。该文件存在,因此调用了echo
命令。现在,让我们在 FreeBSD 虚拟机上运行相同的命令:
donnie@freebsd14:~ $ [[ -f /bin/ls ]] && echo "This file is installed."
-sh: [[: not found
donnie@freebsd14:~ $
这次我遇到了一个错误,因为 FreeBSD 上的默认用户登录 shell 是sh
,而不是bash
。问题在于 FreeBSD 中的sh
实现不支持[[. . .]]
构造。让我们看看是否能解决这个问题:
donnie@freebsd14:~ $ [ -f /bin/ls ] && echo "This file is installed."
This file is installed.
donnie@freebsd14:~ $
非常酷,这个方法有效。那么现在,你可能在想为什么有人会使用非 POSIX 的[[. . .]]
构造,而不是更具移植性的[. . .]
。嗯,主要是因为某些类型的测试无法与[. . .]
一起使用。例如,让我们看一下test-test-1.sh
脚本:
#!/bin/bash
if [[ "$1" == z* ]]; then
echo "Pattern matched: "$1" starts with 'z'."
else
echo "Pattern not matched: "$1" doesn't start with 'z'."
fi
当我调用这个脚本时,我会提供一个单词作为$1
位置参数。如果单词的第一个字母是“z”,那么模式将匹配。
在这个脚本中,z*
是一个正则表达式。我们使用这个正则表达式来匹配所有以字母“z”开头的单词。每次在测试中使用正则表达式时,必须将其放在双中括号[[. . .]]
构造内。
(我在第九章,使用 grep、sed 和正则表达式过滤文本中解释了正则表达式。)
下面是它的工作原理:
donnie@fedora:~$ ./test-test-1.sh zebra
Pattern matched: zebra starts with 'z'.
donnie@fedora:~$ ./test-test-1.sh donnie
Pattern not matched: donnie doesn't start with 'z'.
donnie@fedora:~$
现在,看一下test-test-2.sh
脚本,它使用了[. . .]
:
#!/bin/bash
if [ "$1" == z* ]; then
echo "Pattern matched: "$1" starts with 'z'."
else
echo "Pattern not matched: "$1" doesn't start with 'z'."
fi
这个脚本与第一个脚本完全相同,唯一的不同是它使用了单括号进行测试,而不是双括号。下面是我运行该脚本时发生的情况:
donnie@fedora:~$ ./test-test-2.sh zebra
Pattern not matched: zebra doesn't start with 'z'.
donnie@fedora:~$
你会看到,这个测试在单括号下不起作用。因为单括号构造不识别正则表达式的使用。但是,如果在某些sh
实现中双括号不起作用,那么我该如何使这个脚本具有可移植性呢?好吧,这里有一个解决方案:
#!/bin/sh
if [ "$(echo $1 | cut -c1)" = z ]; then
echo "Pattern matched: "$1" starts with 'z'."
else
echo "Pattern not matched: "$1" doesn't start with 'z'."
fi
我并没有使用z*
正则表达式来匹配任何以字母“z”开头的单词,而是将单词传递给cut -c1
命令,以便提取首字母。我还将测试中的==
改为=
,因为==
也被认为是 bash 语法。
==
实际上在 FreeBSD 的sh
实现中有效,但可能在其他实现中不起作用。例如,在 Debian/Ubuntu 类型的发行版中,#!/bin/sh
调用的dash
就不支持它。
在此过程中,我将 shebang 行更改为#!/bin/sh
,因为我也想在 FreeBSD 上使用 Bourne shell 进行测试。那么,这能行吗?让我们在 Fedora 上试试,在 Fedora 上,#!/bin/sh
指向的是bash
:
donnie@fedora:~$ ./test-test-3.sh zebra
Pattern matched: zebra starts with 'z'.
donnie@fedora:~$ ./test-test-3.sh donnie
Pattern not matched: donnie doesn't start with 'z'.
donnie@fedora:~$
是的,在 Fedora 上运行正常。现在,让我们看看在 FreeBSD 上会发生什么:
donnie@freebsd14:~ $ ./test-test-3.sh zealot
Pattern matched: zealot starts with 'z'.
donnie@freebsd14:~ $ ./test-test-3.sh donnie
Pattern not matched: donnie doesn't start with 'z'.
donnie@freebsd14:~ $
确实,它运行得非常顺利。而且,作为记录,我还在一台使用dash
的 Debian 机器上进行了测试,它也能正常工作。
现在,让我们来看一下下一个可移植性问题。
创建可移植的数组
有时,你需要在 Shell 脚本中创建和操作项目列表。在bash
中,你可以像在 C、C++或 Java 等语言中一样创建变量数组。例如,让我们来看一下ip-2.sh
脚本:
#!/bin/sh
echo "IP Addresses of intruder attempts"
declare -a ip
ip=( 192.168.3.78 192.168.3.4 192.168.3.9 )
echo "ip[0] is ${ip[0]}, the first item in the list."
echo "ip[2] is ${ip[2]}, the third item in the list."
echo "***********************************"
echo "The most dangerous intruder is ${ip[1]}, which is in ip[1]."
echo "***********************************"
echo "Here is the entire list of IP addresses in the array."
echo ${ip[*]}
这个脚本与我在第八章,基本 Shell 脚本构建中展示的ip.sh
脚本完全相同,唯一不同的是我将 shebang 行改为#!/bin/sh
。为了提醒你,我使用了declare -a
命令来创建ip
数组,并用ip=
行来填充数组。这应该能在我的 Fedora 机器上正常工作,因为 Fedora 的sh
指向的是bash
。让我们看看它是否能正常工作:
donnie@fedora:~$ ./ip-2.sh
IP Addresses of intruder attempts
ip[0] is 192.168.3.78, the first item in the list.
ip[2] is 192.168.3.9, the third item in the list.
***********************************
The most dangerous intruder is 192.168.3.4, which is in ip[1].
***********************************
Here is the entire list of IP addresses in the array.
192.168.3.78 192.168.3.4 192.168.3.9
donnie@fedora:~$
是的,它确实有效。
现在,让我们看看在 FreeBSD 上的表现如何:
donnie@freebsd14:~ $ ./ip-2.sh
IP Addresses of intruder attempts
./ip-2.sh: declare: not found
./ip-2.sh: 4: Syntax error: word unexpected (expecting ")")
donnie@freebsd14:~ $
这次的问题是,FreeBSD 上的 Bourne(sh
)shell 不能使用变量数组。为了使脚本具备可移植性,我们需要找到一个解决方法。那么,让我们尝试这个ip-3.sh
脚本:
#!/bin/sh
set 192.168.3.78 192.168.3.4 192.168.3.9
echo "$1 is the first item in the list."
echo "$3 is the third item in the list."
echo "**********************************"
echo "The most dangerous intruder is $2, which is the second item of the list."
echo "*********************************"
echo "Here is the entire list of IP addresses in the array:"
echo "$@"
这次,我没有构建实际的数组,而是使用set
命令创建了一个 IP 地址列表,并通过位置参数访问它。我知道,你可能认为位置参数仅仅用于在调用脚本时从命令行传递参数。但这里展示的其实是另一种使用位置参数的方法。大问题是,这能行吗?让我们看看它在 FreeBSD 机器上的效果:
donnie@freebsd14:~ $ ./ip-3.sh
192.168.3.78 is the first item in the list.
192.168.3.9 is the third item in the list.
**********************************
The most dangerous intruder is 192.168.3.4, which is the second item of the list.
*********************************
Here is the entire list of IP addresses in the array:
192.168.3.78 192.168.3.4 192.168.3.9
donnie@freebsd14:~ $
我们再次成功了。
请注意,在使用set
和位置参数时,我们并没有创建一个真正的数组。我们只是模拟了一个数组。不过,管它的,反正有用,不是吗?
你也可以从文本文件中构建一个模拟数组,使用cat
命令。首先,让我们创建iplist.txt
文件,它看起来像这样:
donnie@fedora:~$ cat iplist.txt
192.168.0.12
192.168.0.16
192.168.0.222
192.168.0.3
donnie@fedora:~$
现在,创建ip-4.sh
脚本,如下所示:
#!/bin/sh
set $(cat iplist.txt)
echo "$1 is the first item in the list."
echo "$3 is the third item in the list."
echo "********************************"
echo "The most dangerous intruder is $2, which is the second item of the list."
echo "*******************************"
echo "Here is the entire list of IP addresses in the array:"
echo "$@"
这是我在 FreeBSD 上运行时的结果:
donnie@freebsd14:~ $ ./ip-4.sh
192.168.0.12 is the first item in the list.
192.168.0.222 is the third item in the list.
********************************
The most dangerous intruder is 192.168.0.16, which is the second item of the list.
*******************************
Here is the entire list of IP addresses in the array:
192.168.0.12 192.168.0.16 192.168.0.222 192.168.0.3
donnie@freebsd14:~ $
所以,看起来不错。
接下来,让我们处理一个到目前为止还没有成为问题的事情,但未来可能会成为问题的。那就是,echo
的使用。
理解echo
的可移植性问题
到目前为止,在我们的脚本中使用echo
没有遇到任何问题。那只是因为我们没有使用echo
的高级格式化功能。为了演示这一点,请在你的 Fedora 虚拟机上运行这个命令:
donnie@fedora:~$ echo -e "I want to fly \vto the moon."
I want to fly
to the moon.
donnie@fedora:~$
你在\vto the moon
字符串中看到的\v
插入了一个垂直制表符,导致输出被分成两行,第二行前面有制表符。
为了使用\v
标签,我必须在echo
命令中使用-e
选项。现在,尝试不使用-e
命令:
donnie@fedora:~$ echo "I want to fly \vto the moon."
I want to fly \vto the moon.
donnie@fedora:~$
你可以看到,在没有-e
选项的情况下,\v
并没有按预期插入垂直制表符。
echo
的大问题是不同的非bash
shell 处理其选项开关的方式不一致。例如,让我们在 Debian 机器上打开一个dash
会话,看看它上面发生了什么:
donnie@debian12:~$ dash
$ echo -e "I want to fly \vto the moon."
-e I want to fly
to the moon.
$ echo "I want to fly \vto the moon."
I want to fly
to the moon.
$
你可以看到在dash
中,echo -e
在输出字符串的开头插入了-e
。但是,如果我们省略-e
,输出仍然正确显示。这是因为 POSIX 标准并没有为echo
定义-e
选项。相反,它只是允许echo
默认识别反斜杠格式化选项,如\v
。
保证脚本输出一致的最好方法是使用printf
而不是echo
。以下是在dash
中显示的情况:
donnie@debian12:~$ dash
$ printf "%b\n" "I want to fly \vto the moon."
I want to fly
to the moon.
$
使用printf
时,你需要将格式化选项放在要输出的字符串前面。在这种情况下,%b
启用在输出字符串中使用反斜杠格式化选项,而\n
表示在输出的末尾附加换行符。你会看到,我将这两个选项组合在"%b\n"
构造中。酷的是,你可以在任何虚拟机上、任何 Shell 中运行这个命令,输出总是会一致的。
最后,让我们来看一下ip-5.sh
脚本,它使用printf
而不是echo
:
#!/bin/sh
set 192.168.3.78 192.168.3.4 192.168.3.9
printf '%s\n' "$1 is the first item in the list."
printf '%s\n' "$3 is the third item in the list."
printf '%s\n' "**********************************"
printf '%b\n' "The most dangerous intruder is \v$2,\v which is the second item of the list."
printf '%s\n' "*********************************"
printf '%s\n' "Here is the entire list of IP addresses in the array:"
printf '%s\n' "$@"
除了一个printf
行之外,我在所有printf
行中都使用了"%s\n"
格式化选项,它的意思是打印指定的文本字符串,并在末尾添加换行符。在第四行printf
中,我使用了"%b\n"
选项,它表示允许在文本中使用反斜杠格式化选项。你会看到,我将位置参数$2
用一对\v
选项括起来,以便插入一对垂直制表符。运行脚本时,它的输出如下所示:
donnie@fedora:~$ ./ip-5.sh
192.168.3.78 is the first item in the list.
192.168.3.9 is the third item in the list.
**********************************
The most dangerous intruder is
192.168.3.4,
which is the second item of the list.
*********************************
Here is the entire list of IP addresses in the array:
192.168.3.78
192.168.3.4
192.168.3.9
donnie@fedora:~$
酷的是,正如我之前所说,printf
的输出在所有的 shell 中都会保持一致。
好了,现在你已经看到了 bashism 的一些例子,我们来看看如何测试我们的脚本是否符合 POSIX 标准。
测试脚本的 POSIX 合规性
在将 shell 脚本投入生产环境之前,测试它们始终非常重要。当你创建需要在多种操作系统和 shell 上运行的脚本时,这一点尤其重要。在这一部分,我们将介绍一些测试方法。
在符合 POSIX 标准的 Shell 上创建脚本
当你第一次开始编写脚本时,可能会想使用一个完全符合 POSIX 标准的解释器 shell。但是需要注意的是,一些符合 POSIX 标准的 shell 仍然允许你使用某些 bashism。这是因为 POSIX 定义了操作系统或 shell 必须满足的最低标准,并没有禁止添加扩展。例如,FreeBSD 上的 sh
允许使用以下两个 bashism:
-
使用
echo -e
输出。 -
使用
==
进行文本字符串比较。
目前,我还没有在 FreeBSD 上对 sh
进行广泛测试,以查看它到底允许多少 bashism。但是,至少允许这两个特性意味着我们不能仅凭它来确定我们的脚本是否能在整个网络上运行。
最符合 POSIX 标准的 shell 是 dash
,它已预装在任何操作系统中。我已经提到过,如果在 Debian/Ubuntu 类型的系统上运行一个 #!/bin/sh
脚本,它会使用 dash
作为脚本解释器。好处是,如果你创建一个在 dash
上运行的脚本,它很可能也能在其他所有 shell 上正确运行。
嗯,差不多吧。记住我刚才给你演示的 echo -e
。我告诉你在 bash
中,必须使用 -e
选项才能包含任何反斜杠格式化选项。我的意思是这个:
donnie@fedora:~$ echo -e "I want to fly \vto the moon."
I want to fly
to the moon.
donnie@fedora:~$ echo "I want to fly \vto the moon."
I want to fly \vto the moon.
donnie@fedora:~$
因此,在 bash
上,\v
格式选项除非使用带有 -e
开关的 echo
,否则无法使用。而在 dash
上,正好相反,正如你在这里看到的:
donnie@debian12:~$ dash
$ echo -e "I want to fly \vto the moon."
-e I want to fly
to the moon.
$ echo "I want to fly \vto the moon."
I want to fly
to the moon.
$
所以,如果你在脚本中使用 echo
命令,它们可能在 dash
上正常工作,但在 bash
上却不行。正如我之前提到的,最好的方法是使用 printf
替代 echo
。除此之外,如果你确实创建了将在 dash
上运行的脚本,你还需要在 bash
上对它们进行测试。我不清楚有多少 POSIX 特性可以在 dash
上运行,但在 bash
上却不行。但是,我们确实知道这个 echo -e
问题。
符合政策的普通 Shell(posh
)是一种比 dash
更严格符合 POSIX 标准的 shell。它的主要问题是,似乎只有 Debian/Ubuntu 类型的发行版在其仓库中包含它。另一方面,您几乎可以在任何 Linux 发行版上轻松安装 dash
,以及一些类 Unix 发行版,如 FreeBSD。
无论如何,您可以在这里阅读更多关于posh
的内容:
如何检查您的 shell 脚本的可移植性:people.redhat.com/~thuth/blog/general/2021/04/27/portable-shell.html
说到测试,让我们看看如何进行测试。
使用 checkbashisms
这个很酷的checkbashisms
工具会检查你的脚本,看看其中是否有可能在非bash
shell 上无法运行的内容。但首先,你需要安装它。下面是安装的方法:
在 Fedora 上:
donnie@fedora:~$ sudo dnf install devscripts-checkbashisms
在 Debian/Ubuntu 上:
donnie@debian12:~$ sudo apt install devscripts
在 FreeBSD 上:
donnie@freebsd14:~ $ sudo pkg install checkbashisms
在 macOS 上:
安装 Homebrew 系统,然后使用以下命令安装checkbashisms
包:
macmini@MacMinis-Mac-mini ~ % brew install checkbashisms
如果你想知道如何在 Mac 上安装 Homebrew 系统,请访问:brew.sh
checkbashisms
的基本用法很简单。如果你想测试的脚本的 shebang 行是#!/bin/sh
,那么只需输入:
donnie@fedora:~$ checkbashisms scriptname.sh
默认情况下,checkbashims
只检查包含#!/bin/sh
shebang 行的脚本。如果你的脚本使用了其他作为 shebang 行的内容,例如#!/bin/bash
,那么可以使用-f
选项强制它检查该脚本,像这样:
donnie@fedora:~$ checkbashisms -f scriptbashname.sh
对于第一个示例,让我们再看一下ip-2.sh
脚本:
#!/bin/sh
echo "IP Addresses of intruder attempts"
declare -a ip
ip=( 192.168.3.78 192.168.3.4 192.168.3.9 )
echo "ip[0] is ${ip[0]}, the first item in the list."
echo "ip[2] is ${ip[2]}, the third item in the list."
echo "***********************************"
echo "The most dangerous intruder is ${ip[1]}, which is in ip[1]."
echo "***********************************"
echo "Here is the entire list of IP addresses in the array."
echo ${ip[*]}
现在,让我们看看checkbashisms
对此有什么看法:
donnie@fedora:~$ checkbashisms ip-2.sh
possible bashism in ip-2.sh line 3 (declare):
declare -a ip
possible bashism in ip-2.sh line 5 (bash arrays, ${name[0|*|@]}):
echo "ip[0] is ${ip[0]}, the first item in the list."
possible bashism in ip-2.sh line 6 (bash arrays, ${name[0|*|@]}):
echo "ip[2] is ${ip[2]}, the third item in the list."
possible bashism in ip-2.sh line 8 (bash arrays, ${name[0|*|@]}):
echo "The most dangerous intruder is ${ip[1]}, which is in ip[1]."
possible bashism in ip-2.sh line 11 (bash arrays, ${name[0|*|@]}):
echo ${ip[*]}
donnie@fedora:~$
嗯,这倒不奇怪,因为我们已经确定一些非bash
的 shell 无法处理数组。而且,我已经展示过如何处理这个问题。
好的,这是你在math6.sh
脚本中还没有见过的一个 bashism:
#!/bin/sh
start=0
limit=10
while [ "$start" -le "$limit" ]; do
echo "$start... "
start=$["$start"+1]
done
这是一个 while 循环,输出数字 0 到 10,后面跟着一个字符串“三个点”,每个都在单独的一行。它的输出如下:
donnie@fedora:~$ ./math6.sh
0...
1...
2...
3...
4...
5...
6...
7...
8...
9...
10...
donnie@fedora:~$
在 Fedora 上,使用bash
看起来一切正常。但是,它能在使用dash
的 Debian 上工作吗?我们来看一下:
donnie@debian12:~$ ./math6.sh
0...
./math6.sh: 4: [: Illegal number: $[0+1]
donnie@debian12:~$
有什么问题吗?也许checkbashisms
能告诉我们:
donnie@debian12:~$ checkbashisms math6.sh
possible bashism in math6.sh line 6 ('$[' should be '$(('):
start=$["$start"+1]
donnie@debian12:~$
问题出在第 6 行。因为只有bash
可以使用[ . . . ]
结构进行整数运算。而其他 shell 则必须使用(( . . . ))
结构。我们来在math7.sh
脚本中修复这个问题:
#!/bin/sh
start=0
limit=10
while [ "$start" -le "$limit" ]; do
echo "$start... "
start=$(("$start"+1))
done
那样应该会好很多,对吧?好吧,我们来看看:
donnie@debian12:~$ ./math7.sh
0...
./math7.sh: 6: arithmetic expression: expecting primary: ""0"+1"
donnie@debian12:~$
不,它仍然有问题。所以,让我们再给它一次checkbashisms
扫描:
donnie@debian12:~$ checkbashisms math7.sh
donnie@debian12:~$
哇,怎么回事?checkbashisms
扫描说我的代码是好的,但脚本在dash
上运行时仍然明显有问题。怎么回事呢?经过一番实验,我发现把数学表达式中的变量名用一对引号括起来也是一个 bashism。但这是checkbashisms
没捕捉到的。我们来在math8.sh
脚本中修复这个问题:
#!/bin/sh
start=0
limit=10
while [ "$start" -le "$limit" ]; do
echo "$start... "
start=$(($start+1))
done
好的,这次应该终于能行吗?请听鼓声!
donnie@debian12:~$ ./math8.sh
0...
1...
2...
3...
4...
5...
6...
7...
8...
9...
10...
donnie@debian12:~$
确实,现在它工作得非常顺利。
所以,你已经看到checkbashisms
是一个很棒的工具,能帮助你发现问题。但正如人类发明的大多数东西一样,它并不完美。它能够标记许多有问题的、非 POSIX 的代码,但它也允许一些特定于bash
的东西漏过。(如果能有一份checkbashisms
漏掉的内容清单就好了,但目前没有。)
好的,让我们继续看下一个代码检查工具。
使用 shellcheck
shellcheck
工具是另一个很棒的代码检查工具,也能检查 bashisms。它在大多数 Linux 和 BSD 发行版上都可以使用。下面是如何安装它:
在 Fedora 上:
donnie@fedora:~$ sudo dnf install ShellCheck
在 Debian/Ubuntu 上:
donnie@debian12:~$ sudo apt install shellcheck
在 FreeBSD 上:
donnie@freebsd14:~ $ sudo pkg install hs-ShellCheck
在安装了 Homebrew 的 macOS 上:
macmini@MacMinis-Mac-mini ~ % brew install shellcheck
为了演示这个问题,让我们回到 Debian 机器,扫描与 checkbashisms
一起扫描的相同脚本。我们从 ip-2.sh
脚本开始。以下是相关的输出部分:
donnie@debian12:~$ shellcheck ip-2.sh
In ip-2.sh line 4:
ip=( 192.168.3.78 192.168.3.4 192.168.3.9 )
^-- SC3030 (warning): In POSIX sh, arrays are undefined.
In ip-2.sh line 5:
echo "ip[0] is ${ip[0]}, the first item in the list."
^------^ SC3054 (warning): In POSIX sh, array references are undefined.
. . .
In ip-2.sh line 11:
echo ${ip[*]}
^------^ SC2048 (warning): Use "${array[@]}" (with quotes) to prevent whitespace problems.
^------^ SC3054 (warning): In POSIX sh, array references are undefined.
^------^ SC2086 (info): Double quote to prevent globbing and word splitting.
. . .
donnie@debian12:~$
你会看到,shellcheck
确实警告我们在 POSIX 兼容的脚本中不能使用数组。它还会提醒我们一些良好编程实践方面的问题,这些问题不一定是 POSIX 的问题。在这个案例中,它提醒我们应该将变量扩展构造 ${ip[0]}
(在这种情况下)用一对双引号括起来,以防止空白问题。当然,这在这里并不重要,因为 ${ip[0]}
构造本身是我们无法使用的数组定义的一部分。
现在,让我们尝试一下 math6.sh
脚本:
donnie@debian12:~$ shellcheck math6.sh
In math6.sh line 6:
start=$["$start"+1]
^-----------^ SC3007 (warning): In POSIX sh, $[..] in place of $((..)) is undefined.
^-----------^ SC2007 (style): Use $((..)) instead of deprecated $[..]
For more information:
https://www.shellcheck.net/wiki/SC3007 -- In POSIX sh, $[..] in place of $(...
https://www.shellcheck.net/wiki/SC2007 -- Use $((..)) instead of
deprecated...
donnie@debian12:~$
正如预期的那样,shellcheck
检测到了一种非 POSIX 的数学运算方式。所以,这很好。
扫描 math7.sh
会得到以下结果:
donnie@debian12:~$ shellcheck math7.sh
donnie@debian12:~$
在这里,我们看到与 checkbashisms
一样的问题。也就是说,math7.sh
中数学构造内的变量名被一对双引号包围,就像你在 start=$(("$start"+1))
这一行中看到的那样。我们已经确认这种方式在 bash
上有效,但在 dash
上无效,然而 shellcheck
并没有将此标记为问题。
最后,让我们尝试一下 math8.sh
。正如你所记得的,这就是我们最终在 dash
上能正常工作的脚本。它的样子是这样的:
donnie@debian12:~$ shellcheck math8.sh
In math8.sh line 6:
start=$(($start+1))
^----^ SC2004 (style): $/${} is unnecessary on arithmetic variables.
For more information:
https://www.shellcheck.net/wiki/SC2004 -- $/${} is unnecessary on arithmeti...
donnie@debian12:~$
在这种情况下,脚本在我们的 Debian/dash
机器上运行得非常顺利。在这里,shellcheck
提出了一个风格问题,这个问题不会影响脚本是否真正运行。如果你还记得 第十一章,执行数学运算,我曾经向你展示过,当回调一个在数学构造中的变量时,变量名前不需要加 $
。我的意思是,如果你加了也没关系,但实际上并不需要。
使用 -s
选项指定 Shell
使用 shellcheck
的另一个好处是,你可以使用 -s
选项指定你想要用来测试脚本的 Shell。即使你的脚本中的 shebang 行是 #!/bin/sh
,而且你机器上的 sh
指向的是非 bash
的 Shell,你仍然可以像这样使用 bash
进行测试:
donnie@debian12:~$ shellcheck -s bash ip-2.sh
In ip-2.sh line 11:
echo ${ip[*]}
^------^ SC2048 (warning): Use "${array[@]}" (with quotes) to prevent whitespace problems.
^------^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
echo "${ip[*]}"
For more information:
https://www.shellcheck.net/wiki/SC2048 -- Use "${array[@]}" (with quotes) t...
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
donnie@debian12:~$
通过在 bash
上测试,我们不再收到关于不能使用数组的警告。这次我们收到的只是一个提醒,告诉我们应该将变量名用双引号括起来。
使用 -s
选项,你可以指定 sh
、bash
、dash
或 ksh
。 (奇怪的是,它在 zsh
上无法使用。)
实践实验室 – 使用 -s 扫描函数库
你还可以使用 -s
选项扫描没有 shebang 行的函数库文件。要查看如何做,让我们扫描一下在 第十四章,使用 awk-第一部分 中最后遇到的 sysinfo.lib
文件。
本节中的脚本和库文件过大,无法在此处完全显示。因此,务必从 GitHub 仓库下载它们。
-
首先,将
sysinfo.lib
文件复制到sysinfo_posix.lib
,如下所示:donnie@debian12:~$ cp sysinfo.lib sysinfo_posix.lib donnie@debian12:~$
-
然后,扫描它以查找
bash
,因为它最初只打算在bash
上使用。以下是相关的输出:donnie@debian12:~$ shellcheck -s bash sysinfo_posix.lib In sysinfo_posix.lib line 19: if [ $(uname) = SunOS ]; then ^------^ SC2046 (warning): Quote this to prevent word splitting. . . . In sysinfo_posix.lib line 48: cd /Users ^-------^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails. Did you mean: cd /Users || exit . . . donnie@debian12:~$
-
对于
bash
,shellcheck
并没有发现任何阻碍的问题。但它确实建议了一些可以避免问题的改进。首先,它建议我用双引号将uname
命令的替换包围起来,以防uname
返回的文本字符串中包含空格或特殊字符,这些字符可能会被 Shell 误解。实际上,这在本脚本中永远不会成为问题,因为我们知道uname
返回的永远是一个纯文本、仅包含字母的字符串。但我们还是做了修改。在这种情况下,if [ $(uname) = SunOS ]; then
这一行将变成:if [ "$(uname)" = SunOS ]; then
我们会对每个uname
命令替换的出现位置做同样的处理。
-
我们看到的第二个建议是,在脚本无法
cd
进入/Users/
目录时插入一个优雅的退出机制。同样,我们知道在本脚本中这不会成为问题,因为我们知道指定的目录始终会存在。但我们还是做了修改。我们将那条cd /Users
命令改成:cd /Users || return
我们会对open_files_users()
函数中的所有cd
命令做同样的处理。这样,如果cd
命令失败,脚本将继续运行,而不会中断这个函数。
-
现在,假设我们希望使这个脚本具有可移植性,这样我们就不必在所有系统上都安装
bash
。如我之前提到的,dash
是大多数 Linux 和 BSD 类型发行版中最符合 POSIX 标准的 Shell。所以,我们再次使用-s dash
来扫描文件:donnie@debian12:~$ shellcheck -s dash sysinfo_posix.lib . . . . . . In sysinfo_posix.lib line 55: if [[ $user != "lost+found" ]] && [[ $user != "Shared" ]]; then ^-------------------------^ SC3010 (error): In dash, [[ ]] is not supported. ^---------------------^ SC3010 (error): In dash, [[ ]] is not supported. . . . . . . In sysinfo.lib line 71: echo "${os:12}" ^------^ SC3057 (error): In dash, string indexing is not supported. . . . . . . donnie@debian12:~$
-
我们在之前的脚本中已经看到第一个问题。只是
[[. . .]]
结构在一些非bash
的 Shell 中不被支持,比如dash
。幸运的是,这里没有任何原因要求我们必须使用双中括号,所以我们可以直接将它们替换为单中括号。所以,我们将修改出问题的那一行:if [ $user != "lost+found" ] && [ $user != "Shared" ]; then
-
第二个问题是我们之前还没有遇到的。
"${os:12}"
类型的变量扩展是另一个bash
特性,在 Bourne shell 或dash
中无法使用。为了提醒你记忆,os
变量是在文件末尾的system_info()
函数中定义的。从命令行定义时,变量是这样的:donnie@debian12:~$ os=$(grep "PRETTY_NAME" /etc/os-release) donnie@debian12:~$ echo $os PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" donnie@debian12:~$
但是,我们不希望PRETTY_NAME=
部分出现在报告中。我们只想要操作系统的名称。这个PRETTY_NAME=
字符串包含 12 个字符。在bash
中,我们可以通过如下方式去掉这 12 个字符:
donnie@debian12:~$ echo "${os:12}"
"Debian GNU/Linux 12 (bookworm)"
donnie@debian12:~$
它在那里看起来不错,但看看我在dash
会话中尝试时会发生什么:
donnie@debian12:~$ dash
$ os=$(grep "PRETTY_NAME" /etc/os-release)
$ echo "${os:12}"
dash: 2: Bad substitution
$
哎呀,这根本不起作用。幸运的是,这很容易修复。我们只需使用一种符合 POSIX 标准的变量扩展形式,看起来是这样的:
$ echo "${os#PRETTY_NAME=}"
"Debian GNU/Linux 12 (bookworm)"
$
我们不再指定要从文本字符串开头去除的字符数,而是直接指定要去除的文本部分。而且,我将 :
替换为了 #
。简单吧?
这是我至今尚未弄明白的生活之谜之一。大多数 bashism 是作为一种更简单的方式来完成某些事情,或是为了让 bash
执行其他 shell 完全无法做到的任务。在这种情况下,bash
执行变量扩展的方式并不比 POSIX 的方式更简便。那么,为什么 bash
的开发者还要为我们提供这种新的非 POSIX 方式呢?你我猜测一样。
无论如何,你可以在这里阅读更多关于 POSIX 兼容的变量扩展的内容:
POSIX shell 备忘单:steinbaugh.com/posts/posix.html
-
接下来,我们需要扫描使用此函数库的脚本。首先,将
system_info.sh
复制为system_info_posix.sh
:donnie@debian12:~$ cp system_info.sh system_info_posix.sh donnie@debian12:~$
-
现在,扫描
system_info_posix.sh
脚本以检查其是否与dash
兼容:donnie@debian12:~$ shellcheck -s dash system_info.sh . . . . . . In system_info_posix.sh line 5: title="System Information for $HOSTNAME" ^-------^ SC3028 (error): In dash, HOSTNAME is not supported. In system_info_posix.sh line 31: if [[ -f /usr/local/bin/pandoc ]] || [[ -f /usr/bin/pandoc ]]; then ^----------------------------^ SC3010 (error): In dash, [[ ]] is not supported. ^----------------------^ SC3010 (error): In dash, [[ ]] is not supported. . . . . . . donnie@debian12:~$
我们首先看到的是,“在 dash 中,HOSTNAME 不受支持。”这听起来有点奇怪,因为我在这里做的只是调用一个环境变量的值。但它说这不起作用,因此我将其改为使用 hostname
命令的命令替换,像这样:
title="System Information for $(hostname)"
下一个问题是我们之前看到过的那个老问题——双中括号。又是一个不需要双中括号的情况,所以我将其更改为单中括号,如下所示:
if [ -f /usr/local/bin/pandoc ] || [ -f /usr/bin/pandoc ]; then
-
接下来,我们将
sysinfo_posix.lib
文件复制到/usr/local/lib/
目录中的正确位置:donnie@debian12:~$ sudo cp sysinfo_posix.lib /usr/local/lib/ donnie@debian12:~$
-
现在,编辑
system_info_posix.sh
脚本,使其加载新的函数库,并使用sh
作为解释器。使脚本的顶部部分看起来像这样:#!/bin/sh # sysinfo_page - A script to produce an HTML file . /usr/local/lib/sysinfo_posix.lib
-
最后,将
system_info_posix.sh
脚本和sysinfo_posix.lib
文件复制到其他操作系统的机器上。你应该会发现,这个脚本和函数库在其他 Linux 发行版、FreeBSD、OpenIndiana 和 macOS 上也能很好地工作。
实验结束
到此,我们已经介绍了 checkbashisms
和 shellcheck
。我们还要介绍另一个有用的脚本检查工具吗?当然要,我们现在就来介绍。
使用 shall
checkbashisms
和 shellcheck
工具作为 静态代码检查工具。这意味着,这两个工具并不是实际运行脚本来验证它们是否有效,而是直接扫描代码以检测潜在问题。不过,有时使用 动态代码检查器 会更有帮助,动态检查器会实际运行代码以观察其结果。这时,shall
就派上用场了。
shall
是一个 bash
脚本,你可以从作者的 GitHub 仓库下载。最简单的安装方法是克隆该仓库,如下所示:
donnie@debian12:~$ git clone https://github.com/mklement0/shall.git
接下来,进入 shall/bin/
目录,将 shall
脚本复制到 /usr/local/bin/
目录中,如下所示:
donnie@debian12:~$ cd shall/bin
donnie@debian12:~/shall/bin$ ls -l
total 28
-rwxr-xr-x. 1 donnie donnie 27212 May 27 18:20 shall
donnie@debian12:~/shall/bin$ sudo cp shall /usr/local/bin
donnie@debian12:~/shall/bin$
现在,这是最酷的部分。通过一个命令,shall
可以测试你的脚本是否适用于sh
、bash
、dash
、zsh
和ksh
。(不管你在脚本的 shebang 行中指定的是哪个解释器 Shell。)大多数 Linux 和 BSD 发行版的包仓库中都有这些不同的 Shell,所以你可以安装那些还没有安装的 Shell。
当然,dash
和bash
已经安装在我们的 Debian 机器上了,所以我们来安装zsh
和ksh
:
donnie@debian12:~$ sudo apt install zsh ksh
让我们从测试我们的ip-2.sh
脚本开始。输出太长,无法完全显示,所以我只会展示一些相关的部分。这里是顶部内容:
donnie@debian12:~$ shall ip-2.sh
✗ sh@ (-> dash) [0.00s]
IP Addresses of intruder attempts
ip-2.sh: 4: Syntax error: "(" unexpected
第一个检查是针对sh
的,在 Debian 中实际上意味着dash
。你可以看到脚本无法运行,因为dash
无法处理数组。接下来的检查是针对bash
的,看起来是这样的:
✓ bash [0.00s]
IP Addresses of intruder attempts
ip[0] is 192.168.3.78, the first item in the list.
ip[2] is 192.168.3.9, the third item in the list.
***********************************
The most dangerous intruder is 192.168.3.4, which is in ip[1].
***********************************
Here is the entire list of IP addresses in the array.
192.168.3.78 192.168.3.4 192.168.3.9
后面的zsh
和ksh
检查看起来是一样的。在输出的最底部,你会看到这样的内容:
FAILED - 1 shell (sh) reports failure, 3 (bash, zsh, ksh) report success.
donnie@debian12:~$
这意味着我们的ip-2.sh
脚本在bash
、zsh
和ksh
上运行正常,但在dash
上不能运行。
但是,尽管shall
非常酷,它也并不完美。为了说明这一点,创建一个fly.sh
脚本,像这样:
#!/bin/sh
#
echo -e "I want to fly to the \v moon."
printf "%b\n" "I want to fly to the \v moon."
这只是一个简单的小脚本,演示了使用echo -e
时在脚本中遇到的问题,正如我在几页前向你展示的那样。当我使用shall
测试fly.sh
时,会发生这样的情况:
donnie@debian12:~$ shall fly.sh
✓ sh@ (-> dash) [0.00s]
-e I want to fly to the
moon.
I want to fly to the
moon.
✓ bash [0.00s]
I want to fly to the
moon.
I want to fly to the
moon.
. . .
OK - All 4 shells (sh, bash, zsh, ksh) report success.
donnie@debian12:~$
所以,shall
表示脚本在所有四个 Shell 上都运行正确。但是,在输出的顶部,你会看到-e
在echo
输出中出现了dash
,这就是我之前向你展示的问题。关键是,当你使用shall
时,不要仅仅依赖输出底部状态行所显示的内容。查看所有 Shell 的输出,确保输出是你真正想看到的。
shall
的唯一其他问题其实并不在shall
本身,而是在sh
。记住,sh
在不同的 Linux、BSD 和 Unix 发行版上指向不同的 Shell。你可以在这些发行版上安装shall
,只要你能够在它们上安装bash
。然后,只需将shall
脚本复制到你想要测试sh
脚本的机器上。
shall
脚本中嵌入了一个手册页面。要查看它,只需执行:
shall --man
好的,我想这一章就到此为止。让我们总结一下并继续下一章。
总结
如果你需要编写可以在各种 Linux、Unix 或类 Unix 操作系统上运行的脚本,脚本的可移植性非常重要。我首先向你展示了如何在各种 BSD 类发行版上安装 bash,并确保你的脚本能够在这些系统上找到bash
。之后,我解释了 POSIX 标准及其必要性。然后,我展示了一些 bash 特性和一些可以测试脚本的实用工具。
在下一章,我们将讨论 Shell 脚本的安全性。到时候见。
问题
-
对于 Shell 脚本编写者,遵循 POSIX 标准的最重要原因是什么?
-
为了确保脚本仅在 Linux 操作系统上运行。
-
确保脚本能够在各种 Linux、Unix 和类似 Unix 的操作系统上运行。
-
确保所有操作系统都运行
bash
。 -
确保脚本能够使用
bash
的高级功能。
-
-
以下哪项是动态代码检查器?
-
checkbashisms
-
shellcheck
-
shall
-
will
-
-
以下关于
sh
的说法哪项正确?-
在每个操作系统上,
sh
总是 Bourne shell。 -
在每个操作系统上,
sh
都是一个指向bash
的符号链接。 -
sh
在不同操作系统上代表不同的 shell。
-
-
你想解决一个数学问题,并将其值赋给一个变量。你会使用以下哪种构造来确保最佳的可移植性?
-
var=$(3*4)
-
var=$[3*4]
-
var=$[[3*4]]
-
var=$((3*4))
-
进一步阅读
-
UNIX 与 Linux:它们有什么不同?:
www.maketecheasier.com/unix-vs-linux-how-are-they-different/
-
“#!/usr/bin/env bash”和“#!/usr/bin/bash”之间有什么区别?:
stackoverflow.com/questions/16365130/what-is-the-difference-between-usr-bin-env-bash-and-usr-bin-bash
-
编写不仅仅是 Bash 的 Bash 脚本:检查 Bashisms 和使用 Dash:
www.bowmanjd.com/bash-not-bash-posix/
-
简短的 POSIX 推广:
www.usenix.org/system/files/login/articles/login_spring16_09_tomei.pdf
-
POSIX 指南:
www.baeldung.com/linux/posix
-
如何测试 shell 脚本的 POSIX 合规性?:
unix.stackexchange.com/questions/48786/how-can-i-test-for-posix-compliance-of-shell-scripts
-
使 Unix Shell 脚本符合 POSIX 标准:
stackoverflow.com/questions/40916071/making-unix-shell-scripts-posix-compliant
-
Rich 的 sh(POSIX shell)技巧:
www.etalabs.net/sh_tricks.html
-
Dash–ArchWiki:
wiki.archlinux.org/title/Dash
-
POSIX shell 备忘单:
steinbaugh.com/posts/posix.html
-
是否有符合 POSIX.2 最小要求的 shell?:
stackoverflow.com/questions/11376975/is-there-a-minimally-posix-2-compliant-shell
答案
-
b
-
c
-
c
-
d
加入我们的 Discord 社区!
和其他用户、Linux 专家以及作者本人一起阅读这本书。
提出问题,为其他读者提供解决方案,通过“问我任何问题”环节与作者聊天,等等。扫描二维码或访问链接加入社区。
第二十章:Shell 脚本安全性
到目前为止,我们还没有讨论很多关于 Shell 脚本安全性的话题。坦白说,这是因为它可能是你从来不需要担心的事情。我的意思是,很多时候你编写的脚本只是供自己使用,你可能只是从自己本地机器的主目录运行它们。即使你是一个需要创建执行某些管理任务的脚本的管理员,你也可能只需要在自己的主目录中运行它们,或者与其他受信任的管理员共享他们并从各自的主目录中运行它们。在这些情况下,Shell 脚本安全性不一定是一个大问题。
然而,有时你可能需要与其他用户或管理员共享你的脚本,而你并不完全信任他们。在这种情况下,Shell 脚本安全性是极其重要的,并且应该成为你脚本编写的主要关注点。例如,你可能有一些管理脚本,需要将其放入一个只有有限权限的管理员才能访问的目录。在这些情况下,你还需要确保没有人可以修改它,并且只有某些指定的管理员可以执行它。你还需要设计脚本,以防止恶意用户利用它进行命令注入攻击。
本章的主题包括:
-
控制对脚本的访问
-
理解 SUID 和 SGID 的注意事项
-
避免敏感数据泄露
-
理解
eval
的命令注入 -
理解路径安全
如果你准备好了,我们就开始吧。
技术要求
我将主要使用 Fedora Server 和 Ubuntu Server 虚拟机进行工作。不过,我展示的技巧应该适用于几乎任何 Linux 发行版。我还会在 FreeBSD 14 虚拟机和 OpenIndiana 虚拟机上展示一些内容。
和往常一样,你可以通过以下方式获取脚本:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
控制对脚本的访问
你创建的大多数脚本可能仅供自己或同事使用。或者,它们可能是公开分发的。在所有这些情况下,你可能不需要担心对脚本设置任何访问控制。
但是,有时你可能需要创建只能由特定人员访问的脚本。你可以使用的这些方法包括:
-
分配
sudo
权限 -
分配访问控制列表
-
混淆明文脚本
我们将首先使用sudo
进行查看。
分配 sudo 权限
sudo
是一个便捷的安全功能,默认安装在 macOS、OpenIndiana 和大多数 Linux 发行版中。它也可以安装在大多数 BSD 类型的发行版上,或任何未默认安装它的 Linux 发行版中。使用sudo
的最常见方式是允许非特权用户以 root 用户权限运行程序。(你也可以使用sudo
让用户以其他非 root 用户的权限运行程序,比如数据库用户。不过我现在不打算讲这些内容,因为我想保持简单。)
现在,看看是什么让sudo
如此酷。假设你想让某个特定的用户以 root 用户权限运行一个特定的程序。使用sudo
,你无需给该用户 root 用户的密码。相反,你只需为该程序配置该用户的sudo
权限,然后让该用户在运行程序时输入自己的密码。我们来看一下是如何实现的。
实践实验 – 配置 sudo
在本实验中,你将创建一个简单的脚本,并配置sudo
,使得只有指定的用户能够运行它。
-
创建
sudo_demo1.sh
脚本,如下所示:#!/bin/bash echo "This is a demo of sudo privileges."
-
将脚本复制到
/usr/local/sbin/
目录:donnie@ubuntu2404:~$ sudo cp sudo_demo1.sh /usr/local/sbin/ donnie@ubuntu2404:~$ ls -l /usr/local/sbin/sudo_demo1.sh -rw-r--r-- 1 root root 55 Jul 6 19:24 /usr/local/sbin/sudo_demo1.sh donnie@ubuntu2404:~$
如你所见,将该脚本复制到/user/local/sbin/
后,文件的所有权会自动更改为 root 用户。
-
设置
sudo_demo1.sh
文件的权限,使得只有 root 用户能够访问它:donnie@ubuntu2404:~$ cd /usr/local/sbin/ donnie@ubuntu2404:/usr/local/sbin$ sudo chmod 700 sudo_demo1.sh donnie@ubuntu2404:/usr/local/sbin$ ls -l sudo_demo1.sh -rwx------ 1 root root 55 Jul 6 19:24 sudo_demo1.sh donnie@ubuntu2404:/usr/local/sbin$
注意,在chmod 700
命令中,7 为 root 用户分配了读取、写入和执行权限。两个 0 则从组和其他用户那里移除了所有权限。但,如何在用户位置获得 7 的值呢?下面是它的详细解释:
读取权限的值为 4。
写入权限的值为 2。
执行权限的值为 1。
在这种情况下,我们希望用户具有完全的读取、写入和执行权限。将这三种权限的值相加得到的值为 7\。
(我知道这是一个相当简略的解释,但现在请你耐心点。)
- 为 Horatio 创建一个非特权用户帐户。
在 Debian/Ubuntu 中,执行:
sudo adduser horatio
在 Fedora 和其他 Red Hat 类型的发行版中:
sudo useradd horatio
sudo passwd horatio
-
开始配置
sudo
,请输入以下命令:sudo visudo
该命令将在nano
、vi
或vim
中打开/etc/sudoers
文件,具体取决于你所使用的操作系统。除此之外,操作步骤是一样的。
-
向下滚动,直到看到以下这一行:
root ALL=(ALL:ALL) ALL
在那一行的正下方,加入以下这一行:
horatio ALL=(ALL:ALL) /usr/local/sbin/sudo_demo1.sh
使用普通文本编辑器保存文件。
我知道ALL=(ALL:ALL)
这一行看起来很困惑,但实际上它非常简单。下面是它的简要解释:
ON_HOSTS=(AS_USER:AS_GROUP_MEMBER) ALLOWED_COMMANDS
现在,以下是更具体的说明:
第一个ALL
表示指定用户可以在本地网络上的所有机器上运行此命令。你也可以选择将这个ALL
替换为特定机器或机器组的主机名,来限制此用户仅能在这些机器上运行该命令。
第二个ALL
表示指定用户可以作为所有用户运行此命令,包括 root 用户。
第三个ALL
表示 Horatio 可以作为所有组的成员运行此命令,包括 root 用户的组。(请注意,这是可选的。你也可以省略该组,并将其设置为ALL=(ALL)
。)
在root
行中,最后的ALL
表示 root 用户可以运行所有特权命令。在horatio
行中,最后的ALL
被替换为我们希望 Horatio 运行的特定命令。
-
在主机机器上打开另一个终端窗口,并使用 Horatio 的账户登录到虚拟机。
-
让 Horatio 通过首先尝试在没有
sudo
的情况下运行脚本,然后再使用sudo
,像这样测试:horatio@ubuntu2404:~$ sudo_demo1.sh -bash: /usr/local/sbin/sudo_demo1.sh: Permission denied horatio@ubuntu2404:~$ sudo sudo_demo1.sh [sudo] password for horatio: This is a demo of sudo privileges. horatio@ubuntu2404:~$
你会看到 Horatio 可以使用他的sudo
权限运行该脚本。
-
让 Horatio 尝试查看脚本的源代码,像这样:
horatio@ubuntu2404:~$ cd /usr/local/sbin horatio@ubuntu2404:/usr/local/sbin$ less sudo_demo1.sh sudo_demo1.sh: Permission denied horatio@ubuntu2404:/usr/local/sbin$ sudo less sudo_demo1.sh Sorry, user horatio is not allowed to execute '/usr/bin/less sudo_demo1.sh' as root on ubuntu2404. horatio@ubuntu2404:/usr/local/sbin$
Horatio 无法查看或编辑源代码,因为他没有适当的sudo
权限来执行此操作。他仅拥有作为 root 用户的sudo
权限,这个权限仅限于执行sudo_demo1.sh
脚本。
这就是我们介绍sudo
的内容。接下来,让我们继续介绍另一种控制脚本访问的方法。
使用访问控制列表(ACL)
如果你曾是 Windows 管理员,你可能知道 Windows 上的 NTFS 文件系统允许你对文件和目录设置非常细粒度的权限。遗憾的是,Linux、Unix 和类 Unix 系统上的文件系统并没有内建如此细粒度的访问控制。但我们可以通过使用访问控制列表(ACL)在一定程度上弥补这一缺陷。让我们看看如何在这个实践实验中做到这一点。
实践实验——在 Linux 上为 Horatio 设置 ACL
在这个实验中,我们将创建另一个脚本,只有 Horatio 有权限运行。为了简化操作,直接使用你之前用于实验的虚拟机,这样你就无需再创建另一个用户账户。
-
你会发现你在 Fedora 虚拟机上设置 ACL 所需的一切已经安装好。如果你使用的是 Debian/Ubuntu 类型的机器,你可能需要通过以下方式安装
acl
软件包:sudo apt install acl
-
登录到你自己的普通用户账户,并创建
acl_demo.sh
脚本,像这样:#!/bin/bash echo "This is a demo of ACLs."
-
将脚本复制到
/usr/local/sbin/
目录,并注意所有权如何自动更改为 root 用户:donnie@ubuntu2404:~$ vim acl_demo.sh donnie@ubuntu2404:~$ sudo cp acl_demo.sh /usr/local/sbin/ [sudo] password for donnie: donnie@ubuntu2404:~$ cd /usr/local/sbin/ donnie@ubuntu2404:/usr/local/sbin$ ls -l acl_demo.sh -rw-r--r-- 1 root root 44 Jul 24 20:56 acl_demo.sh donnie@ubuntu2404:/usr/local/sbin$
-
为了使 ACL 生效,你需要从组和其他用户中移除所有权限,像这样:
donnie@ubuntu2404:/usr/local/sbin$ sudo chmod 700 acl_demo.sh donnie@ubuntu2404:/usr/local/sbin$ ls -l acl_demo.sh -rwx------ 1 root root 44 Jul 24 20:56 acl_demo.sh donnie@ubuntu2404:/usr/local/sbin$
这是因为使用 ACL 的关键在于防止那些没有被设置 ACL 的用户访问文件。
-
使用
getfacl
验证是否设置了 ACL:donnie@ubuntu2404:/usr/local/sbin$ getfacl acl_demo.sh # file: acl_demo.sh # owner: root # group: root user::rwx group::--- other::--- donnie@ubuntu2404:/usr/local/sbin$
-
在另一个终端窗口中,登录 Horatio 的帐户,并尝试运行
acl_demo.sh
脚本,如下所示:horatio@ubuntu2404:~$ acl_demo.sh -bash: /usr/local/sbin/acl_demo.sh: Permission denied horatio@ubuntu2404:~$
你看到 Horatio 已被拒绝访问。
-
回到你自己的终端窗口。创建一个 ACL,使 Horatio 在
acl_demo.sh
脚本上拥有读取和执行权限,如下所示:donnie@ubuntu2404:/usr/local/sbin$ sudo setfacl -m u:horatio:rx acl_demo.sh donnie@ubuntu2404:/usr/local/sbin$ ls -l acl_demo.sh -rwxr-x---+ 1 root root 44 Jul 24 20:56 acl_demo.sh donnie@ubuntu2404:/usr/local/sbin$
以下是命令的详细解释:
-
-m
:这意味着修改现有的 ACL。如果尚未创建 ACL,它还会创建一个。 -
u:horatio
:这表示我们正在为用户horatio
创建一个 ACL。 -
rx
:这意味着我们为指定用户授予该文件的读取和执行权限。 -
注意文件权限设置末尾的
+
。这表示已经创建了一个 ACL。
-
使用
getfacl
验证 ACL 是否已正确创建:donnie@ubuntu2404:/usr/local/sbin$ getfacl acl_demo.sh # file: acl_demo.sh # owner: root # group: root user::rwx user:horatio:r-x group::--- mask::r-x other::--- donnie@ubuntu2404:/usr/local/sbin$
user:horatio:r-x
行表示已为 Horatio 创建了 ACL。
-
回到 Horatio 的终端窗口,让他尝试运行脚本:
horatio@ubuntu2404:~$ acl_demo.sh This is a demo of ACLs. horatio@ubuntu2404:~$
这次,Horatio 达到了酷炫的境界。
-
使用 ACL 而不是
sudo
有一个缺点。即,sudo
会自动假设用户需要读取一个 Shell 脚本才能执行它。因此,sudo
允许用户在没有明确设置读取权限的情况下执行脚本。这意味着用户无法使用任何实用工具(如cat
或less
)查看脚本文件的内容。而使用 ACL 时,必须显式地为脚本设置读取权限和执行权限,才能让某人执行它。所以,使用 ACL 时,你不能防止用户查看文件内容。你可以通过让 Horatio 查看文件来证明这一点,如下所示:horatio@ubuntu2404:~$ cat /usr/local/sbin/acl_demo.sh #!/bin/bash echo "This is a demo of ACLs." horatio@ubuntu2404:~$
这里的结论是,如果你想防止用户查看脚本内容,可以设置适当的sudo
权限,而不是使用 ACL。另一方面,如果你不介意用户查看脚本的源代码,那么使用 ACL 肯定是一个可行的选项。
本章篇幅有限,无法详细介绍权限设置、sudo
和 ACL 的内容。如果你需要更多信息,我在我的《精通 Linux 安全与加固》书中有专门的章节介绍每个主题。
这就是sudo
和 Linux 上的 ACL 的全部内容。现在让我们看看如何在 FreeBSD 14 上实现。
实操实验 – 在 FreeBSD 14 上为 Horatio 设置 ACL
在 FreeBSD 14 上,你会发现所有用于创建 ACL 的工具已经安装好了。我们开始吧。
-
通过以下操作创建 Horatio 的用户帐户:
donnie@freebsd14:~ $ sudo adduser
FreeBSD 的adduser
命令是交互式的,类似于 Debian 和 Ubuntu 上的adduser
命令。调用它之后,你只需按照提示输入 Horatio 的信息。以下是操作界面的样子:
图 20.1:向 FreeBSD 添加用户帐户
如果你对全名字段感到好奇,那只是因为 Horatio 真的是一只最近经常光顾我的黑猫。
-
使用你在 Linux 实验中使用的相同的
acl_demo.sh
脚本。将它复制到/usr/local/sbin/
目录,并验证其所有权已更改为 root 用户:donnie@freebsd14:~ $ sudo cp acl_demo.sh /usr/local/sbin/ Password: donnie@freebsd14:~ $ cd /usr/local/sbin/ donnie@freebsd14:/usr/local/sbin $ ls -l acl_demo.sh -rw-r--r-- 1 root wheel 44 Jul 29 15:42 acl_demo.sh donnie@freebsd14:/usr/local/sbin $
-
为
acl_demo.sh
应用 700 权限设置。这将意味着 root 用户将具有读、写和执行权限,而 组 和 其他用户 没有权限。donnie@freebsd14:/usr/local/sbin $ sudo chmod 700 acl_demo.sh donnie@freebsd14:/usr/local/sbin $ ls -l acl_demo.sh -rwx------ 1 root wheel 44 Jul 29 15:42 acl_demo.sh donnie@freebsd14:/usr/local/sbin $
-
在另一个终端窗口中,登录到 FreeBSD 机器上 Horatio 的用户账户。然后,让他尝试运行
acl_demo.sh
脚本。horatio@freebsd14:~ $ acl_demo.sh -sh: acl_demo.sh: Permission denied horatio@freebsd14:~ $ sudo acl_demo.sh Password: horatio is not in the sudoers file. This incident has been reported to the administrator. horatio@freebsd14:~ $
-
返回到你自己的终端窗口,并像这样为 Horatio 设置 ACL:
donnie@freebsd14:/usr/local/sbin $ sudo setfacl -m u:horatio:rx:allow acl_demo.sh Password: donnie@freebsd14:/usr/local/sbin $
请注意,这次命令略有不同,因为 Linux 使用 网络文件系统版本 4 (NFSv4) 样式的 ACL,而 FreeBSD 使用 POSIX 样式的 ACL。不过,这并不是一个大差异。只是 FreeBSD 需要在
setfacl
命令中添加allow
。 -
验证 ACL 是否已正确设置:
donnie@freebsd14:/usr/local/sbin $ getfacl acl_demo.sh # file: acl_demo.sh # owner: root # group: wheel user:horatio:r-x-----------:-------:allow owner@:rwxp--aARWcCos:-------:allow group@:------a-R-c--s:-------:allow everyone@:------a-R-c--s:-------:allow donnie@freebsd14:/usr/local/sbin $
你可以看到,Horatio 确实对这个脚本拥有读和执行权限。
-
返回到 Horatio 的终端,让他尝试运行
acl_demo.sh
命令:horatio@freebsd14:~ $ acl_demo.sh This is a demo of ACLs. horatio@freebsd14:~ $
它有效,这意味着 Horatio 现在在 FreeBSD 上实现了酷炫。
接下来,让我们在 OpenIndiana 上试试看。
实验操作 – 在 OpenIndiana 上为 Horatio 设置 ACL
在 OpenIndiana 上执行此操作会大不相同,因为它不使用 setfacl
或 getfacl
来管理 ACL。相反,它使用 chmod
来管理正常的权限设置和 ACL。你将再次使用你在之前实验中使用的相同的 acl_demo.sh
脚本。
-
创建 Horatio 的用户账户,如下所示:
donnie@openindiana:~$ sudo useradd -m horatio donnie@openindiana:~$ sudo passwd horatio donnie@openindiana:~$
-m
选项告诉 useradd
创建新用户的主目录。在 OpenIndiana 版本的 useradd
中,通常这是你唯一需要的选项开关。
-
OpenIndiana 上没有
/usr/local/
目录,因此只需将acl_demo.sh
脚本复制到/usr/sbin/
目录。然后,验证所有权已更改为 root 用户。donnie@openindiana:~$ sudo cp acl_demo.sh /usr/sbin/ donnie@openindiana:~$ cd /usr/sbin/ donnie@openindiana:/usr/sbin$ ls -l acl_demo.sh -rw-r--r-- 1 root root 44 Jul 29 16:14 acl_demo.sh donnie@openindiana:/usr/sbin$
-
将
acl_demo.sh
文件的权限设置更改为 700,如你在之前的实验中所做的:donnie@openindiana:/usr/sbin$ sudo chmod 700 acl_demo.sh donnie@openindiana:/usr/sbin$ ls -l acl_demo.sh -rwx------ 1 root root 44 Jul 29 16:14 acl_demo.sh donnie@openindiana:/usr/sbin$
-
在另一个终端窗口中,登录到 Horatio 的账户。让他尝试运行
acl_demo.sh
脚本:horatio@openindiana:~$ acl_demo.sh -bash: /usr/sbin/acl_demo.sh: Permission denied horatio@openindiana:~$ sudo acl_demo.sh Password: horatio is not in the sudoers file. This incident has been reported to the administrator. horatio@openindiana:~$
-
现在,为 Horatio 应用一个 ACL,授予他对
acl_demo.sh
脚本的读和执行权限:donnie@openindiana:/usr/sbin$ sudo chmod A+user:horatio:rx:allow acl_demo.sh Password: donnie@openindiana:/usr/sbin$
请注意,不同于使用 setfacl -m u:horatio
,OpenIndiana 使用 chmod A+user:horatio
。这里的 A+
仅表示我们正在添加一个 ACL。
-
返回到 Horatio 的终端,让他尝试运行脚本:
horatio@openindiana:~$ acl_demo.sh This is a demo of ACLs. horatio@openindiana:~$
是的,确实如此。即使在 OpenIndiana 上,Horatio 也实现了酷炫。
如果你需要了解 OpenIndiana 的管理,你会发现 OpenIndiana 网站上的官方文档相当缺乏。幸运的是,OpenIndiana 是 Oracle 的 Solaris 操作系统的一个分支,这意味着你可以使用官方的 Solaris 文档。 (我已经在 进一步阅读 部分提供了相关页面的链接。)
我认为这大致涵盖了 ACLs。让我们继续探讨另一种控制对脚本访问权限的方法。
混淆明文脚本
您还可以使用shc
实用程序隐藏 shell 脚本的内容,防止任何人篡改它们,将您的脚本转换为混淆的可执行二进制文件。而且,您可以通过shc
做一些使用sudo
或 ACLs 无法做到的事情。具体来说,shc
允许您:
-
创建一个只能在一台机器上运行的可执行文件。
-
为可执行文件设置过期日期。
-
创建无法使用诸如
strace
或truss
等调试工具追踪的可执行文件。(稍后我会解释这些工具。)
让我们开始安装shc
。
安装 shc
在 Linux、FreeBSD 和 macOS 上安装很简单。操作如下:
在 Fedora 上:
sudo dnf install shc gcc
在 Debian/Ubuntu 上:
sudo apt install shc gcc
注意,在 Linux 上,您还需要安装gcc
包,以便具有可以与shc
一起工作的 C 编译器。在 FreeBSD 上,C 编译器作为操作系统的一部分安装。
在 FreeBSD 上:
sudo pkg install shc
在安装了 Homebrew 的 macOS 上:
brew install shc
遗憾的是,shc
在 OpenIndiana 上不可用。
实验室实践 – 使用 shc
在此实验中,您将在您的 Fedora 虚拟机上创建supersecret.sh
脚本。另外,请启动您的 Debian/Ubuntu 虚拟机,以便可以在其上测试编译后的脚本。
-
使用
shc
很简单。为了演示,让我们创建supersecret.sh
脚本,就像这样:#!/bin/bash cat << secret This document contains secrets that could impact the entire world! Please don't let it fall into the wrong hands. secret
这只是一个简单的here document,打印出一个消息,看起来像这样:
donnie@fedora:~$ ./supersecret.sh
This document contains secrets that could impact the entire world!
Please don't let it fall into the wrong hands.
donnie@fedora:~$
-
下一个步骤是将 shell 脚本转换为混淆的二进制文件,如下所示:
donnie@fedora:~$ shc -f supersecret.sh -o supersecret donnie@fedora:~$
正如您所见,我使用-f
选项指向我要混淆的 shell 脚本,并使用-o
选项将混淆后的二进制文件保存为指定的文件名。此操作还会创建一个 C 语言源文件,如您所见:
donnie@fedora:~$ ls -l supersecret*
-rwxrwxr-x. 1 donnie donnie 15984 Jul 1 16:55 supersecret
-rwxr--r--. 1 donnie donnie 149 Jul 1 16:47 supersecret.sh
-rw-r--r--. 1 donnie donnie 18485 Jul 1 16:55 supersecret.sh.x.c
donnie@fedora:~$
shc
的工作方式首先是创建这个 C 源代码文件,然后调用 C 编译器将 C 源代码编译成二进制文件。您可以尝试使用cat
打开二进制文件来证明这一点,如下所示:
donnie@fedora:~$ cat supersecret
@@@@@@@@@@

@@a
a
@ @dd-=@=@>>@88@8@@xx@x@DDStd88@8@@Ptd` ` @` @llQtdRtd-=@/lib64/ld-linux-x86-64.so.20GNUGNUu@W{gGNU0@ĉkĹ@9J<5Q.u n`XC{!D!DDg Dmallocgetpidstat__libc_start_mainfprintfputenvmemsetstrlenstrdupgetenvmemcmpsprintfexecvpstderrmemcpyato llstrerror__errno_locationexit__isoc99_sscanffwritecalloctime__environlibc.so.6GLIBC_2.7GLIBC_2.14GLIBC_2.33GLIBC_2.34GLIBuiart__ii?@?@
. . .
donnie@fedora:~$
如果您能读懂这些内容,那么您比我更强。
-
尝试运行这个新的
supersecret
二进制文件,您会发现它的工作方式与脚本一样:donnie@fedora:~$ ./supersecret This document contains secrets that could impact the entire world! Please don't let it fall into the wrong hands. donnie@fedora:~$
好吧,让我们真实一点。您不会对一个只打印消息的简单脚本进行混淆。我的意思是,任何能执行这个二进制文件的人仍然可以看到消息。因此,让我们只是说您的脚本包含了很多额外的代码或数据,您希望将其隐藏,甚至包括脚本的授权用户在内。在这种情况下,shc
绝对是一个有用的工具。
-
默认情况下,
shc
创建的二进制文件只能在创建它们的机器上运行。例如,让我们看看我将在这台 Fedora 机器上创建的supersecret
二进制文件转移到 Ubuntu 机器上会发生什么:donnie@ubuntu2404:~$ ./supersecret ./supersecret: 3(i'(qX5@ӣZBhas expired! Please contact your provider jahidulhamid@yahoo.com donnie@ubuntu2404:~$
这绝对是一个方便的安全特性,因为它允许你控制程序可以运行的地方。所以,如果恶意黑客不小心找到并下载了你的程序,他们将无法运行它。唯一的缺点是,我们会看到 shc
内置的默认消息 Please contact your provider. . .
。
-
默认的
Please contact your provider. . .
消息对我们来说没有意义,因为它提供了一个虚假的联系地址。在 Fedora 机器上,通过添加-m
选项和自定义消息来解决这个问题,像这样:donnie@fedora:~$ shc -f supersecret.sh -m "You cannot run this program on this machine." -o supersecret donnie@fedora:~$
-
将新的
supersecret
二进制文件传输到 Debian/Ubuntu 机器上,然后尝试运行它。你现在应该能看到你自定义的消息,像这样:donnie@ubuntu2404:~$ ./supersecret ./supersecret: ]b+ !(TR0#50fo_7has expired! You cannot run this program on this machine. donnie@ubuntu2404:~$
当然,如果你真的想要的话,你也可以添加你自己的联系地址。
-
不过,有时你可能希望创建能够在任何与创建它们的机器相同操作系统上运行的二进制文件。为了做到这一点,只需使用
-r
选项稍微放宽一些安全性,像这样:donnie@fedora:~$ shc -rf supersecret.sh -o supersecret donnie@fedora:~$
当我将这个新的二进制文件传输到我的 Ubuntu 机器上时,它将正常运行,正如你在这里看到的:
donnie@ubuntu2404:~$ ./supersecret
This document contains secrets that could impact the entire world!
Please don't let it fall into the wrong hands.
donnie@ubuntu2404:~$
-
另外,理解
shc
会创建只能在某种操作系统上运行的二进制文件。为了查看其工作原理,让我们将我在 Fedora 机器上创建的二进制文件传输到 FreeBSD 机器上。下面是当我尝试运行它时发生的情况:donnie@freebsd14:~ $ ./supersecret ELF binary type "0" not known. -sh: ./supersecret: Exec format error donnie@freebsd14:~ $
但是,如果你将原始脚本传输到 FreeBSD 机器上,然后在其上运行 shc
,你会得到一个能在 FreeBSD 上运行的二进制文件。
好消息是,shc
在 FreeBSD 上的工作方式与在 Linux 上完全相同。所以,你甚至不需要修改你的 shc
命令。
-
接下来的
shc
选项是-e
选项,它允许你为程序设置过期日期。只需按日-月-年(dd/mm/yyyy)格式指定过期日期,像这样:donnie@fedora:~$ shc -e 02/07/2024 -rf supersecret.sh -m "This binary file has expired. Contact me at donnie@any.net if you really need to run it." -o supersecret donnie@fedora:~$
我现在是在 2024 年 7 月 1 日做这个,所以我得等到明天才能看到这个是否真的有效。那么现在先说再见,明天见。
-
好的,现在是 2024 年 7 月 2 日,我回来了。让我们看看这个
supersecret
二进制文件是否还有效:donnie@fedora:~$ ./supersecret ./supersecret: has expired! This binary file has expired. Contact me at donnie@any.net if you really need to run it. donnie@fedora:~$
很棒,过期日期选项工作得非常好。
记住:即使你通过 shc
混淆了脚本,仍然需要使用 sudo
或 ACL 来控制谁可以在特定机器上执行它们。
现在,让我们来看看如何让我们的可执行文件无法被追踪。
实验室练习 – 创建无法追踪的可执行文件
正如你将在下一章看到的,我们有多种跟踪工具可以帮助程序员调试程序。这些工具包括适用于 Linux 系统的 strace
,适用于 FreeBSD 的 truss
,以及适用于 macOS 的 dtrace
或 dtruss
。对于 Linux,还有 ltrace
,它可以跟踪对编程库的调用。
strace
、dtrace
、truss
和 dtruss
工具可以追踪程序所执行的 系统调用。解释系统调用最简单的方式是,它们是程序用来与操作系统内核进行通信的机制。
对我们来说的问题是,恶意或非恶意的未授权人员也可以利用这些工具提供的信息来反向工程你编译的脚本。这可能让这些未授权方看到你不希望他们看到的秘密。
我们不希望未授权的人看到哪些信息?以下是两个例子:
你的程序访问的敏感文件的名称和位置:这些文件可能会泄露给未授权的人员重要信息。
嵌入的密码:即使你加密了密码,使用strace
、truss
、dtrace
或dtruss
在你编译的二进制文件上,也能泄露明文密码。(你很快会在避免敏感数据泄漏部分看到这一点。)
不用说,这两种情况都可能代表一个严重的安全问题。所以,为了确保安全,养成将你的shc
二进制文件设为不可追踪的习惯,就像我接下来要展示的那样。
这是它的工作原理:
-
在 Fedora 机器上,安装
strace
和ltrace
,像这样:donnie@fedora:~$ sudo dnf install strace ltrace
-
在 Fedora 机器上,创建一个新的
supersecret
二进制文件,去除过期日期和自定义消息。donnie@fedora:~$ shc -f supersecret.sh -o supersecret donnie@fedora:~$
-
创建一个包含
strace
数据的文件,关于supersecret
二进制文件,像这样:donnie@fedora:~$ strace ./supersecret 2> supersecret1_trace.txt This document contains secrets that could impact the entire world! Please don't let it fall into the wrong hands. donnie@fedora:~$
请注意,strace
的输出被发送到stderr
,这就是为什么你需要使用2>
重定向符号。
-
使用
less
打开supersecret1_trace.txt
文件。你会看到一堆乱七八糟的内容,看起来像这样:execve("./supersecret", ["./supersecret"], 0x7ffd6152bd00 /* 58 vars */) = 0 brk(NULL) = 0x14345000 arch_prctl(0x3001 /* ARCH_??? */, 0x7ffe27faa7c0) = -1 EINVAL (Invalid argument) access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 . . . . . .
大部分情况下,你需要是 C 语言内核程序员,才能理解发生了什么。但是,正如我们即将看到的,如果你知道如何查找这些信息,你可能会发现一些非常重要的内容。
-
计算
supersecret1_trace.txt
文件中的行数,像这样:donnie@fedora:~$ wc -l supersecret1_trace.txt 297 supersecret1_trace.txt donnie@fedora:~$
所以,supersecret1_trace.txt
文件中有 297 行输出。
请注意,对于所有这些例子,你在自己的机器上可能会得到不同数量的输出行。
-
对
supersecret
二进制文件执行ltrace
操作,并将输出保存到supersecret1_ltrace.txt
文件中。然后,像这样计算supersecret1_ltrace.txt
文件中的行数:donnie@fedora:~$ ltrace ./supersecret 2> supersecret1_ltrace.txt This document contains secrets that could impact the entire world! Please don't let it fall into the wrong hands. donnie@fedora:~$ wc -l supersecret1_ltrace.txt 7270 supersecret1_ltrace.txt donnie@fedora:~$
你会看到supersecret1_ltrace
文件中有 7270 行。
-
创建一个新的
supersecret
二进制文件。不过这次,使用-U
选项将该二进制文件设置为不可追踪。donnie@fedora:~$ shc -Uf supersecret.sh -o supersecret donnie@fedora:~$
在执行此操作之前,你不必删除原始二进制文件,因为此新命令会覆盖原始的二进制文件。
-
再次在
supersecret
二进制文件上运行strace
,将输出保存到supersecret2_tract.txt
文件中。donnie@fedora:~$ strace ./supersecret 2> supersecret2_trace.txt Killed donnie@fedora:~$
这一次,strace
被阻止执行其功能。
-
即使使用了
-U
选项,你仍然会看到一些输出发送到输出文件。不过,这些输出会比没有使用-U
时少得多。通过计算supersecret2_trace.txt
文件中的行数来验证这一点。donnie@fedora:~$ wc -l supersecret2_trace.txt 34 supersecret2_trace.txt donnie@fedora:~$
这次,输出只有 34 行,比我们在没有 -U
开关时的 297 行要少得多。所以,这进一步证明了 -U
选项可以防止 strace
操作成功。
-
现在,对
supersecret
二进制文件进行ltrace
,并将输出保存到supersecret2_ltrace.txt
文件中:donnie@fedora:~$ ltrace ./supersecret 2> supersecret2_ltrace.txt donnie@fedora:~$
-
统计
supersecret2_ltrace.txt
文件中的行数:donnie@fedora:~$ wc -l supersecret2_ltrace.txt 4 supersecret2_ltrace.txt donnie@fedora:~$
哇!这次输出文件只有四行。这与我们在没有 -U
选项时的 7270 行相差甚远。
在 FreeBSD 上,创建混淆二进制文件的 shc
命令和你在 Linux 上看到的一样。但是,要对 FreeBSD 二进制文件进行系统调用追踪,使用 truss
而不是 strace
,像这样:
donnie@freebsd14:~ $ truss ./supersecret 2> supersecret1.truss.txt
就我所知,FreeBSD 上并没有与 Linux ltrace
命令等效的命令。
在 macOS 上,你可以使用 dtrace
和 dtruss
命令。但是,要使用它们,你必须将机器启动到恢复模式并禁用系统完整性保护(SIP)。除非你绝对、一定需要这么做,否则我不建议这样操作。
你还会发现 dtrace
在 OpenIndiana 上是可用的。但是,由于 OpenIndiana 上没有 shc
,我就不再讨论它了。
接下来,让我们看看能不能解密这个脚本。
解密 shc 二进制文件
很多年前,当我在第一次教授 shell 脚本课程时,我向学生们展示了 shc
,那时要破解 shc
算法并将二进制文件转换回原始 shell 脚本是相对容易的。所以当时,shc
不是一个非常安全的选项。现在,由于 shc
开发者利用了现代操作系统内核的改进,破解 shc
要困难得多了。但是,还是有一些工具应该能破解 shc
,或者至少它们的开发者是这么说的。
这可能有些剧透,但我还是决定分享。
正如你很快会看到的,shc
创建的二进制文件目前对这种类型的破解攻击是安全的。不过,总有可能有人会在未来开发出更强的破解工具。所以,把这一部分看作是一个框架,用来对未来可能出现的破解工具进行测试。
让我们快速了解一下这些工具,好吗?
实验室实操:测试 UnSHc
这个实验室将帮助你熟悉 UnSHc 工具。我正在使用 Fedora Server 虚拟机,但你也可以使用其他 Linux 虚拟机或 FreeBSD 虚拟机,随你选择。
-
通过以下命令从 Github 下载 UnSHc:
donnie@fedora-server:~$ git clone https://github.com/yanncam/UnSHc.git
-
下载完成后,只需要几秒钟,
cd
进入UnSHc/latest/
目录,然后将unshc.sh
脚本复制到/usr/local/bin/
目录:donnie@fedora-server:~$ cd UnSHc/latest/ donnie@fedora-server:~/UnSHc/latest$ ls unshc.sh donnie@fedora-server:~/UnSHc/latest$ sudo cp unshc.sh /usr/local/bin/ donnie@fedora-server:~/UnSHc/latest$
-
在
UnSHc/sample/
目录中,你会看到一个示例的 shell 脚本,并且有相应的 C 源代码文件和编译后的二进制文件。
请注意,这个 C 源代码文件和二进制文件是由非常旧版本的 shc
生成的。(我提到这一点的原因马上就会明了。)
将二进制文件 test.sh.x
复制到你的主目录:
donnie@fedora-server:~/UnSHc/sample$ ls
test.sh test.sh.x test.sh.x.c
donnie@fedora-server:~/UnSHc/sample$ cp test.sh.x ~
donnie@fedora-server:~/UnSHc/sample$ cd
donnie@fedora-server:~$
-
尝试解密
test.sh.x
文件,如下所示:donnie@fedora-server:~$ unshc.sh test.sh.x
输出应该类似于这样:
图 20.2:使用 UnSHc
-
验证
test.sh.x
二进制文件是否已成功解密,如下所示:donnie@fedora-server:~$ ls -l test* -rw-r--r--. 1 donnie donnie 193 Jul 5 14:04 test.sh -rw-r--r--. 1 donnie donnie 11440 Jul 5 14:01 test.sh.x donnie@fedora-server:~$ cat test.sh #!/bin/bash # This script is very critical ! echo "I'm a super critical and private script !" PASSWORDROOT="SuPeRrOoTpAsSwOrD"'; myService --user=root --password=$PASSWORDROOT > /dev/null 2>&1 donnie@fedora-server:~$
好的,这样就能正常工作了。
-
接下来,尝试解密一个你用当前版本的
shc
创建的二进制文件。(你可以使用在 使用 shc 实验中创建的二进制文件,或者你也可以创建一个新的。)如下所示:donnie@fedora-server:~$ unshc.sh supersecret . . . . . . UnSHc is used to decrypt script encrypted with SHc Original idea from Luiz Octavio Duarte (LOD) Updated and modernized by Yann CAM - SHc : [http://www.datsi.fi.upm.es/~frosal/] - UnSHc : [https://www.asafety.fr/unshc-the-shc-decrypter/] ------------------------------ [*] Input file name to decrypt [supersecret] [-] Unable to define arc4() call address... donnie@fedora-server:~$
正如你看到的,这次操作失败了。所以,二进制文件没有被解密,原始的 shell 脚本也没有被重建。
- 为了查看为什么这次操作失败,进入
UnSHc/
目录并打开README.md
文件。在文件的顶部附近,你会看到这一段:
由于自 shc
4.0.3 以来出现了许多问题,似乎需要进行澄清。在 shc
4.0.3 中,已经加入了许多结构性更改,使得 shc
现在利用了 Linux 内核本身提供的各种安全机制。因此,如果使用的是新版本的 shc
,当前的 UnSHc 版本几乎不可能提取出原始的 shell 脚本。这需要一种更深入的方法,这意味着需要修改版的 bash
或修改过的 Linux 内核才能绕过这些安全措施。
所以你可以看到,当前版本的 shc
比旧版本要安全得多。
我还在一台 FreeBSD 机器上测试了 UnSHc,结果一样。这告诉我,阻止你成功使用 UnSHc 的安全增强功能也在当前的 FreeBSD 内核中。
此外,我还测试了另一个名为 deshc-deb
的 shc
破解工具。如果你想试试,可以通过以下方式下载:
git clone https://github.com/Rem01Gaming/deshc-deb.git
(剧透警告:deshc-deb
也不起作用。)
好吧,我知道,这个展示过程中做了很多工作来告诉你某些东西不起作用。但是,我觉得你可能想亲自看看,而不是仅仅接受我的说法。说真的,结论是,shc
是一种非常好的、安全的方式来混淆你的脚本,如果你真的需要这么做的话。而且,由于 Linux 和 FreeBSD 内核的改进,目前的解密工具已经不再有效。不过,你仍然需要采取预防措施,以避免敏感数据泄露,稍后你会在避免敏感数据泄露部分看到相关内容。接下来,让我们看一下可能会让你陷入困境的一对权限设置。
理解 SUID 和 SGID 的考虑因素
SUID和SGID,分别代表设置用户身份和设置组身份,是你可以为可执行文件设置的权限。这两种权限设置不仅非常实用,而且对于处理 Linux、Unix 及类 Unix 操作系统某些功能的特定可执行文件来说,是必需的。然而,如果你为自己创建的程序设置了 SUID 或 SGID,可能会给你的系统带来各种安全问题。在我解释为什么会这样之前,我需要解释一下 SUID 和 SGID 实际是如何工作的,以及为什么它们是必要的。
首先,我们进入/bin/
目录,查看rm
可执行文件的权限设置,如下所示:
donnie@fedora:/bin$ ls -l rm
-rwxr-xr-x. 1 root root 61976 Jan 17 19:00 rm
donnie@fedora:/bin$
下面是你所看到的内容的详细解释:
-
输出中的
root root
部分表示该文件属于根用户,并且与根用户的组相关联。 -
权限设置位于行的开头,分为三个部分。
rwx
设置适用于文件的用户,在这种情况下是根用户。(在 Linux/Unix 世界中,我们将文件的所有者称为文件的用户。)这里的rwx
表示根用户对该文件具有读取、写入和执行权限。 -
下一组设置是
r-x
,适用于组,在这种情况下是根组。这意味着该组的成员,在这个例子中仅限于根用户,具有对该文件的读取和执行权限。 -
最后,我们有第三组设置,仍然是
r-x
。这组设置适用于其他人。这意味着不是根用户或根组成员的任何人都可以调用rm
命令,即使rm
可执行文件属于根用户。需要注意的是,普通的、没有特权的用户只能使用rm
删除具有允许普通用户执行删除操作的权限设置的文件和目录。(通常,这意味着普通用户只能删除他或她自己的文件和目录。)删除任何其他文件和目录需要根权限。
有时,普通用户没有根用户或sudo
权限,但需要做一些需要根权限的事情。最常见的任务是当普通用户需要更改自己的密码时。更改密码需要修改 Linux 和 OpenIndiana 系统上的/etc/shadow
文件,以及 FreeBSD 和大多数其他 BSD 类型系统上的/etc/master.passwd
文件。但是,看看我在 Fedora 工作站上shadow
文件的权限设置:
donnie@fedora:/etc$ ls -l shadow
----------. 1 root root 1072 May 10 17:33 shadow
donnie@fedora:/etc$
好吧,这看起来很奇怪,因为这个文件对任何人都没有任何权限。实际上,root 用户确实具有对该文件的读写权限。只是像 Fedora 这样的 Red Hat 类型操作系统,使用另一种机制将这些权限授予 root 用户。(我不想深入探讨这个机制是什么,因为它超出了我们当前话题的范围。)
更典型的是你在这台 Debian 机器上看到的情况:
donnie@debian12:/etc$ ls -l shadow
-rw-r-----. 1 root shadow 1178 Mar 23 13:13 shadow
donnie@debian12:/etc$
在这里,我们看到只有 root 用户具有读写权限,而 shadow 组仅具有读权限。无论如何,普通用户如果需要更改自己的密码,必须修改这个文件,而不需要调用 root 或sudo
权限。怎么实现这一点呢?当然是通过 SUID。
将 SUID 权限添加到可执行文件上,允许任何普通用户以该文件所有者的权限执行该文件。例如,查看passwd
可执行文件的权限设置:
donnie@fedora:/bin$ ls -l passwd
-rwsr-xr-x. 1 root root 32416 Jul 19 2023 passwd
donnie@fedora:/bin$
这次,我们看到rws
作为用户的权限设置,在这种情况下,用户是 root 用户。小写字母s
表示已为 root 用户设置了可执行权限,并且 SUID 权限也已设置。在第三组权限中,你会看到r-x
,这意味着普通的无权限用户也可以运行此程序。SUID 权限正是允许普通用户更改自己的密码,这需要修改/etc/shadow
文件或/etc/master.passwd
文件。所以现在,让我们看看没有 root 或sudo
权限的 Frank 能否成功:
frank@debian12:~$ passwd
Changing password for frank.
Current password:
New password:
Retype new password:
passwd: password updated successfully
frank@debian12:~$
的确如此,这要归功于passwd
可执行文件上的 SUID 设置。
我需要指出的是,passwd
可执行文件上的 SUID 设置仅在用户设置自己的密码时有效。如果用户想要设置其他人的密码,仍然需要拥有适当的sudo
权限。(至于操作系统开发人员是如何使 SUID 像这样有选择地工作,我也不清楚。)
可执行文件上的 SGID 设置以相同的方式工作,只不过是针对组。例如,查看write
可执行文件的设置:
donnie@fedora:/bin$ ls -l write
-rwxr-sr-x. 1 root tty 24288 Apr 7 20:00 write
donnie@fedora:/bin$
在这种情况下,你会看到组权限中的s
设置,这意味着任何执行此程序的用户都拥有与关联组相同的权限。在这种情况下,我们讨论的是 tty 组,它是一个系统组,允许其成员将输出发送到终端。这里的 SGID 权限允许普通用户使用write
将消息发送给登录到另一个终端的用户,像这样:
donnie@fedora-server:~$ write frank
The server is shutting down in five minutes.
donnie@fedora-server:~$
输入write frank
命令后,我按下了Enter。然后我输入了消息,并按下Ctrl-d实际发送消息。由于write
上的 SGID 设置,我能够让这条消息显示在 Frank 的终端上,正如你在这里看到的:
frank@fedora-server:~$
Message from donnie@fedora-server on pts/1 at 17:01 ...
The server is shutting down in five minutes.
EOF
那么,所有这些与 shell 脚本有什么关系呢?嗯,问题在于,尽管 SUID 和 SGID 在操作系统的某些可执行文件上是强制要求的,但如果你在自己创建的可执行文件上设置它们,它们可能会成为安全隐患。这样做,你可能会无意中让普通用户做他们本不应该做的事情,也可能让入侵者调用恶意代码,影响整个系统。
所以,一般规则是,绝对不要在你自己创建的程序上设置 SUID 或 SGID,除非你真的知道自己在做什么,并且能够避免安全问题。
顺便说一下,下面是如何设置文件的 SUID 权限:
donnie@fedora-server:~$ ls -l newscript.sh
-rwxr--r--. 1 donnie donnie 0 Jun 25 17:13 newscript.shdonnie@fedora-server:~$ chmod u+s newscript.sh
donnie@fedora-server:~$ ls -l newscript.sh
-rwsr--r--. 1 donnie donnie 0 Jun 25 17:13 newscript.sh
donnie@fedora-server:~$
设置 SGID 看起来是这样的:
donnie@fedora-server:~$ ls -l newscript2.sh
-rw-r--r--. 1 donnie donnie 0 Jun 25 17:15 newscript2.sh
donnie@fedora-server:~$ chmod g+s newscript2.sh
donnie@fedora-server:~$ ls -l newscript2.sh
-rw-r-Sr--. 1 donnie donnie 0 Jun 25 17:15 newscript2.sh
donnie@fedora-server:~$
但是,对于 shell 脚本编写者来说,有个好消息。也就是说,如果你在 shell 脚本上设置了 SUID 或 SGID,它们将完全没有任何效果。这是因为 Linux、Unix 和类 Unix 操作系统的内核包含代码,使得操作系统忽略包含 shebang 行的任何可执行脚本文件上的 SUID 和 SGID 设置。因此,与其他 shell 脚本教程中可能看到的内容相反,在你的脚本上设置这两种危险权限不是问题,因为它们将完全没有任何效果。然而,它们可能对你使用 shc
创建的任何二进制文件产生影响,所以要注意。
不幸的是,我还没有能够广泛测试 SUID 和 SGID 权限对你使用 shc
创建的二进制文件的影响。所以,只要知道你不想在这些二进制文件上看到这两种权限设置。
我还应该提到,可以使用 nosuid
选项挂载文件系统分区,这样会导致该分区内的任何文件上的 SUID 和 SGID 权限完全被忽略。事实上,许多现代 Linux 和 Unix 发行版已经默认使用 nosuid
选项挂载某些重要的分区,比如 /tmp/
文件系统。不幸的是,如何在你自己的分区上设置这个选项超出了本书的范围。实际上,我真的无法提供,因为不同的 Linux 和 Unix 发行版以及它们使用的不同文件系统格式都有不同的设置方式。如果你需要知道如何设置 nosuid
选项,最好的办法是查阅你特定发行版的文档。
好的,让我们谈谈数据泄露。
避免敏感数据泄露
作为系统管理员,你很可能最终需要处理某些敏感数据,如密码、财务信息或客户信息。你总是需要确保你的脚本不会无意中导致敏感数据泄露给未经授权的人。让我们看看可能发生这种情况的几种方式,以及如何防止它。
确保临时文件安全
到某个时候,你可能需要创建存储某种临时数据的脚本。你可能需要这样做的原因包括:
-
处理大量数据而不使用过多的系统内存。
-
存储某种复杂操作的中间结果。
-
存储用于记录调试信息的临时数据。
-
允许不同的进程或脚本相互通信。
由于 /tmp/
目录是存储临时文件的最常见地方,让我们从解释它开始这个话题。
理解 /tmp/
目录
正如我刚才所说,存储临时文件最常见的地方就是 /tmp/
目录。这个目录的优点是它是全世界可读和可写的,就像你在这里看到的:
donnie@fedora-server:~$ ls -ld /tmp
drwxrwxrwt. 17 root root 340 Jun 27 14:27 /tmp
donnie@fedora-server:~$
这很好,因为它提供了一个公共的、广为人知的地方,供不同的脚本和进程访问。实际上,这个目录不仅仅是你的 shell 脚本使用的,它也被各种操作系统进程使用。在 Fedora 虚拟机上,你可以看到它存储了来自各种systemd
进程的临时数据:
donnie@fedora-server:/tmp$ ls -l
total 0
drwx------. 3 root root 60 Jun 27 13:44 systemd-private-d241f12536d0464393f30d8552359053-chronyd.service-RKt6x4
drwx------. 3 root root 60 Jun 27 13:44 systemd-private-d241f12536d0464393f30d8552359053-dbus-broker.service-plcC5i
drwx------. 3 root root 60 Jun 27 13:45 systemd-private-d241f12536d0464393f30d8552359053-httpd.service-SB6D6A
. . .
. . .
drwx------. 3 root root 60 Jun 27 13:44 systemd-private-d241f12536d0464393f30d8552359053-systemd-resolved.service-uBl3YB
donnie@fedora-server:/tmp$
正如你在这里看到的,每个在 /tmp/
中创建的文件或目录都设置了限制性权限,以便只有这些文件或目录的 用户 能够访问它们。另外,再看一下 /tmp/
目录本身的权限设置:
drwxrwxrwt. 17 root root 340 Jun 27 14:27 /tmp
在权限字符串的末尾,你会看到一个 t
代替了 x
。这意味着可执行权限被设置为 其他人,从这个意义上讲,它和 x
做的工作是一样的。所以,任何人都可以进入这个目录。但 t
,也就是 粘滞位,还确保不同用户无法删除彼此的文件或目录,除非他们具有 root 权限。(即使这些文件和目录设置了全世界可写的权限,这一点依然成立。)
我知道这只是一个关于粘滞位的简要解释,但更详细的解释超出了本书的范围。在我的《Linux 安全与硬化精通》一书中,你会找到更多相关内容。
好的,现在你了解了 /tmp/
目录,让我们看看如何创建可以创建临时文件的脚本。但要理解的是,这有两种做法。首先是错误的做法,然后是正确的做法。
创建临时文件的错误方式
所以现在,假设你真的需要写一个创建临时文件的脚本。你会怎么做呢?它会带来什么安全问题?在我展示如何正确创建临时文件之前,让我先展示在这个 tmp_file1.sh
脚本中错误的做法:
#!/bin/bash
echo "This temporary file contains sensitive data." > /tmp/donnie_temp
cat /tmp/donnie_temp
运行这个脚本,你将在 /tmp/
目录中看到这个:
donnie@fedora-server:~$ ls -l /tmp/donnie_temp
-rw-r--r--. 1 donnie donnie 45 Jun 27 16:02 /tmp/donnie_temp
donnie@fedora-server:~$
你可以看到,读取权限被设置为用户、组和其他人都有。写入权限只为用户设置。所以,我是唯一能写入这个文件的人,但每个登录到服务器的无特权用户都能读取它。如果你真正在处理敏感数据,那可不是你想要的。另一个问题是,这个脚本使用了一个可预测的临时文件命名约定,这使得攻击者容易进行符号链接攻击。
尝试解释符号链接攻击是超出了本书的范围。现在,我们只需要说它们可能导致不好的事情发生,比如:
敏感数据泄露。
注入虚假数据。
拒绝服务攻击。
如果你想了解更多关于符号链接攻击的信息,可以查看进一步阅读部分中的参考资料。
另外,请记住,安全威胁并不总是来自未经授权的入侵者。可以轻松访问/tmp/
目录的授权系统用户也可能构成威胁。
既然你已经看到了错误的做法,那我们来看看正确的方法。
正确创建临时文件的方法
创建临时文件的最佳方法是使用mktemp
工具。下面是如何在命令行中使用它:
donnie@fedora-server:~$ mktemp
/tmp/tmp.StfRN1YkBB
donnie@fedora-server:~$ ls -l /tmp/tmp.StfRN1YkBB
-rw-------. 1 donnie donnie 0 Jun 27 16:50 /tmp/tmp.StfRN1YkBB
donnie@fedora-server:~$
这很棒,因为它解决了我们在前面演示中的两个问题。首先,它自动设置了创建的文件的限制权限,这样除了创建者之外,其他人无法访问这些文件。其次,它创建了随机文件名的文件,这使得这些文件免受符号链接攻击。让我们看看在tmp_file2.sh
脚本中这是如何工作的:
#!/bin/bash
temp_file=$(mktemp)
echo "This file contains sensitive data." > "$temp_file"
在temp_file=$(mktemp)
这一行,我使用了命令替换,将mktemp
命令的输出赋值给temp_file
变量。由于mktemp
创建的是随机文件名的文件,因此每次运行脚本时,临时文件的名称都会不同。echo
这一行只是将一些输出写入临时文件。无论如何,让我们看看运行这个脚本时会有什么结果:
donnie@fedora-server:~$ ./tmp_file2.sh
donnie@fedora-server:~$ cat /tmp/tmp.sggCBdUd1M
This file contains sensitive data.
donnie@fedora-server:~$
正如我们上面已经提到的,文件设置了限制权限,只有我能访问它:
donnie@fedora-server:~$ ls -l /tmp/tmp.sggCBdUd1M
-rw-------. 1 donnie donnie 35 Jun 27 17:00 /tmp/tmp.sggCBdUd1M
donnie@fedora-server:~$
好的,以上这些都挺好看,但我们仍然没有一个方法在不再需要临时文件时自动删除它们。我的意思是,尽管这些文件都设置了限制权限,但当它们不再需要时,你还是不希望它们被留在那里。现在,你可能会想直接在脚本的末尾加一个rm
命令,就像你在tmp_file3.sh
脚本中看到的那样:
#!/bin/bash
temp_file=$(mktemp)
echo "This file contains sensitive data." > "$temp_file"
rm "$temp_file"
当然,这在像这样的简单脚本中有效。但如果你创建的是一个可能在执行过程中提前退出的复杂脚本,rm
命令可能不会被执行。因此,最好的做法是使用trap命令,即使脚本提前退出,也能删除临时文件。下面是tmp_file4.sh
脚本中如何工作的:
#!/bin/bash
trap 'rm -f "$temp_file"' EXIT
temp_file=$(mktemp)
echo "This file contains sensitive data." > "$temp_file"
ls -l /tmp/tmp*
cat "$temp_file"
当你执行脚本时,它总是会在一个新的子 Shell 中打开。当脚本运行完毕或提前退出时,子 Shell 会关闭。脚本中的第二行,trap
行,指定了当该子 Shell 关闭时要运行的命令。在这种情况下,我们希望运行rm -f
命令。(-f
选项强制rm
命令删除文件而不提示用户。)在echo
行之后,向临时文件发送一些文本,我添加了两行代码来证明临时文件确实被创建了。无论如何,以下是我运行脚本时发生的情况:
donnie@fedora-server:~$ ./tmp_file4.sh
-rw-------. 1 donnie donnie 35 Jun 28 15:07 /tmp/tmp.6t5Qqx5WqZ
This file contains sensitive data.
donnie@fedora-server:~$ ls -l /tmp/tmp*
ls: cannot access '/tmp/tmp*': No such file or directory
donnie@fedora-server:~$
所以,当我运行脚本时,它按我预期的那样显示了文件,并且显示了它的内容。但当我之后运行ls -l /tmp/tmp*
命令时,它显示没有tmp
文件。这证明了脚本中的trap
命令确实删除了临时文件。
尽管这些看起来都很好,但我还是想向你展示在tmp_file5.sh
脚本中做的最后一个修改,如下所示:
#!/bin/bash
trap 'rm -f "$temp_file"' EXIT
temp_file=$(mktemp) || exit 1
echo "This file contains sensitive data." > "$temp_file"
ls -l /tmp/tmp*
cat "$temp_file"
我在这里所做的唯一修改是将|| exit 1
添加到temp_file
那一行。这样,如果由于某些奇怪的原因,mktemp
命令无法创建临时文件,脚本将优雅地退出,并返回退出码 1。实际上,这里可能不需要这么做,因为几乎可以确定mktemp
能够创建临时文件。但是,提供一个优雅的退出机制被认为是良好的编程实践,拥有它肯定不会有坏处。
请注意,您还可以通过使用mktemp
并加上-d
选项切换,在/tmp/
目录中创建临时目录。
好的,你现在知道如何以安全的方式处理临时文件了。接下来,让我们处理另一个可能会成为麻烦的事情,那就是如何在脚本中安全地使用密码。
在 Shell 脚本中使用密码
本节中的信息可以帮助你应对两种不同的情况:
-
你需要创建一个脚本,供某个管理员或管理员组使用,并将其放置在他们都可以访问的地方。这些管理员都只有有限的权限,他们需要做的事情需要某个远程服务器的密码。但是,你不希望他们知道这个密码,因为你不想让他们实际登录到那个服务器。你只希望他们做一项特定的工作,例如将文件复制到该服务器。
-
你需要为自己的使用创建一个需要远程服务器密码的脚本。你希望设置脚本,使其在定期安排的时间自动运行,无论是作为
cron
作业还是systemd
定时器作业。如果脚本需要提示你输入密码,作业将会被中断,无法完成。
在这两种情况下,你都需要将密码嵌入到脚本中。但这有可能使你的密码泄露给未经授权的人员。自然,你希望防止这种情况发生。那么,接下来我们来看看如何避免。
实践实验 – 加密密码
对于这个场景,我将展示一个我从How-to Geek网站借来的解决方案。就它所涵盖的范围而言,这是一个不错的解决方案,但它只是一个部分解决方案,正如你很快会看到的。首先,我们加密一个密码并创建一个使用它的脚本。然后,我会展示完整的解决方案。
我已经在进一步阅读部分中提供了原始How-to Geek文章的链接。此外,为了避免重新发明轮子,我不会重复这篇文章的作者已经提供的详细解释。
- 首先,你需要一个系统,其中已安装
openssl
和sshpass
包。openssl
包通常会安装在几乎所有 Linux、Unix 或类 Unix 操作系统上,所以你不必担心这一点。你只需关注如何安装sshpass
。
你将使用openssl
来加密密码,并使用sshpass
自动将密码传递给ssh
客户端。
sshpass
包在 Fedora、Debian/Ubuntu 和 FreeBSD 的常规软件库中都有。无论在哪个系统上,包名都是一样的,因此只需使用你常用的包管理器进行安装。
为了好玩,我将使用 FreeBSD 虚拟机来进行这个实验,但如果你愿意,也可以使用你的任一 Linux 虚拟机。(无论如何,步骤是相同的。)
-
创建
.secret_vault.txt
文件,包含你要访问的远程服务器的加密密码,如下所示:donnie@freebsd14:~ $ echo 'Chicken&&Lips' | openssl enc -aes-256-cbc -md sha512 -a -pbkdf2 -iter 100000 -salt -pass pass:'Turkey&&Lips' > .secret_vault.txt donnie@freebsd14:~ $
在这个命令中,Chicken&&Lips
是你要访问的远程服务器的密码,Turkey&&Lips
是你用来解密密码的密码。(你需要这个解密密码的原因稍后会解释。)Chicken&&Lips
是你将要加密的密码。
-
使用
chmod 600 .secret_vault.txt
命令为自己设置读写权限,并移除其他所有用户的权限:donnie@freebsd14:~ $ ls -l .secret_vault.txt -rw-r--r-- 1 donnie donnie 45 Jul 31 16:31 .secret_vault.txt donnie@freebsd14:~ $ chmod 600 .secret_vault.txt donnie@freebsd14:~ $ ls -l .secret_vault.txt -rw------- 1 donnie donnie 45 Jul 31 16:31 .secret_vault.txt donnie@freebsd14:~ $
-
查看
.secret_vault.txt
文件,你将看到远程服务器密码的sha512
哈希值:donnie@freebsd14:~ $ cat .secret_vault.txt U2FsdGVkX18eeWfcaGbr0/4Fd70vTC3vIjVgymXwfCM= donnie@freebsd14:~ $
-
创建
go-remote.sh
脚本,使用你自己的信息来填写Remote_User
、Remote_Password
和Remote_Server
:#!/bin/bash # name of the remote account Remote_User=horatio # password for the remote account Remote_Password=$(openssl enc -aes-256-cbc -md sha512 -a -d -pbkdf2 -iter 100000 -salt -pass pass:'Turkey&&Lips' < .secret_vault.txt) # remote computer Remote_Server=192.168.0.20 # connect to the remote computer and put a timestamp in a file called script.log sshpass -p $Remote_Password ssh -T $Remote_User@$Remote_Server << _remote_commands echo $USER "-" $(date) >> /home/$Remote_User/script.log _remote_commands
正如你在原始How-to Geek文章中看到的那样,解密Remote_Password
的密码必须以明文形式出现在脚本中。文章的作者并不认为这是个问题,因为.secret_vault.txt
文件是一个所谓的隐藏文件,位于你自己的主目录中,并且文件的权限设置为只有文件所有者才能访问它。然而,这个解释只有在明文脚本位于任何人无法访问的位置时才有意义。(我稍后会详细解释这个问题。)
我没有将远程用户、远程服务器和远程服务器密码硬编码到脚本中,而是将这些信息的值分配给Remote_User
、Remote_Password
和Remote_Server
变量。为了获取我需要分配给Remote_Password
变量的解密密码,我使用stdin
(<
)重定向器将密码哈希从.secret_vault.txt
文件读取到openssl
命令中。
sshpass -p
命令将 Horatio 的密码(来自Remote_Password
变量)传递给ssh
客户端。由于我们不需要 Horatio 打开远程终端,我们将通过-T
选项禁用该功能。_remote_commands
here document包含一个将在远程服务器上执行的命令。也就是说,它将创建一个包含时间戳的script.log
文件,在 Horatio 的家目录中。
-
为了让这个脚本工作,你需要在你的
.ssh/known_hosts
文件中拥有 Horatio 的远程服务器公钥。所以,在你尝试运行脚本之前,让 Horatio 以正常方式登录远程服务器,然后让他像这样退出:donnie@freebsd14:~ ssh horatio@192.168.0.20 . . . . . . horatio@ubuntu2404:~$ exit donnie@freebsd14:~
-
执行
go-remote.sh
脚本。你应该能看到来自远程服务器的登录信息,并且很快会返回到本地机器的命令提示符。 -
登录到 Horatio 的远程服务器账户。你应该能看到一个包含时间戳的
script.log
文件。它看起来应该是这样的:horatio@ubuntu2404:~$ ls -l script.log -rw-rw-r-- 1 horatio horatio 38 Jul 31 21:34 script.log horatio@ubuntu2404:~$ cat script.log donnie - Wed Jul 31 17:34:44 EDT 2024 horatio@ubuntu2404:~$
如果你在 Horatio 的家目录中看到了script.log
文件,那么你已经实现了酷炫的效果。但这真的算是一个完整的解决方案吗?嗯,不完全是。让我们来看看这篇How-to Geek文章的作者没有提到的一些潜在问题。
理解这个解决方案的问题
我再次问,刚才我展示的解决方案是一个好的方案吗?嗯,这取决于一些因素。我的意思是,如果你将脚本和加密密码放在你自己家目录中,并且只有你可以访问它,那么可能没问题。但是,这里有一个重要的考虑因素。一些操作系统,比如 FreeBSD 和旧版的 Linux 实现,默认情况下会将用户的家目录开放给其他用户。任何能够进入你家目录的人都可以读取你脚本中的明文加密密码,并看到包含加密密码的文件名。事实上,让我们来看看这个问题。
FreeBSD 上的家目录权限
这是 FreeBSD 上的样子:
donnie@freebsd14:~ $ cd ..
donnie@freebsd14:/home $ ls -l
total 17
drwxr-xr-x 9 donnie donnie 68 Jul 31 17:52 donnie
drwxr-xr-x 2 horatio horatio 10 Jul 30 17:38 horatio
donnie@freebsd14:/home $ cd horatio/
donnie@freebsd14:/home/horatio $ ls
donnie@freebsd14:/home/horatio $ ls -a
. .cshrc .login_conf .mailrc .sh_history
.. .login .mail_aliases .profile .shrc
donnie@freebsd14:/home/horatio $
如你所见,默认情况下,FreeBSD 会为用户的家目录设置其他用户的读取和执行权限。在这种情况下,这意味着 Horatio 可以查看我目录中的内容,我也可以查看他的目录内容,而不需要使用任何管理员权限。幸运的是,我可以很容易地为自己修复这个问题,像这样:
donnie@freebsd14:/home $ chmod 700 donnie/
donnie@freebsd14:/home $ ls -ld donnie/
drwx------ 9 donnie donnie 68 Jul 31 18:01 donnie/
donnie@freebsd14:/home $
chmod 700
命令保留了我对家目录的读取、写入和执行权限,同时移除了其他所有人的权限。为了确保任何未来的用户在 FreeBSD 上也能拥有这种限制性的权限设置,请按照以下方式创建/etc/adduser.conf
文件:
donnie@freebsd14:~ $ sudo adduser -C
这是一个交互式工具,它会提示你输入很多信息。除Home directory permissions (Leave empty for default):
这一行外,其他选项都可以使用默认值。对于这一行,请输入700
作为值。通过创建另一个用户账户来测试你的设置。你应该看到新用户的家目录会设置限制权限,就像我为 Vicky 刚创建的账户一样:
donnie@freebsd14:/home $ ls -ld vicky/
drwx------ 2 vicky vicky 9 Jul 31 18:04 vicky/
donnie@freebsd14:/home $
另外,记住,当你安装 FreeBSD 时,安装程序为你创建的用户账户会为家目录设置较为开放的权限。因此,在安装完成后,一定要将家目录权限设置为更为限制的值。然后,运行sudo adduser -C
命令来创建/etc/adduser.conf
文件,就像我刚刚给你演示的那样。
Linux 上的家目录
在 Linux 上,家目录权限通常不是问题,因为许多现代 Linux 发行版默认创建具有限制权限的家目录。例如,以下是 Red Hat 类型系统(如 Fedora)和 Debian 12 上的家目录权限显示:
donnie@fedora:/home$ ls -l
total 0
drwx------. 1 donnie donnie 25094 Jul 31 13:46 donnie
donnie@fedora:/home$
另外,这就是它在 Ubuntu 22.04 及更新版本中的样子:
donnie@ubuntu2404:/home$ ls -l
total 12
drwxr-x--- 13 donnie donnie 4096 Jul 31 22:22 donnie
drwxr-x--- 3 horatio horatio 4096 Jul 31 21:34 horatio
drwxr-x--- 2 vicky vicky 4096 Jul 6 19:05 vicky
donnie@ubuntu2404:/home$
唯一的不同点是,Ubuntu 为用户的私有组设置了读取和执行权限,而 Red Hat 系列和 Debian 则没有。这没关系,因为无论如何,除了各自的所有者,其他人都无法访问家目录。
其他 Linux 或 Unix 发行版上的家目录
其他 Linux 或 Unix 发行版可能会有不同的方式来管理用户的家目录。如果你使用其中任何一个,请务必检查家目录权限设置,并根据需要进行修改。
好了,家目录权限的部分就讲到这里。但如果你需要将脚本放到其他位置,以便其他管理员访问该脚本呢?让我们看看如何解决这个问题。
实操实验:创建不可追踪的二进制文件
如果其他管理员需要访问需要嵌入密码的脚本,你可能需要将它和加密的密码文件放到其他目录,比如/usr/local/bin/
或/usr/local/sbin/
。在这种情况下,你需要确保没有人可以读取或修改该脚本,并且没有人可以追踪到它。这是因为任何能读取你的脚本(此处是go-remote.sh
脚本)的人,都能看到你用来解密远程服务器密码的密码,以及包含该远程服务器密码的文件名。当然,任何能够编辑该文件的人,也可以添加额外的命令,可能在远程服务器上执行恶意操作。
幸运的是,你可以通过使用 shc
将脚本转换成可执行的二进制文件来轻松解决这个问题,就像我在 控制脚本访问权限 部分所展示的那样。但在执行此操作时,你一定必须使用 shc
的 -U
选项,以确保二进制文件不可追踪。
如果你不使你的二进制文件不可追踪,那么任何可以访问这些文件的人都可以使用调试工具,如 strace
、truss
、dtrace
或 dtruss
来获取明文密码,即使它已经被最强的加密算法加密。
-
为了演示,回到你用来创建
go-remote.sh
脚本的虚拟机。使用没有-U
选项的shc
将脚本转换成可执行的二进制文件:donnie@freebsd14:~ $ shc -f go-remote.sh -o go-remote donnie@freebsd14:~ $
-
在 FreeBSD 上使用
truss
或在 Linux 上使用strace
,创建go-remote
可执行文件的trace1.txt
跟踪文件。
在 FreeBSD 上:
donnie@freebsd14:~ $ truss ./go-remote 2> trace1.txt
在 Linux 上:
donnie@fedora-server:~$ strace ./go-remote 2> trace1.txt
-
打开
trace1.txt
文件,向下滚动,直到看到 Horatio 的远程服务器密码。在我自己的文件中,它在第 191 行,内容如下:read(3,"Chicken&&Lips\n",4096) = 14 (0xe)
的确,Chicken&&Lips
确实是 Horatio 用来登录远程服务器的密码。
-
重新创建
go-remote
二进制文件,使用-U
选项使其不可追踪:donnie@freebsd14:~ $ shc -Uf go-remote.sh -o go-remote donnie@freebsd14:~ $
-
重复 第 2 步,不过这次将输出保存到
trace2.txt
文件: -
打开
trace2.txt
文件并搜索 Horatio 的密码。(剧透警告:这次你不会找到它。)现在,经过这一切,我需要告诉你一个小秘密。也就是,Horatio 的用户名和远程服务器的地址在
truss
或strace
的输出中都不会出现。所以,任何追踪你的二进制文件的人都能获得密码,但不会有用户名或服务器地址。但是,别以为这样就万事大吉了。记住,你需要像恶意黑客一样思考。任何值得一提的恶意黑客早就会使用其他手段来映射你网络中的服务器,并找到列举可能用户名的方法。所以,尽管这个过程只给了黑客一个谜题的部分答案,但它仍然是一个重要的部分,可以与其他部分结合起来。
好的,我想我们差不多完成这个话题了。现在让我们快速看一下安全编码实践。
理解使用 eval 的命令注入
另一个关于 shell 脚本安全的主要问题是接受来自不信任用户或不信任来源的输入的脚本。如果脚本编码不正确,攻击者可能会利用它注入恶意命令作为脚本的输入。在我们查看这些示例之前,让我们先看一下 eval
命令,它便于将数据或命令传递给脚本。
在命令行上使用 eval
eval
命令是大多数 shell 中的内建命令。当正确使用时,它非常方便,但不当使用时却非常危险。在我们深入了解之前,让我们看看 eval
在命令行上的使用方法。
好的,eval
是一个非常复杂的命令,完全理解它可能会很困难。所以,为了简化,我将在本节中展示一些非常简单的eval
示例。虽然它们展示的内容在实际生活中可能不会做,但它们足以展示基本概念。
对于任何想要更深入了解eval
的人,我将在进一步阅读部分中放入一些链接。
理解eval
的最佳方式是,它可以用来动态处理那些通常会被 shell 视为无意义文本字符串的命令。例如,假设我们想要找到三周前的日期。我们可以使用带有--date=
选项的date
命令,如下所示:
donnie@fedora-server:~$ date
Wed Aug 7 02:48:23 PM EDT 2024
donnie@fedora-server:~$ date --date="3 weeks ago"
Wed Jul 17 02:48:25 PM EDT 2024
donnie@fedora-server:~$
如你所见,我只是使用了“3 周前
”这个文本字符串作为--date
选项的参数,它运行得很好。现在,让我们将这个日期命令分配给threeweeksago
变量,并用echo
输出结果,像这样:
donnie@fedora-server:~$ threeweeksago='date --date="3 weeks ago"'
donnie@fedora-server:~$ echo $threeweeksago
date --date="3 weeks ago"
donnie@fedora-server:~$
如你所见,echo
命令只是返回了我分配给变量的文本字符串。为了展示真正的魔法,看看当我将echo
替换为eval
时会发生什么:
donnie@fedora-server:~$ eval $threeweeksago
Thu Jul 18 04:02:14 PM EDT 2024
donnie@fedora-server:~$
现在,让我们等几分钟,然后再运行一次。
donnie@fedora-server:~$ eval $threeweeksago
Thu Jul 18 04:05:10 PM EDT 2024
donnie@fedora-server:~$
仔细看,你会看到第二个eval
命令更新了时间值。这样的做法在脚本中非常有用,因为它允许你将命令分配给一个变量,然后在脚本的其他部分中使用这个变量。这样,你就能在脚本的不同部分执行该命令,而不必多次输入整个命令。但正如我之前提到的,eval
的使用既有安全的方法,也有危险的方法。让我们先看一下安全的用法。
安全使用 eval
让我们来看一下eval-test1.sh
脚本,查看一个安全使用eval
的简单示例:
#!/bin/bash
threeweeksago='date --date="3 weeks ago"'
eval $threeweeksago
运行脚本的样子如下:
donnie@fedora-server:~$ ./eval-test1.sh
Thu Jul 18 04:19:25 PM EDT 2024
donnie@fedora-server:~$
之所以这样是安全的,是因为我们提供给eval
的是来自脚本本身的文本字符串。只要你确保这个脚本的权限被锁定,没人能修改它,那么它完全是安全的。以这种方式使用eval
,可以防止攻击者插入他们自己的恶意命令。
当然,这个脚本其实没什么实际意义,因为它完全没有做任何有用的事情。所以,让我们来看一个更实际的例子,位于eval-test2.sh
脚本中:
#!/bin/bash
desiredDate='date --date="1 month ago"'
datestamp=$(eval "$desiredDate")
echo "I'm creating a report with the $datestamp timestamp in the filename." >
somefile_"$datestamp".txt
在这里,我使用eval
在命令替换结构中创建datestamp
变量的值。然后,我会使用这个datestamp
变量,将时间戳插入到文本文件和文本文件名中。下面是运行脚本时的效果:
donnie@fedora-server:~$ ./eval-test2.sh
donnie@fedora-server:~$ ls -l somefile_Thu*
-rw-r--r--. 1 donnie donnie 90 Aug 8 16:42 'somefile_Thu Jul 18 04:42:31 PM EDT 2024.txt'
donnie@fedora-server:~$ cat "somefile_Thu Jul 18 04:42:31 PM EDT 2024.txt"
I'm creating a report with the Thu Jul 18 04:42:31 PM EDT 2024 timestamp in the filename.
donnie@fedora-server:~$
如你所见,它运行得很好。而且,如果我稍后再次运行这个命令,我会看到一个带有新时间戳的新文件。当然,如果我决定将--date
值更改为其他内容,比如“1 个月前
”,我只需在desiredDate=
这一行进行更改,而不必在脚本的多个地方进行修改。
现在,既然我已经展示了使用 eval
的安全方法,我们来看一下危险的用法。
使用 eval
的危险性
eval-test3.sh
脚本展示了一个你绝对不想做的非常简单的例子:
#!/bin/bash
eval $1
是的,我知道你在想什么。没人会创建一个如此简单的脚本,以这种方式使用 eval
。不过没关系,因为它的目的是展示这个概念。让我们试试,看看会发生什么。
donnie@fedora-server:~$ ./eval-test3.sh 'date --date="1 day ago"'
Wed Aug 7 05:14:48 PM EDT 2024
donnie@fedora-server:~$
如你所见,date
命令被传递给 eval
作为位置参数 $1
。这看起来足够安全,但真的安全吗?关键在于,任何运行这个脚本的人都可以插入任何他们想要执行的命令。例如,假设这个脚本位于 /usr/local/bin/
目录中。权限设置为无人能够修改它,但同时每个非特权用户都可以执行它。那么,恶意黑客能通过此做些什么呢?我们来看看。
donnie@fedora-server:~$ eval-test3.sh 'cat /etc/passwd'
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
. . .
. . .
charlie:x:1009:1009::/home/charlie:/usr/bin/zsh
horatio:x:1010:1010::/home/horatio:/bin/bash
donnie@fedora-server:~$
这个例子看起来似乎没什么大不了的,因为 passwd
文件本来就是全世界可读的。但在某些情况下,这可能会变得非常严重。举个例子,假设你正在运行一个 Web 服务器,并且有一个 shell 脚本作为 CGI 脚本设置好。
CGI 代表 公共网关接口。你可以使用各种编程语言来创建 CGI 脚本,包括 shell 脚本。这些 CGI 脚本可以在 Web 服务器上执行各种功能,比如计数用户,甚至提供内容。
关于设置 Web 服务器以及如何攻击它的详细解释超出了本书的范围。现在我们就说,外面有一些聪明的黑客能找到并利用设计不当的 CGI 脚本,尤其是在 Web 服务器安全措施没有正确配置的情况下。
一个恶意黑客如果无法登录 Web 服务器,将无法查看 passwd
文件。但如果他或她发现这个设计不当的脚本正在执行某种 CGI 功能,那么他或她可能能够查看 passwd
文件,从而枚举服务器上的用户。(不过,也许不会。适当的 Web 服务器安全措施可能会防止这种情况发生,即使脚本本身存在安全漏洞。)
另一个可能性较高的例子是,假设你把 eval-test3.sh
脚本放在 /usr/local/sbin/
目录中,并将它设置为只有 root 用户有执行权限,如下所示:
donnie@fedora-server:/usr/local/sbin$ ls -l eval-test3.sh
-rwx------. 1 root root 21 Aug 8 17:20 eval-test3.sh
donnie@fedora-server:/usr/local/sbin$
然后你运行 sudo visudo
命令来设置 Charlie,使他仅具有运行 eval-test3.sh
脚本的 root 权限,而不能做其他操作。这里是实现这一点的那一行:
charlie ALL=(ALL) /usr/local/sbin/eval-test3.sh
让我们来看一下这是如何工作的。
charlie@fedora-server:~$ eval-test3.sh 'cat /etc/passwd'
bash: /usr/local/sbin/eval-test3.sh: Permission denied
charlie@fedora-server:~$ sudo eval-test3.sh 'cat /etc/passwd'
[sudo] password for charlie:
root:x:0:0:root:/root:/bin/bash
. . .
. . .
charlie:x:1009:1009::/home/charlie:/usr/bin/zsh
horatio:x:1010:1010::/home/horatio:/bin/bash
charlie@fedora-server:~$
这次 Charlie 没有造成任何危害,但下次呢?让我们看看:
charlie@fedora-server:~$ sudo eval-test3.sh 'systemctl stop httpd'
charlie@fedora-server:~$
这一次,Charlie 做了一些真正的破坏。他关闭了 Web 服务器,造成了拒绝服务攻击(Denial-of-Service)。通常情况下,Charlie 是没有这个权限的。但是,因为他有sudo
权限来运行eval-test3.sh
脚本,而该脚本包含了eval $1
这一行,Charlie 现在可以运行任何他想要的命令,包括系统管理员命令。
正如我在本节开头所说的,我尽量让eval
的解释保持简单。如果你想了解更复杂且更具现实意义的eval
场景,我想推荐两篇我发现的非常好的文章。第一篇文章在 Medium.com 上,是我找到的最好的写作之一,详细讲述了如何利用写得不好的脚本中的eval
进行实际攻击:
Bash eval 的危险:
medium.com/dot-debug/the-perils-of-bash-eval-cc5f9e309cae
第二篇文章位于 Earthly.dev 网站,展示了如何通过一个eval
脚本进行反向 Shell 攻击。(如果你不知道什么是反向 Shell 攻击,你可以在文章中找到解释。)总之,你可以在这里找到这篇文章:
Bash eval:理解和(安全地)使用动态代码评估的威力:earthly.dev/blog/safely-using-bash-eval/
此外,至少有一次与真实世界 shell 脚本漏洞相关的事件,涉及了eval
的错误使用。这是与 Gradle 的安装脚本有关,Gradle 是一个用于软件开发的自动化构建工具。你可以在这里阅读相关内容:
CVE-2021-32751:
cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-32751
现在你了解了eval
的危险,我们来考虑一下是否真的需要使用它。
使用eval
的替代方案
很多时候,你可以使用eval
的安全替代方案。让我们看几个例子。
使用命令替换
在我在本节开头展示的eval-test1.sh
脚本中,你本可以用命令替换构造来替代eval
。
下面是在eval-test1-alternative.sh
脚本中的实现:
#!/bin/bash
threeweeksago=$(date --date="3 weeks ago")
echo $threeweeksago
运行脚本,你会发现它的行为与eval
版本完全相同:
donnie@fedora-server:~$ ./eval-test1-alternative.sh
Mon Jul 22 04:36:14 PM EDT 2024
donnie@fedora-server:~$ ./eval-test1-alternative.sh
Mon Jul 22 04:37:23 PM EDT 2024
donnie@fedora-server:~$
事实上,在这种情况下,它并不重要,因为该脚本不接受外部输入。因此,使用eval
是完全安全的。但是,如果你创建了一个接受外部输入的脚本,并且你有选择在eval
和命令替换之间做决定,最好的选择是使用命令替换。
记住:始终为你的脚本选择最安全的选项。
评估是否有必要使用eval
在某些情况下,使用eval
甚至不会做任何我们不可以在没有它的情况下做的事情。例如,看看eval-test4.sh
脚本:
#!/bin/bash
a=$1
eval echo $(( a + 1 ))
当你运行这个脚本时,你将传递一个数字作为位置参数$1
。eval
行中的算术运算符会将这个数字加 1,并显示结果。它的运行效果如下所示:
donnie@fedora-server:~$ ./eval-test4.sh 36
37
donnie@fedora-server:~$
现在,让我们移除eval
命令,使得脚本变成这样:
#!/bin/bash
a=$1
echo $(( a + 1 ))
不使用eval
运行脚本时,结果是这样的:
donnie@fedora-server:~$ ./eval-test4.sh 36
37
donnie@fedora-server:~$
输出与我们使用eval
时完全相同,这告诉我eval
在这里根本不需要。因此,eval
在这里唯一的作用就是增加一个我们绝对不需要的攻击向量。
记住:当没有必要时,不要使用eval
。
类似的场景涉及将命令赋值给一个变量,正如我们在eval-test5.sh
脚本中看到的那样:
#!/bin/bash
checkWebserver="systemctl status httpd"
eval $checkWebserver
运行此脚本将显示我们网站服务器服务的状态:
donnie@fedora-server:~$ ./eval-test5.sh
● httpd.service - The Apache HTTP Server
. . .
. . .
如前所述,我们可以在完全不使用eval
的情况下完成相同的操作,正如你在eval-test6.sh
脚本中看到的那样:
#!/bin/bash
checkWebserver="systemctl status httpd"
$checkWebserver
运行这个脚本,结果将与使用eval
时的结果相同。因此,eval
并不需要。
另一个例子是我在第十章—理解函数中展示过的value5.sh
脚本。为了帮助你回忆,下面是它的内容:
#!/bin/bash
valuepass() {
local __internalvar=$1
local myresult='Shell scripting is cool!'
eval $__internalvar="'$myresult'"
}
valuepass result
echo $result
我已经在第十章中解释过这个问题,所以现在我只想说,我在这里使用eval
是作为一种机制,帮助在valuepass
函数内部传递值。但如果你回顾第十章,你会看到这只是我向你展示的几种传递值进出函数的方法之一。在这里使用eval
是完全安全的,因为这个脚本只是进行内部值的传递。但是,如果你需要创建一个接受来自脚本用户或外部文本文件的外部值的函数,那么你就需要使用其中一种替代方法。
我认为这就涵盖了eval
的内容。让我们看看最后一个可能的编码问题,然后继续进入下一章节。
理解路径安全性
即使这种可能性非常渺小,也存在某人可能在你的系统上植入恶意版本的某个系统工具,并通过修改用户的PATH
设置来调用该恶意工具,而不是调用真实的工具。这个恶意工具可能会做各种有害的事情,例如窃取敏感数据,或通过加密重要文件来进行勒索软件攻击。在我展示脚本之前,让我们先看看这在命令行上的表现。让我们先来看正常的ls
命令的可执行文件所在的位置:
donnie@fedora-server:~$ which ls
alias ls='ls --color=auto'
/usr/bin/ls
donnie@fedora-server:~$
我们看到它位于/usr/bin/
目录中,这是应该的。现在,让我们创建一个假的ls
脚本,并将其放入/tmp/
目录中。下面是这个ls
脚本:
#!/bin/bash
echo "This is a trojaned ls file. This command does something nasty."
/usr/bin/ls
当然,echo
命令是无害的,但一个真正的恶意黑客会用一些不无害的命令来替换它。运行恶意命令后,脚本会调用正常的ls
命令。
接下来,让我们修改用户的PATH
设置,使得这个脚本会被调用,而不是实际的ls
。
donnie@fedora-server:~$ PATH=/tmp:$PATH
donnie@fedora-server:~$
最后,我们将调用ls
:
donnie@fedora-server:~$ ls
This is a trojaned ls file. This command does something nasty.
04:26:53 Jul
18 link.txt
. . .
. . .
ip_addresses.csv variables.txt
ipaddress_list_2024-06-03.txt
donnie@fedora-server:~$
这次,伪造的ls
被调用了,因为/tmp/
目录是用户PATH
设置中的第一个目录。
默认情况下,你创建的 shell 脚本会使用你正常的PATH
设置来查找脚本中调用的工具和程序。例如,当你在脚本中想调用ls
命令时,只需添加一行ls
,而不是/usr/bin/ls
。然而,采取这种方式后,任何能够操控用户PATH
设置的攻击者都可能让脚本运行一个伪造的木马程序。你可以采取三种措施来防止这种情况发生。
-
方法 1:让脚本不可读且无法追踪,正如我在本章第一部分所展示的那样。这样,攻击者就无法看到脚本正在调用哪些工具,这意味着他们不知道应该替换哪些工具来植入木马。
-
方法 2:在脚本顶部显式设置新的
PATH
。以下是在path-test1.sh
脚本中的实现方式:#!/bin/bash PATH=/bin:/sbin:/usr/local/bin:/usr/local/sbin ls -l
-
方法 3:在脚本中为每个命令使用完整路径。例如,当你想调用
awk
时,应该让这一行写成/bin/awk
,而不是仅仅写awk
。
第三种方法的一个问题是,它可能会使你的脚本不那么可移植。这是因为某些操作系统(例如 FreeBSD)可能将某些工具的可执行文件存储在不同的目录中,而不是你习惯的目录。例如,FreeBSD 将许多工具的可执行文件存储在/usr/local/bin/
目录中,而不是我们在 Linux 操作系统中习惯看到的/usr/bin/
目录。所以,如果你希望确保脚本能在尽可能多的操作系统上运行,你最好的做法是放弃方法 3。使用方法 2 会更简单且同样有效。
现在,你可能在想攻击者是如何操控某人的PATH
设置的。我可以想到两种可能的场景。
攻击场景 1:妥协用户账户
攻击者可能通过以下方式之一来操控用户的PATH
设置:首先获得用户账户的访问权限。如果攻击者已经完成了这一点,那么无论如何,游戏就结束了,路径安全就不是用户最关心的问题。因此,你最好的做法是确保用户账户的配置是安全的,防止任何人入侵。
攻击场景 2:社会工程学
攻击者还可能使用社会工程学手段,欺骗用户运行一个程序,该程序会修改用户的 shell 配置文件。
在网络安全的世界里,社会工程学可以有多种形式。例如,它可以通过一封带有恶意文件链接的诈骗邮件来实现,或者通过面对面的接触来进行。在任何情况下,目标都是说服受害者执行某种可能导致安全漏洞的操作。
对于这个场景,我使用的是 Fedora Server 虚拟机。
-
首先,让我们看一下
harmless-program.sh
脚本:#!/bin/bash echo "This program is harmless." echo "Trust me!" echo "PATH=/tmp:$PATH" >> /home/"$USER"/.bashrc echo "export PATH" >> /home/"$USER"/.bashrc
-
运行脚本,并注意到它在
.bashrc
文件的末尾添加了一个额外的PATH
指令。这个额外的指令覆盖了文件顶部的PATH
指令。这个新设置不会立即生效,但在用户下一次登录系统或打开新终端窗口时将生效。 -
登出后,再重新登录。准备好当你尝试做目录列表时看到的惊人情况:
donnie@fedora-server:~$ ls You've just been pwned! You really should learn to protect yourself against social engineering attacks. acl_demo.sh link.txt . . . . . . ipaddress_count.sh user_activity_original.sh ip_addresses.csv user_agent.tsv ipaddress_list_2024-06-03.txt variables.txt donnie@fedora-server:~$
当然,在现实生活中,脚本可能会做一些更为恶意的事情。而且,将这种恶意程序作为脚本分发会显得有些明显。
-
所以,让我们使用
shc
将其转换为一个不可追踪的可执行二进制文件。同时,使用-r
选项,这样该二进制文件将在其他 Linux 机器上执行。donnie@fedora-server:~$ shc -rUf harmless-program.sh -o harmless-program donnie@fedora-server:~$
-
在尝试执行二进制文件之前,在文本编辑器中打开
.bashrc
文件,删除由脚本添加的那两行。 -
登出后,再重新登录。
-
执行新的
harmless-program
二进制文件。 -
登出后,再重新登录。然后,执行
ls
命令。你应该会看到与步骤 3 中相同的结果。 -
最后,在文本编辑器中打开
.bashrc
文件,删除由harmless-program
程序添加的那两行。
如果你在网上搜索与 Shell 脚本安全相关的文章,你会发现有几篇讨论了路径安全攻击。然而,奇怪的是,我从未见过一篇文章解释有人如何执行这种攻击。
此外,这个场景提供了一个很好的例子,说明为什么所有使用计算机的人员都应该学会识别和避免社会工程攻击。
好的,我想这部分的内容已经覆盖得差不多了。接下来,我们就总结一下并继续前进。
总结
在本章中,我们讨论了对于关注安全的 Linux 或 Unix 管理员来说非常重要的主题。我们首先讨论了如何控制对重要脚本的访问,并展示了多种方法来实现这一点。接下来,我们关注了 SUID 和 SGID 权限设置的相关注意事项,然后探讨了几种防止脚本泄露敏感数据的方法。接着,我们讨论了在脚本中使用eval
命令是多么危险,最后以路径安全的讨论作为总结。
在下一章中,我们将讨论如何调试有漏洞的脚本。到时见。
问题
-
关于
eval
命令,以下哪项是正确的?-
在脚本中使用
eval
总是很安全的。 -
在脚本中使用
eval
总是很危险的。 -
只有当
eval
仅从外部源获取输入时,才可以安全地在脚本中使用它。 -
只有当
eval
仅从脚本内部获取输入时,才可以安全地在脚本中使用它。
-
-
关于
/tmp/
目录,以下哪两项陈述是正确的?(选择两项。)-
它是完全安全的,因为只有管理员用户可以进入。
-
任何人都可以在其中创建文件。
-
任何人都可以读取
mktemp
在/tmp/
目录中创建的文件。 -
mktemp
在/tmp/
目录中创建的任何文件只能由创建它们的用户读取。
-
-
如何防止攻击者从你用
shc
创建的二进制文件中获取信息?-
从
shc
二进制文件中获取信息永远是不可能的。 -
使用
shc
并带上-U
选项。 -
使用
shc
并带上-u
选项。 -
你无法做任何事情来防止这种情况发生。
-
-
在为文件设置访问控制列表(ACL)之前,你需要做什么?
-
移除组和其他的所有权限。
-
你无需做任何事情。
-
确保用户、组和其他都具有读、写和执行权限。
-
将文件的所有权更改为 root 用户。
-
-
确保脚本在
/tmp/
目录中创建的任何临时文件始终被删除的最佳方法是什么?-
运行脚本后,进入
/tmp/
目录并手动删除临时文件。 -
在脚本结束时使用
rm
命令自动删除文件。 -
在脚本开始时使用
trap
命令自动删除文件。 -
不需要任何操作,因为脚本会始终自动删除其临时文件。
-
进一步阅读
-
在 ZFS 文件上设置和显示 ACL(以紧凑格式)—Oracle Solaris ZFS 管理指南:
docs.oracle.com/cd/E23823_01/html/819-5461/gbchf.html#scrolltoc
-
Shell 脚本与安全性:
stackoverflow.com/questions/8935162/shell-scripts-and-security
-
如何在 Linux Shell 脚本中安全地处理临时文件:
youtu.be/zxswimoojh4?si=ERfbJ04U2LJzQE60
-
为什么 SUID 对 shell 脚本禁用但对二进制文件不禁用?:
security.stackexchange.com/questions/194166/why-is-suid-disabled-for-shell-scripts-but-not-for-binaries
-
SUID shell 脚本的危险:
www.drdobbs.com/dangers-of-suid-shell-scripts/199101190
-
使用 trap 内置命令捕获中断以实现 shell 中的优雅事件处理:
www.shellscript.sh/trap.html
-
Linux 中的逆向工程工具—strings, nm, ltrace, strace, LD_PRELOAD:
www.thegeekstuff.com/2012/03/reverse-engineering-tools/
-
使用 strace 进行逆向工程:
function61.com/blog/2017/reverse-engineering-with-strace/
-
如何在 Bash 脚本中使用加密密码:
www.howtogeek.com/734838/how-to-use-encrypted-passwords-in-bash-scripts/
-
Linux 中 eval 命令的初学者指南:
linuxtldr.com/eval-command/
-
如何在 Linux Bash 脚本中使用 eval:
www.howtogeek.com/818088/bash-eval/
-
Eval 命令和安全问题:
mywiki.wooledge.org/BashFAQ/048
-
脚本安全:
www.admin-magazine.com/Archive/2021/64/Best-practices-for-secure-script-programming
答案
-
d
-
b 和 d
-
b
-
a
-
c
加入我们的 Discord 社区!
与其他用户、Linux 专家以及作者本人一起阅读这本书。
提问,向其他读者提供解决方案,参加与作者的问答互动,更多精彩内容。扫描二维码或访问链接加入社区。
第二十一章:调试 Shell 脚本
如果你曾经写过一个看起来很漂亮的脚本,却在它无法正常工作时感到失望,不要觉得孤单。这种情况发生在我们所有人身上,甚至在我为这本书创建脚本时也发生了几次。(我知道,令人震惊吧?)
调试过程应该从你开始设计代码的那一刻起就开始。仔细思考设计,写代码时测试每个部分,并且——天哪——使用带有语法高亮显示的文本编辑器。(这种颜色高亮将帮助你避免大量的排版错误。)
本章中的主题包括:
-
理解常见的脚本错误
-
引号不够
-
创建无限循环
-
使用 shell 脚本调试工具和技术
-
使用
echo
语句 -
使用
xtrace
进行调试 -
检查未定义的变量
-
使用
-e
选项检查错误 -
使用
bash
调试器 -
使用 bashdb 调试脚本
-
使用 bashdb 获取帮助
如果你准备好了,我们就开始吧。
技术要求
对于这一章,我主要使用的是我用来创建这个 Word 文件的 Fedora 工作站。但是,你可以使用任何你喜欢的 Linux 虚拟机,只要它安装了桌面环境。(其中一个演示需要桌面环境。)
对于最后一个主题,我将展示如何在 Debian/Ubuntu、Fedora、FreeBSD 和 macOS 上安装和使用 bashdb
。
一如既往,你可以通过以下命令获取脚本:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
理解常见的脚本错误
调试中最重要的步骤是理解可能导致脚本直接失败或返回错误结果的常见错误。让我们来看一些例子。
引号不够
如果你没有用引号将变量名包围起来,可能会遇到一些问题。这里有一些例子。
文件名中有空格
为了演示,我进入了一个空目录,以便将要创建的文件与我主目录中的文件区分开来。我想创建一些文件,文件名中包含时间戳。为此,我将使用 date
命令,不加任何选项。
首先,我将使用 touch
从命令行创建一个文件:
donnie@fedora:~/quoting_demo$ touch somefile_$(date).txt
donnie@fedora:~/quoting_demo$
好的,让我们来看看文件:
donnie@fedora:~/quoting_demo$ ls -l
total 0
-rw-r--r--. 1 donnie donnie 0 Aug 20 15:08 03:08:51
-rw-r--r--. 1 donnie donnie 0 Aug 20 15:08 20
-rw-r--r--. 1 donnie donnie 0 Aug 20 15:08 2024.txt
-rw-r--r--. 1 donnie donnie 0 Aug 20 15:08 Aug
-rw-r--r--. 1 donnie donnie 0 Aug 20 15:08 EDT
-rw-r--r--. 1 donnie donnie 0 Aug 20 15:08 PM
-rw-r--r--. 1 donnie donnie 0 Aug 20 15:08 somefile_Tue
donnie@fedora:~/quoting_demo$
哇,现在发生了什么?我没有得到一个文件,而是得到了七个。每个文件的文件名中都有当前日期的一部分。问题是,date
在没有任何选项的情况下,会生成带有空格的输出,就像这样:
donnie@fedora:~/quoting_demo$ date
Tue Aug 20 03:13:23 PM EDT 2024
donnie@fedora:~/quoting_demo$
所以当 shell 看到这些空格时,它认为我想要创建多个文件,而不是仅仅一个。让我们删除所有这些文件,再尝试加上一些引号:
donnie@fedora:~/quoting_demo$ touch somefile_"$(date)".txt
donnie@fedora:~/quoting_demo$ ls -l
total 0
-rw-r--r--. 1 donnie donnie 0 Aug 20 15:15 'somefile_Tue Aug 20 03:15:37 PM EDT 2024.txt'
donnie@fedora:~/quoting_demo$
你看到我如何用一对双引号将命令替换构造包围起来,这使得 shell 忽略了空格。现在,我只有一个文件,文件名是正确的。
未设置变量的问题
对于下一个场景,让我们来看一下 quote_problem1.sh
脚本:
#!/bin/bash
number=1
if [ $number = 1 ]; then
echo "Number equals 1"
else
echo "Number does not equal 1"
fi
它只是告诉我们number
的值是否为1
。运行脚本的结果如下:
donnie@fedora:~$ ./quote_problem1.sh
Number equals 1
donnie@fedora:~$
好的,现在它运行正常。接下来,我会编辑文件,将number
的值改为2
,然后再次运行脚本。结果如下:
donnie@fedora:~$ ./quote_problem1.sh
Number does not equal 1
donnie@fedora:~$
现在,让我们尝试使用未设置的变量,就像你在quote_problem2.sh
中看到的那样:
#!/bin/bash
number=
if [ $number = 1 ]; then
echo "Number equals 1"
else
echo "Number does not equal 1"
fi
当我们说变量未设置时,实际上是指我们定义了该变量,但没有为它赋值。让我们看看在尝试运行这个脚本时会发生什么。
donnie@fedora:~$ ./quote_problem2.sh
./quote_problem2.sh: line 4: [: =: unary operator expected
Number does not equal 1
donnie@fedora:~$
问题在于,if [ $number = 1 ]
测试在寻找某种number
变量的值。但number
没有值,这导致测试失败。幸运的是,这很容易修复。只需要将测试语句中的$number
用一对双引号包围,就像你在quote_problem3.sh
中看到的那样:
#!/bin/bash
number=
if [ "$number" = 1 ]; then
echo "Number equals 1"
else
echo "Number does not equal 1"
fi
运行这个脚本会显示正确的结果:
donnie@fedora:~$ ./quote_problem3.sh
Number does not equal 1
donnie@fedora:~$
之所以有效,是因为与之前没有值的情况不同,number
现在有效地拥有了一个空格值。空格不等于1
,所以脚本能够正常运行。
通常,最好在创建变量时就为它们赋一个初始值,以避免这些问题。然而,有时需要使用未设置的变量。例如,你可能希望脚本在一个变量未设置时执行一件事,或者在该变量有值时执行另一件事。只要你知道如何使用引号来避免这些解析问题,这完全没问题。
为了让这个问题更现实一些,我们来制作quote_problem4.sh
,使其能够接受用户定义的值:
#!/bin/bash
number=$1
if [ $number = 1 ]; then
echo "Number equals 1"
else
echo "Number does not equal 1"
fi
让我们看看它在不同值下是如何工作的:
donnie@fedora:~$ ./quote_problem4.sh 1
Number equals 1
donnie@fedora:~$ ./quote_problem4.sh 5
Number does not equal 1
donnie@fedora:~$ ./quote_problem4.sh
./quote_problem4.sh: line 4: [: =: unary operator expected
Number does not equal 1
donnie@fedora:~$
这个方法在1
或5
作为值时运行良好,但当我没有提供值时,它就无法工作。所以,看起来是我忘记引用$number
了。别担心,我会在quote_problem5.sh
中修复这个问题:
#!/bin/bash
number=$1
if [ "$number" = 1 ]; then
echo "Number equals 1"
else
echo "Number does not equal 1"
fi
让我们看看现在的情况:
donnie@fedora:~$ ./quote_problem5.sh 1
Number equals 1
donnie@fedora:~$ ./quote_problem5.sh 5
Number does not equal 1
donnie@fedora:~$ ./quote_problem5.sh
Number does not equal 1
donnie@fedora:~$
它运行得很顺利,这意味着我们再次取得了成功。
既然我们在进行测试,让我们看看当我们像在space_problem1.sh
中那样省略空格会发生什么:
#!/bin/bash
number=$1
if ["$number" = 1 ]; then
echo "Number equals 1"
else
echo "Number does not equal 1"
fi
这与quote_problem5.sh
脚本相同,唯一的区别是我删除了[
和"$number"
之间的空格。运行它时,结果如下:
donnie@fedora:~$ ./space_problem1.sh
./space_problem1.sh: line 4: [: =: unary operator expected
Number does not equal 1
donnie@fedora:~$
令人惊讶的是,它给出了与我忘记引用$number
变量时相同的错误信息。所以,这告诉我们两件事。首先,你需要小心在测试条件中放置空格。其次,你不能总是依赖于 shell 的错误信息来准确告诉你问题所在。在这个案例中,两个完全不同的错误却显示出了完全相同的信息。
等等!这里有一种解决问题的方式。我们只需要使用-u
shell 选项来检查未初始化的变量,正如你在quote_problem6.sh
脚本中看到的那样:
#!/bin/bash -u
number=$1
if [ $number = 1 ]; then
echo "Number equals 1"
else
echo "Number does not equal 1"
fi
这个脚本与之前的唯一不同是我在 shebang 行的末尾添加了 -u
。我们来看看如果不提供 $1
的值时会发生什么:
donnie@fedora:~$ ./quote_problem6.sh
./quote_problem6.sh: line 3: $1: unbound variable
donnie@fedora:~$
这很酷,因为它精确地告诉我们问题出在哪里,而不是只是显示一个通用的错误信息。如果你需要查看更详细的消息,只需添加 v
选项,像这样:
#!/bin/bash -uv
现在运行脚本的样子是这样的:
donnie@fedora:~$ ./quote_problem6.sh
#!/bin/bash -uv
number=$1
./quote_problem6.sh: line 3: $1: unbound variable
donnie@fedora:~$
调试完成后,记得删除 -u
或 -uv
,因为这可能会在生产脚本中引发问题。
我将在接下来的几页中向你展示更多关于使用 -u
选项的内容,具体在 使用 Shell 脚本调试工具 部分。
对于下一个问题,你需要系好安全带。因为事情会变得很疯狂。
创建一个疯狂的循环
当你创建某种循环时,很容易不小心创建一个会无限运行,直到你手动停止它。例如,看看 wild_loop1.sh
脚本:
#!/bin/bash
# while loop running wild
count=30
limit=25
while [ "$count" -gt "$limit" ];
do
echo $count
(( count = (count + 1)))
done
这个循环从 count
值为 30 开始,每次迭代时 count
增加 1。它应该一直运行,直到 count
达到 25。好吧,我知道这次问题显而易见,但还是请你耐心一点。这种情况可能发生在某个程序员急于完成任务时,没有注意到这个问题。无论如何,试着运行这个脚本会输出一个不断增加的数字列表。无论你多快按下 Ctrl-c,输出会一直滚动到终端的顶部,所以你根本看不到输出的顶部部分。因此,我会通过将输出导入到文本文件中来修复这个问题。不过首先,我会打开第二个终端窗口,创建一个空白文本文件,如下所示:
donnie@fedora:~$ touch wild_loop.txt
donnie@fedora:~$
接下来,我仍然在那个窗口中,使用 tail -f
打开文件,这样可以看到文件的更新内容:
donnie@fedora:~$ tail -f wild_loop.txt
现在我回到我的第一个终端窗口,并像这样运行 wild_loop1.sh
脚本:
donnie@fedora:~$ ./wild_loop1.sh > wild_loop.txt
我在几秒钟后按下了 Ctrl-c。但正如你在第二个窗口中看到的,count
真的是乱了:
图 21.1:疯狂的循环正在疯狂运行!
使用 less
打开 wild_loop.txt
文件,你会看到脚本的输出确实是从 30 开始的。
30
31
32
33
. . .
. . .
就像我之前说的,这个问题很明显。只是我不小心交换了 count
和 limit
的值。所以,我们来在 wild_loop2.sh
中修复这个问题:
#!/bin/bash
# while loop NOT running wild
count=25
limit=30
while [ "$limit" -gt "$count" ];
do
echo $count
(( count = (count + 1)))
done
这次,count
设置为 25,limit
设置为 30。运行这个脚本应该效果更好。我们来看看:
donnie@fedora:~$ ./wild_loop2.sh
25
26
27
28
29
donnie@fedora:~$
哦,那个看起来好多了。
还有很多 Shell 脚本的陷阱我可以与大家分享。但是,为了避免重新发明轮子,我就在这里结束,并分享一些你可能觉得有用的优秀资源。
Shell 中的文件名和路径名:如何正确操作:
dwheeler.com/essays/filenames-in-shell.html
常见的 Shell 脚本错误:
www.pixelbeat.org/programming/shell_script_mistakes.html
BashPitfalls:
mywiki.wooledge.org/BashPitfalls
好的,让我们继续讨论调试工具。
使用 Shell 脚本调试工具和技术
我们可以使用几种不同的调试工具,其中包括shellcheck
、checkbashisms
和shall
。我们在第十九章—Shell 脚本的可移植性中已经看过它们,所以这里不再重复讨论。相反,我将介绍一些我们还没有讨论过的工具和技术。
使用 echo 语句
有时,如果你遇到无法解决的 Shell 脚本问题,在合适的位置放置echo
语句可以帮助你找到问题所在。
你可能会在其他参考资料中看到,有些人认为echo
语句是穷人版的调试工具。这是因为echo
总是可用的,如果你无法使用其他工具时,可以使用它。
在第十六章,使用 yad、dialog 和 xdialog 创建用户界面中,我展示了xdialog-hello2.sh
脚本,它可以自动检测两件事。
-
它可以检测
Xdialog
工具是否存在。 -
它还可以检测你的机器是否安装了桌面环境。
如果同时检测到桌面环境和Xdialog
工具,脚本将运行图形化的Xdialog
工具。否则,脚本将运行基于 ncurses 的dialog
工具。以下是脚本的样子:
#!/bin/bash
command -v Xdialog
if [[ $? == 0 ]] && [[ -n $DISPLAY ]]; then
diag=Xdialog
else
diag=dialog
fi
$diag --title "Dialog message box" \
--begin 2 2 \
--msgbox "\nHowdy folks!" 15 50
clear
提醒一下,DISPLAY
环境变量如果安装了桌面环境,将会分配一个非零长度的值,如果没有安装桌面环境,则没有分配值。[[ -n $DISPLAY ]]
语句中的-n
用于测试是否存在一个非零长度的分配值。(-n
实际上代表非零。)因此,如果[[ -n $DISPLAY ]]
测试返回值为 0,这意味着条件为真,那么桌面环境已被检测到。
然而,最初脚本看起来是这样的,正如你在xdialog-hello2-broken.sh
脚本中看到的那样:
#!/bin/bash
command -v Xdialog
if [[ -n $DISPLAY ]] && [[ $? == 0 ]]; then
diag=Xdialog
else
diag=dialog
fi
$diag --title "Dialog message box" \
--begin 2 2 \
--msgbox "\nHowdy folks!" 15 50
clear
正如你所看到的,两个脚本的唯一区别在于测试条件的顺序不同。正常工作的脚本首先进行[[ $? == 0 ]]
测试,而损坏的脚本首先进行[[ -n $DISPLAY ]]
条件测试。
当我尝试运行原始脚本时,脚本首先是[[ -n $DISPLAY ]]
,结果就是无法运行。我的意思是,它在安装了Xdialog
的桌面机器或运行在文本模式下的机器上能够正常工作。但在没有安装Xdialog
的桌面机器上,它总是告诉我已经安装了该工具。当然,运行脚本时总是失败,因为它试图运行Xdialog
。我无法理解这背后的原因,所以我不得不进行一些故障排除。
我从未在我的 Fedora 工作站上安装过Xdialog
,所以我可以用它来展示我的故障排除步骤。当你尝试这个演示时,务必在一个没有安装Xdialog
的桌面类型虚拟机上进行。
你可以通过在command -v Xdialog
命令下方放置echo $?
命令和sleep 10
命令来开始故障排除。现在脚本看起来是这样的:
#!/bin/bash
command -v Xdialog
echo $?
sleep 10
if [[ -n $DISPLAY ]] && [[ $? == 0 ]]; then
diag=Xdialog
else
diag=dialog
fi
$diag --title "Dialog message box" \
--begin 2 2 \
--msgbox "\nHowdy folks!" 15 50
clear
我想看看退出代码是 0 还是 1,并且sleep 10
命令会暂停脚本足够长的时间,这样我就能看到echo $?
的输出。
我插入了sleep 10
命令,给自己十秒钟时间查看echo $?
命令的输出。否则,末尾的clear
命令会在我看到输出之前清除它。如果你愿意,你可以省略sleep 10
命令,而改为注释掉clear
命令。无论哪种方式都可以,所以选择权在你。
1 表示Xdialog
未安装,而 0 表示已安装。将echo
和sleep
命令放在command -v Xdialog
命令下方,可以让我看到脚本是否正确检测到软件包是否安装。现在运行脚本看起来是这样的:
donnie@fedora:~$ ./xdialog-hello2-broken.sh
1
1 退出代码表示脚本正确地检测到Xdialog
不存在。因此,脚本的这一部分正常工作。接下来,你可以注释掉整个if...then...else
语句块,并在其上方放置[[ -n $DISPLAY ]] && echo $?
语句和sleep 10
语句,像这样:
#!/bin/bash
command -v Xdialog
[[ -n $DISPLAY ]] && echo $?
sleep 10
#if [[ -n $DISPLAY ]] && [[ $? == 0 ]]; then
# diag=Xdialog
#else
# diag=dialog
#fi
$diag --title "Dialog message box" \
--begin 2 2 \
--msgbox "\nHowdy folks!" 15 50
clear
运行带有此修改的脚本看起来是这样的:
donnie@fedora:~$ ./xdialog-hello2-broken.sh
0
0 退出代码表示条件为真,这意味着确实安装了桌面环境。所以,这部分代码也正常工作。
这个时候我们需要开始进行逻辑思考。如果[[ -n $DISPLAY ]]
返回 0 退出代码,这会如何影响[[ $? == 0 ]]
部分呢?事实证明,第二个测试条件中的$?
查看的是第一个测试条件返回的 0 退出代码,而不是command -v Xdialog
命令的退出代码。因此,在桌面机器上,$?
总是会查看 0 退出代码,无论Xdialog
是否存在。解决这个问题的方法很简单,就是交换两个测试条件的顺序,如你在xdialog-hello2.sh
脚本中看到的那样。
回想起来,解决这个问题应该是显而易见的。但由于某种原因,我当时陷入了思维障碍,无法看出这一点。这也表明,即使是最优秀的人,有时也会被简单的脚本错误难倒。
好的,让我们继续下一种调试工具。
使用 xtrace 进行调试
使用xtrace调试你的有问题的脚本,显示了脚本中命令的实际执行情况及其输出。这可以帮助你追踪问题的根源。
你可以通过三种不同的方式使用xtrace
模式,分别是:
-
将
-x
追加到 shebang 行的末尾。这对我们一直在使用的所有 shell 都有效。例如,如果你正在编写一个bash
脚本,那么 shebang 行将是#!/bin/bash -x
。如果你正在创建一个需要可移植性的脚本,你可以使用#!/bin/sh -x
。 -
将
set -x
命令放入脚本中,确保你想开启调试模式的地方。 -
在调用脚本之前,从命令行运行
set -x
命令。
为了演示这个问题,让我们看看xdialog-hello2-broken2.sh
脚本,它和你在上一节看到的xdialog-hello2-broken.sh
脚本有着相同的问题。
#!/bin/bash -x
command -v Xdialog
if [[ -n $DISPLAY ]] && [[ $? == 0 ]]; then
diag=Xdialog
sleep 10
else
diag=dialog
sleep 10
fi
$diag --title "Dialog message box" \
--begin 2 2 \
--msgbox "\nHowdy folks!" 15 50
clear
在这里,我将-x
放置在 shebang 行的末尾,然后在diag=Xdialog
行和diag=dialog
行之后放置了sleep 10
行。现在,让我们运行它。
donnie@fedora:~$ ./xdialog-hello2-broken2.sh
+ command -v Xdialog
+ [[ -n :0 ]]
+ [[ 0 == 0 ]]
+ diag=Xdialog
+ sleep 10
你可以立即看到,这比使用echo
命令更有用。[[ -n :0 ]]
这一行是[[ -n $DISPLAY ]]
测试的扩展。它表明DISPLAY
变量确实有一个分配的值(:0
),这意味着已经安装了桌面环境。但是,这里真正的关键是[[ 0 == 0 ]]
这一行,它是[[ $? == 0 ]]
测试的扩展。从这个脚本中调用Xdialog
要求$?
等于 0,这就需要我们两个测试条件都返回 0。
这就要求Xdialog
和桌面环境都必须存在。但是,即使Xdialog
没有安装在这台机器上,这个脚本错误地显示它已经安装了,就像你在diag=Xdialog
这一行看到的那样。所以,再次强调,唯一明显的答案是交换测试条件,就像之前一样,并重新测试。这样,[[ $? == 0 ]]
将扩展为[[ 1 == 0 ]]
,这是我们在这种情况下希望看到的。
如果你使用#!/usr/bin/env bash
作为 shebang 行,那么在 shebang 行末尾追加-x
将不起作用。这是因为env
每次只识别一个选项,在这种情况下是bash
。因此,你需要在运行脚本之前,或者在脚本中放置一个set -x
命令,就像这样:
#!/usr/bin/env bash
set -x
. . .
. . .
如果你在命令行使用set -x
,你可以通过执行set +x
来关闭调试模式。
如果单独使用-x
没有显示足够的信息,你可以通过添加v
选项来启用详细模式,就像你在xdialog-hello2-broken4.sh
中看到的那样:
#!/bin/bash -xv
command -v Xdialog
if [[ -n $DISPLAY ]] && [[ $? == 0 ]]; then
diag=Xdialog
sleep 10
else
diag=dialog
sleep 10
fi
$diag --title "Dialog message box" \
--begin 2 2 \
--msgbox "\nHowdy folks!" 15 50
clear
这是输出:
donnie@fedora:~$ ./xdialog-hello2-broken4.sh
#!/bin/bash -xv
command -v Xdialog
+ command -v Xdialog
if [[ -n $DISPLAY ]] && [[ $? == 0 ]]; then
diag=Xdialog
sleep 10
else
diag=dialog
sleep 10
fi
+ [[ -n :0 ]]
+ [[ 0 == 0 ]]
+ diag=Xdialog
+ sleep 10
除了我们之前看到的,现在我们看到整个脚本被回显出来。我们还看到diag
变量的值是Xdialog
。由于Xdialog
没有安装在此机器上,因此diag
的值应该是dialog
。
如果你需要对调试过程进行永久记录以便进一步研究,可以将-x
输出重定向到文本文件中。-x
的输出被认为是stderr
输出,因此你可以像这样进行重定向:
donnie@fedora:~$ ./xdialog-hello2-broken4.sh 2> debug.txt
当你调试完毕后,记得在将脚本投入生产之前,移除-x
或set -x
选项。
好的,让我们继续下一个技巧。
检查未定义变量
正如我在本章开始时所说的,在 理解常见脚本错误 部分,有时在脚本中定义一个变量而不为其赋初值是可取的。但有时也不是。你可以通过在 shebang 行末尾附加 -u
来追踪未初始化的变量。例如,在 bash
中,你可以使用 #!/bin/bash -u
,这会使整个脚本都启用此功能。或者,你可以在脚本中任何你希望开始检查的地方加入 set -u
命令。例如,看看 unassigned_var1.sh
脚本,它关闭了变量检查:
#!/bin/bash
echo "The uninitialized myvar, without setting -u, looks like this : " $myvar
echo
myvar=Donnie
echo "I've just initialized myvar."
echo "The value of myvar is: " $myvar
这是输出结果:
donnie@fedora:~$ ./unassigned_var1.sh
The uninitialized myvar, without setting -u, looks like this :
I've just initialized myvar.
The value of myvar is: Donnie
donnie@fedora:~$
如你所见,没有 -u
设置时,脚本会正常执行。只是尝试回显未初始化的 myvar
会显示一个空白空间。接下来,让我们通过添加 -u
选项来启用变量检查,如在 unassigned_var2.sh
脚本中所见:
#!/bin/bash -u
echo "The uninitialized myvar, without setting -u, looks like this : " $myvar
echo
myvar=Donnie
echo "I've just initialized myvar."
echo "The value of myvar is: " $myvar
让我们看看这会有什么结果:
donnie@fedora:~$ ./unassigned_var2.sh
./unassigned_var2.sh: line 2: myvar: unbound variable
donnie@fedora:~$
这次,脚本一看到未初始化的变量就失败了。
你可以在脚本中的任何地方设置 -u
选项,通过使用 set -u
,正如在 unassigned_var3.sh
脚本中看到的那样:
#!/bin/bash
echo "The uninitialized myvar, without setting -u, looks like this : " $myvar
echo
echo "I'm now setting -u."
set -u
myvar=Donnie
echo "I've just initialized myvar."
echo "The value of myvar is: " $myvar
echo
echo "Let's now try another uninitialized variable."
echo "Here's the uninitialized " $myvar2
现在,我在第 2 行有一个未初始化的变量。(假设出于某种原因,我希望这个特定变量保持未初始化状态。)然后,在第 5 行启用变量检查。让我们看看它是如何运行的:
donnie@fedora:~$ ./unassigned_var3.sh
The uninitialized myvar, without setting -u, looks like this :
I'm now setting -u.
I've just initialized myvar.
The value of myvar is: Donnie
Let's now try another uninitialized variable.
./unassigned_var3.sh: line 11: myvar2: unbound variable
donnie@fedora:~$
在我启用变量检查之前,未初始化的 myvar
只是显示一个空白空间。启用变量检查后,我将 myvar
初始化为值 Donnie
,它正常打印出来。但是,未初始化的 myvar2
会导致脚本崩溃。
如果你在网上搜索 shell 脚本安全性教程,你会发现有几篇教程告诉你将 -u
或 set -u
永久添加到你的脚本中。这些教程的作者表示,这样做能增强脚本的安全性,但并没有给出任何令人信服的解释,说明为什么或如何这样做。使用 -u
或 set -u
对于调试很有帮助,但它应该仅限于调试!因此,当你调试完脚本后,一定要在将脚本投入生产前移除 -u
或 set -u
。否则,你的脚本可能会产生一些非常不可预测的结果。
另外,使用 -u
还可以帮助你检测脚本中的拼写错误。例如,如果你定义了一个变量 mynum=1
,但不小心使用 $mymum
来引用它,-u
会检测到 mymum
是一个未设置的变量。
以上就是关于未初始化变量的讨论。接下来我们来看下一个技巧。
使用 -e
选项检查错误
我们的下一个技巧是使用 -e
shell 选项或 set -e
命令来测试脚本中的错误,这些错误会导致脚本中的命令失败。让我们看看 bad_dir1.sh
脚本,了解它是如何工作的。
#!/bin/bash
mkdir mydir
cd mydire
ls
通过这个,我想创建mydir
目录,进入其中,并执行文件列表命令。但是,我今天打字不太顺,所以下面的cd
命令我不小心打成了mydire
,而不是mydir
。让我们看看运行时会发生什么。
donnie@fedora:~$ ./bad_dir1.sh
./bad_dir1.sh: line 4: cd: mydire: No such file or directory
15827_zip.zip
18.csv
2023-08-01_15-23-31.mp4
. . .
. . .
yad_timer.sh
yad-weather.sh
zoneinfo.zip
donnie@fedora:~$
是的,这个问题很明显,但没关系。因为-e
选项不仅仅是识别问题,它还会导致脚本在任何命令失败时立即退出。让我们把这个选项放到bad_dir2.sh
脚本中,看看它是怎么工作的。
#!/bin/bash -e
mkdir mydir
cd mydire
ls
我在这里所做的只是插入了-e
选项。现在,我将删除第一个脚本创建的mydir
目录,然后尝试运行这个脚本。
donnie@fedora:~$ rm -rf mydir
donnie@fedora:~$ ./bad_dir2.sh
./bad_dir2.sh: line 4: cd: mydire: No such file or directory
donnie@fedora:~$
使用-e
在失败的命令是复合命令结构的一部分时也同样有效,就像你在bad_dir3.sh
中看到的那样。
#!/bin/bash -e
mkdir mydir && cd mydire
ls
和之前一样,我将删除前一个脚本创建的mydir
目录,然后运行bad_dir3.sh
,它长这样:
donnie@fedora:~$ ./bad_dir3.sh
./bad_dir3.sh: line 3: cd: mydire: No such file or directory
donnie@fedora:~$
所以再次强调,当cd
命令失败时,-e
立刻中止了脚本的执行。
我知道你在想什么,我知道这有点让人毛骨悚然。你在想-e
会检测到的错误类型应该是相当明显的。因为对于这些类型的错误,shell 会显示一个错误信息,明确指出问题所在。那么,为什么我们还需要-e
呢?其实,可以把-e
和set -e
当作一种安全机制,而不是调试工具。例如,如果尝试cd
进入一个不存在的目录后,接下来的命令是rm
所有文件,那么如果在cd
命令失败后仍允许脚本继续运行,那可能会造成灾难性的后果。
好吧,我已经告诉你关于-e
和set -e
的好处。现在,让我告诉你一些不那么好的事情。
理解set -e
和-e
的问题
尽管-e
和set -e
可以很有帮助,但它们也可能让你头疼。有时候,它们甚至会破坏一个之前正常工作的脚本。以下是我的意思。
set -e
和-e
选项的工作原理是检测脚本中的命令是否返回非 0 的退出代码。
记住,退出代码为 0 表示命令执行成功,非 0 退出代码表示命令执行失败。
然而,有时候你需要脚本中的某些命令返回非 0 的退出代码,以使脚本正常工作。这使得set -e
和-e
的操作变得不可预测。举个例子,看看这个set_e_fails1.sh
脚本:
#!/bin/bash
set -e
i=0
let i++
echo "i is $i"
请注意,let i++
是一个 bash 特性,我还没有展示给你。你可以用i=$(( i + 1 ))
来替代它,以使脚本能够在非bash
的 shell 中运行。
这会创建一个值为 0 的i
变量,将其值增加 1,然后打印出i
的最终值。但看看在-e
设置下会发生什么:
donnie@fedora:~$ ./set_e_fails1.sh
donnie@fedora:~$
好吧,它什么都没有打印出来。让我们看看如果我把set -e
命令注释掉,会发生什么:
#!/bin/bash
# set -e
i=0
let i++
echo "i is $i"
让我们再运行一次。
donnie@fedora:~$ ./set_e_fails1.sh
i is 1
donnie@fedora:~$
所以,脚本在没有set -e
的情况下运行完全正常,但插入set -e
却会导致它出错。我知道,这听起来很疯狂,对吧?为了帮助我们弄清楚发生了什么,下面我们创建set_e_fails2.sh
脚本,如下所示:
#!/bin/bash
i=0
echo "Exit code after setting the value of i: " $?
let i++
echo "Exit code after incrementing i: " $?
echo "i is $i"
echo "Exit code after displaying new value of i: " $?
唯一的区别是,我省略了set -e
这一行,并插入了echo
语句来显示每条命令的退出代码。现在,来运行它:
donnie@fedora:~$ ./set_e_fails2.sh
Exit code after setting the value of i: 0
Exit code after incrementing i: 1
i is 1
Exit code after displaying new value of i: 0
donnie@fedora:~$
增量 i 后的退出代码: 1
这一行显示了问题所在。只是由于某种我无法理解的奇怪原因,let i++
命令尽管成功执行,却返回了退出代码 1。所以在这种情况下,使用-e
造成了问题,而不是解决问题。(然而,使用便携式的i=$(( i + 1 ))
语法会返回退出代码 0,这会避免这个问题。我也不知道为什么会这样。)
我在检查未定义变量部分提到过,一些网站上的文章建议将-u
或set -u
作为脚本的永久部分,以增强安全性。这些作者实际上建议将-e
/set -e
和-u
/set -u
都作为脚本的永久部分。但正如我刚才所展示的,-e
/set -e
可能会变得相当不可预测,并且可能会带来更多问题,而非解决问题。无论如何,如果你使用此工具进行故障排除,请确保在将脚本投入生产之前,删除它。
尽管我个人认为-e
和set -e
在谨慎使用的情况下是有用的,但这是一个有争议的观点。在以下文章中,你将看到一篇长文,解释为什么你永远不应该使用-e
或set -e
:
BashFAQ105—Greg 的 Wiki:
mywiki.wooledge.org/BashFAQ/105
这位作者认为,你最好自己编写错误检查,而不是依赖-e
/set -e
。
如果你愿意,可以访问该页面,并按照作者提供的示例进行操作。作者还提供了一个链接,指向与此相对立的观点页面,记得也去查看,这样你可以自己做出决定。
好的,我们继续讲解最后一个调试工具。
使用 bash 调试器
bash 调试器,通常被称为bashdb,允许你逐行执行bash
脚本,每次执行一条命令。这样你可以在执行下一条命令之前,查看脚本中的每一条命令到底在做什么。一旦安装好,它的使用非常简单。
你会在网上找到很多bashdb
教程,但其中很多已经很老了,而且展示的是一种过时的安装bashdb
的方法。这是因为多年前,bashdb
几乎包含在每个 Linux 发行版的仓库中。所以,你可以像这些教程所示的那样,使用普通的包管理器进行安装。
不幸的是,由于某些奇怪的原因,bashdb
已从大多数(如果不是全部)Linux 仓库中删除了。所以,现在如果你想在 Linux 上运行 bashdb
,你需要从源代码进行编译,接下来我将向你展示如何操作。
另一方面,bashdb
已经包含在 FreeBSD 仓库和 macOS 的 Homebrew 仓库中,所以你在这些系统上没有问题。
好的,让我们来看一下如何安装 bashdb
。
在 Linux 上安装 bashdb
正如我在信息框中提到的,你需要从源代码安装 bashdb
。不过别担心,这很简单。
1. 你需要先通过你常用的发行版包管理器,从正常的发行版仓库中安装 autoconf
和 texinfo
包。
2. 通过执行以下命令下载 bashdb
源代码:
git clone https://github.com/rocky/bashdb.git
3. 使用 cd
命令进入 git
命令创建的 bashdb/
目录。
4. 运行以下命令集以编译和安装 bashdb
:
./autogen.sh
./configure
make && make check
sudo make install
就这些了。
在 FreeBSD 上安装 bashdb
这更简单。只需执行:
sudo pkg install bashdb
在 macOS 上安装
在安装了 Homebrew 的 Mac 上,只需执行:
brew install bashdb
现在 bashdb
已经安装好了,接下来让我们看看是否能真正使用它。
使用 bashdb 调试脚本
让我们从一个干净的、未修改的原始损坏 xdialog
脚本开始,我将其命名为 xdialog-hello2-broken5.sh
,内容如下:
#!/bin/bash
command -v Xdialog
if [[ -n $DISPLAY ]] && [[ $? == 0 ]]; then
diag=Xdialog
else
diag=dialog
fi
$diag --title "Dialog message box" \
--begin 2 2 \
--msgbox "\nHowdy folks!" 15 50
clear
正如你在 使用 xtrace 进行调试 部分看到的那样,问题在于脚本无法正确检测 Xdialog
和桌面显示器是否已安装。让我们使用 bashdb
一步步调试,看看它能告诉我们什么。开始如下:
donnie@fedora:~$ bashdb ./xdialog-hello2-broken5.sh
bash debugger, bashdb, release 5.2-1.1.2
Copyright 2002-2004, 2006-2012, 2014, 2016-2019, 2021, 2023 Rocky Bernstein
This is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
(/home/donnie/xdialog-hello2-broken5.sh:3):
3: command -v Xdialog
bashdb<0>
这将运行脚本中的第一个命令,然后把我们带到 bashdb
命令提示符。要运行下一个命令,只需输入 step
,像这样:
. . .
. . .
bashdb<0> step
(/home/donnie/xdialog-hello2-broken5.sh:4):
4: if [[ -n $DISPLAY ]] && [[ $? == 0 ]]; then
bashdb<1>
在这里,我们看到了测试结构内部的 DISPLAY
变量。让我们通过使用 examine
命令来检查该变量的值:
. . .
. . .
bashdb<1> examine DISPLAY
declare -x DISPLAY=":0"
bashdb<2>
declare -x
部分意味着 bashdb
正在将 DISPLAY
变量标记为导出到后续命令。但是,这并不是最重要的部分。重要的是,DISPLAY
变量的值为 :0
,这意味着 [[ -n $DISPLAY ]]
测试将返回值 0,表示条件为真。
与其使用 examine DISPLAY
命令,你可以使用 print $DISPLAY
命令,像这样:
bashdb<1> print $DISPLAY
:0
bashdb<2>
这样,显示的就只有实际的 DISPLAY
值。
现在,我们需要动动脑筋,思考将 DISPLAY
测试放在 $?
测试之前意味着什么。嗯,我们已经在使用 echo
语句和 xtrace
方法排查问题时得出了答案。问题是,如果我们先进行 DISPLAY
检测,那么第二个测试中的 $?
值将始终为 0,即使没有检测到 Xdialog
可执行文件也是如此。但为了让你看到更多内容,假设我们还没有弄明白这一点,且需要查看更多脚本的执行情况。为此,输入你在下一个代码段中看到的一系列命令:
bashdb<2> step
(/home/donnie/xdialog-hello2-broken5.sh:4):
4: if [[ -n $DISPLAY ]] && [[ $? == 0 ]]; then
[[ $? == 0 ]]
bashdb<3> step
(/home/donnie/xdialog-hello2-broken5.sh:5):
5: diag=Xdialog
bashdb<4> print $diag
bashdb<5> step
(/home/donnie/xdialog-hello2-broken5.sh:10):
10: $diag --title "Dialog message box" \
bashdb<6> print $diag
Xdialog
bashdb<7>
我通过执行两个step
命令开始,带我进入了diag
变量定义的地方,并查看了它的赋值。接着,我执行了print $diag
命令,但什么也没有显示。然后,我执行了另一个step
命令,接着又执行了print $diag
命令。这时,我终于看到diag
变量的值是Xdialog
,尽管Xdialog
并没有安装在这台工作站上。
使用bashdb
的明显优势是我不需要修改脚本就能进行调试。如你所见,我不需要添加任何echo
语句来获取DISPLAY
和diag
变量的值。我也不需要添加sleep
命令或注释掉结尾的clear
命令,以防在我查看DISPLAY
值之前屏幕被清空。显然,这对bashdb
来说是一个巨大的优势。
当然,有时候你可能需要查找有关如何使用bashdb
的额外信息。接下来我们就来看这个。
获取 bashdb 帮助
当你安装bashdb
时,系统会安装一份 man 页面。坦率地说,它告诉你不多。幸运的是,bashdb
有一个内置的帮助功能,你可以在bashdb
命令提示符下随时使用。只需输入help
命令,你就能看到如下内容:
图 21.2:bashdb 帮助显示
这显示了可用的bashdb
命令列表。要查看如何使用某个特定命令,只需输入help
,后面跟上命令名称,如下所示:
图 21.3:获取break
命令的帮助
本章内容差不多到此为止。让我们总结一下,然后继续前进。
总结
本章中,我们介绍了一些有用的技巧和窍门,帮助你排查代码中的问题。我们首先查看了几种常见的 shell 脚本错误,并展示了如何在损坏的脚本中找到它们。接着,我们介绍了一些常见的调试工具和技术。对于-u
和-e
shell 选项,我向你展示了使用它们的利弊。最后,我向你展示了如何安装和使用bashdb
。
在下一章,我们将简要介绍如何使用zsh
进行脚本编写。我在那里等你。
问题
这次,我不会给你提供需要回答的问题,而是给你一些有 bug 的 shell 脚本。尝试运行它们,观察错误,然后尝试调试它们。你能调试它们吗?当然能,我相信你。
-
我们将从
bug1.sh
脚本开始:#!/bin/bash echo "Fix this script...I've got bugs" a=45 if [$a -gt 27 ] then echo $a fi exit
-
这是
bug2.sh
脚本:#!/bin/bash echo "Fix this script...I've got bugs" for i in 1 2 3 4 5 6 7 8 9 do echo $i exit
-
现在,来看一下
bug3.sh
:#!/bin/bash echo "Fix this script...I've got bugs" scripts=$(ls | grep [a-z]*.sh) echo $scripts exit
-
这是第四个也是最后一个,
bug4.sh
:#! /bin/bash echo '1 2 3' > sample.data cut -d' ' -f1,3 sample.data | read x z echo $((x+z))
进一步阅读
-
编写 Shell 脚本——第 9 课:避免麻烦:
linuxcommand.org/lc3_wss0090.php
-
如何调试 Bash 脚本:
linuxconfig.org/how-to-debug-bash-scripts
-
15 个必备的 Bash 调试技巧和工具:
www.fosslinux.com/104144/essential-bash-debugging-techniques-and-tools.htm
-
调试 Bash 脚本:
www.baeldung.com/linux/debug-bash-script
-
5 个简单步骤,学会如何调试 Bash Shell 脚本:
www.shell-tips.com/bash/debug-script/#gsc.tab=0
-
Shell 中的文件名和路径名:如何正确处理:
-
常见的 Shell 脚本错误:
-
BashPitfalls:
-
使用 bashdb 调试您的 shell 脚本:
www.linux.com/news/debug-your-shell-scripts-bashdb/
-
使用 bashdb 调试器调试 bash 脚本:
dafoster.net/articles/2023/02/22/debugging-bash-scripts-with-the-bashdb-debugger/
-
使用 BashDB 调试您的 Shell 脚本—YouTube:
www.youtube.com/watch?v=jbOQJDSTksA
-
BASH 调试器文档:
bashdb.sourceforge.net/bashdb.html
-
Bash 陷阱陷阱:
medium.com/@dirk.avery/the-bash-trap-trap-ce6083f36700
答案
-
在
if
语句中,您需要在[
和$a
之间插入一个空格。 -
在
echo $i
语句后面需要放置一个done
语句。 -
这个脚本有几个问题。首先,正则表达式设置错误,导致您无法从此脚本中得到任何或正确的输出。而不是
[a-z]*.sh
,应该是 [a-z].sh
。(对于这种情况,实际上 *
是不需要的。)
您可以查阅《第九章——使用 grep、sed 和正则表达式过滤文本》中关于正则表达式的概念。
还有一个事实是,这个正则表达式并没有对我们产生太大的帮助。除非您的脚本文件名仅由数字或大写字符组成,否则这个 grep
命令将显示目录中的所有脚本。因此,您可能可以完全省略 grep
命令,并使用简单的 ls *.sh
命令。
最后,在 echo
语句中,您需要用一对双引号括起变量,像这样:
echo "$scripts"
这将防止文件名中包含空格时出现问题。
-
这个脚本应该从脚本创建的
sample.data
文件中获取第一列和第三列的值,并将它们相加。第一列和第三列的值分别是 1 和 3,因此它们的和应该是 4。然而,当你运行它时,结果如下:donnie@fedora:~$ ./bug4.sh 0 donnie@fedora:~$
问题在于,第三行的cut
命令将输出传递给了read
命令。问题在于,read
命令是内建在 shell 中的,而不是拥有自己可执行文件的命令。
这很重要,因为你只能将一个命令的输出传递给一个有自己可执行文件的命令。因此,你不能将输出传递给一个内建命令。
你可以不使用管道,而是将第一列和第三列的值发送到tmp.data
文件中,然后使用输入重定向从tmp.data
文件中获取输入,就像你在bug5.sh
脚本中看到的那样:
#! /bin/bash
echo '1 2 3' > sample.data
cut -d' ' -f1,3 sample.data > tmp.data
read x z < tmp.data
echo $((x+z))
另一个选项是使用here文档,正如你在bug6.sh
脚本中看到的那样:
#! /bin/bash
echo '1 2 3' > sample.data
read -r x z <<<$(cut -d' ' -f1,3 sample.data)
echo $((x+z))
无论如何,你现在会看到正确的结果:
donnie@fedora:~$ ./bug5.sh
4
donnie@fedora:~$ ./bug6.sh
4
donnie@fedora:~$
加入我们的 Discord 社区!
读这本书时,可以与其他用户、Linux 专家和作者本人一起学习。
提问,给其他读者提供解决方案,通过“问我任何问题”环节与作者交流,等等。扫描二维码或访问链接加入社区。
第二十二章:介绍 Z Shell 脚本编写
到目前为止,我们主要讨论了 bash
,因为它是 Linux 的主要登录 shell 和脚本环境。近年来,一个相对较新的登录 shell Z Shell (zsh
) 作为默认登录 shell 越来越受欢迎。但即使在预装 zsh
作为登录 shell 的操作系统上,bash
仍然存在,并且大多数人仍然将其用于脚本编写。然而,需要注意的是,在 zsh
中进行脚本编写确实有一些优势,比如更好的数学能力、增强的变量展开功能,以及能够使用本地 zsh
命令而不是像 find
、grep
或 sed
这样的实用程序。这些特性可以帮助您创建比等效的 bash
脚本更简单、更轻量的脚本。
大多数关于 zsh
的文章和 YouTube 视频都集中在用户界面增强上,而很少关注 zsh
脚本编写。不幸的是,少数关注 zsh
脚本编写的书籍和教程写得不太好,您不会从中获得太多收获。希望我能提供一些澄清。
与其提供详细的教程,我更想简要概述 zsh
脚本编写,让您自己决定是否适合您。
本章的主题包括:
-
介绍
zsh
-
安装
zsh
-
理解
zsh
脚本编写的独特功能 -
使用
zsh
模块
如果您准备好了,让我们开始吧。
技术要求
您可以在任何 Linux 虚拟机上使用本章内容。与往常一样,您可以通过执行以下操作获取演示脚本:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
介绍 zsh
Z Shell 由 Paul Falstad 于 1990 年创建。它是 Bourne Shell 的扩展,同时还包括来自 bash
、Korn Shell (ksh
) 和 C Shell (tcsh
) 的功能。
C Shell 曾在 Unix 和类 Unix 发行版中颇受欢迎,但它与你之前接触的任何东西大不相同。编写 C Shell 脚本更类似于编写 C 语言程序,而不是你所熟悉的方式。所以如果你是 C 语言程序员,你可能会喜欢它。如果不是,那你可能不会那么喜欢。
Z Shell 是 macOS 和 Kali Linux 的默认登录 shell。对于几乎所有其他系统,您都需要自行安装。至于 zsh
脚本编写,大多数在 bash
中学到的内容也适用于 zsh
。因此,在本章中,我将仅介绍 zsh
的独特功能,并展示一些简单的脚本示例。
安装 zsh
在我尝试过的所有 Linux 和 BSD 类型发行版以及 OpenIndiana 上,都可以使用 zsh
包。在所有情况下,包名称都是 zsh
,因此您可以使用正常的包管理器安装它。
在 BSD 系统中,你会遇到与bash
相同的路径问题。也就是说,在 BSD 系统中,zsh
的可执行文件位于/usr/local/bin/
目录下,而不是/bin/
目录下。不过没关系,你可以像在bash
中那样,在/bin/
目录下创建一个符号链接。在我的 FreeBSD 机器上,命令如下:
donnie@freebsd-zfs:~ $ which zsh
/usr/local/bin/zsh
donnie@freebsd-zfs:~ $ sudo ln -s /usr/local/bin/zsh /bin/zsh
donnie@freebsd-zfs:~ $ which zsh
/bin/zsh
donnie@freebsd-zfs:~ $
现在,如果你需要在 Linux、OpenIndiana 和 BSD 机器上运行你的zsh
脚本,可以使用相同的 shebang 行,格式如下:
#!/bin/zsh
如果你想将zsh
作为临时登录 shell 使用,可以直接在当前 shell 的命令提示符下输入zsh
。如果你想将zsh
设置为永久登录 shell,则可以在 Linux 和 BSD 系统上使用chsh
命令,在 OpenIndiana 上使用passwd
命令。Linux 和 BSD 系统上可以这样设置:
donnie@ubuntu2404:~$ chsh -s /bin/zsh
Password:
donnie@ubuntu2404:~$
在 OpenIndiana 上的表现如下:
donnie@openindiana:~$ passwd -e
Enter existing login password:
Old shell: /bin/bash
New shell: /bin/zsh
passwd: password information changed for donnie
donnie@openindiana:~$
请注意,当你为自己的用户账户更改 shell 时,系统会提示你输入自己的用户密码。然而,这与sudo
无关,因为即使是非特权用户,也可以在 Linux/BSD 系统上使用chsh
,在 OpenIndiana 上使用passwd -e
来更改自己的默认 shell。
第一次使用zsh
登录时,你会看到一个设置菜单,内容如下所示:
This is the Z Shell configuration function for new users,
zsh-newuser-install.
You are seeing this message because you have no zsh startup files
(the files .zshenv, .zprofile, .zshrc, .zlogin in the directory
~). This function can help you with a few settings that should
make your use of the shell easier.
You can:
(q) Quit and do nothing. The function will be run again next time.
(0) Exit, creating the file ~/.zshrc containing just a comment.
That will prevent this function being run again.
(1) Continue to the main menu.
--- Type one of the keys in parentheses ---
按下1键返回主菜单,并选择你首选的设置选项。配置将保存到你家目录下的.zshrc
文件中。
接下来,让我们来看一下zsh
脚本的一些独特功能。
理解zsh
脚本的独特功能
在zsh
中编写脚本时,你可以利用一些bash
没有的增强功能。这里简要回顾一下其中的一些增强功能。
变量扩展的区别
在第八章—基本 Shell 脚本构建中,我解释了变量扩展的概念,这个概念有时也叫做参数扩展。这使得你可以编写一些很酷的脚本来完成很酷的事情,比如一次性修改一批文件的文件扩展名。bash
中大部分的变量扩展构造在zsh
中也能使用,但zsh
还提供了一些额外的扩展功能,增加了更多的能力。让我们来看几个例子。
替换值
在zsh
中替换值的方式与bash
相同,唯一的例外是,如果你在包含感叹号的文本字符串中替换值,在zsh
脚本中需要对感叹号进行转义。例如,在bash
中,效果如下:
donnie@fedora:~$ unset name
donnie@fedora:~$ echo "Hello, ${name:-Guest}!"
Hello, Guest!
donnie@fedora:~$ name=Horatio
donnie@fedora:~$ echo "Hello, ${name:-Guest}!"
Hello, Horatio!
donnie@fedora:~$
正如我在第八章中所解释的,variable_name:-default_value
结构为任何尚未赋值的变量提供了默认值。这个结构本应在zsh
中也能工作,但看我尝试它时发生了什么:
ubuntu2404% unset name
ubuntu2404% echo "Hello, ${name:-Guest}!"
dquote> "
Hello, Guest
ubuntu2404%
当我在输入完echo
命令后按下Enter键,zsh
会将我带到dquote>
提示符。如果我第二次按下"
键,就能得到我想要的输出。为了第一次就正确输出,我只需在!
之前加上\
,像这样:
ubuntu2404% unset name
ubuntu2404% echo "Hello, ${name:-Guest}\!"
Hello, Guest!
ubuntu2404% name=Horatio
ubuntu2404% echo "Hello, ${name:-Guest}\!"
Hello, Horatio!
Ubuntu2404%
所以,在感叹号前加上\
,它就能正常工作。我不知道为什么zsh
中感叹号需要转义,不过,反正能用就行,对吧?
子字符串替换
子字符串替换在bash
中部分有效,但不是完全有效。我的意思是这样。
我将首先创建name
变量,赋值为lionel
。然后,在bash
和zsh
中,我会尝试将第一个小写字母l替换成大写字母L。首先在bash
中,像这样:
donnie@fedora:~$ echo $SHELL
/bin/bash
donnie@fedora:~$ name="lionel"
donnie@fedora:~$ echo "${name/l/L}"
Lionel
donnie@fedora:~$
现在,在zsh
中:
ubuntu2404% echo $SHELL
/bin/zsh
ubuntu2404% name="lionel"
ubuntu2404% echo "${name/l/L}"
Lionel
ubuntu2404%
在${name/l/L}
构造中,你可以看到我首先列出了name
变量,并用正斜杠后跟要替换的字母,然后是另一个正斜杠和要替换成的字母。如你所见,这在bash
和zsh
中都能正常工作。我还可以通过在name
变量后使用两个正斜杠来替换所有的小写l,像这样:
在bash
中:
donnie@fedora:~$ echo "${name//l/L}"
LioneL
donnie@fedora:~$
在zsh
中:
ubuntu2404% echo "${name//l/L}"
LioneL
ubuntu2404%
这个方法在bash
和zsh
中都能使用。那么,如果它在这两种 shell 中都能工作,为什么我还要向你展示呢?其实,原因是:在bash
中,这个方法仅在字符串不包含空格的情况下有效。
这里是我想表达的意思:
在bash
中:
donnie@fedora:~$ names="Donnie and Vicky and Cleopatra"
donnie@fedora:~$ echo "${names//and/&}"
Donnie and Vicky and Cleopatra
donnie@fedora:~$
这一次,我想把所有的and替换成&符号。但在bash
中这行不通,因为字符串包含空格。我们来试试在zsh
中做这个操作。
在zsh
中:
ubuntu2404% names="Donnie and Vicky and Cleopatra"
ubuntu2404% echo "${names//and/&}"
Donnie & Vicky & Cleopatra
ubuntu2404%
在zsh
中,字符串中包含空格并不影响操作。
大小写转换
这里是完全相反情况的示例。这一次,我将展示一个在bash
中有效,但在zsh
中无效的例子。然后,我会展示在zsh
中使用的替代方法。
首先,假设我们想要将分配给name
变量的值的首字母大写。以下是在bash
中的方法:
donnie@fedora:~$ name=horatio
donnie@fedora:~$ echo "${name^}"
Horatio
donnie@fedora:~$
我这里做的只是将一个^
放在name
变量的echo
语句后面。现在,假设我想让所有字母大写,我可以使用两个^
字符,像这样:
donnie@fedora:~$ echo "${name^^}"
HORATIO
donnie@fedora:~$
如果你有一个全大写的字符串,可以使用一个或两个逗号,将第一个字母或所有字母转换为小写,就像这样:
donnie@fedora:~$ name=HORATIO
donnie@fedora:~$ echo "${name,}"
hORATIO
donnie@fedora:~$ echo "${name,,}"
horatio
donnie@fedora:~$
在zsh
中,完全不管用,正如你看到的:
ubuntu2404% name=horatio
ubuntu2404% echo "${name^}"
zsh: bad substitution
ubuntu2404% echo "${name^^}"
zsh: bad substitution
ubuntu2404% name=HORATIO
ubuntu2404% echo "${name,}"
zsh: bad substitution
ubuntu2404% echo "${name,,}"
zsh: bad substitution
ubuntu2404%
我之所以向你展示这个,是因为一些zsh
脚本书说这种方法在zsh
中有效。但如你所见,显然无效。
那么,在zsh
中的替代方法是什么呢?其实,你只需使用一个内建的zsh
函数。我们首先使用U
函数将整个字符串转换为大写:
ubuntu2404% name=horatio
ubuntu2404% echo "${(U)name}"
HORATIO
ubuntu2404%
我没有在变量名后面加一对^
字符,而是直接在name
变量前加了一个(U)
。
据我所知,zsh
中并没有直接转换第一个字母的函数。所以,我们只能像这样转换第一个字母:
ubuntu2404% name=horatio
ubuntu2404% capitalized_name=${name/h/H}
ubuntu2404% echo $capitalized_name
Horatio
ubuntu2404%
这和我在上一部分所展示的方法是一样的。
接下来,我们来做一些通配符操作。
扩展文件通配符
“文件模式匹配”,你问什么?嗯,其实你在本书的整个过程中都在使用它。只是我从未告诉过你它叫什么。它的意思是,你可以使用通配符字符,比如*
和?
,同时处理多个文件。例如,你可以这样做:
donnie@fedora:~$ ls -l *.zip
-rw-r--r--. 1 root root 32864546 Jul 27 2023 15827_zip.zip
-rw-r--r--. 1 root root 49115374 Jul 27 2023 21261.zip
-rw-r--r--. 1 root root 36996449 Jul 27 2023 46523.zip
. . .
. . .
-rw-r--r--. 1 root root 21798397 Jul 27 2023 tmvx.zip
-rw-r--r--. 1 root root 60822 Jul 27 2023 U_CAN_Ubuntu_20-04_LTS_V1R4_STIG_SCAP_1-2_Benchmark.zip
-rw-r--r--. 1 root root 425884 Jul 27 2023 zoneinfo.zip
donnie@fedora:~$
我在这里做的只是使用*
通配符来查看我目录中的所有.zip
文件。
你可以在zsh
中做所有这些操作,甚至更多。唯一的条件是你需要开启extendedglob
功能。要验证它是否开启,检查你家目录下的.zshrc
文件。你应该能看到像这样的行:
setopt autocd beep extendedglob nomatch notify
如果extendedglob
选项开启,那么你就可以使用它了。如果没有开启,只需添加它。那么,现在我们来看看几个扩展文件模式匹配的例子。
为什么zsh
中的扩展文件模式匹配有用?嗯,实际上,在许多情况下,你可以使用zsh
的扩展模式匹配功能,而不是使用find
工具。你可能会觉得这比使用find
更简单。或者,如果你习惯使用find
,你可能更喜欢使用它。我会让你自己做决定。
此外,为了演示的目的,我主要用ls
工具来展示这个功能。但你也可以使用文件模式匹配与其他工具一起使用,比如cp
、mv
、rm
、chmod
或chown
。
过滤ls
输出
启用这个酷炫的功能后,你可以做一些很酷的事情,比如排除你不想看到的文件。例如,假设你想查看目录中的所有文件,但不包括.sh
文件。只需这样做:
ubuntu2404% ls -d ^*.sh
bashlib-0.4 My_Files
demo_files supersecret
deshc-deb supersecret_trace.txt
Documents sysinfo.html
encrypted_password sysinfo_posix.lib
encrypted_password.sh.x.c test.sh.x
expand_1.txt user_activity_for_donnie_2024-05-12_09-30.txt
expand_2.txt user_activity_for_donnie_2024-07-22_10-40.txt
expand.txt user_activity_for_horatio_2024-07-06_07-12.txt
FallOfSudo while_demo
file2.txt while_demo.sh.x.c
file3.jpg
ubuntu2404%
在*.sh
前面的^
是一个否定符号。这会阻止ls
命令显示.sh
文件。
注意,我必须为ls
添加-d
选项。否则,你还会看到任何可能存在的子目录中的文件。由于某种原因,^
可以过滤掉我顶层目录中不需要的文件,但它不会过滤掉子目录中的不需要的文件。所以,如果没有-d
,你还会看到子目录中的所有文件,包括那些你想过滤掉的文件。
你也可以使用*~
操作符来实现相同的效果。看起来是这样的:
ubuntu2404% ls -d *~*.sh
bashlib-0.4 My_Files
demo_files supersecret
deshc-deb supersecret_trace.txt
Documents sysinfo.html
encrypted_password sysinfo_posix.lib
encrypted_password.sh.x.c test.sh.x
expand_1.txt user_activity_for_donnie_2024-05-12_09-30.txt
expand_2.txt user_activity_for_donnie_2024-07-22_10-40.txt
expand.txt user_activity_for_horatio_2024-07-06_07-12.txt
FallOfSudo while_demo
file2.txt while_demo.sh.x.c
file3.jpg
ubuntu2404%
如你所见,输出与ls -d ^.sh
的输出完全相同。要排除多种类型的文件,只需使用或操作符(|
),像这样:
ubuntu2404% ls -d *~(*.sh|*.jpg|*.txt|*.c)
bashlib-0.4 Documents My_Files sysinfo_posix.lib
demo_files encrypted_password supersecret test.sh.x
deshc-deb FallOfSudo sysinfo.html while_demo
ubuntu2404%
所以在这个例子中,我过滤掉了.sh
、.jpg
、.txt
和.c
文件。现在,让我们看看下一个技巧。
分组ls
搜索
你在bash
中做不到的另一件事是将ls
搜索结果进行分组,像这样:
ubuntu2404% ls -ld (ex|test)*
-rw-r--r-- 1 donnie donnie 52 Apr 29 20:02 expand_1.txt
-rw-r--r-- 1 donnie donnie 111 Apr 29 20:02 expand_2.txt
-rw-r--r-- 1 donnie donnie 51 Apr 29 20:02 expand.txt
-rwxrw-r-- 1 donnie donnie 48 Jun 30 20:26 test.sh
-rw-rw-r-- 1 donnie donnie 0 Jul 5 19:31 test.sh.dec.sh
-rwxr-xr-x 1 donnie donnie 11440 Jul 5 19:12 test.sh.x
ubuntu2404%
在这里,我使用|
符号作为或操作符,查看所有文件,其文件名的前部分是ex
或test
。
使用文件模式匹配标志
正如你所知道的,Linux 和 Unix 操作系统通常区分大小写。因此,如果你使用 ls *.jpg
命令来查看所有 JPEG 类型的图像文件,你将完全错过任何可能有 *.JPG
扩展名的文件。使用 zsh
时,你可以使用通配符标志来使命令不区分大小写。下面是一个例子:
ubuntu2404% touch graphic1.jpg graphic2.JPG graphic3.jpg file3.jpg
ubuntu2404% ls *.jpg
file3.jpg graphic1.jpg graphic3.jpg
ubuntu2404% ls (#i)*.jpg
file3.jpg graphic1.jpg graphic2.JPG graphic3.jpg
ubuntu2404%
第二个 ls
命令中的 (#i)
就是通配符标志。#
表示这是一个标志,它会出现在你想要匹配的模式前面。
(#l)
标志会匹配小写或大写的模式,如果你为搜索指定小写模式,它就会匹配小写模式。如果你指定大写模式,那么它只会匹配大写模式。下面是它的样子:
ubuntu2404% ls (#l)*.jpg
file3.jpg graphic1.jpg graphic2.JPG graphic3.jpg
ubuntu2404% ls (#l)*.JPG
graphic2.JPG
ubuntu2404%
你可以看到,当我指定 *.jpg
作为模式时,它也找到了 .JPG
文件。但是,当我指定 .JPG
作为模式时,它只找到了 .JPG
文件。
现在,让我们扩展一些目录。
扩展目录
你可以使用通配符限定符查看不同类型的目录或文件。我们将从目录的限定符开始,具体包括:
-
*(F)
:这个扩展了非空目录。 -
*(^F)
:这个扩展了普通文件和空目录。 -
*(/^F)
:这个仅扩展空目录。
首先,让我们查看我主目录中的非空目录。
ubuntu2404% ls *(F)
bashlib-0.4:
bashlib config.cache config.status COPYING Makefile README
bashlib.in config.log configure INSTALL Makefile.in
. . .
. . .
My_Files:
afile.txt somefile.txt yetanotherfile.txtx
anotherfile.txt somepicture.jpg yetanotheryetanotherfile.txt
haveicreatedenoughfilesyet.txt somescript.sh
ubuntu2404%
你可以看到,这个命令显示了非空目录及其内容。但它没有显示我顶层主目录中的任何文件。所以,一切正常。
现在,我想查看我顶层主目录中的所有文件,以及可能存在的任何空目录。我会这样做:
ubuntu2404% ls *(^F)
acl_demo.sh rsync_password.sh
diskspace.sh shutdown-update.sh
. . .
. . .
rootlock_2.sh while_demo.sh.x.c
Documents:
empty_directory:
ubuntu2404%
这两个空目录出现在输出的底部。
最后,我只想看到空目录,如下所示:
ubuntu2404% ls *(/^F)
Documents:
empty_directory:
ubuntu2404%
很酷吧?
接下来,让我们看看文件扩展。
扩展文件
扩展文件的方式有几种。首先,我们将根据文件或目录的文件类型进行扩展。
扩展文件和目录
如果你想查看目录中的文件,但又不想看到任何目录,可以这样做:
ubuntu2404% ls *(.)
acl_demo.sh rsync_password.sh
diskspace.sh shutdown-update.sh
encrypted_password sudo_demo1.sh
. . .
. . .
rootlock_1.sh while_demo.sh
rootlock_2.sh while_demo.sh.x.c
ubuntu2404%
如果你只想看到你的目录,无论是空目录还是非空目录,可以这样做:
ubuntu2404% ls *(/)
bashlib-0.4:
bashlib config.cache config.status COPYING Makefile README
bashlib.in config.log configure INSTALL Makefile.in
. . .
. . .
Documents:
empty_directory:
FallOfSudo:
fallofsudo.py LICENSE main.png README.md
My_Files:
afile.txt somefile.txt yetanotherfile.txtx
anotherfile.txt somepicture.jpg yetanotheryetanotherfile.txt
haveicreatedenoughfilesyet.txt somescript.sh
ubuntu2404%
好的,我想你已经明白这里发生了什么。让我们继续看一下文件权限。
通过文件权限扩展
在第二十章,Shell 脚本安全性中,我解释了文件和目录权限是如何工作的。让我们快速回顾一下。
权限设置分为用户(即文件或目录的所有者)、组和其他。每一项都可以设置任何组合的读取、写入和执行权限。如果你需要查找具有特定权限设置的文件或目录,你可以使用 find
工具。在 zsh
中,你可以使用通配符限定符代替 find
。这里是你将使用的限定符列表:
对于用户:
-
r:
用户具有读取权限。 -
w
: 这表示用户具有写权限的文件。 -
x
: 用户具有可执行权限。 -
U
: 这会查找属于当前用户的文件或目录。 -
s
: 这会查找具有 SUID 权限设置的文件。
对于组:
-
A
: 该组具有读取权限。 -
I
: 该组具有写权限。 -
E
: 该组具有可执行权限。 -
G
: 这会查找属于当前用户组的文件或目录。 -
S
: 这会查找具有 SGID 权限设置的文件或目录。
对于其他人:
-
R
: 其他人具有读取权限。 -
W
: 其他人具有写权限。 -
X
: 其他人具有可执行权限。 -
t
: 这会查找具有粘滞位的目录。
那么,所有这些是如何工作的呢?假设您在/bin/
目录中,并且想查看所有具有 SUID 权限的文件。只需像这样做:
ubuntu2404% cd /bin
ubuntu2404% ls -ld *(s)
-rwsr-xr-x 1 root root 72792 Apr 9 07:01 chfn
-rwsr-xr-x 1 root root 44760 Apr 9 07:01 chsh
-rwsr-xr-x 1 root root 39296 Apr 8 15:57 fusermount3
-rwsr-xr-x 1 root root 76248 Apr 9 07:01 gpasswd
-rwsr-xr-x 1 root root 51584 Apr 9 14:02 mount
-rwsr-xr-x 1 root root 40664 Apr 9 07:01 newgrp
-rwsr-xr-x 1 root root 64152 Apr 9 07:01 passwd
-rwsr-xr-x 1 root root 55680 Apr 9 14:02 su
-rwsr-xr-x 1 root root 277936 Apr 8 14:50 sudo
-rwsr-xr-x 1 root root 39296 Apr 9 14:02 umount
ubuntu2404%
如果您在自己的家目录中并且想查看整个文件系统中的所有 SUID 文件,只需使搜索递归,如下所示:
ubuntu2404% ls -ld /**/*(s)
-rwsr-xr-x 1 root root 85064 Feb 6 2024 /snap/core20/2264/usr/bin/chfn
-rwsr-xr-x 1 root root 53040 Feb 6 2024 /snap/core20/2264/usr/bin/chsh
. . .
. . .
-rwsr-xr-x 1 root root 154824 Jul 26 02:32 /usr/lib/snapd/snap-confine
ubuntu2404%
与find
不同,ls
并非本质上是递归的。因此,如果您想在当前目录的所有下级子目录中进行搜索,只需这样做:
ubuntu2404% pwd
/home/donnie
ubuntu2404% ls -l **/*(s)
zsh: no matches found: **/*(s)
ubuntu2404%
这与完整的文件系统搜索之间的唯一区别是,我使用了**/*(s)
而不是/**/*(s)
。 (当然,在我的家目录中没有 SUID 文件,这是可以预期的。)
如果您需要实际处理这个ls
输出,您可以创建一个zsh
脚本,像这样创建一个find_suid.sh
脚本:
#!/bin/zsh
ls -ld /**/*(s) > suid_files.txt
这只是一个简单的小脚本,它创建一个包含您系统中所有 SUID 文件的文本文件。但嘿,谁说我们不能把它弄得更花哨一些呢?让我们看看如何在find_suid2.sh
脚本中实现:
#!/bin/zsh
suid_results_file=$1
if [[ -f "$suid_results_file" ]]; then
echo "This file already exists."
exit
fi
ls -ld /**/*(s) > "$suid_results_file"
使用此脚本时,您需要指定要保存结果的文件名。如果该文件名已经存在,脚本会给出警告信息并退出。
您可以以相同的方式使用其他任何通配符限定符。例如,假设您需要搜索整个文件系统中属于您的文件。让我们看看是如何工作的:
ubuntu2404% ls -ld /**/*(U) | less
这会生成一个非常长的列表,因此我必须将其管道输出到less
以防止输出内容滚出屏幕顶部。以下是您将看到的一部分内容:
crw--w---- 1 donnie tty 136, 0 Sep 3 20:04 /dev/pts/0
crw------- 1 donnie tty 4, 1 Sep 3 19:24 /dev/tty1
drwxr-x--- 13 donnie donnie 4096 Sep 3 20:02 /home/donnie
-rw-rw-r-- 1 donnie donnie 44 Jul 24 20:53 /home/donnie/acl_demo.sh
drwxr-xr-x 2 donnie donnie 4096 Jun 29 21:10 /home/donnie/bashlib-0.4
. . .
. . .
-rw-r--r-- 1 donnie donnie 0 Sep 3 19:24 /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/memory.reclaim
-rwxrwxrwx 1 donnie donnie 2155 Jul 5 19:31 /usr/bin/deshc
正如您所见,典型的 Linux 系统中到处都有属于您的文件。
这是zsh
相对于其他所有 shell(包括bash
)的一个明显优势。zsh
的扩展文件通配符功能可以替代find
的大部分功能,但语法更简单。(当然,您也可以使用这个技巧与除了ls
之外的其他实用程序一起使用,例如cp
,mv
,rm
,chmod
或chown
。)
你还可以使用zsh
文件通配符来替代grep
、awk
和tr
等工具。老实说,用于替代这些工具的通配符命令语法复杂到让人头疼,因此你最好还是使用这些工具。不过,使用zsh
文件通配符会使你的脚本变得稍微轻便和更快。而且,在那些可能没有安装这些工具的罕见情况下,你还可以使用文件通配符。
无论如何,如果你想了解zsh
的额外文件通配符功能,这里有一个链接:
Zsh 原生脚本手册:
github.zshell.dev/docs/zsh/Zsh-Native-Scripting-Handbook.html
你还可以通过阅读zshexpn
的手册页来进一步了解文件通配符和变量扩展的相关内容。
好的,我们简要谈谈数组。
理解zsh
数组
这会很快,因为我只想指出zsh
数组的三点。第一点是,zsh
数组的索引编号从 1 开始,而不像bash
数组那样从 0 开始。下面是bash
的示例:
donnie@fedora:~$ mybasharray=(vicky cleopatra horatio)
donnie@fedora:~$ echo ${mybasharray[0]}
vicky
donnie@fedora:~$ echo ${mybasharray[1]}
cleopatra
donnie@fedora:~$
你看到使用索引号 0 时,返回的是列表中的第一个元素,而索引号 1 则返回第二个元素。现在,在zsh
中是这样的:
ubuntu2404% myzsharray=(vicky cleopatra horatio)
ubuntu2404% echo ${myzsharray[0]}
ubuntu2404% echo ${myzsharray[1]}
vicky
ubuntu2404% echo ${myzsharray[2]}
cleopatra
ubuntu2404%
这次,索引号为 0 什么也不会返回,因为索引 0 不存在。
第二个大的区别在于如何查看整个数组的内容。使用bash
时,你需要像这样做:
donnie@fedora:~$ echo ${mybasharray[@]}
vicky cleopatra horatio
donnie@fedora:~$
如果你愿意,你仍然可以在zsh
中这样做,但你不必这样做。你可以像查看普通变量的值一样查看数组的内容,像这样:
ubuntu2404% echo $myzsharray
vicky cleopatra horatio
ubuntu2404%
最后,我想向你展示如何消除数组中的重复条目。为了演示,让我们创建一个包含水果列表的数组,像这样:
ubuntu2404% fruits=(orange apple orange banana kiwi banana apple orange)
ubuntu2404% echo $fruits
orange apple orange banana kiwi banana apple orange
ubuntu2404%
如你所见,列表中有几个重复项。要去除这些重复项,只需这样做:
ubuntu2404% echo ${(u)fruits}
orange apple banana kiwi
ubuntu2404%
将(u)
替换为(U)
会将整个列表以大写字母打印出来。
ubuntu2404% echo ${(U)fruits}
ORANGE APPLE ORANGE BANANA KIWI BANANA APPLE ORANGE
ubuntu2404%
将u
和U
结合使用,你就能去除重复项,并将列表以大写字母打印出来。
ubuntu2404% echo ${(uU)fruits}
ORANGE APPLE BANANA KIWI
ubuntu2404%
为了实际展示,让我们看看fruit_array.sh
脚本:
#!/bin/zsh
fruits=(apple orange banana apple orange kiwi)
echo "Let's look at the entire list of fruits."
for fruit in $fruits; do
echo $fruit
done
echo "*****************"
echo "*****************"
echo "Now, let's eliminate the duplicate fruits."
printf "%s\n" ${(u)fruits}
在第一个代码段中,我使用for
循环打印出fruits
数组中的每个水果。在第二个代码段中,我想打印出每个独立的水果,但不包括重复项。使用echo
会将所有水果打印在同一行,就像我在上面的命令行示例中展示的那样。因此,我没有使用echo
,而是用了printf
。这里,%s
参数告诉printf
打印指定的文本字符串,在本例中就是数组中的每个成员。\n
选项在每个水果后插入换行符。运行脚本的效果如下:
ubuntu2404% ./fruit_array.sh
Let's look at the entire list of fruits.
apple
orange
banana
apple
orange
kiwi
*****************
*****************
Now, let's eliminate the duplicate fruits.
apple
orange
banana
kiwi
ubuntu2404%
其实你可以在数组上做更多的事情,比我在这里所能展示的要多。要了解它们,看看 zshexpn
手册页。
数组部分到此为止。那么现在,让我们来谈谈数学。
增强的数学功能
在 第十一章—执行数学运算 中,我解释了 bash
只有有限的内建数学功能,并且只能处理整数。对于更复杂的运算,你需要在 bash
脚本中使用外部程序,例如 bc
。另一方面,zsh
内建了更强大的数学功能。
对于我们的第一个例子,让我们尝试在 zsh
命令行中将 5 除以 2:
ubuntu2404% echo $((5/2))
2
ubuntu2404%
好吧,那不是我想要的。我应该看到最终答案是 2.5,而不是 2。发生了什么?答案是,为了执行浮动点数学,你必须明确地将其中一个数字表示为浮动点数。所以,我们来修正这个问题。
ubuntu2404% echo $((5.0/2))
2.5
ubuntu2404%
好得多。通过将第一个数字表示为 5.0 而不是 5,浮动点数学得以正常工作。不幸的是,zsh
的数学功能并不完美。我的意思是,它在这个除法例子中工作得很好,但看看当我尝试其他任何操作时会发生什么:
ubuntu2404% echo $((2.1+2.1))
4.2000000000000002
ubuntu2404% echo $((2.1*2))
4.2000000000000002
ubuntu2404% echo $((2.1-2))
0.10000000000000009
ubuntu2404% echo $((2.1%2))
0.10000000000000009
ubuntu2404%
出于某种我不理解的原因,zsh
在小数点后添加了额外的尾随数字。如果它们全是零倒还无所谓,但尾随的 2 和 9 让我们的计算结果出现了问题。幸运的是,我们可以通过简单地将结果赋给一个变量来轻松修正这个问题,就像这样:
ubuntu2404% ((sum=2.1+2.1))
ubuntu2404% echo $sum
4.2000000000
ubuntu2404%
尾随的零仍然存在,但至少多余的 2 和 9 数字不见了。
即使经过了一些相当广泛的研究,我也从未找到可以用来指定小数点后显示数字位数的命令或选项开关。
分组数学运算,以指定它们的优先级,像这样写:
ubuntu2404% ((product=(2.1+2.1)*8))
ubuntu2404% echo $product
33.6000000000
ubuntu2404%
如果简单的数学运算不足以满足你的需求,你可以加载 mathfunc
模块,我将在接下来的章节中展示给你。
使用 zsh 模块
很多 zsh
功能包含在可加载的 zsh
模块中。我无法在本章中涵盖所有的 zsh
模块,因此我们只看一些更有用的模块。
首先,让我们来看一下默认情况下哪些模块会加载到 zsh
会话中:
ubuntu2404% zmodload
zsh/complete
zsh/computil
zsh/main
zsh/parameter
zsh/stat
zsh/terminfo
zsh/zle
zsh/zutil
ubuntu2404%
还有很多更多可选的模块,你可以通过使用 zmodload
命令自行加载,正如我们接下来要看到的。
要查看所有可用的 zsh
模块,请参阅 zshmodules
手册页。
现在,让我们通过查看 mathfunc
模块来更具体一些。
使用 mathfunc 模块
如果你想创建一些非常复杂的数学脚本,而不需要使用像 bc
这样的外部工具,只需加载 mathfunc
模块,如下所示:
ubuntu2404% zmodload zsh/mathfunc
ubuntu2404%
这个模块提供了三角函数、对数、指数运算以及其他一些数学函数。你可以这样查看模块提供的所有函数:
ubuntu2404% zmodload -lF zsh/mathfunc
+f:abs
+f:acos
+f:acosh
+f:asin
+f:asinh
. . .
. . .
+f:y0
+f:y1
+f:yn
ubuntu2404%
所以现在,假设你想查看 9 的平方根。可以这样做:
ubuntu2404% ((squareroot=sqrt(9)))
ubuntu2404% echo $squareroot
3.0000000000
ubuntu2404%
现在,让我们把这个放入 squareroot.sh
脚本中:
#!/bin/zsh
zmodload zsh/mathfunc
your_number=$1
((squareroot=sqrt(your_number)))
echo $squareroot
请注意,我必须在此脚本的开头放置一个zmodload zsh/mathfunc
命令。这是因为每次关闭终端或注销计算机时,您通过命令行加载的任何模块都会自动卸载。因此,您需要将此zmodload
命令添加到脚本中。
在这里,我使用your_number
变量来保存用户为$1
参数指定的数字值。让我们看看它是否有效。
ubuntu2404% ./squareroot.sh 9
3.0000000000
ubuntu2404% ./squareroot.sh 25
5.0000000000
ubuntu2404% ./squareroot.sh 2
1.4142135624
ubuntu2404%
哦,没错,它运行得很好,而且完全不需要修改任何外部程序,比如bc
。
其他所有mathfunc
函数也以类似方式工作。运用你的想象力,以及我为bash
展示的脚本概念,来创建你自己的zsh
数学脚本。
这就是你对mathfunc
模块的简介。接下来,我们简要看看datetime
模块。
datetime
模块
datetime
模块的功能几乎与date
命令相同。你可以使用其中任何一个做一些酷的事情,比如在文件名中创建带时间戳的文件。它默认没有加载,因此我们现在来加载它:
ubuntu2404% zmodload zsh/datetime
ubuntu2404%
现在,让我们看看它提供了什么:
ubuntu2404% zmodload -lF zsh/datetime
+b:strftime
+p:EPOCHSECONDS
+p:EPOCHREALTIME
+p:epochtime
ubuntu2404%
下面是你正在查看的内容的详细说明:
-
strftime
:这是此模块中唯一的函数。你可以像使用date
命令一样使用它。 -
EPOCHSECONDS
:这个环境变量是一个整数值,表示自 Unix 纪元开始以来经过的秒数。这个变量在bash
中也可用。 -
EPOCHREALTIME
:这个环境变量是一个浮动值,表示自 Unix 纪元开始以来经过的秒数。根据系统的不同,这个值的精确度可能达到纳秒或微秒级别。这个变量在bash
中也可用。 -
epochtime
:这个变量包含两个组件。它像EPOCHREALTIME
变量,只不过小数点被空格代替。这个变量在bash
中不可用。
Unix 纪元从 1970 年 1 月 1 日开始。早期的 Unix 开发者决定,设置 Unix 计算机时间的最简单方法就是以自该日期以来经过的秒数来表示时间。
你可以通过以下方式查看当前三个变量的值:
ubuntu2404% echo $EPOCHSECONDS
1725470539
ubuntu2404% echo $EPOCHREALTIME
1725470545.8938724995
ubuntu2404% echo $epochtime
1725470552 124436538
ubuntu2404%
现在,让我们创建一个使用strftime
函数的zsh_time.sh
脚本:
#!/bin/zsh
zmodload zsh/datetime
timestamp=$(strftime '%A-%F_%T')
echo "I want to create a file at $timestamp." > "timefile_$timestamp.txt"
strftime
函数使用与date
命令相同的日期和时间格式选项。在这种情况下,我们有:
-
%A
:这是星期几。 -
%F
:这是以 YYYY-MM-DD 格式表示的日期。 -
%T
:这是以 HH:MM:SS 格式表示的时间。
使用date
和strftime
的唯一区别是,使用date
时,必须在格式选项字符串前加上+
,像这样:
date +'%A-%F-%T'
strftime
函数有自己的手册页,你可以通过运行man strftime
来查看。
在我运行脚本之后,我应该会得到我的文件:
ubuntu2404% ./zsh_time.sh
ubuntu2404% ls -l timefile_*
-rw-rw-r-- 1 donnie donnie 58 Sep 4 18:16 timefile_Wednesday-2024-09-04_18:16:37.txt
ubuntu2404% cat timefile_Wednesday-2024-09-04_18:16:37.txt
I want to create a file at Wednesday-2024-09-04_18:16:37.
ubuntu2404%
我已经介绍了我认为最有用的模块。如果你想阅读其他zsh
模块的内容,可以查看zshmodules
手册页。
我认为这差不多可以作为我们对zsh
脚本的介绍了。让我们总结一下,继续前进。
总结
在这一章中,我向你介绍了在zsh
中编写脚本的概念,而不是在bash
中编写。我从展示如何安装zsh
开始,如果它还没有安装的话。我展示了bash
和zsh
之间的差异,这些差异可能会影响你编写zsh
脚本的方式。这些差异包括文件通配符、变量扩展、数组索引和数学运算的不同。在过程中,我向你展示了这些zsh
特性如何帮助你编写比bash
脚本更简单、更有功能的脚本。
zsh
脚本的主要问题,除了bash
更加普及之外,就是缺乏文档。关于设置zsh
用户环境的教程和文章有很多,但关于zsh
脚本的很少。希望你能利用我在这里提供的知识编写一些酷炫的zsh
脚本。
在下一章,也是最后一章中,我将向你介绍如何在 Linux 上使用 PowerShell。(是的,你没看错。)我们在那里见。
问题
-
你会使用哪个命令来查看已加载到你的
zsh
会话中的所有zsh
模块?-
zmodls
-
zmod -l
-
zmodload -l
-
zmodload
-
-
你想加载数学函数模块。你会使用以下哪个命令?
-
zmod mathfunc
-
zmod zsh/mathfunc
-
zmodload -l mathfunc
-
zmodload -l zsh/mathfunc
-
zmodload zsh/mathfunc
-
-
bash
和zsh
处理数组的主要区别是什么?-
没有区别。
-
bash
数组索引从 1 开始,zsh
数组索引从 0 开始。 -
bash
数组索引从 0 开始,zsh
数组索引从 1 开始。 -
bash
可以处理数组,但zsh
不能。
-
-
你会使用哪个
zsh
命令来查看目录中所有的.png
文件和.PNG
文件?-
ls *.png
-
ls (#i).png
-
ls (#l).PNG
-
ls *.PNG
-
-
你只想查看当前工作目录中的空目录。你会使用以下哪个
zsh
命令?-
ls -l *(/^F)
-
ls -l *(^F)
-
ls -l *(F)
-
ls -ld
-
进一步阅读
-
Z Shell 手册:
zsh-manual.netlify.app/
-
什么是 ZSH,为什么你应该使用它而不是 Bash?:
www.howtogeek.com/362409/what-is-zsh-and-why-should-you-use-it-instead-of-bash/
-
ZSH 入门:探索 Linux 的优雅 Shell:
www.fosslinux.com/133167/zsh-for-beginners-exploring-linuxs-elegant-shell.htm
-
如何为 ZSH 编写脚本:
www.linuxtoday.com/blog/writing-scripts-for-zsh/
答案
-
d
-
e
-
c
-
b
-
a
加入我们的 Discord 社区!
与其他用户、Linux 专家以及作者本人一起阅读这本书。
提出问题,为其他读者提供解决方案,通过“问我任何问题”(Ask Me Anything)环节与作者交流,等等。扫描二维码或访问链接加入社区。
第二十三章:在 Linux 上使用 PowerShell
PowerShell 最初是一个封闭源代码的专有产品,只能安装在 Windows 操作系统上。然而现在,它是自由开源软件,并且可以在基于 Linux 和 macOS 的机器上自由使用。
在本章中,我无法给你提供一整套 PowerShell 课程,因为这需要一本完整的书籍。而是,我将提供 PowerShell 哲学的高层次概述,展示如何安装它,并提供一些有用的示例。我还会说明为什么你作为 Linux 或 Mac 管理员,可能会想要学习 PowerShell。当然,你会在整个章节中以及 进一步阅读 部分找到许多 PowerShell 参考资料的链接。
本章的主题包括:
-
在 Linux 和 macOS 上安装 PowerShell
-
Linux 和 Mac 管理员学习 PowerShell 的原因
-
PowerShell 脚本与传统 Linux/Unix 脚本的区别
-
查看可用的 PowerShell 命令
-
获取 PowerShell 命令的帮助
-
实际跨平台 PowerShell 脚本
如果你准备好了,我们开始吧。
技术要求
你可以使用 Fedora、Ubuntu 或 Debian 虚拟机进行操作。你不会使用 FreeBSD 或 OpenIndiana 虚拟机,因为 PowerShell 不支持这些操作系统。
一如既往,你可以通过以下方式获取脚本:
git clone https://github.com/PacktPublishing/The-Ultimate-Linux-Shell-Scripting-Guide.git
在 Linux 和 macOS 上安装 PowerShell
我们将首先查看在 Linux 上安装 PowerShell 的方法,然后再看看如何在 macOS 上安装它。
通过 snap 包在 Linux 上安装 PowerShell
snapd
系统是一个通用的软件打包系统,由 Ubuntu 的开发者发明。它在 Ubuntu 操作系统中默认安装,并且可以安装在大多数其他 Linux 发行版上。
如果你正在设置一个新的 Ubuntu 服务器,你可以选择从 Ubuntu 安装程序中安装一些 snap 包,包括 PowerShell。如下所示:
图 23.1:在 Ubuntu 安装过程中选择 PowerShell snap 包
你可以在非 Ubuntu 发行版上安装 snapd
,但是不同发行版的安装说明有所不同。你可以在这里找到相关的安装说明:
snapcraft.io/docs/installing-snapd
安装完 snapd
后,你可以通过以下方式安装 PowerShell:
sudo snap install powershell --classic
在 Fedora 上安装 PowerShell
在 Fedora 系统上,你可以使用多种方法安装 PowerShell。例如,你可以通过 .rpm
包或 Docker 容器来安装它。你可以在这里找到每种方法的详细说明:
fedoramagazine.org/install-powershell-on-fedora-linux/
在 macOS 上安装 PowerShell
你可以通过 Homebrew 系统在 Mac 上安装 PowerShell。你可以在这里找到详细的安装说明:
启动 PowerShell
一旦 PowerShell 安装完成,输入 pwsh
命令来启动它。你的命令行界面会变成这样:
PS /home/donnie>
现在,在我展示任何示范之前,我们先解决今天的燃眉之急。为什么像你这样使用 Linux 或 Mac 的人需要学习一个由微软发明的脚本语言?
Linux 和 Mac 管理员学习 PowerShell 的理由
在 PowerShell 中编写脚本与在传统的 Linux 和 Unix shell 中编写脚本有些不同。但其实并不难,一旦习惯了,你甚至可能会喜欢它。无论如何,Linux 管理员有一些合理的理由想要学习 PowerShell。我们来看看其中的一些原因。
使用混合操作系统环境
第一个理由仅仅是出于便利性和灵活性的考虑。许多企业和组织运行混合的 Linux 和 Windows 服务器,并且通常在工作站上运行 Windows。如果你能够在 Windows 和 Linux 平台上使用相同的脚本语言,可能会非常有帮助。而且,如果你是 Windows 管理员,现在需要学习 Linux 管理,PowerShell 可能会让你更容易做到,因为你可能已经掌握了它。事实上,让我告诉你我学习 PowerShell 的一个原因。
在 2010 年和 2011 年这两年里,我和一个客户合作,他将 Nagios 公司作为他的客户之一。Nagios 公司生产 Nagios 网络监控系统,可以监控几乎所有类型的网络设备。(这包括运行 Linux 或 Windows 的服务器,以及各种网络设备。)我的客户的工作分为三个部分:编写培训文档、进行 Nagios 培训课程,并飞往全国各地为 Nagios 公司的客户设置 Nagios 监控系统。
反正,每当我的客户需要对 Windows 服务器做些什么时,他都会找我,因为我懂 Windows Server,而他不懂。
好吧,我一直守着一个黑暗的秘密,希望你们不要对我有不好的看法。其实早在 2006 年,在我接触 Linux 之前,我已经获得了 Microsoft Certified Systems Engineer (MCSE) 认证,专注于 Windows Server 2003。当我第一次接触 Linux 时,我以为我再也不会用到我的 MCSE 培训了。天啊,我错得真离谱。
不幸的是,我的 MCSE 培训并没有涉及 PowerShell,因为它在那时还没有被发明出来。因此,为了为我的客户提供 Windows Server 监控解决方案,我不得不自学 PowerShell 脚本编写。
这里的关键是,如果你参与任何类型的网络监控解决方案的设置,甚至如果你是 Linux 管理员,学习 PowerShell 也会很有用。
PowerShell 命令可以更简洁
第二个理由是,在某些情况下,PowerShell 更容易处理。你已经看到,在传统的 Linux/Unix shell 语言中,执行某些任务的命令可能变得很长且复杂。例如,假设你想查看所有占用 200 兆字节或更多机器随机存取内存(RAM)的系统进程,并且你只想看到输出的某些字段。在传统的 Linux/Unix 方法中,你需要使用ps
命令加上适当的选项开关,然后将ps
输出传给awk
,像这样:
donnie@fedora:~$ ps -eO rss | awk -F' ' '{ if($2 >= (1024*200)) {printf("%s\t%s\t%s\n",$1,$2,$6);}}'
PID RSS COMMAND
3215 338896 /usr/lib64/chromium-browser/chromium-browser
3339 247596 /usr/lib64/chromium-browser/chromium-browser
. . .
. . .
21502 614792 /usr/lib64/firefox/firefox
23451 369392 /usr/lib64/firefox/firefox
donnie@fedora:~$
那么,这有什么问题呢?首先,你需要熟悉ps
的选项开关,以及各种ps
输出字段。在这个例子中,ps -e
命令会显示类似这样的内容:
donnie@fedora:~$ ps -e
PID TTY TIME CMD
1 ? 00:00:04 systemd
2 ? 00:00:00 kthreadd
3 ? 00:00:00 pool_workqueue_release
. . .
. . .
28122 ? 00:00:00 Web Content
28229 ? 00:00:00 kworker/7:0
28267 pts/1 00:00:00 ps
donnie@fedora:~$
但是,这并没有显示RSS
字段,而这个字段包含了我们想要看到的内存使用数据。所以,我将添加O rss
选项,这样最终的ps
命令就会是ps -eO rss
。现在它应该显示类似这样的内容:
PID RSS S TTY TIME COMMAND
1 29628 S ? 00:00:04 /usr/lib/systemd/systemd --switched-root --sys
2 0 S ? 00:00:00 [kthreadd]
3 0 S ? 00:00:00 [pool_workqueue_release]
. . .
. . .
27199 0 I ? 00:00:00 [kworker/8:2]
27262 62216 S ? 00:00:00 /usr/lib64/firefox/firefox -contentproc -child
27300 5060 R pts/1 00:00:00 ps -eO rss
donnie@fedora:~$
现在的问题是,这比我们想要看到的要多。将这个输出传给awk
,可以过滤掉所有不需要的内容。但为了使用awk
,你需要知道ps
输出的每一列是什么,以便知道在awk
命令中列出哪些字段。在这个例子中,我们想看到PID
、RSS
和COMMAND
字段,它们分别是第 1 列、第 2 列和第 6 列。我们想查看所有第 2 列大于 200 兆字节的进程,在这里我们将其表示为(1024*200)
。
请记住,兆字节的真正定义是 1024 千字节。由于awk
无法理解字节、千字节、兆字节等单位,你必须将内存测量表达为数学公式或正常整数。(1024*200 的整数结果是 204800。)
最后,printf
命令会按照正确的格式选项打印出我们想要查看的字段。
好的,这是可行的。但是,我们能用 PowerShell 简化一下吗?让我们看看。
PS /home/donnie> Get-Process | Where-Object WorkingSet -ge 200MB
NPM(K) PM(M) WS(M) CPU(s) Id SI ProcessName
------ ----- ----- ------ -- -- -----------
0 0.00 331.27 40.27 3215 …10 chromium-browser --enable-n…
0 0.00 227.35 11.00 3674 …10 chromium-browser --type=ren…
0 0.00 221.48 15.93 3369 …10 chromium-browser --type=ren…
0 0.00 228.37 19.19 3588 …10 chromium-browser --type=ren…
. . .
. . .
0 0.00 402.61 271.87 8737 …10 soffice.bin
0 0.00 971.86 1,004.20 5869 …12 VirtualBoxVM
PS /home/donnie>
是的,我觉得这样更简单了。它更简短,这样也不容易出错。你只需要知道WorkingSet (WS)
字段,它相当于ps
中的RSS
字段。此外,你可以看到,我们可以将一个命令(Get-Process
)的输出传递给另一个命令(Where-Object
),就像我们在 Linux/Unix 中将一个工具的输出传给另一个工具一样。最棒的是,这个命令非常直观,我认为即使我不解释,你也能理解它在做什么。
好的,让我们继续学习 PowerShell 的下一个理由。
增强的内建数学功能
如果你需要创建大量数学计算的脚本,PowerShell 可能正是你需要的。你可以在不加载任何外部程序的情况下进行浮点数学运算,并且提供了一个函数库来支持高级数学函数。让我们看一些例子。
首先,让我们做一个简单的除法操作,像这样:
PS /home/donnie> 5 / 2
2.5
PS /home/donnie>
如你所见,PowerShell 默认进行浮点数学运算,不需要调用任何特殊技巧。现在,让我们在math1.ps1
脚本中加入一些不同的数学题目,它看起来是这样的:
param([Float]$number1 = "" , [Float]$number2 = "")
$sum = $number1 + $number2
Write-Host "The sum of these numbers is: " $sum
Write-Host "***********"
$divideresult= $number1 / $number2
Write-Host "Division of these two numbers results in: "$divideresult
Write-Host "***********"
$multiplyresult = $number1 * $number2
Write-Host "Multiplying these two numbers results in: " $multiplyresult
Write-Host "***********"
$modulo = $number1 % $number2
Write-Host "The remainder from dividing these two numbers is: " $modulo
这里首先要注意的是顶部的param
行。这是用来创建位置参数的指令,我将用它将参数传递到脚本中。我没有使用$1
和$2
作为位置参数,而是用param
来创建$number1
和$number2
的位置参数。注意我只用一个param
指令就创建了这两个位置参数。如果我使用两个单独的param
行,脚本会出错,因为它无法识别第二行param
。另外,我已经指定了number1
和number2
这两个用于位置参数的变量类型是浮点数。
剩下的脚本是一个简单的演示,展示如何进行加法、除法、乘法和取模操作。我没有使用echo
或printf
语句,而是使用了 PowerShell 的本地命令Write-Host
。另外,请注意,无论是定义变量还是调用其值,变量名前面都会加上$
符号。现在,让我们使用 5 和 2 作为参数来运行脚本:
PS /home/donnie> ./math1.ps1 5 2
The sum of these numbers is: 7
***********
Division of these two numbers results in: 2.5
***********
Multiplying these two numbers results in: 10
***********
The remainder from dividing these two numbers is: 1
PS /home/donnie>
够简单吧?好吧,在 PowerShell 中做更复杂的数学计算也同样容易。为了演示,我们创建math2.ps1
脚本,如下所示:
param([Float]$number1 = "")
$tangent = [math]::Tan($number1/180*[math]::PI)
Write-Host "The tangent of $number1 degrees is: $tangent."
Write-Host "***********"
$cosine = [math]::Cos($number1/180*[math]::PI)
Write-Host "The cosine of $number1 degrees is: "$cosine
Write-Host "***********"
$squareroot = [math]::Sqrt($number1)
Write-Host "The square root of $number1 is: " $squareroot
Write-Host "***********"
$logarithm = [math]::Log10($number1)
Write-Host "The base 10 logarithm of $number1 is: " $logarithm
在这里,你可以看到我如何使用[math]::
结构来调用计算正切、余弦、平方根和对数的函数。唯一的小问题是,正切和余弦函数默认使用弧度。如果想用角度来计算,我需要将输入的度数除以 180,然后再乘以π的值。
现在,让我们用 45 作为参数运行这个脚本:
PS /home/donnie> ./math2.ps1 45
The tangent of 45 degrees is: 1.
***********
The cosine of 45 degrees is: 0.7071067811865476
***********
The square root of 45 is: 6.708203932499369
***********
The base 10 logarithm of 45 is: 1.6532125137753437
PS /home/donnie> vim
哦,太好了,效果不错。
你可以在这里阅读更多关于 PowerShell 数学的内容,包括它的数学函数的完整列表:ss64.com/ps/syntax-math.html
接下来,让我们看看 PowerShell 与传统 Shell 的一些基本区别。
PowerShell 脚本与传统 Linux/Unix 脚本的区别
PowerShell 仍然使用许多与其他脚本语言相同的编程结构,如函数、循环、if
结构等。不过,正如你已经看到的,PowerShell 在基础设计上也有一些不同之处,使其与传统的 Linux/Unix Shell 脚本有所区别。让我们来看几个例子。
使用文件名扩展名和可执行权限
在本书中,你看到我创建了带有 .sh
扩展名的普通 Linux/Unix 脚本。实际上,在普通的 Linux/Unix 脚本中你不必使用任何文件扩展名。这意味着 somescript.sh
脚本只要文件名为 somescript
也能正常工作。但是对于 PowerShell 脚本,.ps1
扩展名对所有 PowerShell 脚本都是必需的。没有这个扩展名,你的 PowerShell 脚本将无法运行。
另一方面,不必像传统的 Linux/Unix 脚本那样设置 PowerShell 脚本的可执行权限。只要脚本文件名以 .ps1
结尾,并且你在 PowerShell 环境中,脚本就会运行。
PowerShell 是面向对象的
传统的 Linux 和 Unix shell 脚本语言是严格的过程式语言。这意味着你创建的程序或脚本中的命令将处理来自某些外部来源的数据。
这些源可以是键盘、文件、数据库,甚至是嵌入在脚本或程序中的here文档。请理解数据和处理数据的命令是完全独立的实体。
在面向对象编程中,有时会看到称为OOP的东西,数据和操作数据的过程被打包在一个对象中。这使你能够做一些很酷的事情,比如告诉一个数值对象将自己加倍。它还允许你创建从其父对象继承特性的其他对象。
我知道这只是面向对象编程的简化解释。但是,详细的解释超出了本书的范围。当然,如果你曾经在像 C++ 或 Java 这样的语言中编程过,你会明白我在说什么。
PowerShell 使用命令 Cmdlets
大多数 PowerShell 命令实际上被称为Cmdlets,你会发音为command-lets,而其他一些则是别名或函数。每个 Cmdlet 的形式为由连字符分隔的动词和名词,例如 Get-Content
用于从文件获取(读取),或者 Set-Content
用于向文件写入。在 Windows 机器上的工作方式是每个 Windows 子系统都有自己的一组 Cmdlets。例如,作为域名系统(DNS)服务器的 Windows 机器会有一组用于处理 DNS 的 Cmdlets,而Microsoft Exchange服务器则会有用于处理 Exchange 的 Cmdlets。
PowerShell 的 Linux 和 Mac 版本拥有较少的 Cmdlets 集合,因为所有的 Windows 特定内容都被剔除了。你会找到适用于 Linux 或 macOS 的所需内容,但其他内容则没有了。
如你所见,这些 Cmdlets 与 Linux/Unix 管理员习惯使用的命令有很大不同。因此,你可能考虑使用别名来帮助顺利过渡到 PowerShell。让我们接着来看看这个。
在 PowerShell 上使用别名
在 Windows 机器上,PowerShell 已经设置了别名,允许你使用 Linux/Unix 命令替代 PowerShell 命令。(微软这样做是为了让 Linux/Unix 管理员更容易学习 PowerShell。)例如,以下是如何在 PowerShell for Linux 上列出当前目录中文件的方式:
PS /home/donnie> Get-ChildItem
Directory: /home/donnie
UnixMode User Group LastWriteTime Size Name
-------- ---- ----- ------------- ---- ----
drwxr-xr-x donnie donnie 6/29/2024 21:10 4096 bashlib-0.4
drwx------ donnie donnie 7/4/2024 23:59 4096 demo_files
. . .
. . .
-rw-rw-r-- donnie donnie 9/3/2024 22:26 67 zsh_fruits.txt
-rwxrw-r-- donnie donnie 9/4/2024 18:17 141 zsh_time.sh
PS /home/donnie>
你会看到 Get-ChildItem
命令几乎与 ls -l
命令执行相同的工作。在 Windows 机器上,已经有一个 PowerShell 别名允许你使用 ls
命令来运行 Get-ChildItem
命令。以下是在我其中一台 Windows 机器上的效果:
图 23.2:在 Windows 上使用 ls PowerShell 别名
所以,是的,这看起来就像 Get-ChildItem
命令。但是,如果你在 Linux 或 Mac 上打开 PowerShell 会话并运行 ls
命令,你不会调用别名,因为默认情况下没有设置别名。相反,你将调用来自你的 Linux 或 Mac 操作系统的实际 ls
命令。以下是它的效果:
PS /home/donnie> ls
acl_demo.sh rootlock_1.sh
bashlib-0.4 rootlock_2.sh
date_time.sh rsync_password.sh
. . .
. . .
hello.sh zsh_fruits.txt
My_Files zsh_time.sh
rootlock_1a.sh
PS /home/donnie>
你会发现这看起来一点也不像 Get-ChildItem
。实际上,这只是你普通 ls
命令的正常输出。
幸运的是,你可以在 PowerShell 上创建自己的别名,这可以使得输入 PowerShell 命令更加容易。你可以使用 Set-Alias
或 New-Alias
命令来创建别名。让我们首先创建一个别名,让 ls
命令运行 Get-ChildItem
命令,使用 Set-Alias
:
PS /home/donnie> Set-Alias -Name ls -Value Get-ChildItem
PS /home/donnie> ls
Directory: /home/donnie
UnixMode User Group LastWriteTime Size Name
-------- ---- ----- ------------- ---- ----
drwxr-xr-x donnie donnie 6/29/2024 21:10 4096 bashlib-0.4
drwx------ donnie donnie 7/4/2024 23:59 4096 demo_files
. . .
. . .
-rw-rw-r-- donnie donnie 9/3/2024 22:26 67 zsh_fruits.txt
-rwxrw-r-- donnie donnie 9/4/2024 18:17 141 zsh_time.sh
PS /home/donnie>
另一种方法是使用 New-Alias
,如下所示:
PS /home/donnie> New-Alias ls Get-ChildItem
PS /home/donnie>
无论哪种方式都能正常工作,但这只是一个临时修复。一旦关闭 PowerShell 会话,你将失去这个别名。要使你的别名永久生效,你需要编辑你的 PowerShell 配置文件。你可以通过以下方式找到它的位置:
PS /home/donnie> echo $profile
/home/donnie/.config/powershell/Microsoft.PowerShell_profile.ps1
PS /home/donnie>
唯一的问题是 .config/
目录是存在的,但 powershell/
子目录和 Microsoft.PowerShell_profile.ps1
文件都不在。因此,你需要创建它们。
你可以像这样使用你的正常 Linux/Mac 命令来创建 powershell/
子目录:
PS /home/donnie> mkdir .config/powershell
PS /home/donnie>
然后,使用你喜欢的文本编辑器在新的 powershell/
子目录中创建 Microsoft.PowerShell_profile.ps1
文件。添加以下行以创建 ls
到 Get-ChildItem
的别名:
New-Alias ls Get-ChildItem
你可以在这个文件中添加任意多个别名。只需记得退出你的 PowerShell 会话,然后重新登录以确保新配置文件生效。
重要提示:如果你打算创建在多台机器上运行的 PowerShell 脚本,请注意你创建的别名可能不会在某些机器上可用。在这种情况下,你最好放弃使用自己的别名,而是直接使用原生的 PowerShell 命令。
接下来,让我们看看如何查看可用的 PowerShell 命令。
查看可用的 PowerShell 命令
要查看可用的命令,只需执行:
PS /home/donnie> Get-Command
CommandType Name Version S
o
u
r
c
e
----------- ---- ------- -
Alias Get-PSResource 1.0.4.1 M
Function cd..
Function cd\
Function cd~
Function Clear-Host
Function Compress-Archive 1.2.5 M
. . .
. . .
Cmdlet Write-Verbose 7.0.0.0 M
Cmdlet Write-Warning 7.0.0.0 M
PS /home/donnie>
在 CommandType
下,你会看到别名、函数和 Cmdlet。在 Source
列下,你会看到字母 M
,这意味着这些命令都存储在某个 PowerShell 模块中。
当然,Linux 上的 PowerShell 命令远没有 Windows 上的多。要查看有多少个命令,只需执行以下命令:
PS /home/donnie> Get-Command | Measure-Object | Select-Object -Property Count
Count
-----
293
PS /home/donnie>
在这里,我们首先将 Get-Command
Cmdlet 的输出通过管道传输到 Measure-Object
Cmdlet。根据帮助文件,Measure-Object
的官方解释是它计算某些类型对象的属性值。在这个例子中,我们只想查看其中的一项计算,显示有多少个命令。我们通过将 Measure-Object
的输出管道传输到 Select-Object -Property Count
来实现这一点,它与 Linux/Unix 中的 wc -l
命令作用相同。经过这些操作,我们看到这台 Linux 机器上有 293 个可用的 PowerShell 命令。
正如我在 PowerShell 使用 Cmdlets 部分中提到的,所有 Cmdlet 都是 动词-名词 格式。你可以使用 Get-Command
,并结合 -Verb
或 -Noun
选项,查看某个特定动词或名词的所有命令。例如,让我们查看与 Date
名词相关的所有可用命令:
PS /home/donnie> Get-Command -Noun 'Date'
CommandType Name Version S
o
u
r
c
e
----------- ---- ---- -
Cmdlet Get-Date 7.0.0.0 M
Cmdlet Set-Date 7.0.0.0 M
PS /home/donnie>
我们看到 Date
只有两个 Cmdlet。让我们看看 Set
动词有多少个:
PS /home/donnie> Get-Command -Verb 'Set'
CommandType Name Version S
o
u
r
c
e
----------- ---- ------- -
Function Set-HostnameMapping 1.0.1 H
Function Set-PSRepository 2.2.5 P
Cmdlet Set-Alias 7.0.0.0 M
Cmdlet Set-Clipboard 7.0.0.0 M
. . .
. . .
Cmdlet Set-StrictMode 7.4.5.500 M
Cmdlet Set-TraceSource 7.0.0.0 M
Cmdlet Set-Variable 7.0.0.0 M
PS /home/donnie>
现在,你可能在想如何找出这些 PowerShell 命令都做什么。好吧,让我们接着看。
获取 PowerShell 命令帮助
这个很简单。只需使用 Get-Help
Cmdlet,后跟你需要了解信息的命令名称。例如,你可以这样查看 Get-Module
Cmdlet 的信息:
PS /home/donnie> Get-Help Get-Module
你将看到的将类似于普通的 Linux/Unix 手册页。
在每个 Get-Help
屏幕的底部,你会看到一些命令,告诉你如何查看更多的信息。例如,如果你需要查看如何使用 Get-Module
Cmdlet 的示例,可以这样做:
PS /home/donnie> Get-Help Get-Module -Examples
有时候,你需要学习的命令的帮助页面可能不可用。在这种情况下,可以运行 Update-Help
Cmdlet,或者将 -Online
选项附加到 Get-Help
命令的末尾。
另一个很好的资源是 PowerShell 命令页面,你可以在这里找到:ss64.com/ps/
(当然,很多列出的命令是 Windows 专用的,但你也会发现不少是跨平台的。)
好的,我想这就足够解释 PowerShell 的理论了。让我们来看几个实际的 PowerShell 脚本示例。
跨平台 PowerShell 脚本的实际应用
为了找出一些跨平台的 PowerShell 脚本的实际应用示例,我在 DuckDuckGo 上搜索了 PowerShell 脚本示例 Linux 这个文本字符串。我发现的最酷的东西就是 Github 上的 Mega Collection of PowerShell Scripts。你可以通过执行以下命令使用 git
下载它:
git clone https://github.com/fleschutz/PowerShell.git
下载完成后,进入PowerShell/scripts/
目录,看看那里面有什么。作为我们的第一个例子,让我们看一个简单的示例,它可以在所有平台上运行。
write-marquee.ps1 脚本
write-marquee.ps1
脚本真正实现了跨平台,因为它没有使用任何特定于某一操作系统的命令。它所做的只是创建一个在屏幕上流动的跑马灯消息。我不能在这里展示整个脚本,但你可以查看你本地的副本。那么,让我们只看它的各个部分,看看它是如何设置的。
在脚本的顶部,你会看到注释部分,这部分被<#
和#>
包围。实际上,这些不仅仅是注释。如果你执行Get-Help ./write-marquee.ps1
,你会看到<#
和#>
之间的所有内容会显示为帮助屏幕,像这样:
PS /home/donnie/PowerShell/scripts> Get-Help ./write-marquee.ps1
NAME
/home/donnie/PowerShell/scripts/write-marquee.ps1
SYNOPSIS
Writes text as marquee
. . .
. . .
REMARKS
To see the examples, type: "Get-Help /home/donnie/PowerShell/scripts/write-marquee.ps1 -Examples"
For more information, type: "Get-Help /home/donnie/PowerShell/scripts/write-marquee.ps1 -Detailed"
For technical information, type: "Get-Help /home/donnie/PowerShell/scripts/write-marquee.ps1 -Full"
For online help, type: "Get-Help /home/donnie/PowerShell/scripts/write-marquee.ps1 -Online"
PS /home/donnie/PowerShell/scripts>
接下来,param([string]$Text =
这一行创建了一个包含跑马灯消息的字符串类型变量。它看起来像这样:
param([string]$Text = "PowerShell is powerful - fully control your computer! PowerShell is cross-platform - available for Linux, Mac OS and Windows! PowerShell is open-source and free - see the GitHub repository at github.com/PowerShell/PowerShell! PowerShell is easy to learn - see the tutorial for beginners at guru99.com/powershell-tutorial.html! Powershell is fully documented - see the official PowerShell documentation at docs.microsoft.com/en-us/powershell", [int]$Speed = 60) # 60 ms pause
变量定义末尾的[int]$Speed = 60) # 60 ms pause
部分决定了跑马灯消息在屏幕上流动的速度。
接下来的StartMarquee
函数是这样的:
图 23.3:StartMarquee 函数
在绘制跑马灯框并使用$LinePos
行确保所有内容正确定位后,你会看到foreach
循环,它会一字一字地将消息打印到屏幕上。
最后,在函数定义之后,你会看到函数调用:
StartMarquee " +++ $Text +++ $Text +++ $Text +++ $Text +++ $Text +++ $Text +++ $Text +++ $Text +++ $Text +++ $Text +++ $Text +++ $Text +++
"
exit 0 # success
函数调用将包含跑马灯消息的$Text
变量传递给StartMarquee
函数。在每次迭代之间,$Text
消息将插入三个加号符号(+++
)。脚本会一直运行,直到遍历完所有的$Text
消息。无论如何,下面是我在 Ubuntu Server 虚拟机上运行时的效果:
图 23.4:运行 write-marquee.ps1 脚本
很酷吧?现在,让我们看看更酷的东西。
check-cpu.ps1 脚本
check-cpu.ps1
脚本尝试检索机器 CPU 的型号和名称,并获取 CPU 温度。
如果你想查看 CPU 温度,你需要直接在主机 Linux 机器上运行这个脚本,而不是在虚拟机中运行。在虚拟机中运行将无法让脚本访问主机的温度传感器。
我说“尝试”,因为当我在我的 Fedora 工作站上运行这个脚本时,显示的结果是这样的:
PS /home/donnie/PowerShell/scripts> ./check-cpu.ps1
✅ 64-bit CPU (16 cores, ) - 48°C OK
PS /home/donnie/PowerShell/scripts>
所以,它只显示我正在运行某种 64 位 CPU,且有 16 个核心。实际情况是,这台机器运行的是英特尔(R) Xeon(R) CPU E5-2670,具有八个核心并启用了超线程。但是,如果我在 Windows 10 机器上运行相同的脚本,我会看到这样的结果:
图 23.5:Windows 机器上的 check-cpu.ps1 脚本
在这台古老的(大约 2008 年的)戴尔 Windows 10 Pro 机器上,脚本正确地报告 CPU 是旧款 Core 2 Quad,型号为 Q9550。而另一方面,脚本在 Linux 机器上正确地报告了 CPU 温度,但在 Windows 机器上则没有。这个部分很容易解释。只是一些计算机主板,特别是旧的主板,带有的温度传感器不兼容现代操作系统。
至于正确获取 CPU 型号,似乎 Linux 的 PowerShell 命令做不到这一点,而 Windows 的命令则可以。我们来看看脚本,看看发生了什么。
在check-cpu.ps1
脚本的顶部,你会看到GetCPUArchitecture
函数,它是这样的:
function GetCPUArchitecture {
if ("$env:PROCESSOR_ARCHITECTURE" -ne "") { return "$env:PROCESSOR_ARCHITECTURE" }
if ($IsLinux) {
$Name = $PSVersionTable.OS
if ($Name -like "*-generic *") {
if ([System.Environment]::Is64BitOperatingSystem) { return "x64" } else { return "x86" }
} elseif ($Name -like "*-raspi *") {
if ([System.Environment]::Is64BitOperatingSystem) { return "ARM64" } else { return "ARM32" }
} elseif ([System.Environment]::Is64BitOperatingSystem) { return "64-bit" } else { return "32-bit" }
}
}
正如你所见,这里的语法与正常的 Linux/Unix 脚本有些不同。一个区别是,PowerShell 用括号而不是方括号来包围测试条件。例如这样:
if ("$env:PROCESSOR_ARCHITECTURE" -ne "") { return "$env:PROCESSOR_ARCHITECTURE" }
这一行的作用只是返回PROCESSOR_ARCHITECTURE
环境变量的值,如果该变量存在的话。这个函数的其余部分专门用于 Linux,正如if (IsLinux)
语句块所证明的。你可以看到它在检测 CPU 是否为 x86、x86_64 或 ARM。对于 ARM,它会检测是 32 位版本还是 64 位版本。
这个函数中 Windows 使用的唯一部分是第一行,即if ("$env:PROCESSOR_ARCHITECTURE" -ne "") { return "$env:PROCESSOR_ARCHITECTURE" }
这一行。而且,在我的 Windows 10 机器上,这行代码唯一的作用是将“AMD64”字符串插入到你在上面图形中看到的信息里。在 Windows 上,所有其他的 CPU 信息都是通过Windows 管理工具(WMI)接口获取的。这部分代码位于try
语句块的else
分支中,从第 44 行开始。(else
分支从第 54 行开始。)它看起来是这样的:
try {
Write-Progress "Querying CPU status..."
$status = "✅"
$arch = GetCPUArchitecture
if ($IsLinux) {
$cpuName = "$arch CPU"
$arch = ""
$deviceID = ""
$speed = ""
$socket = ""
} else {
$details = Get-WmiObject -Class Win32_Processor
$cpuName = $details.Name.trim()
$arch = "$arch, "
$deviceID = "$($details.DeviceID), "
$speed = "$($details.MaxClockSpeed)MHz, "
$socket = "$($details.SocketDesignation) socket"
}
在if..else
结构中,你可以看到如果机器运行的是 Linux,它将调用GetCPUArchitecture
函数并从中获取 CPU 信息。在else
部分,你看到的是 Windows 的代码。尽管脚本通过PROCESSOR_ARCHITECTURE
环境变量获取了 Windows 的一些信息,但大多数 CPU 信息是通过Windows 管理工具(WMI)子系统获取的。事实上,PROCESSOR_ARCHITECTURE
变量没有包含我这台特定 Fedora 工作站的任何信息。所以,在这台机器上,我只能得到一个关于我运行的是 32 位还是 64 位机器,或者是 x86 还是 ARM 机器的通用消息。虽然这样,但没关系,因为我可以修改脚本来解决这个问题。
在编辑器中打开check-cpu.ps1
脚本,向下滚动直到你看到try
语句块中的这一行,它应该是第 47 行:
$arch = GetCPUArchitecture
我们会保持这一行不变,因为 Windows 也使用GetCPUArchitecture
函数。相反,我们将在if ($IsLinux) {
这一行之后,即第 48 行,添加如下新行:
$arch = Get-Content /proc/cpuinfo | Select-String "model name" | Select-Object -Unique
现在,arch
变量的值不是通过GetCPUArchitecture
函数获取,而是通过命令替换构造获取。
我们在这里看到了 PowerShell 脚本的几个不同之处。
首先,每次使用 PowerShell 变量时,都需要在变量名之前加上$
。这意味着无论是定义变量还是调用其值,都需要加上$
。其次,你会看到,在 PowerShell 中,命令替换构造不需要用$( )
括起来。相反,直接像平常那样写命令即可。
好的,让我们分解这行代码。
-
Get-Content
:这与 Linux/Unix 的cat
命令完成了相同的工作。 -
Select-String
:这与 Linux/Unix 的grep
命令完成了相同的工作。 -
Select-Object -Unique
:这与 Linux/Unix 的uniq
命令完成了相同的工作。我们需要在这里使用它,因为我的机器有 16 个虚拟核心,而我只想看到其中一个的相关信息。
在我的 Fedora 工作站上运行修改后的脚本是这样的:
PS /home/donnie> ./check-cpu.ps1
⚠️model name : Intel(R) Xeon(R) CPU E5-2670 0 @ 2.60GHz CPU (16 cores, ) - 57°C HOT
PS /home/donnie>
这样好多了,但还不完全完美。我真想去掉输出开头的“model name”文本字符串。让我们再对脚本进行一次小改动,像这样:
$longarch = Get-Content /proc/cpuinfo | Select-String "model name" | Select-Object -Unique
$arch = $longarch -replace "model name\t: ", ""
我在这里做的只是将原始的$arch
行中的$arch
改为$longarch
。我会将这个作为我的中间变量。然后,我添加了一行新的$arch
,使用-replace "model name\t: ", ""
命令来删除“model name”字符串以及随后的制表符、冒号和空格。在这种情况下,-replace
命令与 Linux/Unix 的sed
命令完成了相同的工作。为了更好理解,这就是修改后的try
语句块的相关部分:
try {
Write-Progress "Querying CPU status..."
$status = "✅"
$arch = GetCPUArchitecture
if ($IsLinux) {
$longarch = Get-Content /proc/cpuinfo | Select-String "model name" | Select-Object -Unique
$arch = $longarch -replace "model name\t: ", ""
$cpuName = "$arch CPU"
$arch = ""
$deviceID = ""
$speed = ""
$socket = ""
}
运行新脚本的结果是这样的:
PS /home/donnie> ./check-cpu.ps1
⚠ Intel(R) Xeon(R) CPU E5-2670 0 @ 2.60GHz CPU (16 cores, ) - 62°C HOT
PS /home/donnie>
完美。这正是我想看到的,除了我的工作站似乎有点过热的原因。(明天,我会打开它,看看是否需要吹掉一些灰尘。)最棒的是,这仍然是一个跨平台脚本,因为我没有做任何可能影响它在 Windows 上运行的事情。
好的,我想这就是我们对 PowerShell 奥秘的介绍。让我们总结一下,然后结束这部分内容。
总结
在本章的最后,我向你展示了在 Linux 机器上使用 PowerShell 脚本的基本情况。我从历史背景开始,然后介绍了 PowerShell 脚本的哲学,以及它与传统 Linux/Unix Shell 脚本的区别。你还了解了如何使用 PowerShell 命令,以及如何获取命令的帮助。最后,我向你展示了一些实际的 PowerShell 脚本示例,这些脚本可以在 Windows 或 Linux 上运行。但正如我之前所说,单单一章的内容只能提供 PowerShell 脚本的高层概述。如果你想深入了解,可以通过大量的在线资源和书籍来进一步学习。
哦,我差点忘了最重要的事。我还向你解释了作为 Linux 或 Mac 管理员,你可能会考虑使用 PowerShell 脚本的一些理由。是的,它确实不同,需要一些适应。但它确实有一些明显的好处,特别是如果你需要在 Linux、macOS 和 Windows 混合环境中工作时。
这不仅是第二十三章的结束,也是本书的结束。在整个过程中,你从一个命令行初学者成长为一个 Shell 脚本大师。当然,你可能无法记住所有的内容,但没关系。只要你能将本书以及其他任何可以找到的资源作为参考,当你需要创建一个出色的脚本时,随时可以翻阅。
无论如何,这是一段漫长的旅程,但我很享受这过程,也希望你也一样。保重,也许我们会在某个时候再见。
进一步阅读
-
PowerShell Core for Linux 管理员食谱:
www.packtpub.com/en-us/product/powershell-core-for-linux-administrators-cookbook-9781789137231
-
我从 bash 切换到 PowerShell,一切顺利!:
www.starkandwayne.com/blog/i-switched-from-bash-to-powershell-and-its-going-great/index.html
-
在 Fedora Linux 上安装 PowerShell:
fedoramagazine.org/install-powershell-on-fedora-linux/
-
PowerShell 与常见 Linux/Bash 命令的等价物:
mathieubuisson.github.io/powershell-linux-bash/
-
Bash 与 PowerShell 的比较:选择合适的脚本 Shell:
smartscripter.com/bash-vs-powershell-choosing-the-right-scripting-shell/
-
非 Windows 平台上的 PowerShell 差异:
learn.microsoft.com/en-us/powershell/scripting/whats-new/unix-support?view=powershell-7.4
-
PowerShell 与 Linux 的 ls -al 等效命令:深入指南:
thelinuxcode.com/equivalent-of-linux-ls-al-in-powershell/
-
Bash 与 PowerShell 备忘单:
blog.ironmansoftware.com/daily-powershell/bash-powershell-cheatsheet/
留下您的评论!
感谢您购买 Packt 出版的这本书——我们希望您喜欢它!您的反馈至关重要,能帮助我们改进和成长。请花点时间留下一个亚马逊评论;它只需一分钟,但对像您这样的读者来说意义重大。
扫描下面的二维码,获取您选择的免费电子书。
订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及帮助您规划个人发展并推动职业发展的行业领先工具。欲了解更多信息,请访问我们的网站。
为什么要订阅?
-
用来自 4000 多位行业专家的实用电子书和视频,减少学习时间,增加编程时间
-
通过为您特别设计的技能计划提升您的学习
-
每月免费获得一本电子书或视频
-
完全可搜索,便于访问关键信息
-
复制、粘贴、打印和书签内容
在www.packt.com,您还可以阅读一系列免费的技术文章,注册各种免费的通讯,并获得 Packt 书籍和电子书的独家折扣和优惠。
其他您可能喜欢的书籍
如果您喜欢这本书,您可能会对 Packt 出版的其他书籍感兴趣:
Linux 内核编程
Kaiwan N. Billimoria
ISBN: 9781803232225
-
从源代码配置并构建 6.1 LTS 内核
-
为 6.x 内核编写高质量的模块化内核代码(LKM 框架)
-
探索现代 Linux 内核架构
-
掌握内核中关于内存管理的关键内部细节
-
理解并使用各种动态内核内存分配/释放 API
-
发现关于内核中 CPU 调度的关键内部方面,包括 cgroups v2
-
更深入理解内核并发问题
-
学习如何使用关键的内核同步原语
Linux 软件开发人员指南
David Cohen, Christian Sturm
ISBN: 9781804616925
-
学习有用的命令行技巧和工具,使软件开发、测试和故障排除变得轻松
-
了解 Linux 和命令行环境的实际工作原理
-
创建强大、定制化的工具,并通过以开发者为中心的 Linux 工具节省成千上万行代码
-
通过 Docker、SSH 和 Shell 脚本任务获得实践经验,让你成为更高效的开发者
-
熟练掌握在 Linux 服务器上搜索日志和排查问题
-
处理那些让其他开发者头疼的常见命令行问题
Packt 正在寻找像你这样的作者
如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并立即申请。我们与成千上万的开发者和技术专业人士合作,帮助他们将自己的见解分享给全球技术社区。你可以进行通用申请,申请我们正在招聘的特定热门话题的作者,或者提交你自己的想法。
加入我们的 Discord 社区!
和其他读者、Linux 专家以及作者本人一起阅读本书。
提问、为其他读者提供解决方案、通过问我任何问题(Ask Me Anything)环节与作者互动,等等。扫描二维码或访问链接加入社区。