前言
近來公司需要構建一套 EMM(Enterprise Mobility Management)的管理平台,就這種面向企業的應用管理本身需要考慮的需求是十分復雜的,技術層面管理端和服務端構建是架構核心,客戶端本身初期倒不需要那么復雜,作為移動端的負責人(其實也就是一個打雜的小組長),這個平台架構我自然是免不了去參與的,作為一個前端 jser 來公司這邊總是接到這種不太像前端的工作,要是以前我可能會有些抵觸這種業務層面需要考慮的很多,技術實現本身又不太容易積累技術成長的活。這一年我成長了太多,總是嘗試着去做一些可能自己談不上喜歡但還是有意義的事情,所以這次接手這個任務還是想好好把這個事情做好,所以想考慮參與到 EMM 服務端構建。其實話又說回來,任何事只要想去把它做好,怎么會存在有意義還是沒意義的區別呢?
考慮到基於 Node.js 構建的服務目前越來越流行,也方便后續放在平台容器雲上構建微服務,另外作為一個前端 jser 出身的程序員,使用 Node.js 來構建服務格外熟悉。之前學習過一段時間 Egg.js,這次毫不猶豫的選擇了基於 Egg.js 框架來搭建。
為什么是 Egg.js ?
去年在 gitchat JavaScript 進階之 Vue.js + Node.js 入門實戰開發 中安利過 Egg.js,那個時候是初接觸 Egg.js,但是還是被它驚艷到了,Egg 繼承於 Koa,奉行『約定優於配置』,按照一套統一的約定進行應用開發,插件機制也比較完善。雖然說 Egg 繼承於 Koa,大家可能覺得完全可以自己基於 Koa 去實現一套,沒必要基於這個框架去搞,但是其實自己去設計一套這樣的框架,最終也是需要去借鑒各家所長,時間成本上短期是划不來的。Koa 是一個小而精的框架,而 Egg 正如文檔說的為企業級框架和應用而生,對於我們快速搭建一個完備的企業級應用還是很方便的。Egg 功能已經比較完善,另外如果沒有實現的功能,自己根據 Koa 社區提供的插件封裝一下也是不難的。
ORM 設計選型
在數據庫選擇上本次項目考慮使用 MySQL,而不是 MongoDB,開始使用的是 egg-mysql 插件,寫了一部分后發現 service 里面寫了太多東西,表字段修改會影響太多代碼,在設計上缺乏對 Model 的管理,看到資料說可以引入 ORM 框架,比如 sequelize,而 Egg 官方恰好提供了 egg-sequelize 插件。
什么是 ORM ?
首先了解一下什么是 ORM ?
對象關系映射(英語:Object Relational Mapping,簡稱 ORM,或 O/RM,或 O/R mapping),是一種程序設計技術,用於實現面向對象編程語言里不同類型系統的數據之間的轉換。從效果上說,它其實是創建了一個可在編程語言里使用的“虛擬對象數據庫”。
類似於 J2EE 中的 DAO 設計模式,將程序中的數據對象自動地轉化為關系型數據庫中對應的表和列,數據對象間的引用也可以通過這個工具轉化為表。這樣就可以很好的解決我遇到的那個問題,對於表結構修改和數據對象操作是兩個獨立的部分,從而使得代碼更好維護。其實是否選擇 ORM 框架,和以前前端是選擇模板引擎還是手動拼字符串一樣,ORM 框架避免了在開發的時候手動拼接 SQL 語句,可以防止 SQL 注入,另外也將數據庫和數據 CRUD 解耦,更換數據庫也相對更容易。
sequelize 框架
sequelize 是 Node.js 社區比較流行的一個 ORM 框架,相關文檔:
- sequelize.js 文檔:http://docs.sequelizejs.com/
sequelize 使用
安裝:
$ npm install --save sequelize
建立連接:
const Sequelize = require("sequelize"); // 完整用法 const sequelize = new Sequelize("database", "username", "password", { host: "localhost", dialect: "mysql" | "sqlite" | "postgres" | "mssql", operatorsAliases: false, pool: { max: 5, min: 0, acquire: 30000, idle: 10000 }, // SQLite only storage: "path/to/database.sqlite" }); // 簡單用法 const sequelize = new Sequelize("postgres://user:pass@example.com:5432/dbname");
校驗連接是否正確:
sequelize
.authenticate() .then(() => { console.log("Connection has been established successfully."); }) .catch(err => { console.error("Unable to connect to the database:", err); });
定義 Model :
定義一個 Model 的基本語法:
sequelize.define("name", { attributes }, { options });
例如:
const User = sequelize.define("user", { username: { type: Sequelize.STRING }, password: { type: Sequelize.STRING } });
對於一個 Model 字段類型設計,主要考慮以下幾個方面:
Sequelize 默認會添加 createdAt 和 updatedAt,這樣可以很方便的知道數據創建和更新的時間。如果不想使用可以通過設置 attributes 的 timestamps: false;
Sequelize 支持豐富的數據類型,例如:STRING、CHAR、TEXT、INTEGER、FLOAT、DOUBLE、BOOLEAN、DATE、UUID 、JSON 等多種不同的數據類型,具體可以看文檔:DataTypes。
Getters & setters 支持,當我們需要對字段進行處理的時候十分有用,例如:對字段值大小寫轉換處理。
const Employee = sequelize.define("employee", { name: { type: Sequelize.STRING, allowNull: false, get() { const title = this.getDataValue("title"); return this.getDataValue("name") + " (" + title + ")"; } }, title: { type: Sequelize.STRING, allowNull: false, set(val) { this.setDataValue("title", val.toUpperCase()); } } });
字段校驗有兩種類型:非空校驗及類型校驗,Sequelize 中非空校驗通過字段的 allowNull 屬性判定,類型校驗是通過 validate 進行判定,底層是通過 validator.js 實現的。如果模型的特定字段設置為允許 null(allowNull:true),並且該值已設置為 null,則 validate 屬性不生效。例如,有一個字符串字段,allowNull 設置為 true,validate 驗證其長度至少為 5 個字符,但也允許為空。
const ValidateMe = sequelize.define("foo", { foo: { type: Sequelize.STRING, validate: { is: ["^[a-z]+$", "i"], // will only allow letters is: /^[a-z]+$/i, // same as the previous example using real RegExp not: ["[a-z]", "i"], // will not allow letters isEmail: true, // checks for email format (foo@bar.com) isUrl: true, // checks for url format (http://foo.com) isIP: true, // checks for IPv4 (129.89.23.1) or IPv6 format isIPv4: true, // checks for IPv4 (129.89.23.1) isIPv6: true, // checks for IPv6 format isAlpha: true, // will only allow letters isAlphanumeric: true, // will only allow alphanumeric characters, so "_abc" will fail isNumeric: true, // will only allow numbers isInt: true, // checks for valid integers isFloat: true, // checks for valid floating point numbers isDecimal: true, // checks for any numbers isLowercase: true, // checks for lowercase isUppercase: true, // checks for uppercase notNull: true, // won't allow null isNull: true, // only allows null notEmpty: true, // don't allow empty strings equals: "specific value", // only allow a specific value contains: "foo", // force specific substrings notIn: [["foo", "bar"]], // check the value is not one of these isIn: [["foo", "bar"]], // check the value is one of these notContains: "bar", // don't allow specific substrings len: [2, 10], // only allow values with length between 2 and 10 isUUID: 4, // only allow uuids isDate: true, // only allow date strings isAfter: "2011-11-05", // only allow date strings after a specific date isBefore: "2011-11-05", // only allow date strings before a specific date max: 23, // only allow values <= 23 min: 23, // only allow values >= 23 isCreditCard: true, // check for valid credit card numbers // custom validations are also possible: isEven(value) { if (parseInt(value) % 2 != 0) { throw new Error("Only even values are allowed!"); // we also are in the model's context here, so this.otherField // would get the value of otherField if it existed } } } } });
最后我們說明一個最重要的字段主鍵 id 的設計, 需要通過字段 primaryKey: true
指定為主鍵。MySQL 里面主鍵設計主要有兩種方式:自動遞增;UUID。
自動遞增設置 autoIncrement: true
即可,對於一般的小型系統這種方式是最方便,查詢效率最高的,但是這種不利於分布式集群部署,這種基本用過 MySQL 里面應用都用過,這里不做深入討論。
UUID, 又名全球獨立標識(Globally Unique Identifier),UUID 是 128 位(長度固定)unsigned integer, 能夠保證在空間(Space)與時間(Time)上的唯一性。而且無需注冊機制保證, 可以按需隨時生成。據 WIKI, 隨機算法生成的 UUID 的重復概率為 170 億分之一。Sequelize 數據類型中有 UUID,UUID1,UUID4 三種類型,基於node-uuid 遵循 RFC4122。例如:
const User = sequelize.define("user", { id: { type: Sequelize.UUID, primaryKey: true, allowNull: false, defaultValue: Sequelize.UUID1 } });
這樣 id 默認值生成一個 uuid 字符串,例如:'1c572360-faca-11e7-83ee-9d836d45ff41',很多時候我們不太想要這個 -
字符,我們可以通過設置 defaultValue 實現,例如:
const uuidv1 = require("uuid/v1"); const User = sequelize.define("user", { id: { type: Sequelize.UUID, primaryKey: true, allowNull: false, defaultValue: function() { return uuidv1().replace(/-/g, ""); } } });
使用 Model 對象:
對於 Model 對象操作,Sequelize 提供了一系列的方法:
- find:搜索數據庫中的一個特定元素,可以通過 findById 或 findOne;
- findOrCreate:搜索特定元素或在不可用時創建它;
- findAndCountAll:搜索數據庫中的多個元素,返回數據和總數;
- findAll:在數據庫中搜索多個元素;
- 復雜的過濾/ OR / NOT 查詢;
- 使用 limit(限制),offset(偏移量),order(順序)和 group(組)操作數據集;
- count:計算數據庫中元素的出現次數;
- max:獲取特定表格中特定屬性的最大值;
- min:獲取特定表格中特定屬性的最小值;
- sum:特定屬性的值求和;
- create:創建數據庫 Model 實例;
- update:更新數據庫 Model 實例;
- destroy:銷毀數據庫 Model 實例。
通過上述提供的一系列方法可以實現數據的增刪改查(CRUD),例如:
User.create({ username: "fnord", job: "omnomnom" }) .then(() => User.findOrCreate({ where: { username: "fnord" }, defaults: { job: "something else" } }) ) .spread((user, created) => { console.log( user.get({ plain: true }) ); console.log(created); /* In this example, findOrCreate returns an array like this: [ { username: 'fnord', job: 'omnomnom', id: 2, createdAt: Fri Mar 22 2013 21: 28: 34 GMT + 0100(CET), updatedAt: Fri Mar 22 2013 21: 28: 34 GMT + 0100(CET) }, false ] */ });
egg-sequelize 插件
文檔:egg-sequelize:https://github.com/eggjs/egg-sequelize
源碼簡析
這里我們暫時先不分析 egg 插件規范,暫時先只看看 egg-sequelize/lib/loader.js 里面的實現:
"use strict"; const path = require("path"); const Sequelize = require("sequelize"); const MODELS = Symbol("loadedModels"); const chalk = require("chalk"); Sequelize.prototype.log = function() { if (this.options.logging === false) { return; } const args = Array.prototype.slice.call(arguments); const sql = args[0].replace(/Executed \(.+?\):\s{0,1}/, ""); this.options.logging.info("[model]", chalk.magenta(sql), `(${args[1]}ms)`); }; module.exports = app => { const defaultConfig = { logging: app.logger, host: "localhost", port: 3306, username: "root", benchmark: true, define: { freezeTableName: false, underscored: true } }; const config = Object.assign(defaultConfig, app.config.sequelize); app.Sequelize = Sequelize; const sequelize = new Sequelize( config.database, config.username, config.password, config ); // app.sequelize Object.defineProperty(app, "model", { value: sequelize, writable: false, configurable: false }); loadModel(app); app.beforeStart(function*() { yield app.model.authenticate(); }); }; function loadModel(app) { const modelDir = path.join(app.baseDir, "app/model"); app.loader.loadToApp(modelDir, MODELS, { inject: app, caseStyle: "upper", ignore: "index.js" }); for (const name of Object.keys(app[