原来awk真是神器啊


原创:打码日记(微信公众号ID:codelogs),欢迎分享,转载请保留出处。

简介

刚开始入门awk时,觉得awk很简单,像是一个玩具,根本无法应用到工作之中,但随着对awk的了解不断加深,就会越发觉得这玩意的强大,大佬们称其为上古神器,绝不是空穴来风。
这也可以说明,一些热门的技术知识点,如果你觉得它不过如此,那绝对是你对它的掌握不够深入,而不是它没啥用。

基本语法

awk基本语法如下:

awk 'BEGIN{//your code} pattern1{//your code} pattern2{//your code} END{//your code}'
  1. BEGIN部分的代码,最先执行
  2. 然后循环从管道中读取的每行文本,如果匹配pattern1,则执行pattern1的代码,匹配pattern2,则执行pattern2中的代码
  3. 最后,执行END部分的代码
    如下所示,分别求奇数行与偶数行的和:
$ seq 1 5
1
2
3
4
5

$ seq 1 5|awk 'BEGIN{print "odd","even"} NR%2==1{odd+=$0} NR%2==0{even+=$0} END{print odd,even}'
odd even
9 6
  1. seq 1 5用来生成1到5的数字。
  2. awk先执行BEGIN块,打印标题。
  3. 然后第1行尝试匹配NR%2==1这个pattern,其中NR为awk的内置变量,表示行号,$0为awk读到的当前行数据,显然匹配NR%2==1,则执行里面的代码。
  4. 然后第1行尝试匹配NR%2==0这个pattern,显然不匹配。
  5. 然后第2行、第3行...,一直到最后一行,都执行上面两步。
  6. 最后执行END块,将前面求和的变量打印出来,其中9=1+3+56=2+4
    这个程序还可以如下这样写:
seq 1 5|awk 'BEGIN{print "odd","even"} {if(NR%2==1){odd+=$0}else{even+=$0}} END{print odd,even}'

这里使用了if语句,实际上awk的程序语法与C是非常类似的,所以awk也有else,while,for,break,continue,exit

另外,可以看到,awk程序在处理时,默认是一行一行处理的,注意我这里说的是默认,并不代表awk只能一行一行处理数据,接下来看看awk的分列功能,可通过-F选项提供,如下:

$ paste <(seq 1 5) <(seq 6 10) -d,
1,6
2,7
3,8
4,9
5,10

$ paste <(seq 1 5) <(seq 6 10) -d, |awk -F, '{printf "%s\t%s\n",$1,$2}'
1       6
2       7
3       8
4       9
5       10

这个例子用-F指定了,,这样awk会自动将读取到的每行,使用,分列,拆分后的结果保存在$1,$2...中,另外,你也可以使用$NF,$(NF-1)来引用最后两列的值,不指定-F时,awk默认使用空白字符分列。
注意这里面的printf "%s\t%s\n",$1,$2,printf是一个格式化打印函数,其实也可以写成printf("%s\t%s\n", $1, $2),只不过awk中函数调用可以省略括号。

上面已经提到了NR这个内置变量,awk还有如下内置变量

内置变量 作用
FS 与-F功能类似,用来分列的,不过FS可以是正则表达式,默认是空白字符
OFS 与FS对应,指定print函数输出时的列分隔符,默认空格
RS 记录分隔符,默认记录分隔符是\n
ORS 与RS对应,指定print函数输出时的记录分隔符,默认\n

用2个例子体会一下:

$ echo -n '1,2,3|4,5,6|7,8,9'|awk 'BEGIN{RS="|";FS=","} {print $1,$2,$3}'
1 2 3
4 5 6
7 8 9
$ echo -n '1,2,3|4,5,6|7,8,9'|awk 'BEGIN{RS="|";FS=",";ORS=",";OFS="|"} {print $1,$2,$3}'
1|2|3,4|5|6,7|8|9,

总结:awk数据读取模式,总是以RS为记录分隔符,一条一条记录的读取,然后每条记录按FS拆分为字段。

再看看这个例子:

$ seq 1 5|awk '/^[1-4]/ && !/^[3-4]/'
1
2
$ seq 1 5|awk '$0 ~ /^[1-4]/ && $0 !~ /^[3-4]/{print}'
1
2
$ seq 1 5|awk '$0 ~ /^[1-4]/ && $0 !~ /^[3-4]/{print $0}'
1
2

