scrapy与redis结合实现服务化的分布式爬虫

本文介绍如何结合Scrapy和Redis构建可扩展的分布式爬虫服务,通过重写爬虫空闲方法及利用Redis作为任务队列,实现多节点高效抓取。

转载请注明出处:http://blog.csdn.net/gklifg/article/details/54950028

很多场景下应该都有这样的需求:需要一个组件,向它输入一组url,要求返回这些url请求后的结果,当然这些结果通常需要一些必要的解析、规范化和结构化(比如json)。有的场景不要求系统有很高的吞吐量,有时则需要系统处理大量的请求。这时候就需要构建一个可扩展的爬虫服务,在没有任务的时候等待任务到来,一旦有任务到达就可以立即响应,并且在吞吐量要求很高时可以方便地横向扩展,避免遇到带宽、网络延迟等瓶颈。这篇文章就来介绍如何用scrapy与redis结合,实现这样一个可扩展的爬虫服务。

文章内容以scrapy-redis项目(https://github.com/rolando/scrapy-redis.git)为基础修改而成,之所以需要修改是因为完成scrapy的分布式改造只需要对spider获取新url的方法重写在配合一个自定义的spider_idle()方法就可以了,但是这个项目用redis实现了这两个部分,还重写了生成request对象后进入scrapy引擎的队列,这一步需要自己复写一个redis调度器(scheduler)及其所依赖的一套队列组件,工作量巨大,然而单就分布式改造来说,后半部分是不必要的。

这个问题有两个难点:

1.服务化。scrapy的普通用法是启动时指定一组start_url,然后从这些url里面派生新的url(或者不派生新的url),直到url耗尽,任务结束退出。这中间并没有一个可以让爬虫在没任务的时候停下来等待的环节。

2.分布式扩展。scrapy默认是单机运行的,怎么把它变成可以多台机器协作的呢?

首先解决爬虫等待的问题,scrapy内部有一个信号(signal)系统,这个系统用来通知各个组件爬虫当前的状态。当爬虫耗尽内部队列中的request时,就会触发spider_idle信号,爬虫的信号管理器会受到这个信号,我们可以在信号管理器上注册一个对应在spider_idle信号下的spider_idle()方法,这样一来当spider_idle触发是,信号管理器就会调用这个爬虫中的spider_idle()方法来做一些我们希望的事情。

我们在spider中定义了一个spider_idle()方法,然后在spdier的初始化方法中把这个方法注册到信号管理器的spider_idle信号下:

def spider_idle(self):
    """Schedules a request if available, otherwise waits."""
    # XXX: Handle a sentinel to close the spider.
    self.schedule_next_requests()
    raise DontCloseSpider

crawler.signals.connect(self.spider_idle, signal=signals.spider_idle)

我们仔细看一下spider_idle(),这个方法表达的意思是:如果spider空闲了,那么再试图从url列表中进行一次抓取,完成以后通知scrapy引擎,不要关闭spider。第一行为了保证抓取继续下去,这个方法是阻塞式的,如果目前队列中没有值,那么程序会一直等待,直到新的url到来为止。但是正常的scrapy中spdier_idle会直接引发spdier关闭动作,即使我们等到了新的url到来,引擎因为收到了空闲信号,也会关闭spider,但是scrapy留下了开关来阻止spdier被自动关闭,源码如下:

def _spider_idle(self, spider):

    res = self.signals.send_catch_log(signal=signals.spider_idle, \
        spider=spider, dont_log=DontCloseSpider)
    if any(isinstance(x, Failure) and isinstance(x.value, DontCloseSpider) \
            for _, x in res):
        return

    if self.spider_is_idle(spider):
        self.close_spider(spider, reason='finished')
可以看到,如果spider空闲时拿到一个DontCloseSpider类型的Failure那么就不会触发spider_close(),这就是raise DontCloseSpider的作用原理。这样一来爬虫服务化的问题就解决了,这个爬虫可以在队列为空时保持spider打开,并且等待新的url到来。这时候只要我们往队列里面加入新的url,就可以再次启动新一轮抓取了。现在的问题是,怎么拿到这个“队列”呢?我们先看一下scrapy的内部结构,下面是经典的结构图:


scrapy基于twisted框架,内部全部是异步的,各个组件通信就使用诸如信号、队列等等方式,所以scrapy中的队列有很多,我们重点看从spider经过引擎到scheduler这个request数据流,这就是spider产生新request的过程。spider从start_urls中逐个拿出url,将他们封装成Request对象,传递给scheduler的内部队列,scheduler以这个队列为基础进行后续操作。

spider是通过start_requests()方法来获取start_urls中的数据并封装成request对象的,所以如果让这个方法从redis里而不是start_urls里获取url,就可以把url获取的环节变成分布式的。但仅仅这样做还不够,因为spider空闲后,引擎就停止抓取了,所以需要在spider_idle()中获取新的url(利用redis的lpop()阻塞方法)并且手动调用引擎的crawl()方法来逐个抓取。这里也可以更简化一步,就是让start_requests()直接返回空列表,导致spider进入空闲,然后所有url都走spider_idle()的流程。

下面是redisSpider的完整代码:

import json
from scrapy import signals
from scrapy.exceptions import DontCloseSpider
from scrapy.spiders import Spider, CrawlSpider

from web_crawler.scrapyredis import connection


# Default batch size matches default concurrent requests setting.
DEFAULT_START_URLS_BATCH_SIZE = 16
DEFAULT_START_URLS_KEY = '%(name)s:start_urls'

class RedisMixin(object):
    """Mixin class to implement reading urls from a redis queue."""
    # Per spider redis key, default to DEFAULT_KEY.
    redis_key = None
    # Fetch this amount of start urls when idle. Default to DEFAULT_BATCH_SIZE.
    redis_batch_size = None
    # Redis client instance.
    server = None

    def start_requests(self):
        """Returns a batch of start requests from redis."""
        return self.next_requests()

    def setup_redis(self, crawler=None):
        """Setup redis connection and idle signal.

        This should be called after the spider has set its crawler object.
        """
        if self.server is not None:
            return

        if crawler is None:
            # We allow optional crawler argument to keep backwards
            # compatibility.
            # XXX: Raise a deprecation warning.
            crawler = getattr(self, 'crawler', None)

        if crawler is None:
            raise ValueError("crawler is required")

        settings = crawler.settings

        if self.redis_key is None:
            self.redis_key = settings.get(
                'REDIS_START_URLS_KEY', DEFAULT_START_URLS_KEY,
            )

        self.redis_key = self.redis_key % {'name': self.name}

        if not self.redis_key.strip():
            raise ValueError("redis_key must not be empty")

        if self.redis_batch_size is None:
            self.redis_batch_size = settings.getint(
                'REDIS_START_URLS_BATCH_SIZE', DEFAULT_START_URLS_BATCH_SIZE,
            )

        try:
            self.redis_batch_size = int(self.redis_batch_size)
        except (TypeError, ValueError):
            raise ValueError("redis_batch_size must be an integer")

        self.logger.info("Reading start URLs from redis key '%(redis_key)s' "
                         "(batch size: %(redis_batch_size)s)", self.__dict__)

        self.server = connection.from_settings(crawler.settings)
        # The idle signal is called when the spider has no requests left,
        # that's when we will schedule new requests from redis queue
        crawler.signals.connect(self.spider_idle, signal=signals.spider_idle)

    def next_requests(self):
        """Returns a request to be scheduled or none."""
        use_set = self.settings.getbool('REDIS_START_URLS_AS_SET')
        fetch_one = self.server.spop if use_set else self.server.lpop
        # XXX: Do we need to use a timeout here?
        found = 0
        while found < self.redis_batch_size:
            data = fetch_one(self.redis_key)
            if not data:
                # Queue empty.
                break
            data = json.loads(data)
            req = self.make_request_from_data(data)
            if req:
                yield req
                found += 1
            else:
                self.logger.debug("Request not made from data: %r", data)

        if found:
            self.logger.debug("Read %s requests from '%s'", found, self.redis_key)

    def make_request_from_data(self, data):
        raise NotImplementedError

    def schedule_next_requests(self):
        """Schedules a request if available"""
        for req in self.next_requests():
            self.crawler.engine.crawl(req, spider=self)

    def spider_idle(self):
        """Schedules a request if available, otherwise waits."""
        # XXX: Handle a sentinel to close the spider.
        self.schedule_next_requests()
        raise DontCloseSpider
    
class RedisSpider(RedisMixin, Spider):
    """Spider that reads urls from redis queue when idle."""

    @classmethod
    def from_crawler(self, crawler, *args, **kwargs):
        obj = super(RedisSpider, self).from_crawler(crawler, *args, **kwargs)
        obj.setup_redis(crawler)
        return obj
下面是connection.py的完整代码:
import redis
import six


from scrapy.utils.misc import load_object




DEFAULT_REDIS_CLS = redis.StrictRedis




# Sane connection defaults.
DEFAULT_PARAMS = {
    'socket_timeout': 30,
    'socket_connect_timeout': 30,
    'retry_on_timeout': True,
}


# Shortcut maps 'setting name' -> 'parmater name'.
SETTINGS_PARAMS_MAP = {
    'REDIS_URL': 'url',
    'REDIS_HOST': 'host',
    'REDIS_PORT': 'port',
}

def get_redis_from_settings(settings):
    
    params = DEFAULT_PARAMS.copy()
    params.update(settings.getdict('REDIS_PARAMS'))
    # XXX: Deprecate REDIS_* settings.
    for source, dest in SETTINGS_PARAMS_MAP.items():
        val = settings.get(source)
        if val:
            params[dest] = val


    # Allow ``redis_cls`` to be a path to a class.
    if isinstance(params.get('redis_cls'), six.string_types):
        params['redis_cls'] = load_object(params['redis_cls'])


    return get_redis(**params)


# Backwards compatible alias.
from_settings = get_redis_from_settings

def get_redis(**kwargs):
    
    redis_cls = kwargs.pop('redis_cls', DEFAULT_REDIS_CLS)
    url = kwargs.pop('url', None)
    if url:
        return redis_cls.from_url(url, **kwargs)
    else:
        return redis_cls(**kwargs)
这样一来如果我们在多台机器上启动若干个spider,只要他们的redis队列指向一处,就可以实现分布式的抓取了,我们只需要向一个指定的redis 队列中push需要抓取的url,就可以调动所有的节点来为我们工作了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值