Bootstrap

CDP篇: 用 Chrome devtools 的 network 标签页调试 node 请求

1 前言

大家好,我是心锁。

早在我在学校的时候,常做的一件事是用各种手段爬学校的网站。从最开始用 python ,到后边在小程序环境,再到最后用 node。

期间,学习了前端,在最后慢慢发展到一个JS 技术栈工程师。

我逐渐发现一个问题,熟悉了浏览器甚至是小程序的网络调试体验后,在我用 node 写爬虫或者调用第三方接口的过程中,对应的调试体验真是一落千丈。

尤其是,根据库的不同,node 在进行爬虫行为时的各种行为并不完全一致,这点和浏览器的默认行为会有更大的差异。

实际上,在刚开始经历 node 爬虫之苦的时候我就在社区中不断寻找解决方案,最终并不是很理想。

于是上周,我在又一次爬虫之旅结束,在社区中寻找解决方案时,竟然还是没找到心中的方案时,决定自己写一个。

2 社区调研

2.1 调研对象

社区调研阶段,我主要分官方方案和社区方案去看。

其中目前官方提供的调试方案主要就是node v8 inspector,目前最主流且实用的方式应该是 node 自带的调试系统,即 v8 inspector ,常见的启动方式如下。

node --inspect ./src/index.js
node --inspect-brk ./src/index.js

这种情况下会在 9229 端口创建 websocket 服务,此时打开 http://localhost:9229/json 可以看到相关的服务。

此时可以复制 devtoolsFrontendUrl 来打开窗口,或者从 F12 的 node 图标跳转过去,但是会发现打开的页面包含的 tab 并不包含我们需要的 network 标签页。

因为经过阉割,v8 inspector 并没有实现期望中的 network 调试,但是其为我们提供了一个方向,因为 inspector 同样是基于 CDP 协议实现,我们完全可以自己想办法对接。

官方方案主要就是这个,https://github.com/GoogleChromeLabs/ndb 同样是早期的官方调试库,其虽然也是基于 Chrome devtools 的调试体验,但是没有 network 相关的内容。

剩下的就是社区第三方库,我尝试看了不少项目,把其中认为比较具有参考价值的列举了出来:

  • node-inspector。在 node 早期版本,有一个非常优秀的开源库https://github.com/node-inspector/node-inspector,这个库支持 network 标签,但很遗憾,这只适用于 node 8 以及以下版本的情况——它上次更新是在7年前,现在是废弃状态。
  • debugging-aid。https://github.com/naugtur/debugging-aid是我看完一圈代码最简洁的一个,它通过 mitm 库实现了监听 HTTP 请求,能做到自动打印请求的相关信息以及堆栈所在。但是存在的主要问题是终究是终端打印,并且 mitm 已经三年没有更新了,调试体验并没有增加到什么程度。
  • network-activity-viewer。https://github.com/saivishnutammineni/network-activity-viewer这个库,是对我启发最大的一个社区库。作者通过编辑官方库的形式,做到了无代理也能得到响应信息。美中不足的是,思路上是自己尝试去完成了 UI 部分的实现。

再多的信息的话,是一些存在时间比较久的 issues:

其中 Stack Overflow 中记载,想做 node 请求监控,还可以尝试使用代理的形式来监控()。当然,在代理这块我没有再深入探究,代理的缺陷比较明显,有一定门槛且会影响现有的代理。

除却 node 网络这块的调研,我还试着了解了一下关于更多第三方 devtool 的实现。其中就包括 react-native devtool ,我们知道 react-native 的请求本质上是安卓或者苹果设备发出的请求,但是却能在 devtool 上调试,很明显,这就是借助 CDP 的力量,至于 CDP 的细节内容我们后边再说。

总之, react-native devtool 可以让安卓/ IOS 网络拥有 devtool 调试能力,那么我们的思路就清晰了。

2.2 调研总结

经过一阵子的调研,基本可以得出结论,即官方在引入 network 调试这一块不够积极,2016 年的帖子到现在仍在讨论,也期待官方能在这一块努努力。

而社区库这块,说实话比较惊讶,过去了好几年,没想到在这一块仍有缺失,没有能达到我心中语气的解决方案。

我尝试理解了目前的整块逻辑,那么我们其实要做的内容明了路:

  1. 想办法监听 node 发起的各种请求,从 http/https 包发起的请求,到原生 fetch 发起的请求,再到 websocket 相关的内容。
  2. 将监听到的内容根据 CDP 协议,将信息推送到 devtool 上。

在第一期,预期先实现最基本的调试能力,看看效果再优化。

