正則表達式學習心得
前言
一個學習筆記居然會有前言?沒錯,這個是額外增加的,顯得專業一點。
提起正則表達式,不知道大家第一印象是什么,可能是強大好用也可能是晦澀難懂。正則表達式在文本處理中相當重要,各大編程語言中均有支持,但可能使用起來有細微的差別,該學習筆記中元字符介紹一節不特定於某一個編程語言,旨在簡要描述正則本身的基本用法。
前言中先闡述一下正則表達式到底是個什么東西,清楚這個概念的可以直接跳過。正則表達式是對字符串操作的一種邏輯公式,就是用事先定義好的一些特定字符、及這些特定字符的組合,組成一個“規則字符串”,這個“規則字符串”用來表達對字符串的一種過濾邏輯。(該概念摘自百度百科,不要問我為啥不用Google)
看完上面的解釋,我的第一反應是有點似懂非懂。
個人理解如下:某個大佬為了從字符串中匹配或找出符合特定規律(如手機號、身份證號)的子字符串,先定義了一些通用符號來表示字符串中各個類型的元素(如數字用 \d 表示),再將它們組合起來得到了一個模板(如:\d\d模板就是指代兩個數字),拿這個模板去字符串中比對,找出符合該模板的子字符串。再看下面內容前,可以在:https://tool.oschina.net/regex/ 這個正則表達式測試工具中來跟着練習
簡單的例子
了解了什么是正則表達式后,在由幾個例子去進一步理解。
現在有一個字符串為:
I am a tester, and My job is to test some software.
-
test
是一個正則表達式,它的匹配情況:I am a tester, and My job is to test some software. 它既可以匹配tester中的test,又可以匹配第二個test。正則表達式中的test就代表test這個單詞本身。 -
\btest\b
是一個正則表達式,它的匹配情況:I am a tester, and My job is to test some software. 它只能匹配第二個test。因為\b具有特殊意義,指代的是單詞的開頭或結尾。故tester中的test就不符合該模式。 -
test\w*
是一個正則表達式,它的匹配情況:I am a tester, and My job is to test some software. 它匹配出了tester,也匹配出了第二個test。其中\w的意思是匹配字母數字下划線,*表示的是數量,指有0個或多個\w。所以這個正則表達是的意思就是匹配開頭為test,后續跟着0個及以上字母數字下划線的子字符串 -
test\w+
是一個正則表達式,它的匹配情況:I am a tester, and My job is to test some software. 它只匹配了tester。因為+與*不同,+的意思是1個或多個,所以該正則表達式匹配的是開頭為test,后續跟着1個及以上字母數字下划線的字符串。
通過上述幾個例子,應該可以看出正則表達式的工作方式,正則表達式由一般字符和元字符組成,一般字符就是例子中的‘test’,其指代的意思就是字符本身,t匹配的就是字母t;元字符就是例子中有特殊含義的字符,如\w, \b, *, +
等。后續介紹一些基礎的元字符。
元字符介紹
元字符有很多,不同元字符有不同的作用,大致可以分為如下幾類。
用於表示意義
有些元字符專門用來指代字符串中的元素類型,常用的如下:
元字符 | 說明 |
---|---|
\w | 匹配所有字母數字下划線 |
\W | 與上相反 |
\d | 匹配所有數字 |
\D | 與上相反 |
\s | 匹配所有空格字符,如:\n,\t |
\S | 與上相反 |
. | 匹配所有字符,除了換行符 |
\n | 匹配換行符 |
\t | 匹配制表符 |
通過上述表格中的數據可以發現,\w,\d,\s
都有一個與之相反的元字符(將對應字母大寫后就是了)。\w
匹配所有字母數字下划線,那么\W
就是匹配所有不是字母數字下划線的字符。只要記住其中3個,另外3個就很好記了。
乍一看這幾個元字符挺簡單的,但是經常不用的話保不准會忘記,此處分享一下我的記憶方法。我把這幾個元字符都當作是某一個單詞的縮寫(雖然可能就是某個單詞的縮寫,但是沒有找到准確的資料去印證),\s
是space(空間)的縮寫、\d
是digit(數字)的縮寫、\w
是word(可以理解成不是傳統意義上的單詞而是代碼中的變量名,變量名可包含的元素就是字母數字下划線)的縮寫。好了,看到此處你應該已經熟記了6個元字符了。接下來,\n
和\t
平時會經常用到,這個肯定比較熟了,最后一個元字符‘.
’可以理解它匹配一行中的所有元素,因為遇到換行符后就不再進行匹配了(萬事萬物源於一點)。
用於表示數量
有些元字符用於表示某種元素的數量,如\d表示一個數字,當你想表示6位數字怎么辦?當然可以\d\d\d\d\d\d
,但確實太麻煩了,為了簡便就需要一些表示數量的元字符,上述可以寫成\d{6}
,元字符詳情如下:
元字符 | 說明 |
---|---|
* | 0個或多個 |
+ | 1個或多個 |
? | 0個或1個 |
{n} | n個 |
{n,} | n個或多個 |
{n,m} | n到m個(m必須比n大,否則語法錯誤) |
這幾個元字符還算比較好記。*
表示0個或多個,+
表示1個或多個,這個可能會混淆,或許你可以這么記,*
表示1*0=0或多個,+
表示1+0=1或多個。?
表示0或1個,可以理解成某個人在問你這個類型的元素有還是沒有呀?你回答可能有(1)也可能沒有(0)。
剩下的三個只要記住大括號是用來表示數量,后續我們還會看到除了{}
外,還有[]
和()
。它們各有各的作用。
用於表示位置
有些元字符沒有具體的的匹配項,它只是一個抽象的位置概念,它用來表示字符串中的各個位置。一個字符串的位置可以分成:字符串的開頭或結尾、單詞的開頭或結尾。如字符串‘I am a tester_.’,I前面是字符串的開頭位置,英文句號后面為字符串的結尾位置,每一個word(注意此處指的不是傳統意義上的單詞)前后的位置即為單詞的開頭或結尾,對於‘tester_’來說t前面是單詞開頭,下划線是單詞結尾。
元字符 | 說明 |
---|---|
\b | 匹配單詞的開頭或結尾位置 |
^ | 匹配字符串的開頭位置 |
$ | 匹配字符串的結尾位置 |
其中\b
在前面的例子中有說過,此處可以以這種方式記憶:\b
是block(塊)的縮寫,即一個單詞是一塊內容,\b
是這一塊的邊界。至於另外兩個元字符,暫時沒找到很好的記憶方法(^
一個尖角,小荷才露尖尖角?),但應該也不難記。
此處有個地方要提及一下,所有表示位置的不會實際占用字符。為了理解可以繼續看最上面的第二個例子,\btest\b
最終匹配出來了子字符串“test”,而不是“ test ”。
大家依據目前了解的元字符概念,可以思考一下這個正則表達式^\d{6,10}$
,和\d{6,10}
的區別。針對字符串‘12345678‘,第一個和第二個都可以匹配出’12345678‘。但是針對字符串’W12345678‘,只有第二個可以正確匹配出’12345678‘,原因在於第一個正則表達式的意思匹配一個字符串只有6-10個數字組成,而第二個正則表達式意思是匹配字符串中的6-10個連續數字。
除了這三個元字符表示位置外,還有零寬斷言、負向零寬斷言也表示位置,后續會詳細介紹。
用於字符轉義
字符轉義的概念大家肯定不陌生,對於*, +
等有特殊意義的元字符,假如你想匹配5個*號應該怎么寫,*{5}
嗎?肯定不是,這樣寫是語法錯誤,應該使用\將其轉義:\*{5}
。這樣一來*的特殊意義就被\給取消了,想要匹配\的話,也是一樣,再用一個\把特殊意義取消掉就好了。
字符集
前面列出了部分用於表示意義的元字符,但是可能這幾個元字符覆蓋的都太廣泛了,想要具體的匹配某一類字符。比如就是想匹配abcd這四個字符中的某一個,正則表達式當然也是支持的。
這時候就需要用到第二種括號,中括號[]
。匹配abcd中的某一個可以寫成[abcd]
或者[a-d]
,意思是匹配一個a-d中的任意字符。相反若匹配非abcd的任意字符,可以寫成[^abcd]
,意思是匹配一個不是abcd的字符。
括號內也可以寫入不同類型的元素,如[a-d1-7@]
,表示的是匹配一個a-d或1-7或@中的任意字符,[^a-d1-7@]
則與之相反
分組
講完中括號后我們可以看一下小括號()
,小括號的意思是分組,即小括號內部的所有元字符是一個整體。
之前有學過表示數量的元字符,但是那個表示的數量都是針對於一個元字符來說的,比如ab+
表示的是匹配一個a后面跟着1個或多個b的子字符串。
倘若我們想要匹配的是1個或多個ab(如:abababab),此時分組就派上作用了,可以這么寫:(ab)+
。此時ab被綁定為一個整體,后面的數量元字符對這個整體起作用。
分枝條件
元字符中有一個或運算符,它與大多數編程語言類似都是用 | 來表示。它的作用為:Ab|aB
表示的是匹配Ab或者aB。通過這個例子可以很直觀的理解該元字符的作用。當然它也經常和分組一起使用:(Ab|aB)+c
,該正則匹配開始為1-N個Ab或aB之后是c的子字符串,如:AbaBc, AbAbAbaBc。
后向引用
后向引用的使用是依附於分組的,分組的概念之前講過了。
首先,我們先看一下正則表達式中組號的分配方式,此時先看一個用到分組的正則表達式:(ab)?(c|C)d
。這個正則的意思大家現在肯定都清楚了。這個正則表達式里面用到了兩個分組分別是(ab)
和(c|C)
。正則內部會對所有分組進行組號分配,從左向右,第一個分組(ab)
的組號是1,第二個分組(c|C)
的組號是2。而組號0代表的是整個正則表達式。嘗試過python正則的此處應該有印象,匹配對象的group方法傳參為0或不傳則返回整個正則所匹配的結果,傳參為1為第一個分組匹配的結果。
了解了組號分配方式后,可以開始解釋后向引用了。后向引用就是將前面某個分組已經匹配的數據拿過來用,第一個分組匹配的數據用\1
代替,第二個分組匹配的數據用\2
代替,依次類推。
似乎不是特別好理解,直接看例子吧,(ab)?(c|C)d\2D
該正則中\2
表示的是第二個分組匹配到的數據,若第二個分組匹配到了c那么\2
就是c,反之亦然。所以它能匹配到:abcdcD, abCdCD。不能匹配:abcdCD, abCdcD。通過這個例子可以理解它的作用了吧。
當然分組除了有自己的組號外,還可以給它自定義組名。不同編程語言中的方式不同,Python中自定義組名的格式為:(?P<Name>exp)
,Name為你自定義的組名,exp代表任意元字符的組合。后面引用的方法為(?P=name)
。所以上面例子可以修改成:(ab)?(?P<CWord>c|C)d(?P=CWord)D
。
組號分配介紹
上一節簡單的講了一下正則表達式是如何分配組號的,但其實還有幾個需要注意的地方。
-
雖然組號是從左向右進行分配,但是掃描兩遍,第一遍先分配給未命名的分組,第二遍再分配給命名的分組。所以命名后的分組組號會更大
-
使用
(?:exp)
可以使一個分組不分配組號,如(?:ab)?(c|C)d\2D
中(ab)
就沒有分配到組號,而(c|C)
組號為1
貪婪與懶惰
人性是貪婪的,正則表達式與人一樣也是貪婪的。一個正則表達式會盡量多的去匹配字符串,如:ab.+c
去匹配’abccccc’是會將該字符串全部匹配出來。但有時候我們只想要其匹配’abcc’,此時怎么辦呢?需要給正則表達式中表示數量的元字符加一個?
變成ab.+?c
。此時該正則表達式就變懶了,不會再去匹配那么多,匹配到‘abcc’就完事了。
元字符 | 說明 |
---|---|
*? | 0個或多個,盡可能少 |
+? | 1個或多個,盡可能少 |
?? | 0個或1個,盡可能少 |
{n}? | n個,盡可能少 |
{n,}? | n個或多個,盡可能少 |
{n,m}? | n到m個,盡可能少 |
零寬斷言及負向零寬斷言
這兩個個概念有些不太好理解。正如前面所說這兩個也是表示位置的元字符。從字面意思上理解,零寬代表其沒有寬度,即如之前介紹表示位置的元字符中提到的一樣,不會實際占用字符。斷言是什么?是assert,是用來判斷條件是True還是False。理解完這兩個詞語的意思后,零寬斷言的概念應該也就能理解了。那么負向無非就是它的反義詞。
元字符 | 名稱 | 說明 |
---|---|---|
(?=exp) | 零寬度正預測先行斷言 | 匹配exp前面的位置 |
(?<=exp) | 零寬度正回顧后發斷言 | 匹配exp后面的位置 |
(?!exp) | 零寬度負預測先行斷言 | 匹配后面跟的不是exp的位置 |
(?<!exp) | 零寬度負回顧后發斷言 | 匹配前面不是exp的位置 |
上面的表格主要看第一列它是什么格式就好,反正后面的名稱和說明也很難看懂。接下來我來用自己的理解通俗的解釋一下這些概念。
首先字符串中可以有四種方式確認某個子字符串的位置,如字符串‘BACAB’中有兩個A,A前面是B、A前面不是B、A后面是C、A后面不是C。上述四種條件都能夠匹配出唯一一個子字符串A。這個例子大概理解的話就可以往后看了。
-
(?=exp)
中exp指代的是任意元字符的組合,結合具體的例子來理解該元字符的用法,一個正則表達式為A(?=C)
,它代表的情況就是A后面是C的情況。所以匹配出了第一個A,由於該元字符是零寬所以它只能匹配出A而不是AC。 -
(?<=exp)
與上面用法相反,一個正則表達式為(?<=B)A
,它代表的情況就是A前面是B的情況。所以匹配出了第一個A。如果改成(?<=C)A
,則能匹配出第二個A。 -
(?!exp)
的例子為:A(?!C)
,它代表的情況為A后面不是C,所以匹配出第二個A。 -
(?<!exp)
的例子為:(?<!B)A
,它代表的情況為A前面不是B,所以匹配出第二個A。
通過上面四個例子的介紹,應該對於這兩個概念、四個元字符有了了解。理解是重點,記下來也是重點。本人是這樣記下來的,四個元字符的基本格式都是(?),只不過問號后面的不一樣。分下面兩種情況:
-
XXX前/后是XXX的話就寫一個=,XXX前/后不是XXX的話就寫一個!。這個和日常用的=和!=差不多。
-
如果表示的意思是前的話,這個元字符就需要出現在前面且要加一個類似於向前指的箭頭<。如果表示的意思是后的話,就什么都不需要加。
通過上面兩個情況的歸納,是不是這四個元字符就都記下來了,上述記憶方法為個人拙見,僅供參考。
總結
到目前為止,正則表達式的基本內容都介紹完了。但是文中用的例子都比較簡單,只能幫助你理解概念。如果感興趣或者工作中能用到的話,還需要后續勤加練習。最后分享大家幾個比較有用的文章和網站:
-
https://any86.github.io/any-rule/ 13,這個是幾個大佬編寫的正則大全圖形界面版,他們還出了一個插件版,可以下載后試試。
-
https://zhuanlan.zhihu.com/p/38229530 8,一篇有意思的文章。
-
https://deerchao.cn/tutorials/regex/regex.htm 8, 一個大佬的博客教你在30分鍾內學會正則。
實際使用案例
你以為文章到總結就結束了?So naive, 當我們知道正則表達式大概是個什么東西,大概怎么去用了之后。我再來列舉一個日常工作中的案例,將理論應用到實踐。正則表達式在日常使用中一定是基於某一種編程語言的,后面的案例編程語言選擇python(因為我目前只會這個)。
設想這么一個場景,在測試過程中需要獲取某個時間段內某個程序的運行情況,從而分析出該程序的穩定性或使用頻率等指標,該程序的日志記錄完備,日志格式固定且已知。這時候最佳的辦法就是從該程序日志中進行相關信息的獲取。假如該日志內容格式大概如下(注:該日志樣例不是實際項目中的日志文件,為個人舉例):
2020-02-17 11:04:34 [INFO] 接收到來自IP:182.168.3.111的訪問,訪問的認證方式為郵箱:110232123@qq.com,獲取數據狀態碼1,獲取數據12931KB
2020-02-17 11:05:34 [INFO] 接收到來:自IP:182.168.3.111的訪問,訪問的認證方式為手機號:008617626045747,獲取數據狀態碼2,獲取數據0KB
2020-02-17 12:04:34 [WARN] IP:182.168.3.111訪問失敗
2020-02-17 11:04:34 [ERROR] 連接XXX服務失敗,正在重連。。。。
從這個日志中可以看到訪問成功的IP及其認證賬號、訪問失敗的IP、程序的錯誤信息。那么我們怎么把這些數據給抓取出來呢?抓取的方法肯定有很多,如果此時你第一時間想到了正則表達式,那么恭喜你,通過閱讀前面的文章,正則已經在你心中留下了痕跡,或者它本來就留有痕跡。
我們先來分析一下第一條日志,其余的與此類似,有用的信息可以分成如下幾個片段:
- 時間字符串:2020-02-17 11:04:34
- 日志級別:INFO
- IP:182.168.3.111
- 認證郵箱:110232123@qq.com
- 狀態碼:1
- 客戶端獲取到的數據大小:12931KB
上面幾個片段對應的正則為:
- 時間字符串:
\d{4}-\d{2}-\d{2}\s*\d{2}:\d{2}:\d{2}
- 日志級別:
\[INFO\]
- IP:
(\d{1,3}\.){3}\d{1,3}
- 認證郵箱:
\w+@\w+\.\w+
- 狀態碼:
\d+
- 客戶端獲取到的數據大小:
\d+KB
上述中某幾個正則其實並不嚴謹,比如IP對應的正則還可以匹配出999.999.999.999。嚴謹的正則表達式是((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)
。由於該正則太長,加之此處重點在於如何應用,故暫用其寬松版的正則表達式。
知道了各個字段的正則后,我們可以將它們各自寫成一個分組,分組之間填充上其余元字符,把匹配整行日志的正則表達式寫出來,如下:
(\d{4}-\d{2}-\d{2}\s*\d{2}:\d{2}:\d{2})\s*\[(INFO)\]\s*.*:((\d{1,3}\.){3}\d{1,3}).*:(\w+@\w+\.\w+)\D*(\d+)\D*(\d+)KB
現在我們通過這個正則表達式可以抓取出日志文件中這種格式的日志字符串,再根據組號就可以拿出來對應的數據了。不過根據組號取數據可能會有些含糊不清,或許我們可以給每個分組進行命名(使用python支持的方式),形成如下正則表達式:
(?P<Time>\d{4}-\d{2}-\d{2}\s*\d{2}:\d{2}:\d{2})\s*\[(?P<LogLevel>INFO)\]\s*.*:(?P<IP>(\d{1,3}\.){3}\d{1,3}).*:(?P<Email>\w+@\w+\.\w+)\D*(?P<status>\d+)\D*(?P<data_size>\d+)KB
好了現在我們可以很清楚的看到,表示時間的分組命名為Time,依次類推。接下來,我們可以使用上述正則表達式去抓取一行日志,再通過分組的名稱拿到對於的字符串數據了。具體的代碼可以參考下面的樣例:
import redef reg_deal(pattern_list, text, func_dict=None):
if func_dict is None:
func_dict = {}
for pattern in pattern_list:
match_obj = re.match(pattern, text)
if match_obj:
return {k: func_dict.get(k, lambda x: x)(v) for k, v in match_obj.groupdict().items()}if name == 'main':
email_pattern = r"(?P<Time>\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\s[(?P<LogLevel>INFO)]\s.😦?P<IP>(\d{1,"
r"3}.){3}\d{1,3}).😦?P<Email>\w+@\w+.\w+)\D(?P<status>\d+)\D(?P<data_size>\d+)KB"
phone_pattern = r"(?P<Time>\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\s[(?P<LogLevel>INFO)]\s.😦?P<IP>(\d{1,"
r"3}.){3}\d{1,3}).😦?P<Phonenum>((+|00)86)?1[3-9]\d{9})\D(?P<status>\d+)\D(?P<data_size>\d+)KB"
warn_pattern = r"(?P<Time>\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\s[(?P<LogLevel>WARN)]\s.😦?P<IP>(\d{1,"
r"3}.){3}\d{1,3})."
error_pattern = r"(?P<Time>\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\s[(?P<LogLevel>ERROR)]\s(?P<ERROR_Message>.*)"
pattern_list = [email_pattern, phone_pattern, warn_pattern, error_pattern]
status_dict={
'1': 'Sucess',
'2': 'Fail'
}
func_dict = {
'status': lambda x: status_dict[x],
'data_size': lambda x: int(x)/1024
}
result_list = []
with open('logcontent.log', 'r', encoding='utf-8') as f:
for data in f:
result_dict = reg_deal(pattern_list, data, func_dict)
result_list.append(result_dict)
print(result_list)
代碼中實現了一個函數reg_deal,后面代碼都是對於這個函數的實際應用,該函數入參為:正則表達式組成的列表、待匹配的字符串、特殊函數組成的字典。其先循環將字符串與列表中各個正則表達式進行匹配,匹配成功后得到一個匹配對象,調用該匹配對象的groupdict函數可以返回一個結果字典,該結果字典的鍵為分組的名稱,值為分組匹配到的值。針對這一結果字典再進行一步特殊函數處理,如上述中的status字段日志中是碼值,但輸出結果需要是具體的漢字。故對其進行了一步碼值轉換操作,對與數據大小將KB轉化成了MB。
若使用該函數,需自己將正則表達式寫出來並對正則表達式中的分組進行命名,若有些分組數據需要特殊處理,則維護一個特殊函數字典,鍵為分組名,值為函數(匿名函數或者是函數名稱)。將參數傳入后即可獲得結果字典或者None。得到結果字典后具體怎么處理就看你接下來的發揮啦。
注:本文是轉載,並非原創,原文地址:https://ceshiren.com/t/topic/1613