Linux 之旅 10:Shell 脚本

Linux 之旅 10:Shell 脚本

Linux 之旅 10:Shell 脚本_第1张图片

(图片来自shell/bash脚本编程

Linux 上的 Shell 脚本可以看做是类似于Windows上的批处理程序(.bat)一样的东西,其本质就是将一组 shell 命令整合在一起,并添加上一些编程语言普遍使用的控制流程、函数之类的结构,实现自动化和批处理的效果。

事实上,从之前 Linux 之旅 8:初识 BASH 中我们学到的内容就可以发现,Bash 本身就相当有被开发成一门完善的编程语言的潜质,因为其本身就具有很多编程语言的基础特性,比如变量,比如多行执行,比如逻辑运算符等。

而我们要做的就是用vim之类的文本编辑器新建一个文本文件,然后写下一行行的 Bash 命令,然后再加上控制流程或者函数之类的结构组织一下,就可以搞出类似程序源代码之类的东东,这就是所谓的 Shell Script,或者说 Shell 脚本。

Shell Script 概述

程序 or 脚本

以前,刚开始接触Javascript之类的脚本语言的时候对脚本这个词很困惑,不理解这到底是个什么意思,后来明白了,所谓脚本,可以看做是一种“轻量级代码”,就像常见的JavascriptPHP脚本,长的数百行,短的两三行,它们都以实现一个简单且单纯的任务为目标,一般仅会使用单个文件加上一些必要的第三方库引用就可以完成所需的任务,所以我们可以将此类的jsPHP代码称之为脚本。

但并不是脚本语言创建的程序一定是脚本,比如使用Js创建的完整前端框架,或者PHP创建的包含数百个源码文件的Web应用,都很难再被看作是“轻量级代码”了,这种规模的程序我们一般称之为应用或产品。

当然,无论是简单的脚本还是复杂的应用,只要是用代码敲出来的,我们都可以被叫做程序。

Shell Script

知道什么是脚本(Script)之后我们再来看什么是Shell Script

很简单不是?既然是Shell Script,那自然是用Shell编写的脚本,准确的说应当是用Shell命令编写的脚本。我们前边已经说过了,Shell是Linux内核之上的一种“壳程序”,我们通过使用各种Shell命令与内核交互,最简单的当然是通过命令行逐行或者多行执行命令,但那依然只能实现一些简单的功能,如果是某些复杂的相互影响的任务,通过那种方式执行就有点强人所难了,如果这种任务还是需要长时间重复执行的,那就更是一种折磨,这时候比较优雅,或者说懒人福音的方式就是编写一个Shell命令组成的脚本,然后一键执行。

了解什么是Shell脚本之后,很自然的,我们就能想到Shell分为很多种,而且不同的Shell的使用方式也不同,自然的,因为使用Shell种类的不同,Shell脚本也会有所不同。

就像前边说的,因为Bash是目前最流行的Shell,所以我们学习的Shell脚本也是用Bash命令编写的Shell脚本。

Hellow World

几乎所有的编程语言学习的第一课都是Hellow World,我们的第一个Shell脚本也从这里开始。

使用vim在任意一个地方新建一个hellow.sh

#!/bin/bash
# a first shell script
echo 'Hellow Wolrd!'
exit 0

然后执行:

[icexmoon@xyz bin]$ sh hellow.sh
Hellow Wolrd!
  • 需要注意的是编写代码的时候所有标点符号都只能使用英文标点,也就是说在中文输入情况下也要使用半角标点,否则会出现初学者难以发现的错误,比如某个中文感叹号之类的。有种便捷的做法是在输入法中设置中文下使用英文标点,就会杜绝此类错误,但这样会对需要编写中文文章的人带来不便,可以设置两个中文输入法,一个专门用来编程,一个用来编写中文文章。\
  • Hellow Wolrd已经变成了程序员文化的一部分,有个梗是就是关于这个的——“程序员的墓志铭应当写什么?答:Goodby World”。

其中第一行#!/bin/bash的作用是表明这个shell脚本使用的是Bash命令编写,所以应当用/bin/bash执行。

我尝试了删除这一行注释后直接通过hellow.sh执行,结果依然可以正常执行,大概是因为默认ShellBash的缘故,如果是csh估计结果就不同了,总之还是遵守这种约定比较好。

最后一行以exit 0结尾,这是为了在程序正常退出时候给Bash提供一个返回值,这样其它命令就可以通过$?变量来获取到这个脚本的返回值了。

良好的编码习惯

所有编程教学都会将编码习惯放在首位,在所有教学内容之前,毕竟好的习惯需要从开始就培养,而坏的习惯可能会伴随终生。

但也不要对编程习惯痴迷或者奉为信条,因为编程习惯都是为了某些目的而存在的,如果你有更好的方案解决此类问题,或者这种目的影响到了你的工作,那么抛弃对应的编码习惯完全是可行的。

一般来说,无论使用何种编程语言,都需要在每份源代码前加上这些内容作为注释:

  • 源代码的内容概述或目的
  • 创建日期和修改日期
  • 作者及联系方式
  • 版本号
  • 修订历史记录

但要让职业的程序员在每份源码上都手动编写这些内容显然是不现实的且是对人力的一种浪费,所以实际中基本上只有“源码的内容或目的”需要程序员编写,其它的都由IDE等工具自动生成,而版本号和修订历史记录这类信息其实属于版本管理,目前都会用专门的软件版本管理应用代劳,比如svngit,所以你完全可以使用GithubGitee创建个人仓库来存放和管理Shell脚本,这回省去很多事。

脚本的执行方式

Shell脚本的执行方式有3种:

  • 直接执行脚本文件:

    也就是通过绝对路径或相对路径直接执行,或者脚本文件位于PATH包含的目录中,比如:

    [icexmoon@xyz bin]$ ~/bin/hellow.sh
    Hellow Wolrd!
    

    需要注意的是这种情况下脚本文件本身必须要有执行权限,也就是说当前用户要能有权限执行该脚本文件。

  • 通过/bin/bash执行:

    这种方式最常见,只要用户对脚本文件有读权限就可以执行脚本:

    [icexmoon@xyz bin]$ sh hellow.sh
    Hellow Wolrd!
    

    因为sh位于PATH目录,且本身是/bin/bash的链接,所以当然也可以执行。

  • 通过source执行:

    前边我们说过,使用source.可以执行shell的配置文件,事实上shell的环境配置文件本身就是shell脚本,所以普通的shell脚本也同样可以这么执行,通过这种方式执行和前两种的区别在于,前两种都是新建一个Bash进程,然后执行,执行完毕后会退出该进程,而后者则是在当前的主Shell进程上执行。

    区别在于子进程的命名空间是独立的,除了继承父Bash进程的全局变量以外,局部变量是互不影响的,也就是说命名空间是干净的,不会包含其它Bash的局部变量。而后者是非子进程方式执行,也就意味着其命名空间就是主Bash的命名空间,造成的影响也同样会影响到主Bash进程。

    这点可以使用一个简单的脚本证实:

    [icexmoon@xyz bin]$ cat env_test.sh
    #!/bin/bash
    # test the namesapce in father and child bash
    subscript_var='test'
    

    测试:

    [icexmoon@xyz ~]$ cd ~/bin
    [icexmoon@xyz bin]$ vim env_test.sh
    [icexmoon@xyz bin]$ unset subscript_var
    [icexmoon@xyz bin]$ source env_test.sh
    [icexmoon@xyz bin]$ echo $subscript_var
    test
    [icexmoon@xyz bin]$ unset subscript_var
    [icexmoon@xyz bin]$ sh env_test.sh
    [icexmoon@xyz bin]$ echo $subscript_var
    
    

    可以看到,通过source加载后可以访问到脚本创建的局部变量,但通过sh执行后是访问不到的。

    需要注意的是,通过source加载的脚本千万不要在结尾写exit 0这样的语句,因为会直接让主Bash进程退出。

简单的脚本练习

在学习流程控制语句和函数之前,先通过几个简单的脚本案例感受一下Shell脚本的编写。

简单的交互式程序

最简单的交互式程序是从键盘读取数据,然后根据数据进行相应的处理后输出,下面看一个例子。

在这个例子中将会让用户通过键盘分别输入first_namelast_name,然后拼接处全名后输出:

#!/bin/bash
#测试用的shell脚本
read -p 'first name:' first_name
read -p 'last name:' last_name
echo "full name is ${first_name} ${last_name}"
exit 0

以时间作为文件名创建文件

此类行为在Shell脚本的编写中很常见,因为我们往往需要用脚本来定时备份数据,自然的,最简单且直观的方式就是用时间来命名备份,比如filename_2021-08-07.backup这样,下面用一个简单的脚本练习来说明。

在这个脚本的作用是,从键盘读入一个文件名,然后根据今天、昨天和前天的日期作为文件名来创建三个空文件:

#!/bin/bash
# create 3 files by date
# such as filename_2021-08-16
#         filename_2021-08-15
#         filename_2021-08-14

#read filename
read -p "Pleas enter a filename:" filename
#get today's date
date_today=$(date "+%Y-%m-%d")
date_1_before=$(date -d "1 day ago" "+%Y-%m-%d")
date_2_before=$(date -d "2 day ago" "+%Y-%m-%d")
filename1="${filename}_${date_today}"
filename2="${filename}_${date_1_before}"
filename3="${filename}_${date_2_before}"
touch $filename1
touch $filename2
touch $filename3
exit 0

计算器

制作一个简单计算器也算是编程的常见题目了,不过目前没有学习控制流程,所以只能创建一个简单的只能做乘法的计算器。

这个案例中将会读入用户输入的两个数,然后做乘法后进行输出:

#!/bin/bash
# a multiply test
# input two numbers and return multiply result

# get two numbers from user input
declare -i number1
declare -i number2
read -p "Please input number1:" number1
read -p "Please input number2:" number2
declare -i result_num
# multiply and return result
result_num=$number1*$number2
echo "${number1}*${number2}=${result_num}"
exit 0

这里对所有数值类型的变量都使用declare -i进行了类型声明,所以自然是没有问题的。但Bash中虽然变量是有类型的,但实际上变量类型约束相当宽泛,并不像C++那样严格,所以我们也可以这样写:

#!/bin/bash
# a multiply test
# input two numbers and return multiply result

# get two numbers from user input
read -p "Please input number1:" number1
read -p "Please input number2:" number2
# multiply and return result
result_num=$(($number1*$number2))
echo "${number1}*${number2}=${result_num}"
exit 0

$(())的作用是对其内部包裹的字符串进行数学运算,所以虽然脚本中用到的三个变量都是默认的字符串类型,但依然能得出正确的结果。

Bash的语法的确有点奇怪,说它是弱类型语言吧,它有类型。说它是强类型语言吧,数字类型和数字类型整合到一起居然变成了字符串类型,并不存在在运算时根据类型的不同执行不同的操作这种强类型语言常见的特性。

运算符

变量替换

Bash中,使用$标记的名称会尝试使用变量的值进行替换:

#!/bin/bash
# test value
name='icexmoon'
echo 'My name is '$name
echo "My name is ${name}"
echo 'My name is ${name}'
exit 0

也就是说Bash在执行的时候会先使用变量name的值替换掉$name,然后将'My name is icexmoon这样的字符串传递给echo命令。

当然单引号中是例外,不会执行变量替换。

命令替换

可以使用$(commnad)的方式捕获命令的输出:

#!/bin/bash
# test command
users=$(cut -d ':' -f 1 /etc/passwd)
echo "$users"

也就是说,示例中cut -d ':' -f 1 /etc/passwd命令原本执行后会向屏幕输出账号信息,但通过$(),可以将这部分信息捕获,不再输出到屏幕,而是直接作为字符串赋值给了变量users

除了$(),也可以使用``(ESC下边的那个键),是一样的效果。

数值计算

使用双层小括号可以执行数学计算:

#!/bin/bash
# test cal
num1=1+2+3
num2=$((1+2+3))
echo $num1
echo $num2

结果:

[icexmoon@xyz bin]$ sh cal_test.sh
1+2+3
6

如果要赋值给变量,必须使用$(())

此外还可以进行变量自增或自减等类似于C语言的操作:

#!/bin/bash
# test cal
num=10
((num--))
echo $num
((num+=3))
echo $num
exit 0

注释

Shell脚本中,使用#可以标记单行注释,对于多行注释,可以:

#!/bin/bash
<< 'BLOCK'
This is a multi lines comments
This is a multi lines comments
This is a multi lines comments
BLOCK
echo 'Hello world'
exit 0

事实上上边的BLOCK是一个文档的起始和结尾标识,中间可以包含一个多行的长字符串,用这种方式构建的长字符串一般称之为“文档串”(document string)。

文档串的标识是可以自己定义的:

#!/bin/bash
<< 'DOCUMENT'
This is a multi lines comments
This is a multi lines comments
This is a multi lines comments
DOCUMENT
echo 'Hello world'
exit 0

通过这种方式实现多行注释时,需要注意:

  • 事实上这种风格的注释在C语言中也有使用,实际使用中最好用大写字母来命名文档标识。
  • 对于文档的起始标识<< ’DOCUMENT‘,可以不使用单引号,但用文档串实现注释时候使用单引号是一个好习惯,原因见后文中文档串的详细说明。

此外,还可以这样:

#!/bin/bash

: '
This is a multi lines comments
This is a multi lines comments
This is a multi lines comments
'

echo 'Hello world'
exit 0

需要注意的是:与之后的单引号必须以一个空格隔开。

事实上这是一种取巧的做法,:本身在Bash中就是一个命令:

[icexmoon@xyz bin]$ help :
:: :
    空的命令。

    没有效果; 此命令不做任何操作。

    退出状态:
    总是成功。

这个命令代表“空命令”,不会做任何事,返回值恒为True。言外之意就是无论你给它传递什么参数,它都会无视掉。所以上: 'xxx'这样的写法是将后边单引号构造的多行字符串传递给:命令作为参数,进而产生注释一样的效果。

虽然从结果上来说和真实的注释几乎没有区别,但实际上因为原理的不同,会对脚本的执行效率产生影响。因为注释是完全不执行的,而: 'xxx'方式构造的语句会真的执行,只不过“执行结果被人为抛弃”,所以这种写法不应当被提倡。

最后还有一种写法,实际上是将前两者进行结合使用:

#!/bin/bash

: << 'COMMENTS'
This is a multi lines comments
This is a multi lines comments
This is a multi lines comments
COMMENTS

echo 'Hello world'
exit 0

这种方式是用文档串作为:的参数,进而实现多行注释的效果,理论上来说可以避免上边的问题,因为文档串内部是不会进行任何运算和处理的,会被当做一整块纯字符串看待,但这样写好像没有任何必要性,因为文档串本身就可以实现注释,干嘛要用:

其实最好用且最保险的方式还是实用#直接对多行代码进行注释,现代的IDE都可以实现快捷的多选和多行注释操作(不过vim好像不行?),甚至写了这么久Python的我依然不知道Python有没有特殊的多行注释语法ORZ。

字符串

Bash中可以实用双引号或单引号构建字符串,两者的区别类似C语言中的区别:

  • 单引号中不能实用变量和转义字符
  • 双引号中可以实用变量和转义字符

虽然说是这么说,但实际使用中我发现可能和你实用其它语言,比如PHP等的效果有一些出入,比如:

#!/bin/bash
# test echo
str1="your\tname\tis\tmoon\n"
str2='your\tname\tis\tmoon\n'
echo -en "$str1"
echo -en "$str2"
echo -en $str1
echo -en $str2

上边两个字符串变量str1str2分别用双引号和单引号构建,理论上说后者输出的时候不应当对字符串中的转义符解析才对,但实际上:

[icexmoon@xyz bin]$ sh echo_test.sh
your    name    is      moon
your    name    is      moon
your    name    is      moon
your    name    is      moon

这是因为echo是一个第三方命令,并不是语言解释器的一部分,所以它并没有能力去分析传递给它的字符串是双引号构建还是单引号构建,进而区别对待,所以它只会统一根据调用时的参数设置对字符串内容解析后处理。这点和在PHP中实用echo $str是有很大区别的,echoPHP语言的内建关键字,自然可以在解释器运行时分析字符串是何种方式构建,并进行区别对待。

此外,

字符串连接

因为Bash对变量的实用是通过”变量替换“完成的,所以在Bash中进行字符串连接相当随意,并不需要实用特殊的字符串连接符:

#!/bin/bash
# test string connect
name='icexmoon'
message="My name is ${name}"
message2='My name is '$name
echo $message
echo $message2
exit 0

在双引号内的字符串中直接实用变量名或者在字符串一侧直接拼接,都是可行的连接方式。

获取字符串长度

获取字符串长度的方式很简单:

#!/bin/bash
# test string length
message='Hellow World!'
length=${
     #message}
echo $message
echo $length
exit 0

只要实用${#str_name}的方式就可以了。

字符串截取

Bash中的字符串可以很方便地提取子字符串,这点类似于Python中地字符串操作:

#!/bin/bash
# test sub string
string='abcABC123'
echo $string
echo ${string:3}
echo ${string:3:3}
exit 0

测试:

[icexmoon@xyz bin]$ sh str_sub_test.sh
abcABC123
ABC123
ABC

需要注意地是字符串截取时下标从0开始。

文档串

文档串可以简单地理解为多行字符串,但在细节上有所区别,文档串可以作为整体进行输出:

#!/bin/bash
# doc string test
lan1='Java'
lan2='Python'
lan3='C++'
cat << 'EOF'
Hellow world
${lan1}
${lan2}
${lan3}
EOF
cat << EOF
Hellow world
${lan1}
${lan2}
${lan3}
EOF

测试:

[icexmoon@xyz bin]$ sh doc_str_test.sh
Hellow world
${lan1}
${lan2}
${lan3}
Hellow world
Java
Python
C++

实用单引号作为文档串其实标识的时候文档串内部是不会进行变量替换的,反之则会。

虽然文档串的使用中<< EOF是会一起实用的,但实际上可以分开来看,文档串标识包裹的是一个多行的字符串,而开始标识前的<<则是Bash中的stdin重定向符号,其本质用途是将一个数据源重定向到stdin,并且指定一个结束符号,所以文档串可以看作是现编了一个以某个固定字符为结束符号的文档,然后将这个文档重定向到stdin,并指明结束符号。

这样解释以后上边的代码应该很好理解了,cat是管道命令,支持stdin,而我们通过stdin重定向将文档串内的数据输入给它,然后cat通过stdout,也就是屏幕进行输出。

类似的,借助read命令还可以将文档串赋值给变量:

#!/bin/bash
# doc string test
lan1='Java'
lan2='Python'
lan3='C++'
read -rd '' msg2 << EOF
Hellow world
${lan1}
${lan2}
${lan3}
EOF
echo -en "$msg2"
echo
exit 0

测试:

[icexmoon@xyz bin]$ sh doc_str_test2.sh
Hellow world
Java
Python
C++

同样,我们可以借助$()将文档串的内容保存到变量:

#!/bin/bash
# test doc string
message=$(cat << EOF
Hellow Wolrd
Java
PHP
Python
EOF
)
echo -en "$message"
echo

这里的执行逻辑是先通过cat将文档串内容输出到”屏幕“,然后借助$()将输出内容截获,并赋值给message变量。

总的来说文档串应该不怎么常用,了解一下就可以了。

数组

Bash中的数组分为两种:索引数组和关联数组。

索引数组

索引数组就是编程领域常说的那种数组,以数字为下标,从0开始:

#!/bin/bash
# test array
declare -a numbers=(1 2 3 4 5)
echo 'numbers[0]='${numbers[0]}
echo 'numbers[1]='${numbers[1]}
echo 'numbers[2]='${numbers[2]}

除了使用()进行初始化,还可以:

#!/bin/bash
# test array
numbers[0]=1
numbers[1]=2
numbers[2]=3
echo 'numbers[0]='${numbers[0]}
echo 'numbers[1]='${numbers[1]}
echo 'numbers[2]='${numbers[2]}

还可以:

#!/bin/bash
# test array
declare -a numbers=(
        [0]=1
        [1]=2
        [2]=3
)
echo 'numbers[0]='${numbers[0]}
echo 'numbers[1]='${numbers[1]}
echo 'numbers[2]='${numbers[2]}

但这种方式应该不推荐,这是关联数组的命名风格,一般来说索引数组的下标应当依赖于自动生成,而而非人为指定。

关联数组

关联数组就是可以使用数字以外的值(一般是字符串)作为键的数组:

#!/bin/bash
# test array
declare -A numbers=(
        ['num1']=1
        [num2]=2
        [num3]=3
)
numbers[num4]=4
numbers[num5]=5
echo 'numbers[num1]='${numbers['num1']}
echo 'numbers[num2]='${numbers[num2]}
echo 'numbers[num3]='${numbers[num3]}
echo 'numbers[num4]='${numbers['num4']}
echo 'numbers[num5]='${numbers[num5]}

实际使用中发现键在定义和使用的时候都可以用引号也可以不用,相当随意。

获取数组长度

#!/bin/bash
# test array
declare -a numbers=(1 2 3 4 5)
echo ${
     #numbers[@]}
echo ${
     #numbers[*]}
exit 0

使用#获取数组长度,和字符串类似。

获取数组的键和值

#!/bin/bash
# test array
declare -a numbers=(1 2 3 4 5)
echo ${
     !numbers[@]}
echo ${numbers[@]}
echo ${
     !numbers[*]}
echo ${numbers[*]}
exit 0

使用!可以获取数组的键,不使用!是获取数组的值。

*与@的区别

在数组中,array[*]array[@]都可以表示数组的值,如果不适用引号或者使用单引号,它们的效果类似:

#!/bin/bash
# test array
declare -a numbers=(1 2 3 4 5)
for num in ${numbers[*]}
do
        echo -n $num' '
done
echo
for num in ${numbers[@]}
do
        echo -n $num' '
done
echo
for num in '${numbers[*]}'
do
        echo -n $num' '
done
echo
for num in '${numbers[@]}'
do
        echo -n $num' '
done
echo
exit 0

测试:

[icexmoon@xyz bin]$ sh array_test6.sh
1 2 3 4 5
1 2 3 4 5
${numbers[*]}
${numbers[@]}

但是在使用双引号的时候两者解析结果会大为不同:

#!/bin/bash
# test array
declare -a numbers=(1 2 3 4 5)
for num in "${numbers[*]}"
do
        echo $num
done
echo
for num in "${numbers[@]}"
do
        echo $num
done
echo
exit 0

结果:

[icexmoon@xyz bin]$ sh array_test7.sh
1 2 3 4 5

1
2
3
4
5

可以看出来了吧?"numbers[*]"被解析成了一个空格分隔的字符串,而numbers[@]被解析成了一个序列,所以前者仅迭代了一次,后者迭代了多次。

关于for循环的使用见后文的循环部分。

条件表达式

在编写程序的时候我们往往需要进行一些判断,比如变量是否存在,比如文件是否存在。在Shell脚本中有一些命令可以提供帮助。

test

test命令可以用于判断一个文件是否存在,以及文件的类型和权限等:

参数 意义
-e path exist,目标是否存在
-d path directory,目标是否存在,且为目录
-f path file,目标是否存在,且为文件
-r path read,目标是否存在,且是否有读权限
-w path write,目标是否存在,且是否有写权限
-x path excute,目标是否存在,且是否有执行权限
file1 -nt file2 new than,判断文件file1是否比file2
file1 -ot file2 old than,判断文件file1是否比file2
file1 -ef file2 equal file,判断文件file1file2是否为同一个文件(指向同一个inode)
-z string zero length string,判断字符串长度是否为0,如果是,返回True
-n string not zero length string,判断字符串长度是否为0,如果不是,返回True
string 判断字符串是否为空白符组成的字符串,如果不是,返回True
str1 == str2 字符串比较,相等返回True
str1 != str2 字符串比较,不相等返回True
num1 -eq num2 equal,整数比较,相等返回True
num1 -ne num2 not equal,整数比较,不相等返回True
num1 -gt num2 greater than,整数num1是否大于num2
num1 -lt num2 less than,整数num1是否小于num2
num1 -ge num2 greater than or equal,整数num1是否大于等于num2
num1 -le num2 less than or equal,整数num1是否小于等于num2
cond1 -a cond2 and,条件表达式cond1cond2同时成立,返回True,否则为False
cond1 -o cond2 or,条件表达式cond1cond2同时不成立,返回False,否则为True
! condition 取反,对条件表达式condition的结果取反

示例,一个简单的文件检测程序:

[icexmoon@xyz bin]$ cat file_scan.sh
#!/bin/bash
# a program for scan file which inputed by user and print information

# get a file path from user input
read -p 'Please input a path:' file_path
test -e $file_path && echo 'The path exists' || echo 'The path not exists'
test -f $file_path && echo 'The path is file'
test -d $file_path && echo 'The path is directory'
test -r $file_path && echo 'The path is readable'
test -w $file_path && echo 'The path is writeable'
test -x $file_path && echo 'The path is excuteable'
exit 0

需要注意的是,上方的表达式需要根据变量的类型选择对应的表达式,而且对于字符串,-z stringstring的效果是有区别的,前者是按照字符串长度去判断,凡是长度不为零的都返回True,而后者会将只包含空白符的字符串认为是Flase:

#!/bin/bash
# test empty
function empty_test(){
     
        if [ $1 ]
        then
                echo "${1} is not empty"
        else
                echo "${1} is empty"
        fi
}
strings=('' ' ' 0 '     ' '\t' '\n')
for string in "${strings[@]}"
do
        empty_test $string
done
exit 0

实际测试:

[icexmoon@xyz bin]$ sh empty_test.sh
 is empty
 is empty
0 is not empty
 is empty
\t is not empty
\n is not empty

可以看到,无论是制表符还是空格,都会返回False,所以-n-z的判断依据是是否为空字符串,而[ string ]的判断依据是是否为空字符串以及仅包含空白符的字符串。特别的,对于空白符对应的转义符,[ string ]不会按照空白符看待。

在编程领域,仅有长度为0的字符串会被称为空字符串。

判断符号[]

判断符号[]的作用类似于test命令,内部的判断式写法也是完全相同的:

[icexmoon@xyz bin]$ string_test='test'
[icexmoon@xyz bin]$ [ "${string_test}" == 'test' ]; echo $?
0
[icexmoon@xyz bin]$ [ "${string_test}" == 'test1' ]; echo $?
1

需要注意的是:

  • Bash中的返回值,0表示True,1表示False,这点和其它主流编程语言是相反的。

  • []中的字符串必须用引号包裹(防止中间出现空白符切断字符串)

  • []中的变量必须用引号包裹,因为Bash的执行过程更像是先将对变量的引用$xxx变成字符串输出到表达式中,然后再对整个表达式进行处理,如果变量的值中有空白符,比如:

    [icexmoon@xyz bin]$ person_name='Li Lei'
    [icexmoon@xyz bin]$ [ $person_name == 'Li Lei' ]; echo $?
    -bash: [: 参数太多
    2
    

    Bash会先将表达式中的变量用其值替换,整个表达式会变成[ Li Lei == 'Li Lei' ],然后再执行,这样显然是不正确的,等号左边的参数变成了2个独立的字符串,LiLei,不符合判断式的语法,所以就会报错。

  • []中的空格是必须存在的,比如[ exp ],判断式exp两边必须有两个空格,否则不能执行。

编写一个用于测试的脚本:

[icexmoon@xyz bin]$ cat compare_test.sh
#!/bin/bash
# compare test program
# user input
read -p 'Please input any string:' user_input
[ "${user_input}" == 'yes' ] && echo "continue" ||( [ "${user_input}" == 'no' ] && echo "exit" || echo "I don't know!")
exit 0

测试:

[icexmoon@xyz bin]$ sh compare_test.sh
Please input any string:yes
continue
[icexmoon@xyz bin]$ sh compare_test.sh
Please input any string:no
exit
[icexmoon@xyz bin]$ sh compare_test.sh
Please input any string:xxx
I don't know!

需要说明的是这个测试脚本中的写法可读性极差,仅能用于体会判断式的写法,实际中需要用条件语句改写。

命令行参数

基本上所有的程序都可以从命令行读取参数,这点对于脚本语言尤其重要,毕竟对于一个轻量级脚本来说,往往也不存在复杂的交互界面来输入参数,基本上都是命令行一键执行,那么通过命令行来获取必要的参数自然就是首选方案了。

Shell中,可以使用特殊命名的变量获取命令行参数:

  • $0:脚本名称
  • $n:($1~$n),第n个命令行参数
  • $#:参数总数
  • $@:全部参数,以空格分隔
  • $*:全部参数,用$IFS的值(默认为空格)分隔

可以用以下脚本进行测试:

#!/bin/bash
# test script's parameters
echo "fileName:${0}"
echo "params number:${
      #}"
echo "param1:${1}"
echo "param2:${2}"
echo "param3:${3}"
echo "all params:$@"
exit 0

测试:

[icexmoon@xyz bin]$ sh parameters.sh p1 p2 p3
fileName:parameters.sh
params number:3
param1:p1
param2:p2
param3:p3
all params:p1 p2 p3

shift

shift语句可以对命令行参数列表进行“修剪”,这点用文字很难表述,直接看效果:

#!/bin/bash
# test shift
echo "params number:${
      #}"
echo "all params:${@}"
shift
echo "params number:${
      #}"
echo "all params:${@}"
shift 2
echo "params number:${
      #}"
echo "all params:${@}"
shift 3
echo "params number:${
      #}"
echo "all params:${@}"

测试:

[icexmoon@xyz bin]$ sh params_shift.sh p1 p2 p3 p4 p5 p6 p7 p8 p9
params number:9
all params:p1 p2 p3 p4 p5 p6 p7 p8 p9
params number:8
all params:p2 p3 p4 p5 p6 p7 p8 p9
params number:6
all params:p4 p5 p6 p7 p8 p9
params number:3
all params:p7 p8 p9

可以看到,通过shift语句的执行,命令行参数会从头开始被“吞噬”,整个命令行参数列表会越来越短。

这有点像队列的左移操作,将整个命令行参数看做是一个队列,shift就相当于该队列的左移操作,shift 3就相当于从队列的左侧移出3个元素。

切割

整个命令行参数相当于一个序列(空格分隔),可以实用类似于字符串截取地方式进行切割:

[icexmoon@xyz bin]$ cat str_sub_test2.sh
#!/bin/bash
# test sub string
echo '$*='$*
echo '${*:1}='${*:1}
echo '${*:2:2}='${*:2:2}
echo '$@='$@
echo '${@:1}='${@:1}
echo '${@:2:2}='${@:2:2}
exit 0

测试:

[icexmoon@xyz bin]$ sh str_sub_test2.sh p1 p2 p3 p4 p5 p6
$*=p1 p2 p3 p4 p5 p6
${*:1}=p1 p2 p3 p4 p5 p6
${*:2:2}=p2 p3
$@=p1 p2 p3 p4 p5 p6
${@:1}=p1 p2 p3 p4 p5 p6
${@:2:2}=p2 p3

与字符串截取不同地是,这里截取时候下标似乎是从1开始。

条件语句

条件语句就是if/else,这个也是控制流程的基础。

单分支条件语句

单分支if的写法为:

if [条件表达式];then
	something
fi

和主流语言比起来好像有点奇怪,至少那个then看起来就挺多余的,而且以fi结尾也怪怪的,既不像C++之类的使用{},也不像Python完全依赖缩进。

anyway,直接看实例:

#!/bin/bash
# test if
read -p "Please input a choice(y/n)" choice
if [ "${choice}" == 'y' ]||[ "${choice}" == 'Y' ];then
        echo 'yes'
        exit 0
fi
if [ "${choice}" == 'n' ]||[ "${choice}" == 'N' ];then
        echo 'no'
        exit 0
fi
echo "I don't know"
exit 0

这里在一个if中使用了多个条件表达式,所以使用逻辑运算符||进行连接,类似的,也可以使用&&,当然不止一种写法,也可以:

if [ "${choice}" == 'y' -o "${choice}" == 'Y' ];then
        echo 'yes'
        exit 0
fi

这样就是将多个条件整合到一个表达式中。

同样吐槽一下,又是-o又是||的,难道不能统一一下?

多分支条件语句

多分支条件语句的写法为:

if [条件表达式];then
	something
else
	something
fi

或者

if [条件表达式];then
	something
elif [条件表达式];then
	something
else
	something
fi

至少这里的elif和Python的风格很像…我总算知道程序员为啥容易秃顶了,一个条件语句的风格都能整出这么多幺蛾子。

使用多分支if改写前边的例子:

#!/bin/bash
# test if
read -p "Please input a choice(y/n)" choice
if [ "${choice}" == 'y' ]||[ "${choice}" == 'Y' ];then
        echo 'yes'
elif [ "${choice}" == 'n' ]||[ "${choice}" == 'N' ];then
        echo 'no'
else
        echo "I don't know"
fi
exit 0

case

switch/case这种结构并非是编程语言必须具有的,因为其功能完全可以被if/elif结构取代,比如Python之前就没有类似的结构,不过在最近的版本中引入了类似的语法。

说回到Shell,我还从来没有见过这么反人类的switch语法(Shell中甚至没有用switch命名,或许可以叫case语法?):

case '字符串' in
	'str1')
		something
		;;
	'str2')
		something
		;;
	*)
		something
		;;
esac

我不知道算不算“丑陋”,但至少说不上优雅,这种逼死强迫症的单右括号是谁发明的?再搭配上奇怪的双分号;;作为每个case的结束标识,我只能说这个开发者的审美堪称鬼才。

相比之下一个海象标识符:=就把Python的创始人逼走了简直千古奇冤。

anyway,我们继续看示例:

#!/bin/bash
# test switch case
read -p '你掉的是金斧头还是银斧头呢?' answer
case ${answer} in
        '金斧头')
                echo '河神给你两个耳光'
                ;;
        '银斧头')
                echo '河神给了你一个耳光'
                ;;
        '烂斧头')
                echo '河神给了你金斧头和银斧头'
                ;;
        *)
                echo '河神听不懂你说的是啥'
                ;;
