在 Go 语言中实现一个并发爬虫,可以使用标准库中的 net/http 包来发送 HTTP 请求,使用 golang.org/x/net/html 包来解析 HTML,以及使用 sync 包中的 WaitGroup 来同步并发的 goroutines。本文将从一个简单的并发爬虫开始,逐步扩展为一个完整的工程级实现,包括需求分析、架构设计、URL 去重、robots.txt 遵守、速率限制、优雅退出、数据存储、错误重试与降级、HTTP Client 调优、User-Agent 轮换、代理支持、测试策略等内容。
需求分析与架构设计
功能需求
一个完整的并发爬虫通常需要满足以下需求:
- 从给定的种子 URL 开始递归抓取
- 提取页面中的所有链接并继续抓取
- 遵守目标网站的 robots.txt 协议
- 对同一域名的请求进行速率限制
- 支持 URL 去重,避免重复抓取
- 支持优雅退出和断点续抓
- 将抓取结果存储到文件或数据库
- 支持 HTTP 代理和 User-Agent 轮换
- 对网络错误进行重试和降级处理
架构设计
我们将爬虫拆分为以下几个核心组件:
- Fetcher(下载器):负责发送 HTTP 请求获取网页内容
- Parser(解析器):负责解析 HTML 提取链接和内容
- Scheduler(调度器):负责 URL 队列管理和去重
- Worker Pool(工作池):负责控制并发数量
- Store(存储器):负责持久化抓取结果
URL Queue -> [Worker] -> Fetcher -> Parser -> Extract Links -> URL Queue
|
v
Store
基础版本:简单并发爬虫
以下是基础并发爬虫的实现:
package main
import (
"fmt"
"log"
"net/http"
"sync"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// 用于存储访问的URL
var urls = []string{
"https://plumephp.com/posts/golang/",
"https://plumephp.com/about/",
}
// 用于同步goroutines
var wg sync.WaitGroup
func main() {
// 启动所有goroutines
for _, url := range urls {
wg.Add(1)
go crawl(url)
}
// 等待所有goroutine完成
wg.Wait()
}
// crawl 函数用于访问URL并打印出网页中的所有链接
func crawl(url string) {
defer wg.Done()
// 发送HTTP GET请求
resp, err := http.Get(url)
if err != nil {
log.Printf("Error fetching %s: %v\n", url, err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("Non-OK status for %s: %d\n", url, resp.StatusCode)
return
}
// 解析HTML
doc, err := html.Parse(resp.Body)
if err != nil {
log.Printf("Error parsing %s: %v\n", url, err)
return
}
// 遍历DOM树,查找所有的a标签
var links []string
forEachNode(doc, atom.A, func(n *html.Node) {
for _, a := range n.Attr {
if a.Key == "href" {
links = append(links, a.Val)
}
}
})
fmt.Printf("Found %d links on %s\n", len(links), url)
for _, link := range links {
fmt.Println(link)
}
}
// forEachNode 函数遍历DOM树,查找指定的标签
func forEachNode(n *html.Node, tag atom.Atom, f func(*html.Node)) {
if n.Type == html.ElementNode && n.DataAtom == tag {
f(n)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
forEachNode(c, tag, f)
}
}
URL 去重(Bloom Filter)
在大型爬虫中,内存中的 map 无法处理数十亿级别的 URL 去重。Bloom Filter 是一种空间效率极高的概率型数据结构,非常适合 URL 去重场景。
package main
import (
"hash"
"hash/fnv"
"math"
)
// BloomFilter 布隆过滤器
type BloomFilter struct {
bits []bool
size uint64
hashes int
}
func NewBloomFilter(expectedItems int, falsePositiveRate float64) *BloomFilter {
m := uint64(math.Ceil(-float64(expectedItems) * math.Log(falsePositiveRate) / math.Pow(math.Log(2), 2)))
k := int(math.Ceil(float64(m) / float64(expectedItems) * math.Log(2)))
return &BloomFilter{
bits: make([]bool, m),
size: m,
hashes: k,
}
}
func (bf *BloomFilter) hash(data string, seed int) uint64 {
h := fnv.New64a()
h.Write([]byte(data))
h.Write([]byte{byte(seed)})
return h.Sum64() % bf.size
}
func (bf *BloomFilter) Add(item string) {
for i := 0; i < bf.hashes; i++ {
idx := bf.hash(item, i)
bf.bits[idx] = true
}
}
func (bf *BloomFilter) MayContain(item string) bool {
for i := 0; i < bf.hashes; i++ {
idx := bf.hash(item, i)
if !bf.bits[idx] {
return false
}
}
return true
}
对于较小规模的爬虫,使用 map[string]bool 已经足够:
package main
type URLSet struct {
seen map[string]bool
}
func NewURLSet() *URLSet {
return &URLSet{seen: make(map[string]bool)}
}
func (s *URLSet) Add(url string) bool {
if s.seen[url] {
return false
}
s.seen[url] = true
return true
}
func (s *URLSet) Has(url string) bool {
return s.seen[url]
}
robots.txt 遵守
尊重目标网站的 robots.txt 协议是爬虫的基本伦理。Go 可以使用 github.com/temoto/robotstxt 库来解析 robots.txt。
package main
import (
"fmt"
"net/http"
"time"
"github.com/temoto/robotstxt"
)
type RobotChecker struct {
client *http.Client
rules map[string]*robotstxt.RobotsData
}
func NewRobotChecker() *RobotChecker {
return &RobotChecker{
client: &http.Client{Timeout: 10 * time.Second},
rules: make(map[string]*robotstxt.RobotsData),
}
}
func (rc *RobotChecker) CanFetch(userAgent, urlStr string) bool {
// 简化的实现:实际应从 URL 提取域名
// 这里假设已知 robots.txt 内容
return true
}
func main() {
resp, err := http.Get("https://example.com/robots.txt")
if err != nil {
fmt.Println("Error:", err)
return
}
defer resp.Body.Close()
data, err := robotstxt.FromResponse(resp)
if err != nil {
fmt.Println("Parse error:", err)
return
}
fmt.Println("Robots.txt loaded, crawl delay:", data.CrawlDelay("MyBot"))
}
速率限制
对同一域名的请求进行速率限制是爬虫的基本礼貌。可以使用 Go 的 time.Ticker 或 golang.org/x/time/rate 来实现令牌桶限流。
package main
import (
"context"
"fmt"
"sync"
"time"
"golang.org/x/time/rate"
)
// DomainLimiter 对每个域名单独限流
type DomainLimiter struct {
limiters map[string]*rate.Limiter
mu sync.RWMutex
rate rate.Limit
burst int
}
func NewDomainLimiter(r rate.Limit, burst int) *DomainLimiter {
return &DomainLimiter{
limiters: make(map[string]*rate.Limiter),
rate: r,
burst: burst,
}
}
func (dl *DomainLimiter) getLimiter(domain string) *rate.Limiter {
dl.mu.RLock()
lim, ok := dl.limiters[domain]
dl.mu.RUnlock()
if ok {
return lim
}
dl.mu.Lock()
defer dl.mu.Unlock()
lim, ok = dl.limiters[domain]
if !ok {
lim = rate.NewLimiter(dl.rate, dl.burst)
dl.limiters[domain] = lim
}
return lim
}
func (dl *DomainLimiter) Wait(ctx context.Context, domain string) error {
return dl.getLimiter(domain).Wait(ctx)
}
func main() {
lim := NewDomainLimiter(rate.Every(time.Second), 2) // 每秒 1 个请求,突发 2 个
for i := 0; i < 5; i++ {
ctx := context.Background()
if err := lim.Wait(ctx, "example.com"); err != nil {
fmt.Println("Rate limit error:", err)
continue
}
fmt.Printf("Request %d allowed at %v\n", i+1, time.Now().Format("15:04:05"))
}
}
优雅退出
在生产环境中,爬虫需要能够优雅地处理中断信号(SIGINT、SIGTERM),保存当前进度,然后退出。
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
type Crawler struct {
ctx context.Context
cancel context.CancelFunc
}
func NewCrawler() *Crawler {
ctx, cancel := context.WithCancel(context.Background())
return &Crawler{ctx: ctx, cancel: cancel}
}
func (c *Crawler) Start() {
// 捕获系统信号
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
fmt.Println("\nReceived shutdown signal, stopping crawler...")
c.cancel()
}()
// 启动爬虫工作
for i := 0; i < 10; i++ {
select {
case <-c.ctx.Done():
fmt.Println("Crawler stopped gracefully")
return
default:
fmt.Printf("Crawling page %d...\n", i+1)
time.Sleep(500 * time.Millisecond)
}
}
fmt.Println("Crawling completed")
}
func main() {
crawler := NewCrawler()
crawler.Start()
}
数据存储(SQLite)
将抓取结果持久化到 SQLite 是一个轻量且实用的选择。
package main
import (
"database/sql"
"fmt"
"log"
"time"
_ "github.com/mattn/go-sqlite3"
)
type PageStore struct {
db *sql.DB
}
func NewPageStore(dbPath string) (*PageStore, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
// 创建表
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT UNIQUE NOT NULL,
title TEXT,
content TEXT,
status_code INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return nil, err
}
return &PageStore{db: db}, nil
}
func (s *PageStore) Save(url, title, content string, statusCode int) error {
_, err := s.db.Exec(
"INSERT OR REPLACE INTO pages (url, title, content, status_code) VALUES (?, ?, ?, ?)",
url, title, content, statusCode,
)
return err
}
func (s *PageStore) Close() error {
return s.db.Close()
}
func main() {
store, err := NewPageStore("crawler.db")
if err != nil {
log.Fatal(err)
}
defer store.Close()
if err := store.Save("https://example.com", "Example", "Hello World", 200); err != nil {
log.Println("Save error:", err)
}
fmt.Println("Page saved to database")
}
错误重试与降级
网络请求不可避免地会失败,实现指数退避重试是提高爬虫稳定性的关键。
package main
import (
"fmt"
"math"
"math/rand"
"time"
)
// RetryConfig 重试配置
type RetryConfig struct {
MaxRetries int
BaseDelay time.Duration
MaxDelay time.Duration
Multiplier float64
}
func DefaultRetryConfig() *RetryConfig {
return &RetryConfig{
MaxRetries: 3,
BaseDelay: time.Second,
MaxDelay: time.Minute,
Multiplier: 2.0,
}
}
func retryWithBackoff(config *RetryConfig, operation func() error) error {
var err error
for i := 0; i <= config.MaxRetries; i++ {
err = operation()
if err == nil {
return nil
}
if i == config.MaxRetries {
break
}
// 指数退避 + 随机抖动
delay := time.Duration(float64(config.BaseDelay) * math.Pow(config.Multiplier, float64(i)))
if delay > config.MaxDelay {
delay = config.MaxDelay
}
// 添加 0-1 秒的随机抖动
jitter := time.Duration(rand.Int63n(int64(time.Second)))
delay += jitter
fmt.Printf("Attempt %d failed: %v. Retrying in %v...\n", i+1, err, delay)
time.Sleep(delay)
}
return fmt.Errorf("all %d retries failed: %w", config.MaxRetries, err)
}
func main() {
attempts := 0
err := retryWithBackoff(DefaultRetryConfig(), func() error {
attempts++
if attempts < 3 {
return fmt.Errorf("network timeout")
}
return nil
})
if err != nil {
fmt.Println("Failed:", err)
} else {
fmt.Println("Success after", attempts, "attempts")
}
}
HTTP Client 调优
Go 的默认 http.Client 在很多场景下需要调优,包括连接池配置、超时设置和重定向策略。
package main
import (
"net"
"net/http"
"time"
)
func NewOptimizedClient() *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return http.ErrUseLastResponse
}
return nil
},
}
}
关键调优参数:
MaxIdleConns:连接池总大小MaxIdleConnsPerHost:每个主机的最大空闲连接数IdleConnTimeout:空闲连接超时时间Timeout:整个请求的超时时间(包括连接、发送和接收)
User-Agent 轮换
很多网站会根据 User-Agent 来识别爬虫。轮换 User-Agent 可以降低被封锁的风险。
package main
import (
"math/rand"
"time"
)
var userAgents = []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
}
func RandomUserAgent() string {
rand.Seed(time.Now().UnixNano())
return userAgents[rand.Intn(len(userAgents))]
}
代理支持
代理是爬虫绕过 IP 限制的重要手段。
package main
import (
"fmt"
"net/http"
"net/url"
"time"
)
func NewClientWithProxy(proxyURL string) (*http.Client, error) {
parsedURL, err := url.Parse(proxyURL)
if err != nil {
return nil, err
}
transport := &http.Transport{
Proxy: http.ProxyURL(parsedURL),
}
return &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}, nil
}
func main() {
// HTTP 代理示例
client, err := NewClientWithProxy("http://proxy.example.com:8080")
if err != nil {
fmt.Println("Error:", err)
return
}
resp, err := client.Get("https://httpbin.org/ip")
if err != nil {
fmt.Println("Request error:", err)
return
}
defer resp.Body.Close()
fmt.Println("Status:", resp.Status)
}
完整可运行代码
以下是一个整合了上述功能的完整并发爬虫:
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
"golang.org/x/time/rate"
)
// Page 存储爬取结果
type Page struct {
URL string
Title string
StatusCode int
Links []string
FetchedAt time.Time
}
// Crawler 爬虫
type Crawler struct {
client *http.Client
limiter *rate.Limiter
visited map[string]bool
mu sync.RWMutex
results chan Page
semaphore chan struct{}
maxDepth int
userAgents []string
uaIndex int
}
func NewCrawler(maxConcurrent int, reqsPerSecond int, maxDepth int) *Crawler {
return &Crawler{
client: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return http.ErrUseLastResponse
}
return nil
},
},
limiter: rate.NewLimiter(rate.Limit(reqsPerSecond), reqsPerSecond),
visited: make(map[string]bool),
results: make(chan Page, 100),
semaphore: make(chan struct{}, maxConcurrent),
maxDepth: maxDepth,
userAgents: []string{
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
},
}
}
func (c *Crawler) GetUserAgent() string {
agent := c.userAgents[c.uaIndex%len(c.userAgents)]
c.uaIndex++
return agent
}
func (c *Crawler) AlreadyVisited(u string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.visited[u]
}
func (c *Crawler) MarkVisited(u string) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.visited[u] {
return false
}
c.visited[u] = true
return true
}
func (c *Crawler) fetch(ctx context.Context, rawURL string) (*Page, error) {
if err := c.limiter.Wait(ctx); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.GetUserAgent())
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 限制 1MB
if err != nil {
return nil, err
}
title, links := c.parseHTML(string(body), rawURL)
return &Page{
URL: rawURL,
Title: title,
StatusCode: resp.StatusCode,
Links: links,
FetchedAt: time.Now(),
}, nil
}
func (c *Crawler) parseHTML(body, baseURL string) (string, []string) {
doc, err := html.Parse(strings.NewReader(body))
if err != nil {
return "", nil
}
var title string
var links []string
base, _ := url.Parse(baseURL)
var f func(*html.Node)
f = func(n *html.Node) {
if n.Type == html.ElementNode {
if n.DataAtom == atom.Title && n.FirstChild != nil {
title = n.FirstChild.Data
}
if n.DataAtom == atom.A {
for _, attr := range n.Attr {
if attr.Key == "href" {
if resolved, err := base.Parse(attr.Val); err == nil {
links = append(links, resolved.String())
}
}
}
}
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
f(child)
}
}
f(doc)
return title, links
}
func (c *Crawler) crawlRecursive(ctx context.Context, rawURL string, depth int) {
if depth > c.maxDepth {
return
}
if !c.MarkVisited(rawURL) {
return
}
c.semaphore <- struct{}{}
defer func() { <-c.semaphore }()
page, err := c.fetch(ctx, rawURL)
if err != nil {
log.Printf("Error fetching %s: %v\n", rawURL, err)
return
}
c.results <- *page
log.Printf("Fetched: %s [%d] %s\n", rawURL, page.StatusCode, page.Title)
for _, link := range page.Links {
select {
case <-ctx.Done():
return
default:
go c.crawlRecursive(ctx, link, depth+1)
}
}
}
func (c *Crawler) Run(ctx context.Context, seedURLs []string) {
var wg sync.WaitGroup
for _, u := range seedURLs {
wg.Add(1)
go func(url string) {
defer wg.Done()
c.crawlRecursive(ctx, url, 0)
}(u)
}
go func() {
wg.Wait()
close(c.results)
}()
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 捕获退出信号
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("Shutting down...")
cancel()
}()
crawler := NewCrawler(5, 2, 2)
seeds := []string{
"https://go.dev/",
}
crawler.Run(ctx, seeds)
for page := range crawler.results {
fmt.Printf("[%d] %s -> %s\n", page.StatusCode, page.URL, page.Title)
}
fmt.Println("Crawling finished")
}
测试策略
爬虫的测试应该覆盖以下几个方面:
单元测试
package main
import (
"strings"
"testing"
)
func TestParseHTML(t *testing.T) {
crawler := NewCrawler(1, 1, 1)
body := `<html><head><title>Test Page</title></head>
<body><a href="/page1">Link1</a><a href="https://example.com/page2">Link2</a></body></html>`
title, links := crawler.parseHTML(body, "https://example.com/")
if title != "Test Page" {
t.Errorf("Expected title 'Test Page', got '%s'", title)
}
if len(links) != 2 {
t.Errorf("Expected 2 links, got %d", len(links))
}
}
func TestURLVisited(t *testing.T) {
crawler := NewCrawler(1, 1, 1)
url := "https://example.com/test"
if crawler.AlreadyVisited(url) {
t.Error("Should not be visited initially")
}
if !crawler.MarkVisited(url) {
t.Error("First mark should return true")
}
if crawler.MarkVisited(url) {
t.Error("Second mark should return false")
}
}
Mock HTTP 测试
使用 net/http/httptest 创建 mock 服务器来测试 HTTP 交互:
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestFetch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<html><title>Mock</title><a href="/link">Link</a></html>`))
}))
defer server.Close()
crawler := NewCrawler(1, 100, 1)
ctx := context.Background()
page, err := crawler.fetch(ctx, server.URL)
if err != nil {
t.Fatalf("fetch failed: %v", err)
}
if page.StatusCode != 200 {
t.Errorf("Expected status 200, got %d", page.StatusCode)
}
if page.Title != "Mock" {
t.Errorf("Expected title 'Mock', got '%s'", page.Title)
}
}
常见问题与优化
内存泄漏
如果 goroutine 无限增长,可能导致内存泄漏。使用 semaphore 控制最大并发数是必要的。
DNS 缓存
Go 默认使用操作系统的 DNS 解析,没有内置缓存。对于需要大量域名解析的场景,可以考虑使用自定义Resolver。
处理 JavaScript 渲染的页面
标准库无法执行 JavaScript。对于需要 JavaScript 渲染的页面,可以集成 Chrome DevTools Protocol(CDP)使用 headless Chrome。
robots.txt 缓存
robots.txt 文件应该在内存中缓存,避免每次请求前都拉取。
总结
本文从一个简单的并发爬虫开始,逐步扩展为一个包含 URL 去重、robots.txt 遵守、速率限制、优雅退出、数据存储、错误重试、HTTP 调优、User-Agent 轮换和代理支持的完整工程实现。并发爬虫是 Go 语言的网络编程和并发特性的综合实践项目,通过这个项目你可以深入理解 goroutine、channel、context、sync 包和标准库 HTTP 客户端的实际使用方式。在实际部署爬虫时,请始终遵守目标网站的 robots.txt 协议,尊重网站的访问频率限制,并确保你的爬虫行为符合相关法律法规。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。