对话 UNIX,第 6 部分: 通过脚本实现操作的自动化

下面是简介: 如果您曾经在资深 Unix?用户工作时站在他的背后注视屏幕 , 可能会对命令行上不断滚动的咒语般的奇怪内容感到相当迷惑 。如果您阅读过对话 UNIX 系列中以前的文章(请参见参考资料) , 那么至少所输入的某些诗一般的神秘内容——如波形符 (~)、管道 (|)、变量和重定向(< 和 >)——看起来是熟悉的 。您也许还会认出某些 UNIX 命令名称和组合 , 或者了解何时使用别名来作为某个命令组合的简写形式 。
尽管如此 , 还有其他命令组合可能是您无法理解的 , 因为资深的 UNIX 用户通常以 Shell 脚本 的形式收集一大堆小的、高度专门化的命令组合 , 以简化或自动化经常重复的任务 。与输入或重新输入(可能)复杂的命令来完成某个繁琐任务不同 , Shell 脚本可以自动化该工作 。
在对话 UNIX 系列(请参见参考资料)的第 6 部分中 , 您将学习如何编写 Shell 脚本和更多命令行诀窍 。
核心就是一个词:“自动化
有些 Shell 脚本完全就是反复运行同样的命令 , 并处理同样的一组文件 。例如 , 将您的整个主目录内容传播到三台远程计算机的 Z Shell 脚本可以像清单 1 一样简单 。
清单 1. 跨多台计算机同步主目录的简单 Shell 脚本
#! /bin/zsh
for each Machine (groucho chico harpo)
rsync -e ssh --times --perms --recursive --delete $HOME $machine:
end
若要将清单 1 用作 Shell 脚本 , 可以将上述内容保存到某个文件——例如 simpleprop.zsh——并运行 chmodx simpleprop.zsh 以使该文件成为可执行文件 。您可以通过输入 ./simpleprop.zsh 来运行该脚本 。
如果您想查看 Z Shell 如何展开每个命令 , 可以将 -x 选项添加到脚本的 #!(# 号-感叹号对通常称为 shuh-bang)行的结尾 , 如下所示:
#! /bin/zsh -x
该脚本对 groucho、chico 和 harpo 中的每一台计算机运行 rsync 命令 , 并将 $HOME 替换为您的主目录(例如 , /home/joe) , 将 $Machine 替换为计算机名称 。
如清单 1 所示 , 变量和诸如循环等脚本控制结构使脚本更容易编写和维护 。如果您想将第四台计算机(例如 zeppo)包括到计算机池中 , 只需将其添加到该列表 。如果您必须更改 rsync 命令 , 比如说添加另一个选项 , 则只需编辑一个实例 。与在传统编程中一样 , 您也应该努力避免在 Shell 脚本中进行剪切和粘贴 。
使用恰当的参数
其他 Shell 脚本需要参数 , 或要处理的对象——文件、目录、计算机名称——的动态列表 。例如 , 考虑清单 2 , 这是前一示例的变体 , 它允许您使用命令行来指定您想要与之同步的计算机 。
清单 2. 允许您指定要处理的计算机的清单 1 的变体
#! /bin/zsh
for each machine
rsync -e ssh --times --perms --recursive --delete $HOME $machine:
end
假设您将清单 2 保存在名为 synch.zsh 的文件中 , 您得按照 zsh synch.zsh moe larry curly 的形式调用该脚本 , 以将主目录复制到另外的计算机 larry 和 curly 。
foreach 行上缺少的列表并不是输入错误:如果您省略某个列表 , 则 foreach 结构将处理命令行上给出的参数列表 。命令行参数也称为位置参数 (positional parameter) , 因为某个参数在命令行上的位置通常在语义上非常重要 。
例如 , 如果您未 指定任何参数 , 则 清单 2 可以利用位置参数的存在性或非存在性来提供有帮助的用法信息 。增强的脚本如清单 3 所示 。
清单 3. 许多脚本将在未提供参数时提供有帮助的消息
#! /bin/zsh
if [[ -z $1 || $1 == "--help" ]]
then
echo "usage: $0 Machine [machine ...]
fi
foreach machine
rsync -e ssh --times --perms --recursive --delete $HOME $machine:
end
命令行上的每个空格分隔的字符串变成了位置参数 , 包括所调用的脚本的名称 。因此 , 命令 synch.zsh 只有一个位置参数 $0 。synch.zsh --help 命令有两个位置参数:$0 和 $1 , 其中 $1 是字符串 --help 。
所以 , 清单 3 表示“如果第一个位置参数为空(-z 操作符测试空字符串)或(由 || 表示)如果第一个参数等于‘—help’ , 则打印用法信息 。(如果您刚开始编写脚本 , 可以考虑在每个脚本中提供用法信息作为提示 。它提醒其他人——甚至您自己 , 如果您忘了的话——如何使用该脚本 。)
短语 [[ -z $1 || $1 == "--help" ]] 是 if 语句的 条件 , 但您也可以将同样的条件子句用作命令 , 并将其与其他命令组合使用以控制通过脚本的流 。请查看清单 4 。它枚举您的 $PATH 中的所有可执行命令 , 并将条件与其他命令组合使用以执行适当的工作 。
清单 4. 列出 $PATH 中的命令
#! /bin/zsh
DirectorIEs=(`echo $PATH | column -s ':' -t`)
for directory in $directories
do
 [[ -d $directory ]] || continue
 
 pushd "$directory"
 
 for file in *
 do
 [[ -x $file && ! -d $file ]] || continue
 echo $file
 done
 
 popd
