名校课程推荐 | MIT《CS 实用工具课程》-数据整理

数据整理

你是否有过大量文本,要对它进行处理?肯定有过对吧,这就是数据整理要做的!具体来说,就是将数据从一种格式转换成另一种格式,直到最终得到我们想要的结果。

我们已经见过一些数据整理的基本技术,比如journalctl | grep -i intel,它会

  • 找到所有包含intel(不区分大小写)的系统日志

  • 大多数情况下,数据整理需要你知道有哪些工具,并且了解如何组合使用这些工具。

我们从头说起:数据整理需要数据源以及与之相关的场景。日志处理通常是一个比较典型的使用场景,因为我们经常需要在日志中查找某些信息,这种情况下通读全部日志内容是不现实的。我们可以通过查看服务器日志,看看哪些用户尝试登录我的服务器:

ssh myserver journalctl

内容很多,我们把它限制到ssh的内容:

ssh myserver journalctl | grep sshd

注意,我们这里使用管道将远程服务器上的文件传递给本地计算机的grep程序!ssh很厉害。这里的内容仍然比我们想要的要多得多,读起来也很费劲。我们优化一下:

ssh myserver journalctl | grep sshd | grep "Disconnected from"

现在还是有很多无用信息。有很多方法可以改进这点,但现在我们先来看看工具箱中最厉害的一个工具:sed

sed是基于原来ed编辑器构建的一个“流编辑器”。在sed中,你只要给出一些简短的命令来修改文件,而不用直接操作文件的内容(尽管你也可以选择这样做)。相关的命令行非常多,但最常用的是 s,即替换命令,例如,我们可以这样写:

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed 's/.*Disconnected from //'

上面输入的这段命令是一个简单的正则表达式。正则表达式是一种非常强大的工具,可以让我们基于某种模式来匹配文本。s命令的语法如下:s/REGEX/SUBSTITUTION/, 其中REGEX是你想要的正则表达式,SUBSTITUTION是用于替换匹配结果的文本。

正则表达式

正则表达式非常常见,而且也很有用,所以值得我们花时间研究它。看一下我们上面使用的这个正则表达式:/.*Disconnected from /。正则表达式通常以(不总是这样)/ 开始和结束。大多数ASCII字符只是表示他们本来的含义,但有些字符还有表示匹配行为的特殊含义。正则表达式的实现方式不同,字符所表示的含义也会有所不同,这一点对我们来说不是很友好。非常常见的模式有:

  • . 除换行符之外的“任意单个字符“

  • * 匹配前面字符零次或多次

  • + 匹配前面字符一次或多次

  • [abc]匹配 a, bc 中的任意一个

  • (RX1|RX2) 任何能够匹配RX1RX2的结果

  • ^ 行首

  • $ 行尾

sed 的正则表达式有时候比较奇怪,它需要你在这些模式前添加\才能让它有特殊含义。或者,你也可以添加-E选项来支持这些匹配。

回过头看/.*Disconnected from /,我们会发现这个正则表达式可以匹配任何以任意字符开头,后面跟着"Disconnected from"字符串的文本。这正是我们所希望的。但是注意,正则表达式并不容易写对。如果有人用"Disconnected from"作为自己的用户名登录会怎么样?

Jan 17 03:13:00 thesquareplanet.com sshd[2631]: Disconnected from invalid user Disconnected from 46.97.239.16 port 55920 [preauth]

正则表达式会如何匹配?*+在默认情况下是贪婪模式,也就是说,它们会尽可能多的匹配文本。因此对上述字符串的匹配结果如下:

46.97.239.16 port 55920 [preauth]

这可能不是我们想要的结果。对于某些正则表达式的实现来说,您可以在 *+ 后增加一个? 后缀变成非贪婪模式,但是很可惜 sed 并不支持该后缀。不过,我们可以切换到perl 的命令行模式,该模式支持编写这样的正则表达式:

perl -pe 's/.*?Disconnected from //'

