正则表达式很容易让人又爱又怕。它写得好,可以快速从文本里找出订单号、清理多余空白、判断一个字段是否像手机号;写得太贪,就会变成没人敢改的一串符号。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 包适合做格式匹配、简单提取、清洗和替换。常用正则应该预编译,校验类正则要加 ^ 和 $,提取结果要检查长度,用户输入的正则要处理编译错误。
更重要的是边界意识:正则只证明文本符合某种形状,不证明业务事实成立;结构化数据要用对应解析器;敏感信息最好从源头不写日志。把正则当成合适大小的工具,它会很好用;把它当成万能解析器,维护成本会越来越高。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。