Shell脚本的设计模式或最佳实践

184

有人知道针对 shell 脚本(sh、bash 等)的最佳实践或设计模式的任何资源吗?


3
昨晚我写了一篇关于BASH中的模板模式的小文章,看看你觉得怎么样。 - quickshiftin
8个回答

238
我写了相当复杂的Shell脚本,我的第一个建议是“不要”。原因是很容易犯小错误从而使你的脚本受阻,甚至变得危险。
话虽如此,我没有其他资源可以传授给你,只有我的个人经验。这是我通常做的事情,虽然有点过度,但往往比较可靠,尽管非常冗长。
调用
让你的脚本接受长选项和短选项。要小心,因为有两个命令来解析选项,即getopt和getopts。使用getopt会遇到更少的问题。
CommandLineOptions__config_file=""
CommandLineOptions__debug_level=""

getopt_results=`getopt -s bash -o c:d:: --long config_file:,debug_level:: -- "$@"`

if test $? != 0
then
    echo "unrecognized option"
    exit 1
fi

eval set -- "$getopt_results"

while true
do
    case "$1" in
        --config_file)
            CommandLineOptions__config_file="$2";
            shift 2;
            ;;
        --debug_level)
            CommandLineOptions__debug_level="$2";
            shift 2;
            ;;
        --)
            shift
            break
            ;;
        *)
            echo "$0: unparseable option $1"
            EXCEPTION=$Main__ParameterException
            EXCEPTION_MSG="unparseable option $1"
            exit 1
            ;;
    esac
done

if test "x$CommandLineOptions__config_file" == "x"
then
    echo "$0: missing config_file parameter"
    EXCEPTION=$Main__ParameterException
    EXCEPTION_MSG="missing config_file parameter"
    exit 1
fi

另一个重要的点是,如果程序成功完成,应该始终返回零,如果出现错误,则返回非零。

函数调用

在bash中,您可以调用函数,只需记得在调用之前定义它们。函数就像脚本一样,它们只能返回数值。这意味着您必须发明一种不同的策略来返回字符串值。我的策略是使用名为RESULT的变量存储结果,并且如果函数干净地完成,则返回0。 此外,如果返回值与零不同,则可以引发异常,然后设置两个“异常变量”(我的是:EXCEPTION和EXCEPTION_MSG),第一个包含异常类型,第二个包含可读性的消息。

当您调用函数时,函数的参数被分配给特殊变量$0,$1等。我建议您将它们放入更有意义的名称中。在函数内部声明变量时,请将它们声明为局部变量:

function foo {
   local bar="$0"
}

容易出错的情况

在Bash中,除非你声明否则,未设置的变量将被使用为空字符串。这样很危险,如果有拼写错误,错误的变量不会被报告,而且会被评估为空。请使用

set -o nounset

为了防止这种情况发生。但要注意,因为如果您这样做,每次评估未定义的变量时程序将中止。因此,检查变量是否未定义的唯一方法是以下方式:
if test "x${foo:-notset}" == "xnotset"
then
    echo "foo not set"
fi

您可以将变量声明为只读:

readonly readonly_var="foo"

模块化

如果你使用以下代码,你可以实现类似 Python 的模块化:

set -o nounset
function getScriptAbsoluteDir {
    # @description used to get the script path
    # @param $1 the script $0 parameter
    local script_invoke_path="$1"
    local cwd=`pwd`

    # absolute path ? if so, the first character is a /
    if test "x${script_invoke_path:0:1}" = 'x/'
    then
        RESULT=`dirname "$script_invoke_path"`
    else
        RESULT=`dirname "$cwd/$script_invoke_path"`
    fi
}

script_invoke_path="$0"
script_name=`basename "$0"`
getScriptAbsoluteDir "$script_invoke_path"
script_absolute_dir=$RESULT

