朋友们,一起学习下 Chrome DevTools Protocol。

7,489 阅读9分钟

1. Debug 起源

1947 年 9 月 9 日,第一代程序媛大佬 Hopper 正领着她的小组在一间一战时建造的老建筑机房里构造一个称为“Mark II”的艾肯中继器计算机。

那是一个炎热的夏天,房间没有空调,所有窗户都敞开散热。突然 Mark II 死机了,操作人员在电板编号为 70 的中继器触点旁发现了一只飞蛾。操作员把飞蛾贴在操作日志上,并写下了“First actual case of bug being found”,他们还提出了一个词:“debug(调试)”,表示他们已经从机器上移走了bug(调试了机器)。

image.png

于是,引入了一个新的术语“debugging a computer program(调试计算机程序)”。

2. DevTools (Debugging Tools) 发展史

在 2006 年前的 IE 时代,调试 JavaScript 代码主要靠 window.alert() 或者将调试信息输出到页面上,这种硬 debug 的手段,不亚于系统底层开发,效率极低。

2006 年 1 月份,Apple 的 WebKit 团队第一版本的 Web Inspector 问世,尽管最初版的调试工具很简陋(它甚至连 console 都没有),但是它为开发者展示了两个他们很难洞见的内容——DOM 树以和与之匹配的样式规则。

这奠定了今后多年的网页调试工具的原型。

image.png

同年 4 月,以最大的食虫植物命名的 Drosera 发布,它可以给任何的 WebKit 应用添加断点,调试 JavaScript——不仅限于是 Safari。

同时开源社区出现了一款 Firefox 的插件 Firebug,专注于 Web 开发的调试,它是在 Chrome 全世界最好的前端调试工具,同时也奠定了现代 DevTools 的 Web UI 的布局。

Firebug 早期版本就已经支持了 JavaScript 的调试,CSS Box 模型可视化展示,HTTP Archive 的性能分析等优秀特性,后来的 DevTools 参考了此插件的功能和产品定位。

2016 年 Firebug 整合到 Firefox 内置调试工具。

2017 年 Firebug 停止更新,一代神器就此谢幕。

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/29dc6b4f176e42cfbb4c7da2cfe34f66~tplv-k3u1fbpfcp-zoom-1.image

此后开源界的狠角色 Google 团队基于 WebKit 加入浏览器研发,他们推出的 Chrome 以「安全、极速、更稳定」吸引了大部分开发者的关注,同时在开发者工具这方面, Google 吸收多款调试工具的优秀功能,推出了 DevTools。

image.png

虽然当时的界面相比如今,十分简陋,但此后 DevTools 的发展基本就与 Chrome DevTools 的发展史划等号了。

当然,不管是 Firebug 还是后来基于 Webkit(早期)、 Blink (现今) 内核的 Chrome ,再或者是 2016 年后的 node-inspector ,他们都离不开 Web Inspector,更多详细的 Web Inspector 发展史可以参考 10 Years of Web Inspector

3. DevTools 架构

DevTools 是 client-server 架构:

  • client 端提供可视化 Web UI 界面供用户操作,它负责接收用户操作指令,然后将操作指令发往浏览器内核或 Node.js 中进行处理,并将处理结果数据展示在 Web UI 上。

  • server 端启动了两类服务:

    • HTTP 服务: 提供内核信息查询能力,比如获取内核版本、获取调试页的列表、启动或关闭调试。
    • WebSocket 服务:提供与内核进行真实数据通信的能力,负责 Web UI 传递过来的所有操作指令的分发和处理,并将结果送回 Web UI 进行展示。

3.1 Chrome DevTools

以上具体化到 Chrome 开发者工具,你一定倍感亲切。

Chrome DevTools 提供了一套内置于 Google Chrome 中的 Web 开发和调试工具,可用来对网站进行迭代、调试和分析。

