【深入浅出Node.js系列九】一起撸Node.js

1 基本语法

Node.js 的基础是 JavaScript 这门 脚本语言。而大多数的脚本语言一个共同的特点就是“弱类型”

不同于 PHP 的是,PHP 就是是有了新变量也无需申明,而 JavaScript 则还是需要 var 来申明一下的。而这个 var 涵盖了 C++ 中的int、string、char等一切类型的含义,甚至是 function。

1.1 变量声明

function foo() {}

var a = 0;
var b = 'a';
var c = 1.0;
var d = foo;

所以,无论是什么类型的变量,在 Node.js 中都是以一个 var 来解决的。

1.2 循环语句

1.2.1 for...i

鉴于 Node.js 是弱类型,所以只需要:

for(var i = 0; i < foo; i++) {
    // ...
}

1.2.2 for...in

比如我们有一个 JSON对象 如下:

var foo = {
    "hello"     : "world",
    "node"      : "js",
    "blahblah"  : "bar"
};

这个时候我们就可以用 for...in 来循环遍历了:

for(var key in foo) {
    console.log(key + ": " + foo[key]);
}

我们如果在命令行中打入下面的命令:

$ node foo.js

屏幕上就会显示下面的内容了:

hello: world
node: js
blahblah: bar

提示:由上可知,for...in 语句是用来遍历 JSON对象、数组、对象的键名的,而不提供键值的遍历。如果要获取键值,只能通过foo[<当前键名>]的形式来获取。

1.2.3 while...do,do...while

这个就不多做解释了,跟其它语言没什么大的区别,无非就是如果有变量声明的话,需要用 var 就够了

1.3 运算符

1.3.1 +, -, *, /

这几个运算符也就这样,要注意的是 +它既可以作用于字符串,也可以作用于数值运算。弱类型语言虽然说类型是弱的,数字有时候可以以字符串的形态出现,字符串有时候可以用数值的形态出现,但是在必要的时候也还是要说一下它是什么类型的,我们可以用下面的代码去看看结果:

var a = "1";
var b = 2;
console.log(a + b);
// 这里的 parseInt 是 Node.js 的一个内置函数,作用是将一个字符串解析成 int 类型的变量。
console.log(parseInt(a) + b);

上面的代码执行结果是

12
3

第一个 console.log 结果是 12,由于 a 是字符串,所以 b 也被系统以字符串的姿态进行加操作,结果就是将两个字符串黏连在一起就变成了 12。而第二个 console.log 结果是 3,是因为我们将第一个 a 转变为了 int 类型,两个 int 型的变量相加即数值相加,结果当然就是 3 了。

1.3.2 ==, ===, !=, !==

这里有一点要解释,当这个逻辑运算符长度为 2 的时候(==, !=),只是判断外在的值是不是一样的,而不会判断类型。如:

var a = 1, b = "1";
console.log(a == b);

它输出的结果就是 true。但是如果我们在中间判断的时候再加上一个等号,那么就是严格判断了,需要类型和值都一样的时候才会是 true,否则就是 false。也就是说:

var a = 1, b = "1";
console.log(a === b);

的时候,返回的结果就是 false 了,因为 a 是 int 型的,而 b 则是字符串

顺带着就把条件语句讲了吧,其实这里的 if 跟别的语言没什么两样,就是几个逻辑运算符两个等号三个等号的问题。所以就不多做累述了。

1.3.3 typeof

这里我姑且把它当成是一个运算符而不是函数了。这个运算符的作用是判断一个变量的类型,会返回一个字符串,即类型名,具体的执行下面的代码就知道了:

function foo() {}

var a = 0;
var b = '嘘~蛋花汤在睡觉。';
var c = 1.0;
var d = foo;
var e = { "a" : a };
var f = [ 1, 2, 3 ];
var g = null;
var h = undefined;

console.log(typeof a);
console.log(typeof b);
console.log(typeof c);
console.log(typeof d);
console.log(typeof e);
console.log(typeof f);
console.log(typeof g);
console.log(typeof h);

这里的执行结果就将会是:

number
string
number
function
object object object undefined

1.4 null, undefined, NaN

在 JavaScript 中,有三个特殊的值,如标题所示。其中第一个大家可能都比较熟悉吧,C/C++ 里面也有,不过是大写的,其本质就是一个:

#define NULL 0

而在 JavaScript 中,这三个值所代表的意义都不同。

1.4.1 null

null 是一种特殊的 object,大致的意思就是空。比如说:

var a = null;

大家都能看懂,就不多做解释了。但是跟 C/C++ 不同的是,这个 null 跟 0 不相等

1.4.2 undefined

这个东西的意思就是说这个变量未声明。为了能够更好地区分 null,我们的样例代码如下写:

var a = {
    "foo" : null
};
console.log(a["foo"]);
console.log(a["bar"]);

上面的代码中,我们让 a["foo"] 的值为空,即 null。而压根没有声明 a["bar"] 这个东西,它连空都不是。输出的结果大家都差不多应该猜到了:

null
undefined

1.4.3 NaN

这是一个空的数值,是一个特殊的 number。它的全称是 Not a Number。有点奇怪,大家可以理解为 不是数字形态,或者数值出错的 number 类型变量。多在浮点型数值运算错误(如被0除)的情况下出现,甚至可以是用户自己让一个变量等于 NaN 以便返回一个错误值让大家知道这个函数运算出错了云云。

1.5 小杂碎

其它剩余的语句也跟已存在的其它语言差不多,比如说 break 啊、switch 啊、continue 啊等等等等。

2 变量类型

这一节主要讲的是 JavaScript 对象,其它类型差不多一带而过吧。

2.1 基础类型

Node.js 包含的基础类型差不多有如下几个:

number

string

boolean

array

其中前三种类型可以直接赋值,而 array 的赋值只是一个引用赋值而已,在新变量中改变某个值的话旧变量的值也会改变,直接可以试试下面的代码:

var foo = [ 1, 2, 3 ];
var bar = foo;
bar[0] = 3;
console.log(foo);

它得出的结果是:

[ 3, 2, 3 ]

也就是说 array 要是复制出一个新的数组的话,不能用直接赋值的方法,而必须“深拷贝”。这里有必要讲一下 array 的三种创建方法:

// 第一种:
var dog = new Array();
dog[0] = "嘘~";
dog[1] = "蛋花汤";
dog[2] = "在睡觉";
// 第二种:
var dog = new Array( "嘘~", "蛋花汤", "在睡觉" );
// 第三种:
var dog = [
    "嘘~",
    "蛋花汤",
    "在睡觉"
];

2.2 JSON对象

这里我把 JSON对象 单独拎出来而不是把它归类为 JavaScript对象,如果觉得我有点误人子弟就可以直接跳过这一节了。本人对于 JSON对象 和 JavaScript 对象的区分放在 是否只用来存储数据,而并非是一个类的实例化。其实 JSON 的本质便是 JavaScript Object Notation

在 Node.js 中声明一个 JSON对象 非常简单:

var dog = {
    "pre"       : "嘘~",
    "sub"       : {
        "name"  : "蛋花汤",
        "act"   : "在睡觉",
        "time"  : 12
    },

    "suf"       : [ "我说了", "它在睡觉", "就是在睡觉" ]
};

有两种方式能得到 JSON对象 中的某个键名的键值,第一种是用点连接,第二种是用中括号

dog.pre;
dog["pre"];

2.3 类(对象)的基础

严格意义上来讲,Node.js 的类不能算是类,其实它只是一个函数的集合体,加一些成员变量。它的本质其实是一个函数。不过为了通俗地讲,我们接下去以及以后都将其称为“类”,实例化的叫“对象”。

因为类有着很多 函数 的特性,或者说它的本质就是一个 函数,所以这里面我们可能一不留神就顺带着把函数基础给讲了。

2.3.1 类的声明和实例化

声明一个类非常简单,大家不要笑:

function foo() {
    //...
}

好了,我们已经写好了一个 foo 类了。不信?不信你可以接下去打一段代码看看:

var bar = new foo();

别看它是一个函数,如果以这样的形式(new)写出来,它就是这个类的实例化。而这个所谓的 foo() 其实就是这个 foo() 类的构造函数。

2.3.2 成员变量

成员变量有两种方法。

第一种就是在类的构造函数或者任何构造函数中使用 this.<变量名>。你可以在任何时候声明一个成员变量,在外部不影响使用,反正就算在还未声明的时候使用它,也会有一个 undefined 来撑着。所以说这就是第一种方法:

function foo() {
    this.hello = "world";
}

注意:只有在加了 this 的时候才是调用类的成员变量,否则只是函数内的一个局部变量而已。要分清楚有没有 this 的时候变量的作用范围。

第二种方法就是在构造函数或者任何成员函数外部声明,其格式是 <类名>.prototype.<变量名>

function foo() {
    //...
}

foo.prototype.hello = "world";

无论上面哪种方法都是对成员变量的声明,我们可以看看效果:

var bar = new foo();
console.log(bar.hello);

甚至你可以这么修改这个类:

function foo() {
    this.hello = "world";
}

foo.prototype.hello = "蛋花汤";

然后再用上面的代码输出。想想看为什么输出的还是 world 而不是 蛋花汤?

2.3.3 构造函数

我们之前说过了这个 foo() 实际上是一个 构造函数。那么显然我们可以给构造函数传参数,所以就有了下面的代码:

// 代码2.1
function foo(hello) {
    if(hello === undefined) {
        this.hello = "world";
    } else {
        this.hello = hello;
    }
}

我们看到上面有一个奇葩的判断 if(hello === undefined),这个判断有什么用呢?第一种可能,就是开发者很蛋疼地特意传进去一个 undefined 进去,这个时候它是 undefined 无可厚非。

还有一种情况。我们一开始就说了 JavaScript 是一门弱类型语言,其实不仅仅是弱类型,它的传参数也非常不严谨。你可以多传或者少传(只要保证你多传或者少传的时候可以保证程序不出错,或者逻辑不出错),原则上都是可以的。多传的参数会被自动忽略,而少传的参数会以 undefined 补足

看看下面的代码就明白了:

// 上接代码2.1
var bar1 = new foo();
var bar2 = new foo("蛋花汤");

请自行输出一下两个 bar 的 hello 变量,会发现一个是 world 一个是 蛋花汤。显而易见,我们的第一个 bar1 在声明的时候,被 Node.js 自动看成了:

var bar1 = new foo(undefined);

所以就有了它是 world 一说。

还有就是在这个构造函数中,我们看到了传进去的参数是 hello 而这个类中本来就有个成员变量就是 this.hello。不过我们之前说过了有 this 和没 this 的时候作用域不同,那个参数只是作用于构造函数中,而加了 this 的那个则是成员变量。用一个 this 就马上区分开来他们了,所以即使同名也没关系。

2.3.4 成员函数

2.3.4.1 成员函数声明

成员函数的声明跟成员变量的第二种声明方法差不多,即 <类名>.prototype.<函数名> = <函数>;

// 上接代码2.1
function setHello(hello) {
    this.hello = hello;
}
foo.prototype.setHello = setHello;

bar1.setHello("鸡蛋饼");

上面这段代码显而易见,我们实现了 foo 类的 setHello 函数,能通过它修改 foo.hello 的值。但是这么写是不是有点麻烦?接下去我要讲一个 JavaScript 函数重要的特性了。

2.3.4.2 ★ 匿名函数

很多时候我们的某些函数只在一个地方被引用或者调用,那么我们为这个函数起一个名字就太不值了,没必要,所以我们可以临时写好这个函数,直接让引用它的人引用它,调用它的人调用它。所以函数可以省略函数名,如:

function(hello) {
    this.hello = hello;
}

至于怎么引用或者调用呢?如果是上面的那个类需要引用的话,就是写成这样的:

foo.prototype.setHello = function(hello) {
    this.hello = hello;
}

这样的写法跟 2.3.4.1 成员函数声明 是一个效果的,而且省了很多的代码量。而且实际上,基本上的类成员函数的声明都是采用这种匿名函数的方式来声明的。

至于说怎么样让匿名函数被调用呢?这通常用于传入一个只被某个函数调用的函数时这样写。比如我们有一个函数的原型是:

/** * 我们将传入a,b两个变量, * 在算出a+b的值后,交由func(num) * 去进行输出 */
function sumab(a, b, func) {
    var c = a + b;
    func(a, b, c); } 

比如我们有两个版本的输出函数,一个是中文输出,一个是英文输出,那么如果不用匿名函数时候是这么写的:

function zh(a, b, sum) {
    console.log(a + " + " + b + " 的值是:" + sum);
}

function en(a, b, sum) {
    console.log(a + " plus " + b + " is " + sum);
}

sumab(1, 2, zh);
sumab(3, 4, en);

执行一遍这段代码,输出的结果将会是:

1 + 2 的值是:3
3 plus 4 is 7

这样的代码如果采用匿名函数的形式则将会是:

sumab(1, 2, function(a, b, sum) {
    console.log(a + " + " + b + " 的值是:" + sum);
});
sumab(3, 4, function(a, b, sum) {
    console.log(a + " plus " + b + " is " + sum);
});

这种形式通常适用于回调函数。回调机制算是 Node.js 或者说 JavaScript 的精髓。在以后的篇章会做介绍。

2.3.4.3 成员函数声明的匿名函数声明方式

通常我们声明类的成员函数时候都是用匿名函数来声明的,因为反正那个函数也就是这个类的一个成员函数而已,不会在其它地方被单独引用或者调用,所以就有了下面的代码:

// 上接代码2.1
foo.prototype.setHello = function(hello) {
    this.hello = hello;
}

这样我们就使得 foo 类有了 setHello 这个函数了。

2.3.5 类的随意性

这个又是我胡扯的。所谓类的随意性即 JavaScript 中你可以在任何地方修改你的类,这跟 Ruby 有着一定的相似之处。

比如说 string ,它其实也是一个类,有着诸如 length 这样的成员变量,也有 indexOf、substr 等成员函数。但是万一我们觉得这个 string 有些地方不完善,想加自己的方法,那么可以在你想要的地方给它增加一个函数,比如:

String.prototype.sb = function() {
    var newstr = "";
    for(var i = 0; i < this.length; i++) {
        if(i % 2 === 0) newstr += "s";
        else newstr += "b";
    }

    return newstr;
};

这个函数的意思就是填充一个字符串,使其变成 sb 的化身。我们来测试一下:

var str = "嘘~蛋花汤在睡觉。";
console.log(str.sb());

你将会得到这样的结果:

sbsbsbsbs

3 深拷贝

所谓深拷贝就是自己新建一个数组或者对象,把源数组或者对象中的基础类型变量值一个个手动拷过去,而不是只把源数组或者对象的引用拿过来。所以这就涉及到了一个递归的调用什么的

