2. Shell脚本基础入门指南

1 脚本基础介绍

在前一章节Bash入门指南,我们已经初步熟悉了Shell的基本使用,可以看到,本质上,Shell的使用就是用一堆命令去完成各类功能。

而有些时候,一个功能可能需要许多个命令、根据各种不同的条件去执行,如果一条一条去手动输入的话,效率实在是太低了。

而本章介绍的脚本就是为了解决这个问题。

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

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

在学习脚本之前,最好先了解vim编辑器的基本使用,当然,你也可以直接在windows系统上编写、然后将其复制到wsl中去执行,但这样做肯定更加麻烦。

1.1 最简单的脚本

一个脚本实际上就是一个文本文件,通常由三部分组成:Shebang行、命令、注释。

其中Shebang指的是脚本的第一行,作用是指定用什么Shell来执行本脚本。

命令指的就是我们前面已经使用过的各种命令。

而注释则是用来解释说明的、给我们自己看的,并不会被执行。

比如下面就是一个最简单的脚本:

#!/bin/bash

# 输出hello world字符串
echo 'hello world'

上面第一行是Shebang行,第二行是注释,第三行是命令。

将上面的内容保存到任意一个文本文件中,比如test.sh,其中.sh后缀指的是script这个单词,给我们自己看的,即使你不加也没有任何影响。

在linux系统中,编辑文件一般使用vim命令,如果不会使用的可以参考文章:精通 VIM ,这篇文章写的很好,我这里就不再赘述了。

但将上面的文本复制进去之后,它也仅仅只是个普通的文本,想让其变得可执行,就需要给它添加可执行权限:

chmod +x ./test.sh

然后它就可以被执行了,就像下面这样:

image.png

在有了这个最简单脚本雏形的前提下,我们再来一步一步深入学习脚本更多有用的东西。

1.2 Shebang

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

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

#!/bin/sh
# 或者
#!/bin/bash

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

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

#!/usr/bin/env bash

上面命令使用env命令(这个命令总是在/usr/bin目录)返回 Bash 可执行文件的位置。

Shebang 行不是必需的,但是建议加上这行。

在老式shell中,如果缺少该行,就需要手动将脚本传给解释器,举例来说,脚本是script.sh,有 Shebang 行的时候,可以直接调用执行。

./script.sh

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

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

/bin/sh ./script.sh
# 或者
bash ./script.sh

但在当下的大部分shell中,实际上已经可以不加该行直接执行了,加上该行更多的作用是指定一下执行该脚本的shell。

1.3 执行权限与路径

前面提到,一个脚本想要能执行,需要一个前提条件,就是脚本需要有执行权限,可以使用下面的命令,赋予脚本执行权限。

# 给所有用户执行权限
chmod +x script.sh

# 给所有用户读权限和执行权限
chmod +rx script.sh
# 或者
chmod 755 script.sh

# 只给脚本拥有者读权限和执行权限
chmod u+rx script.sh

脚本的权限通常设为755(拥有者有所有权限,其他人有读和执行权限)或者700(只有拥有者可以执行)。

文件权限有关内容可以参考文章:Linux 文件基本属性

除了执行权限,脚本调用时,一般需要指定脚本的路径,比如前面调用时使用的就是:.\test.sh而不是直接test.sh,前面的.代表的就是当前目录,\是路径分隔符,更多介绍可以参考: 文件路径

1.4 env 命令

前面使用了这个命令,因此这里也提一下。

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

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

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

#!/usr/bin/env node

env命令的参数如下。

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

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

env -i /bin/sh

1.5 注释

注释主要是我们自己看的,Shell并不会执行。

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

# 本行是注释
echo 'Hello World!'

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

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

在vim中、乃至其它编辑器中,一般注释的内容颜色与正常命令之间也都是不一样的:

image.png

1.6 脚本参数

调用脚本的时候,我们时常还会有需要往脚本中传入参数的需求,脚本根据不同的参数执行不同的操作。

比如:

.\script.sh word1 word2 word3

script.sh是一个脚本文件,word1word2word3就是传入脚本的三个参数。

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

  • $0:脚本文件名,即script.sh
  • $1~$9:对应脚本的第一个参数到第九个参数。
  • $#:参数的总数。
  • $@:全部的参数,参数之间使用空格分隔。
  • $*:全部的参数,参数之间使用变量$IFS值的第一个字符分隔,默认为空格,但是可以自定义。

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

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

下面是一个脚本内部读取命令行参数的例子。

#!/bin/bash
# script.sh

echo "全部参数:" $@
echo "命令行参数数量:" $#
echo '$0 = ' $0
echo '$1 = ' $1
echo '$2 = ' $2
echo '$3 = ' $3

将其保存到script.sh文件中,添加上可执行权限,然后执行:

image.png

用户可以输入任意数量的参数,利用for循环,可以读取每一个参数,循环的内容后面会单独介绍,这里先使用一下:

#!/bin/bash

for i in "$@"; do
  echo $i
done

上面例子中,$@返回一个全部参数的列表,然后使用for循环遍历所有参数。

如果多个参数放在双引号里面,视为一个参数。

./script.sh "a b"

上面例子中,Bash 会认为"a b"是一个参数,$1会返回a b。注意,返回时不包括双引号。

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

while循环结合shift命令,也可以读取每一个参数:

#!/bin/bash

echo "一共输入了 $# 个参数"

while [ "$1" != "" ]; do
  echo "剩下 $# 个参数"
  echo "参数:$1"
  shift
done

上面例子中,shift命令每次移除当前第一个参数,从而通过while循环遍历所有参数,效果如下:

image.png

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

shift 3

上面的命令移除前三个参数,原来的$4变成$1

此外,我们还可以使用getopts命令在脚本内部,用于解析复杂的脚本命令行参数,通常与while循环一起使用,取出脚本所有的带有前置连词线(-)的参数。

getopts optstring name

它带有两个参数,第一个参数optstring是字符串,给出脚本所有的连词线参数。

比如某个脚本可以有三个配置项参数-l-h-a,其中只有-a可以带有参数值,而-l-h是开关参数,那么getopts的第一个参数写成lha:,顺序不重要。

注意,a后面有一个冒号,表示该参数带有参数值,getopts规定带有参数值的配置项参数,后面必须带有一个冒号(:)。

getopts的第二个参数name是一个变量名,用来保存当前取到的配置项参数,即lha

下面是一个例子:

while getopts 'lha:' OPTION; do
  case "$OPTION" in
    l)
      echo "linuxconfig"
      ;;

    h)
      echo "h stands for h"
      ;;

    a)
      avalue="$OPTARG"
      echo "The value provided is $OPTARG"
      ;;
    ?)
      echo "script usage: $(basename $0) [-l] [-h] [-a somevalue]" >&2
      exit 1
      ;;
  esac
done
shift "$(($OPTIND - 1))"

上面例子中,while循环不断执行getopts 'lha:' OPTION命令,每次执行就会读取一个连词线参数(以及对应的参数值),然后进入循环体。

变量OPTION保存的是,当前处理的那一个连词线参数(即lha)。如果用户输入了没有指定的参数(比如-x),那么OPTION等于?。循环体内使用case判断,处理这四种不同的情况。

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

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

变量$OPTINDgetopts开始执行前是1,然后每次执行就会加1。等到退出while循环,就意味着连词线参数全部处理完毕。这时,$OPTIND - 1就是已经处理的连词线参数个数,使用shift命令将这些参数移除,保证后面的代码可以用$1$2等处理命令的主参数。

效果如下:

image.png

---开头的参数,会被 Bash 当作配置项解释。但是有时它们不是配置项,而是实体参数的一部分,比如文件名叫做-f--file

cat -f
cat --file

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

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

cat -- -f
cat -- --file

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

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

ls -- $myPath

上面示例中,--强制变量$myPath只能当作实体参数(即路径名)解释。如果变量不是路径名,就会报错。

myPath="-l"
ls -- $myPath

上面例子中,变量myPath的值为-l,不是路径。但是,--强制$myPath只能作为路径解释,导致报错“不存在该路径”。

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

grep -- "--hello" example.txt

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

1.7 执行结果

在执行一个命令以及执行完成一个脚本后,我们常常需要知道执行的结果是成功还是失败。

虽然可以通过使用echo命令输出字符串实现,但这并不标准,因为不同人可以会输出不同的内容代表正确或错误。

因此我们规定,当一个命令执行结束后,必定会有一个返回值。0表示执行成功,非0(通常是1)表示执行失败,环境变量$?可以读取前一个命令的返回值。

image.png

可以看到,当一条命令执行成功时,$?的值就是0,否则就是非0的。

在我们自己写的脚本中,也可以通过exit命令设置我们的脚本退出状态:

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

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

注意,一旦执行了这条命令,无论它后面写了多少命令,都不会再执行了,脚本直接结束。

利用所有命令与脚本都有返回值的这一点,我们也可以在脚本中对命令执行结果进行判断。

而判断的方式,便是使用后文将要介绍的条件判断,这里就先不提了。

1.8 source执行

像前面直接执行脚本,实际上都是新建的一个子Shell去执行的命令,其执行的一切效果都不会影响当前的Shell。

但这在某些时候就会存在问题,比如我想通过脚本改变当前Shell的环境变量、路径,那么这样直接执行就是无效的。

source命令就可以直接在当前Shell执行脚本,脚本所造成的影响会直接改变当前Shell的行为。

方式如下:

source .bashrc

正因为它这个特性,所有它常常被用来执行一些配置文件。

下面是一个例子:

#!/bin/bash
# test.sh
echo $foo

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

image.png

上面例子中,由于变量foo只存在于当前Shell,所以直接执行是无法读取,但是source由于就是在当前Shell中执行,所以可以读取。

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

#!/bin/bash

source ./lib.sh

function_from_lib

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

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

. .bashrc