防御式 bash 编程译文
资料来源:
https://kfirlavi.herokuapp.com/blog/2012/11/14/defensive-bash-programming/
更新
导语 这是一篇在草稿里存了很久的译文,一直没有完成,最近才有时间重新整理.有一些描述上变动,大意未变.
Defensive BASH Programming
正文 这里作者提到的防御式 bash 编程,是一系列的 bash 编程范式,来防止 bash 脚本被执行恶意行为,并保持代码整洁.
不可变全局变量 尽量减少全局变量数量
使用 UPPER_CASE (下划线连接大写字母) 的命名方式
使用 readonly
修饰全局变量
使用全局变量替换 $0
(当前 shell 的名称) $1
(第一个传入的变量) 这样隐晦的变量名
下面是使用全局变量的示例
1 2 3 readonly PROGNAME=$(basename $0 )readonly PROGDIR=$(readlink -m $(dirname $0 ))readonly ARGS="$@ "
限定作用域 任何可以是局部变量的不要写成全局变量.
1 2 3 4 5 6 7 change_owner_of_file () { local filename=$1 local user=$2 local group=$3 chown $user :$group $filename }
变量命名要尽可能自解释.
通常循环中的临时变量命名成 i,同时一定不要忘记 local
修饰.
1 2 3 4 5 6 7 8 9 10 11 change_owner_of_files () { local user=$1 ; shift local group=$1 ; shift local files=$@ local i for i in $files do chown $user :$group $i done }
local
修饰的变量全局无法访问.
1 2 kfir@goofy ~ $ local a bash: local : can only be used in a function
Main 函数 将 bash 脚本中的操作尽量写成函数形式,整个脚本类似函数式编程.
只有一个 main 函数,main 函数内部也是变量尽量是 local
全局调用只有一个 main
1 2 3 4 5 6 7 8 9 10 main () { local files="/tmp/a /tmp/b" local i for i in $files do change_owner_of_file kfir users $i done } main
一切操作都写成函数 唯一的顶格写,全局运行的代码
保持代码整洁和良好的说明性.
1 2 3 main () { local files=$(ls /tmp | grep pid | grep -v daemon) }
1 2 3 4 5 6 7 8 9 10 11 temporary_files () { local dir=$1 ls $dir \ | grep pid \ | grep -v daemon } main () { local files=$(temporary_files /tmp) }
下面的拆分后代码要好很多.出现问题可以直接找 temporary_files()
,而不是找 main 的一条一条语句,如果写单元测试可以直接对 temporary_files
而不是 main.
如果第一种写法,直接对 main 测试,那就不是单元测试,转成全局运行调试了.
1 2 3 4 5 6 7 8 9 10 11 12 test_temporary_files () { local dir=/tmp touch $dir /a-pid1232.tmp touch $dir /a-pid1232-daemon.tmp returns "$dir /a-pid1232.tmp" temporary_files $dir touch $dir /b-pid1534.tmp returns "$dir /a-pid1232.tmp $dir /b-pid1534.tmp" temporary_files $dir }
函数调试 使用 -x 启动脚本,-x
会输出全部执行细节.
函数内部使用 set -x
和 set +x
,在它们中间的代码会在执行前打印.
1 2 3 4 5 6 7 8 9 temporary_files () { local dir=$1 set -x ls $dir \ | grep pid \ | grep -v daemon set +x }
输出函数名和参数
1 2 3 4 5 6 7 8 temporary_files () { echo $FUNCNAME $@ local dir=$1 ls $dir \ | grep pid \ | grep -v daemon }
执行 temporary_files /tmp
会得到 temporary_files /tmp
的输出.
代码自解释性 先来看一段代码,除了在脑中模拟一遍执行,你能直接看懂吗?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 main () { local dir=/tmp [[ -z $dir ]] \ && do_something... [[ -n $dir ]] \ && do_something... [[ -f $dir ]] \ && do_something... [[ -d $dir ]] \ && do_something... } main
拆分后就好多了,虽然付出了更多的行数,但是非常清晰明了.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 is_empty () { local var=$1 [[ -z $var ]] } is_not_empty () { local var=$1 [[ -n $var ]] } is_file () { local file=$1 [[ -f $file ]] } is_dir () { local dir=$1 [[ -d $dir ]] } main () { local dir=/tmp is_empty $dir \ && do_something... is_not_empty $dir \ && do_something... is_file $dir \ && do_something... is_dir $dir \ && do_something... } main
每一行只做一件事 因为 shell 中大量使用管道等,造成一行命令可能会完成很多功能,固然强大,但是可读性不好.
尽量使用 \
将原来一行非常复杂的命令拆分成几行,一行只完成一个功能.
1 2 3 4 5 temporary_files () { local dir=$1 ls $dir | grep pid | grep -v daemon }
1 2 3 4 5 6 7 temporary_files () { local dir=$1 ls $dir \ | grep pid \ | grep -v daemon }
连接的符号要放在这样拆分的一行开头
不好的例子
1 2 3 4 5 6 7 temporary_files () { local dir=$1 ls $dir | \ grep pid | \ grep -v daemon }
好的例子
1 2 3 4 5 6 7 print_dir_if_not_empty () { local dir=$1 is_empty $dir \ && echo "dir is empty" \ || echo "dir=$dir " }
正确使用输出 不要出现下面这样的代码
1 2 3 echo "this prog does:..." echo "flags:" echo "-h print help"
正确的示例
1 2 3 4 5 usage () { echo "this prog does:..." echo "flags:" echo "-h print help" }
虽然包裹进函数了,但是 echo
还在每一行都有重复.使用 here 文档.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 usage () { cat <<- EOF usage: $PROGNAME options Program deletes files from filesystems to release space. It gets config file that define fileystem paths to work on, and whitelist rules to keep certain files. OPTIONS: -c --config configuration file containing the rules. use --help-config to see the syntax. -n --pretend do not really delete, just how what you are going to do. -t --test run unit test to check the program -v --verbose Verbose. You can specify more then one -v to have more verbose -x --debug debug -h --help show this help --help-config configuration help Examples: Run all tests: $PROGNAME --test all Run specific test: $PROGNAME --test test_string.sh Run: $PROGNAME --config /path/to/config/$PROGNAME.conf Just show what you are going to do: $PROGNAME -vn -c /path/to/config/$PROGNAME.conf EOF }
注意每一行开头都要有真正的制表符 \t
,vim 中如果你的制表符是 4 个空格,可以使用下面的替换命令.
命令行参数 这里作者用了 Kirk’s blog post - bash shell script to use getopts with gnu style long positional parameters 的一段代码补充上面的例子.(这算是 gnu 风格的参数?)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 cmdline () { local arg= for arg do local delim="" case "$arg " in --config) args="${args} -c " ;; --pretend) args="${args} -n " ;; --test ) args="${args} -t " ;; --help-config) usage_config && exit 0;; --help ) args="${args} -h " ;; --verbose) args="${args} -v " ;; --debug) args="${args} -x " ;; *) [[ "${arg:0:1} " == "-" ]] || delim="\"" args="${args} ${delim} ${arg} ${delim} " ;; esac done eval set -- $args while getopts "nvhxt:c:" OPTION do case $OPTION in v) readonly VERBOSE=1 ;; h) usage exit 0 ;; x) readonly DEBUG='-x' set -x ;; t) RUN_TESTS=$OPTARG verbose VINFO "Running tests" ;; c) readonly CONFIG_FILE=$OPTARG ;; n) readonly PRETEND=1 ;; esac done if [[ $recursive_testing || -z $RUN_TESTS ]]; then [[ ! -f $CONFIG_FILE ]] \ && eexit "You must provide --config file" fi return 0 }
使用函数
1 2 3 4 main () { cmdline $ARGS } main
单元测试 单元测试在高级语言中非常常见了,但是在 bash 确实是应用不多.
这里的测试框架是 shunit2 ,作者是 kward (同时也是 log4sh 的作者).看 commit 开发依旧活跃,今年一直有推送.
开始 shunit2 是托管到了 google code,后来 google code 关闭又迁移到了现在的 GitHub.(google 你关停了多少服务了…)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 test_config_line_paths () { local s='partition cpm-all, 80-90,' returns "/a" "config_line_paths '$s /a, '" returns "/a /b/c" "config_line_paths '$s /a:/b/c, '" returns "/a /b /c" "config_line_paths '$s /a : /b : /c, '" } config_line_paths () { local partition_line="$@ " echo $partition_line \ | csv_column 3 \ | delete_spaces \ | column 1 \ | colons_to_spaces } source /usr/bin/shunit2
下面是使用 df 的另一个示例.这里对上面的原则有一点改变,因为 shunit2 不允许更高全局作用域函数,这里声明了 df 但并没有 readonly
修饰.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 DF=df mock_df_with_eols () { cat <<- EOF Filesystem 1K-blocks Used Available Use% Mounted on /very/long/device/path 124628916 23063572 100299192 19% / EOF } test_disk_size () { returns 1000 "disk_size /dev/sda1" DF=mock_df_with_eols returns 124628916 "disk_size /very/long/device/path" } df_column () { local disk_device=$1 local column=$2 $DF $disk_device \ | grep -v 'Use%' \ | tr '\n' ' ' \ | awk "{print \$$column }" } disk_size () { local disk_device=$1 df_column $disk_device 2 }
其他 这篇文章很早就收入了 plan 文件夹,译文也很早就动笔了,却被遗忘在了角落里,还好又翻出来了..