错误处理和调试—Node出现uncaughtException之后的优雅退出方案

Node 的异步特性是它最大的魅力,但是在带来便利的同时也带来了不少麻烦和坑,错误捕获就是一个。由于 Node 的异步特性,导致我们无法使用 try/catch 来捕获回调函数中的异常,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
try {
console.log('进入 try/catch');
require('fs').stat('SOME_FILE_DOES_NOT_EXIST',
function readCallback(err, content) {
if (err) {
throw err; // 抛出异常
}
});
} catch (e) {
// 这里捕获不到 readCallback 函数中抛出的异常
} finally {
console.log('离开 try/catch');
}

运行结果是:

1
2
3
4
5
6
7
进入 try/catch
离开 try/catch

test.js:7
throw err; // 抛出异常
^
Error: ENOENT, stat 'SOME_FILE_DOES_NOT_EXIST'

上面代码中由于 fs.stat 去查询一个不存在的文件的状态,导致 readCallback 抛出了一个异常。由于 fs.read 的异步特性,readCallback 函数的调用发生在 try/catch 块结束之后,所以该异常不会被 try/catch 捕获。之后 Node 会触发 uncaughtException 事件,如果这个事件依然没有得到响应,整个进程(process)就会 crash。

程序员永远无法保证代码中不出现 uncaughtException,即便是自己代码写的足够小心,也不能保证用的第三方模块没有 bug,例如:

1
2
3
4
5
6
7
8
9
10
11
var deserialize = require('deserialize'); 
// 假设 deserialize 是一个带有 bug 的第三方模块

// app 是一个 express 服务对象
app.get('/users', function (req, res) {
mysql.query('SELECT * FROM user WHERE id=1', function (err, user) {
var config = deserialize(user.config);
// 假如这里触发了 deserialize 的 bug
res.send(config);
});
});

如果不幸触发了 deserialize 模块的 bug,这里就会抛出一个异常,最终结果是整个服务 crash。

当这种情况发生在 Web 服务上时结果是灾难性的。uncaughtException 错误会导致当前的所有的用户连接都被中断,甚至不能返回一个正常的 HTTP 错误码,用户只能等到浏览器超时才能看到一个 no data received 错误。

这是一种非常野蛮粗暴的异常处理机制,任何线上服务都不应该因为 uncaughtException 导致服务器崩溃。一个友好的错误处理机制应该满足三个条件:

  1. 对于引发异常的用户,返回 500 页面
  2. 其他用户不受影响,可以正常访问
  3. 不影响整个进程的正常运行

很遗憾的是,保证 uncaughtException 不影响整个进程的健康运转是不可能的。当 Node 抛出 uncaughtException 异常时就会丢失当前环境的堆栈,导致 Node 不能正常进行内存回收。也就是说,每一次 uncaughtException 都有可能导致内存泄露。

既然如此,退而求其次,我们可以在满足前两个条件的情况下退出进程以便重启服务。

用 domain 来捕获异步异常

普遍的思路是,如果可以通过某种方式来捕获回调函数中的异常,那么就不会有 uncaughtException 错误导致的崩溃。为了解决这个问题,Node 0.8 之后的版本新增了 domain 模块,它可以用来捕获回调函数中抛出的异常。

domain 主要的 API 有 domain.runerror 事件。简单的说,通过 domain.run 执行的函数中引发的异常都可以通过 domainerror 事件捕获,例如:

1
2
3
4
5
6
7
8
9
10
11
var domain = require('domain');
var d = domain.create();
d.run(function () {
setTimeout(function () {
throw new Error('async error'); // 抛出一个异步异常
}, 1000);
});

d.on('error', function (err) {
console.log('catch err:', err); // 这里可以捕获异步异常
});

通过 domain 模块,以及 JavaScript 的词法作用域特性,可以很轻易的为引发异常的用户返回 500 页面。以 express 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var app = express();
var server = require('http').createServer(app);
var domain = require('domain');

app.use(function (req, res, next) {
var reqDomain = domain.create();
reqDomain.on('error', function (err) { // 下面抛出的异常在这里被捕获
res.send(500, err.stack); // 成功给用户返回了 500
});

reqDomain.run(next);
});

app.get('/', function () {
setTimeout(function () {
throw new Error('async exception'); // 抛出一个异步异常
}, 1000);
});

上面的代码将 domain 作为一个中间件来使用,保证之后 express 所有的中间件都在 domain.run函数内部执行。这些中间件内的异常都可以通过 error 事件来捕获。

尽管借助于闭包,我们可以正常的给用户返回 500 错误,但是 domain 捕获到错误时依然会丢失堆栈信息,此时已经无法保证程序的健康运行,必须退出。Node http server 提供了 close 方法,该方法在调用时会停止 server 接收新的请求,但不会断开当前已经建立的连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
reqDomain.on('error', function () {
try {
// 强制退出机制
var killTimer = setTimeout(function () {
process.exit(1);
}, 30000);
killTimer.unref(); // 非常重要

// 自动退出机制,停止接收新链接,等待当前已建立连接的关闭
server.close(function () {
// 此时所有连接均已关闭,此时 Node 会自动退出,不需要再调用
process.exit(1) 来结束进程
});
} catch(e) {
console.log('err', e.stack);
}
});

这个例子来自 Node 的文档。其中有几个关键点:

  • Node 有个非常好的特性,所有连接都被释放后进程会自动结束,所以不需要再 server.close 方法的回调函数中退出进程
  • 强制退出机制: 因为用户连接有可能因为某些原因无法释放,在这种情况下应该强制退出整个进程。
  • killTimer.unref(): 如果不使用 unref 方法,那么即使 server 的所有连接都关闭,Node 也会保持运行直到 killTimer 的回调函数被调用。unref 可以创建一个”不保持程序运行”的计时器。
  • 处理异常时要小心的把异常处理逻辑用 try/catch 包住,避免处理异常时抛出新的异常

通过 domain 似乎就已经解决了我们的需求: 给触发异常的用户一个 500,停止接收新请求,提供正常的服务给已经建立连接的用户,直到所有请求都已结束,退出进程。但是,理想很丰满,现实很骨感,domain 有个最大的问题,它不能捕获所有的异步异常!。也就是说,即使用了 domain,程序依然有因为 uncaughtException crash 的可能。

所幸的是我们可以监听 uncaughtException 事件。

uncaughtException 事件

uncaughtException 是一个非常古老的事件。当 Node 发现一个未捕获的异常时,会触发这个事件。并且如果这个事件存在回调函数,Node 就不会强制结束进程。这个特性,可以用来弥补 domain 的不足:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
process.on('uncaughtException', function (err) {
console.log(err);

try {
var killTimer = setTimeout(function () {
process.exit(1);
}, 30000);
killTimer.unref();

server.close();
} catch (e) {
console.log('error when exit', e.stack);
}
});

uncaughtException 事件的缺点在于无法为抛出异常的用户请求返回一个 500 错误,这是由于 uncaughtException 丢失了当前环境的上下文,比如下面的例子就是它做不到的:

1
2
3
4
5
6
7
8
9
10
11
app.get('/', function (req, res) {
setTimeout(function () {
throw new Error('async error');
// uncaughtException, 导致 req 的引用丢失
res.send(200);
}, 1000);
});

process.on('uncaughtException', function (err) {
res.send(500); // 做不到,拿不到当前请求的 res 对象
});

最终出错的用户只能等待浏览器超时。

domain + uncaughtException

所以,我们可以结合两种异常捕获机制,用 domain 来捕获大部分的异常,并且提供友好的 500 页面以及优雅退出。对于剩下的异常,通过 uncaughtException 事件来避免服务器直接 crash。

代码如下:

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
var app = express();
var server = require('http').create(app);
var domain = require('domain');

// 使用 domain 来捕获大部分异常
app.use(function (req, res, next) {
var reqDomain = domain.create();
reqDomain.on('error', function () {
try {
var killTimer = setTimeout(function () {
process.exit(1);
}, 30000);
killTimer.unref();

server.close();

res.send(500);
} catch (e) {
console.log('error when exit', e.stack);
}
});

reqDomain.run(next);
});

// uncaughtException 避免程序崩溃
process.on('uncaughtException', function (err) {
console.log(err);

try {
var killTimer = setTimeout(function () {
process.exit(1);
}, 30000);
killTimer.unref();

server.close();
} catch (e) {
console.log('error when exit', e.stack);
}
});

其他的一些问题

express 中异常的处理

使用 express 时记住一定不要在 controller 的异步回调中抛出异常,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.get('/', function (req, res, next) { // 总是接收 next 参数
mysql.query('SELECT * FROM users', function (err, results) {
// 不要这样做
if (err) throw err;

// 应该将 err 传递给 errorHandler 处理
if (err) return next(err);
});
});

app.use(function (err, req, res, next) {
// 带有四个参数的 middleware 专门用来处理异常
res.render(500, err.stack);
});

和 cluster 一起使用

cluster 是 node 自带的负载均衡模块,使用 cluster 模块可以方便的建立起一套 master/slave 服务。在使用 cluster 模块时,需要注意不仅需要调用 server.close() 来关闭连接,同时还需要调用 cluster.worker.disconnect() 通知 master 进程已停止服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var cluster = require('cluster');

process.on('uncaughtException', function (err) {
console.log(err);

try {
var killTimer = setTimeout(function () {
process.exit(1);
}, 30000);
killTimer.unref();

server.close();

if (cluster.worker) {
cluster.worker.disconnect();
}
} catch (e) {
console.log('error when exit', e.stack);
}
});

不要通过 uncaughtException 来忽略错误

uncaughtException 事件有一个以上的 listener 时,会阻止 Node 结束进程。因此就有一个广泛流传的做法是监听 processuncaughtException 事件来阻止进程退出,这种做法有内存泄露的风险,所以千万不要这么做:

1
2
3
4
javascript
process.on('uncaughtException', function (err) { // 不要这么做
console.log(err);
});

pm2 对于 uncaughtException 的额外处理

如果你在用 pm2 0.7.1 之前的版本,那么要当心。pm2 有一个 bug,如果进程抛出了 uncaughtException,无论代码中是否捕获了这个事件,进程都会被 pm2 杀死。0.7.2 之后的 pm2 解决了这个问题。

要小心 worker.disconnect()

如果你在退出进程时希望可以发消息给监控服务器,并且还使用了 cluster,那么这个时候要特别小心,比如下面的代码:

1
2
3
4
5
6
7
8
9
10
var udpLog = dgram.createSocket('udp4');
var cluster = require('cluster');

process.on('uncaughtException', function (err) {
udpLog.send('process ' + process.pid + ' down',
/* ... 一些发送 udp 消息的参数 ...*/);

server.close();
cluster.worker.disconnect();
});

这份代码就不能正常的将消息发送出去。因为 udpLog.send 是一个异步方法,真正发消息的操作发生在下一个事件循环中。而在真正的发送消息之前 cluster.worker.disconnect() 就已经执行了。worker.disconnect() 会在当前进程没有任何链接之后,杀掉整个进程,这种情况有可能发生在发送 log 数据之前,导致 log 数据发不出去。

一个解决方法是在 udpLog.send 方法发送完数据后再调用 worker.disconnect:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var udpLog = dgram.createSocket('udp4');
var cluster = require('cluster');

process.on('uncaughtException', function (err) {
udpLog.send('process ' + process.pid + ' down', /* ...
一些发送 udp 消息的参数 ...*/, function () {
cluster.worker.disconnect();
});

server.close();

// 保证 worker.disconnect 不会拖太久..
setTimeout(function () {
cluster.worker.disconnect();
}, 100).unref();
});

小节

说了这么多,结论是,目前为止(Node 0.10.25),依然没有一个完美的方案来解决任意异常的优雅退出问题。用 domain 来捕获大部分异常,并且通过 uncaughtException 避免程序 crash 是目前来说最理想的方案。回调异常的退出问题在遇到 cluster 以后会更加复杂,特别是对于连接关闭的处理要格外小心。

原文:http://www.infoq.com/cn/articles/quit-scheme-of-node-uncaughtexception-emergence

错误处理和调试—JavaScript错误处理机制

Error 实例对象

JavaScript 解析或运行时,一旦发生错误,引擎就会抛出一个错误对象。JavaScript 原生提供Error构造函数,所有抛出的错误都是这个构造函数的实例。

1
2
var err = new Error('出错了');
err.message // "出错了"

上面代码中,我们调用Error构造函数,生成一个实例对象errError构造函数接受一个参数,表示错误提示,可以从实例的message属性读到这个参数。抛出Error实例对象以后,整个程序就中断在发生错误的地方,不再往下执行。

JavaScript 语言标准只提到,Error实例对象必须有message属性,表示出错时的提示信息,没有提到其他属性。大多数 JavaScript 引擎,对Error实例还提供namestack属性,分别表示错误的名称和错误的堆栈,但它们是非标准的,不是每种实现都有。

  • message:错误提示信息
  • name:错误名称(非标准属性)
  • stack:错误的堆栈(非标准属性)

使用namemessage这两个属性,可以对发生什么错误有一个大概的了解。

1
2
3
if (error.name) {
console.log(error.name + ': ' + error.message);
}

stack属性用来查看错误发生时的堆栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function throwit() {
throw new Error('');
}

function catchit() {
try {
throwit();
} catch(e) {
console.log(e.stack); // print stack trace
}
}

catchit()
// Error
// at throwit (~/examples/throwcatch.js:9:11)
// at catchit (~/examples/throwcatch.js:3:9)
// at repl:1:5

上面代码中,错误堆栈的最内层是throwit函数,然后是catchit函数,最后是函数的运行环境。

原生错误类型

Error实例对象是最一般的错误类型,在它的基础上,JavaScript 还定义了其他6种错误对象。也就是说,存在Error的6个派生对象。

SyntaxError 对象

SyntaxError对象是解析代码时发生的语法错误。

1
2
3
4
5
6
7
// 变量名错误
var 1a;
// Uncaught SyntaxError: Invalid or unexpected token