esac
exit 0

没啥好说的,就是字符串匹配,比较特立独行的是那个结尾的*,用来匹配其它结果,等效于其它语言switch/case结构体中的default,也算是相当骨骼清奇了。

函数

函数的基本语法:

function func_name(){
     
	something
	return num
}

老实说看到这个我长呼一口气,终于有个熟悉的不那么奇葩的语法结构了。

来看一个最简单的例子:

#!/bin/bash
# test function
function hello(){
     
        echo 'hellow world!'
}
hello
exit 0

比较特殊的是在Shell中函数调用时不需要使用(),直接使用函数名就可以。

参数

Shell中的函数同样可以传递参数:

#!/bin/bash
# test function
function get_sum(){
     
        echo "param1:$1"
        echo "param2:$2"
        sum_result=$(($1+$2))
        echo "result:${sum_result}"
}
get_sum 23 34
echo $sum_result
exit 0

使用函数名 参数1 参数2的方式可以传递参数(空格分隔),而在函数中使用$1$2之类的可以获取到对应的参数。

变量作用域

在函数中,创建的普通变量的作用域等同于整个脚本的作用域,使用local关键字可以创建只限于函数内部作用域的变量:

#!/bin/bash
# test function
function get_sum(){
     
        local param1="param1:$1"
        param2="param2:$2"
        sum_result=$(($1+$2))
}
get_sum 23 34
echo "param1:${param1}"
echo "param2:${param2}"
echo $sum_result
exit 0

