日拱一卒,MIT教你耍帅,炫酷无比的命令行用法

2022-09-21 11:13:00 浏览数 (1)

作者 | 梁唐

出品 | 公众号:Coder梁(ID:Coder_LT)

大家好,我是梁唐。

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

这次老师讲课的内容依然是关于命令行,不过和之前对一些简单的命令进行科普不同。这一堂课主要是针对一些数据处理的特殊场景,讲解一些比较fancy的命令和工具的使用。基于这些命令和工具,我们可以非常简单,甚至只用一行代码就完成一些看起来比较复杂的数据处理。

我个人感觉还挺有意思的,哪怕只是死记硬背几个,用到的时候耍个酷也很好玩。

这节课和第一节课是同一个老师,口音很不错,语速也不会太快,我个人觉得还蛮适合来练习听力的。提供一下关于这节课的一些资料信息。

首先是B站的视频链接:https://www.bilibili.com/video/BV1ym4y197iZ?spm_id_from=333.999.0.0

这个中英文精校的up主只更新到了第四节课,换句话说以后就没有熟肉视频看了。不得不说还是挺可惜的,尤其是下节课的又是西班牙老师,怎么说呢,也不能吐槽,只能怪我们自己英语不够好吧。且行且珍惜,且听且成长吧。

然后是官方笔记的链接:https://missing.csail.mit.edu/2020/data-wrangling/

这节课其实老师上课演示的时候讲的内容不算非常多,如果比较赶时间的同学也可以只看笔记或者是本文,本文是基于老师上课内容以及笔记的一个翻译整理版本。因为本人各方面水平有限,所以有些错误在所难免,希望大家多多包涵。

好了,废话不多说了,让我们开始今天的课程吧。

前言

你有没有过这样的需求:将某种数据从一种格式转换成另外一种格式?

当然你有了!这节课会介绍一些这个问题下一些比较常规的做法。尤其是对数据进行一些整理,无论是文本格式还是二进制格式,直到它变成你最终想要的结果。

我们在之前的课程当中已经见过了一些基础的数据处理的case,尤其是当你使用管道命令 | 的时候,其实某种程度上你就是在做数据整理。

假设现在我们有这么一条命令journalctl | grep -i intel。它会找到系统当中所有提到intel的log。你可能不会觉得这也能算是数据处理,但它确实从一种形式(你的系统log)转换成了另外一种更加好用的格式(intel日志条目)。

大多数数据整理是关于熟悉哪些工具你可以使用,以及如何将它们组合起来。

让我们从头开始,我们需要两样东西来做数据处理:待处理的数据,以及一些处理数据的工具。日志是一个常用的好例子,因为我们总是需要用到它,并且阅读完整的日志总是很麻烦。让我们通过服务器日志来看看,谁经常登录我的服务器:

这会返回非常大量的数据,让我们通过ssh来做一点限制:

注意,我们在一个远程的文件流中使用了管道命令,将它传输到了本地的命令grep上。ssh命令非常神奇,我们将会在之后的课程上讲述更多关于它的使用方式。即使我们做了过滤,数据也依然非常多,很难读,让我们继续优化:

代码语言:javascript复制
ssh myserver 'journalctl | grep sshd | grep "Disconnected from"' | less

为什么要加上引号呢?因为我们的日志数量太大了,如果全部拉到本地再进行过滤非常浪费时间。所以我们可以在远程服务器上进行过滤,只获取过滤之后的结果。less命令将会给我们一个分页器,允许我们在一个很长的输出结果当中上下翻页。

为了节约时间,我们还可以把当前获取到的过滤之后的结果存入文件当中,这样我们就不用每次都联网获取数据了:

代码语言:javascript复制
$ ssh myserver 'journalctl | grep sshd | grep "Disconnected from"' > ssh.log
$ less ssh.log

数据当中仍然有许多噪音,我们有很多种方法来解决它。我们可以使用一种非常强大的工具:sed

sed是一个流编辑器,它基于非常古老的ed编辑器。我们可以使用很短的命令来修改文件,而不是对整个内容直接编辑。关于sed有非常多的命令,其中最常用的是s代表替换,比如,我们可以这样写:

代码语言:javascript复制
ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed 's/.*Disconnected from //'

我们只是编写了简单的正则表达式,正则表达式是一个强大的文本匹配的结构。s命令的结构是s/REGEX/SUBSTITUTION/REGEX是你想要搜索匹配的正则表达式,SUBSTITUTION是你想要替换的内容。

