写代码写到一定阶段,几乎绕不开正则。它不是什么高深学问,但绝对是投入产出比最高的技能之一:一套模式语法,通吃日志清洗、配置提取、批量替换、输入校验。麻烦的地方在于它长得像天书,写错了还不报错,只会匹配不到。这篇文章按”元字符 → 分组 → 匹配策略 → 坑 → 场景”的顺序把实用部分过一遍,示例以 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 这类在线工具也值得开着,它会实时显示每一步的匹配过程和回溯次数,排查”为什么匹配不到”比肉眼扫字符串快很多。
评论(2)
一直没想明白为什么要这样设计,现在懂了。
实操性很强,不是空谈。
请 登录 后参与评论。