让你的 shell 脚本变得可控

March 20, 2017

刚开始接触 shell 脚本的时候,最痛苦的地方在于出了问题,却不容易定位问题。

shell 脚本遇到错误,“大部分” 情况下都会继续执行剩下的命令,最后返回 Zero Exit Code 并不代表着结果正确。

这让人很难发现问题,它不像其他脚本语言,遇到 语法错误typo 等错误时便会立即退出。

如果想要写出容易维护、容易 debug 的 shell 脚本,我们就需要让 shell 脚本变得可控。

set -e

默认情况下,shell 脚本遇到错误并不会立即退出,它还是会继续执行剩下的命令。

[root@localhost ~]# cat example
#!/usr/bin/env bash
# set -e

sayhi # this command is not available.
echo "sayhi"
[root@localhost ~]# ./example
./example: line 4: sayhi: command not found
sayhi

我们知道 Linux/Unix 用户等于系统的时候,内核会加载 .bashrc 或者 .bash_profile 里的配置。

不同 shell 版本会使用不同的 rc/profile 文件,比如 zsh 版本的 rc 文件名是 .zshrc。

简单设想下,假如 shell 脚本遇到错误就退出,那么只要这些文件里有 typo 等错误,该用户就永远登陆不了系统。

在此,并没有考究默认行为的设计缘由,只是想表达 shell 脚本默认行为会让脚本变得不可控。

set -e 能会让 shell 脚本遇到 Non-Zero Exit Code 时,会立即停止执行。

[root@localhost ~]# cat example
#!/usr/bin/env bash
set -e

sayhi # this command is not available.
echo "sayhi"
[root@localhost ~]# ./example
./example: line 4: sayhi: command not found

set -u

初始化后再使用变量,这是好的编程习惯。

但在默认情况下,shell 脚本使用未初始化的变量并不会报错。

[root@localhost ~]# cat example
#!/usr/bin/env bash
# set -u

echo "Hi, ${1}"
[root@localhost ~]# ./example
Hi,
[root@localhost ~]# echo $?
0

若脚本设置 set -u ,一旦使用没有初始化的变量或者 positional parameter 时,脚本将立即返回 1 Exit Code。

[root@localhost ~]# cat example
#!/usr/bin/env bash
set -u

echo "Hi, ${1}"
[root@localhost ~]# ./example
./example: line 4: 1: unbound variable
[root@localhost ~]# echo $?
1

需要说明的是,对于预定义的 $@, $* 等这些变量,是可以正常使用。

为了避免使用未初始化的变量,常使用 ${VAR:-DEFAULT} 来设置默认值。

#!/usr/bin/env bash
set -u

# ${VAR:-DEFAULT} evals to DEFAULT if VAR undefined.
foo=${nonexisting:-ping}

echo "${foo}" # => ping

bar="pong"

foo=${bar:-ping}

echo "${foo}" # => pong

# DEFAULT can be empty
empty=${nonexisting:-}

echo "${empty}" # => ''

set -o pipefail

在默认情况下,pipeline 会采用最后一个命令的 Exit Code 作为最终返回的 Exit Code。

[root@localhost ~]# cat example
#!/usr/bin/env bash
# set -o pipefail

grep string /non-existing-file | sort
[root@localhost ~]# ./example
grep: /non-existing-file: No such file or directory
[root@localhost ~]# echo $?
0

明明报错了,为什么还会返回 Zero Exit Code?

grep 一个并不存在的文件会返回 2 Exit Code。grep 不仅会输出错误信息到 STDERR 上,还会输出空的字符串到 STDOUT。对于 sort 命令而言,空字符串是合法的输入,所以最后命令返回 Zero Exit Code。

这样错误信息并不能很好地帮助我们改善脚本,返回的 Exit Code 应该要尽可能地反映错误现场。

和前面两个设置一样,set -o pipefail 会让 shell 脚本在 pipeline 过程遇到错误便立即返回相应错误的 Exit Code。

[root@localhost ~]# cat example
#!/usr/bin/env bash
set -o pipefail

grep string /non-existing-file | sort
[root@localhost ~]# ./example
grep: /non-existing-file: No such file or directory
[root@localhost ~]# echo $?
2

non-zero exit code is expected

这三个配置太过于苛刻,某些情况下还需要放宽这些限制:当程序可以接受 non-zero exit code 时。