done | sort | uniq
此脚本中执行了相当多的操作 , 我们将它细分为以下几部分:
第一个实际脚本行——DirectorIEs=(`echo $PATH | column -s ':' -t`)——创建指定目录的数组 。您在 zsh 中通过将参数放在括号中来创建数据 , 例如 directories=(...) 。在此例中 , 数组元素是通过在每个冒号(column -s ':')处分拆 $PATH 以产生空格分隔的目录列表(column 的 -t 参数)来生成的 。
对于列表中的每个目录 , 该脚本尝试枚举该目录中的可执行文件 。步骤 3 至步骤 6 描述了该过程 。
[[ -d $directory ]] || continue 行是所谓的 short-circuiting 命令的一个示例 。short-circuiting 命令在其逻辑条件产生确定的结果时立即终止 。例如 , [[ -d $directory ]] || continue 短语使用逻辑“或(||)——它首先执行第一个命令 , 并且——当且仅当——第一个命令失败时才执行第二个命令 。因此 , 如果 $directory 中的条目存在 , 并且是一个目录(-d 操作符) , 则测试成功 , 求值结束 , 并且 continue 命令(它跳过当前元素的处理)永远不会执行 。
然而 , 如果第一个测试失败 , 则会执行该逻辑的下一个条件或执行 continue 。(continue 始终成功 , 因此它通常出现在 short-circuiting 命令的最后) 。
基于逻辑“与(&&) 的 Short-circuiting 首先执行第一个命令 , 并且——当且仅当——第一个命令成功时才执行第二个命令 。
pushd 和对应的 popd 分别用于在处理前切换到新目录和在处理后切换到先前的目录 。使用目录堆栈是一种理想的脚本技术 , 用于维持您在文件系统中的位置 。
内部的 for 循环枚举当前工作目录中的所有文件——通配符 *(星号)匹配所有条目——然后测试每个条目是否为文件 。[[ -x $file && ! -d $file ]] || continue 行表示“如果 $file 存在并且是可执行文件而且不是目录 , 则处理它;否则执行 continue 。
最后 , 如果前面的所有条件都满足 , 则使用 echo 来显示文件名 。
您弄明白该脚本的最后一行了吗?您可以将大多数控制结构的输出发送给另一个 Unix 命令——毕竟 , Shell 将该控制结构视为一个命令 。因此 , 整个脚本的输出通过 sort、然后通过 uniq 进行管道传输 , 以产生在您的 $PATH 中找到的唯一命令的字母排序列表 。
如果将清单 4 保存到一个名为 listcmds.zsh 的可执行文件 , 则输出可能类似如下:
$ ./listcmds.zsh
[
a2p
ab
ac
accept
accton
aclocal
short-circuiting 命令在脚本中非常有用 。它在单个命令中组合了条件和操作 。而且由于每个 UNIX 命令都返回一个指示成功或失败的状态代码 , 因此 , 您可以使用任何命令作为“条件——而不仅仅是使用测试操作符 。根据约定 , UNIX 返回零 (0) 表示成功 , 返回非零表示失败 , 其中非零值反映所发生的错误类型 。
例如 , 如果将 [[ -d $Directory ]] || continue 行替换为 cd $directory || continue , 则可以从清单 4 中消除 pushd 和 popd 。如果 cd 命令成功 , 则它会返回 0 , 并且逻辑“或的求值可以立即结束 。然而 , 如果 cd 失败 , 则它会返回非零 , 并且会执行 continue 。
不要删除 。应存档!
现代 UNIX Shell——bash、ksh、zsh——提供了许多控制结构和操作以创建复杂的脚本 。由于您可以调用所有 UNIX 命令来将数据从一种形式处理为另一种形式 , Shell 脚本编程几乎与诸如 C 或 Perl 等完整语言中的编程一样丰富 。
您可以使用脚本来自动化几乎所有个人或系统任务 。脚本可以监视、存档、更新、上载、下载和转换数据 。一个脚本可以只有单行或包括无数个子系统 。任务无论大小 , 均可通过脚本来处理 。实际上 , 如果您查看 /etc/init.d 目录 , 会看到在每次启动计算机时运行服务的各种 Shell 脚本 。如果您创建了一个非常有用的脚本 , 您甚至可以将它部署为系统范围的实用程序 。只需将其放到用户的 $PATH 上的某个目录中 。
让我们创建一个实用程序 , 以练习您新发现的诀窍 。脚本 myrm 将替换系统自己的 rm 实用程序 。与彻底删除某个文件不同 , myrm 把要删除的文件复制到某个存档 , 对其进行唯一命名以便您以后能够找到它 , 然后再删除原始文件 。myrm 脚本有效但是非常简单 , 并且您还可以添加许多杂项功能 。您还可以编写一个广泛的 unrm(撤销删除)脚本作为配套实用程序 。(您可以搜索 Internet 来找到各种各样的实现 。)
myrm 脚本如清单 5 所示 。
清单 5. 用于在从文件系统中删除文件之前备份该文件的简单实用程序
#! /bin/zsh
backupdir=$HOME/.tomb
systemrm=/bin/rm
if [[ -z $1 || $1 == "--help" ]]
then
 exec $systemrm
fi
if [[ ! -d $backupdir ]]
then
 mkdir -m 0700 $backupdir || echo "$0: Cannot create $backupdir"exit
fi
args$=$( getopt dfiPRrvw $* ) || exec $systemrm
count=0
flags = ""
foreach argument in $args
do
 case $argument in
--) break;
;;
*) flags="$flags $argument";
(( count=$count1 ));
;;
 esac
