Gong Yong的Blog

exports的用法:Node.js模块的接口设计模式

当我刚开始接触node的时候,我很困惑于为什么一个模块中即可以用exports,也可以用module.exports暴露接口,另外javascript的灵活性也导致暴露接口的写法多种多样,假设我们要定义一个输出“hello world”的模块,下面有两种写法来定义这个模块:

//写法1
exports.hello = function(){
    console.log(‘world’);
}

//写法2
var f = {
    hello : function(){
        console.log(‘world’);
    }
}

module.exports = f;

假设我们写的这个模块的文件名为hello.js,执行下面的代码

var h = require(‘hello’);
h.hello();

对于上面的两种写法,执行这段代码后得出的结果是一样的。

理论上这两种写法没有什么区别。如果一个接口只是提供者一个函数,那么这两种写法在实质上也没有什么区别,这就是javascript灵活之所在。不过既然有这么多不同的写法,我们就不免要问在什么情况下这些不同的写法会有不同的含义?或者我们用一种更高大上的语言来描述,对于通过exports或者module.exports来暴露的接口,我们要怎么设计这些接口才是最合适的呢,这也就是这篇文章所要谈论的主题。根据个人经验,我提炼了7种模式:

require、exports和module.exports

在开始介绍上面7种模式之前,有必要先介绍点基础知识。

我们都知道在node的代码中使用require加载模块,在模块中使用exports或者module.exports导出接口,requiremoduleexports都是node的全局对象,我们不需要在模块中定义它们就可以直接使用,不过实际它们都不是全局的,而是模块对象,node的文档中有说明,此外还有__dirname__filename这两个变量也是模块对象,它们的具体作用请参见文档。所谓模块对象就是它们的作用域仅限于当前模块,这就引出了一个问题,既然作用域仅限于当前模块,那又为什么可以直接使用呢?为了回答这个问题,我们先看一个模块的代码,我们编写了一个bar.js的文件,这是一个node模块,然后再use-bar.js中使用这个模块:

 
//bar.js
var bar = function(){
    console.log(‘it is bar’);
};

exports = bar;

//use-bar.js
var bar = require(‘./bar.js’);
bar();  //这个会报错:TypeError: object is not a function

执行上面的use-bar.js会抛出类型错误的错误,这个错误提示对象不是一个函数,但在bar.js中很明显是将exports赋值给了一个函数啊?ok,我们改一下bar.js,改成下面的样子

//bar.js
var bar = function(){
    console.log(‘it is bar’);
};

module.exports = bar;

然后再执行use-bar.js,这个时候会正常执行并输出it is bar。why?

对于这个问题我们先要了解下node是怎么编译javascript模块的(node还可以用c++写模块,这就是另外一个话题了),上面说了requireexportsmodule都是模块对象,但之所以可以在模块中不先声明就可以直接使用,是因为node在编译js模块的时候,将我们所写的代码进行了包装,将整个代码放进了一个函数中,具体是这个样子:

(function(exports,require,module,__filename,__dirname){ var bar = function(){ console.log(‘it is bar’); } module.exports = bar; });

然后在调用模块的时候会传入这些变量,这样我们在编写模块的时候就可以直接使用这些变量(这里我们又看到了__filename__dirname),那为什么使用exports=bar会报错,而使用module.exports=bar又是正确的呢?这是因为exports本身就只是module.exports的引用,而使用require加载模块的时候返回的是module.exportsexports=bar改变了exports的引用,所以最终返回的module.exports只是一个空对象,所以会报TypeError的错误。

require还有一个重要的行为就是缓存加载的模块,我们在上面的globals的文档中可以看到一个require.cache的对象,这个就是用于缓存加载的模块的对象,node是根据模块的绝对路径进行模块加载的,我们在REPL上看看:

$ node
> f1 = require(‘/Users/lscm/node/test/function’);
[Function]
> f2 = require(‘./function’);
[Function]
> f1 === f2
true
> f1() === f2()
false

我们可以看到require返回的实例是一样的,但如果调用f1()f2()返回的也是对象的话(如果返回的不是对象,例如是字符串或者int型的值,f1()===f2()还是会返回true),它们返回的值不一样,这是因为第二次调用require返回的对象是第一次调用require返回的同一个对象,第一次调用的时候将它缓存起来了。

我们可以在node提供的文档中了解到更多细节,在此就不在深究了,下面我们开始接口设计模式的旅程吧。

exports命名空间

node中没有命名空间的概念,模块和包就是组织代码的唯一方式,另外根据我们上面介绍的node编译javascript模块的方式,它本身就会将模块中不用暴露出来的变量限制在当前模块的作用域中,这就实现了避免变量污染的作用,但通过exports我们可以实现类似命名空间的东西。我们先看下node的文件系统核心模块,下面这段代码使用了文件系统模块。

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);
});

这段代码先使用require(‘fs’)加载fs模块,并将其赋值给变量fs,然后将fs模块中的readFileReadStream两个变量赋值给readFileReadStream两个本地变量,我们可以将fs理解成一个命名空间,而其中的readFileReadStream这两个变量则是fs这个命名空间下的变量。我们再看看node的fs核心模块是怎么做的:

var fs = exports;

它首先将exports的引用赋值给一个本地变量fs,这样fs这个本地变量就跟exports都引用同一个对象,也就是module.exports,所以fs这个对象的所有成员变量和成员函数最终都是module.exports的成员变量和成员函数,这些成员变量和成员函数都会暴露出来,通过require加载这个模块后就可以直接使用。

fs.readFile = function(path, options, callback_) {
  // ...
};

这里导出了readFile这个函数

fs.ReadStream = ReadStream;

function ReadStream(path, options) {
  // ...
}
ReadStream.prototype.open = function() {
  // ...
}

ReadStream是一个构造函数,也是通过赋值给fs导出。

如果要用exports导出一个命名空间,既可以像fs模块这样,将exports赋值给fs,也可以通过将一个新对象赋值给module.exports实现。

module.exports = {
  version: '1.0',

  doSomething: function() {
    //...
  }
}

还有一个通常用于暴露命名空间的方式,就是将命名空间作为一个根模块,然后在这个根模块中载入很多子模块,再将这些子模块加入到命名空间中。这个方式可以用于模型(model)的设计,我们先定义一个models的模块,这里包括所有子模块,每个子模块就是一个特定的模型,假设这个我们有用户(User)、用户信息(UserProfile)、产品(Product)等几个具体的模型,那么使用require引入models这个模块后,就可以通过models这个模块使用这些模型,代码如下:

var models = require('./models'),
    User = models.User,
    Product = models.Product;

models模块的index.js可能是下面这样:

exports.User = require('./user');
exports.Person = require('./person');

实际工程中,如果这些模型的模块文件都在同一个目录下面,我们可以使用一条语句就可以全部加载进行并赋值给module.exports进行暴露。

module.exports = require('../lib/require_siblings')(__filename);

exports一个工厂方法

另外一种模式就是通过exports暴露一个函数,这个函数是一个工厂方法,调用这个工厂方法后会创建一个对象,这个对象会用于完成我们的工作,express就是这么干的。

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的话,相信你会都很熟悉,也会很陌生上面的代码,说熟悉是因为我们用express的话都会使用这段代码,说模式是因为我们基本上这段代码都是用工具直接生成的,或者是直接copy的,很少会自己写,至少我通常都是这么干的。这个段代码中require(‘express’)会返回一个工厂方法,调用这个方法就会创建express的Application对象。

这个模式实际是暴露了一个函数,在使用exports暴露函数的时候,我们建议对这个函数命名,这样在抛出错误的时候会在错误栈中输出这个函数名称,我们看下下面两个例子:

// bomb1.js
module.exports = function () {
  throw new Error('boom');
};

// bomb2.js
module.exports = function bomb() {
  throw new Error('boom');
};

我们现在在REPL中分别引入这两个模块,然后执行:

$ 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
    ...

我们可以看到第一个调用第一个模块,输出的错误信息是at module.exports (/Users/alon/Projects/export_this/bomb1.js:2:9),而第二个模块被调用的时候输出at bomb (/Users/alon/Projects/export_this/bomb2.js:2:9)

exports一个偏函数

首先解释下什么是偏函数,深入浅出nodejs中对此做了很好的定义,而且也举了一个很经典的例子,我们先看这个例子:

var toString = Object.prototype.toString;

var isString = function(obj) {
    return toString.call(obj) == ‘[object String]’;
}

var isFunction = function(obj){
    return toString.call(obj) == ‘[object Function]’;
}

上面这段代码中的isStringisFunction这两个函数分别用于判断变量的类型是字符串和函数,对于这种函数,如果要添加其他类型的判断,就需要写更多的函数,但是因为这些函数都有一些共同的特征,所以只需要写一个函数,然后让这个函数返回判断某个类型的函数就可以了,代码如下:

var isType = function(type){
    return function(obj){
        return toString.call(obj) == ‘[object ‘ + type + ’]’; 
}; }; var isString = isType(‘String’); var isFunction = isType(‘Function’);

isStringisFunction这种类型的函数很容易创建,这里通过指定参数返回一个新定制函数的形式就叫做偏函数,简单点说偏函数就是返回函数的函数,这种模式在node的模块接口设置中很有价值。

用过express的人都应该知道中间件这个东西,express用的是Connect的中间件。Connect提供了一套中间件用于Web开发,中间件就是一个函数,这个函数会有三个参数——(req,res,next),我们看下Express是怎么使用Connect的query中间件的。

var connect = require('connect'),
    query = require('connect/lib/middleware/query');

var app = connect();
app.use(query({maxKeys: 100}));

调用query函数会返回一个参数为(req,res,next)的函数,app.use就可以使用这个中间件,query函数可以根据传入的参数返回不同的中间件,我们看下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的参数,并且会返回一个query(req,res,next)的函数,Express使用这个中间件就是实现将url中的查询字符串转换为一个query对象,并且赋值给req,我们通过req.query访问查询字符串的值。

这种暴露接口的模式非常灵活有效,会给我们的工作带来很多便利。

exports构造函数

先看下javascript中怎么定义构造函数,以及怎么通过new关键字来创建新对象。

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

第一个Person函数就是Person这个类的构造函数,跟普通的函数定义没什么差别,然后再Personprototype上定义了一个greet函数,再使用new Person('')来创建Person类的对象,这个对象就可以访问greet方法,这就是javascript里面的构造函数的定义和使用,既然构造函数是一个普通函数,那么我们也直接在模块中使用exports暴露这个函数。

function Person(name) {
  this.name = name;
}

Person.prototype.greet = function() {
  return "Hi, I'm " + this.name;
};

module.exports = Person;

然后商用上面的模块。

var Person = require('./person');

var person = new Person('Jane');

通过暴露构造函数,我们可以创建多个对象,这种方式在node的模块设计中非常常见。

exports一个单例

有时候我们希望通过require()加载的模块能够在所有调用地方共享这个模块的状态和行为,这就是单例对象的应用模式。

我们来看看Mongoose这个模块,Mongoose是一个对象文档映射库(Object-Document Mapping),从名字可以看出这个库是跟MongoDB有关的,它可以用于为存储在MongoDB中的数据创建富领域模型(Rich domain model)。

如果我们要使用它,必须先建立数据库连接,一般的web应用都是在程序启动的时候建立数据库连接,假设我们是在Express搭建一个Web应用,我们可以在入口文件app.js中先用Mongoose建立数据库连接,代码如下:

var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/test');

然后我们在cat.js这个文件中要使用mongoDB的数据,代码可以这么写:

var mongoose = require('mongoose');

var Cat = mongoose.model('Cat', { name: String });

var kitty = new Cat({ name: 'Zildjian' });
kitty.save(function (err) {
  if (err) // ...
  console.log('meow');
});