我们回到 sed 命令并用它完成后续的任务,因为sed对于这类任务来说是最常见的工具。sed 还可以非常方便地实现一些任务,比如打印匹配后的内容,一次调用中进行多次替换,搜索等。但这些内容我们这里不会过多介绍。sed 本身是一个非常全能的工具,但是在具体功能上往往能找到更好的工具作为替代品。

我们还要去掉用户名后面的后缀,应该如何操作?想要匹配用户名后面的文本,尤其是当这里的用户名可以包含空格时,这个问题就变得非常棘手!我们需要做的是匹配一整行

| sed -E 's/.*Disconnected from (invalid |authenticating )?user .* [^ ]+ port [0-9]+( \[preauth\])?$//'

我们来借助正则表达式在线调试工具regex debugger来理解这段表达式。开始部分和之前一样,然后,匹配两种类型的“user”(在日志中基于两种前缀区分),再匹配属于用户名的所有字符,再匹配任意一个单词([^]+会匹配任意非空且不包含空格的序列),然后是单词“port“和它后面的一串数字,以及可能存在的后缀'[preauth]',最后是行尾。

注意,这样做的话,即使用户名是"Disconnected from",对匹配结果也不会有任何影响,你知道为什么吗?

但是这样做有一个问题,整个日志变成了空的。我们实际上希望能够将用户名保留下来。对此,我们可以通过“捕获组(capture groups)”来完成。被圆括号内的正则表达式匹配到的文本,都会存入到编号的捕获组中。捕获组的内容可以在替换字符串时使用(有些正则表达式的引擎甚至支持替换表达式本身),例如\1\2\3等等,因此可以使用如下命令:

 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'

你可能已经意识到了,我们最终可能会写出相当复杂的正则表达式。例如,这里有一篇关于如何匹配电子邮箱地址的文章,这并不简单。网络上还有很多关于如何匹配电子邮箱地址的讨论,还编写了测试用例测试矩阵。你甚至可以编写一个正则表达式判断一个数是否为质数

正则表达式是出了名的难以写对,但是它同时也会是你强大的常备工具之一。

回到数据整理

现在我们有如下表达式:

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'

我们可以用sed进行处理,但是为什么要用sed呢?为了好玩吧

ssh myserver journalctl
 | sed -E
   -e '/Disconnected from/!d'
   -e 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'

这展示了sed的一些功能。sed还可以插入文本(使用i命令)、显式打印行(使用p命令)、按索引选择行,以及许多其他功能。详见man sed

好了,我们现在得到了所有试图登录的用户名列表。但这没什么用。我们来看一下

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会把连续出现的相同行折叠为一行,并将出现次数作为前缀。我们也希望按照出现次数排序,过滤出最常出现的用户名:

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部分表示“仅排序到第n个字段“,默认情况是到行尾。在这个例子中,针对整个行进行排序也没有任何问题,我们这里主要是为了学习这一用法!

如果我们希望过滤出登陆次数最少的用户,可以使用head来代替tail。或者使用sort -r进行倒序排序。

很好。但我们只想获取用户名,而且不要一行一个地显示。

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,

我们可以用paste命令来合并行(-s),并指定一个分隔符进行分割(-d),那awk的作用又是什么呢?

awk – 另一种编辑器

awk其实是一种编程语言,只不过它碰巧非常擅长文本处理。关于awk可以介绍的内容太多了,限于篇幅,这里我们只介绍一些基础知识。

首先,{print $2}的功能是什么?awk程序接受可选的一个模式,以及一个代码块,指定当模式匹配时应该执行何种操作。默认模式(上面命令中的用法)匹配所有行。在代码块中,$0表示整行的内容,$1$n为一行中的n个字段,字段的分割基于awk的字段分隔符(默认是空格,可以通过-F来修改)。在这个例子中,我们的代码意思是:对于每一行文本,打印其第二个字段的内容,也就是用户名。

