Python 正则表达式实战:从字符匹配到分组捕获

2026-06-01 26 预计阅读时间:1 分钟
来源:realpython.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:11 分钟

正则表达式是文本处理的瑞士军刀——用几行模式就能完成原本需要几十行字符串操作才能做的事。Python 的 re 模块把这套工具直接搬进了标准库,但不少开发者对字符类、锚点、分组、交替和标志位的理解还停留在"能跑就行"的阶段。这篇文章把这些核心概念串起来,配上可以直接复制运行的代码,帮你从"凑出个模式"升级到"写出精准模式"。

字符类与锚点:缩小匹配范围的第一步

字符类(character class)用方括号定义一组允许的字符,锚点(anchor)则把匹配钉在字符串的特定位置。两者配合,才能既不漏匹配、也不乱匹配。

几个高频字符类:

  • \d — 数字,等价于 [0-9]
  • \w — 字母、数字、下划线,等价于 [a-zA-Z0-9_]
  • \s — 空白字符(空格、制表符、换行等)
  • 大写形式 \D\W\S 是对应的补集

自定义字符类更灵活。比如 [aeiou] 只匹配小写元音,[^0-9] 匹配任何非数字字符(注意 ^ 在方括号内表示取反)。

锚点不消耗字符,只声明位置:

  • ^ — 字符串开头(多行模式下是每行开头)
  • $ — 字符串结尾(多行模式下是每行结尾)
  • \b — 单词边界
import re

text = "port: 8080, host: 10.0.3.15"

# 只匹配端口号:紧跟 "port: " 的数字
port_match = re.search(r'port:\s(\d+)', text)
print(port_match.group(1))  # 8080

# 用 \b 确保匹配的是完整单词而非子串
log = "error warning warned"
print(re.findall(r'\bwarn\b', log))   # ['warn'] — 只匹配独立单词
print(re.findall(r'warn', log))        # ['warn', 'warned'] — 子串也命中

关键区别:\bwarn 只命中独立词,不会把 warned 误抓进来。处理日志关键字过滤时,这个细节很实用。

分组与交替:提取结构化数据

分组用圆括号把一部分模式包起来,既改变优先级,又让匹配结果可按组提取。交替(alternation)用 | 表示"或"逻辑,和分组配合才能控制范围。

import re

# 从 URL 片段中提取协议和域名
url = "https://example.com/docs/api?q=regex"
pattern = r'(https?)://([^/]+)'

m = re.match(pattern, url)
if m:
    print(f"协议: {m.group(1)}")  # https
    print(f"域名: {m.group(2)}")  # example.com

# 交替:匹配多种日志级别
log_line = "[WARN] Disk usage at 92%"
level = re.search(r'\[(DEBUG|INFO|WARN|ERROR)\]', log_line)
print(level.group(1))  # WARN

注意 | 的作用域。cat|dog 匹配 "cat" 或 "dog";而 ca(t|d)og 匹配 "catog" 或 "cadog"。如果不加括号,交替会吞掉整条模式左右两侧,这往往是 bug 来源。

命名分组:让代码可读

数字编号的分组在模式变长后很难维护。命名分组用 (?P<name>...) 语法,提取时用名字而非序号:

import re

line = "2024-06-15 14:30:01 [ERROR] Connection timeout"
pattern = r'(?P<date>\d{4}-\d{2}-\d{2})\s(?P<time>\d{2}:\d{2}:\d{2})\s\[(?P<level>ERROR|WARN|INFO)\]\s(?P<msg>.+)'

m = re.match(pattern, line)
if m:
    print(m.group('date'))   # 2024-06-15
    print(m.group('level'))  # ERROR
    print(m.group('msg'))    # Connection timeout

命名分组在后续 re.sub.groupdict() 中都更清晰,推荐在模式超过 3 个分组时一律使用。

标志位:改变匹配行为

re 模块的标志位是容易被忽略的杠杆,几个最常用的:

标志 作用
re.IGNORECASE (re.I) 忽略大小写
re.MULTILINE (re.M) ^$ 匹配每行的开头结尾,而非整个字符串
re.DOTALL (re.S) . 也匹配换行符
re.VERBOSE (re.X) 允许在模式中写注释和换行,提高可读性
import re

html = """<div class="header">
  <h1>Title</h1>
</div>"""

# 不加 DOTALL,. 遇到换行就停,跨行匹配失败
print(re.search(r'<div.*?</div>', html))       # None

# 加 DOTALL,. 穿过换行,匹配成功
print(re.search(r'<div.*?</div>', html, re.DOTALL).group())  # 整段 div

