爬蟲課堂(二十七)|使用scrapy-redis框架實現分布式爬蟲(2)源碼分析

我們在說Scrapy之所以不支持分布式,主要是因為有三大問題沒有解決:

  1. requests隊列不能集中管理。
  2. 去重邏輯不能集中管理。
  3. 保持數據邏輯不能集中管理。

scrapy-redis結合了分布式數據庫Redis,重寫了Scrapy一些比較關鍵的代碼,將Scrapy變成一個可以在多個主機上同時運行的分布式爬蟲。
scrapy-redis是github上的一個開源項目,可以直接下載到它的源代碼
但是scrapy-redis的官方文檔寫的比較簡潔,沒有提及其運行原理。如果想全面的理解分布式爬蟲的運行原理,還是得看scrapy-redis的源代碼才行,當然在這之前需要先理解Scrapy的運行原理,不然看scrapy-redis會很費勁。把Redis加入到Scrapy之后的一個運行流程圖參考下圖27-1所示:

圖27-1

scrapy-redis的源代碼很少,也比較好懂,scrapy-redis工程的主體還是是Redis和Scrapy兩個庫,工程本身實現的東西不是很多,這個工程就像膠水一樣,把這兩個插件粘結了起來。
接下來通過分析scrapy-redis源碼來理解它是如何實現分布式的爬蟲系統,它的源碼文件如下所示。

scrapy_redis
    __init__.py
    connection.py
    defaults.py
    dupefilter.py
    picklecompat.py
    pipelines.py
    queue.py
    scheduler.py
    spiders.py
    utils.py

connection.py和defaults.py

負責根據setting中配置實例化redis連接。被dupefilter和scheduler調用,總之涉及到redis存取的都要使用到這個模塊。
connection.py源碼如下:

import six

from scrapy.utils.misc import load_object

from . import defaults


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


def get_redis_from_settings(settings):
    """Returns a redis client instance from given Scrapy settings object.

    This function uses ``get_client`` to instantiate the client and uses
    ``defaults.REDIS_PARAMS`` global as defaults values for the parameters. You
    can override them using the ``REDIS_PARAMS`` setting.

    Parameters
    ----------
    settings : Settings
        A scrapy settings object. See the supported settings below.

    Returns
    -------
    server
        Redis client instance.

    Other Parameters
    ----------------
    REDIS_URL : str, optional
        Server connection URL.
    REDIS_HOST : str, optional
        Server host.
    REDIS_PORT : str, optional
        Server port.
    REDIS_ENCODING : str, optional
        Data encoding.
    REDIS_PARAMS : dict, optional
        Additional client parameters.

    """
    params = defaults.REDIS_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):
    """Returns a redis client instance.

    Parameters
    ----------
    redis_cls : class, optional
        Defaults to ``redis.StrictRedis``.
    url : str, optional
        If given, ``redis_cls.from_url`` is used to instantiate the class.
    **kwargs
        Extra parameters to be passed to the ``redis_cls`` class.

    Returns
    -------
    server
        Redis client instance.

    """
    redis_cls = kwargs.pop('redis_cls', defaults.REDIS_CLS)
    url = kwargs.pop('url', None)
    if url:
        return redis_cls.from_url(url, **kwargs)
    else:
        return redis_cls(**kwargs)

defaults.py源碼如下:

import redis


# For standalone use.
DUPEFILTER_KEY = 'dupefilter:%(timestamp)s'

PIPELINE_KEY = '%(spider)s:items'

REDIS_CLS = redis.StrictRedis
REDIS_ENCODING = 'utf-8'
# Sane connection defaults.
REDIS_PARAMS = {
    'socket_timeout': 30,
    'socket_connect_timeout': 30,
    'retry_on_timeout': True,
    'encoding': REDIS_ENCODING,
}

SCHEDULER_QUEUE_KEY = '%(spider)s:requests'
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.PriorityQueue'
SCHEDULER_DUPEFILTER_KEY = '%(spider)s:dupefilter'
SCHEDULER_DUPEFILTER_CLASS = 'scrapy_redis.dupefilter.RFPDupeFilter'

START_URLS_KEY = '%(name)s:start_urls'
START_URLS_AS_SET = False

去重邏輯集中管理:dupefilter.py

Scrapy中的去重實現是利用集合這個數據結構,在 Scrapy 分布式中去重就需要利用一個共享的集合,那么在這里使用的就是 Redis 中的集合數據結構,我們來看下它的去重類是怎樣實現的,其內實現了一個 RFPDupeFilter 類,實現如下:

import logging
import time

from scrapy.dupefilters import BaseDupeFilter
from scrapy.utils.request import request_fingerprint

from . import defaults
from .connection import get_redis_from_settings


logger = logging.getLogger(__name__)


# TODO: Rename class to RedisDupeFilter.
class RFPDupeFilter(BaseDupeFilter):
    """Redis-based request duplicates filter.

    This class can also be used with default Scrapy's scheduler.

    """

    logger = logger

    def __init__(self, server, key, debug=False):
        """Initialize the duplicates filter.

        Parameters
        ----------
        server : redis.StrictRedis
            The redis server instance.
        key : str
            Redis key Where to store fingerprints.
        debug : bool, optional
            Whether to log filtered requests.

        """
        self.server = server
        self.key = key
        self.debug = debug
        self.logdupes = True

    @classmethod
    def from_settings(cls, settings):
        """Returns an instance from given settings.

        This uses by default the key ``dupefilter:<timestamp>``. When using the
        ``scrapy_redis.scheduler.Scheduler`` class, this method is not used as
        it needs to pass the spider name in the key.

        Parameters
        ----------
        settings : scrapy.settings.Settings

        Returns
        -------
        RFPDupeFilter
            A RFPDupeFilter instance.


        """
        server = get_redis_from_settings(settings)
        # XXX: This creates one-time key. needed to support to use this
        # class as standalone dupefilter with scrapy's default scheduler
        # if scrapy passes spider on open() method this wouldn't be needed
        # TODO: Use SCRAPY_JOB env as default and fallback to timestamp.
        key = defaults.DUPEFILTER_KEY % {'timestamp': int(time.time())}
        debug = settings.getbool('DUPEFILTER_DEBUG')
        return cls(server, key=key, debug=debug)

    @classmethod
    def from_crawler(cls, crawler):
        """Returns instance from crawler.

        Parameters
        ----------
        crawler : scrapy.crawler.Crawler

        Returns
        -------
        RFPDupeFilter
            Instance of RFPDupeFilter.

        """
        return cls.from_settings(crawler.settings)

    def request_seen(self, request):
        """Returns True if request was already seen.

        Parameters
        ----------
        request : scrapy.http.Request

        Returns
        -------
        bool

        """
        fp = self.request_fingerprint(request)
        # This returns the number of values added, zero if already exists.
        added = self.server.sadd(self.key, fp)
        return added == 0

    def request_fingerprint(self, request):
        """Returns a fingerprint for a given request.

        Parameters
        ----------
        request : scrapy.http.Request

        Returns
        -------
        str

        """
        return request_fingerprint(request)

    @classmethod
    def from_spider(cls, spider):
        settings = spider.settings
        server = get_redis_from_settings(settings)
        dupefilter_key = settings.get("SCHEDULER_DUPEFILTER_KEY", defaults.SCHEDULER_DUPEFILTER_KEY)
        key = dupefilter_key % {'spider': spider.name}
        debug = settings.getbool('DUPEFILTER_DEBUG')
        return cls(server, key=key, debug=debug)

    def close(self, reason=''):
        """Delete data on close. Called by Scrapy's scheduler.

        Parameters
        ----------
        reason : str, optional

        """
        self.clear()

    def clear(self):
        """Clears fingerprints data."""
        self.server.delete(self.key)

    def log(self, request, spider):
        """Logs given request.

        Parameters
        ----------
        request : scrapy.http.Request
        spider : scrapy.spiders.Spider

        """
        if self.debug:
            msg = "Filtered duplicate request: %(request)s"
            self.logger.debug(msg, {'request': request}, extra={'spider': spider})
        elif self.logdupes:
            msg = ("Filtered duplicate request %(request)s"
                   " - no more duplicates will be shown"
                   " (see DUPEFILTER_DEBUG to show all duplicates)")
            self.logger.debug(msg, {'request': request}, extra={'spider': spider})
            self.logdupes = False

在這里我們注意到同樣實現了一個 request_seen() 方法,和 Scrapy 中的 request_seen() 方法實現極其類似,不過在這里集合使用的是 server 對象的 sadd() 操作,也就是集合不再是簡單的一個簡單數據結構了,在這里直接換成了數據庫的存儲方式。

