星期一, 9月 26, 2005

PHP 的正規表示式應用

Ch.0 前言:

這篇文章初稿大約是三年前為了幫 PHP 週報撰稿時倉促寫成的,未經過整理與編排,繆誤在所難免,因此在自己的 blog 上重新編排整理供自己參考。


Ch.1 什麼是正規表示式(Regular Expression)?


Regular Expression 常譯為『正規表示式』或『正則表達式』等等,簡寫為 RE,在後面我們將以 RE 來稱呼 Regular Expression,所謂 RE 就是使用特定字串或符號來表達具有某種符合吾人特定字串或符號的字串,最適合的應用是從一段文章中找出具有某特徵的字串,再加以處理(列印、置換、計算...)。
  • 名稱: RE、Regular Expression、正規表示式、正規表達式、正規表示式、正則表達式。
  • 正義: 使用特定字串或符號,以表達具有某種符合吾人特定字串或符號的字串。
  • 應用: 從文章中找出具有某特徵的字串,再加以處理(列印、置換、計算...)。

Ch.2 為什麼要使用 RE?


RE 在程式設計上的應用是簡化字串處理的複雜度,雖然有許多人批評 RE,如有人說 Perl 是種 Write-Only 的程式語言,也有人罵 sendmail 的 sendmail.cf 簡直就是『有字天書』,如: ^.+@.+\\..+$ 這段看似毫無意義的符號卻是一組精簡有效率的正規式,僅管如此,筆者相信一樣東西存在一定有它的意義和理由,RE 其實沒有想像中的那麼難懂,而且用久了您會發現 RE 其實真的很方便好用,希望筆者這篇文章能讓您對 RE 有所體認。
註:Write-Only 是指你寫好之後就看不懂或很難修改。


Ch.3 如何在 PHP 中使用 RE?


由一連串的字元與符號組成的 RE 字串我們稱之為 Pattern(模版),最簡單的模版是不包含任何特輸字元的一組字串,如 'php' 符合 'php - the hypertext processer',接下來筆者會指引您如何使用 PHP 來做有效的字串處理與應用,PHP 常用的 RE 函式共有兩組,一組是由 preg 開頭的,一組是由 ereg 開頭的,很多人一定不清楚到底該使用哪一個,而筆者個的人習慣是使用 preg (PCRE Library, Perl-Compatible) 的函式庫,最主要的原因有兩點:
  • 它比 PHP 的 POSIX Extended 的 Regular Expression Functions 快又有效率。(註1)
  • 它的模板與 Perl 相容,移植到 Perl 的程式時會比較方便,另外 preg 提供的功能也比 ereg 更多。
如果您只是要做簡單的字串替換,筆者建議使用 str_replace() 或 strtr() 這類的函式,但是如果我們所要搜尋的條件不只一種時,就是 PCRE 函式的最佳應用時間,筆者將一一列出常見的符號使用方式與使用時機,並直接以 PCRE 函式的使用方式來表示。

在您開始使用 PCRE 函式庫時,要先瞭解 PCRE 模板的一些基本用法,在 PCRE (或 Perl) 中,正規式的模板是由兩個 / 來包含住的(註2),第二個斜線後接的是 Pattern Modifiers (模板修飾符),最常見的像: '/php/i' 符合 'PHP - the Hypertext Processor' 其中在斜線後面的 i 這個 modifier (修飾符)代表了不分大小寫,所以 php, PHP, Php, pHp phP 都符合 /php/i,以 PCRE 的函式寫出來就是:
if (preg_match('/php/i', 'PHP - the Hypertext Processor') ) {
echo '找到了';
} else {
echo '什麼也沒有';
}
preg_match 會搜尋符合模版的字串並傳回一個布林變數(真或假)。

註1:
PCRE Lib 是由劍橋大學的 Philip Hazel 寫的,經過效能測試的結果發現速度由快到慢為:
String Functions preg Functions (PCRE) ereg Functions (POSIX Extended)
理論上來說 PCRE 的函式有做自身的 cache 似乎是增進效能的主要原因。

註2:
除了 / 之外,另有一些字元也可當成模板包含符號如: !PHP!i 或 #PHP#i
這時候 / 就不必再加 \ 來脫離,常見的用法像這樣: !http://!i


Ch.4 RE pattern 常用特殊字元介紹

^ --> 字串開頭的第一個字
例:/^PHP/ 符合任一個以 PHP 為開頭的字串

$ --> 字串結尾的最後一個字
例:/PHP$/ 符合任一個以 PHP 為結尾的字串

. --> 符合所有字元
例:/P.P/ 符合 P 開頭P結尾的所有三個字母如 PHP, PPP, PnP, POP