相信你已经注意到了,这里的搜索和替换的命令和vim中的命令非常相似。实际上它们的确拥有非常相似的语法,在你学习新的工具时,一些旧知识可以帮你触类旁通学得更快。

Regular expressions

正则表达式是一个通用且非常好用的工具,它值得我们花费一点时间去学习它的原理。我们先从我们刚刚使用到的命令/.*Disconnected from /开始。

正则表达式通常被/包裹,大多数ASCII字符代表它们原本的含义,但也有一些特殊字符拥有特殊的含义。正因此,在不同的表达式中,同样的字符可能表示不同的含义,这也是很多人被劝退的原因。常用的匹配模式有:

  • .表示匹配任意单个字符
  • *匹配0个或任意多个它之前的字符
  • 匹配一个或多个它之前的字符
  • [abc]匹配括号内的任一字符,a或b或c
  • (RX1 | RX2)表示匹配RX1RX2
  • ^匹配一行开头
  • $匹配一行结束

sed使用的正则表达式有一些奇怪,它需要在特殊符号之前加上,或者你可以传入参数-E。我们回顾一下刚刚用到的/.*Disconnected from /,我们可以看到它在开头匹配任何文本,接着匹配Disconnected from这也是我们希望的。

但有的时候,正则表达式会有trick,如果我们拿到的日志里的用户名叫做Disconnected from会怎样?我们将会获得:

代码语言:javascript复制
Jan 17 03:13:00 thesquareplanet.com sshd[2631]: Disconnected from invalid user Disconnected from 46.97.239.16 port 55920 [preauth]

我们最终将会得到什么结果?

由于*, 在默认情况下是贪婪匹配的,即它们会尽可能多地匹配文本。因此在上面的例子当中,我们将会得到这样的结果:

这可能不是我们想要的,因为我们想要的用户名没有了。在一些正则表达式当中,你可以使用后缀* 或者?来让它不再是贪婪的。但很遗憾的是,sed不支持这种语法。我们可以切换到perl的命令行模式,它支持这种结构:

在接下来的工作当中,我们将继续使用sedsed可以做其他一些方便的事情,比如打印匹配的行,每次调用做多次替换,搜索一些结果等等。但我们这里不会讲解太多,sed是一个非常完整的话题,但我们常常有更好的工具。

好了,我们现在仍然有一些后缀是我们不想要的,我们要怎么做呢?

仅仅匹配username后面的内容有一些棘手,尤其是用户名当中可能还会有空格之类的情况下。我们需要做的是匹配整行:

代码语言:javascript复制
sed -E 's/.*Disconnected from (invalid |authenticating )?user .* [^ ]  port [0-9] ( [preauth])?$//'

让我们在regex_debugger网站(https://regex101.com/r/qqbZqh/2)当中看一下这个命令:

开头和之前一样,然后我们匹配了"user"的变体(日志当中有两个前缀)。接着我们匹配了用户名对应的所有字符。接着我们匹配了一个单个字符[^ ] ;表示非空格字符组成的非空串。再然后是单词"port",之后是一系列数字。

最后是后缀[preauth],再是行尾。

可以注意到,Disconnected from这样的用户名不会再困扰我们了,你能看出来原因吗?

但这仍然有一个问题,就是我们整个日志会变成空的。然而我们希望的是保留用户名。为此,我们可以使用capture groups。任何文本匹配了被括号包围的表达式语句都会被存入capture group当中。这可以在替换的时候被用到:1, 2, 3

代码语言:javascript复制
sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]  port [0-9] ( [preauth])?$/2/'

就像你看到的一样,我们写出了非常非常复杂的正则表达式。

这其实是正常现象,比如有一篇文章讲的是写出一个匹配email格式的正则表达式,它是这样的:

所以正则表达式并不是一个简单的事情,关于它有非常多的讨论。人们写了很多测试样例,你甚至可以通过正则表达式来判断一个数是否是质数。

正则表达式是出了名的难搞,但把它放进你的工具箱,也能帮到你很多。这一段如果看不懂可以去看下视频,老师在课上讲得很好,因为有演示所以很容易理解,只看文章会比较困难。

Back to data wrangling

回到数据处理,现在我们有了命令:

代码语言:javascript复制
ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]  port [0-9] ( [preauth])?$/2/'

sed还能做很多其他有意思的事情,比如说插入文本(使用i命令),显示打印数据(使用p命令),通过下标选择行,以及其他很多内容。通过man sed来查看!

