Node ORM Sequelize 小记

图片地址:https://global.uban360.com/sfs/file?digest=fide187c6cabd9211c3e29dfabd9384faeb&fileType=2

[TOC]

前言

Sequelize 是 Node.js 中常用的 ORM 库,其作用就是对数据库表和Js对象字段映射,让我们能够通过面向对象的方式去查询和操作数据库。

正文

Sequelize支持 MySQL、PostgreSQL、SQLite 等很多数据库,在使用时可以根据自己环境做相应配置。

我们使用的是mysql数据库,使用阿里的eggjs node服务端框架,本篇文档更多介绍项目使用上需要注意的点,关于 mysql 和 sequelize 的基础内容可以查看官方文档:
mysql
sequelize

数据库配置

咱们使用的egg框架,在使用时需要安装sequelize依赖和mysql驱动

1
cnpm i egg-sequelize mysql2 -S

在 config/config.js中添加并启动 sequelize 插件

1
2
3
4
sequelize: {
enable: true, // 启用插件,false 表示禁用此插件
package: 'egg-sequelize',
},

启用 sequelize 映射mysql数据库,所以还需要配置mysql,在 config/config.default.js 添加配置信息,针对不同的环境配置中配置不同的数据源地址,可以查看 egg Sequelize 文档,也可以参考我们另一篇文章:eggjs 入门和使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// sequelize 配置
config.equelize = {
dialect: 'mysql',
host: '127.0.0.1', // 连接的数据库主机地址
port: 3306, // mysql服务端口
database: 'demo', // 数据库名
username: 'root', // 数据库用户名
password: 'root', // 数据库密码
define: { // model的全局配置
freezeTableName: true, // 防止修改表名为复数
underscored: true, // 自动转换字段为snake_case版本
timestamps: false, // 取消时间戳
// paranoid: true, // 偏执表
},
timezone: '+8:00', // 由于 ORM 用的UTC时间,这里必须加上东八区,否则取出来的时间相差8小时
dialectOptions: { // 让读取 date 类型数据时返回字符串而不是UTC时间
dateStrings: true,
typeCast(field, next) {
if (field.type === "DATETIME") {
return field.string();
}
return next();
}
},
// 连接池
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
}
};

在 sequelize 配置 mysql 数据源时,有几项需要特别注意:

  • freezeTableName

freezeTableName默认值是false,表示sequelize连接数据库时默认会将数据表名映射成复数形式,如 sequelize 定义的表 user,映射到 mysql 数据库表 users,这里配置为 true 禁止复数形式。

  • underscored

underscored比较好理解,表示在sequelize定义数据表时,字段会默认映射到 mysql 表中的下滑线表示的字段,如classId 会映射成 class_id。

  • timestamps

timestamps是配置时间戳字段,当为true时,会自定映射数据库表中的 created_at、updated_at和deleted_at三个字段,如果数据库表中没有定义这些字段,则映射时就会报错。
因为数据库中不是所有的表都需要时间戳字段,所以全局配置 timestamps 一般都是false,在具体表的定义时可以自行配置是否开启时间戳,具体可以查看下一节内容。

  • timezone

timezone是针对时间差的配置,这里需要加上加上东八区,确保时间的准确。

  • dialectOptions

dialectOptions 是针对数据库时间戳字段的格式化输出,在数据查询时就会自动格式化成我们配置的格式。

  • pool

pool是配置连接池
如果从单个进程连接到数据库,则应仅创建一个 Sequelize 实例. Sequelize 将在初始化时设置连接池. 可以通过构造函数的 options 参数(使用options.pool)配置此连接池,如以下示例所示:
如果从多个进程连接到数据库,则必须为每个进程创建一个实例,但每个实例应具有最大连接池大小,以便遵守总的最大大小.例如,如果你希望最大连接池大小为 90 并且你有三个进程,则每个进程的 Sequelize 实例的最大连接池大小应为 30.

表的定义

egg项目中,一个数据表对应的是一个 app/model 目录下的一个文件,先运行创建数据库表的脚本,关于sql脚本请自行查询sql语法

1
2
3
4
5
6
7
CREATE TABLE `student` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '学生ID: 主键 + 自增',
`number` varchar(64) NOT NULL COMMENT '学号: 非空',
`password` varchar(11) NOT NULL COMMENT '密码: 非空',
`class_id` int(11) DEFAULT NULL COMMENT '课程ID: 课程表外键',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

在app/model目录下声明映射文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// app/modal/student.js

module.exports = app => {
const { STRING, INTEGER } = app.Sequelize;

const Student = app.model.define('student', {
id: {
type: INTEGER,
autoIncrement: true,
primaryKey: true,
comment: '学生ID: 主键 + 自增',
},
number: {
type: STRING(11),
allowNull: false,
comment: '学号: 非空',
},
password: {
type: STRING(32),
allowNull: false,
comment: '密码: 非空',
},
classId: {
type: INTEGER(11),
foreignKey: true,
comment: '课程ID: 课程表外键',
}
});
return Student;
}

映射数据表字段定义时,mysql数据类型与Sequelize对应如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Sequelize.STRING                      // VARCHAR(255)
Sequelize.STRING(1234) // VARCHAR(1234)
Sequelize.STRING.BINARY // VARCHAR BINARY
Sequelize.TEXT // TEXT
Sequelize.TEXT('tiny') // TINYTEXT

Sequelize.INTEGER // INTEGER
Sequelize.BIGINT // BIGINT
Sequelize.BIGINT(11) // BIGINT(11)

Sequelize.FLOAT // FLOAT
Sequelize.FLOAT(11) // FLOAT(11)
Sequelize.FLOAT(11, 12) // FLOAT(11,12)

Sequelize.DOUBLE // DOUBLE
Sequelize.DOUBLE(11) // DOUBLE(11)
Sequelize.DOUBLE(11, 12) // DOUBLE(11,12)

Sequelize.DECIMAL // DECIMAL
Sequelize.DECIMAL(10, 2) // DECIMAL(10,2)

Sequelize.DATE // DATETIME 针对 mysql / sqlite, TIMESTAMP WITH TIME ZONE 针对 postgres
Sequelize.DATE(6) // DATETIME(6) 针对 mysql 5.6.4+. 小数秒支持多达6位精度
Sequelize.DATEONLY // DATE 不带时间.
Sequelize.BOOLEAN // TINYINT(1)

字段属性值如下:

属性名 类型 默认值 是否必填 说明
type Any 字段数据类型
primaryKey Boolean false 主键,一般每个数据库表都有一个主键
autoIncrement Boolean false 自增,可以配置自增起始值
allowNull Boolean false 是否允许为空
defaultValue Any 默认值,一般在时间戳上用的比较多
field String 字段名 自定义字段名,当名称和数据库字段不一样时(驼峰和下划线转换之后),配置字段映射
unique Any 唯一性约束

具体关于 sequelize model 的操作可以查看 文档

表的操作

Sequelize 封装了底层sql语句提供有很多api,可以很方便的操作数据库,有兴趣可以查看官方文档:Sequelize

查询

Sequelize Api有很多的查询api,用的比较多的如 findAll、findOne、findByPk 等方法,其他请查看官方文档:model-querying-basics

这里以 student 表查询为例,主要讲述实际项目中可能会踩坑的点

  • 条件查询

条件查询又称where查询,即使用 where 关键字来做条件查询,如学生信息的模糊查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 模糊查询序号/姓名
Student.findAll({
where: {
[Sequelize.Op.or]: [
{
// 姓名的模糊查询
name: {
[Sequelize.Op.like]: "%" + keyword + "%"
}
},
{
// 学号的模糊查询
number: {
[Sequelize.Op.like]: "%" + keyword + "%"
}
}
]
}
});

注意网上有些案例会使用 $or 和$like 关键字,我们使用时可能会出现警告或报错,这是因为官方为了更好的安全性,强烈建议在代码中使用Sequelize.Op中的符号运算符,如Op.and/Op.or,而不依赖于任何基于同轴的运算符,如$and/$or。
如果非要使用的话,需要配置别名映射,配置方法如下:

config/config.default.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

config.sequelize = {
// 使用默认运算符别名
operatorsAliases:{
$eq: Op.eq,
$ne: Op.ne,
$gte: Op.gte,
$gt: Op.gt,
$lte: Op.lte,
$lt: Op.lt,
$not: Op.not,
$in: Op.in,
$notIn: Op.notIn,
$is: Op.is,
$like: Op.like,
$notLike: Op.notLike,
$iLike: Op.iLike,
$notILike: Op.notILike,
$regexp: Op.regexp,
$notRegexp: Op.notRegexp,
$iRegexp: Op.iRegexp,
$notIRegexp: Op.notIRegexp,
$between: Op.between,
$notBetween: Op.notBetween,
$overlap: Op.overlap,
$contains: Op.contains,
$contained: Op.contained,
$adjacent: Op.adjacent,
$strictLeft: Op.strictLeft,
$strictRight: Op.strictRight,
$noExtendRight: Op.noExtendRight,
$noExtendLeft: Op.noExtendLeft,
$and: Op.and,
$or: Op.or,
$any: Op.any,
$all: Op.all,
$values: Op.values,
$col: Op.col
},
// 其他配置
}
  • 分页查询

