Total Pageviews

Sunday, 11 August 2019

为什么你应该学点Awk(附指南及示范)

操作文本文件的神器Awk。
即便是在今天,我所认识的90%的程序员几乎从没用过awk,其实只要你花上几分钟了解哪怕只是awk 10%的语法,它都能梦幻般的提高你操作文本文件的能力。下面就让我来教你一些最有用的操作——注意,不是“最基础的”,只要你愿意花上5分钟时间练习下这些技巧,我相信你就将会领悟到我认为这门语言最为有趣的东西。
Awk实际上是一门非常有趣的小型编程语言,它设计的主要目的就是为了处理字符串。还记得有次我们的一个教授为我们布置了一道计算机网络的课程作业,让我们根据RPC协议编写一个可以生成客户端和服务端stub的程序,但是这个教授犯了一个错误,他告诉我们可以使用任何我们想要的语言,于是我决定用Awk来写一个,我原本的目的只是为了更好的掌握Awk,但是让我惊奇的是,我发现最终我的实现要比其它我用过的语言(Python,C++,Java)都更简短。
如果要了解Awk,你可以看看这本书,当然我已经看过了,但是你可能并不想像我那样用Awk来实现某个协议的一个解析器,或许你只是想从你的日志文件中找出那些IP地址加起来刚好是666的家伙,那就跟我来吧,继续往下看!
让我们先来看个例子,假设我们有一个小文件(logs.txt)看起来像是下面这样,很简单,它只有2行带有IP地址的log信息:
07.46.199.184 [28/Sep/2010:04:08:20] "GET /robots.txt HTTP/1.1" 200 0 "msnbot"
123.125.71.19 [28/Sep/2010:04:20:11] "GET / HTTP/1.1" 304 - "Baiduspider"
这是两条由Apache生成的日志信息,很简单,它们显示了Bing和Baidu的爬虫昨天到访过我的网站。
Awk同其它命令行程序(比如grep)一样,都是从stdin读取输入并写到stdout,所以你可以很容易的通过管道使用它,Awk的使用也很简单,你唯一需要关心的它令后面的那个字符串参数:
awk '{print $0}'
大部分awk程度都是以{开头,并以}结束的,{}中包含的命令会在输入中的每一行上执行,大部分awk程序都会打印些什么东西,上面的命令会原封不动的将它读到的输入再打印出来,并添加一个换行,$0代表一整行。所以这个程序很典型——它除了将输入拷贝到输出,其它什么都没做。
Awk还会根据输入行中的空白字符(空格,tab)自动将行切分为字段,并自动处理连着的分隔字符,你可以通过$1,$2,$3等来引用分割后的字段。
echo 'this is a test' | awk '{print $3}' // prints 'a'
awk '{print $1}' logs.txt
输出:
07.46.199.184
123.125.71.19
很简单,并且很有用,不是吗?但是有时我需要从字符串的结尾开始打印,这时我可以使用一个名为NF的特殊变量,它包含了当前行的字段数,因此我可以使用$NF来打印最后一个字段,我也可以通过这个值来反向查找某个字段,同时我还可以在一行打印多个值:
echo 'this is a test' | awk '{print $NF}' // prints "test"
awk '{print $1, $(NF-2) }' logs.txt
输出:
07.46.199.184 200
123.125.71.19 304
更进一步——现在你可以看到,我们可以将日志文件分割,并只打印我们关心的部分。另一个很有用的变量就是NR,这个变量表示当前正在处理的输入的行号。在我演示NR的同时,我还想展示一下如何使用print来格式化输出,你可以使用逗号来分隔print的多个参数,它们会被转化为空格,但是下面的例子我没有使用逗号,所以结果中也没有插入空格:
awk '{print NR ") " $1 " -> " $(NF-2)}' logs.txt
输出:
1) 07.46.199.184 -> 200
2) 123.125.71.19 -> 304
很强大,并且一点也不难,对吧,如果你喜欢,你还可以使用printf函数,或许你对它更熟一些。现在,问题来了,不是所有字段都是以空格作为分隔符的,比如下面这个:
$ awk '{print $2}' logs.txt
输出:
[28/Sep/2010:04:08:20]
[28/Sep/2010:04:20:11]
日期字段使用了/和:作为分隔符,当然我可以通过一条awk命令来完成这个操作,但是我想向你演示一种更简单也是你更为熟悉的方式:Unix管道,下面我要做的就是通过管道使用另外一条awk命令来根据冒号对日期进行分割,要做到这一点,我的第二个awk命令需要使用2个{},不过我暂时不想对这个多做解释,你只要看看我是如何做的就行了:
$ awk '{print $2}' logs.txt | awk 'BEGIN{FS=":"}{print $1}'
输出:
[28/Sep/2010
[28/Sep/2010
我为FS(也就是字段分隔符)指定了一个不同的值,也就是":",然后打印出了第一个字段,现在没有时间了,只有日期!如果你不想看到输出中的那个[,最简单的方法就是使用sed,或许你早就知道了:
$ awk '{print $2}' logs.txt | awk 'BEGIN{FS=":"}{print $1}' | sed 's/\[//'
输出:
28/Sep/2010
28/Sep/2010
使用相同的技巧,我还可以进一步根据'/‘字符来切分日期,但是我认为你已经掌握这个技巧了,所以我就不罗嗦了,下面,让我们来试着加入一点点逻辑判断,如果我只想得到状态为200的行,我可以使用grep,但是如果我想得到IP地址中包含200的行,或者是日期在2000年以后的行,那我就需要先使用awk,然后在通过grep来查找了,不过的问题是输出结果没了上下文,还好,Awk支持if条件判断,所以我们可以像下面这样:
$ awk '{if ($(NF-2)=="200") {print $0}}' logs.txt
输出:
07.46.199.184 [28/Sep/2010:04:08:20] "GET /robots.txt HTTP/1.1" 200 0 "msnbot"
现在,只有符合条件的行才会被输出,这个if语句实在是太熟悉不过了,应该不需要我做过多的解释,下面让我通过一个蠢例子向你演示下awk如何实现跨行保存状态,假设我想得到这个文件中所有HTTP相应的状态字段的和,虽然我实在想不出有有什么理由这样做,但是为了演示字段求和,就让我们先忘掉这一点吧,要做到这一点,我只需要创建一个变量就okay了:
$ awk '{a+=$(NF-2); print "Total so far:", a}' logs.txt
输出:
Total so far: 200
Total so far: 504
很显然,这是有问题的,大部分情况下,我并不需要中间值,只需要最后的结果就可以了,当然我可以使用tail -n1,但是还有一种更好的方式,我可以使用END关键字告诉awk只在最后一行调用print:
$ awk '{a+=$(NF-2)}END{print "Total:", a}' logs.txt
输出:
Total: 504
如果你想要了解更多关于awk的知识,有几本好书以及大量的线上资源可供你参考,只需要花上一些闲暇时间,你就可以很容易的学到关于awk的一切,但是要熟练使用它,还是有些挑战的,因为它的编码方式实在有点特别——你实际上是在写一个for循环的内部实现,仔细想想,这其实有点MapReduce的感觉,只是一开始会让人有些迷惑。
我希望这篇文章能对你有用,如果你觉得它有用,不妨给我留言。
更新: 这篇文章的许多评论都很值得一读,当然我很希望它们都能在一个地方,但现在我只能把它们分别列出来:
最后,如果你是那种对awk感兴趣的人,毫无疑问,你就是那种我希望可以在Google共事的人,如果你感兴趣,可以给我简历(ggrothau@gmail.com),我可以确保它一定会出现在合适的招聘者面前,而不是遗落在我们每天收到的那一大堆简历中。
---------------
Linux下几种去重复的方法的比较
来比较下Linux下对数据去重处理的几种方式,更具体一点讲就是比较sort -u、uniq、awk '!x[$0]++' 等方法的效果与效率。
输入文件为Nginx的日志文件,共100万行,文件名为nginx.log,其格式为“时间|用户IP|主机名|发送流量(字节)|下载流量(字节)|耗时(毫秒)”
具体内容截取如下(部分内容已被隐藏):
2018-03-03T20:00:52+08:00 | 116.237.***.205 | *****.********.com | 780 | 4319 | 1.495
2018-03-03T20:00:52+08:00 | 39.181.***.45 | *****.********.com | 3820 | 3777 | 1.480
2018-03-03T20:00:52+08:00 | 101.87.***.203 | *****.********.com | 617 | 4427 | 29.249
2018-03-03T20:00:52+08:00 | 183.206.***.72 | *****.********.com | 2481 | 5164 | 20.894
2018-03-03T20:00:52+08:00 | 59.56.***.98 | *****.********.com | 825 | 4425 | 1.679
2018-03-03T20:00:53+08:00 | 27.198.***.59 | www.******.**.** | 287 | 1034 | 240.426
2018-03-03T20:00:53+08:00 | 112.67.***.155 | *****.********.com | 2914 | 726 | 40.238
2018-03-03T20:00:53+08:00 | 153.34.***.97 | *****.********.com | 552 | 4420 | 185.736
2018-03-03T20:00:53+08:00 | 113.232.***.0 | www.******.com | 124 | 3221 | 1.022
接下来分别采用上面几种不同的方式去重提取出用户IP地址并比较其效率,每种方法各执行三次取消耗时间的平均值。注意,我这里仅仅保留了IP字段,请灵活运用!如保留整行的话请替换$2为$0。
第一种方式:sort+uniq:
提到文本去重,Linux下有个uniq工具,顾名思义就是用来取唯一值的嘛!uniq走起!但,但是,,,uniq命令只能对相邻的重复行去重,所以在使用uniq前需要先排序,使相同内容的行相邻,再去重才行,于是就有了下面的命令:
time awk 'BEGIN{FS="|"}{print $2}' nginx.log|sort|uniq >/dev/null
耗时:0m7.609s、0m7.538s、0m7.782s 平均:7.643s
注意:这种去重方式会改变待处理内容原本的行序!
第二种方式:sort -u:
uniq使用前还得保证相同行相邻,是不是觉得很鸡肋?sort命令其实是有取唯一值功能的,只需加个-u参数即可:
time awk 'BEGIN{FS="|"}{print $2}' nginx.log|sort -u >/dev/null
耗时:0m6.242s、 0m6.136s、0m5.920s 平均:6.099s
可以看到直接使用sort -u比sort后再uniq要节省一点时间!这种方式同样也是会改变原本的行序的。

第三种方式:awk:
哇!重量级工具出场了!awk是一个非常强大的文本分析处理工具,用起来非常爽!awk支持关联数组,下面三条命令原理和效果基本是一样的,都是把IP作为key进行处理,区别是前两条是直接把第一次出现的IP输出出来,非第一次出现的IP直接忽略;第三条是,遍历所有行后直接输出key实现去重操作。
#只保留第一次出现的字段,去除后续其他字段
time awk 'BEGIN{FS="|"}{print $2}' nginx.log|awk '!x[$0]++' >/dev/null
#只保留第一次出现的字段,去除后续其他字段
time awk 'BEGIN{FS="|"}{if(!x[$2]++){print $2}}' nginx.log >/dev/null
#乱序
time awk 'BEGIN{FS="|"}{a[$2]}END{for(i in a)print i }' nginx.log >/dev/null
#只保留最后一次出现的字段,去除之前其他字段(这条是后续加的,未测试耗时)
awk 'BEGIN{FS="|"}{a[$2]=NR;b[NR]=$2}END{n=asort(a);for(i=1;i<=n;i++)print b[a[i]]}' nginx.log
耗时:
第一条:0m2.362s、0m2.405s、0m2.052s 平均:2.273s
第二条:0m2.116s、0m2.130s、0m2.151s 平均:2.132s
第三条:0m2.238s、0m1.944s、0m2.251s 平均:2.144s
这种利用awk关联数组的去重方式无需进行耗时的排序操作,也就没有那么高的时间复杂度,当处理的内容较多时,效率优势就非常明显了,而且前两条命令是不会改变原内容出现先后顺序的,各行出现的顺序还是原来的!可以说是非常完美的一种去重方式了!注意这里的第一条的第二条完全是一个东西,只是写法稍微有点不同罢了,只保留最后出现的字段还可以使用tac反序再去重然后再反序的思路。
总结:当待去重的数据量非常大或者有保留原有行序需求的时候,应该选择使用awk对数据进行去重!其他情况下嘛!看心情随便选一个就行~~~

No comments:

Post a Comment