现在,我们过滤出了尝试登录我服务器的用户名单。但这依然很多,所以我们来看看最常出现的那些:

代码语言:javascript复制
ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]  port [0-9] ( [preauth])?$/2/'
 | sort | uniq -c

sort将会将它的读入进行排序,uniq -c将相同的行聚合到一起,先输出该行出现的次数,再输出对应的内容。我们希望排序之后保留最常出现的用户名:

代码语言:javascript复制
ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]  port [0-9] ( [preauth])?$/2/'
 | sort | uniq -c
 | sort -nk1,1 | tail -n10

sort -n将会按照数字大小来排序,而不是按照字典序。-k1,1表示按照空格分隔之后仅仅使用第一列的结果进行排序。接着,n表示排序至第几个字段,默认是行末。

在这个case当中,我们排序到行末也没有影响,这里只是为了学习这个特性。

如果我们想要最少出现的那些,我们可以使用head而不是tail,我们也可以使用sort -r按照降序排序。

但如果我们仅仅想要用户名,并且将这些用户名按照逗号分割写进一行,应该怎么办呢?

代码语言:javascript复制
ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]  port [0-9] ( [preauth])?$/2/'
 | sort | uniq -c
 | sort -nk1,1 | tail -n10
 | awk '{print $2}' | paste -sd,

如果你使用的是Mac系统,该命令可能无法生效。因为paste命令在macOS下没有。

这里的paste命令让你能以给定的分隔符(-d)合并多行(-s),但这里的awk是干嘛的呢?

awk – another editor

awk是一个非常擅长处理文本流的编程语言,如果你想要仔细学习它,篇幅可能非常大。所以这里只会介绍最基本的用法。

首先,{print 2}做了什么?awk程序的模式是给定一个可选的模式再加上一个花括号包裹的代码块来说明如果该模式与给定的行匹配该怎么做。默认的模式(我们刚才用的)是匹配所有行。在代码块当中,0表示整行文本,1到n表示akw分隔分出的第n个字段(默认是空格,可以通过-F修改)。

在这个case当中,我们针对每一行都打印它的第二个字段,而这个字段就是我们要的username。

让我们来看看能不能做一些更复杂的事情,比如说找出只登录了一次,并且以c开头以e结尾的用户名:

代码语言:javascript复制
awk '$1 == 1 && $2 ~ /^c[^ ]*e$/ { print $2 }' | wc -l

这里有很多需要解释的,首先可以注意到我们有一个模式,这个模式要求第一个字段等于1,也就是uniq -c输出的数量,然而第二个字段必须要匹配正则表达式:/^c[^ ]*e$

最后的代码块表示我们输出username,最后我们统计一下一共有多少行满足条件被输出了:wc -l

然而,我们说过awk是一个编程语言,还记得吗?

代码语言:javascript复制
BEGIN { rows = 0 }
$1 == 1 && $2 ~ /^c[^ ]*e$/ { rows  = $1 }
END { print rows }

BEGIN是一个模式,它匹配input的开头,END匹配输入的结尾。

现在,每一行块会将rows变量加上$1即第一个字段的值,在这里它永远等于1,表示多了一个匹配。最后输出统计结果。

实际上,我们也可以不用使用grepsed因为awk完全可以搞定这些事。关于这个问题我们将留给读者去解决。

Analyzing data

通过使用bc你可以直接在你的shell里做数学运算,bc是一个从STDIN读入数据的计算器。比如,我们可以把每一行的数字通过 号连在一起:

代码语言:javascript复制
paste -sd  | bc -l

或者一些更精细的表达:

代码语言:javascript复制
echo "2*($(data | paste -sd ))" | bc -l

你也有很多方法可以对数据进行统计,st是一个很好的办法,如果你已经学过R语言,还可以这么写:

代码语言:javascript复制
ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]  port [0-9] ( [preauth])?$/2/'
 | sort | uniq -c
 | awk '{print $1}' | R --no-echo -e 'x <- scan(file="stdin", quiet=TRUE); summary(x)'

R是另外一门奇怪的编程语言,非常擅长数据分析和画图。我们不会过多深入,简单来说,summary将会打印数据的一个汇总信息,我们创建了一个包含input流的向量,R根据这个向量给了我们想要的结果。

如果你想要简单的图标,gnuplot会很好用。

代码语言:javascript复制
ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]  port [0-9] ( [preauth])?$/2/'
 | sort | uniq -c
 | sort -nk1,1 | tail -n10
 | gnuplot -p -e 'set boxwidth 0.5; plot "-" using 1:xtic(2) with boxes'

