學習講究的是學以致用和融會貫通。至此我們已經分別介紹了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有一個龐大的生態圈,在寫代碼之前先看看有沒有現成的三方包能節省不少時間。
-
不要迷信三方包。任何事情做過頭了就不好了,三方包也是一樣。三方包是一個黑盒,每多使用一個三方包,就為程序增加了一份潛在風險。並且三方包很難恰好隻提供程序需要的功能,每多使用一個三方包,就讓程序更加臃腫一些。因此在決定使用某個三方包之前,最好三思而後行。