高效检查Bash多个命令的退出状态

275

在Bash中是否有类似于pipefail的东西,用于多个命令,就像try语句一样。我想做这样的事情:

echo "trying stuff"
try {
    command1
    command2
    command3
}

如果任何命令失败,立即退出并回显该命令的错误。我不想做像这样的事情:

command1
if [ $? -ne 0 ]; then
    echo "command1 borked it"
fi

command2
if [ $? -ne 0 ]; then
    echo "command2 borked it"
fi

等等,或者类似的任何内容:

pipefail -o
command1 "arg1" "arg2" | command2 "arg1" "arg2" | command3

我认为每个命令的参数会相互干扰(如果我错了请纠正)。这两种方法对我来说似乎非常冗长和恶心,所以我在这里呼吁寻求一种更有效的方法。


3
请看非官方_bash严格模式set -euo pipefail。这行代码可以帮助您在Bash脚本中更好地处理错误和异常情况。 - Pablo Bianchi
1
@PabloBianchi,“set -e”是一个可怕的想法。请参见BashFAQ#105中的练习,讨论它引入的一些意外边缘情况,以及比较显示不同shell(和shell版本)实现之间的不兼容性 https://www.in-ulm.de/~mascheck/various/set-e/。 - Charles Duffy
16个回答

282
你可以编写一个函数来启动并测试命令。假设 command1command2 是已经设置为命令的环境变量。
function mytest {
    "$@"
    local status=$?
    if (( status != 0 )); then
        echo "error with $1" >&2
    fi
    return $status
}

mytest "$command1"
mytest "$command2"

36
不要使用$*,如果任何参数中有空格它将失败;改用"$@"。同样,在echo命令中,将$1放在引号内。 - Gordon Davisson
83
我建议避免使用名称“test”,因为它是一个内置命令。 - John Kugelman
1
这是我采用的方法。老实说,我觉得在原始帖子中没有表述清楚,但是使用这种方法可以让我编写自己的“测试”函数,因此我可以在其中执行与脚本执行的相关错误操作。谢谢 :) - jwbensley
7
如果test()发生错误,返回的退出代码是否总是0,因为最后执行的命令是“echo”?你可能需要先保存$?的值。 - magiconair
2
这不是一个好主意,它会鼓励不良实践。考虑ls的简单情况。如果你调用ls foo并得到形式为ls: foo: No such file or directory\n的错误消息,你就能理解问题所在。如果你得到ls: foo: No such file or directory\nerror with ls\n这样的消息,你会被多余的信息分散注意力。在这种情况下,可以很容易地争辩说多余性是微不足道的,但它很快就会增加。简洁的错误消息很重要。但更重要的是,这种包装方式鼓励作者完全省略好的错误消息。 - William Pursell
显示剩余6条评论

189
你所说的“退出并回显错误”是什么意思?如果你想让脚本在任何命令失败后立即终止,那么只需执行以下操作:
set -e    # DON'T do this.  See commentary below.

在脚本开始时(但请注意下面的警告)。不要打印错误消息:让失败的命令处理它。换句话说,如果您执行:

#!/bin/sh

set -e    # Use caution.  eg, don't do this
command1
command2
command3

如果command1成功执行,而command2失败并打印错误消息到stderr,那么你已经达到了你想要的效果。(除非我误解了你的意图!)
作为推论,你编写的任何命令都必须表现良好:它必须将错误报告到stderr而不是stdout(问题中的示例代码将错误打印到stdout),并且在失败时必须以非零状态退出。
然而,我不再认为这是一个好的实践。set -e随着bash的不同版本而改变了其语义,尽管它对于简单的脚本工作得很好,但有太多的边缘情况,因此基本上无法使用。(考虑诸如:set -e; foo() { false; echo should not print; } ; foo && echo ok 这里的语义有些合理,但如果你将代码重构为依赖选项设置来提前终止的函数,你很容易受到影响。)在我看来,更好的做法是编写:
 #!/bin/sh

 command1 || exit
 command2 || exit
 command3 || exit

