[关闭]
@FunC 2017-11-12T14:32:16.000000Z 字数 8784 阅读 2108

Node.js Design Patterns | CH02

Node.js


callback 模式

Callback 是reactor 模式中handler 的具体形式。JavaScript 是使用callback 的一个代表,因为在JavaScript 中,函数是一等公民,可以作为参数传递。其次,闭包的存在能让我们维持异步操作的上下文。

The continuation-passing style

在函数式编程中,把将结果传递给另一个函数(callback),而不是直接返回给调用者的模式称为continuation-passing style(CPS)

异步CSP

  1. function additionAsync(a, b, callback) {
  2. setTimeout(() => callback(a + b), 100);
  3. }
  4. // run
  5. console.log('before');
  6. additionAsync(1, 2, result => console.log('Result: ' + result));
  7. console.log('after');
  8. // output
  9. "before"
  10. "after"
  11. "Result: 3"

当异步操作执行完毕,程序会在从Event Loop中执行回调函数,因此它会有一个新的执行栈。因为有闭包,调用该异步操作的上下文能维持下来。

非CSP回调

回调函数不一定在应用continuation-pass style:

  1. const result = [1, 2, 3].map(element => element - 1);

例如此处的回调函数只是用于遍历数组中的元素,而不是把操作得到的结果传递给它。

同步还是异步?

总体来说,创造一个API ,无论同步还是异步,一定要保持前后一致,否则容易发生不可预测的后果。
当同时使用了同步和异步API 时,要将其统一(例如第一次读取文件时异步读取,第二次从缓存返回)。

使用同步API

其中一个统一的方法就是使用同步版本的API,如fs.readFileSync()
但是使用同步版本的API 来代替异步版本的API 也有一些问题:
1. 不是全部API 都有同步版本。
2. 同步API 会阻塞event loop,减慢整个应用的速度。

当然,某些场景下使用同步API 是更简单更有效率的,例如在启动应用时同步读取配置文件。

延后执行

另一种方法就是统一成异步API。在Node.js 中,可以通过process.nextTick()来将执行推迟到下一个event loop 中,在所有I/O事件前执行。而setImmediate()将执行推迟到现有的I/O队列的最后。

Node.js 中回调函数的使用习惯

回调函数放最后

在参数列表中,回调函数总是作为最后一个参数出现,即使中间有可选参数。

  1. fs.readFile(filename, [option], callback)

异常优先

在CPS 中,error 总是作为回调函数的第一个参数(并且error 要是Error 类型)。

异常传递

同步地传递错误时使用常见的throw。异步中,传递错误的正确做法是将其链式传递给下一个callback,否则将会是整个程序崩溃。
错误可能在一些意料之外的地方发生,即便如此,我们仍然有机会在程序终结之前做一些简单的清理和日志记录。Node.js 在退出进程时会触发一个特殊的uncaughtException事件。
要注意的是,一次意外退出可能会引发一些难以预见的问题(例如有未完成的I/O请求在运行)。因此在生产环境当中,遇到未捕获的异常时要将程序退出。

模块系统及其模式

暴露模块模式

通过自执行函数来创建一个私有作用域,并且仅导出公有API 。

  1. const module = (() => {
  2. const privateBar = [];
  3. const exported = {
  4. publicBar: () => {...}
  5. };
  6. return exported;
  7. })();

Node.js 模块详解

自制模块加载器

  1. function loadModule(filename, module, require) {
  2. const wrappedSrc = `(function(module, exports, require) {
  3. ${fs.readFileSync(filename, 'utf8')}
  4. })(module, module.exports, require);`;
  5. eval(wrappedSrc);
  6. }

