Linux文本处理三剑客之awk学习笔记03:读取文件
读取文件
读取“每行”数据
博客的开头我们说过,默认情况下awk读取文件的每行数据并将其存入$0变量当中。其实,awk在读取数据之前会根据其内部的预定义变量RS的值来分隔每条记录(record)。RS的默认值是“\n”,即换行符,因此也就会有我们刚才所说的默认情况。
所以,awk在读取文件时,会根据其自定义变量RS(Record Separator,记录分隔符)的值将文件分为多条记录来循环读取,每读取一条记录就将其赋值给$0变量,赋值完毕后再执行main代码块。
如果文件是一个空文件,那么就读取不到记录也就不会执行main代码块。
[root@c7-server ~]# touch x.log
[root@c7-server ~]# awk '{print "hello world"}' x.log
[root@c7-server ~]#
可以在BEGIN代码块中设置RS的值来改变awk分隔记录的方式。
[root@c7-server ~]# awk 'BEGIN{RS="com"}{print "---";print $0;print "---"}' a.txt
---
ID name gender age email phone
1 Bob male 28 abc@qq.
---
---
18023394012
2 Alice female 24 def@gmail.
---
... ...
被分隔的每条记录中不会包含RS的值本身,在上述示例中即每条记录不会包含com字符串。
细心的朋友会留意到
~]# awk '{print $0}' a.txt
当我们不修改RS,上述指令在输出的时候会使得每条记录之间自动换行,看起来就好像输出数据包含了换行符(RS的默认值)。这个特点留到我们讲解另一个预定义变量ORS的时候再做解释。
那么为什么一般将RS设置在BEGIN代码块当中呢?
首先,一般来说一个文件的RS在我们使用awk处理文件之前就可以确定了,而且一般不会改动。其次,基于我们截止目前为止介绍的awk特性,如果我们在main代码块中设置RS的话,awk读取第一条记录的时候依然会使用换行符(RS的默认值)来作为分隔符,读取完第一条记录接下来才是第一次进入main代码块,然后才是设置RS的值,而且每次awk内部循环执行main时都要为RS赋相同值也没有必要(性能略微损失)。
RS为单个字符时直接使用该字符作为分隔符;RS为多个字符时被awk识别为正则表达式。
RS的特殊值
如果记录分隔符不存在于读入数据中的话,那么我们便可以在一次内部循环的情况下读取出所有的数据。
RS="\0"和RS="^$":这两种方式均可以一次性读取所有数据,区别在于部分文件可能包含\0字符。虽然在正则中^$表示空行,但是在文件中即使包含空行也不会将其作为RS。
[root@c7-server ~]# awk 'BEGIN{RS="\0"} {print "---";print $0;print "---"}' a.txt
---
ID name gender age email phone
... ...
10 Bruce female 27 bcbd@139.com 13942943905
---
[root@c7-server ~]# awk 'BEGIN{RS="^$"} {print "---";print $0;print "---"}' a.txt
---
ID name gender age email phone
... ...
10 Bruce female 27 bcbd@139.com 13942943905
---
RS="":按段落读取。当段落与段落之间均为空行的时候,按照段落作为分隔符。注意,空格和制表符虽然也是空白看不到的字符,但是不算空行而算空白符。网友们可以自行在a.txt中键入几个空行查看效果。
~]# awk 'BEGIN{RS=""} {print "---";print $0;print "---"}' a.txt
RS="\n+":以至少一个换行符作为分隔符。默认情况下每行数据都视为1条记录,而该情况下可以将多个连续的换行符作为分隔符,使得空行不会被视为记录。
当我们使用正则作为分隔符的时候,分隔符可以有多种情况。每次awk遇到满足正则条件的分隔符时,都会将这次分隔符赋值给RT(Record Termination),我们可以通过查看该值来判断到底这条记录是以什么作为分隔符。
~]# awk 'BEGIN{RS="@[[:alnum:]]{1,5}.com"} {print "---"RT"---"}' a.txt
---@qq.com---
---@gmail.com---
---@163.com---
---@189.com---
---@xyz.com---
---@139.com---
---@189.com---
---@qq.com---
---@sohu.com---
---@139.com---
------
最后一个分隔符比较特殊。我猜测可能是因为已经EOF了,就将EOF视为分隔符,虽然它并不满足于正则条件。
在我们使用正则进行匹配的时候如果想要忽略大小写,可以使用预定义变量IGNORECASE。
~]# awk 'BEGIN{IGNORECASE=1} /alice|bob/{print $0}' a.txt
1 Bob male 28 abc@qq.com 18023394012
2 Alice female 24 def@gmail.com 18084925203
记录号
记录号即“行号”,awk使用NF和FNR两个预定义变量来保存记录号,每读取1条记录,它们的值就会加1。第一条记录号的值就是1,以此开始递增。
~]# awk '{print NR,FNR}' a.txt a.txt
NR会一直递增,即使数据的来源属于不同的文件,而FNR在遇到新的文件的时候其值会重回1开始递增。
至此我们了解到,awk每读取1条记录就会设置$0、NR、FNR和RT的值。
读取每字段数据
awk读取记录以后,还会根据预定义变量FS(Field Separator,字段分隔符)将记录划分成多个字段。其值默认是一个空格(FS=" "),表示将一个至多个空白字符(空格、制表符和换行符)识别为字段分隔符。将第一个字段赋值给$1,第二个字段赋值给$2,依次类推直至将最后一个字段赋值给$NF。预定义变量NF表示这条记录的字段数量。大家可以自己试试。
awk '{print $1}' a.txt
awk '{print $6}' a.txt
awk '{print $NF}' a.txt
引用的字段如果超出最大字段数则反馈空字符串,如果是负数则报错。
[root@c7-server ~]# awk '{print $7}' a.txt
... ...
[root@c7-server ~]# awk '{print $-1}' a.txt
awk: cmd. line:1: (FILENAME=a.txt FNR=1) fatal: attempt to access field -1
根据分隔符划分字段
通过预定义变量FS和选项-F可以用来指定字段分隔符。选项-F和预定义变量FS大同小异,只不过指定的位置不同罢了。
awk 'BEGIN{FS=":"}{print $1}' /etc/passwd awk -F ":" '{print $1}' /etc/passwd
字段分隔符的特性大多数在上面介绍FS时已经介绍过,补充几点。
如果FS的值为空字符串"",那么会将记录中的每个字符都识别为字段。空格字符也是字符。
~]# echo "a c" | awk 'BEGIN{FS=""}{print $1;print $2;print $3}'
a
c
如果在记录中无法找到字段分隔符则将整个记录($0)赋值给第一个字段$1。
~]# awk 'BEGIN{FS="_"}{print $1;print $2}' a.txt
根据字段宽度划分字段
根据分隔符划分字段的前提条件是文件有合适的分隔符便于我们划分字段。我们copy一份a.txt至b.txt,并且修改某几行的某几个字段,使用等量的空格符来替换。
[root@c7-server ~]# cat b.txt
ID name gender age email phone
1 Bob male 28 abc@qq.com 18023394012
2 female 24 def@gmail.com 18084925203
3 Tony male 21 aaa@163.com 17048792503
4 Kevin 21 bbb@189.com 17023929033
5 Alex male 18 ccc@xyz.com 18185904230
6 Andy female ddd@139.com 18923902352
7 Jerry female 25 18785234906
8 Peter male 20 bax@qq.com 17729348758
9 Steven female 23 bc@sohu.com
10 Bruce female 27 bcbd@139.com 13942943905
此时再以字段分隔符的方式来为b.txt划分字段就不合适了。
此时我们通过观察发现:
- 缺失的字段使用了等量的空格字符填充;
- 为每个字段设定一个最大字符数之后(第一个字段最大字符数是2,第二个字段最大字符数是6,以此类推),字段间距是可知的(字段间距刚好都是2个字符,即使不同也没事,主要是可知的),每条记录同字段间的间距相同(例如每条记录的第一个和第二个字段的间距相同)。
此时我们即可使用预定义变量FIELDFIDTHS来根据字段的字符宽度划分字段。
~]# echo "abbcccddd" | awk 'BEGIN{FIELDWIDTHS="1 2 3 4"}{print $1;print $2;print $3;print $4}'
支持跳跃字符指定字段宽度。
~]# echo "a bb ccc ddd" | awk 'BEGIN{FIELDWIDTHS="1 1:2 2:3 3:4"}{print $1;print $2;print $3;print $4}'
支持通配符*匹配剩余所有字符。
~]# echo "abbcccddd" | awk 'BEGIN{FIELDWIDTHS="1 2 3 *"}{print $1;print $2;print $3;print $NF}' ~]# echo "a bb ccc ddd" | awk 'BEGIN{FIELDWIDTHS="1 1:2 2:3 3:*"}{print $1;print $2;print $3;print $NF}'
因此我们可以使用FIELDWIDTHS来处理b.txt了。注意观察结果(结果没有放入博文,请网友自行敲看看)。
~]# awk 'BEGIN{FIELDWIDTHS="2 2:6 2:6 2:3 2:13 2:*"} $1==2||$1==4||$1==6||$1==7||$1==9{print "----";print $1;print $2;print $3;print $4;print $5;print $6;print "----"}' b.txt
根据模式划分字段
预定义变量FPAT的值是一个正则表达式,awk根据这个值去匹配$0,第一次匹配成功赋值给$1,以此类推直到匹配完整个$0。不会修改$0。
FPAT适用于当我们打算使用分隔符取字段时,字段值包含了分隔符的情况。例如如下csv文件。
~]# cat FPAT.csv
Robbins,Arnold,"1234 A Pretty Street, NE",MyTown,MyState,12345-6789,USA
此时我们使用逗号作为分隔符取出的结果就不是我们想要的,因为第三个字段包含了分隔符。另外,这里我们使用了for循环遍历了所有字段,第一次见此用法的网友照着敲直到功能即可,后面会讲解for循环的。
~]# awk 'BEGIN{FS=","}{for(i=1;i<=NF;i++){print $i}}' FPAT.csv
Robbins
Arnold
"1234 A Pretty Street
NE"
MyTown
MyState
12345-6789
USA
这时可以采取FPAT。正则的第一部分指明“分隔符”逗号以外的多个字符识别为字段;正则的第二部分指明当遇到两个双引号(在awk中需要使用转义字符表示双引号\")的时候,将其与其中包裹的任意字符识别为字段。这样就可以正确分隔这个示例的字段了。
~]# awk 'BEGIN{FPAT="[^,]+|\".*\""}{for(i=1;i<=NF;i++){print $i}}' FPAT.csv
Robbins
Arnold
"1234 A Pretty Street, NE"
MyTown
MyState
12345-6789
USA
由于正则的贪婪匹配机制,如果记录中包含2个以上的双引号就会出问题。
[root@c7-server ~]# cat FPAT.csv
Robbins,Arnold,"1234 A Pretty Street, NE",MyTown,MyState,"12345-6789",USA
[root@c7-server ~]# awk 'BEGIN{FPAT="[^,]+|\".*\""}{for(i=1;i<=NF;i++){print $i}}' FPAT.csv
Robbins
Arnold
"1234 A Pretty Street, NE",MyTown,MyState,"12345-6789"
USA
结果并不是我们想要的,此时需要改写正则。
~]# awk 'BEGIN{FPAT="[^,]+|\"[^\"]+\""}{for(i=1;i<=NF;i++){print $i}}' FPAT.csv
Robbins
Arnold
"1234 A Pretty Street, NE"
MyTown
MyState
"12345-6789"
USA
patsplit()函数的功能与FPAT预定义变量的功能相同。
至此我们学习了3种划分字段的方式:
- 根据字段分隔符(预定义变量FS和选项-F)划分字段;
- 根据字段的宽度(预定义变量FIELDWIDTHS)划分字段;
- 根据字段的模式(预定义变量FPAT)划分字段。
这三种方式只能选择一种,它们相互之间是冲突的。
检查字段分隔的方式
数组变量PROCINFO["FS"]存储了字段分隔的三种方式,其值分别是FS、FIELDWIDTHS和FPAT。
~]# cat test.awk
BEGIN {
if(PROCINFO["FS"]=="FS"){
print "FS"
} else if(PROCINFO["FS"]=="FPAT") {
print "FPAT"
} else {
print "FIELDWIDTHS"
}
}
~]# awk -f test.awk
~]# awk -v FS=":" -f test.awk
~]# awk -v FIELDWIDTHS="3" -f test.awk
~]# awk -v FPAT="[[:alpha:]]+" -f test.awk
字段与记录的重建
预定义变量FS的含义我们已经很了解。有一个十分类似的预定义变量叫做OFS(Output FS),它表示当$0(记录)重新计算(可以理解为重建)的时候使用OFS的值作为输出字段的分隔符。接下来我们来看几个重新计算的情况。
1、当修改$0的时候,将使用FS(假定我们就使用FS不使用其他划分字段的方式)重新计算各个字段以及NF值。
awk 'BEGIN{FS=":"}{$0="a:b:c";print NF;for(i=1;i<=NF;i++){print $i}}' a.txt
2、当修改具体的字段的时候,使用OFS重建记录。注意,哪怕是自我赋值也属于字段的修改。
awk 'BEGIN{OFS="-"}{$1=0;print $0}' a.txt
awk 'BEGIN{OFS="-"}{$1=$1;print $0}' a.txt
3、为不存在的字段赋值,将新增字段并为不存在的字段(若有)赋空字符串,使用OFS重建记录。
awk 'BEGIN{OFS="-"}{$(NF+3)=5;print $0}' a.txt
4、增加NF,使用空字符串为新记录赋值;减少NF,截断多余记录。均会使用OFS重建记录。
# awk 'BEGIN{OFS="-"}{NF+=3;print $0}' a.txt
# awk 'BEGIN{OFS="-"}{NF-=3;print $0}' a.txt
awk读取记录以后将数据原原本本存放于$0当中,只要不会发生上述使用OFS重建记录的事情,即便指定了OFS也无妨。
# awk 'BEGIN{OFS="-"}{print $0}' a.txt
OFS的默认值是1个空格。因此即便没指定具体的值也会使用单个空格重建记录。
awk '{$1=$1;print $0}' a.txt
一般我们会先设置OFS的值再重建记录。所以将其放入BEGIN中。如果先重建再设置OFS,那么第一行会按照默认OFS重建,后续行才按照新OFS值重建。
awk '{$1=$1;OFS="-";print $0}' a.txt
awk '{$1=$1;OFS="-";$1=$1;print $0}' a.txt
awk 'BEGIN{OFS="-"}{$1=$1;print $0}' a.txt
awk '{$1=$1;print $0}' OFS="-" a.txt
这里如果看不懂的朋友,等后面学习了awk工作流程和变量以后就会明白awk执行的顺序了。
根据这个特性我们可以压缩连续的多个空格。
# echo " a b c d " | awk '{$1=$1;print $0}'
# echo " a b c d " | awk 'BEGIN{OFS="-"}{$1=$1;print $0}'
数据筛选
记录筛选
1、根据行号(NR或者FNR)筛选记录。
awk 'NR==2{print $0}' a.txt
awk 'NR>2{print}' a.txt
awk 'NR<2' a.txt
awk 'NR>=2' a.txt
awk 'NR<=2' a.txt
此前已经说过,省略{action}即表示{print}等价于{print $0}。
2、根据正则表达式筛选记录。
正则匹配,默认使用$0来匹配,可以省略$0。
awk '/qq.com/' a.txt
awk '$0~/qq.com/' a.txt
匹配不包含@的记录,即整条记录均由非@字符构成。
awk '/^[^@]+$/' a.txt
awk支持取反,使用取反更易理解。
awk '!/@/' a.txt
3、根据字段筛选记录。
# awk '$4>24' a.txt
ID name gender age email phone
1 Bob male 28 abc@qq.com 18023394012
7 Jerry female 25 exdsa@189.com 18785234906
10 Bruce female 27 bcbd@139.com 13942943905
第一条记录的$4是age,age是字符串,24是数字,其在进行比较时会有内部转换机制,将24识别为字符串,字符串比较根据ASCII编码(maybe)按字符一一比较,字符a大于字符2。如果我们期望不筛选出age那条,可以将其+0从而转换成数字。字符串+0等于数字0。
# awk '($4+0)>24' a.txt
1 Bob male 28 abc@qq.com 18023394012
7 Jerry female 25 exdsa@189.com 18785234906
10 Bruce female 27 bcbd@139.com 13942943905
awk '$5~/qq.com/' a.txt
4、组合筛选。
使用逻辑与和逻辑或运算符组合多个条件。
awk 'NR>=2&&NR<=4' a.txt
awk '($4+0)>=20||$3=="male"' a.txt
5、按照范围筛选(flip-flop)。
awk 'NR==2,NR==4' a.txt
awk '$2=="Kevin",$5~/qq.com/' a.txt
字段处理
字段的筛选即print $X(X表示具体的字段)没什么好说的,因此讲字段的处理。
# awk 'NR>1{$4+=4;print $0}' a.txt
1 Bob male 32 abc@qq.com 18023394012
2 Alice female 28 def@gmail.com 18084925203
... ...
处理字段目前只接触到赋值,修改了字段值会导致使用OFS重建$0。
要想使得输出结果恢复重建前的效果,可以结合外部命令,例如该示例中的column。
# awk 'NR>1{$4+=4;print $0}' a.txt | column -t
1 Bob male 32 abc@qq.com 18023394012
2 Alice female 28 def@gmail.com 18084925203
... ...
或者在后续学会了字符串处理函数以后来实现。基本思路是取得$0重建前的$4的前后部分保留,然后修改$4的值,最后再将三部分组合。
awk 'NR>1{$6=$6"*";print $0}' a.txt
awk 'NR>1{$6=$6"*";print $0}' a.txt | column -t
数据筛选示例
该示例要求我们从ifconfig的输出结果中取得ipv4地址(不包含环回地址lo),该示例同时也是常见的运维面试题。
# ifconfig
ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 192.168.152.100 netmask 255.255.255.0 broadcast 192.168.152.255
inet6 fe80::7a4:5a06:46b4:9ce5 prefixlen 64 scopeid 0x20<link>
ether 00:0c:29:46:79:46 txqueuelen 1000 (Ethernet)
RX packets 3151 bytes 258273 (252.2 KiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 1606 bytes 166414 (162.5 KiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
inet6 ::1 prefixlen 128 scopeid 0x10<host>
loop txqueuelen 1000 (Local Loopback)
RX packets 72 bytes 8088 (7.8 KiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 72 bytes 8088 (7.8 KiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
virbr0: flags=4099<UP,BROADCAST,MULTICAST> mtu 1500
inet 192.168.122.1 netmask 255.255.255.0 broadcast 192.168.122.255
ether 52:54:00:a6:3d:cf txqueuelen 1000 (Ethernet)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
有3种思路来取得地址。
思路一:ipv4地址位于包含“inet ”(注意有空格)的记录,因此筛选出该记录。同时我们要过滤掉换回地址,因此$2不以127打头的记录。将2个条件使用逻辑与连接。
# ifconfig | awk '/inet /&&!($2~/^127/)'
inet 192.168.152.100 netmask 255.255.255.0 broadcast 192.168.152.255
inet 192.168.122.1 netmask 255.255.255.0 broadcast 192.168.122.255
思路二:ifconfig输出信息中包含了3段信息,每段表示1张网卡并以空行作为记录分隔符。因此结合我们在讲解RS时提到的,这里我们以段划分记录。记录不包含lo,同时我们取得ip地址所在的字段(手工数一下可知是第6字段)。
# ifconfig | awk 'BEGIN{RS=""}!/lo/{print $6}' 192.168.152.100 192.168.122.1
思路三:基于思路二,假设ip地址所在的字段数比较靠后,那么我们就需要数好几个字段才可以数到ipv4地址,我们来看一下下面这个输出结果。
# ifconfig | awk 'BEGIN{RS=""}!/lo/{print "---"$0"---"}'
---ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 192.168.152.100 netmask 255.255.255.0 broadcast 192.168.152.255
inet6 fe80::7a4:5a06:46b4:9ce5 prefixlen 64 scopeid 0x20<link>
ether 00:0c:29:46:79:46 txqueuelen 1000 (Ethernet)
RX packets 3997 bytes 330636 (322.8 KiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 2063 bytes 225016 (219.7 KiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0---
---virbr0: flags=4099<UP,BROADCAST,MULTICAST> mtu 1500
inet 192.168.122.1 netmask 255.255.255.0 broadcast 192.168.122.255
ether 52:54:00:a6:3d:cf txqueuelen 1000 (Ethernet)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0---
按段划分并筛选记录以后,ipv4地址,从我们的视觉上来看,可以理解为每条记录的第2“行”。不过我们这里因为已经将整个网卡的信息(多行)理解为了1条记录(行)了,因此我们要将原本的第二行识别为第2个字段,即修改FS的值。
# ifconfig | awk 'BEGIN{RS=""}!/lo/{FS="\n";print $2}'
flags=4163<UP,BROADCAST,RUNNING,MULTICAST>
inet 192.168.122.1 netmask 255.255.255.0 broadcast 192.168.122.255
输出结果中第一行并不是我们所期望的字段信息。这是因为根据RS=""读取记录以后会赋值$0,并按照FS的值(没有另外指定因此使用FS=" ")赋值$1...$NF各个位置参数。赋值完毕以后才执行main代码块赋值FS="\n",此时第一条记录的各位置参数已经确定好了。因此从第二条记录开始,$2才是我们所想要的信息。
我们只要将FS设置在BEGIN中即可,这也是为什么大多数情况下如果要修改默认的FS和RS都在BEGIN中设置。
# ifconfig | awk 'BEGIN{RS="";FS="\n"}!/lo/{print $2}'
inet 192.168.152.100 netmask 255.255.255.0 broadcast 192.168.152.255
inet 192.168.122.1 netmask 255.255.255.0 broadcast 192.168.122.255
接下来我们将每条记录的$2赋值给记录$0本身,设置FS并取第2个字段的信息。按照下面的命令明显无法取正确。
ifconfig | awk 'BEGIN{RS="";FS="\n"}!/lo/{$0=$2;FS=" ";print $2}'
# 第一条空信息
# 第二条空信息,注意这两条空信息所取的字段是不同的。
原因此前我们也说了,修改$0($0=$2)会重新划分各字段,而FS=" "在修改$0之后出现,因此第一条记录依然是按照FS="\n"划分字段。
【第一条空信息】的$0是:inet 192.168.152.100 netmask 255.255.255.0 broadcast 192.168.152.255,根据FS,因此它也会是$1,因此$2为空。
想让【第一条空信息】取值正确的话,就要重新设置$0。
# ifconfig | awk 'BEGIN{RS="";FS="\n"}!/lo/{$0=$2;FS=" ";$0=$0;print $2}'
192.168.152.100
# 第二条空信息
【第二条空信息】取值错误的原因是从第二条记录开始,FS的值就一直是main中的" ",我们需要在main的结尾再将其设置回BEGIN中的值。
# ifconfig | awk 'BEGIN{RS="";FS="\n"}!/lo/{$0=$2;FS=" ";$0=$0;print $2;FS="\n"}' 192.168.152.100 192.168.122.1
在我们实际使用当中使用思路一和思路二取ipv4地址即可,思路三只是利于我们理解awk的工作原理,看不懂的同学多看看上面的【字段与记录的重建】。