3 开发篇

那么,现在进入正文。本篇我们将主要探讨技术方案的可行性,并且实现第一期的 demo,同时阅读本篇,我们都可以对 CDP 协议有更深的理解。

3.1 拦截/监听请求

那么首先,我们先思考一下怎么去拦截/监听 node 请求。我整理了几个思路,包括了网络代理、暴露包装器函数、node:async_hooks 监听、修改原始类。

在这几个思路中,首先被我排除的是网络代理,原因在于通过网络代理的形式监听程序,不仅要对 HTTPS 服务做本地证书,成本高。而且需要额外占用资源,同时对于已存在本地代理的应用友好程度并不够高。关于这块,一个场景是调试国外的 支付 API。

其次是包装器函数的方案,我们知道网络请求库这块,不管是 axios、got 还是 node-fetch,它们都提供了不同程度的钩子来对全局请求做管理,但是这个方案也不能考虑,因为我们无暇去给这一个个库适配他们的请求,这不现实。

那么剩下两个思路,这两个思路预计在我们之后的开发中混合使用。

  • node:async_hooks 具有极高的可行性,但是由于两个因素,本期没有考虑进来,一个是其是实验性功能,其次是在我完成了代码半周之后才看到了这么个东西。
  • 修改原始类这一块有一个现成的代码供我们参考https://github.com/saivishnutammineni/network-activity-viewer,虽说作者上次更新是一年前了,但是实验了一下其中的思路是没有问题的。唯一的缺陷是,想对原生模块做 hack 的话,esm 标准下我们无法编辑导出的库,目前的 hack 操作只能先覆盖 commonjs。

💡 commonjs 标准下,我们通过 require 函数导入包之后,可以修改其内部属性。

而我们知道,在 nodejs 18 之前,可以说九成九的网络请求都是通过 http/https 这两个原生包发起的,而各个三方库本质上都是对这两个库的封装。所以我们在实现上,只要确保我们对 http/https 两个模块的动工在三方库引入之前,就能拦截请求。

总之,现在我们先通过修改原始类的方式完成部分代码的编写。这要求我们对原生发起请求有一定了解,这一块的参考代码如下。

    const options = {
      hostname: 'jsonplaceholder.typicode.com',
      port: 80,
      path: '/todos/1',
      method: 'GET',
    };

    const req = http.request(options, (res) => {
      let data = '';

      res.on('data', (chunk) => {
        data += chunk;
      });

      res.on('end', () => {
        resolve(JSON.parse(data));
      });
    });
    
    req.end();

也就是理论上我们只要对 http/https 模块导出的 request 方法做一层包装,就可以得到请求的参数、返回。

request 函数的参数我们可以从 Ts 类型这里看到,是一个重载函数,相应的,我们要实现对应的重载。

那么,整个函数大致的样子如图,我们设置一个 request 工厂,其接受原本的 request 函数,返回一个新的 request 函数。

工厂内部,要基于参数对具体的 callback 函数也做包装,并作为参数传递给真正的 request 函数。同时,我们还需要对返回的 ClientRequest 实例做一层 write 代理,这一步在拦截 payload 的写入操作。

完成了这一块的基础代码,我们现在拥有了拦截请求的能力。我们现在可以尝试在各个代理函数中做我们的逻辑,这个逻辑比较清晰了。

也就是说,我们能拿到所有的内容了,现在要做的就是梳理好数据存储和更新的逻辑,并且和 CDP 对接上。

关于数据存储的逻辑我就不提了,这是用到的类:


export class RequestDetail {
  id: string;
  type?: "Fetch" | "XHR" | "Script" | "Document" | "Other";
  constructor(needStack = true) {
    this.id = Math.random().toString(36).slice(2);
    this.type = "Fetch"
  }
  documentURL?: string;

  url?: string;
  method?: string;
  cookies: any;

  requestHeaders: any;
  requestData: any;

  responseData: any;
  responseStatusCode?: number;
  responseHeaders: any;

  requestStartTime?: number;
  requestEndTime?: number;
}

3.2 对接 CDP 协议

3.2.1 认识 CDP 客户端和服务端的区别

CDP 协议全称Chrome DevTools Protocol ,它允许工具对 Chromium、Chrome 和其他基于 Blink 的浏览器进行检测、检查、调试和分析。

——上述是官方原话,官方提供了一个快速上手的指南,但是要是真按照这个走,其实是走不通的:https://github.com/aslushnikov/getting-started-with-cdp/blob/master/README.md

