前面我们学习了如何使用正则表达式查找符合条件的文本。

在实际的 Linux 运维工作中,我们经常需要从日志、服务器信息或命令输出中提取有用的数据。例如,从一条日志中提取日期和错误级别,或者从服务器信息中分别获取主机名称和 IP 地址。

假设有这样一段文本:

1
text = "12-34"

现在,我们希望分别获取 12 和 34,而不是只得到完整的 12-34。这时就可以使用正则表达式中的捕获组(Capturing Group)。

什么是捕获组

捕获组可以把正则表达式中指定部分的匹配结果单独保存起来,方便我们在匹配成功后分别获取这些内容。

在正则表达式中,我们使用一对圆括号 () 创建捕获组。

例如:

1
2
3
4
5
6
7
8
9
import re

text = "12-34"

m = re.search(r'(\d+)-(\d+)', text)

print(m.group(0))
print(m.group(1))
print(m.group(2))

运行结果:

1
2
3
12-34
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
2
3
4
5
6
7
8
9
10
import re

text = "12-34"

m = re.search(r'(\d+)-(\d+)', text)

if m:
print("完整匹配结果:", m.group(0))
print("第一个捕获组:", m.group(1))
print("第二个捕获组:", m.group(2))

运行结果:

1
2
3
完整匹配结果: 12-34
第一个捕获组: 12
第二个捕获组: 34

这里的 m 是 re.search() 返回的匹配对象。匹配对象保存了完整匹配结果,也保存了各个捕获组的内容。

调用方式获取的内容
m.group(0)完整匹配结果 12-34
m.group(1)第一个捕获组 12
m.group(2)第二个捕获组 34

