本書狀態
你正在閱讀的已經是本書的最終版。因此,只有當進行錯誤更正以及針對新版本Node.js的改動進行對應的修正時,才會進行更新。
本書中的代碼案例都在Node.js 0.6.11版本中測試過,可以正確工作。
讀者對象
本書最適合與我有相似技術背景的讀者: 至少對一門諸如Ruby、Python、PHP或者Java這樣面向對象的語言有一定的經驗;對JavaScript處於初學階段,並且完全是一個Node.js的新手。
這裡指的適合對其他編程語言有一定經驗的開發者,意思是說,本書不會對諸如數據類型、變量、控制結構等等之類非常基礎的概念作介紹。要讀懂本書,這些基礎的概念我都默認你已經會了。
然而,本書還是會對JavaScript中的函數和對象作詳細介紹,因為它們與其他同類編程語言中的函數和對象有很大的不同。
本書結構
讀完本書之後,你將完成一個完整的web應用,該應用允許用戶浏覽頁面以及上傳文件。
當然了,應用本身並沒有什麼了不起的,相比為了實現該功能書寫的代碼本身,我們更關注的是如何創建一個框架來對我們應用的不同模塊進行干淨地剝離。 是不是很玄乎?稍後你就明白了。
本書先從介紹在Node.js環境中進行JavaScript開發和在浏覽器環境中進行JavaScript開發的差異開始。
緊接著,會帶領大家完成一個最傳統的“Hello World”應用,這也是最基礎的Node.js應用。
最後,會和大家討論如何設計一個“真正”完整的應用,剖析要完成該應用需要實現的不同模塊,並一步一步介紹如何來實現這些模塊。
可以確保的是,在這過程中,大家會學到JavaScript中一些高級的概念、如何使用它們以及為什麼使用這些概念就可以實現而其他編程語言中同類的概念就無法實現。
該應用所有的源代碼都可以通過 本書Github代碼倉庫:https://github.com/ManuelKiessling/NodeBeginnerBook/tree/master/code/application.
JavaScript與Node.js
JavaScript與你
拋開技術,我們先來聊聊你以及你和JavaScript的關系。本章的主要目的是想讓你看看,對你而言是否有必要繼續閱讀後續章節的內容。
如果你和我一樣,那麼你很早就開始利用HTML進行“開發”,正因如此,你接觸到了這個叫JavaScript有趣的東西,而對於JavaScript,你只會基本的操作——為web頁面添加交互。
而你真正想要的是“干貨”,你想要知道如何構建復雜的web站點 —— 於是,你學習了一種諸如PHP、Ruby、Java這樣的編程語言,並開始書寫“後端”代碼。
與此同時,你還始終關注著JavaScript,隨著通過一些對jQuery,Prototype之類技術的介紹,你慢慢了解到了很多JavaScript中的進階技能,同時也感受到了JavaScript絕非僅僅是window.open() 那麼簡單。 .
不過,這些畢竟都是前端技術,盡管當想要增強頁面的時候,使用jQuery總讓你覺得很爽,但到最後,你頂多是個JavaScript用戶,而非JavaScript開發者。
然後,出現了Node.js,服務端的JavaScript,這有多酷啊?
於是,你覺得是時候該重新拾起既熟悉又陌生的JavaScript了。但是別急,寫Node.js應用是一件事情;理解為什麼它們要以它們書寫的這種方式來書寫則意味著——你要懂JavaScript。這次是玩真的了。
問題來了: 由於JavaScript真正意義上以兩種,甚至可以說是三種形態存在(從中世紀90年代的作為對DHTML進行增強的小玩具,到像jQuery那樣嚴格意義上的前端技術,一直到現在的服務端技術),因此,很難找到一個“正確”的方式來學習JavaScript,使得讓你書寫Node.js應用的時候感覺自己是在真正開發它而不僅僅是使用它。
因為這就是關鍵: 你本身已經是個有經驗的開發者,你不想通過到處尋找各種解決方案(其中可能還有不正確的)來學習新的技術,你要確保自己是通過正確的方式來學習這項技術。
當然了,外面不乏很優秀的學習JavaScript的文章。但是,有的時候光靠那些文章是遠遠不夠的。你需要的是指導。
本書的目標就是給你提供指導。
簡短申明
業界有非常優秀的JavaScript程序員。而我並非其中一員。
我就是上一節中描述的那個我。我熟悉如何開發後端web應用,但是對“真正”的JavaScript以及Node.js,我都只是新手。我也只是最近學習了一些JavaScript的高級概念,並沒有實踐經驗。
因此,本書並不是一本“從入門到精通”的書,更像是一本“從初級入門到高級入門”的書。
如果成功的話,那麼本書就是我當初開始學習Node.js最希望擁有的教程。
服務端JavaScript
JavaScript最早是運行在浏覽器中,然而浏覽器只是提供了一個上下文,它定義了使用JavaScript可以做什麼,但並沒有“說”太多關於JavaScript語言本身可以做什麼。事實上,JavaScript是一門“完整”的語言: 它可以使用在不同的上下文中,其能力與其他同類語言相比有過之而無不及。
Node.js事實上就是另外一種上下文,它允許在後端(脫離浏覽器環境)運行JavaScript代碼。
要實現在後台運行JavaScript代碼,代碼需要先被解釋然後正確的執行。Node.js的原理正是如此,它使用了Google的V8虛擬機(Google的Chrome浏覽器使用的JavaScript執行環境),來解釋和執行JavaScript代碼。
除此之外,伴隨著Node.js的還有許多有用的模塊,它們可以簡化很多重復的勞作,比如向終端輸出字符串。
因此,Node.js事實上既是一個運行時環境,同時又是一個庫。
要使用Node.js,首先需要進行安裝。關於如何安裝Node.js,這裡就不贅述了,可以直接參考官方的安裝指南。安裝完成後,繼續回來閱讀本書下面的內容。
“Hello World”
好了,“廢話”不多說了,馬上開始我們第一個Node.js應用:“Hello World”。
打開你最喜歡的編輯器,創建一個helloworld.js文件。我們要做就是向STDOUT輸出“Hello World”,如下是實現該功能的代碼:
復制代碼 代碼如下:console.log("Hello World");
保存該文件,並通過Node.js來執行:
復制代碼 代碼如下:node helloworld.js
正常的話,就會在終端輸出Hello World 。
好吧,我承認這個應用是有點無趣,那麼下面我們就來點“干貨”。
一個完整的基於Node.js的web應用
用例
我們來把目標設定得簡單點,不過也要夠實際才行:
1.用戶可以通過浏覽器使用我們的應用。
2.當用戶請求http://domain/start時,可以看到一個歡迎頁面,頁面上有一個文件上傳的表單。
3.用戶可以選擇一個圖片並提交表單,隨後文件將被上傳到http://domain/upload,該頁面完成上傳後會把圖片顯示在頁面上。
差不多了,你現在也可以去Google一下,找點東西亂搞一下來完成功能。但是我們現在先不做這個。
更進一步地說,在完成這一目標的過程中,我們不僅僅需要基礎的代碼而不管代碼是否優雅。我們還要對此進行抽象,來尋找一種適合構建更為復雜的Node.js應用的方式。
應用不同模塊分析
我們來分解一下這個應用,為了實現上文的用例,我們需要實現哪些部分呢?
1.我們需要提供Web頁面,因此需要一個HTTP服務器
2.對於不同的請求,根據請求的URL,我們的服務器需要給予不同的響應,因此我們需要一個路由,用於把請求對應到請求處理程序(request handler)
3.當請求被服務器接收並通過路由傳遞之後,需要可以對其進行處理,因此我們需要最終的請求處理程序
4.路由還應該能處理POST數據,並且把數據封裝成更友好的格式傳遞給請求處理入程序,因此需要請求數據處理功能
5.我們不僅僅要處理URL對應的請求,還要把內容顯示出來,這意味著我們需要一些視圖邏輯供請求處理程序使用,以便將內容發送給用戶的浏覽器
6.最後,用戶需要上傳圖片,所以我們需要上傳處理功能來處理這方面的細節
我們先來想想,使用PHP的話我們會怎麼構建這個結構。一般來說我們會用一個Apache HTTP服務器並配上mod_php5模塊。
從這個角度看,整個“接收HTTP請求並提供Web頁面”的需求根本不需要PHP來處理。
不過對Node.js來說,概念完全不一樣了。使用Node.js時,我們不僅僅在實現一個應用,同時還實現了整個HTTP服務器。事實上,我們的Web應用以及對應的Web服務器基本上是一樣的。
聽起來好像有一大堆活要做,但隨後我們會逐漸意識到,對Node.js來說這並不是什麼麻煩的事。
現在我們就來開始實現之路,先從第一個部分--HTTP服務器著手。
構建應用的模塊
一個基礎的HTTP服務器
當我准備開始寫我的第一個“真正的”Node.js應用的時候,我不但不知道怎麼寫Node.js代碼,也不知道怎麼組織這些代碼。
我應該把所有東西都放進一個文件裡嗎?網上有很多教程都會教你把所有的邏輯都放進一個用Node.js寫的基礎HTTP服務器裡。但是如果我想加入更多的內容,同時還想保持代碼的可讀性呢?
實際上,只要把不同功能的代碼放入不同的模塊中,保持代碼分離還是相當簡單的。
這種方法允許你擁有一個干淨的主文件(main file),你可以用Node.js執行它;同時你可以擁有干淨的模塊,它們可以被主文件和其他的模塊調用。
那麼,現在我們來創建一個用於啟動我們的應用的主文件,和一個保存著我們的HTTP服務器代碼的模塊。
在我的印象裡,把主文件叫做index.js或多或少是個標准格式。把服務器模塊放進叫server.js的文件裡則很好理解。
讓我們先從服務器模塊開始。在你的項目的根目錄下創建一個叫server.js的文件,並寫入以下代碼:
復制代碼 代碼如下:
var http = require("http");
http.createServer(function(request, response) {
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("Hello World");
response.end();
}).listen(8888);
搞定!你剛剛完成了一個可以工作的HTTP服務器。為了證明這一點,我們來運行並且測試這段代碼。首先,用Node.js執行你的腳本:
node server.js
接下來,打開浏覽器訪問http://localhost:8888/,你會看到一個寫著“Hello World”的網頁。
這很有趣,不是嗎?讓我們先來談談HTTP服務器的問題,把如何組織項目的事情先放一邊吧,你覺得如何?我保證之後我們會解決那個問題的。
分析HTTP服務器
那麼接下來,讓我們分析一下這個HTTP服務器的構成。
第一行請求(require)Node.js自帶的 http 模塊,並且把它賦值給 http 變量。
接下來我們調用http模塊提供的函數: createServer 。這個函數會返回一個對象,這個對象有一個叫做 listen 的方法,這個方法有一個數值參數,指定這個HTTP服務器監聽的端口號。
咱們暫時先不管 http.createServer 的括號裡的那個函數定義。
我們本來可以用這樣的代碼來啟動服務器並偵聽8888端口:
復制代碼 代碼如下:
var http = require("http");
var server = http.createServer();
server.listen(8888);
這段代碼只會啟動一個偵聽8888端口的服務器,它不做任何別的事情,甚至連請求都不會應答。
最有趣(而且,如果你之前習慣使用一個更加保守的語言,比如PHP,它還很奇怪)的部分是 createSever() 的第一個參數,一個函數定義。
實際上,這個函數定義是 createServer() 的第一個也是唯一一個參數。因為在JavaScript中,函數和其他變量一樣都是可以被傳遞的。
進行函數傳遞
舉例來說,你可以這樣做:
復制代碼 代碼如下:
function say(word) {
console.log(word);
}
function execute(someFunction, value) {
someFunction(value);
}
execute(say, "Hello");
請仔細閱讀這段代碼!在這裡,我們把 say 函數作為execute函數的第一個變量進行了傳遞。這裡返回的不是 say 的返回值,而是 say 本身!
這樣一來, say 就變成了execute 中的本地變量 someFunction ,execute可以通過調用 someFunction() (帶括號的形式)來使用 say 函數。
當然,因為 say 有一個變量, execute 在調用 someFunction 時可以傳遞這樣一個變量。
我們可以,就像剛才那樣,用它的名字把一個函數作為變量傳遞。但是我們不一定要繞這個“先定義,再傳遞”的圈子,我們可以直接在另一個函數的括號中定義和傳遞這個函數:
復制代碼 代碼如下:
function execute(someFunction, value) {
someFunction(value);
}
execute(function(word){ console.log(word) }, "Hello");
我們在 execute 接受第一個參數的地方直接定義了我們准備傳遞給 execute 的函數。
用這種方式,我們甚至不用給這個函數起名字,這也是為什麼它被叫做 匿名函數 。
這是我們和我所認為的“進階”JavaScript的第一次親密接觸,不過我們還是得循序漸進。現在,我們先接受這一點:在JavaScript中,一個函數可以作為另一個函數接收一個參數。我們可以先定義一個函數,然後傳遞,也可以在傳遞參數的地方直接定義函數。
函數傳遞是如何讓HTTP服務器工作的
帶著這些知識,我們再來看看我們簡約而不簡單的HTTP服務器:
復制代碼 代碼如下:
var http = require("http");
http.createServer(function(request, response) {
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("Hello World");
response.end();
}).listen(8888);
現在它看上去應該清晰了很多:我們向 createServer 函數傳遞了一個匿名函數。
用這樣的代碼也可以達到同樣的目的:
復制代碼 代碼如下:
var http = require("http");
function onRequest(request, response) {
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("Hello World");
response.end();
}
http.createServer(onRequest).listen(8888);
也許現在我們該問這個問題了:我們為什麼要用這種方式呢?
基於事件驅動的回調
這個問題可不好回答(至少對我來說),不過這是Node.js原生的工作方式。它是事件驅動的,這也是它為什麼這麼快的原因。
你也許會想花點時間讀一下Felix Geisendörfer的大作Understanding node.js,它介紹了一些背景知識。
這一切都歸結於“Node.js是事件驅動的”這一事實。好吧,其實我也不是特別確切的了解這句話的意思。不過我會試著解釋,為什麼它對我們用Node.js寫網絡應用(Web based application)是有意義的。
當我們使用 http.createServer 方法的時候,我們當然不只是想要一個偵聽某個端口的服務器,我們還想要它在服務器收到一個HTTP請求的時候做點什麼。
問題是,這是異步的:請求任何時候都可能到達,但是我們的服務器卻跑在一個單進程中。
寫PHP應用的時候,我們一點也不為此擔心:任何時候當有請求進入的時候,網頁服務器(通常是Apache)就為這一請求新建一個進程,並且開始從頭到尾執行相應的PHP腳本。
那麼在我們的Node.js程序中,當一個新的請求到達8888端口的時候,我們怎麼控制流程呢?
嗯,這就是Node.js/JavaScript的事件驅動設計能夠真正幫上忙的地方了——雖然我們還得學一些新概念才能掌握它。讓我們來看看這些概念是怎麼應用在我們的服務器代碼裡的。
我們創建了服務器,並且向創建它的方法傳遞了一個函數。無論何時我們的服務器收到一個請求,這個函數就會被調用。
我們不知道這件事情什麼時候會發生,但是我們現在有了一個處理請求的地方:它就是我們傳遞過去的那個函數。至於它是被預先定義的函數還是匿名函數,就無關緊要了。
這個就是傳說中的 回調 。我們給某個方法傳遞了一個函數,這個方法在有相應事件發生時調用這個函數來進行 回調 。
至少對我來說,需要一些功夫才能弄懂它。你如果還是不太確定的話就再去讀讀Felix的博客文章。
讓我們再來琢磨琢磨這個新概念。我們怎麼證明,在創建完服務器之後,即使沒有HTTP請求進來、我們的回調函數也沒有被調用的情況下,我們的代碼還繼續有效呢?我們試試這個:
復制代碼 代碼如下:
var http = require("http");
function onRequest(request, response) {
console.log("Request received.");
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("Hello World");
response.end();
}
http.createServer(onRequest).listen(8888);
console.log("Server has started.");
注意:在 onRequest (我們的回調函數)觸發的地方,我用 console.log 輸出了一段文本。在HTTP服務器開始工作之後,也輸出一段文本。
當我們與往常一樣,運行它node server.js時,它會馬上在命令行上輸出“Server has started.”。當我們向服務器發出請求(在浏覽器訪問http://localhost:8888/ ),“Request received.”這條消息就會在命令行中出現。
這就是事件驅動的異步服務器端JavaScript和它的回調啦!
(請注意,當我們在服務器訪問網頁時,我們的服務器可能會輸出兩次“Request received.”。那是因為大部分服務器都會在你訪問 http://localhost:8888 /時嘗試讀取 http://localhost:8888/favicon.ico )
服務器是如何處理請求的
好的,接下來我們簡單分析一下我們服務器代碼中剩下的部分,也就是我們的回調函數 onRequest() 的主體部分。
當回調啟動,我們的 onRequest() 函數被觸發的時候,有兩個參數被傳入: request 和 response 。
它們是對象,你可以使用它們的方法來處理HTTP請求的細節,並且響應請求(比如向發出請求的浏覽器發回一些東西)。
所以我們的代碼就是:當收到請求時,使用 response.writeHead() 函數發送一個HTTP狀態200和HTTP頭的內容類型(content-type),使用 response.write() 函數在HTTP相應主體中發送文本“Hello World"。
最後,我們調用 response.end() 完成響應。
目前來說,我們對請求的細節並不在意,所以我們沒有使用 request 對象。
服務端的模塊放在哪裡
OK,就像我保證過的那樣,我們現在可以回到我們如何組織應用這個問題上了。我們現在在 server.js 文件中有一個非常基礎的HTTP服務器代碼,而且我提到通常我們會有一個叫 index.js 的文件去調用應用的其他模塊(比如 server.js 中的HTTP服務器模塊)來引導和啟動應用。
我們現在就來談談怎麼把server.js變成一個真正的Node.js模塊,使它可以被我們(還沒動工)的 index.js 主文件使用。
也許你已經注意到,我們已經在代碼中使用了模塊了。像這樣:
復制代碼 代碼如下:
var http = require("http");
...
http.createServer(...);
Node.js中自帶了一個叫做“http”的模塊,我們在我們的代碼中請求它並把返回值賦給一個本地變量。
這把我們的本地變量變成了一個擁有所有 http 模塊所提供的公共方法的對象。
給這種本地變量起一個和模塊名稱一樣的名字是一種慣例,但是你也可以按照自己的喜好來:
復制代碼 代碼如下:
var foo = require("http");
...
foo.createServer(...);
很好,怎麼使用Node.js內部模塊已經很清楚了。我們怎麼創建自己的模塊,又怎麼使用它呢?
等我們把 server.js 變成一個真正的模塊,你就能搞明白了。
事實上,我們不用做太多的修改。把某段代碼變成模塊意味著我們需要把我們希望提供其功能的部分 導出 到請求這個模塊的腳本。
目前,我們的HTTP服務器需要導出的功能非常簡單,因為請求服務器模塊的腳本僅僅是需要啟動服務器而已。
我們把我們的服務器腳本放到一個叫做 start 的函數裡,然後我們會導出這個函數。
復制代碼 代碼如下:
var http = require("http");
function start() {
function onRequest(request, response) {
console.log("Request received.");
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("Hello World");
response.end();
}
http.createServer(onRequest).listen(8888);
console.log("Server has started.");
}
exports.start = start;
這樣,我們現在就可以創建我們的主文件 index.js 並在其中啟動我們的HTTP了,雖然服務器的代碼還在 server.js 中。
創建 index.js 文件並寫入以下內容:
復制代碼 代碼如下:
var server = require("./server");
server.start();
正如你所看到的,我們可以像使用任何其他的內置模塊一樣使用server模塊:請求這個文件並把它指向一個變量,其中已導出的函數就可以被我們使用了。
好了。我們現在就可以從我們的主要腳本啟動我們的的應用了,而它還是老樣子:
復制代碼 代碼如下:
node index.js
非常好,我們現在可以把我們的應用的不同部分放入不同的文件裡,並且通過生成模塊的方式把它們連接到一起了。
我們仍然只擁有整個應用的最初部分:我們可以接收HTTP請求。但是我們得做點什麼——對於不同的URL請求,服務器應該有不同的反應。
對於一個非常簡單的應用來說,你可以直接在回調函數 onRequest() 中做這件事情。不過就像我說過的,我們應該加入一些抽象的元素,讓我們的例子變得更有趣一點兒。
處理不同的HTTP請求在我們的代碼中是一個不同的部分,叫做“路由選擇”——那麼,我們接下來就創造一個叫做 路由 的模塊吧。
如何來進行請求的“路由”
我們要為路由提供請求的URL和其他需要的GET及POST參數,隨後路由需要根據這些數據來執行相應的代碼(這裡“代碼”對應整個應用的第三部分:一系列在接收到請求時真正工作的處理程序)。
因此,我們需要查看HTTP請求,從中提取出請求的URL以及GET/POST參數。這一功能應當屬於路由還是服務器(甚至作為一個模塊自身的功能)確實值得探討,但這裡暫定其為我們的HTTP服務器的功能。
我們需要的所有數據都會包含在request對象中,該對象作為onRequest()回調函數的第一個參數傳遞。但是為了解析這些數據,我們需要額外的Node.JS模塊,它們分別是url和querystring模塊。
復制代碼 代碼如下:
url.parse(string).query
url.parse(string).pathname |
| |
------ -------------------
http://localhost:8888/start?foo=bar&hello=world
--- -----
| |
querystring(string)["foo"] |
querystring(string)["hello"]
當然我們也可以用querystring模塊來解析POST請求體中的參數,稍後會有演示。
現在我們來給onRequest()函數加上一些邏輯,用來找出浏覽器請求的URL路徑:
var http = require("http");
var url = require("url");
function start() {
function onRequest(request, response) {
var pathname = url.parse(request.url).pathname;
console.log("Request for " + pathname + " received.");
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("Hello World");
response.end();
}
http.createServer(onRequest).listen(8888);
console.log("Server has started.");
}
exports.start = start;
好了,我們的應用現在可以通過請求的URL路徑來區別不同請求了--這使我們得以使用路由(還未完成)來將請求以URL路徑為基准映射到處理程序上。
在我們所要構建的應用中,這意味著來自/start和/upload的請求可以使用不同的代碼來處理。稍後我們將看到這些內容是如何整合到一起的。
現在我們可以來編寫路由了,建立一個名為router.js的文件,添加以下內容:
function route(pathname) {
console.log("About to route a request for " + pathname);
}
exports.route = route;
如你所見,這段代碼什麼也沒干,不過對於現在來說這是應該的。在添加更多的邏輯以前,我們先來看看如何把路由和服務器整合起來。
我們的服務器應當知道路由的存在並加以有效利用。我們當然可以通過硬編碼的方式將這一依賴項綁定到服務器上,但是其它語言的編程經驗告訴我們這會是一件非常痛苦的事,因此我們將使用依賴注入的方式較松散地添加路由模塊(你可以讀讀Martin Fowlers關於依賴注入的大作來作為背景知識)。
首先,我們來擴展一下服務器的start()函數,以便將路由函數作為參數傳遞過去:
復制代碼 代碼如下:
var http = require("http");
var url = require("url");
function start(route) {
function onRequest(request, response) {
var pathname = url.parse(request.url).pathname;
console.log("Request for " + pathname + " received.");
route(pathname);
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("Hello World");
response.end();
}
http.createServer(onRequest).listen(8888);
console.log("Server has started.");
}
exports.start = start;
同時,我們會相應擴展index.js,使得路由函數可以被注入到服務器中:
復制代碼 代碼如下:
var server = require("./server");
var router = require("./router");
server.start(router.route);
在這裡,我們傳遞的函數依舊什麼也沒做。
如果現在啟動應用(node index.js,始終記得這個命令行),隨後請求一個URL,你將會看到應用輸出相應的信息,這表明我們的HTTP服務器已經在使用路由模塊了,並會將請求的路徑傳遞給路由:
復制代碼 代碼如下:
bash$ node index.js
Request for /foo received.
About to route a request for /foo
(以上輸出已經去掉了比較煩人的/favicon.ico請求相關的部分)。
行為驅動執行
請允許我再次脫離主題,在這裡談一談函數式編程。
將函數作為參數傳遞並不僅僅出於技術上的考量。對軟件設計來說,這其實是個哲學問題。想想這樣的場景:在index文件中,我們可以將router對象傳遞進去,服務器隨後可以調用這個對象的route函數。
就像這樣,我們傳遞一個東西,然後服務器利用這個東西來完成一些事。嗨那個叫路由的東西,能幫我把這個路由一下嗎?
但是服務器其實不需要這樣的東西。它只需要把事情做完就行,其實為了把事情做完,你根本不需要東西,你需要的是動作。也就是說,你不需要名詞,你需要動詞。
理解了這個概念裡最核心、最基本的思想轉換後,我自然而然地理解了函數編程。
我是在讀了Steve Yegge的大作名詞王國中的死刑之後理解函數編程。你也去讀一讀這本書吧,真的。這是曾給予我閱讀的快樂的關於軟件的書籍之一。
路由給真正的請求處理程序
回到正題,現在我們的HTTP服務器和請求路由模塊已經如我們的期望,可以相互交流了,就像一對親密無間的兄弟。
當然這還遠遠不夠,路由,顧名思義,是指我們要針對不同的URL有不同的處理方式。例如處理/start的“業務邏輯”就應該和處理/upload的不同。
在現在的實現下,路由過程會在路由模塊中“結束”,並且路由模塊並不是真正針對請求“采取行動”的模塊,否則當我們的應用程序變得更為復雜時,將無法很好地擴展。
我們暫時把作為路由目標的函數稱為請求處理程序。現在我們不要急著來開發路由模塊,因為如果請求處理程序沒有就緒的話,再怎麼完善路由模塊也沒有多大意義。
應用程序需要新的部件,因此加入新的模塊 -- 已經無需為此感到新奇了。我們來創建一個叫做requestHandlers的模塊,並對於每一個請求處理程序,添加一個占位用函數,隨後將這些函數作為模塊的方法導出:
復制代碼 代碼如下:
function start() {
console.log("Request handler 'start' was called.");
}
function upload() {
console.log("Request handler 'upload' was called.");
}
exports.start = start;
exports.upload = upload;
這樣我們就可以把請求處理程序和路由模塊連接起來,讓路由“有路可尋”。
在這裡我們得做個決定:是將requestHandlers模塊硬編碼到路由裡來使用,還是再添加一點依賴注入?雖然和其他模式一樣,依賴注入不應該僅僅為使用而使用,但在現在這個情況下,使用依賴注入可以讓路由和請求處理程序之間的耦合更加松散,也因此能讓路由的重用性更高。
這意味著我們得將請求處理程序從服務器傳遞到路由中,但感覺上這麼做更離譜了,我們得一路把這堆請求處理程序從我們的主文件傳遞到服務器中,再將之從服務器傳遞到路由。
那麼我們要怎麼傳遞這些請求處理程序呢?別看現在我們只有2個處理程序,在一個真實的應用中,請求處理程序的數量會不斷增加,我們當然不想每次有一個新的URL或請求處理程序時,都要為了在路由裡完成請求到處理程序的映射而反復折騰。除此之外,在路由裡有一大堆if request == x then call handler y也使得系統丑陋不堪。
仔細想想,有一大堆東西,每個都要映射到一個字符串(就是請求的URL)上?似乎關聯數組(associative array)能完美勝任。
不過結果有點令人失望,JavaScript沒提供關聯數組 -- 也可以說它提供了?事實上,在JavaScript中,真正能提供此類功能的是它的對象。
在這方面,http://msdn.microsoft.com/en-us/magazine/cc163419.aspx有一個不錯的介紹,我在此摘錄一段:
在C++或C#中,當我們談到對象,指的是類或者結構體的實例。對象根據他們實例化的模板(就是所謂的類),會擁有不同的屬性和方法。但在JavaScript裡對象不是這個概念。在JavaScript中,對象就是一個鍵/值對的集合 -- 你可以把JavaScript的對象想象成一個鍵為字符串類型的字典。
但如果JavaScript的對象僅僅是鍵/值對的集合,它又怎麼會擁有方法呢?好吧,這裡的值可以是字符串、數字或者……函數!
好了,最後再回到代碼上來。現在我們已經確定將一系列請求處理程序通過一個對象來傳遞,並且需要使用松耦合的方式將這個對象注入到route()函數中。
我們先將這個對象引入到主文件index.js中:
復制代碼 代碼如下:
var server = require("./server");
var router = require("./router");
var requestHandlers = require("./requestHandlers");
var handle = {}
handle["/"] = requestHandlers.start;
handle["/start"] = requestHandlers.start;
handle["/upload"] = requestHandlers.upload;
server.start(router.route, handle);
雖然handle並不僅僅是一個“東西”(一些請求處理程序的集合),我還是建議以一個動詞作為其命名,這樣做可以讓我們在路由中使用更流暢的表達式,稍後會有說明。
正如所見,將不同的URL映射到相同的請求處理程序上是很容易的:只要在對象中添加一個鍵為"/"的屬性,對應requestHandlers.start即可,這樣我們就可以干淨簡潔地配置/start和/的請求都交由start這一處理程序處理。
在完成了對象的定義後,我們把它作為額外的參數傳遞給服務器,為此將server.js修改如下:
復制代碼 代碼如下:
var http = require("http");
var url = require("url");
function start(route, handle) {
function onRequest(request, response) {
var pathname = url.parse(request.url).pathname;
console.log("Request for " + pathname + " received.");
route(handle, pathname);
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("Hello World");
response.end();
}
http.createServer(onRequest).listen(8888);
console.log("Server has started.");
}
exports.start = start;
這樣我們就在start()函數裡添加了handle參數,並且把handle對象作為第一個參數傳遞給了route()回調函數。
然後我們相應地在route.js文件中修改route()函數:
復制代碼 代碼如下:
function route(handle, pathname) {
console.log("About to route a request for " + pathname);
if (typeof handle[pathname] === 'function') {
handle[pathname]();
} else {
console.log("No request handler found for " + pathname);
}
}
exports.route = route;
通過以上代碼,我們首先檢查給定的路徑對應的請求處理程序是否存在,如果存在的話直接調用相應的函數。我們可以用從關聯數組中獲取元素一樣的方式從傳遞的對象中獲取請求處理函數,因此就有了簡潔流暢的形如handle[pathname]();的表達式,這個感覺就像在前方中提到的那樣:“嗨,請幫我處理了這個路徑”。
有了這些,我們就把服務器、路由和請求處理程序在一起了。現在我們啟動應用程序並在浏覽器中訪問http://localhost:8888/start,以下日志可以說明系統調用了正確的請求處理程序:
復制代碼 代碼如下:
Server has started.
Request for /start received.
About to route a request for /start
Request handler 'start' was called.
並且在浏覽器中打開http://localhost:8888/可以看到這個請求同樣被start請求處理程序處理了:
復制代碼 代碼如下:
Request for / received.
About to route a request for /
Request handler 'start' was called.
讓請求處理程序作出響應
很好。不過現在要是請求處理程序能夠向浏覽器返回一些有意義的信息而並非全是“Hello World”,那就更好了。
這裡要記住的是,浏覽器發出請求後獲得並顯示的“Hello World”信息仍是來自於我們server.js文件中的onRequest函數。
其實“處理請求”說白了就是“對請求作出響應”,因此,我們需要讓請求處理程序能夠像onRequest函數那樣可以和浏覽器進行“對話”。
不好的實現方式
對於我們這樣擁有PHP或者Ruby技術背景的開發者來說,最直截了當的實現方式事實上並不是非常靠譜: 看似有效,實則未必如此。
這裡我指的“直截了當的實現方式”意思是:讓請求處理程序通過onRequest函數直接返回(return())他們要展示給用戶的信息。
我們先就這樣去實現,然後再來看為什麼這不是一種很好的實現方式。
讓我們從讓請求處理程序返回需要在浏覽器中顯示的信息開始。我們需要將requestHandler.js修改為如下形式:
復制代碼 代碼如下:
function start() {
console.log("Request handler 'start' was called.");
return "Hello Start";
}
function upload() {
console.log("Request handler 'upload' was called.");
return "Hello Upload";
}
exports.start = start;
exports.upload = upload;
好的。同樣的,請求路由需要將請求處理程序返回給它的信息返回給服務器。因此,我們需要將router.js修改為如下形式:
復制代碼 代碼如下:
function route(handle, pathname) {
console.log("About to route a request for " + pathname);
if (typeof handle[pathname] === 'function') {
return handle[pathname]();
} else {
console.log("No request handler found for " + pathname);
return "404 Not found";
}
}
exports.route = route;
正如上述代碼所示,當請求無法路由的時候,我們也返回了一些相關的錯誤信息。
最後,我們需要對我們的server.js進行重構以使得它能夠將請求處理程序通過請求路由返回的內容響應給浏覽器,如下所示:
復制代碼 代碼如下:
var http = require("http");
var url = require("url");
function start(route, handle) {
function onRequest(request, response) {
var pathname = url.parse(request.url).pathname;
console.log("Request for " + pathname + " received.");
response.writeHead(200, {"Content-Type": "text/plain"});
var content = route(handle, pathname)
response.write(content);
response.end();
}
http.createServer(onRequest).listen(8888);
console.log("Server has started.");
}
exports.start = start;
如果我們運行重構後的應用,一切都會工作的很好:請求http://localhost:8888/start,浏覽器會輸出“Hello Start”,請求http://localhost:8888/upload會輸出“Hello Upload”,而請求http://localhost:8888/foo 會輸出“404 Not found”。
好,那麼問題在哪裡呢?簡單的說就是: 當未來有請求處理程序需要進行非阻塞的操作的時候,我們的應用就“掛”了。
沒理解?沒關系,下面就來詳細解釋下。
阻塞與非阻塞
正如此前所提到的,當在請求處理程序中包括非阻塞操作時就會出問題。但是,在說這之前,我們先來看看什麼是阻塞操作。
我不想去解釋“阻塞”和“非阻塞”的具體含義,我們直接來看,當在請求處理程序中加入阻塞操作時會發生什麼。
這裡,我們來修改下start請求處理程序,我們讓它等待10秒以後再返回“Hello Start”。因為,JavaScript中沒有類似sleep()這樣的操作,所以這裡只能夠來點小Hack來模擬實現。
讓我們將requestHandlers.js修改成如下形式:
復制代碼 代碼如下:
function start() {
console.log("Request handler 'start' was called.");
function sleep(milliSeconds) {
var startTime = new Date().getTime();
while (new Date().getTime() < startTime + milliSeconds);
}
sleep(10000);
return "Hello Start";
}
function upload() {
console.log("Request handler 'upload' was called.");
return "Hello Upload";
}
exports.start = start;
exports.upload = upload;
上述代碼中,當函數start()被調用的時候,Node.js會先等待10秒,之後才會返回“Hello Start”。當調用upload()的時候,會和此前一樣立即返回。
(當然了,這裡只是模擬休眠10秒,實際場景中,這樣的阻塞操作有很多,比方說一些長時間的計算操作等。)
接下來就讓我們來看看,我們的改動帶來了哪些變化。
如往常一樣,我們先要重啟下服務器。為了看到效果,我們要進行一些相對復雜的操作(跟著我一起做): 首先,打開兩個浏覽器窗口或者標簽頁。在第一個浏覽器窗口的地址欄中輸入http://localhost:8888/start, 但是先不要打開它!
在第二個浏覽器窗口的地址欄中輸入http://localhost:8888/upload, 同樣的,先不要打開它!
接下來,做如下操作:在第一個窗口中(“/start”)按下回車,然後快速切換到第二個窗口中(“/upload”)按下回車。
注意,發生了什麼: /start URL加載花了10秒,這和我們預期的一樣。但是,/upload URL居然也花了10秒,而它在對應的請求處理程序中並沒有類似於sleep()這樣的操作!
這到底是為什麼呢?原因就是start()包含了阻塞操作。形象的說就是“它阻塞了所有其他的處理工作”。
這顯然是個問題,因為Node一向是這樣來標榜自己的:“在node中除了代碼,所有一切都是並行執行的”。
這句話的意思是說,Node.js可以在不新增額外線程的情況下,依然可以對任務進行並行處理 —— Node.js是單線程的。它通過事件輪詢(event loop)來實現並行操作,對此,我們應該要充分利用這一點 —— 盡可能的避免阻塞操作,取而代之,多使用非阻塞操作。
然而,要用非阻塞操作,我們需要使用回調,通過將函數作為參數傳遞給其他需要花時間做處理的函數(比方說,休眠10秒,或者查詢數據庫,又或者是進行大量的計算)。
對於Node.js來說,它是這樣處理的:“嘿,probablyExpensiveFunction()(譯者注:這裡指的就是需要花時間處理的函數),你繼續處理你的事情,我(Node.js線程)先不等你了,我繼續去處理你後面的代碼,請你提供一個callbackFunction(),等你處理完之後我會去調用該回調函數的,謝謝!”
(如果想要了解更多關於事件輪詢細節,可以閱讀Mixu的博文——理解node.js的事件輪詢。)
接下來,我們會介紹一種錯誤的使用非阻塞操作的方式。
和上次一樣,我們通過修改我們的應用來暴露問題。
這次我們還是拿start請求處理程序來“開刀”。將其修改成如下形式:
復制代碼 代碼如下:
var exec = require("child_process").exec;
function start() {
console.log("Request handler 'start' was called.");
var content = "empty";
exec("ls -lah", function (error, stdout, stderr) {
content = stdout;
});
return content;
}
function upload() {
console.log("Request handler 'upload' was called.");
return "Hello Upload";
}
exports.start = start;
exports.upload = upload;
上述代碼中,我們引入了一個新的Node.js模塊,child_process。之所以用它,是為了實現一個既簡單又實用的非阻塞操作:exec()。
exec()做了什麼呢?它從Node.js來執行一個shell命令。在上述例子中,我們用它來獲取當前目錄下所有的文件(“ls -lah”),然後,當/startURL請求的時候將文件信息輸出到浏覽器中。
上述代碼是非常直觀的: 創建了一個新的變量content(初始值為“empty”),執行“ls -lah”命令,將結果賦值給content,最後將content返回。
和往常一樣,我們啟動服務器,然後訪問“http://localhost:8888/start” 。
之後會載入一個漂亮的web頁面,其內容為“empty”。怎麼回事?
這個時候,你可能大致已經猜到了,exec()在非阻塞這塊發揮了神奇的功效。它其實是個很好的東西,有了它,我們可以執行非常耗時的shell操作而無需迫使我們的應用停下來等待該操作。
(如果想要證明這一點,可以將“ls -lah”換成比如“find /”這樣更耗時的操作來效果)。
然而,針對浏覽器顯示的結果來看,我們並不滿意我們的非阻塞操作,對吧?
好,接下來,我們來修正這個問題。在這過程中,讓我們先來看看為什麼當前的這種方式不起作用。
問題就在於,為了進行非阻塞工作,exec()使用了回調函數。
在我們的例子中,該回調函數就是作為第二個參數傳遞給exec()的匿名函數:
復制代碼 代碼如下:
function (error, stdout, stderr) {
content = stdout;
}
現在就到了問題根源所在了:我們的代碼是同步執行的,這就意味著在調用exec()之後,Node.js會立即執行 return content ;在這個時候,content仍然是“empty”,因為傳遞給exec()的回調函數還未執行到——因為exec()的操作是異步的。
我們這裡“ls -lah”的操作其實是非常快的(除非當前目錄下有上百萬個文件)。這也是為什麼回調函數也會很快的執行到 —— 不過,不管怎麼說它還是異步的。
為了讓效果更加明顯,我們想象一個更耗時的命令: “find /”,它在我機器上需要執行1分鐘左右的時間,然而,盡管在請求處理程序中,我把“ls -lah”換成“find /”,當打開/start URL的時候,依然能夠立即獲得HTTP響應 —— 很明顯,當exec()在後台執行的時候,Node.js自身會繼續執行後面的代碼。並且我們這裡假設傳遞給exec()的回調函數,只會在“find /”命令執行完成之後才會被調用。
那究竟我們要如何才能實現將當前目錄下的文件列表顯示給用戶呢?
好,了解了這種不好的實現方式之後,我們接下來來介紹如何以正確的方式讓請求處理程序對浏覽器請求作出響應。
以非阻塞操作進行請求響應
我剛剛提到了這樣一個短語 —— “正確的方式”。而事實上通常“正確的方式”一般都不簡單。
不過,用Node.js就有這樣一種實現方案: 函數傳遞。下面就讓我們來具體看看如何實現。
到目前為止,我們的應用已經可以通過應用各層之間傳遞值的方式(請求處理程序 -> 請求路由 -> 服務器)將請求處理程序返回的內容(請求處理程序最終要顯示給用戶的內容)傳遞給HTTP服務器。
現在我們采用如下這種新的實現方式:相對采用將內容傳遞給服務器的方式,我們這次采用將服務器“傳遞”給內容的方式。 從實踐角度來說,就是將response對象(從服務器的回調函數onRequest()獲取)通過請求路由傳遞給請求處理程序。 隨後,處理程序就可以采用該對象上的函數來對請求作出響應。
原理就是如此,接下來讓我們來一步步實現這種方案。
先從server.js開始:
復制代碼 代碼如下:
var http = require("http");
var url = require("url");
function start(route, handle) {
function onRequest(request, response) {
var pathname = url.parse(request.url).pathname;
console.log("Request for " + pathname + " received.");
route(handle, pathname, response);
}
http.createServer(onRequest).listen(8888);
console.log("Server has started.");
}
exports.start = start;
相對此前從route()函數獲取返回值的做法,這次我們將response對象作為第三個參數傳遞給route()函數,並且,我們將onRequest()處理程序中所有有關response的函數調都移除,因為我們希望這部分工作讓route()函數來完成。
下面就來看看我們的router.js:
復制代碼 代碼如下:
function route(handle, pathname, response) {
console.log("About to route a request for " + pathname);
if (typeof handle[pathname] === 'function') {
handle[pathname](response);
} else {
console.log("No request handler found for " + pathname);
response.writeHead(404, {"Content-Type": "text/plain"});
response.write("404 Not found");
response.end();
}
}
exports.route = route;
同樣的模式:相對此前從請求處理程序中獲取返回值,這次取而代之的是直接傳遞response對象。
如果沒有對應的請求處理器處理,我們就直接返回“404”錯誤。
最後,我們將requestHandler.js修改為如下形式:
復制代碼 代碼如下:
var exec = require("child_process").exec;
function start(response) {
console.log("Request handler 'start' was called.");
exec("ls -lah", function (error, stdout, stderr) {
response.writeHead(200, {"Content-Type": "text/plain"});
response.write(stdout);
response.end();
});
}
function upload(response) {
console.log("Request handler 'upload' was called.");
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("Hello Upload");
response.end();
}
exports.start = start;
exports.upload = upload;
我們的處理程序函數需要接收response參數,為了對請求作出直接的響應。
start處理程序在exec()的匿名回調函數中做請求響應的操作,而upload處理程序仍然是簡單的回復“Hello World”,只是這次是使用response對象而已。
這時再次我們啟動應用(node index.js),一切都會工作的很好。
如果想要證明/start處理程序中耗時的操作不會阻塞對/upload請求作出立即響應的話,可以將requestHandlers.js修改為如下形式:
復制代碼 代碼如下:
var exec = require("child_process").exec;
function start(response) {
console.log("Request handler 'start' was called.");
exec("find /",
{ timeout: 10000, maxBuffer: 20000*1024 },
function (error, stdout, stderr) {
response.writeHead(200, {"Content-Type": "text/plain"});
response.write(stdout);
response.end();
});
}
function upload(response) {
console.log("Request handler 'upload' was called.");
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("Hello Upload");
response.end();
}
exports.start = start;
exports.upload = upload;
這樣一來,當請求http://localhost:8888/start的時候,會花10秒鐘的時間才載入,而當請求http://localhost:8888/upload的時候,會立即響應,縱然這個時候/start響應還在處理中。
更有用的場景
到目前為止,我們做的已經很好了,但是,我們的應用沒有實際用途。
服務器,請求路由以及請求處理程序都已經完成了,下面讓我們按照此前的用例給網站添加交互:用戶選擇一個文件,上傳該文件,然後在浏覽器中看到上傳的文件。 為了保持簡單,我們假設用戶只會上傳圖片,然後我們應用將該圖片顯示到浏覽器中。
好,下面就一步步來實現,鑒於此前已經對JavaScript原理性技術性的內容做過大量介紹了,這次我們加快點速度。
要實現該功能,分為如下兩步: 首先,讓我們來看看如何處理POST請求(非文件上傳),之後,我們使用Node.js的一個用於文件上傳的外部模塊。之所以采用這種實現方式有兩個理由。
第一,盡管在Node.js中處理基礎的POST請求相對比較簡單,但在這過程中還是能學到很多。
第二,用Node.js來處理文件上傳(multipart POST請求)是比較復雜的,它不在本書的范疇,但,如何使用外部模塊卻是在本書涉獵內容之內。
處理POST請求
考慮這樣一個簡單的例子:我們顯示一個文本區(textarea)供用戶輸入內容,然後通過POST請求提交給服務器。最後,服務器接受到請求,通過處理程序將輸入的內容展示到浏覽器中。
/start請求處理程序用於生成帶文本區的表單,因此,我們將requestHandlers.js修改為如下形式:
function start(response) {
console.log("Request handler 'start' was called.");
var body = '<html>'+
'<head>'+
'<meta http-equiv="Content-Type" content="text/html; '+
'charset=UTF-8" />'+
'</head>'+
'<body>'+
'<form action="/upload" method="post">'+
'<textarea name="text" rows="20" cols="60"></textarea>'+
'<input type="submit" value="Submit text" />'+
'</form>'+
'</body>'+
'</html>';
response.writeHead(200, {"Content-Type": "text/html"});
response.write(body);
response.end();
}
function upload(response) {
console.log("Request handler 'upload' was called.");
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("Hello Upload");
response.end();
}
exports.start = start;
exports.upload = upload;
好了,現在我們的應用已經很完善了,都可以獲得威比獎(Webby Awards)了,哈哈。(譯者注:威比獎是由國際數字藝術與科學學院主辦的評選全球最佳網站的獎項,具體參見詳細說明)通過在浏覽器中訪問http://localhost:8888/start就可以看到簡單的表單了,要記得重啟服務器哦!
你可能會說:這種直接將視覺元素放在請求處理程序中的方式太丑陋了。說的沒錯,但是,我並不想在本書中介紹諸如MVC之類的模式,因為這對於你了解JavaScript或者Node.js環境來說沒多大關系。
余下的篇幅,我們來探討一個更有趣的問題: 當用戶提交表單時,觸發/upload請求處理程序處理POST請求的問題。
現在,我們已經是新手中的專家了,很自然會想到采用異步回調來實現非阻塞地處理POST請求的數據。
這裡采用非阻塞方式處理是明智的,因為POST請求一般都比較“重” —— 用戶可能會輸入大量的內容。用阻塞的方式處理大數據量的請求必然會導致用戶操作的阻塞。
為了使整個過程非阻塞,Node.js會將POST數據拆分成很多小的數據塊,然後通過觸發特定的事件,將這些小數據塊傳遞給回調函數。這裡的特定的事件有data事件(表示新的小數據塊到達了)以及end事件(表示所有的數據都已經接收完畢)。
我們需要告訴Node.js當這些事件觸發的時候,回調哪些函數。怎麼告訴呢? 我們通過在request對象上注冊監聽器實現。這裡的request對象是每次接收到HTTP請求時候,都會把該對象傳遞給onRequest回調函數。
如下所示:
復制代碼 代碼如下:
request.addListener("data", function(chunk) {
// called when a new chunk of data was received
});
request.addListener("end", function() {
// called when all chunks of data have been received
});
問題來了,這部分邏輯寫在哪裡呢? 我們現在只是在服務器中獲取到了request對象 —— 我們並沒有像之前response對象那樣,把 request 對象傳遞給請求路由和請求處理程序。
在我看來,獲取所有來自請求的數據,然後將這些數據給應用層處理,應該是HTTP服務器要做的事情。因此,我建議,我們直接在服務器中處理POST數據,然後將最終的數據傳遞給請求路由和請求處理器,讓他們來進行進一步的處理。
因此,實現思路就是: 將data和end事件的回調函數直接放在服務器中,在data事件回調中收集所有的POST數據,當接收到所有數據,觸發end事件後,其回調函數調用請求路由,並將數據傳遞給它,然後,請求路由再將該數據傳遞給請求處理程序。
還等什麼,馬上來實現。先從server.js開始:
復制代碼 代碼如下:
var http = require("http");
var url = require("url");
function start(route, handle) {
function onRequest(request, response) {
var postData = "";
var pathname = url.parse(request.url).pathname;
console.log("Request for " + pathname + " received.");
request.setEncoding("utf8");
request.addListener("data", function(postDataChunk) {
postData += postDataChunk;
console.log("Received POST data chunk '"+
postDataChunk + "'.");
});
request.addListener("end", function() {
route(handle, pathname, response, postData);
});
}
http.createServer(onRequest).listen(8888);
console.log("Server has started.");
}
exports.start = start;
上述代碼做了三件事情: 首先,我們設置了接收數據的編碼格式為UTF-8,然後注冊了“data”事件的監聽器,用於收集每次接收到的新數據塊,並將其賦值給postData 變量,最後,我們將請求路由的調用移到end事件處理程序中,以確保它只會當所有數據接收完畢後才觸發,並且只觸發一次。我們同時還把POST數據傳遞給請求路由,因為這些數據,請求處理程序會用到。
上述代碼在每個數據塊到達的時候輸出了日志,這對於最終生產環境來說,是很不好的(數據量可能會很大,還記得吧?),但是,在開發階段是很有用的,有助於讓我們看到發生了什麼。
我建議可以嘗試下,嘗試著去輸入一小段文本,以及大段內容,當大段內容的時候,就會發現data事件會觸發多次。
再來點酷的。我們接下來在/upload頁面,展示用戶輸入的內容。要實現該功能,我們需要將postData傳遞給請求處理程序,修改router.js為如下形式:
復制代碼 代碼如下:
function route(handle, pathname, response, postData) {
console.log("About to route a request for " + pathname);
if (typeof handle[pathname] === 'function') {
handle[pathname](response, postData);
} else {
console.log("No request handler found for " + pathname);
response.writeHead(404, {"Content-Type": "text/plain"});
response.write("404 Not found");
response.end();
}
}
exports.route = route;
然後,在requestHandlers.js中,我們將數據包含在對upload請求的響應中:
復制代碼 代碼如下:
function start(response, postData) {
console.log("Request handler 'start' was called.");
var body = '<html>'+
'<head>'+
'<meta http-equiv="Content-Type" content="text/html; '+
'charset=UTF-8" />'+
'</head>'+
'<body>'+
'<form action="/upload" method="post">'+
'<textarea name="text" rows="20" cols="60"></textarea>'+
'<input type="submit" value="Submit text" />'+
'</form>'+
'</body>'+
'</html>';
response.writeHead(200, {"Content-Type": "text/html"});
response.write(body);
response.end();
}
function upload(response, postData) {
console.log("Request handler 'upload' was called.");
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("You've sent: " + postData);
response.end();
}
exports.start = start;
exports.upload = upload;
好了,我們現在可以接收POST數據並在請求處理程序中處理該數據了。
我們最後要做的是: 當前我們是把請求的整個消息體傳遞給了請求路由和請求處理程序。我們應該只把POST數據中,我們感興趣的部分傳遞給請求路由和請求處理程序。在我們這個例子中,我們感興趣的其實只是text字段。
我們可以使用此前介紹過的querystring模塊來實現:
復制代碼 代碼如下:
var querystring = require("querystring");
function start(response, postData) {
console.log("Request handler 'start' was called.");
var body = '<html>'+
'<head>'+
'<meta http-equiv="Content-Type" content="text/html; '+
'charset=UTF-8" />'+
'</head>'+
'<body>'+
'<form action="/upload" method="post">'+
'<textarea name="text" rows="20" cols="60"></textarea>'+
'<input type="submit" value="Submit text" />'+
'</form>'+
'</body>'+
'</html>';
response.writeHead(200, {"Content-Type": "text/html"});
response.write(body);
response.end();
}
function upload(response, postData) {
console.log("Request handler 'upload' was called.");
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("You've sent the text: "+
querystring.parse(postData).text);
response.end();
}
exports.start = start;
exports.upload = upload;
好了,以上就是關於處理POST數據的全部內容。
處理文件上傳
最後,我們來實現我們最終的用例:允許用戶上傳圖片,並將該圖片在浏覽器中顯示出來。
回到90年代,這個用例完全可以滿足用於IPO的商業模型了,如今,我們通過它能學到這樣兩件事情: 如何安裝外部Node.js模塊,以及如何將它們應用到我們的應用中。
這裡我們要用到的外部模塊是Felix Geisendörfer開發的node-formidable模塊。它對解析上傳的文件數據做了很好的抽象。 其實說白了,處理文件上傳“就是”處理POST數據 —— 但是,麻煩的是在具體的處理細節,所以,這裡采用現成的方案更合適點。
使用該模塊,首先需要安裝該模塊。Node.js有它自己的包管理器,叫NPM。它可以讓安裝Node.js的外部模塊變得非常方便。通過如下一條命令就可以完成該模塊的安裝:
復制代碼 代碼如下:
npm install formidable
如果終端輸出如下內容:
復制代碼 代碼如下:
npm info build Success: formidable@1.0.9
npm ok
就說明模塊已經安裝成功了。
現在我們就可以用formidable模塊了——使用外部模塊與內部模塊類似,用require語句將其引入即可:
復制代碼 代碼如下:
var formidable = require("formidable");
這裡該模塊做的就是將通過HTTP POST請求提交的表單,在Node.js中可以被解析。我們要做的就是創建一個新的IncomingForm,它是對提交表單的抽象表示,之後,就可以用它解析request對象,獲取表單中需要的數據字段。
node-formidable官方的例子展示了這兩部分是如何融合在一起工作的:
復制代碼 代碼如下:
var formidable = require('formidable'),
http = require('http'),
sys = require('sys');
http.createServer(function(req, res) {
if (req.url == '/upload' && req.method.toLowerCase() == 'post') {
// parse a file upload
var form = new formidable.IncomingForm();
form.parse(req, function(err, fields, files) {
res.writeHead(200, {'content-type': 'text/plain'});
res.write('received upload:\n\n');
res.end(sys.inspect({fields: fields, files: files}));
});
return;
}
// show a file upload form
res.writeHead(200, {'content-type': 'text/html'});
res.end(
'<form action="/upload" enctype="multipart/form-data" '+
'method="post">'+
'<input type="text" name="title"><br>'+
'<input type="file" name="upload" multiple="multiple"><br>'+
'<input type="submit" value="Upload">'+
'</form>'
);
}).listen(8888);
如果我們將上述代碼,保存到一個文件中,並通過node來執行,就可以進行簡單的表單提交了,包括文件上傳。然後,可以看到通過調用form.parse傳遞給回調函數的files對象的內容,如下所示:
復制代碼 代碼如下:
received upload:
{ fields: { title: 'Hello World' },
files:
{ upload:
{ size: 1558,
path: '/tmp/1c747974a27a6292743669e91f29350b',
name: 'us-flag.png',
type: 'image/png',
lastModifiedDate: Tue, 21 Jun 2011 07:02:41 GMT,
_writeStream: [Object],
length: [Getter],
filename: [Getter],
mime: [Getter] } } }
為了實現我們的功能,我們需要將上述代碼應用到我們的應用中,另外,我們還要考慮如何將上傳文件的內容(保存在/tmp目錄中)顯示到浏覽器中。
我們先來解決後面那個問題: 對於保存在本地硬盤中的文件,如何才能在浏覽器中看到呢?
顯然,我們需要將該文件讀取到我們的服務器中,使用一個叫fs的模塊。
我們來添加/showURL的請求處理程序,該處理程序直接硬編碼將文件/tmp/test.png內容展示到浏覽器中。當然了,首先需要將該圖片保存到這個位置才行。
將requestHandlers.js修改為如下形式:
復制代碼 代碼如下:
var querystring = require("querystring"),
fs = require("fs");
function start(response, postData) {
console.log("Request handler 'start' was called.");
var body = '<html>'+
'<head>'+
'<meta http-equiv="Content-Type" '+
'content="text/html; charset=UTF-8" />'+
'</head>'+
'<body>'+
'<form action="/upload" method="post">'+
'<textarea name="text" rows="20" cols="60"></textarea>'+
'<input type="submit" value="Submit text" />'+
'</form>'+
'</body>'+
'</html>';
response.writeHead(200, {"Content-Type": "text/html"});
response.write(body);
response.end();
}
function upload(response, postData) {
console.log("Request handler 'upload' was called.");
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("You've sent the text: "+
querystring.parse(postData).text);
response.end();
}
function show(response, postData) {
console.log("Request handler 'show' was called.");
fs.readFile("/tmp/test.png", "binary", function(error, file) {
if(error) {
response.writeHead(500, {"Content-Type": "text/plain"});
response.write(error + "\n");
response.end();
} else {
response.writeHead(200, {"Content-Type": "image/png"});
response.write(file, "binary");
response.end();
}
});
}
exports.start = start;
exports.upload = upload;
exports.show = show;
我們還需要將這新的請求處理程序,添加到index.js中的路由映射表中:
復制代碼 代碼如下:
var server = require("./server");
var router = require("./router");
var requestHandlers = require("./requestHandlers");
var handle = {}
handle["/"] = requestHandlers.start;
handle["/start"] = requestHandlers.start;
handle["/upload"] = requestHandlers.upload;
handle["/show"] = requestHandlers.show;
server.start(router.route, handle);
重啟服務器之後,通過訪問http://localhost:8888/show,就可以看到保存在/tmp/test.png的圖片了。
好,最後我們要的就是:
在/start表單中添加一個文件上傳元素
將node-formidable整合到我們的upload請求處理程序中,用於將上傳的圖片保存到/tmp/test.png
將上傳的圖片內嵌到/uploadURL輸出的HTML中
第一項很簡單。只需要在HTML表單中,添加一個multipart/form-data的編碼類型,移除此前的文本區,添加一個文件上傳組件,並將提交按鈕的文案改為“Upload file”即可。 如下requestHandler.js所示:
復制代碼 代碼如下:
var querystring = require("querystring"),
fs = require("fs");
function start(response, postData) {
console.log("Request handler 'start' was called.");
var body = '<html>'+
'<head>'+
'<meta http-equiv="Content-Type" '+
'content="text/html; charset=UTF-8" />'+
'</head>'+
'<body>'+
'<form action="/upload" enctype="multipart/form-data" '+
'method="post">'+
'<input type="file" name="upload">'+
'<input type="submit" value="Upload file" />'+
'</form>'+
'</body>'+
'</html>';
response.writeHead(200, {"Content-Type": "text/html"});
response.write(body);
response.end();
}
function upload(response, postData) {
console.log("Request handler 'upload' was called.");
response.writeHead(200, {"Content-Type": "text/plain"});
response.write("You've sent the text: "+
querystring.parse(postData).text);
response.end();
}
function show(response, postData) {
console.log("Request handler 'show' was called.");
fs.readFile("/tmp/test.png", "binary", function(error, file) {
if(error) {
response.writeHead(500, {"Content-Type": "text/plain"});
response.write(error + "\n");
response.end();
} else {
response.writeHead(200, {"Content-Type": "image/png"});
response.write(file, "binary");
response.end();
}
});
}
exports.start = start;
exports.upload = upload;
exports.show = show;
很好。下一步相對比較復雜。這裡有這樣一個問題: 我們需要在upload處理程序中對上傳的文件進行處理,這樣的話,我們就需要將request對象傳遞給node-formidable的form.parse函數。
但是,我們有的只是response對象和postData數組。看樣子,我們只能不得不將request對象從服務器開始一路通過請求路由,再傳遞給請求處理程序。 或許還有更好的方案,但是,不管怎麼說,目前這樣做可以滿足我們的需求。
到這裡,我們可以將postData從服務器以及請求處理程序中移除了 —— 一方面,對於我們處理文件上傳來說已經不需要了,另外一方面,它甚至可能會引發這樣一個問題: 我們已經“消耗”了request對象中的數據,這意味著,對於form.parse來說,當它想要獲取數據的時候就什麼也獲取不到了。(因為Node.js不會對數據做緩存)
我們從server.js開始 —— 移除對postData的處理以及request.setEncoding (這部分node-formidable自身會處理),轉而采用將request對象傳遞給請求路由的方式:
復制代碼 代碼如下:
var http = require("http");
var url = require("url");
function start(route, handle) {
function onRequest(request, response) {
var pathname = url.parse(request.url).pathname;
console.log("Request for " + pathname + " received.");
route(handle, pathname, response, request);
}
http.createServer(onRequest).listen(8888);
console.log("Server has started.");
}
exports.start = start;
接下來是 router.js —— 我們不再需要傳遞postData了,這次要傳遞request對象:
function route(handle, pathname, response, request) {
console.log("About to route a request for " + pathname);
if (typeof handle[pathname] === 'function') {
handle[pathname](response, request);
} else {
console.log("No request handler found for " + pathname);
response.writeHead(404, {"Content-Type": "text/html"});
response.write("404 Not found");
response.end();
}
}
exports.route = route;
現在,request對象就可以在我們的upload請求處理程序中使用了。node-formidable會處理將上傳的文件保存到本地/tmp目錄中,而我們需要做的是確保該文件保存成/tmp/test.png。 沒錯,我們保持簡單,並假設只允許上傳PNG圖片。
這裡采用fs.renameSync(path1,path2)來實現。要注意的是,正如其名,該方法是同步執行的, 也就是說,如果該重命名的操作很耗時的話會阻塞。 這塊我們先不考慮。
接下來,我們把處理文件上傳以及重命名的操作放到一起,如下requestHandlers.js所示:
復制代碼 代碼如下:
var querystring = require("querystring"),
fs = require("fs"),
formidable = require("formidable");
function start(response) {
console.log("Request handler 'start' was called.");
var body = '<html>'+
'<head>'+
'<meta http-equiv="Content-Type" content="text/html; '+
'charset=UTF-8" />'+
'</head>'+
'<body>'+
'<form action="/upload" enctype="multipart/form-data" '+
'method="post">'+
'<input type="file" name="upload" multiple="multiple">'+
'<input type="submit" value="Upload file" />'+
'</form>'+
'</body>'+
'</html>';
response.writeHead(200, {"Content-Type": "text/html"});
response.write(body);
response.end();
}
function upload(response, request) {
console.log("Request handler 'upload' was called.");
var form = new formidable.IncomingForm();
console.log("about to parse");
form.parse(request, function(error, fields, files) {
console.log("parsing done");
fs.renameSync(files.upload.path, "/tmp/test.png");
response.writeHead(200, {"Content-Type": "text/html"});
response.write("received image:<br/>");
response.write("<img src='/show' />");
response.end();
});
}
function show(response) {
console.log("Request handler 'show' was called.");
fs.readFile("/tmp/test.png", "binary", function(error, file) {
if(error) {
response.writeHead(500, {"Content-Type": "text/plain"});
response.write(error + "\n");
response.end();
} else {
response.writeHead(200, {"Content-Type": "image/png"});
response.write(file, "binary");
response.end();
}
});
}
exports.start = start;
exports.upload = upload;
exports.show = show;
好了,重啟服務器,我們應用所有的功能就可以用了。選擇一張本地圖片,將其上傳到服務器,然後浏覽器就會顯示該圖片。
總結與展望
恭喜,我們的任務已經完成了!我們開發完了一個Node.js的web應用,應用雖小,但卻“五髒俱全”。 期間,我們介紹了很多技術點:服務端JavaScript、函數式編程、阻塞與非阻塞、回調、事件、內部和外部模塊等等。
當然了,還有許多本書沒有介紹到的: 如何操作數據庫、如何進行單元測試、如何開發Node.js的外部模塊以及一些簡單的諸如如何獲取GET請求之類的方法。
但本書畢竟只是一本給初學者的教程 —— 不可能覆蓋到所有的內容。
幸運的是,Node.js社區非常活躍(作個不恰當的比喻就是猶如一群有多動症小孩子在一起,能不活躍嗎?), 這意味著,有許多關於Node.js的資源,有什麼問題都可以向社區尋求解答。 其中Node.js社區的wiki以及 NodeCloud就是最好的資源。