下面是实现的一个深拷贝函数,大家可以写一个自己的然后加入到自己的 Node.js 知识库中。

function cloneObject(src) {
    var dest = {};
    for(var key in src) {
        if(typeof src === "object") dest[key] = cloneObject(src[key]);
        else dest[key] = src[key];
    }

    return dest;
}

系统默认对象参考手册:

字符串:http://www.w3school.com.cn/js/jsref_obj_string.asp

数字:http://www.w3school.com.cn/js/jsref_obj_number.asp

数组:http://www.w3school.com.cn/js/jsref_obj_array.asp

布尔:http://www.w3school.com.cn/js/jsref_obj_boolean.asp

日期:http://www.w3school.com.cn/js/jsref_obj_date.asp

数学库:http://www.w3school.com.cn/js/jsref_obj_math.asp

4 模块

编写稍大一点的程序时一般都会将代码模块化。在NodeJS中,一般将代码合理拆分到不同的JS文件中,每一个文件就是一个模块,而文件路径就是模块名。在编写每个模块时,都有require、exports、module三个预先定义好的变量可供使用。

4.1 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中保存的是同一个模块的导出对象。

另外,可以使用以下方式加载和使用一个JSON文件:

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

4.2 exports

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

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

4.3 module

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

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

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

4.4 模块初始化

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

4.5 主模块

通过命令行参数传递给NodeJS以启动程序的模块被称为主模块主模块负责调度组成整个程序的其它模块完成工作。例如通过以下命令启动程序时,main.js就是主模块。

$ node main.js

4.6 完整示例

例如有以下目录:

- /home/user/hello/     - util/         counter.js
    main.js

其中counter.js内容如下:该模块内部定义了一个私有变量i,并在exports对象导出了一个公有方法count。

var i = 0;

function count() {
    return ++i;
}

exports.count = 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());

运行该程序的结果如下:可以看出,counter.js并没有因为被require了两次而初始化两次

$ node main.js
1
2
3

5 二进制模块

虽然一般我们使用JS编写模块,但NodeJS也支持使用C/C++编写二进制模块。编译好的二进制模块除了文件扩展名是.node外,和JS模块的使用方式相同。虽然二进制模块能使用操作系统提供的所有功能,拥有无限的潜能,但对于前端同学而言编写过于困难,并且难以跨平台使用,因此不在本教程的覆盖范围内。

  • NodeJS是一个JS脚本解析器,任何操作系统下安装NodeJS本质上做的事情都是把NodeJS执行程序复制到一个目录,然后保证这个目录在系统PATH环境变量下,以便终端下可以使用node命令。

  • 终端下直接输入node命令可进入命令交互模式,很适合用来测试一些JS代码片段,比如正则表达式。

  • NodeJS使用CMD模块系统,主模块作为程序入口点,所有模块在执行过程中只初始化一次

  • 除非JS模块不能满足需求,否则不要轻易使用二进制模块,否则你的用户会叫苦连天。

6 代码的组织和部署

有经验的C程序员在编写一个新程序时首先从make文件写起。同样的,使用NodeJS编写程序前,为了有个良好的开端,首先需要准备好代码的目录结构和部署方式,就如同修房子要先搭脚手架。

6.1 模块路径解析规则

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

  1. 内置模块

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

  2. 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

  3. NODE_PATH环境变量

    与PATH环境变量类似,NodeJS允许通过NODE_PATH环境变量来指定额外的模块搜索路径。NODE_PATH环境变量中包含一到多个目录路径,路径之间在Linux下使用:分隔,在Windows下使用;分隔。例如定义了以下NODE_PATH环境变量:

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

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

    /home/user/lib/foo/bar

    /home/lib/foo/bar

6.2 包(package)

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

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

- /home/user/lib/
    - cat/
        head.js
        body.js
        main.js

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

var head = require('./head');
var body = require('./body');

exports.create = function (name) {
    return {
        name: name,
        head: head.create(),
        body: body.create()
    };
};

在其它模块里使用包的时候,需要加载包的入口模块。接着上例,使用require('/home/user/lib/cat/main')能达到目的,但是入口模块名称出现在路径里看上去不是个好主意。因此我们需要做点额外的工作,让包使用起来更像是单个模块。

  1. index.js

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

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

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

  2. package.json

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

    - /home/user/lib/
     - cat/
         + doc/
         - lib/
             head.js
             body.js
             main.js
         + tests/
         package.json
    

    其中package.json内容如下:

    {
      "name": "cat",
      "main": "./lib/main.js" }
    

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

6.3 命令行程序

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

例如我们用NodeJS写了个程序,可以把命令行参数原样打印出来。该程序很简单,在主模块内实现了所有功能。并且写好后,我们把该程序部署在/home/user/bin/node-echo.js这个位置。为了在任何目录下都能运行该程序,我们需要使用以下终端命令:

$ node /home/user/bin/node-echo.js Hello World

----------------------------------------------
Hello World

这种使用方式看起来不怎么像是一个命令行程序,下边的才是我们期望的方式:

$ node-echo Hello World

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

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

    #! /usr/bin/env node
    

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

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

    $ chmod +x /home/user/bin/node-echo.js
    
  • (3) 最后,我们在PATH环境变量中指定的某个目录下,例如在/usr/local/bin下边创建一个软链文件,文件名与我们希望使用的终端命令同名,命令如下:

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

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

6.4 工程目录

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

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

其中部分文件内容如下:

/* bin/node-echo */
#! /usr/bin/env node

var argv = require('argv');
var echo = require('../lib/echo');
console.log(echo(argv.join(' ')));

/* lib/echo.js */
module.exports = function (message) {
    return message;
};

/* package.json */
{
    "name": "node-echo",
    "main": "./lib/echo.js"
}

以上例子中分类存放了不同类型的文件,并通过node_moudles目录直接使用三方包名加载模块。此外,定义了package.json之后,node-echo目录也可被当作一个包来使用。

6.5 NPM

NPM是随同NodeJS一起安装的包管理工具,能解决NodeJS代码部署上的很多问题,常见的使用场景有以下几种:

允许用户从NPM服务器下载别人编写的三方包到本地使用。

允许用户从NPM服务器下载并安装别人编写的命令行程序到本地使用。

允许用户将自己编写的包或命令行程序上传到NPM服务器供别人使用。

