Linux文本处理三剑客之awk学习笔记05:getline用法详解

getline用法详解

在默认情况下,awk支持从文件或者STDIN中读取数据。我们也可以使用getline来灵活读取数据,例如在main代码块执行过程中读取某个非待处理文件的数据,或者从某个读取某个shell命令结果数据。

getline有返回值:

  • 1:正确读取到了数据。
  • 0:读取数据遇到EOF。
  • 负数:读取遇到了错误。-1表示文件无法打开,-2表示IO操作需要重试。遇到错误时还会使用变量ERRNO来描述错误。

为了awk代码的健壮性,在使用getline的时候,一般会加上条件判断。

if((getline)<0){...}
if((getline)<=0){...}
if((getline)>0){...}

记得将getline使用小括号包裹,否则getline<0会被识别为输入重定向而不是大小判断。

无参数的getline

getline无参数时表示立即从当前数据流(文件或者STDIN)中读取下一条记录保存至$0。做字段分割。然后从getline的位置继续向后执行awk代码。

此时getline会设置$0、位置参数($1...$NF)、NR、FNR和RT。

# awk '/^1/{print;getline;print}' a.txt 
1   Bob     male    28   abc@qq.com     18023394012
2   Alice   female  24   def@gmail.com  18084925203
10  Bruce   female  27   bcbd@139.com   13942943905
10  Bruce   female  27   bcbd@139.com   13942943905

记住,print省略参数表示print $0。从输出结果来看,第4行比较诡异。因为Bruce那行已经是文件的末尾,此时再getline会遇到EOF,返回值为0。$0不做修改,依然是Bruce那行。因此Bruce那行输出了两次。

所以我们最好是对getline做条件判断,增强代码健壮性。

# awk '/^1/{print;if((getline)<=0){exit};print}' a.txt 
1   Bob     male    28   abc@qq.com     18023394012
2   Alice   female  24   def@gmail.com  18084925203
10  Bruce   female  27   bcbd@139.com   13942943905

awk中有另外一个指令类似于getline,叫next,我们先来看执行结果。

# awk '/^1/{print;next;print}' a.txt 
1   Bob     male    28   abc@qq.com     18023394012
10  Bruce   female  27   bcbd@139.com   13942943905

遇到next以后,立即读取下一条记录,但是它不会像getline那样从当前位置继续往下执行代码,而是会跳出当前的awk内部循环(类似于循环语句中的continue),重新执行一遍main代码块(即要重新匹配pattern了)。由于需要重新匹配pattern,因此第一次next取得Alice行就不符合pattern,第二次next已经遇到EOF,因此就结束了。

带参数的getline

无参数的getline在获取下一条记录后将记录赋值给$0并划分字段,而带参数的getline带的是一个参数,这个参数是一个变量。带参数的getline在获取下一条记录后将记录赋值给参数变量并且划分字段。

因此,带参数的getline只会设置NR、FNR、RT和参数变量var,不会修改$0、位置参数和NF。

# awk '/^1/{print;if((getline var)<=0){exit};print var;print $0;print $2}' a.txt 
1   Bob     male    28   abc@qq.com     18023394012
2   Alice   female  24   def@gmail.com  18084925203
1   Bob     male    28   abc@qq.com     18023394012
Bob
10  Bruce   female  27   bcbd@139.com   13942943905

上面的输出结果中,即使通过getline已经处理到了Alice那行,但是$0和$2依然是上一行Bob的数据。

再来一个例子对比带参和无参getline的区别。

[root@c7-server awk]# awk '/Tony/{print;getline;print $0,$2}' a.txt 
3   Tony    male    21   aaa@163.com    17048792503
4   Kevin   male    21   bbb@189.com    17023929033 Kevin
[root@c7-server awk]# awk '/Tony/{print;getline var;print $0,$2}' a.txt 
3   Tony    male    21   aaa@163.com    17048792503
3   Tony    male    21   aaa@163.com    17048792503 Tony

注意这里我们为了简便没有对getline的返回值做条件判断。

从指定的文件中getline

上面两种getline的用法均是从当前处理的文件中(假设没有使用STDIN,因为情况较少)读取下一条记录,不过我们使用getline的情况一般是为了在处理当前文件的过程中获取其他文件的数据进行处理。例如假设a.txt是配置文件,在处理该文件的过程中遇到了某些关键字需要追加另一个配置文件c.txt的内容,这种情况是可能存在的。

无参getline从文件中获取数据:记录保存至$0,划分字段(设置$N(即位置参数)),设置NF。由于是读取其他文件的数据,因此不设置NR和FNR。

getline < filename
# awk 'NR==5{print NR,FNR,$0,$2,NF;getline<"c.txt";print NR,FNR,$0,$2,NF}' a.txt 
5 5 4   Kevin   male    21   bbb@189.com    17023929033 Kevin 6
5 5 aaa bbb ccc ddd bbb 4

带参getline从文件中获取数据:记录保存至变量var。$0、$N、NF、NR和FNR均不会设置。

getline var < filename
# awk 'NR==5{print NR,FNR,$0,$2,NF;getline var<"c.txt";print NR,FNR,$0,$2,NF}' a.txt 
5 5 4   Kevin   male    21   bbb@189.com    17023929033 Kevin 6
5 5 4   Kevin   male    21   bbb@189.com    17023929033 Kevin 6

在使用getline获取文件数据时,文件名称需要使用双引号包裹,使其不会被awk识别为变量。

awk 'BEGIN{getline<"c.txt";print $0}' a.txt 
awk 'BEGIN{getline<c.txt;print $0}' a.txt 

文件路径可以拆解成目录和文件名并保存于变量中,结合时要使用小括号调整优先级。

awk 'BEGIN{dir="/root/awk";file="c.txt";getline < dir"/"file;print $0}' a.txt
awk 'BEGIN{dir="/root/awk";file="c.txt";getline < (dir"/"file);print $0}' a.txt

上面的getline均只读取了1条记录,如果我们期望读取整个文件的数据的话,应该使用循环。我们修改c.txt文件内容。

# cat c.txt
abc
def
ABC
DEF

读取c.txt整个文件的内容。由于getline返回值的存在,当读取到EOF的时候会返回0,此时循环就会自动停止。

# awk 'BEGIN{while(getline<"c.txt"){print $0}}' a.txt 
abc
def
ABC
DEF

我们尝试在打印第一条记录后再次读取并输出c.txt。

# awk 'BEGIN{while(getline<"c.txt"){print $0}}NR==1{print $0;while(getline<"c.txt"){print $0}}' a.txt 
abc
def
ABC
DEF
ID  name    gender  age  email          phone

此时我们会发现第二次尝试输出c.txt失败。原因在于每次我们getline c.txt就会读取1条记录返回,并在该记录的尾部打上一个标记(类似于指针的指向)。

abc|    # 第一次getline标记点
def|    # 第二次getline标记点
ABC|    # 第三次getline标记点
DEF|    # 第四次getline标记点

BEGIN中的循环进行了4次,每次都在对应的位置做了标记,下一次getline从该位置读取下一条记录。因此BEGIN循环后,标记点就位于文件的EOF了,并不会因为读取到EOF就将标记重新指向文件头部,而是默认情况下一直处于该位置。main中的循环判断中,由于第一次判断就直接是EOF,因此循环体一次也不会执行。于是就出现了上面的输出结果了。

这也可以理解为文件只在第一次getline时打开了,我们若想使标记重回文件头部就需要重新打开该文件,即我们需要先关闭掉这个文件。我们需要使用到close()函数。

# awk 'BEGIN{while(getline<"c.txt"){print $0}{close("c.txt")}}NR==1{print $0;while(getline<"c.txt"){print $0}{close("c.txt")}}' a.txt 
abc
def
ABC
DEF
ID  name    gender  age  email          phone
abc
def
ABC
DEF

第二个close不加也不会影响输出的结果,但是关闭getline曾经打开的文件是个好习惯,也避免了潜在的bug。

从shell命令结果中getline

"cmd" | getline

从shell命令cmd的结果中读取1条记录保存至$0,会进行字段的分割,因此会设置$0、$N、NF、RT。由于不是getline当前文件,因此不会设置NR和FNR。

"cmd" | getline var

从shell命令cmd的结果中读取1条记录保存至变量var。仅设置var和RT。

