Golang 实现“不包含”正则表达式功能

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

需求背景

业务开发中,需要对错误日志进行监控,目前我们的错误日志格式是 error ......,那么,我们如何匹配到错误日志呢?

我们可以通过 ^error 正则表达式来实现。

现在假设我们想实现一个如下功能:对偶然出现的超时问题进行过滤,不匹配到这个日志,那么我们应该如何实现呢?

知识点普及

基本语法

既然我们要通过正则表达式的方式来过滤日志,我们应该对正则表达式中的一些语法有一定了解。

字符 描述
^ 匹配输入字符串的开始位置,除非在方括号表达式中使用,当该符号在方括号表达式中使用时,表示不接受该方括号表达式中的字符集合。
$ 匹配输入字符串的结尾位置。如果设置了 RegExp 对象的 Multline 属性,则 $ 也匹配 \n\r。要匹配 $ 字符本身,请使用 \$
( ) 标记一个子表达式的开始和结束位置。子表达式可以获取供以后使用。要匹配这些字符,请使用 \(\)
* 匹配前面的子表达式零次或多次。要匹配 * 字符,请使用 \*
+ 匹配前面的子表达式一次或多次。要匹配 + 字符,请使用 \+
. 匹配除换行符 \n 之外的任何单字符。要匹配 .,请使用 \.
? 匹配前面的子表达式零次或一次,或指明一个非贪婪限定符。要匹配 ? 字符,请使用 \?
| 指明两项之间的一个选择。要匹配 |,请使用 \ |

零宽断言

该部分内容参考 正则表达式零宽断言详解

适用场景

在介绍零宽断言的概念时,我们先来看一下会用到零宽断言的场景。

有时候我们需要捕获的内容前后必须是特定内容时,但又不捕获这些特定内容的时候,零宽断言就起作用了。

例如:有如下两个字符串:abcdefgbcdefg。我们想要找到前面是 abcde 字符串。这时就会用到了零宽断言。

概念

零宽断言正如它的名字一样,是一种零宽度的匹配,它匹配到的内容不会保存到匹配结果中去,最终匹配结果只是一个位置而已。

作用是给指定位置添加一个限定条件,用来规定此位置之前或者之后的字符串必须 满足限定条件 才能使正则中的子表达式匹配成功。

注意:这里所说的子表达式并非只有用小括号括起来的表达式,而是正则表达式中的任意匹配单元。

str := "abZW863"
pattern := "/ab(?=[A-Z])/"
regexp.MatchString(pattern, str)
复制代码

在以上代码中,正则表达式的语义是:匹配后面跟随任意一个大写字母的字符串 ab,最终匹配结果是 ab,因为零宽断言 (?=[A-Z]) 并不匹配任何字符,知识用来规定当前位置的后面必须是一个大写字母。

str := "abZW863"
pattern := "/ab(?![A-Z])/"
regexp.MatchString(pattern, str)
复制代码

以上代码中,正则表达式的语义是:匹配后面不跟随任意一个大写字母的字符串 ab。正则表达式没能匹配任何字符,因为在字符串中,ab 的后面跟随有大写字母。

零宽断言是用来查找在某些内容(但并不包括这些内容)之前或之后的东西,也就是说它们像 \b^$ 那样用于指定一个位置,这个位置应该用于满足一个条件(即断言)。因此它们也被称为零宽断言。断言用来声明一个应该为真的事实,正则表达式只有当断言为真时才会继续进行匹配。

(?=exp) 也叫 零宽度正预测先行断言,它断言自身出现的位置的后面能匹配表达式 exp。

(?<=exp) 也叫 零宽度正回顾后发断言,它断言自身出现的位置的前面能匹配表达式 exp。

负向零宽断言

如果我们想要的功能是:确保某个字符没有出现,但是不想去匹配他,这应该怎么办呢?就是开头提到的问题。这就是 负向零宽断言

零宽度负预测先行断言 (?!exp) 断言此位置的后面不能匹配表达式 exp。

同理,有了后面的不匹配,就会有前面的不匹配,即 (?<!exp)零宽度负回顾后发断言

总结

  • (?=exp)零宽度正预测先行断言,它断言自身出现的位置的后面能匹配表达式 exp。

  • (?<=exp)零宽度正回顾后发断言,它断言自身出现的位置的前面能匹配表达式 exp。

  • (?!exp)零宽度负预测先行断言,断言此位置的后面不能匹配表达式 exp。

  • (?<!exp)零宽度负回顾后发断言来断言此位置的前面不能匹配表达式 exp。

“不包含”功能实现

通过上面的零宽断言学习,我们知道了如果要实现查询出以 error 开头但又不包含 timeout 的日志,可以使用如下正则表达式:"^error((?!timeout).)*$"

下面我们来逐步分析一下这个正则表达式:

  1. ?!timeout:是零宽度负预测先行断言,断言此位置的后面不能匹配表达式 timeout

  2. (?!timeout).:会向前查找,看看前面是不是没有 timeout 字符串,如果没有(是其它字符串),那么 .(点号)就会匹配这些其它字符。该表达式不会捕获任何的字符,只是判断。

  3. ((?!timeout).)*:表达式 (?!timeout). 只会执行一次,所以,我们将这个表达式用括号包囊成组(group),然后用 *(星号)修饰——匹配 0 次或多次。

正则表达式测试

我们现在来测试一下,这个正则表达式是否可以正常工作。

首先我们来测试一下没有 timeout 的错误日志能否正常匹配:error1 test normal error。如下,我们发现是可以正常匹配到的。

image.png

然后我们再来测试一下包含 timeout 的错误日志能够被过滤:error2 test with timeout err,如下,我们可以发现没有匹配到这条日志,这条日志被过滤掉了。这正好符合我们的需求。

image.png

go 代码实现

那么我们能否在 go 代码中实现这个功能呢。我们来试验一下。


package main

import (
    "fmt"
    "regexp"
)

func main() {

    pattern := "^error((?!timeout).)*$"
    error1 := "error1 test normal err"
    error2 := "error2 test with timeout err"
    match1, err := regexp.MatchString(pattern, error1)
    match2, err := regexp.MatchString(pattern, error2)
    fmt.Printf("match: %v, err: %v\n", match1, err)
    fmt.Printf("match: %v, err: %v\n", match2, err)
}
复制代码

运行结果如下:

match: false, err: error parsing regexp: invalid or unsupported Perl syntax: `(?!`
match: false, err: error parsing regexp: invalid or unsupported Perl syntax: `(?!`
复制代码

我们可以看到,结果抛错了,错误信息为:invalid or unsupported Perl syntax: (?!。这说明 go 是不支持零宽断言的。

通过 文档 我们也可以发现,零宽断言是不支持的。

语法 介绍
(?=re) before text matching re (NOT SUPPORTED)
(?!re) before text not matching re (NOT SUPPORTED)
(?<=re) after text matching re (NOT SUPPORTED)
(?<!re) after text not matching re (NOT SUPPORTED)

那么该怎么办呢?别慌,前辈们已经造好轮子了,那就是 regexp

关于 regexp 的功能,其中一段介绍如下:

Regexp2 is a feature-rich RegExp engine for Go. It doesn't have constant time guarantees like the built-in regexp package, but it allows backtracking and is compatible with Perl5 and .NET. You'll likely be better off with the RE2 engine from the regexp package and should only use this if you need to write very complex patterns or require compatibility with .NET.

Regexp2 是 Go 的一个功能丰富的 RegExp 引擎。它不像内置的 regexp 包那样有固定的时间保证,但是它允许回溯,并且与 Perl5 和 .net 兼容。使用 regexp 包中的 RE2 引擎可能会更好,只有在需要编写非常复杂的模式或需要与 .net 兼容时才应该使用它。

关于 regexp 和 regexp2 的部分比较如下:

Category regexp regexp2
Catastrophic backtracking possible no, constant execution time guarantees yes, if your pattern is at risk you can use the re.MatchTimeout field
Python-style capture groups (?P<name>re) yes no (yes in RE2 compat mode)
.NET-style capture groups (?<name>re) or (?'name're) no yes
comments (?#comment) no yes
branch numbering reset `(? a b)`
possessive match (?>re) no yes
positive lookahead (?=re) no yes
negative lookahead (?!re) no yes
positive lookbehind (?<=re) no yes
negative lookbehind (?<!re) no yes
back reference \1 no yes
named back reference \k'name' no yes
named ascii character class [[:foo:]] yes no (yes in RE2 compat mode)
conditionals (?(expr)yes|no) no yes

从上面关于 regexp2 的介绍和同 regexp 的比较我们可以发现,regexp2 是支持零宽断言功能的。那么我们按照文档来试一试。


package main

import (
    "fmt"
    "github.com/dlclark/regexp2"
    "regexp"
)

func main() {

    pattern := "^error((?!timeout).)*$"
    error1 := "error1 test normal err"
    error2 := "error2 test with timeout err"
    match1, err := regexp.MatchString(pattern, error1)
    match2, err := regexp.MatchString(pattern, error2)
    fmt.Printf("match1: %v, err: %v\n", match1, err)
    fmt.Printf("match2: %v, err: %v\n", match2, err)
    reg, err := regexp2.Compile(pattern, 0)
    if err != nil {
        fmt.Printf("reg: %v, err: %v\n", reg, err)
        return
    }

    match3, err := reg.FindStringMatch(error1)
    match4, err := reg.FindStringMatch(error2)
    fmt.Printf("match3: %v, err: %v\n", match3, err)
    fmt.Printf("match4: %v, err: %v\n", match4, err)
}
复制代码

运行结果如下:


match1: false, err: error parsing regexp: invalid or unsupported Perl syntax: `(?!`
match2: false, err: error parsing regexp: invalid or unsupported Perl syntax: `(?!`
match3: error1 test normal err, err: <nil>
match4: <nil>, err: <nil>
复制代码

可以看到 match3 是正常匹配到的。测试成功。

结语

从上面我们可以看到,regexp2 的功能还是很强大的,如果我们需要实现复杂的正则表达式,推荐使用。

但是有一点我们需要注意:那就是 regexp2 的时间复杂度无法保证。从 It doesn't have constant time guarantees like the built-in regexp package 中我们也可以看到,他不像官方包会保证确定的时间复杂度。因此,在生产环境中使用时,一定要慎重!!!!

参考文档

猜你喜欢

转载自juejin.im/post/7016886978540994567