Total Pageviews

Monday 9 November 2015

bash shell script 的防御性编程


shell 脚本的坑不少, 一旦踩到, 轻则执行失败, 重则一声不响的干掉 $HOME 目录. 我踩过几个坑吃过几个亏以后, 总结了一些简单的经验.

使用绝对路径

好用的脚本, 不应该依赖于脚本安装的路径 (方便随时迁移), 也不依赖于调用者当前所在的路径。要做到这点, 脚本中用到的各种路径都必须是绝对路径。
比如, 某个脚本需要 source 同一个目录下的一个配置文件 conf.sh, 删掉 tmp 下面存一些临时文件, 不能直接 source ./conf.sh 或者 rm -fr ./tmp/*, 因为你不知道调用者的 pwd 在哪里, 这样做很可能读不到正确的文件, 甚至删了不该删的东西。所以保险的写法可以是这样:
#!/bin/sh

SCRIPT_PATH=`dirname $(readlink -f $0)` # 得到脚本所在的路径
pushd . > /dev/null # 把调用者的当前目录放到 shell 的 directory stack 中
cd "$SCRIPT_PATH"
source ./conf.sh
rm -fr ./tmp/*
...
popd # 回到调用者的当前目录
其中 readlink 比较关键, 它本来的作用是获取符号链接指向的目标, 此处的作用是将相对路径解析成绝对路径。如果不加 readlink, SCRIPT_PATH=`dirname $0` 似乎也能用, 可是当调用者以 sh ../sample.sh 的形式执行你的脚本时, $SCRIPT_PATH 就会变成 '..', 于是脚本里 cd 到别的地方去以后, 依赖 $SCRIPT_PATH 的命令又不能正常工作了。
UPDATE: 发现 OSX 上的 readlink 不支持 -f 参数, 这时可以用 SCRIPT_PATH=`cd $(dirname $0); pwd` 代替,除了不能解析符号链接以外,效果一样,兼容性更好一些。

小心空串和带空格的文件名

方法就是, 引用各种变量时, 都带上双引号, 比如 cp "$1" "$SCRIPT_PATH"/tmp. 如果不加双引号, 遇到 $1 中包含空格的情形时就会被当作多个不同的参数, 执行就失败了.
同理, 检查字符串是否相等的时候也需要加上双引号, 例如 if [ "$1" -eq "$DOMAIN" ], 不加引号的话遇到空串就会报语法错误。这个方法也可用于检测空串。也见到过别人这么写: if [ X$1 -eq X ], 看起来有点丑.
有人喜欢用 for f in `find . | xargs` 来遍历一个目录下的所有文件, 这样的写法无法处理文件名中有空格的情形。如果不需要对 find 的结果做过滤, 遍历操作比较简单, 可以这样改:
find . -exec cat {} \;
如果需要对 find 的结果做 grep, 做的操作比较复杂, 还可以这样改:
find . | grep 'PATTERN' | while read fn
do
    cat $fn
    ...
done
推荐后一种写法, 好读又实用。

在日志中记录所有操作

日志总是 debug 的最好工具, 尤其是对于 shell script 这种没法单步跟踪的脚本来说. 手工记录日志总是太麻烦而且容易遗漏, 所以用 set -x 来记录每一行操作成了比较常见的做法. 我在脚本里一般会这么写:
LOG_PATH=`dirname $(readlink -f $0)`/log
mkdir -p $LOG_PATH
exec 1>>$LOG_PATH/`basename $0`.stdout.log
exec 2>>$LOG_PATH/`basename $0`.stderr.log
date # 用来区分脚本的启动时间
date >&2

小心返回值

shell 跟 python 之类的脚本不同的一点, 就是遇到错误时不会终止执行, 甚至是语法错误. 所以关键的语句如果不判断返回值、判断行为是否达到预期 (应该生成的文件生成没有, 应该非空的环境变量是否非空), 贸然执行下去的话会产生严重的后果.

懒惰删除

在线上跑的 shell 脚本最好不要执行 rm 操作, 尤其是 rm -rf 更要慎重, 参数里有 * 就更是要慎之又慎. 原因是每个脚本都不能保证写的万无一失, 万一哪天生成 $TMP_DIR 变量的语句没有执行成功, cd $TMP_DIR; rm -rf * 这样的操作就成了悲剧.
所以除了检查返回值以外, 将 rm 操作都转换成 mv 操作更保险一些. 可以在脚本启动时建立一个垃圾箱:
TRASH_DIR=trash_`date +%Y%m%d%H%M%S\`
需要删除时都把文件 mv 到这里, 再手动或自动定期清理即可。