LabHub

博客

正则表达式从零到自信:字符类·量词·锚点·分组·环视·ReDoS 全面精通

한국어English日本語中文

引言 — 正则表达式是一门小型语言

第一次看到正则表达式(regular expression,regex)时,它看起来就像猫在键盘上走过的结果。但正则表达式并非随机,而是一门用来描述字符串模式的、极其小巧精密的语言。只要理解几个构成要素,那些曾经令人生畏的符号就会开始像句子一样可读。

本文将正则表达式从零开始搭建起来。我们逐一掌握各个构成要素,并处理那些在实际工作中真正让人栽跟头的性能陷阱。先说一条约定:本文中出现的所有正则表达式模式都放进了行内代码或代码块。因为正则表达式里有大量花括号、尖括号之类的特殊字符,如果原样留在文中,渲染就可能出问题。你自己在代码里写正则表达式时,这个习惯同样有用。

如果你想通过亲手搭建模式来学习,阅读本文时可以一直打开本站的正则表达式测试工具,把模式粘贴进去实时验证。

字面量与元字符

最简单的正则表达式,就是你想查找的文字本身。模式 cat 会在字符串中查找连续的三个字母 "cat"。这类普通字符被称为字面量(literal)。

正则表达式之所以强大,靠的是元字符(metacharacter)。它们不代表字符本身,而是承载特殊含义。常见的元字符有:

  . ^ $ * + ? ( ) [ ] { } | \

想按字面意思匹配这些字符时,要在前面加反斜杠来转义。比如要匹配真正的句点而不是代表"任意字符"的元字符,就要写 \.。匹配域名中句点的模式就是 example\.com

字符类 — "其中任意一个"

字符类(character class)用方括号包裹,匹配"括号内任意一个字符"。

常用字符类有简短的缩写形式。

例如三个数字可以写成 \d\d\d,不过用马上要学的量词会更简洁。

量词 — "重复几次?"

量词(quantifier)表示紧邻其前的元素要重复多少次。

前面提到的三个数字可以简写成 \d{3}。如果想要电话号码局号那样的 3 到 4 位数字,就写 \d{3,4}。一个以上的单词字符是 \w+,协议末尾可有可无的 s 则用 https?(即 http 或 https)表示。

锚点与边界 — "在哪里?"

如果说前面这些要素决定的是"要找什么",那么锚点(anchor)固定的是"在哪里找"。锚点匹配的不是字符,而是位置

例如 ^\d+$ 匹配"从头到尾全部由数字构成的字符串"。如果没有锚点,\d+ 也会匹配"abc123def"中的"123";但只要在前后加上起始锚点 ^ 和结束锚点,就必须整个字符串都是数字才能匹配。在输入校验中,这个差别是决定性的。

单词边界 \b 也很有用。\bcat\b 只匹配独立的单词"cat",而不会匹配"category"或"concatenate"中的"cat"。

分组与捕获 — 打包与记住

圆括号把多个元素打包成一个分组(group)。分组做两件事:把量词一次性应用到多个字符上,并把匹配到的部分捕获(capture)下来,供之后取用。