// 缺少括号
console.log 'hello');
// Uncaught SyntaxError: Unexpected string

上面代码的错误,都是在语法解析阶段就可以发现,所以会抛出SyntaxError。第一个错误提示是“token 非法”,第二个错误提示是“字符串不符合要求”。

ReferenceError 对象

ReferenceError对象是引用一个不存在的变量时发生的错误。

1
2
3
// 使用一个不存在的变量
unknownVariable
// Uncaught ReferenceError: unknownVariable is not defined

另一种触发场景是,将一个值分配给无法分配的对象,比如对函数的运行结果或者this赋值。

1
2
3
4
5
6
7
// 等号左侧不是变量
console.log() = 1
// Uncaught ReferenceError: Invalid left-hand side in assignment

// this 对象不能手动赋值
this = 1
// ReferenceError: Invalid left-hand side in assignment

上面代码对函数console.log的运行结果和this赋值,结果都引发了ReferenceError错误。

RangeError 对象

RangeError对象是一个值超出有效范围时发生的错误。主要有几种情况,一是数组长度为负数,二是Number对象的方法参数超出范围,以及函数堆栈超过最大值。

1
2
3
// 数组长度不得为负数
new Array(-1)
// Uncaught RangeError: Invalid array length

TypeError 对象

TypeError对象是变量或参数不是预期类型时发生的错误。比如,对字符串、布尔值、数值等原始类型的值使用new命令,就会抛出这种错误,因为new命令的参数应该是一个构造函数。

1
2
3
4
5
6
new 123
// Uncaught TypeError: number is not a func

var obj = {};
obj.unknownMethod()
// Uncaught TypeError: obj.unknownMethod is not a function

上面代码的第二种情况,调用对象不存在的方法,也会抛出TypeError错误,因为obj.unknownMethod的值是undefined,而不是一个函数。

URIError 对象

URIError对象是 URI 相关函数的参数不正确时抛出的错误,主要涉及encodeURI()decodeURI()encodeURIComponent()decodeURIComponent()escape()unescape()这六个函数。

1
2
decodeURI('%2')
// URIError: URI malformed

EvalError 对象

eval函数没有被正确执行时,会抛出EvalError错误。该错误类型已经不再使用了,只是为了保证与以前代码兼容,才继续保留。

总结

以上这6种派生错误,连同原始的Error对象,都是构造函数。开发者可以使用它们,手动生成错误对象的实例。这些构造函数都接受一个函数,代表错误提示信息(message)。

1
2
3
4
5
6
7
var err1 = new Error('出错了!');
var err2 = new RangeError('出错了,变量超出有效范围!');
var err3 = new TypeError('出错了,变量类型无效!');

err1.message // "出错了!"
err2.message // "出错了,变量超出有效范围!"
err3.message // "出错了,变量类型无效!"

自定义错误

除了 JavaScript 原生提供的七种错误对象,还可以定义自己的错误对象。

1
2
3
4
5
6
7
function UserError(message) {
this.message = message || '默认信息';
this.name = 'UserError';
}

UserError.prototype = new Error();
UserError.prototype.constructor = UserError;

上面代码自定义一个错误对象UserError,让它继承Error对象。然后,就可以生成这种自定义类型的错误了。

1
new UserError('这是自定义的错误!');

throw 语句

throw语句的作用是手动中断程序执行,抛出一个错误。

1
2
3
4
if (x < 0) {
throw new Error('x 必须为正数');
}
// Uncaught ReferenceError: x is not defined

上面代码中,如果变量x小于0,就手动抛出一个错误,告诉用户x的值不正确,整个程序就会在这里中断执行。可以看到,throw抛出的错误就是它的参数,这里是一个Error实例。

throw也可以抛出自定义错误。

1
2
3
4
5
6
7
function UserError(message) {
this.message = message || '默认信息';
this.name = 'UserError';
}

throw new UserError('出错了!');
// Uncaught UserError {message: "出错了!", name: "UserError"}

上面代码中,throw抛出的是一个UserError实例。

实际上,throw可以抛出任何类型的值。也就是说,它的参数可以是任何值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 抛出一个字符串
throw 'Error!';
// Uncaught Error!

// 抛出一个数值
throw 42;
// Uncaught 42

// 抛出一个布尔值
throw true;
// Uncaught true

// 抛出一个对象
throw {
toString: function () {
return 'Error!';
}
};
// Uncaught {toString: ƒ}

对于 JavaScript 引擎来说,遇到throw语句,程序就中止了。引擎会接收到throw抛出的信息,可能是一个错误实例,也可能是其他类型的值。

try…catch 结构

一旦发生错误,程序就中止执行了。JavaScript 提供了try...catch结构,允许对错误进行处理,选择是否往下执行。

1
2
3
4
5
6
7
8
9
try {
throw new Error('出错了!');
} catch (e) {
console.log(e.name + ": " + e.message);
console.log(e.stack);
}
// Error: 出错了!
// at <anonymous>:3:9
// ...

上面代码中,try代码块抛出错误(上例用的是throw语句),JavaScript 引擎就立即把代码的执行,转到catch代码块,或者说错误被catch代码块捕获了。catch接受一个参数,表示try代码块抛出的值。

如果你不确定某些代码是否会报错,就可以把它们放在try...catch代码块之中,便于进一步对错误进行处理。

1
2
3
4
5
try {
f();
} catch(e) {
// 处理错误
}

上面代码中,如果函数f执行报错,就会进行catch代码块,接着对错误进行处理。

catch代码块捕获错误之后,程序不会中断,会按照正常流程继续执行下去。

1
2
3
4
5
6
7
8
try {
throw "出错了";
} catch (e) {
console.log(111);
}
console.log(222);
// 111
// 222

上面代码中,try代码块抛出的错误,被catch代码块捕获后,程序会继续向下执行。

catch代码块之中,还可以再抛出错误,甚至使用嵌套的try...catch结构。

1
2
3
4
5
6
7
8
9
10
11
12
var n = 100;

try {
throw n;
} catch (e) {
if (e <= 50) {
// ...
} else {
throw e;
}
}
// Uncaught 100

上面代码中,catch代码之中又抛出了一个错误。

为了捕捉不同类型的错误,catch代码块之中可以加入判断语句。

1
2
3
4
5
6
7
8
9
10
try {
foo.bar();
} catch (e) {
if (e instanceof EvalError) {
console.log(e.name + ": " + e.message);
} else if (e instanceof RangeError) {
console.log(e.name + ": " + e.message);
}
// ...
}

上面代码中,catch捕获错误之后,会判断错误类型(EvalError还是RangeError),进行不同的处理。

finally 代码块

try...catch结构允许在最后添加一个finally代码块,表示不管是否出现错误,都必需在最后运行的语句。

1
2
3
4
5
6
7
8
9
10
11
12
function cleansUp() {
try {
throw new Error('出错了……');
console.log('此行不会执行');
} finally {
console.log('完成清理工作');
}
}

cleansUp()
// 完成清理工作
// Error: 出错了……

上面代码中,由于没有catch语句块,所以错误没有捕获。执行finally代码块以后,程序就中断在错误抛出的地方。

1
2
3
4
5
6
7
8
9
10
11
12
13
function idle(x) {
try {
console.log(x);
return 'result';
} finally {
console.log("FINALLY");
}
}

idle('hello')
// hello
// FINALLY
// "result"

上面代码说明,try代码块没有发生错误,而且里面还包括return语句,但是finally代码块依然会执行。注意,只有在其执行完毕后,才会显示return语句的值。

下面的例子说明,return语句的执行是排在finally代码之前,只是等finally代码执行完毕后才返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
var count = 0;
function countUp() {
try {
return count;
} finally {
count++;
}
}

countUp()
// 0
count
// 1

上面代码说明,return语句的count的值,是在finally代码块运行之前就获取了。

下面是finally代码块用法的典型场景。

1
2
3
4
5
6
7
8
9
openFile();

try {
writeFile(Data);
} catch(e) {
handleError(e);
} finally {
closeFile();
}

上面代码首先打开一个文件,然后在try代码块中写入文件,如果没有发生错误,则运行finally代码块关闭文件;一旦发生错误,则先使用catch代码块处理错误,再使用finally代码块关闭文件。

下面的例子充分反映了try...catch...finally这三者之间的执行顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function f() {
try {
console.log(0);
throw 'bug';
} catch(e) {
console.log(1);
return true; // 这句原本会延迟到 finally 代码块结束再执行
console.log(2); // 不会运行
} finally {
console.log(3);
return false; // 这句会覆盖掉前面那句 return
console.log(4); // 不会运行
}

console.log(5); // 不会运行
}

var result = f();
// 0
// 1
// 3

result
// false

上面代码中,catch代码块结束执行之前,会先执行finally代码块。

catch代码块之中,触发转入finally代码块的标志,不仅有return语句,还有throw语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function f() {
try {
throw '出错了!';
} catch(e) {
console.log('捕捉到内部错误');
throw e; // 这句原本会等到finally结束再执行
} finally {
return false; // 直接返回
}
}

try {
f();
} catch(e) {
// 此处不会执行
console.log('caught outer "bogus"');
}

// 捕捉到内部错误

上面代码中,进入catch代码块之后,一遇到throw语句,就会去执行finally代码块,其中有return false语句,因此就直接返回了,不再会回去执行catch代码块剩下的部分了。

原文:http://javascript.ruanyifeng.com/grammar/error.html

基础—JavaScript中的作用域和上下文

JavaScript对于作用域(Scope)和上下文(Context)的实现是这门语言的一个非常独到的地方,部分归功于其独特的灵活性。 函数可以接收不同的的上下文和作用域。这些概念为JavaScript中的很多强大的设计模式提供了坚实的基础。 然而这也概念也非常容易给开发人员带来困惑。为此,本文将全面的剖析这些概念,并阐述不同的设计模式是如何利用它们的。

上下文(Context)和作用域(Scope)

首先需要知道的是,上下文和作用域是两个完全不同的概念。多年来,我发现很多开发者会混淆这两个概念(包括我自己), 错误的将两个概念混淆了。平心而论,这些年来很多术语都被混乱的使用了。

函数的每次调用都有与之紧密相关的作用域和上下文。从根本上来说,作用域是基于函数的,而上下文是基于对象的。 换句话说,作用域涉及到所被调用函数中的变量访问,并且不同的调用场景是不一样的。上下文始终是this关键字的值, 它是拥有(控制)当前所执行代码的对象的引用。

变量作用域

一个变量可以被定义在局部或者全局作用域中,这建立了在运行时(runtime)期间变量的访问性的不同作用域范围。 任何被定义的全局变量,意味着它需要在函数体的外部被声明,并且存活于整个运行时(runtime),并且在任何作用域中都可以被访问到。 在ES6之前,局部变量只能存在于函数体中,并且函数的每次调用它们都拥有不同的作用域范围。 局部变量只能在其被调用期的作用域范围内被赋值、检索、操纵。

需要注意,在ES6之前,JavaScript不支持块级作用域,这意味着在if语句、switch语句、for循环、while循环中无法支持块级作用域。 也就是说,ES6之前的JavaScript并不能构建类似于Java中的那样的块级作用域(变量不能在语句块外被访问到)。但是, 从ES6开始,你可以通过let关键字来定义变量,它修正了var关键字的缺点,能够让你像Java语言那样定义变量,并且支持块级作用域。看两个例子:

ES6之前,我们使用var关键字定义变量:

1
2
3
4
5
6
function func() {
if (true) {
var tmp = 123;
}
console.log(tmp); // 123
}

之所以能够访问,是因为var关键字声明的变量有一个变量提升的过程。而在ES6场景,推荐使用let关键字定义变量:

1
2
3
4
5
6
function func() {
if (true) {
let tmp = 123;
}
console.log(tmp); // ReferenceError: tmp is not defined
}

这种方式,能够避免很多错误。

什么是this上下文

上下文通常取决于函数是如何被调用的。当一个函数被作为对象中的一个方法被调用的时候,this被设置为调用该方法的对象上:

1
2
3
4
5
6
7
var obj = {
foo: function(){
alert(this === obj);
}
};

obj.foo(); // true

这个准则也适用于当调用函数时使用new操作符来创建对象的实例的情况下。在这种情况下,在函数的作用域内部this的值被设置为新创建的实例:

1
2
3
4
5
6
function foo(){
alert(this);
}

new foo() // foo
foo() // window

当调用一个为绑定函数时,this默认情况下是全局上下文,在浏览器中它指向window对象。需要注意的是,ES5引入了严格模式的概念, 如果启用了严格模式,此时上下文默认为undefined。

执行环境(execution context)

JavaScript是一个单线程语言,意味着同一时间只能执行一个任务。当JavaScript解释器初始化执行代码时, 它首先默认进入全局执行环境(execution context),从此刻开始,函数的每次调用都会创建一个新的执行环境。

这里会经常引起新手的困惑,这里提到了一个新的术语——执行环境(execution context),它定义了变量或函数有权访问的其他数据,决定了它们各自的行为。 它更偏向于作用域的作用,而不是我们前面讨论的上下文(Context)。请务必仔细的区分执行环境和上下文这两个概念(注:英文容易造成混淆)。 说实话,这是个非常糟糕的命名约定,但是它是ECMAScript规范制定的,你还是遵守吧。

每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中(execution stack)。在函数执行完后,栈将其环境弹出, 把控制权返回给之前的执行环境。ECMAScript程序中的执行流正是由这个便利的机制控制着。

执行环境可以分为创建和执行两个阶段。在创建阶段,解析器首先会创建一个变量对象(variable object,也称为活动对象 activation object), 它由定义在执行环境中的变量、函数声明、和参数组成。在这个阶段,作用域链会被初始化,this的值也会被最终确定。 在执行阶段,代码被解释执行。

每个执行环境都有一个与之关联的变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。 需要知道,我们无法手动访问这个对象,只有解析器才能访问它。

作用域链(The Scope Chain)