测试:

[icexmoon@xyz bin]$ sh func_test3.sh
param1:
param2:param2:34
57

因为param1使用local被声明位局部变量,所以其作用域仅限于函数内部,无法在函数外被访问。

此外,在函数体内可以定义和全局变量重名的局部变量,并且在函数体内局部变量的优先级高于同名全局变量,可以理解为类似Java中的就近原则。

特别的,在函数中使用declare创建的变量其作用域同样仅限于函数内部。

返回值

可以使用return关键字返回一个整型数值,这看起来和其它编程语言类似,但有着本质上的不同。

因为Shell中的函数可以看作是一个“微型的自定义命令”,其返回值要符合Shell一般命令的返回值规则,即0表示True,非0表示False,具体可以表示一为一个错误码。所以本质上来说,函数返回的是一个类似于状态码一样的东西,且这个返回的整型值是有确切范围的(0~255),所以在Shell的函数中通过返回值来返回计算结果之类的业务数据是不正确的使用方式,正确的做法应当是通过定义一个普通变量来让外部获取其“返回值”。

此外,return语句是可选的,如果不写return语句,则函数在调用时会将退出函数时执行的最后一条语句的执行结果作为函数的返回值进行返回。

循环

while

Shell中的while循环的基本写法为:

while [条件表达式]
do
	something
