英文原文: Export This: Interface Design Patterns for Node.js Modules
當(dāng)你在Node中require一個(gè)模塊時(shí),你從返回的結(jié)果中得到了什么?當(dāng)你編寫一個(gè)Node模塊時(shí),在設(shè)計(jì)模塊的接口時(shí)你有哪些選擇?
今天我們將討論七種Node.js模塊接口設(shè)計(jì)模式,在實(shí)際工作中,它們經(jīng)常會被混合起來使用:
首先我們來回顧一下基礎(chǔ)。
在Node中,require
一個(gè)文件實(shí)際上實(shí)在require
這個(gè)文件定義的模塊。所有的模塊都擁有一個(gè)對隱式module
對象的引用,當(dāng)你調(diào)用require
時(shí)實(shí)際上返回的是module.exports
屬性。對于module.exports
的引用同樣也能寫成exports
。
在每一個(gè)模塊的第一行都隱式的包含了一行下面的代碼:
var exports = module.exports = {};
注意:如果你想要導(dǎo)出一個(gè)函數(shù),你需要將這個(gè)函數(shù)賦值給module.exports
。將一個(gè)函數(shù)賦值給exoports
將會為exports
引用重新賦值,但是module.exports
依然會指向原始的空對象。
因此我們可以像這樣來定義一個(gè)function.js
模塊來導(dǎo)出一個(gè)對象:
module.exports = function(){
return {name: 'Jane'};
}
然后在另一個(gè)文件中require這個(gè)模塊:
var fund = require('./function');
require
的一個(gè)重要行為就是它緩存了module.exports
的值并且在未來再次調(diào)用require
時(shí)返回同樣的值。它依據(jù)被require
文件的絕對路徑來進(jìn)行緩存。因此如果你想要你的模塊返回不同的值,你應(yīng)該導(dǎo)出一個(gè)在再次調(diào)用時(shí)能返回不同值的函數(shù)。
為了證明這點(diǎn),我們在Node REPL中進(jìn)行一些操作:
$ node
> f1 = require('/Users/alon/Projects/export_this/function');
[Function]
> f2 = require('./function'); //同樣的位置
> f1 === f2
true
> f1() === f2()
false
在上面的例子中,你可以看到require
返回了同一個(gè)函數(shù)實(shí)例但是由函數(shù)調(diào)用返回的對象是完全不同的。
更詳細(xì)的信息你可以參看Node模塊系統(tǒng)的文檔。
現(xiàn)在我們開始正式進(jìn)入接口設(shè)計(jì)模式。
一個(gè)簡單而常用的模式是導(dǎo)出一個(gè)擁有若干屬性的對象,這些屬性主要是函數(shù)但是不限于函數(shù)。這種方式允許代碼通過require
一個(gè)模塊在一個(gè)命名空間下獲取一組相關(guān)聯(lián)的功能。
當(dāng)你require
了一個(gè)導(dǎo)出命名空間的模塊,你通常會把整個(gè)命名空間賦值給一個(gè)變量來使用它的成員,或者將它的成員直接賦值給本地變量:
var fs = require('fs'),
readFile = fs.readFile,
ReadStream = fs.ReadStream;
readFile('./file.txt', function(err, data) {
console.log("readFile contents: '%s'", data);
});
new ReadStream('./file.txt').on('data', function(data) {
console.log("ReadStream contents: '%s'", data);
});
下面是fs核心模塊中的一行代碼:
var fs = exports;
它首先將本地變量fs賦值為隱式導(dǎo)出對象exports
然后將函數(shù)引用賦值為fs的屬性。因?yàn)閒s指向exports
并且exports
是當(dāng)你調(diào)用require(’fs’)時(shí)返回的對象,因此任何賦值給fs的東西在你通過require
獲取的對象中都可用。
fs.readFile = function(path,options,callback_){
//...
}
任何東西都是一個(gè)公平的游戲。它接下來會導(dǎo)出一個(gè)構(gòu)造函數(shù):
fs.ReadStream = ReadStream;
function ReadStream(path,options){
//...
}
ReadStream.prototype.open = function(){
//...
}
當(dāng)導(dǎo)出一個(gè)命名空間時(shí),你可以將屬性賦值于給exports
或者fs模塊,或者將一個(gè)新對象復(fù)制給module.exports
。
module.exports = {
version: '1.0',
doSomething: function() {
//...
}
};
導(dǎo)出一個(gè)命名空間的普遍用法是導(dǎo)出其他模塊的根對象以便一次require
就能夠獲取若干個(gè)模塊。在我之前的項(xiàng)目Good Eggs中,我們將每個(gè)分開的子模塊都出了一個(gè)模型構(gòu)造函數(shù)并且接著編寫了一個(gè)能導(dǎo)出所有模型的index文件。這允許我們可以在一個(gè)models
命名空間下獲取所有的model
。
var models = require('./models'),
User = models.User,
Product = models.Product;
對于CoffeeScript用戶,析構(gòu)賦值(restructuring assignment)使得這個(gè)工作更加輕松了:
{User, Product} = require './models'
index.js
文件看起來是這樣的:
exports.User = require('./user');
exports.Person = require('./person');
事實(shí)上,我們使用一個(gè)小巧的庫來require所有的子文件并且將它們使用駝峰命名法導(dǎo)出以便index.js
文件實(shí)際上能夠讀取下面內(nèi)容:
module.exports = require('../lib/require_siblings')(__filename);
另一個(gè)模式是導(dǎo)出一個(gè)函數(shù)作為一個(gè)模塊的接口。一個(gè)普遍的用法是導(dǎo)出一個(gè)在調(diào)用時(shí)能返回一個(gè)兌現(xiàn)高的工廠函數(shù)。在使用Express.js
時(shí)我們這樣編寫代碼:
var express = require('express');
var app = express();
app.get('/hello', function (req, res) {
res.send "Hi there! We're using Express v" + express.version;
});
由Express導(dǎo)出的這個(gè)函數(shù)被用來創(chuàng)建一個(gè)新的Express應(yīng)用。在你自己使用這種模式時(shí),你的工廠函數(shù)可能需要接收一些參數(shù)來配置或者初始化返回的對象。
為了導(dǎo)出一個(gè)函數(shù),你需要將你的函數(shù)賦值給module.exports
。
exports = module.exports = createApplication;
...
function createApplication () {
...
}
上面的例子將createApplication
函數(shù)賦值給了module.exports
然后賦值給隱式的exports
變量?,F(xiàn)在exports
就是模塊導(dǎo)出的函數(shù)。
Express中同樣將這個(gè)導(dǎo)出的函數(shù)作為命令空間來使用。
exports.version = '3.1.1';
要注意的一點(diǎn)是沒有什么阻止我們將導(dǎo)出的函數(shù)作為命令空間使用,它能夠暴露出對于其他函數(shù)、構(gòu)造函數(shù)或者對象的引用。
當(dāng)導(dǎo)出一個(gè)函數(shù)時(shí),最佳實(shí)踐是位這個(gè)函數(shù)命名以便它能在棧追蹤中出現(xiàn)。注意到下面兩個(gè)例子的的棧追蹤的不同之處:
// bomb1.js
module.exports = function () {
throw new Error('boom');
};
// bomb2.js
module.exports = function bomb() {
throw new Error('boom');
};
$ node
> bomb = require('./bomb1');
[Function]
> bomb()
Error: boom
at module.exports (/Users/alon/Projects/export_this/bomb1.js:2:9)
at repl:1:2
...
> bomb = require('./bomb2');
[Function: bomb]
> bomb()
Error: boom
at bomb (/Users/alon/Projects/export_this/bomb2.js:2:9)
at repl:1:2
...
在導(dǎo)出一個(gè)函數(shù)的情形中有許多值得特別說明的點(diǎn)。
一個(gè)高階函數(shù),或者函子(functor),是一個(gè)接收一個(gè)或多個(gè)函數(shù)作為輸入或者輸出的函數(shù)。我們將討論后面一種情形 – 即一個(gè)返回函數(shù)的函數(shù)。
當(dāng)你想要從你的模塊返回一個(gè)函數(shù)但是需要獲取控制函數(shù)行為的輸入時(shí),導(dǎo)出一個(gè)高階函數(shù)是一個(gè)非常有用的模式。
Connect中間件提供了許多對于Express和其他web框架的插件功能。一個(gè)中間件就是一個(gè)接收三個(gè)參數(shù) – (req
,res
,next)
– 的函數(shù)。這樣的用法在connect中間件中是為了導(dǎo)出一個(gè)在調(diào)用時(shí)返回一個(gè)中間件函數(shù)的函數(shù)。這允許導(dǎo)出的函數(shù)接收能夠被用于配置中間件以及在中間件的閉包作用域中可用的變量,當(dāng)它在處理一個(gè)請求時(shí)。
例如,connect中的query
中間件在Express中被喲關(guān)于解析查詢字符串變量。
var connect = require('connect'),
query = require('connect/lib/middleware/query');
var app = connect();
app.use(query({maxKeys: 100}));
query模塊的源代碼如下所示:
var qs = require('qs'),
parse = require('../utils').parseUrl;
module.exports = function query(options) {
return function query(req, res, next) {
if (!req.query) {
req.query = ~req.url.indexOf('?') ? qs.parse(parse(req).query, options) : {};
}
next();
};
};
對于每一個(gè)通過query中間件的請求,在整個(gè)閉包作用域中都可用的options
參數(shù)將單獨(dú)傳遞給Node的核心模塊qs模塊。
這個(gè)設(shè)計(jì)模式是你在工作中非常常用且非常靈活的一個(gè)模式。
我們在JavaScript以構(gòu)造函數(shù)的方式定義類并且使用new
關(guān)鍵字創(chuàng)建類的實(shí)例。
function Person(name) {
this.name = name;
}
Person.prototype.greet = function() {
return "Hi, I'm Jane.";
};
var person = new Person('Jane');
console.log(person.greet()); // prints: Hi, I'm Jane
這種設(shè)計(jì)模式實(shí)現(xiàn)了一個(gè)文件一個(gè)類并且使得你的項(xiàng)目組織結(jié)構(gòu)更加清晰,使得其他的開發(fā)者能夠輕松發(fā)現(xiàn)類的實(shí)現(xiàn)方式。
var Person = require('./person');
var person = new Person('Jane');
實(shí)現(xiàn)的方式如下所示:
function Person(name) {
this.name = name;
}
Person.prototype.greet = function() {
return "Hi, I'm " + this.name;
};
module.exports = Person;
當(dāng)你想要你的模塊的所有用戶來分享一個(gè)類的實(shí)例的狀態(tài)和行為時(shí)你需要導(dǎo)出一個(gè)單體。
Mongoose是一個(gè)對象-文檔映射庫,它被用來創(chuàng)建永久保存在MongoDB中的富結(jié)構(gòu)域?qū)ο蟆?/p>
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) {
// ...
}
console.log('meow');
});
當(dāng)我們r(jià)equire Mongoose時(shí)返回的mongoose對象是什么?在內(nèi)部,mongoose模塊是這樣的:
function Mongoose() {
//...
}
module.exports = exports = new Mongoose();
由于require緩存了所有賦值給module.exports
的值,所有對于require('mongoose')
的調(diào)用那個(gè)將會返回同一個(gè)實(shí)例以確保它在我們的應(yīng)用中是一個(gè)單體。Mongoose使用面向?qū)ο笤O(shè)計(jì)模式來壓縮及解耦功能,保持狀態(tài)并且支持可讀性與可理解行,但是通過創(chuàng)建并導(dǎo)出Mongoose類的一個(gè)實(shí)例來創(chuàng)建一個(gè)面向用戶的簡單接口。
如果用戶需要,Mongoose也會將這個(gè)單體實(shí)例作為命名空間來使用以確保其他的構(gòu)造函數(shù)也可以使用,其中包括Mongoose構(gòu)造函數(shù)本身。你可能需要使用Mongoose構(gòu)造器函數(shù)來創(chuàng)建連接到其他MongoDB數(shù)據(jù)庫的實(shí)例。
在內(nèi)部,Mongoose是這樣做的:
Mongoose.prototype.Mongoose = Mongoose;
因此你可以這樣做:
var mongoose = require('mongoose'),
Mongoose = mongoose.Mongoose;
var myMongoose = new Mongoose();
myMongoose.connect('mongodb://localhost/test');
一個(gè)被require的模塊能做的不僅僅是導(dǎo)出一個(gè)值。他可能夠修改全局對象或者require其他模塊時(shí)返回的對象。它可以定義一個(gè)新的全局對象。它可以只擴(kuò)展一個(gè)對象或者在擴(kuò)展一個(gè)全局對象的基礎(chǔ)上導(dǎo)出一些有用的東西。
當(dāng)你需要在你的對象中擴(kuò)展或者改變?nèi)謱ο蟮男袨闀r(shí)你需要使用這個(gè)模式。雖然飽含爭議并且應(yīng)該謹(jǐn)慎使用(尤其是在開源項(xiàng)目中),該模式確是一個(gè)必不可少的模式。
Should.js
是一個(gè)在單元測試中使用的斷言庫。
require('should');
var user = {
name: 'Jane'
};
user.name.should.equal('Jane');
Should.js通過在對象中擴(kuò)展了一個(gè)不可枚舉的屬性should
來為單元測試中編寫斷言提供一個(gè)清晰的語法。在內(nèi)部,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
});
注意到Should.js
導(dǎo)出了一個(gè)should
函數(shù),它的主要用途是為Object
添加should
屬性。
這里說的猴子補(bǔ)丁指的是在運(yùn)行過程中對類或者模塊進(jìn)行動態(tài)的修改,目的是為了給第三方代碼添加一個(gè)補(bǔ)丁。
當(dāng)一個(gè)存在的模塊沒有提供你需要的功能時(shí)你可以實(shí)現(xiàn)一個(gè)模塊作為它的補(bǔ)丁。這個(gè)模式是前面一個(gè)模式的變形。它并不是像上一個(gè)模式一樣對全局對象進(jìn)行修改,而是依靠Node模塊系統(tǒng)的緩存行為對一個(gè)模塊的同一個(gè)實(shí)例添加補(bǔ)丁以便當(dāng)該模塊被其他代碼require時(shí)仍然能返回修改過的對象。
默認(rèn)情形下Mongoose會將MongoDB的集合以小寫和復(fù)數(shù)來命名。對于一個(gè)叫做CreditCardAccountEntry
的集合最終存儲在MongoDB中的名字叫做creditcardaccountentries
。但是我想要它的名字為credit_card_account_entries
并且我想要全局使用這種行為。
下面是一個(gè)針對mongoose.model的補(bǔ)丁模塊:
var Mongoose = require('mongoose').Mongoose;
var _ = require('underscore');
var model = Mongoose.prototype.model;
var modelWithUnderScoreCollectionName = function(name, schema, collection, skipInit) {
collection = collection || _(name).chain().underscore().pluralize().value();
model.call(this, name, schema, collection, skipInit);
};
Mongoose.prototype.model = modelWithUnderScoreCollectionName;
當(dāng)這個(gè)模塊第一次被require時(shí),它require了mongoose,重定義了Mongoose.prototype.model并且將它代理返回原生的model?,F(xiàn)在所有Mongoose的實(shí)例都將擁有新的欣行為。注意到它并沒有修改exports因此通過require返回的值還是默認(rèn)的空exports對象。
另外,如果你選擇對已有模塊運(yùn)用一個(gè)猴子補(bǔ)丁,最好使用上面例子中的鏈?zhǔn)郊记?。你在猴子補(bǔ)丁中添加你的行為然后這些行為將會代理回到原生的實(shí)現(xiàn)方式。雖然這種方式并不是很簡單,但是它是對于第三方代碼最好的添加補(bǔ)丁的方式,它允許你利用未來庫的升級并且將你的補(bǔ)丁和其他補(bǔ)丁的沖突降低到最小。
Node模塊系統(tǒng)對于封裝功能以及創(chuàng)建清晰的接口提供了一種非常簡單的機(jī)制。希望上面提到的幾種設(shè)計(jì)模式對于你有所幫助。
更多建議: