对话 UNIX: 更多 shell 脚本技术
尽管在近两年使用过 Unix 的一些人可能尝试过 shell 脚本编程,但是他们很可能只是研究操作系统的细节,并不精通 shell 脚本编程。本文针对那些希望进一步了解 shell 脚本,并开始编写更高级脚本的读者。本文提供脚本编程的基础知识,包括如何简化脚本、如何尽可能保持脚本的灵活性、如何编写干净的脚本、在脚本内编写注释以及调试脚本。
保持简单
在人们学习如何编写 shell 脚本时,常常遇到的一个问题是,重复他们在另一个脚本中已经做过的工作。他们其实不需要复制原来的脚本并修改几个硬编码值,只需创建一个函数来处理两个脚本的重复部分。创建集中的函数还可以促进标准化,帮助创建统一的脚本。如果一个函数在脚本的一个部分工作正常,那么它在脚本中的其他地方也会正常工作。
例如,清单 1 所示的脚本应该浓缩和简化为更简单、更干净的程序。
清单 1. 可以简化的脚本示例
#!/usr/bin/kshif [[ $# -lt 2 ]]then echo "Usage: ${0##*/} <file name #1> <file name #2> exit 0fiif [[ ! -f "${1}" ]]then echo "Unable to find file '${1}'" exit 1fiif [[ ! -r "${1}" ]]then echo "Unable to read file '${1}'" exit 2figzip ${1}ls -l ${1}.gzif [[ ! -f "${2}" ]]then echo "Unable to find file '${2}'" exit 1fiif [[ ! -r "${2}" ]]then echo "Unable to read file '${2}'" exit 2figzip ${2}ls -l ${2}.gz
这个脚本看起来很糟糕!(谢天谢地,它只是一个示例)。这个脚本应该尽可能进行浓缩。从便于阅读的角度来看,清单 2 提供的版本更干净。
清单 2. 对清单 1 脚本进行浓缩的版本
#!/usr/bin/kshexit_msg() { [[ $# -gt 1 ]] && echo "${0##*/} (${1}) - ${2}" exit ${1:-0}}[[ $# -lt 2 ]] && exit_msg 0 "Usage: ${0##*/} <file name #1> <file name #2>for _FNAME in $@do [[ ! -f "${_FNAME}" ]] && exit_msg 1 "Unable to find file '${_FNAME}'" [[ ! -r "${_FNAME}" ]] && exit_msg 2 "Unable to read file '${_FNAME}'" gzip ${_FNAME} ls -l ${_FNAME}.gzdone
注意到这两者的差异了吗?这个脚本增加了一个简单的函数来显示一个消息并带适当的返回码退出,还把所有操作转移到一个 for 循环中,这使这个脚本看起来更干净、更容易理解了。
保持灵活性
编程和 shell 脚本编程的新手常常犯的另一个错误是,在程序或 shell 脚本中对静态值进行硬编码。这会限制脚本的灵活性,是一种糟糕的编程习惯。这迫使管理员或开发人员不得不经常修改脚本以使用其他值;为了避免这个问题,应该使用变量并为脚本或函数提供参数。
例如,清单 3 是一个编写得很差的不灵活的示例脚本。
清单 3. 不灵活的示例脚本
#!/bin/bashif [[ -f /home/cormany/FileA ]]then echo "Found file '/home/cormany/FileA'"elif [[ -f /home/cormany/DirA/FileA ]]then echo "Found file '/home/cormany/DirA/FileA'"else echo "Unable to find file FileA"fi
这个脚本可以正常工作,但是它只能在两个位置搜索一个文件。
清单 4 提供相同的功能,但是允许用户在任何位置搜索任何文件。
清单 4. 使脚本更灵活
#!/bin/bashexit_msg() { [[ $# -gt 1 ]] && echo "${0##*/} (${1}) - ${2}" exit ${1:-0}}[[ $# -lt 2 ]] && exit_msg 1 "Usage: ${0##*/} <file name> <location>"_FNAME="${1}"_DNAME="${2}"[[ ! -d "${_DNAME}" ]] && exit_msg 2 "Unable to read or find Directory '${_DNAME}'"if [[ -f "${_DNAME}/${_FNAME}" ]]then exit_msg 0 "Found file '${_DNAME}/${_FNAME}'"else exit_msg 3 "Unable to find file '${_DNAME}/${_FNAME}'"fi
这个脚本更灵活,因为它允许用户指定要搜索的任何文件和任何搜索目录。
提供选项
在编写一个 shell 脚本时,一些用户可能会说,“它真不错! 或者 “我喜欢使用它;而同时,其他用户可能不同意这个评价,他们可能不希望执行相同的操作。人们喜欢有选择,为什么不给他们提供选项呢?内置的 shell 命令 getopt 可以完成这个任务。
清单 5 提供一个在 AIX 中使用 getopt 的基本示例。
清单 5. getopt 示例
#!/usr/bin/ksh_ARGS=`getopt -o x --long xxxxx -n ${0##*/} -- "$@"`while [[ $# -gt 0 ]]do case "${1}" in-x|--xxxxx) echo "Arg x hit!"shift;;--) shift; break;; *) echo "Invalid Option: ${1}"break;; esacdone
在执行包含 getopt(称为 opttest)的脚本时,如果在 -x 或 --xxxxx 中使用有效的参数,getopt 会识别出开关并执行 case 开关中的代码:
# ./hm -xArg x hit!
下面是使用无效开关或选项时的结果:
# ./hm -aInvalid Option: -a
文档,文档,文档
我们在职业生涯中早晚会受到这个问题的困扰。老板要求您看看一个 10 年前编写的脚本,它的作者已经不再为公司工作了。您会说 “没问题 吗?通常情况下,可能没问题;但是,如果这个脚本很复杂,执行了您不习惯使用的命令,采用的编写风格与您的风格不一样,或者干脆就不能正常工作,您就遇到大麻烦了。在这种情况下,一些反映作者当初编写这个脚本时的想法的提示会有很大的帮助。有时候,您开发了一个自认为只使用一次的脚本,但是以后却发现还需要修改它。或者,您用几星期时间编写了一个巨大的脚本,您了解这个脚本的所有细节,但是如果别人阅读它,却不知所云。这几种情况说明,文档之于开发人员就像脚本之于用户,都非常重要。
看看清单 6 所示的函数。
清单 6. 没有注释的脚本示例
confirm_and_exit() { [[ ${_DEBUG_LEVEL} -ge 3 ]] && set -x while [[ -z ${_EXIT_ANS} ]] docup_echo "Are you sure you want to exit? [Y/N] c" ${_PROMPT_ERR_ROW} ${_PROMPT_ERR_COL}${_TPUT_CMD} cnormread ${_NO_EOL_FLAG:+${_READ_FLAG:-'-n'}} ${_NO_EOL_FLAG} _EXIT_ANS${_TPUT_CMD} civis done case ${_EXIT_ANS} in[Nn]) unset _EXIT_ANS; return 0;;[Yy]) exit_msg 0 1 "Exiting Script";; *) invalid_selection ${_EXIT_ANS}; unset _EXIT_ANS;; esac return 0}
如果您有比较丰富的 shell 脚本编程经验,可能能够读懂这个脚本。但是,脚本编程的初学者很难理解这个函数的作用。如果花上几分钟在这个脚本中添加注释,情况就大不一样了。清单 7 给出包含注释的同一个函数。
清单 7. 包含注释的脚本示例
########################################## function confirm_and_exit#########################################confirm_and_exit() { # if the debug level is set to 3 or higher, send every evaluated line to stdout [[ ${_DEBUG_LEVEL} -ge 3 ]] && set –x # Continue to prompt the user until they provide a valid answer while [[ -z ${_EXIT_ANS} ]] do# prompt user if they want to exit the script# cup_echo function calls tput cup <x> <y># syntax:# cup_echo <string to display> <row on stdout to display><column on stdout to display>cup_echo "Are you sure you want to exit? [Y/N] c" ${_PROMPT_ERR_ROW} ${_PROMPT_ERR_COL}# change cursor to normal via tput${_TPUT_CMD} cnorm# read value entered by user# if _NO_EOL_FLAG is supplIEd, use value of _READ_FLAG or “-n# if _NO_EOL_FLAG is supplied, use value as characters aloud on read# assign value entered by user to variable _EXIT_ANSread ${_NO_EOL_FLAG:+${_READ_FLAG:-'-n'}} ${_NO_EOL_FLAG} _EXIT_ANS# change cursor to invisible via tput${_TPUT_CMD} civis done # if user entered “n, return to previous block of code with return code 0 # if user entered “y, exit the script # if user entered anything else, execute function invalid_selection case ${_EXIT_ANS} in[Nn]) unset _EXIT_ANS; return 0;;[Yy]) exit_msg 0 1 "Exiting Script";; *) invalid_selection ${_EXIT_ANS}; unset _EXIT_ANS;; esac # exit function with return code 0 return 0}
对于这么小的函数,这似乎太麻烦了,甚至有点过分,但是对于 shell 脚本编程新手和阅读这个函数的人员而言,注释是非常有价值的。
在 shell 脚本中,注释的另一个极其有帮助的用途是,解释变量的有效值以及解释返回码的含义。
清单 8 中的示例取自一个 shell 脚本的开头。
清单 8. 未加注释的变量示例
#!/usr/bin/bashtrap 'exit_msg 1 0 "Signal Caught. Exiting..."' HUP INT QUIT KILL ABRTtrap 'window_size_changed' WINCH_MSG_SLEEP_TIME=3_RETNUM_SIZE=6_DEBUG_LEVEL=0_TMPDIR="/tmp"_SP_LOG="${0##*/}.log"_SP_REQUESTS="${HOME}/sp_requests"_MENU_ITEMS=15LESS="-P LINE: %l"export _SP_REQUESTS _TMPDIR _SP_LOG _DB_BACKUP_DIRexport _DEBUG_LEVEL _NEW_RMSYNC _RMTOTS_OFFSET_COL
同样,很难理解 trap 语句的作用以及每个变量可以是哪些值。除非把整个脚本都读一遍,否则不可能看出这些变量的意义。另外,这里没有提到这个脚本中使用的任何返回码。这会大大增加解决 shell 脚本问题的难度。向 清单 8 的代码行中添加一些注释和一个专门描述返回码的注释块,这样就可以显著降低理解难度。看看下面的清单 9。
清单 9. 带注释的变量示例
#!/usr/bin/bash########################################################################## traps########################################################################## trap when a user is attempting to leave the scripttrap 'exit_msg 1 0 "Signal Caught. Exiting..."' HUP INT QUIT KILL ABRTtrap 'window_size_changed' WINCH# trap when a user has resized the window################################################################################################################################################### defined/exported variables#########################################################################_MSG_SLEEP_TIME=3 # seconds to sleep for all messages # (if not defined, default will is 1 second)_CUSTNUM_SIZE=6 # length of a customer number in this location # (if not defined, default is 6)_DEBUG_LEVEL=0 # log debug messages. log level is accumulative # (i.e. 1 = 1, 2 = 1 & 2, 3 = 1, 2, & 3) # (if not defined, default is 0) # Log levels: # 0 = No messages # 1 = brIEf messages (start script, errors, etc) # 2 = environment setup (set / env) # 3 = set -x (A LOT of spam)_TMPDIR="/tmp" # Directory to put work/tmp files # (if not defined, default is /tmp)_SP_LOG="${0##*/}.log" # log of script events_SP_REQUESTS="${HOME}/sp_requests"# file to customer record requests, # also read at startup_MENU_ITEMS=15# default number of items to display per page # (it not defined, default is 10)LESS="-P LINE: %l"# format 'less' prompt. MAN less if more info# export the variables defined aboveexport _MSG_SLEEP_TIME _CUSTNUM_SIZE _DEBUG_LEVEL _TMPDIR_SP_LOG _SP_REQUESTS _MENU_ITEMS#########################################################################
看起来好多了,不是吗?所有东西都组织有序,并且有详细的描述,初次阅读这个脚本的人更容易理解它的作用。
调试
编写完一个脚本之后,就要第一次运行它了。但是,如果在执行脚本时显示某些意外的错误,应该怎么办呢?没有人是完美的,而且从头编写脚本并保持没有错误需要大量时间和丰富的经验;大多数时候,开发人员很容易漏掉一个字母或者颠倒了两个字母的顺序,这几乎是不可避免的。不必担心:AIX、其他风格的 Unix 和 Linux 中的 shell 已经考虑到了这个问题,可以帮助您进行调试。
例如,清单 10 中的 shell 脚本(名为 make_errors)已经编写好等待执行。
清单 10. 包含错误的脚本示例
#!/bin/bash_X=1while [[ ${_X} -le 10 ]]do [[ ${_X} -lt 5 ]] && echo "X is less than 5! _Y=`expr ${_X) + 1` if [[ ${_Y} -eq 6 ]]echo "Y is now equal to ${_Y}" fi _X=${_Y}done
但是,初次执行这个脚本时,显示以下错误:
# ./make_errors./make_errors: line 11: unexpected EOF while looking for matching `"'./make_errors: line 16: syntax error: unexpected end of file
Vim 是一种出色的调试工具,您可能使用过它,但不一定了解它的真正价值。Vim 是一种强大的文本编辑器,但是它对调试也很有帮助。如果通过设置 .exrc 或 .vimrc 文件指定用不同的颜色显示某些错误,Vim 就会替您完成大部分调试工作,见图 1。
图 1. 用 Vim 进行调试
第一个错误消息(line 11: unexpected EOF while looking for matching `"')指出在第 11 行上有错误,但是看过这一行之后,并没有发现任何错误。再看看第 9 行。echo 后面的字符串的末尾缺少一个双引号(")。这个示例很好地说明了在进行调试时为什么必须查看整个脚本。错误消息中显示的行号不一定是出现错误的实际位置。报告第 11 行有错误是因为第 9 行用双引号标出一个字符串的开头,但是这个字符串直到第 11 行还没有结束。要想纠正这个错误,应该在第 9 行末尾添加双引号。
其他一些问题也会显示为错误。在第 11 行上,变量值 _X 后面是一个用红色突出显示的后圆括号())。这是 Vim 替您做出的判断,它指出这里有错误。这里用一个前花括号({)标出了变量值 _X 的开头,但是没有用后花括号(})结束。只需把 ) 改为 },就能够纠正这个错误。
到目前为止,已经纠正了两个错误。再次运行这个脚本,看看会发生什么:
./make_errors: line 12: syntax error near unexpected token `fi'./make_errors: line 12: ` fi'
还有另一个错误。错误消息指出问题出现在第 12 行上,但是这一行只有一个用来结束 if 语句的 fi。这有什么错呢?请牢记前一个错误的情况。并非所有错误都源自 shell 所报告的行上。shell 仅仅报告发生错误的位置,但是错误的根源可能出现在这个位置之前。对于这个小脚本,可以很有把握地猜测错误可能出现在实际的 if 语句中。回忆一下基本的脚本编程逻辑:if 语句由 if、then 和 fi 组成。看看这个条件语句,可以看出缺少了 then。只需在脚本中添加 then。完成之后,这个脚本应该类似于清单 11。
清单 11. 纠正清单 10 中的错误之后的脚本
#!/bin/bash_X=1while [[ ${_X} -le 10 ]]do [[ ${_X} -lt 5 ]] && echo "X is less than 5!" _Y=`expr ${_X} + 1` if [[ ${_Y} -eq 6 ]] thenecho "Y is now equal to ${_Y}" fi _X=${_Y}done
再次运行这个脚本:
# ./make_errorsX is less than 5!X is less than 5!X is less than 5!X is less than 5!Y is now equal to 6
恭喜!这个脚本现在正常工作了!
set -x 选项
有时候,对 shell 脚本执行基本的错误排除步骤并不像前一个示例那么容易。如果所有努力都失败了,并且想不出脚本的错误之处在哪里,那么最后一招就是动用 “杀手锏!Ksh、Bash 和其他现代 shell 都支持在 set 命令中使用 -x 开关。如果使用 set –x 选项,执行的每个命令都显示在 stdout 中。为了突出显示执行的代码,set –x 把 PS4 变量的值加在显示的每行代码前面。请记住,这种做法会产生大量文本,所以在查看输出时要有耐心。
减小前一个示例中的循环计数值,在脚本的开头添加 set -x 和一个注释,见清单 12。
清单 12. set -x 示例
#!/bin/bashset -x# loop through and display some test statements_X=1while [[ ${_X} -le 4 ]]do [[ ${_X} -lt 2 ]] && echo "X is less than 2! _Y=`expr ${_X} + 1` if [[ ${_Y} -eq 3 ]] thenecho "Y is now equal to ${_Y}" fi _X=${_Y}done
在执行这个脚本之前,把 PS4 变量改为某个看起来醒目的字符串:
# export PS4="DEBUG => "
接下来,执行这个脚本,就会看到可能非常有价值的信息,见清单 13。
清单 13. set -x 的输出
# ./make_errorsDEBUG => _X=1DEBUG => [[ 1 -le 4 ]]DEBUG => [[ 1 -lt 2 ]]DEBUG => echo 'X is less than 2!'X is less than 2!DDEBUG => expr 1 + 1DEBUG => _Y=2DEBUG => [[ 2 -eq 3 ]]DEBUG => _X=2DEBUG => [[ 2 -le 4 ]]DEBUG => [[ 2 -lt 2 ]]DDEBUG => expr 2 + 1DEBUG => _Y=3DEBUG => [[ 3 -eq 3 ]]DEBUG => echo 'Y is now equal to 3'Y is now equal to 3DEBUG => _X=3DEBUG => [[ 3 -le 4 ]]DEBUG => [[ 3 -lt 2 ]]DDEBUG => expr 3 + 1DEBUG => _Y=4DEBUG => [[ 4 -eq 3 ]]DEBUG => _X=4DEBUG => [[ 4 -le 4 ]]DEBUG => [[ 4 -lt 2 ]]DDEBUG => expr 4 + 1DEBUG => _Y=5DEBUG => [[ 5 -eq 3 ]]DEBUG => _X=5DEBUG => [[ 5 -le 4 ]]
可以看到这里有大量信息:处理并执行的每个命令都显示出来了。还要注意,在调试信息中没有显示 shell 脚本中的注释。这是因为 shell 在读取注释之后并不执行它。还好,在完成前面的修改之后,这个脚本没有错误了!
在使用 set -x 时还要记住一点:如果脚本有内部函数,而且 set -x 放在代码的主体部分,那么它的输出会包含子函数的运算过程。但是,如果 set -x 只放在内部函数中,那么 debug 选项的影响范围只包含这个内部函数中的代码和在其中调用的子函数;shell 脚本的主体并不包含在内,这是因为它不知道它的内部函数会调用这个例程。
结束语
无论是使用 shell 脚本、C、Java™ 语言或其他语言,我们都在不断地改进编程方法。坚持简单化的基本规则,保持代码简洁灵活,给代码加上适当的注释,再借助调试工具的帮助,您很快就能编写出出色的 shell 脚本。祝您好运!