使用 Go regexp 包进行模式匹配与文本处理:从表单校验到爬虫过滤的完整指南

原创2026-10-05 23:58:411,330 阅读
文章标签:文档教程

使用 Go regexp 包进行模式匹配与文本处理:从表单校验到爬虫过滤的完整指南

导读

本文围绕 Go 标准库 regexp 包展开,系统讲解其在 Web 开发中最常见的两类用途:输入校验(Match) 与 文本提取/清洗(Filter)。你会掌握 MatchString、Compile/MustCompile、Find* 系列查找方法、ReplaceAll* 替换方法以及 Expand 模板展开的完整用法,并了解 Go 所采用的 RE2 语法约束与 POSIX 模式差异。文中所有代码示例均可直接运行,且与仓库中 表单校验器 和 Beego 路由实现(见 13.2 节)相互印证,帮助你写出既正确又高效的正则代码。

为什么在 Web 开发中使用 Regexp

正则表达式(Regular Expressions)是一套复杂但极为强大的模式匹配与文本操作工具。虽然它在性能上不如纯文本匹配(例如 strings 包的 Contains、Index),但胜在灵活性:基于它的语法,几乎可以从任意源文本中筛选出想要的内容。在 Web 开发中,无论是校验用户输入、还是从抓取到的页面里提取有意义的数据,用正则表达式都不难做到。

Go 通过标准库 regexp 提供官方支持。如果你在其他语言中使用过正则,那么对它的基本概念不会陌生,但必须注意一个关键差异:Go 实现的是 RE2 标准(除 \C 外)。RE2 采用线性时间算法,避免了回溯导致的灾难性性能问题,这也意味着 Go 正则不支持反向引用(backreference) 等依赖回溯的特性,具体语法细节可查阅 RE2 官方语法文档。

先考虑 strings 包:简单任务用简单工具

Go 的 strings 包实际上已经能完成许多工作:

  • 查找:Contains、Index
  • 替换:Replace
  • 解析:Split、Join

而且 strings 包的这些操作比 Regexp 更快。但它们都属于"琐碎操作"——例如,若要做大小写不敏感的查找,strings 包就无能为力,此时 Regexp 是最佳选择。

实践原则很简单:

  1. 如果 strings 包能满足需求,就直接用它,因为它易用、易读、性能好;
  2. 只有需要更高级的操作(复杂匹配、分组提取、批量替换)时,才使用 Regexp。

回顾本书 4.2 节 表单验证 的内容,我们正是用 Regexp 来校验用户输入信息的合法性。另需注意:Go 正则中所有字符都按 UTF-8 处理,中文等非 ASCII 字符也可以直接参与匹配。

匹配(Match):校验"是否符合"

regexp 包提供了 3 个用于匹配的顶层函数,它们判断输入是否匹配某个模式,匹配则返回 true,否则返回 false:

func Match(pattern string, b []byte) (matched bool, error error)
func MatchReader(pattern string, r io.RuneReader) (matched bool, error error)
func MatchString(pattern string, s string) (matched bool, error error)

三个函数做的事完全一致:检查 pattern 是否匹配输入源。唯一的区别是输入源类型不同——分别是 []byte 切片、io.RuneReader 和 string。如果正则本身存在语法错误,它们会返回 error。

示例 1:验证 IP 地址

func IsIP(ip string) (b bool) {
	if m, _ := regexp.MatchString("^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$", ip); !m {
		return false
	}
	return true
}

要点:^ 与 $ 锚定整个字符串,[0-9]{1,3} 匹配 1 到 3 位数字,\\. 匹配字面量点号(在 Go 字符串字面量中 \\. 转义为正则里的 \.)。注意此例只校验了数字段数量与长度,并未检查每段是否在 0~255 范围内,实际生产中可以进一步收紧。

示例 2:判断命令行参数是否为数字

func main() {
	if len(os.Args) == 1 {
		fmt.Println("Usage: regexp [string]")
		os.Exit(1)
	} else if m, _ := regexp.MatchString("^[0-9]+$", os.Args[1]); m {
		fmt.Println("Number")
	} else {
		fmt.Println("Not number")
	}
}

运行方式:go run main.go 123 输出 Number,go run main.go abc 输出 Not number。

仓库印证:表单校验器中的真实用法

本书 4.2 节 给出了 Web 表单场景下的经典校验正则:

// 校验年龄字段必须是数字
if m, _ := regexp.MatchString("^[0-9]+$", r.Form.Get("age")); !m {
	return false
}

// 校验中文姓名(使用 Unicode 码位范围)
if m, _ := regexp.MatchString("^[\\x{4e00}-\\x{9fa5}]+$", r.Form.Get("realname")); !m {
	return false
}

// 校验英文姓名只含字母
if m, _ := regexp.MatchString("^[a-zA-Z]+$", r.Form.Get("engname")); !m {
	return false
}

// 校验邮箱格式
if m, _ := regexp.MatchString(`^([\w\.\_]{2,10})@(\w{1,}).([a-z]{2,4})$`, r.Form.Get("email")); !m {
	fmt.Println("no")
} else {
	fmt.Println("yes")
}

而在仓库的 表单校验器实现 中,可以看到生产级封装:checkChineseName 用 regexp.MatchString("^<a href="https://link.gitcode.com/i/02aa1a0efe826dc6171d4a7ea8bda954" target="_blank">\\x{4e00}-\\x{9fa5}]+$", ...) 校验中文名([main.go#L90-L97),checkEmail 用 `^<a href="https://link.gitcode.com/i/5376fadf99da151850d8b88388014e44" target="_blank">^@]+@[^@]+$` 校验邮箱([main.go#L121-L127),并以"参数名 → 校验函数"的 map 驱动整张表单的循环校验。这说明 Match 类函数是 Web 表单服务端校验的第一道防线。

以上几个例子都直接使用 Match/MatchString 判断内容是否合法,使用起来非常简单。但匹配模式只能验证内容,无法对内容进行截取、过滤或收集——要做到后者,必须使用 Regexp 的"复杂模式"(先编译再操作)。

过滤(Filter):编译正则并提取数据

爬虫示例:用 Regexp 清洗 HTML

假设我们要写一个爬虫,抓取网页正文。HTML 源码中夹杂着标签、<style> 与 <script> 块,这时就必须用 Regexp 来过滤和裁剪数据:

package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"regexp"
	"strings"
)

func main() {
	resp, err := http.Get("http://www.baidu.com")
	if err != nil {
		fmt.Println("http get error.")
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		fmt.Println("http read error")
		return
	}

	src := string(body)

	// Convert HTML tags to lower case.
	re, _ := regexp.Compile("\\<[\\S\\s]+?\\>")
	src = re.ReplaceAllStringFunc(src, strings.ToLower)

	// Remove STYLE.
	re, _ = regexp.Compile("\\<style[\\S\\s]+?\\</style\\>")
	src = re.ReplaceAllString(src, "")

	// Remove SCRIPT.
	re, _ = regexp.Compile("\\<script[\\S\\s]+?\\</script\\>")
	src = re.ReplaceAllString(src, "")

	// Remove all HTML code in angle brackets, and replace with newline.
	re, _ = regexp.Compile("\\<[\\S\\s]+?\\>")
	src = re.ReplaceAllString(src, "\n")

	// Remove continuous newline.
	re, _ = regexp.Compile("\\s{2,}")
	src = re.ReplaceAllString(src, "\n")

	fmt.Println(strings.TrimSpace(src))
}

处理管线逐层递进,值得仔细拆解:

步骤 正则 作用
1 \<[\S\s]+?\> 将全部标签转小写(ReplaceAllStringFunc 配合 strings.ToLower),便于后续统一识别
2 \<style[\S\s]+?\</style\> 删除 <style> 块
3 \<script[\S\s]+?\</script\> 删除 <script> 块
4 \<[\S\s]+?\> 删除剩余所有标签,替换为换行符
5 \s{2,} 将连续空白折叠为单个换行,最后 strings.TrimSpace 收尾

这里 [\S\s] 匹配"任意非空白或空白",等价于"任意字符";+? 是非贪婪量词,使匹配在遇到第一个 > 时停止,避免一次吞掉多个标签。

编译函数族:Compile 与 MustCompile

上面示例中 regexp.Compile 是进入"复杂模式"的第一步:它验证正则语法是否正确,然后返回一个可复用的 *regexp.Regexp 对象,供后续各种操作反复使用。

func Compile(expr string) (*Regexp, error)
func CompilePOSIX(expr string) (*Regexp, error)
func MustCompile(str string) *Regexp
func MustCompilePOSIX(str string) *Regexp

四个函数的区别非常关键:

  • Compile vs CompilePOSIX:CompilePOSIX 必须遵循 POSIX 语法,采用**最左最长(leftmost-longest)匹配;Compile 采用最左(leftmost)**匹配。例如对正则 [a-z]{2,4} 与内容 "aa09aaa88aaaa":CompilePOSIX 返回 aaaa,而 Compile 返回 aa——因为最左匹配在遇到 aa 时即可满足,而 POSIX 的"最长"要求会继续延伸。
  • Must 前缀:表示当正则语法不正确时直接 panic,而不是返回 error。适合在程序启动阶段就固化、不容出错的正则(如路由规则、全局常量)。

Find 系列:18 个方法的本质是 8 个

*regexp.Regexp 提供了大量查找方法,它们针对字节切片([]byte)、字符串(string)和 io.RuneReader 三种输入源分别提供同名变体,共 18 个:

func (re *Regexp) Find(b []byte) []byte
func (re *Regexp) FindAll(b []byte, n int) [][]byte
func (re *Regexp) FindAllIndex(b []byte, n int) [][]int
func (re *Regexp) FindAllString(s string, n int) []string
func (re *Regexp) FindAllStringIndex(s string, n int) [][]int
func (re *Regexp) FindAllStringSubmatch(s string, n int) [][]string
func (re *Regexp) FindAllStringSubmatchIndex(s string, n int) [][]int
func (re *Regexp) FindAllSubmatch(b []byte, n int) [][][]byte
func (re *Regexp) FindAllSubmatchIndex(b []byte, n int) [][]int
func (re *Regexp) FindIndex(b []byte) (loc []int)
func (re *Regexp) FindReaderIndex(r io.RuneReader) (loc []int)
func (re *Regexp) FindReaderSubmatchIndex(r io.RuneReader) []int
func (re *Regexp) FindString(s string) string
func (re *Regexp) FindStringIndex(s string) (loc []int)
func (re *Regexp) FindStringSubmatch(s string) []string
func (re *Regexp) FindStringSubmatchIndex(s string) []int
func (re *Regexp) FindSubmatch(b []byte) [][]byte
func (re *Regexp) FindSubmatchIndex(b []byte) []int

忽略输入源差异后,核心能力可归结为 8 个方法:

func (re *Regexp) Find(b []byte) []byte          // 第一个匹配的内容
func (re *Regexp) FindAll(b []byte, n int) [][]byte  // 所有匹配(n<0 表示全部)
func (re *Regexp) FindAllIndex(b []byte, n int) [][]int // 所有匹配的起止下标
func (re *Regexp) FindAllSubmatch(b []byte, n int) [][][]byte // 所有匹配及分组
func (re *Regexp) FindAllSubmatchIndex(b []byte, n int) [][]int // 所有匹配及分组的下标
func (re *Regexp) FindIndex(b []byte) (loc []int) // 第一个匹配的起止下标
func (re *Regexp) FindSubmatch(b []byte) [][]byte // 第一个匹配及捕获分组
func (re *Regexp) FindSubmatchIndex(b []byte) []int // 第一个匹配及分组的下标

命名规则一目了然:带 All 的返回所有匹配(n 为负数表示全部,为正数表示最多 n 个);带 Index 的返回 [start, end) 形式的字节位置;带 Submatch 的额外返回括号捕获组的内容;Find 族返回实际内容,Index 族返回位置。

综合示例:一次看懂 Find 全家族

package main

import (
	"fmt"
	"regexp"
)

