日拱一卒,麻省理工教你CS基础,那些酷炫无比的命令行工具

语言: CN / TW / HK

大家好,我是梁唐。

今天和大家继续聊聊麻省理工的missing semester,消失的学期,讲解那些不会在课上提及的工具和技术。

这一节课主要讲的的是一些shell中进阶工具的使用,虽然在日常工作当中我也经常使用shell命令,有时候也会编写shell脚本,这节课听完仍然让我眼界大开,原来还有这么多神奇的工具很用法。也让我感慨,MIT到底是MIT,不愧是全球顶尖的院校,基础内容都能讲出花来。

从内容上来说,我是非常推荐大家都能看一下讲课视频的。这节课B站有up主做了精校的中英双语字幕,不过可惜只更新了四节课。如果大家想看的话,我建议可以看一下双语精校版的

B站课程链接

但这节课换了老师,新老师Jose是西班牙人,所以口音比较重。我也是看了这个视频才知道,原来欧洲人说英语也可以有这么重的口音,甚至弹幕里还有人在为了老师到底是俄罗斯人还是印度人在吵架的……后来看了简介才发现是西班牙人,如果你注意听的话,当老师说到result这个单词的时候,可以听到经典的颤音,很有意思。

口音比较重的结果就是听起来比较吃力,其实老师讲得很好,只不过有时候很容易忽略一些细节。

这节课的笔记非常好,还有很多课程没有来得及提到的内容。但由于篇幅限制,一些命令的演示和说明可能比较简单,感兴趣的同学最好做一下深入的研究。

课程笔记

好了,废话不多说,让我们日拱一卒,开启新的旅程吧。

在这门课上,我们将会演示一些shell工具以及bash脚本语言的基础用法。这些内容基本上能够覆盖大多数命令行的使用场景。

Shell Scripting

目前我们已经演示了如何在shell里运行程序,以及使用管道命令。

然而,在许多场景当中,我们希望能够运行一系列命令并且使用一些控制流命令,比如条件语句、循环等等。

Shell脚本的复杂度会提升一些,大多数Shell拥有它们专属的脚本语言,涵盖变量、控制流以及特有的语法。和其他脚本语言不同的是,shell脚本是专门为了运行shell相关的任务而优化过的。比如创建命令管道,将运行的结果保存在文件里,或者是从标准输入读入数据,都是shell脚本的基础操作,这也使得它比一些通用的脚本语言更加易用。这节课我们将会聚焦在bash脚本,因为它更加普遍。

在bash创建变量,使用语法foo=bar,将会创建一个变量$foo。需要注意foo = bar不会生效,因为它会将foo当成是要执行的程序,而=bar当成是foo的参数。因为shell脚本是按照空格分隔参数的。这个特性在刚开始使用的时候会觉得很别扭,所以记得经常检查。

string可以使用单引号或双引号来表示,但它们不是等价的。以单引号分隔的字符串是纯字符,当中的变量不会被取值。而双引号的字符串可以。

和大多数编程语言一样,bash也支持控制流语法,比如if, case, whilefor。同样,bash也有可以接收参数的函数,并且可以执行。下面是一个函数创建一个文件夹并且cd进入的例子。

这里的$1指的是脚本的第一个参数,和其他脚本语言不同,bash使用许多特殊的变量来代表参数、error代码和其他相关的变量。接下来列举其中常用的一些:

  • $0- 脚本的名称
  • $1 to $9 - 脚本的参数,$1是第一个参数,以此类推
  • [email protected]- 所有的参数
  • $#- 参数的数量
  • $?- 上一个命令的返回结果
  • $$- 当前脚本的运行PID(进程id)
  • !!- 上一个运行的命令,包括参数,比如我们运行某命令提示没有权限失败,我们想重试可以直接简写sudo !!
  • $_- 上一条命令的最后一个参数,如果你是在交互式的shell终端使用,你也可以使用快捷键Esc加上.或者是Alt+.

