第3章 正则表达式基础与应用1

正文开始

3.1 认识正则表达式

正则表达式就是用某种模式去匹配一类字符串的一种公式。通俗地讲,就是用一个“字符串”描述一个特征,然后验证另一个“字符串”是否符合这个特征的公式。

比如“ab+”描述的特征是:一个a和任意个b。那么ab、abb、abbbbbbbbbb都符合这个特征,而字符串ad显然是不符合的。

正则表达式可应用到各个方面。在常用的高级编辑器中,几乎都支持正则表达式,如Word、EditPlus、UltraEdit、Vim等。

正则表达式在〈span class="keylink"〉编程〈/span〉语言中更是得到大规模〈span class="keylink"〉推广〈/span〉。现在的语言几乎都是原生的,都可从语法上支持正则表达式,尤其在Perl的推动下,〈span class="keylink"〉PHP〈/span〉、〈span class="keylink"〉Java〈/span〉、.NET、〈span class="keylink"〉Java〈/span〉Script等语言都支持丰富的正则语法;不支持的可以通过一些包实现扩展。每种语言中对正则表达式的支持有所不同,其中Perl和.NET对正则表达式的支持最为强大,而〈span class="keylink"〉JavaScript〈/span〉对正则表达式的支持则比较“朴素”。

注意 本节所讲的一些特性,并不是在所有语言中都支持。
〈/dd〉〈/dl〉 3.1.1 PHP中的正则函数

正则表达式看起来总是那么古怪,以至于许多人对其望而生畏。首先要澄清一些概念:虽然不同语言间正则语法大同小异,但实际上正则表达式的实现有多种引擎(如非确定性有穷自动机NFA、确定性有穷自动机DFA),其表现又有多种风格(如〈span class="keylink"〉Java〈/span〉Script有自己的朴素正则、Perl有一套高级而强大的正则、.NET也有自己的一套正则风格)。另外,还有人可能容易混淆〈span class="keylink"〉PHP〈/span〉中的preg和ereg。

简单地说,〈span class="keylink"〉PHP〈/span〉中有两套正则函数,两者功能差不多:

1)由PCRE库提供的函数,以“preg_”为前缀命名。

PCRE(Perl Compatible Regular Expression,兼容Perl的正则表达式)由Philip Hazel于1997年开发。现代的〈span class="keylink"〉编程〈/span〉语言和软件中一般都使用PCRE库。

2)由POSIX扩展提供的函数,以“ereg_”为前缀命名。

POSIX(Portable Operating System Interface of UNIX,UNIX可移植操作〈span class="keylink"〉系统〈/span〉接口)由一系列规范构成,定义了UNIX操作〈span class="keylink"〉系统〈/span〉应支持的功能,所以“POSIX风格的正则表达式”也就是“关于正则表达式的POSIX规范”,定义了BRE(Basic Regular Expression,基本型正则表达式)和ERE(Extended Regular Express,扩展型正则表达式)两大流派。通常UNIX的一些工具和较老的软件中会使用POSIX风格的正则。另外,一些〈span class="keylink"〉数据库〈/span〉中也提供了POSIX风格的正则表达式。

自PHP 5.3以后,就不再推荐使用POSIX正则函数库,若程序中使用了则会报Deprecated级别的错误,这种情况通常在一些较老的代码中比较常见。其实使用或不使用POSIX正则函数库二者本质上没多大差别,主要是一些表现形式、语法和扩展功能的差别。
〈/dd〉〈/dl〉3.1.2 正则表达式的组成

在Windows资源管理器中查找文件以及批处理文件时,可使用通配符“?”和“?”表示匹配一组字符,这和正则表达式类似,“?”表示一个不确定的字符,而“?”则表示任意多个不确定字符。比如下面是删除本地垃圾文件批处理的部分代码:

del /f /s /q %systemdrive%\*.tmp

del /f /s /q %systemdrive%\*._mp

del /f /s /q %systemdrive%\*.log

del /f /s /q %systemdrive%\*.gid

del /f /s /q %systemdrive%\*.chk

del /f /s /q %systemdrive%\*.old

需要注意的是,这里的“?”和“*”称为“通配符”,而不是正则表达式。

在〈span class="keylink"〉PHP〈/span〉里,一个正则表达式分为三个部分:分隔符、表达式和修饰符。

