/ JavaScript

Export This: Node.js模組的界面設計模式

This article is transalted in Traditional Chinese from Export This: Interface Design Patterns for Node.js Modules by Alon Salant.

翻譯文章純屬個人方便記憶用。


當你在 Node 中 require 一個模組,你會得到什麼?當你撰寫一個模組時,你有哪些選擇來設計你的界面?

當我第一次學著用 Node 來工作時,我發現幾種全然不同的方式。JavaScript 非常有彈性,並且開放原始碼社群中的開發者,對於同樣的事情上也有不同的實作風格。

我的 Node 旅途中,一直不斷尋找做對事情的好方法,並且將它們套用到我自己以及在 Good Eggs 工作中。

這篇文章我將會分享我對 Node 模組系統中的觀察,並且告訴你幾種方式,你可以用來封裝並分享你的程式碼。我的目標是識別及描述模組界面設計中有用的樣式,並且幫助你了解什麼時候及如何在工作中使用它們。

我會討論下列七種樣式,大多數可以組合使用。分別是:

  • 命名空間(Namespace)
  • 函式(Function)
  • 較高次方函式(Higher Order Function)
  • 建構子(Constructor)
  • 獨體模式(Singleton)
  • 全域物件(Global Object)
  • 猴子補丁(Monkey Patch)

require、exports 及 module.exports

首先,說明一些基礎的。

在 Node 中,引入一個檔案是在引入這個檔案內所定義的模組。當你 require 時,所有的模組都有一個參考(reference)在一個隱含 module 物件的屬性 module.exports 之中。存在於 module.exports 中的參考同樣也存在 exports 之中。

這看似每個模組的開頭都有:

var exports = module.exports = {};

如果你想要匯出一個函式,你必須將它指定到 modules.exports
只指定函式到 exports 只會重新指定(reassign)exports 的參考,module.exports 仍然是指向原始的空物件。

所以,我們可以定義一個模組 function.js 來匯出一個函式:

module.exports = function () {
  return {name: 'Jane'};
};

並且引入它:

var func = require('./function');

require 的一個很重要的行為是,它會快取指定到 module.exports 的值,並且在往後的 require 中傳回相同的值。它是根據檔案的決定路徑來作快取。所以當你希望你的模組必須能夠回傳不同的值,你必須匯出一個函式能夠被調用並回傳新的值。

使用 Node REPL 來做個示範:

$ node
> f1 = require('/Users/alon/Projects/export_this/function');
[Function]
> f2 = require('./function'); // Same location
[Function]
> f1 === f2
true
> f1() === f2()
false

你可以看到 require 回傳了同樣的函式實例(instance),但每一次調用函式所回傳的物件並不相同。

更多有關 Node 模組系統,核心文件提供很詳細的說明,值得你去閱讀。

接下來是界面樣式。

命名空間(Namespace)

一種簡單並常見的樣式,是匯出一個物件(object),裡面包含數個屬性(property),這些屬性主要是函式,但不限定一定只有函式。這允許程式碼在引入模組時候,引入單一命名空間下,有關的功能性集合。

當你引入了一個匯出命名空間的模組,你通常會將整個命名空間指定到某個變數,使用變數的成員來參考函式,或者將變數的成員直接指定到區域變數:

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區域變數指定到隱含的exports物件,接著指定到fs屬性的函式參考。因為fs參考exports,而當你呼叫require('fs')時回傳的是個物件,所以exports是個物件。你可以使用引入模組的物件所有屬性。

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

所有都是公平。匯出成建構子(constructor):

fs.ReadStream = ReadStream;

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

當你匯出一個命名空間,你可以指定多個屬性到exports,如同上述fs模組,或者指定一個新的物件到module.exports

