自Node.js面世以來,它獲得了大量的贊美和批判。這種爭論會一直持續(xù),短時間內(nèi)都不會結(jié)束。而在這些爭論中,我們常常會忽略掉所有語言和平臺都是基于一些核心問題來批判的,就是我們怎么去使用這些平臺。無論使用Node.js編寫可靠的代碼有多難,而編寫高并發(fā)代碼又是多么的簡單,這個平臺終究是有那么一段時間了,而且被用來創(chuàng)建了大量的健壯而又復(fù)雜的web服務(wù)。這些web服務(wù)不僅擁有良好的擴展性,而且通過在互聯(lián)網(wǎng)上持續(xù)的時間證明了它們的健壯性。
然而就像其它平臺一樣,Node.js很容易令開發(fā)者犯錯。這些錯誤有些會降低程序性能,有些則會導(dǎo)致Node.js不可用。在本文中,我們會看到Node.js新手常犯的十種錯誤,以及如何去避免它們。
錯誤#1:阻塞事件循環(huán)
Node.js(正如瀏覽器)里的JavaScript提供了一種單線程環(huán)境。這意味著你的程序不會有兩塊東西同時在運行,取而代之的是異步處理I/O密集操作所帶來的并發(fā)。比如說Node.js給數(shù)據(jù)庫發(fā)起一個請求去獲取一些數(shù)據(jù)時,Node.js可以集中精力在程序的其他地方:
// Trying to fetch an user object from the database. Node.js is free to run other parts of the code from the moment this function is invoked..
db.User.get(userId, function(err, user) {
// .. until the moment the user object has been retrieved here
})
然而,在一個有上千個客戶端連接的Node.js實例里,一小段CPU計算密集的代碼會阻塞住事件循環(huán),導(dǎo)致所有客戶端都得等待。CPU計算密集型代碼包括了嘗試排序一個巨大的數(shù)組、跑一個耗時很長的函數(shù)等等。例如:
function sortUsersByAge(users) {
users.sort(function(a, b) {
return a.age < b.age ? -1 : 1
})
}
在一個小的“users”數(shù)組上調(diào)用“sortUsersByAge”方法是沒有任何問題的,但如果是在一個大數(shù)組上,它會對整體性能造成巨大的影響。如果這種事情不得不做,而且你能確保事件循環(huán)上沒有其他事件在等待(比如這只是一個Node.js命令行工具,而且它不在乎所有事情都是同步工作的)的話,那這沒有問題。但是,在一個Node.js服務(wù)器試圖給上千用戶同時提供服務(wù)的情況下,它就會引發(fā)問題。
如果這個users數(shù)組是從數(shù)據(jù)庫獲取的,那么理想的解決方案是從數(shù)據(jù)庫里拿出已排好序的數(shù)據(jù)。如果事件循環(huán)被一個計算金融交易數(shù)據(jù)歷史總和的循環(huán)所阻塞,這個計算循環(huán)應(yīng)該被推到事件循環(huán)外的隊列中執(zhí)行以免占用事件循環(huán)。
正如你所見,解決這類錯誤沒有銀彈,只有針對每種情況單獨解決?;纠砟钍遣灰谔幚砜蛻舳瞬l(fā)連接的Node.js實例上做CPU計算密集型工作。
錯誤#2:多次調(diào)用一個回調(diào)函數(shù)
一直以來JavaScript都依賴于回調(diào)函數(shù)。在瀏覽器里,事件都是通過傳遞事件對象的引用給一個回調(diào)函數(shù)(通常都是匿名函數(shù))來處理。在Node.js里,回調(diào)函數(shù)曾經(jīng)是與其他代碼異步通信的唯一方式,直到promise出現(xiàn)。回調(diào)函數(shù)現(xiàn)在仍在使用,而且很多開發(fā)者依然圍繞著它來設(shè)置他們的API。一個跟使用回調(diào)函數(shù)相關(guān)的常見錯誤是多次調(diào)用它們。通常,一個封裝了一些異步處理的方法,它的最后一個參數(shù)會被設(shè)計為傳遞一個函數(shù),這個函數(shù)會在異步處理完后被調(diào)用:
module.exports.verifyPassword = function(user, password, done) {
if(typeof password !== ‘string’) {
done(new Error(‘password should be a string’))
return
}
computeHash(password, user.passwordHashOpts, function(err, hash) {
if(err) {
done(err)
return
}
done(null, hash === user.passwordHash)
})
}
注意到除了最后一次,每次“done”方法被調(diào)用之后都會有一個return語句。這是因為調(diào)用回調(diào)函數(shù)不會自動結(jié)束當(dāng)前方法的執(zhí)行。如果我們注釋掉第一個return語句,然后傳一個非字符串類型的password給這個函數(shù),我們依然會以調(diào)用computeHash方法結(jié)束。根據(jù)computeHash在這種情況下的處理方式,“done”函數(shù)會被調(diào)用多次。當(dāng)傳過去的回調(diào)函數(shù)被多次調(diào)用時,任何人都會被弄得措手不及。
避免這個問題只需要小心點即可。一些Node.js開發(fā)者因此養(yǎng)成了一個習(xí)慣,在所有調(diào)用回調(diào)函數(shù)的語句前加一個return關(guān)鍵詞:
if(err) {
return done(err)
}
在很多異步函數(shù)里,這種return的返回值都是沒有意義的,所以這種舉動只是為了簡單地避免這個錯誤而已。
錯誤#3:深層嵌套的回調(diào)函數(shù)
深層嵌套的回調(diào)函數(shù)通常被譽為“回調(diào)地獄”,它本身并不是什么問題,但是它會導(dǎo)致代碼很快變得失控:
function handleLogin(..., done) {
db.User.get(..., function(..., user) {
if(!user) {
return done(null, ‘failed to log in’)
}
utils.verifyPassword(..., function(..., okay) {
if(okay) {
return done(null, ‘failed to log in’)
}
session.login(..., function() {
done(null, ‘logged in’)
})
})
})
}
越復(fù)雜的任務(wù),這個的壞處就越大。像這樣嵌套回調(diào)函數(shù),我們的程序很容易出錯,而且代碼難以閱讀和維護。一個權(quán)宜之計是把這些任務(wù)聲明為一個個的小函數(shù),然后再將它們聯(lián)系起來。不過,(有可能是)最簡便的解決方法之一是使用一個Node.js公共組件來處理這種異步j(luò)s,比如Async.js:
function handleLogin(done) {
async.waterfall([
function(done) {
db.User.get(..., done)
},
function(user, done) {
if(!user) {
return done(null, ‘failed to log in’)
}
utils.verifyPassword(..., function(..., okay) {
done(null, user, okay)
})
},
function(user, okay, done) {
if(okay) {
return done(null, ‘failed to log in’)
}
session.login(..., function() {
done(null, ‘logged in’)
})
}
], function() {
// ...
})
}
Async.js還提供了很多類似“async.waterfall”的方法去處理不同的異步場景。為了簡便起見,這里我們演示了一個簡單的示例,實際情況往往復(fù)雜得多。
(打個廣告,隔壁的《ES6 Generator介紹》提及的Generator也是可以解決回調(diào)地獄的哦,而且結(jié)合Promise使用更加自然,請期待隔壁樓主的下篇文章吧:D)
錯誤#4:期待回調(diào)函數(shù)同步執(zhí)行
使用回調(diào)函數(shù)的異步程序不只是JavaScript和Node.js有,只是它們讓這種異步程序變得流行起來。在其他編程語言里,我們習(xí)慣了兩個語句一個接一個執(zhí)行,除非兩個語句之間有特殊的跳轉(zhuǎn)指令。即使那樣,這些還受限于條件語句、循環(huán)語句以及函數(shù)調(diào)用。
然而在JavaScript里,一個帶有回調(diào)函數(shù)的方法直到回調(diào)完成之前可能都無法完成任務(wù)。當(dāng)前函數(shù)會一直執(zhí)行到底:
function testTimeout() {
console.log(“Begin”)
setTimeout(function() {
console.log(“Done!”)
}, duration * 1000)
console.log(“Waiting..”)
}
你可能會注意到,調(diào)用“testTimeout”函數(shù)會先輸出“Begin”,然后輸出“Waiting..”,緊接著幾秒后輸出“Done!”。
任何要在回調(diào)函數(shù)執(zhí)行完后才執(zhí)行的代碼,都需要在回調(diào)函數(shù)里調(diào)用。
錯誤#5:給“exports”賦值,而不是“module.exports”
Node.js認為每個文件都是一個獨立的模塊。如果你的包有兩個文件,假設(shè)是“a.js”和“b.js”,然后“b.js”要使用“a.js”的功能,“a.js”必須要通過給exports對象增加屬性來暴露這些功能:
// a.js
exports.verifyPassword = function(user, password, done) { ... }
完成這步后,所有需要“a.js”的都會獲得一個帶有“verifyPassword”函數(shù)屬性的對象:
// b.js
require(‘a(chǎn).js’) // { verifyPassword: function(user, password, done) { ... } }
然而,如果我們想直接暴露這個函數(shù),而不是讓它作為某些對象的屬性呢?我們可以覆寫exports來達到目的,但是我們絕對不能把它當(dāng)做一個全局變量:
// a.js
module.exports = function(user, password, done) { ... }
注意到我們是把“exports”當(dāng)做module對象的一個屬性?!癿odule.exports”和“exports”這之間區(qū)別是很重要的,而且經(jīng)常會使Node.js新手踩坑。
錯誤#6:從回調(diào)里拋出錯誤
JavaScript有異常的概念。在語法上,學(xué)絕大多數(shù)傳統(tǒng)語言(如Java、C++)對異常的處理那樣,JavaScript可以拋出異常以及在try-catch語句塊中捕獲異常:
function slugifyUsername(username) {
if(typeof username === ‘string’) {
throw new TypeError(‘expected a string username, got '+(typeof username))
}
// ...
}
try {
var usernameSlug = slugifyUsername(username)
} catch(e) {
console.log(‘Oh no!’)
}
然而,在異步環(huán)境下,tary-catch可能不會像你所想的那樣。比如說,如果你想用一個大的try-catch去保護一大段含有許多異步處理的代碼,它可能不會正常的工作:
try {
db.User.get(userId, function(err, user) {
if(err) {
throw err
}
// ...
usernameSlug = slugifyUsername(user.username)
// ...
})
} catch(e) {
console.log(‘Oh no!’)
}
如果“db.User.get”的回調(diào)函數(shù)異步執(zhí)行了,那么try-catch原來所在的作用域就很難捕獲到回調(diào)函數(shù)里拋出的異常了。
這就是為什么在Node.js里通常使用不同的方式處理錯誤,而且這使得所有回調(diào)函數(shù)的參數(shù)都需要遵循(err, …)這種形式,其中第一個參數(shù)是錯誤發(fā)生時的error對象。
錯誤#7:認為Number是一種整型數(shù)據(jù)格式
在JavaScript里數(shù)字都是浮點型,沒有整型的數(shù)據(jù)格式。你可能認為這不是什么問題,因為數(shù)字大到溢出浮點型限制的情況很少出現(xiàn)??蓪嶋H上,當(dāng)這種情況發(fā)生時就會出錯。因為浮點數(shù)在表達一個整型數(shù)時只能表示到一個最大上限值,在計算中超過這個最大值時就會出問題。也許看起來有些奇怪,但在Node.js中下面代碼的值是true:
Math.pow(2, 53)+1 === Math.pow(2, 53)
很不幸的是,JavaScript里有關(guān)數(shù)字的怪癖可還不止這些。盡管數(shù)字是浮點型的,但如下這種整數(shù)運算能正常工作:
5 % 2 === 1 // true
5 >> 1 === 2 // true
然而和算術(shù)運算不同的是,位運算和移位運算只在小于32位最大值的數(shù)字上正常工作。例如,讓“Math.pow(2, 53)”位移1位總是得到0,讓其與1做位運算也總是得到0:
Math.pow(2, 53) / 2 === Math.pow(2, 52) // true
Math.pow(2, 53) >> 1 === 0 // true
Math.pow(2, 53) | 1 === 0 // true
你可能極少會去處理如此大的數(shù)字,但如果你需要的話,有很多實現(xiàn)了大型精密數(shù)字運算的大整數(shù)庫可以幫到你,比如node-bigint。
錯誤#8:忽略了流式API的優(yōu)勢
現(xiàn)在我們想創(chuàng)建一個簡單的類代理web服務(wù)器,它能通過拉取其他web服務(wù)器的內(nèi)容來響應(yīng)和發(fā)起請求。作為例子,我們創(chuàng)建一個小型web服務(wù)器為Gravatar的圖像服務(wù)。
var http = require('http')
var crypto = require('crypto')
http.createServer()
.on('request', function(req, res) {
var email = req.url.substr(req.url.lastIndexOf('/')+1)
if(!email) {
res.writeHead(404)
return res.end()
}
var buf = new Buffer(1024*1024)
http.get('http://www.gravatar.com/avatar/'+crypto.createHash('md5').update(email).digest('hex'), function(resp) {
var size = 0
resp.on('data', function(chunk) {
chunk.copy(buf, size)
size += chunk.length
})
.on('end', function() {
res.write(buf.slice(0, size))
res.end()
})
})
})
.listen(8080)
在這個例子里,我們從Gravatar拉取圖片,將它存進一個Buffer里,然后響應(yīng)請求。如果Gravatar的圖片都不是很大的話,這樣做沒問題。但想象下如果我們代理的內(nèi)容大小有上千兆的話,更好的處理方式是下面這樣:
http.createServer()
.on('request', function(req, res) {
var email = req.url.substr(req.url.lastIndexOf('/')+1)
if(!email) {
res.writeHead(404)
return res.end()
}
http.get('http://www.gravatar.com/avatar/'+crypto.createHash('md5').update(email).digest('hex'), function(resp) {
resp.pipe(res)
})
})
.listen(8080)
這里我們只是拉取圖片然后簡單地以管道方式響應(yīng)給客戶端,而不需要在響應(yīng)它之前讀取完整的數(shù)據(jù)存入緩存。
錯誤#9:出于Debug的目的使用Console.log
在Node.js里,“console.log”允許你打印任何東西到控制臺上。比如傳一個對象給它,它會以JavaScript對象的字符形式打印出來。它能接收任意多個的參數(shù)并將它們以空格作為分隔符打印出來。有很多的理由可以解釋為什么開發(fā)者喜歡使用它來debug他的代碼,然而我強烈建議你不要在實時代碼里使用“console.log”。你應(yīng)該要避免在所有代碼里使用“console.log”去debug,而且應(yīng)該在不需要它們的時候把它們注釋掉。你可以使用一種專門做這種事的庫代替,比如debug。
這些庫提供了便利的方式讓你在啟動程序的時候開啟或關(guān)閉具體的debug模式,例如,使用debug的話,你能夠阻止任何debug方法輸出信息到終端上,只要不設(shè)置DEBUG環(huán)境變量即可。使用它十分簡單:
// app.js
var debug = require(‘debug’)(‘a(chǎn)pp’)
debug(’Hello, %s!’, ‘world’)
開啟debug模式只需簡單地運行下面的代碼把環(huán)境變量DEBUG設(shè)置到“app”或“*”上:
DEBUG=app node app.js
錯誤#10:不使用監(jiān)控程序
不管你的Node.js代碼是跑在生產(chǎn)環(huán)境或是你的本地開發(fā)環(huán)境,一個能協(xié)調(diào)你程序的監(jiān)控程序是十分值得擁有的。一條經(jīng)常被開發(fā)者提及的,針對現(xiàn)代程序設(shè)計和開發(fā)的建議是你的代碼應(yīng)該有fail-fast機制。如果發(fā)生了一個意料之外的錯誤,不要嘗試去處理它,而應(yīng)該讓你的程序崩潰然后讓監(jiān)控程序在幾秒之內(nèi)重啟它。監(jiān)控程序的好處不只是重啟崩潰的程序,這些工具還能讓你在程序文件發(fā)生改變的時候重啟它,就像崩潰重啟那樣。這讓開發(fā)Node.js程序變成了一個更加輕松愉快的體驗。
Node.js有太多的監(jiān)控程序可以使用了,例如:
pm2
forever
nodemon
supervisor
所有這些工具都有它的優(yōu)缺點。一些擅長于在一臺機器上處理多個應(yīng)用程序,而另一些擅長于日志管理。不管怎樣,如果你想開始寫一個程序,這些都是不錯的選擇。
總結(jié)
你可以看到,這其中的一些錯誤能給你的程序造成破壞性的影響,在你嘗試使用Node.js實現(xiàn)一些很簡單的功能時一些錯誤也可能會導(dǎo)致你受挫。即使Node.js已經(jīng)使得新手上手十分簡單,但它依然有些地方容易讓人混亂。從其他語言過來的開發(fā)者可能已知道了這其中某些錯誤,但在Node.js新手里這些錯誤都是很常見的。幸運的是,它們都可以很容易地避免。我希望這個簡短指南能幫助新手更好地編寫Node.js代碼,而且能夠給我們大家開發(fā)出健壯高效的軟件。
更多信息請查看IT技術(shù)專欄