Node.js调试指南

5,752 阅读8分钟

现今 Node.js 愈发受欢迎,应用场景也越来越多,学会高效调试 Node.js 会让日常开发更高效。下面讲下使用inspector调试nodejs程序

Node6.3+ 的版本提供了两个用于调试的协议:v8 Debugger Protocolv8 Inspector Protocol 可以使用第三方的 Client/IDE 等监测和介入 Node(v8) 运行过程,进行调试。

v8 Inspector Protocol 是新加入的调试协议,通过 websocket (通常使用 9229 端口)与 Client/IDE 交互,同时基于 Chrome/Chromium 浏览器的 devtools 提供了图形化的调试界面。

1 开启调试

1.1 调试服务器代码

如果你的脚本搭建http或者net服务器,你可以直接使用--inspect

const Koa = require('koa')
const app = new Koa()

app.use(async ctx => {
  
  let a = 0
  const longCall = () => { 
    while (a < 10e8) { 
      a++
    }
  }
  longCall()
  ctx.body = `Hello ${a}`
})

app.listen(3000, () => { 
  console.log('程序监听了3000端口')
})

使用 node --inspect=9229 app.js 启动你的脚本,9229 是指定的端口号

# 控制台会输出如下:
/usr/local/bin/node --inspect=9229 src/inspector/demo.js 
Debugger listening on ws://127.0.0.1:9229/c4f1e345-e811-47a2-b44a-65f68c0c2cc3
Debugger attached.
# 可以在浏览器里打开:http://127.0.0.1:9229/json 看到一些信息, c4f1e345-e811-47a2-b44a-65f68c0c2cc3 为uuid,不同调试面板的uuid来区分;

--inspect 对于一般的程序都是一闪而过,断点信号还没发送出去,就执行完毕了。 断点根本不起作用,可以--inspect-brk

1.2 调试脚本代码

如果你的脚本运行完之后直接结束进程,那么你需要使用--inspect-brk来启动调试器,这样使得脚本可以代码执行之前break,否则,整个代码直接运行到代码结尾,结束进程,根本无法进行调试。

node --inspect-brk=9229 app.js

2 调试工具接入

2.1 VS Code

Vs Code 内置了 Node debugger ,支持 v8 Debugger Protocolv8 Inspector Protocol 两种协议。对于 v8 Inspector Protocol ,只需要在配置里添加一条 Attach 类型配置

Debug 控制面板, 点击 settings 图标,打开 .vscode/launch.json. 点击 “Node.js” 进行初始配置即可.

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Launch Program",
      "program": "${workspaceFolder}/app.js"
    }
  ]
}

2.2 Chrome DevTools

  • 方法1:在Chrome浏览器打开 chrome://inspect点击Configure按钮,确定host和端口在列表中。

  • 方法2:从上述host和端口/json/list复制 devtoolsFrontendUrl–-inspect 提示信息,并复制到Chrome.

2.2.1 Console Panel

chrome 接入要调试的 node 进程后,可以在 Console 中代理 Node 进程中所有的控制台输出,提供了灵活的 Filter 过滤功能,还可以在 Node 进程代码的上下文中直接执行代码。

2.2.2 Sources Panel

Sources 中可以查看所有加载的脚本,还包括第三方库和Node 核心库,选中文件可以进行编辑,Ctrl + C 保存可以直接修改运行中的脚本。

2.2.3 Profile Panel

Profile 用于对运行中的脚本进行性能监测,包括CPU和内存的使用,CPU profile,可以记录时间线上 Javascript 函数执行时占用的 CPU 时间.

profile 记录时间段有两种

  • 手动开始/停止:单击 start 开始记录,单击 stop 停止记录
  • 在代码中插入开始/停止的 API 调用 console.profile('tag') console.profileEnd('tag') ,可以在 Sources 面板中直接编辑保存代码,然后 F5 刷新一下。

profile有三种视图

  • chart:俗称火焰图,以时间为横轴显示函数调用栈。下面简单举例分析

火焰图的函数调用栈是倒置的,最上面为栈底,最下面为栈顶。一个栈是一个 tick ,一个 tick 一定是由 Node 底层开始调用的,在 Node 中使用 process.nextTick(fn)setTimeout(fn, deloy) 的系统回调会产生新的 tick ,对应产生新的调用栈。

函数的调用顺序是从栈底到栈顶。上图中第一个栈 parserOnHeadersComplete 由底层调用,parserOnHeadersComplete 中调用了 parserOnIncomingparserOnIncoming 中调用了emit...依次类推。

调用栈的宽度是函数执行的时间。一个函数的执行时间包含了其内部调用其他函数的执行时间,所以相对靠近栈底的函数的调用时间一定比靠近栈顶的函数的调用时间长。除去内部调用其他函数的执行时间,就是当前函数的执行时间。

点击函数会跳转到 Sources 面板中函数定义的位置。

将鼠标悬停在函数上可显示其名称和数据:

下面解释摘自 chrome-devtools 文档

  • Name:函数的名称。

  • Self time:完成函数当前的调用所需的时间,仅包含函数本身的声明,不包含函数调用的任何函数。

  • Total time: 完成此函数和其调用的任何函数当前的调用所需的时间。

  • URL:形式为 file.js:100 的函数定义的位置,其中 file.js 是定义函数的文件名称,100 是定义的行号。

  • Aggregated self time:记录中函数所有调用的总时间,不包含此函数调用的函数。

  • Aggregated total time: 函数所有调用的总时间,不包含此函数调用的函数。

  • Not optimized:如果分析器已检测出函数存在潜在的优化,会在此处列出。

  • heavy(Bottom Up):统计数据,自底向上,底指的是火焰图的底。

  • tree(Top Down):统计数据,自顶向下,顶指的是火焰图的顶。

