ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

正则表达式从入门到实战:语法、高频校验与灾难性回溯避坑

正则表达式从入门到实战:语法、高频校验与灾难性回溯避坑 1. 正则表达式到底在解决什么问题很多人第一次听到正则表达式regex是在某个校验表单的代码里看到一长串看不懂的符号比如^1[3-9]\d{9}$当时的第一反应通常是这什么鬼。但等你真正写过几万行处理文本的代码之后会发现正则表达式不是炫技它是把人类用自然语言描述的文本规律翻译成机器能执行的一套微型语言。你只要花一个下午把它啃下来后面处理日志、清洗数据、批量改名、表单校验、抓取解析这些活儿效率会翻好几倍。我先说清楚它适合谁。如果你日常写 Python、JavaScript、Java、Go 这类语言或者用 Excel、Notepad、VS Code、各种数据库客户端做文本处理那正则就是刚需。如果你只是偶尔改个文件名那学个百分之二十也够用。最怕的是两种情况一种是把正则当万能锤子什么字符串处理都想用一条正则解决最后写出没人能维护的天书另一种是明明三行字符串切分就能搞定的事硬要上正则结果调了两小时。我踩过这两种坑后面会详细讲。这篇文章我按入门到能上手干活的路径来写先把核心语法一块块拆开讲清楚每个符号为什么这么设计然后拿手机号、邮箱、日期、日志提取这些高频场景一条条写出来最后讲不同语言和工具里的差异以及我自己踩过的坑。读完你应该能做到看到一条陌生正则能拆解它遇到一个校验需求能自己写出来并且知道哪些地方容易埋雷。正则表达式这东西学会之后是真的回不去。2. 核心语法拆解从字面量到零宽断言2.1 字面量与转义哪些字符需要加反斜杠最简单的情况正则就是一串普通字符。abc这条正则的意思就是在文本里找连续的 abc 三个字母。这里没什么玄机难点在于——正则里有一批字符被赋予了特殊含义它们叫元字符。你如果想匹配它们的字面意思就必须在前面加反斜杠转义。需要转义的常见元字符有这么一批. ^ $ * ? ( ) [ ] { } | \。举个例子你想匹配一个真实的英文句号写.是错的那个点表示任意一个字符必须写\.。想匹配路径C:\Users就得写C:\\Users因为在正则里反斜杠本身也要转义成\\。注意转义这件事是双层的。如果你的正则写在编程语言的字符串里字符串本身还有一层转义规则这就导致写路径、写换行符的时候经常少写一个反斜杠。后面第 5 章我会专门讲这个坑。我的经验是新手期不要背元字符表直接记一条判断规则这个字符如果单独出现在正则里会不会有特殊行为。有就转义没有就原样写。像-、,、/、这些在绝大多数位置都是普通字符不用转义-在字符类里有特殊含义是个例外。2.2 字符类与预定义简写把一个位置的候选列出来字符类是正则里最实用的工具之一。[abc]表示这一个位置可以是 a、b 或 c 中的任意一个。[0-9]表示一个数字[a-zA-Z]表示一个英文字母[^0-9]表示一个非数字字符方括号里的^是取反的意思。因为匹配单个数字匹配单个字母这种需求太常见正则提供了一批预定义字符类简写直接用单个反斜杠字母表示简写含义等价写法\d一个数字[0-9]\D一个非数字[^0-9]\w单词字符字母、数字、下划线[a-zA-Z0-9_]\W非单词字符[^a-zA-Z0-9_]\s空白字符空格、制表符、换行[ \t\n\r\f\v]\S非空白字符反过来这里有个细节很多人不知道\w在 Unicode 模式下会匹配中文、日文等非 ASCII 字母。在 Python 里对 str 用\w默认就是 Unicode 语义re.match(r\w, 中文abc)是能匹配上的而在 JavaScript 里不加u标志时\w只认 ASCII。这个差异会让同一条正则在两个语言里结果不一样是非常经典的我本地好好的事故来源。字符类里还有几个书写技巧值得记[a-z]这种区间写法要求起始字符的码位小于结束字符写反了[z-a]会直接报错]如果要放进字符类里得放在第一个位置或者转义^只有紧跟在[后面才表示取反放在中间就是普通字符。2.3 量词控制重复几次以及贪婪和懒惰的区别量词解决的是前面那个东西重复多少次的问题这是正则表达力的核心来源*表示 0 次或多次表示 1 次或多次?表示 0 次或 1 次{n}表示恰好 n 次{n,}表示至少 n 次{n,m}表示 n 到 m 次这里必须重点讲贪婪和懒惰因为它是新手翻车率最高的地方。默认情况下量词是贪婪的也就是能匹配多长就匹配多长。假设文本是div标题/divdiv正文/div你用div.*/div去匹配得到的结果会是整串div标题/divdiv正文/div而不是你想要的第一个 div。原因拆开看.*会先一路吞到字符串末尾然后发现后面还要匹配/div匹配不上于是开始回退backtracking一个一个字符往回吐直到找到最后一个/div。解决办法是在量词后面加?变成懒惰模式div.*?/div它会尽可能少地匹配于是只拿到第一个 div 的内容。import re text div标题/divdiv正文/div print(re.findall(rdiv.*/div, text)) # [div标题/divdiv正文/div] print(re.findall(rdiv.*?/div, text)) # [div标题/div, div正文/div]实操心得懒惰模式不是免费的。它同样依赖回溯机制极端情况下性能反而更差。如果模板结构固定、内容里不可能出现/div用div[^]*/div这种否定字符类往往比懒惰量词更快也更安全因为它不需要回退。2.4 锚点、分组、捕获与反向引用锚点用来限定位置不消耗字符。最常用的两个是^和$分别表示字符串开头和结尾。^abc$的意思就是整个字符串必须恰好是 abc一个字符都不能多。这两个符号在做表单校验时几乎是必备的——不加锚点\d{6}能匹配1234567里的前六位这显然不是你要的。分组用圆括号()作用有两个一是把一段内容当成整体加量词比如(ab)表示 ab 重复若干次二是捕获把匹配到的内容单独存下来供后续使用。捕获组按左括号出现的顺序编号从 1 开始。捕获组还有一个进阶用法叫反向引用用\1、\2引用前面捕获组匹配到的内容。经典例子是匹配重复单词import re s 这个 the the 单词重复了 print(re.findall(r\b(\w)\s\1\b, s)) # [the]\b是单词边界锚点它是个零宽断言只表示这里是词的边界本身不占字符。这里(\w)捕获到 the\s匹配空格\1要求跟刚才捕获的一模一样。如果括号很多、你只是想分组而不想捕获用(?:...)这种非捕获组能省一点性能更重要的是不会打乱你的编号。我见过因为随手加了个括号导致\3指向错误位置的 bug排查了半天。2.5 零宽断言不消耗字符的条件判断零宽断言是我认为正则里最优雅的部分。它只做判断不移动匹配位置所以叫零宽。常见四种(?...)正向先行断言右边必须是这个(?!...)负向先行断言右边必须不是这个(?...)正向后行断言左边必须是这个(?!...)负向后行断言左边必须不是这个举个实用场景。你要给一段金额数字加千分位但又不想匹配已经是千分位格式的import re def add_comma(s): return re.sub(r(?\d)(?(\d{3})$), ,, s) print(add_comma(1234567)) # 1,234,567这条正则的意思是在两个数字之间插入一个逗号前提是右边剩下的全是 3 的倍数个数字。两个断言都不消耗字符所以插入逗号不会破坏原来的数字序列。第一次看会觉得绕自己拿纸笔把位置标一遍就通了。需要注意后行断言在部分语言和工具里不支持可变长度。比如 JavaScript 早期版本完全不支持(?...)Python 的re模块要求后行断言里的模式必须是固定长度写(?\d)会直接抛错。要用可变长度的后行断言Python 得换regex这个第三方库。这类限制查文档时一定要看版本说明。3. 高频实战把常见校验规则一条条写出来3.1 手机号11 位和 13 位到底怎么算先说最常见的国内 11 位手机号。规则是首位固定 1第二位是 3 到 9 之间的数字后面跟 9 位任意数字。数位拆一下1首位 1第二位 9剩余 11 位所以正则写成/^1[3-9]\d{9}$/为什么第二位是[3-9]而不是[0-9]因为号段分配上不存在 10、11、12 开头的手机号把这一位收窄能挡掉一批明显的脏数据。为什么不是更精确的1[3-9]\d{9}之后再枚举具体号段因为号段会随年份变化写死具体号段意味着每次上新号段你都得改代码维护成本太高得不偿失。现在来看热搜里提到的13 位数字手机号码。如果你遇到的是 13 位通常不是国内本地号码而是带上了国际区号。比如国内号码前面加86就变成 13 位加86则是 14 个字符含加号。所以写正则之前必须先确定业务场景我列三种常见写法场景正则说明纯 11 位本地号^1[3-9]\d{9}$最常用带 86 前缀的 13 位^861[3-9]\d{9}$13 位数字无加号允许加号可选前缀^(\?86)?1[3-9]\d{9}$兼容 11 位和 14 字符第三条是我在项目里最常用的版本(\?86)?这个可选组能吃下加号、86 前缀的各种组合。要提醒的是在正则里是量词想匹配字面加号必须写\这是新手最常漏的一个转义。注意不要用一条正则去验证号码是否真实存在。正则只能校验格式判断号码有效性那是短信接口的活儿。3.2 邮箱别写教科书上那个完美正则网上流传的邮箱正则有几百个字符试图覆盖所有 RFC 规范那种东西没人看得懂也没人敢改。工程上的做法是只做基本格式拦截真实有效性靠发验证邮件。我通常用这条/^[\w.!#$%*/?^{|}~-][a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)$/拆开看几个关键设计。前面允许的字符比较多因为邮箱本地部分确实支持不少符号但排除了空格和本身。域名部分限制每一段最长 63 个字符这是 DNS 的硬规定所以用了{0,61}加上首尾各一个字符正好凑 63。(?:\.[...])要求至少有一个点也就是必须有顶级域。要注意\w在不同语言里对非 ASCII 的处理不一样如果你的系统允许中文邮箱地址这条正则就不适用了得换成 Unicode 属性类写法。这类看起来通用其实有边界的正则我最怕直接复制粘贴到新项目里不测就用。3.3 日期、身份证这类带校验逻辑的格式日期格式YYYY-MM-DD很多人第一版会写成^\d{4}-\d{2}-\d{2}$。这条能过格式但会放过2024-13-45这种鬼东西。改进思路是把月份和日期分别收窄/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/月份用0[1-9]|1[0-2]覆盖 01 到 12日期用三段分支0[1-9]管 01-09[12]\d管 10-293[01]管 30-31。想更严格地管住 2 月、小月天数那就得引入更复杂的判断而我的建议是别用正则做这件事。正则做的是模式匹配月份天数还要考虑闰年这是逻辑计算交给代码里的日期库去做可读性和正确性都高得多。身份证号同理。18 位身份证的前 17 位是数字最后一位是数字或 X。格式正则是^\d{17}[\dXx]$再加地址码、出生日期段的简单校验也还行。但真正的身份证校验靠的是最后一位校验码那是一套加权求和取模的计算必须用代码实现。硬塞进正则只会写出一坨没人能维护的东西。3.4 日志提取与批量替换这才是正则的主场表单校验只是正则的入门用法真正能体现它价值的是文本提取和批量处理。假设你有这样一行 Nginx 风格日志2024-05-11 08:23:15 ERROR [order-service] userId10086 cost235ms msgtimeout想提取时间、级别、服务名和耗时一条正则加命名捕获组就够了import re line 2024-05-11 08:23:15 ERROR [order-service] userId10086 cost235ms msgtimeout pattern re.compile( r(?Pts\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s r(?Plevel[A-Z])\s r\[(?Psvc[^\]])\]\s ruserId(?Puid\d)\s rcost(?Pcost\d)ms ) m pattern.search(line) print(m.groupdict()) # {ts: 2024-05-11 08:23:15, level: ERROR, svc: order-service, uid: 10086, cost: 235}这里有几个值得说的设计点。(?Pname...)是 Python 的命名捕获组语法好处是取数据时不用数括号位置代码可读性直接上一个台阶。[^\]]用否定字符类匹配服务名比.?更快也更准确因为服务名里不可能有右方括号。\s而不是写死一个空格因为日志格式里空格数量经常不规整。批量替换的场景我也常干。比如把一堆 SQL 里的表名统一加前缀sql re.sub(r\b(?:FROM|JOIN)\s(\w)\b, rFROM t_\1, sql, flagsre.I)re.I让匹配忽略大小写(?:FROM|JOIN)是非捕获组\1在替换串里引用被捕获的表名。注意替换串里引用捕获组用的是\1或者 Python 的\g1后者在数字后紧跟其他数字时更安全。4. 跨语言差异Python、JavaScript 和 SQL Server4.1 Python 的 re 模块怎么用才顺手Python 里最常用的四个函数得记住re.match从字符串开头匹配re.search扫描整个字符串找第一个re.findall返回所有匹配re.sub做替换。新手最容易搞混 match 和 search——re.match(r\d, abc123)是匹配不到的因为它只从位置 0 开始尝试。import re re.search(r\d, abc123).group() # 123 re.findall(r\d, a1b22c333) # [1, 22, 333] re.sub(r\d, #, a1b22) # a#b#有个性能细节值得强调正则在被反复使用时应该先编译。re.compile会把模式字符串编译成内部对象循环十万次的时候预编译和不预编译的差距相当明显。import re, timeit pat re.compile(r\d{4}-\d{2}-\d{2}) print(timeit.timeit(lambda: pat.search(2024-05-11), number100000))Python 的re模块内部有缓存同一个模式字符串多次直接调用不会每次都重新编译但显式 compile 依然更清晰而且在需要传 flags 的时候更好管理。4.2 JavaScript 里的正则字面量和标志位JavaScript 有两种写法字面量/pattern/flags和构造器new RegExp(pattern, flags)。日常用字面量就行需要动态拼接时才用构造器。但用构造器时反斜杠要写两个因为字符串先解析一层这是经典坑/\d/.test(123); // true new RegExp(\\d).test(123); // true必须双反斜杠 new RegExp(\d).test(123); // 错误\d 在字符串里不是转义序列标志位里最常用的是g全局用于多次匹配、i忽略大小写、m多行让^$匹配每行首尾、s让.匹配换行、uUnicode 模式。这里有个特别容易踩的坑带g标志的正则对象是有状态的。const re /\d/g; re.test(1); // truelastIndex 1 re.test(1); // false从 lastIndex1 开始找找不到了同一个正则对象连续调用test会因为lastIndex累积而返回不同结果。我在表单校验里见过这个 bug同一个正则校验两次第二次莫名其妙失败。解决办法是不带g做校验或者每次使用前把lastIndex归零。4.3 SQL Server 里的正则先确认你的版本支持什么SQL Server 长期以来是没有原生正则的这一点跟 MySQL 的REGEXP、Oracle 的REGEXP_LIKE很不一样。老版本里只能靠LIKE和PATINDEX凑合而它们只支持%、_、[]这几个通配符表达能力非常有限。-- 老版本只能做到这种程度 SELECT * FROM users WHERE phone LIKE 1[3-9]%;如果你确实需要在 SQL Server 里用完整正则主要有两条路一是注册 SQLCLR把 .NET 的System.Text.RegularExpressions包成自定义函数二是把数据取到应用层用 Python 或 C# 处理。CLR 方案性能不错但启用 CLR 涉及服务器配置很多运维环境不放行落地前一定要先确认环境策略。比较新的 SQL Server 版本已经开始提供REGEXP_LIKE、REGEXP_REPLACE、REGEXP_SUBSTR这类函数用法跟其他数据库接近。但具体哪个功能在你手上的版本可用必须以官方文档为准我在项目里就遇到过开发环境能跑、生产环境报函数不存在的情况因为两边版本不一致。实操心得数据库层的正则用法尽量收敛成能用 LIKE 就不用正则。因为数据库正则不仅语法跟编程语言有差异还很难调试一个查询慢了你都不知道是索引问题还是回溯问题。能用简单条件过滤的别上正则。4.4 换行和 Unicode跨语言最容易翻车的两块.默认不匹配换行符这个行为各语言基本一致但打开方式不一样。Python 用re.S也叫re.DOTALLJavaScript 用s标志Java 用Pattern.DOTALL。如果你用.*去匹配跨行内容却没打开这个开关结果只会匹配到第一行末尾。Unicode 方面的差异更隐蔽。Python 3 对 str 默认就是 Unicode 语义\d能匹配全角数字\w能匹配汉字而 JavaScript 不加u标志时\d和\w只认 ASCII。同一条正则在不同语言里跑出不同结果这是我在做多语言系统时踩过的实实在在的坑。如果你需要按 Unicode 属性分类匹配比如所有标点符号所有控制字符得用\p{...}语法但 JavaScript 需要u标志Python 的re模块压根不支持得换regex库。写这类规则前一定要先在你的运行环境里跑个小测试确认。5. 踩坑记录与排查手册5.1 灾难性回溯一条正则可以卡死你的服务这是正则里最凶的坑没有之一。当正则里出现嵌套量词且匹配失败时回溯次数会指数级增长。典型写法是^(a)$。面对aaaaaaaaaaaaaaaaaaaaaaaaaaaX这样的输入正则引擎会尝试各种 a 的切分组合数量随长度指数膨胀几秒钟就能把一个 CPU 打满。我在线上见过一次事故某个接口对用户提交的字符串用了一条带嵌套量词的正则做校验有人提交了三四十个字符的恶意串接口响应时间从 5ms 涨到 30 秒之后整台机器都被拖垮了。修复方式是把(a)改成a语义一样但彻底消除了嵌套。自查这类问题的办法很简单看同一段内容上是不是套了两层以上量词。(\w)*、(.*)*、(.*)这些都属于高危写法。另一个缓解手段是加超时Python 的re不支持超时需要换regex库的timeout参数Java 和 .NET 都提供了超时设置。生产环境处理用户可控输入的正则一定要设置超时或长度上限。5.2 转义与字符串二次转义前面提过正则写在代码字符串里会有两层转义。我用一个具体例子说清楚。你想匹配一个反斜杠加数字正则层面是\\\d写成 Python 字符串是\\\\\\d写成原始字符串是r\\\d。三层反斜杠看着就头晕。我的习惯是能用原始字符串就用。Python 里写r\d而不是\d虽然\d在 Python 字符串里恰好不是有效转义序列会被原样保留但会触发警告但写成r\d意图明确也不会在\b退格符、\n换行这类真正有意义的转义上翻车。\b是最阴险的一个正则里的\b是单词边界Python 字符串里的\b是退格字符不写原始字符串就会得到一个完全不同的模式。另外还有一个实际工作中会遇到的情况把正则表达式本身当成参数传给某个接口或配置系统时对方可能会对字符串内容做校验。我自己就遇到过正则字符串被拒的情况排查下来是里面包含了控制字符或者某些特殊字符被对方的校验规则挡了。解决办法是把正则里的转义写清楚避免出现来路不明的不可见字符必要时把模式拆成多个简单模式用代码组合而不是塞进一条超长的字符串。5.3 锚点、边界与为什么匹配到了多余内容^和$在默认模式下只匹配整个字符串的首尾。但在多行文本里你可能期望它匹配每一行。这时候要打开多行模式Python 的re.MJavaScript 的m标志。反过来如果没打开多行模式却写了^abc$去逐行匹配就会发现只有第一行和最后一行可能命中。还有一个隐藏陷阱很多语言的$默认会匹配结尾换行符之前的位置。也就是字符串abc\n用^abc$是能匹配的。如果你要求绝对严格的末尾得用\zPython或者\ZJavaScript 里没有直接对应需要额外处理。\b也是个容易出问题的地方。\b的边界定义是基于\w的而\w又跟 Unicode 模式有关所以写\b你好\b这种匹配中文词的边界结果往往不符合预期。中文场景下与其依赖\b不如显式写出前后的限定字符。5.4 常见问题速查表下面这张表是我这些年攒下来的高频问题出问题的时候可以按图索骥现象常见原因处理办法匹配结果比预期长很多量词默认贪婪加?变懒惰或改用否定字符类明明有匹配却返回空^$只匹配整个字符串打开多行模式第二次调用结果不一样带g标志的正则对象有状态每次使用前重置lastIndex.匹配不到换行DOTALL 未开启加对应标志正则里.匹配到不该匹配的字符忘记转义写成\.数字被多匹配了几位缺少边界或锚点加\b或^$匹配超时甚至卡死嵌套量词造成灾难性回溯消除嵌套加超时和长度限制换到另一种语言结果不同Unicode 语义和标志位差异显式加u标志或改用属性类字符串转义报错二次转义问题改用原始字符串后行断言编译失败变长后行断言不受支持换成第三方库或改写模式6. 调试与验证让正则变得可控6.1 拆解法把长正则打回原形看到一条长正则不要慌也绝对不要直接改。我的做法是拆成几段分别验证再拼起来。比如前面那条命名的日志正则我会先只测时间部分\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}确认它能在样本上命中再加上\s[A-Z]测级别逐段往上加每加一段跑一次。这样做的好处是一旦某段不匹配你能立刻定位到是哪一段的问题而不是对着一条 200 字符的正则干瞪眼。同理写长正则时该加注释就加注释Python 的re.X标志允许你在正则里写空白和注释非常适合复杂模式import re pattern re.compile(r (?Pts\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}) # 时间 \s(?Plevel[A-Z]) # 日志级别 \s\[(?Psvc[^\]])\] # 服务名 , re.X)re.X模式下空格和#后面的内容会被忽略所以可读性大幅提升。唯一的代价是你想匹配真实空格时必须写\s或[ ]。6.2 建一组测试样本尤其是反例我强烈建议给重要的正则配一组测试用例而且要同时准备正例和反例。正例保证它能匹配到该匹配的反例保证它不会匹配到不该匹配的。手机号那条正则正例可以是13800138000、19912345678反例可以是12800138000第二位是 2、1380013800只有 10 位、13800138000112 位、abc13800138000加锚点后应该拒绝。这些反例不是形式主义它们才是真正能发现问题的东西。我见过不少上线后才发现的正则漏洞全都是因为只测了正例没人想过反例会出现。写测试的时候还有个技巧把每一条样本和期望结果做成数据对用同一个测试函数批量跑加新样本的成本几乎为零。import re cases [ (13800138000, True), (12800138000, False), (1380013800, False), (138001380001, False), ] pat re.compile(r^1[3-9]\d{9}$) for s, expected in cases: got bool(pat.fullmatch(s)) print(s, got, OK if got expected else FAIL)6.3 我自己养成的几个小习惯第一优先用最笨的语法。能用字符类就用字符类能用固定长度就不用灵活量词能拆成两个简单正则跑两遍就不用一条复杂正则。代码是给人看的正则尤其如此因为它比普通代码更难读。第二校验类正则一定要加锚点。这是我见过最高频的疏漏没有锚点的校验基本等于没有校验。第三涉及用户输入的匹配加上长度上限。回溯爆炸的风险跟输入长度强相关先限制输入长度风险就降下来一大截。很多系统在入口处限制了字段长度这类事故就少得多。第四善用可视化工具辅助理解但不要依赖工具生成。工具能帮你看清楚每一步的匹配位置和回溯过程对理解很有帮助但工具自动生成的正则往往冗余且晦涩做校验还能用做日志解析基本没法维护。我的做法是工具用来验证我手写的正则而不是代替我写。第五记住正则不是解析器。解析 HTML、JSON、SQL 这种有嵌套结构和上下文状态的东西正则天生不擅长。我早期用正则解析 HTML 吃过亏标签一嵌套就抓瞎。这类任务应该用专门的解析库正则只在提取已知结构的片段时用。清楚这条边界能省下你大量返工时间。
返回列表