done

个人觉得这个写法还和其它编程语言挺类似的,可以接受。

下面看实例,这是我用循环写的一个输出斐波那契数列的例子:

#!/bin/bash
# test while
declare -i a=1
declare -i b=1
declare -i c=0
declare -i count=2
echo -n "$a "
echo -n "$b "
while [ $count -lt 10 ]
do
((count++))
c=$a+$b
echo -n "$c "
a=$b
b=$c
done
echo
exit 0

这里echo -n的作用是不让echo自动换行。

测试一下:

[icexmoon@xyz bin]$ sh while_test.sh
1 1 2 3 5 8 13 21 34 55

until

until循环与while类似,其定义为:

until [条件表达式]
do
	something
done

不同的是,until是条件为False时进入循环,条件为真时跳出循环。

until打印斐波那契数列:

#!/bin/bash
# test while
declare -i a=1
declare -i b=1
declare -i c=0
declare -i count=2
echo -n "$a "
echo -n "$b "
until [ $count -ge 10 ]
do
((count++))
c=$a+$b
echo -n "$c "
a=$b
b=$c
done
echo
exit 0

其实我是拷贝了while的代码,只修改了一个地方until [ $count -ge 10 ],将条件换成了相反的,其实使用!取反也是一样的效果。

我个人不推荐用until,觉得这个False进入循环的逻辑有点反人类,况且也从来没见过其它编程语言需要一个此类的循环,有while足矣。

