《Node.js设计模式》Node.js基本模式

本系列文章为《Node.js Design Patterns Second Edition》的原文翻译和读书笔记,在GitHub连载更新,同步翻译版连接javascript

欢迎关注个人专栏,以后的博文将在专栏同步:html

Node.js Essential Patterns

对于Node.js而言,异步特性是其最显著的特征,但对于别的一些语言,例如PHP,就不常处理异步代码。前端

在同步的编程中,咱们习惯于把代码的执行想象为自上而下连续的执行计算步骤。每一个操做都是阻塞的,这意味着只有在一个操做执行完成后才能执行下一个操做,这种方式利于咱们理解和调试。java

然而,在异步的编程中,咱们能够在后台执行诸如读取文件或执行网络请求的一些操做。当咱们在调用异步操做方法时,即便当前或以前的操做还没有完成,下面的后续操做也会继续执行,在后台执行的操做会在任意时刻执行完毕,而且应用程序会在异步调用完成时以正确的方式作出反应。node

虽然这种非阻塞方法相比于阻塞方法性能更好,但它实在是让程序员难以理解,而且,在处理较为复杂的异步控制流的高级应用程序时,异步顺序可能会变得难以操做。react

Node.js提供了一系列工具和设计模式,以便咱们最佳地处理异步代码。了解如何使用它们编写性能和易于理解和调试的应用程序很是重要。git

在本章中,咱们将看到两个最重要的异步模式:回调和事件发布器。程序员

回调模式

在上一章中介绍过,回调是reactor模式handler的实例,回调原本就是Node.js独特的编程风格之一。回调函数是在异步操做完成后传播其操做结果的函数,老是用来替代同步操做的返回指令。而JavaScript刚好就是表示回调的最好的语言。在JavaScript中,函数是一等公民,咱们能够把函数变量做为参数传递,并在另外一个函数中调用它,把调用的结果存储到某一数据结构中。实现回调的另外一个理想结构是闭包。使用闭包,咱们可以保留函数建立时所在的上下文环境,这样,不管什么时候调用回调,都保持了请求异步操做的上下文。github

在本节中,咱们分析基于回调的编程思想和模式,而不是同步操做的返回指令的模式。算法

CPS

JavaScript中,回调函数做为参数传递给另外一个函数,并在操做完成时调用。在函数式编程中,这种传递结果的方法被称为CPS。这是一个通常概念,并且不仅是对于异步操做而言。实际上,它只是经过将结果做为参数传递给另外一个函数(回调函数)来传递结果,而后在主体逻辑中调用回调函数拿到操做结果,而不是直接将其返回给调用者。

同步CPS

为了更清晰地理解CPS,让咱们来看看这个简单的同步函数:

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

上面的例子成为直接编程风格,其实没什么特别的,就是使用return语句把结果直接传递给调用者。它表明的是同步编程中返回结果的最多见方法。上述功能的CPS写法以下:

function add(a, b, callback) {
  callback(a + b);
}

add()函数是一个同步的CPS函数,CPS函数只会在它调用的时候才会拿到add()函数的执行结果,下列代码就是其调用方式:

console.log('before');
add(1, 2, result => console.log('Result: ' + result));
console.log('after');

既然add()是同步的,那么上述代码会打印如下结果:

before
Result: 3
after

异步CPS

那咱们思考下面的这个例子,这里的add()函数是异步的:

function additionAsync(a, b, callback) {
 setTimeout(() => callback(a + b), 100);
}

在上边的代码中,咱们使用setTimeout()模拟异步回调函数的调用。如今,咱们调用additionalAsync,并查看具体的输出结果。

console.log('before');
additionAsync(1, 2, result => console.log('Result: ' + result));
console.log('after');

上述代码会有如下的输出结果:

before
after
Result: 3

由于setTimeout()是一个异步操做,因此它不会等待执行回调,而是当即返回,将控制权交给addAsync(),而后返回给其调用者。Node.js中的此属性相当重要,由于只要有异步请求产生,控制权就会交给事件循环,从而容许处理来自队列的新事件。

下面的图片显示了Node.js中事件循环过程:

当异步操做完成时,执行权就会交给这个异步操做开始的地方,即回调函数。执行将从事件循环开始,因此它将有一个新的堆栈。对于JavaScript而言,这是它的优点所在。正是因为闭包保存了其上下文环境,即便在不一样的时间点和不一样的位置调用回调,也可以正常地执行。

同步函数在其完成操做以前是阻塞的。而异步函数当即返回,结果将在事件循环的稍后循环中传递给处理程序(在咱们的例子中是一个回调)。

非CPS风格的回调模式

某些状况下状况下,咱们可能会认为回调CPS式的写法像是异步的,然而并非。好比如下代码,Array对象的map()方法:

const result = [1, 5, 7].map(element => element - 1);
console.log(result); // [0, 4, 6]

在上述例子中,回调仅用于迭代数组的元素,而不是传递操做的结果。实际上,这个例子中是使用回调的方式同步返回,而非传递结果。是不是传递操做结果的回调一般在API文档有明确说明。

同步仍是异步?

咱们已经看到代码的执行顺序会因同步或异步的执行方式产生根本性的改变。这对整个应用程序的流程,正确性和效率都产生了重大影响。如下是对这两种模式及其缺陷的分析。通常来讲,必须避免的是因为其执行顺序不一致致使的难以检测和拓展的混乱。下面是一个有陷阱的异步实例:

一个有问题的函数

最危险的状况之一是在特定条件下同步执行本应异步执行的API。如下列代码为例:

const fs = require('fs');
const cache = {};

function inconsistentRead(filename, callback) {
  if (cache[filename]) {
    // 若是缓存命中,则同步执行回调
    callback(cache[filename]);
  } else {
    // 未命中,则执行异步非阻塞的I/O操做
    fs.readFile(filename, 'utf8', (err, data) => {
      cache[filename] = data;
      callback(data);
    });
  }
}

上述功能使用缓存来存储不一样文件读取操做的结果。不过记得,这只是一个例子,它缺乏错误处理,而且其缓存逻辑自己不是最佳的(好比没有缓存淘汰策略)。除此以外,上述函数是很是危险的,由于若是没有设置高速缓存,它的行为是异步的,直到fs.readFile()函数返回结果为止,它都不会同步执行,这时缓存并不会触发,而会去走异步回调调用。

