topics:
1.The Node.js philosophy
2.The reactor pattern
3.The Node.js callback pattern
4.The module system and its patterns
5.The observer pattern: the EventEmitter class
1.Node的哲学
这种哲学一方面源于贡献核心代码的社区参与者,一方面继承于JavaScript文化和Unix哲学。这种内涵的设计哲学在我们寻找程序设计的灵感源头时有着特殊的启发式作用。
尽可能减小核心:
Node架构中维护着最小的函数集,将剩下的函数交给用户空间来定义。其模块生态(module)与核心(core)相互分离。保持核心函数集在一个最小限度水平,不仅提高了架构的可维护性,而且带动了整个Node生态的进化。
尽可能减小模块:
Node通过使用模块(Module)的概念来架构程序代码。将创建应用程序和可重用的库简称为包。由于包通常有一个单模块的入口点,因此也经常将包引申为模块。
Node尽可能的将模块设计限制于较小的范围:1.代码量的控制。2.作用域范围的控制。
其思想根源于Unix的设计哲学中,精华的2点:
• "Small is beautiful."
• "Make each program do one thing well."
Node将模块化的思想带到了全新的层次。在npm(官方包管理)机制的帮助下,为了解决“依赖地狱问题”,Node尽力确保每个安装的包(packet)都有它自己独立维护的一组依赖,这样可以使程序依赖于众多包而不用担心任何引入造成的冲突。
Node风格为了顾忌高度的可重用性,将应用构造为由大量小而集中的依赖组成。Node中鼓励细粒度拆分维护的做法,不难看到很多包中有少于100行的代码或者只暴露出一个单独的函数的范例。
其他好处(便于分享和重用):
• Easier to understand and use
• Simpler to test and maintain
• Perfect to share with the browser
尽可能减小表面积:
除了尽可能的减少代码量和作用域范围,Node模块还拥有特性来确保暴露最少的函数集。这种做法提高了API的可用性,使API设计变得更加清晰并更少的暴露在错误使用中。事实上,大多数情境下,组件的使用只限制和聚焦于有限的特性上,而不需要扩展函数或深入高级方面。
模块定义中非常普遍的做法是只暴露一个功能点,比如说一个函数或者构造器,而让更高级的方面或者次要的特性,成为所暴露出的函数或者构造器的属性。这有助于开发者去确定在这些所提供的功能集中,哪些是主要的,哪些是次要的。不难看到有的模块只暴露出一个函数,提供一个明确无误的入口点。
另外一个值得提出的点是:模块是基于利用而非继承的。通过禁止扩展的可能来锁定模块的内部构造听起来是欠缺灵活性的。但实际上,这样做有减少使用用例、简化实现、便于维护和增加可用性的好处。
简单和实用主义:
"The design must be simple, both in implementation and interface. It is more
important for the implementation to be simple than the interface. Simplicity is
the most important consideration in a design."
设计一个简洁而不是完美但是功能完备的软件,是一种最佳实践,具备以下优点:可以花费较少的努力而实现,能够以较少的资源实现更快的传输,更容易适应,更容易维护和理解。这些特性促进了社区贡献,也完善了软件自身的发展和改进。
Node基于JavaScript这种非常务实的语言,在JS中经常能看到简单的函数、闭包和对象字面量来代替复杂的类层级结构。纯面向对象的设计总是以一个数学描述化的计算机系统来复刻真实世界,而没有考虑到真实世界本身的不完善性和复杂性。真相其实是这样:我们所设计的软件永远是一种近似真实的存在,我们能做的优化主要是以一定可接受的算法复杂度尽可能快的完成工作,而不是尝试去设计一种近似于完美的软件却付出巨大的代价并导致大量的代码需要去维护。在全文可以多次看到这个原则的体现,通过传统的单例、装饰器模式的使用,我们试图做一种琐碎甚至有时候不是万无一失的实施,实践过程中更加趋向于一种简化,可实施性强的纯粹无瑕设计。
2.Reactor模式
在这一节主要分析reactor模式,这部分是Node中异步特性的核心。将逐步剖析模式背后的概念,比如:单线程架构、非阻塞I/O,并了解如何为整个Node平台打下基础。
I/O is slow
在计算机的基础操作中,I/O操作是最慢的。访问RAM是以纳秒计算耗时的(10e-9秒),在访问硬盘或网络中的数据时是以毫秒(到10e-3秒)计算耗时的。对带宽指标而言,也是同样的问题,RAM传输速率保持在GB/s指标上,而硬盘和网络由一般的MB/s级别到优化后的GB/s之间。相对于CPU资源而言,I/O资源相对来说没有那么昂贵,但是它增加了请求发出时刻到操作完成时刻之间的延迟。更重要的是,我们还要考虑到人为因素,举个例子,点击按钮或者发送一条信息在实时聊天应用中这个操作,I/O的速度和频率不再仅仅决定于技术方面,它们可以比硬盘和网络指标慢很多个数量级。
Blocking I/O
在传统的阻塞式I/O编程中,I/O请求相对应的函数调用将阻塞线程的执行指导操作完成。为防止数据是由于用户操作产生的(比如按下一个键),硬盘访问的时间将从几微秒到几分钟甚至更多。下面伪代码显示了一个典型针对socket的阻塞读取操作。
//blocks the thread until the data is available
data = socket.read();
//data is available
print(data);
值得注意的是:使用阻塞式I/O实现的web服务器将不能在同一个线程处理多条连接(每个I/O操作在一个socket将阻塞其他任何连接的处理)。因此在Web服务器中处理并发问题的传统方法是为了需要被处理的每个并发连接启动线程或进程(或者重用一个池中的线程或进程)。这样,当一个线程阻塞I/O操作时,它将不会影响其他请求的可用性,因为他们都是在独立的线程中被处理的。
该图描绘了前文提出的场景,强调了每个线程的空闲时间,等待来自于相关连接的新数据被接收。现在,我们考虑到任何类型的I/O都可能阻塞请求(例如当在和数据库或文件系统交互时,我很快意识到一个线程需要阻塞多少次来等待I/O操作的结果)。不幸的是,线程在系统资源方面来看并不便宜,它消耗内存并导致上下文切换,所以安排一个长时间运行的线程来针对每个连接并导致大多数时间无法利用它,针对效率而言这并不是最好的折中。
Non-blocking I/O
除了阻塞式I/O,大多数的现代操作系统支持另一种资源访问机制,被称为非阻塞式I/O。在这种操作模式下,系统调用总是立即返回,而不需要等待数据被读取或者写入。如果调用时没有结果,则函数只返回预定义的常量,表明当前没有可以返回的数据。
例如,在Unix操作系统中,fcntl()函数被用来操作现有文件描述符以将其模操作式更改为非阻塞形式(伴随着O_NONBLOCK标志位)。一旦资源处于非阻塞模式下,伴随着返回码字为EAGAIN,任何读取操作将会失败,来避免资源中没有任何数据准备好被读取。
访问这种非阻塞I/O模式的基本模式是:在循环中动态的轮询资源,直到返回一些实际数据,这种形式被称作:忙碌-等待模式。以下伪代码将展示如何使用非阻塞式I/O和轮询循环来读取多种资源。
resources = [socketA, socketB, pipeA];
while(!resources.isEmpty()) {
for(i = 0; i < resources.length; i++) {
resource = resources[i];
//try to read
var data = resource.read();
if(data === NO_DATA_AVAILABLE)
//there is no data to read at the moment
continue;
if(data === RESOURCE_CLOSED)
//the resource was closed, remove it from the list
resources.remove(i);
else
//some data was received, process it
consumeData(data);
}
}
我们可以看到,伴随着这种简洁的技术,可以在同一线程中处理不同的资源,但是事实上效率仍然不高。事实上,在前面的例子中,循环将消耗宝贵的CPU资源来对大部分时间不能够提供使用的资源进行迭代。轮询算法常常会导致对于CPU处理时间的大量浪费。
Event demultiplexing
繁忙-等待模式对于非阻塞式资源而言绝对不是一个理想的技术,但是幸运的是,大多数现代操作系统提供了一种原生的机制去处理并发的、非阻塞式资源的高效方式,这种机制被称为同步事件解复用或者事件通知接口。这个组件针对所监控的一组资源而来的I/O事件进行收集和排队处理。并且阻塞指导新的事件可以供处理。以下伪代码描述了一个算法,来使用一个通用的同步的事件解复用器来读取两种不同的资源。
socketA, pipeB;
watchedList.add(socketA, FOR_READ); //[1]
watchedList.add(pipeB, FOR_READ);
while(events = demultiplexer.watch(watchedList)) { //[2]
//event loop
foreach(event in events) { //[3]
//This read will never block and will always return data
data = event.resource.read();
if(data === RESOURCE_CLOSED)
//the resource was closed, remove it from the watched list
demultiplexer.unwatch(event.resource);
else
//some actual data was received, process it
consumeData(data);
}
}
上述伪代码的重要步骤如下:
1.资源被添加到一个数据结构中去,伴随着其中的每一份资源给予一个特殊的操作,在我们的例子中是一个读取操作。
2.伴随着资源组被监控,资源通知机制也被建立起来了。这个调用是同步的,并且阻塞直到所监控资源中的任何一份已经准备好被读取。当发生这种情况时,事件解复用器从一个调用中返回,并且新的一批事件已经准备好被处理。
3.事件解复用器返回的每个事件都被处理。此时,与每个事件相关联的资源都被确保准备好被读取并且在操作期间不会阻塞。当所有事件被处理后,流会再次在事件解复用器被阻塞,直到新的事件再次可以供利用来提供处理。这个过程被称为时间循环(event loop)。
有趣的是使用这种模式,我们可以处理在单线程中的多个I/O操作,而不适用繁忙-等待模式技术。下图告诉我们,一个web服务器如何通过一个同步事件解复用器和一个单线程来处理众多连接。
前面的图像帮助我们了解了并发性在单线程应用程序中如何使用同步事件解复用器和非阻塞式I/O。我们可以看到仅用一个线程不会影响我们并行运行多个I/O绑定任务的能力。任务群是随着时间而传播的,而不是跨多个线程传播。这样设计将就最大限度的减少线程总的空闲时间方面带来明显优势。如图所示。而这还不是选择这个模型的唯一原因。事实上,只有一个线程对程序员处理一般性并发方式产生有益的影响。在本书中,我们将看到如何在不使用进程竞争和多线程同步的前提下,让我们能够采用更简洁的并发处理策略。
在下一章中,我们将有机会去讨论更多关于Node的并发模型。
The reactor pattern
我们现在可以介绍反应器(reactor)模式,这个是前一部分所提出算法一个专门化实现。算法背后的主要思想是对应每一个I/O操作有一个与此联系的处理程序(在Node中以回调函数形式来表示),当事件被事件循环(event loop)产生并且处理时这个处理程序会被触发。反应器模式的架构将在下图中被展示出来:
这描述的一个应用使用反应器模式的过程:
1.应用程序通过向事件解复用器提交一个请求来产生一个新的I/O操作。应用程序还制定一个处理程序,这个处理程序将在操作完成的时候触发。提交一个新的请求到事件解复用器处,这个过程是一个非阻塞式的调用,并且它立即返回控制权给应用程序。
2.当一系列I/O操作完成时,事件解复用器将新的事件push推入事件队列中去。
3.在这个时间点上,时间循环遍历事件队列中的所有项目。
4.对于每个事件而言,其相关联的处理程序被调用。
5.处理程序作为应用代码的一部分,将会在它执行完成的时候,将控制权返回给事件循环(Event Loop)如图(5a)过程所描述。然而,新的异步操作可能在处理程序执行期间被请求到,如(5b)过程所示,导致新的操作被插入到事件解复用器中(1),这个过程发生在控制权被重新给到事件循环(Event Loop)之前。
6.当事件队列中所有项目被处理完时,事件解复用器中的循环将会再次阻塞,这将导致之后触发下一轮次的循环。异步行为现在很清楚了:应用程序在某个时间点表达兴趣去访问一个资源(非阻塞模式)并提供了一个处理程序,这个处理程序将会在操作完成时的那个时间点被调用。
当事件解复用器重没有挂起的操作并且事件队列中没有更多的事件被处理是,Node应用程序将会自动退出。
定义Node核心的设计模式如下:
模式(反应器Reactor):处理I/O阻塞直到监控中的一组资源可以提供给新的事件利用,之后通过分配事件到其所关联的处理程序来发起反应。
The non-blocking I/O engine of Node.js – libuv
每个操作系统都有它自己的事件解复用接口:Linux的epoll、Mac OS X的kqueue、Windows的I/O完成端口(IOCP)。除此之外,每个I/O操作都可以根据资源的类型表现出完全不同的行为,即使是在同一操作系统之中。举个例子,在Unix中常规的文件系统文件不支持非阻塞式操作,因此,为了模拟非阻塞行为,有必要在事件循环(Event Loop)之外使用单独的线程。在操作系统内部和之间的所有不一致性都需要在事件解复用器上建立更高层次的抽象。这就是为什么Node核心团队创作了一个命名为libuv的C语言库,旨在:使Node兼容不同的主流平台,并且标准化不同类型资源的非阻塞行为,在当今libuv库代表Node中底层的I/O引擎。
除了对于抽象底层系统的调用,libuv还实现了反应器(reactor)模式,由此为创建时间循环(Event Loop)、管理实践队列、运行异步I/O操作以及为其他类型的任务排队,来提供API。
The recipe for Node.js
反应器(Reactor)和libuv库是Node的基本构建块,但我们仍需要以下三方面组件来构建完整的平台。
1.一组负责包装和暴露libuv和其他底层JavaScript函数的绑定。
2.V8,由Google最初开发用于Chrome浏览器的JavaScript引擎。这是Node之所以如此快速而高效的原因。V8引擎以其革命性设计所带来的速度提升和高效的内存管理而备受赞誉。
3.实现Node高层API的核心JavaScript库(被称为node-core)。
最后,这是Node的食谱,下图展示出最终架构。
The callback pattern
回调是反应器模式(Reactor)中处理程序的物化,它实际上是语法的印记,来给予Node其独特的编程风格。回调指的是一些函数被调用来传播操作的结果而这正式我们在处理异步操作时所需要的。它实际上取代了return指令的使用(如我们所知,return指令总是同步执行的)。JavaScript是一种伟大的语言适合表示回调,正如我们所知,函数是第一类对象并且很容易赋值给变量、作为参数传递、从另一个函数调用返回、或者存储在数据结构之中。同时,闭包是一种理想的构造来实现回调函数。通过闭包,我们事实上可以引用创建函数的环境。事实上,无论回调被调用的时间或地点,我们都可以保持异步操作被请求的上下文。
在本节中,我们将分析这种由回调而不是return指令所造就的编程风格。
The continuation-passing style
在JavaScript中,回调是一种被作为参数传递给另一个函数的函数,并在操作完成之后调用结果。在函数式编程中,这种传播结果的方式被称为连续传递模式,简称为CPS。它是一种通用的概念,并不总与异步操作相关联,事实上,它只是表明将结果传递给另外一个函数(回调)而不是直接返回给调用者。
Synchronous continuation-passing style
为了说明这种概念,我们来看一个简单的同步函数:
function add(a, b) {
return a + b;
}
这没有什么特别的,结果通过return指令返回给调用者,这也被称为直接模式,代表了在异步编程中返回一个结果的最普遍做法。前文函数中的等价持续传递模式,如下所示:
function add(a, b, callback) {
callback(a + b);
}
add()函数是一个同步CPS函数,这意味着只有当回调完成它的运行时才能返回一个值。以下代码演示出这种声明:
console.log('before');
add(1, 2, function(result) {
console.log('Result: ' + result);
});
console.log('after');
由于add()是同步的,因此之前的代码很自然的,打印如下结果:
before
Result: 3
after
Asynchronous continuation-passing style
现在我们考虑以上示例中,其中add()函数是异步的,如下所示:
function addAsync(a, b, callback) {
setTimeout(function() {
callback(a + b);
}, 100);
}
在前面的伪代码中,我们简单的使用setTimeout()来模拟回调的异步调用。现在,让我们尝试使用这种函数,然后看看执行的顺序如何改变:
console.log('before');
addAsync(1, 2, function(result) {
console.log('Result: ' + result);
});
console.log('after');
之前的伪代码将打印如下结果:
before
after
Result: 3
因为setTimeout()触发了一次异步调用,它将不再会等待回调函数被执行,而是将立即将控制权重新交给addAsync(),并在之后重新交给调用者。这种Node特性很关键,因为它允许堆栈展开,并且一旦异步请求被发送,控制权就将重新给到事件循环(Event Loop),这样就允许来自队列中的一个新的事件被处理。
下图展示这个机制如何运作:
当异步操作完成时,执行将在之后由回调重新开始,来提供给导致展开的异步函数。执行将从事件循环(Event Loop)开始,所以它将拥有全新的堆栈。这正是JavaScript真正得力的地方,事实上,在闭包机制的作用下,保持异步函数的调用者的上下文是非常自然的,即使回调被在一个不同的时间点和一个不同位置被调用。
同步函数将会阻塞,直到它能完成操作为止。异步函数立即返回,并且将结果传递给一个处理程序(handler),在我们的例子中是一个回调函数,这个返回过程在之后的一个事件循环(Event Loop)周期中。
Non continuation-passing style callbacks
在很多情况下,回调函数作为参数的存在可能会让你觉得函数是异步的或者它使用了一种持续传递模式。这种观点未必一直都对,举个例子,对于数组对象的map()方法:
var result = [1, 5, 7].map(function(element) {
return element – 1;
});
很显然,回调函数只是被用来迭代遍历数组中的元素,而不是传递运行的结果。事实上,结果是使用一个直接模式做到同步返回。回调的意图在文档的API中已经明确说明。
Synchronous or asynchronous?
我们已经看到了指令的顺序如何根据函数特性的同步或者异步模式,而彻底变化。对于整个应用程序的业务流而言,无论是正确性还是有效性都带来了强烈的影响。以下是对于两种范式及其缺陷的分析:一般而言,我们必须竭力避免在API特性上造成不一致和混乱,因为这样可能会导致难以检测或者重现的问题。为了推动我们分析,将讨论一个异步函数不一致的范例。
An unpredictable function
最危险的情况之一是:有一个API,在某些条件下表现为同步,在某些条件下表现为异步。我们通过以下代码举例说明:
var fs = require('fs');
var cache = {};
function inconsistentRead(filename, callback) {
if(cache[filename]) {
//invoked synchronously
callback(cache[filename]);
} else {
//asynchronous function
fs.readFile(filename, 'utf8', function(err, data) {
cache[filename] = data;
callback(data);
});
}
}
前面的伪代码通过使用缓存变量来存储不同文件读取操作的结果。请记住这只是一个例子,它没有错误管理并且缓存逻辑本身也是没有优化的。除此之外,前面的函数可能是危险的,因为它将表现为异步模式,直到缓存没有设置-这个过程直到fs.readFile()函数返回它的结果-但是对于一个已经在缓存中的文件而言,其所有的后续请求将会被同步处理-立即触发回调函数的调用。
Unleashing Zalgo
现在我们一起来看看如何使用不可预估的函数,比如我们之前定义的那个,很容易使一个应用程序崩溃。考虑以下代码:
function createFileReader(filename) {
var listeners = [];
inconsistentRead(filename, function(value) {
listeners.forEach(function(listener) {
listener(value);
});
});
return {
onDataReady: function(listener) {
listeners.push(listener);
}
}
}
当前面的函数被调用时,它会创建一个新对象作为通知器(notifier),允许设置很多监听器(listeners)来针对一个文件读取操作。当读取操作完成并且数据可用时,所有的监听器(listeners)将同时被调用。前面的函数通过利用我们的inconsistentRead()函数来实现这个功能。我们现在尝试使用createFileReader()函数:
var reader1 = createFileReader('data.txt');
reader1.onDataReady(function(data) {
console.log('First call data: ' + data);
//...sometime later we try to read again from
//the same file
var reader2 = createFileReader('data.txt');
reader2.onDataReady(function(data) {
console.log('Second call data: ' + data);
});
});
上述伪代码将打印,如下输出结果:
First call data: some data
正如您所见第二个操作的回调从来没有被调用过,让我们看看为什么:
• reader1的构造过程中,我们的inconsistentRead()函数表现为异步的模式,因为这里没有已经缓存的结果可供使用。因此,我们使用所有的时间去注册监听器。因为当读取操作完成时,监听器将会在事件循环(Event Loop)中的另一个循环中稍后被调用。
•然后,reader2在一个所请求文件的缓存已经存在的事件循环(Event Loop)周期中被创建。在这种情况下,针对inconsistentRead()函数的内部调用将会是同步的。因此它的回调会被立即调用,这意味着:针对reader2所有的监听器将会同步被调用。然而,我们在创建reader2之后才正在注册监听器(listeners),因此他们将会无法被调用。
我们的inconsistentRead()函数的回调表现确实是无法预知的,它取决于很多因素,比如说:它被调用的频率、作为参数传递的文件名、用于加载文件所需要的时间。
我们刚才看到的bug,在实际应用中想要识别和复现是非常复杂的。设想在web服务器中使用类似的函数,其业务情境下会有很多并发请求。设想其中一些请求被挂起,没有明显理由和错误记录日志。这毫无疑问属于严重缺陷的范畴。
Isaac Z. Schluete,npm的创立者以及Node项目的主要领导者,在他所发表的博客之一中比较了使用不可预知函数和unleashing Zalgo问题。如果你不熟悉Zalgo问题,请您尝试了解它是什么。
Using synchronous APIs
能从unleashing Zalgo例子中研究的点是:对于一个API定义其特性是同步模式还是异步模式是势在必行的,针对我们所提出inconsistentRead()函数一个最合适的修复方式是:改造使其完全同步。这可能是因为Node提供了一系列同步模式直接风格的API来针对于大多数基本的I/O操作。比如,我们可以利用fs.readFileSync()来代替它所对应的异步函数,代码将会如下所示:
var fs = require('fs');
var cache = {};
function consistentReadSync(filename) {
if(cache[filename]) {
return cache[filename];
} else {
cache[filename] = fs.readFileSync(filename, 'utf8');
return cache[filename];
}
}
我们可以看到整个函数被转换为直接的形式。如果函数是同步的,就没有理由是持续传递模式的。事实上,我们可以确定以直接形式实现同步API是一种好的实现方式。这种方式可以消除关于它本身特性的任何疑惑,并且从性能角度来看也更加有效。
模式:偏好于纯同步函数的直接形式。
请记住,当改变API从持续传递模式到直接模式时,或者说从异步模式到同步模式时,需要改变所有使用它的代码的编程风格。比如,在我们的例子中,我们不得不完全改变createfilereader()函数的API接口并令其适应总是同步的工作。
另外,使用同步API而不是异步API具有一些注意事项:
•对于所需的函数,同步API并不总是可用的。
•同步API将会阻塞事件循环(Event Loop)并将并发请求搁置。它实际上打破了Node的并发性,减缓了整个应用程序。我们将在之后在书的了解整个情况对于我们的应用程序真正意味着什么。在我们的consistentReadSync()函数当中,阻塞事件循环(Event Loop)的风险被部分缓解,因为同步I/O式API针对每个文件名只调用一次,缓存值将用于后续调用。如果我们拥有数目有限的静态文件,在这之后使用consistentReadSync()函数将不会对我们的事件循环(Event Loop)造成很大的影响。情况将会产生变化,如果我们在一个情境下不得不读取很多文件并且读取次数被限定为一次,使用同步I/O模式在Node架构下在很多情形下使被强烈反对的。然而,在一些特定情境下,将会是最简洁并且最有效的解决方案。总是要评估你的具体使用案例来作出最正确的选择。使用阻塞式API只有在它不影响应用程序服务于并发请求的前提下。
Deferred execution
另一个可选的方案来修复我们的inconsistentRead()函数,是构造使其纯异步。这里的技巧是安排同步回调调用在未来被调用而不是在同一个事件循环(Event Loop)周期里被立即运行。在Node中,这是通过使用process.nextTick()函数可能实现的,这个函数能够延迟一个函数的执行直到事件循环(Event Loop)中的下一个事件到达为止。它的函数形式非常简单,将一个回调当做参数并且将它压如事件队列的头部,在任何挂起I/O事件之前,并且立即返回。回调将在事件循环(Event Loop)再次运行之后被返回。
让我们采用这项技术来修复我们的inconsistentRead()函数如下所示:
var fs = require('fs');
var cache = {};
function consistentReadAsync(filename, callback) {
if(cache[filename]) {
process.nextTick(function() {
callback(cache[filename]);
});
} else {
//asynchronous function
fs.readFile(filename, 'utf8', function(err, data) {
cache[filename] = data;
callback(data);
});
}
}
现在我们的函数可以在任何情况下,保证异步的调用它的回调函数。
另外一个API来延迟代码的运行是setImmediate(),这个函数-尽管名字看起来是即时的-事实上却比process.nextTick()函数要慢。尽管两种解决方案的目的是相似的,他们的语义却差别很大。回调以process.nextTick()在任何其他I/O事件被触发之前被运行的形式而被延迟。而伴随着setImmediate()函数,这次运行在任何已经在队列中的I/O事件之后排队。process.nextTick()因为在已经安排好的I/O之前运行,它将会在一定条件下造成I/O的饥饿效应,比如,一个递归调用。而这种情况将不会发生在setImmediate()函数上。在本书后面,我们将在分析使用延迟调用来针对于运行中的同步CPU绑定的任务进行处理时,学会区分这两种API之间的不同。
模式:我们保证回调函数,通过使用process.nextTick()函数延迟它的运行,来确保回调被异步调用。
Node.js callback conventions
在Node架构中,持续传递形式的API和回调函数遵循一系列特殊的约定。这些约定被应用于Node核心API,但是他们也被用户平面的模块进而应用虚拟化的遵循着。因此,当我们需要设计异步API时,我们需要理解这些约定并且遵守它们。
Callbacks come last
在Node架构中,如果一个函数在输入中接收一个回调函数,那么需要把它传递作为最后的参数。让我们通过以下Node核心API作为范例:
fs.readFile(filename, [options], callback)
正如你可以从前面函数的形式中可以看出来的一样,回调函数总是被安排在最后的位置,即使在可选参数存在的情况下也是如此。这个约定的动机是使函数调用有更好的可读性,以防回调需要就地定义。
Error comes first
在持续传递模式中,错误像是其他类型的结果一样被传播,这意味着要使用回调。在Node当中,任何由持续传递式函数导致的错误都被传递作为回调函数的第一个参数,并且任何真实的结果被传递为由第二个位置开始的参数。如果操作成功且无错误,第一个参数将会是null或者undefined。接下来的代码会展示给你,如何定义一个采纳了这些约定的回调:
fs.readFile('foo.txt', 'utf8', function(err, data) {
if(err)
handleError(err);
else
processData(data);
});
经常检查错误的存在是好的实践方式,因为如果不这么做,将使我们很难调试代码来发现很多可能的问题点。另外一个需要考虑到的约定是错误常常是类型错误。这意味着不应该将简单字符串或者数字作为错误对象传递。
Propagating errors
在同步、直接模式函数中传播错误问题,由著名的抛出(throw)命令来处理,这种命令导致在调用栈中跳到错误被捕获的位置为止。然而,在异步持续传递模式下,合适的错误传播处理通过简洁的传递错误到CPS链条中的下一个回调函数为止。典型的模式如下所示:
var fs = require('fs');
function readJSON(filename, callback) {
fs.readFile(filename, 'utf8', function(err, data) {
var parsed;
if(err)
//propagate the error and exit the current function
return callback(err);
try {
//parse the file contents
parsed = JSON.parse(data);
} catch(err) {
//catch parsing errors
return callback(err);
}
//no errors, propagate just the data
callback(null, parsed);
});
};
在前面伪代码中需要注意的点是:当我们想要传递一个有效的结果和我们想要传播一个错误时,回调如何被执行。
Uncaught exceptions
你可能看到过之前定义过的readJSON()函数,在其中为了避免任何的异常被抛入fs.readFile()回调中,我们设计了一个try-catch代码块来包裹JSON.parse()函数。在异步回调中抛出,事实上,会导致异常跳到事件循环(Event Loop)中而永远不能传播到下一个回调函数中去。
在Node架构中,这是一个不可恢复的状态,并且应用将会简单的关闭针对标准错误库接口的错误信息打印。为了证明这点,我们尝试从之前定义的readJSON()函数中移除try-catch代码块。
var fs = require('fs');
function readJSONThrows(filename, callback) {
fs.readFile(filename, 'utf8', function(err, data) {
if(err)
return callback(err);
//no errors, propagate just the data
callback(null, JSON.parse(data));
});
};
现在在刚刚定义的函数中,没有办法缓存来自于JSON.parse()的最终异常。我们尝试,举个例子,解析一个有效的JSON文件,代码如下所示:
readJSONThrows('nonJSON.txt', function(err) {
console.log(err);
});
这将导致应用程序突然中止,并在控制台上打印以下异常信息。
SyntaxError: Unexpected token d
at [...]/06_uncaught_exceptions/uncaught.js:7:25
at fs.js:266:14
at Object.oncomplete (fs.js:107:15)
现在,如果我们观察前面的堆栈跟踪,会发现它从fs.js模块中的某一处开始,实际上是来自于原生API通过事件循环(Event Loop)已经完成读取并返回它的结果给fs.readFile()函数的那一点。这清楚地告诉我们,异常从我们回调处进入我们所看到的堆栈,然后直接进入了事件循环(Event Loop),最终被捕获并抛出,显示在控制台中。
这意味着,以一个try-catch代码块来包裹readJSONThrows()函数的调用是不能工作的。因为阻塞操作所在堆栈与我们回调被调用的堆栈不同。以下代码展示了刚才我们所描述的模式:
try {
readJSONThrows('nonJSON.txt', function(err, result) {
[...]
});
} catch(err) {
console.log('This will not catch the JSON parsing exception');
}
前面的catch声明永远捕捉不到JSON解析异常问题,因为它被传递回到异常所被抛出的堆栈,而且正如我们刚才看见的,堆栈终止于事件循环(Event Loop)而且没有伴随触发异步操作的函数。
我们已经得到结论:一旦异常到达事件循环(Event Loop),应用程序将会终止。然而,在应用程序终止之前,我们仍然又一次机会做一些清理工作和日志记录工作。事实上,当这种情况发生时,Node在推出流程之前,发射出了一种特殊事件uncaughtException。
接下来的代码展示了使用这个思想的案例:
process.on('uncaughtException', function(err){
console.error('This will catch at last the ' +
'JSON parsing exception: ' + err.message);
//without this, the application would continue
process.exit(1);
});
了解未能捕获的异常将导致应用处于一种不保证一致的状态并且这种不一致会导致难以预见的问题是非常重要的。例如,可能仍然有很多没有完成的I/O请求或者闭包都可能导致不一致。这就是为什么总是建议(特别是在生产环境中),当一个未捕获的异常被接收之后,无论如何要立即退出应用程序。
The module system and its patterns
模块是构建平凡应用的基石,同时也是强制信息隐藏的主要机制,通过将没有明确标记被暴露的所有函数和变量私有化来实现隐藏。在这一章节,我们介绍Node的模块系统和最常见的使用模式。
The revealing module pattern
JavaScript语言的主要问题是没有命名空间。在全局作用域运行的程序将会被来自于内部的应用代码和依赖联合污染。一种流行的来解决这种问题的技术被称为揭示模式(revealing),它的流程如下所示:
var module = (function() {
var privateFoo = function() {...};
var privateVar = [];
var export = {
publicFoo: function() {...},
publicBar: function() {...}
}
return export;
})();
这种模式利用一个自调用函数来制造一个私有作用域,只导出想要公开的部分。在之前的代码,模块变量只包含导出的API,而其余的模块内容事实上是无法从外界访问的。正如我们将会看见的一样,在这种模式背后的思想被用作Node模块系统的基石。
Node.js modules explained
CommonJS是一组语法目标旨在标准化JavaScript生态系统,并且其中最受欢迎的建议被称为CommonJS模块。Node.js建立在此之上规范的模块系统,伴随着很多自定义扩展的加入。为了描述它是如何工作的,我们可以将它与揭示(revealing)模块模式进行对比,在揭示(revealing)模块模式当中每个模块都运行在一个私有作用域上,所以定义的每个变量都属于局部定义而没有污染全局命名空间。
A homemade module loader
为了解释这个机制,让我们从头开始构建一个相似的系统。以下代码创建了一个函数来模拟Node原生require()函数中的一个子集。
我们先创建一个函数,该函数加载模块内容,将其封装到一个私有作用域中去,并对其进行评估:
function loadModule(filename, module, require) {
var wrappedSrc =
'(function(module, exports, require) {' +
fs.readFileSync(filename, 'utf8') +
'})(module, module.exports, require);';
eval(wrappedSrc);
}
模块的源码本质上封装在函数中,因为它是为了实现揭示(revealing)模块模式做准备。这里需要区别的是:我们传递了一个变量列表给模块。特别的有以下几个变量:module,exports,和require。请注意,包装函数的出口参数如何以module.exports初始化中的内容进行初始化,正如我们稍后要讨论的一样。
请记住这只是一个例子,并且你很少需要在一个真是的应用软件中评估源代码。eval()或者其他虚拟模块函数很容易伴随着错误输入而被用错,因此开放一个会导致代码注入攻击的系统很危险。它需要总是被特别小心的控制或者完全避免。
让我们一起来看看实施我们的require函数的这些被包含的变量:
var require = function(moduleName) {
console.log('Require invoked for module: ' + moduleName);
var id = require.resolve(moduleName); //[1]
if(require.cache[id]) { //[2]
return require.cache[id].exports;
}
//module metadata
var module = { //[3]
exports: {},
id: id
};
//Update the cache
require.cache[id] = module; //[4]
//load the module
loadModule(id, module, require); //[5]
//return exported variables
return module.exports; //[6]
};
require.cache = {};
require.resolve = function(moduleName) {
/* resolve a full module id from the moduleName */
}
}
上述函数模拟Node原始require函数的行为,被用来加载模块。当然这只是出于教育的目的,而且它不不能准确或者完全的反映真实require()函数的内部行为,但是理解Node模块系统的内部构造很重要,一个模块如何被定义,并且加载。我们自制的模块系统所作行为,如下所述:
1.一个模块名被作为输入值接受并且我们最先要做的一部分工作是解决模块的全路径问题,我们将全路径称为id。这个任务被委托给require.resolve()函数,在其中实现了一个具体的解决算法(我们会在之后详细讨论)
2.如果模块在过去已经完成加载,它将在缓存中可用。在这种情况下,我们将立即返回它。
3.如果模块尚未加载,我们将为第一个负载设置环境。特别地,我们创造一个模块对象包含一个以空对象字面量来初始化的导出属性(exports)。这种属性将被模块的代码利用来导出任何的公共API。
4.模块对象被缓存。
5.模块源码是从它的文件中被读取的并且代码也已经被评估,如我们在之前所看到的那样。我们提供给模块(即:我们刚刚建立的模块对象)并且参考了require()函数的内容。模块通过操作和替换module.exports对象来导出(exports)它的公共API。
6.最后,对于module.exports的内容,其代表了模块的公共API,将被返回return给调用者。
正如我们所看到的,在Node模块系统工作的背后没有什么神奇的机制。技巧在于,围绕模块源码和我们所运行它的人工环境进行封装。
Defining a module
通过观察我们自制的require()函数如何运作,我们现在知道了如何定义一个模块。接下来的代码,给了我们一个范例:
//load another dependency
var dependency = require('./anotherModule');
//a private function
function log() {
console.log('Well done ' + dependency.username);
}
//the API to be exported for public use
module.exports.run = function() {
log();
};
需要记住的重要概念是:模块内部所有的东西是私有的,除非它被赋予给module.exports变量。这个变量的内容稍后会被缓存,并且通过require()函数模块已经被加载之后变量的内容会被返回(return)。
Defining globals
即使在模块中声明的所有变量和函数都在它自己的局部作用域中被定义,它仍然可以定义一个全局变量。事实上,模块系统公开了一个特殊的变量称为global,它可以被用于以上目的。所有被赋予给这个变量的内容将会在全局作用域范围内自动结束。
请注意,污染全局作用域被认为是一种不好的实践,使具备一个模块系统所带来的优势变得无效。所以只有当你确信明白自己所做事情的时候,再使用全局作用域。
module.exports vs exports
对于很多还没有熟悉Node的开发者,产生疑惑的源头常常是使用exports和module.exports来暴露公共接口的区别。我们自制的require函数代码将再次清除这些困惑。变量导出只是对模块初始值的引用。我们已经看到了这样一个值本质上是一个在模块加载之前被创建的简单对象字面量。
这意味着我们只能对导出(exports)变量所引用的对象添加新的属性,正如一下代码所示:
exports.hello = function() {
console.log('Hello');
}
重新分配导出(exports)变量没有任何影响,因为它并不改变module.exports的内容,它将重新分配变量本身。以下代码所示的过程,因此是错误的:
exports = function() {
console.log('Hello');
}
如果我们希望导出一些东西不仅仅限于对象字面量的形式,比如说:一个函数、一个实例、甚至一个字符串,我们不得不重新分配module.exports,如下所示:
module.exports = function() {
module.exports = function() {
console.log('Hello');
}
}
require is synchronous
另外一个我们需要考虑的细节是我们自制的require函数是同步模式的。事实上,它返回模块内容通过一种简洁的直接模式,并且不需要回调。这对于Node的原始require()函数也是适用的。因此,任何赋予给module.export的操作也必须是同步的。距离,以下代码是不正确的:
setTimeout(function() {
module.exports = function() {...};
}, 100);
这个属性对我们定义模块的方式有重要影响,因为它确定在定义模块的过程中限制我们主要运用同步式的代码。这事实上是最重要的原因之一,导致Node核心库提供同步API作为大多数异步API的可替代方案。
如果我们需要一些针对模块的异步初始化步骤,我们可以总是定义并导出一个未初始化模块,它能够在之后异步的被初始化。这种做法的问题是,使用require加载模块并不确保模块准备好被启用。在第六章Recipes中,我们将详细分析这个问题,我们将提出一系列模式来优雅的解决这个问题。
出于好奇的缘故,你可能想知道这种情况在Node项目早期的阶段,Node被启用时拥有一个异步版本的require()函数,但是这个机制很快被废止了,因为这个机制太过于使一个功能复杂了,实际上这意味着仅仅被使用在初始化时间阶段,并且在其中异步式的I/O带来的复杂性超过了好处。
The resolving algorithm
依赖地狱这种术语,描述了软件依赖的一种情境,反过来建立在一个共享的依赖基础上,但是需要引入不相容的版本。Node通过,根据这模块被加载地方来决定模块的不同版本,优雅的解决了这个问题。这种特性的所有优点在npm和使用在require函数中的解析算法中被采纳。
接下来我们快速的整体介绍一下这个算法。正如我们所看到的那样,resolve()函数将模块名(我们在这里称其为moduleName)作为输入值并且在这里会返回模块的完全路径。这个路径值稍后被用来加载它的代码,并且也被用来唯一确定这个模块。解析算法可以被分割为以下三个主要分支:
•文件模块:如果moduleName以"/"开始,它被考虑已经是针对模块的一个绝对路径,而且同样也会就此返回。如果以"./""开始,moduleName被认为是相对路径,即从引入模块开始计算。
•核心模块:如果moduleName不以"/" 或者"./"开始,算法将会首先尝试在核心Node模块内进行查找。
•封装模块:如果没能找到匹配moduleName的核心模块,之后查找将继续搜索,通过在第一个node_modules文件夹中查找一个匹配的模块,这其中发现从引入的模块开始在文件目录中导航。该算法将通过在文件树中进入下一个node_modules目录来继续寻找一个匹配,直到它到达了文件系统的根目录。
对于文件和封装模块而言,无论是单独的文件还是目录都可以匹配模块名moduleName,特别的,算法将会匹配以下规则:
•<moduleName>.js
•<moduleName>/index.js
• 目录/文件由<moduleName>/package.json中的主要属性明确规定
node_modules目录实际上是提供空间来让npm安装每个包的依赖。这意味着,基于刚才所描述的算法,每个包可以由它私有的依赖。例如,思考以下目录结构:
myApp
├── foo.js
└── node_modules
├── depA
│ └── index.js
├── depB
│ ├── bar.js
│ └── node_modules
│ └── depA
│ └── index.js
└── depC
├── foobar.js
└── node_modules
└── depA
└── index.js
在前面的示例中,myApp、depB和dpC全部都依赖于depA。然而,他们都有自己私有版本的依赖。根据解析算法的法则,使用require('depA')将会加载不同的文件,这个加载结构决定于引入它的模块,举例如下:
•由/myApp/foo.js调用require('depA'),
将会加载/myApp/node_modules/depA/index.js
•由/myApp/node_modules/depB/bar.js调用require('depA'),
将会加载/myApp/node_modules/depB/node_modules/depA/index.js
•由/myApp/node_modules/depC/foobar.js调用require('depA'),
将会加载/myApp/node_modules/depC/node_modules/depA/index.js
解析算法Node依赖管理机制中鲁棒性背后的魔法,是它使在一个应用中具备成百上千或者更多的包并避免冲突或者版本兼容问题成为可能。
当我们调用require()时,解析算法为我们被透明的应用。然而,如果需要,它仍然可以被任何模块直接使用,通过简单调用require.resolve()。
The module cache
每个模块将被仅仅在其第一次被引入的时候进行加载和评估,因为后续任何调用require()将仅仅返回缓存的版本。这应该能通过观察我们自制的require函数代码而得到明确的结果。缓存对于性能是至关重要的,但是它有很多重要的函数含义:
•它使得在模块依赖之间形成循环成为可能。
•它在一定程度上保证了,当从给定包中引入同样的模块时,总是相同的实例被返回。
Cycles
很多人将循环依赖问题认为是一种固有的设计问题,但是事实上它是可以在实际工程中发生的,所以对于我们来说至少了解它是如何在Node中工作是非常有用的。如果我们继续观察自制的require()函数,我们将立即了解它是如何工作的以及有哪些注意事项。
如果我们有下面两个模块定义如下:
• Module a.js:
exports.loaded = false;
var b = require('./b');
module.exports = {
bWasLoaded: b.loaded,
loaded: true
};
• Module b.js:
exports.loaded = false;
var a = require('./a');
module.exports = {
aWasLoaded: a.loaded,
loaded: true
};
现在,我们尝试从另一个模块中加载这些,main.js,如下所示:
var a = require('./a');
var b = require('./b');
console.log(a);
console.log(b);
以上代码将会打印以下输出:
{ bWasLoaded: true, loaded: true }
{ aWasLoaded: false, loaded: true }
这个结果揭示了循环依赖的注意事项。尽管两个模块在从main模块中被引入时都已经完成了初始化,a.js模块由b.js模块中加载时,其应该是不完整的。特别地,它的状态将会是那个到达当它引入b.js的时刻所具备的状态。这种表现将会再次敲响另一个警钟,这种情况将会确定当我们交换main.js中引入两模块的顺序。
如果你尝试一下,你会看到,这次将会是模块a.js将会接收一个不完整版本的b.js。我们现在可以理解了,如果我们失去了那个模块能够首先加载的控制权(这种情况当项目足够大的时候会很容易发生),这种业务将变得非常模糊。