作為傳統的同步多執行緒伺服器的備選,非同步事件IO被很多企業評估。非同步意味著開發者需要學習新模式,忘掉老模式。轉換模式時需要忍受嚴重的大腦重新搭線,說不定電擊療法對此改變有幫助。
重佈線
利用node工作,最基礎的是需要理解非同步程式設計模式。我準備把非同步程式碼和同步程式碼放在一起,對比的方式來學習新模式。案例都使用了fs模組,因為它同時實現了同步和非同步的兩種風格的庫函式。
回撥
在node中,callback函式是非同步事件驅動編碼的基本構造塊。它是作為引數傳遞給非同步io操作的函式。它們在io操作完成後會被呼叫。比如fs模組的readdir()就是一個非同步io函式,它第一個引數為目錄名,第二個引數是一個callback 。當readdir()執行完畢,得到結果後,會呼叫這個callback,把結果經由callback的引數,傳遞給callback回撥內。
依賴程式碼和獨立程式碼
下面的案例要讀取當前目錄的檔案清單,列印檔名稱,讀出當前程式id。
同步版本:
1 2 3 4 5 6 7 8 9 10 11 12 |
var fs = require('fs'), filenames, i, processId; filenames = fs.readdirSync("."); for (i = 0; i < filenames.length; i++) { console.log(filenames[i]); } console.log("Ready."); processId = process.getuid(); |
非同步版本:
1 2 3 4 5 6 7 8 9 10 11 12 |
var fs = require('fs'), processId; fs.readdir(".", function (err, filenames) { var i; for (i = 0; i < filenames.length; i++) { console.log(filenames[i]); } console.log("Ready."); }); processId = process.getuid(); |
同步版本案例中,程式碼等待 fs.readdirSync() I/O 完成。和我們日常的程式碼類似。
列印檔名的程式碼是依賴於fs.readdirSync()的結果的,而獲取程式id則獨立於此輸出。因此它們在新的非同步版本程式碼內需要放到不同位置。規則是把依賴程式碼放到callback內,把獨立程式碼放到原來的位置不動。
次序
同步程式碼的標準模式是線性的,幾行程式碼需要一個接一個的順序執行,因為每一個依賴於上一行的輸出。如下案例中,程式碼首先修改檔案訪問模式(類似unix chmod 命令)、然後重新命名檔案、然後檢查被命名檔案是否為符號連結。顯然如果程式碼不能按次序執行;或者檔案在模式被修改前被重新命名;或者檢查符號連結程式碼在檔案被命名前完成;這些都會導致錯誤。
同步:
1 2 3 4 5 6 7 8 9 10 11 12 |
var fs = require('fs'), oldFilename, newFilename, isSymLink; oldFilename = "./processId.txt"; newFilename = "./processIdOld.txt"; fs.chmodSync(oldFilename, 777); fs.renameSync(oldFilename, newFilename); isSymLink = fs.lstatSync(newFilename).isSymbolicLink(); |
非同步:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
var fs = require('fs'), oldFilename, newFilename; oldFilename = "./processId.txt"; newFilename = "./processIdOld.txt"; fs.chmod(oldFilename, 777, function (err) { fs.rename(oldFilename, newFilename, function (err) { fs.lstat(newFilename, function (err, stats) { var isSymLink = stats.isSymbolicLink(); }); }); }); |
非同步程式碼中,這個程式碼的執行次序被翻譯為巢狀的callback。fs.lstat 回撥被巢狀在fs.rename 回撥內,fs.rename 回撥被嵌入到fs.chmod()回撥內
並行 Parallelisation
非同步編碼特別適合io操作併發的場景:程式碼執行不會被io呼叫所阻塞。多個io操作可以並行啟動。
如下案例:一個目錄的全部檔案大小會被累加得到一個總計。使用同步程式碼每次迭代都需要等待io操作返回單一檔案的大小。非同步程式碼則可以啟動全部io操作,無需等待輸出。當io操作中的一個完成,callback就會被呼叫一次,檔案大小會被累加。
同步
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
var fs = require('fs'); function calculateByteSize() { var totalBytes = 0, i, filenames, stats; filenames = fs.readdirSync("."); for (i = 0; i < filenames.length; i ++) { stats = fs.statSync("./" + filenames[i]); totalBytes += stats.size; } console.log(totalBytes); } calculateByteSize(); |
非同步
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
var fs = require('fs'); var count = 0, totalBytes = 0; function calculateByteSize() { fs.readdir(".", function (err, filenames) { var i; count = filenames.length; for (i = 0; i < filenames.length; i++) { fs.stat("./" + filenames[i], function (err, stats) { totalBytes += stats.size; count--; if (count === 0) { console.log(totalBytes); } }); } }); } calculateByteSize(); |
同步程式碼是直截了當的,無需解釋。
非同步版本程式碼採用巢狀callback來保證呼叫次序,前節也已經提及。
有趣的地方在 fs.stat的回撥函式內。它採用檔案計數count作為完成條件。變數count初始化為檔案總數,每次callback呼叫就遞減一次,一旦count等於0就說明全部io操作完成,合計檔案大小被計算完畢。
非同步程式碼案例中還有一個有趣的特徵:它使用了閉包。閉包是一個函式,它嵌入在另外一個函式內,並且內部函式能夠訪問了外部函式內宣告的變數,哪怕外部函式已經執行完成。fs.stat()的callback就是一個閉包,因為它訪問了在fs.readdir 的callback內宣告的count ,totalBytes 變數,哪怕這個callback早就已經執行完畢也可以訪問。閉包有自己的上下文,在這個上下文內可以把它要訪問的變數放置進來。沒有閉包的話,這兩個變數就必須設定為全域性變數。因為fs.stat()的callback函式沒有任何可以放置變數的上下文。calculateBiteSize() 函式早早的就執行完畢也不能放置上下文,唯有全域性的上下文還在。閉包就在這個場景下來救場的。在這樣的場合下,使用閉包就可以不必使用全域性變數了。
程式碼重用
可以抽取回撥函式為單獨函式,可以達到程式碼重用的效果。
下面的同步程式碼案例,展示了一個countFiles函式,它可以返回給定目錄的檔案數量。
同步:
1 2 3 4 5 6 7 8 9 10 11 12 |
var fs = require('fs'); var path1 = "./", path2 = ".././"; function countFiles(path) { var filenames = fs.readdirSync(path); return filenames.length; } console.log(countFiles(path1) + " files in " + path1); console.log(countFiles(path2) + " files in " + path2); |
非同步:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
var fs = require('fs'); var path1 = "./", path2 = ".././", logCount; function countFiles(path, callback) { fs.readdir(path, function (err, filenames) { callback(err, path, filenames.length); }); } logCount = function (err, path, count) { console.log(count + " files in " + path); }; countFiles(path1, logCount); countFiles(path2, logCount); |
替代fs.readdirSync()為非同步版本的fs.readdir()帶來的一個效應,就是本來在同步版本程式碼中的一個封閉的函式countFiles,現在也被迫變成一個帶有callback引數的非同步函式。因為呼叫countFiles的程式碼依賴這個函式的結果,而結果唯有等到fs.readdir()執行完畢。這就導致了countFiles的結構調整:不是console.log()呼叫countFiles,而是countFiles呼叫readdir(),後者完成後呼叫console.log 。
結論
本文強調了非同步程式設計的基本模式。轉換到非同步程式設計模式並不是微不足道的。恰恰相反,你需要一些時間去習慣它。複雜的提升帶來的回報是並行開發的複雜度戲劇化的被改善了。
Node的非同步IO事件驅動模型,再加上靈動、易用性的JavaScript,Node.js 有機會把在企業應用市場打下一個烙印,特別是當在涉及到高度並行的Web2.0應用的子領域內。
參考:
Tim Caswell, Software developer at Creationix innovations | SlideShare – http://www.slideshare.net/creationix