Obscura 架构解析——8 crate 分层、V8 隔离与 CDP 调度

Obscura 的 8 个 crate 分层设计、单 V8 Isolate 架构、DOM 树实现、CDP 调度与生命周期管理的完整介绍与使用说明。

亿牛云技术团队2026年7月5日7 分钟阅读

从命令到字节的完整路径

当 Puppeteer 调用 page.goto('https://example.com') 时,Obscura 内部发生了什么?本文沿着这条路径,介绍 8 个 crate 各自承担的角色,以及为什么架构是这样切分的。理解架构不是为了读源码,而是为了在排障、调优、扩展时知道该看哪一层。

8 个 crate 一览

obscura-cli       CLI 入口:fetch / serve / scrape / mcp
obscura-cdp       CDP WebSocket 服务:消息路由、Domain 处理器
obscura-browser   Page 类型:导航、生命周期、上下文管理
obscura-js        V8 运行时:deno_core、bootstrap.js、Rust ops
obscura-dom       DOM 树:html5ever 解析、选择器、序列化
obscura-net       HTTP 客户端:reqwest、wreq stealth 客户端、Cookie 轴、Tracker Blocklist
obscura-mcp       MCP 协议服务器:30+ 个工具
obscura           嵌入库 API:Browser / Page / Element / CookieStore

跨 crate 调用原则:上层调用下层,不横向依赖。这条规则决定了你排障时的方向——网络层的异常不会因为 DOM 层的修改而消失,反之亦然。

请求流全景

CDP 客户端发送一条 Page.navigate 命令的完整路径:

CDP Client (Puppeteer)
       │ WebSocket frame

obscura-cdp/server.rs        接受连接,按 sessionId 路由


obscura-cdp/dispatch.rs      方法路由,获取 v8_lock


obscura-cdp/domains/page.rs  Page.navigate 处理器


obscura-browser/page.rs      执行 navigate_with_wait

       ├──► obscura-net/client.rs       HTTP 请求

       ├──► obscura-dom/tree.rs         解析 HTML 构建 DOM 树

       └──► obscura-js/runtime.rs       执行内联脚本

                 └──► bootstrap.js + ops.rs   DOM 绑定

调度器通过 WebSocket 将 CDP 事件(Network.requestWillBeSentPage.frameNavigatedPage.lifecycleEvent)发送回客户端。这套事件链就是 Puppeteer/Playwright 判断"页面加载完成"的依据——客户端在收到匹配的 lifecycleEvent 后才会 resolve goto

单 V8 Isolate 设计

所有 Page 共享一个 V8 Isolate。这是 Obscura 最重要的架构决策,也是它内存占用低的根本原因——一个 Chrome 进程会为每个标签页分配独立的渲染进程和 Isolate,而 Obscura 用一个 Isolate 服务所有页面。

// obscura_js::v8_lock::global() 是一个 tokio::sync::Mutex
let _guard = obscura_js::v8_lock::global().lock().await;
page.evaluate(expr).await;

V8 Isolate 是单线程的,因此所有需要执行 JavaScript 的操作都必须先获取这个全局锁:

  • Runtime.evaluatecallFunctionOn
  • Page.navigate(需要运行页面脚本)
  • DOM 操作(Node 插入、属性查询)

并发细节:dispatch.rs 通过 process_with_interception 将长操作(navigation、eval)spawn 到 tokio LocalSet,释放调度器继续处理其他 CDP 消息。这就是为什么 Target.createTarget 从多个并发客户端调用时能够立即返回:每个 newPage 立即返回,实际导航在后台任务中执行。

这个设计对你的使用方式有直接影响:

  • 并发页面要控制数量。虽然 newPage 立即返回,但所有页面的 JS 执行串行通过同一把锁。开 50 个 Page 同时跑重 JS,等于 50 个任务排队抢一把锁。
  • CPU 密集型 JS 会拖累其他页面。一个页面跑死循环(虽然会被 watchdog 终止),在终止前会阻塞所有其他页面的 JS 执行。
  • 要真正并行就用多进程obscura serve --workers N 起多个进程,每个进程一个独立 Isolate,这才是水平扩展的正确方式。

DOM 树实现

Obscura 使用 html5ever 解析 HTML,通过自定义 tree_sink 构建 DOM 树,选择器引擎用的是 Servo 的 selectors crate。

// obscura-dom/src/tree.rs — 核心数据结构
struct Node {
    id: NodeId,
    parent: Option<NodeId>,
    children: Vec<NodeId>,
    node_type: NodeType,  // Element / Text / Comment / Document
}
 
struct Tree {
    nodes: Vec<Node>,
    root: NodeId,
}

