NodeJS伺服器篇之簡單靜態檔案合併
NodeJS
是一個基於Chrome V8
引擎的JavaScript
執行環境,其使用了事件驅動、非同步I/O
機制,具有執行速度快,效能優異等特點,非常適合在分散式裝置上執行資料密集型的實時應用。
本文主要介紹一下通過搭建簡單的NodeJS
伺服器,實現靜態檔案的合併,並通過瀏覽器訪問輸出的功能;同時,還會進行功能的完善,通過不斷的迭代開發,從易用性、效能、安全性等等方面,較為全面的介紹一下NodeJS
伺服器的開發過程,為以後的進一步學習做準備。
在下面的內容開始之前,假定您對JavaScript
已經有了一定的瞭解,如果您之前沒有了解過,請先熟悉一下七天學會NodeJS,本文主要參考上述資料的最後一部分,為作者的開源奉獻精神表示感謝。下面正式開始介紹伺服器的具體實現:
需求
實現一個靜態檔案合併的伺服器,通過請求的連結(URL
)指定需要合併的檔案,之後把檔案內容返回給客戶端。參考連結如下:
http://127.0.0.1:8300/??a.js,b.js
分析
連結中的??
是一個分隔符,前面是需要合併的檔案路徑,後面是需要合併的檔名,多個檔名之間用,
分隔,因此伺服器處理這個URL後返回的是各個檔案的路徑;之後,通過遞迴讀取檔案內容,再進行拼接合並;最後,通過響應資料輸出給客戶端。這是整個伺服器的全部分析過程。
由於涉及到檔案操作,所以需要fs
模組、path
模組;加上伺服器模組http
,一共需要三個模組:fs、path、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 {
const data = Buffer.concat(output);
console.log(data);
callback(null, data);
}
}(0, pathnames.length));
}
function main(argv) {
// 從檔案讀取配置引數
// var config = JSON.parse(fs.readFileSync(argv[0], 'utf-8')),
// root = config.root || '.',
// port = config.port || 80;
// 直接給定配置引數
var root = __dirname;
var port = 8300;
http.createServer(function (request, response) {
var urlInfo = parseURL(root, request.url);
console.log(urlInfo);
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) {
var filePath = path.join(root, base, value);
return filePath;
});
return {
mime: MIME[path.extname(pathnames[0])] || 'text/plain',
pathnames: pathnames
};
}
main(process.argv.slice(2));
/*
測試URL: 127.0.0.1:8300/??a.js,b.js
輸出:
hello
kelvin
world
*/
以上程式碼完整實現了伺服器的功能,可以用測試URL
請求,就會輸出其後的內容。其中,有幾點需要注意:
- 命令列引數可以通過讀取
JSON
配置檔案,或者直接在main
函式內設定(缺點是修改不方便,配置不靈活) - 入口
main
函式開啟了http
伺服器;combineFiles
函式負責非同步讀取檔案內容,併合並檔案內容;parseULR
函式負責解析URL
,並返回檔案的MIME
型別(在返回資料給客戶端時,指定資料的型別)和檔名陣列,。
伺服器的工作流程如下:
傳送請求 等待服務端響應 接收響應
---------+----------------------+------------->
-- 解析請求
------ 讀取a.js
------ 讀取b.js
------ 讀取c.js
-- 合併資料
-- 輸出響應
第二版
由於第一版中,程式碼是把檔案內容全部讀取到記憶體後,再進行資料合併的,這會導致如下問題:
- 當請求的檔案較多,需要合併的資料量又比較大時,序列讀取檔案會比較耗時,拖慢服務的相應時間
- 每次都完整的把資料讀到記憶體快取起來,當伺服器併發數較大時,就會有較大的記憶體開銷
針對上面的第一個問題,如果改為並行讀取方式,對於機械磁碟來說,需要不停的切換磁頭,反而會降低I/O
效率。而對於固態硬碟,是存在多個並行的I/O
的,對單個請求採用並行也不會提高效率。因此,採用流式讀取方式:一遍讀取,一遍輸出,把相應的輸出時機提前至讀取第一個檔案的時刻,這樣就能解決上述的問題。
修改後的伺服器工作流程如下:
傳送請求 等待服務端響應 接收響應
---------+----+------------------------------->
-- 解析請求
-- 檢查檔案是否存在
-- 輸出響應頭
------ 讀取和輸出a.js
------ 讀取和輸出b.js
------ 讀取和輸出c.js
可以看到,調整後的程式碼是邊讀取邊輸出,即快速響應請求,有減少了記憶體的壓力。
原始碼如下:
var fs = require('fs'),
path = require('path'),
http = require('http');
var MIME = {
'.css': 'text/css',
'.js': 'application/javascript'
};
function main(argv) {
var root = __dirname;
var port = 8300;
http.createServer((request, response) => {
var urlInfo = parseURL(root, request.url);
validateFiles(urlInfo.pathnames, (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], (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));
}
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) {
var filePath = path.join(root, base, value);
return filePath;
});
return {
mime: MIME[path.extname(pathnames[0])] || 'text/plain',
pathnames: pathnames
};
}
main();
第三版
伺服器的功能和效能已經得到初步滿足,接下來我們要考慮穩定性。由於沒有系統是絕對的穩定,都存在一定的宕機風險,而這一問題不可避免,所以我們要儘量減少宕機的時間,比如增加一個守護程序,在伺服器掛掉後立即重啟。並且NodeJS
官方也建議在出現異常時重啟,因為這時系統處於一種不穩定的狀態。
所以,我們利用NodeJS
的程序管理機制,將守護程序作為父程序,將伺服器程序作為子程序,讓父程序監控子程序的執行狀態,在其異常時立即退出重啟子程序。
守護程序程式碼如下:
var cp = require('child_process');
var worker;
function spawn(server, config) {
worker = cp.spawn('node', [server, config]);
worker.on('exit', (code) => {
console.log("code: " + code)
if (code != 0) {
console.log('自動重啟');
spawn(server, config);
}
});
}
function main(argv) {
spawn('server2.js', argv[0]);
process.on('SIGTERM', () => {
worker.kill();
process.exit(0);
});
}
main(process.argv.slice(2));
伺服器程式碼也要在main
函式裡做如下調整:
function main(argv) {
...
server = http.createServer((request, response) => {
var urlInfo = parseURL(root, request.url);
validateFiles(urlInfo.pathnames, (err, pathnames) => {
...
})
}).listen(port);
process.on('SIGTERM', () => {
server.close(() => {
process.exit(0);
});
});
}
這樣調整後,守護程序會進一步啟動和監控伺服器程序。此外,為了能夠正常終止服務,我們讓守護程序在接收到SIGTERM訊號時終止伺服器程序。而在伺服器程序這一端,同樣在收到SIGTERM訊號時先停掉HTTP服務再正常退出。至此,我們的伺服器程式就靠譜很多了。
至此,NodeJS
合併檔案的伺服器開發完成,當然還有許多不足之處,比如:提供日誌通知訪問量、充分利用多核CPU
等等。如有興趣,可以在此基礎之上,做進一步的開發。
原始碼地址
參考資料