查看原文
其他

告别回调地狱,在Node里优雅的访问MySQL

自然框架 脚本之家 2022-09-23
 关注
“脚本之家
”,与百万开发者在一起
作者 | 自然框架
出品 | 脚本之家(ID:jb51net)

Node.js 环境里面访问 MySQL 的默认方式,采用了古老的回调方式,这样很容易产生回调地狱。那么如何避免呢?这里介绍一种基于 Promise 的封装方式,可以避免回调地狱,并且支持事务访问。


技术栈


  • Node.js  V14.16.0
  • MySQL V 8.0.15
  • ES6
  • 基于对象

思路和结构


MySQL.help


实现基础访问


建立一个help,实现基础功能。

/**
* 基于 promise 封装 MySQL 的基本操作,支持事务
* * 创建数据库连接
* * 事务相关
* * 提交SQL给数据库执行,得到返回结果
* * 共用函数
*/

class MySQLHelp {
constructor (info) {
// 创建普通连接的参数
this._info = {
host: 'localhost',
port: '3306',
user: 'root',
password: '',
database: ''
}
// 创建池子的参数
this._infoTran = {
host: 'localhost',
port: '3306',
user: 'root',
password: '',
database: '',
connectionLimit: 20
}

// 加参数
Object.assign(this._info, info)
Object.assign(this._infoTran, info)

// 预定一个池子,用于事务
this.pool = null

console.log(' ★ 初始化:不使用事务!')
// 不使用事务,开启连接获得数据库对象,其实也支持事务
this.db = mysql.createConnection(this._info)
//启动连接
this.db.connect((err) => {
if(err) {
console.error('连接数据发生错误:', err)
} else {
console.log('★ [MySQL 已经打开数据库,threadId:]', this.db.threadId)
}
})
}

非事务模式

默认不使用事务,内部创建一个链接数据库的对象,用于实现各种操作。好吧,其实只有一个操作,提交SQL到数据库,然后等待返回结果。

其实我试了一下,这个默认的连接对象,也可以使用事务,只是看到网上提到事务,都是用 pool 的方式。所以事务用的连接对象也采用从 pool 里面获取,因为对比了一下两种连接对象,确实不太一样。

提交SQL给MySQL执行

/**
* 把 SQL 提交给数据库。支持事务。
* @param { string } sql sql语句
* @param { array } params 需要的参数,数组形式
* @param { connection } cn 如果使用事务的话,需要传递一个链接对象进来
* @returns Promise 形式
*/

query(sql, params=[], cn = this.db) {
const myPromise = newPromise((resolve, reject) => {
// 把SQL语句提交给数据库执行,然后等待回调
cn.query(sql, params, (err, res, fields) => {
if (err) { //失败
// 如果开始事务,自动回滚
if (cn !== this.db) {
cn.rollback((err) => {
console.log('执行SQL失败,数据回滚:', err)
})
}
reject(err)
return
}
resolve(res)
})
})
return myPromise
}
  • sql
    要执行的SQL语句,建议参数化的SQL。

  • params
    SQL语句的参数,强烈建议采用参数化的方式,因为可以避免SQL注入攻击。

  • cn
    连接对象,如果不用事务,则使用默认的内部连接对象;如果使用事务,则需要传递一个链接对象进来,以便于区分不同的事务操作。

