語言是強大的力量。
引子
在 “給產品同學解決一個小問題” 一文中,通過 正則表達式和 sed 命令來抽取 total 值。
正則表達式是什么 ? 就像 1+1=2 可以表示 1 個蘋果加 1 個蘋果得到 2 個蘋果一樣,[0-9]{11} 可以表示 11 位數字。正則表達式是一種用於描述文本模式的形式語言,可以從文件里抽取所需的文本和信息。
本文探索用正則表達式來抽取文本的方法。主要使用 Shell 命令演示,但幾乎所有的編程語言都支持正則表達式。為了不至於讓讀者陷入正則語言的瑣碎講解中,將直接進入示例演示,而基礎知識放在文末。閱讀的時候,可以先掃一遍基礎知識,亦可在需要的時候去參考。
目標: 使用正則表達式 + Shell 命令快速抽取所需要的任意文本。
方法與工具
要使用正則表達式進行文本處理,需要了解幾個必要方法和工具:
- 按行處理模式。指每次從指定文件中讀取一行文本進行處理,處理完成后讀取下一行繼續處理,直到將所有行處理完成。
- 分解與組合。 匹配一個較長的文本時,會將該文本分割成若干小段,分別匹配每一小段,再組合起來。比如要匹配郵箱 shuqin@163.com ,可以分解為 shuqin 、 @ 、 163、 com 四段,分別匹配再組合。
- sed & grep & awk 。正則表達式處理的三劍客。grep 通常用來搜索符合條件的行,sed 通常用於文本替換,awk 是一種模式語言,通常是匹配所需模式后做一些處理。
- 管道 | 。 可以將上一個小程序的輸出定向到下一個小程序的輸入,從而將多個命令連接起來構成更強大的能力。
- Python。 當 Shell 命令功能受限時,可以啟用 Python 來搞定。
示例
找到符合條件的行
先給一個文本 dream.txt。 使用 cat dream.txt 可以查看文本內容:
I would like painting 100 stars in the sky.
I would like to resist 1001 rules that oppress people.
I would like to eat 30 boxes of tomatoes.
I want to have a long long sleep.
第一個小任務:找到含有數字的行。怎么辦 ?制定策略:
- 知道數字的識別。
\d , [0-9], [:digit:]
均可識別數字,在表達式中可以相互替換。通常用 \d 節省鍵盤敲擊量,使用 [0-9] 或 [:digit:] 兼容性更強。因為部分終端可能不支持 \d 。這里統一使用 [0-9]。 - 找到匹配行。使用 grep "RE_COND" file 可以在文件 file 找到符合 RE_COND 描述條件的行。
結合起來,就得到了命令:
grep "[0-9]" dream.txt
grep "\d" dream.txt
抽取所需文本
找到了含有數字的行, 第二個小任務:抽取出這些數字。制定策略:
- 使用每行處理模式。只要找到處理一行的方法,就可以了。
- 識別多個數字。可以使用 [0-9]+ 表示一個或多個數字。
- 將數字捕獲並輸出到結果,將非數字替換為空。使用 sed 命令實現替換: sed 's/非數字(數字序列)非數字/(數字序列的引用符)/g' 。非數字用 [^0-9] 。[^range] 是一種范圍排除性匹配,表示匹配出除 range 指定的所有字符之外的其它字符。
使用 sed -E 's/^[^0-9]*([0-9]+)[^0-9]*$/\1/g'
可達到目的。^ 標識文本的起始, $ 標識文本的結束, * 表示任意多個 。^[^0-9]*([0-9]+)[^0-9]*$
的意思是,從文本開始,經過任意個非數字字符,然后遇到數字並捕獲,再經過任意個非數字字符,走到文本的結束。\1 標識被捕獲的第一個分組,也就是數字部分。
結合起來:
grep "[0-9]" dream.txt | sed -E 's/^[^0-9]*([0-9]+)[^0-9]*$/\1/g'
提示: -E 表示使用擴展的正則表達式,表達力更強一些。不同的終端,選項有所不同。有的是 -r ,有的是 -E 。可以 man sed 來查看選項說明。
找到一行的多個匹配
可以使用 cat dream.txt | tr '\n' ' ' > dream_single.txt
將 dream.txt 中的換行符替換成空格,得到單行文本 dream_single.txt :
I would like painting 100 stars in the sky. I would like to resist 1001 rules that oppress people. I would like to eat 30 boxes of tomatoes. I want to have a long long sleep.
第三個小任務:抽取單行文本中的所有數字。想想怎么做 ?
按照第二個小任務的做法,只要稍作擴展即可。有幾個數字就添加個 ([0-9]+)[^0-9]*
。可以說笨拙又高效。
sed -E 's/^[^0-9]*([0-9]+)[^0-9]*([0-9]+)[^0-9]*([0-9]+)[^0-9]*$/\1 \2 \3/g' dream_single.txt
不過,現實常常是,我們並不知道有多少個數字。可能有 100 個, 可能沒有。上述方式不夠靈活。如何能夠自動抽取所有數字呢?
使用 sed -E 's/[^0-9]*//g' dream_single.txt
會得到 100100130 ,空格沒了,數字都擠到一塊了,不符合期望;使用 sed -E 's/[^0-9]*/ /g' dream_single.txt
會得到 1 0 0 1 0 0 1 3 0,空格太多,原來的數字被分割了,也不符合期望。怎么辦呢 ?
再想想抽取的含義:
- 將所需要的信息捕獲並使用引用在結果中保留;
- 所需要的多條匹配信息必須分割來;可使用空格分開;
- 將不需要的信息替換為空;但是空格不替換。
這樣,可以使用 sed -E 's/[^0-9 ]*//g' dream_single.txt
實現第二三點;由於空格太多也不好,可以使用 sed -E 's/[[:space:]]+/ /g'
將多個空白符合並為一個空格符。兩個替換動作可以合並寫為:
sed -E 's/[^0-9 ]*//g;s/[[:space:]]+/ /g' dream_single.txt
找到某個匹配
上一個小任務,找到了指定條件的所有匹配;如果要找到某個匹配呢 ?
第四個小任務:匹配 boxes 之前的數字。
要完成這個任務,需要將所需要的信息進行更精確的識別。現在要拿到的是 boxes 之前的數字,可以將 boxes 作為一個識別參照物,在模式中加入所需要捕獲的數字與該字符串之間的位置關系: ([0-9]+)[^0-9]*boxes
。[^0-9]* 表示不關心 boxes 與之前的數字之間有什么非數字的東西(可能有可能沒有)。
sed -E 's/^.*[^0-9]+([0-9]+)[^0-9]*boxes.*$/\1/g' dream_single.txt
為什么要在 ([0-9]+) 之前加 [^0-9]+ 這個呢 ? 讀者可以思考下。由於正則表達式的默認貪婪匹配模式,如果不加這個非數字的限制,.* 會把所需要的數字吃掉,只剩一個 0 。 . 是萬能通配符,可以匹配任意字符。
可以把 boxes, [^0-9]+ 這樣的字符序列稱為”屏障“,因為它們標識出我們要取的文本旁邊有什么。就像打車時說附近有一座支付寶大樓一樣。
應用
通用抽取器
假設有如下單行文本,要析取出其中的所有 total 。
{"result":true,"code":0,"message":null,"data":{"success":true,"code":200,"message":"successful","requestId":null,"errorData":null,"data":{"orderNos":["2020030316177500001"],"total":19}}}{"result":true,"code":0,"message":null,"data":{"success":true,"code":200,"message":"successful","requestId":null,"errorData":null,"data":{"orderNos":["2020030113422700003","2020030113283300009"],"total":8}}}{"result":true,"code":0,"message":null,"data":{"success":true,"code":200,"message":"successful","requestId":null,"errorData":null,"data":{"orderNos":["2020030522331200009"],"total":58}}}
目前能描述的是想要的信息 (total.*[0-9]+)
。
第一種策略是,想辦法將單行轉換成多行相似格式的文本,然后按行處理。比如:
sed 's/}{/};{/g' result.txt| tr ';' "\n" | sed -E 's/^.*total.*[^0-9]+([0-9]+).*$/\1/g'
其中 sed 's/}{/};{/g' result.txt| tr ';' "\n"
在多個相似的文本之間插入換行符,從而將單行文本轉換成多行文本,每次只需要解析一個 total 即可。不過,這只是繞開了問題。
第二種策略,如上一節所述,將“不需要的信息替換成空”。不過,與上面的數字范圍可以取反不同,對整個正則表達式取反並不簡單。
第三種策略,采用正向思維,捕獲所有需要的文本。 由於 sed 沒法輸出所有捕獲的分組,因此,可采用 Python 編寫一個較通用的正則析取器。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import sys
import re
def extract(text, regex):
pat = re.compile(regex)
matches = pat.findall(text)
# print 'matches: ', matches
if matches:
for m in matches:
print m
if __name__ == '__main__':
args = sys.argv
regex = args[1]
file = args[2]
print 'file: ', file, ' regex: ', regex
text = ''
with open(file, 'r') as f:
text = f.read()
extract(text, regex)
使用 python reg.py '"total":([0-9]+)' result.txt
即可。
這里將文件參數放在后面是有深意的。 這樣,就可以與 xargs 連用,批量處理文件了。 比如 如下命令,可以批量處理當前目錄下的所有后綴為 .txt 的文件。
ls *.txt | xargs -I {} python reg.py '"total":([0-9]+)' {}
話說回來,正則表達式,通常用來從不規則的文本里抽取信息,對於規則結構的數組、JSON ,使用合適的 JSON 模塊來處理更適宜。
日志解析
下面再給一個從日志中抽取示例。從日志中抽取信息,是正則表達式的一大用武之地。給定一個應用日志文件: app.log , 從中抽取出 state-inconsistency 后面的所有訂單。
———————————————————————————————————————1—————————————————————————————————————————
日志概況: 2020-03-06 00:02:50 bd-prod-trade-xxx-kw5r9/10.232.6.144 warn
日志內容: c76e173b3e2a4E59a0f0c31c222c9323 c.y.t.t.delay_compare-tc_order_compare - state-inconsistency: E20200305235232091500001
———————————————————————————————————————2—————————————————————————————————————————
日志概況: 2020-03-06 00:02:08 bd-prod-trade-xxx-kw5r9/10.232.6.144 warn
日志內容: 24ff9ff23be34d15af44bc7f9c8dfe43 c.y.t.t.delay_compare-tc_order_compare - state-inconsistency: E20200305235121088800001
可以用如下命令來解決。 grep 先拿到不一致的那些行, 然后 cut 用 -d : 指定冒號來分割一行文本,分割出 3 個子串,-f 取到第 3 個字段。 sed 將前面的空格去掉。
grep 'inconsis' app.log | cut -f 3 -d ':' | sed 's/ //g'
不過,每次記這么長的命令,還得記住不一致的訂單在第幾個冒號后,也挺有負擔的。 使用上述找到所有匹配的方法,可以得到:
grep 'inconsis' app.log | sed -E 's/^.*tency: *(E[0-9]+) *$/\1/g'
不過,即使比較有經驗的人,要寫出 s/xxx/yyy/g 里的 xxx ,也不是那么容易的。 因此,還是用通用解析器:
python reg.py "(E[0-9]+)" app.log
這樣,會解析出 E59 這樣不期望的字符。需要去掉。可以加一個”屏障標識“: \b ,這樣就能得到所需結果。 \b 標識 E 左邊的應是符號而不是單詞。它是一種位置標識。位置標識就好比打車時告訴附近有個什么標志性建築一樣。
python reg.py "\b(E[0-9]+)" app.log
基礎知識
這里給出一些常用的知識點:
-
普通字符: 除 *, ? , , (, ), [, ], -, . , +, ^, $, {, } , | , 以外的字符匹配它自身, 比如 a 匹配 a
-
點號 . : 匹配任意不包括換行符的單個字符。比如, sa. 可匹配 sat, sa*, sa[ 等。
-
字符組[characters] :匹配字符組中指定字符集合中的任意單個字符: 比如 [abc] 將匹配 a 或 b 或 c , ca[ptb] 將匹配 cap, cat 或 cab.
-
排除性字符組[^characters] : 匹配字符組中指定字符集合之外的任意單個字符: 比如, [^abc] 將匹配除了 a,b,c 之外的任意單個字符。 ca[^ptb],將匹配 caX 的文本,除了 cap, cat, cab, 注意,這里是匹配一個非指定的字符,而不是不匹配。
-
范圍字符組: [char1-char2] 將匹配從char1 到 char2 之間的任意單個字符(按照ASCII編碼). 比如, [a-z] 匹配任意小寫字符; [A-Z] 匹配任意大寫字符 ; [0-9] 匹配任意數字; [a-zA-Z0-9] 匹配任意大小寫字符或數字
-
特殊字符: 凡是在正則式中具有特殊含義的字符,要匹配字符本身(將其作為普通文本)都必須使用反斜線 \ 進行轉義;通常需要轉義的字符有: . + * ? { } [ ] ( ) - \ ^ $ | 比如, 匹配 . 的正則式是 . , 匹配 \ 的正則式是 \ , 匹配 ( 的正則式是 ( ;要匹配 (ab) 的正則表達式是 (ab) ; 要匹配 a? 的表達式是 a? ; a? 將匹配空或單個a
-
匹配空白字符: \s 匹配任意空白符
-
字符類: <> 等價於; \d <> [0-9] 任意單個數字 ; \w <==> [a-zA-Z0-9_]
-
順序結構 XY: 匹配 X 后緊跟 Y 的文本,比如 [0-9][a-z] 匹配 數字后跟小寫字母的文本, 7z, 0x 等, 但不匹配 ap, 77
-
多選分支結構X|Y: 匹配 X 或者 Y ,比如 [0-9]|[a-z] 匹配數字或小寫字母,相當於 [0-9a-z]。
-
匹配一個或多個 X : X+ ; 例如 s/d+ 匹配 s后跟至少一個數字, s9, s34, s235, ...
-
匹配零個或多個 X : X* ; 例如 s/d* 匹配 s后跟空或者至少一個數字, s, s9, s34, s235, ...
-
匹配零個或一個 X : X? ; 例如 https? 匹配 http 或 https
-
子表達式 (X): 將 X 作為一個子表達式,緊鄰的匹配量詞將作用於 X 整體,而不是 X 中的單個字符。例如 (s\d){3} 匹配 s1s1s1 ,而不匹配 s111
捕獲功能: 使用括號將一個子表達式匹配的文本進行捕獲,后面可在模式或處理中對捕獲的文本進行引用或處理。
((regex1)-(regex2))-(regex3) \n 引用被捕獲的第 n 個文本; n 按左括號出現的順序進行標識 \0 表示匹配的整個文本 ;\1 ref= ((regex1)-(regex2)) ; \2 ref= (regex1) ; \3 ref= (regex2) ; \4 ref= (regex3) 在替換文本時可以使用 $1, $2, $3, $4 分別引用 \1, \2, \3, \4 捕獲的文本。 比如, (\d{3}) mygod(\1) 必須匹配 三位數字mygod三位數字 的模式, 並且,后面三位數字必須與前面三位數字完全相同。
小結
正則表達式是一種用於文本模式匹配和文本處理的靈活有效的語言,也是一種很有趣的小型領域語言。學習正則表達式會是一個有趣的經歷。
關聯文章: