进程管理
NodeJS可以感知和控制自身进程的运行环境和状态,也可以创建子进程并与其协同工作,这使得NodeJS可以把多个程序组合在一起共同完成某项工作,并在其中充当胶水和调度器的作用。本章除了介绍与之相关的NodeJS内置模块外,还会重点介绍典型的使用场景。
开门红
我们已经知道了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) {
// ...
});
|
从以上代码中可以看到,子进程是异步运行的,通过回调函数返回执行结果。
API走马观花
我们先大致看看NodeJS提供了哪些和进程管理有关的API。这里并不逐一介绍每个API的使用方法,官方文档已经做得很好了。
Process
任何一个进程都有启动进程时使用的命令行参数,有标准输入标准输出,有运行权限,有运行环境和运行状态。在NodeJS中,可以通过process
对象感知和控制NodeJS自身进程的方方面面。另外需要注意的是,process
不是内置模块,而是一个全局对象,因此在任何地方都可以直接使用。
Child Process
使用child_process
模块可以创建和控制子进程。该模块提供的API中最核心的是.spawn
,其余API都是针对特定使用场景对它的进一步封装,算是一种语法糖。
Cluster
cluster
模块是对child_process
模块的进一步封装,专用于解决单进程NodeJS Web服务器无法充分利用多核CPU的问题。使用该模块可以简化多进程服务器程序的开发,让每个核上运行一个工作进程,并统一通过主进程监听端口和分发请求。
应用场景
和进程管理相关的API单独介绍起来比较枯燥,因此这里从一些典型的应用场景出发,分别介绍一些重要API的使用方法。
如何获取命令行参数
在NodeJS中可以通过process.argv
获取命令行参数。但是比较意外的是,node
执行程序路径和主模块文件路径固定占据了argv[0]
和argv[1]
两个位置,而第一个命令行参数从argv[2]
开始。为了让argv
使用起来更加自然,可以按照以下方式处理。
1
2
3
4
5
|
function
main(argv) {
// ...
}
main(process.argv.slice(2));
|
如何退出程序
通常一个程序做完所有事情后就正常退出了,这时程序的退出状态码为0
。或者一个程序运行时发生了异常后就挂了,这时程序的退出状态码不等于0
。如果我们在代码中捕获了某个异常,但是觉得程序不应该继续运行下去,需要立即退出,并且需要把退出状态码设置为指定数字,比如1
,就可以按照以下方式:
1
2
3
4
5
6
|
try
{
// ...
}
catch
(err) {
// ...
process.exit(1);
}
|
如何控制输入输出
NodeJS程序的标准输入流(stdin)、一个标准输出流(stdout)、一个标准错误流(stderr)分别对应process.stdin
、process.stdout
和process.stderr
,第一个是只读数据流,后边两个是只写数据流,对它们的操作按照对数据流的操作方式即可。例如,console.log
可以按照以下方式实现。
1
2
3
4
|
function
log() {
process.stdout.write(
util.format.apply(util, arguments) +
'\n'
);
}
|
如何降权
在*nix系统下,我们知道需要使用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_UID
和SUDO_GID
里边。如果是通过chmod +s
方式获取root权限的,运行程序的用户的UID和GID可直接通过process.getuid
和process.getgid
方法获取。 -
process.setuid
和process.setgid
方法只接受number
类型的参数。 -
降权时必须先降GID再降UID,否则顺序反过来的话就没权限更改程序的GID了。
如何创建子进程
以下是一个创建NodeJS子进程的例子。
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
字段的不同配置,可以将子进程的输入输出重定向到任何数据流上,或者让子进程共享父进程的标准输入输出流,或者直接忽略子进程的输入输出。
进程间如何通讯
在*nix系统下,进程之间可以通过信号互相通信。以下是一个例子。
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'
);
|
可以看到,工作进程非正常退出时,守护进程立即重启工作进程。
小结
本章介绍了使用NodeJS管理进程时需要的API以及主要的应用场景,总结起来有以下几点:
-
使用
process
对象管理自身。 -
使用
child_process
模块创建和管理子进程。
异步编程
NodeJS最大的卖点——事件机制和异步IO,对开发者并不是透明的。开发者需要按异步方式编写代码才用得上这个卖点,而这一点也遭到了一些 NodeJS反对者的抨击。但不管怎样,异步编程确实是NodeJS最大的特点,没有掌握异步编程就不能说是真正学会了NodeJS。本章将介绍与异步编程相关的各种知识。
回调
在代码中,异步编程的直接体现就是回调。异步编程依托于回调来实现,但不能说使用了回调后程序就异步化了。我们首先可以看看以下代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
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主线程,那情况又不一样了。我们接着看看以下代码。
1
2
3
4
5
6
7
8
9
|
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主线程空闲时才能开始执行。以下就是这么一个例子。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
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主线程忙于运行其它代码,实际执行时间被大幅延迟。
代码设计模式
异步编程有很多特有的代码设计模式,为了实现同样的功能,使用同步方式和异步方式编写的代码会有很大差异。以下分别介绍一些常见的模式。
函数返回值
使用一个函数的输出作为另一个函数的输入是很常见的需求,在同步方式下一般按以下方式编写代码:
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.
}));
|
可以看到,以上代码在异步函数执行一次并返回执行结果后才传入下一个数组成员并开始下一轮执行,直到所有数组成员处理完毕后,通过回调的方式触发后续代码的执行。
如果数组成员可以并行处理,但后续代码仍然需要所有数组成员处理完毕后才能执行的话,则异步代码会调整成以下形式:
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..
,只能用于同步执行的代码。以下是一个例子。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
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
语句,就作为一个全局异常抛出。以下是一个例子。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
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
语句把异常捕获住,并通过回调函数传递被捕获的异常。于是我们可以像下边这样改造上边的例子。
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
|
可以看到,异常再次被捕获住了。在NodeJS中,几乎所有异步API都按照以上方式设计,回调函数中第一个参数都是err
。因此我们在编写自己的异步函数时,也可以按照这种方式来处理异常,与NodeJS的设计风格保持一致。
有了异常处理方式后,我们接着可以想一想一般我们是怎么写代码的。基本上,我们的代码都是做一些事情,然后调用一个函数,然后再做一些事情,然后再调用一个函数,如此循环。如果我们写的是同步代码,只需要在代码入口点写一个try
语句就能捕获所有冒泡上来的异常,示例如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function
main() {
// Do something.
syncA();
// Do something.
syncB();
// Do something.
syncC();
}
try
{
main();
}
catch
(err) {
// Deal with exception.
}
|
但是,如果我们写的是异步代码,就只有呵呵了。由于每次异步函数调用都会打断代码执行路径,只能通过回调函数来传递异常,于是我们就需要在每个回调函数里判断是否有异常发生,于是只用三次异步函数调用,就会产生下边这种代码。
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.
}
});
|
可以看到,回调函数已经让代码变得复杂了,而异步方式下对异常的处理更加剧了代码的复杂度。如果NodeJS的最大卖点最后变成这个样子,那就没人愿意用NodeJS了,因此接下来会介绍NodeJS提供的一些解决方案。
域(Domain)
NodeJS提供了domain
模块,可以简化异步代码的异常处理。在介绍该模块之前,我们需要首先理解“域”的概念。简单的讲,一个域就是一个JS运行环境,在一个运行环境中,如果一个异常没有被捕获,将作为一个全局异常被抛出。NodeJS通过process
对象提供了捕获全局异常的方法,示例代码如下
1
2
3
4
5
6
7
8
9
10
|
process.on(
'uncaughtException'
,
function
(err) {
console.log(
'Error: %s'
, err.message);
});
setTimeout(
function
(fn) {
fn();
});
-- Console ------------------------------
Error: undefined is not a
function
|
虽然全局异常有个地方可以捕获了,但是对于大多数异常,我们希望尽早捕获,并根据结果决定代码的执行路径。我们用以下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
|
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
事件统一捕获。于是以上代码可以做如下改造:
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
|
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文件合并请求。
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));
|
以上代码完整实现了服务器所需的功能,并且有以下几点值得注意:
-
使用命令行参数传递JSON配置文件路径,入口函数负责读取配置并创建服务器。
-
入口函数完整描述了程序的运行逻辑,其中解析URL和合并文件的具体实现封装在其它两个函数里。
-
解析URL时先将普通URL转换为了文件合并URL,使得两种URL的处理方式可以一致。
-
合并文件时使用异步API读取文件,避免服务器因等待磁盘IO而发生阻塞。
我们可以把以上代码保存为server.js
,之后就可以通过node server.js config.json
命令启动程序,于是我们的第一版静态文件合并服务器就顺利完工了。
另外,以上代码存在一个不那么明显的逻辑缺陷。例如,使用以下URL请求服务器时会有惊喜。
1
|
http:
//assets.example.com/foo/bar.js,foo/baz.js
|
经过分析之后我们会发现问题出在/
被自动替换/??
这个行为上,而这个问题我们可以到第二次迭代时再解决。
第二次迭代
在第一次迭代之后,我们已经有了一个可工作的版本,满足了功能需求。接下来我们需要从性能的角度出发,看看代码还有哪些改进余地。
设计
把map
方法换成for
循环或许会更快一些,但第一版代码最大的性能问题存在于从读取文件到输出响应的过程当中。我们以处理/??a.js,b.js,c.js
这个请求为例,看看整个处理过程中耗时在哪儿。
1
2
3
4
5
6
7
8
|
发送请求 等待服务端响应 接收响应
---------+----------------------+------------->
-- 解析请求
------ 读取a.js
------ 读取b.js
------ 读取c.js
-- 合并数据
-- 输出响应
|
可以看到,第一版代码依次把请求的文件读取到内存中之后,再合并数据和输出响应。这会导致以下两个问题:
-
当请求的文件比较多比较大时,串行读取文件会比较耗时,从而拉长了服务端响应等待时间。
-
由于每次响应输出的数据都需要先完整地缓存在内存里,当服务器请求并发数较大时,会有较大的内存开销。
对于第一个问题,很容易想到把读取文件的方式从串行改为并行。但是别这样做,因为对于机械磁盘而言,因为只有一个磁头,尝试并行读取文件只会造成磁头频繁抖动,反而降低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));
}
|
可以看到,第二版代码在检查了请求的所有文件是否有效之后,立即就输出了响应头,并接着一边按顺序读取文件一边输出响应内容。并且,在读取文件时,第二版代码直接使用了只读数据流来简化代码。
第三次迭代
第二次迭代之后,服务器本身的功能和性能已经得到了初步满足。接下来我们需要从稳定性的角度重新审视一下代码,看看还需要做些什么。
设计
从工程角度上讲,没有绝对可靠的系统。即使第二次迭代的代码经过反复检查后能确保没有bug,也很难说是否会因为NodeJS本身,或者是操作系统本身,甚至是硬件本身导致我们的服务器程序在某一天挂掉。因此一般生产环境下的服务器程序都配有一个守护进程,在服务挂掉的时候立即重启服务。一般守护进程的代码会远比服务进程的代码简单,从概率上可以保证守护进程更难挂掉。如果再做得严谨一些,甚至守护进程自身可以在自己挂掉时重启自己,从而实现双保险。
因此在本次迭代时,我们先利用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
|
于是这样我们就有了一个简单的代码部署目录和服务控制脚本,我们的服务器程序就可以上线工作了。
后续迭代
我们的服务器程序正式上线工作后,我们接下来或许会发现还有很多可以改进的点。比如服务器程序在合并JS文件时可以自动在JS文件之间插入一个;
来避免一些语法问题,比如服务器程序需要提供日志来统计访问量,比如服务器程序需要能充分利用多核CPU,等等。而此时的你,在学习了这么久NodeJS之后,应该已经知道该怎么做了。
小结
本章将之前零散介绍的知识点串了起来,完整地演示了一个使用NodeJS开发程序的例子,至此我们的课程就全部结束了。以下是对新诞生的NodeJSer的一些建议。
-
要熟悉官方API文档。并不是说要熟悉到能记住每个API的名称和用法,而是要熟悉NodeJS提供了哪些功能,一旦需要时知道查询API文档的哪块地方。
-
要先设计再实现。在开发一个程序前首先要有一个全局的设计,不一定要很周全,但要足够能写出一些代码。
-
要实现后再设计。在写了一些代码,有了一些具体的东西后,一定会发现一些之前忽略掉的细节。这时再反过来改进之前的设计,为第二轮迭代做准备。
-
要充分利用三方包。NodeJS有一个庞大的生态圈,在写代码之前先看看有没有现成的三方包能节省不少时间。
-
不要迷信三方包。任何事情做过头了就不好了,三方包也是一样。三方包是一个黑盒,每多使用一个三方包,就为程序增加了一份潜在风险。并且三方包很难恰好只提供程序需要的功能,每多使用一个三方包,就让程序更加臃肿一些。因此在决定使用某个三方包之前,最好三思而后行。