Shell Tools And Scripting

官方文档: https://missing.csail.mit.edu/ 官方文档中文翻译: https://missing-semester-cn.github.io/

Shell 脚本

本节中,我们会专注于 bash 脚本,因为它最流行,应用更为广泛。

在bash中的变量赋值语法为foo=bar,访问变量中的值,其语法为$foo

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

在bash、zsh、sh中,空格是用于分隔参数的保留字符,使用空格可能会造成混淆。

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

$ foo=bar
$ echo "$foo"   #打印 bar
$ echo '$foo'   #打印 $foo

和其他大多数的编程语言一样,Bash 也支持ifcasewhilefor这些控制流关键字。同样地, Bash 也支持函数,它可以接受参数并基于参数进行操作。

以下是一个简单的函数定义,该函数位于mcd.sh文件中:

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

该函数会创建一个文件夹并将当前目录切换到该文件夹。

bash中的一些特殊变量
$0      #当前 Shell的名称(在命令行直接执行时)或者脚本名(在脚本中执行时)
$1 ~ $9 #第一个参数及其之后的参数
$@      #脚本的所有参数值
$#      #脚本的参数数量
$?      #上一个命令的返回值。返回值是0,表示上一个命令执行成功;如果不是零,表示上一个命令执行失败。
$$      #当前 Shell 的进程ID 或者当前脚本的进程ID
$_      #上一个命令的最后一个参数
!!      #完整的上一个命令

Bash 变量

source命令会在当前bash环境下读取并执行对应文件中的命令。source mcd.shmcd.sh文件内的内容添加到 Shell 中,即将函数mcd添加到了 Shell 中。 然后该函数即可在 Shell 中直接使用。

~$: source mcd.sh
~$: mcd test
~/test$: cd ..

Bash 脚本入门

退出码可以搭配&&(与操作符)和||(或操作符)使用,用来进行条件判断,决定是否执行其他程序。它们都属于短路运算符(short-circuiting)。 同一行的多个命令可以用;分隔。程序 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"
#

$ 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)会显示文件夹 foo 和 bar 中文件的区别。

$ foo=$(pwd)
$ echo $foo
# 输出当前目录路径
$ echo "We are in $(pwd)"
# We are in 当前目录路径
$ cat <(ls) <(ls ..)
# 输出当前目录与上一级目录内的所有文件名

包含上述所有 bash 变量的程序example.sh

#!/bin/bash

echo "Starting program at $(date)"

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

for file in $@; do
    grep foobar $file > /dev/null 2> /dev/null
    # 如果没有找到,则 grep 退出状态为 1
    # 我们将 STDOUT(>) 和 STDERR (2>) 重定向到 Null,因为我们并不关心这些信息
    # 2> 是用来重定向 STDERR 的,因为 STDOUT 和 STDERR 是分离的
    if [[ $? -ne 0 ]]; then # 比较运算符 -ne (Non Equal),代表不等于
        echo "File $file does not have any foobar, adding one"
        echo "# foobar" >> $file
    fi
done

运行该脚本:

user@localhost:~/missing/shell_tools$ ./example.sh mcd.sh test2.sh
Starting program at Sun July 23 23:51:13 CST 2022
Running program ./example.sh with 2 args with pid 11920
File test2.sh does not have any foobar, adding one

通配

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

通配符:当你想要利用通配符进行匹配时,你可以分别使用?*来匹配一个或任意个字符。

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

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

使用环境变量来解析脚本

脚本的第一行通常是指定解释器,即这个脚本必须通过什么解释器执行。这一行以#!字符开头,这个字符称为 Shebang,所以这一行就叫做 Shebang 行。

例如:

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

但是,如果解释器不放在目录/bin,脚本就无法执行了。为了保险,可以写成下面这样:

#!/usr/bin/env python

env命令总是指向/usr/bin/env文件,或者说,这个二进制文件总是在目录/usr/bin

#!/usr/bin/env NAME这个语法的意思是,让 Shell 查找$PATH环境变量里面第一个匹配的NAME。如果你不知道某个命令的具体路径,或者希望兼容其他用户的机器,这样的写法就很有用。

Shell函数和脚本的一些不同点
  1. 函数只能与shell使用相同的语言,脚本可以使用任意语言。因此在脚本中包含 Shebang 是很重要的。

  2. 函数仅在定义时被加载,脚本会在每次被执行时加载。这让函数的加载比脚本略快一些,但每次修改函数定义,都要重新加载一次。

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

  4. 与其他程序语言一样,函数可以提高代码模块性、代码复用性并创建清晰性的结构。shell脚本中往往也会包含它们自己的函数定义。

Shell 工具

find 指令

# . 表示当前目录
# 查找所有名称为 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'

#对搜索结果执行 rm 操作
find . -name '*.tmp' -exec rm {} \;

find 命令的 7 种用法

grep 命令

在文件中搜索:

$ grep foobar mcd.sh
# foobar

文件夹中递归的搜索,需要 flag -R

$ grep -R foobar .
./example.sh:    grep foobar $file > /dev/null 2> /dev/null
./example.sh:        echo "File $file does not have any foobar, adding one"
./example.sh:        echo "# foobar" >> $file
./mcd.sh:# foobar
./test2.sh:# foobar

grep 命令常见用法

课程中介绍到了ripgrep(rg)命令,但这是非内置命令,并不具有通用性,故不作记录。需要了解的请自行在网络上搜索。

history 命令

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

linux的history命令

组合键ctrl + R可以从指令输入历史中搜索对应的指令。

fzf 命令

摘抄自Github上的学习笔记 安装:https://github.com/junegunn/fzf#using-linux-package-managers 使用:

$ cat example.sh | fzf

可以进行模糊搜索。

安装的时候可以选择绑定到 ctrl + R 指令,从而在历史搜索时启用 fzf

课后习题

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

    • 所有文件(包括隐藏文件)

    • 文件打印以人类可以理解的格式输出 (例如,使用454M 而不是 454279954)

    • 文件以最近访问顺序排序

    • 以彩色文本显示输出结果

    $ man ls
    $ ls -a
    $ ls -h
    $ ls --color=auto
    $ ls -alhS --color=auto

    ls命令常见使用方法

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

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

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

    $ mkdir html_folder
    $ cd html_folder
    $ touch {1..10}.html
    $ mkdir html
    $ cd html
    $ touch test.html
    $ cd ..
    $ find . -type f -name "*.html" | xargs -d '\n' tar -cvzf html.zip

    xargs命令详解 tar命令常用方法

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

    find . -type f -mmin -60 -print0 | xargs -0 ls -lt | head -10

最后更新于