在页面列表显示的时候,数据需要分页查询,要用到两个查询关键字:limit和offset,分别表示限制查询数量和跳过查询的数量。利用这两个关键字就可以很方便的执行分页查询

1
2
3
4
5
6
7
8
9
10
11
12
13
// 获取查询参数
const { pageIndex, pageSize } = ctx.request.query

Student.findAndCountAll({
offset: (pageIndex - 1) * pageSize, // offet 去掉前多少个数据
limit: pageSize, // limit 每页数据数量
}).then(res => {
// 查询结果
return {
data: res.rows,
total: res.count,
}
});
  • 排序查询

排序查询使用order 关键字,结合多个条件,实现顺序或倒序查询,如按id查询:

1
2
3
4
5
6
Student.findAndCountAll({
order: [
// 多添加排序,在下面新增条件即可
['id', 'DESC'], // ASC 升序 DESC 降序
]
})
  • 联表查询

相比较其他查询,联表查询会复杂一些,在查询之前需要明确标识表之间的映射关系(一对一、一对多或多对多),这里只介绍基本查询语法,详细请查看后面表的关联部分。

联表查询使用了 include 关键字,表示需要连接其他数据表来查询。

为了演示demo,我们在新建一个教师表,并修改 student 添加班主任字段:

teacher

1
2
3
4
5
6
CREATE TABLE `teacher` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '教师ID: 主键 + 自增',
`number` varchar(64) NOT NULL COMMENT '教师编号: 非空',
`name` varchar(11) NOT NULL COMMENT '教师姓名: 非空',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

修改学生表,新增班主任ID(head_teacher_id)字段,关联教师表:

1
2
3
4
5
-- 修改 student,新增 head_teacher_id 字段
ALTER TABLE `student` ADD `head_teacher_id` INT NOT NULL COMMENT '班主任';

-- 设置外键关联
ALTER TABLE `student` ADD CONSTRAINT `fk_teacher_student` FOREIGN KEY (`head_teacher_id`) REFERENCES `teacher` (`id`) ;

在查询 student 信息时,同时关联查询到班主任信息,需要两个步骤:设置关联关系、联表查询数据

1.设置关联关系

设置关联关系需要修改 student 表模型,添加外键字段和声明与teacher表的关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module.exports = app => {
const { INTEGER } = app.Sequelize;

const Student = app.model.define('student', {
// 1、修改表字段
headerTeacherId: {
type: INTEGER(11),
foreignKey: true,
comment: '班主任ID',
}
});

// 2、声明关联关系,一对一关系,即一个学生只有一个班主任,其他还有 一对多、多对多关系
app.model.Student.hasOne(app.model.Teacher, {
foreignKey: 'headerTeacherId', // 外键约束
as: 'headerTeacher', // 别名
});
return Student;
}

2.联表查询数据

使用 include 关键字,联表查询班主任数据:

1
2
3
4
5
6
7
8
9
Student.findByPk(id, {
include: [
// 联表查询,
{
model: model.Teacher,
as: "headerTeacher", // 注意这里的别名一定要和 model 中的别名一样
}
]
})

注意:在列表查询的时候,为了查询性能考虑,尽量不要使用联表查询,类似在js 循环中不要做过多的操作,推荐分开查询学生和老师列表,再手动组装数据。

创建/更新

sequelize针对数据的创建和更新封装了很多方法,而我们用的比较多的几个方法就是:create、bulkCreate、update等,而 bulkCreate 这个方法比较特殊,即可以用于批量创建也可以批量更新,下面我们分别查看不同场景下的使用方式

  • 创建和批量创建

sequelize中create和bulkCreate,分别是支持单条创建和批量创建,使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 创建单条记录
Student.create({
number,
password,
classId,
headerTeacherId,
})

// 批量创建记录
Student.bulkCreate([
{
number,
password,
classId,
headerTeacherId,
},
// 其他数据
])

