Scrapy源码分析(二):一个参考Scrapy实现的爬虫框架TinyScrapy

前言

本文的源码来自于网络,为老男孩培训课中的讲解示例,笔者在听完讲解后发现确实讲出了Scrapy的精髓,因此在这里分享给大家,也希望大家支持原视频作者,


从Scrapy到TinyScrapy

Scrapy的整体架构我们从他的文档中基本能理清。但是到代码层级,我们发现它主要是使用的twisted,一个基于事件驱动的网络框架来实现各个组件间的逻辑处理。

因此既要弄明白twisted原理,还要理清Scrapy中本身各个类的关系,这个学习曲线还是很陡峭的。

笔者这里分析一个TinyScrapy的源码,它完全按照Scrapy的类名进行了复刻,同时去掉多余、多层的嵌套,在保留twisted基础上,基本展示了一个爬虫从命令启动,到加载爬虫代码,再到激活引擎,开始读入种子列表,发现链接、链接去重,调度器存储链接的全过程。


TinyScrapy说明

先看下TinyScrapy文件目录,完全模仿Scrapy:

spider下面放我们编写的爬虫逻辑,包括解析页面、链接发现、种子列表三块功能。

engine.py中包含了以下几个关键类:

按照从简单到复杂的顺序,来说下几个类的功能:

  1. Request,请求类。包括url和callback,url很好理解,也就是请求的url。callback这里是请求完成后方法的名称,用于下载器传回引擎的response后,引擎回调爬虫的地方。
  2. HttpResponse,页面返回类。包括上面的request请求,和content网页内容。
  3. Command,命令类。是整个爬虫的入口,Scrapy通过runspider,crawl等参数,调度这个类,激活爬虫。
  4. CrawlerProcess,爬虫进程类。主要包括_active变量,一个集合,来存储爬虫。之后使用defer的DefferList将_active变量加载到defer中,然后reactor run激活twisted过程。
  5. Crawler,爬虫类。每个爬虫中有一个引擎类,和一个spider类。我们要区分好Crawler类和Spider类的关系,Crawler类可以实实在在看成一个完整的“虫子”:里面有它的心脏“引擎”类、它的抓取逻辑“Spider”类、引擎内部的“调度器”类。
  6. ExecutionEngine,引擎类。整个框架的重头戏,内置一个调度器(可以看成一个队列)。运行后把爬虫的种子列表放入调度器,之后通过defer不断的运行_next_request方法,从调度器中取出链接进行下载。下载之后,调度Request类中的callback进行解析,新的链接放入到调度器中。
  7. Scheduler,调度器类。可以看成一个队列,先进先出的将请求缓存。

这个例子主要的关注点在于引擎类的实现,以及引擎类与Spider类的解析回调两方面。这两方面基本上是我们讲解的重点,例子忽略了以下其他的一些功能:

  1. 下载器类。主要用于下载请求,在这里直接使用twisted的getPage进行实现。忽略下载器是因为在引擎中对下载器的方法只有一次调用,功能相对简单。
  2. 爬虫中间件和下载中间件。爬虫中间件是主要是作用于引擎与“Spider”类之间的request、response的操作,下载中间件是下载器和引擎之间的操作,这里都暂时忽略掉。
  3. Pipeline。Pipeline只有在爬虫解析对象后使用到,与引擎、调度器关系不大,也忽略掉。
  4. 链接去重。链接去重在爬虫中是一个绕不开的话题,主要是在调度器中进行调用,具体实现方法与引擎等没有太多关系。
  5. 分布式实现。这里分布式主要是在调度器的缓存上进行体现,与调度器的实现有关,这个话题比较大,我们暂不讨论。

TinyScrapy各个组件源码解析:

我们从入口Command开始分析:

if __name__ == '__main__':
    """
    程序入口:命令方法
    """
    cmd = Commond()
    # 直接运行,原Scrapy中这里有runspider,crawl等方式。这里简化为run()方法。
    cmd.run()

Command类就一个方法:run。1、主要是把我们写的Spider代码放入到爬虫进程中。2、运行爬虫进程。

class Commond(object):

    def run(self):
        # 实例化一个爬虫进程。
        crawl_process = CrawlerProcess()
        # 列出我们写的解析Spider的位置。
        spider_cls_path_list = ['spider.chouti.ChoutiSpider',
                                'spider.cnblogs.CnblogsSpider',]
        for spider_cls_path in spider_cls_path_list:
            # 将这些Spider的位置依次放入到爬虫进程中。
            crawl_process.crawl(spider_cls_path)
        # 开启爬虫进程
        crawl_process.start()

CrawlerProcess类中,crawl方法用于创建Crawler类,并调用crawl方法,返回defer生成器。start方法用于调用twisted中的defer,reactor。

class CrawlerProcess(object):
    """
    开启事件循环
    """
    def __init__(self):
        """
        用一个集合类变量,存放每个Spider形成的Crawler爬虫。
        """
        self._active = set()

    def crawl(self,spider_cls_path):
        """
        :param spider_cls_path:爬虫的路径
        :return:无
        """
        # 主要将我们写的spider实例化成一个个Crawler类
        crawler = Crawler()
        # 调用类方法,返回一个defer的生成器
        d = crawler.crawl(spider_cls_path)
        # 将Crawler的defer生成器到类变量中。
        self._active.add(d)

    def start(self):
        # 将集合中的每个Crawler类放入DefferedList
        dd = defer.DeferredList(self._active)
        # 当所有defer中的对象完成后,stop
        dd.addBoth(lambda _:reactor.stop())
        # 开始运行
        reactor.run()

因此通过crawl方法,Crawler的类变量_active中保存了各个爬虫在crawl方法后返回的defer生成器。再通过start方法,将_active放到defer中,然后激活reactor方法。

下面继续看Crawler类,刚才提到,分析Crawler类时候,你要把它当成一个五脏俱全的爬虫,因此里面会有一个引擎类”ExecutionEngine“的实例,以及包含我们定制的Spider的实例:

class Crawler(object):
    """
    用户封装调度器以及引擎的...
    """
    def _create_engine(self):
        """
        每个爬虫创建一个引擎。
        :return: 
        """
        return ExecutionEngine()

    def _create_spider(self,spider_cls_path):
        """

        :param spider_cls_path:  我们定制的Spider的路径
        :return:
        """
        # 分割出代码的路径,Spider的类名
        module_path,cls_name = spider_cls_path.rsplit('.',maxsplit=1)
        import importlib
        # 使用importlib包导入代码路径
        m = importlib.import_module(module_path)
        # 获得Spider类名
        cls = getattr(m,cls_name)
        # 返回Spider类的实例
        return cls()

    @defer.inlineCallbacks
    def crawl(self,spider_cls_path):
        # 获得引擎实例
        engine = self._create_engine()
        # 获得Spider实例
        spider = self._create_spider(spider_cls_path)
        # 获得Spider种子列表的生成器
        start_requests = iter(spider.start_requests())
        # 调用引擎的open_spider方法,传入种子列表的生成器
        yield engine.open_spider(start_requests)
        # 调用引擎的start方法
        yield engine.start()

从这里开始,代码开始变得有些复杂了。

类中方法crawl首先调用内部方法_create_engine(),获得一个引擎实例,之后调度_create_spider获得Spider实例,进而得到种子列表生成器。

之后把种子列表生成器放倒引擎的open_spider方法中。然后调用引擎的start方法。

这里三个函数,我们先看简单的:

  1. _create_spider方法,它的主要功能是根据路径找到Spider类名,然后实例化。目的是下一步把种子列表生成器作为参数传给引擎。
  2. _create_engine(),主要就是返回引擎实例。
  3. crawl方法为调用1,2两个方法。并调用open_spider与start,同时它本身也是一个生成器。

下一步我们分析最难的ExecutionEngine引擎类,由于里面的方法比较复杂,我先列出上文提到的open_sipder方法,再分析其他方法:

class ExecutionEngine(object):

    def __init__(self):
        # 结束标志,当所有爬虫结束时候,设置此标识
        self._close = None
        # 引擎内部的调度器,用于缓存链接
        self.scheduler = None
        # 并发最大数量
        self.max = 5
        # 并发队列,引擎正在抓取的链接存放在这里,crawlling的长度要小于self.max
        self.crawlling = []
    def get_response_callback(self,content,request):
        """
        下载后的内容,经由此方法处理传给我们自定义的爬虫。爬虫返回的链接,则加入到调度器中
        """

    def _next_request(self):
        """
        类似递归方法,不断的从调度器中拿请求,然后再回调自己
        """
      
    @defer.inlineCallbacks
    def open_spider(self,start_requests):
        # 实例化调度器
        self.scheduler = Scheduler()
        # 激活调度器
        yield self.scheduler.open()
        # 迭代种子列表生成器,将每个请求放入调度器中。
        while True:
            try:
                req = next(start_requests)
            except StopIteration as e:
                break
            self.scheduler.enqueue_request(req)
        # 处理完种子列表后,立刻调用自己的方法_next_request
        reactor.callLater(0,self._next_request)

    @defer.inlineCallbacks
    def start(self):
        self._close = defer.Deferred()
        yield self._close

我们先看初始化方法,里面的scheduler代表调度器的实例,我们把它看成一个队列,专门存储请求。max和crawlling代表目前引擎并发数量阈值和正在处理的请求的容器,_close方法代表最后结束的一个回调过程。

再说open_spider方法,它先实例化自己的调度器,然后把种子列表都放入到这个调度器中,之后使用reactor调度自己的方法_next_request。这样看的话比较直观,open_spider只负责初始化调度器和种子的放入。而下载、与调度器交互、与爬虫Spider交互都在_next_request和get_response_callback处理。

这里在重申一边调度器在这里我们就把它看成一个简单的队列,只有入队列和出队列两种操作,不考虑去重等操作。

下面就来看open_spider中最后一行调用的_next_request方法:

 def _next_request(self):
        """
        类似递归方法,不断的从调度器中拿请求,然后再回调自己
        :return:
        """
        # 如果调度器为空并且抓取队列也为空,为_close设置callback为None参数,返回表示结束
        if self.scheduler.size() == 0 and len(self.crawlling) == 0:
            self._close.callback(None)
            return
        # 如果当前抓取队列中请求数量,小于阈值,则从调度器中取出新请求下载。同时最后回调自己。
        while len(self.crawlling) < self.max:
            # 从迭代器取出请求
            req = self.scheduler.next_request()
            # 如果请求为空,则返回
            if not req:
                return
            # 请求放入下载队列中
            self.crawlling.append(req)
            # 实际下载,这里对下载器做了简化,只调用getPage方法进行下载
            # 这里通过while循环指定了twisted中的defer数量,实现了并发。
            d = getPage(req.url.encode('utf-8'))
            # 下载结果传入自己的方法get_response_callback,进行下一步解析以及链接发现
            d.addCallback(self.get_response_callback,req)
            # 这里由于d已经完成了getPage的下载任务,因此,将d继续增加Callback到本身的方法
            # 以此保证并发数不变
            d.addCallback(lambda _:reactor.callLater(0,self._next_request))

我们在分析_next_request方法可以从递归的思维来分析,当然这里涉及到了twisted的addCallback方法来实现,我们其他文章再讲,这里可以先看成递归。

首先递归第一行就是代码最后的终止条件,当调度器中无请求,且引擎的请求容器为空时,返回。

否则判断,如果引擎的请求容器已经大于阈值,那么也返回。

如果请求容器中中还有量,那么开始进入while循环:

  1. 从调度器中取请求。
  2. 如果不为空,则放在请求容器中。
  3. 使用getPage进行下载,这里返回的是一个defer对象d。
  4. 调用自身方法get_response_callback,将下载的页面返回给我们写的解析函数,并做链接发现。
  5. 自身回调。

因此通过第五步的自身回调,整个_next_request实现了类似递归的思想,一次次的从调度器中取出请求,并且保证请求容器的量尽量达到阈值,这里就体现了并发的概念,而使用defer则体现了异步的概念。