  • 异常默认回滚
    如果出错,在开启事务的情况下,默认回滚事务。

MySQL的基础操作非常简单,就这一个。其他的就是事务如何开启、提交,SQL如何管理的问题。下面来一一介绍。

实现事务


建立池子,获取连接对象,然后把这个连接对象作为参数,这样就可以非常灵活的实现各种各样的操作了。

建立连接池,获取连接对象

建立连接池不是回调函数,但是从中获取连接对象却是一个回调函数,所以只好封装一个内部函数,便于后续操作。

/**
* 在池子里面获取一个链接,创建对象
*/

_poolCreateConnection() {
const myPromise = newPromise((resolve, reject) => {
console.log('初始化:使用事务')
// 如果没有池子则创建一个。好在不是异步
if (this.pool === null) {
this.pool = mysql.createPool(this._infoTran)
}
// 从池子里面获取一个链接对象,这个是异步
this.pool.getConnection((err, connection) => {
if(err) {
reject(err)
} else {
resolve(connection)
}
})
})
return myPromise
}

开启事务

因为开启事务又是一个异步操作,所以只好继续写个内部函数,实现开始事务的功能,然后再做一个对外的函数实现事务操作。

内部事务函数

/**
* 内部开启事务
*/

_beginStran(_cn) {
const myPromise = newPromise((resolve, reject) => {
_cn.beginTransaction((err) => {
if (err) { //失败
console.log('[★ ★ MySQL 开启事务时报错:] --- ', err)
reject(err)
return
}
console.log('[★ MySQL 事务已经开启:] - ')
resolve(_cn)
})
})
return myPromise
}

对外的事务函数:

/**
* 开启一个事务,Promise 的方式
*/

begin() {
const myPromise = newPromise((resolve, reject) => {
console.log('★ 开启事务,promise 模式')
this._poolCreateConnection().then((_cn) => {
// 开启事务
this._beginStran(_cn)
.then(() => {resolve(_cn)}) // 返回连接对象
.catch((err) => {reject(err)})
})
})
return myPromise
}

其实一个有三个函数,两个内部函数一个对外的函数。这么做是为了代码可以更简洁一些,看起来好看一点,否则各种回调地狱,保证你想哭。

await 方式

我们是否可以用 await 的方式实现一下开启事务的代码呢?尝试了一下,也是可以的,虽然这么做没有什么实际意义。

/**
* 开启事务,await 的方式
*/

async beginTransaction() {
console.log('★ 开启事务,await 模式')
let _cn = null
try {
_cn = awaitthis._poolCreateConnection()
awaitthis._beginStran(_cn)
} catch(e) {
console.log('async 开启事务出错:', e)
}
return _cn
}

没有了回调方式,看起来是不是舒服多了?为啥说没啥实际意义呢?因为这种方式,要求调用者也必须使用 await 的方式,有点强制性。而上面那个函数(begin)既可以用 Promise 的方式,也可以用 await 的方式,即满足需求也比较灵活。

提交事务

开启事务,执行各种操作后,需要提交事务,那么我们再做一个提交事务的功能。

/**
* 提交一个事务
* @param { connection } cn 开启事务时创建的连接对象
*/

commit(_cn) {
const myPromise = newPromise((resolve, reject) => {
// 提交事务
_cn.commit((err) => {
if(err) {
console.log('事务提交失败', err)
reject(err)
} else {
resolve()
}
})
})
return myPromise
}

关闭连接、归还连接对象

如果没有开始事务,直接关闭连接即可,如果开启事务,需要把链接对象放回池子。二者写法有点差别。

/**
* 关闭数据库
* @param { connection } cn 开启事务时创建的连接对象
*/

close(_cn = null) {
if (_cn !== null ) {
// 归还连接对象。
_cn.release()
} else {
// 关闭连接
this.db.end((err) => {
if(err) {
console.error('关闭连接发生错误:', err)
} else {
console.log('\n[MySQL 已经关闭数据库:]\n')
}
})
}
}

关闭连接池

如果开启事务的话,还需要关闭连接池。

/**
* 关闭池子
*/

closePool() {
this.pool.end((err) => {
if(err) {
console.error('关闭连接发生错误:', err)
} else {
console.log('\n[MySQL 已经关闭连接池:]\n')
}
})
}

封装SQL语句


核心操作,上面的 help 就可以实现了,下面要封装SQL,避免手撸SQL的尴尬。思路有点像ORM,但是又不完全是ORM,采用 meta + model 的形式拼接参数化的SQL语句。

insert —— addModel

从SQL语句的角度来看,是 insert into table,从对象的角度来看,就是添加了一个model。不管怎么说,都是需要一个SQL语句才行,如果手撸的话,既麻烦又容易出错,那么怎么办呢?我们可以先设定一个 meta 进行描述,然后写个函数拼接即可。

  • meta
{
"tableName": "node_user",
"idKey": "id",
"cols": {
"name": "",
"age": ""
}
}

由表名、主键字段名、需要的字段集合组成,这个看起来好像是一个表、字段的结构,其实并不是,区别在于字段集合的组成方式。

一般情况是基于表建立的 model,需要把表的字段都加上,不能少。

但是这里的 meta 并不要求把表里面的字段都加上,而是根据业务需求设置字段,业务需要哪些字段就放置哪些字段,不需要的可以不放。

另外一个表可以设置多种这样的 meta,完全依据业务需求来决定。