function import() { 
    # @description importer routine to get external functionality.
    # @description the first location searched is the script directory.
    # @description if not found, search the module in the paths contained in $SHELL_LIBRARY_PATH environment variable
    # @param $1 the .shinc file to import, without .shinc extension
    module=$1

    if test "x$module" == "x"
    then
        echo "$script_name : Unable to import unspecified module. Dying."
        exit 1
    fi

    if test "x${script_absolute_dir:-notset}" == "xnotset"
    then
        echo "$script_name : Undefined script absolute dir. Did you remove getScriptAbsoluteDir? Dying."
        exit 1
    fi

    if test "x$script_absolute_dir" == "x"
    then
        echo "$script_name : empty script path. Dying."
        exit 1
    fi

    if test -e "$script_absolute_dir/$module.shinc"
    then
        # import from script directory
        . "$script_absolute_dir/$module.shinc"
    elif test "x${SHELL_LIBRARY_PATH:-notset}" != "xnotset"
    then
        # import from the shell script library path
        # save the separator and use the ':' instead
        local saved_IFS="$IFS"
        IFS=':'
        for path in $SHELL_LIBRARY_PATH
        do
            if test -e "$path/$module.shinc"
            then
                . "$path/$module.shinc"
                return
            fi
        done
        # restore the standard separator
        IFS="$saved_IFS"
    fi
    echo "$script_name : Unable to find module $module."
    exit 1
} 

接下来,您可以使用以下语法导入扩展名为.shinc的文件

import "AModule/ModuleFile"

文件将在SHELL_LIBRARY_PATH中搜索。由于您总是在全局命名空间中导入,请记得为所有函数和变量添加适当的前缀,否则可能会出现名称冲突。我使用双下划线作为Python点。

此外,请将此作为模块中的第一件事。

# avoid double inclusion
if test "${BashInclude__imported+defined}" == "defined"
then
    return 0
fi
BashInclude__imported=1

面向对象编程

在bash中,你不能做面向对象编程,除非你构建一个相当复杂的对象分配系统(我曾考虑过这个想法,但它是可行的,却是疯狂的)。 实际上,你可以进行“单例模式编程”:你只有每个对象的一个实例。

我的做法是:我在一个模块中定义一个对象(请参见模块化入口)。然后我定义空变量(类似于成员变量)、一个 init 函数(构造函数)和成员函数,就像这个示例代码。

# avoid double inclusion
if test "${Table__imported+defined}" == "defined"
then
    return 0
fi
Table__imported=1

readonly Table__NoException=""
readonly Table__ParameterException="Table__ParameterException"
readonly Table__MySqlException="Table__MySqlException"
readonly Table__NotInitializedException="Table__NotInitializedException"
readonly Table__AlreadyInitializedException="Table__AlreadyInitializedException"

# an example for module enum constants, used in the mysql table, in this case
readonly Table__GENDER_MALE="GENDER_MALE"
readonly Table__GENDER_FEMALE="GENDER_FEMALE"

# private: prefixed with p_ (a bash variable cannot start with _)
p_Table__mysql_exec="" # will contain the executed mysql command 

p_Table__initialized=0

function Table__init {
    # @description init the module with the database parameters
    # @param $1 the mysql config file
    # @exception Table__NoException, Table__ParameterException

    EXCEPTION=""
    EXCEPTION_MSG=""
    EXCEPTION_FUNC=""
    RESULT=""

    if test $p_Table__initialized -ne 0
    then
        EXCEPTION=$Table__AlreadyInitializedException   
        EXCEPTION_MSG="module already initialized"
        EXCEPTION_FUNC="$FUNCNAME"
        return 1
    fi


    local config_file="$1"

      # yes, I am aware that I could put default parameters and other niceties, but I am lazy today
      if test "x$config_file" = "x"; then
          EXCEPTION=$Table__ParameterException
          EXCEPTION_MSG="missing parameter config file"
          EXCEPTION_FUNC="$FUNCNAME"
          return 1
      fi


    p_Table__mysql_exec="mysql --defaults-file=$config_file --silent --skip-column-names -e "

    # mark the module as initialized
    p_Table__initialized=1

    EXCEPTION=$Table__NoException
    EXCEPTION_MSG=""
    EXCEPTION_FUNC=""
    return 0

}

