likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 26

1. Debug 起源

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

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

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

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

2. DevTools (Debugging Tools) 发展史

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

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

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

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

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

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

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

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

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

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

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

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

虽然当时的界面相比如今,十分简陋,但此后 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。

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

总结来说,本质上 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 查看具体的通讯信息。

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

每个 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):

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

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

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

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

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

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

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 协议详解