func main() {
	a := "I am learning Go language"

	re, _ := regexp.Compile("[a-z]{2,4}")

	// 第一个匹配
	one := re.Find([]byte(a))
	fmt.Println("Find:", string(one))

	// 所有匹配存入切片,n 小于 0 表示返回全部;n 大于 0 表示切片长度
	all := re.FindAll([]byte(a), -1)
	fmt.Println("FindAll", all)

	// 第一个匹配的起止下标
	index := re.FindIndex([]byte(a))
	fmt.Println("FindIndex", index)

	// 所有匹配的下标,n 的作用同上
	allindex := re.FindAllIndex([]byte(a), -1)
	fmt.Println("FindAllIndex", allindex)

	re2, _ := regexp.Compile("am(.*)lang(.*)")

	// 第一个子匹配返回数组:首元素是完整匹配,
	// 第二个元素是第一个 () 的结果,第三个元素是第二个 () 的结果
	// 输出:
	// 第一个元素: "am learning Go language"
	// 第二个元素: " learning Go "(注意空格也会输出)
	// 第三个元素: "uage"
	submatch := re2.FindSubmatch([]byte(a))
	fmt.Println("FindSubmatch", submatch)
	for _, v := range submatch {
		fmt.Println(string(v))
	}

	// 与 FindIndex() 类似,但包含每个分组的下标
	submatchindex := re2.FindSubmatchIndex([]byte(a))
	fmt.Println(submatchindex)

	// 所有子匹配
	submatchall := re2.FindAllSubmatch([]byte(a), -1)
	fmt.Println(submatchall)

	// 所有子匹配的下标
	submatchallindex := re2.FindAllSubmatchIndex([]byte(a), -1)
	fmt.Println(submatchallindex)
}

对 re2 而言,FindSubmatch 返回的数组语义是:[0] 为完整匹配 "am learning Go language",[1] 为第一个括号组 " learning Go "(注意保留了两侧空格),[2] 为第二个括号组 "uage"。而 FindSubmatchIndex 返回的整数序列则以 [完整匹配start, 完整匹配end, 组1start, 组1end, 组2start, 组2end] 的形式给出所有位置。

Match 方法:顶层函数的底层实现

前面提到 Regexp 也有 3 个匹配方法,它们与顶层函数功能完全一致。事实上,那些导出的顶层函数内部就是调用这些方法:

func (re *Regexp) Match(b []byte) bool
func (re *Regexp) MatchReader(r io.RuneReader) bool
func (re *Regexp) MatchString(s string) bool

因此在需要重复匹配的场景中,先 Compile 一次、再反复调用 Match 方法,比每次都调用顶层 MatchString 更高效(省去重复解析正则的开销)。

仓库印证:Beego 路由中的正则编译

本书 13.2 节 展示的 Beego 风格路由实现,是把 regexp.Compile 用于 Web 框架底层的好例子。路由存储结构直接内嵌编译后的正则:

type controllerInfo struct {
	regex          *regexp.Regexp
	params         map[int]string
	controllerType reflect.Type
}

