Puppeteer于Chromium之间的通信原理

891 阅读5分钟

前段时间,在公司做了一个 htmlpdf 的项目,当时就用到了 Puppeteer,觉得它的能力蛮强大的,除此之外它还有,页面截屏生成图片、爬取页面内容、UI自动化测试、捕获页面加载时间线,分析页面性能等等能力。所以我好奇它是怎么控制浏览器获得这些能力的,因此结合源码和大佬们的解析,理解了它基本的工作原理,那废话少说我们一起看下吧。

什么是 Puppeteer

Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome。Puppeteer 默认以 headless 模式运行,但是可以通过修改配置文件运行“有头”模式。

以上是官网上的回答,讲也很清楚,但是里面有几个东西我们需要了解的 DevTools 协议headless 模式

DevTools 协议

什么是DevTools协议(Chrome DevTool Protocol)呢?

  • CDP 基于 WebSocket,利用 WebSocket 实现与浏览器内核的快速数据通道
  • CDP 分为多个域(DOM,Debugger,Network,Profiler,Console...),每个域中都定义了相关的命令和事件(Commands and Events)
  • 我们可以基于 CDP 封装一些工具对 Chrome 浏览器进行调试及分析,比如我们常用的 “Chrome 开发者工具” 就是基于 CDP 实现的
  • 很多有用的工具都是基于 CDP 实现的,比如 Chrome 开发者工具,chrome-remote-interface,Puppeteer 等。

headless 模式

  • 在无界面的环境中运行 Chrome
  • 通过命令行或者程序语言操作 Chrome
  • 无需人的干预,运行更稳定
  • 启动 Chrome 时添加参数 --headless,便可以 headless 模式启动 Chrome

以上内容来自知乎 张佃鹏 大佬的文章

启动 Browser 实例

Puppeteer 模块提供了两种启动 Chromium(Browser)实例的方法,一种是 puppeteer.launch 方法

const puppeteer = require('puppeteer');  
  
puppeteer.launch().then(async browser => {  
const page = await browser.newPage();  
await page.goto('https://www.google.com');  
// 其他操作...  
await browser.close();  
});

另一种是 puppeteer.connect 方法

const puppeteer = require('puppeteer');  
  
puppeteer.launch().then(async browser => {  
// 存储节点以便能重新连接到 Chromium  
const browserWSEndpoint = browser.wsEndpoint();  
// 从 Chromium 断开和 puppeteer 的连接  
browser.disconnect();  
  
// 使用节点来重新建立连接  
const browser2 = await puppeteer.connect({browserWSEndpoint});  
// 关闭 Chromium  
await browser2.close();  
});

这两方法的的区别是:

  • puppeteer.launch 方法启动一个新的浏览器并建立连接。
  • puppeteer.connect 方法与已启动的浏览器建立连接。

我们先了解一下 puppeteer.launch 方法背后的原理。

puppeteer.launch

先从源码中找到 Puppeteer 类。

// 源码路径 /puppeteer/node/Puppeteer.js

export class PuppeteerNode extends Puppeteer {
    // ...
    launch(options = {}) {
        // launch方法中可以传需要启动的浏览器名,目前仅支持 chrome 和 firefox, 但是product参数在官方文档中没有给出来。
        if (options.product)
            this._productName = options.product;
        return this._launcher.launch(options);
    }
    get _launcher() {
        // ...
        // 获取 browser 实例
        this._lazyLauncher = Launcher(this._projectRoot, this._preferredRevision, this._isPuppeteerCore, this._productName);
        return this._lazyLauncher;
    }
}

浏览器类型选择和对应的类 ChromeLauncher 和 FirefoxLauncher

// 源码路径 /puppeteer/node/Launcher.js

import { Browser } from '../common/Browser.js';
import { BrowserRunner } from './BrowserRunner.js';

