@FunC
2017-11-12T14:32:16.000000Z
字数 8784
阅读 2108
Node.js
Callback 是reactor 模式中handler 的具体形式。JavaScript 是使用callback 的一个代表,因为在JavaScript 中,函数是一等公民,可以作为参数传递。其次,闭包的存在能让我们维持异步操作的上下文。
在函数式编程中,把将结果传递给另一个函数(callback),而不是直接返回给调用者的模式称为continuation-passing style(CPS)。
function additionAsync(a, b, callback) {
setTimeout(() => callback(a + b), 100);
}
// run
console.log('before');
additionAsync(1, 2, result => console.log('Result: ' + result));
console.log('after');
// output
"before"
"after"
"Result: 3"
当异步操作执行完毕,程序会在从Event Loop中执行回调函数,因此它会有一个新的执行栈。因为有闭包,调用该异步操作的上下文能维持下来。
回调函数不一定在应用continuation-pass style:
const result = [1, 2, 3].map(element => element - 1);
例如此处的回调函数只是用于遍历数组中的元素,而不是把操作得到的结果传递给它。
总体来说,创造一个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队列的最后。
在参数列表中,回调函数总是作为最后一个参数出现,即使中间有可选参数。
fs.readFile(filename, [option], callback)
在CPS 中,error 总是作为回调函数的第一个参数(并且error 要是Error 类型)。
同步地传递错误时使用常见的throw
。异步中,传递错误的正确做法是将其链式传递给下一个callback,否则将会是整个程序崩溃。
错误可能在一些意料之外的地方发生,即便如此,我们仍然有机会在程序终结之前做一些简单的清理和日志记录。Node.js 在退出进程时会触发一个特殊的uncaughtException
事件。
要注意的是,一次意外退出可能会引发一些难以预见的问题(例如有未完成的I/O请求在运行)。因此在生产环境当中,遇到未捕获的异常时要将程序退出。
通过自执行函数来创建一个私有作用域,并且仅导出公有API 。
const module = (() => {
const privateBar = [];
const exported = {
publicBar: () => {...}
};
return exported;
})();
function loadModule(filename, module, require) {
const wrappedSrc = `(function(module, exports, require) {
${fs.readFileSync(filename, 'utf8')}
})(module, module.exports, require);`;
eval(wrappedSrc);
}
实现require()
函数
const require = (moduleName) => {
console.log('Require invoked for module: ${moduleName}`);
const id = require.resolve(moduleName); // 解析出模块文件的路径,详见下文
if (require.cache[id]) {
return require.cache[id].expoets;
}
// module metadata
const module = {
exports: {},
id: id
};
// Update the cache
require.cache[id] = module;
// load the module
loadModule(id, module, require);
// return exported variables
return module.exports;
};
require.cache = {};
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
变量的都是私有的。
exports
只是module.exports
的一个引用。也就是说,我们只能在exports
上添加新的属性,而不能直接赋值:
// work
exports.hello = () => {
console.log('Hello');
}
// no effect
exports = () => {
console.log('Hello');
}
如果想要export
处理对象字面量以外的东西(例如函数,实例,甚至是字符串),我们可以通过给module.exports
重新赋值来实现:
module.exports = () => {
console.log(‘Hello’);
}
require
函数是同步的require
函数是同步执行的,所以任何赋值给module.exports
的东西也要是同步的。
// incorrect
setTimeout(() => {
module.exports = function() { … };
}, 100);
这间接带来一个重大影响,那就是限制了我们定义模块时也要使用同步执行的代码(这也是Node.js 核心库提供了这么多同步版API 的重要原因之一)。如果我们的模块实在需要一些异步的初始化步骤,我们可以考虑export 一个未初始化的模块,然后在以后进行异步初始化。
依赖地狱(dependency hell)形容的是当一个软件的依赖(dependencies)轮流依赖同一个依赖(dependency)的互不兼容的版本时的场景。Node.js 通过让模块根据其启动的位置读取不同的依赖版本来解决这个问题。这个特性通过npm 和require
函数里的resolving 算法来实现。
resolving 算法可以大致分为三个方向:
1. 文件模块:如果moduleName 以/
或者./
开头,根据路径寻找相应的文件(其中前者为决定路径,后者为相对路径)
2. 核心模块:如果不是以/
或者./
开头,则在Node.js 的核心模块里寻找
3. 依赖包模块:如果在核心模块中没找到,则依次从mode_modules
目录中向上寻找,直至到达根目录。
具体的算法涉及情况较多,下面给出官方的伪代码作参考:
require(X) from module at path Y
1. If X is a core module,
a. return the core module
b. STOP
2. If X begins with '/'
a. set Y to be the filesystem root
3. If X begins with './' or '/' or '../'
a. LOAD_AS_FILE(Y + X)
b. LOAD_AS_DIRECTORY(Y + X)
4. LOAD_NODE_MODULES(X, dirname(Y))
5. THROW "not found"
LOAD_AS_FILE(X)
1. If X is a file, load X as JavaScript text. STOP
2. If X.js is a file, load X.js as JavaScript text. STOP
3. If X.json is a file, parse X.json to a JavaScript Object. STOP
4. If X.node is a file, load X.node as binary addon. STOP
LOAD_INDEX(X)
1. If X/index.js is a file, load X/index.js as JavaScript text. STOP
2. If X/index.json is a file, parse X/index.json to a JavaScript object. STOP
3. If X/index.node is a file, load X/index.node as binary addon. STOP
LOAD_AS_DIRECTORY(X)
1. If X/package.json is a file,
a. Parse X/package.json, and look for "main" field.
b. let M = X + (json main field)
c. LOAD_AS_FILE(M)
d. LOAD_INDEX(M)
2. LOAD_INDEX(X)
LOAD_NODE_MODULES(X, START)
1. let DIRS=NODE_MODULES_PATHS(START)
2. for each DIR in DIRS:
a. LOAD_AS_FILE(DIR/X)
b. LOAD_AS_DIRECTORY(DIR/X)
NODE_MODULES_PATHS(START)
1. let PARTS = path split(START)
2. let I = count of PARTS - 1
3. let DIRS = []
4. while I >= 0,
a. if PARTS[I] = "node_modules" CONTINUE
b. DIR = path join(PARTS[0 .. I] + "node_modules")
c. DIRS = DIRS + DIR
d. let I = I - 1
5. return DIRS
每个模块都只会被加载和运行一次,之后的每次require
都是返回缓存。缓存除了能提高性能以外,可能还会带来其他的一些后果:
1. 可能出现循环依赖。
2. 在一个给定的依赖包中,require
同一个模块总能返回同一个实例。
模块缓存保存在变量require.cache
中。一个常见的用法是通过删除某个模块的缓存使其失效(这在测试中很有用,但在普通场景下非常危险)。
很多人觉得循环依赖只是一个设计出来的问题,但实际上在项目中是有可能发生的。
下面看一个例子:
// module: a.js
exports.loaded = false;
const b = require(‘./b’);
module.exports = {
bWasLoaded: n.loaded,
loaded: true
};
// module: b.js
exports.loaded = false;
const a = require(‘./a’); // 此处拿到的是a的缓存,此时loaded仍为false
module.exports = {
aWasLoaded: a.loaded,
loaded: true
};
// main.js
const a = require(‘./a’);
const b = require(‘./b’);
console.log(a);
console.log(b);
从之前require()
的代码能看到,在loadModule 之前,require.cache[id]
已经被赋值为module
了,所以b 拿到的是未初始化完的a。
模块系统除了是一个加载依赖的装置,同时也是定义API 的一个工具。关于API 设计的问题普遍都是围绕着公/私有部分之间的平衡展开的。目的是尽可能提高API 可用性的同时,尽可能的隐藏内部信息(在平衡扩展性和代码复用性的基础上)
暴露一个公有API 最基本的方法就是使用命名导出,也就是将要暴露的值作为exports
的属性。这样一来,导出的exports
对象就成为了一个命名空间。
值得注意的是,CommonJS 规范只允许使用
exports
变量来暴露公有成员。使用module.exports
其实是Node.js 提供的扩展功能。
最流行的模块定义模式是将一个函数赋值给module.exports
。这样做的好处是提供了一个明确的入口,同时还满足了小表面(small surface)的原则。
module.exports = (message) => {
console.log(`info: ${message}`);
};
// use the exported function as namespace
module.exports.verbose = (message) => {
console.log(`verbose: ${message}`);
};
这种模式将注意力放在了一个单一的,模块中最重要的函数里。Node.js 强烈推荐遵循单一责任原则(Single Responsibility Principle: SRP):每个模块应该只负责单一的功能,且该功能应该完全被包含在模块当中。
将模块以constructor 的方式导出其实是导出一个函数中的特例,它允许使用者用它来创建一个新的实例,同时给予了扩展其原型的能力。
这种模式下要注意避免没有使用new
操作符调用的情况,可以使用下面两种方式实现:
function Logger(name) {
if(!(this instanceof Logger)) {
return new Logger(name);
}
this.name = name;
};
// using ES2015’s new.target
function Logger(name) {
if (!new.target) {
return new Logger(name);
}
this.name = name;
}
利用require()
的缓存机制,我们能够很容易地就定义一个有状态的实例,并在不同的模块中分享:
// ...
function Logger() {
this.count = 0; // stateful
}
Logger.prototype.log = function(message) {
this.count++;
console.log(message);
}
module.exports = new Logger('DEFAULT');
因为模块被缓存,所以每次require 这个模块都会返回同一个实例对象。该模式类似于创造一个单例,但并不能满足在整个程序中的唯一性。如果我们仔细分析resolving 算法,会发现一个模块有可能被多次安装(例如不同版本)。这导致了同一个逻辑的模块有多个实例存在于同一个Node 应用的上下文中。
我们还可以将construcotr
作为导出实例的一个属性,来提供创建新实例甚至扩展原型的能力:
module.exports.Logger = Logger;
从可用性的角度看,有点类似于把导出函数作为命名空间的场景。模块导出了一个默认的,最常用的实例,其它特性通过属性的方式暴露。
一个模块甚至可以不导出任何东西(看起来似乎有点反直觉)。但别忘了我们在任何模块当中都能修改全局作用域,包括在缓存中的其它模块。
尽管通常被当作不好的实践,但一定环境下还是有用的(例如在测试时)。这也被称作monkey patching:在运行时(runtime)修改已存在的对象,来达到扩展功能或者临时修补的目的。
// file patcher.js
// ./logger is another module
require('./logger').customMessage = () => console.log('This is a new functionality');
// main.js
require('./patcher'); // require before using logger!
const logger = require('./logger');
logger.customMessage();
在上面的代码中,要注意在第一次使用logger 前require pather,以保证补丁生效。
观察者模式是Node.js 中的又一个重要而基础的模式,是平台的支柱以及使用很多核心模块和社区模块的先决条件。正式定义如下:
观察者模式定义了一个对象(称为subject),当它的状态改变时,能通知一系列的观察者(或者叫监听者)。
与callback 模式最大的不同在于subject 能通知多个观察者,而传统的CPS回调通常只能把结果传给一个观察者(也就是回调函数)。
观察者模式在Node.js 的核心库以EventEmitter
类的形式内置实现。有基本的on
,once
,emit
,removeListener
等方法,而且这些方法的返回值都是EventEmitter
实例(便于链式调用)
其中监听函数的签名为function([arg1], […])
,与传统回调函数中必须要以error 为第一个参数不同。在监听函数内部的this
指向产生该事件的EventEmitter
实例。
当发生异常时,不能直接把error 抛出。因为如果事件是异步触发的,其错误信息就会丢失在event loop 中。取而代之的,我们可以触发一个特殊的error 事件,并将Error
对象作为参数传递。
时刻记得给error 事件注册监听器是Node.js 中的最佳实践,因为Node.js 会对该事件特殊处理,如果找不到相应的监听器,就会自动抛出异常并退出程序。
直接从EventEmitter
类中创建新的观察者对象远远不够,更常见的场景是让普通的对象变得可观察。我们可以通过继承EventEmitter
类来实现:
// …
class FindPattern extends EventEmitter {
// ..
}
// how to use
const findPatternObject = new FindPattern();
findPatternObject
.addFile('fileA.txt')
.addFile('fileB.txt')
.find()
.on('found', (file, match) => console.log('find')) // EventEmitter's api
.on('error', err => console.log(err));
需要注意的是,不能混用同步事件和异步事件,原因和上一章讲callback 时相同。
同步事件和异步事件的主要差异在于其listener 的注册方式。
如果事件异步触发,程序有充足的时间去注册listener (即使EventEmitter
已经初始化完全)。因为事件只有在下一轮的event loop中才会触发
如果事件同步触发,那么在事件触发后注册的listener 将不会被调用。
所以我们一定要在文档中说明我们EventEmitter
的行为,以避免歧义。
在定义异步API 时,人们总会纠结应该用EventEmitter
还是callback
。一个最基本的区分方法就是根据其语义:callback
应该在结果异步返回的时候调用,而事件在需要通信的时候使用(来通知刚发生了某件事)。
大多数情况下两者都能实现同样的功能,除了语义上可做区别之外,还有其它的一些区分建议:
1. callback
不太适合支持多种事件
2. 在同一事件可能会触发多次的场景下,EventEmitter
更合适。而callback
的期望是无论成功失败,总要执行一次。
某些情况下callback
可以和EventEmitter
作结合,这种模式能轻松实现小表面(small surface area)。提供主函数作为导出值的同时,利用EventEmitter
提供了更丰富的特性与操作性。例子如下:
const glob = require('glob');
glob('data/*.txt', (error, files) => console.log(`found: ${JSON.stringify(files)}`)
.on('match', match => console.log(`Match found: ${match}`);
可以看到,除了通过回调实现基本的结果处理,还能通过事件来对过程进行细粒度的控制。