done
shift $(( $count ))
for file
do
 [[ -e $file ]] || continue
 copyfile=$backupdir/$(basename $file).$(date " %m.%d.%y.%H.%M.%S")
 /bin/cp -R $file $copyfile
done
exec $systemrm $=flags "$@"
您应该发现该 Shell 脚本很容易理解 , 尽管其中存在一些之前尚未讨论过的新内容 。让我们探讨一下那些新内容 , 然后查看整个脚本 。
当 Shell 运行某个命令(如 cp 或 ls)时 , 它会为该命令产生一个新进程 , 然后在继续之前等待该(子)进程完成 。exec 命令还启动另外一个命令 , 但是与产生新进程不同 , exec 使用一个新命令来“替换当前进程——即 Shell 进程——的任务 。换句话说 , exec 重用同一进程来启动一个新任务 。在该脚本的上下文中 , exec 立即“终止该脚本并启动指定的任务 。
Unix 实用程序 getopt 扫描位置参数以获得您指定的命名参数 。这里 , dfiPRrvw 列表查找 -d、-f、-i、-P、-R、-r、-v 和 -w 。如果出现别的选项 , 则 getopt 将会失败 。否则 , getopt 返回一个以特殊字符串 -- 结尾的选项字符串 。
shift 命令从左到右删除位置参数 。例如 , 如果命令行为 myrm, -r -f -P file1 file2 file3 , 则 shift 3 将分别删除 $0、$1 和 $2 , 或 -r、-f 和 -P 。file1、file2 和 file3 将被重新编号为 $0、$1 和 $2 。
case 语句的工作方式与传统编程语言中的对应结构相似 。它将其参数与列表中的每个模式比较;当找到匹配项时 , 则执行对应的代码 。与在 Shell 中非常类似 , * 匹配所有条目 , 并且可用作在未找到其他匹配项时的缺省操作 。
特殊符号 $@ 展开为所有(其余)的位置参数 。
zsh 操作符 $= 在空白边界处拆分单词 。当您有一个非常长的字符串 , 并且希望将该字符串拆分为各个参数时 , $= 是非常有用的 。例如 , 如果变量 x 包含字符串 '-r -f'——这是一个具有五个字符的单词——$=x 将变为两个单独的单词 -r 和 -f 。
给出这些解释之后 , 您现在应该能够详细分析该脚本了 。下面让我们逐块地研究一下该代码:
第一个块设置整个脚本中使用的变量 。
下一个块应该是非常熟悉的:它在未提供参数时打印用法信息 。它为什么执行 (exec) 实际的 rm 实用程序呢?如果您将此脚本命名为“rm并将其放在 $PATH 中靠前的位置 , 则它就可以充当 /bin/rm 的替代者 。该脚本的错误选项也是 /bin/rm 的错误选项 , 因此该脚本允许 /bin/rm 提供用法信息 。
下一个块在备份目录不存在时创建该目录 。如果 mkdir 失败 , 则该脚本终止并显示适当的错误消息 。
下一个块查找位置参数列表中的 dash 参数 。如果 getopt 成功 , 则 $args 具有一个选项列表 。如果 getopt 失败 , 例如在它无法识别某个选项的时候 , 则它会打印错误消息 , 并且该脚本将退出并显示用法信息 。
随后的块捕获一个字符串中旨在提供给 rm 的所有选项 。当遇到特殊 getopt 选项 -- 时 , 选项收集过程停止 。shift 从参数列表中删除所有已处理的参数 , 保留待处理的文件和目录列表 。
从以 for file 开头的块复制每个文件和目录 , 以便在您自己的存档目录中保存它们 。每个文件的目录被逐字 (-R) 复制到存档目录 , 并附带当前日期和时间作为后缀 , 以确保该副本是唯一的 , 并且不会改写以前存档的具有相同名称的条目 。
最后 , 使用传递给该脚本的相同命令行选项来删除文件和目录 。
然而 , 如果您碰巧需要刚才删除(意外删除?)的文件或目录 , 您可以在存档中查找原始副本 。
向自动化进军
【对话 UNIX,第 6 部分: 通过脚本实现操作的自动化】您使用 Unix 的时间越多 , 就越有可能创建脚本 。脚本可以节省重新输入复杂的较长命令序列所需的时间和精力 , 并且还可以防止发生错误 。Web 上充满了其他人已创建的用于许多目的的有用脚本 。很快您也会发布自己的神奇脚本 。