Lecture 2 Tools and Scripting

课程网址:https://missing-semester-cn.github.io/2020/shell-tools/

到目前为止,我们已经学习来如何在shell中执行命令,并使用管道将命令组合使用。但是,很多情况下我们需要执行一系列的操作并使用条件或循环这样的控制流。

shell脚本是一种更加复杂度的工具。

大多数shell都有自己的一套脚本语言,包括变量、控制流和自己的语法。shell脚本与其他脚本语言不同之处在于,shell脚本针对shell所从事的相关工作进行来优化。因此,创建命令流程(pipelines)、将结果保存到文件、从标准输入中读取输入,这些都是shell脚本中的原生操作,这让它比通用的脚本语言更易用。本节中,我们会专注于bash脚本,因为它最流行,应用更为广泛。

内容概览

包括关于shell的两个部分:

  • Shell脚本(shell scripting)的编写,比如bash(多数mac or Linux系统中默认的shell,可以通过zsh等其他shell向后兼容)

  • Convenient shell tools(avoid doing repetitive tasks)

1. Shell 脚本

1.1 基础操作

bash中变量赋值语法:foo=bar
注意不要有空格,否则解释器会把 =bar当成两个参数,在bash中空格有分割参数的作用

bash中访问变量:echo $foo

bash中字符串定义:

​ double quotes: “ ” 转义字符串;single quotes: ‘’ 原义字符串

bash支持的循环和函数操作: if while for loop

bash脚本中的一些变量:

  • $0 - 脚本名
  • $1$9 - 脚本的参数。 $1 是第一个参数,依此类推。
  • $@ - 所有参数
  • $# - 参数个数
  • $? - 前一个命令的返回值
  • $$ - 当前脚本的进程识别码
  • !! - 完整的上一条命令,包括参数。常见应用:当你因为权限不足执行命令失败时,可以使用 sudo !!再尝试一次。
  • $_ - 上一条命令的最后一个参数。如果你正在使用的是交互式shell,你可以通过按下 Esc 之后键入 . 来获取这个值。

1.2 Vim

https://coolshell.cn/articles/5426.html

Vim可以用来写一个函数function,比如vim mcd.sh(这个函数的功能是:创建一个目录 $1 ,并且cd进去。)

source mcd.sh

用于找到此脚本的来源,并在shell中执行(execute)这个脚本然后加载(load)它。这样mcd函数已经在shell中定义,并且可以被调用。

命令的输出和返回码:

命令通常使用 STDOUT来返回输出值,使用STDERR 来返回错误及错误码,便于脚本以更加友好的方式报告错误。 返回码或退出状态是脚本/命令之间交流执行状态的方式。返回值0表示正常执行,其他所有非0的返回值都表示有错误发生。

退出码可以搭配&& (与操作符) 和 || (或操作符)使用,用来进行条件判断,决定是否执行其他程序。它们都属于短路运算符(short-circuiting)。

同一行的多个命令可以用 ; 分隔。 程序 true 的返回码永远是0,false 的返回码永远是1。

举例:

使用变量接收命令的输出:

以变量的形式获取一个命令的输出,这可以通过 命令替换 (command substitution)实现——$()

当您通过 $( CMD ) 这样的方式来执行CMD 这个命令时,它的输出结果会替换掉 $( CMD ) 。例如,如果执行 for file in $(ls) ,shell首先将调用ls ,然后遍历得到的这些返回值。

举例:

进程替换:

还有一个冷门的类似特性是 进程替换(process substitution), <( CMD ) 会执行 CMD 并将结果输出到一个临时文件中,并将 <( CMD ) 替换成临时文件名。这在我们希望返回值通过文件而不是STDIN传递时很有用。例如, diff <(ls foo) <(ls bar) 会显示文件夹 foo 和 bar 中文件的区别。

举例:

这里先ls当前目录并将其放入一个临时文件夹,然后对父目录做同样的事情,最后将他们串联起来。

注意:
\$0 将替换为脚本名,$# 将被替换为参数的个数,$$将为当前脚本的进程识别码,$@ 所有参数,$?为前一个命令的返回码。
grep foobar “$file” > /dev/null 2> /dev/null 对于这条命令的返回值和错误码是多少我们并不关注,我们只关注错误码是否为0。故需要重定向两个参数,即STDOUT和STDERR。(2<代表重定向第二个参数的输出)
比较运算符:-ne 表示不等于

在条件语句中,我们比较 $? 是否等于0。 Bash实现了许多类似的比较操作,您可以查看 test 手册(man test)。 在bash中进行比较时,尽量使用双方括号 [[ ]] 而不是单方括号 [ ],这样会降低犯错的几率,尽管这样并不能兼容 sh。

运行结果:

注意:

  • 运行脚本:./脚本名
  • 即使脚本只需要一个参数,可以连续加上多个参数以直接多次运行。

shell的通配(globbing):

1. 通配符

当你想要利用通配符进行匹配时,你可以分别使用 ? 和 * 来匹配一个或任意个字符。例如,对于文件foo, foo1, foo2, foo10 和 bar, rm foo?这条命令会删除foo1 和 foo2 ,而rm foo* 则会删除除了bar之外的所有文件。

举例:
ls *.sh
ls project? [project1, project2, project3]

注意一下 * 和 ? 的不同。

2. 花括号

当你有一系列的指令,其中包含一段公共子串时,可以用花括号来自动展开这些命令。这在批量移动或转换文件时非常方便。

1
2
3
convert image.png image.jpg # 将png文件转化为jpg格式
# 可以这样写:
convert image.{png,jpg}

一些例子:

1
2
3
4
5
6
7
8
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
# 注意这里相当于是将project目录下的三个后缀为.sh的文件copy到/newpath

# 也可以结合通配使用
mv *{.py,.sh} folder
# 会移动所有 \*.py 和 \*.sh 文件
  • cp结合花括号使用:
  • touch结合花括号使用:
1
2
3
4
5
#1
# 在两个文件夹下批量创建相同的文件
mkdir foo bar
# 下面命令会创建foo/a, foo/b, ... foo/h, bar/a, bar/b, ... bar/h这些文件
touch {foo,bar}/{a..h}

输出:

1
2
3
4
#2
# 比较文件夹 foo 和 bar 中包含文件的不同
touch foo/x bar/y
diff <(ls foo) <(ls bar)

输出:

另外,如果一个命令中包含两个花括号,那么它将扩展 n*m 次。

1.3 shell脚本的编写

1.3.1 Magic Line: shebang #!/usr/local/bin/python

仅仅是完成一个逆序输出输入的脚本:

1
2
3
4
#!/usr/local/bin/python
import sys
for arg in reversed(sys.argv[1:]):
print(arg)

但是这里是以python3的解释器来运行的,我们更加希望shell能够运行它。而shell知道应该使用python的解释器来运行这个程序正是因为第一行。

shebang 行中使用的 env 命令会利用环境变量中的程序来解析该脚本,这样就提高脚本的可移植性。env 会利用PATH 环境变量来进行定位。 例如,使用了env的shebang看上去是这样的 #!/usr/bin/env python

补充:

但是上例中,运行python脚本的时候,即使加了shebang行,也不能直接运行(好吧,好像可以直接运行了)。运行python脚本有三种方法:

  • 方法1:python3 文件名 参数 (eg. python3 script.py 1 2 3 4)

  • 方法2:加上shebang行:#!/usr/bin/python3#!/usr/bin/env python3

        运行时:直接 `script.py 1 2 3 4` (env - run a program in a modified environment)
    

    ​ 推荐 #!/usr/bin/env python3,因为通过/usr/bin/env 运行程序,用户不需要去寻找程序在系统中的位置(因为在不同的系统,命令或程序存放的位置可能不同,不一定一定在/usr/bin里,比如可能在/usr/local/bin中),只要程序在你的$PATH中就可以

    ​ 通过/usr/bin/env 运行程序另一个好处是,它会根据你的环境寻找并运行默认的版本,提供灵活性。

    ​ 不好的地方是,有可能在一个多用户的系统中,别人在你的$PATH中放置了一个bash,可能出现错误。

    ​ 大部分情况下,/usr/bin/env是优先选择的,因为它提供了灵活性,特别是你想在不同的版本下运行这个脚本;而指定具体位置的方式#! /usr/bin/bash,在某些情况下更安全,因为它限制了代码注入的可能。

  • 方法3: chmod +x 文件名 (eg. chmod +x script.py)

            运行脚本:`文件名 参数`         (eg. script.py 1 2 3 4)
    

    关于 chmod:

1.3.2 Shellcheck可以帮助debug

shell函数和脚本有如下一些不同点:

  • 函数只能用与shell使用相同的语言,脚本可以使用任意语言。因此在脚本中包含 shebang 是很重要的。
  • 函数仅在定义时被加载,脚本会在每次被执行时加载。这让函数的加载比脚本略快一些,但每次修改函数定义,都要重新加载一次。
  • 函数会在当前的shell环境中执行,脚本会在单独的进程中执行。因此,函数可以对环境变量进行更改,比如改变当前工作目录,脚本则不行。脚本需要使用 export 将环境变量导出,并将值传递给环境变量。
  • 与其他程序语言一样,函数可以提高代码模块性、代码复用性并创建清晰性的结构。shell脚本中往往也会包含它们自己的函数定义。

