1.4 Node的特点

作为后端JavaScript的运行平台,Node保留了前端浏览器JavaScript中那些熟悉的接口,没有改写语言本身的任何特性,依旧基于作用域和原型链,区别在于它将前端中广泛运用的思想迁移到了服务器端。下面我们来看看Node相较其他语言的一些特点。

1.4.1 异步I/O

关于异步I/O,向前端工程师解释起来或许会容易一些,因为发起Ajax调用对于前端工程师而言是再熟悉不过的场景了。下面的代码用于发起一个Ajax请求:

$.post('/url', {title: '深入浅出Node.js'}, function (data) {
  console.log('收到响应');
});
console.log('发送Ajax结束');

熟悉异步的用户必然知道,“收到响应”是在“发送Ajax结束”之后输出的。在调用$.post()后,后续代码是被立即执行的,而“收到响应”的执行时间是不被预期的。我们只知道它将在这个异步请求结束后执行,但并不知道具体的时间点。异步调用中对于结果值的捕获是符合“Don’t call me, I will call you”的原则的,这也是注重结果,不关心过程的一种表现。图1-2是一个经典的Ajax调用。

图1-2 经典的Ajax调用

在Node中,异步I/O也很常见。以读取文件为例,我们可以看到它与前端Ajax调用的方式是极其类似的:

var fs = require('fs');

fs.readFile('/path', function (err, file) {
  console.log('读取文件完成')
});
console.log('发起读取文件');

这里的“发起读取文件”是在“读取文件完成”之前输出的。同样,“读取文件完成”的执行也取决于读取文件的异步调用何时结束。图1-3是一个经典的异步调用。

图1-3 经典的异步调用

在Node中,绝大多数的操作都以异步的方式进行调用。Ryan Dahl排除万难,在底层构建了很多异步I/O的API,从文件读取到网络请求等,均是如此。这样的意义在于,在Node中,我们可以从语言层面很自然地进行并行I/O操作。每个调用之间无须等待之前的I/O调用结束。在编程模型上可以极大提升效率。

下面的两个文件读取任务的耗时取决于最慢的那个文件读取的耗时:

fs.readFile('/path1', function (err, file) {
  console.log('读取文件1完成');
});
fs.readFile('/path2', function (err, file) {
  console.log('读取文件2完成');
});

而对于同步I/O而言,它们的耗时是两个任务的耗时之和。这里异步带来的优势是显而易见的。

关于异步I/O是如何提升效率的及其本身的机制和实现,我们将在第3章中详述。

1.4.2 事件与回调函数

随着Web 2.0时代的到来,JavaScript在前端担任了更多的职责,事件也得到了广泛的应用。Node不像Rhino那样受Java的影响很大,而是将前端浏览器中应用广泛且成熟的事件引入后端,配合异步I/O,将事件点暴露给业务逻辑。

下面的例子展示的是Ajax异步提交的服务器端处理过程。Node创建一个Web服务器,并侦听8080端口。对于服务器,我们为其绑定了request事件,对于请求对象,我们为其绑定了data事件和end事件:

var http = require('http');
var querystring = require('querystring');

// 侦听服务器的request事件
http.createServer(function (req, res) {
  var postData = '';
  req.setEncoding('utf8');
  // 侦听请求的data事件
  req.on('data', function (chunk) {
    postData += chunk;
  });
  // 侦听请求的end事件
  req.on('end', function () {
    res.end(postData);
  });
}).listen(8080);
console.log('服务器启动完成');

相应地,我们在前端为Ajax请求绑定了success事件,在发出请求后,只需关心请求成功时执行相应的业务逻辑即可,相关代码如下:

$.ajax({
  'url': '/url',
  'method': 'POST',
  'data': {},
  'success': function (data) {
    // success事件
  }
});

相比之下,无论在前端还是后端,事件都是常用的。对于其他语言来说,这种俯拾皆是JavaScript的熟悉感觉是基本不会出现的。

事件的编程方式具有轻量级、松耦合、只关注事务点等优势,但是在多个异步任务的场景下,事件与事件之间各自独立,如何协作是一个问题。

从前面可以看到,回调函数无处不在。这是因为在JavaScript中,我们将函数作为第一等公民来对待,可以将函数作为对象传递给方法作为实参进行调用。

与其他的Web后端编程语言相比,Node除了异步和事件外,回调函数是一大特色。纵观下来,回调函数也是最好的接受异步调用返回数据的方式。但是这种编程方式对于很多习惯同步思路编程的人来说,也许是十分不习惯的。代码的编写顺序与执行顺序并无关系,这对他们可能造成阅读上的障碍。在流程控制方面,因为穿插了异步方法和回调函数,与常规的同步方式相比,变得不那么一目了然了。

在转变为异步编程思维后,通过对业务的划分和对事件的提炼,在流程控制方面处理业务的复杂度与同步方式实际上是一致的。

关于流程控制和事件协作的方法和技巧,我们将在第4章中进一步探讨。

1.4.3 单线程

Node保持了JavaScript在浏览器中单线程的特点。而且在Node中,JavaScript与其余线程是无法共享任何状态的。单线程的最大好处是不用像多线程编程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。

同样,单线程也有它自身的弱点,这些弱点是学习Node的过程中必须要面对的。积极面对这些弱点,可以享受到Node带来的好处,也能避免潜在的问题,使其得以高效利用。单线程的弱点具体有以下3方面。

  • 无法利用多核CPU。

  • 错误会引起整个应用退出,应用的健壮性值得考验。

  • 大量计算占用CPU导致无法继续调用异步I/O。

像浏览器中JavaScript与UI共用一个线程一样,JavaScript长时间执行会导致UI的渲染和响应被中断。在Node中,长时间的CPU占用也会导致后续的异步I/O发不出调用,已完成的异步I/O的回调函数也会得不到及时执行。

最早解决这种大计算量问题的方案是Google公司开发的Gears。它启用一个完全独立的进程,将需要计算的程序发送给这个进程,在结果得出后,通过事件将结果传递回来。这个模型将计算量分发到其他进程上,以此来降低运算造成阻塞的几率。后来,HTML5定制了Web Workers的标准,Google放弃了Gears,全力支持Web Workers。Web Workers能够创建工作线程来进行计算,以解决JavaScript大计算阻塞UI渲染的问题。工作线程为了不阻塞主线程,通过消息传递的方式来传递运行结果,这也使得工作线程不能访问到主线程中的UI。

Node采用了与Web Workers相同的思路来解决单线程中大计算量的问题:child_process

子进程的出现,意味着Node可以从容地应对单线程在健壮性和无法利用多核CPU方面的问题。通过将计算分发到各个子进程,可以将大量计算分解掉,然后再通过进程之间的事件消息来传递结果,这可以很好地保持应用模型的简单和低依赖。通过Master-Worker的管理方式,也可以很好地管理各个工作进程,以达到更高的健壮性。

关于如何通过子进程来充分利用硬件资源和提升应用的健壮性,这是一个值得探究的话题。怎样才能使我们既享受到无忧无虑的单线程编程,又高效利用资源呢?请挪步到第9章。

1.4.4 跨平台

起初,Node只可以在Linux平台上运行。如果想在Windows平台上学习和使用Node,则必须通过Cygwin或者MinGW。随着Node的发展,微软注意到了它的存在,并投入了一个团队帮助Node实现Windows平台的兼容,在v0.6.0版本发布时,Node已经能够直接在Windows平台上运行了。图1-4是Node基于libuv实现跨平台的架构示意图。

图1-4 Node基于libuv实现跨平台的架构示意图

兼容Windows和*nix平台主要得益于Node在架构层面的改动,它在操作系统与Node上层模块系统之间构建了一层平台层架构,即libuv。目前,libuv已经成为许多系统实现跨平台的基础组件。关于libuv的设计,我们将在第3章中介绍。

通过良好的架构,Node的第三方C++模块也可以借助libuv实现跨平台。目前,除了没有保持更新的C++模块外,大部分C++模块都能实现跨平台的兼容。

目录