Linux-命令行脚本编程技巧-全-

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Linux 命令行与 Shell 脚本技术 是一本帮助你学习如何使用 命令行界面 (CLI) 并进一步扩展你的 CLI 知识,掌握脚本编写能力的书籍。它涵盖了大量的 CLI 命令,Shell 脚本基础(循环、变量、函数)和高级脚本主题——例如故障排除。它还包括两个带有脚本示例的章节,这些示例将帮助你加深对脚本的理解,并深入了解 Shell 脚本的工作过程。

本书适用对象

本书适合初学者和专业人士,因为它不一定需要大量的 Linux 基础知识。这部分正是本书的目的——帮助你掌握命令行的使用,并将这一使用模式扩展到 Shell 脚本编写。对于更高级的用户,本书还包含大量关于 Shell 脚本和相关示例的内容,帮助你组织和提高对 Shell 脚本的理解。

本书所涉及内容

第一章Shell 和文本终端基础,讨论了 Shell 和文本终端的概念、Bash Shell 的配置、一些基础的 Shell 命令的使用,以及如何使用屏幕来访问多个文本模式的虚拟终端。

第二章使用文本编辑器,带我们进入了文本编辑器的高度主观世界,在过去的 30 到 40 年里,关于最佳编辑器的话题一直在讨论。本章的一部分,我们将使用 vi(m)、nano,以及一些更高级的 vi(m) 设置。

第三章使用命令和服务进行进程管理,讲述了如何使用文件、文件夹和服务,具体来说,如何管理它们,如何保护它们(文件和文件夹),以及如何管理它们(服务)。本章的重点之一是关于 ACLs 和 systemctl,它们是系统管理员必备的工具。

第四章使用 Shell 配置和排除网络故障,主要讲述如何使用文件、文件夹和服务——处理权限、操作文件内容、归档和压缩文件、以及管理服务。在这一章中,我们将使用许多简单命令,这些命令在后续的脚本编写过程中也会用到。

第五章使用命令进行文件、目录和服务管理,讲解了我们如何确保掌握基本的网络配置知识——nmcli 和 netplan,FirewallD 和 ufw,DNS 解析和诊断。这些是我们在部署后最常重新配置的设置,因此对它们的深刻理解是必不可少的。

第六章基于 Shell 的软件管理,带我们了解两种最常用的打包系统(dnf/yumapt),以及一些更高级的概念,比如使用额外的仓库、流和配置文件,创建自定义仓库和第三方软件。每一个 Linux 部署都需要我们了解包管理,因此本章内容就是围绕这一点展开的。

第七章基于网络的文件同步,讲解了我们最常用的工具,帮助我们通过网络发送和接收文件以及连接到远程目的地 —— ssh 和 scp, rsync, 以及 vsftpd。无论是托管 Linux 发行版镜像,还是同步文件和备份,这都是必备的知识。

第八章使用命令行查找、提取和操作文本内容,介绍了如何使用基础和更高级的方式来操作文本文件和内容。我们从做一些简单的操作开始,例如 paste 和 dos2unix,然后逐步过渡到 IT 领域最常用的命令 —— cut, (e)grep, 和 sed

第九章Shell 脚本入门,是本书第二部分的起点,主要介绍 Shell 脚本编写,并利用之前提到的工具和命令来创建 Shell 脚本。本章将讲解 Shell 脚本的基础知识,并探讨一些通用概念,比如输入、输出、错误和脚本的清洁性。

第十章使用循环,深入讲解了循环的概念。我们将在本章讨论所有最常用的循环 —— for 循环、breakcontinuewhile 循环、test-if 循环、case 循环,以及带有条件(如 andornot)的逻辑循环。这将进一步增强我们在 Shell 脚本中做更多事情的能力。

第十一章变量使用,介绍了如何在 Shell 脚本代码中使用变量 —— Shell 变量、变量值中的引号和特殊字符、通过命令赋值外部变量,以及一些关于变量的逻辑操作。变量是 Shell 脚本的核心,所有的永久和临时数据都存储在变量中,因此,无论我们开发什么目的的 Shell 脚本,变量都是必不可少的。

第十二章使用参数和函数,进一步定制和模块化我们的 Shell 脚本代码,因为我们可以使用函数来实现这一点。为此,我们将使用外部和 Shell 参数,摆脱大多数之前 Shell 脚本示例中的静态特性。

第十三章使用数组,讲述了如何使用数组存储和操作数据。数组只是其中一种数据结构——我们需要它们,虽然不一定喜欢它们,但在很多情况下却离不开它们,特别是在我们开始接触它们的多种不同功能时,比如索引、添加和移除成员,以及将文件作为事实上的数组源来使用。

第十四章与 Shell 脚本的交互,讲述了从将 Shell 脚本代码视为纯文本驱动的原则,转向相反的方向——创建一个基于 TUI 的界面来与脚本进行交互。我们还将尝试expect脚本,它使我们能够更轻松地创建一个等待特定输出并基于该输出执行某些操作的脚本,这在配置第三方系统时有时非常有用。

第十五章Shell 脚本故障排除,处理 Shell 脚本故障排除——常见错误、通过在脚本执行期间输出值进行调试、Bash -xv选项及其他概念。这是我们开始处理许多脚本示例的最后一章,这些示例将作为学习工具供您使用,也可以在生产环境中使用,如果您愿意的话。

第十六章用于服务器管理、网络配置和备份的 Shell 脚本示例,带领我们进入简单 Shell 脚本的世界——具体来说是九个不同的示例。主题涵盖了从可以在任何 Shell 脚本中实现的简单、模块化代码(例如,如何检查我们是否以 root 身份执行脚本),到更复杂的示例,如处理日期和时间、交互式配置网络设置和防火墙,以及一些备份脚本示例。

第十七章高级 Shell 脚本示例,介绍了更复杂的示例,如修改 Web 服务器和安全设置的脚本、大量创建具有随机密码的用户和组的脚本、脚本化的 KVM 虚拟机安装以及脚本化的 KVM 虚拟机管理(启动、停止、获取信息、操作快照等)。这些是我们在日常生活中使用的示例,旨在强调 Shell 脚本的核心理念——即自动化枯燥、重复的任务,并将其委托给可以为我们完成这些工作的脚本。

如何充分利用本书

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图像。您可以在此下载:static.packt-cdn.com/downloads/9781800205192_ColorImages.pdf

使用的约定

本书中使用了若干文本约定。

文中的代码:表示文中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入及 Twitter 账号。例如:要配置网络的主机端,您需要从用户模式 Linux(UML)项目中获取 tunctl 命令

一段代码块的格式如下:

#include <stdio.h>
#include <stdlib.h>
int main (int argc, char *argv[])
{
    printf ("Hello, world!\n");
    return 0;
}

任何命令行输入或输出按如下格式书写:

$ sudo tunctl -u $(whoami) -t tap0

粗体:表示一个新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的词语在文本中通常会显示为这样的样式。举个例子:点击 Flash 从 Etcher 写入镜像

提示或重要说明

如此显示。

与我们联系

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

一般反馈:如果您对本书的任何部分有疑问,请在邮件主题中提到书名,并通过客户关怀邮箱与我们联系。

勘误:尽管我们已尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现了错误,请您向我们报告。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并填写相关详情。

盗版:如果您在互联网上遇到任何我们作品的非法复制品,我们将非常感激您能提供相关的网址或网站名称。请通过版权邮箱与我们联系,并附上该素材的链接。

如果您有兴趣成为作者:如果您在某个领域有专长,且有兴趣写作或参与书籍编写,请访问authors.packtpub.com

分享您的想法

一旦您读完了Linux 命令行与 Shell 脚本秘籍,我们很期待听到您的想法!请点击这里直接跳转到该书的亚马逊评论页面并分享您的反馈。

您的评价对我们和技术社区都非常重要,它将帮助我们确保提供优质的内容。

第一章:Shell 和文本终端基础

一句古老的中国谚语说:“千里之行,始于足下”。本章将是我们迈向精通 Linux 命令行界面CLI)和Shell 脚本的第一步。具体来说,我们将学习如何使用终端、Shell,一些基本命令以及一个非常实用的工具——screen,来一次处理多个任务。

随着你深入阅读本书,你会注意到我们将频繁使用这些概念,因为它们是后续章节的基础。在进行系统管理时,我们通常在某种命令行界面(CLI)中能做的更多,而不仅仅是在任何一种图形用户界面GUI)中。这源自于CLI的可编程性与大多数 IT 图形界面静态性质之间的对比。此外,像screen这样的工具将使我们的 CLI 操作更加轻松,因为我们可以同时处理多个虚拟屏幕,从而提高工作效率。

简而言之,我们将处理以下几个主题:

  • 访问 Shell

  • 设置用户 Shell

  • 设置 Bash Shell

  • 使用最常见的 Shell 命令

  • 使用screen

技术要求

对于这些主题,我们将使用两台 Linux 机器——在我们的案例中,它们是两台运行Ubuntu20.04 Focal Fossa)的 VMware 虚拟机。我们称它们为cli1gui1,随着书籍的推进,我们将增加更多机器,随着主题的复杂性增加,所以,总的来说,我们需要以下内容:

  • VMware Player、Workstation、Fusion 或 ESXi

  • Ubuntu 20.04 Focal Fossa 安装 ISO 文件

  • 安装这两台没有图形界面的虚拟机(cli1机器)和带图形界面的虚拟机(gui1机器)需要一些时间。

安装过程完成后,我们将开始学习 Shell 基础——这是我们下一个要讨论的主题。

访问 Shell

首先,我们简要讨论各种Shell访问方法。它可以简单到只安装一个运行文本模式的 Linux 虚拟机,但也可以是一个带有图形界面的虚拟机。那样我们就需要做一些工作来访问文本模式;所以,让我们学习这些不同的 Shell 访问方式。

理解为什么访问 Shell 如此重要也很关键。其背后的理由很简单,因为我们在 Shell 中能做的事情远比在 GUI 中做的多。在本书的第二部分,我们将深入探讨 Shell 脚本的概念,那时你会发现,为什么我们在 Shell 中能够做更多的事情,这一点会更加显而易见。

准备工作

首先,我们需要部署我们的两个虚拟机。我们实际上可以将这两台机器安装为文本模式机器,并启用 OpenSSH 服务器(安装过程中的某个步骤会询问 OpenSSH)。然后,我们可以为 gui1 机器添加图形界面,以便我们也可以使用它。我们通过在以 student 用户登录后,在 gui1 机器上输入几个命令来完成此操作(student 是我们为本示例设置的用户名):

sudo apt-get -y install tasksel
tasksel

sudo 将要求我们输入 student 用户的密码(这可以是你在安装过程中创建的任何用户;student 只是我们在示例中使用的用户名)。当 tasksel TUI 界面启动时,我们将选择 Ubuntu 桌面 软件包集,如下图所示:

图 1.1 – Ubuntu 桌面软件包

图 1.1 – Ubuntu 桌面软件包

正如 图 1.1 所示,你需要使用箭头键选择 Ubuntu 桌面,并按空格键从菜单中进行选择,然后使用 Tab 或箭头键选择 Ok 并按 Enter

现在让我们讨论如何访问 shell。

如何操作…

如果我们使用默认选项部署了 Ubuntu 机器,默认情况下我们将看到文本模式。为了访问 shell 并能够对 Linux 虚拟机进行操作,我们需要输入我们在安装过程中设置的 用户名密码。必须是我们在安装过程中输入的用户名或密码。在我们的虚拟机中,创建了名为 student 的用户,密码为预设的 student 密码。当我们成功登录后,将看到常规的文本模式和关联的 shell,如下图所示:

图 1.2 – 登录后从文本模式访问 CLI

图 1.2 – 登录后从文本模式访问 CLI

然而,如果我们进行了图形界面安装,访问 shell 有三种不同的方法:

  1. 我们可以在 GNOMEGNOME 终端)中启动文本终端并使用其中的 shell。优点是它为我们提供了类似图形用户界面的外观和感觉,对许多人来说可能更友好。缺点是,我们很少在生产环境中的 Linux 服务器上找到图形界面,因此这可能会导致 养成不良习惯。要启动 GNOME 终端,我们可以使用内置的 GNOME 搜索功能(按 WIN 键)或直接右键点击桌面并打开 终端。结果如下所示:

图 1.3 – 在 GNOME 图形界面中查找终端使用 WIN 键搜索“Terminal”关键词

图 1.3 – 在 GNOME 图形用户界面中使用 WIN 键搜索“Terminal”关键词来查找终端

  1. 我们可以直接切换到基于文本的控制台,因为 Linux 在部署 GUI 时并不阻止我们使用文本控制台。为此,我们需要按下一个专门的键盘组合,才能进入其中一个文本控制台。例如,我们可以按下 Ctrl + Alt + F3 键组合。这样,我们将进入文本模式,具体来说,进入文本控制台编号。

在那里,我们可以登录并开始输入我们的命令。结果将如下所示:

图 1.4 – 从 GUI 切换到文本终端

图 1.4 – 从 GUI 切换到文本终端

我们又回到 shell 了。现在我们可以开始使用任何我们想要的命令。

  1. 我们可以使用 systemctl 命令将当前会话的默认模式切换为文本模式(直到下次重启)。我们甚至可以使用它将文本模式设置为永久模式,尽管我们已经安装了完整的 GUI。为了实现这一点,在我们的 GUI 中,我们需要登录,然后在 GNOME 终端中输入以下一系列命令:
systemctl set-default multi-user.target
systemctl isolate multi-user.target

如果我们想要将 Linux 虚拟机设置为 默认 启动到文本模式,可以使用第一条命令。如果我们想立即将 Linux 机器重新配置为切换到文本模式,可以使用第二条命令。

它是如何工作的…

在前一个示例中,我们使用了几组命令,接下来让我们解释这些命令的作用,以便我们能够清楚地了解我们所做的事情。

第一组命令如下:

sudo -i
apt-get -y install tasksel
tasksel

这三条命令将执行以下操作:

  • sudo -i 将要求我们输入当前用户的密码。如果该用户已经被添加到 sudoers 系统中(/etc/sudoers),这意味着我们可以使用当前用户的密码以 root 身份登录并使用管理员权限。

  • apt-get -y install tasksel 将安装 tasksel 应用程序。该应用程序的主要目的是简化包的部署。具体来说,我们将在下一步中使用它来部署一组 Ubuntu 桌面 包(多个成百上千个包)。想象一下,如果手动输入所有的 apt-get 命令来完成这一部署过程会是什么样子!

  • tasksel 命令将启动 tasksel 应用程序,该程序将用于部署所需的包。

第二组命令执行以下操作:

  • systemctl set-default multi-user.target 将设置文本模式为默认启动目标。其含义是,我们的 Linux 机器将在下次重启后默认以文本模式启动。

  • systemctl isolate multi-user.target 将立即将我们切换到文本模式。与 set-default 程序完全不同,因为它与重启后的 Linux 机器状态无关。

另请参见

如果你需要更多关于 apt-gettaskselsystemctl 的信息,我们建议你访问以下链接:

设置用户的 shell

现在我们已经了解了如何访问 shell,让我们将其配置为便于使用。我们将看到几个例子,以便理解 Linux shell 的自定义性。具体来说,我们将自定义提示符的外观和感觉。

准备就绪

我们只需要保持虚拟机的运行。

如何操作…

我们将编辑一个名为/home/student/.bashrc的文件。在此之前,让我们创建.bashrc文件的备份副本,以防我们犯错:

cp /home/student/.bashrc /home/student/.bashrc.tmp

在编辑这个文件之前,确保你记下当前提示符的样子。如果你以student身份登录到cli1机器,提示符应该是这样的:

student@cli1:~$

让我们使用nano编辑.bashrc文件。输入以下命令:

nano /home/student/.bashrc

当我们输入这个命令时,我们将会在 nano 编辑器中打开.bashrc。让我们滚动到文件的末尾,应该像这样:

图 1.5 – .bashrc 默认内容

图 1.5 – .bashrc 默认内容

我们需要继续到最后一个fi的位置,并添加以下语句:

PS1="MyCustomPrompt> "

接下来,使用Ctrl + X保存文件。然后,当我们回到 shell 时,输入以下命令:

source .bashrc

如果我们一切都做对了,我们的提示符现在应该是这样的:

MyCustomPrompt> 

这可以通过使用 PS1 参数进一步自定义。让我们定位以下内容:

PS1="MyCustomPrompt> "

我们将其更改为以下内容:

PS1="\u@\H> \A "

\u@\H部分表示提示符中的username@host部分。\A部分表示 24 小时制的时间。所以,当我们执行以下操作时:

source .bashrc

再次,我们应该看到提示符的以下状态:

[student@cli1> 19:30]

19:30表示时间。我们还可以自定义其他内容,比如字体类型(下划线、正常、暗淡、粗体)和颜色(黑色、红色、绿色等)。现在就让我们来做这个。比如,我们再次编辑.bashrc文件,将PS1设置如下:

PS1="\e[0;31m[\u@\H \A] \e[0m"

现在我们的提示符应该是这样的:

[student@cli1 19:39]

在这个具体的例子中,\e[告诉PS1变量我们想要更改提示符的颜色。0;31m表示红色(30是黑色,34是蓝色,以此类推)。[]括起来的部分是我们之前讨论的常规提示符。最后的部分,\e0m,告诉PS1变量我们已完成对PS1输出的颜色修改。

如我们所见,修改一个 shell 变量(PS1)就能大幅改变我们在 Linux 虚拟机中的文本模式体验。

它是如何工作的……

作为一个 Shell 变量,PS1可以用来定制 Shell 的外观和感觉。可以把它理解为大多数用户自定义 GUI 时,使用不同的壁纸、文本大小、颜色等方式,它是我们喜欢的东西,因此这是一个很自然的做法。PS1通常被称为主提示显示变量,如前面章节所述。

我们使用的source命令执行.bashrc,意味着它将应用来自.bashrc的设置。因此,我们无需注销再重新登录,因为那样浪费时间,source命令可以帮助我们解决这个问题。

现在,让我们在.bashrc文件中添加更多设置,因为还有很多内容可以自定义。

设置 Bash shell。

我们调整了PS1变量并将其配置为更符合我们的喜好。现在,让我们使用更多的.bashrc设置,进一步配置我们的 Bash shell。

准备开始。

我们需要保持虚拟机处于运行状态。如果它们没有开机,我们需要重新开机。

如何做到这一点……

让我们讨论如何更改以下 Shell 参数:

  1. 添加一些自定义别名。

如果我们再次打开.bashrc文件,可以对它进行一些额外的设置。首先,添加几个别名。在.bashrc文件的末尾附近,有一部分包含了一些别名(lllal)。我们可以在该部分添加以下行:

alias proc="ps auwwx"
alias pfilter="ps auwwx | grep "
alias start="systemctl start "
alias stop="systemctl stop "
alias ena="systemctl enable "

这段代码将引入五个新别名:

  • 查看进程列表

  • 按照pfilter后面要输入的关键字过滤进程。

  • 启动服务;服务名称应在start后面输入。

  • 停止服务;服务名称应在stop后面输入。

  • 启用服务;服务名称应在ena后面输入。

如我们所见,使用别名可以让我们的输入更简短,且使管理过程更简单。

  1. 调整 Bash 历史记录的大小。

.bashrc文件的顶部,有一个类似这样的部分:

HISTSIZE = 1000
HISTFILESIZE = 1000

如果我们希望 Bash shell 记住当前会话中输入的超过 1,000 条命令(HISTSIZE),并且将超过 1,000 条命令保存在历史文件(.bash_history)中,我们可以更改这些变量的值,例如设置为20002000

  1. 调整PATH变量。

假设我们想向现有的PATH变量中添加一个自定义路径。例如,我们将自定义应用程序安装在/opt/bin目录中,而且我们不想每次都通过完整路径来调用该应用程序。

我们需要编辑.profile文件,因为当前用户的PATH变量是在那里设置的。因此,打开.profile文件并将以下行添加到文件的末尾:

PATH=$PATH:/opt/bin
  1. 设置我们的默认编辑器。

让我们将以下两行添加到.bashrc文件中,位于文件末尾:

export VISUAL=nano
export EDITOR=nano

这将把 nano 设置为我们首选的默认编辑器。

它是如何工作的……

Bash shell 具有一组保留变量,我们只能用于 Bash 目的。其中一些保留变量包括以下内容:

  • PS1PS2PS3PS4

  • HISTFILESIZEHISTSIZE

  • VISUALEDITOR

  • OLDPWD

  • PWD

这些名称是为特定 Bash 函数保留的,因此我们不应该用这些名称创建自定义变量。您可以从还有更多…部分提供的链接了解更多关于这些保留变量的信息。

就 PS 变量而言,我们可以认为它们是我们自定义 Bash shell 的入口。特别是对于 PS1 变量来说,因为它是最常用的变量。我们可以使用所有这些变量来设置 Bash,以适应我们自己的需求,因为我们不必只使用预定义的全局配置。随着时间的推移,越来越多的 Linux 系统管理员为 Bash 创建自己的定制配置,因为这增加了使用 Bash shell 的便利性和他们自己的生产力。

还有更多…

如果我们需要了解更多关于 Bash 保留变量和 PS 变量的信息,我们可以查看以下链接:

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

  • https://access.redhat.com/solutions/505983

使用最常见的 shell 命令

现在让我们转向学习一组基本的Linux shell 命令。我们将讨论用于操作文件和文件夹、进程、归档和链接的命令。我们将通过一个涉及多个步骤的场景来实现这一点。

准备工作

我们仍然需要与之前的配方相同的虚拟机。

如何做…

为了能够使用 shell 命令,我们必须启动 shell。如果我们使用 CLI,我们只需登录并进入 shell 会话。如果我们使用 GUI 方法,我们必须在应用程序菜单中找到一个 GUI 终端。之后,我们可以开始输入命令:

  1. 首先,让我们使用一组基本的命令来处理文件和目录。

让我们列出当前目录的内容:

ls -al

输出将类似于这样:

![图 1.6 – ls -al 命令的标准输出,包含所有相关信息

图 1.6 – ls -al 命令的标准输出,包含所有相关信息

  1. 现在,让我们创建一个名为directory1的目录和一个名为test1test5的五个文件的堆栈。touch命令会创建空文件。接着,让我们把这些文件复制到该目录:

    mkdir directory1
    touch test1
    touch test2
    touch test3
    touch test4
    touch test5
    cp test* directory1
    
  2. 之后,让我们创建一个名为directory2的目录,并将文件 1 到 5 移动到directory2

    mkdir directory2
    mv test* directory2
    
  3. 让我们检查directory1directory2中已使用的磁盘空间量:

    [student@cli1 21:47] du directory1
    4       directory1
    [student@cli1 21:47] du -hs directory2
    4.0K    directory2
    [student@cli1 21:48]
    
  4. 让我们检查当前磁盘的容量(-h开关为我们提供了一个友好的、易读的输出):

    df -h .
    Filesystem                         Size  Used Avail Use% Mounted on
    /dev/mapper/ubuntu--vg-ubuntu--lv   19G  4.5G   14G  26% /
    

接下来的一组命令与硬链接和软链接相关。

  1. 为了创建硬链接和软链接,让我们登录到cli1虚拟机,并以root身份登录。硬链接和软链接的整体概念将在本章稍后解释。因此,创建一个临时目录并使用一些文件。我们将使用一个现有的文件,因为它足以满足这个场景(.bashrc文件):

    mkdir links
    cd links
    cp /root/.bashrc content.cfg
    ln content.cfg hardlink.cfg
    ln -s content.cfg softlink.cfg
    ls -al
    cd /root
    ln -s links links2
    ln links links3
    ln: links: hard link not allowed for directory
    cp .bashrc /tmp
    cd /tmp
    ln .bashrc /root/notworking
    ln: failed to create hard link '/root/notworking' => '.bashrc': invalid cross-device link
    ln -s .bashrc /root/working.cfg
    ls /root/working.cfg
    /root/working.cfg
    
  2. 现在让我们检查其中一个文件的开始和结尾。例如,我们使用/tmp/.bashrc

    head /tmp/.bashrc
    [student@cli1 22:28] head /tmp/.bashrc
    # ~/.bashrc: executed by bash(1) for non-login shells.
    # see /usr/share/doc/bash/examples/startup-files (in the package bash-doc)
    # for examples
    # If not running interactively, don't do anything
    case $- in
     *i*) ;;
     *) return;;
    esac
    

现在让我们检查同一个文件的尾部:

[student@cli1 22:29] tail /tmp/.bashrc
 if [ -f /usr/share/bash-completion/bash_completion ]; then
 . /usr/share/bash-completion/bash_completion
 elif [ -f /etc/bash_completion ]; then
 . /etc/bash_completion
 fi
fi
PS1="\e[0;31m[\u@\H \A] \e[0m"
export VISUAL=nano
export EDITOR=nano
  1. 下一步将涉及检查运行的进程和系统状态。

现在让我们使用命令检查系统当前的负载,找到一些进程,并杀掉其中的一些进程来娱乐一下。

首先,让我们检查负载(使用uptime命令),并找到消耗时间最多的前 20 个进程(ps命令):

uptime
22:35:48 up  3:16,  2 users,  load average: 0.00, 0.00, 0.00
ps  auwwx | head -20
[student@cli1 22:35] ps auwwx | head -20
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.5 103252 11844 ?        Ss   19:18   0:02 /sbin/init
root           2  0.0  0.0      0     0 ?        S    19:18   0:00 [kthreadd]
root           3  0.0  0.0      0     0 ?        I<   19:18   0:00 [rcu_gp]
root           4  0.0  0.0      0     0 ?        I<   19:18   0:00 [rcu_par_gp]
root           6  0.0  0.0      0     0 ?        I<   19:18   0:00 [kworker/0:0H-kblockd]
root           9  0.0  0.0      0     0 ?        I<   19:18   0:00 [mm_percpu_wq]
root          10  0.0  0.0      0     0 ?        S    19:18   0:00 [ksoftirqd/0]
root          11  0.0  0.0      0     0 ?        I    19:18   0:04 [rcu_sched]
root          12  0.0  0.0      0     0 ?        S    19:18   0:00 [migration/0]

接下来,让我们根据进程名称找到特定的进程并杀掉它:

student@gui1:~$ ps auwwx | grep -i firefox
student    47198 22.1 21.3 2825436 426736 ?      Rl   22:38   0:12 /usr/lib/firefox/firefox -new-window
student    47253  1.5  6.8 2427560 137988 ?      Sl   22:38   0:00 /usr/lib/firefox/firefox -contentproc -childID 1 -isForBrowser -prefsLen 1 -prefMapSize 223938 -parentBuildID 20210222142601 -appdir /usr/lib/firefox/browser 47198 true tab
student    47266  1.0  5.5 2402216 110116 ?      Sl   22:38   0:00 /usr/lib/firefox/firefox -contentproc -childID 2 -isForBrowser -prefsLen 85 -prefMapSize 223938 -parentBuildID 20210222142601 -appdir /usr/lib/firefox/browser 47198 true tab
student    47304  1.3  6.6 2468136 133340 ?      Sl   22:38   0:00 /usr/lib/firefox/firefox -contentproc -childID 3 -isForBrowser -prefsLen 1246 -prefMapSize 223938 -parentBuildID 20210222142601 -appdir /usr/lib/firefox/browser 47198 true tab
student    47363  0.6  4.1 2386184 83588 ?       Sl   22:38   0:00 /usr/lib/firefox/firefox -contentproc -childID 4 -isForBrowser -prefsLen 10270 -prefMapSize 223938 -parentBuildID 20210222142601 -appdir /usr/lib/firefox/browser 47198 true tab
student    48047  0.0  0.0   5168   880 pts/1    S+   22:39   0:00 grep --color=auto -i firefox
student@gui1:~$ killall firefox
student@gui1:~$ ps auwwx | grep -i firefox
student    48323  0.0  0.0   5168   884 pts/1    S+   22:40   0:00 grep --color=auto -i firefox

我们已经完成了这部分的内容。接下来,让我们讨论本配方的下一部分,关于用户和组的管理。

  1. 使用命令行管理用户和组,首先让我们浏览一下本配方中要使用的命令列表:

    • useradd:用于创建本地用户账户的命令。

    • usermod:用于修改本地用户账户的命令。

    • userdel:用于删除本地用户账户的命令。

    • groupadd:用于创建本地组的命令。

    • groupmod:用于修改本地组的命令。

    • groupdel:用于删除本地组的命令。

    • passwd:最常用于为用户账户分配密码的命令,但也可以用于其他场景(例如,锁定用户账户)。

    • chage:用于管理用户密码过期的命令。

那么,让我们通过使用useraddgroupadd命令来创建我们的第一个用户和组,配合一个场景。假设我们的任务如下:

  • 创建四个用户,分别为jackjoejillsarah

  • 创建两个用户组,分别为profspupils

  • 重新配置jackjill用户账户,使其成为profs组的成员。

  • 重新配置joesarah用户账户,使其成为pupils组的成员。

  • 为所有账户分配一个标准密码(我们将使用P@ckT2021作为这个目的)。

  • 配置用户账户,使其在下次登录时必须更改密码。

  • profs用户组设置特定的过期日期——密码更改前的最小天数设置为15,强制密码更改前的最大天数设置为30,密码更改的警告在密码到期前一周开始,并将账户的过期日期设置为 2022/01/01(2022 年 1 月 1 日)。

  • pupils用户组设置具体的过期日期——密码更改的最小天数设为7天,强制密码更改的最大天数设为30天,密码更改的提醒需要在密码过期前 10 天开始,并且设置账户的过期日期为 2021/09/01(2021 年 9 月 1 日)。

  • profs组修改为professors,并将pupils组修改为students

  1. 第一个任务是创建用户账户:

    useradd jack
    useradd joe
    useradd jill
    useradd sarah
    

这将在/etc/passwd文件中为这四个用户创建条目(该文件存储了大多数用户的信息——用户名、用户 ID、组 ID、默认主目录和默认 shell),并在/etc/shadow文件中创建条目(该文件存储了用户的密码和密码过期信息)。

  1. 接下来,我们需要创建这些组:

    groupadd profs
    groupadd pupils
    

这将为这些组在/etc/group文件中创建条目,该文件是系统存储所有系统组的地方。

  1. 下一步是管理professorsstudents用户组的成员资格。

在我们开始之前,需要了解一个事实。存在两种不同的本地用户组类型,主组附加组。主组在创建新文件和目录时非常重要,因为创建文件时会默认使用用户的主组(当然也有例外,我们将在本章的设置 Bash shell部分提到关于 umask、权限和 ACL 的内容)。附加组在共享文件和文件夹及相关场景中很重要,通常用于一些附加设置,以应对更复杂的场景。这些场景将在本章提到的设置 Bash shell部分中解释,也会在《第九章》和《Shell 脚本简介》的配方中进一步讲解。

主组和附加组存储在/etc/group文件中。

  1. 现在我们已经完成了这部分内容,接下来让我们修改用户的设置,使他们属于附加组,这些组是根据场景指定的:

    usermod -G profs jack
    usermod -G profs jill
    usermod -G pupils joe
    usermod -G pupils sarah
    

现在我们来检查一下这如何改变/etc/group文件:

图 1.7 – /etc/group 文件中的条目

图 1.7 – /etc/group 文件中的条目

/etc/group文件中的前四个条目实际上是在我们使用useradd命令创建这些用户账户时创建的。接下来的两个条目(冒号后面的部分除外)是由groupadd命令创建的。冒号后面的条目是通过usermod命令创建的。

  1. 现在让我们设置他们的初始密码,并在下次登录时强制更改密码。我们可以通过几种不同的方式来实现这一点,但让我们学习更程序化的做法,通过回显字符串并将其作为用户账户的明文密码:

    echo "jack:P@ckT2021" | chpasswd
    echo "joe:P@ckT2021" | chpasswd
    echo "jill:P@ckT2021" | chpasswd
    echo "sarah:P@ckT2021" | chpasswd
    

仅仅是回显部分,没有其他命令的情况下,意味着在终端中输入P@ckT2021,像这样:

echo "P@ckT2021"
P@ckT2021

在 CentOS 和类似的发行版中,我们可以使用带有--stdin参数的passwd命令,这意味着我们希望通过标准输入(键盘、变量等)为用户帐户添加密码。在 Ubuntu 中,这个功能不可用。因此,我们可以将username:P@ckT2021字符串回显到 shell 并通过管道传输给chpasswd命令,这样就能达到同样的目的;chpasswd命令不会将该字符串输出到终端,而是将其作为标准输入处理。

  1. 让我们为教授和学生设置到期日期。为此,我们需要学习如何使用chage命令及其一些参数(-m-M-W-E)。简而言之,它们的含义如下:

    • 如果我们使用-m参数,这意味着我们想要设置密码更改前允许的最小天数。

    • 如果我们使用-M参数,这意味着我们想要设置密码更改前强制执行的最大天数。

    • 如果我们使用-W参数,这意味着我们希望设置密码过期前的警告天数,这意味着 shell 会开始向我们抛出关于需要在密码过期前更改密码的消息。

    • 如果我们使用-E参数,这意味着我们想要将帐户过期设置为某个特定日期(YYYY-MM-DD 格式)。

现在让我们将其转换为命令:

chage -m 15 -M 30 -W 7 -E 2022-01-01 jack
chage -m 15 -M 30 -W 7 -E 2022-01-01 jill
chage -m 7 -M 30 -W 10 -E 2021-09-01 joe
chage -m 7 -M 30 -W 10 -E 2021-09-01 sarah
  1. 最后,让我们将组修改为最终设置:

    groupmod -n professors profs
    groupmod -n students pupils
    

这些命令只会更改组名,而不会更改其他数据(如组 ID),这些更改也会反映在我们的用户信息中:

图 1.8 – 检查已创建用户的设置

图 1.8 – 检查已创建用户的设置

如我们所见,jackjill是现在叫做professors的组的成员,而joesarah是现在叫做students的组的成员。

我们故意把userdelgroupdel命令放到最后,因为这些命令有一些注意事项,不应轻易使用。让我们创建一个名为temp的用户和一个名为temporary的组,然后删除它们:

useradd temp
groupadd temporary
userdel temp
groupdel temporary

这样就可以正常工作。问题是,由于我们在没有任何参数的情况下使用了userdel命令,它将保留用户的主目录不变。由于用户的主目录通常存储在/home目录中,默认情况下,这意味着/home/temp目录仍然会存在。在删除用户时,我们有时希望删除用户但保留其文件。如果你特别希望删除用户帐户及其所有数据,请使用userdel -r username命令。但在执行之前请三思而后行!

它是如何工作的……

现在让我们讨论一下之前配方中较复杂的部分,即符号链接硬链接

使用ln命令而不带额外参数时,会尝试创建硬链接。使用ln并加上-s参数时,会尝试创建软链接。我们可以明显看到食谱中有一些错误。现在,让我们从头开始讨论这些问题。

当我们输入完食谱中的前六个命令(以ls -al结束),这些命令用于列出文件夹内容时,最终结果应该类似于以下内容:

图 1.9 – 原始文件、硬链接和软链接

图 1.9 – 原始文件、硬链接和软链接

从之前的截图中,我们可以得出一些结论:

  • content文件和hardlink文件的大小相同(在我们的例子中是 1,349 字节)。

  • content文件和softlink文件的文件大小不同(分别为 1,349 字节与 11 字节)。

  • 软链接通常会以不同的方式标记(通常在终端中显示为不同的颜色)。

现在,为了进一步说明这个问题,我们来删除原始的content文件:

r m content.cfg

最终结果将如下所示:

图 1.10 – 删除原始文件后产生的有趣后果

图 1.10 – 删除原始文件后产生的有趣后果

我们可以看到,原始文件已被删除,而硬链接仍然存在并保持相同的大小。另一方面,软链接的颜色发生了变化(从绿色变为红色),这表明出现了某种问题。很有趣,不是吗?

如果我们在vi 编辑器中打开hardlink.cfg文件,内容是确实存在的:

图 1.11 – hardlink.cfg 文件仍然包含原始内容

图 1.11 – hardlink.cfg 文件仍然包含原始内容

之所以会发生这种情况,源于文件系统的工作方式。当我们删除一个文件时,我们并不会删除文件的内容,而只是删除文件系统表中的一个条目(文件名),该条目指向文件内容。这是因为删除文件内容涉及到速度和便捷性。如果操作系统实际删除文件内容,它将需要释放块并将零写入这些块,这会非常耗时。而且,这会使得文件恢复变得复杂。

这就是硬链接和软链接发挥作用的地方。它们之间的主要区别可以通过这个场景轻松推断出来。硬链接指向实际的文件内容,而软链接指向原始的文件名。这也解释了文件大小的不同。硬链接必须与原始文件大小相同(因为原始文件和硬链接都指向相同的内容,因此大小相同)。而softlink.cfg仅占用 11 字节的空间,原因很简单;content.cfg字符串在文件系统表中保存时需要 11 字节。

这也是硬链接和软链接之间存在的另外两个主要区别的原因:

  • 硬链接不能指向目录,必须指向文件。

  • 硬链接不能跨越分区。如果我们从第二个分区的角度来看,我们无法引用/查看第一个挂载分区的数据。第二个分区有自己独立的文件系统表(其中包含指向该分区上实际内容的条目),与第一个分区的文件系统表完全独立。

回到我们的教程,值得注意的是,我们可以轻松恢复原始文件。如果我们回到 /root/links 目录,我们只需要将 hardlink.cfg 文件复制为 content.cfg,那么我们的原始文件和相应的符号链接就恢复了:

cd /root/links
cp hardlink.cfg content.cfg

最终结果就像之前一样,当我们创建 content.cfg 文件以及指向它的硬链接和软链接时:

图 1.12 – 我们的原始文件和软链接已恢复

图 1.12 – 我们的原始文件和软链接已恢复

本书中我们将使用这些命令,因此我们需要确保在进入下一章之前掌握它们。但目前为止,我们只会将其中一个命令添加到命令栈中。它就是我们下一个教程的主题,叫做 screen

使用 screen

screen 是一种曾在 1990 年代和 2000 年代非常流行的文本工具,虽然其流行度在之后有所下降。系统管理员通常需要在同一台机器上打开多个控制台,或者使用这些控制台连接到外部机器。让我们来看一下 screen 如何适应这种场景。

准备工作

在开始使用这个教程之前,我们需要确保我们的 Linux 机器上安装了 screen。为了做到这一点,我们需要使用以下命令:

apt-get -y install screen

之后,我们就可以准备按照我们的教程继续操作了。

如何操作…

我们需要启动一个常规的文本终端(这也可以通过图形界面完成,但从屏幕空间的利用效率来看,这可能被认为是一种不太有效的方式)。然后,我们只需要输入以下命令:

screen

当我们启动 screen 时,它会展示一长段关于许可和其他不太有趣的内容的文本,屏幕底部会有几个重要的信息。它看起来会类似于这样:

图 1.13 – 基本的 screen 信息

图 1.13 – 基本的 screen 信息

我们最感兴趣的这个输出部分是功能。它告诉我们,通过使用 screen,我们可以做一些很酷的事情,比如复制、分离以及处理字体。但即使没有大多数这些高级功能,screen 也能让我们在一个文本终端的限制内打开多个虚拟文本终端。然后,它让我们可以分离(就像把 screen 进程放到后台一样)、注销、稍后重新登录,重新连接会话到 screen。这样可以实现一些很酷的功能,比如为最常用的、最常见的使用场景保留一组永久的虚拟文本控制台。

在我们按下屏幕中前面截图显示的Enter键后,我们将再次进入文本模式。这是 screen 的第一个虚拟文本控制台。如果我们想使用更多的虚拟文本控制台,可以通过按下 Ctrl + A + C 组合键来创建它们。每一个虚拟文本控制台从 0 开始编号。如果我们在 screen 中创建了五个虚拟文本控制台(编号从 0 到 4),并且我们在 screen 4 上,想跳到 screen 0,我们可以通过两种方式轻松做到这一点。第一种方法是使用绝对地址,也就是说,我们可以告诉 screen 我们想特别跳到 screen 0(使用 Ctrl + A + 0)。第二种方法是使用循环的方式。当我们使用 Ctrl + A + 空格键组合时,我们会按顺序循环通过各个屏幕——0,然后是 1,然后是 2,以此类推。如果我们在 screen 4 上,想跳到 0,因为没有 screen 5,我们只需通过 Ctrl + A + 空格键就能从 4 循环到 0。

如果我们需要注销,可以分离我们的 screen。其组合键是 Ctrl + A + D(分离 screen)。如果稍后我们想返回我们的屏幕,我们需要输入以下命令:

screen -R

我们也可以在 screen 中使用 Ctrl + A + ] 键组合来进行复制粘贴,然后滚动并找到我们想要开始复制的文本部分,使用空格键开始复制,并结束复制过程,然后如果我们想把文本粘贴到某个地方,再次使用 Ctrl + A + ] 组合键。它需要一点练习,但也非常好用。试想一下在 1996 年做这些事情!

重要提示

在使用 screen 时,我们建议你首先按下Ctrl + A,松开这两个键,然后按下你需要的任何键,去到你想要在屏幕上去的地方。

它是如何工作的…

screen 通过创建多个可分离的虚拟文本控制台来工作。这些控制台会保持活跃,直到有进程终止了 screen,或者直到系统重启。考虑到大多数基于 Linux 服务器的生产环境没有 GUI,能够一次连接到服务器并打开多个 screen 是非常方便的。

还有更多…

screen 需要一些试验和错误,需要一段时间适应。我们建议你查看以下链接以了解更多内容:

https://www.howtogeek.com/662422/how-to-use-linuxs-screen-command/

第二章:使用文本编辑器

本章的主题是无法绕开的,因为系统管理员会编辑 gedit,尽管我们不会在这里介绍该编辑器,因为它几乎与在 Microsoft Windows 上使用记事本一样。选择这些 编辑器 的原因有很多,但最重要的是,它们几乎都预装在所有 Linux 发行版中,因此它们是最常见的预安装编辑器。有些情况下无法安装额外的软件,比如 隔离 环境。

本章将覆盖以下食谱:

  • 学习 Vi(m) 编辑器的基础

  • 学习 nano 编辑器的基础

  • 浏览高级 Vi(m) 设置

技术要求

对于这些食谱,我们将使用一台 Linux 机器。我们可以使用之前食谱中的任何虚拟机。例如,假设我们将使用 cli1 虚拟机,因为它最方便使用,毕竟它只是一个命令行界面(CLI)机器。所以,总的来说,我们需要以下内容:

  • 安装了任何 Linux 发行版的虚拟机(在我们的例子中,它将是 Ubuntu 20.10)。

  • 花些时间消化使用 Vi(m) 编辑器的复杂性。相比之下,nano 更简单,因此学习它会更容易一些。

那么,启动你的虚拟机,让我们开始吧!

学习 Vi(m) 编辑器的基础

Vi 和 Vim 是许多系统管理员和工程师的 首选文本编辑器。简而言之,它们的区别在于,vimvi 改进版)拥有比原始的 vi(可视化编辑器)更多的功能。你可以在任何地方找到这些编辑器——从所有的 Unix 和 Linux 到当今的商业 Linux 或 Unix 基础软件。例如,VMware 的 vSphere Hypervisor 就内置了 vi 编辑器的一个版本。这样做的理由很简单——你需要有某种 标准化编辑器,可以用来编辑文件系统上各种文本文件。多年来,你肯定会在各种网络设备上找到简化版的 vi 或 Vim,比如交换机、路由器,甚至是更复杂的设备如防火墙。就是这样。如果某个东西是基于 Unix 或 Linux 的,那么它很可能会使用文本配置文件,而文本配置文件需要一个文本编辑器。这是非常直接的逻辑,不是吗?

以一个例子来说——Vim 编辑器有许多衍生版本,可以用不同的方式使用,包括 vim-athena(支持 Athena GUI)、vim-gtkvim-gtk3(支持 GTK/GTK3)、vim-tiny(精简版的 Vim)、以及 vim-nox。不过,仍然大多数我们所知道的人更喜欢在命令行界面(CLI)中使用传统的 vi 或 Vim。

在我们食谱的第一部分,我们将解释 vi 和 Vim 的工作方式,并用它们完成一些常见的操作,比如以下几项:

  • 三种 vi(m) 模式——插入命令ex 模式

  • 在我们想要编辑的文本文件中移动光标

  • 删除文本(我们可以称之为 剪切删除 同时进行)

  • 向文本文件中插入附加内容

  • 在 vi(m) 编辑器中保存并退出

  • 在文本文件中查找内容

  • 复制和粘贴文本(vi 和 Vim 称之为 yankpaste

这对第一个示例来说已经足够了。我们将在本章最后一个食谱中回到 高级 vim 功能,深入研究 Vim,学习如何使用更高级的概念,如正则表达式、行标记、缓冲区和排序。

准备就绪

我们只需要检查一下 vi 和 Vim 是否已经安装在系统中。最简单的方法就是直接执行以下命令:

sudo apt-get -y install Vim-tiny busybox Vim dictionaries-common wamerican

默认情况下,Ubuntu 并不自带或使用 vi 编辑器,所以我们可以安装 Vim-tiny 包来模拟类似的功能。另一种在 Ubuntu 中使用 vi 编辑器的方法是使用以下命令:

busybox vi

busybox 是一款将多个 Linux 命令行工具 嵌入 到一个工具中的命令行工具,因此这个命令是我们需要关注的内容。同时,我们还需要记住,busybox 的目的是将多个流行的 CLI 工具嵌入一个工具中,这意味着这些工具都与它们的独立版本有所不同。

安装完成后(如果需要安装的话),我们将开始使用 Vim,并通过示例学习如何使用它。作为 root 用户,让我们执行以下命令:

cp /etc/passwd /root
cp /usr/share/dict/words /root

请注意,在 cp/etc/passwd/root 之间(第二个命令中的 cp/usr/share/dict/words/root 也适用),我们需要按下 空格键。我们实际上是在将 passwdwords 文件复制到 /root 目录,以便获得一些源文件进行操作。

当我们成功复制了这些文件后,我们将启动 Vim 编辑器并开始编辑。首先,我们将使用 passwd 文件。输入以下内容:

Vim /root/passwd

开始学习吧!

如何做到这一点…

现在,我们已经在 Vim 编辑器中打开了 /root/passwd 文件,让我们试着在其中进行操作。在普通模式下,移动光标很简单。我们可以通过键盘上的箭头键向上、向下、向左或向右移动。完成后,让我们通过按 gg(两次按 g 键)跳到文件的顶部。

首先,我们将删除第一行。Vi(m) 编辑器默认启动在 普通 模式,如果我们按两次 d 键,就会删除第一行。让我们查看一下删除前的状态:

图 2.1 – /root/passwd 文件的顶部部分

图 2.1 – /root/passwd 文件的顶部部分

现在,在我们按下 d 键两次后,应该是这样的效果(如果我们仍然处于第一行,即 root 行):

图 2.2 – 按下 d 键两次后,第一行已被删除

图 2.2 – 按下 d 键两次后,第一行已被删除

现在,让我们通过按下(仅作为示例)5dd 键序列进一步扩展这个用例。结果应该是这样的:

图 2.3 – 执行 5dd 操作(删除五行)后,光标后的五行被删除

图 2.3 – 执行 5dd 操作(删除五行)后,光标后的五行被删除

如我们所见,光标后的前五行(以 daemonbinsyssyncgames 开头的行)已经消失。

现在让我们跳到 /root/passwd 文件的最后一行,并将其复制粘贴到最后一行后面。首先,我们需要跳到文件的末尾,可以通过使用 Shift + g 键序列(即大写字母 G)来实现。接下来,如果我们想复制光标后的这一行(实际上是完成文件的最后一行),我们需要先 yank(复制)它,然后粘贴到正确的位置。yank 可以通过使用 yy 键序列(按两次 y 键)来实现。这样,光标后的这一行就会放入复制和粘贴缓冲区。如果我们想把它粘贴到最后一行后面,我们需要按下 p 键。我们复制的行将会自动粘贴到最后一行后面。如果我们使用的是和 第一章**,Shell 和文本终端基础 中相同的虚拟机,最终结果应该是这样的:

图 2.4 – 一行的 yank 和 paste

图 2.4 – 一行的 yank 和 paste

现在,让我们选择三行以 sshd 开头的行(即 sshdsystemd-coredumpstudent 行),并将它们复制粘贴到以 joe 开头的行后面。首先,我们使用光标键将光标定位到 sshd 行的开头。然后,我们输入 y3y 键序列。这将开始 yank(复制)从光标所在位置开始的三行,将它们复制到复制和粘贴缓冲区中,然后结束 yank。如果我们成功执行了这一步,Vim 会在屏幕底部给我们提示,显示 3 lines yanked

在我们将这些行放入复制和粘贴缓冲区后,我们需要粘贴它们。让我们使用光标键移动到以 joe 开头的行,然后按下 p 键。结果应该是这样的:

图 2.5 – Yank 和 paste,以及多行文本

图 2.5 – Yank 和 paste,以及多行文本

现在我们已经玩过了 yank、paste 和 delete,接下来是时候往这个文件中添加一些内容了。为了做到这一点,我们需要进入 插入模式。可以通过按下 i 键来实现。现在,让我们在光标后添加一些文本——按下 i 键并开始输入。我们来添加以下内容:

something:x:1400:1400::/home/something:/bin/bash

完成插入后,按下Esc键(返回到普通模式)。最终的效果应该如下所示:

图 2.6 – 使用插入模式插入额外文本

图 2.6 – 使用插入模式插入额外文本

现在我们已经成功完成了这些操作,接下来的步骤是,如果我们对文件内容满意,就保存文件。假设我们满意并准备保存文件。为了保存文件,我们需要进入 ex 模式 并告诉 Vim 我们想退出并保存文件。可以使用几个不同的按键组合来完成这个操作,第一个是 :wq!(写入并退出 – 不要求确认),第二个是 :x。还有其他方法,比如使用 ZZ 键组合,但我们先使用更常见的方法(wqx)。我们需要确保在输入这些按键组合时加上冒号符号(:)。正如我们稍后会解释的,使用冒号符号意味着我们想进入 ex 模式并对编辑过的文件进行最终操作。如果我们成功使用这个按键组合,我们应该会回到 shell,文件已经保存并包含我们所做的所有更改。

事实上,Vim 有大量的按键组合可以用于对文本文件执行各种操作。你可以根据自己的看法将 spectacular 翻译为好或坏,因为这是主观的——有些人喜欢它,有些人会讨厌它。以下是一些常用的按键组合:

  • dw – 删除一个单词

  • 2dw – 删除两个单词

  • yw – 复制一个单词

  • u – 撤销上一次更改

  • U – 撤销当前行的更改

  • a – 在光标后追加文本

  • A – 将文本追加到当前行的末尾

  • Ctrl + f – 向前滚动文件一页

  • n Ctrl + f – 向前滚动文件 n

  • Shift + m – 将光标移动到页面的中间

  • :50 – 将光标移动到当前文件的第 50 行

  • $ – 将光标移动到行尾

  • x – 删除光标处的字符

  • X – 删除光标前的字符

  • ^ – 跳转到行首

  • o – 在当前行后插入一行

  • Ctrl + g – 打印文件信息

还有成百上千个其他命令,我们特意选择了一些我们认为有用且常用的命令。现在,让我们通过使用一个内建的 Vim 教学工具 Vimtutor 来做一些更复杂的操作。在命令行中,通过输入以下内容启动 Vimtutor:

Vimtutor

在此之后,Vimtutor 会询问我们练习的目标输出文件,我们可以直接按下 Enter 键。此时,屏幕上应该显示以下内容:

图 2.7 – Vimtutor 启动页

图 2.7 – Vimtutor 启动页

现在,让我们用这个文件进行一些练习。我们要做的第一件事是复制第一段(从 Vim 开始,到 editor. 结束),然后粘贴到以 The approximate time 开头的段落之前。

让我们使用箭头键将光标定位到 Vim 行的开头。做完后,我们需要使用 y} 键序列指示 Vim 剪切 从光标开始的段落。然后,使用光标键移动到第一段和第二段之间的空行,按 p 键将剪切的段落粘贴到光标之后。操作结果应该是这样的:

图 2.8 – 剪切和粘贴文本段落

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_2.8_B16269.jpg)

图 2.8 – 剪切和粘贴文本段落

假设我们想将整个文件转换为小写字符。当然,这个操作涉及到其他几个步骤:

  • 我们需要移动到文件的开头(gg)。

  • 我们需要开启 视觉模式(稍后会详细讲解),可以通过按下 Shift + v 键组合(大写 V)来实现。

  • 我们需要标记文本直到文件的末尾,可以通过按下 Shift + g 键组合(大写 G)来实现。

  • 我们需要将文本转换为小写,可以通过按 u 键来实现。

所以,我们要找的键序列是 ggVGu。我们的操作结果应该是这样的:

图 2.9 – 我们的 Vimtutor 文件,所有字符为小写

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_2.9_B16269.jpg)

图 2.9 – 我们的 Vimtutor 文件,所有字符为小写

如果我们想做相反的操作(将所有字符转换为大写),我们将使用 ggVGU 键序列(U 表示大写,u 表示小写)。

我们将稍微停一下,暂时不讨论这些键序列,来解释一下 Vim 的工作原理——具体来说,我们将专注于常用的几种模式,并简要提及一些不太常用的模式。我们先从 普通模式 开始,然后逐步讲解到视觉模式和 替换模式

它是如何工作的……

Vim 编辑器有超过 10 种不同的模式,基本上是它工作方式的不同表现。最常用的模式如下:

  • 普通模式

  • 插入模式

  • Ex 模式

  • 视觉模式

  • 替换模式

当我们启动 Vim 时,默认处于普通模式;我们可以在其中使用光标进行浏览,也可以进行一些剪切和粘贴操作,还能删除文本。因此,它主要用于诸如在编辑的文件中导航、简单的编辑等常规操作。

如果我们想要在文本文件中添加额外的内容,通常会使用 i 键切换到插入模式。在插入模式下,我们可以轻松地在光标后添加文本并在编辑文件中移动。当我们准备好返回正常模式时,可以按 Esc 键。如果我们完成文件编辑并只想保存文件并退出,则需要进入正常模式,然后进入 ex 模式。这可以通过按 Esc 键,然后输入冒号 (:) 来实现。这样我们就进入了 ex 模式,然后可以继续执行 wq!xZZ

可视模式和替换模式有很大不同。可视模式有子模式(字符),可以用来选择(高亮)我们想要处理和操作的文本部分。例如,行模式和块模式在使用 Ansible 修改 YAML 文件时非常有用。字符模式可以用来高亮代码的一部分。由于 YAML 语法对缩进非常敏感,因此通过使用行模式,我们可以高亮显示剧本中的部分内容并调整其缩进(使用 >< 键),这样就不必手动进行调整。块模式则可以高效地检查通过行模式创建的缩进。这些模式可以通过使用 Shift + V(行模式)和 Ctrl + v(块模式)进入。字符模式可以通过使用 v 键进入。

替换模式允许我们在现有内容上输入文本。我们可以使用 R 键进入替换模式(从正常模式)。

另见

如果你需要更多关于 Vim 基础的信息,我们建议你查看以下内容:

学习 nano 编辑器的基础

如果你觉得 Vim 编辑器对你来说太复杂了,我们能理解你的感受。正因如此,选择你将要使用的编辑器是一个主观的选择。我们想为你提供一个更简单的编辑器,叫做 nano

准备工作

保持 CLI1 虚拟机处于开机状态,让我们继续编辑文件。

如何操作…

我们将编辑在之前的步骤中复制的 words 文件。在此之前,让我们先通过输入以下命令来确认是否已安装 nano:

sudo apt-get -y install nano

现在让我们通过输入以下命令打开 root 目录下的 words 文件:

nano /root/words

我们的文件应已在 nano 编辑器中打开,如下图所示:

图 2.10 – 使用 nano 编辑器开始编辑

图 2.10 – 使用 nano 编辑器开始编辑

对于那些更习惯使用记事本或 Wordpad 等文本编辑器的人来说,nano 应该是一个更熟悉的领域。它没有 Vim 那样的广泛功能或高级功能,但大多数情况下,这可能并不那么重要,至少对于大多数文本文件编辑操作而言。或者,真的这么简单吗?让我们来检查一下。

在 nano 中的编辑方式与其他常规编辑器类似——我们只需要解释截图下半部分(我们可以看到 HelpExit 等部分)。在 nano 中,如果我们需要帮助,需要按 Ctrl + g。这是我们会得到的结果:

图 2.11 – nano 帮助

图 2.11 – nano 帮助

如果我们愿意,可以花时间滚动浏览这个帮助窗口。但是,首先我们可以说,这个 ^ 字符表示 按下 Ctrl 键

所以,在我们第一个 nano 截图中,^G 表示 Ctrl + G^X 表示 Ctrl + X,以此类推。它仍然不像很多人在微软 Windows 上使用的一些文本编辑器那么简单,但比 Vim 更加用户友好。如果没有其他的话,某些常用命令就在我们屏幕的底部,这样我们就不需要记住所有的键盘序列或在使用编辑器之前在网上查找它们。

如果我们想关闭第二个 nano 截图中的帮助窗口,只需要按 Ctrl + X。这样我们就会回到第一个 nano 截图所示的状态。

如果我们想删除一行,我们需要使用 Ctrl + K。如果我们需要删除多行,事情就开始变得有些复杂。我们首先需要选择要删除的内容(Ctrl + Shift + 6),使用光标移动到我们要删除的地方,然后按 Ctrl + K。假设我们要删除五行。那么,选择要删除的内容就像这样:

图 2.12 – 按下 Ctrl + Shift + 6,并使用光标键向下滚动五行,我们准备好按 Ctrl + K 了

图 2.12 – 按下 Ctrl + Shift + 6,并使用光标键向下滚动五行后,我们准备好按 Ctrl + K 了

在我们选择了正确的文本之后,我们只需要按 Ctrl + K 来删除它。结果将如下所示:

图 2.13 – 五行成功删除 – 结果!

图 2.13 – 五行成功删除 – 结果!

同样的思路适用于对段落进行复制和粘贴操作。我们需要使用 Ctrl + Shift + 6 和光标标记文本,Alt + 6 将复制的文本放入复制粘贴缓冲区,然后使用 Ctrl + U 将其粘贴到 nano 中需要粘贴的地方。保存文件相当于使用 Ctrl + X 退出,然后确认我们希望将更改保存到文件中。

还有更多…

如果你想了解更多关于 nano 的信息,可以查看以下链接:

通过高级 Vi(m) 设置

在本章的第一部分,我们学习了一些基本的 Vim 操作,包括移动、复制粘贴、保存和退出。接下来,让我们处理一些高级操作,比如查找和替换、正则表达式以及类似的概念。

准备就绪

我们需要保持 CLI 虚拟机运行。如果它没有开机,我们需要重新开机。

如何做到这一点…

在 Vim 中查找内容是一个多步骤的过程,取决于几个因素。首先,它取决于我们想要的方向,是向前还是向后,因为这两种操作有不同的快捷键序列。让我们再次打开/root/words文件,查找一些文本:

Vim /root/words

让我们从查找单词 fast 开始。为此,我们需要从正常模式使用/字符,因为它告诉 Vim 我们即将使用搜索功能。所以,/fast将从当前光标位置搜索单词 fast forward。这是预期的结果:

图 2.14 – 在 Vim 中查找单词

图 2.14 – 在 Vim 中查找单词

如果我们现在按下Enter键,然后按下n键,我们将搜索单词 fast 的下一个出现位置。预期的结果是:

图 2.15 – 查找单词 fast 的下一个出现位置

图 2.15 – 查找单词 fast 的下一个出现位置

然而,如果我们想查找单词 fast 的第 10 次出现,我们需要按下正确的快捷键序列或使用正则表达式。让我们先从快捷键序列开始,它是(再次从正常模式)10/fast。这是预期的结果:

图 2.16 – 查找单词的第 n 次出现

图 2.16 – 查找单词的第 n 次出现

如果我们想查找单词的上一个出现位置(基本上是向后搜索),我们需要按下N键(大写 N)。这是预期的结果:

图 2.17 – 从上一个光标位置向后查找单词

图 2.17 – 从上一个光标位置向后查找单词

现在我们来做一些搜索和替换。假设我们想要查找单词 airplane 的所有出现,并将它们替换为 metro,从文件的开头开始。为此,使用的快捷键序列是gg(返回文件开头),然后是 :%s/airplane/metro/g,接着按下Enter键。预期的结果是:

图 2.18 – 用另一个单词替换单词的所有出现

图 2.18 – 用另一个单词替换单词的所有出现

该语法假设自动替换文件中所有出现的单词 airplanemetro。如果我们只想替换某一行中字符串的第一次出现,我们需要先使用 /word 键序列找到那个单词。然后,我们需要使用 :s/word1/word2/ 键序列来仅替换 word1 的第一次出现为 word2。我们可以用单词 airship 做这个示例,并将其替换为 ship。如果我们输入 /airship 并按Enter键,Vim 会将我们定位到下一个 airship 的位置。如果接着使用 :s/airship/ship/ 键序列并按Enter键,我们应该得到如下结果:

图 2.19 – 在特定行中将单词的一个出现替换为另一个单词

图 2.19 – 在特定行中将单词的一个出现替换为另一个单词

这是一个微妙的区别,但却是一个重要的区别。

我们还可以在 vi 中使用更多的命令——例如,使用点号(.)。这可以用来重复在普通模式下进行的最后一次更改,您可能会发现这也非常有用。

我们将在这里暂停,因为我们将在第七章基于网络的文件同步中介绍更多高级的文本查找模式,利用正则表达式。

它是如何工作的…

sed,一个流编辑器。这个命令被全球的系统工程师广泛使用,用来快速替换任何给定文件(或多个文件)中的简单或复杂文本模式到另一个复杂的文本模式。它以正则表达式为基础(在第七章基于网络的文件同步中有解释),这意味着默认情况下,在 Vim 中进行查找和替换是相当强大的,尽管有点复杂,因为我们需要学习 sed 的细节以及 Vim 如何将其作为插件来处理。

话虽如此,我们大多数人关注的是最后一段中的相当强大的部分,因为使用 Vim/sed 组合可以快速替换复杂的文本模式,从而得到快速而精确的结果——当然前提是我们知道自己在做什么。

还有更多…

使用这些概念需要一些额外的阅读。因此,我们需要确保检查以下附加链接:

第三章:使用命令和服务进行进程管理

管理进程是 Linux 系统管理员的一项重要工作。原因有很多——也许某些进程卡住了,我们需要结束它们,或者我们希望将某些进程设置为后台运行,甚至是定期或在稍后的日期启动。不管是什么情况,了解如何管理进程并使它们有效地完成需要做的工作,同时考虑到系统上其他进程的运行,都是非常重要的。

在本章中,我们将学习以下内容:

  • 进程管理工具

  • 管理后台任务

  • 管理进程优先级

  • 配置 crond

技术要求

对于这些操作,我们将使用一台 Linux 机器——我们可以使用之前教程中任何虚拟机。再次提醒,我们可以继续使用前一章中使用的cli1机器。那么,总结来说,我们需要以下内容:

  • 一台安装了 Linux 的虚拟机,任何发行版(在我们的例子中,将使用 Ubuntu 20.10)

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

进程管理工具

管理进程意味着了解进程的工作方式以及我们可以用来管理它们的特定文本模式工具。我们将从介绍一些简单的概念开始——解释什么是进程以及它们可能处于哪些状态——然后我们将继续讲解命令以及如何从管理的角度使用这些命令来管理进程。这意味着我们将学习 10 个以上的新命令/概念,这些是理解这一切如何运作所必需的。

准备工作

在本教程中,我们将使用的大多数命令和工具都预先安装在我们的 Linux 发行版中。话虽如此,还有一些很棒的额外工具,我们可以用来进一步加深对进程管理和系统资源管理的理解。所以,让我们安装另一个工具,它能够作为监控系统资源的工具,并且能够处理低级任务,例如处理进程。它叫做 glances;我们可以通过输入以下命令来安装它:

apt-get -y install glances

这些应该覆盖了我们在本教程中需要的所有内容,现在开始吧!

如何操作…

我们必须先讲解的前两个命令是 pstop。这些命令是 Linux 系统管理员每天都会使用几十次的命令,尤其是在管理 Linux 服务器时。这两个命令都非常有价值,因为如果我们知道如何正确使用它们,我们可以获得大量关于系统的信息,特别是 ps 命令。

所以,首先我们使用没有任何附加选项的 ps 命令(选项有很多):

图 3.1 – 默认 ps 命令输出

图 3.1 – 默认 ps 命令输出

默认情况下,ps会给我们当前正在运行的进程报告。我们可以在没有任何附加选项的情况下启动它,从而获取当前 shell 中正在运行的进程列表。在这个输出中,我们已经可以看到一些有趣的信息。首先,我们可以看到五个进程及其 ID(左侧的PID字段)。接着,我们可以看到它们的运行位置,这就是TTY字段的含义。TIME字段告诉我们进程到目前为止使用了多少累计的 CPU 时间。最右侧是CMD字段,显示了启动的实际进程名称。

要充分理解ps命令的强大功能,我们需要查看它的手册页面。里面有一个非常好的EXAMPLES部分。以下是该部分的摘录:

图 3.2 – 使用 ps 命令的示例

图 3.2 – 使用 ps 命令的示例

让我们使用这些示例中的一个极端派生。我们来输入以下命令:

ps auwwx | less

我们使用了| less部分来仅输出ps命令输出的第一页。输出应该类似于这样:

图 3.3 – ps auwwx 命令输出(更加冗长)

图 3.3 – ps auwwx 命令输出(更加冗长)

正如我们清楚看到的,这个输出比之前按PID排序的输出包含了更多的细节。一些新增的字段包括:

  • USER:此字段告诉我们启动进程的用户的名字。

  • %CPU:此字段告诉我们进程使用了多少 CPU 时间。

  • %MEM:此字段告诉我们进程使用了多少内存。

  • VSZ:此字段告诉我们进程使用了多少虚拟内存。

  • RSS:常驻集大小,进程使用的非交换内存量。

  • STAT:进程状态代码。

  • START:进程启动的时间。

作为一个示例,许多系统管理员使用%CPU%MEM字段来查找使用过多 CPU 或内存的进程。

假设我们需要通过进程名称来查找一个进程。有多种方法可以实现这一点,其中最常见的两种方法是使用ps命令或pgrep命令。让我们看看它们是如何工作的:

图 3.4 – 使用 pgrep 或 ps 按名称查找进程

图 3.4 – 使用 pgrep 或 ps 按名称查找进程

作为一种命令,我们通常使用grep来创建一个过滤器,查找文本输出中的特定文本。我们可以看到这两个命令都给出了我们需要的结果——它们只是以不同的格式和不同的详细程度显示结果。我们还可以使用pidof命令来查找任何给定进程的 PID,类似于pgrep

图 3.5 – 使用 pidof 命令

图 3.5 – 使用 pidof 命令

现在我们来解释一下top命令的概念。在启动top命令后,我们应该会看到类似这样的内容:

图 3.6 – 使用 top 命令

图 3.6 – 使用 top 命令

在这个交互式输出中,多个操作正在同时发生:

  1. top 行实际上是来自 uptime 命令的输出。如果我们添加接下来的四行(从 Tasks%Cpu(s)Mib MemMiB Swap 开始),这就是我们所说的顶部 摘要区域

  2. 之后,我们可以清楚地看到,top 作为 ps 命令的前端,但以交互方式实现。

top 命令的交互式部分源于它定期刷新——默认情况下是每 3 秒刷新一次。我们可以通过按 S 键来更改默认刷新间隔,这时 top 会提示我们将延迟从 3.0 更改为任何数字。如果我们想将刷新间隔更改为 1 秒,只需按下 1 并按 Enter

我们可以让 top 显示某个特定用户的进程(按 U 键并输入该用户的登录名),并终止进程(按 K 键并输入 PID 和要发送的信号)。我们还可以调整进程优先级,这将在本章的 第三个 配方中讲解。总的来说,top 是一个非常有用且常用的进程管理命令。它作为许多不同命令的前端,例如 nicerenicekill

接下来我们需要学习的命令是 killkillall。我们不应该通过字面翻译来试图直观理解这些工具的作用,因为我们会发现这种翻译并不适用。具体来说,kill 命令是用于通过进程对应的 PID 来终止一个进程。相比之下,killall 用于通过进程名称来终止进程。当然,这两者都有各自的有效使用场景。为了展示这两个命令的例子,我们将使用以下的 top 输出:

图 3.7 – 顶部输出 – 注意到学生用户启动了两次  命令

图 3.7 – 顶部输出 – 注意到学生用户启动了两次 top 命令

让我们在另一个 shell 中终止这两个 top 进程。如果我们想通过 kill 命令终止第一个进程,我们需要输入以下内容:

kill 41246

如果我们想通过进程名称终止所有已启动的 top 命令,可以输入以下内容:

killall top

当使用 kill 命令时,我们是在终止一个特定的 PID。当使用 killall 命令时,我们是在终止所有启动的 top 进程。当然,为了能够使用这两个命令中的任意一个来终止进程,我们必须以 root 用户或 student 用户身份登录。只有启动了进程的用户和 root 用户才能终止用户进程。我们需要记住,这两个命令的默认信号是 SIGTERM 信号(信号编号 15)。如果我们想通过自定义信号来终止进程,可以通过在这两个命令中添加信号编号,并在前面加上减号来实现。下面是一个例子:

kill -9 41246

这将向进程发送 SIGKILL 信号。这两个信号在本食谱的 它是如何工作的… 部分有解释。

另外需要注意的是,有时我们需要找到当前正在运行的 shell 的 PID 或父进程 PID。我们可以通过以下两个命令来实现:

图 3.8 – 当前 shell 进程的 PID,父进程的 PID

图 3.8 – 当前 shell 进程的 PID,父进程的 PID

现在让我们检查一下 glances 如何帮助我们查看系统中发生了什么。如果我们只启动命令,系统会输出以下内容:

图 3.9 – glances 默认输出

图 3.9 – glances 默认输出

很容易看到 glances 使用的详细信息级别,以及不同的格式。此外,我们也很欣赏它默认使用颜色输出,这使得信息稍微容易阅读。我们可以使用不同的方法来显示数据。例如,我们可以输入 1 来在每个 CPU 核心的统计数据与汇总统计数据之间切换。我们还可以以服务器模式使用它(通过添加 -s 参数启动),这样就可以监控远程主机。因此,从服务器的角度来看,我们会用以下命令启动:

glances -s

从客户端的角度,我们将使用以下命令启动 glances

glances -c @servermachine

glances 是跨平台的(它基于 Python),支持 Linux、OS X、Windows 和 FreeBSD。它还内置了一个 Web UI,可以通过 Web 浏览器使用,如果我们更喜欢使用图形界面而不是命令行界面。但它最方便的功能之一是能够以各种不同的格式导出数据——CSV、Elasticsearch、RabbitMQ、Cassandra 等等。

它是如何工作的…

进程是操作系统初始化的命令执行单元,操作系统和系统管理员都可以管理它。这意味着进程就像是任何给定程序的一个实例,并且它具有一些共同的属性(状态、PID 等,以及我们将在本章中描述的许多其他属性),同时也需要执行一些任务。例如,在我们启动一个命令(进程)之后,命令可以打开并读取文件、用户输入或其他程序的数据,处理这些输入,然后在工作完成后终止。

需要注意的是,如果我们重启计算机,进程不会被暂停——它们会被停止,然后在 Linux 机器启动时重新启动,或者我们在重启后手动启动它们。因此,重启后进程不会保持持续性。大多数情况下(除去操作系统启动过程中涉及的进程),它们甚至不会在重启后保持相同的 PID。

在进程类型方面,我们有五种不同的类型:

  • 父进程与子进程:简单来说,父进程是创建其他进程的进程,这些被创建的进程我们称为子进程。子进程会在父进程退出时退出,而父进程在子进程退出时并不会退出。

  • 僵尸进程与孤儿进程:有时父进程在子进程退出之前被杀死,剩余的子进程称为孤儿进程。另一方面,僵尸进程指的是一个已经被终止,但仍然存在于进程表中的进程。

  • 守护进程:守护进程通常与一些系统任务相关,这些任务通常涉及与其他进程的交互并为其提供服务。守护进程也不会使用终端,因为它们在后台运行。

从状态的角度来看,我们有以下几种:

  • 运行/可运行:运行状态是指进程正在被 CPU 执行的状态。而可运行状态则意味着进程已经准备好被执行,但当前并未消耗 CPU,或者正在等待 CPU 执行。

  • 可中断/不可中断睡眠:在可中断睡眠状态下,进程可以被唤醒,并且可以接受针对它的信号。在不可中断睡眠状态下,进程不会被唤醒,且会保持睡眠状态。这个情况通常涉及到一个系统调用——进程在完成任务之前无法执行系统调用,也不能被暂停或终止。

  • 停止:当进程收到信号或者我们正在调试一个进程时,进程通常会被停止。

  • 僵尸进程:一个已经终止但仍然存在于进程表中的进程处于僵尸状态。

从操作系统的角度来看,进程是程序或服务的执行单元。进程由操作系统调度,这意味着为它们分配资源,使它们能够从程序的角度(上下文)运行,并且分配一些基本的属性,便于从系统管理的角度进行管理。这包括在进程表中创建一个条目,并分配 PID(进程号)以及其他属性数据。我们将在本章稍后的部分解释这些属性,并讨论如何通过使用像 topps 这样的命令来观察进程的状态。

我们提到了信号的概念。在讨论内核与用户空间程序之间建立通信的不同方式时,通常有两种方式——通过系统调用或信号。通常,如果我们想要通过命令发送一个信号给进程,可以使用 killkillall 命令,并通过信号号或名称来指定信号。让我们来看一下信号列表的一个摘录:

图 3.10 – 来自 signal 手册页的摘录

图 3.10 – 来自 signal 手册页的摘录

正如我们所见,信号有很多,约有 30 个信号已经由 Linux 内核实现。此外,从进程的角度来看,信号有两种类型:

  • SIGHUP 信号(编号 1)

  • SIGKILL 信号(编号 9)

这里使用的“handled”一词是以编程意义上的方式出现的——处理某个信号意味着使用某种处理程序编写代码,该代码将拦截信号消息并将其重定向到某个地方,例如函数或子程序。

这两种类型之间有很大的区别。我们以守护进程为例,例如 Apache 网络服务器。如果守护进程接收到 SIGHUP 信号并且支持该信号(即它的源代码中有处理 SIGHUP 信号的例程,像 Apache 一样),它在接收到 SIGHUP 后最常做的事情就是通过重新读取配置来刷新其状态。引用 Apache 手册:

向父进程发送 HUP 或重启信号会导致它像 TERM 信号一样杀死其子进程,但父进程本身不会退出。它会重新读取配置文件,并重新打开日志文件。然后,它会生成一组新的子进程,并继续提供服务

与此场景不同,当你向 Apache 发送 SIGKILL 信号时,它会被终止,而不会考虑刷新其配置、内容或类似操作。我们无法为此信号编写处理程序,将其重定向到除被终止进程之外的任何地方。我们可以把它理解为一种内核吸取进程生命的情境,因为进程无法访问运行所需的资源,并且被系统(内核)有效地消除。

第三种常用信号是 SIGTERM(编号 15)。它也用于终止进程(如 SIGKILL),但它的方式是优雅的。我们可以把它理解为来自内核的你好,进程先生,请优雅地终止自己,好吗?非常感谢!的消息。然后进程会做它需要做的事情并自己关闭。

现在我们简要了解了进程和信号的工作原理,让我们继续探索有关进程管理的知识,学习后台进程的管理。既然我们已经解释了后台进程的基础,应该不会是难事。

另见:

如果你需要更多关于进程、信号以及类似概念的信息,确保查看以下内容:

管理后台任务

有多种情况我们希望启动一个进程并将其放到后台运行。例如,假设我们希望启动一个进程,退出登录,然后等到第二天再检查该进程的结果。让我们通过一个例子来学习它是如何工作的。

准备就绪

保持cli1虚拟机开机,让我们使用 shell 来解释后台进程是如何工作的,与前台进程的不同之处。我们还会确保在它是如何工作的…这一部分中进行解释。

如何做到这一点……

假设我们想通过使用 shell 工具下载一个大文件。在 Linux 中,常见工具是一个叫做wget的程序。我们想要启动一个wget会话(wget是一个shell命令,可以让我们从httpftp的 URI 下载文件)来下载一个大 ISO 文件,但我们希望在下载进行的同时退出(或做其他事情)。这可以通过将wget进程放到后台来实现。这只是利用后台进程的一种常见例子。

首先,我们需要安装wget。我们可以通过以下命令来安装:

apt-get -y install wget

wget是一个常用的工具,通常默认安装。无论如何,通过使用这个命令,我们可以确保它已安装。

让我们使用Ubuntu 20.04 ISO文件作为我们想要下载的文件,使用两个例子来展示。第一个例子是将wget作为前台进程运行,第二个例子是将wget作为后台进程运行。第二个例子实际上可以通过两种不同的方式完成,因为wget有一个内置选项可以将其放到后台。当然,既然我们要解释的是系统级的概念,而非某个特定工具,那么我们一定会确保两种方式都进行讲解。

在撰写时,Ubuntu 20.04 ISO 文件可以在这里找到:

releases.ubuntu.com/20.04/ubuntu-20.04.3-live-server-amd64.iso

让我们通过输入以下命令,使用wget以前台进程的方式下载文件:

wget https://releases.ubuntu.com/20.04/ubuntu-20.04.3-live-server-amd64.iso

结果应该类似于这样:

图 3.11 – 前台进程 – 独占锁定 shell 访问

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_3.11_B16269.jpg)

图 3.11 – 前台进程 – 独占锁定 shell 访问

正如我们清楚看到的,下载正在进行,但问题是,在接下来的 12 分钟以上,我们无法在这个 shell 中做任何事,因为底层的 shell 会话完全被 wget 占用。我们无法输入命令,也无法获取命令结果——什么都做不了。唯一能做的事是使用 Ctrl + C 组合键退出下载并返回到 shell。但这不是我们想做的。我们想做的是:

  1. 启动下载。

  2. 被抛回到 shell 中,并且下载仍在进行。

这是一个运行后台任务非常有帮助的情况。那么,让我们在之前的命令中添加一个额外的参数:

wget https://releases.ubuntu.com/20.04/ubuntu-20.04.2-live-server-amd64.iso&

这条命令末尾的 & 符号告诉内核将该进程放入后台。让我们看看最终结果:

图 3.12 – 在后台启动进程

图 3.12 – 在后台启动进程

我们可以清楚地看到,我们已经被抛回到 shell(root@cli1 提示符),并且我们可以继续输入其他命令。我们还可以看到一个 wget 进程已启动,PID 为 43787,如果我们愿意,可以使用 kill 命令终止它。

显然,我们可以在命令末尾使用 & 来发出多个命令,然后我们就会有多个进程在后台运行。这时,之前输出中的 [1] 部分就派上用场了。这个数字表示分配给后台进程的索引号。换句话说,我们开始的 wget 进程,PID 为 43787,是第一个后台进程。如果我们启动多个后台进程,每个新的后台进程将会得到下一个数字——23,依此类推。

显然,我们需要学习如何管理多个后台任务。这正是 jobs 命令的作用。让我们看看它是如何工作的。首先,我们将启动多个后台任务:

图 3.13 – 启动多个后台进程

图 3.13 – 启动多个后台进程

然后,使用 jobskill 命令来查找我们有哪些后台任务,并通过索引(而不是通过 PID)来终止它们。操作方法如下:

图 3.14 – 检查并终止多个后台进程

图 3.14 – 检查并终止多个后台进程

通过使用kill %index_number语法,我们能够通过索引号终止后台作业,而不是通过它们的 PID。这个语法更简洁,在日常生活中不应忽视,因为它让许多事情变得更简单——只要我们不登出。如果我们登出,情况就有所变化,因为我们无法通过索引号访问这些进程,但我们仍然可以通过 PID 来管理它们。所以,假设我们启动了两个wget会话作为后台进程,然后登出并重新登录。让我们试着将这些进程列为后台进程,然后作为普通的、一般的进程,并通过 PID 终止它们。这是接下来发生的事情:

图 3.15 – jobs 没有输出,但 ps 命令有输出

图 3.15 – jobs 没有输出,但 ps 命令有输出

我们清楚地看到,jobs命令没有任何输出(找不到后台作业的索引号),但我们的进程仍然在运行。为什么?好吧,我们启动的后台进程是在一个已经不再活跃的 shell 中创建的。在我们登出后,我们启动了一个新的 shell,并且由于jobs命令的工作方式,我们无法再看到这些后台作业了。但我们绝对可以将它们视为在系统上运行的进程,并且,如果需要,我们可以通过 PID 成功终止它们,正如我们使用kill命令所做的那样。我们在这里使用了ps命令,并通过使用grep过滤了它的输出——grep是一个可以从基于文本的输出中搜索特定文本的命令(在我们的案例中,我们通过使用ps auwwx命令搜索整个进程表,创建了一个管道并使用管道符号(|),然后将ps命令的输出传递给grep命令)。

我们提到过,wget命令有能力通过使用命令行选项(-b)在后台启动自身。这并不常见,但绝对有用。所以,假设我们使用以下命令:

wget -b https://releases.ubuntu.com/20.04/ubuntu-20.04.2-live-server-amd64.iso

这应该是最终结果:

图 3.16 – 可以通过使用开关在后台启动 wget

图 3.16 – 可以通过使用-b开关在后台启动 wget

这个过程真正有趣的地方在于:

  • wget清楚地表明它会在后台启动,但它没有给我们一个索引编号。

  • 如果我们使用jobs命令,我们无法看到它作为后台进程的状态。

  • 我们可以通过常规方式使用kill命令来终止它。

这是一个稍微不同的概念,因为wget通过创建一个wget子进程并终止父进程,实际上实现了这种jobs 命令隐形的效果。由于父进程不再存在,它不再与特定的 Shell 关联,因此不再被索引。结果是它在当前 Shell 的作业表中不可见。我们可以通过使用disown命令实现类似的效果。让我们在当前 Shell 中启动一个进程,然后做wget基本上做的事情:

图 3.17 – 解除后台进程的关联

图 3.17 – 解除后台进程的关联

还有其他方法可以确保进程进入后台。最常见的情况是我们希望将进程放入后台,但忘记在命令末尾加上&符号,结果进程被困在前台。那么该怎么办呢?

答案很简单 – 我们按下Ctrl + Z(将进程置于挂起状态),然后输入bg命令。这样会将进程放入后台,就像我们一开始用&符号启动它一样。将这些与jobsdisownkill结合起来,会是这样的:

图 3.18 – 使用 Ctrl + Z 和 bg 将进程放入后台

图 3.18 – 使用 Ctrl + Z 和 bg 将进程放入后台

我们在前台启动了wget,然后通过按Ctrl + Z将其置于挂起状态。接着,我们使用bg命令将该进程移至后台。由于它在我们的 Shell 中是作业号1,我们解除它的关联,使用ps查找其 PID,并将其终止。

如果由于某种原因,我们想要将一个进程从后台恢复到前台(前提是该进程有索引号并且是在当前 Shell 中启动的),我们可以使用fg命令来实现。因此,如果我们使用之前的操作步骤作为示例,它将是这样的:

图 3.19 – 使用 Ctrl + Z、bg 和 fg 将一个进程从后台移动到前台再返回后台

图 3.19 – 使用 Ctrl + Z、bg 和 fg 将一个进程从后台移动到前台再返回后台

我们可以清楚地看到,wget进程先是进入了后台(Ctrl + Zbg 命令),然后又回到了前台(fg 命令),最后通过使用Ctrl + C将其终止。如果我们当前的 Shell 中有多个后台进程,我们还可以使用索引来操作前台命令(fg 索引号)。

它是如何工作的……

进程可以以两种不同的方式运行:

  • 前台:如果我们从 shell 启动一个进程,该进程将占用当前的 shell,并且不允许我们输入其他命令。对此规则的一个例外情况是,启动的进程需要额外的用户输入,但这些输入需要嵌入到我们正在执行的进程核心中(程序代码的一部分)。在这种情况下,shell 完全被启动的进程占用,直到进程完成、我们将其置于后台,或者它被其他外部因素(如其他进程或内核,或因某种原因崩溃)终止。

  • 后台:如果我们在后台启动一个进程,它会运行并释放我们的 shell,这样我们可以继续用它输入其他命令。

当一个进程在当前 shell 中转入后台时,它会获得一个索引号,这样我们就可以通过该索引号来管理它。我们可以使用 fgkill 等命令来操作这个索引号(例如,kill %1 会终止作业索引表中的第一个作业)。

正如我们在实际演示中看到的,有多种方法可以确保进程在后台启动——无论是启动时还是启动后。这一概念之所以成立,是因为我们可以轻松地将进程放入后台,由操作系统处理,当我们不在时,这有时意味着节省我们的宝贵时间。

还有更多内容……

如果我们需要了解更多关于前台和后台进程的内容,可以查看以下链接:

管理进程优先级

当我们解释如何使用 top 命令时,我们有意省略了一些细节,留待在本章稍后讨论。我们将在这里讨论其中一个细节:top 输出中 PRNI 字段的区别。现在我们来讨论这个。

准备工作

保持 cli1 虚拟机开启,让我们继续使用我们的 shell。

如何操作……

我们将学习如何使用 topnicerenice 命令,根据我们的需求来管理进程调度。首先,我们来使用 top 命令。我们将 renice 一个正在运行的进程,使其优先级变为更负的值和更正的值。我们将使用以下 top 输出进行演示:

![图 3.20 – 起点 – 学生用户启动的进程]

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_3.20_B16269.jpg)

图 3.20 – 起点 – 学生用户启动的进程

现在让我们改变 PID 为 47160top)的进程的优先级。按下 R 键,top 输出将会变化为如下所示:

图 3.21 – 让我们重新调整一个 PID 的优先级

图 3.21 – 让我们重新调整一个 PID 的优先级

然后,再次输入数字 47160,按 Enter 键,并输入 nice 值——假设为 -10。我们应该会看到类似如下的结果:

图 3.22 – 结果 – 更负的 nice 值,更高的优先级

图 3.22 – 结果 – 更负的 nice 值,更高的优先级

我们可以清楚地看到,我们的 PID 的 NI 字段从 0 改变为 -10。由于我们赋予了更负的 nice 值,这意味着更高的优先级。

这个示例解释了如何 renice 一个已经在运行的进程。但显然,我们不能用它在启动进程之前设置 nice 值。这就是为什么我们有 nice 命令的原因。下面是使用 nice 命令的一个示例:

nice -n -10 top

如果我们以 root 用户身份启动该命令并查看输出,给它一点时间后,我们应该会看到类似这样的内容:

图 3.23 – 使用 nice 在进程启动时预设优先级

图 3.23 – 使用 nice 在进程启动时预设优先级

显然,这里有一个警告——如果我们尝试以普通用户身份启动这个命令,是无法成功的。普通用户没有使用 nice 命令的权限——想象一下,如果普通用户拥有这个权限,他们可能会有多少种滥用系统或崩溃系统的方式。若我们希望授予某些用户使用 nice 命令的权限,可以通过 PAM 模块或 sudo 系统来实现。但目前为止,我们可以一致认为这只是一个例外,不需要像 urbi et orbi 那样普遍适用。

现在让我们解释一下这些概念是如何工作的。

它是如何工作的……

我们先执行 top 命令并查看其输出中的重要部分:

图 3.24 – top 输出与进程优先级相关

图 3.24 – top 输出与进程优先级相关

简要解释一下这两个领域的区别:

  • PR(优先级字段):当前查看时的实际内核调度优先级,由内核分配。rt 标记表示实时;其值范围在 0139 之间,尽管对于实时进程,它可以有负的静态值。

  • NI(nice 值字段):进程应该具有的优先级,默认情况下由用户空间(而非内核空间)分配,或者通过额外的命令(nicerenice)进行调整。数字越低,优先级越高,范围从 -20+19

显然,这两个数字之间有很大的差异,因为其中一个是真实的PR),另一个则像是建议NI)。理论上解释进程优先级相对容易,但由于一些架构原因,将其应用到实际中会变得更具挑战性。因此,我们将尝试用一个曾经可以实现但现在不再适用的例子来解释这一点,因为现代 CPU 和内存的速度已经远远超过了 10 年前的水平。所以,先讨论理论概念,然后再用一个例子来说明。

从理论上讲,当我们使用nicerenice命令时,我们所做的是为进程分配一定的 CPU 上下文——一个正在运行的进程(renice)或一个即将运行的进程(nice)。我们在这里使用“上下文”一词,是从 CPU 的角度在编程中使用的。翻译过来——如果我们想运行一个进程,内核需要为它分配一些 CPU 资源。如果我们有一个正在运行的进程,并且将其renice为一个更负的值,这将告诉内核及其调度器更多地关注这个特定的进程,从而给它更多的 CPU 访问权限。如果我们将其renice为一个更正的值,这将告诉内核减少分配 CPU 资源给该进程。通过给进程分配更多的 CPU,进程可能会变得更快并更快地完成工作。

显然,这有点过于简化,因为这里涉及到其他因素。例如,每个进程都需要一些内存来运行,而内存越是被其他进程占用,进程访问内存内容的速度就越慢,从而降低了内存带宽并增加了内存访问的延迟。因此,给进程分配一个更负的优先级值并不总是能直接提高进程的性能。此外,如果进程当前处于空闲状态,什么也不做,且不需要更多的 CPU 呢?这里可能还有很多其他因素——非统一内存访问NUMA)操作系统/应用程序兼容性、多线程/核心的有效使用、锁机制等。因此,这更多的是一种通用的学术讨论,可能会有各种原因导致的例外,系统状态是其中最常见的原因之一。

现在我们已经处理了理论背景,来看看一个以前很容易展示的例子,因为很多人过去都有类似的经历,当时的 CPU 和内存远远不如今天这么强大。

10 年前,如果我们使用当时的一台普通电脑观看来自 YouTube 的高分辨率 Flash 视频,就会遇到一个问题。CPU 的性能大致够用,但勉强够。所以,为了能通过 Linux 使用网页浏览器(例如 Firefox)观看这些视频,我们不得不对系统进行调优。于是我们打开了网页浏览器,找到了想看的视频,点击了 播放 按钮,视频会播放几秒钟,然后卡顿。接着,它又会播放一段时间,再次卡顿。这是一个令人沮丧的体验。那时候,我们还没有 Flash 的 GPU 加速功能,所以 CPU 是唯一能在这种情况下提供帮助的设备。

但如果我们知道如何设置进程优先级,在大多数情况下,我们可以通过这种方式解决问题,这取决于 CPU 的速度。我们可以进入命令行,找到 Firefox 进程的 PID,并将其 renice 为一个更负的值。突然间,内核会指示其调度器更加关注 Firefox 这个进程,结果就会发生——视频不再卡顿。为什么?因为通过我们使用 renice 命令,内核意识到我们希望赋予这个进程更高的优先级,因此命令 CPU 调度器去执行这一操作。

调优 CPU 性能还有许多其他方面。随着 Linux 内核在处理 CPU 调度时变得越来越程序化,现代 Linux 发行版提供了很多相关选项。因此,各个 Linux 发行版引入了像 tuned 这样的概念,这是一个基于预设或手动创建的配置文件来调节系统性能的系统,另外还有 tuna,这是一款能够进行深度应用特定调优的工具。我们始终需要具备深入调优的能力,以便让系统能够针对特定的使用场景获得最佳性能。

还有更多…

如果我们需要了解更多关于这些概念的信息,可以查看以下链接:

配置 crond

拥有按计划运行任务的能力对于日常系统管理非常重要。我们安排备份、执行清理操作、发送报告、做杀毒检查以及其他业务流程需要的任务。通过调度这些任务,我们实现了一定程度的自动化,摆脱了手动操作,这样我们就可以有更多时间专注于更重要的任务。一般来说,我们使用命令或脚本来执行这些计划任务,而要执行它们,我们使用 cron daemoncrond)。让我们学习如何使用 crond 根据我们的需求调度任务。

准备开始

保持 cli1 虚拟机开机,让我们通过 crond 创建一些计划任务。

如何操作…

让我们从使用 root 创建一个 cron 任务开始。我们将通过以 root 身份输入以下命令来实现:

crontab -e

在 Ubuntu 中,我们将被要求选择想要使用的编辑器。为了保持一致性,假设我们选择了 vi 编辑器(vi.basic)。让我们在我们正在编辑的 vi 文件末尾添加以下内容:

* * * * * ls -al /root > /tmp/root.txt

如果我们按原样保存文件,我们就创建了第一个根 cron 任务——它将在每分钟执行一次。* 符号实际上是这些 crontab 文件中的 频率字段。请看下面的例子:

man 5 crontab

现在稍微向下滚动一下 man 页面,我们将找到以下示例:

图 3.25 – man 5 crontab 页面摘录

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_3.25_B16269.jpg)

图 3.25 – man 5 crontab 页面摘录

第一部分解释了这些字段,因为总共有六个。前五个字段与执行频率有关。我们可以使用空格或 Tab 作为这些字段之间的分隔符。

前五个字段如下:

  • minute 字段:任务应在哪一分钟执行

  • hour 字段:任务应在哪一小时执行

  • day of month 字段:任务应在每月的哪一天执行

  • month 字段:任务应在哪个月执行

  • day of week 字段:指定任务应该在一周中的哪一天执行

所有这些字段都支持各种类型的语法:

  • 75minute 字段中没有意义,因为 minute 字段中的单个数字范围是 059

  • 3-47hour 字段中是无效的,因为小时的范围是 0-23

  • 第一个字段中的 0-10/2cron 会将其解释为从第 0 分钟到第 10 分钟,每隔第二分钟

  • 0,2,4,6,8,10

  • 组合:任何之前提到的语法组合。

所有这些语法给我们提供了许多不同的选项来配置我们希望执行哪些任务以及何时执行,甚至可以精确到分钟级别。我们还需要记住,cron 不允许小于一分钟的频率。如果我们需要做类似的事情,我们需要绕过这个限制(使用 sleep 函数、at 命令等)。

如果我们以 root 用户登录,这也让我们能够管理用户的 cron 任务。例如,假设我们输入以下命令:

crontab -e -u student

我们将要编辑 student 用户的 crontab

此外,如果我们想删除 student 用户的 cron 任务,可以这样做:

crontab -r -u student

如果我们只需要列出 student 用户的 cron 任务,可以使用以下命令:

crontab -l -u student

让我们回到系统 cron 任务,这是第二常见的 cron 任务类型。这些任务在 /etc 目录下的系统文件夹中配置,如 /etc/cron.daily/etc/cron.hourly。目录名中的 cron. 关键字后跟的内容告诉我们该文件夹中所有任务的执行频率。例如,来看一下 cron.daily 文件夹:

图 3.26 – 系统的日常 cron 任务

图 3.26 – 系统的日常 cron 任务

其中一些文件名可能听起来很熟悉。logrotate 用于日志轮转,mlocate 任务更新 updatedb 命令使用的文件/文件夹数据库,等等。在任何给定时刻,这些系统范围的 cron 任务可能在这些目录中有数十个,具体取决于我们在 Linux 服务器上安装了哪些软件包,以及我们自己创建了多少额外的系统任务。让我们以 logrotate 文件为例:

图 3.27 – logrotate cron.daily 任务,它实际上执行一个简单的 shell 脚本

图 3.27 – logrotate cron.daily 任务,它实际上执行一个简单的 shell 脚本

如我们所见,这个 cron 任务实际上执行的是一个 shell 脚本,而且其配置与我们在用户 cron 任务中看到的截然不同,后者的 crontab 文件具有严格的语法要求。而这里,我们拥有更多的自由,能够在这些文件中直接写 shell 脚本代码,这让我们的工作更加轻松。关于 shell 脚本的更多内容将在本书后面详细介绍,从 第九章Shell 脚本入门,一直到本书的结尾。所以,关于这些内容的讨论可以稍后再进行,等到我们介绍完所有必要的概念——变量、循环、函数、数组等。到时一定会非常有趣。

它是如何工作的…

crond 是一种排队类型的服务——它会创建任务队列,然后根据指定的标准执行这些任务。在大型企业中,我们可能会将 crond 的标准视为更大图景的一部分,这通常被称为策略。企业依赖于与 IT 相关的策略来实施标准和服务水平,从这个角度看,IT 策略不过是描述某种需求的对象。比如,需要进行定期的每日备份,需要通过 crond 执行定期的安全检查,它就是实现这些策略的关键工具之一。

crond 负责处理不同类型的计划任务:

  • 与系统相关的计划任务:每日、每小时、每周及其他任务,这些任务的执行是为了确保系统正常工作。

  • crond 用于确保如果我们安装了 anacron 包,它能在稍后的时间执行任务,anacron 处理一些情境,如持续的服务器关机,这会导致周期性任务无法定期执行。

  • 基于用户的 crond 任务:这些是每个用户可以创建的任务,普通系统用户可以根据需要在特定时间执行任务。

首先,我们需要了解基于用户的 cron 任务如何工作,因为这些任务是多用户服务器上最常见的任务。

当我们使用 crontab -e 命令为某个用户设置定时任务时,系统会在某个 crond 目录下创建一个 crontab 文件。这些文件在复杂性上并不特别——虽然其中涉及一些语法,但即使从零开始,文档也非常完善,绝大多数用户不会遇到理解上的问题。在 Ubuntu 中,crontab 文件会被创建在 /var/spool/cron/crontabs 目录下,在这里,每个用户的 cron 任务都会作为一个文本文件保存在系统中。如果该目录中的某个文件名为 root,说明 root 用户有一个定时任务。如果有一个名为 student 的文件,说明名为 student 的用户有一个定时任务。这使得调试过程变得更简单。如果遇到问题,我们还需要注意,某些人可能更喜欢直接编辑这些文件,而不是使用 crontab 命令。归根结底,无论我们使用什么方式来解决 IT 问题,只要能正常工作,就都没有问题。接下来让我们看一下其中一个文件——这里我们将展示 root 用户的 crontab 文件的一段内容:

图 3.28 – Root 的 crontab 文件

图 3.28 – Root 的 crontab 文件

我们可以清楚地看到,root 用户在这里安排了一个每分钟执行的任务。该任务列出了 /root 目录的内容,并将其保存到 /tmp 目录下名为 root.txt 的文件中。这个例子虽然简单,但清晰地展示了 crontab 配置文件的创建方式。

crond 会定期检查这些文件,并在预定时间执行文件中存储的配置。这也是我们需要小心处理这些文件内容的重要原因。我们真的不应该在 crontab 文件中存储明文密码、登录信息或类似的敏感内容。通过使用用户 crontab 文件中的前五个字段,crond 会确定任何给定定时任务的执行频率。它逐行解析这些文件,这意味着我们可以轻松地为每个用户安排多个 cron 任务,而不会遇到任何问题。

如果我们遇到用户安排过多 cron 任务导致服务器性能下降的问题,我们可以随时禁止他们使用 crontab。例如,如果我们想禁止 student 用户创建定时任务,只需编辑 /etc/cron.deny 文件,并逐行添加用户名,如下所示:

student

如果我们这样做,并且名为 student 的用户尝试使用 crontab -e 创建一个定时任务,预期的结果是:

图 3.29 – 使用 cron.deny 禁用用户使用 crontab 的权限

图 3.29 – 使用 cron.deny 禁用用户使用 crontab 的权限

本章就到此为止。下一章将讲解如何使用 shell 配置网络设置,包括网络接口和防火墙。敬请期待!

还有更多内容……

如果我们需要了解更多这些概念,我们可以查看以下链接:

第四章: 使用 Shell 配置和排除网络故障

管理进程是 Linux 系统管理员的重要工作。这有多种原因——可能是某些进程卡住了,我们需要结束它们,或者我们希望将某些进程放到后台运行,甚至定期启动或在稍后时间启动。无论是哪种情况,了解如何管理进程并高效地完成需要的工作,同时考虑到系统中其他进程的运行,都非常重要。

在本章中,我们将学习以下食谱:

  • 使用nmclinetplan

  • 使用firewall-cmdufw

  • 处理开放端口和连接

  • 配置/etc/hosts和 DNS 解析

  • 使用网络诊断工具

技术要求

对于这些食谱,我们将使用两台 Linux 机器。我们可以使用之前食谱中的 client1 虚拟机。同时我们还将使用另一台运行nmclifirewall-cmd的虚拟机。我们称它们为server1和 client2。总的来说,我们需要以下设备:

  • 一台运行 Ubuntu 20.10 的虚拟机

  • 两台运行 CentOS 8 2105 的虚拟机

那么,让我们启动虚拟机,开始吧!

使用 nmcli 和 netplan

在过去的几个版本中,网络配置发生了显著变化——适用于所有 Linux 发行版。无论是讨论 Red Hat 及其衍生版,还是 Debian 及其衍生版,这些变化都发生在所有这些系统中。例如,Red Hat 及其衍生版从网络服务过渡到混合网络和 NetworkManager 服务,最终过渡到完全基于 NetworkManager 的配置。Ubuntu 曾经使用网络服务,直到最近才切换到 netplan。让我们解释这些概念,以便我们能全面了解这些配置方法,并涵盖任何可能遇到的情况。我们还将介绍一个场景,其中有人可能想要关闭 netplan 并回到在 Ubuntu 上使用网络服务。

准备工作

我们只需要一台 Ubuntu 和一台 CentOS 机器来完成这个食谱。假设我们将使用server1和 client1 来掌握nmclinetplan。此外,在 CentOS 上,我们需要部署net-tools包以访问本食谱中使用的某些命令(例如,ifconfig命令)。我们可以使用以下命令来完成:

dnf install net-tools

之后,我们准备好开始了。

如何操作

我们先处理两种最常见的 CentOS 场景——通过nmcli实现网络配置,分别为ens39创建一个名为static的网络连接(例如,静态 IP 地址192.168.2.2/24,网关192.168.2.254,DNS 服务器8.8.8.88.8.4.4),稍后创建一个名为dynamic的网络连接(用于 DHCP 配置)。对于每个场景,我们只需要以 root 身份运行几个命令:

nmcli connection add con-name static ifname ens39 type ethernet ipv4.address 192.168.2.2/24 ipv4.gateway 192.168.2.254 ipv4.dns 8.8.8.8,8.8.4.4
nmcli connection reload
systemctl restart NetworkManager

预期结果应该如下所示:

图 4.1 – 通过 nmcli 添加静态 IP 配置

图 4.1 – 通过 nmcli 添加静态 IP 配置

现在让我们移除该连接并定义一个基于 DHCP 的配置。为此,我们需要在网络中有一个 DHCP 服务器,以便它能够为 client2 分配所需的网络配置信息(IP 地址、子网掩码、网关、DNS 服务器地址等)。我们需要输入以下命令:

nmcli connection delete static
nmcli connection add con-name dynamic ifname ens39 type ethernet
nmcli connection reload
systemctl restart NetworkManager

这是我们的预期结果:

图 4.2 – 通过 nmcli 添加 DHCP 配置

图 4.2 – 通过 nmcli 添加 DHCP 配置

如果我们的网络配置正确,我们应该已经获得了 IP 地址和其他网络信息,并且能够访问互联网。

在 Ubuntu 中,netplan 的这种配置方式更符合当前流行的 基础设施即代码 思维方式,因此它完全依赖于配置文件。因此,我们将再次实现两种最常见的场景——静态 IP 地址和 DHCP,但我们还将涵盖一个包含多个网络接口的场景,以便我们可以看到语法的样子。

首先,让我们从 netplan 静态网络配置开始。假设我们需要为网络接口 ens33 分配 IP 地址 192.168.1.1/24,默认网关为 192.168.1.254,DNS 服务器为 8.8.8.88.8.4.4。我们可以直接修改现有的 YAML 配置文件,即 00-installer-config.yaml

图 4.3 – 通过 netplan 添加静态配置

图 4.3 – 通过 netplan 添加静态配置

这涵盖了我们的静态 IP 地址场景。关于 netplan DHCP 场景,我们需要做的事情是显而易见的,因此该配置文件在特定场景下应如下所示:

图 4.4 – 通过 netplan 添加动态配置

图 4.4 – 通过 netplan 添加动态配置

我们配方的最后部分,如我们所提到的,涉及到多个网络接口的配置。假设我们有一个名为 ens33 的网络接口需要进行 DHCP 配置,另一个名为 ens38 的接口需要分配一个 IP 地址 192.168.1.1/24,并且网关和 DNS 服务器配置与之前相同。配置文件应如下所示:

图 4.5 – 通过 netplan 配置多个网络接口

图 4.5 – 通过 netplan 配置多个网络接口

对于某些最新版本的 Ubuntu,此 yes/no 配置将更改为 true/false,因此如果您遇到错误,只需要做出相应的更改。基本上,它看起来像是前两个文件的合并,去掉了一些重复的行,以免出现不必要的重复。

现在让我们看看这两个概念是如何工作的。它足够简单,但仍然需要一些背景知识,因此让我们深入了解一下。

工作原理

现在我们已经简要介绍了进程和信号的工作原理,让我们继续探索关于进程的知识,学习如何管理后台进程。由于我们已经解释了后台进程的基础知识,这不应该是一个困难的任务。

就 NetworkManager 及其命令行配置接口(nmcli)而言,NetworkManager 通过/etc/sysconfig/network-scripts目录中的配置文件进行配置。让我们通过之前 CentOS 会话中的一个例子来展示——我们创建了一个名为dynamic的接口。在该目录中,有一个名为ifcfg-dynamic的文件,内容如下:

图 4.6 – 常规 NetworkManager 配置文件

图 4.6 – 常规 NetworkManager 配置文件

对于一个简单的配置文件来说,这是一个相当大的配置文件。实际上,如果我们稍微优化一下这个文件,我们可以将其缩短至少三分之二,并且仍然能够正常工作,例如像这样:

图 4.7 – 简化的配置文件

图 4.7 – 简化的配置文件

这些配置选项并不难理解,还有一些其他配置选项是静态 IP 配置所必需的(IPADDRPREFIXNETMASKGATEWAY —— 这些选项都非常直观)。但事实仍然是——至少部分原因——NetworkManager 依然使用这种冗长的语法,这是历史遗留问题,我们已经使用/etc/sysconfig/network-scripts目录及其中的文件来配置网络接口多年了。

与 netplan 相比,我们可以清楚地看到,netplan 更注重声明式语法,所有结构化的代码和缩进都是必需的,而这正是 YAML 所擅长的。刚开始使用时,它会让你感到有些沮丧,至少在你学会如何使用 vim 编辑器编辑 YAML 文件之前,因为那时它会变得容易得多。请查看更多内容部分中的链接,了解如何设置 vim 来帮助你编写 YAML 语法。

这两个服务——当系统启动时——会读取上述配置文件,并根据其中的设置配置网络接口。这个过程相当直接,只要我们理解语法。但我们仍然建议使用nmcli进行 NetworkManager 配置,因为它的语法很容易掌握。

下一步是使用firewalldufw进行防火墙设置。

更多内容

如果你需要更多关于 CentOS 和 Ubuntu 网络的资料,确保你查看以下内容:

使用 firewall-cmd 和 ufw

在 Linux 中,使用内置防火墙已经成为事实上的标准,超过二十年。自从ipfwadm(内核 v2.0)发明以来,Linux 内核开发者不断增加功能,防火墙就是其中之一。ipfwadm之后是ipchains(内核 v2.2),然后是iptables(内核 v2.4),如今则是firewalld(CentOS)和ufw(Ubuntu)。让我们了解这两种概念,以便无论我们使用什么 Linux 发行版,都能在需要时使用它们。

准备就绪

作为这个教程的一部分,我们将通过几十种不同场景的列表,涵盖firewalldufw。换句话说,我们将介绍必要的命令,以便对一些最常用的场景进行配置更改。首先,让我们为 CentOS(在我们的 client2 机器上)和 Ubuntu(client1 机器上)安装必要的包。所以,对于 CentOS,我们需要输入以下命令:

yum -y install firewalld

此外,对于 Ubuntu,使用以下命令:

apt-get -y install ufw

在服务方面,对于 CentOS,我们有这些——以防我们之前使用过iptables,因为 CentOS 8 支持iptables防火墙:

systemctl stop iptables
systemctl disable iptables
systemctl mask iptables
systemctl enable firewalld
systemctl start firewalld

对于 Ubuntu,思路是一样的:

systemctl stop iptables
systemctl disable iptables
systemctl mask iptables
systemctl enable ufw
systemctl start ufw
ufw enable

现在服务已经配置完成,让我们开始吧!

如何操作

对于firewalld,我们将使用其默认命令,即firewall-cmd。对于ufw,命令相同——ufw。首先,让我们处理一些基本命令。首先列出所有规则:

firewall-cmd --list-all

根据我们之前添加的规则数量,应该会得到类似这样的输出:

图 4.8 – firewall-cmd --list-all 输出

图 4.8 – firewall-cmd --list-all 输出

现在让我们添加和删除一些规则。这是我们要做的:

  • 添加一条规则,允许192.168.2.254/24访问我们client2机器上的所有内容。

  • 添加一条规则,允许网络子网192.168.1.0/24访问我们client2机器上的SSH服务。

  • 阻止192.168.3.0/24网络访问 HTTP/HTTPS 服务。

  • 允许同一子网访问 DNS 服务。

  • 将端口900转发到端口9090

  • 配置masquerade,使client1可以作为路由器/网关使用。

作为最后一步,我们将一条一条地删除所有这些规则。

既然我们已经清楚了要做的事情,接下来让我们输入所有必要的命令来实现这个目标。首先,我们从使用默认配置和默认区域开始,这意味着我们需要检查当前是哪个区域。我们可以在之前的截图中看到公共区域是激活的,所以——暂时——我们将所有规则添加到该区域,稍后我们会解释区域和富规则:

firewall-cmd --permanent --zone=public --add-source=192.168.2.254/32
firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port protocol="tcp" port="22" accept'
firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="192.168.3.0/24" port protocol="tcp" port="80" reject' 
firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="192.168.3.0/24" port protocol="tcp" port="443" reject'
firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="192.168.3.0/24" port protocol="udp" port="53" accept' 
firewall-cmd --permanent --zone=public --add-forward-port=port=900:proto=tcp:toport=9090
firewall-cmd --permanent --zone=public --add-masquerade
echo "1" > /proc/sys/net/ipv4/ip_forward
echo "net.ipv4.ip_forward = 1" > /etc/sysctl.d/50-forward.conf
firewall-cmd --reload

Echo 命令用于现在启用 IP 转发(第一个 echo),并通过使用一个sysctl配置文件使其在系统启动时永久启用(第二个 echo)。最后一条命令是将这些设置应用到当前运行的firewalld状态。当我们现在输入firewall-cmd --list-all命令时,应该会得到如下输出:

图 4.9 – 我们在 firewalld 中配置的最终结果

图 4.9 – 我们在 firewalld 中配置的最终结果

学会如何移除这些规则也非常重要。所以,接下来让我们一条一条地反向删除这些规则:

firewall-cmd --permanent --zone=public --remove-masquerade
firewall-cmd --permanent --zone=public --remove-forward-port=port=900:proto=tcp:toport=9090
firewall-cmd --permanent --zone=public --remove-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port protocol="tcp" port="22" accept'
firewall-cmd --permanent --zone=public --remove-rich-rule='rule family="ipv4" source address="192.168.3.0/24" port protocol="udp" port="53" accept' 
firewall-cmd --permanent --zone=public --remove-rich-rule='rule family="ipv4" source address="192.168.3.0/24" port protocol="tcp" port="443" reject'
firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="192.168.3.0/24" port protocol="tcp" port="80" reject'
firewall-cmd --permanent --zone=public --remove-source=192.168.2.254/24
firewall-cmd --reload

这应该移除所有规则并将起始状态应用为当前状态。让我们检查一下:

图 4.10 – 移除规则后的 firewalld 规则集

图 4.10 – 移除规则后的 firewalld 规则集

一切已恢复到原始状态,所以我们可以认为这是成功的。现在让我们使用ufw将相同的规则集应用到名为 client1 的 Ubuntu 虚拟机上。首先,我们通过使用ufw status verbose命令来检查状态。我们应该得到如下结果:

图 4.11 – ufw 配置的起始点

图 4.11 – ufw 配置的起始点

现在让我们添加与在firewalld中添加的规则相同的规则,看看如何通过ufw来实现,并且检查语法差异:

ufw allow from 192.168.2.254/32
ufw allow from 192.168.1.0/24 proto tcp to any port 22
ufw deny from 192.168.3.0/24 proto tcp to any port 80
ufw deny from 192.168.3.0/24 proto tcp to any port 443
ufw allow from 192.168.3.0/24 proto udp to any port 53

对于端口转发,我们需要编辑/etc/ufw/before.rules文件,并在*filter部分之前添加以下配置选项:

*nat
:PREROUTING ACCEPT [0:0]
-A PREROUTING -p tcp --dport 900 -j REDIRECT --to-port 9090
COMMIT

现在我们可以通过使用ufwiptables命令来检查我们的工作状态:

图 4.12 – 添加所有规则后的 ufw 状态详细信息

图 4.12 – 添加所有规则后的 ufw 状态详细信息

我们需要向ufw配置中添加两个配置选项,才能使伪装工作。首先,我们需要在/etc/default/ufw文件中更改转发的默认策略。默认设置为DROP,我们只需将其更改为ACCEPT。该设置位于文件的开头,所以最终结果应该是这样的:

DEFAULT_FORWARD_POLICY="ACCEPT"

这样看起来比较合适:

DEFAULT_FORWARD_POLICY="DROP"

下一个配置选项实际上就在我们之前编辑过的同一个文件中,即 /etc/ufw/before.rules。我们需要在 *nat 部分添加一个名为 POSTROUTING 的子部分,添加到之前使用过的地方。所以,类似于之前的示例,我们需要再次在 *filter 部分之前添加以下配置选项:

*nat
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -o ens33 -j MASQUERADE
COMMIT

ufw 配置的最后一部分是确保内核知道需要启用 ip 转发。为了实现这一点,我们需要编辑一个名为 /etc/ufw/sysctl.conf 的文件,并取消注释以下配置选项,方法是去掉前面的注释标记(#):

net/ipv4/ip_forward=1

这会在 /proc 文件系统中更改一个值,文件名为 /proc/sys/net/ipv4/ip_forward。如果我们想确保即使不重启机器它也能生效,我们还需要执行以下命令:

echo "1" > /proc/sys/net/ipv4/ip_forward

这将启用内核级别的 IP 转发,并使我们能够在 ufw 中使用地址转换。现在让我们检查最终结果:

图 4.13 – 配置地址转换后的 ufw 规则集

图 4.13 – 配置地址转换后的 ufw 规则集

如果需要删除这些配置选项,则需要分两部分进行操作:

  • 通过编辑器直接添加到文件中的配置选项需要通过编辑器删除。

  • 使用 ufw 命令添加的配置选项可以通过 ufw 的规则索引轻松撤销。

让我们输入命令 ufw status numbered。这是预期的结果:

图 4.14 – 按编号索引的 ufw 规则集

图 4.14 – 按编号索引的 ufw 规则集

我们可以看到,每个我们添加的规则都有一个编号。规则 8 和 9 是为端口 9009090 自动添加的规则,适用于 IPv4 和 IPv6 栈。我们可以通过使用附加的编号轻松删除所有这些规则。问题是,ufw 并没有删除多个规则的机制,所以我们需要逐个删除它们,像这样:

ufw delete 9 
<confirm by pressing y>
ufw delete 8 
<confirm by pressing y>
....
ufw delete 1
<confirm by pressing y>

是的,我们本可以用 for 循环简化这个过程,像这样:

for i in {9..1};do yes|ufw delete $i;done

但是,本书后面会介绍 shell 脚本和使用循环,所以...我们先把这当作一个 提前示例

现在让我们解释一下这一切是如何工作的——firewalld 区域、富规则、ufw 语法——以便我们理解实现这一功能的后台服务和能力。

注意

Firewalld 也可以在 Ubuntu 上使用。只需要安装、启用、启动并配置它,同时对 ufw 进行相反的操作。如果你更倾向于使用 firewalld,我们建议你试试这个方法,它很简单,不会浪费你太多时间。

工作原理

ufwfirewalld 之间有一个根本性的区别,ufw 基本上只是 iptables 的前端,而 firewalld 则更加动态,具有与区域(zones)配合使用的能力,我们可以为这些区域分配不同的信任级别。它们的语法也不同,ufw 更加基于命名空间,而 firewalld 在规则编写上则需要更多的努力。但与此同时,firewalld 具有 D-Bus 接口,这使得在应用程序、服务和用户进行配置更改时,配置变得更加容易,此外,我们无需在每次更改后重新启动防火墙以使其生效。它还与 NetworkManager 和 nmclilibvirt、Docker、Podman 等工具良好配合,甚至与 fail2ban 兼容(尽管 fail2ban 同样能与 iptables 一起工作)。有时候这取决于个人偏好;有时候是习惯。一般来说,如果你是 Ubuntu/Debian 用户,你可能会更倾向于使用 ufw。同样,如果你是 CentOS/Red Hat/Fedora/*SuSe 用户,你肯定更倾向于使用 firewalld

firewalld 使用区域的概念,我们可以将网络接口或 IP 地址分配给这些区域,这无疑非常有用,因为它给了我们更多的配置自由度。如果我们输入 firewall-cmd --get-zones 命令,我们会看到当前时刻可用的区域列表:

图 4.15 – 默认的 firewalld 区域

图 4.15 – 默认的 firewalld 区域

默认情况下,firewalld 的配置是 拒绝所有,添加例外 的方式,这也是为了方便使用。我们可以根据端口、IP 地址、子网和服务来允许或拒绝连接,这使得我们能够完成主机防火墙功能所需的一切。它还引入了一个叫做丰富规则(rich rules)的概念(如本食谱中的示例所示),这使得我们能够创建复杂的规则,并具有细致的粒度控制。这些规则可以基于源地址、目的地址、端口、协议、服务、端口转发和每个子网的伪装。它们可以用于速率限制,允许我们设置每分钟接受的 SSH 连接数为 5 或 10,从而使得对我们的 Linux 服务器进行 SSH 暴力破解攻击变得更加困难。总的来说,这是一个经过深思熟虑、功能丰富的防火墙,并且是免费的。我们只需要进行配置。

ufw 作为 iptables 的前端——或者像人们常说的,iptables 的命令行接口——虽然功能相对较少,但绝对更容易配置,至少对于最常见的场景来说是如此。它的命令行接口更具可读性(更简单),也更容易学习。既然它只是 iptables 的前端,本质上是一个用户空间的工具,用来管理由 netfilter 模块堆栈提供的 Linux 内核过滤规则。

现在我们已经讨论了 Linux 防火墙的一些关键概念,是时候进入下一个操作步骤,学习如何检查开放的端口和连接。让我们看看具体内容。

还有更多

如果你需要了解更多关于firewalldufw的内容,建议查看以下链接:

使用开放端口和连接

检查本地和/或远程机器的开放端口通常是安全性和配置审计过程的一部分。这是我们用来检查是否能够连接到某些远程端口,验证服务是否正常工作,防火墙是否配置正确,或路由是否正常工作的操作——就是常规的日常任务。当然,它也可能是一些黑客攻击过程的一部分,这些过程通常开始于使用nmap和类似工具检查开放端口和操作系统指纹。但让我们看看如何使用netstatlsofssnmap等工具来为我们的网络和安全带来益处。

准备工作

保持 client1 虚拟机开机,继续使用我们的 Shell。一般来说,如果我们在 Ubuntu 上进行操作,我们需要使用apt-get安装一些软件包,如traceroutenmap

apt-get -y install traceroute nmap

然而,如果我们使用的是 CentOS,我们需要使用yumdnf

yum -y install traceroute nmap

之后,我们就可以开始我们的操作步骤了。

如何操作

首先,让我们了解一下检查本地 Linux 机器上哪些端口和套接字已打开的常用方法,从netstat命令开始。是的,使用netstatnetstat -rn命令)检查路由表是常见的操作,但通过以不同的方式使用它,我们还可以了解更多有关本地 Linux 机器的有趣细节。首先,使用netstat -a命令检查所有打开的连接和端口:

图 4.16 – netstat -a 输出的一部分 – 由于结果非常长,我们为了格式化的原因略作简化

图 4.16 – netstat -a 输出的一部分 – 由于结果非常长,我们为了格式化的原因略作简化

这里有很多细节。让我们看看能否将其格式化得更好一些。首先,使用netstat -atp命令显示所有打开的 TCP 端口:

图 4.17 – netstat 显示 TCP 端口列表

图 4.17 – netstat 显示 TCP 端口列表

然后,使用netstat -aup命令显示相同内容,但针对打开的 UDP 端口:

图 4.18 – 使用 netstat 查看 UDP 端口列表

图 4.18 – 使用 netstat 查看 UDP 端口列表

我们还可以按监听端口(一个应用程序或进程正在监听的端口)来显示上述信息的子集。这就是 netstat -l 命令的作用:

图 4.19 – 使用 netstat 查看监听端口

图 4.19 – 使用 netstat 查看监听端口

我们可以使用 sslsof 做类似的操作。让我们首先使用 ss

图 4.20 – 使用 ss 查看活动连接

图 4.20 – 使用 ss 查看活动连接

接下来我们要讨论的是 lsof,一个可以用来确定哪些文件正被相应进程打开的命令:

图 4.21 – 与 ss 相同的思路,但提供了更多关于实际命令/服务使用端口的详细信息

图 4.21 – 与 ss 相同的思路,但提供了更多关于实际命令/服务使用端口的详细信息

我们使用的选项如下:

  • -n 用来使用端口号,而不是端口名称

  • -P 用于使用数值地址,而不进行 DNS 解析

  • -iTCP -sTCP:LISTEN 用来仅显示处于 LISTEN 状态的打开端口的文件

然后,如果我们想将其缩小到特定的 TCP 端口——例如端口 22——我们可以使用类似 lsof -nP -iTCP:22 -sTCP:LISTEN 的命令:

图 4.22 – 将 lsof 输出缩小到仅显示 TCP 端口 22

图 4.22 – 将 lsof 输出缩小到仅显示 TCP 端口 22

如果我们需要检查由端口范围指定的开放端口,lsof 可以通过使用 lsof -i 选项来实现。例如,我们可以对 221000 端口范围使用这个选项:

图 4.23 – 按端口范围使用 lsof

图 4.23 – 按端口范围使用 lsof

现在我们已经在本地机器上使用了一些命令,接下来让我们关注远程机器,并讨论如何在它们上查找开放端口以及其他可能需要的信息。为此,我们将使用 nmap 命令。首先让我们使用客户端 1(IP 地址 192.168.1.1)扫描 server1(IP 地址 192.168.1.254)。server1 只是一个普通的 CentOS 8 安装,如本章最后一节所解释的。

首先,让我们进行一次常规扫描,使用 nmap 192.168.1.254 命令:

图 4.24 – 对单个 IP 地址使用 nmap

图 4.24 – 对单个 IP 地址使用 nmap

如果我们希望输出更多的信息,可以在 IP 地址前后加上 -v 选项。不过,我们仍然可以看到远程 IP 地址有几个开放的 TCP 端口。接下来我们可以尝试通过使用 -A 选项启动 nmap(操作系统信息扫描)来获取更多信息:

图 4.25 – 上一次 nmap 会话的更详细版本

图 4.25 – 上一次 nmap 会话的更详细版本

我们可以看到更多关于输出的细节。如果我们仅想进行操作系统指纹识别,我们可以使用 -O 选项:

图 4.26 – nmap 操作系统指纹识别

图 4.26 – nmap 操作系统指纹识别

我们还可以扫描其他一些内容,例如以下内容:

  • 特定 TCP 端口 – nmap -p T:9090,22 192.168.1.254

  • 特定 UDP 端口 – nmap -sU 53 192.168.1.254

  • 扫描端口范围 – nmap -p 22-2000 192.168.1.254

  • 查找远程主机服务版本 – nmap -sV 192.168.1.254

  • 扫描子网 – nmap 192.168.1.*

  • 扫描多个主机 – nmap 192.168.1.252,253,254

  • 扫描完整的 IP 范围 – nmap 192.168.1.1-254

现在让我们讨论这四个工具如何工作,并总结这个操作方法。

工作原理

netstatsslsof 是类似的工具,但它们也各有不同。人们通常使用 netstat 来查看其路由表。但需要指出的是,默认情况下,netstat 是一个可以列出网络层上已打开的 TCP 套接字/UDP 连接的工具。另一方面,lsof 列出了已打开的文件(内核级功能),但它还能够确定哪些进程正在使用这些已打开的文件。请记住,在 Unix 操作系统中,几乎一切都是文件,其中也包括网络套接字等对象。因此,lsof 经常用于处理 Linux 系统的安全相关事项,因为与 netstat 相比,它显然能提供更多的技术细节。

ss,作为 netstat 的替代,可以用于处理网络信息和统计数据,这使得它与 netstat 有些相似。它可以用于获取有关网络连接、套接字、统计数据、TCP 状态过滤、与特定 IP 地址的连接等详细信息。而且,不容忽视的是,ss 使用起来要简单得多,比较 netstatss 的 man 页,你也能看到它们之间的差异。

nmap 与所有这些命令完全不同。它是一个功能更加广泛的工具——它可以扫描本地和远程主机、域名、IP 范围和端口。它是一个常规的网络扫描器,既有优点也有缺点,人们既喜欢使用它,也不喜欢成为它的目标。它通过与远程 IP 地址和端口建立连接,向其发送信息并收集输出信息来获取数据。因此,它是进行安全扫描和审计的完美工具,因为它能够发现开放的端口并报告这些开放端口的事实。它也被广泛用于寻找某些安全问题。

还有更多

如果你需要了解更多关于 netstatlsofssnmap 的信息,请确保查看以下链接:

配置/etc/hosts和 DNS 解析

名称解析是任何操作系统,特别是其网络堆栈中的一个重要部分。一般来说,操作系统有多种不同的方式来执行 DNS 查询——通常,这涉及到某种类型的hosts文件、缓存,以及——当然——网络接口配置。让我们看看/etc/hosts的配置能力,了解它如何融入名称解析的整体方案。

准备就绪

保持 CLI1 虚拟机开机,我们来讨论如何处理名称解析的问题,使用/etc/hosts(一个可以填充主机名和 IP 地址以进行本地解析的文件)和/etc/resolv.conf(一个决定使用哪个 DNS 服务器进行网络解析、以及 Linux 服务器属于哪个域的文件),它们是这个过程的核心部分。当编辑/etc/hosts/etc/resolv.conf时,我们必须以 root 身份登录或使用sudo,因为这是一个系统范围的操作,只有管理员用户才能执行。名称解析过程的工作方式在多年前发生了变化,当时systemd取代了initupstart,并引入了名为systemd-resolved的服务。除此之外,Ubuntu 和 CentOS 的配置方式也不同。那么,让我们深入探讨这些内容,解释一下发生了什么。

如何操作

让我们先处理 Ubuntu 的情况,然后再切换到 CentOS。这是我们 Ubuntu CLI1 机器上的默认/etc/resolv.conf文件:

图 4.27 – 默认的文件

图 4.27 – 默认的/etc/resolv.conf文件

如我们所见,这个文件大部分被注释掉了(配置文件中的#符号表示 Unix Shell 风格的注释,因此这些行在配置中被省略)。我们只有两行配置,这是运行 systemd-resolved 服务(一个本地服务,提供 DNS 解析、DNS over TLS、DNSSEC、mDNS 等功能)以及默认使用 netplan 服务时的副产品:

nameserver 127.0.0.53
options edns0 trust-ad

有两种方法可以配置resolv.conf

  • 我们说过,我们希望坚持使用 systemd-resolved,并以这种方式配置我们的系统(而127.0.0.53实际上是 systemd-resolved 绑定的回环 IP 地址)。

  • 我们说我们不想使用 systemd-resolved,想回到旧方式来配置系统,这意味着安装一个名为 resolvconf 的包。这样我们就可以像过去一样配置 /etc/resolv.conf/etc/hosts,而不依赖 systemd-resolved 动态更改 /etc/resolv.conf(大多数情况下我们并不希望这样做)。

我们先从第一种方法开始,然后再转到第二种方法,因为我们很多 Linux 管理员更倾向于使用传统的方式,觉得按 Unix 时代开始时的配置方式进行配置更加容易。

如果我们使用 systemd-resolved,我们需要提到几个文件。我们首先需要提到的文件是 /run/systemd/resolve/stub-resolv.conf —— 这是一个实际与 /etc/resolv.conf 链接的文件,当使用 systemd-resolved 时。该文件用于保持与旧版 Linux 程序的兼容性,这些程序仅使用旧方法(/etc/resolv.conf/etc/hosts)来访问名称解析信息。如果我们想永久设置 DNS 服务器,必须通过 systemd 来实现。那么,我们来看一下第二个文件,它位于 /etc/systemd 目录下,名为 resolved.conf。在该文件的开头,有一个完全被注释掉的 [Resolve] 部分。让我们将其修改为如下:

[Resolve]
DNS = 8.8.8.8 8.8.4.4
FallBackDNS = 1.1.1.1
Domains =domain.local

第一行和第二行设置了主要的和备用的 DNS 地址,而第三行设置了我们查询的默认域名。

在做出这些更改后,我们需要重启 systemd-resolved 服务,可以通过以下命令完成:

systemctl restart systemd-resolved

我们可以通过使用 systemd-resolve --status 来检查我们的更改是否已应用,根据我们的更改,应该会显示类似以下内容的输出:

图 4.28 – 检查 systemd-resolved 状态

图 4.29 – 检查 DNS 缓存

图 4.28 – 检查 systemd-resolved 状态

现在我们检查 DNS 缓存的工作原理——例如,我们输入以下命令:

nslookup index.hr
nslookup planetf1.com

我们这样做是为了检查 DNS 缓存,因为 DNS 缓存至少需要先填充一些数据。如果我们想检查 systemd-resolved 缓存的状态,可以使用两个命令:

killall -USR1 systemd-resolved
journalctl -u systemd-resolved > cache.txt

第一个命令不会终止 systemd-resolved,而是告诉它将可用条目写入 DNS 缓存。第二个命令将条目导出到名为 cache.txt 的文件中(我们可以任意命名)。当我们检查该文件中包含 CACHE 字符串的内容时,我们会看到类似于以下的条目:

图 4.29 – 检查 DNS 缓存

图 4.29 – 检查 DNS 缓存

图 4.29 – 检查 DNS 缓存

这是正确的——在我们的测试系统中,这两条是我们通过 nslookup 查询的条目。如果我们想刷新 DNS 缓存,可以使用以下命令:

resolvectl flush-caches

如果你注意到文件中有 DNS 违规错误,说明在系统安装或升级过程中出现了问题——没有正确设置 resolv.conf 的符号链接。由于这个问题,符号链接被创建到了错误的文件(stub-resolv.conf),而不是实际的文件(/run/systemd/resolve/resolv.conf)。我们可以通过以下命令来缓解这个问题:

mv /etc/resolv.conf /etc/resolv.conf.old
ln -s /run/systemd/resolve/resolv.conf /etc/resolv.conf
systemctl restart systemd-resolved

现在,让我们尝试第二种方法,这种方法要简单得多。所以,如果我们想摆脱所有这些 systemd-resolved 配置,只通过传统的 resolv.conf 管理流程,而不涉及这些额外的麻烦,我们完全可以轻松做到。首先,让我们安装必要的包:

apt-get -y install resolvconf
systemctl stop systemd-resolved
systemctl disable systemd-resolved
systemctl mask systemd-resolved

接下来,我们进行一些配置。我们打开 /etc/resolv.conf 文件,并将其配置如下(注释部分不重要,从 nameserver 部分开始):

图 4.30 – resolv.conf 配置

图 4.30 – resolv.conf 配置

让我们检查一下这个配置是否有效:

图 4.31 – 检查 DNS 解析是否有效

图 4.31 – 检查 DNS 解析是否有效

完全没有问题,对吧?当然,我们在这里使用了 8.8.8.88.8.4.41.1.1.1 作为 DNS 服务器的示例——这些需要根据我们 Linux 服务器实际运行的环境进行配置。

与 DNS 缓存打交道需要额外的努力。首先,我们需要部署两个额外的包——nscd(负责缓存的服务)和 binutils(这个包包含了一个叫做 strings 的命令,我们将用它来检查二进制文件中的字符串内容):

apt-get -y install nscd binutils
strings /var/cache/nscd/hosts

第二个命令的输出应该类似于以下内容:

图 4.32 – 检查 nscd 缓存

图 4.32 – 检查 nscd 缓存

如果我们需要清除 nscd hosts 缓存,可以使用以下命令:

nscd -i hosts

或者

systemctl restart nscd

第一个命令只是清空 hosts 表,而第二个命令则重启 nscd 服务,并在此过程中清空 hosts 表。

这就引出了 hosts 表,而且——幸运的是——在所有 Linux 发行版上都是一样的。如果我们只需要添加一些解析功能,而不打算通过 BINDdnsmasq 或类似工具构建一个 DNS 服务器,使用 hosts 表似乎是一个相对简单的选择。假设我们需要为以下两个主机进行临时解析:

server1.domain.local

server2.domain.local

假设这两个服务器的 IP 地址分别是 192.168.0.101192.168.0.102。我们通过编辑 /etc/hosts 文件并将这些条目添加到文件底部来进行配置:

192.168.0.101 server1.domain.local

192.168.0.102 server2.domain.local

所以,我们的 /etc/hosts 文件应该像这样:

图 4.33 – 添加了条目的 /etc/hosts 文件

图 4.33 – /etc/hosts 文件和新增内容

如果我们现在使用类似 ping 的命令来检查这些主机是否可用,我们将得到如下结果:

图 4.34 – ping 无法工作

图 4.34 – ping 无法工作

在这个输出中可见的 ^C 字符是因为我们使用了 Ctrl + C 来停止 ping 进程,因为这些主机实际上并不存在于我们的网络中。但这不是重点——重点是测试名称解析是否有效。换句话说,server1server2.domain.local 的解析是否有效?答案是有效的——我们可以清楚地看到 ping 命令正在尝试 ping 它们的 IP 地址。

我们需要简要讨论 CentOS 是如何做这些事情的,因为它与 Ubuntu 的做法有些不同。默认情况下,CentOS 的最新几代使用 NetworkManager 作为默认服务来配置网络。因此,/etc/resolv.conf 默认由 NetworkManager 配置,这一点非常重要,尤其是在最常见的使用场景中——当我们的 CentOS 机器从 DHCP 服务器获取 IP 地址时。如果我们需要配置自定义 DNS 服务器,而又不想使用从 DHCP 服务器获得的 DNS 服务器,应该怎么办呢?

在 CentOS 中,确保一切配置正确有两种基本方式:

  • 通过接口文件配置一切

  • 通过 nmcli 命令配置一切

使用配置文件在这里是很麻烦的,所以我们直接做第二种方法——通过 nmcli 命令配置我们的 DNS 记录。假设我们想为 CentOS 服务器分配 8.8.8.88.8.4.41.1.1.1 作为 DNS 服务器。首先,让我们检查一下网络接口名称:

nmcli con show

我们的系统告诉我们正在使用 ens33 网络接口。让我们通过输入以下命令来修改它的设置:

nmcli con mod ens33 ipv4.ignore-auto-dns yes
nmcli con mod ens33 ipv4.dns "8.8.8.8 8.8.4.4 1.1.1.1"
systemctl restart NetworkManager

这个配置的关键在于第一行——我们基本上是在告诉 NetworkManager 停止自动使用从 DHCP 服务器获取的 DNS 服务器。如果我们不想这样做,可以省略这一行。

如果我们检查 /etc/resolv.conf 文件的内容,它现在应该是这样的:

图 4.35 – /etc/resolv.conf 配置正确

图 4.35 – /etc/resolv.conf 配置正确

配置工作已经完成——无论是在 Ubuntu 还是 CentOS 上。接下来我们来关注一下这一切是如何在 幕后 工作的。

工作原理

我们需要深入理解并解释两个概念。我们需要了解 systemd-resolved 是如何工作的,当然,反过来也需要了解——当我们将 systemd-resolved 从管理方程中去除时,所有的工作是如何进行的。考虑到在 systemd 和 systemd-resolved 之前就有 Linux 和名称解析,首先我们来解释一下 旧方法(即在 systemd-resolved 之前的做法)是如何工作的。

核心概念包括 /etc/passwd/etc/shadow/etc/group、网络配置,以及当然还有服务,比如名称解析(/etc/hosts 等)。我们的重点将仅放在名称解析上,这也是我们需要讨论配置文件 /etc/nsswitch.conf 的原因。具体来说,我们将忽略该文件中的所有配置选项,专注于一行配置,这行通常像这样:

hosts:      files dns

这一配置行告诉我们的名称解析系统 如何 执行其工作。files 选项意味着 检查文件 /etc/hosts,而 dns 选项意味着就如其字面意思——使用其他网络名称解析方法。但这行的关键在于 顺序,它清晰地表示 文件优先,dns 次之。这也是为什么在默认情况下,Linux 会首先检查 /etc/hosts 文件的内容,然后才开始发出网络名称解析请求(例如,nslookup),以获取我们要连接的服务器的 IP 地址。我们还可以将这些条目存储在数据库中,并强制 NSS 访问它以读取必要的数据。例如,20 年前,当 Active Directory 和其他基于 LDAP 的目录尚未普及时,我们通常使用 NIS/NIS+ 来存储用户和类似的数据。我们还可以在 NIS/NIS+ 数据库中存储主机数据(hosts.bynamehosts.byaddr)。这些映射基本上是存储在外部服务中的正向和反向 DNS 表。正因如此,我们可以在 nsswitch.conf 中使用配置选项 db,尽管现在几乎没有人使用这个选项了。

systemd 接管了名称解析(systemd-resolved)后,情况发生了变化,正如我们在上一篇教程中所描述的那样。systemd-resolved 的核心目的是能够更好地与 systemd 集成,并为一些在没有它的情况下实际复杂的使用场景提供支持。例如,VPN 连接,特别是企业级的 VPN,在使用旧式配置时总是会出现问题。systemd-resolved 试图通过引入分割 DNS 功能来解决这堆问题(以及其他问题),这一功能是通过使用 DNS 路由域来确定我们实际发出的 DNS 请求。请不要把这与基于 IP 的子网路由、VLAN 路由或类似的概念混淆——那些是完全不同的概念,基于完全不同的思路。我们这里特别讨论的是 DNS 路由域,它只是一个术语,意思是 让我们确定应该联系哪个 DNS 服务器,以获取关于你的 DNS 查询的正确信息。这与 IP 方面无关,IP 部分是通过使用标准的路由方法来处理的。

拥有分割 DNS 并不是什么新鲜事——我们中的很多人已经使用了十多年。简而言之,分割 DNS 意味着将一些 DNS 服务器分配给内部连接,而将其他 DNS 服务器分配给外部连接。从企业的角度来看,如果我们通过 VPN 连接到工作场所,我们的一部分 DNS 查询是针对内部基础设施的,而另一部分则应该指向互联网上托管的外部 DNS 服务器。在 Linux 中实现这一场景也不是什么新事物——我们早在十多年前就可以轻松使用 BIND 来实现。但一种更紧密集成、尽可能自动化的方式,特别是在客户端这一侧——这正是 systemd-resolved 所做的——其实是全新的。

让我们假设一下,我们有一台 Linux VPN 服务器,我们通过一台 Linux 机器作为 VPN 客户端来连接它。假设这两个系统都在不同子网中有多个网络接口(VPN 客户端有几张物理网卡和一个无线网络适配器)。当我们从 VPN 客户端连接到 VPN 服务器时,VPN 客户端将如何决定将 DNS 查询发送到哪里?是的,它会使用 resolv.conf,但仍然需要正确配置 resolv.confsystemd-resolved,这样名称解析请求才能发送到正确的 DNS 服务器。如果我们有多个子网和多个域(例如,大型企业),事情会很快变得混乱。这个问题通过 NetworkManager/netplan 与 systemd-resolved 的互动来解决。通过这种互动,我们可以将不同的 DNS 服务器分配给不同的网络接口,并为多个不同的域分配不同的 DNS 服务器。这是一种非常聪明的方式来处理潜在的 VPN 客户端问题。

还有更多内容

如果我们需要了解更多关于网络名称解析的信息,可以查看以下链接:

使用网络诊断工具

诊断网络连接问题是资深系统工程师的日常工作。这并不一定是因为我们自己的网络出现问题,也可能是其他因素。例如,有时我们的本地网络工作正常,但互联网连接却无法使用。更糟糕的是,客户反映有些客户能够访问互联网,而有些却无法访问。我们应该如何应对这些情况,使用哪些工具呢?这正是我们将在本节中讨论的内容。所以,准备好讨论pingroutenetstattracepath等命令吧——它们就是为了解决这些问题而存在的!

准备工作

让我们安装一台名为server1的 CentOS 虚拟机,并使用我们现有的客户端(分别为一台名为 client1 的 Ubuntu 虚拟机和一台名为 client2 的 CentOS 虚拟机)来操作本节内容。我们将使用 client1 模拟一个情境,即本地网络中的服务器希望通过将server1设置为默认网关来访问内部资源和/或互联网。我们将使用 client2 模拟另一种情境,即本地客户端或无线客户端希望通过将server1设置为默认网关来访问内部资源和/或互联网。为了实现这一点,我们将临时为 client2 添加另一个网络接口,这样我们就可以在两个不同的子网中拥有两个网络接口,模拟场景中的问题。server1虚拟机将是一个标准的 CentOS 安装,但带有四个网络接口。在我们的场景中,server1ens33网络接口将作为外部网络接口,而ens37ens38ens39网络接口将作为内部网络接口。

如何操作

让我们创建一个场景,完整地演示整个过程。例如,我们公司的一些同事报告说,他们在访问内部资源(公司网络)和外部资源(互联网)时遇到问题。我们讨论的这家公司有多个网络子网:

  • 192.168.1.0/24 – 这个子网用于所有服务器机器;我们在通过nmcli配置时将其称为network1

  • 192.168.2.0/24 – 这个子网用于所有客户端机器;我们在通过nmcli配置时将其称为network2

  • 192.168.3.0/24 – 这个子网用于公司无线网络;我们在通过nmcli配置时将其称为network3

我们机器的第四个网络接口将作为我们的互联网连接。正如我们在本章的第二节中提到的(使用 firewalld 和 ufw),让我们配置这台虚拟机,使其允许所有三个子网连接到互联网并正常工作。

第一步显然是允许这三个子网访问互联网。我们将采用最简单的方式,通过使用 firewalld 来实现。具体来说,我们将通过将这些接口添加到公共区域来实现这一点。因此,我们需要在 server1 上执行一些标准命令和配置步骤:

echo "1" > /proc/sys/net/ipv4/ip_forward
nmcli connection add con-name network1 ifname ens37 type ethernet ip4 192.168.1.254/24
nmcli connection add con-name network2 ifname ens38 type ethernet ip4 192.168.2.254/24
nmcli connection add con-name network3 ifname ens39 type ethernet ip4 192.168.3.254/24 

如果我们的配置正确,当我们输入 nmcli con show 命令时,应该会看到类似这样的输出(具体取决于我们如何在 ens33 上配置外部网络,在我们的虚拟机中,它使用的是 192.168.159.0/24 网络):

图 4.36 – 检查我们的 NM 连接设置

图 4.36 – 检查我们的 NM 连接设置

此外,如果我们通过 ip route 命令检查路由信息,应该会得到类似这样的输出:

图 4.37 – 检查我们的路由

图 4.37 – 检查我们的路由

因此,我们有了三个子网,路由已相应配置,现在我们需要配置 server1 作为路由器。输入以下命令,将我们的接口设置到特定的区域:

nmcli connection modify ens33 connection.zone public
nmcli connection modify network1 connection.zone public
nmcli connection modify network2 connection.zone public
nmcli connection modify network3 connection.zone public
firewall-cmd --zone=public --add-masquerade --permanent
firewall-cmd --reload
firewall-cmd --list-all

当我们输入最后一个命令时,应该会看到类似这样的输出:

图 4.38 – firewall-cmd --list-all 输出

图 4.38 – firewall-cmd --list-all 输出

在 client1 上,我们还需要进行一些重新配置,因为它最初是设置为使用 DHCP 获取 IP 地址的。首先,输入以下命令安装 traceroute 包:

apt-get -y install traceroute

然后,让我们配置这台 Linux 虚拟机,使其 IP 地址为 192.168.1.1/24 并应用该配置。首先,我们需要编辑 netplan 配置文件。为了简便起见,我们就使用默认的配置文件 /etc/netplan/00-installer-config.yaml。它需要通过 netplan apply 命令应用以下内容:

``

图 4.39 – netplan 配置文件

图 4.39 – netplan 配置文件

现在让我们测试一下从这台机器能否访问互联网。如前所述,我们使用 server1 作为默认网关(192.168.1.254):

图 4.40 – 检查配置是否正常

图 4.40 – 检查配置是否正常

所以,连接正常。现在让我们配置 client2。我们的 CentOS 虚拟机 client2 有一个网络接口叫做 ens39。我们将它设置为 network2 子网的一部分(我们在 server1 上定义了该子网)。假设 client2 将临时使用 192.168.2.2/24 作为它的 IP 地址:

nmcli connection add con-name network2 ifname ens39 type ethernet ipv4.address 192.168.2.2/24 gateway 192.168.2.254 ipv4.dns 8.8.8.8,8.8.4.4
nmcli con reload network2

我们之前已经将 server1 配置为默认网关,因此 client1client2 可以愉快地将其作为默认网关并访问外部网络。我们可以通过 ping 命令轻松测试这一点。我们以 client2 为例:

图 4.41 – 配置更改后检查配置是否正常

图 4.41 – 配置更改后检查配置是否正常工作

现在我们已经验证了所有配置都正确,让我们现在检查一些可能需要额外网络故障排除的不同场景:

  • ping到外部主机不工作,但外部网络访问正常。

  • 外部网络访问不正常。

  • 无法在两个子网之间路由。

  • 名称解析未正常工作。

让我们从第一个场景开始。通常,这是防火墙配置设置(我们特意不称其为问题)。让我们首先 ping 一下我们想要访问的网站:

图 4.42 – 场景开始 – ping 不工作

图 4.42 – 场景开始 – ping 不工作

同时,如果我们尝试从浏览器访问packtpub.com,则没有任何问题:

图 4.43 – 浏览器中可以工作

图 4.43 – 浏览器中可以工作

这种类型的问题很常见 – 从ping的输出中,我们可以看到,从我们的 client2 到packtpub.com的路径上经过的防火墙在过滤pingICMP,或Internet Control Message Protocol)流量。这没有什么好担心的,尽管可能会有些困惑。我们需要记住,ping/ICMP 流量与 HTTP(S)/TCP 流量无关,这些协议可以分别进行过滤。这正是这里所做的 – 过滤了 ping/ICMP 流量,而 HTTP(S)/TCP 流量则没有被过滤。

现在让我们增加一些复杂性,通过一个外部网络访问不可用的场景来进行一些检测,让我们尝试从 client2 和server1 ping Google 的 DNS 之一,只是为了看看症状:

图 4.44 – 外部网络访问不工作

图 4.44 – 外部网络访问不工作

如果网络客户端(client2)无法访问外部网络,那是一回事。如果默认网关(在我们这里是server1)无法访问外部网络,那就完全不同了。这指向了一个更大的问题,如果我们没有触及防火墙配置和其他网络设备,那可能是与互联网服务提供商ISP)的连接或 ISP 端的某些问题。

我们可以通过使用额外的工具,如traceroutetracepath,进行更多的侦探工作:

图 4.45 – 进一步验证外部网络访问不工作

图 4.45 – 进一步验证外部网络访问不工作

如果我们使用外部 DNS 服务器,甚至可以使用nslookuphostdig命令几乎可以确定问题在于互联网访问,而非我们的客户端或服务器:

图 4.46 – resolv.conf 配置正确;DNS 名称解析不工作

图 4.46 – resolv.conf 配置正确;DNS 名称解析不工作

假设问题出在连接 server1 和 ISP 路由器的那根电缆断了。当我们更换那根电缆时,ping 应该能够正常工作,如下所示:

图 4.47 – 连接再次正常工作

图 4.47 – 连接再次正常工作

现在,让我们检查一个无法从一个子网到另一个子网的场景。我们将以 network1 和 network2 为例 —— 所以,我们将使用 client2(192.168.2.2/24)来尝试访问 client1(192.168.1.1/24)。由于这两个主机属于同一个第二层网络,我们必须有某种机制来路由它们之间的流量。让我们检查一下路由配置是否正常:

图 4.48 – 跨子网的路由工作正常

图 4.48 – 跨子网的路由工作正常

如之前所配置,我们允许在 server1 上转发所有网络之间的流量。我们通过允许伪装(masquerading)并将所有接口放入公共 firewalld 区域来实现这一点。有时候,当我们配置路由设备时,会犯一些错误。我们的错误可能导致两个网络之间无法再进行通信(通常是两个 VLAN,因为我们在这里讨论的是内部网络的场景)。让我们看看症状:

图 4.49 – 路由不再工作

图 4.49 – 路由不再工作

如果我们通过使用 netstat -rn 命令检查路由表,我们可以看到以下信息:

图 4.50 – 检查我们的 Linux 机器上的路由是否设置正确,确实设置正确

图 4.50 – 检查我们的 Linux 机器上的路由是否设置正确,确实设置正确

所以,我们的网关是正常工作的(我们可以 ping 通它),但它没有正确地将我们转发到 192.168.1.0/24 网络。看到我们将 192.168.1.0/24 网络配置为 server1 的本地网络,很明显我们在这里遇到了一些路由问题。可能是 firewalld 配置错误,导致 firewalld 服务被停止,或者是路由表配置错误,亦或是有人修改了 /proc 文件系统并将 ip_forward 标志重置为 0。无论是哪种情况,问题的根源在于我们的默认网关。在大型企业中,通常有网络团队负责这些事情,因此将 pingtraceroutenetstat 的输出提供给他们,应该能帮助他们找到问题所在(在他们的责任范围内)。我们通常会告诉他们,在 VLAN X(子网 1)和 VLAN Y(子网 2)之间存在 VLAN 路由问题,发送这些命令的输出给他们,让他们从那里着手解决。

最后,我们将通过讨论一些名称解析问题来结束这个教程。这些问题可能由于服务配置错误(例如 systemd-resolved)、错误的 /etc/resolv.conf 配置,甚至是我们自己配置的 /etc/hosts 文件而发生。让我们来看看一些常见问题。

首先,我们将编辑 client1 上的 /etc/resolv.conf 并添加一些自定义的 DNS 服务器。然后,我们将重启 client1 的 Linux 虚拟机,查看在检查 /etc/resolv.conf 内容时会发生什么。这是重启前的截图(我们添加了两个名称服务器,8.8.8.88.8.4.4):

图 4.51 – 手动编辑 /etc/resolv.conf

图 4.51 – 手动编辑 /etc/resolv.conf

下面的截图是重启后的结果。我们可以清楚地看到,这个文件的内容已经被更改:

图 4.52 – 重启后的 /etc/resolv.conf

图 4.52 – 重启后的 /etc/resolv.conf

这是预期的结果——正如我们在之前的教程中描述的那样,在运行 systemd-resolved 的 Linux 机器上更改 /etc/resolv.conf 总是会以这种方式结束。如果我们想要更改 DNS 设置,我们需要正确操作。这意味着在 CentOS 中使用 nmcli,在 Ubuntu 中使用 netplan 配置。这个问题可能只是本地问题,但在涉及 split-dns 的各种场景中,它仍然可能会产生很大影响。

接下来的问题将是相反的——假设我们在 Ubuntu 机器上安装了 resolvconf 包,禁用了 systemd-resolved,并像这样配置了 /etc/resolv.conf

图 4.53 – 在 /etc/resolv.conf 中放入错误的配置选项

图 4.53 – 在 /etc/resolv.conf 中放入错误的配置选项

当我们尝试使用 nslookuphostdig 命令解析某些内容时,虽然我们的互联网连接正常(如通过在 nslookup 中手动配置 DNS 服务器所示),但最终什么也解析不出来:

图 4.54 – 网络显然工作正常,但 DNS 名称解析不行,这指引我们走向正确的方向

图 4.54 – 网络显然工作正常,但 DNS 名称解析不行,这指引我们走向正确的方向

这显然指向了错误的 DNS 服务器配置,因为虽然互联网连接正常,但我们无法解析主机。很明显我们使用的选项(namserver)是错误的——应该是nameserver。这让我们意识到:我们必须始终确保配置文件的语法正确——在这个案例中是resolv.conf。如果我们通过文本编辑器进行更改,错误很容易发生,尤其是当我们忽视了例如在 vi 中标记为红色的高亮字段时。如果我们使用命令来配置并在语法上犯错(例如使用nmclinetplan),我们会在某个地方遇到错误,并且这个错误很容易调试。

我们要处理的最后一种情况是,对于那些从一个提供商迁移公共网站到另一个提供商,从而更改公共 IP 地址的人来说,这是一个常见的情况。在配置这些场景时,我们通常需要在测试新网站时保持旧网站的运行。我们可以在公共 DNS 服务器中有两个 IP 地址条目,指向两个不同的 Web 服务器,但这永远不是我们想要的。这样会让我们的访问者和我们自己都感到困惑。因此,我们希望能有一种快速的方式来测试新网站,直到它完全调试完毕,同时允许公众访问旧网站。

显然,最简单的做法是向/etc/hosts添加一个条目,指向新网站。然后,在我们进行该更改的同一台机器上,我们可以根据需要调试新网站——公共 DNS 记录仍然指向旧网站,而我们的本地机器则访问新网站。

调试过程完成后,我们需要进行切换——我们需要更改公共 DNS 记录并删除调试机器上的/etc/hosts条目。这是一个理想的场景,我们可以在其中犯一些错误。于是,我们去公共 DNS 提供商处,更改我们网站的 IP 地址,使其指向新的 IP 地址,并保存配置。接着,我们到本地调试机器上,删除指向新网站的/etc/hosts条目,启动一个 Web 浏览器,访问我们网站的 URL——然后,奇迹般地——我们仍然看到的是旧网站。这是怎么回事?

一个简单的事实是,公共 DNS 记录需要一些时间才能生效。这可能是一分钟、十五分钟、一小时、一天——取决于配置方式,但无论如何,它需要时间。此外,从世界各地的不同地方——如果我们的网站面向国际受众——同步所需的时间可能不同,这也是我们在处理类似情况时需要保持耐心的原因,因为我们可能会收到一些关于此情况的邮件。我们只需要在周末进行这些类型的配置更改,那时网站访问量最低,然后等待所有 DNS 记录同步。从我们更改 DNS 记录的时刻起,直到一切正常工作,这个过程超出了我们的控制范围。这就是它的工作方式。

从这些例子中,您可以清楚地看到,作为 Linux 服务器、客户端和网络的管理员,可能会遇到许多不同的场景。

它是如何工作的

我们在本节中讲解的所有命令都基于同一个理念——我们有一个网络栈,它要么配置正确,要么配置错误。如果配置正确,我们通常不需要使用这些命令,但如果某些配置错误和/或无法正常工作,可能是由于各种外部原因导致的,那么我们就需要了解这些命令是如何工作的。

如果我们一般讨论网络,有几个广为人知的概念:配置的 IP 地址、子网掩码、网关、DNS 服务器,以及任何给定 Linux 服务器的完全合格域名。记住,网络被隔离成多个子网,而 DNS 是一个层次结构,如果这些概念中的任何一个配置不正确,我们就会遇到网络通信问题。这就是为什么我们大多数系统工程师特别小心地配置所有这些设置,因为它们是避免我们永远头疼的基础。

当我们使用pingtraceroutetracepath时,我们生成的所有流量要么到达本地网络,要么到达非本地网络,而后者需要路由。在路由之上,防火墙可能会成为障碍——有时人们会配置防火墙,拒绝 ICMP 流量。

然后,即使这些都按预期工作,DNS 就像一个绝地大师坐在其上,试图平衡“原力”。有时,它似乎有点邪恶,仿佛没有任何原因就来烦我们。这时,像nslookuphostdig这样的工具就派上用场了——它们可以帮助我们弄清楚问题是出在网络堆栈中的较低层,还是出在 DNS。正如我们在前面的教程中讨论的,使用systemd-resolved改变了 DNS 配置的许多方面。当我们使用它时,我们必须格外小心配置,否则使用nmcli和 netplan 的配置文件时,我们不应随意去编辑文件。否则只会制造更多问题。

话虽如此,当我们正确配置一切,而网络上的其他设备(无论是我们的网络还是外部网络)出现故障时,问题可能会迅速变得复杂。如果执行网络路由的设备(如交换机、路由器、Linux 服务器、防火墙等)配置不正确,我们将无法在多个子网之间进行通信。试想一下,如果你不知道路从巴黎到巴塞罗那的路线,如何前往那座城市。就像我们在路由中有许多不同的路径,从巴黎到巴塞罗那有很多可能的路线,我们无法知道选择哪一条。通常,我们的故障排除过程是先通过 ping 测试网络上的某些地址(检查本地网络是否正常工作),然后检查默认网关,最后确认 DNS 是否作为服务可用。从个人角度来说,多年来我看到学生和课程参与者越来越清楚地意识到,DNS 作为一个系统有多复杂,尤其是在大规模部署时。我们学校有一句话,学生们总是反复说——总是 DNS。因此,我们需要确保我们在 DNS 知识和理解路由工作原理方面有坚实的基础。这样,一切就变得简单多了,因为将这两个概念结合起来可能会变得极其复杂,尤其是在涉及到动态路由协议,如 BGP、EIGRP 和 OSPF,同时又有分割 DNS 和多个位置的情况下。

本章到此结束。下一章将全部介绍如何使用 shell 来管理我们 Linux 系统上的软件包。我们将讨论如何使用aptapt-getyum以及dnf,软件仓库,以及与软件管理相关的其他主题。在那之前,我们向你告别!

还有更多

如果你需要学习更多关于网络故障排除的知识,可以查看以下链接:

第五章:使用命令进行文件、目录和服务管理

在处理文件和文件夹时,使用systemctl命令。这正是我们将在本章中覆盖的内容,通过以下操作步骤:

  • 基本的文件和目录命令

  • 用于操作文件/目录安全性方面的附加命令

  • 查找文件和文件夹

  • 使用命令操作文本文件

  • 归档和压缩文件及文件夹

  • 管理服务和目标

技术要求

对于这些操作,我们将使用一台 Linux 机器——在我们的例子中,使用cli1。我们只需要确保它已开机。

基本的文件和目录命令

让我们讨论一些常用的 shell 命令,用于操作文件和目录。简而言之,我们关注的命令有:

  • ls——用于列出文件夹内容

  • touch——用于创建一个空的文本文件

  • cd——用于切换目录,可以使用绝对路径或相对路径

  • pwd——用于显示当前目录

  • mkdirrm——用于创建和删除文件或目录

  • cpmv——用于复制或移动文件或目录

  • ln——用于操作软链接和硬链接

这些命令是系统管理员/工程师日常工作中最常用的命令之一。让我们看看它们的具体用法。

做好准备

我们需要通过这些命令来真正理解它们在执行时对文件系统的影响。所以,确保我们的cli1机器正在运行,让我们开始吧!

如何执行……

从最简单的命令ls开始,它的作用是查看文件夹内容。因此,如果我们希望以清晰易读的格式查看某个目录的内容,可以这样操作:

图 5.1 – 使用最常见选项的 ls

图 5.1 – 使用最常见选项的 ls

清晰易读的效果是通过使用-la选项实现的,其中l选项代表长格式列出a选项代表所有文件(包括以点开头的文件)。我们还可以看到,默认情况下,ls命令会对输出内容进行颜色标记。例如,文件夹以蓝色显示,而以红色标记的文件是归档文件(在此案例中为tar.gz文件)。稍后,当我们开始处理归档和压缩文件及文件夹时,我们将深入探讨归档文件。ls命令在默认输出中使用了其他颜色。以下是一些示例:

  • 绿色——可执行文件

  • 青色——符号链接

  • 黑底红字——损坏的链接

ls命令可以用于/etc/network,并且可以使用其递归选项(大写字母R):

图 5.2 – 在递归模式下使用 ls

图 5.2 – 在递归模式下使用 ls

通过使用R选项,我们指示ls命令在递归模式下执行。

我们还可以使用ls来显示文件夹内容,并通过最后修改时间对输出进行排序:

图 5.3 – 按最后修改时间对输出进行排序

图 5.3 – 按最后修改时间对ls输出进行排序

我们接下来要介绍的命令是touch,这是一个简单的命令。我们使用touch命令来创建一个空文件,如下所示:

图 5.4 – 在 Linux 中使用创建空文件

图 5.4 – 在 Linux 中使用touch创建空文件

接下来,我们要解释两个紧密相关的命令——cdpwdcd,即更改目录命令,帮助我们离开一个目录并进入另一个目录。而pwd命令则告诉我们当前所在的目录。让我们用/etc/network作为示例,来实际操作一下:

图 5.5 – 使用和来定位我们的目录

图 5.5 – 使用cdpwd来定位我们的目录

接下来我们要处理的两个命令是mkdirrm。我们使用mkdir来创建目录,而rm命令用来删除文件或文件夹。接下来,我们通过一个示例来演示这两个命令的使用。首先,我们将创建一个名为temporary的目录。然后,在该目录中创建两个文件,分别命名为tempfiletempfile2。之后,我们将删除tempfile2,然后递归地删除整个temporary目录及其所有内容。让我们现在来做这个操作:

图 5.6 – 使用和命令

图 5.6 – 使用mkdirrm命令

我们接下来要讨论的话题是cpmv命令——它们使我们能够在想要的地方复制或移动文件和/或文件夹。接下来,我们将复制一个文件和一个文件夹(递归地),然后将它们移动到其他地方。我们将使用与mkdirrm相同的示例,但会稍作调整以适应本次操作。具体来说,我们将创建一个包含两个文件的目录,但这次这些文件将位于一个子目录中。然后,我们将向第一个文件夹添加更多文件,之后将单个文件复制并移动到新位置,再将整个文件夹移动到另一个位置。让我们看看如何实现:

图 5.7 – 复制和移动文件与文件夹

图 5.7 – 复制和移动文件与文件夹

前两个命令可以通过执行mkdir -p temporary/tempdir2合并成一个命令。

这将通过一个命令创建这两个目录。

最后,我们来讨论一下 ln 命令,它可以用来创建硬链接(指向文件内容的指针)和软链接(指向文件/文件夹名称的指针,通常被称为快捷方式)。对于硬链接,我们只需使用不带任何附加选项的 ln 命令,而软链接则需要使用带有 -s 选项的 ln 命令。让我们创建一个示例来解释这个概念,完成这个示例后,我们将解释它是如何工作的。这个场景将包括以下内容:

  • 将一个有一些内容的文件复制到新位置,这样我们就有了一个源文件,用于创建硬链接和软链接

  • 创建硬链接

  • 创建软链接

  • 删除原始文件,然后检查软链接和硬链接发生了什么变化

  • 复制原始文件的硬链接,然后检查软链接和硬链接发生了什么变化

操作步骤如下:

图 5.8 – 硬链接和软链接操作

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_5.8_B16269.jpg)

图 5.8 – 硬链接和软链接操作

现在我们已经经历了所有预定的场景,接下来让我们解释这些命令背后的一些概念,以便理解在执行它们时文件系统发生了什么。

它是如何工作的…

这些命令中有些非常直接,不一定需要进一步解释,比如 lstouchcdmkdirpwd。但其他一些命令则需要一点背景和技术解释,特别是 ln。因此,让我们以此为基础,逐步讲解 rmcpmvln 是如何工作的。

首先,介绍我们最常用的一些基本文件操作命令——rmcpmv。这些命令非常直接,我们用它们来删除文件或文件夹(rm)、复制文件或文件夹(cp)或移动文件或文件夹(mv)。请注意,我们在描述 rmmv 时使用了删除移动这两个词,因为它们是不同的——删除指的是删除文件,而移动指的是将文件放到其他地方。虽然这有时会让新手用户感到困惑,但其实不应该这样。

然而,从技术上讲,最具挑战性的命令是 ln,它要求我们先解释什么是软链接,什么是硬链接。所以,让我们先从这部分开始。

软链接通常指我们在 Windows 中所说的快捷方式,也就是说我们可以为文件或文件夹创建一个快捷方式。正如我们场景中的图片所示,当我们删除原始文件时,软链接停止工作。原因很简单——软链接指向的是文件或文件夹的名称。如果我们删除一个软链接指向的文件或文件夹,这就意味着软链接指向了不存在的内容。这就是为什么在我们的场景中,软链接会变成红色的原因。

硬链接是一个完全不同的概念。它们不指向文件名 – 它们指向文件内容。当使用硬链接时,试着将它们看作是指向相同内容的两个文件。如果我们删除了原始文件,文件内容仍然存在,因为这是现代文件系统的工作方式 – 它们不会浪费时间删除内容,特别是如果另一个文件仍然指向相同的内容。因此,文件删除操作只是从文件系统表中删除指针(文件名)到文件内容。文件系统会处理剩下的事情 – 当时间到来时,如果那个文件内容不再被使用,文件系统会用新内容覆盖它。

通过检查原始情景,我们可以得出这么多结论,我们可以清楚地看到软链接和硬链接之间的大小差异 – 软链接指向文件名,因此与文件名的大小有关(文件名中字符的数量)。正如我们所解释的,硬链接指向文件内容,这就是为什么硬链接的大小与其指向的文件相同的原因。

这两个概念之间有两个基本差异。考虑到硬链接指向文件内容,它们不能指向目录(只能指向文件),并且不能跨分区。因此,如果我们将一个磁盘分区挂载到/directory,另一个磁盘分区挂载到/home directory。我们不能去/home directory并创建一个指向位于/partition中的文件的硬链接。一个分区无法看到另一个分区的内容,这是一个重要的安全概念。这也排除了跨分区的硬链接能够正常工作的可能性。

下一个示例将更深入地探讨文件目录安全概念,我们将讨论权限、特殊权限以及访问控制列表ACLs)。这些概念是 IT 安全的核心概念,也是我们日常处理的内容。所以,让我们接下来通过一个相关的场景来了解一下。

另见

如果您需要更多关于这些命令的信息,建议您访问这些链接:

  • ls 手册页面:https://man7.org/linux/man-pages/man1/ls.1.html

  • touch 手册页面:https://man7.org/linux/man-pages/man1/touch.1.html

  • cd 手册页面:https://linuxcommand.org/lc3_man_pages/cdh.html

  • pwd 手册页面:https://man7.org/linux/man-pages/man1/pwd.1.html

  • mkdir 手册页面:https://man7.org/linux/man-pages/man1/mkdir.1.html

  • rm 手册页面:https://man7.org/linux/man-pages/man1/rm.1.html

  • cp 手册页面:https://man7.org/linux/man-pages/man1/cp.1.html

  • mv 手册页面:https://linux.die.net/man/1/mv

  • ln 手册页面:https://man7.org/linux/man-pages/man1/ln.1.html

附加命令以操作文件/目录安全方面

在这个例子中,我们将使用我们的用户——Jack、Joe、Jill 和 Sarah——来创建一个特定的场景,解释权限、ACL 和 umask 的使用。以下是这些概念的简要解释:权限用于控制对文件和文件夹的访问,包括读取、写入和执行模式。由于权限的粒度有限,开发了 ACL 概念,以便能够在更细粒度的级别上管理权限。Umask 是一个变量,预先决定了将分配给新创建的文件或目录的权限。

该方案将如下进行:

  • 我们需要为我们的学生创建一个共享目录,位于/share/students

  • 我们需要为我们的教授创建一个共享目录,位于/share/professors

  • 学生组的成员需要能够访问/share/students文件夹,以便共同协作项目文件

  • 学生组的成员可以在/share/students文件夹中创建新的文件,这些文件需要由学生组拥有

  • 学生组的一个成员不能使用rm命令删除/share/students文件夹中其他成员的文件

  • 学生组的一个成员必须有权限编辑/share/students文件夹中其他成员的文件

  • 教授需要对所有学生文件以及所有新创建的学生文件具有读写权限

  • 只有教授才能访问他们的共享文件夹/share/professors,在该文件夹中,他们可以删除彼此的文件、读取文件并编辑文件。

让我们开始这个实例吧。

准备工作

我们将使用cli2机器(CentOS)进行本例,因此请确保该机器已开启。

如何操作…

我们将首先通过使用useraddgroupadd命令来创建用户和组,基于一个场景。假设我们的任务如下:

  • 创建四个用户,分别为jackjoejillsarah

  • 创建两个用户组,分别为profspupils

  • 重新配置jackjill用户账户,使其成为profs组的成员

  • 重新配置joesarah用户账户,使其成为students组的成员

  • 为所有账户分配一个标准密码(我们将使用P@ckT2021作为密码)

  • 配置用户账户,以便他们在下次登录时必须更改密码

  • 为教授用户组设置特定的过期数据——密码更改前的最短天数应设置为15,强制密码更改前的最大天数应设置为30,密码更改警告需要在密码过期前一周开始,账户的过期日期应设置为 2023 年 1 月 1 日(2023/01/01)

  • 为学生用户组设置特定的过期数据——密码更改前的最短天数应设置为7,强制密码更改前的最大天数应设置为30,密码更改警告需要在密码过期前 10 天开始,账户的过期日期应设置为 2022 年 9 月 1 日(2022/09/01)

  • profs组修改为professors,并将pupils组修改为students

    注意

    这篇食谱中有很多命令,所以确保参考食谱中的如何工作…部分,以了解我们之前没有使用过的命令的相关内容。

第一个任务是创建用户账户,为其分配唯一的主目录,并将 Bash shell 设置为默认 Shell:

useradd -m -s /bin/bash jack
useradd -m -s /bin/bash joe
useradd -m -s /bin/bash jill
useradd -m -s /bin/bash sarah

这将为这四个用户在/etc/passwd文件中创建条目(该文件存储了大多数用户信息——用户名、用户 ID、组 ID、默认主目录和默认 Shell),以及在/etc/shadow文件中创建条目(该文件存储了用户的密码和过期信息)。

然后,我们需要创建组:

groupadd profs
groupadd pupils

这将为这些组在/etc/group文件中创建条目,系统会在该文件中保留所有的系统组。

下一步是管理用户组的成员资格,包括教授组和学生组。在我们开始之前,需要了解一个事实。存在两种不同的本地组类型——主组附加组主组在创建新文件和目录时至关重要,因为默认情况下,用户的主组将用于这些操作(当然也有例外,我们将在本章的食谱 #4 中提到,关于 umask、权限和 ACLs 的内容)。

附加组在处理文件和文件夹共享以及相关的场景和异常时非常重要。这通常用于一些更高级场景的附加设置。将在本章前面提到的食谱 #4 中部分解释这些场景,以及在第九章《Shell 脚本介绍》中的 NFS 和 Samba 相关食谱中进一步说明。

主组和附加组存储在/etc/group文件中。

既然我们已经搞定了这一点,让我们修改用户的设置,使其属于场景中指定的附加组

usermod -G profs jack
usermod -G profs jill
usermod -G pupils joe
usermod -G pupils sarah

现在让我们检查一下这如何改变/etc/group文件:

图 5.9 –  文件中的条目

图 5.9 – /etc/group 文件中的条目

/etc/group 文件中的前四个条目实际上是在我们使用useradd命令创建这些用户账户时生成的。接下来的两个条目(除了:符号后面的部分)是由groupadd命令创建的,而:符号后的条目是在usermod命令之后创建的。

现在让我们设置他们的初始密码,并在下次登录时强制要求更改密码。我们可以通过几种不同的方式来做到这一点,但我们先通过回显一个字符串并将其作为用户账户的明文密码来学习一种更程序化的方式:

echo "jack:P@ckT2021" | chpasswd
echo "joe:P@ckT2021" | chpasswd
echo "jill:P@ckT2021" | chpasswd
echo "sarah:P@ckT2021" | chpasswd

这不一定是我们推荐的做法,因为它会将这些命令留在命令历史记录中。我们只是将其作为一个示例。

echo部分——没有其他命令——只会将P@ckT2021输入到终端中,如下所示:

echo "P@ckT2021"
P@ckT2021

在 CentOS 和类似的发行版中,我们可以使用带有--stdin参数的passwd命令,这意味着我们希望通过标准输入(键盘、变量等)为用户账户添加密码。而在 Ubuntu 中,这个功能不可用。因此,我们可以将username:P@ckT2021字符串回显到shell,并通过管道传递给chpasswd命令,这样就能达到目的——chpasswd命令不会将该字符串输出到终端,而是将其作为标准输入处理。

现在让我们设置教授和学生的过期日期。为此,我们需要学习如何使用chage命令以及它的一些参数(-m-M-W-E):

  • 如果我们使用-m参数,这意味着我们希望指定密码更改前的最小天数。

  • 如果我们使用-M参数,这意味着我们希望在强制密码更改之前指定最大天数。

  • 如果我们使用-W参数,这意味着我们希望设置密码过期前的警告天数,这反过来意味着 shell 将开始向我们抛出关于密码即将过期的消息,提醒我们需要更改密码。

  • 如果我们使用-E参数,这意味着我们希望将账户过期设置为某个特定日期(格式为 YYYY-MM-DD)。

现在让我们将其转换为命令:

chage -m 15 -M 30 -W 7 -E 2023-01-01 jack
chage -m 15 -M 30 -W 7 -E 2023-01-01 jill
chage -m 7 -M 30 -W 10 -E 2022-09-01 joe
chage -m 7 -M 30 -W 10 -E 2022-09-01 sarah

最后,让我们通过将组名从professors修改为profs,并将students修改为pupils,来调整组的最终设置:

groupmod -n professors profs
groupmod -n students pupils

这些命令只会更改组名,而不会更改其他数据(如组 ID),这也会反映在我们的用户信息中:

图 5.10 – 检查已创建用户的设置

图 5.10 – 检查已创建用户的设置

如我们所见,jackjill是现在被称为professors的组的成员,而joesarah现在是students组的成员。

我们故意将userdelgroupdel命令放在最后,因为它们有一些注意事项,不应该轻易使用。让我们创建一个名为temp的用户和一个名为temporary的组,然后删除它们:

useradd temp
groupadd temporary
userdel temp
groupdel temporary

这样是完全可行的。问题是,由于我们使用了没有任何参数的userdel命令,它将保留用户的主目录不变。由于用户的主目录通常存储在/home目录中,默认情况下,这意味着/home/temp目录仍然存在。当删除用户时,有时我们希望这样做——删除用户,但不删除他们的文件。如果你特别想删除用户账户以及该用户账户的所有数据,请使用userdel -r username命令。但在这样做之前请三思!

此外,我们显然需要创建一堆目录和文件,并且更改一大堆权限和访问控制列表(ACL)。作为一般说明,chmod命令用来更改权限,而setfacl命令则用来修改 ACL。这是正确的操作方法:

mkdir -p /share/students
mkdir /share/professors
chgrp students /share/students
chmod 3775 /share/students
setfacl -m g:professors:rwx /share/students
chgrp professors /share/professors
chmod 2770 /share/professors/
setfacl -m d:g:professors:rwx /share/students/

现在让我们来测试一下是否有效。首先,我们将以两名学生账户(joesarah)登录并创建一些文件。然后我们将使用 Joe 的账户尝试删除 Sarah 的文件,反之亦然,所以我们首先应该使用su命令登录为joesu - joe,并输入 root 密码。

让我们看看这个如何工作:

Figure 5.11 – 从学生的角度来看,场景完美无缺

Figure 5.11 – 从学生的角度来看,场景完美无缺

我们的场景要求我们能够编辑彼此的文件,同时又不能完全删除它们。现在让我们来测试一下:

Figure 5.12 – 修改文件内容有效,而删除文件无效

Figure 5.12 – 修改文件内容有效,而删除文件无效

我们之所以选择这种场景,是有原因的——这是文件服务器管理员常常遇到的现实场景。它基本上是“两个世界的最佳结合”——协作有效,但用户不能意外删除彼此的文件。因此,这个方案涵盖了文件服务器上最常见的一些问题,例如某个用户不小心删除了另一个用户的文件(这里的关键点是没有删除文件的意图)。这种情况我们每个人都遇到过。另一方面,改变文件的内容是我们只能有意自觉地做到的。这也是我们可以通过文件系统审计和文件属性轻松跟踪的事情,前提是我们设置了这样的系统。

现在让我们从教授的角度来回顾一下。我们将使用jill账户来进行此操作:

Figure 5.13 – 检查我们的配置是否对 Jill 有效

Figure 5.13 – 检查我们的配置是否对 Jill 有效

我们还需要检查教授的共享文件夹是否正常工作。让我们来测试一下:

Figure 5.14 – 从教授的角度来看,他们的共享文件夹按预期工作

Figure 5.14 – 从教授的角度来看,他们的共享文件夹按预期工作

让我们尝试使用学生账户访问教授的共享文件夹:

Figure 5.15 – 一名学生尝试访问教授的共享文件夹,但被拒绝访问

Figure 5.15 – 一名学生尝试访问教授的共享文件夹,但被拒绝访问

我们还可以看到,用户创建的这些文件得到了664的默认权限。这就是 umask 的作用。请查看本方案的它是如何工作的……部分,了解 umask 的工作原理。

所以,整个场景是有效的,但它到底是怎么工作的呢?让我们来查看一下。

它是如何工作的……

在详细解释这些命令之前,我们先了解一些基础知识,并描述我们已使用过的命令:

  • useradd – 用于创建本地用户账户的命令

  • usermod – 用于修改本地用户账户的命令

  • userdel – 用于删除本地用户账户的命令

  • groupadd – 用于创建本地用户组的命令

  • groupmod – 用于修改本地用户组的命令

  • groupdel – 用于删除本地用户组的命令

  • passwd – 最常用来为用户账户分配密码的命令,但它也可以用于其他一些场景(例如,锁定用户账户)

  • chage – 用于管理用户密码过期的命令

  • chgrp – 用于更改文件或文件夹的组所有权的命令

  • chmod – 改变文件或文件夹权限的命令

  • setfacl – 改变文件或文件夹 ACL 的命令

现在我们已经讨论过这些命令,让我们来解释一些细节。

Linux 文件系统中的每个文件或目录都有一组属性:

  • 权限

  • 所有权

  • 文件大小

  • 创建日期

  • 文件/目录名称

在这个教程中,我们将重点讨论权限和所有权,因为这是本教程的核心内容。当我们在/share/students目录下执行命令ls -al时,得到的结果如下:

[root@localhost students]# ls -al
total 4
drwxrwsr-t+ 2 root  students 66 Dec  6 21:12 .
drwxr-xr-x. 4 root  root     40 Dec  6 20:51 ..
-rw-rw-r--+ 1 joe   students  0 Dec  6 20:58 myfile1
-rw-rw-r--+ 1 joe   students  0 Dec  6 20:58 myfile2
-rw-rw-r--+ 1 sarah students 76 Dec  6 21:16 myfile3
-rw-rw-r--+ 1 sarah students  0 Dec  6 20:58 myfile4

现在我们以myfile1的输出作为例子。从左到右读取,-rw-rw-r--+部分与该文件的权限相关。第二部分(joe,后接students)与该文件的所有权相关。

让我们先从权限部分开始解析一下:

  • 第一个-表示这是一个文件——这个字段用于表示内容的类型。

  • 第一个rw-表示文件所有者(joe)对该文件具有读写权限——我们称之为用户类(u)。

  • 第二个rw-表示我们对组所有者(students)具有读写权限——我们称之为组类(g)。

  • R—表示其他用户只有读权限——我们称之为others(o)类。

  • 末尾的+表示该文件上有一个活跃的 ACL(稍后将在此解释)。

我们可以给这些权限分配数字值(权重)。读权限的权重是 4(22),写权限的权重是 2(21),执行权限的权重是 1(20)。

所以,如果我们想给所有用户类(用户所有者、组所有者、其他用户)分配所有权限,我们会使用如下命令:

chmod 777 file_name

为什么?因为如果我们加上 4+2+1,结果是 7。那意味着读+写+执行。而我们可以将这个值应用于所有三类用户——u、g 和 o——所以这给我们带来了 777。第一个7代表u(用户所有者),第二个代表g(组所有者),第三个代表其他用户(others)。这大大简化了权限管理。

如果我们谈论的是文件,这些权限的含义是直接的——读取意味着读取,写入意味着写入、删除和修改,执行意味着能够启动文件。

对于目录来说,情况变得有些复杂。为了能够读取目录内容并通过目录(目录遍历),我们需要的默认权限是读取权限和执行权限。对目录进行写权限和读取权限是列出目录内容所需要的,而执行权限是用于遍历目录(能够进入该目录的子文件夹)。写权限意味着在文件夹层级上对该文件夹中的文件进行写入、删除和修改(除非有明确的拒绝设置,例如,通过 ACL 设置)。

如你在命令输出中清晰看到,文件有两种所有权类型:

  • joe

  • students

那这意味着什么?

它意味着一个名为 Joe 的用户拥有该文件。同时,它也意味着一个名为 students 的组从组的角度拥有该文件。

现在让我们继续讨论输出的第二行,具体来说:

drwxrwsr-t+ 2 root  students 66 Dec  6 21:12 .

相同的原则适用,只不过我们需要讨论一些新的设置。我们可以清楚地看到一些我们之前没有提到的字母——组所有权类中的 s,以及其他类中的 t。这是什么意思?

问题是,在读取(r)、写入(w)和执行(x)之外,还有一些额外的特殊权限。这些是用于特殊用例的:

  • 粘性位 – 我们在文件夹级别设置这个特殊权限。当在文件夹级别启用时,它可以防止我们场景中的文件被意外删除。例如,myfile1 是由用户 joe 所拥有的。尽管 sarah 是同一组(学生组)的成员,并且该组拥有文件,但她仍然无法删除该文件。这就是粘性位的作用。

  • setgid – 我们也在文件夹级别设置这个特殊权限。当在文件夹级别设置时,这个特殊权限意味着所有新创建的文件(在设置了 setgid 后)将继承父文件夹的组所有权。在我们的场景中,这意味着所有新创建的文件将由学生组作为组所有者,这符合场景的要求。这就是为什么我们在文件夹级别使用 chgrp 命令来将文件夹所有权设置为学生组。

  • setuid – 现在几乎不再使用,因为它是一个安全隐患。它曾经在文件上使用,特别是为了让当文件由非拥有者用户启动时,看起来是拥有该文件的用户启动的(类似于 Windows 中的“以管理员身份运行”功能)。

这些权限也是通过 chmod 命令设置的,像第一个数字一样。这就是为什么我们的 chmod 命令有四个数字而不是三个——第一个数字完全是关于特殊权限的。一般来说,当我们使用三位数与 chmod 时,它会自动扩展,将左侧补充为零。

回到我们的案例,我们执行了以下命令:

chmod 3775 /share/students

这意味着我们使用chmod设置了粘滞位和setgid(首位数字 1+2 等于 3),以及为用户和组所有者设置了rwx(77)权限,为其他用户设置了rx(5)权限。

接下来,更复杂的部分涉及 ACL。ACL 通常用于处理例外情况(常规 ACL)或权限继承(默认 ACL)。让我们更详细地描述它们。我们使用了以下命令:

setfacl -m g:professors:rwx /share/students

这意味着我们要修改(-m参数)名为/share/students的目录上的 ACL。我们希望修改它,使得名为professors的组对该目录获得rwx(读-写-执行)权限。您可以清楚地看到,为什么我们说 ACL 通常用于处理例外情况。我们的场景要求/share/students文件夹具有学生组的所有权。我们不能将两个用户设置为目录的所有者(根据电影《独闯天涯》,只能有一个所有者)。因此,我们无法直接做到这一点,这意味着我们必须使用其他方法来创建一个例外。这就是 ACL 的作用所在。

我们本可以以不同的方式做到这一点(并不是说我们应该这样做)。我们本可以为我们教授组中的两个成员发布两个基于用户的 ACL。这两个命令如下:

setfacl -m u:jack:rwx /share/students
setfacl -m u:jill:rwx /share/students

这个方法的问题其实很容易理解。假设我们向系统中添加了五个教授。然后我们需要发布五个setfacl命令,为他们设置相同的 ACL。使用组并将用户添加到组中显然更简单。这是一个众所周知的概念,所有操作系统今天都在使用。

如果我们想要为others设置显式拒绝的 ACL,我们可以使用以下命令:

setfacl -m o:--- /share/students

这样,我们确保所有属于others类的成员都无法访问该文件夹。

我们使用的第二个setfacl命令如下:

setfacl -m d:g:professors:rwx /share/students/

这个命令设置了一个默认 ACL,这是与我们刚才描述的常规 ACL 完全不同的概念。默认 ACL 用于确保每个新创建的文件或文件夹在目录下(在本例中是/share/students)自动继承父文件夹的权限,这些权限是由默认 ACL 设置的。在我们的场景中,这个命令意味着每个在我们设置默认 ACL 之后创建的文件或文件夹都会被设置为g:professors:rwx的 ACL。

很明显,您可以看到 ACL 和默认 ACL 是如何有用的,因为没有它们,我们就无法配置更多更复杂、更细粒度的数据访问场景。

现在让我们讨论这个场景中的最后一个重要方面——默认文件权限。我们在教程中提到,我们需要讨论umask的问题。现在我们来讲解这个。

如果我们检查其中一个之前截图的输出,我们可以看到:

-rw-rw-r--. 1 jill professors  0 Dec  6 21:22 prof2
-rw-rw-r--. 1 jack professors 36 Dec  6 21:23 prof3

问题是,为什么默认权限是rw-rw-r--

这个问题的答案叫做umask(用户掩码)。

umask作为一个概念,专门用于此——设置新创建的文件和目录的默认权限。它可以通过 Shell 配置文件、用户配置文件或命令进行设置。让我们使用umask命令来解释它是如何实现这一点的:

[jill@localhost professors]$ umask
0002
[jill@localhost professors]$ touch prof4
[jill@localhost professors]$ umask 0022
[jill@localhost professors]$ touch prof5
[jill@localhost professors]$ umask 0222
[jill@localhost professors]$ touch prof6
[jill@localhost professors]$ ls -al
total 8
drwxrws---. 2 root professors 84 Dec  6 22:32 .
drwxr-xr-x. 4 root root       40 Dec  6 20:51 ..
-rw-rw-r--. 1 jill professors 36 Dec  6 21:23 prof1
-rw-rw-r--. 1 jill professors  0 Dec  6 21:22 prof2
-rw-rw-r--. 1 jack professors 36 Dec  6 21:23 prof3
-rw-rw-r--. 1 jill professors  0 Dec  6 22:32 prof4
-rw-r--r--. 1 jill professors  0 Dec  6 22:32 prof5
-r--r--r--. 1 jill professors  0 Dec  6 22:32 prof6

你可以清楚地看到,当我们为用户更改umask变量时,新创建的文件的默认权限会发生变化。当我们使用umask值为0002时,prof4文件的权限被设置为664。当我们使用umask值为0022时,prof5文件的权限被设置为644。最后,当我们使用umask值为0222时,prof6文件的权限被设置为444。我们还可以按照与chmod命令相同的原则,在设置umask时忽略前导零。

文件的掩码设置为666,目录的掩码设置为777。因此,如果我们想计算新创建的文件或文件夹的默认权限,只需从这些值(666777)中减去umask值,就能得到文件(或文件夹)的默认权限。

如果不手动配置,所有用户的umask值由/etc/profile文件设置,默认情况下在用户登录时加载该文件。该文件中有一个if语句,类似于这样:

if [ $UID -gt 199 ] && [ "'/usr/bin/id -gn'" = "'/usr/bin/id -un'" ]; then
    umask 002
else
    umask 022
fi

基本上,这个if-then-else结构的作用是,对于所有大于199的 UID,umask被设置为002,否则,umask被设置为022。这就是为什么普通用户的umask002,而 root 用户的umask022(root 的 UID 是 0)。

现在让我们进入下一个示例,这一部分完全是关于使用命令操作文本文件——包括catcutmorelessheadtail等命令。

另见

如果你需要更多关于文件权限、特殊权限或 ACL 的信息,我们建议你访问以下链接:

  • 管理文件权限:https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/configuring_basic_system_settings/assembly_managing-file-permissions_configuring-basic-system-settings

  • Linux 权限 – SUID、SGID 和粘滞位:https://www.redhat.com/sysadmin/suid-sgid-sticky-bit

  • Linux ACLs 介绍:https://www.redhat.com/sysadmin/linux-access-control-lists

使用命令操作文本文件

现在让我们将注意力转向学习操作文本文件的命令——仅为了输出的原因,包括headtailmorelesscat。一些与这些概念相关的其他命令将在后续章节中介绍,例如第八章使用命令行查找、提取和操作文本内容,在该章节中我们将讨论与文本文件相关的更高级的场景,如合并、切割,并使用grepsed等工具的正则表达式来操作文本内容。

准备工作

我们仍然需要和之前的示例一样的虚拟机。

如何操作…

我们从使用headtail命令开始,这些命令可以用来显示文本文件的开头和结尾。例如,我们可以使用/root/.bashrc文件:

[student@cli1 22:28] head /root/.bashrc
# ~/.bashrc: executed by bash(1) for non-login shells.
# see /usr/share/doc/bash/examples/startup-files (in the package bash-doc)
# for examples
# If not running interactively, don't do anything
case $- in
    *i*) ;;
      *) return;;
esac

现在,让我们检查同一个文件的尾部:

[student@cli1 22:29] tail /root/.bashrc
  if [ -f /usr/share/bash-completion/bash_completion ]; then
    . /usr/share/bash-completion/bash_completion
  elif [ -f /etc/bash_completion ]; then
    . /etc/bash_completion
  fi
fi
PS1="\e[0;31m[\u@\H \A] \e0m"
export VISUAL=nano
export EDITOR=nano

that不同,moreless仅用于显示输出,但以按页面格式化的方式显示,使得长输出更容易被人类阅读。因此,当我们执行以下命令时:

less /root/.bashrc

或者我们可以执行以下命令:

more /root/.bashrc

这些命令的预期输出类似于以下内容:

![图 5.16 – 使用 more 和 less 命令看起来非常相似与此类似 – 按页面逐页查看文本内容图 5.16 – 使用 more 和 less 命令看起来非常相似 – 按页面逐页查看文本内容Cat(命令,而不是猫)与moreless规范完全相反——它会直接显示整个文件内容,没有任何停顿。当文本文件较短时,这种方式很酷,但如果文件较长,它基本上是无用的,这也是我们使用moreless命令的最常见原因之一。所以,让我们选择一个短文件并使用cat,例如/root/.profile文件:图 5.17 – 在合适的文件上使用 cat 命令 – 一个文本文件它不大到无法适应一个终端页面

图 5.17 – 在合适的文件上使用 cat 命令 – 一个足够小的文本文件,能够适应一个终端页面

Cat还可以用来做一件事,那就是将多个文本文件合并为一个。这个操作通常在将多个日志文件合并为一个文件时使用。我们将在本书后面讨论这一场景,位于第八章使用命令行查找、提取和操作文本内容

它是如何工作的……

moreless是页面查看器——它们使我们能够按页显示内容。正如我们在示例的最后一行看到的,使用这些命令时并没有完成——命令在显示完一页文件内容后停止显示文件内容。现在它会交互式地等待我们继续按页显示文件内容、做其他事情(例如,通过使用/符号搜索)或按q键退出。

headtail命令的命名非常贴切——它们显示文本文件的开头(头部)和结尾(尾部)。它们也可以与各种选项一起使用,以进一步参数化我们想要的输出。例如,如果我们执行以下命令:

tail -n 15 /root/.bashrc

我们将获取该文件的最后 15 行。使用head命令也可以做到这一点。

我们接下来的讨论话题是使用find命令查找文件和文件夹。我们先处理这个问题,然后再继续讨论其他的操作,包括归档、压缩以及通过systemctl处理服务。

还有更多……

如果我们需要了解更多关于这些命令的信息,我们可以查看以下链接:

查找文件和文件夹

我们今天的下一个主题是学习使用find命令,它是一个非常有用的命令。它可以以多种方式使用——根据特定的标准(如权限、所有权、修改日期等)查找文件和文件夹,还可以准备数据,以便在find命令后进一步操作。在这个教程中,我们将介绍这两种原则的示例。

准备工作

我们需要让cli1虚拟机保持运行。如果它没有开启,我们需要重新启动它。

如何操作…

让我们通过几个例子来解释find命令是如何工作的。以下是我们将使用的一些例子:

  • 查找/目录中权限为2755的文件

  • 查找/目录中属于用户jill的文件

  • 查找/目录中属于学生组的文件

  • 查找/目录中具有特定名称的文件(例如,network

  • 查找特定类型的文件(例如,所有具有php扩展名的文件)

  • 查找所有空目录

  • 查找过去两小时内修改过的文件(120 分钟)

  • 查找大小在 100-200 MB 之间的文件

让我们让这些场景发生:

find / -type f -perm 2755
find / -type f -user jill
find / -type f -group student
find / -type f -name network
find / -type f -name "*.php"
find / -type d -empty
find / -mmin -120
find / -size +100M -size -200M

为了更好地强调此命令的重要性,正如我们之前提到的,让我们用它来准备数据,以便在找到必要的内容后进行后续操作。例如,让我们在整个文件系统中查找所有.avi扩展名的文件,并删除它们:

find / -type f -name "*.avi" -exec rm -f {} \;

该命令会查找所有.avi扩展名的文件,将它们放入数组中,然后使用rm -f命令逐个删除。这对于有用户滥用公司资源存储不必要内容的情况非常有用。

它是如何工作的…

按名称查找文件和文件夹是我们经常做的事情。例如,假设我们有一个初步的 shell 脚本,它执行备份,并使用特定标准创建一个文件列表,这些文件将被复制到预定的备份文件夹。如果我们在有数百个用户的大型生产服务器上执行这种操作,那么每天可能都会有很多新文件。使用find命令在这种情况下非常有意义。

最常见的情况是,我们使用find命令来查找文件(-type f选项)或文件夹(-type d选项),然后通过使用更多的标准来进一步缩小搜索范围。标准包括修改日期、用户或组所有权、权限——有很多可用的选项。如果我们查看find命令的手册页面,我们会迅速意识到使用find命令时可以覆盖的选项和高级场景有多少。这就是为什么使用find有一个常见的方法——通常从文件类型或扩展名开始,然后通过使用我们提到的其他选项进一步缩小范围。如果我们从这一点开始,我们就能快速得到结果。

我们盘子上的下一个配方与归档和压缩文件及文件夹相关。所以,让我们来学习如何使用tar及其助手工具gzipbzipxzip以及类似命令。

还有更多…

如果你需要了解更多关于 Bash 保留变量和 PS 变量的信息,请参考Find命令的手册页面:man7.org/linux/man-pages/man1/find.1.html

归档和压缩文件及文件夹

高效使用磁盘空间并不是什么新鲜事——这一直都是存在的。是的,我们正处在一个硬盘和其他存储介质容量巨大的时代,但这并不意味着我们可以对它掉以轻心。这也是我们使用归档和压缩几十年来一直在做的事情,今天我们也将继续讨论这个话题。

准备工作

我们需要确保我们的cli1机器已经准备好使用,这将使我们在这个配方中的工作变得更加轻松。

如何操作…

让我们通过另一个基于场景的示例,涵盖所有必要的主题。我们将在配方的第一部分做以下操作:

  • 使用当前文件夹内容创建tar归档文件

  • 使用当前文件夹内容创建tar.gz压缩归档文件

  • 使用当前文件夹内容创建tar.bz2压缩归档文件

  • 使用当前文件夹内容创建tar.xz压缩归档文件

在我们配方的第二部分,我们将提取这些归档文件:

  • 提取tartar.gztar.bz2tar.xz归档文件

  • tartar.gztar.bz2tar.xz归档文件提取到指定文件夹(假设是/tmp/extract

假设我们位于/root目录,并且希望将所有归档文件保存到/tmp目录。这就是我们如何做场景的第一部分:

cd /root
tar cfp /tmp/root.tar .

如果我们要创建tar.gz归档文件,应该这样做:

tar cfpz /tmp/root.tar.gz .

如果我们要创建tar.bz2归档文件,应该这样做:

tar cfpj /tmp/root.tar.bz2 .

如果我们要创建tar.xz归档文件,应该这样做:

tar cfpJ /tmp/root.tar.xz .

我们场景的第二部分是通过打开一个归档文件开始的。与场景中第一个例子相比,我们只需要改变一个tar参数,并去掉命令的最后部分(即当前目录的.)。所以,我们需要这样做(在实际操作中不要这样做;这仅用于演示目的):

tar fpx /tmp/root.tar

或者,我们可能需要这样做:

tar zfpx /tmp/root.tar.gz

或者我们可以这样做:

tar jfpx /tmp/root.tar.bz2

或者我们可以这样做:

tar Jfpx /tmp/root.tar.xz

这个问题在于输出的位置——提取过程的输出将去哪里?因此,正确的做法如下:

cd /tmp
mkdir /tmp/extract
tar fpx /tmp/root.tar -C /tmp/extract

或者,我们可以这样做:

tar zfpx /tmp/root.tar.gz -C /tmp/extract

或者我们可以这样做:

tar jfpx /tmp/root.tar.bz2 -C /tmp/extract

或者我们可以这样做:

tar Jfpx /tmp/root.tar.xz -C /tmp/extract

这再次取决于归档类型。

Tar有许多其他可用的选项,例如,用于操作 ACL 和 SELinux 上下文的选项,如下所示:

  • --acls – 在创建归档时使用 ACL

  • --no-acls – 在创建归档时忽略 ACL

  • --selinux – 在创建归档时使用 SELinux 上下文

  • --noselinux – 在创建归档时忽略 SELinux 上下文

如果我们正在寻找某个特定的内容,检查相应的手册页是非常重要的。我们必须确保这样做。

它是如何工作的…

tar,或称为磁带归档器(Tape ARchiver),已经存在几十年了。它最初的用途是将内容归档到磁带上,这也是它名字的由来。归档,正如手册中所述,就是将多个文件存储到一个单一的文件中。我们使用的所有其他选项都是在过去 40 多年里添加的,因为它是在 1979 年推出的。

就我们示例中使用的参数而言,我们有以下内容:

  • c – 创建归档文件

  • x – 提取归档文件

  • f – 或--file,选择输出归档文件名

  • p – 保留权限的选项

  • C – 选择输出文件夹

  • z – 使用gzip压缩tar归档文件

  • j – 使用bzip2压缩tar归档文件

  • J – 使用xzip压缩tar归档文件

这些是最常用的tar参数,因此我们特别选择了它们来进行本食谱。

本次tar食谱到此结束,我们准备进入本章的最后一个食谱,内容是通过使用systemctl命令来管理服务。我们来进行一会儿的学习吧。

还有更多…

如果你想了解更多关于tar命令的信息,确保参考tar命令的手册页:man7.org/linux/man-pages/man1/tar.1.html

管理服务和目标

管理服务通常是我们需要做的事情之一。例如,当我们安装一个捆绑为服务的新软件时,我们需要能够管理它,以确保它能正常工作。这就是我们在本食谱中要做的工作。我们还将简要描述systemctl配置文件的工作原理,但不会进行长篇大论的讨论,因为重点是食谱本身。不过,我们会确保提供额外的链接,供你深入了解systemd,因为它是一个重要且广泛的话题。

准备工作

我们将使用cli2 CentOS 进行本食谱,以免它觉得被冷落。

如何做到这一点…

管理服务的基本思想是,在我们希望它们启动的时刻启动服务,或者在我们启动 Linux 服务器后使它们可用。

在过去的几个 Linux 发行版更新中,服务和目标的管理变得更加简单。如果你使用 CentOS 已有一段时间,你可能还记得 upstart、init 以及那些已经深深埋藏在我们不太愉快记忆中的东西。在服务管理方面,无论是从管理者还是 开发 的角度(稍后我们会谈到),CentOS 7 使得这件事变得更简单。CentOS 8 也遵循了相同的路径。关于 systemd 的整体想法,总是会有不同的意见,但这不是我们讨论的主题。所以,让我们集中讨论服务和目标。首先,我们以 root 用户登录并输入一些命令,从以下命令开始:

systemctl set-default multi-user.target

这将把我们默认的启动目标切换为多用户模式,这意味着我们的 CentOS 机器将默认以文本模式启动。所以,在重启机器后,它将以文本模式启动。然后,我们将立即切换到使用文本模式:

systemctl isolate multi-user.target

这将结束所有 GUI 进程,检查 graphical.targetmulti-user.target 之间的服务差异,并执行其魔法操作。

接下来我们要做的是,选择一个服务(例如 sshd),并使用 systemctl 命令来管理它——既可以暂时管理(在命令执行时管理其状态),也可以永久管理(管理 sshd 服务在系统启动时的行为)。我们来输入以下命令:

systemctl stop sshd.service
systemctl status sshd.service

这两条命令的结果如下:

图 5.18 – 使用 systemctl 管理服务 – 以 SSH 服务为例

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_5.18_B16269.jpg)

图 5.18 – 使用 systemctl 管理服务 – 以 SSH 服务为例

它告诉我们 sshd.service 已禁用——活动状态:不活动(死)。我们将启用它并通过输入以下命令检查其状态:

systemctl start sshd.service
systemctl status sshd.service

让我们检查这两条命令的结果:

图 5.19 – 检查配置更改后 SSH 服务的状态

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_5.19_B16269.jpg)

图 5.19 – 检查配置更改后 SSH 服务的状态

我们可以看到,sshd.service 现在是活动状态,并且准备好接受网络连接。

另一个方面是配置服务,以便它在系统启动时启用。如果我们以 sshd.service 为例:

systemctl enable sshd.service

此外,如果我们不希望 sshd.service 在系统启动时启用,我们可以执行相反的操作:

systemctl disable sshd.service

当我们部署一个新服务时,我们可以同时启动并启用它。例如,假设我们刚刚从一个包中安装了 sshd 服务。我们可以通过一个命令来启用并启动它:

systemctl enable --now sshd.service

当然,这预设了我们知道任何给定服务的名称,但这并不总是能做到的。在总结表格使事情更容易之前,让我们学习如何通过文本模式解决这个问题。

如果我们想列出所有可用的服务,可以使用以下命令,因为 systemdsystemctl 知道 的对象不仅仅是服务(这不是本场景的主题):

systemctl list-units --type=service
-----
.......
.... part of the output ommited ....
systemd-journal-flush.service loaded active exited  Flush Journal to Persistent Storage
systemd-journald.service      loaded active running Journal Service
systemd-logind.service        loaded active running Login Service
systemd-machined.service      loaded active running Virtual Machine and Container Registration Service
systemd-modules-load.service  loaded active exited  Load Kernel Modules
systemd-random-seed.service   loaded active exited  Load/Save Random Seed
systemd-remount-fs.service    loaded active exited  Remount Root and Kernel File Systems
systemd-sysctl.service        loaded active exited  Apply Kernel Variables
systemd-sysusers.service      loaded active exited  Create System Users
systemd-tmpfiles-setup-dev.service    loaded active exited  Create Static Device Nodes in /dev
systemd-tmpfiles-setup.service        loaded active exited  Create Volatile Files and Directories
systemd-udev-settle.service   loaded active exited  udev Wait for Complete Device Initialization
systemd-udev-trigger.service  loaded active exited  udev Coldplug all Devices
systemd-udevd.service         loaded active running udev Kernel Device Manager
systemd-update-done.service   loaded active exited  Update is Completed
systemd-update-utmp.service   loaded active exited  Update UTMP about System Boot/Shutdown
systemd-user-sessions.service loaded active exited  Permit User Sessions
tuned.service                 loaded active running Dynamic System Tuning Daemon
udisks2.service               loaded active running Disk Manager
upower.service                loaded active running Daemon for power management
user-runtime-dir@0.service    loaded active exited  /run/user/0 mount wrapper
user-runtime-dir@42.service   loaded active exited  /run/user/42 mount wrapper
user@0.service                loaded active running User Manager for UID 0
user@42.service               loaded active running User Manager for UID 42
vdo.service                   loaded active exited  VDO volume services
vgauthd.service               loaded active running VGAuth Service for open-vm-tools
vmtoolsd.service              loaded active running Service for virtual machines hosted on VMware
wpa_supplicant.service        loaded active running WPA supplicant

在我们讨论之前的某个食谱时,我们提到了 vdo。我们可以清楚地看到这里列出了 vdo 服务。记得我们是通过使用 systemctl 命令启动它的吗?

如果我们想查看所有启用的服务列表,可以使用以下命令:

systemctl list-units --type=service --state=enabled

如果我们需要查看当前运行的服务列表,可以执行以下命令:

systemctl list-units --type=service --state=running

由于 systemctl 命令的工作方式及其相关的配置文件(稍后在 它是如何工作的… 部分中会涉及),它还可以列出服务的依赖关系。例如,sshd 服务需要启动一些其他服务才能正常工作。让我们列出 sshd 的依赖关系:

systemctl list-dependencies sshd

因此,让我们创建一个包含一些常见服务名称的表格,以便我们能够更高效地管理这个问题:

表 5.1 – 关于服务和 systemd 服务名称的详细表格

表 5.1 – 关于服务和 systemd 服务名称的详细表格

我们可以使用这些服务的短名称(不带 .service)以及 Tab 键,通过 Bash shell 补全功能来浏览 systemctl 中的选项和服务名称。我们还可以屏蔽 systemd 服务,从而通过将它们从服务启动的角度链接到 /dev/null 来使它们对系统不可见:

systemctl mask cups.service
Created symlink /etc/systemd/system/cups.service → /dev/null.

在这里,我们可以了解所有这些是如何工作的。显然,systemctl 命令使用了一些配置文件来完成它的工作。现在,让我们讨论一下是如何做的,以及做了什么。

我们可以写关于 systemd 的书,但考虑到这一特定场景,我们需要专注于当前的任务。我们使用 systemctl 命令来管理服务——现在(启动/停止/重启),以及永久(启用/禁用)。

它是如何工作的……

从纯粹管理服务的角度来看,systemctl 命令通过检查其配置文件来执行工作。那么,让我们再次以 sshd 为例,来查看 systemd 服务文件的结构:

图 5.20 – Systemd 配置文件 – 以此示例为例,SSHD

图 5.20 – Systemd 配置文件 – 以此示例为例,SSHD

它几乎可以在没有过多解释的情况下阅读,这是这些服务文件与过去使用的文件之间的一个重大区别。

第一部分,从 [Unit] 部分开始,涉及服务的一般设置——描述和手册页文档是其中的第一部分。然后是一个语句告诉我们启动顺序;也就是说,应该在这些服务之后启动此特定服务。Wants 与依赖关系相关——在这种情况下,哪些目标需要启用才能成功启动此服务。

[Service] 部分稍微复杂一些,它告诉我们一些基本的配置细节和启动选项(如 EnvironmentFile 选项),应该使用哪些命令来启动和重新加载服务,如何杀死服务,以及与重启相关的细节。重启是用来选择服务在超时、杀死或退出进程服务时是否会重启的选项。RestartSec 指定服务重启前的等待时间。

[Install] 部分更为全局,涉及 systemd 如何与该单元协同工作。WantedBy 用于创建此特定服务与其他服务之间的额外依赖关系,与 [Unit] 语句的作用完全相反。

这就是为什么当我们更改或创建新的 systemd 单元/服务/任意文件时,我们必须使用 systemctl daemon-reload 命令。该命令指定 systemctl 会遍历所有配置文件,并将它们视为 是的,管理员可能已更改其中的某些文件,但这是故意的,没问题

还有更多内容……

鉴于 systemd 及其内部机制的重要性,如承诺的那样,接下来我们为读者提供一些与 systemd 相关的附加内容:

  • systemd 入门: https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/configuring_basic_system_settings/introduction-to-systemd_configuring-basic-system-settings

  • 使用 systemctl 管理系统服务: https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/configuring_basic_system_settings/managing-system-services-with-systemctl_configuring-basic-system-settings

  • 使用 systemd 目标: https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/configuring_basic_system_settings/working-with-systemd-targets_configuring-basic-system-settings

  • 使用 systemd 单元文件: https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/configuring_basic_system_settings/assembly_working-with-systemd-unit-files_configuring-basic-system-settings

  • 优化 systemd 以缩短启动时间: https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/configuring_basic_system_settings/optimizing-systemd-to-shorten-the-boot-time_configuring-basic-system-settings

  • 揭开 systemd 的神秘面纱: https://www.youtube.com/watch?v=tY9GYsoxeLg

第六章:基于 Shell 的软件管理

通过网络复制内容通常是一个手动过程——例如,我们可以使用 scpFTP 传输文件,仅此而已。但如果我们需要将此过程变为常规操作呢?我们就需要找到一种方法来执行文件/目录同步,这正是 rsync 的作用。话虽如此,考虑到过去 5 年以上的安全事件,实施某种加密始终是一个好主意,所以使用 sshscp 看起来是合理的做法。这正是我们接下来要做的。

在本章中,我们将学习以下主题:

  • 使用 dnfapt 进行包管理

  • 使用额外的仓库、流和配置文件

  • 创建自定义仓库

  • 编译第三方软件

技术要求

对于这些教程,我们将使用两台 Linux 机器——可以使用我们之前教程中的cli1cli2虚拟机。这些教程在 CentOS 和/或 Ubuntu 上都能进行,因此没有必要为这些场景使用不同的虚拟机。

那么,让我们启动虚拟机,开始吧!

使用 dnf 和 apt 进行包管理

包和包组是两种不同的软件部署方式,用于将软件安装到我们的 CentOS 和 Ubuntu 虚拟机上。一个包就是一堆可以自动化安装到我们机器上的文件,无需人工干预。包组更多是 RedHat/CentOS 的概念。顾名思义,它们是将多个包分组的一种方式,方便我们一次性安装多个包,而不需要手动指定每一个包。让我们学习如何利用它们来达到我们的目的,特别是在部署方面。

准备就绪

我们将继续使用 cli1cli2 机器进行本教程,请确保它们已经开机并准备就绪。我们将使用 cli1 进行 apt 部分的操作,使用 cli2 进行 yum/dnf 部分的操作,因为 cli1 基于 Ubuntu,而 cli2 基于 CentOS。

如何操作……

让我们从 yumdnf 在 cli2 上的基本操作开始。首先,我们列出系统上所有可用的包:

yum list

输出应如下所示(已简化):

图 6.1 – 简化的 yum 列表输出

图 6.1 – 简化的 yum 列表输出

我们已经在 图 6.1 中简化了此截图,因为它包含了成千上万的包。这个输出中有三列。按照从左到右的顺序,第一列是包的名称,第二列是包的版本,第三列是该包所在的包仓库。

如果我们想了解更多有关某个包的详细信息,可以使用 yum info(或 dnf info),例如:

图 6.2 – 获取包信息

图 6.2 – 获取包信息

通过使用此命令,我们可以获取更多关于该软件包的信息。另外,请注意,我们没有在软件包名称中使用x86_64,因为它不是必须的。考虑到我们使用的是 64 位发行版,可以理解在软件包名称中使用架构几乎总是不必要的。

现在让我们安装一个软件包,例如mc(Midnight Commander):

图 6.3 – 安装软件包

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

图 6.3 – 安装软件包

Linux 软件包系统的美妙之处在于此处显现出来。它不仅仅是关于软件包可以毫不费力地安装——依赖项也会默认安装,这非常有用。在过去,当我们只能使用rpm命令在 CentOS 中安装软件包时,解决依赖关系要困难得多。我们必须先部署它们,然后再部署我们想要部署的软件包,而且必须按特定顺序,这使得部署过程更加复杂。

我们可以使用以下命令来删除该软件包:

dnf -y remove mc

如果我们想查找哪个软件包安装了特定文件,可以使用yum providesdnf provides命令:

图 6.4 – 检查哪个软件包安装了特定文件

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

图 6.4 – 检查哪个软件包安装了特定文件

如果我们需要查找软件包依赖关系(哪个软件包依赖于哪个软件包),可以使用以下命令:

图 6.5 – 检查软件包依赖关系

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_6.5_B16269.jpg)

图 6.5 – 检查软件包依赖关系

我们在这个示例中使用了bash,但我们可以使用任何软件包名称来进行查询。

我们还可以使用dnfyum来本地下载和安装软件包。假设我们想要在本地下载并安装joe编辑器,我们可以这样操作:

图 6.6 – 从本地磁盘手动下载并安装软件包

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_6.6_B16269.jpg)

图 6.6 – 从本地磁盘手动下载并安装软件包

我们当然可以使用yum searchdnf search命令来搜索软件包:

图 6.7 – 使用 yum/dnf 搜索命令

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_6.7_B16269.jpg)

图 6.7 – 使用 yum/dnf 搜索命令

有时,这些软件包的列表可能会非常长,因此可能需要额外的筛选。

现在让我们谈谈软件包组,从dnf grouplist命令开始:

图 6.8 – 使用 dnf group list 命令可以得到软件包组列表

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_6.8_B16269.jpg)

图 6.8 – 使用 dnf group list 命令可以得到软件包组列表

该命令的输出将显示我们可以用于更大规模软件包部署的软件包组名称。例如,让我们检查如果我们安装开发工具软件包组,执行以下命令时会发生什么:

dnf groupinstall "Development Tools"

此命令将询问我们是否要下载并部署超过 100 个软件包。如果我们回答,那么这正是即将发生的事情(截图故意渲染得较小,仅显示命令输出的结尾):

图 6.9 – 安装软件包组

图 6.9 – 安装软件包组

如我们所见,能够部署软件包组大大提高了包的部署速度。

我们过程中的下一步是覆盖我们在这个配方中涉及的所有内容,但将其应用于 Ubuntu。所以,让我们切换到我们的 cli1 机器并从头开始。首先,我们来描述几个我们感兴趣的命令:

  • apt-getapt:用于安装、移除、升级和更新包的命令

  • apt-cache:主要用于搜索和查找有关包的信息

现在让我们学习如何使用它们。首先,我们将讨论常规操作——安装、移除、清除、更新和升级。我们来安装一个包,例如 mc

图 6.10 – 使用 apt-get 安装包

图 6.10 – 使用 apt-get 安装包

现在,让我们移除它:

图 6.11 – 使用 apt-get 移除包

图 6.11 – 使用 apt-get 移除包

我们可以看到,我们处于标准情况——包移除,但它的一些依赖项没有被移除。我们也可以使用 apt-get autoremove 命令来完成这项工作:

图 6.12 – 移除不再需要的包

图 6.12 – 移除不再需要的包

这非常有用,因为我们通过删除不必要的软件包来减少服务器的攻击面(防止安全漏洞)。

现在让我们检查一下使用 update 选项时会发生什么:

图 6.13 – 更新仓库和软件包信息

图 6.13 – 更新仓库和软件包信息

如我们所见,apt 在升级过程之前刷新了软件包列表——这些步骤大多数是顺序执行的——update 后接 upgrade

图 6.14 – 升级可用的包 – 这次不需要升级

图 6.14 – 升级可用的包 – 这次不需要升级

有趣的是,没有包被安装,这种情况其实非常罕见。通常,我们至少会有一些包需要升级。

注意

在我们进入 dist-upgrade 主题之前,我们绝对推荐在生产服务器上使用此操作。使用 dist-upgradedo-release-upgrade 是我们可以做的,但不应该做。迁移总是一个更好的选择,无论它花费多少时间。

现在让我们通过尝试做dist-upgrade,然后再做do-release-upgrade来将这种情况推向极限。dist-upgrade apt选项的作用理论上很简单——它尝试准备当前的发行版,使其能够升级到分支中的最新版本。起初,它可能只会获取几个新的软件包。通常,这些软件包包含新的仓库和位置信息,apt将从这些位置升级我们的发行版到最新版本。以下是一个例子:

图 6.15 – 使用 dist-upgrade 获取新发行版版本信息

图 6.15 – 使用 dist-upgrade 获取新发行版版本信息

接下来的步骤是使用do-release-upgrade,这是一个独立的命令,不是apt的子命令。我们需要记住,这不是apt选项(没有apt do-release-upgrade,它只是do-release-upgrade)。执行后,系统会询问我们是否继续进行发行版升级:

图 6.16 – 使用 do-release-upgrade,不建议在生产环境中使用

图 6.16 – 使用 do-release-upgrade,不建议在生产环境中使用

如果我们确认,过程就会开始,并且会持续一段时间。最终结果应该是一个完全更新到最新版本的 Ubuntu 系统,所有的包都更新到最新版本。记住,我们特别提到过,这不应该在生产环境中进行——这只是一个使用apt能力进行系统范围包升级的极端例子。如果我们这样做,可能会更新数百甚至数千个软件包,而且这个过程不可逆转,因此风险很大。最好在一些测试虚拟机上练习一下。如果成功,这个过程将升级到最新的 Ubuntu 版本。写作时,最终结果看起来是这样的:

图 6.17 – do-release-upgrade 的最终结果,在我们的经验中,这次我们运气不错!

图 6.17 – do-release-upgrade 的最终结果,在我们的经验中,这次我们运气不错!

注意,我们的 Ubuntu 系统已经升级到最新的(21.04)版本。

还有一些重要的apt命令——例如,进行包搜索时,我们可以使用apt-cache showpkg package_name命令。让我们举个例子,使用它来查看我们之前安装的mc包:

图 6.18 – 使用 apt-get 获取包信息

图 6.18 – 使用 apt-get 获取包信息

如果我们使用apt命令,有一个稍短的版本来完成相同的事情:

图 6.19 – 使用 apt 获取包信息 – 略短且更简洁

图 6.19 – 使用 apt 获取包信息 – 略短且更简洁

如果我们需要添加仓库,可以使用 add-apt-repository 命令。假设我们想要添加一个非官方仓库,如 个人软件包档案PPA),它托管在 Launchpad 上。一般来说,我们应该只添加 有信誉的 仓库,而不是因为某个仓库中有我们需要的包就随便添加。我们在这里用一个例子 – 假设我们需要在 Ubuntu 机器上安装最新的 PHP 7.4 版本。我们可以这样做:

apt-get install software-properties-common
add-apt-repository ppa:ondrej/php
apt-get update
apt-get install -y php7.4

如果我们从 shell 启动php,应该会得到以下结果:

图 6.20 – 使用 ppa 仓库部署最新 PHP 7.4 版本的最终结果

图 6.20 – 使用 ppa 仓库部署最新 PHP 7.4 版本的最终结果

这涵盖了我们在 Ubuntu 和 CentOS 中需要的所有命令。现在让我们解释一些背景信息,关于一些更重要信息的存储位置 – 适用于 CentOS(dnf/yum)和 Ubuntu(apt/apt-get)。

它是如何工作的…

yumdnf 与位于 /etc/yum.repos.d 仓库文件和 /etc/yum.conf 配置文件的文件协同工作。我们已经涵盖了仓库文件,现在让我们来讨论 /etc/yum.conf 以及我们可以从中使用的几个重要配置选项。这是 dnfyum 命令的全局配置文件。

在这个配置文件中,我们可以管理一些非常有用的配置项。让我们通过两个常用的例子来说明这一点。让我们向其中添加这两个选项:

exclude: kernel* open-vm*
gpgcheck=0

通过这两个命令,我们指示 yum/dnf 在任何操作中(如 yum 更新,它会更新机器上的所有包)排除所有 kernelopen-vm 包(按名称)。gpgcheck=0 设置了一个全局策略,告诉 yumdnf 在处理包时 使用 GPG 密钥检查。这也可以在 /etc/yum.repos.d 中进行管理,正如我们在本配方中所讨论的。

Ubuntu 有一个非常相似的原理;只是目录结构和文件结构有所不同。关于软件仓库位置的最重要信息保存在 /etc/apt目录下,具体来说,是在/etc/apt/sources.list` 文件中。以下是摘录:

图 6.21 – 主要的 apt 配置文件,名为 sources.list

图 6.21 – 主要的 apt 配置文件,名为 sources.list

一般结构足够简单。我们 apt 命令的第二部分位于 /etc/apt/sources.list.d 目录下。几步前,我们添加了 PPA 仓库,果不其然,我们在这里有一个用于该仓库配置文件的配置文件,叫做 ondrej-ubuntu-php-groovy.list

图 6.22 – 额外的 apt 配置文件位于 /etc/apt/sources.list.d

图 6.22 – 额外的 apt 配置文件位于 /etc/apt/sources.list.d

这涵盖了我们的软件包和软件包组的配方。接下来让我们进入下一个配方,关于使用模块和模块流。

还有更多……

如果你需要更多关于 CentOS 和 Ubuntu 网络的资料,确保查阅以下资源:

  • https://access.redhat.com/sites/default/files/attachments/rh_yum_cheatsheet_1214_jcs_print-1.pdf

  • https://fedoraproject.org/wiki/Yum_to_DNF_Cheatsheet

  • https://packagecloud.io/blog/apt-cheat-sheet/

使用额外的仓库、流和配置文件

仓库是最重要的对象/位置,因为它们为我们提供了可以在 CentOS 机器上安装的软件包和软件包组。现在让我们学习如何通过使用yum-config-managerdnf来管理仓库。同时,让我们了解一些对这个过程至关重要的配置文件。

在软件包组的基础上,dnf 引入了附加模块化的概念。这一切都是关于软件包的组织——我们希望有简单的方式来部署软件——运行时、应用程序、软件的零件。这些概念还使我们能够控制我们想安装的软件的版本,这非常方便。例如,假设你需要在机器上部署 PHP 7.2 和 7.3。手动操作可不会有多么愉快。正如我们通过示例展示的那样,如果使用模块流,这将变得更加简单。

配置文件作为准仓库存在,实际上并不是仓库,httpd模块中有几个配置文件(minimaldevelcommon)。minimal 配置文件意味着只安装运行httpd所需的最小软件包。与之不同的是,common 是一个默认配置文件,已准备好生产使用,并且在安全性方面有额外的处理(加固)。

准备工作

启动在前面的示例中创建的cli2虚拟机。我们将用它来在 CentOS 机器上操作流和配置文件。

如何做……

为了管理仓库,我们需要学习使用两个命令——yum-config-managerdnf。此外,我们还需要查看/etc/yum.conf文件,以及/etc/yum.repos.d目录。yum.conf提供了全局的yum命令配置选项,而/etc/yum.repos.d目录包含了仓库位置的配置文件。

它是如何工作的……

让我们先来看一下yum-config-manager。这个命令是在 Red Hat Enterprise Linux/CentOS 7 中引入的,用来方便地将额外的仓库添加到你的 Red Hat Enterprise Linux 或 CentOS 机器上。它的功能就是让我们跳过繁琐的手动仓库配置,直接开始工作。如果没有这个命令,我们就需要学习/etc/yum.repos.d目录文件的配置选项。

如果我们访问第一个安装的虚拟机(源),并列出/etc/yum.repos.d目录的内容,这就是我们将看到的内容:

图 6.23 – /etc/yum.repos.d 目录内容

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_6.23_B16269.jpg)

图 6.23 – /etc/yum.repos.d 目录内容

假设我们想要将一个自定义仓库 url 添加到我们的机器中。我们可以通过三种不同的方式来完成这项操作。第一种方法是使用 yum-package-manager,该工具需要 yum-utils 包(url 是我们想要使用的仓库地址):

yum -y install yum-utils
yum-config-manager --add-repo url 
yum-config-manager --enable repo

我们还可以使用以下命令检查当前已配置的仓库列表:

yum repolist all

如果我们需要查找当前被禁用(未使用)仓库的列表,可以使用以下命令:

yum repolist enabled

如果我们需要启用一个已禁用的仓库,可以使用以下命令(repository_id 是你可以从 yum repolist all 命令中获取的参数):

yum-config-manager --enable repository_id

使用 yum-config-manager 时最明显的问题是,某些参数无法通过该命令本身分配。这时,手动编辑 /etc/yum.repos.d 配置文件就显得非常有用。

这个命令正在逐步淘汰,并重定向到新的 dnf 命令(dnf config-manager),就像 yumdnf 命令是并行使用的。如果我们想使用 dnf 工具完成相同的工作,可以这样做:

dnf config-manager --add-repo url

这将会在 /etc/yum.repos.d 目录下创建一个新的配置文件,并默认启用该仓库。

接下来的步骤是了解这些配置文件,它们其实并不难理解。我们可以用一个仓库配置文件来解释它们的概念,例如 /etc/yum.repos.d/CentOS-Sources.repo

图 6.24 – /etc/yum.repos.d/CentOS-Sources.repo 文件的一部分

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_6.24_B16269.jpg)

图 6.24 – /etc/yum.repos.d/CentOS-Sources.repo 文件的一部分

让我们解释这些配置参数:

  • [BaseOS-Source] 是仓库 ID。我们在 yum-config-managerdnf 中使用它来引用仓库。

  • name 参数是该仓库的描述。

  • baseurl 参数描述了这个仓库的地址,它可以使用多种不同的协议:httphttpsftpfile。如果我们创建一个本地仓库(将其挂载到 CentOS 机器上的某个位置),则将使用 file 协议来访问它。

  • gpgcheck 参数告诉 yum/dnf 是否检查 gpg 密钥与软件包签名的一致性。如果它是 1,则表示检查是强制性的。

  • enabled 参数告诉 yum/dnf 这个仓库是否启用,这意味着 dnf/yum 是否可以使用它来获取软件包。我们也可以通过 yum --enablerepo 启用某个定义的仓库,或者通过 yum --disablerepo 禁用它。

  • gpgkey 参数告诉 yum/dnf gpgcheckgpg 密钥存放位置。

现在让我们继续讨论流和配置文件的概念,这是我们操作的逻辑下一步。

在登录到源机器后,让我们通过一个示例来描述流和配置文件到底是什么。因此,让我们使用一个模块流和配置文件来删除并重新部署 httpd。第一步,我们将使用以下命令:

dnf -y remove @httpd

过程完成后,让我们做相反的操作:

dnf -y install @httpd

现在让我们检查第二个命令的输出:

图 6.25 – 使用流和配置文件

图 6.25 – 使用流和配置文件

我们可以看到,部署过程自动默认使用了 httpd/common 配置文件和默认流 (AppStream) 仓库。

让我们做另一个示例。我们可以使用以下命令查看所有可用模块的列表:

dnf module list

这将为我们提供以下结果:

图 6.26 – dnf 模块列表,包含版本和配置文件;简化输出

图 6.26 – dnf 模块列表,包含版本和配置文件;简化输出

假设我们想安装 container-tools 版本 2.0。我们可以这样做:

图 6.27 – 使用 dnf 命令部署特定模块版本

图 6.27 – 使用 dnf 命令部署特定模块版本

如您所见,这个操作的结果将非常庞大。有时,当我们从模块和流中部署一组包时,我们的机器将部署数百个包。因此,准备好等待一段时间,看是否会发生这种情况。

在我们的一个示例中,我们使用默认的配置文件和流部署了 httpd 包。每个流都可以有多个配置文件,供我们方便使用。如果一个流有多个配置文件,可以将其中一个设置为默认配置文件(并标记为默认)。这不是强制性的,但这是一个好习惯。

在模块方面,已有超过 60 个模块可用,其中包括 Python、PHP、PostgreSQL、nginx 等多个版本,可以列举一些常用服务。我们可以使用该列表中的模块进行部署。此外,命令的输出还会提供有关配置文件的详细信息,我们可以根据这些信息部署特定的模块配置文件。

通过使用这些功能,我们可以将部署特定包的方式模块化。通过流和配置文件进行模块化的总体思路是不错的,尽管在升级方面有些笨拙且未完成。话虽如此,它仍然是未来会存在的一种方法,因此学习它是值得的。

我们暂时完成了高级仓库管理的部分。接下来我们学习如何创建自定义仓库。

创建自定义仓库

有时,创建自己的私有包仓库是必要的。不管是什么原因——没有网络访问、低部署速度——这都是一种完全正常的使用模式,全球范围内都经常使用。我们将为 CentOS 和 Ubuntu 提供示例,以涵盖大多数 Linux 管理员所需的一切。让我们挽起袖子,开始吧!

做好准备

保持cli1虚拟机开机,让我们继续使用我们的终端。通过标准命令确保所需的包已安装。于是,让我们使用以下命令:

dnf -y install vsftpd createrepo lftp

这应该是所有的准备工作,所以让我们开始吧。

如何操作……

设置一个自定义 CentOS 仓库其实是相当简单的。第一步是下载一些包。我们将下载一些包,并将它们放在同一个目录中。然后,我们将通过vsftpd使该目录在网络上可用。关于vsftpd的更详细说明,可以在本书的下一章中找到,下一章讲解的是基于网络的文件同步。在这里,我们将通过vsftpd进行一个一级方程式资格赛圈,以创建仓库。

假设我们想要创建一个本地仓库(托管在我们的cli2机器上),其中将包含两个包——joe编辑器和desktop-backgrounds-basic。我们需要将它们放入一个目录/var/ftp/pub/repository,这样它们就能方便地存放在vsftpd文件夹结构中。我们可以这样做:

图 6.28 – 下载一些包并为仓库配置做准备

图 6.28 – 下载一些包并为仓库配置做准备

由于我们在本章的介绍中已经安装了createrepo包,我们只需使用createrepo命令来创建必要的清单信息:

图 6.29 – 从包含 RPM 包的目录创建仓库

图 6.29 – 从包含 RPM 包的目录创建仓库

下一步是允许通过vsftpd使用此目录。再一次,我们已经安装了vsftpd,并且默认情况下,我们只需要在其配置文件中更改一个选项来允许匿名 FTP。让我们打开配置文件/etc/vsftpd/vsftpd.conf,并找到需要修改的选项:

anonymous_enable=NO

并将其更改为以下内容:

anonymous_enable=YES

现在,我们可以启动并启用该服务:

systemctl restart vsftpd
systemctl enable vsftpd

接下来,我们尝试登录以验证一切是否就绪:

图 6.30 – 检查 vsftpd 配置是否有效

图 6.30 – 检查 vsftpd 配置是否有效

从服务角度来看,一切现在都已准备就绪。接下来,我们只需要告诉yum/dnf它们需要使用这个仓库。于是,让我们进入/etc/yum.repos.d目录,并在其中创建一个仓库配置文件。假设我们将其命名为localrepo.repo。名称无关紧要,关键是它必须以.repo结尾。接下来,我们添加以下选项并保存:

[MyLocalRepo]
name=My Local Package Repository
baseurl=ftp://localhost/pub/repository
enabled=yes
gpgcheck=no

让我们验证一下这个仓库定义是否现在有效。我们需要使用yumdnf来验证,使用repolist关键字:

图 6.31 – 通过仓库配置文件检查我们的仓库是否已正确配置

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_6.31_B16269.jpg)

图 6.31 – 通过仓库配置文件检查我们的仓库是否已正确配置

正如我们所看到的,MyLocalRepo已定义并准备好使用。让我们通过尝试安装desktop-backgrounds-basic软件包来进行测试:

yum -y install desktop-backgrounds-basic

这应该是最终结果:

图 6.32 – 从我们的自定义仓库安装软件包

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_6.32_B16269.jpg)

图 6.32 – 从我们的自定义仓库安装软件包

我们可以清楚地看到相关信息——所使用的仓库名为MyLocalRepo,因此vsftpd和我们的仓库配置文件都能正常工作。

注释

Ubuntu 在自定义仓库方面通常更为丰富,例如,托管在 launchpad.net 上的仓库等。它还比 CentOS 更依赖互联网,但话虽如此,在这两个发行版上创建仓库都很容易。

让我们简要解释一下这一切在 CentOS 中是如何工作的,然后是时候进行另一个方法的学习了!

它是如何工作的……

这个方法有两个方面:

  • 理解创建仓库的工作原理

  • 从服务和yum/dnf的角度理解使用自定义创建仓库的工作原理

和往常一样,我们需要理解这两个概念,以便让它们为我们工作。让我们从仓库创建部分开始。

显然,在创建仓库时,我们必须有一些包用于该仓库。所以,一个合乎逻辑的第一步通常是创建一些包或下载它们。这里有一个关键点——如果你在没有下载其依赖项的情况下下载一些包,可能会遇到问题。我们故意选择了两个没有依赖项的软件包,这样我们就有一些东西可以开始工作。这个问题可以通过以下两种方式解决:

  • 我们下载所有必要的依赖项,以便为我们正在创建仓库的软件包使用。

  • 我们设置了仓库,以便某个其他仓库包含我们为其创建仓库的软件包所需的所有依赖项。

一般来说,通过仅启用适用于我们 CentOS 版本的 EPEL 仓库,我们就可以大大简化解决这个问题的过程。因此,我们通常应该安装 EPEL rpm,因为它能帮助我们处理几乎所有依赖关系:

yum -y install epel-release

接下来,所有的工作就变成了创建一个目录,将软件包放进去,然后使用 createrepo 来生成必要的 XML 文件,这样带有软件包的目录就能作为一个仓库使用。如果没有 createrepo,我们会遇到错误,因此我们应该在使用自定义仓库之前,始终先安装并使用它。

第二个方面涉及到更广泛的图景——也就是说,如何使该仓库对网络上的其他机器可用,并如何配置这些机器以使用它。这就是为什么我们战略性地选择了 vsftpd 作为传输服务,因为它在这种场景下的配置非常简单。我们本来也可以使用 Apache Web 服务器,但考虑到我们下一章的内容与 vsftpd 相关,我们觉得通过实践操作 vsftpd 来作为对它的入门介绍会很有趣。

这个过程的一部分是从仓库客户端的角度来操作 repo 文件——也就是所有将要使用我们自定义仓库的机器。这些文件只需要配置几行,涵盖仓库的唯一名称和描述、位置以及一些通用设置,例如是否启用该仓库,以及我们是否使用签名包并验证其签名。通常,人们往往忽视这一部分,尽管它非常重要。如果我们启用了 gpgcheck 选项,我们还需要安装一个仓库用来签署其包的 gpg 密钥。我们可以通过下载 gpg 文件后,使用 gpg --import file_name.gpg 命令来完成此操作。

现在,让我们准备进入本章的最后部分,这部分内容完全是关于如何从源代码编译软件。我们将使用一些熟悉的、常见的工具来完成这项任务,并在过程中学习如何操作。

还有更多内容……

如果我们需要了解更多关于 vsftpd 的内容,确保查看以下链接:

编译第三方软件

有时候,某个应用程序的包根本不可用——要么是没有人愿意创建它,要么是该应用程序太旧,已经过时且没有人愿意再做。无论哪种情况,如果某个应用程序对我们有用,我们完全可以去尝试找到它的源代码并进行编译。

从源代码编译软件有时就像黑魔法一样,而我们很快就会给出一个很好的例子。有时候它可以毫不费力地工作,我们也将展示这样的一个例子。两种情况之间的主要区别似乎在于至关重要的依赖项及其版本。另外,很多 Linux 软件需要按照特定顺序进行编译。一个典型的例子就是 LAMP 堆栈。安装 Linux 后,如果你想编译 Apache、MySQL 和 PHP,最好按正确的顺序进行。否则,你的键盘可能比你计划的更早送进垃圾桶。让我们看看如何避免这种情况发生。

准备就绪

我们可以使用任何机器来执行这个过程,但最常见的情况是默认安装了某个 Linux 发行版,且缺少许多软件包。所以,让我们安装一台全新的 Ubuntu 机器,并给它取个名字compile1,纯粹为了好玩。这台机器将仅仅是一个原生的 Ubuntu 安装,需要进行所有配置才能完成编译过程。

如何操作…

我们从一个简单的例子开始,这是一个非常容易编译的包,不会让我们头疼。我们将编译joe编辑器,并展示我们所说的内容。我们从常规程序开始:

apt-get -y update
apt-get -y upgrade

为了保险起见,为了让我们的机器准备好进行编译过程,让我们使用这个命令安装大量的软件包:

apt-get -y install autoconf g++ subversion linux-source linux-headers-'uname -r' build-essential tofrodos git-core subversion dos2unix make gcc automake cmake checkinstall git-core dpkg-dev fakeroot pbuilder dh-make debhelper devscripts patchutils quilt git-buildpackage pristine-tar git yasm checkinstall cvs mercurial

结果是,我们的 Ubuntu 机器现在应该已经准备好进行任何编译工作。接下来让我们下载joe源代码:

wget https://kumisystems.dl.sourceforge.net/project/joe-editor/JOE%20sources/joe-4.6/joe-4.6.tar.gz

我们喜欢保持根目录下的文件整洁,所以让我们创建一个用于编译的文件夹。我们叫它source,然后将joe源代码移到那里,再打开它的tar.gz文件以查看源代码:

mkdir source
mv joe-4.6.tar.gz source
cd source
tar zfpx joe-4.6.tar.gz 

最后一个命令(tar)将会打开另一个子文件夹(joe-4.6),其中包含所有必要的编译文件。现在,让我们切换到 joe-4.6 目录并开始配置过程:

cd joe-4.6
./configure

如果一切顺利,我们应该得到如下结果(由于格式原因,已简化):

图 6.33 – 配置步骤成功完成

图 6.33 – 配置步骤成功完成

配置过程已经成功完成。现在,让我们继续实际的编译过程,我们需要使用make命令(这也是我们安装所有这些包的原因之一,make就是其中之一),并且我们可以使用一些额外的选项来加速这个过程。我的 Ubuntu 机器有四个处理器,因此我们可以使用make -j4来加速进程(这样编译过程将使用所有可用的核心,而不仅仅是一个核心)。几秒钟后,编译过程应该会类似于这样完成:

图 6.34 – 编译过程也成功结束

图 6.34 – 编译过程也成功结束

这个过程的最后一步是安装我们编译的应用程序。我们可以通过使用以下命令来完成:

make install

在该命令完成并将joe系统范围安装后,我们应该能够从命令行启动joe并编辑我们的文件。我们还可以使用checkinstall包创建一个deb安装包。当我们运行它时,它会要求我们输入包描述。我们可以输入类似Joe editor v4.6的描述,然后完成安装。经过这个过程,我们将获得一个deb包,其中包含在其他 Ubuntu 服务器上部署joe所需的安装文件。

这还不算太糟糕,是吧?是的,我们做了几个步骤,但总体来说,这还是一个非常简单的过程。

现在让我们做一个与我们所说的非常简单的过程完全相反的例子。我们来尝试编译 Apache 网络服务器。我们将使用写作时的最新版本(2.4.49),该版本位于https://dlcdn.apache.org//httpd/httpd-2.4.49.tar.gz,并按照相同的程序操作——将源代码下载到我们的源代码目录,打开源代码归档文件,然后开始配置过程。让我们看看会发生什么:

图 6.35 – 配置脚本执行过程 – 缺失依赖 – 示例 1

图 6.35 – 配置脚本执行过程 – 缺失依赖 – 示例 1

哎呀!这不可能实现。那么,我们去问问谷歌,看看当收到找不到 APR的消息时该怎么做。最终我们会找到一些文章,说明我们需要安装一些额外的包,因此我们来安装它们:

apt-get -y install libapr1-dev libaprutil1-dev

尝试重新运行configure脚本,并检查结果:

图 6.36 – 配置脚本执行过程 – 缺失依赖 – 示例 2

图 6.36 – 配置脚本执行过程 – 缺失依赖 – 示例 2

另一个包似乎也缺失了。当我们——仅举个例子——尝试在apt缓存中查找libpcre包时,我们会得到如下结果:

图 6.37 – 试图找出缺失的包

图 6.37 – 试图找出缺失的包

现在的问题是如何知道从这个列表中安装哪些软件包?通常情况下,人们会失去耐心,写下这样的命令:

apt-get -y install *pcre* 

这将在我们的机器上安装超过 200 个软件包。如果我们关注安全性,这并不是一个好方法。对于像我们这样做过无数次的人来说,这很容易,但对于普通人来说,这会非常令人沮丧。所以,现在让我们安装所需的软件包及其依赖项:

apt-get -y install libpcre3-dev

在我们进行实际的配置/编译之前,我们必须提一点。如今,许多应用程序代码是通过 Git 等概念共享的。大多数这些存储库都是由应用程序编码者托管的,并且通常具有有关依赖项和如何部署它们的额外说明。然而,如果我们从非 Git 类资源下载源代码,通常在我们解压缩源代码后,可以在诸如INSTALL文件中获取更多有关编译该源代码的信息。因此,在尝试编译应用程序之前,我们需要确保检查这些资源。

依序运行我们的其余步骤:

./configure; make; make install

幸运的是,不会再有更多问题,因为我们可以在下面的截图中看到:

![Figure 6.38 – Compilation and installation completed successfully

![Figure 6.38 – Compilation and installation completed successfully

Figure 6.38 – 编译和安装成功完成

我们特意选择了一个稍微烦人的软件包,但不至于过分烦人。市面上有一些应用程序会让我们花费数小时来解决所有依赖关系,以便编译一个单独的软件包。

工作原理...

现在我们已经完成了逐步流程,让我们讨论具体的工作原理及其如何协调和完美结合。很明显,这个过程包含多个步骤,每一个步骤都非常重要。而且这些步骤是紧密联系的,一个都不能少。所以,现在让我们讨论一下我们使用的所有命令,并描述它们的工作原理。

编译过程中的第一个阶段始于./configure命令。它实际上不是一个具体的命令;它是一个shell脚本,几乎所有的源代码包都会有这个脚本。这个脚本的作用是确保环境准备就绪,用于编译过程 - 检查包含的文件、库、依赖项,以及编译源代码过程所需的一切。它检查必要的编译器及其库,确保为后续过程做好准备。它还会写入一些配置文件,这些文件将在构建过程启动时由make使用。

过程的下一部分涉及使用make命令。通过使用configure脚本创建的配置文件和其他文件,它开始编译源代码。其中一个文件叫做Makefile,它包含了许多关于make需要执行的任务的信息——需要编译哪些文件,如何编译,使用哪些编译器标志,如何将所有编译后的代码链接成最终的二进制文件,等等。

过程的最后部分不是编译源代码本身——而是将编译后的代码安装到我们的 Linux 机器上。通过使用配置文件中的相关信息,make install会安装所有使得我们的命令能够正常运行的文件——库文件、二进制文件、手册页、文档等等。如果前一部分的编译过程成功结束,安装过程就是确保编译好的应用程序能够被使用。

这是本章的最后一个示例。下一章将讨论基于网络的文件同步,在这些示例中,我们将深入探讨vsftpd的内部工作原理,而在本章中我们只是稍微提到了一下,并没有给它太多的时间或篇幅。此外,我们还将讨论sshscp,它们是两种安全连接到服务器并在服务器之间传输文件的方法,以及rsync,一种文件同步方法。敬请期待下一章。

还有更多内容…

如果你需要了解更多关于vsftpd的信息,确保你查看以下链接:

第七章:基于网络的文件同步

通过网络复制内容通常是手动进行的。例如,我们只是使用 SCP 或 FTP 来传输文件,仅此而已。但是,如果我们需要使这个过程变成一个永久性的操作该怎么办呢?这时我们就需要找到一种方法来实现文件/目录同步,这正是 rsync 的用途。话虽如此,考虑到过去几年中的安全相关事件,实施某种加密措施总是明智的做法,因此使用 SSHSCP 似乎是一个合理的选择,这正是我们接下来要做的。

在本章中,我们将涵盖以下主题:

  • 学习如何使用 SSH 和 SCP

  • 学习如何使用rsync

  • 使用 vsftpd

技术要求

对于这些操作,我们将使用两台 Linux 机器——可以使用我们之前章节中的 client1gui1 虚拟机。这些操作适用于 CentOS 和 Ubuntu,因此没有必要为这些场景使用不同的虚拟机。

所以,让我们启动虚拟机并开始吧!

学习如何使用 SSH 和 SCP

在 1990 年代,使用 Telnetrlogin 和 FTP 协议是非常自然的事情。想一想,使用(匿名)FTP 到现在仍然是很常见的。考虑到 1990 年代大多数局域网都是基于网络集线器(而非交换机),以及这些协议都是明文协议,容易通过网络嗅探器进行窃听,实际上我们现在不再频繁使用这些设备和/或协议也并不奇怪。作为书籍作者,我们从 1990 年代末期就没听说过有人在使用 rlogin,尽管 Telnet 仍然广泛用于配置网络设备(主要是交换机和路由器)。这就是 SSH 开发的原因(作为 Telnet/rlogin 的替代品),并且随之而来的是 SCP 的开发(作为 FTP 的替代品)。为了让大家有个大致了解,第一版 SSH 是在 1990 年代中期发布的。让我们来看看它是如何工作的。

准备工作

我们只需要一台 Ubuntu 机器和一台 CentOS 机器来进行这次操作。假设我们将使用 cli1cli2 来掌握这些命令。

如何操作…

我们的第一个场景将是通过使用 SSH 从一台机器连接到另一台机器。我们假设我们没有安装所有必要的软件包——仅安装了足够的基础包。我们知道很多 IT 人员会尽量减少服务器/容器上安装的软件包数量,因此这些额外的步骤应该不会成为大问题。

在基于 Ubuntu 的机器上,我们可以这样做:

apt-get -y install libssh-4 openssh-client openssh-server openssh-sftp-server ssh-import-id

在 CentOS 机器上,我们可以这样做:

dnf install openssh-server

对于这两种情况,如果我们希望永久使用该服务,需要启动服务并启用它:

systemctl start sshd
systemctl enable sshd

作为不安全技术(如 Telnet、rlogin 和 FTP)的替代方案,SSH 使用起来非常简单。我们只需要学习基本的语法。假设我们想从 Linux 主机 cli1 上的 student 用户登录到 Linux 主机 cli2 上的 student 用户。由于我们是从名为 student 的用户登录到同样是名为 student 的用户,因此有两种方法可以实现。这里是第一种方法:

student@cli1:~$ ssh student@cli2

这是第二种方法:

student@cli1:~$ ssh cli2

原因很简单:如果我们登录的是与源 Linux 机器上的用户相同的用户,就不需要明确指定登录的账户。

但是,如果我们想从 cli1 上的 student 用户登录到 cli2 上的其他用户,则必须使用远程用户名作为参数。我们可以用两种方式来实现。这里是第一种方法:

student@cli1:~$ ssh remoteuser@cli2

这是第二个方法:

student@cli1:~$ ssh -l remoteuser cli2

我们可以将这个命令进行一般化,适用于任何远程用户和远程机器。该场景下的命令如下:

ssh remoteuser@remotemachine

或者是这样:

ssh -l remoteuser remotemachine

SSH 堆栈的另一个部分是一个名为 SCP 的命令。我们使用 SCP 通过 SSH 后端(安全拷贝)将文件从一台机器复制到另一台机器。假设我们想将 cli1student 用户的主目录中的文件 source.txt 复制到 cli2student 用户的主目录。我们可以使用以下命令来实现:

scp /home/student/source.txt student@cli2:/home/student 

或者,如果我们已经在源机器上的 /home/student 目录中,我们可以使用以下命令:

scp source.txt student@cli2:/home/student

一般来说,SCP 语法很简单:

scp source destination

只是源和目标可能有很多字母需要输入。我们可以通过另一个有趣的 SCP 使用案例来解释这一点。我们还可以使用 SCP 将文件从远程机器下载到本地机器。语法类似,但在我们第一次使用时可能会有些困惑。所以,假设我们想将 cli2student 用户的主目录中的文件 source.txt 复制到 cli1/tmp 目录中(已登录为 cli1 上的 student 用户)。我们可以使用以下命令来实现:

scp cli2:/home/student/source.txt /tmp

语法遵循相同的规则(scp 源 目标),只是源现在是远程文件,目标是本地目录。当我们考虑到这一点时,这一逻辑就显得合理了。

我们过程中的下一步是安装安全的 Shell 密钥。这意味着——在我们的示例中——我们将启用从一台服务器到另一台服务器的 无密码登录。我们可以避免这样做,但暂时先不讨论这个问题;我们会在接下来讨论它,因为我们现在并不讨论安全问题。我们只是在为从本地用户(假设为 student)到远程用户(假设为 student)的 SSH 和 SCP 环境做好准备。现在,让我们开始吧:

图 7.1 – 创建一个空的私钥的 SSH 密钥

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

图 7.1 – 创建一个空的私钥的 SSH 密钥

现在,让我们将此密钥复制到远程机器(cli2)并通过尝试以该用户身份登录来测试 SSH 密钥复制过程是否有效。对于第一部分,我们将使用名为 ssh-copy-id 的命令(将密钥复制到远程机器),然后使用 SSH 尝试登录以测试 SSH 密钥是否正确复制:

图 7.2 – 将 SSH 密钥复制到远程机器并测试其是否有效

图 7.2 – 将 SSH 密钥复制到远程机器并测试其是否有效

如我们所见,cli1cli2 一切正常。现在让我们反方向重复相同的过程,因为稍后我们需要它来完成本食谱的另一部分。首先,让我们创建一个 SSH 密钥:

图 7.3 – 为 student@cli2 创建 SSH 密钥

图 7.3 – 为 student@cli2 创建 SSH 密钥

然后,让我们将其复制到远程服务器:

图 7.4 – 将 SSH 密钥从 cli2 复制到 cli1 并测试其是否有效

图 7.4 – 将 SSH 密钥从 cli2 复制到 cli1 并测试其是否有效

我们可以看到,在这两个示例中,连接到的远程服务器并没有要求我们输入密码。原因很简单:当我们创建 SSH 密钥时,ssh-keygen 给了我们两个非常重要的输入内容:

Enter passphrase (empty for no passphrase) :
Enter same passphrase again:

如果我们在第一个问题上按下 Enter 键,并在第二个问题上再次按 Enter 键确认,那就意味着我们创建了一个没有私钥的 SSH 密钥。正如我们在示例中所做的那样,我们没有选择任何特定的密码短语,因此将其留空。如果我们想使用自定义私钥,我们只需要在这两步中输入它。

它是如何工作的…

作为一种协议,SSH 是对 Telnet、rlogin 和 FTP 这些不存在安全性的协议的加密回应。这三种纯文本协议很容易被黑客攻击,特别是在我们开始使用网络交换机之前的那个“好日子”(那时我们大多使用的是网络集线器)。它的第一次实现可以追溯到 1995 年。它还可以作为隧道协议使用,过去曾广泛用于此——例如,用于代理 FTP 和 HTTP 流量。如今,它更多用于为远程 X 应用程序(XDMCP)或甚至 SSH 连接到通过基于 SSH 的隧道主机连接的服务器提供隧道服务。

简单来说,SSH 的工作方式如下:

  1. SSH 客户端连接到 SSH 服务器,从而启动连接。

  2. 服务器响应并将其公钥提供给客户端。

  3. 然后,服务器和客户端尝试协商必要的加密参数,接着在服务器和客户端之间打开安全通道。

  4. 应用程序或用户登录到服务器。

对于熟悉 SSL/TLS 的朋友来说,它有点类似这两种协议,因为这些协议都是基于 TCP 的;它们有协商机制,并且通常用于安全目的。是的,它们以略有不同的方式工作,使用场景也略有不同,但这并不意味着它们在总体原理上有很大差异。

我们旅程中的下一站是rsync,并且我们将明确地使用SSH作为rsync的后端。这就是我们制作SSH密钥的原因,尤其是那些没有额外私钥(密码短语)的密钥。现在,让我们学习如何使用rsync

还有更多…

如果你需要更多关于 CentOS 和 Ubuntu 网络的资料,请确保查看以下内容:

学习如何使用 rsync

在我们之前的示例中,我们从客户端的角度使用了 SSH。我们使用 SSH 和SCP同时登录并将文件从源复制到目标。我们讨论了如何使用用户名/密码组合登录远程系统,以及如何使用基于 SSH 密钥的身份验证。如果我们稍微集中讨论 SCP,有一件事情我们没有讨论,那就是如何同步本地源到本地目标,或者更好的是,如何在两个 Linux 服务器之间创建一个同步本地源到远程目标,反之亦然的场景。这时使用rsync,一个专门用于完成这一任务的工具,是最好的选择。让我们开始吧。

准备工作

我们将继续使用我们的cli1cli2机器,分别运行 Ubuntu 和 CentOS。让我们先确保安装了必要的软件包。我们需要使用以下命令来操作 Ubuntu:

apt -y install rsync

我们使用以下命令来操作 CentOS:

dnf -y install rsync

完成后,我们准备开始。

如何操作…

我们将讨论几个场景:

  • 本地源和本地目标之间的同步

  • 本地源和远程目标之间的同步,或反之亦然

可能还会有许多其他子场景,例如处理单向同步和删除源上的文件,rsync只是一个子目录等等。我们将详细处理这两个场景,然后从这些子场景中补充一些细节。

让我们先处理一个简单的场景:如何将一个本地文件夹同步到另一个本地文件夹。假设我们要同步(基本上是创建一个备份)/etc 文件夹,并且我们想将它同步到 /root/etc 文件夹。我们可以通过以下命令作为 root 用户来实现(以 cli1 机器为例):

rsync -av /etc /root 

使用的两个选项,av,分别是为了启用归档模式(保留权限和所有权)和详细模式,以便我们能够看到每次复制操作的输出。我们不需要提前在 /root 目录中创建 /etc 文件夹或将 /root/etc 作为目标文件夹,因为在执行命令时,名为 etc 的文件夹会自动在 /root 中创建。

如果我们想排除某些文件不进行复制(例如,所有以 .conf 扩展名结尾的文件),我们可以这样做:

rsync -av --exclude="*.conf" /etc /root 

rsync 中,还有一些其他很酷的选项可以使某些场景成为可能。假设我们想要复制最大为 5 MB,或最小为 3 MB 的文件。我们可以通过以下语法来实现:

rsync -av --max-size=5M source destination
rsync -av --min-size=3M source destination

例如,如果源目录在第二个示例中有很多大文件(最小大小),我们可能想要为 rsync 命令添加一个 --progress 选项,这样我们就能通过交互式输出看到进度。

现在,让我们处理从远程到本地目标的单向同步。相反的方向几乎是一样的,我们只需要在 rsync 中交换源和目标字段。所以,假设我们在 cli2 上有一个名为 /home/student/source 的源目录。该目录包含文件和子文件夹,它有一个文件和文件夹的层次结构。我们希望将这些内容同步到 cli1,具体来说,同步到 /tmp 目录。下面是我们的源目录内容:

图 7.5 – 位于 /home/student/source 的 cli2 上的源目录

图 7.5 – 位于 /home/student/source 的 cli2 上的源目录

这是我们应该做的,前提是我们已经准备好了源文件:

图 7.6 – 从远程源目录使用 rsync

图 7.6 – 从远程源目录使用 rsync

所以,我们只用了一个简单的命令,rsync -rt-r 表示递归,-t 用来保留时间),将源和目标作为参数,源目录成功地被传输到本地目录。这是因为我们在前面的操作中复制了 SSH 密钥,所以我们不需要进行身份验证,这使得整个过程变得非常简单和直接。

下一个场景将讨论同步源和目标后删除源文件。具体来说,我们是同步 文件而不是文件夹,因为这些场景有不同的选项。让我们看看如何做到这一点:

图 7.7 – 使用 SSH 密钥从远程服务器同步文件,并在下载完成后删除源文件

图 7.7 – 使用 SSH 密钥从远程服务器rsync,并在下载完成后删除源文件

现在,如果我们想要运行相同的场景,但在传输完成后从cli2中删除所有文件和文件夹,我们需要将其分成两个命令。下面是它的工作方式:

图 7.8 – 从远程源目录中删除源文件,然后删除源目录中的所有子目录

图 7.8 – 从远程源目录中删除源文件,然后删除源目录中的所有子目录

现在我们已经展示了这一点,我们还可以注意到其他几个项目,这些项目将使双向同步变得更加容易。像 Unison(www.cis.upenn.edu/~bcpierce/unison/)和bsyncgithub.com/dooblem/bsync)这样的项目已经实现了非常难以通过rsync实现的双向同步方法。如果您需要双向同步,请务必查看它们。

工作原理……

rsync是一种源-目标类型的命令,涵盖了其交互使用时的语法和操作模式(不涉及目标rsync服务)。也可以涉及rsync服务,这通常显著改变了操作模式。重要的是要指出,作为命令使用rsync(结合 SSH)最常用于备份。我们在某些环境中使用这种方式已经超过 15 年了,效果非常完美。

rsyncdrsync服务)通常针对完全不同的使用模型,最常见的是软件镜像。如果我们想创建本地的 CentOS 或 Ubuntu 镜像,一般会使用rsyncd,因为它允许我们在rsync过程中进行更精细的配置。可能还有其他原因,比如我们可以配置rsyncd而不使用SSH,从而提高一些速度。

现在我们已经讨论了 SSH、SCP 和rsync的一些关键概念,是时候继续介绍它们——至少默认情况下——更不安全的表兄vsftpd。尽管如此,我们将确保将其更安全化,因为绝对没有理由不这样做。所以,让我们准备好配置vsftpd

还有更多内容……

如果您需要了解更多关于rsync的信息,我们建议查阅以下链接:

使用 vsftpd

FTP 服务已经存在了几十年。早在 1990 年代中期,FTP 实际上占据了互联网流量的绝大部分。是的,随着时间的推移,它在流量量级上的重要性下降了,但情况并不仅仅如此。FTP 本身就是一个完全开放、明文传输的协议。所有主要发行版中包含的最新版本叫做vsftpd,它已经存在了十多年。我们将在本篇中关注三种场景:让vsftpd正常工作、让vsftpd与用户的主目录一起工作,以及——最后但同样重要——通过实施 TLS 和证书来使vsftpd变得更加安全。让我们开始吧!

准备工作

保持cli1cli2虚拟机开机,并继续使用我们的 Shell。让我们通过使用标准命令来确保必要的包已经安装。对于 Ubuntu,可以使用以下命令:

apt -y install vsftpd

对于 CentOS,我们可以使用以下命令:

dnf -y install vsftpd

然后,我们启用它们并启动服务。我们将使用 Ubuntu 机器来展示vsftpd配置应该如何设置,但在 CentOS 上几乎是 100%相同的。所以,cli1(Ubuntu)将作为vsftpd服务器,cli2(CentOS)将作为FTP客户端。那么,让我们在cli1上运行这些命令:

systemctl start vsftpd
systemctl enable vsftpd

配置防火墙允许连接到必要的 FTP 端口(20, 21)是明智之举。所以,在cli1上,我们需要执行以下操作:

ufw allow ftp
ufw allow ftp-data

在客户端(cli2)上,让我们使用以下命令安装lftp,一个既简单又好用的ftp客户端:

dnf -y install lftp

现在,让我们根据我们提到的三种场景来配置vsftpd

如何操作…

现在我们已经安装了必要的包,是时候在cli1上开始配置vsftpd了。这意味着我们需要查看/etc/vsftpd.conf中的一些选项(通常,在 CentOS 上是/etc/vsftpd/vsftpd.conf)。

通常来说,这个配置文件本身就有很好的文档说明,因此我们应该不会在配置它以适应我们的需求时遇到任何问题。默认情况下,它应该允许我们使用FTP客户端连接,但从一开始我们就做一些改变。让我们允许匿名 FTP 并禁止本地用户登录。如果我们检查配置文件,这意味着我们需要配置anon_rootanonymous_enablelocal_enable这几个选项,所以我们就这么做。让我们确保这两行配置看起来是这样的:

anonymous_enable=YES
local_enable=NO
anon_root=/var/ftp

我们还需要创建一些目录,以确保此配置能够正常工作:

mkdir -p /var/ftp/pub
chown nobody:nogroup /var/ftp/pub

重新启动vsftpd服务,以便它能够与最新的配置一起工作:

systemctl restart vsftpd

cli2上,我们已经安装了lftp,它默认会尝试匿名登录到远程 FTP 服务器(cli1)。让我们来看看它是如何工作的:

图 7.9 – 使用 lftp 测试 FTP 连接

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_7.9_B16269.jpg)

图 7.9 – 使用 lftp 测试 FTP 连接

我们可以看到没有错误,但在匿名 FTP 服务使用的目录中我们没有任何内容。在 Ubuntu 中,该目录位于/srv/ftp,但我们已经将匿名根目录更改为/var/ftp。让我们在这里添加几个文件,并尝试在lftp中列出目录内容:

图 7.10 – 检查我们是否能看到通过 touch 命令在 cli1 上创建的文件

图 7.10 – 检查我们是否能看到通过 touch 命令在 cli1 上创建的文件

现在让我们尝试下载这些文件。为此,FTP 有一个叫做get的命令(类似于 HTTP 的get命令)。现在让我们下载这些我们用touch命令创建的四个文件:

图 7.11 – 使用 FTP 的 get 命令从 FTP 服务器检索多个文件

图 7.11 – 使用 FTP 的 get 命令从 FTP 服务器检索多个文件

如果我们想上传文件,我们需要使用put命令,但当然,这不会生效,因为默认情况下匿名上传是禁止的(这是应该的)。

我们场景的下一部分是允许用户登录到用户的主目录。这应该不会太难,因为我们已经提到过需要更改的第一个选项,local_enable,它需要设置为YES。之后,我们需要重启vsftpd服务。完成后,我们需要以本地用户身份登录到 FTP 服务器。考虑到我们有一个叫做student的用户,我们就用这个账户登录:

图 7.12 – 通过 lftp 以 student 用户身份登录(使用-u 选项)

图 7.12 – 通过 lftp 以 student 用户身份登录(使用-u 选项)

到目前为止没有问题。但是所有这些步骤的前提是我们在一个内部的安全网络范围内进行操作。如果我们的 FTP 服务器需要暴露到互联网上会怎样?我们不想仅仅使用普通的明文 FTP,因为那样会带来灾难。所以,我们的下一步是将 FTP 配置为使用 TLS。

我们需要在vsftpd.conf中配置几个选项,我们可以自由地将这些选项放在文件的末尾:

rsa_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem
rsa_private_key_file=/etc/ssl/private/ssl-cert-snakeoil.key
ssl_enable=Yes
ssl_tlsv1=YES
ssl_sslv2=NO
ssl_sslv3=NO
ssl_ciphers=HIGH
force_local_data_ssl=YES
force_local_logins_ssl=YES
ssl_request_cert=NO
allow_anon_ssl=YES

我们需要根据我们的安全要求配置这些选项。最常见的是,我们希望启用 TLS 1.2 或 1.3(ssl_ciphers=HIGHSSLv2v3=no)。我们总是可以不允许匿名用户使用SSL,如果我们不想运行基于客户端证书的认证,我们必须确保使用ssl_request_cert=NO选项。

在此配置的开始部分,我们可以看到cert文件以及相应的私钥配置选项。我们只是使用了内置的自签名证书。当然,我们也可以创建 Let's Encrypt 证书,或者购买商业证书并将其放入这里的配置中。这完全取决于我们想要运行此类配置的企业安全策略。

关于 Windows 上的 FTP 客户端的简要说明:许多人使用 WinSCP 通过 SCP、SFTP、FTP、WebDav 和 Amazon S3 等协议上传和下载文件与目录。如果我们使用 WinSCP,我们需要根据情况使用 FTP 配置、TLS/SSL 显式加密以及其他相关参数。如果我们点击1.2,还可以选择最小版本和最大版本。但如果我们已经按推荐设置了vsftpd.conf,则无需调整这些选项,因为TLS v1.2将是唯一可用的选项。我们只是想提到这些高级选项,以防万一你需要它们。

话虽如此,这里有一张截图,能帮助了解基本选项:

图 7.13 – 如何连接到启用 TLS 1.2 的 vsftpd

图 7.13 – 如何连接到启用 TLS 1.2 的 vsftpd

192.168.0.16cli1机器的 IP 地址。通过使用之前提到的所有选项,我们可以匿名登录到vsftpd服务器并使用它,就像在进行 TLS 配置之前一样。但是,考虑到过去几年中SSL协议遭遇了成百上千种不同类型的攻击(如 POODLE、BEAST、CRIME、BREACH、Heartbleed、SSL Stripping、使用不受信任的虚假证书颁发机构等),我们必须密切关注每一个新出现的攻击,并采取一切必要措施来减轻这些威胁。

它是如何工作的…

vsftpd是 FTP 的一种实现,意味着它是一个基于 TCP 的服务,用于上传和下载文件。由于它是一个基于 TCP 的服务,这意味着它涉及套接字连接和可靠的数据传输,这对该服务至关重要。想象一下,如果我们的文件下载或上传不可靠,我们肯定不希望发生这种情况。如果我们通过使用 TLS 为其增加一层额外的安全性,我们仍然在使用相同的基本服务,只是它将更加受保护。

FTP 使用端口 20ftp-data)和 21ftp)。这两个端口需要通过防火墙放行,以便 FTP 服务能够正常工作。端口 21 用作命令通信通道,而端口 20 用于数据传输,尽管也有一些实现中端口 21 被同时用于两者。使用 FTP 服务时有其他选项(主动 FTP 和被动 FTP),但这些超出了本书的范围。一般来说,几乎每个人如今都使用 SCP 进行文件上传和下载是有原因的。此外,绝大多数的发行版仓库和镜像站点也转而使用基于 HTTPS 的交付方式而非 FTP。虽然也有例外情况,但这些更多是例外情况,绝不是标准做法。

FTP 使用 putget 命令来完成其基本功能:上传(put)和下载(get)。这两个是 FTP 使用的基本命令/方法,尽管我们也可以通过 FTP 创建和删除内容。

还有更多

如果你想了解更多关于vsftpd的信息,确保查看以下链接:

第八章:使用命令行查找、提取和操作文本内容

操作文本是全职系统管理员的日常工作。这可能出于多种原因——例如,你可能只是想找到你在某个配置文件中看到的某个服务选项,却记不起该配置文件的名字。你知道吗,那种星期一早晨,你还没喝上两杯提神饮料,CPU 还没正确启动的时候?或者,也许当你在处理一个包含大量内容的文本文件时,但需要做一些特定的修改,比如将一些配置选项从关闭改为打开、从真改为假、从 0 改为 1 等等。本章将作为后续章节讨论 Shell 脚本示例的前奏。

在本章中,我们将学习以下内容:

  • 使用文本命令合并文件内容

  • 将 DOS 文本转换为 Linux 文本,反之亦然

  • 使用 cut

  • 使用 egrep

  • 使用 sed

技术要求

对于这些操作,我们将使用一台 Linux 机器——我们可以使用之前食谱中的 client1。实际上,使用哪台虚拟机并不重要,因为我们在这些食谱中讨论的所有命令在所有 Linux 发行版上都以相同的方式工作。

使用文本命令合并文件内容

我们先从简单的开始——即合并文件内容。当然,这里我们只讨论文本内容,因为合并二进制文件是没有意义的。我们的目标是学习如何使用两个命令——pastecat——来做一些简单的事情,比如拼接和逐行合并。让我们开始吧!

准备工作

我们只需要一台 Ubuntu 和一台 CentOS 机器来完成这个操作。在这里,我们将使用 cli1cli2 来掌握这些命令。

如何操作…

从本章最简单的命令开始——cat——我们来看一些它的使用示例。如果我们输入类似 cat filename.txt 这样的命令——前提是存在名为 filename.txt 的文件——我们将看到该文件的内容。我们来看看这个例子:

图 8.1 – 在文本文件上使用  命令

图 8.1 – 在文本文件上使用 cat 命令

因此,我们使用 cat 命令显示位于 /var/log 目录下的 auth.log 文件的内容。如果我们已经使用这台机器一段时间,可能会有其他以 auth.log 为前缀、后跟一个数字和 gz 扩展名的文件。我们来检查一下:

图 8.2 – 查找我们将要使用的内容

图 8.2 – 查找我们将要使用的内容

所以,为了这个教程的目的,我们使用auth.logauth.log.1文件。如果我们想要一个文件同时包含auth.logauth.log.1的内容,会发生什么呢?我们可以打开文本编辑器进行复制粘贴,或者使用cat命令来完成。cat命令可以同时处理多个文件,比如cat auth.log auth.log.1,这会显示第一个文件的内容,然后是第二个文件的内容。我们需要做的唯一事情是将该命令的文本输出重定向到一个新文件,这可以通过使用>符号轻松实现。假设我们想将命令的输出保存到/root目录下的auth-full.log文件中,下面是实现的方法:

图 8.3 – 使用 cat 命令连接文件

图 8.3 – 使用 cat 命令连接文件

cat实际上是逐行显示文本文件,这是我们将在与 Shell 脚本示例相关的章节中大量使用的一个特性。

如果由于某种原因我们想逐行合并文件,可以使用paste命令。让我们来看一下它是如何工作的。由于这些文件太大,我们将创建两个文件。假设第一个文件(命名为first.txt)包含以下内容:

1 today
2 tomorrow
3 someday

第二个文件(命名为second.txt)将包含以下内容:

may be good
may be even better
will be excellent

现在,让我们使用paste命令并查看结果:

图 8.4 – 使用 paste 命令逐行合并文本文件

图 8.4 – 使用 paste 命令逐行合并文本文件

正如我们所看到的,paste命令通过将两个文件并排放置来逐行合并它们。

它是如何工作的……

这两个命令的操作非常简单:

  • 默认情况下,cat会逐行显示文件或多个文件的完整内容。

  • 默认情况下,paste命令是逐行并排合并文件的。

这两种方法在文本处理上完全不同,但都具有实际应用场景。

我们的下一个教程也很简单——当我们将文本文件从 Microsoft 操作系统转移到 Linux 时,如何处理使它们在 Linux 中可用。正如我们将看到的,Microsoft 操作系统和 Linux 之间在.txt格式上存在一些根本性的差异,这使得下一个教程成为必需。敬请期待!

还有更多……

如果你需要更多关于使用catpaste的信息,确保查看以下内容:

将 DOS 文本转换为 Linux 文本,反之亦然

这个想法很奇怪——你可能会认为.txt文件就是.txt文件,对吗?错了。

DOS/Windows 和 Linux 中的 .txt 文件格式之间有细微的差异。有时,这些差异可能让你在几秒钟内抓狂。我们也有过类似的经历——脚本不能正常工作,因为输入文件是在 Windows 而不是 Linux 上准备的;Excel 中的 CSV 文件 按设计 被不同地处理…有时候,经过几个小时的思考,当你意识到像在另一个操作系统上创建的 .txt 文件这样的简单问题造成了这么大的混乱时,真是太好笑了。让我们解释一下问题是什么,并一起来解决它。

准备工作

我们只需要一台 Ubuntu 机器来完成这个教程。假设我们将继续使用 cli1 来掌握这些命令。此外,我们需要安装一个名为 dos2unix 的软件包。所以,如果我们使用的是 cli1(Ubuntu),我们需要输入以下命令:

apt-get -y install dos2unix

安装好这个软件包后,我们就可以开始做我们的教程了。

它是怎么做的…

假设我们在 Windows 上使用 Notepad 创建了一个名为 txtsample.txt.txt 文件,其中包含以下内容:

My first line in a file
My second line in a file
My third line in a file
My fourth line in a file

然后,我们将这个文件上传到我们的 cli1 机器,并在 vi 或 vim 中打开它来检查其内容。它看起来是这样的:

图 8.5 – 我们的文件看起来像什么

图 8.5 – 我们的文件看起来像什么

一切看起来正常,对吧?现在,让我们重新做一遍相同的事情,但这次从 vi 或 vim 中使用 -b 选项启动。例如,使用 vi -b txtsample.txt 命令并检查文件内容。现在应该是这样的:

图 8.6 – 我们的文件实际样子

图 8.6 – 我们的文件实际样子

现在我们可以在 vi/vim 编辑器中看到 ^M 字符。这是 Notepad 和 Linux 文本编辑器处理 .txt 文件时的细微差别之一。Linux 的 shell 命令不一定会以友好的方式处理这种文本,有时脚本因为这些 Linux 命令不需要的 额外 字符而无法正常工作。

这个问题的解决方案是一个简单的软件包和命令,叫做 dos2unix,我们在本教程的 准备工作 步骤中已经安装了它。之后,只需输入以下命令:

dos2unix txtsample.txt

现在让我们在 vi 中用相同的 -b 选项打开这个文件,并检查文件内容:

图 8.7 – 结果 – 一个去除 CR 的文件

图 8.7 – 结果 – 一个去除 CR 的文件

现在好多了。

还有其他类似的例子——文件结束字符,看不见 的字符,有时会在 Excel 导出的 CSV 文件中突然出现。因此,我们必须确保我们意识到这个问题及其简单的解决方法。

我们还可以使用 trawkperl 等工具来做同样的事情。以 tr 为例:

tr -d '\15\32' < input_dos_file.txt > output_linux_file.txt

现在让我们解释一下这个过程是如何工作的,以及它为什么会成为一个问题。

它是如何工作的…

CR 是一个控制字符,它多年来被用来标识行尾,并因此开始新的一行。对于我们这些还记得旧打字机的人来说,打字机上的 CR 就是那个奇怪的杠杆,我们必须拉动它才能跳到下一行。它实际上是 ASCII 码的一部分,用于帮助定位光标(指向下一行的开始位置)。

如果我们不清理.txt文件中的这些字符(以及其他字符),在编写脚本时可能会遇到问题。在本书的最后两章中,我们将提供多个示例脚本,这些脚本使用cat命令从.txt文件中读取内容并传入循环。这些字符可能会干扰这个过程,我们可不希望这样。

dos2unix和前面提到的tr命令可以去除输入文件中的 CR 字符。我们可以辩论哪种方法更好,但最终重要的是结果,两者都能奏效。我们更倾向于使用dos2unix方法;当然,你也可以选择tr方法。

还有更多…

如果你需要更多关于将 DOS 格式.txt文件转换为 Linux 格式的信息,请参考以下链接:

使用 cut

有一些工具在 IT 领域因其出色的功能而被推崇。接下来我们要使用的三个工具正是符合一些最伟大的命令行工具的标准。对我们来说,cut是历史上第二伟大的 CLI 命令;如果你想知道哪个命令摘得第一的位置,请继续关注接下来的教程。

cut是一个非常有用的工具,它能让我们在处理预格式化输入时事半功倍。例如,它可以轻松处理 CSV 格式文件,因为这种格式的内容可以被cut轻松解析。接下来我们将通过一些示例来学习如何使用cut

准备工作

我们只需要一台 Ubuntu 机器来执行这个任务,所以继续使用cli1cut命令是任何 Linux 发行版的标准工具,它的重要性不亚于其他命令,比如lsmkdirps

如何操作…

我们首先创建一个示例 CSV 文件。例如,我们将创建一个包含用户数据的 CSV 文件,并在这个文件上使用cut。以下是我们为这个任务所使用的 CSV 文件内容:

Figure 8.8 – 示例输入 CSV 文件

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_8.8_B16269.jpg)

Figure 8.8 – 示例输入 CSV 文件

现在我们来看看如何使用这个文件和cut命令。首先从一些简单的操作开始。例如,我们将从这个文件中提取出名字,也就是提取第一个字段(第一个逗号前的内容):

Figure 8.9 – 从标准格式文件中提取第一个字段

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_8.9_B16269.jpg)

图 8.9 – 从标准格式文件中提取第一个字段

通过使用 cut 命令和两个开关,我们能够轻松地从 CSV 文件中提取名称。现在,让我们对这个过程再做一点扩展。让我们提取名称和登录名(第一个和第三个字段):

图 8.10 – 从示例文件中提取第一个和第三个字段

图 8.10 – 从示例文件中提取第一个和第三个字段

此外,现在让我们提取前三个字段——姓名、姓氏和登录名:

图 8.11 – 从示例文件中提取字段范围

图 8.11 – 从示例文件中提取字段范围

我们还可以选择按字母顺序对输出进行排序:

图 8.12 – 从切割文件中排序输出

图 8.12 – 从切割文件中排序输出

另一个经典的例子是使用 cut 从某个特定字段开始输出字段——例如,从第二个字段到最后一个字段:

图 8.13 – 从第二个字段开始提取字段

图 8.13 – 从第二个字段开始提取字段

如果你熟悉 Microsoft PowerShell,这个操作可能会让你联想到 Import-Csv PowerShell cmdlet,尽管相似之处仅此而已,因为 PowerShell 是基于对象的 shell 语言。

如你所见,cut 命令在处理一些标准格式的输入文件时非常有用,这些文件使用某些字符作为字段之间的分隔符。我们可以轻松地使用它从文本文件中提取内容,并为后续的操作准备基于文本的输入,这些操作可能会使用 cut 在 shell 脚本中进行。

它是如何工作的…

cut 是一个简单的命令,只有一个前提条件——我们需要有一个格式化为某种标准格式的输入文件。这意味着文件的字段由相同的字符分隔。如果满足此条件,我们可以轻松使用 cut 在一行命令和 shell 脚本中发挥作用。

最常用的是,我们在使用 cut 命令时会使用两个参数:

  • -f 参数用于选择我们要提取的字段或字段范围。

  • -d 参数用于选择分隔符,一个分隔我们文本字符串的字符。

我们还可以将其与其他命令一起使用,如 sorttruniq,对使用 cut 命令提取的输出进行进一步操作。我们甚至可以使用其名为 --output-delimiter 的参数,将输入分隔符更改为其他输出分隔符。

这是一个热身练习,为了之后的明星命令——egrep 命令——做准备,因为它的重要性无法过分强调。接下来我们来讨论 egrep

还有更多…

如果你需要更多关于 CentOS 和 Ubuntu 网络的信息,请确保查看以下内容:

使用egrep

使用egrep和正则表达式通常类似于《如何成为既酷又超级实用的 IT 手册》第一页、第一章的内容,尽管这本书从未写成过。毫无疑问,这是 UNIX/Linux 世界中为系统管理发明的最有用的命令。无论我们是在文件或一组目录中查找特定字符串,还是在大文本文件中找到包含特定字符串的行,或者查找特定字符串不存在的位置,egrep都能做到这一切。我们特别关注egrep,因为它支持该命令背后的两个概念——正则表达式和扩展正则表达式。这就是我们开始的地方——首先解释使用正则表达式的优点,然后进一步解释为什么egrep是如此重要的命令。所以,系好安全带,准备出发!

准备工作

我们将使用两台虚拟机进行这个教程——基于 Ubuntu 的cli1和基于 CentOS 的cli2。这样做可以为我们提供更多的示例,因为 Ubuntu 上的日志配置与 CentOS 略有不同,而 CentOS 的日志配置则更容易帮助我们理解一些要点。所以,请启动这两台虚拟机,准备开始吧。

如何操作…

如果我们花点时间了解如何使用正则表达式,使用它对每个人来说都会变得非常自然。由此,我们与正则表达式的第一个前端就是使用grepegrep命令。这些命令使我们能够在文本文件或文本输入中找到特定的文本字符串。

让我们使用一个简单的例子。在第四章《使用 Shell 配置和故障排除网络》中,我们使用了ps命令来显示正在运行的进程。假设我们现在也想这样做,但是使用正则表达式。例如,我们需要列出在 Linux 服务器上由用户student启动的所有进程。

如果我们首先使用ps命令——例如,使用ps auwwx命令——我们会得到如下输出:

图 8.14 – ps auwwx 输入,因格式化原因被截断

图 8.14 – ps auwwx 输入,因格式化原因被截断

现在,让我们简单讨论一下这只是一个文本输出的事实——它包含了大量的字母和数字。此外,我们还需要关注一个事实,即系统上运行的所有进程在 ps 命令的输出中都由一行表示——这一行,如图所示,以 root, www-data 字符串开头,或者其他代表拥有该进程的用户的字符串。那么,如果我们想办法利用这一点作为属性来过滤 ps 命令的文本输出呢?我们可以通过使用student开头来进行过滤。

如果我们想要做到这一点,我们可以使用一个简单的命令:ps auwwx | grep ^student。正如我们之前讨论的,| 符号表示我们希望将第一个命令的输出传递给第二个命令。此外,grep 表示我们希望过滤某些内容。而这个 ^student 是什么呢?它是我们所称的正则表达式模式,其中 ^ 字符是正则表达式符号。具体来说,它是一个锚点,当与 grep 或其他支持正则表达式的命令一起使用时,意味着行以该字符或其后的一组字符开始。让我们把这个理论付诸实践:

图 8.15 – 使用我们的第一个正则表达式

图 8.15 – 使用我们的第一个正则表达式

好的,我们通过使用 student 字符串作为过滤条件来过滤输出。我们还可以看到每次出现该字符串时,都会用红色标出。这是因为 grep 命令默认使用了 --color 选项——这个选项会高亮显示我们用作过滤条件的字符串。

假设现在我们想要查找所有在 ps auwwx 输出中以 bash 字符串结尾的行。我们可以轻松地使用正则表达式来实现这一点。下面是我们如何操作:

图 8.16 – 使用正则表达式查找位于行尾的字符串

图 8.16 – 使用正则表达式查找位于行尾的字符串

到目前为止,我们已经使用过 ps 命令,因为它方便且熟悉。接下来,我们将转向其他示例,这些示例将以文本文件为基础。首先想到的是 /usr/share/dict/words,这是一个包含超过 100,000 个单词的字典文本文件。该文件的格式是每行一个单词,所以超过 100,000 个单词就意味着超过 100,000 行。我们来尝试找到所有包含 parrot 字符串的行。下面是命令和结果:

图 8.17 – 直接在文本文件上使用 grep

图 8.17 – 直接在文本文件上使用 grep

所以,grep 命令也可以直接用于文本文件,这在处理脚本、输入文件到脚本等情况时非常有用。

到目前为止,一切顺利。让我们把事情变得更复杂一点。假设我们需要找到同一个文件(/usr/share/dict/words)中所有符合以下规则的字符串:

  • 这一行需要以 p 开头。

  • 字母p后面需要跟着一个元音

  • 元音字母后面需要跟着ta字符串。

突然间,事情变得更加复杂。试想一下,必须使用常规文本编辑器来查找这些单词。写下所有这些组合将导致我们找到以下我们正在寻找的单词:

  • pata,后面跟着任何东西

  • peta,后面跟着任何东西

  • pita,后面跟着任何东西

  • pota,后面跟着任何东西

  • puta,后面跟着任何东西

在常规文本编辑器中,我们需要五次不同的连续查找来找到所有单词,即便如此,我们还需要按下一步-下一步-下一步来查找每个出现的地方,以防这些字符串样本可以多次匹配。

这就是正则表达式大有用处的地方。我们可以这样做:

图 8.18 – 查找更复杂的字符串示例

图 8.18 – 查找更复杂的字符串示例

通过使用^p[aeiou]ta正则表达式,我们能够轻松找到所有符合该标准的单词。当我们使用这些方括号输入正则表达式时,实际上是在告诉支持正则表达式的命令去查找字符 a、e、i、o 或 u,作为正则表达式字符串中的一个字符。

正如我们所看到的,了解正则表达式是非常有用的。让我们再添加一些正则表达式,并简要解释一下:

表 8.1 – 常用正则表达式符号及其含义

表 8.1 – 常用正则表达式符号及其含义

我们可以使用更多的正则表达式,但我们先从这些开始,然后再转向更复杂的例子——例如,扩展正则表达式。

那么,如何匹配数字呢?用正则表达式匹配数字可以说是有趣的,特别是当我们需要查找数字范围时——如果是这种情况,事情很快就会变得非常复杂。我们通过三个示例来讨论这个问题——一个是单个数字范围,一个是简单的两位数字范围,还有一个是更复杂的两位数字范围。我们可以推断出这种逻辑如何应用于更大的数字范围。我们从一个简单的例子开始——例如,0 到 5 的数字范围——仅使用正则表达式,暂时忘记grep命令。在正则表达式中,这将是[0-5]

对于下一个例子,我们使用一个简单的两位数字范围——假设是 14-19\。在正则表达式中,我们可以写作1[4-9]

这意味着 1 作为前导数字(十位数),然后是从 4 到 9 的数字范围,即 14-19\。

到目前为止,我们特意选择使用 grep 命令做这些第一个示例,因为它适用于基本正则表达式。如果我们想要使用扩展正则表达式,我们需要在 grep 命令中添加 -E 参数,或者开始使用 egrep 命令。到目前为止,我们已经涵盖了一些基础内容,接下来是让事情变得复杂一些。到目前为止,我们讨论的所有内容中,\?\| 在扩展正则表达式(ERE)中被替换为 ?|。这使得语法更简洁,视觉效果也更好。让我们继续举些例子,首先继续我们的 使用正则表达式匹配数字 的讨论。

如果我们需要找到一个数字范围,例如 37-94,该怎么办?正则表达式不能处理范围内的多位数字——我们需要将其分割为个位、十位、百位等等。然后,我们需要使用一个非常著名的概念——集合的并集(范围集合)来将所有范围组合成一个符合我们想要的正则表达式的范围。记住,我们需要一个集合——在这个例子中是一个数字集合——我们将通过使用扩展正则表达式(ERE)来实现这一点。让我们看看在最小化的前提下是如何工作的——我们希望得到尽可能短的正则表达式。知道我们需要使用集合的并集,最简单的实现这个数字范围的方法如下:

  • 37-39

  • 40-89

  • 90-94

在正则表达式中,这些集合会是如下形式:

  • 3[7-9] 代表 37-39

  • [4-8][0-9] 代表 40-89

  • 9[0-4] 代表 90-94

作为正则表达式,我们会这样写:

3[7-9]|[4-8][0-9]|9[0-4]

这里,| 符号基本上表示 。这是使用正则表达式时实现并集的一种方式。

记住,Ubuntu 版本的 /usr/share/dict/words 文件中没有任何数字,我们在文件顶部添加了一些数字,以便进行测试。例如,我们在文件顶部添加了以下内容:

  • 41

  • 58

  • 36

  • 95

  • 94

我们故意选择了这些数字,因为它们既包含符合我们所做正则表达式的数字(41、58 和 94 会匹配),也包含不符合的数字(36 和 95)。如果我们在这个包含数字的文件上使用带有正则表达式的grep,我们将得到以下输出:

图 8.19 – 使用并集可以找到我们的匹配项

图 8.19 – 使用并集可以找到我们的匹配项

随着我们要查找的数字位数的增加,正则表达式变得越来越复杂。我们应该尽量简化,但在某些情况下,我们必须使用大量并集来找到我们正在寻找的内容。这就是规则,没有什么值得嘲笑的。

我们的下一个例子是查找长度为 19 到 20 个字符的单词(仅限字母),我们依然使用之前的文件,来看如何做到这一点:

图 8.20 – 查找特定长度的单词

图 8.20 – 查找特定长度的单词

很简单,对吧?我们匹配了所有的小写和大写字母,然后说单词需要包含 19 到 20 个这样的字母

当你试图为一些既常见又不同的单词创建正则表达式时,事情往往变得更加复杂,特别是当单词的长度可变时,更加困难的是,如果涉及到数字范围,情况会更加复杂。但是所有这些并没有帮助我们更接近解释正则表达式的实际用途——即其真正的价值所在。到目前为止,我们所做的一切看起来像是一般的“胡说八道”,尝试找一些文本——为什么我们要在乎这些呢? 一般来说,我们只是描述了原理,因为使用通用的示例有助于理解。实际上,通过阅读 10-15 页的文本是绝对无法学会正则表达式的。但现在,我们将努力将这个故事推进到正则表达式在实际生活中的应用。

有一些常见的服务不断使用 grep 和/或正则表达式。我们必须记住,这个食谱是关于 grep 命令的,而不仅仅是正则表达式。

例如,正则表达式在邮件过滤中被广泛使用。检查电子邮件的正文——基本上是文本内容——如果你有一个支持正则表达式的邮件过滤器,这就很容易了。从日常系统管理员的角度来看,正则表达式被不断用于解析日志文件并从中找到有价值的信息。让我们通过对日志文件使用正则表达式来阐明这一点,因为这已经是几十年来最常用的做法之一。

现在让我们切换到 cli2,我们的基于 CentOS 的系统,并使用 /var/log/messages 作为接下来的示例。这个文件包含了 CentOS 上的主要系统日志,因此它非常适合使用正则表达式来查找其中的内容。我们从简单的开始。例如,假设我们需要查找 /var/log/messages 中所有在 10 月 6 日创建的日志条目,更具体地说,是在第九个小时,由名为 PackageKit 的服务创建的日志。首先,我们检查一下这个日志文件的结构 – 它看起来像这样:

图 8.21 – 我们将使用  的文件格式

图 8.21 – 我们将使用 grep 的文件格式

正如我们在之前的食谱中讨论的关于cut命令的内容,我们可以看到这个文件大致上有一个标准的格式。开头是时间戳,之后是主机名,再之后是服务和 PID,最后是某种文本消息。此外,请注意时间戳部分有一个非常酷的附加功能——其格式不是Oct 6,而是两个空格分隔,这非常重要,因为当我们遇到两位数日期(比如Oct 15)时,这种格式能够保持日期长度一致。这样使得格式化更加整齐,非常棒。

所以,简单的事实是,我们可以轻松地通过使用grep来输出这个结果。让我们来做做看:

图 8.22 – 过滤掉我们想要过滤的数据

图 8.22 – 过滤掉我们想要过滤的数据

命令的第一部分从Oct 6中筛选出所有以09开头的时间戳消息;然后,我们将其通过管道传递给另一个grep命令,用于搜索PackageKit字符串。

下一个例子——随着年龄的增长,这种情况变得越来越常见——假设我们记不住文件名,而文件中使用了firewall字符串。我们记得它在/etc/sysconfig文件夹中,但就是记不住文件名——那种星期一早晨,我还没来得及清醒的时刻。我们可以通过使用grep来做到这一点:

图 8.23 – 在一堆文件中同时使用 grep

图 8.23 – 在一堆文件中同时使用 grep

grep选项-r表示递归,而-i选项表示不区分大小写。此外,-r选项会忽略任何符号链接,因为它在递归地遍历子目录时会跳过这些链接。如果我们希望该行为发生变化,可以使用-r选项,这样符号链接就会被考虑进来。由于我们可以递归地搜索文件内容,这意味着grep将深入子目录并浏览所有文件。这也意味着我们必须在讨论中加入一个警告——我们真的,真的不应该在二进制文件上使用它,原因显而易见。如果什么都不发生,它只会让我们的终端输出变得一团糟。

让我们通过使用egrep来结束这个食谱,处理一个更复杂的场景,涉及 ERE 和不同的文本模式匹配,前提是我们希望看到一些上下文,以便更好地理解文本模式的匹配结果。假设我们正在浏览dmesg启动日志,并且我们正在搜索所有硬盘——所有/dev/hd*/dev/sd设备。我们可以使用以下命令来完成这个任务:

dmesg | egrep -C2 '(h|s)d[a-z]'

让我们查看输出结果会是什么样子的:

[root@cli2 sysconfig]# dmesg | egrep '(s|h)d[a-z]' -C2
[    0.000000] Linux version 4.18.0-305.17.1.el8_4.x86_64 (mockbuild@kbuilder.bsys.centos.org) (gcc version 8.4.1 20200928 (Red Hat 8.4.1-1) (GCC)) #1 SMP Wed Sep 8 14:00:07 UTC 2021
[    0.000000] Command line: BOOT_IMAGE=(hd0,msdos1)/vmlinuz-4.18.0-305.17.1.el8_4.x86_64 root=/dev/mapper/cl-root ro resume=/dev/mapper/cl-swap rd.lvm.lv=cl/root rd.lvm.lv=cl/swap rhgb quiet
[    0.000000] Disabled fast string operations
[    0.000000] x86/fpu: Supporting XSAVE feature 0x001: 'x87 floating point registers'
--
[    0.000000] Built 1 zonelists, mobility grouping on.  Total pages: 1032024
[    0.000000] Policy zone: Normal
[    0.000000] Kernel command line: BOOT_IMAGE=(hd0,msdos1)/vmlinuz-4.18.0-305.17.1.el8_4.x86_64 root=/dev/mapper/cl-root ro resume=/dev/mapper/cl-swap rd.lvm.lv=cl/root rd.lvm.lv=cl/swap rhgb quiet
[    0.000000] Specific versions of hardware are certified with Red Hat Enterprise Linux 8\. Please see the list of hardware certified with Red Hat Enterprise Linux 8 at https://catalog.redhat.com.
[    0.000000] Memory: 3113700K/4193716K available (12293K kernel code, 2225K rwdata, 7712K rodata, 2476K init, 14048K bss, 256384K reserved, 0K cma-reserved)
--
[    0.023000] ... event mask:             000000000000000f
[    0.023000] rcu: Hierarchical SRCU implementation.
[    0.023375] NMI watchdog: Perf NMI watchdog permanently disabled
[    0.037028] smp: Bringing up secondary CPUs ...
[    0.037823] x86: Booting SMP configuration:
--
[    2.384538] scsi 2:0:0:0: Attached scsi generic sg0 type 0
[    2.384599] scsi 1:0:0:0: Attached scsi generic sg1 type 5
[    2.391073] sd 2:0:0:0: [sda] 41943040 512-byte logical blocks: (21.5 GB/20.0 GiB)
[    2.391149] sd 2:0:0:0: [sda] Write Protect is off
[    2.391150] sd 2:0:0:0: [sda] Mode Sense: 61 00 00 00
[    2.391304] sd 2:0:0:0: [sda] Cache data unavailable
[    2.391305] sd 2:0:0:0: [sda] Assuming drive cache: write through
[    2.401493]  sda: sda1 sda2
[    2.404998] sr 1:0:0:0: [sr0] scsi3-mmc drive: 1x/1x writer dvd-ram cd/rw xa/form2 cdda tray
[    2.405000] cdrom: Uniform CD-ROM driver Revision: 3.20
[    2.406103] sd 2:0:0:0: [sda] Attached SCSI disk
[    2.413125] usb 2-2.1: new full-speed USB device number 4 using uhci_hcd
[    2.417859] sr 1:0:0:0: Attached scsi CD-ROM sr0
--
[    4.345611] usbcore: registered new interface driver btusb
[    4.396658] intel_pmc_core intel_pmc_core.0:  initialized
[    4.421252] XFS (sda1): Mounting V5 Filesystem
[    4.596411] XFS (sda1): Ending clean mount
[    4.706702] RPC: Registered named UNIX socket transport module.
[    4.706703] RPC: Registered udp transport module.

如果我们查看这个输出的结尾部分,可以看到最后两行并不符合我们用来过滤数据的正则表达式。这是因为我们使用了-C2参数与egrep,该选项允许egrep显示模式匹配前后各两行。如果我们希望自定义显示匹配前后的行数,我们可以将该选项分解为-A-B选项(分别表示匹配之后和之前的行数)。grep还有更多选项可供使用,但这些已经足够让我们入门。在下一篇食谱中,我们将介绍更多使用正则表达式的例子,以及一些我们在这里没有涉及到的主题,关于sed命令。

它是如何工作的…

grep是一个模式匹配命令,可以通过多种方式工作——它可以作为一个独立命令,接受文本文件作为输入,也可以作为我们在命令集中的输入管道。它的目的是明确的——在大量文本中查找特定的文本模式。几年前,它的默认输出有所变化,之前它只显示与我们搜索模式匹配的行,而现在,它不仅做到了这一点,还以彩色的方式显示,将找到的搜索模式标记为红色。

grep通过将文本模式搜索的思想融入到一个可编程的命令中来工作,这个命令是 shell 脚本中的常规部分,正如我们将在最后两章通过脚本的实际例子来解释的那样。因此,它是系统管理员工具包中不可或缺的一部分,因为它使我们能够从一个或多个文件中找到重要的文本数据,从而将混乱转化为秩序。

最常用的选项如下:

  • -E(或egrep):默认情况下,grep只识别 BRE。如果我们使用-E参数,它将支持 ERE。

  • -i:不区分大小写的搜索。

  • -v:反向匹配,查找与搜索模式相反的内容;-A-B-C——这些选项提供上下文,显示模式匹配前后 A 行、B 行或 C 行的内容。

  • -n:显示模式匹配出现的行号。

让我们继续使用基于命令行界面的工具来处理文本内容,接下来我们将看看sed命令。这也将为我们提供更多的机会来扩展对正则表达式的理解,因为sed可以使用正则表达式,使它比常规的功能更为强大。

还有更多内容…

如果你需要更多关于 Linux 中grep的信息,可以查看以下链接:

使用 sed

除了我们讨论的文本处理问题,我们还必须讨论一下sed。它是一个解决许多需要快速处理大量文本问题的得力工具。过去几年里,我有不少案例,sed在其中帮了我大忙。例如,我有几个项目需要将 WordPress 网站从一个域名迁移到另一个域名。由于这项工作必须快速完成,测试迁移模块不可行。更简单的办法是直接导出 MySQL 数据库,将domain1改为domain2,然后检查是否有效。后来,我又做了几个类似的项目,这次不仅是域名的变更,还需要更改子域名等。考虑到如果手动处理一个几 GB 大小的数据库可能需要几周时间——是的,sed在这些困境中确实帮了我不少忙。让我们讨论一下使用sed的优点,并通过一些实例来学习。在本书的最后两章,我们还会通过一些基于 WordPress 的实例,快速演示如何使用sed来处理这些任务。

准备工作

继续使用前面配方中的cli2机器。如果它已经关闭,重新启动它,让我们学习如何使用sed

如何操作…

当我们使用cut时,我们处理的是需要转换的标准格式输入。当我们使用grep时,我们只是为了找到某个文本样本而进行搜索。如果我们需要找到某个文本样本并将其替换为其他内容呢?

对大多数用户来说,答案是,我会打开我最喜欢的编辑器,进行查找和替换

没错。这就是sed的主要功能,尤其是当我们处理一个非常大的文件,大小达到几 GB 甚至更大时。它的关键在于能够基于文本模式和正则表达式进行查找和替换,而无需打开编辑器。

你有没有尝试过在记事本或 Wordpad 中打开一个 1GB 的文本文件,进行查找和替换?如果试过,你觉得效果如何?更不用说需要在多个系统上阅读多个大型文件,尤其是在非 Windows 系统上?

让我们通过一些简单的、稍复杂的和更复杂的场景来练习sed。第一个场景将涉及插入和附加一些文本:

echo "This should be first line" | sed 'i\THIS will be the first line '

让我们检查一下这个命令的结果:

Figure 8.24 – 使用 sed 命令的第一个示例

Figure 8.24 – 使用 sed 命令的第一个示例

此命令中的 -i 开关执行内联替换。记住,我们在没有任何额外选项的情况下使用 sed,它并没有进行任何替换,只是在 echo 命令之前插入了该行,尽管逻辑上应该是 echo 命令的输出应该最先出现。

一般来说,sed 具有以下语法:

sed -parameter 'option/sourcetext/destinationtext/anotheroption' sourcetextfile

如我们所见,在之前的示例中,我们在 sed 引号的开始和结束处没有使用任何选项;我们只使用了源文本。sed 还可以用来从文件中提取特定的行。例如,假设我们要从 cli2 CentOS 机器中的 /var/log/messages 文件中提取第 5 到第 10 行:

图 8.25 – 使用 sed 提取文本文件的特定部分

图 8.25 – 使用 sed 提取文本文件的特定部分

sed 的默认操作方式是打印每一行,如果源/目标文本配置中有替换操作,它将打印替换后的文本,而不是打印原始文本。这就是为什么我们使用 -n,因为我们不想打印任何新行,毕竟我们没有做任何替换。'5,10p' 表示打印第 5 到第 10 行。

我们也可以做相反的操作 —— 假设我们想打印文件中的所有行,并删除第 5 到第 10 行。我们可以使用以下命令来实现:

# sed '5,10d' somefile.txt

我们还可以使用 sed 来显示文本文件中不连续的某些行,例如,显示 somefile.txt 中的第 20-25 行和第 40-100 行(每个表达式使用 -e):

sed -n -e '20,25p' -e '40,100p' somefile.txt

但这仅仅是用 sed 做一些非常基础的操作。现在让我们开始使用它来进行我们通常会使用它的任务——字符串替换。

假设我们有一个名为 sample.txt 的文本文件,内容如下:

这台相机会发出一些奇怪的声音。有时它会发出嗡嗡声,有时会尖叫,总是能够发出某种不符合相机特征的高频尖叫声,只有海豚和鲸鱼才能听到。作为相机,它不错;作为一台能录制声音的相机,它没用。所以,我们需要一台相机。

我们的第一个基于 sed 的任务是将所有出现的 camera 替换为 microphone。为此,我们需要使用以下命令:

sed 's/camera/microphone/g' sample.txt

正如预期的那样,camera 被替换成了 microphone,在控制台中显示,最终结果如下:

图 8.26 – 使用 sed 替换单词,但不将这些更改保存到我们的原始文件中

图 8.26 – 使用 sed 替换单词,但不将这些更改保存到我们的原始文件中

如果我们希望将 camera 替换为 microphone,并且希望这些更改保存到原始文件中,我们需要使用以下命令:

sed -i 's/camera/microphone/g' sample.txt

正如预期的那样,最终结果如下:

图 8.27 – 使用 sed 替换单词并将更改保存到原始文件中

图 8.27 – 使用 sed 替换单词并将更改保存到原始文件中

sed选项sg用于搜索一个单词或正则表达式,然后全局替换它。通过使用-i开关,我们使得替换操作在原地进行,这意味着将替换的更改保存到原始文件中。

下面是一些更多的示例:

a) 为每个原始文件中的非空行插入空白行:

sed G somefile.txt

b) 通过创建一个备份文件来保留原始文件内容,并在原始文件中进行内联替换:

sed -i'.backup' 's/somethingtochange/somethingtobechangedto/g' somefile.txt

c) 当practice字符串出现在行中时,将somethingtochange替换为somethingtobechangedto

 sed '/practice/s/somethingtochange/somethingtochangeto/g' somefile.txt

d) 否定前述语句:将somethingtochange替换为somethingtochangeto,并且仅在practice字符串不出现在该行时才进行替换:

 sed '/practice/!s/somethingtochange/somethingtochangeto/g' somefile.txt

e) 我们可以删除与某些模式匹配的行:

 sed '/somepattern/d'

f) 让我们在内联中搜索一个数字,并在数字前面加上货币符号(这里使用了查找数字的正则表达式,并且为了正确引用使用了反斜杠):

sed 's/\([0-9]\)/EUR\1/g' somefile.txt

g) 让我们将/bin/bash替换为/bin/tcsh,在/etc/password中(这里不需要正则表达式,但我们必须使用\字符来正确解释/字符,因为/是一个特殊字符):

sed 's/\/bin\/bash/\/bin\/tcsh/' /etc/passwd

如我们所见,sed是一个非常强大的工具,可以用来进行许多即时的更改。稍后在本书的最后两章中,我们将展示一些在脚本中使用sed的更多示例,特别是使用 Shell 脚本的示例。这将进一步加深我们对sed的理解,以及它在日常工作中的实用性。

它是如何工作的……

作为一个命令行文本替换工具,sed要求我们解释我们想要做的事情。这就是为什么sed命令的结构看起来有点描述性——那是因为它本来就是这样。它还有很多选项和开关,增加了其整体的可用性和可能的使用场景。

基本的命令结构通常是这样的:

sed (-i) 's/something/tosomething/g' filename.txt

或者,可能是这样的:

sed -someoption filename.txt

当然,sed通常在脚本中使用,无论是独立脚本还是作为串行管道的一部分,类似这样:

command1 |( command2 |) sed …..

无论我们如何使用sed,至少学习一些它的开关和设置都是必要的,从最常用的开始——sed表达式中的sg以及作为命令行参数的-i

假设我们有如下命令:

sed (-i) 's/something/tosomething/g' filename.txt

显然,这里有多个重要的选项。正如我们之前提到的,-i选项就是实现我们搜索和替换标准的交互式更改,它将直接在原始文件中应用。如果没有它,我们会看到sed命令的结果输出到屏幕上,基本上是结果写入控制台。引号内的选项——sg——是最常用的sed选项,表示搜索并全局替换,即在整个文件中进行替换。

我们也可以不使用-i选项,做如下操作:

sed 's/something/tosomething/g' inputfile.txt > outputfile.txt

但是,正如你可能想象的那样,这需要更多的输入,一般来说会更复杂。

sed 命令行选项 -n 可以用来抑制终端输出,这也是它经常被使用的原因。如果我们有一个大文本文件需要修改,并且没有使用 -i 选项,那么如果我们不想让控制台被文本数据填满,这可能是我们首选的选项。

另一个非常有用的选项是 -f 选项,它允许我们使用带有替换定义的输入 sed 文件。假设我们运行以下命令:

sed -f seddefinitionfile.sed inputfile.txt > outputfile.txt

我们创建了一个名为 seddefinitionfile.sed 的文件,内容如下:

#!/usr/bin/sed -f
s/something/tosomething/g
s/somethingelse/tosomethingelse/g

我们可以使用这些选项在一个命令中进行多个 sed 转换。我们只需要在文件中创建 sed 定义并使用它。

本书的下一章将向我们介绍 Shell 脚本的世界——整本书的后半部分都将涉及 Shell 脚本。我们将使用到迄今为止讨论过的所有工具,并将它们结合起来创建 Shell 脚本,这些脚本是最常用的基于编程的管理工具之一。休息一下,准备好进行 Shell 脚本编写吧!

还有更多……

如果你需要更多关于 CentOS 和 Ubuntu 网络方面的信息,确保查看以下内容:

第九章: Shell 脚本简介

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

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

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

本章将介绍以下内容:

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

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

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

  • Shell 脚本的基本规范

技术要求

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

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

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

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

编写你的第一个 Bash Shell 脚本

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

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

图 9.1 – 一个典型的 root shell

图 9.1 – 一个典型的 root shell

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

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

准备就绪

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

如何实现……

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

它是如何工作的……

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

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

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

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

还有更多内容…

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

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

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

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

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

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

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

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

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

准备就绪

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

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

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

  • 哪个用户在运行脚本?

  • 这个脚本有什么权限?

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

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

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

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

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

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

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

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

如何做到这一点……

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

  • 定义你的任务。

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

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

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

想想你假设的一些事情:

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

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

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

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

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

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

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

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

重要说明

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

我们将使用什么命令?

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

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

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

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

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

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

它是如何工作的……

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

现在,让我们运行它。

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

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

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

还有更多……

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

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

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

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

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

准备工作

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

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

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

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

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

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

如何操作…

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

它是如何工作的…

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

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

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

还有更多...

Shell 脚本卫生

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

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

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

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

准备工作

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

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

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

如何做到…

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

还有更多内容……

第十章:使用循环

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

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

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

  • for 循环

  • breakcontinue

  • while 循环

  • test-if 循环

  • case 循环

  • 使用 andornot 进行逻辑循环

for 循环

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

准备工作

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

for item in [LIST]
Do
  [COMMANDS]
done

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

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

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

如何操作…

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

它是如何工作的……

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

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

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

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

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

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

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

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

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

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

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

另请参见

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

break 和 continue

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

准备就绪

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

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

如何实现…

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

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

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

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

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

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

它是如何工作的…

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

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

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

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

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

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

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

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

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

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

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

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

另见

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

while 循环

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

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

准备开始

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

如何实现……

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

while [ condition ]; do commands; done

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

while control-command; do COMMANDS; done

它是如何工作的……

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

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

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

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

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

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

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

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

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

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

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

另见

测试-如果循环

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

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

准备工作

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

例如,考虑以下语句:

  • something.txt 文件存在。

  • 数字2大于数字0

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

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

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

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

如何实现...

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

它是如何工作的……

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

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

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

  • -e文件:文件存在。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

另见

case 循环

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

准备工作

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

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

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

如何实现…

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

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

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

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

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

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

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

它是如何工作的……

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

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

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

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

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

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

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

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

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

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

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

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

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

另见

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

使用 ANDORNOT 进行逻辑循环

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

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

准备工作

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

  • &&(逻辑 AND

  • ||(逻辑 OR

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

如何实现……

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

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

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

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

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

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

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

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

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

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

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

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

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

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

它是如何工作的…

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

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

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

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

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

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

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

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

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

另见

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

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

if 语句。

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

  • 使用 shell 变量

  • 在 shell 脚本中使用变量

  • Shell 中的引号

  • 对变量执行操作

  • 通过外部命令使用变量

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

技术要求

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

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

使用 shell 变量

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

准备工作

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

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

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

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

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

如何做到这一点…

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

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

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

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

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

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

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

demo@cli1:~$ VAR1=value

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

demo@cli1:~$ echo $VAR1
value

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

它是如何工作的……

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

参见

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

在 shell 脚本中使用变量

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

准备就绪

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

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

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

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

如何操作……

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

它是如何工作的……

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

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

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

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

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

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

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

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

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

另见

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

Shell 中的引号

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

准备工作

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

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

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

  • 双引号:""""

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

  • 单引号:'

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

  • 反引号:"`"

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

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

如何操作……

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

它是如何工作的……

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

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

另见

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

对变量执行操作

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

准备工作

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

如何做到这一点…

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

它是如何工作的……

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

${VAR:OFFSET:LENGTH}

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

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

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

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

${VAR/PATTERN/STRING}

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

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

另见

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

通过外部命令获取变量

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

准备就绪

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

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

如何实现…

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

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

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

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

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

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

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

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

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

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

我们在这里停止了输出。

它是如何工作的…

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

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

另见

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

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

本章将涵盖以下食谱:

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

  • 将参数传递给函数

  • 局部和全局变量

  • 处理函数的返回值

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

  • 通过函数实现常用过程

技术要求

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

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

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

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

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

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

准备工作

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

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

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

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

function_name () {
<commands>
}

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

function function_name {
<commands>
}

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

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

如何做到这一点……

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

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

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

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

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

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

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

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

它是如何工作的……

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

另见

向函数传递参数

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

准备工作

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

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

如何实现...

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

它是如何工作的……

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

参见

本地变量和全局变量

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

准备开始

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

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

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

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

如何实现……

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

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

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

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

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

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

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

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

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

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

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

它是如何工作的……

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

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

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

参见

处理函数返回值

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

准备工作

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

如何做到这一点…

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

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

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

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

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

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

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

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

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

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

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

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

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

这是一个示例:

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

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

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

它是如何工作的…

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

另请参阅

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

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

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

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

准备工作

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

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

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

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

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

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

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

如何操作……

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

它是如何工作的…

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

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

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

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

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

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

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

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

另见

通过函数实现常用程序

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

准备工作

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

如何做……

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

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

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

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

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

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

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

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

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

现在,这是实际的脚本:

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

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

它是如何工作的……

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

另见

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

第十三章:使用数组

在前一章的某个操作中,我们提到数组是bash支持的复合数据类型之一。我们说过,bash只有两种数据类型(字符串和数字),但如果需要的话,还有其他方式来使用更多的数据类型。数组就是其中之一——我们需要能够使用它,因为我们需要比单一值变量更复杂的东西来解决一些问题。

在本章中,我们将涵盖与数组相关的两个基本操作:

  • 基础数组操作

  • 高级数组操作

你已经可以看到,我们在这里故意做得比较宽泛;数组就是这样——表面上简单,但如果需要使用它们的话,有一些小技巧。

技术要求

和所有涉及脚本编写的章节一样,我们使用的是任何可以工作的机器,像数组这样基础的内容将在所有运行bash的机器上工作。因此,你将需要以下内容:

一个虚拟机VM),安装任何版本的 Linux(在我们的案例中,将使用Ubuntu 20.10

所以,启动你的虚拟机(VM)吧!

基础数组操作

bashbash中的变量看起来很简单,但实际上它们非常具有迷惑性。没有正式的类型声明,或者说基本上没有任何形式的声明。类型由 Shell 本身来确定,我们可以隐式地做很多事情。这一点对于常规变量尤为明显。数组则稍微复杂一些,在使用时会有一些语法上的特殊之处,但它们是一个非常有用的工具。你可能会想,既然它们不过是同一个变量名下存储一个值,为什么我们还要在任何上下文中提到它们呢?嗯,主要原因是我们经常需要这样的结构。很多时候,我们必须存储属于某一数据集合的多个值。通常,这些值可能是一个无序的值列表,适用于我们不在意值的顺序的情况,或者是一个有序的值列表,适用于我们关心顺序的情况。

准备工作

通常,数组被定义为一维索引数组,因为它们在数组的定义中嵌入了明确的顺序。实际上,当我们有任何多值变量时,它可以是有序的也可以是无序的。区别在于我们是否可以定义值声明的顺序。如果我们能存储值,但无法获取存储的顺序,那就叫做无序集合。在bash中,我们只有有序列表,称之为数组。这意味着变量中的每个值不仅有它本身的值,还有一个定义的索引或位置。我们不仅可以添加或移除数组中的值,还可以直接读取其中任何一个值,如果需要的话,还能重新排序。

我们有两种类型的数组可供使用。两者都是有序的,但一种是索引的,这意味着我们有一个数值来定义数组的特定元素。我们还有一种名为关联数组的东西,有时也称为哈希表。这种类型的数组很有用,因为它不使用数值,而是使用字符串键来定义特定的数组元素。我们将详细讨论这两种。

如何做到这一点...

我们需要做的第一件事是声明一个变量:

demo@ubuntu:~/includes$ TEST=(first second third fourth fifth)
demo@ubuntu:~/includes$ echo $TEST
first

我们的开始并不顺利。显然,我们正确声明了变量,因为没有错误,但是一旦尝试打印它,就会遇到问题。以前我们尝试打印数组时也遇到过这种情况。解决方法是使用特殊字符来表示索引,并告诉 bash 我们想要打印的不仅是第一个值,而是数组中的所有值:

demo@ubuntu:~/includes$ echo ${TEST[@]}
first second third fourth fifth

这样做是有效的,因为bash理解需要快速循环并遍历数组中的每个索引,打印所有值。与此相对的替代方法是使用以下方式:

demo@ubuntu:~/includes$ echo ${TEST[*]}
first second third fourth fifth

这与上一条命令完全相同的输出。

这应该让你思考:还有没有其他使用索引的方式? 当然有——我们可以直接访问数组中的单个值:

demo@ubuntu:~/includes$ echo ${TEST[2]}
third
demo@ubuntu:~/includes$ echo ${TEST[4]}
fifth

请注意,这个例子中我们偏移了一个,因为数组的第一个元素的索引是0。关于数组还有一个很少提到的非常有趣的属性。在展示之前,我们需要解释另一种声明数组的方式。

使用简单括号声明变量是最常见的方法,但我们也可以通过直接指定数组中的元素来完成。有趣的是,我们可以按任意顺序进行操作:

demo@ubuntu:~/includes$ ORDER[2]=second
demo@ubuntu:~/includes$ ORDER[3]=third

如果我们现在尝试打印我们的数组,结果会比惊讶更危险:

demo@ubuntu:~/includes$ echo ${ORDER[*]}
second third

我们的数组在索引下存储了两个值。我们故意在索引中犯了一个错误——second的值存储在索引号2下,使其成为第三个数组元素。但我们没有声明数组的第一个元素。我们说前面命令的结果危险是因为从其输出中,您无法看到特定元素的索引,因此您不知道特定值的实际索引是多少。这使得混淆值变得很容易——或者更明显地说,像这样的东西不会返回一个值,尽管我们可能认为它应该返回:

demo@ubuntu:~/includes$ echo ${ORDER[0]}
demo@ubuntu:~/includes$ echo ${ORDER[1]}

这些东西通常是我们需要排除故障的常见错误源,如果使用直接指定值和其索引的语法,尤其复杂。

我们要说的是,除非有特殊原因需要这种方式声明数组,否则不要使用这种方式。否则,以后可能会变得混乱。

还有第三种声明数组的方式,这是最不常用的方法。使用declare语句和-a开关,你可以显式声明某个变量将保存一个值的数组。我们在代码中很少看到这种方式的原因是,当我们使用前面提到的隐式声明时,我们的变量会自动成为适当类型,因此除非你想提高代码的可读性,否则没有必要做两次声明。

这些只是创建和打印普通索引数组的不同方式。我们提到过,还有一种叫做关联数组的数组类型,也称为哈希表、字典或键值对数组。这种类型在bash 4.0 中引入,并且在某些平台上仍然不可用;最著名的是,某些版本的 OS X 需要你升级bash才能使用这种类型。

现实生活中有许多可以被视为值对的事物。像用户名/密码、姓名/地址、姓名/电话号码等,都是自然形成的数据对,通常会在脚本中使用。显然,我们可以使用普通数组来存储这些数据,但为了能够理解哪些值是某一对数据的一部分,我们不仅需要两个单独的数组,还需要花点心思去声明它们的索引,以便我们可以使用相同的索引来获取第一项和第二项值:

#!/bin/bash
#we are declaring two arrays, one for the names, one for the #phone numbers
NAMES=(John Luke "Ivan from work" Ida "That guy")
NUMBERS=(12345 12344 113312 11111 122222)
#now we need to pair them up:
for i in {0..4}
do
              echo Name:${NAMES[i]} number:${NUMBERS[i]}
done

这将给我们一个类似于以下的输出:

demo@ubuntu:~/variable$ bash pairs.sh 
Name:John number:12345
Name:Luke number:12344
Name:Ivan from work number:113312
Name:Ida number:11111
Name:That guy number:122222

如果我们需要打印出所有的数据,这看起来和运行良好。现在想象一下,我们有一些需要搜索的信息——假设我们在寻找某个人的电话号码。如果我们需要使用普通数组来实现,可能会像这样做:

#!/bin/bash
#we are declaring two arrays, one for the names, one for the #phone numbers
NAMES=(John Luke "Ivan from work" Ida "That guy")
NUMBERS=(12345 12344 113312 11111 122222)
#now we need to pair them up:
for i in {0..4}
do
            if [ "${NAMES[i]}" == "$1" ]
                          then
                                       echo Name:${NAMES[i]} number:${NUMBERS[i]}
            fi
done

为了检查所有的值,我们需要遍历一个数组中的每个元素,然后如果找到匹配的项,就打印出对应的对。首先,我们将测试这个:

demo@ubuntu:~/variable$ bash search.sh John
Name:John number:12345
demo@ubuntu:~/variable$ bash search.sh 
demo@ubuntu:~/variable$

虽然这样是可行的,但这并不是正确的方法。这个方法的一些缺点显而易见:

  • 数组是有索引的,因此它可以在不同的索引位置存储相同的内容。

  • 为了找到某个东西,我们需要遍历所有的值。

  • 如果我们弄错了任何一个数组,可能会创建无效的数据。

  • 这个脚本对于一个简单任务来说非常复杂。

我们提到的另一个关联数组类型就是解决这个问题和更多问题的方案。在普通数组中,我们使用数字来索引我们所使用的值。而在这种特定的数组类型中,我们可以使用任何值作为来引用数组中的特定值。

这样做需要显式声明数组的类型,必须使用declare语句和-A选项来完成。要特别注意这个选项,因为它使用了大写字母A,而普通数组是使用小写字母来声明的。虽然你可以通过多种方式隐式声明索引数组,但关联数组只能通过这种方法显式声明。为了完全演示这一点,我们需要先展示如何声明这种类型的数组。除了必须使用declare语句外,我们还需要声明索引,因为它们可以是任何字符串,而不仅仅是数字。输出看起来像这样:

demo@ubuntu:~/variable$ declare -A NAMES
demo@ubuntu:~/variable$ NAMES["John"]=12345
demo@ubuntu:~/variable$ NAMES["Luke"]=12344
demo@ubuntu:~/variable$ NAMES["Ivan from work"]=113312

还有一种方法可以在一行中声明这些数组,通过指定键值对:

demo@ubuntu:~/variable$ NAMES=([Ida]=11111 ["That guy"]=122222)

现在我们已经以两种方式定义了这个数组,让我们做一个小的后续操作,看看我们可以进行哪些其他操作。我们应该能够输出数组的值。首先,我们将尝试使用我们之前在常规数组中使用的方法:

demo@ubuntu:~/variable$ echo ${NAMES[*]}
11111 122222

这当然是一个问题,但我们早已预料到。这个语法是为具有数字索引的数组设计的,并且会被转化成人类可以理解的内容,这意味着:通过使用所有可能的索引,打印出名为 NAMES 的数组的所有值

这里的关键是我们并不是打印某个值的索引,而只是打印该值本身。由于我们在数组中同时使用了键和值,我们需要能够看到某个特定值的键是什么。这可以通过使用for循环来完成,但在此之前,我们有一点需要说明——按照设计,数组有多个值,因此我们不仅可以重新定义整个数组,还可以从中添加或删除元素。我们已经展示过通过创建一个新索引下的新值来添加元素的例子。

所有这些不仅适用于关联数组,也适用于数组的一般情况;然而,我们将仅使用关联数组,以便让你更熟悉这种类型。我们将重复之前的例子,但会做一些改变:

demo@ubuntu:~/variable$ declare -A NAMES
demo@ubuntu:~/variable$ NAMES["John"]=12345
demo@ubuntu:~/variable$ NAMES["Luke"]=12344
demo@ubuntu:~/variable$ NAMES["Ivan from work"]=113312
demo@ubuntu:~/variable$ echo ${NAMES[*]}
113312 12344 12345
demo@ubuntu:~/variable$ NAMES=([Ida]=11111 ["That guy"]=122222)
demo@ubuntu:~/variable$ echo ${NAMES[*]}
11111 122222

发生了什么?首先,我们定义了一个由三个值对组成的数组。我们通过单独声明每个值对来实现这一点。之后,我们使用了另一种数组声明方式,但由于我们基本上是重新声明了该数组,因此我们使用的值完全替换了数组之前的值。我们本该做的是添加这些值。实现这一点有两种方法——一种是重新定义每一对值:

demo@ubuntu:~/variable$ NAMES["John"]=12345
demo@ubuntu:~/variable$ NAMES["Ivan from work"]=113312
demo@ubuntu:~/variable$ NAMES["Luke"]=12344
demo@ubuntu:~/variable$ echo ${NAMES[*]}
113312 11111 12344 12345 122222

现在我们可以看到数组中有了预期的值数量,尽管我们仍然不知道如何打印它们。

另一种方法是通过使用加法。我们将在例子中只更改一个字符来实现:

demo@ubuntu:~/variable$ declare -A NAMES
demo@ubuntu:~/variable$ NAMES["John"]=12345
demo@ubuntu:~/variable$ NAMES["Luke"]=12344
demo@ubuntu:~/variable$ NAMES["Ivan from work"]=113312
demo@ubuntu:~/variable$ NAMES+=([Ida]=11111 ["That guy"]=122222)
demo@ubuntu:~/variable$ echo ${NAMES[*]}
113312 11111 12344 12345 122222

你能注意到变化吗?我们所做的就是在等号前面使用加号操作符,告诉bash我们希望将这些键值对添加到数组中。这种表示法与我们使用NAMES=NAMES+([Ida]=11111 ["That guy"]=122222)完全相同——只是稍微简短一些。

我们需要知道的最后一件事是如何从数组中删除一个值。

解决方案很简单——有一个叫做unset的命令,它可以简单地删除与特定索引或键相关联的值。通常,这个命令用于键值对,因为在这种情况下更为合理,但你也可以在常规数组上使用它:

demo@ubuntu:~/variable$ echo ${NAMES[*]}
113312 11111 12344 12345 122222
demo@ubuntu:~/variable$ unset NAMES["John"]
demo@ubuntu:~/variable$ echo ${NAMES[*]}
113312 11111 12344 122222

现在,我们将解决一个大问题,并重新编写之前的脚本,使用我们唯一的关联数组。

这种做法的原理基于bash如何使用对象集合。我们将从所有的键创建一个这样的集合,然后打印出键和值。要直接访问键,我们可以使用感叹号:

#!/bin/bash
#we are declaring one associative array for pairs of values: 
declare -A PAIRS
PAIRS=(["John"]=12345 ["Luke"]=12344 ["Ivan from work"] =113312 \
["Ida"]=11111 ["That guy"]=122222)
#now we need to get them printed
for name in "${!PAIRS[@]}"
do
              echo Name:"$name" number:${PAIRS["$name"]} 
done

在这个脚本中,你需要注意一些小细节。例如,引用符号非常重要,因为我们的键包含空格。这里的一般规则是,只要你使用任何字符串作为某个值,它应该用引号括起来,以便正确解析空格。

for循环将逐一遍历键,键将作为整个键值对使用,包括空格。某些手册会指出,键必须是一个单词,但官方来说,它可以是任何东西。然而,这种类型变量的使用场景是,在大多数情况下我们将使用一个或两个单词:

demo@ubuntu:~/variable$ bash associative.sh 
Name:Ivan from work number:113312
Name:Ida number:11111
Name:Luke number:12344
Name:John number:12345
Name:That guy number:122222

它是如何工作的……

我们已经展示了数组在实践中的工作方式,如何创建它们,如何从中读取数据,以及如何删除数组中的单个元素。数组与常规变量的一个不同之处在于,变量只持有一个值,因此没有操作可以修改该值。如果我们需要改变它,我们只需重新声明整个值。而在处理数组时,我们处理的是一个数组中的多个值,这仍然意味着我们必须重新声明需要更改的任何值,但我们只是改变多个值中的一个,而不是整个数组。这也是我们有添加和删除元素操作的主要原因。

一些已经熟悉不同编程语言的你们可能会对某些定义方式感到有些困惑,特别是当我们谈论常规数组以及它们如何访问和打印单个值时。最令人困惑的部分可能是你如何完全跳过一段索引区间,却依然能得到一个有效的数组。我们将在下一个食谱中详细讲解这个问题,但bash在某些方面的模糊性是非常不一致的,而这就是其中之一。

关联数组是你不时需要用到的,特别是当你需要处理具有某些属性的对象时。每个键无法存储多个属性,但即便如此,这种情况也创造了一个不错的环境,因为这个单一的值可以是一个索引值,指向不同的数组,存储关于某个特定对象的其他所有信息。

另请参阅

数组很复杂,同时它们的语法也很简单。查看这些链接,你将看到更多的示例:

高级数组操作

现在我们已经完成了基本内容的处理,我们需要向你传授更多关于数组的知识。首先,我们需要做的是给你一些关于如何让你在脚本中创建的数组更加持久化的思路,因此我们需要处理数组的存储和恢复。

这很重要,因为数组可能相当大,具体取决于你创建的脚本。转储和重用脚本中的变量很容易,因为我们可以使用source来声明我们存储在文件中的变量。数组则更加复杂,因为它们可以包含多个变量,有时我们甚至需要从其他数据源创建它们。

准备工作

脚本的问题是它们有时需要处理大量数据。在许多情况下,我们可以使用文件来存储和加载数据。然而,也有一些情况,特别是关联数组,在处理大量数据时是必需的,这时我们需要知道如何将这些数据保存到磁盘并重新使用它。我们将向你展示如何解决这些问题,并在何时使用它提供建议。

如何实现…

在谈论这些内容时,我们总是需要一个示例,来说明某个特性为何有意义。我们处理过的某些bash特性过于通用,因此我们的示例也必须是通用的。而关联数组不同,尽管它们可以用于多种情况,但有些场景如此常见,以至于你会在考虑如何以及为什么使用它之前,就自动开始声明一个数组。

最常见的场景是保存用户设置。

任何处理任务的大型脚本都必须是可配置的。我们提到过,当我们说可以包含文件时,最常见的文件是包含变量的文件,用于存储不同设置的值。

大多数时候,脚本中的所有设置看起来像这样:

USER=demo
CMD=testing
HOSTNAME=demounit

这些设置只是一个示例,但大多数脚本都会在单独的文件中或脚本的开始部分有一个这样的块。

请注意,它们都有KEY=VALUE的格式,看起来像是为联想数组而创建的。话虽如此,我们需要强调的是,使用任何编程语言的特性时,要做最有利于性能和清晰度的选择,而不是仅仅因为你知道在某个特定的情况下使用了这个特性就去使用它。

这就是你需要经验的地方。如果一个脚本有一组设置,在我们最初加载并保存它们时它们就不再改变,那么使用数组毫无意义。如果一个脚本在启动时只定义了少量变量——在这种情况下也没有必要使用数组。

但如果一个脚本在不同的执行过程中有很多变化,并且如果你使用它们来存储一些重要的运行时操作,这些操作不仅在脚本开始时需要,而且在运行过程中也需要,那么数组可能是一个解决方案。

我们的示例将是一个小脚本,它需要记住一些设置,我们将使用数组从磁盘加载它们,使用它们,并稍后将它们存回磁盘。在这个过程中,我们还将对给定的数组执行一些任务,以展示如何在脚本中操作键值对。

但在我们做这些之前,我们需要通过一些高级示例来向你展示一些我们之前略过的内容是如何工作的。

首先,我们将处理使用*@运算符读取数组中的索引和键的区别。我们之前说过,对于一个给定的数组,这两种运算符是不同的遍历所有索引的方式。这里有一个示例来说明这一点:

demo@ubuntu:~/variable$ SOMEARRAY=(0 1 2 3 4 5)
demo@ubuntu:~/variable$ echo ${!SOMEARRAY[*]}
0 1 2 3 4 5
demo@ubuntu:~/variable$ echo ${!SOMEARRAY[@]}
0 1 2 3 4 5

联想数组也一样:

demo@ubuntu:~/variable$ declare -A PAIRS
demo@ubuntu:~/variable$ PAIRS=(["John"]=12345 ["Luke"]=12344 ["Ivan from work"]=113312 ["Ida"]=11111 ["That guy"]=122222)
demo@ubuntu:~/variable$ echo ${!PAIRS[@]}
Ivan from work Ida Luke John That guy
demo@ubuntu:~/variable$ echo ${!PAIRS[*]}
Ivan from work Ida Luke John That guy

到目前为止,结果乍一看是相同的。这就是bash中的一个问题,它有时会让你感到绝望,因为当我们在循环中使用它们时,结果会有很大差异,我们将通过创建一个小的示例脚本来展示这一点:

#!/bin/bash
declare -A PAIRS
PAIRS=(["John"]=12345 ["Luke"]=12344 ["Ivan from work"] =113312 \
["Ida"]=11111 ["That guy"]=122222)
#now we are going to print keys, once using @ and then using \
#*
echo "for first example we are using @ sign"
for name in "${!PAIRS[@]}"
do
             echo Number: ${PAIRS["$name"]}
             echo Name: "$name"
             echo
done
echo --------------------------
echo "then we are using * sign"
for name in "${!PAIRS[*]}"
do
        echo Number: ${PAIRS["$name"]}
             echo Name: "$name"
             echo
done

如果我们运行这个脚本,且这两个表达式返回相同的值集,那么输出应该是相同的。但如果我们真的运行它,得到的结果却是这样的:

demo@ubuntu:~/variable$ bash arrayops.sh 
for first example we are using @ sign
Number: 113312
Name: Ivan from work
Number: 11111
Name: Ida
Number: 12344
Name: Luke
Number: 12345
Name: John
Number: 122222
Name: That guy
--------------------------
then we are using * sign
Number:
Name: Ivan from work Ida Luke John That guy

我们看到,当我们使用@时,得到了预期的结果,但一旦我们用*替换它,我们可以看到键(或索引),但没有返回值。

其背后的原因,像往常一样,是bash的工作方式。使用@符号告诉bash我们是尝试分别获取每个索引或键。另一方面,使用*符号会让bash将所有的索引或键作为一个由空格分隔的单一字符串返回。所以,在一种情况下,我们的脚本会逐个读取每个元素,而另一个循环只运行一次。由于我们试图查找的值与数组中的任何单个键值都不同,这次运行没有返回结果。最终,这两个表达式并不相同,但简单的输出是相同的——不要被它迷惑了。

现在,我们将创建一个用于操作数组的主脚本,并添加一些元素。

显然,起点是创建一个包含我们数组的文件。有多种方法可以做到这一点,有些方法比其他方法复杂,但几乎所有方法都依赖于使用某种循环来遍历数组,并将其读入或写入文件。

我们将以经典的 Linux 方式来做,使用declare语句。如果我们使用-p选项,我们就是在告诉它打印特定变量的定义及其存储的值:

demo@ubuntu:~/variable$ declare -p PAIRS
declare -A PAIRS=(["Ivan from work"]="113312" [Ida]="11111" [Luke]="12344" [John]="12345" ["That guy"]="122222" )

显然,这是非常棒的,因为这是我们需要记住的唯一事项,以确保变量中存储的所有内容都被保存。为了保存它,我们只需将其重定向到磁盘上的文件:

demo@ubuntu:~/variable$ declare -p PAIRS > PAIRS.save

为了证明这个方法有效,我们将取消设置变量,并验证它已经被删除,以确保值只存在于文件中:

demo@ubuntu:~/variable$ unset PAIRS
demo@ubuntu:~/variable$ echo ${!PAIRS[@]}

现在,让我们看看如何从磁盘重新加载脚本。注意,declare语句的输出实际上是一个declare语句,用于定义变量。如果我们从磁盘加载并执行它,一切应该都没问题:

demo@ubuntu:~/variable$ source PAIRS.save 
demo@ubuntu:~/variable$ echo ${!PAIRS[@]}
Ivan from work Ida Luke John That guy

互联网充满了更复杂的解决方案,但对于一个合理大小的数组,这个方法应该非常有效。与此同时,它也很容易在脚本中读取,且包含数据的文件是通用格式,可以被任何安装了bash的系统上的其他版本读取(前提是bash版本为 4.0,因为这是这些类型的数组首次被引入的版本)。

我们现在知道如何将数组读写到磁盘,但还能对它做什么呢?在我们的示例中,我们将切换到常规数组,向你展示一些可以做的事情:

demo@ubuntu:~/variable$ REGULAR=( zero one two three five four \
)
demo@ubuntu:~/variable$ echo ${REGULAR[@]}
zero one two three five four

所以,我们创建了一个数组,并且犯了一个错误。由于数组已经是错乱的顺序,我们将再次对其进行重新洗牌(shuf命令会随机打乱数组):

demo@ubuntu:~/variable$ shuf -e "${REGULAR[@]}"
three
one
zero
five
two
four

虽然你可以为值的随机化创建自己的解决方案,但使用外部命令是最简单的解决方案。

洗牌很简单,但它不是永久性的。命令的作用是将我们的数组作为输入,打乱其中的值,然后打印结果,而原始数组保持不变。

我们将数组重新赋值为命令的结果。我们还创建了另一个变量并打印它,主要是为了展示洗牌是实时发生的,而且每次启动shuf命令时结果都会不同。

排序数组会更麻烦,因为它需要某种排序机制。要么你自己创建一个,要么你可以小心地使用bash中已包含的sort命令。

接下来,我们将展示如何使用索引范围。我们将重置数组,然后展示一些示例。当我们声明范围时,实际上,我们是在使用bash内置的一个机制,它允许我们定义一个数字范围。我们已经用它来创建循环和迭代器,所以这对你来说应该不算惊讶:

demo@ubuntu:~/variable$ REGULAR=( zero one two three four five \
)
demo@ubuntu:~/variable$ echo ${REGULAR[*]} 
zero one two three four five
demo@ubuntu:~/variable$ echo ${REGULAR[*]:2:3}
two three four
demo@ubuntu:~/variable$ echo ${REGULAR[*]:0:3}
zero one two
demo@ubuntu:~/variable$ echo ${REGULAR[*]:0:}
demo@ubuntu:~/variable$ echo ${REGULAR[*]:0:2}
zero one
demo@ubuntu:~/variable$ echo ${REGULAR[*]:3}
three four five
demo@ubuntu:~/variable$ echo ${REGULAR[*]:2}
two three four five
demo@ubuntu:~/variable$ echo ${REGULAR[*]:2:-2}
bash: -2: substring expression < 0

我们在bash中使用的是标准语法。变量后的第一个数字是我们想要打印的起始索引,后面的可选数字是我们需要的值的数量。请注意,这里负数不起作用,和其他一些地方不同;我们不能通过这种方式从数组的末尾往回查找。

接下来我们可以做的是拼接两个数组。

根据你想做的事情,这个操作的结果可能并不会得到你预期的结果:

demo@ubuntu:~/variable$ ANOTHER=(sixth seventh eighth ninth)
demo@ubuntu:~/variable$ NEW="${REGULAR[*]} ${ANOTHER[*]}"
demo@ubuntu:~/variable$ echo ${NEW[*]}
zero one two three four five sixth seventh eighth ninth
demo@ubuntu:~/variable$ echo ${NEW[@]}
zero one two three four five sixth seventh eighth ninth

另一个可以执行的操作是计算数组中的值的数量。我们之前也做过这件事,所以让我们检查一下我们的数组是否已正确合并:

demo@ubuntu:~/variable$ echo ${#REGULAR[@]}
6
demo@ubuntu:~/variable$ echo ${#ANOTHER[@]}
4
demo@ubuntu:~/variable$ echo ${#NEW[@]}
1

这里发生了什么?

在我们的拼接操作中,我们犯了一个大错。我们想做的是创建一个新数组,用于保存两个数组的所有值。但我们做的是创建了一个string变量,它包含了所有数组值组成的一个大字符串。我们需要通过使用括号来修复这个问题:

demo@ubuntu:~/variable$ NEW=(${REGULAR[*]} ${ANOTHER[*]})
demo@ubuntu:~/variable$ echo ${#NEW[@]}
10
demo@ubuntu:~/variable$ NEW=(${REGULAR[@]} ${ANOTHER[@]})
demo@ubuntu:~/variable$ echo ${#NEW[@]}
10
demo@ubuntu:~/variable$ declare -p NEW
declare -a NEW=([0]="zero" [1]="one" [2]="two" [3]="three" [4]="four" [5]="five" [6]="sixth" [7]="seventh" [8]="eighth" [9]="ninth")

所以,我们创建了数组并检查了它包含多少个值。由于我们想确保完全准确,因此使用了declare语句来展示所有的索引/值对。

在继续之前,我们需要通过使用一对引号制造一个小错误:

demo@ubuntu:~/variable$ NEW=("${REGULAR[@]} ${ANOTHER[@]}")
demo@ubuntu:~/variable$ echo ${#NEW[@]}
9
demo@ubuntu:~/variable$ declare -p NEW
declare -a NEW=([0]="zero" [1]="one" [2]="two" [3]="three" [4]="four" [5]="five sixth" [6]="seventh" [7]="eighth" [8]="ninth")

我们做的事情是创建了一个与我们第一个例子非常相似的东西,但同时完全错误。一个值是两个字符串的组合,而不是两个单独的值。尝试所有不同的引号组合,看看它们如何工作,以及使用@*是否会对结果数组产生影响。

它是如何工作的……

我们已经详细处理了对数组可以执行的各种操作。剩下的就是看看关于数组还有什么需要了解的内容,以及如何检查数组是否包含某个值。数组的长度——或者更准确地说,值的数量是我们刚刚看过的。如果你需要知道数组是否已经存在,可以检查它的长度是否大于0。这将告诉你,要么你测试的数组没有定义,要么它不包含任何元素。如果你明确想检查一个变量是否已定义,可以使用declare语句并统计结果。在我们的示例中,我们有一个名为TEST1的变量和一个未定义的名字TEST2

demo@ubuntu:~/variable$ TEST1=()
demo@ubuntu:~/variable$ declare -p TEST1
declare -a TEST1=()
demo@ubuntu:~/variable$ declare -p TEST2
bash: declare: TEST2: not found
demo@ubuntu:~/variable$ echo ${#TEST1[@]}
0
demo@ubuntu:~/variable$ echo ${#TEST2[@]}
0

在大多数情况下,只检查值的数量就足够了,但有时你需要知道一个变量是否已经定义。

另一个常见的操作是尝试找出数组中是否包含特定的值。你可以通过创建自己的循环来检查值,或者使用bash已经提供的内置测试。例如,你可以像这样做:

demo@ubuntu:~/variable$ [[ ${REGULAR[*]} =~ "one" ]] && echo \
yes || echo no
yes
demo@ubuntu:~/variable$ [[ ${REGULAR[*]} =~ "something" ]] && \
echo yes || echo no
no

我们再次使用了一行逻辑表达式来快速查看结果。

现在,这里有一个小脚本,它将展示我们在这个教程中学到的一些内容:

        #!/bin/bash
        #check if settings exist
                   function checkfile {
               if [ -f setting.list ] 
                          then
                                         return 0
                          else.
                                      return 1
                                       fi
}
function assign_settings {
             echo assigning settings
             SETTINGS=(["USER"]=John ["LOCALDIR"]=$PWD \
["HOSTNAME"]=hostname)

}
declare -A SETTINGS
SETTINGS=()
if checkfile
then 
             source setting.list
else 
             assign_settings
fi
echo Settings are:
for name in "${!SETTINGS[@]}"
do
             echo "$name"=${SETTINGS["$name"]}
done
declare -p SETTINGS > setting.list

我们已经知道了大部分内容,但我们还是会通过这个脚本进行回顾。

脚本中的第一个函数用来测试文件是否存在。我们本可以在代码后面使用if语句来做同样的事情,但我们想提醒你如何使用函数和逻辑检查。如果文件存在,函数返回0,如果文件不存在,则返回1

接下来是我们称之为assign_settings的函数,它在文件未找到时使用。它所做的就是简单地创建一个包含一些数据的新关联数组。

然后,我们进入脚本的主体部分,首先声明我们的数组,因为它们不能隐式声明。接着,我们决定是否已经保存了文件,并判断是否应该从文件中加载数组,或者是否需要重新分配默认值。

之后,我们只是输出值,然后将它们保存到磁盘。

在普通脚本中,这通常是导入和保存重要设置的部分。脚本的其他部分将在保存变量的那一行之前。

我们将连续两次启动脚本。结果应该是,它会检测到我们没有配置,并为我们创建配置:

demo@ubuntu:~/variable$ bash settings.sh 
assigning settings
Settings are:
USER=John
HOSTNAME=hostname
LOCALDIR=/home/demo/variable
demo@ubuntu:~/variable$ bash settings.sh 
Settings are:
USER=John
HOSTNAME=hostname
LOCALDIR=/home/demo/variable

当你在做类似的事情时,我们还必须提醒你,局部变量和全局变量可能会带来大问题。如果你在函数中声明任何应该是全局的变量,或者更糟的是,如果你从函数中引用它,务必小心,因为作用域会限制变量在脚本中的传播。

在这里,我们将暂时离开数组,转向更有趣的内容——开始创建一些接口。

另见

第十四章:与 Shell 脚本交互

我们几乎完成了对脚本基本概念的解释,但在我们完全完成之前,我们需要学习如何与 Shell 脚本进行交互。这在 Shell 脚本中并非总是必要的,但在大多数情况下是适用的。例如,创建一个只做一项工作且仅做一项工作的脚本是一回事。而创建一个在执行过程中需要我们做出一些选择的脚本则完全不同。如果没有其他原因,这第二种类型的脚本就是 Shell 脚本交互的一个典型候选。在本章中,我们将介绍三种不同的方式来处理 Shell 脚本的交互。

本章将涵盖以下内容:

  • 创建基于文本的交互式脚本

  • 使用 expect 自动化基于文本输出的重复任务

  • 使用 dialog 创建基于菜单的交互式脚本

技术要求

就像到目前为止的所有章节一样,我们将使用运行 Bash Shell 的相同虚拟机。所以,我们需要一台安装了 Linux 的虚拟机——任何发行版都可以(在我们的案例中,它将是Ubuntu 20.10)。

现在,启动你的虚拟机!

创建基于文本的交互式脚本

到目前为止,我们做的唯一一件事就是没有在脚本中加入任何交互。原因很简单——到此为止,我们只讨论了如何输出信息,而没有讨论如何从用户或其他来源获取信息。在现实世界中,交互是我们必须处理的事情,因为它是创建任何脚本的核心。我们可以说,交互有两种类型。首先,我们的脚本可以与系统本身进行交互。这意味着使用不同的变量和其他信息,我们可以从系统中获取这些信息——例如,内存或挂载磁盘上的空闲空间。你可以说这不是实际的交互,而只是从系统中读取实时数据。但即便如此,这也是确保脚本按需执行的非常有用的方法。

另一个我们可以做的事情是与用户进行交互。如果脚本由系统运行,它不会以任何方式与用户交互,但当我们为日常工作创建脚本时,用户交互是非常重要的。考虑这个问题——为什么我们要创建一个只备份一个文件夹(仅一个文件夹)的脚本,而我们可以创建一个可以指定备份一个或多个目录的脚本呢?这种设计脚本的方式不是更具可用性吗?

准备工作

当我们开始创建我们的脚本时,我们必须决定它们需要的交互方式。根据所需的脚本类型,我们可以使用交互式提示、菜单、某种预配置或甚至一些图形界面。目前,我们将避免在我们的脚本中使用 GUI。然而,如果需要的话,我们可以使用适当的工具帮助我们使用它们。记住,脚本基本上只是一些执行控制,决定不同命令和应用程序如何交互,因此首先要关注的是这些命令。

首先,我们的第一个配方将是一个简单的交互式脚本,要求用户输入并根据输入进行操作。

如何做到…

在这个配方中,我们将要使用的主要命令是 readecho。在我们做任何其他事情之前,我们需要学习关于这些命令的一些技巧。理论上,read 是很容易理解的——它等待用户输入,然后将该输入存储在某个变量中。但是为了向您展示这个简单命令所能实现的不同功能,我们需要展示一些例子。

read 在其基本形式中接受一个参数——变量——然后接受用户输入并将其放入该变量中,以便我们稍后使用。让我们考虑以下例子:

#!/bin/bash
echo "Input a value: "
read Value1
echo "Your input was: $Value1"

如果我们快速测试这个脚本,这将是我们得到的结果:

demo@cli1:~/interactive$ bash singlevar.sh 
Input a value: 
test
Your input was: test
demo@cli1:~/interactive$ bash singlevar.sh 
Input a value: 
test value
Your input was: test value

有时这还不够。有时,我们需要在我们的脚本中获取多个值。这里的问题是用户输入值的方式。Shell 使用空格字符作为分隔符,因此空格将分隔 read 命令中的值。如果我们需要获取包含空格的值,我们将不得不以不同方式处理它。正如我们在前面的例子中看到的那样,只有在使用多个返回变量时,这才会成为问题。

然而,如果我们使用不包含空格的值,我们可以简单地使用以下代码并将其保存在 doublevar.sh 文件中:

#!/bin/bash
echo -e "Input two numbers "
read num1 num2
echo "Two numbers are $num1 and $num2"

现在,让我们试一试,看看它是否按我们期望的方式工作:

demo@cli1:~/interactive$ bash doublevar.sh 
Input two numbers 
2 3
Two numbers are 2 and 3

我们必须在这里停下来,做另一个测试来澄清一些事情。Bash 对变量的类型不进行任何检查。在我们的脚本中,我们假设用户将输入数字,但没有任何限制他们使用任何字符串。另一件事是如何处理由空格分隔的多个值——第一个值将被分配给第一个变量;其余的值将分配给第二个变量。如果我们使用超过两个变量来存储值,结果将始终是序列中的每个变量都会被分配一个变量,最后一个变量将得到输入行中剩余的内容:

demo@cli1:~/interactive$ bash doublevar.sh 
Input two numbers 
First second
Two numbers are First and second
demo@cli1:~/interactive$ bash doublevar.sh 
Input two numbers 
first second third
Two numbers are first and second third

我们可以使用 read 的另一种方式是将值获取到一个名为 $REPLY 的预定义变量中。如果我们简单地省略变量名,那么您输入的所有内容都将在该变量中,然后可以在您的脚本中使用:

#!/bin/bash
echo "Input a value: "
Read
echo "Your input was: $REPLY"

一个简单的测试证明,这个行为和我们给命令一个正确的变量名时完全相同:

demo@cli1:~/interactive$ bash novar.sh 
Input a value: 
test value no variable
Your input was: test value no variable

使用read的另一种方式是通过使用-a选项。使用这个选项时,我们表示希望将所有获取的值存储为一个数组。在此选项后,我们需要指定用来存储这些值的变量名,或者我们可以简单地使用默认的$REPLY变量。我们不应该做的是使用多个变量名。这是因为我们将多个值存储在一个变量中,所以尝试引用多个变量本身就没有意义:

#!/bin/bash
echo "Input multiple values: "
read REGULAR
echo "Your input was: $REGULAR"
echo "This will not work: ${REGULAR[0]}"
echo "Now input multiple values again:"
read -a REGULAR
echo "This will work: ${REGULAR[0]}"

在这里,我们将多个值读取到一个变量中。由于我们没有要求 Bash 创建一个数组,它将把所有内容存储到这个变量中,但没有办法引用该变量内的元素。Bash 将变量中的所有值视为一个单独的、首个元素,因此如果我们尝试打印它,我们将看到所有内容。

第二次执行这个操作时,我们正在获取用户输入的值。这里,我们使用了一个数组。一切看起来一样,但如果我们引用变量的第一个元素,我们只会打印出第一个元素:

demo@cli1:~/interactive$ bash array.sh 
Input multiple values: 
first second third
Your input was: first second third
This will not work: first second third
Now input multiple values again:
first second third
This will work: first

现在,我们需要稍微实验一下echo命令。在本书的整个脚本部分中,我们一直在以最基本的形式使用这个命令来输出文本到屏幕,或者更准确地说,是输出到标准输出。这对于大多数情况都能正常工作,但有些脚本中我们需要更精确地控制输出。echo工作方式的问题在于它总是用换行符结束它输出的字符串,一旦它打印了作为参数传递的内容,输出就会强制换到新的一行。虽然对于打印信息来说,这没有问题,但当我们尝试在脚本中与用户交互时,如果总是强迫用户将我们需要的值输入到新的一行,看起来就很奇怪。所以,echo提供了一个额外的选项,改变了默认行为(-n)。让我们考虑这个例子:

#!/bin/bash
echo -n "Can you please input a word?: "
read  word
echo "I got: $word"

我们在这里告诉echo做的是打印引号内的文本,但打印完后,它应该停留在同一行。由于我们的read命令会从前一个命令放置光标的位置继续执行,因此结果是我们输入的值最自然地会出现在屏幕上:

demo@cli1:~/interactive$ bash echoline.sh 
Can you please input a word?: singleword
I got: singleword

还有一种做法看起来更复杂,但表现与上面相同:

#!/bin/bash
echo -e "Can you please input a word?:  \c "
read  word
echo "I got: $word"

我们向你展示这个的原因是,这个例子使用了特殊字符来表示行的结束,但与此同时,还有更多的字符可以用来更精细地控制输出。默认情况下,最常用的是以下几种:

  • \\ 反斜杠:当我们需要输出实际的\字符时使用。

  • \a 警报(BEL):当我们想通过大声的声音警告用户时使用。

  • \b 退格:当我们需要提供一个退格字符,删除光标所在行的内容时使用。

  • \c produce no further output:这个用于告诉echo停止输出。

  • \t horizontal tab:这个用于提供tab并对齐输出。

现在我们了解了一些基本的readecho语法,让我们在脚本中好好利用它们吧。

它是如何工作的…

现在我们知道了如何处理 Bash 脚本中用于交互的所有内容,我们可以创建一个展示所有内容的脚本。在这里,我们正在创建一个简单的菜单:

#!/bin/bash
echo "Your favourite scripting language?"
echo "1) bash"
echo "2) perl"
echo "3) python"
echo "4) c++"
echo "5) Dunno!"
echo -n "Your choice is: "
read choice;
# we do a simple case structure
case $choice in
    1) echo "You chose bash";;
    2) echo "You chose perl";;
    3) echo "You chose python";;
    4) echo "You chose c++";;
    5) exit
esac

试试看!

另一种在脚本中创建交互式菜单的方式是使用select命令。这个命令通常用于创建简单的菜单,像这样:

#!/bin/bash
PS3='Please choose an option: '
options=("Option 1" "Option 2" "Option 3" "Quit")
select opt in "${options[@]}"
do
    case $opt in
        "Option 1")
            echo "you chose Option 1"
            ;;
        "Option 2")
            echo "you chose Option 2"
            ;;
        "Option 3")
            echo "you chose Option $REPLY which is $opt"
            ;;
        "Quit")
            break
            ;;
        *) echo "invalid option $REPLY"
            ;;
    esac
done

select看起来像某种循环;它要求我们提前设置一些变量。$PS3包含用户将看到的问题,而$options包含一个字符串数组,表示可选项。当用户运行这个脚本时,将展示一个编号选项列表,用户可以输入任何一个数字。select将根据用户选择的数字从我们的选项列表中替换相应的字符串,并执行相应的命令:

demo@cli1:~/interactive$ bash select.sh 
1) Option 1
2) Option 2
3) Option 3
4) Quit
Please choose an option: 1
you chose Option 1
Please choose an option: 2
you chose Option 2
Please choose an option: 3
you chose Option 3 which is Option 3
Please choose an option: 4
demo@cli1:~/interactive$

这是快速创建一个多选项脚本的好方法。

另请参见

关于echoread命令的更多信息可以在以下链接中找到:

使用expect自动化基于文本输出的重复任务

Bash 是一个强大的工具,但有时我们需要做一些特殊的事情,这时需要额外的工具。在这个示例中,我们将使用一个名为expect的工具。在开始之前,我们必须注意,expect并不是 Bash 脚本的一部分——它是一个完全独立的脚本语言,专门用于实现脚本与用户和其他系统之间的交互。它的核心思想是让脚本不仅能够执行提供信息的普通命令(命令输出),还能够与任何具有命令行界面CLI)的应用程序进行交互,并从中获取信息。

准备工作

简单来说,expect充当了一个虚拟键盘,可以输入文本并读取屏幕上的内容。这是一个强大的功能,因为许多应用程序和脚本是由那些没有启用脚本支持的开发者创建的,或者他们只是选择不这样做。这意味着没有像expect这样的工具,我们将无法与这些应用程序进行交互。这有时意味着我们将无法在脚本中执行我们想做的事情。

在这个示例中,我们将学习如何使用expect与另一台计算机上的另一个 Shell 进行交互,如何输入密码并登录,如何输入命令并从对方获得响应。

但在我们做这些之前,如果系统中未安装expect,我们需要先安装它,因为它不是系统的标准组件。

使用以下命令并等待它完成:

sudo apt install expect

我们需要在这里使用sudo。只有管理员才能安装软件包,因此需要输入你的用户密码。

如何实现…

由于expect不是 Bash,我们必须告诉脚本使用它。语法与创建 Bash 脚本时相同——我们需要将expect的运行放在脚本的第一行。请注意,这意味着我们的脚本将不再使用 Bash 的任何命令,但我们可以执行许多新操作。

我们将从简单的hello world脚本开始:

#!/usr/bin/expect
expect "hello"
send "world"

脚本执行的操作与命令的字面意思完全一致;当我们启动它时,脚本将寻找hello字符串,并在收到后,回复world字符串。

字符串是区分大小写的,因此只有完全匹配的内容才会有效。另外,expect有一个内置的时间段,在此期间它会等待接收字符串。如果在此时间段内没有匹配的内容,脚本将继续执行下一个命令。在我们的示例中,即使我们不给它任何输入,world字符串也会被打印出来。

当我们启动脚本时,必须使用expect命令;我们不能使用 Bash 启动此脚本,因为它是专门为expect编写的:

demo@cli1:~/interactive$ expect expect1.exp 
HEllo
helo
hello
world

我们尝试了三种不同的拼写方式,只有完全匹配的才有效。

让我们做些更有趣的事情,并解释我们在过程中做了什么:

#!/usr/bin/expect
set timeout 20
set host [lindex $argv 0]
set user [lindex $argv 1]
set password [lindex $argv 2]
spawn ssh "$user\@$host"
expect "Password:"
send "$password\r";
interact

这个脚本的目的是帮助你快速使用ssh协议连接到另一台主机。当我们运行它时,我们需要提供三件事:主机的名称或 IP 地址、要使用的用户名以及将要登录的用户的明文密码。我们知道以这种方式使用密码并不常见,但这里我们提供一个示例。

在脚本开始时,我们设置了提示的超时时间。如前所述,如果expect命令没有检测到任何输入,它将在此timeout值指定的时间后继续执行脚本。接下来的三行代码处理我们传递给脚本的参数。我们将每个参数赋值给一个变量,以便稍后使用。

之后,我们使用spawn命令在一个独立的进程中调用ssh命令。我们使用标准的ssh客户端,并为其提供用户名和主机名,以便我们能够启动登录过程。

之后,我们的脚本会等待,直到检测到需要输入密码。当它检测到提示中的password:部分时,它会以明文形式发送我们的密码。

脚本中的最后一条命令是interact,它将控制权交给我们,这样我们就可以使用刚刚登录的会话来执行我们打算做的事情。这就是执行时的样子:

demo@cli1:~/interactive$ expect sshlogin.exp localhost demo demo
spawn ssh demo@localhost
demo@localhost's password: 
Welcome to Ubuntu 20.10 (GNU/Linux 5.8.0-63-generic x86_64)
 Documentation:  https://help.ubuntu.com
* Management:     https://landscape.canonical.com
* Support:        https://ubuntu.com/advantage
0 updates can be installed immediately.
0 of these updates are security updates.
demo@cli1:~$

如果我们需要在远程系统上工作,这是一个不错的起点。但我们如何在另一台系统上运行其他命令,又能做些什么呢?

这里有两种方式可以继续;一种是简单地等待提示符显示,然后发送命令,另一种是“盲目地”编写脚本,等待预定的时间,然后发送我们需要的命令。因此,让我们为这两种概念编写一个示例脚本。我们的目标是有一个场景,其中脚本的一部分作为命令输出的回答(在我们的脚本中,这是ssh部分及其输出)。第二部分与等待预定时间的概念有关(sleep 5意味着等待 5 秒钟),然后做一些事情。让我们来看看我们的示例:

#!/usr/bin/expect
set timeout 20
set host [lindex $argv 0]
set user [lindex $argv 1]
set password [lindex $argv 2]
spawn ssh "$user\@$host"
expect "password:"
send "$password\r";
sleep 5
send "clear\r";
send "ip link\r";
expect "$"
puts $expect_out(buffer);
send "exit\r";

我们添加了两个之前未提到的内容。第一个是我们使用puts命令向屏幕打印信息。它的行为类似于 Bash 中的echo命令。

$expect_out (buffer)变量保存脚本在运行命令(在两个匹配之间)时获取的数据。因此,在我们的脚本中,这将保存ip add命令提供的信息。如果你想知道登录信息去了哪里,它不可见,因为我们发出了clear命令清除了屏幕,而这也清除了缓冲区。

它是如何工作的…

expect是一个令人惊叹的工具,甚至还有更多的功能。它的主要用途之一是通过脚本自动化管理任务。它最常见的用途之一就是我们在前一个示例中所做的——远程执行命令。我们可能经常使用它的原因不仅仅是为了自动化登录,还为了自动化测试。运行特定命令后,我们可以做任何我们想做的事情,然后将结果获取到我们的脚本中。

通常,expect作为另一个脚本的一部分使用。当我们需要某些仅expect能够提供的数据时,我们调用它,然后继续在主脚本中处理这些数据。然而,自动化脚本还意味着另一件事——让它们接受信息,这使我们能够编写测试来检查脚本是否有效。因此,expect有一个工具,用来从任何包含与用户交互的脚本中创建一个expect脚本。

以下是一个快速示例,用于演示我们所说的内容,但首先,我们将创建一个非常简单的脚本,在其中使用不同方式调用echoread命令:

#!/bin/bash
#echo -e "Can you please input a word?:  \c "
echo -n "Can you please input a word?:   "
read  word
echo "I got: $word"
echo -e "Now please input two words: "
read word1 word2
echo "I got: \"$word1\" \"$word2\""
echo -e "Any more thoughts? "
# read will by default create $REPLY variable
read
echo "$REPLY is not a bad thing "
echo -e "Can you give me three of your favorite colors? "
# read -a will read an array of words
read -a colours
echo "Amazing, I also like ${colours[0]}, ${colours[1]} and \n
${colours[2]}:-)"

现在,我们将启动autoexpect工具,以获取脚本的输入和输出:

demo@cli1:~/interactive$ autoexpect bash simpleecho.sh 
autoexpect started, file is script.exp
Can you please input a word?:   word one
I got: word one
Now please input two words: 
two words
I got: "two" "words"
Any more thoughts? 
none whatsoever
none whatsoever is not a bad thing 
Can you give me three of your favourite colors? 
blue yellow cyan
Amazing, I also like blue, yellow and cyan
autoexpect done, file is script.exp

在这里,autoexpect跟踪了我们的脚本使用的提示以及我们给出的答案。当我们执行完脚本后,它创建了一个expect脚本,允许我们完全自动化运行 Bash 脚本。我们将省略部分脚本,因为它包含了大量的注释,既提供了关于工具的信息,也给出了免责声明。以下是autoexpect的输出,它被保存在一个名为script.exp的文件中:

#!/usr/bin/expect -f
# This Expect script was generated by autoexpect on Sun Oct \n
10 13:35:52 2021
set force_conservative 0  ;# set to 1 to force conservative \n
mode even if
                                        ;# script wasn't run \n
conservatively
          Originally
               if {$force_conservative} {                                            
                          set send_slow {1 .1}
                          proc send {ignore arg} {
                          sleep .1
                          exp_send -s -- $arg
             }
} 
set timeout -1
spawn bash simpleecho.sh
match_max 100000
expect -exact "Can you please input a word?:   "
send -- "word one\r"
expect -exact "word one\r
I got: word one\r
Now please input two words: \r
"
send -- "two rods "
expect -exact "
send -- ""
expect -exact "
send -- ""
expect -exact "
send -- ""
expect -exact "
send -- ""
expect -exact "
send -- "words\r"
expect -exact "words\r
I got: \"two\" \"words\"\r
Any more thoughts? \r
"
send -- "none whatsoever\r"
expect -exact "none whatsoever\r
none whatsoever is not a bad thing \r
Can you give me three of your favorite colors? \r
"
send -- "blue llow and "
expect -exact "
send -- ""
expect -exact "
send -- ""
expect -exact "
send -- ""
expect -exact "
send -- "cz"
expect -exact "
send -- "yan\r"
expect eof

这个脚本是自动化工作任务的一个良好起点,但有一个致命的缺陷要小心。在最后一部分检查输入和输出时,它记录了我们在输入过程中所犯的每一个错字和错误,但它没有保存删除它们的整个过程,因此这个输入无法正常工作。我们在使用它之前,必须编辑这个脚本,处理所有错误和输入问题。之后,我们可以在此基础上扩展,并使用附加参数测试脚本的行为。

另见

expect非常适合进行测试。如需更多示例,请访问以下链接:

使用dialog进行基于菜单的交互式脚本

既然我们已经使用了expect,我们知道如何与其他应用程序交互。剩下要做的就是学习如何让我们的脚本更加互动。毫不奇怪,这个问题已经有了标准的解决方法。在这个配方中,我们将使用dialog,一个看起来简单但可以让你与最终用户创建复杂且视觉有趣交互的命令。

准备工作

根据定义,dialog和任何交互工具一样,会使你的脚本在非交互环境中无法使用。可以通过不使用dialog或者检测脚本是作为服务运行还是作为交互式脚本运行来解决这个问题。

expect一样,我们必须安装dialog才能使用它。只需使用以下命令:

sudo apt install dialog

所有所需的内容将根据要求安装。

如何操作…

dialog是一个完整的应用程序,不仅包含菜单,还有很多其他小部件,这些小部件会在文本模式下的 GUI 中显示。如果你的终端支持颜色,它将使用颜色(现代终端几乎都支持显示颜色),并且会使用光标键进行导航。我们将向你展示一些最常见的命令,并介绍如何使用它们。

首先,尝试运行此命令:

dialog --clear --backtitle "Simple menu" --title "Available \n
options" --menu "Choose one:" 16 50 4 1 "First" 2 "Second" 3 \n
"Third"

如果一切顺利,你应该看到类似这样的界面(具体大小取决于你的终端窗口):

图 14.1 – 命令使我们能够在脚本中创建单行菜单

图 14.1 – dialog命令使我们能够在脚本中创建单行菜单

dialog 作为你的脚本与用户之间的一个漂亮代理工作。你的脚本负责你试图自动化的过程的逻辑;dialog 负责处理用户的输入和输出。我们需要对这个菜单做点什么;如果我们只是从 Bash 提示符中调用它,那它就没有什么用途。我们这样使用它的原因是为了展示如何调用一个小部件。许多人在第一次看到dialog在脚本中使用时,会期望一个复杂的过程,实际上这只是一个单行命令和几个参数。为了演示这一点,让我们创建一个实际的菜单并将其保存在名为menu.sh的文件中:

#!/bin/bash
HEIGHT=16
WIDTH=50
CHOICE_HEIGHT=4
BACKTITLE="Simple menu"
TITLE="Available options"
MENU="Choose one:"
OPTIONS=(1 "First" 2 "Second" 3 "Third")
CHOICE=$(dialog --clear \
                --backtitle "$BACKTITLE" \
                --title "$TITLE" \
                --menu "$MENU" \
                $HEIGHT $WIDTH $CHOICE_HEIGHT \
                "${OPTIONS[@]}" \
                2>&1 >/dev/tty)
clear
case $CHOICE in
        1)
            echo "You chose First"
            ;;
        2)
            echo "You chose Second"
            ;;
        3)
            echo "You chose Third"
            ;;
esac

我们在这里做什么?dialog 为它可以显示的每个小部件需要几个参数。它需要许多值来正确显示某些内容是否来自你的终端。这些值包括屏幕的宽度和高度,以及如何正确显示输出。通常,小部件只需要用户定义的内容——小部件本身的高度和宽度、在小部件中使用的标题和其他字符串,以及用户的选择。根据小部件的工作方式和使用方式,这些内容因小部件而异。在我们的第一个示例中,我们使用的是一个菜单小部件,它需要一个选项列表和菜单的大小。我们需要这个选项列表,因为它帮助启动此脚本的用户做出正确的选择。我们还提供了标题,尽管我们并不需要所有标题就能让菜单正常运行。

这里有一个有趣的地方需要注意,就是dialog返回用户选择的值的方式。为了获取这个值,我们在可能的选项列表中提供了索引。当我们运行dialog时,我们会将它从用户那里获取的值直接赋给一个变量,然后根据该值进行操作。

它是如何工作的…

dialog 基于使用不同的图形字符显示不同的小部件,从而在普通终端中给人一种图形界面的感觉。作为一个使用dialog的人,如果你愿意,你几乎可以改变所有元素的外观,但通常,大多数脚本只是简单地使用dialog来快速让用户输入他们需要的数据。

使用像dialog这样的工具是一种很好的方法,能够让用户为脚本中的不同事物选择值,同时避免大量输入错误。

另一个示例是,假设我们需要请求用户提供一个日期。我们可以使用readecho命令通过一个简单的输入框来实现。虽然这样可行,但有很大风险用户会使用错误的格式。你可以通过向用户解释你期望的格式来解决这个问题,但他们不可避免地会忘记并使用错误的格式。

dialog中,你可以做一些简单的事情,比如这样:

#!/bin/bash
DATEPROMPT="Choose a date"
CHOSENDATE=$(dialog --stdout --calendar "$DATEPROMPT" 0 0)
echo "Chosen date is $CHOSENDATE"

以下是输出:

图 14.2 – 使用日历小部件显示日期非常简单

图 14.2 – 使用日历小部件显示日期非常简单

我们得到的日期将根据运行脚本的系统的区域设置以正确的格式显示,用户可以从几个好看的日历中进行选择。dialog在这里非常有用,因为如果我们没有指定默认的具体日期,它会自动跳到当前日期。根据终端仿真类型,dialog甚至可能支持使用计算机鼠标来选择数据。

还有一些非常有用的小部件是dialog提供的,我们将展示其中的一些。我们不会为所有小部件创建脚本,因为它们使用起来非常简单。最简单的方法是使用—stdout选项将dialog的结果存入变量,然后从那里继续工作。

如果我们需要选择一个目录,这可能是一个大问题,因为用户通常会忘记正确写路径,或者使用绝对路径而不是相对路径。通过使用一个简单的dialog 命令,比如下面的命令,我们可以避免很多问题:

dialog --dselect ~ 10 39

这是预期的结果:

图 14.3 – 使用对话框选择目录可以避免很多错误

图 14.3 – 使用对话框选择目录可以避免很多错误

当我们需要从用户那里获取一个简单的答案时,简单的dialog非常有用。我们可以使用以下代码来实现:

dialog --yesno "Do you wish to do it?" 0 0

使用这个dialog命令,我们应该得到以下输出:

图 14.4 – 有时候,你需要问一个简单的问题,答案也很简单

图 14.4 – 有时候,你需要问一个简单的问题,答案也很简单

然后,我们有一些更具信息性的选择。在我们的脚本中,我们经常需要向用户展示一些他们需要阅读的信息。把它放在一个格式化的框中比直接在终端打印要好得多:

dialog --msgbox "A lot of text can be displayed here!" 10 30

这个示例将生成以下输出:

图 14.5 – 使用正确的小部件显示文本既简单又有效

图 14.5 – 使用正确的小部件显示文本既简单又有效

有一些小部件我们不会提及,因为我们想快速向你展示一些可能的用法。对于最后一个例子,我们将向你展示一个非常有用的小部件——这个小部件显示文本文件的内容,并随着更改自动更新它,使你可以在用户安装某个东西时显示日志:

dialog --tailbox /var/log/syslog 40 80

这是预期的输出:

图 14.6 – tailbox 就像在文件上使用 tail -f,但外观更好

图 14.6 – tailbox 就像在文件上使用 tail -f,但外观更好

这就是我们对dialog功能的概述。确保你阅读文档,以了解其他可以使用的小部件以及如何正确使用它们。

请注意,存在其他实现相同思想的工具。其中之一叫做 whiptail,它与dialog相同,但采用不同的方式在屏幕上绘制对象。然而,它并不像dialog那样完整,并且缺少一些与dialog相比的控件。

参见

第十五章:Shell 脚本故障排除

如果你已经读到这里,说明你一定对如何编写 shell 脚本有了很多想法,也会有更多关于如何让脚本中的某些功能正常工作的问题。这是完全正常的。你的脚本之旅才刚刚开始。没有多少阅读能代替写脚本、尝试不同解决方案以及理解不同命令如何工作的实践时间。

我们有一些好消息和一些坏消息要告诉你。擅长编写脚本需要很长时间,而且在编写脚本时,大部分时间将用于理解你的脚本应该做什么,通常还要搞清楚为什么它做错了。好消息是,编写脚本永远不会让人感到无聊。

在本章中,我们将尽量为你提供调试和排除脚本故障的工具,帮助你快速而不混乱地解决问题。这些工具将以不同的方法形式出现,帮助你最大化找到脚本中的逻辑和有时是语法错误的能力。我们将从基本的技巧开始,逐步过渡到更复杂的脚本调试方法。

本章将涵盖以下几个方法:

  • 常见的脚本错误

  • 简单的调试方法——在脚本执行期间输出变量值

  • 使用 bash-x-v 选项

  • 使用 set 来调试脚本的一部分

技术要求

在本章中,我们将使用与本书前面所有关于脚本编写章节相同的机器。请不要因为有几张截图是 Windows 系统下制作的而感到惊慌。它们仅用于说明某个观点;你并不需要 Windows 来做任何事情。就像之前一样,我们使用的是以下内容:

  • 安装了 Linux 的虚拟机,任何版本的 Linux(在我们的案例中,将是Ubuntu 20.10

现在,让我们深入探讨故障排除。

常见的脚本错误

编写脚本将会遇到许多问题,包括如何设计脚本、如何找到适合不同问题的解决方案,以及如何将这一切应用到目标环境中。这些问题有些可能在几分钟内就能轻松解决,也有些可能需要你花费几天甚至几周时间来解决。所有这些时间可能只是你调试和排除脚本问题时所花费时间的一小部分。编写脚本和排除脚本问题是两件完全不同的事情——虽然你通常会从零开始编写自己的脚本,但你不仅仅会调试和排除自己的代码。

编写脚本需要技巧和对环境的深刻理解,但可以说,为了调试和排查故障,你需要更深入地理解任务以及脚本试图如何完成任务。在这个教程中,我们将学习如何掌握技能,不仅能够调试自己编写的脚本,还能理解任何遇到的脚本代码,无论是你自己创建的部分,还是由其他人创建的系统,你需要负责让它正常运行。

准备开始

故障排除和调试看起来像是同一任务,但它们有细微的不同。一般来说,当我们在调试时,我们集中精力寻找脚本中的逻辑错误和其他错误。而在故障排除时,我们不仅在调试,还在尝试更好地理解应该做什么,以及你的应用程序如何尝试完成这些任务。在本章的教程中,我们将这两个术语一起使用——试图让那些不正常工作的东西恢复正常,或者至少表现得更好。

有一些方法可以让这个过程尽可能简单,其中之一就是尽量掌握关于脚本的知识。能够理解脚本以及解决问题的具体方法,不仅能帮助你快速了解问题所在,还能帮助你找到解决方法。

有时候,解决方案可能是使用标准的解决方案简化代码的某个部分,或者将代码拆分成不同的、更标准化的模块。

当面对更复杂的脚本时,将代码分解成更易管理和理解的模块,这种方法可能会非常成功,因为即使是最优秀的脚本编写者,有时也会完全忽略他们所做事情的关键,甚至把最简单的任务都搞复杂了。

说到调试,我们需要谈谈错误。大致来说,我们的脚本可能有四种不同的结果:

  • 脚本按预期工作。

  • 脚本抛出错误。

  • 脚本工作,但不完全,产生错误。

  • 脚本可以工作,但有时会默默地破坏输入或输出数据中的某些内容。

当我们有时间时,我们可以着手处理这些可能的结果,并让脚本表现得更好,即使它已经正常工作。有时,花点时间让你的脚本更加优雅、注释更加清晰、代码更具可读性,哪怕它本来就能正常工作,这也是值得的。

另一个情况是脚本抛出错误。Bash 因其晦涩且通用的错误信息而闻名。其中一些信息过于模糊,难以提供帮助,有时甚至完全没有意义,无法帮助我们理解到底哪里出了问题。

一个常见的例子是有时无法正确理解行尾字符。Windows 和 Linux 处理行尾的方式不同。Windows 在终止文本文件时使用回车符和换行符,而 Linux 只使用换行符来终止行。Bash 可能会遇到问题,Windows 上编写的脚本有时会在 Linux 上莫名其妙地出错。这是 Windows 和 Linux 上相同的脚本,使用一个显示文件中所有字符的编辑器:

图 15.1 – 在 Linux 中,行通过单个字符终止

图 15.1 – 在 Linux 中,行通过单个字符终止

在 Windows 中,表现类似,但我们可以看到行尾有两个字符:

图 15.2 – 在 Windows 中,使用两个字符来终止一行

图 15.2 – 在 Windows 中,使用两个字符来终止一行

在 Linux 中,如果我们没有正确编辑文件并忘记去除多余的字符,最终我们会得到一些在普通编辑器中不可见的字符,并同时破坏代码:

图 15.3 – 在 vim 中,你需要打开几个选项来查看特殊字符

图 15.3 – 在 vim 中,你需要打开几个选项来查看特殊字符

请注意,有一个工具叫做dos2unix(如果你需要转换为另一种格式,也有unix2dos),它能在传输文件时修复行尾问题。这个问题是系统范围的,许多程序在遇到来自 Windows 的文本文件时会表现得异常。

如果我们不处理行尾的字符,脚本会出错。例如,我们尝试在 Linux 上运行从 Windows 获取的文件时,出现了完全无法理解的错误,错误信息中提到了看似根本不在脚本中的命令。我们使用dos.sh作为在 Linux 上保存的脚本名称:

demo@ubuntu:~/Desktop/allscripts$ bash dos.sh 
dos.sh: line 2: $'\r': command not found
dos.sh: line 4: $'\r': command not found
dos.sh: line 26: syntax error near unexpected token 'else'
'os.sh: line 26: '    else

此外,我们需要明确的是,如果你使用复制和粘贴在不同环境之间移动文件,这将直接解决问题。当你在某个操作系统中粘贴一行时,它会自动创建正确的行尾。这个方法并不适用于复制和粘贴整个文件;如果你这么做,你是在传输整个文件的内容。

另一个常见的语法错误是使用错误的引号字符,无论是混用引号,还是在脚本中执行命令时将反引号替换为引号:

图 15.4 – 完全正常的脚本,在 vim 中用语法高亮显示

图 15.4 – 完全正常的脚本,在 vim 中用语法高亮显示

如果我们将一个反引号改成引号,它将变成如下所示:

图 15.5 – 如果没有正确的高亮显示,像这样的错误可能会引发严重问题

图 15.5 – 如果没有正确的高亮显示,像这样的错误可能会引发严重问题

我们在这里使用的是 vim,你会立即注意到变化。编辑器理解语法,并以不同的颜色高亮显示适当的代码块。

如果我们尝试运行这个脚本,它会抛出一个错误:

demo@ubuntu:~/Desktop/allscripts$ bash backupexample.sh 
backupexample.sh: line 4: unexpected EOF while looking for matching '''
backupexample.sh: line 8: syntax error: unexpected end of file

这个错误有点令人困惑。Bash 告诉我们,在试图找到结束引号时,它已经到达了文件的末尾。

所有这些错误的共同点是使用不同编辑器中的不同字体。有时,字符之间的差异非常微小,难以察觉。Bash 通过报告错误时,有时会指向完全不同的代码部分,使得问题更难以发现。

解决这个问题的方法是使用你知道能清晰显示的字体,并且使用一个能够配对括号等字符的编辑器。引号和反引号可能仍然是个问题,因为大多数应用程序无法匹配它们。然而,像 vim 这样的编辑器会高亮显示注释,正如我们在之前的例子中看到的,这样就能让像这样的错误变得可见。

这是在 Windows 上的 Notepad++中高亮显示括号的一个例子,因为我们提到了多平台的方法:

图 15.6 – 在 Notepad 中高亮显示括号

图 15.6 – 在 Notepad 中高亮显示括号

当然,我们还有一些标准的、不可避免的语法错误。一个好的编辑器也能帮助我们发现这些错误:

图 15.7 – 使用一个能够高亮显示大括号和括号的编辑器将为你节省时间

图 15.7 – 使用一个能够高亮显示大括号和括号的编辑器将为你节省时间

错误出现在then关键字上,vim 通过将该关键字的颜色改为白色(而不是通常的黄色)来高亮显示它。

处理完语法问题后,接下来是看看如何避免那些可以说是更复杂且更难发现的逻辑错误。

如何做……

在脚本中提到逻辑可能会造成误导。逻辑可以在术语的严格定义下,是指需要逻辑表达式来工作的条款中的形式逻辑,或者更广泛地说,是指脚本中的任何决策。当我们说逻辑错误时,我们通常指的是后者;即当我们的脚本按照我们告诉它的方式运行,而不是我们认为我们告诉它的方式时,所产生的问题。所有非语法错误导致的意外行为都属于这一类。

例如,假设我们想使用sort命令对几个数字进行排序。这看起来很简单,但有一个小缺陷。sort默认是按字母顺序排序,而不是按数字顺序:

demo@ubuntu:~/Desktop/allscripts$ du -a | sort
0           ./errorfile
0           ./settings
264      .
4           ./arrayops.sh
4           ./array.sh
4           ./associative.sh
4           ./auxscript.sh
4           ./backupexample.sh
4           ./dialogdate.sh
4           ./dos.sh
4           ./doublevar.sh
4           ./echoline1.sh
4           ./echoline.sh

我们最终得到的值是264,它大于0但小于4,这是错误的。如果我们想按照预期排序,我们应该使用适当的开关:

demo@ubuntu:~/Desktop/allscripts$ du -a | sort -n
0           ./errorfile
0           ./settings
4           ./arrayops.sh
4           ./array.sh
4           ./associative.sh
4           ./auxscript.sh
.
.
.
264       .

这种情况要好得多。像这样的错误不完全是 Bash 的问题,而是在我们不确定如何使用某个命令时发生的,结果就是我们的脚本会出现异常行为。

另一件你经常会看到的事情是无效的索引引用。在数组中,索引从0开始,但人们通常从1开始计数:

图 15.8 – 在任何语言中编程时,索引编号错误很常见

图 15.8 – 在任何语言中编程时,索引编号错误很常见

当我们尝试运行时,我们将在输出中丢失一对变量,因为我们错过了数组中的第一个元素:

demo@ubuntu:~/Desktop/allscripts$ bash errpairs.sh 
Name:Luke number:12344
Name:Ivan from work number:113312
Name:Ida number:11111
Name:That guy number:122222
Name: number:

当运行脚本时,执行这些操作会产生的错误有时很容易发现,但某些用例,特别是仅涉及数组部分的用例,可能会产生奇怪的问题。同样的问题可能会在使用参数的循环中发生,并且如果我们不立即打印值,我们可能不会注意到我们只处理了数组的一部分。

从根本上讲,问题在于我们计数的起始数字的定义是非常任意的。通常情况下,我们使用 0 作为第一个索引,但也有一些例外。如果你不确定,可以进行检查。

所有这些问题都在这里广泛提到。你需要知道它们,但是你在你的脚本中处理它们的方式将会因每个脚本的不同而不同。我们在这里的目的是让你意识到问题的存在,这样你就可以在它变得危险之前发现它。

我们提到的最后一个大问题是,有些脚本大部分时间工作正常,只在某些情况下失败,并且仅部分失败。这是最糟糕的问题,因为你不能完全信任脚本的输出,并且很难发现,因为大部分时间输出都是完全正常的。处理这些问题的唯一方法是仔细检查问题的所有边界情况,并在脚本本身上对它们进行测试。

工作原理…

在这个示例中,我们在描述可能问题时故意模糊不清。就像所有直接与在解决某些问题时出错相关的事物一样,我们希望避免所有这些问题,但在出现错误之前不可能定义避免的内容。我们看到的大多数问题将是假设不当或对事实的错误理解的结果。有时,这将是一个简单的打字错误,将不会被注意到。

当编写脚本时,还有一件事可以做得更好。为了避免语法和逻辑(主要是语法)中的最常见问题,您可以使用自动化工具。

你可以使用两种类型的工具。我们已经提到过其中一种,尽管我们没有明确提到它是一个实际的工具。我们只是说过你的编辑器会处理大部分问题。当前可用的编辑器通常包含一些功能,使其能够理解你正在使用的语言的语法,并在发现问题时提供帮助。编辑器中这种支持通常是基础性的,它只限于能够理解关键词和特定语言的词汇结构。当编辑器能够识别文件并自动检测你正在使用的语言时,通常会开启此功能。我们已经看过类似的例子。如需更多信息,请查阅 第二章使用文本编辑器

然而,确实还有另一类工具可供使用。我们在这里说的完全是自动化工具,这些工具不仅能找到你脚本中的错误,还能够发现你命令中的潜在问题,甚至能够建议你如何改进代码。

你可能会想,运行一个会报告与 Bash 相同错误的脚本是否有意义,你的问题是合理的。Bash 本身完全能够报告任何语法错误,但它只包括一组最小的消息来帮助你解决问题。从本质上讲,Bash 只会报告那些会阻止代码正常运行的错误。

一个好的 代码分析 工具(在谈论这些应用时通常使用这个术语)会找到你代码中的问题,并给出改进代码的建议。报告的内容可能一眼就能看出来,但其中也有一些错误可能会导致问题,例如缺少引号或变量赋值错误。

其中一个这样的工具是 ShellCheck,它既有在线版也有离线版,离线版以包的形式提供。要使用离线版,你必须使用以下命令进行安装:

sudo apt install shellcheck

之后,只需要在你的脚本上运行它。我们稍后会介绍如何在浏览器中运行这个工具,它会给出与离线版本完全相同的结果。唯一的区别是界面和在浏览器中点击链接的简便性。两个版本报告的错误完全相同,建议也完全一样。

我们将对我们的一些脚本运行这个工具,看看它对我们的代码质量有什么评价。首先,我们将看到在我们犯了一个简单的语法错误时会发生什么。我们使用的是在介绍 if 语句时用过的脚本。这个脚本名为 testif3.sh,我们只是删除了包含 then 关键字的一行:

![图 15.9 – ShellCheck 提供的语法错误警告比 Bash 更加详细]

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_15.9_B16269.jpg)

图 15.9 – ShellCheck 提供的语法错误警告比 Bash 更好

我们可以看到该工具立即找到了问题,不仅报告了它,还给出了下一步应该做什么的建议。有趣的是,它标记了包含错误的if语句,而 Bash 给出的错误提示相对误导,指向了脚本中稍后的代码片段。

如果我们修复了错误,可以重新运行工具,如下所示:

demo@ubuntu:~/Desktop/allscripts$ shellcheck testif3.sh 
demo@ubuntu:~/Desktop/allscripts$

如果工具没有输出内容,意味着没有检测到错误。

现在,让我们在一个更复杂的脚本中进行尝试。在这个例子中,我们使用的是名为funcglobal.sh的脚本,来自第十二章**,使用参数和函数

图 15.10 – 以这种方式使用变量本身不是错误,但可能会引发问题

图 15.10 – 以这种方式使用变量本身不是错误,但可能会引发问题

输出看起来不太美观,因为终端中的字体过大,但它给了我们关于如何改进脚本的提示。正如我们之前提到的,空格是一个大问题,因此通过使用双引号,我们可以避免空格字符完全搞乱我们的脚本。

我们将做另一个例子,这是我们之前使用过的脚本的修改版,保存为functions.sh

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

        group)
            if [ "$2" != "" && "$3" != "" ]
                  then 
                       usermod -a -G $3 $2
            fi              
                       ;;
               newuser) 
                          if [ "$2" != "" ]
                          then
                                       useradd $2
                          fi
                          ;;
              weather)
                          if [ "$2" != "" ]
                                     then 
                                               curl wttr.in/$2
                                     else 
                                                  curl wttr.in
                          fi
                          ;;

        *)
            echo "ERROR: unknown parameter $1\""
            usage
            exit 1
            ;;
    esac
             else
             usage
fi

如果我们对这个脚本运行 ShellCheck,最终会得到一份长输出,其中一部分如下所示:

图 15.11 – 如果 ShellCheck 发现逻辑错误,它将发出警告

图 15.11 – 如果 ShellCheck 发现逻辑错误,它将发出警告

如果我们点击 ShellCheck 提供的作为输出最后一行的链接,会引导我们到详细的解释页面,正如这个截图所示:

图 15.12 – ShellCheck 提供的链接为你提供有关错误的详细信息

图 15.12 – ShellCheck 提供的链接为你提供有关错误的详细信息

这个解释不仅有用,而且还包含了更多的链接,供我们在了解究竟哪里出了问题、为什么出问题以及为什么这个问题需要注意时参考。有时,工具检测到的问题范围有限,可能在某些版本的 Bash 解释器中得到解决,但在另一些版本中依然存在问题。

另见…

故障排除是复杂的,因为我们无法预见所有可能的问题。不过,以下是一些常见问题:

简单的调试方法 – 在脚本执行期间回显值

当你开始使用 Bash 时,首先学会的就是如何在运行任何脚本时定期使用 echo 命令。这种方法很简单,因为它让我们有机会跟踪脚本的工作流,并在脚本的不同位置打印变量的值。能够理解这两点将帮助我们跟踪脚本的所有输入,并看到它们如何转化为我们预期的输出。

准备好

在这个教程中,我们将探讨一些简单的方法,使得我们的脚本可以帮助我们理解其运行过程。我们可以使用这三种简单的方法。

我们能做的第一件事是,在脚本中我们认为有帮助的地方使用 echo 命令。举个例子,看看前面章节中的一个脚本(funcglobal.sh),它已经相当冗长:

图 15.13 – 使用 echo 调试程序流程,在处理函数时非常有用

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_15.13_B16269.jpg)

图 15.13 – 使用 echo 调试程序流程,在处理函数时非常有用

我们将增加更多的 echo 语句,让我们能够准确看到发生了什么,并且以什么顺序发生:

图 15.14 – 在调试时,echo 命令永远不会太多

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_15.14_B16269.jpg)

图 15.14 – 在调试时,echo 命令永远不会太多

如果现在运行我们修改后的脚本,我们将能够精确地跟踪脚本的执行流程:

demo@ubuntu:~/Desktop/allscripts$ bash funcglobal.sh 
Declaring global variable as Global Variable
In the main script before function is executed variable has the value of: Global variable
Now calling the function
Entered Function
declaring local variable as Local Variable
Inside the function variable has the value of: Local variable
leaving function
returned from function
In the main script after function is executed value is: Global variable
script end

这种方法在处理大量代码块、函数和条件语句时特别有用。这里的基本思路是,我们可以使用 echo 来宣布进入和离开每个代码块,从而看到我们的脚本是否正确执行。

另一件我们要做的事情是,在脚本执行期间打印变量的值。在这样做的时候,我们建议你在脚本的代码中始终注明这个特定命令打印变量的地方。当以这种方式调试时,变量的值会按照它们被赋值的顺序打印到输出中,帮助我们跟踪脚本的执行流程。我们之前的示例已经做了这一点。

如何操作…

你还可以做一件事,使你的脚本在调试时提供更多信息。bash 中有一个内建命令叫做 trap。它的主要作用是帮助你响应中断信号,并确保即使发生意外情况,脚本也能继续运行。它的语法很简单 —— 我们只需要告诉它在什么情况下做什么。这里的“情况”指的是 Linux 下的任何中断信号。最常见的有 SIGHUPSIGKILLSIGQUIT,但还有很多其他信号。

例如,我们可以创建一个像这样的脚本:

图 15.15 – 在脚本中使用 trap 停止 Ctrl + C

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_15.15_B16269.jpg)

图 15.15 – 在脚本中使用 trap 停止 Ctrl + C

这一行代码的作用是建立一个被视为中断例程的东西。如果在脚本的任何时刻,有人按下Ctrl + C来中断它,我们的脚本会检测到这一点并执行引号内的两条命令:

demo@ubuntu:~/Desktop/allscripts$ bash echoline.sh 
Can you please input a word?: dsasd^CScript was interrupted
demo@ubuntu:~/Desktop/allscripts$ bash echoline.sh 
Can you please input a word?: nointerrupt
I got: nointerrupt
Script cleanly finished executing

当我们第一次尝试时,我们按下了中断键,我们的例程按预定操作执行,立刻退出脚本并给出警告。这条命令也非常有用,可以阻止中断脚本的尝试,因为它会执行我们告诉它的任何操作,然后继续执行脚本。

你可以做的另一件事是将EXIT作为trap命令中的关键字,如下所示:

trap "some command" EXIT

这个关键字涵盖了任何可能的退出脚本的方式,这意味着无论脚本发生什么,这个trap都会执行,并且会在控制权返回给运行脚本的进程之前运行。

以这种方式使用时,trap不仅对调试有用,还对脚本结束后的清理工作非常有帮助,因为它将在最后一个命令执行,允许你在脚本执行后进行必要的操作,如关闭文件和清理资源。

它是如何工作的……

无论你选择哪种方式来调试脚本,归根结底都是要对其进行大量修改。使用echo非常有用,但同时也需要在我们调试的脚本中添加很多命令。话虽如此,当你调试任何脚本时,这可能是你会尝试的第一个方法,因为它不仅可以帮助你理解脚本内部的值如何变化,还能让你了解整个脚本的工作原理,因为你能够获取到命令执行的确切位置,从而帮助你理解脚本的变量和工作流程。

使用trap是一种稍微更为细致的调试方法,当我们偏离了预期的程序流程时,它非常有用。若脚本中断或以其他方式停止,trap会为你提供发生了什么和发生地点的信息。

这里并没有特定的调试方法推荐,因为每种方法都在特定的场景下有效。我们可以说的是,你应该尝试所有方法,看看哪种最适合特定的场景。

另见

使用 bash 的-x 和-v 选项

到目前为止,我们已经尝试了使用不同方法调试脚本,这些方法都涉及在脚本中插入命令。不管我们使用的是哪条命令,这种方法有一个缺点——不管我们怎么做,脚本内部的命令要么非常局限于脚本的某个特定部分,要么过于广泛,因为它需要覆盖大段的代码。我们并不是说这种方法在解决脚本问题时无效,但我们仍然需要更多的调试方法。

准备就绪

在开始使用这些选项之前,我们需要了解的一点是,我们将通过将脚本作为解释器的参数来运行它们,所以会是这样的一种形式:

bash -options <scriptname>

这很重要,因为如果我们以其他方式调用脚本,就无法使用这些选项。

如何做到…

在这方面,Bash 相当复杂,因为它几乎没有提供任何系统性调试的支持,实际上几乎没有,我们已经提到过这一点。然而,仍然有一线希望,这就是两个开关,-x-v。第一个开关会启动打印脚本中运行的每个命令,并且还会打印出所有使用的命令参数。这使得理解命令的工作流程变得简单。

使用-v的效果可以说不那么有用。它只是简单地打印出脚本每一行被读取时的内容。

为了理解这些选项,我们将创建一个小示例,使用我们在另一个配方中使用的脚本,但这次我们将使用不同的开关来运行它。

首先,我们将使用-v

demo@ubuntu:~/Desktop/allscripts$ bash -v testif3.sh 
#!/usr/bin/bash
# testing premissions and paths 
if [ -d root ]
               then
                     echo root directory exists!
                     if [ -r root ]
                                   then
                       echo Script can read from the directory!
                                   else
                   echo Script can NOT read from the directory!    
                          fi
                          if [ -w root ]
                          then
                       echo Script can write to the directory!
                          else
                                       echo Script can not write to the directory!    
                          fi
                   else 
                          echo root directory does NOT exists!
fi          
root directory exists!
Script can read from the directory!
Script can write to the directory!

现在,我们将使用-x来运行脚本:

demo@ubuntu:~/Desktop/allscripts$ bash -x testif3.sh 
+ '[' -d root ']'
+ echo root directory 'exists!'
root directory exists!
+ '[' -r root ']'
+ echo Script can read from the 'directory!'
Script can read from the directory!
+ '[' -w root ']'
+ echo Script can write to the 'directory!'
Script can write to the directory!

这两个开关在调试中各有其作用。当我们说-v-x不那么有用时,我们的意思是它仅仅让我们了解 Bash 如何解析脚本,但除此之外没有更多信息。

使用-x可以让我们看到 Bash 是如何执行脚本以及在执行过程中运行了哪些命令。你必须明白的是,这不会是脚本中所有命令的列表,而仅仅是那些实际上执行了的命令。如果脚本的某一部分没有被使用,例如它属于一个仅在特定条件下才会执行的命令块,那么这种运行脚本的方式就不会显示出来。

它是如何工作的…

最常见的做法是同时使用两个开关,因为它使我们能快速了解脚本的样子以及 Bash 在执行时做了什么。在一个较大的脚本中,这将生成大量的输出,但通常这正是我们实际想要做的。然后,我们可以逐步查看脚本,理解它实现的逻辑。

另一方面,我们不能将其视为解决任何问题的普适方案。虽然它为我们提供了很多关于所运行命令的信息,但它在变量值和数据处理过程中实际发生的情况方面非常有限。以文件forloop1.sh中的这个循环为例,它是书中附带的文件的一部分:

demo@ubuntu:~/Desktop/allscripts$ bash -x forloop1.sh 
+ for name in {user1,user2,user3,user4}
+ for server in {srv1,srv2,srv3,srv4}
+ echo 'Trying to ssh user1@srv1'
Trying to ssh user1@srv1
+ for server in {srv1,srv2,srv3,srv4}
+ echo 'Trying to ssh user1@srv2'
Trying to ssh user1@srv2
+ for server in {srv1,srv2,srv3,srv4}
+ echo 'Trying to ssh user1@srv3'
Trying to ssh user1@srv3
+ for server in {srv1,srv2,srv3,srv4}
+ echo 'Trying to ssh user1@srv4'

我们不会复制整个输出,因为它没有其他有用的信息。这里,我们可以看到我们正在一个for循环中循环,并且可以看到可能的值,但除非我们打印它,否则无法看到特定变量的实际值。这意味着,我们将不得不将这种调试方式与本章中介绍的其他调试方法结合使用。

另见…

使用set调试脚本的一部分

在前一个示例中,我们处理了全局使用两个选项来告诉 Bash 在输出中包含大量有用信息。我们提到这提供了另一种处理调试和排查脚本问题的方法。同时,我们也提到,这种方法与在脚本本身中使用命令的方式形成鲜明对比,因为在调试时我们可以全局处理,而无需对脚本做太多更改。

在这个方法中,我们将介绍另一种调试方式,这种方式与之前介绍的方式有很多相似之处,同时也有所不同。

准备就绪

在 Bash 中,一个非常有趣的内置命令是set。它的作用是让我们能够改变 Bash 使用的选项。通过使用set,我们可以更改许多内容,几乎是 Bash 所有的选项。在这个例子中,我们只使用了其中的两个,但你可以开启或关闭所有选项。

set使我们能够在代码的小块中启用特定的选项,而不是全局使用它。你还需要知道,set可以同时开启和关闭选项。如果我们使用带有符号的set,则会启用选项。例如,我们可以这样使用:

set -x 

这告诉 Bash 在执行命令时显示它们。

一种稍微令人困惑的方式是,如果我们关闭当前使用的任何选项。为了做到这一点,我们必须使用+符号,这有点反直觉,因为通常符号用于打开某些东西,而不是关闭。例如,看看这个命令:

set +x 

这将关闭 Bash 中命令的输出。

我们将通过几个例子来帮助你更好地理解。

如何实现…

使用set非常简单。在任何我们希望调试的脚本中,我们只需在开始跟踪的地方之前插入set语句。当我们不再需要跟踪脚本时,只需取消设置该选项,就完成了。我们可以根据需要多次执行此操作。

这是一个例子:

图 15.16 – 在运行脚本时如何设置和取消设置选项

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_15.16_B16269.jpg)

图 15.16 – 在运行脚本时如何设置和取消设置选项

在这个例子中,我们在测试变量之前开始跟踪。这意味着我们将在进行测试之前开始跟踪,并在继续循环之前停止跟踪。结果是这样的:

demo@ubuntu:~/Desktop/allscripts$ bash forbreak.sh 
running command1, number is 1
running command2, number is 1
+ '[' 1 -eq 4 ']'
+ set +xv
running command3, number is 1
running command1, number is 2
running command2, number is 2
+ '[' 2 -eq 4 ']'
+ set +xv
running command3, number is 2
running command1, number is 3
running command2, number is 3
+ '[' 3 -eq 4 ']'
+ set +xv
running command3, number is 3
running command1, number is 4
running command2, number is 4
+ '[' 4 -eq 4 ']'
+ echo breaking out of loop, number is 4
breaking out of loop, number is 4
+ break

我们甚至可以把这种调试方式看作是全局使用选项的一种特殊情况。我们基本上做的和之前示例中的set全局使用相同,但这次我们限制了作用范围,使输出更易读。有时,这可以让工作变得更轻松,因为我们不会生成太多无关的输出。

它是如何工作的…

set 命令还可以用于一个额外的功能,那就是强制 Bash 执行一些与平常不同的操作。例如,我们可以让脚本在其中任何命令失败时终止,或者当脚本引用一个未设置的变量值时让其失败,甚至可以改变 Bash 在命令行中扩展字符的方式。

所有这些内容在工作时可能非常有用,但在刚开始编写脚本时,可能会有点过于复杂,难以一次性掌握,因此我们决定在这些教程中不包括它们。

另见…

第十六章:用于服务器管理、网络配置和备份的 Shell 脚本示例

现在我们已经涵盖了在 Bash 脚本编程中涉及的各种概念和结构,接下来我们来深入探讨一些实例。通过这些示例,我们可以将之前几章的内容应用到实际中,因为 Shell 脚本是系统工程师日常工作中最常用的工具。因此,我们将通过一些 Shell 脚本来强调脚本编写的重要性——让我们的工作变得更加轻松。

本章将涵盖以下脚本示例:

  • 创建文件和文件夹清单

  • 检查是否以 root 用户身份运行

  • 显示服务器统计信息

  • 按文件名、所有者或内容类型查找文件,并将其复制到指定目录

  • 解析日期和时间数据

  • 配置最常见的防火墙设置(firewalldufw

  • 配置网络设置交互式(nmcli

  • 使用 Shell 脚本参数和变量备份当前目录

  • 根据用户输入的备份源和目标路径创建当前备份

技术要求

让我们继续使用我们的 Ubuntu 机器,特别是 cli1 机器。如果你还没有启动它,请现在启动,以便我们可以继续进行示例。我们还将使用 cli2 CentOS 机器进行一些操作,因此请确保在需要时启动它。

创建文件和文件夹清单

让我们从一个简单的脚本开始——一个报告文件夹和文件清单的脚本。尽管这个脚本看起来简单,但它可以使用各种不同的工具,包括命令、内建 CLI 应用程序、循环——选择很多。我们将用最简单的方式来做——利用我们对命令和 CLI 应用程序的了解。我们将创建几个不同版本的这个脚本,因为它可以有多种用途——例如,作为未来脚本的输入,或者作为纯文本报告工具。

如何实现…

我们从创建一个简单的脚本开始,这个脚本将告诉我们以下内容:

  • 当前文件夹中文件夹的数量及其大小,并按大小降序排列

  • 当前文件夹中的文件数量及其大小,按大小降序排列

这是我们脚本的第一个版本——我们将其保存为名为sscript1.sh的文件:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021 Initial script version 
# Distribution allowed under GNU Licence V2.0
echo "Number of directories in current directory is:"
find . -type d | wc -l
echo "Directory usage, sorted in descending order, is as follows:"
find . -type d | du | sort -nr
echo "Number of files in current directory is:"
find . -type f | wc -l
echo "File usage, sorted in descending order, is as follows:"
find . -type f -exec ls -al {} \; | sort -k 5 -nr | sed 's/ \+/\t/g' | cut -f5,9

如我们所见,我们这里只使用了基本命令,没有涉及太多的循环、实际编程等复杂操作。我们将此视为一个报告脚本,从这里开始。

在我们的 cli1 机器上,结果如下:

图 16.1 – 我们的文件夹和文件清单脚本的第一个版本

图 16.1 – 我们的文件夹和文件清单脚本的第一个版本

这已经可以工作,并且可以用于报告——是的,一切正常。但是如果我们想要更多功能怎么办?假设我们使用这个脚本的第一个版本生成一个 .txt 文件,里面包含当前目录中文件的列表,稍作修改后,再利用这个文件做其他事情,比如将这些文件复制到预设位置。

我们需要做一些调整,具体如下:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021 Initial script version 
# Distribution allowed under GNU Licence V2.0 
# First, let's find out if the destination directory exists by
# using test function. If it does, go on with the script. If it
# doesn't, create that destination directory. In our example,
# destination directory is called copylocation.
if [ -d "./copylocation" ]
then
             echo "Directory ./copylocation exists."
else
              echo "Error: Directory ./copylocation does not exist."
              mkdir ./copylocation
fi
# next step, let's create a friendly file list with all of the files
# in current folder
find . -type f -exec ls -al {} \; | sed 's/  */ /g' | cut -f9 -d" " > filelist.txt
# Last step, let's load this file into variable so that we can loop
# over it and copy every file from it to our destination folder
file_list='cat filelist.txt'
for current_file in $file_list
do
             echo "Copying $current_file to destination"
             cp "$current_file" ./copylocation
done

接下来的脚本虽然简单,但非常非常有用——它是用来检查我们是否以 root 身份运行脚本。这个逻辑很简单——有些脚本我们不希望以 root 身份运行,因为怕不小心破坏系统配置,这时我们就会利用一些可访问的资源。我们来看看怎么做。

另见

如果你需要更多关于 sortfindwccutsed 的信息,建议访问以下链接:

检查你是否以 root 身份运行

有不同的方法可以检查我们是否以 root 身份运行脚本。我们可以使用环境变量,也可以使用 whoamiid 命令,检查返回的是否是 root 或数字 0

准备工作

我们将继续使用 cli1 机器来进行本教程,所以确保它已开启。

如何实现…

让我们创建一个简单的 Bash 脚本片段,帮助我们判断当前脚本是否以 root 身份运行。在 Linux 中,这其实是一个很简单的操作,因为我们可以轻松访问一个叫做 EUID 的环境变量,读取它的值即可判断我们是否以 root 身份运行(EUID=0)还是以其他身份运行(EUID 大于 1):

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021 Initial script version 
# Distribution allowed under GNU Licence V2.0 
# First, we need to check if our environment variable UID is set to
# 0 or not and branch that out to either yes or no with appropriate
# status messages
if [ "$EUID" -eq 0 ]
             then
                         echo "You are running as root user. Please be careful!"
             else
                         echo "You are not root. It's all sunshine and roses, you can't do much damage!"
fi
exit 0

接下来的例子我们将讨论如何显示服务器统计信息。我们将使用 sar 命令来实现,开始吧!

另见

如果你需要更多关于内部变量的信息,建议访问 tldp.org/LDP/abs/html/internalvariables.html

显示服务器统计信息

假设我们需要编写一个 shell 脚本,显示以下信息:

  • 当前主机名

  • 当前日期

  • 当前内核版本

  • 当前 CPU 使用情况

  • 当前内存使用情况

  • 当前交换空间使用情况

  • 当前磁盘 I/O

  • 当前网络带宽

这更多是一个过滤数据并使用命令的练习,但在如何格式化数据以使其看起来漂亮易读方面,有一些有趣的概念。这是我们认为非常重要的内容。

准备工作

我们需要保持cli1机器的运行。此外,为了让此脚本正常工作,我们需要部署sysstat包,然后启用必要的服务。我们可以使用以下命令来实现 Ubuntu:

sudo apt-get -y install sysstat

我们可以使用以下命令来实现 CentOS:

sudo yum -y install sysstat

之后,我们需要启动sysstat服务:

sudo systemctl enable --now sysstat

现在,我们可以开始编写我们的脚本了。

如何做到这一点…

我们将使用sar命令获取关于 Linux 机器的大量信息,并且我们还将过滤掉一些不必要的细节。我们的脚本应如下所示:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021 Initial script version 
# Distribution allowed under GNU Licence V2.0 
echo "Hostname: $(hostname)"
echo "Current date: $(date)"
echo "Current kernel version and CPU architecture: $(uname -rp)"
# sar command has a default first line output telling us that it's
# running on Linux, and which kernel we are using. It's pointless
# to get this information four or five times, so let's filter that
# out from the get-go (grep -v "Linux" part of every command)
echo "Current CPU usage:"
sar -u 1 1| grep -v "Linux"
echo ""
echo "Current memory usage:"
sar -r 1 1| grep -v "Linux"
echo ""
echo "Current swap space usage:"
sar -S 1 1| grep -v "Linux"
echo ""
# When sar displays disk I/O info, it displays that info per
# device, which isn't all that important. What's important for
# us are sd* and vd* devices, as well as the status line telling
# us which specific metrics are shown in the column (DEV).
echo "Current disk I/O:"
sar -d 1 1| grep -E "(DEV|sd|vd)" | grep -v "Linux"
echo ""
# When sar displays network information, it shows it per device.
# Having in mind that we have a loopback network device (lo) and 
# that its statistics isn't important, let's just filter that out
# so that we can see network bandwidth info per real network device
echo "Current network bandwidth usage:"
sar -n DEV 1 1| grep -v lo | grep -v "Linux"

我们在这里多次使用了echo "",以使我们的输出看起来清晰易读。输出应如下所示:

图 16.2 – 从我们的脚本中显示服务器统计信息

图 16.2 – 从我们的脚本中显示服务器统计信息

下一个配方是关于查找内容——按名称、所有权或扩展名——以便我们可以将找到的内容复制到特定位置。开始吧!

还有更多…

如果您需要了解更多关于sar命令的信息,请查看sar命令的手册页面:man7.org/linux/man-pages/man1/sar.1.html

按名称、所有权或内容类型查找文件,并将其复制到指定目录

文件管理可能会有点负担。通常我们有成千上万的文件,如果我们讨论的是企业级公司,可能有数百万个文件。如果我们需要查找符合特定条件的文件该怎么办?

我们从一个简单的开始——按名称查找。接着,我们将进入基于所有权的搜索,最后是最复杂的——基于内容类型的搜索。

准备工作

在开始这个配方之前,您需要确保我们的cli1虚拟机已启动并正常运行。

如何做到这一点…

这是一个完美的脚本,可以进行更多交互,因此,我们将使用 case 循环。我们有意识地大量使用 case,并加入了许多状态/调试代码,可以指导我们如何使用脚本。

我们希望将这个脚本分成三个部分,因为它将执行三项不同的任务。以下是脚本的样子:

#!/usr/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021 Initial script version 
# Distribution allowed under GNU Licence V2.0 
read -p "Enter directory to move file to: " DESTDIR
echo -e "\n"
# Let's first establish a destination directory with a loop that can test if that directory exists or not
if [ "$DESTDIR" == "" ];
then
        echo "You must specify a directory."
else
        if [ ! -d "$DESTDIR" ]
        then
                echo "Directory $DESTDIR must exist. Exiting!"

                exit
        fi
fi
# Directory is ready, let's go to the main part of the script. First
# step is selecting which type of search we want to use.
echo "Enter number denoting criteria for search: "
echo "1 = by name "
echo "2 = by ownership "
echo "3 = by content extension "
echo -e "\n"
read CRIT
# Let's start our case loop against CRIT variable.
case $CRIT in
        1)
                read -p "Enter name to search for: " NAME
                echo -e "\n"
                if [ ! -z  $NAME="" ]
                        then
                                find / -name "$NAME" -exec cp {} $DESTDIR \; 2> /dev/null
                        else
                                echo You have to enter the name!
                fi
                ;;
        2)
                read -p "Enter owner to search for: " OWNER
                echo -e "\n"
                if [ ! -z $OWNER="" ]
                      then
                                find / -user $OWNER -exec cp {} $DESTDIR \;  2> /dev/null
                        else
                                echo You have to input an owner!
                fi
                ;;
        3)
                read -p "Enter content extension: " CEXT
                echo -e "\n"
                if [ ! -z $CEXT="" ]
                        then
                                read -p "Where are we looking for files, in which directory?" LOOKUP
                                find "$LOOKUP" -type f -name "$CEXT" -exec cp {} $DESTDIR \; 2> /dev/null
                        else
                                echo You have to enter the content type!
                fi
                  ;;
        *)      echo please make a choice, either 1, 2 or 3!
esac

请注意,当我们被询问扩展名时,我们必须输入类似*.txt的内容,才能使此脚本正常工作。以下是脚本执行时,带有该扩展名的样子:

图 16.3 – 以文件扩展名作为条件执行脚本

图 16.3 – 以文件扩展名作为条件执行脚本

在下一个食谱中,我们将学习如何处理基于日期和时间的数据,这是在 shell 脚本中常用于索引的概念。虽然它易于使用和理解,但我们需要学习如何通过编程、通过变量来使用这个概念。那么,接下来我们就来做这件事!

还有更多……

如果您需要更多关于sar命令的信息,建议您查看以下链接以了解更多:https://www.howtogeek.com/662422/how-to-use-linuxs-screen-command/

解析日期和时间数据

处理基于时间的数据通常不太有趣,尤其是当你处理大量时间相关内容时。但在我们的使用场景中,我们通常使用日期/时间信息进行索引;也就是说,用它来命名我们的备份文件及类似用途。因此,学习如何从date命令获取信息,并将这些信息存储到变量中,以便让我们的代码尽可能模块化,是非常重要的。让我们创建一个 shell 脚本,作为将来脚本中可复用的代码片段——至少是其中的部分。

准备工作

我们不需要安装任何特殊的工具,只需要我们的 Linux 机器处于活动状态并准备就绪。

如何做……

我们将回归基础,使用date命令提取我们未来可能需要的所有日期和时间信息:

  • 当前时间(以小时、分钟和秒表示)的信息

  • 今天日期的信息

  • 今天是星期几的信息

让我们在文本编辑器中输入以下内容并执行脚本:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021 Initial script version 
# Distribution allowed under GNU Licence V2.0 
# This part of our script is just plain using date command to assign
# values to "obviously named variables". This further shows two
# things - how to assign a variable value from external command,
# and how to use that principle on date and time data.
hour=$(date +%H)
minute=$(date +%M)
second=$(date +%S)
day=$(date +%d)
month=$(date +%m)
year=$(date +%Y)
# Let's print that out
echo "Current time is: $hour:$minute:$second"
echo "Current date is: $day-$month-$year"

这是示例输出的一个例子。我们将此脚本命名为sscript2.sh

图 16.4 – 我们的日期和时间脚本的示例输出

图 16.4 – 我们的日期和时间脚本的示例输出

这对备份脚本非常有用——例如,当我们按日期为备份文件(.tar.gz或其他格式)进行索引时。这是我们将在本章后续使用的一个概念。现在,让我们学习如何通过 shell 脚本配置防火墙设置。

交互式配置最常见的防火墙设置

防火墙配置就是这样的事情——我们经常需要做这件事,但我们不一定能马上记得所有命令。让我们通过 shell 脚本来完成它,适用于 CentOS(firewalld)和 Ubuntu(ufw)。

准备工作

在开始本食谱之前,您需要确保您的 CentOS 机器上已经启动了firewalld,Ubuntu 机器上启动了ufw。因此,首先,您需要使用以下命令:

systemctl status firewalld

在 CentOS 和 Ubuntu 上使用以下命令:

systemctl status ufw

如果它们被禁用,我们需要像这样启用它们:

systemctl enable --now firewalld

在 CentOS 和 Ubuntu 上,您可以使用以下命令:

systemctl enable --now ufw

现在,我们已经准备好开始了。当然,你需要以管理员身份登录才能更改防火墙配置,所以确保你已经以 root 用户(或具有类似权限的用户)登录,或者使用 sudo 配置来更改防火墙配置。

此外,使用 firewalld 时,很多人很难记住它使用的服务名称。其实这不是什么问题——我们只需要使用以下命令:

firewall-cmd --get-services

对于 ufw,我们只需查看 /etc/service,因为所有服务名称都列在那里,并且 ufw 使用这些名称进行配置。

如何操作…

首先,让我们为 firewalld 创建一个基于 CentOS 的脚本。我们将包括八个标准操作——操作服务配置、TCP 和 UDP 端口及丰富规则,既能添加也能删除,同时具备列出当前配置的功能。脚本应该是这样的:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021 Initial script version 
# Distribution allowed under GNU Licence V2.0 
echo "1 = firewalld (CentOS) - manipulate service configuration - add"
echo "2 = firewalld (CentOS) - manipulate service configuration - remove"
echo "3 = firewalld (CentOS) - manipulate TCP ports - add"
echo "4 = firewalld (CentOS) - manipulate TCP ports - remove"
echo "5 = firewalld (CentOS) - manipulate UDP ports - add"
echo "6 = firewalld (CentOS) - manipulate UDP ports - remove"
echo "7 = firewalld (CentOS) - manipulate rich rules - add"
echo "8 = firewalld (CentOS) - manipulate rich rules - remove"
echo "9 = firewalld (CentOS) - list current configuration"
echo -e "Your choice:"
read CRIT
# Let's start our case loop against CRIT variable.
case $CRIT in
        1)
                echo "Enter service names, using space as separator."
                echo "Hint: ssh http https etc. Get list from firewall-cmd --get-services"
                echo "Your input:"
                read -a FW1
                for svcs1 in ${FW1[@]}
                do
                        firewall-cmd --permanent --add-service=$svcs1
                done
                firewall-cmd --reload
                ;;
        2)
                echo "Enter service names, using space as separator."
                echo "Hint: ssh http https etc. Get list from firewall-cmd --get-services"
                echo "Your input:"
                read -a FW2
                for svcs2 in ${FW2[@]}
                do
                        firewall-cmd --permanent --remove-service=$svcs2
                done
                firewall-cmd --reload
                ;;
        3)
                echo "Enter TCP port numbers, using space as separator."
                echo "Hint: 22 80 443 etc."
                echo "Your input:"
                read -a FW3
                for svcs3 in ${FW3[@]}
                do
                        firewall-cmd --permanent --add-port=$svcs3/tcp
                done
                firewall-cmd --reload
                ;;
        4)
                echo "Enter TCP port numbers, using space as separator."
                echo "Hint: 22 80 443 etc."
                echo "Your input:"
                read -a FW4
                for svcs4 in ${FW4[@]}
                do
                        firewall-cmd --permanent --remove-port=$svcs4/tcp
                done
                firewall-cmd --reload
                ;;
        5)
                echo "Enter UDP port numbers, using space as separator."
                echo "Hint: 22 80 443 etc."
                echo "Your input:"
                read -a FW5
                for svcs5 in ${FW5[@]}
                do
                        firewall-cmd --permanent --add-port=$svcs5/udp
                done
                firewall-cmd --reload
                ;;
        6)
                echo "Enter UDP port numbers, using space as separator."
                echo "Hint: 22 80 443 etc."
                echo "Your input:"
                read -a FW6
                for svcs6 in ${FW6[@]}
                do
                        firewall-cmd --permanent --remove-port=$svcs6/udp
                done
                firewall-cmd --reload
                ;;
        7)
                echo "Let's manipulate rich rules - to add specific IPs access to specific port."
                echo "Hint: first, we need an endpoint IP address, like 45.67.98.43                   "
                echo "Your input (IP address):"
                read -a FW71
                echo "To which TCP port you want to allow access?"
                echo "Your input (TCP port number):"
                echo "Your input:"
                read -a FW72
                for svcs71 in ${FW71[@]}
                do
                        for svcs72 in ${FW72[@]}
                        do
                                firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="'$svcs71'/32" port protocol="tcp" port="'$svcs72'" accept'
                        done
                done
                firewall-cmd --reload
                ;;
        8)
                echo "Let's manipulate rich rules - to add specific IPs access to specific port."
                echo "Hint: first, we need an endpoint IP address, like 45.67.98.43"
                echo "Your input (IP address):"
                read -a FW81
                echo "To which TCP port you want to allow access?"
                echo "Your input (TCP port number):"
                echo "Your input:"
                read -a FW82
                for svcs81 in ${FW81[@]}
                do
                        for svcs82 in ${FW82[@]}
                        do
                                firewall-cmd --permanent --remove-rich-rule='rule family="ipv4" source address="'$svcs81'/32" port protocol="tcp" port="'$svcs82'" accept'
                        done
                done
                firewall-cmd --reload
                ;;
        9)
                echo "Let's just list the firewalld settings first:"
                firewall-cmd --list-all
                echo "Let's list all the rich rules, if any:"
                firewall-cmd --list-rich-rules
                ;;
        *)      echo "Please make a correct choice, available choices are 1-9!"
esac

这是很多代码,但它让代码变得更加可读(因为我们使用了 case 循环)。我们本可以用几种不同的方式做到这一点,但这是最容易调试的代码,最重要的是,它能很好地工作。

现在,让我们来看一下 Ubuntu 的 ufw 脚本,它将非常相似——我们只需要确保 ufw 命令正确即可。我们还将查看两种删除规则的方法(通过索引号和通过规则),这样我们就知道如何同时进行操作:

#!/bin/bash
echo "1 = ufw (Ubuntu) - manipulate service configuration - add"
echo "2 = ufw (Ubuntu) - manipulate service configuration - remove"
echo "3 = ufw (Ubuntu) - manipulate TCP ports - add"
echo "4 = ufw (Ubuntu) - manipulate TCP ports - remove"
echo "5 = ufw (Ubuntu) - manipulate UDP ports - add"
echo "6 = ufw (Ubuntu) - manipulate UDP ports - remove"
echo "7 = ufw (Ubuntu) - manipulate whitelist IP/port configuration - add"
echo "8 = ufw (Ubuntu) - manipulate whitelist IP/port configuration - remove"
echo "9 = ufw (Ubuntu) - list current configuration"
echo -e "Your choice:"
read CRIT
# Let's start our case loop against CRIT variable.
case $CRIT in
        1)
                echo "Enter service names, using space as separator."
                echo "Hint: ssh http https etc. Get list from /etc/services"
                echo "Your input:"
                read -a FW1
                for svcs1 in ${FW1[@]}
                do
                        ufw allow $svcs1
                done
                ;;
        2)
                echo "Enter rule numbers from the list:"
                ufw status numbered
                echo "Your input, single number or multiple numbers separated by space:"
                echo "Hint: Best way to do it would be backwards - from top rule number to bottom rule number!"
                read -a FW2
                for svcs2 in ${FW2[@]}
                do
                       echo "y" | ufw delete $svcs2
                done
                ;;
        3)
                echo "Enter TCP port numbers, using space as separator."
                echo "Hint: 22 80 443 etc."
                echo "Your input:"
                read -a FW3
                for svcs3 in ${FW3[@]}
                do
                        ufw allow $svcs3/tcp
                done
                ;;
        4)
                echo "Enter TCP port numbers, using space as separator."
                echo "Hint: 22 80 443 etc."
                echo "Your input:"
                read -a FW4
                for svcs4 in ${FW4[@]}
                do
                        ufw delete allow $svcs4/tcp
                done
                ;;
        5)
                echo "Enter UDP port numbers, using space as separator."
                echo "Hint: 22 80 443 etc."
                echo "Your input:"
                read -a FW5
                for svcs5 in ${FW5[@]}
                do
                        ufw allow $svcs5/udp
                done
                ;;
        6)
                echo "Enter UDP port numbers, using space as separator."
                echo "Hint: 22 80 443 etc."
                echo "Your input:"
                read -a FW6
                for svcs6 in ${FW6[@]}
                do
                        ufw delete allow $svcs6/udp
                done
                ;;
        7)
                echo "Let's manipulate whitelist rules - to add specific IPs access to specific port."
                echo "Hint: first, we need an endpoint IP address, like 45.67.98.43"
                echo "Your input (IP address):"
                read -a FW71
                echo "To which port you want to allow access?"
                echo "Your input (port number):"
                echo "Your input:"
                read -a FW72
                for svcs71 in ${FW71[@]}
                do
                        for svcs72 in ${FW72[@]}
                        do
                                ufw allow from $svcs71 to any port $svcs72
                        done
                done
                ;;
        8)
                echo "Let's manipulate whitelist rules - to remove specific IPs access to specific port."
                echo "Hint: first, we need an endpoint IP address, like 45.67.98.43"
                echo "Your input (IP address):"
                read -a FW81
                echo "To which port you want to allow access?"
                echo "Your input (port number):"
                echo "Your input:"
                read -a FW82
                for svcs81 in ${FW81[@]}
                do
                        for svcs82 in ${FW82[@]}
                        do
                                ufw delete allow from $svcs81 to any port $svcs82
                        done
                done
                ;;
        9)
                echo "Let's list the ufw settings:"
                ufw status
                ;;
        *)      echo "Please make a correct choice, available choices are 1-9!"
esac

好了——这是另一个长脚本完成了。这应该能帮助我们在频繁使用 Ubuntu 时。接下来,我们将进入一个不同的方向——使用 nmcli 在交互式脚本模式下配置 CentOS 上的网络设置。

还有更多…

有关 firewall-cmdufw 命令行选项的更多信息,我们建议你访问以下链接:

  • firewall-cmd 手册页: https://firewalld.org/documentation/man-pages/firewall-cmd.html

  • 使用 rich language 语法配置复杂的防火墙规则:https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/7/html/security_guide/configuring_complex_firewall_rules_with_the_rich-language_syntax

  • ufw 快捷手册:https://blog.rtsp.us/ufw-uncomplicated-firewall-cheat-sheet-a9fe61933330

交互式配置网络设置

通常,我们没有访问图形界面(GUI)和基于 GUI 的配置工具。如果我们需要配置网络设置,这可能会导致一系列问题。要么我们需要学习 /etc/sysconfig/network-script 文件的语法(这并不友好),要么我们需要使用可用工具从命令行界面(CLI)配置网络设置。让我们学习如何使用 nmcli 来实现这个目的。

准备就绪

在开始此配方之前,你需要确保你正在使用我们的 cli2 CentOS 机器,因为 Ubuntu 默认不使用 nmcli。完成后,你就可以开始了!

如何操作…

通过 nmcli 配置网络设置并不困难,但同时,它远非非常用户友好。涉及的语法比较复杂,有时可能会让人感到有些困惑。所以,让我们创建一个脚本,帮助我们做三件事:

  • 通过 nmcli 配置网络设置,以便我们使用静态 IP 网络配置。

  • 通过 nmcli 配置网络设置,以便我们使用 DHCP 网络配置。

  • 检查/输出当前的网络设置。

我们的脚本应如下所示:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021 Initial script version 
# Distribution allowed under GNU Licence V2.0 
echo "1 = nmcli - static IP address configuration for existing interface"
echo "2 = nmcli - reconfigure a static IP-based configuration to DHCP"
echo "3 = nmcli - list current device and connection status"
echo -e "Your choice:"
read CRIT
# Let's start our case loop against CRIT variable.
case $CRIT in
        1)
                echo "Let's first check current connection configuration:"
                nmcli con show
                echo "Which interface do you want to configure from this list?"
                echo "HINT: We need to use an entry from NAME field"
                echo "Type in the interface name: "
                read -a interface1
                echo "Type in the IP address/prefix: "
                read -a address1
                echo "Type in the default gateway IP address: "
                read -a gateway1
                echo "Type in DNS servers, use space to separate entries: "
                read -a dns1
                echo
                nmcli con mod $interface1 ipv4.address "$address1" ipv4.gateway "$gateway1"
                nmcli con mod $interface1 ipv4.method manual
                for dnsservers in ${dns1[@]}
                do
                        nmcli con mod $interface1 ipv4.dns $dnsservers
                done
                systemctl restart NetworkManager
                ;;
        2)
                echo "Let's first check current connection configuration:"
                nmcli con show
                echo "Which interface do you want to configure from this list?"
                echo "HINT: We need to use an entry from NAME field"
                echo "Type in the interface name: "
                read -a interface1
                nmcli con mod $interface1 ipv4.method auto
                systemctl restart NetworkManager
                ;;
        3)
                echo "Current status of network devices: "
                nmcli dev show
                echo "Current status of network connections: "
                nmcli con show
                ;;
        *)      echo "Please make a correct choice, available choices are 1-3!"
esac

如果我们使用这个脚本,输出将如下所示:

图 16.5 – 从 shell 脚本配置网络接口,将其设置为静态 IP 配置

图 16.5 – 从 shell 脚本配置网络接口,将其设置为静态 IP 配置

如我们所见,所有的网络设置都已应用。此外,第二个使用场景——从现有配置恢复使用 DHCP——输出将如下所示:

图 16.6 – 正确设置 BOOTPROTO 参数后,恢复我们的 DHCP 配置

图 16.6 – 正确设置 BOOTPROTO 参数后,恢复我们的 DHCP 配置。

这个文件看起来也不错,因此我们可以继续使用这个脚本。

接下来的脚本将涉及备份——一个将使用 shell 脚本参数和变量,另一个将使用 tar 的一个非常有用的特性。让我们一起编写一些备份脚本吧!

还有更多内容…

Screen 需要一些试验和错误,并且需要适应。我们建议您查看以下链接,以了解更多信息:

  • nmcli 手册页:https://linux.die.net/man/1/nmcli

  • nmcli 示例:https://people.freedesktop.org/~lkundrak/nm-docs/nmcli-examples.html

使用 shell 脚本参数和变量备份当前目录

系统工程师使用 Bash shell 脚本的最常见原因之一是为了备份。虽然有多种工具可供使用,但为了进行 shell 脚本编写,我们将创建几个基于 tar 的 shell 脚本,处理参数和变量,并学习如何通过使用 shell 脚本来简化我们的备份工作。让我们来看一下!

准备就绪

在你开始这个过程之前,需要确保你的 Linux 机器上已安装 tar。为此,你需要使用以下命令:

sudo apt-get -y install tar

如果你使用的是 CentOS 系统,请使用以下命令:

sudo yum -y install tar

现在,你已准备好开始了。

如何实现…

我们的第一个备份脚本的前提是基于 tar,其目标很简单:

  • 我们希望能够在使用参数设置备份文件名时创建备份。

  • 我们希望能够轻松地修改我们的 shell 脚本,以便它可以备份我们想要的任何数量的目录(这可以通过在backup_source变量中列出源目录来轻松实现)。

让我们看看这个如何工作:

#!/bin/sh
# V1.0 / Jasmin Redzepagic / 01/11/2021 Initial script version 
# Distribution allowed under GNU Licence V2.0 
# This script contains some pre-defined parameters:
# - which directories we want to backup, used as a variable
# backup_source
# - destination folder, via variable called backup_dest
# - indexing according to date, used as a variable date
#
# Also, it uses a shell script argument $1 (first argument that we
# use to call on the script) to set value for variable filename
filename=$1
# let's set the directory that we want to backup
# if we want to backup more of them, we create a space-separated
# list
backup_source="./"
# let's set the destination folder
backup_dest="/tmp"
# let's set value of the date variable in accordance to current date
date='date '+%d-%B-%Y''
# let's set the value of the hostname variable in accordance to host
# name
hostname=$(hostname -s)
# let's start the backup process
echo "Starting backup"
sleep 2
tar cvpzf   $backup_dest/$filename-$hostname-$date.tar.gz  $backup_source
# let's announce the end of the backup process
echo "Backup done"

过程应如下所示:

图 16.7 – 带参数的简单备份脚本

](https://github.com/OpenDocCN/freelearn-linux-pt3-zh/raw/master/docs/linux-cli-shscp-tech/img/Figure_16.7_B16269.jpg)

图 16.7 – 带参数的简单备份脚本

我们在脚本中输入的backup单词是脚本中的$1参数——即我们开始脚本时的第一个参数。正如我们所看到的,脚本正常执行了它的任务。

还有更多…

如果你需要更多关于tar命令的信息,我们建议你查看以下链接:

基于用户输入的备份源和目标创建当前备份

在使用tar制作备份脚本之后,我们需要制作一个完全互动的脚本,要求用户输入所有详细信息。让我们学习如何实现这一点!

准备开始

如果你按照之前的步骤操作,那么你不需要任何新的东西——相同的要求适用。

如何操作…

这次我们的前提有所改变。我们希望有一个完全功能的备份脚本,但不使用任何静态变量(像之前那样)。此外,我们希望能够随时调用这个脚本,这就是为什么我们使用多个问题来设置必要的变量。脚本应该是这样的:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021 Initial script version 
# Distribution allowed under GNU Licence V2.0 
# This script does a custom backup, based on our arguments
# We need to give it a couple of arguments @start:
# - backup file name
# - list of directories (or a single directory) that we
# want to backup
# We also added a bit of code to skip standard error
echo -e "Type in the backup file name, use something like file-date.tar.gz:"
read filename
echo -e "Type in the list or a single directory that you want to backup:"
read directories
echo "Let's do this thing!"
tar cfvz $filename $directories 2> /dev/null

这很简单,但也非常有效。请注意,我们使用了tar命令的一个非常酷的功能,那就是使用一个目录列表进行备份,特别是作为tar命令语法中末尾的列表,这使得操作变得稍微简单一些。

在下一章中,事情将变得越来越复杂。确保查看我们那里的第二部分 Shell 脚本示例。

还有更多…

如果你需要更多关于tar命令的信息,我们建议你访问以下链接:

第十七章:高级 Shell 脚本示例

到目前为止,我们已经尽力展示了编写脚本的不同方式,并且我们通过许多示例演示了如何完成不同的任务。在本章中,我们将以更复杂的方式在实际生活中可以使用的脚本中实现所有这些。

我们在本章中要展示的脚本解决了系统管理员的日常问题,从创建新用户到处理虚拟机VMs)。通过这些示例的演示,我们的目标不仅是展示脚本的工作原理,还包括其应该如何编写和处理的方法。

在本章中,我们将涵盖以下 Shell 脚本示例:

  • 实施 Web 服务器服务和安全设置

  • 从标准化输入文件中创建用户和组,使用标准化密码,并强制用户在下次登录时更改密码

  • 从标准化输入文件中创建用户和组,并为每个用户生成一个随机密码

  • 基于内核的虚拟机KVM)上进行脚本化的虚拟机安装

  • 一个用于配置sudo权限的 shell 脚本

  • 用于 VM 管理的 Shell 脚本

技术要求

在几乎所有其他章节中,我们都在使用一个通用的设置,只需要任何能够运行 Bash shell 的 Linux 发行版。在本章中,我们将稍作改变——必须这样做,这些脚本将必须在 Ubuntu 或任何其他基于 Debian 的发行版上运行。我们将在后续的示例中提到这些原因,当需要在其他 Linux 发行版上执行某些不同的操作时。因此,为了在本章中运行脚本,您需要以下内容:

  • 安装了 Linux 的 VM——我们使用Ubuntu 20.10,但任何基于 Debian 的发行版都可以工作

  • 了解我们在前几章中所做的所有事情,因为我们假设您已经理解了 Bash 脚本的工作原理

因此,请启动您的虚拟机,以便我们可以开始执行许多有用的操作!

实施 Web 服务器服务和安全设置

在这个特定的示例中,我们的想法是使用一个小的shell脚本来帮助我们配置已安装的 Web 服务器。我们将使我们的脚本能够更改服务器提供的网页所在位置,但您很快会发现,可以轻松地将任何其他选项添加到这个脚本中。

通过使用这个脚本,所有用户只需执行以下操作即可使系统运行起来:

  • 安装 Web 服务器

  • 运行脚本以更改网站文件位置

就像往常一样,当准备为用户进行简单操作时,主要问题是理解和隐藏所有复杂性,同时使管理员能够相对容易地添加新功能。我们如何做到这一点?继续阅读。

准备工作

这是我们的场景:

用户已经在他们的 Ubuntu 机器上安装了 Apache web 服务器。他们想要更改构成网站的文件的位置。

在我们深入讨论之前,我们必须明确这个任务的假设,就像几乎所有脚本一样。

首先,我们希望在运行脚本之前,web 服务器已经安装,并且我们假设它是 Apache。最简单的方法是使用以下命令:

sudo apt install apache2 -y

现在,我们等待包管理器完成它的工作。

我们的脚本无法与 nginx 或 lighttpd 等其他 web 服务器一起使用,因为配置是直接解析的,并且没有一种通用方式来设置我们所需要的参数。话虽如此,由于我们使用的解析方式非常基础,如果你需要修改这个脚本以使其与其他服务器兼容,可能只需要几分钟。

接下来,我们假设用户正在更改默认网站,即配置目录中的 000-default.conf 文件。这个值在我们的脚本中是硬编码的,这意味着如果你在同一台服务器上有多个网站,脚本只会更改被配置为默认的网站。

有时,管理员直接将网站添加到配置的这一部分,而不是为每个网站创建新的文件,这本应是应该做的。我们的脚本通过查找并替换文件中所有关于 DocumentRoot 指令的内容来完成任务。如果我们指定了多个 DocumentRoot 指令,脚本会将它们全部更改为相同的值。

另一个我们必须考虑的问题是错误检查。在脚本本身中,我们试图捕捉配置中是否有错误,但我们处理的方式仍有改进空间。虽然我们的脚本会尝试将文件恢复到修改前的状态,但我们并没有对更改的内容做任何真正的语法检查。如果用户在指定路径时出现错误,这可能会成为一个问题,但没有简单的解决办法;实现一个足够智能的检查来扫描有效路径,对于这样的任务来说过于复杂。

如何操作……

在本章的操作步骤中,我们将首先给出我们的脚本版本,然后解释我们认为重要的细节。所有的脚本中都会有大量的注释,我们强烈建议你在可能的情况下也这样做。注释也可以用于在创建脚本时,定义你想要做的所有事项的大致框架,然后再开始输入命令。

首先,让我们从脚本本身开始:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021
# Distribution allowed under GNU Licence V2.0

# Script configures apache DocumentRoot with a given path 
# and sets firewall accordingly
# Script is interactive, no arguments are accepted
# This script has to be run as root, we need to check that
if [[ $(id -u) -ne 0 ]] 
then 
    echo "This script needs to be run as root!" >&2 
    exit 1 
fi
# If there are multiple sites configured we will show a warning
if [[ $(ls /etc/apache2/sites-enabled/ | wc -l) -gt 1 ]] 
then 
echo "Warning: you may have more than one site!" >&2 
             exit 1 
fi
# First we are going to get what the root of the site is now
# When checking for DocumentRoot we are only checking 
# in default web site
HTTPDIR='grep DocumentRoot /etc/apache2/sites-available/000-\default.conf'
HTTPDIR="/$( cut -d '/' -f 2- <<< "$HTTPDIR" )"
# We are going to print current directory 
# that we read from inside the configuration file
echo "Current HTTPDIR is set as $HTTPDIR"
read -p  "Press Enter to accept current value or input absolute\
path for new DocumentRoot: " NEWDIR
# If user pressed enter we are going to 
# simply use the value we already read, 
# otherwise we use the new value
# Note: there is absolutely no sanity checking 
# if the given value is actually a path
NEWDIR=${NEWDIR:-$HTTPDIR}
echo "Directory is going to be set to $NEWDIR"
# Since we are dealing with a path we need to
# preprocess it before we use it in sed
# otherwise this is going to break
# There is an alternative, sed allows for 
# any other character in place of /
# but this is going to be a problem 
# if our path contains any nonstandard character
# so we simply escape all the slashes
# we need to use the _ character in this 
# case to be able to search for slash 
ESCNEWDIR=$(echo $NEWDIR | sed 's_/_\\/_g')
ESCHTTPDIR=$(echo $HTTPDIR | sed 's_/_\\/_g')
# before we change the configuration 
# we are going to back it up so we can restore if we need to 
cp /etc/apache2/sites-available/000-default.conf /etc/apache2/l\
sites-available/000-default.conf.backedup
sed -i "s/$ESCHTTPDIR/$ESCNEWDIR/g" /etc/apache2/sites-available/000-default.conf
# now we need to restart the service
# in order to use the new configuration.
systemctl reload apache2
# after every command we must check to see if there were any errors.
# In this particular case, we restore from backup if there were
if [ $? -ne 0 ]
 then 
     cp /etc/apache2/sites-available/000-default.conf. \
     backedup/etc/apache2/sites-available/000-default.conf
# we need to exit if we triggered this condition 
# since we are finished here, nothing was changed.
# before exit we need to reload apache once more 
# to make sure old configuration is used
# we are doing a start and stop here 
# because reload obviously failed in the step above 
     echo "Apache was not reloaded correctly, maybe there was \
     an error in the syntax"
     systemctl stop apache2
     systemctl start apache2
     return 1
fi
# if we came this far we need to get our firewall sorted out
# we are adding ports 80 and 443 as permitted. 
ufw allow http
ufw allow https
# alternative to this is ufw allow "Apache Full" 
# but using exact ports and aliases makes this easier to read. 
# end of script

我们在这里需要注意几件事。Apache 作为一个 Web 服务器,目前在所有发行版中默认是最常用的 Web 服务器,但 nginx 正在逐渐变得越来越受欢迎。需要记住的是,根据包含 apache 的发行版包的不同,它被称为 apache2(在基于 Debian 的发行版如 Ubuntu 上)或 httpd(在基于 Red Hat 的发行版如 Red Hat Enterprise LinuxRHEL)或 CentOS 上)。除了包名之外,配置文件在服务器本身的位置也有一点小差异,尽管语法完全相同。

另一个问题是防火墙。Ubuntu 使用 ufw,而 CentOS 使用 firewalld。第三个需要注意的大点是 apparmor(Ubuntu)和 SELinux(CentOS)。

我们版本的脚本适用于基于 Debian 的机器。如果我们想在例如 CentOS 上使用它,则需要做一些轻微的修改。

另见

创建用户和组,并强制用户在下次登录时更改密码

在 Linux 机器上,你最常做的事情之一就是创建大量用户。虽然有一种通过使用集中式数据库进行用户认证的方式可以避免这种情况,但实际上,这种方法通常只在大型部署的机器上使用,因此本地用户在大多数情况下仍然是常见的。

部署用户并为其分配密码是每个管理员在部署新服务器或桌面时都需要的功能。

准备工作

这个食谱需要做两件事。

由于脚本会更改系统上的用户,因此必须以管理员权限使用此脚本。同时,我们还需要提前准备一个包含用户列表的文件。

在展示我们的脚本之前,我们还必须提到,在获取脚本值时,读取和解析文件的方式不止一种。理解不同的做法对提升你的脚本能力非常有帮助。正因如此,在本食谱和下一个食谱中,我们决定避免使用 for 循环,而是选择使用数组和分隔符来解析文件。

如何实现……

正如我们已习惯的那样,我们在记录需要记住的事项之前,先开始编写脚本:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021
# Distribution allowed under GNU Licence V2.0
# script creates users from a csv file, and makes the user 
# change his password at next logon
# argument for the script is csv file name
# csv file is structured as follows: 
# user1,password1
# user2,password2
# ....
# First thing to do is check if we have any arguments supplied
if [ $# -eq 0 ]
  then
    echo "No arguments, proper usage is $0 <CSV file>"
fi
# next we need to get our information from the file
# here we are doing it one line at a time 
# and to do that we need to adjust the delimeter 
# that shell uses to understand how values are 
# separated. 
IFS=$'\n'
# Read the lines into an array
read -d '' -ra USRCREDS < $1
# Now we are going to deal with individual lines
# since right now our array contains both the user 
# and the appropriate password in one value
# separated by a , character 
# we chose to do this by telling the shell 
# we want to use , as a value separator
IFS=','
# Iterate over the lines
for USER in "${USRCREDS[@]}"
do
# Split values into separate variables
   read usr pass <<< "$USER"
# Create the user
   useradd -m $usr
# Set the password, we need to do this using passwd command
# alternative would be to use a hashing function
# passwd asks for password twice!
   echo "$pass"$'\n'"$pass" | passwd $usr
# then we expire the user password
   passwd --expire $usr
done

这里有几个概念需要提及。第一个是处理密码。任何密码在纯文本下可读的时间过长都会成为安全隐患,因此让用户尽早更改密码的想法是明智的。

为新用户创建密码时,我们基本上有两种选择——一种是提前创建用户和密码的列表,就像我们在这个示例中所做的,另一种是先创建用户列表,然后为他们分配随机密码,正如我们将在下一个食谱中做的那样。

每当你处理任何密码时,始终记住,一旦一个用户被攻破,你就面临着一个大大的安全问题,因为许多侵入系统的方法依赖于能够在本地运行应用程序。尽量减少除了用户之外的其他人知道账户密码的时间,绝不要以明文、可读的格式存储密码。

另请参见

从标准化输入文件创建用户和组,并为每个用户分配随机密码

在之前的示例中,我们探讨了创建新用户的一种方式。在这一部分,我们将基于相似的脚本,不仅创建新用户,还为其分配与用户一起提供的组,给管理员提供新用户密码的信息。

准备工作

我们正在创建用户,因此这个脚本必须在管理员账户下运行。在这种情况下,我们可能还希望将脚本的输出重定向到某个文件,因为新用户的密码是在脚本运行时创建的,而密码并没有存储在任何地方。如果我们不把它们保存到某处,它们就会丢失,并且在下次运行时重新创建。

如何操作……

在之前的示例中,我们提到过密码不应该存储在任何地方,但在创建新用户时,这是完全不可避免的。我们认为,处理密码的方式比提前准备密码更好,因为脚本运行时会生成密码,这样管理员可以从一开始就更好地控制它们:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021
# Distribution allowed under GNU Licence V2.0
# script creates users from a csv file 
# creating a group specified for each user
# and adding the user to a group
# password is generated and printed with the username
# argument for the script is csv file name
# csv file is structured as follows: 
# user1,group1
# user2,group2
# ....
# output is structured as: 
# user / password
# First thing to do is check if we have any arguments supplied
if [ $# -eq 0 ]
  then
    echo "No arguments, proper usage is $0 <CSV file>"
fi
# This script has to be run as root, we need to check that
if [[ $(id -u) -ne 0 ]] 
             then 
              echo "In order to add users and groups this \
              scripts needs to be run as root!" >&2 
              exit 1 
fi
# next we need to get our information from the file 
# here we are doing it one line at a time 
# and to do that we need to adjust the delimeter 
# that shell uses to understand how values are 
# separated. 
IFS=$'\n'
# Read the lines into an array
read -d '' -ra USRCREDS < $1
# Now we are going to deal with individual lines
# since right now our array contains both the user 
# and the appropriate password in one value
# separated by a , character 
# we chose to do this by telling the shell 
# we want to use , as a value separator
IFS=','
# Iterate over the lines
for USER in "${USRCREDS[@]}"
do
# Split values into separate variables
read usr grp <<< "$USER"
# Create the user
useradd -m $usr

# if the group does not exist, create it
getent group $grp || groupadd $grp
# add the user to a group
usermod -a -G $grp $usr
# now we create a random password
pass=$(cat /dev/urandom | tr -dc A-Za-z0-9 | head -c8)
# Set the password, we need to do this using passwd command
# alternative would be to use a hashing function
# passwd asks for password twice!
echo "$pass"$'\n'"$pass" | passwd $usr

# in the end we print user and password 

echo $usr/$pass
done

需要注意的是,这个脚本中我们依赖于许多命令返回的消息,而不是自己去检查。例如,如果用户已经创建,useradd 会生成一个错误信息,而不是由我们来处理:

图 17.1 – 脚本中命令提供的错误信息

图 17.1 – 脚本中命令提供的错误信息

另请参见

在 KVM 上进行脚本化的虚拟机安装

在某些环境中,另一个常见的任务是通过命令行创建新虚拟机。我们通常这样做的原因是灵活性和速度——使用图形用户界面GUI)比使用命令行界面CLI)慢一个数量级OOM)。

KVM 提供了一个非常简单的命令行创建虚拟机的解决方案。用户只需要了解一些基本参数。

准备工作

当然,我们需要在运行这个脚本的服务器上有一个有效的 KVM。除此之外,我们的脚本假设用户理解 KVM 创建虚拟机所需的所有不同选项。在尝试理解脚本如何工作之前,请务必尽可能多地了解从命令行创建虚拟机的相关信息,以便了解不同选项的作用。同时,回顾一下dialog工具包的使用知识,因为这个脚本依赖于此进行输入。

如何操作……

这个小脚本中的唯一亮点是我们使用dialog分配值的方式。像往常一样,有几种方式可以做到这一点。我们使用的是最合乎逻辑的一种,至少对我们来说是这样:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021
# Distribution allowed under GNU Licence V2.0
# script creates a virtual machine on the host it is run on
# asking the user for parameters of the VM
# in this script we are going to use dialog to show
# how a script can get values that way
name=$(dialog --inputbox "What is the name of the VM?" 8 25 \
--output-fd 1)
cpus=$(dialog --inputbox "How many VCPUs?" 8 25  --output-fd 1)
mem=$(dialog --inputbox "Enter the amount of memory in MB" 8 25 \
--output-fd 1)
cdrom=$(dialog --inputbox "Path to CDROM:" 8 25 --output-fd 1)
disksize=$(dialog --inputbox "Enter the disk size:" 8 25 \
--output-fd 1)
osv=$(dialog --inputbox "What is the OS variant installed?" 8 \
25 --output-fd 1)
virt-install --name=$name --vcpus=$cpus --memory=$mem \
--cdrom=$cdrom --disk size=$disksize --os-variant=$osv

使用dialog时,必须处理用户输入的重定向方式。在这个示例中,我们使用–output-fd 1来告诉dialog将所有内容重定向到标准输出stdout),我们可以在这里直接将值分配给变量。

另见

使用 Shell 脚本配置 SSH 密钥

处理密码的最安全方法是根本不使用它们。如果我们能够获取一个与用户账户相连接的公共密钥,且该用户可以在不使用密码的情况下登录,那么使用 SSH 密钥是一种避免使用密码的绝佳方式,并且由于只有他们的私钥能启用登录,这使得整个交易更加安全。

这个方案正是处理这样的任务,安装一台新的机器,这台机器将作为 LAMP 服务器,并允许用户完全不使用密码登录。

准备工作

实际上,如果我们有一些服务器需要安装且时间不多,像这样的脚本会被使用。与此类似的替代方案是使用像 Ansible 这样的适当编排工具,但尽管它是一个功能强大的工具,Ansible 对于小型部署来说太复杂了。

无论如何,这个脚本仅假设我们的服务器有一个有效的互联网连接,以便能够获取需要安装的软件包,并且我们已经从计划创建的用户那里获得了一个公共 SSH 密钥。

如何操作……

我们通过使用常规的明文文件来传输密钥。这是完全可以接受的,因为它实际上不包含任何可能带来安全问题的信息——为了使用 SSH 连接,用户必须拥有与我们正在使用的公共密钥对应的私钥。

由于这个密钥是——或者应该是——由唯一的一个特定用户控制的,因此我们在这里不担心安全性:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021
# Distribution allowed under GNU Licence V2.0
# script installs lamp, creates a user and assigns him SSH key
# key is provided in a file 
# script expects filename that contains SSH key
# First thing to do is check if we have any arguments supplied
if [ $# -eq 0 ]
  then
   echo "No arguments, proper usage is $0 <file containing SSH \
key>"
fi
# This script has to be run as root, we need to check that
if [[ $(id -u) -ne 0 ]] 
             then 
                          echo "In order to add services this \
scripts needs to be run as root!" >&2 
                          exit 1 
fi
# now we follow the standard installation procedure for LAMP
# first we aquire new updates
apt update
# then we install apache server
apt install apache2 –y
# we reconfigure the firewall to allow all the traffic in
ufw allow "Apache Full"
# then we install mysql server
# we could also install mariadb as the alternative
apt install mysql-server –y
# then we install php and required modules
apt install php libapache2-mod-php php-mysql –y
# we create our user
useradd lampuser
# we create directory for the ssh keys
mkdir /home/lampuser/.ssh
# we copy the key directly, allowing login without password
# note that user has no password by default, only ssh works
cp $1 /home/lampuser/.ssh/authorized_keys
# apply permissions to files in directory
chown -R lampuser:lampuser /home/lampuser/.ssh
chmod 700 /home/lampuser/.ssh
chmod 600 /home/lampuser/.ssh/authorized_keys
# add user to sudo group enabling sudo command
usermod -a -G sudo lampuser
# finally we run the secure installation to finish setting up \
mysql
mysql_secure_installation

另见

一个用于虚拟机管理的 shell 脚本

有些任务从命令行操作起来比较复杂,原因是我们有许多命令需要一遍又一遍地执行,并且还需要在后续步骤中重复使用某一步骤得到的值。

在这个教程中,我们将处理这样的任务,即对虚拟机进行基本的维护。我们的计划是创建一个脚本,帮助用户在本地服务器上运行的虚拟机上执行几个标准任务,简化管理任务,并避免记住长命令。我们的计划是让用户能够启动、停止、检查状态并恢复本地服务器上运行的虚拟机。该脚本将向用户提供虚拟机列表,并让他们有机会选择任何可用的虚拟机,或者将命令应用于所有虚拟机。

让我们看看这个任务需要什么。

准备工作

到此为止,你应该已经习惯了我们需要启用脚本运行的免责声明和要求。这次也不例外。首先,这个脚本需要一项重要的前提——服务器上必须安装支持 KVM 的软件,然后我们才能进行其他操作。在脚本中,我们使用一个命令来完成所有任务,但实际上,KVM 的安装和配置是必需的。

另一种选择是,这个脚本经过少量修改后,可以用于在其他 KVM 主机上执行任务,但我们将把这个作为一个练习留给你。

所以,在你开始运行脚本之前,先做一个简单的检查,确认一切正常,可以通过以下简单命令来实现:

virsh list –all 

这应该会返回服务器上所有虚拟机的列表。如果有任何错误,需要在你尝试运行脚本之前解决,因为该脚本依赖于这个命令的正常工作。

如何操作…

首先,我们将从脚本本身开始:

#!/bin/bash
# V1.0 / Jasmin Redzepagic / 01/11/2021
# Distribution allowed under GNU Licence V2.0
# Simple interface to virsh command
# this script enumerates all machines on this KVM host 
# and enables user to perform basic commands
# script is interactive and has no command line arguments
# in this script we are going to create a simple two level menu 
# that will first ask user what virtual machine he wants to \
  perform commands on. 
# User has to specify the machine from a list or type ALL
# if he wants to run the command on all the machines on the host
# we need to get the list of virtual
Machines
# notice we are not redirecting errors in order for the user
# to be able to see what actually happened 
virsh list --all 
# then we do some rudimentary error checking to make sure
# we are at least able to use virsh
if [ $? -ne 0 ]
 then 
    echo "Something is wrong with your KVM instance, exiting!"
    return 1
fi 
# if we come this far our script can talk to the user
read -p "Choose VM you want to change state of or type ALL for \
all machines:" HOSTN
echo -e "\n"
if [ $HOSTN == "ALL" ];
then
             echo "You chose all machines."
else
             echo "You chose: " $HOSTN "."
fi
echo "What do you want to do"
echo "1 = START"
echo "2 = STOP"
echo "3 = RESET"
echo "4 = STATUS"
echo -e "\n"
read CHOSENOP
if [ $HOSTN == "ALL" ]; # we are running the commands on all the machines
# every command is run in a loop on all the machines
then 
                if [ $CHOSENOP -eq 1 ];    # user chose start
                 then
                          for i in $(virsh list --name --all);
                          do 
                          echo "Starting  $i"                          
                          virsh start $i;
                          done
                          exit 0
               elif [ $CHOSENOP -eq 2 ]; # user chose stop
               then
                         for i in $(virsh list --name --all);
              do 
                         echo "Stopping  $i"                       virsh shutdown $i
done
                         exit 0
             elif [ $CHOSENOP -eq 3 ]; # user chose to revert \
to snapshot
               then
                          for i in $(virsh list --name --all);
                          do 
                          echo "Reverting $i to latest snapshot: "               
                          virsh snapshot-revert $i start;
                          done
                          exit 0
                elif [ $CHOSENOP -eq 4 ]; # user chose to \
                display status of machines
                then
                           for i in $(virsh list --name --all);
                           do 
                           echo "Status of $i: "
                           virsh dominfo $i;
                           done
                           exit 0
                else
# user made an invalid input
                          echo "Input was not valid!"
                          exit 0
               fi
else
# we do everything the same way but with a particular VM

             if [ $CHOSENOP -eq 1 ];
             then
                          echo "Starting $HOSTN"
                          virsh start $HOSTN                            
                          exit 0
             elif [ $CHOSENOP -eq 2 ];
             then
                          echo "Stopping $HOSTN"
                          virsh shutdown $HOSTN                
                           exit 0
             elif [ $CHOSENOP -eq 3 ];
             then
                          echo "Reverting $HOSTN to last \
                          snapshot"
                          virsh snapshot-revert $HOSTN                 
                          exit 0
             elif [ $CHOSENOP -eq 4 ];
             then
                          echo "Status of $HOSTN: "
                          virsh dominfo $HOSTN                
                          exit 0
              else
# user made an invalid input
                          echo "Input was not valid!"/ \
                          exit 0
              fi
fi

在这个具体的脚本中,我们需要决定的主要事项是如何处理两种不同的情况。第一种情况是:我们是处理某个特定的虚拟机,还是所有虚拟机? 第二种情况是:需要执行什么操作?

我们有几种方法可以做到这一点——我们选择了这种方法,因为它看起来最为合乎逻辑。我们首先给用户提供一份主机上所有虚拟机的列表,然后在他们决定要在哪台虚拟机上运行命令之后,我们再询问他们想要执行什么操作。

我们本可以反过来做,先让用户选择命令,再让他们选择想要执行命令的虚拟机。

我们决定的另一件事是如何显示机器的名称。我们将让用户自己正确输入机器名称,并且我们不做任何检查。可以做的一件事是尝试将用户输入与实际机器名称列表进行比较。这样,如果用户犯了错误,我们可以在脚本尝试对无效机器进行操作之前捕获这个错误。

在这个脚本中,以及几乎所有有很多逻辑判断且缺少足够检查的脚本中,可以做的一件事是对整个脚本进行try-catch循环,这样我们就可以在不让脚本完全崩溃、也不让我们陷入未知状态的情况下处理任何可能的错误。

另请参见

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,并获得帮助你规划个人发展和推进职业生涯的行业领先工具。欲了解更多信息,请访问我们的网站。

第十八章:为什么订阅?

  • 通过来自超过 4,000 名行业专业人士的实用电子书和视频,减少学习时间,增加编码时间

  • 通过专门为你设计的技能计划提升你的学习效果

  • 每月获取一本免费的电子书或视频

  • 完全可搜索,便于快速访问关键信息

  • 复制、粘贴、打印和收藏内容

你知道 Packt 为每本已出版的书籍提供电子书版本,并且提供 PDF 和 ePub 格式的文件吗?你可以在packt.com升级到电子书版本,并且作为纸质书籍的顾客,你可以享受电子书的折扣。详情请联系我们:customercare@packtpub.com。

www.packt.com网站,你还可以阅读一系列免费的技术文章,注册各种免费的时事通讯,并获得 Packt 书籍和电子书的独家折扣和优惠。

你可能喜欢的其他书籍

如果你喜欢这本书,你可能对 Packt 出版的其他书籍感兴趣:

![精通 Adobe Photoshop Elements

](https://www.packtpub.com/product/linux-kernel-programming/9781789953435)

Linux 内核编程

Kaiwan N Billimoria

ISBN:9781789953435

  • 为 5.x 内核编写高质量的模块化内核代码(LKM 框架)

  • 配置并从源代码构建内核

  • 探索 Linux 内核架构

  • 掌握关于内存管理的关键内核内部知识

  • 理解并使用各种动态内核内存分配/释放 API

  • 探索关于 CPU 调度的内核内部关键知识

  • 了解内核并发问题

  • 了解如何使用关键的内核同步原语

![精通 Adobe Photoshop Elements

](https://www.packtpub.com/product/linux-system-programming-techniques/9781789951288)

Linux 系统编程技术

Jack-Benny Persson

ISBN:9781789951288

  • 发现如何使用各种系统调用为 Linux 系统编写程序

  • 深入了解 POSIX 函数的工作原理

  • 理解并使用如信号、管道、IPC 和进程管理等关键概念

  • 了解如何将程序与 Linux 系统集成

  • 探索高级话题,如文件系统操作、创建共享库以及调试程序

  • 了解如何使用 Valgrind 调试你的程序

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天就申请。我们与成千上万的开发者和技术专业人士合作,帮助他们将自己的见解与全球技术社区分享。你可以进行一般申请,申请我们正在招募的特定热门话题作者,或者提交你自己的想法。

分享你的想法

现在你已经完成了Linux 命令行与 Shell 脚本秘籍,我们很想听听你的想法!如果你是从亚马逊购买的这本书,请点击这里直接进入亚马逊书评页面,分享你的反馈或者在购买网站上留下评论。

你的评论对我们和技术社区非常重要,它将帮助我们确保提供优质的内容。

你可能感兴趣的其他书籍

你可能感兴趣的其他书籍

posted @ 2025-07-04 15:40  绝不原创的飞龙  阅读(52)  评论(0)    收藏  举报