7AA7 Blog

正则表达式实用指南:文本处理的通用钥匙

栈外游 UID:91
2026-10-02 18:50
58 2 4

写代码写到一定阶段,几乎绕不开正则。它不是什么高深学问,但绝对是投入产出比最高的技能之一:一套模式语法,通吃日志清洗、配置提取、批量替换、输入校验。麻烦的地方在于它长得像天书,写错了还不报错,只会匹配不到。这篇文章按”元字符 → 分组 → 匹配策略 → 坑 → 场景”的顺序把实用部分过一遍,示例以 Python re 为主,转到 JS 只需把 re.findall 换成 matchAll 一类调用,语法层面几乎一致。

元字符:先认清”一个字符是什么”

正则匹配的最小单位是”字符”,元字符用来描述”这一位上可以出现什么”:

写法 含义
. 任意单个字符(默认不匹配换行)
d / D 数字 / 非数字
w / W 单词字符(字母数字下划线)/ 非单词字符
s / S 空白 / 非空白
[abc] 字符集合,匹配其中一个
[^abc] 取反,匹配不在集合中的字符
^ / $ 行首 / 行尾
b 单词边界

量词跟在元字符后面,控制”重复多少次”:*(0 次或多次)、+(1 次或多次)、?(0 或 1 次)、{n,m}(n 到 m 次)。举个最普通的例子,匹配 2026-10-21 这样的日期:

import re
pat = r"d{4}-d{2}-d{2}"
print(re.findall(pat, "版本 2026-10-21 发布,回归窗口 2026-11-02 关闭"))
# ['2026-10-21', '2026-11-02']

注意 r"..." 的 raw 字符串写法,d 不会被 Python 先转义一次,这是新手最常见的翻车点。

分组与捕获:把”整段”和”局部”同时拿到

圆括号有两个作用:把多个字符当成一个整体受量词控制,以及把括号里的内容单独捕获出来。前者好理解,(?:abc)+ 里的 abc 被当成一个单元;后者才是日常真正会用到的:

line = "2026-10-21 14:03:22 ERROR order-service timeout after 3s"
pat = r"(d{4}-d{2}-d{2} d{2}:d{2}:d{2})s+(w+)s+(S+)s+(.*)"
m = re.search(pat, line)
print(m.group(1))  # 2026-10-21 14:03:22
print(m.group(2))  # ERROR
print(m.group(3))  # order-service
print(m.group(4))  # timeout after 3s

一次匹配就把时间、级别、服务名、消息体拆开,比 split 链式调用干净得多。命名分组在字段多的时候更好维护:(?P<ts>d{4}-d{2}-d{2}),之后用 m.group("ts") 取值。不需要捕获的括号写成 (?:...),省掉引擎的捕获开销,也让”这层括号只是分组”的意图更明确。

贪婪与懒惰:量词到底吃多少

默认量词是贪婪的,能吃多少吃多少,吃不下再吐回来。<.*> 匹配 <a><b> 时会从第一个 < 一直吞到最后一个 >。想让它少吃到第一个结束标记,就在量词后面加 ? 变成懒惰:

html = '<div class="x">hello</div><div>world</div>'
print(re.findall(r"<div.*?>", html))   # ['<div class="x">', '<div>']
print(re.findall(r"<div.*>", html))    # ['<div class="x">hello</div><div>world</div>']

选择贪婪还是懒惰没有固定规则,看你要的是”最外层的一段”还是”最短的一段”。写完拿两组文本试一下,比盯着表达式冥想靠谱。

零宽断言:只判断”边上是什么”,不消费字符

前瞻 (?=...) 和后顾 (?<=...) 用来表达”这个位置右边/左边必须长成那样”,但匹配过程中不吞掉那些字符。典型用法是”金额后面跟单位才认”:

text = "预算 1200 元,另有 300 个节点"
print(re.findall(r"d+(?= 元)", text))   # ['1200']
print(re.findall(r"(?<=预算 )d+", text)) # ['1200']

反过来还有否定断言 (?!...) 和 (?<!...),比如”提取所有非测试域名的 host”:r"(?<!test.)bapi.example.comb"。断言写多了可读性下降很快,能用普通分组表达清楚的就别上断言。

