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中的headersstream都解释掉,再次实行递次后,果真可以平常接见接口(虽然说会提醒失足,由于必选的参数没有通报)。

那末如今我们可以获得一个结论:formstream实例+Promise挪用会致使这个题目。

岑寂、后悔

接下来要做的就是深呼吸,岑寂,让心率恢复安稳再举行下一步的事变。
在我获得上边的结论以后,第一时候是崩溃的,由于致使这个bug的环境照样有些庞杂的,触及到了三个第三方包,coformstreamurllib
而直观的去看代码,本身写的逻辑实际上是很少的,所以不免会在心中最先埋怨,以为是第三方包在搞我。
但这时刻要牢记「递次员修炼之道」中的一句话:

“Select” Isn’t Broken

“Select” 没有题目

所以肯定要在心田通知本身:“你所用的包都是经过了N久时候的浸礼,肯定是一个很妥当的包,这个bug肯定是你的题目”。

剖析题目

当我们杀青这个共鸣今后,就要最先举行题目的剖析了。
起首你要相识你所运用的这几个包的作用是什么,假如能晓得他们是怎样完成的那就更好了。

关于co,就是一个应用yield语法特征将Promise转换为更直观的写法罢了,没有什么分外的逻辑。
urllib也会在每次挪用request时建立一个新的client(刚最先有想过会不会是由于屡次挪用urllib致使的,不过用简朴的Promise.resolve替代以后,这个动机也打消了)

那末锋芒就指向了formstream,如今要进一步的相识它,不过经由过程官方文档举行查阅,并不能获得太多的有用信息。

源码浏览

源码地点

所以为相识决题目,我们须要去浏览它的源码,从你在代码中挪用的那些 API 入手:

  1. 组织函数
  2. field
  3. headers

组织函数养分并不多,就是一些简朴的属性定义,而且看到了它继承自Stream,这也是为何可以在urlliboptions中直接填写它的缘由,由因而一个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.fieldurllib.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,而在切换到了官方的完成后,就形成了运用数组解构拿到的只是结果集合的第一条数据。

末了,再简朴的总结一下套路,愿望可以帮到其他人:

  1. 屏障异常代码,肯定稳固复现(复原修正)
  2. 逐渐开释,减少局限(一行行的删除解释)
  3. 肯定题目,应用基本demo来屏障噪音(相似前边的yield Promise.resolve(1)操纵)
  4. 剖析缘由,看文档,啃源码(相识这些代码为何会失足)
  5. 经由过程简朴的试验来考证猜想(这时刻你就可以晓得怎样才能防止相似的毛病)
    原文作者:贾顺名
    原文地址: https://segmentfault.com/a/1190000017920270
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