Shell 脚本编程
本教程分为两篇:
- 上篇《Shell 基础知识》:必看,每一个开发者都必须了解的命令行基础知识。
- 下篇《Shell 脚本编程》:建议看,了解 shell 脚本编程基础,能够阅读和编写简单脚本工具。
这是下篇部分,许多内容与上篇相关,建议先阅读上篇。本文的目标是让读者对 shell 脚本有一个整体的认识,不会细致介绍命令的使用,如有需要请查阅命令手册。
1. Hello Shell
从一个简单的脚本开始。
hello.sh
#!/bin/bash
echo "Hello Shell!"
1.1. Shebang 指令
脚本的第一行以 #! 开始,称为 shebang 指令,用于指定运行该脚本的解析器。
当一个文件被执行时,计算机会先判断它是否是一个二进制文件,如果不是,就调用 shebang 指令指定的解析器执行该文件。
如果没有解析到 shebang,默认使用 /bin/sh。
如果是显式调用解析器执行脚本(见下),则 shebang 指令会被忽略不起作用。
Shebang 指令是所有脚本语言通用的。在 JS 与 Python 中,一般写成:
# JS 脚本中
#!/usr/bin/env node
# PY3脚本中
#!/usr/bin/env python3
因为这些解析器的安装环境不确定, /usr/bin/env 命令可以找到系统中的安装路径,是一种兼容性更佳的写法。
💡
1.2. 执行脚本
1.2.1. a) 作为可执行文件执行
以文件名的方式直接运行文件,需要先添加执行权限:
# 给所有用户添加执行权限
chmod a+x hello.sh # 同 chmod 755 hello.sh
./hello.sh
注意,这里的路径前缀 ./ 是必须的。缺少路径前缀,会被识别为命令,然后根据 PATH 变量中的路径查找。但 PATH
变量不包含当前目录,这是出于安全性的考虑,具体可以参考 What’s wrong with having ‘.’ in your $PATH? 。
1.2.2. b) 指定解析器执行
调用 Bash 执行脚本,这种情况下文件不需要有可执行权限,路径前缀也不是必须的,shebang 指令会被忽略。
bash hello.sh
1.2.3. c) 作为命令执行
如果你有一个经常需要执行的脚本,希望能像命令一样去执行它。
很简单!根据上面的说法,只要你把文件移动到 PATH 变量下的任意目录即可,推荐 /usr/local/bin。
# 基于上面 hello.sh 例子
# 把后缀去掉,这样更像命令
mv hello.sh /usr/local/bin/hello
hello
# Hello Shell!
或者创建一个文件软链,这样就不用移动文件。
ln -s ./hello.sh /usr/local/bin/hello
不过,更推荐使用别名(见上篇)。
# 不要使用相对路径,这样可以在任何目录下调用
alias hello="bash ~/hello.sh"
hello
# Hello Shell!
2. 变量
见上篇。
3. 输入输出
三个内置命令,简单说下。
输出:
echo: 打印内容到标准输出。printf: 格式化输出,类似 C 的 printf 函数。printf "保留2位小数:Pi= %1.2f" $PI # 3.14
输入:
read: 从标准输入读取用户输入# 展示输入提示,用户输入值被保存到变量name中 read -p "请输入任意值" name # 利用重定向,从文件中读取输入值 read name1 name2 < file.txt
4. 流程控制
4.1. 顺序结构
命令默认以从上到下顺序执行,除非一些特殊的运算符和复合命令作用。
即使某条命令执行异常,也会继续执行后面的命令。这点与大部分编程语言不同。
分号 ; 是命令的结束标识,标志着命令的结束,或者复合命令中的一部分结束。换行符前面的分号是可以省略的
。常见的风格是使用换行,省略分号,即一条(简单)命令占一行。
在命令行中,回车用于表示执行命令,用分号可以做到一行中键入多个命令,或者执行复合命令。
# 一次性输入多个命令
command1; command2; command3
# 执行 if 命令
if true; then echo "true";fi
4.2. 条件结构
4.2.1. && 和 || 运算符
上篇提到,
test-command && command1 || command2
是一种简单实用的条件执行写法。如果 test-command 为真(状态码为 0)就执行 command1,否则,执行 command2。
4.2.2. if 命令
if test-commands; then
branch-commands;
[elif more-test-commands; then
more-brach-commands;
]
[else
alternative-commands;
]
fi
💡 为了强调语法,我会在这些复合语句中使用了显式的分号
;。记住,一行中最后的分号是可以省略的,你可以调整分号和换行,选择不同的风格写法。下同。
[ ]表示elif和else分支是可选的部分,不是语法的一部分。- 单词
commands用了复数,代表这部分可以是多个命令。 if ... fi用关键字的镜像作为该结构的起止标志符,是一种常见做法。
每个 if/elif 分支,先执行 test-commands ,如果最后退出码为 0,即条件为 true,执行对应的分支命令,否则进入下一分支。
Shell 没有表达式语法,使用命令执行后的退出码进行条件判断,退出码为 0 表示 true,非 0 表示 false。
test 命令是一个专门用于条件判断的内置命令,它支持一系列条件表达式,当条件成立时退出码为 0,不成立时为 1。
test expr
# 或者使用简写
[ expr ]
# 还有一种拓展的test,在原有的基础上支持了正则匹配
[[ expr ]]
这里不展开 test
命令的具体使用,可以参考 Bash test and comparison functions - IBM Developer
,或者使用 help test 查看帮助手册。
当判断条件为算术运算时,也经常使用算术表达式 (( expr ))(见下)。
下面是一个简单例子。
file-exist.sh
#!/bin/bash
filename=$1
if [ -e $filename ]; then
echo "文件${filename}存在"
if [ -d $filename ]; then
echo "这是一个文件目录"
elif [ -b $filename ]; then
echo "这是一个块文件"
elif [ -c $filename ]; then
echo "这是一个字符文件"
else
ls -l $filename
fi
else
echo "文件${filename}不存在"
fi
bash file-exist.sh shell-basis.md
# 文件 shell-basis.md 存在
# -rw-r--r-- 1 me staff 23040 Jun 16 18:11 shell-basis.md
4.2.3. case 命令
case word in
[ [(] glob-pattern ) commands ;;]…
esac
- 使用 glob 模式匹配,不是正则。
- 模式用括号包裹,括号左边经常省略,右括号不能省略。
- 子句必须用
;;,;&或;;&结尾(不可省略),它们会影响执行该子句之后的行为。;;同 break,退出 case 结构,不再往下执行。;&表示直接执行下一个子句的命令(不管是否匹配),与其它语言漏掉break类似。;;&表示接着进入下一子句的匹配,好像没有子句命中过一样。
- 最后的
...表示可以出现多个 case 子句。 - 可以在最后一个子句中使用模式
*作为default分支。
同样,看一个例子。
#!/bin/bash
cat <<TIP
你最喜欢的编程语言是?
1) C++
2) Java
3) Python
请输入对应的数字:
TIP
read input_num
case $input_num in
1 )
lang="C++"
echo "C++ 性能优越。"
;;
2 )
lang="Java"
echo "Java 神通广大。"
;;
3 )
lang="Python"
echo "Python 简单高效。"
;;
* )
echo "无效输入"
;;
esac
4.3. 循环结构
4.3.1. while 命令
while test-commands;
do
consequent-commands;
done
当型循环,当 test-commands 成立时,执行 consequent-commands。
4.3.2. until 命令
until test-commands;
do
consequent-commands;
done
直到型循环,执行 consequent-commands 直到 test-commands 成立,退出循环。
4.3.3. for 命令
for 命令有两种语法格式:传统的 for...in 格式和 C 风格的 for(( expr1; expr2; expr3 )) 格式。
4.3.3.1. for...in
for variable [in words];
do
commands;
done
可以理解成对 in 后面的字符串以词(word)为单位做遍历,每次循环,把本次循环的值保存在 variable 变量中,可以在 commands
中使用该变量。
in words 部分内容可以省略,如果省略,默认为 in "$@", 即对所有的命令参数做遍历。
举个例子:
for option in A B C D; do echo -n $option; done
# ABCD
words 部分会执行展开,可以利用这种特性快速生成遍历内容,看两个简单例子:
- 利用大括号展开重写上例:
for option in {A..D}; do echo -n $option; done - 利用文件名展开,遍历当前目录下所有的
.txt文件:for txt_file in *.txt; do echo $txt_file; done
4.3.3.2. for(( expr1; expr2; expr3 ))
for(( expr1; expr2; expr3 ));
do
commands
done
expr1, expr2, expr3 都是算术表达式。注意这里使用双括号,与算术表达式语法相同。
执行结构与 C 语言 for 语句一样。等效于:
(( expr1 ))
while (( expr2 )); do
commands
(( expr3 ))
done
看个例子:
for(( option=1; option<1+4; option++ ));do echo -n $i; done
# 1234
5. 正则表达式
5.1. BRE 和 ERE
必须留意一点,Bash 的正则表达式语法有两种,基础正则表达式(Basic Extended Regular, BRE)和拓展正则表达式(Extended Extended Regular,ERE),并且很多地方默认使用的是 BRE。
它们的区别在于以下几个字符:
( ) { } ? + |
在 BRE 语法下,它们是普通字符,用 \ 转义后,才具有元字符的含义; 相反,ERE 语法下,它们属于元字符,在转义后成为普通字符。
5.2. 匹配模式
Shell 的正则表达式和大部分语言类似。总结如下:
字符匹配
| 模式 | 含义 | 示例 |
|---|---|---|
| 普通字符 | 精确匹配,匹配对应的字符 | abc 只匹配 abc |
. |
匹配任意一个字符 | js. 匹配 jsp,jst,但不匹配 json |
[abc] |
范围匹配,匹配abc 中的任意一个字符 |
[ab]\.txt 匹配 a.txt, b.txt 但不能匹配 d.txt, ab.txt[a-zA-Z] 匹配任意一个大小写字母。 |
[^abc] |
范围排除匹配,匹配除了abc 以外的任意一个字符,用法类似 [abc] |
[^0-9] 匹配任意一个非数字 |
位置匹配
| 模式 | 含义 | 示例 |
|---|---|---|
^ |
匹配开始位置 | ^test 匹配以 test 开头的字符串 |
$ |
匹配结束位置 | \.js$ 匹配以 .js 结尾的字符串 |
数量修饰符
| 修饰符 | 含义 | 示例 |
|---|---|---|
? |
其前面的模式出现 1 次或 0 次 | colou?r 只匹配 color 或 colour |
+ |
其前面的模式出现 1 次以上 | id-[0-9]+ 匹配 id-0,id-44 等,不匹配 id- , id-2k |
* |
其前面的模式出现任意次 | ahh* 匹配 ah,ahhhhh |
{n,m} |
其前面的模式出现 n 次到 m 次 | [a-z]{5,7} 匹配 5 到 7 个小写字母 |
{n,} |
其前面的模式出现 n 次及以上 | [a-z]{5,} 匹配 5 个以上小写字母 |
{,m} |
其前面的模式出现 不超过 m 次 | [a-z]{,7} 匹配不超过 7 个小写字母 |
其它特殊字符
| 符号 | 含义 | 示例 | |
|---|---|---|---|
() |
改变优先级,视为整体 | py(thon)? 匹配 py 和 python |
|
| ` | ` | 匹配其左右任意一个模式即可(低优先级) | py|python 匹配 py 和 python |
5.3. grep 命令
grep 是一个常用的模式匹配文本处理工具。对给定的文件,它会以行为单位,打印出能匹配模式的整行文本内容。
grep [options] [pattern] [file ...]
# 在这篇文档中检索 shell ,-i 忽略大小写。
grep -i shell shell-scripting.md
如果不输入文件,会从标准输入中读取内容。利用这点,grep 经常被应用在流水线中,作为过滤工具,匹配出含有特定模式的内容。
history | grep "echo"
6. 算术表达式
6.1. 算术运算
Shell 采用和 C 语言相同的运算符和运算优先级。支持的运算包括:
- 自增、自减(包括前置后置)。即
i++,i--,++i,--i. - 正负。即
+i,-i. - 基本算术运算:
+ - \* / % - 比较:
== != > < <= >= - 位运算:
~ & | ^ << >> - 逻辑运算:
! && || - 三元运算:
expr1?expr2:expr3 - 赋值:
= += -=…
在逻辑运算和比较运算时,用 0 表示真,1 表示假。
6.2. 支持算术表达式的几个命令
不能像这样直接使用算术表达式:
# 错误用法
sum=1+1; echo $sum # 1+1
只有在使用特定的命令时,才会以算术表达式的形式解析。主要有:
- 算术展开
$(( expr ))sum=$((expr)); echo $sum #2 let命令let sum=1+1; echo $sum #2let简写形式(( expr ))(( sum=1+1 )); echo $sum # 2declare -ideclare -i sum=1+1; echo $sum #2
(( expr )) 和 let expr 命令会在表达式结果非 0 时,设置退出码为 0,反之,退出码为 1。因此,它们也经常作为 if 命令的判断。
7. 函数
与其它语言一样,编写函数能有效提升代码的组织结构和代码复用。
7.1. 函数的定义
函数的语法可以表示成:
fname(){
commands
return
}
还有一种不推荐的写法 function fname(){}
,目前已经废弃。
和其他语言类似,内置命令 return 用于退出函数,函数体最后一行的 return 可省略。
函数的定义本身也是一个命令(关键字),它在执行环境中创建一个函数名到函数体的引用。除非发生语法错误,函数定义的退出码总是为 0。
根据脚本的顺序执行特点,函数的定义必须位于其使用之前。
7.2. 函数的使用
Shell 函数是可以看成命令,执行函数和执行其它命令是一样的。
fname [arguments...]
7.3. 函数内的位置变量
执行函数时,位置变量 $N(N>0) 和 特殊变量 $#, $@, $* 会被赋值成调用函数时的参数对应值,执行完成后再恢复。
function-position-parameters.sh
#!/bin/bash
func(){
# $0 仍然指向脚本文件名称
echo "\$0 = $0"
# 其它位置参数被更新成函数调用时的参数
echo "参数个数 $#, 分别为 $@"
# 函数的名称存储在环境变量 FUNCNAME 中
echo $FUNCNAME
}
# 给这个函数传参执行
func 1 2 3
执行该文件后输出:
$0 = function-position-parameters.sh
参数个数 3, 分别为 1 2 3
func
7.4. 局部变量
在函数内,可以使用 local 命令声明局部变量。
variable-scope.sh
#!/bin/bash
foo(){
local var="var in foo"
bar
}
bar(){
echo $var
var="var in bar"
echo $var
}
var="var in global"
foo
echo $var
输出:
var in foo
var in bar
var in global
foo 函数中,使用 local 声明了一个局部变量 var,覆盖了同名的全局变量。 foo 函数调用了 bar 函数,bar
函数内向上查找到了 var,并且修改了局部变量。 最后,当退出函数时,局部变量释放,var 的值为全局变了。
如果不使用 local,变量默认具有全局作用域。也就是说,如果存在同名全局变量,就修改它,如果不存在,就创建一个全局变量。
为了不污染全局作用域,如果一个变量只在函数内使用,建议声明为本地变量。
8. 处理命令行参数
上篇说到,特殊变量与位置变量可以获取命令行参数,这里我们说一下对参数的基本处理。
8.1. shift 命令
shift 命令用于从左边删掉 n 个位置参数:
shift [n] # n 默认等于1
当我们从左到右依次处理参数时,可以使用这个命令,去掉已经处理过的参数。比如,考虑一个支持子命令的工具,对应的子命令放在其 /bin 目录下,可以这样处理工具入口:
$sub_cmd=$1;
shift
/utility_path/bin/$sub_cmd "$@"
8.2. 选项解析
上篇提到,shell 语法上并不区分参数中的选项和非选项,这些是在脚本内去解析的,一般有 3 种方式:
- 手动解析:参数复杂的时候,解析成本高。 *
内置命令 getopts :推荐,遵循 POSIX 规范,不过不支持长选项。 :
*
外部命令 getopt :linux 命令,能够解析长选项。
这里以 getopts 为例,看看如何解析脚本的参数。
getops optstring name [arg ...]
其中,optstring 是一个选项描述,"ab:c" 表示期望 "-a -b bvalue -c" 的形式个选项,b 后面的冒号表示 b 选项后面紧跟着一个选项值。
getopts 应该在 while 循环中使用, 它每次解析一个选项,如果解析到一个选项,选项会被保存到变量 $name 中, 退出码为 0,进入
while 循环体;如果解析到不存在的选项,$name 的值为?,退出码依然为 0;当解析到第一个非选项的时候,退出码为 1,结束循环。
命令使用了两个隐式变量$OPTIND (OPTion INDex) 和 $OPTARG (OPTion ARGument)。$OPTIND 记录了下次解析的位置(从 1
开始),在每次执行脚本时被设置为 1,并在解析后累加。$OPTARG 记录了当前选项对应的值(如果存在)。
下面是一个脚本例子。
logrm.sh
#!/bin/bash
# 用法:
#*******************************#
# rmfile options files ... #
#*******************************#
# -c 二次确认
# -m message 必须,删除备注,保存在操作日志中
confirm=0 # 默认不需要二次确认
while getopts "cm:" option
do
case option in
c) confirm=1 ;;
m) message="$OPTARG" ;;
?) echo "参数错误"; exit 1 ;;
esac
done
shift $(($OPTIND-1)) # 去掉参数中被解析的选项部分
files="$@"
default_message="删除文件 $files"
if (( $confirm ));then
read -p "确定删除文件?输入y确定:" input
if [[ $input == [^yY] ]]; then
exit 1;
fi
fi
if rm $files; then
echo "log: ${message:-$default_message} " > log.txt
fi
9. 数组
Bash 虽然没有数据结构,但确实有一维数组,可以方便地处理一列数据,关于数组的内容,请移步我的另一篇文章 。
10. 更多资料
- Advanced Bash-Scripting Guide :一份不错的 shell 脚本教程。
- ShellCheck:shell 脚本检查工具。
- Google Shell Style Guide: Google shell 风格指南。
留下评论