当代码在一个环境中执行时,会创建变量对象的一个作用域链(scope chain)。作用域链的用途是保证对执行环境有权访问的所有变量和函数的有序访问。 作用域链包含了在环境栈中的每个执行环境对应的变量对象。通过作用域链,可以决定变量的访问和标识符的解析。 注意,全局执行环境的变量对象始终都是作用域链的最后一个对象。我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var color = "blue";

function changeColor(){
var anotherColor = "red";

function swapColors(){
var tempColor = anotherColor;
anotherColor = color;
color = tempColor;

// 这里可以访问color, anotherColor, 和 tempColor
}

// 这里可以访问color 和 anotherColor,但是不能访问 tempColor
swapColors();
}

changeColor();

// 这里只能访问color
console.log("Color is now " + color);

上述代码一共包括三个执行环境:全局环境、changeColor()的局部环境、swapColors()的局部环境。 上述程序的作用域链如下图所示:

从上图发现。内部环境可以通过作用域链访问所有的外部环境,但是外部环境不能访问内部环境中的任何变量和函数。 这些环境之间的联系是线性的、有次序的。

对于标识符解析(变量名或函数名搜索)是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始, 然后逐级地向后(全局执行环境)回溯,直到找到标识符为止。

闭包

闭包是指有权访问另一函数作用域中的变量的函数。换句话说,在函数内定义一个嵌套的函数时,就构成了一个闭包, 它允许嵌套函数访问外层函数的变量。通过返回嵌套函数,允许你维护对外部函数中局部变量、参数、和内函数声明的访问。 这种封装允许你在外部作用域中隐藏和保护执行环境,并且暴露公共接口,进而通过公共接口执行进一步的操作。可以看个简单的例子:

1
2
3
4
5
6
7
8
9
function foo(){
var localVariable = 'private variable';
return function bar(){
return localVariable;
}
}

var getLocalVariable = foo();
getLocalVariable() // private variable

模块模式最流行的闭包类型之一,它允许你模拟公共的、私有的、和特权成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var Module = (function(){
var privateProperty = 'foo';

function privateMethod(args){
// do something
}

return {

publicProperty: '',

publicMethod: function(args){
// do something
},

privilegedMethod: function(args){
return privateMethod(args);
}
};
})();

模块类似于一个单例对象。由于在上面的代码中我们利用了(function() { … })();的匿名函数形式,因此当编译器解析它的时候会立即执行。 在闭包的执行上下文的外部唯一可以访问的对象是位于返回对象中的公共方法和属性。然而,因为执行上下文被保存的缘故, 所有的私有属性和方法将一直存在于应用的整个生命周期,这意味着我们只有通过公共方法才可以与它们交互。

另一种类型的闭包被称为立即执行的函数表达式(IIFE)。其实它很简单,只不过是一个在全局环境中自执行的匿名函数而已:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(function(window){

var foo, bar;

function private(){
// do something
}

window.Module = {

public: function(){
// do something
}
};

})(this);

对于保护全局命名空间免受变量污染而言,这种表达式非常有用,它通过构建函数作用域的形式将变量与全局命名空间隔离, 并通过闭包的形式让它们存在于整个运行时(runtime)。在很多的应用和框架中,这种封装源代码的方式用处非常的流行, 通常都是通过暴露一个单一的全局接口的方式与外部进行交互。

Call和Apply

这两个方法内建在所有的函数中(它们是Function对象的原型方法),允许你在自定义上下文中执行函数。 不同点在于,call函数需要参数列表,而apply函数需要你提供一个参数数组。如下:

1
2
3
4
5
6
7
8
9
10
var o = {};

function f(a, b) {
return a + b;
}


// 将函数f作为o的方法,实际上就是重新设置函数f的上下文
f.call(o, 1, 2); // 3
f.apply(o, [1, 2]); // 3

两个结果是相同的,函数f在对象o的上下文中被调用,并提供了两个相同的参数1和2。

在ES5中引入了Function.prototype.bind方法,用于控制函数的执行上下文,它会返回一个新的函数, 并且这个新函数会被永久的绑定到bind方法的第一个参数所指定的对象上,无论该函数被如何使用。 它通过闭包将函数引导到正确的上下文中。对于低版本浏览器,我们可以简单的对它进行实现如下(polyfill):

1
2
3
4
5
6
7
8
9
10
if(!('bind' in Function.prototype)){
Function.prototype.bind = function(){
var fn = this,
context = arguments[0],
args = Array.prototype.slice.call(arguments, 1);
return function(){
return fn.apply(context, args.concat(arguments));
}
}
}

bind()方法通常被用在上下文丢失的场景下,例如面向对象和事件处理。之所以要这么做, 是因为节点的addEventListener方法总是为事件处理器所绑定的节点的上下文中执行回调函数, 这就是它应该表现的那样。但是,如果你想要使用高级的面向对象技术,或需要你的回调函数成为某个方法的实例, 你将需要手动调整上下文。这就是bind方法所带来的便利之处:

1
2
3
4
5
6
7
8
function MyClass(){
this.element = document.createElement('div');
this.element.addEventListener('click', this.onClick.bind(this), false);
}

MyClass.prototype.onClick = function(e){
// do something
};

回顾上面bind方法的源代码,你可能会注意到有两次调用涉及到了Array的slice方法:

1
2
Array.prototype.slice.call(arguments, 1);
[].slice.call(arguments);

我们知道,arguments对象并不是一个真正的数组,而是一个类数组对象,虽然具有length属性,并且值也能够被索引, 但是它们不支持原生的数组方法,例如slice和push。但是,由于它们具有和数组类似的行为,数组的方法能够被调用和劫持, 因此我们可以通过类似于上面代码的方式达到这个目的,其核心是利用call方法。

这种调用其他对象方法的技术也可以被应用到面向对象中,我们可以在JavaScript中模拟经典的继承方式:

1
2
3
4
MyClass.prototype.init = function(){
// call the superclass init method in the context of the "MyClass" instance
MySuperClass.prototype.init.apply(this, arguments);
}

也就是利用call或apply在子类(MyClass)的实例中调用超类(MySuperClass)的方法。

ES6中的箭头函数

ES6中的箭头函数可以作为Function.prototype.bind()的替代品。和普通函数不同,箭头函数没有它自己的this值, 它的this值继承自外围作用域。

对于普通函数而言,它总会自动接收一个this值,this的指向取决于它调用的方式。我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var obj = {

// ...

addAll: function (pieces) {
var self = this;
_.each(pieces, function (piece) {
self.add(piece);
});
},

// ...

}

在上面的例子中,最直接的想法是直接使用this.add(piece),但不幸的是,在JavaScript中你不能这么做, 因为each的回调函数并未从外层继承this值。在该回调函数中,this的值为window或undefined, 因此,我们使用临时变量self来将外部的this值导入内部。我们还有两种方法解决这个问题:

使用ES5中的bind()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = {

// ...

addAll: function (pieces) {
_.each(pieces, function (piece) {
this.add(piece);
}.bind(this));
},

// ...

}

使用ES6中的箭头函数

1
2
3
4
5
6
7
8
9
10
11
var obj = {

// ...

addAll: function (pieces) {
_.each(pieces, piece => this.add(piece));
},

// ...

}

在ES6版本中,addAll方法从它的调用者处获得了this值,内部函数是一个箭头函数,所以它集成了外部作用域的this值。

注意:对回调函数而言,在浏览器中,回调函数中的this为window或undefined(严格模式),而在Node.js中, 回调函数的this为global。实例代码如下:

1
2
3
4
5
6
7
8
function hello(a, callback) {
callback(a);
}

hello('weiwei', function(a) {
console.log(this === global); // true
console.log(a); // weiwei
});

小结

在你学习高级的设计模式之前,理解这些概念非常的重要,因为作用域和上下文在现代JavaScript中扮演着的最基本的角色。 无论我们谈论的是闭包、面向对象、继承、或者是各种原生实现,上下文和作用域都在其中扮演着至关重要的角色。 如果你的目标是精通JavaScript语言,并且深入的理解它的各个组成,那么作用域和上下文便是你的起点。

参考资料

Understanding Scope and Context in JavaScript
Arrow functions vs. bind()
理解与使用Javascript中的回调函数

基础—Javascipt面对对象

面向对象编程是用抽象方式创建基于现实世界模型的一种编程模式,主要包括模块化、多态、和封装几种技术。 对JavaScript而言,其核心是支持面向对象的,同时它也提供了强大灵活的基于原型的面向对象编程能力。 本文将会深入的探讨有关使用JavaScript进行面向对象编程的一些核心基础知识,包括对象的创建,继承机制, 最后还会简要的介绍如何借助ES6提供的新的类机制重写传统的JavaScript面向对象代码。

面向对象的几个概念

在进入正题前,先了解传统的面向对象编程(例如Java)中常会涉及到的概念,大致可以包括:

  • 类:定义对象的特征。它是对象的属性和方法的模板定义。
  • 对象(或称实例):类的一个实例。
  • 属性:对象的特征,比如颜色、尺寸等。
  • 方法:对象的行为,比如行走、说话等。
  • 构造函数:对象初始化的瞬间被调用的方法。
  • 继承:子类可以继承父类的特征。例如,猫继承了动物的一般特性。
  • 封装:一种把数据和相关的方法绑定在一起使用的方法。
  • 抽象:结合复杂的继承、方法、属性的对象能够模拟现实的模型。
  • 多态:不同的类可以定义相同的方法或属性。
    在JavaScript的面向对象编程中大体也包括这些。不过在称呼上可能稍有不同,例如,JavaScript中没有原生的“类”的概念, 而只有对象的概念。因此,随着你认识的深入,我们会混用对象、实例、构造函数等概念。

对象(类)的创建

在JavaScript中,我们通常可以使用构造函数来创建特定类型的对象。诸如Object和Array这样的原生构造函数,在运行时会自动出现在执行环境中。 此外,我们也可以创建自定义的构造函数。例如:

1
2
3
4
5
6
7
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
}
var person1 = new Person('Weiwei', 27, 'Student');
var person2 = new Person('Lily', 25, 'Doctor');

按照惯例,构造函数始终都应该以一个大写字母开头(和Java中定义的类一样),普通函数则小写字母开头。 要创建Person的新实例,必须使用new操作符。以这种方式调用构造函数实际上会经历以下4个步骤:

  1. 创建一个新对象(实例)
  2. 将构造函数的作用域赋给新对象(也就是重设了this的指向,this就指向了这个新对象)
  3. 执行构造函数中的代码(为这个新对象添加属性)
  4. 返回新对象

在上面的例子中,我们创建了Person的两个实例person1和person2。 这两个对象默认都有一个constructor属性,该属性指向它们的构造函数Person,也就是说:

1
2
console.log(person1.constructor == Person);  //true
console.log(person2.constructor == Person); //true

自定义对象的类型检测

我们可以使用instanceof操作符进行类型检测。我们创建的所有对象既是Object的实例,同时也是Person的实例。 因为所有的对象都继承自Object。

1
2
3
4
console.log(person1 instanceof Object);  //true
console.log(person1 instanceof Person); //true
console.log(person2 instanceof Object); //true
console.log(person2 instanceof Person); //true

构造函数的问题

我们不建议在构造函数中直接定义方法,如果这样做的话,每个方法都要在每个实例上重新创建一遍,这将非常损耗性能。 ——不要忘了,ECMAScript中的函数是对象,每定义一个函数,也就实例化了一个对象。

幸运的是,在ECMAScript中,我们可以借助原型对象来解决这个问题。

借助原型模式定义对象的方法

我们创建的每个函数都有一个prototype属性,这个属性是一个指针,指向该函数的原型对象, 该对象包含了由特定类型的所有实例共享的属性和方法。也就是说,我们可以利用原型对象来让所有对象实例共享它所包含的属性和方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
}
// 通过原型模式来添加所有实例共享的方法
// sayName() 方法将会被Person的所有实例共享,而避免了重复创建
Person.prototype.sayName = function () {
console.log(this.name);
};
var person1 = new Person('Weiwei', 27, 'Student');
var person2 = new Person('Lily', 25, 'Doctor');
console.log(person1.sayName === person2.sayName); // true
person1.sayName(); // Weiwei
person2.sayName(); // Lily

正如上面的代码所示,通过原型模式定义的方法sayName()为所有的实例所共享。也就是, person1和person2访问的是同一个sayName()函数。同样的,公共属性也可以使用原型模式进行定义。例如:

1
2
3
4
function Chinese (name) {
this.name = name;
}
Chinese.prototype.country = 'China'; // 公共属性,所有实例共享

当我们new Person()时,返回的Person实例会结合构造函数中定义的属性、行为和原型中定义的属性、行为, 生成最终属于Person实例的属性和行为。

构造函数中定义的属性和行为的优先级要比原型中定义的属性和行为的优先级高,如果构造函数和原型中定义了同名的属性或行为, 构造函数中的属性或行为会覆盖原型中的同名的属性或行为。

原型对象

现在我们来深入的理解一下什么是原型对象。

只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个prototype属性,这个属性指向函数的原型对象。 在默认情况下,所有原型对象都会自动获得一个constructor属性,这个属性包含一个指向prototype属性所在函数的指针。 也就是说:Person.prototype.constructor指向Person构造函数。

创建了自定义的构造函数之后,其原型对象默认只会取得constructor属性;至于其他方法,则都是从Object继承而来的。 当调用构造函数创建一个新实例后,该实例内部将包含一个指针(内部属性),指向构造函数的原型对象。ES5中称这个指针为[[Prototype]], 在Firefox、Safari和Chrome在每个对象上都支持一个属性proto(目前已被废弃);而在其他实现中,这个属性对脚本则是完全不可见的。 要注意,这个链接存在于实例与构造函数的原型对象之间,而不是实例与构造函数之间。

这三者关系的示意图如下:

上图展示了Person构造函数、Person的原型对象以及Person现有的两个实例之间的关系。

  • Person.prototype指向了原型对象
  • Person.prototype.constructor又指回了Person构造函数
  • Person的每个实例person1和person2都包含一个内部属性(通常为proto),person1.proto和person2.proto指向了原型对象

查找对象属性

从上图我们发现,虽然Person的两个实例都不包含属性和方法,但我们却可以调用person1.sayName()。 这是通过查找对象属性的过程来实现的。

  1. 搜索首先从对象实例本身开始(实例person1有sayName属性吗?——没有)
  2. 如果没找到,则继续搜索指针指向的原型对象(person1.proto有sayName属性吗?——有)
    这也是多个对象实例共享原型所保存的属性和方法的基本原理。

注意,如果我们在对象的实例中重写了某个原型中已存在的属性,则该实例属性会屏蔽原型中的那个属性。 此时,可以使用delete操作符删除实例上的属性。

Object.getPrototypeOf()

根据ECMAScript标准,someObject.[[Prototype]] 符号是用于指派 someObject 的原型。 这个等同于 JavaScript 的 proto 属性(现已弃用,因为它不是标准)。 从ECMAScript 5开始, [[Prototype]] 可以用Object.getPrototypeOf()和Object.setPrototypeOf()访问器来访问。

其中Object.getPrototypeOf()在所有支持的实现中,这个方法返回[[Prototype]]的值。例如:

1
2
person1.__proto__ === Object.getPrototypeOf(person1); // true
Object.getPrototypeOf(person1) === Person.prototype; // true

也就是说,Object.getPrototypeOf(p1)返回的对象实际就是这个对象的原型。 这个方法的兼容性请参考该链接。

Object.keys()

要取得对象上所有可枚举的实例属性,可以使用ES5中的Object.keys()方法。例如:

1
Object.keys(p1); // ["name", "age", "job"]

此外,如果你想要得到所有实例属性,无论它是否可枚举,都可以使用Object.getOwnPropertyName()方法。

更简单的原型语法

在上面的代码中,如果我们要添加原型属性和方法,就要重复的敲一遍Person.prototype。为了减少这个重复的过程, 更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
}

// 重写整个原型对象
Person.prototype = {

// 这里务必要重新将构造函数指回Person构造函数,否则会指向这个新创建的对象
constructor: Person, // Attention!

sayName: function () {
console.log(this.name);
}
};

var person1 = new Person('Weiwei', 27, 'Student');
var person2 = new Person('Lily', 25, 'Doctor');

console.log(person1.sayName === person2.sayName); // true

person1.sayName(); // Weiwei
person2.sayName(); // Lily

在上面的代码中特意包含了一个constructor属性,并将它的值设置为Person,从而确保了通过该属性能够访问到适当的值。 注意,以这种方式重设constructor属性会导致它的[[Enumerable]]特性设置为true。默认情况下,原生的constructor属性是不可枚举的。 你可以使用Object.defineProperty():

1
2
3
4
5
// 重设构造函数,只适用于ES5兼容的浏览器
Object.defineProperty(Person.prototype, "constructor", {
enumerable: false,
value: Person
});

组合使用构造函数模式和原型模式

创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性, 而原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方的引用, 最大限度的节省了内存。

继承

大多的面向对象语言都支持两种继承方式:接口继承和实现继承。ECMAScript只支持实现继承,而且其实现继承主要依靠原型链来实现。

前面我们知道,JavaScript中实例的属性和行为是由构造函数和原型两部分共同组成的。如果我们想让Child继承Father, 那么我们就需要把Father构造函数和原型中属性和行为全部传给Child的构造函数和原型。

原型链继承

使用原型链作为实现继承的基本思想是:利用原型让一个引用类型继承另一个引用类型的属性和方法。首先我们先回顾一些基本概念:

每个构造函数都有一个原型对象(prototype)
原型对象包含一个指向构造函数的指针(constructor)
实例都包含一个指向原型对象的内部指针([[Prototype]])
如果我们让原型对象等于另一个类型的实现,结果会怎么样?显然,此时的原型对象将包含一个指向另一个原型的指针, 相应的,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立, 如此层层递进,就构成了实例与原型的链条。 更详细的内容可以参考这个链接。 先看一个简单的例子,它演示了使用原型链实现继承的基本框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Father () {
this.fatherValue = true;
}

Father.prototype.getFatherValue = function () {
console.log(this.fatherValue);
};

function Child () {
this.childValue = false;
}

// 实现继承:继承自Father
Child.prototype = new Father();

Child.prototype.getChildValue = function () {
console.log(this.childValue);
};

var instance = new Child();
instance.getFatherValue(); // true
instance.getChildValue(); // false

在上面的代码中,原型链继承的核心语句是Child.prototype = new Father(),它实现了Child对Father的继承, 而继承是通过创建Father的实例,并将该实例赋给Child.prototype实现的。

实现的本质是重写原型对象,代之以一个新类型的实例。也就是说,原来存在于Father的实例中的所有属性和方法, 现在也存在于Child.prototype中了。

这个例子中的实例以及构造函数和原型之间的关系如下图所示:

在上面的代码中,我们没有使用Child默认提供的原型,而是给它换了一个新原型;这个新原型就是Father的实例。 于是,新原型不仅具有了作为一个Father的实例所拥有的全部属性和方法。而且其内部还有一个指针[[Prototype]],指向了Father的原型。

  • instance指向Child的原型对象
  • Child的原型对象指向Father的原型对象
  • getFatherValue()方法仍然还在Father.prototype中
  • 但是,fatherValue则位于Child.prototype中
  • instance.constructor现在指向的是Father
    因为fatherValue是一个实例属性,而getFatherValue()则是一个原型方法。既然Child.prototype现在是Father的实例, 那么fatherValue当然就位于该实例中。

通过实现原型链,本质上扩展了本章前面介绍的原型搜索机制。例如,instance.getFatherValue()会经历三个搜索步骤:

  1. 搜索实例
  2. 搜索Child.prototype
  3. 搜索Father.prototype

别忘了Object

所有的函数都默认原型都是Object的实例,因此默认原型都会包含一个内部指针[[Prototype]],指向Object.prototype。 这也正是所有自定义类型都会继承toString()、valueOf()等默认方法的根本原因。所以, 我们说上面例子展示的原型链中还应该包括另外一个继承层次。关于Object的更多内容,可以参考这篇博客。

也就是说,Child继承了Father,而Father继承了Object。当调用了instance.toString()时, 实际上调用的是保存在Object.prototype中的那个方法。

原型链继承的问题

首先是顺序,一定要先继承父类,然后为子类添加新方法。

其次,使用原型链实现继承时,不能使用对象字面量创建原型方法。因为这样做就会重写原型链,如下面的例子所示:

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
function Father () {
this.fatherValue = true;
}

Father.prototype.getFatherValue = function () {
console.log(this.fatherValue);
};

function Child () {
this.childValue = false;
}

// 继承了Father
// 此时的原型链为 Child -> Father -> Object
Child.prototype = new Father();

// 使用字面量添加新方法,会导致上一行代码无效
// 此时我们设想的原型链被切断,而是变成 Child -> Object
// 所以我们不推荐这么写了
Child.prototype = {
getChildValue: function () {
console.log(this.childValue);
}
};

var instance = new Child();
instance.getChildValue(); // false
instance.getFatherValue(); // error!

在上面的代码中,我们连续两次修改了Child.prototype的值。由于现在的原型包含的是一个Object的实例, 而非Father的实例,因此我们设想中的原型链已经被切断——Child和Father之间已经没有关系了。

最后,在创建子类型的实例时,不能向超类型的构造函数中传递参数。实际上,应该说是没有办法在不影响所有对象实例的情况下, 给超类型的构造函数传递参数。因此,我们很少单独使用原型链。

借用构造函数继承

借用构造函数(constructor stealing)的基本思想如下:即在子类构造函数的内部调用超类型构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Father (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}

function Child (name) {
// 继承了Father,同时传递了参数
// 之所以这么做,是为了获得Father构造函数中的所有属性和方法
// 之所以用call,是为了修正Father内部this的指向
Father.call(this, name);
}

var instance1 = new Child("weiwei");
instance1.colors.push('black');
console.log(instance1.colors); // [ 'red', 'blue', 'green', 'black' ]
console.log(instance1.name); // weiwei

var instance2 = new Child("lily");
console.log(instance2.colors); // [ 'red', 'blue', 'green' ]
console.log(instance2.name); // lily

为了确保Father构造函数不会重写子类型的属性,可以在调用超类型构造函数后,再添加应该在子类型中定义的属性。

借用构造函数的缺点

同构造函数一样,无法实现方法的复用(所有的方法会被重复创建一份)。

组合使用原型链和借用构造函数

通常,我们会组合使用原型链继承和借用构造函数来实现继承。也就是说,使用原型链实现对原型属性和方法的继承, 而通过借用构造函数来实现对实例属性的继承。这样,既通过在原型上定义方法实现了函数复用,又能够保证每个实例都有它自己的属性。 我们改造最初的例子如下:

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 Person (name, age, job) {
this.name = name;
this.age = age;
this.job = job;
}

// 父类方法
Person.prototype.sayName = function () {
console.log(this.name);
};

// --------------

// 子类构造函数
function Student (name, age, job, school) {
// 继承父类的所有实例属性(获得父类构造函数中的属性)
Person.call(this, name, age, job);
this.school = school; // 添加新的子类属性
}

// 继承父类的原型方法(获得父类原型链上的属性和方法)
Student.prototype = new Person();

// 新增的子类方法
Student.prototype.saySchool = function () {
console.log(this.school);
};

var person1 = new Person('Weiwei', 27, 'Student');
var student1 = new Student('Lily', 25, 'Doctor', "Southeast University");

console.log(person1.sayName === student1.sayName); // true

person1.sayName(); // Weiwei
student1.sayName(); // Lily
student1.saySchool(); // Southeast University

组合集成避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为了JavaScript中最常用的继承模式。 而且,instanceof和isPropertyOf()也能够用于识别基于组合继承创建的对象。

组合继承的改进版:使用Object.create()

在上面,我们继承父类的原型方法使用的是Student.prototype = new Person()。 这样做有很多的问题。 改进方法是使用ES5中新增的Object.create()。可以调用这个方法来创建一个新对象。新对象的原型就是调用create()方法传入的第一个参数:

1
2
3
4
5
6
Student.prototype = Object.create(Person.prototype);

console.log(Student.prototype.constructor); // [Function: Person]

// 设置 constructor 属性指向 Student
Student.prototype.constructor = Student;

关于Object.create()的实现,我们可以参考一个简单的polyfill:

1
2
3
4
5
6
7
8
9
function createObject(proto) {
function F() { }
F.prototype = proto;
return new F();
}

// Usage:
Student.prototype = createObject(Person.prototype);
从本质上讲,createObject()对传入其中的对象执行了一次浅复制。

ES6中的面向对象语法

ES6中引入了一套新的关键字用来实现class。 但它并不是映入了一种新的面向对象继承模式。JavaScript仍然是基于原型的,这些新的关键字包括class、 constructor、 static、 extends、 和super。

class关键字不过是提供了一种在本文中所讨论的基于原型模式和构造器模式的面向对象的继承方式的语法糖(syntactic sugar)。

对前面的代码修改如下:

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
'use strict';

class Person {

constructor (name, age, job) {
this.name = name;
this.age = age;
this.job = job;
}

sayName () {
console.log(this.name);
}

}

class Student extends Person {

constructor (name, age, school) {
super(name, age, 'Student');
this.school = school;
}

saySchool () {
console.log(this.school);
}

}

var stu1 = new Student('weiwei', 20, 'Southeast University');
var stu2 = new Student('lily', 22, 'Nanjing University');

stu1.sayName(); // weiwei
stu1.saySchool(); // Southeast University

stu2.sayName(); // lily
stu2.saySchool(); // Nanjing University

类:class

是JavaScript中现有基于原型的继承的语法糖。ES6中的类并不是一种新的创建对象的方法,只不过是一种“特殊的函数”, 因此也包括类表达式和类声明, 但需要注意的是,与函数声明不同的是,类声明不会被提升。 参考链接

类构造器:constructor

constructor()方法是有一种特殊的和class一起用于创建和初始化对象的方法。注意,在ES6类中只能有一个名称为constructor的方法, 否则会报错。在constructor()方法中可以调用super关键字调用父类构造器。如果你没有指定一个构造器方法, 类会自动使用一个默认的构造器。参考链接

类的静态方法:static

静态方法就是可以直接使用类名调用的方法,而无需对类进行实例化,当然实例化后的类也无法调用静态方法。 静态方法常被用于创建应用的工具函数。参考链接

继承父类:extends

extends关键字可以用于继承父类。使用extends可以扩展一个内置的对象(如Date),也可以是自定义对象,或者是null。

关键字:super

super关键字用于调用父对象上的函数。 super.prop和super[expr]表达式在类和对象字面量中的任何方法定义中都有效。

super([arguments]); // 调用父类构造器
super.functionOnParent([arguments]); // 调用父类中的方法
如果是在类的构造器中,需要在this关键字之前使用。参考链接

小结

本文对JavaScript的面向对象机制进行了较为深入的解读,尤其是构造函数和原型链方式实现对象的创建、继承、以及实例化。 此外,本文还简要介绍了如在ES6中编写面向对象代码。

进程—当我们谈论 cluster 时我们在谈论什么

Node.js 诞生之初就遭到不少这样的吐槽,当然这些都早已不是问题了。

  1. 可靠性低。
  2. 单进程,单线程,只支持单核 CPU,不能充分的利用多核 CPU 服务器。一旦这个进程崩掉,那么整个 web 服务就崩掉了。

回想以前用 php 开发 web 服务器的时候,每个 request 都在单独的线程中处理,即使某一个请求发生很严重的错误也不会影响到其它请求。Node.js 会在一个线程中处理大量请求,如果处理某个请求时产生一个没有被捕获到的异常将导致整个进程的退出,已经接收到的其它连接全部都无法处理,对一个 web 服务器来说,这绝对是致命的灾难。

