正则表达式是文本处理的瑞士军刀——用几行模式就能完成原本需要几十行字符串操作才能做的事。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'] — 子串也命中
关键区别:\b 让 warn 只命中独立词,不会把 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。 正则无法正确处理嵌套标签和属性顺序变化,这类场景用 BeautifulSoup 或 lxml。
原始字符串是必须的。 模式一律用 r'...' 写。没有 r 前缀时,\b 会被 Python 先解释成退格符,再交给正则引擎,结果完全不是你想要的单词边界。
测试流程建议:
- 先用
re.findall或re.search在小样本上验证模式命中情况。 - 检查误匹配:构造不应匹配的字符串,确认模式不会误抓。
- 用
re.VERBOSE给模式加注释,再放进生产代码。 - 复杂提取逻辑优先考虑命名分组 +
.groupdict(),避免数字索引带来的维护负担。
正则不是万能的——超过一定复杂度后,可读性和正确性都会急剧下降。但在日志解析、数据清洗、输入校验这些场景里,几行精准的正则模式仍然是最高效的选择。掌握字符类、锚点、分组、交替和标志位,你就有了写出"精准模式"而非"凑出个模式"的基础。