解放zalgo

关于zalgo,其实就是指同步或异步行为的不肯定性,几乎老是致使很是难追踪的bug

如今,咱们来看看如何使用一个不可预测其顺序的函数,它甚至能够轻松地中断一个应用程序。看如下代码:

function createFileReader(filename) {
  const listeners = [];
  inconsistentRead(filename, value => {
    listeners.forEach(listener => listener(value));
  });
  return {
    onDataReady: listener => listeners.push(listener)
  };
}

当上述函数被调用时,它建立一个充当事件发布器的新对象,容许咱们为文件读取操做设置多个事件监听器。当读取操做完成而且数据可用时,全部的监听器将被当即被调用。前面的函数使用以前定义的inconsistentRead()函数来实现这个功能。咱们如今尝试调用createFileReader()函数:

const reader1 = createFileReader('data.txt');
reader1.onDataReady(data => {
 console.log('First call data: ' + data);
 // 以后再次经过fs读取同一个文件
 const reader2 = createFileReader('data.txt');
 reader2.onDataReady(data => {
   console.log('Second call data: ' + data);
 });
});

以后的输出是这样的:

First call data: some data

下面来分析为什么第二次的回调没有被调用:

在建立reader1的时候,inconsistentRead()函数是异步执行的,这时没有可用的缓存结果,所以咱们有时间注册事件监听器。在读操做完成后,它将在下一次事件循环中被调用。

而后,在事件循环的循环中建立reader2,其中所请求文件的缓存已经存在。在这种状况下,内部调用inconsistentRead()将是同步的。因此,它的回调将被当即调用,这意味着reader2的全部监听器也将被同步调用。然而,在建立reader2以后,咱们才开始注册监听器,因此它们将永远不被调用。

inconsistentRead()回调函数的行为是不可预测的,由于它取决于许多因素,例如调用的频率,做为参数传递的文件名,以及加载文件所花费的时间等。

在实际应用中,例如咱们刚刚看到的错误可能会很是复杂,难以在真实应用程序中识别和复制。想象一下,在Web服务器中使用相似的功能,能够有多个并发请求;想象一下这些请求挂起,没有任何明显的理由,没有任何日志被记录。这绝对属于烦人的bug

npm的创始人和之前的Node.js项目负责人Isaac Z. Schlueter在他的一篇博客文章中比较了使用这种不可预测的功能来释放Zalgo。若是您不熟悉Zalgo。能够看看Isaac Z. Schlueter的原始帖子

使用同步API

从上述关于zalgo的示例中,咱们知道,API必须清楚地定义其性质:是同步的仍是异步的?

咱们合适fix上述的inconsistentRead()函数产生的bug的方式是使它彻底同步阻塞执行。而且这是彻底可能的,由于Node.js为大多数基本I/O操做提供了一组同步方式的API。例如,咱们可使用fs.readFileSync()函数来代替它的异步对等体。代码如今以下:

const fs = require('fs');
const cache = {};

function consistentReadSync(filename) {
 if (cache[filename]) {
   return cache[filename];
 } else {
   cache[filename] = fs.readFileSync(filename, 'utf8');
   return cache[filename];
 }
}

咱们能够看到整个函数被转化为同步阻塞调用的模式。若是一个函数是同步的,那么它不会是CPS的风格。事实上,咱们能够说,使用CPS来实现一个同步的API一直是最佳实践,这将消除其性质上的任何混乱,而且从性能角度来看也将更加有效。

请记住,将APICPS更改成直接调用返回的风格,或者说从异步到同步的风格。例如,在咱们的例子中,咱们必须彻底改变咱们的createFileReader()为同步,并使其适应于始终工做。

另外,使用同步API而不是异步API,要特别注意如下注意事项:

  • 同步API并不适用于全部应用场景。
  • 同步API将阻塞事件循环并将并发请求置于阻塞状态。它会破坏JavaScript的并发模型,甚至使得整个应用程序的性能降低。咱们将在本书后面看到这对咱们的应用程序的影响。

在咱们的inconsistentRead()函数中,由于每一个文件名仅调用一次,因此同步阻塞调用而对应用程序形成的影响并不大,而且缓存值将用于全部后续的调用。若是咱们的静态文件的数量是有限的,那么使用consistentReadSync()将不会对咱们的事件循环产生很大的影响。若是咱们文件数量很大而且都须要被读取一次,并且对性能要求较高的状况下,咱们不建议在Node.js中使用同步I/O。然而,在某些状况下,同步I/O多是最简单和最有效的解决方案。因此咱们必须正确评估具体的应用场景,以选择最为合适的方案。上述实例其实说明:在实际应用程序中使用同步阻塞API加载配置文件是很是有意义的。

所以,记得只有不影响应用程序并发能力时才考虑使用同步阻塞I/O

延时处理

另外一种fix上述的inconsistentRead()函数产生的bug的方式是让它仅仅是异步的。这里的解决办法是下一次事件循环时同步调用,而不是在相同的事件循环周期中当即运行,使得其其实是异步的。在Node.js中,可使用process.nextTick(),它延迟函数的执行,直到下一次传递事件循环。它的功能很是简单,它将回调做为参数,并将其推送到事件队列的顶部,在任何未处理的I/O事件前,并当即返回。一旦事件循环再次运行,就会马上调用回调。

因此看下列代码,咱们能够较好的利用这项技术处理inconsistentRead()的异步顺序:

const fs = require('fs');
const cache = {};

function consistentReadAsync(filename, callback) {
  if (cache[filename]) {
    // 下一次事件循环当即调用
    process.nextTick(() => callback(cache[filename]));
  } else {
    // 异步I/O操做
    fs.readFile(filename, 'utf8', (err, data) => {
      cache[filename] = data;
      callback(data);
    });
  }
}

如今,上述函数保证在任何状况下异步地调用其回调函数,解决了上述bug