2. Shell tools

2.1 查看命令的使用

使用 man command,或者:简介工具tldr

2.2 查找文件

2.2.1 find 命令

程序员们面对的最常见的重复任务就是查找文件或目录。所有的类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'

除了列出所寻找的文件之外,find还能对所有查找到的文件进行操作。这能极大地简化一些单调的任务。

1
2
3
4
# 删除全部扩展名为.tmp 的文件
find . -name '*.tmp' -exec rm {} \;
# 查找全部的 PNG 文件并将其转换为 JPG
find . -name '*.png' -exec convert {} {}.jpg \;
  • 按照后缀查找文件:
  • 查找同时进行操作:

2.2.2 fd 命令

尽管 find 用途广泛,它的语法却比较难以记忆。例如,为了查找满足模式 PATTERN 的文件,您需要执行 find -name '*PATTERN*' (如果您希望模式匹配时是不区分大小写,可以使用-iname选项)

您当然可以使用alias设置别名来简化上述操作,但shell的哲学之一便是寻找(更好用的)替代方案。 记住,shell最好的特性就是您只是在调用程序,因此您只要找到合适的替代程序即可(甚至自己编写)。

例如, fd 就是一个更简单、更快速、更友好的程序,它可以用来作为find的替代品。它有很多不错的默认设置,例如输出着色、默认支持正则匹配、支持unicode并且我认为它的语法更符合直觉。以模式PATTERN 搜索的语法是 fd PATTERN

  • 关于locate:

大多数人都认为 findfd 已经很好用了,但是有的人可能想知道,我们是不是可以有更高效的方法,例如不要每次都搜索文件而是通过编译索引或建立数据库的方式来实现更加快速地搜索。

这就要靠 locate 了。 locate 使用一个由 updatedb负责更新的数据库,在大多数系统中 updatedb 都会通过 cron每日更新。这便需要我们在速度和时效性之间作出权衡。而且,find 和类似的工具可以通过别的属性比如文件大小、修改时间或是权限来查找文件,locate则只能通过文件名。 here有一个更详细的对比。

2.3 查找文件内容(代码)

2.3.1 grep 命令

为了实现这一点,很多类UNIX的系统都提供了grep命令,它是用于对输入文本进行匹配的通用工具。它是一个非常重要的shell工具,我们会在后续的数据清理课程中深入的探讨它。

grep 有很多选项,这也使它成为一个非常全能的工具。

  • -C :获取查找结果的上下文(Context);

  • -v :将对结果进行反选(Invert),也就是输出不匹配的结果。举例来说, grep -C 5 会输出匹配结果前后五行。

  • -R :会递归地进入子目录并搜索所有的文本文件,——当需要搜索大量文件的时候使用。

grep替代品之一:rg(较为有效)

1
2
3
4
5
# 查找所有使用了 requests 库的文件
rg -t py 'import requests'# 查找所有没有写 shebang 的文件(包含隐藏文件)
rg -u --files-without-match "^#!"# 查找所有的foo字符串,并打印其之后的5行
rg foo -A 5# 打印匹配的统计信息(匹配的行和文件的数量)
rg --stats PATTERN

2.4 查找shell命令

2.4.1 history命令

history 命令允许您以程序员的方式来访问shell中输入的历史命令。这个命令会在标准输出中打印shell中的里面命令。如果我们要搜索历史记录,则可以利用管道将输出结果传递给 grep 进行模式搜索。 history | grep find 会打印包含find子串的命令。

1
2
3
history | grep find
从头开始:
history 1 | grep find

2.4.2 Ctrl+R

对于大多数的shell来说,您可以使用 Ctrl+R 对命令历史记录进行回溯搜索。敲 Ctrl+R 后您可以输入子串来进行匹配,查找历史命令行。

反复按下就会在所有搜索结果中循环。在 zsh中,使用方向键上或下也可以完成这项工作。

2.4.3 fzf 工具

Ctrl+R 可以配合 fzf 使用。fzf 是一个通用对模糊查找工具,它可以和很多命令一起使用。这里我们可以对历史命令进行模糊查找并将结果以赏心悦目的格式输出。

另外一个和历史命令相关的技巧我喜欢称之为基于历史的自动补全。 这一特性最初是由 fish shell 创建的,它可以根据您最近使用过的开头相同的命令,动态地对当前对shell命令进行补全。这一功能在 zsh 中也可以使用,它可以极大的提高用户体验。

还可以支持交互式查找(还有默认的fzf)

1
2
3
4
5
6
7
8
9
10
# 其他一些工具
ls -R #(递归列出目录结构)
tree #(树形目录结构)
broot
nnn
auto jump
sudo apt install ripgrep
# 关于grep
shebang /r/n
grep -C -v