The Missing Semester - 第二讲 学习笔记

第二讲 Shell 工具和脚本

课程视频地址:

https://www.bilibili.com/video/BV1Vv411v7FR

本机学习使用平台:虚拟机ubuntu18.04.6

主题一:Shell脚本

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

赋值语句

#注意赋值时不能有空格,空格是用于分隔参数的保留字符
grapefruitcat@grapefruitcat:~$ foo=bar
grapefruitcat@grapefruitcat:~$ echo $foo
bar
# not 'foo = bar', 这会变成引用foo命令

原义字符串和转义字符串

对于纯字符串,'"是等价的,例如

grapefruitcat@grapefruitcat:~$ echo 'hello'
hello
grapefruitcat@grapefruitcat:~$ echo "hello"
hello

但对于某些字符,'是作为原义字符串,不会对引号内的串进行语义转换;

"是转义字符串,会将引号内的变量值展开(替换)。如:

grapefruitcat@grapefruitcat:~$ echo 'Value is $foo'
Value is $foo
grapefruitcat@grapefruitcat:~$ echo "Value is $foo"
Value is bar

这也是第一讲课后习题中使用echo命令输入字符串到文件时要将"转换为'使用的原因。

控制流关键字和函数

bash支持if, case, whilefor 这些控制流关键字。

bash也可以像其它的编程语言一样写出可以接受参数的函数:

#接受一个参数,按照参数名称在当前目录下创建同名文件夹,并cd到这个文件夹
grapefruitcat@grapefruitcat:~$ mcd () {
>  mkdir -p "$1"
>  cd "$1"
> }
grapefruitcat@grapefruitcat:~$ mcd kksk
grapefruitcat@grapefruitcat:~/kksk$ 

函数中的$变量对应的表示:

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

更多的表示参见:https://tldp.org/LDP/abs/html/special-chars.html

我们将函数直接键入shell中它就会起作用,实际上把它写进文件中会更好。shell脚本文件后缀为sh

#写入shell脚本
grapefruitcat@grapefruitcat:~$ vim test2.sh
grapefruitcat@grapefruitcat:~$ cat test2.sh
kksk () {
	mkdir -p "$1"
	cd "$1"
}
#脚本未运行,函数kksk未定义
grapefruitcat@grapefruitcat:~$ kksk ppp
kksk:未找到命令
#使用source命令运行脚本
grapefruitcat@grapefruitcat:~$ source test2.sh
grapefruitcat@grapefruitcat:~$ kksk ppp
grapefruitcat@grapefruitcat:~/ppp$ 

命令的返回值

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

错误码(或者退出码、返回码) error code 的理解应该为返回值会更为准确。

#未发生错误
grapefruitcat@grapefruitcat:~$ echo "hello"
hello
grapefruitcat@grapefruitcat:~$ $?
0:未找到命令
#找不到“foobar”
grapefruitcat@grapefruitcat:~$ grep foobar test2.sh
grapefruitcat@grapefruitcat:~$ $?
1:未找到命令

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

在同一行内使用;来连接命令:

grapefruitcat@grapefruitcat:~$ echo "hhh"; echo "bangbang"
hhh
bangbang

命令替换和进程替换

我们要怎样把命令的输出存到变量里面呢?

#把pwd命令的输出存到变量foo里面
grapefruitcat@grapefruitcat:~$ foo=$(pwd)
grapefruitcat@grapefruitcat:~$ echo "$foo"
/home/grapefruitcat

进程替换和命令替换类似,如 <( CMD ) 会执行 CMD 并将结果输出到一个临时文件中,并将 <( CMD ) 替换成临时文件名。即存放在一个匿名文件。

grapefruitcat@grapefruitcat:~$ cat <(ls)
公共的
模板
视频
图片
文档
下载
音乐
桌面

一个大点的例子

#!/bin/bash

echo "Starting program at $(date)" # date会被替换成日期和时间

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

# 将所有参数展开,轮流给file变量赋值
for file in "$@"; do
    grep foobar "$file" > /dev/null 2> /dev/null
    # 如果模式没有找到,则grep退出状态为 1
    # 我们将标准输出流和标准错误流重定向到Null,因为我们并不关心这些信息
    # 如果没找到foobar,则在此文件后面续上
    if [[ $? -ne 0 ]]; then
        echo "File $file does not have any foobar, adding one"
        echo "# foobar" >> "$file"
    fi
done

文件第一行是shebang。说人话就是shebang的内容指定了shell脚本解释器的路径,而且这个指定路径只能放在文件的第一行。第一行写错或者不写时,系统会有一个默认的解释器进行解释。

在计算领域中,Shebang(也称为 Hashbang )是一个由井号和叹号构成的字符序列 #! ,其出现在文本文件的第一行的前两个字符。 在文件中存在 Shebang 的情况下,类 Unix 操作系统的程序加载器会分析 Shebang 后的内容,将这些内容作为解释器指令,并调用该指令,并将载有 Shebang 的文件路径作为该解释器的参数。

在grep的输出中有几个注意的点:

  • /dev/null:表示空设备文件
  • 0 表示stdin标准输入;
  • 1> 表示stdout标准输出;
  • 2> 表示stderr标准错误输出;

流可以使用n>运算符重定向,其中n是文件描述符。省略n时,默认为标准输出流1

通配

  • 通配符 - 当你想要利用通配符进行匹配时,你可以分别使用 ?* 来匹配一个或任意个字符。例如,对于文件foo, foo1, foo2, foo10bar, rm foo?这条命令会删除foo1foo2 ,而rm foo* 则会删除除了bar之外的所有文件。
  • 花括号{} - 当你有一系列的指令,其中包含一段公共子串时,可以用花括号来自动展开这些命令。这在批量移动或转换文件时非常方便。
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)
# 输出,8c8表示两个输入在第8行不同,见
# https://www.cnblogs.com/SoaringLee/p/10532503.html
# 8c8
# < x
# ---
# > y

注意,通配符是系统命令使用,一般用来匹配文件名或者什么的用在系统命令中。而正则表达式是操作字符串,以行尾单位来匹配字符串使用的。

其它注意事项:

  • 编写脚本:shellcheck

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

    将 shell 脚本粘贴到 https://www.shellcheck.net 上以获得即时反馈。

  • 脚本首行:shebang

    关于shebang的解释在上文中已有提及。

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

    #!/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工具

查看帮助文档

如何为特定的命令找到合适的参数和使用方法:

  • 最常用的方法为:为对应的命令行添加-h--help 参数。

  • man工具是常用的文档查阅工具,但有时man调出来的文档会过于详细。

  • tldr是一个不错的替代,它能提供一些使用的样例,简洁明了。(too long dont read)

查找文件

最简单的方法即是一路ls下去找(非常的笨..)

但假设我们要查找一个叫src的文件夹,我们可以使用find命令。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'

除了列出所寻找的文件之外,find 还能对所有查找到的文件进行操作。这能极大地简化一些单调的任务。

# 删除全部扩展名为.tmp 的文件
find . -name '*.tmp' -exec rm {} \;
# 查找全部的 PNG 文件并将其转换为 JPG
find . -name '*.png' -exec convert {} {}.jpg \;

find工具的语法太过复杂,难以记忆——我们使用fd工具来替代它。

fd工具默认使用正则表达式,默认不分大小写,还可以更改输出颜色,支持unicode。

ubuntu系统下为了避免命名冲突,fd的命令是fdfind,可以自己alias。注意,wsl默认安装的是ubuntu。

从Ubuntu 19.04(Disco Dingo)开始,可以通过使用apt-get调用官方维护的软件包直接安装fd。 如果运行的是旧版Ubuntu,请查看GitHub页面上的安装说明。

获取命令是:sudo apt-get install fd-find。

