從零開始寫一個 Node.js 的 MongoDB 驅動庫
為什麼要造個輪子
我想大多數 Noder 都用過 Mongoose(如果你用的資料庫是 MongoDB 的話),沒用過也聽說過,還有一部分人用的官方的 node-mongodb-native,少部分人則用的其他的。Mongoose 功能確實比較強大,也有很多優秀的設計在裡面,我自己也用了挺久的 Mongoose,為什麼我還要去造個輪子?主要以下幾點考慮:
- 設計複雜。新手比較容易迷惑於一些概念,比如:
- 難以理解的 Schema、Model、Entity 之間的關係,在 Mongoose 中,Schema 不僅用來定義文檔結構,還可以用來定義 Model 的靜態方法(Static methods)和實例方法(Instance methods),甚至可以定義索引。Model 用來查詢,也可以創建一個 Entity,Entity 又可以對數據做些修改然後 save 回資料庫。
- 虛擬屬性(Virtual attributes),以及 Entity 調用 toJSON 還是 toObject?
- 使用的 mpromise 不支持 .catch,後來可以自定義了。
- 插件系統不夠靈活。Mongoose 的插件已經比較強大了,但還是有幾點不太滿意的地方,比如:
- 插件的定義順序決定了執行順序。
- 插件一旦引入,就一定會被使用。
- pre 函數內 this 一會是要更新的文檔(如: save)一會是 Query 的實例(如: find),需要自己判別;post 函數第一個參數是 result,只能通過修改這個對象修改返回值而不能通過返回一個新的對象覆蓋。
- 錯誤不夠詳細。用過 Mongoose 的人一定碰到過:
CastError: Cast to ObjectId failed for value "xxx" at path "_id"
錯誤棧也看不出啥來,只知道一個期望是 ObjectId 的欄位傳入了非期望的值,通常很難定位出錯的代碼,即使定位到也得不到錯誤現場。
- 介面與官方驅動不一致。Mongoose 封裝並擴展了 node-mongodb-native 的 API,Mongoose 文檔不是很詳細,官方文檔則十分詳細。Mongoose 改動:
- API 改動。如:Mongoose 是 findOneAndRemove;node-mongodb-native 是 findOneAndDelete 等等。
- API 參數改動。
目標
既然知道了痛點,那造輪子的時候就要考慮如何解決這些問題。針對以上幾點,經過一段時間考慮後,制定以下目標:
- 簡化設計。
- Schema 只用來做參數校驗及格式化,可通過 Schema 生成 Model(Schema 是可選的,不使用也沒有關係),Model 負責所有對資料庫的增刪改查的操作(包括建立索引),去掉 Entity 的概念,Mongoose 中可以對 Entity 做些修改後調用 save 方法更新資料庫,我們設計只能通過調用 Model 的 update 等方法更新資料庫。
- 沒有靜態方法,沒有實例方法,沒有虛擬屬性。血的教訓告訴我,混用靜態方法+實例方法+虛擬屬性+自定義插件+自己寫的 services/models 方法簡直就是作死,一團糟。
- 靈活的插件系統。
- 可定義全局插件或某個 Model 上的插件,Model 級插件的優先順序大於全局插件,跟 Mongoose 一樣。
- 定義了插件後期望:①按需使用 ②順序可隨意組合 ③一個插件針對不同的操作可實現不同的行為,如:beforeInsert、afterFind 等等。
- beforeInsert 會在 Model 的 insert 前被調用,用來:①格式化查詢參數 ②修改要插入或更新的文檔;afterFind 會在 Model 的 find 後被調用,用來處理查詢結果。beforeXxx 和 afterXxx 都支持 Function/GeneratorFunction/AsyncFunction。
- 詳細的錯誤信息。
- 正確的錯誤棧,至少讓前幾行是正確並能定位到出錯的代碼行。
- 更多的錯誤信息,而不是只有一個 error.message。
- 與官方驅動相同的介面。介面和參數都保持一致,好處:
- 直接復用官方文檔,也不用自己寫文檔。
- 學習成本低,也方便 node-mongodb-native 用戶遷移。
借(chao)鑒(xi) Mongoose 一些優點:
- 偽同步建立連接。node-mongodb-native 需要在 connect 的回調函數里獲取 db client,而 Mongoose 寫法是同步 connect,然後直接使用 mongoose,如下:
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(err); } else { console.log("meow"); }});
其實建立連接的過程也是非同步的,只不過在 save 的時候會等待連接成功後才執行插入。
- 鏈式調用。node-mongodb-native 的各種限制條件都放到了 options(如:skip, limit, fields),而 Mongoose 則可以鏈式調用,比較直觀,最後會將參數組合到 options 里,見 mquery。如下:
User .find() .skip(10) .limit(1) .select({ _id: 0 }) .exec()
- 強大的 Schema。這裡單純指 Schema 定義文檔結構和格式化的功能。
- 其他的如:
- 類型轉換。如在 Mongoose 中定義了一個欄位 type 是 ObjectId 後,ObjectId 的字元串形式也無縫使用,node-mongodb-native 則必須調用 ObjectId 函數生成一個 ObjectId 實例。
- 安全更新。update 等更新操作默認是 $set 等等。
簡(kan)化(diao) Mongoose 一些功能:
- 去掉靜態方法和實例方法,只有 Model 方法,如:find、insert 等等。
- 虛擬屬性可用插件代替。
another-json-schema
目標定完了,那就開始造輪子吧。前面提到過,Mongoose 的 Schema 還是挺強大的,考慮是不是可以直接拿過來改改,看了下 Mongoose 的 Schema 源碼,感覺耦合嚴重,搬過來改動大成本挺高,而且我只想要它的文檔校驗和格式化功能,於是尋找有沒有其他開源庫可以用。在找遍了 GitHub 上幾乎所有的 JSON Schema 庫後,感覺沒有一個符合我的期望,那就再造個輪子吧。。
然後花了大約一周的業餘時間,another-json-schema 誕生了,下面以 AJS 代稱。
AJS 只有三個介面:
- AJS.register:註冊 validator
- AJS.prototype.compile: 編譯 Schema
- AJS.prototype.validate: 驗證文檔
AJS 內置了一些常用的 validator。一個簡單的例子:
const util = require("util");const AJS = require("another-json-schema");const userSchema = AJS("userSchema", { name: { type: "string" }, age: { type: "number", gte: 18 }});const user = { name: "nswbmw", age: 17};console.log(util.inspect(userSchema, { depth: 5 }));// AJS {// _name: "userSchema",// _object: true,// _children:// { name:// AJS {// _leaf: true,// _children: { type: "string" },// _parent: [Circular],// _path: "$.name",// _schema: { type: "string" },// _name: "userSchema" },// age:// AJS {// _leaf: true,// _children: { type: "number", gte: 18 },// _parent: [Circular],// _path: "$.age",// _schema: { type: "number", gte: 18 },// _name: "userSchema" } },// _parent: null,// _path: "$",// _schema: { name: { type: "string" }, age: { type: "number", gte: 18 } } }console.log(userSchema.validate({ name: "nswbmw", age: 17 }));// { valid: false,// error:// { Error: ($.age: 17) ? (gte: 18)// validator: "gte",// actual: 17,// expected: { type: "number", gte: 18 },// path: "$.age",// schema: "userSchema" },// result: { name: "nswbmw", age: 17 } }console.log(userSchema.validate({ name: "nswbmw", age: 18 }));// { valid: true, error: null, result: { name: "nswbmw", age: 18 } }
可以看出,AJS 的錯誤信息十分詳細。格式化文檔也很簡單:
const validator = require("validator");const toObjectId = require("mongodb").ObjectId;const AJS = require("another-json-schema");const commentSchema = AJS("commentSchema", { postId: { type: actual => { if (!actual || !actual.toString || !validator.isMongoId(actual.toString())) { throw new TypeError(`Wrong postId, expected ObjectId but got ${JSON.stringify(actual)}`); } return toObjectId(actual); } }});console.log(commentSchema.validate({ postId: 1 }));// { valid: false,// error:// { Error: ($.postId: 1) ? (type: type)// validator: "type",// actual: 1,// expected: { type: [Function: type] },// path: "$.postId",// schema: "commentSchema",// originError:// TypeError: Wrong postId, expected ObjectId but got 1// ...// },// result: { postId: 1 } }console.log(commentSchema.validate({ postId: "000000000000000000000000" }));// { valid: true,// error: null,// result: { postId: 000000000000000000000000 } }
注意:AJS 限定只能在 type 這個 validator 的自定義函數里修改原來的值,其他的 validator 只用來驗證是否合法,返回 true 則通過,否則不通過。
const AJS = require("another-json-schema");AJS.register("in", function (actual, expected) { return expected.indexOf(actual) !== -1;});const productSchema = AJS("productSchema", { id: { type: "string", in: ["A", "B"] }});console.log(productSchema.validate({ id: "A" }));// { valid: true, error: null, result: { id: "A" } }console.log(productSchema.validate({ id: "B" }));// { valid: true, error: null, result: { id: "B" } }console.log(productSchema.validate({ id: "C" }));// { valid: false,// error:// { Error: ($.id: "C") ? (in: A,B)// validator: "in",// actual: "C",// expected: { type: "string", in: ["A", "B"] },// path: "$.id",// schema: "productSchema" },// result: { id: "C" } }
有興趣的可以看下 AJS 源碼,只有不到 240 行,歡迎 fork 與 pr。
Mongolass
在開發 Mongolass 之前,我大體翻了幾個其他的 MongoDB 驅動庫的源碼看了下,最後參考了部分 mongoskin 的代碼。Mongolass 的源碼比較少,只有以下幾個文件:
- index.js: 定義了 Mongolass 主類
- model.js: 定義了 Model 類
- query.js: 定義了 Query 類(包含插件系統)及將 Query 綁定到 Model 的函數
- plugins.js: 內置的插件
- schema.js: 定義了一些內置的 Schema,如給 _id 默認設置為 ObjectId 類型
- Types.js: 內置的 Schema Types
Mongolass 類、Model 類、Query 類的關係:
- Mongolass 類的實例用於:①創建與斷開資料庫的連接 ②定義 Schema ③生成 Model 實例 ④載入全局插件 ⑤對資料庫(db 級)的操作,如: mongolass.listCollections()。
- Model 類的實例用於:①對資料庫(collection 級)的增刪改查,如: User.find() ②定義 Model 級的插件。
- Query 類的實例綁定到 Model 實例上的方法,即:Model 實例上的方法如 User.find() 就是一個 Query 實例。
插件系統是如何實現的?
Mongolass 類中有一個 _plugins 屬性和一個 plugin 方法,源代碼如下:
/** * add global plugin */plugin(name, hooks) { if (!name || !hooks || !_.isString(name) || !_.isPlainObject(hooks)) { throw new TypeError("Wrong plugin name or hooks"); } this._plugins[name] = { name: name, hooks: hooks }; for (let model in this._models) { _.defaults(this._models[model]._plugins, this._plugins); } debug("Add global pulgin: %j", name);}
Model 類也有一個 _plugins 屬性和一個 plugin 方法,源代碼如下:
/** * add model plugin */plugin(name, hooks) { if (!name || !hooks || !_.isString(name) || !_.isPlainObject(hooks)) { throw new TypeError("Wrong plugin name or hooks"); } this._plugins[name] = { name: name, hooks: hooks }; debug("Add %s pulgin: %j", this._name, name);}
Mongolass 類中 this._models 存儲了所有定義的 Model 實例,可以看出:每次調用全局即 Mongolass 實例上的 plugin 方法,會遍歷所有的 Model 實例,以 _.defaults 的形式合併到 Model 的 this._plugins 中。
也就是說,全局插件和 Model 插件沒有定義順序一說,因為全局插件的優先順序總是低於 Model 插件,但同級的同名的插件後定義的會覆蓋之前定義的。
hooks 是一個對象,舉個栗子:
User .find({ name: "haha" }) .xx("A", { age: 18 }) .exec()User.plugin("xx", { beforeFind: (...args) { // args => ["A", { age: 18 }] // this._op => find // this._args => [{ name: "haha" }] }, afterFind: (result, ...args) { // result => 查詢的結果 // args => ["A", { age: 18 }] }})
插件是如何使用的?
前面提到了定義的插件都放到了 Model 的 _plugins 屬性中,那麼該如何使用呢?Model 中執行了這樣一行代碼:
Query.bindQuery(this, mongodb.Collection);
query.js 中 bindQuery 做了以下幾個操作:
- 將 mongodb.Collection 中所有的方法(如: insert, find),生成對應的 Query 實例綁定到 Model 實例上,這樣就有 User.find() 這個方法了。再強調下:這裡 User 是 Model 的實例,User.find() 是 Query 的實例。
- Query 實例在生成的時候,也有一個 _plugins 屬性,但這個是數組用來存儲調用的插件,因為數組可以保證順序而對象則不能,同時做了以下操作:
- 添加內置的 schema 插件,用於設置 _id 默認為 ObjectId,更新時默認為 $set 等等操作:
this._plugins = [{ name: "MongolassSchema", hooks: plugins(ctx._schema), args: []}];
- 遍歷 Model 實例上的插件,定義 Query 實例上的方法:
_.forEach(ctx._plugins, plugin => { this[plugin.name] = (...args) => { this._plugins.push({ name: plugin.name, hooks: plugin.hooks, args: args }); return this; };});
可以看出,只有調用該插件後,才會將該插件 push 到 _plugins,後面才會執行。
- exec 方法調用後才真正執行插件和資料庫查詢:
exec(cb) { return Promise.resolve() .then(() => execBeforePlugins.call(this)) .then(() => ctx._connect()) .then(conn => { let res = conn[this._op].apply(conn, this._args); if (res.toArray && (typeof res.toArray === "function")) { return res.toArray(); } return res; }) .then(result => execAfterPlugins.call(this, result)) .catch(e => addMongoErrorDetail.call(this, e)) .asCallback(cb);}
execBeforePlugins 和 execAfterPlugins 分別在資料庫查詢之前和之後執行,以 execBeforePlugins 為例:
function execBeforePlugins() { let self = this; let hookName = "before" + _.upperFirst(this._op); let plugins = _.filter(this._plugins, plugin => plugin.hooks[hookName]); if (!plugins.length) { return; } return co(function* () { for (let plugin of plugins) { debug("%s %s before plugin %s: args -> %j", self._model._name, hookName, plugin.name, self._args); try { let value = plugin.hooks[hookName].apply(self, plugin.args); yield (isGenerator(value) ? value : Promise.resolve(value)); } catch (e) { e.model = self._model._name; e.plugin = plugin.name; e.type = hookName; e.args = plugin.args; throw e; } debug("%s %s after plugin %s: args -> %j", self._model._name, hookName, plugin.name, self._args); } });}
如執行 User.find().mw1().mw2().exec() 則遍歷這個 Query 實例上的 _plugins 數組,把所有的 beforeFind 方法放到一個數組裡依次執行,execAfterPlugins 同理,只不過數組每一項的結果會作為數組下一項執行的輸入。
- .cursor 用來返回遊標;.then 方便結合 co 使用,可省略 .exec()。
- addMongoErrorDetail 用來給 MongoDB 查詢出錯後的 error 對象添加額外詳細屬性。以 User.find({ name: "haha" }).select({ name: 1, age: 1 }).sort({ name: -1 }).exec() 為例:
- stack: 拼接了額外的錯誤棧信息
- op: 操作符,這裡為:find
- args: 查詢的條件,這裡為:[{"name":"haha"},{"fields":{"name":1,"age":1},"sort":{"name":-1}}]
- model:Model 實例名,這裡為:User
- schema:如果有,這個 Model 實例對應的 Schema 名
Mongolass 的插件有點 Koa 的中間件的概念但本質不同,通過鏈式調用並且在查詢語句之前執行 beforeXxx 和之後執行 afterXxx,功能足夠強大,所以說可以替代 Mongoose 中的虛擬屬性和插件系統。
差不多就這些,雖然沒有手把手從零開始,但也大體講明白了寫一個 Node.js 的 MongoDB 驅動的思路與過程。Mongolass 的代碼還是比較少的,相信你讀完這篇文章後再去看源碼,會一目了然。
歡迎 fork 與 pr。
最後
我們正在招聘!
[北京/武漢] 石墨文檔 做最美產品 - 尋找中國最有才華的工程師加入
推薦閱讀:
※nodejs + react + redux 實踐
※在Egg中使用GraphQL
※Node.js 性能調優之內存篇(二)——heapdump
※NodeJS 工程師必備的 8 個工具