或者
#!/bin/sh

command1 && command2 && command3

1
请注意,尽管这个解决方案是最简单的,但在失败时无法进行任何清理工作。 - Josh J
7
附加说明:在这里,“Cleanup”意为“清理”,“trap”指的是“陷阱”,该句话的意思是:“可以使用陷阱完成清理操作。”(例如, trap some_func 0 表示在退出时执行 some_func 函数。) - William Pursell
3
注意,set -e 命令(errexit)的语义在不同版本的 bash 中有所变化,并且在函数调用和其他设置中经常会表现出意外行为。我不再推荐使用它。我的建议是,最好在每个命令后显式地写上 || exit - William Pursell

89

我有一组脚本函数,在我的红帽系统中广泛使用。它们使用 /etc/init.d/functions 中的系统函数打印绿色[ OK ]和红色[FAILED]状态指示器。

如果你想记录哪些命令失败了,可以选择设置$LOG_STEPS变量为日志文件名。

用法

step "Installing XFS filesystem tools:"
try rpm -i xfsprogs-*.rpm
next

step "Configuring udev:"
try cp *.rules /etc/udev/rules.d
try udevtrigger
next

step "Adding rc.postsysinit hook:"
try cp rc.postsysinit /etc/rc.d/
try ln -s rc.d/rc.postsysinit /etc/rc.postsysinit
try echo $'\nexec /etc/rc.postsysinit' >> /etc/rc.sysinit
next

输出

Installing XFS filesystem tools:        [  OK  ]
Configuring udev:                       [FAILED]
Adding rc.postsysinit hook:             [  OK  ]

代码

#!/bin/bash

. /etc/init.d/functions

# Use step(), try(), and next() to perform a series of commands and print
# [  OK  ] or [FAILED] at the end. The step as a whole fails if any individual
# command fails.
#
# Example:
#     step "Remounting / and /boot as read-write:"
#     try mount -o remount,rw /
#     try mount -o remount,rw /boot
#     next
step() {
    echo -n "$@"

    STEP_OK=0
    [[ -w /tmp ]] && echo $STEP_OK > /tmp/step.$$
}

try() {
    # Check for `-b' argument to run command in the background.
    local BG=

    [[ $1 == -b ]] && { BG=1; shift; }
    [[ $1 == -- ]] && {       shift; }

    # Run the command.
    if [[ -z $BG ]]; then
        "$@"
    else
        "$@" &
    fi

    # Check if command failed and update $STEP_OK if so.
    local EXIT_CODE=$?

    if [[ $EXIT_CODE -ne 0 ]]; then
        STEP_OK=$EXIT_CODE
        [[ -w /tmp ]] && echo $STEP_OK > /tmp/step.$$

        if [[ -n $LOG_STEPS ]]; then
            local FILE=$(readlink -m "${BASH_SOURCE[1]}")
            local LINE=${BASH_LINENO[0]}

            echo "$FILE: line $LINE: Command \`$*' failed with exit code $EXIT_CODE." >> "$LOG_STEPS"
        fi
    fi

    return $EXIT_CODE
}

next() {
    [[ -f /tmp/step.$$ ]] && { STEP_OK=$(< /tmp/step.$$); rm -f /tmp/step.$$; }
    [[ $STEP_OK -eq 0 ]]  && echo_success || echo_failure
    echo

    return $STEP_OK
}

这是纯金。虽然我知道如何使用脚本,但我并没有完全掌握每一步,显然超出了我的Bash脚本知识范围,但我仍认为它是一件艺术品。 - kingmilo
2
这个工具有正式的名称吗?我很想阅读一下关于这种步骤/尝试/下一步日志记录的手册。 - ThorSummoner
这些shell函数在Ubuntu上似乎无法使用?我希望能够使用这些具有可移植性的函数。 - ThorSummoner
@ThorSummoner,这很可能是因为Ubuntu使用Upstart而不是SysV init,并且很快将使用systemd。RedHat倾向于长期维护向后兼容性,这就是为什么init.d的东西仍然存在的原因。 - dragon788
1
我在John的解决方案基础上进行了扩展,并使其可以在非RedHat系统(如Ubuntu)上使用。请参见https://dev59.com/dm435IYBdhLWcg3w0Tnc#54190627。 - Mark Thomson
当只有一个调用try的时候,一个微不足道的一行函数: try_step() { step "$1"; shift; try "$@"; next } 所以: try_step "安装XFS文件系统工具:" rpm -i xfsprogs-*.rpm - quimm2003

