JS全栈后端Server部分笔记 (2) - 接口鉴权、中间件使用、http-assert
前言
NodeJS编写的后台管理系统笔记,github地址:(待补充,)
通用CURD抽离
上文完成基本的框架搭建和分类的CRUD操作,由于后续对物品、图片资源都具有相同操作,因此可以抽象出CRUD,简化程序。
分类CURD部分实现:
const Category = require("../../models/Category.js")
//新增分类
router.post("/categories", async (req, res) => {
const model = await Category.create(req.body)//将提交内容保存到mongoDB中
res.send(model);
})
......
//其他接口处理
观察以上代码,思考发现以下两点,
-
我们前端在接口请求分类资源 (复数形式的分类单词 categories ),需要导入 分类模型(单数首字母大写的分类单词Category),接口资源与导入数据库schema对应。
-
对于分类资源数据库CURD操作方法与对其他资源操作相同
于是我们可以将资源单独作为一个请求的入参,实时导入资源模型model,再对Schema进行CURD操作返回结果,比如请求对categories 的操作,那么动态导入Category 的模型;请求objectes操作,则动态导入 Object的模型。实现如下
......
const router = express.Router({
mergeParams: true,
});//子路由
......
router.get("/", async (req, res) => {
const modelName = require("inflection").classify(req.params.resource);//categories->Category
const Model = require(`../..//models/${modelName}`)//动态引入
const items = await Model.find().populate("parent").limit(10);//查询,限制10条
res.send(items);
})
......
app.use("/admin/api/rest/:resource", router)//将子路由挂在到实例上
......
- 以分类列表接口为例,来修改。
- 主路由修改为:app.use("/admin/api/rest/:resource", router),表示将子路由挂在到实例上,其中resource表示 请求的资源名称。mergeParams: true,表示将express实例的url参数传递到子路由router中,在router中能够拿到resourse,也就是在router中拿到资源名称的值。
- 新增的rest 表示资源操作符合RestFul API风格
- 利用inflection库将资源名称转为模型名称(复数名词转化为首字母大写单数的类名 categories->Category),那么只要接口请求的resourse不同,程序导入的数据库schema也就不同,CURD操作对象也不同,实现CURD的抽离。
由于接口多,不能将上述动态导入写在每个接口内,动态导入模型model可以利用中间件实现。
app.use("/admin/api/rest/:resource", async (req, res, next) => {
const modelName = require("inflection").classify(req.params.resource);
req.Model = require(`../..//models/${modelName}`)//动态引入资源,也就是resource对应的modeal;绑定到req上,便于中间件传递
next();
}, router)//将子路由挂在到实例上
- 在子路由与父路由绑定过程中增加中间件函数,实现根据资源动态导入模型
- 将导入模型绑定到req中(req.Modal),因为中间件函数只有req,res可以传递。
最后注意修改子路由接口的路径
//资源列表
router.get("/", async (req, res) => {
......
//资源详情
router.get("/:id", async (req, res) => {
......
//编辑资源
router.put("/:id", async (req, res) => {
......
//删除资源
router.delete("/:id", async (req, res) => {
......
最后通用 的CURD也要具有扩展性,比如在分类列表查询时对于父级分类的关联查询,这就属于特殊操作,因此也要考虑到这点。
//资源列表
router.get("/", async (req, res) => {
let queryOptions = {}
if (req.Model.modelName == 'Category') {//modelName是mongoose中model的内置属性,等于model的名称
queryOptions.populate = 'parent'; //指定关联查询属性
}
const items = await req.Model.find().setOptions(queryOptions).limit(10);//查询,限制10条
res.send(items);
})
- 如果是对资源的列表进行请求的话,那么可以进行管理查询。
物品管理CURD接口
在完成通用CURD封装后,为物品提供接口只需要在指定目录下定义物品的schema和model即可,因为前端请求 items 资源时,后端后自动载入 Item 的模型进行操作。
//models/Item.js
const mongoose = require("mongoose");
const schema = new mongoose.Schema({
name: { type: String },
icon: { type: String },
})
module.exports = mongoose.model("Item", schema);
- 图表icon的类型也定义 为字符串,因为一般在图片都是存储在OSS中,数据库只保留其url。
文件上传
在新建物品(装备时)有图片(ICON)字段,之前接口只做了图片的url存储,并没有做图片的处理。一般在软件开发中图片等文件的上传时存储在专门的服务器中(如阿里云的OSS,七牛云的空间),主要原因是为了以后的扩展性和使用时的载入速度。如果将文件存储到服务器中,之后对服务器进行横向扩展到多台时会造成无法共享文件的局面,因此将文件保存在独立的空间中。
这里我们暂时放在自己的服务器中,学习文件在express框架的接收。
express处理文件需要依赖第三方库multer,这个库是专门处理文件上传的,执行。
npm install multer --save
修改路由文件
//routes/admin/indexjs
const multer = require("multer");
const upload = multer({ dest: __dirname + "../../uploads" })
app.use("/admin/api/upload", upload.single("file"), async (req, res) => {
const file = req.file;
file.imgTrueUrl = `http://localhost:9999/uploads/${file.filename}`
res.send(file);
})
-
上传处理 不是对资源的操作,单独起一个路由
-
upload是multer的实例化,其中参数 dest指定目标存储路径, __dirname是nodejs内置全局变量,代表本文件所在位置,这里是设置文件上传后的存储位置。
-
在路由处理,插入中间件 upload.single(“file”),single指单个上传文件的处理 file指el上传组件二进制文件的字段名
-
在最后的处理函数中,req本无file,与req.Model相同,file是multer中间件加上的。imgTrueUrl是实际图片地址,部署时修改为域名+路径+文件名,用与前端显示使用和存储到数据库。
图片上传不仅要提供上传接口,而且要提供下载显示接口,我们将图片保存在了后端项目的根目录下的uploads文件夹内,所以可以直接指定一个url路径知道该文件夹做静态文件,如下:
//index.js
app.use("/uploads", express.static(__dirname + "/uploads"));
- static是express内置方法,用于指定静态目录
multer保存到电脑指定文件夹下的图片名字已经被替换为哈希值,且无扩展名,应该是为了安全
英雄管理CURD接口
英雄管理与物品管理大同小异,只是将icon变成了avatar。
享受通用CURD带来的便利,只需要增加英雄的模型即可。
模型中数据取自页面,从页面或者从实际生活中提取模型才是关键!部分页面如下,用手机访问 pvp.qq.com查看完整页面
const mongoose = require("mongoose");
const schema = new mongoose.Schema({
name: { type: String },
avatar: { type: String },
title: { type: String },//称号
categories: [{//一个英雄可能是多种类型
type: mongoose.SchemaTypes.ObjectId, ref: "Category"
}],//这里表示数组里存储多个id,id对应Category模型; 英雄分类用之前的分类模型来存储;
scores: {//分数
difficult: { type: Number },//困难
skills: { type: Number },//技能
attack: { type: Number },//攻击
survive: { type: Number },//生存
},
skills: [{//技能,一个英雄包含多个技能,所以是一个数组,每个技能有名称、描述等,是一个对象
name: { type: String },
icon: { type: String },
description: { type: String },
tips: { type: String },
}],
items1: [{//顺风出装备,是一个数组
type: mongoose.SchemaTypes.ObjectId, ref: "Items"
}],
items2: [{//逆风出装备,是一个数组
type: mongoose.SchemaTypes.ObjectId, ref: "Items"
}],
//铭文与装备类似,新建铭文类型,然后这里是一个指向铭文id的数组
usageTips: { type: String },//使用技巧
battleTips: { type: String }, //对抗技巧
teamTips: { type: String },//团战技巧
partners: [{//英雄关系,注意这里,关系是数组,元素是英雄和相关关系描述,所以英雄可直接用英雄模型
hero: { type: mongoose.SchemaTypes.ObjectId, ref: "Hero" },
descption: { type: String }
}]
})
module.exports = mongoose.model("Hero", schema);
- 在mongoose表示1对多关系时,可以使用数组来实现,比如上述的分类,schema是一个数组包对象的结构,实际中代表本模型中够一个分类对应多个 分类模型。再如技能也是一对多,不过多的一方模型定义在本schema中
- partners 英雄关系中该属性包含一个关联的英雄和一个描述,关联的英雄是独立的模型。
文章管理CRUD接口
与英雄管理大致相同,增加对应Article的model即可。
用户管理CRUD接口
与上述大致相同,不同点在于用户的密码需要加密后存储,这是能看到数据库人员的道德,避免密码泄露,密码加密用到了b’c’r’yptjs,使用npm 安装
npm install bcryptjs --save
在用户名的schema定义中使用set方法来加密密码字段。
const schema = new mongoose.Schema({
username: { type: String },
password: {
type: String,
select:false,//默认不查出
set: (val) => {/
return require("bcryptjs").hashSync("val", 10) //加密:散列密码,
}
},
})
- set是mongose自带的方法,用于处理原来的值。
散列后的值如下:
- 这里制作演示,实际系统不会返回密码,select:false设定查询时该字段(密码)不被查出。
- 即使输入的密码相同,经过散列后的值也不会相同,采用hashSync 加密的字段比md5加密以及加盐(随机字符串)都要安全。
登录接口
本系统鉴权通过token实现,具体原理是前端通过用户名和密码来换取登录的token,之后每次请求都携带token。
登录接口首先校验用户名和密码,当用户名和密码不正确时返回给前端状态码和提示信息
//routes/admin/index.js
app.use("/admin/api/login", async (req, res) => {
const { username, password } = req.body;//从请求体中得到具体请求参数
// 1. 查找用户名
const AdminUser = require("../../models/AdminUser.js");
const user = await AdminUser.findOne({ username }).select('+password')
if (!user) {
//如果用户名不存在则返回422状态码和提示信息
res.status(422).send({
message: "用户不存在"
})
}
// 2. 验证密码
const isValid = require('bcryptjs').compareSync(password, user.password)
if (!isValid) {
res.status(422).send({
message: "密码错误"
})
}
密码和用户名验证通过后,返回token,token通过jsonwebtoken库来生成,安装命令如下:
npm install jsonwebtoken --save
安装完毕后完善登录接口
//routes/admin/index.js
......
// 3. 返回token
const jwt = require("jsonwebtoken").sign({ _id: user._id }, app.get("secret"))
res.send({jwt,jwt});
- 利用user_id作为载荷通过定义在express的secret来生成加密字符串token
- secret 是在 server.js文件中为express实例app增加的变量,实际加密秘钥要写在环境变量当中。
服务端登录校验
在接口插入中间节处理函数,以资源列表为例
//routes/admin/index.js
router.get("/", async (req, res, next) => {
//中间件函数
const token = (req.headers.authorization || " ").split(" ").pop();
const { id } = jwt.verify(token, app.get("secret"))//通过secret解码token
//根据id找用户,验证是否为伪造的token
const user = await AdminUser.findById(id);
console.log("user", user);
if (user) {
req.user = user;
}
console.log("token", token);
next();
}, async (req, res) => {
.......
})
- 前端请求携带在header中的Authorization通过req取出,然后利用数组分解得到token,再利用jwt的verify函数和加密时用的secret来解密前端携带的token,解密得到id查询是否具有这一用户来验证token的有效性。
- 可以看出,在实际工程中,上述提取token、解密、查库只要有一个不满足条件就需要中断响应。每次判断操作结果,如果结果不存在则返回对应状态码在写起来繁琐,利用断言可以简化。
http-assert
断言处理库安装
npm install http-assert --save
以登录接口判断用户名是否存在来演示 http-assert 的使用
//routes/admin/index.js
const assert = require("http-assert");
......
const user = await AdminUser.findOne({ username }).select('+password')
assert(user, 422, "用户不存在");
- assert(user, 422, “用户不存在”);//三个参数分别是 判断条件,不满足条件的状态码,不满足条件的信息。
通过前端接口请求发现http-assert的作用是当不满足设定条件会抛出一个错误,如下
可以使用express的错误处理中间件来统一处理这样的错误,简化所有接口的判断书写,
//routes/admin/index.js
//统一的异常错误处理,用于捕获http-assert抛出的异常
app.use((error, req, res, next) => {
//发送使用assert语句传入的状态码和错误提示信息
res.status(error.statusCode).send({
message: error.message,
})
})
上述代码相互配合相当于
if (!user) {
res.status(422).send({//如果用户名不存在则返回422状态码和提示信息
message: "用户不存在"
})
}
判断条件太多使用http-assert配合express的错误处理可简化代码。以后只要遇到判断一个东西是否存在,如果不存在就返回一个状态码和相关的提示信息就可以使用http-assert来简化
同理,使用 http-assert 处理 token验证 如下:
const token = (req.headers.authorization || " ").split(" ").pop();//提取请求头中的token pop是将数组最后一个元素取出来
assert(token, 401, "请提供 jwt token,请先登录");
const { id } = jwt.verify(token, app.get("secret"))//通过secret解码token
assert(id, 401, "请先登录");
req.user = await AdminUser.findById(id); //根据id找用户,验证是否为伪造的token
assert(req.user, 401, "用户不存在,请先登录")
前后端统一约定 状态码对应的含义,前端根据状态码可以记性处理,比如前端检测到返回的状态码是401,则跳转到登录页面。
抽离为中间件
因为在文件上传和资源操作接口都用到了token的检查,所以可以单独抽离为一个中间件文件,然后导入使用即可
//middleware/Auth.js
module.exports = (options) => { //options是为了以后扩展使用,一般中间件都写成带参返回的函数
const jwt = require("jsonwebtoken");
const AdminUser = require("../models/AdminUser.js");
const assert = require("http-assert");
return async (req, res, next) => {
const token = (req.headers.authorization || " ").split(" ").pop();//提取请求头中的token pop是将数组最后一个元素取出来
assert(token, 401, "请提供 jwt token,请先登录");
const { id } = jwt.verify(token, req.app.get("secret"))//通过secret解码token,注意这里的req.app 等于 express实例app
assert(id, 401, "请先登录");
req.user = await AdminUser.findById(id); //根据id找用户,验证是否为伪造的token
assert(req.user, 401, "用户不存在,请先登录")
await next();
};
}
- 作为一个函数导出的好处是可自定义配置,入参options预留配置的入口。
使用时直接引用即可
//routes/admin/index.js
app.use("/admin/api/rest/:resource", authMiddleware(), resourceMiddleware(), router)
- 将自定义的router挂载到express的app实例上,同时配置两个中间件。