应用部署到多核服务器时,为了充分利用多核 CPU 资源一般启动多个 Node.js 进程提供服务,这时就会使用到 Node.js 内置的 cluster 模块了。相信大多数的 Node.js 开发者可能都没有直接使用到 cluster,cluster 模块对 child_process 模块提供了一层封装,可以说是为了发挥服务器多核优势而量身定做的。简单的一个 fork,不需要开发者修改任何的应用代码便能够实现多进程部署。当下最热门的带有负载均衡功能的 Node.js 应用进程管理器 pm2 便是最好的一个例子,开发的时候完全不需要关注多进程场景,剩余的一切都交给 pm2 处理,与开发者的应用代码完美分离。

1
pm2 start app.js

pm2 确实非常强大,但本文并不讲解 pm2 的工作原理,而是从更底层的进程通信讲起,为大家揭秘使用 Node.js 开发 web 应用时,使用 cluster 模块实现多进程部署的原理。

fork

说到多进程当然少不了 fork ,在 un*x 系统中,fork 函数为用户提供最底层的多进程实现。

fork() creates a new process by duplicating the calling process. The new process is referred to as the child process. The calling process is referred to as the parent process.

The child process and the parent process run in separate memory spaces. At the time of fork() both memory spaces have the same content. Memory writes, file mappings (mmap(2)), and unmappings (munmap(2)) performed by one of the processes do not affect the other.

本文中要讲解的 fork 是 cluster 模块中非常重要的一个方法,当然了,底层也是依赖上面提到的 fork 函数实现。 多个子进程便是通过在master进程中不断的调用 cluster.fork 方法构造出来。下面的结构图大家应该非常熟悉了。

上面的图非常粗糙, 并没有告诉我们 master 与 worker 到底是如何分工协作的。Node.js 在这块做过比较大的改动,下面就细细的剖析开来。

多进程监听同一端口

最初的 Node.js 多进程模型就是这样实现的,master 进程创建 socket,绑定到某个地址以及端口后,自身不调用 listen 来监听连接以及 accept 连接,而是将该 socket 的 fd 传递到 fork 出来的 worker 进程,worker 接收到 fd 后再调用 listen,accept 新的连接。但实际一个新到来的连接最终只能被某一个 worker 进程 accpet 再做处理,至于是哪个 worker 能够 accept 到,开发者完全无法预知以及干预。这势必就导致了当一个新连接到来时,多个 worker 进程会产生竞争,最终由胜出的 worker 获取连接。


为了进一步加深对这种模型的理解,我编写了一个非常简单的 demo。

master 进程

1
2
3
4
5
6
7
8
const net = require('net');
const fork = require('child_process').fork;

var handle = net._createServerHandle('0.0.0.0', 3000);

for(var i=0;i<4;i++) {
fork('./worker').send({}, handle);
}

worker 进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const net = require('net');
process.on('message', function(m, handle) {
start(handle);
});

var buf = 'hello nodejs';
var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;

function start(server) {
server.listen();
server.onconnection = function(err,handle) {
console.log('got a connection on worker, pid = %d', process.pid);
var socket = new net.Socket({
handle: handle
});
socket.readable = socket.writable = true;
socket.end(res);
}
}

保存后直接运行 node master.js 启动服务器,在另一个终端多次运行 ab -n10000 -c100 http://127.0.0.1:3000/

各个 worker 进程统计到的请求数分别为

1
2
3
4
worker 63999  got 14561 connections
worker 64000 got 8329 connections
worker 64001 got 2356 connections
worker 64002 got 4885 connections

相信到这里大家也应该知道这种多进程模型比较明显的问题了

  • 多个进程之间会竞争 accpet 一个连接,产生惊群现象,效率比较低。
  • 由于无法控制一个新的连接由哪个进程来处理,必然导致各 worker 进程之间的负载非常不均衡。

这其实就是著名的”惊群”现象。

简单说来,多线程/多进程等待同一个 socket 事件,当这个事件发生时,这些线程/进程被同时唤醒,就是惊群。可以想见,效率很低下,许多进程被内核重新调度唤醒,同时去响应这一个事件,当然只有一个进程能处理事件成功,其他的进程在处理该事件失败后重新休眠(也有其他选择)。这种性能浪费现象就是惊群。

惊群通常发生在 server 上,当父进程绑定一个端口监听 socket,然后 fork 出多个子进程,子进程们开始循环处理(比如 accept)这个 socket。每当用户发起一个 TCP 连接时,多个子进程同时被唤醒,然后其中一个子进程 accept 新连接成功,余者皆失败,重新休眠。

nginx proxy

现代的 web 服务器一般都会在应用服务器外面再添加一层负载均衡,比如目前使用最广泛的 nginx。
利用 nginx 强大的反向代理功能,可以启动多个独立的 node 进程,分别绑定不同的端口,最后由nginx 接收请求然后进行分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
http { 
upstream cluster {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
}
server {
listen 80;
server_name www.domain.com;
location / {
proxy_pass http://cluster;
}
}
}

这种方式就将负载均衡的任务完全交给了 nginx 处理,并且 nginx 本身也相当擅长。再加一个守护进程负责各个 node 进程的稳定性,这种方案也勉强行得通。但也有比较大的局限性,比如想增加或者减少一个进程时还得再去改下 nginx 的配置。该方案与 nginx 耦合度太高,实际项目中并不经常使用。

小结

说了这么多,一直在讲解 Node.js 多进程部署时遇到的各种问题。小伙伴们肯定会有非常多的疑问。实际的 Node.js 项目中我们到底是如何利用多进程的呢,并且如何保障各个 worker 进程的稳定性。如何利用 cluster 模块 fork 子进程,父子进程间又是如何实现通信的呢?

下篇将为大家一一揭晓,敬请期待!

上篇文章讲解了 Node.js 中多进程部署时遇到的各种问题,那么实际的线上项目中到底是如何利用多进程,如何保障各个 worker 进程稳定性的呢,又是如何利用 cluster 模块 fork 子进程,父子进程间又是如何实现通信的呢?本篇就来一一揭晓。

负载均衡

回忆一下上篇中提到的最初 Node.js 多进程模型,多个进程绑定同一端口,相互竞争 accpet 新到来的连接。由于无法控制一个新的连接由哪个进程来处理,导致各 worker 进程之间的负载非常不均衡。

于是后面就出现了基于 round-robin 算法的另一种模型。主要思路是 master 进程创建 socket,绑定地址以及端口后再进行监听。该 socket 的 fd 不传递到各个 worker 进程。当 master 进程获取到新的连接时,再决定将 accept 到的客户端连接分发给指定的 worker 处理。这里使用了指定, 所以如何传递以及传递给哪个 worker 完全是可控的。round-robin 只是其中的某种算法而已,当然可以换成其他的。

同样基于这种模型也给出一个简单的 demo。

master 进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const net = require('net');
const fork = require('child_process').fork;

var workers = [];
for (var i = 0; i < 4; i++) {
workers.push(fork('./worker'));
}

var handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function (err,handle) {
var worker = workers.pop();
worker.send({},handle);
workers.unshift(worker);
}

woker 进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const net = require('net');
process.on('message', function (m, handle) {
start(handle);
});

var buf = 'hello Node.js';
var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf;

function start(handle) {
console.log('got a connection on worker, pid = %d', process.pid);
var socket = new net.Socket({
handle: handle
});
socket.readable = socket.writable = true;
socket.end(res);
}

由于只有 master 进程接收客户端连接,并且能够按照特定的算法进行分发, 很好的解决了上篇中提到的由于竞争导致各 worker 进程负载不均衡的硬伤。

优雅退出

上篇文章开头提到 Node.js 被吐槽稳定性差,进程发生未捕获到的异常就会退出。实际项目中由于各种原因,不可避免最后上线时还是存在各种 bug 以及异常,最终进程退出。

当进程异常退出时,有可能该进程上还有很多未处理完的请求,简单粗暴的使进程直接退出必然导致所有的请求都会丢失,给用户带来非常糟的体验,这就非常需要一个进程优雅退出的方案。

给 process 对象添加 uncaughtException 事件绑定能够避免发生异常时进程直接退出。在回调函数里调用当前运行 server 对象的 close 方法,停止接收新的连接。同时告知 master 进程该 worker 进程即将退出,可以 fork 新的 worker 了。

接着在几秒中之后差不多所有请求都已经处理完毕后,该进程主动退出,其中 timeout 可以根据实际业务场景进行设置。

1
2
3
setTimeout(function () {
process.exit(1);
}, timeout)

这里面有一个小的细节处理,在关闭服务器之前,后续新接收的 request 全部关闭 keep-alive 特性,通知客户端不需要与该服务器保持 socket 连接了。

1
2
3
4
5
6
7
server.on('request', function (req, res) {
req.shouldKeepAlive = false;
res.shouldKeepAlive = false;
if (!res._header) {
res.setHeader('Connection', 'close');
}
});

第三方 graceful 模块专门来处理这种场景的,感兴趣的同学可以阅读下源码。

进程守护

master 进程除了负责接收新的连接,分发给各 worker 进程处理之外,还得像天使一样默默地守护着这些 worker 进程,保障整个应用的稳定性。一旦某个 worker 进程异常退出就 fork 一个新的子进程顶替上去。

这一切 cluster 模块都已经好处理了,当某个 worker 进程发生异常退出或者与 master 进程失去联系(disconnected)时,master 进程都会收到相应的事件通知。

1
2
3
4
5
6
7
cluster.on('exit', function () {
clsuter.fork();
});

cluster.on('disconnect', function () {
clsuter.fork();
});

推荐使用第三方模块 recluster 和 cfork,已经处理的很成熟了。

这样一来整个应用的稳定性重任就落在 master 进程上了,所以一定不要给 master 太多其它的任务,百分百保证它的健壮性,一旦 master 进程挂掉你的应用也就玩完了。

IPC

master 进程能够接收连接进行分发,同时守护 worker 进程,这一切都离不开进程间的通信。
讲了这么多,终于到最核心的地方了,要用多进程模型就一定会涉及到 IPC(进程间通信)了。Node.js 中 IPC 都是在父子进程之间进行,按有无发送 fd 分为 2 种方式。

发送 fd

当进程间需要发生文件描述符 fd 时,libuv 底层采用消息队列来实现 IPC。master 进程接收到客户端连接分发给 worker 进程处理时就用到了进程间 fd 的传递。

不发送 fd

这种情况父子进程之间只是发送简单的字符串,并且它们之间的通信是双向的。master 与 worker 间的消息传递便是这种方式。虽然 pipe 能够满足父子进程间的消息传递,但由于 pipe 是半双工的,也就是说必须得创建 2 个 pipe 才可以实现双向的通信,这无疑使得程序逻辑更复杂。

libuv 底层采用 socketpair 来实现全双工的进程通信,父进程 fork 子进程之前会调用 socketpair 创建 2 个 fd,下面是一个最简单的也最原始的利用 socketpair 来实现父子进程间双向通信的 demo。

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
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <errno.h>
#include <sys/socket.h>
#include <stdlib.h>
#define BUF_SIZE 100

int main () {
int s[2];
int w,r;
char * buf = (char*)calloc(1 , BUF_SIZE);
pid_t pid;

if (socketpair(AF_UNIX,SOCK_STREAM,0,s) == -1 ) {
printf("create unnamed socket pair failed:%s\n", strerror(errno));
exit(-1);
}

if ((pid = fork()) > 0) {
printf("Parent process's pid is %d\n",getpid());
close(s[1]);
char *messageToChild = "a message to child process!";
if ((w = write(s[0] , messageToChild , strlen(messageToChild) ) ) == -1) {
printf("Write socket error:%s\n",strerror(errno));
exit(-1);
}
sleep(1);
if ( (r = read(s[0], buf , BUF_SIZE )) == -1) {
printf("Pid %d read from socket error:%s\n",getpid() , strerror(errno) );
exit(-1);
}
printf("Pid %d read string : %s \n",getpid(),buf);
} else if (pid == 0) {
printf("Fork child process successed\n");
printf("Child process's pid is :%d\n",getpid());
close(s[0]);
char *messageToParent = "a message to parent process!";
if ((w = write(s[1] , messageToParent , strlen(messageToParent))) == -1 ) {
printf("Write socket error:%s\n",strerror(errno));
exit(-1);
}
sleep(1);
if ((r = read(s[1], buf , BUF_SIZE )) == -1) {
printf("Pid %d read from socket error:%s\n", getpid() , strerror(errno) );
exit(-1);
}
printf("Pid %d read string : %s \n",getpid(),buf);
} else {
printf("Fork failed:%s\n",strerror(errno));
exit(-1);
}
exit(0);
}

保存为 socketpair.c 后运行 gcc socketpair.c -o socket && ./socket 输出

1
2
3
4
5
Parent process's pid is 52853
Fork child process successed
Child process's pid is :52854
Pid 52854 read string : a message to child process!
Pid 52853 read string : a message to parent process!

Node.js 中的 IPC

上面从 libuv 底层方面讲解了父子进程间双向通信的原理,在上层 Node.js 中又是如何实现的呢,让我们来一探究竟。

Node.js 中父进程调用 fork 产生子进程时,会事先构造一个 pipe 用于进程通信,

1
new process.binding('pipe_wrap').Pipe(true);

构造出的 pipe 最初还是关闭的状态,或者说底层还并没有创建一个真实的 pipe,直至调用到 libuv 底层的uv_spawn, 利用 socketpair 创建的全双工通信管道绑定到最初 Node.js 层创建的 pipe 上。

管道此时已经真实的存在了,父进程保留对一端的操作,通过环境变量将管道的另一端文件描述符 fd 传递到子进程。

1
options.envPairs.push('NODE_CHANNEL_FD=' + ipcFd);

子进程启动后通过环境变量拿到 fd

1
var fd = parseInt(process.env.NODE_CHANNEL_FD, 10);

并将 fd 绑定到一个新构造的 pipe 上

1
2
var p = new Pipe(true);
p.open(fd);

于是父子进程间用于双向通信的所有基础设施都已经准备好了。说了这么多可能还是不太明白吧? 没关系,我们还是来写一个简单的 demo 感受下。

Node.js 构造出的 pipe 被存储在进程的_channel属性上

master.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const WriteWrap = process.binding('stream_wrap').WriteWrap;
var cp = require('child_process');

var worker = cp.fork(__dirname + '/worker.js');
var channel = worker._channel;

channel.onread = function (len, buf, handle) {
if (buf) {
console.log(buf.toString())
channel.close()
} else {
channel.close()
console.log('channel closed');
}
}

var message = { hello: 'worker', pid: process.pid };
var req = new WriteWrap();
var string = JSON.stringify(message) + '\n';
channel.writeUtf8String(req, string, null);

worker.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const channel = process._channel;

channel.ref();
channel.onread = function (len, buf, handle) {
if (buf) {
console.log(buf.toString())
}else{
process._channel.close()
console.log('channel closed');
}
}

var message = { hello: 'master', pid: process.pid };
var req = new WriteWrap();
var string = JSON.stringify(message) + '\n';
channel.writeUtf8String(req, string, null);

运行node master.js 输出

1
2
3
{"hello":"worker","pid":58731}
{"hello":"master","pid":58732}
channel closed

进程失联

在多进程服务器中,为了保障整个 web 应用的稳定性,master 进程需要监控 worker 进程的 exit 以及 disconnect 事件,收到相应事件通知后重启 worker 进程。

exit 事件不用说,disconnect 事件可能很多人就不太明白了。还记得上面讲到的进程优雅退出吗,当捕获到未处理异常时,进程不立即退出,但是会立刻通知 master 进程重新 fork 新的进程,而不是等该进程主动退出后再 fork。具体的做法就是调用 worker进程的 disconnect 方法,从而关闭父子进程用于通信的 channel ,此时父子进程之间失去了联系,此时master 进程会触发 disconnect 事件,fork 一个新的 worker进程。

下面是一个触发disconnect事件的简单 demo

master.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const net = require('net');
const fork = require('child_process').fork;

var workers = [];
for (var i = 0; i < 4; i++) {
var worker = fork(__dirname + '/worker.js');
worker.on('disconnect', function () {
console.log('[%s] worker %s is disconnected', process.pid, worker.pid);
});
workers.push(worker);
}

var handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function (err,handle) {
var worker = workers.pop();
var channel = worker._channel;
var req = new WriteWrap();
channel.writeUtf8String(req, 'dispatch handle', handle);
workers.unshift(worker);
}

worker.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const net = require('net');
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const channel = process._channel;
var buf = 'hello Node.js';
var res = ['HTTP/1.1 200 OK','content-length:' + buf.length].join('\r\n') + '\r\n\r\n' + buf;

channel.ref(); //防止进程退出
channel.onread = function (len, buf, handle) {
console.log('[%s] worker %s got a connection', process.pid, process.pid);
var socket = new net.Socket({
handle: handle
});
socket.readable = socket.writable = true;
socket.end(res);
console.log('[%s] worker %s is going to disconnect', process.pid, process.pid);
channel.close();
}

运行node master.js启动服务器后,在另一个终端执行多次curl http://127.0.0.1:3000,下面是输出的内容

1
2
3
[63240] worker 63240 got a connection
[63240] worker 63240 is going to disconnect
[63236] worker 63240 is disconnected

最简单的负载均衡 server

回到前面讲的 round-robin 多进程服务器模型,用于通信的 channel 除了可以发送简单的字符串数据外,还可以发送文件描述符,

1
channel.writeUtf8String(req, string, null);

最后一个参数便是要传递的 fd。round-robin 多进程服务器模型的核心也正式依赖于这个特性。 在上面的 demo 基础上,我们再稍微加工一下,还原在 Node.js 中最原始的处理。

master.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const net = require('net');
const fork = require('child_process').fork;

var workers = [];
for(var i = 0; i < 4; i++) {
workers.push(fork(__dirname + '/worker.js'));
}

var handle = net._createServerHandle('0.0.0.0', 3000);
handle.listen();
handle.onconnection = function (err,handle) {
var worker = workers.pop();
var channel = worker._channel;
var req = new WriteWrap();
channel.writeUtf8String(req, 'dispatch handle', handle);
workers.unshift(worker);
}

worker.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const net = require('net');
const WriteWrap = process.binding('stream_wrap').WriteWrap;
const channel = process._channel;
var buf = 'hello Node.js';
var res = ['HTTP/1.1 200 OK', 'content-length:' + buf.length].join('\r\n') + '\r\n\r\n' + buf;

channel.ref();
channel.onread = function (len, buf, handle) {
var socket = new net.Socket({
handle: handle
});
socket.readable = socket.writable = true;
socket.end(res);
}

运行 node master.js, 一个简单的多进程 Node.js web 服务器便跑起来了。

小结

到此整个 Node.js 的多进程服务器模型,以及底层进程间通信原理就讲完了,也为大家揭开了 cluster 的神秘面纱, 相信大家对 cluster 有了更深刻的认识。祝大家 Node.js 的开发旅途上玩得更愉快!

原文

http://taobaofed.org/blog/2015/11/03/nodejs-cluster/
http://taobaofed.org/blog/2015/11/10/nodejs-cluster-2/

事件—初识单线程的Node.js

前言

从Node.js进入人们的视野时,我们所知道的它就由这些关键字组成 事件驱动、非阻塞I/O、高效、轻量,它在官网中也是这么描述自己的。

Node.js® is a JavaScript runtime built on Chrome’s V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient.

于是,会有下面的场景出现:
当我们刚开始接触它时,可能会好奇:

  • 为什么在浏览器中运行的 Javascript 能与操作系统进行如此底层的交互?
    当我们在用它进行文件 I/O 和网络 I/O 的时候,发现方法都需要传入回调,是异步的:

  • 那么这种异步,非阻塞的 I/O 是如何实现的?
    当我们习惯了用回调来处理 I/O,发现当需要顺序处理时,Callback Hell 出现了,于是有想到了同步的方法:

  • 那么在异步为主的 Node.js,有同步的方法嘛?
    身为一个前端,你在使用时,发现它的异步处理是基于事件的,跟前端很相似:

  • 那么它如何实现的这种事件驱动的处理方式呢?
    当我们慢慢写的多了,处理了大量 I/O 请求的时候,你会想:

  • Node.js 异步非阻塞的 I/O 就不会有瓶颈出现吗?
    之后你还会想:

  • Node.js 这么厉害,难道没有它不适合的事情吗?
    看到这些问题,是否有点头大,别急,带着这些问题我们来慢慢看这篇文章。

Node.js 结构

从 Node.js 本身入手,先来看看 Node.js 的结构。

我们可以看到,Node.js 的结构大致分为三个层次:

Node.js 标准库,这部分是由 Javascript 编写的,即我们使用过程中直接能调用的 API。在源码中的 lib 目录下可以看到。

  • Node bindings,这一层是 Javascript 与底层 C/C++ 能够沟通的关键,前者通过 bindings 调用后者,相互交换数据。实现在 node.cc
  • 这一层是支撑 Node.js 运行的关键,由 C/C++ 实现。
    • V8:Google 推出的 Javascript VM,也是 Node.js 为什么使用的是 Javascript 的关键,它为 Javascript 提供了在非浏览器端运行的环境,它的高效是 Node.js 之所以高效的原因之一。
    • Libuv:它为 Node.js 提供了跨平台,线程池,事件池,异步 I/O 等能力,是 Node.js 如此强大的关键。
    • C-ares:提供了异步处理 DNS 相关的能力。
    • http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、数据压缩等其他的能力。

Libuv

Libuv 是 Node.js 关键的一个组成部分,它为上层的 Node.js 提供了统一的 API 调用,使其不用考虑平台差距,隐藏了底层实现。

具体它能做什么,官网的这张图体现的很好:

可以看出,它是一个对开发者友好的工具集,包含定时器,非阻塞的网络 I/O,异步文件系统访问,子进程等功能。它封装了 Libev、Libeio 以及 IOCP,保证了跨平台的通用性。

我们只要先知道它本身是异步和事件驱动的,记住这点,下面的问题就有了答案,我们一一来看。

与操作系统交互

举个简单的例子,我们想要打开一个文件,并进行一些操作,可以写下面这样一段代码:

1
2
3
4
var fs = require('fs');
fs.open('./test.txt', "w", function(err, fd) {
//..do something
});

这段代码的调用过程大致可描述为:lib/fs.jssrc/node_file.ccuv_fs

Node.js 深入浅出上的一幅图:

具体来说,当我们调用 fs.open 时,Node.js 通过 process.binding 调用 C/C++ 层面的 Open 函数,然后通过它调用 Libuv 中的具体方法 uv_fs_open,最后执行的结果通过回调的方式传回,完成流程。在图中,可以看到平台判断的流程,需要说明的是,这一步是在编译的时候已经决定好的,并不是在运行时中。

总体来说,我们在 Javascript 中调用的方法,最终都会通过 process.binding 传递到 C/C++ 层面,最终由他们来执行真正的操作。Node.js 即这样与操作系统进行互动。

通过这个过程,我们可以发现,实际上,Node.js 虽然说是用的 Javascript,但只是在开发时使用 Javascript 的语法来编写程序。真正的执行过程还是由 V8 将 Javascript 解释,然后由 C/C++ 来执行真正的系统调用,所以并不需要过分担心 Javascript 执行效率的问题。可以看出,Node.js 并不是一门语言,而是一个 平台,这点一定要分清楚。

异步、非阻塞 I/O

通过上文,我们了解到,真正执行系统调用的其实是 Libuv。之前我们提到,Libuv 本身就是异步和事件驱动的,所以,当我们将 I/O 操作的请求传达给 Libuv 之后,Libuv 开启线程来执行这次 I/O 调用,并在执行完成后,传回给 Javascript 进行后续处理。

这里面的 I/O 包括文件 I/O 和 网络 I/O。两者的底层执行略有不同。从上面的 Libuv 官网的图中,我们可以看到,文件 I/O,DNS 等操作,都是依托线程池(Thread Pool)来实现的。而网络 I/O 这一大类,包括:TCP、UDP、TTY 等,是由 epoll、IOCP、kqueue 来具体实现的。

总结来说,一个异步 I/O 的大致流程如下:

  • 发起 I/O 调用
    1. 用户通过 Javascript 代码调用 Node 核心模块,将参数和回调函数传入到核心模块;
    2. Node 核心模块会将传入的参数和回调函数封装成一个请求对象;
    3. 将这个请求对象推入到 I/O 线程池等待执行;
    4. Javascript 发起的异步调用结束,Javascript 线程继续执行后续操作。
  • 执行回调
    1. I/O 操作完成后,会将结果储存到请求对象的 result 属性上,并发出操作完成的通知;
    2. 每次事件循环时会检查是否有完成的 I/O 操作,如果有就将请求对象加入到 I/O 观察者队列中,之后当做事件处理;
    3. 处理 I/O 观察者事件时,会取出之前封装在请求对象中的回调函数,执行这个回调函数,并将 result 当参数,以完成 Javascript 回调的目的。

这里面涉及到了 Libuv 本身的一个设计理念,事件循环(Event Loop),它是一个类似于 while true 的无限循环,其核心函数是 uv_run,下文会用到。

从这里,我们可以看到,我们其实对 Node.js 的单线程一直有个误会。事实上,它的单线程指的是自身 Javascript 运行环境的单线程,Node.js 并没有给 Javascript 执行时创建新线程的能力,最终的实际操作,还是通过 Libuv 以及它的事件循环来执行的。这也就是为什么 Javascript 一个单线程的语言,能在 Node.js 里面实现异步操作的原因,两者并不冲突。

事件驱动

说到,事件驱动,对于前端来说,并不陌生。事件,是一个在 GUI 开发时很常用的一个概念,常见的有鼠标事件,键盘事件等等。在异步的多种实现中,事件是一种比较容易理解和实现的方式。

说到事件,一定会想到回调,当我们写了一大堆事件处理函数后,Libuv 如何来执行这些回调呢?这就提到了我们之前说到的 uv_run,先看一张它的执行流程图:

uv_run 函数中,会维护一系列的监视器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct uv_loop_s uv_loop_t;
typedef struct uv_err_s uv_err_t;
typedef struct uv_handle_s uv_handle_t;
typedef struct uv_stream_s uv_stream_t;
typedef struct uv_tcp_s uv_tcp_t;
typedef struct uv_udp_s uv_udp_t;
typedef struct uv_pipe_s uv_pipe_t;
typedef struct uv_tty_s uv_tty_t;
typedef struct uv_poll_s uv_poll_t;
typedef struct uv_timer_s uv_timer_t;
typedef struct uv_prepare_s uv_prepare_t;
typedef struct uv_check_s uv_check_t;
typedef struct uv_idle_s uv_idle_t;
typedef struct uv_async_s uv_async_t;
typedef struct uv_process_s uv_process_t;
typedef struct uv_fs_event_s uv_fs_event_t;
typedef struct uv_fs_poll_s uv_fs_poll_t;
typedef struct uv_signal_s uv_signal_t;

这些监视器都有对应着一种异步操作,它们通过 uv_TYPE_start,来注册事件监听以及相应的回调。

uv_run 执行过程中,它会不断的检查这些队列中是或有 pending 状态的事件,有则触发,而且它在这里只会执行一个回调,避免在多个回调调用时发生竞争关系,因为 Javascript 是单线程的,无法处理这种情况。

上面的图中,对 I/O 操作的事件驱动,表达的比较清楚。除了我们常提到的 I/O 操作,图中还表述了一种情况,timer(定时器)。它与其他两者不同之处在于,它没有单独开立新的线程,而是在事件循环中直接完成的。

