Go 语言 regexp 正则处理实战指南:从匹配判断到爬虫文本提取(build-web-application-with-golang 第 7.3 节)

原创2026-10-06 21:07:401,020 阅读
文章标签:文档教程

Go 语言 regexp 正则处理实战指南:从匹配判断到爬虫文本提取(build-web-application-with-golang 第 7.3 节)

正则表达式(Regular Expression)是 Go Web 开发中处理文本校验与提取的核心工具:在服务端验证用户输入是否合法、从抓取的 HTML 页面中过滤标签、抽取结构化信息,都需要依靠 regexp 标准库。本篇以本仓库《build-web-application-with-golang》第 7.3 节为基础,系统讲解 Go 语言 regexp 包的匹配判断、编译解析、查找、替换与 Expand 展开等全部核心 API,并结合仓库中的表单验证源码给出可直接落地的实战示例。读完本文,你将能熟练使用 Go 正则完成「判断是否匹配」与「提取/过滤内容」两大类任务。

1. 正则与 Go 的 regexp 包

正则表达式是一种进行模式匹配和文本操作的复杂而强大的工具。虽然正则表达式比纯粹的文字匹配效率低,但是它却更灵活——按照语法规则随时构造出的匹配模式,就能从原始文本中筛选出几乎任何你想要的字符组合。在 Web 开发中,当你需要从文本数据源中取数据时,只需按照语法规则构造出正确的模式字符串,就能从原始数据源提取出有意义的文本信息。

Go 语言通过 regexp 标准包为正则表达式提供了官方支持。如果你已经使用过其他语言提供的正则功能,那么对 Go 版本不会太陌生,但它们之间也有一些小的差异:Go 实现的是 RE2 标准(除 \C 外),RE2 采用线性时间算法,避免了回溯导致的最坏情况灾难性性能问题,这也是 Go 正则在处理不可信输入时更安全的原因。关于 RE2 的详细语法描述可参考官方 Syntax 文档。

1.1 strings 包能解决的,优先用 strings

其实字符串处理我们还可以使用 strings 包进行搜索(Contains、Index)、替换(Replace)和解析(Split、Join)等操作,但是这些都属于简单的字符串操作——它们的搜索区分大小写,而且匹配的是固定字符串。如果我们需要匹配可变内容(如任意数字、任意长度的字符段),strings 包就无法实现了。

当然,如果 strings 包能解决你的问题,就尽量使用它:它们足够简单,而且性能和可读性都会优于正则。

1.2 与表单验证小节的衔接

如果你还记得,在前面表单验证小节中我们已经接触过正则处理——在那里利用它验证输入信息是否满足预设条件(如年龄是否为数字、姓名是否全为中文、邮箱格式是否正确)。使用中需要注意的一点是:所有字符都是 UTF-8 编码的。仓库中对应源码 validator/main.go 正是这一应用的完整实现,例如校验中文姓名与邮箱:

// 校验中文名:只允许 \x{4e00}-\x{9fa5} 区间内的汉字
if m, _ := regexp.MatchString("^[\\x{4e00}-\\x{9fa5}]+$", strings.Trim(str, " ")); !m {
    return errors.New("Please make sure that the chinese name only contains chinese characters.")
}

// 校验邮箱:非空字符 @ 非空字符
if m, err := regexp.MatchString(`^[^@]+@[^@]+$`, str); !m {
    return errors.New("Please enter a valid email address.")
}

可以看到,regexp.MatchString 正是表单服务端校验中最常用、最直接的入口。接下来让我们更深入地学习 Go 语言 regexp 包的相关知识。

2. 通过正则判断是否匹配

regexp 包中含有三个函数用来判断是否匹配,如果匹配则返回 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 是否与输入源匹配,匹配就返回 true;如果解析正则出错则返回 error。三个函数的输入源分别是 byte slice、RuneReader 和 string。

2.1 实战:判断 IP 地址

如果要验证一个输入是不是 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
}

可以看到,regexp 的 pattern 和我们平常使用的正则一模一样:^ 与 $ 锚定整个字符串,[0-9]{1,3} 匹配 1 到 3 位数字,\\. 转义匹配字面点号。

2.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("數字")
	} else {
		fmt.Println("不是數字")
	}
}

该程序从命令行参数 os.Args[1] 取输入,用 ^[0-9]+$ 判断是否全部由数字组成。在上面的两个小例子中,我们采用了 Match(String) 来判断一些字符串是否符合我们的描述需求,它们使用起来非常方便。在仓库的表单验证场景中,判断年龄是否为数字也采用了完全相同的模式(见 validator/main.go 中的 checkAge,配合 strconv.Atoi 做类型转换与范围判断)。

3. 通过正则取得内容

