ShiningDan的博客

Node.js 基础总结

本笔记是学习七天学会NodeJS 的个人总结。

NodeJS基础

JS是脚本语言,脚本语言都需要一个解析器才能运行。对于写在HTML页面里的JS,浏览器充当了解析器的角色。而对于需要独立运行的JS,NodeJS就是一个解析器。

每一种解析器都是一个运行环境,不但允许JS定义各种数据结构,进行各种计算,还允许JS使用运行环境提供的内置对象和方法做一些事情。例如运行在浏览器中的JS的用途是操作DOM,浏览器就提供了document之类的内置对象。而运行在NodeJS中的JS的用途是操作磁盘文件或搭建HTTP服务器,NodeJS就相应提供了fshttp等内置对象。

NodeJS最大的卖点——事件机制和异步IO

模块

编写稍大一点的程序时一般都会将代码模块化。在NodeJS中,一般将代码合理拆分到不同的JS文件中,每一个文件就是一个模块,而文件路径就是模块名。

在编写每个模块时,都有requireexportsmodule三个预先定义好的变量可供使用。

require

require函数用于在当前模块中加载和使用别的模块,传入一个模块名,返回一个模块导出对象。模块名可使用相对路径(以./开头),或者是绝对路径(以/C:之类的盘符开头)。另外,模块名中的.js扩展名可以省略。

1
2
3
4
var foo1 = require('./foo');
var foo2 = require('./foo.js');
var foo3 = require('/home/user/foo');
var foo4 = require('/home/user/foo.js');

可以使用以下方式加载和使用一个JSON文件。

1
var data = require('./data.json');

exports

exports对象是当前模块的导出对象,用于导出模块公有方法和属性。别的模块通过require函数使用当前模块时得到的就是当前模块的exports对象。以下例子中导出了一个公有方法。

1
2
3
exports.hello = function () {
console.log('Hello World!');
};

module

通过module对象可以访问到当前模块的一些相关信息,但最多的用途是替换当前模块的导出对象。例如模块导出对象默认是一个普通对象,如果想改成一个函数的话,可以使用以下方式。

1
2
3
module.exports = function () {
console.log('Hello World!');
};

以上代码中,模块默认导出对象被替换为一个函数。

模块初始化

一个模块中的JS代码仅在模块第一次被使用时执行一次,并在执行过程中初始化模块的导出对象。之后,缓存起来的导出对象被重复利用。

模块提供的对象不会因为被 require 了两次而初始化两次

二进制模块

虽然一般我们使用JS编写模块,但NodeJS也支持使用C/C++编写二进制模块。编译好的二进制模块除了文件扩展名是.node外,和JS模块的使用方式相同。

代码的组织和部署

模块路径解析规则

我们已经知道,require函数支持斜杠(/)或盘符(C:)开头的绝对路径,也支持./开头的相对路径。但这两种路径在模块之间建立了强耦合关系,一旦某个模块文件的存放位置需要变更,使用该模块的其它模块的代码也需要跟着调整,变得牵一发动全身。因此,require函数支持第三种形式的路径,写法类似于foo/bar,并依次按照以下规则解析路径,直到找到模块位置。

内置模块

如果传递给require函数的是NodeJS内置模块名称,不做路径解析,直接返回内部模块的导出对象,例如require('fs')

node_modules目录

NodeJS定义了一个特殊的node_modules目录用于存放模块。例如某个模块的绝对路径是/home/user/hello.js,在该模块中使用require('foo/bar')方式加载模块时,则NodeJS依次尝试使用以下路径。

1
2
3
/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环境变量:

1
NODE_PATH=/home/user/lib:/home/lib

当使用require('foo/bar')的方式加载模块时,则NodeJS依次尝试以下路径。

1
2
/home/user/lib/foo/bar
/home/lib/foo/bar

包(package)

我们已经知道了JS模块的基本单位是单个JS文件,但复杂些的模块往往由多个子模块组成。为了便于管理和使用,我们可以把由多个子模块组成的大模块称做包,并把所有子模块放在同一个目录里。

在组成一个包的所有子模块中,需要有一个入口模块,入口模块的导出对象被作为包的导出对象。例如有以下目录结构。

1
2
3
4
5
- /home/user/lib/
- cat/
head.js
body.js
main.js

其中cat目录定义了一个包,其中包含了3个子模块。main.js作为入口模块,其内容如下:

1
2
3
4
5
6
7
8
9
10
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

当模块的文件名是index.js,加载模块时可以使用模块所在目录的路径代替模块文件路径,因此接着上例,以下两条语句等价。

1
2
var cat = require('/home/user/lib/cat');
var cat = require('/home/user/lib/cat/index');

这样处理后,就只需要把包目录路径传递给require函数,感觉上整个目录被当作单个模块使用,更有整体感。

