并发版爬虫

架构

之前单任务版爬虫的架构是:传入一个种子(request)给engine,engine将url传给fetch,fetch将从url获取到的内容传给parse,parse解析出request和item,再将request传给engine队列.具体如下图:

go语言如何刷leetcode go语言爬虫_赋值

并发版爬虫基于原来的单任务版爬虫,在耗时长的部分使用goroutine,通过channel来传送数据

首先,我们可以看到fetch的输出就是parse的输入,可以把fetch,parse以及engine的一部分合并成一个worker

go语言如何刷leetcode go语言爬虫_单任务_02

func Run(seeds ...Request){
    for len(seeds) > 0 {
		r := seeds[0]
		seeds = seeds[1:]
		fmt.Printf("%s\n", r.URL)
		result, err := worker(r)
		if err != nil {
			log.Printf("error : %s : %v", r.URL, err)
			continue
		}
		for _, v := range result.Item {
			fmt.Printf("%+v\n", v)
		}
		for _, vv := range result.Requests {
			seeds = append(seeds, Request{vv.URL, vv.ParseFunc})
		}
	}
}

//封装成worker
func worker(r Request) ParseResult,error{
    s,err := fetcher.Fetch(r.URL)
    if err != nil {
		log.Printf("error : %s : %v", r.URL, err)
		return ParseResult{}, err
	}
	result := r.ParseFunc(s)
	return result, nil
}

因为要满足并发,所以需要同时有多个worker工作,这时需要一个scheduler调度器来为每一个worker分发任务,所以应该是这样的形式

go语言如何刷leetcode go语言爬虫_赋值_03

这里传递的形式不是参数,而是channel

实现的原理

  • engine和scheduler都是一个goroutine,worker开多个goroutine
  • scheduler向一个channel发送数据,所有worker都从这一个channel中接收数据,谁接收到了,谁就进行处理
  • 这就实现了简单的分发任务

代码实现

//首先定义一个struct,将run方法挂在其下边,方便以后写不同的实现方式
type ConCurrentEngine struct{
    Scheduler Scheduler
    WorkerCount int
}

//先写一个接口,后面再实现
type Scheduler interface{
    Submit(Request)
    //用于为scheduler向worker输入的channel赋值
    ConfigMasterChan(chan Request)
}

//具体的run方法
func (e *ConCurrentEngine) Run(seeds ...Request){
    //直接将request交给scheduler处理
    for _,r := range seeds{
        e.Scheduler.Submit(r)
    }
    //scheduler向worker传输的channel
    in := make(chan Request)
    //worker发送返回数据的channel
    out := make(chan ParseResult)
    e.Scheduler.ConfigMasrerChan(in)
    for i:= 0;i<e.WorkerCount;i++{
        //根据in和out创建worker
        createWorker(in,out)
    }
    for{
        result := <- out
        for _,item := range result.Item{
            log.Printf("Got Item : %v",item)
        }
        for _,request := range result.Requests{
            e.Scheduler.Submit(request)
        }
    }
}

//创建worker的方法实现
func createWorker(in chan Request,out chan ParseResult){  
    go func(){
        for{
            request := <- in
            result,err := worker(request)
            if err != nil{
                continue
            }
            out <- result
        }
    }()
}

//最后就是对scheduler的实现了
//可以封装到一个包中
package scheduler

type SimpleScheduler struct{
    //向worker输入的channel
    WorkerChan chan Request
}

func (s *SimpleScheduler) Submit(r engine.Request){
    //开一个goroutine向WorkerChan发送request
    //因为在Run方法中,从out取完第一层的城市列表之后,就一直循环向WorkerChan发送数据
    //这样没有人从out取数据,worker就发不了,也就没办法再接收WorkerChan的数据
    //所以开一个goroutine避免锁住
    go func(){
        s.WorkerChan <- r
    }()
}

//为scheduler的channel赋值
func (s *SimpleScheduler) ConfigMasterChan(in chan engine.Request){
    s.WorkerChan = in
}
//这样就可以在main函数中直接地调用了
func main(){
    e := engine.ConCurrentEngine{&scheduler.SimpleScheduler{},100}
    e.Run(engine.Request{URL: url, ParseFunc: parser.ParseCityList})
}
//但是这样有可能爬取的太快了,网站可能会组织
//为了降低速度,可以直接减小开的worker的数量
//也可以加一个定时器,每个多长时间爬取一次
var rateLimit = time.Tick(100*time.MilliSecond)

//每次爬取先接收一下,接收到再往下进行
<- rateLimit

现在的流程如下图,scheduler为每一个request开一个goroutine,每个goroutine都向channel发送数据,然后所有worker都从这个channel中接收数据

go语言如何刷leetcode go语言爬虫_爬虫_04

上述方式还有一定缺陷,比如开出来很多goroutine,都无法收回,对程序的控制力量比较弱

