JS是腳本語言,腳本語言都須要一個解析器才能運行。對於寫在HTML頁面裏的JS,瀏覽器充當瞭解析器的角色。而對於須要獨立運行的JS,NodeJS就是一個解析器。javascript
每一種解析器都是一個運行環境,不但容許JS定義各類數據結構,進行各類計算,還容許JS使用運行環境提供的內置對象和方法作一些事情。例如運行在瀏覽器中的JS的用途是操做DOM,瀏覽器就提供了document
之類的內置對象。而運行在NodeJS中的JS的用途是操做磁盤文件或搭建HTTP服務器,NodeJS就相應提供了fs
、http
等內置對象。css
儘管存在一據說能夠直接運行JS文件就以爲很酷的同窗,但大多數同窗在接觸新東西時首先關心的是有啥用處,以及能帶來啥價值。html
NodeJS的做者說,他創造NodeJS的目的是爲了實現高性能Web服務器,他首先看重的是事件機制和異步IO模型的優越性,而不是JS。可是 他須要選擇一種編程語言實現他的想法,這種編程語言不能自帶IO功能,而且須要能良好支持事件機制。JS沒有自帶IO功能,天生就用於處理瀏覽器中的 DOM事件,而且擁有一大羣程序員,所以就成爲了自然的選擇。前端
如他所願,NodeJS在服務端活躍起來,出現了大批基於NodeJS的Web服務。而另外一方面,NodeJS讓前端衆如獲神器,終於可讓本身的能力覆蓋範圍跳出瀏覽器窗口,更大批的前端工具如雨後春筍。java
所以,對於前端而言,雖然不是人人都要拿NodeJS寫一個服務器程序,但簡單可至使用命令交互模式調試JS代碼片斷,複雜可至編寫工具提高工做效率。node
NodeJS生態圈正欣欣向榮。python
NodeJS提供了一些安裝程序,均可以在nodejs.org這裏下載並安裝。程序員
Windows系統下,選擇和系統版本匹配的.msi
後綴的安裝文件。Mac OS X系統下,選擇.pkg
後綴的安裝文件。正則表達式
Linux系統下沒有現成的安裝程序可用,雖然一些發行版能夠使用apt-get
之類的方式安裝,但不必定能安裝到最新版。所以Linux系統下通常使用如下方式編譯方式安裝NodeJS。算法
確保系統下g++版本在4.6以上,python版本在2.6以上。
從nodejs.org下載tar.gz
後綴的NodeJS最新版源代碼包並解壓到某個位置。
進入解壓到的目錄,使用如下命令編譯和安裝。
$ ./configure $ make $ sudo make install
打開終端,鍵入node
進入命令交互模式,能夠輸入一條代碼語句後當即執行並顯示結果,例如:
$ node > console.log('Hello World!'); Hello World!
若是要運行一大段代碼的話,能夠先寫一個JS文件再運行。例若有如下hello.js
。
function hello() { console.log('Hello World!'); } hello();
寫好後在終端下鍵入node hello.js
運行,結果以下:
$ node hello.js Hello World!
在Linux系統下,使用NodeJS監聽80或443端口提供HTTP(S)服務時須要root權限,有兩種方式能夠作到。
一種方式是使用sudo
命令運行NodeJS。例如經過如下命令運行的server.js
中有權限使用80和443端口。通常推薦這種方式,能夠保證僅爲有須要的JS腳本提供root權限。
$ sudo node server.js
另外一種方式是使用chmod +s
命令讓NodeJS老是以root權限運行,具體作法以下。由於這種方式讓任何JS腳本都有了root權限,不太安全,所以在須要很考慮安全的系統下不推薦使用。
$ sudo chown root /usr/local/bin/node $ sudo chmod +s /usr/local/bin/node
編寫稍大一點的程序時通常都會將代碼模塊化。在NodeJS中,通常將代碼合理拆分到不一樣的JS文件中,每個文件就是一個模塊,而文件路徑就是模塊名。
在編寫每一個模塊時,都有require
、exports
、module
三個預先定義好的變量可供使用。
require
函數用於在當前模塊中加載和使用別的模塊,傳入一個模塊名,返回一個模塊導出對象。模塊名可以使用相對路徑(以./
開頭),或者是絕對路徑(以/
或C:
之類的盤符開頭)。另外,模塊名中的.js
擴展名能夠省略。如下是一個例子。
var foo1 = require('./foo'); var foo2 = require('./foo.js'); var foo3 = require('/home/user/foo'); var foo4 = require('/home/user/foo.js'); // foo1至foo4中保存的是同一個模塊的導出對象。
另外,能夠使用如下方式加載和使用一個JSON文件。
var data = require('./data.json');
exports
對象是當前模塊的導出對象,用於導出模塊公有方法和屬性。別的模塊經過require
函數使用當前模塊時獲得的就是當前模塊的exports
對象。如下例子中導出了一個公有方法。
exports.hello = function () { console.log('Hello World!'); };
經過module
對象能夠訪問到當前模塊的一些相關信息,但最多的用途是替換當前模塊的導出對象。例如模塊導出對象默認是一個普通對象,若是想改爲一個函數的話,能夠使用如下方式。
module.exports = function () { console.log('Hello World!'); };
以上代碼中,模塊默認導出對象被替換爲一個函數。
一個模塊中的JS代碼僅在模塊第一次被使用時執行一次,並在執行過程當中初始化模塊的導出對象。以後,緩存起來的導出對象被重複利用。
經過命令行參數傳遞給NodeJS以啓動程序的模塊被稱爲主模塊。主模塊負責調度組成整個程序的其它模塊完成工做。例如經過如下命令啓動程序時,main.js
就是主模塊。
$ node main.js
例若有如下目錄。
- /home/user/hello/ - util/ counter.js main.js
其中counter.js
內容以下:
var i = 0; function count() { return ++i; } exports.count = count;
該模塊內部定義了一個私有變量i
,並在exports
對象導出了一個公有方法count
。
主模塊main.js
內容以下:
var counter1 = require('./util/counter'); var counter2 = require('./util/counter'); console.log(counter1.count()); console.log(counter2.count()); console.log(counter2.count());
運行該程序的結果以下:
$ node main.js 1 2 3
能夠看到,counter.js
並無由於被require了兩次而初始化兩次。
雖然通常咱們使用JS編寫模塊,但NodeJS也支持使用C/C++編寫二進制模塊。編譯好的二進制模塊除了文件擴展名是.node
外,和JS模塊的使用方式相同。雖然二進制模塊能使用操做系統提供的全部功能,擁有無限的潛能,但對於前端同窗而言編寫過於困難,而且難以跨平臺使用,所以不在本教程的覆蓋範圍內。
本章介紹了有關NodeJS的基本概念和使用方法,總結起來有如下知識點:
NodeJS是一個JS腳本解析器,任何操做系統下安裝NodeJS本質上作的事情都是把NodeJS執行程序複製到一個目錄,而後保證這個目錄在系統PATH環境變量下,以便終端下能夠使用node
命令。
終端下直接輸入node
命令可進入命令交互模式,很適合用來測試一些JS代碼片斷,好比正則表達式。
NodeJS使用CMD模塊系統,主模塊做爲程序入口點,全部模塊在執行過程當中只初始化一次。
除非JS模塊不能知足需求,不然不要輕易使用二進制模塊,不然你的用戶會叫苦不迭。
有經驗的C程序員在編寫一個新程序時首先從make文件寫起。一樣的,使用NodeJS編寫程序前,爲了有個良好的開端,首先須要準備好代碼的目錄結構和部署方式,就如同修房子要先搭腳手架。本章將介紹與之相關的各類知識。
咱們已經知道,require
函數支持斜槓(/
)或盤符(C:
)開頭的絕對路徑,也支持./
開頭的相對路徑。但這兩種路徑在模塊之間創建了強耦合關係,一旦某個模塊文件的存放位置須要變動,使用該模塊的其它模塊的代碼也須要跟着調整,變得牽一髮動全身。所以,require
函數支持第三種形式的路徑,寫法相似於foo/bar
,並依次按照如下規則解析路徑,直到找到模塊位置。
內置模塊
若是傳遞給require
函數的是NodeJS內置模塊名稱,不作路徑解析,直接返回內部模塊的導出對象,例如require('fs')
。
node_modules目錄
NodeJS定義了一個特殊的node_modules
目錄用於存放模塊。例如某個模塊的絕對路徑是/home/user/hello.js
,在該模塊中使用require('foo/bar')
方式加載模塊時,則NodeJS依次嘗試使用如下路徑。
/home/user/node_modules/foo/bar /home/node_modules/foo/bar /node_modules/foo/bar
NODE_PATH環境變量
與PATH環境變量相似,NodeJS容許經過NODE_PATH環境變量來指定額外的模塊搜索路徑。NODE_PATH環境變量中包含一到多個目錄路徑,路徑之間在Linux下使用:
分隔,在Windows下使用;
分隔。例如定義瞭如下NODE_PATH環境變量:
NODE_PATH=/home/user/lib:/home/lib
當使用require('foo/bar')
的方式加載模塊時,則NodeJS依次嘗試如下路徑。
/home/user/lib/foo/bar /home/lib/foo/bar
咱們已經知道了JS模塊的基本單位是單個JS文件,但複雜些的模塊每每由多個子模塊組成。爲了便於管理和使用,咱們能夠把由多個子模塊組成的大模塊稱作包
,並把全部子模塊放在同一個目錄裏。
在組成一個包的全部子模塊中,須要有一個入口模塊,入口模塊的導出對象被做爲包的導出對象。例若有如下目錄結構。
- /home/user/lib/ - cat/ head.js body.js main.js
其中cat
目錄定義了一個包,其中包含了3個子模塊。main.js
做爲入口模塊,其內容以下:
var head = require('./head'); var body = require('./body'); exports.create = function (name) { return { name: name, head: head.create(), body: body.create() }; };
在其它模塊裏使用包的時候,須要加載包的入口模塊。接着上例,使用require('/home/user/lib/cat/main')
能達到目的,可是入口模塊名稱出如今路徑裏看上去不是個好主意。所以咱們須要作點額外的工做,讓包使用起來更像是單個模塊。
當模塊的文件名是index.js
,加載模塊時能夠使用模塊所在目錄的路徑代替模塊文件路徑,所以接着上例,如下兩條語句等價。
var cat = require('/home/user/lib/cat'); var cat = require('/home/user/lib/cat/index');
這樣處理後,就只須要把包目錄路徑傳遞給require
函數,感受上整個目錄被看成單個模塊使用,更有總體感。
若是想自定義入口模塊的文件名和存放位置,就須要在包目錄下包含一個package.json
文件,並在其中指定入口模塊的路徑。上例中的cat
模塊能夠重構以下。
- /home/user/lib/ - cat/ + doc/ - lib/ head.js body.js main.js + tests/ package.json
其中package.json
內容以下。
{ "name": "cat", "main": "./lib/main.js" }
如此一來,就一樣能夠使用require('/home/user/lib/cat')
的方式加載模塊。NodeJS會根據包目錄下的package.json
找到入口模塊所在位置。
使用NodeJS編寫的東西,要麼是一個包,要麼是一個命令行程序,而前者最終也會用於開發後者。所以咱們在部署代碼時須要一些技巧,讓用戶以爲本身是在使用一個命令行程序。
例如咱們用NodeJS寫了個程序,能夠把命令行參數原樣打印出來。該程序很簡單,在主模塊內實現了全部功能。而且寫好後,咱們把該程序部署在/home/user/bin/node-echo.js
這個位置。爲了在任何目錄下都能運行該程序,咱們須要使用如下終端命令。
$ node /home/user/bin/node-echo.js Hello World Hello World
這種使用方式看起來不怎麼像是一個命令行程序,下邊的纔是咱們指望的方式。
$ node-echo Hello World
在Linux系統下,咱們能夠把JS文件看成shell腳原本運行,從而達到上述目的,具體步驟以下:
在shell腳本中,能夠經過#!
註釋來指定當前腳本使用的解析器。因此咱們首先在node-echo.js
文件頂部增長如下一行註釋,代表當前腳本使用NodeJS解析。
#! /usr/bin/env node
NodeJS會忽略掉位於JS模塊首行的#!
註釋,沒必要擔憂這行註釋是非法語句。
而後,咱們使用如下命令賦予node-echo.js
文件執行權限。
$ chmod +x /home/user/bin/node-echo.js
最後,咱們在PATH環境變量中指定的某個目錄下,例如在/usr/local/bin
下邊建立一個軟鏈文件,文件名與咱們但願使用的終端命令同名,命令以下:
$ sudo ln -s /home/user/bin/node-echo.js /usr/local/bin/node-echo
這樣處理後,咱們就能夠在任何目錄下使用node-echo
命令了。
在Windows系統下的作法徹底不一樣,咱們得靠.cmd
文件來解決問題。假設node-echo.js
存放在C:\Users\user\bin
目錄,而且該目錄已經添加到PATH環境變量裏了。接下來須要在該目錄下新建一個名爲node-echo.cmd
的文件,文件內容以下:
@node "C:\User\user\bin\node-echo.js" %*
這樣處理後,咱們就能夠在任何目錄下使用node-echo
命令了。
瞭解了以上知識後,如今咱們能夠來完整地規劃一個工程目錄了。以編寫一個命令行程序爲例,通常咱們會同時提供命令行模式和API模式兩種使用方式, 而且咱們會藉助三方包來編寫代碼。除了代碼外,一個完整的程序也應該有本身的文檔和測試用例。所以,一個標準的工程目錄都看起來像下邊這樣。
- /home/user/workspace/node-echo/ # 工程目錄 - bin/ # 存放命令行相關代碼 node-echo + doc/ # 存放文檔 - lib/ # 存放API相關代碼 echo.js - node_modules/ # 存放三方包 + argv/ + tests/ # 存放測試用例 package.json # 元數據文件 README.md # 說明文件
其中部分文件內容以下:
/* bin/node-echo */ var argv = require('argv'), echo = require('../lib/echo'); console.log(echo(argv.join(' '))); /* lib/echo.js */ module.exports = function (message) { return message; }; /* package.json */ { "name": "node-echo", "main": "./lib/echo.js" }
以上例子中分類存放了不一樣類型的文件,並經過node_moudles
目錄直接使用三方包名加載模塊。此外,定義了package.json
以後,node-echo
目錄也可被看成一個包來使用。
NPM是隨同NodeJS一塊兒安裝的包管理工具,能解決NodeJS代碼部署上的不少問題,常見的使用場景有如下幾種:
容許用戶從NPM服務器下載別人編寫的三方包到本地使用。
容許用戶從NPM服務器下載並安裝別人編寫的命令行程序到本地使用。
容許用戶將本身編寫的包或命令行程序上傳到NPM服務器供別人使用。
能夠看到,NPM創建了一個NodeJS生態圈,NodeJS開發者和用戶能夠在裏邊互通有無。如下分別介紹這三種場景下怎樣使用NPM。
須要使用三方包時,首先得知道有哪些包可用。雖然npmjs.org提供了個搜索框能夠根據包名來搜索,但若是連想使用的三方包的名字都不肯定的話,就請百度一下吧。知道了包名後,好比上邊例子中的argv
,就能夠在工程目錄下打開終端,使用如下命令來下載三方包。
$ npm install argv ... argv@0.0.2 node_modules\argv
下載好以後,argv
包就放在了工程目錄下的node_modules
目錄中,所以在代碼中只須要經過require('argv')
的方式就好,無需指定三方包路徑。
以上命令默認下載最新版三方包,若是想要下載指定版本的話,能夠在包名後邊加上@<version>
,例如經過如下命令可下載0.0.1版的argv
。
$ npm install argv@0.0.1 ... argv@0.0.1 node_modules\argv
若是使用到的三方包比較多,在終端下一個包一條命令地安裝未免太人肉了。所以NPM對package.json
的字段作了擴展,容許在其中申明三方包依賴。所以,上邊例子中的package.json
能夠改寫以下:
{ "name": "node-echo", "main": "./lib/echo.js", "dependencies": { "argv": "0.0.2" } }
這樣處理後,在工程目錄下就能夠使用npm install
命令批量安裝三方包了。更重要的是,當之後node-echo
也上傳到了NPM服務器,別人下載這個包時,NPM會根據包中申明的三方包依賴自動下載進一步依賴的三方包。例如,使用npm install node-echo
命令時,NPM會自動建立如下目錄結構。
- project/ - node_modules/ - node-echo/ - node_modules/ + argv/ ... ...
如此一來,用戶只需關心本身直接使用的三方包,不須要本身去解決全部包的依賴關係。
從NPM服務上下載安裝一個命令行程序的方法與三方包相似。例如上例中的node-echo
提供了命令行使用方式,只要node-echo
本身配置好了相關的package.json
字段,對於用戶而言,只須要使用如下命令安裝程序。
$ npm install node-echo -g
參數中的-g
表示全局安裝,所以node-echo
會默認安裝到如下位置,而且NPM會自動建立好Linux系統下須要的軟鏈文件或Windows系統下須要的.cmd
文件。
- /usr/local/ # Linux系統下 - lib/node_modules/ + node-echo/ ... - bin/ node-echo ... ... - %APPDATA%\npm\ # Windows系統下 - node_modules\ + node-echo\ ... node-echo.cmd ...
第一次使用NPM發佈代碼前須要註冊一個帳號。終端下運行npm adduser
,以後按照提示作便可。帳號搞定後,接着咱們須要編輯package.json
文件,加入NPM必需的字段。接着上邊node-echo
的例子,package.json
裏必要的字段以下。
{ "name": "node-echo", # 包名,在NPM服務器上需要保持惟一 "version": "1.0.0", # 當前版本號 "dependencies": { # 三方包依賴,須要指定包名和版本號 "argv": "0.0.2" }, "main": "./lib/echo.js", # 入口模塊位置 "bin" : { "node-echo": "./bin/node-echo" # 命令行程序名和主模塊位置 } }
以後,咱們就能夠在package.json
所在目錄下運行npm publish
發佈代碼了。
使用NPM下載和發佈代碼時都會接觸到版本號。NPM使用語義版本號來管理代碼,這裏簡單介紹一下。
語義版本號分爲X.Y.Z
三位,分別表明主版本號、次版本號和補丁版本號。當代碼變動時,版本號按如下原則更新。
+ 若是隻是修復bug,須要更新Z位。 + 若是是新增了功能,可是向下兼容,須要更新Y位。 + 若是有大變更,向下不兼容,須要更新X位。
版本號有了這個保證後,在申明三方包依賴時,除了可依賴於一個固定版本號外,還可依賴於某個範圍的版本號。例如"argv": "0.0.x"
表示依賴於0.0.x
系列的最新版argv
。NPM支持的全部版本號範圍指定方式能夠查看官方文檔。
除了本章介紹的部分外,NPM還提供了不少功能,package.json
裏也有不少其它有用的字段。除了能夠在npmjs.org/doc/查看官方文檔外,這裏再介紹一些NPM經常使用命令。
NPM提供了不少命令,例如install
和publish
,使用npm help
可查看全部命令。
使用npm help <command>
可查看某條命令的詳細幫助,例如npm help install
。
在package.json
所在目錄下使用npm install . -g
可先在本地安裝當前命令行程序,可用於發佈前的本地測試。
使用npm update <package>
能夠把當前目錄下node_modules
子目錄裏邊的對應模塊更新至最新版本。
使用npm update <package> -g
能夠把全局安裝的對應命令行程序更新至最新版。
使用npm cache clear
能夠清空NPM本地緩存,用於對付使用相同版本號發佈新版本代碼的人。
使用npm unpublish <package>@<version>
能夠撤銷發佈本身發佈過的某個版本代碼。
本章介紹了使用NodeJS編寫代碼前須要作的準備工做,總結起來有如下幾點:
編寫代碼前先規劃好目錄結構,才能作到有條不紊。
稍大些的程序能夠將代碼拆分爲多個模塊管理,更大些的程序能夠使用包來組織模塊。
合理使用node_modules
和NODE_PATH
來解耦包的使用方式和物理路徑。
使用NPM加入NodeJS生態圈互通有無。
想到了心儀的包名時請提早在NPM上搶注。
讓前端以爲如獲神器的不是NodeJS能作網絡編程,而是NodeJS可以操做文件。小至文件查找,大至代碼編譯,幾乎沒有一個前端工具不操做文 件。換個角度講,幾乎也只須要一些數據處理邏輯,再加上一些文件操做,就可以編寫出大多數前端工具。本章將介紹與之相關的NodeJS內置模塊。
NodeJS提供了基本的文件操做API,可是像文件拷貝這種高級功能就沒有提供,所以咱們先拿文件拷貝程序練手。與copy
命令相似,咱們的程序須要能接受源文件路徑與目標文件路徑兩個參數。
咱們使用NodeJS內置的fs
模塊簡單實現這個程序以下。
var fs = require('fs'); function copy(src, dst) { fs.writeFileSync(dst, fs.readFileSync(src)); } function main(argv) { copy(argv[0], argv[1]); } main(process.argv.slice(2));
以上程序使用fs.readFileSync
從源路徑讀取文件內容,並使用fs.writeFileSync
將文件內容寫入目標路徑。
豆知識:
process
是一個全局變量,可經過process.argv
得到命令行參數。因爲argv[0]
固定等於NodeJS執行程序的絕對路徑,argv[1]
固定等於主模塊的絕對路徑,所以第一個命令行參數從argv[2]
這個位置開始。
上邊的程序拷貝一些小文件沒啥問題,但這種一次性把全部文件內容都讀取到內存中後再一次性寫入磁盤的方式不適合拷貝大文件,內存會爆倉。對於大文件,咱們只能讀一點寫一點,直到完成拷貝。所以上邊的程序須要改造以下。
var fs = require('fs'); function copy(src, dst) { fs.createReadStream(src).pipe(fs.createWriteStream(dst)); } function main(argv) { copy(argv[0], argv[1]); } main(process.argv.slice(2));
以上程序使用fs.createReadStream
建立了一個源文件的只讀數據流,並使用fs.createWriteStream
建立了一個目標文件的只寫數據流,而且用pipe
方法把兩個數據流鏈接了起來。鏈接起來後發生的事情,說得抽象點的話,水順着水管從一個桶流到了另外一個桶。
咱們先大體看看NodeJS提供了哪些和文件操做有關的API。這裏並不逐一介紹每一個API的使用方法,官方文檔已經作得很好了。
JS語言自身只有字符串數據類型,沒有二進制數據類型,所以NodeJS提供了一個與String
對等的全局構造函數Buffer
來提供對二進制數據的操做。除了能夠讀取文件獲得Buffer
的實例外,還可以直接構造,例如:
var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
Buffer
與字符串相似,除了能夠用.length
屬性獲得字節長度外,還能夠用[index]
方式讀取指定位置的字節,例如:
bin[0]; // => 0x68;
Buffer
與字符串可以互相轉化,例如能夠使用指定編碼將二進制數據轉化爲字符串:
var str = bin.toString('utf-8'); // => "hello"
或者反過來,將字符串轉換爲指定編碼下的二進制數據:
var bin = new Buffer('hello', 'utf-8'); // => <Buffer 68 65 6c 6c 6f>
Buffer
與字符串有一個重要區別。字符串是隻讀的,而且對字符串的任何修改獲得的都是一個新字符串,原字符串保持不變。至於Buffer
,更像是能夠作指針操做的C語言數組。例如,能夠用[index]
方式直接修改某個位置的字節。
bin[0] = 0x48;
而.slice
方法也不是返回一個新的Buffer
,而更像是返回了指向原Buffer
中間的某個位置的指針,以下所示。
[ 0x68, 0x65, 0x6c, 0x6c, 0x6f ] ^ ^ | | bin bin.slice(2)
所以對.slice
方法返回的Buffer
的修改會做用於原Buffer
,例如:
var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]); var sub = bin.slice(2); sub[0] = 0x65; console.log(bin); // => <Buffer 68 65 65 6c 6f>
也所以,若是想要拷貝一份Buffer
,得首先建立一個新的Buffer
,並經過.copy
方法把原Buffer
中的數據複製過去。這個相似於申請一塊新的內存,並把已有內存中的數據複製過去。如下是一個例子。
var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]); var dup = new Buffer(bin.length); bin.copy(dup); dup[0] = 0x48; console.log(bin); // => <Buffer 68 65 6c 6c 6f> console.log(dup); // => <Buffer 48 65 65 6c 6f>
總之,Buffer
將JS的數據處理能力從字符串擴展到了任意二進制數據。
當內存中沒法一次裝下須要處理的數據時,或者一邊讀取一邊處理更加高效時,咱們就須要用到數據流。NodeJS中經過各類Stream
來提供對數據流的操做。
以上邊的大文件拷貝程序爲例,咱們能夠爲數據來源建立一個只讀數據流,示例以下:
var rs = fs.createReadStream(pathname); rs.on('data', function (chunk) { doSomething(chunk); }); rs.on('end', function () { cleanUp(); });
豆知識:
Stream
基於事件機制工做,全部Stream
的實例都繼承於NodeJS提供的EventEmitter。
上邊的代碼中data
事件會源源不斷地被觸發,無論doSomething
函數是否處理得過來。代碼能夠繼續作以下改造,以解決這個問題。
var rs = fs.createReadStream(src); rs.on('data', function (chunk) { rs.pause(); doSomething(chunk, function () { rs.resume(); }); }); rs.on('end', function () { cleanUp(); });
以上代碼給doSomething
函數加上了回調,所以咱們能夠在處理數據前暫停數據讀取,並在處理數據後繼續讀取數據。
此外,咱們也能夠爲數據目標建立一個只寫數據流,示例以下:
var rs = fs.createReadStream(src); var ws = fs.createWriteStream(dst); rs.on('data', function (chunk) { ws.write(chunk); }); rs.on('end', function () { ws.end(); });
咱們把doSomething
換成了往只寫數據流裏寫入數據後,以上代碼看起來就像是一個文件拷貝程序了。可是以上代碼存在上邊提到的問題,若是寫入速度跟不上讀取速度的話,只寫數據流內部的緩存會爆倉。咱們能夠根據.write
方法的返回值來判斷傳入的數據是寫入目標了,仍是臨時放在了緩存了,並根據drain
事件來判斷何時只寫數據流已經將緩存中的數據寫入目標,能夠傳入下一個待寫數據了。所以代碼能夠改造以下:
var rs = fs.createReadStream(src); var ws = fs.createWriteStream(dst); rs.on('data', function (chunk) { if (ws.write(chunk) === false) { rs.pause(); } }); rs.on('end', function () { ws.end(); }); ws.on('drain', function () { rs.resume(); });
以上代碼實現了數據從只讀數據流到只寫數據流的搬運,幷包括了防爆倉控制。由於這種使用場景不少,例如上邊的大文件拷貝程序,NodeJS直接提供了.pipe
方法來作這件事情,其內部實現方式與上邊的代碼相似。
NodeJS經過fs
內置模塊提供對文件的操做。fs
模塊提供的API基本上能夠分爲如下三類:
文件屬性讀寫。
其中經常使用的有fs.stat
、fs.chmod
、fs.chown
等等。
文件內容讀寫。
其中經常使用的有fs.readFile
、fs.readdir
、fs.writeFile
、fs.mkdir
等等。
底層文件操做。
其中經常使用的有fs.open
、fs.read
、fs.write
、fs.close
等等。
NodeJS最精華的異步IO模型在fs
模塊裏有着充分的體現,例如上邊提到的這些API都經過回調函數傳遞結果。以fs.readFile
爲例:
fs.readFile(pathname, function (err, data) { if (err) { // Deal with error. } else { // Deal with data. } });
如上邊代碼所示,基本上全部fs
模塊API的回調參數都有兩個。第一個參數在有錯誤發生時等於異常對象,第二個參數始終用於返回API方法執行結果。
此外,fs
模塊的全部異步API都有對應的同步版本,用於沒法使用異步操做時,或者同步操做更方便時的狀況。同步API除了方法名的末尾多了一個Sync
以外,異常對象與執行結果的傳遞方式也有相應變化。一樣以fs.readFileSync
爲例:
try { var data = fs.readFileSync(pathname); // Deal with data. } catch (err) { // Deal with error. }
fs
模塊提供的API不少,這裏不一一介紹,須要時請自行查閱官方文檔。
操做文件時不免不與文件路徑打交道。NodeJS提供了path
內置模塊來簡化路徑相關操做,並提高代碼可讀性。如下分別介紹幾個經常使用的API。
path.normalize
將傳入的路徑轉換爲標準路徑,具體講的話,除了解析路徑中的.
與..
外,還能去掉多餘的斜槓。若是有程序須要使用路徑做爲某些數據的索引,但又容許用戶隨意輸入路徑時,就須要使用該方法保證路徑的惟一性。如下是一個例子:
var cache = {}; function store(key, value) { cache[path.normalize(key)] = value; } store('foo/bar', 1); store('foo//baz//../bar', 2); console.log(cache); // => { "foo/bar": 2 }
坑出沒注意: 標準化以後的路徑裏的斜槓在Windows系統下是
\
,而在Linux系統下是/
。若是想保證任何系統下都使用/
做爲路徑分隔符的話,須要用.replace(/\\/g, '/')
再替換一下標準路徑。
path.join
將傳入的多個路徑拼接爲標準路徑。該方法可避免手工拼接路徑字符串的繁瑣,而且能在不一樣系統下正確使用相應的路徑分隔符。如下是一個例子:
path.join('foo/', 'baz/', '../bar'); // => "foo/bar"
path.extname
當咱們須要根據不一樣文件擴展名作不一樣操做時,該方法就顯得很好用。如下是一個例子:
path.extname('foo/bar.js'); // => ".js"
path
模塊提供的其他方法也很少,稍微看一下官方文檔就能所有掌握。
遍歷目錄是操做文件時的一個常見需求。好比寫一個程序,須要找到並處理指定目錄下的全部JS文件時,就須要遍歷整個目錄。
遍歷目錄時通常使用遞歸算法,不然就難以編寫出簡潔的代碼。遞歸算法與數學概括法相似,經過不斷縮小問題的規模來解決問題。如下示例說明了這種方法。
function factorial(n) { if (n === 1) { return 1; } else { return n * factorial(n - 1); } }
上邊的函數用於計算N的階乘(N!)。能夠看到,當N大於1時,問題簡化爲計算N乘以N-1的階乘。當N等於1時,問題達到最小規模,不須要再簡化,所以直接返回1。
陷阱: 使用遞歸算法編寫的代碼雖然簡潔,但因爲每遞歸一次就產生一次函數調用,在須要優先考慮性能時,須要把遞歸算法轉換爲循環算法,以減小函數調用次數。
目錄是一個樹狀結構,在遍歷時通常使用深度優先+先序遍歷算法。深度優先,意味着到達一個節點後,首先接着遍歷子節點而不是鄰居節點。先序遍歷,意味着首次到達了某節點就算遍歷完成,而不是最後一次返回某節點纔算數。所以使用這種遍歷方式時,下邊這棵樹的遍歷順序是A > B > D > E > C > F
。
A / \ B C / \ \ D E F
瞭解了必要的算法後,咱們能夠簡單地實現如下目錄遍歷函數。
function travel(dir, callback) { fs.readdirSync(dir).forEach(function (file) { var pathname = path.join(dir, file); if (fs.statSync(pathname).isDirectory()) { travel(pathname, callback); } else { callback(pathname); } }); }
能夠看到,該函數以某個目錄做爲遍歷的起點。遇到一個子目錄時,就先接着遍歷子目錄。遇到一個文件時,就把文件的絕對路徑傳給回調函數。回調函數拿到文件路徑後,就能夠作各類判斷和處理。所以假設有如下目錄:
- /home/user/ - foo/ x.js - bar/ y.js z.css
使用如下代碼遍歷該目錄時,獲得的輸入以下。
travel('/home/user', function (pathname) { console.log(pathname); }); ------------------------ /home/user/foo/x.js /home/user/bar/y.js /home/user/z.css
若是讀取目錄或讀取文件狀態時使用的是異步API,目錄遍歷函數實現起來會有些複雜,但原理徹底相同。travel
函數的異步版本以下。
function travel(dir, callback, finish) { fs.readdir(dir, function (err, files) { (function next(i) { if (i < files.length) { var pathname = path.join(dir, files[i]); fs.stat(pathname, function (err, stats) { if (stats.isDirectory()) { travel(pathname, callback, function () { next(i + 1); }); } else { callback(pathname, function () { next(i + 1); }); } }); } else { finish && finish(); } }(0)); }); }
這裏不詳細介紹異步遍歷函數的編寫技巧,在後續章節中會詳細介紹這個。總之咱們能夠看到異步編程仍是蠻複雜的。
使用NodeJS編寫前端工具時,操做得最多的是文本文件,所以也就涉及到了文件編碼的處理問題。咱們經常使用的文本編碼有UTF8
和GBK
兩種,而且UTF8
文件還可能帶有BOM。在讀取不一樣編碼的文本文件時,須要將文件內容轉換爲JS使用的UTF8
編碼字符串後才能正常處理。
BOM用於標記一個文本文件使用Unicode編碼,其自己是一個Unicode字符("\uFEFF"),位於文本文件頭部。在不一樣的Unicode編碼下,BOM字符對應的二進制字節以下:
Bytes Encoding ---------------------------- FE FF UTF16BE FF FE UTF16LE EF BB BF UTF8
所以,咱們能夠根據文本文件頭幾個字節等於啥來判斷文件是否包含BOM,以及使用哪一種Unicode編碼。可是,BOM字符雖然起到了標記文件編碼 的做用,其自己卻不屬於文件內容的一部分,若是讀取文本文件時不去掉BOM,在某些使用場景下就會有問題。例如咱們把幾個JS文件合併成一個文件後,若是 文件中間含有BOM字符,就會致使瀏覽器JS語法錯誤。所以,使用NodeJS讀取文本文件時,通常須要去掉BOM。例如,如下代碼實現了識別和去除 UTF8 BOM的功能。
function readText(pathname) { var bin = fs.readFileSync(pathname); if (bin[0] === 0xEF && bin[1] === 0xBB && bin[2] === 0xBF) { bin = bin.slice(3); } return bin.toString('utf-8'); }
NodeJS支持在讀取文本文件時,或者在Buffer
轉換爲字符串時指定文本編碼,但遺憾的是,GBK編碼不在NodeJS自身支持範圍內。所以,通常咱們藉助iconv-lite
這個三方包來轉換編碼。使用NPM下載該包後,咱們能夠按下邊方式編寫一個讀取GBK文本文件的函數。
var iconv = require('iconv-lite'); function readGBKText(pathname) { var bin = fs.readFileSync(pathname); return iconv.decode(bin, 'gbk'); }
有時候,咱們沒法預知須要讀取的文件採用哪一種編碼,所以也就沒法指定正確的編碼。好比咱們要處理的某些CSS文件中,有的用GBK編碼,有的用UTF8編碼。雖然能夠必定程度能夠根據文件的字節內容猜想出文本編碼,但這裏要介紹的是有些侷限,可是要簡單得多的一種技術。
首先咱們知道,若是一個文本文件只包含英文字符,好比Hello World
,那不管用GBK編碼或是UTF8編碼讀取這個文件都是沒問題的。這是由於在這些編碼下,ASCII0~128範圍內字符都使用相同的單字節編碼。
反過來說,即便一個文本文件中有中文等字符,若是咱們須要處理的字符僅在ASCII0~128範圍內,好比除了註釋和字符串之外的JS代碼,咱們就能夠統一使用單字節編碼來讀取文件,不用關心文件的實際編碼是GBK仍是UTF8。如下示例說明了這種方法。
1. GBK編碼源文件內容: var foo = '中文'; 2. 對應字節: 76 61 72 20 66 6F 6F 20 3D 20 27 D6 D0 CE C4 27 3B 3. 使用單字節編碼讀取後獲得的內容: var foo = '{亂碼}{亂碼}{亂碼}{亂碼}'; 4. 替換內容: var bar = '{亂碼}{亂碼}{亂碼}{亂碼}'; 5. 使用單字節編碼保存後對應字節: 76 61 72 20 62 61 72 20 3D 20 27 D6 D0 CE C4 27 3B 6. 使用GBK編碼讀取後獲得內容: var bar = '中文';
這裏的訣竅在於,無論大於0xEF的單個字節在單字節編碼下被解析成什麼亂碼字符,使用一樣的單字節編碼保存這些亂碼字符時,背後對應的字節保持不變。
NodeJS中自帶了一種binary
編碼能夠用來實現這個方法,所以在下例中,咱們使用這種編碼來演示上例對應的代碼該怎麼寫。
function replace(pathname) { var str = fs.readFileSync(pathname, 'binary'); str = str.replace('foo', 'bar'); fs.writeFileSync(pathname, str, 'binary'); }
本章介紹了使用NodeJS操做文件時須要的API以及一些技巧,總結起來有如下幾點:
學好文件操做,編寫各類程序都不怕。
若是不是很在乎性能,fs
模塊的同步API能讓生活更加美好。
須要對文件讀寫作到字節級別的精細控制時,請使用fs
模塊的文件底層操做API。
不要使用拼接字符串的方式來處理路徑,使用path
模塊。
掌握好目錄遍歷和文件編碼處理技巧,很實用。
不瞭解網絡編程的程序員不是好前端,而NodeJS剛好提供了一扇瞭解網絡編程的窗口。經過NodeJS,除了能夠編寫一些服務端程序來協助前端開 發和測試外,還可以學習一些HTTP協議與Socket協議的相關知識,這些知識在優化前端性能和排查前端故障時說不定能派上用場。本章將介紹與之相關的 NodeJS內置模塊。
NodeJS原本的用途是編寫高性能Web服務器。咱們首先在這裏重複一下官方文檔裏的例子,使用NodeJS內置的http
模塊簡單實現一個HTTP服務器。
var http = require('http'); http.createServer(function (request, response) { response.writeHead(200, { 'Content-Type': 'text-plain' }); response.end('Hello World\n'); }).listen(8124);
以上程序建立了一個HTTP服務器並監聽8124
端口,打開瀏覽器訪問該端口http://127.0.0.1:8124/
就可以看到效果。
豆知識: 在Linux系統下,監聽1024如下端口須要root權限。所以,若是想監聽80或443端口的話,須要使用
sudo
命令啓動程序。
咱們先大體看看NodeJS提供了哪些和網絡操做有關的API。這裏並不逐一介紹每一個API的使用方法,官方文檔已經作得很好了。
'http'模塊提供兩種使用方式:
做爲服務端使用時,建立一個HTTP服務器,監聽HTTP客戶端請求並返回響應。
做爲客戶端使用時,發起一個HTTP客戶端請求,獲取服務端響應。
首先咱們來看看服務端模式下如何工做。如開門紅中的例子所示,首先須要使用.createServer
方法建立一個服務器,而後調用.listen
方法監聽端口。以後,每當來了一個客戶端請求,建立服務器時傳入的回調函數就被調用一次。能夠看出,這是一種事件機制。
HTTP請求本質上是一個數據流,由請求頭(headers)和請求體(body)組成。例如如下是一個完整的HTTP請求數據內容。
POST / HTTP/1.1 User-Agent: curl/7.26.0 Host: localhost Accept: */* Content-Length: 11 Content-Type: application/x-www-form-urlencoded Hello World
能夠看到,空行之上是請求頭,之下是請求體。HTTP請求在發送給服務器時,能夠認爲是按照從頭至尾的順序一個字節一個字節地以數據流方式發送的。而http
模塊建立的HTTP服務器在接收到完整的請求頭後,就會調用回調函數。在回調函數中,除了能夠使用request
對象訪問請求頭數據外,還能把request
對象看成一個只讀數據流來訪問請求體數據。如下是一個例子。
http.createServer(function (request, response) { var body = []; console.log(request.method); console.log(request.headers); request.on('data', function (chunk) { body.push(chunk); }); request.on('end', function () { body = Buffer.concat(body); console.log(body.toString()); }); }).listen(80); ------------------------------------ POST { 'user-agent': 'curl/7.26.0', host: 'localhost', accept: '*/*', 'content-length': '11', 'content-type': 'application/x-www-form-urlencoded' } Hello World
HTTP響應本質上也是一個數據流,一樣由響應頭(headers)和響應體(body)組成。例如如下是一個完整的HTTP請求數據內容。
HTTP/1.1 200 OK Content-Type: text/plain Content-Length: 11 Date: Tue, 05 Nov 2013 05:31:38 GMT Connection: keep-alive Hello World
在回調函數中,除了能夠使用response
對象來寫入響應頭數據外,還能把response
對象看成一個只寫數據流來寫入響應體數據。例如在如下例子中,服務端原樣將客戶端請求的請求體數據返回給客戶端。
http.createServer(function (request, response) { response.writeHead(200, { 'Content-Type': 'text/plain' }); request.on('data', function (chunk) { response.write(chunk); }); request.on('end', function () { response.end(); }); }).listen(80);
接下來咱們看看客戶端模式下如何工做。爲了發起一個客戶端HTTP請求,咱們須要指定目標服務器的位置併發送請求頭和請求體,如下示例演示了具體作法。
var options = { hostname: 'www.example.com', port: 80, path: '/upload', method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }; var request = http.request(options, function (response) {}); request.write('Hello World'); request.end();
能夠看到,.request
方法建立了一個客戶端,並指定請求目標和請求頭數據。以後,就能夠把request
對象看成一個只寫數據流來寫入請求體數據和結束請求。另外,因爲HTTP請求中GET
請求是最多見的一種,而且不須要請求體,所以http
模塊也提供瞭如下便捷API。
http.get('http://www.example.com/', function (response) {});
當客戶端發送請求並接收到完整的服務端響應頭時,就會調用回調函數。在回調函數中,除了能夠使用response
對象訪問響應頭數據外,還能把response
對象看成一個只讀數據流來訪問響應體數據。如下是一個例子。
http.get('http://www.example.com/', function (response) { var body = []; console.log(response.statusCode); console.log(response.headers); response.on('data', function (chunk) { body.push(chunk); }); response.on('end', function () { body = Buffer.concat(body); console.log(body.toString()); }); }); ------------------------------------ 200 { 'content-type': 'text/html', server: 'Apache', 'content-length': '801', date: 'Tue, 05 Nov 2013 06:08:41 GMT', connection: 'keep-alive' } <!DOCTYPE html> ...
https
模塊與http
模塊極爲相似,區別在於https
模塊須要額外處理SSL證書。
在服務端模式下,建立一個HTTPS服務器的示例以下。
var options = { key: fs.readFileSync('./ssl/default.key'), cert: fs.readFileSync('./ssl/default.cer') }; var server = https.createServer(options, function (request, response) { // ... });
能夠看到,與建立HTTP服務器相比,多了一個options
對象,經過key
和cert
字段指定了HTTPS服務器使用的私鑰和公鑰。
另外,NodeJS支持SNI技術,能夠根據HTTPS客戶端請求使用的域名動態使用不一樣的證書,所以同一個HTTPS服務器能夠使用多個域名提供服務。接着上例,能夠使用如下方法爲HTTPS服務器添加多組證書。
server.addContext('foo.com', { key: fs.readFileSync('./ssl/foo.com.key'), cert: fs.readFileSync('./ssl/foo.com.cer') }); server.addContext('bar.com', { key: fs.readFileSync('./ssl/bar.com.key'), cert: fs.readFileSync('./ssl/bar.com.cer') });
在客戶端模式下,發起一個HTTPS客戶端請求與http
模塊幾乎相同,示例以下。
var options = { hostname: 'www.example.com', port: 443, path: '/', method: 'GET' }; var request = https.request(options, function (response) {}); request.end();
但若是目標服務器使用的SSL證書是自制的,不是從頒發機構購買的,默認狀況下https
模塊會拒絕鏈接,提示說有證書安全問題。在options
里加入rejectUnauthorized: false
字段能夠禁用對證書有效性的檢查,從而容許https
模塊請求開發環境下使用自制證書的HTTPS服務器。
處理HTTP請求時url
模塊使用率超高,由於該模塊容許解析URL、生成URL,以及拼接URL。首先咱們來看看一個完整的URL的各組成部分。
href ----------------------------------------------------------------- host path --------------- ---------------------------- http: // user:pass @ host.com : 8080 /p/a/t/h ?query=string #hash ----- --------- -------- ---- -------- ------------- ----- protocol auth hostname port pathname search hash ------------ query
咱們能夠使用.parse
方法來將一個URL字符串轉換爲URL對象,示例以下。
url.parse('http://user:pass@host.com:8080/p/a/t/h?query=string#hash'); /* => { protocol: 'http:', auth: 'user:pass', host: 'host.com:8080', port: '8080', hostname: 'host.com', hash: '#hash', search: '?query=string', query: 'query=string', pathname: '/p/a/t/h', path: '/p/a/t/h?query=string', href: 'http://user:pass@host.com:8080/p/a/t/h?query=string#hash' } */
傳給.parse
方法的不必定要是一個完整的URL,例如在HTTP服務器回調函數中,request.url
不包含協議頭和域名,但一樣能夠用.parse
方法解析。
http.createServer(function (request, response) { var tmp = request.url; // => "/foo/bar?a=b" url.parse(tmp); /* => { protocol: null, slashes: null, auth: null, host: null, port: null, hostname: null, hash: null, search: '?a=b', query: 'a=b', pathname: '/foo/bar', path: '/foo/bar?a=b', href: '/foo/bar?a=b' } */ }).listen(80);
.parse
方法還支持第二個和第三個布爾類型可選參數。第二個參數等於true
時,該方法返回的URL對象中,query
字段再也不是一個字符串,而是一個通過querystring
模塊轉換後的參數對象。第三個參數等於true
時,該方法能夠正確解析不帶協議頭的URL,例如//www.example.com/foo/bar
。
反過來,format
方法容許將一個URL對象轉換爲URL字符串,示例以下。
url.format({ protocol: 'http:', host: 'www.example.com', pathname: '/p/a/t/h', search: 'query=string' }); /* => 'http://www.example.com/p/a/t/h?query=string' */
另外,.resolve
方法能夠用於拼接URL,示例以下。
url.resolve('http://www.example.com/foo/bar', '../baz'); /* => http://www.example.com/baz */
querystring
模塊用於實現URL參數字符串與參數對象的互相轉換,示例以下。
querystring.parse('foo=bar&baz=qux&baz=quux&corge'); /* => { foo: 'bar', baz: ['qux', 'quux'], corge: '' } */ querystring.stringify({ foo: 'bar', baz: ['qux', 'quux'], corge: '' }); /* => 'foo=bar&baz=qux&baz=quux&corge=' */
zlib
模塊提供了數據壓縮和解壓的功能。當咱們處理HTTP請求和響應時,可能須要用到這個模塊。
首先咱們看一個使用zlib
模塊壓縮HTTP響應體數據的例子。這個例子中,判斷了客戶端是否支持gzip,並在支持的狀況下使用zlib
模塊返回gzip以後的響應體數據。
http.createServer(function (request, response) { var i = 1024, data = ''; while (i--) { data += '.'; } if ((request.headers['accept-encoding'] || '').indexOf('gzip') !== -1) { zlib.gzip(data, function (err, data) { response.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Encoding': 'gzip' }); response.end(data); }); } else { response.writeHead(200, { 'Content-Type': 'text/plain' }); response.end(data); } }).listen(80);
接着咱們看一個使用zlib
模塊解壓HTTP響應體數據的例子。這個例子中,判斷了服務端響應是否使用gzip壓縮,並在壓縮的狀況下使用zlib
模塊解壓響應體數據。
var options = { hostname: 'www.example.com', port: 80, path: '/', method: 'GET', headers: { 'Accept-Encoding': 'gzip, deflate' } }; http.request(options, function (response) { var body = []; response.on('data', function (chunk) { body.push(chunk); }); response.on('end', function () { body = Buffer.concat(body); if (response.headers['content-encoding'] === 'gzip') { zlib.gunzip(body, function (err, data) { console.log(data.toString()); }); } else { console.log(data.toString()); } }); }).end();
net
模塊可用於建立Socket服務器或Socket客戶端。因爲Socket在前端領域的使用範圍還不是很廣,這裏先不涉及到WebSocket的介紹,僅僅簡單演示一下如何從Socket層面來實現HTTP請求和響應。
首先咱們來看一個使用Socket搭建一個很不嚴謹的HTTP服務器的例子。這個HTTP服務器無論收到啥請求,都固定返回相同的響應。
net.createServer(function (conn) { conn.on('data', function (data) { conn.write([ 'HTTP/1.1 200 OK', 'Content-Type: text/plain', 'Content-Length: 11', '', 'Hello World' ].join('\n')); }); }).listen(80);
接着咱們來看一個使用Socket發起HTTP客戶端請求的例子。這個例子中,Socket客戶端在創建鏈接後發送了一個HTTP GET請求,並經過data
事件監聽函數來獲取服務器響應。
var options = { port: 80, host: 'www.example.com' }; var client = net.connect(options, function () { client.write([ 'GET / HTTP/1.1', 'User-Agent: curl/7.26.0', 'Host: www.baidu.com', 'Accept: */*', '', '' ].join('\n')); }); client.on('data', function (data) { console.log(data.toString()); client.end(); });
使用NodeJS操做網絡,特別是操做HTTP請求和響應時會遇到一些驚喜,這裏對一些常見問題作解答。
問: 爲何經過headers
對象訪問到的HTTP請求頭或響應頭字段不是駝峯的?
答: 從規範上講,HTTP請求頭和響應頭字段都應該是駝峯的。但現實是殘酷的,不是每一個HTTP服務端或客戶端程序都嚴格遵循規範,因此NodeJS在處理從 別的客戶端或服務端收到的頭字段時,都統一地轉換爲了小寫字母格式,以便開發者能使用統一的方式來訪問頭字段,例如headers['content-length']
。
問: 爲何http
模塊建立的HTTP服務器返回的響應是chunked
傳輸方式的?
答: 由於默認狀況下,使用.writeHead
方法寫入響應頭後,容許使用.write
方法寫入任意長度的響應體數據,並使用.end
方法結束一個響應。因爲響應體數據長度不肯定,所以NodeJS自動在響應頭裏添加了Transfer-Encoding: chunked
字段,並採用chunked
傳輸方式。可是當響應體數據長度肯定時,可以使用.writeHead
方法在響應頭裏加上Content-Length
字段,這樣作以後NodeJS就不會自動添加Transfer-Encoding
字段和使用chunked
傳輸方式。
問: 爲何使用http
模塊發起HTTP客戶端請求時,有時候會發生socket hang up
錯誤?
答: 發起客戶端HTTP請求前須要先建立一個客戶端。http
模塊提供了一個全局客戶端http.globalAgent
,可讓咱們使用.request
或.get
方法時不用手動建立客戶端。可是全局客戶端默認只容許5個併發Socket鏈接,當某一個時刻HTTP客戶端請求建立過多,超過這個數字時,就會發生socket hang up
錯誤。解決方法也很簡單,經過http.globalAgent.maxSockets
屬性把這個數字改大些便可。另外,https
模塊遇到這個問題時也同樣經過https.globalAgent.maxSockets
屬性來處理。
本章介紹了使用NodeJS操做網絡時須要的API以及一些坑迴避技巧,總結起來有如下幾點:
http
和https
模塊支持服務端模式和客戶端模式兩種使用方式。
request
和response
對象除了用於讀寫頭數據外,均可以看成數據流來操做。
url.parse
方法加上request.url
屬性是處理HTTP請求時的固定搭配。
使用zlib
模塊能夠減小使用HTTP協議時的數據傳輸量。
經過net
模塊的Socket服務器與客戶端可對HTTP協議作底層操做。
當心踩坑。
NodeJS能夠感知和控制自身進程的運行環境和狀態,也能夠建立子進程並與其協同工做,這使得NodeJS能夠把多個程序組合在一塊兒共同完成某項工做,並在其中充當膠水和調度器的做用。本章除了介紹與之相關的NodeJS內置模塊外,還會重點介紹典型的使用場景。
咱們已經知道了NodeJS自帶的fs
模塊比較基礎,把一個目錄裏的全部文件和子目錄都拷貝到另外一個目錄裏須要寫很多代碼。另外咱們也知道,終端下的cp
命令比較好用,一條cp -r source/* target
命令就能搞定目錄拷貝。那咱們首先看看如何使用NodeJS調用終端命令來簡化目錄拷貝,示例代碼以下:
var child_process = require('child_process'); var util = require('util'); function copy(source, target, callback) { child_process.exec( util.format('cp -r %s/* %s', source, target), callback); } copy('a', 'b', function (err) { // ... });
從以上代碼中能夠看到,子進程是異步運行的,經過回調函數返回執行結果。
咱們先大體看看NodeJS提供了哪些和進程管理有關的API。這裏並不逐一介紹每一個API的使用方法,官方文檔已經作得很好了。
任何一個進程都有啓動進程時使用的命令行參數,有標準輸入標準輸出,有運行權限,有運行環境和運行狀態。在NodeJS中,能夠經過process
對象感知和控制NodeJS自身進程的方方面面。另外須要注意的是,process
不是內置模塊,而是一個全局對象,所以在任何地方均可以直接使用。
使用child_process
模塊能夠建立和控制子進程。該模塊提供的API中最核心的是.spawn
,其他API都是針對特定使用場景對它的進一步封裝,算是一種語法糖。
cluster
模塊是對child_process
模塊的進一步封裝,專用於解決單進程NodeJS Web服務器沒法充分利用多核CPU的問題。使用該模塊能夠簡化多進程服務器程序的開發,讓每一個核上運行一個工做進程,並統一經過主進程監聽端口和分發請求。
和進程管理相關的API單獨介紹起來比較枯燥,所以這裏從一些典型的應用場景出發,分別介紹一些重要API的使用方法。
在NodeJS中能夠經過process.argv
獲取命令行參數。可是比較意外的是,node
執行程序路徑和主模塊文件路徑固定佔據了argv[0]
和argv[1]
兩個位置,而第一個命令行參數從argv[2]
開始。爲了讓argv
使用起來更加天然,能夠按照如下方式處理。
function main(argv) { // ... } main(process.argv.slice(2));
一般一個程序作完全部事情後就正常退出了,這時程序的退出狀態碼爲0
。或者一個程序運行時發生了異常後就掛了,這時程序的退出狀態碼不等於0
。若是咱們在代碼中捕獲了某個異常,可是以爲程序不該該繼續運行下去,須要當即退出,而且須要把退出狀態碼設置爲指定數字,好比1
,就能夠按照如下方式:
try { // ... } catch (err) { // ... process.exit(1); }
NodeJS程序的標準輸入流(stdin)、一個標準輸出流(stdout)、一個標準錯誤流(stderr)分別對應process.stdin
、process.stdout
和process.stderr
,第一個是隻讀數據流,後邊兩個是隻寫數據流,對它們的操做按照對數據流的操做方式便可。例如,console.log
能夠按照如下方式實現。
function log() { process.stdout.write( util.format.apply(util, arguments) + '\n'); }
在Linux系統下,咱們知道須要使用root權限才能監聽1024如下端口。可是一旦完成端口監聽後,繼續讓程序運行在root權限下存在安全隱患,所以最好能把權限降下來。如下是這樣一個例子。
http.createServer(callback).listen(80, function () { var env = process.env, uid = parseInt(env['SUDO_UID'] || process.getuid(), 10), gid = parseInt(env['SUDO_GID'] || process.getgid(), 10); process.setgid(gid); process.setuid(uid); });
上例中有幾點須要注意:
若是是經過sudo
獲取root權限的,運行程序的用戶的UID和GID保存在環境變量SUDO_UID
和SUDO_GID
裏邊。若是是經過chmod +s
方式獲取root權限的,運行程序的用戶的UID和GID可直接經過process.getuid
和process.getgid
方法獲取。
process.setuid
和process.setgid
方法只接受number
類型的參數。
降權時必須先降GID再降UID,不然順序反過來的話就沒權限更改程序的GID了。
如下是一個建立NodeJS子進程的例子。
var child = child_process.spawn('node', [ 'xxx.js' ]); child.stdout.on('data', function (data) { console.log('stdout: ' + data); }); child.stderr.on('data', function (data) { console.log('stderr: ' + data); }); child.on('close', function (code) { console.log('child process exited with code ' + code); });
上例中使用了.spawn(exec, args, options)
方法,該方法支持三個參數。第一個參數是執行文件路徑,能夠是執行文件的相對或絕對路徑,也能夠是根據PATH環境變量能找到的執行文件名。第二個參數中,數組中的每一個成員都按順序對應一個命令行參數。第三個參數可選,用於配置子進程的執行環境與行爲。
另外,上例中雖然經過子進程對象的.stdout
和.stderr
訪問子進程的輸出,但經過options.stdio
字段的不一樣配置,能夠將子進程的輸入輸出重定向到任何數據流上,或者讓子進程共享父進程的標準輸入輸出流,或者直接忽略子進程的輸入輸出。
在Linux系統下,進程之間能夠經過信號互相通訊。如下是一個例子。
/* parent.js */ var child = child_process.spawn('node', [ 'child.js' ]); child.kill('SIGTERM'); /* child.js */ process.on('SIGTERM', function () { cleanUp(); process.exit(0); });
在上例中,父進程經過.kill
方法向子進程發送SIGTERM
信號,子進程監聽process
對象的SIGTERM
事件響應信號。不要被.kill
方法的名稱迷惑了,該方法本質上是用來給進程發送信號的,進程收到信號後具體要作啥,徹底取決於信號的種類和進程自身的代碼。
另外,若是父子進程都是NodeJS進程,就能夠經過IPC(進程間通信)雙向傳遞數據。如下是一個例子。
/* parent.js */ var child = child_process.spawn('node', [ 'child.js' ], { stdio: [ 0, 1, 2, 'ipc' ] }); child.on('message', function (msg) { console.log(msg); }); child.send({ hello: 'hello' }); /* child.js */ process.on('message', function (msg) { msg.hello = msg.hello.toUpperCase(); process.send(msg); });
能夠看到,父進程在建立子進程時,在options.stdio
字段中經過ipc
開啓了一條IPC通道,以後就能夠監聽子進程對象的message
事件接收來自子進程的消息,並經過.send
方法給子進程發送消息。在子進程這邊,能夠在process
對象上監聽message
事件接收來自父進程的消息,並經過.send
方法向父進程發送消息。數據在傳遞過程當中,會先在發送端使用JSON.stringify
方法序列化,再在接收端使用JSON.parse
方法反序列化。
守護進程通常用於監控工做進程的運行狀態,在工做進程不正常退出時重啓工做進程,保障工做進程不間斷運行。如下是一種實現方式。
/* daemon.js */ function spawn(mainModule) { var worker = child_process.spawn('node', [ mainModule ]); worker.on('exit', function (code) { if (code !== 0) { spawn(mainModule); } }); } spawn('worker.js');
能夠看到,工做進程非正常退出時,守護進程當即重啓工做進程。
本章介紹了使用NodeJS管理進程時須要的API以及主要的應用場景,總結起來有如下幾點:
使用process
對象管理自身。
使用child_process
模塊建立和管理子進程。
NodeJS最大的賣點——事件機制和異步IO,對開發者並非透明的。開發者須要按異步方式編寫代碼才用得上這個賣點,而這一點也遭到了一些 NodeJS反對者的抨擊。但無論怎樣,異步編程確實是NodeJS最大的特色,沒有掌握異步編程就不能說是真正學會了NodeJS。本章將介紹與異步編 程相關的各類知識。
在代碼中,異步編程的直接體現就是回調。異步編程依託於回調來實現,但不能說使用了回調後程序就異步化了。咱們首先能夠看看如下代碼。
function heavyCompute(n, callback) { var count = 0, i, j; for (i = n; i > 0; --i) { for (j = n; j > 0; --j) { count += 1; } } callback(count); } heavyCompute(10000, function (count) { console.log(count); }); console.log('hello'); -- Console ------------------------------ 100000000 hello
能夠看到,以上代碼中的回調函數仍然先於後續代碼執行。JS自己是單線程運行的,不可能在一段代碼還未結束運行時去運行別的代碼,所以也就不存在異步執行的概念。
可是,若是某個函數作的事情是建立一個別的線程或進程,並與JS主線程並行地作一些事情,並在事情作完後通知JS主線程,那狀況又不同了。咱們接着看看如下代碼。
setTimeout(function () { console.log('world'); }, 1000); console.log('hello'); -- Console ------------------------------ hello world
此次能夠看到,回調函數後於後續代碼執行了。如同上邊所說,JS自己是單線程的,沒法異步執行,所以咱們能夠認爲setTimeout
這類JS規範以外的由運行環境提供的特殊函數作的事情是建立一個平行線程後當即返回,讓JS主進程能夠接着執行後續代碼,並在收到平行進程的通知後再執行回調函數。除了setTimeout
、setInterval
這些常見的,這類函數還包括NodeJS提供的諸如fs.readFile
之類的異步API。
另外,咱們仍然回到JS是單線程運行的這個事實上,這決定了JS在執行完一段代碼以前沒法執行包括回調函數在內的別的代碼。也就是說,即便平行線程完成工做了,通知JS主線程執行回調函數了,回調函數也要等到JS主線程空閒時才能開始執行。如下就是這麼一個例子。
function heavyCompute(n) { var count = 0, i, j; for (i = n; i > 0; --i) { for (j = n; j > 0; --j) { count += 1; } } } var t = new Date(); setTimeout(function () { console.log(new Date() - t); }, 1000); heavyCompute(50000); -- Console ------------------------------ 8520
能夠看到,原本應該在1秒後被調用的回調函數由於JS主線程忙於運行其它代碼,實際執行時間被大幅延遲。
異步編程有不少特有的代碼設計模式,爲了實現一樣的功能,使用同步方式和異步方式編寫的代碼會有很大差別。如下分別介紹一些常見的模式。
使用一個函數的輸出做爲另外一個函數的輸入是很常見的需求,在同步方式下通常按如下方式編寫代碼:
var output = fn1(fn2('input')); // Do something.
而在異步方式下,因爲函數執行結果不是經過返回值,而是經過回調函數傳遞,所以通常按如下方式編寫代碼:
fn2('input', function (output2) { fn1(output2, function (output1) { // Do something. }); });
能夠看到,這種方式就是一個回調函數套一個回調函多,套得太多了很容易寫出>
形狀的代碼。
在遍歷數組時,使用某個函數依次對數據成員作一些處理也是常見的需求。若是函數是同步執行的,通常就會寫出如下代碼:
var len = arr.length, i = 0; for (; i < len; ++i) { arr[i] = sync(arr[i]); } // All array items have processed.
若是函數是異步執行的,以上代碼就沒法保證循環結束後全部數組成員都處理完畢了。若是數組成員必須一個接一個串行處理,則通常按照如下方式編寫異步代碼:
(function next(i, len, callback) { if (i < len) { async(arr[i], function (value) { arr[i] = value; next(i + 1, len, callback); }); } else { callback(); } }(0, arr.length, function () { // All array items have processed. }));
能夠看到,以上代碼在異步函數執行一次並返回執行結果後才傳入下一個數組成員並開始下一輪執行,直到全部數組成員處理完畢後,經過回調的方式觸發後續代碼的執行。
若是數組成員能夠並行處理,但後續代碼仍然須要全部數組成員處理完畢後才能執行的話,則異步代碼會調整成如下形式:
(function (i, len, count, callback) { for (; i < len; ++i) { (function (i) { async(arr[i], function (value) { arr[i] = value; if (++count === len) { callback(); } }); }(i)); } }(0, arr.length, 0, function () { // All array items have processed. }));
能夠看到,與異步串行遍歷的版本相比,以上代碼並行處理全部數組成員,並經過計數器變量來判斷何時全部數組成員都處理完畢了。
JS自身提供的異常捕獲和處理機制——try..catch..
,只能用於同步執行的代碼。如下是一個例子。
function sync(fn) { return fn(); } try { sync(null); // Do something. } catch (err) { console.log('Error: %s', err.message); } -- Console ------------------------------ Error: object is not a function
能夠看到,異常會沿着代碼執行路徑一直冒泡,直到遇到第一個try
語句時被捕獲住。但因爲異步函數會打斷代碼執行路徑,異步函數執行過程當中以及執行以後產生的異常冒泡到執行路徑被打斷的位置時,若是一直沒有遇到try
語句,就做爲一個全局異常拋出。如下是一個例子。
function async(fn, callback) { // Code execution path breaks here. setTimeout(function () { callback(fn()); }, 0); } try { async(null, function (data) { // Do something. }); } catch (err) { console.log('Error: %s', err.message); } -- Console ------------------------------ /home/user/test.js:4 callback(fn()); ^ TypeError: object is not a function at null._onTimeout (/home/user/test.js:4:13) at Timer.listOnTimeout [as ontimeout] (timers.js:110:15)
由於代碼執行路徑被打斷了,咱們就須要在異常冒泡到斷點以前用try
語句把異常捕獲住,並經過回調函數傳遞被捕獲的異常。因而咱們能夠像下邊這樣改造上邊的例子。
function async(fn, callback) { // Code execution path breaks here. setTimeout(function () { try { callback(null, fn()); } catch (err) { callback(err); } }, 0); } async(null, function (err, data) { if (err) { console.log('Error: %s', err.message); } else { // Do something. } }); -- Console ------------------------------ Error: object is not a function
能夠看到,異常再次被捕獲住了。在NodeJS中,幾乎全部異步API都按照以上方式設計,回調函數中第一個參數都是err
。所以咱們在編寫本身的異步函數時,也能夠按照這種方式來處理異常,與NodeJS的設計風格保持一致。
有了異常處理方式後,咱們接着能夠想想通常咱們是怎麼寫代碼的。基本上,咱們的代碼都是作一些事情,而後調用一個函數,而後再作一些事情,而後再調用一個函數,如此循環。若是咱們寫的是同步代碼,只須要在代碼入口點寫一個try
語句就能捕獲全部冒泡上來的異常,示例以下。
function main() { // Do something. syncA(); // Do something. syncB(); // Do something. syncC(); } try { main(); } catch (err) { // Deal with exception. }
可是,若是咱們寫的是異步代碼,就只有呵呵了。因爲每次異步函數調用都會打斷代碼執行路徑,只能經過回調函數來傳遞異常,因而咱們就須要在每一個回調函數裏判斷是否有異常發生,因而只用三次異步函數調用,就會產生下邊這種代碼。
function main(callback) { // Do something. asyncA(function (err, data) { if (err) { callback(err); } else { // Do something asyncB(function (err, data) { if (err) { callback(err); } else { // Do something asyncC(function (err, data) { if (err) { callback(err); } else { // Do something callback(null); } }); } }); } }); } main(function (err) { if (err) { // Deal with exception. } });
能夠看到,回調函數已經讓代碼變得複雜了,而異步方式下對異常的處理更加重了代碼的複雜度。若是NodeJS的最大賣點最後變成這個樣子,那就沒人願意用NodeJS了,所以接下來會介紹NodeJS提供的一些解決方案。
NodeJS提供了domain
模塊,能夠簡化異步代碼的異常處理。在介紹該模塊以前,咱們須要首先理解「域」的概念。簡單的講,一個域就是一個JS運行環境,在一個運行環境中,若是一個異常沒有被捕獲,將做爲一個全局異常被拋出。NodeJS經過process
對象提供了捕獲全局異常的方法,示例代碼以下
process.on('uncaughtException', function (err) { console.log('Error: %s', err.message); }); setTimeout(function (fn) { fn(); }); -- Console ------------------------------ Error: undefined is not a function
雖然全局異常有個地方能夠捕獲了,可是對於大多數異常,咱們但願儘早捕獲,並根據結果決定代碼的執行路徑。咱們用如下HTTP服務器代碼做爲例子:
function async(request, callback) { // Do something. asyncA(request, function (err, data) { if (err) { callback(err); } else { // Do something asyncB(request, function (err, data) { if (err) { callback(err); } else { // Do something asyncC(request, function (err, data) { if (err) { callback(err); } else { // Do something callback(null, data); } }); } }); } }); } http.createServer(function (request, response) { async(request, function (err, data) { if (err) { response.writeHead(500); response.end(); } else { response.writeHead(200); response.end(data); } }); });
以上代碼將請求對象交給異步函數處理後,再根據處理結果返回響應。這裏採用了使用回調函數傳遞異常的方案,所以async
函數內部若是再多幾個異步函數調用的話,代碼就變成上邊這副鬼樣子了。爲了讓代碼好看點,咱們能夠在每處理一個請求時,使用domain
模塊建立一個子域(JS子運行環境)。在子域內運行的代碼能夠隨意拋出異常,而這些異常能夠經過子域對象的error
事件統一捕獲。因而以上代碼能夠作以下改造:
function async(request, callback) { // Do something. asyncA(request, function (data) { // Do something asyncB(request, function (data) { // Do something asyncC(request, function (data) { // Do something callback(data); }); }); }); } http.createServer(function (request, response) { var d = domain.create(); d.on('error', function () { response.writeHead(500); response.end(); }); d.run(function () { async(request, function (data) { response.writeHead(200); response.end(data); }); }); });
能夠看到,咱們使用.create
方法建立了一個子域對象,並經過.run
方法進入須要在子域中運行的代碼的入口點。而位於子域中的異步函數回調函數因爲再也不須要捕獲異常,代碼一會兒瘦身不少。
不管是經過process
對象的uncaughtException
事件捕獲到全局異常,仍是經過子域對象的error
事件捕獲到了子域異常,在NodeJS官方文檔裏都強烈建議處理完異常後當即重啓程序,而不是讓程序繼續運行。按照官方文檔的說法,發生異常後的程序處於一個不肯定的運行狀態,若是不當即退出的話,程序可能會發生嚴重內存泄漏,也可能表現得很奇怪。
但這裏須要澄清一些事實。JS自己的throw..try..catch
異常處理機制並不會致使內存泄漏,也不會讓程序 的執行結果出乎意料,但NodeJS並非存粹的JS。NodeJS裏大量的API內部是用C/C++實現的,所以NodeJS程序的運行過程當中,代碼執 行路徑穿梭於JS引擎內部和外部,而JS的異常拋出機制可能會打斷正常的代碼執行流程,致使C/C++部分的代碼表現異常,進而致使內存泄漏等問題。
所以,使用uncaughtException
或domain
捕獲異常,代碼執行路徑裏涉及到了C/C++部分的代碼時,若是不能肯定是否會致使內存泄漏等問題,最好在處理完異常後重啓程序比較穩當。而使用try
語句捕獲異常時通常捕獲到的都是JS自己的異常,不用擔憂上訴問題。
本章介紹了JS異步編程相關的知識,總結起來有如下幾點:
不掌握異步編程就不算學會NodeJS。
異步編程依託於回調來實現,而使用回調不必定就是異步編程。
異步編程下的函數間數據傳遞、數組遍歷和異常處理與同步編程有很大差異。
使用domain
模塊簡化異步代碼的異常處理,並當心陷阱。
學習講究的是學以至用和融會貫通。至此咱們已經分別介紹了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有一個龐大的生態圈,在寫代碼以前先看看有沒有現成的三方包能節省很多時間。
不要迷信三方包。任何事情作過頭了就很差了,三方包也是同樣。三方包是一個黑盒,每多使用一個三方包,就爲程序增長了一份潛在風險。而且三方包很難剛好只提供程序須要的功能,每多使用一個三方包,就讓程序更加臃腫一些。所以在決定使用某個三方包以前,最好三思然後行。
咱們是阿里巴巴國際站前端技術部。
咱們喜好NodeJS,用NodeJS作了很多好東西,並寫下了這篇文章。
咱們在杭州市濱江園區,俗稱大內,相比西溪園區:
這吃飯不排隊,停車不收費,通勤不累。
若是你也願意加入咱們,趁着還有一些股票能夠發,趕忙發送簡歷至nanqiao.dengnq@alibaba-inc.com,經過後咱們會盡快與你聯繫,謝謝。
高級前端開發工程師。
工資優異:優秀的公司贏利能力,保障你的付出有回報,工資及年終獎確定讓你滿意。
福利豐厚:健全的福利體系,全額公積金、醫療保險、股票期權獎勵、iHome無息貸款等。
升職空間大:有完善的晉升體系,P1-P14,只要你敢想敢作,晉升空間永遠放在那兒。
學習空間大:大牛多,技術氛圍濃,技術上能夠獲得很大提高。
內部轉崗:阿里系下BU多,不一樣職位,不一樣BU,只要願意,隨時能夠換個發展環境,公司很是鼓勵內部轉崗。
負責網站應用前端開發,與後臺工程師協做,完成數據交互、動態信息展示。
編寫封裝良好的前端交互組件,維護及優化網站前端頁面性能。
研究和探索創新的開發思路和最新的前端技術。
參與新人培訓和前端技術佈道。
精通Web前端技術,包括HTML/CSS/JavaScript等,可以解決各類瀏覽器兼容性問題。
持續關注業界的新話題和新技術,研究過JQuery、Ext、YUI等框架中的一種或以上。
重視團隊協做,願意探尋和改進目前還不成熟的前端開發流程,瞭解各類經常使用工具。
熱愛前端技術,個性樂觀開朗,邏輯性強,善於和各類背景的人溝通合做。
如具有其它語言的開發經驗,好比Java或PHP或Actionscript3/Flex會更受歡迎。