Node程序debug小记
有时候,所见并不是所得,有些包,你需要去翻他的源码才知道为什么会这样。
背景
今天调试一个程序,用到了一个很久之前的NPM包,名为formstream,用来将form
表单数据转换为流的形式进行接口调用时的数据传递。
这是一个几年前的项目,所以使用的是Generator
+co
实现的异步流程。
其中有这样一个功能,从某处获取一些图片URL
,并将URL
以及一些其他的常规参数组装到一起,调用另外的一个服务,将数据发送过去。
大致是这样的代码:
const co = require('co')
const moment = require('moment')
const urllib = require('urllib')
const Formstream = require('formstream')
function * main () {
const imageUrlList = [
'img1',
'img2',
'img3',
]
// 实例化 form 表单对象
const form = new Formstream()
// 常规参数
form.field('timestamp', moment().unix())
// 将图片 URL 拼接到 form 表单中
imageUrlList.forEach(imgUrl => {
form.field('image', imgUrl)
})
const options = {
method: 'POST',
// 生成对应的 headers 参数
headers: form.headers(),
// 告诉 urllib,我们通过流的方式进行传递数据,并指定流对象
stream: form
}
// 发送请求
const result = yield urllib.request(url, options)
// 输出结果
console.log(result)
}
co(main)
也算是一个比较清晰的逻辑,这样的代码也正常运行了一段时间。
如果没有什么意外,这段代码可能还会在这里安静的躺很多年。
但是,现实总是残酷的,因为一些不可抗拒因素,必须要去调整这个逻辑。
之前调用接口传递的是图片URL
地址,现在要改为直接上传二进制数据。
所以需求很简单,就是将之前的URL
下载,拿到buffer
,然后将buffer
传到formstream
实例中即可。
大致是这样的操作:
- imageUrlList.forEach(imgUrl => {
- form.field('image', imgUrl)
- })
+ let imageUrlResults = yield Promise.all(imageUrlList.map(imgUrl =>
+ urllib.request(imgUrl)
+ ))
+
+ imageUrlResults = imageUrlResults.filter(img => img && img.status === 200).map(img => img.data)
+
+ imageUrlResults.forEach(imgBuffer => {
+ form.buffer('image', imgBuffer)
+ })
下载图片 -> 过滤空数据 -> 拼接到form
中去,代码看起来毫无问题。
不过在执行的时候,却出现了一个令人头大的问题。
最终调用yield urllib.request(url, options)
的时候,提示接口超时了,起初还以为是网络问题,于是多执行了几次,发现还是这样,开始意识到,应该是刚才的代码改动引发的bug
。
开始 debug
定位引发 bug 的代码
我习惯的调试方式,是先用最原始的方式,眼,看有哪些代码修改。
因为代码都有版本控制,所以大多数编辑器都可以很直观的看到有什么代码修改,即使编辑器中无法看到,也可以在命令行中通过git diff
来查看修改。
这次的改动就是新增的一个批量下载逻辑,以及URL
改为Buffer
。
先用最简单粗暴的方式来确认是这些代码影响的,注释掉新增的代码,还原老代码。
结果果然是可以正常执行了,那么我们就可以断定bug
就是由这些代码所导致的。
逐步还原错误代码
上边那个方式只是一个rollback
,帮助确定了大致的范围。
接下来就是要缩小错误代码的范围。
一般代码改动大的时候,会有多个函数的声明,那么就按照顺序逐个解开注释,来查看运行的效果。
这次因为是比较小的逻辑调整,所以直接在一个函数中实现。
那么很简单的,在保证程序正常运行的前提下,我们就按照代码语句一行行的释放。
很幸运,在第一行代码的注释被打开后就复现了bug
,也就是那一行yield Promsie.all(XXX)
。
但是这个语句实际上也可以继续进行拆分,为了排除是urllib
的问题,我将该行代码换为一个最基础的Promise
对象:yield Promise.resolve(1)
。
结果令我很吃惊,这么一个简单的Promise
执行也会导致下边的请求超时。
当前的部分代码状态:
const form = new Formstream()
form.field('timestamp', moment().unix())
yield Promise.resolve(1)
const options = {
method: 'POST',
headers: form.headers(),
stream: form
}
// 超时
const result = yield urllib.request(url, options)
再缩小了范围以后,进一步进行排查。
目前所剩下的代码已经不错了,唯一可能会导致请求超时的情况,可能就是发请求时的那些options
参数了。
所以将options
中的headers
和stream
都注释掉,再次执行程序后,果然可以正常访问接口(虽说会提示出错,因为必选的参数没有传递)。
那么目前我们可以得到一个结论:formstream
实例+Promise
调用会导致这个问题。
冷静、忏悔
接下来要做的就是深呼吸,冷静,让心率恢复平稳再进行下一步的工作。
在我得到上边的结论之后,第一时间是崩溃的,因为导致这个bug
的环境还是有些复杂的,涉及到了三个第三方包,co
、formstream
和urllib
。
而直观的去看代码,自己写的逻辑其实是很少的,所以难免会在心中开始抱怨,觉得是第三方包在搞我。
但这时候要切记「程序员修炼之道」中的一句话:
"Select" Isn't Broken
“Select” 没有问题
所以一定要在内心告诉自己:“你所用的包都是经过了N久时间的洗礼,一定是一个很稳健的包,这个bug
一定是你的问题”。
分析问题
当我们达成这个共识以后,就要开始进行问题的分析了。
首先你要了解你所使用的这几个包的作用是什么,如果能知道他们是怎么实现的那就更好了。
对于co
,就是一个利用yield
语法特性将Promise
转换为更直观的写法罢了,没有什么额外的逻辑。
而urllib
也会在每次调用request
时创建一个新的client
(刚开始有想过会不会是因为多次调用urllib
导致的,不过用简单的Promise.resolve
代替之后,这个念头也打消了)
那么矛头就指向了formstream
,现在要进一步的了解它,不过通过官方文档进行查阅,并不能得到太多的有效信息。
源码阅读
所以为了解决问题,我们需要去阅读它的源码,从你在代码中调用的那些 API 入手:
构造函数营养并不多,就是一些简单的属性定义,并且看到了它继承自Stream
,这也是为什么能够在urllib
的options
中直接填写它的原因,因为是一个Stream
的子类。
util.inherits(FormStream, Stream);
然后就要看field
函数的实现了。
FormStream.prototype.field = function (name, value) {
if (!Buffer.isBuffer(value)) {
// field(String, Number)
// https://github.com/qiniu/nodejs-sdk/issues/123
if (typeof value === 'number') {
value = String(value);
}
value = new Buffer(value);
}
return this.buffer(name, value);
};
从代码的实现看,field
也只是一个Buffer
的封装处理,最终还是调用了.buffer
函数。
那么我们就顺藤摸瓜,继续查看buffer函数的实现。
FormStream.prototype.buffer = function (name, buffer, filename, mimeType) {
if (filename && !mimeType) {
mimeType = mime.lookup(filename);
}
var disposition = { name: name };
if (filename) {
disposition.filename = filename;
}
var leading = this._leading(disposition, mimeType);
this._buffers.push([leading, buffer]);
// plus buffer length to total content-length
this._contentLength += leading.length;
this._contentLength += buffer.length;
this._contentLength += NEW_LINE_BUFFER.length;
process.nextTick(this.resume.bind(this));
return this;
};
代码不算少,不过大多都不是这次需要关心的,大致的逻辑就是将Buffer
拼接到数组中去暂存,在最后结尾的地方,发现了这样的一句代码:process.nextTick(this.resume.bind(this))
。
顿时眼前一亮,重点的是那个process.nextTick
,大家应该都知道,这个是在Node
中实现微任务的其中一个方式,而另一种实现微任务的方式,就是用Promise
。
修改代码验证猜想
拿到这样的结果以后,我觉得仿佛找到了突破口,于是尝试性的将前边的代码改为这样:
const form = new Formstream()
form.field('timestamp', moment().unix())
yield Promise.resolve(1)
const options = {
method: 'POST',
headers: form.headers(),
stream: form
}
process.nextTick(() => {
urllib.request(url, options)
})
发现,果然超时了。
从这里就能大致推断出问题的原因了。
因为看代码可以很清晰的看出,field
函数在调用后,会注册一个微任务,而我们使用的yield
或者process.nextTick
也会注册一个微任务,但是field
的先注册,所以它的一定会先执行。
那么很显而易见,问题就出现在这个resume
函数中,因为resume
的执行早于urllib.request
,所以导致其超时。
这时候也可以同步的想一下造成request
超时的情况会是什么。
只有一种可能性是比较高的,因为我们使用的是stream
,而这个流的读取是需要事件来触发的,stream.on('data')
、stream.on('end')
,那么超时很有可能是因为程序没有正确接收到stream
的事件导致的。
当然了,「程序员修炼之道」还讲过:
Don't Assume it - Prove It
不要假定,要证明
所以为了证实猜测,需要继续阅读formstream
的源码,查看resume
函数究竟做了什么。
resume
函数是一个很简单的一次性函数,在第一次被触发时调用drain
函数。
FormStream.prototype.resume = function () {
this.paused = false;
if (!this._draining) {
this._draining = true;
this.drain();
}
return this;
};
那么继续查看drain
函数做的是什么事情。
因为上述使用的是field
,而非stream
,所以在获取item
的时候,肯定为空,那么这就意味着会继续调用_emitEnd
函数。
而_emitEnd
函数只有简单的两行代码emit('data')
和emit('end')
。
FormStream.prototype.drain = function () {
console.log('start drain')
this._emitBuffers();
var item = this._streams.shift();
if (item) {
this._emitStream(item);
} else {
this._emitEnd();
}
return this;
};
FormStream.prototype._emitEnd = function () {
this.emit('data', this._endData);
this.emit('end');
};
看到这两行代码,终于可以证实了我们的猜想,因为stream
是一个流,接收流的数据需要通过事件传递,而emit
就是触发事件所使用的函数。
这也就意味着,resume
函数的执行,就代表着stream
发送数据的动作,在发送完毕数据后,会执行end
,也就是关闭流的操作。
得出结论
到了这里,终于可以得出完整的结论:
formstream
在调用field
之类的函数后会注册一个微任务
微任务执行时会使用流开始发送数据,数据发送完毕后关闭流
因为在调用urllib
之前还注册了一个微任务,导致urllib.request
实际上是在这个微任务内部执行的
也就是说在request
执行的时候,流已经关闭了,一直拿不到数据,所以就抛出异常,提示接口超时。
那么根据以上的结论,现在就知道该如何修改对应的代码。
在调用field
方法之前进行下载图片资源,保证formstream.field
与urllib.request
之间的代码都是同步的。
let imageUrlResults = yield Promise.all(imageUrlList.map(imgUrl =>
urllib.request(imgUrl)
))
const form = new Formstream()
form.field('timestamp', moment().unix())
imageUrlResults = imageUrlResults.filter(img => img && img.status === 200).map(img => img.data)
imageUrlResults.forEach(imgBuffer => {
form.buffer('image', imgBuffer)
})
const options = {
method: 'POST',
headers: form.headers(),
stream: form
}
yield urllib.request(url, options)
小结
这并不是一个有各种高大上名字、方法论的一个调试方式。
不过我个人觉得,它是一个非常有效的方式,而且是一个收获会非常大的调试方式。
因为在调试的过程中,你会去认真的了解你所使用的工具究竟是如何实现的,他们是否真的就像文档中所描述的那样运行。
关于上边这点,顺便吐槽一下这个包:thenify-all。
是一个不错的包,用来将普通的Error-first-callback
函数转换为thenalbe
函数,但是在涉及到callback
会接收多个返回值的时候,该包会将所有的返回值拼接为一个数组并放入resolve
中。
实际上这是很令人困惑的一点,因为根据callback
返回参数的数量来区别编写代码。
而且thenable
约定的规则就是返回callback
中的除了error
以外的第一个参数。
但是这个在文档中并没有体现,而是简单的使用readFile
来举例,很容易对使用者产生误导。
一个最近的例子,就是我使用util.promisify
来替换掉thenify-all
的时候,发现之前的mysql.query
调用莫名其妙的报错了。
// 之前的写法
const [res] = await mysqlClient.query(`SELECT XXX`)
// 现在的写法
const res = await mysqlClient.query(`SELECT XXX`)
这是因为在mysql文档中明确定义了,SELECT
语句之类的会传递两个参数,第一个是查询的结果集,而第二个是字段的描述信息。
所以thenify-all
就将两个参数拼接为了数组进行resolve
,而在切换到了官方的实现后,就造成了使用数组解构拿到的只是结果集中的第一条数据。
最后,再简单的总结一下套路,希望能够帮到其他人:
- 屏蔽异常代码,确定稳定复现(还原修改)
- 逐步释放,缩小范围(一行行的删除注释)
- 确定问题,利用基础
demo
来屏蔽噪音(类似前边的yield Promise.resolve(1)
操作) - 分析原因,看文档,啃源码(了解这些代码为什么会出错)
- 通过简单的实验来验证猜想(这时候你就能知道怎样才能避免类似的错误)
Node程序debug小记的更多相关文章
- 使用webstorm调试node程序
前言 相信大家接触过不少node代码了,如果你应用的比较初级或者针对你的项目不需要接触过深的node代码,也许你仅仅需要简单的console.log('your variable')就完全满足你的需要 ...
- node.js学习(三)简单的node程序&&模块简单使用&&commonJS规范&&深入理解模块原理
一.一个简单的node程序 1.新建一个txt文件 2.修改后缀 修改之后会弹出这个,点击"是" 3.运行test.js 源文件 使用node.js运行之后的. 如果该路径下没有该 ...
- 服务器程序DEBUG
服务器程序DEBUG 服务器端设定 Tomcat 默认我们启动Tomcat是使用下边的命令 ./catalina.sh start 如果想DEBUG的话,只需要加一个参数打开JPDA(Java Pla ...
- 部署node程序并维持正常运行时间
12.2部署的基础知识 假定你创建了一个想要展示的Web程序,或者创建了一个商业应用,在把它放到生产环境中之前需要测试一下.你很可能会从一个简单的部署开始,然后再做些工作让它的正常运行时间和性能达到最 ...
- docker制作node程序镜像:
准备: 需要5个文件 新建一个docker文件夹 1 .ignore git忽略文件用的 2 pakage.json 安装NODE程序的 也可以直接拷贝进 docker文件加 3 node环境 lin ...
- 优雅的使用Chrome调试Node程序
前言 我不知道大家用什么来调试node程序.可能有的人用node-inspect,但是这货很久没更新了,而且一堆的bug用起来很不爽:可能有的人用命令行来,但是这样操作不够灵活:还有人只用consol ...
- 使用 pm2 优雅的部署 node 程序
使用 pm2 优雅的部署 node 程序 # 启动并监控名字为 XXX 的 npm run start:dev 命令 pm2 start npm --watch --name XXX -- run s ...
- 纯小白创建第一个Node程序失败-容易忽略的一个细节
一直觉得自己基础还很差,所以自觉不敢去碰node.js,但又对其心怀好奇.恰巧最近有一点空闲时间,忍不住去试了一下水 这不,在创建第一个node程序时就吃了闭门羹,总是提示我没有定义,如下图, 这 ...
- 启动node程序报错:event.js:183 throw er; // unhandled 'error' event
启动node程序时,报如下错误:
随机推荐
- linux中inittab文件详解
init的进程号是1(ps -aux | less),从这一点就能看出,init进程是系统所有进程的起点,Linux在完成核内引导以后,就开始运行init程序. init程序需要读取配置文件/etc/ ...
- 静态属性加载到jvm时候就存放在数据共享区,而不是等new后出现
静态属性加载到jvm时候就存放在数据共享区,而不是等new后出现.他的生命周期是 jvm结束 才会消失,一般的方法属性是对象结束后 就会消失.
- MT【147】又见最大最小
(2018浙江省赛12题)设$a\in R$,且对任意的实数$b$均有$\max\limits_{x\in[0,1]}|x^2+ax+b|\ge1$求$a$的范围_____解答:由题意$\min\li ...
- Udp广播的发送和接收(iOS + AsyncUdpSocket)下篇
接上篇C#的Udp广播的发送和接收 http://www.cnblogs.com/JimmyBright/p/4637090.html ios中使用AsyncUdpSocket处理Udp的消息非常方便 ...
- 洛谷 P1270 “访问”美术馆 解题报告
P1270 "访问"美术馆 题目描述 经过数月的精心准备,Peer Brelstet,一个出了名的盗画者,准备开始他的下一个行动.艺术馆的结构,每条走廊要么分叉为两条走廊,要么通向 ...
- struts2 的自定义 拦截器
Struts2的 拦截器: 对于拦截器,Struts2官方给出的 定义是: 拦截器是动态拦截Action调用的对象.它提供了一种机制,使开发者可以定义一段代码,在Action执行之前或者之后被调用执行 ...
- 谷歌发布 Android 8.1 首个开发者预览版,优化内存效率
今晨,谷歌推出了 Android 8.1 首个开发者预览版,此次升级涵盖了针对多个功能的提升优化,其中包含对 Android Go (设备运行内存小于等于 1 GB)和加速设备上对机器学习的全新神经网 ...
- 解题:USACO10MAR Great Cow Gather
题面 可以水水换根的,不过我是另一种做法:(按点权)找重心,事实上这是重心的一个性质 考虑换根的这个过程,当我们把点放在重心时,我们移动这个点有两种情况: 1.移动到最大的那个子树里 可以发现这个最大 ...
- bzoj2755【SCOI2012】喵星人的入侵
输入格式 第一行为三个整数n,m,K,分别表示地图的长和宽,以及最多能放置的炮塔数量. 接下来的n行,每行包含m个字符,‘#’表示地图上原有的障碍,‘.’表示该处为空地,数据保证在原地图上存在S到T的 ...
- 【DP/数学】【CF1061C】 Multiplicity
Description 给定一个序列 \(a\),求有多少非空序列 \(b\) 满足 \(b\) 是 \(a\) 的子序列并且 \(\forall~k~\in~[1,len_b],~~k \mid b ...