Shell 脚本

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

shell 脚本的复杂性进一步提高。

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

在bash中为变量赋值的语法是foo=bar,访问变量中存储的数值,其语法为 $foo。 需要注意的是,foo = bar (使用空格隔开)是不能正确工作的,因为解释器会调用程序foo 并将 =bar作为参数。 总的来说,在shell脚本中使用空格会起到分割参数的作用,有时候可能会造成混淆,请务必多加检查。

Bash中的字符串通过'"分隔符来定义,但是它们的含义并不相同。以'定义的字符串为原义字符串,其中的变量不会被转义,而 "定义的字符串会将变量值进行替换。

1
2
3
4
5
foo=bar
echo "$foo"
# 打印 bar
echo '$foo'
# 打印 $foo

和其他大多数的编程语言一样,bash也支持if, case, whilefor 这些控制流关键字。同样地, bash 也支持函数,它可以接受参数并基于参数进行操作。下面这个函数是一个例子,它会创建一个文件夹并使用cd进入该文件夹。

1
2
3
4
mcd () {
mkdir -p "$1"
cd "$1"
} //-p选项表示递归创建目录,即如果上级目录不存在会一并创建

这里 $1 是脚本接收到的第一个参数。与其他脚本语言不同的是,bash使用了很多特殊的变量来表示参数、错误代码和相关变量。

步骤:

  1. 将这段代码保存到一个文件中,例如mcd_function.sh
  2. 使用source命令(或者.命令)来加载这个文件,以便将函数定义加载到当前的Shell环境中。假设文件名为mcd_function.sh,执行以下命令:
1
source mcd_function.sh

或者

1
. mcd_function.sh
  1. 现在您可以直接在命令行中调用mcd函数,并传递一个参数作为目录名。例如:
1
mcd test_directory

这将创建一个名为test_directory的目录,并将当前工作目录切换到这个新创建的目录中。

下面列举了其中一些变量,更完整的列表可以参考 这里

  • $0 - 脚本名(举个例子,如果你有一个名为myscript.sh的bash脚本,里面包含以下内容:

    1
    2
    3
    #!/bin/bash

    echo "The name of this script is: $0"

    当你在终端中执行./myscript.sh时,”$0”会被替换为myscript.sh,并输出”The name of this script is: myscript.sh“。

  • $1$9 - 脚本的参数。 $1 是第一个参数,依此类推。

  • $@ - 所有参数

  • $# - 参数个数

  • $? - 前一个命令的返回值

  • $$ - 当前脚本的进程识别码(PID(进程ID)在操作系统中扮演着重要的角色,它是用来唯一标识正在运行的每个进程的数字标识符。PID对于操作系统的管理和控制进程非常重要,具有以下用途:

    1. 进程管理:操作系统通过PID来标识和管理每个正在运行的进程。通过PID,操作系统可以轻松地识别和跟踪每个进程的状态、资源使用情况等信息。
    2. 进程控制:通过PID,用户可以向特定的进程发送信号,控制其行为。例如,可以通过kill命令向指定PID的进程发送信号,请求终止该进程。
    3. 进程通信:在进程间通信时,PID可以用作标识符。进程可以通过PID来确定要发送消息的目标进程。
    4. 日志跟踪:在日志文件中记录进程的PID可以帮助系统管理员跟踪和诊断系统问题。

    总之,PID是操作系统中重要的标识符,用于唯一标识和管理每个正在运行的进程。

  • !! - 完整的上一条命令,包括参数。常见应用:当你因为权限不足执行命令失败时,可以使用 sudo !!再尝试一次。

  • $_ - 上一条命令的最后一个参数。如果你正在使用的是交互式 shell,你可以通过按下 Esc 之后键入 . 来获取这个值。

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

  • STDOUT(Standard Output)是标准输出流,用于输出命令的正常输出信息。当命令成功执行时,它会将结果输出到STDOUT流,这些结果通常是命令的输出值或结果。
  • STDERR(Standard Error)是标准错误流,用于输出命令的错误信息或警告信息。当命令执行出错时,它会将错误信息输出到STDERR流,这些信息通常包括错误信息和错误码的解释。

通常情况下,STDOUTSTDERR会分别显示在终端上,这样可以让用户更容易区分正常输出和错误信息。如果你想将STDOUTSTDERR输出重定向到不同的文件中,可以使用>符号将STDOUT输出到一个文件中,使用2>符号将STDERR输出到一个文件中,例如:

1
command > output.txt 2> error.txt

这将命令的正常输出写入output.txt文件,将错误信息写入error.txt文件。

2>是一种重定向符号,用于将标准错误流(STDERR)输出到指定文件。在Linux和Unix系统中,每个进程都有三个标准文件描述符:标准输入(stdin,文件描述符为0)、标准输出(stdout,文件描述符为1)和标准错误(stderr,文件描述符为2)。

当使用2>时,表示将标准错误流输出到指定的文件。

退出码可以搭配 &&(与操作符)和 ||(或操作符)使用,用来进行条件判断,决定是否执行其他程序。它们都属于短路运算符(short-circuiting) 同一行的多个命令可以用;分隔。程序 true 的返回码永远是0false 的返回码永远是1。让我们看几个例子

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

false ; echo "This will always run"
# This will always run

另一个常见的模式是以变量的形式获取一个命令的输出,这可以通过 命令替换command substitution)实现。

*当您通过 $( CMD ) 这样的方式来执行CMD 这个命令时,它的输出结果会替换掉 $( CMD ) 。例如,如果执行 for file in $(ls) ,shell首先将调用ls ,然后遍历得到的这些返回值。还有一个冷门的类似特性是 进程替换process substitution), <( CMD ) 会执行 CMD 并将结果输出到一个临时文件中,并将 <( CMD ) 替换成临时文件名。这在我们希望返回值通过文件而不是STDIN传递时很有用。例如, diff <(ls foo) <(ls bar) 会显示文件夹 foobar 中文件的区别。*

栗子:

1
2
3
4
5
6
iteration.sh脚本如下
#!/bin/bash
for file in $(ls)
do
echo "Processing file is: $file"
done
1
2
bash中运行
. iteration.sh > files_name.txt

下面这个例子展示了一部分上面提到的特性。这段脚本会遍历我们提供的参数,使用grep 搜索字符串 foobar,如果没有找到,则将其作为注释追加到文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/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,因为我们并不关心这些信息
#windows一般丢弃到‘NUL’!!
if [[ $? -ne 0 ]]; then #检查上一条命令的退出状态。如果不是 0(即未找到 "foobar"),则执行下面的操作。
echo "File $file does not have any foobar, adding one"
echo "# foobar" >> "$file"
fi
done

1.在 Shell 脚本中,**”fi” 是 “if” 的反写,用于结束 if 结构**。在 if 结构中,通常使用 “if” 关键字开始条件判断,然后在满足条件时执行相应的代码块,最后使用 “fi” 关键字结束 if 结构。

示例:

1
2
3
4
5
if [条件判断]; then
# 在条件满足时执行的代码
else
# 在条件不满足时执行的代码
fi

2.grep 是一个常用的文本搜索工具,用于在文件或标准输入中查找匹配特定模式的行,并将结果输出到标准输出。其名称来自于 Global Regular Expression Print 的缩写。

grep 命令的基本语法如下:

1
grep [options] pattern [file...]

其中:

  • pattern 是您要搜索的模式或正则表达式。
  • file 是要在其中搜索匹配模式的文件。如果不指定文件,则 grep 将从标准输入中读取数据。

grep 命令的一些常用选项包括:

  • -i:忽略大小写。ignore
  • -r:递归搜索子目录。recursion
  • -n:显示匹配行的行号。
  • -v:显示不包含匹配模式的行。
  • -o:仅显示匹配模式的部分。

例如,要在文件 example.txt 中查找包含 hello 的行,您可以使用以下命令:

1
grep hello example.txt

grep 是一个功能强大且灵活的工具,可用于各种文本搜索和处理任务。

3.在条件语句中,我们比较 $? 是否等于0。**-ne意思是not equal**。 Bash实现了许多类似的比较操作,您可以查看 test 手册。 在bash中进行比较时,尽量使用双方括号 [[ ]] 而不是单方括号 [ ],这样会降低犯错的几率,尽管这样并不能兼容 sh。 更详细的说明参见这里

当执行脚本时,我们经常需要提供形式类似的参数。bash使我们可以轻松的实现这一操作,它可以基于文件扩展名展开表达式。这一技术被称为shell的 通配globbing

  • 通配符 - 当你想要利用通配符进行匹配时,你可以分别使用 ?* 来匹配一个或任意个字符。例如,对于文件foo, foo1, foo2, foo10bar, rm foo?这条命令会删除foo1foo2 ,而rm foo* 则会删除除了bar之外的所有文件。
  • 花括号{} - 当你有一系列的指令,其中包含一段公共子串时,可以用花括号来自动展开这些命令。这在批量移动或转换文件时非常方便。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
convert 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
# ---
# > y

convert 是 ImageMagick 图像处理工具集中的一个命令,用于对图像文件进行格式转换、调整大小、合并图像、添加特效等操作。ImageMagick 是一个开源的图像处理工具集,支持多种图像格式,可以在命令行中使用。

下面是 convert 命令的基本语法和一些常用选项:

1
bash复制代码convert input.jpg output.png

这个命令将 input.jpg 图像文件转换为 output.png 图像文件。你可以使用不同的图像格式作为输入和输出。

以下是 convert 命令的一些常用选项:

  • -resize: 调整图像大小

  • -rotate: 旋转图像

  • -crop: 裁剪图像

  • -composite: 合并图像

  • -blur: 添加模糊效果

  • -negate: 反色

    1. 调整图像大小 (-resize)

      1
      convert input.jpg -resize 50% output.jpg

      这个命令将 input.jpg 图像文件调整为原来大小的50%,并保存为 output.jpg

    2. 旋转图像 (-rotate)

      1
      convert input.jpg -rotate 90 output.jpg

      这个命令将 input.jpg 图像文件逆时针旋转90度,并保存为 output.jpg

    3. 裁剪图像 (-crop)

      1
      convert input.jpg -crop 100x100+10+10 output.jpg

      这个命令将 input.jpg 图像文件从坐标 (10, 10) 开始裁剪大小为 100x100 的区域,并保存为 output.jpg

    4. 合并图像 (-composite)

      1
      convert background.jpg overlay.png -composite output.jpg

      这个命令将 overlay.png 图像叠加到 background.jpg 图像上,并保存为 output.jpg

    5. 添加模糊效果 (-blur)

      1
      convert input.jpg -blur 0x8 output.jpg

      这个命令将 input.jpg 图像文件添加一个水平方向模糊半径为8的模糊效果,并保存为 output.jpg

    6. 反色 (-negate)

      1
      convert input.jpg -negate output.jpg

      这个命令将 input.jpg 图像文件进行反色处理,并保存为 output.jpg

      这个命令要安装imagemagick程序

编写 bash 脚本有时候会很别扭和反直觉。例如 shellcheck 这样的工具可以帮助你定位sh/bash脚本中的错误。

注意,脚本并不一定只有用 bash 写才能在终端里调用。比如说,这是一段 Python 脚本,作用是将输入的参数倒序输出:

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

内核知道去用 python 解释器而不是 shell 命令来运行这段脚本,是因为脚本的开头第一行的 shebang

shebang 行中使用 env 命令是一种好的实践,它会利用环境变量中的程序来解析该脚本,这样就提高了您的脚本的可移植性。env 会利用我们第一节讲座中介绍过的PATH 环境变量来进行定位。 例如,使用了env的shebang看上去是这样的#!/usr/bin/env python

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

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

Shell 工具

查看命令如何使用

看到这里,您可能会有疑问,我们应该如何为特定的命令找到合适的标记呢?例如 ls -l, mv -imkdir -p。更普遍的是,给您一个命令行,您应该怎样了解如何使用这个命令行并找出它的不同的选项呢? 一般来说,您可能会先去网上搜索答案,但是,UNIX 可比 StackOverflow 出现的早,因此我们的系统里其实早就包含了可以获取相关信息的方法。