  • 实现代码

/data-add.js

/**
* 实现添加数据的功能。拼接 insert 的 SQL语句
* @param { MySQLHelp } help 访问数据库的实例
* @param { Object } meta 表、字段
* @param { Object } model 数据
* @param { connection } cn 如果使用事务的话,需要传递开启事务时创建的连接对象
* @returns 添加记录的ID
* * meta 结构:
* * * tableName:'', 表名
* * * cols:{colName: '类型'}, josn 字段需要标记
* * model 结构:
* * * colName: value
*/

function addData(help, meta, model, cn = null) {
// 拼接添加用的SQL语句,
// 提交SQL语句
const myPromise = newPromise((resolve, reject) => {
// sql = 'INSERT INTO aaa set aaacol = ? '
// 获取字段名称和值的数组
const { colNames, params } = help.createTableCols(meta, model)
const sql = `INSERT INTO ${meta.tableName} SET ${colNames.join(',')} `
console.log('addData --- sql:', sql, params)
const _cn = cn === null ? help.db : cn
help.query(sql, params, _cn)
.then((res) => {
// console.log('添加数据成功:', res.insertId)
resolve(res.insertId)
})
.catch((res) => {
// 出错了
reject(res)
})
})
return myPromise
}
module.exports = addData

如果使用事务的话,需要传递一个链接对象进来,否则使用内部默认的连接对象。

  • help  实现基础功能的实例。
  • meta  上面说到的表、字段的描述
  • model  要添加的数据

update

同上(代码雷同就不贴了),拼接 update 的SQL语句,加上where即可。

每一个小功能都做成了独立的 js 文件。其实一开始想做一个大的class,后来觉得代码太长不好维护,于是想做成子类的形式,但是想想似乎没啥必要,除了少传递一个help参数之外好像没啥区别。所以最后决定采用这种方式,

delete

同上,只需要表名和主键字段即可。

/**
* 实现删除数据的功能。拼接 DELETE FROM 的 SQL语句
* @param { MySQLHelp } help 访问数据库的实例
* @param { Object } info 表、字段
* @param { number|string } id 数据
* @returns 影响的记录数
* * info 结构:
* * * tableName:'', 表名
* * * idKey '', 主键名称
* * * cols:{colName: '类型'}, josn 字段需要标记
* * id :number | string
*/

function deleteData(help, info, id, cn = null) {
// 拼接 修改 用的SQL语句,
const myPromise = newPromise((resolve, reject) => {
const sql = `DELETE FROM ${info.tableName} WHERE ${info.idKey} = ? `
const _cn = cn === null ? help.db : cn
help.query(sql, [id], _cn)
.then((res) => {
// 删除成功,返回影响行数
resolve(res.affectedRows)
})
.catch((res) => {
// 出错了
reject(res)
})
})
return myPromise
}
module.exports = deleteData

select 获取 model

同上,拼接 select

分页和查询

使用limit来分页。其他分页方式待定。

/**
* 分页获取数据,可以查询
* @param { MySQLHelp } help 访问数据库的实例
* @param { Object } info 表、字段
* @param { Object } query 查询条件
* @param { Object } pager 数据
* @returns 添加记录的ID
* * info 结构:
* * * tableName:'', 表名
* * * cols:{colName: '类型'}, 需要显示的字段
* * query 结构(查询条件):
* * * { colName: [401, 11] } 字段名称,查询方式,查询关键字
* * pager 结构:
* * * pageSize: 20 // 一页显示多少条记录
* * * orderBy: { id: false } // 排序字段:字段名,false表示倒序。
* * * pageTotal: 100 // 符合查询条件的总记录数
* * * pageIndex: 1 // 显示第几页的记录,从 1 开始
*/

asyncfunction getPager(help, info, query, pager) {
console.log('开始分页 :')
const myPromise = newPromise((resolve, reject) => {
// 查询条件和查询参数
const { whereQuery, whereValue } = help._getWhereQuery(query)
// 设置排序和分页
const { orderBy, limit } = help._getPager(pager)

// 设置显示的字段
const showCol = Object.keys(info.cols)
if (showCol.length === 0 ) {showCol.push('*')}

// 拼接查询语句
const sql = `SELECT ${showCol.join(',')} FROM ${info.tableName} ${whereQuery} ${orderBy} ${limit}`
console.log('select-sql:', sql, whereValue)

help.query(sql, whereValue)
.then((res) => {
// 添加成功
resolve(res)
})
.catch((err) => {
// 出错了
console.log('分页获取记录失败了:', err)
reject(err)
})
})

return myPromise
}


function getCount(help, info, query) {
returnnewPromise((resolve, reject) => {
// 查询条件和查询参数
const { whereQuery, whereValue } = help._getWhereQuery(query)
// 统计总数
const sql = `SELECT count(1) as count FROM ${info.tableName} ${whereQuery} `
console.log('count-sql:', sql, whereValue)
help.query(sql, whereValue).then((re) => {
resolve(re[0].count)
}).catch((err) => {
// 出错了
console.log('统计总记录数失败了:', err)
reject(err)
})
})
}

module.exports = {
getCount,
getPager
}

其他待定

以上仅仅是基础的增删改查的封装,SQL非常灵活,还有 group by、 级联查询、子查询等各种形式,这些以后再说。

因为每个功能都做成了单独的js文件,这样就可以遵守“对扩展开放对修改关闭”的开闭原则。

就是说,可以很方便的做扩展。

使用方式和测试


封装完毕,那么要如何使用呢?

MySQL使用与测试.png

使用和测试分为三个维度:是否使用事务、单条还是多条、是否await

