前面的章節(jié)中 ,我們介紹了如何在框架中通過(guò) egg-mysql 插件來(lái)訪問(wèn)數(shù)據(jù)庫(kù)。而在一些較為復(fù)雜的應(yīng)用中,我們可能會(huì)需要一個(gè) ORM 框架來(lái)幫助我們管理數(shù)據(jù)層的代碼。而在 Node.js 社區(qū)中,sequelize 是一個(gè)廣泛使用的 ORM 框架,它支持 MySQL、PostgreSQL、SQLite 和 MSSQL 等多個(gè)數(shù)據(jù)源。
本章節(jié)我們會(huì)通過(guò)開(kāi)發(fā)一個(gè)對(duì) MySQL 中 users 表的數(shù)據(jù)做 CURD 的例子來(lái)一步步介紹如何在 egg 項(xiàng)目中使用 sequelize。
準(zhǔn)備工作在這個(gè)例子中,我們會(huì)使用 sequelize 連接到 MySQL 數(shù)據(jù)源,因此在開(kāi)始編寫代碼之前,我們需要先在本機(jī)上安裝好 MySQL,如果是 MacOS,可以通過(guò) homebrew 快速安裝:
brew install mysql brew services start mysql
初始化項(xiàng)目通過(guò) npm 初始化一個(gè)項(xiàng)目:
$ mkdir sequelize-project && cd sequelize-project $ npm init egg --type=simple $ npm i
安裝并配置 egg-sequelize 插件(它會(huì)輔助我們將定義好的 Model 對(duì)象加載到 app 和 ctx 上)和 mysql2 模塊:
npm install --save egg-sequelize mysql2
在 config/plugin.js 中引入 egg-sequelize 插件 exports.sequelize = { enable: true, package: 'egg-sequelize', };
在 config/config.default.js 中編寫 sequelize 配置 config.sequelize = { dialect: 'mysql', host: '127.0.0.1', port: 3306, database: 'egg-sequelize-doc-default', };
我們可以在不同的環(huán)境配置中配置不同的數(shù)據(jù)源地址,用于區(qū)分不同環(huán)境使用的數(shù)據(jù)庫(kù),例如我們可以新建一個(gè) config/config.unittest.js 配置文件,寫入如下配置,將單測(cè)時(shí)連接的數(shù)據(jù)庫(kù)指向 egg-sequelize-doc-unittest。
exports.sequelize = { dialect: 'mysql', host: '127.0.0.1', port: 3306, database: 'egg-sequelize-doc-unittest', };
完成上面的配置之后,一個(gè)使用 sequelize 的項(xiàng)目就初始化完成了。egg-sequelize 和 sequelize 還支持更多的配置項(xiàng),可以在他們的文檔中找到。
初始化數(shù)據(jù)庫(kù)和 Migrations接下來(lái)我們先暫時(shí)離開(kāi) egg 項(xiàng)目的代碼,設(shè)計(jì)和初始化一下我們的數(shù)據(jù)庫(kù)。首先我們通過(guò) mysql 命令在本地快速創(chuàng)建開(kāi)發(fā)和測(cè)試要用到的兩個(gè) database:
mysql -u root -e 'CREATE DATABASE IF NOT EXISTS `egg-sequelize-doc-default`;' mysql -u root -e 'CREATE DATABASE IF NOT EXISTS `egg-sequelize-doc-unittest`;'
然后我們開(kāi)始設(shè)計(jì) users 表,它有如下的數(shù)據(jù)結(jié)構(gòu):
CREATE TABLE `users` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'primary key', `name` varchar(30) DEFAULT NULL COMMENT 'user name', `age` int(11) DEFAULT NULL COMMENT 'user age', `created_at` datetime DEFAULT NULL COMMENT 'created time', `updated_at` datetime DEFAULT NULL COMMENT 'updated time', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='user';
我們可以直接通過(guò) mysql 命令將表直接建好,但是這并不是一個(gè)對(duì)多人協(xié)作非常友好的開(kāi)發(fā)模式。在項(xiàng)目的演進(jìn)過(guò)程中,每一個(gè)迭代都有可能對(duì)數(shù)據(jù)庫(kù)數(shù)據(jù)結(jié)構(gòu)做變更,怎樣跟蹤每一個(gè)迭代的數(shù)據(jù)變更,并在不同的環(huán)境(開(kāi)發(fā)、測(cè)試、CI)和迭代切換中,快速變更數(shù)據(jù)結(jié)構(gòu)呢?這時(shí)候我們就需要 Migrations 來(lái)幫我們管理數(shù)據(jù)結(jié)構(gòu)的變更了。
sequelize 提供了 sequelize-cli 工具來(lái)實(shí)現(xiàn) Migrations ,我們也可以在 egg 項(xiàng)目中引入 sequelize-cli。
npm install --save-dev sequelize-cli
在 egg 項(xiàng)目中,我們希望將所有數(shù)據(jù)庫(kù) Migrations 相關(guān)的內(nèi)容都放在 database 目錄下,所以我們?cè)陧?xiàng)目根目錄下新建一個(gè) .sequelizerc 配置文件:
'use strict'; const path = require('path'); module.exports = { config: path.join(__dirname, 'database/config.json'), 'migrations-path': path.join(__dirname, 'database/migrations'), 'seeders-path': path.join(__dirname, 'database/seeders'), 'models-path': path.join(__dirname, 'app/model'), };
npx sequelize init:config npx sequelize init:migrations
執(zhí)行完后會(huì)生成 database/config.json 文件和 database/migrations 目錄,我們修改一下 database/config.json 中的內(nèi)容,將其改成我們項(xiàng)目中使用的數(shù)據(jù)庫(kù)配置:
{ "development": { "username": "root", "password": null, "database": "egg-sequelize-doc-default", "host": "127.0.0.1", "dialect": "mysql" }, "test": { "username": "root", "password": null, "database": "egg-sequelize-doc-unittest", "host": "127.0.0.1", "dialect": "mysql" } }
此時(shí) sequelize-cli 和相關(guān)的配置也都初始化好了,我們可以開(kāi)始編寫項(xiàng)目的第一個(gè) Migration 文件來(lái)創(chuàng)建我們的一個(gè) users 表了。
npx sequelize migration:generate --name=init-users
執(zhí)行完后會(huì)在 database/migrations 目錄下生成一個(gè) migration 文件(${timestamp}-init-users.js),我們修改它來(lái)處理初始化 users 表:
'use strict'; module.exports = { // 在執(zhí)行數(shù)據(jù)庫(kù)升級(jí)時(shí)調(diào)用的函數(shù),創(chuàng)建 users 表 up: async (queryInterface, Sequelize) => { const { INTEGER, DATE, STRING } = Sequelize; await queryInterface.createTable('users', { id: { type: INTEGER, primaryKey: true, autoIncrement: true }, name: STRING(30), age: INTEGER, created_at: DATE, updated_at: DATE, }); }, // 在執(zhí)行數(shù)據(jù)庫(kù)降級(jí)時(shí)調(diào)用的函數(shù),刪除 users 表 down: async queryInterface => { await queryInterface.dropTable('users'); }, };
執(zhí)行 migrate 進(jìn)行數(shù)據(jù)庫(kù)變更 # 升級(jí)數(shù)據(jù)庫(kù) npx sequelize db:migrate # 如果有問(wèn)題需要回滾,可以通過(guò) `db:migrate:undo` 回退一個(gè)變更 # npx sequelize db:migrate:undo # 可以通過(guò) `db:migrate:undo:all` 回退到初始狀態(tài) # npx sequelize db:migrate:undo:all
執(zhí)行之后,我們的數(shù)據(jù)庫(kù)初始化就完成了。
編寫代碼現(xiàn)在終于可以開(kāi)始編寫代碼實(shí)現(xiàn)業(yè)務(wù)邏輯了,首先我們來(lái)在 app/model/ 目錄下編寫 user 這個(gè) Model:
'use strict'; module.exports = app => { const { STRING, INTEGER, DATE } = app.Sequelize; const User = app.model.define('user', { id: { type: INTEGER, primaryKey: true, autoIncrement: true }, name: STRING(30), age: INTEGER, created_at: DATE, updated_at: DATE, }); return User; };
這個(gè) Model 就可以在 Controller 和 Service 中通過(guò) app.model.User 或者 ctx.model.User 訪問(wèn)到了,例如我們編寫 app/controller/users.js:
// app/controller/users.js const Controller = require('egg').Controller; function toInt(str) { if (typeof str === 'number') return str; if (!str) return str; return parseInt(str, 10) || 0; } class UserController extends Controller { async index() { const ctx = this.ctx; const query = { limit: toInt(ctx.query.limit), offset: toInt(ctx.query.offset) }; ctx.body = await ctx.model.User.findAll(query); } async show() { const ctx = this.ctx; ctx.body = await ctx.model.User.findByPk(toInt(ctx.params.id)); } async create() { const ctx = this.ctx; const { name, age } = ctx.request.body; const user = await ctx.model.User.create({ name, age }); ctx.status = 201; ctx.body = user; } async update() { const ctx = this.ctx; const id = toInt(ctx.params.id); const user = await ctx.model.User.findByPk(id); if (!user) { ctx.status = 404; return; } const { name, age } = ctx.request.body; await user.update({ name, age }); ctx.body = user; } async destroy() { const ctx = this.ctx; const id = toInt(ctx.params.id); const user = await ctx.model.User.findByPk(id); if (!user) { ctx.status = 404; return; } await user.destroy(); ctx.status = 200; } } module.exports = UserController;
最后我們將這個(gè) controller 掛載到路由上:
// app/router.js module.exports = app => { const { router, controller } = app; router.resources('users', '/users', controller.users); };
針對(duì) users 表的 CURD 操作的接口就開(kāi)發(fā)完了,為了驗(yàn)證代碼邏輯是否正確,我們接下來(lái)需要編寫單元測(cè)試來(lái)驗(yàn)證。
單元測(cè)試在編寫測(cè)試之前,由于在前面的 egg 配置中,我們將單元測(cè)試環(huán)境和開(kāi)發(fā)環(huán)境指向了不同的數(shù)據(jù)庫(kù),因此需要通過(guò) Migrations 來(lái)初始化測(cè)試數(shù)據(jù)庫(kù)的數(shù)據(jù)結(jié)構(gòu):
NODE_ENV=test npx sequelize db:migrate:up
有數(shù)據(jù)庫(kù)訪問(wèn)的單元測(cè)試直接寫起來(lái)會(huì)特別繁瑣,特別是很多接口我們需要?jiǎng)?chuàng)建一系列的數(shù)據(jù)才能進(jìn)行,造測(cè)試數(shù)據(jù)是一個(gè)非常繁瑣的過(guò)程。為了簡(jiǎn)化單測(cè),我們可以通過(guò) factory-girl 模塊來(lái)快速創(chuàng)建測(cè)試數(shù)據(jù)。
npm install --save-dev factory-girl
定義 factory-girl 的數(shù)據(jù)模型到 test/factories.js 中 // test/factories.js 'use strict'; const { factory } = require('factory-girl'); module.exports = app => { // 可以通過(guò) app.factory 訪問(wèn) factory 實(shí)例 app.factory = factory; // 定義 user 和默認(rèn)數(shù)據(jù) factory.define('user', app.model.User, { name: factory.sequence('User.name', n => `name_${n}`), age: 18, }); };
初始化文件 test/.setup.js,引入 factory,并確保測(cè)試執(zhí)行完后清理數(shù)據(jù),避免被影響。 const { app } = require('egg-mock/bootstrap'); const factories = require('./factories'); before(() => factories(app)); afterEach(async () => { // clear database after each test case await Promise.all([ app.model.User.destroy({ truncate: true, force: true }), ]); });
接下來(lái)我們就可以開(kāi)始編寫真正的測(cè)試用例了:
// test/app/controller/users.test.js const { assert, app } = require('egg-mock/bootstrap'); describe('test/app/controller/users.test.js', () => { describe('GET /users', () => { it('should work', async () => { // 通過(guò) factory-girl 快速創(chuàng)建 user 對(duì)象到數(shù)據(jù)庫(kù)中 await app.factory.createMany('user', 3); const res = await app.httpRequest().get('/users?limit=2'); assert(res.status === 200); assert(res.body.length === 2); assert(res.body[0].name); assert(res.body[0].age); }); }); describe('GET /users/:id', () => { it('should work', async () => { const user = await app.factory.create('user'); const res = await app.httpRequest().get(`/users/${user.id}`); assert(res.status === 200); assert(res.body.age === user.age); }); }); describe('POST /users', () => { it('should work', async () => { app.mockCsrf(); let res = await app.httpRequest().post('/users') .send({ age: 10, name: 'name', }); assert(res.status === 201); assert(res.body.id); res = await app.httpRequest().get(`/users/${res.body.id}`); assert(res.status === 200); assert(res.body.name === 'name'); }); }); describe('DELETE /users/:id', () => { it('should work', async () => { const user = await app.factory.create('user'); app.mockCsrf(); const res = await app.httpRequest().delete(`/users/${user.id}`); assert(res.status === 200); }); }); });
最后,如果我們需要在 CI 中運(yùn)行單元測(cè)試,需要確保在執(zhí)行測(cè)試代碼之前,執(zhí)行一次 migrate 確保數(shù)據(jù)結(jié)構(gòu)更新,例如我們?cè)?nbsp;package.json 中聲明 scripts.ci 來(lái)在 CI 環(huán)境下執(zhí)行單元測(cè)試:
{ "scripts": { "ci": "eslint . && NODE_ENV=test npx sequelize db:migrate && egg-bin cov" } }
完整示例更完整的示例可以查看 eggjs/examples/sequelize 。
腳手架我們也提供了 sequelize 的腳手架,集成了文檔中提供的 egg-sequelize , sequelize-cli 與 factory-girl 等模塊。可以通過(guò) npm init egg --type=sequelize 來(lái)基于它快速初始化一個(gè)新的應(yīng)用。
更多建議: