Bash 脚本

警告
本文最后更新于 2023-11-07,文中内容可能已过时。

Bash 脚本

脚本(script)就是包含一系列命令的一个文本文件。Shell 读取这个文件,依次执行里面的所有命令,就好像这些命令直接输入到命令行一样。所有能够在命令行完成的任务,都能够用脚本完成。所以 Bash 脚本其实也没什么复杂的。

脚本的好处是可以重复使用,也可以指定在特定场合自动调用,比如系统启动或关闭时自动执行脚本。

在服务运维领域,Bash 脚本的编写是绕不开的,服务器运维人员必须掌握 Bash 脚本。

脚本编写 IDE

如果我们在 Windows 环境下用 txt 格式编写 Bash 脚本,因为 Windows 操作系统下的文件,每一行的结尾是\n\r,而在 Linux 下文件的每一行的结尾是\n,所以上传到 Linux 系统后,需要运行sed -i 's/\r$//'  filename.sh进行换行符的替换。

每次都这么做,未免太麻烦了,工欲善其事必先利其器,我们不如准备好 Bash 的 Windows 开发环境。

IDEA

首先要安装的是 Bash 的脚本插件神器 BashSupport Pro,只要在 Jetbrain 家的 IDE 中安装这个插件即可,最好是 IDEA。

注意,这个插件时付费的,不过价格也不贵,一年 23 美元,可以先试用一个月,以后需要这个插件了再按月买也可以,一个月才 2.3 美元,装了这个插件,相当于买了一个开发 Bash 的专业 IDE,还是十分划得来的

此外注意,启用这个插件的时候,会自动把 IDEA 默认自带的Shell Script插件禁用掉,如果不再使用BashSupport Pro,记得把 IDEA 自带的Shell Script插件启用。

而且如果不再使用BashSupport Pro插件,所有的.sh的文件的换行符都会自动从LF换成CRLF,我们需要手动将其再切换回LF,不然sh的文件在 Linux 环境下都无法正常执行。根本原因是因为不再使用BashSupport Pro插件之后,IDEA 将不再支持识别拓展名为.sh的文件,此后新建的sh文件换行符都是CRLF

在 IDEA 中,我这里采用Remote Development with Run Targets的方式来开发,即本地开发 shell 脚本,在远端的 Linux 环境下执行,关于如何配置,看教程即可。

通过Run Targets来远程开发的一点不好就是调试的时候需要传输很多文件,有需要的话,可以在远端 Linux 环境中安装 rsync,不过我为了不破坏远端 Linux 的环境,就没装。这样,远程调试完之后,只需要删除在Run Targets中配置的Project path on target中填写的目录即可。

其余的远程开发方式请看官方文档:Remote Development with BashSupport Pro – BashSupport Pro

VSCode

IDEA 中编写 Bash 脚本还是太重了,而且插件还要收费。

在 VSCode 中通过安装以下插件即可可以实现很方便的编辑:

  • shellman

  • ShellCheck

  • shell-format

  • Bash IDE

在 Windows 操作系统下,VSCode 无法很好地实现运行调试,想要实现运行调试,首先需要安装 WSL,然后安装以下插件:

  • Bash Debug

以上插件可通过直接下载BASH Extension Pack打包下载。

在 VSCode 中编写 Bash 脚本的时候,直接新建后缀为.sh的文件,然后用 VSCode 打开(此时 VSCode 默认采用LF作为换行符),进行编辑,而不是直接编辑临时文件。

Shebang 行

脚本的第一行通常是指定解释器,即这个脚本必须通过什么解释器执行。这一行以#!字符开头,这个字符称为 Shebang,所以这一行就叫做 Shebang 行。

#!后面就是脚本解释器的位置,Bash 脚本的解释器一般是/bin/sh/bin/bash

1
2
3
#!/bin/sh
# 或者
#!/bin/bash

#!与脚本解释器之间有没有空格,都是可以的。

如果 Bash 解释器不放在目录/bin,脚本就无法执行了。为了保险,可以写成下面这样。

1
#!/usr/bin/env bash

上面命令使用env命令(这个命令总是在/usr/bin目录),返回 Bash 可执行文件的位置。env命令的详细介绍,请看后文。

在《Bash 变量》中也有对env命令的描述

在本章的env小节中有对 env 命令的具体解析

