Shell文字處理編寫單行指令的訣竅

老錢發表於2018-03-20

小編程式設計資質一般,剛出道的時候使用的是windows來做程式開發,平時linux命令的知識僅限於在學校裡玩ubuntu的時候學到的那丁點。在一次偶然看見專案的主程敲著複雜的shell單行命令來處理日誌的時候感到驚訝不已。後來自己自學了一點shell程式設計,剛看完一本書沒過多久就忘記了,因為工作中用到的實在太少,而且命令如此之多,學了一個忘了另一個,始終摸不著門道在哪。

直到某天靈感爆發,發現了一個竅門之後,才牢牢地把握住了shell指令的精髓。

用寫SQL查詢的思維寫shell命令

寫SQL小編非常在行,畢業第一年的時候SQL就寫的行雲流水。經常別人寫了一個儲存過程來幹某件事的時候,哥用一條語句搞定。自然這樣的語句也是被不少人吐槽的,難以看懂。

偶然一天我將一個資料表匯入成一個CSV檔案的時候發現了這個竅門。如果把這個CSV檔案看成一個資料表,把各種shell指令看成SQL的查詢條件,這兩種資料處理方式在思維模式上就沒有什麼區別了。

然後就開始仔細研究了一番,又有了好多驚人的發現。原來shell指令除了查詢之外還可以做修改,相當於SQL的DML操作。shell指令除了能做單表資料處理之外還可以實現類似於SQL多表的JOIN操作。連排序和聚合功能也能輕鬆搞定。

Shell文字處理編寫單行指令的訣竅

首先下載本章用到的資料,該資料有20多M,建議耐心等待。

git clone https://github.com/pyloque/shellquery_ppt.git
複製程式碼

第一個檔案groups.txt表示小組,有三個欄位,分別是小組ID、小組名稱和小組建立時間

第二個檔案rank_items.txt代表行為積分。欄位分別是行為唯一ID、行為型別、行為關聯資源ID、行為時間和行為積分。行為型別包含group單詞的是和小組相關的積分行為。其它行為還有與帖子、使用者、問題、文章相關的。

文字檔案等價於資料表table

資料表是有模式的資料,每個列都有特定的含義。表的模式資訊可以在資料庫的元表裡找到。

CSV文字檔案也是有模式的資料,只不過它的列資訊只存在於使用者的大腦裡。檔案裡只有純粹的資料和資料分隔符。CSV文字檔案的記錄之間使用換行符分割,列之間使用製表符或者逗號等符號進行分隔。

資料表的行記錄等價於CSV文字檔案的一行資料。資料表一行的列資料可以使用名稱指代,但是CSV行的列資料只能用位置索引,表達能力上相比要差一截。

在測試階段,我們使用少量行的資料進行測試,這個時候可以使用head指令只吐出CSV文字檔案的前N行資料,它相當於SQL的limit條件。同樣也可以使用tail指令吐出檔案的倒數前N行資料。使用cat指令吐出所有。

# 看前5行
bash> head -n 5 groups.txt
205;"真要瘦不瘦不罷休";"2012-11-23 13:42:38+08"
28;"健康朝九晚五";"2010-10-20 16:20:43+08"
280;"核諧家園";"2013-04-17 17:11:49.545351+08"
38;"創意科技";"2010-10-20 16:20:44+08"
39;"死理性派";"2010-10-20 16:20:44+08"

# 看倒數5行
bash> tail -n 5 groups.txt
69;"吃貨研究所";"2010-11-10 14:35:34+08"
27;"DIY";"2010-10-20 16:20:43+08"
33;"心事鑑定組";"2010-10-20 16:20:44+08"
275;"盜夢空間";"2013-03-21 23:35:39.249583+08"
197;"萬有青年養成計劃";"2012-11-14 11:39:50+08"

# 顯示所有
bash> cat groups.txt
...
複製程式碼

資料過濾等價於查詢條件where

