# Go快速入门(四)- 实战训练营

# 本章学习目标

在前几章节夯实基础的下,原本兴趣十足的小伙伴们都跃(mei)跃(you)欲(xing)试(qu)了。那今天这个章节我们就抛开语言的条条款款,来实战一番。

# Web 爬虫实战

上一个章节末尾留下一道练习题目,不知道大家完成得怎么样了呢?

嗯?题目?什么题目?我怎么没有看到呢?

好吧,我知道大家根本没有看啦。那本章节我们来看看这到题目怎么完成。

# 1、需求分析

用爬虫进行Web数据挖掘已经越来越普遍,最常见的是对于网站内容的爬取,包括文本、图片和文件等;其次对于网站结构的爬取,包括网站目录、链接之间的相互跳转关系、二级域名等;还有一种爬虫是对于Web应用数据的挖掘。

既然爬虫如此重要还如此好玩,那我们来看看如何使用go语言实现爬虫吧。

# 2、实现思路

爬虫,就是获取从Web页面内容的数据和URL,然后再从获取URL相对应页面内容的数据和URL。从一个Web页面出发像蜘蛛网一样发散开来。

因此,如何实现一个爬虫程序,关键点是如何从一个Web页面获取该页面下的URL。另一个关键点则是如何并发地抓取Web页面内容。使用go语言http获取网页内容(暂时不涉及),使用goroutine并发抓取网页,so easy!

# 3、习题分析

点开题目链接 Go 的并发特性来实现一个 Web 爬虫。能看到题目中已经写了很多代码啦。那我们来分析一下这些代码目的是什么。

// 7~10行
type Fetcher interface {
	// Fetch 返回 URL 的 body 内容,并且将在这个页面上找到的 URL 放到一个 slice 中。
	Fetch(url string) (body string, urls []string, err error)
}

// 44-49行
func (f fakeFetcher) Fetch(url string) (string, []string, error) {
	if res, ok := f[url]; ok {
		return res.body, res.urls, nil
	}
	return "", nil, fmt.Errorf("not found: %s", url)
}

这个不就是第二章节中的interface接口嘛,而且fakeFetcher实现了接口FetcherFetch方法。那我们再看看结构体fakeFetcher。等等fake fetcher?假的抓取者?

// 36-42行
// fakeFetcher 是返回若干结果的 Fetcher。
type fakeFetcher map[string]*fakeResult

type fakeResult struct {
	body string
	urls []string
}

结构体fakeResult ,就是爬虫抓取URL网页的内容body和body中的很多URL。但是为什么是又是fake呢?

type fakeFetcher map[string]*fakeResult这个好像看不懂啊,type是定义类型的关键字,难道是定义了一个map[string]*fakeResultfakeFetcher类型?

为什么这么多问题啊,再看看题目吧。

// 51-83行
// fetcher 是填充后的 fakeFetcher。
var fetcher = fakeFetcher{
	"https://golang.org/": &fakeResult{
		"The Go Programming Language",
		[]string{
			"https://golang.org/pkg/",
			"https://golang.org/cmd/",
		},
	},
	"https://golang.org/pkg/": &fakeResult{
		"Packages",
		[]string{
			"https://golang.org/",
			"https://golang.org/cmd/",
			"https://golang.org/pkg/fmt/",
			"https://golang.org/pkg/os/",
		},
	},
	"https://golang.org/pkg/fmt/": &fakeResult{
		"Package fmt",
		[]string{
			"https://golang.org/",
			"https://golang.org/pkg/",
		},
	},
	"https://golang.org/pkg/os/": &fakeResult{
		"Package os",
		[]string{
			"https://golang.org/",
			"https://golang.org/pkg/",
		},
	},
}

var fetcher = fakeFetcher{…},定义一个变量类型为fakeFetcher,果然fakeFetcher是自定义的类型。再看赋值内容,fakeResult结构体系属性body、urls都相应地被赋值了。想到FetcherfakeFetcher实现的方法直接返回的是fakeFetcherfakeResult的body、urls。

提示:你可以用一个 map 来缓存已经获取的 URL,但是要注意 map 本身并不是并发安全的!

看到题目提示map 本身并不是并发安全(至于为什么不是并发安全,大家可以了解一下Map的实现原理),那么如何才能使用map并发完全呢?大家都知道,在多线程访问共享内存时也不是并发安全的。那么map也可以使用互斥锁的方式让其并发安全。

# 4、sync包

  • 互斥锁 Mutex

    一个互斥锁只能同时被一个 goroutine锁定,其它 goroutine 将阻塞直到互斥锁被解锁。

    func (m *Mutex) Lock()   // 上锁
    func (m *Mutex) Unlock() // 解锁
    
  • WaitGroup

    WaitGroup用于等待一组 goroutine 结束。

    func (wg *WaitGroup) Add(delta int) // 添加deltaw个goroutine
    func (wg *WaitGroup) Done()			// 添加减少1个goroutine
    func (wg *WaitGroup) Wait()			// 等待goroutine执行完成
    

    sync包下的MutexWaitGroup将会在下面爬虫的实现中用到。