可以看到:

  1. awk中pattern部分可以直接使用正则表达式,而且可以使用&&,||,!这样的逻辑运算符.
  2. 如果正则表达式没有指定匹配变量,默认对$0执行匹配,所以awk '/regex/'直接就可以等效于grep -E 'regex'.
  3. 另外pattern后面的代码部分如果省略的话,默认打印$0.
  4. print函数如果没有指定参数,也默认打印$0.
  5. 另外,一定注意awk中的正则表达式不支持\d,匹配数字请使用[0-9],因为linux中正则语法分BRE,ERE,PCRE,而awk支持的是ERE.

到这里,awk基本语法就差不多讲完了,接下来会介绍一些常用的场景。

查找与提取

示例数据如下,也是用awk生成的:

$ seq 1 10|awk '{printf "id=%s,name=person%s,age=%d,sex=%d\n",$0,$0,$0/3+15,$0/6}'|tee person.txt
id=1,name=person1,age=15,sex=0
id=2,name=person2,age=15,sex=0
id=3,name=person3,age=16,sex=0
id=4,name=person4,age=16,sex=0
id=5,name=person5,age=16,sex=0
id=6,name=person6,age=17,sex=1
id=7,name=person7,age=17,sex=1
id=8,name=person8,age=17,sex=1
id=9,name=person9,age=18,sex=1
id=10,name=person10,age=18,sex=1

然后用awk模拟select id,name,age from person where age > 15 and age < 18 limit 4这样SQL的逻辑,如下:

$ cat person.txt |awk 'match($0, /^id=(\w+),name=(\w+),age=(\w+)/, a) && a[3]>15 && a[3]<18 { print a[1],a[2],a[3]; if(++limit >= 4) exit 0}'
3 person3 16
4 person4 16
5 person5 16
6 person6 17
  1. 首先使用match函数以及正则表达式的捕获组功能,将id,name,age的值提取到a[1],a[2],a[3]中.
  2. 然后a[3]>15 && a[3]<18即类似SQL中age > 15 and age < 18的功能.
  3. 然后打印a[1],a[2],a[3],类似SQL中select id,name,age的功能.
  4. 最后,如果打印条数到达4条,退出程序,即类似limit 4的功能.

简单统计分析

awk可以做一些简单的统计分析任务,还是以SQL为例。
select age,sex,count(*) num, group_concat(id) ids from person where age > 15 and age < 18 group by age,sex这样的统计SQL,用awk实现如下:

$ cat person.txt |awk '
    BEGIN{
        printf "age\tsex\tnum\tids\n"
    }
    match($0, /^id=(\w+),name=(\w+),age=(\w+),sex=(\w+)/, a) && a[3]>15 && a[3]<18 { 
        s[a[3],a[4]]["num"]++; 
        s[a[3],a[4]]["ids"] = (s[a[3],a[4]]["ids"] ? s[a[3],a[4]]["ids"] "," a[1] : a[1])
    } 
    END{
        for(key in s){
            split(key, k, SUBSEP);
            age=k[1];
            sex=k[2];
            printf "%s\t%s\t%s\t%s\n",age,sex,s[age,sex]["num"],s[age,sex]["ids"]
        }
    }'
age     sex     num     ids
17      1       3       6,7,8
16      0       3       3,4,5

awk代码稍微有点长了,但逻辑还是很清晰的。

  1. BEGIN中打印标题行.
  2. match获取出id,name,age,sex,并过滤age>15且age<18的数据,然后将统计结果累计到s这个关联数组中。你可以把s这个关联数组想象中map,然后只是有两级key而已。(注意在awk中,拼接字符串使用空格即可,并不像java中使用+号).
  3. 最后END块中,遍历s这个关联数组,注意,类似s[a[3],a[4]]这样,在awk中是一个key,awk会使用SUBSEP这个变量将a[3],a[4]拼接起来,需要split(key, k, SUBSEP),将key按SUBSEP拆分到k中,SUBSEP默认是\034文件分隔符.

处理csv