可以看到,NPM建立了一个NodeJS生态圈,NodeJS开发者和用户可以在里边互通有无。以下分别介绍这三种场景下怎样使用NPM。

  1. 下载三方包

    需要使用三方包时,首先得知道有哪些包可用。虽然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/
             ...
     ...
    

    如此一来,用户只需关心自己直接使用的三方包,不需要自己去解决所有包的依赖关系。

  2. 安装命令行程序

    从NPM服务上下载安装一个命令行程序的方法与三方包类似。例如上例中的node-echo提供了命令行使用方式,只要node-echo自己配置好了相关的package.json字段,对于用户而言,只需要使用以下命令安装程序。

    $ npm install node-echo -g
    

    参数中的-g表示全局安装,因此node-echo会默认安装到以下位置,并且NPM会自动创建好Linux系统下需要的软链文件或Windows系统下需要的.cmd文件

    - /usr/local/               # Linux系统下
     - lib/node_modules/
         + node-echo/
         ...
     - bin/
         node-echo
         ...
     ...
    
    - %APPDATA%\npm\            # Windows系统下
     - node_modules\
         + node-echo\
         ...
     node-echo.cmd
     ...
    
  3. 发布代码

    第一次使用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发布代码了。

  4. 版本号

    使用NPM下载和发布代码时都会接触到版本号。NPM使用语义版本号来管理代码,这里简单介绍一下。语义版本号分为X.Y.Z三位,分别代表主版本号、次版本号和补丁版本号。当代码变更时,版本号按以下原则更新。

    + 如果只是修复bug,需要更新Z位。
    
    + 如果是新增了功能,但是向下兼容,需要更新Y位。
    
    + 如果有大变动,向下不兼容,需要更新X位。
    

    版本号有了这个保证后,在申明三方包依赖时,除了可依赖于一个固定版本号外,还可依赖于某个范围的版本号。例如"argv": "0.0.x"表示依赖于0.0.x系列的最新版argv。NPM支持的所有版本号范围指定方式可以查看官方文档。

  5. 灵机一点

    除了本章介绍的部分外,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>可以撤销发布自己发布过的某个版本代码。

7 文件操作

让前端觉得如获神器的不是NodeJS能做网络编程,而是NodeJS能够操作文件小至文件查找,大至代码编译,几乎没有一个前端工具不操作文件。换个角度讲,几乎也只需要一些数据处理逻辑,再加上一些文件操作,就能够编写出大多数前端工具。本章将介绍与之相关的NodeJS内置模块。

7.1 开门红

NodeJS提供了基本的文件操作API,但是像文件拷贝这种高级功能就没有提供,因此我们先拿文件拷贝程序练手。与copy命令类似,我们的程序需要能接受源文件路径与目标文件路径两个参数。

  1. 小文件拷贝

    我们使用NodeJS内置的fs模块简单实现这个程序如下:

    var fs = require('fs');
    
    function copy(src, dst) {
      fs.writeFileSync(dst, fs.readFileSync(src));
    }
    
    function main(argv) {
      copy(argv[0], argv[1]);
    }
    
    main(process.argv.slice(2));
    

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

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

  2. 大文件拷贝

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

    var fs = require('fs');
    
    function copy(src, dst) {
      fs.createReadStream(src).pipe(fs.createWriteStream(dst));
    }
    
    function main(argv) {
      copy(argv[0], argv[1]);
    }
    
    main(process.argv.slice(2));
    

    以上程序使用fs.createReadStream创建了一个源文件的只读数据流,并使用fs.createWriteStream创建了一个目标文件的只写数据流,并且用pipe方法把两个数据流连接了起来。连接起来后发生的事情,说得抽象点的话,水顺着水管从一个桶流到了另一个桶。

7.2 API走马观花

  1. Buffer(数据块):http://nodejs.org/api/buffer.html

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

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

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

    bin[0]; // => 0x68;
    

    Buffer与字符串能够互相转化,例如可以使用指定编码将二进制数据转化为字符串:

    var str = bin.toString('utf-8'); // => "hello"
    

    或者反过来,将字符串转换为指定编码下的二进制数据:

    var bin = new Buffer('hello', 'utf-8'); // => <Buffer 68 65 6c 6c 6f>
    

    Buffer与字符串有一个重要区别。字符串是只读的,并且对字符串的任何修改得到的都是一个新字符串,原字符串保持不变至于Buffer,更像是可以做指针操作的C语言数组。例如,可以用[index]方式直接修改某个位置的字节:

    bin[0] = 0x48; 

    .slice方法也不是返回一个新的Buffer,而更像是返回了指向原Buffer中间的某个位置的指针,如下所示:

    [ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]
     ^           ^
     |           |
    bin     bin.slice(2)
    

    因此对.slice方法返回的Buffer的修改会作用于原Buffer,例如:

    var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
    var sub = bin.slice(2);
    
    sub[0] = 0x65;
    console.log(bin); // => <Buffer 0x68 0x65 0x65 0x6c 0x6f>
    

    也因此,如果想要拷贝一份Buffer,得首先创建一个新的Buffer,并通过.copy方法把原Buffer中的数据复制过去。这个类似于申请一块新的内存,并把已有内存中的数据复制过去。以下是一个例子:

    var bin = new Buffer([ 0x68, 0x65, 0x6c, 0x6c, 0x6f ]);
    var dup = new Buffer(bin.length);
    
    bin.copy(dup);
    dup[0] = 0x48;
    console.log(bin); // => <Buffer 0x68 0x65 0x6c 0x6c 0x6f>
    console.log(dup); // => <Buffer 0x48 0x65 0x65 0x6c 0x6f>
    

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

  2. Stream(数据流):http://nodejs.org/api/stream.html

    当内存中无法一次装下需要处理的数据时,或者一边读取一边处理更加高效时,我们就需要用到数据流。NodeJS中通过各种Stream来提供对数据流的操作。以上边的大文件拷贝程序为例,我们可以为数据来源创建一个只读数据流,示例如下:

    var rs = fs.createReadStream(pathname);
    
    rs.on('data', function (chunk) { doSomething(chunk); });
    
    rs.on('end', function () { cleanUp(); });
    

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

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

    var rs = fs.createReadStream(src);
    
    rs.on('data', function (chunk) {
      rs.pause();
      doSomething(chunk, function () {
          rs.resume();
      });
    });
    
    rs.on('end', function () {
      cleanUp();
    });
    

    以上代码给doSomething函数加上了回调,因此我们可以在处理数据前暂停数据读取,并在处理数据后继续读取数据。此外,我们也可以为数据目标创建一个只写数据流,示例如下:

    var rs = fs.createReadStream(src);
    var ws = fs.createWriteStream(dst);
    
    rs.on('data', function (chunk) { ws.write(chunk); });
    
    rs.on('end', function () { ws.end(); });
    

    我们把doSomething换成了往只写数据流里写入数据后,以上代码看起来就像是一个文件拷贝程序了。但是以上代码存在上边提到的问题,如果写入速度跟不上读取速度的话,只写数据流内部的缓存会爆仓。我们可以根据.write方法的返回值来判断传入的数据是写入目标了,还是临时放在了缓存了,并根据drain事件来判断什么时候只写数据流已经将缓存中的数据写入目标,可以传入下一个待写数据了。因此代码可以改造如下:

    var rs = fs.createReadStream(src);
    var ws = fs.createWriteStream(dst);
    
    rs.on('data', function (chunk) { if (ws.write(chunk) === false) { rs.pause(); } });
    
    rs.on('end', function () { ws.end(); });
    
    ws.on('drain', function () { rs.resume(); });
    

    以上代码实现了数据从只读数据流到只写数据流的搬运,并包括了防爆仓控制。因为这种使用场景很多,例如上边的大文件拷贝程序,NodeJS直接提供了.pipe方法来做这件事情,其内部实现方式与上边的代码类似

  3. File System(文件系统):http://nodejs.org/api/fs.html

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

    • 文件属性读写:其中常用的有fs.stat、fs.chmod、fs.chown等等。

    • 文件内容读写:其中常用的有fs.readFile、fs.readdir、fs.writeFile、fs.mkdir等等。

    • 底层文件操作:其中常用的有fs.open、fs.read、fs.write、fs.close等等。

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

    fs.readFile(pathname, function (err, data) {
      if (err) {
          // Deal with error.
      } else {
          // Deal with data.
      }
    });
    

    如上边代码所示,基本上所有fs模块API的回调参数都有两个。第一个参数在有错误发生时等于异常对象,第二个参数始终用于返回API方法执行结果。此外,fs模块的所有异步API都有对应的同步版本,用于无法使用异步操作时,或者同步操作更方便时的情况。同步API除了方法名的末尾多了一个Sync之外,异常对象与执行结果的传递方式也有相应变化。同样以fs.readFileSync为例:

    try {
      var data = fs.readFileSync(pathname);
      // Deal with data.
    } catch (err) {
      // Deal with error.
    }
    
  4. Path(路径):http://nodejs.org/api/path.html

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

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

    function store(key, value) { cache[path.normalize(key)] = value; }

    store('foo/bar', 1); store('foo//baz//../bar', 2); console.log(cache); // => { "foo/bar": 2 }

    >**`坑出没注意:`**标准化之后的路径里的斜杠在Windows系统下是\,而在Linux系统下是/。如果想保证任何系统下都使用/作为路径分隔符的话,需要用`.replace(/\\/g, '/')`再替换一下标准路径。
    
    - path.join:将传入的多个路径拼接为标准路径。该方法可避免手工拼接路径字符串的繁琐,并且能在不同系统下正确使用相应的路径分隔符。以下是一个例子:
    

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

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

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

