Egg 與 Sequelize

2020-02-06 14:11 更新

前面的章節(jié)中,我們介紹了如何在框架中通過 egg-mysql 插件來訪問數(shù)據(jù)庫。而在一些較為復(fù)雜的應(yīng)用中,我們可能會需要一個 ORM 框架來幫助我們管理數(shù)據(jù)層的代碼。而在 Node.js 社區(qū)中,sequelize 是一個廣泛使用的 ORM 框架,它支持 MySQL、PostgreSQL、SQLite 和 MSSQL 等多個數(shù)據(jù)源。

本章節(jié)我們會通過開發(fā)一個對 MySQL 中 users 表的數(shù)據(jù)做 CURD 的例子來一步步介紹如何在 egg 項目中使用 sequelize。

準(zhǔn)備工作

在這個例子中,我們會使用 sequelize 連接到 MySQL 數(shù)據(jù)源,因此在開始編寫代碼之前,我們需要先在本機上安裝好 MySQL,如果是 MacOS,可以通過 homebrew 快速安裝:

brew install mysql
brew services start mysql

初始化項目

通過 npm 初始化一個項目:

$ mkdir sequelize-project && cd sequelize-project
$ npm init egg --type=simple
$ npm i

安裝并配置 egg-sequelize 插件(它會輔助我們將定義好的 Model 對象加載到 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ù)庫,例如我們可以新建一個 config/config.unittest.js 配置文件,寫入如下配置,將單測時連接的數(shù)據(jù)庫指向 egg-sequelize-doc-unittest。

exports.sequelize = {
dialect: 'mysql',
host: '127.0.0.1',
port: 3306,
database: 'egg-sequelize-doc-unittest',
};

完成上面的配置之后,一個使用 sequelize 的項目就初始化完成了。egg-sequelize 和 sequelize 還支持更多的配置項,可以在他們的文檔中找到。

初始化數(shù)據(jù)庫和 Migrations

接下來我們先暫時離開 egg 項目的代碼,設(shè)計和初始化一下我們的數(shù)據(jù)庫。首先我們通過 mysql 命令在本地快速創(chuàng)建開發(fā)和測試要用到的兩個 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`;'

然后我們開始設(shè)計 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';

我們可以直接通過 mysql 命令將表直接建好,但是這并不是一個對多人協(xié)作非常友好的開發(fā)模式。在項目的演進過程中,每一個迭代都有可能對數(shù)據(jù)庫數(shù)據(jù)結(jié)構(gòu)做變更,怎樣跟蹤每一個迭代的數(shù)據(jù)變更,并在不同的環(huán)境(開發(fā)、測試、CI)和迭代切換中,快速變更數(shù)據(jù)結(jié)構(gòu)呢?這時候我們就需要 Migrations 來幫我們管理數(shù)據(jù)結(jié)構(gòu)的變更了。

sequelize 提供了 sequelize-cli 工具來實現(xiàn) Migrations,我們也可以在 egg 項目中引入 sequelize-cli。

  • 安裝 sequelize-cli
npm install --save-dev sequelize-cli

在 egg 項目中,我們希望將所有數(shù)據(jù)庫 Migrations 相關(guān)的內(nèi)容都放在 database 目錄下,所以我們在項目根目錄下新建一個 .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'),
};
  • 初始化 Migrations 配置文件和目錄
npx sequelize init:config
npx sequelize init:migrations

執(zhí)行完后會生成 database/config.json 文件和 database/migrations 目錄,我們修改一下 database/config.json 中的內(nèi)容,將其改成我們項目中使用的數(shù)據(jù)庫配置:

{
"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"
}
}

此時 sequelize-cli 和相關(guān)的配置也都初始化好了,我們可以開始編寫項目的第一個 Migration 文件來創(chuàng)建我們的一個 users 表了。

npx sequelize migration:generate --name=init-users

執(zhí)行完后會在 database/migrations 目錄下生成一個 migration 文件(${timestamp}-init-users.js),我們修改它來處理初始化 users 表:

'use strict';

module.exports = {
// 在執(zhí)行數(shù)據(jù)庫升級時調(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ù)庫降級時調(diào)用的函數(shù),刪除 users 表
down: async queryInterface => {
await queryInterface.dropTable('users');
},
};
  • 執(zhí)行 migrate 進行數(shù)據(jù)庫變更
# 升級數(shù)據(jù)庫
npx sequelize db:migrate
# 如果有問題需要回滾,可以通過 `db:migrate:undo` 回退一個變更
# npx sequelize db:migrate:undo
# 可以通過 `db:migrate:undo:all` 回退到初始狀態(tài)
# npx sequelize db:migrate:undo:all

執(zhí)行之后,我們的數(shù)據(jù)庫初始化就完成了。

編寫代碼

現(xiàn)在終于可以開始編寫代碼實現(xiàn)業(yè)務(wù)邏輯了,首先我們來在 app/model/ 目錄下編寫 user 這個 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;
};

這個 Model 就可以在 Controller 和 Service 中通過 app.model.User 或者 ctx.model.User 訪問到了,例如我們編寫 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;

最后我們將這個 controller 掛載到路由上:

// app/router.js
module.exports = app => {
const { router, controller } = app;
router.resources('users', '/users', controller.users);
};

針對 users 表的 CURD 操作的接口就開發(fā)完了,為了驗證代碼邏輯是否正確,我們接下來需要編寫單元測試來驗證。

單元測試

在編寫測試之前,由于在前面的 egg 配置中,我們將單元測試環(huán)境和開發(fā)環(huán)境指向了不同的數(shù)據(jù)庫,因此需要通過 Migrations 來初始化測試數(shù)據(jù)庫的數(shù)據(jù)結(jié)構(gòu):

NODE_ENV=test npx sequelize db:migrate:up

有數(shù)據(jù)庫訪問的單元測試直接寫起來會特別繁瑣,特別是很多接口我們需要創(chuàng)建一系列的數(shù)據(jù)才能進行,造測試數(shù)據(jù)是一個非常繁瑣的過程。為了簡化單測,我們可以通過 factory-girl 模塊來快速創(chuàng)建測試數(shù)據(jù)。

  • 安裝 factory-girl 依賴
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 => {
// 可以通過 app.factory 訪問 factory 實例
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,并確保測試執(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 }),
]);
});

接下來我們就可以開始編寫真正的測試用例了:

// 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 () => {
// 通過 factory-girl 快速創(chuàng)建 user 對象到數(shù)據(jù)庫中
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 中運行單元測試,需要確保在執(zhí)行測試代碼之前,執(zhí)行一次 migrate 確保數(shù)據(jù)結(jié)構(gòu)更新,例如我們在 package.json 中聲明 scripts.ci 來在 CI 環(huán)境下執(zhí)行單元測試:

{
"scripts": {
"ci": "eslint . && NODE_ENV=test npx sequelize db:migrate && egg-bin cov"
}
}

完整示例

更完整的示例可以查看 eggjs/examples/sequelize。

腳手架

我們也提供了 sequelize 的腳手架,集成了文檔中提供的 egg-sequelizesequelize-cli 與 factory-girl 等模塊??梢酝ㄟ^ npm init egg --type=sequelize 來基于它快速初始化一個新的應(yīng)用。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號