几个对使用有影响的设计特性:

  • 循环引用守卫append_childinsert_before 拒绝将祖先节点插入为子节点。这类树操作守卫确保 DOM 树操作不会产生环,树遍历(如 descendants())始终能在有限步内完成。
  • descendants() 长度上限:作为额外兜底,树遍历带有一个硬编码长度上限,即便遇到异常结构也不会无限递归。
  • 选择器引擎:使用 selectors crate(Servo 的 CSS 选择器实现),支持标准的 CSS 选择器语法,所以你通过 CDP 的 querySelector 或 CLI 的 --selector 用的选择器和浏览器里写的一致。

CDP 调度架构

obscura-cdp 是 WebSocket 服务端,核心组件是 server.rs(接受连接)、dispatch.rs(按 sessionId 路由消息)、domains/*.rs(每个 CDP Domain 一个处理器)。

Session 模型:每个 CDP 客户端连接对应一个或多个 Target。Session ID 格式为 "{targetId}-session"。调度器根据传入帧的 sessionId 将消息路由到正确的 Page。

Target 创建Target.createTarget 创建新的 Page。关闭 WebSocket 会 detach 所有 session,但 Page 继续运行——这一点和 Chrome 不同,断连不会销毁页面状态。

长操作处理process_with_interception 是调度器中的核心机制——它把需要长时间运行的操作(navigation、JS eval)spawn 到独立任务,同时释放当前任务继续处理其他 CDP 消息。这就是为什么单个慢页面的导航不会阻塞其他 session 的命令。

生命周期状态机

Page 的生命周期由 obscura-browser/lifecycle.rs 管理:

init → commit → domcontentloaded → load → networkidle2 → networkidle0
  • init:用户发起导航
  • commit:服务器返回响应首字节
  • domcontentloaded:DOM 解析完成
  • load:所有同步资源加载完成
  • networkidle2:500ms 内活跃连接 ≤ 2
  • networkidle0:500ms 内无活跃连接

Page.navigatewaitUntil 参数对应上述事件。Puppeteer 的 goto 在客户端等待匹配的 Page.lifecycleEvent

理解这条链对调试 goto 超时很有用:如果 goto 卡住,本质是某个 lifecycleEvent 一直没到。用 obscura serve --verbose 看日志里的事件序列,能快速定位是网络层(load 不来)还是页面层(networkidle 一直被长轮询打破)。

Storage 持久化

--storage-dir 参数启用持久化存储:

<storage-dir>/
├── cookies.json          # Cookie Jar 序列化
└── localStorage/
    ├── <origin-1>.json   # 每个源的 localStorage
    └── <origin-2>.json

启动时读取,每次导航和优雅关闭时写入。多身份场景的做法是起多个 obscura serve 进程,每个用不同的 --storage-dir 和端口,互不干扰。

Stealth 集成

--stealth 是全局 CLI 参数(不是子命令参数),作用于所有子命令:

  • obscura fetch https://example.16yun.cn --stealth
  • obscura serve --stealth
  • obscura scrape url1 url2 --stealth
  • obscura mcp --stealth

scrape 命令通过 OBSCURA_STEALTH 环境变量将 stealth 设置传递给每个 Worker 进程。

Stealth 模式在 obscura-net 层将默认的 reqwest 客户端替换为 wreq 客户端,同时在 obscura-js 层的 bootstrap.js 中应用 JS API 伪装。这意味着 Stealth 的开关只影响两个 crate,对其他层透明——这也是分层设计带来的好处。

为什么是 8 个 crate

分层设计不是为了好看,而是有实际的工程收益:

  • 单一职责:每个 crate 对应一个明确的能力域,新开发者可以快速定位代码。改 DOM 不用翻网络层,改 CDP 调度不用碰 V8。
  • 编译缓存:修改某个 crate 不需要重新编译整个 V8。V8 的源码编译是最耗时的部分(首次约 5 分钟),分层让日常迭代的增量编译控制在秒级。
  • 测试边界:每个 crate 可以独立测试,DOM 测试不需要网络,CDP 测试不需要 V8。这让单元测试跑得快、定位准。
  • 可选依赖:Stealth 功能只在 obscura-net 层,不影响核心。不需要反检测的用户用默认 rustls 构建,不引入 BoringSSL/cmake 依赖。

这套分层也决定了扩展 Obscura 的切入点:加 CDP 方法在 obscura-cdp/domains/ 下加处理器;加 Web API 在 bootstrap.js 加 JS shim 配合 Rust op;改网络行为在 obscura-net。每一类扩展都有明确的归属层,不需要跨层改动。

总结

Obscura 的架构核心是一系列"以性能为硬约束"的设计决策:单 V8 Isolate 避免线程同步开销、Rust 直接操作 V8 C API 避免桥接层、DOM 树直接构建在 Rust 中避免序列化开销。8 crate 分层让每一次修改、测试、扩展都有明确的边界。理解这套架构,是为了在排障时知道看哪一层、在调优时知道动哪个旋钮、在扩展时知道从哪里下手。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。