前言
这篇文章试着要整理,翻译Export This: Interface Design Patterns for Node.js Modules这篇非常值得一读的文章。
但因为这篇文章有些时日了,部分示例已经不符合现况。故这是一篇加上小弟收集汇整而成的更新翻译。
旅程的开始
当你在Node中加载一个模块,我们到底会取回什么?当我们撰写一个模块时我们又有哪些选择可以用来设计程序的界面?
在我***次学习Node的时候,发现在Node中有太多的方式处理这个问题,由于Javascript本身非常弹性,加上在社群中的开发者们各自都有不同的实作风格,这让当时的我感到有点挫折。
在原文作者的学习旅程中曾持续的观察寻找好的方式以应用在其的工作上,在这篇文章中将会分享观察到的Node模块设计方式。
大略总结了7种设计模式(pattern)
- 导出命名空间Namespace
- 导出函式Function
- 导出高阶函式High-Order Function
- 导出构造函数/构建函式Constructor
- 导出单一实例物件Singleton
- 扩展全局物件Extend Global Object
- 套用猴子补丁Monkey Patch
require,exports和module.exports
首先我们需要先聊点基础的知识
在Node官方文件中定义了汇入一个档案就是汇入一个模块。
In Node.js,files and modules are in one-to-one correspondence.- Node文件
也就是所有的模块会参考指向(Reference)一个隐式模块物件的module.exports。当我们使用require()时会取得的东西。同时我们也取得exports。
这个exports就是指向module.exports的参考。exports会收集该其属性,如果module.exports没有任何属性就把这些数据交给module.exports,但如果module.exports已经具备属性的话,那么exports的所有数据都会被忽略。
为了让您更好理解关于exports与module.exports下面的示例提供了较详细的说明
- var a = { id: 1 }
- var b = a
- console.log(a)// {id: 1}
- console.log(b)// {id: 1}
- // b参考指向a,意味着修改b的属性a会跟着变动
- b.id = 2
- console.log(a)// {id: 2}
- console.log(b)// {id: 2}
- //但如果将一个全新的物件赋予b那么参考的关系将会中断
- b = { id: 3 }
- console.log(a)// {id: 2}
- console.log(b)// {id: 3}
另外比较具体的示例
- /* person.js */
- exports.name = function(){
- console.log('My name is andyyou.')
- }
- …
- /* main.js */
- var person = require('./person.js')
- person.name()
- /* person.js */
- module.exports = 'Hey,andyyou'
- exports.name = function(){
- console.log('My name is andyyou')
- }
- /* main.js */
- var person = require('./person.js')
- // exports的属性被忽略了
- person.name()// TypeError: Object Hey,andyyou has no method 'name'
- exports只是指向module.exports的参考(Reference)
- module.exports初始值为{}空物件,于是exports也会取得该空物件
- require()回传的是module.exports而不是exports
- 所以您可以使用exports.property_name = something而不会使用exports = something
- 一旦使用exports = something参考关系便会停止,也就是exports的数据都会被忽略。
本质上我们可以理解为所有模块都隐含实作了下面这行代码
- var exports = module.exports = {}
现在我们知道了,当我们要导出一个function时我们得使用module.exports。
如果使用exports那个exports的內存位置(Reference/参考)将会被修改而module.exports就不会得到其内容。
另外,我们在许多项目看到下面的这行代码
- exports = module.exports = something
这行代码作用就是确保exports在module.exports被我们复写之后,仍可以指向相同的参考。
接着我们就可以透过module.exports来定义并导出一个function
- /* function.js */
- module.exports = function(){
- return { name: 'andyyou' }
- }
使用的方式则是
- var func = require('./function')
关于require一个很重要的行为就是它会缓存(Cache)module.exports的值,未来每一次require被调用时都会回传相同的值。
它会根据汇入档案的绝对路径来缓存,所以当我们想要模块能够回传不同得值时,我们就需要导出function,如此一来每次执行函式时就会回传一个新值。
下面在Node REPL中简易的示范
- $ node
- > f1 = require('/Users/andyyou/Projects/export_this/function')
- [Function]
- > f2 = require('./function')//相同路径
- [Function]
- > f1 === f2
- true
- > f1()=== f2()
- false
您可以观察到require回传了同样的函式物件实例,但每一次调用函式回传的物件是不同的。
更详细的介绍可以参考官方文件,值得一读。
现在,我们可以开始探讨界面的设计模式(pattern)了。
导出命名空间
一个简单且常用的设计模式就是导出一个包含数个属性的物件,这些属性具体的内容主要是函式,但并不限于函式。
如此,我们就能够透过汇入该模块来取得这个命名空间下一系列相关的功能。
当您汇入一个命名空间类型的模块时,我们通常会将模块指定到某一个变数,然后透过它的成员(物件属性)来存取使用这些功能。
甚至我们也可以将这些变数成员直接指定到区域变数。
- var fs = require('fs')
- var readFile = fs.readFile
- var ReadStream = fs.ReadStream
- readFile('./file.txt',function(err,data){
- console.log('readFile contents: %s',data)
- })
这便是fs核心模块的做法
- var fs = exports
首先将隐式exports物件设定一个区域变数(即上面提过的exports)到fs,然后透过fs的属性使用各个功能,例如:fs.Stats = binding.Stats。
由于fs参考exports并且它是一个物件,所以当我们require('fs')时,我们就能够透过属性使用那些功能。
- fs.readFile = function(path,options,callback_){
- //…
- }
其他东西也是一样的作法,例如导出构造函数
- fs.ReadStream = ReadStream
- function ReadStream(path,options){
- //…
- }
- ReadStream.prototype.open = function(){
- //…
- }
当导出命名空间时,您可以指定属性到exports,就像fs的作法,又或者可以建立一个新的物件指派给module.exports
- /* exports作法*/
- exports.verstion = '1.0'
- /*或者module.exports作法*/
- module.exports = {
- version: '1.0',
- doYourTasks: function(){
- //…
- }
- }
一个常见的作法就是透过一个根模块(root)来汇整并导出其他模块,如此一来只需要一个require便可以使用所有的模块。
原文作者在Good Eggs工作时,会将数据模型(Model)拆分成个别的模块,并使用导出构造函数的方式导出(请参考下文介绍),然后透过一个index档案来集合该目录下所有的数据模型并一起导出,如此一来在models命名空间下的所有数据模型都可以使用
- var models = require('./models')
- var User = models.User
- var Product = models.Product
在ES2015和CoffeeScript中我们甚至还可以使用解构指派来汇入我们需要的功能
- /* CoffeeScript */
- {User,Product} = require './models'
- /* ES2015 */
- import {User,Product} from './models'
而刚刚提到的index.js大概就如下
- exports.User = require('./User')
- exports.Person = require('./person')
实际上这样分开的写法还有更精简的写法,我们可以透过一个小小的函式库来汇入在同一阶层中所有档案并搭配CamelCase的命名规则导出。
于是在我们的index.js中看起来就会如下
- module.exports = require('../lib/require_siblings')(__filename)
导出函式
另外一个设计模式是导出函式当作该模块的界面。常见的作法是导出一个工厂函式(Factory function),然后呼叫并回传一个物件。
在使用Express.js的时候便是这种作法
- var express = require('express')
- var app = express()
- app.get('/hello',function(req,res,next){
- res.send('Hi there!We are using Express v' + express.version)
- })
Express导出该函式,让我们可以用来建立一个新的express应用程序。
在使用这种模式时,通常我们会使用factory function搭配参数让我们可以设定并回传初始化后的物件。
想要导出function,我们就一定要使用module.exports,Express便是这么做
- exports = module.exports = createApplication
- …
- function createApplication(){
- …
- }
上面指派了createApplication函式到module.exports然后再指给exports确保参考一致。
同时Express也使用下面这种方式将导出函式当作命名空间的作法使用。
- exports.version = '3.1.1'
这边要大略解释一下由于Javascript原生并没有支持命名空间的机制,于是大部分在JS中提到的namespace指的就是透过物件封装的方式来达到namespace的效果,也就是***种设计模式。
注意!并没有任何方式可以阻止我们将导出的函式作为命名空间物件使用,我们可以用其来引用其他的function,构造函数,物件。
Express 3.3.2 / 2013-07-03之后已经将exports.version移除了
另外在导出函式的时候***为其命名,如此一来当出错的时候我们比较容易从错误堆叠信息中找到问题点。
下面是两个简单的例子:
- /* bomb1.js */
- module.exports = function(){
- throw new Error('boom')
- }
- module.exports = function bomb(){
- throw new Error('boom')
- }
- $ node
- > bomb = require('./bomb1');
- [Function]
- > bomb()
- Error: boom
- at module.exports(/Users/andyyou/Projects/export_this/bomb1.js:2:9)
- at repl:1:2
- …
- > bomb = require('./bomb2');
- [Function: bomb]
- > bomb()
- Error: boom
- at bomb(/Users/andyyou/Projects/export_this/bomb2.js:2:9)
- at repl:1:2
- …
导出函式还有些比较特别的案例,值得用另外的名称以区分它们的不同。
导出高阶函式
一个高阶函式或functor基本上就是一个函式可以接受一个或多个函式为其输入或输出。而这边我们要谈论的后者-一个函式回传函式
当我们想要模块能够根据输入控制回传函式的行为时,导出一个高阶函式就是一种非常实用的设计模式。
补充:functor & monad
举例来说Connect就提供了许多可挂载的功能给网页框架。
这里的middleware我们先理解成一个有三个参数(req,res,next)的function。
Express从v4.x版之后不再相依于connect
connect middleware惯例就是导出的function执行后,要回传一个middleware function。
在处理request的过程中这个回传的middleware function就可以接手使用刚刚提到的三个参数,用来在过程中做一些处理或设定。
同时因为闭包的特性这些设定在整个中间件的处理流程中都是有效的。
举例来说,compression这个middleware就可以在处理responsive过程中协助压缩
- var connect = require('connect')
- var app = connect()
- // gzip outgoing responses
- var compression = require('compression')
- app.use(compression())
而它的原始码看起来就如下
- module.exports = compression
- …
- function compression(options){
- …
- return function compression(req,res,next){
- …
- next()
- }
- }
于是每一个request都会经过compression middleware处理,而代入的options也因为闭包的关系会被保留下来
这是一种***弹性的模块作法,也可能在您的开发项目上帮上许多忙。
middleware在这里您可以大略想成串连执行一系列的function,自然其Function Signature要一致
导出构造函数
在一般面向对象语言中,constructor构造函数指的是一小段代码协助我们从类别Class建立一个物件。
- // C#
- class Car {
- // c#构造函数
- // constructor即class中用来初始化物件的method。
- public Car(name){
- name = name;
- }
- }
- var car = new Car('BMW');
由于在ES2015之前Javascript并不支持类别,某种程度上在Javascript之中我们可以把任何一个function当作类别,或者说一个function可以当作function执行或者搭配new关键字当作constructor来使用。如果想知道更详细的介绍可以阅读MDN教学。
欲导出构造函数,我们需要透过构造函式来定义类别,然后透过new来建立物件实例。
- function Person(name){
- this.name = name
- }
- Person.prototype.greet = function(){
- return 'Hi,I am ' + this.name
- }
- var person = new Person('andyyou')
- console.log(person.greet())// Hi,I am andyyou
在这种设计模式底下,我们通常会将每个档案设计成一个类别,然后导出构造函数。这使得我们的项目构架更加清楚。
- var Person = require('./person')
- var person = new Person()
整个档案看起来会如下
- /* person.js */
- function Person(name){
- this.name = name
- }
- Person.prototype.greet = function(){
- return 'Hi,I am ' + this.name
- }
- exports = module.exports = Person
导出单一物件实例Signleton
当我们需要所有的模块使用者共享物件的状态与行为时,就需要导出单一物件实例。
Mongoose是一个ODM(Object-Document Mapper)函式库,让我们可以使用程序中的Model物件去操作MongoDB。
- var mongoose = require('mongoose')
- mongoose.connect('mongodb://localhost/test')
- var Cat = mongoose.model('Cat',{name: String})
- var kitty = new Cat({name: 'Zildjian'})
- kitty.save(function(err){
- if(err)
- throw Error('save failed')
- console.log('meow')
- })
那我们require取得的mongoose物件是什么东西呢?事实上mongoose模块的内部是这么处理的
- function Mongoose(){
- …
- }
- module.exports = exports = new Mongoose()
因为require的缓存了module.exports的值,于是所有reqire('mongoose')将会回传相同的物件实例,之后在整个应用程序之中使用的都会是同一个物件。
Mongoose使用面向对象的设计模式来封装,解耦(分离功能之间的相依性),维护状态使整体具备可读性,同时透过导出一个Mongoose Class的物件给使用者,让我们可以简单的存取使用。
如果我们有需要,它也可以建立其他的物件实例来作为命名空间使用。实际上Mongoose内部提供了存取构造函数的方法
- Mongoose.prototype.Mongoose = Mongoose
因此我们可以这么做
- var mongoose = require('mongoose')
- var Mongoose = mongoose.Mongoose
- var anotherMongoose = new Mongoose()
- anotherMongoose.connect('mongodb://localhost/test')
扩展全局物件
一个被汇入的模块不只限于单纯取得其导出的数据。它也可以用来修改全局物件或回传全局物件,自然也能定义新的全局物件。而在这边的全局物件(Global objects)或称为标准内置物件像是Object,Function,Array指的是在全局能存取到的物件们,而不是当Javascript开始执行时所产生代表global scope的global object。
当我们需要扩增或修改全局物件预设行为时就需要使用这种设计模式。当然这样的方式是有争议,您必须谨慎使用,特别是在开放原始码的项目上。
例如:Should.js是一个常被用在单元测试中用来判断分析值是否正确的函式库。
- require('should')
- var user = {
- name: 'andyyou'
- }
- user.name.should.equal('andyyou')
这样您是否比较清楚了,should.js增加了底层的Object的功能,加入了一个非列举型的属性 should,让我们可以用简洁的语法来撰写单元测试。
而在内部should.js做了这样的事情
- var should = function(obj){
- return new Assertion(util.isWrapperType(obj)?obj.valueOf():obj)
- }
- …
- exports = module.exports = should
- Object.defineProperty(Object.prototype,'should',{
- set: function(){},
- get: function(){
- return should(this);
- },
- configurable: true
- });
就算看到这边您肯定跟我一样有满满的疑惑,全局物件扩展定义跟exprots有啥关联呢?
事实上
- /* whoami.js */
- exports = module.exports = {
- name: 'andyyou'
- }
- Object.defineProperty(Object.prototype,'whoami',{
- set: function(){},
- get: function(){
- return 'I am ' + this.name
- }
- })
- /* app.js */
- var whoami = require('whoami')
- console.log(whoami)// { name: 'andyyou' }
- var obj = { name: 'lena' }
- console.log(obj.whoami)// I am lena
现在我们明白了上面说的修改全局物件的意思了。should.js导出了一个should函式但是它主要的使用方式则是把should加到Object属性上,透过物件本身来呼叫。
套用猴子补丁(Monkey Patch)
在这边所谓的猴子补丁特别指的是在执行时期动态修改一个类别或者模块,通常会这么做是希望补强某的第三方套件的bug或功能。
假设某个模块没有提供您客制化功能的界面,而您又需要这个功能的时候,我们就会实作一个模块来补强既有的模块。
这个设计模式有点类似扩展全局物件,但并非修改全局物件,而是依靠Node模块系统的缓存机制,当其他代码汇入该模块时去补强该模块的实例物件。
预设来说Mongoose会使用小写以及复数的惯例替数据模型命名。例如一个数据模型叫做CreditCard最终我们会得到collection的名称是creditcards。假如我们希望可以换成credit_cards并且其他地方也遵循一样的用法。
下面是我们试着使用猴子补丁的方式来替既有的模块增加功能
- var pluralize = require('pluralize')//处理复数单字的函式库
- var mongoose = require('mongoose')
- var Mongoose = mongoose.Mongoose
- mongoose.Promise = global.Promise // v4.1+ http://mongoosejs.com/docs/promises.html
- var model = Mongoose.prototype.model
- //补丁
- var fn = function(name,schema,collection,skipInit){
- collection = collection || pluralize.plural(name.replace(/([a-z\d])([A-Z])/g,'$1_$2').toLowerCase())
- return model.call(this,name,schema,collection,skipInit)
- }
- Mongoose.prototype.model = fn
- /*实际测试*/
- mongoose.connect('mongodb://localhost/test')
- var CreditCardSchema = new mongoose.Schema({number: String})
- var CreditCardModel = mongoose.model('CreditCard',CreditCardSchema);
- var card = new CreditCardModel({number: '5555444433332222'});
- card.save(function(err){
- if(err){
- console.log(err)
- }
- console.log('success')
- })
您不该轻易使用上面这种方式补丁,这边只是为了说明猴子补丁这种方式,mongoose已经有提供官方的方式设定名称
- var schema = new Schema({..},{ collection: 'your_collection_name' })
当这个模块***次被汇入的时候便会让mongoose重新定义Mongoose.prototype.model并将其设回原本的model的实作。
如此一来所有Mongoose的实例物件都具备新的行为了。注意到这边并没有修改exports所以当我们require的时候得到的是预设的物件
另外如果您想使用上面这种补丁的方式时,记得阅读原始码并注意是否产生冲突。
请善用导出的功能
Node模块系统提供了一个简单的机制来封装功能,使我们能够建立了清楚的界面。希望掌握这七种设计模式提供不同的优缺点能对您有所帮助。
在这边作者并没有彻底的调查所有的方式,一定有其他选项可供选择,这边只有描述几个最常见且不错的方法。
小结
- namespace:导出一个物件包含需要的功能
root module的方式,使用一个根模块导出其他模块
- function:直接将module.exports设为function
Function物件也可以拿来当作命名空间使用
为其命名方便调试
exports = module.exports = something的作法是为了确保参考(Reference)一致
- high-order function:可以透过代入参数控制并回传function。
可协助实作middleware的设计模式
换句话说middleware即一系列相同signature的function串连。一个接一个执行
- constructor:导出类别(function),使用时再new,具备OOP的优点
- singleton:导出单一物件实例,重点在各个档案可以共享物件状态
- global objects:在全局物件作的修改也会一起被导出
- monkey patch:执行时期,利用Node缓存机制在instance加上补丁
笔记
- 一个javascript档案可视为一个模块
- 解决特定问题或需求,功能完整由单一或多个模块组合而成的整体称为套件(package)
- require汇入的模块具有自己的scope
- exports只是module.exports的参考,exports会记录收集属性如果module.exports没有任何属性就把其数据交给module.exports,但如果module.exports已经具备属性的话,那么exports的所有数据都会被忽略。
- 就算exports置于后方仍会被忽略
- Node初始化的顺序
Native Module -> Module
StartNodeInstance()-> CreateEnvironment()-> LoadEnvironment()-> Cached
- Native Module加载机制
检查是否有缓存
->有;直接回传this.exports
->没有;new一个模块物件
cache()
compile()-> NativeModule.wrap()将原始码包进function字串->runInThisContext()建立函式
return NativeModule.exports
- Node的require会cache,也就是说:如果希望模块产生不同的instance时应使用function