一、快速入门
1.1 什么是正则表达式?
简称 RE Regular Expression,是一种描述文本内容组成规律的表示方式
1.2 正则的作用及应用场景?
- 数据提取:简化查找、编辑、提取文件或数据等操作
- 数据校验:校验数据是否符合规则
- 命令执行:grep、egrep、sed、awk、vim
- 开发工具:Atom,Sublime Text,Idea
1.3 元字符
1.3.1 什么是元字符?
元字符,指在正则表达式中具有特殊意义的专用字符,元字符是构成正则表达式的基本元件之一
1.3.2 分字符的分类
(1).特殊单字符
.\d\D\w\W\s\S
(2).空白符
\r\n\f\t\v\s
(3).量词
\d\d+
*+?{m}{m,}{m,n}{...}?{...}+
(4).范围
[...][^...][a-z][A-Z][0-9]
(5).断言
有关更多断言内容,戳[这里](#1.6 断言)
\b引号'空格标点换行^行首$行尾(?<=元字符)左边字符元字符(?左边字符元字符(?=元字符)右边字符元字符(?!元字符)右边字符元字符
1.4 正则匹配模式
前面我们看过量词的元字符,现在再聊一个与量词相关的重要知识点,匹配模式,之所以说它很重要,这是因为 “不同的匹配模式,可能会影响到正则匹配的结果”
正则表达式中有七种模式,前六种都可以对匹配结果产生影响:
{...}?{...}+?i?i?s.(?s)?m(?m)?#(?#comment)
1.4.1 贪婪匹配(Greedy)
当我们使用量词时,默认就是基于规则进行 贪婪匹配
\d+match
由此,我们可以简单的将贪婪匹配理解为,当我们使用量词时,只要附和量词要求,那么正则会尽可能进行最长的匹配,能匹配多少匹配多少
1.4.2 惰性匹配(Lazy)
?
\d+?match
由此,非贪婪匹配可以理解为,尽可能进行最短的匹配,匹配一个算一次
1.4.3 独占模式(Possessive)
贪婪|惰性
1.4.2.1 什么是回溯?
前面提到的 贪婪匹配 & 惰性匹配,它们背后的匹配原理都离不开一种机制,回溯
1.4.2.2 贪婪匹配中的回溯
那 回溯 到底是什么呢?先看一个例子:
x1~3yz
xxxy{1,3}yxy
{1,3}
y{1,3}yxyy
{1,3}
y{1,3}zz != y
z
zzxyyz
1.4.2.3 惰性匹配中的回溯
同时,为了对比加深理解,我们梳理下 惰性匹配 的步骤:
xxxy{1,3}yxy
{1,3}
zyz != y
y{1,3}yxyy
zzxyyz
OK 😃,到此,通过上面两个例子,我们可以基本得出一个理解:
- 当匹配模式为 贪婪匹配,由于是尽可能的多的匹配,所以很有可能出现量词贪婪匹配得不到满足,导致匹配失败,会触发回溯,跳过量词匹配,使用后续规则进行匹配(跳过错误,使用后续规则匹配发生失败的字符)
- 当匹配模式为 惰性匹配,由于是尽可能的少的匹配,当惰性量词匹配后规则匹配失败后,会触发回溯,重新使用上次量词规则进行匹配(跳过错误,使用前面量词匹配规则匹配之前失败的字符)
1.4.2.4 独占模式工作流程
独占模式,在匹配行为上类似于 贪婪匹配,但是,它的匹配过程中如果匹配失败,不会发生回溯,直接返回失败,因此在一些场合下性能会更好
regex
$ pip install regex
Looking in indexes: http://mirrors.cloud.aliyuncs.com/pypi/simple/
Collecting regex
Downloading http://mirrors.cloud.aliyuncs.com/pypi/packages/c7/57/50479adc9028ecd30741e9f1be9881183b061540701cda46a8d2614d838c/regex-2021.8.28-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (759 kB)
|████████████████████████████████| 759 kB 6.4 MB/s
Installing collected packages: regex
Successfully installed regex-2021.8.28
使用示例:
先看下之前几个例子在 Python 中实际效果
>>> import regex
# 贪婪匹配:尽可能的多的匹配,当量词贪婪匹配失败,自动跳过发生错误的规则,使用后续规则匹配触发失败的字符
>>> regex.findall(r'xy{1,3}z', 'xyyz')
['xyyz']
# 惰性匹配:尽可能的少的匹配,当量词惰性匹配失败,自动跳过错误,使用前面量词匹配规则匹配触发失败的字符
>>> regex.findall(r'xy{1,3}?z', 'xyyz')
['xyyz']
# 独占模式:尽可能的多匹配,当进行 y{1,3} 量词匹配时,一次性的将两个 y 都用掉,满足匹配规则后,进行后续匹配
>>> regex.findall(r'xy{1,3}+z', 'xyyz')
['xyyz']
在看下新的例子,尝试理解下他们背后的匹配过程:
# 贪婪匹配:尽可能的多的匹配,量词惰性匹配y{1,2}到 xyy 后,规则y 匹配 字符z 匹配错误,触发回溯,跳过发生错误的规则,使用后续规则匹配触发失败的字符
>>>> regex.findall(r'xy{1,2}yz', 'xyyz')
['xyyz']
# 惰性匹配:尽可能的少的匹配,此表达式下几乎是一对一匹配(x:x|y{1,2}?:y|y:y|z:z),未发生回溯
>>> regex.findall(r'xy{1,2}?yz', 'xyyz')
['xyyz']
# 独占模式:尽可能的多的匹配,当进行 y{1,2} 量词匹配时,一次性的将两个 y 都用掉,后续 规则y 与 字符z 匹配失败,由于是独占模式,所以不进行回溯,直接返回失败,即空
>>> regex.findall(r'xy{1,2}+yz', 'xyyz')
[]
现在,我们明白了,为何 独占模式 可以在一定程度减少 CPU 开销了吧?不过,这不代表独占模式就是我们的第一选择了,选择什么模式是需要根据具体场景进行判断
- 校验数据:如当我们定义某一接口的参数时,如果客户端提交的请求参数,无法校验匹配通过,说明参数本身就不合法,这种情况不需要进行回溯,直接返回失败即可,这种场景下推荐用独占模式
- 检索数据:如当我们检索文本内容时,有时无法确定具体文本规则,那么必要的贪婪匹配是不可缺少的,毕竟首要任务是保证正则能满足功能需求,这时就不得不用贪婪模式
1.4.4 不区分大小写模式(IGNORECASE)
catCATCatcat
[Cc][Aa][Tt]
虽然可以 work,但不够直观,书写写不方便,这时不区分大小写模式就派上用场了…
?i
Python 示例:
In [5]: re.findall(r'(?i)cat', 'cat\r\nCat\r\nCAT')
Out[5]: ['cat', 'Cat', 'CAT']
而且,当统计重复单词时,即使大小写不同,一样可以被匹配上,如图:
Python 示例:
In [21]: re.findall(r'(?i)(cat) \1', 'cat Cat\nCat Cat\nCAT caT')
Out[21]: ['cat', 'Cat', 'CAT']
In [22]: re.match(r'(?i)(cat) \1', 'cat Cat\nCat Cat\nCAT caT').groups()
Out[22]: ('cat',)
the worldtheworld
((?i))
Python 看样子并不支持
终端执行时虽没报错,但并未生效
In [29]: re.findall(r'(?:(?i)the) world', 'ThE world\nthe World')
Out[29]: ['ThE world', 'the World']
re.IGNORECASEre.I
InIn [32]: re.findall(r'cat', 'CAT Cat cat', re.IGNORECASE)
Out[32]: ['CAT', 'Cat', 'cat']
In [33]: re.findall(r'cat', 'CAT Cat cat', re.I)
Out[33]: ['CAT', 'Cat', 'cat']
做个小节:
(?i)re.IGNORECASE/re.I
1.4.5 点号通配模式(DOTALL)
.[\s\S][\d\D][\w\W]
如下图所示,虽然这么写可以实现效果,但不简洁自然
.
需要注意的是,不同语言有所差异
In [38]: regex.findall(r'(?s).', 'ThE world\nthe World')
Out[38]:
['T',
'h',
'E',
' ',
'w',
'o',
'r',
'l',
'd',
'\n',
't',
'h',
'e',
' ',
'W',
'o',
'r',
'l',
'd']
In [39]: regex.findall(r'(?s).+', 'ThE world\nthe World')
Out[39]: ['ThE world\nthe World']
1.4.6 多行模式(Multiline)
^$
c
t
cat|reboott
原因很简单,在默认情况下,正则表达式会将所有内容当作 一行数据 进行查询处理,例如:
In [12]: text = """cat
...: reboot
...: school
...: """
cat\nreboot\nschool\n
In [13]: print(repr(text))
'cat\nreboot\nschool\n'
treboot
\n\n多行模式
具体示例如下:
Python 示例:
In [14]: text = 'cat\nreboot\nschool\n'
# 单行模式进行匹配
In [15]: regex = '\w+t$'
In [16]: re.findall(regex, text)
Out[16]: []
# 多行模式进行匹配
In [17]: regex = '(?m)\w+t$'
In [18]: re.findall(regex, text)
Out[18]: ['cat', 'reboot']
这个模式算是比较常用,其中一个场景便是日志处理。在处理日志时,通常日志都以时间开头,后面包括 日志级别、模块、方法、执行耗时,以及异常时的堆栈信息等,但出现异常时,通常都会占用了多行。这时我们就可以使用多行匹配模式,以日期时间为匹配规则匹配每一行日志,如下所示:
Python 示例:
定义 日志内容 和 匹配规则
>>> import re
# 日志内容
>>> text = '''2021/08/06 06:56:58 [error] 18668#0: *4148 open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or directory), client: 209.141.54.8, server: localhost, request: "POST /boaform/admin/formLogin HTTP/1.1", host: "47.115.121.119:80", referrer: "http://47.115.121.119:80/admin/login.asp"
... 2021/09/27 11:01:45 [error] 12055#12055: *20383 open() "/opt/nginx/html/index.html1" failed (2: No such file or directory), client: 47.115.121.119, server: yo-yo.fun, request: "GET /index.html1 HTTP/1.0", host: "47.115.121.119"'''
# 定义规则
>>> regex = '''(?m)^((?P<datetime>\d{4}/\d{2}/\d{2}\s\d{2}:\d{2}:\d{2})\s\[(?P<level>\w+)\]\s(?P<pid>\d+)\#\d+\:\s\*\d+\s(?P<message>\w+\(\)\s\".*\"\s\w+\s\(.*\)),\s\w+:\s(?P<client_ip>\d+\.\d+\.\d+\.\d+),\s\w+:\s(?P<server_host>.*),\s\w+:\s\"(?P<method>\w{3,7})\s(?P<url>.*)\s(?P<protocol>\w{4}\/\d.\d)\",\s\w+:\s\"(?P<request_host>.*?)\"(?:,\s\w+:\s\"(?P<referrer>.*)\")?)'''
执行 正则匹配
>>> from pprint import pprint
>>> pprint(re.findall(regex, text))
[('2021/08/06 06:56:58 [error] 18668#0: *4148 open() '
'"/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or '
'directory), client: 209.141.54.8, server: localhost, request: "POST '
'/boaform/admin/formLogin HTTP/1.1", host: "47.115.121.119:80", referrer: '
'"http://47.115.121.119:80/admin/login.asp"',
'2021/08/06 06:56:58',
'error',
'18668',
'open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or '
'directory)',
'209.141.54.8',
'localhost',
'POST',
'/boaform/admin/formLogin',
'HTTP/1.1',
'47.115.121.119:80',
'http://47.115.121.119:80/admin/login.asp'),
('2021/09/27 11:01:45 [error] 12055#12055: *20383 open() '
'"/opt/nginx/html/index.html1" failed (2: No such file or directory), '
'client: 47.115.121.119, server: yo-yo.fun, request: "GET /index.html1 '
'HTTP/1.0", host: "47.115.121.119"',
'2021/09/27 11:01:45',
'error',
'12055',
'open() "/opt/nginx/html/index.html1" failed (2: No such file or directory)',
'47.115.121.119',
'yo-yo.fun',
'GET',
'/index.html1',
'HTTP/1.0',
'47.115.121.119',
'')]
逐行遍历进行匹配:
>>> for line in text.split('\n'):
... re.match(regex, line).groupdict()
...
{'datetime': '2021/08/06 06:56:58', 'level': 'error', 'pid': '18668', 'message': 'open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or directory)', 'client_ip': '209.141.54.8', 'server_host': 'localhost', 'method': 'POST', 'url': '/boaform/admin/formLogin', 'protocol': 'HTTP/1.1', 'request_host': '47.115.121.119:80', 'referrer': 'http://47.115.121.119:80/admin/login.asp'}
{'datetime': '2021/09/27 11:01:45', 'level': 'error', 'pid': '12055', 'message': 'open() "/opt/nginx/html/index.html1" failed (2: No such file or directory)', 'client_ip': '47.115.121.119', 'server_host': 'yo-yo.fun', 'method': 'GET', 'url': '/index.html1', 'protocol': 'HTTP/1.0', 'request_host': '47.115.121.119', 'referrer': None}
re.MULTILINEre.M
>>> regex = '''^((?P<datetime>\d{4}/\d{2}/\d{2}\s\d{2}:\d{2}:\d{2})\s\[(?P<level>\w+)\]\s(?P<pid>\d+)\#\d+\:\s\*\d+\s(?P<message>\w+\(\)\s\".*\"\s\w+\s\(.*\)),\s\w+:\s(?P<client_ip>\d+\.\d+\.\d+\.\d+),\s\w+:\s(?P<server_host>.*),\s\w+:\s\"(?P<method>\w{3,7})\s(?P<url>.*)\s(?P<protocol>\w{4}\/\d.\d)\",\s\w+:\s\"(?P<request_host>.*?)\"(?:,\s\w+:\s\"(?P<referrer>.*)\")?)'''
# 逐行遍历匹配(为了获取kv对)
>>> for line in text.split('\n'):
... re.match(regex, line, re.M).groupdict()
...
{'datetime': '2021/08/06 06:56:58', 'level': 'error', 'pid': '18668', 'message': 'open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or directory)', 'client_ip': '209.141.54.8', 'server_host': 'localhost', 'method': 'POST', 'url': '/boaform/admin/formLogin', 'protocol': 'HTTP/1.1', 'request_host': '47.115.121.119:80', 'referrer': 'http://47.115.121.119:80/admin/login.asp'}
{'datetime': '2021/09/27 11:01:45', 'level': 'error', 'pid': '12055', 'message': 'open() "/opt/nginx/html/index.html1" failed (2: No such file or directory)', 'client_ip': '47.115.121.119', 'server_host': 'yo-yo.fun', 'method': 'GET', 'url': '/index.html1', 'protocol': 'HTTP/1.0', 'request_host': '47.115.121.119', 'referrer': None}
# 批量多行匹配
>>> pprint(re.findall(regex, text, re.M))
[('2021/08/06 06:56:58 [error] 18668#0: *4148 open() '
'"/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or '
'directory), client: 209.141.54.8, server: localhost, request: "POST '
'/boaform/admin/formLogin HTTP/1.1", host: "47.115.121.119:80", referrer: '
'"http://47.115.121.119:80/admin/login.asp"',
'2021/08/06 06:56:58',
'error',
'18668',
'open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or '
'directory)',
'209.141.54.8',
'localhost',
'POST',
'/boaform/admin/formLogin',
'HTTP/1.1',
'47.115.121.119:80',
'http://47.115.121.119:80/admin/login.asp'),
('2021/09/27 11:01:45 [error] 12055#12055: *20383 open() '
'"/opt/nginx/html/index.html1" failed (2: No such file or directory), '
'client: 47.115.121.119, server: yo-yo.fun, request: "GET /index.html1 '
'HTTP/1.0", host: "47.115.121.119"',
'2021/09/27 11:01:45',
'error',
'12055',
'open() "/opt/nginx/html/index.html1" failed (2: No such file or directory)',
'47.115.121.119',
'yo-yo.fun',
'GET',
'/index.html1',
'HTTP/1.0',
'47.115.121.119',
'')]
1.4.7 注释模式(Comment)
在实际工作中,正则可能会很复杂,这就导致编写、阅读和维护正则都会很困难,例如上面那个日志匹配的例子,写起来并不难(体力活儿),但是,由于命名、规则,乱七八糟揉成一团,看着是真费劲!
所以,为了让代码更易于理解,提高可读性,关键的地方加上注释是必不可少的,这就是正则的注释模式
(?#)
举个简单的例子:
Python 示例:
In [45]: regex = r'(\w+)(?#匹配英文单词) \1(?#引用分组匹配相邻重复单词)'
In [46]: text = 'cat cat'
In [47]: regex = r'(\w+)(?#匹配英文单词) \1(?#引用分组,匹配相邻重复单词)'
In [48]: re.findall(regex, text)
Out[48]: ['cat']
不过,如果这样书写,势必会导致正则越发的冗长,可读性并不一定会提高多少,我们试下想上面的日志匹配的规则,如果每个字段都加上注释,那规则得有多长…
x
In [60]: text = '2021-09-27'
In [61]: regex = r'''(?x)
...: (?P<date> # 日期
...: (?P<year>\d{4})- # 年
...: (?P<month>\d{2})- # 月
...: (?P<day>\d{2})) # 日
...: '''
In [62]: re.findall(regex, text)
Out[62]: [('2021-09-27', '2021', '09', '27')]
In [63]: re.match(regex, text).groupdict()
Out[63]: {'date': '2021-09-27', 'year': '2021', 'month': '09', 'day': '27'}
x
In [66]: text = '2021 09 27'
In [67]: regex = r'''(?x)
...: (?P<date> # 日期
...: (?P<year>\d{4}) # 年
...: [ ] # 匹配空格
...: (?P<month>\d{2}) # 月
...: [ ]
...: (?P<day>\d{2})) # 日
...: '''
In [68]: re.findall(regex, text)
Out[68]: [('2021 09 27', '2021', '09', '27')]
In [69]: re.match(regex, text).groupdict()
Out[69]: {'date': '2021 09 27', 'year': '2021', 'month': '09', 'day': '27'}
x
定义日志内容:
In [88]: text = '''2021/08/06 06:56:58 [error] 18668#0: *4148 open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or directory)
...: , client: 209.141.54.8, server: localhost, request: "POST /boaform/admin/formLogin HTTP/1.1", host: "47.115.121.119:80", referrer: "http://
...: 47.115.121.119:80/admin/login.asp"
...: 2021/09/27 11:01:45 [error] 12055#12055: *20383 open() "/opt/nginx/html/index.html1" failed (2: No such file or directory), client: 47.115.
...: 121.119, server: yo-yo.fun, request: "GET /index.html1 HTTP/1.0", host: "47.115.121.119"'''
定义正则规则:
In [89]: regex = '''(?mx)
...: ^ # 开头
...: (?P<datetime>\d{4}/\d{2}/\d{2}\s\d{2}:\d{2}:\d{2})\s # 日期时间
...: \[(?P<level>\w+)\]\s # 日志级别
...: (?P<pid>\d+)\#\d+\:\s\*\d+\s # 进程ID、分组外是线程ID、客户端ID
...: (?P<message>\w+\(\)\s\".*\"\s\w+\s\(.*\)), # 错误日志,包括系统调用、url、错误描述
...: \s\w+:\s(?P<client_ip>\d+\.\d+\.\d+\.\d+), # 客户端IP
...: \s\w+:\s(?P<server_host>.*), # 虚拟主机名称
...: \s\w+:\s\"(?P<method>\w{3,7})\s # 请求方法
...: (?P<url>.*)\s # 请求URL
...: (?P<protocol>\w{4}\/\d.\d)\", # 协议及版本
...: \s\w+:\s\"(?P<request_host>.*?)\" # 请求主机名(域名)
...: (?:,\s\w+:\s\"(?P<referrer>.*)\")? # 来源地址
...: $ # 结束
...: '''
执行正则匹配
In [90]: re.findall(regex, text)
Out[90]:
[('2021/08/06 06:56:58',
'error',
'18668',
'open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or directory)',
'209.141.54.8',
'localhost',
'POST',
'/boaform/admin/formLogin',
'HTTP/1.1',
'47.115.121.119:80',
'http://47.115.121.119:80/admin/login.asp'),
('2021/09/27 11:01:45',
'error',
'12055',
'open() "/opt/nginx/html/index.html1" failed (2: No such file or directory)',
'47.115.121.119',
'yo-yo.fun',
'GET',
'/index.html1',
'HTTP/1.0',
'47.115.121.119',
'')]
逐行遍历进行匹配
In [121]: for l in text.split('\n'):
...: re.match(regex, l).groupdict()
...:
...:
所以,为了测试方便,这里用列表解析式
In [112]: [re.match(regex, l).groupdict() for l in text.split('\n')]
Out[112]:
[{'datetime': '2021/08/06 06:56:58',
'level': 'error',
'pid': '18668',
'message': 'open() "/opt/nginx/html/boaform/admin/formLogin" failed (2: No such file or directory)',
'client_ip': '209.141.54.8',
'server_host': 'localhost',
'method': 'POST',
'url': '/boaform/admin/formLogin',
'protocol': 'HTTP/1.1',
'request_host': '47.115.121.119:80',
'referrer': 'http://47.115.121.119:80/admin/login.asp'},
{'datetime': '2021/09/27 11:01:45',
'level': 'error',
'pid': '12055',
'message': 'open() "/opt/nginx/html/index.html1" failed (2: No such file or directory)',
'client_ip': '47.115.121.119',
'server_host': 'yo-yo.fun',
'method': 'GET',
'url': '/index.html1',
'protocol': 'HTTP/1.0',
'request_host': '47.115.121.119',
'referrer': None}]
OK,搞定!
1.5 分组/引用
1.5.1 为什么需要表达式分组?
()()
假设,我们现在要去查找 15 位或 18 位数字,我们第一反应可能是这样的…
但经过测试后发现,这个正则并不能很好地完成任务,因为匹配到了最左的规则,或许想到了调换位置来解决…
\d{15}\d{3}?
{...}?{}?
()
1.5.2 什么是分组?
()
图示中的正则,一共有两个分组,第一个分组用来匹配日期,第二个分组用来匹配时间,Python 示例:
In [8]: re.match(r'(\d{4}-\d{2}-\d{2})\s(\d{2}:\d{2}:\d{2})', '2021-09-21 18:01:23').groups()
Out[8]: ('2021-09-21', '18:01:23')
可以看到,日期 与 时间 分到两个组,方便我们获取使用,可是,有些情况下,我们只需要用括号将某些部分看成一个整体,后续不用再用它。
?:
# 保存分组匹配内容
In [16]: re.findall(r'\d{15}(\d{3})?', '110120119112114\n110120119112114911')
Out[16]: ['', '911']
# 不保存分组匹配内容
In [17]: re.findall(r'\d{15}(?:\d{3})?', '110120119112114\n110120119112114911')
Out[17]: ['110120119112114', '110120119112114911']
?:911
1.5.3 分组嵌套
有些情况会比较复杂,我们可能需要在括号内进行多层嵌套,例如:除了要获取日期,还要获取年月日、时分秒等字段,那么规则如下:
In [29]: re.match(r'((\d{4})-(\d{2})-(\d{2}))\s((\d{2}):(\d{2}):(\d{2}))', '2021-09-21 18:01:23').groups()
Out[29]: ('2021-09-21', '2021', '09', '21', '18:01:23', '18', '01', '23')
In [30]: re.findall(r'((\d{4})-(\d{2})-(\d{2}))\s((\d{2}):(\d{2}):(\d{2}))', '2021-09-21 18:01:23')
Out[30]: [('2021-09-21', '2021', '09', '21', '18:01:23', '18', '01', '23')]
当我们想要判断所需数据所在的分组,只需要数左括号(开括号)是第几个,就可以确定在第几个子组
1.5.4 命名分组
前面我们讨论了分组编号,虽然数起来不难,但是,如若后续正则发生了变动,改动了括号的个数,随之而来的导致编号也会发生变化,那么代码逻辑就会产生问题因此一些编程语言提供了命名分组(namedgrouping),这样和数字相比更容易辨识,不容易出错。命名分组的格式为(?P<分组名>正则)。
namedgrouping?P<分组名>正则
示例如下:
Python 示例:
In [41]: re.findall(r'((\d{4})-(\d{2})-(\d{2}))\s((\d{2}):(\d{2}):(\d{2}))', '2021-09-21 18:01:23')
Out[41]: [('2021-09-21', '2021', '09', '21', '18:01:23', '18', '01', '23')]
In [42]: re.match(r'(?P<date>(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2}))\s(?P<time>(?P<hour>\d{2}):(?P<minute>\d{2}):(?P<second>\d{2}))', '2021
...: -09-21 18:01:23').groupdict()
Out[42]:
{'date': '2021-09-21',
'year': '2021',
'month': '09',
'day': '21',
'time': '18:01:23',
'hour': '18',
'minute': '01',
'second': '23'}
1.5.5 分组引用
前面的内容都是对匹配内容进行分组,以编号或特定名称并命名,现在开始要引用分组(表达式或数据)
1.5.5.1 查找中引用分组表达式
首先,看个例子,找出重复出现的单词:
([a-zA-Z]+)\1
通过上面两部分的匹配规则,我们可以匹配到 两个相邻且重复的单词
1.5.5.2 替换中引用分组表达式
Python 示例:
\
(?Pexpr)\g
1.5.6 课后练习
有一篇英文文章,里面有一些单词连续出现了多次,我们认连续出现多次的单词应该是一次,比如:
the little cat cat is in the hat hat hat, we like it.
其中 cat 和 hat 连接出现多次,要求处理后结果是:
the little cat is in the hat, we like it.
正则表达式该如何写呢?
…
首先,我们思考下,相邻的重复单词如何匹配?
(\w+)\s\1
但是,这能达到效果吗?似乎不能
为什么呢?问题处在了哪呢?
MATCH INFORMATIONhat hat
hat hat hatcat cat
(\w+)\s\1\s\1
cat cat
\s\1\s\1(\s\1)+
修改之后,我们再梳理下当前规则:
(\w+)(\s\1)+
看起来似乎有门儿啊!~ 试试…
我们再放到 Python 中尝试下~
Python 示例:
In [48]: text = 'the little cat cat is in the hat hat hat, we like it.'
In [49]: regex = r'(\w+)(\s\1)+'
In [50]: subex = r'\1'
In [51]: re.sub(regex, subex, text)
Out[51]: 'the little cat is in the hat, we like it.'
OK,搞定!
1.6 断言
1.6.1 什么是断言?
\d{11}
这在某些场景下是不符合要求的,为了解决这个问题,正则中提供了一些结构,用于限定匹配位置,使得匹配规则不仅局限在文本内容本身,这种结构就是 断言
1.6.2 断言的分类
常见的断言有三种:单词边界、行的开始或结束 以及 环视
\b\w+[a-zA-Z0-9_]^$
1.6.3 单词边界(Word Boundary)
\w+[a-zA-Z]引号'空格标点换行
\b\bbBoundary
tomjerry
tom asked me if I would go fishing with him tomorrow.
如果按照之前的思路,肯定是不行的
\b
Python 示例:
In [10]: text = "tom asked me if I would go fishing with him tomorrow.\nhi tom, i 'm ratomas"
In [11]: regex = r'\btom\b'
In [12]: from pprint import pprint
In [13]: pprint(re.sub(regex, 'jerry', text, re.M))
('jerry asked me if I would go fishing with him tomorrow.\n'
"hi jerry, i 'm ratomas")
tom(\b)
tom\btomtom\b\btom\btomtomorrowatomatomic
1.6.4 行开始/结束
^$行
换行符
\r\n\n\n
^$
In [89]: regex = '''(?mx)
...: ^ # 开头
...: (?P<datetime>\d{4}/\d{2}/\d{2}\s\d{2}:\d{2}:\d{2})\s # 日期时间
...: \[(?P<level>\w+)\]\s # 日志级别
...: (?P<pid>\d+)\#\d+\:\s\*\d+\s # 进程ID、分组外是线程ID、客户端ID
...: (?P<message>\w+\(\)\s\".*\"\s\w+\s\(.*\)), # 错误日志,包括系统调用、url、错误描述
...: \s\w+:\s(?P<client_ip>\d+\.\d+\.\d+\.\d+), # 客户端IP
...: \s\w+:\s(?P<server_host>.*), # 虚拟主机名称
...: \s\w+:\s\"(?P<method>\w{3,7})\s # 请求方法
...: (?P<url>.*)\s # 请求URL
...: (?P<protocol>\w{4}\/\d.\d)\", # 协议及版本
...: \s\w+:\s\"(?P<request_host>.*?)\" # 请求主机名(域名)
...: (?:,\s\w+:\s\"(?P<referrer>.*)\")? # 来源地址
...: $ # 结束
...: '''
^$
^$"
除此之外呢,首尾限界符还常用于 数据校验,如下所示:
\d{6}
In [2]: import re
# 用户正常输入 6 位数字,通过
In [3]: re.search(r'\d{6}', '123456') is not None
Out[3]: True
# 用户输入 7 位数字,通过
In [4]: re.search(r'\d{6}', '1234567') is not None
Out[4]: True
# 用户输入 1 个字母、7 位数字,通过
In [5]: re.search(r'\d{6}$', 'a1234567') is not None
Out[5]: True
# 严格限定用户类型及长度,用户输入 7 位数字,失败
In [6]: re.search(r'^\d{6}$', '1234567') is not None
Out[6]: False
# 严格限定用户类型及长度,用户输入 6 位数字,通过
In [7]: re.search(r'^\d{6}$', '123456') is not None
Out[7]: True
\A\Z
In [20]: re.search(r'\A\d{6}\Z', '123456') is not None
Out[20]: True
In [21]: re.search(r'\A\d{6}\Z', 'a123456') is not None
Out[21]: False
In [22]: re.search(r'\A\d{6}\Z', '0123456') is not None
Out[22]: False
In [26]: re.findall(r'\A\d{6}\Z', '123456')
Out[26]: ['123456']
In [27]: re.findall(r'\A\d{6}\Z', '123456', re.M)
Out[27]: ['123456']
In [28]: re.findall(r'\A\d{6}\Z', '123456\n123123', re.M)
Out[28]: []
In [29]: re.findall(r'^\d{6}$', '123456\n123123', re.M)
Out[29]: ['123456', '123123']
In [30]: re.findall(r'^\d{6}$', '123456\n123123')
Out[30]: []
1.6.5 环视( Look Around)
环视,也叫 “零宽断言”,叫什么倒不重要,主要的作用在于 瞻前顾后,要求匹配部分的前面或后面要满足(或不满足)某种规则
举例说明:“邮政编码的规则一共有 6 位数字组成,首位为 1-9,现在要求你写出一个正则,提取文本中的邮政编码”
[1-9]\d{5}
如图所示:
看似它好像是完成了需求,可如果你稍微将内容变得复杂些,就会发现事实并非如此,如下所示:
Python 示例:
In [1]: import re
In [2]: re.search(r'\b[1-9]\d{5}\b', '123456') is not None
Out[2]: True
In [3]: re.search(r'\b[1-9]\d{5}\b', '012345') is not None
Out[3]: False
In [4]: re.search(r'\b[1-9]\d{5}\b', '130400') is not None
Out[4]: True
In [5]: re.search(r'\b[1-9]\d{5}\b', '405411') is not None
Out[5]: True
In [6]: re.search(r'\b[1-9]\d{5}\b', '-405411') is not None
Out[6]: True
In [7]: re.search(r'\b[1-9]\d{5}\b', '405411-') is not None
Out[7]: True
In [8]: re.search(r'\b[1-9]\d{5}\b', '!405411?') is not None
Out[8]: True
\b空格回车换行标点特殊字符
环视的书写规则有 4 中,如下表所示:
(?<=元字符)(?(?=元字符)(?!元字符)
诚然,这几个规则是比较难写的,除了多写没啥好的技巧,不过在阅读理解是有敲门的,说白点就四就话:
(?<=元字符)(?(?=元字符)(?!元字符)(?(?!元字符)
大致弄清楚环视后,我们开始解决 单词边界符 所解决不了的邮编匹配问题
示例一:邮编匹配
邮编匹配除了本身的规则意外,我们还要求其前后不能存在别的无关字符。其实,在实际场景中,我们经常的做法是,先判断参数长度,后校验数据
不过这里是为了学习理解 环视 这个概念,通过简单的分析,结合上面表格中的 环视规则,可以比较轻松的写出解决办法
(?
Python 示例:
In [13]: text = '''012345
...: 130400
...: 405411
...: 405411-
...: 405411$
...: !405411?
...: 45001123
...: 13800138000'''
In [14]: regex = r'(?<!.)[1-9]\d{5}(?!.)'
In [15]: re.findall(regex, text)
Out[15]: ['130400', '405411']
.
示例二:单词匹配
同理,我们再思考下,如果用 环视 思路实现获取文中单词
Python 示例:
In [16]: text = '''the little cat is in the hat'''
In [17]: regex = r'(?<!\w)\w+(?!\w)'
In [18]: re.findall(regex, text)
Out[18]: ['the', 'little', 'cat', 'is', 'in', 'the', 'hat']
1.6.6 课后练习
有一篇英文文章,里面有一些单词连续出现了多次,我们认连续出现多次的单词应该是一次,比如:
the little cat cat2 is in the hat hat2, we like it.
其中 cat 和 hat 连接出现多次,要求处理后结果是:
the little cat is in the hat, we like it.
(\w+)(?:\s\1)+
那么,新的正则表达式该如何写呢?
…
(\w+)(?:\s\1(\d)?)+
思路比较简单,完善后一个表达式的匹配,避免最终内容出现数字
Python 示例:
In [37]: text = """the little cat cat1 is in the hat hat hat, we like it.
...: the little cat cat2 is in the hat hat2, we like it."""
In [38]: from pprint import pprint
In [39]: pprint(re.sub(r'(\w+)(?:\s\1(\d)?)+', r'\1', text))
('the little cat is in the hat, we like it.\n'
'the little cat is in the hat, we like it.')
解法二:
目前没有想到如何使用 断言 实现上面效果的方法,(/ □ )…
1.7 转义
转义对我们来说都不算陌生,当某些符号影响代码逻辑时,我们通常都会对其进行 转义操作,如下:
In [42]: print('i 'm a theacher.')
File "<ipython-input-42-800033b842ab>", line 1
print('i 'm a theacher.')
^
SyntaxError: invalid syntax
In [43]: print('i \'m a theacher.')
i 'm a theacher.
'\\
在正则中,转义也门学问,正确且系统的掌握什么时候需要转义,什么时候不用转义,会减少以后工作中遇到的小麻烦
1.7.1 转移符号(Escape Character)
首先,我们说一下什么是转义字符,维基百科的解释是:所谓转义字符,即标志着转义序列开始的那个字符
在计算机科学与远程通信中,当 转义字符 放在 字符序列 中,它将对它 后续的几个字符 进行 替代并解释。通常,判定某字符是否为转义字符由上下文确定,所谓转义字符即标志着转义序列开始的那个字符。
不太好理解,通俗来说,转义序列有两种功能:
- 编码(动词)对无法用字母表直接表示的特殊数据进行
- 表示无法直接键盘录入的字符
在不同平台(协议),转义字符也有所不同
\%
在日常工作中经常会遇到转义字符,比如我们在 shell 中删除文件,如果文件名中有 * 号,我们就需要转义
$ rm access_log* # 删除当前目录下 access_log 开头的文件
$ rm access_log\* # 删除当前目录下名字叫 access_log* 的文件
下面是一些常见的转义字符及含义
\n\r\t\v\\\'\"
1.7.2 转义的内部过程
\d反斜杠和字母d\\d反斜杠后面紧跟着一个字母d
\d
而在 Python 中使用转移符号有那么几个细小的差异,具体是什么,看下嘛的例子
In [44]: re.findall(r'\\d', 'a*b+c?\d123d\')
File "<ipython-input-44-8cd73b954637>", line 1
re.findall(r'\\d', 'a*b+c?\d123d\')
^
SyntaxError: EOL while scanning string literal
In [45]: re.findall(r'\\d', 'a*b+c?\d123d\\')
Out[45]: ['\\d']
\'\\
r
In [53]: re.findall('\\d', 'a*b+c?\d123d\\')
Out[53]: ['1', '2', '3']
要理解这个现象,就一定要深入理解下 转义的过程
在程序使用过程中,从输入的字符串到正则表达式,其实有两步转换过程,分别是 字符串转义 和 正则转义
\\d
\\d\d\d
r\d\\d\d
1.7.3 元字符的转义
\d\w**+?
In [58]: re.findall('\+', '+')
Out[58]: ['+']
In [60]: re.findall('\(\)\[]\{}', '()[]{}')
Out[60]: ['()[]{}']
()
1.7.4 函数消除元字符特殊含义
\d\\\\d\\d\d
In [71]: re.findall('\\\\d', 'a*b+c?\d123d\\')
Out[71]: ['\\d']
In [72]: re.escape('\d') # 反斜杠和字母d转义
Out[72]: '\\\\d'
In [71]: re.findall(re.escape('\d'), 'a*b+c?\d123d\\')
Out[71]: ['\\d']
尤其是在复杂些的表达式,用它来转移是最适合的
In [74]: re.escape('[0-9]')
Out[74]: '\\[0\\-9\\]'
In [76]: re.findall(re.escape('[0-9a-z]'), 'a*b+c?\d123d\\[0-9a-z]')
Out[76]: ['[0-9a-z]']
小节下,转义函数 可以将整个文本转义,一般用于转义用户输入的内容,即把这些内容看成普通字符串去匹配,其他编程语言同样也转义函数,如下
1.7.5 字符组转义
[]
^^[^...][^ab]ab^
In [1]: import re
In [2]: re.findall(r'[^ab]', '^abc')
Out[2]: ['^', 'c']
In [3]: re.findall(r'[\^ab]', '^abc')
Out[3]: ['^', 'a', 'b']
1.7.5.2 转义不在首位的中杠
-[a-z][a-z]az-
In [5]: re.findall(r'[a-c]', 'abc-')
Out[5]: ['a', 'b', 'c']
In [6]: re.findall(r'[a\-c]', 'abc-')
Out[6]: ['a', 'c', '-']
-
In [7]: re.findall(r'[-ac]', 'abc-')
Out[7]: ['a', 'c', '-']
In [8]: re.findall(r'[ac-]', 'abc-')
Out[8]: ['a', 'c', '-']
1.7.5.3 转义不在首位的右中括号
]]
In [10]: re.findall(r'[a]b]', ']abc')
Out[10]: []
][a]b]]abc
所以,如题,当右中括号不在首位时需要转义,如下:
In [11]: re.findall(r'[a\]b]', ']abc')
Out[11]: [']', 'a', 'b']
]
In [13]: re.findall(r'[ab]]', ']abc')
Out[13]: []
In [14]: re.findall(r'[ab\]]', ']abc')
Out[14]: [']', 'a', 'b']
]
# ] 与 [ 结对出现在开头
In [12]: re.findall(r'[]ab]', ']abc')
Out[12]: [']', 'a', 'b']
1.7.5.4 字符组中不需要转义的元字符
[]
In [15]: re.findall(r'[.*+?()]', '.*+?()')
Out[15]: ['.', '*', '+', '?', '(', ')']
\d\w.*+?()
In [18]: re.findall(r'[\d.*]', 'd12\\')
Out[18]: ['1', '2']
In [19]: re.findall(r'[\d+]', 'd12\\')
Out[19]: ['1', '2']
In [20]: re.findall(r'[\d+\\]', 'd12\\')
Out[20]: ['1', '2', '\\']
1.7.6 课后练习
你能否解释出以下四个示例中的转义过程呢?
In [1]: import re
In [2]: re.findall('\n', '\\n\n\\')
Out[2]: ['\n'] # 匹配到了换行符
In [3]: re.findall('\\n', '\\n\n\\')
Out[3]: ['\n'] # 匹配到了换行符
In [4]: re.findall('\\\n', '\\n\n\\')
Out[4]: ['\n'] # 匹配到了换行符
In [5]: re.findall('\\\\n', '\\n\n\\')
Out[5]: ['\\n'] # 匹配到了 反斜杠 和 字母n
二、深入进阶
2.1 正则演变及流派
首先,先看一张图…
很奇怪,对吧,正则在 Linux 的应用怎么跟编程语言中区别如此大呢?产生 “淮南为橘,淮北为枳” 的现象呢?这一切的一切都和正则的演变有着密不可分的关系
2.1.1 正则表达式简史
2.1.1.1 缘起
正则表达式的起源,可以追溯到到 20 世纪 40 年代,有两位神经生理学家(Warren McCulloch)与(Walter Pitts),他们研究出了一种用数学方式来描述神经网络的方法
Regular Sets
qededgrep
2.1.1.2 岔路
POSIX 流派的诞生
随后,由于正则功能强大,非常实用,越来越多的语言和工具都开始支持正则。不过遗憾的是,由于没有尽早确立标准,导致各种语言和工具中的正则虽然功能大致类似,但仍然有不少细微差别
grepsedawk
这些遵循 POSIX 正则表达式规范的正则表达式,被世人统称为 “POSIX 流派的正则表达式“
PCRE 横空出世
但是,谁成想,Perl 语言杀了出来,在 1987 年 12 月,LarryWall 发布了第一版的 Perl 语言,因其功能强大而一票走红,其中包括它的正则表达式功能,随着 Perl 语言的发展,Perl 语言中的正则表达式功能不断改进、优化,越来越强悍,为了把 Perl 语言中正则的功能移植到其他语言中,PCRE 在 1997 年就诞生了!
PCRE
2.1.2 正则表达式流派
到目前为止,正则表达式在各种计算机语言或各种应用领域得到了更为广泛的应用和发展,两个流派也有各自市场
- POSIX 流派:类 Unix 上的工具遵循 POSIX 标准
- PCRE 流派:编程语言及工具遵循 PCRE 标准
2.1.2.1 POSIX 流派
我们先简要介绍一下 POSIX 流派,POSIX 规范中定义了正则表达式的两种标准:
- BRE 标准(Basic Regular Expression) 基本正则表达式
- ERE 标准(Extended Regular Expression) 扩展正则表达式
BRE 标准
?+|{}()
ERE 标准
ERE 标准中使用花括号,圆括号时不需要转义了,还支持了问号、加号 和 多选分支
GNU 套件
现在使用的 Linux 发行版,大多都集成了 GNU 套件,GNU 在实现 POSIX 标准(BRE、ERE)时做了一定的扩展:
+?\+\?|\|\1\2...
我们将上面的内容做个总结,如下图,浅黄色背景是 BRE 和 ERE 不同的地方、三处天蓝色字体是 GNU 扩展
GNU BREGNU ERE{}()+|
POSIX 字符组
\d[0-9]\w[0-9a-zA-Z_]
[[:alnum:]][0-9a-zA-Z]\w_[[:alpha:]][a-zA-Z][[:ascii:]][\x00-\x7F][[:blank:]][\s\t][[:cntrl:]][\x00-\x1F\x7F][[:digit:]][0-9]\d[[:graph:]][!~~][[:lower:]][a-z][[:upper:]][A-Z][[:print:]][\s-~][[:graph:]][[:punct:]][[:space:]][\t\n\r\v\f][[:xdigit:]][0-9A-Fa-f]
2.1.2.2 PCRE 流派
\d\w\s
兼容问题
虽然 PCRE 流派是从 Perl 语言中衍生出来的,但是各语言在实现 preg 正则表达式 时多多少少都会存在些许差异,这一点从之前我们的学习过程中也能体会到…
理论上来说 PCRE 流派是与 Perl 正则表达式相兼容的流派,但落到实际上,各种语言和工具中还存在程度上的差别,主要分两种情况:
- 直接兼容
- 间接兼容
直接兼容
PCRE 流派中与 Perl 正则表达式直接兼容的语言或工具,比如 Perl、PHPpreg、PCRE 库等,一般称之为 Perl 系
间接兼容
比如 Java 系(包括Java、Groovy、Scala等)、Python 系(包括 Python2/3)、JavaScript 系(包括原生 JavaScript 和扩展库 XRegExp)、Net系(包括C#、VB。Net等)等
2.1.3 linux 中使用正则
在遵循 POSIX 规范的 UNIX/LINUX 系统上,不同工具可能遵循的标准不同,例如:
grepsedvi/vimegrepawk
PCRE 流派POSIX 流派
grepsed
# 使用 ERE 标准
☁ /tmp $ grep -E '[[:digit:]]' testfile
123456
# 使用 PCRE 标准
☁ /tmp $ grep -P '\d+' testfile
123456
倘若,在使用命令前,你不清楚当前工具属于哪个流派,可以通过 Linux 中 man 命令,查看它所只支持的标准,比如:
☁ /tmp $ man grep
# 搜索 regex
-E, --extended-regexp
Interpret PATTERN as an extended regular expression (ERE, see below). (-E is specified by POSIX.)
-F, --fixed-strings, --fixed-regexp
Interpret PATTERN as a list of fixed strings, separated by newlines, any of which is to be matched. (-F is specified by POSIX,
--fixed-regexp is an obsoleted alias, please do not use it in new scripts.)
-G, --basic-regexp
Interpret PATTERN as a basic regular expression (BRE, see below). This is the default.
-P, --perl-regexp
Interpret PATTERN as a Perl regular expression. This is highly experimental and grep -P may warn of unimplemented features.
grep-G -E -P
再比如 sed
☁ /tmp $ man sed
# 搜索 regex
-r, --regexp-extended
use extended regular expressions in the script.
-rERE
2.1.4 课后练习
Linux 中使用 grep 命令练习查找包含一到多个数字的行
123456
abcdef
\d
\d+
d+
解法:
☁ /tmp $ grep -P '\d+' testfile
123456
☁ /tmp $ grep -E '[[:digit:]]' testfile
123456
☁ /tmp $ egrep '[[:digit:]]' testfile
123456
☁ /tmp $ egrep '^[0-9]+$' testfile
123456
# egrep 不支持 \d 元字符
☁ /tmp $ egrep '\d+' testfile
abcdef
\d
\d+
d+
# 统计匹配规则的行数
☁ /tmp $ egrep -P '\d+' testfile
grep: conflicting matchers specified
☁ /tmp $ egrep -c '^[0-9]+$' testfile
1
☁ /tmp $ egrep -c '\\' testfile
2
2.2 Unicode 万国码
2.1.1 Unicode 是什么 ?
Unicode,中文也叫万国码、国际码,是计算机科学领域里的一项业界标准
2.1.2 Unicode 标准的作用
144697
2.1.3 Unicode 平面分组
PlaneCodePoint
就目前而言,我们用到的绝大多数字符都属于 第 0 号平面,即 BMP 平面,除了它之外的其它的平面,我们统称为 补充平面
各个平面大致介绍见下表:
2.1.4 Unicode 编码
目前,Unicode 使用 4 个字节的编码表示一个字符(可以是全世界所有语言的字符),那么 Unicode 在计算机中如何存储和传输的呢?这就涉及部分编码的知识了…
UTF-8UTF-16,UTF-32
为什么现在最常见的是 UTF-8 呢?两个主要原因
- 兼容 ASCII 编码
- 采用的是变长的方法:根据不同类型字符占用 1 到 4 个字节不等,例如表示纯英文时,可能只有 1 字节,表示汉字时通常占用 3 字节
Python UTF-8 编码示例:
In [9]: u'正'.encode('utf-8')
Out[9]: b'\xe6\xad\xa3'
In [10]: u'则'.encode('utf-8')
Out[10]: b'\xe5\x88\x99'
Unicode 和 UTF-8 的转换规则表(补充了解)
| Unicode | Bit 数 | UTF-8 | byte 数 |
|---|---|---|---|
| 0000-007f | 0-7 | 0XXX XXXX | 1 |
| 0080-07FF | 8-11 | 110X XXXX 10XX XXXX |
2 |
| 0800-FFFF | 12-16 | 1110 XXXX 10XX XXXX 10XX XXXX |
3 |
| 10000-1FFFFF | 17-21 | 1111 0XXX 10XX XXXX 10XX XXXX 10XX XXXX |
4 |
2.1.5 Unicode & 正则
2.1.5.1 编码中的坑
2.1.5.1.1 中文匹配
在 Python 语言中使用正则优先选择 Python 3(当下基本也都是这样),为什么?先看个例子:
>>> import re
>>> re.search(r'[时间]', '极客') is not None
True
时间极客
我们深入分析下,在不显示使用 Unicode 编码时,正则会按照系统默认配置进行编码表示,比如,在macOS或Linux下,一般会编码成 UTF-8,而在 Windows 下一般会编码成 GBK
# 查看系统编码
☁ ~ $ locale
LANG=en_US.UTF-8
LC_CTYPE="en_US.UTF-8"
LC_NUMERIC="en_US.UTF-8"
LC_TIME="en_US.UTF-8"
LC_COLLATE="en_US.UTF-8"
LC_MONETARY="en_US.UTF-8"
LC_MESSAGES="en_US.UTF-8"
LC_PAPER="en_US.UTF-8"
LC_NAME="en_US.UTF-8"
LC_ADDRESS="en_US.UTF-8"
LC_TELEPHONE="en_US.UTF-8"
LC_MEASUREMENT="en_US.UTF-8"
LC_IDENTIFICATION="en_US.UTF-8"
LC_ALL=
极客时间
>>> u'极客'.encode('utf-8')
'\xe6\x9e\x81\xe5\xae\xa2' # 含有 e6
>>> u'时间'.encode('utf-8')
'\xe6\x97\xb6\xe9\x97\xb4' # 含有 e6
findall
>>> re.findall(r'[时间]', '极客')
['\xe6']
果然如此,其实的匹配规则基本等同于
# [时间] # 极客
>>> re.findall(r'[\xe6\x97\xb6\xe9\x97\xb4]', '\xe6\x9e\x81\xe5\xae\xa2')
['\xe6']
这就是 Unicode 第一个坑——中文匹配问题
2.1.5.1.2 点号通配
.
Python 2.7 示例:
>>> import re
>>> re.findall(r'^.$', '学')
[]
>>> re.findall(r'^.$', u'学')
[u'\u5b66']
>>> print('\u5b66')
\u5b66
>>> re.findall(ur'^.$', u'学')
[u'\u5b66']
>>> print(u'\u5b66')
学
Python 3.9 示例:
In [2]: re.findall(r'^.$', '学')
Out[2]: ['学']
In [3]: re.findall(r'(?a)^.$', '学')
Out[3]: ['学']
re.A|ASCII\w\W\b\B\d\D\s\S
.
2.1.5.1.3 字符组匹配
用正则的字符组匹配全角的字符
Python 2.7.5 示例:
>>> re.findall(r'\d', u'0123456789')
[]
>>> re.findall(r'\s', u'0123456789')
[]
>>> re.findall(r'\s', u'0123456789 abd!@$')
[]
>>> re.findall(r'[a-zA-Z]', u'0123456789 abd!@$')
[]
>>> re.findall(r'\w', u'0123456789 abd!@$')
[]
>>> re.findall(r'\S', u'0123456789 abd!@$')
[u'\uff10', u'\uff11', u'\uff12', u'\uff13', u'\uff14', u'\uff15', u'\uff16', u'\uff17', u'\uff18', u'\uff19', u'\u3000', u'\uff41', u'\uff42', u'\uff44', u'\uff01', u'\uff20', u'\uff04']
Python 3.9 示例:
In [6]: re.findall(r'\d', u'0123456789')
Out[6]: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
In [18]: re.findall(r'\s', u'0123456789 abd!@$')
Out[18]: ['\u3000']
In [19]: re.findall(r'[a-zA-Z]', u'0123456789 abd!@$')
Out[19]: []
In [20]: re.findall(r'[a-z]', u'0123456789 abd!@$')
Out[20]: ['a']
In [21]: re.findall(r'[a-z]', u'0123456789 abd!@$')
Out[21]: ['a', 'b', 'd']
In [22]: re.findall(r'\w', u'0123456789 abd!@$')
Out[22]: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'd']
In [23]: re.findall(r'\S', u'0123456789 abd!@$')
Out[23]:
['0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'a',
'b',
'd',
'!',
'@',
'$']
\d、\w
2.1.5.2 Unicode 属性
Unicode 把一些相关的 Unicode 字符集划分成不同的字符小集合,通常比较常用的有三种:
Unicode Categories(也叫 Unicode Property)Unicode BlocksUnicode Scripts
下图是相对详细的表格:
\p{属性}
Python 示例:
内置的 re 库不支持 unicode 属性
In [8]: re.findall(r'\p{Han}+', '轻轻地我走了')
---------------------------------------------------------------------------
error Traceback (most recent call last)
<ipython-input-8-3c427821623e> in <module>
----> 1 re.findall(r'\p{Han}+', '轻轻地我走了')
~/.pyenv/versions/3.9.0/lib/python3.9/re.py in findall(pattern, string, flags)
239
240 Empty matches are included in the result."""
--> 241 return _compile(pattern, flags).findall(string)
242
243 def finditer(pattern, string, flags=0):
~/.pyenv/versions/3.9.0/lib/python3.9/re.py in _compile(pattern, flags)
302 if not sre_compile.isstring(pattern):
303 raise TypeError("first argument must be string or compiled pattern")
--> 304 p = sre_compile.compile(pattern, flags)
305 if not (flags & DEBUG):
306 if len(_cache) >= _MAXCACHE:
~/.pyenv/versions/3.9.0/lib/python3.9/sre_compile.py in compile(p, flags)
762 if isstring(p):
763 pattern = p
--> 764 p = sre_parse.parse(p, flags)
765 else:
766 pattern = None
~/.pyenv/versions/3.9.0/lib/python3.9/sre_parse.py in parse(str, flags, state)
946
947 try:
--> 948 p = _parse_sub(source, state, flags & SRE_FLAG_VERBOSE, 0)
949 except Verbose:
950 # the VERBOSE flag was switched on inside the pattern. to be
~/.pyenv/versions/3.9.0/lib/python3.9/sre_parse.py in _parse_sub(source, state, verbose, nested)
441 start = source.tell()
442 while True:
--> 443 itemsappend(_parse(source, state, verbose, nested + 1,
444 not nested and not items))
445 if not sourcematch("|"):
~/.pyenv/versions/3.9.0/lib/python3.9/sre_parse.py in _parse(source, state, verbose, nested, first)
523
524 if this[0] == "\\":
--> 525 code = _escape(source, this, state)
526 subpatternappend(code)
527
~/.pyenv/versions/3.9.0/lib/python3.9/sre_parse.py in _escape(source, escape, state)
424 if len(escape) == 2:
425 if c in ASCIILETTERS:
--> 426 raise source.error("bad escape %s" % escape, len(escape))
427 return LITERAL, ord(escape[1])
428 except ValueError:
error: bad escape \p at position 0
第三方 regex 模块提供了 完整的 Unicode 支持
\p{Han}
In [7]: regex.findall(r'\p{Han}+', '轻轻地我走了')
Out[7]: ['轻轻地我走了']
\p{P}
In [7]: regex.findall(r'\p{P}', ',.!?:[]"",。!?-【】《》「」『』——~“”')
Out[7]:
[',',
'.',
'!',
'?',
':',
'[',
']',
'"',
'"',
',',
'。',
'!',
'?',
'-',
'【',
'】',
'《',
'》',
'「',
'」',
'『',
'』',
'—',
'—',
'“',
'”']
\p{N}
In [6]: regex.findall(r'\p{N}', '①⒉⑶❹Ⅴ㈥柒')
Out[6]: ['①', '⒉', '⑶', '❹', 'Ⅴ', '㈥']
\p{Arrows}
In [8]: regex.In [8]: regex.findall(r'\p{Arrows}', '↖ ↗ ↙ ↘ ← → ↓ ↑ ↕ ↨ ↔ ☜ ☞ > ≧ ≦')
Out[8]: ['↖', '↗', '↙', '↘', '←', '→', '↓', '↑', '↕', '↨', '↔']
\p{Bopomofo}
In [9]: regex.findall(r'\p{Bopomofo}', 'ㄅㄆㄇㄈㄪㄉㄊㄋㄌㄍㄎㄫㄏㄐㄑㄬㄒㄓㄔㄕㄖㄗㄘㄙㄧㄨㄩㄚㄛㄝㄞㄟㄠㄡㄢㄣㄤㄥㄦ')
Out[9]:
['ㄅ',
'ㄆ',
'ㄇ',
'ㄈ',
'ㄪ',
'ㄉ',
'ㄊ',
'ㄋ',
'ㄌ',
'ㄍ',
'ㄎ',
'ㄫ',
'ㄏ',
'ㄐ',
'ㄑ',
'ㄬ',
'ㄒ',
'ㄓ',
'ㄔ',
'ㄕ',
'ㄖ',
'ㄗ',
'ㄘ',
'ㄙ',
'ㄧ',
'ㄨ',
'ㄩ',
'ㄚ',
'ㄛ',
'ㄝ',
'ㄞ',
'ㄟ',
'ㄠ',
'ㄡ',
'ㄢ',
'ㄣ',
'ㄤ',
'ㄥ',
'ㄦ']
2.1.5.3 表情符号
表情符号,也就是我们重用的 emoji 😀,起源于小日本,后来流行到了世界各地,小日本管它叫“绘文字”,英文名字 emoji
表情符号有如下特点:
- 许多表情不在 BMP(17个平面中最常用的 0 号平面) 内 且 码值超过了 FFFF,在用 UTF-8 编码时,ASCII 占用 1 字节,中文 3 字节,而表情通常需要 4 字节
- 表情分散在 BMP 和各个补充平面中,很难用一个正则来表示所有的表情符号
- 部分表情支持使用颜色修饰(5 种色调),使得原本 4 字节上升到 8 字节
如图所示:
安装 emoji 库
$ pip install emoji
Python 示例:
In [5]: import emoji
In [6]: emoji.emojize(':thumbs_up:')
Out[6]: '👍'
In [7]: emoji.emojize(':thumbs_up:').encode('utf-8')
Out[7]: b'\xf0\x9f\x91\x8d'
2.3 正则匹配原理及优化原则
掌握了正则的基本使用后,我们基本可以按照业务需求写出满足功能的正则规则,但满足需求只是第一步,如何在满足功能的基础上,提高规则的匹配效率,才是我们接下来该追求的。要达成这个目标,首先要对正则匹配的原理有一定的了解,这就是本节的内容
在讨论正则匹配过程前,我们先回顾下 回溯 的概念,以及 DFA、NFA 引擎的工作方式,只有了解了它们,我们才能理解正则表达式在使用上的各种小问题
2.3.1 为什么正则能处理复杂文本?
正则之所以能够处理复杂文本,是其背后依靠一种机制,“有穷状态自动机”,那什么是 有穷状态自动机 呢?
我们先把这个词语拆开:有穷状态 + 自动机
- 有穷状态:指一个系统具有有穷个状态,不同的状态代表不同的意义
- 自动机:指系统可以根据相应的条件,在不同的状态下进行转移。从一个初始状态,根据对应的操作(比如录入的字符集)执行状态转移,最终达到终止状态(可能有一到多个终止状态)
套用生活语言来说就是:
- 有穷状态:比如一个人,有“工作”,“吃饭”,“睡觉”,“空闲”等若干个状态,如果这些状态的数量是有限的,那么就是“有穷”的了
- 自动机:指自己能在不同的状态之间进行切换,下班 → 吃饭 → 空闲 → 睡觉 → 工作
有穷状态自动机(finite automaton,FA)本身是一种特定类型算法的数学方法,而正则引擎是其具体实现,主要有两种
DFA:确定性有穷自动机(Deterministic finite automaton)
NFA:非确定性有穷自动机(Non-deterministic finite automaton)
- 传统 NFA
- POSIX NFA
2.3.2 正则匹配过程
2.3.2.1 NFA 非确定性有穷自动机
compile
In [1]: import re
In [2]: re.compile(r'a(?:bb)+a')
Out[2]: re.compile(r'a(?:bb)+a', re.UNICODE)
In [3]: reg = re.compile(r'a(?:bb)+a')
In [4]: reg.findall('abbbba')
Out[4]: ['abbbba']
re.compile
图像基于 Regexper 正则可视化网站 + 二次加工而成
a(bb)+abb
这种情况下状态机,就是非确定性有穷状态自动机(Non-deterministic finite automaton 简称 NFA)
NFA 非确定性有穷状态自动机 主要特点是:以正则为主导,先看正则、再看文本,我们通过一个例子来说明:
regex = 'jike(zhushou|shijian|shixi)'
text = 'we study on jikeshijian app'
j字符 j正则 i字符 j字符i字符 jike
regex = 'jike(zhushou|shijian|shixi)'
↑
text = 'we study on jikeshijian app'
↑
zzhushou
regex = 'jike(zhushou|shijian|shixi)'
↑
淘汰 (zhushou) 分支 -> 剩余分支 (shijian|shixi)
text = 'we study on jikeshijian app'
↑
正则分支 s字符 s规则 hijian字符 hijian
regex = 'jike(zhushou|shijian|shixi)'
↑
淘汰 (zhushou) 分支 -> 剩余分支 (shijian|shixi)
text = 'we study on jikeshijian app'
↑
shijian 分支shixi 分支
jikeshijianjikeshixi
regex = 'jike(zhushou|shijian|shixi)'
↑ (正则 z 匹配不上 字符 s)
淘汰 (zhushou) 分支 -> 尝试其他分支 (shijian|shixi)
↑ (正则 j 匹配不上 字符 x)
淘汰 (shijian) 分支 -> 尝试其他分支 (shixi)
↑
text = 'we study on jikeshixi app'
↑
整个过程变为:
正则 z文本 szhushou 分支正则 s文本 sshijian 分支shijian 分支规则中 shi 后面为 j 匹配不上 文本 shi 中 xshijian 分支xjike[s]hixi规则分支
由此,我们可以理解 “NFA 是以正则主导的是正则引擎” 这句话了吧~,NFA 以正则为主导的情况下,会反复测试字符串,这样字符串在某些条件(多分支)下,可能会被反复测试很多次
2.3.2.2 DFA 确定下有穷自动机
直接上示例:
text = 'we study on jikeshijian app'
regex = 'jike(zhushou|shijian|shixi)'
DFA 与 NFA 不同,DFA 以文本为主导,会先文本,再看正则表达式,我们逐步分析上面示例的匹配过程
we字符 w字符 j
text = 'we study on jikeshijian app'
↑
regex = 'jike(zhushou|shijian|shixi)'
↑
字符 j字符 i规则 ji字符 e规则 e
text = 'we study on jikeshijian app'
↑
regex = 'jike(zhushou|shijian|shixi)'
↑
字符 e字符 s分支 (zhushou)
text = 'we study on jikeshijian app'
↑
regex = 'jike(zhushou|shijian|shixi)'
↑ (字符 s 匹配不上 正则 z)
淘汰 (zhushou) 分支 -> 尝试其他分支 (shijian|shixi)
shijianshixi字符 shijianj分支 (shijian)分支 (shixi)
text = 'we study on jikeshijian app'
↑
regex = 'jike(zhushou|shijian|shixi)'
↑ (字符 s 匹配不上 正则 z)
淘汰 (zhushou) 分支 -> 尝试其他分支 (shijian|shixi)
↑ 符合 ↑ 淘汰(字符 j 匹配不上 正则 x)
# 由于是先看文本字符,再看正则,所以能够直接判断那些是无效分支,从而避免产生回溯
字符 jikeshijian正则 jike(shijian)jikeshijian
2.3.2.3 NFA vs DFA
通过上面两个示例可以看到,DFA 和 NFA 两种引擎工作方式的差异,这里补充一个小节的表格
NFADFA
2.3.2.4 NFA & POSIX NFA
明明已经有了 NFA 为啥后面有出现了一个 Posix NFA 呢?
事情是这样的,由于传统的 NFA 引擎“急于”报告匹配结果,通常找到第一个匹配上的就返回了,所以可能会导致还有更长的匹配未被发现,如下所示:
grepsed
☁ ~ $ echo 'posix'| grep -o -E 'pos|posix'
posix
☁ ~ $ echo 'posix'| sed -r s'/pos|posix/abc/'
abc
# 不过 grep 也支持 DFA 引擎
☁ ~ echo 'posix'| grep -o -P 'pos|posix'
pos
POSIX NFA 引擎的原理与传统的 NFA 引擎类似,但不同之处在于,POSIX NFA 在找到可能的最长匹配之前会继续回溯,也就是说它会尽可能找最长的,如果分支一样长,以最左边的为准(TheLongest-Leftmost),因此,POSIX NFA 引擎的速度比传统的 NFA 引擎还要慢上不少
不过,通常来说,编程语言基于 NFA 引擎,所以我们书写要注意把最容易匹配到的分支写到最左侧,以此提高匹配效率
以下是常见的几种 正则引擎 对比表格,大致了解下即可
2.3.3 深入理解回溯
此前在 [正则匹配模式](#1.4 正则匹配模式) 中的 [独占模式](#1.4.3 独占模式(Possessive)) 小节简单讨论过 什么是回溯,以及回溯是如何影响 贪婪匹配、惰性匹配 的匹配行为的,如果记不太清了,可以返回上面再去复习下,下面我们开始再深入探讨 回溯…
首先,明确一个概念,回溯 是 NFA 引擎独有的,这一点不难理解。DFA 是以文本为主导的,它一次性读取所有文本字符,在遇到量词或条件分支直接就能完成匹配或淘汰分支,所以不需要进行回溯
NFA 引擎也并非所有情况下都会进行回溯,只有正则中出现量词或多选分支结构 且 非独占模式 时,才可能会发生回溯(最左分支直接匹配不触发回溯)
先看个简单的回溯例子,如图所示:
经过前面的学习,我们按照 NFA 的思路重新捋一下,正则在贪婪模式下的匹配及回溯过程是怎样的
# Step 1
regex = 'a+ab'
↑
text = 'aab'
↑ (规则 a+ 匹配 aa)
匹配成功# Step 2
regex = 'a+ab'
↑
text = 'aab'
↑ (规则 a 匹配不上 字符 b)
匹配失败,触发向前回溯# Step 3
regex = 'a+ab'
↑
text = 'aab'
↑ 吐出 a
向前回溯# Step 4
regex = 'a+ab'
↑
text = 'aab'
↑ (规则 a 匹配 字符 a)
匹配成功# Step 5
regex = 'a+ab'
↑
text = 'aab'
↑ (规则 b 匹配 字符 b)
匹配成功
.*ab
它竟然足足经过了 164 个 Step,这说明了什么,说明正则匹配的很辛苦…
为了相对轻松的,我们把内容删减下
大致步骤如下所示:
Step 1:规则 .* 一下子匹配到了所有字符,匹配内容 lab is cmd
Step 2:规则 a 匹配 没得匹配(因为都让.*匹配完了) 匹配失败 触发回溯
Step 3:吐出 d,仍旧失败,继续触发回溯,匹配内容 匹配内容 lab is cm
Step 4:吐出 m,仍旧失败,继续触发回溯,匹配内容 匹配内容 lab is c
Step 5:吐出 c,仍旧失败,继续触发回溯,匹配内容 lab is
Step 6:吐出 空格,仍旧失败,继续触发回溯,匹配内容 lab is
Step 7:吐出 s,仍旧失败,继续触发回溯,匹配内容 lab i
Step 8:吐出 i,仍旧失败,继续触发回溯,匹配内容 lab
Step 9:吐出 空格,仍旧失败,继续触发回溯,匹配内容 lab
Step 10:吐出 b,仍旧失败,继续触发回溯,匹配内容 la
Step 11:吐出 a,直到回溯至 l(吐出l之后所有匹配上的)
Step 12:规则 a 匹配 字符 a,匹配成功,匹配内容 匹配内容 a
Step 13:规则 a 匹配 字符 a,匹配成功,匹配内容 匹配内容 ab
这里补充个 GIF 直观的感受下…
.*
"[^..]+".+?
2.3.4 正则优化建议
(1) 测试正则性能
In [2]: import re
In [3]: x = '-' * 10000 + 'abc'
In [4]: timeit re.search('abc', 'x')
702 ns ± 40.9 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
(2) 预先编译规则
编程语言中一般都有 预先编译正则规则 的方法,我们可以使用这个方法提前将正则构造成自动机,从而避免每次使用的时候再重复构造,以此提高正则匹配的性能
>>> import re
>>> reg = re.compile(r'ab?c') # 先编译好,再使用
>>> reg.findall('abc')
['abc']
re.findall()
(3) 明确区域范围
.*
(4) 提取公共部分
(abcd|abxy)ab(xy|cd)
(abcd|abxy)
ab(xy|cd)
th(?:is|at)this|that^th(is|at) is(^this|^that) is
(5) 高频条件左移
.com.net.(?:com|net)\b.(?:net|com)\b
(6) 尽量少用子组
()
(8) 警惕嵌套子组
(.*)*
(9) 避免分支重叠
在多选分支选择中,要避免不同分支出现相同范围的情况,中心思想与 提取公共部分 差不多
① 优化小实验:Nginx 错误日志匹配
拿早先那条匹配 Nginx error 日志的规则来练手
16187.2
2901.6
2.4 正则书写技巧和常见方案
2.4.1 七个小技巧
通过问题分解,把一个复杂的大问题 拆解为 若干个简单的小问题,常见的小问题
[...]a|b?+*{m,n}/b^$(?<=)(?
(?!\d\d)
2.4.2 通用问题解决方案
(1) 数字
\d[0-9]\d+[0-9]+n\d{n}n\d{m,}m-n\d{m,n}
(2) 正数、负数、小数
[-+]?\d+\.?\d+
示例
(4) 十六进制数
十六进制,除了要匹配 0-9 之外,还要匹配 a-f(或 A-F) 代表 10 到 15 这 6 个数字
[0-9a-fA-F]+
示例
(5) 手机号码
手机号码的正则好写但不好维护,因为时常有新号段加入,所以需要定期维护,就目前而言,我这边使用的是这个
1(?:3\d|4[5-9]|5[0-35-9]|6[2567]|7[0-8]|8\d|9[1389])\d{8}
常见的都覆盖了,如果有新的稍微修改下就行了
(6) 身份证号码
主要就三种变化
- 18 位身份证号
- 15 位身份证号
- 最后是 x 的 18 位身份证号
[1-9]\d{14}(\d\d[0-9xX])?
(7) 邮政编码
邮编一般为 6 位数字,首位非 0,比较简单,可以写成 [1-9]\d{5}
[1-9]\d{5}
(8) QQ 邮箱
目前 QQ 号最长的有 10 位,最短的是 10000 开始,非 0 开头
[1-9][0-9]{4,9}
(9) 中文字符
\p{Han}
In [7]: regex.findall(r'\p{Han}+', '轻轻地我走了')
Out[7]: ['轻轻地我走了']
(10) IPV4 地址
这个最常用,也比较简单
\d{1,3}(\.\d{1,3}){3}
(11) 日期时间
\d{4}-(?:1[0-2]|0?[1-9])-(?:[12]\d|3[01]|0?[1-9])
(12) 邮箱
[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+
2.5 正则表达式 & IDE
一图胜千言,一言以蔽之,选 JetBrains 全家桶!