事件循环除了维护那些观察者队列,还维护了一个 time 字段,在初始化时会被赋值为0,每次循环都会更新这个值。所有与时间相关的操作,都会和这个值进行比较,来决定是否执行。

在图中,与 timer 相关的过程如下:

  1. 更新当前循环的 time 字段,即当前循环下的“现在”;
  2. 检查循环中是否还有需要处理的任务(handlers/requests),如果没有就不必循环了,即是否 alive。
  3. 检查注册过的 timer,如果某一个 timer 中指定的时间落后于当前时间了,说明该 timer 已到期,于是执行其对应的回调函数;
  4. 执行一次 I/O polling(即阻塞住线程,等待 I/O 事件发生),如果在下一个 timer 到期时还没有任何 I/O 完成,则停止等待,执行下一个 timer 的回调。如果发生了 I/O 事件,则执行对应的回调;由于执行回调的时间里可能又有 timer 到期了,这里要再次检查 timer 并执行回调。
    Node.js 会一直调用 uv_run 直到到循环不在 alive。

同步方法

虽然 Node.js 是以异步为主要模式的,但我们在实际开发中,难免会有一些情况是有时序性的,如果由异步来写,就会写出很丑的 Callback Hell,如下:

1
2
3
4
5
6
7
8
9
db.query('select nickname from users where id="12"', function() {
db.query('select * from xxx where id="12"', function() {
db.query('select * from xxx where id="12"', function() {
db.query('select * from xxx where id="12"', function() {
//...
});
});
});
});

这个时候如果有同步方法,就会方便很多。这一点,Node.js 的开发者也想到了,目前大部分的异步操作函数,都存在其对应的同步版本,只需要在其名称后面加上 Sync 即可,不用传入回调。

