This page looks best with JavaScript enabled

[翻译/搬运] 写出安全 bash script 的简洁模板

 ·  ☕ 9 min read

英文原文在此: 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=("[email protected]")

  # check required params and arguments
  [[ -z "${param-}" ]] && die "Missing required parameter: param"
  [[ ${#args[@]} -eq 0 ]] && die "Missing script arguments"

  return 0
}

parse_params "[email protected]"
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

1
#!/usr/bin/env bash

脚本要以 #!开头,这样做是为了更好的兼容, 这里写的是/usr/bin/env, 而不是直接用/bin/bash .

遇到错误,及时退出

1
set -Eeuo pipefail

这里的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
22
#!/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=("[email protected]")

  # 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的参数: getoptgetopts. 有arguments both for and against using them.
但我发现这些工具不是最后的, 因为 getopt 在 macOS behaving completely differently, 而且getopts 并不支持长参数(例如--help).

模板的使用姿势


很简单, Ctrl CCtrl 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

如果您发现模板有任何问题,或者您认为缺少一些重要的东西——请在评论中告诉我。

参考资料

Share on

EXEC
WRITTEN BY
EXEC
Evil EXEC