案例-Koa2+MySQL 开发旧岛项目

  • L160 - Node.js+Koa2+MySQL 打造前后端分离精品项目《旧岛》

目录大纲open in new window

开发过程:搭建基础框架,设计数据库,编写接口,测试接口,和小程序联调。

技术点

  • 直接在 Debug 模式中开发,方便调试
  • 构建完整的框架,大大提高业务效率
  • require-directory工具:自动加载全部路由,提高注册效率
  • 校验 API 参数工具
  • 全局异常处理,统一错误信息
  • 使用面向对象的方式编写异常返回
  • Sequelize 使用类的方式来写,在 models 中操作数据库,但不要定义 constructor,否则会出错,操作数据库时会有很多奇怪的问题
  • bcrypt 密码加密
  • jsonwebtoken 用户登录生成 token
  • 框架会自动序列化数据 dataValues 并返回
  • 数据库事务
  • 数据缓存,前端缓存,服务器缓存 redis
  • 避免循环查询数据库
  • 小心 models 中的模块循环导入
  • 将返回的 JSON 数据序列化,只返回需要的字段传给前端
  • token 在小程序中过期后,无感知刷新 token
  • 双令牌保证无感知登录
  • 处理静态资源的访问
  • 服务端部署
  • CMS 架构思想

工具包

  • require-directory: 自动加载
  • lodash: 基础工具包
  • bcrypt: 加密
  • validator.js: 验证参数
  • jsonwebtoken: 生成 token
  • basic-auth: 解析 token
  • util.format: node.js 自带的工具方法
  • axios: 发送请求的工具

面向对象的服务端思维

  • 多个版本的接口兼容
  • 开闭原则,修改关闭,扩展开发。最好是扩展新的功能,而不是修改原有的功能。

业务分层:

  • 业务逻辑写在 Model 层,MVC 中的 M

    • 在 Model 中还可以再分出 Service 层
  • 在 Sequelize 模型中操作数据库

知识点串讲

基础框架要做好,要有一个得心应手的好框架。

通过邮箱密码显示注册,是为了获得唯一标识,然后再去登录。 在小程序中,微信已经帮我们做了显示注册这一步,生成了唯一标识符 openid,我们可以直接使用。

不建议使用 openid 用做用户的 uid,一个原因是比较长,二个原因是会泄露用户信息。

前端做缓存可以有效提高服务器性能。

数值参数传输,使用body传递服务器可以拿到Number类型,使用path传递拿到的是String类型。

代码中如果只有两处的逻辑相同,没必要提取成函数封装,就直接写两处,应该结合整体的逻辑考虑,如果有三处及以上相同的逻辑可以考虑封装成一个函数。

中间层

  • 双层结构:前端 + 服务端
  • 三层结构:前端 + 后端 + 服务端
  • 前端自己编写 API

image

获取 api 参数

共有四种形式:

  • path: ctx.params
  • query = ctx.request.query
  • body = ctx.request.body
  • headers = ctx.request.header

获取 body 参数需要使用koa-bodyparser中间件。

校验 api 参数

在验证器里面,调用数据库查询邮箱是否重复,提前过滤,throw 错误。

中间件只在应用程序启动时初始化一次

如果用中间件的形式编写,全局只会执行一次,只有一个实例化new,多个请求下属性会冲突会有问题。最好的方式是每个请求都需要一个实例化new

异常处理

函数异常处理:

在函数内判断异常,需要抛出异常throw new Error(),符合编程规范,也为了更好的解决问题。

在调用第三方库的时候,一定要加入try-catch

function fnc() {
  try {
  } catch (error) {
    throw error;
  }
}
1
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;
  }
};
1
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('')
1
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
1
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;
};
1
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);
}
1
2
3
4
5
6
7
8
9

权限问题

这是最难也是最复杂的问题。

给不同的角色用不同的数字来表示。设计成,权限越低使用的数字越小,权限越高使用的数字越大,比如:

  • 普通用户:8
  • 管理员:16
  • 超级管理员:32

这样设计的好处是只要做一个数字大小的判断,就能确定对方是否有权限访问当前接口。 比如:当前接口需要的最低权限是10,普通用户就不能访问,管理员以上都能访问。

微信小程序登录

流程:

  1. 小程序端用户点击按钮授权,拿到 code,传给服务端。
  2. 服务器接受到 code 之后,去微信服务器获取 openid,写入到用户表中,用表中的 uid 生成 token,返回给小程序端。
  3. 小程序端拿到 token 之后,保存到本地,给以后的每个接口请求都携带上 token。
  4. 服务端接受到请求之后,验证 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);
}
1
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);
          }
        },
      });
    }
  },
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

无感知刷新 token

image

小程序每次启动会生成一个 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);
      });
    }
  },
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

双令牌保证无感知登录

适用于需要用户名和密码登录去获取令牌。

小程序不需要输入账号密码,处理起来可以比较容易,但是网站和 APP 需要,有两种方案:

  1. 缓存用户的账号和密码在本地,下次获取令牌的时候,携带上去。这种做法不太好。
  2. 双令牌方式是最主流的方式。有access_tokenrefresh_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
1
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,
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

事务机制的做法是,将里面所有对表的操作视为一个整体执行,需要全部成功才行,否则会失败。

软删除

  • force: false软删除

Favorfavor的区别: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,
    });
  });
}
1
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,
    },
  },
});
1
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);
1
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;
}
1
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=概要
1
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',
  }
);
1
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;
};
1
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;
});
1
2
3
4
5
6

使用二:在指定的模型中排除指定字段,会作用于整个模型,一次解决,但是不太灵活,将模型写死了

Comment.prototype.exclude = ['book_id', 'id'];
1

处理静态资源的访问

静态资源非常消耗流量,不应该放到项目代码中,应该放到专门的资源服务器中,最好是放到阿里云的 OSS 云服务。

方案:

  1. 网站目录
  2. 搭建自己的静态服务器
  3. 云服务,阿里云 OSS,有 CDN
  4. github gitpage

将 js、css、img 全部放到 CDN 中,加载速度是最快的。

服务端部署

部署是非常依赖环境的事情,在没有Docker之前,总是会遇到很多问题。遇到问题之后需要不断的尝试。

部署流程:

  1. 购买云服务器,或者是一台虚拟机,获得外网 IP。购买域名,备案,解析到 IP。
  2. 在服务器上安装相关软件,比如:nginx,mysql,node。启动 node 之后,可以通过 ip 加端口访问,但是通常不会直接使用端口号去访问某个程序,而是使用nginx做代理。
  3. 配置nginx反向代理,使用默认的80端口,所有的访问先进入nginx再做转发。

使用免费的 https 证书:encryptnginx还需要监听443端口。

常规进程与守护进程

用命令窗口直接启动,关闭窗口就会停止的进程叫做常规进程,窗口会阻塞。

在后台启动,比如用pm2,就是守护进程,窗口不会阻塞。

启动node最好是用pm2,开启守护进程,而且还有日志监控功能。

CMS 架构思想

CMS 是系统中必备的一大部分,需要用来录入数据,操作数据,查看数据。

直接去操作数据库去改数据是非常危险的,当涉及多个表时,非常容易发生错乱。

中小型两套 API 系统:

  1. To-C:针对用户
  2. To-B:针对 CMS

小型公司简单化可以只用一套 API 系统,以CMS API为主。复杂的场景做两套 API 系统。数据库统一使用一个。

大型平台三套 API 系统:image

Last Updated: 2023/8/2 10:45:34
Contributors: licong96