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