1. 程式人生 > 實用技巧 >Linux文字處理三劍客之awk學習筆記05:getline用法詳解

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   [email protected]     18023394012
2   Alice   female  24   [email protected]  18084925203
10  Bruce   female  27   [email protected]   13942943905
10  Bruce   female  27   [email protected]   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   [email protected]     18023394012
2   Alice   female  24   [email protected]  18084925203
10  Bruce   female  27   [email protected]   13942943905

awk中有另外一個指令類似於getline,叫next,我們先來看執行結果。

# awk '/^1/{print;next;print}' a.txt 
1   Bob     male    28   [email protected]     18023394012
10  Bruce   female  27   [email protected]   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   [email protected]     18023394012
2   Alice   female  24   [email protected]  18084925203
1   Bob     male    28   [email protected]     18023394012
Bob
10  Bruce   female  27   [email protected]   13942943905

上面的輸出結果中,即使通過getline已經處理到了Alice那行,但是$0和$2依然是上一行Bob的資料。

再來一個例子對比帶參和無參getline的區別。

[root@c7-server awk]# awk '/Tony/{print;getline;print $0,$2}' a.txt 
3   Tony    male    21   [email protected]    17048792503
4   Kevin   male    21   [email protected]    17023929033 Kevin
[root@c7-server awk]# awk '/Tony/{print;getline var;print $0,$2}' a.txt 
3   Tony    male    21   [email protected]    17048792503
3   Tony    male    21   [email protected]    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   [email protected]    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   [email protected]    17023929033 Kevin 6
5 5 4   Kevin   male    21   [email protected]    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   [email protected]     18023394012
1
2
3
4
5
10  Bruce   female  27   [email protected]   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   [email protected]     18023394012
2021-01-08 10:22:05
10  Bruce   female  27   [email protected]   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 "[email protected]" | 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()。