module.exports = {
  version: '1.0',

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

一種常見的匯出命名空間使用方法是匯出一個模組的根(root),因此那一行require的陳述給予你一個呼叫者,能夠存取數個其他模組。在Good Eggs裡,我們分別在不同模組裡面實作我們的不同領域模型(domain model),這些模型匯出建構子(請見下面「匯出成建構子」章節),接著用目錄內的索引檔來匯出所有模型。這讓我們可以去拉這些模型,放在一個模型命名空間裡。

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

給CoffeeScript使用者,解構指派可以讓它更乾淨。

{User, Product} = require './models'

index.js看起來像是:

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

In reality, we use a small library that requires all sibling files and exports their modules with CamelCase names so the index.js file in our models directory actually reads:

事實上,我們用一個小函式庫來引入所有用的兄弟姊妹檔案,並將這些檔案用駝峰命名(CamelCase)法加以匯出,所以我們模型目錄內的index.js實際上是讀取:

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

(譯註:檔案名與建構子(函式)一致,都用CamelCase命名,引入的時候就用require('檔案')(__filename)方式,用檔案名(__filename)來當作屬性名稱匯入。)

匯出成函式(Function)

另一個樣式是匯出成函式作為模組的界面。一種常用方式是匯出一個工場函式,當調用這函式時,會回傳物件。當我們使用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匯出的這個函式是用來建立一個新的Express應用程式。你在使用這個樣式時,你的工廠函式可能會使用參數來設定或初始你要回傳的物件。

匯出一個函式時,你必須指定你的函式到modules.exports

Express作法

exports = module.exports = createApplication;

...

function createApplication () {
  ...
}

它把createApplication函式指定到module.exports及隱含匯出變數exports。現在exports就是模組匯出的exports

Express也使用下列匯出函式作為命名空間:

exports.version = '3.1.1';

注意,這裡並沒有停止我們使用已經匯出的函式作為命名空間,這個命名空間也可以揭露(expose)參考到其他函式、建構子或物件,成為它們自己的命名空間。

當匯出一個函式,有個好的命名函式的實踐方式,讓它可以出現在堆疊追蹤。請注意下列兩個範例會有不一樣的堆疊追蹤:

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

這裡有一對特別的匯出函式案例,值得特別指出其截然不同的樣式。

匯出成較高次方函式(Higher Order Function)

較高次方函式,或者仿函數,是一種函式,將一個或多個函式做為其函式的輸入或輸出。我們談論的正是後者 - 一個回傳函式的函式。

當你希望從你的模組中,回傳一個函式,而且需要拿輸入來控制其函式的行為,匯出一個較高次方函式是很有用的。

Connect middleware提供許多可掛載功能給Express或其他Web框架。Middleware是一種函式,使用三個參數 - (req, res, next)。Connect middleware使用慣例上是當呼叫匯出的函式後傳回middleware函式。這允許匯出的函式可以使用參數,因此可以用來設定middleware,並且在整個閉包在處理請求時,整個範疇中都可以使用。

舉例來說,這裡有個connect的query middleware,由Express內部使用,用來剖析query字串參數,成為一個物件後,透過req.query來使用。

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 middleware處理的請求,通過閉包範疇options參數都會前往到Node核心qs(query字串)模組中。

模組設計中,這是很常見且非常彈性的樣式,也可以在你的工作中可能非常有用。

匯出成建構子(Constructor)

我們用建構子函式來定義類別,使用關鍵字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

這個樣式裡實作成一個檔案一個類別,然後匯出其建構子,讓你清楚的組織專案,也讓其他開發者能夠輕易的找到類別的實作。在Good Eggs,我們在多個檔案中實作多個類別,檔案名稱是用 底線_名稱 命名,將它們指定到駝峰式命名的變數。

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

var person = new Person('Jane');

實作看起來像是:

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

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

module.exports = Person;

匯出成獨體模式(Singleton)

當你希望所有使用你模組的使用者共享狀態及作用在同一個單獨的類別實體時,就可以使用獨體樣式。

Mongoose is an object-document mapping library used to create rich domain models persisted in MongoDB.

Mongoose是一個文件與物件對應的函式庫,用來建立存在於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) // ...
  console.log('meow');
});

當我們引入Mongoose時,我們得到的是什麼樣的mongoose物件?內部地,mongoose模組做了:

function Mongoose() {
  //...
}

module.exports = exports = new Mongoose();

因為module.exports快取了指定到它的值,所有呼叫require('mogoose')都會回傳相同的實例,確保它在我們的應用程式裡是一個獨立個體。Mongoose使用物件導向設計來封裝、退耦、維護狀態和支援可讀性及包容力,透過建立、匯出一個Mongoose類別實例,建立了一個簡單介面給使用者。

它同時也將這個獨體實例作為命名空間,讓其他建構子可被使用,當使用者需要的時候,引入Mongoose本身的建構子。你可能使用Mongoose建構子來建立額外的Mongoose實例來連結到額外的MongoDB資料庫。

內部地,Mongoose做了:

Mongoose.prototype.Mongoose = Mongoose;

所以你可以:

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

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

匯出成全域物件(Global Object)

一個被引入的模組不只是可以被匯出成一個值而已,它也可以修改全域物件或者回傳全域物件。它可以定義新的全域物件。他可以這麼做,或者再去額外匯出某些有用的東西。

當你需要去擴充或者改變全域物件的行為,變成由你的模組所提供的行為,你就可以用這種樣式。當然這是有爭議且必須明智地使用(特別在開放原始碼工作上)。這種樣式也可能是不可或缺的。

Should.js是一個斷言(assertion)函式庫,用在單元測試中:

require('should');

var user = {
    name: 'Jane'
};

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

Should.js利用了非列舉型屬性來延伸物件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
});

注意當Should.js匯出了should它主要在用的函式,是使用了should函式增加到Object的函式。

套用猴子補丁(Monkey Patch)

我所提到的猴子補丁是指"執行階段動態的修改某個類別或模組,動機是補強某個第三方程式碼的臭蟲(bug)或功能(feature)不如我們所期望"

當某個模組沒有提供你所需要的介面,讓你自訂其行為時,實作一個模組來補丁現有模組。這個樣式與前述的樣式有所差異。我們並非修改一個全域物件,而是依賴Node模組系統的快取行為,當其他程式碼在引入該模組時,補強模組的實例。

預設來說,Mongoose使用小寫字母及複數詞來命名MongoDB集合。舉例某個模型名叫 CreditCardAccountEntry,結果是該集合名稱成為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並且代表了原有模型的實作。現在所有Mongoose的實例都會有這個新的行為。注意,它並沒有修改exports,所以require的回傳值將會是個預設為空的exports物件。

附註,如果你對現有程式碼選擇使用猴子補丁,使用類似我上述範例的鏈接(chaining)技巧。加入你的行為後,代表本來的實作。雖然不是最簡單的方式,但是是最安全的方式來補丁第三方程式碼,讓你未來在更新函式庫時,能夠獲得某些益處,並且最小化與其他補丁的衝突。

Export Away!

Node模組系統提供了一個簡單的機制來封裝其功能性,給你程式碼建立了明瞭的介面。我希望這七種樣式提供不同的分析策略對你有所幫助。

我並沒有徹底的調查,一定有其他選項可供選擇,但我嘗試去描述幾個最常見且有用的方法。我有漏掉哪些應該在這被提出的方式嗎?

感謝Node開發社群者驚人的創造力。我鼓勵你閱讀你正在使用的函式庫,找尋優秀開發者清晰、一致且可閱讀的樣式,進而啟發你自己。特別是TJ Holowaychuk,Express.js、Connect及Should.js開發者。