Shell工具和脚本
在本节课中,我们将介绍一些使用bash作为脚本语言的基础知识,以及一些shell工具,这些工具涵盖了你将在命令行中经常执行的几个最常见的任务。
Shell脚本
到目前为止,我们已经看到了如何在shell中执行命令并将它们通过管道连接在一起。但是,在许多场景中,你将希望执行一系列命令,并使用控制流表达式(如条件或循环)。
Shell脚本是复杂性的下一步。大多数shell都有自己的脚本语言,其中包含变量、控制流和自己的语法。shell脚本与其他脚本编程语言的不同之处在于,它针对执行与shell相关的任务进行了优化。因此,在shell脚本中,创建命令管道、将结果保存到文件以及从标准输入读取都是基本的操作,这使得shell脚本比一般用途的脚本语言更容易使用。对于本节,我们将重点关注bash脚本,因为它是最常见的。
要在bash中分配变量,请使用语法foo=bar
并使用$foo
访问变量的值。请注意,foo = bar
将不起作用,因为它被解释为使用参数=
和bar
调用foo
程序。通常,在shell脚本中,空格字符将执行参数分割。这种行为刚开始使用时可能会让人感到困惑,所以一定要检查它。
bash中的字符串可以用'
和"
分隔符定义,但它们不相等。以'
分隔的字符串是字面值字符串,不能替代变量值,而"
分隔的字符串可以。
foo=bar
echo "$foo"
# prints bar
echo '$foo'
# prints $foo
与大多数编程语言一样,bash支持控制流技术,包括if
、case
、while
和for
。类似地,bash也有接受参数的函数,可以对它们进行操作。下面是一个函数示例,它创建一个目录并使用cd
进入其中。
mcd () {
mkdir -p "$1"
cd "$1"
}
这里$1
是脚本/函数的第一个参数。与其他脚本语言不同,bash使用各种特殊变量来引用参数、错误代码和其他相关变量。以下是其中一些。更全面的列表可以在这里找到。
$0
- 脚本名称$1
到$9
- 脚本的参数。$1
是第一个参数,以此类推。$@
- 所有参数$#
- 参数的数量$?
- 上一条命令的返回状态码$$
- 当前脚本的进程标识号(PID)!!
- 最后一个命令,包括参数。一个常见的模式是只执行一个命令,因为它由于缺少权限而失败;通过!! sudo
,你可以快速地重新执行sudo命令。$_
- 最后一个命令的最后一个参数。如果你在交互式shell中,也可以通过键入Esc
后跟.
或Alt +
。
命令通常使用STDOUT
返回输出,通过STDERR
返回错误,并使用返回代码(Return Code)以更适合脚本的方式报告错误。返回代码或退出状态是脚本/命令必须用来传达执行情况的方式。值为0通常意味着一切正常;任何与0不同的值都意味着发生了错误。
退出码可用于使用&&(与运算符)和||(或运算符)有条件地执行命令,两者都是短路运算符。命令也可以用分号;
分隔在同一行中。true
程序的返回码总是0,而false命令的返回码总是1。让我们来看一些例子
false || echo "Oops, fail"
# Oops, fail
true || echo "Will not be printed"
#
true && echo "Things went well"
# Things went well
false && echo "Will not be printed"
#
true ; echo "This will always run"
# This will always run
false ; echo "This will always run"
# This will always run
另一个常见的模式是希望将命令的输出作为变量获取。这可以通过命令替换来完成。无论何时放置$(CMD)
,它都会执行CMD
,获得命令的输出并替换它。例如,如果你执行for file in $(ls)
,shell将首先调用ls
,然后迭代这些值。一个不太为人所知的类似功能是进程替换,<( CMD )
将执行CMD
并将输出放在临时文件中,并用该文件的名称替换<()。当命令希望通过文件而不是通过STDIN
传递值时,这很有用。例如,diff <(ls foo) <(ls bar)
将显示foo
和bar
目录中的文件之间的差异。
看完了上面一大堆文字,让我们来看一个展示这些特性的示例。它将遍历我们提供的参数,grep
用于搜索字符串foobar
,如果没有找到它,则将其作为注释追加到文件中。
#!/bin/bash
echo "Starting program at $(date)" # Date will be substituted
echo "Running program $0 with $# arguments with pid $$"
for file in "$@"; do
grep foobar "$file" > /dev/null 2> /dev/null
# When pattern is not found, grep has exit status 1
# We redirect STDOUT and STDERR to a null register since we do not care about them
if [[ $? -ne 0 ]]; then
# -ne:not equal
echo "File $file does not have any foobar, adding one"
echo "# foobar" >> "$file"
fi
done
在比较中,我们测试了$?
是否不等于0。Bash实现了许多此类比较-你可以在test
的手册中找到详细列表。在bash中执行比较时,尽量使用双括号[[]]
,而不是单括号[]
。虽然不能移植到sh
,但出错的几率更低。更详细的解释可以在这里找到。
在启动脚本时,你通常想要提供相似的参数。Bash有一些方法可以简化这一点,通过执行文件名展开来展开表达式。这些技术通常被称为shell globbing。
- 通配符—当您想执行某种通配符匹配时,可以使用
?
和*
分别匹配一个或任意数量的字符。例如,给定文件foo
,foo1
,foo2
,foo10
和bar
,命令rm foo?
将删除foo1
和foo2
,而rm foo*
将删除除bar以外的所有内容。 - 花括号
{}
—当你在一系列命令中有一个公共子字符串时,您可以使用bash的花括号来自动展开它。这在移动或转换文件时非常方便。
convert image.{png,jpg}
# Will expand to
convert image.png image.jpg
cp /path/to/project/{foo,bar,baz}.sh /newpath
# Will expand to
cp /path/to/project/foo.sh /path/to/project/bar.sh /path/to/project/baz.sh /newpath
# Globbing techniques can also be combined
mv *{.py,.sh} folder
# Will move all *.py and *.sh files
mkdir foo bar
# This creates files foo/a, foo/b, ... foo/h, bar/a, bar/b, ... bar/h
touch {foo,bar}/{a..h}
touch foo/x bar/y
# Show differences between files in foo and bar
diff <(ls foo) <(ls bar)
# Outputs
# < x
# ---
# > y
编写bash脚本可能很棘手,而且不直观。有一些像shellcheck这样的工具可以帮助您查找sh/bash脚本中的错误。
注意,脚本不一定要用bash编写才能从终端调用。例如,下面是一个简单的Python脚本,它以相反的顺序输出参数:
#!/usr/local/bin/python
import sys
for arg in reversed(sys.argv[1:]):
print(arg)
内核知道使用python解释器而不是shell命令来执行这个脚本,因为我们在脚本的顶部包含了shebang行。使用env命令编写shebang行是一个很好的实践,它将解析到命令在系统中的任何位置,从而提高脚本的可移植性。为了解析位置,env
将使用我们在第一讲中介绍的PATH环境变量。对于本例,shebang行是 #!/usr/bin/env python
这样。
shell函数和脚本之间的一些区别你应该记住:
- 函数必须使用与shell相同的语言,而脚本可以使用任何语言编写。这就是为什么要在脚本中引入shebang很重要的原因。
- 函数的定义被读取时加载一次。每次执行脚本时都会加载它们。这使得函数的加载速度稍微快一些,但无论何时更改它们,都必须重新加载它们的定义。
- 函数在当前shell环境中执行,而脚本在自己的进程中执行。因此,函数可以修改环境变量,例如更改当前目录,而脚本不能。脚本将通过使用export导出的环境变量值传递
- 与任何编程语言一样,函数是实现模块化、代码重用和sell代码清晰性的强大构造。通常shell脚本会包含它们自己的函数定义。
shell工具
查找如何使用命令
此时,你可能想知道如何在别名部分找到命令的标志,如ls -l
、mv -i
和mkdir -p
。更一般地说,对于给定的命令,如何找出它能做什么以及它的不同选项?你总是可以开始谷歌,但由于UNIX早于StackOverflow,所以有一些内建的方法来获取这些信息。
正如我们在shell课程中看到的,首要的方法是使用-h
或--help
标志调用所述命令。更详细的方法是使用man
命令。man是manual的缩写,它为你指定的命令提供了一个手册页(称为manpage)。例如,man rm
将输出rm
命令的行为以及它所接受的标志,包括我们前面展示的-i
标志。事实上,到目前为止,我所链接的每个命令都是Linux命令手册的在线版本。即使您安装的非本机命令也会有manpage条目,如果开发人员编写了它们并将它们包含在安装过程中。对于交互式工具,比如基于ncurses的工具,命令的帮助通常可以在程序中使用:help
命令或输入?
来访问。
有时候,manpage可能会提供过分详细的命令描述,这使得很难解释在常见的用例中使用什么标志/语法。TLDR页面是一种很好的补充解决方案,它主要提供命令的示例用例,以便您可以快速确定要使用哪些选项。例如,我发现自己在tldr页面中查阅tar
和ffmpeg
的次数要比查阅manpage的次数多得多。
查找文件和文件夹
每个程序员面临的最常见的重复性任务之一是查找文件或目录。所有类unix系统都附带了find,这是一个用于查找文件的很好的shell工具。find
将递归搜索匹配某些条件的文件。一些例子:
# Find all directories named src
find . -name src -type d
# Find all python files that have a folder named test in their path
find . -path '*/test/*.py' -type f
# Find all files modified in the last day
find . -mtime -1
# Find all zip files with size in range 500k to 10M
find . -size +500k -size -10M -name '*.tar.gz'
除了列出文件之外,find还可以对匹配查询的文件执行操作。这个特性对于简化相当单调的任务非常有帮助。
# Delete all files with .tmp extension
find . -name '*.tmp' -exec rm {} \;
# Find all PNG files and convert them to JPG
find . -name '*.png' -exec convert {} {}.jpg \;
尽管find
无处不在,但它的语法有时很难记住。例如,要简单地找到匹配某种模式PATTERN
的文件,你必须执行find -name '* pattern *'
(或者-iname
,如果你想让模式匹配不区分大小写)。您可以开始为这些场景构建别名,但shell哲学的一部分是探索替代方案。请记住,shell最好的特性之一是您只是调用程序,因此您可以找到(甚至自己编写)一些程序的替代品。例如,fd
是一个简单、快速和用户友好的替代find
的选项。它提供了一些不错的默认值,比如彩色输出、默认regex匹配和Unicode支持。在我看来,它的语法也更直观。例如,查找模式PATTERN
的语法是fd PATTERN
。
大多数人认为find和fd都很好,但是有些人可能想知道每次查找文件的效率与编译某种索引或数据库进行快速搜索的效率的比较。这就是locate
的作用。locate
使用使用updatedb
更新的数据库。在大多数系统中,updatedb
每天通过cron
更新。因此,两者之间的一个权衡就是速度与新鲜度。此外,find和类似的工具还可以使用文件大小、修改时间或文件权限等属性来查找文件,而locate
只使用文件名。一个更深入的比较可以在这里找到。
查找代码
通过名称查找文件很有用,但通常需要基于文件内容进行搜索。一个常见的场景是,想要搜索所有包含某种模式的文件,以及这些文件中出现该模式的位置。为了实现这一点,大多数类unix系统都提供grep
,这是一个用于匹配输入文本中的模式的通用工具。grep
是一个非常有价值的shell工具,我们将在数据 wrangling一节中更详细地介绍它。
现在,要知道grep
有许多标志,这使它成为一个非常通用的工具。我经常使用的是-C
用于获取匹配行周围的上下文,-v
用于反转匹配,即打印所有不匹配的行。例如,grep -C 5
将在匹配前后分别打印5行。当涉及到快速搜索许多文件时,你希望使用-R
,因为它将递归地进入目录并在文件中查找匹配的字符串。
但是grep -R
可以通过多种方式进行改进,比如忽略.git
文件夹、使用多CPU支持等等。许多grep
替代品已经被开发出来,包括ack
、ag
和rg
。它们都非常棒,几乎提供了相同的功能。现在我还是坚持使用ripgrep (rg
),因为它速度快、直观。一些例子:
# Find all python files where I used the requests library
rg -t py 'import requests'
# Find all files (including hidden files) without a shebang line
rg -u --files-without-match "^#!"
# Find all matches of foo and print the following 5 lines
rg foo -A 5
# Print statistics of matches (# of matched lines and files )
rg --stats PATTERN
注意,与find/fd
一样,重要的是要知道这些问题可以使用这些工具之一快速解决,而你使用的特定工具则不那么重要。
查找shell命令
到目前为止,我们已经看到了如何查找文件和代码,但是当您开始在shell中花费更多的时间时,您可能需要查找您键入的特定命令。首先要知道的是,键入向上箭头将返回最后一个命令,如果一直按下去,就会慢慢地查看shell历史记录。
history
命令将允许你以编程方式访问shell历史记录。它将把shell历史记录打印到标准输出。如果我们想在那里搜索,我们可以将输出输送到grep
并搜索模式。history | grep find
将打印包含子字符串“find”的命令。
另一个我很喜欢的与历史相关的技巧是基于历史的自动建议。该特性首先由fish shell引入,它使用与当前shell命令共享一个公共前缀的最新命令动态地自动完成当前shell命令。它可以在zsh中启用,对于你的shell来说,这是一个提高生活质量的好方法。
你可以修改shell的历史行为,比如防止包含前导空格的命令。当你输入带有密码或其他敏感信息的命令时,这很方便。为此,添加HISTCONTROL=ignorespace
到你的.bashrc
或setopt HIST_IGNORE_SPACE
到你的.zshrc
。如果您犯了没有添加前导空格的错误,您总是可以通过编辑.bash_history
或.zhistory
手动删除条目。
目录导航
到目前为止,我们假设你已经到达了执行这些操作所需的位置。但是如何快速浏览目录呢?有许多简单的方法可以做到这一点,例如编写shell别名或创建带有ln -s
的符号链接,但事实上,到目前为止,开发人员已经找到了相当聪明和复杂的解决方案。
与本课程的主题一样,您通常希望针对常见情况进行优化。查找频繁和/或最近的文件和目录可以通过快速和自动跳转等工具完成。fasd
按频率对文件和目录进行排序,即按频率和最近次数进行排序。默认情况下,fasd
添加了一个z
命令,可以使用frecent目录的子字符串快速cd
。例如,如果你经常访问/home/user/files/cool_project
,你可以简单地使用z cool
跳转到那里。使用autojump
,可以使用j cool
完成相同的目录更改。
有更复杂的工具可以快速地了解目录结构:tree
、broot
甚至是完全成熟的文件管理器,如nnn
或ranger
。
练习
- 阅读
man ls
,按如下方式写ls
命令列出文件:
- 包括所有文件,包括隐藏文件
- 尺寸以人类可读的格式列出(例如:454M而不是454279954)
- 文件按最近的时间排序
- 输出是彩色的
输出的示例如下所示
-rw-r--r-- 1 user group 1.1M Jan 14 09:53 baz
drwxr-xr-x 5 user group 160 Jan 14 09:53 .
-rw-r--r-- 1 user group 514 Jan 14 06:42 bar
-rw-r--r-- 1 user group 106M Jan 13 12:12 foo
drwx------+ 47 user group 1.5K Jan 12 18:08 ..
hmt@missing:/$ ls -l -a -h -t --color=always
- 编写执行以下操作的bash函数
marco
和polo
。每当你执行marco
时,当前的工作目录应该以某种方式保存,然后当你执行polo
时,无论你在什么目录,polo
应该cd
你回到你执行marco
的目录。为了便于调试,可以将代码编写在一个文件marco.sh
中,并通过执行source marco.sh
(重新)将定义加载到shell中。
macro.sh
#!/usr/bin/env bash
find dir.txt > /dev/null 2> /dev/null
if [[ $? -ne 0 ]]; then
# dir.txt用于存放当前路径。如果dir.txt不存在,先创建
touch dir.txt
fi
(pwd) > dir.txt
polo
polo() {
a=$(cat < /tmp/dir.txt)
cd $a
}
执行polo
函数:
hmt@missing:~$ source polo
hmt@missing:~$ polo
- 假设你有一个很少出错的命令。为了调试它,你需要捕获它的输出,但是运行一个失败可能会很耗时。编写一个bash脚本,运行以下脚本直到失败,并将其标准输出和错误流捕获到文件中,并在最后打印所有内容。如果你还能报告脚本失败的运行次数,那就更好了。
#!/usr/bin/env bash
n=$(( RANDOM % 100 ))
if [[ n -eq 42 ]]; then
echo "Something went wrong"
>&2 echo "The error was using magic numbers"
exit 1
fi
echo "Everything went according to plan"
#!/usr/bin/env bash
n=1
while true; do
./polo.sh $> tmp
if [[ "$?" -ne 0 ]]; then
break
fi
n=$[n+1]
done
cat tmp
echo "total run times: $n"
-
正如我们在课程中谈到的,
find
的-exec
对于对我们正在搜索的文件执行操作非常强大。但是,如果我们想对所有文件做一些操作,比如创建一个zip文件,该怎么办?正如你目前所看到的,命令将接受参数和STDIN的输入。当管道化命令时,我们将STDOUT连接到STDIN,但有些命令(如tar
)从参数中获取输入。有一个xargs
命令,它将使用STDIN作为参数执行一个命令。例如ls | xargs rm
将删除当前目录下的文件。你的任务是编写一个命令,递归地查找文件夹中的所有HTML文件,并用它们创建一个zip文件。注意,即使文件中有空格,您的命令也应该能够工作(提示:查看
xargs
的-d
标志)。find . -maxdepth 1 -name "*.html" -print0 | xargs -0 zip tmp.zip -@
-
(高级)编写命令或脚本递归地查找目录中最近修改的文件。更一般地说,你能按最近的时间列出所有文件吗?
find -type f -print0| xargs -0 ls -l -t