鑒別重復的方式還是使用指紋,而指紋的獲取同樣是使用 request_fingerprint() 方法完成的。獲取指紋之后就直接嘗試向集合中添加這個指紋,如果添加成功,那么就代表這個指紋原本不存在于集合中,返回值就是 1,而最后的返回結果是判定添加結果是否為 0,如果為 1,那這個判定結果就是 False,也就是不重復,否則判定為重復。

這樣我們就成功利用 Redis 的集合完成了指紋的記錄和重復的驗證。

requests隊列集中管理:queue.py

有三個隊列的實現,首先它實現了一個父類 Base,提供一些基本方法和屬性:

class Base(object):
    """Per-spider base queue class"""

    def __init__(self, server, spider, key, serializer=None):
        """Initialize per-spider redis queue.

        Parameters
        ----------
        server : StrictRedis
            Redis client instance.
        spider : Spider
            Scrapy spider instance.
        key: str
            Redis key where to put and get messages.
        serializer : object
            Serializer object with ``loads`` and ``dumps`` methods.

        """
        if serializer is None:
            # Backward compatibility.
            # TODO: deprecate pickle.
            serializer = picklecompat
        if not hasattr(serializer, 'loads'):
            raise TypeError("serializer does not implement 'loads' function: %r"
                            % serializer)
        if not hasattr(serializer, 'dumps'):
            raise TypeError("serializer '%s' does not implement 'dumps' function: %r"
                            % serializer)

        self.server = server
        self.spider = spider
        self.key = key % {'spider': spider.name}
        self.serializer = serializer

    def _encode_request(self, request):
        """Encode a request object"""
        obj = request_to_dict(request, self.spider)
        return self.serializer.dumps(obj)

    def _decode_request(self, encoded_request):
        """Decode an request previously encoded"""
        obj = self.serializer.loads(encoded_request)
        return request_from_dict(obj, self.spider)

    def __len__(self):
        """Return the length of the queue"""
        raise NotImplementedError

    def push(self, request):
        """Push a request"""
        raise NotImplementedError

    def pop(self, timeout=0):
        """Pop a request"""
        raise NotImplementedError

    def clear(self):
        """Clear queue/stack"""
        self.server.delete(self.key)

首先看一下_encode_request()_decode_request()方法,因為我們需要把一個Request 對象存儲到數據庫中,但數據庫無法直接存儲對象,所以需要將 Request 序列化轉成字符串再存儲,而這兩個方法就分別是序列化和反序列化的操作,利用 pickle 庫來實現,一般在調用push()Request存入數據庫時會調用_encode_request()方法進行序列化,在調用pop()取出Request的時候會調用_decode_request()進行反序列化。

在父類中 len()、push() 和 pop() 方法都是未實現的,會直接拋出 NotImplementedError,因此這個類是不能直接被使用的,所以必須要實現一個子類來重寫這三個方法,而不同的子類就會有不同的實現,也就有著不同的功能。

那么接下來就需要定義一些子類來繼承 Base 類,并重寫這幾個方法,那在源碼中就有三個子類的實現,它們分別是 FifoQueue、PriorityQueue、LifoQueue,我們分別來看下它們的實現原理。

首先是 FifoQueue:

class FifoQueue(Base):
    """Per-spider FIFO queue"""

    def __len__(self):
        """Return the length of the queue"""
        return self.server.llen(self.key)

    def push(self, request):
        """Push a request"""
        self.server.lpush(self.key, self._encode_request(request))

    def pop(self, timeout=0):
        """Pop a request"""
        if timeout > 0:
            data = self.server.brpop(self.key, timeout)
            if isinstance(data, tuple):
                data = data[1]
        else:
            data = self.server.rpop(self.key)
        if data:
            return self._decode_request(data)

可以看到這個類繼承了Base類,并重寫了 len()、push()、pop() 這三個方法,在這三個方法中都是對 server 對象的操作,而 server 對象就是一個 Redis 連接對象,我們可以直接調用其操作 Redis 的方法對數據庫進行操作,可以看到這里的操作方法有 llen()、lpush()、rpop() 等,那這就代表此爬取隊列是使用的 Redis的列表,序列化后的 Request 會被存入列表中,就是列表的其中一個元素,len() 方法是獲取列表的長度,push() 方法中調用了 lpush() 操作,這代表從列表左側存入數據,pop() 方法中調用了 rpop() 操作,這代表從列表右側取出數據。

所以 Request 在列表中的存取順序是左側進、右側出,所以這是有序的進出,即先進先出,英文叫做 First Input First Output,也被簡稱作 Fifo,而此類的名稱就叫做FifoQueue。