function Table__getName() {
    # @description gets the name of the person 
    # @param $1 the row identifier
    # @result the name
    
    EXCEPTION=""
    EXCEPTION_MSG=""
    EXCEPTION_FUNC=""
    RESULT=""
    
    if test $p_Table__initialized -eq 0
    then
        EXCEPTION=$Table__NotInitializedException
        EXCEPTION_MSG="module not initialized"
        EXCEPTION_FUNC="$FUNCNAME"
        return 1
    fi
    
    id=$1
      
      if test "x$id" = "x"; then
          EXCEPTION=$Table__ParameterException
          EXCEPTION_MSG="missing parameter identifier"
          EXCEPTION_FUNC="$FUNCNAME"
          return 1
      fi
    
    local name=`$p_Table__mysql_exec "SELECT name FROM table WHERE id = '$id'"`
      if test $? != 0 ; then
        EXCEPTION=$Table__MySqlException
        EXCEPTION_MSG="unable to perform select"
        EXCEPTION_FUNC="$FUNCNAME"
        return 1
      fi
    
    RESULT=$name
    EXCEPTION=$Table__NoException
    EXCEPTION_MSG=""
    EXCEPTION_FUNC=""
    return 0
}

信号的捕获与处理

我发现这对于捕获和处理异常非常有用。

function Main__interruptHandler() {
    # @description signal handler for SIGINT
    echo "SIGINT caught"
    exit
} 
function Main__terminationHandler() { 
    # @description signal handler for SIGTERM
    echo "SIGTERM caught"
    exit
} 
function Main__exitHandler() { 
    # @description signal handler for end of the program (clean or unclean). 
    # probably redundant call, we already call the cleanup in main.
    exit
} 
    
trap Main__interruptHandler INT
trap Main__terminationHandler TERM
trap Main__exitHandler EXIT

function Main__main() {
    # body
}

# catch signals and exit
trap exit INT TERM EXIT

Main__main "$@"

提示和技巧

如果某些原因导致某些东西无法正常工作,请尝试重新排列代码。顺序很重要,而且并不总是直观的。

请不要考虑使用tcsh。它不支持函数,并且总体上很糟糕。

请注意:如果您必须使用我在这里写的东西,这意味着您的问题过于复杂,不能用shell解决。请使用另一种语言。我不得不使用它,是由于人为因素和遗留问题。


8
哇,我原本以为我的Bash脚本有点过头了...... 我倾向于使用隔离的函数和滥用子Shell(因此当速度变得重要时,我会遭受苦难)。永远不使用全局变量,无论是内部还是外部(为了保留剩余的理智)。所有返回都通过stdout或文件输出。设置-u/设置-e(遗憾的是,只要第一个if出现,设置-e就会变得无效,而且我大部分的代码通常都在那里)。函数参数采用[local something="$1"; shift](允许在重构时轻松重新排序)。完成了一个3000行的bash脚本后,我倾向于按照这种方式编写最小的脚本... - Eugene
对于模块化,需要进行一些小的修正: 1 在 ". "$script_absolute_dir/$module.shinc" 后面加上一个 return,以避免丢失警告。 2 在找到 $SHELL_LIBRARY_PATH 中的模块之前,必须设置 IFS="$saved_IFS",然后再进行返回。 - Duff
“人为因素”是最糟糕的因素。当你给它们更好的东西时,机器不会与你争斗。 - jeremyjjbrown
1
为什么要使用 getopt 而不是 getopts?因为 getopts 更加可移植,可以在任何 POSIX shell 中运行。特别是由于这个问题是关于 shell 最佳实践 而不是特定于 bash 的最佳实践,我支持遵循 POSIX 标准以支持尽可能多的 shell。 - Wimateeka
1
感谢您提供有关Shell脚本的所有建议,即使您很诚实:“希望它能帮到您,但请注意。如果您必须使用我在这里写的东西,那么意味着您的问题太复杂,无法用Shell解决,请使用另一种语言。我不得不使用它是由于人为因素和遗留问题。” - dieHellste
显示剩余2条评论

26