7.3 遍历目录

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

  1. 递归算法

    遍历目录时一般使用递归算法,否则就难以编写出简洁的代码。递归算法与数学归纳法类似,通过不断缩小问题的规模来解决问题。以下示例说明了这种方法。

    function factorial(n) {
      if (n === 1) {
          return 1;
      } else {
          return n * factorial(n - 1);
      }
    }
    

    上边的函数用于计算N的阶乘(N!)。可以看到,当N大于1时,问题简化为计算N乘以N-1的阶乘。当N等于1时,问题达到最小规模,不需要再简化,因此直接返回1。

    陷阱:使用递归算法编写的代码虽然简洁,但由于每递归一次就产生一次函数调用,在需要优先考虑性能时,需要把递归算法转换为循环算法,以减少函数调用次数。

  2. 遍历算法

    目录是一个树状结构,在遍历时一般使用深度优先+先序遍历算法。深度优先,意味着到达一个节点后,首先接着遍历子节点而不是邻居节点先序遍历,意味着首次到达了某节点就算遍历完成,而不是最后一次返回某节点才算数。因此使用这种遍历方式时,下边这棵树的遍历顺序是A > B > D > E > C > F。

            A
           / \
          B   C
         / \   \
        D   E   F
    
  3. 同步遍历

    了解了必要的算法后,我们可以简单地实现以下目录遍历函数。

    function travel(dir, callback) {
      fs.readdirSync(dir).forEach(function (file) {
          var pathname = path.join(dir, file);
    
          if (fs.statSync(pathname).isDirectory()) {
              travel(pathname, callback);
          } else {
              callback(pathname);
          }
      });
    }
    

    可以看到,该函数以某个目录作为遍历的起点。遇到一个子目录时,就先接着遍历子目录。遇到一个文件时,就把文件的绝对路径传给回调函数。回调函数拿到文件路径后,就可以做各种判断和处理。因此假设有以下目录:

    - /home/user/   - foo/       x.js
      - bar/       y.js
      z.css
    

    使用以下代码遍历该目录时,得到的输入如下:

    travel('/home/user', function (pathname) {
      console.log(pathname);
    });
    
    ------------------------
    /home/user/foo/x.js
    /home/user/bar/y.js
    /home/user/z.css
    
  4. 异步遍历

    如果读取目录或读取文件状态时使用的是异步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));
      });
    }
    

7.4 文本编码

使用NodeJS编写前端工具时,操作得最多的是文本文件,因此也就涉及到了文件编码的处理问题。我们常用的文本编码有UTF8和GBK两种,并且UTF8文件还可能带有BOM。在读取不同编码的文本文件时,需要将文件内容转换为JS使用的UTF8编码字符串后才能正常处理

  1. BOM的移除

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

     Bytes Encoding ----------------------------
     FE FF UTF16BE
     FF FE UTF16LE
     EF BB BF UTF8
    

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

    function readText(pathname) {
      var bin = fs.readFileSync(pathname);
    
      if (bin[0] === 0xEF && bin[1] === 0xBB && bin[2] === 0xBF) {
          bin = bin.slice(3);
      }
    
      return bin.toString('utf-8');
    }
    
  2. 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');
    }
    
  3. 单字节编码

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

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

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

    1. GBK编码源文件内容:
     var foo = '中文';
    2. 对应字节:
     76 61 72 20 66 6F 6F 20 3D 20 27 D6 D0 CE C4 27 3B
    3. 使用单字节编码读取后得到的内容:
     var foo = '{乱码}{乱码}{乱码}{乱码}';
    4. 替换内容:
     var bar = '{乱码}{乱码}{乱码}{乱码}';
    5. 使用单字节编码保存后对应字节:
     76 61 72 20 62 61 72 20 3D 20 27 D6 D0 CE C4 27 3B
    6. 使用GBK编码读取后得到内容:
     var bar = '中文';
    

    这里的诀窍在于,不管大于0xEF的单个字节在单字节编码下被解析成什么乱码字符,使用同样的单字节编码保存这些乱码字符时,背后对应的字节保持不变。NodeJS中自带了一种binary编码可以用来实现这个方法,因此在下例中,我们使用这种编码来演示上例对应的代码该怎么写:

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

8 网络操作

不了解网络编程的程序员不是好前端,而NodeJS恰好提供了一扇了解网络编程的窗口。通过NodeJS,除了可以编写一些服务端程序来协助前端开发和测试外,还能够学习一些HTTP协议与Socket协议的相关知识,这些知识在优化前端性能和排查前端故障时说不定能派上用场。本章将介绍与之相关的NodeJS内置模块。

8.1 开门红

NodeJS本来的用途是编写高性能Web服务器。我们首先在这里重复一下官方文档里的例子,使用NodeJS内置的http模块简单实现一个HTTP服务器。

var http = require('http');

http.createServer(function (request, response) {
    response.writeHead(200, { 'Content-Type': 'text-plain' });
    response.end('Hello World\n');
}).listen(8124);

以上程序创建了一个HTTP服务器并监听8124端口,打开浏览器访问该端口http://127.0.0.1:8124/就能够看到效果。

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

8.2 API走马观花

