egg sequelize 实践

背景

和同事一块儿有一个公司内部平台的项目,平台须要对于用户上传的图片,视频等资源进行管理和存储。html

在项目一期,因为申请DB资源的流程比较复杂,因此咱们仅仅将用户上传的内容记录存储在了localStorage中,固然这是很不安全的,很是容易丢失,因此在二期的时候,咱们开始了接入DB的工做。前端

技术选型

下面的整篇文章都会和这部分描述的技术栈相关,固然,sequelize在实际业务场景的使用相关内容,其实自己和技术栈的关系并非很是大,若是你在sequelize的使用过程当中遇到了问题,这里或许会有解答~node

mysql

采用mysql做为数据存储引擎,其实自己也是无奈之举,由于DBA告诉咱们目前MongoDB的资源不足,而且对于这种结构化数据的存储,mysql对于将来将平台扩展到整个公司使用,甚至对外开放,则是必须的。mysql

egg

做为一款比较成熟的node.js开发框架,公司toC端不少业务架构都使用了基于egg的ReactSSR,egg对于sequelize的支持仍是比较好的,提供了专门的插件来辅助使用sequelize进行DB接入。sql

sequelize

这才是本文的核心,sequelize目前能够说是目前最为成熟的node.js ORM框架了。CRUD操做不可能彻底使用SQL语句进行,这样很容易出现各类SQL漏洞,一个成熟的ORM框架能够帮咱们避免掉这些风险,而且将CRUD操做封装成对象函数方法以后,操做起来也更加方便,可是这样会提高必定的开发学习成本。数据库

开始

总体的方案都出来了,剩下的就是爬坑。因为之前仍是作过一些和数据库有关的工做,SQL语句和部分ORM的实现还有过一点接触,可是。。。我依然在坑里栽了好久,长成了参天大树,提及参,我就想到西游记里面的人参果。。。文体两开花。npm

数据模型设计

接入DB,首先须要考虑就是如何设计数据模型。固然这对于前端来讲,仍是有一些难度的。因而请教了最近在合做的后端大哥。后端

关联

通常来讲,一个系统都须要有特定的用户进行登陆。又须要对于用户进行分组,一个用户能够加入多个组,一个组能够有多个用户。n:m的关联关系须要在设计数据模型的时候就体现出来。api

生产环境中,n:m的关联是须要中间表来辅助的,来存储例如用户和用户组之间的映射关系。安全

外键仍是逻辑

关联能够经过设置foreignKey来进行关联,可是被后端大哥批斗了,为了保证数据库的性能,通常不多采用外键关联两个数据模型,而是采用逻辑关联,经过开发者人工保证写入和删除的顺序。

索引

索引是必不可少的,咱们在提交DBA工单的时候,必需要创建索引,尤为是有些数据表会存储很是多的记录,这时对于主键创建索引,能够大幅提升查找的效率。

根据上面的三个重点,完成了个人数据表设计。这里能够给出一个简单的栗子数据库模型。后面的实现也是根据这个栗子进行的。

CREATE TABLE `group` (
  `id` INTEGER PRIMARY KEY AUTO_INCREMENT,
  `name` VARCHAR(255) NOT NULL,
  `db_create_time` DATETIME,
  `db_update_time` DATETIME
);

CREATE TABLE `user` (
  `id` INTEGER PRIMARY KEY AUTO_INCREMENT,
  `name` VARCHAR(255) NOT NULL,
  `db_create_time` DATETIME,
  `db_update_time` DATETIME
);

CREATE TABLE `group_users` (
  `user` INTEGER NOT NULL,
  `group` INTEGER NOT NULL,
  CONSTRAINT `pk_group_users` PRIMARY KEY (`user`, `group`)
);

CREATE INDEX `idx_group_users` ON `group_users` (`group`);
复制代码

Egg-Sequelize实践

egg框架自己提供了不少即插即用的plugin,官方最基本的插件集中就有egg-sequelize插件。插件配置起来很是简单。

下面可能不会说的很是详细,我主要讲一讲本身在进行开发时候遇到的各类坑

项目结构

|-- app                                     // node服务端相关代码
    |-- controller
        |-- api                             // node端接口controller
            |-- group.js                    // 组相关controller
            |-- user.js                     // 用户相关controller
    |-- extend
        |-- helper.js                       // helper扩展
    |-- middleware
    |-- model                               // sequelize数据模型
        |-- user.js
        |-- group.js
        |-- group_user.js
    |-- service                             // 可复用的数据处理及查询方法
    |-- utils                               // service中拿不到helper,部分utils放在这里
    |-- router.js                           // 路由
|-- build                                   // 构建代码
|-- client                                  // 客户端相关代码
|-- config                                  // 配置文件
复制代码

启动插件

// config.local.js

