英文原文在此: Better DEv- Minimal safe Bash script template
几乎每位后端开发工程师都需要写bash script
, 但几乎没人会说:“啊,我喜欢bash script
”, 所以大多数人写的bash script
都不是很给力。
我不会试图让你变成一个写bash script
的专家,(因为我也不是专家),但是现在为你展示一个能让你写出安全的bash script
的最小/最佳模板。现在不用着急感谢我,以后你会感谢你自己的。
为什么用 Bash Script 来编程呢?
有一个关于bash script
的有意思的描述:
The opposite of “it’s like riding a bike” is “it’s like programming in > bash”.\
A phrase which means that no matter how many times you do something, > you will have to re-learn it every single time.
— Jake Wharton (@JakeWharton) December 2, > 2020
去写 bash script 和去骑自行车完全相反。(因为只要你学会了骑自行车,即使好几年没骑,一旦你骑上自行车后,你的以往的骑车训练记忆就会迅速地让你像以往一样熟练。但是 bash script 并不是这样,即使你今天花了好久之后感觉学会了,隔上很久不写的话,可能就变成菜鸟了)
Bash
和很多其他编程语言有一些共同的地方。 比如javascript
, 不会差太远。 bash 也并不是适合做所有工作,但它什么都能做一点点。
在所有Linux
系统,包括docker
镜像 里都能发现 Bash inherited the shell throne 的身影。 这也是几乎所有后端服务运行的环境. 所以如果你需要用脚本来管理服务端应用的启动,CI/CD 流程,或者集成测试的话,试试bash
。
要将几个命令粘合在一起,将输出从一个传递到另一个,然后启动一些可执行文件,Bash 是最简单和最原生的解决方案。
然而,Bash 远非完美。 语法有点怪异。 错误处理很困难。 到处都是 坑。
Bash script 模板
废话不多说,上代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
|
#!/usr/bin/env bash
set -Eeuo pipefail
trap cleanup SIGINT SIGTERM ERR EXIT
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
usage() {
cat << EOF # remove the space between << and EOF, this is due to web plugin issue
Usage: $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-f] -p param_value arg1 [arg2...]
Script description here.
Available options:
-h, --help Print this help and exit
-v, --verbose Print script debug info
-f, --flag Some flag description
-p, --param Some param description
EOF
exit
}
cleanup() {
trap - SIGINT SIGTERM ERR EXIT
# script cleanup here
}
setup_colors() {
if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then
NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m'
else
NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE='' PURPLE='' CYAN='' YELLOW=''
fi
}
msg() {
echo >&2 -e "${1-}"
}
die() {
local msg=$1
local code=${2-1} # default exit status 1
msg "$msg"
exit "$code"
}
parse_params() {
# default values of variables set from params
flag=0
param=''
while :; do
case "${1-}" in
-h | --help) usage ;;
-v | --verbose) set -x ;;
--no-color) NO_COLOR=1 ;;
-f | --flag) flag=1 ;; # example flag
-p | --param) # example named parameter
param="${2-}"
shift
;;
-?*) die "Unknown option: $1" ;;
*) break ;;
esac
shift
done
args=("$@")
# check required params and arguments
[[ -z "${param-}" ]] && die "Missing required parameter: param"
[[ ${#args[@]} -eq 0 ]] && die "Missing script arguments"
return 0
}
parse_params "$@"
setup_colors
# script logic here
msg "${RED}Read parameters:${NOFORMAT}"
msg "- flag: ${flag}"
msg "- param: ${param}"
msg "- arguments: ${args[*]-}"
|
我的想法并不是很长。因为我不想把文件滚动 500 行之后才能看到我想看的逻辑。同时我还想要这段模板变得可靠。但是bash
由于缺乏版本管理,所以不太容易。
一种解决方案是拥有一个包含所有样板和实用程序功能的单独脚本,并在开始时include
进来。 缺点是必须始终在任何地方include
一下,从而失去了“简单 ”的初衷。 所以我决定只在模板中放入我认为最少的内容,以使其尽可能简短。
现在我们来仔细看看
在 bash script 头部声明 bash
脚本要以 #!
开头,这样做是为了更好的兼容, 这里写的是/usr/bin/env
, 而不是直接用/bin/bash
.
遇到错误,及时退出
这里的set
命令用的改变bash script
的执行选项。
比如, 如果不加-e
的话,bash 会不在乎脚本里的命令执行的成功与否,如果执行失败了,它还会继续执行下一行, 并返回一个非 0 的返回值。来看个例子:
1
2
3
|
#!/usr/bin/env bash
cp important_file ./backups/
rm important_file
|
如果backups
目录不存在会发生什么? 确切地说,您将在控制台中收到一条错误消息,但在您做出反应之前,该文件将已被第二个命令删除。
要想要知道 set -Eeuo pipefail
是怎么保护你的, 我推荐你看看 article I have in my bookmarks for a few years now.
然后,你还需要知道这些arguments against setting those options.
set -o pipefail
来看个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
#!/bin/bash
set -e
# 'foo' is a non-existing command
foo | echo "a"
echo "bar"
# output
# ------
# a
# line 5: foo: command not found
# bar
#
# Note how the non-existing foo command does not cause an immediate exit, as
# it's non-zero exit code is ignored by piping it with '| echo "a"'.
|
加上set -o pipefail
之后:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
#!/bin/bash
set -eo pipefail
# 'foo' is a non-existing command
foo | echo "a"
echo "bar"
# output
# ------
# a
# line 5: foo: command not found
#
# This time around the non-existing foo command causes an immediate exit, as
# '-o pipefail' will prevent piping from causing non-zero exit codes to be ignored.
|
可见,如果当你在脚本中使用了管道的话,set -o pipefail
是必须要有的。
set -u
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
#!/bin/bash
set -eo pipefail
echo $a
echo "bar"
# output
# ------
#
# bar
#
# The default behavior will not cause unset variables to trigger an immediate exit.
# In this particular example, echoing the non-existing $a variable will just cause
# an empty line to be printed.
|
1
2
3
4
5
6
7
8
9
10
11
12
|
#!/bin/bash
set -euo pipefail
echo "$a"
echo "bar"
# output
# ------
# line 5: a: unbound variable
#
# Notice how 'bar' no longer gets printed. We can clearly see that '-u' did indeed
# cause an immediate exit upon encountering an unset variable.
|
可见set -u
的作用是当你在脚本中有unset
的变量时,立即报错退出。但是我们怎么处理那些没有定义的变量呢?
可以用: ${a:-b}
这样的方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
#!/bin/bash
set -euo pipefail
DEFAULT=5
RESULT=${VAR:-$DEFAULT}
echo "$RESULT"
# output
# ------
# 5
#
# Even though VAR was not defined, the '-u' option realizes there's no need to cause
# an immediate exit in this scenario as a default value has been provided.
|
用条件语句判断 shell 中的变量有没有被定义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
#!/bin/bash
set -euo pipefail
if [ -z "${MY_VAR:-}" ]; then
echo "MY_VAR was not set"
fi
# output
# ------
# MY_VAR was not set
#
# In this scenario we don't want our program to exit when the unset MY_VAR variable
# is evaluated. We can prevent such an exit by using the same syntax as we did in the
# previous example, but this time around we specify no default value.
|
set -x
set -x
可以在脚本中每行命令在执行前打印每行命令:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
#!/bin/bash
set -euxo pipefail
a=5
echo $a
echo "bar"
# output
# ------
# + a=5
# + echo 5
# 5
# + echo bar
# bar
|
set -E
当脚本捕捉到了某些信号时,可以用 traps 去处理相应的信号。
除了 SIGINT, SIGTERM 等等来自操作系统的信号,traps 还可以捕捉 bash 专用的信号: EXIT, DEBUG,RETURN 和 ERR
如果不加上-E 的话,traps 可能不会捕捉到 ERR 信号。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
#!/bin/bash
set -euo pipefail
trap "echo ERR trap fired!" ERR
myfunc()
{
# 'foo' is a non-existing command
foo
}
myfunc
echo "bar"
# output
# ------
# line 9: foo: command not found
#
# Notice that while '-e' did indeed cause an immediate exit upon trying to execute
# the non-existing foo command, it did not case the ERR trap to be fired.
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
#!/bin/bash
set -Eeuo pipefail
trap "echo ERR trap fired!" ERR
myfunc()
{
# 'foo' is a non-existing command
foo
}
myfunc
echo "bar"
# output
# ------
# line 9: foo: command not found
# ERR trap fired!
#
# Not only do we still have an immediate exit, we can also clearly see that the
# ERR trap was actually fired now.
|
所以如果你的脚本里使用了 traps, 并且还要捕捉 ERR 信号的话, set -E
是必须要有的。
路径相关的
1
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
|
这行这可能是最佳实践 定义了脚本的目录,然后我们cd
进去,为什么要这么写呢?
通常我们的脚本都是在脚本所在位置的相对路径上运行,复制文件和执行命令。
但是,假设我们的 CI 配置执行这样的脚本:
1
|
/opt/ci/project/script.sh
|
如果这个的脚本不是在项目目录中运行,而是在我们 CI 工具的一些完全不同的工作目录中运行。 我们可以通过在执行脚本之前转到目录来修复它:
1
|
cd /opt/ci/project && ./script.sh
|
但是在脚本方面解决这个问题要好得多。 因此,如果脚本从同一目录读取某个文件或执行另一个程序,请像这样调用它:
1
|
cat "$script_dir/my_file"
|
同时,脚本不会更改 workdir 位置。 如果脚本是从其他目录执行的,并且用户提供了某个文件的相对路径,我们仍然可以读取它。
收尾工作要注意的
1
2
3
4
5
6
|
trap cleanup SIGINT SIGTERM ERR EXIT
cleanup() {
trap - SIGINT SIGTERM ERR EXIT
# script cleanup here
}
|
当脚本结束时,cleanup
函数将会被执行。你可能会把一些人物的收尾工作放在这里,比如清除脚本执行过程中产生的临时文件。
cleanup
并不一定只能在脚本结束的时候执行,如果你的脚本在运行过程中需要cleanup
,也可以调用它。
为脚本执行者显示个友好的帮助信息
1
2
3
4
5
6
7
8
9
10
|
usage() {
cat << EOF # remove the space between << and EOF, this is due to web plugin issue
Usage: $(basename "${BASH_SOURCE[0]}") [-h] [-v] [-f] -p param_value arg1 [arg2...]
Script description here.
...
EOF
exit
}
|
在脚本首部写上usage()
, 它有两个作用,如下:
- 来显示帮助 对于不知道所有选项并且不想仔细看代码的人
- 作为一个 最小的帮助文档 当有人修改脚本时(例如你 2 周后甚至不记得最初写过它)
我不是说要为每个函数都写文档。 但是一个简短的、漂亮的脚本使用信息是写bash script
的最低要求。
打印出更友好的信息
1
2
3
4
5
6
7
8
9
10
11
|
setup_colors() {
if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then
NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m'
else
NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE='' PURPLE='' CYAN='' YELLOW=''
fi
}
msg() {
echo >&2 -e "${1-}"
}
|
首先,如果您不想在文本中使用颜色,请删除 setup_colors()
函数。 我保留它是因为我知道如果我不必每次都为它们搜索代码,我会更频繁地使用颜色。
其次,那些 colors 只能用于 msg()
函数,而不是用于 echo
命令。
msg()
函数旨在用于打印所有不是脚本内的命令输出的内容。 这包括所有日志和消息,而不仅仅是错误信息。
Citing the great 12 Factor CLI Apps
长话短说:: stdout 用来输出日志, stderr 输出提示/警示消息.
Jeff Dickey, who knows a > little about
building CLI apps
这就是为什么在大多数情况下你不应该为 stdout
使用颜色。
用 msg()
打印的消息被发送到 stderr
并显示颜色。 如果 stderr
的输出不是交互式终端或传递了 no color 参数,则会禁用颜色。
Usage:
1
|
msg "This is a ${RED}very important${NOFORMAT} message, but not a script output value!"
|
要想研究它在非交互式的stderr
下是什么样子的,可以执行它,然后用管道将结果重定向到stdout
, 管道会让输出失去原本的颜色。
1
2
|
$ ./test.sh 2>&1 | cat
This is a very important message, but not a script output value!
|
传递参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
parse_params() {
# default values of variables set from params
flag=0
param=''
while :; do
case "${1-}" in
-h | --help) usage ;;
-v | --verbose) set -x ;;
--no-color) NO_COLOR=1 ;;
-f | --flag) flag=1 ;; # example flag
-p | --param) # example named parameter
param="${2-}"
shift
;;
-?*) die "Unknown option: $1" ;;
*) break ;;
esac
shift
done
args=("$@")
# check required params and arguments
[[ -z "${param-}" ]] && die "Missing required parameter: param"
[[ ${#args[@]} -eq 0 ]] && die "Missing script arguments"
return 0
}
|
如果你想要为你的脚本加上参数,那么你可能需要以上那么做。
parse_params()
支持three main types of CLI parameters – flags, named parameters, and positional arguments.
有一种常见的参数模式不能被parse_params()
处理: concatenated multiple single-letter flags. 你要是想传递类似于-ab
, 而不是 -a -b
的参数,就得写一些额外代码了。
在所有其他语言中,您应该使用 内置解析器 或 可用库 )。 但是bash
得手动用while
来解析参数, 嗯,bash
的方法很古老。
模板中包含示例标志 (-f
) 和命名参数 (-p
)。 只需更改或复制它们即可添加其他参数。 并且不要忘记之后更新usage()
。
这里最重要的事情是在未知选项上抛出错误。 最好在坏事发生之前完全阻止执行。
还有两种方式可以用来介意bash
的参数: getopt
和getopts
. 有arguments both for and against using them.
但我发现这些工具不是最后的, 因为 getopt
在 macOS behaving completely differently, 而且getopts
并不支持长参数(例如--help
).
模板的使用姿势
很简单, Ctrl C
和Ctrl V
就行,就像大多数从网上看到的代码一样。
复制粘贴后,以下 4 个地方需要改动:
usage()
脚本的说明
cleanup()
内容
parse_params()
里的参数
- 实际的脚本逻辑
可移植性
我在MacOS
(bash 3.2)测试了它,和一些 docker 镜像:(debian, ubuntu, centos, Amazon Linux, Fedora)
显然,它不适用于缺少 Bash 的环境,例如 Alpine Linux。 Alpine 作为一个简约的系统,使用非常轻的ash
(Almquist shell)。
扩展阅读
在写 CLI 脚本时,在 Bash 或任何更好其他语言中,有一些通用规则。 这些文档将指导您如何使您的小型脚本或大型 CLI 应用程序可靠:
后记
我不是第一个也不是最后一个做 Bash 脚本模板的人。 一个不错的选择是 this project,虽然对于我的日常需求来说有点太大了。 毕竟,我尽量让 Bash 脚本尽可能小。
编写 Bash 脚本时,请使用支持 ShellCheck linter 的 IDE,例如 JetBrains IDE。 它会阻止你做 一堆坏事情 。
我的 Bash 脚本模板也在 GitHub Gist 上存放着: (under MITlicense):script-template.sh
如果您发现模板有任何问题,或者您认为缺少一些重要的东西——请在评论中告诉我。
参考资料