当然这里针对类似学号这样的字段,一般都是按照固定格式自动生成,Sequelize也给我们提供了gettersetter 的便捷方式,类似于 Proxy的getter和setter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Student = sequelize.define('student', {
// 其他操作
password: {
type: DataTypes.STRING,
get() {
// 按照指定格式返回数据,一般用于虚拟字段
const rawValue = this.getDataValue(username);
return rawValue ? rawValue.toUpperCase() : null;
},
set(value) {
// 按照预定义规则配置数据
this.setDataValue('password', hash(value));
}
},
});
  • 更新

Sequelize提供的 update 和 bulkCreate 都可以实现更新,参数稍有区别,返回值也不一样,具体可自行查阅:文档,这里值介绍bulkCreate批量更新

1
2
3
4
5
6
7
Student.bulkCreate(vualueArray,
{

// updateOnDuplicate 表示在插入的时候如果行键(主键)已存在,则更新那些字段
updateOnDuplicate: ['password', 'headerTeacherId'],
}
)

这里 bulkCreate 更新数据的方式比较奇怪,它本身执行的其实是插入操作,只是在遇到主键已存在的时候,才会更新指定的字段,使用的时候需要注意,推荐添加注详细说明,避免代码歧义,也方便维护。

而针对单条记录的更新还可以 findByPk/findOne + save 方式,这种方式其实就是先查询到某条记录,然后 set 字段值,最后调用 save 方法更新,没有什么好说的,看代码就完事:

1
2
3
4
5
6
7
8
9
10
// 1、查询数据
const student = await model.Student.findByPk(1)

if (student) {
// 2、设置字段值
student.set('password', newPassword)

// 3 更新到数据库
student.save()
}

删除/恢复

数据的删除可以调用 destroy 或 truncate(Model.destroy({ truncate: true })便捷方法),数据恢复调用restore, 这里不再多说,具体请查询:文档

注意当开启偏执表的时候,destroy 实际上是软删除操作,真实删除需要传 force 参数,具体可以查看:文档,关于偏执表请查看后面章节

表的关联

数据库中联表查询很常见,当关联多个表做查询时,一定要梳理好各个数据表之间的关联关系,文档 Sequelize Association文档,讲述的非常清楚,在我们这里只介绍基础用法。

这里我们以学生、老师、班级、课程为例,分别讲述下数据表中的 一

  • 一对一

学生和信息表对应是一对一的关系,关系定义用到 hadOne 和 belongsTo 方法, 数据 model 定义如下:

以学生表为源表,信息表为目标表

1
2
3
4
5
6
7
8
9
10
// 表的关联
Student.associate = () => {
// 以学生为源表,信息表为目标表,使用 hasOne 表示一个学生有一条信息
app.model.Student.hasOne(app.model.Info,
{
foreignKey: "infoId",
as: "info", // 别名
}
)
};

以信息表为源表,学生表为目标表

1
2
3
4
5
6
7
8
9
10
// 表的关联
Info.associate = () => {
// 以信息为源表,学生表为目标表,使用 belongsTo 表示一条信息属于一个学生
app.model.Info.belongsTo(app.model.Student,
{
foreignKey: "student_id",
as: "student", // 别名
}
)
}

查询语句如下:

查询学生表,连表查询学生信息