因此改进一下,scheduler只开一个goroutine,分别创建request队列和worker队列,在这个goroutine中将request发送给空闲的worker,在这里,worker可以直接看成chan Request类型,因为这里就是一个接收request的东西,实际上并不是完整的worker模块,再接收到之后,还要取出request,然后进行处理。
具体的流程图如下

go语言如何刷leetcode go语言爬虫_赋值_05

代码实现

package scheduler

type QueuedScheduler struct{
    RequestChan chan engine.Request
    WorkerChan chan chan engine.Request
}

func (s *QueuedScheduler) Submit(r engine.Request){
    s.RequestChan <- r
}

func (s *QueuedScheduler) WorkerReady(c chan engine.Request){
    s.WorkerChan <- c
}

func (s *QueuedScheduler) Run(){
    s.RequestChan = make(chan engine.Request)
    s.WorkerChan = make(chan chan engine.Request)
    go func(){
        var requestQ []engine.Request
        var workerQ []chan engine.Request
        for{
            var actualRequest engine.Request
            var actualWorker chan engine.Request
            if len(requestQ) > 0 && len(workerQ) > 0{
                actualRequest = requestQ[0]
                actualWorker = workerQ[0]
            }
            select{
                case r := <- s.RequestChan:
                    requestQ = append(requestQ,r)
                case w := <- s.WorkerChan:
                    workerQ = append(workerQ,w)
                case actualWorker <- actualRequest:
                    requestQ = requestQ[1:]
                    workerQ = workerQ[1:]
            }
        }
    }()
}

func (s *QueuedScheduler) ConfigeMasterChan(c chan engine.Request) {
	panic("")
}

Scheduler接口中也需要加上这几个方法

type Scheduler interface {
	Submit(Request)
	ConfigeMasterChan(chan Request)
	WorkerReady(chan Request)
	Run()
}

Run方法也需要修改一下,不再直接创建in,而是由worker自己创建

func (e *ConCurrentEngine) Run(seeds ...Request){
    out := make(chan ParseResult)
    for i:= 0;i<e.WorkerCount;i++{
        createWorker(out,e.Scheduler)
    }
    e.Scheduler.Run()
    for _,r := range seeds{
        e.Scheduler.Submit(r)
    }
    for {
        result := <- out
        for _,item := range result.Item{
            log.Printf("Got Item :%v",item)
        }
        for _,request := range result.Request{
            e.Scheduler.Submit()
        }
    }
}

func createWorker(out chan ParseResult,s Scheduler){
    in := make(chan Request)
    go func(){
        s.WorkerReady(in)
        request := <- in
        result,err := worker(request)
        if err!= nil{
            continue
        }
        out <- result
    }()
}

func worker(r Request) (ParseResult, error) {
	log.Printf("Fetching URL:%v", r.URL)
	s, err := fetcher.Fetch(r.URL)
	if err != nil {
		log.Printf("error : %s : %v", r.URL, err)
		return ParseResult{}, err
	}
	result := r.ParseFunc(s)
	return result, nil
}

重构

我们这里使用了两种方式来实现scheduler,写完第二种方式之后,发现第一种没办法使用了,因为接口Scheduler的一些方法没有实现,并且engine.Run方法也改变了

所以需要重构一下,使两种方式可以方便的切换使用。

两种方式的主要区别在于,一个只使用一个channel发送给worker,另一个对每个worker都有一个channel,所以可以在第一种方式中,为channel赋值的函数只调用一次

scheduler修改

package scheduler

import "GOProject/bingfa/engine"

type SimpleScheduler struct {
	workerChan chan engine.Request
}

func (s *SimpleScheduler) Submit(r engine.Request) {
	go func() { s.workerChan <- r }()
}

func (s *SimpleScheduler) ConfigeMasterChan(c chan engine.Request) {
	s.workerChan = c
}


//二者都添加一个方法,用于返回具体使用的channel
func (s *SimpleScheduler) WorkerRequest() chan engine.Request {
	return s.workerChan
}

func (s *SimpleScheduler) WorkerReady(w chan engine.Request) {

}

//在该方法中赋值,因为只调用了一遍,只生成一个channel
func (s *SimpleScheduler) Run() {
	c := make(chan engine.Request)
	s.workerChan = c
}
//QueuedScheduler则是在每次调用返回函数时创建一个与worker对应的channel
func (s *QueuedScheduler) WorkerRequest() chan engine.Request {
	c := make(chan engine.Request)
	return c
}
//engine中的createWorker也需要修改一下
func createWorker(in chan Request, out chan ParseResult,s Scheduler){
    go func(){
        for{
            s.WorkerReady()
            request := <- in
            result,err := worker(request)
            if err != nil{
                continue
            }
            out <- result
        }
    }()
}

去重

只需要创建一个map,判断一个url是否存在,如果存在就跳过,不存在就爬取,并且将这个url放到map中

var mmm = make(map[string]bool)

func IsRepeat(url string) bool{
    if _,ok := mmm[url];ok{
        return true
    }
    mmm[url] = true
    return false
}
//在engine.Run方法中,每次调用Scheduler.Submit前进行判断
if IsRepeat(r.URL){
    continue
}