我们可以看到这里没有再连接数据库了。这说明在app.js中加载mongoose模块,并且使用这个模块连接数据库,然后再在cat.jsrequire这个模块的时候,这个模块对于mongoDB数据库还是处于连接状态,这就是一种单例对象。这是怎么实现的呢?我们先看下mongoose这个模块是怎么使用exports导出的:

function Mongoose() {
  //...
}

module.exports = exports = new Mongoose();

exports是一个对象,这个对象是Mongoose的实例。还记得前面我们说过的node的模块缓存机制么?每次require某个模块的时候,node都会将这个require返回的对象缓存起来,缓存的key就是这个模块的绝对路径,所以因为mongoose这个模块exports的是Mongoose的实例,所以每次require这个模块的时候都是返回同一个对象,这样就实现了在所有使用mongoose模块的地方都使用同一个对象,这就是单例模式的经典应用。

mongoose这个模块还是提供了命名空间的应用模式

mongoose的源代码中:

Mongoose.prototype.Mongoose = Mongoose;

所以我们可以在代码中直接创建mongoose的实例:

var mongoose = require('mongoose'),
    Mongoose = mongoose.Mongoose;

var myMongoose = new Mongoose();
myMongoose.connect('mongodb://localhost/test');

exports全局对象

在node的模块中,不仅仅只是可以exports一个值,还可以修改全局对象。当你需要扩展全局对象,或者是修改全局对象的行为时,可以使用这种模式。通常而言,我们并不建议扩展或者修改全局对象,但是对于一些特殊情况或者应用场景,使用这种模式还是很有价值的。

Should.js就是使用这种模式的典型,它是一个用于单元测试的断言库,我们一般会这么使用它:

require('should');

var user = {
    name: 'Jane'
};

user.name.should.equal('Jane');

Should.js通过扩展全局对象,为其添加了一个non-enumerable的属性——should,这让编写单元测试非常清晰方便,我们看看它是怎么实现的:

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作为一个函数导出,这么做是为了实现通过调用should函数也可以实现单元测试的作用,而后面的部分就是扩展全局对象Object

实现Monkey Patch

什么是Monkey Patch呢?直译出来是猴子补丁,它的意思是:在运行时动态修改某个类或者模块,多用于给第三方代码打补丁,一般用于修改第三方代码的bug,或者是添加一些没有的功能,至于为什么要用这个名字,我也不知道为什么,有兴趣的可以查看wiki上的定义。我们可以定义一个模块用于给一个已存在的模块打补丁,特别是当这个已存在的模块并未提供接口定制它的行为。这个模式实际上是上一个模式的变体,

我们还是来看下mongoose这个模块,默认情况mongoose这个模块会将model的名称转换为小写和复数的形式作为MongoDB的collection的名称,例如如果我们将模块的名称命名为CreditCardAccountEntry,那么它对应的collection的名称就是creditcardaccountentries,但实际上这个名称非常难以阅读,通常我更喜欢使用credit_card_account_entries,而且我希望这能够作为一种通用模式。

这里我只有给mongoose.model打补丁,代码如下:

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;

当这个模块第一次被加载的时候,它会加载mongoose,重新定义Mongoose.prototype.model,这里使用了代理模式,最终这个新的model方法也会使用原来的model方式来实现对应的功能。现在所有Mongoose的实例都有这个新的行为。注意,这里没有给exports赋值,所以使用require加载这个模块是时候返回是空对象,这也说exports所表示的默认值。

这里有一点需要注意的,当你要采用这种模式来改变第三方模块的行为的时候,最好是采用这里所用的方式,采用代理模式,尽可能用第三方模块提供的默认行为了完成你的行为,这可以保证在第三方模块更新后还可以继续使用更新后的功能。

结语

这篇文章中7种模式只是我个人总结的几种exports模块的策略,方便我们设计node的模块,当然肯定还有其他不同的模式,欢迎大家提供更多更好的模式。