有时刻,所见并非所得,有些包,你须要去翻他的源码才晓得为何会如许。
背景
本日调试一个递次,用到了一个良久之前的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)
操纵) - 剖析缘由,看文档,啃源码(相识这些代码为何会失足)
- 经由过程简朴的试验来考证猜想(这时刻你就可以晓得怎样才能防止相似的毛病)