Go 语言 regexp 正则处理实战指南:从匹配判断到爬虫文本提取(build-web-application-with-golang 第 7.3 节)
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))
}
这个例子的处理管线清晰展示了正则的典型用法:
http.Get抓取页面 →ioutil.ReadAll读取响应体;\\<[\\S\\s]+?\\>匹配所有尖括号 HTML 标签,用ReplaceAllStringFunc(src, strings.ToLower)将标签统一转为小写(便于后续精确匹配);- 用
\\<style[\\S\\s]+?\\</style\\>与\\<script[\\S\\s]+?\\</script\\>分别移除<style>、<script>块; - 再次用
\\<[\\S\\s]+?\\>把剩余标签替换为换行符; - 用
\\s{2,}将连续的空白字符压缩为单个换行符; - 最后
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 包。回顾全篇,核心能力可归纳为四类:
- 判断匹配:
regexp.Match/MatchReader/MatchString,以及*Regexp上的同名方法,适合表单校验、输入合法性检查; - 编译解析:
Compile/CompilePOSIX/MustCompile/MustCompilePOSIX,注意 POSIX 语法的最左最长与普通模式最左搜索的差异,以及 Must 前缀的 panic 行为; - 查找提取:
Find、FindAll、FindIndex、FindSubmatch及其 Index 变体,配合分组捕获从文本中抽取结构化字段; - 替换与展开:
ReplaceAll系列(含函数式与字面替换)用于清洗/改写文本,Expand/ExpandString基于命名分组与模板批量重构文本。
在实际的 Go Web 开发中,正则的典型落地路径是:表单数据 → regexp.MatchString 校验(如仓库 validator/main.go 所示);爬虫抓取 → Compile + ReplaceAll 清洗 HTML;日志与文本解析 → FindAllSubmatch + Expand 抽取字段。掌握了 regexp 包,你就拥有了对任意文本进行模式匹配与内容操纵的完整能力。
本文为《build-web-application-with-golang》第 7.3 节的深度展开,相关章节可继续阅读:目录、上一节 JSON 处理、下一节 模板处理,以及本节正则的前置应用场景表单验证。