# 5、解题

从上面的分析可以知道,这道题只用写并发逻辑不用真实抓取网页内容。fakeResult表示假的抓取网页内容,fakeFetcher表示假的抓取的网页结构。只用实现并发情况下抓取网页,并且使用map记录下已经抓取过的URL。

首先,我们定义一个已经抓取过网页的结构。

type Fetched struct {
	m map[string]error
	sync.Mutex // 互斥锁、匿名结构体
}
var fetched = Fetched{m: make(map[string]error)}

然后,修改Crawl函数。

func Crawl(url string, depth int, fetcher Fetcher) {
	if depth <= 0 {
		return
	}

	// 判断url是否被抓取过
	fetched.Lock()
	if _, ok := fetched.m[url]; ok {
		fetched.Unlock()
		return
	}
	fetched.Unlock()

	body, urls, err := fetcher.Fetch(url)
	// 记录抓取当前url结果
	fetched.Lock()
	fetched.m[url] = err
	fetched.Unlock()
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Printf("found: %s %q\n", url, body)

	// 使用goroutine循环抓取urls,使用sync.WaitGroup来等待当抓取到的urls被抓取完
	var wg sync.WaitGroup
	for _, u := range urls {
		wg.Add(1)
		go func(wg *sync.WaitGroup, url string) {
			Crawl(url, depth-1, fetcher)
			wg.Done()
		}(&wg, u)
	}
	wg.Wait()
	return
}

最后,在main函数中打印已经被抓取的结果。

package main

import (
	"fmt"
	"sync"
)

type Fetched struct {
	m          map[string]error
	sync.Mutex // 互斥锁、匿名结构体
}

var fetched = Fetched{m: make(map[string]error)}

type Fetcher interface {
	// Fetch 返回 URL 的 body 内容,并且将在这个页面上找到的 URL 放到一个 slice 中。
	Fetch(url string) (body string, urls []string, err error)
}

// Crawl 使用 fetcher 从某个 URL 开始递归的爬取页面,直到达到最大深度。
func Crawl(url string, depth int, fetcher Fetcher) {
	if depth <= 0 {
		return
	}

	// 判断url是否被抓取过
	fetched.Lock()
	if _, ok := fetched.m[url]; ok {
		fetched.Unlock()
		return
	}
	fetched.Unlock()

	body, urls, err := fetcher.Fetch(url)
	// 记录抓取当前url结果
	fetched.Lock()
	fetched.m[url] = err
	fetched.Unlock()
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Printf("found: %s %q\n", url, body)

	// 使用goroutine循环抓取urls,使用sync.WaitGroup来等待当抓取到的urls被抓取完
	var wg sync.WaitGroup
	for _, u := range urls {
		wg.Add(1)
		go func(wg *sync.WaitGroup, url string) {
			Crawl(url, depth-1, fetcher)
			wg.Done()
		}(&wg, u)
	}
	wg.Wait()
	return
}

func main() {
	Crawl("https://golang.org/", 4, fetcher)

	fmt.Println("Fetching stats\n--------------")
	for url, err := range fetched.m {
		if err != nil {
			fmt.Printf("%v failed: %v\n", url, err)
		} else {
			fmt.Printf("%v was fetched\n", url)
		}
	}
}

// fakeFetcher 是返回若干结果的 Fetcher。
type fakeFetcher map[string]*fakeResult

type fakeResult struct {
	body string
	urls []string
}

func (f fakeFetcher) Fetch(url string) (string, []string, error) {
	if res, ok := f[url]; ok {
		return res.body, res.urls, nil
	}
	return "", nil, fmt.Errorf("not found: %s", url)
}

// fetcher 是填充后的 fakeFetcher。
var fetcher = fakeFetcher{
	"https://golang.org/": &fakeResult{
		"The Go Programming Language",
		[]string{
			"https://golang.org/pkg/",
			"https://golang.org/cmd/",
		},
	},
	"https://golang.org/pkg/": &fakeResult{
		"Packages",
		[]string{
			"https://golang.org/",
			"https://golang.org/cmd/",
			"https://golang.org/pkg/fmt/",
			"https://golang.org/pkg/os/",
		},
	},
	"https://golang.org/pkg/fmt/": &fakeResult{
		"Package fmt",
		[]string{
			"https://golang.org/",
			"https://golang.org/pkg/",
		},
	},
	"https://golang.org/pkg/os/": &fakeResult{
		"Package os",
		[]string{
			"https://golang.org/",
			"https://golang.org/pkg/",
		},
	},
}

那么这道题目就正式完成啦,其实上面的实现方法中使用WaitGroup会等待一组goroutine执行完成后才能结束。我们能不能让goroutine执行完成后不让调用函数等待呢?当然可以,这就是下一章节channel的内容,敬请期待。