摘要:當(dāng)前的部分代碼狀態(tài)超時(shí)再縮小了范圍以后,進(jìn)一步進(jìn)行排查。函數(shù)是一個(gè)很簡(jiǎn)單的一次性函數(shù),在第一次被觸發(fā)時(shí)調(diào)用函數(shù)。因?yàn)樯鲜鍪褂玫氖牵?,所以在獲取的時(shí)候,肯定為空,那么這就意味著會(huì)繼續(xù)調(diào)用函數(shù)。
有時(shí)候,所見(jiàn)并不是所得,有些包,你需要去翻他的源碼才知道為什么會(huì)這樣。
背景今天調(diào)試一個(gè)程序,用到了一個(gè)很久之前的NPM包,名為formstream,用來(lái)將form表單數(shù)據(jù)轉(zhuǎn)換為流的形式進(jìn)行接口調(diào)用時(shí)的數(shù)據(jù)傳遞。
這是一個(gè)幾年前的項(xiàng)目,所以使用的是Generator+co實(shí)現(xiàn)的異步流程。
其中有這樣一個(gè)功能,從某處獲取一些圖片URL,并將URL以及一些其他的常規(guī)參數(shù)組裝到一起,調(diào)用另外的一個(gè)服務(wù),將數(shù)據(jù)發(fā)送過(guò)去。
大致是這樣的代碼:
const co = require("co") const moment = require("moment") const urllib = require("urllib") const Formstream = require("formstream") function * main () { const imageUrlList = [ "img1", "img2", "img3", ] // 實(shí)例化 form 表單對(duì)象 const form = new Formstream() // 常規(guī)參數(shù) form.field("timestamp", moment().unix()) // 將圖片 URL 拼接到 form 表單中 imageUrlList.forEach(imgUrl => { form.field("image", imgUrl) }) const options = { method: "POST", // 生成對(duì)應(yīng)的 headers 參數(shù) headers: form.headers(), // 告訴 urllib,我們通過(guò)流的方式進(jìn)行傳遞數(shù)據(jù),并指定流對(duì)象 stream: form } // 發(fā)送請(qǐng)求 const result = yield urllib.request(url, options) // 輸出結(jié)果 console.log(result) } co(main)
也算是一個(gè)比較清晰的邏輯,這樣的代碼也正常運(yùn)行了一段時(shí)間。
如果沒(méi)有什么意外,這段代碼可能還會(huì)在這里安靜的躺很多年。
但是,現(xiàn)實(shí)總是殘酷的,因?yàn)橐恍┎豢煽咕芤蛩?,必須要去調(diào)整這個(gè)邏輯。
之前調(diào)用接口傳遞的是圖片URL地址,現(xiàn)在要改為直接上傳二進(jìn)制數(shù)據(jù)。
所以需求很簡(jiǎn)單,就是將之前的URL下載,拿到buffer,然后將buffer傳到formstream實(shí)例中即可。
大致是這樣的操作:
- 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) + })
下載圖片 -> 過(guò)濾空數(shù)據(jù) -> 拼接到form中去,代碼看起來(lái)毫無(wú)問(wèn)題。
不過(guò)在執(zhí)行的時(shí)候,卻出現(xiàn)了一個(gè)令人頭大的問(wèn)題。
最終調(diào)用yield urllib.request(url, options)的時(shí)候,提示接口超時(shí)了,起初還以為是網(wǎng)絡(luò)問(wèn)題,于是多執(zhí)行了幾次,發(fā)現(xiàn)還是這樣,開(kāi)始意識(shí)到,應(yīng)該是剛才的代碼改動(dòng)引發(fā)的bug。
我習(xí)慣的調(diào)試方式,是先用最原始的方式,__眼__,看有哪些代碼修改。
因?yàn)榇a都有版本控制,所以大多數(shù)編輯器都可以很直觀(guān)的看到有什么代碼修改,即使編輯器中無(wú)法看到,也可以在命令行中通過(guò)git diff來(lái)查看修改。
這次的改動(dòng)就是新增的一個(gè)批量下載邏輯,以及URL改為Buffer。
先用最簡(jiǎn)單粗暴的方式來(lái)確認(rèn)是這些代碼影響的,__注釋掉新增的代碼,還原老代碼__。
結(jié)果果然是可以正常執(zhí)行了,那么我們就可以斷定bug就是由這些代碼所導(dǎo)致的。
上邊那個(gè)方式只是一個(gè)rollback,幫助確定了大致的范圍。
接下來(lái)就是要縮小錯(cuò)誤代碼的范圍。
一般代碼改動(dòng)大的時(shí)候,會(huì)有多個(gè)函數(shù)的聲明,那么就按照順序逐個(gè)解開(kāi)注釋?zhuān)瑏?lái)查看運(yùn)行的效果。
這次因?yàn)槭潜容^小的邏輯調(diào)整,所以直接在一個(gè)函數(shù)中實(shí)現(xiàn)。
那么很簡(jiǎn)單的,在保證程序正常運(yùn)行的前提下,我們就按照代碼語(yǔ)句一行行的釋放。
很幸運(yùn),在第一行代碼的注釋被打開(kāi)后就復(fù)現(xiàn)了bug,也就是那一行yield Promsie.all(XXX)。
但是這個(gè)語(yǔ)句實(shí)際上也可以繼續(xù)進(jìn)行拆分,為了排除是urllib的問(wèn)題,我將該行代碼換為一個(gè)最基礎(chǔ)的Promise對(duì)象:yield Promise.resolve(1)。
結(jié)果令我很吃驚,這么一個(gè)簡(jiǎn)單的Promise執(zhí)行也會(huì)導(dǎo)致下邊的請(qǐng)求超時(shí)。
當(dāng)前的部分代碼狀態(tài):
const form = new Formstream() form.field("timestamp", moment().unix()) yield Promise.resolve(1) const options = { method: "POST", headers: form.headers(), stream: form } // 超時(shí) const result = yield urllib.request(url, options)
再縮小了范圍以后,進(jìn)一步進(jìn)行排查。
目前所剩下的代碼已經(jīng)不錯(cuò)了,唯一可能會(huì)導(dǎo)致請(qǐng)求超時(shí)的情況,可能就是發(fā)請(qǐng)求時(shí)的那些options參數(shù)了。
所以將options中的headers和stream都注釋掉,再次執(zhí)行程序后,果然可以正常訪(fǎng)問(wèn)接口(雖說(shuō)會(huì)提示出錯(cuò),因?yàn)楸剡x的參數(shù)沒(méi)有傳遞)。
那么目前我們可以得到一個(gè)結(jié)論:formstream實(shí)例+Promise調(diào)用會(huì)導(dǎo)致這個(gè)問(wèn)題。
冷靜、懺悔接下來(lái)要做的就是深呼吸,冷靜,讓心率恢復(fù)平穩(wěn)再進(jìn)行下一步的工作。
在我得到上邊的結(jié)論之后,第一時(shí)間是崩潰的,因?yàn)閷?dǎo)致這個(gè)bug的環(huán)境還是有些復(fù)雜的,涉及到了三個(gè)第三方包,co、formstream和urllib。
而直觀(guān)的去看代碼,自己寫(xiě)的邏輯其實(shí)是很少的,所以難免會(huì)在心中開(kāi)始抱怨,覺(jué)得是第三方包在搞我。
但這時(shí)候要切記「程序員修煉之道」中的一句話(huà):
"Select" Isn"t Broken
“Select” 沒(méi)有問(wèn)題
所以一定要在內(nèi)心告訴自己:“你所用的包都是經(jīng)過(guò)了N久時(shí)間的洗禮,一定是一個(gè)很穩(wěn)健的包,這個(gè)bug一定是你的問(wèn)題”。
分析問(wèn)題當(dāng)我們達(dá)成這個(gè)共識(shí)以后,就要開(kāi)始進(jìn)行問(wèn)題的分析了。
首先你要了解你所使用的這幾個(gè)包的作用是什么,如果能知道他們是怎么實(shí)現(xiàn)的那就更好了。
對(duì)于co,就是一個(gè)利用yield語(yǔ)法特性將Promise轉(zhuǎn)換為更直觀(guān)的寫(xiě)法罷了,沒(méi)有什么額外的邏輯。
而urllib也會(huì)在每次調(diào)用request時(shí)創(chuàng)建一個(gè)新的client(剛開(kāi)始有想過(guò)會(huì)不會(huì)是因?yàn)槎啻握{(diào)用urllib導(dǎo)致的,不過(guò)用簡(jiǎn)單的Promise.resolve代替之后,這個(gè)念頭也打消了)
那么矛頭就指向了formstream,現(xiàn)在要進(jìn)一步的了解它,不過(guò)通過(guò)官方文檔進(jìn)行查閱,并不能得到太多的有效信息。
源碼閱讀源碼地址
所以為了解決問(wèn)題,我們需要去閱讀它的源碼,從你在代碼中調(diào)用的那些 API 入手:
構(gòu)造函數(shù)
field
headers
構(gòu)造函數(shù)營(yíng)養(yǎng)并不多,就是一些簡(jiǎn)單的屬性定義,并且看到了它繼承自Stream,這也是為什么能夠在urllib的options中直接填寫(xiě)它的原因,因?yàn)槭且粋€(gè)Stream的子類(lèi)。
util.inherits(FormStream, Stream);
然后就要看field函數(shù)的實(shí)現(xiàn)了。
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); };
從代碼的實(shí)現(xiàn)看,field也只是一個(gè)Buffer的封裝處理,最終還是調(diào)用了.buffer函數(shù)。
那么我們就順藤摸瓜,繼續(xù)查看buffer函數(shù)的實(shí)現(xiàn)。
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; };
代碼不算少,不過(guò)大多都不是這次需要關(guān)心的,大致的邏輯就是將Buffer拼接到數(shù)組中去暫存,在最后結(jié)尾的地方,發(fā)現(xiàn)了這樣的一句代碼:process.nextTick(this.resume.bind(this))。
頓時(shí)眼前一亮,重點(diǎn)的是那個(gè)process.nextTick,大家應(yīng)該都知道,這個(gè)是在Node中實(shí)現(xiàn)微任務(wù)的其中一個(gè)方式,而另一種實(shí)現(xiàn)微任務(wù)的方式,就是用Promise。
拿到這樣的結(jié)果以后,我覺(jué)得仿佛找到了突破口,于是嘗試性的將前邊的代碼改為這樣:
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) })
發(fā)現(xiàn),果然超時(shí)了。
從這里就能大致推斷出問(wèn)題的原因了。
因?yàn)榭创a可以很清晰的看出,field函數(shù)在調(diào)用后,會(huì)注冊(cè)一個(gè)微任務(wù),而我們使用的yield或者process.nextTick也會(huì)注冊(cè)一個(gè)微任務(wù),但是field的先注冊(cè),所以它的一定會(huì)先執(zhí)行。
那么很顯而易見(jiàn),問(wèn)題就出現(xiàn)在這個(gè)resume函數(shù)中,因?yàn)?b>resume的執(zhí)行早于urllib.request,所以導(dǎo)致其超時(shí)。
這時(shí)候也可以同步的想一下造成request超時(shí)的情況會(huì)是什么。
只有一種可能性是比較高的,因?yàn)槲覀兪褂玫氖?b>stream,而這個(gè)流的讀取是需要事件來(lái)觸發(fā)的,stream.on("data")、stream.on("end"),那么超時(shí)很有可能是因?yàn)槌绦驔](méi)有正確接收到stream的事件導(dǎo)致的。
當(dāng)然了,「程序員修煉之道」還講過(guò):
Don"t Assume it - Prove It
不要假定,要證明
所以為了證實(shí)猜測(cè),需要繼續(xù)閱讀formstream的源碼,查看resume函數(shù)究竟做了什么。
resume函數(shù)是一個(gè)很簡(jiǎn)單的一次性函數(shù),在第一次被觸發(fā)時(shí)調(diào)用drain函數(shù)。
FormStream.prototype.resume = function () { this.paused = false; if (!this._draining) { this._draining = true; this.drain(); } return this; };
那么繼續(xù)查看drain函數(shù)做的是什么事情。
因?yàn)樯鲜鍪褂玫氖?b>field,而非stream,所以在獲取item的時(shí)候,肯定為空,那么這就意味著會(huì)繼續(xù)調(diào)用_emitEnd函數(shù)。
而_emitEnd函數(shù)只有簡(jiǎn)單的兩行代碼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"); };
看到這兩行代碼,終于可以證實(shí)了我們的猜想,因?yàn)?b>stream是一個(gè)流,接收流的數(shù)據(jù)需要通過(guò)事件傳遞,而emit就是觸發(fā)事件所使用的函數(shù)。
這也就意味著,resume函數(shù)的執(zhí)行,就代表著stream發(fā)送數(shù)據(jù)的動(dòng)作,在發(fā)送完畢數(shù)據(jù)后,會(huì)執(zhí)行end,也就是關(guān)閉流的操作。
到了這里,終于可以得出完整的結(jié)論:
formstream在調(diào)用field之類(lèi)的函數(shù)后會(huì)注冊(cè)一個(gè)微任務(wù)
微任務(wù)執(zhí)行時(shí)會(huì)使用流開(kāi)始發(fā)送數(shù)據(jù),數(shù)據(jù)發(fā)送完畢后關(guān)閉流
因?yàn)樵谡{(diào)用urllib之前還注冊(cè)了一個(gè)微任務(wù),導(dǎo)致urllib.request實(shí)際上是在這個(gè)微任務(wù)內(nèi)部執(zhí)行的
也就是說(shuō)在request執(zhí)行的時(shí)候,流已經(jīng)關(guān)閉了,一直拿不到數(shù)據(jù),所以就拋出異常,提示接口超時(shí)。
那么根據(jù)以上的結(jié)論,現(xiàn)在就知道該如何修改對(duì)應(yīng)的代碼。
在調(diào)用field方法之前進(jìn)行下載圖片資源,保證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)小結(jié)
這并不是一個(gè)有各種高大上名字、方法論的一個(gè)調(diào)試方式。
不過(guò)我個(gè)人覺(jué)得,它是一個(gè)非常有效的方式,而且是一個(gè)收獲會(huì)非常大的調(diào)試方式。
因?yàn)樵谡{(diào)試的過(guò)程中,你會(huì)去認(rèn)真的了解你所使用的工具究竟是如何實(shí)現(xiàn)的,他們是否真的就像文檔中所描述的那樣運(yùn)行。
關(guān)于上邊這點(diǎn),順便吐槽一下這個(gè)包:thenify-all。
是一個(gè)不錯(cuò)的包,用來(lái)將普通的Error-first-callback函數(shù)轉(zhuǎn)換為thenalbe函數(shù),但是在涉及到callback會(huì)接收多個(gè)返回值的時(shí)候,該包會(huì)將所有的返回值拼接為一個(gè)數(shù)組并放入resolve中。
實(shí)際上這是很令人困惑的一點(diǎn),因?yàn)楦鶕?jù)callback返回參數(shù)的數(shù)量來(lái)區(qū)別編寫(xiě)代碼。
而且thenable約定的規(guī)則就是返回callback中的除了error以外的第一個(gè)參數(shù)。
但是這個(gè)在文檔中并沒(méi)有體現(xiàn),而是簡(jiǎn)單的使用readFile來(lái)舉例,很容易對(duì)使用者產(chǎn)生誤導(dǎo)。
一個(gè)最近的例子,就是我使用util.promisify來(lái)替換掉thenify-all的時(shí)候,發(fā)現(xiàn)之前的mysql.query調(diào)用莫名其妙的報(bào)錯(cuò)了。
// 之前的寫(xiě)法 const [res] = await mysqlClient.query(`SELECT XXX`) // 現(xiàn)在的寫(xiě)法 const res = await mysqlClient.query(`SELECT XXX`)
這是因?yàn)樵趍ysql文檔中明確定義了,SELECT語(yǔ)句之類(lèi)的會(huì)傳遞兩個(gè)參數(shù),第一個(gè)是查詢(xún)的結(jié)果集,而第二個(gè)是字段的描述信息。
所以thenify-all就將兩個(gè)參數(shù)拼接為了數(shù)組進(jìn)行resolve,而在切換到了官方的實(shí)現(xiàn)后,就造成了使用數(shù)組解構(gòu)拿到的只是結(jié)果集中的第一條數(shù)據(jù)。
最后,再簡(jiǎn)單的總結(jié)一下套路,希望能夠幫到其他人:
屏蔽異常代碼,確定穩(wěn)定復(fù)現(xiàn)(還原修改)
逐步釋放,縮小范圍(一行行的刪除注釋?zhuān)?/p>
確定問(wèn)題,利用基礎(chǔ)demo來(lái)屏蔽噪音(類(lèi)似前邊的yield Promise.resolve(1)操作)
分析原因,看文檔,啃源碼(了解這些代碼為什么會(huì)出錯(cuò))
通過(guò)簡(jiǎn)單的實(shí)驗(yàn)來(lái)驗(yàn)證猜想(這時(shí)候你就能知道怎樣才能避免類(lèi)似的錯(cuò)誤)
文章版權(quán)歸作者所有,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請(qǐng)注明本文地址:http://systransis.cn/yun/101116.html
摘要:前幾天寫(xiě)的一段,在下一片空白,顯示。之,說(shuō)是最后一項(xiàng)有多余的逗號(hào),例如最后一項(xiàng)不能有逗號(hào)檢索修正所有文件不表,然而情況依舊。。。繼續(xù)先前的睿智技巧,終于發(fā)現(xiàn),好幾個(gè)。。。 前幾天寫(xiě)的一段Vue,在ie下一片空白,f12顯示script1003: expected :。 baidu、google之,說(shuō)是json最后一項(xiàng)有多余的逗號(hào),例如 { a: 5, b: 4, // 最后一項(xiàng)...
摘要:前言公司最近有一個(gè)頁(yè)面的功能,比較簡(jiǎn)單的一個(gè)調(diào)查表功能,嵌套在我們微信公眾號(hào)里面。同時(shí)用到了微信的登錄和分享接口。參考鏈接使用微信接口前端部分我們用微信接口主要是做的登錄和分享功能,首先是上微信公眾平臺(tái)上邊看看,把權(quán)限搞好之后后端配置。 showImg(https://segmentfault.com/img/bVbrOkH); 前言: 公司最近有一個(gè)H5頁(yè)面的功能,比較簡(jiǎn)單的一個(gè)調(diào)查...
摘要:前言公司最近有一個(gè)頁(yè)面的功能,比較簡(jiǎn)單的一個(gè)調(diào)查表功能,嵌套在我們微信公眾號(hào)里面。同時(shí)用到了微信的登錄和分享接口。參考鏈接使用微信接口前端部分我們用微信接口主要是做的登錄和分享功能,首先是上微信公眾平臺(tái)上邊看看,把權(quán)限搞好之后后端配置。 showImg(https://segmentfault.com/img/bVbrOkH); 前言: 公司最近有一個(gè)H5頁(yè)面的功能,比較簡(jiǎn)單的一個(gè)調(diào)查...
摘要:前言公司最近有一個(gè)頁(yè)面的功能,比較簡(jiǎn)單的一個(gè)調(diào)查表功能,嵌套在我們微信公眾號(hào)里面。同時(shí)用到了微信的登錄和分享接口。參考鏈接使用微信接口前端部分我們用微信接口主要是做的登錄和分享功能,首先是上微信公眾平臺(tái)上邊看看,把權(quán)限搞好之后后端配置。 showImg(https://segmentfault.com/img/bVbrOkH); 前言: 公司最近有一個(gè)H5頁(yè)面的功能,比較簡(jiǎn)單的一個(gè)調(diào)查...
閱讀 1203·2021-11-23 10:10
閱讀 1548·2021-09-30 09:47
閱讀 931·2021-09-27 14:02
閱讀 3007·2019-08-30 15:45
閱讀 3045·2019-08-30 14:11
閱讀 3639·2019-08-29 14:05
閱讀 1845·2019-08-29 13:51
閱讀 2236·2019-08-29 11:33