来看看还有什么炫酷的操作。来统计一下所有以c开头、以e结尾,并且仅尝试登录一次的用户名数量。

| awk '$1 == 1 && $2 ~ /^c[^]\*e$/ { print $2 }' | wc-l

我们来好好分析一下。首先,注意这次我们为awk指定了一个匹配模式串(也就是{...}前面的那部分内容)。该匹配要求文本的第一部分需要等于1(这部分刚好是uniq -c得到的计数值),然后第二部分必须满足给定的正则表达式。代码块中的内容则表示打印用户名。然后我们用wc -l统计输出结果的行数。

不过,既然awk是一种编程语言,还记得吗?

BEGIN { rows = 0 }
$1 == 1 && $2 ~ /^c[^ ]*e$/ { rows += $1 }
END { print rows }

BEGIN也是一种模式,它会匹配输入的开头(END则匹配结尾)。现在,每行的代码块只对每一行第一个部分进行累加,最后打印结果。事实上,我们完全可以抛弃grepsed,因为awk可以解决所有问题。至于怎么做,就留给你们做课后练习吧。

分析数据

想做数学计算也是可以的!例如这样,你可以将每行的数字加起来:

| paste-sd+ | bc -l
echo "2*($(data | paste-sd+))"| bc -l

你可以通过多种方式获取统计数据。如果已经安装了R语言,st是个不错的选择:

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 --slave -e 'x <- scan(file="stdin", quiet=TRUE); summary(x)'

R也是一种编程语言,它非常适合用来进行数据分析和绘制图表。这里我们不会讲的特别详细,你只需要知道summary打印某个矩阵的统计结果。然后我们从输入的数字流中计算出一个矩阵,R可以给出我们想要的统计数据!

如果你希望绘制一些简单的图表,gnuplot可以帮助你:

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' 

利用数据整理来确定参数

有时候你要利用数据整理技术从一长串列表里找出你所需要安装或移除的东西。我们目前讨论的数据处理技术配合xargs就是很强大的组合:

rustup toolchain list | grep nightly | grep -vE "nightly-x86|01-17" | sed 's/-x86.*//' | xargs rustup toolchain uninstall

练习

  1. 如果你不熟悉正则表达式,这里有一个简短的交互式教程,里面涵盖了大部分基础知识

  2. sed s/REGEX/SUBSTITUTION/g和常规sed有什么不同?/I/m呢?

  3. 进行原地替换听上去很不错,比如 sed s/REGEX/SUBSTITUTION/ input.txt > input.txt。但是这并不是一个明智的做法,为什么?只有sed是这样吗?

  4. 用你熟悉的语言使用regex实现一个简单的grep等效工具。如果你希望输出像grep那样颜色高亮,搜索ANSI颜色转义序列。

  5. 有时候,像重命名文件这样的操作对于mv这样的原始命令来说可能很棘手。rename是实现这种操作的一个很好的工具,它的语法和sed类似。尝试创建一组名称中带空格的文件,并使用rename将其替换为下划线。

  6. 查看之前三次重启启动消息中不同的部分(参见journalctl-b选项)。你可能希望将所有的启动日志组合在一个文件中,这样可能会简单点。

  7. 使用消息的日志时间戳生成最近10次的系统启动时间数据

    Logs begin at ...
    

    systemd[577]: Startup finished in ...
    
  8. 统计words文件(/usr/share/dict/words) 中包含至少三个a 且不以's 结尾的单词个数。这些单词中,出现频率前三的末尾两个字母是什么? sedy命令,或者 tr 程序也许可以帮你解决大小写的问题。共存在多少种词尾两字母的组合?还有一个问题:什么组合不会出现?

  9. 查找类似 这个 或者这个的在线数据集。或者从这里找一些。使用 curl 获取数据集并提取其中两列数据,如果你想要获取的是HTML数据,那么pup可能会有帮助。对于JSON类型数据,可以试试jq。请使用一条指令来找出其中一列的最大值和最小值,用另外一条指令计算两列之间差的总和。

客服