3. 正则表达式基础:元字符、量词、分组与捕获、贪婪与非贪婪模式、Python re模块实战
正则表达式,说白了就是一套用来描述文本模式的规则。我刚开始接触时也觉得它像天书,一堆符号看得人眼花。但后来我发现,只要你掌握了几个核心概念,这东西其实特别顺手。今天我们就把它拆开揉碎了讲清楚。
3.1 元字符:正则的“字母表”
元字符是正则表达式的基本构成单元。每个元字符都代表一类字符,而不是具体的某个字符。我个人习惯把它们分成几组来记:
| 元字符 | 含义 | 示例 |
|---|---|---|
. |
匹配任意单个字符(除换行符) | a.b 匹配 aab、a1b |
\d |
匹配数字(0-9) | \d\d 匹配 23 |
\w |
匹配字母、数字、下划线 | \w+ 匹配 hello_123 |
\s |
匹配空白字符(空格、制表符、换行) | a\sb 匹配 a b |
^ |
匹配字符串开头 | ^hello 匹配以 hello 开头的字符串 |
$ |
匹配字符串结尾 | world$ 匹配以 world 结尾的字符串 |
[] |
字符集,匹配括号内任意一个字符 | [aeiou] 匹配任意元音字母 |
[^] |
否定字符集,匹配不在括号内的字符 | [^0-9] 匹配非数字字符 |
我的小技巧: 在写正则时,我习惯先用
. 占位,再逐步替换成更精确的元字符。这样调试起来快很多。
3.2 量词:控制重复次数
光有元字符还不够,你还需要告诉正则“这个字符要出现几次”。量词就是干这个的。我记得有一次在解析日志文件时,就是因为量词用错了,导致匹配结果全乱套了。
| 量词 | 含义 | 示例 |
|---|---|---|
* |
匹配前一个字符 0 次或多次 | ab*c 匹配 ac、abc、abbc |
+ |
匹配前一个字符 1 次或多次 | ab+c 匹配 abc、abbc,但不匹配 ac |
? |
匹配前一个字符 0 次或 1 次 | ab?c 匹配 ac、abc |
{n} |
匹配前一个字符恰好 n 次 | \d{3} 匹配 123 |
{n,} |
匹配前一个字符至少 n 次 | \d{2,} 匹配 12、123 |
{n,m} |
匹配前一个字符 n 到 m 次 | \d{2,4} 匹配 12、123、1234 |
注意: 量词默认是贪婪的,它会尽可能多地匹配。这一点我们后面会详细讲。
3.3 分组与捕获:提取你想要的
分组用圆括号 () 表示。它有两个作用:一是把多个字符当成一个整体来应用量词,二是捕获匹配到的内容供后续使用。
举个例子,假设你要从日志中提取 IP 地址和端口号:
import re
log_line = "192.168.1.1:8080 - - [10/Oct/2023]"
pattern = r"(\d+\.\d+\.\d+\.\d+):(\d+)"
match = re.search(pattern, log_line)
if match:
ip = match.group(1) # 192.168.1.1
port = match.group(2) # 8080
print(f"IP: {ip}, Port: {port}")
这里 group(0) 返回整个匹配,group(1) 返回第一个括号里的内容,以此类推。我在项目中经常用这个技巧来快速提取结构化数据。
避坑指南: 我曾经在写一个日志解析脚本时,忘了给分组命名,结果代码维护起来特别痛苦。后来我改用
(?P<name>...) 命名分组,代码可读性瞬间提升。
# 命名分组示例
pattern = r"(?P<ip>\d+\.\d+\.\d+\.\d+):(?P<port>\d+)"
match = re.search(pattern, log_line)
print(match.group('ip')) # 192.168.1.1
print(match.group('port')) # 8080
3.4 贪婪与非贪婪模式:匹配的“贪心”与“知足”
默认情况下,量词是贪婪的。它会尽可能多地匹配字符。但有时候,你希望它匹配得越少越好,这时候就要用非贪婪模式。
怎么切换?很简单,在量词后面加个 ? 就行。
| 模式 | 写法 | 匹配行为 |
|---|---|---|
| 贪婪 | .* |
尽可能多地匹配 |
| 非贪婪 | .*? |
尽可能少地匹配 |
举个例子,假设有字符串 "<div>hello</div><div>world</div>":
- 贪婪模式
<div>.*</div>会匹配整个字符串,因为它会一直匹配到最后一个</div> - 非贪婪模式
<div>.*?</div>只会匹配<div>hello</div>,因为它找到第一个</div>就停了
我的经验: 在解析 HTML 或 XML 时,我几乎总是用非贪婪模式。否则很容易匹配到不该匹配的内容,导致解析结果出错。
3.5 Python re 模块实战
好了,理论讲完了,咱们来点实战。Python 的 re 模块提供了几个核心函数,我平时用得最多的是这四个:
| 函数 | 作用 | 返回值 |
|---|---|---|
re.search() |
扫描整个字符串,返回第一个匹配 | Match 对象或 None |
re.match() |
从字符串开头匹配 | Match 对象或 None |
re.findall() |
返回所有匹配的列表 | 列表 |
re.sub() |
替换匹配到的内容 | 替换后的字符串 |
来看一个完整的日志解析例子。假设你有这样的日志:
2023-10-10 10:23:45 ERROR [main] Connection timeout to 192.168.1.1:8080
2023-10-10 10:23:46 INFO [worker] Retry attempt 1
2023-10-10 10:23:47 ERROR [main] Connection timeout to 192.168.1.2:9090
你想提取所有错误日志中的 IP 和端口:
import re
log_data = """
2023-10-10 10:23:45 ERROR [main] Connection timeout to 192.168.1.1:8080
2023-10-10 10:23:46 INFO [worker] Retry attempt 1
2023-10-10 10:23:47 ERROR [main] Connection timeout to 192.168.1.2:9090
"""
# 只匹配 ERROR 级别的日志
pattern = r"ERROR.*?(\d+\.\d+\.\d+\.\d+):(\d+)"
matches = re.findall(pattern, log_data)
for ip, port in matches:
print(f"Failed connection: {ip}:{port}")
输出结果:
Failed connection: 192.168.1.1:8080
Failed connection: 192.168.1.2:9090
避坑指南: 我曾经在写一个日志监控脚本时,直接用
re.match() 去匹配多行日志,结果死活匹配不上。后来才发现 re.match() 只从开头匹配,而我的日志每行开头都有时间戳。换成 re.search() 就搞定了。
另外,如果你要处理大量日志,建议先编译正则表达式:
# 编译正则,提高性能
pattern = re.compile(r"ERROR.*?(\d+\.\d+\.\d+\.\d+):(\d+)")
matches = pattern.findall(log_data)
嗯,这里要注意:编译后的正则对象可以重复使用,在循环中性能提升很明显。我个人习惯在脚本开头就把所有正则都编译好,后面直接调用。
好了,正则表达式的基础就讲到这里。下一章我们会把这些知识用到实际的日志分析框架中,到时候你会看到这些技巧有多实用。