csv是以一行为一条记录,以,号分隔为列的数据格式,awk天然适合处理csv这样的数据,但在列值中本身存在,号时,只使用-F就不行了,这时可以使用FPAT,如下:

$ echo 'aa,"bb,cc",dd'|gawk 'BEGIN { FPAT = "[^,]+|\"[^\"]+\"" } { print $3 }'
dd

FPAT变量用于提取字段值,这里指定为正则表达式,只不过这个正则表达式可以匹配类似aa"bb,cc"这种数据.

按段拆分记录

awk可以按段拆分记录,什么是段呢,看一下ifconfig的输出,如下:

$ ifconfig
eth0: flags=4163  mtu 1500
        inet 172.17.73.125  netmask 255.255.240.0  broadcast 172.17.79.255
        inet6 fe80::215:5dff:fe4c:c155  prefixlen 64  scopeid 0x20
        ether 00:15:5d:4c:c1:55  txqueuelen 1000  (Ethernet)
        RX packets 35008  bytes 5829277 (5.8 MB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 4435  bytes 7152614 (7.1 MB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 82  bytes 4100 (4.1 KB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 82  bytes 4100 (4.1 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

ifconfig的输出中,eth0与lo的数据使用空行间隔开了,像这种,用空行间隔的内容,其中eth0部分是一个段,lo部分也是一个段,而在awk中,RS=""即表示按段拆分记录,所以获取eth0网卡ip地址的方法是:

$ ifconfig|awk -v RS="" '/^eth0/{print $6}'
172.17.73.125

这里用-v RS=""来设置RS变量,与在BEGIN中效果是一样的,-v name=var是shell向awk内部传递变量的一种方法。

另外,java中jstack获取的线程栈的内容,也是以空行分段输出的,用awk来处理也很简单,比如,我们只看目前与mysql有关的线程栈,如下:

$ jstack `pgrep java`|awk -v RS="" '/mysql/'
"Abandoned connection cleanup thread" #18 daemon prio=5 os_prio=0 tid=0x00007fbb893d0000 nid=0xd75 in Object.wait() [0x00007fbb586ad000]
   java.lang.Thread.State: TIMED_WAITING (on object monitor)
        at java.lang.Object.wait(Native Method)
        at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:144)
        - locked <0x00000000e160ee38> (a java.lang.ref.ReferenceQueue$Lock)
        at com.mysql.jdbc.AbandonedConnectionCleanupThread.run(AbandonedConnectionCleanupThread.java:40)

到这里,我们不难想到,用awk来搜索异常日志,其实比grep更方便,比如java中如下的错误日志:

$ cat app_demo.log
[2020-12-23 20:12:51] [app_demo] [172.29.128.1] [INFO] [RMI TCP Connection(7)-172.29.128.1] {"logger":"o.a.catalina.core.ContainerBase.[Tomcat].[localhost].[/]","msg":"Initializing Spring DispatcherServlet 'dispatcherServlet'"}
[2020-12-23 20:12:51] [app_demo] [172.29.128.1] [INFO] [RMI TCP Connection(7)-172.29.128.1] {"logger":"org.springframework.web.servlet.DispatcherServlet","msg":"Initializing Servlet 'dispatcherServlet'"}
[2020-12-23 20:12:51] [app_demo] [172.29.128.1] [INFO] [RMI TCP Connection(7)-172.29.128.1] {"logger":"org.springframework.web.servlet.DispatcherServlet","msg":"Completed initialization in 26 ms"}
[2020-12-23 20:15:00] [app_demo] [172.29.128.1] [ERROR] [redisson-netty-1-6] {"logger":"org.redisson.client.handler.CommandsQueue","msg":"Exception occured. Channel: [id: 0x43577278, L:/127.0.0.1:61888 - R:localhost/127.0.0.1:22122]
    java.io.IOException: 远程主机强迫关闭了一个现有的连接。
        at sun.nio.ch.SocketDispatcher.read0(Native Method)
        at sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:43)
        at sun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:223)
        at sun.nio.ch.IOUtil.read(IOUtil.java:192)
        at sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:380)
        at io.netty.buffer.PooledUnsafeDirectByteBuf.setBytes(PooledUnsafeDirectByteBuf.java:288)
        at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132)
        at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:347)
        at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:148)
        at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:648)
        at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:583)
        at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:500)
        at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:462)
        at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:897)
        at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
        at java.lang.Thread.run(Thread.java:748)"}