Shebang 行不是必需的,但是建议加上这行。如果缺少该行,就需要手动将脚本传给解释器。举例来说,脚本是script.sh,有 Shebang 行的时候,可以直接调用执行。

1
$ ./script.sh

上面例子中,script.sh是脚本文件名。脚本通常使用.sh后缀名,不过这不是必需的。

如果没有 Shebang 行,就只能手动将脚本传给解释器来执行。

1
2
3
$ /bin/sh ./script.sh
# 或者
$ bash ./script.sh

执行权限和路径

前面说过,只要指定了 Shebang 行的脚本,可以直接执行。这有一个前提条件,就是脚本需要有执行权限。可以使用chmod命令,赋予脚本执行权限。

关于chmod的具体语法,请看《Linux 实操篇 - 组管理和权限管理》的修改权限 - chmod小节

当用户在 A 脚本中通过.或者source调用 B 脚本的时候,用户只需要有 A 的执行即可,没有 B 的执行权限依旧可以调用,但是如果直接执行 B 脚本,没有权限则会直接报错,提示Permission denied

此外,我们还可以通过[ -x file ]来判断当前用户是否有执行权限。简单实践如下:

创建ts.sh,但是不赋予权限

1
echo shell execute

创建run_shell.sh

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/usr/bin/env bash
if [ ! \( -x $1 \) ];then
  # 没有执行权限的话
  echo no execute permission for $1
  # 赋予权限
  chmod a+x $1
  # 并执行
  . $1
else
  # 有权限 
  echo has permission
fi

赋予执行权限

1
chmod a+rwx run_shell.sh

此时,我们直接调用run_shell.sh,就会自动赋权,然后执行

1
2
3
$ ./run_shell.sh /opt/shell_script/ts.sh
no execute permission for /opt/shell_script/ts.sh
shell execute

再次调用,因为已经赋权,所以只会输出文字

1
2
$ ./run_shell.sh /opt/shell_script/ts.sh
has permission

实际上,因为我们有run_shell.sh的执行权限,因此在run_shell.sh中调用ts.sh是不需要执行权限的,这里只是为了演示这个过程

从上面的例子中,我们可以注意到,脚本调用时,一般需要指定脚本的路径,如果你就在脚本所在的路径,你可以直接使用./run_shell.sh来执行脚本,如果你在根路径下面,而脚本在/opt/shell_script/run_shell.sh这个路径下面,你需要这样执行脚本。

1
2
$ /opt/shell_script/run_shell.sh
has permission

显然这样就很不方便,此时,如果将脚本放在环境变量$PATH指定的目录中,就不需要指定路径了。因为 Bash 会自动到这些目录中,寻找是否存在同名的可执行文件。

建议在主目录新建一个~/script子目录,专门存放可执行脚本。

然后把~/script加入$PATH环境变量中。

1
export PATH=$PATH:~/script

上面命令改变环境变量$PATH。将~/script添加到$PATH的末尾。

可以将这一行加到~/.bash_profile文件里面:

关于配置文件的加载过程,请看《Bash 启动时的配置文件加载》

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# .bash_profile

# Get the aliases and functions
if [ -f ~/.bashrc ]; then
        . ~/.bashrc
fi

# User specific environment and startup programs

PATH=$PATH:$HOME/bin

PATH=$PATH:~/script

export PATH

然后重新加载一次.bash_profile,这个配置就可以生效了。

1
$ . ~/.bash_profile

然后我们将我们想要全局可执行的脚本都放到这个~/script下,比如ts.sh,赋予权限之后,在任何路径下输入脚本名称(包括拓展名)即可直接执行,例如

1
2
$ ts.sh
shell execute

执行脚本的方式

我们前面用的都是./这种方式

详细修改一下./这种说法

总的来说执行脚本的方式有以下几种:

  • source script.sh. script.sh

  • sh script.shbash script.sh

  • /path/script.sh

/path/script.sh这种方式最常用的格式是在脚本所在目录下直接调用./script.sh,这种方式也是我们最常用的方式。

sh script.sh或者bash script.sh/path/script.sh本质上一样,因此总的来说方法分为两类:

  • 通过source执行脚本

  • 通过bash执行脚本

