摘自 shell脚本实战 第二版 第三章 创建实用工具
像 Stickies 这样简单的实用工具多年来广受 Windows 和 Mac 用户的欢迎,你可以用它在屏幕 上保留一些小笔记并发出提醒。这种应用非常适合于记录电话号码或其他提醒事项。可惜在 Unix 命令行中并没有与此对应的命令可用,不过这个问题用两个脚本就能解决。
第一个脚本 remember(如代码清单 3-1 所示)可以让你轻松地将信息片段保存在用户主目录 下的文件 rememberfile 中。如果调用时不使用任何参数,那么该脚本会从标准输入中读取,直到 用户按下 CTRL-D,生成文件结束序列(^D)。如果加入参数,则将这些参数直接保存到数据文 件中。
另一个配套脚本 remindme(如代码清单 3-2 所示),可以显示整个 rememberfile 的内容(如 果没有指定参数)或者搜索结果(将参数作为搜索模式)。
#!/bin/bash # remember -- 一个易用的命令行提醒工具 rememberfile="$HOME/.remember" if [ $# -eq 0 ];then # 提醒用户输入并将输入信息追加到文件.remember中。 echo "Enter note, end with ^D: " cat - >> $rememberfile # 1 cat 命令从stdin 中读取输入 (命令中的-是stdin或stdout的简写,具体表示那个,取决于上下文环境) else # 将传入脚本的参数追到到文件.remember中。 echo "$@" >> $rememberfile # 2 如果指定了脚本参数,那么所有参数都会被追加到rememberfile中 fi exit 0
#!/bin/bash # remindme -- 查找数据文件中匹配的行,如果没有指定参数,则显示数据的全部内容 rememberfile="$HOME/.remember" if [ !-f $remrmberfile ];then echo "$0: You don't seem to have a .remember file." >&2 echo "To remedy this,please use 'remember' to add reminders" ?&2 exit 1 fi if [ $# -eq 0 ];then # 如果没有指定人格搜索条件,则显示整个数据文件 more $rememberfile # 3 使用more命令为用户分页显示文件内容 else # 否则,搜索指定内容并整齐的显示结果 grep -i -- "$@" $rememberfile | ${PAGER:-more} # 4 使用区分大小写的grep命令 搜索关键字,然后分页形式显示搜索结果 fi exit 0
$ remember Southwest Airlines: 800-IFLYSWA $ remember Enter note, end with ^D: Find Dave's film reviews at http://www.DaveOnFilm.com/ ^D # 如果几个月后,你想查看某条便笺 $ remindme film reviews Find Dave's film reviews at http://www.DaveOnFilm.com/ # 如果有个 800 的号码,你实在是记不起来了 $ remindme 800 Southwest Airlines: 800-IFLYSWA
尽管这肯定算不上 shell 编程的代表作品,但这些脚本很好地展现了 Unix 命令行的可扩展 性。如果你有新的想法,那么实现方法可能会非常简单。
改进这些脚本的方法有很多。例如,你可以引入记录的概念:把每个 remember 条目都加上 时间戳,多行输入可以保存成一条可供正则表达式搜索的记录。可以通过这种方法保存一组人的 电话号码,只需要记住其中某个人的名字就可以检索出整个组。如果你不仅仅满足于此,还可以 加入编辑和删除功能。另外,手动编辑~/.remember 文件也非常简单。
要是你还没忘记的话,scriptbc(脚本#9)允许我们以命令行参数的形式调用 bc 执行浮点 运算。接下来自然就是编写一个包装器,把这个脚本完全变成基于命令行的交互式计算器。最终 的包装器脚本(如代码清单 3-6 所示)非常短小!一定要确保 scriptbc 位于 PATH 之中,否则脚 本无法运行。
#!/bin/bash # calc -- 一个命令行计算器,可用作bc的前段 scale=2 show_help(){ cat << EOF In addition to standard math functions, calc also supports: a % b remainder of a/b a ^ b exponential: a raised to the b power s(x) sine of x, x in radians c(x) cosine of x, x in radians a(x) arctangent of x, in radians l(x) natural log of x e(x) exponential log of raising e to the x j(n,x) Bessel function of integer order n of x scale N show N fractional digits (default = 2) EOF } if [ $# -gt 0 ];then exec ./scriptbc "$@" fi echo "Calc -- a simple calculator. Enter 'help' for help, 'quit' to quit." /bin/echo -n "Calc> " while read command args;do # 1 创建一个无穷循环,不断地显示提示符calc>,直到用户输入quit或按下CTRL-D(^D)退出为止 case $command in quit|exit ) exit 0 ;; help|\? ) show_help ;; scale ) scale=$args ;; * ) ./scriptbc -p $scale "$command" "$args" ;; esac /bin/echo -n "Calc> " done echo "" exit 0
$ ./calc 150/3.5 42.85 $ ./calc Calc -- a simple calculator. Enter 'help' for help, 'quit' to quit. Calc> 3/4 .75 Calc> 3^5 243 Calc> quit
你在命令行中用 bc 可以做到的事情,在脚本中一样能行,但要注意,calc.sh 没有跨行记忆 (line-to-line memory)或状态保留功能。这意味着如果你喜欢的话,可以在帮助系统中添加更多 的数学函数。例如,变量 obase 和 ibase 允许用户指定输入和输出的数字基数,但由于缺少跨行 记忆,你只能修改 scriptbc(脚本#9),或是学着在一行中输入所有的设置和等式。
#!/bin/bash # convertatemp -- 温度转换脚本。用户可以输入采用特定单位(华氏单位,摄氏单位或开尔文单位) # 的温度,脚本会输出其对应于其他两种单位的温度 if [ $# -eq 0 ];then cat << EOF >&2 Usage: $0 temperature[F|C|K] where the suffix: F indicates input is in Fahrenheit (default) C indicates input is in Calsius K indicates input is in Kelvin EOF exit 1 fi unit="$(echo $1 |sed -e 's/[-[:digit:]]*//g' |tr '[:lower:]' '[:upper:]' )" # 1 匹配零个或多个"-" 以及紧接着的任意一组数组 并替换为空 temp="$(echo $1 |sed -e 's/[^-[:digit:]]*//g' )" # 2 删除所有非"-"以及数组字符 case ${unit:=F} in F ) # 华氏温度转换为摄氏温度的公式: Tc = (F -32) / 1.8 farn="$temp" cels="$(echo "scale=2;($farn -32)/1.8" |bc)" # 3 将该公式转换成一个可以传给bc的序列 kelv="$(echo "scale=2;$cels + 273.15" |bc)" ;; C ) # 摄氏温度转化为华氏温度公式: Tf = (9 /5 )*Tc+32 cels=$temp kelv="$(echo "scale=2;$cels + 273.15" |bc)" farn="$(echo "scale=2;(1.8 * $cels) + 32" | bc)" # 4 将摄氏转换为华氏的公式 ;; K ) # 摄氏温度 = 开尔文温度 - 273.15,然后使用摄氏温度转换为华氏温度的公式 # 5 将设置转换为开尔文 kelv=$temp cels="$(echo "scale=2;$kelv - 273.15" |bc)" farn="$(echo "scale=2;(1.8 * $cels) + 32" |bc)" ;; * ) echo "Given temperature unit is not supported" exit 1 esac echo "Fahrehit = $farn" echo "Celsius = $cels" echo "Kelvin = $kelv" exit 0
$ ./convertatemp 212 Fahrehit = 212 Celsius = 100.00 Kelvin = 373.15 $ ./convertatemp 100C Fahrehit = 212.0 Celsius = 100 Kelvin = 373.15 $ ./convertatemp 100K Fahrehit = -279.67 Celsius = -173.15 Kelvin = 100
你可以加入几个输入选项,一次只生成一种单位转换结果的简洁输出。例如,convertatemp -c 100F 就只输出华氏 100°所对应的摄氏温度。这种方法也可以帮助你在别的脚本中转换数值。
另一种用户要经常接触的计算大概就是贷款偿还金额了。代码清单 3-10 中的脚本也能帮你 回答“我能用这笔奖金做什么?”以及“我到底能买得起那台新款特斯拉吗?”这类相关问题。
虽然根据贷款金额、利率和贷款期限计算偿还金额的公式有点棘手,但恰当地利用 shell 变 量是可以驯服这匹数学猛兽的,而且能使其变得出奇地易懂。
#!/bin/bash # loancalc -- 根据贷款金额、利率和贷款期限(年),计算每笔付金 # 公式为: M = P * ( J / ( 1 - (1 + J)) ^ - N )) # 其中,P = 贷款金额,J = 月利率, N = 贷款期限 (以月为单位) # 用户一般要输入P、I(年利率) 以及L(年数) # . ../1/library.sh # 引入脚本 1 if [ $# -ne 3 ];then echo "Usage: $0 principal interst loan-duration-years" >&2 exit 1 fi P=$1 I=$2 L=$3 # 2 把公式拆成多个间接部分 J="$(./scriptbc -p 8 $I / \(12 \* 100\))" N="$(( $L * 12 ))" M="$(./scriptbc -p 8 $P \* \($J / \(1-\(1+$J\)\^ -$N\)\))" # 对金额略做美化处理: dollars="$(echo $M | cut -d. -f1)" # 3 第二行代码获取到月支付金额小数点之后的部分,然后只保留两位数字 cents="$(echo $M |cut -d. -f2|cut -c1-2)" cat << EOF A $L-year loan at $I% interest with a principal amount of $(./nicenumber $P 1 ) results in a payment of \$$dollars.$cents each month for the duration of the loan ($N payments). EOF exit 0
$ ./loancalc 44900 4.75 4 A 4-year loan at 4.75% interest with a principal amount of 44,900 results in a payment of $1028.93 each month for the duration of the loan (48 payments). $ ./loancalc 44900 4.75 5 A 5-year loan at 4.75% interest with a principal amount of 44,900 results in a payment of $842.18 each month for the duration of the loan (60 payments).
如果用户没有提供任何参数,那么脚本也可以采用逐项提示的方式处理。更实用的版本是让 用户指定 4 个参数(贷款金额、利率、支付次数和月支付金额)中的任意 3 个,然后自动得出第 四个值。这样的话,如果你知道自己只能承受每月 500 美元的支出,利率 6%的汽车贷款最长期 限是 5 年,那么就能确定可以贷到的最大金额。你可以实现相应的选项,让用户传入他们需要的 值来完成这种计算。
这个简单的日历程序实际上是由两个脚本配合实现的,类似于脚本#22 中的提醒工具。第一 个脚本 addagenda(如代码清单 3-12 所示)允许用户设立一个定期事件(对于周事件,指定星期 几;对于年事件,指定月份和天数)或一次性事件(指定日、月和年)。所有被验证过的日期会 连同一行事件描述信息被保存在用户主目录的.agenda 文件内。第二个脚本 agenda(如代码清单 3-13 所示)会检查所有已知的事件,显示出目前安排的是哪个事件。
这种工具对记住生日和纪念日特别有用。如果你记不住事情,那么这个方便的脚本可以帮你 减少很多痛苦。
#!/bin/bash # addagenda -- 提示用户添加新事件 agendafile="$HOME/.agenda" isDayName(){ # 如果日期没有问题,返回0;否则,返回1 case $(echo $1 |tr '[[:upper:]]' '[[:lower:]]') in sun*|mon*|tue*|wed*|thu*|fri*|sat* ) retval=0 ;; * ) retval=1 ;; esac return $retval } isMonthName(){ case $(echo $1 |tr '[[:upper:]]' '[[:lower:]]') in jan*|feb*|apr*|may*|jun*) return 0 ;; jul*|aug*|sep*|oct*|dec*) return 0 ;; * ) return 1 ;; esac } normalize(){ # 1 规范 压缩字符 # 返回首字母大写,接下来两个字母小写的字符串 /bin/echo -n $1 |cut -c1|tr '[[:lower:]]' '[[:upper:]]' echo $1 |cut -c2-3|tr '[[:upper:]]' '[[:lower:]]' } if [ ! -w $HOME ];then # -w 文件是否存在且可写 echo "$0: cannot write in your home directory ($HOME)" >&2 exit 1 fi echo "Agenda: The Unix Reminder Service" read -p "Date of event (day mon,day month year,or dayname):" word1 word2 word3 junk if isDayName $word1;then if [ ! -z "$word2" ];then echo "Bad dayname format:just specify the dayname by itself." >&2 exit 1 fi date="$(normalize $word1)" else if [ ! -z "$(echo $word1 |sed 's/[[:digit:]]//g')" ];then echo "Bad ate format: please specify day first, by day number" >&2 exit 1 fi if [ "$word1" -lt 1 -o "$word1" -gt 31 ];then echo "Bad date formate: day number can only be in range 1-31" >&2 exit 1 fi word2="$(normalize $word2)" if [ -z "$word3" ];then date="$word1$word2" else if [ ! -z "$(echo $word3 |sed 's/[[:digit:]]//g')" ];then echo "Bad date formate: third field should be year." >&2 exit 1 elif [ $word3 -lt 2000 -o $word3 -gt 2500 ];then echo "Bad date format: year value should be 2000 - 2500 " >&2 exit 1 fi date="$word1$word2$word3" fi fi read -p "One-line description: " description # 准备写入数据文件 echo "$(echo $date |sed 's/ //g')|$description " >> $agendafile # 2 将规范后的记录写入 隐藏文件 exit 0
#!/bin/bash # agenda -- 扫描用户的.agenda 文件,查找是否有安排砸当天或第二天的事情 agendafile="$HOME/.agenda" checkDate(){ # 创建匹配当天的默认值 weekday=$1 day=$2 month=$3 year=$4 format1="$weekday" format2="$day$month" format3="$day$month$year" # 3 为了检查事件,将当亲日期装换成三种可能的日期字符串格式 echo $format1 $format2 $format3 # 在数据文件中对比日期..... IFS="|" # 读入的内容自然在IFS 分割处 将 31Oct 与Hello World分割开 分别赋值给date description echo "On the agenda for today:" while read date description;do echo $date $description if [ "$date" = "$format1" -o "$date" = "$format2" -o "$date" = "$format3" ];then echo " $description" fi done < $agendafile } if [ ! -e $agendafile ];then echo "$0: You don't seem to have an .agenda file. " >&2 echo "To remdy this ,please use 'addagenda' to add events" >&2 exit i fi # 获得当天日期... eval $(date '+weekday="%a" month="%b" day="%e" year="%G" ') # 4 将所需的4个日期值分配给对应的变量 day="$(echo $day|sed 's/ //g')" # 删除可能存在的前挡空格 # 5 checkDate $weekday $day $month $year exit 0
$ ./addagenda Agenda: The Unix Reminder Service Date of event (day mon,day month year,or dayname):31 Oct One-line description: Hello World $ cat ~/.agenda 31Oct|Hello World # 此处博主环境为国内时间 未切换成英语 故打印不在预期 $ ./agenda 二 1412月 1412月2021 On the agenda for today: 31Oct Hello World
像事件跟踪这种既复杂又有趣的话题,这个脚本只能说是仅仅触碰到了表面而已。如果它能 够查看之前几天的事件安排就更好了,这需要在脚本 agenda 中做一些日期匹配操作。如果你使 用的是 GNU date 命令,那么匹配日期不是什么难事。但如果不是的话,单是在 shell 中执行日期 计算就需要复杂的脚本才能实现。关于日期操作,随后会在书中详述,尤其见脚本#99、脚本#100 和脚本#101。
另一处(更简单的)改进是让 agenda 在当前没有事件安排的时候输出 Nothing scheduled for today,而不是只输出 On the agenda for today:就草草了事。
这个脚本也可以用在 Unix 主机中发送系统范围的事件提醒(例如日程安排备份、公司放假 和员工生日)。首先,让每个用户机器上的 agenda 脚本额外检查只读的共享文件.agenda。然后, 在每个用户的.login 或类似的登录文件中调用脚本 agenda。