另外一个用于延迟执行代码的APIsetImmediate()。虽然它们的做用看起来很是类似,但实际含义却大相径庭。process.nextTick()的回调函数会在任何其余I/O操做以前调用,而对于setImmediate()则会在其它I/O操做以后调用。因为process.nextTick()在其它的I/O以前调用,所以在某些状况下可能会致使I/O进入无限期等待,例如递归调用process.nextTick()可是对于setImmediate()则不会发生这种状况。当咱们在本书后面分析使用延迟调用来运行同步CPU绑定任务时,咱们将深刻了解这两种API之间的区别。

咱们保证经过使用process.nextTick()异步调用其回调函数。

Node.js回调风格

对于Node.js而言,CPS风格的API和回调函数遵循一组特殊的约定。这些约定不仅是适用于Node.js核心API,对于它们以后也是绝大多数用户级模块和应用程序也颇有意义。所以,咱们了解这些风格,并确保咱们在须要设计异步API时遵照规定显得相当重要。

回调老是最后一个参数

在全部核心Node.js方法中,标准约定是当函数在输入中接受回调时,必须做为最后一个参数传递。咱们如下面的Node.js核心API为例:

fs.readFile(filename, [options], callback);

从前面的例子能够看出,即便是在可选参数存在的状况下,回调也始终置于最后的位置。其缘由是在回调定义的状况下,函数调用更可读。

错误处理总在最前

CPS中,错误以不一样于正确结果的形式在回调函数中传递。在Node.js中,CPS风格的回调函数产生的任何错误老是做为回调的第一个参数传递,而且任何实际的结果从第二个参数开始传递。若是操做成功,没有错误,第一个参数将为nullundefined。看下列代码:

fs.readFile('foo.txt', 'utf8', (err, data) => {
  if (err)
    handleError(err);
  else
    processData(data);
});

上面的例子是最好的检测错误的方法,若是不检测错误,咱们可能难以发现和调试代码中的bug,但另一个要考虑的问题是错误老是为Error类型,这意味着简单的字符串或数字不该该做为错误对象传递(难以被try catch代码块捕获)。

错误传播

对于同步阻塞的写法而言,咱们的错误都是经过throw语句抛出,即便错误在错误栈中跳转,咱们也能很好地捕获到错误上下文。

可是对于CPS风格的异步调用而言,经过把错误传递到错误栈中的下一个回调来完成,下面是一个典型的例子:

const fs = require('fs');

function readJSON(filename, callback) {
  fs.readFile(filename, 'utf8', (err, data) => {
    let parsed;
    if (err)
    // 若是有错误产生则退出当前调用
      return callback(err);
    try {
      // 解析文件中的数据
      parsed = JSON.parse(data);
    } catch (err) {
      // 捕获解析中的错误,若是有错误产生,则进行错误处理
      return callback(err);
    }
    // 没有错误,调用回调
    callback(null, parsed);
  });
};

从上面的例子中咱们注意到的细节是当咱们想要正确地进行异常处理时,咱们如何向callback传递参数。此外,当有错误产生时,咱们使用了return语句,当即退出当前函数调用,避免进行下面的相关执行。

不可捕获的异常

从上述readJSON()函数,为了不将任何异常抛到fs.readFile()的回调函数中捕获,咱们对JSON.parse()周围放置一个try catch代码块。在异步回调中一旦出错,将抛出异常,并跳转到事件循环,不把错误传播到下一个回调函数去。

Node.js中,这是一个不可恢复的状态,应用程序会关闭,并将错误打印到标准输出中。为了证实这一点,咱们尝试从以前定义的readJSON()函数中删除try catch代码块:

const fs = require('fs');

function readJSONThrows(filename, callback) {
  fs.readFile(filename, 'utf8', (err, data) => {
    if (err) {
      return callback(err);
    }
    // 假设parse的执行没有错误
    callback(null, JSON.parse(data));
  });
};

在上面的代码中,咱们没有办法捕获到JSON.parse产生的异常,若是咱们尝试传递一个非标准JSON格式的文件,将会抛出如下错误:

SyntaxError: Unexpected token d
at Object.parse (native)
at [...]
at fs.js:266:14
at Object.oncomplete (fs.js:107:15)

如今,若是咱们看看前面的错误栈跟踪,咱们将看到它从fs模块的某处开始,刚好从本地API完成文件读取返回到fs.readFile()函数,经过事件循环。这些信息都很清楚地显示给咱们,异常从咱们的回调传入堆栈,而后直接进入事件循环,最终被捕获并抛出到控制台中。
这也意味着使用try catch代码块包装对readJSONThrows()的调用将不起做用,由于块所在的堆栈与调用回调的堆栈不一样。如下代码显示了咱们刚才描述的相反的状况:

try {
  readJSONThrows('nonJSON.txt', function(err, result) {
    // ... 
  });
} catch (err) {
  console.log('This will not catch the JSON parsing exception');
}

前面的catch语句将永远不会收到JSON解析异常,由于它将返回到抛出异常的堆栈。咱们刚刚看到堆栈在事件循环中结束,而不是触发异步操做的功能。
如前所述,应用程序在异常到达事件循环的那一刻停止,然而,咱们仍然有机会在应用程序终止以前执行一些清理或日志记录。事实上,当这种状况发生时,Node.js会在退出进程以前发出一个名为uncaughtException的特殊事件。如下代码显示了一个示例用例:

process.on('uncaughtException', (err) => {
  console.error('This will catch at last the ' +
    'JSON parsing exception: ' + err.message);
  // Terminates the application with 1 (error) as exit code:
  // without the following line, the application would continue
  process.exit(1);
});

重要的是,未被捕获的异常会使应用程序处于不能保证一致的状态,这可能致使不可预见的问题。例如,可能还有不完整的I/O请求运行或关闭可能会变得不一致。这就是为何老是建议,特别是在生产环境中,在接收到未被捕获的异常以后写上述代码进行错误日志记录。

模块系统及相关模式

模块不只是构建大型应用的基础,其主要机制是封装内部实现、方法与变量,经过接口。在本节中,咱们将介绍Node.js的模块系统及其最多见的使用模式。

关于模块

JavaScript的主要问题之一是没有命名空间。在全局范围内运行的程序会污染全局命名空间,形成相关变量、数据、方法名的冲突。解决这个问题的技术称为模块模式,看下列代码:

const module = (() => {
  const privateFoo = () => {
    // ...
  };
  const privateBar = [];
  const exported = {
    publicFoo: () => {
      // ...
    },
    publicBar: () => {
      // ...
    }
  };
  return exported;
})();
console.log(module);

此模式利用自执行匿名函数实现模块,仅导出旨但愿被公开调用的部分。在上面的代码中,模块变量只包含导出的API,而其他的模块内容实际上从外部访问不到。咱们将在稍后看到,这种模式背后的想法被用做Node.js模块系统的基础。

Node.js模块相关解释

CommonJS是一个旨在规范JavaScript生态系统的组织,他们提出了CommonJS模块规范Node.js在此规范之上构建了其模块系统,并添加了一些自定义的扩展。为了描述它的工做原理,咱们能够经过这样一个例子解释模块模式,每一个模块都在私有命名空间下运行,这样模块内定义的每一个变量都不会污染全局命名空间。

自定义模块系统

为了解释模块系统的远离,让咱们从头开始构建一个相似的模块系统。下面的代码建立一个模仿Node.js原始require()函数的功能。

咱们先建立一个加载模块内容的函数,将其包装到一个私有的命名空间内:

function loadModule(filename, module, require) {
  const wrappedSrc = `(function(module, exports, require) {
         ${fs.readFileSync(filename, 'utf8')}
       })(module, module.exports, require);`;
  eval(wrappedSrc);
}

模块的源代码被包装到一个函数中,如同自执行匿名函数那样。这里的区别在于,咱们将一些固有的变量传递给模块,特指moduleexportsrequire。注意导出模块的参数是module.exportsexports,后面咱们将再讨论。

请记住,这只是一个例子,在真实项目中可不要这么作。诸如eval()vm模块有可能致使一些安全性的问题,它人可能利用漏洞来进行注入攻击。咱们应该很是当心地使用甚至彻底避免使用eval

咱们如今来看模块的接口、变量等是如何被require()函数引入的:

const require = (moduleName) => {
  console.log(`Require invoked for module: ${moduleName}`);
  const id = require.resolve(moduleName);
  // 是否命中缓存
  if (require.cache[id]) {
    return require.cache[id].exports;
  }
  // 定义module
  const module = {
    exports: {},
    id: id
  };
  // 新模块引入,存入缓存
  require.cache[id] = module;
  // 加载模块
  loadModule(id, module, require);
  // 返回导出的变量
  return module.exports;
};
require.cache = {};
require.resolve = (moduleName) => {
  /* 经过模块名做为参数resolve一个完整的模块 */
};

上面的函数模拟了用于加载模块的原生Node.jsrequire()函数的行为。固然,这只是一个demo,它并不能准确且完整地反映require()函数的真实行为,可是为了更好地理解Node.js模块系统的内部实现,定义模块和加载模块。咱们的自制模块系统的功能以下:

  • 模块名称被做为参数传入,咱们首先作的是找寻模块的完整路径,咱们称之为idrequire.resolve()专门负责这项功能,它经过一个特定的解析算法实现相关功能(稍后将讨论)。
  • 若是模块已经被加载,它应该存在于缓存。在这种状况下,咱们当即返回缓存中的模块。
  • 若是模块还没有加载,咱们将首次加载该模块。建立一个模块对象,其中包含一个使用空对象字面值初始化的exports属性。该属性将被模块的代码用于导出该模块的公共API
  • 缓存首次加载的模块对象。
  • 模块源代码从其文件中读取,代码被导入,如前所述。咱们经过require()函数向模块提供咱们刚刚建立的模块对象。该模块经过操做或替换module.exports对象来导出其公共API。
  • 最后,将表明模块的公共APImodule.exports的内容返回给调用者。

正如咱们所看到的,Node.js模块系统的原理并非想象中那么高深,只不过是经过咱们一系列操做来建立和导入导出模块源代码。

定义一个模块

经过查看咱们的自定义require()函数的工做原理,咱们如今既然已经知道如何定义一个模块。再来看下面这个例子:

// 加载另外一个模块
const dependency = require('./anotherModule');
// 模块内的私有函数
function log() {
  console.log(`Well done ${dependency.username}`);
}
// 经过导出API实现共有方法
module.exports.run = () => {
  log();
};

须要注意的是模块内的全部内容都是私有的,除非它被分配给module.exports变量。而后,当使用require()加载模块时,缓存并返回此变量的内容。

定义全局变量

即便在模块中声明的全部变量和函数都在其本地范围内定义,仍然能够定义全局变量。事实上,模块系统公开了一个名为global的特殊变量。分配给此变量的全部内容将会被定义到全局环境下。

注意:污染全局命名空间是很差的,而且没有充分运用模块系统的优点。因此,只有真的须要使用全局变量,才去使用它。

module.exports和exports

对于许多还不熟悉Node.js的开发人员而言,他们最容易混淆的是exportsmodule.exports来导出公共API的区别。变量export只是对module.exports的初始值的引用;咱们已经看到,exports本质上在模块加载以前只是一个简单的对象。

这意味着咱们只能将新属性附加到导出变量引用的对象,如如下代码所示:

exports.hello = () => {
  console.log('Hello');
}

从新给exports赋值并不会有任何影响,由于它并不会所以而改变module.exports的内容,它只是改变了该变量自己。所以下列代码是错误的:

exports = () => {
  console.log('Hello');
}

若是咱们想要导出除对象以外的内容,好比函数,咱们能够给module.exports从新赋值:

module.exports = () => {
  console.log('Hello');
}

require函数是同步的

另外一个重要的细节是上述咱们写的require()函数是同步的,它使用了一个较为简单的方式返回了模块内容,而且不须要回调函数。所以,对于module.exports也是同步的,例如,下列的代码是不正确的:

setTimeout(() => {
  module.exports = function() {
    // ...
  };
}, 100);

经过这种方式导出模块会对咱们定义模块产生重要的影响,由于它限制了咱们同步定义并使用模块的方式。这其实是为何核心Node.js库提供同步API以代替异步API的最重要的缘由之一。