module.exports = {
  sequelize: {
    // 数据库类型
    dialect: 'mysql',
    // 数据库名
    database: 'swiss',
    // 数据库IP和端口
    host: '127.0.0.1',
    port: '3306',
    // 数据库链接的用户和密码
    username: 'root',
    password: '123',
    // 是否自动进行下划线转换(这里是由于DB默认的命名规则是下划线方式,而咱们使用的大多数是驼峰方式)
    underscored: true,
    // 时区,sequelize有不少自动时间的方法,都是和时区相关的,记得设置成东8区(+08:00)
    timezone: '+08:00',
  },
}
复制代码

各类配置项一目了然,记得要设置好timezone,不然你全部默认为当前时间的值都会出错。underscored表示自动将驼峰表示法转换为mysql的下划线表示法(固然后面会说到,他的转换机制有些时候让我感受费解,但愿了解的大佬们能够帮我解释一下~)。

直接将启动项配置写死在配置文件里面不是不能够,可是若是须要和同事一块儿合做开发的话,这样写死可能不够灵活。能够将某些配置项提取出来,经过命令行传入参数,来进行开发环境的动态配置。

~ npm run dev -- --u=root --p=123

// config.local.js

let DB_USER = 'root';
let DB_PASSWORD = '123';

const ARGV_2 = JSON.parse(process.argv[2] || {});

DB_USER = (ARGV_2 && ARGV_2.u) || 'root';
DB_PASSWORD = (ARGV_2 && ARGV_2.p) || '123';

module.exports = {
    sequelize: {
        // ....
        username: `${DB_USER}`,
        password: `${DB_PASSWORD}`,
        // ....
    }
};
复制代码

model

egg-sequelize会自动将sequelize实例挂载到app.model上面,而后静态方法和属性则会直接被绑定到app上,经过app.Sequelize进行获取。

model层做为MVC的最底层,须要注意到数据模型的puremodel文件也应该是纯净的,这个文件里面应该是和数据库中的表一一对应,一个model文件对应一个DB中的表,这个文件中不该该包含任何和逻辑相关的代码,应该彻底是数据模型的定义。

// app/model/user.js

module.exports = app => {
    // egg-sequelize插件会将Sequelize类绑定到app上线,从里面能够取到各类静态类型
    const { TEXT, INTEGER, NOW } = app.Sequelize;

    const User = app.model.define(
        'user',
        {
            name: TEXT,
            createAt: {
                type: DATE,
                // 能够重写某个字段的字段名
                field: 'db_create_time',
                allowNull: false,
                defaultValue: NOW,
            },
            updateAt: {
                type: DATE,
                field: 'db_update_time',
                allowNull: false,
                defaultValue: NOW,
            },
        },
        {
            timestamps: false,
            freezeTableName: true,
            tableName: 'users',
            underscored: true,
        }
    );

    // 定义关联关系
    User.associate = () => {
        // 定义多对多关联
        User.belongsToMany(app.model.Groups, {
            // 中间表的model
            through: app.model.groupUser,
            // 进行关联查询时,关联表查出来的数据模型的alias
            as: 'project',
            // 是否采用外键进行物理关联
            constraints: false,
        });
        // 这里若是一个模型和多个模型都有关联关系的话,关联关系须要统必定义在这里
    };

    return User;
};

复制代码

上面的代码有很是多须要注意的地方,咱们经过这个文件定义了一个数据模型,这个模型能够映射到数据库中的某一个表,这里就是映射到了users表,用来存储用户信息。

  • 在默认状况下,id字段会被设置为主键,而且是AUTO_INCREMENT的,不须要咱们本身声明;
  • timestamps字段能够表示是否采用默认的createAtupdateAt字段,咱们经过field字段重写了这两个字段的字段名;
  • associate字段能够用来设置数据模型的关联关系,若是一个数据模型关联了多个数据模型,那么这个方法里面也能够定义多个关系;
  • belongsToMany表示n:m的关系映射,这个在官方文档中描述的很是清楚了;
  • as能够为这个映射设置别名,这样在进行查询的时候,获得的结果就是以别名来标识的;
  • constraints:这个属性很是重要,能够用来表示这个关联关系是否采用外键关联。在大多数状况下咱们是不须要经过外键来进行数据表的物理关联的,直接经过逻辑进行关联便可;
  • through:这个属性表示关联表的数据模型,也就是保存关联关系的数据库表的模型。

上面的这些属性,在开发过程当中多多少少都消耗了我一些时间**-1s**,模型的设置和数据库表之间的关系很是紧密,必定要保证你的数据模型和数据表之间没有歧义。

一样地,咱们能够定义到关联表和中间表的模型:

// app/model/group.js

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

    const Group = app.model.define(
        'group',
        {
            name: TEXT,
            createAt: {
                type: DATE,
                field: 'db_create_time',
                allowNull: false,
                defaultValue: NOW,
            },
            updateAt: {
                type: DATE,
                field: 'db_update_time',
                allowNull: false,
                defaultValue: NOW,
            },
        },
        {
            timestamps: false,
            freezeTableName: true,
            tableName: 'groups',
            underscored: true,
        }
    );

    // 定义关联关系
    Group.associate = () => {
        Group.belongsToMany(app.model.User, {
            through: app.model.groupUser,
            as: 'partner',
            constraints: false,
        });
    };

    return Group;
};

// app/model/group_user.js
// 中间表不须要定义关联关系

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

    const GroupUser = app.model.define(
        'group_user',
        {
            user_id: INTEGER,
            group_id: INTEGER,
        },
        {
            timestamps: false,
            freezeTableName: true,
            tableName: 'group_user',
            underscored: true,
        }
    );
    
    return GroupUser;
};
复制代码

controller

在egg中,controller模块的做用相似于MVC模式中的控制器,进行从modelview的转换,而在提供接口的时候,controller负责的是提供从modelapi的转换,通过model从数据库中查询出来的结果,将在controller里面进行包装,而后返回给接口的调用者。

在进行数据访问的时候,不少的接口请求均可以拆分为几个相似的CRUD操做,好比:

  • 我想查一个用户的注册时间;
  • 我想查一个用户的用户名; 这样相似的操做均可以经过同样的数据库操做拿到,而后再进行单独处理,这些可复用的逻辑,根据egg的建议,均可以写到service里面。而controller只负责请求的响应处理。

当一个接口请求跨过了middleware的处理,通过了router的分发以后:

// app/router.js

module.exports = app => {
    app.get('/api/user/get', app.controller.api.user.get);
    
    app.post('/api/group/set', app.controller.api.group.set);
}
复制代码

会被转发到对应的controller进行处理。

// app/controller/user.js

module.exports = class UserController extends Controller {
    async get = () => {
        const { uuid } = this.ctx.session;

        if (!uuid) {
            ctx.body = {
                code: 401,
                message: 'unauthorized',
            };
            return;
        }

        const userInfo = await this.ctx.service.user.getUserById({ id: uuid });

        if (userInfo) {
            ctx.body = {
                code: 200,
                message: 'success',
                data: userInfo
            }
        } else {
            ctx.body = {
                code: 500,
                message: 'error',
            }
        }
    }
}
复制代码

service

egg官方文档对于service的描述是这样的:

简单来讲,Service 就是在复杂业务场景下用于作业务逻辑封装的一个抽象层,提供这个抽象有如下几个好处:

  • 保持 Controller 中的逻辑更加简洁。
  • 保持业务逻辑的独立性,抽象出来的 Service 能够被多个 Controller 重复调用。
  • 将逻辑和展示分离,更容易编写测试用例。

也就是controller中要尽可能保持clean,而后,能够复用的业务逻辑被统一抽出来,放到service中,被多个controller进行复用。

咱们将CRUD操做,所有提取到service中,封装成一个个通用的CRUD方法,来提供给其余service进行嵌套的时候调用,或者提供给controller进行业务逻辑调用。

好比:读取用户信息的过程:

// app/service/user.js

module.exports = class UserService extends Service {
    // 经过id获取用户信息
    async getUserById = ({
        id,
    }) => {
        const { ctx } = this;

        let userInfo = {};
        try {
            userInfo = await ctx.model.User.findAll({
                where: {
                    id,
                },
                // 查询操做的时候,加入这个参数能够直接拿到对象类型的查询结果,不然还须要经过方法调用解析
                raw: true,
            });
        } catch (err) {
            ctx.logger.error(err);
        }

        return userInfo;
    }
}
复制代码

sequelize事务

以前有说到,在创建模型的时候,咱们创建了UserGroup之间的关联关系,而且经过了一个关联表进行二者之间的关联。

因为咱们没有创建二者之间的外键关联,因此在写入的时候,咱们要进行逻辑的关联写入。

若是咱们须要新建一个用户,而且为这个用户新建一个默认的group,因为组和用户有着多对多的关系,因此这里咱们采用belongsToMany来创建关系。一个用户能够属于多个组,而且一组也能够包含多个用户。

在创建的时候,须要按照必定的顺序,写入三张表,一旦某个写入操做失败以后,须要对于以前的写入操做进行回滚,防止DB中产生垃圾数据。这里须要用到事务机制进行写入控制,而且人工保证写入顺序。

// app/service/user.js