原因很简单,不客气地说,在 Google 上用CDP 或者类似的关键词搜索时,出来的各种教程或三方库,如chrome-remote-interface 这类的库,它们的本质都是 CDP 客户端

但是实际上,CDP 客户端并不具备对 devtool 界面的所有操作能力,比如我们需要的 network 这块,CDP 客户端协议并不支持我们去主动推送一些网络相关的事件,而是只能允许我们监听已有的网络服务。

而我们这次要用做的是 CDP 服务端,和客户端有什么区别呢?我们看到下边这张图。

用一句话说,CDP client 等价于使用 devtool 的你,你能用 devtool 做什么操作,CDP client 协议就能用 devtool 做什么。而CDP server 则等价于在 devtool 后边默默调度、提供各种数据的 Chrome 浏览器。

举例来说,我们常见的chrome-remote-interface 或者puppeteer 这两个和 CDP 协议牵扯比较深的两个开源库,它们运行时都是作为 CDP Client 存在的。我们可以以chrome-remote-interface 为例子观察一下运行时的 Protocol 通信记录。

首先打开一个支持远程调试的 Chrome 浏览器

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=./tmp

运行下边的代码

const CDP = require("chrome-remote-interface");

(async () => {
  let client;

  try {
    const targets = await CDP.List();

    // 选择一个非 DevTools 的目标页面,假设第一个非 DevTools 的页面是我们想要的
    const target = targets.find(
      (target) =>
        target.type === "page" && !target.url.startsWith("devtools://")
    );

    if (!target) {
      throw new Error("No suitable target found");
    }

    // 连接到正确的目标页面
    client = await CDP({ target });

    // 提取需要的 DevTools 协议域
    const { Page } = client;

    await Page.enable();

    // 导航到页面
    await Page.navigate({ url: "https://example.com" });
    await Page.loadEventFired();

  } catch (err) {
    console.error(err);
  } finally {
    if (client) {
      await client.close();
    }
  }
})();

可以看到,我们发起的各种指令,都是作为 request 发起。 而 response 方是谁?在这里, response 方其实是我们打开的远程服务,我们在通过--remote-debugging-port 启动一个新的 Chrome 浏览器时,Chrome 会在后台启动一个服务,其中包含了各种复杂的逻辑用来接受和反馈 client 发起的 request。

而我们说过,CDP client 等价于使用 devtool 的你,你能用 devtool 做什么操作,CDP client 协议就能用 devtool 做什么。你能手动把 Network 信息主动推送到 devtool 上吗,你不行(导入导出 har 除外),所以我们在对接时一开始就应该注意方向。

3.2.2 完成一次完整的消息推送

现在,我们应该启动一个 websocket server 去响应来自 devtool 的消息或者主动推送,而不是作为 client 去操作。

import { Server, WebSocket } from "ws";

export interface DevtoolServerInitOptions {
  port: number;
}


class DevtoolServer{
  constructor(props: DevtoolServerInitOptions) {
    const { port } = props;
    this.port = port;
    this.server = new Server({ port });
    const { server } = this;

    server.on("listening", () => {
      console.log(`devtool server is listening on port ${port}`);
    });

    this.socket = new Promise<[WebSocket]>((resolve) => {
      server.on("connection", (socket) => {
        this.socket.then((l) => {
          l[0] = socket;
        });
        console.log("devtool connected");
        socket.on("message", (message) => {
          const msg = JSON.parse(message.toString());
          this.listeners.forEach((listener) => listener(null, msg));
        });
        socket.on("close", () => {
          console.log("devtool closed");
        });
        socket.on("error", (error) => {
          this.listeners.forEach((listener) => listener(error));
        });
        resolve([socket] satisfies [WebSocket]);
      });
    });
  }
  
  async send(message: any) {
    const [socket] = await this.socket;
    return socket.send(JSON.stringify(message));
  }

}

在上边这份代码中,我们定义了一个基本的 websocket 服务,目前设计的 server 是一个单实例的 server。考虑到在实际开发场景中极可能出现断开重连的情况,有特别对这一情况做了一层包装。

到了这一步,当我们实例化 DevtoolServer ,就会启动一个 websocket 服务,而我们如果希望使用这个服务,可以尝试在 Chrome 浏览器输入devtools://devtools/bundled/inspector.html?ws=localhost:${this.port} ,由此可以打开一个 devtool 界面,这个是 Chrome 内置界面——这也算项目的一个优势,由于无需内置 Chromium,包体积都小了不少。

那我们要如何推送一个网络请求到 devtool 上呢?

这还是要看到我们前边说的Protocol Monitor ,通过 Command + P 输入 > Protocal Monitor 可以展示,如果没有需要在 devtool 右上角设置里点击 Experiments 搜索后打开。