区别是什么

  • source: 用法:source FileName;作用:在当前 bash 环境下读取并执行 FileName 中的命令。该 filename 文件可以无 “执行权限”,即没有x权限。注:该命令通常用命令 . 来替代。

  • shbash:用法:sh FileNamebash FileName;作用:打开一个子 shell 来读取并执行 FileName 中命令。该 filename 文件可以无 “执行权限”。注:运行一个 shell 脚本时会启动另一个命令解释器。这种方式运行的脚本,不需要在第一行指定解释器信息,写了也会被忽略掉。其实解释器也是有参数的,比如你可以通过添加-l参数来让子 shell 成为一个非交互式的登录式 shell,例如bash -l test.sh,我们也可以直接将这个参数写到脚本里,比如#!/bin/bash -l,然后用./执行,效果是一样的

关于何时创建子 shell,请看《Bash 子 shell》

关于/path/script.sh,这种方式,多说一句,跟sh script.shbash script.sh的区别是,这种方式要求我们在脚本的第一行写上要用哪个解释器#!/bin/bash,我们也可以给他带上解释器参数,像这样#!/bin/bash -l,这样,可以获得一个非交互式的登录式的 shell。

注意,在执行当前目录下的script.sh,一定要写成./script.sh,而不是script.sh,运行其它二进制的程序也一样。

直接写script.sh,linux 系统会去 PATH 环境变量里寻找有没有叫script.sh的,而默认只有/bin, /sbin, /usr/bin/usr/sbin 等目录在 PATH 里,也就是只有这些目录下的命令才能通过直接输入名字来调用,你的当前目录通常不在 PATH 里,所以写成script.sh是会找不到命令的,要用./script.sh告诉系统说,就在当前目录找

如果你在需要执行别的目录下的脚本的话,你就得将./换成从当前目录到脚本所在目录的完整目录,确保能找到脚本。

如果你实在是需要全局调用script.sh,那就得将其放到 PATH 环境变量种,方式在执行权限和路径小节中已经演示过了。

source 命令

source命令用于执行一个脚本,通常用于重新加载一个配置文件。

1
$ source .bashrc

source命令最大的特点是在当前 Shell 执行脚本,即不会创建一个子 shell 所以,source命令执行脚本时,不需要export变量。

当我们直接执行脚本的时候,比如./script.sh,会新建一个子 Shell。此时传递环境变量就需要使用export命令

关于何时创建子 shell,请看《Bash 子 shell》

1
2
3
#!/bin/bash
# test.sh
echo $foo

上面脚本输出$foo变量的值。

1
2
3
4
5
6
7
8
9
# 当前 Shell 新建一个变量 foo
$ foo=1

# 打印输出 1
$ source test.sh
1

# 打印输出空字符串
$ bash test.sh

上面例子中,当前 Shell 的变量foo并没有export,所以直接执行无法读取,但是source执行可以读取。

source命令的另一个用途,是在脚本内部加载外部库或者调用其他脚本。

1
2
3
4
5
#!/bin/bash

source ./lib.sh

function_from_lib

上面脚本在内部使用source命令加载了一个外部库,然后就可以在脚本里面,使用这个外部库定义的函数。

source有一个简写形式,可以使用一个点(.)来表示。

1
$ . .bashrc

env 命令

env命令总是指向/usr/bin/env文件,或者说,这个二进制文件总是在目录/usr/bin

#!/usr/bin/env NAME这个语法的意思是,让 Shell 查找$PATH环境变量里面第一个匹配的NAME如果你不知道某个命令的具体路径,或者希望兼容其他用户的机器,这样的写法就很有用。

/usr/bin/env bash的意思就是,返回bash可执行文件的位置,前提是bash的路径是在$PATH里面。其他脚本文件也可以使用这个命令。比如 Node.js 脚本的 Shebang 行,可以写成下面这样。

1
#!/usr/bin/env node

env命令的参数如下。

  • -i--ignore-environment:不带环境变量启动。
  • -u--unset=NAME:从环境变量中删除一个变量。
  • --help:显示帮助。
  • --version:输出版本信息。

下面是一个例子,新建一个不带任何环境变量的 Shell。

1
$ env -i /bin/sh

注释

Bash 脚本中,#表示注释,可以放在行首,也可以放在行尾。

1
2
3
4
# 本行是注释
echo 'Hello World!'

echo 'Hello World!' # 井号后面的部分也是注释

建议在脚本开头,使用注释说明当前脚本的作用,这样有利于日后的维护。

脚本参数

调用脚本的时候,脚本文件名后面可以带有参数。

1
$ script.sh word1 word2 word3