若是咱们须要定义一个须要异步操做来进行初始化的模块,咱们也能够随时定义和导出须要咱们异步初始化的模块。可是这样定义异步模块咱们并不能保证require()后能够当即使用,在第九章,咱们将详细分析这个问题,并提出一些模式来优化解决这个问题。

实际上,在早期的Node.js中,曾经有一个异步版本的require(),但因为它对初始化时间和异步I/O的性能有巨大影响,很快这个API就被删除了。

resolve算法

依赖地狱描述了软件的依赖于不一样版本的软件包的依赖关系,Node.js经过加载不一样版本的模块来解决这个问题,具体取决于模块的加载位置。而都是由npm来完成的,相关算法被称做resolve算法,被用到require()函数中。

如今让咱们快速概述一下这个算法。以下所述,resolve()函数将一个模块名称(moduleName)做为输入,并返回模块的完整路径。而后,该路径用于加载其代码,而且还能够惟一地标识模块。resolve算法能够分为如下三种规则:

  • 文件模块:若是moduleName/开头,那么它已经被认为是模块的绝对路径。若是以./开头,那么moduleName被认为是相对路径,它是从使用require的模块的位置开始计算的。
  • 核心模块:若是moduleName不以/./开头,则算法将首先尝试在核心Node.js模块中进行搜索。
  • 模块包:若是没有找到匹配moduleName的核心模块,则搜索在当前目录下的node_modules,若是没有搜索到node_modules,则会往上层目录继续搜索node_modules,直到它到达文件系统的根目录。

对于文件和包模块,单个文件和目录也能够匹配到moduleName。特别地,算法将尝试匹配如下内容:

  • <moduleName>.js
  • <moduleName>/index.js
  • <moduleName>/package.jsonmain值下声明的文件或目录

resolve算法的具体文档

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

在前面的例子中,myAppdepBdepC都依赖于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

resolve算法Node.js依赖关系管理的核心部分,它的存在使得即使应用程序拥有成百上千包的状况下也不会出现冲突和版本不兼容的问题。

当咱们调用require()时,解析算法对咱们是透明的。然而,仍然能够经过调用require.resolve()直接由任何模块使用。

模块缓存

每一个模块只会在它第一次引入的时候加载,此后的任意一次require()调用均从以前缓存的版本中取得。经过查看咱们以前写的自定义的require()函数,能够看到缓存对于性能提高相当重要,此外也具备一些其它的优点,以下:

  • 使得模块依赖关系的重复利用成为可能
  • 从某种程度上保证了在从给定的包中要求相同的模块时老是返回相同的实例,避免了冲突

模块缓存经过require.cache变量查看,所以若是须要,能够直接访问它。在实际运用中的例子是经过删除require.cache变量中的相对键来使某个缓存的模块无效,这是在测试过程当中很是有用,但在正常状况下会十分危险。

循环依赖

许多人认为循环依赖是Node.js内在的设计问题,但在真实项目中真的可能发生,因此咱们至少知道如何在Node.js中使得循环依赖有效。再来看咱们自定义的require()函数,咱们能够当即看到其工做原理和注意事项。

看下面这两个模块:

  • 模块a.js
exports.loaded = false;
const b = require('./b');
module.exports = {
  bWasLoaded: b.loaded,
  loaded: true
};
  • 模块b.js
exports.loaded = false;
const a = require('./a');
module.exports = {
  aWasLoaded: a.loaded,
  loaded: true
};

而后咱们在main.js中写如下代码:

const a = require('./a');
const b = require('./b');
console.log(a);
console.log(b);

执行上述代码,会打印如下结果:

{
  bWasLoaded: true,
  loaded: true
}
{
  aWasLoaded: false,
  loaded: true
}

这个结果展示了循环依赖的处理顺序。虽然a.jsb.js这两个模块都在主模块须要的时候彻底初始化,可是当从b.js加载时,a.js模块是不完整的。特别,这种状态会持续到b.js加载完毕的那一刻。这种状况咱们应该引发注意,特别要确认咱们在main.js中两个模块所需的顺序。

这是因为模块a.js将收到一个不完整的版本的b.js。咱们如今明白,若是咱们失去了首先加载哪一个模块的控制,若是项目足够大,这可能会很容易发生循环依赖。

关于循环引用的文档

简单说就是,为了防止模块载入的死循环,Node.js在模块第一次载入后会把它的结果进行缓存,下一次再对它进行载入的时候会直接从缓存中取出结果。因此在这种循环依赖情形下,不会有死循环,可是却会由于缓存形成模块没有按照咱们预想的那样被导出(export,详细的案例分析见下文)。

官网给出了三个模块还不是循环依赖最简单的情形。实际上,两个模块就能够很清楚的表达出这种状况。根据递归的思想,解决了最简单的情形,这一类任意大小规模的问题也就解决了一半(另外一半还须要探明随着问题规模增加,问题的解将会如何变化)。

JavaScript做为一门解释型的语言,上面的打印输出清晰的展现出了程序运行的轨迹。在这个例子中,a.js首先requireb.js, 程序进入b.js,在b.js中第一行又requirea.js

如前文所述,为了不无限循环的模块依赖,在Node.js运行a.js 以后,它就被缓存了,但须要注意的是,此时缓存的仅仅是一个未完工的a.jsan unfinished copy of the a.js)。因此在 b.jsrequirea.js时,获得的仅仅是缓存中一个未完工的a.js,具体来讲,它并无明确被导出的具体内容(a.js尾端)。因此b.js中输出的a是一个空对象。

以后,b.js顺利执行完,回到a.jsrequire语句以后,继续执行完成。

模块定义模式

模块系统除了自带处理依赖关系的机制以外,最多见的功能就是定义API。对于定义API,主要须要考虑私有和公共功能之间的平衡。其目的是最大化信息隐藏内部实现和暴露的API可用性,同时将这些与可扩展性和代码重用性进行平衡。

在本节中,咱们将分析一些在Node.js中定义模块的最流行模式;每一个模块都保证了私有变量的透明,可扩展性和代码重用。

命名导出

暴露公共API的最基本方法是使用命名导出,其中包括将咱们想要公开的全部值分配给由export(或module.exports)引用的对象的属性。以这种方式,生成的导出对象将成为一组相关功能的容器或命名空间。

