Scrapy 源碼剖析(2):Scrapy 是如何運行起來的?

這篇文章,我們先從最基礎的運行入口來講,來看一下 Scrapy 究竟是如何運行起來的。

scrapy 命令從哪來?

當我們基於 Scrapy 寫好一個爬蟲後,想要把我們的爬蟲運行起來,怎麼做?非常簡單,只需要執行以下命令就可以了。

 scrapy crawl <spider_name>

通過這個命令,我們的爬蟲就真正開始工作了。那麼從命令行到執行爬蟲邏輯,這個過程中到底發生了什麼?

在開始之前,不知道你有沒有和我一樣的疑惑,我們執行的 scrapy 命令從何而來?

實際上,當你成功安裝好 Scrapy 後,使用如下命令,就能找到這個命令文件,這個文件就是 Scrapy 的運行入口:

$ which scrapy
/usr/local/bin/scrapy

使用編輯打開這個文件,你會發現,它其實它就是一個 Python 腳本,而且代碼非常少。

import re
import sys

from scrapy.cmdline import execute

if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$''', sys.argv[0])
    sys.exit(execute())

安裝好 Scrapy 後,爲什麼入口點是這裏呢?

答案就在於 Scrapy 的安裝文件 setup.py 中,我們找到這個文件,就會發現在這個文件裏,已經聲明好了程序的運行入口處:

from os.path import dirname, join
from setuptools import setup, find_packages

setup(
    name='Scrapy',
    version=version,
    url='http://scrapy.org',
    ...
    entry_points={      # 運行入口在這裏:scrapy.cmdline:execute 
        'console_scripts'['scrapy = scrapy.cmdline:execute']
    },
    classifiers=[
        ...
    ],
    install_requires=[
        ...
    ],
)

我們需要關注的是 entry_points 配置,它就是調用 Scrapy 開始的地方,也就是cmdline.py 的 execute 方法。

也就是說,我們在安裝 Scrapy 的過程中,setuptools 這個包管理工具,就會把上述代碼生成好並放在可執行路徑下,這樣當我們調用 scrapy 命令時,就會調用 Scrapy 模塊下的 cmdline.py 的 execute 方法。

而且在這這裏,我們可以學到一個小技巧——如何用 Python 編寫一個可執行文件?其實非常簡單,模仿上面的思路,只需要以下幾步即可完成:

  1. 編寫一個帶有 main 方法的 Python 模塊(首行必須註明 Python 執行路徑)

  2. 去掉.py後綴名

  3. 修改權限爲可執行(chmod +x 文件名)

  4. 直接用文件名就可以執行這個 Python 文件

例如,我們創建一個文件 mycmd,在這個文件中編寫一個 main 方法,這個方法編寫我們想要的執行的邏輯,之後執行 chmod +x mycmd 把這個文件權限變成可執行,最後通過 ./mycmd 就可以執行這段代碼了,而不再需要通過 python <file.py> 方式就可以執行了,是不是很簡單?

運行入口(execute.py)

現在,我們已經知道了 Scrapy 的運行入口是 scrapy/cmdline.py 的 execute 方法,那我們就看一下這個方法。

def execute(argv=None, settings=None):
    if argv is None:
        argv = sys.argv

    # --- 兼容低版本scrapy.conf.settings的配置 ---
    if settings is None and 'scrapy.conf' in sys.modules:
        from scrapy import conf
        if hasattr(conf, 'settings'):
            settings = conf.settings
    # -----------------------------------------

 # 初始化環境、獲取項目配置參數 返回settings對象
    if settings is None:
        settings = get_project_settings()
    # 校驗棄用的配置項
    check_deprecated_settings(settings)

    # --- 兼容低版本scrapy.conf.settings的配置 ---
    import warnings
    from scrapy.exceptions import ScrapyDeprecationWarning
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", ScrapyDeprecationWarning)
        from scrapy import conf
        conf.settings = settings
    # ---------------------------------------

    # 執行環境是否在項目中 主要檢查scrapy.cfg配置文件是否存在
    inproject = inside_project()
    
    # 讀取commands文件夾 把所有的命令類轉換爲{cmd_name: cmd_instance}的字典
    cmds = _get_commands_dict(settings, inproject)
    # 從命令行解析出執行的是哪個命令
    cmdname = _pop_command_name(argv)
    parser = optparse.OptionParser(formatter=optparse.TitledHelpFormatter()\
        conflict_handler='resolve')
    if not cmdname:
        _print_commands(settings, inproject)
        sys.exit(0)
    elif cmdname not in cmds:
        _print_unknown_command(settings, cmdname, inproject)
        sys.exit(2)

    # 根據命令名稱找到對應的命令實例
    cmd = cmds[cmdname]
    parser.usage = "scrapy %s %s" % (cmdname, cmd.syntax())
    parser.description = cmd.long_desc()
    # 設置項目配置和級別爲command
    settings.setdict(cmd.default_settings, priority='command')
    cmd.settings = settings
    # 添加解析規則
    cmd.add_options(parser)
    # 解析命令參數,並交由Scrapy命令實例處理
    opts, args = parser.parse_args(args=argv[1:])
    _run_print_help(parser, cmd.process_options, args, opts)

    # 初始化CrawlerProcess實例 並給命令實例添加crawler_process屬性
    cmd.crawler_process = CrawlerProcess(settings)
    # 執行命令實例的run方法
    _run_print_help(parser, _run_command, cmd, args, opts)
    sys.exit(cmd.exitcode)

這塊代碼就是 Scrapy 執行的運行入口了,我們根據註釋就能看到,這裏的主要工作包括配置初始化、命令解析、爬蟲類加載、運行爬蟲這幾步。

瞭解了整個入口的流程,下面我會對每個步驟進行詳細的分析。

初始化項目配置

首先第一步,根據環境初始化配置,在這裏有一些兼容低版本 Scrapy 配置的代碼,我們忽略就好。我們重點來看配置是如何初始化的。這主要和環境變量和 scrapy.cfg 有關,通過調用  get_project_settings 方法,最終生成一個 Settings 實例。

def get_project_settings():
    # 環境變量中是否有SCRAPY_SETTINGS_MODULE配置
    if ENVVAR not in os.environ:
        project = os.environ.get('SCRAPY_PROJECT''default')
        # 初始化環境 找到用戶配置文件settings.py 設置到環境變量SCRAPY_SETTINGS_MODULE中
        init_env(project)
    # 加載默認配置文件default_settings.py 生成settings實例
    settings = Settings()
    # 取得用戶配置文件
    settings_module_path = os.environ.get(ENVVAR)
    # 如果有用戶配置 則覆蓋默認配置
    if settings_module_path:
        settings.setmodule(settings_module_path, priority='project')
    # 如果環境變量中有其他scrapy相關配置也覆蓋
    pickled_settings = os.environ.get("SCRAPY_PICKLED_SETTINGS_TO_OVERRIDE")
    if pickled_settings:
        settings.setdict(pickle.loads(pickled_settings)priority='project')
    env_overrides = {k[7:]: v for k, v in os.environ.items() if
                     k.startswith('SCRAPY_')}
    if env_overrides:
        settings.setdict(env_overrides, priority='project')
    return settings

在初始配置時,會加載默認的配置文件 default_settings.py,主要邏輯在 Settings 類中。

class Settings(BaseSettings):
    def __init__(self, values=None, priority='project'):
        # 調用父類構造初始化
        super(Settings, self).__init__()
        # 把default_settings.py的所有配置set到settings實例中
        self.setmodule(default_settings, 'default')
        # 把attributes屬性也set到settings實例中
        for name, val in six.iteritems(self):
            if isinstance(val, dict):
                self.set(name, BaseSettings(val, 'default')'default')
        self.update(values, priority)

可以看到,首先把默認配置文件 default_settings.py 中的所有配置項設置到 Settings 中,而且這個配置是有優先級的。

這個默認配置文件 default_settings.py 是非常重要的,我們讀源碼時有必要重點關注一下里面的內容,這裏包含了所有組件的默認配置,以及每個組件的類模塊,例如調度器類、爬蟲中間件類、下載器中間件類、下載處理器類等等。

# 下載器類
DOWNLOADER = 'scrapy.core.downloader.Downloader'
# 調度器類
CHEDULER = 'scrapy.core.scheduler.Scheduler'
# 調度隊列類
SCHEDULER_DISK_QUEUE = 'scrapy.squeues.PickleLifoDiskQueue'
SCHEDULER_MEMORY_QUEUE = 'scrapy.squeues.LifoMemoryQueue'
SCHEDULER_PRIORITY_QUEUE = 'scrapy.pqueues.ScrapyPriorityQueue'

有沒有感覺比較奇怪,默認配置中配置了這麼多類模塊,這是爲什麼?

這其實是 Scrapy 特性之一,它這麼做的好處是:任何模塊都是可替換的

什麼意思呢?例如,你覺得默認的調度器功能不夠用,那麼你就可以按照它定義的接口標準,自己實現一個調度器,然後在自己的配置文件中,註冊自己的調度器類,那麼 Scrapy 運行時就會加載你的調度器執行了,這極大地提高了我們的靈活性!

所以,只要在默認配置文件中配置的模塊類,都是可替換的。

檢查運行環境是否在項目中

初始化完配置之後,下面一步是檢查運行環境是否在爬蟲項目中。我們知道,scrapy 命令有的是依賴項目運行的,有的命令則是全局的。這裏主要通過就近查找 scrapy.cfg 文件來確定是否在項目環境中,主要邏輯在 inside_project 方法中。

def inside_project():
    # 檢查此環境變量是否存在(上面已設置)
    scrapy_module = os.environ.get('SCRAPY_SETTINGS_MODULE')
    if scrapy_module is not None:
        try:
            import_module(scrapy_module)
        except ImportError as exc:
            warnings.warn("Cannot import scrapy settings module %s: %s" % (scrapy_module, exc))
        else:
            return True
 # 如果環境變量沒有 就近查找scrapy.cfg 找得到就認爲是在項目環境中
    return bool(closest_scrapy_cfg())

運行環境是否在爬蟲項目中的依據就是能否找到 scrapy.cfg 文件,如果能找到,則說明是在爬蟲項目中,否則就認爲是執行的全局命令。

組裝命令實例集合

再向下看,就到了加載命令的邏輯了。我們知道 scrapy 包括很多命令,例如 scrapy crawl 、 scrapy fetch 等等,那這些命令是從哪來的?答案就在 _get_commands_dict 方法中。

def _get_commands_dict(settings, inproject):
    # 導入commands文件夾下的所有模塊 生成{cmd_name: cmd}的字典集合
    cmds = _get_commands_from_module('scrapy.commands', inproject)
    cmds.update(_get_commands_from_entry_points(inproject))
    # 如果用戶自定義配置文件中有COMMANDS_MODULE配置 則加載自定義的命令類
    cmds_module = settings['COMMANDS_MODULE']
    if cmds_module:
        cmds.update(_get_commands_from_module(cmds_module, inproject))
    return cmds

def _get_commands_from_module(module, inproject):
    d = {}
    # 找到這個模塊下所有的命令類(ScrapyCommand子類)
    for cmd in _iter_command_classes(module):
        if inproject or not cmd.requires_project:
            # 生成{cmd_name: cmd}字典
            cmdname = cmd.__module__.split('.')[-1]
            d[cmdname] = cmd()
    return d

def _iter_command_classes(module_name):
    # 迭代這個包下的所有模塊 找到ScrapyCommand的子類
    for module in walk_modules(module_name):
        for obj in vars(module).values():
            if inspect.isclass(obj) and \
                    issubclass(obj, ScrapyCommand) and \
                    obj.__module__ == module.__name__:
                yield obj

這個過程主要是,導入 commands 文件夾下的所有模塊,最終生成一個 {cmd_name: cmd} 字典集合,如果用戶在配置文件中也配置了自定義的命令類,也會追加進去。也就是說,我們自己也可以編寫自己的命令類,然後追加到配置文件中,之後就可以使用自己定義的命令了。

解析命令

加載好命令類後,就開始解析我們具體執行的哪個命令了,解析邏輯比較簡單:

def _pop_command_name(argv):
    i = 0
    for arg in argv[1:]:
        if not arg.startswith('-'):
            del argv[i]
            return arg
        i += 1

這個過程就是解析命令行,例如執行 scrapy crawl <spider_name>,這個方法會解析出 crawl,通過上面生成好的命令類的字典集合,就能找到 commands 目錄下的 crawl.py文件,最終執行的就是它的 Command 類。

解析命令行參數

找到對應的命令實例後,調用 cmd.process_options 方法解析我們的參數:

def process_options(self, args, opts):
    # 首先調用了父類的process_options 解析統一固定的參數
    ScrapyCommand.process_options(self, args, opts)
    try:
        # 命令行參數轉爲字典
        opts.spargs = arglist_to_dict(opts.spargs)
    except ValueError:
        raise UsageError("Invalid -a value, use -a , print_help=False)
    if opts.output:
        if opts.output == '-':
            self.settings.set('FEED_URI', 'stdout:', priority='cmdline')
        else:
            self.settings.set('FEED_URI', opts.output, priority='cmdline')
        feed_exporters = without_none_values(
            self.settings.getwithbase('FEED_EXPORTERS'))
        valid_output_formats = feed_exporters.keys()
        if not opts.output_format:
            opts.output_format = os.path.splitext(opts.output)[1].replace(".", "")
        if opts.output_format not in valid_output_formats:
            raise UsageError("Unrecognized output format '%s'set one"
                             " using the '-t' switch or as a file extension"
                             " from the supported list %s" % (opts.output_format,
                                                                tuple(valid_output_formats)))
        self.settings.set('FEED_FORMAT', opts.output_format, priority='cmdline')

這個過程就是解析命令行其餘的參數,固定參數解析交給父類處理,例如輸出位置等。其餘不同的參數由不同的命令類解析。

初始化 CrawlerProcess

一切準備就緒,最後初始化 CrawlerProcess 實例,然後運行對應命令實例的 run 方法。

cmd.crawler_process = CrawlerProcess(settings)
_run_print_help(parser, _run_command, cmd, args, opts)

我們開始運行一個爬蟲一般使用的是 scrapy crawl <spider_name>,也就是說最終調用的是 commands/crawl.py 的 run 方法:

def run(self, args, opts):
    if len(args) < 1:
        raise UsageError()
    elif len(args) > 1:
        raise UsageError("running 'scrapy crawl' with more than one spider is no longer supported")
    spname = args[0]

    self.crawler_process.crawl(spname, **opts.spargs)
    self.crawler_process.start()

run 方法中調用了 CrawlerProcess 實例的 crawl 和 start 方法,就這樣整個爬蟲程序就會運行起來了。

我們先來看CrawlerProcess初始化:

class CrawlerProcess(CrawlerRunner):
    def __init__(self, settings=None):
        # 調用父類初始化
        super(CrawlerProcess, self).__init__(settings)
        # 信號和log初始化
        install_shutdown_handlers(self._signal_shutdown)
        configure_logging(self.settings)
        log_scrapy_info(self.settings)

其中,構造方法中調用了父類 CrawlerRunner 的構造方法:

class CrawlerRunner(object):
    def __init__(self, settings=None):
        if isinstance(settings, dict) or settings is None:
            settings = Settings(settings)
        self.settings = settings
        # 獲取爬蟲加載器
        self.spider_loader = _get_spider_loader(settings)
        self._crawlers = set()
        self._active = set()

初始化時,調用了 _get_spider_loader方法:

def _get_spider_loader(settings):
    # 讀取配置文件中的SPIDER_MANAGER_CLASS配置項
    if settings.get('SPIDER_MANAGER_CLASS'):
        warnings.warn(
            'SPIDER_MANAGER_CLASS option is deprecated. '
            'Please use SPIDER_LOADER_CLASS.',
            category=ScrapyDeprecationWarning, stacklevel=2
        )
    cls_path = settings.get('SPIDER_MANAGER_CLASS',
                            settings.get('SPIDER_LOADER_CLASS'))
    loader_cls = load_object(cls_path)
    try:
        verifyClass(ISpiderLoader, loader_cls)
    except DoesNotImplement:
        warnings.warn(
            'SPIDER_LOADER_CLASS (previously named SPIDER_MANAGER_CLASS) does '
            'not fully implement scrapy.interfaces.ISpiderLoader interface. '
            'Please add all missing methods to avoid unexpected runtime errors.',
            category=ScrapyDeprecationWarning, stacklevel=2
        )
    return loader_cls.from_settings(settings.frozencopy())

這裏會讀取默認配置文件中的 spider_loader項,默認配置是 spiderloader.SpiderLoader類,從名字我們也能看出來,這個類是用來加載我們編寫好的爬蟲類的,下面看一下這個類的具體實現。

@implementer(ISpiderLoader)
class SpiderLoader(object):
    def __init__(self, settings):
        # 配置文件獲取存放爬蟲腳本的路徑
        self.spider_modules = settings.getlist('SPIDER_MODULES')
        self._spiders = {}
        # 加載所有爬蟲
        self._load_all_spiders()
        
    def _load_spiders(self, module):
        # 組裝成{spider_name: spider_cls}的字典
        for spcls in iter_spider_classes(module):
            self._spiders[spcls.name] = spcls

    def _load_all_spiders(self):
        for name in self.spider_modules:
            for module in walk_modules(name):
                self._load_spiders(module)

可以看到,在這裏爬蟲加載器會加載所有的爬蟲腳本,最後生成一個 {spider_name: spider_cls} 的字典,所以我們在執行 scarpy crawl <spider_name> 時,Scrapy 就能找到我們的爬蟲類。

運行爬蟲

CrawlerProcess 初始化完之後,調用它的 crawl 方法:

def crawl(self, crawler_or_spidercls, *args, **kwargs):
    # 創建crawler
    crawler = self.create_crawler(crawler_or_spidercls)
    return self._crawl(crawler, *args, **kwargs)

def _crawl(self, crawler, *args, **kwargs):
    self.crawlers.add(crawler)
    # 調用Crawler的crawl方法
    d = crawler.crawl(*args, **kwargs)
    self._active.add(d)

    def _done(result):
        self.crawlers.discard(crawler)
        self._active.discard(d)
        return result
    return d.addBoth(_done)

def create_crawler(self, crawler_or_spidercls):
    if isinstance(crawler_or_spidercls, Crawler):
        return crawler_or_spidercls
    return self._create_crawler(crawler_or_spidercls)

def _create_crawler(self, spidercls):
    # 如果是字符串 則從spider_loader中加載這個爬蟲類
    if isinstance(spidercls, six.string_types):
        spidercls = self.spider_loader.load(spidercls)
    # 否則創建Crawler
    return Crawler(spidercls, self.settings)

這個過程會創建 Cralwer 實例,然後調用它的 crawl 方法:

@defer.inlineCallbacks
def crawl(self, *args, **kwargs):
    assert not self.crawling, "Crawling already taking place"
    self.crawling = True

    try:
        # 到現在 纔是實例化一個爬蟲實例
        self.spider = self._create_spider(*args, **kwargs)
        # 創建引擎
        self.engine = self._create_engine()
        # 調用爬蟲類的start_requests方法
        start_requests = iter(self.spider.start_requests())
        # 執行引擎的open_spider 並傳入爬蟲實例和初始請求
        yield self.engine.open_spider(self.spider, start_requests)
        yield defer.maybeDeferred(self.engine.start)
    except Exception:
        if six.PY2:
            exc_info = sys.exc_info()

        self.crawling = False
        if self.engine is not None:
            yield self.engine.close()

        if six.PY2:
            six.reraise(*exc_info)
        raise
        
def _create_spider(self, *args, **kwargs):
    return self.spidercls.from_crawler(self, *args, **kwargs)

到這裏,纔會對我們的爬蟲類創建一個實例對象,然後創建引擎,之後調用爬蟲類的 start_requests 方法獲取種子 URL,最後交給引擎執行。

最後來看 Cralwer 是如何開始運行的額,也就是它的 start 方法:

def start(self, stop_after_crawl=True):
    if stop_after_crawl:
        d = self.join()
        if d.called:
            return
        d.addBoth(self._stop_reactor)
    reactor.installResolver(self._get_dns_resolver())
    # 配置reactor的池子大小(可修改REACTOR_THREADPOOL_MAXSIZE調整)
    tp = reactor.getThreadPool()
    tp.adjustPoolsize(maxthreads=self.settings.getint('REACTOR_THREADPOOL_MAXSIZE'))
    reactor.addSystemEventTrigger('before''shutdown', self.stop)
    # 開始執行
    reactor.run(installSignalHandlers=False)

在這裏有一個叫做 reactor 的模塊。reactor 是個什麼東西呢?它是 Twisted 模塊的事件管理器,我們只要把需要執行的事件註冊到 reactor 中,然後調用它的 run 方法,它就會幫我們執行註冊好的事件,如果遇到網絡 IO 等待,它會自動幫切換到可執行的事件上,非常高效。

在這裏我們不用深究 reactor 是如何工作的,你可以把它想象成一個線程池,只是採用註冊回調的方式來執行事件。

到這裏,Scrapy 運行的入口就分析完了,之後爬蟲的調度邏輯就交由引擎 ExecuteEngine 處理了,引擎會協調多個組件,相互配合完成整個任務的執行。

總結

總結一下,Scrapy 在真正運行前,需要做的工作包括配置環境初始化、命令類的加載、爬蟲模塊的加載,以及命令類和參數解析,之後運行我們的爬蟲類,最終,這個爬蟲類的調度交給引擎處理。

這裏我把整個流程也總結成了思維導圖,方便你理解:

好了,Scrapy 是如何運行的代碼剖析就先分析到這裏,下篇文章我們會深入剖析各個核心組件,分析它們都是負責做什麼工作的,以及它們之間又是如何協調完成抓取任務的,敬請期待。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/JXP4yE1m5-IzZY1Wye1SIg