scrapy與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,就可以調動所有的節點來爲我們工作了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章