Match 模式只能用于对字符串的判定,而无法截取字符串的一部分、过滤字符串、或者提取出符合条件的一批字符串。如果想要满足这些需求,那就需要使用正则表达式的复杂模式。

我们经常需要一些爬虫程序,下面就以爬虫为例来说明如何使用正则来过滤或截取抓取到的数据:

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)

	//將 HTML 標籤全轉換成小寫
	re, _ := regexp.Compile("\\<[\\S\\s]+?\\>")
	src = re.ReplaceAllStringFunc(src, strings.ToLower)

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

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

	//去除所有尖括號內的 HTML 程式碼,並換成換行符
	re, _ = regexp.Compile("\\<[\\S\\s]+?\\>")
	src = re.ReplaceAllString(src, "\n")

	//去除連續的換行符
	re, _ = regexp.Compile("\\s{2,}")
	src = re.ReplaceAllString(src, "\n")

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

这个例子的处理管线清晰展示了正则的典型用法:

  1. http.Get 抓取页面 → ioutil.ReadAll 读取响应体;
  2. \\<[\\S\\s]+?\\> 匹配所有尖括号 HTML 标签,用 ReplaceAllStringFunc(src, strings.ToLower) 将标签统一转为小写(便于后续精确匹配);
  3. 用 \\<style[\\S\\s]+?\\</style\\> 与 \\<script[\\S\\s]+?\\</script\\> 分别移除 <style>、<script> 块;
  4. 再次用 \\<[\\S\\s]+?\\> 把剩余标签替换为换行符;
  5. 用 \\s{2,} 将连续的空白字符压缩为单个换行符;
  6. 最后 strings.TrimSpace 去掉首尾空白,输出纯净的页面文本。

注意其中的 [\\S\\s]+? 是非贪婪匹配任意字符(包括换行)的惯用写法。从这个例子可以看出,使用复杂正则的第一步是 Compile:它会解析正则表达式是否合法,如果正确,就返回一个 *Regexp,然后就可以利用返回的 Regexp 在任意字符串上执行需要的操作。

4. 解析正则表达式的方法

解析正则表达式有如下几个方法:

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

这四者的区别要点如下:

函数 语法约束 搜索策略 错误处理
Compile 通用 RE2 语法 最左方式(leftmost-first) 返回 error
CompilePOSIX 必须使用 POSIX 语法 最左最长方式(leftmost-longest) 返回 error
MustCompile 通用 RE2 语法 最左方式 解析失败直接 panic
MustCompilePOSIX 必须使用 POSIX 语法 最左最长方式 解析失败直接 panic

CompilePOSIX 与 Compile 的不同点在于:POSIX 必须使用 POSIX 语法,它使用最左最长方式搜索;而 Compile 只采用最左方式搜索。例如对 [a-z]{2,4} 这样一个正则表达式,应用于 "aa09aaa88aaaa" 这个文本串时,CompilePOSIX 返回 aaaa,而 Compile 返回的是 aa。

前缀带 Must 的函数表示:在解析正则语法的时候,如果匹配模式串不满足正确的语法则直接 panic;而不带 Must 的版本则只返回错误,由调用方决定如何处理。在编写爬虫、日志解析等「模式是硬编码常量」的场景中,MustCompile 更常用——它可以在程序启动时立即暴露正则有误的问题,如文档的 Expand 示例中所用的 regexp.MustCompile(...)。

5. Regexp 的查找方法

在了解如何创建一个 Regexp 之后,我们再来看一下这个 struct 提供了哪些方法来辅助我们操作字符串。首先来看这些用于搜索的函数:

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

上面这 18 个函数根据输入源(byte slice、string 和 io.RuneReader)不同,还可以简化成如下几个——其余只是输入源不一样,功能基本是一样的:

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) 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

使用时的关键语义:

  • Find:返回第一个匹配到的内容(byte slice);
  • FindAll:返回全部匹配内容,第二个参数 n 控制返回数量,n 小于 0 表示返回全部,否则返回指定长度;
  • FindIndex / FindAllIndex:返回匹配内容的起始与结束位置([]int,长度为 2 或 2n);
  • FindSubmatch / FindAllSubmatch:返回包含分组捕获的匹配结果,[][]byte 的第一个元素是整体匹配,后续元素依次是第 1、第 2 个 () 分组的内容;
  • FindSubmatchIndex / FindAllSubmatchIndex:返回分组的起止位置索引。

