后端搭起大体的框架后,接着涉及到的就是如何将数据持久化的问题,也就是对数据库进行 CURD 操作。
关于数据库方案, mongodb 和 mysql 都使用过,但我选用的是 mysql ,原因:
目前为止 mysql 与 mongodb 性能相差不大,尤其是 mysql 8.0 版本,速度非常快,查询数据是 mysql 更快,写数据方面 mongodb 则更胜一筹;
mysql 建立 关联数据要更方便些,比如: 一对多,多对多的关系;
mysql 作为关系型数据库,数据一致性方面更好,尤其是事务用起来更顺手;
本人对 sql 操作比较得心应手,毕竟大部分项目用得都是 mysql ,而 mongodb 在正式些的项目上用的就少了,而且目前关系型数据库也在进化, postgrep 和 mysql 都已经支持 json了。
node-mysql node-mysql 是用 sql 语句对 mysql 进行操作的库, 并没有使用 Sequelize 这种 orm。因为我对 sql 熟悉,原生开发效率高。
连接池 连接数据库我选用 连接池的方式,这种方式能高效的利用数据库连接
const mysql = require ('mysql' );const dbconfig = require ('../config/db' );const log = require ('../common/logger' );let pool = null ;exports.getPool = function ( ) { if (!pool) { log.info("creating pool" ); pool = mysql.createPool(dbconfig); } return pool; }
数据库配置文件 emoji 格式要用 utf8mb4 格式存储,所以这里连接字符集选用 utf8mb4 ,当然客户端和数据结果集 一样也要设置为 utf8mb4 。
module .exports={ host: "localhost" , port: "3306" , user: "root" , password: "jeff" , database: "chatdb" , charset : 'utf8mb4' , multipleStatements: true , connectionLimit: 100 };
Dao的编写 基本的代码编写方式如下,每个方法基本都是这么一种流程,获取数据库连接,执行 sql 语句,返回结果,处理异常。
exports.queryInfo = function (params, callback ) { pool.query('select ...' , params, function (error, result, fields ) { if (error) { log(error); callback(false ); } else callback(result) }); }
exportDao 这造成了一大堆重复的样板代码,我们需要封装它,用 JavaScript 高阶函数特性 很容易就能实现,同时加上 Promise,调用时就能方便地用 async await 了,还有日志记录功能也加上。
const pool = require ("./dbPool" ).getPool();const log = require ('../common/logger' );const exportDao = opts => Object .keys(opts).reduce((next, key ) => { next[key] = (...args ) => new Promise ((resolve, reject ) => { if (opts[key]) args.unshift(opts[key]); log.info('====== execute sql ======' ) log.info(args); pool.query(...args, (err, result, fields) => { if (err) reject(err) else resolve(result); }); }); return next; }, {});
userDao文件为例,使用 exportDao 直接就能把里面的 key-value 对象输出为 以key 为方法名的dao方法,挂载到 module.exports 下。
const { exportDao } = require ('./common' );module .exports = exportDao({ sql: null , count: 'select count(*) as count from user where ?' , getUser: 'select * from user where ?' , insert: 'insert into user set ?' , update: 'update user set ? where id = ?' , delete : 'delete from user where ?' });
transaction 还有事务 transaction 的功能需要用到,来看一下 node-mysql 官方的例子,层层回调😢,如果每个业务都要这样编写,简直不能忍,我们还是手动封装下事务吧。
pool.getConnection(function (err, connection ) { if (err) { throw err; } connection.beginTransaction(function (err ) { if (err) { throw err; } connection.query('INSERT INTO posts SET title=?' , title, function (error, results, fields ) { if (error) { return connection.rollback(function ( ) { throw error; }); } var log = 'Post ' + results.insertId + ' added' ; connection.query('INSERT INTO log SET data=?' , log, function (error, results, fields ) { if (error) { return connection.rollback(function ( ) { throw error; }); } connection.commit(function (err ) { if (err) { return connection.rollback(function ( ) { throw err; }); } console .log('success!' ); }); }); }); }); });
下面就是封装好的事务方法,调用参数为数组,数组项既可以是 sql 字符串,也可以是 node-mysql 中的带参数传值的数组,这才是给人用的嘛,心情好多了。推荐还是用 参数化传值,这样可以避免 sql 注入,如果确实要用sql字符串,可以调用 mysql.escape 方法对 参数 进行转换。
const rets = await transaction([ ["insert into user_group values (?,?)" ,[11 ,11 ]], ["insert into user_friend set ? " ,{user_id :'12' ,friend_id :12 }], 'select * from user' ]); const transaction = list => { return new Promise ((resolve, reject ) => { if (!Array .isArray(list) || !list.length) return reject('it needs a Array with sql' ) pool.getConnection((err, connection ) => { if (err) return reject(err); connection.beginTransaction(err => { if (err) return reject(err); log.info('============ begin execute transaction ============' ) let rets = []; return (function dispatch (i ) { let args = list[i]; if (!args) { connection.commit(err => { if (err) { connection.rollback(); connection.release(); return reject(err); } log.info('============ success executed transaction ============' ) connection.release(); resolve(rets); }); } else { log.info(args); args = typeof args == 'string' ? [args] : args; connection.query(...args, (error, ret) => { if (error) { connection.rollback(); connection.release(); return reject(error); } rets.push(ret); dispatch(i + 1 ); }); } })(0 ); }); }); }) }
controller 调用 dao 都封装完毕,最后就是调用, 就以apply控制器为例,其中的 apply 方法是普通调用,accept 方法则使用了事务进行处理。
const { stringFormat } = require ('../common/util' )const { transaction } = require ('../daos/common' )const applyDao = require ('../daos/apply' )exports.apply = async function (ctx ) { const form = ctx.request.body; const token = await ctx.verify(); const ret = await applyDao.apply({ ...form, from_id : token.uid }); if (!ret.affectedRows) { return ctx.body = { code: 2 , message: '申请失败' }; } ctx.body = { code: 0 , message: '申请成功' , data:ret.insertId }; } exports.accept = async function (ctx ) { const { id, friend_id } = ctx.request.body; const token = await ctx.verify(); const ret = await transaction([ ['update apply set status = 1 where id = ? and to_id = ?' , [id, token.uid]], stringFormat("replace into user_friend values ('$1','$2'),('$2','$1')" , token.uid, friend_id) ]); if (!ret[0 ].affectedRows || !ret[1 ].affectedRows) { return ctx.body = { code: 2 , message: '添加好友失败' }; } ctx.body = { code: 0 , message: '添加好友成功' }; }
sql脚本 当然还需要定义数据结构,有很多工具可以方便建表和建生产sql,这里以部分表为例,项目使用到的表要多得多。我这里还写了些无关紧要的触发器处理 数据插入时间 和数据修改时间,这是个人的习惯。完全可以不用触发器,直接在代码里面赋值,不影响使用。有用到 emoji 的数据表,记得要用 utf8mb4 格式。
create database if not exists chatdb;use chatdb;drop table if exists `user` ;CREATE TABLE `user` ( `id` char (36 ) NOT NULL DEFAULT '' COMMENT '主键' , `name` varchar (50 ) DEFAULT NULL COMMENT '用户名' , `num` int (8 ) DEFAULT NULL COMMENT '用户号码' , `salt` varchar (13 ) DEFAULT NULL COMMENT '加密的盐' , `hash_password` varchar (64 ) DEFAULT NULL COMMENT '加密后的密码' , `email` varchar (50 ) NOT NULL COMMENT 'email地址' , `nick` varchar (50 ) DEFAULT NULL COMMENT '昵称' , `avatar` varchar (200 ) DEFAULT NULL COMMENT '头像' , `signature` varchar (200 ) DEFAULT NULL COMMENT '个性签名' , `status` tinyint (1 ) DEFAULT 0 COMMENT '状态(0 离线 1 在线 2 隐身)' , `is_admin` tinyint (1 ) DEFAULT 0 COMMENT '是否管理员' , `is_block` tinyint (1 ) DEFAULT 0 COMMENT '是否禁用' , `create_date` int (10 ) unsigned DEFAULT NULL COMMENT '注册时间' , `update_date` int (10 ) unsigned DEFAULT NULL COMMENT '更新时间' , PRIMARY KEY (`id` ) ) ENGINE =InnoDB DEFAULT CHARSET =utf8mb4 COLLATE =utf8mb4_general_ci COMMENT ='用户表' ; drop table if exists `message` ;CREATE TABLE `message` ( `id` int (11 ) NOT NULL AUTO_INCREMENT COMMENT '主键' , `content` text NOT NULL COMMENT '内容' , `type` tinyint (1 ) DEFAULT 0 COMMENT '类型(0 用户 1 组群)' , `send_id` char (36 ) NOT NULL COMMENT '发送用户id' , `receive_id` char (36 ) DEFAULT NULL COMMENT '接收用户id' , `group_id` int (11 ) DEFAULT NULL COMMENT '组id' , `create_date` int (10 ) unsigned DEFAULT NULL COMMENT '创建时间' , PRIMARY KEY (`id` ) ) ENGINE =InnoDB DEFAULT CHARSET =utf8mb4 COLLATE =utf8mb4_general_ci COMMENT ='消息表' ; drop table if exists `user_message` ;CREATE TABLE `user_message` ( `user_id` char (36 ) DEFAULT NULL COMMENT '接收用户id' , `send_id` char (36 ) NOT NULL COMMENT '发送用户id' , `message_id` int (11 ) NOT NULL COMMENT '消息id' , `is_read` tinyint (1 ) DEFAULT 0 COMMENT '是否读过(0 没有 1 读过)' , PRIMARY KEY (`send_id` ,`message_id` ) ) ENGINE =InnoDB DEFAULT CHARSET =utf8 COMMENT ='用户消息连接表' ; delimiter $$ create trigger `user_insert` before insert on `user` for each ROW begin if (new.id = '' or new.id is null ) then set new.id = uuid (); end if ;if (new.num = 0 or new.num is null) then set new.num = 1000 ; end if ;if (new.`create_date` = 0 or new.`create_date` is null) then set new.`create_date` = unix_timestamp (now ()); end if ;if (new.`update_date` = 0 or new.`update_date` is null) then set new.`update_date` = unix_timestamp (now ()); end if ;END $$ delimiter $$ create trigger `user_update` before update on `user` for each ROW begin if ((new.`name` <> old.`name` ) or (new.`name` is not null and old.`name` is null ) or (new.`email` <> old.`email` ) or (new.`email` is not null and old.`email` is null ) or (new.`nick` <> old.`nick` ) or (new.`nick` is not null and old.`nick` is null ) or (new.`avatar` <> old.`avatar` ) or (new.`avatar` is not null and old.`avatar` is null ) or (new.`signature` <> old.`signature` ) or (new.`signature` is not null and old.`signature` is null )) then set new.`update_date` = unix_timestamp (now ()); end if ;END $$ delimiter $$ create trigger `message_insert` before insert on `message` for each ROW begin if (new.`create_date` = 0 or new.`create_date` is null ) then set new.`create_date` = unix_timestamp (now ()); end if ;END $$
后续 接着就是我们的大前端部分了,将会用到 vue,vuex,请继续关注。