  1. 单条操作,无事务;
  • 添加、修改、删除获取的基础操作。
  • 分页、查询等基础操作。
  • 多条操作,有事务;
    • 批量添加
    • 主从添加
    • 模拟转账
    • 添加、修改、删除
    • 回滚测试
  • 事务的开启方式
    • Promise 模式
    • await 模式
    • 多事务同时开始,测试干扰性

    由于篇幅有限,这里先介绍基础使用方式。

    基本使用方式


    基于 model 的添加、修改、删除和获取 这里说的 model 是基于业务需求建立的,而不是基于表建立的。

    首先引入封装的函数以及配置信息,建立实例:

    // 引入help
    const {
    MySQLHelp,
    addModel,
    updateModel,
    deleteModel,
    getModel
    } = require('../packages/mysql.js')

    // 配置信息
    const config = require('../public/config/mysql-test.json')

    // 实例
    const help = new MySQLHelp(config)

    然后建立实现添加、修改、删除和获取的函数:

    // meta
    const meta = require('../public/model/10010-user.json')

    let newDataId = 0

    // 添加数据的测试
    const test_addData = (number) => {
    console.log('\n---- addModel ----')
    returnnewPromise((resolve, reject) => {
    // 要添加的数据
    const model = {
    name: '测试基础添加数据-A-11',
    age: number
    }

    // meta.tableName = 'a'
    addModel(help, meta, model).then((newId) => {
    // 添加成功
    console.log('外部添加新数据:', newId)
    newDataId = newId
    resolve()
    })
    })
    }


    // 修改数据的测试
    const test_updateData = () => {
    console.log('\n---- updateModel ----')
    returnnewPromise((resolve, reject) => {
    // 要修改的数据
    const model = {
    name: '测试基础添加数据-A-11--修改了',
    age: 300
    }

    // meta.tableName = 'a'
    updateModel(help, meta, model, newDataId).then((count) => {
    // 添加成功
    console.log('修改数据,影响行数:', count)
    resolve()
    })
    })
    }

    // 删除数据的测试
    const test_deleteData = () => {
    console.log('\n---- deleteModel ----')
    returnnewPromise((resolve, reject) => {
    // meta.tableName = 'a'
    deleteModel(help, meta, newDataId).then((count) => {
    // 添加成功
    console.log('删除数据,影响行数:', count)
    resolve()
    })
    })
    }


    // 获取数据的测试
    const test_getData = () => {
    console.log('\n---- updateModel ----')
    returnnewPromise((resolve, reject) => {
    // meta.tableName = 'a'
    getModel(help, meta, newDataId).then((model) => {
    // 添加成功
    console.log('获取model:', model)
    resolve(model)
    })
    })
    }
    • addModel
      传入需要的参数,然后可以得到新添加的记录的主键ID。