上面例子中,script.sh是一个脚本文件,word1word2word3是三个参数。

脚本文件内部,可以使用特殊变量,引用这些参数。

  • $0:脚本所在路径加文件名:path/script.sh,一般为./script.sh。可通过basename命令获取文件名

  • $#:参数的总数。

  • $1~$9:对应脚本的第一个参数到第九个参数。

  • $@:全部的参数,参数之间使用空格分隔。

  • $*:全部的参数,参数之间使用变量$IFS值的第一个字符分隔,默认为空格,但是可以自定义。

    关于,请看《Bash read 命令》中的IFS 变量小节

如果脚本的参数多于 9 个,那么第 10 个参数可以用${10}的形式引用,以此类推。

注意,如果命令是command -o foo bar,那么-o$1foo$2bar$3

如果想要通过script.sh -name val这种形式传递参数,请参考本章的getopts 命令小节,这也是我们写脚本比较常用的传参方式

简单实践:

创建shell_params.sh

1
2
3
4
5
6
7
#!/usr/bin/env bash
echo $0 has $# parmas
echo param 1 is $1
echo param 2 is $2
echo param 3 is $3
echo param 4 is $4
echo all params: $@

chmod赋权之后执行

1
2
3
4
5
6
7
$ ./shell_params.sh a b c d e 12 '34' xiashuo.xyz
./shell_params.sh has 8 parmas
param 1 is a
param 2 is b
param 3 is c
param 4 is d
all params: a b c d e 12 34 xiashuo.xyz

我们也可以用 for 循环的方式来遍历参数,修改shell_params.sh

1
2
3
4
5
#!/usr/bin/env bash
echo $0 has $# parmas
for i in "$@"; do
  echo $i
done

赋权之后执行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ ./shell_params.sh a b c d e 12 '34' xiashuo.xyz
./shell_params.sh has 8 parmas
a
b
c
d
e
12
34
xiashuo.xyz

注意,脚本传参是做不到传递数组变量的,只能做到将所有的数组元素挨个传入,然后再在脚本中组合成一个数组。

简单实践如下:

创建script_param.sh

1
2
3
4
5
#!/usr/bin/env bash

name=("$@")
echo arr content: "${name[@]}"
echo arr length: "${#name[@]}"

赋权后执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ ./script_param.sh 111 222 333
arr content: 111 222 333
arr length: 3
$ arr=(aa bbb ccc)
$ ./script_param.sh "${arr[@]}"
arr content: aa bbb ccc
arr length: 3
$ ./script_param.sh ${arr[@]}
arr content: aa bbb ccc
arr length: 3

shift 命令

shift命令可以改变脚本参数,每次执行都会移除脚本当前的第一个参数($1),使得后面的参数向前一位,即$2变成$1$3变成$2$4变成$3,以此类推。

shift命令可以接受一个整数作为参数,指定所要移除的参数个数,默认为1

简单实践:

创建shell_params.sh

1
2
3
4
5
6
7
#!/usr/bin/env bash
echo $0 has $# parmas
echo remove the first 3 parmas
shift 3
for i in "$@"; do
  echo $i
done

赋权后执行

1
2
3
4
5
6
7
8
$ ./shell_params.sh a b c d e 12 '34' xiashuo.xyz
./shell_params.sh has 8 parmas
remove the first 3 parmas
d
e
12
34
xiashuo.xyz

getopts 命令

getopts命令用在脚本内部,可以解析复杂的脚本命令行参数,通常与while循环一起使用,取出脚本所有的带有前置连词线(-)的参数。但是不支持--开头的参数。

1
getopts optstring name

它带有两个参数。第一个参数optstring是字符串,给出脚本所有的连词线参数。比如,某个脚本可以有三个配置项参数-s-n-v,其中-n-v可以带有参数值,而-s是开关参数,那么getopts的第一个参数写成sn:v:,顺序不重要。注意,nv后面有一个冒号,表示该参数带有参数值,getopts规定带有参数值的配置项参数,后面必须带有一个冒号(:)。如果这个冒号在开头,比如:sn:v:,则表示不打印错误信息。getopts的第二个参数name是一个变量名,用来保存当前取到的配置项参数,即snv

简单实践

shell_params.sh

 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