分隔符:可以是除了字母、数字、反斜线及空白字符以外的任何字符(比如/、!、#、%、|、~等)。经常使用的分隔符是正斜线(/)、hash符号(#)以及取反符号(~)。考虑到可读性,为了避免和反斜线混淆,一般不使用正斜线做分隔符。

表达式:由一些特殊字符和非特殊的字符串组成,比如“[a-z0-9_-]+@[a-z0-9_-.]+”可以匹配一个简单的电子邮件字符串。

修饰符:用于开启或者关闭某种功能/模式。
〈/dd〉〈/dl〉3.1.3 测试工具的使用

在学习过程中,建议下载RegexTester工具验证和测试正则表达式,也可使用Firefox的扩展Regular Expression Tester进行测试,其界面如图3-1所示。

本书以后测试都将利用此工具进行,而不再写〈span class="keylink"〉PHP〈/span〉代码测试。

注意 这个工具测试的代码不一定能在〈span class="keylink"〉PHP〈/span〉中通过,反之PHP中合法的正则表达式在此工具里也不一定能测试通过。其中的道理前面已经讲过了,不同语言实现的正则表达式略有区别。

下面,就来开始最简单的正则表达式入门的介绍。
3.2 正则表达式中的元字符

假设要在一篇文章里查找“he”,可以使用正则表达式“he”。这几乎是最简单的正则表达式,它可以精确匹配这样的字符串:由两个字符组成,前一个字符是“h”,后一个是“e”。通常,处理正则表达式的工具会提供一个忽略大小写的选项,如果选中这个选项,它可以匹配“he”、“HE”、“He”、“hE”这四种情况中的任意一种。

但是很多单词里包含“he”这两个连续的字符,比如“her”、“heet”等。用“he”来查找,这些单词中的“he”也会被找出来。如果要精确地查找“he”这个单词,应该使用以下形式:

\bhe\b

“\b”是正则表达式规定的一个特殊代码,代表单词的开头或结尾,也就是单词的分界处。虽然通常英文单词是由空格、标点符号或者换行来分隔,但是“\b”并不匹配这些单词分隔字符中的任何一个,它只匹配一个位置。

“\b”匹配位置的精确说法:前一个字符和后一个字符不全是(一个是,一个不是或不存在)“\w”。

假如要找“he”后面不远处跟着一个“is”,应该表示如下:

\bhe\b.*\bis\b

这里,点号(.)是元字符,匹配除了换行符以外的任意字符。“*”同样是元字符,不过它代表的不是字符,也不是位置,而是数量——它指定“*”前边的内容可以连续重复使用任意次以使整个表达式得到匹配。因此,“.”和“*”连在一起就意味着任意数量的、不包含换行的字符。现在,“\bhe\b.*\bis\b”的意思很明显:先是一个单词he,然后是任意个任意字符(但不能是换行符),最后是is这个单词。
〈/dd〉〈/dl〉3.2.1 什么是元字符

元字符(Meta-Characters)是正则表达式中具有特殊意义的专用字符,用来规定其前导字符(即位于元字符前面的字符)在目标对象中的出现模式。通过前面的例子,我们已经知道几个很有用的元字符。正则表达式里有很多元字符,常用元字符如表3-1所示。

下面看一些例子。

1)匹配以字母“a”开头的单词:

\ba\w*\b

以上表达式先是某个单词开始处(\b),然后是字母“a”,接着是任意数量的字母或数字(\w*),最后是单词结束处(\b),匹配的单词如adandon、action、a等。

2)匹配1个或更多连续的数字:

\d+

以上表达式可以匹配0、1、555等。这里的元字符+和*类似,不同的是,*匹配重复任意次(可能是0次),而+则匹配重复1次或更多次。

3)匹配刚好6个字符的单词:

\b\w{6}\b

以上表达式匹配action、123456、ste_ph等。

注意 正则表达式里“单词”指不少于1个的连续字母和数字。

如果同时使用其他元字符,则能构造出功能更强大的正则表达式。比如下面这个例子:

0\d\d-\d\d\d\d\d\d\d\d

匹配字符串:以0开头,然后是2个数字,1个连字符,最后是8个数字,也就是中国部分地区的电话号码,如010-12345678。

这里“\d”是元字符,匹配1位数字(0、1、2……)。“-”不是元字符,只匹配它本身——连字符(或者减号,或者中横线,或者随你怎么称呼它)。

为了避免那么多烦人的重复,也可以这样写这个表达式:

0\d{2}-\d{8}
这里\d后面{2}和{8}的意思是,前面\d必须连续重复匹配2次和8次。

思考题 使用“he”、“\bhe\b”分别查找句子“he is a good student ,the most proud of his mother.With him,she hold the hope.”有多少种匹配结果?

下面重点介绍几个常用元字符。
3.2.2 起始和结束元字符

元字符中有两个用来匹配位置:

^:匹配字符串的开始。

$:匹配字符串的结束。

元字符“^”、“$”与“\b”有点类似。“^”匹配字符串的开头,“$”匹配结尾。这两个代码在验证输入内容时非常有用,比如某网站如果要求填写QQ号必须为5~11位数字时,可以使用:

^\d{5,11}$
这里{5,11}表示重复次数不能少于5次,不能多于11次,否则都不匹配。因为使用“^”和“$”,所以输入的整个字符串都要和\d{5,11}匹配。也就是说,整个输入必须是5~11个数字,如果输入QQ号能匹配这个正则表达式,就符合要求。如果输入含有5~11个数字,但不是完整数字串,而只是一串字符的一部分,也不能匹配成功,如图3-2所示。

从图中就能清晰地看出“^\d{5,11}$”的确切含义。我想,你也能猜测到它和正则表达式\d{5,11}的区别。为了加深印象,分别使用下面4个正则表达式看一下效果:

^\d{5,11}$ // 匹配起始和结束位置都是数字的,且连续5~11位

\d{5,11}$// 匹配结束位置是数字的,且连续5~11位

^\d{5,11}// 匹配起始位置是数字的,且连续5~11位

\d{5,11}// 匹配连续的5~11位数字

很自然,在一行中,前三个正则表达式结果只可能有一个匹配结果,而最后一个正则表达式则可以有多个匹配成功的结果。因为一行只可能有一个开始位置和一个结束位置。

注意 我们在正则表达式处理工具处勾选Multiline选项,即多行选项,^和$的意义就变成匹配行的开始处和结束处,否则将把整个输入视作一个字符串,忽视换行符。可以试着把多行选项去除后再看看效果。如果用过Vim编辑器,就知道命令“d^”和“d$”的作用了。
3.2.3 点号

点号(.)是使用频率最高的元字符。例如,在做采集时抓取页面,要匹配某DIV里的内容,就需要用到点号匹配。下面代码是抓取本地HTML页面的一部分:

〈title〉我的博客〈/title〉

要匹配这个网页的标题应该怎么办呢?很简单,使用点号匹配全部字符,如下:

〈title〉.*<\/title>〈/title〉

这样就可以抓取你想要的任何内容了,包括DIV、SPAN等。

思考题 延伸思路,是不是还可以抓取页面的字符集?要判断这个页面有多少张图片是不是也很容易?只要找到特征字符就可以。试一下,看看和预想的结果是否一致。

 3.2.4 量词