这里有两种常用的方式去放宽限制:

set +

这里有一个脚本是用来产生长度为 64 的随机字符串:

root@localhost ~]# cat example
#!/usr/bin/env bash
set -euo pipefail

str=$(cat /dev/urandom | tr -dc '0-9A-Za-z' | head -c 64)

echo "${str}"

[root@localhost ~]# ./example
[root@localhost ~]# echo $?
141

该脚本有一个问题,就是 head 命令在获取到第 64 个字节之后,会关闭 STDIN,但是 pipe 还在不断地输出,导致内核不得不抛出 SIGPIPE 来终止命令。

因为设置 set -o pipefail 了 ,整个脚本因为 SIGPIPE 会退出。

假设该脚本剩下命令还很多,不能整体去掉 pipefail ,那么我们就局部放弃这个限制好了。

[root@localhost ~]# cat example
#!/usr/bin/env bash
# exit immediately if non-zero exit code/unset variable/pipe error
set -euo pipefail

# loosen up
set +o pipefail
str=$(cat /dev/urandom | tr -dc '0-9A-Za-z' | head -c 64)
set -o pipefail

echo "${str}"

[root@localhost ~]# ./example
pvScFHDZrdjlI091rQbruyEPM9e6iTN59IyzaKcCJwiCxYmiSNRmkFOfp0YuXi1C
[root@localhost ~]# echo $?
0

同理,只要设置上 set +e 或者 set +u 时,就会放宽相应的限制。

记得 有借有还,再借不难 就好了。

短路运算

现在有一个脚本,该脚本用来统计文件 file 中有多少行是包含了 string 这个字符串。

root@localhost ~]# cat example
#!/usr/bin/env bash
set -euo pipefail

count=$(grep -c string ./file)

echo "${count}"

[root@localhost ~]# ./example
[root@localhost ~]# echo $?
1
[root@localhost ~]# cat ./file
example

因为文件 file 中并不包含 string 这一字符串,所以 grep 返回 1 Exit Code。

假设遇到没有匹配上的文件,该脚本应该显示零,而不是错误。

$(cmd || true) 短路运算会让该命令永远都正常执行。

[root@localhost ~]# cat example
#!/usr/bin/env bash
set -euo pipefail

count=$(grep -c string ./file || true)

echo "${count}"

[root@localhost ~]# ./example
0
[root@localhost ~]# echo $?
0

思考

shell 脚本能帮助我们轻松地完成自动化的任务,这是它的优势。

但是劣势也比较明显,就是 shell 脚本的返回值。我们来看看下面的一个例子。

相对于 if/else, 短路运算可以让代码变得简洁。

但是一旦最终的判断结果为否,那么该短路运算将会返回 Non-Zero Exit Code。

假如有一个脚本的最后一条命令是短路运算。

[root@localhost ~]# cat ./echo_filename
#!/usr/bin/env bash
set -euo pipefail

file=${1:-}

[[ -f "${file}" ]] && echo "File: ${file}"
[root@localhost ~]# ./echo_filename
[root@localhost ~]# echo $?
1

如果没有传参数,那么短路运算将会返回 1 Exit Code,这个结果也将作为整个脚本的返回结果。

需要说明的是,虽然短路运算返回的 Non-Zero Exit Code,但 set -e 不会因为它而退出。

然后我们再看看使用 if/else 的结果。

[root@localhost ~]# cat echo_filename
#!/usr/bin/env bash
set -euo pipefail

file=${1:-}

if [[ -f "${file}" ]]; then
    echo "File: ${file}"
fi
[root@localhost ~]# ./echo_filename
[root@localhost ~]# echo $?
0

从逻辑上来分析,即使不传参数,呈现的应该是空字符串,并返回 Zero Exit Code。

if/else 语句相对于短路运算要合理。

我们别小看这一区别,如果这里有脚本调用 echo_filename,那么使用短路运算将会导致调用该脚本的脚本停止工作。

归根结底,是因为 shell 脚本并不像其他语言那样支持返回多种数据类型,它只能返回数字的 Exit Code。

这就代表着脚本的程序设计必须要考虑返回正确的 Exit Code,这样 set -euo pipefail 才能让脚本变得更加可控。

关于 set 更多的内容,请前往 Link