可以看到程序大部分时间是消耗在longCall这个函数的调用上;

2.2.4 Memory profile

堆分析器可以按页面的 JavaScript 对象和相关 DOM 节点显示内存分配(另请参阅对象保留树)。使用分析器可以拍摄 JS 堆快照分析内存图比较快照以及查找内存泄漏.

3. Node Inspector 代理实现

通过 node inspector 来进行断点调试是一个很常用的 debug 方式。但是以前的调试中有几个问题会导致我们的调试效率降低。

  • vscode 中调试,在 inspector 端口变更或者 websocket id 变更后要重连。
  • devtools 中调试,在inspector 端口变更或者 websocket id 变更后要重连。

那 node inspector是如何解决上述两个问题呢?

对于第一个问题,在 vscode 上,它是会自己去调用 /json 接口获取最新的 websocket id,然后使用新的 websocket id 连接到 node inspector 服务上。因此解决方法就是实现一个 tcp 代理功能做数据转发即可。

对于第二个问题,由于 devtools 是不会自动去获取新的 websocket id 的,所以我们需要做动态替换,所以解决方案就是代理服务去 /json 获取 websocket id,然后在 websocket 握手的时候将 websocket id 进行动态替换到请求头上。

画了一张流程图:

3.1 Tcp 代理

首先,先实现一个 tcp 代理的功能,其实很简单,就是通过 nodenet 模块创建一个代理端口的 Tcp Server,然后当有连接过来的时候,再创建一个连接到目标端口即可,然后就可以进行数据的转发了。

简易的实现如下:

const net = require('net');
const proxyPort = 9229;
const forwardPort = 5858;

net.createServer(client => {
  const server = net.connect({
    host: '127.0.0.1',
    port: forwardPort,
  }, () => {
    client.pipe(server).pipe(client);
  });
  // 如果真要应用到业务中,还得监听一下错误/关闭事件,在连接关闭时即时销毁创建的 socket。
}).listen(proxyPort);

上面实现了比较简单的一个代理服务,通过 pipe 方法将两个服务的数据连通起来。client 有数据的时候会被转发到 server 中,server 有数据的时候也会转发到 client 中。

当完成这个 Tcp 代理功能之后,就已经可以实现 vscode 的调试需求了,在 vscode 中项目下 launch.json 中指定端口为代理端口,在 configurations 中添加配置

{
  "type": "node",
  "request": "attach",
  "name": "Attach",
  "protocol": "inspector",
  "restart": true,
  "port": 9229
}

那么当应用重启,或者更换 inspect 的端口,vscode 都能自动重新通过代理端口 attach 到你的应用。

3.2 获取 websocketId

这一步开始,就是为了解决 devtools链接不变的情况下能够重新 attach 的问题了,在启动 node inspector server的时候,inspector 服务还提供了一个 /jsonhttp 接口用来获取 websocket id

这个就相当简单了,直接发个 http 请求到目标端口的 /json,就可以获取到数据了:

[ { description: 'node.js instance',
    devtoolsFrontendUrl: '...',
    faviconUrl: 'https://nodejs.org/static/favicon.ico',
    id: 'e7ef6313-1ce0-4b07-b690-d3cf5274d8b0',
    title: '/Users/wanghx/Workspace/larva-team/vscode-log/index.js',
    type: 'node',
    url: 'file:///Users/wanghx/Workspace/larva-team/vscode-log/index.js',
    webSocketDebuggerUrl: 'ws://127.0.0.1:5858/e7ef6313-1ce0-4b07-b690-d3cf5274d8b0' } ]

上面数据中的 id 字段,就是我们需要的 websocket id 了。

3.3 Inspector 代理

拿到了 websocket id后,就可以在 tcp 代理中做 websocket id 的动态替换了,首先我们需要固定链接,因此先定一个代理链接,比如我的代理服务端口是 9229,那么 chrome devtools 的代理链接就是:

chrome-devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:9229/__ws_proxy__

上面除了最后面的 ws=127.0.0.1:9229/__ws_proxy__ 其他都是固定的,而最后这个也一眼就可以看出来是 websocket 的链接。其中 __ws_proxy__则是用来占位的,用于在 chrome devtools 向这个代理链接发起 websocket 握手请求的时候,将 __ws_proxy__ 替换成 websocket id 然后转发到 nodeinspector 服务上。

对上面的 tcp 代理中的 pipe 逻辑的代码做一些小修改即可。

const through = require('through2')

client
    .pipe(through.obj((chunk, enc, done) => {
        if (chunk[0] === 0x47 && chunk[1] === 0x45 && chunk[2] === 0x54) {
          const content = chunk.toString();
          if (content.includes('__ws_proxy__')) {
            return done(null, Buffer.from(content.replace('__ws_proxy__', websocketId)));
          }
        }
        done(null, chunk);
      }))
    .pipe(server)
    .pipe(client)

通过 through2 创建一个 transform 流来对传输的数据进行一下更改。

简单判断一下 chunk 的头三个字节是否为GET,如果是 GET 说明这可能是个 http 请求,也就可能是 websocket 的协议升级请求。把请求头打印出来就是这个样子的:

GET /__ws_proxy__ HTTP/1.1
Host: 127.0.0.1:9229
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
Origin: chrome-devtools://devtools
Sec-WebSocket-Version: 13

然后将其中的路径/__ws_proxy替换成对应的 websocketId,然后转发到 nodeinspector server 上,即可完成websocket 的握手,接下来的 websocket 通信就不需要对数据做处理,直接转发即可。

接下来就算各种重启应用,或者更换 inspector 的端口,都不需要更换 debug 链接,只需要再 inspector server 重启的时候,在下图的弹窗中

点击一下 Reconnect DevTools 即可恢复 debug。

参考:Node Inspector 代理实现