当前位置: 首页 > 后端技术 > Node.js

Node程序调试笔记

时间:2023-04-03 19:06:55 Node.js

有时候,所见并非所得。对于某些包,您需要检查它们的源代码以找出发生这种情况的原因。背景今天调试一个程序,用了很久以前的一个npm包,叫formstream,用于在调用接口时将form表单数据转成流,进行数据传输。这是几年前的项目,所以用的是Generator+co实现的异步流程。其中有一个函数可以从某处获取一些图像URL,组合URL和一些其他通用参数,调用另一个服务,并发送数据。大致这段代码:constco=require('co')constmoment=require('moment')consturllib=require('urllib')constFormstream=require('formstream')function*main(){constimageUrlList=['img1','img2','img3',]//实例化form表单对象constform=newFormstream()//通用参数form.field('timestamp',moment().unix())//拼接图片URL转化为表单imageUrlList.forEach(imgUrl=>{form.field('image',imgUrl)})constoptions={method:'POST',//生成对应的headers参数headers:form.headers(),//告诉urllib我们通过流传递数据,并指定流对象stream:form}//发送请求constresult=yieldurllib.request(url,options)//输出结果console.log(result)}co(main)也是一个比较清晰的逻辑,这样的代码已经正常运行了一段时间。如果不出意外的话,这段代码可能还会在这里躺很多年。然而,现实总是残酷的,因为一些不可抗拒的因素,这个逻辑必须调整。之前调用接口传递图片的url地址,现在改为直接上传二进制数据。所以需求很简单,就是下载之前的url,拿到buffer,然后把buffer传给formstream实例。大致这个操作:-imageUrlList.forEach(imgUrl=>{-form.field('image',imgUrl)-})+letimageUrlResults=yieldPromise.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)+})下载图片->过滤空数据->拼接成表格,代码看起来没问题。但是,在实施的时候,出现了很大的问题。最后调用yieldurllib.request(url,options)时提示接口超时。一开始以为是网络问题,又执行了几次,发现还是老样子。我开始意识到应该是刚才改代码导致的bug。.开始调试以定位导致错误的代码。我习惯的调试方法是先用最原始的方法,__eye__,看看有什么代码改动。因为代码有版本控制,大部分编辑都可以直观的看到代码做了哪些改动。即使在编辑器中看不到,也可以在命令行中通过gitdiff查看。这次的变化是新的批量下载逻辑,URL改为Buffer。先用最简单粗暴的方式确认这些代码受到影响,__注释掉新添加的代码,恢复旧代码__。结果是可以正常执行,那么我们就可以断定这个bug就是这些代码造成的。上述逐步恢复错误代码的方法只是回滚,有助于确定大概的范围。下一步是缩小错误代码的范围。一般当代码改动比较多的时候,函数声明会有多个,所以要将注释一一去掉,才能看到运行效果。这次因为是比较小的逻辑调整,所以直接在一个函数中实现。那么就很简单了,在保证程序正常运行的前提下,我们按照代码语句逐行释放。幸运的是,在第一行代码的注释打开后重现了错误,即yieldPromsie.all(XXX)行。但这个说法其实还可以继续拆分。为了排除urllib的问题,我将这行代码换成了一个基本的Promise对象:yieldPromise.resolve(1)。结果让我大吃一惊,这么简单的Promise执行也会导致后面的请求超时。当前部分代码状态:constform=newFormstream()form.field('timestamp',moment().unix())yieldPromise.resolve(1)constoptions={method:'POST',headers:form.headers(),stream:form}//timeoutconstresult=yieldurllib.request(url,options)缩小范围后,进一步调查。剩下的代码已经好了,唯一可能导致请求超时的情况可能就是发送请求时的options参数了。于是把options中的headers和stream注释掉,再次执行程序后,就可以正常访问界面了(虽然会因为没有传递需要的参数而提示错误)。所以现在我们可以得出一个结论:formstream实例+Promise调用会导致这个问题。冷静下来坦白。接下来要做的就是深吸一口气,冷静下来,让心率恢复到一个稳定的水平,再进行下一步。得出以上结论后,第一时间崩溃了,因为导致这个bug的环境还是有些复杂,涉及三个第三方包,co、formstream、urllib。直观的看代码,自己写的逻辑其实很少,心里难免开始吐槽,觉得是第三方包在搞我。但是这个时候,你一定要记住《程序员修行之道》里的一句话:“Select”不破“Select”是没问题的,所以你一定要在心里告诉自己:“你用的包都通过了N久了。洗礼一定是一个很健壮的包,这个bug一定是你的问题。”分析问题当我们达成了这个共识后,我们就开始分析问题。首先,你需要了解你所使用的这些包的功能是什么。如果你能知道它们是如何实现的就更好了。对于co来说,它只是利用yield语法特性将Promise转换成更直观的写法,不需要任何额外的逻辑。而且urllib也会在每次请求调用的时候创建一个新的client(一开始我怀疑会不会是多次调用urllib导致的,但是换成一个简单的Promise.resolve之后,这个念头也打消了)然后矛头指向了formstream,现在我们需要更多地了解它,但是通过官方文档我们无法获得太多有效的信息。源码阅读源码地址那么为了解决问题,我们需要阅读它的源码,从你在代码中调用的API开始:constructorfieldheadersconstructor没什么营养,就是一些简单的属性定义,我们可以看到它继承了SinceStream,这也是为什么可以直接填urllib的options,因为它是Stream的子类。util.inherits(FormStream,Stream);那么就看字段函数的实现了。FormStream.prototype.field=function(name,value){if(!Buffer.isBuffer(value)){//field(String,Number)//https://github.com/qiniu/nodejs-sdk/issues/123if(typeofvalue==='number'){值=字符串(值);}值=新缓冲区(值);}returnthis.buffer(name,value);};从代码的实现来看,field也只是一个Buffer的封装过程,最后调用了.buffer函数。那么我们顺藤摸瓜,继续看buffer函数的实现。FormStream.prototype.buffer=function(name,buffer,filename,mimeType){if(filename&&!mimeType){mimeType=mime.lookup(filename);}vardisposition={name:name};if(filename){处置.filename=文件名;}varleading=this._leading(disposition,mimeType);this._buffers.push([leading,buffer]);//将缓冲区长度加到总内容长度this._contentLength+=leading.length;this._contentLength+=buffer.length;this._contentLength+=NEW_LINE_BUFFER.length;process.nextTick(this.resume.bind(this));返回这个;};代码不算少,不过这次大部分不用关心了,大体的逻辑就是将Buffer拼接成数组暂存。最后找到了这么一行代码:process.nextTick(this.resume.bind(this))。立即,焦点在process.nextTick上。大家应该都知道,这是在Node中实现microtasks的一种方式,另外一种实现microtasks的方式是使用Promise。修改代码验证猜想。得到这样的结果后,感觉好像找到了突破口,于是尝试把之前的代码改成这样:constform=newFormstream()form.field('timestamp',moment().unix())yieldPromise.resolve(1)constoptions={method:'POST',headers:form.headers(),stream:form}process.nextTick(()=>{urllib.request(url,options)})发现它真的超时了。从这里我们可以大致推断出问题的原因。因为看代码可以清楚的看到,调用完field函数之后,会注册一个microtask,而我们使用的yield或者process.nextTick也会注册一个microtask,但是field是先注册的,所以必须先注册。实施。那么很明显问题出在这个resume函数,因为resume的执行早于urllib.request,导致超时。这个时候你也可以同步想想什么情况会导致请求超时。只有一种可能性比较大,因为我们用的是stream,这个stream的读取需要有事件触发,stream.on('data'),stream.on('end'),然后timeout就可以了很有可能是程序没有正确接收到流事件。当然,《程序员修行之道》也说了:Don'tAssumeit-ProveItDon'tassumeit,youhavetoproveit。所以为了证实猜测,还需要继续阅读formstream的源码,看看resume函数是干什么的。resume函数是一个简单的一次性函数,它会在第一次触发时调用drain函数。FormStream.prototype.resume=function(){this.paused=false;如果(!this._draining){this._draining=true;这个.drain();}返回这个;};然后继续看drain函数是干什么的。因为上面使用的是field而不是stream,所以在获取item的时候必须为空,也就是说会继续调用_emitEnd函数。_emitEnd函数只有两行简单的代码emit('data')和emit('end')。FormStream.prototype.drain=function(){console.log('开始排水')this._emitBuffers();varitem=this._streams.shift();如果(项目){this._emitStream(项目);}else{this._emitEnd();}returnthis;};FormStream.prototype._emitEnd=function(){this.emit('data',this._endData);this.emit('结束');};看到这两行代码终于可以印证我们的猜想了,因为stream就是一个流,接收流的数据需要通过events传递,而emit就是用来触发事件的函数。这也意味着resume函数的执行代表了流发送数据的动作。发送数据后,会执行end,即关闭流的操作。至此,我们终于可以得出一个完整的结论:formstream调用field等函数后,会注册一个microtask。当microtask执行时,会使用stream开始发送数据,发送完数据后关闭stream,因为在调用urllib之前还注册了一个microtask,导致urllib.request实际上是在microtask内部执行的。也就是说在执行请求的时候,stream已经关闭,还没有获取到数据,所以抛出异常,说明接口超时。那么基于以上的结论,现在我们知道如何修改相应的代码了。在调用field方法之前先下载图片资源,保证formstream.field和urllib.request之间的代码是同步的。让imageUrlResults=yieldPromise.all(imageUrlList.map(imgUrl=>urllib.request(imgUrl)))constform=newFormstream()form.field('timestamp',moment().unix())imageUrlResults=imageUrlResults。过滤器(img=>img&&img.status===200).map(img=>img.data)imageUrlResults.forEach(imgBuffer=>{form.buffer('image',imgBuffer)})constoptions={method:'POST',headers:form.headers(),stream:form}yieldurllib.request(url,options)总结这不是各种高端名称和方法论的调试方法。不过我个人认为这是一种非常有效的方式,是一种收获很大的调试方式。因为在调试的过程中,你会认真了解你使用的工具是如何实现的,是否真的如文档中描述的那样工作。关于以上一点,顺便说一下这个包:thenify-all。它是一个很好的将普通的Error-first-callback函数转换成thenalbe函数的封装,但是当涉及到接收多个返回值的回调时,这个封装会将所有返回值拼接成一个数组并放入resolve中。这其实挺让人费解的,因为根据回调返回参数的个数不同,代码的写法也不一样。而thenable协议的规则是在回调中返回第一个参数excepterror。但是文档中并没有体现这一点,只是简单地以readFile为例,容易误导用户。最近的一个例子是,当我用util.promisify替换thenify-all时,发现之前的mysql.query调用莫名其妙的报错了。//以前的写法const[res]=awaitmysqlClient.query(`SELECTXXX`)//现在的写法constres=awaitmysqlClient.query(`SELECTXXX`)这是因为里面明确定义了mysql文档、SELECT语句等都会传递两个参数,第一个是查询的结果集,第二个是字段的描述信息。于是thenify-all将两个参数拼接成一个数组进行resolve,切换到官方实现后,使用数组解构只得到结果集中的第一条数据。最后简单总结一下套路,希望对大家有帮助:屏蔽异常代码,确定稳定复现(恢复修改),逐步发布,缩小范围(逐行删除注释)确定问题,使用基础demo屏蔽噪音(类似到前面的yieldPromise.resolve(1)操作),分析原因,看文档,啃源码(理解这些代码为什么会出错)通过简单的实验来验证猜想(这时候就可以知道如何避免类似错误)