52

说句实话,检查每个命令是否成功的代码可以更简短:

command1 || echo "command1 borked it"
command2 || echo "command2 borked it"

仍然很繁琐,但至少可读性更强。


没想到这个方法,虽然不是我采用的方式,但它很快速和易于阅读,感谢提供信息 :) - jwbensley
3
为了静默地执行命令并实现相同的效果:command1 &> /dev/null || echo "command1 出了故障" - Matt Byrne
1
我很喜欢这种方法,有没有办法在OR之后执行多个命令?类似于command1 || (echo command1 borked it ; exit)这样的东西。 - AndreasKralj
2
@AndreasKralj,是的,您可以运行一条命令来在失败后执行多个命令: command1 || { echo command1 borken it ; exit; } 最后一个分号是必须的! - Vladimir Perepechenko
1
@VladimirPerepechenko 非常感谢!我已经使用这种方法多年了,它一直为我服务得很好! - AndreasKralj

40

另一种选择是使用&& 将多个命令链接在一起,这样第一个失败的命令会阻止其余命令的执行:

command1 &&
  command2 &&
  command3

这不是你在问题中要求的语法,但它是描述的使用情况的常见模式。通常,命令应该负责打印失败信息,这样你就不必手动操作(也许使用-q标志来静音错误信息)。如果你有修改这些命令的能力,我建议编辑它们以在失败时发出警告,而不是将其包装在其他东西中。


同时请注意,你不需要做:

command1
if [ $? -ne 0 ]; then

你可以简单地说:

if ! command1; then

