Shell中错误处理的探索

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

起:错误和异常

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

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

#!/bin/sh

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

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

老的方案

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

#!/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”。

#!/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部分的错误处理代码。

# 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函数:

#!/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,可以将剩余部分展开如下

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

最内层_0=$($@)显然就是执行参数函数的地方。它将标准输出(stdout)保存到变量$_0之中,并在外层的2>&1将标准错误(stderr)重定向到stdout。状态总结如下

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

之后,又执行了语句echo -n "_try_out='$_0' _try_return=$? " >&2,将一段信息打印(重定向)到stderr。注意,由于这是一条中括号内的指令,因此在执行到语句的时候相关变量(比如$?)才会被替换。此时

  • 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在编码时会方便许多,而且其实对脚本来说一两毫秒的性能损耗也并不算大,因此我还是挺乐意使用这个新的方式的。

$ ./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代码如下

#!/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
分享到

KAAAsS

喜欢二次元的程序员,喜欢发发教程,或者偶尔开坑。(←然而并不打算填)

相关日志

  1. 没有图片
  2. 没有图片
  3. 没有图片
  4. 没有图片
  5. 没有图片
  6. 没有图片

评论

  1. hm 2021.12.09 11:05上午

    我都是这么着干的:

    1. 只是要提示的话

    ~~~~ sh
    curl xxxx && echo :ok xxx || echo :err xxx
    ~~~~

    (上面的冒号 `:` 没有特别作用只是字符串而已)

    如果想这个提示进入标准错误,对应片段像这样就行:

    ~~~ sh
    echo xxx >&2
    ~~~

    2. 如果想要它自动重做的话(由于要用 `declare` 所以 `sh` 就不行了这里是 `bash` 代码)

    ~~~~ bash
    # def your work
    works ()
    {
    sleep 1 ; cd xxx ;
    } ;

    # redo if fail
    retry ()
    {
    works_def_name="$1" tried="${2:-0}" &&

    "$works_def_name" &&
    { echo :ok "$works_def_name" … "$tried" ; } ||
    { echo :err "$works_def_name" … "$tried" ;
    exec bash -c "$(declare -f "$works_def_name") ; $(declare -f retry) ; retry ‘$works_def_name’ $((tried+1))" ; } ;
    } ;
    ~~~~

    使用:

    ~~~ bash
    (retry works)
    ~~~

    上面的代码会不停地在等待后尝试 `cd xxx` 这个命令,如果没这个目录就会失败,输出的信息里会带有已经尝试了几次的计数。

    也可以用这样一个更简单的例子理解:

    ~~~~ bash
    cd_retring ()
    {
    n="${1:-0}" &&
    cd "$n" &&
    { echo :succ cd … "${1:-0}" ; } ||
    { echo :fail cd … "${1:-0}" ; exec bash -c "$(declare -f cd_retring) ; cd_retring ‘$((n+1))’" ; } ;
    } ;
    ~~~

    使用:

    ~~~ bash
    (cd_retring) #or (cd_retring 114514) will let num begin with 114514
    ~~~

    原理就是把定义交给 `exec bash -c` 从而在子进程里也保持定义。这个方法也可以用于远程执行本地定义的函数。

    3. 如果不是用函数而是把 `works` 里的代码写进脚本文件,对应的 `retry` 里就不需写 `exec bash -c "$(declare -f works)"` 了,只需要 `exec bash works.sh` 就好,而且由于不用 `declare` 了所以应该也能用 `sh` 了,但这就导致要对文件系统内的内容产生影响。

    关于 `exec` :

    如果不是 `exec bash` 而是 `bash` 的话,就可能会让函数调用的类似于【压栈】的操作只增不减,从而在某个时候溢出,现象就是卡死然后报个错,然后结束。不过,比较新版本的 `bash` 不会卡死或者结束,而是在【压栈】够多后给个警告。

    而 `exec` 的作用就是,哪怕 `exec bash` 这句后面还有语句,也会被忽略掉,也就是强行丢弃外一层的函数中的一切,用新启动的进程把外层原本要做的事全覆盖掉。

    这个玩法是我自己因为不想写循环的嵌套然后试出来的。后来又了解了 lambda 演算还有 Y 组合子什么的,不知道有没有联系……这里面相当于有递归了,函数在错误时用新的参数(新的计数)调用了自身。而 `exec` 相当于在 `sh` 上强制造成一种函数式的尾递归的效果。

    • KAAAsS 2021.12.16 12:49上午

      挺有意思的方法!不过如果用 [code]bash -c[/code] 的话可能会导致当前变量的丢失,比如

      [code]
      a=3

      func() {
      echo &quot;a = $a&quot;
      }

      func
      exec bash -c &quot;$(declare -f func); func&quot;
      [/code]

      所以还需要在执行前补充声明所有需要的变量。Btw,虽然这个不是 Y 组合子,但是确实是一种很有意思的在 bash 里实现尾递归循环的方法 🙂
      以及抱歉因为邮件回复提醒烂掉了,导致好久都没有回复 >_<

      • hm 2021.12.22 4:24下午

        确实是这样, `exec bash -c <str>` 的话,后面的 `<str>` 处需要包括所有你要重复执行的东西。

        所以,我的方法是,搞了个 `works` 函数,然后如果有变量,也在里面重新声明。

        (这样也可以确保,重复执行的工作里的变量,不会被当前 SHell 里面已经定义了的变量影响(除非是 `export` 了的),所以,如果是需要让子进程也用到的变量,可以写到 `works` 的函数定义里头,也可以不写在里头但是要把它 `export` 一下。对于你给的示例就是,在上面的 `func()` 定义之前,得执行下 `export a` 。)

        —-

        这个办法是我不想把东西框在 `for` 的那个 `do-done` 之间,所以这么搞的,一开始还是 `exec bash xxx.sh` 这样弄的,当时正好在写一个固定3并发度的工具,并发的每个串行都是在做一件事情然后只要收到带 `error` 的日志就让它以非`0`退出(这个是用`awk`做的),后面再写上让只要非`0`退出就重新执行自身。

        这样,就能做到按照指定并发同时处理一批文件,而其中的每个串行都具备失败自动重试的能力,成功了才会继续下一个。代码也很简单,重试是用 `exec bash` 完成,固定并发量(比如`3`)就是:

        ~~~~ bash
        cat want-to-dl.urls | xargs -i -P3 — bash retring-downloader.sh {}
        ~~~~

        上面的 `retring-downloader.sh` 也可以换成函数。函数可以用 `export -f 函数名` 给变成子进程可用的,或者也可以用 `declare` 把定义塞进字符串。(前者会更快一点其实)

        —-

        回复晚不晚的吧,我比较在意的是,评论区好像不支持 markdown 。。。(不过我还是按照md写了先。。。)

      • hm 2021.12.22 4:34下午

        哦,错了,不是在 `func ()` 的定义之前 `export a` ,而是在使用这个定义之前 `export a` 。
        (如果有更多变量可以一次性广播: `export a foo bar …` 这样。)

        然后,不管是 `export a foo bar` 还是 `export -f func1 func2` ,都只能在本机生效,如果要批量分发给远程的机器,就还是得把东西都直接写在 `works` (也就是你的 `func` )的定义里头,然后用 `declare -f works` 把需要分发的命令塞进字符串来分发就好。

        分发也可以并发做:

        ~~~~ bash
        awk /nodekeywords/'{print$1}’ /etc/hosts | xargs -i -P3 — ssh {} — "$(declare -f works) ; works"
        ~~~~

        上面这个是假设 `awk /nodekeywords/'{print$1}’ /etc/hosts` 打印出来的IP都已经做好了免密访问。没有免密访问或许可以用 `sshpass` 来辅佐 `ssh` 。

        • KAAAsS 2021.12.23 11:51下午

          确实,远程的情况是一个优点。以及关于评论区 Markdown,我现在确实认识到他的重要性了(噗)

          • hm 2021.12.27 7:47下午

            o(<W<)o

            上面那些我改进了一点,点这条回复的名字就能看到。

在此评论中不能使用 HTML 标签。