调试是每个程序员必备的技能,因此选择合适的调试工具能极大地方便我们调试代码。Node.js 的调试方式也有很多,常见的有:
- 万能的 console.log
- debugger
- node-inspector
以上本节都不会讲解,因为:
- console.log 就不用说了。
- debugger 不推荐使用,因为:
- 使用繁琐,需手动打点。
- 若忘记删除 debugger,还会引起性能问题。
- node-inspector 已经退出历史舞台。node@6.3 以后内置了一个调试器,可以结合 Chrome DevTools 使用,而且比 node-inspector 更强大。
JavaScript 程序越来越复杂,调试工具的重要性日益凸显。客户端脚本有浏览器,Node 脚本怎么调试呢?
2016年,Node 决定将 Chrome 浏览器的”开发者工具”作为官方的调试工具,使得 Node 脚本也可以使用图形界面调试,这大大方便了开发者。
一、示例程序
为了方便讲解,下面是一个示例脚本。首先,新建一个工作目录,并进入该目录。
$ mkdir debug-demo
$ cd debug-demo
然后,生成package.json
文件,并安装 Koa 框架和 koa-route 模块。
$ npm init -y
$ npm install --save koa koa-route
接着,新建一个脚本app.js
,并写入下面的内容。
const Koa = require('koa');
const router = require('koa-route');
const app = new Koa();
const main = ctx => {
ctx.response.body = 'Hello World';
};
const welcome = (ctx, name) => {
ctx.response.body = 'Hello ' + name;
};
app.use(router.get('/', main));
app.use(router.get('/:name', welcome));
app.listen(3000);
console.log('listening on port 3000');
上面代码是一个简单的 Web 应用,指定了两个路由,访问后会显示一行欢迎信息。如果想了解代码的详细含义,可以参考 Koa 教程。
二、启动开发者工具
现在,运行上面的脚本。
$ node --inspect app.js
上面代码中,--inspect
参数是启动调试模式必需的。这时,打开浏览器访问http://127.0.0.1:3000
,就可以看到 Hello World 了。
接下来,就要开始调试了。一共有两种打开调试工具的方法,第一种是在 Chrome 浏览器的地址栏,键入 chrome://inspect
或者about:inspect
,回车后就可以看到下面的界面。
在 Target 部分,点击 inspect 链接,就能进入调试工具了。
第二种进入调试工具的方法,是在 http://127.0.0.1:3000 的窗口打开”开发者工具”,顶部左上角有一个 Node 的绿色标志,点击就可以进入。
三、调试工具窗口
调试工具其实就是”开发者工具”的定制版,省去了那些对服务器脚本没用的部分。
它主要有四个面板。
- Console:控制台
- Memory:内存
- Profiler:性能
- Sources:源码
这些面板的用法,基本上跟浏览器环境差不多,这里只介绍 Sources (源码)面板。
四、设置断点
进入 Sources 面板,找到正在运行的脚本app.js
。
在第11行(也就是下面这一行)的行号上点一下,就设置了一个断点。
ctx.response.body = 'Hello ' + name;
这时,浏览器访问 http://127.0.0.1:3000/alice ,页面会显示正在等待服务器返回。切换到调试工具,可以看到 Node 主线程处于暂停(paused)阶段。
进入 Console 面板,输入 name,会返回 alice。这表明我们正处在断点处的上下文(context)。
再切回 Sources 面板,右侧可以看到 Watch、Call Stack、Scope、Breakpoints 等折叠项。打开 Scope 折叠项,可以看到 Local 作用域和 Global 作用域里面的所有变量。
Local 作用域里面,变量name
的值是alice
,双击进入编辑状态,把它改成bob
。
然后,点击顶部工具栏的继续运行按钮。
页面上就可以看到 Hello bob 了。
命令行下,按下 ctrl + c,终止运行app.js
。
五、调试非服务脚本
Web 服务脚本会一直在后台运行,但是大部分脚本只是处理某个任务,运行完就会终止。这时,你可能根本没有时间打开调试工具。等你打开了,脚本早就结束运行了。这时怎么调试呢?
$ node --inspect=9229 -e "setTimeout(function() { console.log('yes'); }, 30000)"
上面代码中,--inspect=9229
指定调试端口为 9229,这是调试工具默认的通信端口。-e
参数指定一个字符串,作为代码运行。
访问chrome://inspect
,就可以进入调试工具,调试这段代码了。
代码放在setTimeout
里面,总是不太方便。那些运行时间较短的脚本,可能根本来不及打开调试工具。这时就要使用下面的方法。
$ node --inspect-brk=9229 app.js
上面代码中,--inspect-brk
指定在第一行就设置断点。也就是说,一开始运行,就是暂停的状态。
六、忘了写 –inspect 怎么办?
打开调试工具的前提是,启动 Node 脚本时就加上--inspect
参数。如果忘了这个参数,还能不能调试呢?
回答是可以的。首先,正常启动脚本。
$ node app.js
然后,在另一个命令行窗口,查找上面脚本的进程号。
$ ps ax | grep app.js
30464 pts/11 Sl+ 0:00 node app.js
30541 pts/12 S+ 0:00 grep app.js
上面命令中,app.js
的进程号是30464
。
接着,运行下面的命令。
$ node -e 'process._debugProcess(30464)'
上面命令会建立进程 30464 与调试工具的连接,然后就可以打开调试工具了。
还有一种方法,就是向脚本进程发送 SIGUSR1 信号,也可以建立调试连接。
$ kill -SIGUSR1 30464
七、参考链接
VS Code 调试
Visual Studio Code(简称 VS Code)是一款微软开源的现代化、跨平台、轻量级的代码编辑器。VS Code 很好很强大,本节将介绍如何使用 VS Code 来调试 Node.js 代码。
基本调试
示例代码如下:
const Paloma = require('paloma')
const app = new Paloma()
app.use(ctx => {
ctx.body = 'hello world!'
})
app.listen(3000)
用 VS Code 加载 test 文件夹,打开 app.js,然后进行如下操作:
- 单击左侧第 4 个 tab,切换到调试模式。
- 单击代码第 5 行
ctx.body='hello world!'
左侧空白处添加断点。
- 单击左上角 ”调试“ 的绿色三角按钮启动调试。
- 单击左上角的终端图标打开调试控制台。
最终如下所示:
从 “调试控制台“ 切换到 ”终端“,运行:
$ curl localhost:3000
如下所示:
可以看出,VS Code 基本覆盖了 Chrome DevTools 的所有功能,并且有两个额外的优点:
- 集成了终端,不用再打开新的终端输入命令了。
- 调试动作里添加了 ”重启“ 和 ”停止“ 按钮,不用每次修改完代码后切回终端去重启了。
但 VS Code 的强大远不止如此,通过 launch.json 可以配置详细的调试功能。
launch.json
上图可以看出,”调试“ 按钮右边有一个下拉菜单,默认是 ”没有配置“。单击右侧的齿轮状图标,会在项目根目录下创建 .vscode 文件夹及 launch.json 文件。launch.json 的内容如下:
这个默认配置的意思是执行:
$ node ${workspaceFolder}/app.js
launch.json 其实就是存储了一些调试相关的配置,VS Code 在启动调试时,会读取 launch.json 决定以何种方式调试。launch.json 有以下常用选项:
必需字段如下:
- type:调试器类型。这里是 node(内置的调试器),如果安装了 Go 和 PHP 的扩展后,则对应的 type 分别为 go 和 php。
- request:请求的类型,支持 launch 和 attach。launch 就是以 debug 模式启动调试,attach 就是附加到已经启动的进程开启 debug 模式并调试,跟在上一小节中提到的用
node -e "process._debugProcess(PID)"
作用一样。
- name:下拉菜单显示的名字。
可选字段(括号里表示适用的类型)如下:
- program:可执行文件或者调试器要运行的文件 (launch)。
- args:要传递给调试程序的参数 (launch)。
- env:环境变量 (launch)。
- cwd:当前执行目录 (launch)。
- address:IP 地址 (launch & attach)。
- port:端口号 (launch & attach)。
- skipFiles:想要忽略的文件,数组类型 (launch & attach)。
- processId:进程 PID (attach)。
- …
变量替换:
- ${workspaceFolder}:当前打开工程的路径。
- ${file}:当前打开文件的路径。
- ${fileBasename}:当前打开文件的名字,包含后缀名。
- ${fileDirname}:当前打开文件所在的文件夹的路径。
- ${fileExtname}:当前打开文件的后缀名。
- ${cwd}:当前执行目录。
- …
如果当前打开的文件是 app.js,则以下配置与默认配置是等效的:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "启动程序",
"program": "${file}"
}
]
}
若想了解更多的 launch.json 选项,则请查阅:
下面以 5 个实用的技巧讲解部分 launch.json 配置的作用。
技巧 1——条件断点
VS Code 可以添加条件断点,即执行到该行代码满足特定条件后程序才会中断。在断点小红点上右键选择 ”编辑断点“,可以选择以下两种条件:
- 表达式:当表达式计算结果为 true 时中断,例如设置:
ctx.query.name === 'nswbmw'
,表示当访问 localhost:3000?name=nswbmw
时断点才会生效,其余请求断点无效。
- 命中次数:同样当表达式计算结果为 true 时中断,支持运算符 <、<=、==、>、>=、%。例如:
- >10:执行 10 次以后,断点才会生效。
- <3:只有前 2 次断点会生效。
- 10:等价于 >=10。
- %2:隔一次中断一次。
注意:可以组合表达式和命中次数条件一起使用。在切换条件类型时,需要将原来的条件清空,否则会添加两种条件。将鼠标悬浮在断点上,可以查看设置了哪些条件。
技巧 2——skipFiles
从上面图中可以看到,在 VS Code 左侧有一个 ”调用堆栈“ 面板,显示了当前断点的调用堆栈,但无法直观地看出哪些是我们项目的代码,哪些是 node_modules 里模块的代码,而且在单步调试时会进入到 node_modules 里。总之,我们不关心 node_modules 里的代码,我们只关心项目本身的代码。这时,skipFiles 就派上用场了。
skipFiles 顾名思义就是忽略我们不关心的文件。修改 launch.json 如下:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "启动程序",
"program": "${workspaceFolder}/app.js",
"skipFiles": [
"${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js"
]
}
]
}
有以下几点需要解释:
- 支持 ${xxx} 这种变量替换。
- 支持 glob 模式匹配。
- 用来忽略 Node.js 核心模块。
重启调试后,如下所示:
可以看出:在左侧 ”调用堆栈“ 中,我们不关心的调用栈都变灰了,而且单步调试也不会进入到 skipFiles 所匹配的文件里。
技巧 3——自动重启
在每次修改代码保存后都要手动重启,否则修改后的代码和断点都不会生效。VS Code 开发者们想到了这一点,通过添加配置可以实现修改代码保存后会自动重启调试,需要结合 nodemon 一起使用。
首先,全局安装 nodemon:
$ npm i nodemon -g
然后,修改 launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "启动程序",
"runtimeExecutable": "nodemon",
"program": "${workspaceFolder}/app.js",
"restart": true,
"console": "integratedTerminal",
"skipFiles": [
"${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js"
]
}
]
}
当前的 launch.json 相比较上一个版本的 launch.json,多了以下几个字段:
- runtimeExecutable:用什么命令执行 app.js,这里设置为 nodemon。
- restart:设置为 true,修改代码并保存后会自动重启调试。
- console:当单击停止按钮或者修改代码并保存后自动重启调试,而 nodemon 是仍然在运行的,通过设置为 console 为 integratedTerminal 可以解决这个问题。此时 VS Code 终端将会打印 nodemon 的 log,可以在终端右侧的下拉菜单中选择返回第 1 个终端,然后运行
curl localhost:3000
进行调试。
对于已经使用 nodemon 运行的程序,例如:
$ nodemon --inspect app.js
可使用 attach 模式启动调试,launch.json 如下:
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to node",
"type": "node",
"request": "attach",
"restart": true,
"processId": "${command:PickProcess}"
}
]
}
运行 Attach to node 配置进行调试时,VS Code 会列出正在执行的 node 进程及对应的 PID 以供选择。也可以通过 address 和 port 参数设置 attach 到具体的进程开启调试。
技巧 4——特定操作系统设置
针对不同的操作系统,可能会用到不同的调试配置。可选的参数为:
示例如下:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "启动调试",
"program": "./node_modules/gulp/bin/gulpfile.js",
"args": ["/path/to/app.js"],
"windows": {
"args": ["\\path\\to\\app.js"]
}
}
]
}
技巧 5——多配置
configurations 是个数组而不是个对象,这样设计就是为了可以添加多个调试配置。打开 launch.json,单击右下角的 ”添加配置…“,会弹出配置模板,如下所示:
configurations 可以用来配置不同的调试规则,比如最终将 launch.json 修改如下:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "Attach to node",
"restart": true,
"processId": "${command:PickProcess}"
},
{
"type": "node",
"request": "launch",
"name": "启动程序",
"runtimeExecutable": "nodemon",
"program": "${workspaceFolder}/app.js",
"restart": true,
"console": "integratedTerminal",
"skipFiles": [
"${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js"
]
}
]
}
总结
VS Code 的调试功能十分强大,本节只讲解了一些常用的调试功能,对于其余的调试功能,还请读者自行尝试。
TypeScript 调试
VS Code 内置的 Node.js 的调试器支持 JavaScript Source Map,可以结合 Source Map 调试转译前的代码,如 TypeScript,压缩混淆的 JavaScript 代码等都可以利用 Source Map 的支持调试源码。
我准备了一个简单的 TS Server Demo,可以直接 Clone 源码本地测试。下面是项目中的 src/index.ts
文件,创建了一个 HTTP Server
import * as http from "http";
let reqCount = 1;
http
.createServer((req, res) => {
const message = `Request Count: ${reqCount}`;
res.writeHead(200, { "Content-Type": "text/html" });
res.end(`<html><body>${message}</body></html>`);
console.log("handled request: " + reqCount++);
})
.listen(3000);
console.log("server running on port 3000");
创建 tsconfig.json
配置,配置编译生成 Source Map
{
"compilerOptions": {
"outDir": "./dist",
"sourceMap": true
},
"include": ["src/**/*"]
}
使用 tsc
编译一下,生成 JS 代码:dist/index.js
,创建调试配置,入口文件为 dist/index.js
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/dist/index.js",
"skipFiles": ["<node_internals>/**"]
}
复制代码
然后打断点,启动调试,浏览器访问 http://localhost:3000
,即可看到调试进入了 TS 文件
远程调试
当我们需要在真实的服务器等远程运行环境调试 Node.js 时,我们可以利用上面提到的方式,在服务器上开启 Node.js 调试功能,并在本地连接上远程的调试端口进行调试。
VS Code 默认支持远程调试,我们需要 launch.json
配置文件中指定远程服务的 IP 地址以及端口,如下所示:
{
"type": "node",
"request": "attach",
"name": "远程调试",
"address": "IP 地址",
"port": "9229"
}
VS Code 会自动加载远程的文件,展示为只读代码供调试使用。
如果想要在调试的过程中编辑源代码,或者更好的调试体验,可以在远程文件夹和本地项目之间设置一个映射。VS Code 提供了 localRoot
和 remoteRoot
属性来映射本地 VS Code 项目和(远程)Node.js 文件夹:
{
"type": "node",
"request": "attach",
"name": "远程调试",
"address": "IP 地址",
"port": "9229",
"localRoot": "${workspaceFolder}/src",
"remoteRoot": "/var/user/"
}
在建立映射关系后,即可在本地项目进行断点调试,远程的断点信息会同步到本地项目,使用起来十分方便。
子进程调试
与普通进程调试原理一致,子进程调试时也需要传入 --inspect
参数,这一点需要特别注意,否则无法启动子进程调试。
如下通过子进程启动 Server
的例子:
const { spawn } = require("child_process");
const sp = spawn("node", ["./fork_server.js"]);
console.log("父进程 PID", sp.pid);
sp.stdout.on("data", (data) => {
console.log(`stdout: ${data}`);
});
sp.stderr.on("data", (data) => {
console.error(`stderr: ${data}`);
});
如果直接使用 node --inspect
启动主进程的话,会发现只显示了主进程的调试端口,这就是因为我们在程序中启动子进程时没有传递 --inspect
选项导致的。
这里我们在启动进程时添加上 --inspect
参数,同时注意要指定一个默认 9229
端口之外的端口号,避免调试端口冲突
- const sp = spawn("node", ["./fork_server.js"]);
+ const sp = spawn("node", ["--inspect=9230", "./fork_server.js"]);
再次启动,就能看到两个调试信息输出了
当然,怎么能少得了强大的 VS Code 呢。VS Code 的 Node 调试器提供了一种机制,可以追踪所有子进程,并在调试模式下,自动链接进程。可以通过 autoAttachChildProcesses
属性开启此机制:
{
"type": "node",
"request": "launch",
"name": "启动程序",
"program": "${workspaceFolder}/fork.js",
"autoAttachChildProcesses": true
}
启动后,即可对父进程,或子进程进行断点调试,效果如下
调试工具
调试不只是打断点,我们在开发过程中往往还会有其他方面的需求,比如:
- 如何快速地切换输出的日志类型(或级别)?
- 我想用 moment 打印出年份,是使用
moment().format('YYYY')
,还是 moment().format('yyyy')
,还是两种写法都可以?
- 断言报错:AssertionError: false == true,没啥有用信息,黑人问号???
这里将介绍 3 款实用的调试工具,分别解决以上 3 种情况,来提高我们的调试效率。
debug
debug 是一个小巧却非常实用的日志模块,可以根据环境变量决定打印不同类型(或级别)的日志。代码如下:
const normalLog = require('debug')('log')
const errorLowLog = require('debug')('error:low')
const errorNormalLog = require('debug')('error:normal')
const errorHighLog = require('debug')('error:high')
setInterval(() => {
const value = Math.random()
switch (true) {
case value < 0.5: normalLog(value); break
case value >= 0.5 && value < 0.7: errorLowLog(value); break
case value >= 0.7 && value < 0.9: errorNormalLog(value); break
case value >= 0.9: errorHighLog(value); break
default: normalLog(value)
}
}, 1000)
运行上面的代码,每一秒生成一个随机数,根据随机数的值模拟不同级别的日志输出:
- < 0.5:正常日志。
- 0.5~0.7:低级别的错误日志。
- 0.7~0.9:一般级别的错误日志。
- >= 0.9:严重级别的错误日志。
运行:
$ DEBUG=* node app.js
打印如下:
可以看出,debug 模块打印的日志与 console.log 相比,有以下几个特点:
- 不同的日志类型分配了不同的颜色加以区分,更直观。
- 添加了日志类型的前缀。
- 添加了自上一次该类型日志打印到这次日志打印经历了多长时间的后缀。
debug 模块支持以下用法:
- DEBUG=*:打印所有类型的日志。
- DEBUG=log:只打印 log 类型的日志。
- DEBUG=error:*:打印所有以 error: 开头的日志。
- DEBUG=error:*,-error:low:打印所有以 error: 开头的并且过滤掉 error:low 类型的日志。
下面演示一下第 4 种的用法,运行:
$ DEBUG=error:*,-error:low node app.js
打印如下:
repl2
我们在写代码时,有时可能记不清某个模块的某个方法的具体用法,比如:用 moment 格式化年份是用 moment().format('YYYY')
还是用 moment().format('yyyy')
还是两种写法都可以?lodash 的 _.pick
方法能否能接收数组作为参数?这个时候相对于翻阅官方文档,在 REPL 里试一下可能会更快,通常步骤是:
$ npm i moment
$ node
> const moment = require('moment')
> moment().format('YYYY')
'2017'
> moment().format('yyyy')
'yyyy'
一次还好,次数多了也略微烦琐。repl2 模块便是为了解决这个问题而生的。
repl2 顾名思义是 REPL 的增强版,repl2 会根据一个用户配置(~/.noderc),预先加载模块到 REPL 中,省下了我们手动在 REPL 中 require 模块的过程。
全局安装 repl2:
$ npm i repl2 -g
使用方式很简单:
- 将常用的模块全局安装,例如:
$ npm i lodash validator moment -g
- 添加配置到 ~/.noderc:
{
"lodash": "__",
"moment": "moment",
"validator": "validator"
}
- 运行 noder:
$ noder
__ = lodash@4.17.4 -> local
moment = moment@2.18.1 -> global
validator = validator@7.0.0 -> global
> moment().format('YYYY')
'2017'
> __.random(0, 5)
3
> validator.isEmail('foo@bar.com')
true
需要讲解以下几点:
- ~/.noderc 是一个 JSON 文件,key 是模块的名字,value 是 require 这个模块后加载到 REPL 中的变量名。这里给 lodash 命名的变量名是 _ 而不是 ,是因为 REPL 中 _ 有特殊含义,表示上一个表达式的结果。
- repl2 会优先加载当前目录下的模块,没有找到然后再去加载全局安装的模块。上面结果显示 lodash 是从本地目录加载的,因为 test 目录下已经安装了 lodash,其余的模块没有从本地目录找到则尝试从全局 npm 目录加载。如果都没有找到,则不会加载。
power-assert
我们常用的断言库有:
但这类断言库都有一些通病:
- 过分追求语义化,API 复杂。
- 错误信息不足。
先看一段代码:
const assert = require('assert')
const should = require('should')
const expect = require('expect.js')
const tom = { id: 1, age: 18 }
const bob = { id: 2, age: 20 }
describe('app.js', () => {
it('assert', () => {
assert(tom.age > bob.age)
})
it('should.js', () => {
tom.age.should.be.above(bob.age)
})
it('expect.js', () => {
expect(tom.age).be.above(bob.age)
})
})
运行:
$ mocha
结果如下:
app.js
1) assert
2) should.js
3) expect.js
0 passing (13ms)
3 failing
1) app.js
assert:
AssertionError [ERR_ASSERTION]: false == true
+ expected - actual
-false
+true
at Context.it (test.js:10:5)
2) app.js
should.js:
AssertionError: expected 18 to be above 20
at Assertion.fail (node_modules/should/cjs/should.js:275:17)
at Assertion.value (node_modules/should/cjs/should.js:356:19)
at Context.it (test.js:13:23)
3) app.js
expect.js:
Error: expected 18 to be above 20
at Assertion.assert (node_modules/expect.js/index.js:96:13)
at Assertion.greaterThan.Assertion.above (node_modules/expect.js/index.js:297:10)
at Function.above (node_modules/expect.js/index.js:499:17)
at Context.it (test.js:16:24)
可以看出,基本没有有用的信息。这时,power-assert 粉墨登场。
power-assert 使用起来很简单,理论上只用一个 assert 就可以了,而且可以无缝迁移。
注意:在使用 intelli-espower-loader 时,要求必须将测试文件放到 test/ 目录下,所以我们在 test 目录下创建 test/app.js,将原来的 test.js 代码粘贴过去。
安装 power-assert 和 intelli-espower-loader,然后运行测试:
$ npm i power-assert intelli-espower-loader --save-dev
$ mocha -r intelli-espower-loader
结果如下:
app.js
1) assert
2) should.js
3) expect.js
0 passing (42ms)
3 failing
1) app.js
assert:
AssertionError [ERR_ASSERTION]: # test/app.js:10
assert(tom.age > bob.age)
| | | | |
| | | | 20
| | | Object{id:2,age:20}
| 18 false
Object{id:1,age:18}
+ expected - actual
-false
+true
...
错误信息非常直观,有以下两点需要说明:
- mocha 需要引入 intelli-espower-loader,主要是转译代码,转译之后
require('assert')
都不需要改。
- intelli-espower-loader 可选择地在 package.json 中添加 directories.test 配置,例如:
"directories": {
"test": "mytest/"
}
如果没有 directories.test 配置,则默认是 test/
。
# Android 调试
USB 有线调试
无线调试