Module definition patterns
除了作为加载依赖的机制之外,模块系统也是一种用于定义API的工具。正如针对其他与API设计相关的问题一样,所要考虑的主要因素是:在公有函数和私有函数之间获得平衡。其目的是最大限度的实现信息隐藏和API可用性,与此同时与其他软件质量指标(可扩展性和代码复用性)相平衡。
在本节中,我们将分析一些最流行的设计模式来定义Node中的模块。每一种模式,都有它自身针对信息隐藏、可扩展性和代码复用的平衡。
Named exports
暴露公共API最基本的方式是使用命名导出,它包括将所有要公开的值赋予exports (或module.exports)所引用的对象。在这种方式下,生成的导出对象变成了容器或者命名空间来容纳一系列相关的函数。
以下代码展示了一个采用这种模式的模块:
//file logger.js
exports.info = function(message) {
console.log('info: ' + message);
};
exports.verbose = function(message) {
console.log('verbose: ' + message);
};
所导出的函数将作为已加载的模块属性而变得可用,如以下代码所示:
//file main.js
var logger = require('./logger');
logger.info('This is an informational message');
logger.verbose('This is a verbose message');
大多数的Node核心模块使用这种模式
CommonJS规范只允许通过使用导出变量来暴露公共的成员。因此,命名导出模式是唯一能够实际与CommonJS规范相互兼容的规范。使用module.exports是由Node提供的一种拓展来支持更范围更广的模块定义模式,如图我们竟会在下一节所将看到的一样。
Exporting a function
一个模块导出一个构造器是一个模块导出一个函数的特例。不同之处在于,使用这种新模式,我们允许用户使用构造器创造新的实例,但是同时我们要给它们能力去继承原型并且构造新的类。接下来是一个以这种模式实现的例子:
//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 logger.js var Logger = require('./logger');
var dbLogger = new Logger('DB');
dbLogger.info('This is an informational message');
var accessLogger = new Logger('ACCESS');
accessLogger.verbose('This is a verbose message');
导出一个构造器始终为模块提供了一个单独的入口点,但是与子栈模式相比相比,它暴露了模块内部的内容。然而,与此同时,当需要去扩展它的函数时,它提供了更多的能力。
这种模式的变化包括应用在一个确保不使用新的指令调用的机制上。这个小技巧允许我们使用我们的模块作为一个工厂。以下的代码向你展示了这个机制如何工作:
function Logger(name) {
if(!(this instanceof Logger)) {
return new Logger(name);
}
this.name = name;
};
技巧很简单,我们检查它是否存在并且作为日志记录器的一个实例。如果这些条件的其中任意一个是错误的,这意味着Logger()函数被调用的时候不使用new,所以我们继续适当的创建实例并将它返回给调用者。这种技术允许我们将模块作为工厂使用,如下列代码所述:
//file logger.js
var Logger = require('./logger');
var dbLogger = Logger('DB');
accessLogger.verbose('This is a verbose message');
Exporting an instance
我们可以利用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
var logger = require('./logger');
logger.log('This is an informational message');
因为模块是缓存的,每个引入logger模块的模块实际上总要检索这对象相同的实例,由此来分享它的状态。这个模式非常像是创建了一种单例模式,然而,它不能确保遍及整个应用范围时这个实例的唯一性(就想它所发生在传统单例模式中那样)。当分析解析算法时,我们实际上看到,一个模块会在一个应用程序的依赖树种反复被安装很多次。这一结果伴随着同一个模块的很多个实例,全部都运行在同一个Node应用的上下文上。在第五章,写模块的部分中,我们将分析导出具有状态的实例所带来的影响和一些我们可以作为替代的模式。
针对我们刚才所描述模式的一个拓展,包括:暴露一个构造器来用来创建这个实例,更近一步的针对这个实例本身。这一机制允许用户去创建同一个对象中新的实例。甚至在需要的时候能够扩展这个实例。为了实现这个设计,我们只需要将一个新的属性赋予给实例,这个过程如下代码所示:
module.exports.Logger = Logger;
然后,我们可以使用导出控制器来创建这个类其他的实例,如下所示:
var customLogger = new logger.Logger('CUSTOM');
customLogger.log('This is an informational message');
从可用性的角度来看,这种机制与将导出函数用作命名空间的做法相似。模块导出一个对象的默认实例,作为我们大多数时间需要使用的函数部件。然而更多的特性,比如建立新实例或者继承对象的特性,仍然可以通过很少暴露的属性来使其可用。
Modifying other modules or the global scope
模块甚至可以到处任何内容,这似乎看起来有一点不恰当,然而,我们应该不会忘记一个模块可以改变全局作用域以及所有在它之内的对象,包括其他在缓存中的模块。请注意这些做法在通常情况下被认为是不好的实践,但是因为这种模式在某些场景下(比如说:测试环境下)可以是有用且安全的,并且有时候是在非生产环境下使用的,因此它很值得去了解和理解。所以,我们说一个模块可以改变其他摩羯和全局作用域中的对象。好的,这种方式被称为猴子补丁,这种机制通常用来指在运行时间修改现存对象或者改变或者扩充他们的行为来应用临时修补的实践。
以下实例向你展示了,如何向其他模块添加一个函数:
//file patcher.js
// ./logger is another module
require('./logger').customMessage = function() {
console.log('This is a new functionality');
};
使用我们新的补丁(patcher)模块,将会很容易写出如下代码:
//file main.js
require('./patcher');
var logger = require('./logger');
logger.customMessage();
在前面的代码中,补丁patcher必须在使用logger日志模块之前被第一次引入,来允许补丁patch被使用。
这里所描述的技术都是应用起来很危险的。最主要的关注点如下:具备一个模块可以改变全局的命名空间或者其他模块,这个过程是一个具有副作用的操作。换句话来说,它影响它们作用域以外实体的状态,这将会产生不能被预知的影响,特别是当很多模块与相同的实体交互。假设有两个不同的模块试图去设置同一个全局变量。或者改变同一个模块的同一个属性。影响将是无法预测的(哪个模块能够赢?)但是更重要的是,它将对整个应用程序产生影响。
The observer pattern
另一个重要但是基础的模式被采用在Node中,是观察observer模式。与反应器reactor、回调callbacks、以及模块module一样,这个模式是平台的基石,并且是针对使用很多node核心模块与用户平面模块的绝对先决条件。
观察者模式是一种针对Node反应特性建模的理想的解决方式,而且对于回调来说是一种理想的补充。让我们给出一个正确的定义如下:
观察者模式(observe):定义一个对象(被称为主体),其可以通知一系列的观察者(或者被称为监听者),当状态的一个改变发生时。
这种模式与回调模式最主要的区别是主体实际上可以通知很多观察者,而一个传统的持续传递模式的回调一般情况下只能传递它的结果给一个唯一的监听者,即那个回调。
The EventEmitter
在传统的面向对象编程中,观察者模式需要接口、具体的类、层级概念。而在Node当中,一切都变得更加简洁,观察者模式已经成为核心并且通过EventEmitter类来提供使用。EventEmitter类允许我们注册一个或者多个函数来作为监听者,这些监听器将会被调用当一个特殊的事件类型开始启用。下面的图象直观的展示了这种概念:
EventEmitter是一种原型,并且它是从events核心模块导出的。下述代码展示了我们如何对它进行引用:
var EventEmitter = require('events').EventEmitter;
var eeInstance = new EventEmitter();
EventEmitter的关键方法被给出,如下所示:
•on(event, listener):此方法允许你注册一个新的监听器(a function)来针对于给定的事件类型(a string)进行监听。
•once(event, listener):此方法注册一个新的监听器,在事件第一次被发射后会删除这个监听器。
•emit(event, [arg1], […]):这种方法会产生一个新的事件并提供附加的参数并传递给监听器。
•removeListener(event, listener):这个方法针对一个特殊的事件类型,删除一个监听器。
上述的所有方法将会返回EventEmitter实例来允许链式。监听器函数具有签名,function([arg1], […]),所以它只有当事件发射时,简单的接受所提供的参数。在监听器内部,这种参考EventEmitter实例的做法产生了事件。
我们已经看到在监听器和传统的Node回调之间巨大的不同。特别是,它的第一个参数不是error,而是在它被调用的时刻,可以放置任何传递给emit()函数的数据。
Create and use an EventEmitter
让我们来看看如何在实践中使用EventEmitter。最简洁的方法是创建一个新实例并直接使用它,以下代码展示了一个函数,当一个特殊模式在文件列表中找到时,它可以使用EventEmitter来实时通知所有用户:
var EventEmitter = require('events').EventEmitter;
var fs = require('fs');
function findPattern(files, regex) {
var emitter = new EventEmitter();
files.forEach(function(file) {
fs.readFile(file, 'utf8', function(err, content) {
if(err)
return emitter.emit('error', err);
emitter.emit('fileread', file);
var match = null;
if(match = content.match(regex))
match.forEach(function(elem) {
emitter.emit('found', file, elem);
});
});
});
return emitter;
}
由之前函数创建的EventEmitter,将会创造以下三种事件:
• fileread:这种事件发生在当一个文件被读取时。
• found:这种发生在当一个匹配被找到时。
• error:这种事件发生在当文件在读取过程中一个错误发生时。
让我们来看看,我们的findPattern()函数如何被使用:
findPattern(
['fileA.txt', 'fileB.json'],
/hello \w+/g
)
.on('fileread', function(file) {
console.log(file + ' was read');
})
.on('found', function(file, match) {
console.log('Matched "' + match + '" in file ' + file);
})
.on('error', function(err) {
console.log('Error emitted: ' + err.message);
});
在前面的例子中,我们针对于由EventEmitter创建的三种类型事件,每一种事件都注册一个监听器,而EventEmitter由findPattern()函数创建。
Propagating errors
EventEmitter和回调一样,不能抛出异常,当错误条件发生时,如果事件发射是异步的,它们将在事件循环(event loop)里丢失。相反,约定发射一个特殊事件,称为error,并将Error对象作为参数传递。而这正是我们在之前定义的函数findPattern()中所做的事情。
针对error事件注册一个监听器一直是一种很好的实践,因为Node将会以特殊的方式对待它,并且当没有相关联的监听器被找到时,可以自动抛出异常并且从程序中退出。
Make any object observable
有时候,从EventEmitter类中直接创建一个新的观察对象是不够的,因为这使得提供函数,其功能超越了仅仅针对于新事件创造的职能。事实上更常见的是,建立一种通用对象的观察,这个是通过继承EventEmitter类而变得可能的。
为了论证这种模式,我们尝试在对象中实现findPattern()函数,如下所示:
var EventEmitter = require('events').EventEmitter;
var util = require('util');
var fs = require('fs');
function FindPattern(regex) {
EventEmitter.call(this);
this.regex = regex; this.files = [];
}
util.inherits(FindPattern, EventEmitter);
FindPattern.prototype.addFile = function(file) {
this.files.push(file);
return this;
};
FindPattern.prototype.find = function() {
var self = this;
self.files.forEach(function(file) {
fs.readFile(file, 'utf8', function(err, content) {
if(err)
return self.emit('error', err);
self.emit('fileread', file);
var match = null;
if(match = content.match(self.regex))
match.forEach(function(elem) {
self.emit('found', file, elem);
});
});
});
return this;
};
我们所定义的FindPattern原型,继承了EventEmitter,使用了核心模块util所提供的inherits()函数。通过这种方式,它变成了完全可观察类。以下是使用它的实例:
var findPatternObject = new FindPattern(/hello \w+/);
findPatternObject
.addFile('fileA.txt')
.addFile('fileB.json')
.find() .on('found', function(file, match) {
console.log('Matched "' + match + '" in file ' + file);
})
.on('error', function(err) {
console.log('Error emitted ' + err.message);
})
我们可以看到FindPattern如何拥有一套完整的方法,除此之外能够被EventEmitter的函数所继承。
这是一个在Node生态系统中非常通用的模式,例如,核心http模块中服务器对象所定义的方法,比如:listen(), close(), setTimeout(),在内部它也是继承于EventEmitter函数,由此允许它去创造事件(比如:request),当一个新的请求被接收时,或者连接时,当一个新的连接被建立时,或者关闭时,当服务器关闭时。
其他关于对象继承自EventEmitter的例子是Node.js streams(流),我们将在第3章 Coding with Streams中更细节的分析流。
Synchronous and asynchronous events
正如回调一样,事件可以是同步或者异步发射的,并且关键的是我们可以把这两种方式在同一个EventEmitter中进行混合,但是更重要的是,当发射相同的事件类型时,避免创造我们在Unleashing Zalgo章节所讨论过的同样的问题。
发射同步与异步事件时,主要的区别在于,监听器被注册的方式。当事件异步发射时,用户具有全部的时间来注册新的监听器,即使当EventEmitter已经被初始化了。因为事件被确保不会被抛弃,直到事件循环(event loop)的下一轮周期。这正是findPattern()函数中所发生的事情。我们在之前定义了这个函数,并且它代表了在大多数Node模块中使用的通用方式。
相反,发射事件是同步的,需要所有的监听器在EventEmitter函数开始发射任何事件之前被注册。让我们一起来看看一个例子:
function SyncEmit() {
this.emit('ready');
}
util.inherits(SyncEmit, EventEmitter);
var syncEmit = new SyncEmit();
syncEmit.on('ready', function() {
console.log('Object is ready to be used');
});
如果准备好的事件是异步发出的,之前的代码将能够完美工作。然而,事件是同步创建的,并且监听器在事件已经发出之后被注册,所以结果是:监听器永远 不会被调用。代码不会向控制台打印任何内容。
相对于回调而言,这里有一些情况下以同步方式使用EventEmitter是有意义的,能够给它一个不同的意图。因为这个原因,在EventEmitter的文档中去强调它的表现是非常重要的,这样能够避免误解和潜在的错误使用。
EventEmitter vs Callbacks
当定义异步接口时一个常见的困境是:去检查是否使用EventEmitter或者只是简单接收一个回调callback。通用的区分法则是语义:当结果必须以异步方式返回时,回调callback方式需要被使用。当需要讨论一个刚刚发生的事情时,要转而去使用事件方式。
但是除了这个简单的原则之外,很多困惑由此产生:这两种范式在大多时间下是等价的,并且允许你获得相同的结果。考虑以下代码为例:
function helloEvents() {
var eventEmitter = new EventEmitter();
setTimeout(function() {
eventEmitter.emit('hello', 'world');
}, 100);
return eventEmitter;
}
function helloCallback(callback) {
setTimeout(function() {
callback('hello', 'world');
}, 100);
}
helloEvents() 和 helloCallback()这两个函数,就功能而言是完全等价的。第一个函数通过使用事件来交流超时情况,第二个函数转而使用回调函数来通知调用者,将事件类型(event type)作为参数而传递。但真正区分开它们的标准是:语义、可读性、需要被实现和使用的代码量。尽管我们不能给出一组确定的规则来在其中之一与另外一个模式之间进行选择,一些建议仍然能够帮助我们作出决定:
作为第一个观察,我们可以说当需要支持不同类型的事件时,回调方式有一些局限性。事实上,我们仍然可以区分多个事件通过:传递事件类型作为回调的参数,或者通过接受很多回调函数,其中每一个都支持事件。然而,但这些事实上不会被认为是优雅的API。在这种情况下,EventEmitter方式可以提供更优雅的接口和更精简的代码。
另外一个EventEmitter方式更被偏向于使用的情境是:当一个事件发生很多次或不再发生时。一个回调,事实上,被认为仅仅能够被调用一次,无论执行是成功还是失败。事实上,当我们面对一个可能重复的情况,需要我们再次深入思考事件发生的语义特性,这似乎意味着对于一个事件来说需要去通信而不仅仅是作为一个结果。在这种情况下,EventEmitter方式是更被偏爱的一个选择。
最后,一个使用API的回调只能通知特殊的回调,而使用EventEmitter函数,可以使众多的监听器接收同一个通知变为可能。
Combine callbacks and EventEmitter
也有一些情境下,EventEmitter方式可以与回调callback方式结合。当我们想要通过导出一个传统的异步函数作为主功能,同时要提供丰富的特性并通过返回一个EventEmitter来完善控制时,想要实现最小接触面原则时,这种模式非常有用。
这种模式的一个例子是使用node-glob模块(一个库,能够提供全局范围文件搜索功能)。模块的主要入口点是它所导出的函数,具有以下签名:
glob(pattern, [options], callback)
函数将模式作为第一个参数,接下来的参数是一组选项、以及一个回调函数,该函数被调用伴随着与所提供模式匹配的文件列表。与此同时,该函数返回了一个EventEmitter来提供一个关于流程状态的更细粒度的报告。举个例子,当一个匹配通过监听所匹配的事件而发生时,它很可能被实时通知,来获得与最后一个事件相关的所有匹配文件列表,或者去确定流程是否因监听废止事件而人为中止。以下代码展示了这种机制:
var glob = require('glob');
glob('data/*.txt', function(error, files) {
console.log('All files found: ' + JSON.stringify(files));
}).on('match', function(match) {
console.log('Match found: ' + match);
});
正如我们所看到的,在Node中暴露一个简洁、干净、并且最小化的入口点并且仍然提供高级或者次重要的特性,以第二种方式,是一种非常普遍的做法。并且将传统回调callback方式与提到的EventEmitter方式相结合,是一种能达到这个目标的方案:
模式:创建一个函数接受一个回调,并且返回一个EventEmitter,由此提供一个简洁但是干净的入口点给主要功能,同时通过EventEmitter来发射一些更细粒度的事件。
Summary
在这一章中,我们看到了Node平台是如何基于几个重要原则来提供基础来建立有效和可重用的代码。在平台背后的哲学和设计选择,事实上,对于我们所创建的每个应用程序和模块的表现来说都有着强烈影响。通常,对于一个从另一种技术中迁移出来的开发者来说,这些原则可能看起来并不熟悉,通常的本能反应是以尝试寻找在一个世界中更加熟悉的模式挑战这些改变,而这实际上需要思维方式上的真正转变。一方面,反应器(reactor)模式的异步特性,需要不同编程风格的回调和稍后发生的工序,不需要担心太多线程和竞争的条件。另一方面,模块模式和它简洁化、微小化的原则,针对代码的可重用性、可维护性和可用性方面,创造了有趣的新场景。
最终除了显然的技术优势,比如说:快速、高效。基于JavaScript,Node吸引了如此多开发者的兴趣是因为我们刚才所发现的那一系列原则。对很多人而言,把握世界的本质就像回到起源,用更人性化的方式编程来针对规模和复杂度进行优化,这就是为什么那么多开发者最后爱上了Node。
在下一章节中,我们将把经历放在处理异步代码的机制上,我们将看到回调如何轻易地变成了我们的敌人,并且我们将学会如何修复这个问题通过简单的原则、模式甚至构造来实现一个不需要持续传递风格的编程方式。