Shell中错误处理的探索

2022-01-14 16:34:45 浏览数 (1)

最近集中折腾了下闲置的NAS,总算是有了阶段性成果,过段时间我会单独写一篇Blog。写这篇文章主要是因为我在写一些维护脚本的时候正好遇到了需求,所以就尝试了一下。

起:错误和异常

错误和异常主要的区别在于是否需要脚本的编写者进行处理。对于错误,通常是脚本本身的问题或者是系统的运行环境不符合预期,这种时候停止脚本的运行是更加妥当的选择。而异常则是需要脚本处理的问题,如curl请求失败、文件操作无权限等等。

不过Shell脚本本身并没有明确的区分错误和异常,只有返回码(exit code)用于判断程序执行状态。如果要对一个异常进行处理,则需要在其后根据返回码进行判断

代码语言:javascript复制
#!/bin/sh

false
if [[ $? -ne 0 ]]; then
    echo "错误"
fi

但是每条语句都进行判断显然不现实。而且这样判断还存在一个问题,就是如果程序出现预期之外的错误,脚本并不会停止执行。这可能会让后面的逻辑也无法进行(比如准备环境的语句出错),使脚本进行非预期的行为。所以,Shell脚本前通常会加set -o errexit -o pipefail以在错误时及时退出脚本。但是这样,上面的判断就失效了——执行false语句后脚本会直接退出。

老的方案

我曾经使用的方法是Shell脚本中比较常见的一种方法,简述如下

代码语言:javascript复制
#!/bin/sh
set -o errexit -o pipefail

! false
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
    echo "错误"
fi

这里的!就是取反,其原理是Shell在执行判断语句(比如if的条件)时不会在错误时退出,即整个语句的返回码是0。不过也是因为这个原因就无法使用$?获得真正的返回码(永远是0),必须要用给管道指令设计的PIPESTATUS

简单的包装一下,并且读取标准错误流的输出,我们就得到了一个set -e环境下的简易“try-catch”。

代码语言:javascript复制
#!/bin/sh
set -o errexit -o pipefail

__try() {
    if [[ $try_status -eq 0 ]]; then  # 用于连续try时,出错后不继续进行
        ! exception=$( $@ 2>&1 >/dev/null)
        try_status=${PIPESTATUS[0]}
    fi
}

__catch() {
    _old_try=$try_status
    try_status=0
    [[ $_old_try -ne 0 ]]
}

# Usage
__try expr 1 / 0
        
 if __catch e; then
        echo "错误: $e"
fi

其他方案

其他实现同样效果的还有trap方式和set e方式。以bash-oo-framework的try/catch为例,它使用的就是set e方式(虽然也使用了trap,但是只用于处理Exception的细节)。

可以看到,在进入try块时设置了set -e,而之前设置了set e。这样如果遇到错误则会结束set -e部分的语句,而运行catch部分的错误处理代码。

代码语言:javascript复制
# in case try-catch is nested, we set  e before so the parent handler doesn't catch us instead
alias try='[[ $__oo__insideTryCatch -eq 0 ]] || __oo__presetShellOpts="$(echo $- | sed 's/[is]//g')"; __oo__insideTryCatch =1; set  e; ( set -e; true; '
alias catch='); declare __oo__tryResult=$?; __oo__insideTryCatch =-1; [[ $__oo__insideTryCatch -lt 1 ]] || set -${__oo__presetShellOpts:-e} && Exception::Extract $__oo__tryResult || '

改进方案

上面的方案其实已经能满足绝大部分需求了。但是它们依旧有且都有一个很大的问题——只能同时获得一个流,要么是标准错误流,要么是标准输出流。虽说一般情况下获得标准输出流就足够,但是总有时候需要获得进一步的信息。所以就有了这个方案。

这个方案来自于StackOverflow。它通过一种非常怪异的方法同时得到标准输出流和标准错误流的输出。先来看下最终的__try函数:

代码语言:javascript复制
#!/bin/sh
__try() {
    [[ $_try_return -eq 0 ]] && eval $({ ! _1=$({ _0=$($@); } 2>&1; echo -n "_try_out='$_0' _try_return=$? " >&2); echo -n "_try_err='$_1'"; } 2>&1)
}

__catch() {
    _old_try=$_try_return
    _try_return=0
    export $1="$_try_err"
    [[ $_old_try -ne 0 ]]
}

乍一看可能会感觉十分难理解。的确,因为它的实现原理就比较“扭曲”。只看关键的执行部分,排除eval,可以将剩余部分展开如下

代码语言:javascript复制
{
    ! _1=$(
        { 
            _0=$($@) 
        } 2>&1
        echo -n "_try_out='$_0' _try_return=$? " >&2
    )
    echo -n "_try_err='$_1'"
} 2>&1

最内层_0=(@)显然就是执行参数函数的地方。它将标准输出(stdout)保存到变量

  • 变量$_0:指令输出的stdout
  • stdout:指令输出的stderr

之后,又执行了语句echo -n "_try_out='

  • stdout:指令输出的stderr
  • stderr:"_try_out='指令输出的stdout' _try_return=指令返回码 "

再向外走一层,! _1=( ... )将stdout保存到了变量_1。这里的感叹号的用法和老方法中的相同。此时

  • 变量$_1:指令输出的stderr(之前保存在stdout之中)
  • stderr:"_try_out='指令输出的stdout' _try_return=指令返回码 "

之后和之前类似的语句echo -n "_try_err='$_1'"将另一些信息打印到stdout。此时

  • stdout:"_try_err='指令输出的stderr'"
  • stderr:"_try_out='指令输出的stdout' _try_return=指令返回码 "

最后语句2>&1将stderr重定向到了stdout。注意是重定向,因此先输出的内容仍会在前面。所以最终状态是

  • stdout:"_try_out='指令输出的stdout' _try_return=指令返回码 _try_err='指令输出的stderr'"
  • stderr:空

所以,一顿骚操作下来我们就得到了一段包含指令输出的“生成指令”!而最后通过eval $( ... )执行,就成功的将指令的stdout、stderr、返回码给带了出来。

不过这个方法也并不是没有缺点。最主要的问题是这个方法给脚本带来了额外的开销,流重定向的影响倒是不大,关键是echo的指令替换和最后的eval。原作者这里使用的是declare -p,性能应该稍好于echo,但是经过测试似乎有兼容性问题(sh之下)。

简单的进行Benchmark与原方法进行测试,可以看到性能确实差了不少。不过一来__try的使用次数通常有限,二来提供完整的stdout和stderr在编码时会方便许多,而且其实对脚本来说一两毫秒的性能损耗也并不算大,因此我还是挺乐意使用这个新的方式的。

代码语言:javascript复制
$ ./try_benchmark.sh
Old way: 

real    0m0.722s
user    0m0.502s
sys     0m0.290s
New way: 

real    0m2.679s
user    0m2.274s
sys     0m0.867s

Benchmark代码如下

代码语言:javascript复制
#!/bin/sh

cat > temp.sh <<EOF
#!/bin/sh

set -o errexit -o pipefail

__try() {
    ! exception=$( $@ 2>&1 >/dev/null)
    try_status=${PIPESTATUS[0]}
}

func() {
    echo "$(seq 1 100)"
    echo "$(seq 1 100)" >&2
}

for i in {1..1000}; do
    __try func
done
EOF

echo "Old way: "
time sh temp.sh

cat > temp.sh <<EOF
#!/bin/sh

set -o errexit -o pipefail

__try() {
    eval $({ ! _1=$({ _0=$($@); } 2>&1; echo -n "_try_out='$_0' _try_return=$? " >&2); echo -n "_try_err='$_1'"; } 2>&1)
}

func() {
    echo "$(seq 1 100)"
    echo "$(seq 1 100)" >&2
}

for i in {1..1000}; do
    __try func
done
EOF

echo "New way: "
time sh temp.sh

rm -f temp.sh

Reference

  1. Capture both stdout and stderr in Bash:https://stackoverflow.com/a/26827443

0 人点赞