# 使用grep直接搜索IOException,效果如下,看不到前后的调用栈了
$ cat app_demo.log |grep 'IOException'
    java.io.IOException: 远程主机强迫关闭了一个现有的连接。 

# grep可以使用-B -C指定匹配内容前面显示几行,后面显示几行,如grep -B 2 -C 10
# 效果如下,由于不知道线程栈有多深,-C有时会设置小了,有时又会设置大了
$ cat app_demo.log |grep -B2 -C10 'IOException'
[2020-12-23 20:12:51] [app_demo] [172.29.128.1] [INFO] [RMI TCP Connection(7)-172.29.128.1] {"logger":"org.springframework.web.servlet.DispatcherServlet","msg":"Completed initialization in 26 ms"}
[2020-12-23 20:15:00] [app_demo] [172.29.128.1] [ERROR] [redisson-netty-1-6] {"logger":"org.redisson.client.handler.CommandsQueue","msg":"Exception occured. Channel: [id: 0x43577278, L:/127.0.0.1:61888 - R:localhost/127.0.0.1:22122]
    java.io.IOException: 远程主机强迫关闭了一个现有的连接。
        at sun.nio.ch.SocketDispatcher.read0(Native Method)
        at sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:43)
        at sun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:223)
        at sun.nio.ch.IOUtil.read(IOUtil.java:192)
        at sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:380)
        at io.netty.buffer.PooledUnsafeDirectByteBuf.setBytes(PooledUnsafeDirectByteBuf.java:288)
        at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132)
        at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:347)
        at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:148)
        at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:648)"

# 使用awk,RS='\n\\S',效果如下,简直完美!
$ cat app_demo.log |awk -v RS='\n\\S' '/IOException/'
2020-12-23 20:15:00] [app_demo] [172.29.128.1] [ERROR] [redisson-netty-1-6] {"logger":"org.redisson.client.handler.CommandsQueue","msg":"Exception occured. Channel: [id: 0x43577278, L:/127.0.0.1:61888 - R:localhost/127.0.0.1:22122]
    java.io.IOException: 远程主机强迫关闭了一个现有的连接。
        at sun.nio.ch.SocketDispatcher.read0(Native Method)
        at sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:43)
        at sun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:223)
        at sun.nio.ch.IOUtil.read(IOUtil.java:192)
        at sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:380)
        at io.netty.buffer.PooledUnsafeDirectByteBuf.setBytes(PooledUnsafeDirectByteBuf.java:288)
        at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132)
        at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:347)
        at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:148)
        at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:648)
        at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:583)
        at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:500)
        at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:462)
        at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:897)
        at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
        at java.lang.Thread.run(Thread.java:748)"}

这里的RS='\n\\S'是一个正则表达式,记录分隔符为换行符后面带一个非空白符。

最后,指定RS='^$'可以将数据一次性的读取到$0中,这也是一个常用的小技巧,因为^$显然无法匹配任何字符,这样awk就会将所有数据读取到一条记录中了。
如下,将多行数据用,拼接为一行数据:

$ seq 1 10|awk -v RS='^$' '{gsub(/\n/, "," , $0);print $0}'
1,2,3,4,5,6,7,8,9,10,

范围匹配

awk中另一个比grep强的地方,就是awk可以使用范围过滤数据,而grep只能使用正则,比如我们要搜索2021-01-04 23:33:402021-01-04 23:34:16的日志:

# 搜索2021-01-04 23:33:40到2021-01-04 23:34:16的日志,前提是这两个时间在日志中都存在,因为awk是在遇到2021-01-04 23:33:40后,开启打印,直到遇到2021-01-04 23:34:16又关闭打印
cat app.log|awk '/2021-01-04 23:33:40/,/2021-01-04 23:34:16/'

# 另一种更有效的方式
cat app.log|awk 'match($0,/^\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2})\]/,a){
        if(a[1]>="2021-01-07 12:26:43") 
          print $0; 
        if(a[1]>"2021-01-07 12:26:56") exit
    }'

多文件join处理

awk还可以实现类似SQL中的join处理,求交集或差集,如下:

$ cat user.txt
1 zhangsan
2 lisi
3 wangwu
4 pangliu

$ cat score.txt
1 86
2 57
3 92

# 类似 select a.id,a.name,b.score from user a left join score b on a.id=b.id
# 这里FNR是当前文件中的行号,而NR一直是递增的,所以对于第一个score.txt,NR==FNR成立,第二个user.txt,NR!=FNR成立
$ awk 'NR==FNR{s[$1]=$2} NR!=FNR{print $1,$2,s[$1]}' score.txt user.txt
1 zhangsan 86
2 lisi 57
3 wangwu 92
4 pangliu

# 当然,也可以直接使用FILENAME内置变量,如下
$ awk 'FILENAME=="score.txt"{s[$1]=$2} FILENAME=="user.txt"{print $1,$2,s[$1]}' score.txt user.txt

# 求差集,打印user.txt不在score.txt中的行
$ awk 'FILENAME=="score.txt"{s[$1]=$2} FILENAME=="user.txt" && !($1 in s){print $0}' score.txt user.txt
4 pangliu

awk常用函数

函数名 说明 示例
sub 替换一次 sub(/,/,"|",$0)
gsub 替换所有 gsub(/,/,"|",$0)
match 匹配,捕获内容在a数组中 match($0,/id=(\w+)/,a)
split 拆分,拆分内容在a数组中 split($0,a,/,/)
index 查找字符串,返回查找到的位置,从1开始 i=index($0,"hi")
substr 截取子串 substr($0,1,i)substr($0,i)
tolower 转小写 tolower($0)
toupper 转大写 toupper($0)
srand,rand 生成随机数 BEGIN{srand();printf "%d",rand()*10}

替换其它文本处理命令

grep

命令 说明 awk实现
grep 'regex' 过滤记录 awk '/regex/'

sed

命令 说明 awk实现
sed -n '/regex/ p' 过滤记录 awk '/regex/'
sed -n '1,5 p' 显示前5行 awk 'NR<=5'
sed '/1~2/ s/hello/hi/g' 奇数行所有hello替换为hi awk 'NR%2==1{gsub(/hello/,"hi",$0);print $0}'
sed '/regex/ d' 删除匹配行 awk '!/regex/'
sed '1 i\id,name' 第1行插入一行标题 awk '{if(NR==1) print "id,name"; print $0}'
sed '1 a\id,name' 第1行后面添加一行 awk '{print $0; if(NR==1) print "id,name"}'
sed '1 c\id,name' 修改第1行整行内容 awk '{if(NR==1){print "id,name"}else{print $0}}'

可以发现,sed其实是awk程序在某些场景的固化,因为awk程序类似awk 'pattern{}',而sed程序类似sed 'pattern action',action就是p,s,d,i,a,c这些动作,而这些动作对应awk固化在{}中的代码。

然后grep是进一步的场景固化,它只支持正则过滤。

tr

命令 说明 awk实现
tr -d '\n' 删除换行符 awk -v RS='^$' '{gsub(/\n/, "" , $0);print $0}'
tr -s ' ' 压缩多个空格为一个,awk的这个实现有点hack awk '{$1=$1;print $0}'
tr [a-z] [A-Z] 转大写 awk '{print toupper($0)}'

cut

命令 说明 awk实现
cut -d, -f2 ,拆分取第2个字段 awk -F, '{print $2}'

head

命令 说明 awk实现
head -n10 取前10行 awk '{print $0;if(++n >= 10) exit 0}'

tail

命令 说明 awk实现
tail -n10 取倒数10行 这个直接用awk实现有点长,没必要,可以用tac辅助
`tac test.log

wc

命令 说明 awk实现
wc -l 统计文件行数 awk 'END{print NR}'

uniq

命令 说明 awk实现
uniq -c 分组计数 awk '{s[$0]++} END{for(k in s){print s[k],k}}'

总结

awk其本身就是为文本处理而生的,不太复杂的临时性的文本处理分析,第一个想到的就应该是它!
后面,我再总结一下shell本文处理的常见技巧,作为本篇的补充。

往期内容

不容易自己琢磨出来的正则表达式用法
好用的parallel命令
还在胡乱设置连接空闲时间?
常用网络命令总结
使用socat批量操作多台机器