#!/usr/bin/env bash
echo $( basename $0 ) has $# parmas
echo all params: $@
while getopts 'sn:v:' OPTION; do
  case "$OPTION" in
    s)
      echo "switch on"
      ;;
    n)
      name="$OPTARG"
      echo "name is $name"
      ;;
    v)
      value="$OPTARG"
      echo "value is $value"
      ;;
    ?)
      echo "script usage: $(basename $0) [-s] [-n somevalue] [-v somevalue]" >&2
      exit 1
      ;;
  esac
done
echo all params: $@
echo "params traversal count:  $OPTIND"
shift "$(($OPTIND - 1))"
echo all params: $@
echo -------

在《Bash 字符串操作》的字符串头部的模式匹配小节,我们了解过basenamedirname

赋权后执行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ ./shell_params.sh -s -n xiashuo -v 12
shell_params.sh has 5 parmas
all params: -s -n xiashuo -v 12
switch on
name is xiashuo
value is 12
all params: -s -n xiashuo -v 12
params traversal count:  6
all params:
-------

错误传参

1
2
3
4
5
6
$ ./shell_params.sh -s -x
shell_params.sh has 2 parmas
all params: -s -x
switch on
./shell_params.sh: illegal option -- x
script usage: shell_params.sh [-s] [-n somevalue] [-v somevalue]

可以看到

上面例子中,while循环不断执行getopts 'sn:v:' OPTION命令,每次执行就会读取一个连词线参数(以及对应的参数值),然后进入循环体。变量OPTION保存的是,当前处理的那一个连词线参数(即snv)。如果用户输入了没有指定的参数(比如-x),那么OPTION等于?。循环体内使用case判断,处理这四种不同的情况。

如果某个连词线参数带有参数值,比如-n foo,那么处理a参数的时候,环境变量$OPTARG保存的就是参数值。

注意,只要遇到不带连词线的参数,getopts就会执行失败,从而退出while循环。比如,getopts可以解析command -n foo,但不可以解析command foo -n。另外,多个连词线参数写在一起的形式,比如command -sngetopts也可以正确处理。

变量$OPTINDgetopts开始执行前是1,然后每次执行就会加1。等到退出while循环,就意味着连词线参数全部处理完毕。$OPTIND - 1就是已经处理的连词线参数个数,这时,你可以通过使用shift命令将这些参数移除,然后只使用你已经从参数中提取了的参数,比如$name$value。或者你也可以留着原始参数,然后通过位置比如$1$2$3等变量来访问他们,不过一般情况下我们都会通过shift删除掉,因为通常情况下,可以通过-来传参的时候,参数的位置是没有规律的,通过位置来获取执行参数没有意义。

getopts 在解析传入 Shell 脚本的参数时(也就是$@),并不会执行 shift 操作,而是通过变量 OPTIND 来记住接下来要解析的参数的位置。

getopts 在解析到选项的参数时,就会将参数保存在 OPTARG 变量当中;如果 getopts 遇到不合法的选项,择把选项本身保存在 OPTARG 当中。

getopt 命令

参考文档:shell - 参数解析三种方式 (手工,getopts, getopt) | 面向信仰

getopts:Bash 内置,支持大多数复杂命令,不支持长选项,而getopt支持长选项,getoptgetopts更强大,其不仅支持短参-s,还支持-–longopt的长参数,甚至支持--longopt的简化参数。相较于getoptsgetopts不但支持长短选项,其还支持选项和参数放在一起写。

getopt用法:

1
getopt [options] -o|--options optstring [options] [--] parameters

选项说明:

-a:使getopt长参数支持” - “符号打头,必须与-l 同时使用

-l:后面接 getopt 支持长参数列表

-n:program:如果 getopt 处理参数返回错误,会指出是谁处理的这个错误,这个在调用多个脚本时,很有用

-o:后面接短参数列表,这种用法与 getopts 类似

-u:不给参数列表加引号,默认是加引号的(不使用-u 选项),例如在加引号的时候 –longoption“arg1 arg2” ,只会取到”arg1”,而不是完整的”arg1 arg2”

简单实践一下:

创建脚本shell_long_params.sh

 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
#!/usr/bin/env bash
ARGS=`getopt -a -o I:D:Fh -l instence:,database:,flag,help -- "$@"`
function usage() {
    echo  'help'
}
[ $? -ne 0 ] && usage
#set -- "${ARGS}"
eval set -- "${ARGS}"
while true
do
      case "$1" in
      -I|--instence)
              instence="$2"
              shift
              ;;
      -D|--database)
              database="$2"
              shift
              ;;
      -F|--flag)
              flag="yes"
              ;;
      -h|--help)
              usage
              ;;
      --)
              shift
              break
              ;;
      esac