package.json

如果想自定义入口模块的文件名和存放位置,就需要在包目录下包含一个package.json文件,并在其中指定入口模块的路径。上例中的cat模块可以重构如下。

1
2
3
4
5
6
7
8
9
- /home/user/lib/
- cat/
+ doc/
- lib/
head.js
body.js
main.js
+ tests/
package.json

其中package.json内容如下。

1
2
3
4
{
"name": "cat",
"main": "./lib/main.js"
}

如此一来,就同样可以使用require('/home/user/lib/cat')的方式加载模块。NodeJS会根据包目录下的package.json找到入口模块所在位置。

命令行程序

使用NodeJS编写的东西,要么是一个包,要么是一个命令行程序,而前者最终也会用于开发后者。因此我们在部署代码时需要一些技巧,让用户觉得自己是在使用一个命令行程序。

Linux

在Linux系统下,我们可以把JS文件当作shell脚本来运行,从而达到上述目的,具体步骤如下:

在shell脚本中,可以通过#!注释来指定当前脚本使用的解析器。所以我们首先在node-echo.js文件顶部增加以下一行注释,表明当前脚本使用NodeJS解析。

1
#! /usr/bin/env node

NodeJS会忽略掉位于JS模块首行的#!注释,不必担心这行注释是非法语句。

然后,我们使用以下命令赋予node-echo.js文件执行权限。

1
$ chmod +x /home/user/bin/node-echo.js

最后,我们在PATH环境变量中指定的某个目录下,例如在/usr/local/bin下边创建一个软链文件,文件名与我们希望使用的终端命令同名,命令如下:

1
$ sudo ln -s /home/user/bin/node-echo.js /usr/local/bin/node-echo

这样处理后,我们就可以在任何目录下使用node-echo命令了。

工程目录

了解了以上知识后,现在我们可以来完整地规划一个工程目录了。以编写一个命令行程序为例,一般我们会同时提供命令行模式和API模式两种使用方式,并且我们会借助三方包来编写代码。除了代码外,一个完整的程序也应该有自己的文档和测试用例。因此,一个标准的工程目录都看起来像下边这样。

1
2
3
4
5
6
7
8
9
10
11
- /home/user/workspace/node-echo/   # 工程目录
- bin/ # 存放命令行相关代码
node-echo
+ doc/ # 存放文档
- lib/ # 存放API相关代码
echo.js
- node_modules/ # 存放三方包
+ argv/
+ tests/ # 存放测试用例
package.json # 元数据文件
README.md # 说明文件

NPM

发布代码

第一次使用NPM发布代码前需要注册一个账号。终端下运行npm adduser,之后按照提示做即可。账号搞定后,接着我们需要编辑package.json文件,加入NPM必需的字段。接着上边node-echo的例子,package.json里必要的字段如下。

1
2
3
4
5
6
7
8
9
10
11
{
"name": "node-echo", # 包名,在NPM服务器上须要保持唯一
"version": "1.0.0", # 当前版本号
"dependencies": { # 三方包依赖,需要指定包名和版本号
"argv": "0.0.2"
},
"main": "./lib/echo.js", # 入口模块位置
"bin" : {
"node-echo": "./bin/node-echo" # 命令行程序名和主模块位置
}
}

版本号

使用NPM下载和发布代码时都会接触到版本号。NPM使用语义版本号来管理代码,这里简单介绍一下。

语义版本号分为X.Y.Z三位,分别代表主版本号、次版本号和补丁版本号。当代码变更时,版本号按以下原则更新。

1
2
3
4
5
+ 如果只是修复bug,需要更新Z位。

+ 如果是新增了功能,但是向下兼容,需要更新Y位。

+ 如果有大变动,向下不兼容,需要更新X位。

npm 常用命令

除了本章介绍的部分外,NPM还提供了很多功能,package.json里也有很多其它有用的字段。除了可以在npmjs.org/doc/查看官方文档外,这里再介绍一些NPM常用命令。

NPM提供了很多命令,例如installpublish,使用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内置的fs模块简单实现这个程序如下。

以上程序使用fs.readFileSync从源路径读取文件内容,并使用fs.writeFileSync将文件内容写入目标路径。

1
2
3
4
5
6
7
8
9
10
11
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));

process是一个全局变量,可通过process.argv获得命令行参数。由于argv[0]固定等于NodeJS执行程序的绝对路径,argv[1]固定等于主模块的绝对路径,因此第一个命令行参数从argv[2]这个位置开始。

process.argv 的值如下:

1
2
3
4
[ '/usr/local/Cellar/node/7.3.0/bin/node',
'/Users/yuchenzhang/Documents/web/test/test.js',
'./test.js',
'./test1.js' ]

如果文件的大小过大,则这个方法会报错:

1
2
3
4
5
6
7
8
buffer.js:11
super(arg1, arg2, arg3);
^

RangeError: Invalid typed array length
at Buffer.Uint8Array (native)
at FastBuffer (buffer.js:11:5)
...

如果是使用异步的文件传输方法 writeFilereadFile,也会因为文件大小过大而报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let fs = require("fs");

function copy(src, dst) {
fs.writeFile(dst, fs.readFile(src), function(err, data){
if (err) throw err;
console.log(data);
});
}

function main(argv) {
copy(argv[0], argv[1]);
}

main(process.argv.slice(2));

报错为:

1
2
RangeError: File size is greater than possible Buffer: 0x7fffffff bytes
at FSReqWrap.readFileAfterStat [as oncomplete] (fs.js:362:11)

大文件拷贝

上边的程序拷贝一些小文件没啥问题,但这种一次性把所有文件内容都读取到内存中后再一次性写入磁盘的方式不适合拷贝大文件,内存会爆仓。对于大文件,我们只能读一点写一点,直到完成拷贝。因此上边的程序需要改造如下。

1
2
3
4
5
6
7
8
9
10
11
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方法把两个数据流连接了起来。连接起来后发生的事情,说得抽象点的话,水顺着水管从一个桶流到了另一个桶。

使用 stream 的方法传输文件是异步传输。

Buffer(数据块)

JS语言自身只有字符串数据类型,没有二进制数据类型,因此NodeJS提供了一个与String对等的全局构造函数Buffer来提供对二进制数据的操作。除了可以读取文件得到Buffer的实例外,还能够直接构造,例如:

1
var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);

Buffer与字符串类似,除了可以用.length属性得到字节长度外,还可以用[index]方式读取指定位置的字节,例如:

Buffer将JS的数据处理能力从字符串扩展到了任意二进制数据。

Stream(数据流)

当内存中无法一次装下需要处理的数据时,或者一边读取一边处理更加高效时,我们就需要用到数据流。NodeJS中通过各种Stream来提供对数据流的操作。

以上边的大文件拷贝程序为例,我们可以为数据来源创建一个只读数据流,示例如下:

1
2
3
4
5
6
7
8
9
var rs = fs.createReadStream(pathname);

rs.on('data', function (chunk) {
doSomething(chunk);
});

rs.on('end', function () {
cleanUp();
});

Stream基于事件机制工作,所有Stream的实例都继承于NodeJS提供的EventEmitter

上边的代码中data事件会源源不断地被触发,不管doSomething函数是否处理得过来。代码可以继续做如下改造,以解决这个问题。

1
2
3
4
5
6
7
8
9
10
11

rs.on('data', function (chunk) {
rs.pause();
doSomething(chunk, function () {
rs.resume();
});
});

rs.on('end', function () {
cleanUp();
});

以上代码给doSomething函数加上了回调,因此我们可以在处理数据前暂停数据读取,并在处理数据后继续读取数据。

此外,我们也可以为数据目标创建一个只写数据流,示例如下:

1
2
3
4
5
6
7
8
9
10
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事件来判断什么时候只写数据流已经将缓存中的数据写入目标,可以传入下一个待写数据了。因此代码可以改造如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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();
});

File System(文件系统)

NodeJS通过fs内置模块提供对文件的操作。fs模块提供的API基本上可以分为以下三类:

  • 文件属性读写。

其中常用的有fs.statfs.chmodfs.chown等等。

  • 文件内容读写。

其中常用的有fs.readFilefs.readdirfs.writeFilefs.mkdir等等。

  • 底层文件操作。

其中常用的有fs.openfs.readfs.writefs.close等等。

NodeJS最精华的异步IO模型在fs模块里有着充分的体现,例如上边提到的这些API都通过回调函数传递结果。以fs.readFile为例:

1
2
3
4
5
6
7
fs.readFile(pathname, function (err, data) {
if (err) {
// Deal with error.
} else {
// Deal with data.
}
});

Path(路径)

操作文件时难免不与文件路径打交道。NodeJS提供了path内置模块来简化路径相关操作,并提升代码可读性。以下分别介绍几个常用的API。

path.normalize

将传入的路径转换为标准路径,具体讲的话,除了解析路径中的...外,还能去掉多余的斜杠。如果有程序需要使用路径作为某些数据的索引,但又允许用户随意输入路径时,就需要使用该方法保证路径的唯一性。以下是一个例子:

1
2
3
4
5
6
7
8
9
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

将传入的多个路径拼接为标准路径。该方法可避免手工拼接路径字符串的繁琐,并且能在不同系统下正确使用相应的路径分隔符。以下是一个例子:

1
path.join('foo/', 'baz/', '../bar'); // => "foo/bar"

path.extname

当我们需要根据不同文件扩展名做不同操作时,该方法就显得很好用。以下是一个例子:

1
path.extname('foo/bar.js'); // => ".js"

遍历目录

遍历目录是操作文件时的一个常见需求。比如写一个程序,需要找到并处理指定目录下的所有JS文件时,就需要遍历整个目录。

同步遍历

总结:

  • 使用 fs.readdirSync(path) 同步获得一个文件目录下所有的文件夹和文件
  • 使用 path 对象构造新的文件夹目录
  • 使用 fs.statSync(path) 同步得到关于一个文件或者文件夹的信息,判断该路径是文件还是文件夹等
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let fs = require('fs');
let path = require('path');

function travel(dir, callback) {
fs.readdirSync(dir).forEach( function(element, index) {
let pathname = path.join(dir, element);
if(fs.statSync(pathname).isDirectory()){
callback(pathname, true);
travel(pathname, callback);
} else {
callback(pathname, false);
}
});
};

function travelCallback(path, isDirectory) {
if (isDirectory) {
console.log('\n---------- ' + path + ' ---------\n');
} else {
console.log(path);
}
}

travel("/Users/yuchenzhang/Documents/web/test", travelCallback);

异步遍历

如果读取目录或读取文件状态时使用的是异步API,目录遍历函数实现起来会有些复杂,但原理完全相同。travel函数的异步版本如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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编写前端工具时,操作得最多的是文本文件,因此也就涉及到了文件编码的处理问题。我们常用的文本编码有UTF8GBK两种,并且UTF8文件还可能带有BOM。在读取不同编码的文本文件时,需要将文件内容转换为JS使用的UTF8编码字符串后才能正常处理。

BOM的移除

BOM用于标记一个文本文件使用Unicode编码,其本身是一个Unicode字符(”\uFEFF”),位于文本文件头部。在不同的Unicode编码下,BOM字符对应的二进制字节如下:

1
2
3
4
5
    Bytes      Encoding
----------------------------
FE FF UTF16BE
FF FE UTF16LE
EF BB BF UTF8

因此,我们可以根据文本文件头几个字节等于啥来判断文件是否包含BOM,以及使用哪种Unicode编码。但是,BOM字符虽然起到了标记文件编码的作用,其本身却不属于文件内容的一部分,如果读取文本文件时不去掉BOM,在某些使用场景下就会有问题。例如我们把几个JS文件合并成一个文件后,如果文件中间含有BOM字符,就会导致浏览器JS语法错误。因此,使用NodeJS读取文本文件时,一般需要去掉BOM。

1
2
3
4
5
6
7
8
9
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');
}

GBK转UTF8

NodeJS支持在读取文本文件时,或者在Buffer转换为字符串时指定文本编码,但遗憾的是,GBK编码不在NodeJS自身支持范围内。因此,一般我们借助iconv-lite这个三方包来转换编码。使用NPM下载该包后,我们可以按下边方式编写一个读取GBK文本文件的函数。

1
2
3
4
5
6
7
8
let iconv = require('iconv-lite');
let fs = require('fs');

function readGBKText(pathname) {
let bin = fs.readFileSync(pathname);
let result = iconv.decode(bin, 'gbk');
return result;
}

单字节编码

有时候,我们无法预知需要读取的文件采用哪种编码,因此也就无法指定正确的编码。比如我们要处理的某些CSS文件中,有的用GBK编码,有的用UTF8编码。虽然可以一定程度可以根据文件的字节内容猜测出文本编码,但这里要介绍的是有些局限,但是要简单得多的一种技术。

首先我们知道,如果一个文本文件只包含英文字符,比如Hello World,那无论用GBK编码或是UTF8编码读取这个文件都是没问题的。这是因为在这些编码下,ASCII0~128范围内字符都使用相同的单字节编码。

反过来讲,即使一个文本文件中有中文等字符,如果我们需要处理的字符仅在ASCII0~128范围内,比如除了注释和字符串以外的JS代码,我们就可以统一使用单字节编码来读取文件,不用关心文件的实际编码是GBK还是UTF8。以下示例说明了这种方法。

这里的诀窍在于,不管大于0xEF的单个字节在单字节编码下被解析成什么乱码字符,使用同样的单字节编码保存这些乱码字符时,背后对应的字节保持不变。

NodeJS中自带了一种binary编码可以用来实现这个方法,因此在下例中,我们使用这种编码来演示上例对应的代码该怎么写。

1
2
3
4
5
function replace(pathname) {
var str = fs.readFileSync(pathname, 'binary');
str = str.replace('foo', 'bar');
fs.writeFileSync(pathname, str, 'binary');
}

网络操作

HTTP

在Linux系统下,监听1024以下端口需要root权限。因此,如果想监听80或443端口的话,需要使用sudo命令启动程序。

http模块提供两种使用方式:

  • 作为服务端使用时,创建一个HTTP服务器,监听HTTP客户端请求并返回响应。
  • 作为客户端使用时,发起一个HTTP客户端请求,获取服务端响应。

HTTP 服务器端

首先需要使用.createServer方法创建一个服务器,然后调用.listen方法监听端口。之后,每当来了一个客户端请求,创建服务器时传入的回调函数就被调用一次。可以看出,这是一种事件机制。

关于Server 上获得的 request 相关 API,可以去 Node IncomingMessage 查询

HTTP请求在发送给服务器时,可以认为是按照从头到尾的顺序一个字节一个字节地以数据流方式发送的。而http模块创建的HTTP服务器在接收到完整的请求头后,就会调用回调函数。在回调函数中,除了可以使用request对象访问请求头数据外,还能把request对象当作一个只读数据流来访问请求体数据。以下是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);

在回调函数中,除了可以使用response对象来写入响应头数据外,还能把response对象当作一个只写数据流来写入响应体数据。例如在以下例子中,服务端原样将客户端请求的请求体数据返回给客户端。

1
2
3
4
5
6
7
8
9
10
11
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 客户端

为了发起一个客户端HTTP请求,我们需要指定目标服务器的位置并发送请求头和请求体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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。

1
http.get('http://www.example.com/', function (response) {});

当客户端发送请求并接收到完整的服务端响应头时,就会调用回调函数。在回调函数中,除了可以使用response对象访问响应头数据外,还能把response对象当作一个只读数据流来访问响应体数据。以下是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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());
});
});

HTTPS

https模块与http模块极为类似,区别在于https模块需要额外处理SSL证书。

在服务端模式下,创建一个HTTPS服务器的示例如下。

1
2
3
4
5
6
7
8
var options = {
key: fs.readFileSync('./ssl/default.key'),
cert: fs.readFileSync('./ssl/default.cer')
};

var server = https.createServer(options, function (request, response) {
// ...
});

可以看到,与创建HTTP服务器相比,多了一个options对象,通过keycert字段指定了HTTPS服务器使用的私钥和公钥。

URL

处理HTTP请求时url模块使用率超高,因为该模块允许解析URL、生成URL,以及拼接URL。首先我们来看看一个完整的URL的各组成部分。

1
2
3
4
5
6
7
8
9
                           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对象,示例如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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方法解析。

另外,.resolve方法可以用于拼接URL,示例如下。

1
2
3
4
url.resolve('http://www.example.com/foo/bar', '../baz');
/* =>
http://www.example.com/baz
*/

querystring

querystring模块用于实现URL参数字符串与参数对象的互相转换,示例如下,返回一个对象。

1
2
3
4
5
6
7
8
9
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

zlib模块提供了数据压缩和解压的功能。当我们处理HTTP请求和响应时,可能需要用到这个模块。

首先我们看一个使用zlib模块压缩HTTP响应体数据的例子。这个例子中,判断了客户端是否支持gzip,并在支持的情况下使用zlib模块返回gzip之后的响应体数据。

1
2
3
4
5
6
7
{ host: 'localhost:8888',
'upgrade-insecure-requests': '1',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/602.4.8 (KHTML, like Gecko) Version/10.0.3 Safari/602.4.8',
'accept-language': 'zh-cn',
'accept-encoding': 'gzip, deflate',
connection: 'keep-alive' }

如图的测试案例中,accept-encoding 中包含了 gzip 选项,说明浏览器支持返回的数据为 gzip 格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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模块解压响应体数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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

net模块可用于创建Socket服务器或Socket客户端。由于Socket在前端领域的使用范围还不是很广,这里先不涉及到WebSocket的介绍,仅仅简单演示一下如何从Socket层面来实现HTTP请求和响应。

首先我们来看一个使用Socket搭建一个很不严谨的HTTP服务器的例子。这个HTTP服务器不管收到啥请求,都固定返回相同的响应。

