diff --git a/1.1 perf + FlameGraph.md b/1.1 perf + FlameGraph.md new file mode 100644 index 0000000..778b6ca --- /dev/null +++ b/1.1 perf + FlameGraph.md @@ -0,0 +1,253 @@ +[火焰图](http://www.brendangregg.com/flamegraphs.html)(Flame Graph)想必大家都听过,它可以将 CPU 的使用情况可视化展示,能够直观地帮助我们了解到程序的性能瓶颈。通常要结合操作系统的性能分析工具(profiling tracer)使用,常见的有: + +- Linux:perf, eBPF, SystemTap, and ktap +- Solaris, illumos, FreeBSD:DTrace +- Mac OS X:DTrace and Instruments +- Windows:Xperf.exe + +## perf + +[perf](http://www.brendangregg.com/linuxperf.html)(也称 perf_events)是 Linux kernal 自带的系统性能分析工具,能够进行函数级与指令级的热点查找。它基于事件采样原理,以性能事件为基础,支持针对处理器相关性能指标与操作系统相关性能指标的性能剖析,常用于性能瓶颈的查找与热点代码的定位。 + +测试机器: + +```sh +$ uname -a +Linux nswbmw-VirtualBox 4.10.0-28-generic #32~16.04.2-Ubuntu SMP Thu Jul 20 10:19:48 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux +``` + +**注意:**非 Linux 用户可以用虚拟机安装 Ubuntu 16.04 和 node@8.9.4 后进行后面的操作。 + +安装 perf: + +```sh +$ sudo apt install linux-tools-common +$ perf # 根据提示安装对应内核版本的 tools, 如下 +$ sudo apt install linux-tools-4.10.0-28-generic linux-cloud-tools-4.10.0-28-generic +``` + +创建测试目录 ~/test 和测试代码: + +**app.js** + +```js +const crypto = require('crypto') +const Paloma = require('paloma') +const app = new Paloma() +const users = {} + +app.route({ method: 'GET', path: '/newUser', controller (ctx) { + const username = ctx.query.username || 'test' + const password = ctx.query.password || 'test' + + const salt = crypto.randomBytes(128).toString('base64') + const hash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex') + + users[username] = { salt, hash } + + ctx.status = 204 +}}) + +app.route({ method: 'GET', path: '/auth', controller (ctx) { + const username = ctx.query.username || 'test' + const password = ctx.query.password || 'test' + + if (!users[username]) { + ctx.throw(400) + } + const hash = crypto.pbkdf2Sync(password, users[username].salt, 10000, 64, 'sha512').toString('hex') + + if (users[username].hash === hash) { + ctx.status = 204 + } else { + ctx.throw(403) + } +}}) + +app.listen(3000) +``` + +添加 --perf_basic_prof(或者--perf-basic-prof)参数运行此程序,对应会生成一个 /tmp/perf-.map 的文件。如下: + +```sh +$ node --perf_basic_prof app.js & +[1] 3590 +$ tail /tmp/perf-3590.map +51b87a7b93e 18 Function:~emitListeningNT net.js:1375 +51b87a7b93e 18 LazyCompile:~emitListeningNT net.js:1375 +51b87a7bad6 39 Function:~emitAfterScript async_hooks.js:443 +51b87a7bad6 39 LazyCompile:~emitAfterScript async_hooks.js:443 +51b87a7bcbe 77 Function:~tickDone internal/process/next_tick.js:88 +51b87a7bcbe 77 LazyCompile:~tickDone internal/process/next_tick.js:88 +51b87a7bf36 12 Function:~clear internal/process/next_tick.js:42 +51b87a7bf36 12 LazyCompile:~clear internal/process/next_tick.js:42 +51b87a7c126 b8 Function:~emitPendingUnhandledRejections internal/process/promises.js:86 +51b87a7c126 b8 LazyCompile:~emitPendingUnhandledRejections internal/process/promises.js:86 +``` + +**三列依次为**:16进制符号地址(symbol addresses)、大小(sizes)和符号名(symbol names)。perf 会尝试查找 /tmp/perf-.map 文件,用来做符号转换,即把 16 进制符号地址转换成人能读懂的符号名。 + +**注意**:--perf_basic_prof_only_functions 参数也可以,但经尝试后发现生成的火焰图信息不全(不全的地方显示 [perf-.map]),所以本文使用 —perf_basic_prof,但使用 --perf_basic_prof 有个缺点是会导致 map 文件一直增大,原因是符号(symbols)地址不断变换导致的,用 --perf_basic_prof_only_functions 可以缓解这个问题,如何取舍还请读者自行尝试。 + +接下来 clone 用来生成火焰图的工具: + +```sh +$ git clone http://github.com/brendangregg/FlameGraph ~/FlameGraph +``` + +我们先用 ab 压测: + +```sh +$ curl "http://localhost:3000/newUser?username=admin&password=123456" +$ ab -k -c 10 -n 2000 "http://localhost:3000/auth?username=admin&password=123456" +``` + +新开另一个窗口,在 ab 压测之后立即运行: + +```sh +$ sudo perf record -F 99 -p 3590 -g -- sleep 30 +$ sudo chown root /tmp/perf-3590.map +$ sudo perf script > perf.out +$ ~/FlameGraph/stackcollapse-perf.pl --kernel < ~/perf.out | ~/FlameGraph/flamegraph.pl --color=js --hash> ~/flamegraph.svg +``` + +**注意**:第一次生成的 svg 可能不太准确,以上步骤最好重复几次,使用第二次及以后生成的 flamegraph.svg。 + +有几点需要解释一下: + +- perf record + - -F 指定了采样频率 99Hz(即每秒 99 次,如果 99 次都返回同一个函数名,那就说明 CPU 这一秒钟都在执行同一个函数,可能存在性能问题) + - -p 指定进程的 pid + - -g 启用 call-graph 记录 + - -- sleep 30 指定记录 30s + +- sudo chown root /tmp/perf-3009.map + + - 将 map 文件更改为 root 权限,否则会报这个错: + > File /tmp/perf-PID.map not owned by current user or root, ignoring it (use -f to override). + > Failed to open /tmp/perf-PID.map, continuing without symbols + +- perf record 会将记录的信息保存到当前执行目录的 perf.data 文件里,用 perf script 将信息 dump 到 perf.out + +- --color=js 指定生成针对 js 配色的 svg,即:green == JavaScript, blue == Builtin, yellow == C++, red == system (native user-level, and kernel) + +ab 压测用了 30s 左右,浏览器打开 flamegraph.svg,截取关键部分如下图所示: +![](./assets/1.1.1.png) + +## 理解火焰图 + +火焰图含义: + +- 每一个小块代表了一个函数在栈中的位置(即一个栈帧) +- Y 轴代表了栈的深度(栈上的帧数),顶端的小块显示了占据 CPU 的函数。每个小块下面是它的祖先(即父函数) +- X 轴表示总的样例群体。它不像绝大多数图表那样从左到右表示时间的流逝,它们的左右顺序没有特殊含义,仅仅是按照字母表顺序排列 +- 小块的宽度表示 CPU 使用时间或者说相对父函数而言使用 CPU 的比率(基于所有样例),越宽代表占用 CPU 的时间越长,或者使用 CPU 很频繁 +- 如果采取多线程并发运行取样,取样数量会超过运行时间 + +**从上图可以看出**:最上面的绿色小块(即 js 代码)指向 test/app.js:18 行,即 `GET /auth` 这个路由。再往上看,黄色的小块(即 C++ 代码) node::crypto::PBKDF2 占用了大量的 CPU 时间。 + +**解决方法**:将同步改为异步,即将 crypto.pbkdf2Sync 改为 crypto.pbkdf2,如下修改: + +```js +app.route({ method: 'GET', path: '/auth', async controller (ctx) { + const username = ctx.query.username || 'test' + const password = ctx.query.password || 'test' + + if (!users[username]) { + ctx.throw(400) + } + const hash = await new Promise((resolve, reject) => { + crypto.pbkdf2(password, users[username].salt, 10000, 64, 'sha512', (err, derivedKey) => { + if (err) { + return reject(err) + } + resolve(derivedKey.toString('hex')) + }) + }) + + if (users[username].hash === hash) { + ctx.status = 204 + } else { + ctx.throw(403) + } +}}) +``` + +用 ab 重新压测,结果用了 16s。重新生成的火焰图如下: + +![](./assets/1.1.2.png) + +**可以看出**:只有左侧极窄的小块可以看到 js 的代码,红色的部分我们不关心也无法优化。那为什么异步比同步的 rps 要高呢?原因是 Node.js 底层的 libuv 用了多个线程做计算任务,这里就不再深入介绍。 + +svg 火焰图其他小技巧: + +1. 点击任意一个小块可以展开,即被点击的小块宽度变宽,它的子函数也按比例变宽,方便查看。 +2. 可点击 svg 右上角的 search 按钮搜索,被搜索的关键词会高亮显示,在有目的查找某个函数时比较有用。 + +## 红蓝差分火焰图 + +虽然我们有了火焰图,但要处理性能回退问题,就要在修改代码前后的火焰图之间,不断切换对比,来找出问题所在。于是 [Brendan D. Gregg](http://www.brendangregg.com/index.html) 又发明了红蓝差分火焰图(red/blue differential flame graphs)。 +![](./assets/1.1.3.jpg) + +**如上所示**:红色表示增长,蓝色表示衰减。 + +红蓝差分火焰图工作原理: + +1. 抓取修改前的栈 profile1 文件 +2. 抓取修改后的栈 profile2 文件 +3. 使用 profile2 来生成火焰图(这样栈帧的宽度就是以 profile2 文件为基准的) +4. 使用 2 - 1 的差异来对火焰图重新上色。上色的原则是,如果栈帧在 profile2 中出现出现的次数更多,则标为红色,否则标为蓝色。色彩是根据修改前后的差异来填充的。 + +这样,通过红蓝差分火焰图,我们可以清楚地看到系统性能差异之处。 + +生成红蓝差分火焰图: + +1. 修改代码前: + + ```sh + $ sudo perf record -F 99 -p -g -- sleep 30 + $ sudo chown root /tmp/perf-.map + $ sudo perf script > perf.out1 + ``` + +2. 修改代码后: + + ```sh + $ sudo perf record -F 99 -p -g -- sleep 30 + $ sudo chown root /tmp/perf-.map + $ sudo perf script > perf.out2 + ``` + +3. 将 profile 文件进行折叠(fold), 再生成差分火焰图: + + ```sh + $ ~/FlameGraph/stackcollapse-perf.pl ~/perf.out1 > perf.folded1 + $ ~/FlameGraph/stackcollapse-perf.pl ~/perf.out2 > perf.folded2 + $ ./FlameGraph/difffolded.pl perf.folded1 perf.folded2 | ./FlameGraph/flamegraph.pl > flamegraph_diff.svg + ``` + +**缺点**:如果一个代码执行路径完全消失了,那么在火焰图中就找不到地方来标注蓝色。你只能看到当前的 CPU 使用情况,而不知道为什么会变成这样。 + +一个解决办法是,生成一个相反的差分火焰图,即基于 profile1 生成 1 - 2 的差分火焰图。对应命令如下: + +```sh +$ ./FlameGraph/difffolded.pl perf.folded2 perf.folded1 | ./FlameGraph/flamegraph.pl --negate > flamegraph_diff2.svg +``` + +--negate 用于颠倒红/蓝配色。最终我们得到: + +- flamegraph_diff.svg:宽度是以修改前 profile 文件为基准,颜色表明将要发生的情况 +- flamegraph_diff2.svg:宽度是以修改后 profile 文件为基准,颜色表明已经发生的情况 + +**总结**:红蓝差分火焰图可能只在代码变化不大的情况下使用效果明显,代码变化多了效果可能并不明显。 + +## 参考链接 + +- https://yunong.io/2015/11/23/generating-node-js-flame-graphs/ +- http://www.brendangregg.com/perf.html +- http://www.brendangregg.com/blog/2014-09-17/node-flame-graphs-on-linux.html +- https://linux.cn/article-4670-1.html +- http://www.brendangregg.com/blog/2014-11-09/differential-flame-graphs.html +- http://www.ruanyifeng.com/blog/2017/09/flame-graph.html + +下一节:[1.2 v8-profiler](https://github.com/nswbmw/node-in-debugging/blob/master/1.2%20v8-profiler.md) diff --git a/1.2 v8-profiler.md b/1.2 v8-profiler.md new file mode 100644 index 0000000..6322dda --- /dev/null +++ b/1.2 v8-profiler.md @@ -0,0 +1,161 @@ +我们知道 Node.js 是基于 v8 引擎的,v8 暴露了一些 profiler api,所以我们可以通过 [v8-profiler](https://github.com/node-inspector/v8-profiler) 收集一些运行时数据(如:CPU 和内存)。本文将介绍如何使用 v8-profiler 分析 CPU 的使用情况。 + +## v8-profiler + +创建测试代码: + +**app.js** + +```js +const fs = require('fs') +const crypto = require('crypto') +const Bluebird = require('bluebird') +const profiler = require('v8-profiler') +const Paloma = require('paloma') +const app = new Paloma() + +app.route({ method: 'GET', path: '/encrypt', controller: function encryptRouter (ctx) { + const password = ctx.query.password || 'test' + const salt = crypto.randomBytes(128).toString('base64') + const encryptedPassword = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex') + + ctx.body = encryptedPassword +}}) + +app.route({ method: 'GET', path: '/cpuprofile', async controller (ctx) { + //Start Profiling + profiler.startProfiling('CPU profile') + await Bluebird.delay(30000) + //Stop Profiling after 30s + const profile = profiler.stopProfiling() + profile.export() + .pipe(fs.createWriteStream(`cpuprofile-${Date.now()}.cpuprofile`)) + .on('finish', () => profile.delete()) + ctx.status = 204 +}}) + +app.listen(3000) +``` + +`GET /encrypt` 有一个 CPU 密集型计算函数 crypto.pbkdf2Sync,`GET /cpuprofile` 用来收集 30s 的 v8 log 然后 dump 到一个文件。 + +运行该程序,然后起两个终端窗口。一个运行: + +```sh +$ curl localhost:3000/cpuprofile +``` + +触发 CPU profiling,接着另一个立即运行: + +```sh +$ ab -c 20 -n 2000 "http://localhost:3000/encrypt?password=123456" +``` + +触发 CPU 密集计算。 + +最后生成 cpuprofile-xxx.cpuprofile 文件,文件内容其实就是一个大的 JSON 对象,大体如下: + +```json +{ + "typeId": "CPU", + "uid": "1", + "title": "CPU profile", + "head": + { "functionName": "(root)", + "url": "", + "lineNumber": 0, + "callUID": 154, + "bailoutReason": "", + "id": 1, + "scriptId": 0, + "hitCount": 0, + "children": [ ... ] }, + "startTime": 276245, + "endTime": 276306, + "samples": [ ... ], + "timestamps": [ ... ] +} +``` + +这个 JSON 对象记录了函数调用栈、路径、时间戳和其他一些信息,samples 节点数组和 timestamps 节点数组中的时间戳是一一对应的,并且 samples 节点数组中的每一个值,其实对应了 head 节点的深度优先遍历 ID。这里我们不深究每个字段的含义,下面我们来看如何可视化这些数据。 + +## 1. Chrome DevTools + +Chrome 自带了分析 CPU profile 日志的工具。打开 Chrome -> 调出开发者工具(DevTools) -> 点击右上角三个点的按钮 -> More tools -> JavaScript Profiler -> Load,加载刚才生成的 cpuprofile 文件。左上角下拉菜单可选三种模式: + +1. Chart:显示按时间顺序排列的火焰图 +2. Heavy (Bottom Up):按照函数对性能的影响排列,同时可以检查函数的调用路径 +3. Tree (Top Down):显示调用结构的总体状况,从调用堆栈的顶端开始 + +这里我们选 Tree (Top Down) 模式,按 Total Time 降序排列。可以看到有三列,分别为: + +1. Self Time:函数调用所花的时间,仅包含函数本身的声明,不包含任何子函数执行时间 +2. Total Time:函数调用所花的总时间,包含函数本身的声明及所有子函数执行时间 +3. Function:函数名及路径,可展开查看子函数 + +一句话解释:**父函数的 Total Time = 父函数的 Self Time + 所有子函数的 Total Time。** + +我们不断地展开,并定位到了 encryptRouter,如下图所示: + +![](./assets/1.2.1.png) + +**可以看出**:我们定位到了 encryptRouter 这个路由,并且这个路由中 exports.pbkdf2Sync 占据了绝大部分 CPU 时间。 + +## 2. 火焰图 + +我们也可以用火焰图来展示 cpuprofile 数据。首先全局安装 flamegraph 这个模块: + +```sh +$ npm i flamegraph -g +``` + +运行以下命令将 cpuprofile 文件生成 svg 文件: + +```sh +$ flamegraph -t cpuprofile -f cpuprofile-xxx.cpuprofile -o cpuprofile.svg +``` + +浏览器打开 cpuprofile.svg,如下所示: + +![](./assets/1.2.2.png) + +**可以看出**:我们定位到了 app.js 第 8 行,即 encryptRouter 这个路由,并且这个路由中 exports.pbkdf2Sync 占据了绝大部分 CPU 时间。 + +## 3. v8-analytics + +[v8-analytics](https://github.com/hyj1991/v8-analytics) 是社区开源的一个解析 v8-profiler 和 heapdump 等模块生成的 CPU & heap-memory 日志的工具。提供以下功能: + +- V8 引擎逆优化或者优化失败的函数标红展示以及优化失败原因展示 +- 函数执行时长超过预期标红展示 +- 当前项目中可疑的内存泄漏点展示 + +我们以上述第二条功能为例,使用 v8-analytics 分析 CPU 使用情况。 + +首先,全局安装 v8-analytics: + +```sh +$ npm i v8-analytics -g +``` + +使用以下命令只查看执行时间大于 200ms 的函数: + +```sh +$ va timeout cpuprofile-xxx.cpuprofile 200 --only +``` + +截图如下: + +![](./assets/1.2.3.png) + +**可以看出**:我们依然能够定位到 encryptRouter 和 exports.pbkdf2Sync。 + +## 参考链接 + +- https://developers.google.com/web/tools/chrome-devtools/rendering-tools/js-execution +- http://www.ebaytechblog.com/2016/06/15/igniting-node-js-flames/ +- https://cnodejs.org/topic/58b562f97872ea0864fee1a7 +- https://github.com/hyj1991/v8-analytics/blob/master/README_ZH.md + +上一节:[1.1 perf + FlameGraph](https://github.com/nswbmw/node-in-debugging/blob/master/1.1%20perf%20%2B%20FlameGraph.md) + +下一节:[2.1 gcore + llnode](https://github.com/nswbmw/node-in-debugging/blob/master/2.1%20gcore%20%2B%20llnode.md) diff --git a/2.1 gcore + llnode.md b/2.1 gcore + llnode.md new file mode 100644 index 0000000..cdec0ca --- /dev/null +++ b/2.1 gcore + llnode.md @@ -0,0 +1,283 @@ +在开始之前,我们先了解下什么是 Core 和 Core Dump。 + +**测试环境** + +```sh +$ uname -a +Linux nswbmw-VirtualBox 4.13.0-36-generic #40~16.04.1-Ubuntu SMP Fri Feb 16 23:25:58 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux +``` + +**什么是 Core?** + +> 在使用半导体作为内存材料前,人类是利用线圈当作内存的材料,线圈就叫作 core ,用线圈做的内存就叫作 core memory。如今 ,半导体工业澎勃发展,已经没有人用 core memory 了,不过在许多情况下, 人们还是把记忆体叫作 core 。 + +**什么是 Core Dump?** + +> 当程序运行的过程中异常终止或崩溃,操作系统会将程序当时的内存状态记录下来,保存在一个文件中,这种行为就叫做 Core Dump(中文有的翻译成“核心转储”)。我们可以认为 Core Dump 是“内存快照”,但实际上,除了内存信息之外,还有些关键的程序运行状态也会同时 dump 下来,例如寄存器信息(包括程序指针、栈指针等)、内存管理信息、其他处理器和操作系统状态和信息。Core Dump 对于编程人员诊断和调试程序是非常有帮助的,因为对于有些程序错误是很难重现的,例如指针异常,而 Core Dump 文件可以再现程序出错时的情景。 + +**开启 Core Dump** + +在终端中输入: + +```sh +$ ulimit -c +``` + +查看允许 Core Dump 生成的文件的大小,如果是 0 则表示关闭了 Core Dump。使用以下命令开启 Core Dump 功能,并且不限制 Core Dump 生成的文件大小: + +```sh +$ ulimit -c unlimited +``` + +以上命令只针对当前终端环境有效,如果想永久生效,可以修改 /etc/security/limits.conf 文件,如下: + +![](./assets/2.1.1.jpg) + +## [gcore](http://man7.org/linux/man-pages/man1/gcore.1.html) + +我们可以使用 gcore 不重启程序而 dump 出特定进程的 core 文件。gcore 使用方法: + +```sh +$ gcore [-o filename] pid +``` + +Core Dump 时,默认会在执行 gcore 命令的目录生成 core. 的文件。 + +## llnode + +什么是 llnode? + +> Node.js v4.x+ C++ plugin for [LLDB](http://lldb.llvm.org/) - a next generation, high-performance debugger. + +什么是 LLDB? + +> LLDB is a next generation, high-performance debugger. It is built as a set of reusable components which highly leverage existing libraries in the larger LLVM Project, such as the Clang expression parser and LLVM disassembler. + +安装 llnode + lldb: + +```sh +$ sudo apt-get update + +# Clone llnode +$ git clone https://github.com/nodejs/llnode.git ~/llnode && cd ~/llnode + +# Install lldb and headers +$ sudo apt-get install lldb-4.0 liblldb-4.0-dev + +# Initialize GYP +$ git clone https://github.com/bnoordhuis/gyp.git tools/gyp + +# Configure +$ ./gyp_llnode -Dlldb_dir=/usr/lib/llvm-4.0/ + +# Build +$ make -C out/ -j9 + +# Install +$ sudo make install-linux +``` + +**注意:**如果 `sudo apt-get update` 遇到这种错误: + +``` +W: GPG error: xxx stable Release: The following signatures couldn't be verified because the public key is not available: NO_PUBKEY 6DA62DE462C7DA6D +``` + +用以下命令解决: + +```sh +$ sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 6DA62DE462C7DA6D +``` + +--recv-keys 后面跟的是前面报错提示的 PUBKEY。 + +## 测试 Core Dump + +下面用一个典型的全局变量缓存导致的内存泄漏的例子来测试 llnode 的用法。代码如下: + +**app.js** + +```js +const leaks = [] + +function LeakingClass() { + this.name = Math.random().toString(36) + this.age = Math.floor(Math.random() * 100) +} + +setInterval(() => { + for (let i = 0; i < 100; i++) { + leaks.push(new LeakingClass) + } + + console.warn('Leaks: %d', leaks.length) +}, 1000) +``` + +一个窗口运行该程序: + +```sh +$ node app.js +``` + +等待几秒,打开另一个窗口运行 gcore: + +```sh +$ ulimit -c unlimited +$ sudo gcore `pgrep -n node` +``` + +生成 core.2763 文件。 + +## 分析 Core 文件 + +使用 lldb 加载刚才生成的 Core 文件: + +```sh +$ lldb-4.0 -c ./core.2763 +(lldb) target create --core "./core.2763" +Core file '/home/nswbmw/test/./core.2763' (x86_64) was loaded. +(lldb) +``` + +输入 v8 查看使用文档,有以下几条命令: + +- bt +- findjsinstances +- findjsobjects +- findrefs +- inspect +- nodeinfo +- print +- source + +运行 `v8 findjsobjects` 查看所有对象实例及总共占内存大小: + +``` +(lldb) v8 findjsobjects + Instances Total Size Name + ---------- ---------- ---- + ... + 2100 84000 LeakingClass + 8834 39792 (String) + ---------- ---------- + 12088 181320 +``` + +**可以看出**:LeakingClass 有 2100 个实例,占内存 84000 bit。使用 `v8 findjsinstances` 查看所有 LeakingClass 实例: + +``` +(lldb) v8 findjsinstances LeakingClass +0x000022aaa118ab19: +0x000022aaa118acf9: +0x000022aaa118ade1: +... +``` + +使用 `v8 i` 检索实例具体内容: + +``` +(lldb) v8 i 0x000022aaa118ab19 +0x000022aaa118ab19:, + .age=}> +(lldb) v8 i 0x000022aaa118acf9 +0x000022aaa118acf9:, + .age=}> +(lldb) v8 i 0x000022aaa118ade1 +0x000022aaa118ade1:, + .age=}> +``` + +可以看到每个 LeakingClass 实例的 name 和 age 字段的值。 + +使用 `v8 findrefs` 查看引用: + +``` +(lldb) v8 findrefs 0x000022aaa118ab19 +0x22aaa1189729: (Array)[0]=0x22aaa118ab19 +(lldb) v8 i 0x22aaa1189729 +0x000022aaa1189729:, + [1]=0x000022aaa118acf9:, + [2]=0x000022aaa118ade1:, + [3]=0x000022aaa118aea1:, + [4]=0x000022aaa118af61:, + [5]=0x000022aaa118b021:, + [6]=0x000022aaa118b0e1:, + [7]=0x000022aaa118b1a1:, + [8]=0x000022aaa118b221:, + [9]=0x000022aaa118b2a1:, + [10]=0x000022aaa118b321:, + [11]=0x000022aaa118b3a1:, + [12]=0x000022aaa118b421:, + [13]=0x000022aaa118b4a1:, + [14]=0x000022aaa118b521:, + [15]=0x000022aaa118b5a1:}> +``` + +**可以看出**:通过一个 LeakingClass 实例的内存地址,我们使用 `v8 findrefs` 找到了引用它的数组的内存地址,然后通过这个地址去检索数组,得到这个数组长度 2100,每一项都是一个 LeakingClass 实例,这不就是我们代码中的 leaks 数组嘛。 + +**Tips**: `v8 i` 是 `v8 inspect` 的缩写,`v8 p` 是 `v8 print` 的缩写。 + +## --abort-on-uncaught-exception + +Node.js 中通过添加 --abort-on-uncaught-exception 参数启动,当程序 crash 的时候,会自动 Core Dump,方便『死后验尸』。 + +添加 --abort-on-uncaught-exception 参数启动测试程序: + +```sh +$ ulimit -c unlimited +$ node --abort-on-uncaught-exception app.js +``` + +启动另外一个终端运行: + +```sh +$ kill -BUS `pgrep -n node` +``` + +第一个终端会显示: + +```sh +Leaks: 100 +Leaks: 200 +Leaks: 300 +Leaks: 400 +Leaks: 500 +Leaks: 600 +Leaks: 700 +Leaks: 800 +Bus error (core dumped) +``` + +调试步骤与上面一致: + +```sh +$ lldb-4.0 -c ./core +(lldb) target create --core "./core" +Core file '/home/nswbmw/test/./core' (x86_64) was loaded. +(lldb) v8 findjsobjects + Instances Total Size Name + ---------- ---------- ---- + ... + 800 32000 LeakingClass + 7519 38512 (String) + ---------- ---------- + 9440 126368 +``` + +## 总结 + +我们的测试代码很简单,没有引用任何第三方模块,如果项目较大且引用的模块较多,`v8 findjsobjects` 的结果将难以甄别,这个时候可以多次使用 gcore 进行 Core Dump,对比发现增长的对象,再进行诊断。 + +## 参考链接 + +- http://www.cnblogs.com/Anker/p/6079580.html +- http://www.brendangregg.com/blog/2016-07-13/llnode-nodejs-memory-leak-analysis.html + +上一节:[1.2 v8-profiler](https://github.com/nswbmw/node-in-debugging/blob/master/1.2%20v8-profiler.md) + +下一节:[2.2 heapdump](https://github.com/nswbmw/node-in-debugging/blob/master/2.2%20heapdump.md) diff --git a/2.2 heapdump.md b/2.2 heapdump.md new file mode 100644 index 0000000..676e4d0 --- /dev/null +++ b/2.2 heapdump.md @@ -0,0 +1,108 @@ +[heapdump](https://github.com/bnoordhuis/node-heapdump) 是一个 dump V8 堆信息的工具。[v8-profiler](https://github.com/node-inspector/v8-profiler) 也包含了此功能,这两个工具的原理都是一致的,都是 v8::Isolate::GetCurrent()->GetHeapProfiler()->TakeHeapSnapshot(title, control)。heapdump 的使用简单些,下面我们以 heapdump 为例讲解如何分析 Node.js 内存泄漏。 + +我们用一段经典的内存泄漏代码当作我们的测试代码,代码如下: + +**app.js** + +```js +const heapdump = require('heapdump') +let leakObject = null +let count = 0 + +setInterval(function testMemoryLeak() { + const originLeakObject = leakObject + const unused = function () { + if (originLeakObject) { + console.log('originLeakObject') + } + } + leakObject = { + count: String(count++), + leakStr: new Array(1e7).join('*'), + leakMethod: function () { + console.log('leakMessage') + } + } +}, 1000) +``` + +为什么这段程序会发生内存泄漏呢?首先我们要明白闭包的原理: + +**同一个函数内部的闭包作用域只有一个,所有闭包共享。在执行函数的时候,如果遇到闭包,会创建闭包作用域内存空间,将该闭包所用到的局部变量添加进去,然后再遇到闭包,会在之前创建好的作用域空间添加此闭包会用到而前闭包没用到的变量。函数结束,清除没有被闭包作用域引用的变量。** + +**这段代码内存泄露原因是**:testMemoryLeak 函数内有两个闭包:unused 和 leakMethod。unused 这个闭包引用了父作用域中的 originLeakObject 变量,如果没有后面的 leakMethod,则会在函数结束后清除,闭包作用域也跟着清除了。因为后面的 leakObject 是全局变量,即 leakMethod 是全局变量,它引用的闭包作用域(包含了 unused 所引用的 originLeakObject)不会释放。而随着 testMemoryLeak 不断调用,originLeakObject 指向前一次的 leakObject,下次的 leakObject.leakMethod 又会引用之前的 originLeakObject,从而形成一个闭包引用链,而 leakStr 是一个大字符串,得不到释放,从而造成内存泄漏。 + +**解决方法**:在 testMemoryLeak 函数内部最后添加 `originLeakObject = null` 即可。 + +运行测试代码: + +```sh +$ node app +``` + +然后先后执行**两**次: + +```sh +$ kill -USR2 `pgrep -n node` +``` + +目录下产生了两个 heapsnapshot 文件: + +``` +heapdump-100427359.61348.heapsnapshot +heapdump-100438986.797085.heapsnapshot +``` + +## Chrome DevTools + +我们使用 Chrome DevTools 来分析前面生成的 heapsnapshot 文件。调出 Chrome DevTools -> Memory -> Load,按顺序依次加载前面生成的 heapsnapshot 文件。点击第二个堆快照,左上角有个下拉菜单,有四个选项: + +1. Summary:以构造函数名分类显示 +2. Comparison:比较多个快照之间的差异 +3. Containment:查看整个 GC 路径 +4. Statistics:以饼状图显示内存占用信息 + +通常我们只会用前两个,第三个一般用不到,因为展开在 Summary 和 Comparison 中的每一项,都可以看到从 GC roots 到这个对象的路径。第四个选项只能看到内存占用比,如下图所示: + +![](./assets/2.2.1.png) + +切换到 Summary 页,可以看到有 5 个属性: + +1. Contructor:构造函数名,如 Object、Module、Socket,(array)、(string)、(regexp) 等加了括号的分别代表内置的 Array、String、Regexp +2. Distance:到 GC roots (GC 根对象)的距离。GC 根对象在浏览器中一般是 window 对象,在 Node.js 中是 global 对象。距离越大,说明引用越深,则有必要重点关注一下,极有可能是内存泄漏的对象 +3. Objects count:对象个数 +4. Shallow Size:对象自身大小,不包括它引用的对象 +5. Retained Size:对象自身大小和它引用对象的大小,即该对象被 GC 之后所能回收的内存大小 + +**Tips**: 一个对象的 Retained Size = 该对象的 Shallow Size + 该对象可直接或间接引用到的对象的 Shallow Size 之和。Shallow Size == Retained Size 的有 (boolean)、(number)、(string),它们无法引用其他值,并且始终是叶子节点。 + +我们点击 Retained Size 选择降序展示,可以看到 (closure) 这一项引用的内容达到 98%,继续展开如下: + +![](./assets/2.2.2.png) + +**可以看出**:一个 leakStr 占了 8% 的内存,而 leakMethod 引用了 81% 的内存。对象保留树(Retainers,老版本 Chrome 叫 Object's retaining tree)展示了对象的 GC path,如上图点击 leakStr(Distance 是 13),Retainers 会自动展开,Distance 从 13 递减到 1。 + +我们继续展开 leakMethod,如下所示: + +![](./assets/2.2.3.png) + +**可以看出**:有一个 count="10" 的 originLeakObject 的 leakMethod 函数的 context(即上下文) 引用了一个 count="9" 的 originLeakObject 对象,而这个 originLeakObject 对象的 leakMethod 函数的 context 又引用了 count="8" 的 originLeakObject 对象,以此类推。而每个 originLeakObject 对象上都有一个大字符串 leakStr(占用 8% 内存),从而造成内存泄漏,符合我们之前的推断。 + +**Tips:**背景色是黄色的,表示这个对象在 JavaScript 中还存在引用,所以可能没有被清除。如果是红色的,表示的是这个对象在 JavaScript 中不存在引用,但是依然存活在内存中,一般常见于 DOM 对象,它们存放的位置和 JavaScript 中对象还是有不同的,在 Node.js 中很少遇见。 + +## 对比快照 + +切换到 Comparison 视图下,可以看到一些 #New、#Deleted、#Delta 等属性,+ 和 - 代表相对于比较的堆快照而言。我们对比第二个快照和第一个快照,从下图可以看出,(string) 增加的最多: + +![](./assets/2.2.4.png) + +## 参考链接 + +- https://blog.meteor.com/an-interesting-kind-of-javascript-memory-leak-8b47d2e7f156 +- https://www.zhihu.com/question/56806069 +- http://taobaofed.org/blog/2016/04/15/how-to-find-memory-leak/ +- https://developers.google.com/web/tools/chrome-devtools/memory-problems/memory-101 + +上一节:[2.1 gcore + llnode](https://github.com/nswbmw/node-in-debugging/blob/master/2.1%20gcore%20%2B%20llnode.md) + +下一节:[2.3 memwatch-next](https://github.com/nswbmw/node-in-debugging/blob/master/2.3%20memwatch-next.md) diff --git a/2.3 memwatch-next.md b/2.3 memwatch-next.md new file mode 100644 index 0000000..fc44fd7 --- /dev/null +++ b/2.3 memwatch-next.md @@ -0,0 +1,229 @@ +[memwatch-next](https://github.com/marcominetti/node-memwatch) 是一个用来监测 Node.js 的内存泄漏和堆信息比较的模块,以下简称 memwatch。 + +下面我们以一段事件监听器导致内存泄漏的代码为例,讲解如何使用 memwatch。 + +测试代码: + +**app.js** + +```js +let count = 1 +const memwatch = require('memwatch-next') +memwatch.on('stats', (stats) => { + console.log(count++, stats) +}) +memwatch.on('leak', (info) => { + console.log('---') + console.log(info) + console.log('---') +}) + +const http = require('http') +const server = http.createServer((req, res) => { + for (let i = 0; i < 10000; i++) { + server.on('request', function leakEventCallback() {}) + } + res.end('Hello World') + global.gc() +}).listen(3000) +``` + +每一个请求到来时,给 server 注册 10000 个 request 事件的监听函数(大量的事件监听函数存储到内存中,造成了内存泄漏),然后手动触发一次 GC。 + +运行该程序: + +```sh +$ node --expose-gc app.js +``` + +**注意:**这里添加 --expose-gc 参数启动,这样我们才可以在程序中手动触发 GC。 + +memwatch 可以监听两个事件: + +1. stats:GC 事件,每执行一次 GC,触发该函数,打印 heap 相关信息。如下: + + ```js + { + num_full_gc: 1,// 完整的垃圾回收次数 + num_inc_gc: 1,// 增长的垃圾回收次数 + heap_compactions: 1,// 内存压缩次数 + usage_trend: 0,// 使用趋势 + estimated_base: 5350136,// 预期基数 + current_base: 5350136,// 当前基数 + min: 0,// 最小值 + max: 0// 最大值 + } + ``` + +2. leak:内存泄露事件,触发该事件的条件是:连续 5 次 GC 后内存都是增长的。打印如下: + + ```js + { + growth: 4051464, + reason: 'heap growth over 5 consecutive GCs (2s) - -2147483648 bytes/hr' + } + ``` + +运行: + +```sh +$ ab -c 1 -n 5 http://localhost:3000/ +``` + +输出: + +```js +(node:20989) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 request listeners added. Use emitter.setMaxListeners() to increase limit +1 { num_full_gc: 1, + num_inc_gc: 1, + heap_compactions: 1, + usage_trend: 0, + estimated_base: 5720064, + current_base: 5720064, + min: 0, + max: 0 } +2 { num_full_gc: 2, + num_inc_gc: 1, + heap_compactions: 2, + usage_trend: 0, + estimated_base: 7073824, + current_base: 7073824, + min: 0, + max: 0 } +3 { num_full_gc: 3, + num_inc_gc: 1, + heap_compactions: 3, + usage_trend: 0, + estimated_base: 7826368, + current_base: 7826368, + min: 7826368, + max: 7826368 } +4 { num_full_gc: 4, + num_inc_gc: 1, + heap_compactions: 4, + usage_trend: 0, + estimated_base: 8964784, + current_base: 8964784, + min: 7826368, + max: 8964784 } +--- +{ growth: 3820272, + reason: 'heap growth over 5 consecutive GCs (0s) - -2147483648 bytes/hr' } +--- +5 { num_full_gc: 5, + num_inc_gc: 1, + heap_compactions: 5, + usage_trend: 0, + estimated_base: 9540336, + current_base: 9540336, + min: 7826368, + max: 9540336 } +``` + +**可以看出**:Node.js 已经警告我们事件监听器超过了 11 个,可能造成内存泄露。连续 5 次内存增长触发 leak 事件打印出增长了多少内存(bytes)和预估每小时增长多少 bytes。 + +### Heap Diffing + +memwatch 有一个 HeapDiff 函数,用来对比并计算出两次堆快照的差异。修改测试代码如下: + +```js +const memwatch = require('memwatch-next') +const http = require('http') +const server = http.createServer((req, res) => { + for (let i = 0; i < 10000; i++) { + server.on('request', function leakEventCallback() {}) + } + res.end('Hello World') + global.gc() +}).listen(3000) + +const hd = new memwatch.HeapDiff() +memwatch.on('leak', (info) => { + const diff = hd.end() + console.dir(diff, { depth: 10 }) +}) +``` + +运行这段代码并执行同样的 ab 命令,打印如下: + +```js +{ before: { nodes: 35727, size_bytes: 4725128, size: '4.51 mb' }, + after: { nodes: 87329, size_bytes: 8929792, size: '8.52 mb' }, + change: + { size_bytes: 4204664, + size: '4.01 mb', + freed_nodes: 862, + allocated_nodes: 52464, + details: + [ ... + { what: 'Array', + size_bytes: 530200, + size: '517.77 kb', + '+': 1023, + '-': 510 }, + { what: 'Closure', + size_bytes: 3599856, + size: '3.43 mb', + '+': 50001, + '-': 3 }, + ... + ] + } +} +``` + +**可以看出**:内存由 4.51mb 涨到了 8.52mb,其中 Closure 和 Array 涨了绝大部分,而我们知道注册事件监听函数的本质就是将事件函数(Closure)push 到相应的数组(Array)里。 + +## HeapDump + +memwatch 结合 heapdump 使用才能发挥更好的作用。通常用 memwatch 监测到发生内存泄漏,用 heapdump 导出多份堆快照,然后用 Chrome DevTools 分析比较,定位内存泄漏元凶。 + +修改代码如下: + +```js +const memwatch = require('memwatch-next') +const heapdump = require('heapdump') + +const http = require('http') +const server = http.createServer((req, res) => { + for (let i = 0; i < 10000; i++) { + server.on('request', function leakEventCallback() {}) + } + res.end('Hello World') + global.gc() +}).listen(3000) + +dump() +memwatch.on('leak', () => { + dump() +}) + +function dump() { + const filename = `${__dirname}/heapdump-${process.pid}-${Date.now()}.heapsnapshot` + + heapdump.writeSnapshot(filename, () => { + console.log(`${filename} dump completed.`) + }) +} +``` + +**代码含义**:程序启动后先执行一次 heap dump,当触发 leak 事件时再执行一次 heap dump。运行这段代码并执行同样的 ab 命令,生成两个 heapsnapshot 文件: + +``` +heapdump-21126-1519545957879.heapsnapshot +heapdump-21126-1519545975702.heapsnapshot +``` + +用 Chrome DevTools 加载这两个 heapsnapshot 文件,选择 comparison 比较视图,如下所示: + +![](./assets/2.3.1.png) + +**可以看出**:增加了 5 万个 leakEventCallback 函数,选中任意一个,可以从 Retainers 中看到更详细的信息,如 GC path 和所在的文件等信息。 + +## 参考链接 + +- [https://github.com/marcominetti/node-memwatch](https://github.com/marcominetti/node-memwatch) + +上一节:[2.2 heapdump](https://github.com/nswbmw/node-in-debugging/blob/master/2.2%20heapdump.md) + +下一节:[2.4 cpu-memory-monitor](https://github.com/nswbmw/node-in-debugging/blob/master/2.4%20cpu-memory-monitor.md) diff --git a/2.4 cpu-memory-monitor.md b/2.4 cpu-memory-monitor.md new file mode 100644 index 0000000..6b9b7f8 --- /dev/null +++ b/2.4 cpu-memory-monitor.md @@ -0,0 +1,134 @@ +前面介绍了 heapdump 和 memwatch-next 的用法,但在实际使用时并不那么方便,你总不能一直盯着服务器状况,发现内存持续增长并超过了你心里的阈值时,手动的去触发 Memory Dump 吧?大多数情况下当你发现问题时,已经错过了现场。 + +那你可能需要 [cpu-memory-monitor](https://github.com/nswbmw/cpu-memory-monitor)。顾名思义,这个模块可以用来监控 CPU 和 Memory 的使用情况,并可以根据配置策略自动 dump CPU 的使用情况(cpuprofile)和内存快照(heapsnapshot)。 + +## 使用方法 + +我们先来看看 cpu-memory-monitor 怎么使用,其实很简单,只需要在进程启动的入口文件引入以下代码即可: + +```js +require('cpu-memory-monitor')({ + cpu: { + interval: 1000, + duration: 30000, + threshold: 60, + profileDir: '/tmp', + counter: 3, + limiter: [5, 'hour'] + } +}) +``` + +**含义**:每 **1000ms**(interval)检查一次 CPU 使用情况,如果发现连续 **3**(counter)次 CPU 使用率大于 **60%**(threshold),则 dump **30000ms**(duration) CPU 的使用情况,生成 `cpu-${process.pid}-${Date.now()}.cpuprofile` 到 **/tmp**(profileDir) 目录下,**1**(limiter[1]) 小时最多 dump **5**(limiter[0]) 次。 + +上面是自动 dump CPU 使用情况的策略。Memory 的同理: + +```js +require('cpu-memory-monitor')({ + memory: { + interval: 1000, + threshold: '1.2gb', + profileDir: '/tmp', + counter: 3, + limiter: [3, 'hour'] + } +}) +``` + +**含义**:每 **1000ms**(interval) 检查一次 Memory 使用情况,如果发现连续 **3**(counter) 次 Memory 大于 **1.2gb**(threshold),则 dump 一次 Memory,生成 `memory-${process.pid}-${Date.now()}.heapsnapshot` 到 **/tmp**(profileDir) 目录下,**1**(limiter[1]) 小时最多 dump **3**(limiter[0]) 次。 + +**注意**:memory 的配置没有 duration 参数,因为 Memroy 的 dump 只是某一时刻而不是一段时间的。 + +那聪明的你肯定会问了:能不能 cpu 和 memory 配置一块用?比如: + +```js +require('cpu-memory-monitor')({ + cpu: { + interval: 1000, + duration: 30000, + threshold: 60, + ... + }, + memory: { + interval: 10000, + threshold: '1.2gb', + ... + } +}) +``` + +答案是:**可以,但不要这么做。**因为可能会出现这种情况:内存高了 -> 达到阈值,触发 Memory Dump/GC -> 导致了 CPU 高 -> 达到阈值,触发 CPU Dump -> 堆积的请求越来越多(比如内存中堆积了很多 SQL 查询)-> 触发 Memory Dump -> 雪崩。 + +通常情况下,只需要用其中一种就可以了。 + +## 源代码解读 + +cpu-memory-monitor 的源代码不过百余行,大体逻辑如下: + +```js +... +const processing = { + cpu: false, + memory: false +} + +const counter = { + cpu: 0, + memory: 0 +} + +function dumpCpu(cpuProfileDir, cpuDuration) { ... } +function dumpMemory(memProfileDir) { ... } + +module.exports = function cpuMemoryMonitor(options = {}) { + ... + if (options.cpu) { + const cpuTimer = setInterval(() => { + if (processing.cpu) { + return + } + pusage.stat(process.pid, (err, stat) => { + if (err) { + clearInterval(cpuTimer) + return + } + if (stat.cpu > cpuThreshold) { + counter.cpu += 1 + if (counter.cpu >= cpuCounter) { + memLimiter.removeTokens(1, (limiterErr, remaining) => { + if (limiterErr) { + return + } + if (remaining > -1) { + dumpCpu(cpuProfileDir, cpuDuration) + counter.cpu = 0 + } + }) + } else { + counter.cpu = 0 + } + } + }) + }, cpuInterval) + } + + if (options.memory) { + ... + memwatch.on('leak', () => { + dumpMemory(...) + }) + } +} +``` + +**可以看出**:cpu-memory-monitor 没有用到什么新鲜的东西,还是之前讲解过的 v8-profiler、heapdump、memwatch-next 组合使用而已。 + +有几点需要注意下: + +1. 只有传入了 cpu 或者 memory 的配置,才会去监听相应的 CPU 或者 Memory。 +2. 传入 memory 配置时,用了 memwatch-next 额外监听了 leak 事件,也会 dump Memory,格式是 `leak-memory-${process.pid}-${Date.now()}.heapsnapshot`。 +3. 顶部引入了 heapdump,所以即使没有 memory 配置,也可以通过 `kill -USR2 ` 手动触发 Memory Dump。 + +上一节:[2.3 memwatch-next](https://github.com/nswbmw/node-in-debugging/blob/master/2.3%20memwatch-next.md) + +下一节:[3.1 Async + Await](https://github.com/nswbmw/node-in-debugging/blob/master/3.1%20Async%20%2B%20Await.md) diff --git a/3.1 Async + Await.md b/3.1 Async + Await.md new file mode 100644 index 0000000..1032811 --- /dev/null +++ b/3.1 Async + Await.md @@ -0,0 +1,237 @@ +笔者很长一段时间都使用 koa@1 +(generator|bluebird)+ sequelize 这个组合,这个组合并没有什么问题,也很常见,但是到了滥用的地步,导致后来维护和调试都很痛苦。排除掉 sequelize 这个不得不用的模块,从调试 cpuprofile 角度讲讲为什么我认为应该用 async/await + Promise 替代 co + generator|bluebird。 + +我的观点是:**使用原生模块具有更清晰的调用栈**。 + +下面用 4 个例子对比,看下相同逻辑不同代码生成的 cpuprofile 中调用栈的信息。 + +**async.js** + +```js +const fs = require('fs') +const profiler = require('v8-profiler') + +async function A () { + return await Promise.resolve('A') +} + +async function B () { + return await A() +} + +(async function asyncWrap () { + const start = Date.now() + profiler.startProfiling() + while (Date.now() - start < 10000) { + await B() + } + const profile = profiler.stopProfiling() + profile.export() + .pipe(fs.createWriteStream('async.cpuprofile')) + .on('finish', () => { + profile.delete() + console.error('async.cpuprofile export success') + }) +})() +``` + +加载运行后生成的 async.cpuprofile,如下所示: + +![](./assets/3.1.1.png) + +**可以看出**:asyncWrap 中调用了 B 函数,B 函数调用了 A 函数,A 函数中 resolve 了一个值。asyncWrap 中还调用了 stopProfiling 函数。 + +**co.js** + +```js +const fs = require('fs') +const co = require('co') +const profiler = require('v8-profiler') + +function * A () { + return yield Promise.resolve('A') +} + +function * B () { + return yield A() +} + +co(function * coWrap () { + const start = Date.now() + profiler.startProfiling() + while (Date.now() - start < 10000) { + yield B() + } + const profile = profiler.stopProfiling() + profile.export() + .pipe(fs.createWriteStream('co.cpuprofile')) + .on('finish', () => { + profile.delete() + console.error('co.cpuprofile export success') + }) +}) +``` + +加载运行后生成的 co.cpuprofile,如下所示: + +![](./assets/3.1.2.png) + +**可以看出**:调用栈非常深,太多没有用的 co 相关的调用栈。如果 n 个 generator 层层嵌套,就会出现 n 倍的 (anonymous)->onFullfiled->next->toPromise->co->Promise->(anonymous) 调用栈。如果你读过 co 的源码可能知道,这是 co 将 generator 解包的过程。其实这个可以将 `yield generator` 替换成 `yield* generator` 来优化。 + +**co_better.js** + +```js +const fs = require('fs') +const co = require('co') +const profiler = require('v8-profiler') + +function * A () { + return yield Promise.resolve('A') +} + +function * B () { + return yield * A() +} + +co(function * coWrap () { + const start = Date.now() + profiler.startProfiling() + while (Date.now() - start < 10000) { + yield * B() + } + const profile = profiler.stopProfiling() + profile.export() + .pipe(fs.createWriteStream('co_better.cpuprofile')) + .on('finish', () => { + profile.delete() + console.error('co_better.cpuprofile export success') + }) +}) +``` + +加载运行后生成的 co_better.cpuprofile,如下所示: + +![](./assets/3.1.3.png) + +**可以看出**:相比 co.js 调用栈就清晰了很多,不过相比用 async/await 还是多了些 onFulfilled、next。 + +**co_bluebird.js** + +```js +const fs = require('fs') +const co = require('co') +const Promise = require('bluebird') +const profiler = require('v8-profiler') + +function * A () { + return yield Promise.resolve('A') +} + +function * B () { + return yield * A() +} + +co(function * coBluebirdWrap () { + const start = Date.now() + profiler.startProfiling() + while (Date.now() - start < 10000) { + yield * B() + } + const profile = profiler.stopProfiling() + profile.export() + .pipe(fs.createWriteStream('co_bluebird.cpuprofile')) + .on('finish', () => { + profile.delete() + console.error('co_bluebird.cpuprofile export success') + }) +}) +``` + +加载运行后生成的 co_bluebird.cpuprofile,如下所示: + +![](./assets/3.1.4.png) + +**可以看出**:相比较 co_better.js,调用栈中多了许多 bluebird 模块的无用信息。而且这只是非常简单的示例代码,要是复杂的业务逻辑大量使用 Bluebird 代码生成的 cpuprofile,几乎没法看了。 + +**结论**:使用 async/await + Promise + 命名函数,具有更清晰的调用栈,让分析 cpuprofile 时不再痛苦。 + +聪明的你可能会问: + +1. 为什么不建议用 bluebird?原因是: + 1. 随着 V8 不断优化,原生 Promise 性能逐渐提高,bluebird 性能优势不明显 + 2. 原生 Promise 的 API 足够用,至少能 cover 大部分使用场景,而且还在不断完善,未来还会添加新的 api,如:Promise.prototype.finally。 + 3. 具有更清晰的调用栈 +2. 由于历史遗留原因,现在代码中大量使用了 yield + generator 怎么办? + 1. 将所有 yield generator 替换成 yield * generator + 2. 升级到 node@^8,逐步用 async/await 替换,毕竟 async 函数调用后返回的也是一个 promise 嘛,也是 yieldable 的 +3. 性能比较呢?node@8+ 下 async/await 完胜 co,见参考链接 + +## yield -> yield* 的坑 + +上面讲到可以将 yield generator -> yield * generator,这里面有一个坑,是由于不明白 co 的原理滥用 co 导致的。代码如下: + +```js +const co = require('co') + +function * genFunc () { + return Promise.resolve('genFunc') +} + +co(function * () { + console.log(yield genFunc()) // => genFunc + console.log(yield * genFunc()) // => Promise { 'genFunc' } +}) +``` + +**可以看出**:一个 generatorFunction 执行后返回一个 promise,当使用 yield 的时候,co 判断返回了一个 promise 会继续帮我们调用它的 then 得到真正的字符串。如果使用 yield *,即用了语言原生的特性而不经过 co,直接返回一个 promise。 + +**解决方法(任选其一)**: + +1. `function * genFunc` -> `function genFunc`,用 `yield genFunc()` +2. `return Promise.resolve('genFunc')` ->` return yield Promise.resolve('genFunc')`,用 `yield* genFunc()` + +不过,建议最终转换到 async/await + Promise 上来,毕竟 co + generator 只是一个过渡产物。 + +## Async + Bluebird + +如果是使用 async/await + bluebird 的情况呢? + +**async_bluebird.js** + +```js +const fs = require('fs') +const profiler = require('v8-profiler') +const Promise = require('bluebird') + +async function A () { + return await Promise.resolve('A') +} + +async function B () { + return await A() +} + +(async function asyncBluebirdWrap () { + const start = Date.now() + profiler.startProfiling() + while (Date.now() - start < 10000) { + await B() + } + const profile = profiler.stopProfiling() + profile.export() + .pipe(fs.createWriteStream('async_bluebird.cpuprofile')) + .on('finish', () => { + profile.delete() + console.error('async_bluebird.cpuprofile export success') + }) +})() +``` + +**结论**:调用栈比 co_blueblird.js 的还乱。 + +## 参考链接 + +- [https://medium.com/@markherhold/generators-vs-async-await-performance-806d8375a01a](https://medium.com/%40markherhold/generators-vs-async-await-performance-806d8375a01a) + +上一节:[2.4 cpu-memory-monitor](https://github.com/nswbmw/node-in-debugging/blob/master/2.4%20cpu-memory-monitor.md) + +下一节:[3.2 Error Stack](https://github.com/nswbmw/node-in-debugging/blob/master/3.2%20Error%20Stack.md) diff --git a/3.2 Error Stack.md b/3.2 Error Stack.md new file mode 100644 index 0000000..603b820 --- /dev/null +++ b/3.2 Error Stack.md @@ -0,0 +1,489 @@ +JavaScript 中的 Error 想必大家都很熟悉了,毕竟天天与它打交道。 + +Node.js 内置的 Error 类型有: + +1. [Error](https://nodejs.org/api/errors.html%23errors_class_error):通用的错误类型,如:`new Error('error!!!')` +2. [SyntaxError](https://nodejs.org/api/errors.html%23errors_class_syntaxerror):语法错误,如:`require('vm').runInThisContext('binary ! isNotOk')` +3. [ReferenceError](https://nodejs.org/api/errors.html%23errors_class_referenceerror):引用错误,如引用一个未定义的变量,如:`doesNotExist` +4. [TypeError](https://nodejs.org/api/errors.html%23errors_class_typeerror):类型错误,如:`require('url').parse(() => { })` +5. [URIError](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/URIError):全局的 URI 处理函数抛出的错误,如:`encodeURI('\uD800')` +6. [AssertError](https://nodejs.org/api/errors.html%23errors_class_assertionerror):使用 assert 模块时抛出的错误,如:`assert(false)` + +每个 Error 对象通常有 name、message、stack、constructor 等属性。当程序抛出异常,我们需要根据错误栈(error.stack)定位到出错代码,错误栈这块还是有许多学问在里面的,希望本文能够帮助读者理解并玩转错误栈,写出错误栈清晰的代码,方便调试。 + +## Stack Trace + +错误栈本质上就是调用栈(或者叫:堆栈追踪)。所以我们先复习下 JavaScript 中调用栈的概念。 + +**调用栈**:每当有一个函数调用,就会将其压入栈顶,在调用结束的时候再将其从栈顶移出。 + +来看一段代码: + +```js +function c () { + console.log('c') + console.trace() +} + +function b () { + console.log('b') + c() +} + +function a () { + console.log('a') + b() +} + +a() +``` + +执行后打印出: + +```js +a +b +c +Trace + at c (/Users/nswbmw/Desktop/test/app.js:3:11) + at b (/Users/nswbmw/Desktop/test/app.js:8:3) + at a (/Users/nswbmw/Desktop/test/app.js:13:3) + at Object. (/Users/nswbmw/Desktop/test/app.js:16:1) + at ... +``` + +**可以看出**:c 函数中 console.trace() 打印出的堆栈追踪依次为 c->b->a,即 a 调用了 b,b 调用了 c。 + +稍微修改下上面的例子: + +```js +function c () { + console.log('c') +} + +function b () { + console.log('b') + c() + console.trace() +} + +function a () { + console.log('a') + b() +} + +a() +``` + +执行后打印出: + +``` +a +b +c +Trace + at b (/Users/nswbmw/Desktop/test/app.js:8:11) + at a (/Users/nswbmw/Desktop/test/app.js:13:3) + at Object. (/Users/nswbmw/Desktop/test/app.js:16:1) + at ... +``` + +**可以看出**:c() 在 console.trace() 之前执行完毕,从栈中移除,所以栈中从上往下为 b->a。 + +上面示例的代码过于简单,实际情况下错误栈并没有这么直观。就拿常用的 [mongoose](https://www.npmjs.com/package/mongoose) 举例,mongoose 的错误栈并不友好。 + +```js +const mongoose = require('mongoose') +const Schema = mongoose.Schema +mongoose.connect('mongodb://localhost/test') + +const UserSchema = new Schema({ + id: mongoose.Schema.Types.ObjectId +}) +const User = mongoose.model('User', UserSchema) +User + .create({ id: 'xxx' }) + .then(console.log) + .catch(console.error) +``` + +运行后打印出: + +```js +{ ValidationError: User validation failed: id: Cast to ObjectID failed for value "xxx" at path "id" + at ValidationError.inspect (/Users/nswbmw/Desktop/test/node_modules/mongoose/lib/error/validation.js:56:24) + at ... + errors: + { id: + { CastError: Cast to ObjectID failed for value "xxx" at path "id" + at new CastError (/Users/nswbmw/Desktop/test/node_modules/mongoose/lib/error/cast.js:27:11) + at model.$set (/Users/nswbmw/Desktop/test/node_modules/mongoose/lib/document.js:792:7) + at ... + message: 'Cast to ObjectID failed for value "xxx" at path "id"', + name: 'CastError', + stringValue: '"xxx"', + kind: 'ObjectID', + value: 'xxx', + path: 'id', + reason: [Object] } }, + _message: 'User validation failed', + name: 'ValidationError' } +``` + +从 mongoose 给出的 error.stack 中看不到任何有用的信息,error.message 告诉我们 "xxx" 不匹配 User 这个 Model 的 id(ObjectID)的类型,其他的字段基本也是这个结论的补充,却没有给出我们最关心的问题:**我写的代码中,哪一行出问题了?** + +如何解决这个问题呢?我们先看看 Error.captureStackTrace 的用法。 + +## Error.captureStackTrace + +Error.captureStackTrace 是 Node.js 提供的一个 api,可以传入两个参数: + +```js +Error.captureStackTrace(targetObject[, constructorOpt]) +``` + +Error.captureStackTrace 会在 targetObject 中添加一个 stack 属性。对该属性进行访问时,将以字符串的形式返回 Error.captureStackTrace() 语句被调用时的代码位置信息(即:调用栈历史)。 + +举个简单的例子: + +```js +const myObject = {} +Error.captureStackTrace(myObject) +console.log(myObject.stack) +// 输出 +Error + at Object. (/Users/nswbmw/Desktop/test/app.js:2:7) + at ... +``` + +除了 targetObject,captureStackTrace 还接受一个类型为 function 的可选参数 constructorOpt,当传递该参数时,调用栈中所有 constructorOpt 函数之上的信息(包括 constructorOpt 函数自身),都会在访问 targetObject.stack 时被忽略。当需要对终端用户隐藏内部的实现细节时,constructorOpt 参数会很有用。传入第二个参数通常用于自定义错误,如: + +```js +function MyError() { + Error.captureStackTrace(this, MyError) + this.name = this.constructor.name + this.message = 'you got MyError' +} + +const myError = new MyError() +console.log(myError) +console.log(myError.stack) +// 输出 +MyError { name: 'MyError', message: 'you got MyError' } +Error + at Object. (/Users/nswbmw/Desktop/test/app.js:7:17) + at ... +``` + +如果去掉 captureStackTrace 第二个参数: + +```js +function MyError() { + Error.captureStackTrace(this) + this.name = this.constructor.name + this.message = 'you got MyError' +} + +const myError = new MyError() +console.log(myError) +console.log(myError.stack) +// 输出 +MyError { name: 'MyError', message: 'you got MyError' } +Error + at new MyError (/Users/nswbmw/Desktop/test/app.js:2:9) + at Object. (/Users/nswbmw/Desktop/test/app.js:7:17) + at ... +``` + +**可以看出**:出现了 MyError 相关的调用栈,但我们并不关心 MyError 及其内部是如何实现的。 + +captureStackTrace 第二个参数可以传入其他函数,不一定是当前函数,如: + +```js +const myObj = {} + +function c () { + Error.captureStackTrace(myObj, b) +} + +function b () { + c() +} + +function a () { + b() +} + +a() +console.log(myObj.stack) +// 输出 +Error + at a (/Users/nswbmw/Desktop/test/app.js:12:3) + at Object. (/Users/nswbmw/Desktop/test/app.js:15:1) + at ... +``` + +**可以看出**:captureStackTrace 第二个参数传入了函数 b,调用栈中隐藏了 b 函数及其以上所有的堆栈帧。 + +讲到这相信读者都明白了 captureStackTrace 的用法。但这具体有什么用呢?其实上面提到了:**隐藏内部实现细节,优化错误栈**。 + +下面我以自己写的一个模块——[Mongolass](https://github.com/mongolass/mongolass) 为例,讲讲如何应用 captureStackTrace。 + +> [Mongolass](https://github.com/mongolass/mongolass) 是一个轻量且优雅的连接 MongoDB 的模块。 + +## captureStackTrace 在 Mongolass 中的应用 + +先大体讲下 Mongolass 的用法。Mongolass 跟 Mongoose 类似,有 Model 的概念,Model 上挂载的方法对应对 MongoDB 的 collections 的操作,如:`User.insert`。User 是一个 Model 实例,`User.insert` 方法返回的是一个 Query 实例。Query 代码如下: + +```js +class Query { + constructor(op, args) { + Error.captureStackTrace(this, this.constructor); + ... + } +} +``` + +这里用 Error.captureStackTrace 隐藏了 Query 内部的错误栈细节,但这样带来一个问题:丢失了原来的 error.stack,Mongolass 中可以自定义插件,而插件函数的执行是在 Query 内部,假如插件中抛错,则会丢失相关错误栈信息。 + +如何弥补呢?Mongolass 的做法是,当 Query 内部抛出错误(error)时,截取有用的 error.stack,然后拼接到 Query 实例通过 Error.captureStackTrace 生成的 stack 上。 + +来看一段 Mongolass 的代码: + +```js +const Mongolass = require('mongolass') +const Schema = Mongolass.Schema +const mongolass = new Mongolass('mongodb://localhost:27017/test') + +const UserSchema = new Schema('UserSchema', { + name: { type: 'string' }, + age: { type: 'number' } +}) +const User = mongolass.model('User', UserSchema) + +User + .insertOne({ name: 'nswbmw', age: 'wrong age' }) + .exec() + .then(console.log) + .catch(console.error) +``` + +运行后打印的错误信息如下: + +```js +{ TypeError: ($.age: "wrong age") ✖ (type: number) + at Model.insertOne (/Users/nswbmw/Desktop/test/node_modules/mongolass/lib/query.js:104:16) + at Object. (/Users/nswbmw/Desktop/test/app.js:12:4) + at ... + validator: 'type', + actual: 'wrong age', + expected: { type: 'number' }, + path: '$.age', + schema: 'UserSchema', + model: 'User', + op: 'insertOne', + args: [ { name: 'nswbmw', age: 'wrong age' } ], + pluginName: 'MongolassSchema', + pluginOp: 'beforeInsertOne', + pluginArgs: [] } +``` + +**可以看出**:app.js 第 12 行的 insertOne 报错,报错原因是 age 字段是字符串 "wrong age",而我们期望的是 number 类型的值。 + +## Error.prepareStackTrace + +V8 还暴露了另外一个接口——Error.prepareStackTrace。简单讲,这个函数的作用就是:**定制 stack**。 + +用法如下: + +```js +Error.prepareStackTrace(error, structuredStackTrace) +``` + +第一个参数是个 Error 对象,第二个参数是一个数组,每一项是一个 CallSite 对象,包含错误的函数名、行数等信息。对比以下两种代码: + +正常的 throw error: + +```js +function c () { + throw new Error('error!!!') +} + +function b () { + c() +} + +function a () { + b() +} + +try { + a() +} catch (e) { + console.log(e.stack) +} +// 输出 +Error: error!!! + at c (/Users/nswbmw/Desktop/test/app.js:2:9) + at b (/Users/nswbmw/Desktop/test/app.js:6:3) + at a (/Users/nswbmw/Desktop/test/app.js:10:3) + at Object. (/Users/nswbmw/Desktop/test/app.js:14:3) + at ... +``` + +使用 Error.prepareStackTrace 格式化 stack: + +```js +Error.prepareStackTrace = function (error, callSites) { + return error.toString() + '\n' + callSites.map(callSite => { + return ' -> ' + callSite.getFunctionName() + ' (' + + callSite.getFileName() + ':' + + callSite.getLineNumber() + ':' + + callSite.getColumnNumber() + ')' + }).join('\n') +} + +function c () { + throw new Error('error!!!') +} + +function b () { + c() +} + +function a () { + b() +} + +try { + a() +} catch (e) { + console.log(e.stack) +} +// 输出 +Error: error!!! + -> c (/Users/nswbmw/Desktop/test/app.js:11:9) + -> b (/Users/nswbmw/Desktop/test/app.js:15:3) + -> a (/Users/nswbmw/Desktop/test/app.js:19:3) + -> null (/Users/nswbmw/Desktop/test/app.js:23:3) + -> ... +``` + +**可以看出**:我们自定义了一个 Error.prepareStackTrace 格式化了 stack 并打印出来。 + +CallSite 对象还有许多 api,如:getThis、getTypeName、getFunction、getFunctionName、getMethodName、getFileName、getLineNumber、getColumnNumber、getEvalOrigin、isToplevel、isEval、isNative、isConstructor,这里不一一介绍,有兴趣的读者可查看参考链接。 + +Error.prepareStackTrace 使用时需要注意两点: + +1. 这个方法是 V8 暴露出来的,所以只能在基于 V8 的 Node.js 或者 Chrome 里才能使用 +2. 这个方法会修改全局 Error 的行为 + +## Error.prepareStackTrace 另类用法 + +Error.prepareStackTrace 除了格式化错误栈外还有什么作用呢?[sindresorhus](https://github.com/sindresorhus) 大神还写了一个 [callsites](https://github.com/sindresorhus/callsites) 的模块,可以用来获取函数调用相关的信息。如获取执行该函数所在的文件名: + +```js +const callsites = require('callsites') + +function getFileName() { + console.log(callsites()[0].getFileName()) + //=> '/Users/nswbmw/Desktop/test/app.js' +} + +getFileName() +``` + +我们来看下源代码: + +```js +module.exports = () => { + const _ = Error.prepareStackTrace + Error.prepareStackTrace = (_, stack) => stack + const stack = new Error().stack.slice(1) + Error.prepareStackTrace = _ + return stack +} +``` + +注意几点: + +1. 因为修改 Error.prepareStackTrace 会全局生效,所以将原来的 Error.prepareStackTrace 存到一个变量,函数执行完后再重置回去,避免影响全局的 Error +2. Error.prepareStackTrace 函数直接返回 CallSite 对象数组,而不是格式化后的 stack 字符串 +3. new 一个 Error,stack 是返回的 CallSite 对象数组,因为第一项是 callsites 总是这个模块的 CallSite,所以通过 slice(1) 去掉 + +假如我们想获取当前函数的父函数名,可以这样用: + +```js +const callsites = require('callsites') + +function b () { + console.log(callsites()[1].getFunctionName()) + // => 'a' +} + +function a () { + b() +} +a() +``` + +## Error.stackTraceLimit + +Node.js 还暴露了一个 Error.stackTraceLimit 的设置,可以通过设置这个值来改变输出的 stack 的行数,默认值是 10。 + +## Long Stack Trace + +stack trace 也有短板,问题出在异步操作。正常的 stack trace 遇到异步回调就会丢失绑定回调前的调用栈信息,来看个例子: + +```js +const foo = function () { + throw new Error('error!!!') +} +const bar = function () { + setTimeout(foo) +} +bar() +// 输出 +/Users/nswbmw/Desktop/test/app.js:2 + throw new Error('error!!!') + ^ + +Error: error!!! + at Timeout.foo [as _onTimeout] (/Users/nswbmw/Desktop/test/app.js:2:9) + at ontimeout (timers.js:469:11) + at tryOnTimeout (timers.js:304:5) + at Timer.listOnTimeout (timers.js:264:5) +``` + +**可以看出**:丢失了 bar 的调用栈。 + +在实际开发过程中,异步回调的例子数不胜数,如果不能知道异步回调之前的触发位置,会给 debug 带来很大的难度。这时,出现了一个概念叫 long Stack Trace。 + +long Stack Trace 并不是 JavaScript 原生就支持的东西,所以要拥有这样的功能,就需要我们做一些 hack,幸好在 V8 环境下,所有 hack 所需的 API,V8 都已经提供了。 + +对于异步回调,目前能做的就是在所有会产生异步操作的 API 做一些手脚,这些 API 包括: + +- setTimeout, setInterval, setImmediate +- nextTick, nextDomainTick +- EventEmitter.addEventListener +- EventEmitter.on +- Ajax XHR + +Long Stack Trace 相关的库可以参考: + +1. [AndreasMadsen/trace](https://github.com/AndreasMadsen/trace) +2. [mattinsler/longjohn](https://github.com/mattinsler/longjohn) +3. [tlrobinson/long-stack-traces](https://github.com/tlrobinson/long-stack-traces) + +node@8+ 提供了强大的 async_hooks 模块,后面会介绍如何使用。 + +## 参考链接 + +- https://zhuanlan.zhihu.com/p/25338849 +- https://segmentfault.com/a/1190000007076507 +- https://github.com/v8/v8/wiki/Stack-Trace-API +- https://www.jianshu.com/p/1d5120ad62bb + +上一节:[3.1 Async + Await](https://github.com/nswbmw/node-in-debugging/blob/master/3.1%20Async%20%2B%20Await.md) + +下一节:[3.3 Node@8](https://github.com/nswbmw/node-in-debugging/blob/master/3.3%20Node%408.md) diff --git a/3.3 Node@8.md b/3.3 Node@8.md new file mode 100644 index 0000000..408213e --- /dev/null +++ b/3.3 Node@8.md @@ -0,0 +1,129 @@ +如果你想以最简单的方式提升 node 程序的性能,那就升级到 node@8 吧。这不是一个玩笑,多少 js 前辈们前仆后继以血的教训总结出了一长列『Optimization killers』,口耳相传,典型的比如: + +1. try 里面不要写过多代码,V8 无法优化,最好将这些代码放到一个函数里,然后 try 这个函数 +2. 少用 delete +3. 少用 arguments +4. ... + +然而随着 V8 彻底换上了新的 JIT 编译器—— Turbofan,大多数『Optimization killers』都已经成了过去式。所以在这一小节,我们来扒一扒哪些过去常见的『Optimization killers』已经可以被 V8 优化。 + +## Turbofan + Ignition + +之前 V8 使用的是名为 Crankshaft 的编译器,这个编译器后来逐渐暴露出一些缺点: + +1. Doesn’t scale to full, modern JavaScript (try-catch, for-of, generators, async/await, …) +2. Defaults to deoptimization (performance cliffs, deoptimization loops) +3. Graph construction, inlining and optimization all mixed up +4. Tight coupling to fullcodegen / brittle environment tracking +5. Limited optimization potential / limited static analysis (i.e. type propagation) +6. High porting overhead +7. Mixed low-level and high-level semantics of instructions + +而引入 Turbofan 的好处是: + +1. Full ESnext language support (try-catch/-finally, class literals, eval, generators, async functions, modules, destructuring, etc.) +2. Utilize and propagate (static) type information +3. Separate graph building from optimization / inlining +4. No deoptimization loops / deoptimization only when really beneficial +5. Sane environment tracking (also for lazy deoptimization) +6. Predictable peak performance + +Ignition 是 V8 新引入的解释器,用来将代码编译成简洁的字节码,而不是之前的机器码,大大减少了结果代码,减少系统的内存使用。由于字节码较小,所以可以编译全部源代码,而不用避免编译未使用的代码。也就是说,脚本只需要解析一次,而不是像之前的编译过程那样解析多次。 + +补充一点,之前的 V8 将代码编译成机器码执行,而新的 V8 将代码编译成字节码解释执行,动机是什么呢?可能是: + +1. 减轻机器码占用的内存空间,即牺牲时间换空间(主要动机) +2. 提高代码的启动速度 +3. 对 V8 的代码进行重构,降低 V8 的代码复杂度 + +## 版本对应关系 + +``` +node@6 -> V8@5.1 -> Crankshaft +node@8.0-8.2 -> V8@5.8 -> Crankshaft + Turbofan + V8@5.9 -> Turbofan +node@8.3-8.4 -> V8@6.0 -> Turbofan +``` + +## try/catch + +最著名的去优化之一是使用 『try/catch』代码块。下面用四种场景比较不同 V8 版本下执行的效率: + +1. a function with a try/catch in it (sum with try catch) +2. a function without any try/catch (sum without try catch) +3. calling a function within a try block (sum wrapped) +4. simply calling a function, no try/catch involved (sum function) + +代码地址:[https://github.com/davidmarkclements/v8-perf/blob/master/bench/try-catch.js](https://github.com/davidmarkclements/v8-perf/blob/master/bench/try-catch.js) + +![](./assets/3.3.1.jpg) + +**结论**:node@8.3+,在 try 块内写代码的性能损耗可以忽略不计。 + +## delete + +多年以来,delete 对于任何希望编写高性能 JavaScript 的人来说都是受限制的,我们通常用赋值 undefined 替代。delete 的问题归结为 V8 处理 JavaScript 对象的动态特性和原型链方式,使得属性查找在实现上变得复杂。下面用三种场景比较不同 V8 版本下执行的效率: + +1. serializing an object after an object’s property has been set to undefined +2. serializing an object after delete has been used to remove an object’s property which was not the last property added. +3. serializing an object after delete has been used to remove the object’s most recently added property. + +代码地址:[https://github.com/davidmarkclements/v8-perf/blob/master/bench/property-removal.js](https://github.com/davidmarkclements/v8-perf/blob/master/bench/property-removal.js) + +![](./assets/3.3.2.jpg) + +**结论**:在 node@8+ 中 delete 一个对象上的属性比 node@6 快了一倍,node@8.3+ delete 一个对象上最后一个属性几乎与赋值 undefined 同样快了。 + +## arguments + +我们知道 arguments 是个类数组,所以通常我们要使用 `Array.prototype.slice.call(arguments)` 将它转化成数组再使用,这样会有一定的性能损耗。下面用四种场景比较不同 V8 版本下执行的效率: + +1. Exposing the arguments object to another function - no array conversion (leaky arguments) +2. Making a copy of the arguments object using the Array.prototype.slice tricks (Array prototype.slice arguments) +3. Using a for loop and copying each property across (for-loop copy arguments) +4. Using the EcmaScript 2015 spread operator to assign an array of inputs to a reference (spread operator) + +代码地址:[https://github.com/davidmarkclements/v8-perf/blob/master/bench/arguments.js](https://github.com/davidmarkclements/v8-perf/blob/master/bench/arguments.js) + +![](./assets/3.3.3.jpg) + +**结论**:node@8.3+ 使用对象展开运算符是除了直接使用 arguments 最快的方案,对于 node@8.2- 版本,我们应该使用一个 for 循环将 key 从 arguments 复制到一个新的(预先分配的)数组中。总之,是时候抛弃 Array.prototype.slice.call 了。 + +## async + +V8@5.7 发布后,原生的 async 函数跟 Promise 一样快了,同时 Promise 性能相比 V8@5.6 也快了一倍。如图: + +![](./assets/3.3.4.jpg) + +## 不会优化的特性 + +并不是说上了 Turbofan 就能优化所有的 JavaScript 语法,有些语法 V8 也是不会去优化的(也没有必要),如: + +1. debugger +2. eval +3. with + +拿 debugger 为例,比较使用和不使用 debugger 时的性能。 + +代码地址:[https://github.com/davidmarkclements/v8-perf/blob/master/bench/debugger.js](https://github.com/davidmarkclements/v8-perf/blob/master/bench/debugger.js) + +![](./assets/3.3.5.jpg) + +**结论**:在所有测试的 V8 版本中,debugger 一直都很慢,所以记得打断点测试完后一定要删掉 debugger。 + +## 总结 + +1. 使用最新 LTS 版本的 Node.js +2. 关注 V8 团队的博客——[https://v8project.blogspot.com](https://v8project.blogspot.com/),了解第一手资讯 +3. 清晰的代码远比使用一些奇技淫巧提升一点性能重要得多 + +## 参考链接 + +- http://www.infoq.com/cn/news/2016/08/v8-ignition-javascript-inteprete +- +- [https://www.nearform.com/blog/node-js-is-getting-a-new-v8-with-turbofan/](https://www.nearform.com/blog/node-js-is-getting-a-new-v8-with-turbofan/) +- [https://zhuanlan.zhihu.com/p/26669846](https://zhuanlan.zhihu.com/p/26669846) + +上一节:[3.2 Error Stack](https://github.com/nswbmw/node-in-debugging/blob/master/3.2%20Error%20Stack.md) + +下一节:[3.4 Rust Addons](https://github.com/nswbmw/node-in-debugging/blob/master/3.4%20Rust%20Addons.md) diff --git a/3.4 Rust Addons.md b/3.4 Rust Addons.md new file mode 100644 index 0000000..f3d7007 --- /dev/null +++ b/3.4 Rust Addons.md @@ -0,0 +1,440 @@ +我们知道 Node.js 不适合 CPU 密集型计算的场景,通常解决方法是用 C/C++ 编写 Node.js 的扩展(Addons)。以前只能用 C/C++,现在我们有了新的选择——Rust。 + +## 环境 + +- node@8.9.4 +- rust@1.26.0-nightly + +## Rust + +Rust 是 Mozilla 开发的注重安全、性能和并发的现代编程语言。相比较于其他常见的编程语言,它有 3 个独特的概念: + +1. 所有权 +2. 借用 +3. 生命周期 + +正是这 3 个特性保证了 Rust 是内存安全的,这里不会展开讲解,有兴趣的读者可以去了解下。 + +接下来,我们通过三种方式使用 Rust 编写 Node.js 的扩展。 + +## [FFI](https://github.com/node-ffi/node-ffi) + +FFI 全称是 Foreign Function Interface,即可以用 Node.js 调用动态链接库。 + +运行以下命令: + +```sh +$ cargo new ffi-demo && cd ffi-demo +$ npm init -y +$ npm i ffi --save +$ touch index.js +``` + +部分文件修改如下: + +**src/lib.rs** + +```rust +#[no_mangle] +pub extern fn fib(n: i64) -> i64 { + return match n { + 1 | 2 => 1, + n => fib(n - 1) + fib(n - 2) + } +} +``` + +**Cargo.toml** + +```toml +[package] +name = "ffi-demo" +version = "0.1.0" + +[lib] +name = "ffi" +crate-type = ["dylib"] +``` + +Cargo.toml 是 Rust 项目的配置文件,相当于 Node.js 中的 package.json。这里指定编译生成的类型是 dylib(动态链接库),名字在 *inux 下是 libffi,Windows 下是 ffi。 + +使用 cargo 编译代码: + +```sh +$ cargo build #开发环境用 +或者 +$ cargo build --release #生产环境用,编译器做了更多优化,但编译慢 +``` + +cargo 是 Rust 的构建工具和包管理工具,负责构建代码、下载依赖库并编译它们。此时会生成一个 target 的目录,该目录下会有 debug(不加 --release)或者 release(加 --release)目录,存放了生成的动态链接库。 + +**index.js** + +```js +const ffi = require('ffi') +const isWin = /^win/.test(process.platform) + +const rust = ffi.Library('target/debug/' + (!isWin ? 'lib' : '') + 'ffi', { + fib: ['int', ['int']] +}) + +function fib(n) { + if (n === 1 || n === 2) { + return 1 + } + return fib(n - 1) + fib(n - 2) +} + +// js +console.time('node') +console.log(fib(40)) +console.timeEnd('node') + +// rust +console.time('rust') +console.log(rust.fib(40)) +console.timeEnd('rust') +``` + +运行 index.js: + +```sh +$ node index.js +102334155 +node: 1053.743ms +102334155 +rust: 1092.570ms +``` + +将 index.js 中 debug 改为 release,运行: + +```sh +$ cargo build --release +$ node index.js +102334155 +node: 1050.467ms +102334155 +rust: 273.508ms +``` + +可以看出,添加 --release 编译后的代码执行效率提升十分明显。 + +## [Neon](https://github.com/neon-bindings/neon) + +官方介绍: + +> Rust bindings for writing safe and fast native Node.js modules. + +使用方法: + +```sh +$ npm i neon-cli -g +$ neon new neon-demo +$ cd neon-demo +$ tree . +. +├── README.md +├── lib +│ └── index.js +├── native +│ ├── Cargo.toml +│ ├── build.rs +│ └── src +│ └── lib.rs +└── package.json + +3 directories, 6 files +$ npm i #触发 neon build +$ node lib/index.js +hello node +``` + +接下来我们看看关键的代码文件。 + +**lib/index.js** + +```js +var addon = require('../native'); +console.log(addon.hello()); +``` + +**native/src/lib.rs** + +```rust +#[macro_use] +extern crate neon; + +use neon::vm::{Call, JsResult}; +use neon::js::JsString; + +fn hello(call: Call) -> JsResult { + let scope = call.scope; + Ok(JsString::new(scope, "hello node").unwrap()) +} + +register_module!(m, { + m.export("hello", hello) +}); +``` + +**native/build.rs** + +```rust +extern crate neon_build; + +fn main() { + neon_build::setup(); // must be called in build.rs + + // add project-specific build logic here... +} +``` + +**native/Cargo.toml** + +```toml +[package] +name = "neon-demo" +version = "0.1.0" +authors = ["nswbmw "] +license = "MIT" +build = "build.rs" + +[lib] +name = "neon_demo" +crate-type = ["dylib"] + +[build-dependencies] +neon-build = "0.1.22" + +[dependencies] +neon = "0.1.22" +``` + +运行 `neon build` 会根据 native/Cargo.toml 中 build 字段指定的文件(这里是 build.rs)编译,并且生成的类型是 dylib(动态链接库)。native/src/lib.rs 存放了扩展的代码逻辑,通过 register_module 注册了一个 hello 方法,返回 hello node 字符串。 + +接下来我们测试一下原生 Node.js 和 Neon 编写的扩展运行斐波那契数列的执行效率对比。 + +修改对应文件如下: + +**native/src/lib.rs** + +```rust +#[macro_use] +extern crate neon; + +use neon::vm::{Call, JsResult}; +use neon::mem::Handle; +use neon::js::JsInteger; + +fn fib(call: Call) -> JsResult { + let scope = call.scope; + let index: Handle = try!(try!(call.arguments.require(scope, 0)).check::()); + let index: i32 = index.value() as i32; + let result: i32 = fibonacci(index); + Ok(JsInteger::new(scope, result)) +} + +fn fibonacci(n: i32) -> i32 { + match n { + 1 | 2 => 1, + _ => fibonacci(n - 1) + fibonacci(n - 2) + } +} + +register_module!(m, { + m.export("fib", fib) +}); +``` + +**lib/index.js** + +```js +const rust = require('../native') + +function fib (n) { + if (n === 1 || n === 2) { + return 1 + } + return fib(n - 1) + fib(n - 2) +} + +// js +console.time('node') +console.log(fib(40)) +console.timeEnd('node') + +// rust +console.time('rust') +console.log(rust.fib(40)) +console.timeEnd('rust') +``` + +运行: + +```sh +$ neon build +$ node lib/index.js +102334155 +node: 1030.681ms +102334155 +rust: 270.417ms +``` + +接下来看一个复杂点的例子,用 Neon 编写一个 User 类,可传入一个含有 first_name 和 last_name 对象,暴露出一个 get_full_name 方法。 + +修改对应文件如下: + +**native/src/lib.rs** + +```rust +#[macro_use] +extern crate neon; + +use neon::js::{JsFunction, JsString, Object, JsObject}; +use neon::js::class::{Class, JsClass}; +use neon::mem::Handle; +use neon::vm::Lock; + +pub struct User { + first_name: String, + last_name: String, +} + +declare_types! { + pub class JsUser for User { + init(call) { + let scope = call.scope; + let user = try!(try!(call.arguments.require(scope, 0)).check::()); + let first_name: Handle = try!(try!(user.get(scope, "first_name")).check::()); + let last_name: Handle = try!(try!(user.get(scope, "last_name")).check::()); + + Ok(User { + first_name: first_name.value(), + last_name: last_name.value(), + }) + } + + method get_full_name(call) { + let scope = call.scope; + let first_name = call.arguments.this(scope).grab(|user| { user.first_name.clone() }); + let last_name = call.arguments.this(scope).grab(|user| { user.last_name.clone() }); + Ok(try!(JsString::new_or_throw(scope, &(first_name + &last_name))).upcast()) + } + } +} + +register_module!(m, { + let class: Handle> = try!(JsUser::class(m.scope)); + let constructor: Handle> = try!(class.constructor(m.scope)); + try!(m.exports.set("User", constructor)); + Ok(()) +}); +``` + +**lib/index.js** + +```js +const rust = require('../native') +const User = rust.User + +const user = new User({ + first_name: 'zhang', + last_name: 'san' +}) + +console.log(user.get_full_name()) +``` + +运行: + +```sh +$ neon build +$ node lib/index.js +zhangsan +``` + +## [NAPI](https://nodejs.org/api/n-api.html) + +NAPI 是 node@8 新添加的用于原生模块开发的接口,相比较于以前的开发方式,NAPI 提供了稳定的 ABI 接口,消除了 Node.js 版本差异、引擎差异等编译后不兼容问题。 + +目前 NAPI 还处于试验阶段,所以相关资料并不多,笔者写了一个 demo 放到了 GitHub 上,这里直接 clone 下来运行: + +```sh +$ git clone https://github.com/nswbmw/rust-napi-demo +``` + +主要文件代码如下: + +**src/lib.rs** + +```rust +#[macro_use] +extern crate napi; +#[macro_use] +extern crate napi_derive; + +use napi::{NapiEnv, NapiNumber, NapiResult}; + +#[derive(NapiArgs)] +struct Args<'a> { + n: NapiNumber<'a> +} + +fn fibonacci<'a>(env: &'a NapiEnv, args: &Args<'a>) -> NapiResult> { + let number = args.n.to_i32()?; + NapiNumber::from_i32(env, _fibonacci(number)) +} + +napi_callback!(export_fibonacci, fibonacci); + +fn _fibonacci(n: i32) -> i32 { + match n { + 1 | 2 => 1, + _ => _fibonacci(n - 1) + _fibonacci(n - 2) + } +} +``` + +**index.js** + +```js +const rust = require('./build/Release/example.node') + +function fib (n) { + if (n === 1 || n === 2) { + return 1 + } + return fib(n - 1) + fib(n - 2) +} + +// js +console.time('node') +console.log(fib(40)) +console.timeEnd('node') + +// rust +console.time('rust') +console.log(rust.fibonacci(40)) +console.timeEnd('rust') +``` + +运行结果: + +```sh +$ npm start +102334155 +node: 1087.650ms +102334155 +rust: 268.395ms +(node:33302) Warning: N-API is an experimental feature and could change at any time. +``` + +## 参考链接 + +- [https://github.com/neon-bindings/neon](https://github.com/neon-bindings/neon) +- https://github.com/napi-rs/napi +- [https://zhuanlan.zhihu.com/p/27650526](https://zhuanlan.zhihu.com/p/27650526) + +上一节:[3.3 Node@8](https://github.com/nswbmw/node-in-debugging/blob/master/3.3%20Node%408.md) + +下一节:[4.1 Source Map](https://github.com/nswbmw/node-in-debugging/blob/master/4.1%20Source%20Map.md) diff --git a/4.1 Source Map.md b/4.1 Source Map.md new file mode 100644 index 0000000..cf35d02 --- /dev/null +++ b/4.1 Source Map.md @@ -0,0 +1,162 @@ +Source Map 想必大家并不陌生,在前端开发中通常要压缩 js, css,减少体积,加快网页显示。但带来的后果是如果出现错误,导致无法定位错误,Source Map 应用而生。举个例子, jQuery 1.9 引入了 Source Map,打开 http://ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js,最后一行是这样的: + +```js +//@ sourceMappingURL=jquery.min.map +``` + +这就是 Source Map。它是一个独立的 map(其实就是 JSON) 文件,通常与源码在同一个目录下。 + +Source Map 用在以下几个场景: + +1. 压缩代码,减小体积。比如 jQuery 1.9 的源码,压缩前是 252KB,压缩后是 32KB。 +2. 多个文件合并,减少 HTTP 请求数,针对前端而言。 +3. 其他语言编译成 JavaScript,如:CoffeeScript、TypeScript 等。 + +本文只讲解如何使用 Source Map,关于 map 文件中字段的含义本文不会解释,有兴趣的请查看参考链接中的文章。接下来我们在 Node.js 环境下以场景 1、3 为例,分别介绍 uglify-es 和 TypeScript 如何结合 Source Map 使用。 + +## uglify-es + +uglify-js 是最常用的 js 代码压缩工具,但只支持到 ES5,uglify-es 支持 ES6+ 并且兼容 uglify-js,所以本文使用 uglify-es。 + +source-map-support 是一个在 Node.js 环境下支持 Source Map 的模块。 + +安装 uglify-es 和 source-map-support: + +```sh +$ npm i uglify-es -g +$ npm i source-map-support +``` + +创建测试代码: + +**app.js** + +```js +require('source-map-support').install() + +function sayHello (name) { + throw new Error('error!!!') + console.log(`Hello, ${name}`) +} + +sayHello('World') +``` + +使用 uglify-es 压缩代码文件并生成 map 文件: + +```sh +$ uglifyjs app.js -o app.min.js --source-map "url=app.min.js.map" +``` + +生成 app.min.js 和 app.min.js.map 文件,内容分别如下: + +**app.min.js** + +```js +require("source-map-support").install();function sayHello(name){throw new Error("error!!!");console.log(`Hello, ${name}`)}sayHello("World"); +//# sourceMappingURL=app.min.js.map +``` + +**app.min.js.map** + +```json +{"version":3,"sources":["app.js"],"names":["require","install","sayHello","name","Error","console","log"],"mappings":"AAAAA,QAAQ,sBAAsBC,UAE9B,SAASC,SAAUC,MACjB,MAAM,IAAIC,MAAM,YAChBC,QAAQC,cAAcH,QAGxBD,SAAS"} +``` + +此时运行 app.min.js 可以显示正确的错误栈: + +```js +$ node app.min.js + +/Users/nswbmw/Desktop/test/app.js:4 + throw new Error('error!!!') + ^ +Error: error!!! + at sayHello (/Users/nswbmw/Desktop/test/app.js:4:9) +``` + +如果删除 app.min.js 最后那行注释,重新运行则无法显示正确的错误栈: + +```js +$ node app.min.js + +/Users/nswbmw/Desktop/test/app.min.js:1 +require("source-map-support").install();function sayHello(name){throw new Error("error!!!");console.log(`Hello, ${name}`)}sayHello("World"); + ^ +Error: error!!! + at sayHello (/Users/nswbmw/Desktop/test/app.min.js:1:71) +``` + +source-map-support 是通过 Error.prepareStackTrace 实现的,前面讲解过它的用法,这里不再赘述。 + +## TypeScript + +全局安装 TypeScript: + +```sh +$ npm i typescript -g +``` + +创建测试代码: + +**app_ts.ts** + +```typescript +declare function require(name: string) +require('source-map-support').install() + +function sayHello (name: string): any { + throw new Error('error!!!') +} + +sayHello('World') +``` + +运行: + +```sh +$ tsc --sourceMap app_ts.ts +``` + +生成 app_ts.js 和 app_ts.js.map,运行 app_ts.js 如下: + +```js +$ node app_ts.js + +/Users/nswbmw/Desktop/test/app_ts.ts:5 + throw new Error('error!!!') + ^ +Error: error!!! + at sayHello (/Users/nswbmw/Desktop/test/app_ts.ts:5:9) +``` + +## source-map-support 高级用法 + +我们可以在调用 install 方法时传入一个 retrieveSourceMap 参数,用来自定义处理 Source Map: + +```js +require('source-map-support').install({ + retrieveSourceMap: function(source) { + if (source === 'compiled.js') { + return { + url: 'original.js', + map: fs.readFileSync('compiled.js.map', 'utf8') + } + } + return null + } +}) +``` + +比如将所有 map 文件缓存到内存中,而不是磁盘上。 + +## 参考链接 + +- http://www.ruanyifeng.com/blog/2013/01/javascript_source_map.html +- https://yq.aliyun.com/articles/73529 +- https://github.com/v8/v8/wiki/Stack-Trace-API +- https://github.com/evanw/node-source-map-support + +上一节:[3.4 Rust Addons](https://github.com/nswbmw/node-in-debugging/blob/master/3.4%20Rust%20Addons.md) + +下一节:[4.2 Chrome DevTools](https://github.com/nswbmw/node-in-debugging/blob/master/4.2%20Chrome%20DevTools.md) diff --git a/4.2 Chrome DevTools.md b/4.2 Chrome DevTools.md new file mode 100644 index 0000000..6fae9be --- /dev/null +++ b/4.2 Chrome DevTools.md @@ -0,0 +1,96 @@ +调试是每个程序员必备的技能,因此选择合适的调试工具能极大的方便我们调试代码。Node.js 的调试方式也有很多,常见的如: + +1. 万能的 console.log +2. debugger +3. node-inspector + +以上本文都不会涉及,因为: + +1. console.log 就不用说了 +2. debugger 不推荐使用,因为: + 1. 使用繁琐,需手动打点 + 2. 若忘记删除 debugger,会引起性能问题 +3. node-inspector 已经退出历史舞台。node@6.3 以后,Node.js 内置了一个调试器,可以结合 Chrome DevTools 使用,而且比 node-inspector 更强大 + +下面就讲讲 Chrome DevTools 的用法。 + +## Chrome DevTools + +创建示例代码: + +**app.js** + +```js +const Paloma = require('paloma') +const app = new Paloma() + +app.use(ctx => { + ctx.body = 'hello world!' +}) + +app.listen(3000) +``` + +运行: + +```sh +$ node --inspect app.js +``` + +**注意:**如果想让代码在第一行就暂停执行,需要使用 --inspect-brk 参数启动,即 `node --inspect-brk app.js`。 + +打开 Chrome 浏览器,访问 chrome://inspect,如下所示: + +![](./assets/4.2.1.png) + +点击 Remote Target 下的 inspect,选择 Sources,如下所示: + +![](./assets/4.2.2.png) + +使用方式跟 node-inspector 类似,可以添加断点,然后在 Console 里面直接输入变量名打印该变量的值。如下所示,在第 6 行添加断点,然后通过 `curl localhost:3000?name=nswbmw`,代码执行到第 6 行暂停执行,在 Console 里打印 ctx.query 的值: + +![](./assets/4.2.3.png) + +**Tips**:将鼠标悬浮在某个变量上,也会显示它的值,如 ctx。 + +展开右侧的 debugger 有更多的功能,如单步执行、单步进入、单步退出等等,这里不再详细讲解。 + +## [NIM](https://chrome.google.com/webstore/detail/nim-node-inspector-manage/gnhhdgbaldcilmgcpfddgdbkhjohddkj) + +每次调试 Node.js 都要打开隐藏那么深的入口烦不烦?还好我们有 [NIM](https://chrome.google.com/webstore/detail/nim-node-inspector-manage/gnhhdgbaldcilmgcpfddgdbkhjohddkj)。NIM(Node Inspector Manager)是一个 Chrome 插件,可以方便我们快捷的打开 DevTools,也可以设置自动发现并打开 DevTools。 + +![](./assets/4.2.4.png) + +## [inspect-process](https://github.com/jaridmargolin/inspect-process) + +NIM 也麻烦?那你可能需要 [inspect-process](https://github.com/jaridmargolin/inspect-process)。 + +全局安装: + +```sh +$ npm i inspect-process -g +``` + +使用: + +```sh +$ inspect app.js +``` + +inspect-process 会自动调起 Chrome DevTools,然后定位到 app.js,其余用法跟 Chrome DevTools 一致。 + +## process._debugProcess + +如果一个 Node.js 进程已经启动,没有添加 --inspect 参数,我们不想重启(会丢失现场)又想调试怎么办?幸好我们有 process._debugProcess。使用方法: + +1. 通过 ps 命令或者 `pgrep -n node` 查看当前启动的 Node.js 进程的 pid,如:53911 +2. 打开新的终端,运行:`node -e "process._debugProcess(53911)"`,原来的 Node.js 进程会打印出:Debugger listening on ws://127.0.0.1:9229/2331fa07-32af-45eb-a1a8-bead7a0ab905 +3. 调出 Chrome DevTools 进行调试 + +## 参考链接 + +- [https://medium.com/@paul_irish/debugging-node-js-nightlies-with-chrome-devtools-7c4a1b95ae27](https://medium.com/@paul_irish/debugging-node-js-nightlies-with-chrome-devtools-7c4a1b95ae27) + +上一节:[4.1 Source Map](https://github.com/nswbmw/node-in-debugging/blob/master/4.1%20Source%20Map.md) + +下一节:[4.3 Visual Studio Code](https://github.com/nswbmw/node-in-debugging/blob/master/4.3%20Visual%20Studio%20Code.md) diff --git a/4.3 Visual Studio Code.md b/4.3 Visual Studio Code.md new file mode 100644 index 0000000..c0ee118 --- /dev/null +++ b/4.3 Visual Studio Code.md @@ -0,0 +1,301 @@ +Visual Studio Code(简称 VS Code)是一款微软开源的现代化、跨平台、轻量级的代码编辑器。VS Code 很好很强大,本文将介绍如何使用 VS Code 来调试 Node.js 代码。 + +## 基本调试 + +示例代码: + +**app.js** + +```js +const Paloma = require('paloma') +const app = new Paloma() + +app.use(ctx => { + ctx.body = 'hello world!' +}) + +app.listen(3000) +``` + +用 VS Code 加载 test 文件夹,打开 app.js,然后: + +1. 点击左侧第 4 个 tab 切换到调试模式 +2. 点击代码第 5 行 `ctx.body='hello world!'` 左侧空白处添加断点 +3. 点击左上角『调试』的绿色三角按钮启动调试 +4. 点击左上角终端图标打开调试控制台 + +最终如下所示: + +![](./assets/4.3.1.png) + +从『调试控制台』切换到『终端』,运行: + +```sh +$ curl localhost:3000 +``` + +如下所示: + +![](./assets/4.3.2.png) + +可以看出,VS Code 基本覆盖了 Chrome DevTools 所有的功能,并且有两个额外的优点: + +1. 集成了终端,不用再打开新的终端输入命令了 +2. 调试动作里添加了『重启』和『停止』按钮,不用每次修改完代码切回终端去重启了 + +但 VS Code 的强大远不止如此,通过 launch.json 可以配置详细的调试功能。 + +## launch.json + +上图可以看出,『调试』右边有个下拉菜单,默认是『没有配置』。点击右侧的齿轮状图标,会在项目根目录下创建 .vscode 文件夹及 launch.json 文件。launch.json 内容如下: + +![](./assets/4.3.3.png) + +这个默认配置的意思是执行: + +```sh +$ 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,则以下配置跟默认配置是等效的: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "启动程序", + "program": "${file}" + } + ] +} +``` + +更多 launch.json 选项请查阅: + +- [Debugging in Visual Studio Code](https://code.visualstudio.com/docs/editor/debugging#_launchjson-attributes) +- [Debug Node.js Apps using VS Code](https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_launch-configuration-attributes) + +下面以 5 个实用的技巧讲解部分 launch.json 配置的作用。 + +## 技巧 1——条件断点 + +VS Code 可以添加条件断点,即执行到该行代码满足特定条件后程序才会中断。在断点小红点上右键选择『编辑断点』,可以选择两种条件: + +1. 表达式:当表达式计算结果为 true 时中断,如设置:`ctx.query.name === 'nswbmw'`,表示当访问 `localhost:3000?name=nswbmw` 时断点才会生效,其余请求断点无效 +2. 命中次数:同样当表达式计算结果为 true 时中断,支持运算符 <, <=, ==, >, >=, %。如: + 1. \>10:执行 10 次以后断点才会生效 + 2. <3:只有前 2 次断点会生效 + 3. 10:等价于 >=10 + 4. %2:隔一次中断一次 + +**注意:**可以组合表达式和命中次数条件一起使用。在切换条件类型时,需要将原来的条件清空,否则会添加两种条件。将鼠标悬浮在断点上,可以查看设置了哪些条件。 + +## 技巧 2——skipFiles + +从上面图中可以看到 VS Code 左侧有一个『调用堆栈』面板,显示了当前断点的调用堆栈,但无法直观的看出哪些是我们项目的代码,哪些是 node_moduels 里模块的代码,而且在点击单步调试时会进入到 node_modules 里。总之,我们不关心 node_modules 里的代码,我们只关心项目本身的代码。这个时候,skipFiles 就派上用场了。 + +skipFiles 顾名思义就是忽略我们不关心的文件。修改 launch.json 如下: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "启动程序", + "program": "${workspaceFolder}/app.js", + "skipFiles": [ + "${workspaceFolder}/node_modules/**/*.js", + "/**/*.js" + ] + } + ] +} +``` + +有几点需要解释: + +1. 支持 ${xxx} 这种变量替换 +2. 支持 glob 模式匹配 +3. 用来忽略 Node.js 核心模块 + +重启调试后,如下所示: + +![](./assets/4.3.4.png) + +**可以看出**:左侧『调用堆栈』中我们不关心的调用栈都变灰了,而且单步调试也不会进入到 skipFiles 所匹配的文件里。 + +## 技巧 3——自动重启 + +每次修改代码保存后都要手动重启,否则修改后的代码和断点都不会生效。VS Code 开发者们想到了这一点,通过添加配置可以实现修改代码保存后会自动重启调试,需要结合 [nodemon](https://nodemon.io/) 一起使用。 + +首先全局安装 nodemon: + +```sh +$ npm i nodemon -g +``` + +然后修改 launch.json 如下: + +```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", + "/**/*.js" + ] + } + ] +} +``` + +相比较上一个版本的 launch.json,多了以下几个字段: + +- runtimeExecutable:用什么命令执行 app.js,这里设置为 nodemon +- restart:设置为 true,修改代码保存后会自动重启调试 +- console:当点击停止按钮或者修改代码保存后自动重启调试,而 nodemon 是仍然在运行的,通过设置为 console 为 integratedTerminal 可以解决这个问题。此时 VS Code 终端将会打印 nodemon 的 log,可以在终端右侧下拉菜单选择返回第一个终端,然后运行 `curl localhost:3000` 调试 + +对于已经使用 nodemon 运行的程序,如: + +```sh +$ nodemon --inspect app.js +``` + +可使用 attach 模式启动调试,launch.json 如下: + +```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——特定操作系统设置 + +针对不同的操作系统,可能会用到不同的调试配置。可选参数为: + +- windows +- linux +- osx + +示例: + +```json +{ + "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 点击右下角的『添加配置...』会弹出配置模板,如下所示: + +![](./assets/4.3.5.png) + +可以用来配置不同的调试规则,比如最终我们将 launch.json 修改成如下: + +```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", + "/**/*.js" + ] + } + ] +} +``` + +## 总结 + +VS Code 调试功能十分强大,本文只讲解了一些常用的调试功能,其余的调试功能还请读者自行尝试。 + +## 参考链接 + +- https://code.visualstudio.com/docs/editor/debugging +- https://code.visualstudio.com/docs/nodejs/nodejs-debugging + +上一节:[4.2 Chrome DevTools](https://github.com/nswbmw/node-in-debugging/blob/master/4.2%20Chrome%20DevTools.md) + +下一节:[4.4 debug + repl2 + power-assert](https://github.com/nswbmw/node-in-debugging/blob/master/4.4%20debug%20%2B%20repl2%20%2B%20power-assert.md) diff --git a/4.4 debug + repl2 + power-assert.md b/4.4 debug + repl2 + power-assert.md new file mode 100644 index 0000000..501cc26 --- /dev/null +++ b/4.4 debug + repl2 + power-assert.md @@ -0,0 +1,282 @@ +上一小节讲解了如何用使用 VS Code 调试 Node.js 代码,但调试不只是打断点,比如: + +- 如何快速的切换输出的日志类型(或级别)? +- 我只想试一下用 moment 打印出年份是 `moment().format('YYYY')` 还是 `moment().format('yyyy')` 还是两种写法都可以? +- 断言报错:AssertionError: false == true,没啥有用信息,黑人问号??? + +本文将介绍给大家 3 款实用的调试工具,分别解决以上 3 种情况,提高我们的调试效率。 + +## debug + +debug 是一个小巧却非常实用的日志模块,可以根据环境变量决定打印不同类型(或级别)的日志。直接上代码: + +**app.js** + +```js +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:严重级别的错误日志 + +运行: + +```sh +$ DEBUG=* node app.js +``` + +打印如下: + +![](./assets/4.4.1.jpg) + +可以看出,debug 模块打印的日志相比较于 console.log,有以下几个特点: + +1. 不同的日志类型分配了不同的颜色加以区分,更直观 +2. 添加了日志类型的前缀 +3. 添加了自上一次该类型日志打印到这次日志打印经历了多长时间的后缀 + +debug 模块支持以下用法: + +- DEBUG=*:打印所有类型的日志 +- DEBUG=log:只打印 log 类型的日志 +- DEBUG=error:*:打印所有以 error: 开头的日志 +- DEBUG=error:*,-error:low:打印所有以 error: 开头的并且过滤掉 error:low 类型的日志 + +下面演示一下第 4 种的用法,运行: + +```sh +$ DEBUG=error:*,-error:low node app.js +``` + +打印如下: + +![](./assets/4.4.2.jpg) + +## repl2 + +我们在写代码的时候,有时候记不太清某个模块某个方法的具体用法,比如:moment 打印出年份是 `moment().format('YYYY')` 还是 `moment().format('yyyy')` 还是两种写法都可以?lodash 的 `_.pick` 方法能否能接收数组作为参数?这个时候相比较于翻阅官网网站,在 REPL 里试一下可能来得更快,通常步骤是: + +```sh +$ npm i moment +$ node +> const moment = require('moment') +> moment().format('YYYY') +'2017' +> moment().format('yyyy') +'yyyy' +``` + +一次还好,这样用次数多了也略微繁琐,repl2 模块便是为了解决这个问题而生。 + +repl2 顾名思义是 REPL 的增强版,repl2 会根据一个用户配置(~/.noderc),预先加载模块到 REPL 中,省下了我们手动在 REPL 中 require 模块的过程。 + +全局安装: + +```sh +$ npm i repl2 -g +``` + +使用方式很简单: + +1. 将常用的模块全局安装,如: + +```sh +$ npm i lodash validator moment -g +``` + +2. 添加配置到 ~/.noderc: + +```json +{ + "lodash": "__", + "moment": "moment", + "validator": "validator" +} +``` + +3. 运行 noder: + +```sh +$ 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 +``` + +有几点需要讲解下: + +1. ~/.noderc 是一个 JSON 文件,key 是模块名字,value 是 require 这个模块后加载到 REPL 中的变量名。这里给 lodash 命名的变量名是 __ 而不是 _,是因为 REPL 中 _ 有特殊含义,表示上一个表达式的结果 +2. repl2 会优先加载当前目录下的模块,没有找到然后再去加载全局安装的模块。上面结果显示 lodash 是从本地目录加载的,因为 test 目录下已经安装了 lodash,其余的模块没有从本地目录找到则尝试从全局 npm 目录加载。如果都没有找到,则不会加载 + +## power-assert + +我们常用的断言库有: + +- [should.js](https://github.com/shouldjs/should.js) +- [expect.js](https://github.com/Automattic/expect.js) +- [chai](https://github.com/chaijs/chai) + +但这类断言库都有一些通病: + +1. 过分追求语义化,API 复杂 +2. 错误信息不足 + +看一段代码: + +**test.js** + +```js +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) + }) +}) +``` + +运行: + +```sh +$ mocha +``` + +结果如下: + +```js +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,然后运行测试: + +```sh +$ npm i power-assert intelli-espower-loader --save-dev +$ mocha -r intelli-espower-loader +``` + +结果如下: + +```js +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 + ... +``` + +错误信息非常直观,有两点需要说明下: + +1. mocha 需要引入 intelli-espower-loader,主要是转译代码,转译之后 `require('assert')` 都不需要改 +2. intelli-espower-loader 可选地在 package.json 添加 directories.test 配置,如: + +```json +"directories": { + "test": "mytest/" +} +``` + +如果没有 directories.test 配置则默认是 `test/`。 + +## 参考链接 + +- https://zhuanlan.zhihu.com/p/25956323 +- https://www.npmjs.com/package/intelli-espower-loader + +上一节:[4.3 Visual Studio Code](https://github.com/nswbmw/node-in-debugging/blob/master/4.3%20Visual%20Studio%20Code.md) + +下一节:[4.5 supervisor-hot-reload](https://github.com/nswbmw/node-in-debugging/blob/master/4.5%20supervisor-hot-reload.md) diff --git a/4.5 supervisor-hot-reload.md b/4.5 supervisor-hot-reload.md new file mode 100644 index 0000000..f986577 --- /dev/null +++ b/4.5 supervisor-hot-reload.md @@ -0,0 +1,331 @@ +我们在本地开发 Node.js 程序时通常会使用 [nodemon](https://github.com/remy/nodemon) 或者 [supervisor](https://github.com/petruisfan/node-supervisor) 这种进程管理工具,当有文件修改时自动重启应用。小项目还好,项目大了每次重启应用都用几秒到十几秒的时间,大部分时间花在了加载及编译代码上。 + +这让我联想到前端比较火的一个名词——Hot Reload(热加载),比如 React 静态资源的热加载通过 webpack-dev-server 和 react-hot-loader 实现,webpack-dev-server 负责重新编译代码,react-hot-loader 负责热加载。 + +那在 Node.js 应用中,如何实现 Hot Reload 呢?最好能实现不重启应用使新代码生效。幸好 ES6 引入了一个黑(xin)魔(te)法(xing)——Proxy,Proxy 用于修改对象的默认行为,等同于在语言层面做出修改,属于一种 “元编程”。Proxy 在要访问的对象之前架设一层拦截,要访问该对象成员必须先经过这层拦截。示例代码: + +```js +const obj = new Proxy({}, { + get: function (target, key) { + console.log(`getting ${key}!`) + return 'haha' + } +}) + +console.log(obj.name) +// getting name! +// haha +console.log(obj.age) +// getting age! +// haha +``` + +**可以看出**:我们并没有在 obj 上定义 name 和 age 属性,所有获取 obj 上属性都会执行 get 方法然后打印 getting xxx! 和返回 haha。 + +这里 Proxy 的第一个参数是一个空对象,也可以是一个其他的对象,比如函数(毕竟 js 中函数也是对象)。 + +```js +function user () {} + +const obj = new Proxy(user, { + get: function (target, key) { + console.log(`getting ${key}!`) + return 'haha' + } +}) + +console.log(user.name) +// user +console.log(user.age) +// undefined +console.log(obj.name) +// getting name! +// haha +console.log(obj.age) +// getting age! +// haha +new Proxy(1, {}) +// TypeError: Cannot create proxy with a non-object as target or handler +``` + +## Proxy 实现 Hot Reload + +**核心原理**:使用 Proxy 将模块导出的对象包装一层『代理』,即 module.exports 导出的是一个 Proxy 实例,定义一个 get 方法,使得获取实例上的属性其实是去获取最新的 require.cache 中的对象上的属性。同时,监听代码文件,如果有修改,则更新 require.cache。 + +**简言之**:我们获取对象属性时,中间加了一层代理,通过代理间接获取原有属性的值,如果属性值有更新会更新 require.cache 的缓存,那么下次获取对象属性时,通过代理将获取该属性最新的值。可见,Proxy 可以实现属性访问拦截,也可实现断开 "强引用" 的作用。 + +笔者发布了一个 [proxy-hot-reload](https://www.npmjs.com/package/proxy-hot-reload) 模块,核心代码如下: + +```js +module.exports = function proxyHotReload(opts) { + const includes = [ ... ] + const excludes = [ ... ] + const filenames = _.difference(includes, excludes) + + chokidar + .watch(filenames, { + usePolling: true + }) + .on('change', (path) => { + try { + if (require.cache[path]) { + const _exports = require.cache[path].exports + if (_.isPlainObject(_exports) && !_.isEmpty(_exports)) { + delete require.cache[path] + require(path) + } + } + } catch (e) { ... } + }) + .on('error', (error) => console.error(error)) + + shimmer.wrap(Module.prototype, '_compile', function (__compile) { + return function proxyHotReloadCompile(content, filename) { + if (!_.includes(filenames, filename)) { + try { + return __compile.call(this, content, filename) + } catch (e) { ... } + } else { + const result = __compile.call(this, content, filename) + this._exports = this.exports + // non-object return original compiled code + if (!_.isPlainObject(this._exports)) { + return result + } + try { + this.exports = new Proxy(this._exports, { + get: function (target, key, receiver) { + try { + if (require.cache[filename]) { + return require.cache[filename]._exports[key] + } else { + return Reflect.get(target, key, receiver) + } + } catch (e) { ... } + } + }) + } catch (e) { ... } + } + } + }) +} +``` + +简单讲解一下: + +1. 可传入 includes 和 excludes 参数,支持 glob 写法,用来设置监听哪些代码文件 +2. 用 chokidar 模块监听文件,如有改动则重新加载该文件。这里只针对 module.exports 导出的是纯对象的模块有用,做这个限制的原因是:非对象比如函数,一般我们导出一个函数会直接调用执行而不是获取函数上的属性或方法,这种导出非纯对象模块即使重建缓存也不会生效,所以干脆忽略。幸运的是,module.exports 导出对象占了大多数场景。 +3. 用 shimmer 模块重载 Module.prototype._compile 方法,如果是被监听的文件并且导出的是纯对象,则尝试将导出的对象包装成 Proxy 实例。这样,获取该对象上的属性时,将从 require.cache 中读取最新的值。 + +使用示例: + +**user.js** + +```js +module.exports = { + id: 1, + name: 'nswbmw' +} +``` + +**app.js** + +```js +if (process.env.NODE_ENV !== 'production') { + require('proxy-hot-reload')({ + includes: '**/*.js' + }) +} + +const Paloma = require('paloma') +const app = new Paloma() +const user = require('./user') + +app.route({ method: 'GET', path: '/', controller (ctx) { + ctx.body = user +}}) + +app.listen(3000) +``` + +浏览器访问 localhost:3000 查看结果,修改 user.js 中字段的值,然后刷新浏览器查看结果。 + +proxy-hot-reload 有个非常明显的**缺点**:只支持对导出的是纯对象的文件做代理,而且程序入口文件不会生效,比如上面的 app.js,改个端口号只能重启才会生效。Proxy 再怎么黑魔法也只能做到这个地步了,退一步想,如果修改了 proxy-hot-reload 覆盖不到的文件(如 app.js)降级成自动重启就好了,如果 proxy-hot-reload 和 supervisor 结合会怎么样呢? + +## [supervisor-hot-reload](https://github.com/nswbmw/supervisor-hot-reload) + +如果要将 proxy-hot-reload 结合 supervisor,需要解决几个难点: + +1. 非侵入式。即代码里不再写: + +```js +if (process.env.NODE_ENV !== 'production') { + require('proxy-hot-reload')({ + includes: '**/*.js' + }) +} +``` + +2. 参数统一。supervisor 可接受 -w 参数表明监听哪些文件,-i 参数表明忽略哪些文件,这两个参数怎么跟 proxy-hot-reload 的 includes 和 excludes 参数整合 + +3. 职责分明。代码文件修改,优先尝试 proxy-hot-reload 的热更新,如果 proxy-hot-reload 热更新不了则使用 supervisor 重启 + +首先我们去看下 supervisor 的源码(lib/supervisor.js),源码中有这么一段: + +```js +var watchItems = watch.split(','); +watchItems.forEach(function (watchItem) { + watchItem = path.resolve(watchItem); + + if ( ! ignoredPaths[watchItem] ) { + log("Watching directory '" + watchItem + "' for changes."); + if(interactive) { + log("Press rs for restarting the process."); + } + findAllWatchFiles(watchItem, function(f) { + watchGivenFile( f, poll_interval ); + }); + } +}); +``` + +这段代码的作用是:遍历找到所有需要监听的文件,然后调用 watchGivenFile 监听文件。watchGivenFile 代码如下: + +```js +function watchGivenFile (watch, poll_interval) { + if (isWindowsWithoutWatchFile || forceWatchFlag) { + fs.watch(watch, { persistent: true, interval: poll_interval }, crashWin); + } else { + fs.watchFile(watch, { persistent: true, interval: poll_interval }, function(oldStat, newStat) { + // we only care about modification time, not access time. + if ( newStat.mtime.getTime() !== oldStat.mtime.getTime() ) { + if (verbose) { + log("file changed: " + watch); + } + } + crash(); + }); + } + if (verbose) { + log("watching file '" + watch + "'"); + } +} +``` + +watchGivenFile 的作用是:用 fs.watch/fs.watchFile 监听文件,如果有改动则调用 crashWin/crash 程序退出。supervisor 用 child_process.spawn 将程序运行在子进程,子进程退出后会被 supervisor 重新启动。相关代码如下: + +```js +function startProgram (prog, exec) { + var child = exports.child = spawn(exec, prog, {stdio: 'inherit'}); + ... + child.addListener("exit", function (code) { + ... + startProgram(prog, exec); + }); +} +``` + +大体理清 supervisor 的关键源码后,我们就知道如何解决上面提到的几个难点了。 + +首先需要修改 proxy-hot-reload,添加几个功能: + +1. 添加 includeFiles 和 excludeFiles 选项,值为数组,用来接受 supervisor 传来的文件列表 +2. 添加 watchedFileChangedButNotReloadCache 参数。proxy-hot-reload 可以知道哪些代码文件可以热更新,哪些不可以。当监听到不能热更新的文件有修改时,调用这个 watchedFileChangedButNotReloadCache 函数,这个函数里有 process.exit() 让进程退出 + +难点及解决方案: + +1. 非侵入式。因为真正的程序试运行在 supervisor 创建的子进程中,所以我们无法在 supervisor 进程中引入 proxy-hot-reload,只能通过子进程用 `node -r xxx` 提前引入并覆盖 Module.prototype._compile。解决方案:将 supervisor 需要监听的文件数组(watchFiles)和 proxy-hot-reload 配置写到一个文件(如:proxy-hot-reload.js)里,子进程通过 `node -r proxy-hot-reload.js app.js` 预加载此文件启动。 + +supervisor 相关代码: + +```js +// 获取 watchFiles +fs.writeFileSync(path.join(__dirname, 'proxy-hot-reload.js'), ` + require('${path.join(__dirname, "..", "node_modules", "proxy-hot-reload")}')({ + includeFiles: ${JSON.stringify(watchFiles)}, + excludeFiles: [], + watchedFileChangedButNotReloadCache: function (filename) { + console.log(filename + ' changed, restarting...'); + setTimeout(function () { + process.exit(); + }, ${poll_interval}); + } +});`); +// startChildProcess() +``` + +2. 参数统一。将上面 watchItems.forEach 内异步遍历需要监听的文件列表修改为同步,代码如下: + +```js +var watchFiles = [] +var watchItems = watch.split(','); +watchItems.forEach(function (watchItem) { + watchItem = path.resolve(watchItem); + if ( ! ignoredPaths[watchItem] ) { + log("Watching directory '" + watchItem + "' for changes."); + if(interactive) { + log("Press rs for restarting the process."); + } + findAllWatchFiles(watchItem, function(f) { + watchFiles.push(f) + // watchGivenFile( f, poll_interval ); + }); + } +}); +``` + +**注意:**这里 findAllWatchFiles 虽然有回调函数,但是同步的。将 findAllWatchFiles 内的 fs.lstat/fs.stat/fs.readdir 分别改为 fs.lstatSync/fs.statSync/fs.readdirSync,这里就不贴代码了。 + +3. 职责分明。子进程使用 `node -r proxy-hot-reload.js app.js` 启动后,能热更新的则热更新,不能热更新的执行 watchedFileChangedButNotReloadCache,子进程退出,supervisor 会启动一个新的子进程,实现了职责分明。 + +我将改进后的 supervisor 发布成一个新的包——[supervisor-hot-reload](https://github.com/nswbmw/supervisor-hot-reload) 。使用方法: + +**user.js** + +```js +module.exports = { + id: 1, + name: 'nswbmw' +} +``` + +**app.js** + +```js +const Paloma = require('paloma') +const app = new Paloma() +const user = require('./user') + +app.route({ method: 'GET', path: '/', controller (ctx) { + ctx.body = user +}}) + +app.listen(3000) +``` + +全局安装并使用 supervisor-hot-reload: + +```sh +$ npm i supervisor-hot-reload -g +$ DEBUG=proxy-hot-reload supervisor-hot-reload app.js +``` + +修改 user.js,程序不会重启,打印: + +``` +proxy-hot-reload Reload file: /Users/nswbmw/Desktop/test/user.js +``` + +修改 app.js,程序会重启,打印: + +``` +/Users/nswbmw/Desktop/test/app.js changed, restarting... +Program node app.js exited with code 0 + +Starting child process with 'node app.js' +... +``` + +上一节:[4.4 debug + repl2 + power-assert](https://github.com/nswbmw/node-in-debugging/blob/master/4.4%20debug%20%2B%20repl2%20%2B%20power-assert.md) + +下一节:[5.1 NewRelic](https://github.com/nswbmw/node-in-debugging/blob/master/5.1%20NewRelic.md) diff --git a/5.1 NewRelic.md b/5.1 NewRelic.md new file mode 100644 index 0000000..9805075 --- /dev/null +++ b/5.1 NewRelic.md @@ -0,0 +1,93 @@ +[NewRelic](https://newrelic.com/) 是一个老牌的应用性能监测工具,提供 14 天免费试用,本文将讲解如何使用 NewRelic 监控代码的性能。 + +测试代码: + +**app.js** + +```js +require('newrelic') + +const crypto = require('crypto') +const express = require('express') +const app = express() +const createUser = require('./routes/users').createUser + +app.get('/', (req, res) => { + const salt = crypto.randomBytes(128).toString('base64') + const hash = crypto.pbkdf2Sync(String(Math.random()), salt, 10000, 512, 'sha512').toString('hex') + res.json({ salt, hash }) +}) + +app.get('/error', (req, res, next) => { + next(new Error('error!!!')) +}) + +app.post('/users/:user', async (req, res) => { + const user = await createUser(req.params.user, 18) + res.json(user) +}) + +app.listen(3000) +``` + +**routes/users.js** + +```js +const Mongolass = require('mongolass') +const mongolass = new Mongolass('mongodb://localhost:27017/test') +const User = mongolass.model('User') + +exports.createUser = async function (ctx) { + const name = ctx.query.name || 'default' + const age = +ctx.query.age || 18 + const user = await createUser(name, age) + ctx.status = user +} + +async function createUser (name, age) { + const user = (await User.create({ + name, + age + })).ops[0] + return user +} +``` + +## 使用 NewRelic + +首先,注册一个 [NewRelic](https://newrelic.com/signup) 账号。创建一个应用,如下所示: + +![](./assets/5.1.1.png) + +选择 APM,进入下一步,选择 Node.js 应用,并拿到 license key: + +![](./assets/5.1.2.png) + +在 Node.js 中使用 NewRelic 步骤如下: + +```sh +$ npm i newrelic --save # 安装 NewRelic 的 Node.js SDK +$ cp node_modules/newrelic/newrelic.js . # 将默认配置文件拷贝到根目录下 +``` + +修改 newrelic.js,app_name 填写你的应用名(如:api),license_key 填写刚才生成的 license key。 + +启动测试程序,并发起几个请求,稍等几分钟,NewRelic 的后台将会收到并展示一些数据(如:吞吐量,请求的 Urls,错误率、Apdex score 等等): + +![](./assets/5.1.3.png) + +试用版功能有限,升级到付费版可使用更多功能,如:数据库分析、错误分析甚至 Node.js VM 监控(如:CPU、内存、GC、Event Loop)等等。 + +类似的其他 APM 有: + +- [AppDynamics](https://www.appdynamics.com/) +- [OneAPM](oneapm.com) +- [DataDog](https://www.datadoghq.com/monitor-nodejs/) +- [atatus](https://www.atatus.com/) +- [opbeat](https://opbeat.com/nodejs ) + +用法大同小异,这里就不一一介绍了。 + +上一节:[4.5 supervisor-hot-reload](https://github.com/nswbmw/node-in-debugging/blob/master/4.5%20supervisor-hot-reload.md) + +下一节:[5.2 Elastic APM](https://github.com/nswbmw/node-in-debugging/blob/master/5.2%20Elastic%20APM.md) diff --git a/5.2 Elastic APM.md b/5.2 Elastic APM.md new file mode 100644 index 0000000..6fe64df --- /dev/null +++ b/5.2 Elastic APM.md @@ -0,0 +1,127 @@ +Elastic APM 是 Elastic 公司开源的一款 APM 工具,目前还处于 Beta 阶段,它有以下几个优势: + +1. 开源。你可以免费使用,像使用 ELK 一样。 +2. 功能完善。API 比较完善,有 Agent、Transaction、Trace,默认创建响应时间和每分钟请求数两种图表,且可以使用 Kibana 的 Filter 过滤生成关心的数据的图表。 +3. 监控与日志统一。Elastic APM 依赖 ElasticSearch + Kibana,所以可以结合 ELK 使用,可在 Kibana 查看监控然后直接查询日志。 + +Elastic APM 架构如下: + +![](./assets/5.2.1.png) + +**可以看出**:APM Agent(即在应用端引入的探针)将收集的日志发送到 APM Server(Go 写的 HTTP 服务),APM Server 将数据存储到 ElasticSearch,然后通过 Kibana 展示。 + +Kibana 展示如下: + +![](./assets/5.2.2.png) + +## 安装并启动 ELK + +我们使用 docker 安装并启动 ELK,运行如下命令: + +```sh +$ docker run -p 5601:5601 \ + -p 9200:9200 \ + -p 5044:5044 \ + -it --name elk sebp/elk +``` + +## 安装并启动 APM Server + +首先,下载 [APM Server](https://www.elastic.co/downloads/apm/apm-server) 解压。然后运行以下命令: + +```sh +$ ./apm-server setup # 导入 APM 仪表盘到 Kibana +$ ./apm-server -e # 启动 APM Server,默认监听 8200 端口 +``` + +浏览器打开 localhost:5601,进入 Dashboard 页,如下所示: + +![](./assets/5.2.3.png) + +## 测试代码 + +```js +const apm = require('elastic-apm-node').start({ + appName: 'test' +}) + +const Paloma = require('paloma') +const app = new Paloma() + +app.route({ method: 'GET', path: '/', controller (ctx) { + apm.setTransactionName(`${ctx.method} ${ctx._matchedRoute}`) + ctx.status = 200 +}}) + +app.route({ method: 'GET', path: '/:name', controller (ctx) { + apm.setTransactionName(`${ctx.method} ${ctx._matchedRoute}`) + ctx.status = 200 +}}) + +app.listen(3000) +``` + +运行该程序,发起两个请求: + +```sh +$ curl localhost:3000/ +$ curl localhost:3000/nswbmw +``` + +等待一会,Kibana 展示如下: + +![](./assets/5.2.4.png) + +在 Elastic APM 中,有两个术语: + +- transaction:一组 traces 的集合,如:一个 HTTP 请求 +- trace:一个事件及持续时间,如:一个 SQL 查询 + +## 错误日志 + +我们来测试下 Elastic APM 的错误收集功能。修改测试代码为: + +```js +const apm = require('elastic-apm-node').start({ + appName: 'test' +}) + +const Paloma = require('paloma') +const app = new Paloma() + +app.use(async (ctx, next) => { + try { + await next() + } catch (e) { + apm.captureError(e) + ctx.status = 500 + ctx.message = e.message + } +}) + +app.route({ method: 'GET', path: '/', controller: function indexRouter (ctx) { + apm.setTransactionName(`${ctx.method} ${ctx._matchedRoute}`) + throw new Error('error!!!') +}}) + +app.listen(3000) +``` + +重启测试程序,并发起一次请求。回到 Kibana,点击 Dashboard -> [APM] Errors 可以看到错误日志记录(自动聚合)和图表,如下所示: + +![](./assets/5.2.5.png) + +点击 View Error Details 进入错误详情页,如下所示: + +![](./assets/5.2.6.png) + +**可以看出**:错误日志中展示了错误代码及行数,上下几行代码,父级函数名和所在文件等信息。 + +## 参考链接 + +- https://www.elastic.co/guide/en/apm/agent/nodejs/0.x/index.html +- https://www.elastic.co/guide/en/apm/agent/nodejs/0.x/custom-stack.html + +上一节:[5.1 NewRelic](https://github.com/nswbmw/node-in-debugging/blob/master/5.1%20NewRelic.md) + +下一节:[6.1 koa-await-breakpoint](https://github.com/nswbmw/node-in-debugging/blob/master/6.1%20koa-await-breakpoint.md) diff --git a/6.1 koa-await-breakpoint.md b/6.1 koa-await-breakpoint.md new file mode 100644 index 0000000..a535ed3 --- /dev/null +++ b/6.1 koa-await-breakpoint.md @@ -0,0 +1,279 @@ +日志打点一直是个调试最头疼的问题。如果直接在代码中插入埋点代码不仅侵入性强而且工作量大也不够灵活,如果能做到智能打点就好了,koa-await-breakpoint 正是你需要的。 + +## [koa-await-breakpoint](https://github.com/shimohq/koa-await-breakpoint) + +koa-await-breakpoint 是一个 Koa 的中间件,是一个在 routes/controllers 里(作用域包含 ctx)的 await 表达式前后自动打点的工具,不用插入一行日志打点代码,只需要在引入的时候配置一下,可以记录每个请求到来时 await 表达式前后的现场,如: + +1. await 表达式所在的文件及行列号(filename) +2. await 表达式是执行的第几步(step) +3. await 表达式字符串形式(fn) +4. 执行 await 表达式所花费的毫秒(take) +5. 执行 await 表达式的结果(result) +6. 当前请求的 ctx + +使用方法: + +```js +// On top of the main file +const koaAwaitBreakpoint = require('koa-await-breakpoint')({ + name: 'api', + files: ['./routes/*.js'] +}) + +const Koa = require('koa') +const app = new Koa() + +// Generally, above other middlewares +app.use(koaAwaitBreakpoint) +... + +app.listen(3000) +``` + +## 实现原理 + +1. 重载 Module.prototype._compile,相当于 hack 了 require,如果发现是 require 了配置里指定的文件,则进行下一步,否则返回原始代码内容,相关源代码如下: + ```js + shimmer.wrap(Module.prototype, '_compile', function (__compile) { + return function koaBreakpointCompile(content, filename) { + if (!_.includes(filenames, filename)) { + return __compile.call(this, content, filename); + } + ... + }; + }); + ``` + +2. 用 esprima 解析代码,生成 AST。如: + ```js + const Mongolass = require('mongolass') + const mongolass = new Mongolass('mongodb://localhost:27017/test') + const User = mongolass.model('users') + + exports.getUsers = async function getUsers(ctx) { + await User.create({ + name: 'xx', + age: 18 + }) + const users = await User.find() + return users + } + ``` + 会生成如下 AST,只截取了 `await User.create(...)` 相关的 AST: + + ```js + Script { + ... + AwaitExpression { + type: 'AwaitExpression', + argument: + CallExpression { + type: 'CallExpression', + callee: + StaticMemberExpression { + type: 'MemberExpression', + computed: false, + object: + Identifier { + type: 'Identifier', + name: 'User', + loc: { start: { line: 6, column: 10 }, end: { line: 6, column: 14 } } }, + property: + Identifier { + type: 'Identifier', + name: 'create', + loc: { start: { line: 6, column: 15 }, end: { line: 6, column: 21 } } }, + loc: { start: { line: 6, column: 10 }, end: { line: 6, column: 21 } } }, + arguments: + [ ObjectExpression { + type: 'ObjectExpression', + properties: + [ Property { + type: 'Property', + key: + Identifier { + type: 'Identifier', + name: 'name', + loc: { start: { line: 7, column: 6 }, end: { line: 7, column: 10 } } }, + computed: false, + value: + Literal { + type: 'Literal', + value: 'xx', + raw: '\'xx\'', + loc: { start: { line: 7, column: 12 }, end: { line: 7, column: 16 } } }, + kind: 'init', + method: false, + shorthand: false, + loc: { start: { line: 7, column: 6 }, end: { line: 7, column: 16 } } }, + Property { + type: 'Property', + key: + Identifier { + type: 'Identifier', + name: 'age', + loc: { start: { line: 8, column: 6 }, end: { line: 8, column: 9 } } }, + computed: false, + value: + Literal { + type: 'Literal', + value: 18, + raw: '18', + loc: { start: { line: 8, column: 11 }, end: { line: 8, column: 13 } } }, + kind: 'init', + method: false, + shorthand: false, + loc: { start: { line: 8, column: 6 }, end: { line: 8, column: 13 } } } ], + loc: { start: { line: 6, column: 22 }, end: { line: 9, column: 5 } } } ], + loc: { start: { line: 6, column: 10 }, end: { line: 9, column: 6 } } }, + loc: { start: { line: 6, column: 4 }, end: { line: 9, column: 6 } } }, + ... + ``` + +3. 遍历找到 awaitExpression 节点,进行以下包装后生成 AST 替换掉原来的节点。 + ```js + global.logger( + (typeof ctx !== 'undefined' ? ctx : this), + function(){ + return awaitExpression + }, + awaitExpressionString, + filename + ) + ``` + 相关源代码如下: + ```js + findAwaitAndWrapLogger(parsedCodes) + try { + content = escodegen.generate(parsedCodes, { + format: { indent: { style: ' ' } }, + sourceMap: filename, + sourceMapWithCode: true + }) + } catch (e) { + console.error('cannot generate code for file: %s', filename) + console.error(e.stack) + process.exit(1) + } + debug('file %s regenerate codes:\n%s', filename, content.code) + ``` + findAwaitAndWrapLogger 作用就是遍历 AST 将 awaitExpression 替换成用日志函数包裹后新的 awaitExpression 的 AST。最后用 escodegen 将 AST 生成代码(支持 soucemap,所以错误栈对应的行数是正确的)。 + +**核心**:每个请求到来时,生成一个 requestId(可自定义,默认 uuid)挂载到 ctx 上,这样就可以通过 requestId 将日志串起来了。 + +**特点**:可以记录每个请求的每一步(await 表达式)的现场及返回值,方便查日志。 + +## 举个栗子 + +**app.js** + +```js +const koaAwaitBreakpoint = require('koa-await-breakpoint')({ + name: 'api', + files: ['./routes/*.js'] +}) + +const Paloma = require('paloma') +const app = new Paloma() +const userRouter = require('./routes/user') + +app.use(koaAwaitBreakpoint) +app.route({ method: 'GET', path: '/users', controller: userRouter.getUsers }) + +app.listen(3000) +``` + +**routes/user.js** + +```js +const Mongolass = require('mongolass') +const mongolass = new Mongolass('mongodb://localhost:27017/test') +const User = mongolass.model('users') + +exports.getUsers = async function getUsers (ctx) { + await User.create({ + name: 'xx', + age: 18 + }) + + const users = await User.find() + ctx.body = users +} +``` + +运行: + +```sh +$ DEBUG=koa-await-breakpoint node app.js +``` + +终端打印出转换后的代码,routes/users.js 被转换成了: + +```js +const Mongolass = require('mongolass'); +const mongolass = new Mongolass('mongodb://localhost:27017/test'); +const User = mongolass.model('users'); +exports.getUsers = async function getUsers(ctx) { + await global.logger(typeof ctx !== 'undefined' ? ctx : this, function () { + return User.create({ + name: 'xx', + age: 18 + }); + }, 'User.create({\n name: \'xx\',\n age: 18\n})', '/Users/nswbmw/Desktop/test/routes/user.js:6:2'); + const users = await global.logger(typeof ctx !== 'undefined' ? ctx : this, function () { + return User.find(); + }, 'User.find()', '/Users/nswbmw/Desktop/test/routes/user.js:11:16'); + ctx.body = users; +}; +``` + +访问 localhost:3000/users,终端打印出: + +```js +{"name":"api","requestId":"50dbda0c-9e13-4659-acce-b237bc5178b7","timestamp":"2018-02-26T06:31:31.100Z","this":...,"type":"start","step":1,"take":0} +{"name":"api","requestId":"50dbda0c-9e13-4659-acce-b237bc5178b7","step":2,"filename":"/Users/nswbmw/Desktop/test/routes/user.js:6:2","timestamp":"2018-02-26T06:31:31.104Z","this":...,"type":"beforeAwait","fn":"User.create({\n name: 'xx',\n age: 18\n})","take":4} +{"name":"api","requestId":"50dbda0c-9e13-4659-acce-b237bc5178b7","step":3,"filename":"/Users/nswbmw/Desktop/test/routes/user.js:6:2","timestamp":"2018-02-26T06:31:31.175Z","this":...,"type":"afterAwait","fn":"User.create({\n name: 'xx',\n age: 18\n})","result":{"result":{"ok":1,"n":1},"ops":[{"name":"xx","age":18,"_id":"5a93a9c3cf8c8797c9b47482"}],"insertedCount":1,"insertedIds":["5a93a9c3cf8c8797c9b47482"]},"take":71} +{"name":"api","requestId":"50dbda0c-9e13-4659-acce-b237bc5178b7","step":4,"filename":"/Users/nswbmw/Desktop/test/routes/user.js:11:16","timestamp":"2018-02-26T06:31:31.175Z","this":...,"type":"beforeAwait","fn":"User.find()","take":0} +{"name":"api","requestId":"50dbda0c-9e13-4659-acce-b237bc5178b7","step":5,"filename":"/Users/nswbmw/Desktop/test/routes/user.js:11:16","timestamp":"2018-02-26T06:31:31.180Z","this":...,"type":"afterAwait","fn":"User.find()","result":[{"_id":"5a93a9c3cf8c8797c9b47482","name":"xx","age":18}],"take":5} +{"name":"api","requestId":"50dbda0c-9e13-4659-acce-b237bc5178b7","timestamp":"2018-02-26T06:31:31.181Z","this":...,"type":"end","step":6,"take":1} +``` + +**注意**:type 是以下其中一种,take 单位是 ms。 + +1. start:请求到来时第一次打点 +2. beforeAwait:上一个 awaitExpression 之后到这一个 awaitExpression 之前 +3. afterAwait:这个 awaitExpression 开始到结束 +4. error:错误日志,包含了错误信息 +5. end:请求结束时打点 + +## koa-await-breakpoint 自定义日志存储 + +store 参数最好自己定义(默认打印日志到 stdout),该参数是一个对象并且有一个 save 方法即可。在 save 方法内可做一些逻辑修改或者日志策略,比如: + +1. 添加日志标识(如:name)方便区分不同服务的日志 +2. 错误日志时,添加一些额外字段方便追踪现场 +3. 将日志发送到 Logstash 或其他日志服务 +4. 限制日志频率,比如:只有响应时间大于 500ms 的请求日志才会记录 + +**koa_await_breakpoint_store.js** + +```js +exports.save = function save(record, ctx) { + record.name = 'app name' + record.env = process.env.NODE_ENV + + if (record.error) { + record.error = { + message: record.error.message, + stack: record.error.stack, + status: record.error.status || record.error.statusCode || 500 + } + } + ... + logstash.send(record) +} +``` + +上一节:[5.2 Elastic APM](https://github.com/nswbmw/node-in-debugging/blob/master/5.2%20Elastic%20APM.md) + +下一节:[6.2 async_hooks](https://github.com/nswbmw/node-in-debugging/blob/master/6.2%20async_hooks.md) diff --git a/6.2 async_hooks.md b/6.2 async_hooks.md new file mode 100644 index 0000000..d8ce973 --- /dev/null +++ b/6.2 async_hooks.md @@ -0,0 +1,321 @@ +上一小节讲解了 koa-await-breakpoint 的用法,但 koa-await-breakpoint 仍然有一个很大的缺憾,即:无法记录除 routes/controllers 之外的函数的执行时间(因为获取不到当前请求的 ctx)。举个更通俗的例子:我在一个路由的 controller 里面调用了 A ,A 调用了其他文件的 B ,B 又调用了其他文件的 C...这是非常常见的用法,但之前使用 koa-await-breakpoint 只能获取 A 的执行时间,无法获取 B 和 C 的执行时间。 + +**根本原因在于**:无法知道函数之间的调用关系,即 B 不知道是 A 调用的它,即便知道也不知道是哪次请求到来时执行的 A 调用的它。 + +但是,node@8.1 引入了一个黑魔法——Async Hooks。 + +## Async Hooks + +我们先看下 async_hooks 是个什么东西。官网介绍: + +> The `async_hooks` module provides an API to register callbacks tracking the lifetime of asynchronous resources created inside a Node.js application. + +**一句话概括**:async_hooks 用来追踪 Node.js 中异步资源的生命周期。 + +我们来看段测试代码: + +```js +const fs = require('fs') +const async_hooks = require('async_hooks') + +async_hooks.createHook({ + init (asyncId, type, triggerAsyncId, resource) { + fs.writeSync(1, `${type}(${asyncId}): trigger: ${triggerAsyncId}\n`) + }, + destroy (asyncId) { + fs.writeSync(1, `destroy: ${asyncId}\n`); + } +}).enable() + +async function A () { + fs.writeSync(1, `A -> ${async_hooks.executionAsyncId()}\n`) + setTimeout(() => { + fs.writeSync(1, `A in setTimeout -> ${async_hooks.executionAsyncId()}\n`) + B() + }) +} + +async function B () { + fs.writeSync(1, `B -> ${async_hooks.executionAsyncId()}\n`) + process.nextTick(() => { + fs.writeSync(1, `B in process.nextTick -> ${async_hooks.executionAsyncId()}\n`) + C() + C() + }) +} + +function C () { + fs.writeSync(1, `C -> ${async_hooks.executionAsyncId()}\n`) + Promise.resolve().then(() => { + fs.writeSync(1, `C in promise.then -> ${async_hooks.executionAsyncId()}\n`) + }) +} + +fs.writeSync(1, `top level -> ${async_hooks.executionAsyncId()}\n`) +A() +``` + +async_hooks.createHook 可以注册 4 个方法来跟踪所有异步资源的初始化(init),回调之前(before),回调之后(after),销毁后(destroy)事件,并通过调用 .enable() 启用,调用 .disable() 关闭。 + +这里我们只关心异步资源的初始化和销毁的事件,并使用 `fs.writeSync(1, msg)` 打印到标准输出,writeSync 第一个参数接收文件描述符,1 表示标准输出。为什么不使用 console.log 呢?因为 console.log 是一个异步操作,如果在 init, before, after, destroy 事件处理函数中出现,就会导致无限循环,同理也不能使用任何其他的异步操作。 + +运行该程序,打印如下: + +``` +top level -> 1 +PROMISE(6): trigger: 1 +A -> 1 +Timeout(7): trigger: 1 +TIMERWRAP(8): trigger: 1 +A in setTimeout -> 7 +PROMISE(9): trigger: 7 +B -> 7 +TickObject(10): trigger: 7 +B in process.nextTick -> 10 +C -> 10 +PROMISE(11): trigger: 10 +PROMISE(12): trigger: 11 +C -> 10 +PROMISE(13): trigger: 10 +PROMISE(14): trigger: 13 +C in promise.then -> 12 +C in promise.then -> 14 +destroy: 7 +destroy: 10 +destroy: 8 +``` + +这段程序的打印结果包含了很多信息,我们逐个解释: + +1. 为了实现对异步资源的跟踪,node 对每一个函数(不论异步还是同步)提供了一个 async scope,我们可以通过调用 `async_hooks.executionAsyncId()` 来获取函数当前的 async scope 的 id(称为 asyncId),通过调用 `async_hooks.triggerAsyncId()` 来获取当前函数调用者的 asyncId。 +2. 异步资源创建时触发 init 事件函数,init 函数中的第一个参数代表该异步资源的 asyncId,type 表示异步资源的类型(如 TCPWRAP, PROMISE, Timeout, Immediate, TickObject 等等),triggerAsyncId 表示该异步资源的调用者的 asyncId。异步资源销毁时触发 destroy 事件函数,该函数只接收一个参数即该异步资源的 asyncId。 +3. 函数调用关系明确。从上面的打印结果我们可以很容易看出(从下往上看) :C(asyncId: 10) 被 B(asyncId: 7) 调用,B(asyncId: 7) 被 A(asyncId: 1) 调用。而且 C 的 promise.then 里面的 asyncId(值为 12/14) 也可以通过 12/14 -> 11/13 -> 10 定位到 C 的 asyncId(值为 10)。 +4. 同步函数每次调用的 asyncId 都一样,如上 C 调用了 2 次,都打印的 C -> 10,与调用方的作用域的 asyncId 一致,即上面打印的 B in process.nextTick -> 10。异步函数每次调用的 asyncId 不一样,如上面打印的 C in promise.then -> 12 和 C in promise.then -> 14。 +5. 最外层作用域的 asyncId 总是 1,每个异步资源创建时 asyncId 全局递增。 + +上面 5 条结论非常重要。接下来我们看看如何使用 async_hooks 改造 koa-await-breakpoint。 + +## 改造 koa-await-breakpoint + +通过前面结论我们知道,使用 async_hooks 可以通过 asyncId 串起函数的调用关系,但是如何将这些函数的调用链与 koa 接收的每个请求关联起来呢? + +首先,定义一个全局 Map,存储函数的调用关系: + +```js +const async_hooks = require('async_hooks') +const asyncIdMap = new Map() + +async_hooks.createHook({ + init (asyncId, type, triggerAsyncId) { + const ctx = getCtx(triggerAsyncId) + if (ctx) { + asyncIdMap.set(asyncId, ctx) + } else { + asyncIdMap.set(asyncId, triggerAsyncId) + } + }, + destroy (asyncId) { + asyncIdMap.delete(asyncId) + } +}).enable() + +function getCtx (asyncId) { + if (!asyncId) { + return + } + if (typeof asyncId === 'object' && asyncId.app) { + return asyncId + } + return getCtx(asyncIdMap.get(asyncId)) +} +``` + +有 3 点需要解释: + +1. 定义了一个全局 Map 存储函数的调用关系,在适当的地方(下面会讲到)将当前请求的 ctx 存储到 Map 中,key 是 asyncId。 +2. 每个异步资源初始化时,会拿 asyncId 尝试往上寻找祖先的 value 是否是 ctx(koa 应用中每个请求的 ctx),如果有则直接设置 value 是 ctx,否则设置为调用者的 asyncId(即 triggerAsyncId)。 +3. destroy 事件函数里直接删除调用关系,保证了不会引起内存泄漏,即杜绝了引用 ctx 没有释放的情况。 + +然后修改 global[loggerName] 如下: + +```js +global[loggerName] = async function (ctx, fn, fnStr, filename) { + const originalContext = ctx + let requestId = _getRequestId() + + const asyncId = async_hooks.executionAsyncId() + if (!requestId) { + const _ctx = getCtx(asyncId) + if (_ctx) { + ctx = _ctx + requestId = _getRequestId() + } + } else { + asyncIdMap.set(asyncId, ctx) + } + + if (requestId) { + _logger('beforeAwait') + } + const result = await fn.call(originalContext) + if (requestId) { + _logger('afterAwait', result) + } + return result + + function _getRequestId () { + return ctx && ctx.app && _.get(ctx, requestIdPath) + } + + function _logger (type, result) { + ... + } +} +``` + +有 2 点需要解释: + +1. logger 函数传入的第一个参数 ctx,之前是每个请求的 ctx,现在可能是当前执行上下文的 this,所以先将 ctx 赋值给 originalContext,然后通过 `await fn.call(originalContext)` 让函数执行时有正确的上下文。 +2. 如果传入的 ctx 是来自请求的 ctx 且能拿到 requestId,那么将当前 asyncId 和 ctx 写入 Map,如果不是来自请求的 ctx,则尝试从 Map 里往上寻找祖先的 value 是否是 ctx,如果找到则覆盖当前的 ctx 并拿到 requestId。 + +至此,koa-await-breakpoint 全部改造完毕。接下来我们通过一个例子验证下升级后的 koa-await-breakpoint: + +**app.js** + +```js +const koaAwaitBreakpoint = require('koa-await-breakpoint')({ + files: ['./routes/*.js'] +}) + +const Paloma = require('paloma') +const app = new Paloma() + +app.use(koaAwaitBreakpoint) +app.route({ method: 'POST', path: '/users', controller: require('./routes/user').createUser }) + +app.listen(3000) +``` + +**routes/users.js** + +```js +const Mongolass = require('mongolass') +const mongolass = new Mongolass('mongodb://localhost:27017/test') +const User = mongolass.model('User') +const Post = mongolass.model('Post') +const Comment = mongolass.model('Comment') + +exports.createUser = async function (ctx) { + const name = ctx.query.name || 'default' + const age = +ctx.query.age || 18 + await createUser(name, age) + ctx.status = 204 +} + +async function createUser (name, age) { + const user = (await User.create({ + name, + age + })).ops[0] + await createPost(user) +} + +async function createPost (user) { + const post = (await Post.create({ + uid: user._id, + title: 'post', + content: 'post' + })).ops[0] + + await createComment(user, post) +} + +async function createComment (user, post) { + await Comment.create({ + userId: user._id, + postId: post._id, + content: 'comment' + }) +} +``` + +这段代码的意思是:访问创建用户接口时,调用 createUser,createUser 里面又调用了 createPost,createPost 里面调用了 createComment。 + +```sh +$ curl -XPOST localhost:3000/users +``` + +打印如下: + +```js +{ type: 'start', + step: 1, + take: 0 ... } +{ type: 'beforeAwait', + step: 2, + fn: 'createUser(name, age)', + take: 1 ... } +{ type: 'beforeAwait', + step: 3, + fn: 'User.create(...)', + take: 1 ... } +{ type: 'afterAwait', + step: 4, + fn: 'User.create(...)', + take: 36 ... } +{ type: 'beforeAwait', + step: 5, + fn: 'createPost(user)', + take: 1 ... } +{ type: 'beforeAwait', + step: 6, + fn: 'Post.create(...)', + take: 0 ... } +{ type: 'afterAwait', + step: 7, + fn: 'Post.create(...)', + take: 3 ... } +{ type: 'beforeAwait', + step: 8, + fn: 'createComment(user, post)', + take: 1 ... } +{ type: 'beforeAwait', + step: 9, + fn: 'Comment.create(...)', + take: 0 ... } +{ type: 'afterAwait', + step: 10, + fn: 'Comment.create(...)', + take: 1 ... } +{ type: 'afterAwait', + step: 11, + fn: 'createComment(user, post)', + take: 1 ... } +{ type: 'afterAwait', + step: 12, + fn: 'createPost(user)', + take: 6 ... } +{ type: 'afterAwait', + step: 13, + fn: 'createUser(name, age)', + take: 44 ... } +{ type: 'end', + step: 14, + take: 0 ... } +``` + +至此,一个全链路、无侵入、强大的日志打点工具就完成了。 + +## 警告 + +使用 async_hooks 目前有较严重的性能损耗,见 [bmeurer/async-hooks-performance-impact](https://github.com/bmeurer/async-hooks-performance-impact),请慎重在生产环境中使用。 + +## 参考文献 + +- https://nodejs.org/dist/latest-v8.x/docs/api/async_hooks.html +- https://zhuanlan.zhihu.com/p/27394440 +- https://www.jianshu.com/p/4a568dac41ed + +上一节:[6.1 koa-await-breakpoint](https://github.com/nswbmw/node-in-debugging/blob/master/6.1%20koa-await-breakpoint.md) + +下一节:[6.3 ELK](https://github.com/nswbmw/node-in-debugging/blob/master/6.3%20ELK.md) diff --git a/6.3 ELK.md b/6.3 ELK.md new file mode 100644 index 0000000..51b7ade --- /dev/null +++ b/6.3 ELK.md @@ -0,0 +1,162 @@ +ELK 是 ElasticSearch + Logstash + Kibana 这套组合工具的简称。 + +- ElasticSearch:是一个开源的基于 Lucene 之上实现的一个分布式搜索引擎,也是一个存储引擎(如:日志),它的特点有:分布式,零配置,自动发现,索引自动分片,索引副本机制,restful 风格接口,多数据源,自动搜索负载等。 +- Logstash:开源的日志收集工具,它可以对日志进行收集、分析、过滤,并将其存储(如:ElasticSearch)供以后使用。 +- Kibana:开源和免费的可视化工具,可以为 ElasticSearch 提供的日志分析友好的 Web 界面,可以汇总、分析和搜索重要数据日志。 + +## 安装 ELK + +我们使用 docker 安装 ELK,运行如下命令: + +```sh +$ docker run -p 5601:5601 \ + -p 9200:9200 \ + -p 5044:5044 \ + -p 15044:15044/udp \ + -it --name elk sebp/elk +``` + +进入容器: + +```sh +$ docker exec -it elk /bin/bash +``` + +运行以下命令设置 logstash 的 input 和 output: + +```sh +# /opt/logstash/bin/logstash --path.data /tmp/logstash/data \ + -e 'input { udp { codec => "json" port => 15044 } } output { elasticsearch { hosts => ["localhost"] } }' +``` + +这里我们启动一个 15044 udp 端口,用来接收通过 UDP 发送到 Logstash 的日志。 + +浏览器打开 localhost:5601,如下所示: + +![](./assets/6.3.1.png) + +目前还没有指定 index(ElasticSearch 的 index 类似 MySQL/MongoDB 中的 database),即日志来源。下面我们尝试往 ELK 写入一些日志。 + +## 使用 ELK + +我们仍然使用 koa-await-breakpoint 为例,来演示如何将日志发送到 ELK。 + +**app.js** + +```js +const koaAwaitBreakpoint = require('koa-await-breakpoint')({ + name: 'api', + files: ['./routes/*.js'], + store: require('./logger') +}) + +const Paloma = require('paloma') +const app = new Paloma() + +app.use(koaAwaitBreakpoint) +app.route({ method: 'POST', path: '/users', controller: require('./routes/user').createUser }) + +app.listen(3000) +``` + +**logger.js** + +```js +const Logstash = require('logstash-client') + +const logstash = new Logstash({ + type: 'udp', + host: 'localhost', + port: 15044 +}) + +module.exports = { + save (log) { + if (log.error) { + log.errMsg = log.error.message + log.errStack = log.error.stack + } + logstash.send(log) + } +} +``` + +**routes/user.js** + +```js +const Mongolass = require('mongolass') +const mongolass = new Mongolass('mongodb://localhost:27017/test') +const User = mongolass.model('User') +const Post = mongolass.model('Post') +const Comment = mongolass.model('Comment') + +exports.createUser = async function (ctx) { + const name = ctx.query.name || 'default' + const age = +ctx.query.age || 18 + await createUser(name, age) + ctx.status = 204 +} + +async function createUser (name, age) { + const user = (await User.create({ + name, + age + })).ops[0] + await createPost(user) +} + +async function createPost (user) { + const post = (await Post.create({ + uid: user._id, + title: 'post', + content: 'post' + })).ops[0] + + await createComment(user, post) +} + +async function createComment (user, post) { + await Comment.create({ + userId: user._id, + postId: post._id, + content: 'comment' + }) +} +``` + +运行: + +```sh +$ curl -XPOST localhost:3000/users +``` + +此时,刷新 Kibana 如下所示: + +![](./assets/6.3.2.png) + +初次使用需要配置 Kibana 从 ElasticSearch 的哪些 index 搜索日志,我们在 `Index pattern` 处填 `logstash-*`,然后点击 Next step。下一步的 `Time Filter field name` 选择 timestamp 点击 Create index pattern 完成配置。 + +**注意**:我们选择 timestamp 而不是默认的 @timestamp,因为 koa-await-breakpoint 的日志中有 timestamp 字段。 + +点击左侧目录 Discover,我们发现已经有日志了。分别点击左侧出现的 Available Fields 的 fn、type、step、take,然后按 step 升序展示,如下所示: + +![](./assets/6.3.3.png) + +是不是一目了然!每个请求每一步的函数及其执行时间都记录下来了。 + +修改 routes/users.js 的 createComment,throw 一个 `new Error('test')`。重启程序并发起一个请求,ELK 显示如下: + +![](./assets/6.3.4.png) + +**Tips**: 实际应用中会有非常多的日志,我们可以通过 requestId 找到一个请求的所有日志,第 7.2 小节会讲解。 + +ELK 非常强大,基本能满足所有日志查询需求,查询语法使用 lucene 语法,花个 10 分钟就能大体上手。Kibana 还能创建各种仪表盘和聚合图表,还请读者自己动手尝试。 + +## 参考文献 + +- http://blog.51cto.com/baidu/1676798 +- http://elk-docker.readthedocs.io + +上一节:[6.2 async_hooks](https://github.com/nswbmw/node-in-debugging/blob/master/6.2%20async_hooks.md) + +下一节:[6.4 OpenTracing + Jaeger](https://github.com/nswbmw/node-in-debugging/blob/master/6.4%20OpenTracing%20%2B%20Jaeger.md) diff --git a/6.4 OpenTracing + Jaeger.md b/6.4 OpenTracing + Jaeger.md new file mode 100644 index 0000000..513b992 --- /dev/null +++ b/6.4 OpenTracing + Jaeger.md @@ -0,0 +1,170 @@ +## 什么是 OpenTracing ? + +OpenTracing([opentracing.io](http://opentracing.io/))是一个分布式追踪规范。OpenTracing 通过提供平台无关、厂商无关的 API,为分布式追踪提供统一的概念和数据标准,使得开发人员能够方便的添加(或更换)追踪系统的实现。OpenTracing 定义了几个术语: + +- Span:代表了系统中的一个逻辑工作单元,它具有操作名、操作开始时间以及持续时长。Span 可能会有嵌套或排序,从而对因果关系建模。 + - Tags:每个 Span 可以有多个键值对(key: value)形式的 Tags,Tags 是没有时间戳的,支持简单的对 Span 进行注解和补充。 + - Logs:每个 Span 可以进行多次 Log 操作,每一次 Log 操作,都需要一个带时间戳的时间名称,以及可选的任意大小的存储结构。 +- Trace:代表了系统中的一个数据/执行路径(一个或多个 Span),可以理解成 Span 的有向无环图。 + +OpenTracing 还有其他一些概念,这里不过多解释。我们看个传统的调用关系例子: + +![](./assets/6.4.1.jpg) + +> 在一个分布式系统中,追踪一个事务或者调用流一般如上图所示。虽然这种图对于看清各组件的组合关系是很有用的,但是,它不能很好显示组件的调用时间,是串行调用还是并行调用,如果展现更复杂的调用关系,会更加复杂,甚至无法画出这样的图。另外,这种图也无法显示调用间的时间间隔以及是否通过定时调用来启动调用。一种更有效的展现一个典型的 trace 过程,如下图所示: + +![](./assets/6.4.2.jpg) + +> 这种展现方式增加显示了执行时间的上下文,相关服务间的层次关系,进程或者任务的串行或并行调用关系。这样的视图有助于发现系统调用的关键路径。通过关注关键路径的执行过程,项目团队可能专注于优化路径中的关键位置,最大幅度的提升系统性能。例如:可以通过追踪一个资源定位的调用情况,明确底层的调用情况,发现哪些操作有阻塞的情况。 + +## 什么是 Jaeger ? + +Jaeger 是 OpenTracing 的一个实现,是 Uber 开源的一个分布式追踪系统,其灵感来源于Dapper 和 OpenZipkin。从 2016 年开始,该系统已经在 Uber 内部得到了广泛的应用,它可以用于微服务架构应用的监控,特性包括分布式上下文传播(Distributed context propagation)、分布式事务监控、根原因分析、服务依赖分析以及性能/延迟优化。该项目已经被云原生计算基金会(Cloud Native Computing Foundation,CNCF)接纳为第 12 个项目。 + +## 启动 Jaeger + Jaeger UI + +我们使用 Docker 启动 Jaeger + Jaeger UI(Jaeger 可视化 web 控制台),运行如下命令: + +```sh +$ docker run -d -p5775:5775/udp \ + -p 6831:6831/udp \ + -p 6832:6832/udp \ + -p 5778:5778 \ + -p 16686:16686 \ + -p 14268:14268 \ + jaegertracing/all-in-one:latest +``` + +浏览器打开 localhost:16686,如下所示: + +![](./assets/6.4.3.jpg) + +现在并没有任何数据,接下来我们看看如何使用 Jaeger 接收并查询日志。 + +## 如何使用 OpenTracing + Jaeger ? + +OpenTracing 和 Jaeger 分别提供了 JavaScript/Node.js 的 SDK: + +- [opentracing/opentracing-javascript](https://github.com/opentracing/opentracing-javascript) +- [jaegertracing/jaeger-client-node](https://github.com/jaegertracing/jaeger-client-node) + +opentracing 示例代码如下: + +```js +const http = require('http') +const opentracing = require('opentracing') + +// NOTE: the default OpenTracing tracer does not record any tracing information. +// Replace this line with the tracer implementation of your choice. +const tracer = new opentracing.Tracer() + +const span = tracer.startSpan('http_request') +const opts = { + host : 'example.com', + method: 'GET', + port : '80', + path: '/', +} +http.request(opts, res => { + res.setEncoding('utf8') + res.on('error', err => { + // assuming no retries, mark the span as failed + span.setTag(opentracing.Tags.ERROR, true) + span.log({'event': 'error', 'error.object': err, 'message': err.message, 'stack': err.stack}) + span.finish() + }) + res.on('data', chunk => { + span.log({'event': 'data_received', 'chunk_length': chunk.length}) + }) + res.on('end', () => { + span.log({'event': 'request_end'}) + span.finish() + }) +}).end() +``` + +有 2 点需要解释下: + +1. 需要将上面的 `const tracer = new opentracing.Tracer()` 替换成自己的 tracer 实现,即 jaeger 的实现。 +2. 通过 tracer.startSpan 启动一个 Span,span.setTag 设置 Tags,span.log 设置 Logs,span.finish 结束一个 Span。 + +有点类似于我们的手动埋点,只不过变成了一个规范而已。但 OpenTracing 不止如此,上面只是一个 Span 的用法,Span 之间还可以关联调用关系,最后得到一个 DAG(有向无环图)。 + +**举个例子**:假如你正在搞微服务,多个服务之间有调用关系(不管是 HTTP 还是 RPC 等),每次调用服务内部可能产生多个 Span,最终会在 Jaeger 控制台页面看到一个完整的 Trace 和 DAG 图(微服务之间的调用关系)。 + +jaeger-client-node 使用如下: + +```js +const tracer = new jaeger.Tracer( + serviceName, + new jaeger.RemoteReporter(new UDPSender()), + new jaeger.RateLimitingSampler(1) +) +``` + +创建一个 tracer 接收 3 个参数: + +1. serviceName:服务名 +2. Reporter:上报器,即往哪发日志,如上例是通过 UDP 发送日志,默认地址 localhost:6832 +3. Sampler:采样器,即日志如何采样,如上例是限制 1 秒采样一次 + +更多选项这里不再详细介绍,读者可自行去查阅 jaeger-client-node 的文档。 + +## koa-await-breakpoint-jaeger + +通过上面例子我们知道,使用 Jaeger 需要手动埋点,前面我们介绍了 koa-await-breakpoint 日志自动打点,可自定义 store,koa-await-breakpoint-jaeger 是为 koa-await-breakpoint 写的 store 的 adaptor,实现上有一些小技巧,这里也不过多讲解,有兴趣的可以去看下源码。 + +还是拿 koa-await-breakpoint 的 example 举例,只添加了 2 行代码引入 jaeger 的使用。代码如下: + +**app.js** + +```js +const JaegerStore = require('koa-await-breakpoint-jaeger') +const koaAwaitBreakpoint = require('koa-await-breakpoint')({ + name: 'api', + files: ['./routes/*.js'], + store: new JaegerStore() +}) + +const Paloma = require('paloma') +const app = new Paloma() + +app.use(koaAwaitBreakpoint) +app.route({ method: 'POST', path: '/users', controller: require('./routes/user').createUser }) + +app.listen(3000) +``` + +运行: + +```sh +$ curl -XPOST localhost:3000/users +``` + +刷新 localhost:16686 可以看到已经有日志了,如下所示: + +![](./assets/6.4.4.jpg) + +选择 Sercice -> api,Operation -> POST /users,点击 Find Traces 查看所有结果,右侧展示了一条日志,点进去如下所示: + +![](./assets/6.4.5.jpg) + +**Tips:**可以根据 tags 过滤结果。 + +**注意:**Jaeger 是分布式追踪系统,通常用来追踪多个服务之间的调用关系,而这里用来追踪一个服务多个函数之间的调用关系。 + +修改 routes/user.js 的 createComment 函数 throw 一个 `new Error('test')`,重新运行,如下所示: + +![](./assets/6.4.6.jpg) + +完美的展现了一个请求到来时,函数之间的调用关系和层级关系以及耗时,甚至函数体和错误栈都有!也可以通过 requestId 去 ELK 查询日志。 + +## 参考文献 + +- https://wu-sheng.gitbooks.io/opentracing-io/content/ +- https://segmentfault.com/a/1190000008895129 +- http://www.infoq.com/cn/news/2017/11/Uber-open-spurce-Jaeger + +上一节:[6.3 ELK](https://github.com/nswbmw/node-in-debugging/blob/master/6.3%20ELK.md) + +下一节:[6.5 Sentry](https://github.com/nswbmw/node-in-debugging/blob/master/6.5%20Sentry.md) diff --git a/6.5 Sentry.md b/6.5 Sentry.md new file mode 100644 index 0000000..b5f0705 --- /dev/null +++ b/6.5 Sentry.md @@ -0,0 +1,177 @@ +## Sentry 是什么? + +Sentry [官网](sentry.io)介绍: + +> Sentry’s real-time error tracking gives you insight into production deployments and information to reproduce and fix crashes. + +**简言之**:Sentry 是一个开源的实时错误日志收集平台。 + +## 安装 Sentry + +我们使用 Docker 安装并启动 Sentry,步骤如下: + +1. 启动一个 Redis 容器,命名为 sentry-redis: + + ```sh + $ docker run -d --name sentry-redis redis + ``` + +2. 启动一个 Postgres 容器,命名为 sentry-postgres: + + ```sh + $ docker run -d \ + --name sentry-postgres \ + -e POSTGRES_PASSWORD=secret \ + -e POSTGRES_USER=sentry \ + postgres + ``` + +3. 生成一个 Sentry 的 secret key: + + ```sh + $ docker run --rm sentry config generate-secret-key + ``` + + 将下面的 都替换掉上面生成的 secret key。 + +4. 如果是新的数据库(即第一次运行),需要运行 upgrade: + + ```sh + $ docker run -it --rm \ + -e SENTRY_SECRET_KEY='' \ + --link sentry-postgres:postgres \ + --link sentry-redis:redis \ + sentry upgrade + ``` + + 如下,按步骤填写自己的信息: + + ![](./assets/6.5.1.jpg) + + 最终创建了一个超级管理员和一个默认的名为 sentry 的组织(organization)。 + +5. 启动 Sentry,并对外暴露 9000 端口: + + ```sh + $ docker run -d \ + --name my-sentry \ + -e SENTRY_SECRET_KEY='' \ + --link sentry-redis:redis \ + --link sentry-postgres:postgres \ + -p 9000:9000 \ + sentry + ``` + +6. 启动 Celery cron 和 Celery workers: + + ```sh + $ docker run -d \ + --name sentry-cron \ + -e SENTRY_SECRET_KEY='' \ + --link sentry-postgres:postgres \ + --link sentry-redis:redis \ + sentry run cron + ``` + + ```sh + $ docker run -d \ + --name sentry-worker-1 \ + -e SENTRY_SECRET_KEY='' \ + --link sentry-postgres:postgres \ + --link sentry-redis:redis \ + sentry run worker + ``` + + **Tips**:Celery 是 Python 写的一个分布式任务调度模块。 + +7. 完成! + +浏览器打开 localhost:9000 就能看到 Sentry 的登录页面了。 + + ![](./assets/6.5.2.jpg) + +首次登录需要填写一些必要信息 ,如下所示:![](./assets/6.5.3.png) + +点击 Continue 进入 Sentry 仪表盘(Dashboard)。点击右上角 New Project 创建一个项目,选择 Node.js 并填写项目名称为 API,然后 Create Project 创建项目。如下所示: ![](./assets/6.5.4.png) + +创建成功后进入 Node.js 使用示例页面,我们选择使用 Koa 测试,右侧选择 Koa: + + ![](./assets/6.5.5.png) + +上图是 koa@1 的示例代码,我们以 Paloma(基于 koa@2)为例,编写测试代码: + +```js +const Raven = require('raven') +const Paloma = require('paloma') +const app = new Paloma() + +Raven.config(DSN).install() + +app.on('error', (err) => { + Raven.captureException(err, function (err, eventId) { + console.log('Reported error ' + eventId) + }) +}) + +app.use((ctx) => { + throw new Error('test') +}) + +app.listen(3000) +``` + +**Tips**:DSN 替换为第 10 步截图中的 `http://xxx@localhost:9000/2`,DSN 既告诉客户端 Sentry 服务器地址,也用来当做身份认证的 token。 + +运行以上测试代码,访问 localhost:3000,错误信息会发送给 Sentry。Sentry 展示如下: + +![](./assets/6.5.6.png) + +点进去可以看到详细的信息: + +![](./assets/6.5.7.png) + +Sentry 还有许多功能比如:错误的归类、展示错误的频率柱状图、将错误指派给组织中的某个人、给错误添加标签、查看这类错误事件的历史、标记错误为已解决、在错误下发表评论、警报等等功能,更多配置见 Project Settings。 + +## koa-raven + +笔者将 Raven 封装成 Koa 的一个中间件。使用如下: + +```js +const raven = require('koa-raven') +const Paloma = require('paloma') +const app = new Paloma() + +app.use(raven(DSN)) + +app.use((ctx) => { + throw new Error('test') +}) + +app.listen(3000) +``` + +或者使用 ctx.raven: + +```js +const raven = require('koa-raven') +const Paloma = require('paloma') +const app = new Paloma() + +app.use(raven(DSN)) + +app.use((ctx) => { + try { + throw new Error('test') + } catch (e) { + ctx.raven.captureException(e, { extra: { name: 'tom' } }) + ctx.status = 500 + ctx.body = e.stack + } +}) + +app.listen(3000) +``` + +上一节:[6.4 OpenTracing + Jaeger](https://github.com/nswbmw/node-in-debugging/blob/master/6.4%20OpenTracing%20%2B%20Jaeger.md) + +下一节:[7.1 Telegraf + InfluxDB + Grafana(上)](https://github.com/nswbmw/node-in-debugging/blob/master/7.1%20Telegraf%20%2B%20InfluxDB%20%2B%20Grafana(%E4%B8%8A).md) diff --git "a/7.1 Telegraf + InfluxDB + Grafana(\344\270\212).md" "b/7.1 Telegraf + InfluxDB + Grafana(\344\270\212).md" new file mode 100644 index 0000000..07cea21 --- /dev/null +++ "b/7.1 Telegraf + InfluxDB + Grafana(\344\270\212).md" @@ -0,0 +1,247 @@ +本文将会讲解如何使用 Telegraf(StatsD) + InfluxDB + Grafana 搭建一套完整的监控系统。 + +## 简介 + +[Telegraf](https://github.com/influxdata/telegraf) 是一个使用 Go 语言开发的代理程序,可收集系统和服务或者其他来源(inputs)的数据,并写入到 InfluxDB(outputs)数据库,支持多种 inputs 和 outputs 插件。[StatsD](https://github.com/etsy/statsd) 是一个使用 Node.js 开发的网络守护进程,通过 UDP 或者 TCP 方式收集各种统计信息,包括计数器和定时器等。 + +[InfluxDB](https://github.com/influxdata/influxdb) 是一个使用 Go 语言开发的开源的分布式时序、事件和指标数据库,无需外部依赖,其设计目标是实现分布式和水平伸缩扩展。 + +[Grafana](https://github.com/grafana/grafana) 是一个使用 Go 语言开发的开源的、功能齐全的、漂亮的仪表盘和图表的编辑器,可用来做日志的分析与展示曲线图(如 api 的请求日志),支持多种 backend,如 ElasticSearch、InfluxDB、OpenTSDB 等等。 + +**工作流程**:Telegraf 将 StatsD(inputs) 和 InfluxDB(outputs) 结合起来,即发往 StatsD 的数据,最终通过 Telegraf 写入了 InfluxDB,然后 Grafana 读取 InfluxDB 的数据展示成图表。 + +## 启动 docker-statsd-influxdb-grafana + +我们使用 Docker 一键启动 Telegraf(StatsD) + InfluxDB + Grafana,节省搭建环境的时间。 + +```sh +$ docker run -d \ + --name docker-statsd-influxdb-grafana \ + -p 3003:3003 \ + -p 3004:8083 \ + -p 8086:8086 \ + -p 22022:22 \ + -p 8125:8125/udp \ + samuelebistoletti/docker-statsd-influxdb-grafana:latest +``` + +端口映射关系: + +``` +Host Container Service +----------------------------------- +3003 3003 grafana +3004 8083 influxdb-admin +8086 8086 influxdb +8125 8125 statsd +22022 22 sshd +``` + +## 熟悉 InfluxDB + +容器启动后,浏览器访问 localhost:3004(以下称为 influxdb-admin),如下所示: + +![](./assets/7.1.1.png) + +InfluxDB 基本概念: + +- database:数据库 +- measurement:数据库中的表 +- point:表里面的一行数据,由时间戳(time)、数据(field)和标签(tag)组成 + - time:每条数据记录的时间戳,是数据库中的主索引(会自动生成) + - field:各种记录的值(没有索引的属性) + - tag:各种有索引的属性 +- ... + +InfluxDB 采用了类 SQL 的查询语法,如: + +- show databases:列出所有数据库 +- show measurements:列出当前数据库的所有表 +- select * from xxx:列出 xxx 表的所有数据 +- ... + +我们在 Query 中输入: + +```sql +show databases +``` + +查询结果如下: + +![](./assets/7.1.2.png) + +_interal 是 InfluxDB 内部使用的数据库,telegraf 是我们当前 docker 容器启动后默认创建的测试数据库。 + +## 配置 Grafana + +浏览器打开 localhost:3003,如下所示: + +![](./assets/7.1.3.png) + +输入用户名 root 和密码 root 登录,进入初始化配置页,点击 Add data source 绿色按钮,如下填写: + +![](./assets/7.1.4.png) + +点击 Save & Test 保存配置。目前配置好了 Grafana 默认的 datasource 是 InfluxDB,接下来我们创建测试代码,产生测试数据。 + +## node-statsd + +[node-statsd](https://github.com/sivy/node-statsd) 是一个 statsd 的 Node.js client。创建以下测试代码: + +```js +const StatsD = require('node-statsd') +const statsdClient = new StatsD({ + host: 'localhost', + port: 8125 +}) + +setInterval(() => { + const responseTime = Math.floor(Math.random() * 100) + statsdClient.timing('api', responseTime, function (error, bytes) { + if (error) { + console.error(error) + } else { + console.log(`Successfully sent ${bytes} bytes, responseTime ${responseTime}ms`) + } + }) +}, 1000) +``` + +运行以上代码,每一秒钟产生一个 0-99 之间的随机值(模拟响应时间,单位毫秒),发送到 StatsD,StatsD 会通过 Telegraf 将这些数据写入 InfluxDB 的 telegraf 数据库。 + +回到 influxdb-admin,点击右上角切换到 telegraf 数据库,然后输入 `show measurements ` 查看已经存在 api 表了,然后输入: + +```sql +select * from api +``` + +查询结果如下: + +![](./assets/7.1.5.png) + +可以看出 api 表有以下几个字段: + +- time:InfluxDB 默认添加的时间戳 +- 90_percentile:所有记录中从小到大 90% 那个点的值 +- count:一次收集的日志数量,可以看出每条记录(point)的 count 值接近或等于 10,而我们的测试代码是 1s 发送一条数据,也就说明 Telegraf 默认设置是 10s 收集一次数据,默认配置的确是这样的,见:https://github.com/samuelebistoletti/docker-statsd-influxdb-grafana/blob/master/telegraf/telegraf.conf +- host:机器地址 +- lower:最小的那条记录的值 +- mean:所有记录的平均值 +- metric_type:metric 类型 +- stddev:所有记录的标准差 +- upper:最大的那条记录的值 + +## 创建 Grafana 图表 + +回到 Grafana,点击左上角 Grafana 图标的下拉菜单,点击 Dashboards 回到仪表盘页继续完成配置,点击 New dashboard 绿色按钮,然后点击 Graph 类型的图表,创建了一个空的图表,如下所示: + +![](./assets/7.1.6.png) + +点击当前图表,选择 Edit,修改几个地方: + +1. Metrics 配置中选择 FROM -> api 表,SELECT -> field(mean) 字段 +2. Display 配置中 Null value 选择 connected 将每个点连成折线 + +如下所示: + +![](./assets/7.1.7.png) + +## 模拟真实环境 + +**middlewares/statsd.js** + +```js +const StatsD = require('node-statsd') +const statsdClient = new StatsD({ + host: 'localhost', + port: 8125 +}) + +module.exports = function (routerName) { + return async function statsdMiddleware (ctx, next) { + const start = Date.now() + + try { + await next() + const spent = Date.now() - start + statsdClient.timing(`api_${routerName}`, spent) + } catch (e) { + statsdClient.increment(`api_${routerName}_${e.status || (ctx.status !== 404 ? ctx.status : 500)}`) + throw e + } + } +} +``` + +**server.js** + +```js +const Bluebird = require('bluebird') +const Paloma = require('paloma') +const app = new Paloma() +const statsd = require('./middlewares/statsd') + +app.route({ method: 'GET', path: '/', controller: [ + statsd('getHome'), + async (ctx) => { + // 模拟十分之一出错概率 + if (Math.random() < 0.1) { + console.error('error') + ctx.throw(400) + } + // 模拟 1-99 毫秒响应时间 + const responseTime = Math.floor(Math.random() * 100) + await Bluebird.delay(responseTime) + console.log(`Spent ${responseTime}ms`) + ctx.status = 200 + } +]}) + +app.listen(3000) +``` + +**client.js** + +```js +const axios = require('axios') + +setInterval(() => { + // 模拟 1-9 的 tps + const tps = Math.floor(Math.random() * 10) + for (let i = 0; i < tps; i++) { + axios.get('http://localhost:3000') + } +}, 1000) +``` + +起两个终端,分别运行: + +```sh +$ node server.js +$ node client.js +``` + +回到 influxdb-admin,输入: + +```sql +show measurements +``` + +可以看到已经有 api_getHome 和 api_getHome_400 表了。回到 Grafana,在一行(row)创建两个图表,分别为: + +- 请求量:包含了正常请求(200)和错误请求(4xx、5xx 等等)tps 的折线图 +- 响应时间:正常请求的最低(lower)、平均(mean)、最高(upper)响应时间的折线图 + +![](./assets/7.1.8.png) + +以『getHome 响应时间』的图表为例,Metrics 配置截图如下: + +![](./assets/7.1.9.png) + +## 参考链接 + +- https://www.cnblogs.com/shhnwangjian/p/6897216.html + +上一节:[6.5 Sentry](https://github.com/nswbmw/node-in-debugging/blob/master/6.5%20Sentry.md) + +下一节:[7.2 Telegraf + InfluxDB + Grafana(下)](https://github.com/nswbmw/node-in-debugging/blob/master/7.2%20Telegraf%20%2B%20InfluxDB%20%2B%20Grafana(%E4%B8%8B).md) diff --git "a/7.2 Telegraf + InfluxDB + Grafana(\344\270\213).md" "b/7.2 Telegraf + InfluxDB + Grafana(\344\270\213).md" new file mode 100644 index 0000000..529d8a9 --- /dev/null +++ "b/7.2 Telegraf + InfluxDB + Grafana(\344\270\213).md" @@ -0,0 +1,98 @@ +上一小节主要讲解了 Telegraf(StatsD) + InfluxDB + Grafana 的搭建和基本用法,以及创建了请求量和响应时间两种图表。这一小节讲解几个高级用法: + +1. 如何将 Grafana(监控) 跟 ELK(日志) 结合起来 +2. Grafana 监控报警 +3. 脚本一键生成图表 + +## Grafana + ELK + +在观察 Grafana 监控时,发现某个 api 接口响应时间突然有一个尖刺,这个时候想查一查到底什么原因导致的,前面介绍过 koa-await-breakpoint + ELK 的用法,是否可以结合 Grafana 使用呢?答案是可以的。 + +因为涉及的代码量大,笔者写了一个 demo 托管到了 GitHub 上,有两个 repo,分别为: + +- [grafana-to-elk](https://github.com/nswbmw/grafana-to-elk):包含 web server 和模拟请求的 client,分别将统计信息发送到 StatsD 和将日志发送到 ELK。 +- [grafana-to-elk-extension](https://github.com/nswbmw/grafana-to-elk-extension):Chrome 扩展,作用是: + - 格式化从 Grafana 跳转到 ELK 的时间范围 + - 添加 requestId 的链接 + - 高亮显示重要的字段 + +首先 clone 到本地: + +```sh +$ git clone https://github.com/nswbmw/grafana-to-elk.git +$ git clone https://github.com/nswbmw/grafana-to-elk-extension.git +``` + +测试步骤如下: + +1. 按照 7.2 小节启动 Telegraf(StatsD) + InfluxDB + Grafana + +2. 按照 6.3 小节启动 ELK + +3. 到 grafana-to-elk 目录下运行: + ```sh + $ npm i + $ node server + ``` + 起另外一个终端运行: + ```sh + $ node client + ``` + 此时,ELK 应该有日志了。 + +4. 加载 Chrome 扩展。打开 Chrome 扩展程序页 -> 加载已解压的扩展程序... -> 加载 grafana-to-elk-extension(非测试环境需要修改 manifest.json 的 matches 字段)。 + +5. 回到 Grafana 的『getHome 响应时间』图表,进入编辑页的 General tab,如下填写: + ![](./assets/7.2.1.png) + 保存,图表的左上角会出现一个类似分享的按钮,鼠标悬浮到上面出现 Go to ELK,点击跳转到 ELK。 + +6. ELK 显示如下: + ![](./assets/7.2.2.png)grafana-to-elk-extension 插件自动处理并跳转到对应 Grafana 中的时间段并且查询出了我们关心的结果。点击第一个 requestId,将会跳转并显示该请求所有的日志,如下所示: + ![](./assets/7.2.3.png) + 错误请求的日志: + ![](./assets/7.2.4.png) + +## 监控报警 + +Grafana 有内置的监控报警,设置步骤如下: + +1. 进入 Alerting -> Notifications 页,点击 New Notification 添加新的报警组,如下所示: + ![](./assets/7.2.5.png) +2. 回到『getHome 响应时间』图表,进入编辑页的 Alert tab,点击 Create Alert 创建报警规则,如下所示: + ![](./assets/7.2.6.png) + **报警规则**:每 60s 检查一次过去 1min 的 mean(B 在 Metrics 里面代表了别名为 mean 折线图)折线图的平均值是否大于 50,如果是则触发报警。 + **注意**:如需发邮件,需要设置 Grafana 的 [SMTP settings](http://docs.grafana.org/installation/configuration/#smtp)。 + +我们还可以给 『getHome 请求量』设置错误报警监控,如下所示: + +![](./assets/7.2.7.png) + +每 60s 检查一次过去 1min 是否有 400 报错,如果有则触发报警,其中 B 代表了别名为 400 的折线图。 + +**Tips**:报警信息可以发送到 Email、Slack、DingTalk 或者 Webhook 等等。报警内容可以包含图表的截图,需要配置 [external image uploader](http://docs.grafana.org/installation/configuration/#external-image-storage)。 + +## 脚本一键生成图表 + +我们只创建了一个接口的两种(请求量和响应时间)图表,还要设置 link、alert 等等就很麻烦了。如果我们的 api 有几百个接口,岂不是灾难了。 + +Grafana 虽然有 Template 的功能,但我们接下来讲一个奇技淫巧。 + +我们在保存图表的时候从 Chrome DevTools 的 Network 看到发起了一个 Ajax 请求,如下所示: + +![](./assets/7.2.8.png) + +dashboard 就是包含了当前仪表盘页所有图表的完整 JSON,其中: + +- dashboard:包含一到多行 row +- rows:一行 row 包含一到多个 panel +- panels:一个 panel 是一个具体的图表 + +拿到这个 JSON 我们就可以不断尝试修改它,然后用 axios 带上浏览器拿到的 Cookie 发送到图中的 url,模拟浏览器的保存操作,这里就不再展开讲解了。 + +## 参考链接 + +- http://docs.grafana.org/alerting/notifications/ + +上一节:[7.1 Telegraf + InfluxDB + Grafana(上)](https://github.com/nswbmw/node-in-debugging/blob/master/7.1%20Telegraf%20%2B%20InfluxDB%20%2B%20Grafana(%E4%B8%8A).md) + +下一节:[8.1 node-clinic](https://github.com/nswbmw/node-in-debugging/blob/master/8.1%20node-clinic.md) diff --git a/8.1 node-clinic.md b/8.1 node-clinic.md new file mode 100644 index 0000000..7b12e88 --- /dev/null +++ b/8.1 node-clinic.md @@ -0,0 +1,86 @@ +[node-clinic](https://github.com/nearform/node-clinic)(简称 clinic) 是一个开箱即用的 Node.js 应用诊断工具。 + +首先,安装 Node.js@9+ + +```sh +$ nvm install 9.6.1 +``` + +全局安装 clinic: + +```sh +$ npm i clinic -g +``` + +创建测试代码: + +**app.js** + +```js +const Paloma = require('paloma') +const app = new Paloma() + +function sleep (ms) { + const future = Date.now() + ms + while (Date.now() < future); +} + +app.use(() => { + sleep(50) +}) + +app.listen(3000) +``` + +使用 clinic doctor 启动并诊断 Node.js 应用: + +```sh +$ clinic doctor -- node app.js +``` + +使用 ab 压测: + +```sh +$ ab -c 10 -n 200 "http://localhost:3000/" +``` + +CTRL+C 终止测试程序,终端打印出: + +``` +Warning: Trace event is an experimental feature and could change at any time. +^Canalysing data +generated HTML file is 51485.clinic-doctor.html +``` + +浏览器打开 51485.clinic-doctor.html,如下所示: + +![](./assets/8.1.1.png) + +**可以看出**:Event Loop 被阻塞,CPU Usage 也居高不下,一定是有 CPU 密集计算,与我们的测试代码吻合。 + +clinic 也给出了猜测和解决方案,我们尝试使用 clinic flame 生成火焰图: + +```sh +$ clinic flame -- node app.js +``` + +也可以用以下命令代替: + +```sh +$ clinic flame --collect-only -- node app.js # 只收集数据 +$ clinic flame --visualize-only PID.flamegraph # 将数据生成火焰图 +``` + +使用同样的 ab 命令压测后,生成的火焰图如下: + +![](./assets/8.1.2.png) + +**可以看出**:app.js 第 4 行的 sleep 函数占用了大量的 CPU 计算。 + +## 参考链接 + +- https://www.nearform.com/blog/introducing-node-clinic-a-performance-toolkit-for-node-js-developers/ + +上一节:[7.2 Telegraf + InfluxDB + Grafana(下)](https://github.com/nswbmw/node-in-debugging/blob/master/7.2%20Telegraf%20%2B%20InfluxDB%20%2B%20Grafana(%E4%B8%8B).md) + +下一节:[8.2 alinode](https://github.com/nswbmw/node-in-debugging/blob/master/8.2%20alinode.md) diff --git a/8.2 alinode.md b/8.2 alinode.md new file mode 100644 index 0000000..ea38733 --- /dev/null +++ b/8.2 alinode.md @@ -0,0 +1,157 @@ +## 什么是 alinode ? + +> Node.js 性能平台(原 alinode)是面向中大型 Node.js 应用提供性能监控、安全提醒、故障排查、性能优化 等服务的整体性解决方案。alinode 团队凭借对 Node.js 内核深入的理解,提供完善的工具链和服务,协助客户主动、快速发现和定位线上问题。 + +## 创建 alinode 应用 + +访问官网 http://alinode.aliyun.com/,登录后点击『创建新应用』,创建一个名为 test_alinode 的应用。 + +进入设置页面,如下所示: + +![](./assets/8.2.1.png) + +App ID 和 App Secret 后面会用到。 + +## 安装 alinode + +alinode 整套服务由 alinode 运行时、agenthub(原 agentx + commdx 命令集)、服务平台组成,所以在自己的服务器上部署时需要安装 alinode 运行时和 agenthub。 + +我们使用交互式一键安装 alinode 和 agenthub: + +```sh +$ uname -a # 阿里云 ECS Ubuntu@16.04 +Linux nswbmw 4.4.0-105-generic #128-Ubuntu SMP Thu Dec 14 12:42:11 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux +$ wget https://raw.githubusercontent.com/aliyun-node/alinode-all-in-one/master/alinode_all.sh +$ bash -i alinode_all.sh # App ID 和 App Secret 填写上面生成的 +... +$ node -p 'process.alinode' # 查看 alinode 版本 +``` + +**注意**:如果遇到 wget 报错 `wget: unable to resolve host address 'raw.githubusercontent.com'`,需要修改 DNS 配置,在 /etc/resolv.conf 最上面添加 `nameserver 8.8.8.8`。 + +生成 yourconfig.json 配置文件如下: + +```json +{ + "server": "agentserver.node.aliyun.com:8080", + "appid": "xxx", + "secret": "xxx", + "logdir": "/tmp/", + "reconnectDelay": 10, + "heartbeatInterval": 60, + "reportInterval": 60, + "error_log": [], + "packages": [] +} +``` + +使用该配置启动 agenthub: + +```sh +$ nohup agenthub yourconfig.json & +``` + +agenthub 将以常驻进程的方式运行。 + +## 测试代码 + +我们使用 alinode 演示如何调试内存泄漏的问题。 + +**server.js** + +```js +const Paloma = require('paloma') +const session = require('koa-generic-session') +const app = new Paloma() + +app.keys = ['some secret'] +app.use(session()) + +class User { + constructor () { + this.name = new Array(1e6).join('*') + } +} + +app.use((ctx) => { + ctx.session.user = new User() + ctx.status = 204 +}) + +app.listen(3000) +``` + +这段代码内存泄露的原因是:koa-generic-session 默认将 session 信息存储到了内存中。 + +**client.js** + +```js +const axios = require('axios') + +setInterval(() => { + axios.get('http://localhost:3000') +}, 1000) +``` + +起两个终端分别运行 : + +```sh +$ ENABLE_NODE_LOG=YES node server # 开启 alinode 的 log 功能,使得 agenthub 可以监控内核级的性能数据 +$ node client # 1s 发起一次请求 +``` + +过一会就可以在 alinode 控制台看到数据了,如下所示: + +![](./assets/8.2.2.png) + +可以看出,alinode 监控了: + +- 异常日志 +- 慢 HTTP 日志 +- 模块依赖 +- 系统监控数据(包含非常详尽的图表数据) + - Memory + - CPU + - Load + - QPS + - GC + - Apdex + - Apdex detail + - node 进程数 + - 磁盘 + + +点击『堆快照』生成一个 heapsnapshot 文件,点击左侧『文件』查看刚才生成的堆快照: + +![](./assets/8.2.3.png) + +转储后点击『分析』,选择『对象簇视图』的树状列表,展开如下所示: + +![](./assets/8.2.4.png) + +**可以看出**:MemoryStore 的 sessions 对象中存储了 97 个 session,并且每个 session.user 上有一个 name 字段是长字符串。 + +**Tips**:你也可以选择『devtools 分析』或者下载下来使用 Chrome DevTools 分析,效果是一样的。 + +alinode 支持抓取并在线分析四种性能数据: + +- 堆快照 +- 堆时间线 +- CPU profile +- GC Trace + +本文就不一一演示了。 + +alinode 如此强大,而且免费使用,可以说是开发 Node.js 应用必不可少的好伙伴了。 + +## 参考链接 + +- http://alinode.aliyun.com/ + +- https://www.aliyun.com/product/nodejs + +- https://github.com/aliyun-node/agenthub + +- https://cnodejs.org/topic/561f289b4928c5872abc18ee + +上一节:[8.1 node-clinic](https://github.com/nswbmw/node-in-debugging/blob/master/8.1%20node-clinic.md) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0071998 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +## node-in-debugging + +《Node.js 调试指南》是本人整理的从事 Node.js 开发这几年的一些调试经验和思路,希望授人以鱼也能授人以渔。 + +## 开发环境 + +- MacOS|Linux(Ubuntu@16.04 64位) +- Node.js@8.9.4 + +## 目录 + +- CPU 篇 + - [perf + FlameGraph](https://github.com/nswbmw/node-in-debugging/blob/master/1.1%20perf%20%2B%20FlameGraph.md) + - [v8-profiler](https://github.com/nswbmw/node-in-debugging/blob/master/1.2%20v8-profiler.md) +- 内存篇 + - [gcore + llnode](https://github.com/nswbmw/node-in-debugging/blob/master/2.1%20gcore%20%2B%20llnode.md) + - [heapdump](https://github.com/nswbmw/node-in-debugging/blob/master/2.2%20heapdump.md) + - [memwatch-next](https://github.com/nswbmw/node-in-debugging/blob/master/2.3%20memwatch-next.md) + - [cpu-memory-monitor](https://github.com/nswbmw/node-in-debugging/blob/master/2.4%20cpu-memory-monitor.md) +- 代码篇 + - [Async + Await](https://github.com/nswbmw/node-in-debugging/blob/master/3.1%20Async%20%2B%20Await.md) + - [Error Stack](https://github.com/nswbmw/node-in-debugging/blob/master/3.2%20Error%20Stack.md) + - [Node@8](https://github.com/nswbmw/node-in-debugging/blob/master/3.3%20Node%408.md) + - [Rust Addons](https://github.com/nswbmw/node-in-debugging/blob/master/3.4%20Rust%20Addons.md) +- 调试篇 + - [Source Map](https://github.com/nswbmw/node-in-debugging/blob/master/4.1%20Source%20Map.md) + - [Chrome DevTools](https://github.com/nswbmw/node-in-debugging/blob/master/4.2%20Chrome%20DevTools.md) + - [Visual Studio Code](https://github.com/nswbmw/node-in-debugging/blob/master/4.3%20Visual%20Studio%20Code.md) + - [debug + repl2 + power-assert](https://github.com/nswbmw/node-in-debugging/blob/master/4.4%20debug%20%2B%20repl2%20%2B%20power-assert.md) + - [supervisor-hot-reload](https://github.com/nswbmw/node-in-debugging/blob/master/4.5%20supervisor-hot-reload.md) +- APM 篇 + - [NewRelic](https://github.com/nswbmw/node-in-debugging/blob/master/5.1%20NewRelic.md) + - [Elastic APM](https://github.com/nswbmw/node-in-debugging/blob/master/5.2%20Elastic%20APM.md) +- 日志篇 + - [koa-await-breakpoint](https://github.com/nswbmw/node-in-debugging/blob/master/6.1%20koa-await-breakpoint.md) + - [async_hooks](https://github.com/nswbmw/node-in-debugging/blob/master/6.2%20async_hooks.md) + - [ELK](https://github.com/nswbmw/node-in-debugging/blob/master/6.3%20ELK.md) + - [OpenTracing + Jaeger](https://github.com/nswbmw/node-in-debugging/blob/master/6.4%20OpenTracing%20%2B%20Jaeger.md) + - [Sentry](https://github.com/nswbmw/node-in-debugging/blob/master/6.5%20Sentry.md) +- 监控篇 + - [Telegraf + InfluxDB + Grafana(上)](https://github.com/nswbmw/node-in-debugging/blob/master/7.1%20Telegraf%20%2B%20InfluxDB%20%2B%20Grafana(%E4%B8%8A).md) + - [Telegraf + InfluxDB + Grafana(下)](https://github.com/nswbmw/node-in-debugging/blob/master/7.2%20Telegraf%20%2B%20InfluxDB%20%2B%20Grafana(%E4%B8%8B).md) +- 工具篇 + - [node-clinic](https://github.com/nswbmw/node-in-debugging/blob/master/8.1%20node-clinic.md) + - [alinode](https://github.com/nswbmw/node-in-debugging/blob/master/8.2%20alinode.md) + +## 捐赠 + +支付宝 | 微信 +------|------ +![](./assets/alipay.png) | ![](./assets/wechat.jpeg) diff --git a/assets/1.1.1.png b/assets/1.1.1.png new file mode 100644 index 0000000..61439c0 Binary files /dev/null and b/assets/1.1.1.png differ diff --git a/assets/1.1.2.png b/assets/1.1.2.png new file mode 100644 index 0000000..dc1a7fe Binary files /dev/null and b/assets/1.1.2.png differ diff --git a/assets/1.1.3.jpg b/assets/1.1.3.jpg new file mode 100644 index 0000000..7f0a24f Binary files /dev/null and b/assets/1.1.3.jpg differ diff --git a/assets/1.2.1.png b/assets/1.2.1.png new file mode 100644 index 0000000..d870391 Binary files /dev/null and b/assets/1.2.1.png differ diff --git a/assets/1.2.2.png b/assets/1.2.2.png new file mode 100644 index 0000000..d43ce8b Binary files /dev/null and b/assets/1.2.2.png differ diff --git a/assets/1.2.3.png b/assets/1.2.3.png new file mode 100644 index 0000000..72e27df Binary files /dev/null and b/assets/1.2.3.png differ diff --git a/assets/2.1.1.jpg b/assets/2.1.1.jpg new file mode 100644 index 0000000..dd237ef Binary files /dev/null and b/assets/2.1.1.jpg differ diff --git a/assets/2.2.1.png b/assets/2.2.1.png new file mode 100644 index 0000000..d03573b Binary files /dev/null and b/assets/2.2.1.png differ diff --git a/assets/2.2.2.png b/assets/2.2.2.png new file mode 100644 index 0000000..8e71f4b Binary files /dev/null and b/assets/2.2.2.png differ diff --git a/assets/2.2.3.png b/assets/2.2.3.png new file mode 100644 index 0000000..10dd7ef Binary files /dev/null and b/assets/2.2.3.png differ diff --git a/assets/2.2.4.png b/assets/2.2.4.png new file mode 100644 index 0000000..8f5673c Binary files /dev/null and b/assets/2.2.4.png differ diff --git a/assets/2.3.1.png b/assets/2.3.1.png new file mode 100644 index 0000000..4be6f67 Binary files /dev/null and b/assets/2.3.1.png differ diff --git a/assets/3.1.1.png b/assets/3.1.1.png new file mode 100644 index 0000000..97ac366 Binary files /dev/null and b/assets/3.1.1.png differ diff --git a/assets/3.1.2.png b/assets/3.1.2.png new file mode 100644 index 0000000..1e66b86 Binary files /dev/null and b/assets/3.1.2.png differ diff --git a/assets/3.1.3.png b/assets/3.1.3.png new file mode 100644 index 0000000..41a5311 Binary files /dev/null and b/assets/3.1.3.png differ diff --git a/assets/3.1.4.png b/assets/3.1.4.png new file mode 100644 index 0000000..5812616 Binary files /dev/null and b/assets/3.1.4.png differ diff --git a/assets/3.3.1.jpg b/assets/3.3.1.jpg new file mode 100644 index 0000000..6abdd83 Binary files /dev/null and b/assets/3.3.1.jpg differ diff --git a/assets/3.3.2.jpg b/assets/3.3.2.jpg new file mode 100644 index 0000000..acc362b Binary files /dev/null and b/assets/3.3.2.jpg differ diff --git a/assets/3.3.3.jpg b/assets/3.3.3.jpg new file mode 100644 index 0000000..358d6a1 Binary files /dev/null and b/assets/3.3.3.jpg differ diff --git a/assets/3.3.4.jpg b/assets/3.3.4.jpg new file mode 100644 index 0000000..532da81 Binary files /dev/null and b/assets/3.3.4.jpg differ diff --git a/assets/3.3.5.jpg b/assets/3.3.5.jpg new file mode 100644 index 0000000..6722995 Binary files /dev/null and b/assets/3.3.5.jpg differ diff --git a/assets/4.2.1.png b/assets/4.2.1.png new file mode 100644 index 0000000..234f027 Binary files /dev/null and b/assets/4.2.1.png differ diff --git a/assets/4.2.2.png b/assets/4.2.2.png new file mode 100644 index 0000000..f1262bf Binary files /dev/null and b/assets/4.2.2.png differ diff --git a/assets/4.2.3.png b/assets/4.2.3.png new file mode 100644 index 0000000..e857620 Binary files /dev/null and b/assets/4.2.3.png differ diff --git a/assets/4.2.4.png b/assets/4.2.4.png new file mode 100644 index 0000000..f5f89c1 Binary files /dev/null and b/assets/4.2.4.png differ diff --git a/assets/4.3.1.png b/assets/4.3.1.png new file mode 100644 index 0000000..06bf7ff Binary files /dev/null and b/assets/4.3.1.png differ diff --git a/assets/4.3.2.png b/assets/4.3.2.png new file mode 100644 index 0000000..b7c5d15 Binary files /dev/null and b/assets/4.3.2.png differ diff --git a/assets/4.3.3.png b/assets/4.3.3.png new file mode 100644 index 0000000..aea87da Binary files /dev/null and b/assets/4.3.3.png differ diff --git a/assets/4.3.4.png b/assets/4.3.4.png new file mode 100644 index 0000000..bb63b32 Binary files /dev/null and b/assets/4.3.4.png differ diff --git a/assets/4.3.5.png b/assets/4.3.5.png new file mode 100644 index 0000000..e58ac04 Binary files /dev/null and b/assets/4.3.5.png differ diff --git a/assets/4.4.1.jpg b/assets/4.4.1.jpg new file mode 100644 index 0000000..e0b606c Binary files /dev/null and b/assets/4.4.1.jpg differ diff --git a/assets/4.4.2.jpg b/assets/4.4.2.jpg new file mode 100644 index 0000000..ba33813 Binary files /dev/null and b/assets/4.4.2.jpg differ diff --git a/assets/5.1.1.png b/assets/5.1.1.png new file mode 100644 index 0000000..73b4315 Binary files /dev/null and b/assets/5.1.1.png differ diff --git a/assets/5.1.2.png b/assets/5.1.2.png new file mode 100644 index 0000000..9d4b33a Binary files /dev/null and b/assets/5.1.2.png differ diff --git a/assets/5.1.3.png b/assets/5.1.3.png new file mode 100644 index 0000000..0997ad5 Binary files /dev/null and b/assets/5.1.3.png differ diff --git a/assets/5.2.1.png b/assets/5.2.1.png new file mode 100644 index 0000000..f2c3f25 Binary files /dev/null and b/assets/5.2.1.png differ diff --git a/assets/5.2.2.png b/assets/5.2.2.png new file mode 100644 index 0000000..26506d8 Binary files /dev/null and b/assets/5.2.2.png differ diff --git a/assets/5.2.3.png b/assets/5.2.3.png new file mode 100644 index 0000000..efad2b2 Binary files /dev/null and b/assets/5.2.3.png differ diff --git a/assets/5.2.4.png b/assets/5.2.4.png new file mode 100644 index 0000000..1c4c1b6 Binary files /dev/null and b/assets/5.2.4.png differ diff --git a/assets/5.2.5.png b/assets/5.2.5.png new file mode 100644 index 0000000..6dbd86c Binary files /dev/null and b/assets/5.2.5.png differ diff --git a/assets/5.2.6.png b/assets/5.2.6.png new file mode 100644 index 0000000..2c49972 Binary files /dev/null and b/assets/5.2.6.png differ diff --git a/assets/6.3.1.png b/assets/6.3.1.png new file mode 100644 index 0000000..d71e9e4 Binary files /dev/null and b/assets/6.3.1.png differ diff --git a/assets/6.3.2.png b/assets/6.3.2.png new file mode 100644 index 0000000..e8d1194 Binary files /dev/null and b/assets/6.3.2.png differ diff --git a/assets/6.3.3.png b/assets/6.3.3.png new file mode 100644 index 0000000..e942e2a Binary files /dev/null and b/assets/6.3.3.png differ diff --git a/assets/6.3.4.png b/assets/6.3.4.png new file mode 100644 index 0000000..1d0d8fe Binary files /dev/null and b/assets/6.3.4.png differ diff --git a/assets/6.4.1.jpg b/assets/6.4.1.jpg new file mode 100644 index 0000000..6e6c9d9 Binary files /dev/null and b/assets/6.4.1.jpg differ diff --git a/assets/6.4.2.jpg b/assets/6.4.2.jpg new file mode 100644 index 0000000..04c7c59 Binary files /dev/null and b/assets/6.4.2.jpg differ diff --git a/assets/6.4.3.jpg b/assets/6.4.3.jpg new file mode 100644 index 0000000..aa27342 Binary files /dev/null and b/assets/6.4.3.jpg differ diff --git a/assets/6.4.4.jpg b/assets/6.4.4.jpg new file mode 100644 index 0000000..db713fd Binary files /dev/null and b/assets/6.4.4.jpg differ diff --git a/assets/6.4.5.jpg b/assets/6.4.5.jpg new file mode 100644 index 0000000..ef0117d Binary files /dev/null and b/assets/6.4.5.jpg differ diff --git a/assets/6.4.6.jpg b/assets/6.4.6.jpg new file mode 100644 index 0000000..afbc8a3 Binary files /dev/null and b/assets/6.4.6.jpg differ diff --git a/assets/6.5.1.jpg b/assets/6.5.1.jpg new file mode 100644 index 0000000..829ab7c Binary files /dev/null and b/assets/6.5.1.jpg differ diff --git a/assets/6.5.2.jpg b/assets/6.5.2.jpg new file mode 100644 index 0000000..61ce16c Binary files /dev/null and b/assets/6.5.2.jpg differ diff --git a/assets/6.5.3.png b/assets/6.5.3.png new file mode 100644 index 0000000..08a1cfa Binary files /dev/null and b/assets/6.5.3.png differ diff --git a/assets/6.5.4.png b/assets/6.5.4.png new file mode 100644 index 0000000..4e5bcc1 Binary files /dev/null and b/assets/6.5.4.png differ diff --git a/assets/6.5.5.png b/assets/6.5.5.png new file mode 100644 index 0000000..4eb87ad Binary files /dev/null and b/assets/6.5.5.png differ diff --git a/assets/6.5.6.png b/assets/6.5.6.png new file mode 100644 index 0000000..8b9bb06 Binary files /dev/null and b/assets/6.5.6.png differ diff --git a/assets/6.5.7.png b/assets/6.5.7.png new file mode 100644 index 0000000..7726442 Binary files /dev/null and b/assets/6.5.7.png differ diff --git a/assets/7.1.1.png b/assets/7.1.1.png new file mode 100644 index 0000000..a25e483 Binary files /dev/null and b/assets/7.1.1.png differ diff --git a/assets/7.1.2.png b/assets/7.1.2.png new file mode 100644 index 0000000..3056cf2 Binary files /dev/null and b/assets/7.1.2.png differ diff --git a/assets/7.1.3.png b/assets/7.1.3.png new file mode 100644 index 0000000..46a6c7a Binary files /dev/null and b/assets/7.1.3.png differ diff --git a/assets/7.1.4.png b/assets/7.1.4.png new file mode 100644 index 0000000..e06be8a Binary files /dev/null and b/assets/7.1.4.png differ diff --git a/assets/7.1.5.png b/assets/7.1.5.png new file mode 100644 index 0000000..21c2a5f Binary files /dev/null and b/assets/7.1.5.png differ diff --git a/assets/7.1.6.png b/assets/7.1.6.png new file mode 100644 index 0000000..20c8c9e Binary files /dev/null and b/assets/7.1.6.png differ diff --git a/assets/7.1.7.png b/assets/7.1.7.png new file mode 100644 index 0000000..0fa147f Binary files /dev/null and b/assets/7.1.7.png differ diff --git a/assets/7.1.8.png b/assets/7.1.8.png new file mode 100644 index 0000000..3152a2c Binary files /dev/null and b/assets/7.1.8.png differ diff --git a/assets/7.1.9.png b/assets/7.1.9.png new file mode 100644 index 0000000..ef40927 Binary files /dev/null and b/assets/7.1.9.png differ diff --git a/assets/7.2.1.png b/assets/7.2.1.png new file mode 100644 index 0000000..920054f Binary files /dev/null and b/assets/7.2.1.png differ diff --git a/assets/7.2.2.png b/assets/7.2.2.png new file mode 100644 index 0000000..460c6ba Binary files /dev/null and b/assets/7.2.2.png differ diff --git a/assets/7.2.3.png b/assets/7.2.3.png new file mode 100644 index 0000000..4b8f8a7 Binary files /dev/null and b/assets/7.2.3.png differ diff --git a/assets/7.2.4.png b/assets/7.2.4.png new file mode 100644 index 0000000..42a2048 Binary files /dev/null and b/assets/7.2.4.png differ diff --git a/assets/7.2.5.png b/assets/7.2.5.png new file mode 100644 index 0000000..93329c5 Binary files /dev/null and b/assets/7.2.5.png differ diff --git a/assets/7.2.6.png b/assets/7.2.6.png new file mode 100644 index 0000000..74e489d Binary files /dev/null and b/assets/7.2.6.png differ diff --git a/assets/7.2.7.png b/assets/7.2.7.png new file mode 100644 index 0000000..f1a1198 Binary files /dev/null and b/assets/7.2.7.png differ diff --git a/assets/7.2.8.png b/assets/7.2.8.png new file mode 100644 index 0000000..a02013a Binary files /dev/null and b/assets/7.2.8.png differ diff --git a/assets/8.1.1.png b/assets/8.1.1.png new file mode 100644 index 0000000..e0b685b Binary files /dev/null and b/assets/8.1.1.png differ diff --git a/assets/8.1.2.png b/assets/8.1.2.png new file mode 100644 index 0000000..4a7ba6f Binary files /dev/null and b/assets/8.1.2.png differ diff --git a/assets/8.2.1.png b/assets/8.2.1.png new file mode 100644 index 0000000..620affa Binary files /dev/null and b/assets/8.2.1.png differ diff --git a/assets/8.2.2.png b/assets/8.2.2.png new file mode 100644 index 0000000..8a5c49e Binary files /dev/null and b/assets/8.2.2.png differ diff --git a/assets/8.2.3.png b/assets/8.2.3.png new file mode 100644 index 0000000..02778c2 Binary files /dev/null and b/assets/8.2.3.png differ diff --git a/assets/8.2.4.png b/assets/8.2.4.png new file mode 100644 index 0000000..8cb0ddc Binary files /dev/null and b/assets/8.2.4.png differ diff --git a/assets/alipay.png b/assets/alipay.png new file mode 100644 index 0000000..178ab25 Binary files /dev/null and b/assets/alipay.png differ diff --git a/assets/wechat.jpeg b/assets/wechat.jpeg new file mode 100644 index 0000000..17ccc70 Binary files /dev/null and b/assets/wechat.jpeg differ