我們爲什麼要使用NodeJS

科普文一則,說說我對[url=http://nodejs.org]NodeJS[/url](一種服務端JavaScript實現)的一些認識,以及我爲什麼會向後端工程師推薦NodeJS.

[i]"Node.js 是服務器端的 JavaScript 運行環境,它具有[url=http://en.wikipedia.org/wiki/Non-blocking_algorithm]無阻塞(non-blocking)[/url]和[url=http://en.wikipedia.org/wiki/Event-driven_programming]事件驅動(event-driven)[/url]等的特色,Node.js 採用 [url=http://code.google.com/p/v8/]V8[/url] 引擎,同樣,Node.js 實現了類似 [url=http://apache.org/]Apache[/url] 和 [url=http://nginx.org/]nginx[/url] 的web服務,讓你可以通過它來搭建基於 JavaScript 的 Web App。"[/i]

上週末參與了CNodeJS社區的第一次北京聚會,現場氣氛非常的好.而作爲一名前端開發,我在後面的討論環節講了下我對NodeJS的看法,主要回答的問題是"我爲什麼會向後端工程師推薦NodeJS".這其實是去年年底大團隊技術總結的話題之一,包含在我之前發過的PPT:[url=http://limu.iteye.com/blog/852813]團隊年終技術Review[/url]中.因爲之前沒有準備,當天倉促上陣,也不知道說清楚了沒,不如就在這裏再詳細展開記錄下.

我想不僅僅是NodeJS,當我們要引入任何一種新技術前都必須要搞清楚幾個問題:
1.我們遇到了什麼問題?
2.這項新技術解決什麼問題,是否契合我們遇到的問題?
3.我們遇到問題的多種解決方案中,當前這項新技術的優勢體現在哪兒?
4.使用新技術,帶來哪些新問題,嚴重麼,我們能否解決掉?

[b]我們的問題:Server端阻塞[/b]
NodeJS被設計用來解決服務端阻塞問題.通過一段簡單的代碼解釋何爲阻塞:

//根據ID,在數據庫中Persons表中查出Name
var name = db.query("select name from persons where id=1");
//進程等待數據查詢完畢,然後使用查詢結果.
output("name")

這段代碼的問題是在上面兩個語句之間,在整個數據查詢的過程中,當前程序進程往往只是在等待結果的返回.這就造成了進程的阻塞.對於高併發,I/O密集行的網絡應用中,一方面進程很長時間處於等待狀態,一方面爲了應付新的請求不斷的增加新的進程.這樣的浪費會導致系統支持QPS遠遠小於後端數據服務能夠支撐的QPS,成爲系統的瓶頸.而且這樣的系統也特別容易被慢鏈接攻擊(客戶端故意不接收或減緩接收數據,加長進程等待時間).
[b]如何解決阻塞問題[/b]
解決這個問題的辦法是,建立一種事件機制,發起查詢請求之後,立即將進程交出,當數據返回後觸發事件,再繼續處理數據:

//定義如何後續數據處理函數
function onDataLoad(name){
output("name");
}
//發起數據請求,同時指定數據返回後的回調函數
db.query("select name from persons where id=1",onDataLoad);

我們看到按照這個思路解決阻塞問題,首先我們要提供一套高效的異步事件調度機制.而主要用於處理瀏覽器端的各種交互事件的JavaScript.相對於其他語言,至少有兩個關鍵點特別適合完成這個任務.
[b]爲什麼JS適合解決阻塞問題[/b]
首先JavaScript是一種[url=http://baike.baidu.com/view/1711147.htm]函數式編程[/url]語言,函數編程語言最重要的數學基礎是[url=http://baike.baidu.com/view/1179241.htm]λ演算(lambda calculus)[/url] -- 即函數可以接受函數當作輸入(參數)和輸出(返回值).
函數可以作爲其他函數的參數輸入的這個特性,使得爲事件指定回調函數變得很容易.特別是JavaScript還支持匿名函數.通過匿名函數的輔助,之前的代碼可以進行簡寫如下.

db.query("select name from persons where id=1",function(name){
output(name);
});

還有一個關鍵問題是,異步回調的運行上下文保持(稱狀態保持),我看一段代碼來說明何爲狀態保持.

//傳統同步寫法:將查詢和結果打印抽象爲一個方法
function main(){
var id = "1";
var name = db.query("select name from persons where id=" + id);
output("person id:" + id + ", name:" + name);
}
main();

前面的寫法在傳統的阻塞是編程中非常常見,但接下來進行異步改寫時會遇到一些困擾.

//異步寫法:
function main(){
var id = "1";
db.query("select name from persons where id=" + id,function(name){
output("person id:" + id + ", name:" + name);//n秒後數據返回後執行回調
});
}
main();

細心的同學可以注意到,當等待了n秒數據查詢結果返回後執行回調時.回調函數中卻仍然使用了main函數的局部變量"id",而"id"已經在n秒前走出了其作用域,這是爲什麼呢?熟悉JavaScript的同學會淡然告訴您:"這是閉包(closures)~".
其實在複雜的應用中,我們一定會遇到這類場景.即在函數運行時需要訪問函數定義時的上下文數據([i]注意:一定要區分函數定義時和函數運行時這樣的字眼和其代表的意義,不然很快就會糊塗[/i]).而在異步編程中,函數的定義和運行又分處不同的時間段,那麼保持上下文的問題變得更加突出了.
在這個例子中,db.query作爲一個公共的數據庫查詢方法,把"id"這個業務數據傳入給db.query,交由其保存是不太合適的.但聰明的同學們可以抽象一下,讓db.query再支持一個需要保持狀態的數據對象傳入,當數據查詢完畢後可以把這些狀態數據原封不動的回傳.如下:

function main(){
var id = "1";
var currentState = new Object();
currentState.person_id = id;
db.query("select name from persons where id=" + id, function(name,state){
output("person id:" + state.person_id + ", name:" + name);
},currentState);//注意currentState是db.query的第三個參數
}
main();

記住這種重要的思路,我們再看看是否還能進一步的抽象?可以的,不過接下的動作之前,我們先要了解在JavaScript中一個函數也是一個對象.一個函數實例fn除了具備可函數體的定義之外,仍然可以在這個函數對象實例之上擴展屬性,如fn.a=1;受到這個啓發我們嘗試把需要保持的狀態直接綁定到函數實例上.

function main(){
var id = "1";
var currentState = new Object();
currentState.person_id = id;
function onDataLoad(name){
output("person id:" + onDataLoad.state.person_id + ", name:" + name);
}
onDataLoad.state = currentState ;//爲函數指定state屬性,用於保持狀態
db.query("select name from persons where id=" + id, onDataLoad);
}

我們做了什麼?生成了currentState對象,然後在函數onDataLoad定義時,將currentState綁定給onDataLoad這個函數實例.那麼在onDataLoad運行時,就可以拿到定義時的state對象了.而閉包就是內置了這個過程而已.

在每個函數運行時,都有一個運行時對象稱爲Execution context,它包含如下variable object(VO,變量對象),scope chain(作用域鏈)和thisValue三部分.詳見[url=http://dmitrysoshnikov.com/ecmascript/javascript-the-core/#execution-context]ECMA-262 JavaScript. The Core[/url]
[img]http://dl.iteye.com/upload/attachment/469149/04d45d28-49c8-3c5b-a619-111b971b7217.png[/img]
其中變量對象VO,包含了所有局部變量的引用.對於main函數,局部變量"id"存儲在VO.id內.看起來用VO來代替我們的currentSate最合適了.但main函數還可能嵌套在其他函數之內,所以我們需要ScopeChain,它是一個包含當前運行函數VO和其所有父函數scope的數組.
所以在這個例子中,在onDataLoad函數[b]定義時[/b],就爲默認爲其綁定了一個[[scope]]屬性指向其父函數的ExecutionContext的ScopeChain.而當函數onDataLoad執行時,就可以通過[[scope]]屬性來訪問父函數的VO對象來找到id,如果父函數的VO中沒有id這個屬性,就再繼續向上查找其祖先的VO對象,直到找到id這個屬性或到達最外層返回undefined.也正是因爲這個引用,造成VO的引用計數不爲0,在走出作用域時,纔不會被垃圾回收.
很多人覺得閉包很難理解,其實我們只要能明確需要區分函數定義和函數運行這兩個時機,記住[b]閉包讓函數在運行時能夠訪問到函數定義時的所處作用域內的所有變量[/b].或者說[b]函數定義時能訪問到什麼變量,那麼在函數運行時通過相同的變量名一樣能訪問到[/b].

關於狀態保持是本文的重點,在我看到的多數NodeJS的介紹文章,並沒有詳解這裏,我們只是知道了要解決阻塞問題,但是JavaScript解決阻塞問題的優勢在哪裏,作爲一個前端開發,我想有必要詳細解釋一下.

其實說到狀態保持還有一個類似的場景,比如用戶從A頁面提交表單到B頁面,如果提交數據校驗不通過,則需要返回A頁面,同時保持用戶在A頁面填寫的內容並提示用戶修改不對的地方.從提交到返回顯示這也是一個包含網絡交互的異步過程.傳統網頁,用戶的狀態通過請求傳遞到服務端,交由後端狀態保持(類似交給db.query的currentSate).而使用Ajax的網頁,因爲並未離開原頁面,那麼服務端只要負責校驗用戶提交的數據是否正確即可,發送錯誤,返回錯誤處相關信息即可,這就是所謂前端狀態保持.可以看到這個場景裏邊服務端做的事情變少了,變純粹了.正如我們的例子中db.query不再存儲轉發第三個state參數,變得更輕量.

我們看到通過JavaScript函數式語言特性,匿名函數支持和閉包很漂亮的解決了同步編程到異步編程轉化過程中遇到的一系列最重要的問題.但JavaScript是否就是最好的?這就要回答我們引用新技術時需要考慮的最後一個問題了
[b]使用NodeJS是否帶來額外的困擾,如何解決[/b]
性能真的是最好麼?不用比較我們也可以得到結論NodeJS,做無阻塞編程性能較難做到極致.何爲極致,處理一個請求需要佔用多少內存,多少cpu資源,多少帶寬,如果有浪費就不是極致.阻塞式編程浪費了大量進程資源只是在等待,導致大量內存和cpu的浪費.NodeJs好很多,但也正是因爲一些閉包等JS內建機制也會導致資源的浪費,看下面的代碼

function main(){
var id = "1";
var str = "..."; //這裏存儲一個2M的字符串
db.query("select name from persons where id=" + id,function(name){
output("person id:" + id + ", name:" + name);//n秒後數據返回後執行回調
});
}
main();

直到數據查詢完成,變量str所使用的2M內存不會被釋放,而str保持下去可能並沒有意義.前面已經解釋過閉包的原理,閉包並沒有智能到只包起來今後可能被訪問到的對象.即使不瞭解閉包的原理,也可以通過一段簡單腳本驗證這點:

function main(){
var id = "1";
var str = "..."; //這裏存儲一個2M的字符串
window.setTimeout(function(){
debugger; //我們在這裏設置斷點
},10000)
}
main();

我們在回調函數當中只設置一個斷點,並不指明我們要訪問哪個變量.然後我們在控制檯監視一下,id和str都是可以拿到的.(此處結論不嚴謹,各種新瀏覽器已經就此做了相關優化,詳見評論2樓,特別是2樓給出的詳細測試報告的連接)
所以我來不負責任的預測一下,性能極端苛刻的場景,無阻塞是未來,但無阻塞發展下去,或者有更輕量的腳本引擎產生(lua?),或者V8JS引擎可能要調整可以disable閉包,或者我們可以通過給JS開發靜態編譯器在代碼發佈前優化我們的代碼.

我之前談到過JS靜態編譯器:[url=http://limu.iteye.com/blog/845870]"如果給JS代碼發佈正式使用前增加一個編譯步驟,我們能做些什麼"[/url],動態語言的實時編譯系統只完成了靜態語言編譯中的將代碼轉化爲字節碼的過程,而靜態語言編譯器的額外工作,如接口校驗,全局性能優化等待.所以JS也需要一個靜態的編譯器來完成這些功能,[url=http://calendar.perfplanet.com/2010/coding-better-object-oriented-javascript-with-closure-compiler/]Google利用ClouserComplier提供了系列編譯指令,讓JS更好的實現OO編程[/url],我來利用靜態編譯器解決一些JS做細粒度模塊化引入的性能方面的問題.而老趙最近的項目[url=https://github.com/JeffreyZhao/jscex]JSCEX[/url],則也是利用JS發佈前的編譯環節重點解決異步編程的代碼複雜度問題.
我們習慣於阻塞式編程的寫法,切換到異步模式編程,往往對於太多多層次的callback嵌套弄得不知所措.所以老趙開發的JS靜態編譯器,借鑑F#的Computation Expressions,讓大家遵守一些小的約定後,能夠仍然保持同步編程的寫法,寫完的代碼通過JSCEX編譯爲異步回調式的代碼再交給JS引擎執行.
如果這個項目足夠好用,那就也解決了一個使用NodeJS這種新技術,卻加大編程複雜度這個額外引入的困擾.甚至可以沿着這個思路,在靜態編譯階段優化內存使用.

[b]NodeJS還要解決什麼問題[/b]
說了這麼多,無阻塞編程要做的還遠不止這些.首先需要一個高效的JS引擎,高效的事件池和線程池.另外幾乎所有和NodeJS交互的傳統模塊如文件系統,數據訪問,HTTP解析,DNS解析都是阻塞式的,都需要額外改造.
正是NodeJS作者極其團隊,認清問題問題以及JS解決這些問題方面的優勢.基於高效的V8 JavaScript引擎,貢獻了大量的智慧和精力解決上述大部分問題後纔有NodeJS橫空出世.
當前Node社區如此火熱,[url=https://github.com/joyent/node/wiki/modules]千餘開源的NodeJS模塊[/url],活躍在WebFramework,WebSocket,RPC,模板引擎,數據抓取服務,圖形圖像幾乎所有工程領域.

[b]後記[/b]
本文主要的信息來自nodejs作者在[url=http://s3.amazonaws.com/four.livejournal/20091117/jsconf.pdf]JSConf09[/url],[url=http://nodejs.org/jsconf2010.pdf]JSConf10[/url]上的分享.
而作爲前端開發,着重講了函數式編程,閉包對於無阻塞開發的重要意義.我期待這篇文章能夠給前端和後端同學都帶來收穫.

同樣作爲前端開發,不得不再插幾句,說說服務端JS能夠解決的另一個問題:
當前的Web開發前後端使用不同的語言,很多相同的業務邏輯要前後端分別用不同語言重複實現.比如越來越多重度依賴JS的胖客戶端應用,當客戶瀏覽器禁用JavaScript時,則需要使用服務端語言將主業務流程再實現一次(這即是所謂的"漸進增強").
當我們擁有了服務端JavaScript語言,我們自然就會想到能否利用NodeJS做到"一次開發,漸進增強".解決掉這個爲小量用戶,浪費大量時間的惱人的問題.我們先要解決問題,這是使用NodeJS的最大動力.基於之前的統計,因爲各種原因瀏覽器不支持JS的用戶大概接近1%,至少淘寶絕對不會主動放棄這部分用戶.至於在服務端也使用JS是否能夠替掉LAMP架構,抑或NodeJS會對常見MVC架構帶來何種衝擊,V/C這些層是否能在前後端任意流動這些問題都是NodeJS解決問題後帶來的額外話題.
"一次開發,漸進增強"這方面的實踐,YAHOO仍然是先驅,早在一年多前開始YAHOO通過[url=https://github.com/davglass/nodejs-yui3]nodejs-yui3[/url]項目做了很多卓越的貢獻,而淘寶自主開發的前端框架Kissy也有服務端運行的相關嘗試,詳見我的同事[url=http://ued.taobao.com/blog/2010/11/04/nodejs-kissy/]拔赤的分享[/url].而接下來的幾個月我也將在這方面做一些嘗試,有一定積累後我將再寫一篇文章更好的分析這個問題..

JS在誕生之時就不僅僅是瀏覽器端工具,如今JS能再一次回到服務端展示拳腳,感謝V8,感謝NodeJS作者,團隊和社區的諸多貢獻者,祝Node好運,JS好運.
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章