在上一节中我们介绍过,最常用的方法是为对应的命令行添加-h--help 标记。另外一个更详细的方法则是使用man 命令。man 命令是手册(manual)的缩写,它提供了命令的用户手册。(windows没有man程序!!

例如,man rm 会输出命令 rm 的说明,同时还有其标记列表,包括之前我们介绍过的-i。 事实上,目前我们给出的所有命令的说明链接,都是网页版的Linux命令手册。即使是您安装的第三方命令,前提是开发者编写了手册并将其包含在了安装包中。在交互式的、基于字符处理的终端窗口中,一般也可以通过 :help 命令或键入 ? 来获取帮助。

有时候手册内容太过详实,让我们难以在其中查找哪些最常用的标记和语法。 TLDR pages 是一个很不错的替代品,它提供了一些案例,可以帮助您快速找到正确的选项。

例如,自己就常常在tldr上搜索tarffmpeg 的用法。

查找文件(指令太多,我决定在要找文件时直接向ChatGPT索要能达到检索目的的命令)

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

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

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

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

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

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

查找代码

查找文件是很有用的技能,但是很多时候您的目标其实是查看文件的内容。一个最常见的场景是您希望查找具有某种模式的全部文件,并找它们的位置。

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

grep 有很多选项,这也使它成为一个非常全能的工具。其中我经常使用的有 -C :获取查找结果的上下文(Context);-v 将对结果进行反选(Invert),也就是输出不匹配的结果。举例来说, grep -C 5 会输出匹配结果前后五行。当需要搜索大量文件的时候,使用 -R 会递归地进入子目录并搜索所有的文本文件。

但是,我们有很多办法可以对 grep -R 进行改进,例如使其忽略.git 文件夹,使用多CPU等等。

因此也出现了很多它的替代品,包括 ack, agrg。它们都特别好用,但是功能也都差不多,我比较常用的是 ripgrep (rg) ,因为它速度快,而且用法非常符合直觉。例子如下:

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

find/fd 一样,重要的是你要知道有些问题使用合适的工具就会迎刃而解,而具体选择哪个工具则不是那么重要。

举个栗子:

1
find . -type f -print0 | xargs -0 grep -li 'STRING'

这个命令的含义是:

  • find . -type f -print0:这部分命令使用find命令来查找当前目录及其子目录下的所有文件(-type f表示只查找文件),并使用null字符(\0)分隔文件名(-print0选项)。(-print0find命令的一个选项,用来在输出文件名时使用null字符(\0)作为分隔符。)
  • |:这是管道符号,用于将前一个命令的输出作为后一个命令的输入。
  • xargs -0 grep -li 'STRING':这部分命令使用xargs命令来接收前一个命令(find)的输出,并将每个文件名作为参数传递给grep命令。-0选项表示使用null字符作为分隔符。grep -li 'STRING'命令用于在每个文件中查找包含STRING字符串的行,且不区分大小写(-i选项)并打印出文件名(-l选项)。(xargs -0命令的作用是从标准输入中读取数据,并将其作为参数传递给指定的命令。其中的-0选项表示使用null字符(\0)作为参数的分隔符,这通常与find命令中的-print0选项一起使用,用于处理含有特殊字符的文件名。)

综合起来,这个命令的作用是在当前目录及其子目录下的所有文件中查找包含STRING字符串的行,并打印出包含该字符串的文件名。

查找 shell 命令

目前为止,我们已经学习了如何查找文件和代码,但随着你使用shell的时间越来越久,您可能想要找到之前输入过的某条命令。首先,按向上的方向键会显示你使用过的上一条命令,继续按上键则会遍历整个历史记录。

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

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

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

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

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

你可以修改 shell history 的行为,例如,如果在命令的开头加上一个空格,它就不会被加进shell记录中。当你输入包含密码或是其他敏感信息的命令时会用到这一特性。 为此你需要在.bashrc中添加HISTCONTROL=ignorespace或者向.zshrc 添加 setopt HIST_IGNORE_SPACE。 如果你不小心忘了在前面加空格,可以通过编辑 .bash_history.zhistory 来手动地从历史记录中移除那一项。

文件夹导航

之前对所有操作我们都默认一个前提,即您已经位于想要执行命令的目录下,但是如何才能高效地在目录间随意切换呢?有很多简便的方法可以做到,比如设置alias,使用 ln -s 创建符号连接等。而开发者们已经想到了很多更为精妙的解决方案。

由于本课程的目的是尽可能对你的日常习惯进行优化。因此,我们可以使用fasdautojump 这两个工具来查找最常用或最近使用的文件和目录。

Fasd 基于 frecency 对文件和文件排序,也就是说它会同时针对频率(frequency)和时效(recency)进行排序。默认情况下,fasd使用命令 z 帮助我们快速切换到最常访问的目录。例如, 如果您经常访问/home/user/files/cool_project 目录,那么可以直接使用 z cool 跳转到该目录。对于 autojump,则使用j cool代替即可。

还有一些更复杂的工具可以用来概览目录结构,例如 tree, broot 或更加完整的文件管理器,例如 nnnranger