[] --> 某字元可以符合哪些字元
例:/P[Hn]P/ 符合 PHP 與 PnP
其中 [] 括起來的字可以使用 - 來指定一個範圍如:
[A-Z] --> 符合 A 到 Z
[0-9] --> 符合 0 到 9
[A-Z0-9] --> 符合 A-Z 或 0-9

那我們現在有個問題,如果我們需要符合 - 這個字元怎麼辦?
答案很簡單,那就是 - 放在最前面如: /[-0-9]/ 表示符合 - 或 0 到 9。
另一種方式是加一個 \ 來脫離特殊字元如: /[0-9\-]/
| --> 或是 (OR)
例:/PHP|JSP/ 符合 PHP 與 JSP

() --> 副模板
例:/(PH|JS|AS)P/ 符合 PHP, JSP, ASP

{n, [m]} --> 前面那個字元符合 n 到 m 個
例:/a{1,2}/ 符合 'a' 或 'aa'
我們可以把後面的 m 省略,代表符合 n 個以上如:
/a{2,}/ 符合兩個以上的 a ,但是不符合只有一個 a。

+ --> 等於 {1,} 符合一個以上
例: /a+/ 等於 /a{1,}/ 符合一個以上的 a

* --> 等於 {0,} 符合零個以上
例:/.*b$/ 符合任意字以 b 結束

? --> 等於 {0,1} 可有可無
例:/a?ba/ 符合 aba 或 ba;

大致上正規式會用到的語法就是這些了,這邊只講基本的部份,其實 PCRE Library 還有一些更精確的用法像貪心判別,條件式和精確符合式等等。


Ch.5 Modifier 修正符介紹與應用技巧

在這麼多 modifiers 中與 perl 相容的 modifier 只有 /i /m /s /x (也是最常用到的),其他的 modifier 和 perl 是不相容的,而在 php 中除了 /e 之外比較常用到的也是這幾個,所以如果您覺得太多學不來,可以只學這幾個就好,因為用到的機會最多,而說實在的除了這幾個之外,筆者也沒用過其他的(/x 用到的機會也很少,筆者幾乎沒用過)。

基本上筆者認為在 php 使用 PCRE 必學的大概只有 /i /m /s /e,尤其是 /s 在中文字串比對時是強烈建議要加的。


其實其他的修飾符筆者本來是不想介紹的,一來自己沒在使用怕誤導大家,二來用到的機會是微乎其微的,若有錯誤的地方還請不吝指正。


i --> (PCRE_CASELESS) case-insensitive - 不分大小寫
說明:加入 i 這個修飾符時,模板中的字串將不會區分英文大小寫,預設是會區分大小寫。
範例:/php/i 符合 PHP, PHp, PhP, Php, pHP... 等等


m --> (PCRE_MULTILINE) - 多行比對
說明:在預設的狀態中,PCRE 函式會將目標字串都當成一行(乎略掉 \n),如果這個修飾符被設定時,則每碰到 \n 都會重新比對 ^ 和 $ 這兩個特殊字。
範例:
$wc3cheats = "WHO'S YOUR DADDY\nTHERE IS NO SPOON\nGREED IS GOOD\n";
preg_match('/^THERE/m', $wc3cheats); // true


s --> (PCRE_DOTALL) dot - "." 這個符號符合所有字元
設置 /s 修飾符,則在模版中的 "." 這個魔法字元(metacharacter)會符合所有的字元,包含 \n 換行等,預設的 "." 是不符合特殊字元的 (如中文碼的開頭 0x80,換行碼等)。
範例:
$wc3cheats = "WHO'S YOUR DADDY\nTHERE IS NO SPOON\nGREED IS GOOD\n";
preg_match('/.greed/i', $wc3cheats); // false
preg_match('/.greed/is', $wc3cheats); // true


x --> (PCRE_EXTENDED) 模板去白字
說明:設置這個修飾符時,只要是在模板中的白字(Whitespace 包含空白、換行等無法顯示的控制碼)都會被忽略掉,就是在比對之前先把模板中的白字去除就是了,但是有用 \ 脫離的字元或是被 [] 包起來的字元不會被乎略。
範例:
$wc3cheats = "WHO'S YOUR DADDY\nTHERE IS NO SPOON\nGREED IS GOOD\n";
preg_match('/greed is/i', $wc3cheats); // true
preg_match('/greed is/ix', $wc3cheats); // false
preg_match('/go od/ix', $wc3cheats); // true


e --> 執行自訂或外部定義函式
說明:這個修飾符只有 preg_replace 這個函式有效,如果我們想替代的字串要執行某個函式來轉換時,就一定要加這個修飾符。
範例:
$wc3cheats = "WHO'S YOUR DADDY\nTHERE IS NO SPOON\nGREED IS GOOD\n";
echo preg_replace('/greed is good/ie', "strtolower('\\0')", $wc3cheats); // 把 GREED IS GOOD 轉成小寫
註:在筆者實作的階段中發現 class 中的 member functions 是無法使用 /e 來執行的,如 $obj- 或 CLASS::function() 都是不可行的。
重新排版加註:據說現在的較新版本已經可以使用 /e 來呼叫 member functions 了,因為筆者已經數年未使用 PHP,故無法證實此說法,如果您使用較舊版本的 PHP 請注意 /e 無法呼叫 member functions。


