Node程序debug小记

有时候,所见并不是所得,有些包,你需要去翻他的源码才知道为什么会这样。用Console来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

原文地址:https://www.cnblogs.com/lalalagq/p/10282519.html

时间: 2024-10-13 08:21:14

Node程序debug小记的相关文章

使用webstorm调试node程序

前言 相信大家接触过不少node代码了,如果你应用的比较初级或者针对你的项目不需要接触过深的node代码,也许你仅仅需要简单的console.log('your variable')就完全满足你的需要了.如果你应用node比较深入,想要调试(debug)之,我想你应该接触过node-inspector,大致用法流程,简单如下—— 1.安装:npm install -g node-inspector 2.启动debug模式(单独命令行): node-debug &(该命令默认8080端口) nod

服务器程序DEBUG

服务器端设定 Tomcat 默认我们启动Tomcat是使用下边的命令 ./catalina.sh start 如果想DEBUG的话,只需要加一个参数打开JPDA(Java Platform Debugger Architecture)就可以了 ./catalina.sh jpda start 注:默认的监听端口是8000,如果想修改这个端口的话,可以修改环境变量JPDAADDRESS. 如果我们的服务器是通过daemon.sh启动的tomcat话,貌似没有找到默认打开JPDA的方法,我们可以环境

部署node程序并维持正常运行时间

12.2部署的基础知识 假定你创建了一个想要展示的Web程序,或者创建了一个商业应用,在把它放到生产环境中之前需要测试一下.你很可能会从一个简单的部署开始,然后再做些工作让它的正常运行时间和性能达到最优.本节会带着你经历一次简单.临时的Git部署,并教你如何用Forever把程序跑起来.临时性部署在重启后会丢失,但它们的优势是设置起来很迅速. 12.2.1 从Git存储库部署我们快速过一下使用Git存储库的基本部署,让你对主要步骤有个直观的认识.大多数部署都需要完成下面这些步骤:(1) 用SSH

IIS 7.5关闭.net程序DEBUG

在生产环境下,我们一般都会把线上的DEBUG关闭,只有在程序的测试环境才会打开,但是只靠开发人员的是远远不够的,必须在程序的源头上进行关闭. 因为线上即有使用.net 2.0的环境也有使用.net 4.0环境,因此这次就有2个地方进行关闭. 打开文件C:\Windows\Microsoft.NET\Framework64\v2.0.50727\CONFIG\machine.config 下找到<system.web>下增加 <deployment retail="true&qu

一次程序调试小记

我用 C# 编写的一个 WinForm 程序,在业务部门的机器上运行出现"SqlDbType 枚举值 31 无效"错误,如下图所示: 而这个程序在我自己的机器上运行很正常: 于是我就到业务部门的机器上打开该程序的 debug 模式(在程序命令行后跟 /debug 参数,下面这个屏幕截图是在我自己的机器上截取的): 再次运行这个程序,得到更详细的错误信息: 可以看出,是 Skyiv.Ben.Icbc.Mis.Mgr.Data 命名空间中的 AccBalance 类的 GetValues

MR程序Debug调式或者运行模式

问题描述:在开发MR程序中会遇一些问题需要通过Debug调式,那么本文将介绍描述配置eclipse如何在Windows或者Linux下启动Debug调式. MR程序的Debug调式换言之就是MR程序的运行模式,MR程序有三种运行模式:本地模式(local).集群模式(cluster) Linux环境下: 1.本地模式:直接通过eclipse右键进行Debug即可 2.集群模式: 2.1.将hadoop的配置文件:core-site.xml/hdfs-site.xml/mapreduce-site

【转】程序debug正常release错误

原文:http://hi.baidu.com/leggle/blog/item/ec0c2e5400c3ed5dd10906a7.html 呵呵,Iris来了!由于最近所做项目接近尾声,想在release下运行却发现遇此问题,遂在网上搜寻,摘录如下: VC编写程序在debug下正常,在release下错误可能存在的原因:1.内存分配问题(1)变量初始化Release对程序的要求较Debug严格,应该对所有的变量(特别是指针和BOOL型)都先初始化再使用.(2)数据溢出的问题如:程序段 char

宝爷Debug小记——Cocos2d-x(3.13之前的版本)底层BUG导致Spine渲染花屏

最近在工作中碰到不少棘手的BUG,其中的一个是Spine骨骼的渲染花屏,在战斗中派发出大量士兵之后有概率出现花屏闪烁(如下图所示),这种莫名奇妙且难以重现的BUG最为蛋疼. 前段时间为了提高Spine骨骼动画的加载速度,将Spine库进行了升级,新的Spine库支持skel二进制格式,二进制格式的加载速度比json格式要快5倍以上. 这是一个大工程,游戏中所有的骨骼动画都需要使用更高版本的Spine编辑器重新导出,由于部分美术没有对源文件进行版本管理,丢失了源文件,导致部分骨骼动画要重新制作,浪

前端技术之:如何运行使用了ES(import)的node程序

方式一: 在package.json文件的scripts域中,配置以下的命令: "start": "cross-env NODE_ENV=dev node -r esm server/index.js” 这行命令的重点在于node命令的-r参数,参数值为esm. 如果程序的运行使用的是pm2,则可以使用类似以下的命令来启动应用: pm2 start npm -- run <scriptname> 对于上例来说,就是: pm2 start npm -- run st