shift
done
echo instence:$instence database:$database flag:$flag

赋权并执行:

1
2
$ ./shell_long_params.sh -I 12 --database 22 -f
instence:12 database:22 flag:yes

用起来很方便。

脚本参数规范

在创建 shell 脚本时,尽量保持选项与 Linux 通用的选项含义相同,Linux 通用选项有

-a:显示所有对象

-c:生产一个计数

-d:指定一个目录

-e:扩展一个对象

-f:指定读入数据的文件

-h:显示命令的帮助信息

-i:忽略文本大小写

-l:产生输出得长格式文本

-n:使用非交互模式

-o:指定将所有输出重定向到输出文件

-q:以安静模式运行    

-r:递归的处理目录和文件

-s:以安静模式运行    

-v:生成详细输出  

-x:排除某个对象

-y:对所有问题回答 yes

配置项参数终止符

Bash Shell 有三种解析参数的方式:

  • 手工处理:大多数简单的命令
  • getopts: 大多数复杂命令,不支持长选项
  • getopt: 支持长选项,getopts getopt功能相似但又不完全相同,其中 getopt是独立的可执行文件,而 getopts是由 Bash 内置的

这三种方式我们前面都学习了。

在学习getoptsgetopt的时候我们就知道,以-或者--开头的参数,虽然是一个参数,但是本质上来说是一个配置项,会被getoptsgetopt解析,而不会被手工处理的方式解析,那如果---开头的参数不是配置项,而是实体参数的一部分,比如文件名叫做-f--file

1
2
$ cat -f
$ cat --file

上面命令的原意是输出文件-f--file的内容,但是会被 Bash 当作配置项解释。

这时就可以使用配置项参数终止符--,它的作用是告诉 Bash,在它后面的参数开头的---不是配置项,只能当作实体参数解释。

1
2
$ cat -- -f
$ cat -- --file

上面命令可以正确展示文件-f--file的内容,因为它们放在--的后面,开头的---就不再当作配置项解释了。

如果要确保某个变量不会被当作配置项解释,就要在它前面放上参数终止符--

实际上我们要创建出名称为-f的文件,也要用到参数终止符--

1
$ touch -- -f

一个实际的例子,如果想在文件里面搜索--hello,这时也要使用参数终止符--

1
$ grep -- "--hello" example.txt

上面命令在example.txt文件里面,搜索字符串--hello。这个字符串是--开头,如果不用参数终止符,grep命令就会把--hello当作配置项参数,从而报错。

退出脚本执行

参考教程:Shell 中 exit 和 return 的区别_恋喵大鲤鱼的博客-CSDN 博客

exit 命令

exit命令是 Shell 内建命令,用于退出当前 Shell 进程。使用格式如下:

1
exit [N]

可以指定退出状态 N,N 的取值范围是 0-255,一般情况下,0 表示正常退出,非零表示异常退出。如果状态码是 0-255 之外的数值,则会被强制转换为 uint8_t 类型的数值,比如 -1 会被转换为 255,256 会发生类型宽度截断,被转换为 0。状态码 N 可以不指定,默认是上一条命令的退出码。

关于状态码值的定义尚未有统一的标准,但是结束程序时随意的指定一个状态码是一个不好的行为,应该使用统一的状态码。这样便于调用者更具状态码快速粗略地推断出被调的状态,而不用去查找状态码的具体含义。当然实际的状态码值可以自定义,项目中统一即可,但还是推荐使用 GNU C 的头文件 <sysexits.h> 中对状态码的定义。

exit命令可用于终止当前脚本的执行,并向 Shell 返回一个退出值。这个退出值可以写在exit后面手动指定,也可以不手动指定,此时会将当前脚本最后一条命令的退出状态,作为整个脚本的退出状态。

1
2
3
4
5
6
7
8
# 不指定退出值
$ exit

# 退出值为0(成功)
$ exit 0

# 退出值为1(失败)
$ exit 1

退出时,脚本会返回一个退出值。脚本的退出值,0表示正常,1表示发生错误,2表示用法不对,126表示不是可执行脚本,127表示命令没有发现。如果脚本被信号N终止,则退出值为128 + N。简单来说,只要退出值非 0,就认为执行出错。