只想打包而不需要捕获时,用非捕获分组,写法是 (?:...)。例如 (?:https?://)? 可选地打包协议部分,但不捕获。减少不必要的捕获能让模式的意图更清晰,也能带来一点性能上的好处。

许多语言也支持命名分组。写成 (?<year>\d{4}) 这样,就能用名称而不是数字索引取出结果,可读性更好。

选择 — "这个或那个"

竖线符号表示选择(alternation),意思是"左边或右边"。

^(cat|dog)$    → 整个字符串是 "cat" 或 "dog"
^cat|dog$      → 被解析为 "^cat" 或 "dog$" (与本意不同)

只有用分组包起来的第一种写法,才表示"整个字符串是 cat 或 dog"。没有分组的话,选择符的作用范围会扩散到整个模式,得到完全不同的结果。

选项多个时就依次列出,像 (jpg|jpeg|png|gif) 这样。这时要留意顺序和锚点,才能精确捕获想要的部分。

环视 — 窥视而不消耗

环视(lookaround)是稍微高级一点的工具。它检查某个模式是否出现在前面或后面,但不会把那部分纳入匹配结果(即不消耗)。一共有四种。

来看一个实用的例子。要给数字插入千位分隔符时,需要用先行断言找到"后面还剩下三的倍数位数字的位置"。再比如密码规则校验中要求"至少包含一个数字"时,会用 (?=.*\d)。这个片段实际上不消耗任何字符,只检查"某处存在一个数字"这个条件。把多个条件叠在一起,像 ^(?=.*[a-z])(?=.*\d).{8,}$ 这样,就能一次性校验"包含小写字母、包含数字、长度 8 位以上"。

贪婪 vs 懒惰 — 匹配的胃口

量词还有隐藏的性格。默认情况下,量词是贪婪(greedy)的,也就是尽可能多吃。这个特性常常会变成陷阱。

假设你想抓取 HTML 标签,用了 <.+> 这个模式。在字符串 "<b>bold</b>" 中,你大概期望只捕获到 <b>,但贪婪的 .+ 会尽量多吞,把 <b>bold</b> 整个都抓走了。它把从第一个尖括号到最后一个尖括号整段都吃掉了。

解决办法是把量词变成懒惰(lazy),只需要在量词后面加上 ?<.+?> 会"尽量少吃",所以只捕获 <b>。总结一下:

顺带一提,这个例子更好的做法是使用否定字符类。<[^>]+> 只吃"不是尖括号的字符",所以一开始就不会越过闭合的尖括号。像这样从设计上减少回溯本身,正是预防接下来要讲的性能问题的关键。

灾难性回溯与 ReDoS

相当多的正则表达式引擎,是靠回溯(backtracking)方式运作的:匹配失败时就退回去尝试别的可能性。大多数情况下没问题,但如果模式写得不好,需要尝试的可能性数量会随着输入长度呈指数级爆炸。这就是灾难性回溯(catastrophic backtracking)。

典型的危险模式发生在重复嵌套着重复、边界又模糊的时候。比如给 (a+)+$ 这样的模式一个像"aaaaaaaaaaX"这种在末尾匹配失败的输入,引擎就会把这些 a 分配进内层分组和外层分组的所有组合方式全部试一遍,事实上等于卡死。输入哪怕只多几个字符,耗时就会爆炸式增长。

利用这个弱点让服务瘫痪的攻击,就是 ReDoS(Regular expression Denial of Service)。攻击者只需一个精心设计的恶意输入,就能把服务器 CPU 死死钉在 100%。现实中,知名库里的 ReDoS 漏洞被反复发现过。

防御方法如下。

不该使用正则表达式的时刻

正则表达式很强大,但不是万能的。最有名的反例就是解析 HTML(或 XML)。HTML 是嵌套的、递归的结构,而传统正则表达式从根本上无法表达这种任意深度的嵌套。硬用正则表达式解析 HTML 的尝试,曾在 Stack Overflow 一则传奇回答里被激烈警告过,实际工作中也会在各种边界情况上崩溃。HTML 应该交给专用解析器(DOM 解析器)来处理。

还有其他一些提示,说明正则表达式不适合。

正则表达式在"以 token 为单位的局部模式匹配"上最为出色。邮箱格式的粗略校验、从日志行中提取字段、查找替换,这些都是正则表达式的主场。

几个实用技巧

最后是一些实际使用正则表达式时有帮助的习惯。

巩固所学最好的方法就是动手解题。用本站的正则表达式测验逐一检查各个概念,再用正则表达式测试工具实验属于你自己的模式。

结语

正则表达式表面看起来像密码,但归根到底不过是几种构成要素的组合。用字面量和字符类决定"匹配什么",用量词决定"匹配几次",用锚点决定"在哪里匹配",再用分组、选择和环视把结构精细打磨。再加上贪婪与懒惰的区别,以及灾难性回溯这个陷阱的知识,你就已经能够自信地应对绝大多数实际场景了。

最重要的教训是克制。正则表达式该用在局部模式匹配上,不要用来解析嵌套结构。守住这条界线,正则表达式就不再是危险的咒语,而会成为可靠的工具。

参考资料

评论

还没有评论。

登录后即可发表评论