Chrome DevTools 主要由四部分组成:

  • Frontend:调试器前端,默认由 Chromium 内核层集成,DevTools Frontend 是一个 Web 应用程序;
  • Backend:调试器后端,一般是 Chromium、V8 或 Node.js;
  • Protocol:调试协议,调试器前后端将遵守该协议进行通信。 它分为代表被检查实体的语义方面的域。 每个域定义类型、命令(从前端发送到后端的消息)和事件(从后端发送到前端的消息)。该协议基于 json rpc 2.0 运行;
  • Message Channels:调试消息通道,消息通道是调试前后端间发送协议消息的一种方式。包括:Embedder Channel、WebSocket Channel、Chrome Extensions Channel、USB/ADB Channel。

服务中心流程图(1) (3).png

总结来说,本质上 Chrome DevTools 就是一个 Web 应用程序,它通过使用 Chrome DevTools Protocol 与后端进行交互,达到调试目的。

关于 Chrome 开发者工具的详细使用可以看官方文档

接下来聚焦 DevTools 的核心:Protocol 。

4. Chrome DevTools Protocol

CDP 本质就是一组 JSON 格式的数据封装协议,JSON 是轻量的文本交换协议,可以被任何平台任何语言进行解析。

4.1 定义

以 Tracing 的协议为例:

{
  "domain": "Tracing",
  "experimental": true,
  "dependencies": ["IO"],
  "types": [
      {
          "id": "TraceConfig",
          "type": "object",
          "properties": [
              {
                  "name": "recordMode",
                  "description": "Controls how the trace buffer stores data.",
                  "optional": true,
                  "type": "string",
                  "enum": [
                      "recordUntilFull",
                      "recordContinuously",
                      "recordAsMuchAsPossible",
                      "echoToConsole"
                  ]
              },
              ...
          ]
      },
      ...
  ],
  "commands": [
      {
          "name": "start",
          "description": "Start trace events collection.",
          "parameters": [
							{...}
          ]
      },
			{
          "name": "end",
          "description": "Stop trace events collection."
      },
			...
  ],
  "events": [
      {
          "name": "tracingComplete",
          "description": "Signals that tracing is stopped and there is no trace buffers pending flush, all data were\ndelivered via dataCollected events.",
          "parameters": [
              {
                  "name": "dataLossOccurred",
                  "description": "Indicates whether some trace data is known to have been lost, e.g. because the trace ring\nbuffer wrapped around.",
                  "type": "boolean"
              },
              ...
          ]
      }
  ]
}
  • domain:协议把操作划分为不同的 domain(DOM、Console、Network 等,可以理解为 DevTools 中不同的功能模块)。每个 domain 内还定义了他们支持的命令(commands)和事件(events)以及相关类型(types)的具体结构
  • experimental:该 domain 是否属于实验性
  • description:domain 的功能描述
  • dependencies:domain 的依赖
  • commands:如同异步调用,对应 socket 通信的请求/响应模式,包含 request/response,通过请求信息,获取相应返回结果,通讯需要有 message id
  • events:发生的事件信息,对应 socket 通信的发布/订阅模式,用于发送通知信息
  • types:domain 包含的 commands 和 events 数据类型定义

4.2 调试

如下图在 Chrome DevTools 中操作了 Performance 的录制,可以在 Chrome 中开启 Protocol monitor 查看具体的通讯信息。

image.png

每个 Method (${domain}.${conmand})包含 request 和 response 两部分,request 部分指定所要进行的操作以及操作说要的参数,response 部分表明操作状态,成功或失败。

除了使用 Protocol Monitor,还可以参考 stackoverflow.com/questions/1…,开启对 Chrome DevTools 的调试。

4.3 使用

官方推荐的支持 CDP 的 Libraries 多达近十种语言。

Google 官方推荐了 Node.js 版本 Puppeteer ,通过 Puppeteer 完整地实现了 CDP 协议,为 Chrome 内核通信的方式打了一个样,接着开源世界陆续推出了多个语言版本的 CDP 的使用库。

4.3.1 Puppeteer

Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 CDP 协议控制 Chrome 或 Chromium。

Puppeteer 怎么用,我就不多写了(如果英文文档看不懂,咱就看中文文档)。

