- 到目前为止,我们已经学习来如何在 shell 中执行命令,并使用管道将命令组合使用。但是,很多情况下我们需要执行一系列的操作并使用条件或循环这样的控制流。-> shell 脚本是一种更加复杂的工具。
- 大多数 shell 都有自己的一套脚本语言,包括变量、控制流和自己的语法。shell脚本与其他脚本语言不同之处在于,shell 脚本针对 shell 所从事的相关工作进行来优化。因此,创建命令流程(pipelines)、将结果保存到文件、从标准输入中读取输入,这些都是 shell 脚本中的原生操作,这让它比通用的脚本语言更易用。本节中,我们会专注于 bash 脚本,因为它最流行,应用更为广泛。
Lecture 2 Shell Tools and Scripting
★Shell 脚本
- 到目前为止,我们已经学习来如何在 shell 中执行命令,并使用管道将命令组合使用。但是,很多情况下我们需要执行一系列的操作并使用条件或循环这样的控制流。-> shell 脚本是一种更加复杂的工具。
- 大多数 shell 都有自己的一套脚本语言,包括变量、控制流和自己的语法。shell脚本与其他脚本语言不同之处在于,shell 脚本针对 shell 所从事的相关工作进行来优化。因此,创建命令流程(pipelines)、将结果保存到文件、从标准输入中读取输入,这些都是 shell 脚本中的原生操作,这让它比通用的脚本语言更易用。本节中,我们会专注于 bash 脚本,因为它最流行,应用更为广泛。
变量及访问
在 bash 中为变量赋值的语法是
foo=bar
,访问变量中存储的数值,其语法为$foo
。 需要注意的是,foo = bar
(使用空格隔开)是不能正确工作的,因为解释器会调用程序foo
并将=
和bar
作为参数。 总的来说,在 shell 脚本中使用空格会起到分割参数的作用,有时候可能会造成混淆,请务必多加检查。1
2
3god@god-virtual-machine:~/桌面$ foo=bar
god@god-virtual-machine:~/桌面$ echo $foo
barBash 中的字符串通过
'
和"
分隔符来定义,但是它们的含义并不相同。以'
定义的字符串为原义字符串,其中的变量不会被转义,而"
定义的字符串会将变量值进行替换。1
2
3
4
5god@god-virtual-machine:~/桌面$ foo=bar
god@god-virtual-machine:~/桌面$ echo 'the value is $foo'
the value is $foo
god@god-virtual-machine:~/桌面$ echo "the value is $foo"
the value is bar
★脚本及使用
source、diff
★命令替换、进程替换、通配符、shebang
和其他大多数的编程语言一样,
bash
也支持if
,case
,while
和for
这些控制流关键字。同样地,bash
也支持函数,它可以接受参数并基于参数进行操作。下面这个函数是一个例子,它会创建一个文件夹并使用cd
进入该文件夹。1
2
3
4god@god-virtual-machine:~/桌面$ vim mcd.sh
god@god-virtual-machine:~/桌面$ source mcd.sh
god@god-virtual-machine:~/桌面$ mcd test
god@god-virtual-machine:~/桌面/test$上面的命令使用
vim
创建并编辑了mcd.sh
脚本文件,该文件的内容如下面所示;source
命令用于重新执行刚修改的mcd.sh
文件,使之立即生效,而不必注销并重新登录;然后使用mcd
函数,新建了test
文件夹并进入。1
2
3
4mcd(){
mkdir "$1"
cd "$1"
}- **
source
**:通常用于重新执行刚修改的初始化文件,使之立即生效,而不必注销并重新登录。因为linux所有的操作都会变成文件的格式存在。
这里
$1
是脚本的第一个参数。与其他脚本语言不同的是,bash使用了很多特殊的变量来表示参数、错误代码和相关变量。下面是列举来其中一些变量:$0
- 脚本名$1
到$9
- 脚本的参数。$1
是第一个参数,依此类推。$@
- 所有参数$#
- 参数个数$?
- 前一个命令的返回值,返回值0表示正常执行,其他所有非0的返回值都表示有错误发生。$$
- 当前脚本的进程识别码!!
- 完整的上一条命令,包括参数。常见应用:当你因为权限不足执行命令失败时,可以使用sudo !!
再尝试一次。$_
- 上一条命令的最后一个参数。如果你正在使用的是交互式 shell,你可以通过按下Esc
之后键入 . 来获取这个值。$HOME
:用户主文件夹。$PATH
:全局命令的搜索路径。1
2
3
4running_noob@RNoob-VM:~$ echo $HOME
/home/running_noob
running_noob@RNoob-VM:~$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
- **
命令通常使用
STDOUT
来返回输出值,使用STDERR
来返回错误及错误码,便于脚本以更加友好的方式报告错误。 返回码或退出状态是脚本/命令之间交流执行状态的方式。返回值0表示正常执行,其他所有非0的返回值都表示有错误发生。退出码可以搭配
&&
(与操作符)和||
(或操作符)使用,用来进行条件判断,决定是否执行其他程序。它们都属于短路运算符(short-circuiting) 。同一行的多个命令可以用;
分隔。程序true
的返回码永远是0
,false
的返回码永远是1
。让我们看几个例子:1
2
3
4
5
6
7
8
9
10
11
12
13god@god-virtual-machine:~/桌面$ true
god@god-virtual-machine:~/桌面$ echo $?
0
god@god-virtual-machine:~/桌面$ false
god@god-virtual-machine:~/桌面$ echo $?
1
god@god-virtual-machine:~/桌面$ false || echo "Oops fail"
Oops fail
god@god-virtual-machine:~/桌面$ true || echo "Will not be printed"
god@god-virtual-machine:~/桌面$ true && echo "Will be printed"
Will be printed
god@god-virtual-machine:~/桌面$ false && echo "Will not be printed"
god@god-virtual-machine:~/桌面$另一个常见的模式是以变量的形式获取一个命令的输出,这可以通过 命令替换(command substitution)实现。
当您通过
$( CMD )
这样的方式来执行CMD
这个命令时,它的输出结果会替换掉$( CMD )
。例如,如果执行for file in $(ls)
,shell首先将调用ls
,然后遍历得到的这些返回值。1
2
3god@god-virtual-machine:~/桌面$ foo=$(pwd)
god@god-virtual-machine:~/桌面$ echo "$foo"
/home/god/桌面上面的这个命令,将函数
pwd
的输出结果作为foo
变量的值。还有一个冷门的类似特性是 进程替换(process substitution), <( CMD ) 会执行 CMD 并将结果输出到一个临时文件中,并将 <( CMD ) 替换成临时文件名。这在我们希望返回值通过文件而不是 STDIN 传递时很有用。例如,
cat <(ls) <(ls ..)
会显示当前目录下的文件和当前目录的直接父目录的文件。1
2
3
4
5
6
7god@god-virtual-machine:~/桌面$ cat <(ls) <(ls ..)
HIT-Linux-0.11
mcd.sh
vmware-tools-distrib
公共的
模板
......说了很多,现在该看例子了,下面这个例子展示了一部分上面提到的特性。这段脚本
example.sh
会遍历我们提供的参数,使用grep
搜索字符串foobar
,如果没有找到,则将其作为注释追加到文件中。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15!/bin/bash
echo "Starting program at $(date)" # date会被替换成日期和时间
echo "Running program $0 with $# arguments with pid $$"
for file in "$@"; do
grep foobar "$file" > /dev/null 2> /dev/null
# 如果模式没有找到,则grep退出状态为 1
# 我们将标准输出流和标准错误流重定向到Null,因为我们并不关心这些信息
if [[ $? -ne 0 ]]; then
echo "File $file does not have any foobar, adding one"
echo "# foobar" >> "$file"
fi
done在条件语句中,我们比较
$?
是否等于0(-ne
的意思是不等于,not equal
)。 Bash实现了许多类似的比较操作,您可以查看 test 手册。 **在bash中进行比较时,尽量使用双方括号[[ ]]
而不是单方括号[ ]
,这样会降低犯错的几率,尽管这样并不能兼容sh
**。 更详细的说明参见这里。1
2
3
4
5
6god@god-virtual-machine:~/桌面$ ./example.sh 1.txt
Starting program at 2023年 01月 25日 星期三 15:16:27 CST
Running program ./example.sh with 1 arguments with pid 2780
File 1.txt does not have any foobar, adding one
god@god-virtual-machine:~/桌面$ cat 1.txt
# foobar当执行脚本时,我们经常需要提供形式类似的参数。bash 使我们可以轻松的实现这一操作,它可以基于文件扩展名展开表达式。这一技术被称为 shell 的 通配(globbing)。
通配符 - 当你想要利用通配符进行匹配时,你可以分别使用
?
和*
来匹配一个或任意个字符。例如,对于文件foo
,foo1
,foo2
,foo10
和bar
,rm foo?
这条命令会删除foo1
和foo2
,而rm foo*
则会删除除了bar
之外的所有文件。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16god@god-virtual-machine:~/桌面/missing-semester/tools$ ls
example.sh mcd.sh project1 project2 project33
god@god-virtual-machine:~/桌面/missing-semester/tools$ ls *.sh
example.sh mcd.sh
god@god-virtual-machine:~/桌面/missing-semester/tools$ ls project?
project1:
1.txt
project2:
god@god-virtual-machine:~/桌面/missing-semester/tools$ ls project*
project1:
1.txt
project2:
project33:花括号
{}
- 当你有一系列的指令,其中包含一段公共子串时,可以用花括号来自动展开这些命令。这在批量移动或转换文件时非常方便。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23convert image.{png,jpg}
# 会展开为
convert image.png image.jpg
cp /path/to/project/{foo,bar,baz}.sh /newpath
# 会展开为
cp /path/to/project/foo.sh /path/to/project/bar.sh /path/to/project/baz.sh /newpath
# 也可以结合通配使用
mv *{.py,.sh} folder
# 会移动所有 *.py 和 *.sh 文件
mkdir foo bar
# 下面命令会创建foo/a, foo/b, ... foo/h, bar/a, bar/b, ... bar/h这些文件
touch {foo,bar}/{a..h}
touch foo/x bar/y
# 比较文件夹 foo 和 bar 中包含文件的不同
diff <(ls foo) <(ls bar)
# 输出
# < x
# ---
# > y1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19god@god-virtual-machine:~/桌面/missing-semester/tools$ ls
example.sh image.png mcd.sh project1 project2 project33
god@god-virtual-machine:~/桌面/missing-semester/tools$ mkdir foo bar
god@god-virtual-machine:~/桌面/missing-semester/tools$ ls
bar example.sh foo image.png mcd.sh project1 project2 project33
god@god-virtual-machine:~/桌面/missing-semester/tools$ touch {foo,bar}/{a..h}
god@god-virtual-machine:~/桌面/missing-semester/tools$ touch foo/x
god@god-virtual-machine:~/桌面/missing-semester/tools$ touch bar/y
god@god-virtual-machine:~/桌面/missing-semester/tools$ diff <(ls foo) <(ls bar)
9c9
< x
---
> y
god@god-virtual-machine:~/桌面/missing-semester/tools$ ls {foo,bar}
bar:
a b c d e f g h y
foo:
a b c d e f g h x编写
bash
脚本有时候会很别扭和反直觉。例如 shellcheck 这样的工具可以帮助你定位 sh/bash 脚本中的错误。diff
:用于比较文件的差异。diff 以逐行的方式,比较文本文件的异同处。若指定的是目录,则 diff 会比较目录中不同文件名的文件,但不会比较其中子目录。例如上面的terminal
中,==9c9== 表明文件夹foo
和bar
的第九个文件不同,一个是x
,一个是y
。
注意,脚本并不一定只有用 bash 写才能在终端里调用。例如,这是一段 Python 脚本
script.py
,作用是将输入的参数倒序输出:1
2
3
4#!/usr/bin/python
import sys
for arg in reversed(sys.argv[1:]):
print(arg)1
2
3
4
5god@god-virtual-machine:~/桌面/missing-semester/tools$ chmod a+x script.py
god@god-virtual-machine:~/桌面/missing-semester/tools$ ./script.py 1 2 3
3
2
1内核知道去用 python 解释器而不是 shell 命令来运行这段脚本,是因为**脚本的开头第一行的 shebang**:
#!/usr/bin/python
。在
shebang
行中使用 env 命令(environment)是一种好的实践,它会利用环境变量中的程序来解析该脚本,这样就提高来您的脚本的可移植性。env
会利用我们第一节讲座中介绍过的PATH
环境变量来进行定位。 例如,使用了env
的 shebang 看上去时这样的#!/usr/bin/env python
。
脚本函数
脚本函数的语法:
funcName(){}
function funcName(){}
以下面例子为例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15!/bin/bash
printName(){
if [ $# -ne 2 ]; then
echo "illegal parameter."
exit 1
fi
echo "firstname is $1"
echo "lastname is $2"
echo "this is script $0"
}
printName jacky chen输出结果为:
1
2
3
4running_noob@RNoob-VM:~/桌面/test$ ./test.sh
firstname is jacky
lastname is chen
this is script ./test.sh注意:
shell 自上而下执行,函数必须在使用前定义;
函数获取变量和 shell script 类似,
$0
代表函数名,后续参数通过$1
、$2
… 获取;函数内
return
仅仅表示函数执行状态,不代表函数执行结果;函数执行结果一般使用
echo
、printf
返回,在函数外面使用$()
、`` 获取结果,例如下面例子:1
2
3
4
5
6
7
8
9
10
11
12
13!/bin/bash
function test() {
local word="hello,world"
echo $word #用echo返回函数执行结果
return 10 #返回函数执行状态
unset word
}
content=`test` #用$()或者``得到函数执行结果
echo "status: $?"
echo "result: $content"输出结果为:
1
2
3running_noob@RNoob-VM:~/桌面/test$ ./test2.sh
status: 10
result: hello,world如果没有 return,则函数状态是上一条命令的执行状态,存储在
$?
中 。
★shell函数和脚本的不同点
函数只能与 shell 使用相同的语言,脚本可以使用任意语言。因此在脚本中包含
shebang
是很重要的。函数仅在定义时被加载,脚本会在每次被执行时加载。这让函数的加载比脚本略快一些,但每次修改函数定义,都要重新加载一次。
★函数会在当前的 shell 环境中执行,脚本会在单独的进程中执行。因此,函数可以对环境变量进行更改,比如改变当前工作目录,脚本则不行。脚本需要使用
export
将环境变量导出,并将值传递给环境变量。1
2
3
4
5
6god@god-virtual-machine:~/桌面/missing-semester/tools$ ls
bar foo mcd.sh project2 script.py
example.sh image.png project1 project33 text
god@god-virtual-machine:~/桌面/missing-semester/tools$ ./mcd.sh test #脚本在单独的进程中执行
god@god-virtual-machine:~/桌面/missing-semester/tools$ mcd test #函数可以对环境变量进行更改
god@god-virtual-machine:~/桌面/missing-semester/tools/test$如果我们改变了
mcd.sh
脚本的内容,把函数名由mcd
改为mkdirAndcd
,则:1
2
3
4
5
6god@god-virtual-machine:~/桌面/missing-semester/tools$ ls
bar foo mcd.sh project2 script.py
example.sh image.png project1 project33
god@god-virtual-machine:~/桌面/missing-semester/tools$ ./mcd.sh test
god@god-virtual-machine:~/桌面/missing-semester/tools$ mkdirAndcd test
god@god-virtual-machine:~/桌面/missing-semester/tools/test$mcd.sh
是脚本,mkdirAndcd
是该脚本中的函数。与其他程序语言一样,函数可以提高代码模块性、代码复用性并创建清晰性的结构。shell 脚本中往往也会包含它们自己的函数定义。
★shell 工具
find、“up-arrow”、history、Ctrl+R、tree
★查找文件
程序员们面对的最常见的重复任务就是查找文件或目录。所有的类UNIX系统都包含一个名为
find
的工具,它是 shell 上用于查找文件的绝佳工具。find
命令会递归地搜索符合条件的文件,例如:1
2
3
4
5
6
7
8查找所有名称为src的文件夹
find . -name src -type d
查找所有文件夹路径中包含test的python文件
find . -path '*/test/*.py' -type f
查找前一天修改的所有文件
find . -mtime -1
查找所有大小在500k至10M的tar.gz文件
find . -size -500k -size -10M -name '*.tar.gz'1
2
3
4
5
6查找在当前目录(.)下的所有脚本文件
god@god-virtual-machine:~/桌面/missing-semester/tools$ find . -name '*.sh' -type f
./mcd.sh
./project1/mcd.sh
./project1/example.sh
./example.sh除了列出所寻找的文件之外,
find
还能对所有查找到的文件进行操作。这能极大地简化一些单调的任务。1
2
3
4删除全部扩展名为.tmp 的文件
find . -name '*.tmp' -exec rm {} \;
查找全部的 PNG 文件并将其转换为 JPG
find . -name '*.png' -exec convert {} {}.jpg \;find
:用来在指定目录下查找文件。任何位于参数之前的字符串都将被视为欲查找的目录名。如果使用该命令时,不设置任何参数,则find
命令将在当前目录下查找子目录与文件。并且将查找到的子目录和文件全部进行显示。语法:
find path -option [ ] [ -exec command {} \ ];
★查找内容
查找文件是很有用的技能,但是很多时候您的目标其实是查看文件的内容。一个最常见的场景是您希望查找具有某些内容的全部文件,并找它们的位置。
为了实现这一点,很多类UNIX的系统都提供了
grep
命令,它是用于对输入文本进行匹配的通用工具。它是一个非常重要的 shell 工具,我们会在后续的数据清理课程中深入的探讨它,这里我们只是简单使用grep
命令。在使用grep
命令时,当需要搜索大量文件的时候,使用-R
会递归地进入子目录并搜索所有的文本文件。1
2
3
4
5
6god@god-virtual-machine:~/桌面/missing-semester/tools$ grep -R foobar
example.sh: grep foobar "$file" > /dev/null 2> /dev/null
example.sh: echo "File $file does not have any foobar, adding one"
example.sh: echo "# foobar" >> "$file"
2.txt:# foobar
1.txt:# foobar上面的命令会递归地进入当前目录及其子目录并搜索所有匹配 “foobar” 的文本文件。
★查找shell命令
目前为止,我们已经学习了如何查找文件和代码,但随着你使用shell的时间越来越久,您可能想要找到之前输入过的某条命令。首先,按向上的方向键“up-arrow”会显示你使用过的上一条命令,继续按上键则会遍历整个历史记录。
history
命令允许您以程序员的方式来访问 shell 中输入的历史命令。这个命令会在标准输出中打印 shell 中的里面命令。如果我们要搜索历史记录,则可以利用管道将输出结果传递给grep
进行模式搜索。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18history | grep find 会打印包含 find 子串的命令
god@god-virtual-machine:~/桌面/missing-semester/tools$ history | grep find
1206 find . -name project -type d
1207 find . -name project? -type d
1208 find . -name project1 -type d
1209 find . -name project33 -type d
1211 find . -name sh -type f
1212 find . -name *.sh -type f
1213 find . -name '*.sh' -type f
......
history 5 会列出最近的 5 条命令
god@god-virtual-machine:~/桌面/missing-semester/tools$ history 5
1266 history | grep find
1267 history | tail -5
1268 history 5
1269 history | tail -5
1270 history 5history [n]
:列出最近的 n 条命令,不带 n 时,为列出全部。通过将
history
命令和grep
、tail
等命令结合,满足找具体某些命令的需求。
你可以修改 shell history 的行为,例如,如果在命令的开头加上一个空格,它就不会被加进 shell 的历史记录中。当你输入包含密码或是其他敏感信息的命令时会用到这一特性。
对于大多数的 shell 来说,您可以使用
Ctrl+R
对命令历史记录进行回溯搜索。敲Ctrl+R
后您可以输入子串来进行匹配,查找历史命令行。并且可以一直按Ctrl+R
来向前回溯,在找到了想要搜索的命令以后,按回车键即可再次执行该命令。
文件夹导航
为了能够了解目录的结构,我们可以用
tree
命令来实现。tree
:用于以树形结构列出指定目录下的所有内容,包括所有文件、子目录及子目录里的目录和文件。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
31god@god-virtual-machine:~/桌面$ tree ./missing-semester/
./missing-semester/
└── tools
├── bar
│ ├── a
│ ├── b
│ ├── c
│ ├── d
│ ├── e
│ ├── f
│ ├── g
│ ├── h
│ ├── j
│ └── y
├── example.sh
├── foo
│ ├── a
│ ├── b
│ ├── c
│ ├── d
│ ├── e
│ ├── f
│ ├── g
│ ├── h
│ ├── i
│ └── x
├── image.png
├── script.py
└── test
4 directories, 23 files