Shell脚本中参数处理方法

Published by rcdfrd on 2022-04-01

Shell 脚本中参数处理方法

在 Shell 脚本中处理命令行参数,可以使用 getopts/getopt 来进行——当然,手工解析也是可以的。

下面通过一个特定的情景来讲一下这三种参数处理方法。

手工解析

所谓的手工解析,就是取到参数后手工一个一个解析了,以下是手工解析上述情景参数的过程:

while [ $# -gt 0 ];do
    case $1 in
        -d)
            shift
            file_to_trash=$1
            trash $file_to_trash # trash is a function
            ;;
        -l)
            print_trashed_file  # print_trashed_file is a function
            ;;
        -b)
            shift
            file_to_untrash=$1
            untrash $file_to_untrash # untrash is a function
            ;;
        -c)
            clean_all           # clean all is a function
            ;;
        -h)
            usage
            exit 0
            ;;
        \?)
            usage
            exit 1
            ;;
    esac
done

这样,在使用了 shift 后,我们每次都只要去看参数列表中的第一个就行了。当然,其实不用'shift'也是可以的,比如说这样:

i=1
while [ $i -le $# ];do
    case ${!i} in
        -d)
            i=$(expr $i + 1)
            file_to_trash=${!i}
            trash $file_to_trash # trash is a function
            ;;
        -l)
            print_trashed_file  # print_trashed_file is a function
            ;;
        -b)
            i=$(expr $i + 1)
            file_to_untrash=${!i}
            untrash $file_to_untrash # untrash is a function
            ;;
        -c)
            clean_all           # clean all is a function
            ;;
        -h)
            usage
            exit 0
            ;;
        \?)
            usage
            exit 1
            ;;
    esac
    i=$(expr $i + 1)
done

这样,在使用了 shift 后,我们每次都只要去看参数列表中的第一个就行了。当然,其实不用'shift'也是可以的,比如说这样:

i=1
while [ $i -le $# ];do
    case ${!i} in
        -d)
            i=$(expr $i + 1)
            file_to_trash=${!i}
            trash $file_to_trash # trash is a function
            ;;
        -l)
            print_trashed_file  # print_trashed_file is a function
            ;;
        -b)
            i=$(expr $i + 1)
            file_to_untrash=${!i}
            untrash $file_to_untrash # untrash is a function
            ;;
        -c)
            clean_all           # clean all is a function
            ;;
        -h)
            usage
            exit 0
            ;;
        \?)
            usage
            exit 1
            ;;
    esac
    i=$(expr $i + 1)
done

getopts

'getopts'是 POSIX Shell 中内置的一个命令,其使用方法是:

getopts <opt_string> <optvar> <arguments>

本质上来说,'getopts'的处理和我们手工处理是差不多的,它不过是提供了更便利的方式而已。它的使用方式非常简单明了,其形式为:

while getopts <opt_string> <optvar>
    case $<optvar> in
        # ...
    esac
done

其中 <opt_string> 是要处理的选项的一个集合,每个选项在其中用不包含连字符'-'的字母来表示,每个代表选项的字母前后可以有一个冒号,前面有冒号表示当处理该选项出错时不输出'getopts'自身产生的错误信息,这方便我们自己编写对应的错误处理方法;后面的冒号表示这个选项需要一个值。对于我们这个"安全删除"的例子,这个 <opt_string> 应该是:

d:lb:ch

冒号的归属的话,先到先得吧,大概是这样。

在使用'getopts'时,有两个特殊的变量,它们是 OPTINDOPTARG ,前者表示当前参数在参数列表中的位置——相当于手工解析第二种方法中那个自定义的变量 i ,其值初始时为 1, 会在每次取了选项以及其值(如果有的话)后更新; OPTARG 则是在选项需要值时,存储这个选项对应的值。这样,我们这个例子用'getopts'就可以写成:

while getopts d:lb:ch OPT;do
    case $OPT in
        d)
            file_to_trash=$OPTARG
            trash $file_to_trash # trash is a function
            ;;
        l)
            print_trashed_file  # print_trashed_file is a function
            ;;
        b)
            file_to_untrash=$OPTARG
            untrash $file_to_untrash # untrash is a function
            ;;
        c)
            clean_all           # clean all is a function
            ;;
        h)
            usage
            exit 0
            ;;
        \?)
            usage
            exit 1
            ;;
    esac
done

对比可以看到,相比手工解析的第一种办法,又更为简洁一点了。不过需要注意的是,'getopts'会从第一个参数开始,只按照 <opt_string> 指定的形式来寻找并解析参数,如果给出的实际命令行参数与其所描述的参数形式不符,则会出错中止。

比如说,对于上面的例子,假设这个脚本已经完全写好了,脚本名为 trash.sh ,其参数处理就是上面这样,那么如果我在终端里执行:

./trash.sh a -b hello.txt

开始那个多余的参数'a'将会导致'getopts'在解析到选项'-b'前就出错终止。所以呢,像使用'getopts'这样的方法,其自由度不如手工解析,如果要保证脚本在任何情况下都能正确解析参数,它需要多做一点——当然啦,上面这个愚蠢的错误使用情况还是比较少出现的啦,反正我现在写的脚本里压根没考虑这样的情况。

getopt

'getopt'与'getopts'类似,不过'getopts'只能处理短选项,'getopt'则能处理短选项和长选项。所谓的短选项就是类似下面这样的选项:

-a

而下面这样的则是长选项

--action=delete

当然,事无绝对,通过一些技巧,用'getopts'处理长选项也是可能的。这里先说一下如何用'getopt'来处理参数吧。

需要事先说明的一点是,'getopt'不是 Shell 内建的命令,而是'util-linux'这个软件包提供的功能,它不是 POSIX 标准的一部分,所以也有人建议不使用'getopt'

首先将之前说到的五种动作对应的短选项扩展一下,以便讲解'getopt'的使用:

  1. -d/–delete : 将文件移动到回收站,该选项后需要指定一个文件或目录名
  2. -l/–list : 列出被移动到回收站的文件及其 id,该选项不需要值
  3. -b/–back : 恢复被移动到回收站的文件,该选项需要指定一个文件对应的 id
  4. -c/–clear : 清空回收站,该选项不需要值
  5. -h/–help : 打印帮助信息

'getopt'既能处理短选项也能处理长选项,短选项通过参数 -o 指定,长选项通过参数 -l 指定。同'getopts'一样,它一次也只解析一个选项,所以也需要循环处理,不过与'getopts'不同的是,'getopt'没有使用 OPTINDOPTARG 这两个变量,所以我们还得手动对参数进行'shift',对需要值的选项,也得手动去取出值。

可以看到,'getopt'将参数中以下形式的内容:

--longopt=argument

在返回结果中替换成下面这样的形式:

--longopt argument

这样就可以通过循环和'shift'来进行处理了,不过在脚本中,'shift'命令是对命令行参数起作用的,即特殊变量"$@",而我们在脚本中只能将'getopt'的返回结果作为字符串存储到一个变量中。为了让'shift'起作用,通常还要使用'set'命令来将变量的值赋给"$@"这个特殊变量。

真是有够麻烦的……算了,下面再集中吐槽吧……

然后,在设置好短选项和长选项后,在将实际的参数传给'getopt'时,要在实际参数前加上一个两个连字符 -- ,而'getopt'会将这两个连字符放到返回结果的最后面,在处理时可以将这两个连字符视为结束标志。

以下是针对本文假设的情景,使用'getopt'解析参数的流程:

arg=$(getopt -o d:lb:ch -l delete:,list,back:,clear,help -- $@)

set -- "$arg"

