軟體測試之30分鐘輕鬆搞定正規表示式基礎

霍格沃茲測試學院發表於2020-09-02

 

 

 

本文為霍格沃茲測試學院優秀學員學習筆記。

正規表示式簡介

提起正規表示式,可能大家的第一印象是:既強大好用但也晦澀難懂。正規表示式在文字處理中相當重要,各大程式語言中均有支援(跟 Linux 三劍客結合更是神兵利器)。

正規表示式是對字串操作的一種邏輯公式,就是用事先定義好的一些特定字元、及這些特定字元的組合,組成一個“規則字串”,這個“規則字串”用來表達對字串的一種過濾邏輯。(來自百度百科)

個人理解如下:某個大佬為了從字串中匹配或找出符合特定規律(如手機號、身份證號)的子字串,先定義了一些通用符號來表示字串中各個型別的元素(如數字用 \d 表示),再將它們組合起來得到了一個模板(如:\d\d模板就是指代兩個數字),拿這個模板去字串中比對,找出符合該模板的子字串。

簡單的例子

由幾個例子去進一步理解,比如現在有一個字串為:

I am a tester, and My job is to test some software.

  1. test是一個正規表示式,它的匹配情況:I am a tester, and My job is to test some software. 它既可以匹配tester中的test,又可以匹配第二個test。正規表示式中的test就代表test這個單詞本身。

  2. \btest\b是一個正規表示式,它的匹配情況:I am a tester, and My job is to test some software. 它只能匹配第二個test。因為\b具有特殊意義,指代的是單詞的開頭或結尾。故tester中的test就不符合該模式。

  3. test\w*是一個正規表示式,它的匹配情況:I am a tester, and My job is to test some software. 它匹配出了tester,也匹配出了第二個test。其中\w的意思是匹配字母數字下劃線,*表示的是數量,指有0個或多個\w。所以這個正則表達是的意思就是匹配開頭為test,後續跟著0個及以上字母數字下劃線的子字串

  4. 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。

  • (?&lt;!exp)的例子為:(?&lt;!B)A,它代表的情況為A前面不是B,所以匹配出第二個A。

通過上面四個例子的介紹,應該對於這兩個概念、四個元字元有了瞭解。理解是重點,記下來也是重點。本人是這樣記下來的,四個元字元的基本格式都是(?),只不過問號後面的不一樣。分下面兩種情況:

  • XXX前/後是XXX的話就寫一個=,XXX前/後不是XXX的話就寫一個!。這個和日常用的=和!=差不多。

  • 如果表示的意思是前的話,這個元字元就需要出現在前面且要加一個類似於向前指的箭頭<。如果表示的意思是後的話,就什麼都不需要加。

通過上面兩個情況的歸納,是不是這四個元字元就都記下來了?

到目前為止,正規表示式的基本內容都介紹完了。但是文中用的例子都比較簡單,只能幫助你理解概念。如果感興趣或者工作中能用到的話,還需要後續勤加練習。

實際使用案例

你以為文章到總結就結束了?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 re

def 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。得到結果字典後具體怎麼處理就看你接下來的發揮啦。

以上,僅供大家參考,期待多交流指正。

 

 更多技術文章分享及測試資料

相關文章