A --> (PCRE_ANCHORED) 下錨
說明:筆者不是很清楚這個修飾符的正確應用,原文說明是指如果設定了 A 則模板會被強制下錨 ("anchored"),也就是說,模板的字串只會符合要比對字串的開頭 (the "subject string" - 標題字串),換句話說這和我們在模板中設置 ^ 符號是很類似的,基本上來說這個應該是用來增進速度使用的。
範例:
$wc3cheats = "WHO'S YOUR DADDY\nTHERE IS NO SPOON\nGREED IS GOOD\n";
preg_match('/who/iA', $wc3cheats); // true
preg_match('/who/i', $wc3cheats); // false
preg_match('/^who/i', $wc3cheats); // true

// 可是和 m 修飾符同用時和會加 ^ 不同
preg_match('/^who/im', $wc3cheats); // true

// 這就是差異處:
preg_match('/^there/im', $wc3cheats); // true

//
這邊就不同了
preg_match('/there/imA', $wc3cheats); // false


D --> (PCRE_DOLLAR_ENDONLY) 完整比對 $
說明:當 D 被設置時,在模版中的 $ 魔法字元會完全比對結尾字串,在一般狀況下 $ 會忽略換行碼(\n),但是如果這個修飾符被設置時,換行將不會被忽略掉。要注意的是不可以和 m 這個修飾符同時使用。(使用 m 時,D 會被忽略掉)
範例:
$wc3cheats = "WHO'S YOUR DADDY\nTHERE IS NO SPOON\nGREED IS GOOD\n";
preg_match('/good$/i', $wc3cheats); // true
preg_match('/good$/iD', $wc3cheats); // false
preg_match('/good\n$/iD', $wc3cheats); // true


S --> 模板多次使用初始化
說明:筆者也沒用過這個東西,所以就直接翻譯了。
翻譯:當一個模板被預知會被多次使用時,是很值得加上這個修飾符來分析並改善比對的速度。當這個修飾符被設置時,則會先執行這個附加的分析處理。
範例:無


U --> (PCRE_UNGREEDY) 貪心判別切換

說明:這個修飾符會反轉貪心判別狀態,預設值是會貪心判別,當加上 U 時啟始值就會變成不貪心判別,除非碰到 ? 時才會轉為貪心判別。反之亦然(如果想在預設為貪心判別的模板中想轉成不貪心判別則加一個 ? 即可),最有用的例子像 C 語言的註解取法,如下。

範例:
header('Content-Type: text/plain'); // 為了 var_dump() 輸出時的美觀
$comment = '/* 我是第一個註解 */ 我不是註解 /* 我是第二個註解 */';
preg_match_all('|/\*(.*)\*/|s', $comment, $backref);
var_dump($backref);

執行結果:

array(2) {
[0]= / {
[0]= / string(52) "/* 我是第一個註解 */ 我不是註解 /* 我是第二個註解 */"
}
[1]= / {
[0]= / string(48) " 我是第一個註解 */ 我不是註解 /* 我是第二個註解 "
}
}
像這樣子,$backref 會把中間不是註解的部份一也併抓出,因為貪心判別在符合第一個結尾的 "*/" 還會繼續判斷未完的字串而抓到最後那一個 "/*",這樣的結果就不是我們所想要的。

註:backref 是 Back Reference , 關於 Back Reference 筆者稍後會說。

而如果我們想要正確的取出我們想要的註解字串,很簡單,只要加上 U 這個 modifier 就可以,如:
header('Content-Type: text/plain');
$comment = '/* 我是第一個註解 */ 我不是註解 /* 我是第二個註解 */';
preg_match_all('|/\*(.*)\*/|sU', $comment, $backref);
var_dump($backref);

執行結果:

array(2) {
[0]= / {
[0]= / string(20) "/* 我是第一個註解 */"
[1]= / string(20) "/* 我是第二個註解 */"
}
[1]= / {
[0]= / string(16) " 我是第一個註解 "
[1]= / string(16) " 我是第二個註解 "
}
}
另外說明中有提到如果我們在模板中有加上 ? 則會反轉貪心判別,所以像上面的例子也可以用這種方式來取代:
header('Content-Type: text/plain');
$comment = '/* 我是第一個註解 */ 我不是註解 /* 我是第二個註解 */';
preg_match_all('|/\*(.*?)\*/|s', $comment, $backref); // 沒有 U 哦!仔細看模板多了一個 ?
var_dump($backref);