实现require()函数

  1. const require = (moduleName) => {
  2. console.log('Require invoked for module: ${moduleName}`);
  3. const id = require.resolve(moduleName); // 解析出模块文件的路径,详见下文
  4. if (require.cache[id]) {
  5. return require.cache[id].expoets;
  6. }
  7. // module metadata
  8. const module = {
  9. exports: {},
  10. id: id
  11. };
  12. // Update the cache
  13. require.cache[id] = module;
  14. // load the module
  15. loadModule(id, module, require);
  16. // return exported variables
  17. return module.exports;
  18. };
  19. require.cache = {};
  20. require.resolve = (moduleName) => {/* resolve a full module id from name */}

大致流程如下
1. 通过一定的算法,从模块名中找到其文件路径,并作为id
2. 如果该模块先前已被加载过,则从缓存中立即返回。
3. 否则,创建一个module对象,其包括exports属性和id。
4. 缓存模块
5. 读取模块源码并执行,把require()的引用提供给它。模块通过操作exports或重写module.exports来暴露公有API。
6. 最后将module.exports的内容返回给它的调用者。

定义模块

require()函数可以看出,定义一个模块时,所有没赋值给module.exports变量的都是私有的。

module.exports Vs exports

exports 只是module.exports 的一个引用。也就是说,我们只能在exports上添加新的属性,而不能直接赋值:

  1. // work
  2. exports.hello = () => {
  3. console.log('Hello');
  4. }
  5. // no effect
  6. exports = () => {
  7. console.log('Hello');
  8. }

如果想要export处理对象字面量以外的东西(例如函数,实例,甚至是字符串),我们可以通过给module.exports 重新赋值来实现:

  1. module.exports = () => {
  2. console.log(‘Hello’);
  3. }

require函数是同步的

require函数是同步执行的,所以任何赋值给module.exports的东西也要是同步的。

  1. // incorrect
  2. setTimeout(() => {
  3. module.exports = function() { };
  4. }, 100);

这间接带来一个重大影响,那就是限制了我们定义模块时也要使用同步执行的代码(这也是Node.js 核心库提供了这么多同步版API 的重要原因之一)。如果我们的模块实在需要一些异步的初始化步骤,我们可以考虑export 一个未初始化的模块,然后在以后进行异步初始化。

resolving 算法

依赖地狱(dependency hell)形容的是当一个软件的依赖(dependencies)轮流依赖同一个依赖(dependency)的互不兼容的版本时的场景。Node.js 通过让模块根据其启动的位置读取不同的依赖版本来解决这个问题。这个特性通过npm 和require函数里的resolving 算法来实现。
resolving 算法可以大致分为三个方向:
1. 文件模块:如果moduleName 以/或者./开头,根据路径寻找相应的文件(其中前者为决定路径,后者为相对路径)
2. 核心模块:如果不是以/或者./开头,则在Node.js 的核心模块里寻找
3. 依赖包模块:如果在核心模块中没找到,则依次从mode_modules目录中向上寻找,直至到达根目录。

具体的算法涉及情况较多,下面给出官方的伪代码作参考:

  1. require(X) from module at path Y
  2. 1. If X is a core module,
  3. a. return the core module
  4. b. STOP
  5. 2. If X begins with '/'
  6. a. set Y to be the filesystem root
  7. 3. If X begins with './' or '/' or '../'
  8. a. LOAD_AS_FILE(Y + X)
  9. b. LOAD_AS_DIRECTORY(Y + X)
  10. 4. LOAD_NODE_MODULES(X, dirname(Y))
  11. 5. THROW "not found"
  12. LOAD_AS_FILE(X)
  13. 1. If X is a file, load X as JavaScript text. STOP
  14. 2. If X.js is a file, load X.js as JavaScript text. STOP
  15. 3. If X.json is a file, parse X.json to a JavaScript Object. STOP
  16. 4. If X.node is a file, load X.node as binary addon. STOP
  17. LOAD_INDEX(X)
  18. 1. If X/index.js is a file, load X/index.js as JavaScript text. STOP
  19. 2. If X/index.json is a file, parse X/index.json to a JavaScript object. STOP
  20. 3. If X/index.node is a file, load X/index.node as binary addon. STOP
  21. LOAD_AS_DIRECTORY(X)
  22. 1. If X/package.json is a file,
  23. a. Parse X/package.json, and look for "main" field.
  24. b. let M = X + (json main field)
  25. c. LOAD_AS_FILE(M)
  26. d. LOAD_INDEX(M)
  27. 2. LOAD_INDEX(X)
  28. LOAD_NODE_MODULES(X, START)
  29. 1. let DIRS=NODE_MODULES_PATHS(START)
  30. 2. for each DIR in DIRS:
  31. a. LOAD_AS_FILE(DIR/X)
  32. b. LOAD_AS_DIRECTORY(DIR/X)
  33. NODE_MODULES_PATHS(START)
  34. 1. let PARTS = path split(START)
  35. 2. let I = count of PARTS - 1
  36. 3. let DIRS = []
  37. 4. while I >= 0,
  38. a. if PARTS[I] = "node_modules" CONTINUE
  39. b. DIR = path join(PARTS[0 .. I] + "node_modules")
  40. c. DIRS = DIRS + DIR
  41. d. let I = I - 1
  42. 5. return DIRS

模块缓存

每个模块都只会被加载和运行一次,之后的每次require都是返回缓存。缓存除了能提高性能以外,可能还会带来其他的一些后果:
1. 可能出现循环依赖。
2. 在一个给定的依赖包中,require同一个模块总能返回同一个实例。

模块缓存保存在变量require.cache中。一个常见的用法是通过删除某个模块的缓存使其失效(这在测试中很有用,但在普通场景下非常危险)。

循环依赖

很多人觉得循环依赖只是一个设计出来的问题,但实际上在项目中是有可能发生的。
下面看一个例子:

  1. // module: a.js
  2. exports.loaded = false;
  3. const b = require(‘./b’);
  4. module.exports = {
  5. bWasLoaded: n.loaded,
  6. loaded: true
  7. };
  8. // module: b.js
  9. exports.loaded = false;
  10. const a = require(‘./a’); // 此处拿到的是a的缓存,此时loaded仍为false
  11. module.exports = {
  12. aWasLoaded: a.loaded,
  13. loaded: true
  14. };
  15. // main.js
  16. const a = require(‘./a’);
  17. const b = require(‘./b’);
  18. console.log(a);
  19. console.log(b);

从之前require()的代码能看到,在loadModule 之前,require.cache[id]已经被赋值为module了,所以b 拿到的是未初始化完的a。

“模块定义”的模式

模块系统除了是一个加载依赖的装置,同时也是定义API 的一个工具。关于API 设计的问题普遍都是围绕着公/私有部分之间的平衡展开的。目的是尽可能提高API 可用性的同时,尽可能的隐藏内部信息(在平衡扩展性和代码复用性的基础上)

命名导出(Named exports)

暴露一个公有API 最基本的方法就是使用命名导出,也就是将要暴露的值作为exports的属性。这样一来,导出的exports 对象就成为了一个命名空间。

值得注意的是,CommonJS 规范只允许使用exports 变量来暴露公有成员。使用module.exports 其实是Node.js 提供的扩展功能。

导出一个函数

最流行的模块定义模式是将一个函数赋值给module.exports 。这样做的好处是提供了一个明确的入口,同时还满足了小表面(small surface)的原则。

  1. module.exports = (message) => {
  2. console.log(`info: ${message}`);
  3. };
  4. // use the exported function as namespace
  5. module.exports.verbose = (message) => {
  6. console.log(`verbose: ${message}`);
  7. };

这种模式将注意力放在了一个单一的,模块中最重要的函数里。Node.js 强烈推荐遵循单一责任原则(Single Responsibility Principle: SRP):每个模块应该只负责单一的功能,且该功能应该完全被包含在模块当中。

导出一个constructor

将模块以constructor 的方式导出其实是导出一个函数中的特例,它允许使用者用它来创建一个新的实例,同时给予了扩展其原型的能力。
这种模式下要注意避免没有使用new操作符调用的情况,可以使用下面两种方式实现:

  1. function Logger(name) {
  2. if(!(this instanceof Logger)) {
  3. return new Logger(name);
  4. }
  5. this.name = name;
  6. };
  7. // using ES2015’s new.target
  8. function Logger(name) {
  9. if (!new.target) {
  10. return new Logger(name);
  11. }
  12. this.name = name;
  13. }

导出一个实例

利用require()的缓存机制,我们能够很容易地就定义一个有状态的实例,并在不同的模块中分享:

  1. // ...
  2. function Logger() {
  3. this.count = 0; // stateful
  4. }
  5. Logger.prototype.log = function(message) {
  6. this.count++;
  7. console.log(message);
  8. }
  9. module.exports = new Logger('DEFAULT');

因为模块被缓存,所以每次require 这个模块都会返回同一个实例对象。该模式类似于创造一个单例,但并不能满足在整个程序中的唯一性。如果我们仔细分析resolving 算法,会发现一个模块有可能被多次安装(例如不同版本)。这导致了同一个逻辑的模块有多个实例存在于同一个Node 应用的上下文中。

我们还可以将construcotr 作为导出实例的一个属性,来提供创建新实例甚至扩展原型的能力:

  1. module.exports.Logger = Logger;

从可用性的角度看,有点类似于把导出函数作为命名空间的场景。模块导出了一个默认的,最常用的实例,其它特性通过属性的方式暴露。

修改全局作用域或其他模块

一个模块甚至可以不导出任何东西(看起来似乎有点反直觉)。但别忘了我们在任何模块当中都能修改全局作用域,包括在缓存中的其它模块。
尽管通常被当作不好的实践,但一定环境下还是有用的(例如在测试时)。这也被称作monkey patching:在运行时(runtime)修改已存在的对象,来达到扩展功能或者临时修补的目的。

  1. // file patcher.js
  2. // ./logger is another module
  3. require('./logger').customMessage = () => console.log('This is a new functionality');
  4. // main.js
  5. require('./patcher'); // require before using logger!
  6. const logger = require('./logger');
  7. logger.customMessage();

在上面的代码中,要注意在第一次使用logger 前require pather,以保证补丁生效。

观察者模式

观察者模式是Node.js 中的又一个重要而基础的模式,是平台的支柱以及使用很多核心模块和社区模块的先决条件。正式定义如下:
观察者模式定义了一个对象(称为subject),当它的状态改变时,能通知一系列的观察者(或者叫监听者)。
与callback 模式最大的不同在于subject 能通知多个观察者,而传统的CPS回调通常只能把结果传给一个观察者(也就是回调函数)。

EventEmitter 类

观察者模式在Node.js 的核心库以EventEmitter 类的形式内置实现。有基本的ononceemitremoveListener等方法,而且这些方法的返回值都是EventEmitter实例(便于链式调用)
其中监听函数的签名为function([arg1], […]),与传统回调函数中必须要以error 为第一个参数不同。在监听函数内部的this指向产生该事件的EventEmitter实例。

传递异常

当发生异常时,不能直接把error 抛出。因为如果事件是异步触发的,其错误信息就会丢失在event loop 中。取而代之的,我们可以触发一个特殊的error 事件,并将Error对象作为参数传递。
时刻记得给error 事件注册监听器是Node.js 中的最佳实践,因为Node.js 会对该事件特殊处理,如果找不到相应的监听器,就会自动抛出异常并退出程序。

让所有对象都变得可观察(observable)

直接从EventEmitter类中创建新的观察者对象远远不够,更常见的场景是让普通的对象变得可观察。我们可以通过继承EventEmitter类来实现:

  1. // …
  2. class FindPattern extends EventEmitter {
  3. // ..
  4. }
  5. // how to use
  6. const findPatternObject = new FindPattern();
  7. findPatternObject
  8. .addFile('fileA.txt')
  9. .addFile('fileB.txt')
  10. .find()
  11. .on('found', (file, match) => console.log('find')) // EventEmitter's api
  12. .on('error', err => console.log(err));

同步事件与异步事件

需要注意的是,不能混用同步事件和异步事件,原因和上一章讲callback 时相同。
同步事件和异步事件的主要差异在于其listener 的注册方式。
如果事件异步触发,程序有充足的时间去注册listener (即使EventEmitter已经初始化完全)。因为事件只有在下一轮的event loop中才会触发
如果事件同步触发,那么在事件触发后注册的listener 将不会被调用。
所以我们一定要在文档中说明我们EventEmitter的行为,以避免歧义。

EventEmitter Vs callback

在定义异步API 时,人们总会纠结应该用EventEmitter还是callback。一个最基本的区分方法就是根据其语义:callback应该在结果异步返回的时候调用,而事件在需要通信的时候使用(来通知刚发生了某件事)。
大多数情况下两者都能实现同样的功能,除了语义上可做区别之外,还有其它的一些区分建议:
1. callback不太适合支持多种事件
2. 在同一事件可能会触发多次的场景下,EventEmitter更合适。而callback的期望是无论成功失败,总要执行一次。

将callback 和EvenEmitter 结合

某些情况下callback可以和EventEmitter作结合,这种模式能轻松实现小表面(small surface area)。提供主函数作为导出值的同时,利用EventEmitter提供了更丰富的特性与操作性。例子如下:

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

可以看到,除了通过回调实现基本的结果处理,还能通过事件来对过程进行细粒度的控制。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注