資料過濾一般會使用grep或者awk指令。grep用來將整個行作為文字來進行搜尋,保留滿足指定文字條件的行,或者是保留不滿足匹配條件的行。awk可以用來對指定列內容進行文字匹配或者是數字匹配。

# 顯示包含‘技術’單詞的行
bash> cat groups.txt | grep 技術
73;"美麗也是技術活";"2010-11-10 15:08:59+08"
279;"灰機與航空技術";"2013-04-12 13:30:31.617491+08"
243;"科學技術史";"2013-01-24 12:48:44.06041+08"

# 顯示即包含單詞‘技術’又包含‘灰機’的行
bash> cat groups.txt | grep 技術 | grep 灰機
279;"灰機與航空技術";"2013-04-12 13:30:31.617491+08"

# 顯示小組ID小於30的行 -F限定分隔符 後面是一個awk指令碼
# awk一門簡單的程式語言,它處理的物件是以行為單位
# $0表示整行內容 $1代表第一列內容
# awk分4段,選擇端|起始段|處理段|結束段
# filter BEGIN{} {} END{}
# 選擇端起到過濾行的作用,選擇成功的行進入處理段
# 起始端在第一個行處理之前進行,結束段在最後一個行處理完成之後進行,只進行依次
# 處理段就是對選擇成功的行依次處理,依次處理一行
# 這些段都是可選的
# 參考awk簡明教程 https://coolshell.cn/articles/9070.html
bash> cat groups.txt | awk -F';' '$1<30  {print $0}'
28;"健康朝九晚五";"2010-10-20 16:20:43+08"
29;"愛寵";"2010-10-20 16:20:44+08"
27;"DIY";"2010-10-20 16:20:43+08"
複製程式碼

限定欄位輸出

我們經常使用列名稱來限定SQL的輸出物件。

SQL> select id, user from group
同樣對於文字檔案,我們可以使用cut指令或者awk來完成。

# 只顯示前3行的第一列和第二列,保留分隔符 -d指明分隔符
bash> cat groups.txt | head -n 3 | cut -d';' -f1 -f2
205;"真要瘦不瘦不罷休"
28;"健康朝九晚五"
280;"核諧家園"
# 只顯示前3行的第一列和第二列,用空格作為分隔符
bash> cat groups.txt | head -n 3 | awk -F';' '{print $1" "$2}'
205 "真要瘦不瘦不罷休"
28 "健康朝九晚五"
280 "核諧家園"
複製程式碼

組合命令的效率

一個複雜的單行命令可以有非常多的單條指令組成,每個指令都會對應著一個程式。程式和程式之間使用管道將輸入輸出串接起來,形如人體蜈蚣。

Shell文字處理編寫單行指令的訣竅

第一個程式處理了一行資料後從輸出吐了出來,成了第二個程式的輸入,在第二個程式對第一行資料進行處理的過程中,第一個程式又可以繼續處理後面的行。

如此就形成了一個流水線結構,每個程式都在並行的進行資料處理。整個組合命令的效率將取決於所有命令中最慢的一條。

排序操作又不同於其它操作,它需要等待所有的資料都接受完成才能決定第一個輸出。所以排序是一個即佔用記憶體又耗費時間的操作,它會導致後續程式的飢餓感。

Shell文字處理編寫單行指令的訣竅

聚合

資料聚合也是shell裡經常使用到的命令,最常用的可能就是用wl來統計行數,其實也可以使用awk來完成更加複雜的統計功能。

# 總共多少行
bash> cat groups.txt | wc -l
216
# 用awk實現,遇到一行對變數l加1,最後輸出l變數的值,也即行數
bash> cat groups.txt | awk '{l+=1} END{print l}'
awk還可以完成類似於group by的功能,這個指令碼就要複雜一點