1
2
3
4
5
6
7
8
9
Student.findByPk(id, {
include: [
{
model: model.Info,
as: "info", // 必须和associate中的名称一样
attributes: ["name", "mobile"], // 过滤需要的属性
}
]
}

查询信息表,连表查询学生信息

1
2
3
4
5
6
7
8
9
Info.findByPk(id, {
include: [
{
model: model.Student,
as: "student", // 必须和associate中的名称一样
attributes: ["name", "mobile"], // 过滤需要的属性
}
]
}
  • 一对多

班级和学生的关系是一对多的关系,关系定义用到 hadMany 和 belongsTo 方法, 数据 model 定义如下:

以班级表为源表,学生表为目标表

1
2
3
4
5
6
7
8
9
10
// 表的关联
Class.associate = () => {
// 以班级表为源,学生表为目标,使用 hasMany 表示一个班级有多个学生
app.model.Class.hasMany(app.model.Student,
{
foreignKey: "student_id",
as: "student", // 别名
}
)
}

以学生表为源表,班级表为目标表

1
2
3
4
5
6
7
8
9
10
// 表的关联
Student.associate = () => {
// 以学生表为源表,班级表为目标表,使用 belongsTo 表示一个学生属于某个班级
app.model.Student.belongsTo(app.model.Class,
{
foreignKey: "classId",
as: "class", // 别名
}
)
};
  • 多对多

学生和课程的关系就是多对多关系, 关系定义受用 hasMany 和 belongsToMany, 通过中间表 student_lesson 关联数据, 数据 model 定义如下:

多对多关系,源数据表和目标数据表都可以使用 hasMany 和 belongsToMany 做关系映射,使用参数一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 表的关联
Student.associate = () => {
// 查询当前学生有哪些课程
app.model.Student.hasMany(app.model.Lesson,
{
through: app.model.StudentLesson, // 通过中间表关联查询
foreignKey: "student_id", // StudentLesson 对 Student 表的外键
otherKey: 'lesson_id', // StudentLesson 对 Lesson 表的外键,也可以不用声明,会自动匹配
as: "lessons", // 别名
}
)

// 或者

// 查询当前课程有哪些学生
app.model.Lesson.belongsToMany(app.model.Student,
{
through: app.model.StudentLesson, // 通过中间表关联查询
foreignKey: "lesson_id", // StudentLesson 对 Lesson 表的外键
otherKey: 'student_id', // StudentLesson 对 Student 表的外键,也可以不用声明,会自动匹配
as: "students", // 别名
}
)
}

这里需要注意的是,在关联数据表查询时,需要明确源表和目标表的关系是一对一、一对多还是多对多,在 associate 方法中正确声明关联方式,确保查询结果正常

偏执表

对于需要删除的数据,一般不会深处真实数据,而是使用软删除,即用一个状态值来表示是否删除该条记录,从而保留数据库数据。

在 Sequelize 中删除数据使用了字段 deletedAt 来表示数据是否被删除,当然使用这个字段需要 Sequelize 同时开启 timestamps 和 paranoid 配置项,表示会启用时间戳和软删除,这时自动插入 createdAt、updatedAt 和 deletedAt 这个时间戳字段。

在创建数据表时的配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

module.exports = app => {
const { INTEGER, DATE, NOW } = app.Sequelize;

const Student = app.model.define('student', {
id: {
type: INTEGER,
autoIncrement: true,
primaryKey: true,
comment: '学生ID: 主键 + 自增',
},
// 其他
gmtCreate: {
type: DATE,
default: NOW,
comment: "创建时间"
},
gmtModified: {
type: DATE,
comment: "修改时间"
},
gmtDelete: {
type: DATE,
comment: "删除标识",
description: '当值非空时,则表示被删除'
}
},
{
paranoid: true, // 偏执表,用于软删除
timestamps: true, // 启用时间戳

// 重命名时间戳字段
createdAt: "gmtCreate",
updatedAt: "gmtModified",
deletedAt: "gmtDelete"
}
);
return Student;
};

事务处理

在数据库中,事务是指一个最小的不可再分的工作但愿,通常一个事务对应一个完整的业务(例如银行账户转账业务,该业务就是一个最小的工作单元)

事务的四大特征(ACID):

  1. 原子性(A):事务只最小单元,不可再分

  2. 一致性(C):事务要求所有的DML语句操作的时候,必须保证同时成功或者失败

  3. 隔离性(I):事务A和事务B之间具有隔离性

  4. 持久性(D):持久性是事务的保证,事务终结的标志

事务有很多专业术语,如开启事务、事务结束、提交事务、回滚事务等,而与事务相关的两条重要的 Sequelize 方法是:commit(提交)和 rollback(回滚)

例如事务处理场景:

新入职一个老师,需要为其分配教授班级和课程,这是一个完整的业务逻辑,需要使用事务确保完整性,在 Sequelize 中对业务处理流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
let transaction;
try {
// 创建事务
transaction = await model.transaction();
// 处理业务流程

// 步骤一:新增教师记录
const { dataValues: teacher } = await model.Teacher.create(rest, {
transaction
});

// 步骤二:新增教授的课程列表:毛概、历史等课程
await model.Lesson.bulkCreate(
lessons.map(lesson => ({ ...lesson, teacherId: teacher.id })),
{
transaction
}
);

// 步骤三:新增教授的班级
await model.Class.create(
{ ...class, teacherId: teacher.id },
{ transaction }
);

// 事物提交
await transaction.commit();
return project;
} catch (error) {
// 异常:事务回滚
await transaction.rollback();
throw error;
}

注意对数据库中的复杂业务处理必须使用事务,确保数据流程正确

总结

Node ORM Sequelize 对数据库的操作封装了很多便捷方法,在使用时可以参考本篇文档,避免踩坑,当然如果文档中的理解或使用方法有问题,欢迎指正。

参考文档