Skip to content
Published on

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

分享
Authors

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

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

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

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

字面量与元字符

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

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

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

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

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

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

  • [aeiou] 匹配一个元音字母。
  • [a-z] 匹配一个小写字母,连字符表示范围。
  • [a-zA-Z0-9] 匹配一个英文字母或数字字符。
  • [^0-9] 这样把插入符放在方括号内最前面,就构成否定,匹配一个非数字字符。

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

  • \d 表示数字,\D 表示非数字字符。
  • \w 表示单词字符(字母、数字、下划线),\W 相反。
  • \s 表示空白字符(空格、制表符、换行等),\S 相反。
  • . 表示(默认设置下)除换行符之外的任意一个字符。

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

量词 — "重复几次?"

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

  • * 表示 0 次以上(可有可无)。
  • + 表示 1 次以上(至少一次)。
  • ? 表示 0 次或 1 次(可选)。
  • {n} 表示恰好 n 次。
  • {n,} 表示 n 次以上。
  • {n,m} 表示 n 次以上 m 次以下。

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

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

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

  • ^ 表示字符串(或行)的开头。
  • $ 表示字符串(或行)的结尾。
  • \b 表示单词边界,即单词字符与非单词字符之间的缝隙。
  • \B 表示不是单词边界的位置。

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

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

分组与捕获 — 打包与记住

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

  • (ab)+ 匹配"ab"重复一次以上的结果(如"ababab"等)。如果没有分组,ab+ 匹配的是"abbbb",意义完全不同。
  • 拆解日期的模式 (\d{4})-(\d{2})-(\d{2}) 把年、月、日分别捕获为第 1、2、3 组。程序可以取出这些捕获组来使用。

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

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

选择 — "这个或那个"

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

  • cat|dog 匹配"cat"或"dog"。
  • 想限制选择的范围,就用分组包起来。比较下面两个模式:
^(cat|dog)$    → 整个字符串是 "cat" 或 "dog"
^cat|dog$      → 被解析为 "^cat" 或 "dog$" (与本意不同)

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

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

环视 — 窥视而不消耗

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

  • 正向先行断言(lookahead) (?=...):如果后面跟着这个。
  • 负向先行断言 (?!...):如果后面不跟着这个。
  • 正向后行断言(lookbehind) (?<=...):如果前面有这个。
  • 负向后行断言 (?<!...):如果前面没有这个。

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

贪婪 vs 懒惰 — 匹配的胃口

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

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

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

  • *+?{n,m} 是贪婪的,尽量多吃。
  • *?+???{n,m}? 是懒惰的,尽量少吃。

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

灾难性回溯与 ReDoS

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

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

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

防御方法如下。

  • 避免嵌套量词:警惕像 (a+)+(a*)* 这种重复里模糊嵌套着重复的结构。
  • 尽量具体:用 [^>] 这类窄字符类代替宽泛的 .,能减少回溯的空间。
  • 用锚点固定:用 ^$ 把匹配范围框住,能减少引擎游走的空间。
  • 考虑线性时间引擎:使用不做回溯、始终保证线性时间的引擎,比如 RE2(Google)或 Rust 的 regex crate,能让 ReDoS 从根本上不可能发生。
  • 给不可信输入加超时:如果语言或库支持,给正则表达式的执行加上超时限制。

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

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

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

  • 需要为嵌套括号或结构配平时:把开括号和闭括号在任意深度上配对,不是正则表达式的领域。
  • 模式变得比代码更难理解时:比起一个要读 5 分钟的 100 字符正则表达式,几行明确的字符串处理代码往往更好。
  • 已经有解析器的格式:JSON、CSV、URL、日期等大多有经过验证的专用解析库。动手写正则表达式之前,先去找找看有没有现成的。

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

几个实用技巧

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

  • 注释与扩展模式:很多语言支持 x 标志(扩展模式),让你可以在正则表达式里加入空白和注释,分成多行书写。模式越复杂,这样展开写就越有利于维护。
  • 理解各个标志:忽略大小写(i)、多行模式(m,^$ 作用于每一行)、点号也匹配换行(s)这类标志会大幅改变结果。
  • 预先编译:如果同一个模式会反复使用,在循环外编译一次再复用,对性能更好。
  • 配上测试:正则表达式很微妙。用代表性输入和边界情况写好测试,以后修改模式时会更安全。

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

结语

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

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

参考资料