# 因為命令太長,下面用了shell命令續行符"\"
# 統計每行的名稱長度[去掉前後兩個引號],將相同長度的進行聚合統計數量
# awk不識別unicode,所以長度都是按位元組算的,可以使用gawk工具來取代
# awk支援字典資料結構和迴圈控制語句,所以可以幹聚合的事
bash> cat groups.txt | awk -F';' '{print length($2)-2}' | \
    > awk '{g[$1]+=1} END{for (l in g) print l,"=",g[l]}'
22 = 1
3 = 2
4 = 1
24 = 9
6 = 6
...
複製程式碼

排序和去重

排序命令是一種消耗記憶體的運算,它需要將全部的內容放置到記憶體的陣列裡,然後使用排序演算法進行內容排序後輸出。shell的排序就是sort命令,sort可以按字元排序也可以按數字排序。

# 以分號作為分隔符,排序第一列小組的ID
# 預設按字元進行排序
bash> cat groups.txt | sort -t';' -k1 | head -n 5
102;"說文解字";"2012-03-19 18:10:47+08"
103;"廣告研發局";"2012-03-21 17:50:02+08"
104;"掀起你的內幕來";"2012-03-26 17:23:11+08"
105;"一分鐘學堂";"2012-03-28 17:06:37+08"
106;"泥瓦匠";"2012-04-11 21:30:34+08"

# 加上-n選項按數字進行排序
bash> cat groups.txt | sort -t';' -n -k1 | head -n 5
27;"DIY";"2010-10-20 16:20:43+08"
28;"健康朝九晚五";"2010-10-20 16:20:43+08"
29;"愛寵";"2010-10-20 16:20:44+08"
30;"性 情";"2010-10-20 16:20:44+08"
31;"謀殺 現場 法醫";"2010-10-20 16:20:44+08"

# 加上-r選項倒排
bash> cat groups.txt | sort -t';' -n -r -k1 | head -n 5
303;"怎麼玩小組";"2013-06-05 13:18:06.079734+08"
302;"**精選";"2013-06-05 13:15:52.187787+08"
301;"土木建築之家";"2013-06-05 13:14:58.968257+08"
300;"NBA那些事兒";"2013-06-03 15:50:14.415515+08"
299;"資料江湖";"2013-05-30 17:27:10.514241+08"
複製程式碼

去重的命令時uniq,但是跟SQL的distinct不一樣,uniq一般和sort配合使用,它要求去重的物件必須是排過序的,否則就不能起到去重的效果。distinct一般是在記憶體裡記錄一個Set放入所有的值,然後查詢新值是否在Set中。uniq只記錄一個值,就是上一行的值,然後看新行的值是否和上一行的值一樣。

# 列印第二列小組名稱的長度的所有可能的值的個數
# awk列印長度,sort -n按長度數字排序, uniq去重,wc -l統計個數
bash> cat groups.txt | awk -F';' '{print length($2)-2}' | sort -n | uniq | wc -l
21

# 我們再看看,如果不排序會怎樣
bash> cat groups.txt | awk -F';' '{print length($2)-2}' | uniq | wc -l
166

# 很明顯這個值不是我們期望的
複製程式碼

程式替換操作符 <()

有很多指令可以接受一個檔名作為引數,然後對這個檔案進行文字處理。如果輸入不是檔案而是由一串命令生成的動態檔案怎麼辦呢?也許你會想到先將這一串命令輸出到臨時檔案中再將這個臨時檔名作為指令的輸入,處理完畢後再刪除這個臨時檔案。

# 首先建立臨時檔案
bash> mktemp
/var/folders/w3/4z1zbpdn6png5y3bl0pztph40000gn/T/tmp.LoWLFvJp

# 輸出到臨時檔案
bash> cat groups.txt | grep 技術 > /var/folders/w3/4z1zbpdn6png5y3bl0pztph40000gn/T/tmp.LoWLFvJp

# 處理臨時檔案,統計臨時檔案的行數
bash> cat /var/folders/w3/4z1zbpdn6png5y3bl0pztph40000gn/T/tmp.LoWLFvJp | wc -l
3

# 刪除臨時檔案
bash> rm /var/folders/w3/4z1zbpdn6png5y3bl0pztph40000gn/T/tmp.LoWLFvJp
複製程式碼