我们可以打开这个网址:https://jsonplaceholder.typicode.com/posts,在跳转前先打开 devtool,观察 monitor。

可以从图中看到,上方发出了两个请求,下边通过 Network 过滤也可以大概看到两块比较重复的逻辑。

那么我们可以大概知道,一个请求的完整呈现,大致需要 6 条消息交换,去掉其中两个 ExtraInfo,有 4 条消息是必要的。

这些实际上就是 devtool 用于跟踪和调试 HTTP 请求的生命周期。

  • Network.requestWillBeSent
    • 场景:在浏览器准备发送 HTTP 请求时触发。
    • 作用:提供即将发送的请求的详细信息,包括请求的 URL、方法、请求头等。可以用来捕获和分析发出的请求。
  • Network.requestWillBeSentExtraInfo
    • 场景:在浏览器发送 HTTP 请求之前,补充发送的额外信息。
    • 作用:提供请求附加信息,比如包含认证信息和 Cookie 的请求头。这些信息可能在初始请求信息中不可用。
  • Network.responseReceivedExtraInfo
    • 场景:在接收到响应头之后触发,但在接收响应体之前。
    • 作用:提供响应的附加信息,比如额外的响应头或包含认证信息的响应头。可以用来获取服务器返回的额外信息。
  • Network.responseReceived
    • 场景:在接收到 HTTP 响应头时触发。
    • 作用:提供接收到的响应头和其他响应元数据,包括状态码、响应头等。用于分析服务器的响应状态和头信息。
  • Network.dataReceived
    • 场景:在接收 HTTP 响应数据时持续触发,直到响应数据全部接收完毕。
    • 作用:提供接收到的响应数据的字节信息。可以用来跟踪响应数据的接收进度。
  • Network.loadingFinished
    • 场景:在整个 HTTP 请求和响应过程完成时触发,即响应数据完全接收且连接关闭时。
    • 作用:标识请求过程的完成,提供下载的总字节数。用于确定请求和响应的结束点。

理论上,只要我们发起了一次 requestWillBeSent,也自然能在 devtool 上看到相关的内容。我们可以用下边的代码试一下:

import { Server, WebSocket } from "ws";

export interface DevtoolServerInitOptions {
  port: number;
}

export class RequestDetail {
  id: string;
  type?: "Fetch" | "XHR" | "Script" | "Document" | "Other";
  constructor(needStack = true) {
    this.id = Math.random().toString(36).slice(2);
    this.type = "Fetch";
  }
  documentURL?: string;

  url?: string;
  method?: string;
  cookies: any;

  requestHeaders: any;
  requestData: any;

  responseData: any;
  responseStatusCode?: number;
  responseHeaders: any;

  requestStartTime?: number;
  requestEndTime?: number;
}

class DevtoolServer {
  private server: Server;
  private port: number;
  private socket: Promise<[WebSocket]>;

  private listeners: ((error: unknown | null, message?: any) => void)[] = [];
  constructor(props: DevtoolServerInitOptions) {
    const { port } = props;
    this.port = port;
    this.server = new Server({ port });
    const { server } = this;

    server.on("listening", () => {
      console.log(`devtool server is listening on port ${port}`);
    });

    this.socket = new Promise<[WebSocket]>((resolve) => {
      server.on("connection", (socket) => {
        this.socket.then((l) => {
          l[0] = socket;
        });
        console.log("devtool connected");
        socket.on("message", (message) => {
          const msg = JSON.parse(message.toString());
          this.listeners.forEach((listener) => listener(null, msg));
        });
        socket.on("close", () => {
          console.log("devtool closed");
        });
        socket.on("error", (error) => {
          this.listeners.forEach((listener) => listener(error));
        });
        resolve([socket] satisfies [WebSocket]);
      });
    });
  }

  async send(message: any) {
    const [socket] = await this.socket;
    return socket.send(JSON.stringify(message));
  }
}

const devtool = new DevtoolServer({
  port: 5270,
});

const request = new RequestDetail();
request.url = "http://localhost:3000/post";
request.method = "POST";
request.requestHeaders = {
  "content-type": "application/json",
};
request.requestData = {
  a: 1,
};

const contentType = request.requestHeaders["content-type"];

