七天學會NodeJS(四)一邊讀取一邊輸出回應、守護行程(child process、spawn)、功能-性能-穩定性-代碼部署
文章目錄
- 七天學會NodeJS(四)一邊讀取一邊輸出回應、守護行程(child process、spawn)、功能-性能-穩定性-代碼部署
- 1. 大示例
- 需求
- 第一次迭代
- 設計
- 實作
- 第二次迭代
- 設計
- 實作
- 第三次迭代
- 設計
- 實作
- 第四次迭代
- 設計
- 實作
- 后續迭代
- 小結
總結:
合并靜態檔案示例
本章小結
process補充
SIGKILL是告訴行程要立即終止的信號,理想情況下,其行為類似于process.exit(),
SIGTERM是告訴行程要正常終止的信號,它是從行程管理者(如upstart或supervisord)等發出的信號,可以從程式內部另一個函式中發送此信號:
process.kill(process.pid, 'SIGTERM')或從另一個正在運行的 Node.js 程式、或從系統中運行的其他任何的應用程式(能知道要終止的行程的 PID),
Child Process
- 官方檔案: http://nodejs.org/api/child_process.html
- 使用child_process模塊可以創建和控制子行程,
- 該模塊提供的API中最核心的是.spawn,其余API都是針對特定使用場景對它的進一步封裝,算是一種語法糖,
- 使用.spawn(exec, args, options)方法,創建子行程,該方法支持三個引數,
- 第一個引數是執行檔案路徑,可以是執行檔案的相對或絕對路徑,也可以是根據PATH環境變數能找到的執行檔案名,
- 第二個引數中,陣列中的每個成員都按順序對應一個命令列引數,
- 第三個引數可選,用于配置子行程的執行環境與行為,
第一次迭代(功能)
服務器會首先分析URL,得到請求的檔案的路徑和型別(MIME),
- 使用命令列引數傳遞JSON組態檔路徑,入口函式負責讀取配置并創建服務器,
- 入口函式完整描述了程式的運行邏輯,其中決議URL和合并檔案的具體實作封裝在其它兩個函式里,
- 決議URL時先將普通URL轉換為了檔案合并URL,使得兩種URL的處理方式可以一致,
然后,服務器會讀取請求的檔案,并按順序合并檔案內容,
- 合并檔案時使用異步API讀取檔案,避免服務器因等待磁盤IO而發生阻塞,
最后,服務器回傳回應,完成對一次請求的處理,
邏輯缺陷:
http://assets.example.com/foo/bar.js,foo/baz.js經過分析之后我們會發現問題出在
/被自動替換/??這個行為上,而這個問題我們可以到第二次迭代時再解決,第二次迭代(性能)
- 第二版代碼在檢查了請求的所有檔案是否有效之后,立即就輸出了回應頭,并接著一邊按順序讀取檔案一邊輸出回應內容,
- 并且,在讀取檔案時,第二版代碼直接使用了只讀資料流來簡化代碼,
- 服務器本身的功能和性能已經得到了初步滿足
第三次迭代(穩定性)
- 從工程角度上講,沒有絕對可靠的系統,即使第二次迭代的代碼經過反復檢查后能確保沒有bug,也很難說是否會因為NodeJS本身,或者是作業系統本身,甚至是硬體本身導致我們的服務器程式在某一天掛掉,因此一般生產環境下的服務器程式都配有一個守護行程,在服務掛掉的時候立即重啟服務,一般守護行程的代碼會遠比服務行程的代碼簡單,從概率上可以保證守護行程更難掛掉,如果再做得嚴謹一些,甚至守護行程自身可以在自己掛掉時重啟自己,從而實作雙保險,
- 因此在本次迭代時,我們先利用NodeJS的行程管理機制,將守護行程作為父行程,將服務器程式作為子行程,并讓父行程監控子行程的運行狀態,在其例外退出時重啟子行程,
- 步驟
- 把守護行程的代碼保存為
daemon.js,之后我們可以通過node daemon.js config.json啟動服務,而守護行程會進一步啟動和監控服務器行程- 讓守護行程在接收到
SIGTERM信號時終止服務器行程- 而在服務器行程這一端,同樣在收到
SIGTERM信號時先停掉HTTP服務再正常退出,第四次迭代(代碼部署及服務器控制)
1. 大示例
學習講究的是學以致用和融會貫通,至此我們已經分別介紹了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));
}
//1.入口函式main
//2.命令列中輸入的是`node server.js config.json`第一個引數是 `node` 命令的完整路徑,第二個引數是正被執行的檔案的完整路徑,所有其他的引數從第三個位置開始
function main(argv) {
//argv[0]是`node` 命令的完整路徑
var config = JSON.parse(fs.readFileSync(argv[0], 'utf-8')),
//服務器在讀取檔案時需要有個根目錄,并且服務器監聽的HTTP埠最好也不要寫死在代碼里,因此服務器需要是可配置的,
root = config.root || '.',
port = config.port || 80;
//創建了一個HTTP服務器并監聽port埠
http.createServer(function (request, response) {
//呼叫URL決議函式,得到請求的檔案的路徑和型別(MIME)
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);
}
//URL決議函式,得到請求的檔案的路徑和型別(MIME)
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組態檔路徑,入口函式負責讀取配置并創建服務器,
- 在命令列中輸入的是
node server.js config.json第一個引數是node命令的完整路徑,第二個引數是正被執行的檔案的完整路徑,所有其他的引數從第三個位置開始, - 入口函式完整描述了程式的運行邏輯,其中決議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]);
//守護行程在接收到`SIGTERM`信號時終止服務器行程
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);
//而在服務器行程這一端,同樣在收到`SIGTERM`信號時先停掉HTTP服務再正常退出,
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有一個龐大的生態圈,在寫代碼之前先看看有沒有現成的三方包能節省不少時間,
- 不要迷信三方包,任何事情做過頭了就不好了,三方包也是一樣,三方包是一個黑盒,每多使用一個三方包,就為程式增加了一份潛在風險,并且三方包很難恰好只提供程式需要的功能,每多使用一個三方包,就讓程式更加臃腫一些,因此在決定使用某個三方包之前,最好三思而后行,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qianduan/296612.html
標籤:其他
上一篇:vuex的基本概念
下一篇:Ureport2原始碼啟動