class ChromeLauncher {
    // ...
    // 其实 puppeteer.launch 调的就是这里的 launch 方法
    async launch(options = {}) {
        // 真正启动浏览器操作在 BrowserRunner 类的 start 方法里进行
        const runner = new BrowserRunner(this.product, chromeExecutable, chromeArguments, userDataDir, isTempUserDataDir);
        runner.start({
            handleSIGHUP,
            handleSIGTERM,
            handleSIGINT,
            dumpio,
            env,
            pipe: usePipe,
        });
        // 这里通过CDP的“ws://”开头的地址进行puppeteer和chrome的连接
        const connection = await runner.setupConnection({
            usePipe,
            timeout,
            slowMo,
            preferredRevision: this._preferredRevision,
        });
        // 初始化 browser 实例方法(例如:newPage、wsEndpoint、target等方法)
        let browser = await Browser.create(connection, [], ignoreHTTPSErrors, defaultViewport, (_a = runner.proc) !== null && _a !== void 0 ? _a : undefined, runner.close.bind(runner));
        return browser;
    }
}

export default function Launcher(projectRoot, preferredRevision, isPuppeteerCore, product) {
    //...
    // 如果没有传 product 字段的话,默认启动Chrome浏览器
    switch (product) {
        case 'firefox':
            return new FirefoxLauncher(projectRoot, preferredRevision, isPuppeteerCore);
        case 'chrome':
        default:
            if (typeof product !== 'undefined' && product !== 'chrome') {
                console.warn(`Warning: unknown product name ${product}. Falling back to chrome.`);
            }
            // 初始化Chrome浏览器实例
            return new ChromeLauncher(projectRoot, preferredRevision, isPuppeteerCore);
    }
}

以上是 puppeteer.launch 方法入口到启动浏览器的部分,下面我们看下启动浏览器方法内具体做了什么。

BrowserRunner 通过子进程启动浏览器

nodejs的 child_process 模块提供了创建子进程能力,其中 child_process.spawn() 方法异步衍生子进程,不会阻塞 Node.js 事件循环。下面代码中 puppeteer 利用 child_process.spawn() 方法启动了浏览器。

// 源码路径 /puppeteer/node/BrowserRunner.js

import * as childProcess from 'child_process';
import * as readline from 'readline';
import { Connection } from '../common/Connection.js';
import { NodeWebSocketTransport as WebSocketTransport } from '../node/NodeWebSocketTransport.js';

export class BrowserRunner {
    start(options) {
        const { handleSIGINT, handleSIGTERM, handleSIGHUP, dumpio, env, pipe } = options;
        // 通过node的child_process模块在子进程中启动浏览器
        // _executablePath 浏览器可执行文件路径,在初始化是存到环境变量中
        // 浏览器进程存到 this.proc 实例属性中
        this.proc = childProcess.spawn(this._executablePath, this._processArguments, {
            detached: process.platform !== 'win32',
            env,
            stdio,
        });
        // 之后初始化一些进程事件...
    }
    async setupConnection(options) {
        const { usePipe, timeout, slowMo, preferredRevision } = options;
        // 获取 this.proc 进程中启动的浏览器的websocket端点号
        const browserWSEndpoint = await waitForWSEndpoint(this.proc, timeout, preferredRevision);
        // 拿到端点号之后,通过 NodeWebSocket 建立运输通道
        const transport = await WebSocketTransport.create(browserWSEndpoint);
        // ws通道建立之后,给通道实例初始化事件函数(例如 send、_onMessage、_onClose等事件)
        this.connection = new Connection(browserWSEndpoint, transport, slowMo);
        
        return this.connection;
    }
}

这里的 browserWSEndpoint 是建立通道的重要因素,waitForWSEndpoint 函数负责进程中获取 browserWSEndpoint 的任务。通过 readline 模块监听子进程的 stderr 日志信息,从日志信息中匹配到符合 /^DevTools listening on (ws:\/\/.*)$/ 正则的信息,最后返回 ws:// 开头的 CDP URL信息。

function waitForWSEndpoint(browserProcess, timeout, preferredRevision) {
    return new Promise((resolve, reject) => {
        const rl = readline.createInterface({ input: browserProcess.stderr });
        let stderr = '';
        function onLine(line) {
            stderr += line + '\n';
            const match = line.match(/^DevTools listening on (ws:\/\/.*)$/);
            if (!match)
                return;
            cleanup();
            resolve(match[1]);
        }
    });
}