看一下高级Bash脚本指南,了解有关shell脚本的许多智慧-不仅仅是Bash。

不要听信那些告诉你去看其他较复杂语言的人。如果shell脚本满足您的需求,请使用它。您需要的是功能性而不是花哨。新语言为您的简历提供了有价值的新技能,但如果您已经知道如何使用shell,并且有工作需要完成,那就没有帮助了。

正如所述,对于shell脚本没有很多“最佳实践”或“设计模式”。不同的用途有不同的指导方针和偏见-就像任何其他编程语言一样。


9
请注意,对于即使是轻微复杂的脚本,这不是最佳实践。编码不仅仅是让某些东西工作,它还包括快速构建、易于编写和可靠性强,在任何逻辑上都易于重用且易于阅读和维护(特别是对于他人来说)。Shell脚本在任何级别上都不具备很好的扩展性。更健壮的语言对于任何具有任何逻辑的项目都更加简单。 - drifter

20

Shell脚本是一种设计用于操作文件和进程的语言。虽然它非常适合于此,但不是一种通用的语言,因此始终尝试通过使用现有工具的逻辑来连接代码,而不是在shell脚本中重新创建新的逻辑。

除了这个通用原则外,我还收集了一些常见的shell脚本错误


13

知道何时使用它。 对于快速且粗略的命令粘合,这是可以的。如果需要做出任何超过少数非平凡决策、循环等内容,请选择Python、Perl和模块化

Shell脚本的最大问题通常是最终结果看起来像一个巨大的混乱,在4000行的Bash代码中不断增长......而你无法摆脱它,因为现在你整个项目都依赖于它。当然,它最初只有40行美丽的Bash代码


12

10

使用set -e,这样在出现错误后就不会继续执行。如果你希望它可以在非Linux系统上运行,请尝试使其兼容sh而不是仅依赖于bash。


9

简单易懂: 使用Python代替Shell脚本。 这样可以使可读性提高近100倍,而无需复杂化任何不必要的事情,并保留将脚本的部分演变为函数、对象、持久对象(ZODB)、分布式对象(Pyro)的能力,几乎不需要额外的代码。


8
你自相矛盾,一方面说“无需使事情复杂”,另一方面列举了你认为增加价值的各种复杂性,而在大多数情况下,这些复杂性被滥用成了丑陋的怪物,而不是用来简化问题和实现。 - Evgeny
3
这意味着一个很大的缺点,你的脚本在没有安装Python的系统上将无法运行。 - astropanic
1
我意识到这个问题在'08年已经有了答案(现在距离'12年还有两天);但是,对于那些几年后才看到这个问题的人,我要警告大家不要放弃像Python或Ruby这样的语言,因为它们更容易获得,并且如果没有,安装也只需一个命令(或几个点击)即可。如果您需要更多的可移植性,请考虑用Java编写程序,因为很难找到一台没有JVM可用的计算机。 - Wil Moore III
@astropanic 目前几乎所有带有 Python 的 Linux 端口。 - Pithikos
@Pithikos,当然,还要折腾Python2和Python3的麻烦。现在我用Go编写所有的工具,感觉非常满意。 - astropanic
Python 可能是我最喜欢的语言,但当主要需求是操作文件和进程并调用其他工具时,与 shell 脚本相比,Python 冗长且繁琐。当然,可以考虑使用 Python(或 Ruby 或 Go 等),但是 shell 也有其位置和作用。 - Soren Bjornstad

7
要寻找一些“最佳实践”,看看Linux发行版(例如Debian)如何编写它们的init脚本(通常在/ etc / init.d中找到)。其中大部分都没有“bash-isms”,并且具有良好的配置设置、库文件和源格式的分离。
我的个人风格是编写一个主shell脚本,定义一些默认变量,然后尝试加载(“source”)可能包含新值的配置文件。
我尽量避免使用函数,因为它们往往会使脚本更加复杂。(Perl就是为此而创建的。)
为了确保脚本是可移植的,请不仅测试#!/bin/sh,还要使用#!/bin/ash,#!/bin/dash等。你很快就会发现Bash特定的代码。

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