- 引言 — 正则表达式是一门小型语言
- 字面量与元字符
- 字符类 — "其中任意一个"
- 量词 — "重复几次?"
- 锚点与边界 — "在哪里?"
- 分组与捕获 — 打包与记住
- 选择 — "这个或那个"
- 环视 — 窥视而不消耗
- 贪婪 vs 懒惰 — 匹配的胃口
- 灾难性回溯与 ReDoS
- 不该使用正则表达式的时刻
- 几个实用技巧
- 结语
- 参考资料
引言 — 正则表达式是一门小型语言
第一次看到正则表达式(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)这类标志会大幅改变结果。 - 预先编译:如果同一个模式会反复使用,在循环外编译一次再复用,对性能更好。
- 配上测试:正则表达式很微妙。用代表性输入和边界情况写好测试,以后修改模式时会更安全。
巩固所学最好的方法就是动手解题。用本站的正则表达式测验逐一检查各个概念,再用正则表达式测试工具实验属于你自己的模式。
结语
正则表达式表面看起来像密码,但归根到底不过是几种构成要素的组合。用字面量和字符类决定"匹配什么",用量词决定"匹配几次",用锚点决定"在哪里匹配",再用分组、选择和环视把结构精细打磨。再加上贪婪与懒惰的区别,以及灾难性回溯这个陷阱的知识,你就已经能够自信地应对绝大多数实际场景了。
最重要的教训是克制。正则表达式该用在局部模式匹配上,不要用来解析嵌套结构。守住这条界线,正则表达式就不再是危险的咒语,而会成为可靠的工具。
参考资料
- MDN: Regular expressions guide — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions
- regular-expressions.info — https://www.regular-expressions.info/
- OWASP: Regular expression Denial of Service (ReDoS) — https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- Google RE2 engine — https://github.com/google/re2
- Rust regex crate (linear-time guarantee) — https://docs.rs/regex/
현재 단락 (1/85)
第一次看到正则表达式(regular expression,regex)时,它看起来就像猫在键盘上走过的结果。但正则表达式并非随机,而是一门用来描述字符串模式的、极其小巧精密的语言。只要理解几个构成要...