再有一个是locate工具,find 和类似的工具可以通过别的属性比如文件大小、修改时间或是权限来查找文件,locate则只能通过文件名。

因为是事先搭好了索引,所以搜索非常的快。locate 使用一个由 updatedb负责更新的数据库,在大多数系统中 updatedb 都会通过 cron 每日更新。

查找代码

我们需要查找不仅是文件,还有文件里面的内容。

第一个是grep命令,这是最常用的对输入文本进行匹配的通用工具。grep 有很多选项,这也使它成为一个非常全能的工具。

  • -C :获取查找结果的上下文;
  • -v :将对结果进行反选,也就是输出不匹配的结果;
  • -R(r):当需要搜索大量文件的时候,使用 -R 会递归地进入子目录并搜索所有的文本文件。

此外,我们可以使用rg命令对grep -r进行改进:

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

除此还有ackag等工具。

查找shell命令

  • 的方向键会显示你使用过的上一条命令,继续按上键则会遍历整个历史记录;
  • history命令会打印出你使用过的命令,配合管道符还有grep可以查阅你使用过的命令。

语句为:history | grep "** keywords **"

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

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

文件夹导航

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

Fasd 基于 frecency 对文件和文件排序,也就是说它会同时针对频率(frequency)和时效(recency)进行排序。

使用 tree, broot 来概览文件目录结构。

课后练习

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

    • 所有文件(包括隐藏文件)
      • ls -la, Long format list (permissions, ownership, size, and modification date) of all files
    • 文件打印以人类可以理解的格式输出 (例如,使用454M 而不是 454279954)
      • ls -lh, Long format list with size displayed using human-readable units (KiB, MiB, GiB)
    • 文件以最近访问顺序排序
      • ls -u -lt, with -lt: sort by, and show, access time; with -l: show access time and sort by name; otherwise: sort by access time, newest first
    • 以彩色文本显示输出结果
      • ls -l --color=auto
  2. 编写两个bash函数 marcopolo 执行下面的操作。 每当你执行 marco 时,当前的工作目录应当以某种形式保存,当执行 polo 时,无论现在处在什么目录下,都应当 cd 回到当时执行 marco 的目录。 为了方便debug,你可以把代码写在单独的文件 marco.sh 中,并通过 source marco.sh命令,(重新)加载函数。

    # 函数内容
    grapefruitcat@grapefruitcat:~/bar/a$ cat ~/macro.sh
    macro(){
    	pwd > ~/cur_dir.txt
    }
    polo(){
    	# 利用命令替换(管道符不能作为cd输入)
    	cd $(cat ~/cur_dir.txt)
    }
    # 执行结果
    grapefruitcat@grapefruitcat:~$ cd bar/a
    grapefruitcat@grapefruitcat:~/bar/a$ source ~/macro.sh
    grapefruitcat@grapefruitcat:~/bar/a$ macro
    grapefruitcat@grapefruitcat:~/bar/a$ cd ~
    grapefruitcat@grapefruitcat:~$ polo
    grapefruitcat@grapefruitcat:~/bar/a$ 
    
  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"
    

    结果展示:

    # 首先清空错误流和输出流的两个文件
    grapefruitcat@grapefruitcat:~$ >outwrong.txt
    grapefruitcat@grapefruitcat:~$ cat outwrong.txt
    grapefruitcat@grapefruitcat:~$ >outstream.txt
    grapefruitcat@grapefruitcat:~$ cat outstream.txt
    # 执行调试脚本,可以见到测试脚本被运行了六次后出错
    grapefruitcat@grapefruitcat:~$ ./t3.sh
    yeahyeah 1
    yeahyeah 2
    yeahyeah 3
    yeahyeah 4
    yeahyeah 5
    yeahyeah 6
    # 输出流文件展示
    grapefruitcat@grapefruitcat:~$ cat outstream.txt
    Everything went according to plan
    Everything went according to plan
    Everything went according to plan
    Everything went according to plan
    Everything went according to plan
    Everything went according to plan
    Something went wrong
    # 错误流文件展示
    grapefruitcat@grapefruitcat:~$ cat outwrong.txt
    The error was using magic numbers
    

    代码展示:

    #!/usr/bin/env bash
    #受测试脚本basesh.sh
    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
    # 设置两个变量,jud用于循环判断,k用于记录测试次数,注意不能和受测脚本变量名相同,会造成作用域覆盖
    jud=0
    k=0
    while [[ jud -ne 1 ]];
    do
    		# 两个不同的流输入到文件中
            source ~/basesh.sh >> outstream.txt 2>> outwrong.txt
            # 通过返回值来判断是否有错误流产生
            jud=($?)
            # 次数自增
            let k++
            echo "yeahyeah $k"
    done
    
  4. 本节课我们讲解的 find 命令中的 -exec 参数非常强大,它可以对我们查找的文件进行操作。但是,如果我们要对所有文件进行操作呢?例如创建一个zip压缩文件?我们已经知道,命令行可以从参数或标准输入接受输入。在用管道连接命令时,我们将标准输出和标准输入连接起来,但是有些命令,例如tar 则需要从参数接受输入。这里我们可以使用xargs 命令,它可以使用标准输入中的内容作为参数。 例如 ls | xargs rm 会删除当前目录中的所有文件。

    利用xargs和cd配合进行目录切换是不可行的,“xargs牵涉写管道,而cd是内部命令。具体的牵涉shell的工作原理。”

    详细见链接:https://cloud.tencent.com/developer/news/368132

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

    # 我们可以先在文件夹创建一些文件用于操作
    grapefruitcat@grapefruitcat:~/missing_sem$ touch file-{a..j}.html image-{1..10}.jpg 'file\ with\ spaces\ {k..z}.html'
    grapefruitcat@grapefruitcat:~/missing_sem$ sudo find $(pwd) -name "*.html"
    /home/grapefruitcat/missing_sem/file-h.html
    /home/grapefruitcat/missing_sem/file-a.html
    /home/grapefruitcat/missing_sem/file-f.html
    /home/grapefruitcat/missing_sem/file-j.html
    /home/grapefruitcat/missing_sem/file-i.html
    /home/grapefruitcat/missing_sem/file-e.html
    /home/grapefruitcat/missing_sem/file-g.html
    /home/grapefruitcat/missing_sem/file-d.html
    /home/grapefruitcat/missing_sem/file-c.html
    /home/grapefruitcat/missing_sem/file\ with\ spaces\ {k..z}.html
    /home/grapefruitcat/missing_sem/file-b.html
    
    # 然后就可以用命令完成任务, xargs记得加上参数-d确认分隔符,看输入流,以换行为分隔,
    # 使得“即使文件名中包含空格,您的命令也应该能够正确执行”。
    grapefruitcat@grapefruitcat:~/missing_sem$ sudo find . -name "*.html" | xargs -d '\n' tar czf t4.tar
    

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

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

    我们在man文档中可以找到一个参数atime

    -atime n
    File was last accessed n*24 hours ago. When find figures out how many 24-hour periods ago the file was last accessed, any fractional part is ignored, so to match -atime +1, a file has to have been accessed at least two days ago.

    但似乎不是很中用啊..😥

    继续向下看,find有一个格式化输出的参数-printf

    我们用%A@ %p\n就可以实现按访问时间列出文件:(具体字符含义看文档)

    # 使用sort排序,用tail列出后n行,再用cut剪去前面不必要的字符
    grapefruitcat@grapefruitcat:~$ sudo find . -type f -printf '%A@ %p\n' | sort -n | tail -3 | cut -d' ' -f2
    ./.tldr/tldr/pages/common/cut.md
    ./.tldr/tldr/pages/common/tail.md
    ./.config/nautilus/desktop-metadata
    

    大功告成!!🥳🥳🥳

posted @ 2023-01-27 00:28  GrapefruitCat  阅读(113)  评论(0编辑  收藏  举报