类似于从文件中getline,cmd必须使用双引号包裹,shell命令的结果也可以理解为文件的数据,getline读取完毕后要关闭。

# awk '/^1/{print;while("seq 1 5" | getline){print};{close("seq 1 5")}}' a.txt 
1   Bob     male    28   abc@qq.com     18023394012
1
2
3
4
5
10  Bruce   female  27   bcbd@139.com   13942943905
1
2
3
4
5

shell命令一般比较长,而且至少要打开一次和关闭一次,可以将其保存至变量中,方便打开和关闭。shell命令中出现引号的话要适当使用转义字符或者在条件允许的情况下交替使用引号。

# awk 'BEGIN{getDate="date +\"%F %T\""}/^1/{print;getDate|getline date;print date;close(getDate)}' a.txt 
1   Bob     male    28   abc@qq.com     18023394012
2021-01-08 10:22:05
10  Bruce   female  27   bcbd@139.com   13942943905
2021-01-08 10:22:05

该示例中,date命令的双引号使用反斜线转义。此处不能使用单引号,否则会和包裹awk代码的最外层单引号冲突。

shell命令本身也可以包含一些特殊字符,例如管道与重定向等。

awk 'BEGIN{cmd="seq 1 5|xargs -i echo x{}y 2>/dev/null"}/^1/{print;while(cmd|getline){print};close(cmd)}' a.txt

从Coprocess中getline

中文协程,在英文中有两种解释,一种叫做Coroutine,另一种叫做Coprocess,它俩是不同的概念。

我们这里说的awk的协程指的是Coprocess,有协助的程序之意。要解释协程我们先来看bash中的1条命令。

cmd1 | cmd2 | cmd3 ...

这个是bash的管道,管道之间的命令是同步执行的。而协程是异步执行的,形如管道。

cmd1 |& cmd2
cmd2 |& cmd3

这边展示的是伪代码,因为bash中实现协程使用的是bash内置命令coproc。“|&”是awk实现协程的符号。其中cmd2被称作协程程序(coprocess)。

注意这种管道也叫做双路管道(two-way pipe)。

协程的使用场景:虽然awk功能强大,但是某些功能不好用awk实现或者用户更熟悉bash下其他的命令,那么我们可以使用协程将数据由awk传递给协程处理,再由协程传递回awk。伪代码如下。

awkPrint "data" |& shellCmd
shellCmd |& getline [var]

例如,假设我们不懂awk中的substr()这个取子字符串的函数,那么我们可以借助shell命令sed来取得邮箱字段的域名。

首先我们先确定sed命令。

# echo "abc@qq.com" | sed -nr "s/.*@(.*)/\1/p"
qq.com

代码量比较多,因此写成文件使用-f选项调用。awk中的sed中的双引号和反斜线需要使用转义。

# cat getlineCoprocSed.awk 
BEGIN {
    CMD="sed -nr \"s/.*@(.*)/\\1/p\""
}

NR>1{
    print $5 |& CMD
    close(CMD,"to")
    CMD |& getline email_domain
    close(CMD)
    print email_domain
}
# awk -f getlineCoprocSed.awk a.txt 
qq.com
... ...
139.com

代码中有两处close函数需要引起我们的注意。我们先来看看第一个close()函数。

print $5 |& CMD
close(CMD,"to")

close()函数的第二个参数的值如果是to,则表示关闭向协程写入数据的管道,也可以理解为向协程写入EOF。用来标识我们已经向协程写完了数据,协程中的命令可以开始执行了(对于该案例就是sed命令)。这么做的原因是某些协程中的命令需要等待文件内容全部准备好了才可以开始执行,例如sort排序命令,无论排序的规则是什么,它想实现排序的前提条件就是要读取完全部的数据才可以,而确定自己是否读取完了文件的全部数据就是看是否遇到了EOF。如果命令需要EOF而协程中又不存在的话,命令就会阻塞在那里等待EOF。同学们可以自己尝试注释掉该close试看看。

再来看看第二个close()函数。

CMD |& getline email_domain
close(CMD)

这里的close()函数虽然没有带第二个参数,其实它是省略了from,因为它是默认参数,下面两个是等价的。

close(CMD)
close(CMD,"from")