while true
do
    case $1 in
        -d|--delete)
            file_to_trash=$2
            trash $file_to_trash # trash is a function
            shift 2
            ;;
        -l|--list)
            print_trashed_file  # print_trashed_file is a function
            shift
            ;;
        -b|--back)
            file_to_untrash=$2
            untrash $file_to_untrash # untrash is a function
            shift
            ;;
        -c|--clear)
            clean_all           # clean all is a function
            shift
            ;;
        -h|--help)
            usage
            exit 0
            ;;
        --)
            shift
            break
            ;;
    esac
done

然而,知道了'getopt'的使用及其原理后,自然而然地可以发现,我可以不用去管这个结束标志,用"$#"这个表示参数个数的特殊变量,同样可以控制参数解析的流程,这完全和手工解析是同一个道理。我甚至可以将'getopt'的返回结果存储到一个数组里,直接循环处理这个数组,而不用使用'set'命令了。

好了,吐槽时间。

我之前写脚本都是用的'getopts',一来我用不上长选项,二来'getopts'的使用足够简单。在写本文之前,我倒是知道'getopt'可以处理长选项,但没仔细了解过。这两天了解了一下,觉得还是别用'getopt'的好,理由如下:

  1. 'getopt'不是 Shell 内建命令,跨平台使用时可能会出现问题;

  2. 只是将'–longopt=val'这样的参数形式替换成了'–longopt val',但因此增加了许多复杂性,比如使用了'set'命令,在使用'set'命令时还要考虑'getopt'的返回结果中有无 Shell 命令,有的话应该使用'eval'命令来消除可能导致的错误

    eval set -- "$arg"
    
  3. 调用完还要进行与手工解析类似的工作,相比手工解析,并没有多大优势;

  4. 真的需要长选项吗?我觉得短选项就足够了

getopts 处理长选项

既然不建议使用'getopt',那么怎么处理长选项呢?自然是有办法的。

为了方便讲解,这里假设一个简单的情景吧,在这个情景里,我们只需要处理两个可能的选项

  1. -f/–file: 设置文件名,该选项需要值
  2. -h/–help: 打印帮助信息,该选项不需要值

用'getopts'处理这种情况,可以这么做:

filename=""
while getopts f:h-: opt;do
    case $opt in
        -)
            case $OPTARG in
                help)
                    usage
                    exit 0
                    ;;
                file=*)
                    filename=${OPTARG#*=}
                    ;;
            esac
            ;;
        f)
            filename=$OPTARG
            ;;
        h)
            usage
            exit 0
            ;;
        \?)
            usage
            exit 1
            ;;
    esac
done

当然,也许并不比手工解析简洁多少,但用起来肯定是比'getopt'要舒服的。

在函数中解析参数

有时候,我们也许想把参数解析的工作放到函数中去做,比如说定义了一个'main'函数然后在'main'函数中封装整个流程处理逻辑。又或者像我一样,写了几个小小的工具函数,放到了 Bash 的配置文件 .bashrc 中,参数解析的工作必须得在函数中做。

手工解析是能想到的最直接的办法,简单可行。

不过假如我们想用'getopts'来处理呢?动手尝试后,你会发现直接在函数中使用'getopts'是会出错的。要在函数中使用'getopts',必须在这个函数中使用'getopts'前,将 OPTIND 这个被'getopts'使用的特殊变量设置为函数局部变量,像这样:

function main() {

    local OPTIND

    while getopts d:lb:ch OPT;do
       case $OPT in
           d)
               file_to_trash=$OPTARG
               trash $file_to_trash # trash is a function
               ;;
           l)
               print_trashed_file  # print_trashed_file is a function
               ;;
           b)
               file_to_untrash=$OPTARG
               untrash $file_to_untrash # untrash is a function
               ;;
           c)
               clean_all           # clean all is a function
               ;;
           h)
               usage
               exit 0
               ;;
           \?)
               usage
               exit 1
               ;;
       esac
    done
}

main $@

就是这样啦!