for

for循环用于固定次数的循环,其基本语法:

for item in list
do
	something
done

这个list表示为一个列表一样的东西,可以用来迭代并获取每一个元素,我们看示例:

#!/bin/bash
names="Li_Lei Han_Meimei Jack_Chen"
for name in $names
do
        echo -n "name:${name} "
done
echo
exit 0

测试:

[icexmoon@xyz bin]$ sh for_test.sh
name:Li_Lei name:Han_Meimei name:Jack_Chen

这里的list是一个空格分隔的字符串,表示一系列人名,在执行for name in $names语句的时候,Bash会将$names用空格分隔的字符串进行替换,也就变成了for name in Li_lei Han_Meimei Jack_Chen这样的东西,自然会认为是一个包含了多个字符串的列表,进行迭代。

当然,因为上述原因,人名中不得不用下划线连接姓和名,否则就无法正常输出完整的名字。

如果要不加下划线,可以使用数组:

#!/bin/bash
# test for
declare -a names=("Li lei" "Han Meimei" "Jack Chen")
for name in "${names[@]}"
do
        echo -n "name:${name} "
done
echo
exit 0

输出结果是和上边类似的,不过人名中间就不用下划线了。

需要注意的是,这种写法中${names[@]}变量必须用双引号包裹,否则会被认为姓和名是两个字符串。