我们先大致看看NodeJS提供了哪些和网络操作有关的API。这里并不逐一介绍每个API的使用方法,官方文档已经做得很好了。

  1. HTTP:http://nodejs.org/api/http.html

    'http'模块提供两种使用方式:(1)作为服务端使用时,创建一个HTTP服务器,监听HTTP客户端请求并返回响应。(2)作为客户端使用时,发起一个HTTP客户端请求,获取服务端响应。

    首先我们来看看服务端模式下如何工作。如开门红中的例子所示,首先需要使用.createServer方法创建一个服务器,然后调用.listen方法监听端口。之后,每当来了一个客户端请求,创建服务器时传入的回调函数就被调用一次。可以看出,这是一种事件机制

    HTTP请求本质上是一个数据流,由请求头(headers)和请求体(body)组成。例如以下是一个完整的HTTP请求数据内容:

    POST / HTTP/1.1
    User-Agent: curl/7.26.0
    Host: localhost
    Accept: */*
    Content-Length: 11
    Content-Type: application/x-www-form-urlencoded
    
    Hello World 

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

    http.createServer(function (request, response) {
      var body = [];
    
      console.log(request.method);
      console.log(request.headers);
    
      request.on('data', function (chunk) {
          body.push(chunk);
      });
    
      request.on('end', function () {
          body = Buffer.concat(body);
          console.log(body.toString());
      });
    }).listen(80);
    
    ------------------------------------
    POST
    { 
    'user-agent': 'curl/7.26.0',
    host: 'localhost',
    accept: '*/*',
    'content-length': '11',
    'content-type': 'application/x-www-form-urlencoded'
    }
    
    Hello World
    

    HTTP响应本质上也是一个数据流,同样由响应头(headers)和响应体(body)组成。例如以下是一个完整的HTTP请求数据内容。

    HTTP/1.1 200 OK
    Content-Type: text/plain
    Content-Length: 11
    Date: Tue, 05 Nov 2013 05:31:38 GMT
    Connection: keep-alive
    
    Hello World 

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

    http.createServer(function (request, response) {
      response.writeHead(200, { 'Content-Type': 'text/plain' });
    
      request.on('data', function (chunk) {
          response.write(chunk);
      });
    
      request.on('end', function () {
          response.end();
      });
    }).listen(80);
    

    接下来我们看看客户端模式下如何工作。为了发起一个客户端HTTP请求,我们需要指定目标服务器的位置并发送请求头和请求体,以下示例演示了具体做法。

    var options = {
      hostname: 'www.example.com',
      port: 80,
      path: '/upload',
      method: 'POST',
      headers: {
          'Content-Type': 'application/x-www-form-urlencoded'
      }
    };
    
    var request = http.request(options, function (response) {});
    
    request.write('Hello World');
    request.end();
    

    可以看到,.request方法创建了一个客户端,并指定请求目标和请求头数据。之后,就可以把request对象当作一个只写数据流来写入请求体数据和结束请求。另外,由于HTTP请求中GET请求是最常见的一种,并且不需要请求体,因此http模块也提供了以下便捷API

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

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

    http.get('http://www.example.com/', function (response) {
      var body = [];
    
      console.log(response.statusCode);
      console.log(response.headers);
    
      response.on('data', function (chunk) {
          body.push(chunk);
      });
    
      response.on('end', function () {
          body = Buffer.concat(body);
          console.log(body.toString());
      });
    });
    
    ------------------------------------
    200
    { 
    'content-type': 'text/html',
    server: 'Apache',
    'content-length': '801',
    date: 'Tue, 05 Nov 2013 06:08:41 GMT',
    connection: 'keep-alive'
    }
    <!DOCTYPE html>
    ...
    
  2. HTTPS:http://nodejs.org/api/https.html

    https模块与http模块极为类似,区别在于https模块需要额外处理SSL证书。在服务端模式下,创建一个HTTPS服务器的示例如下。

    var options = {
      key: fs.readFileSync('./ssl/default.key'),
      cert: fs.readFileSync('./ssl/default.cer')
    };
    
    var server = https.createServer(options, function (request, response) {
      // ...
    });
    

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

    另外,NodeJS支持SNI技术,可以根据HTTPS客户端请求使用的域名动态使用不同的证书,因此同一个HTTPS服务器可以使用多个域名提供服务。接着上例,可以使用以下方法为HTTPS服务器添加多组证书。

    server.addContext('foo.com', {
      key: fs.readFileSync('./ssl/foo.com.key'),
      cert: fs.readFileSync('./ssl/foo.com.cer')
    });
    
    server.addContext('bar.com', {
      key: fs.readFileSync('./ssl/bar.com.key'),
      cert: fs.readFileSync('./ssl/bar.com.cer')
    });
    

    在客户端模式下,发起一个HTTPS客户端请求与http模块几乎相同,示例如下。

    var options = {
      hostname: 'www.example.com',
      port: 443,
      path: '/',
      method: 'GET'
    };
    
    var request = https.request(options, function (response) {});
    
    request.end();
    

    但如果目标服务器使用的SSL证书是自制的,不是从颁发机构购买的,默认情况下https模块会拒绝连接,提示说有证书安全问题。在options里加入rejectUnauthorized: false字段可以禁用对证书有效性的检查,从而允许https模块请求开发环境下使用自制证书的HTTPS服务器

  1. URL:http://nodejs.org/api/url.html

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

     href -----------------------------------------------------------------
     host path
     --------------- ----------------------------
    http: // user:pass @ host.com : 8080 /p/a/t/h ?query=string #hash
    ----- ---------   --------   ---- -------- ------------- -----
    protocol     auth     hostname   port pathname     search     hash
     ------------
     query
    

    我们可以使用.parse方法来将一个URL字符串转换为URL对象,示例如下:

    url.parse('http://user:[email protected]: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 */
    
  2. Query String:http://nodejs.org/api/querystring.html

    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='
    */
    
  3. Zlib:http://nodejs.org/api/zlib.html

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

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

    http.createServer(function (request, response) {
      var i = 1024;
      var data = '';
    
      while (i--) {
          data += '.';
      }
    
      if ((request.headers['accept-encoding'] || '').indexOf('gzip') !== -1) {
          zlib.gzip(data, function (err, data) {
              response.writeHead(200, {
                  'Content-Type': 'text/plain',
                  'Content-Encoding': 'gzip'
              });
              response.end(data);
          });
      } else {
          response.writeHead(200, {
              'Content-Type': 'text/plain'
          });
          response.end(data);
      }
    }).listen(80);
    

    接着我们看一个使用zlib模块解压HTTP响应体数据的例子。这个例子中,判断了服务端响应是否使用gzip压缩,并在压缩的情况下使用zlib模块解压响应体数据。

    var options = {
      hostname: 'www.example.com',
      port: 80,
      path: '/',
      method: 'GET',
      headers: {
          'Accept-Encoding': 'gzip, deflate'
      }
    };
    
    http.request(options, function (response) {
      var body = [];
    
      response.on('data', function (chunk) {
          body.push(chunk);
      });
    
      response.on('end', function () {
          body = Buffer.concat(body);
    
          if (response.headers['content-encoding'] === 'gzip') {
              zlib.gunzip(body, function (err, data) {
                  console.log(data.toString());
              });
          } else {
              console.log(data.toString());
          }
      });
    }).end();
    
  4. Net:http://nodejs.org/api/net.html

    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();
    });
    

8.3 灵机一点

  • 问:为什么通过headers对象访问到的HTTP请求头或响应头字段不是驼峰的?

    答:从规范上讲,HTTP请求头和响应头字段都应该是驼峰的。但现实是残酷的,不是每个HTTP服务端或客户端程序都严格遵循规范,所以NodeJS在处理从别的客户端或服务端收到的头字段时,都统一地转换为了小写字母格式,以便开发者能使用统一的方式来访问头字段,例如headers['content-length']。

  • 问:为什么http模块创建的HTTP服务器返回的响应是chunked传输方式的?

    答:因为默认情况下,使用.writeHead方法写入响应头后,允许使用.write方法写入任意长度的响应体数据,并使用.end方法结束一个响应。由于响应体数据长度不确定,因此NodeJS自动在响应头里添加了Transfer-Encoding: chunked字段,并采用chunked传输方式。但是当响应体数据长度确定时,可使用.writeHead方法在响应头里加上Content-Length字段,这样做之后NodeJS就不会自动添加Transfer-Encoding字段和使用chunked传输方式。

  • 问:为什么使用http模块发起HTTP客户端请求时,有时候会发生socket hang up错误?

    答:发起客户端HTTP请求前需要先创建一个客户端。http模块提供了一个全局客户端http.globalAgent,可以让我们使用.request或.get方法时不用手动创建客户端。但是全局客户端默认只允许5个并发Socket连接,当某一个时刻HTTP客户端请求创建过多,超过这个数字时,就会发生socket hang up错误。解决方法也很简单,通过http.globalAgent.maxSockets属性把这个数字改大些即可。另外,https模块遇到这个问题时也一样通过https.globalAgent.maxSockets属性来处理。