下面是一些例子:

1)匹配Windows后面跟1个或更多数字:

Windows\d+

2)表示index后面紧跟0个或1个数字,:

index\d?

以上表达式匹配index、index1、index9这样的文件名,但不匹配index10、indexa这样的文件名。

3)匹配一行第一个单词(或整个字符串第一个单词,具体匹配哪种,得看选项设置):

^\w+

提示 在学习量词的过程中,要注意*和?这两个量词。前面提到过通配符的概念,通配符里也有这两个符号,要注意它们之间的区别。

3.3.1 字符组

3.3 正则表达式匹配规则

我们已经学习“*”、“-”、“?”等元字符,它们都有各自的特殊含义。如果想匹配没有预定义元字符的字符集合,或者表达式和已知定义相反,或者存在多种匹配情况,应该怎么办?本节就介绍几种常用匹配规则。

3.3.1 字符组

查找数字、字母、空白很简单,因为已经有了对应这些字符集合的元字符,但是如果想匹配没有预定义元字符的字符集合(比如元音字母a、e、i、o、u),方法很简单,只需要在方括号里列出它们。

例如[aeiou]匹配任何一个英文元音字母,[.?!]匹配标点符号(“.”、“?”或“!”),c[aou]t匹配“cat”、“cot”、“cut”这三个单词,而“caout”则不匹配。

注意 []匹配单个字符,尽管看起来[]里有好多字符。

也可以指定字符范围,例如[0-9]的含意与\d完全一致:代表一位数字;同理[a-z0-9A-Z_]完全等同于\w(如果只考虑英文)。

字符组很简单,但是一定要弄清楚字符组中什么时候需要转义。
〈/dd〉〈/dl〉3.3.2 转义

如果想查找或匹配元字符本身,比如查找*、?等就出现问题:没办法指定,因为它们会被解释成别的意思。这时就使用\来取消这些字符的特殊意义。因此,应该使用\.和\*。当然,查找\本身用\\。这叫做转义。

通俗地讲,转义就是防止特殊字符被解析,或者说用某个符号表示另一个特殊符号。例如:unibetter\.com匹配unibetter.com,C:\\Windows匹配C:\Windows。

在〈span class="keylink"〉Java〈/span〉Script或者〈span class="keylink"〉PHP〈/span〉中都接触过转义的概念。例如,〈span class="keylink"〉Java〈/span〉Script中要弹出一个对话框,对话框中需要分成两行显示,用HTML的
标签或者在源代码里手工换行都不行,应该用\r\n表示换行并新起一行,如下所示:

alert("警告:

操作无效");           // 错误

alert("警告
操作无效");// 错误

alert("警告\r\n操作无效");// 正确写法
在〈span class="keylink"〉PHP〈/span〉里使用反斜杠(\)表示转义,\Q和\E也可以在模式中忽略正则表达式元字符,比如:

\d+\Q.$.\E$

以上表达式先匹配一个或多个数字,紧接着一个点号,然后一个$,再然后一个点号,最后是字符串末尾。也就是说,\Q和\E之间的元字符都会作为普通字符用来匹配。

正则表达式是不是遇到这些特殊字符就该转义呢?答案显然是否定的。转义只有在一定条件下,比如可能引起歧义或者被误解析的情况下才需要。有些情况并不需要转义这些“特殊”字符,并且在时转义也是无效的。这需要不断尝试并积累经验。看一个例子:

<?php

$reg="#[aby\}]#";

$str= 'a\bc[]{}';

preg_match_all($reg, $str, $m);

var_dump($m);

在字符组中匹配“a”、“b”、“y”和“}”中任意一个,由于“}”是元字符,具有特殊意义,所以这里进行转义,使用“\{”表示“{”。

但是实际上,这个转义是多余的。虽然“}”是元字符,具有特殊意义,但是在字符组中,“}”却无法发挥意义,不会引起歧义,所以不需要转义。在这里“\{”和“{”是等价的。

既然转义符“\”是多余的,那么会不会被当作普通字符呢?字符串$str里有“\”,但是可以从代码运行结果中看出,“\”字符并没有被匹配,也就是说正则表达式“#[abc\}]#”中,虽然“\”转义符是多余的,但是也并没有被当作普通字符进行匹配。

如果确实要把“\”当作普通字符匹配,正则表达式需要写成:

#[}ab\\\y]#

前面提到,不是所有出现特殊字符的地方都要转义。例如,以下正则表达式可以匹配“cat”、“c?t”、“c)t”等字符:

c[aou?*)]t

其中“?”和“?”等特殊字符都不需要转义。原因很简单,字符组里匹配的是单个字符,这些特殊字符不会引起歧义。

字符组里可以使用转义吗?可以,例如“c[\d]d”可以匹配“c1d”、“c2d”等。下面是复杂的表达式:

\(?0\d{2}[) -]?\d{8}

“(”和“)”也是元字符(后面在分组章节会提到),所以在这里需要使用转义。这个表达式可以匹配几种格式的电话号码,例如(010)88886666、022-22334455或02912345678等。首先是转义符“\(”,表示出现0或1次(?),然后是一个0,后面跟着两个数字(\d{2}),然后是“)”、“-”或空格中的一个,出现1次或不出现(?),最后是八个数字(\d{8})。

 3.3.3 反义