    依次执行的测试


    test_addData('51').then(() => {
    // 修改
    return test_updateData()
    }).then(() => {
    // 获取
    return test_getData()
    }).then(() => {
    // 删除
    return test_deleteData()
    }).then(() => {
    help.close()
    })

    执行结果:

    运行结果

    分页和查询


    分页信息和查询条件

    {
    "tableName": "node_user",
    "idKey": "id",
    "cols": {
    "id": "",
    "name": "",
    "age": ""
    },
    "pager": {
    "pagerTotal": 100,
    "pagerSize": 2,
    "pagerIndex": 1,
    "orderBy": { "id": false }
    },
    "query": {
    "name":[401, 402, 403],
    "age":[401]
    }
    }
    // meta
    const meta = require('../public/model/10050-user-find.json')

    const pager = meta.pager

    // 查询条件
    const query = {
    name: [403, 'a']
    }

    // 获取分页数据
    const test_getPager = () => {
    console.log('\n---- getPager ----')
    returnnewPromise((resolve, reject) => {
    getPager(help, meta, query, pager).then((list) => {
    // 获取列表
    resolve(list)
    })
    })
    }

    // 获取总记录数
    const test_getCount = () => {
    console.log('\n---- getCount ----')
    returnnewPromise((resolve, reject) => {
    getCount(help, meta, query).then((count) => {
    // 获取总记录数
    console.log('外部获取总记录数:', count)
    resolve(count)
    })
    })
    }

    const test = async (canCount) => {
    if (canCount) {
    pager.pageTotal = await test_getCount()
    }

    // 开始测试
    test_getPager().then((list) => {
    // 关闭
    help.close()
    })
    }

    test(true)

    这里可以灵活的设置查询条件,以及具体的分页方式。还可以根据需求设置是否需要统计总记录数。

    运行结果:

    分页结果

    事务的用法


    简单地说就是开启事务,执行操作,提交事务。也就是在上面那些代码的外面套上事务即可。

    // 循环添加
    help.begin().then((cn) => {
    const arr = []
    for (let i=100; i<110;i++) {
    arr.push(test_addData(i, cn))
    }

    Promise.all(arr).then((res) => {
    console.log('事务的批量添加:', res)
    // 提交事务
    help.commit(cn).then(() => {
    console.log(' + + + promise 提交事务的回调:')
    // 关闭
    help.close(cn)
    help.closePool()
    })
    })
    })

    await模式开启事务

    asyncfunction check() {
    const cn = await help.begin()

    // 添加数据
    await test_addData('51', cn)
    // 获取记录进行验证
    const model1 = await test_getData(cn)
    console.log('(await 添加)name :', model1[0].name === '测试基础添加数据-A-11')
    console.log('(await 添加)age :', model1[0].age === 51)

    // 修改数据
    await test_updateData(cn)
    // 获取记录进行验证
    const model2 = await test_getData(cn)
    console.log('(await 修改)name :', model2[0].name === '测试基础添加数据-A-11--修改了')
    console.log('(await 修改)age :', model2[0].age === 300)

    // 修改数据
    await test_deleteData(cn)
    // 获取记录进行验证
    const model3 = await test_getData(cn)
    // 验证数据
    console.log('删除!', model3.length === 0)

    await help.commit(cn)
    help.close(cn)
    help.closePool()
    }

    check()

    最后需要注意的是“事务的并发性”。js是单线程的,但是MySQL是多线程的。所以如果同时开启两个事务的话,虽然可以通过两个独立的连接对象加以区分,提交事务的时候不会干扰,但是添加、修改的顺序却无法独立,会出现交叉的情况

    源码:


    https://gitee.com/naturefw/node-services

    https://gitee.com/naturefw/node-services/tree/master/packages

    本文作者:自然框架

    个人网址:jyk.cnblogs.com

    声明:本文为 脚本之家专栏作者 投稿,未经允许请勿转载。

    写的不错?赞赏一下

    长按扫码赞赏我

      推荐阅读:

    让你 nodejs 水平暴增的 debugger 技巧

    关于NodeJS工作原理的五个误解

    使用了这个数据库神器,让我工作效率提升了数倍

    35 张图带你 MySQL 调优

    47 张图带你 MySQL 进阶!!!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存