5.1 完整示例: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))

	//查詢符合正則的所有 slice,n 小於 0 表示回傳全部符合的字串,不然就是回傳指定的長度
	all := re.FindAll([]byte(a), -1)
	fmt.Println("FindAll", all)

	//查詢符合條件的 index 位置,開始位置和結束位置
	index := re.FindIndex([]byte(a))
	fmt.Println("FindIndex", index)

	//查詢符合條件的所有的 index 位置,n 同上
	allindex := re.FindAllIndex([]byte(a), -1)
	fmt.Println("FindAllIndex", allindex)

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

	//查詢 Submatch,回傳陣列,第一個元素是匹配的全部元素,第二個元素是第一個()裡面的,第三個是第二個()裡面的
	//下面的輸出第一個元素是"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)

	//FindAllSubmatch,查詢所有符合條件的子匹配
	submatchall := re2.FindAllSubmatch([]byte(a), -1)
	fmt.Println(submatchall)

	//FindAllSubmatchIndex,查詢所有字匹配的 index
	submatchallindex := re2.FindAllSubmatchIndex([]byte(a), -1)
	fmt.Println(submatchallindex)
}

运行后可以直观看到:Find 得到首个匹配 am;FindAll 得到所有 [a-z]{2,4} 片段(am、learning、Go、language 等);FindSubmatch 中 re2 的正则 am(.*)lang(.*) 会返回三个元素——整体 "am learning Go language"、第一个分组 " learning Go "(注意包含空格)和第二个分组 "uage"。这正是从文本中按分组抽取字段的核心手段。

6. Regexp 的匹配方法

前面介绍过匹配函数,Regexp 也定义了三个方法,它们和同名的包级函数功能一模一样——其实包级函数(Match、MatchReader、MatchString)内部就是调用这三个方法来实现的:

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

也就是说,当你已经通过 Compile 持有 *Regexp 时,无需再传入 pattern 字符串,直接调用 re.MatchString(s) 即可完成判断,同时避免了对同一模式反复编译的开销。

7. 替换函数

接下来了解替换函数是如何操作的:

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:将所有匹配到的内容替换为 repl,且 repl 中支持 $1、${name} 形式的反向引用;
  • ReplaceAllFunc / ReplaceAllStringFunc:第二个参数是回调函数,对每个匹配到的内容调用该函数,用返回值替换(前述爬虫示例中用 strings.ToLower 做函数式替换即属此类);
  • ReplaceAllLiteral / ReplaceAllLiteralString:字面替换,repl 中的 $ 不会做反向引用展开,按原样输出。

这些替换函数我们在上面的抓网页例子中有详细应用示范(去除 <style>、<script>、压缩连续空白等)。

8. Expand:基于模板与分组展开文本

接下来看一下 Expand 的说明:

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

Expand 的用途是:在已经通过 FindSubmatchIndex 拿到分组位置信息的基础上,按照 template 模板把匹配到的分组内容展开追加到 dst 中。它通常与 FindAllSubmatchIndex 配合使用,用于批量重构文本。请看下面的例子:

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))
}

关键点解读:

  • 正则 (?m)(call)\s+(?P<cmd>\w+)\s+(?P<arg>.+)\s*$ 中,(?m) 开启多行模式($ 匹配行尾),(?P<cmd>...)、(?P<arg>...) 是命名分组;
  • FindAllSubmatchIndex(src, -1) 一次性找出所有匹配行的分组位置 []int;
  • 对每个匹配位置,pat.Expand(res, []byte("$cmd('$arg')\n"), src, s) 按模板 $cmd('$arg') 展开——$cmd、$arg 引用命名分组的内容,展开结果追加进 res;
  • 最终输出为:
hello('alice')
hello('eve')

注意 hello bob 一行因不满足 (call) 前缀条件未被匹配,这正是用正则做「条件化文本重构」的典型场景。

9. 小结

至此我们已完整介绍 Go 语言的 regexp 包。回顾全篇,核心能力可归纳为四类:

  1. 判断匹配:regexp.Match / MatchReader / MatchString,以及 *Regexp 上的同名方法,适合表单校验、输入合法性检查;
  2. 编译解析:Compile / CompilePOSIX / MustCompile / MustCompilePOSIX,注意 POSIX 语法的最左最长与普通模式最左搜索的差异,以及 Must 前缀的 panic 行为;
  3. 查找提取:Find、FindAll、FindIndex、FindSubmatch 及其 Index 变体,配合分组捕获从文本中抽取结构化字段;
  4. 替换与展开:ReplaceAll 系列(含函数式与字面替换)用于清洗/改写文本,Expand / ExpandString 基于命名分组与模板批量重构文本。

在实际的 Go Web 开发中,正则的典型落地路径是:表单数据 → regexp.MatchString 校验(如仓库 validator/main.go 所示);爬虫抓取 → Compile + ReplaceAll 清洗 HTML;日志与文本解析 → FindAllSubmatch + Expand 抽取字段。掌握了 regexp 包,你就拥有了对任意文本进行模式匹配与内容操纵的完整能力。

本文为《build-web-application-with-golang》第 7.3 节的深度展开,相关章节可继续阅读:目录、上一节 JSON 处理、下一节 模板处理,以及本节正则的前置应用场景表单验证。

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