脚本编程的附加特性
1、脚本"守护进程化"
有时候,你希望脚本能像守护进程(daemon)那样在后台永不休止地运行。要想正确地做到这一点,需要将脚本与其控制 TTY(controlling TTY,也就是用于启动守护进程的终端会话)断开。简单的在命令后面放上一个 & 是不够的。如果通过 SSH(或类似的)会话在远程系统启动守护进程脚本,你会发现当注销时,SSH 会话并不会终止,窗口直到脚本结束才会关闭(真正的守护进程可不会这样)。
用下列方式调用脚本,将其置于后台运行,同时自己也可以注销。
bash
nohup mydaemonscript 0<&-1>/dev/null 2>&1 &
或者:
bash
nohup mydaemonscript >>/var/log/myadmin.log 2>&1 <&- &
你需要关闭控制 TTY(终端),它通过 3 种方式与你的(或任何)作业相连:标准输入(STDIN)、标准输出(STDOUT)和标准错误(STDERR)。关闭 STDOUT 和STDERR 的方法是将其指向其他文件,通常是日志文件,以便随后检索输出信息;或者指向 /dev/null 来丢弃所有输出。我们用重定向运算符 > 来实现。
那 STDIN 呢?最干脆的方法是关闭其文件描述符。实现该操作的 bash 语法和重定向差不多,但用的是连字符作为文件名(0<&- 或 <&-)。
我们用到了 nohup 命令,这样一来,脚本就不会在注销时被挂起(hangup)信号中断了。
第一个示例中的 3 个重定向都明确使用了文件描述符编号(0、1、2)。对于 STDIN 和 STDOUT,这是可选的,因此第二个示例中就不再写明了。另外,相较于起始部分,我们还将输入重定向放在了第二个命令的末尾,因为这里次序并不重要。(但重定向 STDERR 时,次序就变得重要起来了,而且文件描述符编号也要写出。)
2、代码重用
你想将一组 shell 变量赋值语句共用于所编写的一批脚本。你尝试过将这些配置信息放入单独的脚本,但在其他脚本中运行此脚本时,变量值无法保留下来。这是因为配置脚本运行在另一个 shell 中,当该 shell 退出时,这些变量值也一并消失了。有什么办法能在当前 shell 中运行配置脚本吗?
用 bash shell 的 source 命令或 POSIX 的点号(.)读取配置文件的内容。就像在当前脚本中一样处理文件中的各行。
以下是一些配置数据示例。
bash
$ cat myprefs.cfg
SCRATCH_DIR=/var/tmp
IMG_FMT=png
SND_FMT=ogg
$
这不过就是由 3 个赋值语句组成的简单脚本。以下是另一个用到这些值的脚本。
bash
# 使用用户的偏好配置
source $HOME/myprefs.cfg
cd ${SCRATCH_DIR:-/tmp}
echo You prefer $IMG_FMT image files
echo You prefer $SND_FMT sound files
打算使用配置文件的脚本可以通过 source 命令读入该文件。也可以用点号(.)代替 source。在键盘上敲入点号既快又省事,但在脚本或截图中不太容易注意到这个字符。
bash
. $HOME/myprefs.cfg
放心,你绝对不是第一个漏看了点号,觉得直接就能运行该脚本的那个人。
读入(sourcing)是 bash 脚本编程中一种既强大又危险的特性。有了它,就可以创建配置文件并将其共享给多个脚本。利用这种机制,修改配置文件时,只用改动一次就够了。
配置文件的内容并不局限于简单的变量赋值。只要是有效的 shell 命令都可以,因为读入配置文件时,无非就是从其他地方获取输入,命令还是由 bash shell 处理的。不管被读入的文件包含什么样的 shell 命令(例如,循环或调用其他命令),全都属于合法的 shell 输入,将作为脚本的一部分运行。
下面是一个修改过的配置文件。
bash
$ cat myprefs.cfg
SCRATCH_DIR=/var/tmp
IMG_FMT=$(cat $HOME/myimage.pref)
if [ -e /media/mp3 ]
then
SND_FMT=mp3
else
SND_FMT=ogg
fi
echo config file loaded
$
该文件中并非想象那样充斥着各种配置变量。它还可以执行其他命令(如 cat)、使用 if 语句进行条件判断,甚至还能输出消息。读入文件时一定要当心,它就像一扇敞开的大门,直通你的脚本。
最好的办法是定义 bash 函数(参见 10.4 节)。这些函数能够以通用函数库的形式在所有读入了函数定义的脚本之间共享。
3、在脚本中使用配置文件
你想在脚本中使用外部配置文件。
你可以编写大量代码来解析某些特殊的配置文件格式。不过,拜托了,别这么干。将配置文件制作成 shell 脚本,然后使用 2 节中的方法就行了。
这只不过是 source 命令的一种特定应用而已。值得一提的是,你可能得花点心思,想想如何将所有的配置项转换成合法的 bash 语句。特别是,你可以利用布尔标志和可选变量。
bash
# 在配置文件中
VERBOSE=0 # 0或者''表示关闭,1表示启用
SSH_USER='jbagadonutz@' # 注意结尾的@,如果将该配置项的值设置为'',
# 则表示使用当前用户
# 在脚本中
[ "$VERBOSE" ] || echo "Verbose msg from $0 goes to STDERR" >&2
[...]
ssh $SSH_USER$REMOTE_HOST [...]
当然了,依靠用户正确书写配置文件有一定风险,因此,不要指望用户阅读注释并在结尾处添加 @,我们还是自己在脚本中完成吧。
bash
# 如果已设置$SSH_USER且结尾处没有@,那么将其添加上:
[ -n "$SSH_USER" -a "$SSH_USER" = "${SSH_USER%@}" ] && SSH_USER="$SSH_USER@"
或者只用:
bash
ssh ${SSH_USER:+${SSH_USER}@}${REMOTE_HOST} [...]
两种写法的效果相同。bash 变量运算符 :+ 的操作如下:如果 $SSH_USER 不为空,则返回 :+ 右侧的值(这里指定的是该变量自身以及额外的 @);否则,什么都不返回。
4、定义函数
你想在 shell 脚本的几处地方为用户显示用法信息(描述正确的命令语法),但又不愿意重复同样的 echo 语句。有办法只写一次代码,然后多次引用吗?如果可以将用法信息作为单独的脚本,那就可以在原始脚本的任意位置调用了,但这样的话,本来一个脚本就能搞定的事,现在则需要两个。而且,将一个脚本的用法信息作为另一个脚本的输出看起来怪怪的。有没有更好的解决方法?
你需要的是 bash 函数。在脚本的起始部分加入下列内容:
bash
function usage ()
{
printf "usage: %s [ -a | - b ] file1 ... filen\n" ${0##*/} > &2
}
随后,可以在脚本中按以下方式写代码:
bash
if [ $# -lt 1]
then
usage
fi
定义函数的方法不止一种( function name () {compound-command } redirections ),以下方法都可以。
bash
function usage ()
{
printf "usage: %s [ -a | - b ] file1 ... filen\n" ${0##*/} > &2
}
function usage {
printf "usage: %s [ -a | - b ] file1 ... filen\n" ${0##*/} > &2
}
usage ()
{
printf "usage: %s [ -a | - b ] file1 ... filen\n" ${0##*/} > &2
}
保留字 function 或结尾的 () 必须出现。如果使用了function,那么 () 就是可选的。我们喜欢使用 function,因为既清晰,可读性又好,还便于 grep 搜索。例如,grep^'function' script 可以列出 script 文件中的所有函数。
函数定义应该放在 shell 脚本的开头,或者至少位于函数调用之前。从某种意义上来说,函数定义就是另一种bash 语句。但只要得以执行,就定义了函数。如果在定义之前调用函数,则会产生"command not found"错误。这就是为什么我们要始终将函数定义写在脚本中的其他命令之前。
例子中的函数基本上没干什么事,就只有一个 printf 语句而已。但由于我们将用法信息放在了函数中,以后如果增加了新选项,就不用修改散落在脚本各处的各个语句,只修改这个函数就够了。
除了格式字符串之外,printf 唯一的参数就是 KaTeX parse error: Expected 'EOF', got '#' at position 31: ...ell 脚本的完整名称,经过 #̲# 运算符修改后,可以得到路径...(basename $0)。
由于用法信息属于错误信息,我们将 printf 语句的输出重定向到了标准错误。也可以将重定向放在函数定义之外,如此一来,函数的所有输出都会被重定向。如果有多个输出语句,这种做法很方便。
bash
function usage ()
{
printf "usage: %s [ -a | - b ] file1 ... filen\n" ${0##*/}
printf "example: %s -b *.jpg \n" ${0##*/}
printf "or else: %s -a myfile.txt yourfile.txt \n" ${0##*/}
} > &2
5、使用函数:参数和返回值
你想使用函数,同时需要将一些值传入该函数。怎么传递参数呢?又该如何将值返回?
你不用像在某些编程语言中那样在参数两边加上括号。直接将参数放在函数名右边,彼此之间用空白字符分隔,就像调用 shell 脚本或命令那样。如果有必要,记得给参数加上引号!
bash
# 定义函数:
function max ()
{ ... }
#
# 调用函数:
#
max 128 $SIM
max $VAR $CNT
从函数返回值的方法有两种。一种方法是向函数内部的变量赋值,如例所示。这些变量都是全局变量,除非函数内明确使用 local 声明。
bash
# 实例文件:func_max.1
# 定义函数:
function max ()
{
local HIDN
if [ $1 -gt $2 ]
then
BIGR=$1
else
BIGR=$2
fi
HIDN=5
}
例如:
bash
# 调用函数:
max 128 $SIM
# 使用结果:
echo $BIGR
另一种方法是使用 echo 或 printf 将输出发送到标准输出,如例所示。
bash
# 实例文件:func_max.2
# 定义函数:
function max ()
{
if [ $1 -gt $2 ]
then
echo $1
else
echo $2
fi
}
然后,你必须在 $() 内调用函数,获取其输出并使用,否则就白白显示在屏幕上了。例如:
bash
# 调用函数:
BIGR=$(max 128 $SIM)
# 使用结果:
echo $BIGR
在调用函数时放置参数就和调用 shell 脚本一样。参数无非就是另一种命令行单词罢了。
与命令行参数一样,在函数中引用参数也是使用 1、2等。但是,0 保持不变,其中包含的还是所调用脚本的名称。一旦从函数中返回,1、$2 等就恢复原状,仍旧引用脚本参数。
$FUNCNAME 数组也值得关注。$FUNCNAME 本身引用的是该数组的第 0 个元素,其中包含的是当前执行函数的名称。换句话说,$FUNCNAME 对于函数来说就像 $0 对于脚本一样,只是前者不包含路径信息。数组的其余元素形成了一个调用栈(call stack),最底部或者最后那个元素就是"main"。$FUNCNAME 仅存在于函数执行期间。
我们创建 $HIDN 这个没什么用的变量只是为了表明它是函数的局部变量。虽然可以在函数内为其赋值,但该值无法在脚本的其他地方使用。函数的局部变量仅在函数被调用时存在,函数返回后就会被销毁。
通过设置变量的方式从函数返回值更为高效,而且可以处理大量数据(设置多个变量),但这种方法也有缺点。它要求函数和脚本其余部分就传递信息的变量名达成一致。这种耦合性存在维护方面的问题。别的方法(通过输出返回值)确实可以降低耦合,但同时也限制了其实用性:由于脚本不得不花很多精力来解析函数的结果,因而限制了返回的数据量。那该采用哪种方法呢?和大量的工程实践一样,这同样需要根据具体需求进行权衡。
6、中断陷阱
你正在编写一个能够捕获信号并做出相应处理的脚本。
使用实用工具 trap 来设置信号处理程序。首先,用 trap -l(或者 kill -l)列出你想捕获的信号。输出结果因系统而异。
bash
# NetBSD
$ trap -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL
5) SIGTRAP 6) SIGABRT 7) SIGEMT 8) SIGFPE
9) SIGKILL 10) SIGBUS 11) SIGSEGV 12) SIGSYS
13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGURG
17) SIGSTOP 18) SIGTSTP 19) SIGCONT 20) SIGCHLD
21) SIGTTIN 22) SIGTTOU 23) SIGIO 24) SIGXCPU
25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH
29) SIGINFO 30) SIGUSR1 31) SIGUSR2 32) SIGPWR
$
# Linux(为了适合页面排版,对输出做了折行处理)
$ trap -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL
5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE
9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2
13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT
17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU
25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH
29) SIGIO 30) SIGPWR 31) SIGSYS 34) SIGRTMIN
35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3 38) SIGRTMIN+4
39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12
47) SIGRTMIN+13 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14
51) SIGRTMAX-13 52) SIGRTMAX-12 53) SIGRTMAX-11 54) SIGRTMAX-10
55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7 58) SIGRTMAX-6
59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
$
接下来,设置要捕获的信号和信号处理程序。注意,如果命令被信号 signal number 所终止,则脚本的退出状态码为 128+signal number。下面是一个简单的例子,其中只关心是否捕获到信号,并不在意是什么信号。如果使用trap '' ABRT EXIT HUP INT QUIT TERM,脚本将很难被"杀死",因为这些信号都会被忽略。
bash
$ cat hard_to_kill
#!/bin/bash
trap ' echo "You got me! $?" ' ABRT EXIT HUP INT QUIT TERM
trap ' echo "Later... $?"; exit ' USR1
sleep 120
$ ./hard_to_kill
^CYou got me! 130
You got me! 130
$ ./hard_to_kill &
[1] 26354
$ kill -USR1 %1
User defined signal 1
Later... 158
You got me! 0
[1]+ Done ./hard_to_kill
$ ./hard_to_kill &
[1] 28180
$ kill %1
You got me! 0
[1]+ Terminated ./hard_to_kill
下例更有意思。
bash
#!/usr/bin/env bash
# 实例文件:hard_to_kill
function trapped {
if [ "$1" = "USR1" ]; then
echo "Got me with a $1 trap!"
exit
else
echo "Received $1 trap--neener, neener"
fi
}
trap "trapped ABRT" ABRT
trap "trapped EXIT" EXIT
trap "trapped HUP" HUP
trap "trapped INT" INT
trap "trapped KILL" KILL # 没有效果
trap "trapped QUIT" QUIT
trap "trapped TERM" TERM
trap "trapped USR1" USR1 # 特殊情况
# 空循环,什么都不做,不引入"第三方"捕获行为,
# 效果就像使用了sleep一样
while (( 1 )); do
: # :是一个空命令
done
这里调用该脚本,然后尝试将其"杀死":
bash
$ ./hard_to_kill
^CReceived INT trap--neener, neener
^CReceived INT trap--neener, neener
^CReceived INT trap--neener, neener
^Z
[1]+ Stopped ./hard_to_kill
$ kill -TERM %1
[1]+ Stopped ./hard_to_kill
Received TERM trap--neener, neener
$ jobs
[1]+ Stopped ./hard_to_kill
$ bg
[1]+ ./hard_to_kill &
$ jobs
[1]+ Running ./hard_to_kill &
$ kill -TERM %1
Received TERM trap--neener, neener
$ kill -HUP %1
Received HUP trap--neener, neener
$ kill -USR1 %1
Got me with a USR1 trap!
Received EXIT trap--neener, neener
[1]+ Done ./hard_to_kill
首先得说明一下,其实你是无法捕获 -SIGKILL(-9)的。该信号可以立刻"杀死"进程,压根没机会捕获。因此,也许我们的例子并不是真的难以被"杀死"。但要记住,该信号使得脚本或程序无法随时完成清理工作或者有条不紊地终止。这往往不是什么好事,所以除非没有选择,否则尽力避免使用 kill -KILL。
trap 的用法如下所示。
bash
trap [-lp] [arg] [signal [signal]]
第一个非选项参数是接收到信号时要执行的代码。如先前的示例所示,代码可以是独立的(self-contained),也可以调用函数。对于大多数一般应用,调用一个或多个错误处理函数可能是最好的方法,这种形式可以很好地完成清理工作,然后有条不紊地终止。如果该参数为空串,则忽略指定的信号。如果该参数为 - 或不指定,那么后续列出的一个或多个信号会被重置为默认值。-l 选项会列出信号名称,如6.2 节所示,而 -p 选项会打印出当前捕获的信号及其处理程序。
如果使用了多个信号处理程序,我们建议你花点时间按照字母顺序排列信号名称,因为这样更易于阅读和后续查找。
之前提到过,如果命令是被信号 signal number 终止的,则脚本的退出状态码为 128+signal number。
有 3 个用于特殊目的的伪信号。DEBUG 信号类似于EXIT,但它是在每个命令执行前产生的,可作为调试之用。从函数中返回或用 source(.)运行完脚本后,RETURN 信号会被触发。如果命令执行失败,ERR 信号会被触发。更多具体细节和注意事项,请查阅 BashReference Manual,尤其是如何用 declare 或 set -ofunctrace 选项处理函数。
一些 POSIX 差异会对 trap 产生影响。如 Bash Reference Manual 中所述:"使用命令行选项 --posix 启动 bash 或者在 bash 运行时执行 'set -o posix',会使得 bash 在默认行为存在差异的地方做出改变,以符合 POSIX 的规定,从而更严格地遵循 POSIX 标准。"尤其是,这会导致 kill 和trap 不显示信号名称前的 SIG,同时 kill -l 的输出也会有所不同。另外,trap 在处理参数时会更严格,重置信号时会要求在信号名称前加上 -。这也就是说,需要写成 trap -USR1,而不是 trap USR1。不管有没有必要,我们都建议你加上 -,这样能更清晰地表达意图。
7、用别名重新定义命令
你想略微更改一下命令的定义,这样就可以总是使用该命令的特定选项(例如,ls 命令的 -a 选项或 rm 命令的 -i 选项)。
(仅)对于交互式 shell,可以使用 bash 的别名特性。alias 命令足够聪明,即使写成下面这样,也不会陷入死循环。
bash
alias ls='ls -a'
事实上,如果只输入 alias,不使用其他参数,你可以看到当前 bash 会话中已经定义好的别名列表。有些发行版可能已经预先设置好了一些别名。
别名机制就是一种直截了当的文本替换。它发生在命令行处理的早期阶段,因此在别名之后还会出现其他替换操作。例如,要想将字母"h"定义为可列出用户主目录内容的命令,可以使用以下方式:
bash
alias h='ls $HOME'
或者:
bash
alias h='ls ~'
第一种写法中的单引号至关重要,这意味着定义别名时并不会对变量 HOME 求值。只有执行命令时才会进行(字符串)替换并对变量 HOME 求值。因此可以说,如果更改了 $HOME 值,别名也会随之变动。
如果改用双引号,变量会被立刻求值,定义别名时使用的就是 $HOME 的值。输入不带参数的 alias 就可以从所有的别名定义中看到类似于下列的结果。
bash
...
alias h='ls /home/youracct'
...
如果对别名不满意,不想再用,使用 unalias 以及想删除的别名即可。例如:
bash
\unalias h
如果场面难以收拾,可以用 unalias -a 删除当前 shell 会话中的所有别名定义。为什么要在上面的命令前添加反斜线前缀?反斜线能够禁止任何命令的别名扩展,这是标准的最佳安全实践,目的就是避免不安好心的人给 unalias也设置别名(可能会设置成":"),从而令其丧失作用。
bash
$ alias unalias=':'
$ alias unalias
alias unalias=':'
$ unalias unalias
$ alias unalias
alias unalias=':'
$ \unalias unalias
$ alias unalias
bash: alias: unalias: not found
定义别名时不允许使用参数。例如,你不能写成:
bash
alias='mkdir $1 && cd $1'
$1 和 $HOME 的区别在于:$HOME 是在定义别名时指定的,而你希望 $1 在运行时传入。不好意思,这无法实现。改用函数吧。
8、避开别名和函数
你创建的别名或函数覆盖了真正的命令,但现在想执行的是后者。
使用 bash shell 的 builtin 命令来忽略 shell 函数和别名,执行实际的内建命令。
使用 command 命令来忽略 shell 函数和别名,并执行实际的外部命令。
如果只是想避开别名扩展,但仍允许执行函数,在命令前加上 \ 前缀即可。
使用 type 命令(以及 -a 选项)来了解 shell 如何解释指定的名称。
我们来看几个示例。
bash
$ alias echo='echo ~~~'
$ echo test
~~~ test
$ \echo test
test
$ builtin echo test
test
$ type echo
echo is aliased to `echo ~~~'
$ unalias echo
$ type echo
echo is a shell builtin
$ type -a echo
echo is a shell builtin
echo is /bin/echo
$ echo test
test
我们来接着讨论这个函数定义。
bash
function cd ()
{
if [[ $1 = "..." ]]
then
builtin cd ../..
else
builtin cd "$1"
fi
}
alias 命令足够聪明,不会在碰到类似于 alias ls='ls-a' 或alias echo='echo ~~~' 这种写法时陷入死循环,因此,第一个例子不需要对别名定义的右侧部分做什么特殊处理就可以引用实际的 echo 命令。
如果已经将 echo 定义为别名,type 命令不仅会告诉我们它是别名,还会显示该别名的定义。与此类似,如果是函数定义,则显示实际的函数代码。type -asome_command(只要不同时使用 -p)会显示所有包含 some_command 的地方(别名、内建命令、函数、外部命令)。
如果同时使用 -p 选项与 -a 选项,type 不会输出别名和函数。
最后一个例子用函数覆盖了内建命令 cd,以便添加一项简单的便捷功能。我们希望函数能够理解 cd ... 代表向上退回两级目录,也就是 cd .../...(参见 16.15 节)。其他所有参数仍按照正常方式对待。该函数只是简单地尝试匹配...,然后将其替换成实际含义。但在函数内部(或外部),如何调用实际的 cd 命令来切换目录呢?builtin 命令告诉 bash 将其后的命令视为 shell 的内建命令,不使用任何别名或函数。我们在函数中用到了该命令,但任何时候都可以用它来明明白白地引用实际命令,避开同名的函数。
如果与函数同名的是可执行文件(如 ls),而非内建命令,那么你可以改用可执行文件的完整路径(如 /bin/ls)来避开别名以及函数定义。如果不知道完整路径,那就在命令前加上关键字 command,这样 bash 也会忽略同名的别名和函数定义,使用实际命令。但要注意,查找命令时仍会用到 PATH 变量。如果因为 PATH 设置有误而导致无法执行 ls,在这种情况下,使用 command 也无济于事。
9、计算已过去的时间
你想要显示脚本或脚本中某个操作花费了多长时间。
使用内建命令 time 或 bash 变量 $SECONDS。
time 以多种方式报告某个进程或管道所花费的时间。
bash
$ time sleep 4
real 0m4.029s
user 0m0.000s
sys 0m0.000s
$ time sha256sum /bin/* &> /dev/null
real 0m1.252s
user 0m0.072s
sys 0m0.028s
你可以在脚本中对各个命令或函数计时,但无法对整个脚本计时。显然可以在调用脚本时或者对 cron 作业使用time,但要注意,将其添加到 cron 会一直产生输出,因而总是会收到 cron 发来的邮件。
如果这种方法看起来有些大材小用,或者你只是想知道整个脚本用了多长时间,那么不妨使用变量 $SECONDS。根据 Bash Reference Manual 的说法:
$SECONDS 会扩展成 shell 启动以来的时长(秒数)。如果为其赋值,那么随后在扩展该变量时,得到的值就是先前的赋值与赋值操作以后的时长(秒数)之和。
例如:
bash
$ cat seconds
started="$SECONDS"
sleep 4
echo "Run-time = $(($SECONDS - $started)) seconds..."
$ bash seconds
Run-time = 4 seconds...
$ time bash seconds
Run-time = 4 seconds...
real 0m4.003s
user 0m0.000s
sys 0m0.000s
10、编写包装器
你手头有一系列相关的命令或工具,经常临阵拿来使用,你想将它们汇集到一处,以便使用和记忆。
根据需要,用 case...esac 编写一个 shell 脚本"包装器"。
基本的处理方法有两种。一种是编写大量的微型 shell 脚本或别名来应对所有需求。BusyBox 就是这么做的,其中的大量工具只是指向单个二进制文件的符号链接。另一种方法和大多数版本控制工具一样,调用类似于"前缀"(prefix)的单个二进制文件,然后添加操作或命令。两种方法各有千秋,不过我们更倾向于后一种,因为你只需记得一个前缀命令就够了。
网站 Signal v. Noise 对此曾有过精彩的讨论和更为复杂的实现,值得一读。我们的实现要更简单些,用到了一些技巧,其中部分设计考虑如下:
- 易于阅读和理解
- 易于扩展
- 易于编写内建在线帮助
- 易于使用和记忆
本书是用 Asciidoc 编写的,需要记忆大量标记。以下代码取自我们自己编写的实用工具。该工具能够从命令行获取输入,也可以读写 Linux 的剪切板。
bash
#!/usr/bin/env bash
# 实例文件:ad
# 用于Asciidoc的O'Reilly"图书"工具包装器
# 普通的完备性检查 ➊
[ -n "$BOOK_ASC" ] || {
echo "FATAL: must export \$BOOK_ASC to the location of '...bcb2/head/asciidoc/'!"
exit 1
}
\cd "$BOOK_ASC" || {
echo "FATAL: can't cd to '$BOOK_ASC'!"
exit 2
}
SELF="$0" # 为了在递归中更加清晰 ➋
action="$1" # 提高代码的可读性 ➌
shift # 删除参数 ➍
# 如果xsel是可执行文件且没有更多的参数 ➎
[ -x /usr/bin/xsel -a $# -lt 1 ] && {
# 读/写剪贴板
text=$(xsel -b)
function Output {
echo -en "$*" | xsel -bi
}
} || { # 否则......
# 读/写STDIN/STDOUT
text=$*
function Output {
echo -en "$*"
}
}
case "$action" in
#######################################################################
# 内容/标记
rec|recipe ) # 为新实例创建标签 ➏
id="$($SELF id $text)" # 创建一个ID ➐
Output "$(cat <<- EoF ➑
[[$id]]
=== $text
[[problem-$id]]
==== Problem
[[solution-$id]]
==== Solution
[[discussion-$id]]
==== Discussion
[[see_also-$id]]
==== See Also
* \`man \`
* item1
* <<xref-id-here>>
* URL[text]
EoF
)"
;;
table ) # 为新表格创建标签 ➒
Output "$(cat <<- EoF
.A Table
[options="header"]
|=======
|head|h|h
|cell|c|c
|cell|c|c
|=======
EoF
)"
;;
# ...
### 标题
h1 ) # 在每章的一级标题内(Asciidoc h3) ➓
Output "=== $text"
;;
h2 ) # 在每章的二级标题内(Asciidoc h4)
Output "==== $text"
;;
h3 ) # 在每章的三级标题内(Asciidoc h5)
Output "===== $text"
;;
### 列表
bul|bullet ) # 无序列表(.. = 2级,+ = 多行元素)
Output ". $text"
;;
nul|number|order* ) # 有序列表(## = 2级,+ = 多行元素)
Output "# $text"
;;
term ) # 术语
Output "term_here::\n $text" ⓫
;;
# ...
cleanup ) ## 清理所有xHTML/XML/PDF碎片
rm -fv {ch??,app?}.{pdf,xml,html} book.xml docbook-xsl.css ⓬
;;
* )
\cd - > /dev/null # 返回到前一个目录 ⓭
# 另外参见Stack OverFlow网站上的如下问题及其答案:How to get the
# source directory of a Bash script from within the script itself?
# can-a-bash-script-tell-what-directory-its-stored-in
( echo "Usage:"
egrep '\)[[:space:]]+# ' $SELF
echo ''
egrep '\)[[:space:]]+## ' $SELF ) | more ⓮
;;
esac
- ❶ 对要求的变量和位置进行完备性检查。
- ❷ 为递归设置可读性更好的名称。
- ❸ 为接下来要执行的命令或操作设置可读性更好的名称。
- ❹ 删除参数,以后不再重复使用或将其包含在输入或输出中。
- ❺ 如果 xsel 命令可用且可执行,同时我们也没有传递任何参数,则可以将输入和输出设置为剪切板。该脚本摇身变成了应用程序通用宏工具。无论使用什么编辑器,如果你拥有 GUI 并能读写剪贴板,则切换到终端会话时,就可以轻而易举地对文本进行复制、处理、粘贴,实在是方便极了!
- ❻ case...esac 中的每个语句块都包含了代码和文档,其中的分支代码部分可以采用任何有意义的顺序,但是帮助 /用法信息可能因此有所不同。
- ❼ 获得输入文本并发起递归调用以得到 ID,然后输出样板标记。
- ❽ 注意,在 here-document 内部必须使用制表符缩进。
- ❾ 有时样板标记不包括任何输入文本。
- ❿ 有时候操作非常简单,记住需要多少个等号就行了。
- ⓫有时候操作就有些复杂了,还有嵌入的换行符和扩展的转义字符。
- ⓬可以执行你能想到的任何操作,弄清楚如何实现自动化。
- ⓭如果没有提供任何参数,或者提供的参数不正确,甚至包括 -h 或 --help 等,你都会看到用法信息。
- ⓮我们将一段代码放入子 shell (),按照正确的顺序获得输出并将其全部传给 more 命令。两个 egrep 命令负责显示case...esac 中与分支判断相关的行,如➏中所述,这些行包含了代码和文档,按照 # 字符的数量(1 个或 2 个)进行分组。
用法示例:
bash
$ ad
Usage:
rec|recipe ) # 为新实例创建标签
table ) # 为新表格创建标签
h1 ) # 在每章的一级标题内(Asciidoc h3)
h2 ) # 在每章的二级标题内(Asciidoc h4)
h3 ) # 在每章的三级标题内(Asciidoc h5)
bul|bullet ) # 无序列表(.. = 2级,+ = 多行元素)
nul|number|order* ) # 有序列表(## = 2级,+ = 多行元素)
term ) # 术语
cleanup ) ## 清理所有xHTML/XML/PDF碎片
$
要想用 ad 为一则新实例创建标签,你需要输入标题,将其选中,打开或切换到终端窗口,输入 ad rec,再切换回编辑器,然后粘贴。说起来麻烦,但做起来又快又简单。这种脚本的优点在于"包治百病",通常很容易扩展,也就用法提示需要自己来写。我们已经按照这种模式将脚本应用于以下方面:
- 包装各种 SSH 命令来负责多组服务器的日常杂务工作先于 apt 收集各种 Debian 软件包系统工具
- 自动执行各种"清理"任务,如修剪空白字符、排序,以及执行各种简单的文本操作(例如,剥离富文本格式)
- 自动化 grep 命令以搜索各种特定的文件类型和位置,获取备注和归档文档