自 Node.js 公諸於世的那一刻,就伴隨著讚揚和批評的聲音。這個爭論仍在持續,而且並不會很快消失。而我們常常忽略掉這些爭論產生的原因,每種程式語言和平臺都是因某些問題而受到批評,而這些問題的產生,是取決於我們如何使用這個平臺。不管有多難才能寫出安全的 Node.js 程式碼,或有多容易寫出高併發的程式碼,該平臺已經有相當長一段時間,並已被用來建立一個數量龐大、穩健和成熟的 web 伺服器。這些 web 伺服器伸縮性強,並且它們通過在 Internet 上穩定的執行時間,證明自己的穩定性。
然而,像其它平臺一樣,Node.js 容易因開發者問題而受到批評。一些錯誤會降低效能,而其它一些問題會讓 Node.js 直接崩潰。在這篇文章裡,我們將會聊一聊關於 Node.js 新手的 10 個常犯錯誤,並讓他們知道如何避免這些錯誤,從而成為一名 Node.js 高手。
錯誤 #1:阻塞事件迴圈
JavaScript 在 Node.js (就像在瀏覽器一樣) 提供單執行緒執行環境。這意味著你的程式不能同時執行兩部分程式碼,但能通過 I/O 繫結非同步回撥函式實現併發。例如:一個來自Node.js 的請求是到資料庫引擎獲取一些文件,在這同時允許 Node.js 專注於應用程式其它部分:
1 2 3 4 5 6 |
// 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.. // 嘗試從資料庫中獲取一個使用者物件。在這個函式執行的一刻,Node.js 有空去執行程式碼其它部分.. db.User.get(userId, function(err, user) { // .. until the moment the user object has been retrieved here // .. 直到使用者物件檢索到這裡的那一刻 }) |
然而,具有計算密集型程式碼的 Node.js 例項被數以萬計客戶端同時連線執行時,會導致阻塞事件迴圈,並使所有客戶端處於等待響應狀態。計算密集型程式碼,包括嘗試給一個龐大陣列進行排序操作和執行一個格外長的迴圈等。例如:
1 2 3 4 5 |
function sortUsersByAge(users) { users.sort(function(a, b) { return a.age > b.age ? -1 : 1 }) } |
基於小 “users” 陣列執行 “sortUserByAge” 函式,可能沒什麼問題,當基於龐大陣列時,會嚴重影響整體效能。如果在不得不這樣操作的情況下,你必須確保程式除了等待事件迴圈而別無他事(例如,用 Node.js 建立命令列工具的一部分,整個東西同步執行是沒問題的),然後這可能沒問題。然而,在 Node.js 伺服器例項嘗試同時服務成千上萬個使用者的情況下,這將是一個毀滅性的問題。
如果使用者陣列是從資料庫檢索出來的,有個解決辦法是,先在資料庫中排序,然後再直接檢索。如果因需要計算龐大的金融交易歷史資料總和,而造成阻塞事件迴圈,這可以建立額外的worker / queue 來避免阻塞事件迴圈。
正如你所看到的,這沒有新技術來解決這類 Node.js 問題,而每種情況都需要單獨處理。而基本解決思路是:不要讓 Node.js 例項的主執行緒執行 CPU 密集型工作 – 客戶端同時連結時。
錯誤 #2:呼叫回撥函式多於一次
JavaScript 一直都是依賴於回撥函式。在瀏覽器中,處理事件是通過呼叫函式(通常是匿名的),這個動作如同回撥函式。Node.js 在引進 promises 之前,回撥函式是非同步元素用來互相連線對方的唯一方式 。現在回撥函式仍被使用,並且包開發者仍然圍繞著回撥函式設計 APIs。一個關於使用回撥函式的常見 Node.js 問題是:不止一次呼叫。通常情況下,一個包提供一個函式去非同步處理一些東西,設計出來是期待有一個函式作為最後一個引數,當非同步任務完成時就會被呼叫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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” 都有一個返回語句(return),而最後一個 “done” 則可省略返回語句。這是因為呼叫回撥函式後,並不會自動結束當前執行函式。如果第一個 “return” 註釋掉,然後給這個函式傳進一個非字串密碼,導致 “computeHash” 仍然會被呼叫。這取決於 “computeHash” 如何處理這樣一種情況,“done” 可能會呼叫多次。任何一個人在別處使用這個函式可能會變得措手不及,因為它們傳進的該回撥函式被多次呼叫。
只要小心就可以避免這個 Node.js 錯誤。而一些 Node.js 開發者養成一個習慣是:在每個回撥函式呼叫前新增一個 return 關鍵字。
1 2 3 |
if(err) { return done(err) } |
對於許多非同步函式,它的返回值幾乎是無意義的,所以該方法能讓你很好地避免這個問題。
錯誤 #3:函式巢狀過深
函式巢狀過深,時常被稱為“回撥函式地獄”,但這並不是 Node.js 自身問題。然而,這會導致一個問題:程式碼很快失去控制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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’) }) }) }) } |
任務有多複雜,程式碼就有多糟糕。以這種方式巢狀回撥函式,我們很容易就會碰到問題而崩潰,並且難以閱讀和維護程式碼。一種替代方式是以函式宣告這些任務,然後將它們連線起來。儘管,有一種最乾淨的方法之一 (有爭議的)是使用 Node.js 工具包,它專門處理非同步 JavaScript 模式,例如 Async.js :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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.waterfall”,Async.js 提供了很多其它函式來解決不同的非同步模式。為了簡潔,我們在這裡使用一個較為簡單的案例,但實際情況往往更糟。
錯誤 #4:期望回撥函式以同步方式執行
非同步程式的回撥函式並不是 JavaScript 和 Node.js 獨有的,但它們是造成回撥函式流行的原因。而對於其它程式語言,我們潛意識地認為執行順序是一步接一步的,如兩個語句將會執行完第一句再執行第二句,除非這兩個語句間有一個明確的跳轉語句。儘管那樣,它們經常侷限於條件語句、迴圈語句和函式呼叫。
然而,在 JavaScript 中,回撥某個特定函式可能並不會立刻執行,而是等到任務完成後才執行。下面例子就是直到沒有任何任務,當前函式才執行:
1 2 3 4 5 6 7 |
function testTimeout() { console.log(“Begin”) setTimeout(function() { console.log(“Done!”) }, duration * 1000) console.log(“Waiting..”) } |
你會注意到,呼叫 “testTimeout” 函式會首先列印 “Begin”,然後列印 “Waiting..”,緊接大約一秒後才列印 “Done!”。
任何一個需要在回撥函式被觸發後執行的東西,都要把它放在回撥函式內。
錯誤 #5:用“exports”,而不是“module.exports”
Node.js 將每個檔案視為一個孤立的小模組。如果你的包(package)含有兩個檔案,或許是 “a.js” 和 “b.js”。因為 “b.js” 要獲取 “a.js” 的功能,所以 “a.js” 必須通過為 exports 物件新增屬性來匯出它。
1 2 |
// a.js exports.verifyPassword = function(user, password, done) { ... } |
當這樣操作後,任何引入 “a.js” 模組的檔案將會得到一個帶有屬性方法 “verifyPassword” 的物件:
1 2 |
// b.js require(‘a.js’) // { verifyPassword: function(user, password, done) { ... } } |
然而,如果我們想直接匯出這個函式,而不是作為某個物件的屬性呢?我們能通過覆蓋 exports 物件來達到這個目的,但我們不能將它視為一個全域性變數:
1 2 |
// a.js module.exports = function(user, password, done) { ... } |
注意,我們是如何將 “exports” 作為 module 物件的一個屬性。在這裡知道 “module.exports” 和 “exports” 之間區別是非常重要的,並且這經常會導致 Node.js 開發新手們產生挫敗感。
錯誤 #6:在回撥函式內丟擲錯誤
JavaScript 有個“異常”概念。異常處理與大多數傳統語言的語法類似,例如 Java 和 C++,JavaScript 能在 try-catch 塊內 “丟擲(throw)” 和 捕捉(catch)異常:
1 2 3 4 5 6 7 8 9 10 11 12 |
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!’) } |
然而,如果你把 try-catch 放在非同步函式內,它會出乎你意料,它並不會執行。例如,如果你想保護一段含有很多非同步活動的程式碼,而且這段程式碼包含在一個 try-catch 塊內,而結果是:它不一定會執行。
1 2 3 4 5 6 7 8 9 10 11 12 |
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” 非同步觸發了,雖然作用域裡包含的 try-catch 塊離開了上下文,仍然能捕捉那些在回撥函式的丟擲的錯誤。
這就是 Node.js 中如何處理錯誤的另外一種方式。另外,有必要遵循所有回撥函式的引數(err, …)模式,所有回撥函式的第一個引數期待是一個錯誤物件。
錯誤 #7:認為數字是整型
數字在 JavaScript 中都是浮點型,JS 沒有整型。你可能不能預料到這將是一個問題,因為數大到超出浮點型範圍的情況並不常見。
1 |
Math.pow(2, 53)+1 === Math.pow(2, 53) |
不幸的是,在 JavaScript 中,這種關於數字的怪異情況遠不止於此。儘管數字都是浮點型,對於下面的表示式,操作符對於整型也能正常執行:
1 |
5 >> 1 === 2 // true |
然而,不像算術運算子那樣,位操作符和位移操作符只能操作後 32 位,如同 “整型” 數。例如,嘗試位移 “Math.pow(2,53)” 1 位,會得到結果 0。嘗試與 1 進行按位或運算,得到結果 1。
1 2 3 |
Math.pow(2, 53) / 2 === Math.pow(2, 52) // true Math.pow(2, 53) >> 1 === 0 // true Math.pow(2, 53) | 1 === 1 // true |
你可能很少需要處理很大的數,但如果你真的要處理的話,有很多大整型庫能對大型精度數完成重要的數學運算,如 node-bigint。
錯誤 #8:忽略了 Streaming(流) API 的優勢
大家都說想建立一個小型代理伺服器,它能響應從其它伺服器獲取內容的請求。作為一個案例,我們將建立一個供應 Gravatar 影像的小型 Web 伺服器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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) |
在這個特殊例子中有一個 Node.js 問題,我們從 Gravatar 獲取影像,將它讀進快取區,然後響應請求。這不是一個多麼糟糕的問題,因為 Gravatar 返回的影像並不是很大。然而,想象一下,如果我們代理的內容大小有成千上萬兆。那就有一個更好的方法了:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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) |
這裡,我們獲取影像,並簡單地通過管道響應給客戶端。絕不需要我們在響應之前,將全部內容讀取到緩衝區。
錯誤 #9:把 Console.log 用於除錯目的
在 Node.js 中,“console.log” 允許你向控制檯列印幾乎所有東西。傳遞一個物件給它,它會以 JavaScript 物件字面量的方式列印出來。它接受任意多個引數,並以空格作為分隔符列印它們。有許多個理由讓開發者很想用這個來除錯(debug)自己的程式碼;然而,我強烈建議你避免在真正程式裡使用 “console.log” 。你應該避免在全部程式碼裡使用 “console.log” 進行除錯(debug),當不需要它們的時候,應註釋掉它們。相反,使用專門為除錯建立的庫,如:debug。
當你開始編寫應用程式時,這些庫能方便地啟動和禁用某行除錯(debug)功能。例如,通過不設定 DEBUG 環境變數,能夠防止所有除錯行被列印到終端。使用它很簡單:
1 2 3 |
// app.js var debug = require(‘debug’)(‘app’) debug(’Hello, %s!’, ‘world’) |
為了啟動除錯行,將環境變數 DEBUG 設定為 “app” 或 “*”,就能簡單地執行這些程式碼了:
1 |
DEBUG=app node app.js |
錯誤 #10:不使用管理程式
不管你的 Node.js 程式碼執行在生產環境還是本地開發環境,一個監控管理程式能很好地管理你的程式,所以它是一個非常有用並值得擁有的東西。開發者設計和實現現代應用時常常推薦的一個最佳實踐是:快速失敗,快速迭代。
如果發生一個意料之外的錯誤,不要試圖去處理它,而是讓你的程式崩潰,並有個監控者在幾秒後重啟它。管理程式的好處不止是重啟崩潰的程式。這個工具允許你重啟崩潰的程式的同時,也允許檔案發生改變時重啟程式。這讓開發 Node.js 程式變成一段更愉快的體驗。
有很多 Node.js 可用的管理程式。例如:
所有這些工具各有優劣。一些有利於在同一個機器裡處理多個應用程式,而其它擅長於日誌管理。然而,如果你想開始使用這些程式,它們都是很好的選擇。
總結
正如你所知道的那樣,一些 Node.js 問題能對你的程式造成毀滅性打擊。而一些則會在你嘗試完成最簡單的東西時,讓你產生挫敗感。儘管 Node.js 的開發門檻較低,但它仍然有很容易搞混的地方。從其它程式語言轉過來學習 Node.js 開發者可能會遇到這些問題,但這些錯誤在 Node.js 新手中也是十分常見的。幸運的是,它們很容易避免。我希望這個簡短指導能幫助 Node.js 新手寫出更優秀的程式碼,併為我們開發出穩定高效的軟體。
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式