七天學會NodeJS(大示例)
大示例
學習講究的是學以致用和融會貫通。至此我們已經分別介紹了NodeJS的很多知識點,本章作為最後一章,將完整地介紹一個使用NodeJS開發Web伺服器的示例。
需求
我們要開發的是一個簡單的靜態檔案合併伺服器,該伺服器需要支援類似以下格式的JS或CSS檔案合併請求。
http://assets.example.com/foo/??bar.js,baz.js
在以上URL中,??
是一個分隔符,之前是需要合併的多個檔案的URL的公共部分,之後是使用,
分隔的差異部分。因此伺服器處理這個URL時,返回的是以下兩個檔案按順序合併後的內容。
/foo/bar.js
/foo/baz.js
另外,伺服器也需要能支援類似以下格式的普通的JS或CSS檔案請求。
http://assets.example.com/foo/bar.js
以上就是整個需求。
第一次迭代
快速迭代是一種不錯的開發方式,因此我們在第一次迭代時先實現伺服器的基本功能。
設計
簡單分析了需求之後,我們大致會得到以下的設計方案。
+---------+ +-----------+ +----------+ request -->| parse |-->| combine |-->| output |--> response +---------+ +-----------+ +----------+
也就是說,伺服器會首先分析URL,得到請求的檔案的路徑和型別(MIME)。然後,伺服器會讀取請求的檔案,並按順序合併檔案內容。最後,伺服器返回響應,完成對一次請求的處理。
另外,伺服器在讀取檔案時需要有個根目錄,並且伺服器監聽的HTTP埠最好也不要寫死在程式碼裡,因此伺服器需要是可配置的。
實現
根據以上設計,我們寫出了第一版程式碼如下。
var fs = require('fs'), path = require('path'), http = require('http'); var MIME = { '.css': 'text/css', '.js': 'application/javascript' }; function combineFiles(pathnames, callback) { var output = []; (function next(i, len) { if (i < len) { fs.readFile(pathnames[i], function (err, data) { if (err) { callback(err); } else { output.push(data); next(i + 1, len); } }); } else { callback(null, Buffer.concat(output)); } }(0, pathnames.length)); } function main(argv) { var config = JSON.parse(fs.readFileSync(argv[0], 'utf-8')), root = config.root || '.', port = config.port || 80; http.createServer(function (request, response) { var urlInfo = parseURL(root, request.url); combineFiles(urlInfo.pathnames, function (err, data) { if (err) { response.writeHead(404); response.end(err.message); } else { response.writeHead(200, { 'Content-Type': urlInfo.mime }); response.end(data); } }); }).listen(port); } function parseURL(root, url) { var base, pathnames, parts; if (url.indexOf('??') === -1) { url = url.replace('/', '/??'); } parts = url.split('??'); base = parts[0]; pathnames = parts[1].split(',').map(function (value) { return path.join(root, base, value); }); return { mime: MIME[path.extname(pathnames[0])] || 'text/plain', pathnames: pathnames }; } main(process.argv.slice(2));
以上程式碼完整實現了伺服器所需的功能,並且有以下幾點值得注意:
-
使用命令列引數傳遞JSON配置檔案路徑,入口函式負責讀取配置並建立伺服器。
-
入口函式完整描述了程式的執行邏輯,其中解析URL和合並檔案的具體實現封裝在其它兩個函式裡。
-
解析URL時先將普通URL轉換為了檔案合併URL,使得兩種URL的處理方式可以一致。
-
合併檔案時使用非同步API讀取檔案,避免伺服器因等待磁碟IO而發生阻塞。
我們可以把以上程式碼儲存為server.js
,之後就可以通過node server.js config.json
命令啟動程式,於是我們的第一版靜態檔案合併伺服器就順利完工了。
另外,以上程式碼存在一個不那麼明顯的邏輯缺陷。例如,使用以下URL請求伺服器時會有驚喜。
http://assets.example.com/foo/bar.js,foo/baz.js
經過分析之後我們會發現問題出在/
被自動替換/??
這個行為上,而這個問題我們可以到第二次迭代時再解決。
第二次迭代
在第一次迭代之後,我們已經有了一個可工作的版本,滿足了功能需求。接下來我們需要從效能的角度出發,看看程式碼還有哪些改進餘地。
設計
把map
方法換成for
迴圈或許會更快一些,但第一版程式碼最大的效能問題存在於從讀取檔案到輸出響應的過程當中。我們以處理/??a.js,b.js,c.js
這個請求為例,看看整個處理過程中耗時在哪兒。
傳送請求 等待服務端響應 接收響應
---------+----------------------+------------->
-- 解析請求
------ 讀取a.js
------ 讀取b.js
------ 讀取c.js
-- 合併資料
-- 輸出響應
可以看到,第一版程式碼依次把請求的檔案讀取到記憶體中之後,再合併資料和輸出響應。這會導致以下兩個問題:
-
當請求的檔案比較多比較大時,序列讀取檔案會比較耗時,從而拉長了服務端響應等待時間。
-
由於每次響應輸出的資料都需要先完整地快取在記憶體裡,當伺服器請求併發數較大時,會有較大的記憶體開銷。
對於第一個問題,很容易想到把讀取檔案的方式從序列改為並行。但是別這樣做,因為對於機械磁碟而言,因為只有一個磁頭,嘗試並行讀取檔案只會造成磁頭頻繁抖動,反而降低IO效率。而對於固態硬碟,雖然的確存在多個並行IO通道,但是對於伺服器並行處理的多個請求而言,硬碟已經在做並行IO了,對單個請求採用並行IO無異於拆東牆補西牆。因此,正確的做法不是改用並行IO,而是一邊讀取檔案一邊輸出響應,把響應輸出時機提前至讀取第一個檔案的時刻。這樣調整後,整個請求處理過程變成下邊這樣。
傳送請求 等待服務端響應 接收響應
---------+----+------------------------------->
-- 解析請求
-- 檢查檔案是否存在
-- 輸出響應頭
------ 讀取和輸出a.js
------ 讀取和輸出b.js
------ 讀取和輸出c.js
按上述方式解決第一個問題後,因為伺服器不需要完整地快取每個請求的輸出資料了,第二個問題也迎刃而解。
實現
根據以上設計,第二版程式碼按以下方式調整了部分函式。
function main(argv) {
var config = JSON.parse(fs.readFileSync(argv[0], 'utf-8')),
root = config.root || '.',
port = config.port || 80;
http.createServer(function (request, response) {
var urlInfo = parseURL(root, request.url);
validateFiles(urlInfo.pathnames, function (err, pathnames) {
if (err) {
response.writeHead(404);
response.end(err.message);
} else {
response.writeHead(200, {
'Content-Type': urlInfo.mime
});
outputFiles(pathnames, response);
}
});
}).listen(port);
}
function outputFiles(pathnames, writer) {
(function next(i, len) {
if (i < len) {
var reader = fs.createReadStream(pathnames[i]);
reader.pipe(writer, { end: false });
reader.on('end', function() {
next(i + 1, len);
});
} else {
writer.end();
}
}(0, pathnames.length));
}
function validateFiles(pathnames, callback) {
(function next(i, len) {
if (i < len) {
fs.stat(pathnames[i], function (err, stats) {
if (err) {
callback(err);
} else if (!stats.isFile()) {
callback(new Error());
} else {
next(i + 1, len);
}
});
} else {
callback(null, pathnames);
}
}(0, pathnames.length));
}
可以看到,第二版程式碼在檢查了請求的所有檔案是否有效之後,立即就輸出了響應頭,並接著一邊按順序讀取檔案一邊輸出響應內容。並且,在讀取檔案時,第二版程式碼直接使用了只讀資料流來簡化程式碼。
第三次迭代
第二次迭代之後,伺服器本身的功能和效能已經得到了初步滿足。接下來我們需要從穩定性的角度重新審視一下程式碼,看看還需要做些什麼。
設計
從工程角度上講,沒有絕對可靠的系統。即使第二次迭代的程式碼經過反覆檢查後能確保沒有bug,也很難說是否會因為NodeJS本身,或者是作業系統本身,甚至是硬體本身導致我們的伺服器程式在某一天掛掉。因此一般生產環境下的伺服器程式都配有一個守護程序,在服務掛掉的時候立即重啟服務。一般守護程序的程式碼會遠比服務程序的程式碼簡單,從概率上可以保證守護程序更難掛掉。如果再做得嚴謹一些,甚至守護程序自身可以在自己掛掉時重啟自己,從而實現雙保險。
因此在本次迭代時,我們先利用NodeJS的程序管理機制,將守護程序作為父程序,將伺服器程式作為子程序,並讓父程序監控子程序的執行狀態,在其異常退出時重啟子程序。
實現
根據以上設計,我們編寫了守護程序需要的程式碼。
var cp = require('child_process');
var worker;
function spawn(server, config) {
worker = cp.spawn('node', [ server, config ]);
worker.on('exit', function (code) {
if (code !== 0) {
spawn(server, config);
}
});
}
function main(argv) {
spawn('server.js', argv[0]);
process.on('SIGTERM', function () {
worker.kill();
process.exit(0);
});
}
main(process.argv.slice(2));
此外,伺服器程式碼本身的入口函式也要做以下調整。
function main(argv) {
var config = JSON.parse(fs.readFileSync(argv[0], 'utf-8')),
root = config.root || '.',
port = config.port || 80,
server;
server = http.createServer(function (request, response) {
...
}).listen(port);
process.on('SIGTERM', function () {
server.close(function () {
process.exit(0);
});
});
}
我們可以把守護程序的程式碼儲存為daemon.js
,之後我們可以通過node daemon.js config.json
啟動服務,而守護程序會進一步啟動和監控伺服器程序。此外,為了能夠正常終止服務,我們讓守護程序在接收到SIGTERM
訊號時終止伺服器程序。而在伺服器程序這一端,同樣在收到SIGTERM
訊號時先停掉HTTP服務再正常退出。至此,我們的伺服器程式就靠譜很多了。
第四次迭代
在我們解決了伺服器本身的功能、效能和可靠性的問題後,接著我們需要考慮一下程式碼部署的問題,以及伺服器控制的問題。
設計
一般而言,程式在伺服器上有一個固定的部署目錄,每次程式有更新後,都重新發布到部署目錄裡。而一旦完成部署後,一般也可以通過固定的服務控制指令碼啟動和停止服務。因此我們的伺服器程式部署目錄可以做如下設計。
- deploy/
- bin/
startws.sh
killws.sh
+ conf/
config.json
+ lib/
daemon.js
server.js
在以上目錄結構中,我們分類存放了服務控制指令碼、配置檔案和伺服器程式碼。
實現
按以上目錄結構分別存放對應的檔案之後,接下來我們看看控制指令碼怎麼寫。首先是start.sh
。
#!/bin/sh
if [ ! -f "pid" ]
then
node ../lib/daemon.js ../conf/config.json &
echo $! > pid
fi
然後是killws.sh
。
#!/bin/sh
if [ -f "pid" ]
then
kill $(tr -d '\r\n' < pid)
rm pid
fi
於是這樣我們就有了一個簡單的程式碼部署目錄和服務控制指令碼,我們的伺服器程式就可以上線工作了。
後續迭代
我們的伺服器程式正式上線工作後,我們接下來或許會發現還有很多可以改進的點。比如伺服器程式在合併JS檔案時可以自動在JS檔案之間插入一個;
來避免一些語法問題,比如伺服器程式需要提供日誌來統計訪問量,比如伺服器程式需要能充分利用多核CPU,等等。而此時的你,在學習了這麼久NodeJS之後,應該已經知道該怎麼做了。
小結
本章將之前零散介紹的知識點串了起來,完整地演示了一個使用NodeJS開發程式的例子,至此我們的課程就全部結束了。以下是對新誕生的NodeJSer的一些建議。
-
要熟悉官方API文件。並不是說要熟悉到能記住每個API的名稱和用法,而是要熟悉NodeJS提供了哪些功能,一旦需要時知道查詢API文件的哪塊地方。
-
要先設計再實現。在開發一個程式前首先要有一個全域性的設計,不一定要很周全,但要足夠能寫出一些程式碼。
-
要實現後再設計。在寫了一些程式碼,有了一些具體的東西后,一定會發現一些之前忽略掉的細節。這時再反過來改進之前的設計,為第二輪迭代做準備。
-
要充分利用三方包。NodeJS有一個龐大的生態圈,在寫程式碼之前先看看有沒有現成的三方包能節省不少時間。
-
不要迷信三方包。任何事情做過頭了就不好了,三方包也是一樣。三方包是一個黑盒,每多使用一個三方包,就為程式增加了一份潛在風險。並且三方包很難恰好只提供程式需要的功能,每多使用一個三方包,就讓程式更加臃腫一些。因此在決定使用某個三方包之前,最好三思而後行。