# VERBOSE 模式:把复杂模式拆成多行加注释
phone_pattern = re.compile(r'''
    ^                # 字符串开头
    \+?              # 可选的国际区号前缀 +
    (\d{1,3})        # 区号
    [\s.-]?          # 可选分隔符
    (\d{3,4})        # 前三位/四位
    [\s.-]?          # 可选分隔符
    (\d{4})          # 后四位
    $                # 字符串结尾
''', re.VERBOSE)

print(phone_pattern.match("+86-138-1234").groups())  # ('86', '138', '1234')

re.VERBOSE 是写长模式时的救命工具——没有注释的正则,三天后自己都看不懂。

实战:用正则解析 nginx 访问日志

把上面所有概念串起来,做一个实际场景:从 nginx 默认格式的访问日志中提取结构化字段。

import re
import json

# nginx combined log 格式示例
log_lines = [
    '192.168.1.10 - - [15/Jun/2024:13:55:36 +0800] "GET /api/users?page=1 HTTP/1.1" 200 1234 "https://example.com" "Mozilla/5.0"',
    '10.0.0.5 - admin [15/Jun/2024:14:02:11 +0800] "POST /api/login HTTP/1.1" 401 0 "-" "curl/8.1.2"',
]

# 用 VERBOSE 写清晰模式
NGINX_LOG = re.compile(r'''
    (?P<ip>\S+)                          # 客户端 IP
    \s-\s
    (?P<user>\S+)                         # 认证用户(- 表示无)
    \s\[
    (?P<time>[^]]+)                       # 请求时间
    \]\s"
    (?P<method>GET|POST|PUT|DELETE|HEAD)  # HTTP 方法
    \s
    (?P<path>\S+)                         # 请求路径
    \s
    (?P<proto>HTTP/\d\.\d)                # 协议版本
    "\s
    (?P<status>\d{3})                     # 状态码
    \s
    (?P<size>\d+)                         # 响应字节数
    \s"
    (?P<referer>[^"]*)                    # Referer
    "\s"
    (?P<ua>[^"]*)                         # User-Agent
    "
''', re.VERBOSE)

for line in log_lines:
    m = NGINX_LOG.match(line)
    if m:
        record = m.groupdict()
        # 把状态码转为整数,方便后续统计
        record['status'] = int(record['status'])
        print(json.dumps(record, ensure_ascii=False))

# 输出:
# {"ip":"192.168.1.10","user":"-","time":"15/Jun/2024:13:55:36 +0800","method":"GET","path":"/api/users?page=1","proto":"HTTP/1.1","status":200,"size":"1234","referer":"https://example.com","ua":"Mozilla/5.0"}
# {"ip":"10.0.0.5","user":"admin","time":"15/Jun/2024:14:02:11 +0800","method":"POST","path":"/api/login","proto":"HTTP/1.1","status":401,"size":"0","referer":"-","ua":"curl/8.1.2"}

这段代码可以直接复制运行(只需标准库)。改动 log_lines 里的内容就能处理你自己的日志。如果日志格式有差异,调整 NGINX_LOG 模式中对应字段即可。

选用建议与常见坑

优先用 re.compile 同一个模式反复使用时,compile 比每次调用 re.search 更高效,也方便把模式作为常量集中管理。

警惕贪婪匹配。 默认 *+ 是贪婪的,会尽量多吃字符。加 ? 变成非贪婪(.*?\d+?),在提取 HTML 标签内容、截取日志片段时几乎总是该用非贪婪版本。

别用正则解析复杂 HTML。 正则无法正确处理嵌套标签和属性顺序变化,这类场景用 BeautifulSouplxml

原始字符串是必须的。 模式一律用 r'...' 写。没有 r 前缀时,\b 会被 Python 先解释成退格符,再交给正则引擎,结果完全不是你想要的单词边界。

测试流程建议:

  1. 先用 re.findallre.search 在小样本上验证模式命中情况。
  2. 检查误匹配:构造不应匹配的字符串,确认模式不会误抓。
  3. re.VERBOSE 给模式加注释,再放进生产代码。
  4. 复杂提取逻辑优先考虑命名分组 + .groupdict(),避免数字索引带来的维护负担。

正则不是万能的——超过一定复杂度后,可读性和正确性都会急剧下降。但在日志解析、数据清洗、输入校验这些场景里,几行精准的正则模式仍然是最高效的选择。掌握字符类、锚点、分组、交替和标志位,你就有了写出"精准模式"而非"凑出个模式"的基础。


相关推荐