它表示关闭从协程(coprocess)读取数据的管道。如果数据写入端的协程管道关闭了,数据读取端的协程管道没关闭,那么这个管道就会存在,下次即便是相同的代码也会继续使用同一个管道。我们尝试注释掉getlineCoprocess.awk中的第二个close()函数就会遇到报错。

# awk -f getlineCoprocSed.awk a.txt 
qq.com
awk: getlineCoprocSed.awk:6: (FILENAME=a.txt FNR=3) fatal: print: attempt to write to closed write end of two-way pipe

在NR==2时我们输出了qq.com,但是遇到NR==3的时候,由于上一条记录处理过程中我们没有关闭掉读取协程数据的管道导致这个双路管道依然存在,而这个管道的数据写入端此前已经被我们关闭了,所以遇到了这样的报错。

因此正确使用协程双路管道的方式是:

  • 向协程写入数据完毕以后要关闭写入端的管道(close(cmd,"to"))。
  • 从协程读取数据完毕以后要关闭读取端的管道(close(cmd[,"from"]))。

我们再来看一个使用协程的例子。我们期望对a.txt文件内容按照年龄字段进行排序,输出的内容要是sort命令的输出结果,但是我们必须使用awk命令。

sort -k4n a.txt

思路:awk是我们的主程序。将sort命令作为协助程序。awk内部循环将第二行开始的每一行数据发送给协程。要在数据全部发送完毕后(END代码块)再对数据进行排序,然后再循环输出排序后的数据。

# cat getlineCoprocSort.awk 
BEGIN {
    cmd="sort -k4n"
}
NR==1 {
    print
}
NR>1 {
    print |& cmd
}
END {
    close(cmd,"to")    # 这里需要close,否则协程sort会阻塞。
    while(cmd |& getline){
        print
    }
    close(cmd)    # 这里的close实测是可以不要的,因为刚好到了代码的尾部了,不过强烈不建议养成这种坏习惯!
}

这里还有一个知识点,我不太了解,但是还是列出。

如果协程中的cmd是按块缓冲的,则需要将其改变成按行缓冲,否则getline会阻塞。

cmd="cmdline"
cmd="stdbuf -oL cmdline"

close()函数

在awk当中,使用getline从文件或者命令结果中获取数据,文件/命令只会在第一次getline时打开/执行。当文件内容/命令结果有多条记录时,getline每次仅获取下一条记录,想让getline获取多条记录就需要使用循环。

由于getline的运行机制,当读取完数据集(文件的内容与命令的执行结果我称之为数据集比较方便)的所有记录后,getline的标记会一直停留在EOF处导致同样的文件或者命令的数据集无法被getline重新获取,要想重新获取的话就必须关闭它。关闭数据集以后,下次使用数据集才会重新打开。

close("file")
close("cmd")

在从coprocess中getline的情况下,会产生一个双路管道(two-way pipe),一端向协程写入数据,另一端从协程读取数据。两端都需要关闭。

awkPrint "data" |& shellCmd    # 使用close(shellCmd,"to")关闭。
shellCmd |& getline [var]    # 使用close(shellCmd,"from")关闭,可简写close(shellCmd)。

通过system()函数执行shell命令

我们可以通过管道,将需要执行的shell命令print给shell解释器来执行。

# awk 'BEGIN{print "pwd" | "bash"}'
/root
# awk 'BEGIN{print "date" | "bash"}'
Sat Jan  9 15:36:49 CST 2021

shell解释器可以是sh、bash等,可以先绝对路径也可以只写解释器名称。

我们也可以通过system()来执行shell命令。system()函数的返回值是shell命令的退出状态码。通过system调用的shell命令也可以包含重定向、管道之类的复杂操作。

# awk 'BEGIN{system("date +\"%F %T\"")}'
2021-01-09 15:40:14
# awk 'BEGIN{system("date +\"%F %T\">/dev/null")}'
# awk 'BEGIN{system("date +\"%F %T\"|cat")}'
2021-01-09 15:40:52

system()在开始运行前会flush出awk的缓冲区数据。如果shell命令是空的话,那么system("")不会执行任何shell命令而只会flush缓冲。这部分的概念请参考awk内置函数fflush()

posted @ 2021-01-19 13:07  阿龙弟弟  阅读(3163)  评论(0编辑  收藏  举报
回到顶部