〈/dd〉〈dt〉

    有些时候,查找的字符不属于某个字符类,或者表达式和已知定义相反(比如除了数字以外其他任意字符),这时需要用到反义。常用反义如表3-3所示。


    

    反义有一个比较明显的特征,就是和一些已知元字符相反,并且为大写形式。比如“\d”表示数字,而“\D”就表示非数字。看一些实际的例子。

    1)不包含空白符的字符串:

    \S+
    2)用尖括号括起来、以a开头的字符串:

    〈a〉]+>

    比如,要匹配字符串“〈/a〉百度”,这个正则表达式匹配的结果就是“”。

    提示 “^”在这里是“非”的意思,不要和表示开头的“^”混淆。那怎么区分呢?很简单,表示开始位置的“^”只能用在正则表达式最前端,而表示取反的“^”只用在字符组中,即只在中括号里出现。记住这一点,就不会搞混了。

    日常工作中反义用得不多,因为扩大了范围。例如程序里的变量,第一个字符不允许是数字,一般使用“^[a-zA-Z_]”表示,而不会使用“\D”,因为“\D”扩大了范围,包括所有非数字的字符,显然,变量命名不仅仅要求第一个字符不是数字,也不能是其他除了26个大小写字母和下画线以外的字符。因此,不要随意使用反义,以免无形中扩大范围,而使自己没有考虑到。〈/dt〉〈dd〉3.3.4 分支〈/dd〉〈dt〉

分支就是存在多种可能的匹配情况。例如,匹配“cat”或者“hat”,可以写成[ch]at;要匹配“cat”、“hat”、“fat”、“toat”,很显然不能用字符组匹配的方式。这里表明前面的匹配字符可以是c、h、f或者to,而[]只能匹配单个字符,此时可用分支形式,即:

(c| h| f| to)at

其中括号里的表达式将视作一个整体(后面会讲到分组的概念),“|”表示分支,即可能存在的多种情况,可以匹配多个字符。分支的功能更强大,字符组方式只能对单个字符“分支”,而分支可以是多个字符以及更复杂的表达式。但对于单字符的情况,字符组的效率更高。也就是说,能使用字符组就不用分支。

看到这里,你可能会有疑问:表达式“[ch]at”括号里面是可能的匹配,分支也是表示可能的匹配,那么“[ch]at”是否可以写成“(c|h)at”呢?答案显然是可以的,“[ch]at=(c|h)at”。

注意 括号匹配会捕获文本,如果不需要捕获文本,上面的例子可以使用“(?:)”,后面还会讲到。

正则表达式分支条件指有几种规则,无论满足其中哪一种规则都能匹配,具体方法是用“|”把不同规则分隔开,例如:

0\d{2}-\d{8}| 0\d{3}-\d{7}

这个表达式能匹配两种以连字号分隔的电话号码:一种是3位区号,8位本地号(如010-12345678),一种是4位区号,7位本地号(如0376-2233445)。匹配3位区号的电话号码表达式如下:

\(0\d{2}\)[- ]?\d{8}| 0\d{2}[- ]?\d{8}

其中区号可以用小括号括起来,也可以不用,区号与本地号间可以用连字号或空格间隔,也可以没有间隔。可以试试用分支条件把这个表达式扩展成同时支持4位区号。

例如,美国邮编规则是5位数字,或者用连字号间隔的9位数字。匹配表达式如下:

\d{5}-\d{4}| \d{5}

另外,使用分支条件时,要注意各个条件的顺序。如果改成以下形式,就只匹配5位邮编以及9位邮编的前5位:

\d{5}| \d{5}-\d{4}

注意 匹配分支条件时,将从左到右测试每个条件,如果满足某个分支,就不会再考虑其他条件。
3.3.5 分组〈/dt〉〈dd〉重复单个字符只需要直接在字符后面加上限定符,但如果想重复多个字符又该怎么办呢?可以用小括号指定子表达式,然后规定这个子表达式的重复次数,也可以对子表达式进行其他一些操作。这就是本节介绍的分组,常用分组语法如表3-4所示。




注释  (?#comment)提供注释辅助阅读,不对正则表达式的处理产生任何影响

例如,简单的IP地址匹配表达式如下:

(\d{1,3}\.){3}\d{1,3}

要理解以上表达式,应按下列顺序分析:

1)匹配1~3位的数字:

\d{1,3}

2)匹配3位数字加上1个英文句号(分组),重复3次(最后加上一个1~3位的数字):

(\d{1,3}\.){3}

IP地址中每个数字都不能大于255,所以严格来说这个正则表达式是有问题的。因为它将匹配256.300.888.999这种不可能存在的IP地址。如果能使用算术比较,或许能简单地解决这个问题,但是正则表达式中没有提供关于数学的任何功能,所以只能使用冗长的分组、选择、字符类来描述一个正确IP地址,如下所示:

((2[0-4]\d|25[0-5]|[01]?\d\d?)\.){3}(2[0-4]\d|25[0-5]|[01]?\d\d?)

思考题 理解这个表达式的关键是理解“2[0-4]\d|25[0-5]|[01]?\d\d?”,读者应该能分析出它的意义。

默认情况下,每个分组会自动拥有一个组号,规则是:从左向右,以分组的左括号为标志,第一个出现的分组,其组号为1,第二个为2,以此类推;分组0对应整个正则表达式。

也可以自己指定子表达式的组名,语法如下:

?〈word〉\w+

把尖括号换成单引号也行,如下所示:

?'Word'\w+

这样就把\w+组名指定为Word。

提示 组号分配远没有这么简单。组号分配过程是要从左向右扫描两遍:第一遍只给未命名组分配,第二遍只给命名组分配。因此,所有命名组的组号都大于未命名的组号。可以使用语法(?:exp)剥夺一个分组对组号分配的参与权。〈/word〉〈/dd〉〈dt〉3.3.6 反向引用〈/dt〉〈dd〉

反向引用用于重复搜索前面某个分组匹配的文本。首先看示例,“\1”代表分组1匹配的文本:

\b(\w+)\b\s+\1\b

