Shell脚本开发基础

  虽然不是运维狗,不需要使用shell来写主程维持生计,但是还是感觉平时用shell比较多的,其中命令行和脚本方式都用。虽然对于C++程序员来说Python的语法更为的亲切上手,而且对应的库也聆郎满目,shell可以直接方便的调用命令行工具,比如date、awk、sed,从而做些简单的处理或者控制系统更为的直接,信手拈来增加生产率也实觉容易,还是印证了那句话,没有绝对的熟优熟劣,存在的就是合理的。

一、背景

1.1 脚本执行方式

  当一个shell启动的时候,会依次按照/etc/profile、~/.bash_profile、~/.bashrc、/etc/bashrc的顺序加载配置文件,后面对环境变量的重命名可以覆盖之前的设置值。执行脚本通常有以下格式:
  (1) bash script-name.sh: 当脚本本身没有可执行权限的时候可以这么执行,或者脚本的开头没有执行脚本执行解释器的时候。
  (2) path/script-name.sh或./script-name.sh: 当脚本本身有可执行权限的时候可以这么运行。
  (3) source script-name.sh或. script-name.sh: 这会读取脚本内容并执行脚本中的所有语句,其区别是该脚本是在当前shell中执行的,而不是像其他方法那样会开启一个子shell进程去执行,其通常用作加载shell脚本库,将脚本中的环境变量、函数等导入到当前的shell中来。
  通过bash -x script-name.sh是调试脚本的一种很好的习惯,它会把执行的脚本内容输出到显示器上,在输出的内容中以+开头的表示是程序的代码,其他部分的是正常输出,这样脚本出问题的时候就可以快速定位到是哪一行异常。有时候,如果整个脚本都输出可能内容过多会分散注意力,就可以使用set -x和set +x的方式只针对某一局部内容进行输出调试,其他部分的脚本正常执行即可。

1.2 环境和配置加载

  shell可以分成interactive login、interactive non-login、non-interactive这几种类型:
  interactive login, 启动首先会读取加载/etc/profile,然后依次执行 ~/.bash_profile(这个文件会调用加载~/.bashrc)、~/.bash_login、~/.profile,而当其退出的时候,会执行~/.bash_logout。interactive shell是标准输入和标准错误输出都被绑定到了终端terminal上,因为启动脚本会加载设置\$PS1,所以可以通过[ -z “\$PS1” ]可以检查当前shell是否是交互式shell;需要在字符模式输入密码的情况,比如在终端上或者ssh远程到远程主机的情况,都是login shell。
  interactive non-login,启动时候会读取加载~/.bashrc文件。
  non-interactive,通常是执行脚本所用的shell,其会检查环境变量\$BASH_ENV,如果其指定了某个文件就会加载该文件。
  remote shell,当shell是被rshd、sshd启动的时候,它也会尝试读取和加载~/.bashrc文件。
  需要区分他们的主要原因是他们自动加载的配置文件会有所差异,interactive loginshell的功能自然是最齐全的,而如果想要配置在大多情况下生效,最好还是能被~/.bashrc直接或者间接加载到,他才是大多数情况下都会被自动加载的配置文件。

二、语法

2.1 变量

2.1.1 特殊位置变量

  shell中有一些特殊的位置变量,他们在命令行、函数、脚本执行时传递参数中会被涉及到,他们的定义和含义有:

1
2
3
4
5
6
7
8
9
10
11
$0  获取当前执行Shell脚本的文件名,如果执行脚本本身包含了路径,则该变量也包含路径信息。使用
系统的dirname工具可以获取脚本的路径,使用basename可以获取脚本不含路径的名字;
$n 表示传递进来的具体参数,n=1..9,当超过9个参数的时候,引用需要使用花括号保护起来${10};
通过shift命令,会将做左端参数略去然后右端参数依次重新命名,即原来的$2变成$1,原来的$3
变成$2并依次类推,同时参数$#减1,这个工具很多时候在工具传递参数名和参数值的时候,略去
参数名取参数值的时候很方便;
$# 获取当前执行Shell脚本后面跟接的参数的总个数;
#* 获取当前脚本执行的所有参数,其不加""和下面的$@相同,如果加上双引号,则所有的参数被视为
单个字符串形式,形如"$1 $2 $3 ...";
#@ 不加""和上面的$*相同,如果加了双引号,则表示每个参数为不同的独立字符串,形如"$1" "$2"
"...",这是将参数完美转发给其他程序的完美方式,因为它会保留每个参数内部可能的空白字符。

  通过下面的set命令可以在当前的shell中产生位置变量,然后通过for访问这些位置变量:

1
2
➜  ~  set -- "I am" a boy
➜ ~ for i in $*; do echo $i; done

  \$?可以获取上一个指令执行状态返回值,0表示成功。若在脚本中调用exit n,或者函数中调用return n的时候,n就会自动被传递到\$?中去,因为这个值根据每个命令执行的结果会实时的变动,所以一般命令刚执行完后立即赋值给一个变量保存(比如取名RETVAL)后再做处理才是有意义的。

2.1.2 变量子串

  主要是针对当前已经存在的变量,可以通过特殊的手段方便访问其属性、部分值或者全部值,甚至可以对变量的某些内容进行替换操作。

1
2
3
4
5
6
7
8
9
10
11
12
${param}              返回变量的全部内容;
${#param} 返回变量内容的长度,以字符计数;
${param:offset} 返回变量从位置offset开始后的子串;
${param:offset:len} 返回变量从位置offset开始长度为len的子串,这个目的其也可以使用
echo ${param} | cut -c offset-offset+len来实现;
${param#word} 从变量开头开始删除最短匹配word的子串,如果将#改为%则表示从结
尾开始;
${param##word} 从变量开头开始删除最长匹配word的子串,这个和上面相比的差异,主
要是在引入*这类正则表达式的时候会有差异,如果将#改为%则表示从
结尾开始;
${param/pattern/str} 使用str代替第一个匹配的pattern;
${param//pattern/str} 使用str代替所有匹配的pattern;

  下面展示了一个批量重命名文件的方法。

1
2
3
4
5
6
➜  ~  touch stu_111_{1..5}.jpg
➜ ~ ls
stu_111_1.jpg stu_111_2.jpg stu_111_3.jpg stu_111_4.jpg stu_111_5.jpg
➜ ~ for f in $(ls stu*.jpg); do mv $f ${f//_111/}; done
➜ ~ ls
stu_1.jpg stu_2.jpg stu_3.jpg stu_4.jpg stu_5.jpg

2.1.3 特殊扩展变量

  扩展变量体现在考量变量是否赋值的情况下的默认行为,包括是否对变量重新赋值、返回内容是什么的特殊规则。

1
2
3
4
5
6
7
8
${param:-word}  如果变量值为空或者未赋值,则返回word字符串代替,param仍然保持值
为空或者未赋值的状态,主要在变量未定义的时候提供备用的值代替;
${param:=word} 如果变量值为空或者未赋值,则设置这个变量的值为word,并返回其值,和上
面的差异就是在变量为空或者未赋值的时候,在返回的同时也对原变量进行了重复值;
${param:?word} 如果变量值为空或者未赋值,则word字符串将被作为标准错误输出的内容,否则
输出变量的值。这个主要用来捕捉由于变量未定义导致的错误,并退出程序的执行,
比如echo ${key:?not defined};
${param:+word} 如果变量的值为空或者未赋值,则什么都不做,否则word字符串将替代变量的值;

  注意的是,上面的:符号都是可以省略的,如果没有:符号,则上面的变量为空或者未赋值语义会被替换成仅仅未赋值语义。

2.2 数值计算

  Shell中支持的算数运算命令比较多,最常用的是(()),虽然他仅仅支持整数运算,但是语法简单。

2.2.1 (())

1
2
3
4
((i=i+1))     此为运算后赋值法,这通常作为但一条语句,因为如果外部使用该值必须添加$,
而直接计算的时候不需要添加$符号;
i=$((i+1)) 将运算后的结果赋值给i;
((8>7&&5!=3)) 可以混合比较计算和逻辑计算,为真的时候可以取值为1;

  该计算方法还支持诸如2**3、5%3、a+=3一些稍微复杂的点的算数运算,乘除运算自然也不在话下,不过其使用的变量或者数字必须是整形,不能是小数或者字符串类型。
  工具let和expr也可以进行整数的计算,功能类似差不多,这里就不掌握那么多“茴”字的写法了。

2.2.2 awk

  如果需要计算小数,则推荐使用awk工具,使用比较简洁明了,而且也建议这么做。如果需要额外开启一个shell专门做算数的话,可以启用bc工具。

1
2
➜  ~  echo "6.5 342" | awk '{print ($1-$2)}'
-335.5

2.3 条件测试和比较

  shell中条件测试的语法很多,比如[[ ]]、test、[ ]、(( )),前三者待测试的表达式和符号之间需要至少一个空字符,而(( ))用于整数关系运算符,且符号的两端不需要有额外空格。上面的几个语法只有[[ ]]支持的最广泛完善,建议统一语法只使用它,其支持通配符模式匹配、比较运算符(>、<=等)和逻辑运算(&&、||、!)符等功能,表示方法也是常见的形式。

2.3.1 文件测试表达式

1
2
3
4
5
6
-d   文件存在且为目录类型
-f 文件存在且为普通文件
-L 文件存在且为链接类型
-e 文件存在
-r -w -x 文件存在,且可读、可写、可执行
-s 文件存在且大小不为0

2.3.2 字符串测试表达式

1
2
3
-n    字符串长度部位0
-z 字符串长度为0
= != 字符串相同、不相同,比较的时候两端一定要有空格,也可以用==代替=

2.4 控制语句

(1) if

1
2
3
4
5
if xxx; then
;
else
;
fi

(2) case
  case在系统启动服务脚本这类情况中经常被使用,相比很长的if-else更加的清晰可辨识,当匹配到某个值后会执行后面的语句直到遇到;;或者esac结束为止,每个执行条目的值可以使用|进行联合,如果所有case的值都不符合则执行 *) 后面的指令。

1
2
3
4
5
6
7
8
9
10
11
case $color in
red)
echo "\$color is red."
;;
green|blue)
echo "\$color is green or blue"
;;
*)
echo "what color?"
;;
esac

(3) while/until

1
2
3
4
while [[ var -lt 10 ]]; do
((var++))
echo "now var is $var"
done

  除了上面最简单的使用情形,while还经常被用来从某个文件中不断地读取数据直到文件末尾,而常用的读取文件的方式有:

1
2
3
4
5
6
7
cat $FILE_NAME | while read line; do
cmd
done

while read line; do
cmd
done < $FILE_NAME

(4) for
  for语句用于向一个列表中不断的取值,虽然for还支持一种C/C++语言类型的访问形式,但是不去折腾那玩意儿了,因为从来没从正规脚本中看到用过。

1
2
3
4
for color in red green blue 
do
echo "\$color is $color"
done

  说到列表,就不得不提C++中可以快速的生成相关序列,比如{1..100}、{a..z}了,之前手动for生成连续的整数序列,真是太弱智了。

2.5 函数

  shell这么好用,就是因为命令行的好多工具都是写好的Shell函数构成的,不带参数的函数直接输入函数名就可以执行,而带参数的函数调用,在函数体中可以使用位置参数(\$1、\$2… \$#、\$*、\$?、\$@)来进行访问,父脚本的位置参数此时会被隐藏,不过参数\$0比较特殊,它还是表示执行脚本名。
  shell执行各种程序的顺序是:系统别名、函数、系统命令、可执行文件,当需要加载其他脚本中的函数时候,需要使用.或者source命令来加载。

1
2
3
4
function func() {
...
return n
}

  shell中的变量有三种类型的作用域:shell函数中的变量可以使用local方式进行定义,只在当前的函数作用域中有效;而在函数外边定义的变量则是文件局部变量,其只被当前的shell可以使用;而全局变量通过export进行导出,或者使用declare -x的语法进行声明,可以被当前shell和其派生的所有子进程所使用。

2.6 数组

  数组有以下定义方式:

1
2
3
4
array1={val1 val2 val3}
array2[0]=val1; array2[1]=val2; array2[2]=val3;
declare -a array3 = {val1 val2 val3}
array4=($(cmd)) # 命令的输出作为数组的内容

  数组元素也有特殊的访问形式

1
2
3
${arr_name[@]} ${arr_name[*]}    # 数组的所有元素
${#arr_name[*]} ${#arr_name[@]} # 数组元素的个数
${arr_name[$]:1:3} # 数组的1到3号元素

  因为数组也是变量,所以上面的#符号和之前的变量子串是同样的语义。

1
2
3
for item in ${arr_name[*]}; do
echo "current item: $item"
done

  通过命令unset arr_name[n]可以清除相对应的数组单个元素,而不带下标的话表示清空整个数组的所有数据。

2.7 shell中的正则表达式:

  对于UNIX/Linux,很多程序的流派比较的多,所以正则表达式的混乱不堪也是司空见惯了。下面是shell中的元字符和正则表达:

1
2
3
4
5
6
7
8
9
\  按照字面解释后面的字符,比如\$就解释为$而不是变量解析
& 进程后台处理
$ 替换变量
? 匹配单个字符
* 匹配任意0个或者多个字符
[abc] 匹配字符列表中的一个
[!abc] 匹配除字符列表外的字符
(cmd) 在子shell中执行命令
{cmd} 在当前shell中执行命令

三、脚本规范

  Google在网站发布了Shell脚本规范,虽然不是脑残粉,但是绝大多数东西还是向着大厂看齐的。
  Shell只允许使用bash,脚本头使用#!/bin/bash,扩展名为.sh。可运行的脚本才允许添加执行权限,作为库使用的不可执行的脚本不允许有执行权限,所有脚本不允许有UID|SGID权限,如有权限需要使用sudo执行。
  如果不是短小、含义明确的函数需要提供注释信息,所有的库函数都需要提供注释信息。对于临时的、不完善的解决方法提供TODO的注释信息。
  所有文件2-Space缩进,每行最多80个字符, ; do ; then这些关键字需要和while、if、for放在同一行。
  执行命令使用\$()的格式代替反引号,因为后者很容易和单引号弄混,测试条件的时候使用[[代替[、test。不允许使用eval。
  对于shell函数,function和函数后的括号需要保留,函数名使用小写字符和下划线组成。一般变量名适合函数名使用相同的命名规则;对于常量和环境变量使用全大写字母命名,并且尽量定义在脚本的开始位置;脚本文件名使用小写字母和下划线组成。对于不需要修改的只读变量,使用readonly或declare -r进行声明。对于函数作用域的局部变量,使用local声明,而且声明和赋值请放置在单独的两行上。函数定义应该紧接着放在常量和环境变量下面,而且函数定义应该聚集在一块,函数定义之间不应该夹杂可执行代码。如果一个脚本的函数超过一个,那么应该定义一个main函数,用于脚本的执行入口点。
  只有定义数值类型变量才不需要加引号,对于普通字符串变量定义应该使用”,而需要所见即所得的字符串则使用’,通常如果字符串需要进行转意的时候,使用’定义而减少转意的使用。还需要注意的是,awk处理单引号、双引号的方式和shell相比比较另类,所以遇见这种情况最好echo \$xxx | awk 的方式进行先解析然后管道传递的方式以防生变,而sed处理单引号和双引号的方式和bash是一致的。
  函数的返回值总应该进行检查和处理。

本文完!

参考文献