Skip to content

egg-sequelize

我们可以使用egg-mysql 插件来访问数据库。而在一些较为复杂的应用中,我们可能会需要一个 ORM 框架来帮助我们管理数据层的代码。而在 Node.js 社区中,sequelize 是一个广泛使用的 ORM 框架,它支持 MySQL、PostgreSQL、SQLite 和 MSSQL 等多个数据源。

如下是 MySQL 中 users 表的数据做 CURD 的例子来一步步介绍如何在 egg 项目中使用 sequelize。

准备工作

首先在本机上安装好 MySQL,如果是 MacOS,可以通过 homebrew 快速安装:

bash
brew install mysql
brew services start mysql

初始化项目

安装并配置 egg-sequelize 插件(它会辅助我们将定义好的 Model 对象加载到 app 和 ctx 上)和 mysql2 模块:

  • 安装
bash
npm install --save egg-sequelize mysql2
  • config/plugin.js 中引入 egg-sequelize 插件
js
exports.sequelize = {
  enable: true,
  package: 'egg-sequelize',
}
  • config/config.default.js 中编写 sequelize 配置
js
config.sequelize = {
  dialect: 'mysql',
  host: '127.0.0.1',
  port: 3306,
  database: 'egg-sequelize-doc-default',
}

我们可以在不同的环境配置中配置不同的数据源地址,用于区分不同环境使用的数据库,例如我们可以新建一个 config/config.unittest.js 配置文件,写入如下配置,将单测时连接的数据库指向 egg-sequelize-doc-unittest

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

完成上面的配置之后,一个使用 sequelize 的项目就初始化完成了。egg-sequelizesequelize 还支持更多的配置项,可以在他们的文档中找到。

初始化数据库和 Migrations

sequelize 提供了 sequelize-cli 工具来实现 Migrations,在 egg 项目中引入 sequelize-cli。

  • 安装 sequelize-cli
bash
npm install --save-dev sequelize-cli

在 egg 项目中,我们希望将所有数据库 Migrations 相关的内容都放在 database 目录下,所以我们在项目根目录下新建一个 .sequelizerc 配置文件:

js
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 配置文件和目录
bash
npx sequelize init:config
npx sequelize init:migrations

执行完后会生成 database/config.json 文件和 database/migrations 目录,修改一下 database/config.json 中的内容,将其改成项目中使用的数据库配置. 此时 sequelize-cli 和相关的配置也都初始化好了,我们可以开始编写项目的第一个 Migration 文件来创建我们的一个 users 表了

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

执行完后会在 database/migrations 目录下生成一个 migration 文件(${timestamp}-init-users.js),我们修改它来处理初始化 users 表:

js
'use strict'

module.exports = {
  // 在执行数据库升级时调用的函数,创建 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,
    })
  },
  // 在执行数据库降级时调用的函数,删除 users 表
  down: async (queryInterface) => {
    await queryInterface.dropTable('users')
  },
}
  • 执行 migrate 进行数据库变更
bash
# 升级数据库
npx sequelize db:migrate
# 如果有问题需要回滚,可以通过 `db:migrate:undo` 回退一个变更
# npx sequelize db:migrate:undo
# 可以通过 `db:migrate:undo:all` 回退到初始状态
# npx sequelize db:migrate:undo:all

编写代码

现在终于可以开始编写代码实现业务逻辑了,首先我们来在 app/model/ 目录下编写 user 这个 Model:

js
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

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 挂载到路由上;

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

单元测试

在编写测试之前,由于在前面的 egg 配置中,我们将单元测试环境和开发环境指向了不同的数据库,因此需要通过 Migrations 来初始化测试数据库的数据结构:

bash
NODE_ENV=test npx sequelize db:migrate

有数据库访问的单元测试直接写起来会特别繁琐,特别是很多接口我们需要创建一系列的数据才能进行,造测试数据是一个非常繁琐的过程。为了简化单测,我们可以通过 factory-girl 模块来快速创建测试数据。

  • 安装 factory-girl 依赖
bash
npm install --save-dev factory-girl
  • 定义 factory-girl 的数据模型到 test/factories.js
js
// test/factories.js

const { factory } = require('factory-girl')

module.exports = (app) => {
  // 可以通过 app.factory 访问 factory 实例
  app.factory = factory

  // 定义 user 和默认数据
  factory.define('user', app.model.User, {
    name: factory.sequence('User.name', (n) => `name_${n}`),
    age: 18,
  })
}
  • 初始化文件 test/.setup.js,引入 factory,并确保测试执行完后清理数据,避免被影响。
js
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 })])
})

接下来我们就可以开始编写真正的测试用例了:

js
// 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 快速创建 user 对象到数据库中
      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 中运行单元测试,需要确保在执行测试代码之前,执行一次 migrate 确保数据结构更新,例如我们在 package.json 中声明 scripts.ci 来在 CI 环境下执行单元测试:

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

完整示例

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

脚手架

我们也提供了 sequelize 的脚手架,集成了文档中提供的 egg-sequelize, sequelize-clifactory-girl 等模块。可以通过 npm init egg --type=sequelize 来基于它快速初始化一个新的应用。