註: 關於 preg_match 和 preg_match_all 的差別在於前者只會幫您找出第一組符合的字串,而 preg_match_all 會幫您找出所有符合的字串,如果只是要判別是否符合用 preg_match 會比較快,如果是要截取所有符合的字串則應該使用 preg_match_all,而 preg_replace 的搜尋替代方式則是和 preg_match_all 是相同的。


X --> (PCRE_EXTRA) PCRE 擴增
說明:這個修飾符和 Perl 是不相容的,所有倒斜線之後的字元如果沒有特別含義都會引發錯誤,以這樣的方式來保留未來可能會加上的字元(功能)。預設狀態中,就像 Perl 一樣,倒斜線之後的字元若沒有特別含義將會被當成一般字元。


u --> (PCRE_UTF8) 使用 UTF8 Unicode 模板 (PHP 4.1.0+)
說明:模板中的字串會被對待成 UTF-8 (Unicode) 格式,這個功能和 Perl 是不相容的。


Ch.6 About "Back Reference"

Back Reference 就是把比對字串中符合的部份回傳,其實就是回傳值,這項功能也是 Perl 所具備的(只是語法和用法不太一樣),Back Reference 的應用層面非常的大,也非常實用,像上述有兩個例子就有使用到 Back Reference,如 e 修飾符的範例: echo preg_replace('/greed is good/ie', "strtolower('\\0')", $wc3cheats); 其中的 \\0 就是 back reference,\0 的義思就是符合整個模板的字串,如果我們只想要取出模版的部份則可以使用 () - 所謂的 sub pattern 來取。


如 '/((greed) (is)) good/' 則有四個 back reference 如下列:
\0 = 符合整個模板的字串
\1 = 符合 ((greed) (is)) 的字串
\2 = 符合 (greed) 的字串
\3 = 符合 (is) 的字串

而我們使用 preg_match 的第三組參數就是用來承遞 back reference 的。

像 U 裡面的那個範例:
preg_match_all('|/\*(.*)\*/|sU', $comment, $backref);

其中的 $backref 會回傳一個二維陣列,第一維表示有符合幾筆資料,
第二維就是符合 pattern 或 sub-pattern 的 back reference。


Ch.7 結語

我們上面講了一大堆正規式的使用方式,可是讀者一定很好奇我們學東西有什麼用?其實 RE 可以運用的地方實在太多了,像:
輸入資料的查驗、爬虫程式、編碼轉換、標簽過濾等等...以下列出部份的應用範例,其他更多的應用就等您去發掘了。

台灣地區身份證字號查驗用的模板(最簡單的應用):/^[A-Z][0-9]{9}$/i

MySQL 格式的日期取年月日時分秒方法:
list($year, $month, $day, $hour, $min, $sec) = preg_split('/[\- :]/', $datetime);

分析某個網址有多少連結:
/* 分析某個網址有多少連結 */

function carwler($url) {
$base_url = preg_replace('/'.preg_quote(strrchr($url, '/'), '/').'$/ism', '', $url).'/';
$base_server = 'http://'.strtok(preg_replace('#^http://#i', '', $url), '/').'/'; // get server url;
$string = join('', file($url));
preg_match_all('/href=["\']?([^\s\'"#]+)/mis', $string, $back_ref);
$ref = $back_ref[1];
for($i=0; $i count($ref); $i++) {
if(!preg_match('#^http://#mi', $ref[$i])) { // 不是完整的 url (相對路徑)
$pdp = preg_quote('../', '/'); // parent dir pattern
if(preg_match('/^'.$pdp.'/', $ref[$i])) { // link url 使用 ../ 之類的指向上層目錄
preg_match_all('/'.$pdp.'/', $ref[$i], $bf);
$nested = count($bf[0]); // 有幾層
preg_match_all('#[^/]+/#', $url, $parent_dirs);
$parent_dirs = $parent_dirs[0];
for($j=0; $j $nested; $j++) { // 換算成完整的 url
array_pop($parent_dirs);
}
$parent_dirs = str_replace('http:/', 'http://', join('', $parent_dirs));
echo $parent_dirs;
} else if(preg_match('#^/#i', $ref[$i])){ // 使用 /xxx/xxx 指向某個本端路徑
$ref[$i] = ''.$base_server.preg_replace('#^/#', '', $ref[$i]).'';
} else {
$ref[$i] = ''.$base_url.$ref[$i].'';
}
}
echo $ref[$i].'';
}
}

正規表示式介紹到此也該告個段落了,網路上有許許多多這方面的資源,請注意這幾篇文章只是在介紹正規式在 PHP 上的應用,而不是正規式的教學文件,如果有未臻完善的地方還請各位高手自己補充。

沒有留言: