Bash 中的高级参数处理
在上一篇文章中,我们介绍了Bash 脚本中处理参数的基础知识。但这些基础知识只能帮你到此为止。如果你想构建一个拥有美观、精致的用户界面,并包含多个长短选项、标志和参数的脚本,该怎么办?本文将介绍一些以健壮、可维护、可读的方式处理这些更复杂情况的技巧。
1. 参数替换
有一整类功能可以增强您对参数的控制:强制参数、提供默认值、替代值以及修改已提供的值。它们都采用 的形式"${variable_name_with_funky_symbols}"
。所有这些功能与 之类的位置参数一起使用都非常方便$1, $2, ...
,但实际上它们可以在脚本中的任何位置用于任何变量。
1.1. 必需值:${1:?Error Message}
它尝试评估指定的变量,但如果变量未设置或为空,它将退出脚本并显示提供的错误消息。退出代码将为失败。
name="${1:?"A name is required."}"
echo "Hello, $name!"
我见过一些脚本将这种技术与:
命令结合起来,但并不执行任何操作,以此来确保参数存在,而无需将它们存储在命名变量中。
# This script expects 3 side lengths of a triangle
: ${1:?"Missing side 1"}
: ${2:?"Missing side 2"}
: ${3:?"Missing side 3"}
if [[ "$1" -eq "$2" && "$2" -eq "$3" ]]; then
echo "Equilateral Triangle"
fi
如果这让您感到焦虑,这里有一些有关该:
命令的更多信息。
大多数此类操作都有一种替代形式,即括号内不包含冒号:
${1?Error Message}
。此版本工作原理相同,但只要变量已设置,即使变量为空也不会引发错误消息。此版本通常无法提供您想要的安全性,但我还是想提一下。
1.2. 默认值:${1:-default}
当您想帮助新用户默认做出正确的选择并使更常见的情况变得更容易时,这很有用。
name="${1:-World}"
echo "Hello, $name!"
$ ./greet "Ryan"
Hello, Ryan!
$ ./greet
Hello, World!
1.3. 指定默认值:${var:=value}
与上面的默认值非常相似。不同之处在于,这会将值赋给该变量——该:-
版本只执行一次替换。
$ echo "${username:=supercoolfriend}" # username was undefined before this
supercoolfriend
$ echo "$username" # Now it's defined! The value sticks!
supercoolfriend
需要注意的是,由于您不能以正常方式为位置参数分配值,因此您不能将此方法与一起使用$1, $2, ...
。
# This won't work
$1=banana
# So, neither will this
echo ${1:=banana}
我还没有看到真正令人信服的用例,所以如果你以一种巧妙的方式使用了它,请告诉我!
1.4. 替代值:${1:+value}
这又是一个我从未真正用过的操作。但它确实是一个比较有趣的操作,因为它的意思是“如果提供了变量,则忽略该值并使用我们的值”。
$ name="Ryan"
$ echo "${name:+Shaq}"
Shaq
$ unset name
$ echo "${name:+Shaq}"
# Empty string
1.5. 还有更多!
上面的示例与处理变量有关,无论变量是否已提供,以及变量是否有值。这类变量/括号功能还有很多,但它们更侧重于修改/操作现有变量,所以我想把它们留到以后的“Bash 中的字符串操作”文章中再讲。同时,每当我忘记确切的语法时,我都会参考这份文档。
2. 让shift
我们
就其本身而言,shift
命令1本身并没有什么特别之处,但我们会将它与下面的一些想法结合起来,所以我觉得现在介绍一下它会很不错。读完我上一篇文章后,你显然已经是使用位置参数的经验丰富的高手了:$1, $2, $3, ...
。没问题!
shift
获取这些参数并删除第一个,将其余每个参数向下移动一位。
echo "$# arguments: $1 $2 $3 $4"
# 4 arguments: nick nack patty whack
shift
# drop the first one. Now we have "nack" "patty" "whack"
echo "$1"
# nack
shift 2
# drop two more! Now we have "whack"
echo "$1"
# whack
3. 处理选项
在某些时候,你会需要选项和标志来调整脚本的工作方式。Bash 提供了一个很棒的内置命令,名为 ,getopts
它提供了一个框架来定义哪些参数包含参数,以及在出现错误时该如何处理。但它也有一些缺点,因为它比普通的 while/case 语句更不透明/隐晦,而且灵活性也稍差。因此,根据你的需要,你可以选择编写自己的参数处理循环。我将在这里展示这两个选项,以便你在需要时参考它们。
3.1. getopts
这是循环的基本布局getopts
。我会给你看一个例子,然后逐一讲解每个部分。
#!/usr/bin/env bash
function usage() {
echo "Usage: $0 [-i] [-v*] [-h] [-f FILENAME] [-t TIMES] <word>"
}
VERBOSE=0
INTERACTIVE=
FILENAME=memo.txt
TIMES=1
while getopts "ivhf:t:" opt; do
case "$opt" in
i) INTERACTIVE=1;;
v) (( VERBOSE++ ));;
h) usage && exit 0;;
f) FILENAME="$OPTARG";;
t) TIMES="$OPTARG";;
esac
done
shift $(( OPTIND - 1 ))
name=${1:?$( usage )}
if [[ "$TIMES" -le 0 ]]; then
echo "TIMES must be a positive integer." >&2
exit 1
fi
if [[ -n "$INTERACTIVE" ]]; then
echo "Are you sure you want to party?"
read -r -p"[yn] " answer
if [[ "$answer" != y ]]; then
echo "Exiting."
exit 0
fi
fi
printf "We are being "
for (( i=0; i<VERBOSE; i++ )); do
printf "very "
done
printf "verbose.\n"
for (( i=0; i<TIMES; i++ )); do
echo "Hello, $name!" >> $FILENAME
done
echo "Complete!"
exit 0
呼!深呼吸。
3.1.1. 主选项循环
好的!这里有很多内容需要讲解,所以我们开始吧。前几行代码用于设置,获取我们将要用到的标志、变量和函数的占位符。本例的核心内容如下:
while getopts "ivhf:t:" opt; do
case "$opt" in
i) INTERACTIVE=1;;
v) (( VERBOSE++ ));;
h) usage && exit 0;;
f) FILENAME="$OPTARG";;
t) TIMES="$OPTARG";;
esac
done
shift $(( OPTIND - 1 ))
name=${1:?$( usage )}
在 while 循环中,我们调用getopts
.getopts
来处理逐个传递的参数。 的第一个参数getopts
是一个字符串,它列出了我们期望的选项以及哪些选项需要参数。如果某个选项接受/需要参数,我们会在其字母后添加一个冒号。第二个参数是一个变量名,用于保存当前正在处理的字母选项。
3.1.2. 匹配选项
现在,我们来讨论一下循环内的 case 语句。
case "$opt" in
i) INTERACTIVE=1;;
v) (( VERBOSE++ ));;
h) usage && exit 0;;
f) FILENAME="$OPTARG";;
t) TIMES="$OPTARG";;
esac
在这里,我们根据选项决定要做什么。请注意,如何将字母前的“ ”getopts
去掉-
。case 语句本身看起来可能有点吓人。它有很多奇怪的语法,在其他地方根本看不到。查看这些示例,了解更多关于如何使用 case 语句的信息。
您还会注意到,对于任何接受参数的选项,该参数都会$OPTARG
自动存储在变量中。
在当前循环结束时,getopts
将跳过本轮使用的所有参数,但不会跳过 shift
它们。只要还有更多要getopts
查找的参数(任何以单破折号 ( -
) 开头的参数),循环就会继续。一旦没有更多选项,循环就会结束,我们看到下一行:
3.1.3. 魔法$OPTIND
shift $(( OPTIND - 1 ))
getopts
用另一个名为 的自动魔法变量来跟踪下一个要处理的参数$OPTIND
。(这些魔法变量或许是我更喜欢自己处理参数的原因之一,稍后会详细介绍。)因此,在解析选项之后,如果我们想继续处理任何常规参数,就必须知道它们在列表中的起始位置!我们可以继续返回 来$OPTIND
获取这些信息,或者像通常的做法一样,将所有已处理的选项(即$OPTIND - 1
参数)移走。
长话短说,这一行允许我们继续我们的脚本,就好像不需要发生任何选项解析一样,
$1
传递给脚本的第一个位置参数在哪里,等等。
3.1.4. 处理位置参数
您会看到我们在下一行处理这个位置参数:
name=${1:?$( usage )}
我们甚至还使用了全新的“必需参数”语法——而且略有改进!您知道可以将命令输出插入到错误消息中吗?现在就知道了!
脚本的其余部分是我们继续处理已设置的任何标志或常量。我想提醒您注意的是,您可以多次传递同一个标志,如$VERBOSE
变量所示。
3.1.5. 尝试一下
下面是我们运行上述脚本的示例:
$ ./args_example -i -vvvv -t 3 -f banana.txt GLOOMP
Are you sure you want to party?
[yn] y
We are being very very very very verbose.
Complete!
$ cat banana.txt
Hello, GLOOMP!
Hello, GLOOMP!
Hello, GLOOMP!
因为我们提供了-i
(interactive) 选项,所以它会询问我们是否确定(read
稍后我们会讲到命令)。我们传递了 4 个-v
选项,所以正如您在输出中看到的那样,我们显得非常冗长。我们将默认次数更改为 3,将默认文件名更改为banana.txt
,最后将其GLOOMP
作为第一个真正的位置参数传递。
酷?酷!这就是getopts
命令。
3.1.6. 处理错误
在上例中,我们使用了getopts
所谓的“loud”模式。任何无法识别的选项,或者需要参数但无法获取的选项,都会导致输出警告。除此之外,一切正常。
如果您想要对输出进行更多的控制,或者完全使其静音,则需要通过:
在选项字符串前面添加冒号()来使用“静音”模式getopts
:
while getopts ":ivhf:t:" opt; do
如果此字符串以冒号开头,则任何无法识别的输入都会导致$opt
被设置为文字“?”,$OPTARG
并被设置为发现的未知字符,并且不会写入任何输出。您可以通过在 case 语句中添加 case 来解决这种情况:
case "$opt" in
# ...
\?) echo "Invalid option $OPTARG" && exit 1;;
esac
在这种“静默”报告模式下,如果您有一个需要参数的选项,而该参数未提供,$opt
则将设置为“:” 2,而不是“?”,$OPTARG
并将其设置为缺少参数的选项字母。
case "$opt" in
# ...
:) echo "Option '$OPTARG' missing a required argument." && exit 1;;
esac
3.2. 案例陈述
哦天哪,真是太多了。干得漂亮,完成了这个getopts
例子。站起来喘口气,四处走走。告诉你所有的朋友,你现在当getopts
老板了,有多酷。
回去工作。
getopts
很酷,但它也有缺点。
- 它无法处理长选项。这是主要问题。
- 有很多神奇的东西。从半神秘的 opts-string 到是否处于“静默模式”,再到那些神奇的
$OPTARG
变量$OPTIND
,在某个时候,你可能会写出一个很棒的脚本来解析选项,但六个月后回来修改它时,你还得再次查看文档(或者这篇文章!)。
根据您的使用情况以及读取/更新/修改此脚本的频率,编写您自己的选项解析循环可能是有意义的。
注意:互联网上的人们对 Bash 和参数解析持有不同意见。Bash 脚本对于bike-shedding来说,简直是唾手可得的。
如果人们对你的选项处理方式大声或糟糕地表达意见,不要因此而沮丧。打印一张大大的冒号图片,告诉他们你已将其设置为“静音”模式。
基本流程和 一样getopts
,不同之处在于需要我们自己去处理和转变。
为了强调两者的相同之处,我们用自己的版本重写上面的例子!我现在要添加长选项——因为我们可以!
#!/usr/bin/env bash
# ... Same setup code
while [[ "$1" == -* ]]; do
case "$1" in
-i|--interactive)
INTERACTIVE=1
;;
-v*)
(( VERBOSE += ${#1} - 1 ))
;;
--verbose)
(( VERBOSE++ ))
;;
-h|--help)
usage
exit 0
;;
-f|--filename)
shift
FILENAME="$1"
;;
-t|--times)
shift
TIMES="$1"
;;
--)
shift
break
;;
esac
shift
done
# ... The rest is the same
让我们来看看它们的区别。希望你对自定义循环的优缺点有了初步的了解:灵活性更高一些,但对于一些更高级的功能(例如将标志组合成一个 blob、为选项添加参数等等),你需要做更多工作,并且对用户更加信任。
3.2.1. 新的循环条件
我们从顶部开始:
while [[ "$1" == -* ]]; do
因为我们必须自己驱动循环,所以我们必须手动检查是否存在任何选项。这里我们使用“shell 通配符”(一种更小、更轻量的正则表达式)来检查第一个参数是否是短划线后跟任何字符。这行代码也可以这样写:
while [[ "$1" =~ ^- ]]; do
对于熟悉正则表达式的人来说,这句话可能更熟悉一些。它表示“如果第一个参数以破折号开头,则将其作为选项处理。”
如果您不想在选项后添加任何附加参数,我还看到人们使用更简单的方法:
while [[ -n "$1" ]]; do
只要有要处理的参数,它就会进行处理,但它不会检查它是否以破折号开头。
3.2.2. 匹配选项
case "$1" in
-i|--interactive) INTERACTIVE=1;;
到目前为止,变化不大。我们直接匹配第一个位置参数。不再需要神奇地去掉破折号。但是,我们获得了处理长选项的能力!
3.2.3. 在单个 Dash 上处理多个选项
-v*) (( VERBOSE += ${#1} - 1 ));;
--verbose) (( VERBOSE++ ));;
这时,我们的情况就变得有点棘手了。虽然我们无缝地getopts
处理了多个v
选项,但我们必须记住自己使用正确的 Shell 全局匹配来处理。由于这里的复杂性,我最终将长选项和短选项拆分成了多个案例。短选项匹配-v
后面的任意字符。然后,它会将匹配的字符数添加到$VERBOSE
。表面上,我们相信用户通常会这样做-vvvv
,但如果他们这样做了,我们的脚本仍然会允许这样做-vslfjadsfjadklsfjsl
。
如果这确实很麻烦,我们可以合理使用 来改进shopt -s extglob
。目前,如果你愿意,就留给你自己去研究吧。
3.2.4. 处理带参数的选项
-f|--filename)
shift
FILENAME="$1"
;;
这里,我们去掉了或shift
选项,然后将下一个参数保存为实际的。错误处理在这里有点棘手。如果您想确保他们为我们提供了文件名,我建议在主选项循环完成后进行一些错误检查。您至少可以确保文件名参数不以 开头,但这样一来,您就阻止用户创建以破折号开头的文件名了。这也许是您想要的,也许不是。这部分取决于您。-f
--filename
$FILENAME
-
然而,这段代码确实展示了多行的case匹配形式。你可以在这里加入额外的逻辑,但前提是你能保证它不会太长,难以阅读。
为什么我们在处理完参数后不也将shift
其移除呢?你马上就会在循环底部看到。
3.2.5. 参数结束
--)
shift
break
;;
命令中包含一个--
表示标志选项结束的选项是很常见的。getopts
会在后台为我们完成这项工作。这里我们需要自己完成。因为中断循环会导致我们错过最后一个选项shift
,所以我们必须手动shift
将此--
选项从队列中移除。
3.2.6. 完成循环
esac
shift
每次迭代结束后,我们必须假设找到了一个匹配项,处理了它,并且完成了队列中当前参数的处理。因此,我们将其移出,然后循环继续尝试评估队列中的下一个参数。
3.2.7. 处理错误
在上面的代码中,我们根本没有处理任何错误。实际上有两种类型的错误:我们可能得到了一个我们未预料到的选项,以及一个选项可能期望一个它没有收到的参数。
对于我们不希望出现的选项,目前我们的脚本会默默地忽略它们。它会读取它们,如果它们不匹配,就会将它们移走,这样既不会造成任何损害,也不会造成任何错误。这可能没问题。如果问题不大,处理这个问题的主要方法是添加一个 catch-all case:
*) echo "Unrecognized option $1." && usage && exit 1;;
确保它位于列表底部。它会匹配任何情况(这要归功于 shell 全局变量星号),但所有情况的评估都是从上到下进行的,没有回溯。所以如果它位于列表底部,它将捕获所有我们尚未捕获/关注的情况,这正是我们想要的。
对于不接收参数的选项,我们已经讨论过一些了。我建议在主循环之后处理这个问题。在我们当前的脚本中,它会捕获以下代码:
if [[ "$TIMES" -le 0 ]]; then
echo "TIMES must be a positive integer." >&2
exit 1
fi
如果参数$TIMES
不是数字,-le
则会自动将该值转换为零进行比较。例如,如果我们运行
$ ./case_example -t -f test.txt garbanzo
的值$TIMES
实际上是-f
其后的那个。由于它不是数字,所以会被转换为零。零小于或等于零,所以我们的脚本会抛出一个错误。
如果零在你的特定用例中是有效输入,那么这可能会有问题。在这种情况下,也许使用正则表达式检查会更好:
if ! [[ "$TIMES" =~ ^[[:digit:]]+$ ]]; then
3.3. 比较
到目前为止,您已经了解了这两种方法。
getopts
虽然有很多内置的优秀功能,但设置起来可能有点复杂,而且无法处理长选项。它在处理参数方面也比较死板。但有时这也挺好!
手动编写的解决方案增加了灵活性、长选项,而且它们往往更易读/更容易理解。你不用费力地处理Bash 的怪异之处。但是,诸如错误处理、自动移位和选项结束标志之类的好东西并非默认内置。你必须确保它们得到处理,并且处理得当。如果事情变得非常复杂,这个过程可能会导致更多潜在的错误。
所以这取决于你的决定,但是现在,当你试图弄清楚时,至少你有一个很好的参考可以查阅。
当然,任何关于 Bash 的文章如果不提及以下内容都是不完整的:如果您的应用程序很大/很复杂/很重要/很慢,那么最好考虑将这个脚本转换成您选择的更健壮、更强大、更快的语言。
但是,如果您想在 Bash 中执行此操作,至少现在您知道该怎么做了!
4. 展望未来
我本来想加一节讲解如何从 STDIN 读取数据以及如何从管道接收输入,但写完上面的部分后,我的字数统计已经达到了 3400 字。所以,也许这里的内容已经够多了,我们下一篇文章再讲 STDIN 的内容。
我这里提供的是一个很好的起点,并且综合了我见过的人们使用的各种方法。不过,我相信方法论和酷炫的用例几乎和 Shell 用户的数量一样多。所以,如果你有一些我遗漏的处理输入和选项的巧妙方法,一定要和我分享!
编写脚本愉快!
文章来源:https://dev.to/rpalo/advanced-argument-handling-in-bash-377b