看下面代码,是此模式的实现:

//file logger.js
exports.info = (message) => {
  console.log('info: ' + message);
};
exports.verbose = (message) => {
  console.log('verbose: ' + message);
};

导出的函数随后做为引入其的模块的属性使用,以下面的代码所示:

// file main.js
const logger = require('./logger');
logger.info('This is an informational message');
logger.verbose('This is a verbose message');

大多数Node.js模块使用这种定义。

CommonJS规范仅容许使用exports变量来公开public成员。所以,命名的导出模式是惟一与CommonJS规范兼容的模式。使用module.exportsNode.js提供的一个扩展,以支持更普遍的模块定义模式。

函数导出

最流行的模块定义模式之一包括将整个module.exports变量从新分配给一个函数。它的主要优势是它只暴露了一个函数,为模块提供了一个明确的入口点,使其更易于理解和使用,它也很好地展示了单一职责原则。这种定义模块的方法在社区中也被称为substack模式,在如下示例中查看此模式:

// file logger.js
module.exports = (message) => {
  console.log(`info: ${message}`);
};

该模式也能够将导出的函数用做其余公共API的命名空间。这是一个很是强大的组合,由于它仍然给模块一个单独的入口点(exports的主函数)。这种方法还容许咱们公开具备次要或更高级用例的其余函数。如下代码显示了如何使用导出的函数做为命名空间来扩展咱们以前定义的模块:

module.exports.verbose = (message) => {
  console.log(`verbose: ${message}`);
};

这段代码演示了如何调用咱们刚才定义的模块:

// file main.js
const logger = require('./logger');
logger('This is an informational message');
logger.verbose('This is a verbose message');

虽然只是导出一个函数也多是一个限制,但实际上它是一个完美的方式,把重点放在一个单一的函数,它表明着这个模块最重要的一个功能,同时使得内部私有变量属性更加透明,而只是暴露导出函数自己的属性。

Node.js的模块化鼓励咱们遵循采用单一职责原则(SRP):每一个模块应该对单个功能负责,该职责应彻底由该模块封装,以保证复用性。

注意,这里讲的substack模式,就是经过仅导出一个函数来暴露模块的主要功能。使用导出的函数做为命名空间来导出别的次要功能。

构造器(类)导出

导出构造函数的模块是导出函数的模块的特例。其不一样之处在于,使用这种新模式,咱们容许用户使用构造函数建立新的实例,可是咱们也能够扩展其原型并建立新类(继承)。如下是此模式的示例:

// file logger.js
function Logger(name) {
  this.name = name;
}
Logger.prototype.log = function(message) {
  console.log(`[${this.name}] ${message}`);
};
Logger.prototype.info = function(message) {
  this.log(`info: ${message}`);
};
Logger.prototype.verbose = function(message) {
  this.log(`verbose: ${message}`);
};
module.exports = Logger;

咱们经过如下方式使用上述模块:

// file main.js
const Logger = require('./logger');
const dbLogger = new Logger('DB');
dbLogger.info('This is an informational message');
const accessLogger = new Logger('ACCESS');
accessLogger.verbose('This is a verbose message');

经过ES2015class关键字语法也能够实现相同的模式:

class Logger {
  constructor(name) {
    this.name = name;
  }
  log(message) {
    console.log(`[${this.name}] ${message}`);
  }
  info(message) {
    this.log(`info: ${message}`);
  }
  verbose(message) {
    this.log(`verbose: ${message}`);
  }
}
module.exports = Logger;

鉴于ES2015的类只是原型的语法糖,该模块的使用将与其基于原型和构造函数的方案彻底相同。

导出构造函数或类仍然是模块的单个入口点,但与substack模式比起来,它暴露了更多的模块内部结构。然而,另外一方面,当想要扩展该模块功能时,咱们能够更加方便。

这种模式的变种包括对不使用new的调用。这个小技巧让咱们将咱们的模块用做工厂。看下列代码:

function Logger(name) {
  if (!(this instanceof Logger)) {
    return new Logger(name);
  }
  this.name = name;
};

其实这很简单:咱们检查this是否存在,而且是Logger的一个实例。若是这些条件中的任何一个都为false,则意味着Logger()函数在不使用new的状况下被调用,而后继续正确建立新实例并将其返回给调用者。这种技术容许咱们将模块也用做工厂:

// file logger.js
const Logger = require('./logger');
const dbLogger = Logger('DB');
accessLogger.verbose('This is a verbose message');

ES2015new.target语法从Node.js 6开始提供了一个更简洁的实现上述功能的方法。该利用公开了new.target属性,该属性是全部函数中可用的元属性,若是使用new关键字调用函数,则在运行时计算结果为true
咱们可使用这种语法重写工厂:

function Logger(name) {
  if (!new.target) {
    return new LoggerConstructor(name);
  }
  this.name = name;
}

这个代码彻底与前一段代码做用相同,因此咱们能够说ES2015new.target语法糖使得代码更加可读和天然。

实例导出

咱们能够利用require()的缓存机制来轻松地定义具备从构造函数或工厂建立的状态的有状态实例,能够在不一样模块之间共享。如下代码显示了此模式的示例:

//file logger.js
function Logger(name) {
  this.count = 0;
  this.name = name;
}
Logger.prototype.log = function(message) {
  this.count++;
  console.log('[' + this.name + '] ' + message);
};
module.exports = new Logger('DEFAULT');

这个新定义的模块能够这么使用:

// file main.js
const logger = require('./logger');
logger.log('This is an informational message');

由于模块被缓存,因此每一个须要Logger模块的模块实际上老是会检索该对象的相同实例,从而共享它的状态。这种模式很是像建立单例。然而,它并不保证整个应用程序的实例的惟一性,由于它发生在传统的单例模式中。在分析解析算法时,实际上已经看到,一个模块可能会屡次安装在应用程序的依赖关系树中。这致使了同一逻辑模块的多个实例,全部这些实例都运行在同一个Node.js应用程序的上下文中。在第7章中,咱们将分析导出有状态的实例和一些可替代的模式。

