Python 系列(五十九) 学会正则捕获组,轻松提取文本数据
前面我们学习了如何使用正则表达式查找符合条件的文本。
在实际的 Linux 运维工作中,我们经常需要从日志、服务器信息或命令输出中提取有用的数据。例如,从一条日志中提取日期和错误级别,或者从服务器信息中分别获取主机名称和 IP 地址。
假设有这样一段文本:
1 | text = "12-34" |
现在,我们希望分别获取 12 和 34,而不是只得到完整的 12-34。这时就可以使用正则表达式中的捕获组(Capturing Group)。
什么是捕获组
捕获组可以把正则表达式中指定部分的匹配结果单独保存起来,方便我们在匹配成功后分别获取这些内容。
在正则表达式中,我们使用一对圆括号 () 创建捕获组。
例如:
1 | import re |
运行结果:
1 | 12-34 |
这段代码中,r'(\d+)-(\d+)' 是正则表达式,可以拆分成三个部分:
(\d+):匹配一个或多个数字,并将匹配结果保存到第一个捕获组中。-:匹配一个连字符。(\d+):匹配一个或多个数字,并将匹配结果保存到第二个捕获组中。
当正则表达式匹配字符串 12-34 时,第一个捕获组得到 12,第二个捕获组得到 34。
整个过程可以通过下面的流程图理解:
flowchart TD
A["原始文本:12-34"] --> B["执行正则表达式匹配"]
B --> C["第一个捕获组:12"]
B --> D["匹配连字符:-"]
B --> E["第二个捕获组:34"]
C --> F["保存匹配结果"]
D --> F
E --> F需要注意的是,捕获组不会修改原始字符串,也不会把原始文本真正拆开。它只是让我们能够在匹配成功后,单独获取指定部分的匹配结果。
使用 group() 获取捕获组内容
前面的代码使用了 m.group() 方法。这个方法可以帮助我们获取正则表达式匹配到的内容。
其中有一个需要记住的规则:group(0) 表示完整匹配结果,而捕获组从 group(1) 开始编号。
例如:
1 | import re |
运行结果:
1 | 完整匹配结果: 12-34 |
这里的 m 是 re.search() 返回的匹配对象。匹配对象保存了完整匹配结果,也保存了各个捕获组的内容。
| 调用方式 | 获取的内容 |
|---|---|
m.group(0) | 完整匹配结果 12-34 |
m.group(1) | 第一个捕获组 12 |
m.group(2) | 第二个捕获组 34 |
捕获组的编号按照左括号 ( 在正则表达式中出现的先后顺序确定。
例如,正则表达式 r'(\d+)-(\d+)' 中,第一个左括号创建第一个捕获组,第二个左括号创建第二个捕获组。
如果我们希望一次性获取所有捕获组,可以使用 groups() 方法:
1 | import re |
运行结果:
1 | ('12', '34') |
groups() 会返回一个元组,其中包含所有捕获组的匹配结果,但不包含完整匹配结果本身。
如果暂时还不熟悉元组,可以先把它理解为一种能够保存多个数据的数据结构。
使用命名捕获组
前面我们使用 group(1)、group(2) 获取不同的捕获组。
这种方式比较简单,但是当正则表达式越来越复杂时,捕获组的数量可能会增加。如果代码中出现 group(3)、group(4) 等调用,就需要反复查看正则表达式,才能知道每个编号对应什么内容。
为了解决这个问题,Python 支持命名捕获组。我们可以给捕获组指定一个有意义的名称,之后直接通过名称获取匹配结果。
命名捕获组的基本语法如下:
1 | (?P<名称>正则表达式) |
例如:
1 | import re |
运行结果:
1 | 12 |
其中:
(?P<first>\d+):创建名为first的捕获组,用于匹配12。(?P<second>\d+):创建名为second的捕获组,用于匹配34。
命名捕获组仍然属于普通捕获组,因此也可以通过编号访问:
1 | import re |
运行结果:
1 | 12-34 |
除了 group(),我们还可以使用 groupdict() 获取所有命名捕获组组成的字典:
1 | import re |
运行结果:
1 | {'first': '12', 'second': '34'} |
groupdict() 返回一个字典,字典的键是捕获组名称,值是对应的匹配结果。它只包含命名捕获组,不会把没有命名的捕获组放进去。
使用 findall() 查找多次匹配结果
前面我们使用 re.search() 查找一段文本中的匹配内容。
但是,re.search() 只返回第一个符合条件的匹配结果。如果一段日志中包含多个 IP 地址、多个错误编号,或者多个需要提取的字段,只使用 search() 就无法一次性获得所有匹配结果。
例如,有下面这段服务器检查记录:
1 | import re |
运行结果:
1 | 75 |
为什么只有 75?
因为 re.search() 从左向右查找,找到第一个符合条件的内容后就会返回,不会自动继续查找后面的匹配结果。
如果希望一次性找到所有符合条件的内容,可以使用 re.findall()。
它的基本语法如下:
1 | re.findall(正则表达式, 字符串) |
例如:
1 | import re |
运行结果:
1 | ['75', '92', '48'] |
这次,程序找到了三个 CPU 使用率。
这里的正则表达式 r'CPU=(\d+)%' 使用了一个捕获组 (\d+),因此 findall() 返回的是每次匹配中捕获组的内容,而不是完整匹配文本。
我们可以把整个过程理解为:
flowchart TD
A["包含多条 CPU 数据的文本"] --> B["re.findall()"]
B --> C["第一次匹配:75"]
B --> D["第二次匹配:92"]
B --> E["第三次匹配:48"]
C --> F["返回列表"]
D --> F
E --> F
F --> G["['75', '92', '48']"]需要注意,findall() 返回的列表中,这些数字默认是字符串,而不是整数。如果后续需要进行数值比较,可以使用 int() 进行转换。
例如,我们想找出 CPU 使用率超过 80% 的服务器,可以先提取所有服务器名称和 CPU 使用率,再进行判断。
1 | import re |
运行结果:
1 | 告警:web02 CPU 使用率为 92% |
这里的正则表达式有两个捕获组:
(\w+):匹配服务器名称。(\d+):匹配 CPU 使用率的数字部分。
因为正则表达式包含两个捕获组,所以 findall() 返回的每个匹配结果都是一个元组。每个元组中,第一个元素是服务器名称,第二个元素是 CPU 使用率。
因此,循环中的 for hostname, cpu in results 可以分别取出这两个值。
findall() 与捕获组的关系
理解 findall() 时,有一个非常重要的规则:返回结果的形式取决于正则表达式中捕获组的数量。
我们通过三个例子来理解。
没有捕获组
1 | import re |
运行结果:
1 | ['CPU=75%', 'CPU=92%'] |
正则表达式没有使用圆括号创建捕获组,因此 findall() 返回每次匹配到的完整文本。
只有一个捕获组
1 | import re |
运行结果:
1 | ['75', '92'] |
因为正则表达式只有一个捕获组,所以 findall() 返回每次匹配中该捕获组的内容。
有多个捕获组
1 | import re |
运行结果:
1 | [('web01', '75'), ('web02', '92')] |
因为正则表达式有两个捕获组,所以每次匹配返回一个元组,元组中保存两个捕获组的内容。
可以总结为:
| 正则表达式中的捕获组数量 | findall() 返回的每个匹配结果 |
|---|---|
| 没有捕获组 | 完整匹配文本 |
| 一个捕获组 | 该捕获组的内容 |
| 多个捕获组 | 包含各个捕获组内容的元组 |
这也是初学者使用 findall() 时容易遇到的情况。如果发现返回结果和预期不同,可以先检查正则表达式中有没有圆括号,以及圆括号的数量。
使用 finditer() 获取每一次匹配对象
findall() 适合一次性获取所有匹配结果。不过,它返回的是字符串或元组列表,并不会直接提供每一次匹配对应的匹配对象。
如果我们既想遍历所有匹配结果,又想使用 group()、start()、end() 等方法获取匹配内容和位置,可以使用 re.finditer()。
例如:
1 | import re |
运行结果:
1 | 完整匹配: CPU=75% |
这里的 finditer() 会从左向右查找所有符合条件的匹配结果,并依次产生匹配对象。
group(0):获取完整匹配结果。group(1):获取第一个捕获组。start():获取完整匹配结果在原始字符串中的起始索引。end():获取完整匹配结果结束位置的索引。这个位置不包含在匹配范围内。
例如,第一个匹配结果 CPU=75% 从索引 6 开始,到索引 13 结束,因此对应的字符位置范围是 6 到 12。
与 findall() 不同,finditer() 返回的是一个迭代器,其中依次提供匹配对象。这样可以逐个处理结果,而不必先把所有结果整理成一个列表。
简单来说:
- 只需要所有匹配内容时,可以使用
findall()。 - 需要匹配对象、捕获组信息或匹配位置时,可以使用
finditer()。
search()、findall() 和 finditer() 有什么区别
我们已经学习了三个常用的匹配方法,它们适用于不同的场景。
| 方法 | 匹配数量 | 返回结果 | 适用场景 |
|---|---|---|---|
re.search() | 第一个匹配结果 | 匹配对象或 None | 查找第一个符合条件的内容 |
re.findall() | 所有非重叠匹配结果 | 列表 | 一次性提取所有匹配内容 |
re.finditer() | 所有非重叠匹配结果 | 匹配对象迭代器 | 逐个处理匹配结果,获取内容和位置 |
例如,在分析服务器日志时,如果只想知道第一条 CPU 使用率记录,可以使用 search();如果希望直接获得所有 CPU 使用率,可以使用 findall();如果还需要知道每条记录在原始文本中的位置,则可以使用 finditer()。
还需要注意,这三个方法默认都按照从左向右的顺序查找非重叠匹配结果。它们并不等同于查找所有可能的重叠组合。
动手练习:提取服务器名称和 IP 地址
假设我们需要从服务器信息中提取主机名称和 IP 地址。
原始文本如下:
1 | server = "web01-192.168.8.128" |
现在,请尝试编写正则表达式,完成以下任务:
- 第一个捕获组匹配主机名称
web01。 - 中间使用连字符
-分隔。 - 第二个捕获组匹配 IP 地址
192.168.8.128。 - 分别打印主机名称和 IP 地址。
可以先使用下面的代码框架:
1 | import re |
参考答案:使用编号捕获组
1 | import re |
运行结果:
1 | 主机名称: web01 |
第一个捕获组 (\w+) 用于匹配主机名称。中间的 - 用于匹配分隔符。第二个捕获组 (\d+\.\d+\.\d+\.\d+) 用于匹配由四段数字组成的地址。
这条正则表达式适合演示捕获组的基础用法,但它并不能完整验证 IPv4 地址。例如,它不能保证每段数字都在 0 到 255 之间。
参考答案:使用命名捕获组
1 | import re |
运行结果:
1 | 主机名称: web01 |
命名捕获组让我们可以直接使用 hostname 和 ip 获取相应内容,更容易理解代码的含义。
进阶练习:一次提取多台服务器的信息
如果文本中包含多台服务器的信息,我们就可以结合 findall(),一次性提取所有主机名称和 IP 地址。
例如:
1 | import re |
运行结果:
1 | 主机名称:web01,IP 地址:192.168.8.128 |
这次,正则表达式有两个捕获组,分别用于提取主机名称和 IP 地址。findall() 会查找文本中的所有符合条件的记录,并返回包含多个元组的列表。
我们再结合 for 循环,逐条取出服务器名称和 IP 地址,就可以完成批量提取。
如果希望使用命名捕获组,可以使用 finditer():
1 | import re |
运行结果:
1 | 主机名称:web01,IP 地址:192.168.8.128 |
通过这个例子,我们可以把捕获组、命名捕获组、多次匹配和循环结合起来,完成简单的服务器清单提取任务。
需要注意,以上示例假设每条服务器记录都符合给定格式。在生产环境中,还需要考虑空行、格式异常、重复记录以及 IP 地址合法性等问题。
写在最后
本文学习了正则表达式中的捕获组,以及如何使用它们提取一段文本中的指定内容。
- 使用圆括号
()创建捕获组。 - 使用
group(0)获取完整匹配结果,使用group(1)、group(2)等获取指定捕获组。 - 使用
(?P<名称>...)创建命名捕获组,并通过group("名称")或groupdict()获取结果。 - 使用
re.findall()一次性获取所有非重叠匹配结果。 - 理解
findall()在没有捕获组、一个捕获组和多个捕获组时返回结果的区别。 - 使用
re.finditer()逐个处理匹配对象,并获取匹配内容和位置。
捕获组让正则表达式能够提取匹配文本中的指定部分,而 findall() 和 finditer() 则进一步帮助我们处理一段文本中的多条记录。这些知识可以应用于 Linux 日志分析、服务器清单提取和简单的运维自动化任务。
下一篇将继续学习 Python re 模块提供的常用函数,了解不同函数的作用,以及如何根据实际需求选择合适的匹配方法。