但是本文的主題是單行shell命令。你很難使用單行命令來實現上面提到的臨時檔案法。這時我們就需要藉助於一個高階語法:程式替換。

# 等價於上面的臨時檔案法,程式替換符號<()
bash> cat <(cat groups.txt | grep 技術) | wc -l
3
複製程式碼

程式替換的原理也是臨時檔案法,只是這裡的檔案路徑是/dev/fd/。

連表Join操作

當兩個資料表有關聯時,可以使用join操作進行連表查詢。同樣shell也有特殊的方法可以關聯兩個檔案的內容進行查詢,這個命令在shell裡面也是join。考慮到效能,join指令要求兩個輸入檔案的join欄位必須是排序的。

# rank_items表裡面的行為型別欄位有個值為hot_group,它表示小組因為活躍而上了熱門小組
# 然後系統給這個小組累積了一個score,比如
# hot_group後面跟的是小組ID,最後的值1表示score積分
bash> cat rank_items.txt | grep hot_group | head -n 5
"5aa19d6a-3482-4a92-ae20-f26218d8debd";"hot_group";"96";"2013-06-03 21:43:58.62761+08";1
"6ae0f144-33af-432b-a9af-db51938e8faf";"hot_group";"48";"2013-06-03 21:44:05.050322+08";1
"55dcb43e-e2c0-43d2-8ed7-dbec6771e7b4";"hot_group";"185";"2013-06-05 18:14:08.406047+08";1
"98a54f24-fdef-4029-ad79-90055423f5c3";"hot_group";"31";"2013-06-03 21:47:28.476056+08";1
"4284d4d5-41b9-4dfd-ada9-537332c5cbd6";"hot_group";"63";"2013-06-01 10:07:18.58019+08";1

# 現在我們來聚合一下所有小組的各自積分,然後排序取前5名
# 用grep過濾只保留包含hot_group的行
# 篩選欄位,只保留小組ID和積分欄位,因為小組ID前後有引號,所以得用substr去掉引號
# 用awk的聚合功能累積各小組的積分
# sort -n -r按積分數字倒排,再head -n 5取前5名展示出來
bash> cat rank_items.txt| grep hot_group | \
awk -F';' '{print substr($3, 2, length($3)-2)";"$5}' | \
awk -F';' '{scores[$1]+=$2} END{for(id in scores) print id";"scores[id]}' | \
sort -t';' -n -r -k2 | head -n 5
63;5806
30;4692
69;4605
73;3177
27;2801

# 接下來我們將上面的結果和groups.txt檔案join起來,以顯示小組ID對應的名稱
# -t指定分隔符,兩個輸入分隔符必須一致
# -1 1 -2 1表示取第一個輸入檔案的第一個欄位和第二個輸入檔案的第一個欄位來join
# -o1.1,1.2,2.2表示輸出第一個輸入檔案的第一第二欄位和第二個輸入檔案的第二欄位
bash> join -t';' -1 1 -2 1 -o1.1,1.2,2.2 \
<(sort -t';' -k1 groups.txt) \
<(cat rank_items.txt| grep hot_group | \
awk -F';' '{print substr($3, 2, length($3)-2)";"$5}' | \
awk -F';' '{scores[$1]+=$2} END{for(id in scores) print id";"scores[id]}' | \
sort -t';' -n -r -k2 | head -n 5)
63;"Geek笑點低";5806
69;"吃貨研究所";4605
73;"美麗也是技術活";3177
# 我們看到結果只有3條,原因是有30和27兩個ID在groups.txt裡面找不到。
複製程式碼

推薦資源

《Unix Shell程式設計》
《The AWK programming language》
《Sed & Awk 101 Hacks》
 GNU Parallel http://www.gnu.org/software/parallel/
複製程式碼

Shell文字處理編寫單行指令的訣竅

閱讀相關文章,請關注公眾號【碼洞】

相關文章