七天學會NodeJS
NodeJS基礎
什么是NodeJS
JS是腳本語言,腳本語言都需要一個解析器才能運行。對于寫在HTML頁面里的JS,瀏覽器充當了解析器的角色。而對于需要獨立運行的JS,NodeJS就是一個解析器。
每一種解析器都是一個運行環境,不但允許JS定義各種數據結構,進行各種計算,還允許JS使用運行環境提供的內置對象和方法做一些事情。例如運行在瀏覽器中的JS的用途是操作DOM,瀏覽器就提供了document
之類的內置對象。而運行在NodeJS中的JS的用途是操作磁盤文件或搭建HTTP服務器,NodeJS就相應提供了fs
、http
等內置對象。
有啥用處
盡管存在一聽說可以直接運行JS文件就覺得很酷的同學,但大多數同學在接觸新東西時首先關心的是有啥用處,以及能帶來啥價值。
NodeJS的作者說,他創造NodeJS的目的是為了實現高性能Web服務器,他首先看重的是事件機制和異步IO模型的優越性,而不是JS。但是他需要選擇一種編程語言實現他的想法,這種編程語言不能自帶IO功能,并且需要能良好支持事件機制。JS沒有自帶IO功能,天生就用于處理瀏覽器中的 DOM事件,并且擁有一大群程序員,因此就成為了天然的選擇。
如他所愿,NodeJS在服務端活躍起來,出現了大批基于NodeJS的Web服務。而另一方面,NodeJS讓前端眾如獲神器,終于可以讓自己的能力覆蓋范圍跳出瀏覽器窗口,更大批的前端工具如雨后春筍。
因此,對于前端而言,雖然不是人人都要拿NodeJS寫一個服務器程序,但簡單可至使用命令交互模式調試JS代碼片段,復雜可至編寫工具提升工作效率。
NodeJS生態圈正欣欣向榮。
如何安裝
安裝程序
NodeJS提供了一些安裝程序,都可以在nodejs.org這里下載并安裝。
Windows系統下,選擇和系統版本匹配的.msi
后綴的安裝文件。Mac OS X系統下,選擇.pkg
后綴的安裝文件。
編譯安裝
Linux系統下沒有現成的安裝程序可用,雖然一些發行版可以使用apt-get
之類的方式安裝,但不一定能安裝到最新版。因此Linux系統下一般使用以下方式編譯方式安裝NodeJS。
-
確保系統下g++版本在4.6以上,python版本在2.6以上。
</li> -
從nodejs.org下載
</li>tar.gz
后綴的NodeJS最新版源代碼包并解壓到某個位置。 -
進入解壓到的目錄,使用以下命令編譯和安裝。
$ ./configure $ make $ sudo make install
</li> </ol>如何運行
打開終端,鍵入
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
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中保存的是同一個模塊的導出對象。</pre>
另外,可以使用以下方式加載和使用一個JSON文件,模塊名中
.json
擴展名不可省略。var data = require('./data.json');
exports
exports
對象是當前模塊的導出對象,用于導出模塊公有方法和屬性。別的模塊通過require
函數使用當前模塊時得到的就是當前模塊的exports
對象。以下例子中導出了一個公有方法。exports.hello = function () { console.log('Hello World!'); };
module
通過
module
對象可以訪問到當前模塊的一些相關信息,但最多的用途是替換當前模塊的導出對象。例如模塊導出對象默認是一個普通對象,如果想改成一個函數的話,可以使用以下方式。module.exports = function () { console.log('Hello World!'); };
以上代碼中,模塊默認導出對象被替換為一個函數。
模塊初始化
一個模塊中的JS代碼僅在模塊第一次被使用時執行一次,并在執行過程中初始化模塊的導出對象。之后,緩存起來的導出對象被重復利用。
主模塊
通過命令行參數傳遞給NodeJS以啟動程序的模塊被稱為主模塊。主模塊負責調度組成整個程序的其它模塊完成工作。例如通過以下命令啟動程序時,
main.js
就是主模塊。$ node main.js
完整示例
例如有以下目錄。
- /home/user/hello/
- util/ counter.js main.js</pre> <p>其中<code>counter.js</code>內容如下:</p>
var i = 0;
function count() { return ++i; }
exports.count = count;</pre>
該模塊內部定義了一個私有變量
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());</pre>
運行該程序的結果如下:
$ node main.js 1 2 3
可以看到,
counter.js
并沒有因為被require了兩次而初始化兩次。二進制模塊
雖然一般我們使用JS編寫模塊,但NodeJS也支持使用C/C++編寫二進制模塊。編譯好的二進制模塊除了文件擴展名是
.node
外,和JS模塊的使用方式相同。雖然二進制模塊能使用操作系統提供的所有功能,擁有無限的潛能,但對于前端同學而言編寫過于困難,并且難以跨平臺使用,因此不在本教程的覆蓋范圍內。小結
本章介紹了有關NodeJS的基本概念和使用方法,總結起來有以下知識點:
-
NodeJS是一個JS腳本解析器,任何操作系統下安裝NodeJS本質上做的事情都是把NodeJS執行程序復制到一個目錄,然后保證這個目錄在系統PATH環境變量下,以便終端下可以使用
</li>node
命令。 -
終端下直接輸入
</li>node
命令可進入命令交互模式,很適合用來測試一些JS代碼片段,比如正則表達式。 -
NodeJS使用CMD模塊系統,主模塊作為程序入口點,所有模塊在執行過程中只初始化一次。
</li> -
除非JS模塊不能滿足需求,否則不要輕易使用二進制模塊,否則你的用戶會叫苦連天。
</li> </ul>代碼的組織和部署
有經驗的C程序員在編寫一個新程序時首先從make文件寫起。同樣的,使用NodeJS編寫程序前,為了有個良好的開端,首先需要準備好代碼的目錄結構和部署方式,就如同修房子要先搭腳手架。本章將介紹與之相關的各種知識。
模塊路徑解析規則
我們已經知道,
require
函數支持斜杠(/
)或盤符(C:
)開頭的絕對路徑,也支持./
開頭的相對路徑。但這兩種路徑在模塊之間建立了強耦合關系,一旦某個模塊文件的存放位置需要變更,使用該模塊的其它模塊的代碼也需要跟著調整,變得牽一發動全身。因此,require
函數支持第三種形式的路徑,寫法類似于foo/bar
,并依次按照以下規則解析路徑,直到找到模塊位置。-
內置模塊
如果傳遞給
</li>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
</li> -
NODE_PATH環境變量
與PATH環境變量類似,NodeJS允許通過NODE_PATH環境變量來指定額外的模塊搜索路徑。NODE_PATH環境變量中包含一到多個目錄路徑,路徑之間在*nix下使用
:
分隔,在Windows下使用;
分隔。例如定義了以下NODE_PATH環境變量:NODE_PATH=/home/user/lib:/home/lib
當使用
require('foo/bar')
的方式加載模塊時,則NodeJS依次嘗試以下路徑。/home/user/lib/foo/bar /home/lib/foo/bar
</li> </ol>包(package)
我們已經知道了JS模塊的基本單位是單個JS文件,但復雜些的模塊往往由多個子模塊組成。為了便于管理和使用,我們可以把由多個子模塊組成的大模塊稱做
包
,并把所有子模塊放在同一個目錄里。在組成一個包的所有子模塊中,需要有一個入口模塊,入口模塊的導出對象被作為包的導出對象。例如有以下目錄結構。
- /home/user/lib/
- cat/ head.js body.js main.js</pre> <p>其中<code>cat</code>目錄定義了一個包,其中包含了3個子模塊。<code>main.js</code>作為入口模塊,其內容如下:</p>
var head = require('./head'); var body = require('./body');
exports.create = function (name) { return { name: name, head: head.create(), body: body.create() }; };</pre>
在其它模塊里使用包的時候,需要加載包的入口模塊。接著上例,使用
require('/home/user/lib/cat/main')
能達到目的,但是入口模塊名稱出現在路徑里看上去不是個好主意。因此我們需要做點額外的工作,讓包使用起來更像是單個模塊。index.js
當模塊的文件名是
index.js
,加載模塊時可以使用模塊所在目錄的路徑代替模塊文件路徑,因此接著上例,以下兩條語句等價。var cat = require('/home/user/lib/cat'); var cat = require('/home/user/lib/cat/index');
這樣處理后,就只需要把包目錄路徑傳遞給
require
函數,感覺上整個目錄被當作單個模塊使用,更有整體感。package.json
如果想自定義入口模塊的文件名和存放位置,就需要在包目錄下包含一個
package.json
文件,并在其中指定入口模塊的路徑。上例中的cat
模塊可以重構如下。- /home/user/lib/
- cat/ + doc/ - lib/ head.js body.js main.js + tests/ package.json</pre> <p>其中<code>package.json</code>內容如下。</p>
{ "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
*nix
在*nix系統下,我們可以把JS文件當作shell腳本來運行,從而達到上述目的,具體步驟如下:
-
在shell腳本中,可以通過
#!
注釋來指定當前腳本使用的解析器。所以我們首先在node-echo.js
文件頂部增加以下一行注釋,表明當前腳本使用NodeJS解析。#! /usr/bin/env node
NodeJS會忽略掉位于JS模塊首行的
</li>#!
注釋,不必擔心這行注釋是非法語句。 -
然后,我們使用以下命令賦予
node-echo.js
文件執行權限。$ chmod +x /home/user/bin/node-echo.js
</li> -
最后,我們在PATH環境變量中指定的某個目錄下,例如在
/usr/local/bin
下邊創建一個軟鏈文件,文件名與我們希望使用的終端命令同名,命令如下:$ sudo ln -s /home/user/bin/node-echo.js /usr/local/bin/node-echo
</li> </ol>這樣處理后,我們就可以在任何目錄下使用
node-echo
命令了。Windows
在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 # 說明文件</pre> <p>其中部分文件內容如下:</p>
/ 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" }</pre>
以上例子中分類存放了不同類型的文件,并通過
node_moudles
目錄直接使用三方包名加載模塊。此外,定義了package.json
之后,node-echo
目錄也可被當作一個包來使用。NPM
NPM是隨同NodeJS一起安裝的包管理工具,能解決NodeJS代碼部署上的很多問題,常見的使用場景有以下幾種:
-
允許用戶從NPM服務器下載別人編寫的三方包到本地使用。
</li> -
允許用戶從NPM服務器下載并安裝別人編寫的命令行程序到本地使用。
</li> -
允許用戶將自己編寫的包或命令行程序上傳到NPM服務器供別人使用。
</li> </ul>可以看到,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/ ... ...</pre> <p>如此一來,用戶只需關心自己直接使用的三方包,不需要自己去解決所有包的依賴關系。</p>
安裝命令行程序
從NPM服務上下載安裝一個命令行程序的方法與三方包類似。例如上例中的
node-echo
提供了命令行使用方式,只要node-echo
自己配置好了相關的package.json
字段,對于用戶而言,只需要使用以下命令安裝程序。$ npm install node-echo -g
參數中的
-g
表示全局安裝,因此node-echo
會默認安裝到以下位置,并且NPM會自動創建好*nix系統下需要的軟鏈文件或Windows系統下需要的.cmd
文件。- /usr/local/ # *nix系統下
- lib/node_modules/ + node-echo/ ... - bin/ node-echo ... ...
%APPDATA%\npm\ # Windows系統下
- node_modules\
- node-echo\
...
node-echo.cmd
...</pre>
發布代碼
第一次使用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位。
- node-echo\
...
node-echo.cmd
...</pre>
- node_modules\
如果是新增了功能,但是向下兼容,需要更新Y位。
如果有大變動,向下不兼容,需要更新X位。</pre>
版本號有了這個保證后,在申明三方包依賴時,除了可依賴于一個固定版本號外,還可依賴于某個范圍的版本號。例如
"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));</pre>
以上程序使用
fs.readFileSync
從源路徑讀取文件內容,并使用fs.writeFileSync
將文件內容寫入目標路徑。豆知識:
</blockquote>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));</pre>
以上程序使用
fs.createReadStream
創建了一個源文件的只讀數據流,并使用fs.createWriteStream
創建了一個目標文件的只寫數據流,并且用pipe
方法把兩個數據流連接了起來。連接起來后發生的事情,說得抽象點的話,水順著水管從一個桶流到了另一個桶。API走馬觀花
我們先大致看看NodeJS提供了哪些和文件操作有關的API。這里并不逐一介紹每個API的使用方法,官方文檔已經做得很好了。
Buffer(數據塊)
官方文檔: http://nodejs.org/api/buffer.html
</blockquote>JS語言自身只有字符串數據類型,沒有二進制數據類型,因此NodeJS提供了一個與
String
對等的全局構造函數Buffer
來提供對二進制數據的操作。除了可以讀取文件得到Buffer
的實例外,還能夠直接構造,例如:var bin = new Buffer([ 0x48, 0x65, 0x6c, 0x6c, 0x6c ]);
Buffer
與字符串類似,除了可以用.length
屬性得到字節長度外,還可以用[index]
方式讀取指定位置的字節,例如:bin[0]; // => 0x48;
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
中間的某個位置的指針,如下所示。[ 0x48, 0x65, 0x6c, 0x6c, 0x6c ] ^ ^ | | bin bin.slice(2)
因此對
.slice
方法返回的Buffer
的修改會作用于原Buffer
,例如:var bin = new Buffer([ 0x48, 0x65, 0x6c, 0x6c, 0x6c ]); var sub = bin.slice(2);
sub[0] = 0x65; console.log(bin); // => <Buffer 48 65 65 6c 6f></pre>
也因此,如果想要拷貝一份
Buffer
,得首先創建一個新的Buffer
,并通過.copy
方法把原Buffer
中的數據復制過去。這個類似于申請一塊新的內存,并把已有內存中的數據復制過去。以下是一個例子。var bin = new Buffer([ 0x48, 0x65, 0x6c, 0x6c, 0x6c ]); var dup = new Buffer(bin.length);
bin.copy(dup); dup[0] = 0x68; console.log(bin); // => <Buffer 48 65 6c 6c 6f> console.log(dup); // => <Buffer 68 65 65 6c 6f></pre>
總之,
Buffer
將JS的數據處理能力從字符串擴展到了任意二進制數據。Stream(數據流)
官方文檔: http://nodejs.org/api/buffer.html
</blockquote>當內存中無法一次裝下需要處理的數據時,或者一邊讀取一邊處理更加高效時,我們就需要用到數據流。NodeJS中通過各種
Stream
來提供對數據流的操作。以上邊的大文件拷貝程序為例,我們可以為數據來源創建一個只讀數據流,示例如下:
var rs = fs.createReadStream(pathname);
rs.on('data', function (chunk) { doSomething(chunk); });
rs.on('end', function () { cleanUp(); });</pre>
豆知識:
</blockquote>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(); });</pre>
以上代碼給
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(); });</pre>
我們把
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(); });</pre>
以上代碼實現了數據從只讀數據流到只寫數據流的搬運,并包括了防爆倉控制。因為這種使用場景很多,例如上邊的大文件拷貝程序,NodeJS直接提供了
.pipe
方法來做這件事情,其內部實現方式與上邊的代碼類似。File System(文件系統)
官方文檔: http://nodejs.org/api/buffer.html
</blockquote>NodeJS通過
fs
內置模塊提供對文件的操作。fs
模塊提供的API基本上可以分為以下三類:-
文件屬性讀寫。
其中常用的有
</li>fs.stat
、fs.chmod
、fs.chown
等等。 -
文件內容讀寫。
其中常用的有
</li>fs.readFile
、fs.readdir
、fs.writeFile
、fs.mkdir
等等。 -
底層文件操作。
其中常用的有
</li> </ul>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很多,這里不一一介紹,需要時請自行查閱官方文檔。Path(路徑)
官方文檔: http://nodejs.org/api/buffer.html
</blockquote>操作文件時難免不與文件路徑打交道。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 }</pre>
坑出沒注意: 標準化之后的路徑里的斜杠在Windows系統下是
</blockquote> </li>\
,而在*nix系統下是/
。如果想保證任何系統下都使用/
作為路徑分隔符的話,需要用.replace(/\\/g, '/')
再替換一下標準路徑。path.join
將傳入的多個路徑拼接為標準路徑。該方法可避免手工拼接路徑字符串的繁瑣,并且能在不同系統下正確使用相應的路徑分隔符。以下是一個例子:
path.join('foo/', 'baz/', '../bar'); // => "foo/bar"
</li>path.extname
當我們需要根據不同文件擴展名做不同操作時,該方法就顯得很好用。以下是一個例子:
path.extname('foo/bar.js'); // => ".js"
</li> </ul>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。
陷阱: 使用遞歸算法編寫的代碼雖然簡潔,但由于每遞歸一次就產生一次函數調用,在需要優先考慮性能時,需要把遞歸算法轉換為循環算法,以減少函數調用次數。
</blockquote>遍歷算法
目錄是一個樹狀結構,在遍歷時一般使用深度優先+先序遍歷算法。深度優先,意味著到達一個節點后,首先接著遍歷子節點而不是鄰居節點。先序遍歷,意味著首次到達了某節點就算遍歷完成,而不是最后一次返回某節點才算數。因此使用這種遍歷方式時,下邊這棵樹的遍歷順序是
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); } });
}</pre>
可以看到,該函數以某個目錄作為遍歷的起點。遇到一個子目錄時,就先接著遍歷子目錄。遇到一個文件時,就把文件的絕對路徑傳給回調函數。回調函數拿到文件路徑后,就可以做各種判斷和處理。因此假設有以下目錄:
- /home/user/
- foo/ x.js - bar/ y.js z.css</pre> <p>使用以下代碼遍歷該目錄時,得到的輸入如下。</p>
travel('/home/user', function (pathname) { console.log(pathname); });
/home/user/foo/x.js /home/user/bar/y.js /home/user/z.css</pre>
異步遍歷
如果讀取目錄或讀取文件狀態時使用的是異步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)); });
}</pre>
這里不詳細介紹異步遍歷函數的編寫技巧,在后續章節中會詳細介紹這個。總之我們可以看到異步編程還是蠻復雜的。
文本編碼
使用NodeJS編寫前端工具時,操作得最多的是文本文件,因此也就涉及到了文件編碼的處理問題。我們常用的文本編碼有
UTF8
和GBK
兩種,并且UTF8
文件還可能帶有BOM。在讀取不同編碼的文本文件時,需要將文件內容轉換為JS使用的UTF8
編碼字符串后才能正常處理。BOM的移除
BOM用于標記一個文本文件使用Unicode編碼,其本身是一個Unicode字符("\uFEFF"),位于文本文件頭部。在不同的Unicode編碼下,BOM字符對應的二進制字節如下:
Bytes Encoding
FE FF UTF16BE FF FE UTF16LE EF BB BF UTF8</pre> <p>因此,我們可以根據文本文件頭幾個字節等于啥來判斷文件是否包含BOM,以及使用哪種Unicode編碼。但是,BOM字符雖然起到了標記文件編碼的作用,其本身卻不屬于文件內容的一部分,如果讀取文本文件時不去掉BOM,在某些使用場景下就會有問題。例如我們把幾個JS文件合并成一個文件后,如果文件中間含有BOM字符,就會導致瀏覽器JS語法錯誤。因此,使用NodeJS讀取文本文件時,一般需要去掉BOM。例如,以下代碼實現了識別和去除 UTF8 BOM的功能。</p>
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');
}</pre>
GBK轉UTF8
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');
}</pre>
單字節編碼
有時候,我們無法預知需要讀取的文件采用哪種編碼,因此也就無法指定正確的編碼。比如我們要處理的某些CSS文件中,有的用GBK編碼,有的用UTF8編碼。雖然可以一定程度可以根據文件的字節內容猜測出文本編碼,但這里要介紹的是有些局限,但是要簡單得多的一種技術。
首先我們知道,如果一個文本文件只包含英文字符,比如
Hello World
,那無論用GBK編碼或是UTF8編碼讀取這個文件都是沒問題的。這是因為在這些編碼下,ASCII0~128范圍內字符都使用相同的單字節編碼。反過來講,即使一個文本文件中有中文等字符,如果我們需要處理的字符僅在ASCII0~128范圍內,比如除了注釋和字符串以外的JS代碼,我們就可以統一使用單字節編碼來讀取文件,不用關心文件的實際編碼是GBK還是UTF8。以下示例說明了這種方法。
1. GBK編碼源文件內容: var foo = '中文';
- 對應字節: 76 61 72 20 66 6F 6F 20 3D 20 27 D6 D0 CE C4 27 3B
- 使用單字節編碼讀取后得到的內容: var foo = '{亂碼}{亂碼}{亂碼}{亂碼}';
- 替換內容: var bar = '{亂碼}{亂碼}{亂碼}{亂碼}';
- 使用單字節編碼保存后對應字節: 76 61 72 20 62 61 72 20 3D 20 27 D6 D0 CE C4 27 3B
- 使用GBK編碼讀取后得到內容:
var bar = '中文';</pre>
這里的訣竅在于,不管大于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);</pre>
以上程序創建了一個HTTP服務器并監聽
8124
端口,打開瀏覽器訪問該端口http://127.0.0.1:8124/
就能夠看到效果。豆知識: 在*nix系統下,監聽1024以下端口需要root權限。因此,如果想監聽80或443端口的話,需要使用
</blockquote>sudo
命令啟動程序。API走馬觀花
我們先大致看看NodeJS提供了哪些和網絡操作有關的API。這里并不逐一介紹每個API的使用方法,官方文檔已經做得很好了。
HTTP
官方文檔: http://nodejs.org/api/http.html
</blockquote>'http'模塊提供兩種使用方式:
-
作為服務端使用時,創建一個HTTP服務器,監聽HTTP客戶端請求并返回響應。
</li> -
作為客戶端使用時,發起一個HTTP客戶端請求,獲取服務端響應。
</li> </ul>首先我們來看看服務端模式下如何工作。如開門紅中的例子所示,首先需要使用
.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</pre>
可以看到,空行之上是請求頭,之下是請求體。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</pre>
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</pre>
在回調函數中,除了可以使用
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);</pre>
接下來我們看看客戶端模式下如何工作。為了發起一個客戶端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();</pre>
可以看到,
.request
方法創建了一個客戶端,并指定請求目標和請求頭數據。之后,就可以把request
對象當作一個只寫數據流來寫入請求體數據和結束請求。另外,由于HTTP請求中GET
請求是最常見的一種,并且不需要請求體,因此http
模塊也提供了以下便捷API。http.get('http://www.example.com/', function (response) {});
當客戶端發送請求并接收到完整的服務端響應頭時,就會調用回調函數。在回調函數中,除了可以使用
response
對象訪問響應頭數據外,還能把response
對象當作一個只讀數據流來訪問響應體數據。以下是一個例子。http.get('
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> ...</pre>
HTTPS
官方文檔: http://nodejs.org/api/https.html
</blockquote>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) { // ... });</pre>
可以看到,與創建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') });</pre>
在客戶端模式下,發起一個HTTPS客戶端請求與
http
模塊幾乎相同,示例如下。var options = { hostname: 'www.example.com', port: 443, path: '/', method: 'GET' };
var request = https.request(options, function (response) {});
request.end();</pre>
但如果目標服務器使用的SSL證書是自制的,不是從頒發機構購買的,默認情況下
https
模塊會拒絕連接,提示說有證書安全問題。在options
里加入rejectUnauthorized: false
字段可以禁用對證書有效性的檢查,從而允許https
模塊請求開發環境下使用自制證書的HTTPS服務器。URL
官方文檔: http://nodejs.org/api/url.html
</blockquote>處理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</pre> <p>我們可以使用<code>.parse</code>方法來將一個URL字符串轉換為URL對象,示例如下。</p>
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 */
Query String
官方文檔: http://nodejs.org/api/querystring.html
</blockquote>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='/</pre>
Zlib
官方文檔: http://nodejs.org/api/zlib.html
</blockquote>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);</pre>
接著我們看一個使用
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();</pre>
Net
官方文檔: http://nodejs.org/api/net.html
</blockquote>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(); });</pre>
靈機一點
使用NodeJS操作網絡,特別是操作HTTP請求和響應時會遇到一些驚喜,這里對一些常見問題做解答。
-
問: 為什么通過
headers
對象訪問到的HTTP請求頭或響應頭字段不是駝峰的?答: 從規范上講,HTTP請求頭和響應頭字段都應該是駝峰的。但現實是殘酷的,不是每個HTTP服務端或客戶端程序都嚴格遵循規范,所以NodeJS在處理從別的客戶端或服務端收到的頭字段時,都統一地轉換為了小寫字母格式,以便開發者能使用統一的方式來訪問頭字段,例如
</li>headers['content-length']
。 -
問: 為什么
http
模塊創建的HTTP服務器返回的響應是chunked
傳輸方式的?答: 因為默認情況下,使用
</li>.writeHead
方法寫入響應頭后,允許使用.write
方法寫入任意長度的響應體數據,并使用.end
方法結束一個響應。由于響應體數據長度不確定,因此NodeJS自動在響應頭里添加了Transfer-Encoding: chunked
字段,并采用chunked
傳輸方式。但是當響應體數據長度確定時,可使用.writeHead
方法在響應頭里加上Content-Length
字段,這樣做之后NodeJS就不會自動添加Transfer-Encoding
字段和使用chunked
傳輸方式。 -
問: 為什么使用
http
模塊發起HTTP客戶端請求時,有時候會發生socket hang up
錯誤?答: 發起客戶端HTTP請求前需要先創建一個客戶端。
</li> </ul>http
模塊提供了一個全局客戶端http.globalAgent
,可以讓我們使用.request
或.get
方法時不用手動創建客戶端。但是全局客戶端默認只允許5個并發Socket連接,當某一個時刻HTTP客戶端請求創建過多,超過這個數字時,就會發生socket hang up
錯誤。解決方法也很簡單,通過http.globalAgent.maxSockets
屬性把這個數字改大些即可。另外,https
模塊遇到這個問題時也一樣通過https.globalAgent.maxSockets
屬性來處理。小結
本章介紹了使用NodeJS操作網絡時需要的API以及一些坑回避技巧,總結起來有以下幾點:
-
</li>http
和https
模塊支持服務端模式和客戶端模式兩種使用方式。 -
</li>request
和response
對象除了用于讀寫頭數據外,都可以當作數據流來操作。 -
</li>url.parse
方法加上request.url
屬性是處理HTTP請求時的固定搭配。 -
使用
</li>zlib
模塊可以減少使用HTTP協議時的數據傳輸量。 -
通過
</li>net
模塊的Socket服務器與客戶端可對HTTP協議做底層操作。 -
小心踩坑。
</li> </ul>進程管理
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) { // ... });</pre>
從以上代碼中可以看到,子進程是異步運行的,通過回調函數返回執行結果。
API走馬觀花
我們先大致看看NodeJS提供了哪些和進程管理有關的API。這里并不逐一介紹每個API的使用方法,官方文檔已經做得很好了。
Process
官方文檔: http://nodejs.org/api/process.html
</blockquote>任何一個進程都有啟動進程時使用的命令行參數,有標準輸入標準輸出,有運行權限,有運行環境和運行狀態。在NodeJS中,可以通過
process
對象感知和控制NodeJS自身進程的方方面面。另外需要注意的是,process
不是內置模塊,而是一個全局對象,因此在任何地方都可以直接使用。Child Process
官方文檔: http://nodejs.org/api/child_process.html
</blockquote>使用
child_process
模塊可以創建和控制子進程。該模塊提供的API中最核心的是.spawn
,其余API都是針對特定使用場景對它的進一步封裝,算是一種語法糖。Cluster
官方文檔: http://nodejs.org/api/cluster.html
</blockquote>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));</pre>
如何退出程序
通常一個程序做完所有事情后就正常退出了,這時程序的退出狀態碼為
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'); }
如何降權
在*nix系統下,我們知道需要使用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);
});</pre>
上例中有幾點需要注意:
-
如果是通過
</li>sudo
獲取root權限的,運行程序的用戶的UID和GID保存在環境變量SUDO_UID
和SUDO_GID
里邊。如果是通過chmod +s
方式獲取root權限的,運行程序的用戶的UID和GID可直接通過process.getuid
和process.getgid
方法獲取。 -
</li>process.setuid
和process.setgid
方法只接受number
類型的參數。 -
降權時必須先降GID再降UID,否則順序反過來的話就沒權限更改程序的GID了。
</li> </ol>如何創建子進程
以下是一個創建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); });</pre>
上例中使用了
.spawn(exec, args, options)
方法,該方法支持三個參數。第一個參數是執行文件路徑,可以是執行文件的相對或絕對路徑,也可以是根據PATH環境變量能找到的執行文件名。第二個參數中,數組中的每個成員都按順序對應一個命令行參數。第三個參數可選,用于配置子進程的執行環境與行為。另外,上例中雖然通過子進程對象的
.stdout
和.stderr
訪問子進程的輸出,但通過options.stdio
字段的不同配置,可以將子進程的輸入輸出重定向到任何數據流上,或者讓子進程共享父進程的標準輸入輸出流,或者直接忽略子進程的輸入輸出。進程間如何通訊
在*nix系統下,進程之間可以通過信號互相通信。以下是一個例子。
/ parent.js / var child = child_process.spawn('node', [ 'child.js' ]);
child.kill('SIGTERM');
/ child.js / process.on('SIGTERM', function () { cleanUp(); process.exit(0); });</pre>
在上例中,父進程通過
.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); });</pre>
可以看到,父進程在創建子進程時,在
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');</pre>
可以看到,工作進程非正常退出時,守護進程立即重啟工作進程。
小結
本章介紹了使用NodeJS管理進程時需要的API以及主要的應用場景,總結起來有以下幾點:
-
使用
</li>process
對象管理自身。 -
使用
</li> </ul>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</pre>
可以看到,以上代碼中的回調函數仍然先于后續代碼執行。JS本身是單線程運行的,不可能在一段代碼還未結束運行時去運行別的代碼,因此也就不存在異步執行的概念。
但是,如果某個函數做的事情是創建一個別的線程或進程,并與JS主線程并行地做一些事情,并在事情做完后通知JS主線程,那情況又不一樣了。我們接著看看以下代碼。
setTimeout(function () { console.log('world'); }, 1000);
console.log('hello');
-- Console ------------------------------ hello world</pre>
這次可以看到,回調函數后于后續代碼執行了。如同上邊所說,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</pre>
可以看到,本來應該在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.</pre>
如果函數是異步執行的,以上代碼就無法保證循環結束后所有數組成員都處理完畢了。如果數組成員必須一個接一個串行處理,則一般按照以下方式編寫異步代碼:
(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</pre>
可以看到,異常會沿著代碼執行路徑一直冒泡,直到遇到第一個
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)</pre>
因為代碼執行路徑被打斷了,我們就需要在異常冒泡到斷點之前用
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</pre>
可以看到,異常再次被捕獲住了。在NodeJS中,幾乎所有異步API都按照以上方式設計,回調函數中第一個參數都是
err
。因此我們在編寫自己的異步函數時,也可以按照這種方式來處理異常,與NodeJS的設計風格保持一致。有了異常處理方式后,我們接著可以想一想一般我們是怎么寫代碼的。基本上,我們的代碼都是做一些事情,然后調用一個函數,然后再做一些事情,然后再調用一個函數,如此循環。如果我們寫的是同步代碼,只需要在代碼入口點寫一個
try
語句就能捕獲所有冒泡上來的異常,示例如下。function main() { // Do something. syncA(); // Do something. syncB(); // Do something. syncC(); }
try { main(); } catch (err) { // Deal with exception. }</pre>
但是,如果我們寫的是異步代碼,就只有呵呵了。由于每次異步函數調用都會打斷代碼執行路徑,只能通過回調函數來傳遞異常,于是我們就需要在每個回調函數里判斷是否有異常發生,于是只用三次異步函數調用,就會產生下邊這種代碼。
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. } });</pre>
可以看到,回調函數已經讓代碼變得復雜了,而異步方式下對異常的處理更加劇了代碼的復雜度。如果NodeJS的最大賣點最后變成這個樣子,那就沒人愿意用NodeJS了,因此接下來會介紹NodeJS提供的一些解決方案。
域(Domain)
官方文檔: http://nodejs.org/api/domain.html
</blockquote>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</pre>
雖然全局異常有個地方可以捕獲了,但是對于大多數異常,我們希望盡早捕獲,并根據結果決定代碼的執行路徑。我們用以下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); } }); });</pre>
以上代碼將請求對象交給異步函數處理后,再根據處理結果返回響應。這里采用了使用回調函數傳遞異常的方案,因此
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); }); });
});</pre>
可以看到,我們使用
.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。
</li> -
異步編程依托于回調來實現,而使用回調不一定就是異步編程。
</li> -
異步編程下的函數間數據傳遞、數組遍歷和異常處理與同步編程有很大差別。
</li> -
使用
</li> </ul>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));</pre>
以上代碼完整實現了服務器所需的功能,并且有以下幾點值得注意:
-
使用命令行參數傳遞JSON配置文件路徑,入口函數負責讀取配置并創建服務器。
</li> -
入口函數完整描述了程序的運行邏輯,其中解析URL和合并文件的具體實現封裝在其它兩個函數里。
</li> -
解析URL時先將普通URL轉換為了文件合并URL,使得兩種URL的處理方式可以一致。
</li> -
合并文件時使用異步API讀取文件,避免服務器因等待磁盤IO而發生阻塞。
</li> </ol>我們可以把以上代碼保存為
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 -- 合并數據 -- 輸出響應
可以看到,第一版代碼依次把請求的文件讀取到內存中之后,再合并數據和輸出響應。這會導致以下兩個問題:
-
當請求的文件比較多比較大時,串行讀取文件會比較耗時,從而拉長了服務端響應等待時間。
</li> -
由于每次響應輸出的數據都需要先完整地緩存在內存里,當服務器請求并發數較大時,會有較大的內存開銷。
</li> </ol>對于第一個問題,很容易想到把讀取文件的方式從串行改為并行。但是別這樣做,因為對于機械磁盤而言,因為只有一個磁頭,嘗試并行讀取文件只會造成磁頭頻繁抖動,反而降低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)); }</pre>
可以看到,第二版代碼在檢查了請求的所有文件是否有效之后,立即就輸出了響應頭,并接著一邊按順序讀取文件一邊輸出響應內容。并且,在讀取文件時,第二版代碼直接使用了只讀數據流來簡化代碼。
第三次迭代
第二次迭代之后,服務器本身的功能和性能已經得到了初步滿足。接下來我們需要從穩定性的角度重新審視一下代碼,看看還需要做些什么。
設計
從工程角度上講,沒有絕對可靠的系統。即使第二次迭代的代碼經過反復檢查后能確保沒有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));</pre>
此外,服務器代碼本身的入口函數也要做以下調整。
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); }); });
}</pre>
我們可以把守護進程的代碼保存為
daemon.js
,之后我們可以通過node daemon.js config.json
啟動服務,而守護進程會進一步啟動和監控服務器進程。此外,為了能夠正常終止服務,我們讓守護進程在接收到SIGTERM
信號時終止服務器進程。而在服務器進程這一端,同樣在收到SIGTERM
信號時先停掉HTTP服務再正常退出。至此,我們的服務器程序就靠譜很多了。第四次迭代
在我們解決了服務器本身的功能、性能和可靠性的問題后,接著我們需要考慮一下代碼部署的問題,以及服務器控制的問題。
設計
一般而言,程序在服務器上有一個固定的部署目錄,每次程序有更新后,都重新發布到部署目錄里。而一旦完成部署后,一般也可以通過固定的服務控制腳本啟動和停止服務。因此我們的服務器程序部署目錄可以做如下設計。
- deploy/
- bin/ startws.sh killws.sh + conf/ config.json + lib/ daemon.js server.js</pre> <p>在以上目錄結構中,我們分類存放了服務控制腳本、配置文件和服務器代碼。</p>
實現
按以上目錄結構分別存放對應的文件之后,接下來我們看看控制腳本怎么寫。首先是
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文檔的哪塊地方。
</li> -
要先設計再實現。在開發一個程序前首先要有一個全局的設計,不一定要很周全,但要足夠能寫出一些代碼。
</li> -
要實現后再設計。在寫了一些代碼,有了一些具體的東西后,一定會發現一些之前忽略掉的細節。這時再反過來改進之前的設計,為第二輪迭代做準備。
</li> -
要充分利用三方包。NodeJS有一個龐大的生態圈,在寫代碼之前先看看有沒有現成的三方包能節省不少時間。
</li> -
不要迷信三方包。任何事情做過頭了就不好了,三方包也是一樣。三方包是一個黑盒,每多使用一個三方包,就為程序增加了一份潛在風險。并且三方包很難恰好只提供程序需要的功能,每多使用一個三方包,就讓程序更加臃腫一些。因此在決定使用某個三方包之前,最好三思而后行。
</li> </ul> </article>來自:http://nqdeng.github.io/7-days-nodejs/#7.6
-
-
-
-
-
-
-
-
-
-
-
-
-