下面看一些更实际一些的例子,之前在Linux 之旅 8:初识 BASH中介绍xargs命令时举过一个例子,先用cut截取用户名,然后再使用xargs传递参数给id命令,以打印用户的相关信息,我们通过Shell脚本同样可以完成类似的工作:

#!/bin/bash
# show user info
users=$(cut -d ':' -f 1 /etc/passwd)
for user in $users:
do
        id $user
done
echo
exit 0

再看一个例子,这个例子可以检测一定范围内的ip连接是否有效:

#!/bin/bash
# test ip
master_ip='192.168.1.'
declare -i ping_result
for last_ip in $(seq 1 10)
do
        tested_ip="${master_ip}${last_ip}"
        ping -c 1 -w 1 ${tested_ip} &> /dev/null
        ping_result=$?
        if [ $ping_result -eq 0 ]
        then
                echo "server ${tested_ip} is UP"
        else
                echo "server ${tested_ip} is DOWN"
        fi
done
exit 0

seq 1 10是一个方便的序列产生命令,可以产生m~n的序列,并且用换行符进行分隔。除了使用seq以外,还可以使用{1..100},也是同样的效果:

for last_ip in {
     1..10}

这种写法还支持设置步进:

for last_ip in {
     1..10..2}

这样会“跳步”,比如产生1,3,5,7,9这样的序列。