另外還有一個與之相反的實現類,叫做LifoQueue,實現如下:

class LifoQueue(Base):
    """Per-spider LIFO queue."""

    def __len__(self):
        """Return the length of the stack"""
        return self.server.llen(self.key)

    def push(self, request):
        """Push a request"""
        self.server.lpush(self.key, self._encode_request(request))

    def pop(self, timeout=0):
        """Pop a request"""
        if timeout > 0:
            data = self.server.blpop(self.key, timeout)
            if isinstance(data, tuple):
                data = data[1]
        else:
            data = self.server.lpop(self.key)

        if data:
            return self._decode_request(data)

與 FifoQueue 不同的就是它的 pop() 方法,在這里使用的是 lpop() 操作,也就是從左側出,而 push() 方法依然是使用的 lpush() 操作,是從左側入。那么這樣達到的效果就是先進后出、后進先出,英文叫做 Last In First Out,簡稱為 Lifo,而此類名稱就叫做 LifoQueue。同時這個存取方式類似棧的操作,所以其實也可以稱作 StackQueue。

另外在源碼中還有一個子類實現,叫做PriorityQueue,顧名思義,它叫做優先級隊列,實現如下:

class PriorityQueue(Base):
    """Per-spider priority queue abstraction using redis' sorted set"""

    def __len__(self):
        """Return the length of the queue"""
        return self.server.zcard(self.key)

    def push(self, request):
        """Push a request"""
        data = self._encode_request(request)
        score = -request.priority
        # We don't use zadd method as the order of arguments change depending on
        # whether the class is Redis or StrictRedis, and the option of using
        # kwargs only accepts strings, not bytes.
        self.server.execute_command('ZADD', self.key, score, data)

    def pop(self, timeout=0):
        """
        Pop a request
        timeout not support in this queue class
        """
        # use atomic range/remove using multi/exec
        pipe = self.server.pipeline()
        pipe.multi()
        pipe.zrange(self.key, 0, 0).zremrangebyrank(self.key, 0, 0)
        results, count = pipe.execute()
        if results:
            return self._decode_request(results[0])

在這里我們可以看到 len()、push()、pop() 方法中使用了 server 對象的 zcard()、zadd()、zrange() 操作,可以知道這里使用的存儲結果是有序集合 Sorted Set,在這個集合中每個元素都可以設置一個分數,那么這個分數就代表優先級。

在 len() 方法里調用了 zcard() 操作,返回的就是有序集合的大小,也就是爬取隊列的長度,在 push() 方法中調用了 zadd() 操作,就是向集合中添加元素,這里的分數指定成 Request 的優先級的相反數,因為分數低的會排在集合的前面,所以這里高優先級的 Request 就會存在集合的最前面。pop() 方法是首先調用了 zrange() 操作取出了集合的第一個元素,因為最高優先級的 Request 會存在集合最前面,所以第一個元素就是最高優先級的 Request,然后再調用 zremrangebyrank() 操作將這個元素刪除,這樣就完成了取出并刪除的操作。

此隊列是默認使用的隊列,也就是爬取隊列默認是使用有序集合來存儲的。

調度器:scheduler.py

ScrapyRedis 還幫我們實現了一個配合 Queue、 DupeFilter 使用的調度器 Scheduler,源文件名稱是 scheduler.py。

在這里指定了一些配置,如 SCHEDULER_FLUSH_ON_START 即是否在爬取開始的時候清空爬取隊列,SCHEDULER_PERSIST 即是否在爬取結束后保持爬取隊列不清除,我們可以在 settings.py 里面自由配置,而此調度器很好的實現了對接。

接下來我們再看下兩個核心的存取方法,實現如下:

    def enqueue_request(self, request):
        if not request.dont_filter and self.df.request_seen(request):
            self.df.log(request, self.spider)
            return False
        if self.stats:
            self.stats.inc_value('scheduler/enqueued/redis', spider=self.spider)
        self.queue.push(request)
        return True

    def next_request(self):
        block_pop_timeout = self.idle_before_close
        request = self.queue.pop(block_pop_timeout)
        if request and self.stats:
            self.stats.inc_value('scheduler/dequeued/redis', spider=self.spider)
        return request

enqueue_request() 就是調度器向隊列中添加 Request,在這里做的核心操作就是調用 Queue 的 push() 操作,同時還有一些統計和日志操作,next_request() 就是從隊列中取 Request,核心操作就是調用 Queue 的 pop() 操作,那么此時如果隊列中還有 Request,則會直接取出來,接著爬取,否則當隊列為空時,則會重新開始爬取。

保持數據邏輯不能集中管理:pipelines.py

首先說明一點,如果我們僅僅是想把分布式的數據都保持到一個數據庫中,這里的操作不是必須的,而我們之所以要先把數據集中放到Redis里是為了集中管理。
源代碼如下:

from scrapy.utils.misc import load_object
from scrapy.utils.serialize import ScrapyJSONEncoder
from twisted.internet.threads import deferToThread

from . import connection, defaults


default_serialize = ScrapyJSONEncoder().encode


class RedisPipeline(object):
    """Pushes serialized item into a redis list/queue

    Settings
    --------
    REDIS_ITEMS_KEY : str
        Redis key where to store items.
    REDIS_ITEMS_SERIALIZER : str
        Object path to serializer function.

    """

    def __init__(self, server,
                 key=defaults.PIPELINE_KEY,
                 serialize_func=default_serialize):
        """Initialize pipeline.

        Parameters
        ----------
        server : StrictRedis
            Redis client instance.
        key : str
            Redis key where to store items.
        serialize_func : callable
            Items serializer function.

        """
        self.server = server
        self.key = key
        self.serialize = serialize_func

    @classmethod
    def from_settings(cls, settings):
        params = {
            'server': connection.from_settings(settings),
        }
        if settings.get('REDIS_ITEMS_KEY'):
            params['key'] = settings['REDIS_ITEMS_KEY']
        if settings.get('REDIS_ITEMS_SERIALIZER'):
            params['serialize_func'] = load_object(
                settings['REDIS_ITEMS_SERIALIZER']
            )

        return cls(**params)

    @classmethod
    def from_crawler(cls, crawler):
        return cls.from_settings(crawler.settings)

    def process_item(self, item, spider):
        return deferToThread(self._process_item, item, spider)

    def _process_item(self, item, spider):
        key = self.item_key(item, spider)
        data = self.serialize(item)
        self.server.rpush(key, data)
        return item

    def item_key(self, item, spider):
        """Returns redis key based on given spider.

        Override this function to use a different key depending on the item
        and/or spider.

        """
        return self.key % {'spider': spider.name}

先看from_settings方法,它是這個功能的入口,是通過from_crawler方法調用的。它首先是通過'server': connection.from_settings(settings) 初始化了一個Redis server,接下來會默認進入process_item方法(這點和Scrapy是一樣的),process_item會調用到_process_item方法,_process_item方法會調用item_key方法為每個爬蟲的item利用spider.name做區分,最后會在_process_item方法中使用Redis的rpush方法把item放入Redis隊列中。

序列化工具類:picklecompat.py

這里實現了loads和dumps兩個函數,其實就是實現了一個serializer,因為redis數據庫不能存儲復雜對象(value部分只能是字符串,字符串列表,字符串集合和hash,key部分只能是字符串),所以在存儲之前都需要序列化。這里使用的就是python的pickle模塊,一個兼容py2和py3的串行化工具。
源碼如下:

"""A pickle wrapper module with protocol=-1 by default."""

try:
    import cPickle as pickle  # PY2
except ImportError:
    import pickle


def loads(s):
    return pickle.loads(s)


def dumps(obj):
    return pickle.dumps(obj, protocol=-1)

爬蟲:spider.py

重寫這個spider主要是為了從Redis中讀取要爬的URL,然后執行爬取,若爬取過程中返回更多的URL,那么繼續進行直至所有的Request完成。之后繼續從Redis中讀取URL,循環這個過程。
在這個spider中通過connect signals.spider_idle信號實現對crawler狀態的監視。當idle時,返回新的make_requests_from_url(url)給引擎,進而交給調度器調度。

    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

總結

到現在,我們把實現分布式的三大問題都解決了,總結如下:

  1. requests隊列不能集中管理:在這里提供了三種隊列,使用了Redis的列表或有序集合來維護。
  2. 去重邏輯不能集中管理:去重的實現,使用了 Redis 的集合來保存 Request 的指紋來提供重復過濾。
  3. 保持數據邏輯不能集中管理:通過把各個服務器上的Spider返回的Items打個唯一key,并都使用Redis的rpush方法把Items提交到Redis隊列中。

參考資料:https://cloud.tencent.com/developer/article/1006246

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容