命令通常使用STDOUT返回,错误通过STDERR,并且一个返回码提示错误是脚本友好的常用做法。返回码或者是退出时的状态是脚本/命令用来交互运行结果的一种方式。0通常意味着一切OK,除了0以外的值通常代表着出现了一些错误。

返回码可以被用在条件语句当中,使用&&||,两者都是短路运算符。命令之间也可以使用分号;进行分隔,true命令永远返回0,false命令永远返回1。让我们来看一些例子:

另外一个常用的语句是将一个命令的结果作为变量,这可以通过命令替换来实现。当你输入$( CMD )它会先运行CMD命令,获取命令的输出之后,将它立即当做是变量。

举个例子,如果你运行了for file in $(ls),shell会首先调用ls,然后遍历它的结果。一个比较小众的做法是process substitution(没想到好的翻译),<( CMD )将会运行CMD然后将它的结果存放在一个临时的文件中,并且将<()替换成临时文件的名称。这在一些接收文件而不是STDIN的命令当中可能会非常好用。

举个例子,diff <(ls foo) <(ls bar)将会展示foo文件夹和bar文件夹下文件的差异。让我们再来看另外一个例子,程序会遍历我们所有传入的参数,使用grep命令筛选字符串foobar,如果没有找到就将foobar添加在文件末尾。

这一段代码可能有些难懂,尽可能解释一下。grep foobar "$file" > /dev/null 2> /dev/null这行代码当中细节有些多,展开来说一说。

首先是grep语句,这是过滤语句,意思是从$file文件当中过滤包含foobar的文本。正常grep找到之后的结果会输出到stdout,这里我们给它重定向到了/dev/null,这是Linux系统中的一个特殊文件,输入的数据都会丢弃。

如果grep语句没有找到一条吻合的文本,那么会生成一个错误码。为了不让错误码影响程序的运行,我们把错误码也重定向到了/etc/null,错误码重定向使用的是2>

在比较的时候,我们判断了$?是否等于0,也就是判断grep的运行结果是否有报错,如果报错了,就说明没有找到我们指定的字符串foobar

Bash当中实现了许多这样的比较方式,你可以使用man命令来查看test的细节。在bash中进行比较的时候,使用双方括号[[ ]]而非单括号[]。这样会降低犯错的几率,虽然它对于sh来说不是很便携。大家可以查阅一下这两者的区别。

当进入bash脚本之后,你可能会希望输入一些命令近似的脚本。Bash提供一些方法可以拓展文件名,这种技术通常被称为shell global(全局匹配)。

  • 通配符- 当你想要匹配任意字符时,你可以使用?或者*来代替一个或任意多个字符。比如我们有foo, foo1, foo2, foo10, bar这几个文件。命令rm foo?将会删除foo1, foo2rm foo*将会删除除了bar之外所有的
  • 花括号{} - 当你的命令拥有一系列共同的单词时,你可以使用花括号来扩展。尤其是移动或者是转变文件的时候。

```bash convert image.{png,jpg}

Will expand to

convert image.png image.jpg

cp /path/to/project/{foo,bar,baz}.sh /newpath

Will expand to

cp /path/to/project/foo.sh /path/to/project/bar.sh /path/to/project/baz.sh /newpath

Globbing techniques can also be combined

mv *{.py,.sh} folder

Will move all .py and .sh files

mkdir foo bar

This creates files foo/a, foo/b, ... foo/h, bar/a, bar/b, ... bar/h

touch {foo,bar}/{a..h} touch foo/x bar/y

Show differences between files in foo and bar

diff <(ls foo) <(ls bar)

Outputs

< x

---

> y

```

编写shell脚本有时候是奇妙并且违反直觉的,有一些类似shellcheck的工具可以帮助你检查你的sh/bash脚本中的错误。

注意,脚本并不是一定需要写在bash中才能被终端调用。比如说,你这里有一个简单的Python脚本,可以反向输出它得到的参数:

内核知道这是一个Python脚本,而不是shell命令,是因为我们在脚本头部引入了shebang。在脚本当中写入shebang是一个很好的习惯,当你不确定命令调用的程序在什么位置时,可以使用env命令,可以增加你的脚本的可移植性。

