Mabolo: 輕量級的 MongoDB ORM
一開始我像很多人一樣使用 Mongoose 作為 ORM, 但時間長了我發現了 Mongoose 的一些不理想的地方。
Mongoose 通過定義 Setter 的方式記錄了對文檔的每一次修改,以便可以用 save 方法將文檔無沖突地儲存在數據庫中。但我在實際使用中發現,我很少會使用這個功能,每當對文檔進行更新的時候,幾乎都是直接使用 MongoDB 的原子性操作符($set等)。Mongoose 在這個功能上下了很大功夫,也增加了很多額外的約束。例如它 使用了一些黑科技 來阻止用戶修改從數據庫查出的文檔。而我希望從數據庫中查出文檔后進行一些加工,向文檔上儲存一些額外的數據來供渲染頁面時使用(但不儲存到數據庫),本來我們在 JavaScript 這樣的語言中是期待一個對象是可以被隨意修改的,但在 Mongoose 中卻不可以。
我發現我其實只需要 Mongoose 的一小部分功能,于是我自己編寫了 Mabolo , 我對它的定位是一個輕量級、無黑科技的 ORM. 它完成于 2015 年初,目前已被使用到了我的大部分個人項目中。
Mabolo 用 300 行代碼實現了一個 ORM 最核心的一些功能:為數據集合定義字段的類型、驗證文檔字段的合法性、定義類方法和實例方法、嵌入式的文檔和數組、原子性地更新整個文檔、同時兼容 Promise 和 callback 風格的 API.
Mabolo 幾乎沒有使用什么黑科技,每個 Model 都是一個普通的 JavaScript 構造函數,而每個文檔則都是由這個構造函數生成的實例 —— 除了幾個用來保存內部狀態的不可枚舉屬性之外和普通的對象沒有任何區別。
接下來我來談一談 Mabolo 中的幾個實現細節。
定義 Model
在 Mongoose 中,要先創建 Mongoose 實例(即代表一個數據庫連接)才能根據它來創建 Model, 但這樣會造成 Model 定義依賴于這個全局的數據庫連接。而在 Mabolo 中,可以先創建與實例無關的 Model, 然后再將其綁定到 Mabolo 實例上:
User.coffee:
Mabolo = reuqire 'mabolo' module.exports = Mabolo.model 'User', name: String
app.coffee
Mabolo = reuqire 'mabolo' mabolo = new Mabolo 'mongodb://localhost/test' User = mabolo.bind require './User'
即使 Model 還沒被綁定到 Mabolo 實例上,也是可以執行查詢的,這些插件會被阻塞,直到 Model 被綁定到一個數據庫連接上。
實現上,Mabolo.model會創建一個繼承(CoffeeScript 的 extends)自AbstractModel的類,作為 Model 來使用。在綁定時,Mabolo.bind會調用 Model 上的bindCollection函數,這個函數會 resolve 一個內部的 Promise, 讓對數據庫的操作開始執行。
嵌入式文檔
Mabolo 中 Model 的字段定義,既可以是基本類型,也可以是另一個 Model, 還可以是基本類型或 Model 的數組。
Token = mabolo.model 'Token', code: String User = mabolo.model 'User', tokens: [Token]
在保存文檔到數據庫時,Mabolo 會調用Model::transform構造字段定義中的嵌入式文檔,這樣才可以運行定義在嵌入式文檔上的字段驗證。而在從數據庫取出文檔時,也會構造字段定義中所描述的嵌入文檔, 以便用戶調用嵌入文檔上的實例方法。
下一步會支持在嵌入文檔上運行 update 和 remove 方法。主要實現方法是Model::transform會在構造出的嵌入文檔上儲存父文檔和在父文檔中的位置,以便 update 時為查詢和更新中的字段名加上前綴。
再之后會支持文檔中的引用關系,在從數據庫中取出文檔時,Mabolo 會自動取出被引用到的文檔,這個過程被稱為「填充」,用戶也可以自己定義更復雜的填充規則。
原子性地更新文檔
Mabolo 使用了和 Mongoose 類似的技術來原子性地更新整個文檔,即在每次更新時都為文檔設置一個版本號(在 Mabolo 中是一個隨機的字符串),在進行原子更新時會將當前版本號作為一個查詢條件來運行更新,如果沒有成功(版本號被另一個操作修改了),會從數據庫中查出最新的文檔,重放修改然后再一次嘗試提交。
user.modify (user) -> Q.delay(1000).then -> user.age = 19 .then ->
Mabolo 使用了一種更簡單的方式實現重放修改 —— 即要求用戶傳入一個無副作用的修改函數,這個函數會在每次重放修改的時候被調用一次。應該說這只是一種不推薦大量使用的備選方案,更好的做法是直接使用 MongoDB 的原子操作符:
user.update $set: age: 19