第三十七章:奇珍异宝

最后更新于:2022-04-02 01:46:31

在我们 bash 学习旅程中的最后一站,我们将看一些零星的知识点。当然我们在之前的章节中已经 涵盖了很多方面,但是还有许多 bash 特性我们没有涉及到。其中大部分特性相当晦涩,主要对 那些把 bash 集成到 Linux 发行版的程序有用处。然而还有一些特性,虽然不常用, 但是对某些程序问题是很有帮助的。我们将在这里介绍它们。 ## 组命令和子 shell bash 允许把命令组合在一起。可以通过两种方式完成;要么用一个 group 命令,要么用一个子 shell。 这里是每种方式的语法示例: 组命令: ~~~ { command1; command2; [command3; ...] } ~~~ 子 shell: ~~~ (command1; command2; [command3;...]) ~~~ 这两种形式的不同之处在于,组命令用花括号把它的命令包裹起来,而子 shell 用括号。值得注意的是,鉴于 bash 实现组命令的方式, 花括号与命令之间必须有一个空格,并且最后一个命令必须用一个分号或者一个换行符终止。 那么组命令和子 shell 命令对什么有好处呢? 尽管它们有一个很重要的差异(我们马上会接触到),但它们都是用来管理重定向的。 让我们考虑一个对多个命令执行重定向的脚本片段。 ~~~ ls -l > output.txt echo "Listing of foo.txt" >> output.txt cat foo.txt >> output.txt ~~~ 这些代码相当简洁明了。三个命令的输出都重定向到一个名为 output.txt 的文件中。 使用一个组命令,我们可以重新编 写这些代码,如下所示: ~~~ { ls -l; echo "Listing of foo.txt"; cat foo.txt; } > output.txt ~~~ 使用一个子 shell 是相似的: ~~~ (ls -l; echo "Listing of foo.txt"; cat foo.txt) > output.txt ~~~ 使用这样的技术,我们为我们自己节省了一些打字时间,但是组命令和子 shell 真正闪光的地方是与管道线相结合。 当构建一个管道线命令的时候,通常把几个命令的输出结果合并成一个流是很有用的。 组命令和子 shell 使这种操作变得很简单: ~~~ { ls -l; echo "Listing of foo.txt"; cat foo.txt; } | lpr ~~~ 这里我们已经把我们的三个命令的输出结果合并在一起,并把它们用管道输送给命令 lpr 的输入,以便产生一个打印报告。 在下面的脚本中,我们将使用组命令,看几个与关联数组结合使用的编程技巧。这个脚本,称为 array-2,当给定一个目录名,打印出目录中的文件列表, 伴随着每个文件的文件所有者和组所有者。在文件列表的末尾,脚本打印出属于每个所有者和组的文件数目。 这里我们看到的结果(缩短的,为简单起见),是给定脚本的目录为 /usr/bin 的时候: ~~~ [me@linuxbox ~]$ array-2 /usr/bin /usr/bin/2to3-2.6 root root /usr/bin/2to3 root root /usr/bin/a2p root root /usr/bin/abrowser root root /usr/bin/aconnect root root /usr/bin/acpi_fakekey root root /usr/bin/acpi_listen root root /usr/bin/add-apt-repository root root . /usr/bin/zipgrep root root /usr/bin/zipinfo root root /usr/bin/zipnote root root /usr/bin/zip root root /usr/bin/zipsplit root root /usr/bin/zjsdecode root root /usr/bin/zsoelim root root File owners: daemon : 1 file(s) root : 1394 file(s) File group owners: crontab : 1 file(s) daemon : 1 file(s) lpadmin : 1 file(s) mail : 4 file(s) mlocate : 1 file(s) root : 1380 file(s) shadow : 2 file(s) ssh : 1 file(s) tty : 2 file(s) utmp : 2 file(s) ~~~ 这里是脚本代码列表(带有行号): ~~~ #!/bin/bash # array-2: Use arrays to tally file owners declare -A files file_group file_owner groups owners if [[ ! -d "$1" ]]; then echo "Usage: array-2 dir" >&2 exit 1 fi for i in "$1"/*; do owner=$(stat -c %U "$i") group=$(stat -c %G "$i") files["$i"]="$i" file_owner["$i"]=$owner file_group["$i"]=$group ((++owners[$owner])) ((++groups[$group])) done # List the collected files { for i in "${files[@]}"; do printf "%-40s %-10s %-10s\n" \ "$i" ${file_owner["$i"]} ${file_group["$i"]} done } | sort echo # List owners echo "File owners:" { for i in "${!owners[@]}"; do printf "%-10s: %5d file(s)\n" "$i" ${owners["$i"]} done } | sort echo # List groups echo "File group owners:" { for i in "${!groups[@]}"; do printf "%-10s: %5d file(s)\n" "$i" ${groups["$i"]} done } | sort ~~~ 让我们看一下这个脚本的运行机制: 行5: 关联数组必须用带有 -A 选项的 declare 命令创建。在这个脚本中我们创建了如下五个数组: files 包含了目录中文件的名字,按文件名索引 file_group 包含了每个文件的组所有者,按文件名索引 file_owner 包含了每个文件的所有者,按文件名索引 groups 包含了属于索引的组的文件数目 owners 包含了属于索引的所有者的文件数目 行7-10:查看是否一个有效的目录名作为位置参数传递给程序。如果不是,就会显示一条使用信息,并且脚本退出,退出状态为1。 行12-20:循环遍历目录中的所有文件。使用 stat 命令,行13和行14抽取文件所有者和组所有者, 并把值赋给它们各自的数组(行16,17),使用文件名作为数组索引。同样地,文件名自身也赋值给 files 数组。 行18-19:属于文件所有者和组所有者的文件总数各自加1。 行22-27:输出文件列表。为做到这一点,使用了 “${array[@]}” 参数展开,展开成整个的数组元素列表, 并且每个元素被当做是一个单独的词。从而允许文件名包含空格的情况。也要注意到整个循环是包裹在花括号中, 从而形成了一个组命令。这样就允许整个循环输出会被管道输送给 sort 命令的输入。这是必要的,因为 展开的数组元素是无序的。 行29-40:这两个循环与文件列表循环相似,除了它们使用 “${!array[@]}” 展开,展开成数组索引的列表 而不是数组元素的。 ## 进程替换 虽然组命令和子 shell 看起来相似,并且它们都能用来在重定向中合并流,但是两者之间有一个很重要的不同。 然而,一个组命令在当前 shell 中执行它的所有命令,而一个子 shell(顾名思义)在当前 shell 的一个 子副本中执行它的命令。这意味着运行环境被复制给了一个新的 shell 实例。当这个子 shell 退出时,环境副本会消失, 所以在子 shell 环境(包括变量赋值)中的任何更改也会消失。因此,在大多数情况下,除非脚本要求一个子 shell, 组命令比子 shell 更受欢迎。组命令运行很快并且占用的内存也少。 我们在第20章中看到过一个子 shell 运行环境问题的例子,当我们发现管道线中的一个 read 命令 不按我们所期望的那样工作的时候。为了重现问题,我们构建一个像这样的管道线: ~~~ echo "foo" | read echo $REPLY ~~~ 该 REPLY 变量的内容总是为空,是因为这个 read 命令在一个子 shell 中执行,所以它的 REPLY 副本会被毁掉, 当该子 shell 终止的时候。因为管道线中的命令总是在子 shell 中执行,任何给变量赋值的命令都会遭遇这样的问题。 幸运地是,shell 提供了一种奇异的展开方式,叫做进程替换,它可以用来解决这种麻烦。进程替换有两种表达方式: 一种适用于产生标准输出的进程: ~~~ <(list) ~~~ 另一种适用于接受标准输入的进程: ~~~ >(list) ~~~ 这里的 list 是一串命令列表: 为了解决我们的 read 命令问题,我们可以雇佣进程替换,像这样: ~~~ read < <(echo "foo") echo $REPLY ~~~ 进程替换允许我们把一个子 shell 的输出结果当作一个用于重定向的普通文件。事实上,因为它是一种展开形式,我们可以检验它的真实值: ~~~ [me@linuxbox ~]$ echo <(echo "foo") /dev/fd/63 ~~~ 通过使用 echo 命令,查看展开结果,我们看到子 shell 的输出结果,由一个名为 /dev/fd/63 的文件提供。 进程替换经常被包含 read 命令的循环用到。这里是一个 read 循环的例子,处理一个目录列表的内容,内容创建于一个子 shell: ~~~ #!/bin/bash # pro-sub : demo of process substitution while read attr links owner group size date time filename; do cat <<- EOF Filename: $filename Size: $size Owner: $owner Group: $group Modified: $date $time Links: $links Attributes: $attr EOF done < <(ls -l | tail -n +2) ~~~ 这个循环对目录列表的每一个条目执行 read 命令。列表本身产生于该脚本的最后一行代码。这一行代码把从进程替换得到的输出 重定向到这个循环的标准输入。这个包含在管道线中的 tail 命令,是为了消除列表的第一行文本,这行文本是多余的。 当脚本执行后,脚本产生像这样的输出: ~~~ [me@linuxbox ~]$ pro_sub | head -n 20 Filename: addresses.ldif Size: 14540 Owner: me Group: me Modified: 2009-04-02 11:12 Links: 1 Attributes: -rw-r--r-- Filename: bin Size: 4096 Owner: me Group: me Modified: 2009-07-10 07:31 Links: 2 Attributes: drwxr-xr-x Filename: bookmarks.html Size: 394213 Owner: me Group: me ~~~ ## 陷阱 在第10章中,我们看到过程序是怎样响应信号的。我们也可以把这个功能添加到我们的脚本中。然而到目前为止, 我们所编写过的脚本还不需要这种功能(因为它们运行时间非常短暂,并且不创建临时文件),大且更复杂的脚本 可能会受益于一个信息处理程序。 当我们设计一个大的,复杂的脚本的时候,若脚本仍在运行时,用户注销或关闭了电脑,这时候会发生什么,考虑到这一点非常重要。 当像这样的事情发生了,一个信号将会发送给所有受到影响的进程。依次地,代表这些进程的程序会执行相应的动作,来确保程序 合理有序的终止。比方说,例如,我们编写了一个会在执行时创建临时文件的脚本。在一个好的设计流程,我们应该让脚本删除创建的 临时文件,当脚本完成它的任务之后。若脚本接收到一个信号,表明该程序即将提前终止的信号, 此时让脚本删除创建的临时文件,也会是很精巧的设计。 为满足这样需求,bash 提供了一种机制,众所周知的 trap。陷阱由被恰当命令的内部命令 trap 实现。 trap 使用如下语法: ~~~ trap argument signal [signal...] ~~~ 这里的 argument 是一个字符串,它被读取并被当作一个命令,signal 是一个信号的说明,它会触发执行所要解释的命令。 这里是一个简单的例子: ~~~ #!/bin/bash # trap-demo : simple signal handling demo trap "echo 'I am ignoring you.'" SIGINT SIGTERM for i in {1..5}; do echo "Iteration $i of 5" sleep 5 done ~~~ 这个脚本定义一个陷阱,当脚本运行的时候,这个陷阱每当接受到一个 SIGINT 或 SIGTERM 信号时,就会执行一个 echo 命令。 当用户试图通过按下 Ctrl-c 组合键终止脚本运行的时候,该程序的执行结果看起来像这样: ~~~ [me@linuxbox ~]$ trap-demo Iteration 1 of 5 Iteration 2 of 5 I am ignoring you. Iteration 3 of 5 I am ignoring you. Iteration 4 of 5 Iteration 5 of 5 ~~~ 正如我们所看到的,每次用户试图中断程序时,会打印出这条信息。 构建一个字符串形成一个有用的命令序列是很笨拙的,所以通常的做法是指定一个 shell 函数作为命令。在这个例子中, 为每一个信号指定了一个单独的 shell 函数来处理: ~~~ #!/bin/bash # trap-demo2 : simple signal handling demo exit_on_signal_SIGINT () { echo "Script interrupted." 2>&1 exit 0 } exit_on_signal_SIGTERM () { echo "Script terminated." 2>&1 exit 0 } trap exit_on_signal_SIGINT SIGINT trap exit_on_signal_SIGTERM SIGTERM for i in {1..5}; do echo "Iteration $i of 5" sleep 5 done ~~~ 这个脚本的特色是有两个 trap 命令,每个命令对应一个信号。每个 trap,依次,当接受到相应的特殊信号时, 会执行指定的 shell 函数。注意每个信号处理函数中都包含了一个 exit 命令。没有 exit 命令, 信号处理函数执行完后,该脚本将会继续执行。 当用户在这个脚本执行期间,按下 Ctrl-c 组合键的时候,输出结果看起来像这样: ~~~ [me@linuxbox ~]$ trap-demo2 Iteration 1 of 5 Iteration 2 of 5 Script interrupted. ~~~ > 临时文件 > > 把信号处理程序包含在脚本中的一个原因是删除临时文件,在脚本执行期间,脚本可能会创建临时文件来存放中间结果。 命名临时文件是一种艺术。传统上,在类似于 unix 系统中的程序会在 /tmp 目录下创建它们的临时文件,/tmp 是 一个服务于临时文件的共享目录。然而,因为这个目录是共享的,这会引起一定的安全顾虑,尤其对那些用 超级用户特权运行的程序。除了为暴露给系统中所有用户的文件设置合适的权限,这一明显步骤之外, 给临时文件一个不可预测的文件名是很重要的。这就避免了一种为大众所知的 temp race 攻击。 一种创建一个不可预测的(但是仍有意义的)临时文件名的方法是,做一些像这样的事情: > > tempfile=/tmp/$(basename $0).$.$RANDOM > > 这将创建一个由程序名字,程序进程的 ID(PID)文件名,和一个随机整数组成。注意,然而,该 $RANDOM shell 变量 只能返回一个范围在1-32767内的整数值,这在计算机术语中不是一个很大的范围,所以一个单一的该变量实例是不足以克服一个坚定的攻击者的。 > > 一个比较好的方法是使用 mktemp 程序(不要和 mktemp 标准库函数相混淆)来命名和创建临时文件。 这个 mktemp 程序接受一个用于创建文件名的模板作为参数。这个模板应该包含一系列的 “X” 字符, 随后这些字符会被相应数量的随机字母和数字替换掉。一连串的 “X” 字符越长,则一连串的随机字符也就越长。 这里是一个例子: > > tempfile=$(mktemp /tmp/foobar.$.XXXXXXXXXX) > > 这里创建了一个临时文件,并把临时文件的名字赋值给变量 tempfile。因为模板中的 “X” 字符会被随机字母和 数字代替,所以最终的文件名(在这个例子中,文件名也包含了特殊参数 $$ 的展开值,进程的 PID)可能像这样: > > /tmp/foobar.6593.UOZuvM6654 > > 对于那些由普通用户操作执行的脚本,避免使用 /tmp 目录,而是在用户家目录下为临时文件创建一个目录, 通过像这样的一行代码: > > [[ -d $HOME/tmp ]] || mkdir $HOME/tmp ## 异步执行 有时候需要同时执行多个任务。我们已经知道现在所有的操作系统若不是多用户的但至少是多任务的。 脚本也可以构建成多任务处理的模式。 通常这涉及到启动一个脚本,依次,启动一个或多个子脚本来执行额外的任务,而父脚本继续运行。然而,当一系列脚本 以这种方式运行时,要保持父子脚本之间协调工作,会有一些问题。也就是说,若父脚本或子脚本依赖于另一方,并且 一个脚本必须等待另一个脚本结束任务之后,才能完成它自己的任务,这应该怎么办? bash 有一个内置命令,能帮助管理诸如此类的异步执行的任务。wait 命令导致一个父脚本暂停运行,直到一个 特定的进程(例如,子脚本)运行结束。 ### 等待 首先我们将演示一下 wait 命令的用法。为此,我们需要两个脚本,一个父脚本: ~~~ #!/bin/bash # async-parent : Asynchronous execution demo (parent) echo "Parent: starting..." echo "Parent: launching child script..." async-child & pid=$! echo "Parent: child (PID= $pid) launched." echo "Parent: continuing..." sleep 2 echo "Parent: pausing to wait for child to finish..." wait $pid echo "Parent: child is finished. Continuing..." echo "Parent: parent is done. Exiting." ~~~ 和一个子脚本: ~~~ #!/bin/bash # async-child : Asynchronous execution demo (child) echo "Child: child is running..." sleep 5 echo "Child: child is done. Exiting." ~~~ 在这个例子中,我们看到该子脚本是非常简单的。真正的操作通过父脚本完成。在父脚本中,子脚本被启动, 并被放置到后台运行。子脚本的进程 ID 记录在 pid 变量中,这个变量的值是 $! shell 参数的值,它总是 包含放到后台执行的最后一个任务的进程 ID 号。 父脚本继续,然后执行一个以子进程 PID 为参数的 wait 命令。这就导致父脚本暂停运行,直到子脚本退出, 意味着父脚本结束。 当执行后,父子脚本产生如下输出: ~~~ [me@linuxbox ~]$ async-parent Parent: starting... Parent: launching child script... Parent: child (PID= 6741) launched. Parent: continuing... Child: child is running... Parent: pausing to wait for child to finish... Child: child is done. Exiting. Parent: child is finished. Continuing... Parent: parent is done. Exiting. ~~~ ## 命名管道 在大多数类似也 Unix 的操作系统中,有可能创建一种特殊类型的饿文件,叫做命名管道。命名管道用来在 两个进程之间建立连接,也可以像其它类型的文件一样使用。虽然它们不是那么流行,但是它们值得我们去了解。 有一种常见的编程架构,叫做客户端-服务器,它可以利用像命名管道这样的通信方式, 也可以使用其它类型的进程间通信方式,比如网络连接。 最为广泛使用的客户端-服务器系统类型是,当然,一个 web 浏览器与一个 web 服务器之间进行通信。 web 浏览器作为客户端,向服务器发出请求,服务器响应请求,并把对应的网页发送给浏览器。 命令管道的行为类似于文件,但实际上形成了先入先出(FIFO)的缓冲。和普通(未命令的)管道一样, 数据从一端进入,然后从另一端出现。通过命令管道,有可能像这样设置一些东西: ~~~ process1 > named_pipe ~~~ 和 ~~~ process2 < named_pipe ~~~ 表现出来就像这样: ~~~ process1 | process2 ~~~ ### 设置一个命名管道 首先,我们必须创建一个命名管道。使用 mkfifo 命令能够创建命令管道: ~~~ [me@linuxbox ~]$ mkfifo pipe1 [me@linuxbox ~]$ ls -l pipe1 prw-r--r-- 1 me me 0 2009-07-17 06:41 pipe1 ~~~ 这里我们使用 mkfifo 创建了一个名为 pipe1 的命名管道。使用 ls 命令,我们查看这个文件, 看到位于属性字段的第一个字母是 “p”,表明它是一个命名管道。 ### 使用命名管道 为了演示命名管道是如何工作的,我们将需要两个终端窗口(或用两个虚拟控制台代替)。 在第一个终端中,我们输入一个简单命令,并把命令的输出重定向到命名管道: ~~~ [me@linuxbox ~]$ ls -l > pipe1 ~~~ 我们按下 Enter 按键之后,命令将会挂起。这是因为在管道的另一端没有任何接受数据。当这种现象发生的时候, 据说是管道阻塞了。一旦我们绑定一个进程到管道的另一端,该进程开始从管道中读取输入的时候,这种情况会消失。 使用第二个终端窗口,我们输入这个命令: ~~~ [me@linuxbox ~]$ cat < pipe1 ~~~ 然后产自第一个终端窗口的目录列表出现在第二个终端中,并作为来自 cat 命令的输出。在第一个终端 窗口中的 ls 命令一旦它不再阻塞,会成功地结束。 ## 总结 嗯,我们已经完成了我们的旅程。现在剩下的唯一要做的事就是练习,练习,再练习。 纵然在我们的长途跋涉中,我们涉及了很多命令,但是就命令行而言,我们只是触及了它的表面。 仍留有成千上万的命令行程序,需要去发现和享受。开始挖掘 /usr/bin 目录吧,你将会看到! ## 拓展阅读 * bash 手册页的 “复合命令” 部分包含了对组命令和子 shell 表示法的详尽描述。 * bash 手册也的 EXPANSION 部分包含了一小部分进程替换的内容: * 《高级 Bash 脚本指南》也有对进程替换的讨论: [http://tldp.org/LDP/abs/html/process-sub.html](http://tldp.org/LDP/abs/html/process-sub.html) * 《Linux 杂志》有两篇关于命令管道的好文章。第一篇,源于1997年9月: [http://www.linuxjournal.com/article/2156](http://www.linuxjournal.com/article/2156) * 和第二篇,源于2009年3月: [http://www.linuxjournal.com/content/using-named-pipes-fifos-bash](http://www.linuxjournal.com/content/using-named-pipes-fifos-bash)
';

第三十六章:数组

最后更新于:2022-04-02 01:46:29

在上一章中,我们查看了 shell 怎样操作字符串和数字的。目前我们所见到的数据类型在计算机科学圈里被 成为标量变量;也就是说,只能包含一个值的变量。 在本章中,我们将看看另一种数据结构叫做数组,数组能存放多个值。数组几乎是所有编程语言的一个特性。 shell 也支持它们,尽管以一个相当有限的形式。即便如此,为解决编程问题,它们是非常有用的。 ## 什么是数组? 数组是一次能存放多个数据的变量。数组的组织结构就像一张表。我们拿电子表格举例。一张电子表格就像是一个 二维数组。它既有行也有列,并且电子表格中的一个单元格,可以通过单元格所在的行和列的地址定位它的位置。 数组行为也是如此。数组有单元格,被称为元素,而且每个元素会包含数据。 使用一个称为索引或下标的地址可以访问一个单独的数组元素。 大多数编程语言支持多维数组。一个电子表格就是一个多维数组的例子,它有两个维度,宽度和高度。 许多语言支持任意维度的数组,虽然二维和三维数组可能是最常用的。 Bash 中的数组仅限制为单一维度。我们可以把它们看作是只有一列的电子表格。尽管有这种局限,但是有许多应用使用它们。 对数组的支持第一次出现在 bash 版本2中。原来的 Unix shell 程序,sh,根本就不支持数组。 ## 创建一个数组 数组变量就像其它 bash 变量一样命名,当被访问的时候,它们会被自动地创建。这里是一个例子: ~~~ [me@linuxbox ~]$ a[1]=foo [me@linuxbox ~]$ echo ${a[1]} foo ~~~ 这里我们看到一个赋值并访问数组元素的例子。通过第一个命令,把数组 a 的元素1赋值为 “foo”。 第二个命令显示存储在元素1中的值。在第二个命令中使用花括号是必需的, 以便防止 shell 试图对数组元素名执行路径名展开操作。 也可以用 declare 命令创建一个数组: ~~~ [me@linuxbox ~]$ declare -a a ~~~ 使用 -a 选项,declare 命令的这个例子创建了数组 a。 ## 数组赋值 有两种方式可以给数组赋值。单个值赋值使用以下语法: ~~~ name[subscript]=value ~~~ 这里的 name 是数组的名字,subscript 是一个大于或等于零的整数(或算术表达式)。注意数组第一个元素的下标是0, 而不是1。数组元素的值可以是一个字符串或整数。 多个值赋值使用下面的语法: ~~~ name=(value1 value2 ...) ~~~ 这里的 name 是数组的名字,value… 是要按照顺序赋给数组的值,从元素0开始。例如,如果我们希望 把星期几的英文简写赋值给数组 days,我们可以这样做: ~~~ [me@linuxbox ~]$ days=(Sun Mon Tue Wed Thu Fri Sat) ~~~ 还可以通过指定下标,把值赋给数组中的特定元素: ~~~ [me@linuxbox ~]$ days=([0]=Sun [1]=Mon [2]=Tue [3]=Wed [4]=Thu [5]=Fri [6]=Sat) ~~~ ## 访问数组元素 那么数组对什么有好处呢? 就像许多数据管理任务一样,可以用电子表格程序来完成,许多编程任务则可以用数组完成。 让我们考虑一个简单的数据收集和展示的例子。我们将构建一个脚本,用来检查一个特定目录中文件的修改次数。 从这些数据中,我们的脚本将输出一张表,显示这些文件最后是在一天中的哪个小时被修改的。这样一个脚本 可以被用来确定什么时段一个系统最活跃。这个脚本,称为 hours,输出这样的结果: ~~~ [me@linuxbox ~]$ hours . Hour Files Hour Files ---- ----- ---- ---- 00 0 12 11 01 1 13 7 02 0 14 1 03 0 15 7 04 1 16 6 04 1 17 5 06 6 18 4 07 3 19 4 08 1 20 1 09 14 21 0 10 2 22 0 11 5 23 0 Total files = 80 ~~~ 当执行该 hours 程序时,指定当前目录作为目标目录。它打印出一张表显示一天(0-23小时)每小时内, 有多少文件做了最后修改。程序代码如下所示: ~~~ #!/bin/bash # hours : script to count files by modification time usage () { echo "usage: $(basename $0) directory" >&2 } # Check that argument is a directory if [[ ! -d $1 ]]; then usage exit 1 fi # Initialize array for i in {0..23}; do hours[i]=0; done # Collect data for i in $(stat -c %y "$1"/* | cut -c 12-13); do j=${i/#0} ((++hours[j])) ((++count)) done # Display data echo -e "Hour\tFiles\tHour\tFiles" echo -e "----\t-----\t----\t-----" for i in {0..11}; do j=$((i + 12)) printf "%02d\t%d\t%02d\t%d\n" $i ${hours[i]} $j ${hours[j]} done printf "\nTotal files = %d\n" $count ~~~ 这个脚本由一个函数(名为 usage),和一个分为四个区块的主体组成。在第一部分,我们检查是否有一个命令行参数, 且该参数为目录。如果不是目录,会显示脚本使用信息并退出。 第二部分初始化一个名为 hours 的数组。给每一个数组元素赋值一个0。虽然没有特殊需要在使用之前准备数组,但是 我们的脚本需要确保没有元素是空值。注意这个循环构建方式很有趣。通过使用花括号展开({0..23}),我们能 很容易为 for 命令产生一系列的数据(words)。 接下来的一部分收集数据,对目录中的每一个文件运行 stat 程序。我们使用 cut 命令从结果中抽取两位数字的小时字段。 在循环里面,我们需要把小时字段开头的零清除掉,因为 shell 将试图(最终会失败)把从 “00” 到 “09” 的数值解释为八进制(见表35-1)。 下一步,我们以小时为数组索引,来增加其对应的数组元素的值。最后,我们增加一个计数器的值(count),记录目录中总共的文件数目。 脚本的最后一部分显示数组中的内容。我们首先输出两行标题,然后进入一个循环产生两栏输出。最后,输出总共的文件数目。 ## 数组操作 有许多常见的数组操作。比方说删除数组,确定数组大小,排序,等等。有许多脚本应用程序。 ### 输出整个数组的内容 下标 * 和 @ 可以被用来访问数组中的每一个元素。与位置参数一样,@ 表示法在两者之中更有用处。 这里是一个演示: ~~~ [me@linuxbox ~]$ animals=("a dog" "a cat" "a fish") [me@linuxbox ~]$ for i in ${animals[*]}; do echo $i; done a dog a cat a fish [me@linuxbox ~]$ for i in ${animals[@]}; do echo $i; done a dog a cat a fish [me@linuxbox ~]$ for i in "${animals[*]}"; do echo $i; done a dog a cat a fish [me@linuxbox ~]$ for i in "${animals[@]}"; do echo $i; done a dog a cat a fish ~~~ 我们创建了数组 animals,并把三个含有两个字的字符串赋值给数组。然后我们执行四个循环看一下对数组内容进行分词的效果。 表示法 ${animals[*]} 和 ${animals[@]}的行为是一致的直到它们被用引号引起来。 ### 确定数组元素个数 使用参数展开,我们能够确定数组元素的个数,与计算字符串长度的方式几乎相同。这里是一个例子: ~~~ [me@linuxbox ~]$ a[100]=foo [me@linuxbox ~]$ echo ${#a[@]} # number of array elements 1 [me@linuxbox ~]$ echo ${#a[100]} # length of element 100 3 ~~~ 我们创建了数组 a,并把字符串 “foo” 赋值给数组元素100。下一步,我们使用参数展开来检查数组的长度,使用 @ 表示法。 最后,我们查看了包含字符串 “foo” 的数组元素 100 的长度。有趣的是,尽管我们把字符串赋值给数组元素100, bash 仅仅报告数组中有一个元素。这不同于一些其它语言的行为,数组中未使用的元素(元素0-99)会初始化为空值, 并把它们计入数组长度。 ### 找到数组使用的下标 因为 bash 允许赋值的数组下标包含 “间隔”,有时候确定哪个元素真正存在是很有用的。为做到这一点, 可以使用以下形式的参数展开: ${!array[*]} ${!array[@]} 这里的 array 是一个数组变量的名字。和其它使用符号 * 和 @ 的展开一样,用引号引起来的 @ 格式是最有用的, 因为它能展开成分离的词。 ~~~ [me@linuxbox ~]$ foo=([2]=a [4]=b [6]=c) [me@linuxbox ~]$ for i in "${foo[@]}"; do echo $i; done a b c [me@linuxbox ~]$ for i in "${!foo[@]}"; do echo $i; done 2 4 6 ~~~ ### 在数组末尾添加元素 如果我们需要在数组末尾附加数据,那么知道数组中元素的个数是没用的,因为通过 * 和 @ 表示法返回的数值不能 告诉我们使用的最大数组索引。幸运地是,shell 为我们提供了一种解决方案。通过使用 += 赋值运算符, 我们能够自动地把值附加到数组末尾。这里,我们把三个值赋给数组 foo,然后附加另外三个。 ~~~ [me@linuxbox~]$ foo=(a b c) [me@linuxbox~]$ echo ${foo[@]} a b c [me@linuxbox~]$ foo+=(d e f) [me@linuxbox~]$ echo ${foo[@]} a b c d e f ~~~ ### 数组排序 就像电子表格,经常有必要对一列数据进行排序。Shell 没有这样做的直接方法,但是通过一点儿代码,并不难实现。 ~~~ #!/bin/bash # array-sort : Sort an array a=(f e d c b a) echo "Original array: ${a[@]}" a_sorted=($(for i in "${a[@]}"; do echo $i; done | sort)) echo "Sorted array: ${a_sorted[@]}" ~~~ 当执行之后,脚本产生这样的结果: ~~~ [me@linuxbox ~]$ array-sort Original array: f e d c b a Sorted array: a b c d e f ~~~ 脚本运行成功,通过使用一个复杂的命令替换把原来的数组(a)中的内容复制到第二个数组(a_sorted)中。 通过修改管道线的设计,这个基本技巧可以用来对数组执行各种各样的操作。 ### 删除数组 删除一个数组,使用 unset 命令: ~~~ [me@linuxbox ~]$ foo=(a b c d e f) [me@linuxbox ~]$ echo ${foo[@]} a b c d e f [me@linuxbox ~]$ unset foo [me@linuxbox ~]$ echo ${foo[@]} [me@linuxbox ~]$ ~~~ 也可以使用 unset 命令删除单个的数组元素: ~~~ [me@linuxbox~]$ foo=(a b c d e f) [me@linuxbox~]$ echo ${foo[@]} a b c d e f [me@linuxbox~]$ unset 'foo[2]' [me@linuxbox~]$ echo ${foo[@]} a b d e f ~~~ 在这个例子中,我们删除了数组中的第三个元素,下标为2。记住,数组下标开始于0,而不是1!也要注意数组元素必须 用引号引起来为的是防止 shell 执行路径名展开操作。 有趣地是,给一个数组赋空值不会清空数组内容: ~~~ [me@linuxbox ~]$ foo=(a b c d e f) [me@linuxbox ~]$ foo= [me@linuxbox ~]$ echo ${foo[@]} b c d e f ~~~ 任何引用一个不带下标的数组变量,则指的是数组元素0: ~~~ [me@linuxbox~]$ foo=(a b c d e f) [me@linuxbox~]$ echo ${foo[@]} a b c d e f [me@linuxbox~]$ foo=A [me@linuxbox~]$ echo ${foo[@]} A b c d e f ~~~ ## 关联数组 现在最新的 bash 版本支持关联数组了。关联数组使用字符串而不是整数作为数组索引。 这种功能给出了一种有趣的新方法来管理数据。例如,我们可以创建一个叫做 “colors” 的数组,并用颜色名字作为索引。 ~~~ declare -A colors colors["red"]="#ff0000" colors["green"]="#00ff00" colors["blue"]="#0000ff" ~~~ 不同于整数索引的数组,仅仅引用它们就能创建数组,关联数组必须用带有 -A 选项的 declare 命令创建。 访问关联数组元素的方式几乎与整数索引数组相同: ~~~ echo ${colors["blue"]} ~~~ 在下一章中,我们将看一个脚本,很好地利用关联数组,生产出了一个有意思的报告。 ## 总结 如果我们在 bash 手册页中搜索单词 “array”的话,我们能找到许多 bash 在哪里会使用数组变量的实例。其中大部分相当晦涩难懂, 但是它们可能在一些特殊场合提供临时的工具。事实上,在 shell 编程中,整套数组规则利用率相当低,很大程度上归咎于这样的事实, 传统 Unix shell 程序(比如说 sh)缺乏对数组的支持。这样缺乏人气是不幸的,因为数组广泛应用于其它编程语言, 并为解决各种各样的编程问题,提供了一个强大的工具。 数组和循环有一种天然的姻亲关系,它们经常被一起使用。该 ~~~ for ((expr; expr; expr)) ~~~ 形式的循环尤其适合计算数组下标。 ## 拓展阅读 * Wikipedia 上面有两篇关于在本章提到的数据结构的文章: [http://en.wikipedia.org/wiki/Scalar_(computing)](http://en.wikipedia.org/wiki/Scalar_(computing)) [http://en.wikipedia.org/wiki/Associative_array](http://en.wikipedia.org/wiki/Associative_array)
';

第三十五章:字符串和数字

最后更新于:2022-04-02 01:46:27

所有的计算机程序都是用来和数据打交道的。在过去的章节中,我们专注于处理文件级别的数据。 然而,许多程序问题需要使用更小的数据单位来解决,比方说字符串和数字。 在这一章中,我们将查看几个用来操作字符串和数字的 shell 功能。shell 提供了各种执行字符串操作的参数展开功能。 除了算术展开(在第七章中接触过),还有一个常见的命令行程序叫做 bc,能执行更高级别的数学运算。 ## 参数展开 尽管参数展开在第七章中出现过,但我们并没有详尽地介绍它,因为大多数的参数展开会用在脚本中,而不是命令行中。 我们已经使用了一些形式的参数展开;例如,shell 变量。shell 提供了更多方式。 ### 基本参数 最简单的参数展开形式反映在平常使用的变量上。 例如: **$a** 当 $a 展开后,会变成变量 a 所包含的值。简单参数也可能用花括号引起来: **${a}** 虽然这对展开没有影响,但若该变量 a 与其它的文本相邻,可能会把 shell 搞糊涂了。在这个例子中,我们试图 创建一个文件名,通过把字符串 “_file” 附加到变量 a 的值的后面。 ~~~ [me@linuxbox ~]$ a="foo" [me@linuxbox ~]$ echo "$a_file" ~~~ 如果我们执行这个序列,没有任何输出结果,因为 shell 会试着展开一个称为 a_file 的变量,而不是 a。通过 添加花括号可以解决这个问题: ~~~ [me@linuxbox ~]$ echo "${a}_file" foo_file ~~~ 我们已经知道通过把数字包裹在花括号中,可以访问大于9的位置参数。例如,访问第十一个位置参数,我们可以这样做: **${11}** ### 管理空变量的展开 几种用来处理不存在和空变量的参数展开形式。这些展开形式对于解决丢失的位置参数和给参数指定默认值的情况很方便。 **${parameter:-word}** 若 parameter 没有设置(例如,不存在)或者为空,展开结果是 word 的值。若 parameter 不为空,则展开结果是 parameter 的值。 ~~~ [me@linuxbox ~]$ foo= [me@linuxbox ~]$ echo ${foo:-"substitute value if unset"} if unset substitute value [me@linuxbox ~]$ echo $foo [me@linuxbox ~]$ foo=bar [me@linuxbox ~]$ echo ${foo:-"substitute value if unset"} bar [me@linuxbox ~]$ echo $foo bar ~~~ **${parameter:=word}** 若 parameter 没有设置或为空,展开结果是 word 的值。另外,word 的值会赋值给 parameter。 若 parameter 不为空,展开结果是 parameter 的值。 ~~~ [me@linuxbox ~]$ foo= [me@linuxbox ~]$ echo ${foo:="default value if unset"} default value if unset [me@linuxbox ~]$ echo $foo default value if unset [me@linuxbox ~]$ foo=bar [me@linuxbox ~]$ echo ${foo:="default value if unset"} bar [me@linuxbox ~]$ echo $foo bar ~~~ * * * 注意: 位置参数或其它的特殊参数不能以这种方式赋值。 * * * **${parameter:?word}** 若 parameter 没有设置或为空,这种展开导致脚本带有错误退出,并且 word 的内容会发送到标准错误。若 parameter 不为空, 展开结果是 parameter 的值。 ~~~ [me@linuxbox ~]$ foo= [me@linuxbox ~]$ echo ${foo:?"parameter is empty"} bash: foo: parameter is empty [me@linuxbox ~]$ echo $? 1 [me@linuxbox ~]$ foo=bar [me@linuxbox ~]$ echo ${foo:?"parameter is empty"} bar [me@linuxbox ~]$ echo $? 0 ~~~ **${parameter:+word}** 若 parameter 没有设置或为空,展开结果为空。若 parameter 不为空, 展开结果是 word 的值会替换掉 parameter 的值;然而,parameter 的值不会改变。 ~~~ [me@linuxbox ~]$ foo= [me@linuxbox ~]$ echo ${foo:+"substitute value if set"} [me@linuxbox ~]$ foo=bar [me@linuxbox ~]$ echo ${foo:+"substitute value if set"} substitute value if set ~~~ ## 返回变量名的参数展开 shell 具有返回变量名的能力。这会用在一些相当独特的情况下。 ~~~ ${!prefix*} ${!prefix@} ~~~ 这种展开会返回以 prefix 开头的已有变量名。根据 bash 文档,这两种展开形式的执行结果相同。 这里,我们列出了所有以 BASH 开头的环境变量名: ~~~ [me@linuxbox ~]$ echo ${!BASH*} BASH BASH_ARGC BASH_ARGV BASH_COMMAND BASH_COMPLETION BASH_COMPLETION_DIR BASH_LINENO BASH_SOURCE BASH_SUBSHELL BASH_VERSINFO BASH_VERSION ~~~ ### 字符串展开 有大量的展开形式可用于操作字符串。其中许多展开形式尤其适用于路径名的展开。 **${#parameter}** 展开成由 parameter 所包含的字符串的长度。通常,parameter 是一个字符串;然而,如果 parameter 是 @ 或者是 * 的话, 则展开结果是位置参数的个数。 ~~~ [me@linuxbox ~]$ foo="This string is long." [me@linuxbox ~]$ echo "'$foo' is ${#foo} characters long." 'This string is long.' is 20 characters long. ~~~ **${parameter:offset}** **${parameter:offset:length}** 这些展开用来从 parameter 所包含的字符串中提取一部分字符。提取的字符始于 第 offset 个字符(从字符串开头算起)直到字符串的末尾,除非指定提取的长度。 ~~~ [me@linuxbox ~]$ foo="This string is long." [me@linuxbox ~]$ echo ${foo:5} string is long. [me@linuxbox ~]$ echo ${foo:5:6} string ~~~ 若 offset 的值为负数,则认为 offset 值是从字符串的末尾开始算起,而不是从开头。注意负数前面必须有一个空格, 为防止与 ${parameter:-word} 展开形式混淆。length,若出现,则必须不能小于零。 如果 parameter 是 @,展开结果是 length 个位置参数,从第 offset 个位置参数开始。 ~~~ [me@linuxbox ~]$ foo="This string is long." [me@linuxbox ~]$ echo ${foo: -5} long. [me@linuxbox ~]$ echo ${foo: -5:2} lo ~~~ **${parameter#pattern}** **${parameter##pattern}** 这些展开会从 paramter 所包含的字符串中清除开头一部分文本,这些字符要匹配定义的 patten。pattern 是 通配符模式,就如那些用在路径名展开中的模式。这两种形式的差异之处是该 # 形式清除最短的匹配结果, 而该 ## 模式清除最长的匹配结果。 ~~~ [me@linuxbox ~]$ foo=file.txt.zip [me@linuxbox ~]$ echo ${foo#*.} txt.zip [me@linuxbox ~]$ echo ${foo##*.} zip ~~~ **${parameter%pattern}** **${parameter%%pattern}** 这些展开和上面的 # 和 ## 展开一样,除了它们清除的文本从 parameter 所包含字符串的末尾开始,而不是开头。 ~~~ [me@linuxbox ~]$ foo=file.txt.zip [me@linuxbox ~]$ echo ${foo%.*} file.txt [me@linuxbox ~]$ echo ${foo%%.*} file ~~~ **${parameter/pattern/string}** **${parameter//pattern/string}** **${parameter/#pattern/string}** **${parameter/%pattern/string}** 这种形式的展开对 parameter 的内容执行查找和替换操作。如果找到了匹配通配符 pattern 的文本, 则用 string 的内容替换它。在正常形式下,只有第一个匹配项会被替换掉。在该 // 形式下,所有的匹配项都会被替换掉。 该 /# 要求匹配项出现在字符串的开头,而 /% 要求匹配项出现在字符串的末尾。/string 可能会省略掉,这样会 导致删除匹配的文本。 ~~~ [me@linuxbox~]$ foo=JPG.JPG [me@linuxbox ~]$ echo ${foo/JPG/jpg} jpg.JPG [me@linuxbox~]$ echo ${foo//JPG/jpg} jpg.jpg [me@linuxbox~]$ echo ${foo/#JPG/jpg} jpg.JPG [me@linuxbox~]$ echo ${foo/%JPG/jpg} JPG.jpg ~~~ 知道参数展开是件很好的事情。字符串操作展开可以用来替换其它常见命令比方说 sed 和 cut。 通过减少使用外部程序,展开提高了脚本的效率。举例说明,我们将修改在之前章节中讨论的 longest-word 程序, 用参数展开 ${#j} 取代命令 $(echo $j | wc -c) 及其 subshell ,像这样: ~~~ #!/bin/bash # longest-word3 : find longest string in a file for i; do if [[ -r $i ]]; then max_word= max_len= for j in $(strings $i); do len=${#j} if (( len > max_len )); then max_len=$len max_word=$j fi done echo "$i: '$max_word' ($max_len characters)" fi shift done ~~~ 下一步,我们将使用 time 命令来比较这两个脚本版本的效率: ~~~ [me@linuxbox ~]$ time longest-word2 dirlist-usr-bin.txt dirlist-usr-bin.txt: 'scrollkeeper-get-extended-content-list' (38 characters) real 0m3.618s user 0m1.544s sys 0m1.768s [me@linuxbox ~]$ time longest-word3 dirlist-usr-bin.txt dirlist-usr-bin.txt: 'scrollkeeper-get-extended-content-list' (38 characters) real 0m0.060s user 0m0.056s sys 0m0.008s ~~~ 原来的脚本扫描整个文本文件需耗时3.168秒,而该新版本,使用参数展开,仅仅花费了0.06秒 —— 一个非常巨大的提高。 ### 大小写转换 最新的 bash 版本已经支持字符串的大小写转换了。bash 有四个参数展开和 declare 命令的两个选项来支持大小写转换。 那么大小写转换对什么有好处呢? 除了明显的审美价值,它在编程领域还有一个重要的角色。 让我们考虑一个数据库查询的案例。假设一个用户已经敲写了一个字符串到数据输入框中, 而我们想要在一个数据库中查找这个字符串。该用户输入的字符串有可能全是大写字母或全是小写或是两者的结合。 我们当然不希望把每个可能的大小写拼写排列填充到我们的数据库中。那怎么办? 解决这个问题的常见方法是规范化用户输入。也就是,在我们试图查询数据库之前,把用户的输入转换成标准化。 我们能做到这一点,通过把用户输入的字符全部转换成小写字母或大写字母,并且确保数据库中的条目 按同样的方式规范化。 这个 declare 命令可以用来把字符串规范成大写或小写字符。使用 declare 命令,我们能强制一个 变量总是包含所需的格式,无论如何赋值给它。 ~~~ #!/bin/bash # ul-declare: demonstrate case conversion via declare declare -u upper declare -l lower if [[ $1 ]]; then upper="$1" lower="$1" echo $upper echo $lower fi ~~~ 在上面的脚本中,我们使用 declare 命令来创建两个变量,upper 和 lower。我们把第一个命令行参数的值(位置参数1)赋给 每一个变量,然后把变量值在屏幕上显示出来: ~~~ [me@linuxbox ~]$ ul-declare aBc ABC abc ~~~ 正如我们所看到的,命令行参数(“aBc”)已经规范化了。 有四个参数展开,可以执行大小写转换操作: 表 35-1: 大小写转换参数展开 | 格式 | 结果 | |------|-------| | ${parameter,,} | 把 parameter 的值全部展开成小写字母。 | | ${parameter,} | 仅仅把 parameter 的第一个字符展开成小写字母。 | | ${parameter^^} | 把 parameter 的值全部转换成大写字母。 | | ${parameter^} | 仅仅把 parameter 的第一个字符转换成大写字母(首字母大写)。 | 这里是一个脚本,演示了这些展开格式: ~~~ #!/bin/bash # ul-param - demonstrate case conversion via parameter expansion if [[ $1 ]]; then echo ${1,,} echo ${1,} echo ${1^^} echo ${1^} fi ~~~ 这里是脚本运行后的结果: ~~~ [me@linuxbox ~]$ ul-param aBc abc aBc ABC ABc ~~~ 再次,我们处理了第一个命令行参数,输出了由参数展开支持的四种变体。尽管这个脚本使用了第一个位置参数, 但参数可以是任意字符串,变量,或字符串表达式。 ## 算术求值和展开 我们在第七章中已经接触过算术展开了。它被用来对整数执行各种算术运算。它的基本格式是: ~~~ $((expression)) ~~~ 这里的 expression 是一个有效的算术表达式。 这个与复合命令 (( )) 有关,此命令用做算术求值(真测试),我们在第27章中遇到过。 在之前的章节中,我们看到过一些类型的表达式和运算符。这里,我们将看到一个更完整的列表。 ### 数基 回到第9章,我们看过八进制(以8为底)和十六进制(以16为底)的数字。在算术表达式中,shell 支持任意进制的整形常量。 表 35-2: 指定不同的数基 | 表示法 | 描述 | |------|-------| | number | 默认情况下,没有任何表示法的数字被看做是十进制数(以10为底)。 | | 0number | 在算术表达式中,以零开头的数字被认为是八进制数。 | | 0xnumber | 十六进制表示法 | | base#number | number 以 base 为底 | 一些例子: ~~~ [me@linuxbox ~]$ echo $((0xff)) 255 [me@linuxbox ~]$ echo $((2#11111111)) 255 ~~~ 在上面的示例中,我们打印出十六进制数 ff(最大的两位数)的值和最大的八位二进制数(以2为底)。 ### 一元运算符 有两个二元运算符,+ 和 -,它们被分别用来表示一个数字是正数还是负数。例如,-5。 ### 简单算术 下表中列出了普通算术运算符: 表 35-3: 算术运算符 | 运算符 | 描述 | |------|-------| | + | 加 | | - | 减 | | * | 乘 | | / | 整除 | | ** | 乘方 | | % | 取模(余数) | 其中大部分运算符是不言自明的,但是整除和取模运算符需要进一步解释一下。 因为 shell 算术只操作整形,所以除法运算的结果总是整数: ~~~ [me@linuxbox ~]$ echo $(( 5 / 2 )) 2 ~~~ 这使得确定除法运算的余数更为重要: ~~~ [me@linuxbox ~]$ echo $(( 5 % 2 )) 1 ~~~ 通过使用除法和取模运算符,我们能够确定5除以2得数是2,余数是1。 在循环中计算余数是很有用处的。在循环执行期间,它允许某一个操作在指定的间隔内执行。在下面的例子中, 我们显示一行数字,并高亮显示5的倍数: ~~~ #!/bin/bash # modulo : demonstrate the modulo operator for ((i = 0; i <= 20; i = i + 1)); do remainder=$((i % 5)) if (( remainder == 0 )); then printf "<%d> " $i else printf "%d " $i fi done printf "\n" ~~~ 当脚本执行后,输出结果看起来像这样: ~~~ [me@linuxbox ~]$ modulo <0> 1 2 3 4 <5> 6 7 8 9 <10> 11 12 13 14 <15> 16 17 18 19 <20> ~~~ ### 赋值运算符 尽管它的使用不是那么明显,算术表达式可能执行赋值运算。虽然在不同的上下文中,我们已经执行了许多次赋值运算。 每次我们给变量一个值,我们就执行了一次赋值运算。我们也能在算术表达式中执行赋值运算: ~~~ [me@linuxbox ~]$ foo= [me@linuxbox ~]$ echo $foo [me@linuxbox ~]$ if (( foo = 5 ));then echo "It is true."; fi It is true. [me@linuxbox ~]$ echo $foo 5 ~~~ 在上面的例子中,首先我们给变量 foo 赋了一个空值,然后验证 foo 的确为空。下一步,我们执行一个 if 复合命令 (( foo = 5 ))。 这个过程完成两件有意思的事情:1)它把5赋值给变量 foo,2)它计算测试条件为真,因为 foo 的值非零。 * * * 注意: 记住上面表达式中 = 符号的真正含义非常重要。单个 = 运算符执行赋值运算。foo = 5 是说“使得 foo 等于5”, 而 == 运算符计算等价性。foo == 5 是说“是否 foo 等于5?”。这会让人感到非常迷惑,因为 test 命令接受单个 = 运算符 来测试字符串等价性。这也是使用更现代的 [[ ]] 和 (( )) 复合命令来代替 test 命令的另一个原因。 * * * 除了 = 运算符,shell 也提供了其它一些表示法,来执行一些非常有用的赋值运算: 表35-4: 赋值运算符 | 表示法 | 描述 | |------|-------| | parameter = value | 简单赋值。给 parameter 赋值。 | | parameter += value | 加。等价于 parameter = parameter + value。 | | parameter -= value | 减。等价于 parameter = parameter – value。 | | parameter *= value | 乘。等价于 parameter = parameter * value。 | | parameter /= value | 整除。等价于 parameter = parameter / value。 | | parameter %= value | 取模。等价于 parameter = parameter % value。 | | parameter++ | 后缀自增变量。等价于 parameter = parameter + 1 (但,要看下面的讨论)。 | | parameter-- | 后缀自减变量。等价于 parameter = parameter - 1。 | | ++parameter | 前缀自增变量。等价于 parameter = parameter + 1。 | | --parameter | 前缀自减变量。等价于 parameter = parameter - 1。 | 这些赋值运算符为许多常见算术任务提供了快捷方式。特别关注一下自增(++)和自减(--)运算符,它们会把它们的参数值加1或减1。 这种风格的表示法取自C 编程语言并且被其它几种编程语言吸收,包括 bash。 自增和自减运算符可能会出现在参数的前面或者后面。然而它们都是把参数值加1或减1,这两个位置有个微小的差异。 若运算符放置在参数的前面,参数值会在参数返回之前增加(或减少)。若放置在后面,则运算会在参数返回之后执行。 这相当奇怪,但这是它预期的行为。这里是个演示的例子: ~~~ [me@linuxbox ~]$ foo=1 [me@linuxbox ~]$ echo $((foo++)) 1 [me@linuxbox ~]$ echo $foo 2 ~~~ 如果我们把1赋值给变量 foo,然后通过把自增运算符 ++ 放到参数名 foo 之后来增加它,foo 返回1。 然而,如果我们第二次查看变量 foo 的值,我们看到它的值增加了1。若我们把 ++ 运算符放到参数 foo 之前, 我们得到更期望的行为: ~~~ [me@linuxbox ~]$ foo=1 [me@linuxbox ~]$ echo $((++foo)) 2 [me@linuxbox ~]$ echo $foo 2 ~~~ 对于大多数 shell 应用来说,前缀运算符最有用。 自增 ++ 和 自减 -- 运算符经常和循环操作结合使用。我们将改进我们的 modulo 脚本,让代码更紧凑些: ~~~ #!/bin/bash # modulo2 : demonstrate the modulo operator for ((i = 0; i <= 20; ++i )); do if (((i % 5) == 0 )); then printf "<%d> " $i else printf "%d " $i fi done printf "\n" ~~~ ### 位运算符 位运算符是一类以不寻常的方式操作数字的运算符。这些运算符工作在位级别的数字。它们被用在某类底层的任务中, 经常涉及到设置或读取位标志。 表35-5: 位运算符 | 运算符 | 描述 | |------|-------| | ~ | 按位取反。对一个数字所有位取反。 | | << | 位左移. 把一个数字的所有位向左移动。 | | >> | 位右移. 把一个数字的所有位向右移动。 | | & | 位与。对两个数字的所有位执行一个 AND 操作。 | | | | 位或。对两个数字的所有位执行一个 OR 操作。 | | ^ | 位异或。对两个数字的所有位执行一个异或操作。 | 注意除了按位取反运算符之外,其它所有位运算符都有相对应的赋值运算符(例如,<<=)。 这里我们将演示产生2的幂列表的操作,使用位左移运算符: ~~~ [me@linuxbox ~]$ for ((i=0;i<8;++i)); do echo $((1<= | 大于或相等 | | < | 小于 | | > | 大于 | | == | 相等 | | != | 不相等 | | && | 逻辑与 | | || | 逻辑或 | | expr1?expr2:expr3 | 条件(三元)运算符。若表达式 expr1 的计算结果为非零值(算术真),则 执行表达式 expr2,否则执行表达式 expr3。 | 当表达式用于逻辑运算时,表达式遵循算术逻辑规则;也就是,表达式的计算结果是零,则认为假,而非零表达式认为真。 该 (( )) 复合命令把结果映射成 shell 正常的退出码: ~~~ [me@linuxbox ~]$ if ((1)); then echo "true"; else echo "false"; fi true [me@linuxbox ~]$ if ((0)); then echo "true"; else echo "false"; fi false ~~~ 最陌生的逻辑运算符就是这个三元运算符了。这个运算符(仿照于 C 编程语言里的三元运算符)执行一个单独的逻辑测试。 它用起来类似于 if/then/else 语句。它操作三个算术表达式(字符串不会起作用),并且若第一个表达式为真(或非零), 则执行第二个表达式。否则,执行第三个表达式。我们可以在命令行中实验一下: ~~~ [me@linuxbox~]$ a=0 [me@linuxbox~]$ ((a<1?++a:--a)) [me@linuxbox~]$ echo $a 1 [me@linuxbox~]$ ((a<1?++a:--a)) [me@linuxbox~]$ echo $a 0 ~~~ 这里我们看到一个实际使用的三元运算符。这个例子实现了一个切换。每次运算符执行的时候,变量 a 的值从零变为1,或反之亦然。 请注意在表达式内执行赋值却并非易事。 当企图这样做时,bash 会声明一个错误: ~~~ [me@linuxbox ~]$ a=0 [me@linuxbox ~]$ ((a<1?a+=1:a-=1)) bash: ((: a<1?a+=1:a-=1: attempted assignment to non-variable (error token is "-=1") ~~~ 通过把赋值表达式用括号括起来,可以解决这个错误: ~~~ [me@linuxbox ~]$ ((a<1?(a+=1):(a-=1))) ~~~ 下一步,我们看一个使用算术运算符更完备的例子,该示例产生一个简单的数字表格: ~~~ #!/bin/bash # arith-loop: script to demonstrate arithmetic operators finished=0 a=0 printf "a\ta**2\ta**3\n" printf "=\t====\t====\n" until ((finished)); do b=$((a**2)) c=$((a**3)) printf "%d\t%d\t%d\n" $a $b $c ((a<10?++a:(finished=1))) done ~~~ 在这个脚本中,我们基于变量 finished 的值实现了一个 until 循环。首先,把变量 finished 的值设为零(算术假), 继续执行循环之道它的值变为非零。在循环体内,我们计算计数器 a 的平方和立方。在循环末尾,计算计数器变量 a 的值。 若它小于10(最大迭代次数),则 a 的值加1,否则给变量 finished 赋值为1,使得变量 finished 算术为真, 从而终止循环。运行该脚本得到这样的结果: ~~~ [me@linuxbox ~]$ arith-loop a a**2 a**3 = ==== ==== 0 0 0 1 1 1 2 4 8 3 9 27 4 16 64 5 25 125 6 36 216 7 49 343 8 64 512 9 81 729 10 100 1000 ~~~ ## bc - 一种高精度计算器语言 我们已经看到 shell 是可以处理所有类型的整形算术的,但是如果我们需要执行更高级的数学运算或仅使用浮点数,该怎么办? 答案是,我们不能这样做。至少不能直接用 shell 完成此类运算。为此,我们需要使用外部程序。 有几种途径可供我们采用。嵌入的 Perl 或者 AWK 程序是一种可能的方案,但是不幸的是,超出了本书的内容大纲。 另一种方式就是使用一种专业的计算器程序。这样一个程序叫做 bc,在大多数 Linux 系统中都可以找到。 该 bc 程序读取一个用它自己的类似于 C 语言的语法编写的脚本文件。一个 bc 脚本可能是一个分离的文件或者是读取 标准输入。bc 语言支持相当少的功能,包括变量,循环和程序员定义的函数。这里我们不会讨论整个 bc 语言, 仅仅体验一下。查看 bc 的手册页,其文档整理非常好。 让我们从一个简单的例子开始。我们将编写一个 bc 脚本来执行2加2运算: ~~~ /* A very simple bc script */ 2 + 2 ~~~ 脚本的第一行是一行注释。bc 使用和 C 编程语言一样的注释语法。注释,可能会跨越多行,开始于 `/*` 结束于`*/`。 ### 使用 bc 如果我们把上面的 bc 脚本保存为 foo.bc,然后我们就能这样运行它: ~~~ [me@linuxbox ~]$ bc foo.bc bc 1.06.94 Copyright 1991-1994, 1997, 1998, 2000, 2004, 2006 Free Software Foundation, Inc. This is free software with ABSOLUTELY NO WARRANTY. For details type `warranty'. 4 ~~~ 如果我们仔细观察,我们看到算术结果在最底部,版权信息之后。可以通过 -q(quiet)选项禁止这些版权信息。 bc 也能够交互使用: ~~~ [me@linuxbox ~]$ bc -q 2 + 2 4 quit ~~~ 当使用 bc 交互模式时,我们简单地输入我们希望执行的运算,结果就立即显示出来。bc 的 quit 命令结束交互会话。 也可能通过标准输入把一个脚本传递给 bc 程序: ~~~ [me@linuxbox ~]$ bc < foo.bc 4 ~~~ 这种接受标准输入的能力,意味着我们可以使用 here 文档,here字符串,和管道来传递脚本。这里是一个使用 here 字符串的例子: ~~~ [me@linuxbox ~]$ bc <<< "2+2" 4 ~~~ ### 一个脚本实例 作为一个真实世界的例子,我们将构建一个脚本,用于计算每月的还贷金额。在下面的脚本中, 我们使用了 here 文档把一个脚本传递给 bc: ~~~ #!/bin/bash # loan-calc : script to calculate monthly loan payments PROGNAME=$(basename $0) usage () { cat <<- EOF Usage: $PROGNAME PRINCIPAL INTEREST MONTHS Where: PRINCIPAL is the amount of the loan. INTEREST is the APR as a number (7% = 0.07). MONTHS is the length of the loan's term. EOF } if (($# != 3)); then usage exit 1 fi principal=$1 interest=$2 months=$3 bc <<- EOF scale = 10 i = $interest / 12 p = $principal n = $months a = p * ((i * ((1 + i) ^ n)) / (((1 + i) ^ n) - 1)) print a, "\n" EOF ~~~ 当脚本执行后,输出结果像这样: ~~~ [me@linuxbox ~]$ loan-calc 135000 0.0775 180 475 1270.7222490000 ~~~ 若贷款 135,000 美金,年利率为 7.75%,借贷180个月(15年),这个例子计算出每月需要还贷的金额。 注意这个答案的精确度。这是由脚本中变量 scale 的值决定的。bc 的手册页提供了对 bc 脚本语言的详尽描述。 虽然 bc 的数学符号与 shell 的略有差异(bc 与 C 更相近),但是基于目前我们所学的内容, 大多数符号是我们相当熟悉的。 ## 总结 在这一章中,我们学习了很多小东西,在脚本中这些小零碎可以完成“真正的工作”。随着我们编写脚本经验的增加, 能够有效地操作字符串和数字的能力将具有极为重要的价值。我们的 loan-calc 脚本表明, 甚至可以创建简单的脚本来完成一些真正有用的事情。 ## 额外加分 虽然该 loan-calc 脚本的基本功能已经很到位了,但脚本还远远不够完善。为了额外加分,试着 给脚本 loan-calc 添加以下功能: * 完整的命令行参数验证 * 用一个命令行选项来实现“交互”模式,提示用户输入本金、利率和贷款期限 * 输出格式美化 ## 拓展阅读 * 《Bash Hackers Wiki》对参数展开有一个很好的论述: [http://wiki.bash-hackers.org/syntax/pe](http://wiki.bash-hackers.org/syntax/pe) * 《Bash 参考手册》也介绍了这个: [http://www.gnu.org/software/bash/manual/bashref.html#Shell-Parameter-Expansion](http://www.gnu.org/software/bash/manual/bashref.html#Shell-Parameter-Expansion) * Wikipedia 上面有一篇很好的文章描述了位运算: [http://en.wikipedia.org/wiki/Bit_operation](http://en.wikipedia.org/wiki/Bit_operation) * 和一篇关于三元运算的文章: [http://en.wikipedia.org/wiki/Ternary_operation](http://en.wikipedia.org/wiki/Ternary_operation) * 还有一个对计算还贷金额公式的描述,我们的 loan-calc 脚本中用到了这个公式: [http://en.wikipedia.org/wiki/Amortization_calculator](http://en.wikipedia.org/wiki/Amortization_calculator)
';

第三十四章:流程控制 for循环

最后更新于:2022-04-02 01:46:24

在这关于流程控制的最后一章中,我们将看看另一种 shell 循环构造。for 循环不同于 while 和 until 循环,因为 在循环中,它提供了一种处理序列的方式。这证明在编程时非常有用。因此在 bash 脚本中,for 循环是非常流行的构造。 实现一个 for 循环,很自然的,要用 for 命令。在现代版的 bash 中,有两种可用的 for 循环格式。 ## for: 传统 shell 格式 原来的 for 命令语法是: ~~~ for variable [in words]; do commands done ~~~ 这里的 variable 是一个变量的名字,这个变量在循环执行期间会增加,words 是一个可选的条目列表, 其值会按顺序赋值给 variable,commands 是在每次循环迭代中要执行的命令。 在命令行中 for 命令是很有用的。我们可以很容易的说明它是如何工作的: ~~~ [me@linuxbox ~]$ for i in A B C D; do echo $i; done A B C D ~~~ 在这个例子中,for 循环有一个四个单词的列表:“A”,“B”,“C”,和 “D”。由于这四个单词的列表,for 循环会执行四次。 每次循环执行的时候,就会有一个单词赋值给变量 i。在循环体内,我们有一个 echo 命令会显示 i 变量的值,来演示赋值结果。 正如 while 和 until 循环,done 关键字会关闭循环。 for 命令真正强大的功能是我们可以通过许多有趣的方式创建 words 列表。例如,通过花括号展开: ~~~ [me@linuxbox ~]$ for i in {A..D}; do echo $i; done A B C D ~~~ 或者路径名展开: ~~~ [me@linuxbox ~]$ for i in distros*.txt; do echo $i; done distros-by-date.txt distros-dates.txt distros-key-names.txt distros-key-vernums.txt distros-names.txt distros.txt distros-vernums.txt distros-versions.txt ~~~ 或者命令替换: ~~~ #!/bin/bash # longest-word : find longest string in a file while [[ -n $1 ]]; do if [[ -r $1 ]]; then max_word= max_len=0 for i in $(strings $1); do len=$(echo $i | wc -c) if (( len > max_len )); then max_len=$len max_word=$i fi done echo "$1: '$max_word' ($max_len characters)" fi shift done ~~~ 在这个示例中,我们要在一个文件中查找最长的字符串。当在命令行中给出一个或多个文件名的时候, 该程序会使用 strings 程序(其包含在 GNU binutils 包中),为每一个文件产生一个可读的文本格式的 “words” 列表。 然后这个 for 循环依次处理每个单词,判断当前这个单词是否为目前为止找到的最长的一个。当循环结束的时候,显示出最长的单词。 如果省略掉 for 命令的可选项 words 部分,for 命令会默认处理位置参数。 我们将修改 longest-word 脚本,来使用这种方式: ~~~ #!/bin/bash # longest-word2 : find longest string in a file for i; do if [[ -r $i ]]; then max_word= max_len=0 for j in $(strings $i); do len=$(echo $j | wc -c) if (( len > max_len )); then max_len=$len max_word=$j fi done echo "$i: '$max_word' ($max_len characters)" fi done ~~~ 正如我们所看到的,我们已经更改了最外围的循环,用 for 循环来代替 while 循环。通过省略 for 命令的 words 列表, 用位置参数替而代之。在循环体内,之前的变量 i 已经改为变量 j。同时 shift 命令也被淘汰掉了。 > 为什么是 i? > > 你可能已经注意到上面所列举的 for 循环的实例都选择 i 作为变量。为什么呢? 实际上没有具体原因,除了传统习惯。 for 循环使用的变量可以是任意有效的变量,但是 i 是最常用的一个,其次是 j 和 k。 > > 这一传统的基础源于 Fortran 编程语言。在 Fortran 语言中,以字母 I,J,K,L 和 M 开头的未声明变量的类型 自动设为整形,而以其它字母开头的变量则为实数类型(带有小数的数字)。这种行为导致程序员使用变量 I,J,和 K 作为循环变量, 因为当需要一个临时变量(正如循环变量)的时候,使用它们工作量比较少。这也引出了如下基于 fortran 的俏皮话: > > “神是真实的,除非是声明的整数。” ## for: C 语言格式 最新版本的 bash 已经添加了第二种格式的 for 命令语法,该语法相似于 C 语言中的 for 语法格式。 其它许多编程语言也支持这种格式: ~~~ for (( expression1; expression2; expression3 )); do commands done ~~~ 这里的 expression1,expression2,和 expression3 都是算术表达式,commands 是每次循环迭代时要执行的命令。 在行为方面,这相当于以下构造形式: ~~~ (( expression1 )) while (( expression2 )); do commands (( expression3 )) done ~~~ expression1 用来初始化循环条件,expression2 用来决定循环结束的时间,还有在每次循环迭代的末尾会执行 expression3。 这里是一个典型应用: ~~~ #!/bin/bash # simple_counter : demo of C style for command for (( i=0; i<5; i=i+1 )); do echo $i done ~~~ 脚本执行之后,产生如下输出: ~~~ [me@linuxbox ~]$ simple_counter 0 1 2 3 4 ~~~ 在这个示例中,expression1 初始化变量 i 的值为0,expression2 允许循环继续执行只要变量 i 的值小于5, 还有每次循环迭代时,expression3 会把变量 i 的值加1。 C 语言格式的 for 循环对于需要一个数字序列的情况是很有用处的。我们将在接下来的两章中看到几个这样的应用实例。 ## 总结 学习了 for 命令的知识,现在我们将对我们的 sys_info_page 脚本做最后的改进。 目前,这个 report_home_space 函数看起来像这样: ~~~ report_home_space () { if [[ $(id -u) -eq 0 ]]; then cat <<- _EOF_

Home Space Utilization (All Users)

$(du -sh /home/*)
_EOF_ else cat <<- _EOF_

Home Space Utilization ($USER)

$(du -sh $HOME)
_EOF_ fi return } ~~~ 下一步,我们将重写它,以便提供每个用户家目录的更详尽信息,并且包含用户家目录中文件和目录的总个数: ~~~ report_home_space () { local format="%8s%10s%10s\n" local i dir_list total_files total_dirs total_size user_name if [[ $(id -u) -eq 0 ]]; then dir_list=/home/* user_name="All Users" else dir_list=$HOME user_name=$USER fi echo "

Home Space Utilization ($user_name)

" for i in $dir_list; do total_files=$(find $i -type f | wc -l) total_dirs=$(find $i -type d | wc -l) total_size=$(du -sh $i | cut -f 1) echo "

$i

" echo "
"
        printf "$format" "Dirs" "Files" "Size"
        printf "$format" "----" "-----" "----"
        printf "$format" $total_dirs $total_files $total_size
        echo "
" done return } ~~~ 这次重写应用了目前为止我们学过的许多知识。我们仍然测试超级用户(superuser),但是我们在 if 语句块内 设置了一些随后会在 for 循环中用到的变量,来取代在 if 语句块内执行完备的动作集合。我们添加了给 函数添加了几个本地变量,并且使用 printf 来格式化输出。 ## 拓展阅读 * 《高级 Bash 脚本指南》有一章关于循环的内容,其中列举了各种各样的 for 循环实例: [http://tldp.org/LDP/abs/html/loops1.html](http://tldp.org/LDP/abs/html/loops1.html) * 《Bash 参考手册》描述了循环复合命令,包括了 for 循环: [http://www.gnu.org/software/bash/manual/bashref.html#Looping-Constructs](http://www.gnu.org/software/bash/manual/bashref.html#Looping-Constructs)
';

第三十三章:位置参数

最后更新于:2022-04-02 01:46:22

现在我们的程序还缺少一种本领,就是接收和处理命令行选项和参数的能力。在这一章中,我们将探究一些能 让程序访问命令行内容的 shell 性能。 ## 访问命令行 shell 提供了一个称为位置参数的变量集合,这个集合包含了命令行中所有独立的单词。这些变量按照从0到9给予命名。 可以以这种方式讲明白: ~~~ #!/bin/bash # posit-param: script to view command line parameters echo " \$0 = $0 \$1 = $1 \$2 = $2 \$3 = $3 \$4 = $4 \$5 = $5 \$6 = $6 \$7 = $7 \$8 = $8 \$9 = $9 " ~~~ 一个非常简单的脚本,显示从 $0 到 $9 所有变量的值。当不带命令行参数执行该脚本时,输出结果如下: ~~~ [me@linuxbox ~]$ posit-param $0 = /home/me/bin/posit-param $1 = $2 = $3 = $4 = $5 = $6 = $7 = $8 = $9 = ~~~ 即使不带命令行参数,位置参数 $0 总会包含命令行中出现的第一个单词,也就是已执行程序的路径名。 当带参数执行脚本时,我们看看输出结果: ~~~ [me@linuxbox ~]$ posit-param a b c d $0 = /home/me/bin/posit-param $1 = a $2 = b $3 = c $4 = d $5 = $6 = $7 = $8 = $9 = ~~~ 注意: 实际上通过参数展开方式你可以访问的参数个数多于9个。只要指定一个大于9的数字,用花括号把该数字括起来就可以。 例如 ${10}, ${55}, ${211},等等。 ### 确定参数个数 另外 shell 还提供了一个名为 $#,可以得到命令行参数个数的变量: ~~~ #!/bin/bash # posit-param: script to view command line parameters echo " Number of arguments: $# \$0 = $0 \$1 = $1 \$2 = $2 \$3 = $3 \$4 = $4 \$5 = $5 \$6 = $6 \$7 = $7 \$8 = $8 \$9 = $9 " ~~~ 结果是: ~~~ [me@linuxbox ~]$ posit-param a b c d Number of arguments: 4 $0 = /home/me/bin/posit-param $1 = a $2 = b $3 = c $4 = d $5 = $6 = $7 = $8 = $9 = ~~~ ### shift - 访问多个参数的利器 但是如果我们给一个程序添加大量的命令行参数,会怎么样呢? 正如下面的例子: ~~~ [me@linuxbox ~]$ posit-param * Number of arguments: 82 $0 = /home/me/bin/posit-param $1 = addresses.ldif $2 = bin $3 = bookmarks.html $4 = debian-500-i386-netinst.iso $5 = debian-500-i386-netinst.jigdo $6 = debian-500-i386-netinst.template $7 = debian-cd_info.tar.gz $8 = Desktop $9 = dirlist-bin.txt ~~~ 在这个例子运行的环境下,通配符 * 展开成82个参数。我们如何处理那么多的参数? 为此,shell 提供了一种方法,尽管笨拙,但可以解决这个问题。执行一次 shift 命令, 就会导致所有的位置参数 “向下移动一个位置”。事实上,用 shift 命令也可以 处理只有一个参数的情况(除了其值永远不会改变的变量 $0): ~~~ #!/bin/bash # posit-param2: script to display all arguments count=1 while [[ $# -gt 0 ]]; do echo "Argument $count = $1" count=$((count + 1)) shift done ~~~ 每次 shift 命令执行的时候,变量 $2 的值会移动到变量 $1 中,变量 $3 的值会移动到变量 $2 中,依次类推。 变量 $# 的值也会相应的减1。 在该 posit-param2 程序中,我们编写了一个计算剩余参数数量,只要参数个数不为零就会继续执行的 while 循环。 我们显示当前的位置参数,每次循环迭代变量 count 的值都会加1,用来计数处理的参数数量, 最后,执行 shift 命令加载 $1,其值为下一个位置参数的值。这里是程序运行后的输出结果: ~~~ [me@linuxbox ~]$ posit-param2 a b c d Argument 1 = a Argument 2 = b Argument 3 = c Argument 4 = d ~~~ ### 简单应用 即使没有 shift 命令,也可以用位置参数编写一个有用的应用。举例说明,这里是一个简单的输出文件信息的程序: ~~~ #!/bin/bash # file_info: simple file information program PROGNAME=$(basename $0) if [[ -e $1 ]]; then echo -e "\nFile Type:" file $1 echo -e "\nFile Status:" stat $1 else echo "$PROGNAME: usage: $PROGNAME file" >&2 exit 1 fi ~~~ 这个程序显示一个具体文件的文件类型(由 file 命令确定)和文件状态(来自 stat 命令)。该程序一个有意思 的特点是 PROGNAME 变量。它的值就是 basename $0 命令的执行结果。这个 basename 命令清除 一个路径名的开头部分,只留下一个文件的基本名称。在我们的程序中,basename 命令清除了包含在 $0 位置参数 中的路径名的开头部分,$0 中包含着我们示例程序的完整路径名。当构建提示信息正如程序结尾的使用信息的时候, basename $0 的执行结果就很有用处。按照这种方式编码,可以重命名该脚本,且程序信息会自动调整为 包含相应的程序名称。 ### Shell 函数中使用位置参数 正如位置参数被用来给 shell 脚本传递参数一样,它们也能够被用来给 shell 函数传递参数。为了说明这一点, 我们将把 file_info 脚本转变成一个 shell 函数: ~~~ file_info () { # file_info: function to display file information if [[ -e $1 ]]; then echo -e "\nFile Type:" file $1 echo -e "\nFile Status:" stat $1 else echo "$FUNCNAME: usage: $FUNCNAME file" >&2 return 1 fi } ~~~ 现在,如果一个包含 shell 函数 file_info 的脚本调用该函数,且带有一个文件名参数,那这个参数会传递给 file_info 函数。 通过此功能,我们可以写出许多有用的 shell 函数,这些函数不仅能在脚本中使用,也可以用在 .bashrc 文件中。 注意那个 PROGNAME 变量已经改成 shell 变量 FUNCNAME 了。shell 会自动更新 FUNCNAME 变量,以便 跟踪当前执行的 shell 函数。注意位置参数 $0 总是包含命令行中第一项的完整路径名(例如,该程序的名字), 但不会包含这个我们可能期望的 shell 函数的名字。 ## 处理集体位置参数 有时候把所有的位置参数作为一个集体来管理是很有用的。例如,我们可能想为另一个程序编写一个 “包裹程序”。 这意味着我们会创建一个脚本或 shell 函数,来简化另一个程序的执行。包裹程序提供了一个神秘的命令行选项 列表,然后把这个参数列表传递给下一级的程序。 为此 shell 提供了两种特殊的参数。他们二者都能扩展成完整的位置参数列表,但以相当微妙的方式略有不同。它们是: 表 32-1: * 和 @ 特殊参数 | 参数 | 描述 | | $* | 展开成一个从1开始的位置参数列表。当它被用双引号引起来的时候,展开成一个由双引号引起来 的字符串,包含了所有的位置参数,每个位置参数由 shell 变量 IFS 的第一个字符(默认为一个空格)分隔开。 | | $@ | 展开成一个从1开始的位置参数列表。当它被用双引号引起来的时候, 它把每一个位置参数展开成一个由双引号引起来的分开的字符串。 | 下面这个脚本用程序中展示了这些特殊参数: ~~~ #!/bin/bash # posit-params3 : script to demonstrate $* and $@ print_params () { echo "\$1 = $1" echo "\$2 = $2" echo "\$3 = $3" echo "\$4 = $4" } pass_params () { echo -e "\n" '$* :'; print_params $* echo -e "\n" '"$*" :'; print_params "$*" echo -e "\n" '$@ :'; print_params $@ echo -e "\n" '"$@" :'; print_params "$@" } pass_params "word" "words with spaces" ~~~ 在这个相当复杂的程序中,我们创建了两个参数: “word” 和 “words with spaces”,然后把它们 传递给 pass_params 函数。这个函数,依次,再把两个参数传递给 print_params 函数, 使用了特殊参数 $* 和 $@ 提供的四种可用方法。脚本运行后,揭示了这两个特殊参数存在的差异: ~~~ [me@linuxbox ~]$ posit-param3 $* : $1 = word $2 = words $3 = with $4 = spaces "$*" : $1 = word words with spaces $2 = $3 = $4 = $@ : $1 = word $2 = words $3 = with $4 = spaces "$@" : $1 = word $2 = words with spaces $3 = $4 = ~~~ 通过我们的参数,$* 和 $@ 两个都产生了一个有四个词的结果: ~~~ word words with spaces "$*" produces a one word result: "word words with spaces" "$@" produces a two word result: "word" "words with spaces" ~~~ 这个结果符合我们实际的期望。我们从中得到的教训是尽管 shell 提供了四种不同的得到位置参数列表的方法, 但到目前为止, “$@” 在大多数情况下是最有用的方法,因为它保留了每一个位置参数的完整性。 ## 一个更复杂的应用 经过长时间的间断,我们将恢复程序 sys_info_page 的工作。我们下一步要给程序添加如下几个命令行选项: * **输出文件**。 我们将添加一个选项,以便指定一个文件名,来包含程序的输出结果。 选项格式要么是 -f file,要么是 --file file * **交互模式**。这个选项将提示用户输入一个输出文件名,然后判断是否指定的文件已经存在了。如果文件存在, 在覆盖这个存在的文件之前会提示用户。这个选项可以通过 -i 或者 --interactive 来指定。 * **帮助**。指定 -h 选项 或者是 --help 选项,可导致程序输出提示性的使用信息。 这里是处理命令行选项所需的代码: ~~~ usage () { echo "$PROGNAME: usage: $PROGNAME [-f file | -i]" return } # process command line options interactive= filename= while [[ -n $1 ]]; do case $1 in -f | --file) shift filename=$1 ;; -i | --interactive) interactive=1 ;; -h | --help) usage exit ;; *) usage >&2 exit 1 ;; esac shift done ~~~ 首先,我们添加了一个叫做 usage 的 shell 函数,以便显示帮助信息,当启用帮助选项或敲写了一个未知选项的时候。 下一步,我们开始处理循环。当位置参数 $1 不为空的时候,这个循环会持续运行。在循环的底部,有一个 shift 命令, 用来提升位置参数,以便确保该循环最终会终止。在循环体内,我们使用了一个 case 语句来检查当前位置参数的值, 看看它是否匹配某个支持的选项。若找到了匹配项,就会执行与之对应的代码。若没有,就会打印出程序使用信息, 该脚本终止且执行错误。 处理 -f 参数的方式很有意思。当监测到 -f 参数的时候,会执行一次 shift 命令,从而提升位置参数 $1 为 伴随着 -f 选项的 filename 参数。 我们下一步添加代码来实现交互模式: ~~~ # interactive mode if [[ -n $interactive ]]; then while true; do read -p "Enter name of output file: " filename if [[ -e $filename ]]; then read -p "'$filename' exists. Overwrite? [y/n/q] > " case $REPLY in Y|y) break ;; Q|q) echo "Program terminated." exit ;; *) continue ;; esac elif [[ -z $filename ]]; then continue else break fi done fi ~~~ 若 interactive 变量不为空,就会启动一个无休止的循环,该循环包含文件名提示和随后存在的文件处理代码。 如果所需要的输出文件已经存在,则提示用户覆盖,选择另一个文件名,或者退出程序。如果用户选择覆盖一个 已经存在的文件,则会执行 break 命令终止循环。注意 case 语句是怎样只检测用户选择了覆盖还是退出选项。 其它任何选择都会导致循环继续并提示用户再次选择。 为了实现这个输出文件名的功能,首先我们必须把现有的这个写页面(page-writing)的代码转变成一个 shell 函数, 一会儿就会明白这样做的原因: ~~~ write_html_page () { cat <<- _EOF_ $TITLE

$TITLE

$TIMESTAMP

$(report_uptime) $(report_disk_space) $(report_home_space) _EOF_ return } # output html page if [[ -n $filename ]]; then if touch $filename && [[ -f $filename ]]; then write_html_page > $filename else echo "$PROGNAME: Cannot write file '$filename'" >&2 exit 1 fi else write_html_page fi ~~~ 解决 -f 选项逻辑的代码出现在以上程序片段的末尾。在这段代码中,我们测试一个文件名是否存在,若文件名存在, 则执行另一个测试看看该文件是不是可写文件。为此,会运行 touch 命令,紧随其后执行一个测试,来决定 touch 命令 创建的文件是否是个普通文件。这两个测试考虑到了输入是无效路径名(touch 命令执行失败),和一个普通文件已经存在的情况。 正如我们所看到的,程序调用 write_html_page 函数来生成实际的网页。函数输出要么直接定向到 标准输出(若 filename 变量为空的话)要么重定向到具体的文件中。 ## 总结 伴随着位置参数的加入,现在我们能编写相当具有功能性的脚本。例如,重复性的任务,位置参数使得编写 非常有用的,可以放置在一个用户的 .bashrc 文件中的 shell 函数成为可能。 我们的 sys_info_page 程序日渐精进。这里是一个完整的程序清单,最新的更改用高亮显示: ~~~ #!/bin/bash # sys_info_page: program to output a system information page PROGNAME=$(basename $0) TITLE="System Information Report For $HOSTNAME" CURRENT_TIME=$(date +"%x %r %Z") TIMESTAMP="Generated $CURRENT_TIME, by $USER" report_uptime () { cat <<- _EOF_

System Uptime

$(uptime)
_EOF_ return } report_disk_space () { cat <<- _EOF_

Disk Space Utilization

$(df -h)
_EOF_ return } report_home_space () { if [[ $(id -u) -eq 0 ]]; then cat <<- _EOF_

Home Space Utilization (All Users)

$(du -sh /home/*)
_EOF_ else cat <<- _EOF_

Home Space Utilization ($USER)

$(du -sh $HOME)
_EOF_ fi return } usage () { echo "$PROGNAME: usage: $PROGNAME [-f file | -i]" return } write_html_page () { cat <<- _EOF_ $TITLE

$TITLE

$TIMESTAMP

$(report_uptime) $(report_disk_space) $(report_home_space) _EOF_ return } # process command line options interactive= filename= while [[ -n $1 ]]; do case $1 in -f | --file) shift filename=$1 ;; -i | --interactive) interactive=1 ;; -h | --help) usage exit ;; *) usage >&2 exit 1 ;; esac shift done # interactive mode if [[ -n $interactive ]]; then while true; do read -p "Enter name of output file: " filename if [[ -e $filename ]]; then read -p "'$filename' exists. Overwrite? [y/n/q] > " case $REPLY in Y|y) break ;; Q|q) echo "Program terminated." exit ;; *) continue ;; esac fi done fi # output html page if [[ -n $filename ]]; then if touch $filename && [[ -f $filename ]]; then write_html_page > $filename else echo "$PROGNAME: Cannot write file '$filename'" >&2 exit 1 fi else write_html_page fi ~~~ 我们还没有完成。仍然还有许多事情我们可以做,可以改进。 ## 拓展阅读 * Bash Hackers Wiki 上有一篇不错的关于位置参数的文章: [http://wiki.bash-hackers.org/scripting/posparams](http://wiki.bash-hackers.org/scripting/posparams) * Bash 的参考手册有一篇关于特殊参数的文章,包括 $* 和 $@: [http://www.gnu.org/software/bash/manual/bashref.html#Special-Parameters](http://www.gnu.org/software/bash/manual/bashref.html#Special-Parameters) * 除了本章讨论的技术之外,bash 还包含一个叫做 getopts 的内部命令,此命令也可以用来处理命令行参数。 bash 参考页面的 SHELL BUILTIN COMMANDS 一节介绍了这个命令,Bash Hackers Wiki 上也有对它的描述: [http://wiki.bash-hackers.org/howto/getopts_tutorial](http://wiki.bash-hackers.org/howto/getopts_tutorial)
';

第三十二章:流程控制 case分支

最后更新于:2022-04-02 01:46:20

在这一章中,我们将继续看一下程序的流程控制。在第28章中,我们构建了一些简单的菜单并创建了用来 应对各种用户选择的程序逻辑。为此,我们使用了一系列的 if 命令来识别哪一个可能的选项已经被选中。 这种类型的构造经常出现在程序中,出现频率如此之多,以至于许多编程语言(包括 shell) 专门为多选决策提供了一种流程控制机制。 ## case Bash 的多选复合命令称为 case。它的语法规则如下所示: ~~~ case word in [pattern [| pattern]...) commands ;;]... esac ~~~ 如果我们看一下第28章中的读菜单程序,我们就知道了用来应对一个用户选项的逻辑流程: ~~~ #!/bin/bash # read-menu: a menu driven system information program clear echo " Please Select: 1\. Display System Information 2\. Display Disk Space 3\. Display Home Space Utilization 0\. Quit " read -p "Enter selection [0-3] > " if [[ $REPLY =~ ^[0-3]$ ]]; then if [[ $REPLY == 0 ]]; then echo "Program terminated." exit fi if [[ $REPLY == 1 ]]; then echo "Hostname: $HOSTNAME" uptime exit fi if [[ $REPLY == 2 ]]; then df -h exit fi if [[ $REPLY == 3 ]]; then if [[ $(id -u) -eq 0 ]]; then echo "Home Space Utilization (All Users)" du -sh /home/* else echo "Home Space Utilization ($USER)" du -sh $HOME fi exit fi else echo "Invalid entry." >&2 exit 1 fi ~~~ 使用 case 语句,我们可以用更简单的代码替换这种逻辑: ~~~ #!/bin/bash # case-menu: a menu driven system information program clear echo " Please Select: 1\. Display System Information 2\. Display Disk Space 3\. Display Home Space Utilization 0\. Quit " read -p "Enter selection [0-3] > " case $REPLY in 0) echo "Program terminated." exit ;; 1) echo "Hostname: $HOSTNAME" uptime ;; 2) df -h ;; 3) if [[ $(id -u) -eq 0 ]]; then echo "Home Space Utilization (All Users)" du -sh /home/* else echo "Home Space Utilization ($USER)" du -sh $HOME fi ;; *) echo "Invalid entry" >&2 exit 1 ;; esac ~~~ case 命令检查一个变量值,在我们这个例子中,就是 REPLY 变量的变量值,然后试图去匹配其中一个具体的模式。 当与之相匹配的模式找到之后,就会执行与该模式相关联的命令。若找到一个模式之后,就不会再继续寻找。 ## 模式 这里 case 语句使用的模式和路径展开中使用的那些是一样的。模式以一个 “)” 为终止符。这里是一些有效的模式。 表32-1: case 模式实例 | 模式 | 描述 | |------|-------| | a) | 若单词为 “a”,则匹配 | | [[:alpha:]]) | 若单词是一个字母字符,则匹配 | | ???) | 若单词只有3个字符,则匹配 | | *.txt) | 若单词以 “.txt” 字符结尾,则匹配 | | *) | 匹配任意单词。把这个模式做为 case 命令的最后一个模式,是一个很好的做法, 可以捕捉到任意一个与先前模式不匹配的数值;也就是说,捕捉到任何可能的无效值。 | 这里是一个模式使用实例: ~~~ #!/bin/bash read -p "enter word > " case $REPLY in [[:alpha:]]) echo "is a single alphabetic character." ;; [ABC][0-9]) echo "is A, B, or C followed by a digit." ;; ???) echo "is three characters long." ;; *.txt) echo "is a word ending in '.txt'" ;; *) echo "is something else." ;; esac ~~~ 还可以使用竖线字符作为分隔符,把多个模式结合起来。这就创建了一个 “或” 条件模式。这对于处理诸如大小写字符很有用处。例如: ~~~ #!/bin/bash # case-menu: a menu driven system information program clear echo " Please Select: A. Display System Information B. Display Disk Space C. Display Home Space Utilization Q. Quit " read -p "Enter selection [A, B, C or Q] > " case $REPLY in q|Q) echo "Program terminated." exit ;; a|A) echo "Hostname: $HOSTNAME" uptime ;; b|B) df -h ;; c|C) if [[ $(id -u) -eq 0 ]]; then echo "Home Space Utilization (All Users)" du -sh /home/* else echo "Home Space Utilization ($USER)" du -sh $HOME fi ;; *) echo "Invalid entry" >&2 exit 1 ;; esac ~~~ 这里,我们更改了 case-menu 程序的代码,用字母来代替数字做为菜单选项。注意新模式如何使得大小写字母都是有效的输入选项。 ## 执行多个动作 早于版本号4.0的 bash,case 语法只允许执行与一个成功匹配的模式相关联的动作。 匹配成功之后,命令将会终止。这里我们看一个测试一个字符的脚本: ~~~ #!/bin/bash # case4-1: test a character read -n 1 -p "Type a character > " echo case $REPLY in [[:upper:]]) echo "'$REPLY' is upper case." ;; [[:lower:]]) echo "'$REPLY' is lower case." ;; [[:alpha:]]) echo "'$REPLY' is alphabetic." ;; [[:digit:]]) echo "'$REPLY' is a digit." ;; [[:graph:]]) echo "'$REPLY' is a visible character." ;; [[:punct:]]) echo "'$REPLY' is a punctuation symbol." ;; [[:space:]]) echo "'$REPLY' is a whitespace character." ;; [[:xdigit:]]) echo "'$REPLY' is a hexadecimal digit." ;; esac ~~~ 运行这个脚本,输出这些内容: ~~~ [me@linuxbox ~]$ case4-1 Type a character > a 'a' is lower case. ~~~ 大多数情况下这个脚本工作是正常的,但若输入的字符不止与一个 POSIX 字符集匹配的话,这时脚本就会出错。 例如,字符 “a” 既是小写字母,也是一个十六进制的数字。早于4.0的 bash,对于 case 语法绝不能匹配 多个测试条件。现在的 bash 版本,添加 “;;&” 表达式来终止每个行动,所以现在我们可以做到这一点: ~~~ #!/bin/bash # case4-2: test a character read -n 1 -p "Type a character > " echo case $REPLY in [[:upper:]]) echo "'$REPLY' is upper case." ;;& [[:lower:]]) echo "'$REPLY' is lower case." ;;& [[:alpha:]]) echo "'$REPLY' is alphabetic." ;;& [[:digit:]]) echo "'$REPLY' is a digit." ;;& [[:graph:]]) echo "'$REPLY' is a visible character." ;;& [[:punct:]]) echo "'$REPLY' is a punctuation symbol." ;;& [[:space:]]) echo "'$REPLY' is a whitespace character." ;;& [[:xdigit:]]) echo "'$REPLY' is a hexadecimal digit." ;;& esac ~~~ 当我们运行这个脚本的时候,我们得到这些: ~~~ [me@linuxbox ~]$ case4-2 Type a character > a 'a' is lower case. 'a' is alphabetic. 'a' is a visible character. 'a' is a hexadecimal digit. ~~~ 添加的 “;;&” 的语法允许 case 语句继续执行下一条测试,而不是简单地终止运行。 ## 总结 case 命令是我们编程技巧口袋中的一个便捷工具。在下一章中我们将看到, 对于处理某些类型的问题来说,case 命令是一个完美的工具。 ## 拓展阅读 * Bash 参考手册的条件构造一节详尽的介绍了 case 命令: [http://tiswww.case.edu/php/chet/bash/bashref.html#SEC21](http://tiswww.case.edu/php/chet/bash/bashref.html#SEC21) * 高级 Bash 脚本指南提供了更深一层的 case 应用实例: [http://tldp.org/LDP/abs/html/testbranch.html](http://tldp.org/LDP/abs/html/testbranch.html)
';

第三十一章:疑难排解

最后更新于:2022-04-02 01:46:18

随着我们的脚本变得越来越复杂,当脚本运行错误,执行结果出人意料的时候, 我们就应该查看一下原因了。 在这一章中,我们将会看一些脚本中出现地常见错误类型,同时还会介绍几个可以跟踪和消除问题的有用技巧。 ## 语法错误 一个普通的错误类型是语法。语法错误涉及到一些 shell 语法元素的拼写错误。大多数情况下,这类错误 会导致 shell 拒绝执行此脚本。 在以下讨论中,我们将使用下面这个脚本,来说明常见的错误类型: ~~~ #!/bin/bash # trouble: script to demonstrate common errors number=1 if [ $number = 1 ]; then echo "Number is equal to 1." else echo "Number is not equal to 1." fi ~~~ 参看脚本内容,我们知道这个脚本执行成功了: ~~~ [me@linuxbox ~]$ trouble Number is equal to 1. ~~~ ### 丢失引号 如果我们编辑我们的脚本,并从跟随第一个 echo 命令的参数中,删除其末尾的双引号: ~~~ #!/bin/bash # trouble: script to demonstrate common errors number=1 if [ $number = 1 ]; then echo "Number is equal to 1. else echo "Number is not equal to 1." fi ~~~ 观察发生了什么: ~~~ [me@linuxbox ~]$ trouble /home/me/bin/trouble: line 10: unexpected EOF while looking for matching `"' /home/me/bin/trouble: line 13: syntax error: unexpected end of file ~~~ 这个脚本产生了两个错误。有趣地是,所报告的行号不是引号被删除的地方,而是程序中后面的文本行。 我们能知道为什么,如果我们跟随丢失引号文本行之后的程序。bash 会继续寻找右引号,直到它找到一个, 其就是这个紧随第二个 echo 命令之后的引号。找到这个引号之后,bash 变得很困惑,并且 if 命令的语法 被破坏了,因为现在这个 fi 语句在一个用引号引起来的(但是开放的)字符串里面。 在冗长的脚本中,此类错误很难找到。使用带有语法高亮的编辑器将会帮助查找错误。如果安装了 vim 的完整版, 通过输入下面的命令,可以使语法高亮生效: ~~~ :syntax on ~~~ ### 丢失或意外的标记 另一个常见错误是忘记补全一个复合命令,比如说 if 或者是 while。让我们看一下,如果 我们删除 if 命令中测试之后的分号,会出现什么情况: ~~~ #!/bin/bash # trouble: script to demonstrate common errors number=1 if [ $number = 1 ] then echo "Number is equal to 1." else echo "Number is not equal to 1." fi ~~~ 结果是这样的: ~~~ [me@linuxbox ~]$ trouble /home/me/bin/trouble: line 9: syntax error near unexpected token `else' /home/me/bin/trouble: line 9: `else' ~~~ 再次,错误信息指向一个错误,其出现的位置靠后于实际问题所在的文本行。所发生的事情真是相当有意思。我们记得, if 能够接受一系列命令,并且会计算列表中最后一个命令的退出代码。在我们的程序中,我们打算这个列表由 单个命令组成,即 [,测试的同义词。这个 [ 命令把它后面的东西看作是一个参数列表。在我们这种情况下, 有三个参数: $number,=,和 ]。由于删除了分号,单词 then 被添加到参数列表中,从语法上讲, 这是合法的。随后的 echo 命令也是合法的。它被解释为命令列表中的另一个命令,if 将会计算命令的 退出代码。接下来遇到单词 else,但是它出局了,因为 shell 把它认定为一个 保留字(对于 shell 有特殊含义的单词),而不是一个命令名,因此报告错误信息。 ### 预料不到的展开 可能有这样的错误,它们仅会间歇性地出现在一个脚本中。有时候这个脚本执行正常,其它时间会失败, 这是因为展开结果造成的。如果我们归还我们丢掉的分号,并把 number 的数值更改为一个空变量,我们 可以示范一下: ~~~ #!/bin/bash # trouble: script to demonstrate common errors number= if [ $number = 1 ]; then echo "Number is equal to 1." else echo "Number is not equal to 1." fi ~~~ 运行这个做了修改的脚本,得到以下输出: ~~~ [me@linuxbox ~]$ trouble /home/me/bin/trouble: line 7: [: =: unary operator expected Number is not equal to 1. ~~~ 我们得到一个相当神秘的错误信息,其后是第二个 echo 命令的输出结果。这问题是由于 test 命令中 number 变量的展开结果造成的。当此命令: ~~~ [ $number = 1 ] ~~~ 经过展开之后,number 变为空值,结果就是这样: ~~~ [ = 1 ] ~~~ 这是无效的,所以就产生了错误。这个 = 操作符是一个二元操作符(它要求每边都有一个数值),但是第一个数值是缺失的, 这样 test 命令就期望用一个一元操作符(比如 -z)来代替。进一步说,因为 test 命令运行失败了(由于错误), 这个 if 命令接收到一个非零退出代码,因此执行第二个 echo 命令。 通过为 test 命令中的第一个参数添加双引号,可以更正这个问题: ~~~ [ "$number" = 1 ] ~~~ 然后当展开操作发生地时候,执行结果将会是这样: ~~~ [ "" = 1 ] ~~~ 其得到了正确的参数个数。除了代表空字符串之外,引号应该被用于这样的场合,一个要展开 成多单词字符串的数值,及其包含嵌入式空格的文件名。 ## 逻辑错误 不同于语法错误,逻辑错误不会阻止脚本执行。虽然脚本会正常运行,但是它不会产生期望的结果, 归咎于脚本的逻辑问题。虽然有不计其数的可能的逻辑错误,但下面是一些在脚本中找到的最常见的 逻辑错误类型: 1. 不正确的条件表达式。很容易编写一个错误的 if/then/else 语句,并且执行错误的逻辑。 有时候逻辑会被颠倒,或者是逻辑结构不完整。 2. “超出一个值”错误。当编写带有计数器的循环语句的时候,为了计数在恰当的点结束,循环语句 可能要求从 0 开始计数,而不是从 1 开始,这有可能会被忽视。这些类型的错误要不导致循环计数太多,而“超出范围”, 要不就是过早的结束了一次迭代,从而错过了最后一次迭代循环。 3. 意外情况。大多数逻辑错误来自于程序碰到了程序员没有预见到的数据或者情况。这也 可以包括出乎意料的展开,比如说一个包含嵌入式空格的文件名展开成多个命令参数而不是单个的文件名。 ### 防错编程 当编程的时候,验证假设非常重要。这意味着要仔细得计算脚本所使用的程序和命令的退出状态。 这里有个实例,基于一个真实的故事。为了在一台重要的服务器中执行维护任务,一位不幸的系统管理员写了一个脚本。 这个脚本包含下面两行代码: ~~~ cd $dir_name rm * ~~~ 从本质上来说,这两行代码没有任何问题,只要是变量 dir_name 中存储的目录名字存在就可以。但是如果不是这样会发生什么事情呢?在那种情况下,cd 命令会运行失败, 脚本会继续执行下一行代码,将会删除当前工作目录中的所有文件。完成不是期望的结果! 由于这种设计策略,这个倒霉的管理员销毁了服务器中的一个重要部分。 让我们看一些能够提高这个设计的方法。首先,在 cd 命令执行成功之后,再运行 rm 命令,可能是明智的选择。 ~~~ cd $dir_name && rm * ~~~ 这样,如果 cd 命令运行失败后,rm 命令将不会执行。这样比较好,但是仍然有可能未设置变量 dir_name 或其变量值为空,从而导致删除了用户家目录下面的所有文件。这个问题也能够避免,通过检验变量 dir_name 中包含的目录名是否真正地存在: ~~~ [[ -d $dir_name ]] && cd $dir_name && rm * ~~~ 通常,当某种情况(比如上述问题)发生的时候,最好是终止脚本执行,并对这种情况提示错误信息: ~~~ if [[ -d $dir_name ]]; then if cd $dir_name; then rm * else echo "cannot cd to '$dir_name'" >&2 exit 1 fi else echo "no such directory: '$dir_name'" >&2 exit 1 fi ~~~ 这里,我们检验了两种情况,一个名字,看看它是否为一个真正存在的目录,另一个是 cd 命令是否执行成功。 如果任一种情况失败,就会发送一个错误说明信息到标准错误,然后脚本终止执行,并用退出状态 1 表明脚本执行失败。 ### 验证输入 一个良好的编程习惯是如果一个程序可以接受输入数据,那么这个程序必须能够应对它所接受的任意数据。这 通常意味着必须非常仔细地筛选输入数据,以确保只有有效的输入数据才能被程序用来做进一步地处理。在前面章节 中我们学习 read 命令的时候,我们遇到过一个这样的例子。一个脚本中包含了下面一条测试语句, 用来验证一个选择菜单: ~~~ [[ $REPLY =~ ^[0-3]$ ]] ~~~ 这条测试语句非常明确。只有当用户输入是一个位于 0 到 3 范围内(包括 0 和 3)的数字的时候, 这条语句才返回一个 0 退出状态。而其它任何输入概不接受。有时候编写这类测试条件非常具有挑战性, 但是为了能产出一个高质量的脚本,付出还是必要的。 > 设计是时间的函数 > > 当我还是一名大学生,在学习工业设计的时候,一位明智的教授说过一个项目的设计程度是由 给定设计师的时间量来决定的。如果给你五分钟来设计一款能够 “杀死苍蝇” 的产品,你会设计出一个苍蝇拍。如果给你五个月的时间,你可能会制作出激光制导的 “反苍蝇系统”。 > > 同样的原理适用于编程。有时候一个 “快速但粗糙” 的脚本就可以解决问题, 但这个脚本只能被其作者使用一次。这类脚本很常见,为了节省气力也应该被快速地开发出来。 所以这些脚本不需要太多的注释和防错检查。相反,如果一个脚本打算用于生产使用,也就是说, 某个重要任务或者多个客户会不断地用到它,此时这个脚本就需要非常谨慎小心地开发了。 ## 测试 在各类软件开发中(包括脚本),测试是一个重要的环节。在开源世界中有一句谚语,“早发布,常发布”,这句谚语就反映出这个事实(测试的重要性)。 通过提早和经常发布,软件能够得到更多曝光去使用和测试。经验表明如果在开发周期的早期发现 bug,那么这些 bug 就越容易定位,而且越能低成本 的修复。 在之前的讨论中,我们知道了如何使用 stubs 来验证程序流程。在脚本开发的最初阶段,它们是一项有价值的技术 来检测我们的工作进度。 让我们看一下上面的文件删除问题,为了轻松测试,看看如何修改这些代码。测试原本那个代码片段将是危险的,因为它的目的是要删除文件, 但是我们可以修改代码,让测试安全: ~~~ if [[ -d $dir_name ]]; then if cd $dir_name; then echo rm * # TESTING else echo "cannot cd to '$dir_name'" >&2 exit 1 fi else echo "no such directory: '$dir_name'" >&2 exit 1 fi exit # TESTING ~~~ 因为在满足出错条件的情况下代码可以打印出有用信息,所以我们没有必要再添加任何额外信息了。 最重要的改动是仅在 rm 命令之前放置了一个 echo 命令, 为的是把 rm 命令及其展开的参数列表打印出来,而不是执行实际的 rm 命令语句。这个改动可以安全的执行代码。 在这段代码的末尾,我们放置了一个 exit 命令来结束测试,从而防止执行脚本其它部分的代码。 这个需求会因脚本的设计不同而变化。 我们也在代码中添加了一些注释,用来标记与测试相关的改动。当测试完成之后,这些注释可以帮助我们找到并删除所有的更改。 ### 测试案例 为了执行有用的测试,开发和使用好的测试案例是很重要的。这个要求可以通过谨慎地选择输入数据或者运行边缘案例和极端案例来完成。 在我们的代码片段中(是非常简单的代码),我们想要知道在下面的三种具体情况下这段代码是怎样执行的: 1. dir_name 包含一个已经存在的目录的名字 2. dir_name 包含一个不存在的目录的名字 3. dir_name 为空 通过执行以上每一个测试条件,就达到了一个良好的测试覆盖率。 正如设计,测试也是一个时间的函数。不是每一个脚本功能都需要做大量的测试。问题关键是确定什么功能是最重要的。因为 测试若发生故障会存在如此潜在的破坏性,所以我们的代码片在设计和测试段期间都应值得仔细推敲。 ## 调试 如果测试暴露了脚本中的一个问题,那下一步就是调试了。“一个问题”通常意味着在某种情况下,这个脚本的执行 结果不是程序员所期望的结果。若是这种情况,我们需要仔细确认这个脚本实际到底要完成什么任务,和为什么要这样做。 有时候查找 bug 要牵涉到许多监测工作。一个设计良好的脚本会对查找错误有帮助。设计良好的脚本应该具备防卫能力, 能够监测异常条件,并能为用户提供有用的反馈信息。 然而有时候,出现的问题相当稀奇,出人意料,这时候就需要更多的调试技巧了。 ### 找到问题区域 在一些脚本中,尤其是一些代码比较长的脚本,有时候隔离脚本中与出现的问题相关的代码区域对查找问题很有效。 隔离的代码区域并不总是真正的错误所在,但是隔离往往可以深入了解实际的错误原因。可以用来隔离代码的一项 技巧是“添加注释”。例如,我们的文件删除代码可以修改成这样,从而决定注释掉的这部分代码是否导致了一个错误: ~~~ if [[ -d $dir_name ]]; then if cd $dir_name; then rm * else echo "cannot cd to '$dir_name'" >&2 exit 1 fi # else # echo "no such directory: '$dir_name'" >&2 # exit 1 fi ~~~ 通过给脚本中的一个逻辑区块内的每条语句的开头添加一个注释符号,我们就阻止了这部分代码的执行。然后可以再次执行测试, 来看看清除的代码是否影响了错误的行为。 ### 追踪 在一个脚本中,错误往往是由意想不到的逻辑流导致的。也就是说,脚本中的一部分代码或者从未执行,或是以错误的顺序, 或在错误的时间给执行了。为了查看真实的程序流,我们使用一项叫做追踪(tracing)的技术。 一种追踪方法涉及到在脚本中添加可以显示程序执行位置的提示性信息。我们可以添加提示信息到我们的代码片段中: ~~~ echo "preparing to delete files" >&2 if [[ -d $dir_name ]]; then if cd $dir_name; then echo "deleting files" >&2 rm * else echo "cannot cd to '$dir_name'" >&2 exit 1 fi else echo "no such directory: '$dir_name'" >&2 exit 1 fi echo "file deletion complete" >&2 ~~~ 我们把提示信息输出到标准错误输出,让其从标准输出中分离出来。我们也没有缩进包含提示信息的语句,这样 想要删除它们的时候,能比较容易找到它们。 当这个脚本执行的时候,就可能看到文件删除操作已经完成了: ~~~ [me@linuxbox ~]$ deletion-script preparing to delete files deleting files file deletion complete [me@linuxbox ~]$ ~~~ bash 还提供了一种名为追踪的方法,这种方法可通过 -x 选项和 set 命令加上 -x 选项两种途径实现。 拿我们之前的 trouble 脚本为例,给该脚本的第一行语句添加 -x 选项,我们就能追踪整个脚本。 ~~~ #!/bin/bash -x # trouble: script to demonstrate common errors number=1 if [ $number = 1 ]; then echo "Number is equal to 1." else echo "Number is not equal to 1." fi ~~~ 当脚本执行后,输出结果看起来像这样: ~~~ [me@linuxbox ~]$ trouble + number=1 + '[' 1 = 1 ']' + echo 'Number is equal to 1.' Number is equal to 1. ~~~ 追踪生效后,我们看到脚本命令展开后才执行。行首的加号表明追踪的迹象,使其与常规输出结果区分开来。 加号是追踪输出的默认字符。它包含在 PS4(提示符4)shell 变量中。可以调整这个变量值让提示信息更有意义。 这里,我们修改该变量的内容,让其包含脚本中追踪执行到的当前行的行号。注意这里必须使用单引号是为了防止变量展开,直到 提示符真正使用的时候,就不需要了。 ~~~ [me@linuxbox ~]$ export PS4='$LINENO + ' [me@linuxbox ~]$ trouble 5 + number=1 7 + '[' 1 = 1 ']' 8 + echo 'Number is equal to 1.' Number is equal to 1. ~~~ 我们可以使用 set 命令加上 -x 选项,为脚本中的一块选择区域,而不是整个脚本启用追踪。 ~~~ #!/bin/bash # trouble: script to demonstrate common errors number=1 set -x # Turn on tracing if [ $number = 1 ]; then echo "Number is equal to 1." else echo "Number is not equal to 1." fi set +x # Turn off tracing ~~~ 我们使用 set 命令加上 -x 选项来启动追踪,+x 选项关闭追踪。这种技术可以用来检查一个有错误的脚本的多个部分。 ### 执行时检查数值 伴随着追踪,在脚本执行的时候显示变量的内容,以此知道脚本内部的工作状态,往往是很用的。 使用额外的 echo 语句通常会奏效。 ~~~ #!/bin/bash # trouble: script to demonstrate common errors number=1 echo "number=$number" # DEBUG set -x # Turn on tracing if [ $number = 1 ]; then echo "Number is equal to 1." else echo "Number is not equal to 1." fi set +x # Turn off tracing ~~~ 在这个简单的示例中,我们只是显示变量 number 的数值,并为其添加注释,随后利于其识别和清除。 当查看脚本中的循环和算术语句的时候,这种技术特别有用。 ## 总结 在这一章中,我们仅仅看了几个在脚本开发期间会出现的问题。当然,还有很多。这章中描述的技术对查找 大多数的常见错误是有效的。调试是一种艺术,可以通过开发经验,在知道如何避免错误(整个开发过程中不断测试) 以及在查找 bug(有效利用追踪)两方面都会得到提升。 ## 拓展阅读 * Wikipedia 上面有两篇关于语法和逻辑错误的短文: [http://en.wikipedia.org/wiki/Syntax_error](http://en.wikipedia.org/wiki/Syntax_error) [http://en.wikipedia.org/wiki/logic_error](http://en.wikipedia.org/wiki/logic_error) * 网上有很多关于技术层面的 bash 编程的资源: [http://mywiki.wooledge.org/BashPitfalls](http://mywiki.wooledge.org/BashPitfalls) [http://tldp.org/LDP/abs/html/gotchas.html](http://tldp.org/LDP/abs/html/gotchas.html) [http://www.gnu.org/software/bash/manual/html_node/Reserved-Word-Index.html](http://www.gnu.org/software/bash/manual/html_node/Reserved-Word-Index.html) * 想要学习从编写良好的 Unix 程序中得知的基本概念,可以参考 Eric Raymond 的《Unix 编程的艺术》这本 伟大的著作。书中的许多想法都能适用于 shell 脚本: [http://www.faqs.org/docs/artu/](http://www.faqs.org/docs/artu/) [http://www.faqs.org/docs/artu/ch01s06.html](http://www.faqs.org/docs/artu/ch01s06.html) * 对于真正的高强度的调试,参考这个 Bash Debugger: [http://bashdb.sourceforge.net/](http://bashdb.sourceforge.net/)
';

第三十章:流程控制 while/until 循环

最后更新于:2022-04-02 01:46:15

在前面的章节中,我们开发了菜单驱动程序,来产生各种各样的系统信息。虽然程序能够运行, 但它仍然存在重大的可用问题。它只能执行单一的选择,然后终止。更糟糕地是,如果做了一个 无效的选择,程序会以错误终止,而没有给用户提供再试一次的机会。如果我们能构建程序, 以致于程序能够重复显示菜单,而且能一次由一次的选择,直到用户选择退出程序,这样的程序会更好一些。 在这一章中,我们将看一个叫做循环的程序概念,其可用来使程序的某些部分重复。shell 为循环提供了三个复合命令。 本章我们将查看其中的两个命令,随后章节介绍第三个命令。 ## 循环 日常生活中充满了重复性的活动。每天去散步,遛狗,切胡萝卜,所有任务都要重复一系列的步骤。 让我们以切胡萝卜为例。如果我们用伪码表达这种活动,它可能看起来像这样: 1. 准备切菜板 2. 准备菜刀 3. 把胡萝卜放到切菜板上 4. 提起菜刀 5. 向前推进胡萝卜 6. 切胡萝卜 7. 如果切完整个胡萝卜,就退出,要不然回到第四步继续执行 从第四步到第七步形成一个循环。重复执行循环内的动作直到满足条件“切完整个胡萝卜”。 ### while bash 能够表达相似的想法。比方说我们想要按照顺序从1到5显示五个数字。可如下构造一个 bash 脚本: ~~~ #!/bin/bash # while-count: display a series of numbers count=1 while [ $count -le 5 ]; do echo $count count=$((count + 1)) done echo "Finished." ~~~ 当执行的时候,这个脚本显示如下信息: ~~~ [me@linuxbox ~]$ while-count 1 2 3 4 5 Finished. ~~~ while 命令的语法是: ~~~ while commands; do commands; done ~~~ 和 if 一样, while 计算一系列命令的退出状态。只要退出状态为零,它就执行循环内的命令。 在上面的脚本中,创建了变量 count ,并初始化为1。 while 命令将会计算 test 命令的退出状态。 只要 test 命令返回退出状态零,循环内的所有命令就会执行。每次循环结束之后,会重复执行 test 命令。 第六次循环之后, count 的数值增加到6, test 命令不再返回退出状态零,且循环终止。 程序继续执行循环之后的语句。 我们可以使用一个 while 循环,来提高前面章节的 read-menu 程序: ~~~ #!/bin/bash # while-menu: a menu driven system information program DELAY=3 # Number of seconds to display results while [[ $REPLY != 0 ]]; do clear cat <<- _EOF_ Please Select: 1\. Display System Information 2\. Display Disk Space 3\. Display Home Space Utilization 0\. Quit _EOF_ read -p "Enter selection [0-3] > " if [[ $REPLY =~ ^[0-3]$ ]]; then if [[ $REPLY == 1 ]]; then echo "Hostname: $HOSTNAME" uptime sleep $DELAY fi if [[ $REPLY == 2 ]]; then df -h sleep $DELAY fi if [[ $REPLY == 3 ]]; then if [[ $(id -u) -eq 0 ]]; then echo "Home Space Utilization (All Users)" du -sh /home/* else echo "Home Space Utilization ($USER)" du -sh $HOME fi sleep $DELAY fi else echo "Invalid entry." sleep $DELAY fi done echo "Program terminated." ~~~ 通过把菜单包含在 while 循环中,每次用户选择之后,我们能够让程序重复显示菜单。只要 REPLY 不 等于”0”,循环就会继续,菜单就能显示,从而用户有机会重新选择。每次动作完成之后,会执行一个 sleep 命令,所以在清空屏幕和重新显示菜单之前,程序将会停顿几秒钟,为的是能够看到选项输出结果。 一旦 REPLY 等于“0”,则表示选择了“退出”选项,循环就会终止,程序继续执行 done 语句之后的代码。 ## 跳出循环 bash 提供了两个内部命令,它们可以用来在循环内部控制程序流程。这个 break 命令立即终止一个循环, 且程序继续执行循环之后的语句。这个 continue 命令导致程序跳过循环中剩余的语句,且程序继续执行 下一次循环。这里我们看看采用了 break 和 continue 两个命令的 while-menu 程序版本: ~~~ #!/bin/bash # while-menu2: a menu driven system information program DELAY=3 # Number of seconds to display results while true; do clear cat <<- _EOF_ Please Select: 1\. Display System Information 2\. Display Disk Space 3\. Display Home Space Utilization 0\. Quit _EOF_ read -p "Enter selection [0-3] > " if [[ $REPLY =~ ^[0-3]$ ]]; then if [[ $REPLY == 1 ]]; then echo "Hostname: $HOSTNAME" uptime sleep $DELAY continue fi if [[ $REPLY == 2 ]]; then df -h sleep $DELAY continue fi if [[ $REPLY == 3 ]]; then if [[ $(id -u) -eq 0 ]]; then echo "Home Space Utilization (All Users)" du -sh /home/* else echo "Home Space Utilization ($USER)" du -sh $HOME fi sleep $DELAY continue fi if [[ $REPLY == 0 ]]; then break fi else echo "Invalid entry." sleep $DELAY fi done echo "Program terminated." ~~~ 在这个脚本版本中,我们设置了一个无限循环(就是自己永远不会终止的循环),通过使用 true 命令 为 while 提供一个退出状态。因为 true 的退出状态总是为零,所以循环永远不会终止。这是一个 令人惊讶的通用脚本编程技巧。因为循环自己永远不会结束,所以由程序员在恰当的时候提供某种方法来跳出循环。 此脚本,当选择”0”选项的时候,break 命令被用来退出循环。continue 命令被包含在其它选择动作的末尾, 为的是更加高效执行。通过使用 continue 命令,当一个选项确定后,程序会跳过不需要的代码。例如, 如果选择了选项”1”,则没有理由去测试其它选项。 ### until 这个 until 命令与 while 非常相似,除了当遇到一个非零退出状态的时候, while 退出循环, 而 until 不退出。一个 until 循环会继续执行直到它接受了一个退出状态零。在我们的 while-count 脚本中, 我们继续执行循环直到 count 变量的数值小于或等于5。我们可以得到相同的结果,通过在脚本中使用 until 命令: ~~~ #!/bin/bash # until-count: display a series of numbers count=1 until [ $count -gt 5 ]; do echo $count count=$((count + 1)) done echo "Finished." ~~~ 通过把 test 表达式更改为 $count -gt 5 , until 会在正确的时间终止循环。决定使用 while 循环 还是 until 循环,通常是选择一个 test 可以编写地很清楚的循环。 ## 使用循环读取文件 while 和 until 能够处理标准输入。这就可以使用 while 和 until 处理文件。在下面的例子中, 我们将显示在前面章节中使用的 distros.txt 文件的内容: ~~~ #!/bin/bash # while-read: read lines from a file while read distro version release; do printf "Distro: %s\tVersion: %s\tReleased: %s\n" \ $distro \ $version \ $release done < distros.txt ~~~ 为了重定向文件到循环中,我们把重定向操作符放置到 done 语句之后。循环将使用 read 从重定向文件中读取 字段。这个 read 命令读取每个文本行之后,将会退出,其退出状态为零,直到到达文件末尾。到时候,它的 退出状态为非零数值,因此终止循环。也有可能把标准输入管道到循环中。 ~~~ #!/bin/bash # while-read2: read lines from a file sort -k 1,1 -k 2n distros.txt | while read distro version release; do printf "Distro: %s\tVersion: %s\tReleased: %s\n" \ $distro \ $version \ $release done ~~~ 这里我们接受 sort 命令的标准输出,然后显示文本流。然而,因为管道将会在子 shell 中执行 循环,当循环终止的时候,循环中创建的任意变量或赋值的变量都会消失,记住这一点很重要。 ## 总结 通过引入循环,和我们之前遇到的分支,子例程和序列,我们已经介绍了程序流程控制的主要类型。 bash 还有一些锦囊妙计,但它们都是关于这些基本概念的完善。 ## 拓展阅读 * Linux 文档工程中的 Bash 初学者指南一书中介绍了更多的 while 循环实例: [http://tldp.org/LDP/Bash-Beginners-Guide/html/sect_09_02.html](http://tldp.org/LDP/Bash-Beginners-Guide/html/sect_09_02.html) * Wikipedia 中有一篇关于循环的文章,其是一篇比较长的关于流程控制的文章中的一部分: [http://en.wikipedia.org/wiki/Control_flow#Loops](http://en.wikipedia.org/wiki/Control_flow#Loops)
';

第二十九章:读取键盘输入

最后更新于:2022-04-02 01:46:13

到目前为止我们编写的脚本都缺乏一项在大多数计算机程序中都很常见的功能-交互性。也就是, 程序与用户进行交互的能力。虽然许多程序不必是可交互的,但一些程序却得到益处,能够直接 接受用户的输入。以这个前面章节中的脚本为例: ~~~ #!/bin/bash # test-integer2: evaluate the value of an integer. INT=-5 if [[ "$INT" =~ ^-?[0-9]+$ ]]; then if [ $INT -eq 0 ]; then echo "INT is zero." else if [ $INT -lt 0 ]; then echo "INT is negative." else echo "INT is positive." fi if [ $((INT % 2)) -eq 0 ]; then echo "INT is even." else echo "INT is odd." fi fi else echo "INT is not an integer." >&2 exit 1 fi ~~~ 每次我们想要改变 INT 数值的时候,我们必须编辑这个脚本。如果脚本能请求用户输入数值,那 么它会更加有用处。在这个脚本中,我们将看一下我们怎样给程序增加交互性功能。 ## read - 从标准输入读取数值 这个 read 内部命令被用来从标准输入读取单行数据。这个命令可以用来读取键盘输入,当使用 重定向的时候,读取文件中的一行数据。这个命令有以下语法形式: ~~~ read [-options] [variable...] ~~~ 这里的 options 是下面列出的可用选项中的一个或多个,且 variable 是用来存储输入数值的一个或多个变量名。 如果没有提供变量名,shell 变量 REPLY 会包含数据行。 基本上,read 会把来自标准输入的字段赋值给具体的变量。如果我们修改我们的整数求值脚本,让其使用 read ,它可能看起来像这样: ~~~ #!/bin/bash # read-integer: evaluate the value of an integer. echo -n "Please enter an integer -> " read int if [[ "$int" =~ ^-?[0-9]+$ ]]; then if [ $int -eq 0 ]; then echo "$int is zero." else if [ $int -lt 0 ]; then echo "$int is negative." else echo "$int is positive." fi if [ $((int % 2)) -eq 0 ]; then echo "$int is even." else echo "$int is odd." fi fi else echo "Input value is not an integer." >&2 exit 1 fi ~~~ 我们使用带有 -n 选项(其会删除输出结果末尾的换行符)的 echo 命令,来显示提示信息, 然后使用 read 来读入变量 int 的数值。运行这个脚本得到以下输出: ~~~ [me@linuxbox ~]$ read-integer Please enter an integer -> 5 5 is positive. 5 is odd. ~~~ read 可以给多个变量赋值,正如下面脚本中所示: ~~~ #!/bin/bash # read-multiple: read multiple values from keyboard echo -n "Enter one or more values > " read var1 var2 var3 var4 var5 echo "var1 = '$var1'" echo "var2 = '$var2'" echo "var3 = '$var3'" echo "var4 = '$var4'" echo "var5 = '$var5'" ~~~ 在这个脚本中,我们给五个变量赋值并显示其结果。注意当给定不同个数的数值后,read 怎样操作: ~~~ [me@linuxbox ~]$ read-multiple Enter one or more values > a b c d e var1 = 'a' var2 = 'b' var3 = 'c' var4 = 'd' var5 = 'e' [me@linuxbox ~]$ read-multiple Enter one or more values > a var1 = 'a' var2 = '' var3 = '' var4 = '' var5 = '' [me@linuxbox ~]$ read-multiple Enter one or more values > a b c d e f g var1 = 'a' var2 = 'b' var3 = 'c' var4 = 'd' var5 = 'e f g' ~~~ 如果 read 命令接受到变量值数目少于期望的数字,那么额外的变量值为空,而多余的输入数据则会 被包含到最后一个变量中。如果 read 命令之后没有列出变量名,则一个 shell 变量,REPLY,将会包含 所有的输入: ~~~ #!/bin/bash # read-single: read multiple values into default variable echo -n "Enter one or more values > " read echo "REPLY = '$REPLY'" ~~~ 这个脚本的输出结果是: ~~~ [me@linuxbox ~]$ read-single Enter one or more values > a b c d REPLY = 'a b c d' ~~~ ### 选项 read 支持以下选送: 表29-1: read 选项 | 选项 | 说明 | | --- | --- | | -a array | 把输入赋值到数组 array 中,从索引号零开始。我们 将在第36章中讨论数组问题。 | | -d delimiter | 用字符串 delimiter 中的第一个字符指示输入结束,而不是一个换行符。 | | -e | 使用 Readline 来处理输入。这使得与命令行相同的方式编辑输入。 | | -n num | 读取 num 个输入字符,而不是整行。 | | -p prompt | 为输入显示提示信息,使用字符串 prompt。 | | -r | Raw mode. 不把反斜杠字符解释为转义字符。 | | -s | Silent mode. 不会在屏幕上显示输入的字符。当输入密码和其它确认信息的时候,这会很有帮助。 | | -t seconds | 超时. 几秒钟后终止输入。read 会返回一个非零退出状态,若输入超时。 | | -u fd | 使用文件描述符 fd 中的输入,而不是标准输入。 | 使用各种各样的选项,我们能用 read 完成有趣的事情。例如,通过-p 选项,我们能够提供提示信息: ~~~ #!/bin/bash # read-single: read multiple values into default variable read -p "Enter one or more values > " echo "REPLY = '$REPLY'" ~~~ 通过 -t 和 -s 选项,我们可以编写一个这样的脚本,读取“秘密”输入,并且如果在特定的时间内 输入没有完成,就终止输入。 ~~~ #!/bin/bash # read-secret: input a secret pass phrase if read -t 10 -sp "Enter secret pass phrase > " secret_pass; then echo -e "\nSecret pass phrase = '$secret_pass'" else echo -e "\nInput timed out" >&2 exit 1 if ~~~ 这个脚本提示用户输入一个密码,并等待输入10秒钟。如果在特定的时间内没有完成输入, 则脚本会退出并返回一个错误。因为包含了一个 -s 选项,所以输入的密码不会出现在屏幕上。 ## IFS 通常,shell 对提供给 read 的输入按照单词进行分离。正如我们所见到的,这意味着多个由一个或几个空格 分离开的单词在输入行中变成独立的个体,并被 read 赋值给单独的变量。这种行为由 shell 变量__IFS__ (内部字符分隔符)配置。IFS 的默认值包含一个空格,一个 tab,和一个换行符,每一个都会把 字段分割开。 我们可以调整 IFS 的值来控制输入字段的分离。例如,这个 /etc/passwd 文件包含的数据行 使用冒号作为字段分隔符。通过把 IFS 的值更改为单个冒号,我们可以使用 read 读取 /etc/passwd 中的内容,并成功地把字段分给不同的变量。这个就是做这样的事情: ~~~ #!/bin/bash # read-ifs: read fields from a file FILE=/etc/passwd read -p "Enter a user name > " user_name file_info=$(grep "^$user_name:" $FILE) if [ -n "$file_info" ]; then IFS=":" read user pw uid gid name home shell <<< "$file_info" echo "User = '$user'" echo "UID = '$uid'" echo "GID = '$gid'" echo "Full Name = '$name'" echo "Home Dir. = '$home'" echo "Shell = '$shell'" else echo "No such user '$user_name'" >&2 exit 1 fi ~~~ 这个脚本提示用户输入系统中一个帐户的用户名,然后显示在文件 /etc/passwd/ 文件中关于用户记录的 不同字段。这个脚本包含两个有趣的文本行。 第一个是: ~~~ file_info=$(grep "^$user_name:" $FILE) ~~~ 这一行把 grep 命令的输入结果赋值给变量 file_info。grep 命令使用的正则表达式 确保用户名只会在 /etc/passwd 文件中匹配一个文本行。 第二个有意思的文本行是: ~~~ IFS=":" read user pw uid gid name home shell <<< "$file_info" ~~~ 这一行由三部分组成:一个变量赋值,一个带有一串参数的 read 命令,和一个奇怪的新的重定向操作符。 我们首先看一下变量赋值。 Shell 允许在一个命令之前立即发生一个或多个变量赋值。这些赋值为跟随着的命令更改环境变量。 这个赋值的影响是暂时的;只是在命令存在期间改变环境变量。在这种情况下,IFS 的值改为一个冒号。 另外,我们也可以这样编码: ~~~ OLD_IFS="$IFS" IFS=":" read user pw uid gid name home shell <<< "$file_info" IFS="$OLD_IFS" ~~~ 我们先存储 IFS 的值,然后赋给一个新值,再执行 read 命令,最后把 IFS 恢复原值。显然,完成相同的任务, 在命令之前放置变量名赋值是一种更简明的方式。 这个 `<<<` 操作符指示一个 here 字符串。一个 here 字符串就像一个 here 文档,只是比较简短,由 单个字符串组成。在这个例子中,来自 /etc/passwd 文件的数据发送给 read 命令的标准输入。 我们可能想知道为什么选择这种相当晦涩的方法而不是: ~~~ echo "$file_info" | IFS=":" read user pw uid gid name home shell ~~~ > 你不能管道 read > > 虽然通常 read 命令接受标准输入,但是你不能这样做: > > echo “foo” | read > > 我们期望这个命令能生效,但是它不能。这个命令将显示成功,但是 REPLY 变量 总是为空。为什么会这样? > > 答案与 shell 处理管道线的方式有关系。在 bash(和其它 shells,例如 sh)中,管道线 会创建子 shell。它们是 shell 的副本,且用来执行命令的环境变量在管道线中。 上面示例中,read 命令将在子 shell 中执行。 > > 在类 Unix 的系统中,子 shell 执行的时候,会为进程创建父环境的副本。当进程结束 之后,环境副本就会被破坏掉。这意味着一个子 shell 永远不能改变父进程的环境。read 赋值变量, 然后会变为环境的一部分。在上面的例子中,read 在它的子 shell 环境中,把 foo 赋值给变量 REPLY, 但是当命令退出后,子 shell 和它的环境将被破坏掉,这样赋值的影响就会消失。 > > 使用 here 字符串是解决此问题的一种方法。另一种方法将在37章中讨论。 ## 校正输入 从键盘输入这种新技能,带来了额外的编程挑战,校正输入。很多时候,一个良好编写的程序与 一个拙劣程序之间的区别就是程序处理意外的能力。通常,意外会以错误输入的形式出现。在前面 章节中的计算程序,我们已经这样做了一点儿,我们检查整数值,甄别空值和非数字字符。每次 程序接受输入的时候,执行这类的程序检查非常重要,为的是避免无效数据。对于 由多个用户共享的程序,这个尤为重要。如果一个程序只使用一次且只被作者用来执行一些特殊任务, 那么为了经济利益而忽略这些保护措施,可能会被原谅。即使这样,如果程序执行危险任务,比如说 删除文件,所以最好包含数据校正,以防万一。 这里我们有一个校正各种输入的示例程序: ~~~ #!/bin/bash # read-validate: validate input invalid_input () { echo "Invalid input '$REPLY'" >&2 exit 1 } read -p "Enter a single item > " # input is empty (invalid) [[ -z $REPLY ]] && invalid_input # input is multiple items (invalid) (( $(echo $REPLY | wc -w) > 1 )) && invalid_input # is input a valid filename? if [[ $REPLY =~ ^[-[:alnum:]\._]+$ ]]; then echo "'$REPLY' is a valid filename." if [[ -e $REPLY ]]; then echo "And file '$REPLY' exists." else echo "However, file '$REPLY' does not exist." fi # is input a floating point number? if [[ $REPLY =~ ^-?[[:digit:]]*\.[[:digit:]]+$ ]]; then echo "'$REPLY' is a floating point number." else echo "'$REPLY' is not a floating point number." fi # is input an integer? if [[ $REPLY =~ ^-?[[:digit:]]+$ ]]; then echo "'$REPLY' is an integer." else echo "'$REPLY' is not an integer." fi else echo "The string '$REPLY' is not a valid filename." fi ~~~ 这个脚本提示用户输入一个数字。随后,分析这个数字来决定它的内容。正如我们所看到的,这个脚本 使用了许多我们已经讨论过的概念,包括 shell 函数,`[[ ]]`,`(( ))`,控制操作符 `&&`,以及 `if` 和 一些正则表达式。 ## 菜单 一种常见的交互类型称为菜单驱动。在菜单驱动程序中,呈现给用户一系列选择,并要求用户选择一项。 例如,我们可以想象一个展示以下信息的程序: ~~~ Please Select: 1.Display System Information 2.Display Disk Space 3.Display Home Space Utilization 0.Quit Enter selection [0-3] > ~~~ 使用我们从编写 sys_info_page 程序中所学到的知识,我们能够构建一个菜单驱动程序来执行 上述菜单中的任务: ~~~ #!/bin/bash # read-menu: a menu driven system information program clear echo " Please Select: 1\. Display System Information 2\. Display Disk Space 3\. Display Home Space Utilization 0\. Quit " read -p "Enter selection [0-3] > " if [[ $REPLY =~ ^[0-3]$ ]]; then if [[ $REPLY == 0 ]]; then echo "Program terminated." exit fi if [[ $REPLY == 1 ]]; then echo "Hostname: $HOSTNAME" uptime exit fi if [[ $REPLY == 2 ]]; then df -h exit fi if [[ $REPLY == 3 ]]; then if [[ $(id -u) -eq 0 ]]; then echo "Home Space Utilization (All Users)" du -sh /home/* else echo "Home Space Utilization ($USER)" du -sh $HOME fi exit fi else echo "Invalid entry." >&2 exit 1 fi ~~~ The presence of multiple `exit` points in a program is generally a bad idea (it makes 从逻辑上讲,这个脚本被分为两部分。第一部分显示菜单和用户输入。第二部分确认用户反馈,并执行 选择的行动。注意脚本中使用的 exit 命令。在这里,在一个行动执行之后, exit 被用来阻止脚本执行不必要的代码。 通常在程序中出现多个 exit 代码是一个坏想法(它使程序逻辑较难理解),但是它在这个脚本中起作用。 ## 总结归纳 在这一章中,我们向着程序交互性迈出了第一步;允许用户通过键盘向程序输入数据。使用目前 已经学过的技巧,有可能编写许多有用的程序,比如说特定的计算程序和容易使用的命令行工具 前端。在下一章中,我们将继续建立菜单驱动程序概念,让它更完善。 ### 友情提示 仔细研究本章中的程序,并对程序的逻辑结构有一个完整的理解,这是非常重要的,因为即将到来的 程序会日益复杂。作为练习,用 test 命令而不是`[[ ]]`复合命令来重新编写本章中的程序。 提示:使用 grep 命令来计算正则表达式及其退出状态。这会是一个不错的实践。 ## 拓展阅读 * Bash 参考手册有一章关于内部命令的内容,其包括了`read`命令: [http://www.gnu.org/software/bash/manual/bashref.html#Bash-Builtins](http://www.gnu.org/software/bash/manual/bashref.html#Bash-Builtins)
';

第二十八章:流程控制 if分支结构

最后更新于:2022-04-02 01:46:11

在上一章中,我们遇到一个问题。怎样使我们的报告生成器脚本能适应运行此脚本的用户的权限? 这个问题的解决方案要求我们能找到一种方法,在脚本中基于测试条件结果,来“改变方向”。 用编程术语表达,就是我们需要程序可以分支。让我们考虑一个简单的用伪码表示的逻辑实例, 伪码是一种模拟的计算机语言,为的是便于人们理解: ~~~ X=5 If X = 5, then: Say “X equals 5.” Otherwise: Say “X is not equal to 5.” ~~~ 这就是一个分支的例子。根据条件,“Does X = 5?” 做一件事情,“Say X equals 5,” 否则,做另一件事情,“Say X is not equal to 5.” ## if 使用 shell,我们可以编码上面的逻辑,如下所示: ~~~ x=5 if [ $x = 5 ]; then echo "x equals 5." else echo "x does not equal 5." fi ~~~ 或者我们可以直接在命令行中输入以上代码(略有缩短): ~~~ [me@linuxbox ~]$ x=5 [me@linuxbox ~]$ if [ $x = 5 ]; then echo "equals 5"; else echo "does not equal 5"; fi equals 5 [me@linuxbox ~]$ x=0 [me@linuxbox ~]$ if [ $x = 5 ]; then echo "equals 5"; else echo "does not equal 5"; fi does not equal 5 ~~~ 在这个例子中,我们执行了两次这个命令。第一次是,把 x 的值设置为5,从而导致输出字符串“equals 5”, 第二次是,把 x 的值设置为0,从而导致输出字符串“does not equal 5”。 这个 if 语句语法如下: ~~~ if commands; then commands [elif commands; then commands...] [else commands] fi ~~~ 这里的 commands 是指一系列命令。第一眼看到会有点儿困惑。但是在我们弄清楚这些语句之前,我们 必须看一下 shell 是如何评判一个命令的成功与失败的。 ## 退出状态 当命令执行完毕后,命令(包括我们编写的脚本和 shell 函数)会给系统发送一个值,叫做退出状态。 这个值是一个 0 到 255 之间的整数,说明命令执行成功或是失败。按照惯例,一个零值说明成功,其它所有值说明失败。 Shell 提供了一个参数,我们可以用它检查退出状态。用具体实例看一下: ~~~ [me@linuxbox ~]$ ls -d /usr/bin /usr/bin [me@linuxbox ~]$ echo $? 0 [me@linuxbox ~]$ ls -d /bin/usr ls: cannot access /bin/usr: No such file or directory [me@linuxbox ~]$ echo $? 2 ~~~ 在这个例子中,我们执行了两次 ls 命令。第一次,命令执行成功。如果我们显示参数`$?`的值,我们 看到它是零。我们第二次执行 ls 命令的时候,产生了一个错误,并再次查看参数`$?`。这次它包含一个 数字 2,表明这个命令遇到了一个错误。有些命令使用不同的退出值,来诊断错误,而许多命令当 它们执行失败的时候,会简单地退出并发送一个数字1。手册页中经常会包含一章标题为“退出状态”的内容, 描述了使用的代码。然而,一个零总是表明成功。 这个 shell 提供了两个极其简单的内部命令,它们不做任何事情,除了以一个零或1退出状态来终止执行。 True 命令总是执行成功,而 false 命令总是执行失败: ~~~ [me@linuxbox~]$ true [me@linuxbox~]$ echo $? 0 [me@linuxbox~]$ false [me@linuxbox~]$ echo $? 1 ~~~ 我们能够使用这些命令,来看一下 if 语句是怎样工作的。If 语句真正做的事情是计算命令执行成功或失败: ~~~ [me@linuxbox ~]$ if true; then echo "It's true."; fi It's true. [me@linuxbox ~]$ if false; then echo "It's true."; fi [me@linuxbox ~]$ ~~~ 当 if 之后的命令执行成功的时候,命令 echo “It’s true.” 将会执行,否则此命令不执行。 如果 if 之后跟随一系列命令,则将计算列表中的最后一个命令: ~~~ [me@linuxbox ~]$ if false; true; then echo "It's true."; fi It's true. [me@linuxbox ~]$ if true; false; then echo "It's true."; fi [me@linuxbox ~]$ 3 ~~~ ## 测试 到目前为止,经常与 if 一块使用的命令是 test。这个 test 命令执行各种各样的检查与比较。 它有两种等价模式: ~~~ test expression ~~~ 比较流行的格式是: ~~~ [ expression ] ~~~ 这里的 expression 是一个表达式,其执行结果是 true 或者是 false。当表达式为真时,这个 test 命令返回一个零 退出状态,当表达式为假时,test 命令退出状态为1。 ### 文件表达式 以下表达式被用来计算文件状态: 表28-1: 测试文件表达式 | 表达式 | 如果为真 | |------|-------| | file1 -ef file2 | file1 和 file2 拥有相同的索引号(通过硬链接两个文件名指向相同的文件)。 | | file1 -nt file2 | file1新于 file2。 | | file1 -ot file2 | file1早于 file2。 | | -b file | file 存在并且是一个块(设备)文件。 | | -c file | file 存在并且是一个字符(设备)文件。 | | -d file | file 存在并且是一个目录。 | | -e file | file 存在。 | | -f file | file 存在并且是一个普通文件。 | | -g file | file 存在并且设置了组 ID。 | | -G file | file 存在并且由有效组 ID 拥有。 | | -k file | file 存在并且设置了它的“sticky bit”。 | | -L file | file 存在并且是一个符号链接。 | | -O file | file 存在并且由有效用户 ID 拥有。 | | -p file | file 存在并且是一个命名管道。 | | -r file | file 存在并且可读(有效用户有可读权限)。 | | -s file | file 存在且其长度大于零。 | | -S file | file 存在且是一个网络 socket。 | | -t fd | fd 是一个定向到终端/从终端定向的文件描述符 。 这可以被用来决定是否重定向了标准输入/输出错误。 | | -u file | file 存在并且设置了 setuid 位。 | | -w file | file 存在并且可写(有效用户拥有可写权限)。 | | -x file | file 存在并且可执行(有效用户有执行/搜索权限)。 | 这里我们有一个脚本说明了一些文件表达式: ~~~ #!/bin/bash # test-file: Evaluate the status of a file FILE=~/.bashrc if [ -e "$FILE" ]; then if [ -f "$FILE" ]; then echo "$FILE is a regular file." fi if [ -d "$FILE" ]; then echo "$FILE is a directory." fi if [ -r "$FILE" ]; then echo "$FILE is readable." fi if [ -w "$FILE" ]; then echo "$FILE is writable." fi if [ -x "$FILE" ]; then echo "$FILE is executable/searchable." fi else echo "$FILE does not exist" exit 1 fi exit ~~~ 这个脚本会计算赋值给常量 FILE 的文件,并显示计算结果。对于此脚本有两点需要注意。第一个, 在表达式中参数`$FILE`是怎样被引用的。引号并不是必需的,但这是为了防范空参数。如果`$FILE`的参数展开 是一个空值,就会导致一个错误(操作符将会被解释为非空的字符串而不是操作符)。用引号把参数引起来就 确保了操作符之后总是跟随着一个字符串,即使字符串为空。第二个,注意脚本末尾的 exit 命令。 这个 exit 命令接受一个单独的,可选的参数,其成为脚本的退出状态。当不传递参数时,退出状态默认为零。 以这种方式使用 exit 命令,则允许此脚本提示失败如果 `$FILE` 展开成一个不存在的文件名。这个 exit 命令 出现在脚本中的最后一行,是一个当一个脚本“运行到最后”(到达文件末尾),不管怎样, 默认情况下它以退出状态零终止。 类似地,通过带有一个整数参数的 return 命令,shell 函数可以返回一个退出状态。如果我们打算把 上面的脚本转变为一个 shell 函数,为了在更大的程序中包含此函数,我们用 return 语句来代替 exit 命令, 则得到期望的行为: ~~~ test_file () { # test-file: Evaluate the status of a file FILE=~/.bashrc if [ -e "$FILE" ]; then if [ -f "$FILE" ]; then echo "$FILE is a regular file." fi if [ -d "$FILE" ]; then echo "$FILE is a directory." fi if [ -r "$FILE" ]; then echo "$FILE is readable." fi if [ -w "$FILE" ]; then echo "$FILE is writable." fi if [ -x "$FILE" ]; then echo "$FILE is executable/searchable." fi else echo "$FILE does not exist" return 1 fi } ~~~ ### 字符串表达式 以下表达式用来计算字符串: 表28-2: 测试字符串表达式 | 表达式 | 如果为真... | |------|-------| | string | string 不为 null。 | | -n string | 字符串 string 的长度大于零。 | | -z string | 字符串 string 的长度为零。 | | string1 = string2 string1 == string2 | string1 和 string2 相同. 单或双等号都可以,不过双等号更受欢迎。 | | string1 != string2 | string1 和 string2 不相同。 | | string1 > string2 | sting1 排列在 string2 之后。 | | string1 < string2 | string1 排列在 string2 之前。 | * * * 警告:这个 > 和 <表达式操作符必须用引号引起来(或者是用反斜杠转义), 当与 test 一块使用的时候。如果不这样,它们会被 shell 解释为重定向操作符,造成潜在地破坏结果。 同时也要注意虽然 bash 文档声明排序遵从当前语系的排列规则,但并不这样。将来的 bash 版本,包含 4.0, 使用 ASCII(POSIX)排序规则。 * * * 这是一个演示这些问题的脚本: ~~~ #!/bin/bash # test-string: evaluate the value of a string ANSWER=maybe if [ -z "$ANSWER" ]; then echo "There is no answer." >&2 exit 1 fi if [ "$ANSWER" = "yes" ]; then echo "The answer is YES." elif [ "$ANSWER" = "no" ]; then echo "The answer is NO." elif [ "$ANSWER" = "maybe" ]; then echo "The answer is MAYBE." else echo "The answer is UNKNOWN." fi ~~~ 在这个脚本中,我们计算常量 ANSWER。我们首先确定是否此字符串为空。如果为空,我们就终止 脚本,并把退出状态设为零。注意这个应用于 echo 命令的重定向操作。其把错误信息 “There is no answer.” 重定向到标准错误,这是处理错误信息的“合理”方法。如果字符串不为空,我们就计算 字符串的值,看看它是否等于“yes,” “no,” 或者“maybe”。为此使用了 elif,它是 “else if” 的简写。 通过使用 elif,我们能够构建更复杂的逻辑测试。 ### 整型表达式 下面的表达式用于整数: 表28-3: 测试整数表达式 | 表达式 | 如果为真... | |------|-------| | integer1 -eq integer2 | integer1 等于 integer2. | | integer1 -ne integer2 | integer1 不等于 integer2. | | integer1 -le integer2 | integer1 小于或等于 integer2. | | integer1 -lt integer2 | integer1 小于 integer2. | | integer1 -ge integer2 | integer1 大于或等于 integer2. | | integer1 -gt integer2 | integer1 大于 integer2. | 这里是一个演示以上表达式用法的脚本: ~~~ #!/bin/bash # test-integer: evaluate the value of an integer. INT=-5 if [ -z "$INT" ]; then echo "INT is empty." >&2 exit 1 fi if [ $INT -eq 0 ]; then echo "INT is zero." else if [ $INT -lt 0 ]; then echo "INT is negative." else echo "INT is positive." fi if [ $((INT % 2)) -eq 0 ]; then echo "INT is even." else echo "INT is odd." fi fi ~~~ 这个脚本中有趣的地方是怎样来确定一个整数是偶数还是奇数。通过用模数2对数字执行求模操作, 就是用数字来除以2,并返回余数,从而知道数字是偶数还是奇数。 ## 更现代的测试版本 目前的 bash 版本包括一个复合命令,作为加强的 test 命令替代物。它使用以下语法: ~~~ [[ expression ]] ~~~ 这里,类似于 test,expression 是一个表达式,其计算结果为真或假。这个`[[ ]]`命令非常 相似于 test 命令(它支持所有的表达式),但是增加了一个重要的新的字符串表达式: ~~~ string1 =~ regex ~~~ 其返回值为真,如果 string1匹配扩展的正则表达式 regex。这就为执行比如数据验证等任务提供了许多可能性。 在我们前面的整数表达式示例中,如果常量 INT 包含除了整数之外的任何数据,脚本就会运行失败。这个脚本 需要一种方法来证明此常量包含一个整数。使用 `[[ ]]` 和 `=~` 字符串表达式操作符,我们能够这样来改进脚本: ~~~ #!/bin/bash # test-integer2: evaluate the value of an integer. INT=-5 if [[ "$INT" =~ ^-?[0-9]+$ ]]; then if [ $INT -eq 0 ]; then echo "INT is zero." else if [ $INT -lt 0 ]; then echo "INT is negative." else echo "INT is positive." fi if [ $((INT % 2)) -eq 0 ]; then echo "INT is even." else echo "INT is odd." fi fi else echo "INT is not an integer." >&2 exit 1 fi ~~~ 通过应用正则表达式,我们能够限制 INT 的值只是字符串,其开始于一个可选的减号,随后是一个或多个数字。 这个表达式也消除了空值的可能性。 `[[ ]]`添加的另一个功能是`==`操作符支持类型匹配,正如路径名展开所做的那样。例如: ~~~ [me@linuxbox ~]$ FILE=foo.bar [me@linuxbox ~]$ if [[ $FILE == foo.* ]]; then > echo "$FILE matches pattern 'foo.*'" > fi foo.bar matches pattern 'foo.*' ~~~ 这就使`[[ ]]`有助于计算文件和路径名。 ## (( )) - 为整数设计 除了 `[[ ]]` 复合命令之外,bash 也提供了 `(( ))` 复合命名,其有利于操作整数。它支持一套 完整的算术计算,我们将在第35章中讨论这个主题。 `(( ))`被用来执行算术真测试。如果算术计算的结果是非零值,则一个算术真测试值为真。 ~~~ [me@linuxbox ~]$ if ((1)); then echo "It is true."; fi It is true. [me@linuxbox ~]$ if ((0)); then echo "It is true."; fi [me@linuxbox ~]$ ~~~ 使用`(( ))`,我们能够略微简化 test-integer2脚本,像这样: ~~~ #!/bin/bash # test-integer2a: evaluate the value of an integer. INT=-5 if [[ "$INT" =~ ^-?[0-9]+$ ]]; then if ((INT == 0)); then echo "INT is zero." else if ((INT < 0)); then echo "INT is negative." else echo "INT is positive." fi if (( ((INT % 2)) == 0)); then echo "INT is even." else echo "INT is odd." fi fi else echo "INT is not an integer." >&2 exit 1 fi ~~~ 注意我们使用小于和大于符号,以及==用来测试是否相等。这是使用整数较为自然的语法了。也要 注意,因为复合命令 `(( ))` 是 shell 语法的一部分,而不是一个普通的命令,而且它只处理整数, 所以它能够通过名字识别出变量,而不需要执行展开操作。我们将在第35中进一步讨论 `(( ))` 命令 和相关的算术展开操作。 ## 结合表达式 也有可能把表达式结合起来创建更复杂的计算。通过使用逻辑操作符来结合表达式。我们 在第18章中已经知道了这些,当我们学习 find 命令的时候。它们是用于 test 和 `[[ ]]` 三个逻辑操作。 它们是 AND,OR,和 NOT。test 和 `[[ ]]` 使用不同的操作符来表示这些操作: 表28-4: 逻辑操作符 | 操作符 | 测试 | [[ ]] and (( )) | |------|-------|-----| | AND | -a | && | | OR | -o | \|\| | | NOT | ! | ! | 这里有一个 AND 操作的示例。下面的脚本决定了一个整数是否属于某个范围内的值: ~~~ #!/bin/bash # test-integer3: determine if an integer is within a # specified range of values. MIN_VAL=1 MAX_VAL=100 INT=50 if [[ "$INT" =~ ^-?[0-9]+$ ]]; then if [[ INT -ge MIN_VAL && INT -le MAX_VAL ]]; then echo "$INT is within $MIN_VAL to $MAX_VAL." else echo "$INT is out of range." fi else echo "INT is not an integer." >&2 exit 1 fi ~~~ 我们也可以对表达式使用圆括号,为的是分组。如果不使用括号,那么否定只应用于第一个 表达式,而不是两个组合的表达式。用 test 可以这样来编码: ~~~ if [ ! \( $INT -ge $MIN_VAL -a $INT -le $MAX_VAL \) ]; then echo "$INT is outside $MIN_VAL to $MAX_VAL." else echo "$INT is in range." fi ~~~ 因为 test 使用的所有的表达式和操作符都被 shell 看作是命令参数(不像 `[[ ]]` 和 `(( ))` ), 对于 bash 有特殊含义的字符,比如说 ,(,和 ),必须引起来或者是转义。 知道了 test 和 `[[ ]]` 基本上完成相同的事情,哪一个更好呢?test 更传统(是 POSIX 的一部分), 然而 `[[ ]]` 特定于 bash。知道怎样使用 test 很重要,因为它被非常广泛地应用,但是显然 `[[ ]]` 更 有助于,并更易于编码。 > 可移植性是头脑狭隘人士的心魔 > > 如果你和“真正的”Unix 用户交谈,你很快就会发现他们大多数人不是非常喜欢 Linux。他们 认为 Linux 肮脏且不干净。Unix 追随者的一个宗旨是,一切都应“可移植的”。这意味着你编写 的任意一个脚本都应当无需修改,就能运行在任何一个类 Unix 的系统中。 > > Unix 用户有充分的理由相信这一点。在 POSIX 之前,Unix 用户已经看到了命令的专有扩展以及 shell 对 Unix 世界的所做所为,他们自然会警惕 Linux 对他们心爱系统的影响。 > > 但是可移植性有一个严重的缺点。它防碍了进步。它要求做事情要遵循“最低常见标准”。 在 shell 编程这种情况下,它意味着一切要与 sh 兼容,最初的 Bourne shell。 > > 这个缺点是一个借口,专有软件供应商用它来证明他们的专利扩展,只有他们称他们为“创新”。 但是他们只是为他们的客户锁定设备。 > > GNU 工具,比如说 bash,就没有这些限制。他们通过支持标准和普遍地可用性来鼓励可移植性。你几乎可以 在所有类型的系统中安装 bash 和其它的 GNU 工具,甚至是 Windows,而没有损失。所以就 感觉可以自由的使用 bash 的所有功能。它是真正的可移植。 ## 控制操作符:分支的另一种方法 bash 支持两种可以执行分支任务的控制操作符。这个 `&&(AND)`和`||(OR)`操作符作用如同 复合命令`[[ ]]`中的逻辑操作符。这是语法: ~~~ command1 && command2 ~~~ 和 ~~~ command1 || command2 ~~~ 理解这些操作很重要。对于 && 操作符,先执行 command1,如果并且只有如果 command1 执行成功后, 才会执行 command2。对于 || 操作符,先执行 command1,如果并且只有如果 command1 执行失败后, 才会执行 command2。 在实际中,它意味着我们可以做这样的事情: ~~~ [me@linuxbox ~]$ mkdir temp && cd temp ~~~ 这会创建一个名为 temp 的目录,并且若它执行成功后,当前目录会更改为 temp。第二个命令会尝试 执行只有当 mkdir 命令执行成功之后。同样地,一个像这样的命令: ~~~ [me@linuxbox ~]$ [ -d temp ] || mkdir temp ~~~ 会测试目录 temp 是否存在,并且只有测试失败之后,才会创建这个目录。这种构造类型非常有助于在 脚本中处理错误,这个主题我们将会在随后的章节中讨论更多。例如,我们在脚本中可以这样做: ~~~ [ -d temp ] || exit 1 ~~~ 如果这个脚本要求目录 temp,且目录不存在,然后脚本会终止,并返回退出状态1。 ## 总结 这一章开始于一个问题。我们怎样使 `sys_info_page` 脚本来检测是否用户拥有权限来读取所有的 家目录?根据我们的 if 知识,我们可以解决这个问题,通过把这些代码添加到 `report_home_space` 函数中: ~~~ report_home_space () { if [[ $(id -u) -eq 0 ]]; then cat <<- _EOF_

Home Space Utilization (All Users)

$(du -sh /home/*)
_EOF_ else cat <<- _EOF_

Home Space Utilization ($USER)

$(du -sh $HOME)
_EOF_ fi return } ~~~ 我们计算 id 命令的输出结果。通过带有 -u 选项的 id 命令,输出有效用户的数字用户 ID 号。 超级用户总是零,其它每个用户是一个大于零的数字。知道了这点,我们能够构建两种不同的 here 文档, 一个利用超级用户权限,另一个限制于用户拥有的家目录。 我们将暂别 `sys_info_page` 程序,但不要着急。它还会回来。同时,当我们继续工作的时候, 将会讨论一些我们需要的话题。 ## 拓展阅读 bash 手册页中有几部分对本章中涵盖的主题提供了更详细的内容: * Lists ( 讨论控制操作符 `||` 和 `&&` ) * Compound Commands ( 讨论 `[[ ]]`, `(( ))` 和 if ) * CONDITIONAL EXPRESSIONS (条件表达式) * SHELL BUILTIN COMMANDS ( 讨论 test ) 进一步,Wikipedia 中有一篇关于伪代码概念的好文章: [http://en.wikipedia.org/wiki/Pseudocode](http://en.wikipedia.org/wiki/Pseudocode)
';

第二十七章:自顶向下设计

最后更新于:2022-04-02 01:46:09

随着程序变得更加庞大和复杂,设计,编码和维护它们也变得更加困难。对于任意一个大项目而言, 把繁重,复杂的任务分割为细小且简单的任务,往往是一个好主意。想象一下,我们试图描述 一个平凡无奇的工作,一位火星人要去市场买食物。我们可能通过下面一系列步骤来形容整个过程: * 上车 * 开车到市场 * 停车 * 买食物 * 回到车中 * 开车回家 * 回到家中 然而,火星人可能需要更详细的信息。我们可以进一步细化子任务“停车”为这些步骤: * 找到停车位 * 开车到停车位 * 关闭引擎 * 拉紧手刹 * 下车 * 锁车 这个“关闭引擎”子任务可以进一步细化为这些步骤,包括“关闭点火装置”,“移开点火匙”等等,直到 已经完整定义了要去市场买食物整个过程的每一个步骤。 这种先确定上层步骤,然后再逐步细化这些步骤的过程被称为自顶向下设计。这种技巧允许我们 把庞大而复杂的任务分割为许多小而简单的任务。自顶向下设计是一种常见的程序设计方法, 尤其适合 shell 编程。 在这一章中,我们将使用自顶向下的设计方法来进一步开发我们的报告产生器脚本。 ## Shell 函数 目前我们的脚本执行以下步骤来产生这个 HTML 文档: * 打开网页 * 打开网页标头 * 设置网页标题 * 关闭网页标头 * 打开网页主体部分 * 输出网页标头 * 输出时间戳 * 关闭网页主体 * 关闭网页 为了下一阶段的开发,我们将在步骤7和8之间添加一些额外的任务。这些将包括: * 系统正常运行时间和负载。这是自上次关机或重启之后系统的运行时间,以及在几个时间间隔内当前运行在处理 中的平均任务量。 * 磁盘空间。系统中存储设备的总使用量。 * 家目录空间。每个用户所使用的存储空间数量。 如果对于每一个任务,我们都有相应的命令,那么通过命令替换,我们就能很容易地把它们添加到我们的脚本中: ~~~ #!/bin/bash # Program to output a system information page TITLE="System Information Report For $HOSTNAME" CURRENT_TIME=$(date +"%x %r %Z") TIME_STAMP="Generated $CURRENT_TIME, by $USER" cat << _EOF_ $TITLE

$TITLE

$TIME_STAMP

$(report_uptime) $(report_disk_space) $(report_home_space) _EOF_ ~~~ 我们能够用两种方法来创建这些额外的命令。我们可以分别编写三个脚本,并把它们放置到 环境变量 PATH 所列出的目录下,或者我们也可以把这些脚本作为 shell 函数嵌入到我们的程序中。 我们之前已经提到过,shell 函数是位于其它脚本中的“微脚本”,作为自主程序。Shell 函数有两种语法形式: ~~~ function name { commands return } and name () { commands return } ~~~ 这里的 name 是函数名,commands 是一系列包含在函数中的命令。 两种形式是等价的,可以交替使用。下面我们将查看一个说明 shell 函数使用方法的脚本: ~~~ 1 #!/bin/bash 2 3 # Shell function demo 4 5 function funct { 6 echo "Step 2" 7 return 8 } 9 10 # Main program starts here 11 12 echo "Step 1" 13 funct 14 echo "Step 3" ~~~ 随着 shell 读取这个脚本,它会跳过第1行到第11行的代码,因为这些文本行由注释和函数定义组成。 从第12行代码开始执行,有一个 echo 命令。第13行会调用 shell 函数 funct,然后 shell 会执行这个函数, 就如执行其它命令一样。这样程序控制权会转移到第六行,执行第二个 echo 命令。然后再执行第7行。 这个 return 命令终止这个函数,并把控制权交给函数调用之后的代码(第14行),从而执行最后一个 echo 命令。注意为了使函数调用被识别出是 shell 函数,而不是被解释为外部程序的名字,所以在脚本中 shell 函数定义必须出现在函数调用之前。 我们将给脚本添加最小的 shell 函数定义: ~~~ #!/bin/bash # Program to output a system information page TITLE="System Information Report For $HOSTNAME" CURRENT_TIME=$(date +"%x %r %Z") TIME_STAMP="Generated $CURRENT_TIME, by $USER" report_uptime () { return } report_disk_space () { return } report_home_space () { return } cat << _EOF_ $TITLE

$TITLE

$TIME_STAMP

$(report_uptime) $(report_disk_space) $(report_home_space) _EOF_ ~~~ Shell 函数的命名规则和变量一样。一个函数必须至少包含一条命令。这条 return 命令(是可选的)满足要求。 ## 局部变量 目前我们所写的脚本中,所有的变量(包括常量)都是全局变量。全局变量在整个程序中保持存在。 对于许多事情来说,这很好,但是有时候它会使 shell 函数的使用变得复杂。在 shell 函数中,经常期望 会有局部变量。局部变量只能在定义它们的 shell 函数中使用,并且一旦 shell 函数执行完毕,它们就不存在了。 拥有局部变量允许程序员使用的局部变量名,可以与已存在的变量名相同,这些变量可以是全局变量, 或者是其它 shell 函数中的局部变量,却不必担心潜在的名字冲突。 这里有一个实例脚本,其说明了怎样来定义和使用局部变量: ~~~ #!/bin/bash # local-vars: script to demonstrate local variables foo=0 # global variable foo funct_1 () { local foo # variable foo local to funct_1 foo=1 echo "funct_1: foo = $foo" } funct_2 () { local foo # variable foo local to funct_2 foo=2 echo "funct_2: foo = $foo" } echo "global: foo = $foo" funct_1 echo "global: foo = $foo" funct_2 echo "global: foo = $foo" ~~~ 正如我们所看到的,通过在变量名之前加上单词 local,来定义局部变量。这就创建了一个只对其所在的 shell 函数起作用的变量。在这个 shell 函数之外,这个变量不再存在。当我们运行这个脚本的时候, 我们会看到这样的结果: ~~~ [me@linuxbox ~]$ local-vars global: foo = 0 funct_1: foo = 1 global: foo = 0 funct_2: foo = 2 global: foo = 0 ~~~ 我们看到对两个 shell 函数中的局部变量 foo 赋值,不会影响到在函数之外定义的变量 foo 的值。 这个功能就允许 shell 函数能保持各自以及与它们所在脚本之间的独立性。这个非常有价值,因为它帮忙 阻止了程序各部分之间的相互干涉。这样 shell 函数也可以移植。也就是说,按照需求, shell 函数可以在脚本之间进行剪切和粘贴。 ## 保持脚本运行 当开发程序的时候,保持程序的可执行状态非常有用。这样做,并且经常测试,我们就可以在程序 开发过程的早期检测到错误。这将使调试问题容易多了。例如,如果我们运行这个程序,做一个小的修改, 然后再次执行这个程序,最后发现一个问题,非常有可能这个最新的修改就是问题的来源。通过添加空函数, 程序员称之为占位符,我们可以在早期阶段证明程序的逻辑流程。当构建一个占位符的时候, 能够包含一些为程序员提供反馈信息的代码是一个不错的主意,这些信息展示了正在执行的逻辑流程。 现在看一下我们脚本的输出结果: ~~~ [me@linuxbox ~]$ sys_info_page System Information Report For twin2

System Information Report For linuxbox

Generated 03/19/2009 04:02:10 PM EDT, by me

~~~ 我们看到时间戳之后的输出结果中有一些空行,但是我们不能确定这些空行产生的原因。如果我们 修改这些函数,让它们包含一些反馈信息: ~~~ report_uptime () { echo "Function report_uptime executed." return } report_disk_space () { echo "Function report_disk_space executed." return } report_home_space () { echo "Function report_home_space executed." return } ~~~ 然后再次运行这个脚本: ~~~ [me@linuxbox ~]$ sys_info_page System Information Report For linuxbox

System Information Report For linuxbox

Generated 03/20/2009 05:17:26 AM EDT, by me

Function report_uptime executed. Function report_disk_space executed. Function report_home_space executed. ~~~ 现在我们看到,事实上,执行了三个函数。 我们的函数框架已经各就各位并且能工作,是时候更新一些函数代码了。首先,是 report_uptime 函数: ~~~ report_uptime () { cat <<- _EOF_

System Uptime

$(uptime)
_EOF_ return } ~~~ 这些代码相当直截了当。我们使用一个 here 文档来输出标题和 uptime 命令的输出结果,命令结果被 标签包围, 为的是保持命令的输出格式。这个 report_disk_space 函数类似: ~~~ report_disk_space () { cat <<- _EOF_

Disk Space Utilization

$(df -h)
_EOF_ return } ~~~ 这个函数使用 df -h 命令来确定磁盘空间的数量。最后,我们将建造 report_home_space 函数: ~~~ report_home_space () { cat <<- _EOF_

Home Space Utilization

$(du -sh /home/*)
_EOF_ return } ~~~ 我们使用带有 -sh 选项的 du 命令来完成这个任务。然而,这并不是此问题的完整解决方案。虽然它会 在一些系统(例如 Ubuntu)中起作用,但是在其它系统中它不工作。这是因为许多系统会设置家目录的 权限,以此阻止其它用户读取它们,这是一个合理的安全措施。在这些系统中,这个 report_home_space 函数, 只有用超级用户权限执行我们的脚本时,才会工作。一个更好的解决方案是让脚本能根据用户的使用权限来 调整自己的行为。我们将在下一章中讨论这个问题。 > 你的 .bashrc 文件中的 shell 函数 > > Shell 函数是更为完美的别名替代物,实际上是创建较小的个人所用命令的首选方法。别名 非常局限于命令的种类和它们支持的 shell 功能,然而 shell 函数允许任何可以编写脚本的东西。 例如,如果我们喜欢 为我们的脚本开发的这个 report_disk_space shell 函数,我们可以为我们的 .bashrc 文件 创建一个相似的名为 ds 的函数: > > ~~~ > ds () { > echo “Disk Space Utilization For $HOSTNAME” > df -h > } > > ~~~ ## 总结归纳 这一章中,我们介绍了一种常见的程序设计方法,叫做自顶向下设计,并且我们知道了怎样 使用 shell 函数按照要求来完成逐步细化的任务。我们也知道了怎样使用局部变量使 shell 函数 独立于其它函数,以及其所在程序的其它部分。这就有可能使 shell 函数以可移植的方式编写, 并且能够重复使用,通过把它们放置到多个程序中;节省了大量的时间。 ## 拓展阅读 * Wikipedia 上面有许多关于软件设计原理的文章。这里是一些好文章: [http://en.wikipedia.org/wiki/Top-down_design](http://en.wikipedia.org/wiki/Top-down_design) [http://en.wikipedia.org/wiki/Subroutines](http://en.wikipedia.org/wiki/Subroutines)
';

第二十六章:启动一个项目

最后更新于:2022-04-02 01:46:06

从这一章开始,我们将建设一个项目。这个项目的目的是为了了解怎样使用各种各样的 shell 功能来 创建程序,更重要的是,创建好程序。 我们将要编写的程序是一个报告生成器。它会显示系统的各种统计数据和它的状态,并将产生 HTML 格式的报告, 所以我们能通过网络浏览器,比如说 Firefox 或者 Konqueror,来查看这个报告。 通常,创建程序要经过一系列阶段,每个阶段会添加新的特性和功能。我们程序的第一个阶段将会 产生一个非常小的 HTML 网页,其不包含系统信息。随后我们会添加这些信息。 ## 第一阶段:最小的文档 首先我们需要知道的事是一个规则的 HTML 文档的格式。它看起来像这样: ~~~ Page Title Page body. ~~~ 如果我们将这些内容输入到文本编辑器中,并把文件保存为 foo.html,然后我们就能在 Firefox 中 使用下面的 URL 来查看文件内容: ~~~ file:///home/username/foo.html ~~~ 程序的第一个阶段将这个 HTML 文件输出到标准输出。我们可以编写一个程序,相当容易地完成这个任务。 启动我们的文本编辑器,然后创建一个名为 ~/bin/sys_info_page 的新文件: ~~~ [me@linuxbox ~]$ vim ~/bin/sys_info_page ~~~ 随后输入下面的程序: ~~~ #!/bin/bash # Program to output a system information page echo "" echo " " echo " Page Title" echo " " echo " " echo " Page body." echo " " echo "" ~~~ 我们第一次尝试解决这个问题,程序包含了一个 shebang,一条注释(总是一个好主意)和一系列的 echo 命令,每个命令负责输出一行文本。保存文件之后,我们将让它成为可执行文件,再尝试运行它: ~~~ [me@linuxbox ~]$ chmod 755 ~/bin/sys_info_page [me@linuxbox ~]$ sys_info_page ~~~ 当程序运行的时候,我们应该看到 HTML 文本在屏幕上显示出来,因为脚本中的 echo 命令会输出 发送到标准输出。我们再次运行这个程序,把程序的输出重定向到文件 sys_info_page.html 中, 从而我们可以通过网络浏览器来查看输出结果: ~~~ [me@linuxbox ~]$ sys_info_page > sys_info_page.html [me@linuxbox ~]$ firefox sys_info_page.html ~~~ 到目前为止,一切顺利。 在编写程序的时候,尽量做到简单明了,这总是一个好主意。当一个程序易于阅读和理解的时候, 维护它也就更容易,更不用说,通过减少键入量,可以使程序更容易书写了。我们当前的程序版本 工作正常,但是它可以更简单些。实际上,我们可以把所有的 echo 命令结合成一个 echo 命令,当然 这样能更容易地添加更多的文本行到程序的输出中。那么,把我们的程序修改为: ~~~ #!/bin/bash # Program to output a system information page echo " Page Title Page body. " ~~~ 一个带引号的字符串可能包含换行符,因此可以包含多个文本行。Shell 会持续读取文本直到它遇到 右引号。它在命令行中也是这样工作的: ~~~ [me@linuxbox ~]$ echo " > Page Title > > > Page body. > >" ~~~ 开头的 “>” 字符是包含在 PS2shell 变量中的 shell 提示符。每当我们在 shell 中键入多行语句的时候, 这个提示符就会出现。现在这个功能有点儿晦涩,但随后,当我们介绍多行编程语句时,它会派上大用场。 ## 第二阶段:添加一点儿数据 现在我们的程序能生成一个最小的文档,让我们给报告添加些数据吧。为此,我们将做 以下修改: ~~~ #!/bin/bash # Program to output a system information page echo " System Information Report

System Information Report

" ~~~ 我们增加了一个网页标题,并且在报告正文部分加了一个标题。 ## 变量和常量 然而,我们的脚本存在一个问题。请注意字符串 “System Information Report” 是怎样被重复使用的?对于这个微小的脚本而言,它不是一个问题,但是让我们设想一下, 我们的脚本非常冗长,并且我们有许多这个字符串的实例。如果我们想要更换一个标题,我们必须 对脚本中的许多地方做修改,这会是很大的工作量。如果我们能整理一下脚本,让这个字符串只 出现一次而不是多次,会怎样呢?这样会使今后的脚本维护工作更加轻松。我们可以这样做: ~~~ #!/bin/bash # Program to output a system information page title="System Information Report" echo " $title

$title

" ~~~ 通过创建一个名为 title 的变量,并把 “System Information Report” 字符串赋值给它,我们就可以利用参数展开功能,把这个字符串放到文件中的多个位置。 那么,我们怎样来创建一个变量呢?很简单,我们只管使用它。当 shell 碰到一个变量的时候,它会 自动地创建它。这不同于许多编程语言,它们中的变量在使用之前,必须显式的声明或是定义。关于 这个问题,shell 要求非常宽松,这可能会导致一些问题。例如,考虑一下在命令行中发生的这种情形: ~~~ [me@linuxbox ~]$ foo="yes" [me@linuxbox ~]$ echo $foo yes [me@linuxbox ~]$ echo $fool [me@linuxbox ~]$ ~~~ 首先我们把 “yes” 赋给变量 foo,然后用 echo 命令来显示变量值。接下来,我们显示拼写错误的变量名 “fool” 的变量值,然后得到一个空值。这是因为 shell 很高兴地创建了变量 fool,当 shell 遇到 fool 的时候, 并且赋给 fool 一个空的默认值。因此,我们必须小心谨慎地拼写!同样理解实例中究竟发生了什么事情也 很重要。从我们以前学习 shell 执行展开操作,我们知道这个命令: ~~~ [me@linuxbox ~]$ echo $foo ~~~ 经历了参数展开操作,然后得到: ~~~ [me@linuxbox ~]$ echo yes ~~~ 然而这个命令: ~~~ [me@linuxbox ~]$ echo $fool ~~~ 展开为: ~~~ [me@linuxbox ~]$ echo ~~~ 这个空变量展开值为空!对于需要参数的命令来说,这会引起混乱。下面是一个例子: ~~~ [me@linuxbox ~]$ foo=foo.txt [me@linuxbox ~]$ foo1=foo1.txt [me@linuxbox ~]$ cp $foo $fool cp: missing destination file operand after `foo.txt' Try `cp --help' for more information. ~~~ 我们给两个变量赋值,foo 和 foo1。然后我们执行 cp 操作,但是拼写错了第二个参数的名字。 参数展开之后,这个 cp 命令只接受到一个参数,虽然它需要两个。 有一些关于变量名的规则: 1. 变量名可由字母数字字符(字母和数字)和下划线字符组成。 2. 变量名的第一个字符必须是一个字母或一个下划线。 3. 变量名中不允许出现空格和标点符号。 单词 “variable” 意味着可变的值,并且在许多应用程序当中,都是以这种方式来使用变量的。然而, 我们应用程序中的变量,title,被用作一个常量。常量有一个名字且包含一个值,在这方面就 像是变量。不同之处是常量的值是不能改变的。在执行几何运算的应用程序中,我们可以把 PI 定义为 一个常量,并把 3.1415 赋值给它,用它来代替数字字面值。shell 不能辨别变量和常量;它们大多数情况下 是为了方便程序员。一个常用惯例是指定大写字母来表示常量,小写字母表示真正的变量。我们 将修改我们的脚本来遵从这个惯例: ~~~ #!/bin/bash # Program to output a system information page TITLE="System Information Report For $HOSTNAME" echo " $title

$title

" ~~~ 我们亦借此机会,通过在标题中添加 shell 变量名 HOSTNAME,让标题变得活泼有趣些。 这个变量名是这台机器的网络名称。 * * * 注意:实际上,shell 确实提供了一种方法,通过使用带有-r(只读)选项的内部命令 declare, 来强制常量的不变性。如果我们给 TITLE 这样赋值: 那么 shell 会阻止之后给 TITLE 的任意赋值。这个功能极少被使用,但为了很早之前的脚本, 它仍然存在。 * * * ### 给变量和常量赋值 这里是我们真正开始使用参数扩展知识的地方。正如我们所知道的,这样给变量赋值: ~~~ variable=value ~~~ 这里的variable是变量的名字,value是一个字符串。不同于一些其它的编程语言,shell 不会 在乎变量值的类型;它把它们都看作是字符串。通过使用带有-i 选项的 declare 命令,你可以强制 shell 把 赋值限制为整型,但是,正如像设置变量为只读一样,极少这样做。 注意在赋值过程中,变量名,等号和变量值之间必须没有空格。那么,这些值由什么组成呢? 可以展开成字符串的任意值: ~~~ a=z # Assign the string "z" to variable a. b="a string" # Embedded spaces must be within quotes. c="a string and $b" # Other expansions such as variables can be # expanded into the assignment. d=$(ls -l foo.txt) # Results of a command. e=$((5 * 7)) # Arithmetic expansion. f="\t\ta string\n" # Escape sequences such as tabs and newlines. ~~~ 可以在同一行中对多个变量赋值: ~~~ a=5 b="a string" ~~~ 在参数展开过程中,变量名可能被花括号 “{}” 包围着。由于变量名周围的上下文,其变得不明确的情况下, 这会很有帮助。这里,我们试图把一个文件名从 myfile 改为 myfile1,使用一个变量: ~~~ [me@linuxbox ~]$ filename="myfile" [me@linuxbox ~]$ touch $filename [me@linuxbox ~]$ mv $filename $filename1 mv: missing destination file operand after `myfile' Try `mv --help' for more information. ~~~ 这种尝试失败了,因为 shell 把 mv 命令的第二个参数解释为一个新的(并且空的)变量。通过这种方法 可以解决这个问题: ~~~ [me@linuxbox ~]$ mv $filename ${filename}1 ~~~ 通过添加花括号,shell 不再把末尾的1解释为变量名的一部分。 我们将利用这个机会来添加一些数据到我们的报告中,即创建包括的日期和时间,以及创建者的用户名: ~~~ #!/bin/bash # Program to output a system information page TITLE="System Information Report For $HOSTNAME" CURRENT_TIME=$(date +"%x %r %Z") TIME_STAMP="Generated $CURRENT_TIME, by $USER" echo " $TITLE

$TITLE

$TIME_STAMP

" ~~~ ## Here Documents 我们已经知道了两种不同的文本输出方法,两种方法都使用了 echo 命令。还有第三种方法,叫做 here document 或者 here script。一个 here document 是另外一种 I/O 重定向形式,我们 在脚本文件中嵌入正文文本,然后把它发送给一个命令的标准输入。它这样工作: ~~~ command << token text token ~~~ 这里的 command 是一个可以接受标准输入的命令名,token 是一个用来指示嵌入文本结束的字符串。 我们将修改我们的脚本,来使用一个 here document: ~~~ #!/bin/bash # Program to output a system information page TITLE="System Information Report For $HOSTNAME" CURRENT_TIME=$(date +"%x %r %Z") TIME_STAMP="Generated $CURRENT_TIME, by $USER" cat << _EOF_ $TITLE

$TITLE

$TIME_STAMP

_EOF_ ~~~ 取代 echo 命令,现在我们的脚本使用 cat 命令和一个 here document。这个字符串_EOF_(意思是“文件结尾”, 一个常见用法)被选作为 token,并标志着嵌入文本的结尾。注意这个 token 必须在一行中单独出现,并且文本行中 没有末尾的空格。 那么使用一个 here document 的优点是什么呢?它很大程度上和 echo 一样,除了默认情况下,here documents 中的单引号和双引号会失去它们在 shell 中的特殊含义。这里有一个命令中的例子: ~~~ [me@linuxbox ~]$ foo="some text" [me@linuxbox ~]$ cat << _EOF_ > $foo > "$foo" > '$foo' > \$foo > _EOF_ some text "some text" 'some text' $foo ~~~ 正如我们所见到的,shell 根本没有注意到引号。它把它们看作是普通的字符。这就允许我们 在一个 here document 中可以随意的嵌入引号。对于我们的报告程序来说,这将是非常方便的。 Here documents 可以和任意能接受标准输入的命令一块使用。在这个例子中,我们使用了 一个 here document 将一系列的命令传递到这个 ftp 程序中,为的是从一个远端 FTP 服务器中得到一个文件: ~~~ #!/bin/bash # Script to retrieve a file via FTP FTP_SERVER=ftp.nl.debian.org FTP_PATH=/debian/dists/lenny/main/installer-i386/current/images/cdrom REMOTE_FILE=debian-cd_info.tar.gz ftp -n << _EOF_ open $FTP_SERVER user anonymous me@linuxbox cd $FTP_PATH hash get $REMOTE_FILE bye _EOF_ ls -l $REMOTE_FILE ~~~ 如果我们把重定向操作符从 “<<” 改为 “<<-”,shell 会忽略在此 here document 中开头的 tab 字符。 这就能缩进一个 here document,从而提高脚本的可读性: ~~~ #!/bin/bash # Script to retrieve a file via FTP FTP_SERVER=ftp.nl.debian.org FTP_PATH=/debian/dists/lenny/main/installer-i386/current/images/cdrom REMOTE_FILE=debian-cd_info.tar.gz ftp -n <<- _EOF_ open $FTP_SERVER user anonymous me@linuxbox cd $FTP_PATH hash get $REMOTE_FILE bye _EOF_ ls -l $REMOTE_FILE ~~~ ## 总结归纳 在这一章中,我们启动了一个项目,其带领我们领略了创建一个成功脚本的整个过程。 同时我们介绍了变量和常量的概念,以及怎样使用它们。它们是我们将找到的众多参数展开应用程序中的第一批实例。 我们也知道了怎样从我们的脚本文件中产生输出,及其各种各样嵌入文本块的方法。 ## 拓展阅读 * 关于 HTML 的更多信息,查看下面的文章和教材: [http://en.wikipedia.org/wiki/Html](http://en.wikipedia.org/wiki/Html) [http://en.wikibooks.org/wiki/HTML_Programming](http://en.wikibooks.org/wiki/HTML_Programming) [http://html.net/tutorials/html/](http://html.net/tutorials/html/) * Bash 手册包括一节“HERE DOCUMENTS”的内容,其详细的讲述了这个功能。
';

第二十五章:编写第一个shell脚本

最后更新于:2022-04-02 01:46:04

在前面的章节中,我们已经装备了一个命令行工具的武器库。虽然这些工具能够解决许多种计算问题, 但是我们仍然局限于在命令行中手动地一个一个使用它们。难道不是很棒,如果我们能够让 shell 来完成更多的工作? 我们可以的。通过把我们的工具一起放置到我们自己设计的程序中,然后 shell 就会自己来执行这些复杂的任务序列。 通过编写 shell 脚本,我们让 shell 来做这些事情。 ## 什么是 Shell 脚本? 最简单的解释,一个 shell 脚本就是一个包含一系列命令的文件。shell 读取这个文件,然后执行 文件中的所有命令,就好像这些命令已经直接被输入到了命令行中一样。 Shell 有些独特,因为它不仅是一个功能强大的命令行接口,也是一个脚本语言解释器。我们将会看到, 大多数能够在命令行中完成的任务也能够用脚本来实现,同样地,大多数能用脚本实现的操作也能够 在命令行中完成。 虽然我们已经介绍了许多 shell 功能,但只是集中于那些经常直接在命令行中使用的功能。 Shell 也提供了一些通常(但不总是)在编写程序时才使用的功能。 ## 怎样编写一个 Shell 脚本 为了成功地创建和运行一个 shell 脚本,我们需要做三件事情: 1. 编写一个脚本。 Shell 脚本就是普通的文本文件。所以我们需要一个文本编辑器来书写它们。最好的文本 编辑器都会支持语法高亮,这样我们就能够看到一个脚本关键字的彩色编码视图。语法高亮会帮助我们查看某种常见 错误。为了编写脚本文件,vim,gedit,kate,和许多其它编辑器都是不错的候选者。 2. 使脚本文件可执行。 系统会相当挑剔不允许任何旧的文本文件被看作是一个程序,并且有充分的理由! 所以我们需要设置脚本文件的权限来允许其可执行。 3. 把脚本放置到 shell 能够找到的地方 当没有指定可执行文件明确的路径名时,shell 会自动地搜索某些目录, 来查找此可执行文件。为了最大程度的方便,我们会把脚本放到这些目录当中。 ## 脚本文件格式 为了保持编程传统,我们将创建一个 “hello world” 程序来说明一个极端简单的脚本。所以让我们启动 我们的文本编辑器,然后输入以下脚本: ~~~ #!/bin/bash # This is our first script. echo 'Hello World!' ~~~ 对于脚本中的最后一行,我们应该是相当的熟悉,仅仅是一个带有一个字符串参数的 echo 命令。 对于第二行也很熟悉。它看起来像一个注释,我们已经在许多我们检查和编辑过的配置文件中 看到过。关于 shell 脚本中的注释,它们也可以出现在文本行的末尾,像这样: ~~~ echo 'Hello World!' # This is a comment too ~~~ 文本行中,# 符号之后的所有字符都会被忽略。 类似于许多命令,这也在命令行中起作用: ~~~ [me@linuxbox ~]$ echo 'Hello World!' # This is a comment too Hello World! ~~~ 虽然很少在命令行中使用注释,但它们也能起作用。 我们脚本中的第一行文本有点儿神秘。它看起来它应该是一条注释,因为它起始于一个#符号,但是 它看起来太有意义,以至于不仅仅是注释。事实上,这个#!字符序列是一种特殊的结构叫做 shebang。 这个 shebang 被用来告诉操作系统将执行此脚本所用的解释器的名字。每个 shell 脚本都应该把这一文本行 作为它的第一行。 让我们把此脚本文件保存为 hello_world。 ## 可执行权限 下一步我们要做的事情是让我们的脚本可执行。使用 chmod 命令,这很容易做到: ~~~ [me@linuxbox ~]$ ls -l hello_world -rw-r--r-- 1 me me 63 2009-03-07 10:10 hello_world [me@linuxbox ~]$ chmod 755 hello_world [me@linuxbox ~]$ ls -l hello_world -rwxr-xr-x 1 me me 63 2009-03-07 10:10 hello_world ~~~ 对于脚本文件,有两个常见的权限设置;权限为755的脚本,则每个人都能执行,和权限为700的 脚本,只有文件所有者能够执行。注意为了能够执行脚本,脚本必须是可读的。 ## 脚本文件位置 当设置了脚本权限之后,我们就能执行我们的脚本了: ~~~ [me@linuxbox ~]$ ./hello_world Hello World! ~~~ 为了能够运行此脚本,我们必须指定脚本文件明确的路径。如果我们没有那样做,我们会得到这样的提示: ~~~ [me@linuxbox ~]$ hello_world bash: hello_world: command not found ~~~ 为什么会这样呢?什么使我们的脚本不同于其它的程序?结果证明,什么也没有。我们的 脚本没有问题。是脚本存储位置的问题。回到第12章,我们讨论了 PATH 环境变量及其它在系统 查找可执行程序方面的作用。回顾一下,如果没有给出可执行程序的明确路径名,那么系统每次都会 搜索一系列的目录,来查找此可执行程序。这个/bin 目录就是其中一个系统会自动搜索的目录。 这个目录列表被存储在一个名为 PATH 的环境变量中。这个 PATH 变量包含一个由冒号分隔开的目录列表。 我们可以查看 PATH 的内容: ~~~ [me@linuxbox ~]$ echo $PATH /home/me/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin: /bin:/usr/games ~~~ 这里我们看到了我们的目录列表。如果我们的脚本驻扎在此列表中任意目录下,那么我们的问题将 会被解决。注意列表中的第一个目录,/home/me/bin。大多数的 Linux 发行版会配置 PATH 变量,让其包含 一个位于用户家目录下的 bin 目录,从而允许用户能够执行他们自己的程序。所以如果我们创建了 一个 bin 目录,并把我们的脚本放在这个目录下,那么这个脚本就应该像其它程序一样开始工作了: ~~~ [me@linuxbox ~]$ mkdir bin [me@linuxbox ~]$ mv hello_world bin [me@linuxbox ~]$ hello_world Hello World! ~~~ 它的确工作了。 如果这个 PATH 变量不包含这个目录,我们能够轻松地添加它,通过在我们的.bashrc 文件中包含下面 这一行文本: ~~~ export PATH=~/bin:"$PATH" ~~~ 当做了这个修改之后,它会在每个新的终端会话中生效。为了把这个修改应用到当前的终端会话中, 我们必须让 shell 重新读取这个 .bashrc 文件。这可以通过 “sourcing”.bashrc 文件来完成: ~~~ [me@linuxbox ~]$ . .bashrc ~~~ 这个点(.)命令是 source 命令的同义词,一个 shell 内部命令,用来读取一个指定的 shell 命令文件, 并把它看作是从键盘中输入的一样。 * * * 注意:在 Ubuntu 系统中,如果存在 ~/bin 目录,当执行用户的 .bashrc 文件时, Ubuntu 会自动地添加这个 ~/bin 目录到 PATH 变量中。所以在 Ubuntu 系统中,如果我们创建 了这个 ~/bin 目录,随后退出,然后再登录,一切会正常运行。 * * * ### 脚本文件的好去处 这个 ~/bin 目录是存放为个人所用脚本的好地方。如果我们编写了一个脚本,系统中的每个用户都可以使用它, 那么这个脚本的传统位置是 /usr/local/bin。系统管理员使用的脚本经常放到 /usr/local/sbin 目录下。 大多数情况下,本地支持的软件,不管是脚本还是编译过的程序,都应该放到 /usr/local 目录下, 而不是在 /bin 或 /usr/bin 目录下。这些目录都是由 Linux 文件系统层次结构标准指定,只包含由 Linux 发行商 所提供和维护的文件。 ## 更多的格式技巧 严肃认真的脚本书写,一个关键目标是为了维护方便;也就是说,一个脚本可以轻松地被作者或其它 用户修改,使它适应变化的需求。使脚本容易阅读和理解是一种方便维护的方法。 ### 长选项名称 我们学过的许多命令都以长短两种选项名称为特征。例如,这个 ls 命令有许多选项既可以用短形式也 可以用长形式来表示。例如: ~~~ [me@linuxbox ~]$ ls -ad ~~~ 和: ~~~ [me@linuxbox ~]$ ls --all --directory ~~~ 是等价的命令。为了减少输入,当在命令行中输入选项的时候,短选项更受欢迎,但是当书写脚本的时候, 长选项能提供可读性。 ### 缩进和行继续符 当雇佣长命令的时候,通过把命令在几个文本行中展开,可以提高命令的可读性。 在第十八章中,我们看到了一个特别长的 find 命令实例: ~~~ [me@linuxbox ~]$ find playground \( -type f -not -perm 0600 -exec chmod 0600 ‘{}’ ‘;’ \) -or \( -type d -not -perm 0711 -exec chmod 0711 ‘{}’ ‘;’ \) ~~~ 显然,这个命令有点儿难理解,当第一眼看到它的时候。在脚本中,这个命令可能会比较容易 理解,如果这样书写它: ~~~ find playground \ \( \ -type f \ -not -perm 0600 \ -exec chmod 0600 ‘{}’ ‘;’ \ \) \ -or \ \( \ -type d \ -not -perm 0711 \ -exec chmod 0711 ‘{}’ ‘;’ \ \) ~~~ 通过使用行继续符(反斜杠-回车符序列)和缩进,这个复杂命令的逻辑性更清楚地描述给读者。 这个技巧在命令行中同样生效,虽然很少使用它,因为输入和编辑这个命令非常麻烦。脚本和 命令行的一个区别是,脚本可能雇佣 tab 字符拉实现缩进,然而命令行却不能,因为 tab 字符被用来 激活自动补全功能。 > 为书写脚本配置 vim > > 这个 vim 文本编辑器有许多许多的配置设置。有几个常见的选项能够有助于脚本书写: > > :syntax on > > 打开语法高亮。通过这个设置,当查看脚本的时候,不同的 shell 语法元素会以不同的颜色 显示。这对于识别某些编程错误很有帮助。并且它看起来也很酷。注意为了这个功能起作用,你 必须安装了一个完整的 vim 版本,并且你编辑的文件必须有一个 shebang,来说明这个文件是 一个 shell 脚本。如果对于上面的命令,你遇到了困难,试试 :set syntax=sh。 > > :set hlsearch > > 打开这个选项是为了高亮查找结果。比如说我们查找单词“echo”。通过设置这个选项,这个 单词的每个实例会高亮显示。 > > :set tabstop=4 > > > 设置一个 tab 字符所占据的列数。默认是8列。把这个值设置为4(一种常见做法), 从而让长文本行更容易适应屏幕。 > > :set autoindent > > 打开 “auto indent” 功能。这导致 vim 能对新的文本行缩进与刚输入的文本行相同的列数。 对于许多编程结构来说,这就加速了输入。停止缩进,输入 Ctrl-d。 > > 通过把这些命令(没有开头的冒号字符)添加到你的 ~/.vimrc 文件中,这些改动会永久生效。 ## 总结归纳 在这脚本编写的第一章中,我们已经看过怎样编写脚本,怎样让它们在我们的系统中轻松地执行。 我们也知道了怎样使用各种格式技巧来提高脚本的可读性(可维护性)。在以后的各章中,轻松维护 会作为编写好脚本的中心法则一次又一次地出现。 ## 拓展阅读 * 查看各种各样编程语言的“Hello World”程序和实例: [http://en.wikipedia.org/wiki/Hello_world](http://en.wikipedia.org/wiki/Hello_world) * 这篇 Wikipedia 文章讨论了更多关于 shebang 机制的内容: [http://en.wikipedia.org/wiki/Shebang_(Unix)](http://en.wikipedia.org/wiki/Shebang_(Unix))
';

第二十四章:编译程序

最后更新于:2022-04-02 01:46:02

在这一章中,我们将看一下如何通过编译源代码来创建程序。源代码的可用性是至关重要的自由,从而使得 Linux 成为可能。 整个 Linux 开发生态圈就是依赖于开发者之间的自由交流。对于许多桌面用户来说,编译是一种失传的艺术。以前很常见, 但现在,由系统发行版提供商维护巨大的预编译的二进制仓库,准备供用户下载和使用。在写这篇文章的时候, Debian 仓库(最大的发行版之一)包含了几乎23,000个预编译的包。 那么为什么要编译软件呢? 有两个原因: 1. 可用性。尽管系统发行版仓库中已经包含了大量的预编译程序,但是一些发行版本不可能包含所有期望的应用。 在这种情况下,得到所期望程序的唯一方式是编译程序源码。 2. 及时性。虽然一些系统发行版专门打包前沿版本的应用程序,但是很多不是。这意味着, 为了拥有一个最新版本的程序,编译是必需的。 从源码编译软件可以变得非常复杂且具有技术性;许多用户难以企及。然而,许多编译任务是 相当简单的,只涉及到几个步骤。这都取决于程序包。我们将看一个非常简单的案例, 为的是给大家提供一个对编译过程的整体认识,并为那些愿意进一步学习的人们构筑一个起点。 我们将介绍一个新命令: > * make - 维护程序的工具 ## 什么是编译? 简而言之,编译就是把源码(一个由程序员编写的人类可读的程序描述)翻译成计算机处理器的母语的过程。 计算机处理器(或 CPU)工作在一个非常基本的水平,执行用机器语言编写的程序。这是一种数值编码,描述非常小的操作, 比如“加这个字节”,“指向内存中的这个位置”,或者“复制这个字节”。 这些指令中的每一条都是用二进制表示的(1和0)。最早的计算机程序就是用这种数值编码写成的,这可能就 解释了为什么编写它们的程序员据说吸很多烟,喝大量咖啡,并带着厚厚的眼镜。这个问题克服了,随着汇编语言的出现, 汇编语言代替了数值编码(略微)简便地使用助记符,比如 CPY(复制)和 MOV(移动)。用汇编语言编写的程序通过 汇编器处理为机器语言。今天为了完成某些特定的程序任务,汇编语言仍在被使用,例如设备驱动和嵌入式系统。 下一步我们谈论一下什么是所谓的高级编程语言。之所以这样称呼它们,是因为它们可以让程序员少操心处理器的 一举一动,而更多关心如何解决手头的问题。早期的高级语言(二十世纪60年代期间研发的)包括 FORTRAN(为科学和技术问题而设计)和 COBOL(为商业应用而设计)。今天这两种语言仍在有限的使用。 虽然有许多流行的编程语言,两个占主导地位。大多数为现代系统编写的程序,要么用 C 编写,要么是用 C++ 编写。 在随后的例子中,我们将编写一个 C 程序。 用高级语言编写的程序,经过另一个称为编译器的程序的处理,会转换成机器语言。一些编译器把 高级指令翻译成汇编语言,然后使用一个汇编器完成翻译成机器语言的最后阶段。 一个称为链接的过程经常与编译结合在一起。有许多程序执行的常见任务。以打开文件为例。许多程序执行这个任务, 但是让每个程序实现它自己的打开文件功能,是很浪费资源的。更有意义的是,拥有单独的一段知道如何打开文件的程序, 并允许所有需要它的程序共享它。对常见任务提供支持由所谓的库完成。这些库包含多个程序,每个程序执行 一些可以由多个程序共享的常见任务。如果我们看一下 /lib 和 /usr/lib 目录,我们可以看到许多库定居在那里。 一个叫做链接器的程序用来在编译器的输出结果和要编译的程序所需的库之间建立连接。这个过程的最终结果是 一个可执行程序文件,准备使用。 ### 所有的程序都是可编译的吗? 不是。正如我们所看到的,有些程序比如 shell 脚本就不需要编译。它们直接执行。 这些程序是用所谓的脚本或解释型语言编写的。近年来,这些语言变得越来越流行,包括 Perl, Python,PHP,Ruby,和许多其它语言。 脚本语言由一个叫做解释器的特殊程序执行。一个解释器输入程序文件,读取并执行程序中包含的每一条指令。 通常来说,解释型程序执行起来要比编译程序慢很多。这是因为每次解释型程序执行时,程序中每一条源码指令都需要翻译, 而一个编译程序,一条源码指令只翻译一次,翻译后的指令会永久地记录到最终的执行文件中。 那么为什么解释型程序这样流行呢?对于许多编程任务来说,原因是“足够快”,但是真正的优势是一般来说开发解释型程序 要比编译程序快速且容易。通常程序开发需要经历一个不断重复的写码,编译,测试周期。随着程序变得越来越大, 编译阶段会变得相当耗时。解释型语言删除了编译步骤,这样就加快了程序开发。 ## 编译一个 C 语言 让我们编译一些东西。在我们行动之前,然而我们需要一些工具,像编译器,链接器,还有 make。 在 Linux 环境中,普遍使用的 C 编译器叫做 gcc(GNU C 编译器),最初由 Richard Stallman 写出来的。 大多数 Linux 系统发行版默认不安装 gcc。我们可以这样查看该编译器是否存在: ~~~ [me@linuxbox ~]$ which gcc /usr/bin/gcc ~~~ 在这个例子中的输出结果表明安装了 gcc 编译器。 * * * 小提示: 你的系统发行版可能有一个用于软件开发的 meta-package(软件包的集合)。如果是这样的话, 考虑安装它,若你打算在你的系统中编译程序。若你的系统没有提供一个 meta-package,试着安装 gcc 和 make 工具包。 在许多发行版中,这就足够完成下面的练习了。 — ### 得到源码 为了我们的编译练习,我们将编译一个叫做 diction 的程序,来自 GNU 项目。这是一个小巧方便的程序, 检查文本文件的书写质量和样式。就程序而言,它相当小,且容易创建。 遵照惯例,首先我们要创建一个名为 src 的目录来存放我们的源码,然后使用 ftp 协议把源码下载下来。 ~~~ [me@linuxbox ~]$ mkdir src [me@linuxbox ~]$ cd src [me@linuxbox src]$ ftp ftp.gnu.org Connected to ftp.gnu.org. 220 GNU FTP server ready. Name (ftp.gnu.org:me): anonymous 230 Login successful. Remote system type is UNIX. Using binary mode to transfer files. ftp> cd gnu/diction 250 Directory successfully changed. ftp> ls 200 PORT command successful. Consider using PASV. 150 Here comes the directory listing. -rw-r--r-- 1 1003 65534 68940 Aug 28 1998 diction-0.7.tar.gz -rw-r--r-- 1 1003 65534 90957 Mar 04 2002 diction-1.02.tar.gz -rw-r--r-- 1 1003 65534 141062 Sep 17 2007 diction-1.11.tar.gz 226 Directory send OK. ftp> get diction-1.11.tar.gz local: diction-1.11.tar.gz remote: diction-1.11.tar.gz 200 PORT command successful. Consider using PASV. 150 Opening BINARY mode data connection for diction-1.11.tar.gz (141062 bytes). 226 File send OK. 141062 bytes received in 0.16 secs (847.4 kB/s) ftp> bye 221 Goodbye. [me@linuxbox src]$ ls diction-1.11.tar.gz ~~~ * * * 注意:因为我们是这个源码的“维护者”,当我们编译它的时候,我们把它保存在 ~/src 目录下。 由你的系统发行版源码会把源码安装在 /usr/src 目录下,而供多个用户使用的源码,通常安装在 /usr/local/src 目录下。 * * * 正如我们所看到的,通常提供的源码形式是一个压缩的 tar 文件。有时候称为 tarball,这个文件包含源码树, 或者是组成源码的目录和文件的层次结构。当到达 ftp 站点之后,我们检查可用的 tar 文件列表,然后选择最新版本,下载。 使用 ftp 中的 get 命令,我们把文件从 ftp 服务器复制到本地机器。 一旦 tar 文件下载下来之后,必须打开。通过 tar 程序可以完成: ~~~ [me@linuxbox src]$ tar xzf diction-1.11.tar.gz [me@linuxbox src]$ ls diction-1.11 diction-1.11.tar.gz ~~~ * * * 小提示:该 diction 程序,像所有的 GNU 项目软件,遵循着一定的源码打包标准。其它大多数在 Linux 生态系统中 可用的源码也遵循这个标准。该标准的一个条目是,当源码 tar 文件打开的时候,会创建一个目录,该目录包含了源码树, 并且这个目录将会命名为 project-x.xx,其包含了项目名称和它的版本号两项内容。这种方案能在系统中方便安装同一程序的多个版本。 然而,通常在打开 tarball 之前检验源码树的布局是个不错的主意。一些项目不会创建该目录,反而,会把文件直接传递给当前目录。 这会把你的(除非组织良好的)src 目录弄得一片狼藉。为了避免这个,使用下面的命令,检查 tar 文件的内容: ~~~ tar tzvf tarfile | head --- ~~~ ## 检查源码树 打开该 tar 文件,会创建一个新的目录,名为 diction-1.11。这个目录包含了源码树。让我们看一下里面的内容: ~~~ [me@linuxbox src]$ cd diction-1.11 [me@linuxbox diction-1.11]$ ls config.guess diction.c getopt.c nl config.h.in diction.pot getopt.h nl.po config.sub diction.spec getopt_int.h README configure diction.spec.in INSTALL sentence.c configure.in diction.texi.in install-sh sentence.h COPYING en Makefile.in style.1.in de en_GB misc.c style.c de.po en_GB.po misc.h test diction.1.in getopt1.c NEWS ~~~ 在源码树中,我们看到大量的文件。属于 GNU 项目的程序,还有其它许多程序都会,提供文档文件 README,INSTALL,NEWS,和 COPYING。 这些文件包含了程序描述,如何建立和安装它的信息,还有它许可条款。在试图建立程序之前,仔细阅读 README 和 INSTALL 文件,总是一个不错的主意。 在这个目录中,其它有趣的文件是那些以 .c 和 .h 为后缀的文件: ~~~ [me@linuxbox diction-1.11]$ ls *.c diction.c getopt1.c getopt.c misc.c sentence.c style.c [me@linuxbox diction-1.11]$ ls *.h getopt.h getopt_int.h misc.h sentence.h ~~~ 这些 .c 文件包含了由该软件包提供的两个 C 程序(style 和 diction),被分割成模块。这是一种常见做法,把大型程序 分解成更小,更容易管理的代码块。源码文件都是普通文本,可以用 less 命令查看: ~~~ [me@linuxbox diction-1.11]$ less diction.c ~~~ 这些 .h 文件以头文件而著称。它们也是普通文件。头文件包含了程序的描述,这些程序被包括在源码文件或库中。 为了让编译器链接到模块,编译器必须接受所需的所有模块的描述,来完成整个程序。在 diction.c 文件的开头附近, 我们看到这行代码: ~~~ #include "getopt.h" ~~~ 这行代码指示编译器去读取文件 getopt.h,因为它会读取 diction.c 中的源码,为的是“知道” getopt.c 中的内容。 getopt.c 文件提供由 style 和 diction 两个程序共享的代码。 在 getopt.h 的 include 语句上面,我们看到一些其它的 include 语句,比如这些: ~~~ #include #include #include #include #include ~~~ 这些也涉及到头文件,但是这些头文件居住在当前源码树的外面。它们由操作系统供给,来支持每个程序的编译。 如果我们看一下 /usr/include 目录,能看到它们: ~~~ [me@linuxbox diction-1.11]$ ls /usr/include ~~~ 当我们安装编译器的时候,这个目录中的头文件会被安装。 ### 构建程序 大多数程序通过一个简单的,两个命令的序列构建: ~~~ ./configure make ~~~ 这个 configure 程序是一个 shell 脚本,由源码树提供。它的工作是分析程序建立环境。大多数源码会设计为可移植的。 也就是说,它被设计成,能建立在多于一个的类 Unix 系统中。但是为了做到这一点,在建立程序期间,为了适应系统之间的差异, 源码可能需要经过轻微的调整。configure 也会检查是否安装了必要的外部工具和组件。让我们运行 configure 命令。 因为 configure 命令所在的位置不是位于 shell 通常期望程序所呆的地方,我们必须明确地告诉 shell 它的位置,通过 在命令之前加上 ./ 字符,来表明程序位于当前工作目录: ~~~ [me@linuxbox diction-1.11]$ ./configure ~~~ configure 将会输出许多信息,随着它测试和配置整个构建过程。当结束后,输出结果看起来像这样: ~~~ checking libintl.h presence... yes checking for libintl.h... yes checking for library containing gettext... none required configure: creating ./config.status config.status: creating Makefile config.status: creating diction.1 config.status: creating diction.texi config.status: creating diction.spec config.status: creating style.1 config.status: creating test/rundiction config.status: creating config.h [me@linuxbox diction-1.11]$ ~~~ 这里最重要的事情是没有错误信息。如果有错误信息,整个配置过程失败,然后程序不能构建直到修正了错误。 我们看到在我们的源码目录中 configure 命令创建了几个新文件。最重要一个是 Makefile。Makefile 是一个配置文件, 指示 make 程序究竟如何构建程序。没有它,make 程序就不能运行。Makefile 是一个普通文本文件,所以我们能查看它: ~~~ [me@linuxbox diction-1.11]$ less Makefile ~~~ 这个 make 程序把一个 makefile 文件作为输入(通常命名为 Makefile),makefile 文件 描述了包括最终完成的程序的各组件之间的关系和依赖性。 makefile 文件的第一部分定义了变量,这些变量在该 makefile 后续章节中会被替换掉。例如我们看看这一行代码: ~~~ CC= gcc ~~~ 其定义了所用的 C 编译器是 gcc。文件后面部分,我们看到一个使用该变量的实例: ~~~ diction: diction.o sentence.o misc.o getopt.o getopt1.o $(CC) -o $@ $(LDFLAGS) diction.o sentence.o misc.o \ getopt.o getopt1.o $(LIBS) ~~~ 这里完成了一个替换操作,在程序运行时,$(CC) 的值会被替换成 gcc。大多数 makefile 文件由行组成,每行定义一个目标文件, 在这种情况下,目标文件是指可执行文件 diction,还有目标文件所依赖的文件。剩下的行描述了从目标文件的依赖组件中 创建目标文件所需的命令。在这个例子中,我们看到可执行文件 diction(最终的成品之一)依赖于文件 diction.o,sentence.o,misc.o,getopt.o,和 getopt1.o都存在。在 makefile 文件后面部分,我们看到 diction 文件所依赖的每一个文件做为目标文件的定义: ~~~ diction.o: diction.c config.h getopt.h misc.h sentence.h getopt.o: getopt.c getopt.h getopt_int.h getopt1.o: getopt1.c getopt.h getopt_int.h misc.o: misc.c config.h misc.h sentence.o: sentence.c config.h misc.h sentence.h style.o: style.c config.h getopt.h misc.h sentence.h ~~~ 然而,我们不会看到针对它们的任何命令。这个由一个通用目标解决,在文件的前面,描述了这个命令,用来把任意的 .c 文件编译成 .o 文件: ~~~ .c.o: $(CC) -c $(CPPFLAGS) $(CFLAGS) $< ~~~ 这些看起来非常复杂。为什么不简单地列出所有的步骤,编译完成每一部分?一会儿就知道答案了。同时, 让我们运行 make 命令并构建我们的程序: ~~~ [me@linuxbox diction-1.11]$ make ~~~ 这个 make 程序将会运行,使用 Makefile 文件的内容来指导它的行为。它会产生很多信息。 当 make 程序运行结束后,现在我们将看到所有的目标文件出现在我们的目录中。 ~~~ [me@linuxbox diction-1.11]$ ls config.guess de.po en en_GB sentence.c config.h diction en_GB.mo en_GB.po sentence.h config.h.in diction.1 getopt1.c getopt1.o sentence.o config.log diction.1.in getopt.c getopt.h style config.status diction.c getopt_int.h getopt.o style.1 config.sub diction.o INSTALL install-sh style.1.in configure diction.pot Makefile Makefile.in style.c configure.in diction.spec misc.c misc.h style.o COPYING diction.spec.in misc.o NEWS test de diction.texi nl nl.mo de.mo diction.texi.i nl.po README ~~~ 在这些文件之中,我们看到 diction 和 style,我们开始要构建的程序。恭喜一切正常!我们刚才源码编译了 我们的第一个程序。但是出于好奇,让我们再运行一次 make 程序: ~~~ [me@linuxbox diction-1.11]$ make make: Nothing to be done for `all'. ~~~ 它只是产生这样一条奇怪的信息。怎么了?为什么它没有重新构建程序呢?啊,这就是 make 奇妙之处了。make 只是构建 需要构建的部分,而不是简单地重新构建所有的内容。由于所有的目标文件都存在,make 确定没有任何事情需要做。 我们可以证明这一点,通过删除一个目标文件,然后再次运行 make 程序,看看它做些什么。让我们去掉一个中间目标文件: ~~~ [me@linuxbox diction-1.11]$ rm getopt.o [me@linuxbox diction-1.11]$ make ~~~ 我们看到 make 重新构建了 getopt.o 文件,并重新链接了 diction 和 style 程序,因为它们依赖于丢失的模块。 这种行为也指出了 make 程序的另一个重要特征:它保持目标文件是最新的。make 坚持目标文件要新于它们的依赖文件。 这个非常有意义,做为一名程序员,经常会更新一点儿源码,然后使用 make 来构建一个新版本的成品。make 确保 基于更新的代码构建了需要构建的内容。如果我们使用 touch 程序,来“更新”其中一个源码文件,我们看到发生了这样的事情: ~~~ [me@linuxboxdiction-1.11]$ ls -l diction getopt.c -rwxr-xr-x 1 me me 37164 2009-03-05 06:14 diction -rw-r--r-- 1 me me 33125 2007-03-30 17:45 getopt.c [me@linuxboxdiction-1.11]$ touch getopt.c [me@linuxboxdiction-1.11]$ ls -l diction getopt.c -rwxr-xr-x 1 me me 37164 2009-03-05 06:14 diction -rw-r--r-- 1 me me 33125 2009-03-05 06:23 getopt.c [me@linuxbox diction-1.11]$ make ~~~ 运行 make 之后,我们看到目标文件已经更新于它的依赖文件: ~~~ [me@linuxbox diction-1.11]$ ls -l diction getopt.c -rwxr-xr-x 1 me me 37164 2009-03-05 06:24 diction -rw-r--r-- 1 me me 33125 2009-03-05 06:23 getopt.c ~~~ make 程序这种智能地只构建所需要构建的内容的特性,对程序来说,是巨大的福利。虽然在我们的小项目中,节省的时间可能 不是非常明显,在庞大的工程中,它具有非常重大的意义。记住,Linux 内核(一个经历着不断修改和改进的程序)包含了几百万行代码。 ### 安装程序 打包良好的源码经常包括一个特别的 make 目标文件,叫做 install。这个目标文件将在系统目录中安装最终的产品,以供使用。 通常,这个目录是 /usr/local/bin,为在本地所构建软件的传统安装位置。然而,通常普通用户不能写入该目录,所以我们必须变成超级用户, 来执行安装操作: ~~~ [me@linuxbox diction-1.11]$ sudo make install After we perform the installation, we can check that the program is ready to go: [me@linuxbox diction-1.11]$ which diction /usr/local/bin/diction [me@linuxbox diction-1.11]$ man diction And there we have it! ~~~ ## 总结 在这一章中,我们已经知道了三个简单命令: ~~~ ./configure make make install ~~~ 可以用来构建许多源码包。我们也知道了在程序维护过程中,make 程序起到了举足轻重的作用。make 程序可以用到 任何需要维护一个目标/依赖关系的任务中,不仅仅为了编译源代码。 ## 拓展阅读 * Wikipedia 上面有关于编译器和 make 程序的好文章: [http://en.wikipedia.org/wiki/Compiler](http://en.wikipedia.org/wiki/Compiler) [http://en.wikipedia.org/wiki/Make_(software)](http://en.wikipedia.org/wiki/Make_(software)) * GNU Make 手册 [http://www.gnu.org/software/make/manual/html_node/index.html](http://www.gnu.org/software/make/manual/html_node/index.html)
';

第二十三章:打印

最后更新于:2022-04-02 01:45:59

前几章我们学习了如何操控文本,下面要做的是将文本呈于纸上。在这章中,我们将会着手用于打印文件和控制打印选项的命令行工具。通常不同发行版的打印配置各有不同且都会在其安装时自动完成,因此这里我们不讨论打印的配置过程。本章的练习需要一台正确配置的打印机来完成。 我们将讨论一下命令: > * pr —— 转换需要打印的文本文件 > * lpr —— 打印文件 > * lp —— 打印文件(System V) > * a2ps —— 为 PostScript 打印机格式化文件 > * lpstat —— 显示打印机状态信息 > * lpq —— 显示打印机队列状态 > * lprm —— 取消打印任务 > * cancel —— 取消打印任务(System V) ## 打印简史 为了较好的理解类 Unix 操作系统中的打印功能,我们必须先了解一些历史。类 Unix 系统中的打印可追溯到操作系统本身的起源,那时候打印机和它的用法与今天截然不同。 ### 早期的打印 和计算机一样,前 PC 时代的打印机都很大、很贵,并且很集中。1980年的计算机用户都是在离电脑很远的地方用一个连接电脑的终端来工作的,而打印机就放在电脑旁并受到计算机管理员的全方位监视。 由于当时打印机既昂贵又集中,而且都工作在早期的 Unix 环境下,人们从实际考虑通常都会多人共享一台打印机。为了区别不同用户的打印任务,每个打印任务的开头都会打印一张写着用户名字的标题页,然后计算机工作人员会用推车装好当天的打印任务并分发给每个用户。 ### 基于字符的打印机 80年代的打印机技术有两方面的不同。首先,那时的打印机基本上都是打击式打印机。打击式打印机使用撞针打击色带的机械结构在纸上形成字符。这种流行的技术造就了当时的菊轮式打印和点阵式打印。 其次,更重要的是,早期打印机的特点是它使用设备内部固定的一组字符集。比如,一台菊轮式打印机只能打印固定在其菊花轮花瓣上的字符,就这点而言打印机更像是高速打字机。大部分打字机都使用等宽字体,意思是说每个字符的宽度相等,页面上只有固定的区域可供打印,而这些区域只能容纳固定的字符数。大部分打印机采用横向10字符每英寸(CPI)和纵向6行每英寸(LPI)的规格打印,这样一张美式信片纸就有横向85字符宽纵向66行高,加上两侧的页边距,一行的最大宽度可达80字符。据此,使用等宽字体就能提供所见即所得(WYSIWYG,What You See Is What You Get)的打印预览。 接着,一台类打字机的打印机会收到以简单字节流的形式传送来的数据,其中就包含要打印的字符。例如要打印一个字母a,计算机就会发送 ASCII 码97,如果要移动打印机的滑动架和纸张,就需要使用回车、换行、换页等的小编号 ASCII 控制码。使用控制码,还能实现一些之前受限制的字体效果,比如粗体,就是让打印机先打印一个字符,然后退格再打印一遍来得到颜色较深的效果的。用 nroff 来产生一个手册页然后用 cat -A 检查输出,我们就能亲眼看看这种效果了: ~~~ [me@linuxbox ~]$ zcat /usr/share/man/man1/ls.1.gz | nroff -man | cat -A | head LS(1) User Commands LS(1) $ N^HNA^HAM^HME^HE$ ls - list directory contents$ $ S^HSY^HYN^HNO^HOP^HPS^HSI^HIS^HS$ l^Hls^Hs [_^HO_^HP_^HT_^HI_^HO_^HN]... [_^HF_^HI_^HL_^HE]...$ ~~~ ^H(ctrl-H)字符是用于打印粗体效果的退格符。同样,我们还可以看到用于打印下划线效果的[退格/下划线]序列。 ### 图形化打印机 图形用户界面(GUI)的发展催生了打印机技术中主要的变革。随着计算机的展现步入更多以图形为基础的方式,打印技术也从基于字符走向图形化技术,这一切都是源于激光打印机的到来,它不仅廉价,还可以在打印区域的任意位置打印微小的墨点,而不是使用固定的字符集。这让打印机能够打印成比例的字体(像用排字机那样),甚至是图片和高质量图表。 然而,从基于字符的方式到转移到图形化的方式提出了一个严峻的技术挑战。原因如下:使用基于字符的打印机时,填满一张纸所用的字节数可以这样计算出来(假设一张纸有60行,每行80个字符):60 × 80 = 4800字节。 相比之下,用一台300点每英寸(DPI)分辨率的激光打印机(假设一张纸有8乘10英寸的打印区域)打印则需要 (8 × 300) × (10 × 300) / 8 = 900,000字节。 当时许多慢速的个人电脑网络无法接受激光打印机打印一页需要传输将近1兆的数据这一点,因此,很有必要发明一种更聪明的方法。 这种发明便是页面描述语言(PDL)。PDL 是一种描述页面内容的编程语言。简单的说就是,“到这个地方,印一个10点大小的黑体字符 a ,到这个地方。。。” 这样直到页面上的所有内容都描述完了。第一种主要的 PDL 是 Adobe 系统开发的 PostScript,直到今天,这种语言仍被广泛使用。PostScript 是专为印刷各类图形和图像设计的完整的编程语言,它内建支持35种标准的高质量字体,在工作是还能够接受其他的字体定义。最早,对 PostScript 的支持是打印机本身内建的。这样传输数据的问题就解决了。相比基于字符打印机的简单字节流,典型的 PostScript 程序更为详细,而且比表示整个页面的字节数要小很多。 一台 PostScript 打印机接受 PostScript 程序作为输入。打印机有自己的处理器和内存(通常这让打印机比连接它的计算机更为强大),能执行一种叫做 PostScript 解析器的特殊程序用于读取输入的 PostScript 程序并生成结果导入打印机的内存,这样就形成了要转移到纸上的位(点)图。这种将页面渲染成大型位图(bitmap)的过程有个通用名称作光栅图像处理器(raster image processor),又叫 RIP。 多年之后,电脑和网络都变得更快了。这使得 RIP 技术从打印机转移到了主机上,还让高品质打印机变得更便宜了。 现在的许多打印机仍能接受基于字符的字节流,但很多廉价的打印机却不支持,因为它们依赖于主机的 RIP 提供的比特流来作为点阵打印。当然也有不少仍旧是 PostScript 打印机。 ## 在 Linux 下打印 当前 Linux 系统采用两套软件配合显示和管理打印。第一,CUPS(Common Unix Printing System,一般 Unix 打印系统),用于提供打印驱动和打印任务管理;第二,Ghostscript,一种 PostScript 解析器,作为 RIP 使用。 CUPS 通过创建并维护打印队列来管理打印机。如前所述,Unix 下的打印原本是设计成多用户共享中央打印机的管理模式的。由于打印机本身比连接到它的电脑要慢,打印系统就需要对打印任务进行调度使其保持顺序。CUPS 还能识别出不同类型的数据(在合理范围内)并转换文件为可打印的格式。 ## 为打印准备文件 作为命令行用户,尽管打印各种格式的文本都能实现,不过打印最多的,还是文本。 ### pr - 转换需要打印的文本文件 前面的章节我们也有提到过 pr 命令,现在我们来探讨一下这条命令结合打印使用的一些选项。我们知道,在打印的历史上,基于字符的打印机曾经用过等宽字体,致使每页只能打印固定的行数和字符数,而 pr 命令则能够根据不同的页眉和页边距排列文本使其适应指定的纸张。表23-1总结了最常用的选项。 表23-1: 常用 pr 选项 | 选项 | 描述 | |-------|----------| | +first[:last] | 输出从 first 到 last(默认为最后)范围内的页面。 | | -columns | 根据 columns 指定的列数排版页面内容。 | | -a | 默认多列输出为垂直,用 -a (across)可使其水平输出。 | | -d | 双空格输出。 | | -D format | 用 format 指定的格式修改页眉中显示的日期,日期命令中 format 字符串的描述详见参考手册。 | | -f | 改用换页替换默认的回车来分割页面。 | | -h header | 在页眉中部用 header 参数替换打印文件的名字。 | | -l length | 设置页长为 length,默认为66行(每英寸6行的美国信纸)。 | | -n | 输出行号。 | | -o offset | 创建一个宽 offset 字符的左页边。 | | -w width | 设置页宽为 width,默认为72字符。 | 我们通常用管道配合 pr 命令来做筛选。下面的例子中我们会列出目录 /usr/bin 并用 pr 将其格式化为3列输出的标题页: ~~~ [me@linuxbox ~]$ ls /usr/bin | pr -3 -w 65 | head 2012-02-18 14:00 Page 1 [ apturl bsd-write 411toppm ar bsh a2p arecord btcflash a2ps arecordmidi bug-buddy a2ps-lpr-wrapper ark buildhash ~~~ ## 将打印任务送至打印机 CUPS 打印体系支持两种曾用于类 Unix 系统的打印方式。一种,叫 Berkeley 或 LPD(用于 Unix 的 Berkeley 软件发行版),使用 lpr 程序;另一种,叫 SysV(源自 System V 版本的 Unix),使用 lp 程序。这两个程序的功能大致相同。具体使用哪个完全根据个人喜好。 ### lpr - 打印文件(Berkeley 风格) lpr 程序可以用来把文件传送给打印机。由于它能接收标准输入,所以能用管道来协同工作。例如,要打印刚才多列目录列表的结果,我们只需这样: ~~~ [me@linuxbox ~]$ ls /usr/bin | pr -3 | lpr ~~~ 报告会送到系统默认的打印机,如果要送到别的打印机,可以使用 -P 参数: ~~~ lpr -P printer_name ~~~ printer_name 表示这台打印机的名称。若要查看系统已知的打印机列表: ~~~ [me@linuxbox ~]$ lpstat -a ~~~ 注意:许多 Linux 发行版允许你定义一个输出 PDF 文件但不执行实体打印的“打印机”,这可以用来很方便的检验你的打印命令。看看你的打印机配置程序是否支持这项配置。在某些发行版中,你可能要自己安装额外的软件包(如 cups-pdf)来使用这项功能。 表23-2显示了 lpr 的一些常用选项 表23-2: 常用 lpr 选项 | 选项 | 描述 | |-------|--------| | -# number | 设定打印份数为 number。 | | -p | 使每页页眉标题中带有日期、时间、工作名称和页码。这种所谓的“美化打印”选项可用于打印文本文件。 | | -P printer | 指定输出打印机的名称。未指定则使用系统默认打印机。 | | -r | 打印后删除文件。对程序产生的临时打印文件较为有用。 | ### lp - 打印文件(System V 风格) 和 lpr 一样,lp 可以接收文件或标准输入为打印内容。与 lpr 不同的是 lp 支持不同的选项(略为复杂),表23-3列出了其常用选项。 表23-3: 常用 lp 选项 | 选项 | 描述 | |-------|--------| | -d printer | 设定目标(打印机)为 printer。若d 选项未指定,则使用系统默认打印机。 | | -n number | 设定的打印份数为 number。 | | -o landscape | 设置输出为横向。 | | -o fitplot | 缩放文件以适应页面。打印图像时较为有用,如 JPEG 文件。 | | -o scaling=number | 缩放文件至 number。100表示填满页面,小于100表示缩小,大于100则会打印在多页上。 | | -o cpi=number | 设定输出为 number 字符每英寸。默认为10。 | | -o lpi=number | 设定输出为 number 行每英寸,默认为6。 | | -o page-bottom=points -o page-left=points -o page-right=points -o page-top=points | 设置页边距,单位为点,一种印刷上的单位。一英寸 =72点。 | | -P pages | 指定打印的页面。pages 可以是逗号分隔的列表或范围——例如 1,3,5,7-10。 | 再次打印我们的目录列表,这次我们设置12 CPI、8 LPI 和一个半英寸的左边距。注意这里我必须调整 pr 选项来适应新的页面大小: ~~~ [me@linuxbox ~]$ ls /usr/bin | pr -4 -w 90 -l 88 | lp -o page-left=36 -o cpi=12 -o lpi=8 ~~~ 这条命令用小于默认的格式产生了一个四列的列表。增加 CPI 可以让我们在页面上打印更多列。 ### 另一种选择:a2ps a2ps 程序很有趣。单从名字上看,这是个格式转换程序,但它的功能不止于此。程序名字的本意为 ASCII to PostScript,它是用来为 PostScript 打印机准备要打印的文本文件的。多年后,程序的功能得到了提升,名字的含义也变成了 Anything to PostScript。尽管名为格式转换程序,但它实际的功能却是打印。它的默认输出不是标准输出,而是系统的默认打印机。程序的默认行为被称为“漂亮的打印机”,这意味着它可以改善输出的外观。我们能用程序在桌面上创建一个 PostScript 文件: ~~~ [me@linuxbox ~]$ ls /usr/bin | pr -3 -t | a2ps -o ~/Desktop/ls.ps -L 66 [stdin (plain): 11 pages on 6 sheets] [Total: 11 pages on 6 sheets] saved into the file `/home/me/Desktop/ls.ps' ~~~ 这里我们用带 -t 参数(忽略页眉和页脚)的 pr 命令过滤数据流,然后用 a2ps 指定一个输出文件(-o 参数),并设定每页66行(-L 参数)来匹配 pr 的输出分页。用合适的文件查看器查看我们的输出文件,我们就会看到图23-1中显示的结果。 ![2015-06-23/55892daddd6c2](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-06-23_55892daddd6c2.png) 图 23-1: 浏览 a2ps 的输出结果 可以看到,默认的输出布局是一面两页的,这将导致两页的内容被打印到一张纸上。a2ps 还能利用页眉和页脚。 a2ps 有很多选项,总结在表23-4中。 表23-4: a2ps 选项 | 选项 | 描述 | |------|-------| | --center-title text | 设置中心页标题为 text。 | | --columns number | 将所有页面排列成 number 列。默认为2。 | | --footer text | 设置页脚为 text。 | | --guess | 报告参数中文件的类型。由于 a2ps 会转换并格式化所有类型的数据,所以当给定文件类型后,这个选项可以很好的用来判断 a2ps 应该做什么。 | | --left-footer text | 设置左页脚为 text。 | | --left-title text | 设置页面左标题为 text。 | | --line-numbers=interval | 每隔 interval 行输出行号。 | | --list=defauls | 显示默认设置。 | | --list=topic | 显示 topic 设置,topic 表示下列之一:代理程序(用来转换数据的外部程序),编码,特征,变量,媒介(页面大小等),ppd(PostScript 打印机描述信息),打印机,起始程序(为常规输出添加前缀的代码部分),样式表,或用户选项。 | | --pages range | 打印 range 范围内的页面。 | | --right-footer text | 设置右页脚为 text。 | | --right-title text | 设置页面右标题为 text。 | | --rows number | 将所有页面排列成 number 排。默认为1。 | | -B | 没有页眉。 | | -b text | 设置页眉为 text。 | | -f size | 使用字体大小为 size 号。 | | -l number | 设置每行字符数为 number。此项和 -L 选项(见下方)可以给文件用其他程序来更准确的分页,如 pr。 | | -L number | 设置每页行数为 number。 | | -M name | 使用打印媒介的名称——例如,A4。 | | -n number | 每页输出 number 份。 | | -o file | 输出到文件 file。如果指定为 - ,则输出到标准输出。 | | -P printer | 使用打印机 printer。如果未指定,则使用系统默认打印机。 | | -R | 纵向打印。 | | -r | 横向打印。 | | -T number | 设置制表位为每 number 字符。 | | -u text | 用 text 作为页面底图(水印)。 | 以上只是对 a2ps 的总结,更多的选项尚未列出。 注意:a2ps 目前仍在不断的开发中。就我的测试而言,不同版本之间都多少有所变化。CentOS 4 中输出总是默认为标准输出。在 CentOS 4 和 Fedora 10 中,尽管程序配置信纸为默认媒介,输出还是默认为 A4纸。我可以明确的指定需要的选项来解决这些问题。Ubuntu 8.04 中,a2ps 表现的正如参考文档中所述。 另外,我们也要注意到另一个转换文本为 PostScript 的输出格式化工具,名叫 enscript。它具有许多相同的格式化和打印功能,但和 a2ps 唯一的不同在于,它只能处理纯文本的输入。 ## 监视和控制打印任务 由于 Unix 打印系统的设计是能够处理多用户的多重打印任务,CUPS 也是如此设计的。每台打印机都有一个打印队列,其中的任务直到传送到打印机才停下并进行打印。CUPS 支持一些命令行程序来管理打印机状态和打印队列。想 lpr 和 lp 这样的管理程序都是以 Berkeley 和 System V 打印系统的相应程序为依据进行排列的。 ### lpstat - 显示打印系统状态 lpstat 程序可用于确定系统中打印机的名字和有效性。例如,我们系统中有一台实体打印机(名叫 printer)和一台 PDF 虚拟打印机(名叫 PDF),我们可以像这样查看打印机状态: ~~~ [me@linuxbox ~]$ lpstat -a PDF accepting requests since Mon 05 Dec 2011 03:05:59 PM EST printer accepting requests since Tue 21 Feb 2012 08:43:22 AM EST ~~~ 接着,我们可以查看打印系统更具体的配置信息: ~~~ [me@linuxbox ~]$ lpstat -s system default destination: printer device for PDF: cups-pdf:/ device for printer: ipp://print-server:631/printers/printer ~~~ 上例中,我们看到 printer 是系统默认的打印机,其本身是一台网络打印机,使用网络打印协议(ipp://)通过网络连接到名为 print-server 的系统。 lpstat 的常用选项列于表23-5。 表23-5: 常用 lpstat 选项 | 选项 | 描述 | |------|-------| | -a [printer...] | 显示 printer 打印机的队列。这里显示的状态是打印机队列承受任务的能力,而不是实体打印机的状态。若未指定打印机,则显示所有打印队列。 | | -d | 显示系统默认打印机的名称。 | | -p [printer...] | 显示 printer 指定的打印机的状态。若未指定打印机,则显示所有打印机状态。 | | -r | 显示打印系统的状态。 | | -s | 显示汇总状态。 | | -t | 显示完整状态报告。 | ### lpq - 显示打印机队列状态 使用 lpq 程序可以查看打印机队列的状态,从中我们可以看到队列的状态和所包含的打印任务。下面的例子显示了一台名叫 printer 的系统默认打印机包含一个空队列的情况: ~~~ [me@linuxbox ~]$ lpq printer is ready no entries ~~~ 如果我们不指定打印机(用 -P 参数),就会显示系统默认打印机。如果给打印机添加一项任务再查看队列,我们就会看到下列结果: ~~~ [me@linuxbox ~]$ ls *.txt | pr -3 | lp request id is printer-603 (1 file(s)) [me@linuxbox ~]$ lpq printer is ready and printing Rank Owner Job File(s) Total Size active me 603 (stdin) 1024 bytes ~~~ ### lprm 和 cancel - 取消打印任务 CUPS 提供两个程序来从打印队列中终止并移除打印任务。一个是 Berkeley 风格的(lprm),另一个是 System V 的(cancel)。在支持的选项上两者有较小的区别但是功能却几乎相同。以上面的打印任务为例,我们可以像这样终止并移除任务: ~~~ [me@linuxbox ~]$ cancel 603 [me@linuxbox ~]$ lpq printer is ready no entries ~~~ 每个命令都有选项可用于移除某用户、某打印机或多个任务号的所有任务,相应的参考手册中都有详细的介绍。
';

第二十二章:格式化输出

最后更新于:2022-04-02 01:45:57

在这章中,我们继续着手于文本相关的工具,关注那些用来格式化输出的程序,而不是改变文本自身。 这些工具通常让文本准备就绪打印,这是我们在下一章会提到的。我们在这章中会提到的工具有: > * nl – 添加行号 > * fold – 限制文件列宽 > * fmt – 一个简单的文本格式转换器 > * pr – 让文本为打印做好准备 > * printf – 格式化数据并打印出来 > * groff – 一个文件格式系统 ## 简单的格式化工具 我们将先着眼于一些简单的格式工具。他们都是功能单一的程序,并且做法有一点单纯, 但是他们能被用于小任务并且作为脚本和管道的一部分 。 ### nl - 添加行号 nl 程序是一个相当神秘的工具,用作一个简单的任务。它添加文件的行数。在它最简单的用途中,它相当于 cat -n: ~~~ [me@linuxbox ~]$ nl distros.txt | head ~~~ 像 cat,nl 既能接受多个文件作为命令行参数,也能标准输出。然而,nl 有一个相当数量的选项并支持一个简单的标记方式去允许更多复杂的方式的计算。 nl 在计算文件行数的时候支持一个叫“逻辑页面”的概念 。这允许nl在计算的时候去重设(再一次开始)可数的序列。用到那些选项 的时候,可以设置一个特殊的开始值,并且在某个可限定的程度上还能设置它的格式。一个逻辑页面被进一步分为 header,body 和 footer 这样的元素。在每一个部分中,数行数可以被重设,并且/或被设置成另外一个格式。如果nl同时处理多个文件,它会把他们当成一个单一的 文本流。文本流中的部分被一些相当古怪的标记的存在加进了文本: 每一个上述的标记元素肯定在自己的行中独自出现。在处理完一个标记元素之后,nl 把它从文本流中删除。 这里有一些常用的 nl 选项: 表格 22-2: 常用 nl 选项 | 选项 | 含义 | |-----|-------| | -b style | 把 body 按被要求方式数行,可以是以下方式:a = 数所有行 t = 数非空行。这是默认设置。n = 无 pregexp = 只数那些匹配了正则表达式的行 | | -f style | 将 footer 按被要求设置数。默认是无 | | -h style | 将 header 按被要求设置数。默认是 | | -i number | 将页面增加量设置为数字。默认是一。 | | -n format | 设置数数的格式,格式可以是:ln = 左偏,没有前导零。rn = 右偏,没有前导零。rz = 右偏,有前导零。 | | -p | 不要在没一个逻辑页面的开始重设页面数。 | | -s string | 在没一个行的末尾加字符作分割符号。默认是单个的 tab。 | | -v number | 将每一个逻辑页面的第一行设置成数字。默认是一。 | | -w width | 将行数的宽度设置,默认是六。 | 坦诚的说,我们大概不会那么频繁地去数行数,但是我们能用 nl 去查看我们怎么将多个工具结合在一个去完成更复杂的任务。 我们将在之前章节的基础上做一个 Linux 发行版的报告。因为我们将使用 nl,包含它的 header/body/footer 标记将会十分有用。 我们将把它加到上一章的 sed 脚本来做这个。使用我们的文本编辑器,我们将脚本改成一下并且把它保存成 distros-nl.sed: ~~~ # sed script to produce Linux distributions report 1 i\ \\:\\:\\:\ \ Linux Distributions Report\ \ Name Ver. Released\ ---- ---- --------\ \\:\\: s/\([0-9]\{2\}\)\/\([0-9]\{2\}\)\/\([0-9]\{4\}\)$/\3-\1-\2/ $ i\ \\:\ \ End Of Report ~~~ 这个脚本现在加入了 nl 的逻辑页面标记并且在报告的最后加了一个 footer。记得我们在我们的标记中必须两次使用反斜杠, 因为他们通常被 sed 解释成一个转义字符。 下一步,我们将结合 sort, sed, nl 来生成我们改进的报告: ~~~ [me@linuxbox ~]$ sort -k 1,1 -k 2n distros.txt | sed -f distros-nl.sed | nl Linux Distributions Report Name Ver. Released ---- ---- -------- 1 Fedora 5 2006-03-20 2 Fedora 6 2006-10-24 3 Fedora 7 2007-05-31 4 Fedora 8 2007-11-08 5 Fedora 9 2008-05-13 6 Fedora 10 2008-11-25 7 SUSE 10.1 2006-05-11 8 SUSE 10.2 2006-12-07 9 SUSE 10.3 2007-10-04 10 SUSE 11.0 2008-06-19 11 Ubuntu 6.06 2006-06-01 12 Ubuntu 6.10 2006-10-26 13 Ubuntu 7.04 2007-04-19 14 Ubuntu 7.10 2007-10-18 15 Ubuntu 8.04 2008-04-24 End Of Report ~~~ 我们的报告是一串命令的结果,首先,我们给名单按发行版本和版本号(表格1和2处)进行排序,然后我们用 sed 生产结果, 增加了 header(包括了为 nl 增加的逻辑页面标记)和 footer。最后,我们按默认用 nl 生成了结果,只数了属于逻辑页面的 body 部分的 文本流的行数。 我们能够重复命令并且实验不同的 nl 选项。一些有趣的方式: ~~~ nl -n rz ~~~ 和 ~~~ nl -w 3 -s ' ' ~~~ ### fold - 限制文件行宽 折叠是将文本的行限制到特定的宽的过程。像我们的其他命令,fold 接受一个或多个文件及标准输入。如果我们将 一个简单的文本流 fold,我们可以看到它工具的方式: ~~~ [me@linuxbox ~]$ echo "The quick brown fox jumped over the lazy dog." | fold -w 12 The quick br own fox jump ed over the lazy dog. ~~~ 这里我们看到了 fold 的行为。这个用 echo 命令发送的文本用 -w 选项分解成块。在这个例子中,我们设定了行宽为12个字符。 如果没有字符设置,默认是80。注意到文本行不会因为单词边界而不会被分解。增加的 -s 选项将让 fold 分解到最后可用的空白 字符,即会考虑单词边界。 ~~~ [me@linuxbox ~]$ echo "The quick brown fox jumped over the lazy dog." | fold -w 12 -s The quick brown fox jumped over the lazy dog. ~~~ ### fmt - 一个简单的文本格式器 fmt 程序同样折叠文本,外加很多功能。它接受文本或标准输入并且在文本流上呈现照片转换。基础来说,他填补并且将文本粘帖在 一起并且保留了空白符和缩进。 为了解释,我们将需要一些文本。让我们抄一些 fmt 主页上的东西吧: 我们将把这段文本复制进我们的文本编辑器并且保存文件名为 fmt-info.txt。现在,让我们重新格式这个文本并且让它成为一个50 个字符宽的项目。我们能用 -w 选项对文件进行处理: ~~~ [me@linuxbox ~]$ fmt -w 50 fmt-info.txt | head 'fmt' reads from the specified FILE arguments (or standard input if none are given), and writes to standard output. By default, blank lines, spaces between words, and indentation are preserved in the output; successive input lines with different indentation are not joined; tabs are expanded on input and introduced on output. ~~~ 好,这真是一个奇怪的结果。大概我们应该认真的阅读这段文本,因为它恰好解释了发生了什么: 默认来说,空白行,单词间距,还有缩进都会在输出中保留;持续输入不同的缩进的流不会被结合;tabs被用来扩展 输入并且引入输出。 所以,fmt 保留了第一行的缩进。幸运的是,fmt 提供一个修正这个的选项: 好多了。通过加了 -c 选项,我们现在有了我们想要的结果。 fmt 有一些有趣的选项: -p 选项特别有趣。通过它,我们可以格式文件选中的部分,通过在开头使用一样的符号。 很多编程语言使用锚标记(#)去提醒注释的开始,而且它可以通过这个选项来被格式。让我们创建一个有用到注释的程序。 ~~~ [me@linuxbox ~]$ cat > fmt-code.txt # This file contains code with comments. # This line is a comment. # Followed by another comment line. # And another. This, on the other hand, is a line of code. And another line of code. And another. ~~~ 我们的示例文件包含了用 “#” 开始的注释(一个 # 后跟着一个空白符)和代码。现在,使用 fmt,我们能格式注释并且 不让代码被触及。
';

第二十一章:文本处理

最后更新于:2022-04-02 01:45:55

所有类 Unix 的操作系统都非常依赖于被用于几种数据类型存储的文本文件。所以这很有道理, 有许多用于处理文本的工具。在这一章中,我们将看一些被用来“切割”文本的程序。在下一章中, 我们将查看更多的文本处理程序,但主要集中于文本格式化输出程序和其它一些人们需要的工具。 这一章会重新拜访一些老朋友,并且会给我们介绍一些新朋友: > * cat – 连接文件并且打印到标准输出 > * sort – 给文本行排序 > * uniq – 报告或者省略重复行 > * cut – 从每行中删除文本区域 > * paste – 合并文件文本行 > * join – 基于某个共享字段来联合两个文件的文本行 > * comm – 逐行比较两个有序的文件 > * diff – 逐行比较文件 > * patch – 给原始文件打补丁 > * tr – 翻译或删除字符 > * sed – 用于筛选和转换文本的流编辑器 > * aspell – 交互式拼写检查器 ## 文本应用程序 到目前为止,我们已经知道了一对文本编辑器(nano 和 vim),看过一堆配置文件,并且目睹了 许多命令的输出都是文本格式。但是文本还被用来做什么? 它可以做很多事情。 ### 文档 许多人使用纯文本格式来编写文档。虽然很容易看到一个小的文本文件对于保存简单的笔记会 很有帮助,但是也有可能用文本格式来编写大的文档。一个流行的方法是先用文本格式来编写一个 大的文档,然后使用一种标记语言来描述已完成文档的格式。许多科学论文就是用这种方法编写的, 因为基于 Unix 的文本处理系统位于支持技术学科作家所需要的高级排版布局的一流系统之列。 ### 网页 世界上最流行的电子文档类型可能就是网页了。网页是文本文档,它们使用 HTML(超文本标记语言)或者是 XML (可扩展的标记语言)作为标记语言来描述文档的可视格式。 ### 电子邮件 从本质上来说,email 是一个基于文本的媒介。为了传输,甚至非文本的附件也被转换成文本表示形式。 我们能看到这些,通过下载一个 email 信息,然后用 less 来浏览它。我们将会看到这条信息开始于一个标题, 其描述了信息的来源以及在传输过程中它接受到的处理,然后是信息的正文内容。 ### 打印输出 在类 Unix 的系统中,输出会以纯文本格式发送到打印机,或者如果页面包含图形,其会被转换成 一种文本格式的页面描述语言,以 PostScript 著称,然后再被发送给一款能产生图形点阵的程序, 最后被打印出来。 ### 程序源码 在类 Unix 系统中会发现许多命令行程序被用来支持系统管理和软件开发,并且文本处理程序也不例外。 许多文本处理程序被设计用来解决软件开发问题。文本处理对于软件开发者来言至关重要是因为所有的软件 都起始于文本格式。源代码,程序员实际编写的一部分程序,总是文本格式。 ## 回顾一些老朋友 回到第7章(重定向),我们已经知道一些命令除了接受命令行参数之外,还能够接受标准输入。 那时候我们只是简单地介绍了它们,但是现在我们将仔细地看一下它们是怎样被用来执行文本处理的。 ### cat 这个 cat 程序具有许多有趣的选项。其中许多选项用来帮助更好的可视化文本内容。一个例子是-A 选项, 其用来在文本中显示非打印字符。有些时候我们想知道是否控制字符嵌入到了我们的可见文本中。 最常用的控制字符是 tab 字符(而不是空格)和回车字符,在 MS-DOS 风格的文本文件中回车符经常作为 结束符出现。另一种常见情况是文件中包含末尾带有空格的文本行。 让我们创建一个测试文件,用 cat 程序作为一个简单的文字处理器。为此,我们将键入 cat 命令(随后指定了 用于重定向输出的文件),然后输入我们的文本,最后按下 Enter 键来结束这一行,然后按下组合键 Ctrl-d, 来指示 cat 程序,我们已经到达文件末尾了。在这个例子中,我们文本行的开头和末尾分别键入了一个 tab 字符以及一些空格。 ~~~ [me@linuxbox ~]$ cat > foo.txt The quick brown fox jumped over the lazy dog. [me@linuxbox ~]$ ~~~ 下一步,我们将使用带有-A 选项的 cat 命令来显示这个文本: ~~~ [me@linuxbox ~]$ cat -A foo.txt ^IThe quick brown fox jumped over the lazy dog. $ [me@linuxbox ~]$ ~~~ 在输出结果中我们看到,这个 tab 字符在我们的文本中由^I 字符来表示。这是一种常见的表示方法,意思是 “Control-I”,结果证明,它和 tab 字符是一样的。我们也看到一个$字符出现在文本行真正的结尾处, 表明我们的文本包含末尾的空格。 > MS-DOS 文本 Vs. Unix 文本 > > 可能你想用 cat 程序在文本中查看非打印字符的一个原因是发现隐藏的回车符。那么 隐藏的回车符来自于哪里呢?它们来自于 DOS 和 Windows!Unix 和 DOS 在文本文件中定义每行 结束的方式不相同。Unix 通过一个换行符(ASCII 10)来结束一行,然而 MS-DOS 和它的 衍生品使用回车(ASCII 13)和换行字符序列来终止每个文本行。 > > 有几种方法能够把文件从 DOS 格式转变为 Unix 格式。在许多 Linux 系统中,有两个 程序叫做 dos2unix 和 unix2dos,它们能在两种格式之间转变文本文件。然而,如果你 的系统中没有安装 dos2unix 程序,也不要担心。文件从 DOS 格式转变为 Unix 格式的过程非常 简单;它只简单地涉及到删除违规的回车符。通过随后本章中讨论的一些程序,这个工作很容易 完成。 cat 程序也包含用来修改文本的选项。最著名的两个选项是-n,其给文本行添加行号和-s, 禁止输出多个空白行。我们这样来说明: ~~~ [me@linuxbox ~]$ cat > foo.txt The quick brown fox jumped over the lazy dog. [me@linuxbox ~]$ cat -ns foo.txt 1 The quick brown fox 2 3 jumped over the lazy dog. [me@linuxbox ~]$ ~~~ 在这个例子里,我们创建了一个测试文件 foo.txt 的新版本,其包含两行文本,由两个空白行分开。 经由带有-ns 选项的 cat 程序处理之后,多余的空白行被删除,并且对保留的文本行进行编号。 然而这并不是多个进程在操作这个文本,只有一个进程。 ### sort 这个 sort 程序对标准输入的内容,或命令行中指定的一个或多个文件进行排序,然后把排序 结果发送到标准输出。使用与 cat 命令相同的技巧,我们能够演示如何用 sort 程序来处理标准输入: ~~~ [me@linuxbox ~]$ sort > foo.txt c b a [me@linuxbox ~]$ cat foo.txt a b c ~~~ 输入命令之后,我们键入字母“c”,“b”,和“a”,然后再按下 Ctrl-d 组合键来表示文件的结尾。 随后我们查看生成的文件,看到文本行有序地显示。 因为 sort 程序能接受命令行中的多个文件作为参数,所以有可能把多个文件合并成一个有序的文件。例如, 如果我们有三个文本文件,想要把它们合并为一个有序的文件,我们可以这样做: ~~~ sort file1.txt file2.txt file3.txt > final_sorted_list.txt ~~~ sort 程序有几个有趣的选项。这里只是一部分列表: 表21-1: 常见的 sort 程序选项 | 选项 | 长选项 | 描述 | |-------|-------|-------| | -b | --ignore-leading-blanks | 默认情况下,对整行进行排序,从每行的第一个字符开始。这个选项导致 sort 程序忽略 每行开头的空格,从第一个非空白字符开始排序。 | | -f | --ignore-case | 让排序不区分大小写。 | | -n | --numeric-sort | 基于字符串的长度来排序。使用此选项允许根据数字值执行排序,而不是字母值。 | | -r | --reverse | 按相反顺序排序。结果按照降序排列,而不是升序。 | | -k | --key=field1[,field2] | 对从 field1到 field2之间的字符排序,而不是整个文本行。看下面的讨论。 | | -m | --merge | 把每个参数看作是一个预先排好序的文件。把多个文件合并成一个排好序的文件,而没有执行额外的排序。 | | -o | --output=file | 把排好序的输出结果发送到文件,而不是标准输出。 | | -t | --field-separator=char | 定义域分隔字符。默认情况下,域由空格或制表符分隔。 | 虽然以上大多数选项的含义是不言自喻的,但是有些也不是。首先,让我们看一下 -n 选项,被用做数值排序。 通过这个选项,有可能基于数值进行排序。我们通过对 du 命令的输出结果排序来说明这个选项,du 命令可以 确定最大的磁盘空间用户。通常,这个 du 命令列出的输出结果按照路径名来排序: ~~~ [me@linuxbox ~]$ du -s /usr/share/\* | head 252 /usr/share/aclocal 96 /usr/share/acpi-support 8 /usr/share/adduser 196 /usr/share/alacarte 344 /usr/share/alsa 8 /usr/share/alsa-base 12488 /usr/share/anthy 8 /usr/share/apmd 21440 /usr/share/app-install 48 /usr/share/application-registry ~~~ 在这个例子里面,我们把结果管道到 head 命令,把输出结果限制为前 10 行。我们能够产生一个按数值排序的 列表,来显示 10 个最大的空间消费者: ~~~ [me@linuxbox ~]$ du -s /usr/share/* | sort -nr | head 509940 /usr/share/locale-langpack 242660 /usr/share/doc 197560 /usr/share/fonts 179144 /usr/share/gnome 146764 /usr/share/myspell 144304 /usr/share/gimp 135880 /usr/share/dict 76508 /usr/share/icons 68072 /usr/share/apps 62844 /usr/share/foomatic ~~~ 通过使用此 -nr 选项,我们产生了一个反向的数值排序,最大数值排列在第一位。这种排序起作用是 因为数值出现在每行的开头。但是如果我们想要基于文件行中的某个数值排序,又会怎样呢? 例如,命令 ls -l 的输出结果: ~~~ [me@linuxbox ~]$ ls -l /usr/bin | head total 152948 -rwxr-xr-x 1 root root 34824 2008-04-04 02:42 [ -rwxr-xr-x 1 root root 101556 2007-11-27 06:08 a2p ... ~~~ 此刻,忽略 ls 程序能按照文件大小对输出结果进行排序,我们也能够使用 sort 程序来完成此任务: ~~~ [me@linuxbox ~]$ ls -l /usr/bin | sort -nr -k 5 | head -rwxr-xr-x 1 root root 8234216 2008-04-0717:42 inkscape -rwxr-xr-x 1 root root 8222692 2008-04-07 17:42 inkview ... ~~~ sort 程序的许多用法都涉及到处理表格数据,例如上面 ls 命令的输出结果。如果我们 把数据库这个术语应用到上面的表格中,我们会说每行是一条记录,并且每条记录由多个字段组成, 例如文件属性,链接数,文件名,文件大小等等。sort 程序能够处理独立的字段。在数据库术语中, 我们能够指定一个或者多个关键字段,来作为排序的关键值。在上面的例子中,我们指定 n 和 r 选项来执行相反的数值排序,并且指定 -k 5,让 sort 程序使用第五字段作为排序的关键值。 这个 k 选项非常有趣,而且还有很多特点,但是首先我们需要讲讲 sort 程序怎样来定义字段。 让我们考虑一个非常简单的文本文件,只有一行包含作者名字的文本。 ~~~ William Shotts ~~~ 默认情况下,sort 程序把此行看作有两个字段。第一个字段包含字符: 和第二个字段包含字符: 意味着空白字符(空格和制表符)被当作是字段间的界定符,当执行排序时,界定符会被 包含在字段当中。再看一下 ls 命令的输出,我们看到每行包含八个字段,并且第五个字段是文件大小: ~~~ -rwxr-xr-x 1 root root 8234216 2008-04-07 17:42 inkscape ~~~ 让我们考虑用下面的文件,其包含从 2006 年到 2008 年三款流行的 Linux 发行版的发行历史,来做一系列实验。 文件中的每一行都有三个字段:发行版的名称,版本号,和 MM/DD/YYYY 格式的发行日期: ~~~ SUSE 10.2 12/07/2006 Fedora 10 11/25/2008 SUSE 11.04 06/19/2008 Ubuntu 8.04 04/24/2008 Fedora 8 11/08/2007 SUSE 10.3 10/04/2007 ... ~~~ 使用一个文本编辑器(可能是 vim),我们将输入这些数据,并把产生的文件命名为 distros.txt。 下一步,我们将试着对这个文件进行排序,并观察输出结果: ~~~ [me@linuxbox ~]$ sort distros.txt Fedora 10 11/25/2008 Fedora 5 03/20/2006 Fedora 6 10/24/2006 Fedora 7 05/31/2007 Fedora 8 11/08/2007 ... ~~~ 恩,大部分正确。问题出现在 Fedora 的版本号上。因为在字符集中 “1” 出现在 “5” 之前,版本号 “10” 在 最顶端,然而版本号 “9” 却掉到底端。 为了解决这个问题,我们必须依赖多个键值来排序。我们想要对第一个字段执行字母排序,然后对 第三个字段执行数值排序。sort 程序允许多个 -k 选项的实例,所以可以指定多个排序关键值。事实上, 一个关键值可能包括一个字段区域。如果没有指定区域(如同之前的例子),sort 程序会使用一个键值, 其始于指定的字段,一直扩展到行尾。下面是多键值排序的语法: ~~~ [me@linuxbox ~]$ sort --key=1,1 --key=2n distros.txt Fedora 5 03/20/2006 Fedora 6 10/24/2006 Fedora 7 05/31/2007 ... ~~~ 虽然为了清晰,我们使用了选项的长格式,但是 -k 1,1 -k 2n 格式是等价的。在第一个 key 选项的实例中, 我们指定了一个字段区域。因为我们只想对第一个字段排序,我们指定了 1,1, 意味着“始于并且结束于第一个字段。”在第二个实例中,我们指定了 2n,意味着第二个字段是排序的键值, 并且按照数值排序。一个选项字母可能被包含在一个键值说明符的末尾,其用来指定排序的种类。这些 选项字母和 sort 程序的全局选项一样:b(忽略开头的空格),n(数值排序),r(逆向排序),等等。 我们列表中第三个字段包含的日期格式不利于排序。在计算机中,日期通常设置为 YYYY-MM-DD 格式, 这样使按时间顺序排序变得容易,但是我们的日期为美国格式 MM/DD/YYYY。那么我们怎样能按照 时间顺序来排列这个列表呢? 幸运地是,sort 程序提供了一种方式。这个 key 选项允许在字段中指定偏移量,所以我们能在字段中 定义键值。 ~~~ [me@linuxbox ~]$ sort -k 3.7nbr -k 3.1nbr -k 3.4nbr distros.txt Fedora 10 11/25/2008 Ubuntu 8.10 10/30/2008 SUSE 11.0 06/19/2008 ... ~~~ 通过指定 -k 3.7,我们指示 sort 程序使用一个排序键值,其始于第三个字段中的第七个字符,对应于 年的开头。同样地,我们指定 -k 3.1和 -k 3.4来分离日期中的月和日。 我们也添加了 n 和 r 选项来实现一个逆向的数值排序。这个 b 选项用来删除日期字段中开头的空格( 行与行之间的空格数迥异,因此会影响 sort 程序的输出结果)。 一些文件不会使用 tabs 和空格做为字段界定符;例如,这个 /etc/passwd 文件: ~~~ [me@linuxbox ~]$ head /etc/passwd root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/bin/sh bin:x:2:2:bin:/bin:/bin/sh sys:x:3:3:sys:/dev:/bin/sh sync:x:4:65534:sync:/bin:/bin/sync games:x:5:60:games:/usr/games:/bin/sh man:x:6:12:man:/var/cache/man:/bin/sh lp:x:7:7:lp:/var/spool/lpd:/bin/sh mail:x:8:8:mail:/var/mail:/bin/sh news:x:9:9:news:/var/spool/news:/bin/sh ~~~ 这个文件的字段之间通过冒号分隔开,所以我们怎样使用一个 key 字段来排序这个文件?sort 程序提供 了一个 -t 选项来定义分隔符。按照第七个字段(帐户的默认 shell)来排序此 passwd 文件,我们可以这样做: ~~~ [me@linuxbox ~]$ sort -t ':' -k 7 /etc/passwd | head me:x:1001:1001:Myself,,,:/home/me:/bin/bash root:x:0:0:root:/root:/bin/bash dhcp:x:101:102::/nonexistent:/bin/false gdm:x:106:114:Gnome Display Manager:/var/lib/gdm:/bin/false hplip:x:104:7:HPLIP system user,,,:/var/run/hplip:/bin/false klog:x:103:104::/home/klog:/bin/false messagebus:x:108:119::/var/run/dbus:/bin/false polkituser:x:110:122:PolicyKit,,,:/var/run/PolicyKit:/bin/false pulse:x:107:116:PulseAudio daemon,,,:/var/run/pulse:/bin/false ~~~ 通过指定冒号字符做为字段分隔符,我们能按照第七个字段来排序。 ### uniq 与 sort 程序相比,这个 uniq 程序是个轻量级程序。uniq 执行一个看似琐碎的认为。当给定一个 排好序的文件(包括标准输出),uniq 会删除任意重复行,并且把结果发送到标准输出。 它常常和 sort 程序一块使用,来清理重复的输出。 * * * uniq 程序是一个传统的 Unix 工具,经常与 sort 程序一块使用,但是这个 GNU 版本的 sort 程序支持一个 -u 选项,其可以从排好序的输出结果中删除重复行。 * * * 让我们创建一个文本文件,来实验一下: ~~~ [me@linuxbox ~]$ cat > foo.txt a b c a b c ~~~ 记住输入 Ctrl-d 来终止标准输入。现在,如果我们对文本文件执行 uniq 命令: ~~~ [me@linuxbox ~]$ uniq foo.txt a b c a b c ~~~ 输出结果与原始文件没有差异;重复行没有被删除。实际上,uniq 程序能完成任务,其输入必须是排好序的数据, ~~~ [me@linuxbox ~]$ sort foo.txt | uniq a b c ~~~ 这是因为 uniq 只会删除相邻的重复行。uniq 程序有几个选项。这里是一些常用选项: 表21-2: 常用的 uniq 选项 | 选项 | 说明 | |-------|-----| | -c | 输出所有的重复行,并且每行开头显示重复的次数。 | | -d | 只输出重复行,而不是特有的文本行。 | | -f n | 忽略每行开头的 n 个字段,字段之间由空格分隔,正如 sort 程序中的空格分隔符;然而, 不同于 sort 程序,uniq 没有选项来设置备用的字段分隔符。 | | -i | 在比较文本行的时候忽略大小写。 | | -s n | 跳过(忽略)每行开头的 n 个字符。 | | -u | 只是输出独有的文本行。这是默认的。 | 这里我们看到 uniq 被用来报告文本文件中重复行的次数,使用这个-c 选项: ~~~ [me@linuxbox ~]$ sort foo.txt | uniq -c 2 a 2 b 2 c ~~~ ## 切片和切块 下面我们将要讨论的三个程序用来从文件中获得文本列,并且以有用的方式重组它们。 ### cut 这个 cut 程序被用来从文本行中抽取文本,并把其输出到标准输出。它能够接受多个文件参数或者 标准输入。 从文本行中指定要抽取的文本有些麻烦,使用以下选项: 表21-3: cut 程序选择项 | 选项 | 说明 | |-----|------| | -c char_list | 从文本行中抽取由 char_list 定义的文本。这个列表可能由一个或多个逗号 分隔开的数值区间组成。 | | -f field_list | 从文本行中抽取一个或多个由 field_list 定义的字段。这个列表可能 包括一个或多个字段,或由逗号分隔开的字段区间。 | | -d delim_char | 当指定-f 选项之后,使用 delim_char 做为字段分隔符。默认情况下, 字段之间必须由单个 tab 字符分隔开。 | | --complement | 抽取整个文本行,除了那些由-c 和/或-f 选项指定的文本。 | 正如我们所看到的,cut 程序抽取文本的方式相当不灵活。cut 命令最好用来从其它程序产生的文件中 抽取文本,而不是从人们直接输入的文本中抽取。我们将会看一下我们的 distros.txt 文件,看看 是否它足够 “整齐” 成为 cut 实例的一个好样本。如果我们使用带有 -A 选项的 cat 命令,我们能查看是否这个 文件符号由 tab 字符分离字段的要求。 ~~~ [me@linuxbox ~]$ cat -A distros.txt SUSE^I10.2^I12/07/2006$ Fedora^I10^I11/25/2008$ SUSE^I11.0^I06/19/2008$ Ubuntu^I8.04^I04/24/2008$ Fedora^I8^I11/08/2007$ SUSE^I10.3^I10/04/2007$ Ubuntu^I6.10^I10/26/2006$ Fedora^I7^I05/31/2007$ Ubuntu^I7.10^I10/18/2007$ Ubuntu^I7.04^I04/19/2007$ SUSE^I10.1^I05/11/2006$ Fedora^I6^I10/24/2006$ Fedora^I9^I05/13/2008$ Ubuntu^I6.06^I06/01/2006$ Ubuntu^I8.10^I10/30/2008$ Fedora^I5^I03/20/2006$ ~~~ 看起来不错。字段之间仅仅是单个 tab 字符,没有嵌入空格。因为这个文件使用了 tab 而不是空格, 我们将使用 -f 选项来抽取一个字段: ~~~ [me@linuxbox ~]$ cut -f 3 distros.txt 12/07/2006 11/25/2008 06/19/2008 04/24/2008 11/08/2007 10/04/2007 10/26/2006 05/31/2007 10/18/2007 04/19/2007 05/11/2006 10/24/2006 05/13/2008 06/01/2006 10/30/2008 03/20/2006 ~~~ 因为我们的 distros 文件是由 tab 分隔开的,最好用 cut 来抽取字段而不是字符。这是因为一个由 tab 分离的文件, 每行不太可能包含相同的字符数,这就使计算每行中字符的位置变得困难或者是不可能。在以上事例中,然而, 我们已经抽取了一个字段,幸运地是其包含地日期长度相同,所以通过从每行中抽取年份,我们能展示怎样 来抽取字符: ~~~ [me@linuxbox ~]$ cut -f 3 distros.txt | cut -c 7-10 2006 2008 2007 2006 2007 2006 2008 2006 2008 2006 ~~~ 通过对我们的列表再次运行 cut 命令,我们能够抽取从位置7到10的字符,其对应于日期字段的年份。 这个 7-10 表示法是一个区间的例子。cut 命令手册包含了一个如何指定区间的完整描述。 > 展开 Tabs > > distros.txt 的文件格式很适合使用 cut 程序来抽取字段。但是如果我们想要 cut 程序 按照字符,而不是字段来操作一个文件,那又怎样呢?这要求我们用相应数目的空格来 代替 tab 字符。幸运地是,GNU 的 Coreutils 软件包有一个工具来解决这个问题。这个 程序名为 expand,它既可以接受一个或多个文件参数,也可以接受标准输入,并且把 修改过的文本送到标准输出。 > > 如果我们通过 expand 来处理 distros.txt 文件,我们能够使用 cut -c 命令来从文件中抽取 任意区间内的字符。例如,我们能够使用以下命令来从列表中抽取发行年份,通过展开 此文件,再使用 cut 命令,来抽取从位置 23 开始到行尾的每一个字符: > > [me@linuxbox ~]$ expand distros.txt | cut -c 23- > > Coreutils 软件包也提供了 unexpand 程序,用 tab 来代替空格。 当操作字段的时候,有可能指定不同的字段分隔符,而不是 tab 字符。这里我们将会从/etc/passwd 文件中 抽取第一个字段: ~~~ [me@linuxbox ~]$ cut -d ':' -f 1 /etc/passwd | head root daemon bin sys sync games man lp mail news ~~~ 使用-d 选项,我们能够指定冒号做为字段分隔符。 ### paste 这个 paste 命令的功能正好与 cut 相反。它会添加一个或多个文本列到文件中,而不是从文件中抽取文本列。 它通过读取多个文件,然后把每个文件中的字段整合成单个文本流,输入到标准输出。类似于 cut 命令, paste 接受多个文件参数和 / 或标准输入。为了说明 paste 是怎样工作的,我们将会对 distros.txt 文件 动手术,来产生发行版的年代表。 从我们之前使用 sort 的工作中,首先我们将产生一个按照日期排序的发行版列表,并把结果 存储在一个叫做 distros-by-date.txt 的文件中: ~~~ [me@linuxbox ~]$ sort -k 3.7nbr -k 3.1nbr -k 3.4nbr distros.txt > distros-by-date.txt ~~~ 下一步,我们将会使用 cut 命令从文件中抽取前两个字段(发行版名字和版本号),并把结果存储到 一个名为 distro-versions.txt 的文件中: ~~~ [me@linuxbox ~]$ cut -f 1,2 distros-by-date.txt > distros-versions.txt [me@linuxbox ~]$ head distros-versions.txt Fedora 10 Ubuntu 8.10 SUSE 11.0 Fedora 9 Ubuntu 8.04 Fedora 8 Ubuntu 7.10 SUSE 10.3 Fedora 7 Ubuntu 7.04 ~~~ 最后的准备步骤是抽取发行日期,并把它们存储到一个名为 distro-dates.txt 文件中: ~~~ [me@linuxbox ~]$ cut -f 3 distros-by-date.txt > distros-dates.txt [me@linuxbox ~]$ head distros-dates.txt 11/25/2008 10/30/2008 06/19/2008 05/13/2008 04/24/2008 11/08/2007 10/18/2007 10/04/2007 05/31/2007 04/19/2007 ~~~ 现在我们拥有了我们所需要的文本了。为了完成这个过程,使用 paste 命令来把日期列放到发行版名字 和版本号的前面,这样就创建了一个年代列表。通过使用 paste 命令,然后按照期望的顺序来安排它的 参数,就能很容易完成这个任务。 ~~~ [me@linuxbox ~]$ paste distros-dates.txt distros-versions.txt 11/25/2008 Fedora 10 10/30/2008 Ubuntu 8.10 06/19/2008 SUSE 11.0 05/13/2008 Fedora 9 04/24/2008 Ubuntu 8.04 11/08/2007 Fedora 8 10/18/2007 Ubuntu 7.10 10/04/2007 SUSE 10.3 05/31/2007 Fedora 7 04/19/2007 Ubuntu 7.04 ~~~ ### join 在某些方面,join 命令类似于 paste,它会往文件中添加列,但是它使用了独特的方法来完成。 一个 join 操作通常与关系型数据库有关联,在关系型数据库中来自多个享有共同关键域的表格的 数据结合起来,得到一个期望的结果。这个 join 程序执行相同的操作。它把来自于多个基于共享 关键域的文件的数据结合起来。 为了知道在关系数据库中是怎样使用 join 操作的,让我们想象一个很小的数据库,这个数据库由两个 表格组成,每个表格包含一条记录。第一个表格,叫做 CUSTOMERS,有三个数据域:一个客户号(CUSTNUM), 客户的名字(FNAME)和客户的姓(LNAME): ~~~ CUSTNUM FNAME ME ======== ===== ====== 4681934 John Smith ~~~ 第二个表格叫做 ORDERS,其包含四个数据域:订单号(ORDERNUM),客户号(CUSTNUM),数量(QUAN), 和订购的货品(ITEM)。 ~~~ ORDERNUM CUSTNUM QUAN ITEM ======== ======= ==== ==== 3014953305 4681934 1 Blue Widget ~~~ 注意两个表格共享数据域 CUSTNUM。这很重要,因为它使表格之间建立了联系。 执行一个 join 操作将允许我们把两个表格中的数据域结合起来,得到一个有用的结果,例如准备 一张发货单。通过使用两个表格 CUSTNUM 数字域中匹配的数值,一个 join 操作会产生以下结果: ~~~ FNAME LNAME QUAN ITEM ===== ===== ==== ==== John Smith 1 Blue Widget ~~~ 为了说明 join 程序,我们需要创建一对包含共享键值的文件。为此,我们将使用我们的 distros.txt 文件。 从这个文件中,我们将构建额外两个文件,一个包含发行日期(其会成为共享键值)和发行版名称: ~~~ [me@linuxbox ~]$ cut -f 1,1 distros-by-date.txt > distros-names.txt [me@linuxbox ~]$ paste distros-dates.txt distros-names.txt > distros-key-names.txt [me@linuxbox ~]$ head distros-key-names.txt 11/25/2008 Fedora 10/30/2008 Ubuntu 06/19/2008 SUSE 05/13/2008 Fedora 04/24/2008 Ubuntu 11/08/2007 Fedora 10/18/2007 Ubuntu 10/04/2007 SUSE 05/31/2007 Fedora 04/19/2007 Ubuntu ~~~ 第二个文件包含发行日期和版本号: ~~~ [me@linuxbox ~]$ cut -f 2,2 distros-by-date.txt > distros-vernums.txt [me@linuxbox ~]$ paste distros-dates.txt distros-vernums.txt > distros-key-vernums.txt [me@linuxbox ~]$ head distros-key-vernums.txt 11/25/2008 10 10/30/2008 8.10 06/19/2008 11.0 05/13/2008 9 04/24/2008 8.04 11/08/2007 8 10/18/2007 7.10 10/04/2007 10.3 05/31/2007 7 04/19/2007 7.04 ~~~ 现在我们有两个具有共享键值( “发行日期” 数据域 )的文件。有必要指出,为了使 join 命令 能正常工作,所有文件必须按照关键数据域排序。 ~~~ [me@linuxbox ~]$ join distros-key-names.txt distros-key-vernums.txt | head 11/25/2008 Fedora 10 10/30/2008 Ubuntu 8.10 06/19/2008 SUSE 11.0 05/13/2008 Fedora 9 04/24/2008 Ubuntu 8.04 11/08/2007 Fedora 8 10/18/2007 Ubuntu 7.10 10/04/2007 SUSE 10.3 05/31/2007 Fedora 7 04/19/2007 Ubuntu 7.04 ~~~ 也要注意,默认情况下,join 命令使用空白字符做为输入字段的界定符,一个空格作为输出字段 的界定符。这种行为可以通过指定的选项来修改。详细信息,参考 join 命令手册。 ## 比较文本 通常比较文本文件的版本很有帮助。对于系统管理员和软件开发者来说,这个尤为重要。 一名系统管理员可能,例如,需要拿现有的配置文件与先前的版本做比较,来诊断一个系统错误。 同样的,一名程序员经常需要查看程序的修改。 ### comm 这个 comm 程序会比较两个文本文件,并且会显示每个文件特有的文本行和共有的文把行。 为了说明问题,通过使用 cat 命令,我们将会创建两个内容几乎相同的文本文件: ~~~ [me@linuxbox ~]$ cat > file1.txt a b c d [me@linuxbox ~]$ cat > file2.txt b c d e ~~~ 下一步,我们将使用 comm 命令来比较这两个文件: ~~~ [me@linuxbox ~]$ comm file1.txt file2.txt a b c d e ~~~ 正如我们所见到的,comm 命令产生了三列输出。第一列包含第一个文件独有的文本行;第二列, 文本行是第二列独有的;第三列包含两个文件共有的文本行。comm 支持 -n 形式的选项,这里 n 代表 1,2 或 3。这些选项使用的时候,指定了要隐藏的列。例如,如果我们只想输出两个文件共享的文本行, 我们将隐藏第一列和第二列的输出结果: ~~~ [me@linuxbox ~]$ comm -12 file1.txt file2.txt b c d ~~~ ### diff 类似于 comm 程序,diff 程序被用来监测文件之间的差异。然而,diff 是一款更加复杂的工具,它支持 许多输出格式,并且一次能处理许多文本文件。软件开发员经常使用 diff 程序来检查不同程序源码 版本之间的更改,diff 能够递归地检查源码目录,经常称之为源码树。diff 程序的一个常见用例是 创建 diff 文件或者补丁,它会被其它程序使用,例如 patch 程序(我们一会儿讨论),来把文件 从一个版本转换为另一个版本。 如果我们使用 diff 程序,来查看我们之前的文件实例: ~~~ [me@linuxbox ~]$ diff file1.txt file2.txt 1d0 < a 4a4 > e ~~~ 我们看到 diff 程序的默认输出风格:对两个文件之间差异的简短描述。在默认格式中, 每组的更改之前都是一个更改命令,其形式为 range operation range , 用来描述要求更改的位置和类型,从而把第一个文件转变为第二个文件: 表21-4: diff 更改命令 | 改变 | 说明 | |-----|--------| | r1ar2 | 把第二个文件中位置 r2 处的文件行添加到第一个文件中的 r1 处。 | | r1cr2 | 用第二个文件中位置 r2 处的文本行更改(替代)位置 r1 处的文本行。 | | r1dr2 | 删除第一个文件中位置 r1 处的文本行,这些文本行将会出现在第二个文件中位置 r2 处。 | 在这种格式中,一个范围就是由逗号分隔开的开头行和结束行的列表。虽然这种格式是默认情况(主要是 为了服从 POSIX 标准且向后与传统的 Unix diff 命令兼容), 但是它并不像其它可选格式一样被广泛地使用。最流行的两种格式是上下文模式和统一模式。 当使用上下文模式(带上 -c 选项),我们将看到这些: ~~~ [me@linuxbox ~]$ diff -c file1.txt file2.txt *** file1.txt 2008-12-23 06:40:13.000000000 -0500 --- file2.txt 2008-12-23 06:40:34.000000000 -0500 *************** *** 1,4 **** - a b c d --- 1,4 ---- b c d + e ~~~ 这个输出结果以两个文件名和它们的时间戳开头。第一个文件用星号做标记,第二个文件用短横线做标记。 纵观列表的其它部分,这些标记将象征它们各自代表的文件。下一步,我们看到几组修改, 包括默认的周围上下文行数。在第一组中,我们看到: ~~~ *** 1,4 *** ~~~ 其表示第一个文件中从第一行到第四行的文本行。随后我们看到: ~~~ --- 1,4 --- ~~~ 这表示第二个文件中从第一行到第四行的文本行。在更改组内,文本行以四个指示符之一开头: 表21-5: diff 上下文模式更改指示符 | 指示符 | 意思 | |-------|---------| | blank | 上下文显示行。它并不表示两个文件之间的差异。 | | - | 删除行。这一行将会出现在第一个文件中,而不是第二个文件内。 | | + | 添加行。这一行将会出现在第二个文件内,而不是第一个文件中。 | | ! | 更改行。将会显示某个文本行的两个版本,每个版本会出现在更改组的各自部分。 | 这个统一模式相似于上下文模式,但是更加简洁。通过 -u 选项来指定它: ~~~ [me@linuxbox ~]$ diff -u file1.txt file2.txt --- file1.txt 2008-12-23 06:40:13.000000000 -0500 +++ file2.txt 2008-12-23 06:40:34.000000000 -0500 @@ -1,4 +1,4 @@ -a b c d +e ~~~ 上下文模式和统一模式之间最显著的差异就是重复上下文的消除,这就使得统一模式的输出结果要比上下文 模式的输出结果简短。在我们上述实例中,我们看到类似于上下文模式中的文件时间戳,其紧紧跟随字符串 @@ -1,4 +1,4 @@。这行字符串表示了在更改组中描述的第一个文件中的文本行和第二个文件中的文本行。 这行字符串之后就是文本行本身,与三行默认的上下文。每行以可能的三个字符中的一个开头: 表21-6: diff 统一模式更改指示符 | 字符 | 意思 | |-----|-------| | 空格 | 两个文件都包含这一行。 | | - | 在第一个文件中删除这一行。 | | + | 添加这一行到第一个文件中。 | ### patch 这个 patch 程序被用来把更改应用到文本文件中。它接受从 diff 程序的输出,并且通常被用来 把较老的文件版本转变为较新的文件版本。让我们考虑一个著名的例子。Linux 内核是由一个 大型的,组织松散的贡献者团队开发而成,这些贡献者会提交固定的少量更改到源码包中。 这个 Linux 内核由几百万行代码组成,虽然每个贡献者每次所做的修改相当少。对于一个贡献者 来说,每做一个修改就给每个开发者发送整个的内核源码树,这是没有任何意义的。相反, 提交一个 diff 文件。一个 diff 文件包含先前的内核版本与带有贡献者修改的新版本之间的差异。 然后一个接受者使用 patch 程序,把这些更改应用到他自己的源码树中。使用 diff/patch 组合提供了 两个重大优点: 1. 一个 diff 文件非常小,与整个源码树的大小相比较而言。 2. 一个 diff 文件简洁地显示了所做的修改,从而允许程序补丁的审阅者能快速地评估它。 当然,diff/patch 能工作于任何文本文件,不仅仅是源码文件。它同样适用于配置文件或任意其它文本。 准备一个 diff 文件供 patch 程序使用,GNU 文档(查看下面的拓展阅读部分)建议这样使用 diff 命令: ~~~ diff -Naur old_file new_file > diff_file ~~~ old_file 和 new_file 部分不是单个文件就是包含文件的目录。这个 r 选项支持递归目录树。 一旦创建了 diff 文件,我们就能应用它,把旧文件修补成新文件。 ~~~ patch < diff_file ~~~ 我们将使用测试文件来说明: ~~~ [me@linuxbox ~]$ diff -Naur file1.txt file2.txt > patchfile.txt [me@linuxbox ~]$ patch < patchfile.txt patching file file1.txt [me@linuxbox ~]$ cat file1.txt b c d e ~~~ 在这个例子中,我们创建了一个名为 patchfile.txt 的 diff 文件,然后使用 patch 程序, 来应用这个补丁。注意我们没有必要指定一个要修补的目标文件,因为 diff 文件(在统一模式中)已经 在标题行中包含了文件名。一旦应用了补丁,我们能看到,现在 file1.txt 与 file2.txt 文件相匹配了。 patch 程序有大量的选项,而且还有额外的实用程序可以被用来分析和编辑补丁。 ## 运行时编辑 我们对于文本编辑器的经验是它们主要是交互式的,意思是我们手动移动光标,然后输入我们的修改。 然而,也有非交互式的方法来编辑文本。有可能,例如,通过单个命令把一系列修改应用到多个文件中。 ### tr 这个 tr 程序被用来更改字符。我们可以把它看作是一种基于字符的查找和替换操作。 换字是一种把字符从一个字母转换为另一个字母的过程。例如,把小写字母转换成大写字母就是 换字。我们可以通过 tr 命令来执行这样的转换,如下所示: ~~~ [me@linuxbox ~]$ echo "lowercase letters" | tr a-z A-Z LOWERCASE LETTERS ~~~ 正如我们所见,tr 命令操作标准输入,并把结果输出到标准输出。tr 命令接受两个参数:要被转换的字符集以及 相对应的转换后的字符集。字符集可以用三种方式来表示: 1. 一个枚举列表。例如, ABCDEFGHIJKLMNOPQRSTUVWXYZ 2. 一个字符域。例如,A-Z 。注意这种方法有时候面临与其它命令相同的问题,归因于 语系的排序规则,因此应该谨慎使用。 3. POSIX 字符类。例如,[:upper:] 大多数情况下,两个字符集应该长度相同;然而,有可能第一个集合大于第二个,尤其如果我们 想要把多个字符转换为单个字符: ~~~ [me@linuxbox ~]$ echo "lowercase letters" | tr [:lower:] A AAAAAAAAA AAAAAAA ~~~ 除了换字之外,tr 命令能允许字符从输入流中简单地被删除。在之前的章节中,我们讨论了转换 MS-DOS 文本文件为 Unix 风格文本的问题。为了执行这个转换,每行末尾的回车符需要被删除。 这个可以通过 tr 命令来执行,如下所示: ~~~ tr -d '\r' < dos_file > unix_file ~~~ 这里的 dos_file 是需要被转换的文件,unix_file 是转换后的结果。这种形式的命令使用转义序列 \r 来代表回车符。查看 tr 命令所支持地完整的转义序列和字符类别列表,试试下面的命令: ~~~ [me@linuxbox ~]$ tr --help ~~~ > ROT13: 不那么秘密的编码环 > > tr 命令的一个有趣的用法是执行 ROT13文本编码。ROT13是一款微不足道的基于一种简易的替换暗码的 加密类型。把 ROT13称为“加密”是大方的;“文本模糊处理”更准确些。有时候它被用来隐藏文本中潜在的攻击内容。 这个方法就是简单地把每个字符在字母表中向前移动13位。因为移动的位数是可能的26个字符的一半, 所以对文本再次执行这个算法,就恢复到了它最初的形式。通过 tr 命令来执行这种编码: > > | _echo “secret text” | tr a-zA-Z n-za-mN-ZA-M_ | > > frperg grkg > > 再次执行相同的过程,得到翻译结果: > > | _echo “frperg grkg” | tr a-zA-Z n-za-mN-ZA-M+ | > > secret text > > 大量的 email 程序和 USENET 新闻读者都支持 ROT13 编码。Wikipedia 上面有一篇关于这个主题的好文章: > > [http://en.wikipedia.org/wiki/ROT13](http://en.wikipedia.org/wiki/ROT13) tr 也可以完成另一个技巧。使用-s 选项,tr 命令能“挤压”(删除)重复的字符实例: ~~~ [me@linuxbox ~]$ echo "aaabbbccc" | tr -s ab abccc ~~~ 这里我们有一个包含重复字符的字符串。通过给 tr 命令指定字符集“ab”,我们能够消除字符集中 字母的重复实例,然而会留下不属于字符集的字符(“c”)无更改。注意重复的字符必须是相邻的。 如果它们不相邻: ~~~ [me@linuxbox ~]$ echo "abcabcabc" | tr -s ab abcabcabc ~~~ 那么挤压会没有效果。 ### sed 名字 sed 是 stream editor(流编辑器)的简称。它对文本流进行编辑,要不是一系列指定的文件, 要不就是标准输入。sed 是一款强大的,并且有些复杂的程序(有整本内容都是关于 sed 程序的书籍), 所以在这里我们不会详尽的讨论它。 总之,sed 的工作方式是要不给出单个编辑命令(在命令行中)要不就是包含多个命令的脚本文件名, 然后它就按行来执行这些命令。这里有一个非常简单的 sed 实例: ~~~ [me@linuxbox ~]$ echo "front" | sed 's/front/back/' back ~~~ 在这个例子中,我们使用 echo 命令产生了一个单词的文本流,然后把它管道给 sed 命令。sed,依次, 对流文本执行指令 s/front/back/,随后输出“back”。我们也能够把这个命令认为是相似于 vi 中的“替换” (查找和替代)命令。 sed 中的命令开始于单个字符。在上面的例子中,这个替换命令由字母 s 来代表,其后跟着查找 和替代字符串,斜杠字符做为分隔符。分隔符的选择是随意的。按照惯例,经常使用斜杠字符, 但是 sed 将会接受紧随命令之后的任意字符做为分隔符。我们可以按照这种方式来执行相同的命令: ~~~ [me@linuxbox ~]$ echo "front" | sed 's\_front\_back\_' back ~~~ 通过紧跟命令之后使用下划线字符,则它变成界定符。sed 可以设置界定符的能力,使命令的可读性更强, 正如我们将看到的. sed 中的大多数命令之前都会带有一个地址,其指定了输入流中要被编辑的文本行。如果省略了地址, 然后会对输入流的每一行执行编辑命令。最简单的地址形式是一个行号。我们能够添加一个地址 到我们例子中: ~~~ [me@linuxbox ~]$ echo "front" | sed '1s/front/back/' back ~~~ 给我们的命令添加地址 1,就导致只对仅有一行文本的输入流的第一行执行替换操作。如果我们指定另一 个数字: ~~~ [me@linuxbox ~]$ echo "front" | sed '2s/front/back/' front ~~~ 我们看到没有执行这个编辑命令,因为我们的输入流没有第二行。地址可以用许多方式来表达。这里是 最常用的: 表21-7: sed 地址表示法 | 地址 | 说明 | |-----|-------| | n | 行号,n 是一个正整数。 | | $ | 最后一行。 | | /regexp/ | 所有匹配一个 POSIX 基本正则表达式的文本行。注意正则表达式通过 斜杠字符界定。选择性地,这个正则表达式可能由一个备用字符界定,通过\cregexpc 来 指定表达式,这里 c 就是一个备用的字符。 | | addr1,addr2 | 从 addr1 到 addr2 范围内的文本行,包含地址 addr2 在内。地址可能是上述任意 单独的地址形式。 | | first~step | 匹配由数字 first 代表的文本行,然后随后的每个在 step 间隔处的文本行。例如 1~2 是指每个位于偶数行号的文本行,5~5 则指第五行和之后每五行位置的文本行。 | | addr1,+n | 匹配地址 addr1 和随后的 n 个文本行。 | | addr! | 匹配所有的文本行,除了 addr 之外,addr 可能是上述任意的地址形式。 | 通过使用这一章中早前的 distros.txt 文件,我们将演示不同种类的地址表示法。首先,一系列行号: ~~~ [me@linuxbox ~]$ sed -n '1,5p' distros.txt SUSE 10.2 12/07/2006 Fedora 10 11/25/2008 SUSE 11.0 06/19/2008 Ubuntu 8.04 04/24/2008 Fedora 8 11/08/2007 ~~~ 在这个例子中,我们打印出一系列的文本行,开始于第一行,直到第五行。为此,我们使用 p 命令, 其就是简单地把匹配的文本行打印出来。然而为了高效,我们必须包含选项 -n(不自动打印选项), 让 sed 不要默认地打印每一行。 下一步,我们将试用一下正则表达式: ~~~ [me@linuxbox ~]$ sed -n '/SUSE/p' distros.txt SUSE 10.2 12/07/2006 SUSE 11.0 06/19/2008 SUSE 10.3 10/04/2007 SUSE 10.1 05/11/2006 ~~~ 通过包含由斜杠界定的正则表达式 \/SUSE\/,我们能够孤立出包含它的文本行,和 grep 程序的功能 是相同的。 最后,我们将试着否定上面的操作,通过给这个地址添加一个感叹号: ~~~ [me@linuxbox ~]$ sed -n '/SUSE/!p' distros.txt Fedora 10 11/25/2008 Ubuntu 8.04 04/24/2008 Fedora 8 11/08/2007 Ubuntu 6.10 10/26/2006 Fedora 7 05/31/2007 Ubuntu 7.10 10/18/2007 Ubuntu 7.04 04/19/2007 Fedora 6 10/24/2006 Fedora 9 05/13/2008 Ubuntu 6.06 06/01/2006 Ubuntu 8.10 10/30/2008 Fedora 5 03/20/2006 ~~~ 这里我们看到期望的结果:输出了文件中所有的文本行,除了那些匹配这个正则表达式的文本行。 目前为止,我们已经知道了两个 sed 的编辑命令,s 和 p。这里是一个更加全面的基本编辑命令列表: 表21-8: sed 基本编辑命令 | 命令 | 说明 | |-----|-------| | = | 输出当前的行号。 | | a | 在当前行之后追加文本。 | | d | 删除当前行。 | | i | 在当前行之前插入文本。 | | p | 打印当前行。默认情况下,sed 程序打印每一行,并且只是编辑文件中匹配 指定地址的文本行。通过指定-n 选项,这个默认的行为能够被忽略。 | | q | 退出 sed,不再处理更多的文本行。如果不指定-n 选项,输出当前行。 | | Q | 退出 sed,不再处理更多的文本行。 | | s/regexp/replacement/ | 只要找到一个 regexp 匹配项,就替换为 replacement 的内容。 replacement 可能包括特殊字符 &,其等价于由 regexp 匹配的文本。另外, replacement 可能包含序列 \1到 \9,其是 regexp 中相对应的子表达式的内容。更多信息,查看 下面 back references 部分的讨论。在 replacement 末尾的斜杠之后,可以指定一个 可选的标志,来修改 s 命令的行为。 | | y/set1/set2 | 执行字符转写操作,通过把 set1 中的字符转变为相对应的 set2 中的字符。 注意不同于 tr 程序,sed 要求两个字符集合具有相同的长度。 | 到目前为止,这个 s 命令是最常使用的编辑命令。我们将仅仅演示一些它的功能,通过编辑我们的 distros.txt 文件。我们以前讨论过 distros.txt 文件中的日期字段不是“友好地计算机”模式。 文件中的日期格式是 MM/DD/YYYY,但如果格式是 YYYY-MM-DD 会更好一些(利于排序)。手动修改 日期格式不仅浪费时间而且易出错,但是有了 sed,只需一步就能完成修改: ~~~ [me@linuxbox ~]$ sed 's/\([0-9]\{2\}\)\/\([0-9]\{2\}\)\/\([0-9]\{4\}\)$/\3-\1-\2/' distros.txt SUSE 10.2 2006-12-07 Fedora 10 2008-11-25 SUSE 11.0 2008-06-19 Ubuntu 8.04 2008-04-24 Fedora 8 2007-11-08 SUSE 10.3 2007-10-04 Ubuntu 6.10 2006-10-26 Fedora 7 2007-05-31 Ubuntu 7.10 2007-10-18 Ubuntu 7.04 2007-04-19 SUSE 10.1 2006-05-11 Fedora 6 2006-10-24 Fedora 9 2008-05-13 Ubuntu 6.06 2006-06-01 Ubuntu 8.10 2008-10-30 Fedora 5 2006-03-20 ~~~ 哇!这个命令看起来很丑陋。但是它起作用了。仅用一步,我们就更改了文件中的日期格式。 它也是一个关于为什么有时候会开玩笑地把正则表达式称为是“只写”媒介的完美的例子。我们 能写正则表达式,但是有时候我们不能读它们。在我们恐惧地忍不住要逃离此命令之前,让我们看一下 怎样来构建它。首先,我们知道此命令有这样一个基本的结构: ~~~ sed 's/regexp/replacement/' distros.txt ~~~ 我们下一步是要弄明白一个正则表达式将要孤立出日期。因为日期是 MM/DD/YYYY 格式,并且 出现在文本行的末尾,我们可以使用这样的表达式: ~~~ [0-9]{2}/[0-9]{2}/[0-9]{4}$ ~~~ 此表达式匹配两位数字,一个斜杠,两位数字,一个斜杠,四位数字,以及行尾。如此关心_regexp, 那么_replacement_又怎样呢?为了解决此问题,我们必须介绍一个正则表达式的新功能,它出现 在一些使用 BRE 的应用程序中。这个功能叫做_逆参照,像这样工作:如果序列\n 出现在_replacement_中 ,这里 n 是指从 1 到 9 的数字,则这个序列指的是在前面正则表达式中相对应的子表达式。为了 创建这个子表达式,我们简单地把它们用圆括号括起来,像这样: ~~~ ([0-9]{2})/([0-9]{2})/([0-9]{4})$ ~~~ 现在我们有了三个子表达式。第一个表达式包含月份,第二个包含某月中的某天,以及第三个包含年份。 现在我们就可以构建_replacement_,如下所示: ~~~ \3-\1-\2 ~~~ 此表达式给出了年份,一个斜杠,月份,一个斜杠,和某天。 ~~~ sed 's/([0-9]{2})/([0-9]{2})/([0-9]{4})$/\3-\1-\2/' distros.txt ~~~ 我们还有两个问题。第一个是在我们表达式中额外的斜杠将会迷惑 sed,当 sed 试图解释这个 s 命令 的时候。第二个是因为 sed,默认情况下,只接受基本的正则表达式,在表达式中的几个字符会 被当作文字字面值,而不是元字符。我们能够解决这两个问题,通过反斜杠的自由应用来转义 令人不快的字符: ~~~ sed 's/\([0-9]\{2\}\)\/\([0-9]\{2\}\)\/\([0-9]\{4\}\)$/\3-\1-\2/' distros.txt ~~~ 你掌握了吧! s 命令的另一个功能是使用可选标志,其跟随替代字符串。一个最重要的可选标志是 g 标志,其 指示 sed 对某个文本行全范围地执行查找和替代操作,不仅仅是对第一个实例,这是默认行为。 这里有个例子: ~~~ [me@linuxbox ~]$ echo "aaabbbccc" | sed 's/b/B/' aaaBbbccc ~~~ 我们看到虽然执行了替换操作,但是只针对第一个字母 “b” 实例,然而剩余的实例没有更改。通过添加 g 标志, 我们能够更改所有的实例: ~~~ [me@linuxbox ~]$ echo "aaabbbccc" | sed 's/b/B/g' aaaBBBccc ~~~ 目前为止,通过命令行我们只让 sed 执行单个命令。使用-f 选项,也有可能在一个脚本文件中构建更加复杂的命令。 为了演示,我们将使用 sed 和 distros.txt 文件来生成一个报告。我们的报告以开头标题,修改过的日期,以及 大写的发行版名称为特征。为此,我们需要编写一个脚本,所以我们将打开文本编辑器,然后输入以下文字: ~~~ # sed script to produce Linux distributions report 1 i\ \ Linux Distributions Report\ s/\([0-9]\{2\}\)\/\([0-9]\{2\}\)\/\([0-9]\{4\}\)$/\3-\1-\2/ y/abcdefghijklmnopqrstuvwxyz/ABCDEFGHIJKLMNOPQRSTUVWXYZ/ ~~~ 我们将把 sed 脚本保存为 distros.sed 文件,然后像这样运行它: ~~~ [me@linuxbox ~]$ sed -f distros.sed distros.txt Linux Distributions Report SUSE 10.2 2006-12-07 FEDORA 10 2008-11-25 SUSE 11.0 2008-06-19 UBUNTU 8.04 2008-04-24 FEDORA 8 2007-11-08 SUSE 10.3 2007-10-04 UBUNTU 6.10 2006-10-26 FEDORA 7 2007-05-31 UBUNTU 7.10 2007-10-18 UBUNTU 7.04 2007-04-19 SUSE 10.1 2006-05-11 FEDORA 6 2006-10-24 FEDORA 9 2008-05-13 ~~~ 正如我们所见,我们的脚本文件产生了期望的结果,但是它是如何做到的呢?让我们再看一下我们的脚本文件。 我们将使用 cat 来给每行文本编号: ~~~ [me@linuxbox ~]$ cat -n distros.sed 1 # sed script to produce Linux distributions report 2 3 1 i\ 4 \ 5 Linux Distributions Report\ 6 7 s/\([0-9]\{2\}\)\/\([0-9]\{2\}\)\/\([0-9]\{4\}\)$/\3-\1-\2/ 8 y/abcdefghijklmnopqrstuvwxyz/ABCDEFGHIJKLMNOPQRSTUVWXYZ/ ~~~ 我们脚本文件的第一行是一条注释。如同 Linux 系统中的许多配置文件和编程语言一样,注释以#字符开始, 然后是人类可读的文本。注释可以被放到脚本中的任意地方(虽然不在命令本身之中),且对任何 可能需要理解和/或维护脚本的人们都很有帮助。 第二行是一个空行。正如注释一样,添加空白行是为了提高程序的可读性。 许多 sed 命令支持行地址。这些行地址被用来指定对输入文本的哪一行执行操作。行地址可能被 表示为单独的行号,行号范围,以及特殊的行号“$”,它表示输入文本的最后一行。 从第三行到第六行所包含地文本要被插入到地址 1 处,也就是输入文本的第一行中。这个 i 命令 之后是反斜杠回车符,来产生一个转义的回车符,或者就是所谓的连行符。这个序列能够 被用在许多环境下,包括 shell 脚本,从而允许把回车符嵌入到文本流中,而没有通知 解释器(在这是指 sed 解释器)已经到达了文本行的末尾。这个 i 命令,同样地,命令 a(追加文本, 而不是插入文本)和 c(取代文本)命令都允许多个文本行,只要每个文本行,除了最后一行,以一个 连行符结束。实际上,脚本的第六行是插入文本的末尾,它以一个普通的回车符结尾,而不是一个 连行符,通知解释器 i 命令结束了。 * * * 注意:一个连行符由一个斜杠字符其后紧跟一个回车符组成。它们之间不允许有空白字符。 * * * 第七行是我们的查找和替代命令。因为命令之前没有添加地址,所以输入流中的每一行文本 都得服从它的操作。 第八行执行小写字母到大写字母的字符替换操作。注意不同于 tr 命令,这个 sed 中的 y 命令不 支持字符区域(例如,[a-z]),也不支持 POSIX 字符集。再说一次,因为 y 命令之前不带地址, 所以它会操作输入流的每一行。 > 喜欢 sed 的人们也会喜欢。。。 > > sed 是一款非常强大的程序,它能够针对文本流完成相当复杂的编辑任务。它最常 用于简单的行任务,而不是长长的脚本。许多用户喜欢使用其它工具,来执行较大的工作。 在这些工具中最著名的是 awk 和 perl。它们不仅仅是工具,像这里介绍的程序,且延伸到 完整的编程语言领域。特别是 perl,经常被用来代替 shell 脚本,来完成许多系统管理任务, 同时它也是一款非常流行网络开发语言。awk 更专用一些。其具体优点是其操作表格数据的能力。 awk 程序通常逐行处理文本文件,这点类似于 sed,awk 使用了一种方案,其与 sed 中地址 之后跟随编辑命令的概念相似。虽然关于 awk 和 perl 的内容都超出了本书所讨论的范围, 但是对于 Linux 命令行用户来说,它们都是非常好的技能。 ### aspell 我们要查看的最后一个工具是 aspell,一款交互式的拼写检查器。这个 aspell 程序是早先 ispell 程序 的继承者,大多数情况下,它可以被用做一个替代品。虽然 aspell 程序大多被其它需要拼写检查能力的 程序使用,但它也可以作为一个独立的命令行工具使用。它能够智能地检查各种类型的文本文件, 包括 HTML 文件,C/C++ 程序,电子邮件和其它种类的专业文本。 拼写检查一个包含简单的文本文件,可以这样使用 aspell: ~~~ aspell check textfile ~~~ 这里的 textfile 是要检查的文件名。作为一个实际例子,让我们创建一个简单的文本文件,叫做 foo.txt, 包含一些故意的拼写错误: ~~~ [me@linuxbox ~]$ cat > foo.txt The quick brown fox jimped over the laxy dog. ~~~ 下一步我们将使用 aspell 来检查文件: ~~~ [me@linuxbox ~]$ aspell check foo.txt ~~~ 因为 aspell 在检查模式下是交互的,我们将看到像这样的一个屏幕: ~~~ The quick brown fox jimped over the laxy dog. 1)jumped 6)wimped 2)gimped 7)camped 3)comped 8)humped 4)limped 9)impede 5)pimped 0)umped i)Ignore I)Ignore all r)Replace R)Replace all a)Add l)Add Lower b)Abort x)Exit ? ~~~ 在显示屏的顶部,我们看到我们的文本中有一个拼写可疑且高亮显示的单词。在中间部分,我们看到 十个拼写建议,序号从 0 到 9,然后是一系列其它可能的操作。最后,在最底部,我们看到一个提示符, 准备接受我们的选择。 如果我们按下 1 按键,aspell 会用单词 “jumped” 代替错误单词,然后移动到下一个拼写错的单词,就是 “laxy”。如果我们选择替代物 “lazy”,aspell 会替换 “laxy” 并且终止。一旦 aspell 结束操作,我们 可以检查我们的文件,会看到拼写错误的单词已经更正了。 ~~~ [me@linuxbox ~]$ cat foo.txt The quick brown fox jumped over the lazy dog. ~~~ 除非由命令行选项 --dont-backup 告诉 aspell,否则通过追加扩展名.bak 到文件名中, aspell 会创建一个包含原始文本的备份文件。 为了炫耀 sed 的编辑本领,我们将还原拼写错误,从而能够重用我们的文件: ~~~ [me@linuxbox ~]$ sed -i 's/lazy/laxy/; s/jumped/jimped/' foo.txt ~~~ 这个 sed 选项-i,告诉 sed 在适当位置编辑文件,意思是不要把编辑结果发送到标准输出中。sed 会把更改应用到文件中, 以此重新编写文件。我们也看到可以把多个 sed 编辑命令放在同一行,编辑命令之间由分号分隔开来。 下一步,我们将看一下 aspell 怎样来解决不同种类的文本文件。使用一个文本编辑器,例如 vim(胆大的人可能想用 sed), 我们将添加一些 HTML 标志到文件中: ~~~ Mispelled HTML file

The quick brown fox jimped over the laxy dog.

~~~ 现在,如果我们试图拼写检查我们修改的文件,我们会遇到一个问题。如果我们这样做: ~~~ [me@linuxbox ~]$ aspell check foo.txt ~~~ 我们会得到这些: ~~~ Mispelled HTML file

The quick brown fox jimped over the laxy dog.

1) HTML 4) Hamel 2) ht ml 5) Hamil 3) ht-ml 6) hotel i) Ignore I) Ignore all r) Replace R) Replace all a) Add l) Add Lower b) Abort x) Exit ? ~~~ aspell 会认为 HTML 标志的内容是拼写错误。通过包含-H(HTML)检查模式选项,这个问题能够 解决,像这样: ~~~ [me@linuxbox ~]$ aspell -H check foo.txt ~~~ 这会导致这样的结果: ~~~ <b>Mispelled</b> HTML file

The quick brown fox jimped over the laxy dog.

1) HTML 4) Hamel 2) ht ml 5) Hamil 3) ht-ml 6) hotel i) Ignore I) Ignore all r) Replace R) Replace all a) Add l) Add Lower b) Abort x) Exit ? ~~~ 这个 HTML 标志被忽略了,并且只会检查文件中非标志部分的内容。在这种模式下,HTML 标志的 内容被忽略了,不会进行拼写检查。然而,ALT 标志的内容,会被检查。 * * * 注意:默认情况下,aspell 会忽略文本中 URL 和电子邮件地址。通过命令行选项,可以重写此行为。 也有可能指定哪些标志进行检查及跳过。详细内容查看 aspell 命令手册。 * * * ## 总结归纳 在这一章中,我们已经查看了一些操作文本的命令行工具。在下一章中,我们会再看几个命令行工具。 诚然,看起来不能立即显现出怎样或为什么你可能使用这些工具为日常的基本工具, 虽然我们已经展示了一些半实际的命令用法的例子。我们将在随后的章节中发现这些工具组成 了解决实际问题的基本工具箱。这将是确定无疑的,当我们学习 shell 脚本的时候, 到时候这些工具将真正体现出它们的价值。 ## 拓展阅读 GNU 项目网站包含了本章中所讨论工具的许多在线指南。 * 来自 Coreutils 软件包: [http://www.gnu.org/software/coreutils/manual/coreutils.html#Output-of-entire-files](http://www.gnu.org/software/coreutils/manual/coreutils.html#Output-of-entire-files) [http://www.gnu.org/software/coreutils/manual/coreutils.html#Operating-on-sorted-files](http://www.gnu.org/software/coreutils/manual/coreutils.html#Operating-on-sorted-files) [http://www.gnu.org/software/coreutils/manual/coreutils.html#Operating-on-fields-within-a-line](http://www.gnu.org/software/coreutils/manual/coreutils.html#Operating-on-fields-within-a-line) [http://www.gnu.org/software/coreutils/manual/coreutils.html#Operating-on-characters](http://www.gnu.org/software/coreutils/manual/coreutils.html#Operating-on-characters) * 来自 Diffutils 软件包: [http://www.gnu.org/software/diffutils/manual/html_mono/diff.html](http://www.gnu.org/software/diffutils/manual/html_mono/diff.html) * sed 工具 [http://www.gnu.org/software/sed/manual/sed.html](http://www.gnu.org/software/sed/manual/sed.html) * aspell 工具 [http://aspell.net/man-html/index.html](http://aspell.net/man-html/index.html) * 尤其对于 sed 工具,还有很多其它的在线资源: [http://www.grymoire.com/Unix/Sed.html](http://www.grymoire.com/Unix/Sed.html) [http://sed.sourceforge.net/sed1line.txt](http://sed.sourceforge.net/sed1line.txt) * 试试用 google 搜索 “sed one liners”, “sed cheat sheets” 关键字 ### 友情提示 有一些更有趣的文本操作命令值得。在它们之间有:split(把文件分割成碎片), csplit(基于上下文把文件分割成碎片),和 sdiff(并排合并文件差异)。
';

第二十章:正则表达式

最后更新于:2022-04-02 01:45:52

接下来的几章中,我们将会看一下一些用来操作文本的工具。正如我们所见到的,在类 Unix 的 操作系统中,比如 Linux 中,文本数据起着举足轻重的作用。但是在我们能完全理解这些工具提供的 所有功能之前,我们不得不先看看,经常与这些工具的高级使用相关联的一门技术——正则表达式。 我们已经浏览了许多由命令行提供的功能和工具,我们遇到了一些真正神秘的 shell 功能和命令, 比如 shell 展开和引用,键盘快捷键,和命令历史,更不用说 vi 编辑器了。正则表达式延续了 这种“传统”,而且有可能(备受争议地)是其中最神秘的功能。这并不是说花费时间来学习它们 是不值得的,而是恰恰相反。虽然它们的全部价值可能不能立即显现,但是较强理解这些功能 使我们能够表演令人惊奇的技艺。什么是正则表达式? 简而言之,正则表达式是一种符号表示法,被用来识别文本模式。在某种程度上,它们与匹配 文件和路径名的 shell 通配符比较相似,但其规模更庞大。许多命令行工具和大多数的编程语言 都支持正则表达式,以此来帮助解决文本操作问题。然而,并不是所有的正则表达式都是一样的, 这就进一步混淆了事情;不同工具以及不同语言之间的正则表达式都略有差异。我们将会限定 POSIX 标准中描述的正则表达式(其包括了大多数的命令行工具),供我们讨论, 与许多编程语言(最著名的 Perl 语言)相反,它们使用了更多和更丰富的符号集。 ## grep 我们将使用的主要程序是我们的老朋友,grep 程序,它会用到正则表达式。实际上,“grep”这个名字 来自于短语“global regular expression print”,所以我们能看出 grep 程序和正则表达式有关联。 本质上,grep 程序会在文本文件中查找一个指定的正则表达式,并把匹配行输出到标准输出。 到目前为止,我们已经使用 grep 程序查找了固定的字符串,就像这样: ~~~ [me@linuxbox ~]$ ls /usr/bin | grep zip ~~~ 这个命令会列出,位于目录 /usr/bin 中,文件名中包含子字符串“zip”的所有文件。 这个 grep 程序以这样的方式来接受选项和参数: ~~~ grep [options] regex [file...] ~~~ 这里的 regx 是指一个正则表达式。 这是一个常用的 grep 选项列表: 表20-1: grep 选项 | 选项 | 描述 | |-------|---------| | -i | 忽略大小写。不会区分大小写字符。也可用--ignore-case 来指定。 | | -v | 不匹配。通常,grep 程序会打印包含匹配项的文本行。这个选项导致 grep 程序 只会不包含匹配项的文本行。也可用--invert-match 来指定。 | | -c | 打印匹配的数量(或者是不匹配的数目,若指定了-v 选项),而不是文本行本身。 也可用--count 选项来指定。 | | -l | 打印包含匹配项的文件名,而不是文本行本身,也可用--files-with-matches 选项来指定。 | | -L | 相似于-l 选项,但是只是打印不包含匹配项的文件名。也可用--files-without-match 来指定。 | | -n | 在每个匹配行之前打印出其位于文件中的相应行号。也可用--line-number 选项来指定。 | | -h | 应用于多文件搜索,不输出文件名。也可用--no-filename 选项来指定。 | 为了更好的探究 grep 程序,让我们创建一些文本文件来搜寻: ~~~ [me@linuxbox ~]$ ls /bin > dirlist-bin.txt [me@linuxbox ~]$ ls /usr/bin > dirlist-usr-bin.txt [me@linuxbox ~]$ ls /sbin > dirlist-sbin.txt [me@linuxbox ~]$ ls /usr/sbin > dirlist-usr-sbin.txt [me@linuxbox ~]$ ls dirlist*.txt dirlist-bin.txt dirlist-sbin.txt dirlist-usr-sbin.txt dirlist-usr-bin.txt ~~~ 我们能够对我们的文件列表执行简单的搜索,像这样: ~~~ [me@linuxbox ~]$ grep bzip dirlist*.txt dirlist-bin.txt:bzip2 dirlist-bin.txt:bzip2recover ~~~ 在这个例子里,grep 程序在所有列出的文件中搜索字符串 bzip,然后找到两个匹配项,其都在 文件 dirlist-bin.txt 中。如果我们只是对包含匹配项的文件列表,而不是对匹配项本身感兴趣 的话,我们可以指定-l 选项: ~~~ [me@linuxbox ~]$ grep -l bzip dirlist*.txt dirlist-bin.txt ~~~ 相反地,如果我们只想查看不包含匹配项的文件列表,我们可以这样操作: ~~~ [me@linuxbox ~]$ grep -L bzip dirlist*.txt dirlist-sbin.txt dirlist-usr-bin.txt dirlist-usr-sbin.txt ~~~ ## 元字符和文本 它可能看起来不明显,但是我们的 grep 程序一直使用了正则表达式,虽然是非常简单的例子。 这个正则表达式“bzip”意味着,匹配项所在行至少包含4个字符,并且按照字符 “b”, “z”, “i”, 和 “p”的顺序 出现在匹配行的某处,字符之间没有其它的字符。字符串“bzip”中的所有字符都是原义字符,因为 它们匹配本身。除了原义字符之外,正则表达式也可能包含元字符,其被用来指定更复杂的匹配项。 正则表达式元字符由以下字符组成: ~~~ ^ $ . [ ] { } - ? * + ( ) | \ ~~~ 然后其它所有字符都被认为是原义字符,虽然在个别情况下,反斜杠会被用来创建元序列, 也允许元字符被转义为原义字符,而不是被解释为元字符。 > 注意:正如我们所见到的,当 shell 执行展开的时候,许多正则表达式元字符,也是对 shell 有特殊 含义的字符。当我们在命令行中传递包含元字符的正则表达式的时候,把元字符用引号引起来至关重要, 这样可以阻止 shell 试图展开它们。 ## 任何字符 我们将要查看的第一个元字符是圆点字符,其被用来匹配任意字符。如果我们在正则表达式中包含它, 它将会匹配在此位置的任意一个字符。这里有个例子: ~~~ [me@linuxbox ~]$ grep -h '.zip' dirlist*.txt bunzip2 bzip2 bzip2recover gunzip gzip funzip gpg-zip preunzip prezip prezip-bin unzip unzipsfx ~~~ 我们在文件中查找包含正则表达式“.zip”的文本行。对于搜索结果,有几点需要注意一下。 注意没有找到这个 zip 程序。这是因为在我们的正则表达式中包含的圆点字符把所要求的匹配项的长度 增加到四个字符,并且字符串“zip”只包含三个字符,所以这个 zip 程序不匹配。另外,如果我们的文件列表 中有一些文件的扩展名是.zip,则它们也会成为匹配项,因为文件扩展名中的圆点符号也会被看作是 “任意字符”。 ## 锚点 在正则表达式中,插入符号和美元符号被看作是锚点。这意味着正则表达式 只有在文本行的开头或末尾被找到时,才算发生一次匹配。 ~~~ [me@linuxbox ~]$ grep -h '^zip' dirlist*.txt zip zipcloak zipgrep zipinfo zipnote zipsplit [me@linuxbox ~]$ grep -h 'zip$' dirlist*.txt gunzip gzip funzip gpg-zip preunzip prezip unzip zip [me@linuxbox ~]$ grep -h '^zip$' dirlist*.txt zip ~~~ 这里我们分别在文件列表中搜索行首,行尾以及行首和行尾同时包含字符串“zip”(例如,zip 独占一行)的匹配行。 注意正则表达式‘^$’(行首和行尾之间没有字符)会匹配空行。 ## 字谜助手 到目前为止,甚至凭借我们有限的正则表达式知识,我们已经能做些有意义的事情了。 我妻子喜欢玩字谜游戏,有时候她会因为一个特殊的问题,而向我求助。类似这样的问题,“一个 有五个字母的单词,它的第三个字母是‘j’,最后一个字母是‘r’,是哪个单词?”这类问题会 让我动脑筋想想。 你知道你的 Linux 系统中带有一本英文字典吗?千真万确。看一下 /usr/share/dict 目录,你就能找到一本, 或几本。存储在此目录下的字典文件,其内容仅仅是一个长长的单词列表,每行一个单词,按照字母顺序排列。在我的 系统中,这个文件仅包含98,000个单词。为了找到可能的上述字谜的答案,我们可以这样做: ~~~ [me@linuxbox ~]$ grep -i '^..j.r$' /usr/share/dict/words Major major ~~~ 使用这个正则表达式,我们能在我们的字典文件中查找到包含五个字母,且第三个字母 是“j”,最后一个字母是“r”的所有单词。 ## 中括号表达式和字符类 除了能够在正则表达式中的给定位置匹配任意字符之外,通过使用中括号表达式, 我们也能够从一个指定的字符集合中匹配一个单个的字符。通过中括号表达式,我们能够指定 一个字符集合(包含在不加中括号的情况下会被解释为元字符的字符)来被匹配。在这个例子里,使用了一个两个字符的集合: ~~~ [me@linuxbox ~]$ grep -h '[bg]zip' dirlist*.txt bzip2 bzip2recover gzip ~~~ 我们匹配包含字符串“bzip”或者“gzip”的任意行。 一个字符集合可能包含任意多个字符,并且元字符被放置到中括号里面后会失去了它们的特殊含义。 然而,在两种情况下,会在中括号表达式中使用元字符,并且有着不同的含义。第一个元字符 是插入字符,其被用来表示否定;第二个是连字符字符,其被用来表示一个字符区域。 ## 否定 如果在正则表示式中的第一个字符是一个插入字符,则剩余的字符被看作是不会在给定的字符位置出现的 字符集合。通过修改之前的例子,我们试验一下: ~~~ [me@linuxbox ~]$ grep -h '[^bg]zip' dirlist*.txt bunzip2 gunzip funzip gpg-zip preunzip prezip prezip-bin unzip unzipsfx ~~~ 通过激活否定操作,我们得到一个文件列表,它们的文件名都包含字符串“zip”,并且“zip”的前一个字符 是除了“b”和“g”之外的任意字符。注意文件 zip 没有被发现。一个否定的字符集仍然在给定位置要求一个字符, 但是这个字符必须不是否定字符集的成员。 这个插入字符如果是中括号表达式中的第一个字符的时候,才会唤醒否定功能;否则,它会失去 它的特殊含义,变成字符集中的一个普通字符。 ## 传统的字符区域 如果我们想要构建一个正则表达式,它可以在我们的列表中找到每个以大写字母开头的文件,我们 可以这样做: ~~~ [me@linuxbox ~]$ grep -h '^[ABCDEFGHIJKLMNOPQRSTUVWXZY]' dirlist*.txt ~~~ 这只是一个在正则表达式中输入26个大写字母的问题。但是输入所有字母非常令人烦恼,所以有另外一种方式: ~~~ [me@linuxbox ~]$ grep -h '^[A-Z]' dirlist*.txt MAKEDEV ControlPanel GET HEAD POST X X11 Xorg MAKEFLOPPIES NetworkManager NetworkManagerDispatcher ~~~ 通过使用一个三字符区域,我们能够缩写26个字母。任意字符的区域都能按照这种方式表达,包括多个区域, 比如下面这个表达式就匹配了所有以字母和数字开头的文件名: ~~~ [me@linuxbox ~]$ grep -h '^[A-Za-z0-9]' dirlist*.txt ~~~ 在字符区域中,我们看到这个连字符被特殊对待,所以我们怎样在一个正则表达式中包含一个连字符呢? 方法就是使连字符成为表达式中的第一个字符。考虑一下这两个例子: ~~~ [me@linuxbox ~]$ grep -h '[A-Z]' dirlist*.txt ~~~ 这会匹配包含一个大写字母的文件名。然而: ~~~ [me@linuxbox ~]$ grep -h '[-AZ]' dirlist*.txt ~~~ 上面的表达式会匹配包含一个连字符,或一个大写字母“A”,或一个大写字母“Z”的文件名。 ## POSIX 字符集 这个传统的字符区域在处理快速地指定字符集合的问题方面,是一个易于理解的和有效的方式。 不幸地是,它们不总是工作。到目前为止,虽然我们在使用 grep 程序的时候没有遇到任何问题, 但是我们可能在使用其它程序的时候会遭遇困难。 回到第5章,我们看看通配符怎样被用来完成路径名展开操作。在那次讨论中,我们说过在 某种程度上,那个字符区域被使用的方式几乎与在正则表达式中的用法一样,但是有一个问题: ~~~ [me@linuxbox ~]$ ls /usr/sbin/[ABCDEFGHIJKLMNOPQRSTUVWXYZ]* /usr/sbin/MAKEFLOPPIES /usr/sbin/NetworkManagerDispatcher /usr/sbin/NetworkManager ~~~ (依赖于不同的 Linux 发行版,我们将得到不同的文件列表,有可能是一个空列表。这个例子来自于 Ubuntu) 这个命令产生了期望的结果——只有以大写字母开头的文件名,但是: ~~~ [me@linuxbox ~]$ ls /usr/sbin/[A-Z]* /usr/sbin/biosdecode /usr/sbin/chat /usr/sbin/chgpasswd /usr/sbin/chpasswd /usr/sbin/chroot /usr/sbin/cleanup-info /usr/sbin/complain /usr/sbin/console-kit-daemon ~~~ 通过这个命令我们得到整个不同的结果(只显示了一部分结果列表)。为什么会是那样? 说来话长,但是这个版本比较简短: 追溯到 Unix 刚刚开发的时候,它只知道 ASCII 字符,并且这个特性反映了事实。在 ASCII 中,前32个字符 (数字0-31)都是控制码(如 tabs,backspaces,和回车)。随后的32个字符(32-63)包含可打印的字符, 包括大多数的标点符号和数字0到9。再随后的32个字符(64-95)包含大写字符和一些更多的标点符号。 最后的31个字符(96-127)包含小写字母和更多的标点符号。基于这种安排方式,系统使用这种排序规则 的 ASCII: `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz` 这个不同于正常的字典顺序,其像这样: `aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ` 随着 Unix 系统的知名度在美国之外的国家传播开来,就需要支持不在 U.S.英语范围内的字符。 于是就扩展了这个 ASCII 字符表,使用了整个8位,添加了字符(数字128-255),这样就 容纳了更多的语言。 为了支持这种能力,POSIX 标准介绍了一种叫做 locale 的概念,其可以被调整,来为某个特殊的区域, 选择所需的字符集。通过使用下面这个命令,我们能够查看到我们系统的语言设置: ~~~ [me@linuxbox ~]$ echo $LANG en_US.UTF-8 ~~~ 通过这个设置,POSIX 相容的应用程序将会使用字典排列顺序而不是 ASCII 顺序。这就解释了上述命令的行为。 当[A-Z]字符区域按照字典顺序解释的时候,包含除了小写字母“a”之外的所有字母,因此得到这样的结果。 为了部分地解决这个问题,POSIX 标准包含了大量的字符集,其提供了有用的字符区域。 下表中描述了它们: 表20-2: POSIX 字符集 | 字符集 | 说明 | |-------|-------| | [:alnum:] | 字母数字字符。在 ASCII 中,等价于:[A-Za-z0-9] | | [:word:] | 与[:alnum:]相同, 但增加了下划线字符。 | | [:alpha:] | 字母字符。在 ASCII 中,等价于:[A-Za-z] | | [:blank:] | 包含空格和 tab 字符。 | | [:cntrl:] | ASCII 的控制码。包含了0到31,和127的 ASCII 字符。 | | [:digit:] | 数字0到9 | | [:graph:] | 可视字符。在 ASCII 中,它包含33到126的字符。 | | [:lower:] | 小写字母。 | | [:punct:] | 标点符号字符。在 ASCII 中,等价于: | | [:print:] | 可打印的字符。在[:graph:]中的所有字符,再加上空格字符。 | | [:space:] | 空白字符,包括空格,tab,回车,换行,vertical tab, 和 form feed.在 ASCII 中, 等价于:[ \t\r\n\v\f] | | [:upper:] | 大写字母。 | | [:xdigit:] | 用来表示十六进制数字的字符。在 ASCII 中,等价于:[0-9A-Fa-f] | 甚至通过字符集,仍然没有便捷的方法来表达部分区域,比如[A-M]。 通过使用字符集,我们重做上述的例题,看到一个改进的结果: ~~~ [me@linuxbox ~]$ ls /usr/sbin/[[:upper:]]* /usr/sbin/MAKEFLOPPIES /usr/sbin/NetworkManagerDispatcher /usr/sbin/NetworkManager ~~~ 记住,然而,这不是一个正则表达式的例子,而是 shell 正在执行路径名展开操作。我们在这里展示这个例子, 是因为 POSIX 规范的字符集适用于二者。 ## 恢复到传统的排列顺序 通过改变环境变量 LANG 的值,你可以选择让你的系统使用传统的(ASCII)排列规则。如上所示,这个 LANG 变量包含了语种和字符集。这个值最初由你安装 Linux 系统时所选择的安装语言决定。 使用 locale 命令,来查看 locale 的设置。 ~~~ [me@linuxbox ~]$ locale LANG=en_US.UTF-8 LC_CTYPE="en_US.UTF-8" LC_NUMERIC="en_US.UTF-8" LC_TIME="en_US.UTF-8" LC_COLLATE="en_US.UTF-8" LC_MONETARY="en_US.UTF-8" LC_MESSAGES="en_US.UTF-8" LC_PAPER="en_US.UTF-8" LC_NAME="en_US.UTF-8" LC_ADDRESS="en_US.UTF-8" LC_TELEPHONE="en_US.UTF-8" LC_MEASUREMENT="en_US.UTF-8" LC_IDENTIFICATION="en_US.UTF-8" LC_ALL= ~~~ 把这个 LANG 变量设置为 POSIX,来更改 locale,使其使用传统的 Unix 行为。 ~~~ [me@linuxbox ~]$ export LANG=POSIX ~~~ 注意这个改动使系统为它的字符集使用 U.S.英语(更准确地说,ASCII),所以要确认一下这 是否是你真正想要的效果。通过把这条语句添加到你的.bashrc 文件中,你可以使这个更改永久有效。 ~~~ export LANG=POSIX ~~~ ## POSIX 基本的 Vs.扩展的正则表达式 就在我们认为这已经非常令人困惑了,我们却发现 POSIX 把正则表达式的实现分成了两类: 基本正则表达式(BRE)和扩展的正则表达式(ERE)。既服从 POSIX 规范又实现了 BRE 的任意应用程序,都支持我们目前研究的所有正则表达式特性。我们的 grep 程序就是其中一个。 BRE 和 ERE 之间有什么区别呢?这是关于元字符的问题。BRE 可以辨别以下元字符: ~~~ ^ $ . [ ] * ~~~ 其它的所有字符被认为是文本字符。ERE 添加了以下元字符(以及与其相关的功能): ~~~ ( ) { } ? + | ~~~ 然而(这也是有趣的地方),在 BRE 中,字符“(”,“)”,“{”,和 “}”用反斜杠转义后,被看作是元字符, 相反在 ERE 中,在任意元字符之前加上反斜杠会导致其被看作是一个文本字符。在随后的讨论中将会涵盖 很多奇异的特性。 因为我们将要讨论的下一个特性是 ERE 的一部分,我们将要使用一个不同的 grep 程序。照惯例, 一直由 egrep 程序来执行这项操作,但是 GUN 版本的 grep 程序也支持扩展的正则表达式,当使用了-E 选项之后。 在 20 世纪 80 年代,Unix 成为一款非常流行的商业操作系统,但是到了1988年,Unix 世界 一片混乱。许多计算机制造商从 Unix 的创建者 AT&T 那里得到了许可的 Unix 源码,并且 供应各种版本的操作系统。然而,在他们努力创造产品差异化的同时,每个制造商都增加了 专用的更改和扩展。这就开始限制了软件的兼容性。 专有软件供应商一如既往,每个供应商都试图玩嬴游戏“锁定”他们的客户。这个 Unix 历史上 的黑暗时代,就是今天众所周知的 “the Balkanization”。 然后进入 IEEE( 电气与电子工程师协会 )时代。在上世纪 80 年代中叶,IEEE 开始制定一套标准, 其将会定义 Unix 系统( 以及类 Unix 的系统 )如何执行。这些标准,正式成为 IEEE 1003, 定义了应用程序编程接口( APIs ),shell 和一些实用程序,其将会在标准的类 Unix 操作系统中找到。“POSIX” 这个名字,象征着可移植的操作系统接口(为了额外的,添加末尾的 “X” ), 是由 Richard Stallman 建议的( 是的,的确是 Richard Stallman ),后来被 IEEE 采纳。 ## Alternation 我们将要讨论的扩展表达式的第一个特性叫做 alternation(交替),其是一款允许从一系列表达式 之间选择匹配项的实用程序。就像中括号表达式允许从一系列指定的字符之间匹配单个字符那样, alternation 允许从一系列字符串或者是其它的正则表达式中选择匹配项。为了说明问题, 我们将会结合 echo 程序来使用 grep 命令。首先,让我们试一个普通的字符串匹配: ~~~ [me@linuxbox ~]$ echo "AAA" | grep AAA AAA [me@linuxbox ~]$ echo "BBB" | grep AAA [me@linuxbox ~]$ ~~~ 一个相当直截了当的例子,我们把 echo 的输出管道给 grep,然后看到输出结果。当出现 一个匹配项时,我们看到它会打印出来;当没有匹配项时,我们看到没有输出结果。 现在我们将添加 alternation,以竖杠线元字符为标记: ~~~ [me@linuxbox ~]$ echo "AAA" | grep -E 'AAA|BBB' AAA [me@linuxbox ~]$ echo "BBB" | grep -E 'AAA|BBB' BBB [me@linuxbox ~]$ echo "CCC" | grep -E 'AAA|BBB' [me@linuxbox ~]$ ~~~ 这里我们看到正则表达式’AAA|BBB’,这意味着“匹配字符串 AAA 或者是字符串 BBB”。注意因为这是 一个扩展的特性,我们给 grep 命令(虽然我们能以 egrep 程序来代替)添加了-E 选项,并且我们 把这个正则表达式用单引号引起来,为的是阻止 shell 把竖杠线元字符解释为一个 pipe 操作符。 Alternation 并不局限于两种选择: ~~~ [me@linuxbox ~]$ echo "AAA" | grep -E 'AAA|BBB|CCC' AAA ~~~ 为了把 alternation 和其它正则表达式元素结合起来,我们可以使用()来分离 alternation。 ~~~ [me@linuxbox ~]$ grep -Eh '^(bz|gz|zip)' dirlist*.txt ~~~ 这个表达式将会在我们的列表中匹配以“bz”,或“gz”,或“zip”开头的文件名。如果我们删除了圆括号, 这个表达式的意思: ~~~ [me@linuxbox ~]$ grep -Eh '^bz|gz|zip' dirlist*.txt ~~~ 会变成匹配任意以“bz”开头,或包含“gz”,或包含“zip”的文件名。 ## 限定符 扩展的正则表达式支持几种方法,来指定一个元素被匹配的次数。 ### ? - 匹配零个或一个元素 这个限定符意味着,实际上,“使前面的元素可有可无。”比方说我们想要查看一个电话号码的真实性, 如果它匹配下面两种格式的任意一种,我们就认为这个电话号码是真实的: ~~~ (nnn) nnn-nnnn nnn nnn-nnnn ~~~ 这里的“n”是一个数字。我们可以构建一个像这样的正则表达式: ~~~ ^\(?[0-9][0-9][0-9]\)? [0-9][0-9][0-9]-[0-9][0-9][0-9][0-9]$ ~~~ 在这个表达式中,我们在圆括号之后加上一个问号,来表示它们将被匹配零次或一次。再一次,因为 通常圆括号都是元字符(在 ERE 中),所以我们在圆括号之前加上了反斜杠,使它们成为文本字符。 让我们试一下: ~~~ [me@linuxbox ~]$ echo "(555) 123-4567" | grep -E '^\(?[0-9][0-9][0-9] \)? [0-9][0-9][0-9]$' (555) 123-4567 [me@linuxbox ~]$ echo "555 123-4567" | grep -E '^\(?[0-9][0-9][0-9]\) ? [0-9][0-9][0-9]-[0-9][0-9][0-9][0-9]$' 555 123-4567 [me@linuxbox ~]$ echo "AAA 123-4567" | grep -E '^\(?[0-9][0-9][0-9]\) ? [0-9][0-9][0-9]-[0-9][0-9][0-9][0-9]$' [me@linuxbox ~]$ ~~~ 这里我们看到这个表达式匹配这个电话号码的两种形式,但是不匹配包含非数字字符的号码。 ### * - 匹配零个或多个元素 像 ? 元字符一样,这个 * 被用来表示一个可选的字符;然而,又与 ? 不同,匹配的字符可以出现 任意多次,不仅是一次。比方说我们想要知道是否一个字符串是一句话;也就是说,字符串开始于 一个大写字母,然后包含任意多个大写和小写的字母和空格,最后以句号收尾。为了匹配这个(非常粗略的) 语句的定义,我们能够使用一个像这样的正则表达式: ~~~ [[:upper:]][[:upper:][:lower:] ]*. ~~~ 这个表达式由三个元素组成:一个包含[:upper:]字符集的中括号表达式,一个包含[:upper:]和[:lower:] 两个字符集以及一个空格的中括号表达式,和一个被反斜杠字符转义过的圆点。第二个元素末尾带有一个 *元字符,所以在开头的大写字母之后,可能会跟随着任意数目的大写和小写字母和空格,并且匹配: ~~~ [me@linuxbox ~]$ echo "This works." | grep -E '[[:upper:]][[:upper:][[:lower:]]*.' This works. [me@linuxbox ~]$ echo "This Works." | grep -E '[[:upper:]][[:upper:][[:lower:]]*.' This Works. [me@linuxbox ~]$ echo "this does not" | grep -E '[[:upper:]][[:upper: ][[:lower:]]*.' [me@linuxbox ~]$ ~~~ 这个表达式匹配前两个测试语句,但不匹配第三个,因为第三个句子缺少开头的大写字母和末尾的句号。 ### + - 匹配一个或多个元素 这个 + 元字符的作用与 * 非常相似,除了它要求前面的元素至少出现一次匹配。这个正则表达式只匹配 那些由一个或多个字母字符组构成的文本行,字母字符之间由单个空格分开: ~~~ ^([[:alpha:]]+ ?)+$ [me@linuxbox ~]$ echo "This that" | grep -E '^([[:alpha:]]+ ?)+$' This that [me@linuxbox ~]$ echo "a b c" | grep -E '^([[:alpha:]]+ ?)+$' a b c [me@linuxbox ~]$ echo "a b 9" | grep -E '^([[:alpha:]]+ ?)+$' [me@linuxbox ~]$ echo "abc d" | grep -E '^([[:alpha:]]+ ?)+$' [me@linuxbox ~]$ ~~~ 我们看到这个正则表达式不匹配“a b 9”这一行,因为它包含了一个非字母的字符;它也不匹配 “abc d” ,因为在字符“c”和“d”之间不止一个空格。 ### { } - 匹配特定个数的元素 这个 { 和 } 元字符都被用来表达要求匹配的最小和最大数目。它们可以通过四种方法来指定: 表20-3: 指定匹配的数目 | 限定符 | 意思 | |-------|-------| | {n} | 匹配前面的元素,如果它确切地出现了 n 次。 | | {n,m} | 匹配前面的元素,如果它至少出现了 n 次,但是不多于 m 次。 | | {n,} | 匹配前面的元素,如果它出现了 n 次或多于 n 次。 | | {,m} | 匹配前面的元素,如果它出现的次数不多于 m 次。 | 回到之前处理电话号码的例子,我们能够使用这种指定重复次数的方法来简化我们最初的正则表达式: ~~~ ^\(?[0-9][0-9][0-9]\)? [0-9][0-9][0-9]-[0-9][0-9][0-9][0-9]$ ~~~ 简化为: ~~~ ^\(?[0-9]{3}\)? [0-9]{3}-[0-9]{4}$ ~~~ 让我们试一下: ~~~ [me@linuxbox ~]$ echo "(555) 123-4567" | grep -E '^\(?[0-9]{3}\)? [0-9]{3}-[0-9]{4}$' (555) 123-4567 [me@linuxbox ~]$ echo "555 123-4567" | grep -E '^\(?[0-9]{3}\)? [0-9]{3}-[0-9]{4}$' 555 123-4567 [me@linuxbox ~]$ echo "5555 123-4567" | grep -E '^\(?[0-9]{3}\)? [0-9]{3}-[0-9]{4}$' [me@linuxbox ~]$ ~~~ 我们可以看到,我们修订的表达式能成功地验证带有和不带有圆括号的数字,而拒绝那些格式 不正确的数字。 ## 让正则表达式工作起来 让我们看看一些我们已经知道的命令,然后看一下它们怎样使用正则表达式。 ### 通过 grep 命令来验证一个电话簿 在我们先前的例子中,我们查看过单个电话号码,并且检查了它们的格式。一个更现实的 情形是检查一个数字列表,所以我们先创建一个列表。我们将背诵一个神奇的咒语到命令行中。 它会很神奇,因为我们还没有涵盖所涉及的大部分命令,但是不要担心。我们将在后面的章节里面 讨论那些命令。这里是这个咒语: ~~~ [me@linuxbox ~]$ for i in {1..10}; do echo "(${RANDOM:0:3}) ${RANDO M:0:3}-${RANDOM:0:4}" >> phonelist.txt; done ~~~ 这个命令会创建一个包含10个电话号码的名为 phonelist.txt 的文件。每次重复这个命令的时候, 另外10个号码会被添加到这个列表中。我们也能够更改命令开头附近的数值10,来生成或多或少的 电话号码。如果我们查看这个文件的内容,然而我们会发现一个问题: ~~~ [me@linuxbox ~]$ cat phonelist.txt (232) 298-2265 (624) 381-1078 (540) 126-1980 (874) 163-2885 (286) 254-2860 (292) 108-518 (129) 44-1379 (458) 273-1642 (686) 299-8268 (198) 307-2440 ~~~ 一些号码是残缺不全的,但是它们很适合我们的需求,因为我们将使用 grep 命令来验证它们。 一个有用的验证方法是扫描这个文件,查找无效的号码,并把搜索结果显示到屏幕上: ~~~ [me@linuxbox ~]$ grep -Ev '^\([0-9]{3}\) [0-9]{3}-[0-9]{4}$' phonelist.txt (292) 108-518 (129) 44-1379 [me@linuxbox ~]$ ~~~ 这里我们使用-v 选项来产生相反的匹配,因此我们将只输出不匹配指定表达式的文本行。这个 表达式自身的两端都包含定位点(锚)元字符,是为了确保这个号码的两端没有多余的字符。 这个表达式也要求圆括号出现在一个有效的号码中,不同于我们先前电话号码的实例。 ### 用 find 查找丑陋的文件名 这个 find 命令支持一个基于正则表达式的测试。当在使用正则表达式方面比较 find 和 grep 命令的时候, 还有一个重要问题要牢记在心。当某一行包含的字符串匹配上了一个表达式的时候,grep 命令会打印出这一行, 然而 find 命令要求路径名精确地匹配这个正则表达式。在下面的例子里面,我们将使用带有一个正则 表达式的 find 命令,来查找每个路径名,其包含的任意字符都不是以下字符集中的一员。 ~~~ [-\_./0-9a-zA-Z] ~~~ 这样一种扫描会发现包含空格和其它潜在不规范字符的路径名: ~~~ [me@linuxbox ~]$ find . -regex '.*[^-\_./0-9a-zA-Z].*' ~~~ 由于要精确地匹配整个路径名,所以我们在表达式的两端使用了.*,来匹配零个或多个字符。 在表达式中间,我们使用了否定的中括号表达式,其包含了我们一系列可接受的路径名字符。 ### 用 locate 查找文件 这个 locate 程序支持基本的(--regexp 选项)和扩展的(--regex 选项)正则表达式。通过 locate 命令,我们能够执行许多与先前操作 dirlist 文件时相同的操作: ~~~ [me@linuxbox ~]$ locate --regex 'bin/(bz|gz|zip)' /bin/bzcat /bin/bzcmp /bin/bzdiff /bin/bzegrep /bin/bzexe /bin/bzfgrep /bin/bzgrep /bin/bzip2 /bin/bzip2recover /bin/bzless /bin/bzmore /bin/gzexe /bin/gzip /usr/bin/zip /usr/bin/zipcloak /usr/bin/zipgrep /usr/bin/zipinfo /usr/bin/zipnote /usr/bin/zipsplit ~~~ 通过使用 alternation,我们搜索包含 bin/bz,bin/gz,或/bin/zip 字符串的路径名。 ### 在 less 和 vim 中查找文本 less 和 vim 两者享有相同的文本查找方法。按下/按键,然后输入正则表达式,来执行搜索任务。 如果我们使用 less 程序来浏览我们的 phonelist.txt 文件: ~~~ [me@linuxbox ~]$ less phonelist.txt ~~~ 然后查找我们有效的表达式: ~~~ (232) 298-2265 (624) 381-1078 (540) 126-1980 (874) 163-2885 (286) 254-2860 (292) 108-518 (129) 44-1379 (458) 273-1642 (686) 299-8268 (198) 307-2440 ~ /^\([0-9]{3}\) [0-9]{3}-[0-9]{4}$ ~~~ less 将会高亮匹配到的字符串,这样就很容易看到无效的电话号码: ~~~ (232) 298-2265 (624) 381-1078 (540) 126-1980 (874) 163-2885 (286) 254-2860 (292) 108-518 (129) 44-1379 (458) 273-1642 (686) 299-8268 (198) 307-2440 ~ (END) ~~~ 另一方面,vim 支持基本的正则表达式,所以我们用于搜索的表达式看起来像这样: ~~~ /([0-9]\{3\}) [0-9]\{3\}-[0-9]\{4\} ~~~ 我们看到表达式几乎一样;然而,在扩展表达式中,许多被认为是元字符的字符在基本的表达式 中被看作是文本字符。只有用反斜杠把它们转义之后,它们才被看作是元字符。 依赖于系统中 vim 的特殊配置,匹配项将会被高亮。如若不是,试试这个命令模式: ~~~ :hlsearch ~~~ 来激活搜索高亮功能。 注意:依赖于你的发行版,vim 有可能支持或不支持文本搜索高亮功能。尤其是 Ubuntu 自带了 一款非常简化的 vim 版本。在这样的系统中,你可能要使用你的软件包管理器来安装一个功能 更完备的 vim 版本。 ## 总结归纳 在这章中,我们已经看到几个使用正则表达式例子。如果我们使用正则表达式来搜索那些使用正则表达式的应用程序, 我们可以找到更多的使用实例。通过查找手册页,我们就能找到: ~~~ [me@linuxbox ~]$ cd /usr/share/man/man1 [me@linuxbox man1]$ zgrep -El 'regex|regular expression' *.gz ~~~ 这个 zgrep 程序是 grep 的前端,允许 grep 来读取压缩文件。在我们的例子中,我们在手册文件所在的 目录中,搜索压缩文件中的内容。这个命令的结果是一个包含字符串“regex”或者“regular expression”的文件列表。正如我们所看到的,正则表达式会出现在大量程序中。 基本正则表达式中有一个特性,我们没有涵盖。叫做反引用,这个特性在下一章中会被讨论到。 ## 拓展阅读 有许多在线学习正则表达式的资源,包括各种各样的教材和速记表。 另外,关于下面的背景话题,Wikipedia 有不错的文章。 * POSIX: [http://en.wikipedia.org/wiki/Posix](http://en.wikipedia.org/wiki/Posix) * ASCII: [http://en.wikipedia.org/wiki/Ascii](http://en.wikipedia.org/wiki/Ascii)
';

第十九章:归档和备份

最后更新于:2022-04-02 01:45:50

计算机系统管理员的一个主要任务就是保护系统的数据安全,其中一种方法是通过时时备份系统文件,来保护 数据。即使你不是一名系统管理员,像做做拷贝或者在各个位置和设备之间移动大量的文件,通常也是很有帮助的。 在这一章中,我们将会看看几个经常用来管理文件集合的程序。 它们就是文件压缩程序: > * gzip – 压缩或者展开文件 > * bzip2 – 块排序文件压缩器 归档程序: > * tar – 磁带打包工具 > * zip – 打包和压缩文件 还有文件同步程序: > * rsync – 同步远端文件和目录 ## 压缩文件 纵观计算领域的发展历史,人们努力想把最多的数据存放到到最小的可用空间中,不管是内存,存储设备 还是网络带宽。今天我们把许多数据服务都看作是理所当然的事情,但是诸如便携式音乐播放器, 高清电视,或宽带网络之类的存在都应归功于高效的数据压缩技术。 数据压缩就是一个删除冗余数据的过程。让我们考虑一个假想的例子,比方说我们有一张100*100像素的 纯黑的图片文件。根据数据存储方案(假定每个像素占24位,或者3个字节),那么这张图像将会占用 30,000个字节的存储空间: ~~~ 100 * 100 * 3 = 30,000 ~~~ 一张单色图像包含的数据全是多余的。我们要是聪明的话,可以用这种方法来编码这些数据, 我们只要简单地描述这个事实,我们有3万个黑色的像素数据块。所以,我们不存储包含3万个0 (通常在图像文件中,黑色由0来表示)的数据块,取而代之,我们把这些数据压缩为数字30,000, 后跟一个0,来表示我们的数据。这种数据压缩方案被称为游程编码,是一种最基本的压缩技术。 压缩算法(数学技巧被用来执行压缩任务)分为两大类,无损压缩和有损压缩。无损压缩保留了 原始文件的所有数据。这意味着,当还原一个压缩文件的时候,还原的文件与原文件一模一样。 而另一方面,有损压缩,执行压缩操作时会删除数据,允许更大的压缩。当一个有损文件被还原的时候, 它与原文件不相匹配; 相反,它是一个近似值。有损压缩的例子有 JPEG(图像)文件和 MP3(音频)文件。 在我们的讨论中,我们将看看完全无损压缩,因为计算机中的大多数数据是不能容忍丢失任何数据的。 ### gzip 这个 gzip 程序被用来压缩一个或多个文件。当执行 gzip 命令时,则原始文件的压缩版会替代原始文件。 相对应的 gunzip 程序被用来把压缩文件复原为没有被压缩的版本。这里有个例子: ~~~ [me@linuxbox ~]$ ls -l /etc > foo.txt [me@linuxbox ~]$ ls -l foo.* -rw-r--r-- 1 me me 15738 2008-10-14 07:15 foo.txt [me@linuxbox ~]$ gzip foo.txt [me@linuxbox ~]$ ls -l foo.* -rw-r--r-- 1 me me 3230 2008-10-14 07:15 foo.txt.gz [me@linuxbox ~]$ gunzip foo.txt.gz [me@linuxbox ~]$ ls -l foo.* -rw-r--r-- 1 me me 15738 2008-10-14 07:15 foo.txt ~~~ 在这个例子里,我们创建了一个名为 foo.txt 的文本文件,其内容包含一个目录的列表清单。 接下来,我们运行 gzip 命令,它会把原始文件替换为一个叫做 foo.txt.gz 的压缩文件。在 foo.* 文件列表中,我们看到原始文件已经被压缩文件替代了,并将这个压缩文件大约是原始 文件的十五分之一。我们也能看到压缩文件与原始文件有着相同的权限和时间戳。 接下来,我们运行 gunzip 程序来解压缩文件。随后,我们能见到压缩文件已经被原始文件替代了, 同样地保留了相同的权限和时间戳。 gzip 命令有许多选项。这里列出了一些: 表19-1: gzip 选项 | 选项 | 说明 | |---------|------------| | -c | 把输出写入到标准输出,并且保留原始文件。也有可能用--stdout 和--to-stdout 选项来指定。 | | -d | 解压缩。正如 gunzip 命令一样。也可以用--decompress 或者--uncompress 选项来指定. | | -f | 强制压缩,即使原始文件的压缩文件已经存在了,也要执行。也可以用--force 选项来指定。 | | -h | 显示用法信息。也可用--help 选项来指定。 | | -l | 列出每个被压缩文件的压缩数据。也可用--list 选项。 | | -r | 若命令的一个或多个参数是目录,则递归地压缩目录中的文件。也可用--recursive 选项来指定。 | | -t | 测试压缩文件的完整性。也可用--test 选项来指定。 | | -v | 显示压缩过程中的信息。也可用--verbose 选项来指定。 | | -number | 设置压缩指数。number 是一个在1(最快,最小压缩)到9(最慢,最大压缩)之间的整数。 数值1和9也可以各自用--fast 和--best 选项来表示。默认值是整数6。 | 返回到我们之前的例子中: ~~~ [me@linuxbox ~]$ gzip foo.txt [me@linuxbox ~]$ gzip -tv foo.txt.gz foo.txt.gz: OK [me@linuxbox ~]$ gzip -d foo.txt.gz ~~~ 这里,我们用压缩文件来替代文件 foo.txt,压缩文件名为 foo.txt.gz。下一步,我们测试了压缩文件 的完整性,使用了-t 和-v 选项。 ~~~ [me@linuxbox ~]$ ls -l /etc | gzip > foo.txt.gz ~~~ 这个命令创建了一个目录列表的压缩文件。 这个 gunzip 程序,会解压缩 gzip 文件,假定那些文件名的扩展名是.gz,所以没有必要指定它, 只要指定的名字与现有的未压缩文件不冲突就可以: ~~~ [me@linuxbox ~]$ gunzip foo.txt ~~~ 如果我们的目标只是为了浏览一下压缩文本文件的内容,我们可以这样做: ~~~ [me@linuxbox ~]$ gunzip -c foo.txt | less ~~~ 另外,对应于 gzip 还有一个程序,叫做 zcat,它等同于带有-c 选项的 gunzip 命令。 它可以被用来如 cat 命令作用于 gzip 压缩文件: ~~~ [me@linuxbox ~]$ zcat foo.txt.gz | less ~~~ * * * 小贴士: 还有一个 zless 程序。它与上面的管道线有相同的功能。 * * * ### bzip2 这个 bzip2 程序,由 Julian Seward 开发,与 gzip 程序相似,但是使用了不同的压缩算法, 舍弃了压缩速度,而实现了更高的压缩级别。在大多数情况下,它的工作模式等同于 gzip。 由 bzip2 压缩的文件,用扩展名 .bz2 来表示: ~~~ [me@linuxbox ~]$ ls -l /etc > foo.txt [me@linuxbox ~]$ ls -l foo.txt -rw-r--r-- 1 me me 15738 2008-10-17 13:51 foo.txt [me@linuxbox ~]$ bzip2 foo.txt [me@linuxbox ~]$ ls -l foo.txt.bz2 -rw-r--r-- 1 me me 2792 2008-10-17 13:51 foo.txt.bz2 [me@linuxbox ~]$ bunzip2 foo.txt.bz2 ~~~ 正如我们所看到的,bzip2 程序使用起来和 gzip 程序一样。我们之前讨论的 gzip 程序的所有选项(除了-r) ,bzip2 程序同样也支持。注意,然而,压缩级别选项(-number)对于 bzip2 程序来说,有少许不同的含义。 伴随着 bzip2 程序,有 bunzip2 和 bzcat 程序来解压缩文件。bzip2 文件也带有 bzip2recover 程序,其会 试图恢复受损的 .bz2 文件。 > 不要强迫性压缩 > > 我偶然见到人们试图用高效的压缩算法,来压缩一个已经被压缩过的文件,通过这样做: > > $ gzip picture.jpg > > 不要这样。你可能只是在浪费时间和空间!如果你再次压缩已经压缩过的文件,实际上你 会得到一个更大的文件。这是因为所有的压缩技术都会涉及一些开销,文件中会被添加描述 此次压缩过程的信息。如果你试图压缩一个已经不包含多余信息的文件,那么再次压缩不会节省 空间,以抵消额外的花费。 ## 归档文件 一个常见的,与文件压缩结合一块使用的文件管理任务是归档。归档就是收集许多文件,并把它们 捆绑成一个大文件的过程。归档经常作为系统备份的一部分来使用。当把旧数据从一个系统移到某 种类型的长期存储设备中时,也会用到归档程序。 ### tar 在类 Unix 的软件世界中,这个 tar 程序是用来归档文件的经典工具。它的名字,是 tape archive 的简称,揭示了它的根源,它是一款制作磁带备份的工具。而它仍然被用来完成传统任务, 它也同样适用于其它的存储设备。我们经常看到扩展名为 .tar 或者 .tgz 的文件,它们各自表示“普通” 的 tar 包和被 gzip 程序压缩过的 tar 包。一个 tar 包可以由一组独立的文件,一个或者多个目录,或者 两者混合体组成。命令语法如下: ~~~ tar mode[options] pathname... ~~~ 这里的 mode 是指以下操作模式(这里只展示了一部分,查看 tar 的手册来得到完整列表)之一: 表19-2: tar 模式 | 模式 | 说明 | |---------|------------| | c | 为文件和/或目录列表创建归档文件。 | | x | 抽取归档文件。 | | r | 追加具体的路径到归档文件的末尾。 | | t | 列出归档文件的内容。 | tar 命令使用了稍微有点奇怪的方式来表达它的选项,所以我们需要一些例子来展示它是 怎样工作的。首先,让我们重新创建之前我们用过的操练场: ~~~ [me@linuxbox ~]$ mkdir -p playground/dir-{00{1..9},0{10..99},100} [me@linuxbox ~]$ touch playground/dir-{00{1..9},0{10..99},100}/file-{A-Z} ~~~ 下一步,让我们创建整个操练场的 tar 包: ~~~ [me@linuxbox ~]$ tar cf playground.tar playground ~~~ 这个命令创建了一个名为 playground.tar 的 tar 包,其包含整个 playground 目录层次结果。我们 可以看到模式 c 和选项 f,其被用来指定这个 tar 包的名字,模式和选项可以写在一起,而且不 需要开头的短横线。注意,然而,必须首先指定模式,然后才是其它的选项。 要想列出归档文件的内容,我们可以这样做: ~~~ [me@linuxbox ~]$ tar tf playground.tar ~~~ 为了得到更详细的列表信息,我们可以添加选项 v: ~~~ [me@linuxbox ~]$ tar tvf playground.tar ~~~ 现在,抽取 tar 包 playground 到一个新位置。我们先创建一个名为 foo 的新目录,更改目录, 然后抽取 tar 包中的文件: ~~~ [me@linuxbox ~]$ mkdir foo [me@linuxbox ~]$ cd foo [me@linuxbox ~]$ tar xf ../playground.tar [me@linuxbox ~]$ ls playground ~~~ 如果我们检查 ~/foo/playground 目录中的内容,会看到这个归档文件已经被成功地安装了,就是创建了 一个精确的原始文件的副本。有一个警告,然而:除非你是超级用户,要不然从归档文件中抽取的文件 和目录的所有权由执行此复原操作的用户所拥有,而不属于原始所有者。 tar 命令另一个有趣的行为是它处理归档文件路径名的方式。默认情况下,路径名是相对的,而不是绝对 路径。当创建归档文件的时候,tar 命令会简单地删除路径名开头的斜杠。为了说明问题,我们将会 重新创建我们的归档文件,这次指定一个绝对路径: ~~~ [me@linuxbox foo]$ cd [me@linuxbox ~]$ tar cf playground2.tar ~/playground ~~~ 记住,当按下回车键后,~/playground 会展开成 /home/me/playground,所以我们将会得到一个 绝对路径名。接下来,和之前一样我们会抽取归档文件,观察发生什么事情: ~~~ [me@linuxbox ~]$ cd foo [me@linuxbox foo]$ tar xf ../playground2.tar [me@linuxbox foo]$ ls home playground [me@linuxbox foo]$ ls home me [me@linuxbox foo]$ ls home/me playground ~~~ 这里我们看到当我们抽取第二个归档文件时,它重新创建了 home/me/playground 目录, 相对于我们当前的工作目录,~/foo,而不是相对于 root 目录,作为带有绝对路径名的案例。 这看起来似乎是一种奇怪的工作方式,但事实上这种方式很有用,因为这样就允许我们抽取文件 到任意位置,而不是强制地把抽取的文件放置到原始目录下。加上 verbose(v)选项,重做 这个练习,将会展现更加详细的信息。 让我们考虑一个假设,tar 命令的实际应用。假定我们想要复制家目录及其内容到另一个系统中, 并且有一个大容量的 USB 硬盘,可以把它作为传输工具。在现代 Linux 系统中, 这个硬盘会被“自动地”挂载到 /media 目录下。我们也假定硬盘中有一个名为 BigDisk 的逻辑卷。 为了制作 tar 包,我们可以这样做: ~~~ [me@linuxbox ~]$ sudo tar cf /media/BigDisk/home.tar /home ~~~ tar 包制作完成之后,我们卸载硬盘,然后把它连接到第二个计算机上。再一次,此硬盘被 挂载到 /media/BigDisk 目录下。为了抽取归档文件,我们这样做: ~~~ [me@linuxbox2 ~]$ cd / [me@linuxbox2 /]$ sudo tar xf /media/BigDisk/home.tar ~~~ 值得注意的一点是,因为归档文件中的所有路径名都是相对的,所以首先我们必须更改目录到根目录下, 这样抽取的文件路径就相对于根目录了。 当抽取一个归档文件时,有可能限制从归档文件中抽取什么内容。例如,如果我们想要抽取单个文件, 可以这样实现: ~~~ tar xf archive.tar pathname ~~~ 通过给命令添加末尾的路径名,tar 命令就只会恢复指定的文件。可以指定多个路径名。注意 路径名必须是完全的,精准的相对路径名,就如存储在归档文件中的一样。当指定路径名的时候, 通常不支持通配符;然而,GNU 版本的 tar 命令(在 Linux 发行版中最常出现)通过 --wildcards 选项来 支持通配符。这个例子使用了之前 playground.tar 文件: ~~~ [me@linuxbox ~]$ cd foo [me@linuxbox foo]$ tar xf ../playground2.tar --wildcards 'home/me/playground/dir-\*/file-A' ~~~ 这个命令将只会抽取匹配特定路径名的文件,路径名中包含了通配符 dir-*。 tar 命令经常结合 find 命令一起来制作归档文件。在这个例子里,我们将会使用 find 命令来 产生一个文件集合,然后这些文件被包含到归档文件中。 ~~~ [me@linuxbox ~]$ find playground -name 'file-A' -exec tar rf playground.tar '{}' '+' ~~~ 这里我们使用 find 命令来匹配 playground 目录中所有名为 file-A 的文件,然后使用-exec 行为,来 唤醒带有追加模式(r)的 tar 命令,把匹配的文件添加到归档文件 playground.tar 里面。 使用 tar 和 find 命令,来创建逐渐增加的目录树或者整个系统的备份,是个不错的方法。通过 find 命令匹配新于某个时间戳的文件,我们就能够创建一个归档文件,其只包含新于上一个 tar 包的文件, 假定这个时间戳文件恰好在每个归档文件创建之后被更新了。 tar 命令也可以利用标准输出和输入。这里是一个完整的例子: ~~~ [me@linuxbox foo]$ cd [me@linuxbox ~]$ find playground -name 'file-A' | tar cf - --files-from=- | gzip > playground.tgz ~~~ 在这个例子里面,我们使用 find 程序产生了一个匹配文件列表,然后把它们管道到 tar 命令中。 如果指定了文件名“-”,则其被看作是标准输入或输出,正是所需(顺便说一下,使用“-”来表示 标准输入/输出的惯例,也被大量的其它程序使用)。这个 --file-from 选项(也可以用 -T 来指定) 导致 tar 命令从一个文件而不是命令行来读入它的路径名列表。最后,这个由 tar 命令产生的归档 文件被管道到 gzip 命令中,然后创建了压缩归档文件 playground.tgz。此 .tgz 扩展名是命名 由 gzip 压缩的 tar 文件的常规扩展名。有时候也会使用 .tar.gz 这个扩展名。 虽然我们使用 gzip 程序来制作我们的压缩归档文件,但是现在的 GUN 版本的 tar 命令 ,gzip 和 bzip2 压缩两者都直接支持,各自使用 z 和 j 选项。以我们之前的例子为基础, 我们可以这样简化它: ~~~ [me@linuxbox ~]$ find playground -name 'file-A' | tar czf playground.tgz -T - ~~~ 如果我们本要创建一个由 bzip2 压缩的归档文件,我们可以这样做: ~~~ [me@linuxbox ~]$ find playground -name 'file-A' | tar cjf playground.tbz -T - ~~~ 通过简单地修改压缩选项,把 z 改为 j(并且把输出文件的扩展名改为 .tbz,来指示一个 bzip2 压缩文件), 就使 bzip2 命令压缩生效了。另一个 tar 命令与标准输入和输出的有趣使用,涉及到在系统之间经过 网络传输文件。假定我们有两台机器,每台都运行着类 Unix,且装备着 tar 和 ssh 工具的操作系统。 在这种情景下,我们可以把一个目录从远端系统(名为 remote-sys)传输到我们的本地系统中: ~~~ [me@linuxbox ~]$ mkdir remote-stuff [me@linuxbox ~]$ cd remote-stuff [me@linuxbox remote-stuff]$ ssh remote-sys 'tar cf - Documents' | tar xf - me@remote-sys’s password: [me@linuxbox remote-stuff]$ ls Documents ~~~ 这里我们能够从远端系统 remote-sys 中复制目录 Documents 到本地系统名为 remote-stuff 目录中。 我们怎样做的呢?首先,通过使用 ssh 命令在远端系统中启动 tar 程序。你可记得 ssh 允许我们 在远程联网的计算机上执行程序,并且在本地系统中看到执行结果——远端系统中产生的输出结果 被发送到本地系统中查看。我们可以利用。在本地系统中,我们执行 tar 命令, ### zip 这个 zip 程序既是压缩工具,也是一个打包工具。这程序使用的文件格式,Windows 用户比较熟悉, 因为它读取和写入.zip 文件。然而,在 Linux 中 gzip 是主要的压缩程序,而 bzip2则位居第二。 在 zip 命令最基本的使用中,可以这样唤醒 zip 命令: ~~~ zip options zipfile file... ~~~ 例如,制作一个 playground 的 zip 版本的文件包,这样做: ~~~ [me@linuxbox ~]$ zip -r playground.zip playground ~~~ 除非我们包含-r 选项,要不然只有 playground 目录(没有任何它的内容)被存储。虽然会自动添加 .zip 扩展名,但为了清晰起见,我们还是包含文件扩展名。 在创建 zip 版本的文件包时,zip 命令通常会显示一系列的信息: ~~~ adding: playground/dir-020/file-Z (stored 0%) adding: playground/dir-020/file-Y (stored 0%) adding: playground/dir-020/file-X (stored 0%) adding: playground/dir-087/ (stored 0%) adding: playground/dir-087/file-S (stored 0%) ~~~ 这些信息显示了添加到文件包中每个文件的状态。zip 命令会使用两种存储方法之一,来添加 文件到文件包中:要不它会“store”没有压缩的文件,正如这里所示,或者它会“deflate”文件, 执行压缩操作。在存储方法之后显示的数值表明了压缩量。因为我们的 playground 目录 只是包含空文件,没有对它的内容执行压缩操作。 使用 unzip 程序,来直接抽取一个 zip 文件的内容。 ~~~ [me@linuxbox ~]$ cd foo [me@linuxbox foo]$ unzip ../playground.zip ~~~ 对于 zip 命令(与 tar 命令相反)要注意一点,就是如果指定了一个已经存在的文件包,其被更新 而不是被替代。这意味着会保留此文件包,但是会添加新文件,同时替换匹配的文件。可以列出 文件或者有选择地从一个 zip 文件包中抽取文件,只要给 unzip 命令指定文件名: ~~~ [me@linuxbox ~]$ unzip -l playground.zip playground/dir-87/file-Z Archive: ../playground.zip Length Date Time Name 0 10-05-08 09:25 playground/dir-87/file-Z 0 1 file [me@linuxbox ~]$ cd foo [me@linuxbox foo]$ unzip ./playground.zip playground/dir-87/file-Z Archive: ../playground.zip replace playground/dir-87/file-Z? [y]es, [n]o, [A]ll, [N]one, [r]ename: y extracting: playground/dir-87/file-Z ~~~ 使用-l 选项,导致 unzip 命令只是列出文件包中的内容而没有抽取文件。如果没有指定文件, unzip 程序将会列出文件包中的所有文件。添加这个-v 选项会增加列表的冗余信息。注意当抽取的 文件与已经存在的文件冲突时,会在替代此文件之前提醒用户。 像 tar 命令一样,zip 命令能够利用标准输入和输出,虽然它的实施不大有用。通过-@选项,有可能把一系列的 文件名管道到 zip 命令。 ~~~ [me@linuxbox foo]$ cd [me@linuxbox ~]$ find playground -name "file-A" | zip -@ file-A.zip ~~~ 这里我们使用 find 命令产生一系列与“file-A”相匹配的文件列表,并且把此列表管道到 zip 命令, 然后创建包含所选文件的文件包 file-A.zip。 zip 命令也支持把它的输出写入到标准输出,但是它的使用是有限的,因为很少的程序能利用输出。 不幸地是,这个 unzip 程序,不接受标准输入。这就阻止了 zip 和 unzip 一块使用,像 tar 命令那样, 来复制网络上的文件。 然而,zip 命令可以接受标准输入,所以它可以被用来压缩其它程序的输出: ~~~ [me@linuxbox ~]$ ls -l /etc/ | zip ls-etc.zip - adding: - (deflated 80%) ~~~ 在这个例子里,我们把 ls 命令的输出管道到 zip 命令。像 tar 命令,zip 命令把末尾的横杠解释为 “使用标准输入作为输入文件。” 这个 unzip 程序允许它的输出发送到标准输出,当指定了-p 选项之后: ~~~ [me@linuxbox ~]$ unzip -p ls-etc.zip | less ~~~ 我们讨论了一些 zip/unzip 可以完成的基本操作。它们两个都有许多选项,其增加了 命令的灵活性,虽然一些选项只针对于特定的平台。zip 和 unzip 命令的说明手册都相当不错, 并且包含了有用的实例。然而,这些程序的主要用途是为了和 Windows 系统交换文件, 而不是在 Linux 系统中执行压缩和打包操作,tar 和 gzip 程序在 Linux 系统中更受欢迎。 ## 同步文件和目录 维护系统备份的常见策略是保持一个或多个目录与另一个本地系统(通常是某种可移动的存储设备) 或者远端系统中的目录(或多个目录)同步。我们可能,例如有一个正在开发的网站的本地备份, 需要时不时的与远端网络服务器中的文件备份保持同步。在类 Unix 系统的世界里,能完成此任务且 备受人们喜爱的工具是 rsync。这个程序能同步本地与远端的目录,通过使用 rsync 远端更新协议,此协议 允许 rsync 快速地检测两个目录的差异,执行最小量的复制来达到目录间的同步。比起其它种类的复制程序, 这就使 rsync 命令非常快速和高效。 rsync 被这样唤醒: ~~~ rsync options source destination ~~~ 这里 source 和 destination 是下列选项之一: * 一个本地文件或目录 * 一个远端文件或目录,以[user@]host:path 的形式存在 * 一个远端 rsync 服务器,由 rsync://[user@]host[:port]/path 指定 注意 source 和 destination 两者之一必须是本地文件。rsync 不支持远端到远端的复制 让我们试着对一些本地文件使用 rsync 命令。首先,清空我们的 foo 目录: ~~~ [me@linuxbox ~]$ rm -rf foo/* ~~~ 下一步,我们将同步 playground 目录和它在 foo 目录中相对应的副本 ~~~ [me@linuxbox ~]$ rsync -av playground foo ~~~ 我们包括了-a 选项(递归和保护文件属性)和-v 选项(冗余输出), 来在 foo 目录中制作一个 playground 目录的镜像。当这个命令执行的时候, 我们将会看到一系列的文件和目录被复制。在最后,我们将看到一条像这样的总结信息: ~~~ sent 135759 bytes received 57870 bytes 387258.00 bytes/sec total size is 3230 speedup is 0.02 ~~~ 说明复制的数量。如果我们再次运行这个命令,我们将会看到不同的结果: ~~~ [me@linuxbox ~]$ rsync -av playgound foo building file list ... done sent 22635 bytes received 20 bytes total size is 3230 speedup is 0.14 45310.00 bytes/sec ~~~ 注意到没有文件列表。这是因为 rsync 程序检测到在目录~/playground 和 ~/foo/playground 之间 不存在差异,因此它不需要复制任何数据。如果我们在 playground 目录中修改一个文件,然后 再次运行 rsync 命令: ~~~ [me@linuxbox ~]$ touch playground/dir-099/file-Z [me@linuxbox ~]$ rsync -av playground foo building file list ... done playground/dir-099/file-Z sent 22685 bytes received 42 bytes 45454.00 bytes/sec total size is 3230 speedup is 0.14 ~~~ 我们看到 rsync 命令检测到更改,并且只是复制了更新的文件。作为一个实际的例子, 让我们考虑一个假想的外部硬盘,之前我们在 tar 命令中用到过的。如果我们再次把此 硬盘连接到我们的系统中,它被挂载到/media/BigDisk 目录下,我们可以执行一个有 用的系统备份了,首先在外部硬盘上创建一个目录,名为/backup,然后使用 rsync 程序 从我们的系统中复制最重要的数据到此外部硬盘上: ~~~ [me@linuxbox ~]$ mkdir /media/BigDisk/backup [me@linuxbox ~]$ sudo rsync -av --delete /etc /home /usr/local /media/BigDisk/backup ~~~ 在这个例子里,我们把/etc,/home,和/usr/local 目录从我们的系统中复制到假想的存储设备中。 我们包含了–delete 这个选项,来删除可能在备份设备中已经存在但却不再存在于源设备中的文件, (这与我们第一次创建备份无关,但是会在随后的复制操作中有用途)。挂载外部驱动器,运行 rsync 命令,不断重复这个过程,是一个不错的(虽然不理想)方式来保存少量的系统备份文件。 当然,别名会对这个操作更有帮助些。我们将会创建一个别名,并把它添加到.bashrc 文件中, 来提供这个特性: ~~~ alias backup='sudo rsync -av --delete /etc /home /usr/local /media/BigDisk/backup' ~~~ 现在我们所做的事情就是连接外部驱动器,然后运行 backup 命令来完成工作。 ### 在网络间使用 rsync 命令 rsync 程序的真正好处之一,是它可以被用来在网络间复制文件。毕竟,rsync 中的“r”象征着“remote”。 远程复制可以通过两种方法完成。第一个方法要求另一个系统已经安装了 rsync 程序,还安装了 远程 shell 程序,比如 ssh。比方说我们本地网络中的一个系统有大量可用的硬盘空间,我们想要 用远程系统来代替一个外部驱动器,来执行文件备份操作。假定远程系统中有一个名为/backup 的目录, 其用来存放我们传送的文件,我们这样做: ~~~ [me@linuxbox ~]$ sudo rsync -av --delete --rsh=ssh /etc /home /usr/local remote-sys:/backup ~~~ 我们对命令做了两处修改,来方便网络间文件复制。首先,我们添加了--rsh=ssh 选项,其指示 rsync 使用 ssh 程序作为它的远程 shell。以这种方式,我们就能够使用一个 ssh 加密通道,把数据 安全地传送到远程主机中。其次,通过在目标路径名前加上远端主机的名字(在这种情况下, 远端主机名为 remote-sys),来指定远端主机。 rsync 可以被用来在网络间同步文件的第二种方式是通过使用 rsync 服务器。rsync 可以被配置为一个 守护进程,监听即将到来的同步请求。这样做经常是为了允许一个远程系统的镜像。例如,Red Hat 软件中心为它的 Fedora 发行版,维护着一个巨大的正在开发中的软件包的仓库。对于软件测试人员, 在发行周期的测试阶段,镜像这些软件集合是非常有帮助的。因为仓库中的这些文件会频繁地 (通常每天不止一次)改动,定期同步本地镜像,这是可取的,而不是大量地拷贝软件仓库。 这些软件库之一被维护在 Georgia Tech;我们可以使用本地 rsync 程序和它们的 rsync 服务器来镜像它。 ~~~ [me@linuxbox ~]$ mkdir fedora-devel [me@linuxbox ~]$ rsync -av -delete rsync://rsync.gtlib.gatech.edu/fedora-linux- core/development/i386/os fedora-devel ~~~ 在这个例子里,我们使用了远端 rsync 服务器的 URI,其由协议(rsync://),远端主机名 (rsync.gtlib.gatech.edu),和软件仓库的路径名组成。 ## 拓展阅读 * 在这里讨论的所有命令的手册文档都相当清楚明白,并且包含了有用的例子。另外, GNU 版本的 tar 命令有一个不错的在线文档。可以在下面链接处找到: [http://www.gnu.org/software/tar/manual/index.html](http://www.gnu.org/software/tar/manual/index.html)
';

第十八章:查找文件

最后更新于:2022-04-02 01:45:48

因为我们已经浏览了 Linux 系统,所以一件事已经变得非常清楚:一个典型的 Linux 系统包含很多文件! 这就引发了一个问题,“我们怎样查找东西?”。虽然我们已经知道 Linux 文件系统良好的组织结构,是源自 类 Unix 的操作系统代代传承的习俗。但是仅文件数量就会引起可怕的问题。在这一章中,我们将察看 两个用来在系统中查找文件的工具。这些工具是: > * locate – 通过名字来查找文件 > * find – 在目录层次结构中搜索文件 我们也将看一个经常与文件搜索命令一起使用的命令,它用来处理搜索到的文件列表: > * xargs – 从标准输入生成和执行命令行 另外,我们将介绍两个命令来协助我们探索: > * touch – 更改文件时间 > * stat – 显示文件或文件系统状态 ## locate - 查找文件的简单方法 这个 locate 程序快速搜索路径名数据库,并且输出每个与给定字符串相匹配的文件名。比如说, 例如,我们想要找到所有名字以“zip”开头的程序。因为我们正在查找程序,可以假定包含 匹配程序的目录以”bin/”结尾。因此,我们试着以这种方式使用 locate 命令,来找到我们的文件: ~~~ [me@linuxbox ~]$ locate bin/zip ~~~ locate 命令将会搜索它的路径名数据库,输出任一个包含字符串“bin/zip”的路径名: ~~~ /usr/bin/zip /usr/bin/zipcloak /usr/bin/zipgrep /usr/bin/zipinfo /usr/bin/zipnote /usr/bin/zipsplit ~~~ 如果搜索要求没有这么简单,locate 可以结合其它工具,比如说 grep 命令,来设计更加 有趣的搜索: ~~~ [me@linuxbox ~]$ locate zip | grep bin /bin/bunzip2 /bin/bzip2 /bin/bzip2recover /bin/gunzip /bin/gzip /usr/bin/funzip /usr/bin/gpg-zip /usr/bin/preunzip /usr/bin/prezip /usr/bin/prezip-bin /usr/bin/unzip /usr/bin/unzipsfx /usr/bin/zip /usr/bin/zipcloak /usr/bin/zipgrep /usr/bin/zipinfo /usr/bin/zipnote /usr/bin/zipsplit ~~~ 这个 locate 程序已经存在了很多年了,它有几个不同的变体被普遍使用着。在现在 Linux 发行版中发现的两个最常见的变体是 slocate 和 mlocate,但是通常它们被名为 locate 的 符号链接访问。不同版本的 locate 命令拥有重复的选项集合。一些版本包括正则表达式 匹配(我们会在下一章中讨论)和通配符支持。查看 locate 命令的手册,从而确定安装了 哪个版本的 locate 程序。 > locate 数据库来自何方? > > 你可能注意到了,在一些发行版中,仅仅在系统安装之后,locate 不能工作, 但是如果你第二天再试一下,它就工作正常了。怎么回事呢?locate 数据库由另一个叫做 updatedb 的程序创建。通常,这个程序作为一个 cron 工作例程周期性运转;也就是说,一个任务 在特定的时间间隔内被 cron 守护进程执行。大多数装有 locate 的系统会每隔一天运行一回 updatedb 程序。因为数据库不能被持续地更新,所以当使用 locate 时,你会发现 目前最新的文件不会出现。为了克服这个问题,可以手动运行 updatedb 程序, 更改为超级用户身份,在提示符下运行 updatedb 命令。 ## find - 查找文件的复杂方式 locate 程序只能依据文件名来查找文件,而 find 程序能基于各种各样的属性, 搜索一个给定目录(以及它的子目录),来查找文件。我们将要花费大量的时间学习 find 命令,因为 它有许多有趣的特性,当我们开始在随后的章节里面讨论编程概念的时候,我们将会重复看到这些特性。 find 命令的最简单使用是,搜索一个或多个目录。例如,输出我们的家目录列表。 ~~~ [me@linuxbox ~]$ find ~ ~~~ 对于最活跃的用户帐号,这将产生一张很大的列表。因为这张列表被发送到标准输出, 我们可以把这个列表管道到其它的程序中。让我们使用 wc 程序来计算出文件的数量: ~~~ [me@linuxbox ~]$ find ~ | wc -l 47068 ~~~ 哇,我们一直很忙!find 命令的美丽所在就是它能够被用来识别符合特定标准的文件。它通过 (有点奇怪)应用选项,测试条件,和操作来完成搜索。我们先看一下测试条件。 ### Tests 比如说我们想要目录列表。我们可以添加以下测试条件: ~~~ [me@linuxbox ~]$ find ~ -type d | wc -l 1695 ~~~ 添加测试条件-type d 限制了只搜索目录。相反地,我们使用这个测试条件来限定搜索普通文件: ~~~ [me@linuxbox ~]$ find ~ -type f | wc -l 38737 ~~~ 这里是 find 命令支持的普通文件类型测试条件: 表18-1: find 文件类型 | 文件类型 | 描述 | |---------|------------| | b | 块设备文件 | | c | 字符设备文件 | | d | 目录 | | f | 普通文件 | | l | 符号链接 | 我们也可以通过加入一些额外的测试条件,根据文件大小和文件名来搜索:让我们查找所有文件名匹配 通配符模式“*.JPG”和文件大小大于1M 的文件: ~~~ [me@linuxbox ~]$ find ~ -type f -name "\*.JPG" -size +1M | wc -l 840 ~~~ 在这个例子里面,我们加入了 -name 测试条件,后面跟通配符模式。注意,我们把它用双引号引起来, 从而阻止 shell 展开路径名。紧接着,我们加入 -size 测试条件,后跟字符串“+1M”。开头的加号表明 我们正在寻找文件大小大于指定数的文件。若字符串以减号开头,则意味着查找小于指定数的文件。 若没有符号意味着“精确匹配这个数”。结尾字母“M”表明测量单位是兆字节。下面的字符可以 被用来指定测量单位: 表18-2: find 大小单位 | 字符 | 单位 | |---------|------------| | b | 512 个字节块。如果没有指定单位,则这是默认值。 | | c | 字节 | | w | 两个字节的字 | | k | 千字节(1024个字节单位) | | M | 兆字节(1048576个字节单位) | | G | 千兆字节(1073741824个字节单位) | find 命令支持大量不同的测试条件。下表是列出了一些常见的测试条件。请注意,在需要数值参数的 情况下,可以应用以上讨论的“+”和”-“符号表示法: 表18-3: find 测试条件 | 测试条件 | 描述 | |---------|------------| | -cmin n | 匹配的文件和目录的内容或属性最后修改时间正好在 n 分钟之前。 指定少于 n 分钟之前,使用 -n,指定多于 n 分钟之前,使用 +n。 | | -cnewer file | 匹配的文件和目录的内容或属性最后修改时间早于那些文件。 | | -ctime n | 匹配的文件和目录的内容和属性最后修改时间在 n\*24小时之前。 | | -empty | 匹配空文件和目录。 | | -group name | 匹配的文件和目录属于一个组。组可以用组名或组 ID 来表示。 | | -iname pattern | 就像-name 测试条件,但是不区分大小写。 | | -inum n | 匹配的文件的 inode 号是 n。这对于找到某个特殊 inode 的所有硬链接很有帮助。 | | -mmin n | 匹配的文件或目录的内容被修改于 n 分钟之前。 | | -mtime n | 匹配的文件或目录的内容被修改于 n\*24小时之前。 | | -name pattern | 用指定的通配符模式匹配的文件和目录。 | | -newer file | 匹配的文件和目录的内容早于指定的文件。当编写 shell 脚本,做文件备份时,非常有帮助。 每次你制作一个备份,更新文件(比如说日志),然后使用 find 命令来决定自从上次更新,哪一个文件已经更改了。 | | -nouser | 匹配的文件和目录不属于一个有效用户。这可以用来查找 属于删除帐户的文件或监测攻击行为。 | | -nogroup | 匹配的文件和目录不属于一个有效的组。 | | -perm mode | 匹配的文件和目录的权限已经设置为指定的 mode。mode 可以用 八进制或符号表示法。 | | -samefile name | 相似于-inum 测试条件。匹配和文件 name 享有同样 inode 号的文件。 | | -size n | 匹配的文件大小为 n。 | | -type c | 匹配的文件类型是 c。 | | -user name | 匹配的文件或目录属于某个用户。这个用户可以通过用户名或用户 ID 来表示。 | 这不是一个完整的列表。find 命令手册有更详细的说明。 ### 操作符 即使拥有了 find 命令提供的所有测试条件,我们还需要一个更好的方式来描述测试条件之间的逻辑关系。例如, 如果我们需要确定是否一个目录中的所有的文件和子目录拥有安全权限,怎么办呢? 我们可以查找权限不是0600的文件和权限不是0700的目录。幸运地是,find 命令提供了 一种方法来结合测试条件,通过使用逻辑操作符来创建更复杂的逻辑关系。 为了表达上述的测试条件,我们可以这样做: ~~~ [me@linuxbox ~]$ find ~ \( -type f -not -perm 0600 \) -or \( -type d -not -perm 0700 \) ~~~ 呀!这的确看起来很奇怪。这些是什么东西?实际上,这些操作符没有那么复杂,一旦你知道了它们的原理。 这里是操作符列表: 表18-4: find 命令的逻辑操作符 | 操作符 | 描述 | |---------|------------| | -and | 如果操作符两边的测试条件都是真,则匹配。可以简写为 -a。 注意若没有使用操作符,则默认使用 -and。 | | -or | 若操作符两边的任一个测试条件为真,则匹配。可以简写为 -o。 | | -not | 若操作符后面的测试条件是真,则匹配。可以简写为一个感叹号(!)。 | | () | 把测试条件和操作符组合起来形成更大的表达式。这用来控制逻辑计算的优先级。 默认情况下,find 命令按照从左到右的顺序计算。经常有必要重写默认的求值顺序,以得到期望的结果。 即使没有必要,有时候包括组合起来的字符,对提高命令的可读性是很有帮助的。注意 因为圆括号字符对于 shell 来说有特殊含义,所以在命令行中使用它们的时候,它们必须 用引号引起来,才能作为实参传递给 find 命令。通常反斜杠字符被用来转义圆括号字符。 | 通过这张操作符列表,我们重建 find 命令。从最外层看,我们看到测试条件被分为两组,由一个 -or 操作符分开: ~~~ ( expression 1 ) -or ( expression 2 ) ~~~ 这很有意义,因为我们正在搜索具有不同权限集合的文件和目录。如果我们文件和目录两者都查找, 那为什么要用 -or 来代替 -and 呢?因为 find 命令扫描文件和目录时,会计算每一个对象,看看它是否 匹配指定的测试条件。我们想要知道它是具有错误权限的文件还是有错误权限的目录。它不可能同时符合这 两个条件。所以如果展开组合起来的表达式,我们能这样解释它: ~~~ ( file with bad perms ) -or ( directory with bad perms ) ~~~ 下一个挑战是怎样来检查“错误权限”,这个怎样做呢?我们不从这个角度做。我们将测试 “不是正确权限”,因为我们知道什么是“正确权限”。对于文件,我们定义正确权限为0600, 目录则为0711。测试具有“不正确”权限的文件表达式为: ~~~ -type f -and -not -perms 0600 ~~~ 对于目录,表达式为: ~~~ -type d -and -not -perms 0700 ~~~ 正如上述操作符列表中提到的,这个-and 操作符能够被安全地删除,因为它是默认使用的操作符。 所以如果我们把这两个表达式连起来,就得到最终的命令: ~~~ find ~ ( -type f -not -perms 0600 ) -or ( -type d -not -perms 0700 ) ~~~ 然而,因为圆括号对于 shell 有特殊含义,我们必须转义它们,来阻止 shell 解释它们。在圆括号字符 之前加上一个反斜杠字符来转义它们。 逻辑操作符的另一个特性要重点理解。比方说我们有两个由逻辑操作符分开的表达式: ~~~ expr1 -operator expr2 ~~~ 在所有情况下,总会执行表达式 expr1;然而由操作符来决定是否执行表达式 expr2。这里 列出了它是怎样工作的: 表18-5: find AND/OR 逻辑 |||| |---------|------------|---| | expr1 的结果 | 操作符 | expr2 is... | | 真 | -and | 总要执行 | | 假 | -and | 从不执行 | | 真 | -or | 从不执行 | | 假 | -or | 总要执行 | 为什么这会发生呢?这样做是为了提高性能。以 -and 为例,我们知道表达式 expr1 -and expr2 不能为真,如果表达式 expr1的结果为假,所以没有必要执行 expr2。同样地,如果我们有表达式 expr1 -or expr2,并且表达式 expr1的结果为真,那么就没有必要执行 expr2,因为我们已经知道 表达式 expr1 -or expr2 为真。好,这样会执行快一些。为什么这个很重要? 它很重要是因为我们能依靠这种行为来控制怎样来执行操作。我们会很快看到… ## 预定义的操作 让我们做一些工作吧!从 find 命令得到的结果列表很有用处,但是我们真正想要做的事情是操作列表 中的某些条目。幸运地是,find 命令允许基于搜索结果来执行操作。有许多预定义的操作和几种方式来 应用用户定义的操作。首先,让我们看一下几个预定义的操作: 表18-6: 几个预定义的 find 命令操作 | 操作 | 描述 | |---------|------------| | -delete | 删除当前匹配的文件。 | | -ls | 对匹配的文件执行等同的 ls -dils 命令。并将结果发送到标准输出。 | | -print | 把匹配文件的全路径名输送到标准输出。如果没有指定其它操作,这是 默认操作。 | | -quit | 一旦找到一个匹配,退出。 | 和测试条件一样,还有更多的操作。查看 find 命令手册得到更多细节。在第一个例子里, 我们这样做: ~~~ find ~ ~~~ 这个命令输出了我们家目录中包含的每个文件和子目录。它会输出一个列表,因为会默认使用-print 操作 ,如果没有指定其它操作的话。因此我们的命令也可以这样表述: ~~~ find ~ -print ~~~ 我们可以使用 find 命令来删除符合一定条件的文件。例如,来删除扩展名为“.BAK”(这通常用来指定备份文件) 的文件,我们可以使用这个命令: ~~~ find ~ -type f -name '*.BAK' -delete ~~~ 在这个例子里面,用户家目录(和它的子目录)下搜索每个以.BAK 结尾的文件名。当找到后,就删除它们。 * * * 警告:当使用 -delete 操作时,不用说,你应该格外小心。首先测试一下命令, 用 -print 操作代替 -delete,来确认搜索结果。 * * * 在我们继续之前,让我们看一下逻辑运算符是怎样影响操作的。考虑以下命令: ~~~ find ~ -type f -name '*.BAK' -print ~~~ 正如我们所见到的,这个命令会查找每个文件名以.BAK (-name ‘*.BAK’) 结尾的普通文件 (-type f), 并把每个匹配文件的相对路径名输出到标准输出 (-print)。然而,此命令按这个方式执行的原因,是 由每个测试和操作之间的逻辑关系决定的。记住,在每个测试和操作之间会默认应用 -and 逻辑运算符。 我们也可以这样表达这个命令,使逻辑关系更容易看出: ~~~ find ~ -type f -and -name '*.BAK' -and -print ~~~ 当命令被充分表达之后,让我们看看逻辑运算符是如何影响其执行的: | 测试/行为 | 只有...的时候,才被执行 | |---------|------------| | -print | 只有 -type f and -name '*.BAK'为真的时候 | | -name ‘*.BAK’ | 只有 -type f 为真的时候 | | -type f | 总是被执行,因为它是与 -and 关系中的第一个测试/行为。 | 因为测试和行为之间的逻辑关系决定了哪一个会被执行,我们知道测试和行为的顺序很重要。例如, 如果我们重新安排测试和行为之间的顺序,让 -print 行为是第一个,那么这个命令执行起来会截然不同: ~~~ find ~ -print -and -type f -and -name '*.BAK' ~~~ 这个版本的命令会打印出每个文件(-print 行为总是为真),然后测试文件类型和指定的文件扩展名。 ## 用户定义的行为 除了预定义的行为之外,我们也可以唤醒随意的命令。传统方式是通过 -exec 行为。这个 行为像这样工作: ~~~ -exec command {} ; ~~~ 这里的 command 就是指一个命令的名字,{}是当前路径名的符号表示,分号是要求的界定符 表明命令结束。这里是一个使用 -exec 行为的例子,其作用如之前讨论的 -delete 行为: ~~~ -exec rm '{}' ';' ~~~ 重述一遍,因为花括号和分号对于 shell 有特殊含义,所以它们必须被引起来或被转义。 也有可能交互式地执行一个用户定义的行为。通过使用 -ok 行为来代替 -exec,在执行每个指定的命令之前, 会提示用户: ~~~ find ~ -type f -name 'foo*' -ok ls -l '{}' ';' < ls ... /home/me/bin/foo > ? y -rwxr-xr-x 1 me me 224 2007-10-29 18:44 /home/me/bin/foo < ls ... /home/me/foo.txt > ? y -rw-r--r-- 1 me me 0 2008-09-19 12:53 /home/me/foo.txt ~~~ 在这个例子里面,我们搜索以字符串“foo”开头的文件名,并且对每个匹配的文件执行 ls -l 命令。 使用 -ok 行为,会在 ls 命令执行之前提示用户。 ## 提高效率 当 -exec 行为被使用的时候,若每次找到一个匹配的文件,它会启动一个新的指定命令的实例。 我们可能更愿意把所有的搜索结果结合起来,再运行一个命令的实例。例如,而不是像这样执行命令: ~~~ ls -l file1 ls -l file2 ~~~ 我们更喜欢这样执行命令: ~~~ ls -l file1 file2 ~~~ 这样就导致命令只被执行一次而不是多次。有两种方法可以这样做。传统方式是使用外部命令 xargs,另一种方法是,使用 find 命令自己的一个新功能。我们先讨论第二种方法。 通过把末尾的分号改为加号,就激活了 find 命令的一个功能,把搜索结果结合为一个参数列表, 然后执行一次所期望的命令。再看一下之前的例子,这个: ~~~ find ~ -type f -name 'foo*' -exec ls -l '{}' ';' -rwxr-xr-x 1 me me 224 2007-10-29 18:44 /home/me/bin/foo -rw-r--r-- 1 me me 0 2008-09-19 12:53 /home/me/foo.txt ~~~ 会执行 ls 命令,每次找到一个匹配的文件。把命令改为: ~~~ find ~ -type f -name 'foo*' -exec ls -l '{}' + -rwxr-xr-x 1 me me 224 2007-10-29 18:44 /home/me/bin/foo -rw-r--r-- 1 me me 0 2008-09-19 12:53 /home/me/foo.txt ~~~ 虽然我们得到一样的结果,但是系统只需要执行一次 ls 命令。 ### xargs 这个 xargs 命令会执行一个有趣的函数。它从标准输入接受输入,并把输入转换为一个特定命令的 参数列表。对于我们的例子,我们可以这样使用它: ~~~ find ~ -type f -name 'foo\*' -print | xargs ls -l -rwxr-xr-x 1 me me 224 2007-10-29 18:44 /home/me/bin/foo -rw-r--r-- 1 me me 0 2008-09-19 12:53 /home/me/foo.txt ~~~ 这里我们看到 find 命令的输出被管道到 xargs 命令,反过来,xargs 会为 ls 命令构建 参数列表,然后执行 ls 命令。 * * * 注意:当被放置到命令行中的参数个数相当大时,参数个数是有限制的。有可能创建的命令 太长以至于 shell 不能接受。当命令行超过系统支持的最大长度时,xargs 会执行带有最大 参数个数的指定命令,然后重复这个过程直到耗尽标准输入。执行带有 –show–limits 选项 的 xargs 命令,来查看命令行的最大值。 * * * > 处理古怪的文件名 > > 类 Unix 的系统允许在文件名中嵌入空格(甚至换行符)。这就给一些程序,如为其它 程序构建参数列表的 xargs 程序,造成了问题。一个嵌入的空格会被看作是一个界定符,生成的 命令会把每个空格分离的单词解释为单独的参数。为了解决这个问题,find 命令和 xarg 程序 允许可选择的使用一个 null 字符作为参数分隔符。一个 null 字符被定义在 ASCII 码中,由数字 零来表示(相反的,例如,空格字符在 ASCII 码中由数字32表示)。find 命令提供的 -print0 行为, 则会产生由 null 字符分离的输出,并且 xargs 命令有一个 –null 选项,这个选项会接受由 null 字符 分离的输入。这里有一个例子: > > find ~ -iname ‘*.jpg’ -print0 | xargs –null ls -l > > 使用这项技术,我们可以保证所有文件,甚至那些文件名中包含空格的文件,都能被正确地处理。 ## 返回操练场 到实际使用 find 命令的时候了。我们将会创建一个操练场,来实践一些我们所学到的知识。 首先,让我们创建一个包含许多子目录和文件的操练场: ~~~ [me@linuxbox ~]$ mkdir -p playground/dir-{00{1..9},0{10..99},100} [me@linuxbox ~]$ touch playground/dir-{00{1..9},0{10..99},100}/file-{A..Z} ~~~ 惊叹于命令行的强大功能!只用这两行,我们就创建了一个包含一百个子目录,每个子目录中 包含了26个空文件的操练场。试试用 GUI 来创建它! 我们用来创造这个奇迹的方法中包含一个熟悉的命令(mkdir),一个奇异的 shell 扩展(大括号) 和一个新命令,touch。通过结合 mkdir 命令和-p 选项(导致 mkdir 命令创建指定路径的父目录),以及 大括号展开,我们能够创建一百个目录。 这个 touch 命令通常被用来设置或更新文件的访问,更改,和修改时间。然而,如果一个文件名参数是一个 不存在的文件,则会创建一个空文件。 在我们的操练场中,我们创建了一百个名为 file-A 的文件实例。让我们找到它们: ~~~ [me@linuxbox ~]$ find playground -type f -name 'file-A' ~~~ 注意不同于 ls 命令,find 命令的输出结果是无序的。其顺序由存储设备的布局决定。为了确定实际上 我们拥有一百个此文件的实例,我们可以用这种方式来确认: ~~~ [me@linuxbox ~]$ find playground -type f -name 'file-A' | wc -l ~~~ 下一步,让我们看一下基于文件的修改时间来查找文件。当创建备份文件或者以年代顺序来 组织文件的时候,这会很有帮助。为此,首先我们将创建一个参考文件,我们将与其比较修改时间: ~~~ [me@linuxbox ~]$ touch playground/timestamp ~~~ 这个创建了一个空文件,名为 timestamp,并且把它的修改时间设置为当前时间。我们能够验证 它通过使用另一个方便的命令,stat,是一款加大马力的 ls 命令版本。这个 stat 命令会展示系统对 某个文件及其属性所知道的所有信息: ~~~ [me@linuxbox ~]$ stat playground/timestamp File: 'playground/timestamp' Size: 0 Blocks: 0 IO Block: 4096 regular empty file Device: 803h/2051d Inode: 14265061 Links: 1 Access: (0644/-rw-r--r--) Uid: ( 1001/ me) Gid: ( 1001/ me) Access: 2008-10-08 15:15:39.000000000 -0400 Modify: 2008-10-08 15:15:39.000000000 -0400 Change: 2008-10-08 15:15:39.000000000 -0400 ~~~ 如果我们再次 touch 这个文件,然后用 stat 命令检测它,我们会发现所有文件的时间已经更新了。 ~~~ [me@linuxbox ~]$ touch playground/timestamp [me@linuxbox ~]$ stat playground/timestamp File: 'playground/timestamp' Size: 0 Blocks: 0 IO Block: 4096 regular empty file Device: 803h/2051d Inode: 14265061 Links: 1 Access: (0644/-rw-r--r--) Uid: ( 1001/ me) Gid: ( 1001/ me) Access: 2008-10-08 15:23:33.000000000 -0400 Modify: 2008-10-08 15:23:33.000000000 -0400 Change: 2008-10-08 15:23:33.000000000 -0400 ~~~ 下一步,让我们使用 find 命令来更新一些操练场中的文件: ~~~ [me@linuxbox ~]$ find playground -type f -name 'file-B' -exec touch '{}' ';' ~~~ 这会更新操练场中所有名为 file-B 的文件。接下来我们会使用 find 命令来识别已更新的文件, 通过把所有文件与参考文件 timestamp 做比较: ~~~ [me@linuxbox ~]$ find playground -type f -newer playground/timestamp ~~~ 搜索结果包含所有一百个文件 file-B 的实例。因为我们在更新了文件 timestamp 之后, touch 了操练场中名为 file-B 的所有文件,所以现在它们“新于”timestamp 文件,因此能被用 -newer 测试条件识别出来。 最后,让我们回到之前那个错误权限的例子中,把它应用于操练场里: ~~~ [me@linuxbox ~]$ find playground \( -type f -not -perm 0600 \) -or \( -type d -not -perm 0700 \) ~~~ 这个命令列出了操练场中所有一百个目录和二百六十个文件(还有 timestamp 和操练场本身,共 2702 个) ,因为没有一个符合我们“正确权限”的定义。通过对运算符和行为知识的了解,我们可以给这个命令 添加行为,对实战场中的文件和目录应用新的权限。 ~~~ [me@linuxbox ~]$ find playground \( -type f -not -perm 0600 -exec chmod 0600 '{}' ';' \) -or \( -type d -not -perm 0711 -exec chmod 0700 '{}' ';' \) ~~~ 在日常的基础上,我们可能发现运行两个命令会比较容易一些,一个操作目录,另一个操作文件, 而不是这一个长长的复合命令,但是很高兴知道,我们能这样执行命令。这里最重要的一点是要 理解怎样把操作符和行为结合起来使用,来执行有用的任务。 ### 选项 最后,我们有这些选项。这些选项被用来控制 find 命令的搜索范围。当构建 find 表达式的时候, 它们可能被其它的测试条件和行为包含: 表 18-7: find 命令选项 | 选项 | 描述 | |---------|------------| | -depth | 指导 find 程序先处理目录中的文件,再处理目录自身。当指定-delete 行为时,会自动 应用这个选项。 | | -maxdepth levels | 当执行测试条件和行为的时候,设置 find 程序陷入目录树的最大级别数 | | -mindepth levels | 在应用测试条件和行为之前,设置 find 程序陷入目录数的最小级别数。 | | -mount | 指导 find 程序不要搜索挂载到其它文件系统上的目录。 | | -noleaf | 指导 find 程序不要基于搜索类 Unix 的文件系统做出的假设,来优化它的搜索。 | ## 拓展阅读 * 程序 locate,updatedb,find 和 xargs 都是 GNU 项目 findutils 软件包的一部分。 这个 GUN 项目提供了大量的在线文档,这些文档相当出色,如果你在高安全性的 环境中使用这些程序,你应该读读这些文档。 [http://www.gnu.org/software/findutils/](http://www.gnu.org/software/findutils/)
';