我可能更偏向于结合源码理解它是如何做到与 CDP 关联,并且如何发挥作用的。

还是以 Tracing 为例

const puppeteer = require('puppeteer');

puppeteer.launch({ headless: false }).then(async browser => {
	const page = await browser.newPage();
	await page.tracing.start({ path: './trace.json' });
	await page.goto('<https://www.google.com>');
	await page.tracing.stop();
  await browser.close();
});

以上代码片段会将录制的 tracing 数据存储中 trace.json 中。

(是否记得之前 Tracing 协议的定义,与 start 配对的是 end,pupputeer 做了调整,具体在下面源码中体现)

再看看这个构建函数源码,其实非常简单:

// Page.ts
export class Page extends EventEmitter {
	constructor(client,...) {
		super()

		...
		this.#tracing = new Tracing(client);
	}

	get tracing(): Tracing {
    return this.#tracing;
  }
}

Tracing 是一个被标记为 Internal 的构造函数,意味着我们不能直接调用或扩展它的子类,如上代码片段,它挂载在 Page 上,随 Page 被实例化。

// Tracing.ts
import {assert} from './assert.js';
import {
  getReadableAsBuffer,
  getReadableFromProtocolStream,
  isErrorLike,
} from './util.js';
import {CDPSession} from './Connection.js';

/**
 * @public
 */
export interface TracingOptions {
  path?: string;
  screenshots?: boolean;
  categories?: string[];
}

/**
 * The Tracing class exposes the tracing audit interface.
 * @remarks
 * You can use `tracing.start` and `tracing.stop` to create a trace file
 * which can be opened in Chrome DevTools or {@link <https://chromedevtools.github.io/timeline-viewer/> | timeline viewer}.
 *
 * @example
 * ```ts
 * await page.tracing.start({path: 'trace.json'});
 * await page.goto('<https://www.google.com>');
 * await page.tracing.stop();
 * ```
 *
 * @public
 */
export class Tracing {
  #client: CDPSession;
  #recording = false;
  #path?: string;

  /**
   * @internal
   */
  constructor(client: CDPSession) {
    this.#client = client;
  }

  /**
   * Starts a trace for the current page.
   * @remarks
   * Only one trace can be active at a time per browser.
   *
   * @param options - Optional `TracingOptions`.
   */
  async start(options: TracingOptions = {}): Promise<void> {
    assert(
      !this.#recording,
      'Cannot start recording trace while already recording trace.'
    );

    const defaultCategories = [
      '-*',
      'devtools.timeline',
      'v8.execute',
      'disabled-by-default-devtools.timeline',
      'disabled-by-default-devtools.timeline.frame',
      'toplevel',
      'blink.console',
      'blink.user_timing',
      'latencyInfo',
      'disabled-by-default-devtools.timeline.stack',
      'disabled-by-default-v8.cpu_profiler',
    ];
    const {path, screenshots = false, categories = defaultCategories} = options;

    if (screenshots) {
      categories.push('disabled-by-default-devtools.screenshot');
    }

    const excludedCategories = categories
      .filter(cat => {
        return cat.startsWith('-');
      })
      .map(cat => {
        return cat.slice(1);
      });
    const includedCategories = categories.filter(cat => {
      return !cat.startsWith('-');
    });

    this.#path = path;
    this.#recording = true;
    await this.#client.send('Tracing.start', {
      transferMode: 'ReturnAsStream',
      traceConfig: {
        excludedCategories,
        includedCategories,
      },
    });
  }

  /**
   * Stops a trace started with the `start` method.
   * @returns Promise which resolves to buffer with trace data.
   */
  async stop(): Promise<Buffer | undefined> {
    let resolve: (value: Buffer | undefined) => void;
    let reject: (err: Error) => void;
    const contentPromise = new Promise<Buffer | undefined>((x, y) => {
      resolve = x;
      reject = y;
    });
    this.#client.once('Tracing.tracingComplete', async event => {
      try {
        const readable = await getReadableFromProtocolStream(
          this.#client,
          event.stream
        );
        const buffer = await getReadableAsBuffer(readable, this.#path);
        resolve(buffer ?? undefined);
      } catch (error) {
        if (isErrorLike(error)) {
          reject(error);
        } else {
          reject(new Error(`Unknown error: ${error}`));
        }
      }
    });
    await this.#client.send('Tracing.end');
    this.#recording = false;
    return contentPromise;
  }
}

