Bash Shell Scripts

我们之前学习了如何在shell中运行单行命令。现在我们将学习如何将多个命令组合成一个脚本,以便重复使用和自动化任务。

在bash中为变量赋值的语法是 VAR_NAME=value,注意等号两边不能有空格。要引用变量的值,可以使用 $VAR_NAME${VAR_NAME}
bash中的字符串通过'"分隔定义,但是它们的含义不尽相同。单引号定义的字符串为原义字符串,变量不会被展开;双引号定义的字符串允许变量展开。
例如:

1
2
3
foo=bar
echo "$foo" # 输出 bar
echo '$foo' # 输出 $foo

与其他编程语言类似,bash也支持if,case,for,while等控制结构。同样,bash也支持函数,它可以接收参数并返回值。

1
2
3
4
mcd () {
mkdir -p "$1"
cd "$1"
}

这里定义了一个名为mcd的函数,它接受一个参数(目录名),创建该目录并切换到该目录。$1表示传递给函数的第一个参数。与其他编程语言不同的是,bash使用了很多特殊的变量来表示参数、错误代码和相关变量。下面列举了其中一些:

  • $0:脚本的名称
  • $1 ~ $9:传递给脚本或函数的第1到第9个参数
  • $#:传递给脚本或函数的参数个数
  • $@:传递给脚本或函数的所有参数,作为一个列表
  • $?:上一个命令的退出状态码,0表示成功,非0表示失败
  • $$:当前脚本的进程ID
  • $!:上一个后台命令的进程ID
  • $-:当前shell的选项标志

这里有关于这项的更多信息。

命令行通常使用STDOUT(标准输出)和STDERR(标准错误)来输出信息。默认情况下,STDOUT显示在终端上,而STDERR也显示在终端上,但它们是分开的。你可以使用重定向符号将它们重定向到文件或其他命令。返回值0表示命令成功执行,非0表示失败。你可以使用$?变量来检查上一个命令的返回值。

1
2
3
4
some_command
if [ $? -ne 0 ]; then
echo "some_command failed"
fi

退出码可以跟&&||一起使用,以实现简洁的错误处理。同一行的多个命令采用;分隔。程序true总是返回0,而false总是返回1。

1
2
3
4
5
false || echo "previous command failed"
true && echo "previous command succeeded"
false && echo "this will not be printed"
true || echo "this will not be printed"
false ; echo "this will be printed"

如果希望以变量形式获取一个命令的输出,可以通过命令替换的方式实现。具体来讲,就是利用$(CMD)的方式,将CMD的执行结果替换掉$(CMD)。例如:

1
2
current_date=$(date)
echo "Current date is: $current_date"

另外,你还可以采用进程替换的方式,它会将一个命令的输出作为一个临时文件传递给另一个命令。具体来讲,就是利用<(CMD)的方式,将CMD的输出作为一个文件传递给另一个命令。例如:

1
diff <(sort file1.txt) <(sort file2.txt)

这里,sort file1.txtsort file2.txt的输出会被传递给diff命令进行比较,而不需要创建临时文件。

下面是一个bash脚本的完整示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/bash

echo "Starting program at $(date)"

echo "Running program $0 with $# arguments with pid $$"

for file in "$@"; do
grep foobar "$file" > /dev/null 2> /dev/null
#将grep的输出和错误码重定向到/dev/null,避免在终端显示
if [[ $? -ne 0 ]]; then
echo "File $file does not have any foobar, adding one"
echo "# foobar" >> "$file"
fi
done

这个脚本首先打印当前日期和时间,然后打印脚本名称、参数个数和进程ID。接着,它遍历传递给脚本的每个文件,检查文件中是否包含字符串”foobar”。如果不包含,则在文件末尾添加一行”# foobar”。

在使用脚本时,我们通常需要提供形式类似的参数,而通配技术可以帮我们做到这一点。

  • 通配符:*表示任意数量的任意字符,?表示单个任意字符。例如,*.txt匹配所有以.txt结尾的文件。
  • 字符类:[abc]表示匹配字符a、b或c中的任意一个,[a-z]表示匹配小写字母a到z中的任意一个。
  • 大括号扩展:{a,b,c}表示匹配a、b或c中的任意一个。例如,file{1,2,3}.txt匹配file1.txtfile2.txtfile3.txt

通过结合使用这些通配技术,我们可以方便地指定一组文件或目录,从而简化脚本的编写和使用。

1
2
3
4
5
6
7
8
9
10
11
12
convert image.{png,jpg}
# 这将匹配image.png和image.jpg
cp /path/to/project/{foo,bar,baz}.sh /newpath/
# 这将匹配foo.sh, bar.sh和baz.sh
mv *{.py,.sh} folder
# 这将匹配所有以.py或.sh结尾的文件
mkdir foo bar
touch {foo,bar}/{a..h}
# 这将在foo和bar目录下创建a到h的文件
touch foo/x bar/y
diff <(ls foo) <(ls bar)
# 比较foo和bar目录下的文件列表

要运行bash脚本,首先需要确保脚本文件具有可执行权限。可以使用chmod +x script.sh命令为脚本添加可执行权限。然后,可以通过./script.sh命令运行脚本。

1
2
chmod +x my_script.sh
./my_script.sh

编写bash脚本的时候,使用类似shellcheck的工具可以帮助你发现潜在的问题和错误。它会检查脚本中的语法错误、不推荐的用法以及可能导致错误的代码模式。
脚本不一定必须用bash写才能在终端里调用,你也可以用python、perl等其他语言编写脚本,只要在脚本的第一行指定解释器即可。例如:

1
2
3
4
#!/usr/bin/env python3
print("Hello, World!")
#!/usr/bin/env perl
print "Hello, World!\n";

内核从何得知使用python和perl来解释这些脚本呢?这是因为在脚本的第一行使用了#!shebang)来指定解释器路径。/usr/bin/env命令会在系统的环境变量中查找指定的解释器,并使用它来运行脚本。

shell工具

给出一个命令行,您应该怎样了解如何使用这个命令行并找出它的不同选项呢?最常用的方法是查看--help,man等方式。有时候mannual可能太过详实,你也可以使用tldr来获取简洁的命令行帮助。

查找文件

在Linux系统中,有许多命令行工具可以帮助我们查找文件。以下是一些常用的文件查找工具:

  • find:这是一个功能强大的命令行工具,可以根据文件名、类型、大小、修改时间等多种条件查找文件。例如,find /path/to/search -name "*.txt"会在指定路径下查找所有以.txt结尾的文件。
  • fd:这是一个现代化的文件查找工具,具有更快的速度和更简洁的语法。它默认忽略隐藏文件和.gitignore中列出的文件。例如,fd .txt /path/to/search会在指定路径下查找所有以.txt结尾的文件。
  • locate:这是一个基于预先构建的数据库的文件查找工具,速度非常快。使用前需要运行updatedb命令来更新数据库。例如,locate filename会在数据库中查找包含filename的文件路径。

查找代码

grep命令是一个强大的文本搜索工具,可以在文件中查找包含特定字符串的行。它支持正则表达式,可以进行复杂的模式匹配。例如,grep "search_string" file.txt会在file.txt中查找包含search_string的行。
rg(ripgrep)是一个现代化的代码搜索工具,具有更快的速度和更简洁的语法。它默认忽略隐藏文件和.gitignore中列出的文件。例如,rg "search_string" /path/to/search会在指定路径下查找包含search_string的行。
例如:

1
2
3
4
rg -t py 'import requests'
rg -u --files-without-match "#\!"
rg foo -A 5
rg --stats PATTERN

查找shell命令

有时候我们可能忘记了某个命令的名称,或者想找到一个可以完成特定任务的命令。以下是一些常用的工具,可以帮助我们查找shell命令:

  • history: 这个命令可以显示我们之前执行过的命令历史记录。我们可以使用history | grep "search_string"来查找包含特定字符串的命令。
  • Ctrl+R: 这是一个快捷键,可以在命令行中启动反向搜索,帮助我们快速找到之前执行过的命令。
  • fzf:这是一个通用的模糊查找工具,可与很多命令一起使用。这里我们可以对历史命令进行模糊查找并将结果以赏心悦目的格式输出。

文件夹导航

如何才能高效地在目录间切换呢?

  • fasd: 这是一个命令行工具,可以帮助我们快速跳转到之前访问过的目录。它会根据访问频率和最近访问时间来排序目录。例如,fasd -d partial_name会跳转到与partial_name匹配的目录中最常用或最近访问的目录。
  • autojump: 这是另一个命令行工具,可以帮助我们快速跳转到之前访问过的目录。它会根据访问频率来排序目录。例如,j partial_name会跳转到与partial_name匹配的目录中最常用的目录。
  • z: 这是一个轻量级的命令行工具,可以帮助我们快速跳转到之前访问过的目录。它会根据访问频率和最近访问时间来排序目录。例如,z partial_name会跳转到与partial_name匹配的目录中最常用或最近访问的目录。

习题

Q1

阅读 man ls ,然后使用 ls 命令进行如下操作:

  • 所有文件(包括隐藏文件):-a
  • 文件打印以人类可以理解的格式输出 (例如,使用 454M 而不是 454279954): -h
  • 文件以最近访问顺序排序:-t
  • 以彩色文本显示输出结果 –color=auto
    典型输出如下:
1
2
3
4
5
-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 ..

Q2

编写两个 bash 函数 marco 和 polo 执行下面的操作。 每当你执行 marco 时,当前的工作目录应当以某种形式保存,当执行 polo 时,无论现在处在什么目录下,都应当 cd 回到当时执行 marco 的目录。 为了方便 debug,你可以把代码写在单独的文件 marco.sh 中,并通过 source marco.sh 命令,(重新)加载函数。通过 source 来加载函数,随后可以在 bash 中直接使用。

Q3

假设您有一个命令,它很少出错。因此为了在出错时能够对其进行调试,需要花费大量的时间重现错误并捕获输出。 编写一段 bash 脚本,运行如下的脚本直到它出错,将它的标准输出和标准错误流记录到文件,并在最后输出所有内容。 加分项:报告脚本在失败前共运行了多少次。

1
2
3
4
5
6
7
8
9
10
11
#!/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"

Q4

本节课我们讲解的 find 命令中的 -exec 参数非常强大,它可以对我们查找的文件进行操作。 如果我们要对所有文件进行操作呢?例如创建一个 zip 压缩文件?我们已经知道,命令行可以从参数或标准输入接受输入。在用管道连接命令时,我们将标准输出和标准输入连接起来,但是有些命令,例如 tar 则需要从参数接受输入。这里我们可以使用 xargs 命令,它可以使用标准输入中的内容作为参数。 例如 ls | xargs rm 会删除当前目录中的所有文件。您的任务是编写一个命令,它可以递归地查找文件夹中所有的 HTML 文件,并将它们压缩成 zip 文件。注意,即使文件名中包含空格,您的命令也应该能够正确执行(提示:查看 xargs 的参数-d)

Q5

编写一个命令或脚本递归的查找文件夹中最近使用的文件。更通用的做法,你可以按照最近的使用时间列出文件吗?