正则很好用,但不要让它变成谜语
正则表达式适合处理文本模式:校验格式、提取字段、替换内容、扫描日志。Go 标准库提供了 regexp 包,使用起来比较直接。你可以编译一个正则,判断字符串是否匹配,也可以查找子匹配,把日志行拆成结构化数据。
但正则也容易被滥用。一个过长的正则会让代码像密文,后面没人敢改。入门阶段要学的不只是 API,还包括边界:什么时候用正则,什么时候用 strings 包更清楚,正则应该在哪里编译,错误怎么处理。
这篇文章用几个常见场景讲 Go 正则:邮箱粗校验、日志字段提取、手机号脱敏和多空格规范化。同时我们会深入讲解正则的底层实现、性能优化和常见陷阱。
Go 正则语法速查(对比 PCRE)
Go 的 regexp 包使用 RE2 语法引擎,与 PCRE(Perl Compatible Regular Expressions)有一些区别:
| 功能 | RE2 (Go) | PCRE (Perl/Python ) | 说明 |
|---|---|---|---|
| 回溯引用 | 不支持 | \1 \2 | RE2 不支持反向引用 |
| 前瞻断言 | 不支持 | (?=...) (?!...) | RE2 不支持零宽断言 |
| 后瞻断言 | 不支持 | (?<=...) (?<!...) | RE2 不支持 |
| 原子分组 | 不支持 | (?>...) | RE2 不支持 |
| 条件表达式 | 不支持 | (?(...)...|...) | RE2 不支持 |
| Unicode 属性 | 支持 | 支持 | \p{Han} \p{Latin} |
| 命名捕获组 | 支持 | 支持 | (?P<name>...) |
RE2 的设计目标是保证线性时间复杂度,避免灾难性回溯。这也是为什么它不支持回溯引用和前瞻断言的原因。
常用元字符:
package main
import (
"fmt"
"regexp"
)
func main() {
// 基础匹配
patterns := []struct {
pattern string
input string
desc string
}{
{`^hello$`, "hello", "精确匹配 hello"},
{`\d+`, "abc123def", "匹配数字"},
{`[a-z]+`, "HelloWorld", "匹配小写字母"},
{`(?i)^hello$`, "HELLO", "不区分大小写"},
{`\w+@\w+\.\w+`, "test@example.com", "简单邮箱匹配"},
{`^\d{4}-\d{2}-\d{2}$`, "2024-01-15", "日期格式"},
{`https?://\S+`, "Visit https://example.com today", "匹配 URL"},
}
for _, p := range patterns {
re := regexp.MustCompile(p.pattern)
match := re.MatchString(p.input)
fmt.Printf("%-12s %-30s match=%v\n", p.desc, p.input, match)
}
}
regexp Compile/Parse 底层实现
理解正则的底层有助于写出更高效的表达式。Go 的 regexp 包在编译时将正则表达式转换为 NFA(Nondeterministic Finite Automaton),然后在匹配时运行:
package main
import (
"fmt"
"regexp/syntax"
)
func main() {
// 解析正则语法树
expr := `a(b|c)*d`
parsed, err := syntax.Parse(expr, syntax.Perl)
if err != nil {
panic(err)
}
fmt.Println("Parsed expression:")
fmt.Println(parsed.String())
// 简化表达式(优化)
simplified := parsed.Simplify()
fmt.Println("Simplified:")
fmt.Println(simplified.String())
// 编译为正则程序
prog, err := syntax.Compile(simplified)
if err != nil {
panic(err)
}
fmt.Printf("Program has %d instructions\n", len(prog.Inst))
// 输出编译结果
for i, inst := range prog.Inst {
fmt.Printf("Inst %d: %v\n", i, inst)
}
}
编译过程分为三个阶段:
- Parse:将正则字符串解析为抽象语法树(AST)
- Simplify:简化 AST,移除冗余节点
- Compile:将 AST 编译为字节码指令
编译结果可以被重用,这就是为什么预编译正则很重要。
完整 API 使用指南
MatchString - 判断匹配
package main
import (
"fmt"
"regexp"
)
func main() {
// 一次性匹配(适合偶尔使用)
matched, err := regexp.MatchString(`^go`, "golang")
if err != nil {
panic(err)
}
fmt.Println("One-off match:", matched)
// 预编译后匹配(推荐频繁使用)
re := regexp.MustCompile(`^go`)
fmt.Println("Compiled match:", re.MatchString("golang"))
fmt.Println("Compiled match:", re.MatchString("[java](/posts/java/)"))
}
regexp.MatchString 每次都会编译表达式。偶尔用一次没问题,频繁调用时应该先编译。性能对比:
package main
import (
"regexp"
"testing"
)
func BenchmarkMatchStringOneOff(b *testing.B) {
for i := 0; i < b.N; i++ {
regexp.MatchString(`^\d{4}-\d{2}-\d{2}$`, "2024-01-15")
}
}
func BenchmarkMatchStringCompiled(b *testing.B) {
re := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
b.ResetTimer()
for i := 0; i < b.N; i++ {
re.MatchString("2024-01-15")
}
}
// 预期结果:Compiled 版本快约 100-1000 倍
FindAllString - 查找所有匹配
package main
import (
"fmt"
"regexp"
)
func main() {
re := regexp.MustCompile(`\b[A-Z][a-z]+\b`)
text := "John and Mary went to New York City"
matches := re.FindAllString(text, -1)
fmt.Println("All matches:", matches)
// 限制返回数量
firstTwo := re.FindAllString(text, 2)
fmt.Println("First two:", firstTwo)
// 返回匹配位置
locations := re.FindAllStringIndex(text, -1)
fmt.Println("Locations:", locations)
}
FindStringSubmatch - 捕获分组
package main
import (
"fmt"
"regexp"
)
func main() {
// 提取日期各部分
re := regexp.MustCompile(`(\d{4})-(\d{2})-(\d{2})`)
text := "Date: 2024-01-15"
matches := re.FindStringSubmatch(text)
fmt.Println("Full match:", matches[0]) // 2024-01-15
fmt.Println("Year:", matches[1]) // 2024
fmt.Println("Month:", matches[2]) // 01
fmt.Println("Day:", matches[3]) // 15
// 命名捕获组
reNamed := regexp.MustCompile(`(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})`)
matchesNamed := reNamed.FindStringSubmatch(text)
fmt.Println("Named groups:", matchesNamed)
// 获取组名
names := reNamed.SubexpNames()
for i, name := range names {
if name != "" {
fmt.Printf("%s = %s\n", name, matchesNamed[i])
}
}
}
ReplaceAllString - 替换
package main
import (
"fmt"
"regexp"
)
func main() {
// 简单替换
re := regexp.MustCompile(`world`)
result := re.ReplaceAllString("Hello world!", "Go")
fmt.Println(result) // Hello Go!
// 使用分组引用
rePhone := regexp.MustCompile(`(\d{3})\d{4}(\d{4})`)
masked := rePhone.ReplaceAllString("13812345678", "$1****$2")
fmt.Println(masked) // 138****5678
// 使用函数替换
reNum := regexp.MustCompile(`\d+`)
result = reNum.ReplaceAllStringFunc("foo 123 bar 456", func(s string) string {
return "[NUMBER]"
})
fmt.Println(result) // foo [NUMBER] bar [NUMBER]
// 替换为模板
reDate := regexp.MustCompile(`(\d{4})-(\d{2})-(\d{2})`)
reformatted := reDate.ReplaceAllString("2024-01-15", "${2}/${3}/${1}")
fmt.Println(reformatted) // 01/15/2024
}
Split - 字符串分割
package main
import (
"fmt"
"regexp"
)
func main() {
// 按多个分隔符分割
re := regexp.MustCompile(`[\s,;]+`)
parts := re.Split("apple, banana; cherry date", -1)
fmt.Println(parts) // [apple banana cherry date]
// 限制分割数量
limited := re.Split("a,b;c d", 2)
fmt.Println(limited) // [a b;c d]
}
邮箱校验要克制
很多人会找一个特别长的邮箱正则。真实世界的邮箱格式比想象复杂,完全符合标准的正则很难读。业务里通常只需要粗略校验,再依靠邮件验证流程确认。
package main
import (
"fmt"
"regexp"
"strings"
)
var emailPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
func validEmail(email string) bool {
email = strings.TrimSpace(email)
if len(email) > 254 {
return false
}
return emailPattern.MatchString(email)
}
func main() {
tests := []string{
"user@example.com",
"test.email+tag@domain.co.uk",
"123@numeric.com",
"invalid.email",
"@nodomain.com",
"double..dots@example.com",
}
for _, email := range tests {
fmt.Printf("%-30s valid=%v\n", email, validEmail(email))
}
}
这个正则表达的是:基本格式正确(有 @ 符号、域名、点号后缀)。它不追求完美,但对很多注册表单的第一层校验已经够用。
如果业务要求非常严格,最好使用成熟库或发送验证邮件,而不是在正则里堆越来越多规则。正则应该让规则更清楚,而不是把复杂性藏起来。
提取日志字段
日志行:
2020-04-17 19:10:00 GET /api/users 200 150.2
正则:
package main
import (
"fmt"
"regexp"
"strconv"
)
var logPattern = regexp.MustCompile(
`^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+` +
`(\w+)\s+` +
`(\S+)\s+` +
`(\d{3})\s+` +
`([\d.]+)$`)
type AccessLog struct {
Time string
Method string
Path string
Status int
Duration float64
}
func parseLog(line string) (*AccessLog, error) {
matches := logPattern.FindStringSubmatch(line)
if matches == nil {
return nil, fmt.Errorf("invalid log format: %s", line)
}
status, _ := strconv.Atoi(matches[4])
duration, _ := strconv.ParseFloat(matches[5], 64)
return &AccessLog{
Time: matches[1],
Method: matches[2],
Path: matches[3],
Status: status,
Duration: duration,
}, nil
}
func main() {
logLine := "2020-04-17 19:10:00 GET /api/users 200 150.2"
entry, err := parseLog(logLine)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Printf("%+v\n", entry)
}
FindStringSubmatch 返回完整匹配和每个括号捕获的内容。matches[0] 是整行,后面才是分组。使用前要检查 nil,否则不匹配时访问下标会 panic。
手机号脱敏
手机号脱敏:
package main
import (
"fmt"
"regexp"
)
var phonePattern = regexp.MustCompile(`(\d{3})\d{4}(\d{4})`)
func maskPhone(s string) string {
return phonePattern.ReplaceAllString(s, "${1}****${2}")
}
func main() {
fmt.Println(maskPhone("电话 13812345678"))
fmt.Println(maskPhone("联系人: 15987654321"))
fmt.Println(maskPhone("call me at 15011112222 please"))
}
输出:
电话 138****5678
联系人: 159****4321
call me at 150****2222 please
规范化空白
规范化空白:
package main
import (
"fmt"
"regexp"
"strings"
)
var spaces = regexp.MustCompile(`\s+`)
func normalizeSpace(s string) string {
return strings.TrimSpace(spaces.ReplaceAllString(s, " "))
}
func main() {
texts := []string{
" hello world ",
"foo\t\tbar\n\nbaz",
" trim me please ",
}
for _, t := range texts {
fmt.Printf("%q -> %q\n", t, normalizeSpace(t))
}
}
如果只是判断前缀、后缀、包含、简单切分,优先用 strings 包。比如:
strings.HasPrefix(path, "/api/")
strings.Contains(title, "Go")
strings.Split(input, ",")
strings.TrimSpace(input)
这些比正则更清楚,也通常更快。
正则性能优化
正则性能优化的核心策略:
1. 预编译正则
// 反例:每次调用都编译
func badCheck(s string) bool {
matched, _ := regexp.MatchString(`^\d+$`, s)
return matched
}
// 正例:包级预编译
var digitPattern = regexp.MustCompile(`^\d+$`)
func goodCheck(s string) bool {
return digitPattern.MatchString(s)
}
2. 避免过度量词
// 慢:. 匹配任意字符,可能导致大量回溯
var slow = regexp.MustCompile(`.*a.*b.*c`)
// 快:使用具体字符类
var fast = regexp.MustCompile(`[^a]*a[^b]*b[^c]*c`)
3. 使用具体锚点
// 慢:没有锚点,需要尝试每个位置
var slow = regexp.MustCompile(`pattern`)
// 快:^ 和 $ 减少搜索范围
var fast = regexp.MustCompile(`^pattern$`)
4. 字符类优于选择
// 慢:选择
var slow = regexp.MustCompile(`a|b|c|d`)
// 快:字符类
var fast = regexp.MustCompile(`[abcd]`)
regexp/syntax 包的自定义匹配
regexp/syntax 包提供了底层正则语法操作能力:
package main
import (
"fmt"
"regexp/syntax"
)
func main() {
// 使用不同 flags 编译
flags := syntax.Perl ^ syntax.OneLine // 关闭单行模式
re, err := syntax.Parse(`^hello$`, flags)
if err != nil {
panic(err)
}
fmt.Println("Flags used:", flags)
fmt.Println("Parsed:", re.String())
}
常见正则模式速查
package main
import (
"fmt"
"regexp"
)
func main() {
patterns := map[string]string{
"IPv4": `^(\d{1,3}\.){3}\d{1,3}$`,
"HexColor": `^#[0-9A-Fa-f]{6}$`,
"UUID": `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`,
"CreditCard": `^\d{4}-\d{4}-\d{4}-\d{4}$`,
"URL": `^https?://[\w.-]+(?:/[\w./-]*)?$`,
"Username": `^[a-zA-Z0-9_-]{3,16}$`,
"ChineseID": `^\d{6}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$`,
}
for name, pattern := range patterns {
_, err := regexp.Compile(pattern)
status := "OK"
if err != nil {
status = "ERROR: " + err.Error()
}
fmt.Printf("%-12s %s\n", name+":", status)
}
}
与 strings 包的性能对比
package main
import (
"regexp"
"strings"
"testing"
)
func BenchmarkStringsHasPrefix(b *testing.B) {
for i := 0; i < b.N; i++ {
strings.HasPrefix("golang.org", "go")
}
}
func BenchmarkRegexpMatch(b *testing.B) {
re := regexp.MustCompile(`^go`)
b.ResetTimer()
for i := 0; i < b.N; i++ {
re.MatchString("golang.org")
}
}
func BenchmarkStringsContains(b *testing.B) {
for i := 0; i < b.N; i++ {
strings.Contains("hello world", "world")
}
}
func BenchmarkRegexpContains(b *testing.B) {
re := regexp.MustCompile(`world`)
b.ResetTimer()
for i := 0; i < b.N; i++ {
re.MatchString("hello world")
}
}
性能对比结果(典型值):
| 操作 | strings | regexp | 比例 |
|---|---|---|---|
| HasPrefix | 2 ns | 100 ns | 50x |
| Contains | 3 ns | 150 ns | 50x |
| Replace | 50 ns | 500 ns | 10x |
| Split | 100 ns | 1000 ns | 10x |
结论:能用 strings 包完成的操作,不要用正则。正则的解释执行开销远大于简单的字符串操作。
正则选择策略
什么时候用正则,什么时候用 strings:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 前缀/后缀判断 | strings | 更快、更清晰 |
| 包含关系 | strings | 更快、更清晰 |
| 简单替换 | strings | 更快 |
| 固定分隔符分割 | strings | 更快 |
| 复杂模式匹配 | regexp | strings 无法完成 |
| 分组提取 | regexp | strings 无法完成 |
| 复杂替换逻辑 | regexp | strings 难以完成 |
| 输入验证 | regexp | 模式表达力强 |
常见陷阱
陷阱一:MatchString 每次编译
// 错误:每次都重新编译
for _, email := range emails {
matched, _ := regexp.MatchString(`...`, email)
// ...
}
// 正确:先编译
var re = regexp.MustCompile(`...`)
for _, email := range emails {
matched := re.MatchString(email)
// ...
}
陷阱二:MustCompile 在用户输入上使用
// 危险:panic
var re = regexp.MustCompile(userInput) // 如果用户输入不合法,程序 panic
// 安全:返回错误
re, err := regexp.Compile(userInput)
if err != nil {
return fmt.Errorf("invalid pattern: %w", err)
}
陷阱三:FindAllString 参数为 -1 导致内存溢出
// 在超长字符串上查找所有匹配可能会消耗大量内存
re := regexp.MustCompile(`\d`)
matches := re.FindAllString(veryLongString, -1) // 可能数百万个匹配
// 限制返回数量
matches := re.FindAllString(veryLongString, 1000)
陷阱四:没有验证直接访问子匹配
matches := re.FindStringSubmatch(text)
// 危险:如果匹配失败,matches 为 nil,访问下标 panic
name := matches[1]
// 安全:先检查
if matches == nil {
return fmt.Errorf("no match")
}
name := matches[1]
小结
Go 的 regexp 包适合校验、提取和替换文本模式。固定正则可以用 regexp.MustCompile 放在包级变量,用户输入的正则要用 regexp.Compile 返回错误。提取分组时使用 FindStringSubmatch,并检查是否匹配。
核心要点回顾:
- 预编译:频繁使用的正则务必预编译
- strings 优先:能用 strings 包完成的不要用正则
- 克制使用:正则要短、要可读、要有注释
- 安全检查:处理用户输入的正则要返回错误而非 panic
- 性能意识:正则比 strings 慢约 10-50 倍
正则要克制。能用 strings 清楚表达的,就不要上正则;正则太长时,拆成小函数或加注释解释意图;业务校验不要把所有复杂性都压进一个表达式里。
写得好的正则能让文本处理很利落,写得糟的正则会让代码没人敢碰。入门阶段先追求可读和足够准确,再考虑更复杂的模式。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。