env将会使用我们上节课提到的PATH环境变量来寻找合适的程序。举个例子:#!/usr/bin/env python

shell函数和脚本存在一些差异,你需要牢牢记住:

  • shell中函数需要是相同语言编写的,而脚本可以是任何语言写的。这也是我们为脚本引入shebang的原因
  • 函数的定义只会被加载一次,而脚本每次运行的时候都会被加载一次。这就使得函数导入的时候会稍微快一点,不过每次它被修改的时候,你都需要重新导入
  • 函数时在当前shell环境当中执行的,而脚本则会在它们独自的进程当中运行。所以函数可以修改环境变量,比如修改你的当前路径,而脚本不行。脚本可以通过环境变量获得一些被export关键字导出的值
  • 就和其他语言一样,函数是一个很有用的结构,它可以提升程序的富有性、shell代码的简洁性以及模块化。通常,shell脚本会导入它们的函数定义

Shell Tools

寻找如何使用命令

现在,你可能想要知道,怎么样知道命令中那些flag的用法,比如ls -l, mv -i, mkdir -p。准确地说,给定一个命令之后,我们要如何找到它的使用说明,知道各个选项的用法?你当然可以使用谷歌,但Unix早于Stack Overflow,它有内置的方法可以获取信息。

就和我们上节课看到的一样,最先考虑的方法是使用使用参数-h--help。更细节的话,是使用man命令。man命令是manual的缩写,它为这个命令提供一个人工说明页面(叫做manpage)。比如,man rm会输出rm命令和它所有的flag的用法。包括我们之前展示过的-i

除了原生的命令之外,通过其他渠道安装的一些命令一样可以使用man来查看manpage。只要它的开发者为它编写了对应的manpage,并且将它加入了安装过程。

对于一些交互式的工具来说,帮助信息通常可以通过在程序中输入:help或者?来查看。

有时候manpage提供的信息太多太细节,使人难以解读应该使用什么flag。这个时候我们可以使用TLDR命令,它会提供命令的一些具体例子,这样我们可以快速找到我们需要的选项。比如我就常常参考tarffmpeg命令的tldr页面,而不是manpage。

寻找文件

一个所有程序员都经常会遇到的问题是寻找文件或文件夹。所有类Unix系统都提供了find命令,它是一个查找文件的非常好用的工具。find将会递归式地根据一些标准查找匹配的文件,一些例子:

除了列出文件之外,find命令还可以根据你的查询执行一些操作。对于一些单调的重复性操作会非常有帮助。

比如批量删除所有.tmp的临时文件,或者是批量将.png文件转换成.jpg

虽然find工具很好用, 但有时候它的语法很难记住。比如你想要根据某个模式PATTERN找到匹配的文件,你需要运行find -name '*PATTERN*',如果你要忽略大小写,则需要使用-iname

针对这个场景,你可以创建一些别名,但shell哲学中,你还可以探索替代品。记住,shell中最好的一个属性是,它仅仅是调用程序的,所以你可以找到或者是干脆自己针对某一个问题写一个替代品。

比如说fd是一个简单快速,并且好用的find替代品。它提供许多默认的功能,比如说彩色输出、正则表达式匹配以及支持unicode。它拥有一个我个人认为更直观的语法,比如说当你想要找到一个模式PATTERN,你可以仅仅输入fd PATTERN

大多数人都同意findfd非常好,但你们可能也会好奇,这样搜索文件和使用一些编译语言或者数据库进行快速搜索的方法相比如何。这就是locate命令的原理,.locate使用一个被updatedb更新的数据库。在大多数系统当中,updatedb会被定时调度,以天为单位进行更新。

所以这两种方法在数据的时效性和性能上有一个权衡,另外,find和类似的工具可以根据其他的一些特性比如文件大小、修改时间、权限等进行查找。而locate只能使用文件名。大家感兴趣可以进一步调研这两者的差别。

查找代码

通过文件名查找文件非常方便,但也经常会希望根据文件中的内容进行查找。

比如我们可能会希望搜索所有包含了某个特定pattern的文件,以及这些pattern出现的位置。为了实现这一点,大多数类Unix系统提供了grep工具,它可以从输入文本中进行模式匹配。grep在shell工具当中非常重要,在之后的内容当中我们还会详细阐述。

现在,我们知道grep有许多flag,让它变得非常通用。我个人经常使用-c来获取匹配行的上下文,以及-v来翻转过滤,比如说打印出所有没有匹配上的内容。

grep -C 5将会在匹配之后输出5行上下文内容,如果你希望快速搜索很多文件,你可以使用-R,这样它会递归式地进入文件夹检索文件。

grep -R也有很多改进的地方,比如说忽略.git文件夹,使用多核CPU等等。有很多grep的替代工具,比如说ack, agrg。这些工具都非常好用,并且功能非常接近。我个人目前使用ripgrep(rg),它运行非常快速,并且很直观。

注意,和find/fd一样,重要的是你得知道这些问题怎么样快速通过合适的工具解决,而这些工具本身并没有那么重要。

查找shell命令

现在我们已经研究了怎么查找文件和代码,当你在shell中花费更多时间的时候,你可能会希望你能在某些时刻找到一些特定的命令。最快速的方法就是通过上箭头往上翻你之前运行的命令,但如果你一直用这种方式翻命令,显然是非常缓慢的。

history命令可以让你看到你shell中历史上所有的命令,它会通过标准输出来展示所有的记录。如果我们想要搜索一些特定的命令,可以使用grep来查找特定的模式。history | grep find将会输出包括find关键字的命令。

在大多数shell当中,你可以使用Ctrl + R来搜索你的历史记录。在按下Ctrl + R之后,你可以输入你想要搜索的命令的关键字。当你持续按下Ctrl + R,它将会在匹配的多条记录中循环查找。这也可以在zsh中设置成使用上下箭头。

我们也可以将Ctrl + R的结果和fzf绑定,fzf是一个通用的模糊查找器,它可以和许多命令一起使用。在这里,它将可以在你的历史记录中进行模糊匹配,并且以一种方便和舒服的方式进行展示。

另外一个我很喜欢的关于历史记录的工具是自动提示功能,最早被fish shell使用。这个特性可以自动地根据你当前输入的内容用前缀匹配的方式展示最近一次命令的匹配结果。它也可以在zsh中激活,这是shell的一个非常重要的技巧。

你可以修改你的历史行为,比如防止命令以空格开头。当你输入带密码或者是其它比特敏感的信息时,将会非常好用。你需要在你的.bashrc加入HISTCONTROL=ignorespace,或者在你的.zshrc中加入HIST_IGNORE_SPACE

如果你之前不小心有过输入了前导空格的命令,你可以在.bash_history或者.zhistory中手动删除它们。

路径导航

现在,我们已经假设你已经熟悉了上面这些操作。

但你怎样快速地导航到路径呢?关于这一点,有很多简单的方法。比如说可以在shell里创建别名,或者是使用ln - s创建软连接。实际上,开发者已经想出了相当聪明和好用的方案。

就像是这门课的主题一样,你需要经常对一些通用问题进行优化。可以使用fasdautojump找到频繁使用或最近使用的文件或路径,fasd对文件和路径按照使用频率和最近使用时间进行排序。fasd默认带上z命令使得你可以通过一个子串快速cd到一个最近访问过的路径。

比如,你经常去/home/user/files/cool_project,你可以使用z cool来跳转过去。使用autojump可以通过j cool命令实现类似的功能。

除此之外还有一些更加复杂的工具,比如说tree, broot甚至是完全成熟的文件管理器,例如nnnranger

练习

  1. 阅读man ls并且写一个ls命令,使得它完成以下格式:

  2. 包括所有文件,包括隐藏文件

  3. 将文件大小以人们可阅读的形式展示比如(454M 而不是 454279954)
  4. 文件按照最近访问时间排序
  5. 输出彩色结果