当您需要检查返回代码时,请使用算术上下文而不是[ ... -ne

ret=$?
# do something
if (( ret != 0 )); then

35

不要创建运行函数或使用set -e,而是使用trap

trap 'echo "error"; do_cleanup failed; exit' ERR
trap 'echo "received signal to stop"; do_cleanup interrupted; exit' SIGQUIT SIGTERM SIGINT

do_cleanup () { rm tempfile; echo "$1 $(date)" >> script_log; }

command1
command2
command3

陷阱甚至可以访问触发它的命令的行号和命令行。 相应的变量是$BASH_LINENO$BASH_COMMAND


5
如果你想更加接近模拟一个try块,可以使用 trap - ERR 在“块”的结尾处关闭陷阱。 - Gordon Davisson

16

个人而言,我更倾向于使用轻量级的方法,就像这里所示;

yell() { echo "$0: $*" >&2; }
die() { yell "$*"; exit 111; }
try() { "$@" || die "cannot $*"; }
asuser() { sudo su - "$1" -c "${*:2}"; }

使用示例:

try apt-fast upgrade -y
try asuser vagrant "echo 'uname -a' >> ~/.profile"

8
run() {
  $*
  if [ $? -ne 0 ]
  then
    echo "$* failed with exit code $?"
    return 1
  else
    return 0
  fi
}

run command1 && run command2 && run command3

6
不要运行 $*,如果任何参数中包含空格,它将失败;改用 "$@"。 (虽然在 echo 命令中使用 $* 是可以的。) - Gordon Davisson

8

我已经在Bash中开发了几乎完美的try & catch实现,使您可以编写类似以下方式的代码:

try 
    echo 'Hello'
    false
    echo 'This will not be displayed'

catch 
    echo "Error in $__EXCEPTION_SOURCE__ at line: $__EXCEPTION_LINE__!"

甚至可以将try-catch块嵌套在自身内部!

try {
    echo 'Hello'

    try {
        echo 'Nested Hello'
        false
        echo 'This will not execute'
    } catch {
        echo "Nested Caught (@ $__EXCEPTION_LINE__)"
    }

    false
    echo 'This will not execute too'

} catch {
    echo "Error in $__EXCEPTION_SOURCE__ at line: $__EXCEPTION_LINE__!"
}

该代码是我bash模板/框架的一部分。它进一步扩展了try和catch的思想,并添加了回溯和异常处理等功能(以及其他一些不错的特性)。
以下是仅负责try & catch的代码:
set -o pipefail
shopt -s expand_aliases
declare -ig __oo__insideTryCatch=0

# if try-catch is nested, then set +e before so the parent handler doesn't catch us
alias try="[[ \$__oo__insideTryCatch -gt 0 ]] && set +e;
           __oo__insideTryCatch+=1; ( set -e;
           trap \"Exception.Capture \${LINENO}; \" ERR;"
alias catch=" ); Exception.Extract \$? || "

Exception.Capture() {
    local script="${BASH_SOURCE[1]#./}"

    if [[ ! -f /tmp/stored_exception_source ]]; then
        echo "$script" > /tmp/stored_exception_source
    fi
    if [[ ! -f /tmp/stored_exception_line ]]; then
        echo "$1" > /tmp/stored_exception_line
    fi
    return 0
}

Exception.Extract() {
    if [[ $__oo__insideTryCatch -gt 1 ]]
    then
        set -e
    fi

    __oo__insideTryCatch+=-1

    __EXCEPTION_CATCH__=( $(Exception.GetLastException) )

    local retVal=$1
    if [[ $retVal -gt 0 ]]
    then
        # BACKWARDS COMPATIBILE WAY:
        # export __EXCEPTION_SOURCE__="${__EXCEPTION_CATCH__[(${#__EXCEPTION_CATCH__[@]}-1)]}"
        # export __EXCEPTION_LINE__="${__EXCEPTION_CATCH__[(${#__EXCEPTION_CATCH__[@]}-2)]}"
        export __EXCEPTION_SOURCE__="${__EXCEPTION_CATCH__[-1]}"
        export __EXCEPTION_LINE__="${__EXCEPTION_CATCH__[-2]}"
        export __EXCEPTION__="${__EXCEPTION_CATCH__[@]:0:(${#__EXCEPTION_CATCH__[@]} - 2)}"
        return 1 # so that we may continue with a "catch"
    fi
}

Exception.GetLastException() {
    if [[ -f /tmp/stored_exception ]] && [[ -f /tmp/stored_exception_line ]] && [[ -f /tmp/stored_exception_source ]]
    then
        cat /tmp/stored_exception
        cat /tmp/stored_exception_line
        cat /tmp/stored_exception_source
    else
        echo -e " \n${BASH_LINENO[1]}\n${BASH_SOURCE[2]#./}"
    fi

    rm -f /tmp/stored_exception /tmp/stored_exception_line /tmp/stored_exception_source
    return 0
}

欢迎自由使用、复制和贡献——这个项目在GitHub上。


1
我看了一下这个仓库,不会自己使用,因为对我来说有太多的魔法(在我看来,如果需要更多的抽象能力,最好使用Python),但是绝对给它一个大大的赞,因为它看起来真的很棒。 - Alexander Malakhov
感谢您的赞美,@AlexanderMalakhov。我同意关于“魔法”数量的观点 - 这也是我们正在进行头脑风暴的简化3.0版本框架的原因之一,这将更容易理解、调试等。在 GH 上有一个关于 3.0 的开放问题,如果您想要提出自己的想法,请参与讨论。 - niieani

3

很抱歉,我无法对第一个回答进行评论,但你应该使用新实例来执行命令:cmd_output=$($@)

#!/bin/bash

function check_exit {
    cmd_output=$($@)
    local status=$?
    echo $status
    if [ $status -ne 0 ]; then
        echo "error with $1" >&2
    fi
    return $status
}

function run_command() {
    exit 1
}

check_exit run_command

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接