shell脚本编程
工作中使用shell脚本比较多, shell脚本需要注意的细节也比较多, 一不留神就容易出错, 而脚本也没有类似编译器之类的辅助检查, 直接运行可能会导致问题.
此篇文章总结了shell脚本编程的一些知识点, 以便在编写shell脚本时, 通过回顾这些知识点, 能够避免一些常见的错误.
shell环境变量
局部环境变量
var="hello world"
# 局部变量, 子shell无法获取var
局部环境变量只对创建它们的shell可见, bash程序执行脚本时将新启一个shell环境, 这意味着执行脚本所在的shell中的局部环境变量在脚本执行过程中是不可见的.
全局环境变量
var="hello world"; export;
# 局部变量导出为全局变量, 此时子shell也能获取该变量
全局环境变量对于当前shell和所有生成的子shell都是可见的, 局部环境变量可以通过export命令
导出为全局环境变量.
不同shell下的环境变量
子shell可以定义同名变量, 子shell修改变量不会影响到父shell中该变量的值, 即使再次使用export也不行.
printenv命令
查看全局环境变量, env命令
修改全局环境变量, set命令
查看当前shell的所有环境变量, unset命令
删除环境变量.
执行set | less
可以发现系统启动就已经预定义了非常多的环境变量如: HOME,PATH..., 它们通过执行环境文件时设置.
shell环境文件
shell通过执行环境文件(通常是脚本), 为系统设置初始环境变量. 启动shell的方式会影响将执行何种环境文件, 也就意味着不同shell环境下环境变量可能不同(用户可能根据不同的喜好, 修改一些环境文件使shell更符合他们的使用习惯).
linux系统上有两类环境文件:
系统环境文件: /etc/profile
(执行/etc/profile
会间接执行/etc/profile.d
目录下所有文件)
用户环境文件: 此类文件若执行, 只会优先选择并执行其中一个文件. 优先级$HOME/.bash_profile
(间接执行$HOME/.bashrc
) > $HOME/.bash_login
> $HOME/.profile
不同shell启动方式的区别
是否使用或何时使用这些文件和shell的启动方式有关, 启动shell的方式有以下三种:
- 登录shell(一般情况), 此时先执行
/etc/profile
, 然后在执行$HOME
目录下的用户环境文件, 按优先级选择执行 - 非登录的交互式shell(执行
bash命令
的情况), 此时会直接执行$HOME/.bashrc
- 运行脚本的非交互shell(脚本中启动子shell): 此时不会执行任何环境文件, 它的环境变量全部继承自父shell, 另外会查看BASH_ENV环境变量(一般为空), 若有则执行其指定的环境文件.
由此可以知道, 在shell脚本执行时, 哪些环境变量是可用的(系统环境变量, 用户定义的全局变量等)
保存环境变量
- 存到
$HOME/.bashrc
(推荐): 在该文件存储个人使用的环境变量. - 存到
/etc/profile
: 该文件升级系统会被更新, 所以设置可能会被覆盖. 最好在/etc/profile.d
目录创建一个sh脚本, 把所有新的和修改过的全局变量放在这里.
shell脚本语法
-
脚本解释器
shell脚本文件第一行
#!/bin/bash
用于标识使用/bin/bash
来处理文件中的内容.
更一般的用法, 使用#!
指定其他解释器来执行脚本, 例如若文件的第一行是#!/usr/bin/python
, 则标识文件为为python脚本. 需要注意#!
必须在第一行, 解释器必须为绝对路径.
执行脚本需要可执行权限, 也可以使用相应的解释器将脚本文件作为参数来执行脚本. -
命令行参数
在脚本中获取命令行参数
$0
是程序名,$1
是第一个参数,$n
是第n个参数(n>9时则需要使用\${n}).
另外,$#
命令行参数的个数,${!#}
获取最后一个参数,$*
会将命令行上提供的所有参数当作一个单词保存,$@
变量会将命令行上提供的所有参数当作同一字符串中的多个独立的单词处理命令行参数
shift命令
, 跳过不需要的参数, 此命令在做参数处理是很好用while [ -n "$1" ]; do case "$1" in --option ) command ;; # 如果有参数, 通过$2获取参数后shift一次 --option_with_param ) command $2; shift ;; * ) ;; esac shift # 参数处理后shift, $1将获取下一个参数 done
-
变量
变量赋值
var="hello world"
需要注意等号前后不要有空格, 否则就会被处理成命令
变量的类型会根据值自动决定变量取值
echo $var
# 等价于echo ${var}
通过在变量前加上$
符号来读取变量值, 有时候需要加上花括号${var}
用以标识变量变量取值的同时可以做一些处理, 如下:
若parameter不存在或为空时
${parameter:-word}
展开结果为word
${parameter:=word}
展开结果为word, 并且将word赋值给parameter(特殊参数不能以这种方式赋值)
${parameter:?word}
脚本错误退出, 并且word的内容会发送到标准错误)若parameter有值, 则展开结果为word, parameter本身不会被改变
${parameter:+word}
获取字符串长度
${#parameter}
获取子串
${parameter:offset}
offset支持负数, 但负号前需要加空格, 避免和${parameter:-word}
混淆
${parameter:offset:length}
length必须大于0清除开头一部分文本
${parameter#pattern}
非贪婪模式
${parameter##pattern}
贪婪模式清除结尾一部分文本
${parameter%pattern}
非贪婪模式
${parameter%%pattern}
贪婪模式字符串匹配替换(
/string
省略掉意味着删除匹配的字符串)
${parameter/pattern/string}
替换一次
${parameter//pattern/string}
替换所有
${parameter/#pattern/string}
替换所有
${parameter/%pattern/string}
匹配头部并替换大小写转化
${parameter,}
首字符大写
${parameter,,}
全部小写
${parameterˆˆ}
首字符大写
${parameterˆˆ}
全部大写执行命令结果赋值到变量
var=$(echo "hello world")
通过$(command)
将命令包裹, 此时将创建一个子shell来执行命令
有时候直接使用可能有问题, 如下
$ var=$(cal); echo $var January 2022 Su Mo Tu We Th Fr Sa 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 $ var=$(cal); echo "$var" January 2022 Su Mo Tu We Th Fr Sa 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
大多数情况下, 希望得到的是第二次的执行结果. 为何出这样?
前者展开为很多参数传入echo, 参数之间的空格,制表符,换行符被视为单词之间的界定符, 而后者使用双引号将数据视为一个字符串, 作为一个参数传入echo后被原封不动输出.关于字符串中的特殊符号展开, 在双引号和单引号中的区别:
使用双引号: 字符串中的 美元符号($), 反斜杠()和反引号(`)被视为特殊字符(若要将其看做是普通字符则需要通过\转义), 其他则为普通字符.
使用单引号: 字符串中的所有字符都被视为普通字符.整数运算结果赋值到变量
var=$[5*2]
同var=$(expr 5*2), 但推荐使用方括号, 此方式只支持整形(浮点数运算使用bc命令) -
数组变量
数组变量赋值
array[0]=null
指定元素赋值
array=(zero one two three)
# 等价于array=([0]=zero [1]=one [2]=two [3]=three)
同时赋值多个元素
array+=(four)
向数组中追加数据, 注意此处添加括号, 否则array+=four
将被理解为对变量array(此时为${array[0]}
)拼接字符串four, 得到zerofour的错误结果.数组变量取值时
${array[2]}
获取指定元素
${array[*]} ${array[@]}
获取整个数组
${!array[*]} ${!array[@]}
获取数组元素的下标
*
会将命令行上提供的所有参数当作一个单词保存
@
会将命令行上提供的所有参数当作同一字符串中的多个独立的单词
${#array[@]}
获取数组变量元素个数
${#array[0]}
获取数组变量中指定元素的长度数组变量删除
unset array[0]
删除指定元素
unset array
删除整个数组关联数组
array["element0"]=zero
允许使用字符串作为数组索引
此类数组必须使用declare命令声明declare -A array
, 其他操作方式同上. -
条件语句(if/case)
if 为真的条件是命令的退出状态码为0
if [ -d /etc/passwd ]; then # 等价if test condition; then, 即使用test命令 # 注意方括号前后都要留有空格. 如果then放在下一行, 则不用加;, 但放在同一行节省空间 # condition不能测试命令退出状态码之外的条件, 常用判断如下 # 判断数值: -eq -ge -gt -le -lt -ne, 不能比较浮点数, 会报错 # 比较字符串: = != < > -n长度是否非0 -z长度是否为0, 注意 > <的转义问题 # test比较文件: # -d是否存在并且是一个目录 -f是否存在并且是一个普通文件 # -e是否存在 -s是否存在并且内容非空 # -r/-w/-x是否存在且检查权限 -O/-G用户组相关 # -nt/-ot更新或者更旧 echo "/etc/passwd is plain file" elif (( 20 > 5**2 )); then # 使用双括号来支持数学表达式 # 数学表达式: 除了上面test提供的数值比较操作外, 还支持++ -- ! ~ ** << >> & | && || echo "20 < 25, the answer as plain as the nose on your face" elif [[ "hello" = "h"* ]]; then # 使用双括号来支持字符串模式匹配 # 增加了一个新的字符串表达式: =~ 字符串表达式, 匹配扩展的正则表达式 # 字符串模式匹配: 字符串比较和上面test的比较方式一样, 只不过这里比价的对象可以是一个正则表达式 elif [ condition1 ] && [ condition2 ]; then # 逻辑判断: [ condition1 ] && [ condition2 ] # &&与 ||或 echo "here match successful" else echo "..." fi
case 模式匹配 (结尾;;&语法, 继续测试? 同时满足, 执行多个动作)
# 格式: case var in pattern1 ) commands1 ;; # 若pattern1匹配, 则case匹配完成退出 pattern2 ) commands2 ;;& # 若pattern2匹配, case执行动作后向下继续匹配 # 注意case结尾使用 ;; 和 ;;& 的差异, 后者类似于C语言case没有使用break的情况. pattern3 | pattern4 ) commands3 ;; * ) default commands ;; esac
-
循环语句(for/while/until)
# shell下的for循环 for item in list; do # 如果do放在下一行, 则不用加这个; # list可从 $var, $(..), **目录**(/home/*)获取 # **list通过内部字段分隔符(环境变量IFS, internal field separator)划分**, IFS默认为空格/制表符/换行符 # 修改IFS
IFS=$'\n'
, 同时指定多个IFSIFS=$'\n':
done # C语言风格的for循环: for (( i = 1 ; i < 10 ; ++ i )); do ... donewhile 测试命令测试的是退出状态码为0
until和while命令工作的方式完全相反
break/continuewhile test condition; do ... continue # continue 可带一个参数, 为要继续的循环层级, 默认为1 done until test condition; do ... break # break可带一个参数, 为要跳出的循环层级, 默认为1 done # while和until的**测试条件和if一致**, 对于continue和break, 和C语言一样, 不过它支持参数, 能够一次性跳出多个循环层级. 用的比较少, 还没有写脚本复杂到这种程度.
done 可以重定向循环中的输出
for (( i = 1; i <= 10; i++ )); do echo "$i" done > output.txt # done命令重定向输出到output.txt而不是标准输出 while IFS=',' read -r name; do # 使用IFS分隔符, read命令会自动读取文本文件的下一行内容 # 当read命令返回FALSE时,文件处理完成, while命令就会退出 useradd "$name" done < "user.csv" # done命令重定向输入, 把数据从文件中送入while命令
-
函数
function 定义函数
function get_name { # 同命令行参数一样, 使用$1 $2来获取参数 ... echo "?" # 通过echo命令来返回值 return 0 # 使用return命令退出函数并返回特定的退出状态码 } # 运行结束时会返回一个退出状态码, 用标准变量$?来确定函数的退出状态码 # 调用函数并获取返回值: name=$(get_name) # !并非最后一条echo才是输出, 所有的echo都会当做返回值输出
-
引用脚本
source other.sh
# 等价于. other.sh
, 但用source更直观一些通过
source命令
引用脚本文件, 类似于C语言中的include.
source命令
会在当前shell上下文中执行脚本内容, 不会创建一个新shell. -
退出状态码
exit 0
或者在函数中return 0
exit退出脚本和return函数返回时, 可指定一个退出状态码. 退出状态码要立即获取使用, 任意的操作都会改变退出状态码.
$?
保存了已执行命令的退出状态码(0-255), 命令执行成功返回0, 否则大于0(一般设置为1). -
交互
read name
# 等待输入并保存在name变量
read命令从标准输入或另一个文件描述符中接受输入, 将输入的数据赋给变量.
(一些常用的选项-p:
添加输入提示-t:
设置等待输入超时-s:
隐藏输入)
如果不指定接收变量, read命令会将它收到的任何数据都放进特殊环境变量REPLY中. -
重定向
输出(将命令的输出保存到指定文件), 输入(将文件内容重定向到命令)
echo "hello world" > data.txt
输出覆盖数据
echo "hello world" >> data.txt
输出追加数据
wc < data.txt
# 分别输出了文件的 行数 词数 字节数
输入文件数据
wc << EOF(换行等待输入直到遇到下一个EOF为止)EOF
手动输入数据, 需要指定一个文本标记划分输入数据的开始和结尾)
(无需使用文件进行重定向, 只需要在命令行中指定用于输入重定向的数据就可以) -
管道
echo "hello world" | more
将一个命令的输出作为另一个命令的输入 -
标准输入输出
默认情况下, Linux会将STDERR导向STDOUT
echo "This is an error message" > &2
# 临时重定向输出到标准错误
临时重定向, 在重定向到文件描述符时, 必须在文件描述符数字之前加一个&
exec 1>testout
永久重定向, 创建输出文件描述符: 用exec命令
来给输出分配文件描述符, 除了0 1 2, 还可以自定义6个
exec 3>&1 \ exec 1>test14out \ exec 1>&3
重定向文件描述符
exec 3>&-
关闭文件描述符
shell脚本编程经验总结
-
shell脚本中命令的四种形式
ls -a $PWD
# 同ll $PWD
shell命令(包括别名)shell_function
shell函数./some_program
外部可执行文件(二进制程序或者脚本程序)
shell会先按其语法展开表达式, 之后将展开的内容视作命令来执行, 命令大多数情况是上述三种形式之一.
要能够区分哪些是语法, 哪些是命令, 否则shell可能会报错, 例如:
var = 1
上述语句多半会报错, 此处想必是定义变量, 但变量赋值等号两边不应该有空格, 因此shell将其当做命令执行, 但它发现此环境并没有var命令供其调用, 于是报错. (若var命令存在, 则 = 1 分别当做命令的两个参数传入命令)shell先展开表达式, 在执行命令的这个特性有点像是C里面的宏, 但又比宏更容易出问题, 因为没有编译器帮助检查语法, 有些脚本的问题可能的延迟到运行时才能触发. 例如:
module=parser
${module}_do_something
# 此处将会执行parser_do_something函数脚本没有运行前, shell无法发现parser_do_something函数是否已经定义, 可能就会出问题. 这时可以通过type命令来判断函数是否存在, 若存在再执行.
type ${module}_do_something &> /dev/null && ${module}_do_something
-
调试shell脚本
-
set -x/+x
使用set
命令来追踪命令的执行情况, 一般来说这需要至少定位到问题的一个区间, 在这个区间内, 脚本有问题, 但通过观察又不能发现明显的问题, 这时候set
命令就能派上用场了.
set
命令会打印出set -x
到set +x
区间内脚本的实际运行情况, 显示出类似于gdb中堆栈的一些信息, 它将实际执行的命名展开后完整的输出, 发现问题就方便了. -
打日志
在脚本中打日志可以使用tee
命令,tee
命令相当于管道的一个T型接头, 它将从STDIN过来的数据同时发往两处: STDOUT和所指定的文件名, 这样可以将脚本的输出都记录日志中供以后分析:
do_some_thing | tee -a $logfile
#-a
选项将数据追加到文件中
-
-
双引号, 单引号及转义字符
双引号中需要转义的特殊字符:
'$' '
' '"' '\', 对于这几个字符出现在双引号中需要在其前面添加
'\'字符进行转义 **单引号中需要转义的特殊字符**: '
'
', 单引号自身, 在单引号中输出单引号则需要写成'
'\''
`例如, 需求是查找远程主机上某个目录的配置文件, 并修改其中的内容. 写了如下的脚本:
remote_host_config_path=./ # 存在配置config.xml并有一行
<config value="1">
target_value=2 ssh root@127.0.0.1 "find $remote_host_config_path -name config.xml | eval \$(gawk '{printf(\"sed -i '\''/config/s/\\\\bvalue=\\\"[^\\\"]*/value=\\\"$target_value/g'\'' %s\n\",\$1)}')"相信如果对以上两条规则不熟悉, 基本很难很快的正确的写出上述脚本, 少了某个引号或者转义字符都会报错, 容易栽跟头.
下面从简单的命令一步一步拆解上述脚本:# 首先使用sed来修改config.xml配置, 找到config所在的行, 匹配value="直到下一个"的位置, 将其替换为value="2 # 此处写了个\b, 避免匹配到其他含有value串的字段 # 最终的到
sed -i '/config/s/\bvalue="[^"]*/value="2/g' ./config.xml # 使用find查找文件, 通过gawk处理找找结果, 将结果作为参数传入sed # 由于gawd中使用单引号, gawk语句范围中出现的单引号需要转义为'\'' # 由于printf中的双引号, printf语句范围中的特殊字符需要转义, 前面加\ find ./ -name config.xml | gawk '{printf("sed -i '\''/config/s/\\bvalue=\"[^\"]*/value=\"2/g'\'' %s\n", $1)}' # 加上变量$remote_host_config_path和$target_value # eval命令会将参数作为字符串处理一遍之后再执行, 这里第一遍处理后$target_value被处理为实际的值 find $remote_host_config_path -name config.xml | eval $(gawk '{printf("sed -i '\''/config/s/\\bvalue=\"[^\"]*/value=\"$target_value/g'\'' %s\n", $1)}') # 加上ssh, 因为变量的存在ssh执行命令参数使用双引号括起来 # ssh双引号范围中出现的特殊字符需要转义, 四个\是对两个\进行转义, 最后输出\\ # 这条语句使用了5个命令(ssh, find, eval, gawk, sed) # 其中ssh和printf参数需要使用双引号, sed和gawk使用了单引号, 变量参数都含有特殊字符, 通过转义最终写出了这样的语句 ssh root@127.0.0.1 "find $remote_host_config_path -name config.xml | eval \$(gawk '{printf(\"sed -i '\''/config/s/\\\\bvalue=\\\"[^\\\"]*/value=\\\"$target_value/g'\'' %s\n\",\$1)}')" 以下是等价的写法, 区别在于sed语句使用双引号, 这将导致最终有太多字符需要转义
gawk那一层也可以写成双引号, 那\的数量又会倍增!
由此可见, 非必要最好不用双引号, 合理的替换掉双引号会减小命令的复杂度sed -i "/config/s/\\bvalue=\"[^\"]*/value=\"2/g" ./config.xml find ./ -name config.xml | gawk '{printf("sed -i \"/config/s/\\\\bvalue=\\\"[^\\\"]*/value=\\\"2/g\" %s\n",$1)}' find $remote_host_config_path -name config.xml | eval $(gawk '{printf("sed -i \"/config/s/\\\\bvalue=\\\"[^\\\"]*/value=\\\"$target_value/g\" %s\n",$1)}') ssh root@127.0.0.1 "find $remote_host_config_path -name config.xml | eval \$(gawk '{printf(\"sed -i \\\"/config/s/\\\\\\\\bvalue=\\\\\\\"[^\\\\\\\"]*/value=\\\\\\\"$target_value/g\\\" %s\\n\",\$1)}')"
以下同样是等价的写法, 使用xargs替换掉了eval和gawk
这一版命令瞬间就简单了不少, 为什么没有直接这样写, 因为不知道xargs这个命令...
由此可见, 想要写好脚本, 命令的积累和灵活使用也非常重要sed -i "/config/s/\\bvalue=\"[^\"]*/value=\"2/g" ./config.xml find ./ -name config.xml | xargs sed -i '/config/s/\bvalue="[^"]*/value="2/g' find $remote_host_config_path -name config.xml | xargs sed -i "/config/s/\bvalue=\"[^\"]*/value=\"$target_value/g" ssh root@127.0.0.1 "find $remote_host_config_path -name config.xml | xargs sed -i '/config/s/\bvalue=\"[^\"]*/value=\"$target_value/g'"
-
连续执行多个逻辑相关的命令时
cd $dir_name; rm *
一旦在脚本中发现这样的写法, 就要敲响警钟了, 试想如果dir_name变量不是期望的值, 将会产生怎样的后果? 如果dir_name是空值,cd
命令默认进入当前用户根目录, 此时再执行rm
... 就像在秋季萧瑟的街道, 四下无人你站在街边, 街道非常干净, 没有垃圾, 甚至连落叶都没有, 天空阴沉沉的, 万籁俱寂, 时间在这一刻仿佛停止了转动, 脑子里只有一件事, 什么重要的东西被删除了.
好在没有重要的东西被删除了, 但此次事故不得不引起重视, 在脚本中如果发现类似的问题, 解决的办法是先判断, 在执行.上面的命令在cd和rm之间使用逗号, 命令将串行执行, 实际上是一种逻辑缺陷, 此处需要的是cd成功之后在执行rm, 所以改成下面这样:
cd $dir_name && rm *
cd还是会失败, 但至少不会意外删除任何东西, 但cd还是进入了用户根目录, 破坏了上下文.
比较保险的方法是先检查目录是否存在, 改为:
[[ -d $dir_name ]] && cd $dir_name && rm *
这样便不会有什么问题了.这里顺便一提, 关于shell中写类似于C语言中条件运算符的方法, 在很多时候能够简化脚本:
[ "$result" == "true" ] && echo 0 || echo 1
此处判断result变量的值, 如果result为"true", 脚本退出且退出码为0, 否则脚本退出码为1.也可以出这样的语句:
[ "$result" == "true" ] && echo 0 || echo 1 && echo "true" || echo "false"
如果result为"true", 此处将输出 0 和 true, 但不建议这样写吧, 迷惑性太强. -
shell不适合处理复杂逻辑
在当前shell环境定义一个变量, 在当前shell环境的任何地方都能够再获取这个变量, 这点和C语言中的全局变量类似.
但shell中没有类似封装的概念, 如果想要模拟一个批对象? 比如, 我希望同时保存这种结构的多个对象... 似乎不太方便, 要想同时保存相似的状态, 只有定义成不同的变量, 高级一点的定义成数组, 但维护这些变量又会变得很麻烦.
比如要处理一组数据: {{1, 2}, {2, 3}, {3, 4}, {4, 5}, {5, 6}}
定义了两个变量a,b来处理这一组数据, 但希望在处理第5项数据的时候, 能获取到第1项的数据.
可以定义成数组, 数组的值就为一项数据, 通过索引来获取相应的值.
如果情况稍微复杂一些, 处理起来实际上没这么容易.脚本本身应该是处理一组确定的数据, 它应该足够轻量, 如果数据存在很多关联, 逻辑复杂, 从实现的角度来说, 使用shell脚本来处理可能事倍功半.
这仅仅是我的看法, 你可以不同意.
最后
对于shell脚本, 像那些经常需要操作的重复性任务, 写一些轻量级的脚本, 就可以从乏味的流程中解脱出来, 而工作中需要的复杂脚本, 要认真对待, 写脚本和写代码没什么区别, 养成好习惯, 对于复杂的实现写好注释, 注重函数的命名, 按逻辑划分不同的模块等, 保证脚本干净清晰易维护.
熟悉shell语法能够写出可以工作的shell脚本, 掌握更多的命令,熟知命令的行为是写好shell脚本的关键, 所以学习和涉猎更多的命令很有必要.
参考
[1] 官方文档: https://www.gnu.org/software/bash/manual/bash.html
[2] 《The Linux Command Line》 资料链接: http://billie66.github.io/TLCL/
[3] 《Linux命令行与shell脚本编程大全 第三版》 门佳 武海峰 译
[4] https://github.com/skywind3000/awesome-cheatsheets/blob/master/languages/bash.sh