9 进程管理

NodeJS可以感知和控制自身进程的运行环境和状态,也可以创建子进程并与其协同工作,这使得NodeJS可以把多个程序组合在一起共同完成某项工作,并在其中充当胶水和调度器的作用。本章除了介绍与之相关的NodeJS内置模块外,还会重点介绍典型的使用场景。

9.1 开门红

我们已经知道了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) {
    // ...
});

从以上代码中可以看到,子进程是异步运行的,通过回调函数返回执行结果

9.2 API走马观花

我们先大致看看NodeJS提供了哪些和进程管理有关的API。这里并不逐一介绍每个API的使用方法,官方文档已经做得很好了。

  1. Process:http://nodejs.org/api/process.html

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

  2. Child Process:http://nodejs.org/api/child_process.html

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

  3. Cluster:http://nodejs.org/api/cluster.html

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

9.3 应用场景

和进程管理相关的API单独介绍起来比较枯燥,因此这里从一些典型的应用场景出发,分别介绍一些重要API的使用方法。

  1. 如何获取命令行参数

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

    function main(argv) {
      // ...
    }
    
    main(process.argv.slice(2));
    
  2. 如何退出程序

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

    try {
      // ...
    } catch (err) {
      // ...
      process.exit(1);
    }
    
  3. 如何控制输入输出

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

    function log() {
     process.stdout.write(util.format.apply(util, arguments) + '\n');
    }
    
  4. 如何降权

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

    http.createServer(callback).listen(80, function () {
     var env = process.env;
     var uid = parseInt(env['SUDO_UID'] || process.getuid(), 10);
     var 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了。

  5. 如何创建子进程

    以下是一个创建NodeJS子进程的例子。

    var child = child_process.spawn('node', [ 'xxx.js' ]);
    
    child.stdout.on('data', function (data) {
      console.log('stdout: ' + data);
    });
    
    child.stderr.on('data', function (data) {
      console.log('stderr: ' + data);
    });
    
    child.on('close', function (code) {
      console.log('child process exited with code ' + code);
    });
    

    上例中使用了.spawn(exec, args, options)方法,该方法支持三个参数。第一个参数是执行文件路径,可以是执行文件的相对或绝对路径,也可以是根据PATH环境变量能找到的执行文件名。第二个参数中,数组中的每个成员都按顺序对应一个命令行参数。第三个参数可选,用于配置子进程的执行环境与行为。

    另外,上例中虽然通过子进程对象的.stdout和.stderr访问子进程的输出,但通过options.stdio字段的不同配置,可以将子进程的输入输出重定向到任何数据流上,或者让子进程共享父进程的标准输入输出流,或者直接忽略子进程的输入输出。

  6. 进程间如何通讯

    在Linux系统下,进程之间可以通过信号互相通信。以下是一个例子。

    /* parent.js */
    var child = child_process.spawn('node', [ 'child.js' ]);
    
    child.kill('SIGTERM');
    
    /* child.js */
    process.on('SIGTERM', function () {
      cleanUp();
      process.exit(0);
    });
    

    在上例中,父进程通过.kill方法向子进程发送SIGTERM信号,子进程监听process对象的SIGTERM事件响应信号。不要被.kill方法的名称迷惑了,该方法本质上是用来给进程发送信号的,进程收到信号后具体要做啥,完全取决于信号的种类和进程自身的代码。

    另外,如果父子进程都是NodeJS进程,就可以通过IPC(进程间通讯)双向传递数据。以下是一个例子。

    /* parent.js */
    var child = child_process.spawn('node', [ 'child.js' ], {
      stdio: [ 0, 1, 2, 'ipc' ]
    });
    
    child.on('message', function (msg) {
      console.log(msg);
    });
    
    child.send({ hello: 'hello' });
    
    /* child.js */
    process.on('message', function (msg) {
      msg.hello = msg.hello.toUpperCase();
      process.send(msg);
    });
    

    可以看到,父进程在创建子进程时,在options.stdio字段中通过ipc开启了一条IPC通道,之后就可以监听子进程对象的message事件接收来自子进程的消息,并通过.send方法给子进程发送消息。在子进程这边,可以在process对象上监听message事件接收来自父进程的消息,并通过.send方法向父进程发送消息。数据在传递过程中,会先在发送端使用JSON.stringify方法序列化,再在接收端使用JSON.parse方法反序列化。

  7. 如何守护子进程

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

    /* 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');
    

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

10 异步编程

NodeJS最大的卖点——事件机制和异步IO,对开发者并不是透明的。开发者需要按异步方式编写代码才用得上这个卖点,而这一点也遭到了一些NodeJS反对者的抨击。但不管怎样,异步编程确实是NodeJS最大的特点,没有掌握异步编程就不能说是真正学会了NodeJS。本章将介绍与异步编程相关的各种知识。

10.1 回调

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

function heavyCompute(n, callback) {
    var count = 0,
        i, j;

    for (i = n; i > 0; --i) { for (j = n; j > 0; --j) { count += 1; }
    }

    callback(count);
}

heavyCompute(10000, function (count) {
    console.log(count);
});

console.log('hello');

-- Console ------------------------------
100000000
hello

可以看到,以上代码中的回调函数仍然先于后续代码执行。JS本身是单线程运行的,不可能在一段代码还未结束运行时去运行别的代码,因此也就不存在异步执行的概念。

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

setTimeout(function () {
    console.log('world');
}, 1000);

console.log('hello');

-- Console ------------------------------
hello
world

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

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

function heavyCompute(n) {
    var count = 0,
        i, j;

    for (i = n; i > 0; --i) {
        for (j = n; j > 0; --j) {
            count += 1;
        }
    }
}

var t = new Date();

setTimeout(function () {
    console.log(new Date() - t);
}, 1000);

heavyCompute(50000);

-- Console ------------------------------
8520

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

10.2 代码设计模式

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

  1. 函数返回值

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

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

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

    fn2('input', function (output2) {
     fn1(output2, function (output1) {
         // Do something.
     });
    });
    

    可以看到,这种方式就是一个回调函数套一个回调函多,套得太多了很容易写出>形状的代码。

  2. 遍历数组

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

    var len = arr.length;
    var i = 0;
    
    for (; i < len; ++i) {
      arr[i] = sync(arr[i]);
    }
    
    // All array items have processed.
    

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

    (function next(i, len, callback) { if (i < len) { async(arr[i], function (value) { arr[i] = value; next(i + 1, len, callback); }); } else { callback(); } }(0, arr.length, function () { // All array items have processed. }));
    

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

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

    (function (i, len, count, callback) { for (; i < len; ++i) { (function (i) { async(arr[i], function (value) { arr[i] = value; if (++count === len) { callback(); } }); }(i)); } }(0, arr.length, 0, function () { // All array items have processed. })); 

    可以看到,与异步串行遍历的版本相比,以上代码并行处理所有数组成员,并通过计数器变量来判断什么时候所有数组成员都处理完毕了。

  3. 异常处理

    JS自身提供的异常捕获和处理机制——try..catch..,只能用于同步执行的代码。以下是一个例子。

    function sync(fn) {
     return fn();
    }
    
    try {
     sync(null);
     // Do something.
    } catch (err) {
     console.log('Error: %s', err.message);
    }
    
    -- Console ------------------------------
    Error: object is not a function 

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

    function async(fn, callback) {
      // Code execution path breaks here.
      setTimeout(function () {
          callback(fn());
      }, 0);
    }
    
    try {
      async(null, function (data) {
          // Do something.
      });
    } catch (err) {
      console.log('Error: %s', err.message);
    }
    
    -- Console ------------------------------
    /home/user/test.js:4
         callback(fn());
                  ^
    TypeError: object is not a function at null._onTimeout (/home/user/test.js:4:13)
      at Timer.listOnTimeout [as ontimeout] (timers.js:110:15)
    

    因为代码执行路径被打断了,我们就需要在异常冒泡到断点之前用try语句把异常捕获住,并通过回调函数传递被捕获的异常。于是我们可以像下边这样改造上边的例子。

    function async(fn, callback) {
      // Code execution path breaks here.
      setTimeout(function () {
          try {
              callback(null, fn());
          } catch (err) {
              callback(err);
          }
      }, 0);
    }
    
    async(null, function (err, data) {
      if (err) {
          console.log('Error: %s', err.message);
      } else {
          // Do something.
      }
    });
    
    -- Console ------------------------------
    Error: object is not a function 

    可以看到,异常再次被捕获住了。在NodeJS中,几乎所有异步API都按照以上方式设计,回调函数中第一个参数都是err。因此我们在编写自己的异步函数时,也可以按照这种方式来处理异常,与NodeJS的设计风格保持一致。

    有了异常处理方式后,我们接着可以想一想一般我们是怎么写代码的。基本上,我们的代码都是做一些事情,然后调用一个函数,然后再做一些事情,然后再调用一个函数,如此循环。如果我们写的是同步代码,只需要在代码入口点写一个try语句就能捕获所有冒泡上来的异常,示例如下。

    function main() {
      // Do something.
      syncA();
      // Do something.
      syncB();
      // Do something.
      syncC();
    }
    
    try {
      main();
    } catch (err) {
      // Deal with exception.
    }
    

    但是,如果我们写的是异步代码,就只有呵呵了。由于每次异步函数调用都会打断代码执行路径,只能通过回调函数来传递异常,于是我们就需要在每个回调函数里判断是否有异常发生,于是只用三次异步函数调用,就会产生下边这种代码。

    function main(callback) {
     // Do something.
     asyncA(function (err, data) {
         if (err) {
             callback(err);
         } else {
             // Do something
             asyncB(function (err, data) {
                 if (err) {
                     callback(err);
                 } else {
                     // Do something
                     asyncC(function (err, data) {
                         if (err) {
                             callback(err);
                         } else {
                             // Do something
                             callback(null);
                         }
                     });
                 }
             });
         }
     });
    }
    
    main(function (err) {
     if (err) {
         // Deal with exception.
     }
    });
    

    可以看到,回调函数已经让代码变得复杂了,而异步方式下对异常的处理更加剧了代码的复杂度。如果NodeJS的最大卖点最后变成这个样子,那就没人愿意用NodeJS了,因此接下来会介绍NodeJS提供的一些解决方案。

10.3 域(Domain)

官方文档:http://nodejs.org/api/domain.html

NodeJS提供了domain模块,可以简化异步代码的异常处理。在介绍该模块之前,我们需要首先理解“域”的概念。简单的讲,一个域就是一个JS运行环境,在一个运行环境中,如果一个异常没有被捕获,将作为一个全局异常被抛出。NodeJS通过process对象提供了捕获全局异常的方法,示例代码如下:

process.on('uncaughtException', function (err) {
    console.log('Error: %s', err.message);
});

setTimeout(function (fn) {
    fn();
});

-- Console ------------------------------
Error: undefined is not a function 

虽然全局异常有个地方可以捕获了,但是对于大多数异常,我们希望尽早捕获,并根据结果决定代码的执行路径。我们用以下HTTP服务器代码作为例子:

function async(request, callback) {
    // Do something.
    asyncA(request, function (err, data) {
        if (err) {
            callback(err);
        } else {
            // Do something
            asyncB(request, function (err, data) {
                if (err) {
                    callback(err);
                } else {
                    // Do something
                    asyncC(request, function (err, data) {
                        if (err) {
                            callback(err);
                        } else {
                            // Do something
                            callback(null, data);
                        }
                    });
                }
            });
        }
    });
}

http.createServer(function (request, response) {
    async(request, function (err, data) {
        if (err) {
            response.writeHead(500);
            response.end();
        } else {
            response.writeHead(200);
            response.end(data);
        }
    });
});

以上代码将请求对象交给异步函数处理后,再根据处理结果返回响应。这里采用了使用回调函数传递异常的方案,因此async函数内部如果再多几个异步函数调用的话,代码就变成上边这副鬼样子了。为了让代码好看点,我们可以在每处理一个请求时,使用domain模块创建一个子域(JS子运行环境)。在子域内运行的代码可以随意抛出异常,而这些异常可以通过子域对象的error事件统一捕获。于是以上代码可以做如下改造:

function async(request, callback) {
    // Do something.
    asyncA(request, function (data) {
        // Do something
        asyncB(request, function (data) {
            // Do something
            asyncC(request, function (data) {
                // Do something
                callback(data);
            });
        });
    });
}

http.createServer(function (request, response) {
    var d = domain.create();

    d.on('error', function () {
        response.writeHead(500);
        response.end();
    });

    d.run(function () {
        async(request, function (data) {
            response.writeHead(200);
            response.end(data);
        });
    });
});

可以看到,我们使用.create方法创建了一个子域对象,并通过.run方法进入需要在子域中运行的代码的入口点。而位于子域中的异步函数回调函数由于不再需要捕获异常,代码一下子瘦身很多。

  • 陷阱

    无论是通过process对象的uncaughtException事件捕获到全局异常,还是通过子域对象的error事件捕获到了子域异常,在NodeJS官方文档里都强烈建议处理完异常后立即重启程序,而不是让程序继续运行。按照官方文档的说法,发生异常后的程序处于一个不确定的运行状态,如果不立即退出的话,程序可能会发生严重内存泄漏,也可能表现得很奇怪。

    但这里需要澄清一些事实。JS本身的throw..try..catch异常处理机制并不会导致内存泄漏,也不会让程序的执行结果出乎意料,但NodeJS并不是存粹的JS。NodeJS里大量的API内部是用C/C++实现的,因此NodeJS程序的运行过程中,代码执行路径穿梭于JS引擎内部和外部,而JS的异常抛出机制可能会打断正常的代码执行流程,导致C/C++部分的代码表现异常,进而导致内存泄漏等问题

    因此,使用uncaughtException或domain捕获异常,代码执行路径里涉及到了C/C++部分的代码时,如果不能确定是否会导致内存泄漏等问题,最好在处理完异常后重启程序比较妥当。而使用try语句捕获异常时一般捕获到的都是JS本身的异常,不用担心上诉问题

你可能感兴趣的:(【深入浅出Node.js系列九】一起撸Node.js)