module.exports = class UserService extends Service {
    async setUser = ({
        name,
    }) => {
        const { ctx } = this;
        let transaction;

        try {
            // 这里须要注意,egg-sequelize会将sequelize实例做为app.model对象
            transaction = await ctx.model.transaction();

            // 建立用户
            const user = await ctx.model.User.create({
                name,
            }, {
                transaction,
            });

            // 建立默认组
            const group = await ctx.model.Group.create({
                name: 'default',
            }, {
                transaction,
            });

            const userId = user && user.getDataValue('id');
            const groupId = group && group.getDataValue('id');

            if (!userId || !groupId) {
                throw new Error('建立用户失败');
            }

            // 建立用户和组之间的关联
            const associate = await ctx.mode.GroupUser.create({
                user_id: userId,
                group_id: groupId,
            }, {
                transaction,
            });

            await transaction.commit();

            return userId;
        } catch (err) {
            ctx.logger.error(err);
            await transaction.rollback();
        }
    }
}
复制代码

经过sequelize提供的事务功能,能够将串联写入过程当中的错误进行回滚,保证了每次写入操做的原子性。

关联查询

既然咱们已经建立了关联关系,那么若是经过关联关系,查询到对应的数据库内容呢?

在多对多的关联条件下,若是咱们要查询某个用户的全部分组信息,须要经过用户id来查询其关联的全部group

// service

async getGroupByUserId = ({
    id,
}) => {
    const { ctx } = this;
    const group = await ctx.model.User.findAll({
        attributes: ['project.id', 'project.name'],
        include: [
            {
                model: ctx.model.Group,
                as: 'project',
                // 指定关联表查询属性,这里表示不须要任何关联表的属性
                attributes: [],
                through: {
                    // 指定中间表的属性,这里表示不须要任何中间表的属性
                    attributes: []
                }
            }
        ],
        where: {
            id,
        },
        raw: true,
        // 这个须要和上面的中间表属性配合,表示不忽略include属性中的attributes参数
        includeIgnoreAttributes: false,
    });
}
复制代码

经过上面的关联查询方法,能够获得这样的一条SQL语句:

SELECT `project`.`id`, `project`.`name` FROM `users` AS `user` LEFT OUTER JOIN ( `group_user` AS `project->group_user` INNER JOIN `groups` AS `project` ON `project`.`id` = `project->group_user`.`group_id`) ON `user`.`id` = `project->group_user`.`user_id` WHERE `user`.`id` = 1;
复制代码

对应的查询结果:

[ { id: 1, name: 'default' } ]
复制代码

而在一对多和多对一的关系下,其本质和多对多基本上是一致的,是在多的方向存储一个冗余字段,来保存其对应的惟一元素的主键,不管是何种关系,其默认在sequelize中实现的数据模型,都是范式化的,若是须要反范式来提升数据库效率,仍是须要本身去作冗余的。

hooks

在数据库查询的过程当中,不免须要在真正的CRUD先后进行一些数据的处理。

考虑到这样的一个场景:

在客户端,咱们前端存储的用户名并非经过name来表示的,而是经过nickName字段来进行表示的,在每次进行读写操做以前,例如这里容许用户本身修改本身的名字,当请求发送到服务端以后,交予service进行处理。

// app/model/user
const User = app.model.define({
    // ...
}, {
    hooks: {
        beforeUpdate: (user, options) => {
            const name = user.nickName;
            delete user.nickName;
            user.name = name;
        }
    }
});
复制代码

咱们在定义模型的时候,直接定义好这个hookbeforeUpdate会在User模型每次调用update以前,调用这个hook。这个hook会传入update操做传入的参数实例,能够直接对这个实例进行修改,保证明际update操做的实例是正确的。

hooks可使用的地方不少,这里只是简单介绍一下使用的方法,hooks中间也能够包含异步操做,可是要注意,若是包含异步操做的话,须要返回一个Promise。咱们还能够在进行具备反作用的操做以前,对于用户权限进行校验。

hooks的使用是须要了解到其功能,而后根据本身的业务场景,灵活地进行使用的。

总结

egg提供了很是多的可扩展空间,除了使用其做为前端页面的部署环境以外,还能够承担一些model层的工做,有兴趣的小伙伴能够试下经过egg实现先后端分离的全栈开发工做~

在实际业务场景的实践过程当中,sequelize的不少解决方案都要从官方文档中一个字一个字的查找,有些问题甚至须要去翻issue才能找到对应的处理方法,不知道为何官方文档会有那么多版的中文翻译。。实践的方案却特别少,前端的小伙伴们大部分仍是热衷于MongoDB。确实关系型数据的操做相较于NoSQL仍是比较复杂的。不过解决问题的过程虽然烦恼,可是结果总仍是愉悦的。

sequelize还有不少须要挖掘的地方,它自己提供的不少功能在此次迭代的过程当中都没有用到。好比scopemigration,有机会能够尝试下一些新的功能和实现方案。

相关文章
相关标签/搜索