以上表达式可以匹配重复的单词,例如go go或者kitty kitty。首先这个表达式是一个单词,也就是单词开始处和结束处之间大于一个的字母或数字,即“\b(\w+)\b”,这个单词会被捕获到编号为1的分组中,然后是1个或几个空白符(\s+),最后是分组1中捕获的内容(也就是前面匹配的那个单词),即\1,这样就相当于把所匹配的重复一次。

要反向引用分组捕获的内容,可以使用“\k〈word〉”,所以上个例子也可以写成这样:〈/word〉

\b(?〈word〉\w+)\b\s+\k〈word〉\b〈/word〉〈/word〉

例如,要捕获字符串“\"This is a 'string'\"”引号内的字符,如果使用以下正则表达式:

(\"| ').*?(\"| ')

将返回“"This is a'”。显然,这并不是我们想要的内容。这个表达式从第一个双引号开始匹配,遇到单引号之后就错误地结束匹配。这是因为表达式里包含"|',也就是双引号(")和单引号(')均可。要修正这个问题,可以用到反向引用。

表达式“\1,\2,…,\9”是对前面已捕获子内容的编号,可以作为对这些编组的“指针”引用。在此例中,第一个匹配的引号就由1代表。可以这么写成:

("|\').*?\1

如果使用命名捕获组,可以写成:

(?P〈quote〉"| ').*?(?P=quote)〈/quote〉

看〈span class="keylink"〉PHP〈/span〉使用反向引用的例子。

在很多〈span class="keylink"〉论坛〈/span〉中都会看到UBB标签代码。UBB标签最早的设计是用来在〈span class="keylink"〉论坛〈/span〉和留言本里代替HTML,实现一些简单的HTML效果,同时防止滥用HTML出现安全问题。例如,HTML中粗体的标签是:

〈strong〉粗体〈/strong〉

或者:

〈strong〉粗体〈/strong〉

而UBB标签则是:

[b]粗体[/b]

UBB标签以其更好的安全性,目前已经成为论坛发帖的代码标准,只不过不同论坛产品的叫法不一样而已。

最终,UBB标签还是要解析成HTML代码,才能让〈span class="keylink"〉浏览器〈/span〉认识。这个过程是怎样实现的呢?下面以URL标签为例解释。

例如,UBB标签“[url]1.gif[/url]”用于插入表情。在解析时,需要把1.gif换成实际路径,并且需要用HTML的IMG标签进行替换,方法如下所示:

<?php

$str='[url]1.gif[/url][url]2.gif[/url][url]3.gif[/url]';

$s=preg_replace("#\[url\](?〈word〉\d\.gif)\[\/url\]#","〈img src="http://image.ai.com/upload/$1" alt="" /〉",$str);〈/word〉

var_dump($s);

运行结果如下:

string(141)"〈img src="http://image.ai.om/upload/1.gif" alt="" /〉

〈img src="http://image.ai.com/upload/2.gif" alt="" /〉

〈img src="http://image.ai.com/upload/3.gif" alt="" /〉"

是不是很简单?一个简易表情标签就这样实现了。

这里再给出一个表达式实现同样的效果:

<?php

$str='[url]1.gif[/url][url]2.gif[/url][url]3.gif[/url]';

$s=preg_replace("#\[url\](.*?)\[\/url\]#","<img

src=http://image.ai.com/upload/$1>",$str);

var_dump($s);

提示 这个正则表达式涉及贪婪/懒惰匹配知识,后面会进一步介绍。
〈/dd〉〈/dl〉3.3.7 环视〈/dd〉〈dt〉

断言用来声明一个应该为真的事实。正则表达式中,只有当断言为真时才会继续进行匹配。断言匹配的是一个事实,而不是内容。本节介绍四个断言,它们用于查找在某些内容(但并不包括这些内容)之前或之后,也就是一个位置(如\b、^、$)应该满足的一定条件(即断言),因此也称为零宽断言。

1.顺序肯定环视(?=exp)

零宽度正预测先行断言,又称顺序肯定环视,断言自身出现位置的后面能匹配表达式exp。

比如,匹配以“ing”结尾的单词前面部分(除了“ing”以外的部分):

\b\w+(?=ing\b)
以上表达式查找以下句子时,会匹配“sing”和“danc”:

I'm singing while you're dancing.
2.逆序肯定环视(?<=exp)

零宽度正回顾后发断言,又称逆序肯定环视,断言自身出现位置的前面能匹配表达式exp。

比如,以re开头的单词的后半部分(除了re以外的部分):

(?<=\bre)\w+\b

以上表达式在查找以下句子时匹配“ading”:

reading a book

假如在很长的数字中,每3位间加1个逗号(当然是从右边加起),可以在前面和里面添加逗号的部分:

((?<=\d)\d{3})+\b

用以上表达式对“1234567890”进行查找,结果是“,234,567,890”。这里的逗号只是匹配需要添加逗号的位置,还没有实际添加逗号。

下面这个例子同时使用这两种断言,匹配以空白符间隔的数字(再次强调,不包括这些空白符):

(?<=\s)\d+(?=\s)

前面提到过反义,用来查找不是某个字符或不在某个字符类里的字符。如果只是想要确保某个字符没有出现,但并不想去匹配它时怎么办?例如,如果想查找这样的单词——出现字母q,但是q后面跟的不是字母u。可以尝试这样:

\b\w*q[^u]\w*\b

以上表达式匹配包含后面不是字母u的字母q的单词。但是如果多做几次测试就会发现,如果q出现在单词的结尾,例如Iraq、Benq,这个表达式就会出错。这是因为[^u]总要匹配一个字符,如果q是单词的最后一个字符,后面的“[^u]”将会匹配q后面的单词分隔符(可能是空格、句号或其他),后面的“\w*\b”将会匹配下一个单词,于是以上表达式就能匹配整个Iraq fighting。

逆序肯定环视能解决这样的问题,因为它只匹配一个位置,并不消费任何字符。现在,解决这个问题如下所示:

\b\w*q(?!u)\w*\b

3.顺序否定环视(?!exp)

零宽度负预测先行断言,又称顺序否定环视,断言此位置的后面不能匹配表达式“exp”。例如:

1)匹配3位数字,而且这3位数字的后面不能是数字:

\d{3}(?!\d)

2)匹配不包含连续字符串abc的单词:

\b((?!abc)\w)+\b

如果匹配的单词是c开头、t结尾,中间有一个字符,但不能是u(也就是说,整个单词不能是cut),直接用“c[^u]t”就可以了,若中间的字符不能是a或u(也就是说,整个单词不能是cat或cut),则表达式改为“c[^au]t”。

如果认真读过关于排除型字符组的章节的读者肯定会知道,这个表达式能匹配的只是cot之类的单词,因为中间的排除型字符组“[^au]”必须匹配一个字符。可是,如果还想匹配chart、conduct和court怎么办?最简单的想法是:去掉排除型字符组的长度限制,改成“c[^au]+t”。

不幸的是,这样行不通,因为这个表达式的意思是:c和t之间由多于一个“除a或u之外的字符”构成,而chart、conduct和court都包含a或u。

我们发现,其实要否定的是“单个出现的a或u”,而不仅仅是“出现的a或u”,所以才出现这样的问题。要解决这个问题,就应当把意思准确表达出来,变成“在结尾的t之前,不允许只出现一个a或u”。想到这一步,就可以用顺序否定环视(?!…)来解决。表示在这个位置向右,不允许出现子表达式能够匹配的文本,把子表达式规定为“[au]t\b”(最后的“\b”很重要,它出现在t之后,保证t是单词的结尾字母)。有了限制,匹配a和t之间文本的表达式就随意很多,可以用匹配单词字符的简记法“\w”表示,于是整个表达式变成:

c(?![au]t \b)\w+t

注意 这里出现的并不是排除型字符组“[^au]”,而是普通的字符组[au],因为顺序否定环视本身已经表示了否定。

进一步思考,整个匹配文本中都不能出现字符串“cat”,要怎么办呢?这个正则表达式应该是:

^(?:(?!cat).)+$

即在文本中的任意位置,都不能出现该字符串。

4.逆序否定环视(?〈!--exp)〈/p--〉

零宽度负回顾后发断言,又称逆序否定环视,可以用(?〈!--exp)断言此位置的前面不能匹配表达式exp。例如,前面不是小写字母的7位数字:〈/p--〉

(?〈!--[a-z])\d{7}〈/p--〉

分析以下表达式,匹配不包含属性的简单HTML标签内的内容:

(?<=<(\w+)>).*(?=<\/\1>)

以上表达式最能表现零宽断言的真正用途。(<?(\w+)>)指定前缀为:被尖括号括起来的单词(比如可能是“〈strong〉”),然后是“.*”(任意的字符串),最后是一个后缀(?=<\/\1>)。注意后缀里的“\/”,用到了前面提过的字符转义;“\1”则是反向引用,引用的正是捕获的第一组,即前面(\w+)匹配的内容,如果前缀实际上是“〈strong〉”,后缀就是“〈/strong〉”。整个表达式匹配的是“〈strong〉”和“〈/strong〉”之间的内容(再次提醒,不包括前缀和后缀本身)。〈/strong〉

总体而言,环视相当于对“所在位置”附加一个条件,难点就在于找到这个“位置”。这一点解决了,环视就没有什么秘密可言了。
〈/dd〉〈/dl〉3.3.8 贪婪/懒惰匹配模式〈/dt〉〈dd〉

    当正则表达式中包含能接受重复的限定符时,通常的行为是(在使整个表达式能得到匹配的前提下)匹配尽可能多的字符。例如以下表达式将匹配以a开始,以b结束的最长字符串:

    a.*b

    如果用来搜索“aabab”,它会匹配整个字符串“aabab”。这就是贪婪匹配。

    有时,需要匹配尽可能少的字符,也就是懒惰匹配。前面给出的限定符都可以转化为懒惰匹配模式,只要在后面加上一个问号。例如“.*?”就意味着匹配任意数量的重复,但是在能使整个匹配成功的前提下使用最少的重复。例如,匹配以a开始、以b结束的最短字符串,正则表达式如下:

    a.*?b

    把上述表达式应用于aabab,如果只考虑“.*?”这个表达式,最先会匹配到aab(1~3字符)和ab(第2~3个字符)这两组字符。

    为什么第一个匹配是aab(第1~3个字符)而不是ab(第2~3个字符)?简单地说,正则表达式有另一条规则,比懒惰/贪婪规则的优先级更高:最先开始的匹配拥有最高优先权。

    常用懒惰限定符如表3-5所示。


    

    懒惰模式匹配原理简单来说,是在匹配和不匹配都可以的情况下,优先不匹配,记录备选状态,并将匹配控制交给正则表达式的下一个匹配字符。当后面的匹配失败时,回溯,进行匹配。关于回溯以及正则表达式效率等高级内容,可以查阅《精通正则表达式》一书。

    在3.3.6节涉及懒惰匹配,把该节的例子稍作更改:

    <?php

    $str='[url]1.gif[/url][url]2.gif[/url][url]3.gif[/url]';

    $s=preg_replace("#\[url\](.*)\[\/url\]#","〈img src="http://image.aiyooyoo.om/upload/$1" alt="" /〉",$str);

    var_dump($s);

    在贪婪模式下,由于匹配表达式是“.*”,即任意字符出现任意次,这个正则表达式会一直匹配[url]后的内容,直到遇到结束条件“[\/”。匹配结果如图3-3所示。


    

    提示 实际开发中,涉及贪婪模式与懒惰模式的地方是很多的。在一定情况下,使用懒惰模式可以减少回溯,提高效率。


3.4.1 正则表达式的逻辑关系〈/dd〉〈dt〉

    3.4 构造正则表达式

    在构造和理解正则表达式的过程中,通常都是由简到繁的过程,如果理解正则表达式内部间的关系,就可以把比较复杂的正则表达式拆分成几个小块来理解,从而帮助消化。

    3.4.1 正则表达式的逻辑关系

    正则表达式之间的逻辑关系可以简单地用与、或、非来描述,如表3-6所示。


    

    通常来说,正则表达式可以看做这三种逻辑关系的组合。下面分析这三种逻辑。

    1.与

    “与”是正则表达式中最普遍的逻辑关系。一般来说,如果正则表达式中的元素没有任何量词(比如*、?、+)修饰,就是“与”关系。比如正则表达式:

    abc

    表示必须同时出现a、b、c三个字符。

    连续字符是“与”关系的最佳代表。此外,有些环视结构也可以表达“与”关系。比如顺序肯定环视(?=exp)表示自身出现的位置后面能匹配表达式exp,换而言之,就是在它后面必须出现表达式exp。例如:

    \w+(?=ing)

    表示单词的后面必须是ing结尾。

    除了顺序肯定环视外,逆序肯定环视也能表达“与”关系。

    比如匹配DIV标签里的内容,例如〈div〉logo中的logo,就可用以下正则表达式来匹配:

    (?<=〈div〉).*(?=)

    “(?<=〈div〉)”表示自身(即要匹配的部分)出现的位置前面匹配表达式“〈div〉”,“(?=〈/div〉)”表示它的后面需要匹配表达式“”,中间的“.*”就是匹配到的内容。

    2.或

    “或”是正则表达式中容易出现的逻辑关系。

    如果“或”代表元素可以出现,也可以不出现,或者出现的次数不确定,可以用量词来表示“或”关系。比如以下表达式表示在此处,字符a可以出现,也可以不出现:

    ?a??

    以下表达式表示在此处,字符串ab必然要出现1次,也可以出现无限多次:

    『(ab)+』

    如果“或”表示出现的是某个元素的一个,那么可以使用字符组。比如以下正则表达式表示此处出现的字符是a、b、c中的任何一个:

    [abc]

    如果要匹配多个字符,则使用分支结构(…|…)。比如匹配单词foot及其复数形式,就可以用正则表达式:

    f(oo| ee)t

    或者使用以下形式:

    f[oe]{2}t

    3.非

    提到“非”,最容易想到正则表达式中的反义和“^”元字符。比如“\d”表示数字,那么其对应的\D就表示非数字;[a]表示a字符,那么[^a]就表示这个字符不是a。

    “非”关系最常用来匹配成对的标签,例如双引号字符串的匹配,首尾两个双引号很容易匹配,其中的内容肯定不是双引号(暂不考虑转义的情况),所以可以用[^"]表示,其长度不确定,用*来限定,所以整个表达式如下:

    [^"]*

    比如,需要匹配HTML里成对的A标签,先匹配左尖括号,紧接着是a字符,后面可以是任意内容,最后是一个右尖括号。在这对括号之间可以出现空格、字母、数字、引号等字符,但是不能出现“>”字符,于是就可以用排除型字符组“[^>]”来表示。再加上后面的配对标签,整个表达式如下:

    〈a〉]*>.*<\/a>

    运行下面这段代码验证这个表达式:

    <?php

    $reg="#〈/a〉〈a〉]*>(.*)<\/a>#";

    $str= '〈/a〉baidusomesohu';

    preg_match_all($reg, $str, $m);

    var_dump($m);

    运行结果如下:

    array(2) {

    [0] =>

    array(1) {

    [0] =>

    string(74) "baidusomesohu"

    }

    [1] =>

    array(1) {

    [0] =>

    string(43) "baidusomesohu"

    }

    }
    发现结果不符合预期,出现了嵌套匹配。原因在于A标签之间的文本忘了做排除型匹配,于是修改后的正则表达式就成了“〈a〉]*>([^<>]*)<\/a>”。经过修改后就符合预期了。

    除了反义和排除型字符组外,否定环视也能表示“非”这种关系。比如有一串文字:“ab〈/a〉

one
cde〈div〉fgh〈img src="" alt="" /〉”。现在需要匹配除P标签外的所有标签。换而言之,就是先匹配所有HTML标签,可以使用如下表达式:

    ]+>

    匹配闭合的“〈xxx〉”或“〈/xxx〉”标签,然后再排除“XXX”或“/XXX”部分是P的标签,于是使用顺序否定环视,用表达式:

    (?!/?p\b)

    排除了“<”或“
    <(?!/?p\b)[^>]+>

    通过上面的分析得到正则表达式中的“与或非”关系及其代表语法,如表3-7所示。〈/dt〉〈dd〉3.4.2 运算符优先级〈/dd〉〈dt〉〈p style="text-align: center"〉正则表达式从左到右进行计算,并遵循优先级顺序,这与算术表达式非常类似。表3-8说明了各种正则表达式运算符的优先级顺序,其中优先级从上到下、由高到低排列。


字符的优先级比替换运算符高,替换运算符允许m|food与m或food匹配。要匹配mood或food,使用括号创建子表达式,从而产生如下表达式:

(m| f)ood
〈/dd〉〈/dl〉
〈/dt〉〈/dl〉
     3.5.2 匹配E-mail地址

    运用学过的知识,我们实现简单的E-mail识别。

    E-mail最简单的形式为user@domain.com。其中,user为用户名,domain为域名,com为后缀;当然,后缀还可以是net、name、cn等。一般用户名由3个以上的字母和数字组成,当然也不能太长,允许出现下画线。中间一个@符号,后面的域名长度为1~64位,后缀长度一般为2~5位,如下所示:

    \w{3,16}@\w{1,64}\.\w{2,5}

    以上表达式匹配用户名长度3~16位,紧跟一个@符号,然后是1~64位的域名,再然后是dot(.号),最后是2~5位的后缀。这个正则表达式还存在一些问题,比如xxx_yyy@yahoo.com.cn这样的地址,后面的.cn就无法匹配到。这就需要进一步学习。

    Regular Expression Tester工具提供一些预定义的正则表达式,它提供的匹配E-mail的正则表达式如下:

    ^[a-z0-9_\-]+(\.[_a-z0-9\-]+)*@([_a-z0-9\-]+\.)+([a-z]{2}| aero| arpa| biz| com| coop| edu| gov| info| int| jobs| mil| museum| name| nato| net| org| pro| travel)$



    这个表达式很长,使用字符组和分支条件语法,很好地解决了com.cn这样多个后缀域名的问题。相信现在读者对于匹配电话号码、QQ号等应该能得心应手了。




3.5.3 转义在数据安全中的应用

数据安全是任何一款软件设计中都需要考虑的问题。从技术层面讲,数据安全就是保护存储和使用的数据不被窃听、盗取和破坏。这可能是由外部因素造成的,比如由于过滤不严格造成SQL注入漏洞、提升脚本执行权限等,也有可能是由代码内部设计造成的,如死循环、低效率的语句造成服务器性能下降以致影响访问。

社会学意义上的数据安全则更广泛。比如,在在线购物商城的设计中,由于设计者错误地使用自增ID作为商品的单据流水号,竞争对手或有心人很容易分析出这个商城的每日销售量,进而估算出销售额、利润等商业机密数据。

在程序中要保证数据的安全,除了要保证代码内部运行的可靠外,最主要的就是严格处理外部数据,即秉持“一切输入输出都是不可靠的”理论,这就要求我们做好数据过滤和验证。PHP编程中最简单的过滤机制就是转义,即对用户的输入和输出进行转义和过滤。图3-4所示是简单的留言本。

简单留言本的演示代码如下:


〈pre class="code"〉简单留言本的演示代码如下:〈form action="" method="POST"〉〈textarea name="content"〉〈/textarea〉〈input value="留言" type="submit" /〉<?phpecho $_POST['content'];〈pre class="code"〉?>程序中没有任何的输入输出过滤,当在留言框中输入以下内容时得到如图3-5所示的页面。 〈style〉Body{background:#000000}〈/style〉很显然,由于输入含有CSS代码和JavaScript代码,没有对其进行处理便原样输出,导致页面被篡改。这就是常说的“XSS攻击”。针对上面的情况,只需在接收数据时使用htmlspecialchars函数,把代码中的特殊字符转为HTML实体,这样在输出时就不会使页面受影响了。这些特殊字符主要是“"”、“'”、“&”、“<”、“>”。比如把“”转为“”,就可以阻止大部分XSS攻击。注 HTML中规定字符实体引用(HTML Entities)还有对应的数字型实体(NCR),实际上是对这个实体的编号。比如,HTML Entities的格式“<”,NCR的格式“<”或“<”,均表示“<”字符。有的时候,不希望出现这些无意义的字符,因为既然页面不允许这些HTML标签,那就干脆过滤掉,而不是显示出来,这样页面就不会留下恶意攻击者所留下的这些代码。要达到这个目的,就需要借助正则表达式。比如,要过滤所有HTML标签的正则表达式如下所示:<\/?[^>]+>// 过滤所有HTML标签这个正则表达式匹配嵌套的尖括号,一个“\/?”表示斜杠可有可无,这样就匹配标签的起始和关闭位置。“[^>]+”意思是:不是右尖括号的字符重复一次和更多次。为什么不是“?”呢?因为HTML标签里至少也是一个字符,如〈strong〉,而<>不是合法HTML标签,最后关闭右尖括号。用下面字符测试:〈strong〉strong〈/strong〉〈img src="a.jpg/" alt="" /〉其匹配情况如图3-6所示。 从图中可以看出,运行结果基本符合我们的需求。同理,还可以只保留部分HTML标签,既不造成安全问题,又能使页面内容更丰富,这就可以利用UBB代码功能来实现。前面提到过用正则表达式实现UBB标签的功能比较麻烦,但在这里只需要白名单功能,即只保留比较“安全”的标签,通过PHP内置的strip_tags函数可以容易做到。这个函数用于从字符串中去除HTML和PHP标记,仅保留参数中指定的标签。演示代码如下所示:<?php$text = '〈/strong〉

Test paragraph.
〈!-- Comment --〉 〈a href="#fragment"〉Other text〈/a〉';echo strip_tags($text);echo "\n";// 允许

和 〈a〉echo strip_tags($text, '〈/a〉

〈a〉');?>〈/a〉
〈/form〉〈/pre〉
另外,在SQL语句构造中,当字符含有引号的时候,可能造成SQL解析和执行失败。这样就要求转义。一种处理办法是把引号转义成实体,另一种处理办法是使用addslashes函数把其转义。我们通常会使用后者。

addslashes函数返回一个字符串,该字符串为了数据库查询语句等的需要在某些字符前加上了反斜线。这些字符是单引号(')、双引号(")、反斜线(\)与NUL(NULL字符)。

注意 为了处理更多情况,还需要特别注意“%”、“'”等特殊字符。如果能转义则进行转义,没有对应的转义时,则进行过滤。

正文结束

PHP接口(interface)和抽象类(abstract) 第3章 正则表达式基础与应用2