目录

Shell脚本实用技巧小结

不够全面,但主要针对于日常工作常用的细节&技巧

前言

Shell脚本 ,故名思义,是“外壳程序”,由于用户无法和内核直接对话,给Kernel套了一层外壳语言Shell,通过这个中间层来和系统交互,主要功能是提升各项日常事务的自动化程度,并不像C/C++那般集中于复杂的特定任务计算。

Shell有很多种,$sh$、$csh$、$ksh$、$bash$、$zsh$ 是比较常见的,可以通过echo $SHELL查看当前使用的Shell是什么,也可以通过chsh命令来管理终端使用的SHELL类别:

1
2
3
4
#查看系统安装了哪些shell
chsh -l   
#如果有zsh,可以选择切换为zsh
chsh -s /bin/zsh

在Shell脚本的开头,需要指定该脚本使用哪个shell来执行(可以和终端使用的SHELL不同):

1
#!/bin/bash

或者开头加上:

1
#!/bin/zsh

当然,bash是最常用的,建议shell脚本还是用bash,即使在win平台的git-bash,也可以运行bash。

所以,本文提及的Shell语法技巧,是跨平台的,适用于 Windows/Linux/MacOS

基本语句结构

if

基本用法

if语句应该是使用最多的分支结构了,基本结构是if...then...fi,多种情况可插入elseelif

注意条件判断的括号可以用中括号[ ],也可以用 (( )) ,或者[[ ]]

[ ][[ ]] 的区别是,前者使用-a-o表示与、或的逻辑关系,或者用&&||

(( ))[[ ]] 的区别是,前者才可以判断简单数学运算的判断,后者更适合处理字符串比较。

关于空格

  • if和括号之间要留空格
  • 括号和内部表达式之间要有空格
  • ⚠️shell脚本里的赋值语句,等号=前后不要留空格
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/bin/sh
a=20   #⚠️等号=前后不要留空格!
b=10
if [[ $a == $b || $a > $b ]]  # 左边的$a和最右边的$b,都和括号至少留有1个空格s 
then # then语句可以写在第二行
   echo "a is equal or greater than b"
fi
if [ $a != $b ] ;then #then语句可以写在同一行,加分号;
   echo "a is not equal to b"
fi
if (( $a + $b < 100 )) ; then #  数学四则运算用这个括号(( ))
	echo "sum of a and b is smaller than 100"
else
	echo "sum of a and b is greater than 100"
fi

🚀如果有多个分支结构,需要else if的逻辑,那么使用elif ... then即可。

数值判断

