快速迭代是一種不錯(cuò)的開發(fā)方式,因此我們?cè)诘谝淮蔚鷷r(shí)先實(shí)現(xiàn)服務(wù)器的基本功能。
簡(jiǎn)單分析了需求之后,我們大致會(huì)得到以下的設(shè)計(jì)方案。
+---------+ +-----------+ +----------+
request -->| parse |-->| combine |-->| output |--> response
+---------+ +-----------+ +----------+
也就是說,服務(wù)器會(huì)首先分析 URL,得到請(qǐng)求的文件的路徑和類型(MIME)。然后,服務(wù)器會(huì)讀取請(qǐng)求的文件,并按順序合并文件內(nèi)容。最后,服務(wù)器返回響應(yīng),完成對(duì)一次請(qǐng)求的處理。
另外,服務(wù)器在讀取文件時(shí)需要有個(gè)根目錄,并且服務(wù)器監(jiān)聽的HTTP端口最好也不要寫死在代碼里,因此服務(wù)器需要是可配置的。
根據(jù)以上設(shè)計(jì),我們寫出了第一版代碼如下。
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));
以上代碼完整實(shí)現(xiàn)了服務(wù)器所需的功能,并且有以下幾點(diǎn)值得注意:
使用命令行參數(shù)傳遞 JSON 配置文件路徑,入口函數(shù)負(fù)責(zé)讀取配置并創(chuàng)建服務(wù)器。
入口函數(shù)完整描述了程序的運(yùn)行邏輯,其中解析 URL 和合并文件的具體實(shí)現(xiàn)封裝在其它兩個(gè)函數(shù)里。
解析 URL 時(shí)先將普通 URL 轉(zhuǎn)換為了文件合并URL,使得兩種 URL 的處理方式可以一致。
我們可以把以上代碼保存為 server.js,之后就可以通過 node server.js config.json 命令啟動(dòng)程序,于是我們的第一版靜態(tài)文件合并服務(wù)器就順利完工了。
另外,以上代碼存在一個(gè)不那么明顯的邏輯缺陷。例如,使用以下 URL 請(qǐng)求服務(wù)器時(shí)會(huì)有驚喜。
http://assets.example.com/foo/bar.js,foo/baz.js
經(jīng)過分析之后我們會(huì)發(fā)現(xiàn)問題出在/被自動(dòng)替換/??這個(gè)行為上,而這個(gè)問題我們可以到第二次迭代時(shí)再解決。
在第一次迭代之后,我們已經(jīng)有了一個(gè)可工作的版本,滿足了功能需求。接下來我們需要從性能的角度出發(fā),看看代碼還有哪些改進(jìn)余地。
把 map 方法換成 for 循環(huán)或許會(huì)更快一些,但第一版代碼最大的性能問題存在于從讀取文件到輸出響應(yīng)的過程當(dāng)中。我們以處理/??a.js,b.js,c.js
這個(gè)請(qǐng)求為例,看看整個(gè)處理過程中耗時(shí)在哪兒。
發(fā)送請(qǐng)求 等待服務(wù)端響應(yīng) 接收響應(yīng)
---------+----------------------+------------->
-- 解析請(qǐng)求
------ 讀取a.js
------ 讀取b.js
------ 讀取c.js
-- 合并數(shù)據(jù)
-- 輸出響應(yīng)
可以看到,第一版代碼依次把請(qǐng)求的文件讀取到內(nèi)存中之后,再合并數(shù)據(jù)和輸出響應(yīng)。這會(huì)導(dǎo)致以下兩個(gè)問題:
當(dāng)請(qǐng)求的文件比較多比較大時(shí),串行讀取文件會(huì)比較耗時(shí),從而拉長(zhǎng)了服務(wù)端響應(yīng)等待時(shí)間。
對(duì)于第一個(gè)問題,很容易想到把讀取文件的方式從串行改為并行。但是別這樣做,因?yàn)閷?duì)于機(jī)械磁盤而言,因?yàn)橹挥幸粋€(gè)磁頭,嘗試并行讀取文件只會(huì)造成磁頭頻繁抖動(dòng),反而降低 IO 效率。而對(duì)于固態(tài)硬盤,雖然的確存在多個(gè)并行IO通道,但是對(duì)于服務(wù)器并行處理的多個(gè)請(qǐng)求而言,硬盤已經(jīng)在做并行 IO 了,對(duì)單個(gè)請(qǐng)求采用并行 IO 無異于拆東墻補(bǔ)西墻。因此,正確的做法不是改用并行 IO,而是一邊讀取文件一邊輸出響應(yīng),把響應(yīng)輸出時(shí)機(jī)提前至讀取第一個(gè)文件的時(shí)刻。這樣調(diào)整后,整個(gè)請(qǐng)求處理過程變成下邊這樣。
發(fā)送請(qǐng)求 等待服務(wù)端響應(yīng) 接收響應(yīng)
---------+----+------------------------------->
-- 解析請(qǐng)求
-- 檢查文件是否存在
-- 輸出響應(yīng)頭
------ 讀取和輸出a.js
------ 讀取和輸出b.js
------ 讀取和輸出c.js
按上述方式解決第一個(gè)問題后,因?yàn)榉?wù)器不需要完整地緩存每個(gè)請(qǐng)求的輸出數(shù)據(jù)了,第二個(gè)問題也迎刃而解。
根據(jù)以上設(shè)計(jì),第二版代碼按以下方式調(diào)整了部分函數(shù)。
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));
}
可以看到,第二版代碼在檢查了請(qǐng)求的所有文件是否有效之后,立即就輸出了響應(yīng)頭,并接著一邊按順序讀取文件一邊輸出響應(yīng)內(nèi)容。并且,在讀取文件時(shí),第二版代碼直接使用了只讀數(shù)據(jù)流來簡(jiǎn)化代碼。
第二次迭代之后,服務(wù)器本身的功能和性能已經(jīng)得到了初步滿足。接下來我們需要從穩(wěn)定性的角度重新審視一下代碼,看看還需要做些什么。
從工程角度上講,沒有絕對(duì)可靠的系統(tǒng)。即使第二次迭代的代碼經(jīng)過反復(fù)檢查后能確保沒有 bug,也很難說是否會(huì)因?yàn)?NodeJS 本身,或者是操作系統(tǒng)本身,甚至是硬件本身導(dǎo)致我們的服務(wù)器程序在某一天掛掉。因此一般生產(chǎn)環(huán)境下的服務(wù)器程序都配有一個(gè)守護(hù)進(jìn)程,在服務(wù)掛掉的時(shí)候立即重啟服務(wù)。一般守護(hù)進(jìn)程的代碼會(huì)遠(yuǎn)比服務(wù)進(jìn)程的代碼簡(jiǎn)單,從概率上可以保證守護(hù)進(jìn)程更難掛掉。如果再做得嚴(yán)謹(jǐn)一些,甚至守護(hù)進(jìn)程自身可以在自己掛掉時(shí)重啟自己,從而實(shí)現(xiàn)雙保險(xiǎn)。
因此在本次迭代時(shí),我們先利用 NodeJS 的進(jìn)程管理機(jī)制,將守護(hù)進(jìn)程作為父進(jìn)程,將服務(wù)器程序作為子進(jìn)程,并讓父進(jìn)程監(jiān)控子進(jìn)程的運(yùn)行狀態(tài),在其異常退出時(shí)重啟子進(jìn)程。
根據(jù)以上設(shè)計(jì),我們編寫了守護(hù)進(jìn)程需要的代碼。
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));
此外,服務(wù)器代碼本身的入口函數(shù)也要做以下調(diào)整。
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);
});
});
}
我們可以把守護(hù)進(jìn)程的代碼保存為 daemon.js,之后我們可以通過 node daemon.js config.json 啟動(dòng)服務(wù),而守護(hù)進(jìn)程會(huì)進(jìn)一步啟動(dòng)和監(jiān)控服務(wù)器進(jìn)程。此外,為了能夠正常終止服務(wù),我們讓守護(hù)進(jìn)程在接收到 SIGTERM 信號(hào)時(shí)終止服務(wù)器進(jìn)程。而在服務(wù)器進(jìn)程這一端,同樣在收到 SIGTERM 信號(hào)時(shí)先停掉 HTTP 服務(wù)再正常退出。至此,我們的服務(wù)器程序就靠譜很多了。
在我們解決了服務(wù)器本身的功能、性能和可靠性的問題后,接著我們需要考慮一下代碼部署的問題,以及服務(wù)器控制的問題。
一般而言,程序在服務(wù)器上有一個(gè)固定的部署目錄,每次程序有更新后,都重新發(fā)布到部署目錄里。而一旦完成部署后,一般也可以通過固定的服務(wù)控制腳本啟動(dòng)和停止服務(wù)。因此我們的服務(wù)器程序部署目錄可以做如下設(shè)計(jì)。
- deploy/
- bin/
startws.sh
killws.sh
+ conf/
config.json
+ lib/
daemon.js
server.js
在以上目錄結(jié)構(gòu)中,我們分類存放了服務(wù)控制腳本、配置文件和服務(wù)器代碼。
按以上目錄結(jié)構(gòu)分別存放對(duì)應(yīng)的文件之后,接下來我們看看控制腳本怎么寫。首先是 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
于是這樣我們就有了一個(gè)簡(jiǎn)單的代碼部署目錄和服務(wù)控制腳本,我們的服務(wù)器程序就可以上線工作了。
我們的服務(wù)器程序正式上線工作后,我們接下來或許會(huì)發(fā)現(xiàn)還有很多可以改進(jìn)的點(diǎn)。比如服務(wù)器程序在合并 JS 文件時(shí)可以自動(dòng)在 JS 文件之間插入一個(gè);來避免一些語法問題,比如服務(wù)器程序需要提供日志來統(tǒng)計(jì)訪問量,比如服務(wù)器程序需要能充分利用多核 CPU,等等。而此時(shí)的你,在學(xué)習(xí)了這么久 NodeJS 之后,應(yīng)該已經(jīng)知道該怎么做了。