捕获组的编号按照左括号 ( 在正则表达式中出现的先后顺序确定。

例如,正则表达式 r'(\d+)-(\d+)' 中,第一个左括号创建第一个捕获组,第二个左括号创建第二个捕获组。

如果我们希望一次性获取所有捕获组,可以使用 groups() 方法:

1
2
3
4
5
6
7
8
import re

text = "12-34"

m = re.search(r'(\d+)-(\d+)', text)

if m:
print(m.groups())

运行结果:

1
('12', '34')

groups() 会返回一个元组,其中包含所有捕获组的匹配结果,但不包含完整匹配结果本身。

如果暂时还不熟悉元组,可以先把它理解为一种能够保存多个数据的数据结构。

使用命名捕获组

前面我们使用 group(1)、group(2) 获取不同的捕获组。

这种方式比较简单,但是当正则表达式越来越复杂时,捕获组的数量可能会增加。如果代码中出现 group(3)、group(4) 等调用,就需要反复查看正则表达式,才能知道每个编号对应什么内容。

为了解决这个问题,Python 支持命名捕获组。我们可以给捕获组指定一个有意义的名称,之后直接通过名称获取匹配结果。

命名捕获组的基本语法如下:

1
(?P<名称>正则表达式)

例如:

1
2
3
4
5
6
7
8
9
import re

text = "12-34"

m = re.search(r'(?P<first>\d+)-(?P<second>\d+)', text)

if m:
print(m.group("first"))
print(m.group("second"))

运行结果:

1
2
12
34

其中:

  • (?P<first>\d+):创建名为 first 的捕获组,用于匹配 12。
  • (?P<second>\d+):创建名为 second 的捕获组,用于匹配 34。

命名捕获组仍然属于普通捕获组,因此也可以通过编号访问:

1
2
3
4
5
6
7
8
9
10
11
12
import re

text = "12-34"

m = re.search(r'(?P<first>\d+)-(?P<second>\d+)', text)

if m:
print(m.group(0))
print(m.group(1))
print(m.group(2))
print(m.group("first"))
print(m.group("second"))

运行结果:

1
2
3
4
5
12-34
12
34
12
34

除了 group(),我们还可以使用 groupdict() 获取所有命名捕获组组成的字典:

1
2
3
4
5
6
7
8
import re

text = "12-34"

m = re.search(r'(?P<first>\d+)-(?P<second>\d+)', text)

if m:
print(m.groupdict())

运行结果:

1
{'first': '12', 'second': '34'}

groupdict() 返回一个字典,字典的键是捕获组名称,值是对应的匹配结果。它只包含命名捕获组,不会把没有命名的捕获组放进去。

使用 findall() 查找多次匹配结果

前面我们使用 re.search() 查找一段文本中的匹配内容。

但是,re.search() 只返回第一个符合条件的匹配结果。如果一段日志中包含多个 IP 地址、多个错误编号,或者多个需要提取的字段,只使用 search() 就无法一次性获得所有匹配结果。

例如,有下面这段服务器检查记录:

1
2
3
4
5
6
7
8
import re

text = "web01 CPU=75% web02 CPU=92% web03 CPU=48%"

m = re.search(r'CPU=(\d+)%', text)

if m:
print(m.group(1))

运行结果:

1
75

为什么只有 75?

因为 re.search() 从左向右查找,找到第一个符合条件的内容后就会返回,不会自动继续查找后面的匹配结果。

如果希望一次性找到所有符合条件的内容,可以使用 re.findall()。

它的基本语法如下:

1
re.findall(正则表达式, 字符串)

例如:

1
2
3
4
5
6
7
import re

text = "web01 CPU=75% web02 CPU=92% web03 CPU=48%"

results = re.findall(r'CPU=(\d+)%', text)

print(results)

运行结果:

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
2
3
4
5
6
7
8
9
10
11
import re

text = "web01 CPU=75% web02 CPU=92% web03 CPU=48%"

results = re.findall(r'(\w+) CPU=(\d+)%', text)

for hostname, cpu in results:
cpu = int(cpu)

if cpu > 80:
print(f"告警:{hostname} CPU 使用率为 {cpu}%")

运行结果:

1
告警:web02 CPU 使用率为 92%

这里的正则表达式有两个捕获组:

  • (\w+):匹配服务器名称。
  • (\d+):匹配 CPU 使用率的数字部分。

因为正则表达式包含两个捕获组,所以 findall() 返回的每个匹配结果都是一个元组。每个元组中,第一个元素是服务器名称,第二个元素是 CPU 使用率。

因此,循环中的 for hostname, cpu in results 可以分别取出这两个值。

findall() 与捕获组的关系

理解 findall() 时,有一个非常重要的规则:返回结果的形式取决于正则表达式中捕获组的数量。

我们通过三个例子来理解。

没有捕获组

1
2
3
4
5
6
7
import re

text = "web01 CPU=75% web02 CPU=92%"

results = re.findall(r'CPU=\d+%', text)

print(results)

运行结果:

1
['CPU=75%', 'CPU=92%']

正则表达式没有使用圆括号创建捕获组,因此 findall() 返回每次匹配到的完整文本。

只有一个捕获组

1
2
3
4
5
6
7
import re

text = "web01 CPU=75% web02 CPU=92%"

results = re.findall(r'CPU=(\d+)%', text)

print(results)

运行结果:

1
['75', '92']

因为正则表达式只有一个捕获组,所以 findall() 返回每次匹配中该捕获组的内容。

有多个捕获组

1
2
3
4
5
6
7
import re

text = "web01 CPU=75% web02 CPU=92%"

results = re.findall(r'(\w+) CPU=(\d+)%', text)

print(results)

运行结果:

1
[('web01', '75'), ('web02', '92')]

因为正则表达式有两个捕获组,所以每次匹配返回一个元组,元组中保存两个捕获组的内容。

可以总结为:

正则表达式中的捕获组数量findall() 返回的每个匹配结果
没有捕获组完整匹配文本
一个捕获组该捕获组的内容
多个捕获组包含各个捕获组内容的元组

这也是初学者使用 findall() 时容易遇到的情况。如果发现返回结果和预期不同,可以先检查正则表达式中有没有圆括号,以及圆括号的数量。

使用 finditer() 获取每一次匹配对象

findall() 适合一次性获取所有匹配结果。不过,它返回的是字符串或元组列表,并不会直接提供每一次匹配对应的匹配对象。

如果我们既想遍历所有匹配结果,又想使用 group()、start()、end() 等方法获取匹配内容和位置,可以使用 re.finditer()。

例如:

1
2
3
4
5
6
7
8
9
import re

text = "web01 CPU=75% web02 CPU=92%"

for m in re.finditer(r'CPU=(\d+)%', text):
print("完整匹配:", m.group(0))
print("CPU 使用率:", m.group(1))
print("起始位置:", m.start())
print("结束位置:", m.end())

运行结果:

1
2
3
4
5
6
7
8
完整匹配: CPU=75%
CPU 使用率: 75
起始位置: 6
结束位置: 13
完整匹配: CPU=92%
CPU 使用率: 92
起始位置: 20
结束位置: 27

这里的 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
2
3
4
5
6
7
8
9
import re

server = "web01-192.168.8.128"

m = re.search(r'你的正则表达式', server)

if m:
print("主机名称:", m.group(1))
print("IP 地址:", m.group(2))

参考答案:使用编号捕获组

1
2
3
4
5
6
7
8
9
10
11
12
13
import re

server = "web01-192.168.8.128"

pattern = r'(\w+)-(\d+\.\d+\.\d+\.\d+)'

m = re.search(pattern, server)

if m:
print("主机名称:", m.group(1))
print("IP 地址:", m.group(2))
else:
print("服务器信息格式不正确")

运行结果:

1
2
主机名称: web01
IP 地址: 192.168.8.128

第一个捕获组 (\w+) 用于匹配主机名称。中间的 - 用于匹配分隔符。第二个捕获组 (\d+\.\d+\.\d+\.\d+) 用于匹配由四段数字组成的地址。

这条正则表达式适合演示捕获组的基础用法,但它并不能完整验证 IPv4 地址。例如,它不能保证每段数字都在 0 到 255 之间。

参考答案:使用命名捕获组

1
2
3
4
5
6
7
8
9
10
11
12
13
import re

server = "web01-192.168.8.128"

pattern = r'(?P<hostname>\w+)-(?P<ip>\d+\.\d+\.\d+\.\d+)'

m = re.search(pattern, server)

if m:
print("主机名称:", m.group("hostname"))
print("IP 地址:", m.group("ip"))
else:
print("服务器信息格式不正确")

运行结果:

1
2
主机名称: web01
IP 地址: 192.168.8.128

命名捕获组让我们可以直接使用 hostname 和 ip 获取相应内容,更容易理解代码的含义。

进阶练习:一次提取多台服务器的信息

如果文本中包含多台服务器的信息,我们就可以结合 findall(),一次性提取所有主机名称和 IP 地址。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import re

text = """
web01-192.168.8.128
web02-192.168.8.129
web03-192.168.8.130
"""

pattern = r'(\w+)-(\d+\.\d+\.\d+\.\d+)'

results = re.findall(pattern, text)

for hostname, ip in results:
print(f"主机名称:{hostname},IP 地址:{ip}")

运行结果:

1
2
3
主机名称:web01,IP 地址:192.168.8.128
主机名称:web02,IP 地址:192.168.8.129
主机名称:web03,IP 地址:192.168.8.130

这次,正则表达式有两个捕获组,分别用于提取主机名称和 IP 地址。findall() 会查找文本中的所有符合条件的记录,并返回包含多个元组的列表。

我们再结合 for 循环,逐条取出服务器名称和 IP 地址,就可以完成批量提取。

如果希望使用命名捕获组,可以使用 finditer():

1
2
3
4
5
6
7
8
9
10
11
12
import re

text = """
web01-192.168.8.128
web02-192.168.8.129
web03-192.168.8.130
"""

pattern = r'(?P<hostname>\w+)-(?P<ip>\d+\.\d+\.\d+\.\d+)'

for m in re.finditer(pattern, text):
print(f"主机名称:{m.group('hostname')},IP 地址:{m.group('ip')}")

运行结果:

1
2
3
主机名称:web01,IP 地址:192.168.8.128
主机名称:web02,IP 地址:192.168.8.129
主机名称:web03,IP 地址:192.168.8.130

通过这个例子,我们可以把捕获组、命名捕获组、多次匹配和循环结合起来,完成简单的服务器清单提取任务。

需要注意,以上示例假设每条服务器记录都符合给定格式。在生产环境中,还需要考虑空行、格式异常、重复记录以及 IP 地址合法性等问题。

写在最后

本文学习了正则表达式中的捕获组,以及如何使用它们提取一段文本中的指定内容。

  • 使用圆括号 () 创建捕获组。
  • 使用 group(0) 获取完整匹配结果,使用 group(1)、group(2) 等获取指定捕获组。
  • 使用 (?P<名称>...) 创建命名捕获组,并通过 group("名称") 或 groupdict() 获取结果。
  • 使用 re.findall() 一次性获取所有非重叠匹配结果。
  • 理解 findall() 在没有捕获组、一个捕获组和多个捕获组时返回结果的区别。
  • 使用 re.finditer() 逐个处理匹配对象,并获取匹配内容和位置。

捕获组让正则表达式能够提取匹配文本中的指定部分,而 findall() 和 finditer() 则进一步帮助我们处理一段文本中的多条记录。这些知识可以应用于 Linux 日志分析、服务器清单提取和简单的运维自动化任务。

下一篇将继续学习 Python re 模块提供的常用函数,了解不同函数的作用,以及如何根据实际需求选择合适的匹配方法。