1
var file = fs.readFileSync('/test.txt', {"encoding": "utf-8});

这写方法还是比较好用的,执行 shell 命令,读取文件等都比较方便。不过,体验不太好的一点就是这种调用的错误收集,它不会像回调函数那样,在第一参数中传入错误信息,它会将错误直接抛出,你需要使用 try...catch 来获取,如下:

1
2
3
4
5
6
7
8
9
var data;
try {
data = fs.readFileSync('/test.txt');
} catch (e) {
if (e.code == 'ENOENT') {
//...
}
//...
}

至于这些方法如何实现的,我们下回再论。

一些可能的瓶颈

首先,文件的 I/O 方面,用户代码的运行,事件循环的通知等,是通过 Libuv 维护的线程池来进行操作的,它会运行全部的文件系统操作。既然这样,我们抛开硬盘的影响,对于严谨的 C/C++ 来说,这个线程池一定是有大小限制的。官方默认给出的大小是 4。当然是可以改变的。在启动时,通过设置 UV_THREADPOOL_SIZE 来改变这个值即可。不过,最大也只能是 128,因为这个是涉及到内存占用的。

这个线程池对于所有的事件循环是共享的。当一个函数要使用线程池的时候(比如调用 uv_queue_work),Libuv 会预先分配并初始化 UV_THREADPOOL_SIZE 所允许的线程出来。而128 占用的内存大约是 1MB,如果设置的太高,当使用线程池频繁时,会因为内存占用过多而降低线程的性能。具体说明;

对于网络 I/O 方面,以 Linux 系统下来说,网络 I/O 采用的是 epoll 这个异步模型。它的优点是采用了事件回调的方式,大大降低了文件描述符的创建(Linux下什么都是文件)。

在每次调用 epoll_wait 时,实际返回的是就绪描述符的数量,根据这个值,去 epoll 指定的数组里面取对应数量的描述符,是一种 内存映射 的方式,减少了文件描述符的复制开销。

上面提到的 epoll 指定的数组,它的大小即可监听的数量大小,它在不同的系统下,有不同的默认值,可见这里 epoll create

有了大小的限制,还远不够,为了保证运行的稳定,防止你在调用 epoll 函数时,指针越界,导致内存泄漏。还会用到另外一个值 maxevents,它是 epoll_wait 所能处理的最大数量,在调用 epoll_wait 时可以指定。一般情况下小于创建时(epoll_create)的数组大小,当然,也可以设置的比 size 大,不过应该没什么用。可以想到如果就绪的事件很多,超过了 maxevents,那么超出的事件就要等待前面的事件处理完成,才可以继续,可能会导致效率的下降。

在这种情况下,你可能会担心事件会丢失。其实,是不会丢失的,它会通过 ep_collect_ready_items 将这些事件保存在一个队列中,在下一个 epoll_wait 再进行通知。

Node.js 不适合做什么

虽然看起来,Node.js 可以做很多事情,并且拥有很高的性能。比如做聊天室,搭建 Blog 等等,这些 I/O 密集型的应用,是比较适合 Node.js 的。

但是,有一种类型的应用,可能 Node.js 处理起来会比较吃力,那就是 CPU 密集型的应用。前文提到,Libuv 通过事件循环来处理异步的事件,这是存在于 Node.js 主线程的机制。通过这个机制,所有的 I/O 操作,底层API的调用都变成了异步的。但用户的 Javascript 代码是运行在主线程中的,如果这部分代码运行耗时很长,就会导致事件循环被阻塞。因为,它对于事件的处理,都是按照队列顺序的,所以如果其中的任何一个事务/事件本身没有完成,那么其他的回调、监听器、超时、nextTick() 都得不到运行的机会,被阻塞的事件循环没有机会去处理它们。这样下去,轻则效率降低,重则运行停滞。

比如我们常见的模板渲染,压缩,解压缩,加/解密等操作,都是 Node.js 的软肋,所以使用的时候要考虑到这方面。

总结

  • Node.js 通过 libuv 来处理与操作系统的交互,并且因此具备了异步、非阻塞、事件驱动的能力。
  • Node.js 实际上是 Javascript 执行线程的单线程,真正的的 I/O 操作,底层 API 调用都是通过多线程执行的。
  • CPU 密集型的任务是 Node.js 的软肋。

原文

http://taobaofed.org/blog/2015/10/29/deep-into-node-1/

模块—Node.js 中的循环依赖

我们在写node的时候有可能会遇到循环依赖的情况,什么是循环依赖,怎么避免或解决循环依赖问题?

先看一段官网给出的循环依赖的代码:

a.js:

1
2
3
4
5
6
console.log('a starting'); 
exports.done = false;
var b = require('./b.js'); // ---> 1
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done') // ---> 4

b.js:

1
2
3
4
5
6
7
console.log('b starting'); 
exports.done = false;
var a = require('./a.js'); // ---> 2
// console.log(a); ---> {done:false}
console.log('in b, a.done = %j', a.done); // ---> 3
exports.done = true;
console.log('b done');

main.js:

1
2
3
4
console.log('main starting'); 
var a = require('./a.js'); // --> 0
var b = require('./b.js');
console.log('in main, a.done=%j, b.done=%j', a.done, b.done);

如果我们启动 main.js 会出现什么情况? 在 a.js 中加载 b.js,然后在b.js中加载 a.js,然后再在 a.js中加载 b.js 吗?这样就会造成循环依赖死循环。

让我们执行看看:

1
2
3
4
5
6
7
8
9
10
$ node main.js

main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done=true, b.done=true

可以看到程序并没有陷入死循环,从上面的执行结果可以看到 main.js 中先requirea.jsa.js 中执行完了consoleexport.done=fasle之后,转而去加载b.js,待b.js被load完之后,再返回a.js中执行完剩下的代码。

我在官网的代码基础上增加了一些注释,基本 load 顺序就是按照这个0-->1-->2-->3-->4的顺序去执行的,然后在第二步下面我打印出了require('./a')的结果,可以看到是{done:false},可以猜测在b.jsrequire('./a')的结果是a.js中已经执行到的exports出的值。

上面所说的还只是基于结果基础上的猜测,没有什么说服力,为了验证我的猜测是正确的,我把 Node 的源码稍微翻看了一些,C++ 的代码看不懂没关系,能看懂 JS 的部分就可以了,下面就是 Node 源码的分析(主要是 module 的分析, Node 源码在此):

将会分析的主要源码:

  1. node/src/node.js
  2. node/lib/module.js

启动 $ node main.js

C++ 的代码我看不懂,总而言之,在我查了资料之后知道当我们在shell中输入node main.js之后,会先执行 node/src/node.cc,然后会执行 node/src/node.js, 所以C++代码不分析,从分析 node/src/node.js 开始(只会分析和主题相关的代码)。

node.js 源码分析

node.js文件主要结构为

1
2
3
4
5
6
7
8
9
10
11
(function(process) {

this.global = this

function startup() {
...
}

startup()

})

这种闭包代码很常见,从名字可以看出,此处为启动文件。接下来看看 startup 函数中有一大块条件语句,我删除大多数无关代码,如下:

1
2
3
4
5
6
if (process.argv[1]) {
// ...

var Module = NativeModule.require('module');
Module.runMain();
}

我把无关的代码基本都删除了。可以看到这段代码主要做的事是先通过 Native 引入module模块,执行 Module.runMain()

很多人都知道 require 核心代码,如 require(‘path’),不需要写全路径,Node 是怎样做到的呢?

Node 采用了 V8 附带的 js2c.py 工具,将所有内置的 JavasSript 代码( src/node.js 和 lib/*.js) 转成 c++ 里面的数组生成 node_navtives.h 头文件。
在这个过程中, JavasSript 以字符串的形式存储在 node 命名空间中, 是不可直接执行的。
在启动 Node 进程时, JavaScript 代码直接加载进内存中。

Node 在启动时,会生成一个全局变量 process, 并提供 binding() 方法来协助加载内建模块。

上面大段介绍基本引自朴老师的「深入浅出 Node.js」。大概理解就是在启动命令的时候,Node 会把 node.jslib/*.js 的内容都放到 process 中传入当前闭包中,我们在当前函数就可以通过process.binding('natives')取出来放到 _source 中,如下代码所示:

1
2
3
4
5
6
7
8
9
function NativeModule(id) {
this.filename = id + '.js';
this.id = id;
this.exports = {};
this.loaded = false;
}

NativeModule._source = process.binding('natives');
NativeModule._cache = {};

接下来看看NativeModule.require做了哪些事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
NativeModule.require = function(id) {
if (id == 'native_module') {
return NativeModule;
}

var cached = NativeModule.getCached(id);
if (cached) {
return cached.exports;
}

var nativeModule = new NativeModule(id);

nativeModule.cache();
nativeModule.compile();

return nativeModule.exports;
};

这上面的代码表明内建模块被缓存,就直接返回内建模块的exports,如果没有的话,就生成一个核心模块的实例,然后先把模块根据id来cache,然后调用nativeModule.compile接口编译源文件:

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
NativeModule.getSource = function(id) {
return NativeModule._source[id];
};

NativeModule.wrap = function(script) {
return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};

NativeModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) {\n',
'\n});'
];

NativeModule.prototype.compile = function() {
var source = NativeModule.getSource(this.id);
source = NativeModule.wrap(source);

var fn = runInThisContext(source, {
filename: this.filename,
lineOffset: -1
});
fn(this.exports, NativeModule.require, this, this.filename);

this.loaded = true;
};

NativeModule.prototype.cache = function() {
NativeModule._cache[this.id] = this;
};

cache 是把实例根据 id 放到 _cache 对象中。先从 _source 中取出对应id的源文件字符串,包上一层
(function (exports, require, module, __filename, __dirname) {\n','\n});。比如main.js最终变成如下JS代码的字符串:

1
2
3
4
5
6
7
(function (exports, require, module, __filename, __dirname) {
// 如果是main.js
console.log('main starting');
var a = require('./a.js'); // --> 0
var b = require('./b.js');
console.log('in main, a.done=%j, b.done=%j', a.done, b.done);
})

runInThisContext是将被包装后的源字符串转成可执行函数,(runInThisContext来自contextify模块),runInThisContext的作用,类似eval,再执行这个被eval后的函数,就算被 load 完成了,最后把 load 设为 true。

可以看到fn的实参为 this.exports; NativeModule.require; this; this.filename;

所以require('module')的作用是加载/lib/module.js文件。让我们再回到 startup 函数,加载完 module.js,紧接着运行 Module.runMain()方法。(估计有人忘了前面的startup函数是干嘛的,我再放一次,省得再拉回去了)

1
2
3
4
5
6
if (process.argv[1]) {
// ...

var Module = NativeModule.require('module');
Module.runMain();
}

module.js源码分析

上面走完了NatvieModule的加载代码。再看看module.js是怎样加载用户使用的文件的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;
if (parent && parent.children) {
parent.children.push(this);
}

this.filename = null;
this.loaded = false;
this.children = [];
}
module.exports = Module;

Module._cache = {};
Module._pathCache = {};
Module._extensions = {};
var modulePaths = [];
Module.globalPaths = [];

Module.wrapper = NativeModule.wrapper;
Module.wrap = NativeModule.wrap;

这是Module的构造函数,Module.wrapperModule.wrap,是由NativeModule赋值来的,Module._cache是个空对象,存放所有被 load 后的模块 id。

node.js文件的 startup 函数中,最后一步走到Module.runMain():

1
2
3
4
5
6
Module.runMain = function() {
// Load the main module--the command line argument.
Module._load(process.argv[1], null, true);
// Handle any nextTicks added in the first tick of the program
process._tickCallback();
};

runMain方法中调用了_load方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Module._load = function(request, parent, isMain) {
var filename = Module._resolveFilename(request, parent);
var cachedModule = Module._cache[filename];

if (cachedModule) {
return cachedModule.exports;
}

var module = new Module(filename, parent);
Module._cache[filename] = module;

module.load(filename);

return module.exports;
};

上述代码照例我删除了一些不是很相关的代码,从剩下的代码可以看出_load函数的主要干了两件事(还有一件加载NativeModule的代码被我删掉了):

  1. 先判断当前的源文件有没有被加载过,如果 _cache 对象中存在,直接返回 _cache 中的exports对象
  2. 如果没有被加载过,新建这个源文件的 module 的实例,并存放到 _cache 中,然后调用 load 方法。
1
2
3
4
5
6
7
8
9
Module.prototype.load = function(filename) {
this.filename = filename;
this.paths = Module._nodeModulePaths(path.dirname(filename));

var extension = path.extname(filename) || '.js';
if (!Module._extensions[extension]) extension = '.js';
Module._extensions[extension](this, filename);
this.loaded = true;
};

load方法中判断源文件的扩展名是什么,默认是'.js',(我这里也只分析后缀是 .js 的情况),然后调用 Module._extensions[extension]() 方法,并传入 this 和 filename;当extension'.js'的时候, 调用Module._extensions['.js']() 方法。

1
2
3
4
5
// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
var content = fs.readFileSync(filename, 'utf8');
module._compile(internalModule.stripBOM(content), filename);
};

这个方法是读到源文件的字符串后,调用module._compile方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Module.prototype._compile = function(content, filename) {

var self = this;

function require(path) {
return self.require(path);
}

var dirname = path.dirname(filename);
// create wrapper function
var wrapper = Module.wrap(content);

var compiledWrapper = runInThisContext(wrapper,
{ filename: filename, lineOffset: -1 });

var args = [self.exports, require, self, filename, dirname];
return compiledWrapper.apply(self.exports, args);
};

其实跟NativeModule_complie做的事情差不多。先把源文件content包装一层(function (exports, require, module, __filename, __dirname) {\n','\n});, 然后通过 runInThisContext把字符串转成可执行的函数,最后把
self.exports, require, self, filename, dirname 这几个实参传入可执行函数中。

require 方法为:

1
2
3
Module.prototype.require = function(path) {
return Module._load(path, this);
};

循环依赖的时候为什么不会无限循环引用

所谓的循环依赖就是在两个不同的文件中互相应用了对方。假设按照最上面官网给出的例子中,

main.js 中:

  1. require('./a.js');此时会调用 self.require(),
    然后会走到module._load,在_load中会判断./a.js是否被load过,当然运行到这里,./a.js还没被 load 过,所以会走完整个load流程,直到_compile
  2. 运行./a.js,运行到 exports.done = false 的时候,给 esports 增加了一个属性。此时的 exports={done: false}
  3. 运行require('./b.js'),同 第 1 步。
  4. 运行./b.js,到require('./a.js')。此时走到_load函数的时候发现./a.js已经被load过了,所以会直接从_cache中返回。所以此时./a.js还没有运行完,exports = {done.false},那么返回的结果就是 in b, a.done = false;
  5. ./b.js全部运行完毕,回到./a.js中,继续向下运行,此时的./b.jsexports={done:true}, 结果自然是in main, a.done=true, b.done=true

原文

https://segmentfault.com/a/1190000004151411

Node.js与Redis

1.简介


Redis官方推荐的Node.js的Redis客户端可以选择的有node_redis[7] 和ioredis[8] ,相比而言前者发布时间较早,而后者的功能则更加丰富一些。从接口来看两者的使用方法大同小异。

2.安装


使用npm install ioredis命令安装最新版本的ioredis。

3.使用方法


首先加载ioredis模块:

1
var Redis = require('ioredis');

下面的代码将创建一个默认连接到地址127.0.0.1,端口6379的Redis连接:

1
var redis = new Redis();

也可以显式地指定需要连接的地址:

1
var redis = new Redis(6379, '127.0.0.1');

由于Node.js的异步特性,在处理返回值的时候与其他客户端差别较大。还是以GET/SET命令为例:

1
2
3
4
5
6
7
8
9
redis.set('foo', 'bar', function () {
//此时 SET 命令执行完并返回结果,
//因为这里并不关心 SET命令的结果,所以我们省略了回调函数的形参。
redis.get('foo', function (error, fooValue) {
//error 参数存储了命令执行时返回的错误信息,如果没有错误则返回 null。
//回调函数的第二个参数存储的是命令执行的结果
console.log(fooValue); // 'bar'
});
});

使用ioredis执行命令时需要传入回调函数(callback function)来获得返回值,当命令执行完返回结果后ioredis会调用该函数,并将命令的错误信息作为第一个参数、返回值作为第二个参数传递给该函数。同时ioredis还支持Promise形式的异步处理方式,如果省略最后一个回调函数,命令语句会返回一个Promise值,如:

1
2
3
redis.get('foo').then(function (fooValue) {
//fooValue 即为键值
});

Node.js的异步模型使得通过ioredis调用Redis命令的表现与Redis的底层管道协议十分相似:调用命令函数时(如redis.set())并不会等待Redis返回命令执行结果,而是直接继续执行下一条语句,所以在Node.js中通过异步模型就能实现与管道类似的效果。上面的例子中我们并不需要SET命令的返回值,只要保证SET命令在GET命令前发出即可,所以完全不用等待SET命令返回结果后再执行GET命令。因此上面的代码可以改写成:

1
2
3
4
5
//不需要返回值时可以省略回调函数
redis.set('foo', 'bar');
redis.get('foo', function (error, fooValue) {
console.log(fooValue); // 'bar'
});

不过由于SET和GET并未真正使用Redis的管道协议发送,所以当有多个客户端同时向 Redis 发送命令时,上例中的两个命令之间可能会被插入其他命令,换句话说,GET命令得到的值未必是“bar”。
虽然Node.js的异步特性给我们带来了相对更高的性能,然而另一方面使用Redis实现某个功能时我们经常需要读写若干个键,而且很多情况下都会依赖之前命令的返回结果。这时就会出现嵌套多重回调函数的情况,影响代码可读性。就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
redis.get('people:2:home', function (error, home) {
redis.hget('locations', home, function (error, address) {
redis.exists('address:' + address, function (error, addressExists) {
if (addressExists) {
console.log('地址存在。');
} else {
redis.exists('backup.address:' + address, function (error, backupAddressExists) {
if (backupAddressExists) {
console.log('备用地址存在。');
} else {
console.log('地址不存在。');
}
});
}
});
});
});

上面的代码并不是极端的情况,相反在实际开发中经常会遇到这种多层嵌套。为了减少嵌套,可以考虑使用 Async 、Step等第三方模块。如上面的代码可以稍微修改后使用Async重写为:

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
async.waterfall([
function (callback) {
redis.get('people:2:home', callback);
},
function (home, callback) {
redis.hget('locations', home, callback);
},
function (address, callback) {
async.parallel([
function (callback) {
redis.exists('address:' + address, callback);
},
function (callback) {
redis.exists('backup.address:' + address, callback);
}], function (err, results) {
if (results[0]) {
console.log('地址存在。');
} else if (results[1]) {
console.log('备用地址存在。');
} else {
console.log('地址不存在。');
}
});
}
]);

另外,可以使用co模块借助ES6的Generator特性来将ioredis的返回结果“串行化”:

1
2
3
4
5
6
7
var co = require('co');
co(function* () {
var result = yield redis.get('foo');
return result;
}).then(function (fooValue) {
console.log(fooValue);
});

4.简便用法


1.HMSET/HGETALL
ioredis同样支持在HMSET命令中使用对象作参数(对象的属性值只能是字符串),相应的HGETALL命令会返回一个对象。
2.事务
事务的用法如下:

1
2
3
4
5
6
7
var multi = redis.multi();
multi.set('foo', 'bar');
multi.sadd('set', 'a');
mulit.exec(function (err, replies) {
//replies 是一个数组,依次存放事务队列中命令的结果
console.log(replies);
});

或者使用链式调用:

1
2
3
4
5
6
redis.multi()
.set('foo', 'bar')
.sadd('set', 'a')
.exec(function (err, replies) {
console.log(replies);
});

3.“发布/订阅”模式
Node.js 使用事件的方式实现“发布/订阅”模式。现在创建两个连接分别充当发布者和订阅者:

1
2
var pub = new Redis();
var sub = new Redis();

然后让sub订阅chat频道并在订阅成功后发送一条消息:

1
2
3
sub.subscribe('chat', function () {
pub.publish('chat', 'hi!');
});

定义当接收到消息时要执行的回调函数:

1
2
3
sub.on('message', function (channel, message) {
console.log('收到' + channel + '频道的消息:' + message);
});

运行后可以看到打印的结果:

1
$ node testpubsub.js

收到chat频道的消息:’hi!’
补充知识 在 ioredis 中建立连接的过程也是异步的,执行 redis = new Redis()后连接并没有立即建立完成。在连接建立完成前执行的命令会被加入到离线任务队列中,当连接建立成功后ioredis会按照加入的顺序依次执行离线任务队列中的命令。

一.什么是redis

1.什么是redis


随着互联网的普及,用户数量的快速增长,产生的数据也越来越多,这也对我们的产品提出了新的考验,如何才能构建出高性能,而且扩展性高的应用程序呢?听说Redis是一个不错的选择,那么问题来了,什么是Redis呢?

Redis—— Remote Dictionary Server,它是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API,我们可使用它构建高性能,可扩展的Web应用程序。

Redis是目前最流行的键值对存储数据库,从2010年3月15日起,Redis的开发工作由VMware主持。从2013年5月开始,Redis的开发由Pivotal赞助。

如果你想了解Redis最新的资讯,可以访问 [官方网站]:http://redis.io/

2.什么时候使用redis


在实际生产环境中,很多公司都曾经使用过这样的架构,使用MySQL进行海量数据存储的,通过Memcached将热点数据加载到cache,加速访问,但随着业务数据量的不断增加,和访问量的持续增长,我们遇到了很多问题:   

  • MySQL需要不断进行拆库拆表,Memcached也需不断跟着扩容,扩容和维护工作占据大量开发时间。
  • Memcached与MySQL数据库数据一致性问题。
  • Memcached数据命中率低或down机,大量访问直接穿透到DB,MySQL无法支撑。
  • 跨机房cache同步问题。

以上问题都是非常的棘手,不过现在不用担心了,因为我们可以使用redis来完美解决,下面我们来了解下redis的特点,看看redis是如何解决以上问题的。

3.Redis特点


有那么多相同类型的数据库,为什么要选择redis?

相对于其他的同类型数据库而言,Redis支持更多的数据类型,除了和string外,还支持lists(列表)、sets(集合)和zsets(有序集合)几种数据类型。

这些数据类型都支持push/pop、add/remove及取交集、并集和差集及更丰富的操作,而且这些操作都是原子性的。Redis具备以下特点:

  • 异常快速: Redis数据库完全在内存中,因此处理速度非常快,每秒能执行约11万集合,每秒约81000+条记录。
  • 数据持久化: redis支持数据持久化,可以将内存中的数据存储到磁盘上,方便在宕机等突发情况下快速恢复。
  • 支持丰富的数据类型: 相比许多其他的键值对存储数据库,Redis拥有一套较为丰富的数据类型。
  • 数据一致性: 所有Redis操作是原子的,这保证了如果两个客户端同时访问的Redis服务器将获得更新后的值。
  • 多功能实用工具: Redis是一个多实用的工具,可以在多个用例如缓存,消息,队列使用(Redis原生支持发布/订阅),任何短暂的数据,应用程序,如 Web应用程序会话,网页命中计数等。

四.写自己的gulp

1.项目需求


我们将创建一个自己的gulp,具体的需求是通过gulp把我们自己所编写的JS文件合并压缩、CSS文件进行压缩后,并且生成新的文件。我们所需要的插件为:gulp-minify-css gulp-concat gulp-uglify gulp-rename del 如下图所示,完成后的项目目录结构:

2.创建项目


首先我们先来创建一个名为project的目录,然后进入到该目录下面,再将gulp安装到我们项目的目录中,然后在该目录下新建一个名称为gulpfile.js的文件。安装好后的目录结构为:

在该目录下再创建一个src目录,来存放源JS与CSS文件。建立完成后,再src目录分别建立两个js文件与一个CSS文件。完成后的目录结构为:

3.安装插件


  根据我们项目的需求,安装所需要的插件,可以通过”npm install 插件名” 来安装插件。安装完成后的目录结构如图所示。

然后打开gulpfile.js,将我们所用到的插件引用到我们项目中,代码如下:

1
2
3
4
5
6
var gulp = require('gulp'),
minifycss = require('gulp-minify-css'), //CSS压缩
concat = require('gulp-concat'), // 文件合并
uglify = require('gulp-uglify'), //js压缩插件
rename = require('gulp-rename'), // 重命名
del = require('del'); // 文件删除

4.编写代码


上一节中已经完成了对插件的引用,下面就开始我们的代码编写,可以通过gulp.start()方法来开始执行我们的任务。

1.gulp默认的执行任务是 “default”,当然你也可以指定别的名称,然后通过”gulp 任务名称” 来运行。

1
2
3
gulp.task('default',  function() {
gulp.start('clean','minifycss', 'minifyjs'); // 要执行的任务
});

2.CSS压缩

1
2
3
4
5
gulp.task('minifycss', function() {
return gulp.src('src/*.css') //压缩的文件
.pipe(minifycss()) //执行压缩
.pipe(gulp.dest('minified/css')); //输出文件夹
});

3.JS 合并压缩

1
2
3
4
5
6
7
8
gulp.task('minifyjs', function() {
return gulp.src('src/*.js')
.pipe(concat('main.js')) //合并所有js到main.js
.pipe(gulp.dest('minified/js')) //输出main.js到文件夹
.pipe(rename({suffix: '.min'})) //rename压缩后的文件名
.pipe(uglify()) //压缩
.pipe(gulp.dest('minified/js')); //输出
});

4.执行压缩前,先删除目录里的内容

1
2
3
gulp.task('clean', function(cb) {
del(['minified/css', 'minified/js'], cb)
});

好了,这样我们的代码就完成了。

5.运行项目


前面我们已经编写完成了代码,在命令行中先转到project目录下,就可以输入gulp命令来运行本项目了,刷新project目录看看会出现什么结果呢。运行完成后的目录如下图:

运行过程中的消息如下图所示: