ZMonster's Blog 巧者劳而智者忧,无能者无所求,饱食而遨游,泛若不系之舟

Shell脚本中参数处理方法

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

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

这两天写了一个安全删除的脚本,原理就是将指定的文件移动到某个特定的目录下并保存其原始路径信息,这和在Windows下以及在Linux的桌面环境下"将文件移动到回收站"的意义是一样的。就拿这个来做例子吧。

在这个脚本中,有五个选项,分别代表五种动作:

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

手工解析

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

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'这个命令,这个命令的作用是将参数列表以空格为分隔符左移一个单位,或者可以理解为将第一个参数给去掉了,比如获取的命令行参数为:

-d hello.txt

在执行了'shift'后,命令行参数就变成了

hello.txt

这样,在使用了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'会稍微方便一点。

当然,上面的处理没有进行参数检查,这些检查应该要防止这些错误情况:参数个数为0、完全冲突的"动作"一起出现、选项需要值但未给值。

getopts

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

getopts <opt_string> <optvar> <arguments>

下面是在Shell中使用该命令的一个示例:

getopts.gif

本质上来说,'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',对需要值的选项,也得手动去取出值。

下面是在Shell中使用'getopt'的一个示例:

getopt.gif

可以看到,'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 $@

就是这样啦!