參透Node中exports的7種設計模式
前言
這篇文章試著要整理,翻譯Export This: Interface Design Patterns for Node.js Modules這篇非常值得一讀的文章。
但因為這篇文章有些時日了,部分示例已經不符合現(xiàn)況。故這是一篇加上小弟收集匯整而成的更新翻譯。
旅程的開始
當你在Node中加載一個模塊,我們到底會取回什么?當我們撰寫一個模塊時我們又有哪些選擇可以用來設計程序的界面?
在我***次學習Node的時候,發(fā)現(xiàn)在Node中有太多的方式處理這個問題,由于Javascript本身非常彈性,加上在社群中的開發(fā)者們各自都有不同的實作風格,這讓當時的我感到有點挫折。
在原文作者的學習旅程中曾持續(xù)的觀察尋找好的方式以應用在其的工作上,在這篇文章中將會分享觀察到的Node模塊設計方式。
大略總結了7種設計模式(pattern)
- 導出命名空間Namespace
- 導出函式Function
- 導出高階函式High-Order Function
- 導出構造函數(shù)/構建函式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沒有任何屬性就把這些數(shù)據(jù)交給module.exports,但如果module.exports已經具備屬性的話,那么exports的所有數(shù)據(jù)都會被忽略。
為了讓您更好理解關于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的數(shù)據(jù)都會被忽略。
本質上我們可以理解為所有模塊都隱含實作了下面這行代碼
- var exports = module.exports = {}
現(xiàn)在我們知道了,當我們要導出一個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被調用時都會回傳相同的值。
它會根據(jù)匯入檔案的絕對路徑來緩存,所以當我們想要模塊能夠回傳不同得值時,我們就需要導出function,如此一來每次執(zhí)行函式時就會回傳一個新值。
下面在Node REPL中簡易的示范
- $ node
- > f1 = require('/Users/andyyou/Projects/export_this/function')
- [Function]
- > f2 = require('./function')//相同路徑
- [Function]
- > f1 === f2
- true
- > f1()=== f2()
- false
您可以觀察到require回傳了同樣的函式物件實例,但每一次調用函式回傳的物件是不同的。
更詳細的介紹可以參考官方文件,值得一讀。
現(xiàn)在,我們可以開始探討界面的設計模式(pattern)了。
導出命名空間
一個簡單且常用的設計模式就是導出一個包含數(shù)個屬性的物件,這些屬性具體的內容主要是函式,但并不限于函式。
如此,我們就能夠透過匯入該模塊來取得這個命名空間下一系列相關的功能。
當您匯入一個命名空間類型的模塊時,我們通常會將模塊指定到某一個變數(shù),然后透過它的成員(物件屬性)來存取使用這些功能。
甚至我們也可以將這些變數(shù)成員直接指定到區(qū)域變數(shù)。
- 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物件設定一個區(qū)域變數(shù)(即上面提過的exports)到fs,然后透過fs的屬性使用各個功能,例如:fs.Stats = binding.Stats。
由于fs參考exports并且它是一個物件,所以當我們require('fs')時,我們就能夠透過屬性使用那些功能。
- fs.readFile = function(path,options,callback_){
- //…
- }
其他東西也是一樣的作法,例如導出構造函數(shù)
- 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工作時,會將數(shù)據(jù)模型(Model)拆分成個別的模塊,并使用導出構造函數(shù)的方式導出(請參考下文介紹),然后透過一個index檔案來集合該目錄下所有的數(shù)據(jù)模型并一起導出,如此一來在models命名空間下的所有數(shù)據(jù)模型都可以使用
- 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的命名規(guī)則導出。
于是在我們的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搭配參數(shù)讓我們可以設定并回傳初始化后的物件。
想要導出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,構造函數(shù),物件。
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
- …
導出函式還有些比較特別的案例,值得用另外的名稱以區(qū)分它們的不同。
導出高階函式
一個高階函式或functor基本上就是一個函式可以接受一個或多個函式為其輸入或輸出。而這邊我們要談論的后者-一個函式回傳函式
當我們想要模塊能夠根據(jù)輸入控制回傳函式的行為時,導出一個高階函式就是一種非常實用的設計模式。
補充:functor & monad
舉例來說Connect就提供了許多可掛載的功能給網頁框架。
這里的middleware我們先理解成一個有三個參數(shù)(req,res,next)的function。
Express從v4.x版之后不再相依于connect
connect middleware慣例就是導出的function執(zhí)行后,要回傳一個middleware function。
在處理request的過程中這個回傳的middleware function就可以接手使用剛剛提到的三個參數(shù),用來在過程中做一些處理或設定。
同時因為閉包的特性這些設定在整個中間件的處理流程中都是有效的。
舉例來說,compression這個middleware就可以在處理responsive過程中協(xié)助壓縮
- 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也因為閉包的關系會被保留下來
這是一種***彈性的模塊作法,也可能在您的開發(fā)項目上幫上許多忙。
middleware在這里您可以大略想成串連執(zhí)行一系列的function,自然其Function Signature要一致
導出構造函數(shù)
在一般面向對象語言中,constructor構造函數(shù)指的是一小段代碼協(xié)助我們從類別Class建立一個物件。
- // C#
- class Car {
- // c#構造函數(shù)
- // constructor即class中用來初始化物件的method。
- public Car(name){
- name = name;
- }
- }
- var car = new Car('BMW');
由于在ES2015之前Javascript并不支持類別,某種程度上在Javascript之中我們可以把任何一個function當作類別,或者說一個function可以當作function執(zhí)行或者搭配new關鍵字當作constructor來使用。如果想知道更詳細的介紹可以閱讀MDN教學。
欲導出構造函數(shù),我們需要透過構造函式來定義類別,然后透過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
在這種設計模式底下,我們通常會將每個檔案設計成一個類別,然后導出構造函數(shù)。這使得我們的項目構架更加清楚。
- 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
當我們需要所有的模塊使用者共享物件的狀態(tài)與行為時,就需要導出單一物件實例。
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使用面向對象的設計模式來封裝,解耦(分離功能之間的相依性),維護狀態(tài)使整體具備可讀性,同時透過導出一個Mongoose Class的物件給使用者,讓我們可以簡單的存取使用。
如果我們有需要,它也可以建立其他的物件實例來作為命名空間使用。實際上Mongoose內部提供了存取構造函數(shù)的方法
- Mongoose.prototype.Mongoose = Mongoose
因此我們可以這么做
- var mongoose = require('mongoose')
- var Mongoose = mongoose.Mongoose
- var anotherMongoose = new Mongoose()
- anotherMongoose.connect('mongodb://localhost/test')
擴展全局物件
一個被匯入的模塊不只限于單純取得其導出的數(shù)據(jù)。它也可以用來修改全局物件或回傳全局物件,自然也能定義新的全局物件。而在這邊的全局物件(Global objects)或稱為標準內置物件像是Object,F(xiàn)unction,Array指的是在全局能存取到的物件們,而不是當Javascript開始執(zhí)行時所產生代表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有啥關聯(lián)呢?
事實上
- /* 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
現(xiàn)在我們明白了上面說的修改全局物件的意思了。should.js導出了一個should函式但是它主要的使用方式則是把should加到Object屬性上,透過物件本身來呼叫。
套用猴子補丁(Monkey Patch)
在這邊所謂的猴子補丁特別指的是在執(zhí)行時期動態(tài)修改一個類別或者模塊,通常會這么做是希望補強某的第三方套件的bug或功能。
假設某個模塊沒有提供您客制化功能的界面,而您又需要這個功能的時候,我們就會實作一個模塊來補強既有的模塊。
這個設計模式有點類似擴展全局物件,但并非修改全局物件,而是依靠Node模塊系統(tǒng)的緩存機制,當其他代碼匯入該模塊時去補強該模塊的實例物件。
預設來說Mongoose會使用小寫以及復數(shù)的慣例替數(shù)據(jù)模型命名。例如一個數(shù)據(jù)模型叫做CreditCard最終我們會得到collection的名稱是creditcards。假如我們希望可以換成credit_cards并且其他地方也遵循一樣的用法。
下面是我們試著使用猴子補丁的方式來替既有的模塊增加功能
- var pluralize = require('pluralize')//處理復數(shù)單字的函式庫
- 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模塊系統(tǒng)提供了一個簡單的機制來封裝功能,使我們能夠建立了清楚的界面。希望掌握這七種設計模式提供不同的優(yōu)缺點能對您有所幫助。
在這邊作者并沒有徹底的調查所有的方式,一定有其他選項可供選擇,這邊只有描述幾個最常見且不錯的方法。
小結
- namespace:導出一個物件包含需要的功能
root module的方式,使用一個根模塊導出其他模塊
- function:直接將module.exports設為function
Function物件也可以拿來當作命名空間使用
為其命名方便調試
exports = module.exports = something的作法是為了確保參考(Reference)一致
- high-order function:可以透過代入?yún)?shù)控制并回傳function。
可協(xié)助實作middleware的設計模式
換句話說middleware即一系列相同signature的function串連。一個接一個執(zhí)行
- constructor:導出類別(function),使用時再new,具備OOP的優(yōu)點
- singleton:導出單一物件實例,重點在各個檔案可以共享物件狀態(tài)
- global objects:在全局物件作的修改也會一起被導出
- monkey patch:執(zhí)行時期,利用Node緩存機制在instance加上補丁
筆記
- 一個javascript檔案可視為一個模塊
- 解決特定問題或需求,功能完整由單一或多個模塊組合而成的整體稱為套件(package)
- require匯入的模塊具有自己的scope
- exports只是module.exports的參考,exports會記錄收集屬性如果module.exports沒有任何屬性就把其數(shù)據(jù)交給module.exports,但如果module.exports已經具備屬性的話,那么exports的所有數(shù)據(jù)都會被忽略。
- 就算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