Data wrangling to make arguments

有时候你想要用数据清理来批量安装或卸载,我们刚才谈论的技术加上xargs工具会是一个很有效的组合。

比如像是课上我展示的一样,我可以使用接下来的命令来批量提取旧版本的nightly名称从而来卸载它们。这需要使用数据清洗加上xargs命令:

代码语言:javascript复制
rustup toolchain list | grep nightly | grep -vE "nightly-x86" | sed 's/-x86.*//' | xargs rustup toolchain uninstall

Wrangling binary data

目前为止,我们已经谈论了清洗文本数据,但管道对于二进制数据一样有效。比如,我们可以使用ffmpeg来从摄像头捕获一张图片,将它转成灰度数据压缩再通过ssh将它传到我们远程的服务器中解压拷贝再展示出来:

代码语言:javascript复制
ffmpeg -loglevel panic -i /dev/video0 -frames 1 -f image2 -
 | convert - -colorspace gray -
 | gzip
 | ssh mymachine 'gzip -d | tee copy.jpg | env DISPLAY=:0 feh -'

这个环节上课也做了展示,看视频会更加直观。

Exercises

  1. 完成交互式的正则表达式的教程:https://regexone.com/
  2. 找出在/usr/share/dict/words中至少包含3个a又不是以a结尾的单词的数量。给出3个这些单词最频繁的最后两个字母,sed y命令或者是tr命令可以帮助你解决大小写敏感的问题。这些字母组合一共有多少个?以及最难的挑战,哪一个组合没有出现过?
  3. 对于文本做原地替换看起来很诱人,比如sed s/REGEX/SUBSTITUTION/ input.txt > input.txt,然而这并不是一个好主意,为什么?sed是特例吗?请阅读man sed来找出这种情况的解决方案
  4. 找出你系统平均、中位数以及最大的开机时间,对于Linux系统可以使用journalctl,对于macOS可以使用log show。以及找出每次开机记录的开始和结束的时间戳。在Linux上,它看起来是这样的:

在macOS上,看起来是这样的:

  1. 寻找启动信息中,过去三次重启不共享的信息。将这个任务拆分成多个步骤。首先找到过去三次重启的日志。你使用提取开机日志的命令当中应该有一个flag可以完成,或者你可以使用sed '0,/STRING/d'来移除match STRING的所有行。接着,移除行中每次都变化的值,比如时间戳。接着,对输入行进行去重,对每一个部分进行计数(uniq可以用)。最后,取出数量不等于3的行
  2. 找一些网上的数据集,比如https://stats.wikimedia.org/EN/TablesWikipediaZZ.htm,https://ucr.fbi.gov/crime-in-the-u.s/2016/crime-in-the-u.s.-2016/topic-pages/tables/table-1或者从这里找一个:https://www.springboard.com/blog/data-science/free-public-data-sets-data-science-project/。使用curl命令来获取它,并且提取出是数字的两列。如果你获取HTML数据,pup会很好用。对于JSON数据来说,试试jq。使用一行命令找到一列的最小和最大值,在另外一条命令中算出两列之和的差值
答案
  1. 第一题是给大家自己练习的,虽然是英文的,但并不难懂。如果实在是觉得吃力,配合翻译软件基本上没什么太大的问题。

我做了一下,连基础带提高,一共有23篇练习。快的话,一个小时不到就可以刷完。刷完之后,熟悉正则表达式的基本用法问题不大。质量很不错,非常建议。比看枯燥无聊的教程说明好多了。

第二题有点难,算是一个比较综合性的应用。

首先,我们要先对单词进行大小写转换,我没查到sed y命令的语法,所以只能使用tr进行转换:

代码语言:javascript复制
cat words | tr "[:upper:]" "[:lower:]"

其次,我们要找出其中包含三个a,并且又不是以a结尾的单词。这需要我们使用正则表达式来完成,首先是3个a。这3个a可以连续也可以不连续,我们可以写成:(.*a){3}表示若干个字母带上a的组合,出现3次。又说不能以a结尾,我们写成[^a]$。中间再加上.*作为衔接,再管道接上wc -l计算数量整个命令就是:

代码语言:javascript复制
cat words | tr "[:upper:]" "[:lower:]" | grep -E "^(.*a){3}.*[^a]$" | wc -l

不同的电脑上跑出来结果不同,我的Mac的结果是5471

