摘自 shell脚本实战 第二版 第一章 遗失的代码库
脚本6 验证浮点数输入
鉴于 shell 脚本的限制和本事,浮点数(或“实数”)的验证过程乍一看似乎让人望而生畏, 不过考虑到浮点数只不过是由小数点分隔的两个整数,再配合能够在脚本中引用其他脚本的能力 (validint),你就会发现浮点数验证的代码长度出奇地短。代码清单 1-12 中的脚本假设和脚本 validint 位于同一目录下
代码 validfloat
#!/bin/bash
# validfloat -- 测试数字是否为有效的浮点数
# 注意,该脚本不接受科学计数法(1.304e5)
# 要测试输入的值是否为有效的浮点数,需要将值分为两部分:整数部分
# 和小数部分。先测试第一部分是否为有效整数,然后测试第二部分是否
# 为大于或等于0的有效整数。因此-30.5是有效的,-30.-8是无效的
# 使用"."记法可以将另一个脚本包含到此脚本中。非常简单
. ./validint
validfloat(){
fvalue="$1"
# 检查输入的数字是否有小数点
if [ ! -z $(echo $fvalue |sed 's/[^.]//g') ];then
# 提取小数点之前的部分
decimalPart="$(echo $fvalue |cut -d. -f1)"
# 提取小数点之后的部分
fractionalPart="${fvalue#*\.}"\
# 先测试小数点左侧的整数部分
if [ ! -z $decimalPart ];then
# 由于 "!" 会颠倒测试逻辑,因此下面表示"如果不是有效的整数".
if ! validint "$decimalPart" "" "" ;then
return 1
fi
fi
# 现在测试小数部分
# 小数点之后不能有负号(例如33.-11就不正确),因此先来测试负号。
if [ "${fractionalPart%${fractionalPart#?}}" = "-" ];then
echo "Invalid floating-point number : "-" not allowed after decimal point." >&2
return 1
fi
if [ "$fractionalPart" != "" ];then
# 如果小数部分不是有效的整数
if ! validint "$fractionalPart" "0" "" ;then
return 1
fi
fi
else
# 如果整个值只是一个"-" ,那也不行
if [ "$fvalue" = "-" ];then
echo "Invalid floating-point format." >&2
return 1
fi
# 最后检查剩下的是否为有效的整数
if ! validint "$fvalue" "" "" ;then
return 1
fi
fi
return 0
}
if validfloat $1;then
echo "$1 is a valid floating-point value"
fi
exit 0
运行结果
$ ./validfloat 1234.56
1234.56 is a valid floating-point value
$ ./validfloat -1234.56
-1234.56 is a valid floating-point value
$ ./validfloat -.75
-.75 is a valid floating-point value
$ ./validfloat -1.-75
Invalid floating-point number : - not allowed after decimal point.
$ ./validfloat 1.03e4
Invalid number format! Only digits, no commas, spaces, etc.
精益求精
一个比较酷的改进是让这个函数能够处理最后一个例子中出现的科学记数法。这算不上多 难,你可以先测试是否存在’e’或’E’,然后将值分成 3 部分:整数部分(只有一个数字)、小数 部分以及 10 的幂。剩下的事情就是确保每部分都是有效的整数
如果你不想要求小数点之前的前导 0,也可以修改代码清单 1-12 中第处的条件测试。不过 要小心一些奇异的格式
脚本7 验证日期格式
最富有挑战性的验证任务之一是确保特定的日期在日历上真实存在,这对于涉及日期处理的 shell 脚本同样至关重要。如果忽略闰年,此工作并不麻烦,因为每一年的日历都是一样的。在这 种情况下,我们需要的是一张包含每个月有多少天的表,然后根据特定的日期比对就行了。要是 考虑到闰年的话,就得再加入一些其他的处理逻辑了,脚本也会因此变得更复杂
检验是否为闰年的一组规则如下
- 不能被 4 整除的年份不是闰年;
- 能够被 4 和 400 整除的年份是闰年;
- 能够被 4 整除,不能被 400 整除,但是可以被 100 整除的年份不是闰年;
- 其他能够被 4 整除的年份是闰年。
代码 valid-date
#!/bin/bash
# valid-date -- 验证日期 (考虑闰年规则)
#PATH=.:$PATH
exceedsDaysInMonth(){
# 给定月份和天数,如果指定的天数小于或等于该月份的最大天数
# 函数返回0;否则,返回1
case $(echo $1 |tr '[:upper:]' '[:lower:]') in
jan* ) days=31 ;; feb* ) days=28 ;;
mar* ) days=31 ;; apr* ) days=30 ;;
may* ) days=31 ;; jun* ) days=30 ;;
jul* ) days=30 ;; aug* ) days=31 ;;
sep* ) days=30 ;; oct* ) days=31 ;;
nov* ) days=30 ;; dec* ) days=31 ;;
* ) echo "$0: Unknown month name $1" >&2
exit 1
esac
if [ $2 -lt 1 -o $2 -gt $days ];then
return 1
else
return 0 # 天数有效
fi
}
isLeapYear(){
# 如果指定的年份是闰年,该函数返回 0;否则,返回 1。 # 验证闰年的规则如下。
# (1) 不能被 4 整除的年份不是闰年。
# (2) 能够被 4 和 400 整除的年份是闰年。
# (3) 能够被 4 整除,不能被 400 整除,但是可以被 100 整除的年份不是闰年。
# (4) 其他能够被 4 整除的年份是闰年。
year=$1
if [ "$((year % 4 ))" -ne 0 ];then
return 1 # 不是闰年
elif [ "$((year % 400))" -eq 0 ];then
return 0 # 是闰年
elif [ "$((year % 100 ))" -eq 0 ];then
return 1
else
return 0
fi
}
# 主脚本开始
# ==================
if [ $# -ne 3 ];then
echo "Usage: $0 month day year" >&2
echo "Typical input format are Auguest 3 1962 and 8 3 1962" >&2
exit 1
fi
# 规范日期,保存返回值以供错误检查
newdate=`./normdate "$@"`
if [ $? -eq 1 ];then
exit 1 # 错误情况已经由normdate报告过了
fi
# 拆分规范后的日期,其中第一个字段是月份
# 第二个字段是天数,第三个字段是年份
month="$(echo $newdate |cut -d\ -f1)"
day="$(echo $newdate |cut -d\ -f2)"
year="$(echo $newdate |cut -d\ -f3)"
# 现在来检查天数是否合法(例如,不能是1月36号)
if ! exceedsDaysInMonth $month "$2" ;then
if [ "$month" = "Feb" -a "$2" -eq "29" ];then
if ! isLeapYear $3 ;then
echo "$0:$3 is not a leap year,so Feb doesn't have 29 days." >&2
exit 1
fi
else
echo "$0: bad day value: $month doesn't have $2 days." >&2
exit 1
fi
fi
echo "Valid date: $newdate"
exit 0
运行结果
$ ./valid-date august 3 1960
Valid date: Aug 3 1960
$ ./valid-date 9 31 2001
./valid-date: bad day value: Sep doesn't have 31 days.
$ ./valid-date feb 29 2014
./valid-date:2014 is not a leap year,so Feb doesn't have 29 days.
脚本8 避用差劲的echo实现
“什么是 POSIX”一节中曾提到过,尽管大多数现代 Unix 和 GNU/Linux 实现所包含的 echo 命令都知道选项-n 应该禁止在输出内容的末尾出现换行符,但并非所有的实现都是如此。有些 使用\c 作为一个特殊的嵌入式字符来克服默认行为,有些则无论如何都坚持加入尾部换行符。
要想知道你所用的 echo 实现是否完善,方法很简单,输入下列命令,查看结果
$ echo -n "The rain in Spain";echo " falls mainly on the Plain."
# 如果echo命令支持-n选项,那么你会看到如下输出:
The rain in Spain falls mainly on the Plain.
# 如果不支持,则会看到如果输出:
-n The rain in Spain
falls mainly on the Plain
确保脚本输出按照要求呈现在用户面前非常重要,而且随着脚本的交互性越强,这一点会变 得愈发重要。可以编写一个名为 echon 的新版本 echo 来实现这个目标,它不管怎样都不会输出 尾部换行符。这样一来,每当我们需要 echo -n 功能的时候,总能有一个可靠的替代品可用
代码
解决 echo 这个古怪问题的方法有很多。我们偏爱的方法中有一种非常简洁:它只是通过 awk 的 printf 命令来过滤输入,如代码清单 1-16 所示
# 代码清单1-16 通过 awk 的 printf 命令实现的另一个简单的 echo
echon(){
echo "$*" |awk '{printf "%s","$0"}'
}
不过,你可能不愿意承受调用 awk 命令所带来的开销。如果你有一个用户级的 printf 命令, 不妨用这个命令来过滤输入,如代码清单 1-17 所示
# 代码清单1-17 使用简单的 printf 命令实现的 echo
echon(){
printf "%s" "$*"
}
如果没有 printf,也不想调用 awk,那该怎么办?我们也可以像代码清单 1-18 那样用 tr 命 令删除末尾的换行符。
# 代码清单1-18 使用 tr 实用工具实现的另一个简单的 echo
echon(){
echo "$*" | tr -d '\n'
}
运行结果
$ echon "Enter coordinates for satellite acquisition: "
Enter coordinates for satellite acquisition: 12,34
精益求精
我们不会说谎。有些 shell 的 echo 命令了解-n 选项,有些将\c 作为结束序列,还有些干脆就 无法阻止添加换行符,这种现象对于脚本编写人员可是个大麻烦。要想解决这种差异问题,可以 创建一个函数,自动测试 echo 的输出,以确定碰上的是哪种情况,然后修改 echo 的调用方式。 例如,可以写成 echo -n hi | wc -c,然后测试结果是否为 2 个字符(hi)、3 个字符(hi 加上换 行符)、4 个字符(-n hi)或者 5 个字符(-n hi 加上换行符)。
脚本9 任意精度的浮点数计算器
脚本编写中最常用到的语句之一是$(()),你可以用它执行各种数学运算。该语句颇为有用, 一些常见操作(例如增加计数变量)都得靠它。所支持的运算包括加法、减法、乘法、除法、求余(或求模),但不支持分数或十进制值。所以下面的命令会返回 0,而不是 0.5:
echo $(( 1 / 2 ))
因此,当计算需要更高精度的值时,这就比较麻烦了。除了 bc 之外,并没有太多优秀的命 令行计算器程序,但鲜有人会用这个古怪的程序。作为一款任意精度的计算器,bc 的历史可以 追溯到 Unix 的黎明时期,其错误信息晦涩难懂,没有任何提示,而且还有一套“既然你用了, 那就知道自己在干什么”的假设。不过没关系,我们可以编写一个包装器(wrapper),让 bc 用 起来更趁手,如代码清单 1-20 所示
代码 scriptbc
#!/bin/bash
# script -- bc 的包装器,可返回计算结果
if [ "$1" = "-p" ];then
precision=$2
shift 2 # 脚本后面的参数 向左移两个 以删除给定参数中的 -p xx 信息 只保留需要计算的表达式
else
precision=2 # 默认精度
fi
bc -q << EOF
scale=$precision
$*
quit
EOF
exit 0
运行结果
$ ./scriptbc 14600/7
2085.71
$ ./scriptbc -p 10 14600/7
2085.7142857142
脚本10 文件锁定
但凡涉及读取或追加到共享文件(例如日志文件)的脚本,都需要一种可靠的方法来锁定文件, 这样在完成自己的工作前才不会出现数据被其他脚本覆盖的情况。一种常见的方法是为每个要用到 的文件创建单独的锁文件。锁文件相当于一个信号量(semaphore),表明该文件已被占用,暂时无 法使用。然后发出请求的脚本不断地等待、重试,直到锁文件被删除(表明该文件可以编辑了)。
锁文件的用法颇具技巧性,很多看似万无一失的解决方案其实都有问题。例如,下列代码是一种典型的方法
while [ -f $lockfile ]; do
sleep 1
done
touch $lockfile
看起来应该没毛病呀,难道不是?如果锁文件存在,代码会不断循环,循环结束后,创建自 己的锁文件,以便能够安全地修改文件。其他采用相同代码的脚本要是发现了你上的锁,也会不 断循环,直到文件解锁。但这种方法其实并不靠谱。想象一下,如果在 while 循环结束之后,touch 命令执行之前,这个脚本被交换出去,放回到了处理器队列,其他脚本便有机会运行了。
如果你还不明白我们所说的是什么意思,那就记住,尽管计算机看起来一次只能做一件事, 但它实际上可以同时运行多个程序,实现方法是运行一个程序一小会儿,切换到另一个程序再运 行一小会儿,然后又切换回来。问题是如果在脚本检查完锁文件和创建锁文件这两个时间点之间, 系统切换到了另一个脚本,这个脚本同样会尽职地检查锁文件,当它发现并没有上锁的时候,也 创建锁文件。然后该脚本被换出,换入之前的脚本并接着执行 touch 命令。结果是两个脚本现在 都认为只有自己才能访问共享文件,而这恰恰是我们一直力图避免的。
好在电子邮件过滤程序 procmail 的作者 Stephen van den Berg 和 Philip Guenther 创建了命令行 工具 lockfile,可以让你在 shell 脚本中安全可靠地使用锁文件。
包括 GNU/Linux 和 OS X 在内的很多 Unix 发行版都已经预装了 lockfile。你只需输入 man 1 lockfile 就能确认系统中是否有 lockfile。如果看到了相应的手册页,那就恭喜你了!代码清单 1-22 中的脚本假设你已经有了 lockfile。后续脚本依赖于脚本#10 中可靠的文件锁定机制才能正 常工作,所以一定要保证系统中安装了 lockfile。
代码 filelock
#!/bin/bash
# filelock -- 一种灵活的文件锁定机制
retries="10" # 默认的重试次数
action="lock" # 默认操作
nullcmd="'which true'" # 用于锁文件的空命令
while getopts "lur:" opt;do
case $opt in
l ) action="lock" ;;
u ) action="unlock" ;;
r ) retries="$OPTARG" ;;
esac
done
shift $(($OPTIND - 1)) # 存储原始$*中下一个要处理的元素位置 此处的用法为 shfit 移除处理过的参数
if [ $# -eq 0 ];then # 向stdout输出一条包含多行信息的错误信息。
cat << EOF >&2
Usage: $0 [-l|-u] [-r retries] LOCKFILE
Where -l requests a lock (the default), -u requests an unlock, -r X
specifies a max number of retries before it fails (default = $retries).
EOF
exit 1
fi
# 确定是否有lockfile命令。
if [ -z "$(which lockfile | grep -v '^no ')" ];then
echo "$0 failed: 'lockfile' utility not found in PATH." >&2
exit 1
fi
if [ "$action" = "lock" ];then
if ! lockfile -1 -r $retries "$1" 2 > /dev/null;then
echo "$0: Failed : Couldn't create lockfile in time." >&2
exit 1
fi
else # unlock 操作
if [ ! -f "$1" ];then
echo "$0: Warning: lockfile $1 doesn't exist to unlock." >&2
exit 1
fi
rm -f "$1"
fi
exit 0
运行代码
$ ./filelock aaa.lck
$ ls -l aaa.lck
-r--r--r-- 1 amlogic amlogic 1 12月 9 15:32 aaa.lck
$ ./filelock aaa.lck
lockfile: Sorry, giving up on "aaa.lck"
./filelock: Failed : Couldn't create lockfile in time.
$ ./filelock -u aaa.lck
精益求精
因为脚本将锁文件的存在作为是否上锁的证据,所以可以再添加一个参数,指定一个锁的最 长有效时间。如果 lockfile 超时,则检查锁文件的最后访问时间,如果时间早于该参数值,那 就可以放心地删除锁文件,同时还可能发出警告信息。
lockfile 无法用于通过网络文件系统(NFS)挂载的网络驱动器,不过这不太可能会影响到你。实际上,在 NFS 挂载的磁盘上实现可靠的文件上锁机制非常复杂。要想完全避开这种问题, 一种比较好的策略是只在本地磁盘上创建锁文件,或是使用能够对文件锁实现跨系统管理的网络 感知脚本(network-aware script)
脚本 11 ANSI颜色序列
你可能并没有意识到大多数 Terminal 应用都支持不同风格的描绘文本。无论是你想让脚本中 的某些单词以粗体显示,还是用红色文字黄色背景,能实现的效果都不计其数。但是,依靠美国 国家标准协会(ANSI)序列描述各种效果变化可不是件容易事,因为其写法对用户颇不友善。 为了便于使用,代码清单 1-26 创建了一组可以描述 ANSI 代码的变量,可用于启用和关闭颜色及 格式化选项。
代码 initializeANSI
#!/bin/bash
# ANSI color -- 使用这些变量输出不同的颜色和格式
# 以f结尾的颜色名称表示前景色,以b结尾的表示背景色
initializeANSI(){
esc="\033" # 如果无效,直接敲ESC键。
# 前景色:
blackf="${esc}[30m"; redf="${esc}[31m"; greenf="${esc}[32m"
yellowf="${esc}[33m"; bluef="${esc}[34m"; purplef="${esc}[35m"
cyanf="${esc}[36m"; whitef="${esc}[37m";
# 背景色:
blackb="${esc}[40m"; redb="${esc}[41m"; greenb="${esc}[42m"
yellowb="${esc}[43m"; blueb="${esc}[44m"; purpleb="${esc}[45m"
cyanb="${esc}[46m"; whiteb="${esc}[47m";
# 粗体、斜体、下划线以及样式切换:
boldon="${esc}[1m"; boldoff="${esc}[22m"
italicson="${esc}[3m"; italicsoff="${esc}[23m"
ulon="${esc}[4m"; uloff="${esc}[24m"
invon="${esc}[7m"; invoff="${esc}[27m"
reset="${esc}[0m"
}
initializeANSI
cat << EOF
${yellowf}This is a phrase in yellow${redb} and red${reset}
${boldon}This is bold${ulon} this is italics${reset} bye-bye
${italicson}This is italics${italicsoff} and this is not
${ulon}This is ul${uloff} and this is not
${invon}This is inv${invoff} and this is not
${yellowf}${redb}Warning I ${yellowb}${redf}Warning II${reset}
EOF
运行结果
This is a phrase in yellow and red
This is bold this is italics bye-bye
This is italics and this is not
This is ul and this is not This is inv and this is not
Warning I Warning II
精益求精
在使用这个脚本的时候,你也许会看到类似于下面这样的输出:
\033[33m\033[41mWarning!\033[43m\033[31mWarning!\033[0m
问题的原因可能在于你所使用的终端或终端窗口不支持 ANSI 颜色序列,或是不理解至关重 要的 esc 变量中的\033 这种写法。对于后者,解决方法是在 vi 或你喜欢的其他终端编辑器中打 开脚本,删除\033 序列,然后先按下 ESC 键,再按下组合键 ^V (CTRL-V),这时候会显示出^[。 如果屏幕上看起来是 esc="^[",那应该就没问题了。
另一方面,如果你的终端或终端窗口压根就不支持 ANSI 颜色序列,那可能需要升级才能使 脚本输出带有颜色和增强字体的文本。不过在放弃现有终端之前,先检查一下其首选项,有些终 端需要启用相应的设置才能完全支持 ANSI
脚本12 构建shell脚本库
本章中的很多脚本并没有写成独立脚本,而是采用了函数的形式,这样既不会引发系统调用 开销,还可以方便地将其纳入其他脚本。尽管 shell 脚本没有 C 语言那样的#include 特性,但它 有一个极其重要的“读入”(sourcing)功能,可以实现相同的效果,你可以利用该功能将其他脚 本像库函数那样包含进来。
要想知道为什么这个功能如此重要,让我们来考虑另一种做法。如果你在 shell 中调用了一 个 shell 脚本,那么该脚本默认会运行在属于自己的子 shell 中。从下面的实验中可以看出:
代码 library-test
要想将本章中出现过的函数汇集成一个能在其他脚本中使用的函数库,需要提取所有的函数 以及涉及的全局变量或数组(也就是在多个函数之间共用的那些值),将其合并到一个大文件中。 如果你把这个文件命名为 library.sh,那么可以用下面的测试脚本访问本章我们已写的所有函数, 看看是否能够正常使用,如代码清单 1-28 所示
#!/bin/bash
# 函数库测试脚本。
# 先读入文件 library.sh。
. library.sh
initializeANSI # 设置所有的 ANSI 转义序列。
# 测试 validint 功能。
echon "First off, do you have echo in your path? (1=yes, 2=no) "
read answer
while ! validint $answer 1 2 ; do
echon "${boldon}Try again${boldoff}. Do you have echo "
echon "in your path? (1=yes, 2=no) "
read answer
done
# 测试 checkForCmdInPath 功能。
if ! checkForCmdInPath "echo" ; then
echo "Nope, can't find the echo command."
else
echo "The echo command is in the PATH."
fi
echo ""
echon "Enter a year you think might be a leap year: "
read year
# 使用带有区间的 validint 测试指定年份是否在 1 到 9999 之间。
while ! validint $year 1 9999 ; do
echon "Please enter a year in the ${boldon}correct${boldoff} format: "
read year
done
# 测试是否为闰年。
if isLeapYear $year ; then
echo "${greenf}You're right! $year is a leap year.${reset}"
else
echo "${redf}Nope, that's not a leap year.${reset}"
fi
exit 0
运行结果
$ library-test
First off, do you have echo in your PATH? (1=yes, 2=no) 1
The echo command is in the PATH.
Enter a year you think might be a leap year: 432423
Your value is too big: largest acceptable value is 9999.
Please enter a year in the correct format: 432
You're right! 432 is a leap year.
在屏幕上,由于值过大而产生的错误信息以粗体显示,正确猜测出闰年的提示信息以绿色显示。
从历史上看,公元 432 年并不是闰年,因为直到 1752 年闰年才开始出现在日历中。不过我 们现在谈论的是 shell 脚本,又不是要摆弄日历,这点问题就算了吧