devtool.send({
  method: "Network.requestWillBeSent",
  params: {
    requestId: request.id,
    frameId: "123",
    loaderId: "123.1",
    request: {
      url: request.url,
      method: request.method || "GET",
      headers: request.requestHeaders,
      initialPriority: "High",
      mixedContentType: "none",
      ...(request.requestData
        ? {
            postData: contentType?.includes("application/json")
              ? JSON.stringify(request.requestData)
              : request.requestData,
          }
        : {}),
    },
    timestamp: Date.now(),
    wallTime: Date.now(),
    initiator: {
      type: "Other",
    },
    type: request.type,
  },
});

这份代码会使得 devtool 上产生一个持续 pending 状态的请求,效果如下

现在我们再调用 responseReceived,看看效果。现在重新运行一下,会看到 devtool 上的response headers 中多了我们随手写的请求头,响应状态也变成了 200。

devtool.send({
  method: "Network.responseReceived",
  params: {
    requestId: request.id,
    frameId: "123",
    loaderId: "123.1",
    timestamp: Date.now(),
    type: request.type,
    response: {
      url: request.url,
      status: 200,
      statusText: "OK",
      headers: {
        "content-type": "application/json",
        "xxx": "yyy",
      },
    },
  },
});

不过此时我们会看到请求仍在 Pending,并且 Size 为 0B,这是因为我们还没有调用 dataReceivedloadingFinished

我们尝试调用 dataReceived,现在 devtool 上又有了变化。在 dataReceived 的参数中,dataLength 是指未压缩或未编码的原始数据的大小,encodedDataLength 是指在传输过程中实际接收到的压缩或编码后的数据大小。

devtool.send({
  method: "Network.dataReceived",
  params: {
    requestId: request.id,
    dataLength: 200,
    encodedDataLength: 100,
  },
});

——当然在第一期实现的时候我们可以先不考虑两者的区别,跑起来再说。

接着,让我们结束请求,注意 timestamp 的单位是秒(在后边的流程中发现的 https://github.com/GrinZero/node-network-devtools/issues/11

devtool.send({
  method: "Network.loadingFinished",
  params: {
    requestId: request.id,
    timestamp: Date.now() + 1,
    encodedDataLength: 200,
  },
});

那么现在 bingo~我们完成了一个完整的请求。

但是我们会发现,body 这里是空的。但是这并不是因为我们在 responseReceived 中没有填写相关的数据,而是因为这里需要我们监听来自 devtool 的消息来主动推送。

我们给 DevtoolServer 添加一个 on 函数,再添加一个我们自己的监听用来推送消息。

class DevtoolServer{
  ...
  public on(listener: (error: unknown | null, message?: any) => void) {
    this.listeners.push(listener);
  }
}
devtool.on((error, message) => {
  if (error) return;
  if (message.method === "Network.getResponseBody") {
    devtool.send({
      id: message.id,
      result: {
        body: JSON.stringify({
          hello: "world",
        }),
        base64Encoded: false,
      },
    });
  }
});

那么最终,我们完成了消息的推送。

3.3 开发篇总结

开发篇,在本期我们没有太多非常高深的内容,主要是对核心能力的梳理,同时完成了完整的请求推送 demo,确定了技术方案的可行性。

整体方案可以总结成,我们需要完成一个包装器以及一个 Devtool Server,通过 CDP 协议作为服务端向 devtool 推送数据。

在确定思路没有问题的情况下,第一期的代码的话实现可以查看 https://github.com/GrinZero/node-network-devtools/commit/237fa6e731c55337c0b243386da498d0f92ceae9 这个 commit,在这里已经完成了包装器和 CDP 服务器推送的部分,已经能实现对于基本 HTTP/HTTPS 请求的推送和展示(当然还有一些额外内容,比如自动打开调试页面)。

4 尾声

本文我们通过一次完整的请求推送验证了自己手写 node network devtools,并且在此基础上完成了第一期的 devtool。

但是这一期我们考虑其实疏漏了,遗漏了两个比较重要的地方:

  • 我们将 CDP Server 放到了主进程和用户的应用一起运行,对用户程序有比较大的影响。
  • 我们没有考虑到 NodeJS 调试场景下,用户不断更新时来自第三方工具带来的主进程热重启对我们程序以及 devtool 使用体验的影响。

这两个问题可以考虑通过多进程的方式来处理掉,下一期,我们通过多进程的方式来处理这些问题。

——如果你等不及看下一期内容,可以直接看到仓库https://github.com/GrinZero/node-network-devtools,因为文章的滞后性,实际上项目已经实现了非常漂亮的主进程调度模型,保证了热重启不影响我们的程序。

并且线上的版本已经实现比较全面的 devtool 体验,欢迎有需求的小伙伴使用,也欢迎有兴趣参与开发的同学参与到 devtools 的贡献 🎉

;