Add(pattern, c) 在注册路由时,会把形如 /user/:id(<a href="https://link.gitcode.com/i/e2099bd63621625078891743ebc4065d" target="_blank">0-9]+) 的路径中 :参数 替换为 ([^/]+) 或用户自定义的正则片段,然后用 regexp.Compile(pattern) 编译并保存在 controllerInfo.regex 中([13.2.md):

// 路由模式中的 :param 被替换为正则表达式后统一编译
pattern = strings.Join(parts, "/")
regex, regexErr := regexp.Compile(pattern)
if regexErr != nil {
	panic(regexErr)
	return
}

注意这里对编译错误直接 panic——因为路由规则是开发者启动期写死的静态代码,若语法错误应在进程启动时立刻暴露,而不是运行期才报错。这与 MustCompile 的设计哲学一致。请求到达时再通过 regex.MatchString(r.URL.Path) 之类的方式完成动态路由匹配。这从框架层面印证了:正则编译一次、匹配多次,是 Web 开发中的最佳实践。

替换(Replace):批量改写文本

*regexp.Regexp 提供 6 个替换方法:

func (re *Regexp) ReplaceAll(src, repl []byte) []byte
func (re *Regexp) ReplaceAllFunc(src []byte, repl func([]byte) []byte) []byte
func (re *Regexp) ReplaceAllLiteral(src, repl []byte) []byte
func (re *Regexp) ReplaceAllLiteralString(src, repl string) string
func (re *Regexp) ReplaceAllString(src, repl string) string
func (re *Regexp) ReplaceAllStringFunc(src string, repl func(string) string) string

各方法语义:

  • ReplaceAll / ReplaceAllString:将 src 中所有匹配替换为 repl。注意 repl 中若包含 $1、$name 形式的引用,会被展开为对应的捕获分组。
  • ReplaceAllLiteral / ReplaceAllLiteralString:repl 被当作纯字面量,$ 不做任何展开。若替换文本本身含 $ 字符(如价格、模板代码),用这类方法可避免意外展开。
  • ReplaceAllFunc / ReplaceAllStringFunc:对每个匹配调用回调函数,用回调返回值替换。爬虫示例中 re.ReplaceAllStringFunc(src, strings.ToLower) 正是用它把每个 HTML 标签转小写。

这些方法在前面的爬虫示例中已经得到充分应用,这里不再赘述。

展开(Expand):按模板重组捕获组

Expand 是 regexp 包中非常实用但容易被忽略的功能,它让你用模板字符串 + 捕获组的方式重构文本:

func (re *Regexp) Expand(dst []byte, template []byte, src []byte, match []int) []byte
func (re *Regexp) ExpandString(dst []byte, template string, src string, match []int) []byte

使用示例:

func main() {
	src := []byte(`
		call hello alice
		hello bob
		call hello eve
	`)
	pat := regexp.MustCompile(`(?m)(call)\s+(?P<cmd>\w+)\s+(?P<arg>.+)\s*$`)
	res := []byte{}
	for _, s := range pat.FindAllSubmatchIndex(src, -1) {
		res = pat.Expand(res, []byte("$cmd('$arg')\n"), src, s)
	}
	fmt.Println(string(res))
}

运行输出:

hello('alice')
hello('eve')

理解这段代码的三个关键点:

  1. 命名捕获组:(?P<cmd>\w+)、(?P<arg>.+) 给括号组起了名字,模板中即可用 $cmd、$arg 引用,比 $1、$2 可读性更强;
  2. (?m) 多行模式:让 $ 匹配每一行的行尾,配合 \s*$ 逐行扫描;
  3. 工作流程:先用 FindAllSubmatchIndex 拿到所有匹配及其分组的起止下标,再把每个 match 切片连同模板交给 Expand——Expand 根据下标从 src 中切出对应分组内容填入模板,追加写入 dst。这样"先定位、后重组"的两步法,正是数据抽取流水线的通用模式。

实战决策清单

至此,Go regexp 包的核心能力已全部覆盖。在实际 Web 开发中,可以按以下清单决策:

  1. 简单查找/替换:优先用 strings 包的 Contains、Index、Replace、Split、Join——性能更好、代码更易读;
  2. 需要大小写不敏感或复杂模式:用 regexp;
  3. 一次性校验:直接调用 MatchString/Match/MatchReader;
  4. 多次使用同一正则:先 Compile 一次,之后反复调用其方法;
  5. 启动期固化、语法错误必须立刻暴露:用 MustCompile/MustCompilePOSIX;
  6. 要求最长匹配(POSIX 语义):用 CompilePOSIX;
  7. 提取内容与捕获组:Find/FindAll/FindSubmatch 系列按需取用;
  8. 批量改写文本:ReplaceAll 系列,注意 Literal 变体可避免 $ 展开;
  9. 按模板重组分组:Expand + FindAllSubmatchIndex 两步法;
  10. 记住 RE2 约束:Go 正则基于 RE2(除 \C 外),不支持反向引用等回溯特性,所有字符按 UTF-8 处理——中文校验请使用 [\x{4e00}-\x{9fa5}] 这类 Unicode 范围写法。

相关章节延伸阅读:4.2 表单验证、7.2 JSON 处理、7.4 模板引擎、13.2 Beego 路由实现;完整校验器实现见 表单校验器源码。

登录后查看全文
build-web-application-with-golang