有2种情况:

  • 写在[ ][[ ]]内部

    比较数字表达式的大小要注意,只支持>,<==,``!=的判断,不支持>=<=的判断。需要使用-ge-le来替换。当然,大于、小于、等于、不等于也可以用-gt-lt-eq-ne`来替换。

    📍Ps:gt表示greater than ,ge 表示 greater or equal,lt表示little than,le表示little or equal。

    1
    2
    3
    4
    5
    6
    
    #!/bin/bash
    a=5
    b=6
    if [ $a -le $b ] ; then
    	echo "a is little or equal than b"
    fi
    
  • 写在(( ))内部

    (( ))内部可以非常完美地支持四则运算,所以关于数字表达式的判断,建议和支持使用这种形式,非常自然地支持>,<,>=,<=四种符号的判断。

文件属性判断

一般是对文件或者文件夹做判断,常用的类型如下几类:

选项 含义
-e 判断文件或目录是否存在
-d 判断是不是目录,并是否存在
-f 判断是否是普通文件,并存在
-r 判断文档是否有读权限
-w 判断是否有写权限
-x 判断是否可执行

使用时的具体格式举例为:if [ -e filename ] ; then

字符串判断

选项 含义
-z 判断该字符串是否为空(为空返回真)
-n 判断该字符串是否为非空(非空返回真)
== 字符串1和 字符串2是否相等
!= 字符串1和 字符串2是否不同
=~ 字符串str和正则表达式reg是否匹配

逻辑复合判断

逻辑判断,通常指“与”,“或”,“非”。

  • :即and,用于[ ]内部之间,使用-a表示;用于[[ ]]内部之间,使用&&表示;

  • :即or,用于[ ]内部之间,使用-o表示;用于[[ ]]内部之间,使用||表示;

  • :即not,使用 ! 表示。

-a 和 -o 作为测试命令的参数用在测试命令的内部,而 && 和 || 则用来运算测试的返回值,! 为两者通用。

for

for循环分为数字型循环和字符型循环。基本结构为for ... do ...done

通常,do是独占一行,也可以与for语句同行,但要使用分号; 隔开。

数字型for

注意注释中另外2种写法建议。

1
2
3
4
5
#!/bin/bash                                                                                                                                                                                                                                        
for((i=1;i<=10;i++))  #可以换成 for i in $(seq 1 10)  或 for i in {1..10} 
do
	echo $i*5-1 =  $(expr $i \* 5 - 1);
done

字符型for

字符型的循环一般用来列表的遍历,或者文件的遍历。

文件的遍历

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/bin/bash
echo "in current dir,include these files/dirs:"
for i in `ls .`   #文件的遍历
do 
	echo $i #输出的文件/文件夹不带路径
done
# 文件/文件夹的遍历的第二种方法
echo "in director ~/Downloads,has theses files/sub-directories"
for dir in ~/Downloads/*
do
	echo $dir #输出的文件/文件夹是全路径
done

🚥 如果需要递归遍历子目录,需要命令选项-R,即 ls -R

字符串的遍历

1
2
3
4
5
6
echo "one week,has seven days:"  #下方列表以空格为间隔符
weekDays="Monday Tuesday Wednesday Thursday Friday Saturday Sundat"
for day in $weekDays  #可以换成for param in $* 变成对脚本入参的遍历
do
	echo $day
done

while

shell脚本的while循环和其他语言的逻辑判断一致,当条件不符合时跳出循环。基本语句结构是while... do...done

同样地,可以让 do语句独立成行,也可以while 判别式; do以分号隔开,实现while和do同一行。

1
2
3
4
5
6
7
8
#!/bin/bash
sum=0
echo "请输入您要累加的数字,按 Ctrl+D 组合键结束读取"
while read n
do
    ((sum += n)) # (( ))符号内部放置四则运算,支持+=运算
done
echo "The sum is: $sum"

case

case语句对应其他语言的switch-case结构,只是在写法上有所区别。通常用于针对用户的不同输入,作出不同的分支流程。

举例一个编译脚本需要选择分支,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/bin/bash
echo "想要编译的模式为:r(表示release),d(d表示debug)"
read type
case $type in 
	r)  #每个case以单个右括号结束,作为界定
		echo "build type is release"
		;; #2个连续的分号,;;相当于c语言的每个case需要的break语句
	d)
		echo "build type is debug"
		;;
	*)   # * 表示default情况,上述情况都没匹配上
		echo "unkown build type"
		;;
esac  # 以esac结尾,其实是case反过来拼写

continue/break

这2者的用法都是结合for/while循环使用的,意义和其他语言类似,此处不赘述。

遍历文件

有时会有需要对文件夹内每个文件都进行相同的处理,这时遍历文件操作是无法避免的了。

1
2
3
4
5
6
#注意shell里的赋值语句,等号左右2边不要留空格
ScriptPath=`dirname $0` #比如想遍历打印脚本所在目录的所有文件
for file in `ls ${ScriptPath}`
do
	echo  file is $file
done

脚本运行计时

系统time指令

主要使用了time指令,time后接linux指令可以测量指令运行时间。

1
2
time ./build.sh
# 在build结束后输出:   114.75s user 10.32s system 423% cpu 29.552 total

具体time指令的用法,可以参考:菜鸟教程:time指令

脚本内计时

1
2
3
4
startTime=$(date +%s)
# ... 脚本主体内容 ...
endTime=$(date +%s)
echo -e "Cost time:$(($endTime - $startTime)) seconds\n"

接收命令行参数

命令行特殊变量

通常Shell脚本内可以自定义变量,比如buildDir="build",读引用的时候使用$buildDir或者${buildDir}

但还有一类变量不需要定义,每个shell脚本内都可以直接使用,可以理解为built-in 变量。

变量 含义
$0 当前脚本的文件名
$n 传递给脚本或函数的参数。n 是一个数字,表示第几个参数
$# 传递给脚本或函数的参数个数。
$* 传递给脚本或函数的所有参数。
$@ 传递给脚本或函数的所有参数。加双引号"才和$*有区别,引号中返回每个参数。
$? 上个命令的退出状态,或函数的返回值。
$$ 当前Shell进程ID。对于 Shell 脚本,就是这些脚本所在的进程ID。

✳️对比$*$@的区别(使用时要加引号"才会体现区别,不加引号就一样,都是逐个参数多行输出):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/bin/bash
# testParam.sh
echo "-- \$* 演示 ---"
for i in "$*"; do   # 使用时要加引号",才会体现区别
    echo $i  # 若不加引号",也是逐行单个参数输出
done

echo "-- \$@ 演示 ---"
for i in "$@"; do   # 使用时要加引号",才会体现区别
    echo $i
done

如果执行./testParam.sh a b 1 2 -x -y,那么输出如下内容:

1
2
3
4
5
6
7
8
9
-- $* 演示 ---
a b 1 2 -x -y
-- $@ 演示 ---
a
b
1
2
-x
-y

命令行参数分析

运行一个脚本可能会带有参数,比如 ./build.sh -d -arm64的含义可能为“编译arm64架构的debug包”。

在学会getopts分析之前,我是对$1,$2...$n逐个分析(n为参数个数$#),然后利用case语句赋值的,写出来的代码比较冗长麻烦且不美观。

废话不多说,直接看getopts的使用吧:

while与getops联合使用,待选的选项里有2类,带参数的以冒号:隔开不带参数的不需分隔,写在一起。示例代码如下所示 ⬇️

 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
#!/bin/bash
# build.sh 文件内容
function usage(){
	echo "-h : print help message"
	echo "-r : build type is release"
	echo "-d : build type is debug"
	echo "-a : architecture type,with value,one of i386/x86_64/arm64"
	echo "-s : compiler sdk version,with value,format like 10.16,no limits about value"
}

while getopts "a:s:hd" o; do
    case "${o}" in
        h)
            usage
            ;;
        d)
            buildDebug=true
            echo "build debug version, without optimizations"
            ;;
        a)
            arch=${OPTARG}
            echo "Architecture: ${arch}"
            ;;
        s) 
            sdkVersion=${OPTARG}
            versionFull="${sdkDir}/OSX${sdkVersion}.sdk"
            echo "==> Now using sdk-root version is : ${versionFull}"
						;;
        *)
            usage
            ;;
    esac
done

鉴于上述getopts的定义,可以使用./build.sh -h来查看帮助信息,也可以使用./build.sh -r -a x86_64 -s 10.15来执行脚本进行build(示例代码略去build脚本其他内容)。

字符串处理

说起字符串,先说个比较常用的,字符串的长度表示为${#str}

1
2
3
#!/bin/bash
str="Hello World"
echo str's length is ${#str}

字符串截取

Shell脚本中字符串截取分2种情况:使用数字下标,或使用特定字符标记。

  • 数字下标截取

    这种方式就是选定一个起始点start-pos,然后按照从左往右的阅读顺序截取一段长度为length的子串。

    1. 从字符串开头截取

      形式为${originStr:start_pos:length},起始点为从左边第一个字符(下标0)开始数,数到下标为start_pos的字符,从左往右截取长度为length的字串,length设置过大则到远串末尾截止。

    2. 从字符串尾部开始截取

      形式为${originStr:0-start_pos:length},“0-”是表示从后往前数,起始点为从右边第一个字符(下标1,倒着数,从1开始)开始数,数到下标为start_pos的字符,同样从左往右截取长度为length的字串,length设置过大则到远串末尾截止。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      
      从字符串开头截取 #!/bin/bash
       originStr="https://kissingfire123.github.io"
       echo "originStr is $originStr"
      
       subStr1=${originStr:3:6}    # 从字符串开头截取
       echo "subStr1 is $subStr1"
      
       subStr2=${originStr:0-6:4}  # 从字符串尾部开始截取
       echo "subStr2 is $subStr2"
      

      运行后输出如下内容:

      1
      2
      3
      
       originStr is https://kissingfire123.github.io
       subStr1 is ps://k
       subStr2 is hub.
      
  • 特定子串标记

    特定字符标记的意思是“遍历字符串,遇到某个目标字符串chars为止,截取其前/其后的部分”。明显,这种方式不能指定长度。

    🤔 这种截取方式的截取内容,都不包括分隔符chars

    1. 截取左边

      使用%来截取左边的字符串,其中星号* 表示通配符,注意是char*,因为需要左边,所有chars的右边内容我们不关心。

      格式 含义
      ${originStr%chars*} 遍历originStr,遇到第一个子字符串chars为止,截取左边内容
      ${originStr%%chars*} 遍历originStr,遇到最后一个子字符串chars为止,截取左边内容

      示例代码如下所示:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      
      #!/bin/bash
      
      originStr="https://kissingfire123.github.io"
      echo "originStr is $originStr"
      
      subStr1=${originStr%.*}      # 匹配第一个符号'.'
      echo "subStr1 is $subStr1"
      
      subStr2=${originStr%%.*}     # 匹配最后一个符号'.'
      echo "subStr2 is $subStr2"
      
      subStr3=${originStr%ss*}    # 匹配第一个子字符串'ss'
      echo "subStr3 is $subStr3"
      

      输出内容为:

      1
      2
      3
      4
      
      originStr is https://kissingfire123.github.io
      subStr1 is https://kissingfire123.github
      subStr2 is https://kissingfire123
      subStr3 is https://ki
      
    2. 截取右边

      使用#来截取左边的字符串。逻辑和上述左边字串的非常类似,注意是*char,因为需要右边,所有chars的左边内容我们不关心。类似地有下列表格:

      格式 含义
      ${originStr#*chars} 遍历originStr,遇到第一个子字符串chars为止,截取右边内容
      ${originStr##*chars} 遍历originStr,遇到最后一个子字符串chars为止,截取右边内容

      示例代码如下:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      
      #!/bin/bash
      
      originStr="https://kissingfire123.github.io"
      echo "originStr is $originStr"
      
      subStr1=${originStr#*i}     # 寻找第一个字符串'i',保留其右边
      echo "subStr1 is $subStr1"
      
      subStr2=${originStr##*i}    # 寻找最后一个字符串'i',保留其右边
      echo "subStr2 is $subStr2"
      
      subStr3=${originStr#*ss}    # 寻找第一个字符串'ss',保留其右边
      echo "subStr3 is $subStr3"
      

      输出如下内容:

      1
      2
      3
      4
      
      originStr is https://kissingfire123.github.io
      subStr1 is ssingfire123.github.io
      subStr2 is o
      subStr3 is ingfire123.github.io
      

字符串替换

如果说,上一小节是描述substr功能,那么本小节就是描述replace功能了。

格式 含义
${originStr/match_str/replace_str} 1个/,表示将匹配到的第一个match_str,替换成replace_str
${originStr//match_str/replace_str} 2个/,表示将匹配到的所有match_str,全替换成replace_str

示例如下:

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

originStr="https://kissingfire123.github.io/2022/02/14_Shell语法小结/"
echo "originStr is $originStr"

subStr1=${originStr/s/T}    # 第一个匹配的子字符串's',替换为T
echo "subStr1 is $subStr1"

subStr2=${originStr//02/哈哈}  # 所有匹配的子字符串'02',都替换为'哈哈'
echo "subStr2 is $subStr2"

输出内容为:

1
2
3
originStr is https://kissingfire123.github.io/2022/02/14_Shell语法小结/
subStr1 is httpT://kissingfire123.github.io/2022/02/14_Shell语法小结/
subStr2 is https://kissingfire123.github.io/2哈哈2/哈哈/14_Shell语法小结/

⚠️:match_str可以为正则表达式,留待下节讲述。

字符串正则

正则表达式,用于模糊查找匹配 ,本身比较复杂,但掌握后受益良多,详细可参考菜鸟教程:正则表达式。 Shell 字符串的正则表达式主要用于2处,一处为字符串比较判断,另一处为字符串的正则匹配后替换

Shell 脚本中应用正则表达式的几个常用命令为 grepsedcutawk ,需要注意的是,具体命令支持的正则功能范围可能是不同的。

  • 正则比较判断

    正则比较判断的比较符号是 =~ ,使用原始字符串和匹配正则pattern进行匹配,如果成功匹配返回1,失败返回0,常用于判断字符类型(比如时间、电话号、版本类型等)。关于 if 语句针对 =~ 的应用,可参考GNU-Bash参考手册: 条件表达式

  • 正则字符替换

    在 sed 指令中使用频繁,在 vim 指令中也可以发挥用武之地,此处不赘述。

下方代码使用 if 表达式 和 grep 简单示范上述2者的用法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/bin/bash
fullVimStr=`vim --version | grep IMproved` # 提取Vim版本号的关键行
echo fullVimStr is ${fullVimStr} 

pattern=^.*[0-9]\.[0-9].*$  # 此处正则表达式可以用单引号' 包裹,也可以不用
if [[ $fullVimStr =~ $pattern ]] ; then  
    echo -e "match Ok:match a valid version !"
    numPattern='[0-9]\.[0-9]'
    versionNum=`echo $fullVimStr | grep -P $numPattern -o `
    echo "split Ok: versionNum is $versionNum"
fi

上述脚本执行后输出如下内容:

1
2
3
fullVimStr is VIM - Vi IMproved 8.2 (2019 Dec 12, compiled Jun 1 2020 06:42:35)
match Ok:match a valid version !
split Ok: versionNum is 8.2

多行注释

多行注释有以下2种方法可以做到 👇

空命令接收注释行

“:<<任意字符与数字” + "相同字符与数字"将需要注释的多行内容包裹起来即可。注意,这个字符尽量不要取单引号,双引号,以免和注释内容中的引号提前匹配。

下方代码取字符为"EOF",其实换成其他的,比如"Comment"也是一样的。

1
2
3
4
5
:<<EOF
被注释的Line1
被注释的Line2
被注释的Line3
EOF

值得注意的是,这里是用个空命令来接收注释内容,被注释内容也确实没有在当前终端执行,但是“空命令接收”这一动作还是背后有开销的。

函数定义式

对于要注释的多行内容,将其封装成一个函数,这样在脚本运行时完全没有开销。

还是上述的例子,可以使用函数的形式,只定义,不调用:

1
2
3
4
5
EofFuncHere(){
被注释的Line1
被注释的Line2
被注释的Line3
}

重要的FAQs

  1. echo的常用输出选项?

    • echo -n: 取消换行符
    • echo -e:启用反斜杠转义
  2. 单引号' '和双引号" "的区别?

    • 单引号:也叫hard quote,关闭所有引用
    • 双引号:也叫soft quote,保留$引用
  3. 变量赋值和export差别?

    • 变量赋值:使用等号=赋值,等号前后不能留有空格

    • export变量:如果是在Shell脚本内export,即成为当前Shell脚本运行时的环境变量;如果是在终端命令窗口export,有效范围为当前窗口;在~/.bash_profile export,有效范围为当前用户;在/etc/bashrc(Ubuntu为/etc/bash.bashrc),有效范围为本机所有用户。

  4. exec和source的差异?

    我们执行一个shell脚本时,实际是先产生一个sub-shell的子进程,然后sub-shell在去产生命令行的子进程。

    • ./test.sh:创建sub-shell执行脚本;
    • source test.sh :以当前shell执行;
    • exec test.sh:当前shell执行后退出;
  5. 如何实现命令替换和变量替换?

    • 命令替换:使用圆括号$( )或者反引号**` `**,将命令包裹其中
    • 变量替换:使用大括号${ },将变量包裹其中
  6. 不同情况下的’退出’含义?

    • break:是提前结束循环
    • return:是结束function
    • exit:是结束Shell脚本,并给出返回值
  7. 如何检测脚本是否执行成功?

    第一,当然是打印信息或者直接写result文件了。

    其二,Shell中有个特殊的变量$?,就是上一句命令或者上一个刚执行完的脚本的返回值,只需要echo $?,就能快速查看上一条语句/脚本是否成功了。

    通常来说,脚本执行成功都是约定俗成返回值0

  8. Shell脚本的数组怎么使用?

    比如定义一个数组A=(a b c d),那么如下示例:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    # 引用数组
    ${A[@]}
    ${A[*]}
    # 访问数组成员
    ${A[0]}
    # 数组长度
    ${#A[@]}
    ${#A[*]}
    # 数组修改赋值
    A[2]=xyzw
    

参考链接:

菜鸟教程:Shell传递参数

C语言中文网:Shell字符串截断

腾讯云:Shell正则遇到的问题

CSDN:Shell正则表达式

博客园:Shell 多行注释

知乎:Shell 实现多行注释的几种常用方法

Linux科技:你应该知道的Shell十三问