创建websocket实例

其实 waitForWSEndpoint.create 中用 ws 这个npm包实例化了一个websocket通道,并初始化实例通信事件。下面我们看下具体代码。

// 源码路径 /puppeteer/node/NodeWebSocketTransport.js

import NodeWebSocket from 'ws';

export class NodeWebSocketTransport {
    constructor(ws) {
        this._ws = ws;
        this._ws.addEventListener('message', (event) => {
            if (this.onmessage)
                this.onmessage.call(null, event.data);
        });
        this._ws.addEventListener('close', () => {
            if (this.onclose)
                this.onclose.call(null);
        });
        this._ws.addEventListener('error', () => { });
        this.onmessage = null;
        this.onclose = null;
    }
    static create(url) {
        const pkg = require('../../../../package.json');
        return new Promise((resolve, reject) => {
            const ws = new NodeWebSocket(url, [], {
                followRedirects: true,
                perMessageDeflate: false,
                maxPayload: 256 * 1024 * 1024,
                headers: {
                    'User-Agent': `Puppeteer ${pkg.version}`,
                },
            });
            ws.addEventListener('open', () => resolve(new NodeWebSocketTransport(ws)));
            ws.addEventListener('error', reject);
        });
    }
    send(message) {
        this._ws.send(message);
    }
    close() {
        this._ws.close();
    }
}

CDP通信方式

Chrome DevTool Protocol 有内置的方法和事件,通过 websocket 通知浏览器你需要调的方法。我们以创建新页面 newPage 方法为例,看看它是怎么实现的。

// 源码路径 /puppeteer/common/Browser.js

import { EventEmitter } from './EventEmitter.js';

export class Browser extends EventEmitter {
    async newPage() {
        return this._browser._createPageInContext(this._id);
    }
    async _createPageInContext(contextId) {
        const { targetId } = await this._connection.send('Target.createTarget', {
            url: 'about:blank',
            browserContextId: contextId || undefined,
        });
        const target = this._targets.get(targetId);
        const page = await target.page();
        return page;
    }
}

this._connection 是我们已经绑定事件方法的 ws 实例,因此我们通过它给浏览器发送 Target.createTarget 方法名告诉他我们创建新页面。其他协议方法可以查看此地

image.png

puppeteer.connect

puppeteer.connect 方法比较简单,因为它在浏览器已启动的前提下建立连接,因为拿到用户传进来的 browserWSEndpoint 之后直接创建连接。

// 源码路径 /puppeteer/common/connectToBrowser.js

export const connectToBrowser = async (options) => {
    const { browserWSEndpoint, browserURL, transport } = options;
    let connection = null;
    if (transport) {
        connection = new Connection('', transport, slowMo);
    }
    else if (browserWSEndpoint) {
        // 常用的连接方式
        const WebSocketClass = await getWebSocketTransportClass();
        const connectionTransport = await WebSocketClass.create(browserWSEndpoint);
        connection = new Connection(browserWSEndpoint, connectionTransport, slowMo);
    }
    else if (browserURL) {
        const connectionURL = await getWSEndpoint(browserURL);
        const WebSocketClass = await getWebSocketTransportClass();
        const connectionTransport = await WebSocketClass.create(connectionURL);
        connection = new Connection(connectionURL, connectionTransport, slowMo);
    }
    const { browserContextIds } = await connection.send('Target.getBrowserContexts');
    // 与浏览器连接成功之后,创建新页面,并返回页面实例
    return Browser.create(connection, browserContextIds, ignoreHTTPSErrors, defaultViewport, null, () => connection.send('Browser.close').catch(debugError), targetFilter);
    
}

总结

本文讲述了 Puppeteer 与浏览器之间的通信原理,简单总结如下:

  • 通过node子进程模块启动浏览器;
  • 通过子进程实例,获取 ws:// 开头的 CDP 地址;
  • 通过 ws 库创建 websocket 实例,并且用上一步获取到的地址创建连接;
  • 通过协议定制的方法名进行通信。

如有不足,欢迎指出。

本文章基于puppeteer@13.5.1版本写的

参考文献