咱们刚刚描述的模式的扩展包括exports用于建立实例的构造函数以及实例自己。这容许用户建立相同对象的新实例,或者若是须要也能够扩展它们。为了实现这一点,咱们只须要为实例分配一个新的属性,以下面的代码所示:

module.exports.Logger = Logger;

而后,咱们可使用导出的构造函数建立类的其余实例:

const customLogger = new logger.Logger('CUSTOM');
customLogger.log('This is an informational message');

从代码可用性的角度来看,这相似于将导出的函数用做命名空间,该模块导出一个对象的默认实例,这是咱们大部分时间使用的功能,而更多的高级功能(如建立新实例或扩展对象的功能)仍然能够经过较少的暴露属性来使用。

修改其余模块或全局做用域

一个模块甚至能够导出任何东西这能够看起来有点不合适;可是,咱们不该该忘记一个模块能够修改全局范围和其中的任何对象,包括缓存中的其余模块。请注意,这些一般被认为是很差的作法,可是因为这种模式在某些状况下(例如测试)多是有用和安全的,有时确实能够利用这一特性,这是值得了解和理解的。咱们说一个模块能够修改全局范围内的其余模块或对象。它一般是指在运行时修改现有对象以更改或扩展其行为或应用的临时更改。

如下示例显示了咱们如何向另外一个模块添加新函数:

// file patcher.js
// ./logger is another module
require('./logger').customMessage = () => console.log('This is a new functionality');

编写如下代码:

// file main.js
require('./patcher');
const logger = require('./logger');
logger.customMessage();

在上述代码中,必须首先引入patcher程序才能使用logger模块。

上面的写法是很危险的。主要考虑的是拥有修改全局命名空间或其余模块的模块是具备反作用的操做。换句话说,它会影响其范围以外的实体的状态,这可能致使不可预测的后果,特别是当多个模块与相同的实体进行交互时。想象一下,有两个不一样的模块尝试设置相同的全局变量,或者修改同一个模块的相同属性,效果多是不可预测的(哪一个模块胜出?),但最重要的是它会对在整个应用程序产生影响。

观察者模式

Node.js中的另外一个重要和基本的模式是观察者模式。与reactor模式,回调模式和模块同样,观察者模式是Node.js基础之一,也是使用许多Node.js核心模块和用户定义模块的基础。

观察者模式是对Node.js的数据响应的理想解决方案,也是对回调的完美补充。咱们给出如下定义:

发布者定义一个对象,它能够在其状态发生变化时通知一组观察者(或监听者)。

与回调模式的主要区别在于,主体实际上能够通知多个观察者,而传统的CPS风格的回调一般主体的结果只会传播给一个监听器。

EventEmitter类

在传统的面向对象编程中,观察者模式须要接口,具体类和层次结构。在Node.js中,都变得简单得多。观察者模式已经内置在核心模块中,能够经过EventEmitter类来实现。 EventEmitter类容许咱们注册一个或多个函数做为监听器,当特定的事件类型被触发时,它的回调将被调用,以通知其监听器。如下图像直观地解释了这个概念:

EventEmitter是一个类(原型),它是从事件核心模块导出的。如下代码显示了如何得到对它的引用:

const EventEmitter = require('events').EventEmitter;
const eeInstance = new EventEmitter();

EventEmitter的基本方法以下:

  • on(event,listener):此方法容许您为给定的事件类型(String类型)注册一个新的侦听器(一个函数)
  • once(event, listener):此方法注册一个新的监听器,而后在事件首次发布以后被删除
  • emit(event, [arg1], [...]):此方法会生成一个新事件,并提供其余参数以传递给侦听器
  • removeListener(event, listener):此方法将删除指定事件类型的侦听器

全部上述方法将返回EventEmitter实例以容许连接。监听器函数function([arg1], [...]),因此它只是接受事件发出时提供的参数。在侦听器中,这是指EventEmitter生成事件的实例。
咱们能够看到,一个监听器和一个传统的Node.js回调有很大的区别;特别地,第一个参数不是error,它是在调用时传递给emit()的任何数据。

建立和使用EventEmitter

咱们来看看咱们如何在实践中使用EventEmitter。最简单的方法是建立一个新的实例并当即使用它。如下代码显示了在文件列表中找到匹配特定正则的文件内容时,使用EventEmitter实现实时通知订阅者的功能:

const EventEmitter = require('events').EventEmitter;
const fs = require('fs');

function findPattern(files, regex) {
  const emitter = new EventEmitter();
  files.forEach(function(file) {
    fs.readFile(file, 'utf8', (err, content) => {
      if (err)
        return emitter.emit('error', err);
      emitter.emit('fileread', file);
      let match;
      if (match = content.match(regex))
        match.forEach(elem => emitter.emit('found', file, elem));
    });
  });
  return emitter;
}

由前面的函数EventEmitter处理将产生的三个事件:

  • fileread事件:当文件被读取时触发
  • found事件:当文件内容被正则匹配成功时触发
  • error事件:当读取文件出现错误时触发

下面看findPattern()函数是如何被触发的:

findPattern(['fileA.txt', 'fileB.json'], /hello \w+/g)
  .on('fileread', file => console.log(file + ' was read'))
  .on('found', (file, match) => console.log('Matched "' + match + '" in file ' + file))
  .on('error', err => console.log('Error emitted: ' + err.message));

在前面的例子中,咱们为EventParttern()函数建立的EventEmitter生成的每一个事件类型注册了一个监听器。

错误传播

若是事件是异步发送的,EventEmitter不能在异常状况发生时抛出异常,异常会在事件循环中丢失。相反,而是emit是发出一个称为错误的特殊事件,Error对象经过参数传递。这正是咱们在以前定义的findPattern()函数中正在作的。

对于错误事件,始终是最佳作法注册侦听器,由于Node.js会以特殊的方式处理它,而且若是没有找到相关联的侦听器,将自动抛出异常并退出程序。

让任意对象可观察

有时,直接经过EventEmitter类建立一个新的可观察的对象是不够的,由于原生EventEmitter类并无提供咱们实际运用场景的拓展功能。咱们能够经过扩展EventEmitter类使一个通用对象可观察。

为了演示这个模式,咱们试着在对象中实现findPattern()函数的功能,以下代码所示:

const EventEmitter = require('events').EventEmitter;
const fs = require('fs');
class FindPattern extends EventEmitter {
  constructor(regex) {
    super();
    this.regex = regex;
    this.files = [];
  }
  addFile(file) {
    this.files.push(file);
    return this;
  }
  find() {
    this.files.forEach(file => {
      fs.readFile(file, 'utf8', (err, content) => {
        if (err) {
          return this.emit('error', err);
        }
        this.emit('fileread', file);
        let match = null;
        if (match = content.match(this.regex)) {
          match.forEach(elem => this.emit('found', file, elem));
        }
      });
    });
    return this;
  }
}

咱们定义的FindPattern类中运用了核心模块util提供的inherits()函数来扩展EventEmitter。以这种方式,它成为一个符合咱们实际运用场景的可观察类。如下是其用法的示例:

const findPatternObject = new FindPattern(/hello \w+/);
findPatternObject
  .addFile('fileA.txt')
  .addFile('fileB.json')
  .find()
  .on('found', (file, match) => console.log(`Matched "${match}"
       in file ${file}`))
  .on('error', err => console.log(`Error emitted ${err.message}`));

如今,经过继承EventEmitter的功能,咱们如今能够看到FindPattern对象除了可观察外,还有一整套方法。
这在Node.js生态系统中是一个很常见的模式,例如,核心HTTP模块的Server对象定义了listen()close()setTimeout()等方法,而且在内部它也继承自EventEmitter函数,从而容许它在收到新的请求、创建新的链接或者服务器关闭响应请求相关的事件。

扩展EventEmitter的对象的其余示例是Node.js流。咱们将在第五章中更详细地分析Node.js的流。

同步和异步事件

与回调模式相似,事件也支持同步或异步发送。相当重要的是,咱们决不该当在同一个EventEmitter中混合使用两种方法,可是在发布相同的事件类型时考虑同步或者异步显得相当重要,以免产生因同步与异步顺序不一致致使的zalgo

发布同步和异步事件的主要区别在于观察者注册的方式。当事件异步发布时,即便在EventEmitter初始化以后,程序也会注册新的观察者,由于必须保证此事件在事件循环下一周期以前不被触发。正如上边的findPattern()函数中的状况。它表明了大多数Node.js异步模块中使用的经常使用方法。

相反,同步发布事件要求在EventEmitter函数开始发出任何事件以前就得注册好观察者。看下面的例子:

const EventEmitter = require('events').EventEmitter;
class SyncEmit extends EventEmitter {
  constructor() {
    super();
    this.emit('ready');
  }
}
const syncEmit = new SyncEmit();
syncEmit.on('ready', () => console.log('Object is ready to be  used'));

若是ready事件是异步发布的,那么上述代码将会正常运行,然而,因为事件是同步发布的,而且监听器在发送事件以后才被注册,因此结果不调用监听器,该代码将没法打印到控制台。

因为不一样的应用场景,有时以同步方式使用EventEmitter函数是有意义的。所以,要清楚地突出咱们的EventEmitter的同步和异步性,以免产生没必要要的错误和异常。

事件机制与回调机制的比较

在定义异步API时,常见的难点是检查是否使用EventEmitter的事件机制或仅接受回调函数。通常区分规则是这样的:当一个结果必须以异步方式返回时,应该使用回调函数,当须要结果不肯定其方式时,应该使用事件机制来响应。

可是,因为这二者实在太相近,而且可能两种方式都能实现相同的应用场景,因此产生了许多混乱。如下列代码为例:

function helloEvents() {
  const eventEmitter = new EventEmitter();
  setTimeout(() => eventEmitter.emit('hello', 'hello world'), 100);
  return eventEmitter;
}

function helloCallback(callback) {
  setTimeout(() => callback('hello world'), 100);
}

helloEvents()helloCallback()在其功能上能够被认为是等价的,第一个使用事件机制实现,第二个则使用回调来通知调用者,而将事件做为参数传递。可是真正区分它们的是可执行性,语义和要实现或使用的代码量。虽然咱们不能给出一套肯定性的规则来选择一种风格,但咱们固然能够提供一些提示来帮助你作出决定。

相比于第一个例子,即观察者模式而言,回调函数在支持不一样类型的事件时有一些限制。可是事实上,咱们仍然能够经过将事件类型做为回调的参数传递,或者经过接受多个回调来区分多个事件。然而,这样作的话不能被认为是一个优雅的API。在这种状况下,EventEmitter能够提供更好的接口和更精简的代码。

EventEmitter更优秀的另外一种应用场景是屡次触发同一事件或不触发事件的状况。事实上,不管操做是否成功,一个回调预计都只会被调用一次。但有一种特殊状况是,咱们可能不知道事件在哪一个时间点触发,在这种状况下,EventEmitter是首选。

最后,使用回调的API仅通知特定的回调,可是使用EventEmitter函数可让多个监听器都接收到通知。

回调机制和事件机制结合使用

还有一些状况能够将事件机制和回调结合使用。特别是当咱们导出异步函数时,这种模式很是有用。node-glob模块是该模块的一个示例。

glob(pattern, [options], callback)

该函数将一个文件名匹配模式做为第一个参数,后面两个参数分别为一组选项和一个回调函数,对于匹配到指定文件名匹配模式的文件列表,相关回调函数会被调用。同时,该函数返回EventEmitter,它展示了当前进程的状态。例如,当成功匹配文件名时能够实时发布match事件,当文件列表所有匹配完毕时能够实时发布end事件,或者该进程被手动停止时发布abort事件。看如下代码:

const glob = require('glob');
glob('data/*.txt', (error, files) => console.log(`All files found: ${JSON.stringify(files)}`))
  .on('match', match => console.log(`Match found: ${match}`));

总结

在本章中,咱们首先了解了同步和异步的区别。而后,咱们探讨了如何使用回调机制和回调机制来处理一些基本的异步方案。咱们还了解到两种模式之间的主要区别,什么时候比另外一种模式更适合解决具体问题。咱们只是迈向更先进的异步模式的第一步。

在下一章中,咱们将介绍更复杂的场景,了解如何利用回调机制和事件机制来处理高级异步控制问题。

相关文章
相关标签/搜索