1
2
3
4
5
6
7
8
9
10
11
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事件监听函数来获取服务器响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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自带的fs模块比较基础,把一个目录里的所有文件和子目录都拷贝到另一个目录里需要写不少代码。另外我们也知道,终端下的cp命令比较好用,一条cp -r source/* target命令就能搞定目录拷贝。那我们首先看看如何使用NodeJS调用终端命令来简化目录拷贝,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
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) {
// ...
});

Process

任何一个进程都有启动进程时使用的命令行参数,有标准输入标准输出,有运行权限,有运行环境和运行状态。在NodeJS中,可以通过process对象感知和控制NodeJS自身进程的方方面面。另外需要注意的是,process不是内置模块,而是一个全局对象,因此在任何地方都可以直接使用。

Child Process

使用child_process模块可以创建和控制子进程。该模块提供的API中最核心的是.spawn,其余API都是针对特定使用场景对它的进一步封装,算是一种语法糖。

Cluster

cluster模块是对child_process模块的进一步封装,专用于解决单进程NodeJS Web服务器无法充分利用多核CPU的问题。使用该模块可以简化多进程服务器程序的开发,让每个核上运行一个工作进程,并统一通过主进程监听端口和分发请求。

应用场景

如何获取命令行参数

在NodeJS中可以通过process.argv获取命令行参数。但是比较意外的是,node执行程序路径和主模块文件路径固定占据了argv[0]argv[1]两个位置,而第一个命令行参数从argv[2]开始。为了让argv使用起来更加自然,可以按照以下方式处理。

1
2
3
4
5
function main(argv) {
// ...
}

main(process.argv.slice(2));

例如我们运行 node test1.js arg1 arg2 arg3,然后打印出 process.argv,得到的是以下:

1
2
3
4
5
[ '/usr/local/Cellar/node/7.3.0/bin/node',
'/Users/yuchenzhang/Documents/web/test/test1.js',
'arg1',
'arg2',
'arg3' ]

如何退出程序

通常一个程序做完所有事情后就正常退出了,这时程序的退出状态码为0。或者一个程序运行时发生了异常后就挂了,这时程序的退出状态码不等于0。如果我们在代码中捕获了某个异常,但是觉得程序不应该继续运行下去,需要立即退出,并且需要把退出状态码设置为指定数字,比如1,就可以按照以下方式:

1
2
3
4
5
6
try {
// ...
} catch (err) {
// ...
process.exit(1);
}

如何控制输入输出

NodeJS程序的标准输入流(stdin)、一个标准输出流(stdout)、一个标准错误流(stderr)分别对应process.stdinprocess.stdoutprocess.stderr,第一个是只读数据流,后边两个是只写数据流,对它们的操作按照对数据流的操作方式即可。例如,console.log可以按照以下方式实现。

1
2
3
4
5
6
7
8
9
10
11

process.stdin.on('readable', ()=>{
let chunk = process.stdin.read();
if (chunk != null) {
process.stdout.write(`data: ${chunk}`);
}
});

process.stdin.on('end', ()=>{
process.stdout.write('end');
})

可以通过 Ctrl + D 来触发 end 事件。

如何降权

在Linux系统下,我们知道需要使用root权限才能监听1024以下端口。但是一旦完成端口监听后,继续让程序运行在root权限下存在安全隐患,因此最好能把权限降下来。以下是这样一个例子。

1
2
3
4
5
6
7
8
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_UIDSUDO_GID里边。如果是通过chmod +s方式获取root权限的,运行程序的用户的UID和GID可直接通过process.getuidprocess.getgid方法获取。

  • process.setuidprocess.setgid方法只接受number类型的参数。

  • 降权时必须先降GID再降UID,否则顺序反过来的话就没权限更改程序的GID了。

如何创建子进程

1
2
3
4
5
6
7
8
9
10
11
12
13
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系统下,进程之间可以通过信号互相通信。以下是一个例子。

1
2
3
4
5
6
7
8
9
10
/* 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(进程间通讯)双向传递数据。以下是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 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方法反序列化。

如何守护子进程

守护进程一般用于监控工作进程的运行状态,在工作进程不正常退出时重启工作进程,保障工作进程不间断运行。以下是一种实现方式。

1
2
3
4
5
6
7
8
9
10
11
12
/* 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');

可以看到,工作进程非正常退出时,守护进程立即重启工作进程。

  • 使用process对象管理自身。
  • 使用child_process模块创建和管理子进程。

异步编程

NodeJS最大的卖点——事件机制和异步IO,对开发者并不是透明的。开发者需要按异步方式编写代码才用得上这个卖点

回调

在代码中,异步编程的直接体现就是回调。异步编程依托于回调来实现,但不能说使用了回调后程序就异步化了。

但是,如果某个函数做的事情是创建一个别的线程或进程,并与JS主线程并行地做一些事情,并在事情做完后通知JS主线程,那情况又不一样了。

JS本身是单线程的,无法异步执行,因此我们可以认为setTimeout这类JS规范之外的由运行环境提供的特殊函数做的事情是创建一个平行线程后立即返回,让JS主进程可以接着执行后续代码,并在收到平行进程的通知后再执行回调函数。除了setTimeoutsetInterval这些常见的,这类函数还包括NodeJS提供的诸如fs.readFile之类的异步API。

另外,我们仍然回到JS是单线程运行的这个事实上,这决定了JS在执行完一段代码之前无法执行包括回调函数在内的别的代码。也就是说,即使平行线程完成工作了,通知JS主线程执行回调函数了,回调函数也要等到JS主线程空闲时才能开始执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    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 ------------------------------

3039

可以看到,本来应该在1秒后被调用的回调函数因为JS主线程忙于运行其它代码,实际执行时间被大幅延迟。

代码设计模式

异步编程有很多特有的代码设计模式,为了实现同样的功能,使用同步方式和异步方式编写的代码会有很大差异。以下分别介绍一些常见的模式。

函数返回值

使用一个函数的输出作为另一个函数的输入是很常见的需求,在同步方式下一般按以下方式编写代码:

1
2
var output = fn1(fn2('input'));
// Do something.

而在异步方式下,由于函数执行结果不是通过返回值,而是通过回调函数传递,因此一般按以下方式编写代码:

1
2
3
4
5
fn2('input', function (output2) {
fn1(output2, function (output1) {
// Do something.
});
});

遍历数组

在遍历数组时,使用某个函数依次对数据成员做一些处理也是常见的需求。如果函数是同步执行的,一般就会写出以下代码:

1
2
3
4
5
6
7
8
var len = arr.length,
i = 0;

for (; i < len; ++i) {
arr[i] = sync(arr[i]);
}

// All array items have processed.

如果函数是异步执行的,以上代码就无法保证循环结束后所有数组成员都处理完毕了。如果数组成员必须一个接一个串行处理,则一般按照以下方式编写异步代码:

1
2
3
4
5
6
7
8
9
10
11
12
(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.
}));

这里的 callback 是在所有的异步遍历执行完毕以后才会被调用。

可以看到,以上代码在异步函数执行一次并返回执行结果后才传入下一个数组成员并开始下一轮执行,直到所有数组成员处理完毕后,通过回调的方式触发后续代码的执行。

如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let arr = [1, 2, 3, 4, 5];

(function next(i, a, callback) {
if (i < a.length) {
setTimeout(function() {
console.log('go to loop: ' + a[i]);
next(i+1, a, callback)
}, 1000)
} else {
callback();
}
}(0, arr, function() {
console.log('async method end');
}))

如果数组成员可以并行处理,但后续代码仍然需要所有数组成员处理完毕后才能执行的话,则异步代码会调整成以下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(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..,只能用于同步执行的代码。

可以看到,异常会沿着代码执行路径一直冒泡,直到遇到第一个try语句时被捕获住。但由于异步函数会打断代码执行路径,异步函数执行过程中以及执行之后产生的异常冒泡到执行路径被打断的位置时,如果一直没有遇到try语句,就作为一个全局异常抛出。

因为代码执行路径被打断了,我们就需要在异常冒泡到断点之前用try语句把异常捕获住,并通过回调函数传递被捕获的异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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

意思就是在回调函数 callback 中,如果有需要被捕获的 err 参数,(通常是通过 API 查询看会获得什么参数),就在 callback 函数中处理 err

但是如果回调函数嵌套异步执行函数,异步执行函数又需要回调函数处理其得到的值,就会形成回调函数嵌套回调函数的情况出现,严重影响代码的可读性,我们称之为回调地狱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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.
}
});

我们可以使用 domain 模块来处理多重回调函数嵌套的问题,也可以使用新的异步处理方法,如:

  • promise
  • Generator
  • await/async

大示例

需求

我们要开发的是一个简单的静态文件合并服务器,该服务器需要支持类似以下格式的JS或CSS文件合并请求。

1
http://assets.example.com/foo/??bar.js,baz.js

在以上URL中,??是一个分隔符,之前是需要合并的多个文件的URL的公共部分,之后是使用,分隔的差异部分。因此服务器处理这个URL时,返回的是以下两个文件按顺序合并后的内容。

1
2
/foo/bar.js
/foo/baz.js

另外,服务器也需要能支持类似以下格式的普通的JS或CSS文件请求。

1
http://assets.example.com/foo/bar.js

第一次迭代

简单分析了需求之后,我们大致会得到以下的设计方案。

1
2
3
          +---------+  +-----------+  +----------+
request-->| parse |-->| combine |-->| output |--> response
+---------+ +-----------+ +----------+

也就是说,服务器会首先分析URL,得到请求的文件的路径和类型(MIME)。然后,服务器会读取请求的文件,并按顺序合并文件内容。最后,服务器返回响应,完成对一次请求的处理。

另外,服务器在读取文件时需要有个根目录,并且服务器监听的HTTP端口最好也不要写死在代码里,因此服务器需要是可配置的。

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
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));

config.js 文件的内容如下:

1
2
3
4
{
"root" : ".",
"port" : 8888
}

以上代码完整实现了服务器所需的功能,并且有以下几点值得注意:

  1. 使用命令行参数传递JSON配置文件路径,入口函数负责读取配置并创建服务器。
  2. 入口函数完整描述了程序的运行逻辑,其中解析URL和合并文件的具体实现封装在其它两个函数里。
  3. 解析URL时先将普通URL转换为了文件合并URL,使得两种URL的处理方式可以一致。
  4. 合并文件时使用异步API读取文件,避免服务器因等待磁盘IO而发生阻塞。

我们可以把以上代码保存为test.js,之后就可以通过node test.js config.json命令启动程序,于是我们的第一版静态文件合并服务器就顺利完工了。

1
http://localhost:8888/test/??test.js,test1.js

访问以上链接,可以在浏览器得到 test.js 和 test1.js 中的代码。

另外,以上代码存在一个不那么明显的逻辑缺陷。例如,使用以下URL请求服务器时会有惊喜。

1
http://localhost:8888/test/test.js,test1.js

经过分析之后我们会发现问题出在/被自动替换/??这个行为上,而这个问题我们可以到第二次迭代时再解决。

第二次迭代

map方法换成for循环或许会更快一些,但第一版代码最大的性能问题存在于从读取文件到输出响应的过程当中。我们以处理/??a.js,b.js,c.js这个请求为例,看看整个处理过程中耗时在哪儿。

1
2
3
4
5
6
7
8
 发送请求       等待服务端响应         接收响应
---------+----------------------+------------->
-- 解析请求
------ 读取a.js
------ 读取b.js
------ 读取c.js
-- 合并数据
-- 输出响应

可以看到,第一版代码依次把请求的文件读取到内存中之后,再合并数据和输出响应。这会导致以下两个问题:

  1. 当请求的文件比较多比较大时,串行读取文件会比较耗时,从而拉长了服务端响应等待时间。
  2. 由于每次响应输出的数据都需要先完整地缓存在内存里,当服务器请求并发数较大时,会有较大的内存开销。

对于第一个问题,很容易想到把读取文件的方式从串行改为并行。但是别这样做,因为对于机械磁盘而言,因为只有一个磁头,尝试并行读取文件只会造成磁头频繁抖动,反而降低IO效率。而对于固态硬盘,虽然的确存在多个并行IO通道,但是对于服务器并行处理的多个请求而言,硬盘已经在做并行IO了,对单个请求采用并行IO无异于拆东墙补西墙。因此,正确的做法不是改用并行IO,而是一边读取文件一边输出响应,把响应输出时机提前至读取第一个文件的时刻。这样调整后,整个请求处理过程变成下边这样。

1
2
3
4
5
6
7
8
发送请求 等待服务端响应 接收响应
---------+----+------------------------------->
-- 解析请求
-- 检查文件是否存在
-- 输出响应头
------ 读取和输出a.js
------ 读取和输出b.js
------ 读取和输出c.js

根据以上设计,第二版代码按以下方式调整了部分函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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));
}

修改部分有:

  1. 使用 fs.stat 来判断路径是否存在,来判断该路径对应的是否为一个文件
  2. 添加 outputFiles 文件,使用 readStream 来进行对 response 对象的大文件异步数据流写入,并且每读取完一个文件都进行一次写入,而不是先用一个数组存在本地。

第三次迭代

一般生产环境下的服务器程序都配有一个守护进程,在服务挂掉的时候立即重启服务。一般守护进程的代码会远比服务进程的代码简单,从概率上可以保证守护进程更难挂掉。如果再做得严谨一些,甚至守护进程自身可以在自己挂掉时重启自己,从而实现双保险。

因此在本次迭代时,我们先利用NodeJS的进程管理机制,将守护进程作为父进程,将服务器程序作为子进程,并让父进程监控子进程的运行状态,在其异常退出时重启子进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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));

此外,服务器代码本身的入口函数也要做以下调整

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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服务再正常退出。至此,我们的服务器程序就靠谱很多了。

第四次迭代

在我们解决了服务器本身的功能、性能和可靠性的问题后,接着我们需要考虑一下代码部署的问题,以及服务器控制的问题。

一般而言,程序在服务器上有一个固定的部署目录,每次程序有更新后,都重新发布到部署目录里。而一旦完成部署后,一般也可以通过固定的服务控制脚本启动和停止服务。因此我们的服务器程序部署目录可以做如下设计。

1
2
3
4
5
6
7
8
9
- deploy/
- bin/
startws.sh
killws.sh
+ conf/
config.json
+ lib/
daemon.js
server.js

在以上目录结构中,我们分类存放了服务控制脚本、配置文件和服务器代码。

按以上目录结构分别存放对应的文件之后,接下来我们看看控制脚本怎么写。首先是start.sh。

1
2
3
4
5
6
#!/bin/sh
if [ ! -f "pid" ]
then
node ../lib/daemon.js ../conf/config.json &
echo $! > pid
fi

然后是killws.sh。

1
2
3
4
5
6
#!/bin/sh
if [ -f "pid" ]
then
kill $(tr -d '\r\n' < pid)
rm pid
fi