Go 入门:regexp 能做什么,不能替你做什么

从输入清洗、提取字段和简单校验出发,讲 Go regexp 的常用写法、预编译、命名分组和边界意识。

正则表达式很容易让人又爱又怕。它写得好,可以快速从文本里找出订单号、清理多余空白、判断一个字段是否像手机号;写得太贪,就会变成没人敢改的一串符号。Go 的 regexp 包使用 RE2 语法,避免了某些语言里灾难性回溯的问题,但这不代表正则可以随便写。

入门阶段可以把正则当成一个工具:适合做格式匹配、简单提取和文本替换;不适合解析复杂语法,也不适合承担所有业务校验。

最简单的匹配

判断字符串是否包含订单号:

package main

import (
	"fmt"
	"regexp"
)

func main() {
	re := regexp.MustCompile(`ORD-\d{6}`)
	fmt.Println(re.MatchString("pay order ORD-102938 today"))
}

MustCompile 会在正则写错时 panic。它适合包级变量或程序启动阶段,因为正则是开发者写死的,错了应该尽早暴露。如果正则来自用户输入,就要用 regexp.Compile,把错误返回给用户。

预编译正则

不要在高频循环里反复编译正则:

var orderIDRE = regexp.MustCompile(`^ORD-\d{6}$`)

func validOrderID(s string) bool {
	return orderIDRE.MatchString(s)
}

编译正则有成本。把它放到包级变量,逻辑更清楚,性能也更稳。这里使用 ^$ 限定整段字符串,否则 xxxORD-123456yyy 也会匹配成功。入门时很多校验漏洞都来自忘记锚点。

清理多余空白

用户复制文本时经常带着多个空格、换行和制表符。可以用正则压缩空白:

var spacesRE = regexp.MustCompile(`\s+`)

func normalizeSpace(s string) string {
	s = strings.TrimSpace(s)
	return spacesRE.ReplaceAllString(s, " ")
}

这个函数适合搜索关键词、备注、标题清洗。但它不适合密码,因为密码里的空格可能是用户有意输入的。清洗规则要看字段语义,不能因为“看起来整洁”就改掉用户输入。

提取字段

从一行日志里提取状态码:

var statusRE = regexp.MustCompile(`status=(\d{3})`)

func extractStatus(line string) (int, bool) {
	m := statusRE.FindStringSubmatch(line)
	if len(m) != 2 {
		return 0, false
	}
	n, err := strconv.Atoi(m[1])
	if err != nil {
		return 0, false
	}
	return n, true
}

FindStringSubmatch 的第 0 个元素是完整匹配,后面才是括号捕获。每次取下标前都要检查长度。很多 panic 都是因为假设输入一定匹配。

命名分组

字段多时,可以用命名分组提高可读性:

var lineRE = regexp.MustCompile(`user=(?P<user>\w+) action=(?P<action>\w+)`)

func parseLine(s string) map[string]string {
	matches := lineRE.FindStringSubmatch(s)
	if matches == nil {
		return nil
	}
	names := lineRE.SubexpNames()
	out := make(map[string]string)
	for i, name := range names {
		if i == 0 || name == "" {
			continue
		}
		out[name] = matches[i]
	}
	return out
}

这段代码比硬记 matches[1]matches[2] 更清楚。不过如果日志格式可以由你控制,结构化 JSON 日志通常比正则解析更稳。正则适合补救已有文本,不一定是新系统的首选格式。

校验手机号的边界

很多教程会写手机号正则,但现实里手机号规则会变。一个简单校验可以这样:

var cnMobileRE = regexp.MustCompile(`^1[3-9]\d{9}$`)

func validCNMobile(s string) bool {
	s = strings.TrimSpace(s)
	return cnMobileRE.MatchString(s)
}

这只能判断“看起来像中国大陆手机号”,不能证明号码真实存在,也不能证明号码属于当前用户。注册、登录、找回密码都应该结合短信验证码或其他校验。正则只负责格式,不负责身份。

正则不是解析器

如果你要解析 URL、JSON、HTML、SQL,不要自己写正则。Go 标准库已经有对应工具:

u, err := url.Parse("https://example.com/search?q=go")
if err != nil {
	return err
}
fmt.Println(u.Host, u.Query().Get("q"))

用正则解析 URL 可能一开始能跑,遇到编码、端口、IPv6、查询参数里的特殊字符就会出错。入门阶段要养成习惯:结构化格式用结构化解析器,正则只处理局部文本模式。

替换敏感信息

日志里有时需要脱敏:

var emailRE = regexp.MustCompile(`([a-zA-Z0-9._%+\-])[a-zA-Z0-9._%+\-]*(@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})`)

func maskEmail(s string) string {
	return emailRE.ReplaceAllString(s, `$1***$2`)
}

这个函数会把 alice@example.com 变成 a***@example.com。它适合降低日志暴露风险,但不要把它当成合规的全部。真正敏感的字段最好从源头不打印,脱敏只是最后一道防线。

错误信息也要可读

正则校验失败时,不要把正则原文丢给用户:

if !validOrderID(input) {
	return fmt.Errorf("订单号格式应类似 ORD-123456")
}

业务错误应该告诉用户怎么改,而不是展示 ^ORD-\d{6}$。正则是程序员之间的表达,不是所有用户都能理解的说明。

先 Trim 再匹配

很多校验函数应该先处理首尾空白,再做正则匹配。用户从表格复制数据时,末尾多一个空格很常见。你要根据业务决定这是可接受的输入噪音,还是应该严格拒绝。

func validCode(s string) bool {
	s = strings.TrimSpace(s)
	return regexp.MustCompile(`^[A-Z]{3}-\d{4}$`).MatchString(s)
}

上面为了展示写在函数里,真实代码仍然应该把正则预编译成包级变量。清洗和校验要分清:TrimSpace 是清洗,正则是校验。如果字段是备注,可以清洗得宽松;如果字段是签名串或密钥,就不要自动改动。

分阶段处理更容易维护

一条超长正则能解决很多问题,也能制造很多问题。比如解析 key=value 列表时,与其写一个复杂表达式,不如先按空格切分,再对每一段做小正则校验。

var pairRE = regexp.MustCompile(`^([a-z_]+)=([^ ]+)$`)

func parsePairs(s string) map[string]string {
	out := make(map[string]string)
	for _, part := range strings.Fields(s) {
		m := pairRE.FindStringSubmatch(part)
		if len(m) != 3 {
			continue
		}
		out[m[1]] = m[2]
	}
	return out
}

分阶段处理的好处是每一步都能单独测试。正则越短,下一位维护者越敢改。工程代码不是正则竞赛,可读性通常比一行写完更重要。

小结

Go 的 regexp 包适合做格式匹配、简单提取、清洗和替换。常用正则应该预编译,校验类正则要加 ^$,提取结果要检查长度,用户输入的正则要处理编译错误。

更重要的是边界意识:正则只证明文本符合某种形状,不证明业务事实成立;结构化数据要用对应解析器;敏感信息最好从源头不写日志。把正则当成合适大小的工具,它会很好用;把它当成万能解析器,维护成本会越来越高。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页