前言
本文的源码来自于网络,为老男孩培训课中的讲解示例,笔者在听完讲解后发现确实讲出了Scrapy的精髓,因此在这里分享给大家,也希望大家支持原视频作者,
从Scrapy到TinyScrapy
Scrapy的整体架构我们从他的文档中基本能理清。但是到代码层级,我们发现它主要是使用的twisted,一个基于事件驱动的网络框架来实现各个组件间的逻辑处理。
因此既要弄明白twisted原理,还要理清Scrapy中本身各个类的关系,这个学习曲线还是很陡峭的。
笔者这里分析一个TinyScrapy的源码,它完全按照Scrapy的类名进行了复刻,同时去掉多余、多层的嵌套,在保留twisted基础上,基本展示了一个爬虫从命令启动,到加载爬虫代码,再到激活引擎,开始读入种子列表,发现链接、链接去重,调度器存储链接的全过程。
TinyScrapy说明
先看下TinyScrapy文件目录,完全模仿Scrapy:
spider下面放我们编写的爬虫逻辑,包括解析页面、链接发现、种子列表三块功能。
engine.py中包含了以下几个关键类:
按照从简单到复杂的顺序,来说下几个类的功能:
- Request,请求类。包括url和callback,url很好理解,也就是请求的url。callback这里是请求完成后方法的名称,用于下载器传回引擎的response后,引擎回调爬虫的地方。
- HttpResponse,页面返回类。包括上面的request请求,和content网页内容。
- Command,命令类。是整个爬虫的入口,Scrapy通过runspider,crawl等参数,调度这个类,激活爬虫。
- CrawlerProcess,爬虫进程类。主要包括_active变量,一个集合,来存储爬虫。之后使用defer的DefferList将_active变量加载到defer中,然后reactor run激活twisted过程。
- Crawler,爬虫类。每个爬虫中有一个引擎类,和一个spider类。我们要区分好Crawler类和Spider类的关系,Crawler类可以实实在在看成一个完整的“虫子”:里面有它的心脏“引擎”类、它的抓取逻辑“Spider”类、引擎内部的“调度器”类。
- ExecutionEngine,引擎类。整个框架的重头戏,内置一个调度器(可以看成一个队列)。运行后把爬虫的种子列表放入调度器,之后通过defer不断的运行_next_request方法,从调度器中取出链接进行下载。下载之后,调度Request类中的callback进行解析,新的链接放入到调度器中。
- Scheduler,调度器类。可以看成一个队列,先进先出的将请求缓存。
这个例子主要的关注点在于引擎类的实现,以及引擎类与Spider类的解析回调两方面。这两方面基本上是我们讲解的重点,例子忽略了以下其他的一些功能:
- 下载器类。主要用于下载请求,在这里直接使用twisted的getPage进行实现。忽略下载器是因为在引擎中对下载器的方法只有一次调用,功能相对简单。
- 爬虫中间件和下载中间件。爬虫中间件是主要是作用于引擎与“Spider”类之间的request、response的操作,下载中间件是下载器和引擎之间的操作,这里都暂时忽略掉。
- Pipeline。Pipeline只有在爬虫解析对象后使用到,与引擎、调度器关系不大,也忽略掉。
- 链接去重。链接去重在爬虫中是一个绕不开的话题,主要是在调度器中进行调用,具体实现方法与引擎等没有太多关系。
- 分布式实现。这里分布式主要是在调度器的缓存上进行体现,与调度器的实现有关,这个话题比较大,我们暂不讨论。
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方法。
这里三个函数,我们先看简单的:
- _create_spider方法,它的主要功能是根据路径找到Spider类名,然后实例化。目的是下一步把种子列表生成器作为参数传给引擎。
- _create_engine(),主要就是返回引擎实例。
- 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循环:
- 从调度器中取请求。
- 如果不为空,则放在请求容器中。
- 使用getPage进行下载,这里返回的是一个defer对象d。
- 调用自身方法get_response_callback,将下载的页面返回给我们写的解析函数,并做链接发现。
- 自身回调。
因此通过第五步的自身回调,整个_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就分析完成了。我们整个引擎也分析完成了。
其实在我看来,引擎中涉及了三个重要的知识点:
- 引擎内部的请求容器crawlling的使用。主要通过容器和while循环来保证并发数在可控的范围内。
- _next_request的递归思想。使用defer异步的思维,不断调用自身,结合1中while循环,完成了异步与并发。
- _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的贡献者。