後端搭起大體的框架後,接著涉及到的就是如何將資料持久化的問題,也就是對資料庫進行 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 熟悉,原生開發效率高。
連線池
連線資料庫我選用 連線池的方式,這種方式能高效的利用資料庫連線
//dbPool.js
const mysql = require('mysql');
const dbconfig = require('../config/db');
const log = require('../common/logger');
let pool = null;
/**
* get the connection pool of database
* 獲取資料庫連線池
*/
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',//utf8mb4才能儲存emoji
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');
/**
* export named query function
*/
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) => {// fields is useless
if (err) reject(err)
else resolve(result);
});
});
return next;
}, {});
複製程式碼
userDao檔案為例,使用 exportDao 直接就能把裡面的 key-value 物件輸出為 以key 為方法名的dao方法,掛載到 module.exports 下。
const { exportDao } = require('./common');
//直接就exports裡面的key值對應的方法
module.exports = exportDao({
sql: null,// 有些時候需要直接寫sql
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 ?'
});
/* 最終輸出格式
module.exports = {
sql:() => {},
count:() => {},
...
}*/
複製程式碼
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'
]);
/**
* sql transaction 封裝後的事務
* @param {Array} list
*/
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) {//finally commit
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([// 非使用者輸入的 id,沒有使用 escape 進行轉換。
['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='使用者訊息連線表';
-- user表insert觸發器
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
$$
-- user表update觸發器
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
$$
-- message表insert觸發器
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,請繼續關注。