V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
kaiduo
V2EX  ›  Node.js

puppeteer 的实现原理

  •  
  •   kaiduo · 2021-11-15 00:49:14 +08:00 · 5708 次点击
    这是一个创建于 1099 天前的主题,其中的信息可能已经有所发展或是发生改变。

    hi all ,分享一个 puppeteer 的 debug, 更多可以持续关注 🌟 github | blog 🌟

    image

    背景

    有同学吐槽整个 CI/CD 下来时间太长了, 其中 e2e 测试节点就花了 10 分钟 🐢

    现在我们采用的是 puppeteer 进行的一个自动化 e2e 测试, 该节点是在正式发布前, 预发发布后。

    作为一个所有项目都必须要通过的一个节点, 它主要的功能是读取项目中的所有路由页面进行一个白屏测试与检查是否有 console.error 、网络错误等。

    排查

    收到反馈后首先是进行排查, 发现该 spa 项目共 96 个 ⚠️ 路由页面, 而只会开启 ⚠️ 一个 puppeteer browser 实例去逐个对页面测试导致了耗时过长。

    一开始也没有着急去改, 而是问第一版开发 e2e 的大佬, 为何没有开启多个 browser 实例去并行完成这些路由页面的任务, 得到的反馈是当时项目还比较小, 就没有做这方面的优化了。

    解决

    看样子多个实例不是因为有坑才没做, 当时可能只是不想 Overdesign 。解决这个问题比较简单把收到的若干个任务进行分组, 然后去开启多个 browser 实例去并行完成这些任务即可。

    img

    如上图, 最后按照每组最多 20 个任务为一组优化后, 将该节点耗时减少到了 3 分 25 秒。

    这里说明的一点分的组不是越多越好, 比如 96 个任务每组最大 20 个分为 5 组, 总时长并不会减少 5 倍。因为 browser 实例越多占用的系统资源也会越多。这有点像小学求最优解的题, 随着每组数量(x 轴)的增长, 总耗时(y 轴)会类似于一个抛物线。

    puppeteer

    其实 puppeteer 已经应用在我们很多的前端领域, 如上面所说的 e2e 测试, 其他诸如爬虫、页面定时巡检、页面性能监控都是使用的 puppeteer 。

    本次就很快解决了这个问题, 出于好奇也粗略的去学习了一下 puppeteer 的实现原理。

    const puppeteer = require('puppeteer');
    
    (async () => {
      const browser = await puppeteer.launch();
      const page = await browser.newPage();
      await page.goto('https://example.com');
      await page.screenshot({ path: 'example.png' });
    
      await browser.close();
    })();
    

    新的 browser 实例实现

    1. 通过 childProcess.spawn 运行 chromium 的可执行文件, 打开一个 chromium browser
    2. 绑定一些事件监听
    // src/node/BrowserRunner.ts
    
    start(options: LaunchOptions): void {
        //...
    
        this.proc = childProcess.spawn(
          this._executablePath,
          this._processArguments,
          {
            // On non-windows platforms, `detached: true` makes child process a
            // leader of a new process group, making it possible to kill child
            // process tree with `.kill(-pid)` command. @see
            // https://nodejs.org/api/child_process.html#child_process_options_detached
            detached: process.platform !== 'win32',
            env,
            stdio,
          }
        );
        
        // ...
        
        this._listeners = [
          helper.addEventListener(process, 'exit', this.kill.bind(this)),
        ];
        if (handleSIGINT)
          this._listeners.push(
            helper.addEventListener(process, 'SIGINT', () => {
              this.kill();
              process.exit(130);
            })
          );
        if (handleSIGTERM)
          this._listeners.push(
            helper.addEventListener(process, 'SIGTERM', this.close.bind(this))
          );
        if (handleSIGHUP)
          this._listeners.push(
            helper.addEventListener(process, 'SIGHUP', this.close.bind(this))
          );
      }
    

    browser 的启动流程

    1. 通过上面的 start 函数中赋值的 this.proc 获取新打开的 chromium 进程句柄, 即该函数的入参 browserProcess
    2. 通过 readline.createInterface 以及 browserProcess.stderr 获取 chromium 进程的日志输出, 这里是通过 readline 去获取子进程的输出也是一个比较少见的用法
    3. 当监听到 /^DevTools listening on (ws://.*)$/ 匹配的日志后, 即算获取到了 chromium 进程上的 WebSocket 运行的 url
    // src/node/BrowserRunner.ts
    
    function waitForWSEndpoint(
      browserProcess: childProcess.ChildProcess,
      timeout: number,
      preferredRevision: string
    ): Promise<string> {
      return new Promise((resolve, reject) => {
        const rl = readline.createInterface({ input: browserProcess.stderr });
        let stderr = '';
        const listeners = [
          helper.addEventListener(rl, 'line', onLine),
          helper.addEventListener(rl, 'close', () => onClose()),
          helper.addEventListener(browserProcess, 'exit', () => onClose()),
          helper.addEventListener(browserProcess, 'error', (error) =>
            onClose(error)
          ),
        ];
        const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0;
    
        /**
         * @param {!Error=} error
         */
        function onClose(error?: Error): void {
          cleanup();
          reject(
            new Error(
              [
                'Failed to launch the browser process!' +
                  (error ? ' ' + error.message : ''),
                stderr,
                '',
                'TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md',
                '',
              ].join('\n')
            )
          );
        }
    
        function onTimeout(): void {
          cleanup();
          reject(
            new TimeoutError(
              `Timed out after ${timeout} ms while trying to connect to the browser! Only Chrome at revision r${preferredRevision} is guaranteed to work.`
            )
          );
        }
    
        function onLine(line: string): void {
          stderr += line + '\n';
          const match = line.match(/^DevTools listening on (ws:\/\/.*)$/);
          if (!match) return;
          cleanup();
          resolve(match[1]);
        }
    
        function cleanup(): void {
          if (timeoutId) clearTimeout(timeoutId);
          helper.removeEventListeners(listeners);
        }
      });
    

    与 browser 通信

    上面 waitForWSEndpoint 函数获取到新打开的 chromium 进程的 WebSocket 监听的 url 后, 这里就通过 ws 这个 npm 包生成了一个 NodeWebSocket 。

    到这里我们知道了提供若干个 api 的 puppeteer 原来是一个 WebSocket 客户端, 另一端是 chromium 进程进行真实的操作。

    // src/node/NodeWebSocketTransport.ts
    
    import NodeWebSocket from 'ws';
    
    export class NodeWebSocketTransport implements ConnectionTransport {
      static create(url: string): Promise<NodeWebSocketTransport> {
        // eslint-disable-next-line @typescript-eslint/no-var-requires
        const pkg = require('../../../../package.json');
        return new Promise((resolve, reject) => {
          const ws = new NodeWebSocket(url, [], {
            followRedirects: true,
            perMessageDeflate: false,
            maxPayload: 256 * 1024 * 1024, // 256Mb
            headers: {
              'User-Agent': `Puppeteer ${pkg.version}`,
            },
          });
    
          ws.addEventListener('open', () =>
            resolve(new NodeWebSocketTransport(ws))
          );
          ws.addEventListener('error', reject);
        });
      }
    

    通信协议

    以浏览器新打开一个页面 newPage 函数的实现为例, 可知是通过 NodeWebSocket 发送了一个 'Target.createTarget' 事件, 可传参数见下面的 DevTools Protocol

    //src/common/Browser.ts
    
    newPage(): Promise<Page> {
        return this._browser._createPageInContext(this._id);
    }
      
    async _createPageInContext(contextId?: string): Promise<Page> {
        const { targetId } = await this._connection.send('Target.createTarget', {
          url: 'about:blank',
          browserContextId: contextId || undefined,
        });
        const target = this._targets.get(targetId);
        assert(
          await target._initializedPromise,
          'Failed to create target for page'
        );
        const page = await target.page();
        return page;
      }
    

    这里用来操控 chromium 的协议都可以在这里查阅 Chrome DevTools Protocol

    image

    小结

    发现问题后最好先追本溯源, 以免走前人踩过的坑。其次有多余的时间也不妨探究一下其实现原理, 技术其实都是相通的, 看的多了总是能举一反三 ~

    8 条回复    2021-11-16 10:25:48 +08:00
    seki
        1
    seki  
       2021-11-15 02:19:24 +08:00
    可以考虑上一个 jest 这样的 test runner ,然后还有配合 jest-puppeteer 来写测试,这样可以免去一些对 puppeteer 的底层管理的需求
    chavyleung
        2
    chavyleung  
       2021-11-15 09:36:32 +08:00   ❤️ 1
    4ark
        3
    4ark  
       2021-11-15 09:58:05 +08:00
    推荐一个之前项目用的 e2e 测试框架: https://www.cypress.io
    ssshooter
        4
    ssshooter  
       2021-11-15 10:17:03 +08:00
    模拟用户操作的原理是什么?
    darrh00
        5
    darrh00  
       2021-11-15 10:21:55 +08:00
    比跨平台的 selenium 好在哪里?
    bxb100
        6
    bxb100  
       2021-11-15 16:31:00 +08:00
    @darrh00 google 出的, API 更加语义化
    aaronlam
        7
    aaronlam  
       2021-11-16 09:54:14 +08:00
    遇到问题追根溯源的态度很赞,值得学习!
    gavingeng
        8
    gavingeng  
       2021-11-16 10:25:48 +08:00
    可以看看微软的 playwright ,也是原来的团队 2019 年跳槽过去搞的
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2644 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 15:36 · PVG 23:36 · LAX 07:36 · JFK 10:36
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.