接下来我们要统计最后两个字母出现的频次,找出其中出现次数最多的3个。我们可以使用sed命令,利用正则表达式过滤出这部分。这里的正则很简单,我们只需要捕获最后的两个字母,其余的全用.*匹配即可。

接着是sort, uniq再sort,最后tail取出最后3个即可

代码语言:javascript复制
cat words | tr "[:upper:]" "[:lower:]" | grep -E "^(.*a){3}.*[^a]$" | sed -E "s/^.*([a-z]{2})$/1/" | sort | uniq -c | sort | tail -n3

接着我们要找出所有没有出现过的字母组合,这部分说实话有点麻烦。首先,我们要找出所有出现的字母组合,这部分很简单,我们只需要稍微改一下上面的命令,把统计的数字去掉,只保留字符组合,然后再排序即可。

代码语言:javascript复制
cat words|  tr "[:upper:]" "[:lower:]" | grep -E "^(.*a){3}.*[^a]$" | sed -E "s/^.*([a-z]{2})$/1/" | sort | uniq | sort > occur.txt

其次我们要枚举所有的组合,这里我们可以用脚本来做:

代码语言:javascript复制
#!/bin/bash

for i in {a..z};
do for j in {a..z};
  do echo ${i}${j}
  done
done
./enum.sh > all.txt

最后,我们用diff命令查看一下两个文件的差别,再统计行数:

代码语言:javascript复制
diff --unchanged-group-format='' occur.txt all.txt | wc -l
  1. 不能使用sed s/REGEX/SUBSTITUTION/ input.txt > input.txt的操作,因为会先执行> input.txt将后者清空。我在Stack Overflow上查到可以使用sed -i.bak操作为原文件创建一个备份。但我在man sed当中没有找到类似的用法
  2. 由于我Mac很少关机,所以这题用了我的树莓派。

这里有一个坑点,journalctl默认只会保存最近一次启动的日志。需要我们手动修改设置才可以让它存储更多日志

代码语言:javascript复制
sudo vim /etc/systemd/journald.conf

将其中的Storate设置成persistent:

之后我们多重启几次,搜集启动日志。

首先我们使用journalctl以及grep筛选出系统重启的日志:

观察一下日志会发现,每次启动的时候都会输出两条。一条200多毫秒,一条20多秒。看起来20多秒的那个才是真正的启动时间。所以我们再加上grep [1]进行过滤:

最后我们使用sed命令以正则表达式选出启动的时间。使用sed的正则非常蛋疼,因为它当中很多语法不支持……基本上只支持最基本的那些,连d,w这些符号都不支持

代码语言:javascript复制
journalctl  | grep "Startup" | grep "[1]" | sed -E "s/.*= (.*)s.$/1/"

筛选出了时间之后,我们可以仿照老师上课的例子,用R来做统计:

代码语言:javascript复制
journalctl  | grep "Startup" | grep "[1]" | sed -E "s/.*= (.*)s.$/1/" | R --no-echo -e 'x <- scan(file="stdin", quiet=TRUE); summary(x)'
  1. 这题有些繁琐,得分成好几个步骤执行。首先我们需要把前3次重启时的日志保存下来。

首先我们需要使用我们使用journalctl -b命令将对应的启动日志写入文件,这样我们就不用每次都通过journalctl获取日志了,可以直接从文件中读取。

由于启动日志里内容非常多,所以我们需要筛选出刚好是重启这部分的日志。使用题目中提示的sed命令来搞定:journalctl -b -4 | sed '0,/Startup finished/d'

这个时候还不够,日志的开头都是时间戳,这部分需要去掉。

我们可以使用捕获组,从raspberrypi后面开始捕获。

代码语言:javascript复制
cat log.txt | sed -E "s/.*raspberrypi (.*)$/1/" | head -n10

最后,我们进行老一套操作,排序、计数、排序、过滤,筛选出结果:

代码语言:javascript复制
cat log.txt | sed -E "s/.*raspberrypi (.*)$/1/" | sort | uniq -c | sort | awk '$1<3 { print }'

这一次的作业难度比之前大了很多,而且涉及的知识点也很杂,除了各种命令行工具之外,还包含了很多其他的知识点和内容。我做的时候也查阅了大量的资料,踩了不少的坑,但做完之后好处也是很明显的,就是对于命令行工具的使用明显比之前更加熟练了。

因此,推荐有需要的同学也能亲自动手尝试尝试。

喜欢本文的话不要忘记三连~

0 人点赞