Scrapy SEP-009 深度解读:单例移除与 Crawler 根对象模型的设计演进
SEP-009(sep/sep-009.rst)是 Scrapy 增强提案(SEP,Scrapy Enhancement Proposals)中关于"移除单例(Singleton removal)"的设计文档。它提出了用单一的 Crawler 根对象取代各模块级单例的重构方案,并由此奠定了 Scrapy 至今沿用的组件注入模型。读完全文,你将理解 Scrapy 从 0.7 时代的单例架构演进到当前 Crawler / CrawlerProcess / from_crawler() 对象体系的完整脉络,并能对照当前仓库源码,看清每一个设计决策(包括提案中遗留的未决问题)最终是如何落地的。
提案背景:Scrapy 0.7 的七个单例
单例(Singleton)是一种"全局唯一实例"的设计模式,在早期项目中常被用来共享状态,但代价是模块间隐式耦合、难以测试、难以在同一进程中并行运行多个实例。
SEP-009 开宗明义:该提案提出对 Scrapy 进行重构以移除单例,"这将带来更干净的 API,并使我们能够实现 SEP-004 中提出的库 API"。原文档列出了 Scrapy 0.7 中实际存在的七个单例:
| 单例组件 | 当时的模块级实例 |
|---|---|
| 执行引擎(Execution engine) | scrapy.core.engine.scrapyengine |
| 执行管理器(Execution manager) | scrapy.core.manager.scrapymanager |
| 扩展管理器(Extension manager) | scrapy.extension.extensions |
| Spider 管理器(Spider manager) | scrapy.spider.spiders |
| 统计收集器(Stats collector) | scrapy.stats.stats |
| 日志系统(Logging system) | scrapy.log |
| 信号系统(Signals system) | scrapy.xlib.pydispatcher |
这些组件都被挂在各自模块的模块级变量上,任何地方 import 一下就能拿到"全局那份"。问题在于:同一个进程里跑不了第二个隔离的爬虫实例,组件之间也无法通过显式的依赖关系表达协作方式。
提案的架构:一个 Crawler 作为唯一根对象
SEP-009 提出的核心方案是:引入一个"根"对象 Crawler(取代当时的 Execution Manager),让上述所有单例都成为这个对象的成员。原文档给出的完整对象树如下:
- crawler:
scrapy.crawler.Crawler实例(取代当时的scrapy.core.manager.ExecutionManager)——用一个Settings对象实例化- crawler.settings:
scrapy.conf.Settings实例(在__init__方法中传入) - crawler.extensions:
scrapy.extension.ExtensionManager实例 - crawler.engine:
scrapy.core.engine.ExecutionEngine实例crawler.engine.schedulercrawler.engine.scheduler.middleware—— 访问调度器中间件
crawler.engine.downloadercrawler.engine.downloader.middleware—— 访问下载器中间件
crawler.engine.scrapercrawler.engine.scraper.spidermw—— 访问 Spider 中间件
- crawler.spiders:
SpiderManager实例(具体类由SPIDER_MANAGER_CLASS设置项给出) - crawler.stats:
StatsCollector实例(具体类由STATS_CLASS设置项给出) - crawler.log:Logger 类,其方法取代当时
scrapy.log的函数。日志在Crawler实例化时(若启用)即启动,因此不再需要单独的日志启动函数crawler.log.msg
- crawler.signals:信号处理
crawler.signals.send()—— 等同于pydispatch.dispatcher.send()crawler.signals.connect()—— 等同于pydispatch.dispatcher.connect()crawler.signals.disconnect()—— 等同于pydispatch.dispatcher.disconnect()
- crawler.settings:
这个对象树和当前仓库中的实现高度同构。查看 Crawler 类定义(scrapy/crawler.py),可以看到它确实以 spidercls 和 Settings 为构造输入,并把 addons、signals、stats、engine、extensions 等全部挂在实例属性上:
class Crawler:
engine: _LateAttribute[ExecutionEngine] = _LateAttribute()
extensions: _LateAttribute[ExtensionManager] = _LateAttribute()
logformatter: _LateAttribute[LogFormatter] = _LateAttribute()
request_fingerprinter: _LateAttribute[RequestFingerprinterProtocol] = _LateAttribute()
stats: _LateAttribute[StatsCollector] = _LateAttribute()
def __init__(
self,
spidercls: type[Spider],
settings: dict[str, Any] | Settings | None = None,
init_reactor: bool = False,
):
...
self.spidercls: type[Spider] = spidercls
self.settings: Settings = settings.copy()
self.spidercls.update_settings(self.settings)
self.addons: AddonManager = AddonManager(self)
self.signals: SignalManager = SignalManager(self)
与提案相比有几处演进,都体现为"对同一思想的工程化完善":
Settings在构造时即确定并拷贝。提案设想crawler.settings在__init__中传入,当前实现中self.settings = settings.copy()后还会合并 Spider 类上的设置(update_settings),并且只在爬取真正启动时才调用_apply_settings()来实例化stats、engine、extensions等重组件并冻结设置。crawler.log没有落地为对象。从源码结构看,提案中"Logger 类 +crawler.log.msg"的方向被 Python 标准logging模块取代——当前Crawler上并没有log属性,日志统一走logging体系,并由scrapy.utils.log.configure_logging在CrawlerProcess初始化时配置(见 CrawlerProcessBase.init)。- 组件按设置项构造。提案中的
SPIDER_MANAGER_CLASS、STATS_CLASS等"具体类由设置项给出"的思想被完整保留:_apply_settings()中self.stats = load_object(self.settings["STATS_CLASS"])(self)正是"由STATS_CLASS设置项决定具体类、构造时把Crawler自身传进去"的直接实现。 crawler.spiders演进为 Spider Loader。当前的 Spider 发现逻辑收敛到 scrapy/spiderloader.py,由get_spider_loader()按设置加载,不再作为Crawler的常驻属性,但"Spider 管理器是可插拔组件"的定位一脉相承。
组件注入机制:__init__(self, crawler) 取代模块级 import
SEP-009 最关键的一节是"移除单例后所需的代码变更"。原文档指出:所有组件(extensions、middlewares 等)都会在各自的 __init__ 方法中接收这个 Crawler 对象,这将(而且是唯一)成为访问其他任何组件的机制——取代过去"从各自模块 import 单例"的做法。原文档给出的改造前/改造后对照代码,值得完整保留:
改造前(依赖模块级 settings 单例):
#!python
from scrapy.core.exceptions import NotConfigured
from scrapy.conf import settings
class SomeMiddleware(object):
def __init__(self):
if not settings.getbool("SOMEMIDDLEWARE_ENABLED"):
raise NotConfigured
改造后(依赖注入的 crawler 参数):
#!python
from scrapy.core.exceptions import NotConfigured
class SomeMiddleware(object):
def __init__(self, crawler):
if not crawler.settings.getbool("SOMEMIDDLEWARE_ENABLED"):
raise NotConfigured
当前仓库中这一机制已经完全固化,且比提案更进一步——约定俗成的 __init__(self, crawler) 签名被提升为一个标准类方法 from_crawler(crawler)。以 MiddlewareManager 基类(scrapy/middleware.py)为例:
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
mwlist = cls._get_mwlist_from_settings(crawler.settings)
middlewares = []
enabled = []
for clspath in mwlist:
try:
mwcls = load_object(clspath)
mw = build_from_crawler(mwcls, crawler)
...
即:从 crawler.settings 读取启用列表,逐类加载,构造时统一注入 crawler;中间件若在 __init__ / from_crawler 中 raise NotConfigured 即被跳过(可选组件的禁用开关)。这一模式由 build_from_crawler()(scrapy/utils/misc.py)统一支撑:类若有 from_crawler 方法就走 from_crawler(crawler, *args, **kwargs),否则直接调用 __init__。
这正是 SEP-009 预言的"稳定核心 API"的直接成果:扩展、下载器中间件、Spider 中间件、信号管理器全部拿到的是"自己这次爬取"的 Crawler 实例,而不是全局共享的单例——由此同一进程内运行多个互不干扰的爬虫实例才成为可能。
命令行运行流程:从三步设想走到 execute()
SEP-009 的"Running from command line"一节描述了当时的命令行流程(彼时命令行是唯一受支持的运行方式):
- 实例化一个
Settings对象,装入SCRAPY_SETTINGS_MODULE中的值,以及各命令的覆盖值; - 用该
Settings对象实例化一个Crawler对象(Crawler依据给定设置实例化其全部组件); - 用命令行传入的 URL 或域名调用
Crawler.crawl()。
当前仓库中这条链路依然清晰可辨。入口是 execute()(scrapy/cmdline.py):
def execute(argv: list[str] | None = None, settings: Settings | None = None) -> None:
...
if settings is None:
settings = get_project_settings() # 第 1 步:加载项目设置
...
settings.setdict(cmd.default_settings, priority="command") # 命令级覆盖
cmd.settings = settings
...
if cmd.requires_crawler_process:
if (...TWISTED_REACTOR 为 asyncio 且未强制...) or not settings.getbool("TWISTED_REACTOR_ENABLED"):
cmd.crawler_process = AsyncCrawlerProcess(settings)
else:
cmd.crawler_process = CrawlerProcess(settings) # 第 2 步:构造根对象容器
_run_print_help(parser, _run_command, cmd, args, opts) # 第 3 步:执行爬取
可以看到三步结构被原样继承,只是发生了两处扩展:其一,"第 1 步"中 get_project_settings() 从 scrapy.cfg / SCRAPY_SETTINGS_MODULE 加载设置,命令再叠加自己的 default_settings;其二,"第 2 步"从单个 Crawler 泛化为 CrawlerProcess(scrapy/crawler.py 中 CrawlerProcess 继承自 CrawlerRunner),它负责管理多个 Crawler 实例,并额外承担日志配置、信号处理(优雅停机)、DNS 解析器安装等进程级职责——这些正是"进程根"与"爬虫根"分离后的自然分层。
作为库使用:Crawler + Settings 两步走
原文档"Using Scrapy as a library"一节给出了库 API 的两步用法:
- 实例化一个
Settings对象(默认只有默认设置),并覆盖想要的设置; - 用该
Settings对象实例化一个Crawler。
当前代码中这一步是字面兑现的——Crawler.__init__ 接受 dict 或 Settings,dict 会被包成 Settings 并拷贝;爬取时再传入爬虫初始参数:
from scrapy import Crawler, Spider
from scrapy.settings import Settings
class QuotesSpider(Spider):
name = "quotes"
start_urls = ["https://example.com/"]
settings = Settings()
settings.setbot # 按需覆盖任意设置,例如:
settings.set("USER_AGENT", "mybot/1.0", priority="project")
settings.set("ROBOTSTXT_OBEY", True)
crawler = Crawler(QuotesSpider, settings=settings)
crawler.crawl() # 同步接口;asyncio 场景可用 crawl_async()
Crawler.crawl() 内部流程(scrapy/crawler.py#L259-L290)即 SEP-009 设想中"Crawler 依据给定设置实例化其全部组件"的精确实现:创建 Spider 实例 → _apply_settings()(加载 stats、extensions、addons、reactor 等)→ 创建 ExecutionEngine → open_spider → start。
未决问题的最终答案:Spider 如何访问设置
SEP-009 最后列出了"Open issues to resolve",其中对 Spider 开发者最相关的一个是:Spider 应该怎样访问设置? 提案给出了两个选项:
- Option 1:也把
Crawler对象传给 Spider 的__init__。- 优点:访问所有组件(对 Spider 而言最重要的是 settings 和 signals)的通道统一;
- 缺点:Spider 代码可以访问(并控制)任意 crawler 组件——而官方并不希望 Spider 去摆弄爬虫内部("如果真需要,请写扩展或 Spider 中间件")。
- Option 2:只把
Settings对象传给 Spider 的__init__,之后通过self.settings访问,就像通过self.log访问日志那样。- 缺点:还需要一种方式来访问 stats 等组件。
当前仓库的实现可以看作"以 Option 2 为主、吸收 Option 1 优点"的折中。查看 Spider.from_crawler 与 _set_crawler(scrapy/spiders/init.py):
@classmethod
def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self:
spider = cls(*args, **kwargs)
spider._set_crawler(crawler)
return spider
def _set_crawler(self, crawler: Crawler) -> None:
self.crawler: Crawler = crawler
self.settings: BaseSettings = crawler.settings
crawler.signals.connect(self.close, signals.spider_closed)
也就是说:Spider 的 __init__ 保持"无 crawler 参数"的干净签名(Option 1 担心的问题被规避),但框架在构造后会通过 from_crawler 把 crawler 与 settings 两个属性挂到实例上,使 Spider 中 self.settings 可用(Option 2 的易用性保留),而 self.crawler 的存在也提供了访问 stats 等组件的官方通道(回应 Option 2 的缺点)。注意 self.settings 是 Spider 专属的只读语义——与提案中"不鼓励 Spider 控制 crawler 内部"的原则一致,Spider 拿到的是 crawler.settings 这一份冻结后的设置视图。
另外,提案中"是否要把 Settings 传给 ScrapyCommand.add_options()"这一开放问题,在现行命令基类中也已有答案:execute() 在解析参数前显式执行 cmd.settings = settings(scrapy/cmdline.py#L193-L203),命令类通过 self.settings 读取设置,而非 import 任何全局对象。
小结:一份 2009 年提案在今天的投影
SEP-009 状态栏标注为 "Document in progress (being written)",但它描绘的骨架已在当前 Scrapy 中基本长成实体:
| SEP-009 提案 | 当前仓库对应实现 |
|---|---|
单一根对象 Crawler,成员含 settings/extensions/engine/stats/signals |
Crawler,settings/signals/stats/engine/extensions 均为实例属性 |
组件在 __init__ 中接收 Crawler,作为访问其他组件的唯一机制 |
from_crawler(crawler) 约定 + build_from_crawler() |
具体类由 STATS_CLASS 等设置项给出 |
_apply_settings() 中 load_object(self.settings["STATS_CLASS"])(self) |
命令行三步:加载 Settings → 构造根对象 → crawl() |
execute():get_project_settings() → CrawlerProcess(settings) → 命令执行 |
| 库 API 两步:构造 Settings → 构造 Crawler | Crawler(spidercls, settings) + crawl() / crawl_async() |
| 未决问题:Spider 访问 settings 的 Option 1 / Option 2 | Spider._set_crawler():self.settings = crawler.settings,self.crawler 同步可用 |
从单例到根对象的意义,不仅是"更干净的 API":它让依赖关系显式化、让同一进程内的多爬虫并行(CrawlerRunner / CrawlerProcess)成为可能,也让"把 Scrapy 当库用"获得了稳定可文档化的核心契约。研究 sep/ 目录下这份提案及其后续 SEP,是理解 Scrapy 内部架构设计动机的高效入口。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00