注意到 Puppeteer 提供的对 Tracing 的 config 有限,仅可自定义:

export interface TracingOptions {
	path?: string;
	screenshots?: boolean;
	categories?: string[];
}

看到以上代码片段,你可能会有一些疑惑:

  • 为什么继承自 EventEmitter ?
  • client 是什么?client.send 又是?

分享一下我的见解:

  • Puppeteer 作为一个 Node 库,它需要事件模型支撑(可以在源码中看到大量的on emit)来更好的串联各个模块,并实现解耦。而在Nodejs 中,事件模型就是我们常见的订阅发布模式,所有可能触发事件的对象都应该是一个继承自 EventEmitter 类的子类实例对象。
  • client 即 CDPSession ,用于处理原生的 Chrome Devtools 协议通讯,而 client.send 即表示调用协议方法。

其实在 puppeteer 实现中,client 都承担着使用 CDP 与 server 通讯的责任,它其实就是 puppeteer launch 阶段与 server 通讯的 websocket transport。

// BrowserRunner.ts
async setupConnection(options: {
    ...
    const transport = await WebSocketTransport.create(browserWSEndpoint);
    this.connection = new Connection(browserWSEndpoint, transport, slowMo);
		...
    return this.connection;
  }
}

4.3.2 chrome-remote-interface

CRI(简称)不同于 Puppeteer 附加的高级 API,它通过开放简单的 API 和事件通知,我们只需要使用简单的 JavaScript API 即可实现对 Chrome(或任何其他支持 Devtools Protocol 的实现)的控制。

它被 CDP 官方多次推荐。

setup

以远程调试模式启动 Chrome (增加参数—remote-debugging-port=9222),DevTools server 将监听本地的端口9222

# 退出 Chorme 后再命令行输入命令,打开新的 Chrome
open -a "Google Chrome" --args --remote-debugging-port=9222

访问 http://localhost:9222/json 可以看到可用调试页面数据信息(包括打开的 Tab 页和 Chrome 上添加的 Extensions):

image.png

访问 http://localhost:9222/ + 任意一个 Tab 的 devtoolsFrontendUrl,将会打开对该页面调试页。

image.png

或者同移动端调试一般,打开about://inspect界面,可以发现此时本地浏览器被作为一个 remote device 来调试,找到具体 Tab 页点击 inspect 即可。

image.png

这在移动端调试是十分有帮助的。

use case

如下片段,我们可以通过 CRI 使用 CDP 的所有 API。

const fs = require('fs');
const CDP = require('chrome-remote-interface');

CDP(async (client) => {
    try {
        const {Page, Tracing} = client;
        // enable Page domain events
        await Page.enable();
        // trace a page load
        const events = [];
        Tracing.dataCollected(({value}) => {
            events.push(...value);
        });
        await Tracing.start();
        await Page.navigate({url: '<https://github.com>'});
        await Page.loadEventFired();
        await Tracing.end();
        await Tracing.tracingComplete();
        // save the tracing data
        fs.writeFileSync('./trace.json', JSON.stringify(events));
    } catch (err) {
        console.error(err);
    } finally {
        await client.close();
    }
}).on('error', (err) => {
    console.error(err);
});

4.4 数据处理

对于以上收集到的 Tracing 数据(存储在 trace.json),因为数据量大而且晦涩,一般直接在 Chrome DevTools 或其他 timeline viewer 打开,用来分析 Web 站点性能表现的文件。

或者可以参照 Trace Event Format,使用脚本过滤出期望格式的 event 数据再做进一步分析。

参考

DevTools 实现原理与性能分析实战

Chrome DevTools Protocol 协议详解