此外,for循环还支持C风格的三表达式:

for ((i-0; i<n; i++))
do
	something
done

不知道是不是错觉,总觉得用双层小括号包起来就可以执行C风格的代码了。

这里看一个例子,经典的累加求和:

#!/bin/bash
# test for
declare -i total=0
declare -i index=0
for ((index=1; index<=10; index++))
do
        echo -n "${total}+${index}="
        total=$total+$index
        echo $total
done
echo "total:${total}"

debug

在编写Shell脚本的时候往往需要进行调试,输出一些调试信息以观察哪里出现问题,其实Bash本身有提供一些debug功能,比如:

[icexmoon@xyz bin]$ sh -x for_test3.sh
+ declare -i total=0
+ declare -i index=0
+ (( index=1 ))
+ (( index<=10 ))
+ total=0+1
+ (( index++ ))
+ (( index<=10 ))
+ total=1+2
+ (( index++ ))
+ (( index<=10 ))

注释掉脚本中的相关中间数据输出,使用sh -x可以清晰看到脚本执行的全过程,包括每次循环,以及求和变量的每次运算,可以对debug提供相当多的支持。

此外,可能有些脚本不方便在bug较多的时候直接执行,比如会产生一些难以轻松回滚的影响之类的,这时候就要这样:

[icexmoon@xyz bin]$ sh -n for_test3.sh

使用-n参数可以在不执行脚本的情况下检查脚本的语法错误。

关于Shell脚本的内容就介绍到这里了,谢谢阅读。

  • 虽然我已经很努力了,这篇文章也花了两天时间来完善,但依然有很多地方存在不足,也不够详尽,或许后续会开个新坑?
  • 虽然大体上《鸟哥的私房菜》纰漏不多,但这个章节的确很不让人满意,泛泛而谈也就算了,很多地方讲的不对。

参考资料

  • Shell函数详解(函数定义、函数调用)
  • shell/bash脚本编程
  • 一篇教会你写90%的shell脚本
  • Bash 脚本入门
  • shell 多行注释详解
  • Bash 中常见的字符串操作
  • Shell 编程 – 多行字符串变量
  • Bash技巧:详解键值对关联数组、一维索引数组的用法

你可能感兴趣的:(linux,shell,脚本,Linux,bash,sh)