Go 爬虫入门
本文内容基于 AI 生成结果整理,可能包含不准确信息,仅供参考使用。
通过模仿玩游戏打怪升级的方式,简单入门 Go 爬虫。
第一关:新手村 - 单线程爬虫
目标: 学会最基本的网页抓取
go
// 最简单的爬虫:只能打一个怪(单线程)
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
// 打第一个小怪:访问百度
resp, err := http.Get("https://www.baidu.com")
if err != nil {
panic(err) // 被打败了
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("打败了小怪!获得%d字节的战利品\n", len(body))
}
技能获得:
http.Get()
- 你的第一把武器resp.Body
- 战利品箱子defer
- 战斗后自动清理战场io.ReadAll()
- 打开战利品箱,读取所有内容
练习: 修改 URL,尝试访问其他网站
第二关:学会连招 - 抓取多个页面
目标: 一次打多个怪
go
func main() {
// 要打的怪物列表
urls := []string{
"https://www.baidu.com",
"https://www.qq.com",
"https://www.taobao.com",
}
for i, url := range urls {
fmt.Printf("正在打第%d个怪: %s\n", i+1, url)
resp, err := http.Get(url)
if err != nil {
fmt.Printf("打怪失败: %v\n", err)
continue
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("打败了%s!获得%d字节\n", url, len(body))
}
}
发现问题: 一个个打太慢了!需要学习新技能
第三关:召唤分身 - 使用 goroutine
目标: 同时打多个怪
go
func main() {
urls := []string{"https://www.baidu.com", "https://www.qq.com", "https://www.taobao.com"}
for _, url := range urls {
go func(u string) { // go关键字:召唤分身!
fmt.Printf("分身开始打怪: %s\n", u)
resp, err := http.Get(u)
if err != nil {
fmt.Printf("分身打怪失败: %v\n", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("分身打败了%s!获得%d字节\n", u, len(body))
}(url) // 把url传给分身
}
time.Sleep(5 * time.Second) // 给分身时间完成战斗
}
新问题: 分身太多会把网站打崩!需要控制分身数量
第四关:控制分身 - 使用管道限流
目标: 控制同时战斗的分身数量
go
func main() {
urls := []string{"https://www.baidu.com", "https://www.qq.com", "https://www.taobao.com"}
// 创建令牌桶:最多同时3个分身战斗
tokens := make(chan struct{}, 3)
for i := 0; i < 3; i++ {
tokens <- struct{}{} // 放入3个令牌
}
for _, url := range urls {
<-tokens // 领取令牌(如果没有令牌就等待)
go func(u string) {
defer func() { tokens <- struct{}{} }() // 战斗完归还令牌
fmt.Printf("分身开始打怪: %s\n", u)
resp, err := http.Get(u)
if err != nil {
fmt.Printf("分身打怪失败: %v\n", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("分身打败了%s!获得%d字节\n", u, len(body))
}(url)
}
// 等待所有分身归还令牌
for i := 0; i < 3; i++ {
<-tokens
}
}
技能升级: 现在你可以控制战斗节奏了!
信号量的选择 空结构体 vs 布尔
在 Go 语言的并发模式中,使用 chan struct{} 作为信号量或令牌(token)是一种非常常见且地道的做法,而不是使用 chan bool。
主要原因有以下几点:
- 内存占用最小化
- struct{}:这是一个空结构体。空结构体的一个关键特性是它不占用任何内存 (确切地说,大小为 0 字节)。无论你创建多少个 struct{}{},它们都指向同一个内存地址,不会增加额外的内存开销。
- bool:布尔类型在 Go 中占用 1 个字节 的内存。
当你创建一个 channel 来纯粹传递信号(“有”或“无”),而不需要携带任何实际数据时,使用零内存占用的类型是最经济的选择。虽然对于只有 3 个令牌的场景来说,1 字节 vs 0 字节的差异微乎其微,但在设计模式和代码习惯上,追求最小化开销是一种最佳实践。
- 语义清晰化
代码的语义(即代码向阅读者传达的意图)非常重要。
- chan struct{}:这种用法通常被称为 “信号量 Channel” 或 “令牌 Channel”。它的存在仅仅是为了控制并发、实现同步或限制资源访问。当你看到 tokens <- struct{}{} 时,你立刻明白我们只是在投放一个“空信号”或“令牌”,其值本身毫无意义,我们只关心 channel 的“可操作状态”(是否可读/可写)。
- chan bool:布尔 channel 通常用于传递一个具有真假含义的状态或标志。例如,它可能用于通知某个计算是否完成(<-done)或某个条件是否成立。使用 bool 会暗示这个 channel 正在传递一个具有逻辑意义的值(true/false),而在这个限流器的上下文中,我们并不需要这样的值。
简单来说:
- struct{} 表示:“发生了一个事件”(我不关心事件的内容,只关心它发生了)。
- bool 表示:“传递一个真假状态”(我关心这个值是 true 还是 false)。
- 习惯和惯例
在 Go 的并发编程社区和标准库中,使用 struct{} 作为信号 Channel 的元素类型已经成为一种惯用法(idiom)。
有经验的 Gopher 一看到 make(chan struct{}, N) 或 done := make(chan struct{}),就能立刻反应过来这是一个用于同步或控制并发的工具,而不是一个用于传输数据的管道。遵循这种惯例可以使你的代码更容易被其他 Go 开发者理解和维护。
第五关:团队协作 - 使用等待组包
目标: 让分身们更好地协作
go
func main() {
urls := []string{"https://www.baidu.com", "https://www.qq.com", "https://www.taobao.com"}
tokens := make(chan struct{}, 3)
for i := 0; i < 3; i++ {
tokens <- struct{}{}
}
var wg sync.WaitGroup // 创建团队协作工具
for _, url := range urls {
<-tokens
wg.Add(1) // 告诉团队:又一个分身出发了
go func(u string) {
defer func() {
tokens <- struct{}{}
wg.Done() // 告诉团队:我回来了
}()
fmt.Printf("分身开始打怪: %s\n", u)
resp, err := http.Get(u)
if err != nil {
fmt.Printf("分身打怪失败: %v\n", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("分身打败了%s!获得%d字节\n", u, len(body))
}(url)
}
wg.Wait() // 等待所有分身归来
fmt.Println("所有分身都完成任务了!")
}
现在你有了一个真正的并发爬虫团队!
第六关:避免重复 - 记录已访问链接
目标: 不要重复打同一个怪
go
func main() {
urls := []string{"https://www.baidu.com", "https://www.baidu.com"} // 重复的URL
visited := make(map[string]bool) // 记录打过的怪
var mutex sync.Mutex // 保护记录的锁
tokens := make(chan struct{}, 3)
for i := 0; i < 3; i++ {
tokens <- struct{}{}
}
var wg sync.WaitGroup
for _, url := range urls {
<-tokens
wg.Add(1)
go func(u string) {
defer func() {
tokens <- struct{}{}
wg.Done()
}()
// 检查是否打过这个怪
mutex.Lock()
if visited[u] {
mutex.Unlock()
fmt.Printf("已经打过%s了,跳过\n", u)
return
}
visited[u] = true // 标记为已打
mutex.Unlock()
fmt.Printf("开始打新怪: %s\n", u)
resp, err := http.Get(u)
if err != nil {
fmt.Printf("打怪失败: %v\n", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("打败了%s!获得%d字节\n", u, len(body))
}(url)
}
wg.Wait()
}
第七关:探索迷宫 - 提取页面链接
目标: 从一个页面找到更多页面
go
func extractLinks(body string) []string {
var links []string
// 简单查找href(真实项目应该用goquery等库)
for i := 0; i < len(body)-6; i++ {
if body[i:i+6] == "href=\"" {
i += 6
end := i
for end < len(body) && body[end] != '"' {
end++
}
if end > i {
links = append(links, body[i:end])
}
}
}
return links
}
// 在打怪成功后添加:
links := extractLinks(string(body))
fmt.Printf("在%s中发现%d个新怪物!\n", u, len(links))
第八关:组建远征军 - 完整并发爬虫
目标: 把所有技能组合起来
go
type Crawler struct {
maxWorkers int
visited map[string]bool
mutex sync.Mutex
wg sync.WaitGroup
tokens chan struct{}
}
func NewCrawler(workers int) *Crawler {
c := &Crawler{
maxWorkers: workers,
visited: make(map[string]bool),
tokens: make(chan struct{}, workers),
}
for i := 0; i < workers; i++ {
c.tokens <- struct{}{}
}
return c
}
func (c *Crawler) Crawl(url string) {
<-c.tokens
c.wg.Add(1)
go func(u string) {
defer func() {
c.tokens <- struct{}{}
c.wg.Done()
}()
// 检查是否访问过
c.mutex.Lock()
if c.visited[u] {
c.mutex.Unlock()
return
}
c.visited[u] = true
c.mutex.Unlock()
// 抓取页面
resp, err := http.Get(u)
if err != nil {
return
}
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
links := extractLinks(string(body))
// 递归抓取新链接
for _, link := range links {
c.Crawl(link) // 派出新的分身!
}
}(url)
}
func (c *Crawler) Wait() {
c.wg.Wait()
}
func main() {
crawler := NewCrawler(5) // 组建5人远征军
crawler.Crawl("https://www.example.com") // 开始冒险!
crawler.Wait() // 等待远征军归来
fmt.Println("冒险结束!")
}
第九关:应对特殊怪物 - 错误和超时
目标: 处理各种异常情况
go
func (c *Crawler) crawlWithTimeout(u string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
fmt.Printf("打怪超时: %s\n", u)
}
return
}
defer resp.Body.Close()
// ... 其余逻辑
}