下面是一个例子。

1
2
3
4
if [ $(id -u) != "0" ]; then
  echo "根用户才能执行当前脚本"
  exit 1
fi

上面的例子中,id -u命令返回用户的 ID,一旦用户的 ID 不等于0(根用户的 ID),脚本就会退出,并且退出码为1,表示运行失败。

当我们用exit命令退出脚本执行的时候,我们可以在交互式终端的下一个语句中,通过$?这个变量获取脚本中exit命令的退出值,通过不同的退出值来判断不同的错误类型。

关于$?变量的解释,请看本章的命令 / 脚本执行结果小节。

通过 SSH 打开交互式终端之后,输入exit,可以退出 SSH,即退出 Shell 进程。

return 命令

return是语言级别的一个关键字,用于结束函数并返回一个结果。return不带参数时,则会返回函数体中最后一个命令的返回值(这一点跟exit命令一样)。

return也可以用于使用.source的方式包含的子 Shell 脚本中,用于退出子脚本的执行,可以返回指定的状态或者脚本中最后一个命令的exit status。退出子脚本的执行之后,父脚本的 Shell 进程不会中止,会继续往后执行。

比如如下脚本:

1
2
3
4
5
6
#!/bin/bash
if [ $# -ne 1 ]; then
    echo "please input parameter"
    # 直接在全局范围内像使用 exit 一样使用 return 是不行的
      return 1
fi

输出

1
2
3
$ ./shell_return.sh
please input parameter
./shell_return.sh: line 5: return: can only `return' from a function or sourced script

也就是说,只能在从方法中返回或者从子脚本中返回的时候,才可以使用return

sourced script

A 脚本通过.或者source调用了 B 脚本,那么 B 脚本就叫 sourced script,可以在 B 中通过return退出 A 对 B 的调用,退出之后,A 继续往后执行。

exit 与 return 的区别

  • 作用不同。exit用于在程序运行的过程中随时结束程序,exit的参数是返回给 OS 的。exit是结束一个进程,它将删除进程使用的内存空间,同时把错误信息返回父进程。而return是返回函数值并退出函数;

  • 语义层级不同。return是语言级别的,它表示了调用堆栈的返回;而exit是系统调用级别的,它表示了一个进程的结束;

  • 使用方法不用。return一般用在函数方法体内,exit可以出现在 Shell 脚本中的任意位置。

在脚本中调用其他脚本

其实方法跟执行脚本的方式中提到的方法是一样的,即source script.sh/. script.shsh script.sh/bash script.sh/path/script.sh,而通过sh或者bash跟直接输入脚本路径执行脚本本质上一样,

因此总的来说方法分为两类:

  • .source:这两种方式都不需要考虑变量传递的问题,因为他们没有创建子 shell,而是将指定的脚本内容拷贝至当前的脚本中,由一个 Shell 进程来执行。在 shell 脚本中,我们最常用的就是这种方式。

  • bash或者sh:这种方式实际上就是新开了一个 shell 进程来执行另一个脚本,这就需要考虑父 shell 到子 shell 的变量传递问题,因此需要使用export来传递变量,

在通过.执行另一个脚本的时候,在另一个脚本中执行exit的时候,父脚本也会退出。但是在另一个脚本中执行return的时候只会退出子脚本,回到父脚本中继续往后执行。

换个角度说,在 Bash 脚本通过source或者.调用别的脚本,可以看做是对别的脚本的引用,就跟 Javascript、或者 Python 中通过 import 调用其他脚本是一个概念。

命令 / 脚本执行结果

命令或脚本执行结束后,会有一个返回值。0表示执行成功,非0(通常是1)表示执行失败。环境变量$?可以读取前一个命令的返回值。

我们在本章的退出脚本执行小节中提到过退出码

关于$?变量的解释,请看《Bash 变量》的特殊变量小节

利用这一点,可以在脚本中对命令执行结果进行判断。

我们在编写 shell 脚本的时候,经常通过此变量来判断前一个命令是否执行成功,此时需要注意,想要正确地获取前一个命令的执行结果,对$?的获取必须紧接上一条命令,中间隔着一个 if 判断都是不行的,必须得是上下两句的关系才行。

比如这样,$?是无法获取dataview_single_machine_install.sh或者dataview_cluster_install.sh的执行结果的,因为还隔着一个 if 判断

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
if (($is_cluster == 0)); then
  . ${base_folder}/bin/dataview_single_machine_install.sh >>${log_file} 2>&1
else
  . ${base_folder}/bin/dataview_cluster_install.sh >>${log_file} 2>&1
fi
if [ $? != 0 ]; then
  echo -e '\033[31m dataview install error \033[0m'
  show_error
  exit
fi

只有这样才行,$?才可以正确获取dataview_single_machine_install.sh或者dataview_cluster_install.sh的执行结果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
if (($is_cluster == 0)); then
  . ${base_folder}/bin/dataview_single_machine_install.sh >>${log_file} 2>&1
  if [ $? != 0 ]; then
    echo -e '\033[31m dataview install error \033[0m'
    show_error
    exit
  fi
else
  . ${base_folder}/bin/dataview_cluster_install.sh >>${log_file} 2>&1
  if [ $? != 0 ]; then
    echo -e '\033[31m dataview install error \033[0m'
    show_error
    exit
  fi
fi

简单实践一下:

1
2
3
4
5
6
7
cd /path/to/somewhere
if [ "$?" = "0" ]; then
  rm *
else
  echo "无法切换目录!" 1>&2
  exit 1
fi

上面例子中,cd /path/to/somewhere这个命令如果执行成功(返回值等于0),就删除该目录里面的文件,否则退出脚本,整个脚本的返回值变为1,表示执行失败。

由于if可以直接判断命令的执行结果,执行相应的操作,上面的脚本可以改写成下面的样子。

1
2
3
4
5
6
if cd /path/to/somewhere; then
  rm *
else
  echo "Could not change directory! Aborting." 1>&2
  exit 1
fi

更简洁的写法是利用两个逻辑运算符&&(且)和||(或)。

1
2
3
4
5
# 第一步执行成功,才会执行第二步
cd /path/to/somewhere && rm *

# 第一步执行失败,才会执行第二步
cd /path/to/somewhere || exit 1

但是注意,不要通过cd $val这样来切换目录

1
cd $dir_name && rm *

上面脚本中,只有cd $dir_name执行成功,才会执行rm *。但是,如果变量$dir_name为空,cd就会进入用户主目录,从而删光用户主目录的文件。

下面的写法才是正确的。

1
[[ -d $dir_name ]] && cd $dir_name && rm *

上面代码中,先判断目录$dir_name是否存在,然后才执行其他操作。

脚本日志

我们可以将脚本中所有的输出和异常,全部输出到指定的日志文件中,具体做法,请看《Bash 函数》的参数变量小节的例子,很完整。

脚本模板

参考《set 命令和 shopt 命令》和《mktemp 命令和 trap 命令》

标准格式:

1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash

set -ueEo pipefail

trap "echo script has been interrupted" INT
trap "echo script has been terminated;exit 1" TERM
trap "echo script has exit" EXIT

# 脚本的具体内容

alias 命令

我们在《alias 别名》中已经学习过alias命令了

alias命令用来为一个命令指定别名,这样更便于记忆和使用。下面是alias的格式。

1
alias NAME=DEFINITION

上面命令中,NAME是别名的名称,DEFINITION是别名对应的原始命令。注意,等号两侧不能有空格,否则会报错。

一个常见的例子是为grep命令起一个search的别名。

1
alias search=grep

alias也可以用来为长命令指定一个更短的别名。下面是通过别名定义一个today的命令。

1
2
3
$ alias today='date +"%A, %B %-d, %Y"'
$ today
星期一, 一月 6, 2020

有时为了防止误删除文件,可以指定rm命令的别名。

1
$ alias rm='rm -i'

上面命令指定rm命令是rm -i,每次删除文件之前,都会让用户确认。

alias定义的别名也可以接受参数,参数会直接传入原始命令。

1
2
3
$ alias echo='echo It says: '
$ echo hello world
It says: hello world

上面例子中,别名定义了echo命令的前两个参数,等同于修改了echo命令的默认行为。

指定别名以后,就可以像使用其他命令一样使用别名。一般来说,都会把常用的别名写在~/.bashrc的末尾。另外,只能为命令定义别名,为其他部分(比如很长的路径)定义别名是无效的。

关于~/.bashrc,请看《Bash 启动时的配置文件加载》

直接调用alias命令,可以显示所有别名。

1
$ alias

unalias命令可以解除别名。

1
$ unalias lt
0%