常见的坑

转义。 要匹配 .、*、?、(、[ 这些元字符本身,必须写成 . * ? ( [。反过来,- 只在字符集合的中间位置是范围符号,[ 内的 ^ 只有在开头才是取反,这些”位置敏感”的语义最好用实例记住。

回溯爆炸。 模式里如果出现嵌套量词,比如 (a+)+b,再碰上一长串不匹配的 aaaaaaaaaa...,引擎会尝试指数级的切分方式,表现就是脚本卡住不动。规避手段是把嵌套量词拆开、用具体字符类替代 .、或者换用支持惰性 DFA 的引擎。一个粗暴但有效的检查办法:拿一条”注定匹配失败”的最长输入跑一下,超时就说明模式有回溯风险。

性能与可读性的平衡。 单条正则写到三行以上就该考虑拆成两步,先用简单模式切分,再对局部用正则细抠。正则是工具,不是用来炫技的语言。

忽略大小写与多行。 re.I 让 error 也能吃下 ERROR/Error,日志级别匹配基本都该带上;re.M 把 ^ 和 $ 的语义从”整串的首尾”改成”每一行的首尾”,按行处理批量文本时是标配。多行模式还容易和另一个开关混淆:re.S(DOTALL)让 . 可以跨越换行符,抓跨行的 XML/JSON 片段时才需要它,两个开关按场景分开选。

findall 的返回形状会变。 模式里没有捕获组时返回整段匹配的列表;有一个捕获组时返回组内内容;有多个捕获组时返回元组列表。一个不小心,调用方拿到的数据结构就变了。想统一行为,要么全部用命名分组配合 finditer 自己解析,要么明确约定模式里捕获组的数量。

编译一次,复用多次。 在循环里每轮都写 re.findall(...),引擎每次都要重新解析模式。把模式提到循环外用 re.compile 编译成对象,既是性能优化,也让”这个模式要复用”的意图更清楚。写库代码时,这种对象还能作为参数传出去,方便调用方按需替换。

几个能直接抄的场景

日志清洗:把 ANSI 颜色码和控制字符从管道输出里剥掉:

clean = re.sub(r"x1b[[0-9;]*m", "", raw)
clean = re.sub(r"[x00-x1fx7f]", "", clean)

配置提取:从一段 ini/环境变量风格的文本里抓出键值对,容忍多余空白和引号:

pat = r"^s*([A-Za-z_][A-Za-z0-9_]*)s*=s*["']?([^"'n]+)["']?s*$"
pairs = dict(re.findall(pat, blob, flags=re.M))

批量替换:给 Markdown 里裸写的 URL 统一加上尖括号,同时避免重复处理已经是链接的:

pat = r"(?<![(<])(https?://[^s)>]+)(?![)>])"
out = re.sub(pat, r"<1>", text)

字段脱敏:把手机号中间四位打码,日志落地前处理:

masked = re.sub(r"(1d{2})d{4}(d{4})", r"1****2", text)

按错误码聚合:从大批量日志里把错误码和出现位置一起取出来,后面好统计分布:

pat = re.compile(r"err_code=(d{4})")
hits = pat.findall(blob)
from collections import Counter
print(Counter(hits).most_common(5))

行内容分流:把”只包含 DEBUG 的行”整体过滤掉,剩下的交给后续管道:

kept = [ln for ln in text.splitlines() if not re.search(r"bDEBUGb", ln)]

这几个例子的共同点是”边界条件写在模式里”——后顾避免已经加括号的被再包一层,捕获组保住需要还原的部分,量词限定范围避免多吞。正则写得好不好,看的就是边界想得全不全。

写完一条正则,随手在解释器里用 re.compile 编译并跑两三条正样本、两条负样本,比上线后翻日志找原因省事得多。regex101 这类在线工具也值得开着,它会实时显示每一步的匹配过程和回溯次数,排查”为什么匹配不到”比肉眼扫字符串快很多。

1.1kViews
58Likes
2Comments
2Participants
11 minread time

Comments (2)

  • 码同客 · 2026-10-02 21:50

    一直没想明白为什么要这样设计,现在懂了。

    Reply
  • NullRef · 2026-10-03 04:50

    实操性很强,不是空谈。

    Reply