通过_next_request,我们看到了调度器的消费者端,_next_request不断的从调度器中消费数据,那么调度器的生产者端是谁呢?一个是我们之前提到的open_spider,在最开始的种子列表生成器将请求不断发送到调度器中,另一个就是我们自定义的parse_xx方法,不断的把新请求返回给引擎,再发送到调度器中。

而引擎中最后一个要介绍的方法,get_response_callback就是我们自定义的parse_xx与引擎的桥梁,在_next_request的第4步中调用:

    def get_response_callback(self,content,request):
        """
        下载后的内容,经由此方法处理传给我们自定义的爬虫。爬虫返回的链接,则加入到调度器中
        :param content:网页文本内容
        :param request: 原始请求链接
        :return:
        """
        # 从并发队列中移除这个已经下载好的request
        self.crawlling.remove(request)
        # 将content和request实例化为response
        response = HttpResponse(content,request)
        # 这一步的request的callback十分重要,它就是我们方法中定义的callback方法:parse_xxx,这里把response传递给parse_xxx方法
        result = request.callback(response)
        # 获得result对象,也就是我们parse_xxx中return或者yield的方法
        import types
        # 如果result是生成器类型,这里就认为是新的请求(暂时不考虑Item的情况)
        if isinstance(result,types.GeneratorType):
            # 将生成器中的值取出,多个值的话迭代放入到调度器的队列中。
            for req in result:
                self.scheduler.enqueue_request(req)

get_response_callback在获得抓取到的网页和原始请求request后,先将两者实例化为response,然后传回我们的parse_xx方法。而parse方法在哪里呢?就在原始请求的request的callback中,因此我们通过调用request.callback(response),这样就调度了我们的parse_xx方法。

由于parse_xx方法会yield新发现的请求,因此会返回一个生成器,并赋值给result。

我们判断下如果result是生成器类型,那就迭代取出请求(这里假设我们的爬虫只会yield request不会yield item),把请求放入调度器中。

到这里,get_response_callback就分析完成了。我们整个引擎也分析完成了。

其实在我看来,引擎中涉及了三个重要的知识点:

  1. 引擎内部的请求容器crawlling的使用。主要通过容器和while循环来保证并发数在可控的范围内。
  2. _next_request的递归思想。使用defer异步的思维,不断调用自身,结合1中while循环,完成了异步与并发。
  3. _next_request消费者端与open_spider,get_response_callback生产者端的数据消费关系。通过调度器,三个方法不断的生产消费数据,达到爬虫一直运行的状态。通过容器crawling,间接的维持了生产者消费者的生产消费速度。

引擎在1、2两点的基础上,使用调度器完成了3的生产者消费者关系模型,我们最后简单的看下调度器的实现:


class Scheduler(object):
    """
    任务调度器
    """
    def __init__(self):
        self.q = Queue()

    def open(self):
        pass

    def next_request(self):
        """
        消费端,请求出队列
        :return:请求
        """
        try:
            req = self.q.get(block=False)
        except Exception as e:
            req = None
        return req

    def enqueue_request(self,req):
        """
        生产端,请求入队列
        :param req: 请求
        :return:
        """
        self.q.put(req)

    def size(self):
        return self.q.qsize()

从生产者消费者的模式来看,队列进代表生产,队列出代表消费,很好的平衡了引擎取链接和爬虫链接发现的关系。


尾声

本篇文章主要通过TinyScrapy这个模拟版本的Scrapy重点分析了引擎类的内部构造,引擎类的方法间生产消费关系,引擎类与调度器交互关系。

但是Scrapy的核心还有很多,包括调度器的具体实现,调度器的链接去重,中间件的加载等等。在之后的文章中,笔者会与大家逐一分析这些专题。

 

参考内容:

1.https://www.youtube.com/watch?v=E-fTiygBNEI&t=297s,老男孩的python全栈学习

2.https://www.youtube.com/watch?v=3R4gP6Egh5M演讲人主要是twisted的贡献者。

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值