一个参考输出应该是这样的:

  1. 写一个bash函数macropolo。当你运行macro时,你当前工作的路径应当以某种方式被保存。当你运行polo时,无论你处在什么路径下,polo都会cd回你之前运行macro的地方。为了方便debug,你可以将代码写在macro.sh中,通过source macro.sh载入代码
  2. 假设你有一个命令很少失败,为了debug,你需要捕获它的输出,但可能会花很多时间才能重现失败。写一个bash函数,它会重复执行下列脚本,直到失败,并且捕获它的标准输出以及错误流写入文件,并在结束时打印出来。如果你还能汇报一共执行了多少次可以获得额外分数奖励

```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" ```

  1. 我们上课的时候说过find命令的exec参数非常强大,可以批量搜索处理文件。然而,如果我们想要对所有文件做一些操作,比如说创建一个zip文件,我们该怎么操作呢?就像你看到的一样,命令从参数和STDIN接收输入,当使用管道时,我们将STDOUTSTDIN结合起来。但一些命令,比如tar从参数获取数据。为了打通这两者之间的信息沟通,有一个叫做xargs的命令,可以使用STDIN当做参数来运行命令。比如ls | xargs rm将会删除当前路径下所有文件。你的任务是写一个命令,它能够递归查找当前路径下所有HTML文件,并且给它们创建zip压缩包。注意:即使文件名中包含空格,你的命令也依然需要生效。(提示,查看xargs``-dflag)。如果你是macOS,需要注意,findGNU coreutils中的不同。你可以使用find -print0以及xargs中的-0flag。作为一个mac用户,你也需要意识到,mac安装命令行工具的方法和GNU不同,你可以使用brew安装GNU版本
  2. (进阶)写一个命令或脚本来递归式地查找当前路径下最经常访问的文件。另外,你可以根据最近访问时间列出所有的文件吗?

答案

不知道大家有没有感觉到,这一次练习的难度明显提升了很多。

第一题

相对比较简单,英语不错的同学读一下man ls中的说明就可以找到答案,如果不愿意逐行读大段的英文,也可以通过搜索引擎很快找到答案。

bash ls --laht --color

-l命令是输出完整信息,包括权限以及文件大小,-a包含隐藏路径,-h将文件大小以阅读友好的方式展示,-t将文件按照创建时间排序,--colorls命令的显示结果变成彩色

第二题

逻辑不难想到,当我们执行macro时,我们需要保存下当前路径。由于当函数执行结束,函数中的变量即销毁,所以我们要把它export成全局变量。

polo函数当中,直接cd到导出的全局变量即可。

```bash macro() { export cachepath=$(pwd) }

polo() { cd $cachepath } ```

第三题

这题也不难,笔记当中有一个近似的例子。核心在于使用2>符号将错误流改写到文件中。再使用$?捕获上一次命令运行的结果,通过返回值判断有没有错误发生。

整体的逻辑不复杂,只不过shell的语法不熟悉,刚接触可能需要查一下。

bash function func() { cnt=1 ./random.sh > output.txt 2> error.txt while [[ $? -eq 0 ]] do (( cnt++ )) ./random.sh > output.txt 2> error.txt done cat error.txt echo "$cnt" }

第四题

这题题目比较长,看着有点唬人,我们需要冷静下来好好分析。

首先我们使用find -exec命令时,是针对每一个文件进行的,而我们希望针对所有文件创建一个压缩包。这就需要我们把所有find出来的文件作为压缩命令的输入。但find命令都是直接输出的,我们需要使用-print0这个flag,将它的输出改写到stdout

之后我们需要将之前的输出作为命令的参数,需要用到xargs命令。使用man xargs查看一下用法, 不难写出代码:

bash find . -path "*.html" -type f -print0 | xargs -0 zip archieve.zip

第五题

第五题和第四题类似的套路,首先我们需要找到当前文件夹下所有的文件,这可以使用find命令完成:find . -type f。接着我们需要使用ls命令对它进行排序。这里我们一样使用-print0xargs -0两个命令来进行衔接,加上ls命令的显示更多信息的参数,以及排序即可。

bash find . -type f -print0 | xargs -0 ls -lht;