Sequelize的一些小技巧
2019年11月10日
Sequelize.js是一个用于Node.js的数据库ORM库,支持Postgres、MySQL/MariaDB、SQLite、SQL Server等引擎。
本文记录一些团队在使用Sequelize过程中积累的经验教训。
介绍
ORM即Object Relational Mapping,中文叫“对象关系映射”。简单地说就是可以将数据库的各种对象(表、字段)及关系映射为程序语言的对象和关系,从而使开发者不需要直接操作数据库,转而操作对象即可。
例如,将表user
映射为模型User
后,从数据库中查询id
为1
的用户就可以直接调用findOne()
方法:
const user = await User.findOne({
where: {
id: 1
}
});
const user = await User.findOne({
where: {
id: 1
}
});
这样做会带来几个明显的好处:
- 降低开发难度:ORM都有完善的文档,几乎所有的操作只需要按文档调用指定方法即可,不需要自己拼接SQL
- 提升安全性:ORM会处理好SQL注入问题,不需要开发者关注
- 降低封装复杂度:公共逻辑可以基于ORM封装,非常方便
下文不区分“模型”和“Model”,均指Sequelize中与数据表对应的数据模型。
命名
团队合作中统一大家的命名规则是很重要的事情,因此一般稍微规范一些的团队都会有比较详尽的命名规范。但是不同地方的命名规则却不一定完全一致,例如:
- 数据库规范:表名及字段名使用小写字母,单词间以下划线分隔
- JS编码规范:变量命名使用驼峰式命名(即首字母小写,后续单词的首字母大写)
这种情况可以通过Sequelize模型定义来解决,直接指定表名和字段名即可:
sequelize.define('targetInfo', {
targetId: {
type: DataTypes.INTEGER(11),
allowNull: true,
field: 'target_id',
},
}, {
tableName: 'target_info',
});
sequelize.define('targetInfo', {
targetId: {
type: DataTypes.INTEGER(11),
allowNull: true,
field: 'target_id',
},
}, {
tableName: 'target_info',
});
上例中的target_id
字段,在使用Sequelize的Model时就可以使用targetId
属性来访问target_id
字段,完全遵守JS编码规则。
软删除 & 自动管理时间戳
很多时候,因为保留痕迹、灾难恢复等各种原因,在设计技术方案时,我们都会使用一个字段来标记数据是否被删除。当业务需要删除数据时,只需要改变这个标记即可,而不是真的删除数据库记录。
但是选择这种方案的同时,却会为业务带来一些复杂性,即每一个查询都需要考虑删除标记的状态。作为一个合格的ORM库,Sequelize也很贴心地提供了软删除的支持。在开启这个特性后,开发者不需要关注数据记录是否已被删除,只需要正常地使用查询、删除等操作即可,Sequelize会在执行对应的SQL查询前自动加上软删除的条件。
具体的操作非常简单:
- 数据库和模型文件添加
deleted_at
字段 - 在模型定义的选项中加上
paranoid: true
选项
此后,被删除的数据记录的deleted_at
会记录被删除的时间,而没被删除的记录deleted_at
为NULL
。
除了软删除外,记录的建立和更新时间也可以交给Sequelize来管理,操作同样简单:
- 数据库和模型文件添加
created_at
和updated_at
字段 - 在模型定义的选项中加上
timestamps: true
选项
这样定义之后,数据建立时会自动记录创建时间到created_at
字段中,而当数据发生修改时,updated_at
会自动记录更新时间。
关联
多表的查询在数据操作中也是一个比较常见的操作。Sequelize也可以让我们指定模型之间的关联(且有完善的1:1、1:n、m:n关联)。一旦指定完成,则可以在查询数据时直接带出关联数据。
例如每一个会议(Meeting
)有多个参会者(Participator
),在查询会议时可以直接拿出参会者信息:
// 指定1:n关联
Meeting.hasMany(Participator);
// 查询会议
const meeting = Meeting.findOne({
where: {
id: 1,
},
include: [Participator],
});
// 指定1:n关联
Meeting.hasMany(Participator);
// 查询会议
const meeting = Meeting.findOne({
where: {
id: 1,
},
include: [Participator],
});
接下来访问meeting.participators
即可获得会议参会者列表。
如果希望关联数据进行排序,则可以直接在查询中指定order
排序规则。但是这个排序和直观想法不太一样,从SQL的角度来讲,关联数据的查询无论是用多表查询还是join
,都没有办法单独对关联表单独排序,因此不管怎么排序会影响主表的排序。所以如果要对关联数据排序,最好将主表的排序依据写在前面:
// 查询会议
const meeting = Meeting.findAll({
where: {
id: 1,
// 排序
order: [
// 先对主表排序
['id', 'asc'],
// 再对关联表排序
[Participator, 'id', 'asc'],
],
},
include: [Participator],
});
// 查询会议
const meeting = Meeting.findAll({
where: {
id: 1,
// 排序
order: [
// 先对主表排序
['id', 'asc'],
// 再对关联表排序
[Participator, 'id', 'asc'],
],
},
include: [Participator],
});
Model Diff
之前团队碰到了一个需求:在同步数据的同时记录数据发生的变化情况。一开始的想法是在同步前先读取一次数据,等数据同步完之后,再读取一次数据,然后对两次数据进行对比和记录。但是在深入了解Sequelize之后,发现这个事情有更简单的解法。
Sequelize的Model在结构上是有记录两个值的,内部分别用_previousDataValues
和dataValues
记录。其中_previousDataValues
表示从数据库中读出来的原始记录,而dataValues
则记录Model经过一些操作之后的新值。例如.set()
方法会改变dataValues
的值,但不会改变_previousDataValues
的值。但是如果调用.save()
方法,则新值会写入数据库,_previousDataValues
也会改变。
因此,我们可以将数据保存的过程分为设置新值和保存到数据库两步,并且从中获取数据的变更:
- 通过
findOne()/findAll()
读取原值获取Model - 通过
.set()
方法设置Model的新值 - 读取原值和新值的变化
- 通过
.save()
方法保存数据
而具体的第3步,获取变化,Sequelize也有提供一些帮助:.changed()
方法可以返回所有发生变更的字段名,.previous()
方法在不传参数的情况下,会返回仅包含变化字段的原数据,可以直接作为记录变化的原值。而新值则只要拿到发生变更的字段名列表,然后新值即可。
// 获取一个模型发生变化的值
const getChanges = function(model){
const changedFields = model.changed();
if(!changedFields){
return false;
}
const oldValue = model.previous();
const newValue = {};
changedFields.forEach((field) => {
newValue[field] = model[field];
});
return {
oldValue,
newValue,
};
};
// 获取一个模型发生变化的值
const getChanges = function(model){
const changedFields = model.changed();
if(!changedFields){
return false;
}
const oldValue = model.previous();
const newValue = {};
changedFields.forEach((field) => {
newValue[field] = model[field];
});
return {
oldValue,
newValue,
};
};
事务
事务是数据库的一个很重要的特性,它的最重要的一个应用场景即是将一系列的数据库操作原子化——要么全部成功,要么全部失败。上一节提到的场景,在提交数据变更本身的同时记录数据变更情况即是一种典型的适合使用事务的场景。
Sequelize也提供了事务的支持,在使用时先初始化一个事务对象,然后在进行数据操作时传入事务对象即可,没有很特别的地方,仅仅是作为一个记录,在适当的场景下记得使用它即可。
下面的例子删除了一堆数据,并且新增了一堆与之对应的日志:
await sequelize.transaction((t) => {
return Promise.all([
// 创建删除日志
Event.bulkCreate(models.map((item)=>{
return {
type: 'delete',
targetId: item.id,
oldValue: JSON.stringify(item),
newValue: null,
};
}), {transaction: t}),
// 删除数据
Model.destroy({
where: {
id: {
[Op.in]: deleteIdList
}
}
}, {transaction: t})
]);
});
await sequelize.transaction((t) => {
return Promise.all([
// 创建删除日志
Event.bulkCreate(models.map((item)=>{
return {
type: 'delete',
targetId: item.id,
oldValue: JSON.stringify(item),
newValue: null,
};
}), {transaction: t}),
// 删除数据
Model.destroy({
where: {
id: {
[Op.in]: deleteIdList
}
}
}, {transaction: t})
]);
});
Model扩展
Sequelize的Model有很丰富的内部结构,但在进行JSON输出(JSON.stringify
)的时候,却只会输出模型的数据,不需要进行其他的额外处理。在大部分情况下这种处理是合适的,但是在某些情况下,我们仍然需要对数据进行一些处理,例如字段扩展或者字段裁剪。
在这种情况下,我们可以对Model进行扩展,添加一些最终输出前进行整理的代码:
Model.protoype.output = function(){
// 只输出指定的字段,且根据需要格式化
return {
foo: this.foo,
bar: this.bar + '@toobug.net'
}
}
Model.protoype.output = function(){
// 只输出指定的字段,且根据需要格式化
return {
foo: this.foo,
bar: this.bar + '@toobug.net'
}
}
在Sequelize 4中,我们使用的Model的原型并不是Model本身,而是Model.Instance,所以需要在
Model.Instance.prototype
上定义方法才有效。
在实际使用的过程中,我们还可以将这个过程整理得更加工程化:
- 定义一个
extend
目录,专门定义对每个Model的扩展,且命名与Model定义一一对应 - 在初始化的时候读取所有的Model(
sequelize.models
),然后一一读取对应的extend
,并扩展到原型上
这样在以后需要扩展模型的时候,只要在extend
目录下定义对模型的扩展方法即可,不用再手工操作Model。
Model生成
Sequelize Model的使用非常方便,但是手写模型的过程并不太愉快。一个字段就要定义类型、默认值、是否为NULL、字段名等,如果一个表有30个字段,则光写模型定义就需要写100多行。
事实上,如果已经建好了数据库表,则这些模型定义的内容基本上都可以从数据库中读取出来。sequelize-auto
正是做这件事情的库,它会连接数据库,并从数据库中读出所有表,生成对应的模型文件。
它的使用很简单,首先作为工具安装sequelize-auto
模块(npm i sequelize-auto -g
或者不加-g
安装到项目中),然后在命令行中调用它即可,例如:
sequelize-auto -h 127.0.0.1 -d database -u username -x password -p 3306 -C -a .modelConfig.json -o server/models/models
sequelize-auto -h 127.0.0.1 -d database -u username -x password -p 3306 -C -a .modelConfig.json -o server/models/models
参数
-h
/-p
/-d
/-u
/-x
,数据库的IP、端口、数据库、用户名、密码-C
,使用驼峰式命名规则-a
,模型的配置项
其中模型的配置项是一个JSON文件,这些配置项会出现在生成的Model文件中,作为模型的配置,例如:
{
"paranoid": true,
"timestamps": true
}
{
"paranoid": true,
"timestamps": true
}
生成模型后,使用sequelize.import(modelFilePath)
即可引入模型,然后愉快地使用。
sequelize-auto略有些年久失修,比如生成的模型中还有jshint的注释,且缩进是2空格。如果与你的项目规范不符,可以在生成后再加一个
eslint --fix
或者prettier
之类的工具进行格式化即可。