Post

Shell(进阶)

Shell(进阶)

Shell 脚本

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

1
2
3
4
5
6
foo=bar
#赋值语句
echo "$foo"
# 打印 bar
echo '$foo'
# 打印 $foo
  • 变量赋值: foo=bar
  • 访问变量中存储的数值: $foo

注意⚠️: foo = bar (使用空格隔开)是不能正确工作的,因为解释器会调用程序 foo 并将 =bar 作为参数。

字符串 :Bash 中的字符串通过 '" 分隔符来定义,但含义并不相同。

  • ' 定义的字符串为原义字符串,其中的变量不会被转义
  • " 定义的字符串会将变量值进行替换。

控制流

bash 支持 if, case, whilefor 这些控制流关键字和函数(它可以接受参数并基于参数进行操作)。

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

这里 $1 是脚本的第一个参数。与其他脚本语言不同的是,bash 使用了很多特殊的变量来表示参数、错误代码和相关变量。下面列举了其中一些变量,更完整的列表可以参考 这里

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

获取命令的输出

终端输出

命令通常使用 STDOUT 来返回输出值,使用 STDERR 来返回错误及错误码,

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

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

命令替换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
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

shell 的 通配globbing

当执行脚本时,我们经常需要提供形式类似的参数。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

⚠️❗️❗️❗️shellcheck 这样的工具可以帮助你定位 sh/bash 脚本中的错误.

PS:注意,脚本并不一定只有用 bash 写才能在终端里调用

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

shebang 行中使用 env 命令是一种好的实践,它会利用环境变量中的程序来解析该脚本,这样就提高了您的脚本的可移植性。使用了 env 的 shebang 看上去是这样的 #!/usr/bin/env python

脚本会在单独的进程中执行。因此,函数可以对环境变量进行更改,比如改变当前工作目录,脚本则不行。使用 export 导出的环境变量会以传值的方式传递给脚本。

Shell 工具

man rm 有时候手册内容太过详实可以用 TLDR pages 替代,它提供了一些案例,可以帮助您快速找到正确的选项。

查找文件

find

find 命令会递归地搜索符合条件的文件

1
2
3
4
5
6
7
8
# 查找所有名称为src的文件夹
find . -name src -type d #如果您希望模式匹配时是不区分大小写,可以使用 -iname 选项
# 查找所有文件夹路径中包含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 magick {} {}.jpg \;

fd

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

locate

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

查找代码

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

grep

很多类 UNIX 的系统都提供了 grep 命令,它是用于对输入文本进行匹配的通用工具。

grep 有很多选项

  • -C :获取查找结果的上下文(Context)
    • grep -C 5 会输出匹配结果前后五行
  • -v:将对结果进行反选(Invert),也就是输出不匹配的结果
  • -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

查找Shell 命令

快捷键

  • 按向上的方向键会显示你使用过的上一条命令,继续按上键则会遍历整个历史记录。
  • Ctrl+R:对于大多数的 shell 来说,您可以使用 Ctrl+R 对命令历史记录进行回溯搜索。敲 Ctrl+R 后您可以输入子串来进行匹配,查找历史命令行。
  • fzf: Ctrl+R 可以配合 fzf 使用。fzf 是一个通用的模糊查找工具,它可以和很多命令一起使用。这里我们可以对历史命令进行模糊查找并将结果以赏心悦目的格式输出。

⚠️❗️❗️❗️:如果在命令的开头加上一个空格,它就不会被加进 shell 记录中,如果你不小心忘了在前面加空格,可以通过编辑 .bash_history.zhistory 来手动地从历史记录中移除那一项。

history 命令

history 命令允许您以程序员的方式来访问 shell 中输入的历史命令。这个命令会在标准输出中打印 shell 中的历史命令。

如果我们要搜索历史记录,则可以利用管道将输出结果传递给 grep 进行模式搜索: history | grep find 会打印包含 find 子串的命令。

文件夹导航

使用 fasdautojump 这两个工具来查找最常用或最近使用的文件和目录。

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

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

  • tree:用“树状图”概览目录适合快速看项目结构、找大目录、贴到文档里。

  • broot:交互式目录浏览 + 搜索(很强的“导航器”)

    适合:目录很大时,想边搜边进、快速定位文件/目录;比 tree 更“可操作”。

  • ranger:类 Vim 风格的三栏文件管理器(非常经典)

课后练习

习题解答

  1. 阅读 man ls ,然后使用 ls 命令进行如下操作:
    • 所有文件(包括隐藏文件)(ls -a)
    • 文件打印以人类可以理解的格式输出 (例如,使用 454M 而不是 454279954) ls -lh
    • 文件以最近修改顺序排序 ls -ltr
    • 以彩色文本显示输出结果 ls -G -ltr
  2. 编写两个 bash 函数 marcopolo 执行下面的操作。 每当你执行 marco 时,当前的工作目录应当以某种形式保存,当执行 polo 时,无论现在处在什么目录下,都应当 cd 回到当时执行 marco 的目录。 为了方便 debug,你可以把代码写在单独的文件 marco.sh 中,并通过 source marco.sh 命令,(重新)加载函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
macro(){
  touch ~/.pwd.txt
  echo "$PWD" > ~/.pwd.txt
  echo "Current directory saved as $(cat ~/.pwd.txt)"
}

polo(){
  echo "Returned to $(cat ~/.pwd.txt)"
  cd "$(cat ~/.pwd.txt)"
  rm ~/.pwd.txt
}

source macro.sh
macro
polo
  1. 假设您有一个命令,它很少出错。因此为了在出错时能够对其进行调试,需要花费大量的时间重现错误并捕获输出。 编写一段 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"
1
2
3
4
5
6
7
8
9
n=0
while true; do
    bash macro.sh
    if [[ $? -ne 0 ]]; then
        break
    fi
    n=$((n+1))
done
echo "The script failed after $n runs"
  1. 本节课我们讲解的 find 命令中的 -exec 参数非常强大,它可以对我们查找的文件进行操作。但是,如果我们要对所有文件进行操作呢?例如创建一个 zip 压缩文件?我们已经知道,命令行可以从参数或标准输入接受输入。在用管道连接命令时,我们将标准输出和标准输入连接起来,但是有些命令,例如 tar 则需要从参数接受输入。这里我们可以使用 xargs 命令,它可以使用标准输入中的内容作为参数。 例如 ls | xargs rm 会删除当前目录中的所有文件。

​ 您的任务是编写一个命令,它可以递归地查找文件夹中所有的 HTML 文件,并将它们压缩成 zip 文件。注意,即使文件名 中包含空格,您的命令也应该能够正确执行(提示:查看 xargs 的参数 -d,译注:MacOS 上的 xargs 没有 -d查看这个 issue

如果您使用的是 MacOS,请注意默认的 BSD findGNU coreutils 中的是不一样的。你可以为 find 添加 -print0 选项,并为 xargs 添加 -0 选项。作为 Mac 用户,您需要注意 mac 系统自带的命令行工具和 GNU 中对应的工具是有区别的;如果你想使用 GNU 版本的工具,也可以使用 brew 来安装

  1. (进阶)编写一个命令或脚本递归的查找文件夹中最近修改的文件。更通用的做法,你可以按照最近的修改时间列出文件吗?
This post is licensed under CC BY 4.0 by the author.