正则表达式系列总结:
前言
最近我在运用正则的过程中,发现了一些让程序变慢的现象,甚至导致了...服务器CPU飙升至100%的问题。 血泪教训不愿再说,引发问题的根本原因还是对正则的运用不够熟练,导致在匹配文本时发生了大量的回溯。
本文主要来总结一下,关于避免正则中灾难性回溯的学习收获。
前置知识
一些需要用到的正则表达式基础知识。
正则引擎
正则表达式的引擎决定了它的工作原理,正则引擎可以分为两大类:
- DFA(确定型有穷自动机):文本主导。
- NFA(非确定型有穷自动机):表达式主导,又分为传统型NFA和POSIX NFA。
我们常用的Java、JavaScript、PHP,都属于(传统型)NFA引擎。
判断方法:如果支持非贪婪模式,基本可以确定是传统型NFA。
回溯是NFA引擎最大的特点。因为NFA引擎在匹配时是表达式主导的,某个字符可能会被正则中的不同部分重复检测。如果正则表达式运用不当,会带来非常严重的后果。
量词
javascript MDN文档 - Quantifiers
量词:表示要匹配项(字符或表达式)的数量,也可以理解为重复的次数。
量词的基本形式:
字符 | 含义 | 我的理解 |
---|---|---|
x* | 将x匹配0次或无穷多次 | x出现任意次,可以没有 |
x+ | 将x匹配1次或无穷多次 | x出现任意次,且必须有 |
x? | 将x匹配0次,或1次 | x有没有都行,有的话就一次 |
x{n} | 将x匹配n次 | x精确出现n次才会匹配 |
x{n,} | 将x至少匹配n次 | x出现次数>n |
x{n,m} | 将x最少匹配n次,最多匹配m次 | n<x出现次数<m |
一个例子:
var r = /^(?:(?:+|00)86)?1\d{10}$/
这是一则宽松匹配手机号的正则表达式,这里使用到了?
、{n}
两种量词,表示在匹配手机号时,+86或0086的前缀有没有都可以,1后面要跟上10位数字。
除了上述的基本用法,量词还有几种模式:
模式 | 描述 | 匹配规则 |
---|---|---|
贪婪模式 | 量词的基础用法,默认使用量词时就是贪婪模式 | 匹配时引擎会选择进行尝试 |
懒惰模式 | 在量词后再添加一个? ,如: *?、+?、??、{n,m}? | 匹配时引擎会跳过尝试 |
占有模式 | 在量词后再添加一个+ ,如: *+、++、?+、{n,m}+ | 和贪婪模式类似,但更为强势, javascript中目前不支持 |
多选分支
x|y
:匹配x或者y,依靠"|"符号,可以在一个表达式中包含多个不同的子表达式(x和y)
例子:
var r = /gr(a|e)y/
其中,括号可以帮助划定多选分支的作用范围。
断言
利用断言可以进行边界判断而不占用任何字符:
- 向前断言:x(?=y)
- 向前否定断言:x(?!y)
- 向后断言:(?<=y)x
- 向后否定断言:(?<!y)x
调试工具
使用的调试工具:regex101
目前只有在选择PHP语言时能够使用调试功能, 关于PHP(PCRE)和javascript正则的区别:
- PHP使用的是PCRE正则库,PCRE是一套兼容Perl正则表达式的库,全面仿制了Perl的正则语法和语义。
- javascript的正则语法借鉴自Perl。
因此,JS和PHP的正则表达式语法大体上是类似的,在一些细节上表现稍有不同,因此可以使用PHP模式的调试功能,然后验证在javascript模式下有没有问题。
注: Perl是非常流行、特性十分丰富的正则表达式库。其他语言的开发人员,在某种程度上兼容Perl,开发了各自的正则表达式包。
回溯是什么
如果把正则表达式匹配的过程,想象成一个迷宫游戏:
- 从起始位置出发
- 遇到分岔路口,选择其中一条路线
- 走入死胡同,返回刚才的分岔路口
- 选择另外一条路线继续走
- 重复上述步骤,直到找到一条成功的路线,或者尝试所有可能路线后失败
上述步骤中:走入死胡同后返回上一个分岔路口的过程,我理解为和正则中的回溯过程类似。
发生回溯的情况
回溯大多发生在有分岔路口的地方,即遇到需要进行选择的时候。
在正则表达式中,选择会发生在遇到量词和多选分支时:
- 遇到量词:需要选择是否要尝试另一次匹配
- 遇到多选分支:需要选择尝试哪个分支,同时也会记录下另外的分支(备用状态)稍后尝试
在没有得到正确的匹配结果时,正则会继续尝试直到走完所有可能的分支,所以得出匹配失败的结果可能需要耗费很多的时间。
匹配量词和回溯
贪婪模式中的回溯
由星号或其他贪婪量词限定的部分,不受后面元素的影响。会匹配尽可能多的内容,只有在全局匹配需要的情况下才会被迫交还一些字符。
var s = '<b>HTML</b> and <b>CSS</b> are ...'
var r = /<b>.*<\/b>/
console.log(s.match(r))
表达式中的.
匹配除换行符以外的任意字符,*
又将任意字符匹配任意次,贪婪模式下.*
组合起来会一直匹配到文本的行末尾,
而表达式中.*
后边跟随</b>
,这时候.*
就会往回匹配(发生回溯),直到找到最右边的</b>
结束。
匹配的结果为<b>HTML</b> and <b>CSS</b>
使用懒惰模式避免回溯
懒惰模式下量词是忽略优先的,它一开始不去匹配任何字符,把控制权交给后面的字符。
把这个例子改成懒惰模式,当.*?
后面的字符<
无法匹配时,控制权就又交还给它,.
匹配字符H
后继续选择忽略,重复上面的步骤...直到找到一个<
。
var s = '<b>HTML</b> and <b>CSS</b> are ...'
var r = /<b>.*?<\/b>/
前面这个例子中,如果改成懒惰模式,就会只匹配<b>HTML</b>
了,并且不存在回溯问题。
使用排除型字符组消除回溯
使用排除型字符组[^x]
,排除一些不需要匹配的字符,来代替类似 .*
的宽泛匹配方式。
适用于排除单个字符的情况。
var s = '"HTML" and "CSS" in web development...'
var r1 = /".*"/
var r2 = /"[^"]*"/
比较例子中的两个正则表达式,第一个正则使用.*
,会存在回溯,且匹配的结果更为宽泛,
第二个正则使用排除型字符组,匹配时不存在回溯,且匹配结果更为精准。
通过断言实现多字符的排除
由于排除型字符组仅能实现对单字符的排除,如果是连续的多个字符,可以通过断言的方式实现排除。
例子:
var r = /<b>((?!<\/?b>).)*<\/b>/
var s = '<b>HTML<b>(H5)</b> and <b>CSS</b> are ..'
这个例子中,使用了向前否定断言,来实现和排除单字符的排除型字符组类似的效果。
表达式图解:
警惕和减少嵌套的量词
嵌套的量词,如 (.+)*,会制造指数级的回溯。
一个例子:
var r = /"([^\\"]+)*"/
var s = 'no \"match\" words'
因为+
和*
组合起来有很多种可能,对于不能得到匹配结果的文本,18个字符在经过307次尝试后才报告失败。如果表达式和文本再复杂一点,回溯次数就更无法控制了。
因此,对于这种嵌套的量词,也要警惕造成过多回溯的问题。
利用贪婪模式实现文本的提取
一个例子:
var s = '...class="time">2021-07-01至今(1个月),....'
var s1 = '...class="time">2001-01至2021-12(10年11个月),....'
var r = /time">[\s\S]{1,20}(?<endYear>\S{2})(/
以提取结束日期中的月份,可能的值为至今
或形如12
的月份,
我的匹配思路:提取时间范围的字符中的最后两个,先定位到特征为class="time">
的部分,
然后利用贪婪模式的特点,尽可能多的匹配后面的字符(根据观察进行长度判断,最多匹配20次),
匹配到左括号后,取左括号左边的两个字符。
这里使用贪婪模式是因为,要提取字符的右侧特征更为明显,左侧字符相对不确定,所以先尽可能向右匹配。
但需要注意进行长度判断,如果使用宽泛的.*,虽然写起来方便,但会无限制地向右匹配直到行末尾,造成更多的回溯。
因此,并不是所有的情况都需要使用懒惰模式,而是根据具体情况来选择。
多选分支和回溯
遇到多选分支时,引擎会按照从左到右的顺序检查表达式中的多选分支。
字符组和多选分支:
- 字符组只进行简单的测试
- 多选分支则需要在每个位置进行尝试
正则引擎会回溯到存在尚未尝试的多选分支的地方,这个过程会不断重复,直到完成全局匹配,或所有的分支都尝试穷尽为止。
因此,分支越多,可能的回溯次数越多。
可以通过以下方式减少多选分支带来的回溯:
- 提取多选分支中的必须元素(开头或结尾)例如:
/th(is|at)/
- 尽量减少分支数量
注意:多选结构是按序排列的,匹配时正则引擎会按序尝试。
因此要注意避免因分支顺序导致的匹配失败。如例子中的D轮及以上
也会被 .*轮
匹配,所以将D轮及以上
提前。
更换正则引擎
由于NFA引擎的回溯特征无法避免,还有可能因此而引发ReDoS(Regular Expression Denial of Service, 正则表达式攻击),还有一种终极做法就是换用其他引擎。
Google RE2:一款使用C++编写的正则库:
- 优点:安全、快速,能避免ReDoS
- 特性:支持PCRE的大部分语法,有Go、Python、Nodejs等多种语言的实现
- 缺陷:不能使用回溯、反向引用、断言的特性
RE2在Node.js中的用法和js自身的Regex用法很相似。具体用法参考文档。不过目前的使用倒也不至于更换引擎。
避免灾难性回溯的总结
- 正确使用贪婪模式和非贪婪模式
- 不过分依赖
.*
,通过使用有明显特征的具体字符、字符组代替通配符,来消除某些回溯 - 复杂情况可以考虑通过断言、固化分组(javascript中暂时不支持)等来解决回溯问题
- 减少嵌套的量词
- 减少多选分支数量
- 使用检测工具进行测试
- 必要时可以考虑更换正则引擎
其他正则优化措施
- 使用非捕获型括号:如果不需要引用括号内的文本,请使用非捕获括号,不但能节省捕获的时间,而且会减少回溯使用的状态的数量,从两方面提高速度。
- 不要滥用括号:在需要的时候再使用括号,其他时候使用括号会阻止某些优化措施。eg:
.*
和(.)*
- 不要滥用字符组:不使用只包含一个字符的字符组,需要付出处理字符组的代价。
- 将最可能匹配的多选分支放在前面。
结束语
关于正则表达式的回溯问题,这里记录的内容也只是冰山一角,梳理的过程也是第二次学习,希望读到这里的你也能有一些收获。
最后,如果文中有任何错漏之处,欢迎指正,鞠躬。
参考资料
- 《精通正则表达式(第三版)》
- 正则表达式回溯法原理
- 《JavaScript正则表达式迷你书》
- zhuanlan.zhihu.com/p/44425997