案例-Koa2+MySQL 开发旧岛项目
- L160 - Node.js+Koa2+MySQL 打造前后端分离精品项目《旧岛》
开发过程:搭建基础框架,设计数据库,编写接口,测试接口,和小程序联调。
技术点
- 直接在
Debug
模式中开发,方便调试 - 构建完整的框架,大大提高业务效率
require-directory
工具:自动加载全部路由,提高注册效率- 校验 API 参数工具
- 全局异常处理,统一错误信息
- 使用面向对象的方式编写异常返回
Sequelize
使用类的方式来写,在models
中操作数据库,但不要定义constructor
,否则会出错,操作数据库时会有很多奇怪的问题bcrypt
密码加密jsonwebtoken
用户登录生成token
- 框架会自动序列化数据
dataValues
并返回 - 数据库事务
- 数据缓存,前端缓存,服务器缓存
redis
- 避免循环查询数据库
- 小心
models
中的模块循环导入 - 将返回的
JSON
数据序列化,只返回需要的字段传给前端 token
在小程序中过期后,无感知刷新token
- 双令牌保证无感知登录
- 处理静态资源的访问
- 服务端部署
- CMS 架构思想
工具包
require-directory
: 自动加载lodash
: 基础工具包bcrypt
: 加密validator.js
: 验证参数jsonwebtoken
: 生成 tokenbasic-auth
: 解析 tokenutil.format
: node.js 自带的工具方法axios
: 发送请求的工具
面向对象的服务端思维
- 多个版本的接口兼容
- 开闭原则,修改关闭,扩展开发。最好是扩展新的功能,而不是修改原有的功能。
业务分层:
业务逻辑写在 Model 层,MVC 中的 M
- 在 Model 中还可以再分出 Service 层
在 Sequelize 模型中操作数据库
知识点串讲
基础框架要做好,要有一个得心应手的好框架。
通过邮箱密码显示注册,是为了获得唯一标识,然后再去登录。 在小程序中,微信已经帮我们做了显示注册这一步,生成了唯一标识符 openid,我们可以直接使用。
不建议使用 openid 用做用户的 uid,一个原因是比较长,二个原因是会泄露用户信息。
前端做缓存可以有效提高服务器性能。
数值参数传输,使用body
传递服务器可以拿到Number
类型,使用path
传递拿到的是String
类型。
代码中如果只有两处的逻辑相同,没必要提取成函数封装,就直接写两处,应该结合整体的逻辑考虑,如果有三处及以上相同的逻辑可以考虑封装成一个函数。
中间层
- 双层结构:前端 + 服务端
- 三层结构:前端 + 后端 + 服务端
- 前端自己编写 API
获取 api 参数
共有四种形式:
- path: ctx.params
- query = ctx.request.query
- body = ctx.request.body
- headers = ctx.request.header
获取 body 参数需要使用koa-bodyparser
中间件。
校验 api 参数
- 使用
LinValidator
工具 - 编写验证器
- 通用验证工具: validator.jsopen in new window
在验证器里面,调用数据库查询邮箱是否重复,提前过滤,throw 错误。
中间件只在应用程序启动时初始化一次
如果用中间件
的形式编写,全局只会执行一次,只有一个实例化new
,多个请求下属性会冲突会有问题。最好的方式是每个请求都需要一个实例化new
。
异常处理
函数异常处理:
在函数内判断异常,需要抛出异常throw new Error()
,符合编程规范,也为了更好的解决问题。
在调用第三方库的时候,一定要加入try-catch
。
function fnc() {
try {
} catch (error) {
throw error;
}
}
2
3
4
5
6
全局异常处理:
编写一个中间件,注册到路由中,监听错误,输出一段有意义的提示信息。
添加了全局异常处理之后,就不需要在每个函数中都加try-catch
,只需要抛出throw new Error()
。
const catchError = async (ctx, next) => {
try {
await next();
} catch (error) {
ctx.body = {
msg: error.msg,
error_code: error.errorCode,
request: `${ctx.method} ${ctx.path}`,
};
ctx.status = error.code;
}
};
2
3
4
5
6
7
8
9
10
11
12
Sequelize 操作数据库
- 需要捕获 Sequelize 的异常。
init
定义模型,直接在模型中操作数据库
使用绝对不要在自定义模型中添加构造函数constructor
,Sequelize 在操作数据库的时候会有奇怪的错误,可以直接使用静态方法,传入指定参数。
const { Sequelize, Model } = require('sequelize');
// 定义用户模型,可以在里面添加静态方法操作数据库
class User extends Model {
// 查询是否存在 opendid 的小程序用户
static async getUserByOpenid(openid) {
// 查询用户
const user = await User.findOne({
where: {
openid,
},
});
return user;
}
}
// 初始用户模型
User.init(
{
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
},
nickname: Sequelize.STRING,
openid: {
type: Sequelize.STRING(64),
unique: true,
},
},
{
sequelize,
tableName: 'user',
}
);
// await User.getUserByOpenid('')
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
用户登录 token
生成 token 令牌,使用jwt
,不用session
。将 uid 放入 jwt 中作为 token。不要把 uid 直接传给前端,容易泄露,非常危险,应该放入 token 中一起传输,再从中解析出来使用。
写一个接口,用来颁布令牌。这在 web 开发中非常通用。
API 权限,请求需要携带 token 放在 header 中。
使用basic-auth
方式,小程序传输的时候需要用 base64 工具包加密 token。
区分登录类型:
- 邮箱密码登录
- 小程序登录
- 手机号登录
JS 模拟枚举,区分类型
function isThisType(val) {
for (let key in this) {
if (this[key] === val) {
return true;
}
}
return false;
}
const LoginType = {
USER_MINI_PROGRAM: 100,
USER_EMAIL: 101,
USER_MOBILE: 102,
ADMIN_EMAIL: 200,
isThisType,
};
LoginType.isThisType(101); // true
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
生成 token 令牌
- 安装
jsonwebtoken
- 配置
secretKey
:密钥 - 配置
expiresIn
:过期时间
token 中包含:uid、scope
const jwt = require('jsonwebtoken');
// 颁发令牌
const generateToken = function (uid, scope) {
const token = jwt.sign(
{
uid,
scope, // 权限
},
secretKey,
{
expiresIn,
}
);
return token;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
API 权限
给接口添加权限,请求需要携带 token。
编写一个中间件来管理。
使用basic-auth
工具解析前端传过来的 token: Authorization: Basic base64(account:password)
校验 token 令牌
const jwt = require('jsonwebtoken');
try {
var decode = jwt.verify(tokenToken.name, secretKey);
// decode 里面有自定义数据,uid、scope,可以存储到全局使用
} catch (error) {
// token 不合法 过期
throw new global.errs.Forbidden(errMsg);
}
2
3
4
5
6
7
8
9
权限问题
这是最难也是最复杂的问题。
给不同的角色用不同的数字来表示。设计成,权限越低使用的数字越小,权限越高使用的数字越大,比如:
- 普通用户:8
- 管理员:16
- 超级管理员:32
这样设计的好处是只要做一个数字大小的判断,就能确定对方是否有权限访问当前接口。 比如:当前接口需要的最低权限是10
,普通用户就不能访问,管理员以上都能访问。
微信小程序登录
流程:
- 小程序端用户点击按钮授权,拿到 code,传给服务端。
- 服务器接受到 code 之后,去微信服务器获取 openid,写入到用户表中,用表中的 uid 生成 token,返回给小程序端。
- 小程序端拿到 token 之后,保存到本地,给以后的每个接口请求都携带上 token。
- 服务端接受到请求之后,验证 token 是否合法,是否有权限访问,再返回相应的结果。
服务端:
const util = require('util');
const axios = require('axios');
async function codeToToken(code) {
// 格式化字符串
const url = util.format(
global.config.wx.loginUrl,
global.config.wx.appId,
global.config.wx.appSecret,
code
);
const result = await axios.get(url);
if (result.status !== 200) {
throw new global.errs.AuthFailed('openid获取失败');
}
const errCode = result.data.errcode; // 0:请求成功,其它数字都是失败
const errMsg = result.data.errmsg;
if (errCode) {
throw new global.errs.AuthFailed('openid获取失败: ' + errMsg);
}
// 建立档案 user uid
// 判断数据库是否存在微信用户 opendid
let user = await User.getUserByOpenid(result.data.openid);
// 如果不存在,就创建一个微信小程序用户
if (!user) {
user = await User.createUserByOpenid(result.data.openid);
}
return generateToken(user.id, Auth.AUSE);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
小程序端:
wx.login({
success: (res) => {
if (res.code) {
wx.request({
url: 'http://localhost:3000/v1/token',
method: 'POST',
data: {
account: res.code,
type: 100,
},
success: (res) => {
const code = res.statusCode.toString();
if (code.startsWith('2')) {
wx.setStorageSync('token', res.data.token);
}
},
});
}
},
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
无感知刷新 token
小程序每次启动会生成一个 token,如果 2 小时过期,用户在小程序中停留了 2 小时,token 过期之后,需要无感知自动重新刷新 token。
使用二次重发机制。
在小程序端处理,判断code
,如果是403
就重新去生成 token,再重新去掉一下用户上一次的接口。
wx.request({
url: url,
method: method,
data: data,
header: {
'content-type': 'application/json',
Authorization: this._encode(),
},
success: (res) => {
if (res.code == '403') {
var token = new Token();
token.getTokenFromServer((token) => {
this._request(...param, true);
});
}
},
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
双令牌保证无感知登录
适用于需要用户名和密码登录去获取令牌。
小程序不需要输入账号密码,处理起来可以比较容易,但是网站和 APP 需要,有两种方案:
- 缓存用户的账号和密码在本地,下次获取令牌的时候,携带上去。这种做法不太好。
- 双令牌方式是最主流的方式。有
access_token
和refresh_token
,使用refresh_token
去获取最新的令牌。假如access_token
过期时间是 2 小时,refresh_token
过期时间是 1 个月,每次获取到最新的access_token
,都再次重置refresh_token
的过期时间为 1 个月。这样可以保证用户一个月内访问过 APP,就不需要再输入账号和密码。如果 1 个月没有访问refresh_token
过期,那就要求用户输入账号和密码。
数据库设计
code first,先设计 model,然后再使用 Sequelize 创建出表。
思路先粗后细,先找大的主题,再从中找到小的主题。设计出实体表,再抽象出业务表。
实体表和业务表:
- 实体表记录主体本身相关的信息
- 业务表是抽象的,将实体表抽象,为解决业务问题而设计
表之间一定要有关联,可以用外键关联,也可以使用其它方式关联,比如抽象出来的业务表和实体表的对应关系。
业务开发
取最新期刊
先将排序,再取数据。且在 dataValues 中追加数据,框架会自动序列化数据。
// ...5,4,3,2,1,降序,取第一条
const flow = await Flow.findOne({
order: [['index', 'DESC']],
});
// 在dataValues中追加数据
flow.setDataValue('label', 1);
ctx.body = flow; // KOA框架会自动序列化数据,返回dataValues
2
3
4
5
6
7
8
数据库事务
保证数据的一致性。
关系型数据库 ACID:
- A: 原子性
- C:一致性
- I:隔离性
- D:持久性
使用事务机制,在Favor
表中创建一条记录,并在Art
表中同步更新fav_nums
字段:
// 一定要return
return sequelize.transaction(async (t) => {
await Favor.create(
{
art_id,
type,
uid,
},
{
transaction: t,
}
);
const art = await Art.getData(art_id, type, false);
await art.increment('fav_nums', {
by: 1, // 累加 1
transaction: t,
});
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
事务机制的做法是,将里面所有对表的操作视为一个整体执行,需要全部成功才行,否则会失败。
软删除
force: false
软删除
Favor
和favor
的区别:Favor
是一个类,还没有被实例化。favor
是从表里查询出来的一个记录。
查询出这条记录后,直接使用favor.destroy
,将自身删除掉。
async function disLike(art_id, type, uid) {
const favor = await Favor.findOne({
where: {
art_id,
type,
uid,
},
});
if (!favor) {
throw new global.errs.DislikeError();
}
// Favor 表 favor 记录
return sequelize.transaction(async (t) => {
// 使用已经查询出来的`favor`,直接删除掉自身
await favor.destroy({
force: true,
transaction: t,
});
const art = await Art.getData(art_id, type, false);
await art.decrement('fav_nums', {
by: 1,
transaction: t,
});
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
sequelize[Op.*]语句
Op.not:type 不等于 400
const arts = await Favor.findAll({
where: {
type: {
[Op.not]: 400,
},
},
});
2
3
4
5
6
7
避免循环查询数据库
如果要查询特定的一组数据,不建议使用 for 循环单条查询,因为循环查询次数不可控,这样的行为很危险。可以使用Op.in
查询来解决,传入一组条件,查询出一组数据。
const ids = [1, 2, 3, 4]; // 只查询这几条数据
const finder = {
where: {
id: {
[Op.in]: ids,
},
},
};
const movies = await Movie.findAll(finder);
2
3
4
5
6
7
8
9
小心模块循环导入
模块a
导入模块b
,模块b
导入模块a
,这样做会有一个模块为空而报错。
解决方案是在代码的函数中导入:
async function getDetail(uid) {
const { Favor } = require('./favor'); // 在局部导入,而不是在文件头部导入
const like = await Favor.userLikeIt(this.art_id, this.type, uid);
return like;
}
2
3
4
5
util.format 格式化字符串
将指定字符替换成传入的参数,会将%s
替换成传入的参数,生成新的字符串
%s
: 字符串%d
: 数值
const url =
'http://t.yushu.im/v2/book/search?q=%s&count=%s&start=%s&summary=%s';
util.format(url, '韩寒', 10, 20, '概要');
// 返回:http://t.yushu.im/v2/book/search?q=韩寒&count=10&start=20&summary=概要
2
3
4
将返回的 JSON 数据序列化
方法一:直接重写模型的toJSON
方法,只返回自己需要的数据
class Comment extends Model {
toJSON() {
// 或者直接使用 this.defaultValue
return {
content: this.getDataValue('content'),
nums: this.getDataValue('nums'),
};
}
}
Comment.init(
{
content: Sequelize.STRING(12),
nums: Sequelize.INTEGER,
book_id: Sequelize.INTEGER,
},
{
sequelize,
tableName: 'comment',
}
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
方法二:直接将toJSON
方法,添加到Model
中,对所有模型生效,过滤掉不需要返回给前端的字段
const { unset, clone, isArray } = require('lodash');
// 当数据返回给前端,模型序列化的时候才会执行
Model.prototype.toJSON = function () {
// let data = this.dataValues
let data = clone(this.dataValues);
unset(data, 'updated_at');
unset(data, 'created_at');
unset(data, 'deleted_at');
// this.exclude 指定需要过滤的字段
if (isArray(this.exclude)) {
this.exclude.forEach((value) => {
unset(data, value);
});
}
return data;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
this.exclude 的用法
使用一:在接口返回的时候过滤掉指定字段,这样比较灵活
router.get('/:index/next', new Auth().m, async (ctx) => {
const art = await Art.getData(flow.art_id, flow.type);
art.setDataValue('index', flow.index);
art.exclude = ['index', 'like_status'];
ctx.body = art;
});
2
3
4
5
6
使用二:在指定的模型中排除指定字段,会作用于整个模型,一次解决,但是不太灵活,将模型写死了
Comment.prototype.exclude = ['book_id', 'id'];
处理静态资源的访问
静态资源非常消耗流量,不应该放到项目代码中,应该放到专门的资源服务器中,最好是放到阿里云的 OSS 云服务。
方案:
- 网站目录
- 搭建自己的静态服务器
- 云服务,阿里云 OSS,有 CDN
- github gitpage
将 js、css、img 全部放到 CDN 中,加载速度是最快的。
服务端部署
部署是非常依赖环境的事情,在没有Docker
之前,总是会遇到很多问题。遇到问题之后需要不断的尝试。
部署流程:
- 购买云服务器,或者是一台虚拟机,获得外网 IP。购买域名,备案,解析到 IP。
- 在服务器上安装相关软件,比如:nginx,mysql,node。启动 node 之后,可以通过 ip 加端口访问,但是通常不会直接使用端口号去访问某个程序,而是使用
nginx
做代理。 - 配置
nginx
反向代理,使用默认的80
端口,所有的访问先进入nginx
再做转发。
使用免费的 https 证书:encrypt
,nginx
还需要监听443
端口。
常规进程与守护进程
用命令窗口直接启动,关闭窗口就会停止的进程叫做常规进程,窗口会阻塞。
在后台启动,比如用pm2
,就是守护进程,窗口不会阻塞。
启动node
最好是用pm2
,开启守护进程,而且还有日志监控功能。
CMS 架构思想
CMS 是系统中必备的一大部分,需要用来录入数据,操作数据,查看数据。
直接去操作数据库去改数据是非常危险的,当涉及多个表时,非常容易发生错乱。
中小型两套 API 系统:
- To-C:针对用户
- To-B:针对 CMS
小型公司简单化可以只用一套 API 系统,以CMS API
为主。复杂的场景做两套 API 系统。数据库统一使用一个。
大型平台三套 API 系统: