3、正则表达式入门:元字符、量词、分组与捕获、Python re模块实战、性能优化技巧
正则表达式,说白了就是一门「用符号描述文本模式」的语言。我刚开始接触时也觉得它像天书,一堆乱七八糟的符号。但后来在日志分析中天天跟它打交道,才发现——这玩意儿是真香。
你想想看,运维日志里动辄几万行,你要从中找出所有IP地址、提取错误码、过滤时间戳。手动翻?不现实。用字符串函数硬写?代码又臭又长。正则表达式就是干这个的。
3.1 元字符:正则的「字母表」
元字符是正则表达式最基本的构建块。它们不匹配字面字符,而是匹配某一类字符。
| 元字符 | 含义 | 示例 |
|---|---|---|
. |
匹配任意单个字符(除换行符) | a.c 匹配 "abc"、"a1c" |
\d |
匹配数字,等价于 [0-9] | \d\d\d 匹配 "123" |
\w |
匹配字母、数字、下划线 | \w+ 匹配 "hello_123" |
\s |
匹配空白字符(空格、制表符、换行) | name\s+age 匹配 "name age" |
^ |
匹配字符串开头 | ^ERROR 匹配以 "ERROR" 开头的行 |
$ |
匹配字符串结尾 | timeout$ 匹配以 "timeout" 结尾的行 |
[] |
字符集,匹配括号内任意一个字符 | [aeiou] 匹配任意元音字母 |
[^] |
否定字符集,匹配不在括号内的字符 | [^0-9] 匹配非数字字符 |
^ 和 $ 用得特别多。比如你要过滤所有以 "FATAL" 开头的行,直接 ^FATAL 就搞定了。我曾经在排查线上故障时,靠这个从 50 万行日志里 3 秒定位到关键错误。
3.2 量词:控制重复次数
光有元字符还不够,你还需要告诉正则「这个字符要出现几次」。量词就是干这个的。
| 量词 | 含义 | 示例 |
|---|---|---|
* |
匹配前一个字符 0 次或多次 | ab*c 匹配 "ac"、"abc"、"abbc" |
+ |
匹配前一个字符 1 次或多次 | ab+c 匹配 "abc"、"abbc",不匹配 "ac" |
? |
匹配前一个字符 0 次或 1 次 | colou?r 匹配 "color" 和 "colour" |
{n} |
精确匹配 n 次 | \d{4} 匹配 "2024" |
{n,} |
至少匹配 n 次 | \d{2,} 匹配 "12"、"123"、"1234" |
{n,m} |
匹配 n 到 m 次 | \d{2,4} 匹配 "12"、"123"、"1234" |
\d* 去匹配数字,结果匹配到了空字符串。因为 * 允许 0 次匹配。后来我改成 \d+,问题就解决了。记住:能用 + 就别用 *,除非你真的需要匹配 0 次。
3.3 分组与捕获:提取关键信息
正则不只是用来「匹配」的,更重要的是「提取」。分组就是用来干这个的。
用圆括号 () 把一部分模式包起来,就形成了一个分组。分组有两个作用:一是改变优先级,二是捕获匹配的内容。
import re
# 提取日志中的 IP 地址和端口
log_line = "192.168.1.1:8080 - - [10/Oct/2024]"
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}")
我个人习惯给重要的分组起个名字,用 (?P<name>...) 语法。这样代码可读性会好很多。
pattern = r'(?P<ip>\d+\.\d+\.\d+\.\d+):(?P<port>\d+)'
match = re.search(pattern, log_line)
if match:
print(match.group('ip')) # 更清晰
print(match.group('port'))
| 或运算),可以用 (?:...)。这在性能上有好处,因为 Python 不需要保存捕获内容。我在处理海量日志时,都会用非捕获分组来提升速度。
3.4 Python re 模块实战
Python 的 re 模块是正则的主力军。常用的就四个函数:
| 函数 | 作用 | 返回类型 |
|---|---|---|
re.search() |
扫描整个字符串,找到第一个匹配 | Match 对象或 None |
re.match() |
从字符串开头匹配 | Match 对象或 None |
re.findall() |
找到所有匹配,返回列表 | 列表 |
re.sub() |
替换匹配的内容 | 字符串 |
来看一个完整的日志分析实战:
import re
# 模拟 Nginx 访问日志
log_data = """
192.168.1.1 - - [10/Oct/2024:13:55:36 +0800] "GET /api/users HTTP/1.1" 200 1234
10.0.0.2 - - [10/Oct/2024:13:56:01 +0800] "POST /api/login HTTP/1.1" 401 56
192.168.1.1 - - [10/Oct/2024:13:57:22 +0800] "GET /api/orders HTTP/1.1" 500 789
"""
# 提取所有状态码为 5xx 的错误请求
pattern = r'(\d+\.\d+\.\d+\.\d+).*?"(\w+) (\S+).*?" (\d{3})'
errors = []
for line in log_data.strip().split('\n'):
match = re.search(pattern, line)
if match and match.group(4).startswith('5'):
errors.append({
'ip': match.group(1),
'method': match.group(2),
'path': match.group(3),
'status': match.group(4)
})
print(f"发现 {len(errors)} 个 5xx 错误")
for err in errors:
print(f"{err['ip']} - {err['method']} {err['path']} -> {err['status']}")
re.match() 只从字符串开头匹配,而 re.search() 扫描整个字符串。我见过不少新手用 re.match() 去匹配不在开头的模式,结果死活匹配不上。如果你不确定位置,就用 re.search()。
3.5 性能优化技巧
正则表达式虽然强大,但写不好会非常慢。我在处理每天几 GB 的日志时,踩过不少坑。下面是我总结的几个优化点:
3.5.1 预编译正则
如果你要多次使用同一个正则,一定要预编译。这能省掉每次解析模式的开销。
import re
# 不好的写法:每次循环都编译
for line in log_lines:
match = re.search(r'\d+\.\d+\.\d+\.\d+', line)
# ...
# 好的写法:只编译一次
ip_pattern = re.compile(r'\d+\.\d+\.\d+\.\d+')
for line in log_lines:
match = ip_pattern.search(line)
# ...
3.5.2 避免回溯灾难
为什么会慢?很多时候是回溯导致的。比如 .* 这种贪婪匹配,在复杂模式下会疯狂回溯。
.* 去匹配 HTML 标签,结果一个 100KB 的页面跑了 30 秒。后来改成 [^<>]*,瞬间完成。记住:能用字符集就别用 .*,能用非贪婪 .*? 就别用贪婪 .*。
3.5.3 使用原始字符串
Python 中写正则一定要用 r'' 原始字符串。不然你要写一堆反斜杠转义,代码又丑又容易错。
# 错误:需要转义反斜杠
pattern = "\\d+\\.\\d+\\.\\d+\\.\\d+"
# 正确:原始字符串
pattern = r"\d+\.\d+\.\d+\.\d+"
3.5.4 锚定优化
如果你知道模式一定在行首或行尾,加上 ^ 或 $。这能让正则引擎快速失败,不用扫描整个字符串。
# 慢:扫描整行
pattern = r'ERROR.*timeout'
# 快:锚定行首
pattern = r'^ERROR.*timeout'
^;如果是在特定位置,用 re.search() 而不是 re.findall()。这些小细节在百万行日志上能差出几十倍的速度。
正则表达式这东西,说白了就是「用空间换时间」。你花 10 分钟写一个正则,可能省下 10 个小时的手工查找。但也要注意,正则不是万能的。如果日志格式特别复杂,或者你需要做上下文关联分析,那还是老老实实写解析器吧。
嗯,这一章的内容就到这里。记住:多练、多踩坑、多总结。正则的功力都是在实战中磨出来的。